Skip to content

외부 API 사용 및 테스트를 위한 리팩토링 여정

byulcode edited this page Jan 18, 2024 · 1 revision

외부 API 사용을 위한 리팩토링 여정

빙터파크의 예매-결제 플로우는 다음과 같다.

  1. 예매 정보 생성(좌석, 회차, 수령 방법 등 선택)
  2. 토스페이먼츠API에 예매 정보를 전달하여 결제 요청(Frontend)
  3. 인증(카드사에서 진행) → 성공 URL로 리다이렉트
  4. 결제 승인 요청 - PaymentKey를 이용해 카드사에 결제를 승인해달라고 요청
  5. 응답 값 처리 - 승인 요청에 대한 응답 값을 이용해 Booking, Payment 상태 업데이트

문제

기존에는 4, 5단계 동작을 모두 PaymentService 에서 처리했다. 그러나 결제 성공 처리와 외부 API와의 통신까지 모두 다루고 있어, 이는 단일 책임 원칙을 위배하고 코드의 유지 보수성을 어렵게 만드는 구조라 판단했다.

테스트 또한 쉽지 않았다. 외부 API 통신과 연관된 부분을 모두 Mocking하고 응답 값을 매번 만들어 주어야 한다는 번거로움이 있었기 때문이다. 더군다나 여러 도메인과 복잡하게 얽혀 있어 여러 구성 요소 간의 상호 작용 및 전체 시스템 동작을 검증하기 위한 통합 테스트를 작성해야 했다.

이 같은 문제를 해결하기 위해 리팩토링을 진행했다.

기존 코드

기존 PaymentService코드

@Service
public class PaymentService {

	private final PaymentRepository paymentRepository;
	private final BookingRepository bookingRepository;
	private final TossPaymentConfig tossPaymentConfig;
	private final RestTemplate restTemplate;

	public PaymentSuccessResponse successPayment(String paymentKey, String bookingId, int amount) {
		Booking booking = bookingRepository.findWithPaymentById(bookingId)
			.orElseThrow(() -> new BookingException(BookingErrorCode.BOOKING_NOT_FOUND));
		Payment payment = booking.getPayment();

		if (payment.getAmount() != amount) {
			throw new BookingException(BookingErrorCode.PAYMENT_AMOUNT_MISMATCH);
		}

		PaymentSuccessResponse response = **requestPaymentConfirmation**(paymentKey, bookingId, amount);

		// 승인 성공 후 처리
		switch (payment.getMethod()) {
			case CARD -> {
				PaymentCardResponse card = response.card();
				payment.updateCardInfo(
					card.number(),
					card.installmentPlanMonths(),
					card.isInterestFree()
				);
				payment.updateApprovedAt(DateTimeUtil.parse(response.approvedAt()));
			}
			case VIRTUAL_ACCOUNT -> {
				PaymentVirtualResponse virtualAccount = response.virtualAccount();
				payment.updateVirtualWaiting(
					virtualAccount.accountNumber(),
					virtualAccount.bankCode(),
					virtualAccount.customerName(),
					DateTimeUtil.parse(virtualAccount.dueDate())
				);
			}
			default -> throw new BookingException(BookingErrorCode.INVALID_PAYMENT_METHOD);
		}
		payment.updateConfirmInfo(paymentKey, DateTimeUtil.parse(response.requestedAt()));
		payment.updateStatus(PaymentStatus.valueOf(response.status()));
		booking.updateStatus(BookingStatus.PAYMENT_COMPLETED);

		return response;
	}

	//외부 API 통신 
	public PaymentSuccessResponse requestPaymentConfirmation(String paymentKey, String bookingId, int amount) {
		HttpHeaders headers = buildTossApiHeaders();
		PaymentConfirmRequest request = new PaymentConfirmRequest(paymentKey, bookingId, amount);
		try { // tossPayments post 요청 (url , HTTP 객체 ,응답 Dto)
			return restTemplate.postForObject(
				TossPaymentConfig.TOSS_CONFIRM_URL, new HttpEntity<>(request, headers), PaymentSuccessResponse.class);
		} catch (HttpClientErrorException e) {
			log.warn("HttpClientErrorException: {}", e.getMessage());
			throw new BookingException(BookingErrorCode.TOSS_PAYMENTS_ERROR);
		} catch (Exception e) {
			log.error("Exception: {}", e.getMessage(), e);
			throw new BookingException(BookingErrorCode.INTERNAL_SERVER_ERROR);
		}
	}
...
}

1차 리팩토링 - 서비스 코드 분리

TossPaymentService 클래스를 생성해 외부 API 통신과 관련된 비즈니스 로직을 분리했다.

TossPaymentService

@Service
public class TossPaymentService {
	private final TossPaymentConfig tossPaymentConfig;
	private final RestTemplate restTemplate;

	public PaymentSuccessResponse **requestTossPaymentConfirmation**(PaymentConfirmRequest request) {
		HttpHeaders headers = buildTossApiHeaders();
		try { // 외부 API 통신
			return restTemplate.postForObject(
				TossPaymentConfig.TOSS_CONFIRM_URL, new HttpEntity<>(request, headers), PaymentSuccessResponse.class);
		} catch (HttpClientErrorException e) {
			log.warn("HttpClientErrorException: {}", e.getMessage());
			throw new BookingException(BookingErrorCode.TOSS_PAYMENTS_ERROR);
		} catch (Exception e) {
			log.error("Exception: {}", e.getMessage(), e);
			throw new BookingException(BookingErrorCode.INTERNAL_SERVER_ERROR);
		}
	}
...
}
@Service
public class PaymentService {

	private final PaymentRepository paymentRepository;
	private final BookingRepository bookingRepository;
	private final TossPaymentService tossPaymentService;

	public PaymentSuccessResponse successPayment(String paymentKey, String bookingId, int amount) {
	...
		PaymentConfirmRequest request = new PaymentConfirmRequest(paymentKey, bookingId, amount);
		PaymentSuccessResponse response = **tossPaymentService.requestTossPaymentConfirmation(request)**;
	
	...승인 성공 후 처리
	}
}

토스페이먼츠 API를 통해 결제 승인 요청을 하는 부분과, 승인된 결제 응답을 처리하는 부분을 분리함으로써 가독성과 유지보수성을 높였다!

하지만 여전히 테스트가 어렵다는 문제는 해소되지 않았다.

2차 리팩토링 - 인터페이스로 분리

테스트 어려움을 해소하기 위해 TossPaymentService 인터페이스를 생성했다.

image

기존 비즈니스 로직은 TossPaymentServiceImpl 에 담고, 테스트를 위해 임의의 API통신 응답을 반환하는 가짜 객체 TossPaymentServiceFake 구현체를 생성했다.

@RequiredArgsConstructor
public class TossPaymentServiceFake implements TossPaymentService {

	OffsetDateTime NOW = OffsetDateTime.now();
	DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssXXX");

	private final BookingRepository bookingRepository;
	private final PaymentRepository paymentRepository;

	@Override
	public PaymentSuccessResponse requestTossPaymentConfirmation(PaymentConfirmRequest request) {
		Booking booking = bookingRepository.findWithPaymentById(request.orderId())
			.orElseThrow(() -> new BookingException(BookingErrorCode.BOOKING_NOT_FOUND));

		switch (booking.getPayment().getMethod()) {
			case CARD -> {
				return new PaymentSuccessResponse(
					request.paymentKey(),
					request.orderId(),
					booking.getBookingName(),
					booking.getPayment().getMethod().getDescription(),
					request.amount(),
					PaymentStatus.DONE.name(),
					NOW.format(formatter),
					NOW.format(formatter),
					new PaymentCardResponse(
						"61",
						"12341234****123*",
						0,
						false
					),
					null
				);
			}
			case VIRTUAL_ACCOUNT -> {
				return new PaymentSuccessResponse(
					request.paymentKey(),
					request.orderId(),
					booking.getBookingName(),
					booking.getPayment().getMethod().getDescription(),
					request.amount(),
					PaymentStatus.DONE.name(),
					NOW.format(formatter),
					NOW.format(formatter),
					null,
					new PaymentVirtualResponse(
						"X6505636518308",
						"20",
						"박토스",
						NOW.plusDays(7).format(formatter)
					)
				);
			}
			default -> {
				return null;
			}
		}
	}
...

PaymentService에 대한 테스트코드를 작성할 때 TossPaymentServiceFake 구현체를 활용함으로써 통합 테스트를 원활하게 수행할 수 있게 되었다!