인프런 커뮤니티 질문&답변

햄토리님의 프로필 이미지

작성한 질문수

토비의 스프링 6 - 이해와 원리

도메인 오브젝트 테스트

도메인 모델 아키텍처 패턴 추가 리팩토링

작성

·

653

·

수정됨

1

추가적인 리팩토링 해보기

PaymentService에서 exRate 계산을 Payment가 직접 하도록 할 수 있음

  • exRateProviderPayment 안으로 넣어주면됨

  • 시간계산은 ClockPayment 안으로 넣어줘도 됨

추가 리팩토링을 진행하면 Paymentprepare 메서드의 내부 로직은 한 줄이면 끝남

강의 중 말씀 해주신 위의 설명을 토대로
PaymentService, Payment 클래스를 리팩토링하고 PaymentTest 클래스를 수정 해 보았습니다.


PaymentService의 prepare 메서드

@Component
public class PaymentService {
    private final ExRateProvider exRateProvider;
    private final Clock clock;

    public PaymentService(ExRateProvider exRateProvider, Clock clock) {
        this.exRateProvider = exRateProvider;
        this.clock = clock;
    }

    public Payment prepare(Long orderId, String currency, BigDecimal foreginCurrencyAmount) throws IOException {

        return Payment.createPrepared(orderId, currency, foreginCurrencyAmount, this.exRateProvider, this.clock);

    }
}

Payment의 createdPrepared 메서드

public static Payment createPrepared(Long orderId, String currency, BigDecimal foreginCurrencyAmount, ExRateProvider exRateProvider,
                                     Clock clock) throws IOException {

    BigDecimal exRate = exRateProvider.getExRate(currency);
    BigDecimal convertedAmount = foreginCurrencyAmount.multiply(exRate);
    LocalDateTime validUntil = LocalDateTime.now(clock).plusMinutes(30);

    return new Payment(orderId, currency, foreginCurrencyAmount, exRate, convertedAmount, validUntil);
}

PaymentTest

class PaymentTest {

    @Test
    void createPrepared() throws IOException {
        Clock clock = Clock.fixed(Instant.now(), ZoneId.systemDefault());
        ExRateProviderStub exRateProvider = new ExRateProviderStub(valueOf(1_000));

        Payment payment = Payment.createPrepared(
                1L, "USD", BigDecimal.TEN, exRateProvider, clock
        );

        Assertions.assertThat(payment.getConvertedAmount()).isEqualByComparingTo(valueOf(10_000));
        Assertions.assertThat(payment.getValidUntil()).isEqualTo(LocalDateTime.now(clock).plusMinutes(30));
    }

    @Test
    void isValid() throws IOException {
        Clock clock = Clock.fixed(Instant.now(), ZoneId.systemDefault());
        ExRateProviderStub exRateProvider = new ExRateProviderStub(valueOf(1_000));

        Payment payment = Payment.createPrepared(
                1L, "USD", BigDecimal.TEN, exRateProvider, clock
        );

        Assertions.assertThat(payment.isValid(clock)).isTrue();
        Assertions.assertThat(
                payment.isValid(Clock.offset(clock, Duration.of(30, ChronoUnit.MINUTES)))).isFalse();
    }
}

질문

  1. 제가 진행한 리팩토링이 올바르게 행하였는지 궁금합니다.

     

     

  2. Test를 진행할 때 exRateProvider를 사용하기 위해서 기존에 작성하였던 ExRateProviderStub오브젝트를 생성하여 테스트를 진행해 주는 것이 맞는지 궁금합니다.

     

  • 처음에 ExRateProviderStub exRateProvider = null; 을 사용해 봤더니 java.lang.NullPointerException: Cannot invoke "tobyspring.hellospring.payment.ExRateProvider.getExRate(String)" because "exRateProvider" is null

    이와 같은 에러가 발생하였습니다. 제가 ExRateProvider 객체가 null로 설정해서 발생한 에러인걸 이해 하고, ExRateProvider를 구현한 객체가 필요하기 때문에 ExRateProviderStub오브젝트를 생성하여 exRate를 넣어 주었습니다.

 

3. ExRateProviderStub오브젝트를 생성하여 exRate(적용환율)을 넣고 생성하는 이유는 실제 api가 아닌 일부 기능을 테스트하기 위해서 저희가 직접 적용환율을 적용해보고 외화금액과 곱해서 계산된게 맞는지 테스트하는 목적이다. 라고 제가 이해하였는데 올바르게 이해한 것인지 궁금합니다.

 

PS. 제가 아직 배운 내용을 완전히 소화하지 못한 부분이 있을 수 있어, 질문에 대한 설명이 부족할 수 있습니다. 혹시 잘못된 부분이나 추가적인 조언이 있다면 피드백 부탁드립니다. 감사합니다.

답변 2

3

Zin님의 프로필 이미지
Zin
지식공유자

안녕하세요, 다람님!
수강은 물론 리팩터링까지 진행하시다니, 멋집니다. 😀

먼저, 질문주신 1, 2, 3번은 말씀하신 내용이 맞습니다.
추가적으로 공유해주신 내용에서 크게 3가지를 말씀드리고 싶습니다.

 

Domain Service의 적용

Payment 도메인에 ExRateProvider와 Clock을 직접 넘겨 구현한 것이 의도하신 도메인 모델 패턴으로 볼 수 있겠습니다.

 

추가 리팩토링

  1. Test Code에서 @BeforeEach 라는 애노테이션 붙인 메소드를 만드시면, 각 테스트 메소드가 실행되기 전에 '준비'하는 작업을 구성해둘 수 있습니다. 또한 중복 코드를 제거하는 효과도 생기겠죠? 아래는 제가 만든 예시 입니다.

class PaymentTest {

    private Clock clock;
    private ExRateProviderStub exRateProvider;

    @BeforeEach
    void setUp() {
        this.clock = Clock.fixed(Instant.now(), ZoneId.systemDefault());
        this.exRateProvider = new ExRateProviderStub(valueOf(1_000));
    }

    @Test
    void createPrepared() throws IOException {
        Payment payment = Payment.createPrepared(
                1L, "USD", BigDecimal.TEN, exRateProvider, clock
        );

        Assertions.assertThat(payment.getConvertedAmount()).isEqualByComparingTo(valueOf(10_000));
        Assertions.assertThat(payment.getValidUntil()).isEqualTo(LocalDateTime.now(clock).plusMinutes(30));
    }

    @Test
    void isValid() throws IOException {
        Payment payment = Payment.createPrepared(
                1L, "USD", BigDecimal.TEN, exRateProvider, clock
        );

        Assertions.assertThat(payment.isValid(clock)).isTrue();
        Assertions.assertThat(
                payment.isValid(Clock.offset(clock, Duration.of(30, ChronoUnit.MINUTES)))).isFalse();
    }
}

해당 Test Class 자체에서 Payment가 전역에서 필요하다면 Payment 도 setUp()에 넣을 수 있겠죠?

 

  1. ExRateProviderStub 오브젝트를 저장하는 변수의 타입을 ExRateProvider로 지정하면 테스트하는 목적과 대상이 더욱 명확히 보일 것 같습니다. 위 예시에서 ExRateProviderStub 타입 선언만 ExRateProvider 으로 바꿔줘도, 테스트 코드를 읽으면서 ExRateProvider를 테스트에 사용한다는 것이 명확해집니다.

class PaymentTest {

    private Clock clock;
    private ExRateProvider exRateProvider;

    @BeforeEach
    void setUp() {
        this.clock = Clock.fixed(Instant.now(), ZoneId.systemDefault());
        this.exRateProvider = new ExRateProviderStub(valueOf(1_000));
    }
    
    // 생략
}

 

Lambda 활용

위의 예시 같이 new 생성자로 ExRateProviderStub을 사용할 수도 있습니다. 또 다르게는, 인터페이스에 메소드가 1개일 경우 Lambda를 통해 그 1개의 메소드를 사용하는 방법도 있습니다. 이는 테스트 목적으로 추가 Class를 정의하지 않고도 실행할 수 있는 방법이기도 합니다.

class PaymentTest {

    private Clock clock;
    private ExRateProvider exRateProvider;

    @BeforeEach
    void setUp() {
        this.clock = Clock.fixed(Instant.now(), ZoneId.systemDefault());
        this.exRateProvider = currency -> BigDecimal.valueOf(1_000);
    }
    
    // 생략
}

 

제 예시 코드가 정답은 아닙니다. 다만, 왜 이렇게 구성하고 고치는지 이해하면 그게 더 나은 개발이고 코드라고 생각합니다. 😁 지금처럼 꾸준히 강의나 학습을 통해 성장하시길 응원하겠습니다!

햄토리님의 프로필 이미지
햄토리
질문자

답변해주신걸 이제 확인했습니다

 

지식공유자 Zin님의 답변으로

코드에 대해서 좀 더 생각해볼 수 있는 것 같아서 감사합니다

생각도 많이 더 해보고 꾸준한 학습을 통해서 성장하도록 하겠습니다!!

 

답변해주셔서 감사합니다

0

안녕하세요, 인프런 AI 인턴입니다.

다람님의 질문에 답변 드리겠습니다.

  1. 진행하신 리팩토링은 객체의 책임과 역할을 Payment 클래스로 명확하게 옮기는 것으로 보여, 객체지향 프로그래밍의 원칙에 따른 설계 방향으로 잘 진행하셨습니다. PaymentService에서 계산 로직을 제외함으로써 역할을 명확히 분리하였고, Payment 클래스 내에서 환율 계산과 시간 계산을 수행함으로써 캡슐화를 강화했습니다.

  2. 단위 테스트에서 실제 외부 시스템에 의존하지 않기 위해 스텁(Stub) 객체를 사용하는 것은 올바른 접근입니다. ExRateProviderStub를 활용하여 특정 환율을 반환하도록 구현하는 것은 환율 정보를 제공하는 외부 시스템을 대신하여, 테스트 환경을 구성하는 데 있어 적절합니다. 따라서, ExRateProvider 인터페이스를 구현한 ExRateProviderStub 객체를 생성하여 테스트에 사용하는 것은 단위 테스트의 격리성을 보장하는 좋은 방법입니다.

  3. ExRateProviderStub 객체를 생성하여 테스트에 사용하는 이유에 대해 올바르게 이해하셨습니다. 실제 환율 정보 제공 API를 호출하지 않고, 테스트 목적에 맞도록 임의의 환율을 설정하여 기능이 의도대로 동작하는지 검증하기 위함입니다. 이러한 방식은 외부 시스템의 영향 없이 원하는 테스트 조건을 설정할 수 있게 하여, 테스트의 신뢰성과 독립성을 높이는 데 기여합니다.

추가적인 조언으로는, 리팩토링 과정에서 도메인 모델의 책임과 역할을 명확히 파악하는 것이 중요합니다. 따라서, 각 클래스와 메서드가 수행해야 할 역할을 기반으로 설계를 진행하고, 단위 테스트를 통해 각 기능이 정확히 동작하는지 지속적으로 검증하는 것이 좋습니다. 또한, 객체지향 설계 원칙(SOLID 원칙 등)을 고려하여 유지보수와 확장성이 용이한 코드를 작성하려는 노력도 중요합니다.

다람님의 학습에 도움이 되길 바랍니다. 추가적인 질문이 있다면 언제든 문의해 주세요. 감사합니다.