JAVA

테스트가 어렵다면 코드 탓이 아니라 설계 탓이다 - 숨겨진 의존성의 진실

경딩 2026. 3. 8. 02:25

이 글에서 얻어갈 것들

이 글을 다 읽고 나면 다음 질문들에 스스로 답할 수 있게 된다.

  • "테스트 짜기 어렵다"는 느낌이 사실은 무엇을 의미하는가?
  • 외부 연동 (I/O, DB, 네트워크) 을 왜 도메인 로직과 받드시 분리해야 하는가?
  • 분리했을 때 의존성 방향은 어느 쪽이어야 하며, 외 그 방향이어야한 하는가?
  • 의존성 역전 원칙(DIP) 이 이론이 아니라 생존 전략인 이유는 무엇인가?
  • 패키지로 분리하는 것만으로 충분한가? 의존성 방향까지 확인해야 하는 이유

프롤로그: 수동 테스트를 반복하는 개발자의 하루

아마 이런 경험이 있을 것이다.

 

코드를 수정했다. 프로그램을 직접 실행하고, 키보드로 명령어를 입력하고, 눈으로 결과를 확인한다. 또 수정했다. 또 실행한다. 또 확인한다.

자동화 테스트를 작성하려고 시도해봤지만 뭔가 이상하다.

 

System.setIn()으로 입력을 주입하고 ByteArrayOutputStream으로 출력을 가로채야 한다.

 

테스트 코드가 점점 마법처럼 보이기 시작한다.

 

이걸 처음 겪을 떄는 보통 두 가지 결론 중 하나를 내린다.

 

첫째, "나는 테스트를  못짠다" 둘째, "이 코드는 테스트하기 어려운 코드다"

둘다 틀렸다. 진짜 결론은 이것이다. "이 코드는 설계가 잘못됐다"


1. 테스트 케이스는 설계의 거울이다

아래 테스트 코드를 보자. 텍스트 어드벤처 게임의 이동 테스트다.

@Test
public void move_north_passed() {
    OutputStream output = new ByteArrayOutputStream();
    System.setOut(new PrintStream(output));
    System.setIn(new ByteArrayInputStream("go north\nquit\n".getBytes()));

    Game game = new Game();
    game.run();

    assertThat(output.toString().split("\\R")).containsSequence(
            "> 당신은 [다리]에 있습니다.",
            "큰 강 위에 돌로 만든 커다란 다리가 있습니다.",
            "> ",
            "게임을 종료합니다.");
}

 

 

처음 이 코드를 마주한 사람이라면 이런 질문이 생긴다.

  • "go north\nquit\n" 이 문자열은 게임과 어떤 관련이 있나? 명령어인가? 어디서 처리되나?
  • 플레이어가 왜 "다리" 로 이동했나? 원래 어디 있었다?
  • System.setIn과 Game 사이를 연결하는 코드가 어디 있나?

코드 어디를 봐도 힌트가 없다. 정보가 숨겨져 있다.

이 숨겨진 정보의 원인은 테스트 코드를 못 짠게 아니다. Game 클래스 내부를 열어보면 원인이 드러난다.

package hello.objectprinciple;


// 단일 책임 원칙 사전 작업- 단일 추상화 수준 원칙 적용 (추상화 수준이 동일한 조합 메서드 형태로 만들기)
// 객체 지향 핵심: 자신의 상태를 스스로 책임지는 객체
public class Game {

    private WorldMap worldMap;
    private Position position;
    private boolean running = false;
    private Console console;

    public Game() {
        this.position = Position.of(0, 2);
        this.worldMap = new WorldMap(
                Size.with(2,3),
                new Room(Position.of(0,0), "샘", "아름다운 샘물이 흐르는 곳입니다. 이곳에서 휴식을 취할 수 있습니다."),
                new Room(Position.of(0, 1), "다리", "큰 강 위에 돌로 만든 커다란 다리가 있습니다."),
                new Room(Position.of(1, 1), "성", "용왕이 살고 있는 성에 도착했습니다."),
                new Room(Position.of(0,2), "언덕", "저 멀리 성이 보이고 언덕 아래로 좁은 길이 나 있습니다."),
                new Room(Position.of(1,2), "동굴", "어둠에 잠긴 동굴 안에 작은 화톳불이 피어 있습니다.")
        );
        this.console = new Console();
    }

    // 1. 책임을 한문장으로 적어본다.
    // 게임이 종료될때까지 루프를 실행하고,
    // 사용자에게서 입력을 받고,
    // 입력을 파싱하고,
    // 명령을 처리하고,
    // 처리 결과를 출력한다.
    private void play() {
        // 게임 플레이

        start();
        while (isRunning()) {
            showPrompt();
            parseCommand();
        }
    }



    public void run() {
        welcome();
        play();
        farewell();
    }


    private void welcome() {
        showGreeting();
        showRoom();
        showHelp();
    }

    private void showHelp() {
        console.showLine("다음 명령어를 사용할 수 있습니다.");
        console.showLine("go {north|east|south|west} - 이동, quit - 게임 종료");
    }

    private void showRoom() {
        console.showLine("당신은 ["+ worldMap.roomAt(position).name() + "]에 있습니다.");
        console.showLine(worldMap.roomAt(position).description());
    }

    private  void showGreeting() {
        console.showLine("환영합니다!");
    }


    private void parseCommand() {
        String[] commands = console.input().split("\\s+");
        switch (commands[0]) {
            case "go" -> {
                switch (commands[1]) {
                    case "north" -> tryMove(Direction.NORTH);
                    case "south" -> tryMove(Direction.SOUTH);
                    case "east" -> tryMove(Direction.EAST);
                    case "west" -> tryMove(Direction.WEST);

                    default -> showUnknown();
                }
            }
            case "quit" -> stop();
            default -> showUnknown();
        }
    }



    private void showPrompt() {
        console.show("> ");

    }

    private void showUnknown() {
        console.showLine("이해할 수 없는 명령어입니다.");
    }

    private void stop() {
        running = false;
    }

    private boolean isRunning() {
        return running;
    }

    private void start() {
        running = true;
    }




    private void tryMove(Direction direction) {
        if (worldMap.isBlocked(position.shift(direction))) {
            showBlocked();
        } else {
            position = position.shift(direction);
            showRoom();
        }
    }




    private void showBlocked() {
        console.showLine("이동할 수 없습니다.");
    }



    private  void farewell() {
        // 작별 문구 출력
        console.showLine("\n게임을 종료합니다.");
    }

}

Game은 내부에서 Scanner 를 직접 생성해 입력을 받고, System.out으로 출력한다. 외부에서는 이 사실을 알 방법이 없다. 의존성이 클래스 안에 완전히 감춰져 있기 때문이다.

 

테스트 코드가 이해하기 어려운 건 설계가 나쁘다는 신호다.

테스트 코드는 설계를 가장 솔직하게 드러내는 거울이기 때문이다.


2. 의존성을 분리해봤다 — 하지만 여전히 어렵다

문제를 인식했다. 외부 의존성을 별도 클래스로 분리해 명시적으로 드러내본다. 입출력 의존성을 Console 클래스로 분리한다.

public class Console {
    private Scanner scanner;

    public Console() {
        this.scanner = new Scanner(System.in);
    }

    public String input() {
        return scanner.nextLine().toLowerCase().trim();
    }

    public void showLine(String text) {
        System.out.println(text);
    }

    public void show(String text) {
        System.out.print(text);
    }
}

 

 

이제 Game은 Scanner와 System에 직접 의존하지 않는다. Console에만 의존한다. 

 

ConsoleTest도 별도로 작성할 수 있다.

@Test
public void showLine() {
    OutputStream output = new ByteArrayOutputStream();
    System.setOut(new PrintStream(output));

    new Console().showLine("showLine");

    Assertions.assertThat(output.toString()).isEqualToIgnoringNewLines("showLine");
}

 

입출력 테스트는 ConsoleTest 안에서만 검증한다. 논리적으로 맞다.

그런데... GameTest를 다시 열어본다. 여전히 System.setIn()을 써야 하고, ByteArrayOutputStream으로 출력을 가로채야 한다. 달라진 게 없다.

왜일까?


3. 의존성 전이 — 분리해도 해결이 안 된 진짜 이유

이 현상의 이름은 의존성 전이(Dependency Transitivity) 다.

Game → Console → Scanner / System → 키보드 / 콘솔 화면

 

Game이 Console에 의존하면, Console이 의존하는 Scanner와 System의 의존성이 Game으로 전파된다. Game은 Scanner를 직접 쓰지 않지만, 간접적으로 의존하게 된다.

 

Console이라는 중간계층을 만들었지만, 그 콘솔이 여전히 외부 세계와 단단히 연결되어 있다. 의존성의 실뭉치가 풀린게 아니라 한겹 더 감긴 것이다.

 

의존성 전이가 발목을 잡는 이유는 두 가지다.

 

첫째, 구체 클래스에 의존하고 있다. Game은 구체 클래스인 Console에 의존하고, Console은 구체 클래스인 Scanner와 System에 의존한다. 구체 클래스에 의존한다는 건 그 내부 구현이 바뀔 때 영향을 받는다는 뜻이고, 필요할 때 다른 구현으로 교체하기도 어렵다는 뜻이다. 결합도가 높아진다.

 

둘째, 의존성이 숨겨져 있다. Game은 생성자에서 Console을 직접 생성한다. Console은 생성자에서 Scanner를 직접 생성한다. 이 모든 의존성이 클래스 내부에 숨어 있어서, 인터페이스만 봐서는 무엇에 의존하는지 알 수 없다. 내부 구현을 열어봐야 비로소 드러난다.

 

정리하면 이렇다. 구체 클래스에 의존하고, 그 의존성이 숨겨져 있는 클래스는 테스트 하기 어렵다. 외부 의존성을 감추고 있다면 문제가 더 커진다. 트릭을 동원한 난해한 테스트를 쓰거나, 개발자가 직접 손으로 테스트할 수밖에 없게 된다.


4. 변경의 관점에서 본 의존성 — 진짜 위험은 여기에 있다

테스트 문제만으로 끝나면 그나마 다행이다. 숨겨진 의존성의 진짜 위험은 변경에서 드러난다. 

 

코드 수정의 여파는 의존성의 반대 방향으로 흐른다.

Scanner 수정 → Console 영향 → Game 영향

저수준 세부사항이 바뀌면 고수준 핵심 로직이 흔들린다.

이건 설계에서 가장 위험하다.

왜 위험한지, 클래스가 담당하는 책임을 정리하면 명확해진다. 

  • Console: 키보드 입력과 화면 출력을 담당하는 저수준 메커니즘
  • Game: 게임 실행에 필요한 규칙과 흐름을 담당하는 고수준 정책

이게 왜 문제일까? 게임 정보를 화면이 아니라 파일에 출력하고 싶어졌다고 가정해보자. Disk라는 클래스가 필요하다. 그런데 Console은 Game 생성자 안에 숨겨져 있다. Console 대신 Disk를 쓰려면 Game 클래스를 직접 수정해야 한다.

 

출력 방식 변경 때문에 게임 로직을 건드려야 한다.

 

이건 단순히 불편한 게 아니다. 핵심 정책을 세부사항 때문에 수정하는 건 위험하다. 출력 방식을 바꾸다가 게임 로직에 버그가 생길 수 있다. 변경의 이유와 변경의 영향 범위가 일치하지 않는 상태다.

 

바람직한 방향은 반대다. 고수준 정책이 변경됐을 떄 저수준 메커니즘이 그에 맞춰 변경되어야 한다. 저수준 세부사향의 변경이 고수준 정책에 영향을 주어선 안된다.


5. 의존성 역전 — 방향을 바꾸면 모든 게 달라진다

이 문제를 근본적으로 해결하는 원칙이 의존성 역전 원칙(Dependency Inversion Principle, DIP) 이다.

DIP의 핵심은 인터페이스를 쓰는 것이 아니라, 의존성의 방향을 바꾸는 것이다.

 

구체 클래스가 아닌 추상화(인터페이스) 에 의존할 때 비로소 바꿀 수 있다.

지금의 의존성 구조:

Game(구체) → Console(구체) → Scanner/System(구체)

 

의존성을 역전시킨 구조

Game → IO 인터페이스 ← Console
                     ← Disk
                     ← MockIO (테스트용)

 

Game 은 Console 이 무엇인지 모른다. 그냥 "입력을 받고 출력을 할 수 있는 무언가" 라는 계약(인터페이스)만 알고 있다. 실제 구현이 Console인지  Disk인지는 Game 바깥에서 결정된다.

 

테스트할 때는 MockIO를 주힙하면 된다. System.setIn() 트릭이 사라진다. 파일 출력으로 바꾸고 싶으면 Disk를 주입한다. Game은 한줄도 건드리지 않는다. 의존성이 인터페이스를 통해 외부에 드러나기 때문에, 숨겨진 의존성도 사라진다.

 

이게 가능한 이유는 의존성의 방향이 바뀌었기 때문이다. Console이 IO 인터페이스를 구현함으로써 Game이 원하는 방식으로 동작한다. 저수준이 고수준의 규약을 따르는 구조다.


6. 패키지로 분리했다 — 그것만으로 충분한가?

외부 연동을 client나 infrastructure 같은 별도 패키지로 분리하는 것은 좋은 방향이다. 도메인과 물리적으로 분리한 것만으로도 코드 수정 시 영향 범위가 줄어드는 효과가 있다. 

 

하지만 패키지 분리는 필요조건이지 충분 조건이 아니다. 진짜 질문은 이것이다.

도메인이 외부 연동 구현을 알고 있나?

 

만약 도메인 코드 안에서 외부 연동 구현체를 직접 생성하거나 참조하고 있다면, 물리적으로 패키지를 나눴더라도 논리적 결합은 그대로다. Console을 별도 클래스로 분리했지만 Game안에서 직접 생성했던 것처럼.

 

이상적인 구조는 이렇다.

domain 패키지
  ├── Game (고수준 정책)
  ├── IO (인터페이스 - 도메인이 정의)
  └── ...

infrastructure 패키지 (또는 client, adapter)
  ├── ConsoleIO implements IO
  ├── DiskIO implements IO
  └── ...

 

도메인이 인터페이스를 정의하고, infrastructure 가 그 인터페이스를 구현한다. 의존성의 방향이  infrastructure → domain이다. 도메인은 외부 연동 구현을 모른다.

 

패키지 이름이 client든 infrastructure든 adapter든 크게 중요하지 않다. 중요한 건 의존성의 방향인터페이스를 누가 소유하느냐다.


7. 외부 연동을 분리하는 진짜 이유

마지막으로 가장 본질적인 질문이다. 왜 외부 연동은 도메인 로직과 분리해야 할까?

 

표면적 대답은 "테스트하기 쉬워서" 다. 맞지만 충분하지 않다.

더 깊은 이유는 변경의 이유가 다르기 때문이다.

 

게임 로직은 게임 규칙이 바뀔 때 변경된다. 콘솔 코드는 출력 방식이 바뀔 때 변경된다. DB 연결 코드는DB 가 바뀔 때 변경된다. HTTP 클라이언트는 외부 API 스펙이 바뀔 떄 변경된다.

 

이것들의 변경 주기와 변경 이유는 전혀 다르다. 다른 이유로 변하는 것을 같은 클래스에 두면, 하나가 바뀔 때 다른 것도 수정해야 하는 상황이 반복된다.

 

특히 외부 연동은 도메인보다 훨씬 자주, 예측하기 어려운 이유로 변한다. DB가 MySQL에서 PostgreSQL로 바뀔 수 있고, 콘솔 출력이 REST API 응답으로 바뀔 수 있으며, 키보드 입력이 메시지 큐 이벤트로 바뀔 수 있다. 이 변화들이 핵심 도메인 로직을 건드리게 두어선 안 된다.

한 문장으로 정리하면 이렇다.

변하는 이유가 다른 코드는 같은 곳에 두면 안 된다. 안정적인 것이 불안정한 것에 의존하면 시스템 전체가 불안정해진다.

에필로그: 테스트가 어려우면 설계를 먼저 의심하라

테스트 코드를 짜다가 막힐 때 가장 쉬운 반응은 "이건 원래 테스트하기 어려운 코드야"라고 합리화하는 것이다.

하지만 그 막힘은 거의 항상 설계 문제에서 온다. 숨겨진 의존성이 있지 않은지, 구체 클래스에 직접 의존하고 있지 않은지, 고수준 정책이 저수준 세부사항에 묶여 있진 않은지 확인해보자.

 

테스트가 쉬운 코드는 대부분 좋은 설계를 갖고 있다. 반대도 성립한다. 좋은 설계를 가진 코드는 대부분 테스트하기 쉽다.

테스트하기 어렵다는 느낌은 설계를 개선하라는 신호다. 그 신호를 무시하지 말자.