각종 후기/우아한테크코스

[우아한 테크코스 3기] LEVEL 1 회고 - 블랙잭 1단계 미션의 1차 피드백을 받아보다 (35일차)

제이온 (Jayon) 2021. 3. 8.

안녕하세요? 제이온입니다.

 

주말에 글을 쓰려고 했는데,  생각보다 할 일이 많아서 주말동안 있었던 일도 몰아서 써 보겠습니다.

 

 

자격증 시험

먼저, 토요일에는 정보처리산업기사 필기 시험이 있었습니다. 이걸 왜 따냐고 하시겠지만, 현역 산업기능요원 자격 요건이 '대학교 2학년 수료 + 산업기사 취득'이기 때문에 어쩔 수 없었습니다.

 

제가 다른건 열심히 공부해도, 이렇게 밑도 끝도 없이 암기하는 방식을 굉장히 싫어해서 사실 시나공 책을 사놓고 1회독도 안했습니다. 출제 빈도에 따라서 A, B, C, D 등급으로 나뉘는데, 시간이 없어서 A와 B등급 내용만 빠르게 훑고 기출문제도 1년치 정도만 풀고 시험장에 갔습니다.

 

문제를 풀면서 공부를 별로 안한 저를 자책하였고, 모르는 문제는 일단 모두 재꼈습니다. 근데 재낀 문제를 대충 세보니까 20문제가 넘었고, 와 이거 설마 필기 떨어지는건가 싶었습니다. 침착하게 조금이라도 알 법한 문제는 풀어주고, 나머지 문제는 앞뒤 5문제를 살펴보면서 적당히 찍었습니다 ㅋㅋㅋㅋㅋ

 

CBT 방식의 시험이어서 바로 채점 결과가 나오는데, 저는 솔직히 떨어졌다고 생각하였습니다. 그런데, 다행히 모든 과목 과락 없이 75점이 나와서 무난하게 합격하였습니다.

 

시험이 끝난 다음, 실기 시험 일정을 확인해 보니까 4월 초 접수해서 4월 말 또는 5월 초에 시험을 봐야하는 것을 보고 바로 책을 주문하였습니다. 이 시기에는 우테코 레벨2라서 더 바빠질 것 같은데, 조금씩 짬내서 공부를 미리 미리 해 둬야 할 것 같습니다.

 

 

블랙잭 1단계 미션 1차 피드백 - 게이츠

필기 시험 끝나고, 오후에 리뷰어 님인 게이츠가 1차 피드백을 해 주셨다는 알림이 왔습니다. 피드백 내용 자체는 많지 않았지만, 한 가지 피드백을 수정하는 데 굉장히 힘들었습니다.

 

 

 

 

첫 번째는 상위 클래스를 추상 클래스로 선언하여 상속을 이용하라는 것입니다. 저는 이 당시에 Dealer와 Player의 상위 클래스인 Participant (피드백에서는 클래스명을 복수형으로 썼지만, 이후 단수형으로 수정)을 일반 클래스로 만들고, 사실 써먹지도 않았습니다.

 

또한, 추상 메소드로 뭘 선언할지 몰라서 막막하였는데, 딜러와 플레이어에 따라 초반에 카드를 보여주는 방식을 다르게 할 수 있겠다는 생각이 들었습니다. 딜러는 처음에 카드를 1장만 보여주고, 플레이어는 2장을 보여주는데, 이 부분을 추상 메소드로 선언하는 것이죠. 다만, 지금 생각하였을 때, 이 부분은 뷰 로직이 아닐까 생각이 들어서 게이츠에게 질문을 하였습니다.

 

아무튼 리팩토링을 진행하면서 필요할 때마다 추상 메소드를 추가해 주었습니다. 개인적으로는 무언가 'instanceof'를 통해 특정 객체를 검사함으로써 다운 캐스팅을 할 필요가 생길 때, 추상 메소드를 정의해 주었습니다. 이것은 다운 캐스팅이 개방 폐쇄 원칙을 위반하기 때문입니다.

 

 

 

 

두 번째 피드백은 제가 질문한 것에 대한 답변인데요, ACE 카드 때문에 다른 모든 카드에도 extraValue 속성을 추가하는 것이 비효율적이지 않나 질문을 드렸습니다. 하지만, 게이츠 말씀대로 예외 상황을 두려고 하였으나, 현재 방식도 다시 생각해 보니 크게 비효율적이지는 않아보이고, 마땅히 더 나은 방식이 생각 안 나서 일단은 패스하였습니다.

 

 

 

 

세 번째 피드백은 책임을 다른 객체로 옮기라는 것입니다. isAce()라는 것은 특정 카드가 에이스 카드인지 확인하는 것인데, Card 객체가 이 책임을 갖기보다는 CardNumber가 역할을 수행하는 것이 더 적합하다고 생각합니다. 따라서, 아래와 같이 수정하였습니다.

 

 

    public boolean isAce() {
        return this == CardNumber.ACE;
    }

 

 

해당 객체가 에이스면 true, 그렇지 않으면 false인 것이죠. 사실, 처음에는 this를 쓸 생각 안하고 삽질을 하였는데, 본인 자체를 나타내는 this를 사용하니 코드가 깔끔해졌습니다.

 

 

 

 

세 번째 피드백은 컨트롤러의 책임을 도메인 객체로 분산하는 것입니다. 사실, 이 피드백이 가장 어려웠고, 제가 주말동안 29개의 커밋을 하는 데 크게 기여한 문제입니다. 저는 우선, "게임 진행"은 도메인 객체로 바꾸는 방법은 모르곘어서 "게임 결과"에 대한 책임을 도메인 객체로 넘겼습니다.

 

GameResult라는 클래스를 정의하고, 승패와 관련된 상수를 Result 클래스에서 관리를 하였습니다.

 

 

public class GameResult {

    public int calculateDealerResult(final Participant dealer, final List<Participant> players,
        final Result result) {
        return (int) players.stream()
            .filter(player -> dealer.decideWinner(player).isSameResult(result))
            .count();
    }

    public Map<Name, Result> makePlayerResults(final List<Participant> players,
        final Participant dealer) {
        final Map<Name, Result> results = new LinkedHashMap<>();
        for (final Participant player : players) {
            final Result result = player.decideWinner(dealer);
            results.put(player.getName(), result);
        }
        return results;
    }

}

 

 

딜러의 승, 무, 패 횟수를 반환하는 메소드와 각 플레이어의 경기 결과를 반환하는 메소드를 만들었습니다. 여기서 들어온 순서를 기억하기 위하여 LinkedHashMap을 활용하였습니다.

 

 

public enum Result {
    WIN("승"),
    DRAW("무"),
    LOSE("패");

    private final String value;

    Result(final String value) {
        this.value = value;
    }

    public boolean isSameResult(final Result result) {
        return this == result;
    }

    public String getValue() {
        return value;
    }
}

 

 

Result는 enum 클래스로 정의하여 상수를 관리하였습니다.

 

 

이를 통하여 컨트롤러에서 경기의 결과를 얻어오는 책임을 깔끔하게 GameResult로 넘길 수 있었습니다. 하지만, 여전히 "게임 진행"은 메소드도 복잡하고 뷰 로직이 섞여있어서 도메인으로 어떻게 책임을 분산할 수 있을지는 게이츠에게 질문하였습니다.

 

 

블랙잭 1단계 미션 1차 피드백 - 다른 리뷰어님

이번 페어는 3인팀이었기 때문에 더 다양한 리뷰어님의 의견을 들을 수 있었습니다. 바다와 조엘 리뷰어님의 피드백도 참고하여 제 코드를 리팩토링해 보았는데, 가장 중요한 수정 사항은 "뷰 로직을 컨트롤러나 도메인에 작성하지 말라"였습니다. 제 코드에 이러한 문제가 여러 곳에 존재하였는데, 예시를 하나 보여 드리겠습니다.

 

 

    private void showParticipantsName(final List<Participant> participants) {
        final String status = participants.stream()
            .map(Participant::getName)
            .filter(name -> !name.isSameName("딜러"))
            .map(Name::getValue)
            .collect(Collectors.joining(", "));
        OutputView.distributeMessage(status);

 

 

컨트롤러에서 출력 뷰로 출력 로직 자체를 만들어서 넘겨주고 있습니다. 하지만, 이렇게 할 필요 없이 단순히 Participant 리스트를 뷰에게 넘겨주면 컨트롤러의 책임이 줄어들게 됩니다.

 

저도 사실 이 문제를 어느정도 인식하고 있었으나, Participant가 불변 보장이 안 돼서 일부러 출력 로직을 컨트롤러에서 작성해 주었습니다. 하지만, 조엘의 리뷰어님의 의견을 들어보니까 불변을 고민하는 것도 좋지만, 우선 객체지향 방식을 따르는 것이 더 좋다는 의견이 나와있었습니다. 그리고 이렇게 특정 객체의 정보를 출력해야하는데, 그 객체가 가변 객체일 경우 DTO를 도입하는 것이 바람직하다는 사실을 알게 되었습니다.

 

저는 우선, 바로 DTO를 도입하기 보다는 도메인이나 컨트롤러에 있는 뷰 로직을 전부 뜯어 고쳤습니다. 최대한 Participant 객체의 가변 필드를 없애고, 그 객체 자체를 넘겨서 뷰가 로직을 작성하도록 수정하였습니다.

 

 

두 번째 수정 사항은 '검증'입니다. 현재 제 코드에는 '플레이어의 이름'을 입력받지만, 검증 기능은 작성하지 않았습니다. 그래서 저는 "이름이 빈 값인가?", "중복된 이름이 있는가?", "딜러를 제외한 플레이어는 1명이상 7명이하인가?"를 예외 사항으로 추가하기로 하였습니다.

 

이름 자체를 Name 클래스로 추상화하여 이름이 빈 값인지 확인하였고, 여러 개의 이름을 Names 클래스로 추상화하여 중복된 이름 여부와 플레이어 명수 여부를 체크하였습니다.

 

그 외에, "플레이어에게 카드를 더 뽑을지 물어본다. (예 : y, 아니오 : n)"의 입력값도 y 또는 n이 아닐 경우 예외가 발생하도록 검증을 해야합니다. 저는 InputView에 검증을 추가하는 것은 단일책임원칙을 위배한다고 생각해서, 이 부분도 Select 클래스로 추상화하여 검증을 하였습니다. 하지만, 조엘이 특정 예외에 대해서는 InputView에서 검증을 해도 된다는 말을 해 주셨습니다.

 

예를 들어, 우리가 어떠한 홈페이지에 회원 가입을 한다고 합시다. 아이디의 길이가 초과하거나 허용되지 않는 문자를 입력할 경우 무언가 빨간색 박스가 쳐진다거나 하는 식으로 예외가 발생하는 것을 알 수 있습니다. 이렇게 1차적으로 걸러줘야할 부분은 View에서 하고, 이름이 중복되는 예외는 Domain에서 하는 식으로 순차적으로 검증 책임을 부여해야합니다.

 

따라서, 저는 기존의 Select 클래스를 제거하고, InputView에서 플레이어에게 카드를 더 뽑을 것인지에 대한 검증 기능을 추가하였습니다.

 

이름의 빈 값이 들어오는 것도 뷰에서 하는 것이 맞지 않나 고민을 하였는데, 플레이어는 이름 입력 자체를 "a,b,c"와 같이 한 줄로 모든 이름을 입력하므로 Name 객체에서 빈 값 판단, 나머지는 Names 객체에서 판단하는 것이 바람직하다고 판단하였습니다.

 

 

블랙잭 1단계 미션 1차 피드백 - 개인적인 판단

위의 피드백을 적용함으로써 컨트롤러가 한결 깔끔해졌습니다. 하지만, 여전히 컨트롤러는 비대하다고 생각하여 어떻게 책임을 분산할 수 있을지 고민해 보았습니다. 먼저, 수정 전 run 메소드를 봅시다.

 

 

    public void run() {
        final CardDeck cardDeck = new CardDeck();
        final List<Participant> participants = participantsSetUp();
        final List<Participant> players = new ArrayList<>(
            participants.subList(1, participants.size()));

        distributeCard(participants, cardDeck);
        showNameAndCardInfo(players, participants);
        playerGameProgress(players, cardDeck);
        dealerGameProgress(participants.get(0), cardDeck);
        showFinalCardResult(participants);
        showGameResult(participants.get(0), players, new GameResult());
    }

 

 

위와 같이 참가자 리스트를 setUp() 메소드를 통해 초기화하고, 그 리스트에서 플레이어 리스트만 따로 뽑아서 사용하는 것을 알 수 있습니다. 또한, dealerGameprogress를 수행하고 위하여 인자로 get(0)을 사용하는 것을 확인하실 수 있습니다.

 

저는 이 부분을 Participant를 Participants 일급 컬렉션으로 정의하면 바람직하겠다고 생각하였습니다. 그래서 아래와 같이 Participants 코드를 작성하였습니다.

 

 

public class Participants {

    private final List<Participant> participantGroup;

    public Participants(final Names names) {
        participantGroup = participantsSetUp(names);
    }

    private List<Participant> participantsSetUp(final Names names) {
        final List<Participant> participants = names.toList().stream()
            .map(Player::new)
            .collect(Collectors.toList());
        participants.add(0, new Dealer());
        return new ArrayList<>(participants);
    }

    public void distributeCard(final CardDeck cardDeck) {
        participantGroup.forEach(participant -> {
            participant.receiveCard(cardDeck.distribute());
            participant.receiveCard(cardDeck.distribute());
        });
    }

    public Participant getDealer() {
        return participantGroup.get(0);
    }

    public List<Participant> getPlayers() {
        return Collections.unmodifiableList(participantGroup.subList(1, participantGroup.size()));
    }

    public List<Participant> getParticipantGroup() {
        return Collections.unmodifiableList(participantGroup);
    }
}

 

 

참가자 리스트를 생성하는 책임과 처음에 카드를 받아오는 책임을 부여하고, 딜러와 플레이어, 참가자 리스트를 반환하는 기능을 추가하였습니다.

 

 

    public void run() {
        final CardDeck cardDeck = new CardDeck();
        final Participants participants = new Participants(requestName());
        participants.distributeCard(cardDeck);
        OutputView.showNameAndCardInfo(participants);

        gameProgress(participants.getPlayers(), participants.getDealer(), cardDeck);
        OutputView.showCardsResult(participants.getParticipantGroup());
        showGameResult(participants.getDealer(), participants.getPlayers());
    }

 

 

위 코드는 수정 이후 코드인데, 여러 리스트를 일급 컬렉션 내에서만 관리를 할 수 있고, 일부 책임을 분산함으로써 코드가 간결해졌습니다.

 

 

데일리 미팅

오늘은 오후 1시에 데일리 미팅을 진행하였습니다. 진행자는 샐리였고, 다음 회식때 뭘 먹을지, 그리고 최근에 무엇을 공부하고 있는지에 대해서 말하기로 하였습니다. 사실, 오늘 랜선 회식 예정이었는데 워니가 치과에서 검사하고 나니까 오늘은 술이나 뭘 먹을 수 없다고 합니다.

 

저는 최근에 기름진 음식을 좀 자주 먹어가지고, 아마 다음 회식 때는 회나 초밥을 먹을 것 같다고 말하였습니다. 그리고 객체지향 공부에 초점을 맞추고 있는데, 알고리즘 문제를 OOP 방식으로 풀 예정이라고 하였습니다. 다른 크루들은 대부분 치킨을 먹는다고 하였고, 공부는 OOP나, 생각하는 법, 마음의 여유를 찾는 법 등등 다양한 의견이 있었습니다.

 

 

자습

오늘은 수업이 없는 날이고, 블랙잭 미션도 아직 2차 피드백은 오지 않아서 자습을 진행하였습니다. 먼저, 개방 폐쇄 원칙에 대해서 포스팅을 하나 작성하였고, 백준 '빙산' 문제를 OOP로 구현하려고 저장소를 만들었습니다.

 

오늘은 빙산 문제를 OOP로 구현하기 위해서 시간을 많이 할애하였는데, 객체지향을 연습하는 데 꽤 도움이 되었다고 생각합니다. 특히, 다시 한 번 TDD의 장점을 느낄 수 있었습니다. 처음에 요구 사항을 나름대로 나열한 다음에 막상 시작하려니까 뭐부터 해야할지 되게 막막했는데, 일단 TDD를 통해 도메인 객체부터 차근 차근 단계적으로 해 나가니까 길이 잘 보였습니다.

 

다만, 이 문제가 BFS와 DFS를 활용하는 것이었는데, 객체지향 설계로 어떻게 구현해야할지는 잘 모르겠어서 내일까지 쭉 고민해 봐야겠습니다.

 

 

정리

내일은 루터회관에서 오프라인으로 모이는 날입니다. 특히, 오전에는 수업이 있고 오후 중으로 프로필 사진을 찍고, 워니와 면담을 해야하므로 바쁜 일정이 될 것으로 예상됩니다. 그래도 크루들을 실제로 보고, 궁금증을 바로 바로 해소할 수 있는 좋은 기회라서 얼른 가고 싶습니다 ㅎㅎ

댓글

추천 글