Post

[우테코 프리코스] 2주차 - 리팩토링

보다 객체지향적으로 리팩토링 해보자

[우테코 프리코스] 2주차 - 리팩토링

들어가며

2주차 자동차 경주 미션을 모두 구현하고 리팩토링 과정에서 있었던 기술적 고민들에 대해 공유하고자 한다.

1. Domain과 View의 역할 혼재

먼저 처음 작성했던 Car 클래스는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package racingcar.domain;

public class Car {
    private static final int START_POSITION = 0;
    private static final int MAX_NAME_LENGTH = 5;
    private static final String POSITION = "-";

    private final String name;
    private int position;
    
    //..

    public String getStatusString() {
        return name + " : " + POSITION.repeat(position);
    }
    
    //..
}

작성한 코드를 보다보니 getStatusString() 메서드를 보면서 자동차의 객체가 자신의 위치를 ‘표현’하는 것까지 담당해야하나? 라는 의문이 들었다. 도메인 객체는 순수하게 비즈니스 로직과 데이터만 관리해야 하는데, 출력 형식(" : ", "-" 반복)을 결정하는 것은 명백히 View의 책임이었다. 따라서 View 계층와 도메인 계층은 서로 몰라야한다는 원칙을 따르면서 Car 도메인의 혼재되어있는 기능을 분리해야했다.

DTO 도입

기능을 도입하기 위해 record 타입의 CarStatusDto를 도입하기로 결정했다.

DTO란?

DTO(Data Transfer Object)는 계층 간 데이터를 전달하기 위한 객체다. 비즈니스 로직 없이 순수하게 데이터만 담고 있으며, 주로 다음과 같은 상황에서 사용된다

  • 계층 간 데이터 전달: Domain ↔ View, API 응답 등
  • 필요한 데이터만 선별: 도메인 객체의 모든 정보가 아닌, 특정 상황에 필요한 데이터만 추출
  • 결합도 감소: 도메인 객체의 내부 구조 변경이 다른 계층에 영향을 주지 않음

DTO를 사용하면 도메인 계층은 어떤 데이터를 전달할지만 결정하고 View 계층은 그 데이터를 어떻게 표현할지를 독립적으로 결정할 수 있다.

Record 타입이란?

Java 14에서 도입된 Record는 불변 데이터를 간결하게 표현하기 위한 특수한 클래스다. DTO처럼 단순히 데이터만 담는 객체를 만들 때 유용하다.

일반 클래스로 CarStatusDto를 만들 때

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class CarStatusDto {
    private final String name;
    private final int position;
    
    public CarStatusDto(String name, int position) {
        this.name = name;
        this.position = position;
    }
    
    public String getName() {
        return name;
    }
    
    public int getPosition() {
        return position;
    }
    
    // equals, hashCode, toString도 추가로 작성해야한다.
}

반면, record 타입으로 작성하면

1
2
public record CarStatusDto(String name, int position) {
}

생성자, getter, equals, hashCode, toString이 자동으로 생성되니 훨씬 간편하다는 장점이 있다.

Car 클래스에서 View 책임 제거

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Car {
    private static final int START_POSITION = 0;
    private static final int MAX_NAME_LENGTH = 5;

    private final String name;
    private int position;

    // getStatusString() 메서드 삭제

    public String getName() {
        return name;
    }

    public int getPosition() {
        return position;
    }
}

getStatusString()POSITION 상수를 제거하고, 순수하게 데이터를 반환하는 getter만 남겼다.

Cars 클래스에서 DTO 변환

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Cars {
    private final List<Cars> cars;

    /**
     * 모든 자동차의 현재 상태를 반환하는 메서드
     */
    public List<CarStatusDto> getStatusDtos() {
        List<CarStatusDto> statusDtos = new ArrayList<>();
        
        for (Car car : cars) {
            statusDtos.add(new CarStatusDto(car.getName(), car.getPosition()));
        }
        
        return statusDtos;
    }
}

RacingGame에서도 DTO 반환

1
2
3
4
5
6
7
8
9
10
11
public class RacingGame {
    private final Cars cars;
    private final int attemptCount;

    /**
     * 모든 자동차의 현재 상태를 반환하는 메서드
     */
    public List<CarStatusDto> getStatusDtos() {
        return cars.getStatusDtos();
    }
}

RacingGame 또한 Cars로부터 받은 DTO를 그대로 반환함으로써, Domain 계층 내에서도 일관되게 DTO를 사용하도록 했다. Domain 계층에서는 Car 객체를 CarStatusDto로 변환하여 반환한다. 이 과정에서 “무엇을 전달할지”만 결정하고, “어떻게 보여줄지”는 전혀 관여하지 않는다.

OutputView에서 포맷팅 담당

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class OutputView {
    private static final String POSITION = "-";

    //..
    public void printAttemptResult(List<CarStatusDto> statuses) {
        for (CarStatusDto status : statuses) {
            String formattedStatus = formatCarStatus(status);
            System.out.println(formattedStatus);
        }
        System.out.println();
    }

    private String formatCarStatus(CarStatusDto status) {
        return status.name() + " : " + POSITION.repeat(status.position());
    }
}

이제 View 계층에서 DTO를 받아 “어떻게 표현할지”를 결정한다. " : " 형식, "-" 반복 등 모든 표현 로직이 View로 분리시켰다.

2. Controller지만 Controller가 아닌 것

초기 구현에서는 MVC 패턴을 따르기 위해 Controller 클래스를 만들었다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class Controller {
    private final InputView inputView;
    private final OutputView outputView;

    public Controller() {
        this.inputView = new InputView();
        this.outputView = new OutputView();
    }

    public void run() {
        List<String> carNames = inputView.readCarNames();
        int attemptCount = inputView.readAttemptCount();

        Cars cars = new Cars(carNames);
        RacingGame racingGame = new RacingGame(cars, attemptCount);

        play(racingGame);
    }

    private void play(RacingGame game) {
        outputView.printResultMessage();

        for (int attempt = 0; attempt < game.getAttemptCount(); attempt++) {
            game.startRacing();
            outputView.printAttemptResult(game.getStatusDtos());
        }

        outputView.printWinners(game.getWinners());
    }
}

하지만 구현을 마치고 코드를 다시 보니, 이 Controller가 정말 “Controller”의 역할을 하고 있는지 의문이 들었다.

Controller의 본래 역할은 무엇인가?

MVC 패턴에서 각 계층이 가져야 할 책임을 알아보았다.

MVC 패턴의 각 계층

  • Model: 애플리케이션의 데이터와 비즈니스 로직을 담당
  • View: 사용자 인터페이스를 담당하며, 데이터를 표현
  • Controller: 사용자의 입력을 받아 Model과 View를 연결하고 조정

전통적인 MVC 패턴, 특히 웹 애플리케이션에서의 Controller는 다음과 같은 특징을 가진다

웹 MVC의 Controller

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Controller
public class UserController {
    
    @GetMapping("/users")
    public String getUsers(Model model) {
        // 사용자 요청에 반응
        List<User> users = userService.findAll();
        model.addAttribute("users", users);
        return "userList";  // View 이름 반환
    }
    
    @PostMapping("/users")
    public String createUser(@RequestParam String name) {
        // 사용자 입력 처리
        userService.create(name);
        return "redirect:/users";
    }
}

웹 MVC에서 Controller는

  • 사용자의 HTTP 요청에 반응한다
  • 어떤 비즈니스 로직을 실행할지 결정한다
  • 어떤 View를 보여줄지 결정한다
  • 여러 개의 Controller가 각자 다른 요청을 처리한다

나의 Controller는?

하지만 콘솔 애플리케이션에서 Controller는

1
2
3
4
5
// Application.java
public static void main(String[] args) {
    Controller controller = new Controller();
    controller.run();  // 단순 위임
}

메인 메서드에서의 역할을 단순 위임시킨 것 일뿐에 불과했다. 이것은 Controller라기보다는 프로그램의 실행 흐름을 관리하는 진입점에 가까웠다.

전통적인 MVC에서 View의 역할

전통적인 MVC패턴에서의 View의 역할과 동작 과정이 궁금해져서 찾아봤다.

전통적인 MVC의 흐름

  1. View에서 사용자 입력 발생
  2. View가 Controller에게 이벤트 전달
  3. Controller가 Model 업데이트
  4. Model이 변경되면 Observer들에게 알림
  5. View가 알림을 받아 자동으로 화면 갱신

이런 구조에서는

  • View가 Model을 관찰(observe)한다
  • Model이 변경되면 View가 자동으로 알림을 받는다
  • Controller는 Model만 조작하고, View 업데이트는 신경 쓰지 않는다

하지만 우리의 콘솔 애플리케이션을 보면

1
2
3
4
5
6
7
8
9
10
private void play(RacingGame game) {
    outputView.printResultMessage();

    for (int attempt = 0; attempt < game.getAttemptCount(); attempt++) {
        game.startRacing();  // Model 업데이트
        outputView.printAttemptResult(game.getStatusDtos());  // 직접 View 호출!
    }

    outputView.printWinners(game.getWinners());
}
  • View는 Model을 관찰하지 않는다
  • Model이 변경되어도 View에게 알림이 가지 않는다
  • Controller(또는 Application)가 직접 View를 호출해서 출력한다
  • 순차적으로 “업데이트 → 출력 → 업데이트 → 출력” 반복

이것은 전통적인 MVC가 아니라 순차적 실행 흐름이고 내가 작성한 것은 Controller가 아니라 ApplicationRunner에 가까웠다.

다른 사례들을 찾아보며

콘솔 애플리케이션에서 MVC 패턴을 어떻게 적용하는지 여러 자료를 찾아봤다.

Spring Boot Console Application의 예시

1
2
3
4
5
6
7
8
9
10
11
12
@SpringBootApplication
public class Application implements CommandLineRunner {
    
    @Override
    public void run(String... args) {
        // 애플리케이션 로직
    }
    
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

여기서도 별도의 Controller를 두지 않고, Application 클래스가 직접 실행 흐름을 관리한다.

다른 콘솔 프로그램 예시들

1
2
3
4
5
6
7
8
9
10
public class Main {
    public static void main(String[] args) {
        GameService gameService = new GameService();
        InputHandler inputHandler = new InputHandler();
        OutputHandler outputHandler = new OutputHandler();
        
        // 직접 실행
        gameService.start();
    }
}

대부분의 콘솔 애플리케이션에서는 별도의 Controller를 두지 않았다.

Controller vs Application

지금 구조에서 Controller의 역할을 다시 분석해봤다

1
2
3
4
5
6
7
8
Application.java (4줄)
  └─ Controller.run() 호출
  
Controller.java (30줄)
  ├─ 입력 받기
  ├─ 객체 생성
  ├─ 게임 실행
  └─ 결과 출력

Application은 비어있고 실제 로직은 모두 Controller에 있었다.

YAGNI 원칙

YAGNI (You Aren’t Gonna Need It)

“지금 필요하지 않은 기능은 만들지 마라”

미래에 필요할 것 같다는 이유로 미리 구현하지 말고, 실제로 필요할 때 구현하라.

현재 시점에서 Controller를 분리하면 얻는 장점은 의존성 주입으로 테스트가 가능하다 밖에 없다. 진입점이 하나이고, 여러 컨트롤러가 필요하지 않으며, 이벤트 기반이 아니라서 작성하는데 복잡도만 증가할 것 같다.

결론: Controller 제거 결정

여러 자료를 찾아보고 다음과 같은 결론을 내렸다.

  1. 전통적인 MVC의 Controller는 주로 웹 애플리케이션에 적합하다
  2. 콘솔 애플리케이션에서 Controller를 억지로 만드는 것은 패턴을 위한 패턴이다
  3. Application이 직접 실행 흐름을 관리하는 것이 더 자연스럽다

Controller를 제거하고 Application에 실행과 흐름 제어 로직을 담았다. 조금 길다고 느껴질 순 있지만 빈껍데기인 Controller를 남기기가 싫었다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
/**
 * 자동차 경주 게임 애플리케이션 진입점 클래스
 */
public class Application {
    private final InputView inputView;
    private final OutputView outputView;

    public Application() {
        this.inputView = new InputView();
        this.outputView = new OutputView();
    }

    /**
     * 애플리케이션의 메인 메서드
     *
     * @param args 커맨드 라인 매개변수
     */
    public static void main(String[] args) {
        try {
            new Application().start();
        } finally {
            Console.close();
        }
    }

    /**
     * 게임을 실행하는 메서드
     */
    public void start() {
        List<String> carNames = inputView.readCarNames();
        int attemptCount = inputView.readAttemptCount();

        Cars cars = new Cars(carNames);
        RacingGame racingGame = new RacingGame(cars, attemptCount);

        play(racingGame);
    }

    /**
     * 게임을 진행하고 결과를 출력하는 메서드
     *
     * @param game 진행할 레이싱 게임
     */
    private void play(RacingGame game) {
        outputView.printResultMessage();

        for (int attempt = 0; attempt < game.getAttemptCount(); attempt++) {
            game.startRacing();
            outputView.printAttemptResult(game.getStatusDtos());
        }

        outputView.printWinners(game.getWinners());
    }
}

마치며

1주차와 다르게 계층, 설계에 관한 기술적 고민들이 많았어서 리팩토링할 부분이 굉장히 많았다. 단순 주석 작성에서 계층 전반적으로의 리팩토링을 하고나니 그래도 조금 쓸만한 코드가 나온 것 같다. 리팩토링이 살짝 재밌어지기 시작한다.

This post is licensed under CC BY 4.0 by the author.