Console MVC Design

콘솔 애플리케이션에서 MVC를 적용할 때는 철저한 책임 분리와 파이프라인 설계가 테스트 가능성을 좌우한다. Java 기반 콘솔 애플리케이션(우아한테크코스 과제 등)에서, 순수 Java 객체(POJO)로 도메인을 격리하고 엄격한 단위 테스트 커버리지를 요구하는 환경을 전제로 한다. 콘솔 환경을 위한 MVC 패턴의 변형이며, 각 계층과 객체가 하나의 책임만 갖도록 강제하는 단일 책임 원칙(SRP)이 그 위에 있다.

이 패턴이 막는 네 가지 문제

Solution: 책임 분리와 처리 파이프라인

Controller는 순수 흐름 제어만 한다

Controller는 흐름만 조율하며, 직접적인 비즈니스 로직이나 입출력을 수행하지 않는다. 도메인 로직을 Service와 Domain으로 분산시키고 DI로 계층 간 결합도를 낮춘다. 테스트가 안 써지는 순간은 구조가 잘못됐다는 신호이고, 테스트 가능 여부 자체가 설계 품질 지표다.[1:2]

입력만 맡는 InputView, 라운드 진행을 맡는 RacingService, 우승자 판별을 맡는 WinnerService(Service), 컬렉션 조작과 최대값 계산을 맡는 Cars와 이름 검증·이동 결정을 맡는 Car(Domain)를 나누는 예시는 이 책임 분리를 구체적인 클래스 배치로 보여 준다.[5]

Controller  → 흐름 조율만 (try-catch 없음)
InputView   → 재입력 루프 + 파싱 + 기본 검증
Service     → 비즈니스 로직
Domain      → 도메인 규칙 + 자기 검증

Domain은 POJO로 스스로 검증한다

핵심 비즈니스 로직은 어떠한 입출력에도 의존하지 않는 순수 Java 객체로 구성하고, 입력값은 매개변수로 받고 결과는 반환하게 한다.[2:2] 도메인 객체는 스스로 유효성 규칙을 검증해야 한다.

처리 순서는 파싱 → 검증 → 로직이다

문자열을 먼저 int 등으로 파싱한 결과를 대상으로 유효성을 검사해야, 빈 배열의 0 같은 쓰레기값이 검증 오류를 일으키는 상황을 막을 수 있다.[3:1]

// 수정 후: 파싱 먼저
for (int i = 0; i < textNumbers.length; i++) {
    numbers[i] = Integer.parseInt(textNumbers[i]); // 파싱 먼저
}
if (!isPositive(numbers)) {                        // 파싱된 값 검사
    throw new IllegalArgumentException("...");
}

Controller에서 예외를 다시 던진다

View에서 재입력 루프를 처리하지 않고 최상위 Controller에서 예외를 처리할 때는(이 적용 조건은 이 page의 해석이다), 테스트 코드(assertThatThrownBy)가 예외를 감지할 수 있도록 에러 메시지 출력 후 throw e;로 다시 예외를 던져야 한다.[4:1]

public void run() {
    try {
        String input = InputView.readInput();
        List<Integer> parsed = parse(input); // 1. 파싱
        Lotto lotto = new Lotto(parsed);     // 2. 도메인에서 자체 검증
        play(lotto);                         // 3. 로직
    } catch (IllegalArgumentException e) {
        OutputView.printError(e.getMessage());
        throw e; // 4. 예외 재전파
    }
}

절충점

실패 모드

관련

출처

테스트 질문


  1. Controller 비대화 문제 해결.md — ""일단 만들고 리팩토링" 전략으로 접근하여 초기 책임 분리를 누락함. 그 결과 로직이 Controller에 묶여 테스트 불가능한 구조가 형성됨." (L28), "메서드가 private이라 외부에서 단위 테스트 불가능. 하나를 테스트하려면 전체를 실행해야 함." (L51), "Controller는 전체 흐름 조율만 담당하도록 로직을 Service와 Domain으로 분산시킴. DI를 통해 각 계층의 결합도를 낮춤." (L55), "테스트가 안 써지는 순간 = 구조가 잘못됐다는 신호. 테스트 가능 여부 자체가 설계 품질 지표." (L65) ↩︎ ↩︎ ↩︎

  2. IO 결합으로 인한 단위 테스트 불가.md — "메서드가 연산 결과를 반환하지 않고 내부에서 직접 출력. 외부 환경(I/O)에 의존하여 독립적인 실행이 불가능.", "Domain 계층(Car, Cars, Lotto 등)에서 I/O 코드를 완전히 배제. 입력값은 매개변수로 받고 결과는 반환하는 순수 자바 객체(POJO)로 구성." ↩︎ ↩︎ ↩︎

  3. 파싱 전 유효성 검사 순서 오류.md — "new int[size]로 생성된 배열은 모든 요소가 0으로 초기화됨. 파싱 전에 유효성 검사를 먼저 실행하면 이 0을 실제 입력값으로 오인해 예외를 발생시킴.", "처리 순서: 파싱 → 검증 → 비즈니스 로직 순서를 지키는 것이 기본 원칙." ↩︎ ↩︎

  4. assertThatThrownBy 실패 — try-catch re-throw.md — "Controller의 catch 블록이 예외를 출력하고 종료. 예외가 외부로 전파되지 않아 테스트 코드가 예외를 감지하지 못함.", "에러 메시지 출력 후 throw e로 예외를 다시 던짐." ↩︎ ↩︎

  5. 객체지향 설계 실전 — 자동차 경주 예시에서 InputView·RacingService·WinnerService·Cars·Car의 책임을 분리한다. 최종 설계 표(L119-125): RacingService "라운드 진행, Cars 상태 변경", WinnerService "우승자 판별", Cars "컬렉션 조작, 최대값 계산", Car "이름 검증, 이동 결정" ↩︎