많은 아키텍처, 다양한 기술들로 문제를 해결해 나가는 것은 매우 중요해요. 하지만, 가독성 좋은 코드는 개발자 자신을 위해, 즉 가까운 미래의 나와 먼 미래의 나 모두를 위해 중요하며, 동료 개발자를 위해서도 마찬가지로 중요해요. 구체적인 예시를 통해 가독성 좋은 메서드로 리팩토링하는 과정과 그 결과를 공유해보려 해요.
이 메서드는 다양한 검증을 필요로하는데 필요한 검증 내용은 다음과 같아요.
이제 코드를 기반으로 설명을 할게요.
@Transactional
public void redeemCoupon(int userId, ReDeemCouponRequestDto requestDto) {
User user = userService.getUser(userId);
Coupon coupon = couponService.getCoupon(requestDto.couponNumber());
LocalDate currentDate = LocalDate.now();
if (coupon.getUserId() != userId) {
throw new CouponUserMismatchException();
}
if (coupon.isUsed()) {
throw new AlreadyRedeemedCouponException();
}
if (currentDate.isAfter(coupon.getExpiredDate())) {
throw new ExpiredCouponException();
}
couponService.useCoupon(coupon);
cashPointService.addPoint(user, coupon);
}
위 코드는 서비스 내에서 다양한 검증을 수행해요. 1번과 2번은 그 도메인을 담당하는 서비스에서 존재 유무를 판단한 후에 데이터를 가져오고 있어요. 3~5번 각각의 검증 단계를 이 메서드 내에서 직접 처리되고 있으며, 메서드의 책임이 커지고 있습니다.
또한, 이러한 코드 구조는 단위 테스트 작성 시, 각 조건을 개별적으로 테스트하기 때문에 유지보수 할 코드가 늘어나게 되요. 새로운 검증 로직 추가나 변경 시 테스트 코드를 대폭 수정해야 하는 거죠.
이 테스트는 상호작용이 올바르게 되고 있는지 파악하기 위한 단위테스트를 작성했어요. 그리고 테스트 코드 내에 분기문이 있는 경우 테스트 스위트의 품질이 저하되기에 성공에 대한 케이스, 예외에 대한 케이스들을 분리해서 작성했어요.
@Test
@DisplayName("쿠폰 적용에 성공한다.")
void redeem_coupon_success() {
int userId = 1;
User testUser = User.builder().id(userId).build();
Coupon testCoupon = Coupon.builder()
.userId(userId)
.name("testCoupon")
.couponNumber("COUPON123")
.couponType(CouponType.FREE_POINT)
.isUsed(false)
.expiredDate(LocalDate.now().plusDays(1))
.build();
ReDeemCouponRequestDto requestDto = new ReDeemCouponRequestDto("COUPON123");
when(userService.getUser(userId)).thenReturn(testUser);
when(couponService.getCoupon(requestDto.couponNumber())).thenReturn(testCoupon);
thisService.redeemCoupon(userId, requestDto);
verify(userService).getUser(userId);
verify(couponService).getCoupon(requestDto.couponNumber());
verify(couponService).useCoupon(testCoupon);
verify(cashPointService).addPoint(testUser, testCoupon);
}
@Test
@DisplayName("쿠폰 owner와 유저가 일치하지 않아 CouponUserMismatchException 예외가 발생한다.")
void when_redeem_coupon_should_throw_CouponUserMismatchException() {
// 예외처리에 대한 검증
}
@Test
@DisplayName("쿠폰이 이미 사용되어 AlreadyRedeemedCouponException 예외가 발생한다.")
void when_redeem_coupon_should_throw_AlreadyRedeemedCouponException() {
// 예외처리에 대한 검증
}
@Test
@DisplayName("쿠폰 만료기간이 지나 ExpiredCouponException 예외가 발생한다.")
void when_redeem_coupon_should_throw_ExpiredCouponException() {
// 예외처리에 대한 검증
}
테스트코드는 해당 메서드를 이해하는데 큰 도움이 된다고 생각해요. 그래서 이 코드들을 보면 어떠한 검증을 하는지 바로 파악할 수 있지만, 메서드를 사용하는 입장에서 검증되었다는 것만 중요하지, 검증 세부사항을 모두 궁금해 하진 않아요. 즉, 이 코드는 구현 세부사항이 노출되어 있다고 볼 수 있어요.
리팩토링한 코드는 검증 로직은 Coupon
클래스의 isValidate
메서드로 이동시켜, redeemCoupon
메서드의 책임을 축소하고, 관련 로직을 쿠폰 객체 내부로 캡슐화했어요.
@Transactional
public void redeemCoupon(int userId, ReDeemCouponRequestDto requestDto) {
User user = userService.getUser(userId);
Coupon coupon = couponService.getCoupon(requestDto.couponNumber());
LocalDate currentDate = LocalDate.now();
coupon.isValidate(userId, currentDate);
couponService.useCoupon(coupon);
cashPointService.addPoint(user, coupon);
}
@Getter
@NoArgsConstructor
public class Coupon {
// 상태 관련 변수...
// 생성자 관련 함수...
public void useCoupon() {
this.isUsed = true;
}
// 쿠폰이 유효하다는 메서드만 드러내고
public void isValidate(int userId, LocalDate currentDate) {
validateUserOwnership(userId);
validateUsability();
validateExpiration(currentDate);
}
// 구현 세부사항은 감춘다.
private void validateUserOwnership(int userId) {
if (this.userId != userId) {
throw new CouponUserMismatchException();
}
}
private void validateUsability() {
if (this.isUsed) {
throw new AlreadyRedeemedCouponException();
}
}
private void validateExpiration(LocalDate currentDate) {
if (currentDate.isAfter(this.expiredDate)) {
throw new ExpiredCouponException();
}
}
}
검증 로직을 별도의 메서드로 분리함으로써, 단위 테스트가 간결해지고 각각의 검증 조건을 독립적으로 테스트할 수 있게 되요. 이렇게 하면 유지 보수와 기능 확장 시 테스트 코드의 수정을 최소화하는 장점을 살릴 수 있어요.
@Test
@DisplayName("쿠폰 적용에 성공한다.")
void redeem_coupon_success() {
int userId = 1;
User testUser = User.builder().id(userId).build();
Coupon testCoupon = Coupon.builder()
.userId(userId)
.name("testCoupon")
.couponNumber("COUPON123")
.couponType(CouponType.FREE_POINT)
.isUsed(false)
.expiredDate(LocalDate.now().plusDays(1))
.build();
ReDeemCouponRequestDto requestDto = new ReDeemCouponRequestDto("COUPON123");
when(userService.getUser(userId)).thenReturn(testUser);
when(couponService.getCoupon(requestDto.couponNumber())).thenReturn(testCoupon);
sut.redeemCoupon(userId, requestDto);
verify(userService).getUser(userId);
verify(couponService).getCoupon(requestDto.couponNumber());
verify(couponService).useCoupon(testCoupon);
verify(cashPointService).addPoint(testUser, testCoupon);
}
그리고 검증하는 메서드들은 서비스의 단위 테스트가 아니라, 도메인의 단위 테스트로 옮기면 끝나요.
리팩토링을 통해 객체 지향 원칙 중 하나인 캡슐화를 강화하고 메서드의 책임을 명확히 분리함으로써, 코드의 가독성과 유지 보수성을 향상시켰어요. 쿠폰 객체가 자신의 상태와 동작을 책임지게 만들어 응집력이 높아졌어요.
또한,isValidate
메서드는 쿠폰의 유효성을 검사하는데 사용되는데, 이 메서드명은 해당 동작을 명확하게 설명해주고, 각각의 유효성 검사를 private 메서드들로 적절한 이름을 사용하여 코드의 기능을 명확하게 설명하고 있으며, 코드의 의도롤 파악하기가 쉬워졌어요.
이와 같이 코드 (성능) 최적화를 포함한 기술을 통해 메서드를 잘 작성하는 것도 중요하지만, 결국 그 메서드를 쓰는 것은 사람이기 때문에 가독성 좋은 코드를 만드는 것이 기본이지만 잊지 않도록 습관으로 만들어봐요 !