메서드 체이닝과 문자열
this 반환이 만드는 체인의 실행 순서를 추적하고 게시글 문장 빌더·파일명 분석·검색·역순 변환을 안전한 문자열 도구로 완성합니다.
메서드가 다음 호출 대상 객체를 반환하면 점을 이어 여러 연산을 한 표현식으로 작성할 수 있습니다.
StringBuilder의 append가 대표적입니다.
체이닝은 호출 순서를 읽기 쉽게 만들 수 있지만 반환 계약이 끊기거나 중간 상태가 숨겨지면 디버깅을 어렵게 합니다.
체이닝 중간의 null 실패
다음 빌더의 title은 this를 반환하지만 viewCount는 실수로 null을 반환합니다.
문법은 컴파일되며 실제 실행에서 그 다음 build 호출이 실패합니다.
public final class BrokenFluentChainFailure {
public static void main(String[] args) {
String line = new PostLineBuilder()
.title("string")
.viewCount(45)
.build();
System.out.println(line);
}
private static final class PostLineBuilder {
private String title;
private int viewCount;
PostLineBuilder title(String value) {
title = value;
return this;
}
PostLineBuilder viewCount(int value) {
viewCount = value;
return null;
}
String build() {
return title + ":" + viewCount;
}
}
}Exception in thread "main" java.lang.NullPointerException체인 가능한 메서드는 정상 경로에서 항상 문서화된 대상 객체를 반환해야 합니다.
현재 객체를 계속 구성한다면 this, 불변 변환이라면 새 값, 처리 단계가 바뀐다면 다음 단계 타입을 반환합니다.
null은 체인을 끊는 정상 신호로 사용하지 않습니다.
this 반환과 객체 동일성
각 호출이 필드를 바꾸고 this를 돌려주면 모든 표현식이 하나의 빌더 인스턴스를 사용합니다.
별도 변수로 풀어 쓰면 실행 원리를 확인할 수 있습니다.
public final class ValueAdderChaining {
public static void main(String[] args) {
ValueAdder adder = new ValueAdder();
ValueAdder afterOne = adder.add(1);
ValueAdder afterTwo = afterOne.add(2);
ValueAdder afterThree = afterTwo.add(3);
System.out.println("same=" + (adder == afterThree));
System.out.println("expanded=" + afterThree.value());
int chained = new ValueAdder().add(1).add(2).add(3).value();
System.out.println("chained=" + chained);
}
private static final class ValueAdder {
private int value;
ValueAdder add(int amount) {
value += amount;
return this;
}
int value() { return value; }
}
}same=true
expanded=6
chained=6afterOne, afterTwo, afterThree은 각 시점의 스냅숏이 아니라 같은 객체의 별칭입니다.
나중에 하나를 수정하면 모든 변수를 통해 새 값이 보입니다.
체인 빌더는 한 식이나 한 메서드 안에서만 사용하고 중간 참조를 장기간 보관하지 않는 편이 안전합니다.
게시글 문장 조립
build에서 필수 값을 검사하면 완성되지 않은 결과가 외부로 나가는 것을 막을 수 있습니다.
체인 메서드는 입력 즉시 단순 범위를 검증하고, 필드 간 관계는 최종 생성 시 확인합니다.
public final class PostLineFluentBuilder {
public static void main(String[] args) {
String line = new PostLineBuilder()
.title("method chaining")
.viewCount(50)
.published(true)
.build();
System.out.println(line);
}
private static final class PostLineBuilder {
private String title;
private int viewCount;
private boolean published;
PostLineBuilder title(String value) {
if (value == null || value.isBlank()) throw new IllegalArgumentException("title");
title = value.strip();
return this;
}
PostLineBuilder viewCount(int value) {
if (value <= 0) throw new IllegalArgumentException("viewCount");
viewCount = value;
return this;
}
PostLineBuilder published(boolean value) {
published = value;
return this;
}
String build() {
if (title == null) throw new IllegalStateException("title missing");
if (viewCount == 0) throw new IllegalStateException("viewCount missing");
return new StringBuilder()
.append(title)
.append(':')
.append(viewCount)
.append(':')
.append(published ? "published" : "draft")
.toString();
}
}
}method chaining:50:published선택 매개변수가 많고 이름으로 의미를 보여 줘야 할 때 빌더가 유용합니다.
필수 값이 두세 개뿐이면 생성자가 더 짧고 불완전한 중간 객체를 만들지 않습니다.
체이닝을 쓰고 싶다는 이유만으로 단순 값 객체 앞에 빌더를 추가하지 않습니다.
불변 객체의 연쇄 변환
String의 strip().toLowerCase().replace(...)는 각 단계가 새 String 값을 돌려주므로 가능한 체인입니다.
가변 this 체인과 달리 중간 값이 서로 다른 인스턴스일 수 있습니다.
호출자는 반환 타입과 불변 계약을 보고 의미를 판단합니다.
import java.util.Locale;
public final class ImmutableTitlePipeline {
public static void main(String[] args) {
String raw = " Java Object Model ";
String slug = raw
.strip()
.toLowerCase(Locale.ROOT)
.replace(' ', '-');
System.out.println("raw='" + raw + "'");
System.out.println("slug=" + slug);
}
}raw=' Java Object Model '
slug=java-object-model중간 결과를 조사해야 한다면 체인을 여러 줄의 이름 있는 변수로 풀어냅니다.
예외가 날 수 있는 파싱이나 서로 다른 업무 단계를 한 체인에 과도하게 묶으면 실패 위치와 정책이 숨겨집니다.
null을 반환하는 실패 체인, 같은 this를 반환하는 가변 체인, 원본을 보존하는 String 체인은 실행 결과가 다르다.
점으로 이어진 문법이 같아도 반환값의 의미는 다릅니다. 아래는 서로 독립된 원본 프로그램의 계약과 관찰 결과입니다.
| 반환 계약 | 중간 호출의 의미 | 원본 실행 결과 |
|---|---|---|
실패 · null | BrokenFluentChainFailure의 viewCount(45)는 필드에 45를 쓴 뒤 null 반환 | 다음 build() 호출에서 NullPointerException. build 본문과 line 출력은 실행되지 않음 |
가변 · 같은 this | ValueAdderChaining의 add는 하나의 객체를 변경. afterOne·afterTwo·afterThree은 스냅숏이 아닌 별칭 | same=true, expanded=6, chained=6 |
| 불변 · String 결과 | ImmutableTitlePipeline의 strip, toLowerCase(Locale.ROOT), replace가 반환한 값을 다음 호출이 사용 | raw는 양끝 공백을 포함한 원본 그대로. slug=java-object-model |
- 실패 ·
null - 중간 호출의 의미:
BrokenFluentChainFailure의viewCount(45)는 필드에 45를 쓴 뒤 null 반환원본 실행 결과: 다음build()호출에서NullPointerException. build 본문과 line 출력은 실행되지 않음 - 가변 · 같은
this - 중간 호출의 의미:
ValueAdderChaining의 add는 하나의 객체를 변경. afterOne·afterTwo·afterThree은 스냅숏이 아닌 별칭원본 실행 결과:same=true,expanded=6,chained=6 - 불변 · String 결과
- 중간 호출의 의미:
ImmutableTitlePipeline의strip,toLowerCase(Locale.ROOT),replace가 반환한 값을 다음 호출이 사용원본 실행 결과: raw는 양끝 공백을 포함한 원본 그대로.slug=java-object-model
첫 계산은 adder와 그 별칭들이 같은 객체를 사용합니다. chained는 별도의 new ValueAdder()로 계산합니다. 두 계산의 결과가 모두 6인 것이지 같은 객체를 공유하는 것은 아닙니다.
불변 String 연산이 모든 입력에서 반드시 새 인스턴스를 할당한다는 뜻은 아닙니다. 핵심은 원본의 문자 값이 바뀌지 않는다는 계약입니다.
정상 예제 PostLineFluentBuilder의 설정 메서드는 this를 반환하고, build()는 Post 객체가 아닌 String을 만듭니다. builder 자체를 동결하지는 않습니다.
문자열 문제와 메서드 선택
문자열 연습은 반복문부터 작성하기보다 입력과 원하는 결과의 관계를 분류합니다.
- 특정 문자의 길이와 위치: length, indexOf, lastIndexOf
- 파일 이름과 확장자 분리: 마지막 구분자 위치 + substring
- 검색어 등장 횟수: indexOf를 시작 위치와 함께 반복
- 양끝 공백 정리: strip
- 특정 단어 교체: replace
- 단어 순서 결합: split 후 join
- 역순 문자: StringBuilder.reverse
검색어 개수를 셀 때 다음 탐색 시작 위치를 한 글자 뒤로 옮기면 겹치는 검색도 셉니다.
검색어 길이만큼 옮기면 겹치지 않는 등장만 셉니다.
요구사항이 어느 쪽인지 예제로 확정해야 합니다.
public final class StringProblemToolkit {
public static void main(String[] args) {
System.out.println("count=" + count("hello java, hello string", "hello"));
System.out.println("base=" + baseName("board.notes.txt"));
System.out.println("ext=" + extension("board.notes.txt"));
System.out.println("reverse=" + new StringBuilder("object").reverse());
}
private static int count(String source, String keyword) {
if (keyword.isEmpty()) throw new IllegalArgumentException("keyword");
int count = 0;
int from = 0;
while (true) {
int found = source.indexOf(keyword, from);
if (found < 0) return count;
count++;
from = found + keyword.length();
}
}
private static String baseName(String fileName) {
int dot = fileName.lastIndexOf('.');
return dot <= 0 ? fileName : fileName.substring(0, dot);
}
private static String extension(String fileName) {
int dot = fileName.lastIndexOf('.');
return dot <= 0 || dot == fileName.length() - 1 ? "" : fileName.substring(dot + 1);
}
}count=2
base=board.notes
ext=txt
reverse=tcejbo빈 검색어를 허용하면 indexOf가 같은 위치를 계속 반환해 무한 루프가 될 수 있으므로 경계에서 거부합니다.
파일명 규칙도 시작 점을 확장자 구분자로 볼지 업무 요구에 맞춰 조정합니다.
StringProblemToolkit의 비중첩 검색과 두 파일명 함수가 사용하는 서로 다른 반환 규칙을 보여 준다.
StringProblemToolkit의 검사와 실제 예제 결과를 대응합니다. 범용 이메일·경로·유니코드 처리의 완전한 검증을 뜻하지 않습니다.
| 함수 | 소스의 경계·진행 규칙 | 예제 결과 |
|---|---|---|
검색 횟수 · count | 빈 검색어는 예외로 거부 발견 위치 + 검색어 길이부터 다시 탐색. 찾지 못하면 누적 개수 반환 | "hello java, hello string"에서 "hello" → 2 (겹치지 않는 검색) |
이름 · baseName | 마지막 점의 위치가 0 이하이면 원본 이름, 그 외에는 점 앞부분 | "board.notes.txt" → "board.notes" |
확장자 · extension | 마지막 점의 위치가 0 이하이거나 끝이면 빈 문자열, 그 외에는 점 뒷부분 | "board.notes.txt" → "txt" |
- 검색 횟수 ·
count - 소스의 경계·진행 규칙:
빈 검색어는 예외로 거부
발견 위치 + 검색어 길이부터 다시 탐색. 찾지 못하면 누적 개수 반환
예제 결과:"hello java, hello string"에서"hello"→2(겹치지 않는 검색) - 이름 ·
baseName - 소스의 경계·진행 규칙: 마지막 점의 위치가
0 이하이면 원본 이름, 그 외에는 점 앞부분예제 결과:"board.notes.txt"→"board.notes" - 확장자 ·
extension - 소스의 경계·진행 규칙: 마지막 점의 위치가
0 이하이거나 끝이면 빈 문자열, 그 외에는 점 뒷부분예제 결과:"board.notes.txt"→"txt"
검색을 한 문자 뒤에서 재개하는 알고리즘과 다릅니다. 위 count는 keyword.length()만큼 건너뛰는 비중첩 정책입니다.
각 함수의 null 입력 검사는 구현되어 있지 않습니다. baseName과 extension은 파일명 문자열 규칙이며, 운영체제 경로 처리는 별도입니다. 역순 예제의 object → tcejbo는 원문 코드와 출력에 남깁니다.
메서드 체이닝 선택 기준
각 단계의 반환 타입이 명확하고 왼쪽에서 오른쪽으로 자연스럽게 읽히며 실패 정책이 단순할 때 체인을 사용합니다.
중간 값을 재사용하거나 조건 분기가 필요하거나 한 줄이 여러 책임을 섞으면 이름 있는 문장으로 나눕니다.
체인의 길이보다 개념 단계 수가 판단 기준입니다.
가변 빌더는 한 소유자에게 제한하고, build 결과는 불변 값으로 반환하며, build 이후 재사용을 허용할지 정합니다.
사용 후 빌더를 계속 바꾸면 이미 만든 String은 불변이라 영향을 받지 않지만 새 build 결과는 달라집니다.
연습 문제
TitleBuilder에 prefix, title, suffix를 차례로 설정하고 [JAVA] OBJECT - PUBLISHED를 만드세요.
각 설정 메서드는 this를 반환하고 build는 title 누락을 거부합니다.
해설 보기
public final class TitleBuilderExercise {
public static void main(String[] args) {
String title = new TitleBuilder()
.prefix("java")
.title("object")
.suffix("published")
.build();
System.out.println(title);
}
private static final class TitleBuilder {
private String prefix = "";
private String title;
private String suffix = "";
TitleBuilder prefix(String value) {
prefix = "[" + value.strip().toUpperCase() + "] ";
return this;
}
TitleBuilder title(String value) {
title = value.strip().toUpperCase();
return this;
}
TitleBuilder suffix(String value) {
suffix = " - " + value.strip().toUpperCase();
return this;
}
String build() {
if (title == null || title.isBlank()) throw new IllegalStateException("title missing");
return prefix + title + suffix;
}
}
}[JAVA] OBJECT - PUBLISHED설정 메서드는 같은 builder를 이어 주고 build가 최종 String 경계를 만듭니다.
필수 title만 생성자에서 받고 선택 요소만 체인으로 두는 대안도 가능합니다.