본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
9장 : Object와 불변성

메서드 체이닝과 문자열

this 반환이 만드는 체인의 실행 순서를 추적하고 학습 기록 문장 빌더·파일명 분석·검색·역순 변환을 안전한 문자열 도구로 완성합니다.

메서드가 다음 호출 대상 객체를 반환하면 점을 이어 여러 연산을 한 표현식으로 작성할 수 있습니다. StringBuilder의 append가 대표적입니다. 체이닝은 호출 순서를 읽기 쉽게 만들 수 있지만 반환 계약이 끊기거나 중간 상태가 숨겨지면 디버깅을 어렵게 합니다.

체이닝 중간의 null 실패

다음 빌더의 topic은 this를 반환하지만 minutes는 실수로 null을 반환합니다. 문법은 컴파일되며 실제 실행에서 그 다음 build 호출이 실패합니다.

lab/BrokenFluentChainFailure.java
public final class BrokenFluentChainFailure {
    public static void main(String[] args) {
        String line = new StudyLineBuilder()
                .topic("string")
                .minutes(45)
                .build();
        System.out.println(line);
    }

    private static final class StudyLineBuilder {
        private String topic;
        private int minutes;

        StudyLineBuilder topic(String value) {
            topic = value;
            return this;
        }

        StudyLineBuilder minutes(int value) {
            minutes = value;
            return null;
        }

        String build() {
            return topic + ":" + minutes;
        }
    }
}
실패 관찰
Exception in thread "main" java.lang.NullPointerException

체인 가능한 메서드는 정상 경로에서 항상 문서화된 대상 객체를 반환해야 합니다. 현재 객체를 계속 구성한다면 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

afterOne, afterTwo, afterThree은 각 시점의 스냅숏이 아니라 같은 객체의 별칭입니다. 나중에 하나를 수정하면 모든 변수를 통해 새 값이 보입니다. 체인 빌더는 한 식이나 한 메서드 안에서만 사용하고 중간 참조를 장기간 보관하지 않는 편이 안전합니다.

학습 기록 문장 조립

build에서 필수 값을 검사하면 완성되지 않은 결과가 외부로 나가는 것을 막을 수 있습니다. 체인 메서드는 입력 즉시 단순 범위를 검증하고, 필드 간 관계는 최종 생성 시 확인합니다.

src/StudyLineFluentBuilder.java
public final class StudyLineFluentBuilder {
    public static void main(String[] args) {
        String line = new StudyLineBuilder()
                .topic("method chaining")
                .minutes(50)
                .completed(true)
                .build();

        System.out.println(line);
    }

    private static final class StudyLineBuilder {
        private String topic;
        private int minutes;
        private boolean completed;

        StudyLineBuilder topic(String value) {
            if (value == null || value.isBlank()) throw new IllegalArgumentException("topic");
            topic = value.strip();
            return this;
        }

        StudyLineBuilder minutes(int value) {
            if (value <= 0) throw new IllegalArgumentException("minutes");
            minutes = value;
            return this;
        }

        StudyLineBuilder completed(boolean value) {
            completed = value;
            return this;
        }

        String build() {
            if (topic == null) throw new IllegalStateException("topic missing");
            if (minutes == 0) throw new IllegalStateException("minutes missing");
            return new StringBuilder()
                    .append(topic)
                    .append(':')
                    .append(minutes)
                    .append(':')
                    .append(completed ? "done" : "open")
                    .toString();
        }
    }
}
method chaining:50:done

선택 매개변수가 많고 이름으로 의미를 보여 줘야 할 때 빌더가 유용합니다. 필수 값이 두세 개뿐이면 생성자가 더 짧고 불완전한 중간 객체를 만들지 않습니다. 체이닝을 쓰고 싶다는 이유만으로 단순 값 객체 앞에 빌더를 추가하지 않습니다.

불변 객체의 연쇄 변환

String의 strip().toLowerCase().replace(...)는 각 단계가 새 String 값을 돌려주므로 가능한 체인입니다. 가변 this 체인과 달리 중간 값이 서로 다른 인스턴스일 수 있습니다. 호출자는 반환 타입과 불변 계약을 보고 의미를 판단합니다.

src/ImmutableTopicPipeline.java
import java.util.Locale;

public final class ImmutableTopicPipeline {
    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

중간 결과를 조사해야 한다면 체인을 여러 줄의 이름 있는 변수로 풀어냅니다. 예외가 날 수 있는 파싱이나 서로 다른 업무 단계를 한 체인에 과도하게 묶으면 실패 위치와 정책이 숨겨집니다.

문자열 문제와 메서드 선택

문자열 연습은 반복문부터 작성하기보다 입력과 원하는 결과의 관계를 분류합니다.

  • 특정 문자의 길이와 위치: length, indexOf, lastIndexOf
  • 파일 이름과 확장자 분리: 마지막 구분자 위치 + substring
  • 검색어 등장 횟수: indexOf를 시작 위치와 함께 반복
  • 양끝 공백 정리: strip
  • 특정 단어 교체: replace
  • 단어 순서 결합: split 후 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("study.notes.txt"));
        System.out.println("ext=" + extension("study.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=study.notes
ext=txt
reverse=tcejbo

빈 검색어를 허용하면 indexOf가 같은 위치를 계속 반환해 무한 루프가 될 수 있으므로 경계에서 거부합니다. 파일명 규칙도 시작 점을 확장자 구분자로 볼지 업무 요구에 맞춰 조정합니다.

메서드 체이닝 선택 기준

각 단계의 반환 타입이 명확하고 왼쪽에서 오른쪽으로 자연스럽게 읽히며 실패 정책이 단순할 때 체인을 사용합니다. 중간 값을 재사용하거나 조건 분기가 필요하거나 한 줄이 여러 책임을 섞으면 이름 있는 문장으로 나눕니다. 체인의 길이보다 개념 단계 수가 판단 기준입니다.

가변 빌더는 한 소유자에게 제한하고, build 결과는 불변 값으로 반환하며, build 이후 재사용을 허용할지 정합니다. 사용 후 빌더를 계속 바꾸면 이미 만든 String은 불변이라 영향을 받지 않지만 새 build 결과는 달라집니다.

연습 문제

TitleBuilder에 prefix, topic, suffix를 차례로 설정하고 [JAVA] OBJECT - DONE을 만드세요. 각 설정 메서드는 this를 반환하고 build는 topic 누락을 거부합니다.

해설 보기
src/TitleBuilderExercise.java
public final class TitleBuilderExercise {
    public static void main(String[] args) {
        String title = new TitleBuilder()
                .prefix("java")
                .topic("object")
                .suffix("done")
                .build();
        System.out.println(title);
    }

    private static final class TitleBuilder {
        private String prefix = "";
        private String topic;
        private String suffix = "";

        TitleBuilder prefix(String value) {
            prefix = "[" + value.strip().toUpperCase() + "] ";
            return this;
        }

        TitleBuilder topic(String value) {
            topic = value.strip().toUpperCase();
            return this;
        }

        TitleBuilder suffix(String value) {
            suffix = " - " + value.strip().toUpperCase();
            return this;
        }

        String build() {
            if (topic == null || topic.isBlank()) throw new IllegalStateException("topic missing");
            return prefix + topic + suffix;
        }
    }
}
[JAVA] OBJECT - DONE

설정 메서드는 같은 builder를 이어 주고 build가 최종 String 경계를 만듭니다. 필수 topic만 생성자에서 받고 선택 요소만 체인으로 두는 대안도 가능합니다.