ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [우아한 테크코스 7기] 프리코스 3주차 회고
    회고 2024. 11. 4. 11:12

    Getter 에 대한 기준이 필요해

    이번 주차에 들어서며, 나는 getter 에 대한 기준이 필요하다는 생각이 들었다.

     

    우리는 왜 매일같이 'getter 를 지양하세요' 라는 말을 들을까? 나는 getter 지양의 중요성에 대해 저번 주차에 진하게 깨달았었다. 유연한 설계에 대해 계속 고민하다 보니, getter 로 필드를 노출하게 되면 추후에 데이터 구조가 변경됐을 때 getter 를 사용한 코드를 모두 바꿔야 하기 때문이다! 여기서 나는 다음과 같은 것들을 느꼈다.

    캡슐화는 정말 꽁꽁 숨기라는 것이 아냐

    처음에 나는 캡슐화, getter .. 와 같은 것들에 대해 알게 됐을 때, 외부가 정말 알 수가 없도록 꽁꽁 숨기라는 것인 줄 알았다.

    예를 들어 특정 모델에 대한 규칙은 해당 모델만 알아야 한다고 생각했다. 아무도 모르게 꽁꽁 숨기자는 줄 알았다.

    그래서 뷰에서 해당 모델의 어떤 것을 노출하더라도 캡슐화를 '저해' 한다고 생각했다.

    public class User {
    	private String name;
    }
    
    System.out.println("승희님 안녕하세요.");

    위와 같이 User 클래스에 name 이라는 필드가 있다고 했을 때 사용자의 이름을 출력하는 것마저 캡슐화를 저해한다고 지난 2주동안 생각했던 것 같다. 즉, 초점을 잘못 맞춘 것이다. 이러한 방식으로 꽁꽁 숨기라는 것이 아님을 깨닫고 다시 문제에 대한 초점을 맞춰 나갔다.

    범위를 정해보자

    위와 같이, getter 를 지양하는 것에만 목표를 두게 된다면 궤변을 늘여놓게 된다는 것을 깨달았다.

    예를 들어, getter 를 쓰지 않겠다는 일념 하에 toString 과 같이 모델의 구조를 문자열로 반환해 출력 형태를 그대로 만드는 경우! 완전히 view 와 이렇게 결합도가 높게 설계될 수가 없다. 

     

    또한, getter 를 아예 쓰지 않겠다고 다짐하게 되면 모델 내부에서만 데이터를 사용하게 되어 해당 모델이 엄청나게 거대해진다고 생각했다. (이번 주차 나의 경우가 그랬다.) 즉, 책임분리가 되지 않는 것이다. 그렇기에 가끔은 getter 를 과감히 쓸 필요도 있다고 생각했다.

     

    대신, 나만의 기준을 두고 움직였다.

     

    ✅ 1. 서비스 레이어 밖으로 getter 가 나가지 않도록 하자

    dto 에서는 허용하나, 이 역시도 dto 로 모델을 넘기는 것을 서비스 레이어에서만 하는 것으로 하자

     

    ✅ 2. 모델 레이어에서는 모델 간에 아무 기준 없이 도입하는 것은 안된다.

    public class Lotto {
        protected final List<Integer> numbers;
    }
    
    public class Customer {
        private final int paidAmount;
        private final List<LottoTicket> lottoTickets;
    }
    
    public class WinningLotto {
    
        private final Lotto winningNumbers;
        private final int bonusNumber;
    }

    - 구성으로서 갖는 경우에는 허용

    • WinningLotto 가 Lotto 를 호출하는 것은 허용
    • WinningLotto 가 Lotto 의 numbers 를 호출하는 것은 허용

    - 외부에서 호출하는 것은 허용 x

    • Customer 에서 WinningLotto 의 Lotto 의 numbers 는 허용 x
    • Customer 에서 LottoTicket 의 Lotto 의 numbers 는 허용 o

    서로 관련 있는 것 끼리만 연관성을 갖도록 하기 위함이다. 이렇게 되면, 변경의 주기를 같이 하는 객체들끼리만 데이터를 노출하게 될 수 있을 것이라고 생각했다.

    언제 인터페이스를 사용해야 하지?

    저번 주차 과제를 진행하며 인터페이스를 통한 추상화의 중요성을 깨닫게 됐다. 이번 주차에도 어떤 경우에 인터페이스를 적용해야 하는지에 대한 고민이 많아 이런저런 자료들을 찾아봤다.

     

    그러다가 다음과 같은 글을 보게 됐다.

     

    https://techblog.woowahan.com/2561/

     

    안정된 의존관계 원칙과 안정된 추상화 원칙에 대하여 | 우아한형제들 기술블로그

    Robert C. Martin의 Agile Software Development - Principles, Patterns, and Practices 에서 SDP, SAP 를 정리해보았습니다. 이 글은 기본적으로는 Java와 Spring Framework 기반(혹은 이와 유사한 계층형 방식)으로 개발하시는

    techblog.woowahan.com

    불안정성 = 패키지 외부 클래스에 의존하는 패키지 내부 클래스의 수 / (이 패키지에 의존하는 외부 클래스의 수 + 패키지 외부 클래스에 의존하는 패키지 내부 클래스의 수) 이러한 방식으로 불안정성을 측정하면 된다는 것이다.

    이가 1이면 불안정이고 의존적이라는 뜻이며, 불안정성이 0이면 독립적이고 안정적이라는 것이다.

     

    또한, 안정적일수록 확장이 가능하도록 추상화를 해야 하며 불안정적일수록 추상화를 하지 말고 그대로 둬야한다는 것을 알게 됐다. 이를 통해 인터페이스로 구현을 해야 하는지 말아야 하는지.. 이러한 기준선을 어느 정도 잡게 됐다.

     

    즉, 다시 정리해보면 해당 클래스가 외부의 클래스를 의존하는 것보다 해당 클래스를 외부에서 많이 의존한다면 추상화하는 것이 적절하다는 의미이다. 이번 주차에는 대부분의 클래스의 메서드들을 정적 메서드로 활용하게 되어 인터페이스로 분리하는 기회가 없었지만, 추후에 이런 기준을 적용해 판단해 나갈 것 같다.

    의존관계에 대해 고민하면 길이 보인다

    이번 주차에는 유연한 설계를 위해 의존 관계의 흐름에 대해 고민을 많이 했다. 이에 대한 고민을 하다 보니 많은 문제들이 부수적으로 해결되는 것을 느꼈다.

    WinningLotto 와 Lotto

    public class WinningLotto {
    
        private final Lotto lotto;
        private final int bonusNumber;
    
        public int countMatchingNumberWith(List<Integer> comparedNumbers) {
            int matchCount = 0;
            for (Integer number : comparedNumbers) {
                if (this.lotto.numbers.contains(number)) {
                    matchCount++;
                }
            }
            return matchCount;
        }
    }
    
    public class Lotto {
        protected final List<Integer> numbers;
    
        public int countMatchingNumberWith(WinningLotto winningLotto) {
            return winningLotto.countMatchingNumberWith(this.numbers);
    	}
    }

    기존에는 위와 같이 Lotto 가 WinningLotto 의 메서드를 호출하면 WinningLotto 내부에서 필드로 선언되어 있는 Lotto 에 접근해 중복되는 숫자를 세도록 했다. 그러나, 이가 다시 생각해보면 Lotto 도 WinningLotto 을 호출하고, WinningLotto 도 Lotto 를 호출하는 서로 의존하는 잘못된 구조라고 생각이 들었다.

    public class LottoTicket {
        private final Lotto lotto;
        private Rank rank;
    
        public void determineRank(WinningLotto winningLotto) {
            this.rank = Rank.findRank(winningLotto.countMatchingNumberWith(lotto),
                    winningLotto.isBonusNumberMatchedWith(lotto));
        }
    }
    
    public class WinningLotto {
    
        private final Lotto winningNumbers;
        private final int bonusNumber;
    
        public int countMatchingNumberWith(Lotto lotto) {
            int matchCount = 0;
            for (Integer number : lotto.numbers) {
                if (this.winningNumbers.numbers.contains(number)) {
                    matchCount++;
                }
            }
            return matchCount;
        }
    }

    그래서 Lotto 에서는 해당 메서드를 삭제하고, WinningLotto 에만 해당 메서드를 유지했다. 그리고 외부에서 winningLotto 를 호출하되, Lotto 를 파라미터로 넘겼다. 이렇게 되면, 어차피 Lotto 는 WinningLotto 에 필드로서 존재하기에 Lotto 를 호출하고 있어 의존성 방향의 흐름이 일정하게 된다.

     

    사실 이렇게 고쳤다면 Lotto 에는 애초에 메서드가 필요하지 않았을 것이다. 이렇게 의존성의 방향을 고민하다 보니, 불필요하고 잘못됐던 메서드 호출도 교정할 수 있게 됐다는 생각을 했다.

    Rank 의 description 을 문자열로 파싱

    3개 일치 (5,000원) - 1개
    4개 일치 (50,000원) - 0개
    5개 일치 (1,500,000원) - 0개
    5개 일치, 보너스 볼 일치 (30,000,000원) - 0개
    6개 일치 (2,000,000,000원) - 0개
    public enum Rank {
        FIRST_PLACE(2_000_000_000, 6, false),
    
        SECOND_PLACE(30_000_000, 5, true),
    
        THIRD_PLACE(1_500_000, 5, false),
    
        FOURTH_PLACE(50_000, 4, false),
    
        FIFTH_PLACE(5_000, 3, false),
    
        OUT_OF_RANK(0, 0, false);
    
        private final int prizeMoney;
        private final int matchingCount;
        private final boolean requiresBonusMatch;
    }

    요구 사항에는 위와 같이 순위에 대한 설명을 출력해야 하는 부분이 있다. 처음에는 enum 인 Rank 에 description 이라는 필드를 선언해 고정적인 문자열을 반환하도록 했다. 

    해당 필드를 직접적으로 뷰에서 호출하지는 않지만, 결국 출력 형태에 매여 있기에 간접적으로 뷰에 의존한다고 생각해 문제라고 봤다. 그래서 아예 설명을 만들어 내는 기능을 구현했다.

    public class RankDescriptionGenerator {
    
        private RankDescriptionGenerator() {
        }
    
        public static String makeDescription(Rank rank) {
            StringBuilder description = new StringBuilder();
    
            description.append(rank.getMatchingCount()).append("개 일치");
            appendConditionAboutBonusNumber(rank, description);
            appendInformationAboutPrizeMoney(rank, description);
    
            return description.toString();
        }
    
        private static void appendConditionAboutBonusNumber(Rank rank, StringBuilder description) {
            if (rank.isRequiresBonusMatch()) {
                description.append(", 보너스 볼 일치");
            }
        }
    
        private static void appendInformationAboutPrizeMoney(Rank rank, StringBuilder description) {
            description.append(" (")
                    .append(String.format("%,d", rank.getPrizeMoney()))
                    .append("원)");
        }
    }

    이렇게 했더니 간접적으로도 뷰에 의존하지 않게 되고, 추후 새로운 순위가 도입됐을 때에도 아무런 추가적인 설정 없이 description 을 만들어 낼 수 있기에 좋은 선택이라고 봤다.

    상속일까 구성일까?

    처음에는 Lotto 를 WinningLotto 가 상속하고 있었다. 이렇게 구현한 이유는 어차피 WinningLotto 가 Lotto 를 갖고 있고 겹치는 기능들도 존재했기 때문이다. 그러나, 이렇게 하다 보니 상속이 맞는 방법인지 확신이 들지 않았다. 그래서 이에 대해 찾아보던 중, 좋은 개념을 습득하게 됐다.

    is-a 관계

    is-a 관계는 A 는 일종의 B 이다 라는 정의이다. 예를 들어, Animal 을 상속한 Tiger 클래스는 Tiger 는 일종의 Animal 이다 라는 것이다. 이러한 경우에는 상속이 적합하다. 베이스 클래스로 묶어 다른 클래스들이 확장하는 것이다.

    has-a 관계

    A 는 B 를 가진다는 것이다. 즉, 다른 클래스의 기능을 온전히 받아들여서 사용하는 것이다.

     

    그렇다면 이제 따져보자. WinningLotto 와 Lotto 는 상속일까 구성일까?

    결론부터 말하자면 나는 구성으로 결정했다. 만약 WinningLotto 와 Lotto 를 상속으로 구성하게 된다면 LSP 원칙에 위반하게 된다. 예를 들어, 다음과 같은 코드가 가능해야 한다.

    Lotto winningLotto = new WinningLotto();

    그러나, 우리가 당첨 여부를 판단할 때 위와 같이 코드가 선언되어 있다면 괜찮을까? WinningLotto 는 보너스 넘버까지 갖는 당첨 번호이다. 따라서 A 는 일종의 B 이다가 성립하지 못한다. 이는 엄연히 특수한 버전의 Lotto 이기에 책임과 역할이 다르다. 따라서, 이는 WinningLotto 의 필드로서 Lotto 를 갖는 것은 적절하지만, 상속하는 것은 부적절하다고 봤다.

    살아있는 문서를 작성해보자

    이번 주차에는 처음 기능을 구현할 때 아예 의도를 잘못 파악해 포크했던 레포지토리를 삭제하고 처음부터 다시 해야 하는 상황이 있었다. 이렇게 미션을 잘못 이해하는 것을 방지하고 쉽게 미션을 이해하기 위해서 이야기를 만들어 이번 주차의 미션을 다시 이해해 나갔다.

    이렇게 하다 보니, 처음에 내가 헷갈렸던 부분이 어떤 부분인지 깨닫게 되었다. 보통 자연스럽게 입력을 하는 주체가 한사람일 것이라고 생각하게 된다. 나 역시도 그랬다.

    로또 몇개를 살 것인지 '고객'이 입력하기에, 그 이후의 당첨 번호도 '고객' 이 입력한다고 자연스레 받아들여 CustomerCustomLottoNumber 이런 식의 객체들을 만들고 있었던 것이다. 그러나 이야기로 만들어 이해하다 보니, 이 미션에서 입력을 하고 있는 주체는 두개 혹은 그 이상이겠다 하는 식으로 명확하지 않은 부분들도 받아들일 수 있게 됐다.

    상수에 대한 고민

    로또 번호의 범위, 유효한 크기, 구분자(,), 로또의 가격 등이 이번 주차의 상수로 선언될 수 있는 부분들이었다.

    프로덕션 코드 (main 패키지의 코드) 들에서는 로또 번호의 범위, 유효한 크기가 여러 클래스에서 반복이 됐다. 그래서 처음에는 전역 상수로서 정의를 해야 할지 고민을 했다. 그러나, 캡슐화를 지키고 도메인의 규칙은 모델에서만 알게 하는 것이 우선이라고 생각해 중복을 허용하고 굳이 전역 상수로 두지 않았다.

    그러다가 문득, 왜 내가 이렇게 생각했는지 곰곰히 생각해봤다. 모델 내부에서 캡슐화를 지키기 위해서가 주된 이유였다.

    그래서 상수를 사용해 내가 추구하는 원칙까지 지킬 수는 없을까 생각했다. 그래서, model 패키지 내부에서 로또와 관련된 클래스들은 /lotto 로 모으고, LottoConstants 라는 클래스를 생성해 protected 로 각 상수를 선언했다. 이렇게 되니, 상수의 이점도 가져 가면서 내가 추구한 원칙까지 지킬 수 있게 됐다는 생각을 했다. 


    이제 리뷰를 열심히 하며 더 배움의 폭을 넓혀 나가야겠다. 아자아자~ 파이팅!

    리뷰하러 후다닥

     

Designed by Tistory.