Console MVC Design
콘솔 애플리케이션에서 MVC를 적용할 때는 철저한 책임 분리와 파이프라인 설계가 테스트 가능성을 좌우한다. Java 기반 콘솔 애플리케이션(우아한테크코스 과제 등)에서, 순수 Java 객체(POJO)로 도메인을 격리하고 엄격한 단위 테스트 커버리지를 요구하는 환경을 전제로 한다. 콘솔 환경을 위한 MVC 패턴의 변형이며, 각 계층과 객체가 하나의 책임만 갖도록 강제하는 단일 책임 원칙(SRP)이 그 위에 있다.
이 패턴이 막는 네 가지 문제
- 비대한 Controller(Fat Controller): "일단 만들고 리팩토링" 전략으로 접근해 초기 책임 분리를 누락하면, 입력 파싱·도메인 검증·비즈니스 로직·출력이 Controller에 모두 몰려 하나의 거대한 메서드를 이룬다.[1] 내부 메서드가 서로 강하게 결합되어 개별 기능에 대한 단위 테스트를 작성할 수 없게 된다.[1:1]
- I/O와 핵심 로직의 결합: 도메인 메서드가 연산 결과를 반환하지 않고 내부에서 직접
System.out.println등으로 출력하면, 외부 환경(I/O)에 의존해 독립적인 실행이 불가능해진다.[2] 테스트 실행 시 멈추거나 콘솔 출력이 섞여 결과 검증이 불가능하다.[2:1] - 파싱-검증 순서 오류:
new int[size]로 생성한 배열은 모든 요소가0으로 초기화되는데, 파싱 전에 유효성 검사를 먼저 실행하면 이0을 실제 입력값으로 오인해 잘못된 예외를 던진다.[3] - 예외 감지 실패: Controller의
catch블록이 에러 메시지를 출력하고 그대로 종료하면, 예외가 외부로 전파되지 않아assertThatThrownBy같은 테스트 코드가 예외를 감지하지 못한다.[4]
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. 예외 재전파
}
}
절충점
- 계층 분리로 인해 작은 프로그램에서는 클래스와 파일 수가 불필요하게 증가하는 오버헤드가 발생할 수 있다.
실패 모드
- View 로직(입력 루프나 파싱)이 Controller로 유출됨.
- Domain 객체가 Getter/Setter만 가지고 있어 캡슐화가 없는 '가짜 객체' 상태.
관련
- 일급 컬렉션: 도메인 로직을 분리하는 대표적인 패턴.
- Custom Exception Design: Controller가 예외를 소비하지 않고 전파할 때, 도메인 실패 원인을 더 명확하게 전달한다.
출처
테스트 질문
- Controller 메서드가 비대해졌을 때 나타나는 가장 명확한 증상은 무엇인가?
try-catch에서 에러 메시지를 정상 출력했는데assertThatThrownBy테스트가 실패한다면 원인은 무엇인가?
Controller 비대화 문제 해결.md — ""일단 만들고 리팩토링" 전략으로 접근하여 초기 책임 분리를 누락함. 그 결과 로직이 Controller에 묶여 테스트 불가능한 구조가 형성됨." (L28), "메서드가
private이라 외부에서 단위 테스트 불가능. 하나를 테스트하려면 전체를 실행해야 함." (L51), "Controller는 전체 흐름 조율만 담당하도록 로직을 Service와 Domain으로 분산시킴. DI를 통해 각 계층의 결합도를 낮춤." (L55), "테스트가 안 써지는 순간 = 구조가 잘못됐다는 신호. 테스트 가능 여부 자체가 설계 품질 지표." (L65) ↩︎ ↩︎ ↩︎IO 결합으로 인한 단위 테스트 불가.md — "메서드가 연산 결과를 반환하지 않고 내부에서 직접 출력. 외부 환경(I/O)에 의존하여 독립적인 실행이 불가능.", "Domain 계층(Car, Cars, Lotto 등)에서 I/O 코드를 완전히 배제. 입력값은 매개변수로 받고 결과는 반환하는 순수 자바 객체(POJO)로 구성." ↩︎ ↩︎ ↩︎
파싱 전 유효성 검사 순서 오류.md — "new int[size]로 생성된 배열은 모든 요소가 0으로 초기화됨. 파싱 전에 유효성 검사를 먼저 실행하면 이 0을 실제 입력값으로 오인해 예외를 발생시킴.", "처리 순서: 파싱 → 검증 → 비즈니스 로직 순서를 지키는 것이 기본 원칙." ↩︎ ↩︎
assertThatThrownBy 실패 — try-catch re-throw.md — "Controller의 catch 블록이 예외를 출력하고 종료. 예외가 외부로 전파되지 않아 테스트 코드가 예외를 감지하지 못함.", "에러 메시지 출력 후 throw e로 예외를 다시 던짐." ↩︎ ↩︎
객체지향 설계 실전 — 자동차 경주 예시에서 InputView·RacingService·WinnerService·Cars·Car의 책임을 분리한다. 최종 설계 표(L119-125):
RacingService"라운드 진행, Cars 상태 변경",WinnerService"우승자 판별",Cars"컬렉션 조작, 최대값 계산",Car"이름 검증, 이동 결정" ↩︎