우테코 프리코스 3주차 회고

김한준 (Hanjun Kim)·2022년 11월 14일
0

회고

목록 보기
2/4

들어가며

3주차는 로또 미션이었다. 지켜야할 요구사항도 더 많아졌다. 자바 코드 컨밴션도 지켜야 하고, 한 메서드 안에 15 라인 이상을 작성하지 않아야 한다. 요구사항이 누적될 수록 과제 제출 직전에 찾아오는 불안감이 점점 커지는 것 같다. 미션을 진행하며 해당 요구사항들이 내 몸에 체화되길 바랄 뿐이다.

이번 주차에서 핵심적으로 학습해야 할 내용은 클래스 분리와 도메인 로직에 대한 단위 테스트였다. 역시나 만만치 않았다.

중점적으로 학습한 내용

클래스 분리

이번 주차에 가장 시간을 많이 들인 부분은 클래스 분리였다. 이유는 애매함 때문이었다. '지금 이 도메인을 새로운 도메인으로 분리해야할까?', '지금 객체가 갖고있는 행동이 다른 클래스의 메서드로 분리되었을 때 더 적절하지 않을까?' 등등의 생각들을 수 십번은 넘게 한 것 같다. 객체지향의 사실과 오해를 읽으며 그래도 객체에 대해 이해하는게 조금이나마 더 수월해졌다고 느꼈었는데 역시 실제로 코드를 작성하며 적용해보려니 만만치 않았다.

public class Lottos {
    private final List<Lotto> lottos = new ArrayList<>();

    private Lottos(int money) {
        validate(money);
        purchaseRandomLottos(money);
    }
    
    (중략)
    
    public Map<LottoResult, Integer> getWinningCounts(Lotto winningLotto, int bonusNumber) {
        HashMap<LottoResult, Integer> winningCounts = getEmptyWinningCounts();
        for (Lotto lotto : lottos) {
            LottoResult lottoResult = lotto.getResult(winningLotto, bonusNumber);
            Integer winningCount = winningCounts.getOrDefault(lottoResult, DEFAULT_COUNT) + ADD_COUNT;
            winningCounts.put(lottoResult, winningCount);
        }
        return winningCounts;
    }
    
    (후략)
    
}

위 코드는 2주차 과제를 수행하면서 클래스 분리에 가장 많은 고민을 했던 부분이다. getWinningCounts 메서드는 구매한 로또들을 모아 놓은 객체에서 당첨 횟수를 계산하는 기능을 수행한다. 우선 해당 메서드 내에서 HashMap이 선언된다는 것 부터 맘에 안들었다. HashMap 대신 WinningCounts라는 독립적인 타입을 정의해서 사용하는 방법도 검토해봤다.

또한 Lottos 클래스의 public 메서드들 중 다수가 winningLottosbonusNumber를 인자로 받고 있었다. 이 부분 또한 클래스로 분리해서 'LottobonusNumber를 멤버 변수로 갖는 WinningLotto를 만들어 보는 것은 어떨까?'라는 고민도 해봤다. 그렇다면 Lotto 클래스를 상속받는 PurchasedLotto 클래스도 새롭게 정의해야할 것이다.

이처럼 머릿 속으로 여러가지 클래스 분리 방식을 고민했다. 하지만 그럴때마다

'객체에서 중요한 것은 행동이다. 객체를 설계할 때는 행동이 중심이 되야한다.'

라는 <객체지향의 사실과 오해>의 한 구절이 자꾸 멤돌았다.

단순히 내가 코드를 깔끔하게 구현하기 위해서 '상태'를 기준으로 클래스를 나누려고 하는 과오를 범하고 있는 것만 같았다. 뿐만 아니라 Lottos 클래스에서 수행되는 많은 행동(메서드)들이 나름 알맞은 곳에 위치하고 있다는 생각도 들었다.

그렇게 고민만 거듭하다 일주일이란 시간이 훌쩍 지나버렸다. 행동을 기준으로 객체를 설계하라는 말이 진정으로 의미하는 것은 무엇일까? 지금까지 보통 상태를 기준으로 클래스를 분리해오는 것에 익숙했던 것 같다. 아니면 내가 객체의 행동에만 너무 집착하고 있는 건 아닐까 라는 생각도 든다.

단위 테스트

확실히 테스트를 작성하는 것은 효율적이다. 이전엔 테스트 코드를 작성하느라 오히려 시간이 더 많이 소요된다고 생각했다. 기능을 구현하기도 바쁜데 테스트까지 해야하니 할 일이 더 많아진 느낌이었다. 하지만 돌이켜 생각해보면 테스트를 작성하니 시간이 더욱 절약되는 듯 했다. 도메인에 구현한 기능이 잘 작동하는지 콘솔이나 디버깅을 통해 확인할 일이 현저히 줄어들었다. 테스트 코드를 작성하니 그냥 테스트 실행 버튼만 누르면 끝이었다.

뿐만 아니라 테스트 코드를 작성하니 기능이 점점 쌓여도 불안하지 않았다. 구현하는 기능이 많아지면 서로 다른 기능들이 상호작용 하며 개발자가 미쳐 확인하지 못한 부분에서 버그를 일으킬 수도 있다. 하지만 기능 단위로 테스트도 함께 진행하니 새로운 기능이 추가되어도 이전에 구현했던 기능들이 제대로 동작하는지 쉽게 파악할 수 있었다. 테스트 코드 덕분에 개발을 하면서도 좀 더 안전한 환경에서 프로그래밍한다는 느낌을 받았던 것 같다.

2주차에 이어 3주차까지 테스트를 작성하다 보니 테스트 코드의 중요성이 더 확실히 와닿는 느낌이었다. 그만큼 테스트 코드를 더 빈틈없이 설계하는 것이 중요하다는 걸 뼈저리게 느꼈다. 테스트 코드가 부실하면 테스트가 통과해도 막상 기능은 고장나있는 경우가 생길 수도 있다. 이를 방지하기 위해 최대한 발생할 수 있는 모든 경우를 염두해 두고 테스트를 설계하는 연습을 해야한다고 느꼈다.

미션을 거듭하며 역시 무엇이든 '제대로' 하기가 정말 어렵다는 걸 느낀다. 단순히 기능 구현에만 초점을 맞춘 코딩은 쉽다. 하지만 클린코드를 적용하면서 기능을 구현하는 건 다른 차원의 일이었다. 테스트 코드도 마찬가지였다. 단순히 기능을 테스트 하는 것은 쉽다. 하지만 제대로 된 테스트 코드를 작성하는 것은 쉽지 않았다. 좋은 테스트 코드가 무엇인지 배워가면서 간단한 테스트를 진행하는 데에도 고려하고 검토해야할 사항들이 많아지고, 방금 내가 작성한 코드가 좋은 테스트 코드인지 되돌아보게 되는 일이 잦아진 것 같다. 뿐만 아니라 기능을 구현하면서도 '이 코드가 과연 테스트하기 좋은 코드일까?', '테스트하기 어려운 코드라면 어떻게 바꾸면 좋을까?'와 같은 방식으로 계속 고민하게 된다.

단위 테스트를 학습하며 참고한 자료입니다.

Enum

쓰면 쓸수록 느낀다. Enum은 진짜 좋은 놈이다. 단순히 상수를 관리한다는 차원을 넘어 독립적인 객체로써 활용된다는 느낌을 준다. 특히 하나의 상수에 여러가지 값을 대응시킬 수 있다는 좋았다.

public enum LottoResult {
    FIRST_PLACE(2000000000, 6),
    SECOND_PLACE(30000000, 5),
    THIRD_PLACE(1500000, 5),
    FOURTH_PLACE(50000, 4),
    FIFTH_PLACE(5000, 3),
    LAST_PLACE(0, 0);

    private final int amount;
    private final int matchingCount;

    LottoResult(int amount, int matchingCount) {
        this.amount = amount;
        this.matchingCount = matchingCount;
    }
    
    (후략)
}

위의 코드처럼 LottoResult 열거체를 통해 몇 개의 숫자를 맞추면 1등을 하고, 해당하는 당첨 금액은 얼마인지까지 하나의 상수에 담아둘 수 있었다.

이를 통해 추후에 당첨 조건과 당첨 금액이 변하더라도 유지 보수가 편하게 끔 코드를 작성할 수 있었다. 예를 들어 아래의 코드처럼 당첨 결과를 출력하는 기능을 구현할 때 Enum을 활용하여 당첨 조건과 금액의 변화에도 끄떡없도록 설계할 수 있었다.

private static String getResultMessage(Map<LottoResult, Integer> winningCounts, LottoResult result) {
	StringBuilder message = new StringBuilder();

	message.append(String.format(INFORM_MATCHING_COUNT, result.getMatchingCount()));
    if (result.equals(LottoResult.SECOND_PLACE)) {
    	message.append(INFORM_SAME_BONUS_NUMBER);
    }
    message.append(String.format(INFORM_WINNING_AMOUNT, Numbers.getNumbersWithComma(result.getAmount())))
    	.append(STATISTIC_SEPARATOR)
        .append(String.format(INFORM_WINNING_COUNTS, winningCounts.get(result)));

	return message.toString();
}

Enum은 정말 활용 가능성이 무궁무진한 것 같다. Enum에 대해 학습하다 보니 상수에 여러가지 컬렉션을 부여해 줄 수도 있고, 다형성을 활용하여 각각에 대응되는 메서드를 따로 정의해 줄 수도 있다는 것도 배웠다.

하지만 3주차 미션을 수행하면서 Enum을 배운만큼 제대로 활용하지 못했다는 느낌이 들어 너무 아쉬웠다. Enum 활용은 정말 개발자의 역량에 따라 천차만별이 될 수 있겠다는걸 뼈저리게 느꼈고 다음 주차엔 좀 더 발전된 방식으로 활용할 수 있게 더 열심히 공부하고 사용해봐야겠다.

Enum을 학습하며 참고한 자료입니다.

아직 풀리지 않은 궁금증

Enum의 상수 값에 범위를 설정할 순 없을까?

public enum LottoResult {
    FIRST_PLACE(2000000000, 6),
    SECOND_PLACE(30000000, 5),
    THIRD_PLACE(1500000, 5),
    FOURTH_PLACE(50000, 4),
    FIFTH_PLACE(5000, 3),
    LAST_PLACE(0, 0);

    private final int amount;
    private final int matchingCount;

    LottoResult(int amount, int matchingCount) {
        this.amount = amount;
        this.matchingCount = matchingCount;
	}
    
    (후략)
    
}

앞서 이번 주차 미션에서의 Enum 활용이 만족스럽지 않다고 말한 이유가 여기있다. 위의 코드에 따르면 '로또 꼴등은 일치하는 숫자가 0개일 때'라고 정의되어있다. 잘못된 코드다. 하지만 문제가 되진 않았다. 핵심 기능에서 필요한건 5등까지의 정보이기 때문에 LAST_PLACEmatchingCount가 3보다 작다면 기능엔 전혀 지장이 없었다.

하지만 그럼에도 잘못된 코드인 것은 확실하다. 로또 꼴등은 일치하는 숫자가 3개 미만이어야 한다고 코드에 명시적으로 드러아냐한다. 그래야 코드를 보는 다른 사람도 쉽게 납득할 수 있다.

이러한 문제를 해결하기 위해 Enum을 학습하며 배운 내용을 바탕으로 여러가지 방법을 시도해봤다. 상수 내부에 함수나 조건식을 걸어보기도 했고 그냥 LAST_PLACE를 제거해보기도 했다. 하지만 문제가 해결되진 않았다. 문제를 해결하면 부수적으로 다른 문제가 생겨나거나 코드가 너무 더러워지는 등의 사단이 발생했다.

정말 간단하게 해결될 수 있는 문제 같은데 해결 되지 않고, 정답에 다다른 것 같은데 결국엔 또 실패하는 과정을 거듭하면서 자기 혐오에 빠지기도 했던 것 같다. 3주차 과제 기간동엔 결국 해결하지 못했지만 이 문제 만큼은 꼭 다른 사람의 도움 없이 스스로 해결하고 싶다.

@ParameterizedTest는 정말 클린코드에 효과적일까?

여러 동기들의 2주차 미션을 코드 리뷰 하면서 @ParameterizedTest로 작성된 테스트를 빈번히 마주했다. 확실히 @ParamterizedTest를 사용하는 것은 코드의 길이를 줄여준다.

@MethodSource 사용 이전

@DisplayName("로또에 당첨됐을 때")
@Nested
class WhenWinningLottery {
    Lotto winningLotto;
    int bonusNumber = 7;

    @BeforeEach
    void setUp() {
        winningLotto = Lotto.of(List.of(1, 2, 3, 4, 5, 6));
    }

    @DisplayName("숫자 6개가 모두 일치하면 1등")
    @Test
    void createFirstPlaceLotto() {
        Lotto lotto = Lotto.of(List.of(1, 2, 3, 4, 5, 6));

        assertThat(lotto.getResult(winningLotto, bonusNumber)).isEqualTo(LottoResult.FIRST_PLACE);
    }

    @DisplayName("숫자 5개가 일치하고 보너스 숫자가 일치하면 2등")
    @Test
    void createSecondPlaceLotto() {
        Lotto lotto = Lotto.of(List.of(1, 2, 3, 4, 5, 7));

        assertThat(lotto.getResult(winningLotto, bonusNumber)).isEqualTo(LottoResult.SECOND_PLACE);
    } 
    
(후략)

@MethodSource 사용 이후

@DisplayName("맞춘 번호의 갯수에 따라 당첨 결과를 반환한다.")
@ParameterizedTest(name = "로또 번호 : {0}, 보너스 번호: {1}, 결과 : {2}")
@MethodSource("lottoResultSources")
void createLottoResultTest(Lotto purchasedLotto, int bonusNumber, LottoResult result) {
    Lotto winningLotto = Lotto.of(List.of(1, 2, 3, 4, 5, 6));

    assertThat(purchasedLotto.getResult(winningLotto, bonusNumber)).isEqualTo(result);
}

public static Stream<Arguments> lottoResultSources() {
    return Stream.of(
            Arguments.arguments(Lotto.of(List.of(1, 2, 3, 4, 5, 6)), 7, LottoResult.FIRST_PLACE),
            Arguments.arguments(Lotto.of(List.of(1, 2, 3, 4, 5, 7)), 7, LottoResult.SECOND_PLACE),
            Arguments.arguments(Lotto.of(List.of(1, 2, 3, 4, 5, 8)), 7, LottoResult.THIRD_PLACE),
            Arguments.arguments(Lotto.of(List.of(1, 2, 3, 4, 7, 8)), 7, LottoResult.FOURTH_PLACE),
            Arguments.arguments(Lotto.of(List.of(1, 2, 3, 7, 8, 9)), 7, LottoResult.FIFTH_PLACE),
            Arguments.arguments(Lotto.of(List.of(1, 2, 7, 8, 9, 10)), 7, LottoResult.LAST_PLACE)
    );
}

위의 코드 처럼 확실히 @ParameterizedTest를 사용하니 코드의 길이가 현저히 줄어들었다.

그러나 기존에 작성한 테스트에서 테스트마다 부여해주던 이름들이 사라졌다. 맞춘 번호의 갯수에 따라 당첨 결과가 반환된다는 사실은 알겠다. 하지만 몇 개의 숫자가 일치해야 1등에 당첨되는지 쉽게 파악하기 힘들다. 직접 코드를 보고 이해해야한다.

또한 아래의 사진처럼 테스트를 실행하고 테스트 결과가 표시되는 콘솔에서도 해당 테스트가 어떤 테스트인지 한 눈에 파악하기 힘들다.

개인적으로 코드의 가독성은 해당 메서드나 변수에 얼마나 적절한 이름이 부여됐느냐에 따라 좌우된다고 느꼈다. 따라서 @ParameterizedTest는 전달된 변수 값만으로 충분히 의미가 전달될 때만 사용하는 것이 적절하다고 생각한다.

과연 반복되는 모든 테스트에 @ParameterizedTest를 사용하는 것이 클린코드에 부합한 행동인지는 더 고민을 해봐야 할 것 같다.

마치며

개인적으로 지난 주차에 비해 만족스럽지 않은 한 주였다. 반드시 해결하고자 했던 문제를 해결하지 못하기도 했고 스스로를 돌아봤을 때도 지난 주에 비해 내 코드가 크게 성장하지 못했다는 느낌을 받았다. 오히려 검증 로직을 구현하는 부분에서는 오히려 퇴보했다는 느낌을 받기도 했다.

또한 학습 과정에서도 아쉬움이 많이 남는 한 주였다. 기존엔 공부하며 참조했던 여러 자료들을 추합해서 글로 정리해왔다. 그런데 이번주는 학습한 내용을 내 언어로 풀어내지 못했다는 점이 아쉽다. 학습한 내용을 바로 코드에 적용해보고 어떻게 하면 더 좋은 방식으로 리팩토링할 수 있는지 머리 싸메며 고민하느라 시간을 너무 많이 소비한 것 같다.

왜 내 코드는 그 다음날이 되서야 형편없어 보이는 걸까. 분명 자기 전까지만 해도 나름 괜찮은 코드다. 라고 생각했는데 자고 일어나면 고쳐야 할 부분이 자꾸 눈에 띈다. 최종적으로 제출한 코드가 너무 부족한 것 같아 아쉬움이 많이 남는다. 3주차 과제를 수행하며 느꼈던 분노와 후회들을 거름 삼아 4주차에는 한 단계 더 진화한 코드를 작성하고 싶다.

3주차 로또 미션 구현 코드는 여기에서 확인할 수 있습니다.

profile
조금 느려도 꾸준한 성장을 추구합니다.

0개의 댓글