
2주차 프리코스 미션은 자동차 경주 게임이었다.
마침 학교 중간고사 기간과 겹쳐 정말 정신없는 한 주였지만,
그럼에도 불구하고 설계하고 코드를 작성하는 시간만큼은 오히려 즐거웠다.
그냥... 학교 시험이 힘들었을 뿐이다 😅
이번 미션은 1주차보다 요구사항이 늘어나면서 설계 단계의 중요성을 더욱 크게 느낄 수 있었다.
“어떻게 객체를 나누고 책임을 분리할 것인가”를 고민하는 시간이 많아졌고,
그 과정 자체가 개발에 더욱 몰입을 할 수 있게 해준 것이였다.
이번 회고는 1주차에 느꼈던 아쉬운 점을 어떻게 보안했는지에 대해 먼저 시작하겠다.
1주차에서 아쉬웠던 점 중 하나는 기능 커밋과 테스트 커밋을 분리하지 못한 것이었다.
이번에는 이를 반드시 개선해보자 마음먹고 기능 → 테스트 → 문서(README) 순으로 세분화하여 커밋을 남겼다.
기능 단위 커밋 사진
결과적으로 커밋 개수가 엄청나게 많아졌지만 그만큼 개발 과정이 세밀하게 기록되어 있다는 것을 느꼈다.
커밋 개수 사진
'처음엔 이런 간단한 문제에 비해 너무 오바인가?'라고만 생각했지만 미션을 마치고 나서는 이 방식의 진짜 장점을 깨달았다.
언제든지 안전하게 되돌릴 수 있다.
이전에는 여러 기능을 한꺼번에 커밋해 실수했을 때 되돌리기 어려웠다.
하지만 커밋 단위가 작아지니, 실수한 부분만 정확히 롤백할 수 있었다.
객체의 역할과 책임이 명확해졌다.
기능을 나누며 커밋하다 보니, 각 객체가 “무엇을 해야 하는지”
더 또렷하게 구분할 수 있었다. 자연스럽게 SRP(단일 책임 원칙)에 가까워졌다.
기능별 테스트가 쉬워졌다.
작은 단위로 커밋해두면 테스트도 기능별로 실행할 수 있다.
덕분에 어떤 부분에서 오류가 났는지 빠르게 파악할 수 있었다.
개발 흐름이 하나의 문서가 되었다.
커밋 로그를 보면 그 주의 개발 태도와 과정이 그대로 남아 있었다.
하나하나의 커밋이 나의 사고흐름을 기록하는 문서처럼 느껴졌다.
1주차에서는 입력 정책과 책임 경계를 명확히 확정하지 못한 것이 아쉬웠다.
그래서 이번에는 설계 단계에서 README를 입력(Input) 출력(Output) 기능(Feature) 으로 구분하는 것에 더해 각 기능이 어떤 객체의 책임인지까지 표시해두었다.
README 사진
이 과정을 거치니 개발 단계에서 훨씬 수월하게 구현할 수 있었고, “설계를 잘 해두면 개발은 자연스럽게 따라온다”는 것을 몸소 느꼈다.
또한 이번 미션에서는 Controller를 분리할지 여부를 직접 판단해보았다.
1주차에서는 Application이 모든 역할(입출력, 도메인)을 맡았지만 이번에는 Controller를 별도로 분리해보며 도메인의 협력 흐름과 입출력 로직을 한눈에 관리할 수 있도록 했다.
Controller 분리 여부 판단을 말하기전에 분리를 하면서 느꼈던 생각들은 먼저 말하겠다.
package racingcar.controller;
import racingcar.domain.Round;
import racingcar.domain.racer.Racer;
import racingcar.domain.racer.Racers;
import racingcar.domain.racer.RacingCar;
import racingcar.domain.racer.RacingCars;
import racingcar.service.RacingService;
import racingcar.view.InputView;
import racingcar.view.OutputView;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
public class RacingController {
private InputView inputView;
private OutputView outputView;
private RacingService racingService;
public RacingController(InputView inputView, OutputView outputView, RacingService racingService) {
this.inputView = inputView;
this.outputView = outputView;
this.racingService = racingService;
}
public void run() {
String input = inputView.readCarName();
List<String> inputList = racingService.parseInputs(input);
String roundCount = inputView.readRoundCount();
Round round = new Round(roundCount);
List<Racer> racerList = makeRacer(inputList);
Racers racers = new RacingCars(racerList);
racingStart(racers, round);
}
private List<Racer> makeRacer(List<String> inputList) {
List<Racer> racerList = new ArrayList<>();
for (int i = 0; i < inputList.size(); i++) {
racerList.add(new RacingCar(inputList.get(i)));
}
return racerList;
}
private void racingStart(Racers racers, Round round) {
for (int i = 0; i < round.getRoundNumber(); i++) {
Map<String, Integer> roundResult = racingService.roundStart(racers);
outputView.printRoundResult(roundResult);
}
outputView.printGameResult(racingService.gameEnd(racers));
}
}
처음에는 Controller에 모든 객체를 주입하다 보니 인자가 너무 많아졌는데, 이를 개선하기 위해 Service 계층을 따로 만들어 입출력을 제외한 도메인 간 협력 로직을 맡기도록 했다.
이 과정을 통해 MVC 구조의 진짜 의도, 즉 “각 계층이 무엇을 책임져야 하는지”를 명확히 이해할 수 있었다.
package racingcar.service;
import racingcar.domain.moverule.MoveRule;
import racingcar.domain.racer.Racers;
import racingcar.domain.result.Result;
import racingcar.util.Parser;
import racingcar.util.generator.NumberGenerator;
import java.util.List;
import java.util.Map;
public class RacingService {
private final Parser parser;
private final MoveRule moveRule;
private final Result result;
public RacingService(Parser parser, MoveRule moveRule, Result result) {
this.parser = parser;
this.moveRule = moveRule;
this.result = result;
}
public List<String> parseInputs(String input) {
return parser.parseCarNames(input);
}
public Map<String, Integer> roundStart(Racers racers) {
racers = racers.moveAll(moveRule);
return result.roundResult(racers);
}
public String gameEnd(Racers racers) {
return result.gameResult(racers);
}
}
이번 미션에서는 Controller와 Service의 역할을 분리해서 코드의 응집도를 높이고 결합도를 낮춰봤다.
Controller는 입출력 흐름 제어와 사용자와의 인터페이스만 담당하게 하고, Service는 도메인 객체 간 협력(비즈니스 로직)을 맡도록 하니 자연스럽게 의존성 방향이 “View → Controller → Service → Domain”으로 흘러갔다.
이전에는 Controller가 너무 많은 책임을 가지고 있어서 코드가 복잡해졌는데, 로직을 Service로 분리하고 나니 구조가 한결 깔끔해졌다. 그 과정을 거치면서 “Controller는 흐름 제어자이지 계산자가 아니다” 라는 말을 진짜 몸으로 느꼈다.
그러나 이번 미션에서 과연 Controller를 분리했을 때 큰 이점을 얻었는가에 대해서는 다소 아쉬움이 남았다. Controller를 직접 분리하고 적용해보면서 지금은 ‘Controller’라는 이름보다 ‘GameManager’처럼 역할이 더 드러나는 이름이 오히려 가독성을 높인다는 생각이 들었다. 이 과정을 통해 클래스의 이름은 코드의 의도를 가장 명확히 드러내는 수단이고 모든 설계 원칙은 상황과 맥락에 맞게 적용할 때 비로소 의미가 생긴다는 걸 깨달았다.
이번 경험을 통해 객체의 책임을 명확히 나누고, 상황에 맞는 구조를 설계하는 습관이 정말 중요하다는 걸 다시 한 번 느꼈다.
1주차 피드백 일부
2주차 시작전에 1주차 피드백을 쭉 다 읽었다. 내가 잘 지켰던 부분도 있고 내가 지키지 못했던 부분도 있었다. 그 중에서도 나는
에러를 만나면 바로 검색하지 말고 5분간 스스로 원인을 추측해보기
이 부분에 눈길이 확 갔다. 지금까지는 에러가 나면 에러 메시지를 읽지도 않고 검색을 하였다. 살짝의 찔림과 양심 때문인지는 몰라도 저 문구가 쌔게 들어왔다. 그래서 이번 주차에 추가로 에러를 만나면 에러 메시지를 읽고 내가 직접 찾아서 해결해보자! 라는 목표를 세웠다.
개발을 하면서 에러가 많이 나타나지는 않았지만(설계를 잘했나? ㅎㅎ)
가장 기억에 남았던 걸 하나 보여주겠다. Controller에서 각 도메인간의 협력을 생각하며 코드를 짜던 중 발생했던 오류이다.
java.lang.AssertionError:
Expecting actual:
"경주할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분)
시도할 횟수는 몇 회인가요?
실행 결과
최종 우승자 : pobi, woni"
to contain:
["pobi : -", "woni : ", "최종 우승자 : pobi"]
but could not find:
["pobi : -", "woni : "]
출력 결과를 보면 테스트는 pobi : -, woni : 을 기대했지만
실제 프로그램에서는 최종 우승자만 출력되고 있었다.
검색하지 않고 코드를 직접 따라가며 흐름을 확인했다.
RacingController의 racingStart() 메서드를 살펴보던 중 다음 코드가 눈에 들어왔다.
문제 코드 (수정전)
private void racingStart(Racers racers, Round round) {
for (int i = 0; i < round.getRoundNumber(); i++) {
racingService.roundStart(racers); // 반환값을 사용하지 않음
Map<String, Integer> roundResult = result.roundResult(racers);
outputView.printRoundResult(roundResult);
}
outputView.printGameResult(racingService.gameEnd(racers));
}
racingService.roundStart(racers) 내부에서는
자동차들이 이동된 새로운 Racers 객체를 반환하고 있었지만 이 반환값을 다시 racers 변수에 갱신하지 않았던 것이 문제였다. (내가 설계 해놓고 Controller 코드 짜면서 잊어버렸다...ㅠ)
즉, 불변 객체로 설계된 Racers가 이동 후의 상태를 외부로 전달하지 못한 것이다.
문제 코드 (수정 후)
private void racingStart(Racers racers, Round round) {
for (int i = 0; i < round.getRoundNumber(); i++) {
racers = racingService.roundStart(racers); // 반환값으로 갱신
Map<String, Integer> roundResult = result.roundResult(racers);
outputView.printRoundResult(roundResult);
}
outputView.printGameResult(racingService.gameEnd(racers));
}
racingService.roundStart()의 반환값을 racers에 재할당하여 각 라운드마다 최신 상태를 유지하도록 수정했다.
수정 후 테스트를 다시 실행하자 다음과 같이 기대한 출력이 정상적으로 표시되었다.
실행 결과
pobi : -
woni :
최종 우승자 : pobi
출력 결과와 코드를 비교하면서 메서드의 흐름을 직접 따라가 본 끝에 객체의 반환 구조가 원인이었음을 스스로 찾아낼 수 있었다.
그 결과, 단순히 오류를 고치는 데서 끝나지 않고 “왜 이 문제가 생겼는가”를 논리적으로 설명할 수 있게 되었다.
이 과정을 통해 정답을 빨리 찾기보다는 코드의 동작 원리를 이해하고, 문제를 구조적으로 분석하게 되었다.
검색으로는 단순히 에러의 형태만 알 수 있지만 직접 코드를 추론하며 따라가면 내 코드가 왜 그렇게 동작하는지를 스스로 이해할 수 있다.
앞으로도 에러를 만나면 무조건 검색부터 하기보다 출력 결과와 코드의 흐름을 대조하며 원인을 추측해 보는 시간을 먼저 갖으려고 한다.
또한, 이번 오류는 운이 좋게 빨리 발견하였다고 생각한다. 그래서 다음부터는 오류를 찾지 못한다면 디버깅을 사용해보려고 한다.
그 과정을 통해 단순히 문제를 해결하는 개발자가 아니라 문제를 이해하고 설명할 수 있는 개발자로 성장하고자 한다.
이번 자동차 경주 미션에서는 추상화의 방향과 순서에 대해 깊이 고민하게 되었다.
처음 설계할 때는 객체지향적으로 보이게 하겠다는 생각이 강했다.
그래서 자연스럽게 Racer(자동차)와 Racers(자동차 집합)를 먼저 추상화했다.
그때는 자동차의 역할을 분리한 것이 올바른 접근이라고 생각했다.
README 일부
Racer 코드
package racingcar.domain.racer;
import racingcar.domain.moverule.MoveRule;
import racingcar.util.generator.NumberGenerator;
public interface Racer {
String getName();
int getDistance();
Racer move(MoveRule moveRule);
}
Racers 코드
package racingcar.domain.racer;
import racingcar.domain.moverule.MoveRule;
import racingcar.util.generator.NumberGenerator;
import java.util.List;
public interface Racers {
Racers moveAll(MoveRule moverule);
List<Racer> getRacers();
}
하지만 구현을 진행하면서 점점 핵심 로직인 이동(move)이 오히려 구체 클래스 내부에 묶여버린 것을 깨달았다.
이 문제의 핵심은 “어떻게 달릴 것인가”, 즉 이동 규칙(MoveRule)이었는데 나는 초기에 “누가 달리는가(자동차 구조)”에만 집중한 셈이었다.
결과적으로 추상화 자체가 잘못된 건 아니었지만 추상화의 우선순위를 잘못 판단한 것이 이번 미션의 아쉬운 부분이었다.
이후 리팩터링 과정에서 MoveRule까지 추상화하며 이동 조건이 바뀌더라도 도메인을 수정하지 않아도 되는 구조로 개선했다.
예를 들어, 난수 대신 사용자가 직접 조건을 정의하거나, 다른 이동 규칙으로 바꾸더라도 MoveRule 구현체만 교체하면 된다.
그때서야 비로소 “이 문제에서 진짜 추상화가 필요한 부분은 바로 여기였구나”라는 걸 느꼈다.
이번 경험을 통해 “추상화는 얼마나 하느냐보다, 무엇부터 해야 하느냐가 더 중요하다”는 교훈을 얻었다.
다음 미션에서는 객체의 역할과 책임을 먼저 명확히 분리한 뒤 그 안에서 변화 가능성이 높은 것을 찾아 추상화하는 순서로 설계해볼 생각이다.
이 아쉬움이 다음 설계의 밑거름이 될 것이다.
1주차에서 느꼈던 아쉬움을 대부분 해소할 수 있었던 한 주였다.
기능 단위 커밋, 명확한 책임 분리, Controller–Service 구조 정리 등
지난 회고에서 다짐했던 부분들을 실제 코드로 구현해냈다는 점이 큰 의미였다.
물론 move 추상화처럼 설계 우선순위를 잘못 판단한 아쉬움도 있었지만,
그 과정을 통해 설계 단계에서 추상화를 결정하기 전에 무엇이 변할 수 있는지 한 번 더 고민해야겠다고 다짐하는 계기가 되었다.
이번 과제에서는 운이 좋게 빠르게 오류를 발견하여 디버깅을 사용하지 않았다...
그래서 다음 과제에서는 오류를 찾지 못한다면 꼭 디버깅을 사용해보는게 목표이다!
이번 경험들을 다음 미션의 설계 밑거름 삼아,
더 단단한 구조와 명확한 의도를 가진 코드를 작성해 나갈 것이다.