본문 바로가기

구체 클래스에 의존하면 생기는 문제

게시글 내보내기 형식이 늘어날 때 구체 클래스를 필드와 if 문으로 다루는 코드가 어디서 복잡해지고 어떤 버그를 만드는지 확인하고, 역할을 분리할 시점을 판단하는 기준을 배웁니다.

앞 글에서는 서비스가 구체 클래스 대신 역할 인터페이스를 받으면 구현을 바꿔 끼울 수 있다는 것을 보았습니다. 그런데 실제 코드는 처음부터 그렇게 시작하지 않는 경우가 많습니다. 게시글을 문자열로 내보내는 기능을 처음 만들 때는 형식이 하나뿐이니, 서비스가 그 형식의 클래스를 직접 쓰는 것이 가장 간단합니다. 문제는 두 번째, 세 번째 형식이 추가될 때 드러납니다.

이 글에서는 역할을 나누지 않은 채 내보내기 형식을 하나씩 늘려 가며 서비스 코드가 어떻게 바뀌는지 따라갑니다. 형식마다 필드를 두고 if 문으로 고르는 방식이 어디서 복잡해지는지, 그 과정에서 어떤 버그가 생기는지 확인한 뒤, 언제 역할을 분리하는 것이 좋은지 판단 기준을 정리합니다. 그래서 이 글의 예제는 일부러 인터페이스를 쓰지 않습니다.

형식이 하나일 때는 직접 의존해도 충분하다

게시글 하나를 제목=조회수 형태로 출력하는 내보내기부터 시작합니다. ExportService는 내보내기 앞뒤로 시작과 끝을 알리는 줄을 출력하고, 실제 형식 변환은 TextExporter에게 맡깁니다.

src/SingleConcreteExporter.java
public final class SingleConcreteExporter {
    public static void main(String[] args) {
        ExportService service = new ExportService(new TextExporter());
        service.export("가입 인사", 40);
    }

    private static final class ExportService {
        private final TextExporter exporter;

        ExportService(TextExporter exporter) {
            this.exporter = exporter;
        }

        void export(String title, int viewCount) {
            System.out.println("export:start");
            exporter.export(title, viewCount);
            System.out.println("export:end");
        }
    }

    private static final class TextExporter {
        void export(String title, int viewCount) {
            System.out.println(title + "=" + viewCount);
        }
    }
}
export:start
가입 인사=40
export:end

첫 줄과 마지막 줄은 ExportService가 출력하고, 가운데 줄은 서비스가 호출한 TextExporter.export가 출력합니다. 지금의 요구만 보면 이 코드는 읽기 쉽고 잘 동작하며, 여기에 미리 인터페이스를 만들어 둘 필요는 없습니다. 우리는 String이나 StringBuilder 같은 구체 클래스를 매일 직접 쓰지만, 바꿔 끼울 일이 없으니 인터페이스로 감싸지 않습니다. 바뀔 가능성이 낮은 곳에 미리 역할을 만들면 타입과 이름만 늘어나고, 실제로 어떤 코드가 실행되는지 따라 읽기도 어려워집니다.

다만 이 구조가 무엇을 전제하는지는 알아 두어야 합니다. ExportService의 필드와 생성자 매개변수가 TextExporter라는 이름을 직접 가리키므로, CSV 형식으로 바꾸려면 서비스 소스를 열어 두 곳의 타입을 고쳐야 합니다. 그렇게 고치고 나면 이번에는 Text 형식을 쓸 수 없습니다. 새 구현을 추가하려면 기존 클라이언트를 고쳐야 한다는 것, 이것이 구체 클래스에 의존하는 구조의 특징입니다.

형식이 늘면 서비스가 모든 형식을 알게 된다

이제 CSV 형식도 지원해야 합니다. 형식마다 클래스를 하나씩 두고, 서비스가 문자열 옵션을 보고 어느 클래스를 부를지 고르게 해 보겠습니다. main은 text, csv, 그리고 지원하지 않는 bad 옵션으로 한 번씩 내보냅니다.

src/BranchedExportService.java
public final class BranchedExportService {
    public static void main(String[] args) {
        ExportService service = new ExportService();
        service.export("text", "가입 인사", 40);
        service.export("csv", "로그인 구현", 50);
        service.export("bad", "input", 30);
    }

    private static final class ExportService {
        private final TextExporter text = new TextExporter();
        private final CsvExporter csv = new CsvExporter();

        void export(String option, String title, int viewCount) {
            System.out.println("export:start=" + option);
            if (option.equals("text")) {
                text.export(title, viewCount);
            } else if (option.equals("csv")) {
                csv.export(title, viewCount);
            } else {
                System.out.println("unknown");
            }
        }
    }

    private static final class TextExporter {
        void export(String title, int viewCount) { System.out.println(title + "=" + viewCount); }
    }

    private static final class CsvExporter {
        void export(String title, int viewCount) { System.out.println(title + "," + viewCount); }
    }
}
export:start=text
가입 인사=40
export:start=csv
로그인 구현,50
export:start=bad
unknown

호출마다 서비스가 먼저 export:start= 뒤에 옵션을 붙여 출력하고, 옵션에 맞는 분기로 들어갑니다. text는 =로, csv는 쉼표로 이은 줄을 출력합니다. bad는 어느 조건에도 맞지 않아 마지막 else가 unknown만 출력하고 넘어갑니다. 잘못된 옵션을 이렇게 조용히 넘길지, 오류로 알릴지는 이 장의 마지막 글에서 다시 다룹니다.

동작은 맞지만 ExportService가 하는 일을 세어 보면 문제가 보입니다. 이 클래스는 옵션 문자열을 해석하고, 두 내보내기 객체를 직접 만들고, 시작 줄을 출력하는 공통 흐름을 실행하고, 형식별 메서드를 직접 호출합니다. 네 가지 일이 한 클래스에 모여 있어서 그중 무엇이 바뀌어도 이 클래스를 고쳐야 합니다.

JSON 형식을 추가한다고 해 봅시다. JsonExporter 클래스를 새로 만드는 것은 당연하지만, 그것으로 끝나지 않습니다. ExportService에 JsonExporter 필드를 추가하고, if 사슬에 else if (option.equals("json")) 분기를 끼워 넣어야 합니다. 형식이 열 개가 되면 필드 열 개와 분기 열 개가 한 클래스에 쌓이고, 이미 잘 동작하던 Text와 CSV 분기가 있는 메서드도 새 형식을 넣을 때마다 다시 편집됩니다. 검증이 끝난 코드를 다시 열어 고치면, 새 형식과 상관없는 기존 형식을 실수로 망가뜨릴 위험도 함께 생깁니다.

필드를 선언하는 자리에서 객체를 직접 만드는 것도 문제입니다. ExportService를 만들면 쓰든 안 쓰든 모든 형식의 객체가 함께 만들어집니다. 지금은 가벼운 객체라 괜찮지만, 파일을 열거나 네트워크에 연결해야 하는 내보내기라면 쓰지도 않을 자원을 미리 잡게 됩니다. 또 서비스가 형식 객체를 스스로 만들기 때문에 밖에서 다른 객체로 바꿔 넣을 수 없습니다. 예를 들어 '시작 줄을 출력한 뒤에 형식 변환을 호출하는가'를 테스트하려고 호출 기록만 남기는 가짜 내보내기 객체를 넣고 싶어도, 서비스 코드를 고치지 않고는 방법이 없습니다.

형식을 setter로 바꾸려다 생긴 버그

옵션 문자열 대신 쓸 형식 객체를 미리 설정해 두는 방식은 어떨까요? 다음 서비스는 형식마다 필드와 setter를 하나씩 두고, export는 설정된 필드를 찾아 호출합니다. main은 Text 형식을 설정했다가 마음을 바꿔 CSV 형식을 설정한 뒤 내보냅니다. 무엇이 출력될지 예상해 보세요.

lab/ConcreteExporterSwitchBug.java
public final class ConcreteExporterSwitchBug {
    public static void main(String[] args) {
        ExportService service = new ExportService();
        service.setTextExporter(new TextExporter());
        service.setCsvExporter(new CsvExporter());
        service.export("가입 인사", 40);
    }

    private static final class ExportService {
        private TextExporter textExporter;
        private CsvExporter csvExporter;

        void setTextExporter(TextExporter exporter) {
            textExporter = exporter;
        }

        void setCsvExporter(CsvExporter exporter) {
            csvExporter = exporter;
        }

        void export(String title, int viewCount) {
            if (textExporter != null) {
                textExporter.export(title, viewCount);
            } else if (csvExporter != null) {
                csvExporter.export(title, viewCount);
            }
        }
    }

    private static final class TextExporter {
        void export(String title, int viewCount) { System.out.println(title + "=" + viewCount); }
    }

    private static final class CsvExporter {
        void export(String title, int viewCount) { System.out.println(title + "," + viewCount); }
    }
}
잘못된 결과
가입 인사=40

마지막에 CSV를 설정했으니 가입 인사,40을 기대했겠지만 실제로는 Text 형식이 출력됩니다. setCsvExporter는 csvExporter 필드만 채울 뿐, 앞에서 설정한 textExporter를 비우지 않기 때문입니다. main을 한 줄씩 실행하며 두 필드를 따라가 보면 다음과 같습니다.

실행한 코드textExportercsvExporter
new ExportService()nullnull
setTextExporter(new TextExporter())TextExporter 객체null
setCsvExporter(new CsvExporter())TextExporter 객체CsvExporter 객체
export("가입 인사", 40)첫 조건이 참이라 Text 실행검사하지 않음

export가 호출될 때는 두 필드가 모두 객체를 가리키고 있습니다. 먼저 검사하는 textExporter != null이 참이므로 Text 분기가 실행되고, else if라서 CSV 조건은 검사조차 하지 않습니다. 아래 그림은 두 필드가 가질 수 있는 네 가지 조합마다 export가 어느 분기를 실행하는지 정리한 것입니다. 이 main이 만든 것은 '둘 다 설정'이고, 나머지 세 조합은 같은 메서드의 조건으로 따져 본 경우입니다.

두 exporter 필드의 조합과 먼저 선택되는 분기

두 필드가 모두 참조를 가지면 첫 text 조건이 참이 되어 Text를 호출하고 CSV 조건은 검사하지 않습니다. 설정 상태와 첫 분기의 우선순위를 실행 순서로 드러낼 수 있습니다.

두 exporter 필드의 조합과 먼저 선택되는 분기

ConcreteExporterSwitchBug의 export는 textExporter부터 검사한다. 두 필드가 모두 null이면 출력 없이 끝나고, 둘 다 참조를 담으면 CSV를 마지막에 설정했어도 TextExporter를 호출한다.

두 필드가 남아 있으면 먼저 검사하는 Text가 실행됩니다

ConcreteExporterSwitchBug의 export는 textExporter를 먼저 검사합니다. 표는 두 필드의 null 여부에 따른 소스 분기를 비교합니다.

두 필드가 남아 있으면 먼저 검사하는 Text가 실행됩니다 textExporter 참조가 있으면 Text를 호출하고 CSV조건은검사하지않는다. 없을때만csvExporter를검사한다. 두참조가모두null이면출력없이끝난다. 원본main은둘다참조가있으므로Text출력이다. 있음 없음 있음 없음 export 호출 원문은 두 필드 모두 참조 textExporter 참조 있음? Text 호출 가입 인사=40 CSV 조건은 검사 안 함 csvExporter 참조 있음? CSV 호출 가입 인사,40 둘 다 null 출력 없이 종료
두 필드가 남아 있으면 먼저 검사하는 Text가 실행됩니다 textExporter 참조가 있으면 Text를 호출하고 CSV조건은검사하지않는다. 없을때만csvExporter를검사한다. 두참조가모두null이면출력없이끝난다. 원본main은둘다참조가있으므로Text출력이다. 있음 없음 있음 없음 export 호출 원문은 둘 다 참조 textExporter 참조 있음? Text 호출 가입 인사=40 CSV 조건은 검사 안 함 csvExporter 참조 있음? CSV 호출 가입 인사,40 둘 다 null 출력 없이 종료
ConcreteExporterSwitchBug의 두 필드 조합과 export 호출 결과
필드 조합textExportercsvExporter선택하는 실행
설정 없음nullnull두 조건 모두 거짓입니다. exporter를 호출하지 않고 출력 없이 끝납니다.
Text만 설정참조 있음null첫 if에서 TextExporter를 호출합니다.
CSV만 설정null참조 있음첫 조건은 거짓이고, else if에서 CsvExporter를 호출합니다.
둘 다 설정

원문 main

참조 있음참조 있음첫 if에서 TextExporter를 호출합니다. CSV 조건은 검사하지 않습니다.
설정 없음
textExporter = null, csvExporter = null. 두 조건 모두 거짓이므로 exporter 호출이나 출력 없이 끝납니다.
Text만 설정
textExporter는 참조를 담고 csvExporter = null입니다. 첫 if에서 TextExporter를 호출합니다.
CSV만 설정
textExporter = null이고 csvExporter는 참조를 담습니다. 첫 조건은 거짓이며 else if에서 CsvExporter를 호출합니다.
둘 다 설정 — 원문 main
두 필드 모두 참조를 담습니다. 첫 if에서 TextExporter를 호출하므로 CSV 조건은 검사하지 않습니다.

main은 setTextExporter(...) 다음에 setCsvExporter(...)를 호출하지만, 첫 필드를 비우지 않습니다. 이어 export("가입 인사", 40)를 호출한 실제 출력은 다음 한 줄입니다.

가입 인사=40

다른 세 조합은 원문 main에서 실행한 단계가 아니라 같은 메서드의 조건으로 판별한 경우입니다. 둘 다 설정된 상태의 선택은 모호한 것이 아니라 코드 순서상 Text 우선입니다.

이 예의 조건

  • 원 main은 text와 csv 둘 다 참조가 있습니다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2textExporter 참조가 있으면 Text를 호출하고 CSV 조건은 검사하지 않는다. 없을 때만 csvExporter를 검사한다. 두 참조가 모두 null이면 출력 없이 끝난다. 원본 main은 둘 다 참조가 있으므로 Text 출력이다.
원 도표의 설명과 주석
구분내용
설명textExporter 참조가 있으면 Text를 호출하고 CSV 조건은 검사하지 않는다. 없을 때만 csvExporter를 검사한다. 두 참조가 모두 null이면 출력 없이 끝난다. 원본 main은 둘 다 참조가 있으므로 Text 출력이다.
전체 읽기 정보두 exporter 필드의 조합과 먼저 선택되는 분기 두 exporter 필드의 조합과 먼저 선택되는 분기 ConcreteExporterSwitchBug의 export는 textExporter부터 검사한다. 두 필드가 모두 null이면 출력 없이 끝나고, 둘 다 참조를 담으면 CSV를 마지막에 설정했어도 TextExporter를 호출한다. 두 필드가 남아 있으면 먼저 검사하는 Text가 실행됩니다 ConcreteExporterSwitchBug 의 export 는 textExporter 를 먼저 검사합니다. 표는 두 필드의 null 여부에 따른 소스 분기를 비교합니다. ConcreteExporterSwitchBug의 두 필드 조합과 export 호출 결과 필드 조합 textExporter csvExporter 선택하는 실행 설정 없음 null null 두 조건 모두 거짓입니다. exporter를 호출하지 않고 출력 없이 끝납니다. Text만 설정 참조 있음 null 첫 if 에서 TextExporter 를 호출합니다. CSV만 설정 null 참조 있음 첫 조건은 거짓이고, else if 에서 CsvExporter 를 호출합니다. 둘 다 설정 원문 main 참조 있음 참조 있음 첫 if 에서 TextExporter 를 호출합니다. CSV 조건은 검사하지 않습니다. 설정 없음 textExporter = null , csvExporter = null . 두 조건 모두 거짓이므로 exporter 호출이나 출력 없이 끝납니다. Text만 설정 textExporter 는 참조를 담고 csvExporter = null 입니다. 첫 if 에서 TextExporter 를 호출합니다. CSV만 설정 textExporter = null 이고 csvExporter 는 참조를 담습니다. 첫 조건은 거짓이며 else if 에서 CsvExporter 를 호출합니다. 둘 다 설정 — 원문 main 두 필드 모두 참조를 담습니다. 첫 if 에서 TextExporter 를 호출하므로 CSV 조건은 검사하지 않습니다. main 은 setTextExporter(...) 다음에 setCsvExporter(...) 를 호출하지만, 첫 필드를 비우지 않습니다. 이어 export("가입 인사", 40) 를 호출한 실제 출력은 다음 한 줄입니다. 가입 인사=40 다른 세 조합은 원문 main 에서 실행한 단계가 아니라 같은 메서드의 조건으로 판별한 경우입니다. 둘 다 설정된 상태의 선택은 모호한 것이 아니라 코드 순서상 Text 우선입니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
첫 필드 검사

export가 textExporter 참조부터 확인합니다.

  • 원 조건: 있음
  • 선택: Text
  • 첫 조건의 true가 뒤 조건 검사를 막습니다.
원 출력

Text로 가입 인사를 출력합니다.

  • 출력: 가입 인사=40
  • CSV 조건: 미검사
  • 두 필드가 있다는 것이 두 출력을 뜻하지 않습니다.
별도 부재 분기

Text가 없을 때만 CSV를 검사합니다.

  • CSV 있음: 가입 인사,40
  • 둘 다 null: 출력 없이 종료
  • 순서가 우선순위와 동작이 없는 경계를 만듭니다.

두 exporter 필드의 조합과 먼저 선택되는 분기

두 필드가 모두 참조를 가지면 첫 text 조건이 참이 되어 Text를 호출하고 CSV 조건은 검사하지 않습니다. 설정 상태와 첫 분기의 우선순위를 실행 순서로 드러낼 수 있습니다.

두 exporter 필드의 조합과 먼저 선택되는 분기

ConcreteExporterSwitchBug의 export는 textExporter부터 검사한다. 두 필드가 모두 null이면 출력 없이 끝나고, 둘 다 참조를 담으면 CSV를 마지막에 설정했어도 TextExporter를 호출한다.

두 필드가 남아 있으면 먼저 검사하는 Text가 실행됩니다

ConcreteExporterSwitchBug의 export는 textExporter를 먼저 검사합니다. 표는 두 필드의 null 여부에 따른 소스 분기를 비교합니다.

두 필드가 남아 있으면 먼저 검사하는 Text가 실행됩니다 textExporter 참조가 있으면 Text를 호출하고 CSV조건은검사하지않는다. 없을때만csvExporter를검사한다. 두참조가모두null이면출력없이끝난다. 원본main은둘다참조가있으므로Text출력이다. 있음 없음 있음 없음 export 호출 원문은 두 필드 모두 참조 textExporter 참조 있음? Text 호출 가입 인사=40 CSV 조건은 검사 안 함 csvExporter 참조 있음? CSV 호출 가입 인사,40 둘 다 null 출력 없이 종료
두 필드가 남아 있으면 먼저 검사하는 Text가 실행됩니다 textExporter 참조가 있으면 Text를 호출하고 CSV조건은검사하지않는다. 없을때만csvExporter를검사한다. 두참조가모두null이면출력없이끝난다. 원본main은둘다참조가있으므로Text출력이다. 있음 없음 있음 없음 export 호출 원문은 둘 다 참조 textExporter 참조 있음? Text 호출 가입 인사=40 CSV 조건은 검사 안 함 csvExporter 참조 있음? CSV 호출 가입 인사,40 둘 다 null 출력 없이 종료
ConcreteExporterSwitchBug의 두 필드 조합과 export 호출 결과
필드 조합textExportercsvExporter선택하는 실행
설정 없음nullnull두 조건 모두 거짓입니다. exporter를 호출하지 않고 출력 없이 끝납니다.
Text만 설정참조 있음null첫 if에서 TextExporter를 호출합니다.
CSV만 설정null참조 있음첫 조건은 거짓이고, else if에서 CsvExporter를 호출합니다.
둘 다 설정

원문 main

참조 있음참조 있음첫 if에서 TextExporter를 호출합니다. CSV 조건은 검사하지 않습니다.
설정 없음
textExporter = null, csvExporter = null. 두 조건 모두 거짓이므로 exporter 호출이나 출력 없이 끝납니다.
Text만 설정
textExporter는 참조를 담고 csvExporter = null입니다. 첫 if에서 TextExporter를 호출합니다.
CSV만 설정
textExporter = null이고 csvExporter는 참조를 담습니다. 첫 조건은 거짓이며 else if에서 CsvExporter를 호출합니다.
둘 다 설정 — 원문 main
두 필드 모두 참조를 담습니다. 첫 if에서 TextExporter를 호출하므로 CSV 조건은 검사하지 않습니다.

main은 setTextExporter(...) 다음에 setCsvExporter(...)를 호출하지만, 첫 필드를 비우지 않습니다. 이어 export("가입 인사", 40)를 호출한 실제 출력은 다음 한 줄입니다.

가입 인사=40

다른 세 조합은 원문 main에서 실행한 단계가 아니라 같은 메서드의 조건으로 판별한 경우입니다. 둘 다 설정된 상태의 선택은 모호한 것이 아니라 코드 순서상 Text 우선입니다.

이 예의 조건

  • 원 main은 text와 csv 둘 다 참조가 있습니다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2textExporter 참조가 있으면 Text를 호출하고 CSV 조건은 검사하지 않는다. 없을 때만 csvExporter를 검사한다. 두 참조가 모두 null이면 출력 없이 끝난다. 원본 main은 둘 다 참조가 있으므로 Text 출력이다.
원 도표의 설명과 주석
구분내용
설명textExporter 참조가 있으면 Text를 호출하고 CSV 조건은 검사하지 않는다. 없을 때만 csvExporter를 검사한다. 두 참조가 모두 null이면 출력 없이 끝난다. 원본 main은 둘 다 참조가 있으므로 Text 출력이다.
전체 읽기 정보두 exporter 필드의 조합과 먼저 선택되는 분기 두 exporter 필드의 조합과 먼저 선택되는 분기 ConcreteExporterSwitchBug의 export는 textExporter부터 검사한다. 두 필드가 모두 null이면 출력 없이 끝나고, 둘 다 참조를 담으면 CSV를 마지막에 설정했어도 TextExporter를 호출한다. 두 필드가 남아 있으면 먼저 검사하는 Text가 실행됩니다 ConcreteExporterSwitchBug 의 export 는 textExporter 를 먼저 검사합니다. 표는 두 필드의 null 여부에 따른 소스 분기를 비교합니다. ConcreteExporterSwitchBug의 두 필드 조합과 export 호출 결과 필드 조합 textExporter csvExporter 선택하는 실행 설정 없음 null null 두 조건 모두 거짓입니다. exporter를 호출하지 않고 출력 없이 끝납니다. Text만 설정 참조 있음 null 첫 if 에서 TextExporter 를 호출합니다. CSV만 설정 null 참조 있음 첫 조건은 거짓이고, else if 에서 CsvExporter 를 호출합니다. 둘 다 설정 원문 main 참조 있음 참조 있음 첫 if 에서 TextExporter 를 호출합니다. CSV 조건은 검사하지 않습니다. 설정 없음 textExporter = null , csvExporter = null . 두 조건 모두 거짓이므로 exporter 호출이나 출력 없이 끝납니다. Text만 설정 textExporter 는 참조를 담고 csvExporter = null 입니다. 첫 if 에서 TextExporter 를 호출합니다. CSV만 설정 textExporter = null 이고 csvExporter 는 참조를 담습니다. 첫 조건은 거짓이며 else if 에서 CsvExporter 를 호출합니다. 둘 다 설정 — 원문 main 두 필드 모두 참조를 담습니다. 첫 if 에서 TextExporter 를 호출하므로 CSV 조건은 검사하지 않습니다. main 은 setTextExporter(...) 다음에 setCsvExporter(...) 를 호출하지만, 첫 필드를 비우지 않습니다. 이어 export("가입 인사", 40) 를 호출한 실제 출력은 다음 한 줄입니다. 가입 인사=40 다른 세 조합은 원문 main 에서 실행한 단계가 아니라 같은 메서드의 조건으로 판별한 경우입니다. 둘 다 설정된 상태의 선택은 모호한 것이 아니라 코드 순서상 Text 우선입니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
첫 필드 검사

export가 textExporter 참조부터 확인합니다.

  • 원 조건: 있음
  • 선택: Text
  • 첫 조건의 true가 뒤 조건 검사를 막습니다.
원 출력

Text로 가입 인사를 출력합니다.

  • 출력: 가입 인사=40
  • CSV 조건: 미검사
  • 두 필드가 있다는 것이 두 출력을 뜻하지 않습니다.
별도 부재 분기

Text가 없을 때만 CSV를 검사합니다.

  • CSV 있음: 가입 인사,40
  • 둘 다 null: 출력 없이 종료
  • 순서가 우선순위와 동작이 없는 경계를 만듭니다.

그림에서 두 필드가 모두 null인 조합도 눈여겨보세요. 이때 export는 아무것도 출력하지 않고 조용히 끝나므로, 형식 설정을 잊었다는 사실을 알아차리기 어렵습니다.

이 버그의 근본 원인은 '형식은 한 번에 하나만 선택된다'는 규칙이 코드 구조 어디에도 드러나 있지 않다는 데 있습니다. 필드가 두 개이니 둘 다 채워질 수 있고, 그때 무엇을 실행할지는 if 문을 쓴 순서가 정합니다. 실제로 setter 호출 순서를 바꿔 CSV를 먼저, Text를 나중에 설정해도 출력은 똑같이 가입 인사=40입니다. 사용자가 마지막으로 고른 형식이 아니라 코드에서 먼저 검사하는 형식이 이기는 것입니다.

형식이 늘면 이런 잘못된 조합도 빠르게 늘어납니다. 필드 하나는 null이거나 아니거나 두 가지 상태이므로, 필드가 n개면 조합은 2ⁿ가지입니다. 그중 정확히 하나만 설정된 정상 상태가 n가지, 아무것도 설정하지 않은 상태가 1가지이고, 나머지 2ⁿ − n − 1가지는 둘 이상이 설정된 잘못된 상태입니다. 형식이 두 개일 때는 잘못된 조합이 1가지뿐이지만 세 개면 4가지, 네 개면 11가지가 됩니다. 게다가 JSON을 추가하려면 필드와 setter와 분기를 하나씩 더 만들어야 하고, 새 분기를 if 사슬의 어디에 넣느냐에 따라 형식 사이의 우선순위까지 달라집니다.

다른 필드를 비워서 하나만 남기기

버그를 고치는 가장 직접적인 방법은 형식을 선택할 때 다른 필드를 모두 비우는 것입니다. 다음 서비스의 useText는 text를 채우면서 csv를 null로 만들고, useCsv는 반대로 합니다. 형식이 하나도 설정되지 않은 채 export를 호출하면 조용히 넘어가지 않고 IllegalStateException을 던지도록 고쳤습니다. main은 Text를 선택해 한 번, CSV로 바꿔 한 번 내보냅니다.

src/SingleSlotExporter.java
public final class SingleSlotExporter {
    public static void main(String[] args) {
        ExportService service = new ExportService();

        service.useText(new TextExporter());
        service.export("가입 인사", 40);
        service.useCsv(new CsvExporter());
        service.export("로그인 구현", 50);
    }

    private static final class ExportService {
        private TextExporter text;
        private CsvExporter csv;

        void useText(TextExporter value) {
            text = value;
            csv = null;
        }

        void useCsv(CsvExporter value) {
            csv = value;
            text = null;
        }

        void export(String title, int viewCount) {
            if (text != null) text.export(title, viewCount);
            else if (csv != null) csv.export(title, viewCount);
            else throw new IllegalStateException("no exporter");
        }
    }

    private static final class TextExporter {
        void export(String title, int viewCount) { System.out.println(title + "=" + viewCount); }
    }

    private static final class CsvExporter {
        void export(String title, int viewCount) { System.out.println(title + "," + viewCount); }
    }
}
가입 인사=40
로그인 구현,50

첫 줄은 useText 뒤에 text만 남아 있으니 Text 형식으로, 둘째 줄은 useCsv가 text를 비웠으니 CSV 형식으로 나옵니다. 앞 예제와 달리 마지막에 고른 형식이 실제로 쓰이는 것은, 선택 메서드가 끝날 때마다 두 필드 중 많아야 하나만 채워져 있기 때문입니다. 아래 그림은 각 선택 메서드가 끝난 뒤 두 필드의 상태와, 그 상태에서 export가 출력하는 내용을 보여 줍니다. 마지막 장면의 useText(null)은 바로 뒤에서 설명합니다.

구현을 교체할 때 반대 필드를 비우는 효과

useText와 useCsv는 다른 필드를 비우며 순차 선택 뒤 각 형식으로 출력하고 useText(null)은 둘 다 null이 됩니다. 완료 시점의 상태 변화와 동시 원자성 미보장을 유지합니다.

구현을 교체할 때 반대 필드를 비우는 효과

SingleSlotExporter의 main은 useText 후 text만 남겨 가입 인사=40을 출력하고, useCsv 후 text를 null로 비워 로그인 구현,50을 출력한다. 여전히 구체 타입 필드 두 개이며 null 입력을 거부하지 않는다.

새 구현을 선택하면서 이전 필드를 null로 비웁니다

SingleSlotExporter의 main에서 각 선택 메서드가 끝난 뒤, 다음 export 호출이 읽는 상태입니다.

새 구현을 선택하면서 이전 필드를 null로 비웁니다 선택메서드가복귀한순차상태를보여준다. useText는text참조/csvnull,useCsv는textnull/csv참조로바꾼다. 별도반례useText(null)는둘다null이므로뒤export는예외다. 동시원자성보장이아니다. useText useCsv useText(null) 시작 상태 text = null / csv = null Text 선택 완료 text = Text 참조 csv = null 뒤의 export 가입 인사=40 CSV 선택 완료 text = null csv = CSV 참조 뒤의 export 로그인 구현,50 별도 반례 둘 다 null export → no exporter 예외 각 메서드가 끝난 뒤를 읽음 · 동시 원자성 보장 아님
새 구현을 선택하면서 이전 필드를 null로 비웁니다선택메서드가복귀한순차상태를보여준다. useText는text참조/csvnull,useCsv는textnull/csv참조로바꾼다. 별도반례useText(null)는둘다null이므로뒤export는예외다. 동시원자성보장이아니다.
시작 상태text = nullcsv = null
useText(...)useText(...)시작 상태 · text = null · csv = null → Text 선택 완료 · text = Text 참조 · csv = null
Text 선택 완료text = Text 참조csv = null
연결 관계Text 선택 완료 · text = Text 참조 · csv = null → 뒤의 export · 가입 인사=40
뒤의 export가입 인사=40
useCsv(...)useCsv(...)뒤의 export · 가입 인사=40 → CSV 선택 완료 · text = null · csv = CSV 참조
CSV 선택 완료text = nullcsv = CSV 참조
연결 관계CSV 선택 완료 · text = null · csv = CSV 참조 → 뒤의 export · 로그인 구현,50
뒤의 export로그인 구현,50
연결 관계뒤의 export · 로그인 구현,50 → 별도 반례 · useText(null) · 두 필드 null → 예외
별도 반례useText(null)두 필드 null → 예외

메서드 복귀 뒤의 순차 상태

SingleSlotExporter의 두 선택 호출 뒤 필드 상태와 실제 출력
원문의 선택 호출복귀 뒤 두 필드뒤따르는 export의 출력
useText(...)text: TextExporter 참조

csv = null

가입 인사=40
useCsv(...)csv: CsvExporter 참조

text = null

로그인 구현,50
  1. useText(...) 복귀 뒤

    text는 새 TextExporter 참조이고 csv = null입니다. 이어 export("가입 인사", 40)를 호출하면 가입 인사=40을 출력합니다.

  2. useCsv(...) 복귀 뒤

    csv는 새 CsvExporter 참조이고 이전 text는 null로 비워집니다. 이어 export("로그인 구현", 50)를 호출하면 로그인 구현,50을 출력합니다.

main은 useText에 new TextExporter(), useCsv에 new CsvExporter()를 전달합니다. 각 메서드는 전달받은 참조를 저장하고 다른 필드에 null을 대입합니다.

여전히 구체 타입 필드 두 개입니다. 선택 메서드는 null 입력을 거부하지 않으므로 “항상 하나가 설정됨”을 보장하지 않습니다. 두 필드가 null인 채 export를 호출하면 IllegalStateException("no exporter")가 발생합니다. 이 경계는 원문 main의 두 호출에는 나타나지 않습니다.

이 예의 조건

  • 선택 메서드 정상 복귀 뒤 상태이며 동시 원자성 보장이 아닙니다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2선택 메서드가 복귀한 순차 상태를 보여 준다. useText는 text 참조/csv null, useCsv는 text null/csv 참조로 바꾼다. 별도 반례 useText(null)은 둘 다 null이므로 뒤 export는 예외를 던진다. 동시 원자성을 보장하지 않는다.
원 도표의 설명과 주석
구분내용
설명선택 메서드가 복귀한 순차 상태를 보여 준다. useText는 text 참조/csv null, useCsv는 text null/csv 참조로 바꾼다. 별도 반례 useText(null)은 둘 다 null이므로 뒤 export는 예외를 던진다. 동시 원자성을 보장하지 않는다.
전체 읽기 정보구현을 교체할 때 반대 필드를 비우는 효과 구현을 교체할 때 반대 필드를 비우는 효과 SingleSlotExporter의 main은 useText 후 text만 남겨 가입 인사=40을 출력하고, useCsv 후 text를 null로 비워 로그인 구현,50을 출력한다. 여전히 구체 타입 필드 두 개이며 null 입력을 거부하지 않는다. 새 구현을 선택하면서 이전 필드를 null로 비웁니다 SingleSlotExporter 의 main 에서 각 선택 메서드가 끝난 뒤, 다음 export 호출이 읽는 상태입니다. SingleSlotExporter의 두 선택 호출 뒤 필드 상태와 실제 출력 원문의 선택 호출 복귀 뒤 두 필드 뒤따르는 export의 출력 useText(...) text : TextExporter 참조 csv = null 가입 인사=40 useCsv(...) csv : CsvExporter 참조 text = null 로그인 구현,50 useText(...) 복귀 뒤 text 는 새 TextExporter 참조이고 csv = null 입니다. 이어 export("가입 인사", 40) 를 호출하면 가입 인사=40 을 출력합니다. useCsv(...) 복귀 뒤 csv 는 새 CsvExporter 참조이고 이전 text 는 null 로 비워집니다. 이어 export("로그인 구현", 50) 를 호출하면 로그인 구현,50 을 출력합니다. main 은 useText 에 new TextExporter() , useCsv 에 new CsvExporter() 를 전달합니다. 각 메서드는 전달받은 참조를 저장하고 다른 필드에 null 을 대입합니다. 여전히 구체 타입 필드 두 개입니다. 선택 메서드는 null 입력을 거부하지 않으므로 “항상 하나가 설정됨”을 보장하지 않습니다. 두 필드가 null인 채 export 를 호출하면 IllegalStateException("no exporter") 가 발생합니다. 이 경계는 원문 main 의 두 호출에는 나타나지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
Text 선택

useText가 text 참조를 두고 csv를 비웁니다.

  • text: Text 참조
  • csv: null, 뒤 출력 가입 인사=40
  • 이전 다른 전략을 남기지 않도록 선택이 두 필드를 갱신합니다.
CSV 선택

useCsv가 text를 비우고 csv 참조를 둡니다.

  • text: null
  • csv: CSV 참조, 로그인 구현,50
  • 새 선택이 이전 Text 우선 조건을 제거합니다.
null 반례

별도 useText(null)은 둘 다 null을 만듭니다.

  • 필드: null/null
  • export: no exporter 예외
  • 선택 API의 null 허용이 정상 내보내기를 보장하지 않습니다.

구현을 교체할 때 반대 필드를 비우는 효과

useText와 useCsv는 다른 필드를 비우며 순차 선택 뒤 각 형식으로 출력하고 useText(null)은 둘 다 null이 됩니다. 완료 시점의 상태 변화와 동시 원자성 미보장을 유지합니다.

구현을 교체할 때 반대 필드를 비우는 효과

SingleSlotExporter의 main은 useText 후 text만 남겨 가입 인사=40을 출력하고, useCsv 후 text를 null로 비워 로그인 구현,50을 출력한다. 여전히 구체 타입 필드 두 개이며 null 입력을 거부하지 않는다.

새 구현을 선택하면서 이전 필드를 null로 비웁니다

SingleSlotExporter의 main에서 각 선택 메서드가 끝난 뒤, 다음 export 호출이 읽는 상태입니다.

새 구현을 선택하면서 이전 필드를 null로 비웁니다 선택메서드가복귀한순차상태를보여준다. useText는text참조/csvnull,useCsv는textnull/csv참조로바꾼다. 별도반례useText(null)는둘다null이므로뒤export는예외다. 동시원자성보장이아니다. useText useCsv useText(null) 시작 상태 text = null / csv = null Text 선택 완료 text = Text 참조 csv = null 뒤의 export 가입 인사=40 CSV 선택 완료 text = null csv = CSV 참조 뒤의 export 로그인 구현,50 별도 반례 둘 다 null export → no exporter 예외 각 메서드가 끝난 뒤를 읽음 · 동시 원자성 보장 아님
새 구현을 선택하면서 이전 필드를 null로 비웁니다선택메서드가복귀한순차상태를보여준다. useText는text참조/csvnull,useCsv는textnull/csv참조로바꾼다. 별도반례useText(null)는둘다null이므로뒤export는예외다. 동시원자성보장이아니다.
시작 상태text = nullcsv = null
useText(...)useText(...)시작 상태 · text = null · csv = null → Text 선택 완료 · text = Text 참조 · csv = null
Text 선택 완료text = Text 참조csv = null
연결 관계Text 선택 완료 · text = Text 참조 · csv = null → 뒤의 export · 가입 인사=40
뒤의 export가입 인사=40
useCsv(...)useCsv(...)뒤의 export · 가입 인사=40 → CSV 선택 완료 · text = null · csv = CSV 참조
CSV 선택 완료text = nullcsv = CSV 참조
연결 관계CSV 선택 완료 · text = null · csv = CSV 참조 → 뒤의 export · 로그인 구현,50
뒤의 export로그인 구현,50
연결 관계뒤의 export · 로그인 구현,50 → 별도 반례 · useText(null) · 두 필드 null → 예외
별도 반례useText(null)두 필드 null → 예외

메서드 복귀 뒤의 순차 상태

SingleSlotExporter의 두 선택 호출 뒤 필드 상태와 실제 출력
원문의 선택 호출복귀 뒤 두 필드뒤따르는 export의 출력
useText(...)text: TextExporter 참조

csv = null

가입 인사=40
useCsv(...)csv: CsvExporter 참조

text = null

로그인 구현,50
  1. useText(...) 복귀 뒤

    text는 새 TextExporter 참조이고 csv = null입니다. 이어 export("가입 인사", 40)를 호출하면 가입 인사=40을 출력합니다.

  2. useCsv(...) 복귀 뒤

    csv는 새 CsvExporter 참조이고 이전 text는 null로 비워집니다. 이어 export("로그인 구현", 50)를 호출하면 로그인 구현,50을 출력합니다.

main은 useText에 new TextExporter(), useCsv에 new CsvExporter()를 전달합니다. 각 메서드는 전달받은 참조를 저장하고 다른 필드에 null을 대입합니다.

여전히 구체 타입 필드 두 개입니다. 선택 메서드는 null 입력을 거부하지 않으므로 “항상 하나가 설정됨”을 보장하지 않습니다. 두 필드가 null인 채 export를 호출하면 IllegalStateException("no exporter")가 발생합니다. 이 경계는 원문 main의 두 호출에는 나타나지 않습니다.

이 예의 조건

  • 선택 메서드 정상 복귀 뒤 상태이며 동시 원자성 보장이 아닙니다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2선택 메서드가 복귀한 순차 상태를 보여 준다. useText는 text 참조/csv null, useCsv는 text null/csv 참조로 바꾼다. 별도 반례 useText(null)은 둘 다 null이므로 뒤 export는 예외를 던진다. 동시 원자성을 보장하지 않는다.
원 도표의 설명과 주석
구분내용
설명선택 메서드가 복귀한 순차 상태를 보여 준다. useText는 text 참조/csv null, useCsv는 text null/csv 참조로 바꾼다. 별도 반례 useText(null)은 둘 다 null이므로 뒤 export는 예외를 던진다. 동시 원자성을 보장하지 않는다.
전체 읽기 정보구현을 교체할 때 반대 필드를 비우는 효과 구현을 교체할 때 반대 필드를 비우는 효과 SingleSlotExporter의 main은 useText 후 text만 남겨 가입 인사=40을 출력하고, useCsv 후 text를 null로 비워 로그인 구현,50을 출력한다. 여전히 구체 타입 필드 두 개이며 null 입력을 거부하지 않는다. 새 구현을 선택하면서 이전 필드를 null로 비웁니다 SingleSlotExporter 의 main 에서 각 선택 메서드가 끝난 뒤, 다음 export 호출이 읽는 상태입니다. SingleSlotExporter의 두 선택 호출 뒤 필드 상태와 실제 출력 원문의 선택 호출 복귀 뒤 두 필드 뒤따르는 export의 출력 useText(...) text : TextExporter 참조 csv = null 가입 인사=40 useCsv(...) csv : CsvExporter 참조 text = null 로그인 구현,50 useText(...) 복귀 뒤 text 는 새 TextExporter 참조이고 csv = null 입니다. 이어 export("가입 인사", 40) 를 호출하면 가입 인사=40 을 출력합니다. useCsv(...) 복귀 뒤 csv 는 새 CsvExporter 참조이고 이전 text 는 null 로 비워집니다. 이어 export("로그인 구현", 50) 를 호출하면 로그인 구현,50 을 출력합니다. main 은 useText 에 new TextExporter() , useCsv 에 new CsvExporter() 를 전달합니다. 각 메서드는 전달받은 참조를 저장하고 다른 필드에 null 을 대입합니다. 여전히 구체 타입 필드 두 개입니다. 선택 메서드는 null 입력을 거부하지 않으므로 “항상 하나가 설정됨”을 보장하지 않습니다. 두 필드가 null인 채 export 를 호출하면 IllegalStateException("no exporter") 가 발생합니다. 이 경계는 원문 main 의 두 호출에는 나타나지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
Text 선택

useText가 text 참조를 두고 csv를 비웁니다.

  • text: Text 참조
  • csv: null, 뒤 출력 가입 인사=40
  • 이전 다른 전략을 남기지 않도록 선택이 두 필드를 갱신합니다.
CSV 선택

useCsv가 text를 비우고 csv 참조를 둡니다.

  • text: null
  • csv: CSV 참조, 로그인 구현,50
  • 새 선택이 이전 Text 우선 조건을 제거합니다.
null 반례

별도 useText(null)은 둘 다 null을 만듭니다.

  • 필드: null/null
  • export: no exporter 예외
  • 선택 API의 null 허용이 정상 내보내기를 보장하지 않습니다.

하지만 버그를 고친 대가로 코드는 더 늘었습니다. 형식을 추가할 때마다 필드 하나, 선택 메서드 하나, export의 분기 하나가 늘어나는 것은 같고, 여기에 기존의 모든 선택 메서드에 새 필드를 비우는 줄을 추가해야 합니다. 선택 메서드가 n개이고 메서드마다 나머지 n − 1개의 필드를 비우므로, null을 대입하는 줄은 형식이 두 개일 때 2줄, 세 개면 6줄, 네 개면 12줄로 늘어납니다. 새 형식을 추가하면서 기존 메서드 하나에 비우는 줄을 빠뜨리면 앞에서 본 버그가 그대로 돌아옵니다.

또 이 코드가 막은 것은 '둘 이상이 선택된 상태'뿐입니다. '최대 하나'와 '반드시 하나'는 다른 조건입니다. useText(null)을 호출하면 text에 null이 들어가고 csv도 비워지므로 두 필드가 모두 null이 됩니다. 원래 main의 끝에 다음 두 줄을 덧붙여 실행해 보세요.

service.useText(null);
service.export("추가 인사", 30);
가입 인사=40
로그인 구현,50
Exception in thread "main" java.lang.IllegalStateException: no exporter
	at SingleSlotExporter$ExportService.export(SingleSlotExporter.java:30)
	at SingleSlotExporter.main(SingleSlotExporter.java:10)

처음 두 줄은 그대로 출력되고, 마지막 export에서 예외가 납니다. 두 필드가 모두 비어 있으니 앞의 두 조건이 거짓이 되어 마지막 else에 도달한 것입니다. 문제는 잘못을 저지른 곳과 예외가 난 곳이 다르다는 점입니다. 실수는 9번째 줄의 useText(null)에서 일어났지만, 스택 트레이스가 가리키는 곳은 10번째 줄의 export 호출과 그 안에서 예외를 던진 30번째 줄입니다. 이 코드는 잘못된 값이 들어온 순간이 아니라 나중에 그 값을 쓸 때에야 문제를 알려 주는 것입니다. 두 호출 사이에 다른 코드가 많이 실행되었다면 원인을 찾기가 훨씬 어려워집니다.

더 깊이: 여러 스레드가 함께 쓰면

useCsv의 두 대입문 csv = value;와 text = null; 사이에는 짧게나마 두 필드가 모두 채워진 순간이 있습니다. 스레드가 하나뿐인 프로그램에서는 메서드가 끝난 뒤에야 export가 실행되므로 이 순간이 드러나지 않습니다. 하지만 다른 스레드가 동시에 export를 호출하면 이 중간 상태를 볼 수 있고, 동기화를 하지 않으면 한 스레드가 쓴 값이 다른 스레드에게 쓴 순서대로 보인다는 보장도 없습니다. 이 문제와 해결 방법은 19장에서 가시성과 동기화를 배우며 다룹니다.

필드를 하나로 줄이면 버그가 생길 자리가 없다

결국 필드를 비우는 방법은 증상을 막았을 뿐, '형식은 하나'라는 규칙은 여전히 사람이 기억하고 지켜야 합니다. 그렇다면 형식마다 필드를 따로 두는 대신, 어떤 형식이든 담을 수 있는 공통 타입의 필드를 하나만 두면 어떨까요? 필드가 하나뿐이니 두 형식이 동시에 선택될 방법이 구조적으로 사라지고, 형식을 바꾸는 일은 그 필드에 다른 객체를 넣는 일이 됩니다. 그 공통 타입이 바로 앞 글에서 만든 역할 인터페이스입니다.

지금까지 본 설계마다 JSON 형식을 추가할 때 기존 코드에서 무엇을 고쳐야 하는지 모아 보면 차이가 분명해집니다. 어느 설계든 JsonExporter 클래스 자체는 새로 만들어야 하므로, 표에는 그 밖에 고쳐야 하는 곳만 적었습니다.

설계JSON을 추가할 때 기존 코드에서 고칠 곳
형식 하나에 직접 의존 (SingleConcreteExporter)필드와 생성자의 타입을 바꿔야 하고, 바꾸면 Text를 쓸 수 없음
옵션 문자열로 분기 (BranchedExportService)필드 추가, if 사슬에 분기 추가
형식마다 setter (ConcreteExporterSwitchBug)필드, setter, 분기 추가. 분기를 넣는 위치가 우선순위를 정함
선택할 때 다른 필드 비우기 (SingleSlotExporter)필드, 선택 메서드, 분기 추가. 기존 선택 메서드마다 비우는 줄 추가
역할 인터페이스 필드 하나 (다음 글)서비스는 그대로 두고, 구현을 골라 넣어 주는 코드만 고침

마지막 줄에서도 고칠 곳이 완전히 사라지지는 않습니다. 옵션 문자열이나 사용자 선택을 보고 어떤 구현 객체를 만들지 정하는 코드는 새 형식을 알아야 하기 때문입니다. 역할 분리는 모든 수정을 없애 주는 것이 아니라, 수정이 필요한 곳을 핵심 서비스 밖의 좁은 곳으로 모아 주는 방법입니다.

언제 역할로 분리할까

그렇다면 구체 클래스에 의존하는 코드를 볼 때마다 인터페이스를 만들어야 할까요? 첫 예제에서 보았듯 그렇지는 않습니다. 다음 질문에 답해 보면 역할을 분리할 때가 되었는지 판단할 수 있습니다.

  • 클라이언트 입장에서 같은 일인가? Text, CSV, JSON 내보내기는 모두 '게시글을 문자열로 바꾼다'는 같은 일을 서로 다른 방식으로 합니다. 하는 일 자체가 다르다면 하나의 역할로 묶을 수 없습니다.
  • 입력과 결과, 실패 방식을 하나로 정할 수 있는가? 모든 형식이 같은 입력(제목과 조회 수)을 받아 같은 종류의 결과(문자열)를 내고, 실패도 같은 방식으로 알려야 하나의 메서드로 표현할 수 있습니다.
  • 바뀌는 속도가 다른가? 새 형식은 자주 추가되는데 시작과 끝을 출력하는 공통 흐름은 거의 바뀌지 않는다면, 자주 바뀌는 쪽을 떼어 내는 효과가 큽니다.
  • 실행 중에 바꾸거나 밖에서 넣어 줘야 하는가? 사용자가 고른 형식을 쓰거나 테스트에서 가짜 객체를 넣어야 한다면, 구현을 서비스 밖에서 만들어 넣을 수 있어야 합니다.
  • 구현 선택은 어디서 할 것인가? 옵션을 해석해 구현을 고르는 코드는 역할을 도입한 뒤에도 남습니다. 그 코드를 서비스 밖 한곳에 모을 수 있는지 생각해 봅니다.

게시글 내보내기는 모든 질문에 '예'라고 답할 수 있으므로 역할로 분리하기 좋은 경우입니다. 반대로 구현이 하나뿐이고 앞으로 늘어날 이유도 없다면, 첫 예제처럼 구체 클래스를 직접 쓰는 편이 더 단순합니다. 중요한 것은 두 번째 구현이 실제로 나타났을 때 이 질문들을 다시 던져 보는 것입니다.

정리

  • 구현이 하나이고 바뀔 일이 없다면 구체 클래스에 직접 의존해도 괜찮습니다. 다만 그 구조에서는 구현을 바꾸거나 추가할 때마다 클라이언트를 고쳐야 합니다.
  • 형식마다 필드와 if 분기를 두면 서비스가 옵션 해석, 객체 생성, 공통 흐름, 형식별 호출을 모두 떠안고, 형식이 늘 때마다 같은 클래스를 다시 편집하게 됩니다.
  • 형식마다 null일 수 있는 필드를 따로 두면 둘 이상이 동시에 설정되는 잘못된 조합이 생기고, 그때 무엇이 실행될지는 if 문의 순서가 정합니다. 필드를 비우는 방식으로 막아도 수정할 곳이 늘고, '반드시 하나'까지 지켜 주지는 못합니다.
  • 공통 역할 타입의 필드를 하나만 두면 잘못된 조합이 생길 자리가 구조적으로 사라집니다. 같은 일을 하고, 입력과 결과를 하나로 정할 수 있고, 바뀌는 속도가 다를 때 역할로 분리합니다.

연습 문제

문제 1: setter 순서를 바꾸면

ConcreteExporterSwitchBug의 main을 다음 두 가지로 각각 바꾸면 무엇이 출력될까요? 실행하기 전에 표처럼 두 필드의 상태를 적어 보고 답하세요.

  1. setCsvExporter를 먼저, setTextExporter를 나중에 호출합니다.
  2. setTextExporter 호출을 지우고 setCsvExporter만 호출합니다.
해설 보기

1번은 가입 인사=40입니다. 호출 순서가 바뀌어도 export 직전에는 두 필드가 모두 객체를 가리키고 있으므로, 먼저 검사하는 textExporter != null이 참이 되어 Text 분기가 실행됩니다. 마지막에 설정한 형식이 쓰인 것처럼 보이지만, 실제로는 if 문의 순서 때문에 Text가 선택된 것입니다.

2번은 가입 인사,40입니다. textExporter가 null이므로 첫 조건이 거짓이 되고, else if에서 csvExporter != null이 참이 되어 CSV 분기가 실행됩니다. 결국 이 서비스는 둘 중 하나만 설정했을 때만 기대대로 동작합니다.

문제 2: 다른 필드를 비우는 서비스에 JSON 추가하기

SingleSlotExporter에 {"title":"제목","viewCount":조회수} 형태로 출력하는 JsonExporter와 이를 선택하는 useJson을 추가하세요. main은 Text, JSON, CSV를 차례로 선택하며 한 번씩 내보냅니다. 다 고친 뒤 기존 메서드 중 몇 개를 고쳐야 했는지 세어 보세요.

해설 보기
src/SingleSlotJson.java
public final class SingleSlotJson {
    public static void main(String[] args) {
        ExportService service = new ExportService();
        service.useText(new TextExporter());
        service.export("가입 인사", 40);
        service.useJson(new JsonExporter());
        service.export("로그인 구현", 50);
        service.useCsv(new CsvExporter());
        service.export("추가 인사", 30);
    }

    private static final class ExportService {
        private TextExporter text;
        private CsvExporter csv;
        private JsonExporter json;

        void useText(TextExporter value) {
            text = value;
            csv = null;
            json = null;
        }

        void useCsv(CsvExporter value) {
            csv = value;
            text = null;
            json = null;
        }

        void useJson(JsonExporter value) {
            json = value;
            text = null;
            csv = null;
        }

        void export(String title, int viewCount) {
            if (text != null) text.export(title, viewCount);
            else if (csv != null) csv.export(title, viewCount);
            else if (json != null) json.export(title, viewCount);
            else throw new IllegalStateException("no exporter");
        }
    }

    private static final class TextExporter {
        void export(String title, int viewCount) { System.out.println(title + "=" + viewCount); }
    }

    private static final class CsvExporter {
        void export(String title, int viewCount) { System.out.println(title + "," + viewCount); }
    }

    private static final class JsonExporter {
        void export(String title, int viewCount) {
            System.out.println("{\"title\":\"" + title + "\",\"viewCount\":" + viewCount + "}");
        }
    }
}
가입 인사=40
{"title":"로그인 구현","viewCount":50}
추가 인사,30

새로 만든 것은 JsonExporter 클래스, json 필드, useJson 메서드입니다. 그와 별도로 기존 메서드 세 개를 고쳐야 했습니다. useText와 useCsv에는 json = null;을 한 줄씩 넣었고, export에는 else if (json != null) 분기를 넣었습니다. useText나 useCsv 중 하나에서 json = null;을 빠뜨려도, 지금은 export가 json을 맨 마지막에 검사하므로 출력은 맞게 나옵니다. 하지만 비우지 않은 json 필드가 그대로 남아 있으므로, 나중에 json 분기를 export의 맨 앞으로 옮기면 JSON을 선택한 뒤 그 형식으로 바꿔도 이전 선택인 JSON이 다시 출력됩니다. 형식이 하나 늘 때마다 이런 손질이 기존 코드 전체에 퍼진다는 것이 이 설계의 비용입니다.

문제 3: 알림 서비스에서 고칠 곳 표시하기

ConsoleNotifier와 SilentNotifier를 문자열 옵션으로 고르는 NotificationService를 작성하세요. 그리고 나중에 EmailNotifier를 추가한다면 어디를 고쳐야 하는지 주석으로 표시합니다. 아직 인터페이스로 개선하지는 말고, 고칠 곳이 어떻게 흩어지는지만 확인합니다.

해설 보기
src/ConcreteNotificationBranches.java
public final class ConcreteNotificationBranches {
    public static void main(String[] args) {
        NotificationService service = new NotificationService();
        service.notifyPublished("console", "가입 인사");
        service.notifyPublished("silent", "로그인 구현");
    }

    private static final class NotificationService {
        private final ConsoleNotifier console = new ConsoleNotifier();
        private final SilentNotifier silent = new SilentNotifier();
        // ② EmailNotifier 필드를 추가한다

        void notifyPublished(String option, String title) {
            if (option.equals("console")) console.send(title);
            else if (option.equals("silent")) silent.send(title);
            // ③ else if (option.equals("email")) 분기를 추가한다
            else System.out.println("unknown");
        }
    }

    private static final class ConsoleNotifier {
        void send(String title) { System.out.println("console=" + title); }
    }

    private static final class SilentNotifier {
        void send(String title) { System.out.println("silent"); }
    }

    // ① EmailNotifier 클래스를 새로 만든다
}
console=가입 인사
silent

①은 새 기능이니 당연히 필요한 추가이지만, ②와 ③은 이미 잘 동작하던 NotificationService를 다시 여는 수정입니다. 알림 방식이 늘어날 때마다 ②와 ③이 반복되므로, 서비스는 모든 알림 방식의 이름을 알아야 하는 클래스가 됩니다. 앞 글처럼 PostNotifier 역할을 받도록 바꾸면 ②와 ③이 필요 없어지고, 옵션을 해석해 구현을 고르는 코드만 서비스 밖에 남습니다.

다음 글

다음 글에서는 형식마다 있던 필드를 Exporter 인터페이스 필드 하나로 바꿉니다. 이렇게 바꿔 끼울 수 있는 알고리즘을 객체로 넘기는 방식을 전략 패턴이라고 부르며, 새 형식을 추가해도 서비스 코드를 고치지 않는다는 것이 실제로 무엇을 뜻하는지 확인합니다.