✅ 들어가며
지난주에는 회고를 의식의 흐름대로 작성했었는데 테크코스 커뮤니티에서 어떤 분이 효율적인 회고작성 방식에 관한 글을 올려주셨다. 형식도 없이 적는 것 보다는 어느정도 검증된 구조적인 방법으로 회고를 작성하는 것이 나을 것 같아 이번에는 해당 글을 참고해서 적어보기로 했다.
많은 회고작성 방식이 있지만 그중에서도 나는 KPT 방식으로 회고를 작성해보기로 했다.
KPT 방식에 관해 간단히 설명하자면, KPT는 Keep, Problem, Try의 약자로 Keep은 잘한점, Problem은 부족한 점, 변화가 필요한 점, Try는 앞으로 도전해볼 부분을 작성한다.
👍 Keep
1️⃣ 1주차 코드리뷰
여기에 넣기 애매했지만 앞으로도 열심히 하자는 의미에서 한번 넣어봤다.
정말 1주차 동안은 코드리뷰를 열심히 했다고 생각한다. 내 코드에 대한 다른 분들의 코드리뷰를 읽고 피드백을 반영하고 나도 다른 분들의 코드를 읽고 성심성의껏 피드백을 줬다. 그 과정에서 분명 혼자 코드를 짰을 때 보다 배우는게 더욱 많았다.
앞으로도 열심히 하자!!!
2️⃣ 도메인 중심 설계 도입
해당 미션을 보고 도메인들의 역할을 명확하게 이해하는 것이 중요하다고 생각해 도메인 중심 설계를 도입했다.
추출해낸 도메인들은 다음과 같다.

| 클래스 | 설명 |
| RacingGame | 자동차 경주 |
| Car | 자동차 |
| RacingCar | 자동자 경주에 참가하는 차 |
| RacingRecord | 경주 라운드별 기록 |
해당 방식으로 설계하게 되면 비즈니스 로직이 도메인들을 중심으로 전개되게 된다.
이렇게 하면 오는 장점이 뭘까? 나는 크게 두 가지라고 생각한다.
1️⃣ 첫번째는 객체지향적인 표현력이 향상된다.
다음은 RacingCar의 attemptMove()라는 함수다.
public void attemptMove() {
if (Randoms.pickNumberInRange(0, 9) >= 4) {
// 난수가 4 이상이면 1 만큼 이동
position++;
}
}
이 코드를 실행하는 racingCar.attemptMove()는 “레이싱 카가 움직임을 시도한다”는 비즈니스 문장과 의미적으로 완전히 일치한다.
즉, 이 메서드는 단순한 기능 호출을 넘어 비즈니스 상황을 그대로 코드로 표현하는 살아 있는 모델이 된다.
2️⃣ 두번째는 비즈니스 규칙 변경에 유연하게 대응할 수 있다.
비즈니스는 항상 변하기 마련인데, 도메인 중심 설계를 도입하면 훨씬 쉽게 대응할 수 있다.
왜냐하면 비즈니스 규칙이 한 곳에 모여 있기 때문이다.
RacingGame에서는 다음의 비즈니스 규칙을 검증한다.
- 자동차 경주에는 두 개 이상의 자동차가 참여해야한다.
- 자동차 경주에는 중복된 이름을 가진 자동차가 참여하면 안된다.
- 라운드 수는 1개 이상이어야 한다.
public class RacingGame {
private final List<RacingCar> racingCars;
private final List<RacingRecord> racingRecords;
private final List<String> winners;
private final int roundCount;
public static RacingGame create(List<RacingCar> racingCars, int roundCount) {
validateCarListSize(racingCars);
validateCarNameDuplicate(racingCars);
validateRoundCount(roundCount);
return new RacingGame(racingCars, new ArrayList<>(), new ArrayList<>(), roundCount);
}
}
여기서 추후 ‘자동차 경주에는 최소 자동차가 3대 이상은 있어야 한다.’라는 비즈니스 규칙이 추가되면 고민할 것 없이 오직 이 RacingGame만 수정하면 된다.
3️⃣ 사소한 것에도 집중
메서드로서 private과 private static의 차이 파악
인텔리제이의 메서드 추출 기능(MacOS 기준: CMD+Option+N)을 사용해보면 메서드를 private 혹은 private static으로 만들어주게 된다. 사실 그동안은 기능 구현에만 몰두하는 바람에 그 이유를 공부할 엄두를 내지 못했다. 그러다가 이번에 기회가 됐고 그 차이를 제대로 고민해보았다.
🧩 private static 메서드
private static Map<String, Integer> initCarPositionMap(List<Car> cars) {
Map<String, Integer> carPositions = new HashMap<>(cars.size());
for (Car car : cars) {
carPositions.put(car.getName(), 0);
}
return carPositions;
}
특징
- 클래스 레벨에서 동작한다.
- → 즉, CarRacingService의 객체를 만들지 않아도 바로 쓸 수 있다.
CarRacingService.initCarPositionMap(cars);
- 인스턴스 필드나 메서드를 사용할 수 없다.
- → this 키워드 접근 불가.
의미
initCarPositionMap()은 cars라는 매개변수만 받아서 Map을 초기화하는 순수 유틸리티 메서드이다.
즉, CarRacingService 인스턴스의 내부 상태(필드)를 전혀 사용하지 않으니까 static으로 선언하는 게 논리적으로 맞다.
🧠 private 만 있고 static 이 없는 경우
private Map<String, Integer> initCarPositionMap(List<Car> cars) {
Map<String, Integer> carPositions = new HashMap<>(cars.size());
for (Car car : cars) {
carPositions.put(car.getName(), 0);
}
return carPositions;
}
✅ 특징
- 인스턴스 메서드라서 반드시 객체가 존재해야 호출 가능하다.
CarRacingService service = new CarRacingService();
service.initCarPositionMap(cars);
- 내부에서 this.someField처럼 객체 상태를 접근할 수 있다.
💡 의미
이건 “이 서비스 인스턴스가 가진 상태와 연관된 초기화 작업”을 한다는 의미이다..
하지만 CarRacingService는 필드가 없고, initCarPositionMap()도 단순히 매개변수 기반으로 동작한다.
따라서 인스턴스 메서드로 둘 이유가 거의 없다.
👉 결론
initCarPositionMap()처럼
- 인스턴스 필드에 의존하지 않고
- 입력값만으로 결과를 만드는 순수한 기능이라면
private static으로 선언하는 게 가독성과 명확성 모두 좋다.
👎 Problem
1️⃣ Getter의 무분별한 사용
1주차 백엔드 공통 피드백에 ‘Setter, Getter 사용을 지양해라’는 말이 있었다. 하지만 나는 이말이 이해가 되질 않았다. ‘Setter는 왜 지양해야하는지 알겠는데 Getter는 왜? 그냥 조회하는건 문제 없지 않나?’라는 생각을 하고 건방지게 해당 피드백을 무시하고 코드를 구현했었다.
그러던 중 프리코스 커뮤니티에서 추천해 주신 다음 글을 읽게 됐다.
https://velog.io/@backfox/getter-쓰지-말라고만-하고-가버리면-어떡해요#조회를-위해-필드-값을-꼭-가져와야-겠다면
읽고나니 그동안 내가 얼마나 건방졌는지 알게됐다… 그리고 착각을 하고 있었다.
핵심은 Getter 자체이기 보다는 Getter를 통해 해당 객체를 외부에서 수정할 수 있다는 점이었다.
다음은 내가 짠 건방진 코드이다. RacingRecord의 필드인 Map을 반환하는 Getter이다.
public Map<String, Integer> getCarPositions() {
return this.carPositions;
}
물론 단순 조회로만 Getter를 사용하면 아무 문제가 없을 것이다.
하지만 해당 객체를 사용하는 누군가가 다음과 같이 코드를 짠다면 어떻게 될까?
Map<String, Integer> racingRecords = racingRecord.getCarPositions();
racingRecords.clear(); // 데이터 다 삭제
정말 큰 문제로 이어질 수도 있다.
그럼 어떻게 수정해야 할까?
위의 글에서 제시하는 방법은 바로 수정할 수 없게 하는 것이다. 예를 들면 Collections.unmodifiableMap()을 사용하는 것이다.
public Map<String, Integer> getCarPositions() {
return Collections.unmodifiableMap(carPositions);
}
이렇게 하면 위에 처럼 clear()와 같은 메서드를 호출하게 되면 예외가 터지게 된다.
[UnmodifiableCollection 내부 코드]
public void clear() {
throw new UnsupportedOperationException();
}
이번 경험을 통해 확실히 알지 못하는 것에 소신을 갖지 말고 한번 더 의심해보는 자세를 갖기로 결심했다.
2️⃣ 꼼꼼한 코드 작성
스터디원들과 코드리뷰를 진행하면서 한가지 놓친게 있다는 것을 깨달았다. 바로 입력으로 차 이름 앞 뒤에 공백이 오는 경우를 생각하지 못한 것이다.
Arrays.stream(split)
.map(String::trim) // 이름 앞 뒤에 포함되어 있는 공백 제거
.toList();
위와 같이 trim()을 호출하면 간단하게 처리되는 문제이지만 처음부터 꼼꼼하게 코드를 짜지 못한 것 같아 아쉬웠다.
3️⃣ 꼼꼼한 예외 처리
제출 후 커뮤니티에서 코드리뷰를 진행하다가 예외처리를 미흡하게 했다는 것을 알게됐다.
그 중 대표적인 예시가 라운드 수에 대한 오버플로우 처리였다.
public static void validateRoundCountOverflow(String roundCount) {
BigInteger bi = null;
try {
bi = new BigInteger(roundCount); // 여기서 예외가 터지면 roundCount 가 숫자가 아닌 형태
} catch (NumberFormatException ignored) {
return; // 다른 곳에서 예외처리
}
if(bi.compareTo(BigInteger.valueOf(Integer.MAX_VALUE)) > 0) {
throw new IllegalArgumentException("입력된 숫자가 너무 큽니다.");
}
}
사실 이 부분은 지난주에 실제로 구현했던 부분인데 이번에는 놓쳤다는 부분이 아쉬웠다.
대체적으로 나는 비즈니스 규칙에 대해서는 예외처리를 잘하지만, 입력값 검증 같은 시스템 규칙을 많이 놓치는 것 같다.
🙌 Try
1️⃣ 객체의 행동을 중심으로 생각하자
“객체지향의 사실과 오해”라는 책을 공부하고 있는데 거기서 다음과 같은 말이 있었다.
객체지향 개발자가 가장 쉽게 빠지는 함정은 상태를 중심으로 객체를 바라보는 것이며 상태를 먼저 결정하고 행동을 나중에 결정하는 방법은 설계에 나쁜 영향을 끼친다.
나는 지금까지 어떤 객체를 설계하려면 ‘이 객체가 어떤 상태를 가지지?’ 부터 생각했다.
예를 들어, 자동차라면 ‘자동차는 이름이라는 상태를 가진다’에서 부터 시작했었다.
이렇게 하지말고 행동을 중심으로 생각해야 한다.
'자동차는 움직일 수 있다. 자동차의 이름을 변경할 수 있다.' 등의 행동을 생각 한 뒤 ‘이 행동을 하려면 어떤 상태가 필요할까?’ 부터 생각해봐야 한다.
2️⃣ 상수들을 Enum으로 설계
미션에서 사용되는 상수로는 다음과 같은 것들이 있다.
- 콘솔 안내 메시지
- 예외 메시지
나는 지금까지 이 상수들을 하드코딩하거나
public static void validateRoundCountIsNumber(String roundCount) {
try {
Integer.parseInt(roundCount);
} catch (NumberFormatException e) {
throw new IllegalArgumentException("시도할 횟수로 숫자만 가능합니다.");
}
}
해당 클래스 상단에 private static final로 추가했었다.
public class ConsoleRacingGameInputView implements RacingGameInputView {
private static final String CAR_NAME_GUIDE = "경주할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분)";
private static final String ROUND_COUNT_GUIDE = "시도할 횟수는 몇 회인가요?";
@Override
public String readCarNames() {
System.out.println(CAR_NAME_GUIDE);
return Console.readLine();
}
@Override
public String readRoundCount() {
System.out.println(ROUND_COUNT_GUIDE);
return Console.readLine();
}
}
구현하면서도 찜찜한 느낌이 들었다.
클래스 상단에 두면 코드가 지저분해지는 느낌이고 하드 코딩을 하게되면, 전달력이 떨어진다는 느낌이 들었다.
그러던 중 공통 피드백에서 ‘상수를 하드 코딩하지 마라’라는 이야기가 있었고 다른 분들의 코드를 리뷰하면서 해당 부분을 enum으로 구현하면 내 찜찜한 느낌이 모두 해결된다는 것을 알게되었다. 다음은 그 예시이다.
public enum ErrorMessage {
BLANK_INPUT_ERROR("빈 문자열이 입력되었습니다."),
NAME_BLANK_ERROR("자동차 이름은 빈 값을 허용하지 않습니다."),
NAME_LENGTH_ERROR("자동차의 이름은 이름은 5자 이하만 가능합니다."),
NAME_DUPLICATE_ERROR("자동차의 이름이 중복됩니다."),
CAR_SIZE_ERROR("자동차는 최소 2대 이상이여야 경주가 가능합니다."),
NUMBER_FORMAT_ERROR("전진 시도할 횟수는 숫자만 입력 가능합니다."),
COUNT_RANGE_ERROR("전진 시도할 횟수는 양수만 입력 가능합니다."),
;
private final String message;
ErrorMessage(String message) {
this.message = message;
}
public String getMessage() {
return message;
}
}
이렇게 하면 상수를 한 곳에서 관리할 수 있어 유지보수가 쉬워지고, 의미 있는 이름으로 상수를 표현하므로 코드의 가독성이 높아진다. 또한 문자열 오타나 중복 선언을 방지할 수 있고, 컴파일 시점에 타입 안정성(Type Safety)을 확보할 수 있어 안정적인 코드 작성이 가능하다.
예를 들어 다음과 같이 테스트 과정에서 에러 메시지까지 검증하는데 만약 에러메시지가 변경되게 되면 테스트 코드까지 변경해야 한다.
public static void validateRoundCountOverflow(String roundCount) {
BigInteger bi = null;
try {
bi = new BigInteger(roundCount); // 여기서 예외가 터지면 roundCount 가 숫자가 아닌 형태
} catch (NumberFormatException ignored) {
return; // 다른 곳에서 예외처리
}
if(bi.compareTo(BigInteger.valueOf(Integer.MAX_VALUE)) > 0) {
throw new IllegalArgumentException("입력된 숫자가 너무 큽니다.");
}
}
@Test
@DisplayName("라운드로 입력받은 숫자가 너무 크면 예외가 발생한다.")
public void validateRoundCountOverflow() throws Exception {
// given
String roundCount = "2147483648"; // int 범위는 2,147,483,647까지
// when // then
assertThatThrownBy(() -> CarRacingValidator.validateRoundCountOverflow(roundCount))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("입력된 숫자가 너무 큽니다.");
}
하지만 이를 Enum을 사용한다면 다음과 같이 사용할 수 있게 돼 유지보수가 용이해진다.
public static void validateRoundCountOverflow(String roundCount) {
BigInteger bi = null;
try {
bi = new BigInteger(roundCount); // 여기서 예외가 터지면 roundCount 가 숫자가 아닌 형태
} catch (NumberFormatException ignored) {
return; // 다른 곳에서 예외처리
}
if(bi.compareTo(BigInteger.valueOf(Integer.MAX_VALUE)) > 0) {
throw new IllegalArgumentException(ErrorMessage.TOO_BIG_NUMBER.getMessage());
}
}
@Test
@DisplayName("라운드로 입력받은 숫자가 너무 크면 예외가 발생한다.")
public void validateRoundCountOverflow() throws Exception {
// given
String roundCount = "2147483648"; // int 범위는 2,147,483,647까지
// when // then
assertThatThrownBy(() -> CarRacingValidator.validateRoundCountOverflow(roundCount))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage(ErrorMessage.TOO_BIG_NUMBER.getMessage());
}
👏 마치며
이번 한 주는 정말 만족스러운 시간이었다. 그동안 자바로 프로젝트를 진행할 때는 프레임워크나 디자인 패턴에 코드를 억지로 끼워 맞추는 데에만 집중했었다. 하지만 이번에는 처음부터 도메인을 직접 설계하고, 각 객체의 책임에 대해 깊이 고민하면서 그동안 미처 알지 못했던 부분들을 많이 배우게 되었다.
또한 다른 사람들과 코드를 공유하며 내가 놓쳤던 부분이나 생각하지 못했던 다양한 구현 방식을 접할 수 있었고, 이를 통해 한층 더 성장할 수 있었다.
나는 프리코스의 핵심이 ‘모르거나 해보지 않았던 것들을 스스로 깨닫고 시도하며 성장하는 과정’이라고 생각한다. 다음 주에는 또 어떤 새로운 배움과 경험이 기다리고 있을지 벌써 기대가 된다.
'우테코 8기 > 프리코스 회고' 카테고리의 다른 글
| [8기 프리코스 최종 회고] 코딩테스트 및 합격 후기 (0) | 2026.01.23 |
|---|---|
| [프리코스] 3주차 회고 (0) | 2025.11.04 |
| [프리코스] 1주차 회고 (0) | 2025.10.21 |