본문 바로가기

우아한테크코스 7기 프리코스

7기 프리코스 4주차 회고 (KPT 회고)

Keep - 잘하고 있는 점. 계속 했으면 좋겠다 싶은 점


Keep1 - VO 사용

 VO 를 사용하며 원시값을 포장함과 동시에 해당 값만의 역할을 분리할 수 있었다. 처음 VO 를 접했을 때에는 원시값을 클래스로 한번 더 포장하는 것을 잘 이해할 수 없었지만, 직접 사용해보고 장점과 단점을 분석해보니 VO 를 사용하는 것이 더 좋은 방법이라는 결론을 내릴 수 있었다.

 

VO 의 장점

1. 자료형에 의미를 부여할 수 있다. - 자료형이 의미 있는 이름을 가지게 되기 때문이다.

2. 타입 안정성을 높일 수 있다. - 같은 int 자료형이더라도, 각각의 VO 로 다루게 되면 검증이 적절히 수행되기 때문이다.

3. 최소한의 데이터 검증의 책임을 분리할 수 있다. - VO 내부에서 검증을 수행하기 때문이다.

 

VO 의 단점

1. 객체 생성에 오버헤드가 발생할 수 있다. - 원시값을 포장하는 객체를 생성해야 하므로, 비용이 든다. 하지만, 많은 비용은 아니고, VO 를 캐싱함으로써 어느 정도 해결할 수 있다.

2. GC 의 부담이 늘어날 수 있다. - VO 도 객체이므로, GC 가 관리해야 하는 객체가 늘어나게 된다.

 

 장단점을 분석해 보며 장점이 단점을 어느 정도 상쇄할 수 있다는 결론을 내렸다. 물론, 많은 인스턴스를 가질 것이라 예상되는 클래스에는 VO 사용을 더 고려해야 한다는 생각도 했다. 모든 상황에 정답인 것은 없기 때문이다.

 

VO 사용 예시 - 프로모션 클래스

public class Promotion {

    private final PromotionName name;
    private final PromotionPurchaseQuantity purchaseQuantity;
    private final PromotionBonusQuantity bonusQuantity;
    private final PromotionPeriod period;

    private Promotion(
            final String name,
            final int purchaseQuantity,
            final int bonusQuantity,
            final LocalDate startDate,
            final LocalDate endDate) {

        this.name = PromotionName.from(name);
        this.purchaseQuantity = PromotionPurchaseQuantity.from(purchaseQuantity);
        this.bonusQuantity = PromotionBonusQuantity.from(bonusQuantity);
        this.period = PromotionPeriod.of(startDate, endDate);
    }

    public boolean isApplicable(final LocalDate date) {
        return period.isPromotionPeriod(date);
    }
    
    // ...
}

 

가장 잘 사용했다고 느낀 부분은 프로모션의 적용 가능 여부를 판단하기 위해 값을 직접 사용하지 않고, PromotionPeriod 객체를 통해 객체 간 메시지를 주고받았다는 부분이다.

 

 

 

Keep2 - 메서드 파라메터의 final 키워드

 지난 주 피드백 문서에서 메서드의 파라메터에 final 키워드를 붙이라는 피드백을 발견했다. 값을 변경하지 않기 위해 final 키워드를 사용하는 것은 알고 있었는데, 자바가 Call by Value 로 동작하기 때문에, final 키워드를 붙여야 하는 이유에 대해 잘 이해할 수 없었다.

 

 피드백을 반영하기 위해 메서드 파라메터에 final 키워드를 붙여야 하는 이유를 스스로 생각해 보았다.

 

세 가지 관점에서 생각해 볼 수 있었다.

1. 원시값의 경우(int, float, double, long 등)

 원시값은 함수로 전달 시 Call by Value 가 일어나기 때문에, 변경사항이 기존 값에 영향을 주지 않는다. 함수 Scope 가 끝나면 사라지기 때문이다.

2. 객체의 경우

 Call by Value 는 값을 복사해서 전달하지만, 객체의 경우에는 조금 다른 개념으로 동작한다. 객체의 참조 즉, 객체의 주소값(ex. 0X1D) 이 복사되어 전달된다. 그러므로, 함수 내부에서 발생한 객체 상태 변화가 기존 객체에 반영된다.

3. 공통

 final 은 값을 변경할 수 없는 기능이다. 하지만, 원시값의 경우는 함수 내에서의 변경이 원본에 영향을 미치지 않고, 객체의 경우 재할당만 금지하기 때문에, 메서드의 파라메터에 final 을 붙이는 것이 원본의 변화를 억제하기 위함은 아닌 것이라 판단했다.

 이 관점을 기반으로, final 을 붙이는 목적에 대해 다시 생각해보았다. final 을 통해 막고 싶은 값 변화가 과연 원본의 값 변화인가?

 위 1, 2 를 기반으로 생각하면 아니다. 원본 값과는 관련이 없다. 

 그렇다면, final 을 붙임으로써 얻는 이점은 함수 내부에서의 값 변화 방지이다. 이를 더 이해하기 위해 여러 가지 코드를 작성해보았다. 우선, 함수 내부에서 값이 변경된다면, 코드를 이해하는 데에 어려움이 생긴다. 매개변수에 대한 신뢰성이 떨어지며, 언제 어떻게 값이 변하게 될 지 예측할 수 없다.

 

이 생각들을 통해 함수의 파라메터에 final 키워드를 붙이는 이유는 "메서드 동작의 신뢰성과 불변을 명시적으로 나타내기 위함" 이라는 결론을 낼 수 있었다.

final 키워드를 통해 함수 내 값의 변경을 막을 수 있고, 코드를 분석할 때, 값이 변경되지 않는다는 것을 명시적으로 보여줄 수 있다고 생각한다.

 

 

 

Keep3 - static 상수

 기존에는 static 을 사용하는 이유를 단순히 해당 클래스의 인스턴스를 생성하지 않고 상수를 가져다 사용하기 위함이라고 생각했다.

예)

RacingCar.DEFAULT_POSITION;

 

하지만 private 한 상수에도 static 을 붙이면 좋겠다는 코드리뷰들을 받으며 왜 그렇게 생각하는지와 그렇게 하면 어떻게 되는지 생각할 수 있었다.

 

이를 이해하기 위해 static 키워드를 붙임으로써 어떻게 동작하게 될 지 생각해보았다.

static 키워드를 붙이면 해당 상수는 메모리 영역에 상주하게 되며, GC 대상이 되지 않는다. 메모리 전체에 해당 변수가 하나만 있으므로, RacigCar 의 모든 인스턴스들은 하나의 참조를 공유하게 된다.

 

그러므로, 내부 상수를 private static final 로 선언하면 메모리적으로 이점이 있다.

 

이번 미션에서 대표적으로 상품의 가격인 ProductPrice 클래스를 설계할 때 사용했다.

public class ProductPrice {

    private static final int MIN_PRICE = 0;

    private final int price;

    private ProductPrice(final int price) {

        validatePrice(price);
        this.price = price;
    }
    
    // ...
    
}

 

 

 

 

Problem : 뭔가 문제가 있다 싶은 점. 변화가 필요한 점


Problem1 - 클래스의 책임과 Line 수

  물건에 대한 모든 책임을 부여하기 위해 Store 클래스를 만들었다. 초기에는 Store 가 "물건" 을 다루는 모든 책임을 가지고 있어 SRP에 부합한다고 생각했다.

 

 하지만, 내부 함수를 전부 구현하니 줄 수가 200 줄이 넘었다. 분명 클래스가 SRP 를 준수했다고 생각하고 설계했는데, 줄 수가 많을 것을 보고 클래스의 책임에 대해 다시 한번 생각해 볼 수 있었다.

 

 초점을 바꿔 생각해보았다. Store 는 물건을 검증하고, 구매하고, 물건의 프로모션 적용 관련 연산을 하는 책임을 가지고 있었다. "물건" 과 관련된 역할인 것은 맞지만, 행동의 관점에서 생각해 볼 필요가 있었다. 구매, 검증, 프로모션 연산은 전부 다른 책임이라는 것을 뒤늦게 깨달을 수 있었다.

 

 그렇다면 물건과 관련된 모든 책임을 지고 있는 Store 를 어떻게 리팩터링할 수 있을까? 

더 생각해봐야겠지만, 지금 드는 생각은 "점원" 개념을 만들면 좋을 것 같다는 생각이다.

물건을 계산하는 점원, 프로모션 연산을 하는 점원 등, 현재 Store 의 책임을 가지는 여러 가지 점원을 Store 가 부리도록(의존하도록) 하면 해결할 수 있을 것 같다.

 

 이 방식이 문제가 없는 것은 아니다. 점원의 수가 많아질수록 Store 는 점원들에 대한 의존성이 생긴다. 점원의 구체적인 클래스를 알아야 하기 때문이다.

 

 이를 방지하기 위해 외부에서 의존성을 주입함으로써 최소한의 문제는 해결될 수 있겠지만, Store 의 필드가 너무 많아진다는 단점은 여전히 존재한다.

 

 클래스의 줄 수가 너무 많아지지 않도록 더 좋은 방법을 찾아 리팩터링해야겠다고 다짐했다.

 

 

 

Problem2 - 요구사항뿐만 아니라 프로그램 관점에서의 예외도 고려하자.

 이번 미션은 요구사항이 많아 요구사항을 세분화하여 각 요구사항이 요구하는 것과, 각 요구사항에서 발생할 수 있는 예외를 정리했다.

 

 세분화한 요구사항을 하나하나 구현하는 데 성공했지만, 테스트를 진행하면서 예상치 못한 문제가 발생했다. 제출 테스트에서 4개의 테스트 중 3개만 통과한 것이다. 요구사항별로 예외를 정리하는 과정에서 프로그램 관점에서의 예외 상황은 정리하지 못했던 것이다. 여러 요구사항들과 얽힌 예외 케이스들이 있었고, 요구사항 별 예외 케이스들만 정리하는 방식으로는 이 예외를 걸러낼 수 없었다.

 

 이미 복잡하게 구성된 코드에서 뒤늦게 예외 케이스를 찾아 리팩터링하려다 보니, 큰 어려움에 부딪혔다. 리팩터링되지 않은 코드였기에, 기술 부채가 쌓여있었고, 예외 상황을 추가할 때마다 코드가 점점 복잡해지면서 수정 작업이 더 힘들어졌다.

 

 이 경험을 통해, 앞으로 요구사항을 세분화하고 구체화할 때 프로그램 관점에서의 예외 케이스까지 깊이 있게 고민해야겠다고 다짐했다.

 

 

 

Try : 잘하고 있는 것을 더 잘하기 위해서, 문제가 있는 점을 해결하기 위해서 시도해 볼 것들


Try1 - VO 사용 시, VO 노출 범위에 대해 깊게 고민하자

VO 를 사용하면 원시값을 포장해 행위를 가지게 할 수 있고, 책임을 분리할 수 있다.

 

하지만, VO 를 가지는 클래스가 외부에 VO 를 노출해야 하는가?에 대해 고려해야 한다고 생각한다.

 

1. VO 를 외부에 노출하는 경우

public class Product {

    private final ProductName productName;

    public Product(final ProductName productName) {
        this.productName = productName;
    }

    // 예시를 위한 메서드
    public void changeName(final ProductName productName) {
        this.productName = productName;
    }
}

이 방식은, 외부에서 Product 를 초기화하거나 Product 와 소통할 때, VO 를 사용해 소통하는 방식이다.

 

장단점은 다음과 같다.

장점. VO를 재사용할 수 있다.

단점. 외부에서 VO 에 대한 의존성이 생긴다.

 

2. VO 가 포장하는 값을 통해 소통하는 경우

public class Product {

    private final ProductName productName;

    public Product(final String productName) {
        this.productName = ProductName.from(productName);
    }

    // 예시를 위한 메서드
    public void changeName(final String productName) {
        this.productName = ProductName.from(productName);
    }
}

 

장단점은 다음과 같다.

장점. 외부에서 VO 에 대한 의존성을 가지지 않아도 된다.

단점. VO 를 재사용할 수 없다(VO 캐싱을 통해 어느 정도 해결할 수 있다)

 

 

 

Try2 - 디미터의 법칙을 혼동하지 말자

디미터의 법칙은 한 라인에 점(.) 을 하나만 찍을 것을 요구한다.

왜 하나의 점(.) 만 찍으라고 요구할까? 이 법칙이 요구하는 점(.)의 의미를 생각해야 한다.

 

A.b 는 A "의" b 필드이다.

stream().filter().map(). 은 메서드 체이닝이다.

 

A.b 를 호출하는 쪽은 A 가 b 필드를 가진다는 것을 알아야 한다.

반면, stream() 을 호출하는 쪽은 객체와는 관련이 없어 보인다.

 

디미터의 법칙은 객체의 속사정을 숨기기 위해 점(.)을 강조하는 것이다.

A.b.c 는 이를 호출하는 쪽에서 A 에 b 가 있고, b 에 c 가 있다는 것을 알아야 하므로 디미터의 법칙을 위반한다.

그러나, stream() 과 같은 메서드 체이닝은 객체와 관련이 없기 때문에 한 줄에 점(.)을 여러 개 찍어도 이를 위반한 것이 아니다.

 

이 상황 말고도 더 다양한 상황이 있다(자료구조 등). 이를 반영해 과도하게 코드를 변경하지 않도록 주의해야 한다고 생각한다.