✅ 들어가며
지금까지는 회고에 대한 별다른 피드백이 없어서 지난주에는 KPT 방식으로 회고를 작성했는데, 이번 주에는 프로코스 측에서 회고 내용에 대한 가이드라인을 잡아줬다.

따라서 이번주엔 나의 도전 과정을 중심으로 회고를 적어보고자 한다.
또한 다음과 같은 피드백도 있어서 추가적으로 테스트를 작성하는 이유를 내 경험을 토대로 한번 적어보겠다.

🤔 고민했던 것들
1️⃣ 컨트롤러에 대한 해석
이번 미션에서 가장 많이 고민했던 부분이다.
이번 미션의 입출력 과정을 보면 크게 두 가지로 나눌 수 있다.

- 로또 발행: 구입 금액 입력 → 발행한 로또 출력
- 당첨 통계 계산: 당첨번호 및 보너스 번호 입력 → 당첨 통계 출력
여기서 가장 고민됐던 부분은 ‘컨트롤러를 분리해야 할까?’였다.
컨트롤러를 하나로 두자
‘로또 발행과 당첨통계 계산’을 하나의 프로세스로 보는 것이다.
이렇게 하면 코드가 단순해진다. 그리고 지금의 목표는 미션을 완성하는 것이기 때문에, 처음에는 컨트롤러를 하나로 구현하는 것도 나쁘지 않다고 생각했다.
public class LottoController {
public void run() {
// 1. 로또 발행
// 2. 로또 당첨 통계 계산
}
}
하지만 곧 의문이 들었다. ‘컨트롤러의 메서드 하나 여러 번의 요청을 처리해도 괜찮을까?’
물론 웹소켓처럼 한 번 연결해서 지속적으로 통신하는 경우도 있지만, 지금의 케이스는 그와는 조금 다르다고 느꼈다. 무엇보다 현재 이루어지는 두 번의 요청은 서로 다른 요구사항이라고 생각했다. 그래서 자연스럽게 컨트롤러를 분리하고 싶은 마음이 강하게 들었다.
컨트롤러를 분리하자
아무리 한 화면에서 같이 이루어진다고 해도 ‘로또 발행, ‘당첨 통계 계산’은 엄연히 다른 요구사항이다. 그렇기에 분리를 해서 구현하기로 마음을 먹었다.
public class PurchaseLottoTicketController {
public 발행된_로또 purchase(구매_금액){}
}
public class AnalysisLottoTicketController{
public 당첨_통계 analysis(당첨번호&보너스번호){}
}
하지만 또 하나 걸리는게 있었다. 왜냐하면 두번째 요청인 ‘당첨 통계 계산’을 하려면 첫번째 요청에서 발행된 로또 정보가 있어야 하기 때문이다.
이를 해결하기 위해 생각한 첫번째 방법은 데이터베이스를 사용하는 것이다. 발행한 로또에 대해 고유 키값을 부여해 응답으로 함께 넘겨주고, 이후 요청을 보낼 때 해당 키값을 포함시키는 것이다. 물론 실제 데이터베이스를 사용하는 것이 아니라 자바의 map 자료구조를 사용해서 간이 데이터베이스를 만드는 것을 고려했다.
public class LottoRepository {
private static final Map<String, LottoTicket> store = new ConcurrentHashMap<>();
}
하지만 현재 요구사항이 매우 적기 때문에 이는 다소 오버엔지니어링이라고 느껴졌다.
따라서 두번째 생각한 방법은 로또 통계 요청을 할 때 발행한 로또 정보를 같이 넘기는 것이었다. 다소 논리적으로 어색하다고 느껴지긴 했지만 현재 문제를 해결할 가장 합리적인 방법이라고 생각했다.
public class PurchaseLottoTicketController {
public 발행된_로또 purchase(구매_금액){}
}
public class AnalysisLottoTicketController{
public 당첨_통계 analysis(당첨번호&보너스번호, 발행된_로또){}
}
입력과 출력은 어디서?
마지막으로 고민했던 부분이다. 구매 금액, 당첨번호&보너스번호의 입력, 발행된 로또, 당첨 통계에 대한 출력을 어디서 진행할 것이냐다.
당연히 이건 컨트롤러의 책임이 아니라고 생각했다. ‘컨트롤러는 요청을 처리하고, 그에 맞는 응답을 내려주는 역할’만 해야 한다고 봤고 직접 입력이나 출력에 관여하는 건 컨트롤러의 역할이 아니라고 생각했기 때문이다. 비유하자면, 게임 컨트롤러도 사용자가 조작해야 동작하는 것이지, 스스로 움직이는 건 말이 안 되는 것처럼 말이다. 또한 추후 웹 환경으로 확장되면 컨트롤러를 재사용할 수 없어서 컨트롤러에 넣는 것은 좋은 방식이 아니라고 생각했다.
따라서 나는 로또를 구매하고 통계를 계산하는 일련의 과정을 중계해주는 역할을 하는 클래스를 별도로 만들었다.
public class LottoCommandLineRunner {
private final PurchaseFlow purchaseFlow;
private final AnalysisFlow analysisFlow;
public LottoCommandLineRunner(PurchaseFlow purchaseFlow, AnalysisFlow analysisFlow) {
this.purchaseFlow = purchaseFlow;
this.analysisFlow = analysisFlow;
}
public void run() {
// 로또 구매
PurchaseResponse purchaseResponse = purchaseFlow.run();
// 로또 통계
analysisFlow.run(purchaseResponse);
}
}
2️⃣ 로또 번호 생성 로직의 위치

로또 번호 생성 로직은 현재 요구사항상 랜덤 방식으로 동작한다. 랜덤 로직은 java.util.Random이나 외부 라이브러리에 의존하기 때문에, 처음에는 해당 구현체를 인프라스트럭처(Infra) 계층에 두는 것이 타당하다고 판단했다.
하지만 로또 발행에는 번호를 무작위로 생성하는 자동 방식뿐만 아니라, 구매자가 직접 번호를 지정하는 수동 방식도 존재한다.
이 수동 방식의 경우 외부 기술이나 랜덤 로직을 사용하지 않고 입력된 값을 그대로 로또 번호로 사용하기 때문에, 이를 인프라 계층에 포함시키는 것은 적절하지 않다. 따라서 해당 구현체는 도메인(Domain) 계층에 위치하는 것이 타당하다.
domain/
└── LottoNumberGenerator.java
└── ManualLottoNumberGenerator.java
infra/
└── RandomLottoNumberGenerator.java
그런데 여기서 한 가지 고민이 됐다. 같은 인터페이스의 구현체가 서로 다른 위치에 있는 것이 맞을까? 만약 협업하고 있는 프로젝트라면 팀원 입장에서는 다소 혼란이 올 수 도 있을 것이라고 생각했다.
이와 관련해서 자료를 조금 찾아봤다. 그러던 중 다음과 같은 말을 찾게 되었다.
“도메인 인터페이스는 비즈니스 규칙을 표현하는 계약이며, 그 구현은 기술적 세부사항에 따라 적절한 계층에 둘 수 있다.”
- 『Domain-Driven Design』 Eric Evans
결국, 핵심은 “의존성의 방향이 도메인 → 인프라로만 흐르는가”에 있다.
이 원칙이 지켜진다면, 같은 인터페이스의 구현체가 도메인과 인프라에 각각 존재하더라도 이는 혼란이 아니라 DDD를 잘 적용한 사례라고 생각한다.
👏 다음번에 시도해보고 싶은 도전
1️⃣ 예외 처리 분리
현재는 컨트롤러에서 직접 예외를 잡아 이를 처리하고 있다.
public BaseResponse<PurchaseResponse> purchase(PurchaseRequest request) {
// TODO: 예외 처리 분리
LottoTicket lottoTicket = null;
try {
lottoTicket = generateLottoTicketService.generate(PurchaseAmount.from(request.amount()));
} catch (IllegalArgumentException e) {
return BaseResponse.onFailure(e.getMessage());
}
return BaseResponse.onSuccess(toPurchaseResponse(lottoTicket));
컨트롤러가 요청의 시작점이고 직접 로직을 호출해서 응답을 반환하기 때문에 여기서 예외를 처리하는 것이 이상한 것은 아니다. 하지만 try-catch문 자체가 너무 코드를 지저분하게 만들기도 하고 컨트롤러마다 반복적으로 작성되기 때문에 유지보수가 어렵다.
따라서 이를 분리할 수 있는 방법을 찾아보다가 프록시 패턴이라는 것을 알게됐다.
프록시 패턴에 대해 간단히 적어보자면, 클라이언트는 인터페이스를 바라보고 런타임시점에 Proxy를 주입한다.
Proxy는 중간에서 어떠한 일을 추가로 수행하며 실제 서버에 요청을 한다.

컨트롤러 인터페이스를 정의하고, 프록시 구현체와 실제 컨트롤러 구현체를 분리한 뒤 프록시에 예외 처리를 위임하는 방식으로 구현할 수 있을 것 같았다. 자바의 동적 프록시 개념도 함께 알게 되었지만, 개념만 이해했을 뿐 실제 코드로 적용하기엔 아직 어려웠다. 그래서 이번 주 내에는 구현이 힘들 것 같다고 판단했다.
비록 이번에 실제로 적용하진 못했지만, 좋은 방법을 알게 되었고 다음 미션에서는 꼭 제대로 구현해보고 싶다.
2️⃣ 객체의 캡슐화를 잘하자(TDA 원칙)
제출 후 코드리뷰를 진행하면서 다른 분들의 코드를 보다 보니 내 코드에서 객체를 통해 이루어지는 행동들이 대부분 ‘서비스’ 클래스로 흘러들어간것을 알게되었다.
다음은 그 대표적인 예시이다.
AnalyzeLottoTicketService의 countMatchedNumbers(): 특정 로또 번호 중 당첨번호와 일치하는 것이 몇개인지 계산하는 메서드
private static int countMatchedNumbers(Lotto targetLotto, WinningLotto winningNumber) {
int matchedCount = 0;
for (Integer number : targetLotto.getNumbers()) {
if(winningNumber.getNumbers().contains(number)) {
matchedCount++;
}
}
return matchedCount;
}
사실 처음에 구현을 할 때 기준을 해당 도메인의 필드로만 해결 가능한 것만 해당 객체 안에 두기로 잡았어서 WinningLotto와 Lotto와의 협력이 필요한 해당 메서드를 서비스로 분리했었다.
그러다 보니 서비스 클래스의 책임이 너무 과중됐다.
TDA(Tell, Don’t Ask) 원칙
코드리뷰를 하던 중 TDA 원칙에 대해 알게되었다.
TDA 원칙이란 ‘객체의 상태를 직접 조회하지 않고 필요한 행동만 요청하라는 의미’이다.
위에서 봤던 내 코드를 다시보자.
private static int countMatchedNumbers(Lotto targetLotto, WinningLotto winningNumber) {
int matchedCount = 0;
for (Integer number : targetLotto.getNumbers()) {
if(winningNumber.getNumbers().contains(number)) {
matchedCount++;
}
}
return matchedCount;
}
해당 코드는 현재 서비스에서 Lotto 및 WinningLotto 객체를 직접 가져와 일치여부를 체크하고 있다.
이는 명백한 TDA 원칙 위반이며, 객체에 대한 캡슐화가 제대로 지켜지지 않은 상황이다.
TDA 원칙을 지키려면 다음과 같이 수정해야 한다.
public class LottoWinningNumber {
private final WinningLotto winningNumber;
public int countMatchedNumbers(Lotto targetLotto) {
return targetLotto.countMatches(winningNumber::contains);
}
}
public final class WinningLotto {
private final List<Integer> numbers;
public boolean contains(int number) {
return this.numbers.contains(number);
}
}
/**
* 구매한 로또
*/
public final class Lotto {
private final List<Integer> numbers;
public int countMatches(IntPredicate predicate) {
int cnt = 0;
for (int n : numbers) {
if (predicate.test(n)) {
cnt++;
}
}
return cnt;
}
}
// 외부 호출 예시
int matchedCount = lottoWinningNumber.countMatchedNumbers(lotto);
서비스로 분리하는 기준 재정립
- 둘 이상의 애그리거트를 동시에 변경/조정한다.
- 외부 기술(랜덤 로직)과 결합된다.
- 복잡한 정책 조합/전략 교체가 필요하다.
- 저장소(Repository) 접근/트랜잭션 경계까지 다룬다.
그럼 Getter는 언제 사용해?
Getter를 아예 사용하지 않는 것은 말이 안된다고 생각한다. 하지만 최대한 지양해야한다.
핵심은 Getter를 가지고 특정 로직을 전개하냐에 달려있다.
나 나름대로의 기준표를 만들어봤다.
| 상황 | 게터 사용 | 설명 |
| 단순 조회 / 표시 / 출력 | ✅ 허용 | “읽기 전용”은 안전 |
| DB 저장 / 직렬화 | ✅ 허용 | 데이터 전달 목적 |
| 다른 로직의 조건 판단 | 🚫 금지 | 객체 외부에서 상태 기반 로직 수행 |
| 도메인 내부 계산 | ⚠️ 제한적 | 협력 객체가 대신 책임질 수 있는지 먼저 생각 |
🧩 내가 생각하는 테스트를 작성하는 이유
1️⃣ 프로그램의 안정성
생각보다 기능을 구현할 때 실수를 하는 경우가 많다. 하지만 꼼꼼하게 테스트를 작성하면 그런 실수를 미리 발견할 수 있다.
여기서 중요한 점은 모든 사소한 메서드라도 꼼꼼하게 테스트하는 것이다.
사실 예전에는 Postman으로 단순히 응답이 잘 오는지만 확인했는데, 그렇게 하다 보니 놓치는 버그들이 많았다.
결국 테스트를 작성하는 이유는 단순히 코드가 “잘 동작한다”는 걸 확인하기 위함이 아니다.
테스트는 내가 작성한 코드가 의도한 대로 작동하고 있는지, 그리고 변경 후에도 여전히 올바르게 작동하는지 보장하기 위한 안전망이다.
테스트가 잘 갖춰져 있으면 코드 수정이나 리팩터링을 할 때도 훨씬 자신감을 가질 수 있다.
2️⃣ 문서로서의 기능
테스트는 단순히 코드의 동작 여부를 확인하는 용도에 그치지 않는다. 특정 기능에 대한 문서로서의 역할도 한다.
보통 테스트를 작성할 때는 그 메서드의 성공 케이스와 실패 케이스를 함께 작성한다. 따라서 다른 사람이 테스트 코드를 보면, 실제 메서드 코드를 일일이 읽지 않아도 그 기능이 어떤 일을 하고, 어떤 예외 상황이 발생할 수 있는지를 훨씬 빠르게 파악할 수 있다.
이 말은 곧, 테스트 코드 역시 실제 코드 못지않게, 오히려 더 가독성 있게 작성해야 한다는 뜻이다.
내가 생각하는 가독성 있는 테스트 코드를 작성하는 방법은 다음과 같다.
- 하나의 테스트 메서드는 하나의 케이스만 테스트한다.
- @DisplayName()을 명확하게 작성한다.
- BDD 기반으로 구조화된 테스트를 작성한다.
✅ 마치며
최근에 읽고 있는 『객체지향의 사실과 오해』에서 “좋은 객체지향 설계를 위해서는 객체의 상태가 아니라 행동에 집중해야 한다”라는 문장을 보고 꽤 큰 깨달음을 얻었다.
이번 미션의 목표 중 하나가 “관련된 함수를 묶어 클래스로 만들고, 객체들이 협력해 하나의 기능을 완성하도록 한다”는 것이던 만큼, 나도 이번엔 행동 중심으로 생각을 전환해보기로 했다.
그래서 과정을 이렇게 정리해 봤다.
- 금액을 입력받는다.
- 입력받은 금액만큼 로또 번호를 발행한다.
- 발행된 로또 번호를 출력한다.
- 당첨 번호와 보너스 번호를 입력받는다.
- 발행된 로또들과 비교해 수익률을 계산한다.
- 최종 당첨 통계를 출력한다.
이렇게 정리하고 나니, 자연스럽게 어떤 객체들이 필요할지 하나씩 보이기 시작했다. 그 이후엔 객체 단위로 디렉토리를 나누고, 각자 역할에 맞게 배치했다.
물론 쉽진 않았다. 특히 컨트롤러 설계 부분에서 정말 고민을 많이 했다. 그냥 “돌아가는 코드”를 만드는 건 어렵지 않은데, “좋은 구조의 코드”를 만드는 건 완전히 다른 문제라는 걸 새삼 느꼈다.
처음엔 일단 기능을 완성하는 데 집중했지만, 시간이 지나면서 “이 코드, 나중에 유지 보수하기 편할까?” “새로운 기능을 추가할 때 쉽게 확장할 수 있을까?” 같은 생각들이 머릿속을 떠나질 않았다.
결국 이번 미션은 단순한 코딩 과제가 아니라, 사고방식 자체를 바꾸는 경험이었다.
아직 부족한 점이 많지만, 그래도 이제는 코드를 짜기 전에 “이 행동은 어느 객체의 책임이지?”라는 질문이 먼저 떠오르는 걸 보면, 나름대로 성장한 것 같다.
'우테코 8기 > 프리코스 회고' 카테고리의 다른 글
| [8기 프리코스 최종 회고] 코딩테스트 및 합격 후기 (0) | 2026.01.23 |
|---|---|
| [프리코스] 2주차 회고 (0) | 2025.10.28 |
| [프리코스] 1주차 회고 (0) | 2025.10.21 |