본문 바로가기

메서드 체이닝과 문자열 활용

메서드가 무엇을 반환하느냐에 따라 점으로 이은 호출이 어떻게 실행되는지 따라가고, 체이닝 빌더를 만든 뒤 String 메서드로 검색어 세기와 파일 이름 나누기 같은 문제를 풉니다.

앞 글에서 StringBuilder의 append를 builder.append('\n').append("- ").append(entry.title())처럼 점으로 줄줄이 이어 썼습니다. 이렇게 쓸 수 있었던 것은 append가 builder 자기 자신을 돌려주기 때문입니다. 점 뒤의 메서드는 언제나 점 앞의 식이 돌려준 값을 대상으로 호출됩니다. 그래서 메서드가 다음 호출에 쓸 객체를 돌려주기만 하면 호출을 몇 개든 이어 쓸 수 있습니다. 이렇게 메서드 호출을 하나의 식으로 이어 쓰는 방식을 메서드 체이닝(method chaining)이라고 합니다.

체이닝은 작업 순서를 왼쪽에서 오른쪽으로 읽히게 해 주지만, 각 메서드가 무엇을 돌려주는지 모르면 코드가 실제로 하는 일을 잘못 읽게 됩니다. 이 글에서는 체인의 메서드가 null을 돌려줄 때, 객체 자신(this)을 돌려줄 때, 새 값을 돌려줄 때 각각 무슨 일이 일어나는지 따라갑니다. 그런 다음 체이닝으로 게시글 한 줄을 조립하는 빌더를 만들고, 9장에서 익힌 String 메서드로 검색어 세기, 파일 이름 나누기, 문자열 뒤집기 같은 문제를 풉니다.


체인 중간에서 null을 돌려주면

다음 프로그램의 PostLineBuilder는 제목과 조회 수를 받아 게시글 한 줄을 만듭니다. title은 this를 돌려주지만, viewCount는 실수로 null을 돌려주도록 작성했습니다.

lab/BrokenFluentChainFailure.java
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: Cannot invoke "BrokenFluentChainFailure$PostLineBuilder.build()" because the return value of "BrokenFluentChainFailure$PostLineBuilder.viewCount(int)" is null
	at BrokenFluentChainFailure.main(BrokenFluentChainFailure.java:6)

viewCount의 반환 타입은 PostLineBuilder이고 null은 어떤 참조 타입으로도 돌려줄 수 있으므로, 이 코드는 아무 경고 없이 컴파일됩니다. 실행 순서를 따라가 보면, new PostLineBuilder()로 만든 객체에서 title("string")이 호출되고 같은 객체가 돌아옵니다. 그 객체에서 viewCount(45)가 호출되어 필드에 45를 저장한 뒤 null을 돌려줍니다. 이제 .build()는 null에 대해 호출되므로 NullPointerException이 납니다. build 본문은 실행되지 않고 println에도 도달하지 못합니다.

이 상세한 메시지는 JDK 14에서 도입되고 JDK 15부터 기본으로 켜진 '도움이 되는 NullPointerException(helpful NullPointerExceptions)' 기능이 만든 것입니다. viewCount(int)의 반환값이 null이어서 build()를 호출할 수 없었다고 정확히 짚어 줍니다. 끝의 java:6은 예외가 난 위치인 6번째 줄, 즉 .build();가 적힌 줄을 가리킵니다. 하지만 원인은 그 앞 줄의 viewCount가 돌려준 null입니다. 체인은 한 문장이 여러 줄에 걸치므로, 줄 번호만 보지 말고 메시지에 적힌 메서드 이름까지 읽어야 원인에 닿습니다.

체인에 쓰이는 메서드라면 정상적으로 끝났을 때 항상 다음 호출에 쓸 객체를 돌려주어야 합니다. 같은 객체를 계속 설정하는 메서드는 this를, 원본을 바꾸지 않는 불변 객체의 메서드는 새 값을 돌려줍니다. 처리 단계가 바뀌는 경우에는 다음 단계를 나타내는 타입의 객체를 돌려줄 수도 있습니다. 입력이 잘못되어 더 진행할 수 없다면 null을 돌려주는 대신 예외를 던져서, 문제가 생긴 그 자리에서 이유와 함께 멈추게 합니다.


this를 돌려주면 모두 같은 객체다

this를 돌려주는 메서드를 이으면 체인 전체가 하나의 객체를 계속 사용합니다. 다음 프로그램은 이를 확인하려고, 같은 계산을 한 번은 단계마다 변수에 받아 가며 하고 한 번은 체인으로 합니다.

src/ValueAdderChaining.java
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=6

add는 value에 인수를 더한 뒤 this, 즉 호출된 객체 자신을 돌려줍니다. 그래서 afterOne, afterTwo, afterThree에는 모두 adder와 같은 참조가 들어가고, adder == afterThree가 true가 됩니다. 객체는 하나뿐이고, 그 객체의 value가 0에서 1, 3, 6으로 바뀌었습니다. 마지막의 chained는 새로 만든 다른 ValueAdder로 같은 계산을 체인으로 한 것입니다. 변수에 받지 않았을 뿐 실행 과정은 위와 똑같아서 결과도 6입니다. 두 계산이 같은 결과를 냈다는 뜻이지, 두 계산이 객체를 공유했다는 뜻은 아닙니다.

여기서 주의할 점은 afterOne이 '1을 더한 시점의 상태'를 따로 저장해 둔 값이 아니라는 것입니다. afterOne, afterTwo, afterThree는 기본형과 참조형 복사에서 본 별칭(alias), 즉 같은 객체를 가리키는 다른 이름일 뿐입니다. 위 프로그램 끝에서 afterOne.add(10)을 한 번 더 호출하면 afterThree.value()도 16이 됩니다. 그래서 this를 돌려주는 가변 객체의 체인은 한 문장이나 한 메서드 안에서 끝내고, 중간 참조를 변수에 담아 오래 들고 다니지 않는 편이 안전합니다.


체이닝으로 게시글 한 줄 조립하기

값을 하나씩 설정받아 두었다가 마지막에 결과를 만들어 내는 객체를 빌더(builder)라고 부릅니다. 설정 메서드가 this를 돌려주게 만들면 설정을 체인으로 이어 쓸 수 있습니다. 다음 PostLineBuilder는 앞의 실패 예제를 고친 정상 버전으로, 제목과 조회 수, 게시 여부를 받아 게시글 한 줄을 만듭니다.

src/PostLineFluentBuilder.java
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

검사가 두 군데로 나뉘어 있다는 점을 눈여겨보세요. 설정 메서드는 받은 값 하나만 보고 판단할 수 있는 규칙을 그 자리에서 검사합니다. title은 null이거나 공백뿐인 제목을, viewCount는 0 이하의 조회 수를 IllegalArgumentException으로 거부합니다. 반면 '제목을 설정하지 않았다'는 문제는 설정 메서드가 호출되지 않았다는 뜻이므로 설정 메서드 안에서는 알아낼 수 없습니다. 그래서 build가 마지막에 필드를 확인합니다. title이 여전히 null이거나 viewCount가 기본값 0이면 IllegalStateException을 던집니다. 예를 들어 new PostLineBuilder().viewCount(50).build()는 title missing으로 실패합니다. 덕분에 완성되지 않은 줄은 밖으로 나가지 않습니다.

build 안의 StringBuilder도 같은 원리로 체이닝됩니다. append가 builder 자신을 돌려주므로 다섯 조각을 차례로 이어 붙인 뒤, toString()으로 완성된 String을 돌려줍니다. PostLineBuilder 자체가 아니라 그 결과인 String을 밖으로 내보낸다는 점도 앞 글에서 정리한 원칙과 같습니다.

빌더는 설정할 항목이 많고 그중 상당수가 선택 사항일 때 쓸모가 있습니다. new Post("method chaining", 50, true, false, null) 같은 생성자 호출은 각 인수가 무엇인지 알기 어렵지만, 빌더는 .title(...), .viewCount(...)처럼 이름이 의미를 알려 줍니다. 반대로 필수 값이 두세 개뿐이라면 생성자가 더 짧습니다. 생성자는 처음부터 완성된 객체만 만들기 때문에 아직 설정이 끝나지 않은 중간 상태도 생기지 않습니다. 체이닝을 쓰고 싶다는 이유만으로 단순한 값 객체에 빌더를 붙일 필요는 없습니다.


불변 값의 체인은 새 값을 이어 받는다

String의 메서드도 체인으로 이을 수 있습니다. 다만 앞의 빌더와 달리 각 메서드가 자기 자신이 아니라 변환 결과인 새 문자열을 돌려주고, 다음 메서드는 그 새 문자열에 대해 호출됩니다. 다음 프로그램은 게시글 제목을 주소에 쓸 수 있는 형태로 바꿉니다.

src/ImmutableTitlePipeline.java
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

값이 어떻게 이어지는지 단계별로 보겠습니다.

단계호출돌려준 값
시작raw원본 그대로 (양끝에 공백 두 칸씩)
1.strip()"Java Object Model"
2.toLowerCase(Locale.ROOT)"java object model"
3.replace(' ', '-')"java-object-model"

각 단계의 결과는 다음 단계의 호출 대상이 되고, 마지막 결과만 slug에 저장됩니다. 출력의 첫 줄이 보여 주듯이 raw는 양끝 공백까지 처음 그대로입니다. 체인 어디에서도 원본을 고치지 않았기 때문입니다. toLowerCase에 Locale.ROOT를 준 이유는 문자열 비교와 String 메서드에서 설명했듯이, 주소처럼 프로그램이 식별자로 쓰는 값이 실행 환경의 언어 설정에 따라 달라지지 않게 하려는 것입니다.

불변 객체의 메서드가 언제나 새 객체를 만드는 것은 아닙니다. 바꿀 것이 없으면 원래 객체를 그대로 돌려줄 수도 있습니다. 예를 들어 OpenJDK 21에서 이미 양끝 공백이 없는 "Java"에 strip()을 호출하면 같은 객체가 돌아옵니다. 그러니 불변성은 '새 객체가 만들어지는가'가 아니라 '원본의 값이 바뀌지 않는가'로 판단해야 합니다. 같은 모양의 체인이라도 this를 돌려주는 가변 빌더인지, 새 값을 돌려주는 불변 객체인지는 각 메서드의 반환 타입과 문서를 보고 알아내야 합니다.

아래 단계별 그림은 이 절까지 본 세 체인을 나란히 비교합니다. null을 돌려주는 체인은 다음 호출에서 멈추고, this를 돌려주는 체인은 한 객체의 값을 6까지 바꾸고, String 체인은 원본을 둔 채 마지막 결과만 slug에 남깁니다.

체인의 다음 호출 대상은 반환 계약으로 결정된다

null 반환 체인은 필드 기록 후 다음 build에서 실패하고 가변 this 체인은 0→1→3→6을 공유하며 불변 String 체인은 slug만 저장합니다. 세 독립 실행의 중간 반환과 다음 호출 허가를 추적할 수 있습니다.

체인의 다음 호출 대상은 반환 계약으로 결정된다

null을 반환하는 실패 체인, 같은 this를 반환하는 가변 체인, 원본을 보존하는 String 체인은 실행 결과가 다르다.

체인의 다음 호출 대상은 앞선 메서드의 반환값입니다

점으로 이어진 문법이 같아도 반환값의 의미는 다릅니다. 아래는 서로 독립된 원본 프로그램의 계약과 관찰 결과입니다.

체인의 다음 호출 대상은 앞선 메서드의 반환값입니다 세독립프로그램을비교한다. 실패builder는viewCount45기록뒤null반환하여다음build호출NPE로출력없음,가변adder별칭은한객체를6까지변경,불변String체인은원본을보존하며slugjava-object-model을반환한다. String상자들은실행단계값으로새할당보장이아니다. 실패 체인 시작 설정 값 title this 반환 중간 반환 viewCount = 45 필드 기록 뒤 null 반환 관찰 다음 build 호출 실패 NPE / 출력 없음 가변 this 체인 시작 같은 adder 초기 value = 0 중간 반환 add(1)→add(2)→add(3) 하나의 상태를 변경 관찰 모든 별칭: value 6 다른 chained도 결과6 불변 String 체인 시작 raw 원본 보존 양끝 공백 포함 중간 반환 strip → ROOT 소문자 공백을 -로 replace 관찰 반환값을 slug에 저장 java-object-model 각 영역은 독립 프로그램 · String 결과의 새 인스턴스 할당을 뜻하지 않음
체인의 다음 호출 대상은 앞선 메서드의 반환값입니다 세독립프로그램을비교한다. 실패builder는viewCount45기록뒤null반환하여다음build호출NPE로출력없음,가변adder별칭은한객체를6까지변경,불변String체인은원본을보존하며slugjava-object-model을반환한다. String상자들은실행단계값으로새할당보장이아니다. 실패 체인 시작 설정 값 title this 반환 중간 반환 viewCount = 45 필드 기록 뒤 null 반환 관찰 다음 build 호출 실패 NPE / 출력 없음 가변 this 체인 시작 같은 adder 초기 value = 0 중간 반환 add(1)→add(2)→add(3) 하나의 상태를 변경 관찰 모든 별칭: value 6 다른 chained도 결과6 불변 String 체인 시작 raw 원본 보존 양끝 공백 포함 중간 반환 strip → ROOT 소문자 공백을 -로 replace 관찰 반환값을 slug에 저장 java-object-model 세 영역은 서로 독립된 프로그램 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 자체를 동결하지는 않습니다.

이 예의 조건

  • 세 체인은 독립 프로그램이며 String 중간값은 새 인스턴스 할당 보장이 아닙니다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2세 독립 프로그램을 비교한다. 실패 builder는 viewCount 45 기록 뒤 null을 반환하여 다음 build 호출에서 NPE가 발생하고 출력은 없다. 가변 adder 별칭은 한 객체를 6까지 변경하고 불변 String 체인은 원본을 보존하며 slug java-object-model을 반환한다. String 상자는 실행 단계의 값이며 새 할당을 보장하지 않는다.
12NPE
13출력 없음
23다른 chained도 결과 6
원 도표의 설명과 주석
구분내용
설명세 독립 프로그램을 비교한다. 실패 builder는 viewCount 45 기록 뒤 null을 반환하여 다음 build 호출에서 NPE가 발생하고 출력은 없다. 가변 adder 별칭은 한 객체를 6까지 변경하고 불변 String 체인은 원본을 보존하며 slug java-object-model을 반환한다. String 상자는 실행 단계의 값이며 새 할당을 보장하지 않는다.
전체 읽기 정보체인의 다음 호출 대상은 반환 계약으로 결정된다 체인의 다음 호출 대상은 반환 계약으로 결정된다 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 자체를 동결하지는 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
다음 호출 대상

체인의 뒤 호출은 앞 메서드 반환값을 대상으로 합니다.

  • 실패 builder: title 설정, this 반환
  • 가변 adder: 같은 this 반환
  • 체인 문법이 항상 정상 객체 반환을 보장하지 않습니다.
중간 결과

builder는 viewCount 45 기록 뒤 null, adder는 1+2+3을 누적합니다.

  • builder: 다음 build에서 NPE
  • adder: value 6, 모든 별칭이 같음
  • 필드 기록 완료와 다음 호출 성공을 구별합니다.
불변 값 체인

별도 String은 strip, ROOT 소문자, 공백 치환 결과를 slug에 둡니다.

  • slug: java-object-model
  • raw: 원본 보존
  • 반환값 연결로 새 값을 채택하며 원본은 그대로입니다.

체인의 다음 호출 대상은 반환 계약으로 결정된다

null 반환 체인은 필드 기록 후 다음 build에서 실패하고 가변 this 체인은 0→1→3→6을 공유하며 불변 String 체인은 slug만 저장합니다. 세 독립 실행의 중간 반환과 다음 호출 허가를 추적할 수 있습니다.

체인의 다음 호출 대상은 반환 계약으로 결정된다

null을 반환하는 실패 체인, 같은 this를 반환하는 가변 체인, 원본을 보존하는 String 체인은 실행 결과가 다르다.

체인의 다음 호출 대상은 앞선 메서드의 반환값입니다

점으로 이어진 문법이 같아도 반환값의 의미는 다릅니다. 아래는 서로 독립된 원본 프로그램의 계약과 관찰 결과입니다.

체인의 다음 호출 대상은 앞선 메서드의 반환값입니다 세독립프로그램을비교한다. 실패builder는viewCount45기록뒤null반환하여다음build호출NPE로출력없음,가변adder별칭은한객체를6까지변경,불변String체인은원본을보존하며slugjava-object-model을반환한다. String상자들은실행단계값으로새할당보장이아니다. 실패 체인 시작 설정 값 title this 반환 중간 반환 viewCount = 45 필드 기록 뒤 null 반환 관찰 다음 build 호출 실패 NPE / 출력 없음 가변 this 체인 시작 같은 adder 초기 value = 0 중간 반환 add(1)→add(2)→add(3) 하나의 상태를 변경 관찰 모든 별칭: value 6 다른 chained도 결과6 불변 String 체인 시작 raw 원본 보존 양끝 공백 포함 중간 반환 strip → ROOT 소문자 공백을 -로 replace 관찰 반환값을 slug에 저장 java-object-model 각 영역은 독립 프로그램 · String 결과의 새 인스턴스 할당을 뜻하지 않음
체인의 다음 호출 대상은 앞선 메서드의 반환값입니다 세독립프로그램을비교한다. 실패builder는viewCount45기록뒤null반환하여다음build호출NPE로출력없음,가변adder별칭은한객체를6까지변경,불변String체인은원본을보존하며slugjava-object-model을반환한다. String상자들은실행단계값으로새할당보장이아니다. 실패 체인 시작 설정 값 title this 반환 중간 반환 viewCount = 45 필드 기록 뒤 null 반환 관찰 다음 build 호출 실패 NPE / 출력 없음 가변 this 체인 시작 같은 adder 초기 value = 0 중간 반환 add(1)→add(2)→add(3) 하나의 상태를 변경 관찰 모든 별칭: value 6 다른 chained도 결과6 불변 String 체인 시작 raw 원본 보존 양끝 공백 포함 중간 반환 strip → ROOT 소문자 공백을 -로 replace 관찰 반환값을 slug에 저장 java-object-model 세 영역은 서로 독립된 프로그램 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 자체를 동결하지는 않습니다.

이 예의 조건

  • 세 체인은 독립 프로그램이며 String 중간값은 새 인스턴스 할당 보장이 아닙니다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2세 독립 프로그램을 비교한다. 실패 builder는 viewCount 45 기록 뒤 null을 반환하여 다음 build 호출에서 NPE가 발생하고 출력은 없다. 가변 adder 별칭은 한 객체를 6까지 변경하고 불변 String 체인은 원본을 보존하며 slug java-object-model을 반환한다. String 상자는 실행 단계의 값이며 새 할당을 보장하지 않는다.
12NPE
13출력 없음
23다른 chained도 결과 6
원 도표의 설명과 주석
구분내용
설명세 독립 프로그램을 비교한다. 실패 builder는 viewCount 45 기록 뒤 null을 반환하여 다음 build 호출에서 NPE가 발생하고 출력은 없다. 가변 adder 별칭은 한 객체를 6까지 변경하고 불변 String 체인은 원본을 보존하며 slug java-object-model을 반환한다. String 상자는 실행 단계의 값이며 새 할당을 보장하지 않는다.
전체 읽기 정보체인의 다음 호출 대상은 반환 계약으로 결정된다 체인의 다음 호출 대상은 반환 계약으로 결정된다 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 자체를 동결하지는 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
다음 호출 대상

체인의 뒤 호출은 앞 메서드 반환값을 대상으로 합니다.

  • 실패 builder: title 설정, this 반환
  • 가변 adder: 같은 this 반환
  • 체인 문법이 항상 정상 객체 반환을 보장하지 않습니다.
중간 결과

builder는 viewCount 45 기록 뒤 null, adder는 1+2+3을 누적합니다.

  • builder: 다음 build에서 NPE
  • adder: value 6, 모든 별칭이 같음
  • 필드 기록 완료와 다음 호출 성공을 구별합니다.
불변 값 체인

별도 String은 strip, ROOT 소문자, 공백 치환 결과를 slug에 둡니다.

  • slug: java-object-model
  • raw: 원본 보존
  • 반환값 연결로 새 값을 채택하며 원본은 그대로입니다.

체인이 길어져서 중간 결과를 확인하고 싶어지면, 각 단계를 이름 있는 변수에 받는 여러 문장으로 풀어 쓰면 됩니다. 특히 실패할 수 있는 단계는 체인에 묶지 않는 편이 좋습니다. 예를 들어 Integer.parseInt(line.split(":")[1].strip())에 "views 45"처럼 콜론이 없는 줄을 넣으면 ArrayIndexOutOfBoundsException이 나고, "views: forty"를 넣으면 NumberFormatException이 납니다. 한 식 안에서 일어나는 일이라 어느 단계가 왜 실패했는지 읽어 내기 어렵고, 단계마다 다른 처리를 하기도 어렵습니다. 나누어 쓰면 콜론이 없는 경우와 숫자가 아닌 경우에 각각 맞는 오류 메시지를 낼 수 있습니다.


String 메서드로 문자열 문제 풀기

문자열 문제를 풀 때는 반복문부터 쓰기 전에 입력과 원하는 결과의 관계를 먼저 따져 보면 대개 알맞은 메서드가 보입니다.

하고 싶은 일쓰는 메서드
길이, 특정 문자의 위치length, indexOf, lastIndexOf
파일 이름과 확장자 나누기lastIndexOf로 마지막 점을 찾은 뒤 substring
검색어가 나온 횟수 세기indexOf(검색어, 시작 위치)를 반복
양끝 공백 정리strip
특정 단어 바꾸기replace
단어로 나눈 뒤 다시 잇기split과 String.join
문자 순서 뒤집기StringBuilder의 reverse

다음 프로그램은 이 가운데 검색어 세기, 파일 이름 나누기, 문자열 뒤집기를 구현합니다.

src/StringProblemToolkit.java
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

count는 indexOf(keyword, from)으로 from 위치부터 검색어를 찾습니다. 찾으면 개수를 하나 늘리고 다음 검색 시작 위치를 found + keyword.length(), 즉 방금 찾은 검색어의 바로 뒤로 옮깁니다. "hello java, hello string"에서는 0번과 12번에서 hello를 찾고, 17번부터는 더 찾지 못해 2를 돌려줍니다.

baseName과 extension은 둘 다 마지막 점의 위치를 기준으로 이름을 나눕니다. 점이 여러 개인 board.notes.txt에서는 마지막 점 앞의 board.notes가 이름이고 뒤의 txt가 확장자입니다. 조건이 dot <= 0이라는 점에 주목하세요. 점이 없을 때(-1)뿐 아니라 점이 맨 앞에 있을 때(0)도 확장자가 없다고 봅니다. 그래서 .gitignore는 이름 전체가 .gitignore이고 확장자는 빈 문자열입니다. 점으로 시작하는 숨김 파일을 확장자만 있는 파일로 읽지 않으려는 규칙인데, 문자열 비교와 String 메서드의 extension은 점이 없거나(dot < 0) 맨 끝에 있는 경우만 걸러서 .gitignore에 gitignore를 돌려주었습니다. 두 규칙 모두 문자열로는 맞고, 어느 쪽이 옳은지는 프로그램의 요구가 정합니다. 운영체제의 경로까지 다룬다면 Path API가 따로 있습니다. 또 세 함수 모두 null은 검사하지 않으므로, 호출하는 쪽에서 null이 아닌 값을 넘겨야 합니다.

마지막 줄의 reverse는 StringBuilder의 메서드로, 버퍼의 문자 순서를 뒤집고 builder 자신을 돌려줍니다. String에는 뒤집는 메서드가 없으므로 StringBuilder로 감싸 뒤집고, 문자열 결합으로 출력할 때 자동으로 toString()이 호출됩니다. reverse는 서로게이트 쌍을 한 단위로 취급하므로 😀 같은 이모지 하나는 깨지지 않습니다. 다만 여러 코드 포인트가 이어져 한 글자로 보이는 이모지는 구성 요소의 순서가 뒤집혀 다른 모양이 됩니다.

겹치는 등장까지 셀 것인가

빈 검색어를 미리 거부하는 이유는 무한 반복을 막기 위해서입니다. indexOf("", from)은 빈 문자열이 어디에나 있다고 보고 from을 그대로 돌려줍니다. 그러면 다음 시작 위치가 found + 0이 되어 같은 자리에서 끝없이 '찾게' 됩니다. 그래서 반복문에 들어가기 전에 빈 검색어를 예외로 거부합니다.

다음 시작 위치를 어디로 옮기느냐는 '횟수'를 어떻게 정의하느냐의 문제이기도 합니다. "aaaa"에서 "aa"를 세는 경우로 count를 따라가 보겠습니다.

단계fromfoundcount다음 from
10010 + 2 = 2
22222 + 2 = 4
34-12반복 종료

검색어 길이만큼 건너뛰므로 한 번 센 글자는 다시 세지 않고, 결과는 2입니다. 이것을 겹치지 않는 등장 횟수라고 합니다. 반면 다음 시작 위치를 found + 1로 한 칸씩만 옮기면 0번, 1번, 2번에서 시작하는 "aa"를 모두 세어 3이 됩니다. 1번에서 시작하는 "aa"는 앞의 두 등장과 글자를 하나씩 나눠 쓰는 겹치는 등장입니다. 아래 단계별 그림은 위 표의 겹치지 않는 경우를 따라갑니다.

문자열 도구는 탐색 위치와 파일명 경계를 명시한다

aaaa/aa 비중첩 검색의 from 0→2→4와 found 0→2→미발견, count 0→1→2가 실제로 바뀝니다. 다음 탐색 위치와 중첩 정책 차이를 고정 상태로 설명할 수 있습니다.

문자열 도구는 탐색 위치와 파일명 경계를 명시한다

StringProblemToolkit의 비중첩 검색과 두 파일명 함수가 사용하는 서로 다른 반환 규칙을 보여 준다.

검색은 비중첩으로 진행하고 파일명은 마지막 점에서 나눕니다

StringProblemToolkit의 탐색 규칙과 원문 main의 결과를 비교합니다.

검색은 비중첩으로 진행하고 파일명은 마지막 점에서 나눕니다 새독립연습aaaa에서aa의비중첩count를추적한다. found0뒤from2,found2뒤from4,다음found-1이면count2반환. 빈keyword는원함수입력경계에서거부하며그조건뒤의루프이다. 파일명함수와원hello입력은아래원설명에보존한다. 비중첩 검색 시작 source: aaaa / keyword: aa from0 / count0 indexOf의 found 0 이상인가? 발견 처리 count++ from = found + 2 발견 없음: 반환 누적 count = 2 예 아니오 발견 위치0 → 다음2 발견 위치2 → 다음4 다음 발견 없음 → 2 반환 별도 독립 입력 aaaa/aa · 원 main의 hello 예제는 아래 표에 보존
검색은 비중첩으로 진행하고 파일명은 마지막 점에서 나눕니다 새독립연습aaaa에서aa의비중첩count를추적한다. found0뒤from2,found2뒤from4,다음found-1이면count2반환. 빈keyword는원함수입력경계에서거부하며그조건뒤의루프이다. 파일명함수와원hello입력은아래원설명에보존한다. 비중첩 검색 시작 source: aaaa keyword: aa from0 / count0 indexOf의 found 0 이상인가? 발견 처리 count++ from = found + 2 발견 없음: 반환 누적 count = 2 예 아니오 found0 → from2 found2 → from4 다음 found -1 → 종료
구현된 경계와 원본 main의 결과
함수소스의 경계·진행 규칙예제 결과
검색 횟수 · 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는 원문 코드와 출력에 남깁니다.

이 예의 조건

  • aaaa/aa는 별도 독립 입력이며 원 main의 hello 사례를 대체하지 않는다.
  • 비중첩 정책은 다음 from=found+keyword.length이다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2새 독립 연습 aaaa에서 aa의 비중첩 count를 추적한다. found 0 뒤 from 2, found 2 뒤 from 4, 다음 found -1이면 count 2를 반환한다. 빈 keyword는 원 함수의 입력 경계에서 거부하며 그 조건 뒤의 루프이다. 파일명 함수와 원 hello 입력은 아래 원 설명에 보존한다.
6from 0
7count 0
17발견 위치 0 → 다음 2
18발견 위치 2 → 다음 4
원 도표의 설명과 주석
구분내용
설명새 독립 연습 aaaa에서 aa의 비중첩 count를 추적한다. found 0 뒤 from 2, found 2 뒤 from 4, 다음 found -1이면 count 2를 반환한다. 빈 keyword는 원 함수의 입력 경계에서 거부하며 그 조건 뒤의 루프이다. 파일명 함수와 원 hello 입력은 아래 원 설명에 보존한다.
전체 읽기 정보문자열 도구는 탐색 위치와 파일명 경계를 명시한다 문자열 도구는 탐색 위치와 파일명 경계를 명시한다 StringProblemToolkit의 비중첩 검색과 두 파일명 함수가 사용하는 서로 다른 반환 규칙을 보여 준다. 검색은 비중첩으로 진행하고 파일명은 마지막 점에서 나눕니다 StringProblemToolkit 의 탐색 규칙과 원문 main 의 결과를 비교합니다. 구현된 경계와 원본 main의 결과 함수 소스의 경계·진행 규칙 예제 결과 검색 횟수 · 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 는 원문 코드와 출력에 남깁니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
첫 탐색 준비

from 0/count 0에서 aa를 찾습니다.

  • from: 0
  • count: 0
  • 현재 검색 시작점을 상태로 보관한다.
첫 발견

found 0을 세고 다음 시작점을 2로 옮깁니다.

  • found: 0
  • from: 2
  • count: 1
  • 찾은 두 글자를 건너뛰므로 겹치는 위치 1은 탐색하지 않는다.
둘째 발견

found 2를 세고 다음 시작점을 4로 옮깁니다.

  • found: 2
  • from: 4
  • count: 2
  • 두 번째 비중첩 일치도 같은 이동 규칙을 사용한다.
검색 종료

from 4에서 더 발견하지 못해 2를 반환합니다.

  • found: -1
  • 반환 count: 2
  • 미발견 시 추가 누산이나 이동 없이 끝낸다.

문자열 도구는 탐색 위치와 파일명 경계를 명시한다

aaaa/aa 비중첩 검색의 from 0→2→4와 found 0→2→미발견, count 0→1→2가 실제로 바뀝니다. 다음 탐색 위치와 중첩 정책 차이를 고정 상태로 설명할 수 있습니다.

문자열 도구는 탐색 위치와 파일명 경계를 명시한다

StringProblemToolkit의 비중첩 검색과 두 파일명 함수가 사용하는 서로 다른 반환 규칙을 보여 준다.

검색은 비중첩으로 진행하고 파일명은 마지막 점에서 나눕니다

StringProblemToolkit의 탐색 규칙과 원문 main의 결과를 비교합니다.

검색은 비중첩으로 진행하고 파일명은 마지막 점에서 나눕니다 새독립연습aaaa에서aa의비중첩count를추적한다. found0뒤from2,found2뒤from4,다음found-1이면count2반환. 빈keyword는원함수입력경계에서거부하며그조건뒤의루프이다. 파일명함수와원hello입력은아래원설명에보존한다. 비중첩 검색 시작 source: aaaa / keyword: aa from0 / count0 indexOf의 found 0 이상인가? 발견 처리 count++ from = found + 2 발견 없음: 반환 누적 count = 2 예 아니오 발견 위치0 → 다음2 발견 위치2 → 다음4 다음 발견 없음 → 2 반환 별도 독립 입력 aaaa/aa · 원 main의 hello 예제는 아래 표에 보존
검색은 비중첩으로 진행하고 파일명은 마지막 점에서 나눕니다 새독립연습aaaa에서aa의비중첩count를추적한다. found0뒤from2,found2뒤from4,다음found-1이면count2반환. 빈keyword는원함수입력경계에서거부하며그조건뒤의루프이다. 파일명함수와원hello입력은아래원설명에보존한다. 비중첩 검색 시작 source: aaaa keyword: aa from0 / count0 indexOf의 found 0 이상인가? 발견 처리 count++ from = found + 2 발견 없음: 반환 누적 count = 2 예 아니오 found0 → from2 found2 → from4 다음 found -1 → 종료
구현된 경계와 원본 main의 결과
함수소스의 경계·진행 규칙예제 결과
검색 횟수 · 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는 원문 코드와 출력에 남깁니다.

이 예의 조건

  • aaaa/aa는 별도 독립 입력이며 원 main의 hello 사례를 대체하지 않는다.
  • 비중첩 정책은 다음 from=found+keyword.length이다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2새 독립 연습 aaaa에서 aa의 비중첩 count를 추적한다. found 0 뒤 from 2, found 2 뒤 from 4, 다음 found -1이면 count 2를 반환한다. 빈 keyword는 원 함수의 입력 경계에서 거부하며 그 조건 뒤의 루프이다. 파일명 함수와 원 hello 입력은 아래 원 설명에 보존한다.
6from 0
7count 0
17발견 위치 0 → 다음 2
18발견 위치 2 → 다음 4
원 도표의 설명과 주석
구분내용
설명새 독립 연습 aaaa에서 aa의 비중첩 count를 추적한다. found 0 뒤 from 2, found 2 뒤 from 4, 다음 found -1이면 count 2를 반환한다. 빈 keyword는 원 함수의 입력 경계에서 거부하며 그 조건 뒤의 루프이다. 파일명 함수와 원 hello 입력은 아래 원 설명에 보존한다.
전체 읽기 정보문자열 도구는 탐색 위치와 파일명 경계를 명시한다 문자열 도구는 탐색 위치와 파일명 경계를 명시한다 StringProblemToolkit의 비중첩 검색과 두 파일명 함수가 사용하는 서로 다른 반환 규칙을 보여 준다. 검색은 비중첩으로 진행하고 파일명은 마지막 점에서 나눕니다 StringProblemToolkit 의 탐색 규칙과 원문 main 의 결과를 비교합니다. 구현된 경계와 원본 main의 결과 함수 소스의 경계·진행 규칙 예제 결과 검색 횟수 · 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 는 원문 코드와 출력에 남깁니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
첫 탐색 준비

from 0/count 0에서 aa를 찾습니다.

  • from: 0
  • count: 0
  • 현재 검색 시작점을 상태로 보관한다.
첫 발견

found 0을 세고 다음 시작점을 2로 옮깁니다.

  • found: 0
  • from: 2
  • count: 1
  • 찾은 두 글자를 건너뛰므로 겹치는 위치 1은 탐색하지 않는다.
둘째 발견

found 2를 세고 다음 시작점을 4로 옮깁니다.

  • found: 2
  • from: 4
  • count: 2
  • 두 번째 비중첩 일치도 같은 이동 규칙을 사용한다.
검색 종료

from 4에서 더 발견하지 못해 2를 반환합니다.

  • found: -1
  • 반환 count: 2
  • 미발견 시 추가 누산이나 이동 없이 끝낸다.

두 방식을 한 프로그램에서 비교하면 다음과 같습니다.

src/OverlapCount.java
public final class OverlapCount {
    public static void main(String[] args) {
        System.out.println("non-overlap=" + count("aaaa", "aa", false));
        System.out.println("overlap=" + count("aaaa", "aa", true));
    }

    private static int count(String source, String keyword, boolean overlap) {
        int count = 0, from = 0;
        while (true) {
            int found = source.indexOf(keyword, from);
            if (found < 0) return count;
            count++;
            from = overlap ? found + 1 : found + keyword.length();
        }
    }
}
non-overlap=2
overlap=3

차이는 from을 옮기는 한 줄뿐입니다. 어느 쪽이 맞는지는 코드가 아니라 요구가 정합니다. 문장에서 단어가 몇 번 쓰였는지 세는 일이라면 겹치지 않는 방식이 자연스럽고, DNA 서열에서 특정 패턴이 나타나는 모든 위치를 찾는 일이라면 겹치는 방식이 필요합니다. 그래서 '몇 번 나오는가'라는 요구를 받으면 "aaaa"와 "aa" 같은 작은 예로 기대하는 답이 2인지 3인지 먼저 확인해 두어야 합니다.


체인으로 쓸까, 문장으로 나눌까

체인은 각 단계가 무엇을 돌려주는지 분명하고, 왼쪽에서 오른쪽으로 읽으면 작업 순서가 그대로 드러나며, 중간에 실패할 일이 없을 때 쓰기 좋습니다. 앞의 slug 만들기가 그런 예입니다. 반대로 중간 값을 두 번 이상 써야 하거나, 중간 값에 따라 분기해야 하거나, 한 체인에 서로 다른 성격의 작업이 섞인다면 이름 있는 변수를 둔 여러 문장으로 나누는 편이 읽기도 쉽고 오류를 찾기도 쉽습니다. 판단 기준은 체인의 길이가 아니라 그 안에 담긴 단계의 수입니다. 같은 일을 하는 append 열 번은 한 단계이지만, 파싱과 검증과 저장은 짧게 써도 세 단계입니다.

빌더처럼 가변 객체로 체인을 만든다면 몇 가지를 정해 두어야 합니다. 먼저 빌더는 한 곳에서만 쓰고 여러 곳이 공유하지 않게 합니다. 다음으로 build는 빌더가 아니라 String이나 불변 객체를 돌려주게 합니다. 마지막으로 build를 호출한 뒤 빌더를 다시 써도 되는지 정합니다. 앞의 PostLineBuilder는 build 뒤에도 설정 메서드를 호출할 수 있습니다. 그 뒤에 다시 build를 호출하면 다른 결과가 나오지만, 이미 만들어진 String은 불변이라 영향을 받지 않습니다.


정리

  • 체인에서 점 뒤의 메서드는 점 앞의 식이 돌려준 값에 대해 호출됩니다. 그래서 각 메서드가 무엇을 돌려주는지가 체인의 의미를 정합니다.
  • 체인 중간의 메서드가 null을 돌려주면 다음 호출에서 NullPointerException이 납니다. 체인에 쓰이는 메서드는 정상적으로 끝나면 항상 다음 호출에 쓸 객체를 돌려주고, 진행할 수 없으면 예외를 던집니다.
  • this를 돌려주는 체인은 하나의 가변 객체를 계속 바꿉니다. 중간에 받은 변수들은 그 시점의 값을 따로 저장한 것이 아니라 같은 객체의 별칭입니다.
  • 빌더는 설정 메서드에서 값 하나로 판단할 수 있는 규칙을, build에서 빠진 값처럼 마지막에야 알 수 있는 규칙을 검사합니다.
  • String의 체인은 각 단계가 새 값을 돌려주고 원본은 바뀌지 않습니다. 실패할 수 있는 단계는 체인에서 꺼내 문장으로 나눕니다.
  • 검색어 세기에서 다음 시작 위치를 검색어 길이만큼 옮기면 겹치지 않는 등장을, 한 칸씩 옮기면 겹치는 등장까지 셉니다.

연습 문제

  1. TitleBuilder로 prefix, title, suffix를 차례로 설정해 [JAVA] OBJECT - PUBLISHED를 만드세요. 각 설정 메서드는 this를 돌려주고, build는 제목이 없으면 거부합니다.
해설 보기
src/TitleBuilderExercise.java
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

세 설정 메서드가 모두 this를 돌려주므로 체인 전체가 하나의 TitleBuilder를 설정합니다. prefix와 suffix는 빈 문자열로 초기화해 두었기 때문에 설정하지 않아도 build가 동작하지만, title은 초깃값이 null이라서 설정하지 않으면 build가 IllegalStateException으로 거부합니다. build는 세 조각을 이은 String을 돌려주므로, 그 뒤에 빌더를 다시 설정해도 이미 만든 제목은 바뀌지 않습니다.

제목처럼 반드시 있어야 하는 값은 new TitleBuilder("object")처럼 생성자에서 받고, 선택 항목만 체인으로 설정하게 만드는 방법도 있습니다. 그러면 제목 없는 빌더가 아예 만들어지지 않으므로 build의 검사가 필요 없어집니다.

이 예제는 화면에 보여 줄 제목을 만드는 것이라 toUpperCase()에 로케일을 지정하지 않았습니다. 그러면 실행 환경의 로케일을 따르므로, 터키어 로케일에서는 PUBLISHED가 아니라 PUBLİSHED가 나옵니다. 만든 제목을 비교나 검색의 키로도 쓸 계획이라면 toUpperCase(Locale.ROOT)로 고정해야 합니다.

  1. 다음 프로그램의 출력을 예상하고, 그 이유를 설명하세요. ValueAdder는 본문의 것과 같습니다.
ValueAdder first = new ValueAdder().add(5);
ValueAdder second = first.add(1);
first.add(10);
System.out.println(second.value());
해설 보기

출력은 16입니다. add는 this를 돌려주므로 first와 second는 같은 객체를 가리킵니다. 객체의 value는 5에서 6이 되고, first.add(10)으로 16이 됩니다. second는 6이던 시점을 저장한 것이 아니라 같은 객체의 다른 이름이라서, second.value()도 지금의 값 16을 돌려줍니다.

  1. count("banana", "ana")가 돌려주는 값을 본문의 표처럼 from, found, count를 따라가며 구하세요. 겹치는 등장까지 세도록 바꾸면 결과가 어떻게 달라지나요?
해설 보기

from이 0일 때 found는 1이고 count가 1이 됩니다. 다음 from은 1 + 3 = 4인데, 4번부터는 "na"만 남아서 found가 -1이 되고 1을 돌려줍니다. 겹치는 방식이라면 다음 from이 1 + 1 = 2가 되고, 2번부터 찾으면 3번에서 시작하는 "ana"를 찾아 count가 2가 됩니다. 다음 from 4부터는 찾지 못하므로 결과는 2입니다. 1번에서 시작하는 "ana"와 3번에서 시작하는 "ana"가 3번 글자 a를 함께 쓰기 때문에, 겹치지 않는 방식에서는 둘째 등장을 세지 않습니다.


다음 장

9장에서는 Object의 메서드와 동등성에서 시작해 불변 객체와 String까지 살펴보았습니다. 래퍼 클래스와 박싱·언박싱에서는 int 같은 기본형을 객체로 감싸는 래퍼 클래스를 다룹니다. Integer도 String처럼 불변 객체이고, ==로 비교하면 이 장에서 본 것과 같은 함정이 있습니다.