우아한 테크코스[프리코스] 3주차 회고록

2025. 11. 4. 00:00·우아한테크벨로/프리코스 회고록
728x90

들어가며,

이번주는 정말 고민을 많이 했던 것 같다.

왜냐면? 로또를 사본적이 없어서 로또가 어떤식으로 운영되는지를 몰랐다...😂

그래서 역할을 어떻게 분리하고, 어떻게 책임과 역할을 나눠야할지 고민을 했다.

TDD를 적용해보려고 했지만...역할을 분리하면서 클래스를 작성하다보니 테스트 코드를 작성하지 않고, 무의식이 구현을 해버렸다...

그래서 차근차근 리팩토링을 진행하면서 코드를 수정하였다.

2주차 공통 피드백 내용을 정리해보면,

  • READEME.md를 상세하게 적기
  • 기능 목록을 검토하기
  • 기능 목록을 업데이트하기
  • 값 하드 코딩하지 않기
  • 구현 순서 코딩 컨벤션 지키기
  • 변수 이름에 자료형 사용하지 않기
  • 한 메서드가 한 가지 기능만 담당하기
  • 메서드가 한 가지 기능을 하는지 확인하는 기준을 세우기
  • 작은 단위 테스트부터 만들기

 이다.

여기서 이번 과제를 하면서 집중한 부분은 한 메서드가 한 가지 기능만 담당하는지, 값을 하드 코딩하지 않기, 변수 이름에 자료형 사용하지 않기, 작은 단위의 테스트 만들기이다.(수시로 기능 목록을 수정하고, 검토했다.)

 

먼저, 구현을 하면서 어려웠던 것은 어떤 클래스를 만들어서 사용해야하는가? 어떤 역할을 전달해야하는가? 이었다.

그래서 그림으로 그려보았다.

그림으로 그리면서 헷갈렸던 부분은 Application Service와 Domain Service이다.

둘의 차이를 구분하는 것은 쉽지 않았다.

Application Service와 Domain Service의 차이

간단하게 생각해보면, 둘의 차이는 핵심 비즈니스 로직 자체를 가진것과 단순히 비즈니스 로직의 흐름을 다루는 것이다.

이번 과제를 예를 들어서 얘기해보자.

Application Service는 LottoManager이고, Domain Service는 ProfitRateCalculator이다.

LottoManager의 경우는 Controller의 요청에 따라서 Bonus 번호나 Lotto 번호를 생성하고, 당첨 통계값을 계산하도록 Rank와 Bonus, Lotto에 요청하는 것이다. 따라서 핵심 비즈니스 로직은 Lotto와 Rank, Bonus에서 동작하고, LottoManager는 이러한 동작을 하도록 흐름을 제어할 뿐이다.

반면에 ProfitRateCalculator는 Controller단에서 전달받은 purchaseAmount와 winningStatistics(Map<Rank, Integer>)를 가지고 받은 총상금을 계산하여 수익률을 반환한다. 즉, 단순한 비즈니스 로직 흐름 제어가 아닌 핵심 비즈니스 자체를 처리한다.

따라서 Domain Service가 되는 것이다.

 

InputView와 OutputView는 입/출력만 담당하게 하자

역할을 분리하면서 다시 한 번 생각해보았다. 1주차와 2주차 과제를 하면서 InputView에서 검증을 위한 Validator를 호출하였었는데, 과연 View에서 검증을 호출하는게 맞을까라는 생각을 했다.

결론은 View에는 단순히 입력과 출력만 하게 만드는 것이다.

View에서 검증을 요청하는 대신 Controller단에서 검증을 처리하게 했다.

그러면서 Controller의 역할에 대해서 다시 알아보았다.

Controller의 역할

Controller는 교통정리를 하는 중간관리자이다.

무슨말인가하면, 크게 4가지의 기능을 한다.

  • 요청 수신 (Input)
    • 사용자로부터의 모든 요청을 가장 먼저 받는다.
    • (예: Application이 inputView로부터 받은 "5000"이라는 문자열을 컨트롤러에게 전달)
  • 데이터 변환 및 검증 (Validation & Parsing)
    • 외부에서 받은 원시 데이터(String)를 시스템 내부에서 사용할 수 있는 유효한 도메인 객체나 데이터 타입으로 변환하고 검증한다.
    • (예: "5000"을 int 5000으로 변환, "1,2,3"을 Lotto 객체로 생성)
    • 이 과정에서 유효성 검사에 실패하면 예외(Exception)를 발생시킨다.
  • 로직 위임 (Delegate)
    • 가장 중요한 역할이다. 컨트롤러는 직접 비즈니스 로직을 처리하지 않는다.
    • 변환된 데이터를 LottoManager(애플리케이션 서비스)나 ProfitRateCalculator(도메인 서비스) 같은 전문가에게 전달하여 "일해달라"고 위임한다.
  • 결과 반환 (Response)
    • 서비스 계층(LottoManager)으로부터 처리된 결과를 돌려받는다.
    • 이 결과를 다시 Client(혹은 View)으로 전달하여 사용자에게 보여주도록 한다.

따라서 기존에 InputView에서 검증을 호출하는 대신 Controller단에서 검증을 하도록 구현하였다.

이를 통해서 조금 더 역할에 맞게 분리하여 구현할 수 있었다.

 

현실 반영 다형성

구현을 하면서 테스트 코드를 작성할 때, LottoTicket에 대해서 고민했다. 어떻게 해야 랜덤으로 생성되는 LottoTicket을 테스트하지?

그래서 생각한 것은 현실 세계의 로또 반영이다. 로또는 자동구매와 수동구매가 있다.

그래서 LottoTicket을 인터페이스로 두고, 구현체로 RandomLottoTicket과 CustomLottoTicket(과제에서는 test code에서만 사용)으로 나누었다.

이를 통해서 예측가능한 테스트 환경을 구성할 수 있었고, 확장성 또한 확보할 수 있었다.

과제에서는,

LottoManagerTest 코드에서 LottoManager가 의존하는 대상을 RandomLottoTicket이 아닌 CustomLottoTicket으로 대체(주입)할 수 있게 되었다.

calculateStatistics()를 테스트할 때, 1등부터 꽝(NONE)까지 모든 경계 값을 명확하게 제어할 수 있었다.

@DisplayName("5등이 2개 나머지가 1개씩 있는 당첨 통계를 계산한다")
@Test
void calculateStatisticsRankTest() {
    Lotto lotto = new Lotto(List.of(1, 2, 3, 4, 5, 6));
    Bonus bonus = new Bonus(7);

    List<LottoTicket> tickets = List.of(
            new CustomLottoTicket(List.of(1, 2, 3, 4, 5, 6)),
            new CustomLottoTicket(List.of(1, 2, 3, 4, 5, 7)),
            new CustomLottoTicket(List.of(1, 2, 3, 4, 5, 8)),
            new CustomLottoTicket(List.of(1, 2, 3, 4, 8, 9)),
            new CustomLottoTicket(List.of(1, 2, 3, 8, 9, 10)),
            new CustomLottoTicket(List.of(1, 2, 3, 11, 12, 13)),
            new CustomLottoTicket(List.of(10, 11, 12, 13, 14, 15))
    );
    Map<Rank, Integer> result = lottoManager.calculateStatistics(lotto, bonus, tickets);

    assertThat(result.get(Rank.FIRST)).isEqualTo(1);
    assertThat(result.get(Rank.SECOND)).isEqualTo(1);
    assertThat(result.get(Rank.THIRD)).isEqualTo(1);
    assertThat(result.get(Rank.FOURTH)).isEqualTo(1);
    assertThat(result.get(Rank.FIFTH)).isEqualTo(2);
    assertThat(result.get(Rank.NONE)).isEqualTo(1);
}

 

이것은 DIP(의존관계 역전 원칙)를 지키고, LottoManager가 구체적인 RandomLottoTicket이 아닌 추상적인 LottoTicket 인터페이스에 의존함으로써, 테스트가 가능한 유연한 구조를 구성했다.

추가로 OCP(개방-폐쇄 원칙)를 만족할 수도 있다. LottoManager에 대해서는 수정할 일이 없고, LottoTicket에 대해서는 새로운 기능(구현체)을 추가할 수 있기 때문이다.

 

상수를  사용하자

개발을 하면서 무의식적으로 상수를 사용하지 않고, 메서드 내부에서 하드코딩을 하는 경우가 많았었다. 그런데 이번 프리코스를 진행하면서 피드백으로 상수 사용을 통한 역할 부여의 필요성을 생각했다.

상수를 왜 써야하는가?

  • 변하지 않는 값을 관리하기 위해서 사용
  • 가독성을 높이기 위해서 사용
  • 유지보수를 쉽게하기 위해서 사용
  • 휴먼에러 방지

이렇게 4가지 정도의 이유가 있는 것 같다. 특히 같은 값이 많이 사용되는 경우라면 상수로 정의하는 것이 매우 유용할 것 같다고 생각한다.

그래서 과제에 반영해보았다.

public class RandomLottoTicket implements LottoTicket {

    private static final int MIN_LOTTO_NUMBER = 1;
    private static final int MAX_LOTTO_NUMBER = 45;
    private static final int NUMBERS_PER_TICKET = 6;
    private final List<Integer> numbers;

    public RandomLottoTicket() {
        this.numbers = Randoms.pickUniqueNumbersInRange(MIN_LOTTO_NUMBER, MAX_LOTTO_NUMBER, NUMBERS_PER_TICKET);
    }
}

public class LottoManager {

    private static final int LOTTO_PRICE = 1000;

    public int purchaseCount(int purchaseAmount) {
        return purchaseAmount / LOTTO_PRICE;
    }
}

이렇게 정의하면 값들이 어떤걸 의미하는지 알 수 있어서 단순히 값만을 하드코딩 했을 때보다 가독성이 좋다.

 

테스트를 작성하는 이유는 무엇인가?

테스트 코드를 작성하는 이유는 다양하다.

  • 기능의 정확성을 점검
  • 즉각적인 피드백
  • 구현한 기능의 문제를 빠르게 발견
  • 코드 구조와 의도 파악
  • 학습 도구
  • 더 나은 설계와 유지보수
  • CI/CD 자동화 파이프라인의 핵심 기반
  • 명세서로서의 역할

등등 다양한 가치가 있다. 그중에서 나에게 제일 크게 와닿은 테스트 코드를 작성하는 이유는 구현한 기능의 문제를 빠르게 발견 할 수 있다는 것과 CI/CD 자동화 파이프라인 구축을 위함이다.

이번 과제를 수행하며, 복잡한 로직에서 오류가 발생했을 때 테스트 코드는 마치 네비게이션처럼 어느 지점에서 문제가 발생했는지 빠르게 알려주었다. 이는 원인을 찾기 위해 수동으로 디버깅하던 시간을 획기적으로 줄여주었고, '신속한 피드백 루프'가 개발 효율성에 얼마나 큰 기여를 하는지 체감하게 했다.

최근 오픈 소스 프로젝트에 기여하면서 대규모 코드베이스의 안정성을 유지하는 것이 얼마나 중요한지 깨닫고 있다. 테스트 코드는 이러한 대규모 프로젝트의 품질을 보장하는 필수 요소다. 특히 CI/CD 파이프라인에 통합된 자동화된 테스트는, 내가 수정한 코드가 기존의 방대한 기능에 예기치 않은 영향을 미치지 않는지(회귀 테스트) 즉각적으로 검증해준다. 이는 코드의 안정성을 확보하고 안전하게 협업하기 위한 결정적인 안전장치다.

결론적으로 나에게 테스트 코드는 단순히 오류를 찾는 수동적인 검사 도구가 아니라 개발 속도를 높이는 '효율적인 개발 엔진'이자, 대규모 협업에서 코드의 안정성을 보장하는 '필수적인 안전망'이다.

 

이렇게 중요한 테스트 코드를 작성하는 것에 좀 더 효율적으로 중복코드를 줄이기 위한 여러 기능을 JUnit5는 가지고 있다.

그중에서 이번 과제를 수행하면서 사용했던 일부 기능을 보겠다.

@DisplayName("5등이 2개 나머지가 1개씩 있는 당첨 통계를 계산한다")
@Test
void calculateStatisticsRankTest() {
    Lotto lotto = new Lotto(List.of(1, 2, 3, 4, 5, 6));
    Bonus bonus = new Bonus(7);

    List<LottoTicket> tickets = List.of(
            new CustomLottoTicket(List.of(1, 2, 3, 4, 5, 6)),
            new CustomLottoTicket(List.of(1, 2, 3, 4, 5, 7)),
            new CustomLottoTicket(List.of(1, 2, 3, 4, 5, 8)),
            new CustomLottoTicket(List.of(1, 2, 3, 4, 8, 9)),
            new CustomLottoTicket(List.of(1, 2, 3, 8, 9, 10)),
            new CustomLottoTicket(List.of(1, 2, 3, 11, 12, 13)),
            new CustomLottoTicket(List.of(10, 11, 12, 13, 14, 15))
    );
    Map<Rank, Integer> result = lottoManager.calculateStatistics(lotto, bonus, tickets);

    assertThat(result.get(Rank.FIRST)).isEqualTo(1);
    assertThat(result.get(Rank.SECOND)).isEqualTo(1);
    assertThat(result.get(Rank.THIRD)).isEqualTo(1);
    assertThat(result.get(Rank.FOURTH)).isEqualTo(1);
    assertThat(result.get(Rank.FIFTH)).isEqualTo(2);
    assertThat(result.get(Rank.NONE)).isEqualTo(1);
}

이건 기존에 JUnit5에서 제공하는 @ParameterizedTest를 사용하지 않은 코드이다.

@DisplayName("5등이 2개 나머지가 1개씩 있는 당첨 통계를 계산한다")
@ParameterizedTest
@CsvSource({
        "FIRST, 1",
        "SECOND, 1",
        "THIRD, 1",
        "FOURTH, 1",
        "FIFTH, 2",
        "NONE, 1"
})
void calculateStatisticsRankTest(Rank rank, int expectedCount) {
    Lotto lotto = new Lotto(List.of(1, 2, 3, 4, 5, 6));
    Bonus bonus = new Bonus(7);

    List<LottoTicket> tickets = List.of(
            new CustomLottoTicket(List.of(1, 2, 3, 4, 5, 6)),
            new CustomLottoTicket(List.of(1, 2, 3, 4, 5, 7)),
            new CustomLottoTicket(List.of(1, 2, 3, 4, 5, 8)),
            new CustomLottoTicket(List.of(1, 2, 3, 4, 8, 9)),
            new CustomLottoTicket(List.of(1, 2, 3, 8, 9, 10)),
            new CustomLottoTicket(List.of(1, 2, 3, 11, 12, 13)),
            new CustomLottoTicket(List.of(10, 11, 12, 13, 14, 15))
    );
    Map<Rank, Integer> result = lottoManager.calculateStatistics(lotto, bonus, tickets);

    assertThat(result.get(rank)).isEqualTo(expectedCount);
}

이건 @ParameterizedTest와 @CsvSource를 사용한 테스트 코드이다.

코드를 보면 반복적으로 호출되던 아래 코드 부분이 한줄로 줄어든걸 볼 수 있다.

assertThat(result.get(...)).isEqualTo(...);

 

 

@ParameterizedTest는 JUnit 5(Jupiter)에서 사용하는 어노테이션으로, 하나의 테스트 메서드를 여러 개의 다른 파라미터(입력값)로 반복 실행할 수 있게 해준다. 이를 통해서 중복코드를 줄이고, 가독성을 높일 수 있다.

@DisplayName("구입 금액이 1000원 단위가 아니면 예외가 발생한다.")
@ParameterizedTest
@ValueSource(ints = {1100, 999})
void amountUnitTest(int input) {
    assertThatThrownBy(() -> PurchaseAmountValidator.validatePurchaseAmount(input))
            .isInstanceOf(IllegalArgumentException.class);
}

@ParameterizedTest는 데이터 소스(Data Source) 어노테이션과 함께 사용되어야 하며, JUnit 5는 테스트에 필요한 값들을 주입하기 위한 다양한 어노테이션을 제공한다. ex) @ValueSource, @CsvSource

더 다양한 데이터 소스는 아래의 링크를 통해서 Junit5 문서를 읽어보면 좋을 것 같다.

https://www.baeldung.com/parameterized-tests-junit-5

 

마무리하며,

2주차 피드백을 바탕으로 3주차 로또 미션을 진행하며, 테스트 코드의 중요성과 객체지향적인 설계에 대해 깊이 고민하는 시간을 가졌다.

처음에는 로또 도메인 지식이 부족해 TDD 적용에 실패하고 역할 분리에 어려움을 겪었다. 이 문제를 해결하기 위해, 2주차 피드백이었던 '메서드의 단일 책임', '상수 활용' 등을 적용하며 리팩토링을 진행했다.

이 글은 이번 미션의 핵심 고민들을 공유한다.

  1. 서비스 계층의 분리: Application Service와 Domain Service의 모호했던 경계를 명확히 구분하려 노력한 과정을 담았다.
  2. Controller의 역할 재정의: View는 입출력만 담당하도록 분리하고, 데이터 검증 및 변환의 책임을 Controller로 이관하며 각 계층의 역할을 명확히 했다.
  3. '랜덤'을 테스트하는 방법: "무작위로 생성되는 로또 티켓을 어떻게 테스트할 것인가?"라는 난제를 해결하기 위해, LottoTicket 인터페이스를 도입하고 다형성을 활용하여 테스트가 가능한 설계를(DIP, OCP) 구현한 경험을 공유한다.
  4. 테스트 코드의 진정한 가치: 과제 중 버그를 빠르게 찾는 '개발 효율성'과, 오픈 소스 기여 경험에서 느낀 'CI/CD를 통한 협업 안정성'이라는 두 가지 측면에서 테스트 코드가 왜 필수적인지 정리했다.

마지막으로, @ParameterizedTest와 같은 JUnit5 기능을 활용해 어떻게 더 효율적인 테스트 코드를 작성할 수 있었는지 돌아보면서 3주차 과제 회고를 마친다.

728x90

'우아한테크벨로 > 프리코스 회고록' 카테고리의 다른 글

우아안 테크코스[프리코스] 최종 코딩 테스트 회고록  (1) 2026.01.23
우아한테크코스[프리코스] 5주차 회고록  (0) 2025.11.20
우아한 테크코스[프리코스] 4주차 회고록  (0) 2025.11.12
우아한 테크코스[프리코스] 2주차 회고록  (0) 2025.10.28
우아한 테크코스[프리코스] 1주차 회고록  (0) 2025.10.21
'우아한테크벨로/프리코스 회고록' 카테고리의 다른 글
  • 우아한테크코스[프리코스] 5주차 회고록
  • 우아한 테크코스[프리코스] 4주차 회고록
  • 우아한 테크코스[프리코스] 2주차 회고록
  • 우아한 테크코스[프리코스] 1주차 회고록
Bello's
Bello's
개발하는 벨로
  • Bello's
    벨로의 개발일지
    Bello's
  • 전체
    오늘
    어제
    • 분류 전체보기 (199) N
      • 노예 일지 (7)
        • 스타트업 노예일지 (3)
      • CS 이론 (81)
        • 학과 수업 (4)
        • 알고리즘 (64)
        • 시스템 프로그래밍 (3)
        • 데이터 통신 (1)
        • 운영체제 (2)
        • 데이터베이스 (1)
      • project (3)
      • 나는 감자다. (4)
      • Spring (27)
      • 모각코 (45)
        • 절개와지조(모각코) (7)
        • 어쩌다보니 박준태가 조장이조 (11)
        • 어쩌다보니 박준태가 또 조장이조 (12)
      • LikeLion🦁 (20)
      • 캘리포니아 감자 (4)
      • OpenSource Contribute (1)
      • 우아한테크벨로 (1) N
        • 프리코스 회고록 (6) N
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    그래프 순회
    뛰슈
    BFS
    나는 감자
    JPA
    프리코스
    절개와지조
    티스토리챌린지
    타임리프
    백준
    누적합
    DFS
    어렵다
    회고록
    감자
    자바
    8기
    Spring
    모각코
    오블완
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
Bello's
우아한 테크코스[프리코스] 3주차 회고록
상단으로

티스토리툴바