Skip to content

Blog

Logger 성능 저하 방지와 구조화된 로깅 설계

로그를 단순히 println()으로 찍는 건 성능 이슈와 레벨을 구분할 수 없기 때문에, 개발 초기 테스트 외엔 적절하지 않다.
로그는 문제를 추적하고 흐름을 분석하기 위해 많은 양의 데이터를 남기기 때문에, 구조화된 출력과 불필요한 연산 제어가 반드시 필요하다.

  • 로그 레벨 구분이 없음 (debug, info, warn 등 불가능)
  • 시간, 클래스, 스레드 정보가 출력되지 않음
  • 로그 파일 관리 및 저장 불가
  • 멀티스레드 환경에서 병목 발생 (System.out은 synchronized I/O)

로그를 남길 때는 "Hello" + "World"와 같은 문자열 결합 방식이 아닌, "Hello {}"와 같은 포맷팅 방식을 권장하는데, 이는 성능에 영향을 미치기 때문이다.

private static void concatLog(Order order) {
logger.trace("OrderId=" + order);
}

이 방식은 로그 레벨이 trace가 아니라면 출력도 안 되지만, order.toString() 호출과 문자열 결합은 그대로 발생하여, 출력은 안 되지만 연산은 수행되는 비효율이 생긴다.
이를 피하기 위해 두 가지 방식을 사용할 수 있다.

  1. 로그 레벨을 직접 체크하는 방식
  2. 파라미터 포맷팅 방식 ({} 사용)
// 1. 레벨을 직접 확인하는 방식
private static void checkConditionLog(Order order) {
if (logger.isTraceEnabled()) {
logger.trace("OrderId= " + order);
}
}
// 2. 파라미터 포맷팅 방식
private static void paramLog(Order order) {
logger.trace("OrderId={}", order);
}

1번 방식은 조건을 직접 해야하는 조건문을 작성해야 하므로 코드가 복잡해지고 가독성이 떨어져, 보통 2번 방식인 파라미터 포맷팅 방식을 사용한다.

로그를 남기는 방식에 따라 성능 차이가 얼마나 나는지 테스트를 진행해보기 위해, 다음과 같이 멀티스레드 환경에서 로그를 남기는 테스트를 작성했다.(info 레벨 기준)

class LoggerTest extends IntegrationTest {
private static final Logger logger = LoggerFactory.getLogger(LoggerTest.class);
private static final int THREAD_COUNT = 10;
private static final int TASK_COUNT = 100000;
private void runTest(ExecutorService executor, String label, Runnable task) throws InterruptedException {
CountDownLatch latch = new CountDownLatch(THREAD_COUNT);
long start = System.currentTimeMillis();
for (int i = 0; i < THREAD_COUNT; i++) {
executor.submit(() -> {
for (int j = 0; j < TASK_COUNT; j++) {
task.run();
}
latch.countDown();
});
}
latch.await();
long end = System.currentTimeMillis();
System.out.println(label + ": " + (end - start) + "ms");
}
static class TestData {
private final List<String> data;
public TestData(int size) {
this.data = new ArrayList<>();
for (int i = 0; i < size; i++) {
data.add(UUID.randomUUID().toString());
}
}
public String getTestDataString() {
return data.stream().sorted().collect(Collectors.joining(","));
}
@Override
public String toString() {
return this.getTestDataString();
}
}
}

방식은 3가지로 나누어 테스트를 진행했다.

  1. 문자열 결합 방식 (+ 사용)
  2. String.format() 방식
  3. 파라미터 포맷팅 방식 ({} 사용)
class LoggerTest extends IntegrationTest {
private static void logWithConcat(TestData testData) {
logger.trace("[TEST] | TEST | testData=" + testData);
}
private static void logWithFormat(TestData testData) {
logger.trace(String.format("[TEST] | TEST | testData=%s", testData));
}
private static void logWithParam(TestData testData) {
logger.trace("[TEST] | TEST | orderId={}", testData);
}
@Test
void testLogSpeed() throws InterruptedException {
ExecutorService executor = Executors.newFixedThreadPool(THREAD_COUNT);
TestData testData = new TestData(100);
// 948ms
runTest(executor, "String concatenation (+)", () -> logWithConcat(testData));
// 886ms
runTest(executor, "String.format()", () -> logWithFormat(testData));
// 19ms
runTest(executor, "Parameterized logging ({})", () -> logWithParam(testData));
executor.shutdown();
}
}
방식소요 시간
문자열 결합 (+)948ms
String.format() 사용886ms
파라미터 포맷팅 ({})19ms
  • +, String.format() 연산자 방식은 로그 레벨에 관계없이 toString()이 호출되고 문자열 결합이 발생
  • 파라미터 포맷팅({}) 방식은 로그 레벨이 활성화된 경우에만 포맷팅이 수행되어, toString() 호출이 발생하지 않음

결과적으로 파라미터 포맷팅 방식이 가장 빠르며, 문자열 결합/포맷은 로그 레벨이 활성화되어 있지 않아도 toString() 호출과 문자열 결합이 발생하여 성능이 저하되는 것을 확인할 수 있었다.

{} 포맷팅을 사용하면 문자열 결합 비용은 줄일 수 있지만, 메서드 호출 자체는 여전히 발생한다는 점에서 성능 이슈는 완전히 사라지지 않는다.

private static String getExpensiveDetail(Order order) {
logger.trace("OrderId={}, detail={}", order.getId(), order.getExpensiveDetail());
}

위 코드처럼 로그 레벨과 무관하게 getExpensiveDetail()이 호출된다면, 불필요한 연산이 수행되어 성능 저하가 발생할 수 있다.

private static void logWithSupplier(Order order) {
logger.trace("OrderId={}, detail={}", () -> order.getId(), () -> order.getExpensiveDetail());
}

Log4j2는 이를 해결하기 위해 Supplier 기반 지연 평가를 지원하지만, Slf4j는 해당 기능을 제공하지 않기 때문에 isTraceEnabled() 같은 조건문을 명시적으로 사용하여 방지할 수 있다.

메서드 호출 성능 테스트(SlF4J 환경)

Section titled “메서드 호출 성능 테스트(SlF4J 환경)”

위와 동일한 테스트 환경에서 메서드 호출을 포함한 로그 성능을 테스트해보았다.

class LoggerTest extends IntegrationTest {
private static void logConcatWithMethodCall(TestData testData) {
logger.trace("[TEST] | TEST | testData=" + testData.getTestDataString());
}
private static void logFormatWithMethodCall(TestData testData) {
logger.trace(String.format("[TEST] | TEST | testData=%s", testData.getTestDataString()));
}
private static void logParamWithMethodCall(TestData testData) {
logger.trace("[TEST] | TEST | orderId={}", testData.getTestDataString());
}
private static void logParamWithCheck(TestData testData) {
if (logger.isTraceEnabled()) {
logger.trace("[TEST] | TEST | testData={}", testData.getTestDataString());
}
}
@Test
void testLogSpeedWithMethodCall() throws InterruptedException {
ExecutorService executor = Executors.newFixedThreadPool(THREAD_COUNT);
TestData testData = new TestData(100);
// 1042ms
runTest(executor, "String concat + method call",
() -> logConcatWithMethodCall(testData));
// 1092ms
runTest(executor, "String.format + method call",
() -> logFormatWithMethodCall(testData));
// 947ms
runTest(executor, "Param-style logging + method call",
() -> logParamWithMethodCall(testData));
// 14ms
runTest(executor, "Level check logging + method call",
() -> logParamWithCheck(testData));
executor.shutdown();
}
}
방식소요 시간
문자열 결합 + 메서드 호출1042ms
String.format + 메서드 호출1092ms
파라미터 포맷팅 + 메서드 호출947ms
로그 레벨 체크 후 포맷팅 + 메서드 호출14ms
  • 파라미터 포맷팅 방식은 로그 레벨과 관계없이 메서드 호출이 발생하여 성능 저하가 발생
  • isTraceEnabled() 조건을 사용한 경우, 로그 레벨이 비활성화되어 있으면 해당 연산 자체가 수행되지 않아 성능 저하가 거의 없음

실전 대응: 구조화된 포맷 + 연산 최소화

Section titled “실전 대응: 구조화된 포맷 + 연산 최소화”

로그 포맷을 강제하고 싶었고, 동시에 로그 레벨이 꺼져 있을 때 불필요한 연산이 발생하지 않도록 막고 싶어, 직접 LogFmt 유틸 클래스를 만들어 사용했다.

  • 로그의 도메인과 이벤트명을 명확하게 구분
  • 로그 메시지는 Supplier<String>으로 감싸, 로그 레벨이 비활성화되었을 땐 메시지 연산 자체가 발생하지 않도록 처리
  • Slf4j 환경이므로 isInfoEnabled() 등의 레벨 체크 사용
public void example() {
LogFmt.info(
logger,
LogDomain.PAYMENT,
EventType.PAYMENT_STATUS_TO_IN_PROGRESS,
() -> String.format("orderId=%s", orderId)
);
}
2025-08-03 12:56:56.129 ... - [3ce5fab6] [PAYMENT] | PAYMENT_STATUS_TO_IN_PROGRESS | orderId=55996af6-e5b5-47e5-ac3c-44508ee6fd6b
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class LogFmt {
private static final String INFO_LOG_FORMAT = "[{}] | {} | {}";
private static final String INFO_LOG_FORMAT_NO_MESSAGE = "[{}] | {}";
// ...
public static void info(Logger logger, LogDomain logDomain, EventType event) {
if (logger.isInfoEnabled()) {
logger.info(INFO_LOG_FORMAT_NO_MESSAGE, logDomain.name(), event.name());
}
}
public static void info(Logger logger, LogDomain logDomain, EventType event, Supplier<String> messageSupplier) {
if (logger.isInfoEnabled()) {
logger.info(INFO_LOG_FORMAT, logDomain.name(), event.name(), messageSupplier.get());
}
}
// ...
}

이렇게 구성된 로깅 유틸은 성능을 해치지 않으면서도 흐름을 추적할 수 있는 구조화된 로그 설계로 이어졌다.
불필요한 연산을 막고, 도메인과 이벤트 타입을 명확히 하여 로그를 쉽게 파악할 수 있게 되었다.

로그

결제 상태 전환 관리와 재시도 로직을 통한 결제 복구 시스템 구축

실행 환경: Java 21, Spring Boot 3.3.3

최초 구상했던 결제 로직은 결제 정보 검증을 통해 안전한 결제 연동 시스템을 목표로 하였지만, 예상치 못한 에러에 대한 처리 로직이 미흡했다.
그로 인해 특수한 상황에서 사용자가 신뢰성을 느끼기 어려울 것으로 판단되어 결제 시스템에 대한 설계 및 구현을 다시 진행하게 되었다.

문제를 해결하기 위한 방법을 찾던 중 가상 면접 사례로 배우는 대규모 시스템 설계 기초 중 결제 시스템 파트를 통해 결제 시스템에 대한 도메인 지식과 안전한 결제 처리를 위한 해결 방법을 찾을 수 있었고, 이를 바탕으로 더 나은 결제 시스템을 설계하고자 했다.

책에서는 결제 서비스/결제 실행 서비스/원장 서비스/지갑 서비스로 분리된 구조를 제시했는데, 현 프로젝트에서는 결제 서비스와 결제 실행 서비스를 하나로 통합하여 구현하였다.
또한, 이번 개선 작업에서는 결제 서비스와 그 안에서 발생하는 문제들에 대해서 다뤘으며, 원장 서비스와 지갑 서비스에 대한 부분과 시스템에 주는 이점은 다음 단계에서 다루고자 한다.

기존 결제 시스템 구현에서 발생할 수 있던 문제점은 다음과 같았다.

  1. 결제 승인 중 발생한 에러에 대한 명확한 처리 부재
    • 기존 코드에선 모든 에러에 대해 결제 실패로 처리하고 있었음
    • 그 중 재시도를 통해 해결될 수 있는 에러도 실패로 간주
  2. API 지연으로 인한 결제 처리 오류
    • 토스 측에 결제 승인 요청을 보낸 후 응답이 지연되어 타임아웃 발생 가능성 존재
    • 서버 측에서는 지연으로 인해 실패 처리되었으나, 실제로는 토스 측에서 승인 완료하여 금액만 빠져나갈 수 있음
  3. 결제 승인 요청 중 서버 중단
    • 결제 승인 요청이 토스 결제 서비스까지 전달되어 실제 결제가 완료되었으나, 서버가 중단되면서 결과를 반환하지 못함
    • 이로 인해 결제는 이미 이루어져 금액이 빠져나갔음에도 불구하고, 시스템에서는 결제가 완료되지 않은 상태로 남아있을 수 있음

이러한 문제들을 해결하기 위해 크게 아래 네 가지 방법들을 도입하였다.

  1. 재시도 가능/불가능 에러 구분
  2. 재시도 로직 구현
  3. 결제 상태 전환 관리
  4. 멱등키 사용

1. 재시도 가능/불가능 에러 구분

Section titled “1. 재시도 가능/불가능 에러 구분”

기존 코드에서는 어떤 에러가 발생하든지 동일하게 실패 처리했지만, 에러를 크게 두 가지 유형으로 구분하였다.

  • 재시도 가능한 에러: 일시적인 네트워크 문제 / 외부 서비스 장애 / API 타임아웃과 같이 일정 시간 후 재 시도를 통해 해결될 수도 있는 에러
  • 재시도 불가능한 에러: 잘못된 요청, 상품 재고 부족 등으로 인해 재시도가 불가능한 근본적인 문제

위에서 구분 된 재시도 가능한 에러에 대해, 일정 횟수까지만큼 재시도 처리하는 스케줄러를 구현하여 결제 성공으로 이어질 수 있도록 하였다.

  • 재시도 가능한 특정 결제 상태인 결제 요청에 대해 결제 재시도 요청
  • 재시도가 너무 많아져 시스템에 부하가 걸리는 것을 방지하기 위해 재시도 횟수 제한

기존 프로젝트에서는 시작 -> 진행 -> 완료/실패 -> 결제 취소로 순차적으로만 관리되었으나, 재시도 처리를 위한 디테일한 상태 관리가 필요했다.

결제 상태 다이어그램

  • READY: checkout 으로 인해 최초 생성된 결제 상태
  • IN_PROGRESS: 결제 승인 요청 처리 시작한 상태
  • DONE: 결제가 성공적으로 완료된 상태
  • FAILED: 결제 실패한 상태
  • CANCEL: 결제가 취소된 상태
  • UNKNOWN: 에러가 발생하였으나, 재시도를 통해 해결 가능성이 있는 상태

개선된 상태 다이어그램에서는 UNKNOWN 상태를 도입하여, 모든 에러를 바로 실패 처리하지 않고 재시도를 통해 결제 성공으로 이어질 수 있도록 하였다.

이미 성공한 결제일 수 있지만, 알 수 없는 상태에 대한 결제 재시도로 인해 동일한 결제가 여러 번 이루어질 수 있다.
특히 재시도 로직이 도입되는 만큼, 멱등키를 이용하여 중복 결제를 방지하여 정확히 한 번만 결제 금액이 빠져나가도록 해야 했다.

  • 멱등키 생성: 각 결제 요청 시 고유한 멱등키를 DB에 저장하고, 같은 결제에 대해 다시 요청하면 해당 키로 중복 결제 방지
  • 재시도 시 동일 결과 보장: 이미 토스 측에 성공한 결제더라도, 같은 키를 통해 중복 결제 방지

결과적으로, 이러한 네 가지 해결 방안을 통해 기존 프로젝트에서 발생하던 문제들을 다음과 같이 해결할 수 있었다.

1. 결제 승인 중 발생한 에러에 대한 명확한 처리 부재

Section titled “1. 결제 승인 중 발생한 에러에 대한 명확한 처리 부재”

모든 에러를 동일하게 처리하여 결제가 실패로 처리하던 부분을 재시도 가능한 에러와 불가능한 에러로 명확하게 구분하여 각각에 맞는 처리를 적용할 수 있게 되었다.

  • 재시도 가능 에러: 재시도 로직을 통해 성공으로 처리
  • 재시도 불가능한 에러: 근본적인 문제로 인한 에러는 즉시 결제 실패로 처리하여, 사용자에게 정확한 정보 전달

API 응답 지연 시에는 결제를 UNKNOWN 상태로 변경하여, 재시도를 통해 최종적으로 결제 성공으로 이어질 수 있도록 처리할 수 있게 되었다.

  • 응답 지연 시 재시도 처리: 토스 측 응답 지연으로 결제가 실패 처리되는 경우, 즉시 실패로 간주하지 않고 재시도할 수 있도록 함
  • 중복 결제 방지: 이미 성공했으나 토스측에 결과만 못 받은 경우에도, 멱등키를 사용하여 중복해서 결제되지 않도록 함

3. 결제 승인 요청 중 서버가 중단되는 경우

Section titled “3. 결제 승인 요청 중 서버가 중단되는 경우”

서버 중단으로 인해 결제가 제대로 처리되지 못하거나 중복 결제가 발생할 수 있었으나, 재시도를 통해 올바른 결제 처리를 보장할 수 있게 되었다.

  • 서버 중단 시 복구: IN_PROGRESS 상태로 남아있는 결제들에 대해 결제 요청을 다시 시도
  • 중복 결제 방지: (토스 측에 요청을 보냈으나 반환되기 전에 서버가 중단 된 경우)성공한 결제에 대해 중복 결제 방지를 위해 멱등키 사용

앞서 설명한 문제들을 해결하기 위해 도입한 네 가지 개선 방안에 대한 실제 구체적인 구현 방법과 코드를 살펴보자.

1. 재시도 가능/불가능 에러 구분 + 3. 결제 상태 전환 관리

Section titled “1. 재시도 가능/불가능 에러 구분 + 3. 결제 상태 전환 관리”

결제 승인 과정에서 발생하는 다양한 에러에 따라 시스템은 다음과 같이 처리하도록 구현하였다.

결제 승인 처리 흐름과 에러에 따른 상태 처리

이러한 에러 상태를 구분하기 위해, 먼저 토스 측에서 발생하는 에러 코드를 명확히 정의할 필요가 있었다.

  • 토스 측에서 발생하는 모든 에러 코드에 대해 Enum으로 정의
  • 해당 에러 코드에 대해 성공 / 재시도 가능 여부 판단 메서드 구현

그 외에 추가적으로 타임아웃 관련 에러 코드를 추가해 재시도 가능한 에러로 처리하였다.

@Getter
@RequiredArgsConstructor
public enum TossPaymentErrorCode {
ALREADY_PROCESSED_PAYMENT(400, "이미 처리된 결제 입니다."),
// ...
UNKNOWN_PAYMENT_ERROR(500, "결제에 실패했어요. 같은 문제가 반복된다면 은행이나 카드사로 문의해주세요."),
UNKNOWN(500, "알 수 없는 에러입니다."),
NETWORK_ERROR(500, "네트워크 오류가 발생했습니다. 잠시 후 다시 시도해주세요."); // 타임아웃 처리를 위한 별도로 추가한 에러
//...
public boolean isSuccess() {
return this == ALREADY_PROCESSED_PAYMENT;
}
public boolean isRetryableError() {
return switch (this) {
case PROVIDER_ERROR, FAILED_PAYMENT_INTERNAL_SYSTEM_PROCESSING,
FAILED_INTERNAL_SYSTEM_PROCESSING, UNKNOWN_PAYMENT_ERROR,
UNKNOWN, NETWORK_ERROR -> true;
default -> false;
};
}
public boolean isFailure() {
return !isSuccess() && !isRetryableError();
}
}

위 클래스를 통해 토스 결제 결과가 성공인지, 재시도 가능한 에러인지, 실패인지를 판단할 수 있도록 구현하였고, 호출한 곳에서 해당 결과에 따라 성공 객체 혹은 Checked Exception을 던지도록 하였다.

@Service
@RequiredArgsConstructor
public class PaymentProcessorUseCase {
// ...
public TossPaymentInfo confirmPaymentWithGateway(PaymentConfirmCommand paymentConfirmCommand)
throws PaymentTossRetryableException, PaymentTossNonRetryableException {
// ... 토스 측에 결제 승인 요청 및 반환
PaymentConfirmResultStatus paymentConfirmResultStatus = tossPaymentInfo.getPaymentConfirmResultStatus();
// 상태에 따라 처리
return switch (paymentConfirmResultStatus) {
// 1. 성공
case PaymentConfirmResultStatus.SUCCESS -> tossPaymentInfo;
// 2. 재시도 가능한 에러 - Checked Exception
case PaymentConfirmResultStatus.RETRYABLE_FAILURE ->
throw PaymentTossRetryableException.of(PaymentErrorCode.TOSS_RETRYABLE_ERROR);
// 3. 재시도 불가능한 에러 - Checked Exception
case PaymentConfirmResultStatus.NON_RETRYABLE_FAILURE ->
throw PaymentTossNonRetryableException.of(PaymentErrorCode.TOSS_NON_RETRYABLE_ERROR);
};
}
// ...
}

호출한 메서드에서 해당 에러를 처리하도록 하였고(Checked Exception으로 이를 강제), 에러 핸들링은 다음과 같은 유형으로 나누어 처리하였다.

예외 상황재시도 가능결제 상태 처리재고 처리
1. 재고 차감 중 오류X실패 처리복구 불필요
2. 이미 처리된 결제에 대한 중복 요청X실패 처리재고 복구
3. 외부 결제사의 일시적인 오류O알 수 없음 상태로 변경재고 유지
4. 외부 결제사의 복구 불가능 오류X실패 처리재고 복구
5. 결제 검증 또는 기타 시스템 오류X실패 처리재고 복구
@Service
@RequiredArgsConstructor
public class PaymentConfirmServiceImpl implements PaymentConfirmService {
// ...
@Override
public PaymentConfirmResult confirm(PaymentConfirmCommand paymentConfirmCommand) {
// 1. 결제 이벤트 조회
PaymentEvent paymentEvent = paymentLoadUseCase.getPaymentEventByOrderId(
paymentConfirmCommand.getOrderId()
);
try {
// 2. 재고 차감 - 재고 부족 에러 발생 가능
orderedProductUseCase.decreaseStockForOrders(paymentEvent.getPaymentOrderList());
} catch (PaymentOrderedProductStockException e) { // 에러 1: 재고 부족 에러 발생
handleStockFailure(paymentEvent); // FAIL 처리(재고 중 일어난 에러로, 감소가 일어나지 않았으므로 복구 필요 없음)
throw PaymentTossConfirmException.of(PaymentErrorCode.ORDERED_PRODUCT_STOCK_NOT_ENOUGH);
}
try {
// 3. 결제 시작 처리 - 결제 시작 상태 검증 중 에러 발생 가능
paymentProcessorUseCase.executePayment(paymentEvent, paymentConfirmCommand.getPaymentKey());
// 4. 결제 검증 - 결제 검증 중 에러 발생 가능
// 5. 토스 결제 승인 요청 - 토스 측 재시도 가능/불가능한 에러 발생 가능
// 6. 결제 완료 처리
PaymentEvent completedPayment = processPayment(paymentEvent, paymentConfirmCommand);
// 7. 결제 완료 결과 반환
// ...
} catch (PaymentStatusException e) { // 에러 2: 이미 요청이 진행 된 건에 대한 요청
handleNonRetryableFailure(paymentEvent); // FAIL 처리 + 재고 복구
throw e;
} catch (PaymentTossRetryableException e) { // 에러 3: 토스 측 재시도 가능한 에러 발생
handleRetryableFailure(paymentEvent); // UNKNOWN 상태로 변경 + 재고 그대로 유지
throw PaymentTossConfirmException.of(PaymentErrorCode.TOSS_RETRYABLE_ERROR);
} catch (PaymentTossNonRetryableException e) { // 에러 4: 토스 측 재시도 불가능한 에러 발생
handleNonRetryableFailure(paymentEvent); // FAIL 처리 + 재고 복구
throw PaymentTossConfirmException.of(PaymentErrorCode.TOSS_NON_RETRYABLE_ERROR);
} catch (Exception e) { // 에러5: 결제 검증 중 에러 등 기타 에러 발생
handleUnknownException(paymentEvent); // FAIL 처리 + 재고 복구
throw e;
}
}
// ...
}

위 구현 사항으로 처리 된 재시도 가능으로 구분 된 에러에 대해, 재시도 로직을 구현하여 두 가지 상태에 대해 결제 성공으로 이어질 수 있도록 구현하였다.

  1. UNKNOWN: 토스 측 재시도 가능 에러 혹은 타임아웃으로 인해 결제 결과를 받지 못한 상태
  2. IN_PROGRESS: 결제 승인 시작 처리는 했지만 아직 결과를 받지 못한 상태

이 두 가지 상태를 기반으로 재시도 가능한 결제를 주기적으로 조회하여 재시도 처리를 수행하여, 결제를 성공으로 마무리하거나, 실패로 처리하도록 구현하였다.

@Service
@RequiredArgsConstructor
public class PaymentRecoverServiceImpl implements PaymentRecoverService {
// 1. 재시도 가능한 결제 이벤트 조회 후 아래 로직 수행
private void processRetryablePaymentEvent(PaymentEvent retryablePaymentEvent) {
try {
// 2. 재시도 가능 여부 횟수 검증 - 재시도 불가능 에러 발생 가능
if (!retryablePaymentEvent.isRetryable(localDateTimeProvider.now())) {
throw PaymentRetryableValidateException.of(PaymentErrorCode.RETRYABLE_VALIDATION_ERROR);
}
// 3. 재시도 횟수 증가
paymentProcessorUseCase.increaseRetryCount(retryablePaymentEvent);
// ...
// 4. 토스 결제 승인 요청 재시도 후 완료 처리 - 토스 측 재시도 가능/불가능한 에러 발생 가능
TossPaymentInfo tossPaymentInfo = paymentProcessorUseCase.confirmPaymentWithGateway(
paymentConfirmCommand
);
paymentProcessorUseCase.markPaymentAsDone(
retryablePaymentEvent,
tossPaymentInfo.getPaymentDetails().getApprovedAt()
);
} catch (PaymentRetryableValidateException // 에러 1: 재시도 불가능한 상태 에러 발생
| PaymentTossNonRetryableException e) { // 에러 2: 토스 측 재시도 불가능한 에러 발생
handleNonRetryableFailure(retryablePaymentEvent); // FAIL 처리 + 재고 복구
} catch (PaymentTossRetryableException e) { // 에러 3: 토스 측 재시도 가능한 에러 발생
handleRetryableFailure(retryablePaymentEvent); // UNKNOWN 상태로 변경
}
}
// ...
}

재시도 검증 후, 기존 결제 승인 요청과 같이 토스 측에 재시도 요청을 보내도록 처리하였으며, 다음과 같이 구분하여 처리하였다.

결제 재시도 다이어그램

  • 성공: 재시도 성공 후 결제 완료 처리(이미 재고가 차감되어 있으므로, 재고 감소나 복구는 필요 없음)
  • 토스 측 재시도 불가능한 에러: 해당 결제에 대해 실패 처리 + 재고 복구
  • 토스 측 재시도 가능 에러: UNKNOWN 상태로 변경
  • 재시도 횟수 초과: 해당 결제에 대해 실패 처리 + 재고 복구

재시도 횟수 초과 에러로, 계속 해서 UNKNOWN 상태로 유지되더라도, 일정 횟수 이상 재시도를 했을 때 결제 실패로 처리하여 무한히 재시도하는 것을 방지하였다.

결제 처리 중 서버의 장애나 네트워크 문제로 동일한 결제 요청이 중복될 가능성이 존재하고, 해당 결제들에 대한 복구 로직이 실행되기 때문에 중복 결제를 방지하기 위해 멱등키를 도입하였다.

  • 이번 프로젝트에서는 결제 이벤트 생성 시 만들어지는 고유한 orderId를 멱등키로 사용
  • UUID를 사용하여 고유한 키임을 보장

이렇게 생성 된 멱등키는 토스 결제 서비스로 요청을 보낼 때 HTTP 헤더에 포함되어 전송하여, 동일한 결제 요청에도 중복 결제를 막고 성공적인 결제 처리를 보장할 수 있었다.

이번 결제 시스템 개선을 통해 기존 구현에서 발생했던 주요 문제점들을 해결할 수 있었다.

항목기존 시스템 문제점개선된 시스템 해결 방안
에러 처리모든 에러를 동일하게 결제 실패로 처리재시도 가능/불가능 에러로 구분하여 각각에 맞는 처리 도입
API 응답 지연응답 지연 시 결제 실패로 처리UNKNOWN 상태로 관리하고 재시도를 통해 최종적으로 성공 처리
결제 도중 서버 중단서버 중단으로 결제 완료 여부를 알 수 없고, 중복 결제 발생 가능성 존재멱등키를 사용하여 중복 결제 방지 및 재시도 로직을 통한 결제 완료 처리

이번 결제 시스템 개선을 통해 재시도 가능한 에러와 불가능한 에러를 구분하고, 멱등키를 통해 중복 결제를 방지하며, 재시도 로직을 도입하여 결제의 안정성과 유연성을 크게 향상시킬 수 있었다.
이를 통해 결제를 복구할 수 있는 시스템을 구현하였으며, 결제 처리의 신뢰성을 높일 수 있었다.

그러나 정확한 상태 변경 이력 추적과 원장 및 지갑 관리 시스템의 부재로 인해 결제 내역의 투명성 및 결제 상태 추적이 어려운 한계점이 존재한다.
이를 보완하기 위한 추가적인 개선이 이루어진다면, 결제 시스템의 완성도를 더욱 높일 수 있을 것으로 기대된다.

결제 상태가 변경될 때마다 상태 변경 이력을 추적하고, 상태 변경에 따른 추가적인 로그를 남기지 않아 추후 에러에 대한 추적이 어려울 수 있음

책에서 제시한 것처럼 원장/지갑 관리 기능이 포함되지 않아 결제 내역과 잔액 간의 정확한 일치 여부를 추적할 수 없음

외부 의존성 제어를 통한 결제 프로세스 다양한 시나리오 검증

실행 환경: Java 21, Spring Boot 3.3.3, JUnit 5

결제 시스템은 서비스 신뢰성과 직결되기 때문에 안정적이고 오류 없는 시스템을 구축하기 위해서는 다양한 시나리오를 검증하는 테스트 코드 작성이 필수적이다.
결제 승인 과정에서 발생할 수 있는 다양한 시나리오에 대응할 수 있고, 신뢰성 높은 결제 프로세스를 보장하기 위해 여러 테스트 전략을 적용해보았다.

크게 단위 테스트와 통합 테스트로 구분지어 테스트 코드를 작성했으며, 각 테스트 코드의 목표는 다음과 같이 설정하였다.

  • 단위 테스트: 개별 도메인 및 서비스 로직을 대상으로 세부적인 로직 검증
  • 통합 테스트: 실제 결제 흐름을 따르고 다양한 예외 상황에 대해 대응하는 테스트를 작성

특히 토스 결제 시스템의 의존성을 제어하는 것이 가장 주요한 과제였는데, 이를 위해 여러 전략을 활용하여 결제 시나리오를 테스트하는 방법을 적용했다.

** 참고: 토스 실제 결제 승인은 클라이언트 SDK를 통해 결제 요청을 먼저 수행해야 하며, 그 후에 결제 승인을 진행해야 한다.

위 두 가지를 기반으로 테스트를 통해 보장하고자 했던 주요 목표는 다음과 같다.

  1. 결제 승인 과정 중 발생할 수 있는 다양한 시나리오 검증 및 도메인/서비스 로직 신뢰성 확보
  2. 인프라스트럭처 영역은 페이크 또는 목 객체를 활용하여 효율적인 테스트 환경 구성
  3. 멀티 스레드 테스트로 기본적인 동시성 이슈 검증 및 타임아웃 시나리오에 대한 검증

각 목표를 달성하기 위해 취한 전략 및 작성한 테스트 코드의 예시는 다음과 같다.

1. 결제 승인 과정 중 발생할 수 있는 다양한 시나리오 검증 및 도메인/서비스 로직 신뢰성 확보

Section titled “1. 결제 승인 과정 중 발생할 수 있는 다양한 시나리오 검증 및 도메인/서비스 로직 신뢰성 확보”

결제 승인 과정은 다양한 예외 상황과 복잡한 절차적 흐름을 포함하고 있기 때문에, 이를 신뢰성 있게 처리하기 위해서는 다양한 시나리오를 고려한 테스트 코드 작성이 필수적이다.
단일 케이스를 넘어 여러 파라미터를 조합한 테스트를 통해 다양한 시나리오를 검증하고, 서비스 로직에서 발생 가능한 모든 에러에 대한 처리를 확인함으로써 신뢰성을 높일 수 있었다.

PaymentEvent 관련된 로직에서는 결제 승인 및 검증 과정에서 발생할 수 있는 다양한 시나리오를 파라미터화된 테스트로 작성하여, 단순 성공 케이스 뿐만 아니라, 예외 상황이나 여러 상태 값에 대한 다양한 케이스를 고려하여 테스트 코드를 작성했다.

class PaymentEventTest {
@ParameterizedTest
@CsvSource({
"1, validPaymentKey, 15000, order123, INVALID_TOTAL_AMOUNT",
"2, invalidPaymentKey, 15000, order123, INVALID_PAYMENT_KEY",
"1, validPaymentKey, 14000, wrongOrderId, INVALID_ORDER_ID"
})
@DisplayName("다양한 조건에서 검증 실패 시 PaymentValidException 예외가 발생한다.")
void validate_InvalidCases(
Long userId,
String paymentKey,
int amount,
String orderId
) {
PaymentEvent paymentEvent = defaultPaymentEvent();
// ...
assertThatThrownBy(
() -> paymentEvent.validateCompletionStatus(paymentConfirmCommand, paymentInfo))
.isInstanceOf(PaymentValidException.class);
}
@ParameterizedTest
@EnumSource(value = TossPaymentStatus.class, names = {
"CANCELED", "EXPIRED", "PARTIAL_CANCELED", "ABORTED"
})
@DisplayName("결제 상태가 유효하지 않은 경우 PaymentStatusException 예외가 발생한다.")
void validate_InvalidPaymentStatus(TossPaymentStatus tossPaymentStatus) {
PaymentEvent paymentEvent = defaultPaymentEvent();
// ...
assertThatThrownBy(
() -> paymentEvent.validateCompletionStatus(paymentConfirmCommand, paymentInfo))
.isInstanceOf(PaymentStatusException.class);
}
}

위 코드에서는 다양한 조건과 상황에 따라 결제 승인 검증 과정에서 발생할 수 있는 다양한 케이스를 다루며, 각 상황에 적합한 예외 처리를 확인했다.

또한, 서비스 로직에서 Toss API와의 통신 중 발생할 수 있는 모든 에러 코드를 검수하고, 이를 적절하게 처리할 수 있도록 테스트 할 수 있었다.

class PaymentGatewayServiceImplErrorCaseTest extends IntegrationTest {
// ...
@ParameterizedTest(name = "{index}: Test with TossPaymentErrorCode={0}")
@EnumSource(TossPaymentErrorCode.class)
@DisplayName("TossPaymentErrorCode에 따라 Header를 설정하고 결제 확인 결과를 검증한다.")
void confirmPayment_withTossPaymentErrorCode(TossPaymentErrorCode errorCode) {
// given
String uuid = new SystemUUIDProvider().generateUUID();
ReflectionTestUtils.invokeMethod(httpOperator, "addHeader", "TossPayments-Test-Code", errorCode.name());
// ...
// when
TossPaymentInfo tossPaymentInfo = paymentGatewayService.confirmPayment(tossConfirmCommand, uuid);
PaymentConfirmResultStatus paymentConfirmResultStatus = tossPaymentInfo.getPaymentConfirmResultStatus();
// then
Assertions.assertThat(paymentConfirmResultStatus)
.isEqualTo(getExpectedResultStatus(errorCode));
}
// ...
@TestConfiguration
static class TestConfig {
@Bean
public HttpOperator httpOperator() {
return new AdditionalHeaderHttpOperator();
}
}
}

실제 요청을 보내되, 헤더에 특정한 에러 코드를 추가하면 토스 측에서 에러를 반환해주는 테스트 API를 이용해 에러 상황을 시뮬레이션하고 검증했다.
이를 통해 결제 승인 과정에서 발생할 수 있는 모든 예외 상황에 대한 적절한 처리를 확인하고, 각 에러에 대해 재요청 가능/불가능 여부를 검증할 수 있었다.

2. 인프라스트럭처 영역은 페이크 또는 목 객체를 활용하여 효율적인 테스트 환경 구성

Section titled “2. 인프라스트럭처 영역은 페이크 또는 목 객체를 활용하여 효율적인 테스트 환경 구성”

토스 API의 결제 승인 요청은 클라이언트 SDK를 통해 요청을 먼저 수행해야 하며, 그 후에 결제 승인을 진행해야 하는 선행 작업을 만족해야하는 어려움이 있었다.
이를 해결하기 위해 페이크 객체를 활용하여 외부 의존성을 제거하여 테스트 환경을 구성했다.

public class FakeTossHttpOperator implements HttpOperator {
// ...
// Reflection을 통해 호출되는 메서드
@SuppressWarnings("unused")
public void setDelayRange(int minDelayMillis, int maxDelayMillis) {
this.minDelayMillis = minDelayMillis;
this.maxDelayMillis = maxDelayMillis;
}
@SuppressWarnings("unused")
public void addErrorInPostRequest(String code, String message) {
this.code = code;
this.message = message;
this.isErrorInPostRequest = true;
}
@SuppressWarnings("unused")
public void clearErrorInPostRequest() {
this.isErrorInPostRequest = false;
}
@SuppressWarnings("java:S2925")
private void simulateNetworkDelay() {
long delay = minDelayMillis + (long) (Math.random() * (maxDelayMillis - minDelayMillis));
try {
TimeUnit.MILLISECONDS.sleep(delay);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
private void throwError() {
// ...
}
// ...
@Override
public <T, E> E requestPost(String url, Map<String, String> httpHeaderMap, T body, Class<E> responseType) {
// 네트워크 지연 시뮬레이션
simulateNetworkDelay();
// 에러 발생 시뮬레이션
if (isErrorInPostRequest) {
throwError();
}
// ...
// 실제 API 호출 없이 응답 반환
return responseType.cast(tossPaymentApiResponse);
}
}

위 구현체는 기존 실제 요청을 보내는 구현체의 인터페이스인 HttpOperator를 구현했지만 실제 네트워크 요청 없이 결제 승인을 테스트할 수 있게 해준다.

  1. 실제 API 호출 X: 외부 API 호출 없이 승인 결과를 반환
  2. 네트워크 지연 시뮬레이션: 실제 환경과 유사하게 네트워크 지연 시간 설정 가능
  3. 에러 발생 시뮬레이션: 특정한 오류 코드와 메시지를 지정한 에러 발생 가능

이렇게 구성 된 페이크 객체는 @TestConfiguration을 통해 테스트 환경에서만 사용되도록 설정한 뒤, Reflection을 통해 테스트 코드에서 호출하여 사용할 수 있었다.

class PaymentControllerTest extends IntegrationTest {
@Test
@DisplayName("Payment Confirm 요청이 성공하면 결제가 승인되고 DONE / SUCCESS 상태로 변경되면서 재고가 감소한다.")
void confirmPayment_Success() throws Exception {
// ...
ReflectionTestUtils.invokeMethod(httpOperator, "clearErrorInPostRequest"); // 에러 없음 => 성공
// ...
}
@Test
@DisplayName("Payment Confirm 요청 중 재시도 가능 오류가 발생하면 결제는 실패하고 UNKNOWN / UNKNOWN 상태로 변경되면서 재고는 감소된 상태로 유지된다.")
void confirmPayment_Failure_RetryableError() throws Exception {
// ...
ReflectionTestUtils.invokeMethod(httpOperator, "addErrorInPostRequest",
TossPaymentErrorCode.PROVIDER_ERROR.name(), // 재시도 가능한 오류 코드
TossPaymentErrorCode.PROVIDER_ERROR.getDescription()
);
// ...
}
@Test
@DisplayName("Payment Confirm 요청 중 재시도 불가능 오류가 발생하면 결제는 실패하고 FAILED / FAIL 상태로 변경되면서 재고는 다시 복구된다.")
void confirmPayment_Failure_NonRetryableError() throws Exception {
// ...
ReflectionTestUtils.invokeMethod(httpOperator, "addErrorInPostRequest",
TossPaymentErrorCode.INVALID_STOPPED_CARD.name(), // 재시도 불가능한 오류 코드
TossPaymentErrorCode.INVALID_STOPPED_CARD.getDescription()
);
// ...
}
@TestConfiguration
static class TestConfig {
@Bean
public HttpOperator httpOperator() {
return new FakeTossHttpOperator();
}
}
}

페이크 객체를 활용함으로써 외부 API 의존성 및 절차적 테스트의 어려움을 해소했을 뿐만 아니라, 결제 승인 중 발생할 수 있는 다양한 시나리오에 대한 테스트를 보다 쉽게 작성할 수 있었다.

3. 멀티 스레드 테스트로 기본적인 동시성 이슈 검증 및 타임아웃 시나리오에 대한 검증

Section titled “3. 멀티 스레드 테스트로 기본적인 동시성 이슈 검증 및 타임아웃 시나리오에 대한 검증”

결제 시스템에서 다수의 사용자가 동시에 결제를 시도할 때 발생할 수 있는 동시성 문제는 중요한 검증 대상이다.
특히, 여러 사용자가 동일한 상품을 동시에 주문하거나 외부 API 타임아웃이 발생할 수 있기 때문에 이러한 상황에 대한 검증이 필요하다.

동시성 문제는 여러 스레드가 동시에 동일한 리소스를 수정할 때 발생하며, 이를 해결하지 못할 경우 결제가 중복되거나 재고 관리에 오류가 발생할 수 있다.
본 프로젝트에서는 비관적 락을 사용하여 동시 결제 시 재고 수량의 일관성을 보장했으며, 멀티 스레드 테스트를 통해 상황을 시뮬레이션하고, 주문 수량과 재고가 정확히 관리되는지 확인했다.

@Tag("TooLongIntegrationTest")
class PaymentConfirmConcurrentTest extends IntegrationTest {
@ParameterizedTest
@CsvSource({
"1000, 1000, 1000, 0, 0", // 재고와 주문 수량이 일치
"1000, 999, 999, 0, 1", // 주문 수량이 재고보다 적음
"1000, 1001, 1000, 1, 0", // 주문 수량이 재고보다 많음
"1000, 1050, 1000, 50, 0", // 재고 초과 주문 (50개 초과 주문 실패)
"1200, 1000, 1000, 0, 200", // 재고가 1200개, 주문 수량은 1000개 (200개 남음)
})
@DisplayName("멀티스레드로 Payment Confirm 요청 시 결제 승인 처리와 상태가 동시성에 맞게 처리된다.")
void concurrentConfirmPayment_withStock(
int stock,
int orderCount,
int expectedSuccess,
int expectedFail,
int expectedStock
) {
// ...
executeConcurrentActions(orderIndex -> {
try {
// ...
MvcResult mvcResult = mockMvc.perform(
post("/api/v1/payments/confirm")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(confirmRequest))
).andReturn();
if (mvcResult.getResponse().getStatus() == 200) {
successCount.incrementAndGet();
} else {
failCount.incrementAndGet();
}
} catch (Exception e) {
throw new RuntimeException(e);
}
}, orderCount);
// then
Product updatedProduct = jpaProductRepository.findById(1L).orElseThrow().toDomain();
assertThat(successCount.get()).isEqualTo(expectedSuccess);
assertThat(failCount.get()).isEqualTo(expectedFail);
assertThat(updatedProduct.getStock()).isEqualTo(expectedStock);
}
@TestConfiguration
static class TestConfig {
@Bean
public HttpOperator httpOperator() {
return new FakeTossHttpOperator();
}
}
}

다섯 번의 시나리오에서 동시 다발적인 결제 요청을 시뮬레이션하고, 결제가 성공적으로 처리되었는지, 재고가 정확하게 차감되었는지를 검증한다.
FakeTossHttpOperator를 활용해 실제 API 호출 없이 외부 의존성을 가지지 않는 환경에서 테스트를 진행할 수 있었다.
(실제 테스트 키로 부하 테스트를 수행하면 토스 측에서 일정 시간 동안 요청을 제한한다.)

동시성 문제 외에도 외부 네트워크 지연으로 인한 타임아웃 문제는 실제 결제 시스템에서 발생할 수 있다.
이를 검증하기 위해 결제 승인 요청에 대해 일정 지연을 설정하여 해당 상황에서도 모든 결제 요청이 적절하게 처리되는지 검증하는 테스트를 작성했다.

@Tag("TooLongIntegrationTest")
class PaymentConfirmConcurrentTest extends IntegrationTest {
@ParameterizedTest
@CsvSource({
"500, 1000",
"1000, 3000",
"3000, 7000",
})
@DisplayName("멀티스레드로 Payment Confirm 요청 시 결제 승인 처리와 상태가 동시성에 맞게 처리된다.")
void confirmPayment_withTimeout(
int minDelayMills,
int maxDelayMills
) {
// ...
// 네트워크 지연 설정
ReflectionTestUtils.invokeMethod(httpOperator, "setDelayRange", minDelayMills, maxDelayMills);
executeConcurrentActions(orderIndex -> {
try {
// ...
MvcResult mvcResult = mockMvc.perform(
post("/api/v1/payments/confirm")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(confirmRequest))
).andReturn();
if (mvcResult.getResponse().getStatus() == 200) {
successCount.incrementAndGet();
} else {
failCount.incrementAndGet();
}
} catch (Exception e) {
throw new RuntimeException(e);
}
}, orderCount);
// then
Product updatedProduct = jpaProductRepository.findById(1L).orElseThrow().toDomain();
assertThat(successCount.get()).isEqualTo(expectedSuccess);
assertThat(failCount.get()).isEqualTo(expectedFail);
assertThat(updatedProduct.getStock()).isEqualTo(expectedStock);
}
@TestConfiguration
static class TestConfig {
@Bean
public HttpOperator httpOperator() {
return new FakeTossHttpOperator();
}
}
}

jacoco test coverage

결제 시스템은 안정성이 최우선이기 때문에, 발생 가능한 다양한 시나리오와 문제를 테스트하는 것이 다른 도메인보다 더 중요하다고 생각한다.
본 테스트에서는 단위 테스트와 통합 테스트를 통해 각각의 로직과 전체 흐름을 검증하고, 멀티 스레드 테스트를 통해 동시성 및 타임아웃 같은 문제도 처리할 수 있는지 확인했다.

이번 테스트 과정을 통해 다음과 같은 성과를 얻을 수 있었다.

  1. 결제 승인 과정 검증: 다양한 시나리오를 고려한 테스트를 통해 결제 승인 과정의 여러 상황에 대한 검증을 수행할 수 있었다.
  2. 효율적인 테스트 환경 구성: 페이크 객체(FakeTossHttpOperator)를 활용하여 외부 API 호출을 대체함으로써 유연한 테스트 환경을 구축할 수 있었다.
  3. 동시성 이슈와 타임아웃 처리: 멀티 스레드 테스트를 통해 결제 시스템의 동시성 이슈와 타임아웃 처리 능력을 검증함으로써 실서비스 환경에서 발생할 수 있는 문제를 미리 방지할 수 있었다.

특히, 외부 API 서버에 요청을 보내는 대신 페이크 객체를 사용해 실제 요청 없이 테스트를 수행한 점이 테스트의 유연성을 높이는 데 큰 도움이 되었다.

다만, 이번 테스트는 동시성 이슈와 타임아웃 문제를 기본적인 수준에서 검증했을 뿐, 실제 부하가 걸리는 환경에서의 완벽한 검증을 수행했다고 보기는 어렵다.
따라서, 더 정밀한 부하 테스트는 전용 툴을 사용해 추가적으로 진행해야 하며, 이를 통해 결제 시스템의 성능 지표와 처리량을 더욱 정확하게 파악할 수 있을 것이다.
이러한 추가적인 검증 작업을 통해 결제 시스템의 안정성을 한층 더 강화할 수 있을 것이다.

MySQL COUNT() 함수의 동작 원리와 성능

실행 환경: MySQL 8.0.33, InnoDB Storage Engine

MySQL에서 COUNT()함수 인자로 아래 두 가지 방식을 사용할 수 있다.

  • COUNT(*)(= COUNT(1)): 테이블의 전체 레코드 수를 반환
  • COUNT(column_name): 특정 컬럼의 값이 NULL이 아닌 레코드 수를 반환

COUNT(*)은 테이블의 전체 레코드 수를 반환하기 때문에, PK인 id 컬럼을 사용할 것 처럼 보이지만, 실제로 옵티마이저는 다른 방식을 사용하여 처리하게 된다.

COUNT(*) 실행 계획과 옵티마이저 동작

Section titled “COUNT(*) 실행 계획과 옵티마이저 동작”

SELECT COUNT(*)SELECT *처럼 전체 컬럼 혹은 PK를 의미하는 것이 아닌, 옵티마이저가 최적화하여 가장 빠르게 처리할 수 있는 컬럼을 의미한다.
여기서 가장 빠르게 처리할 수 있는 컬럼이란 사용 가능한 가장 작은 세컨더리 인덱스인데, 실제로 MySQL 공식 문서에서도 아래와 같이 설명하고 있다.

InnoDB processes SELECT COUNT() statements by traversing the smallest available secondary index unless an index or optimizer hint directs the optimizer to use a different index. If a secondary index is not present, InnoDB processes SELECT COUNT() statements by scanning the clustered index.
인덱스 또는 옵티마이저 힌트가 옵티마이저에 다른 인덱스를 사용하도록 지시하지 않는 한, InnoDB는 사용 가능한 가장 작은 보조 인덱스를 탐색하여 SELECT COUNT() 문을 처리합니다. 보조 인덱스가 없는 경우, InnoDB는 클러스터된 인덱스를 스캔하여 SELECT COUNT() 문을 처리합니다.

실제로 그런지 확인하기 위해 employees 테이블에 COUNT(*) 쿼리를 실행하고, 실행 계획을 확인해보자.
테스트에는 MySQL 샘플 데이터의 employees 테이블을 사용하였다.

-- 데이터 개수 300,024개
create table employees
(
emp_no int not null primary key,
birth_date date not null,
first_name varchar(14) not null,
last_name varchar(16) not null,
gender enum ('M', 'F') not null,
hire_date date not null
);
EXPLAIN
SELECT COUNT(*)
FROM employees;
-- | id | select\_type | table | partitions | type | possible\_keys | key | key\_len | ref | rows | filtered | Extra |
-- |:---|:-------------|:----------|:-----------|:------|:---------------|:--------|:---------|:-----|:-------|:---------|:------------|
-- | 1 | SIMPLE | employees | null | index | null | PRIMARY | 4 | null | 298841 | 100 | Using index |

실행 계획을 확인해보면, PRIMARY 키를 사용하여 인덱스 스캔을 수행하는 것을 확인할 수 있는데, 여기서 인덱스를 추가하면 달라지는 것을 확인할 수 있다.

CREATE INDEX idx_first_name
ON employees (first_name);
EXPLAIN
SELECT COUNT(*)
FROM employees;
-- | id | select\_type | table | partitions | type | possible\_keys | key | key\_len | ref | rows | filtered | Extra |
-- |:---|:-------------|:----------|:-----------|:------|:---------------|:-----------------|:---------|:-----|:-------|:---------|:------------|
-- | 1 | SIMPLE | employees | null | index | null | idx\_first\_name | 58 | null | 298841 | 100 | Using index |

key 컬럼이 PRIMARY에서 idx_first_name으로 변경되었는데, 여기서 또 다른 인덱스를 추가하면 어떻게 처리되는지 확인해보자.

CREATE INDEX idx_gender
ON employees (gender);
CREATE INDEX idx_birth_date
ON employees (birth_date);
EXPLAIN
SELECT COUNT(*)
FROM employees;
-- | id | select\_type | table | partitions | type | possible\_keys | key | key\_len | ref | rows | filtered | Extra |
-- |:---|:-------------|:----------|:-----------|:------|:---------------|:------------|:---------|:-----|:-------|:---------|:------------|
-- | 1 | SIMPLE | employees | null | index | null | idx\_gender | 1 | null | 298841 | 100 | Using index |

idx_gender로 변경되었는데, 해당 컬럼은 enum 데이터 타입을 사용하고 있다.
enum 데이터 타입은 1-2바이트만 사용하는 가장 작은 크기의 인덱스이기 때문에 옵티마이저가 해당 인덱스를 사용하여 COUNT(gender) 쿼리로 처리한 것이다.

EXPLAIN
SELECT COUNT(emp_no)
FROM employees;
-- | id | select\_type | table | partitions | type | possible\_keys | key | key\_len | ref | rows | filtered | Extra |
-- |:---|:-------------|:----------|:-----------|:------|:---------------|:------------|:---------|:-----|:-------|:---------|:------------|
-- | 1 | SIMPLE | employees | null | index | null | idx\_gender | 1 | null | 298841 | 100 | Using index |

추가적으로 COUNT(emp_no)처럼 PK 컬럼으로 명시하더라도 옵티마이저에서 PK가 아닌 가장 작은 크기의 인덱스를 사용하여 처리하는 것을 확인할 수 있었다.

가장 작은 크기의 세컨더리 인덱스가 더 빠른 이유

Section titled “가장 작은 크기의 세컨더리 인덱스가 더 빠른 이유”

COUNT(*) 쿼리는 가장 작은 크기의 세컨더리 인덱스를 사용하여 처리하는 것이 더 빠른 이유는 InnoDB 엔진의 세컨더리 인덱스 구조 때문이다.

InnoDB Non-Clustered Index

InnoDB의 세컨더리 인덱스는 Non-Clustered Index로, 해당 인덱스에는 해당 컬럼 값과 PK 값만 저장되어 있는데,
때문에 COUNT(*) 쿼리에선 아래의 이유로 더 빠르게 처리할 수 있게 된다.

  1. 레코드 수 집계의 특성: COUNT 쿼리에서 필요한 것은 실제 데이터가 아닌 레코드 수이기 때문에 논 클러스터 인덱스더라도 실제 데이터에 대한 I/O가 필요 없음
  2. 인덱스 크기와 스캔 속도: 세컨더리 인덱스는 해당 인덱스와 PK만 저장하므로 작은 데이터 크기를 가져, 하나의 페이지에 많은 레코드가 들어가 디스크 읽기 작업이 줄어듦

** 페이지: 디스크에 데이터를 저장하는 기본 단위로, 디스크의 모든 읽기 및 쓰기 작업의 최소 작업 단위

COUNT(column_name) 인자의 컬럼 데이터 크기가 미치는 영향

Section titled “COUNT(column_name) 인자의 컬럼 데이터 크기가 미치는 영향”

아래 예시는 세컨더리 인덱스를 사용하여 처리하는 것은 아니지만, 컬럼의 데이터 크기와 양에 따라 처리 속도가 크게 달라질 수 있음을 확인할 수 있다.

-- TEXT 타입 컬럼 추가
alter table employees
add text_column text null;
-- 컬럼에 대량의 데이터 추가
UPDATE employees
SET test_column = '...'; -- 약 2^16 길이의 영문자
SELECT COUNT(text_column)
FROM employees;
-- 17025ms
SELECT COUNT(last_name)
FROM employees;
-- 50-100ms

이처럼 데이터의 크기는 COUNT() 쿼리에 큰 영향을 미치는 요인 중 하나이며, 특히 TEXT와 같이 큰 데이터 타입을 사용할 때는 더욱 주의하는 것이 좋다.

MyISAM과 InnoDB 스토리지 엔진에서 테이블의 레코드 전체 개수를 조회하는 쿼리인 SELECT COUNT(*) FROM table_name는 아래와 같이 처리된다.

  • MyISAM: 메타 데이터를 통해 별도 연산 없이 조회
  • InnoDB: 데이터나 인덱스 스캔을 통해 레코드 건수 조회

InnoDB가 MyISAM과 같이 메타 데이터를 통해 조회하지 않는 이유는 InnoDB의 트랜잭션 지원을 위해 MVCC를 사용하기 때문이다.

InnoDB does not keep an internal count of rows in a table because concurrent transactions might “see” different numbers of rows at the same time. Consequently, SELECT COUNT() statements only count rows visible to the current transaction.
동시 트랜잭션이 동시에 다른 수의 행을 ‘볼’ 수 있기 때문에 InnoDB는 테이블의 행 내부 개수를 유지하지 않습니다. 따라서 SELECT COUNT(
) 문은 현재 트랜잭션에 표시되는 행만 카운트합니다.

WHERE 조건 / GROUP BY 조건 없는 COUNT(*) 쿼리

Section titled “WHERE 조건 / GROUP BY 조건 없는 COUNT(*) 쿼리”

이전에 커서 기반 페이징 성능 개선 을 진행하면서 WHERE 조건이 없는 COUNT(*) 쿼리가 빠르게 처리되는 것을 확인할 수 있었는데, 그 때 상황을 재현하면 아래와 같다.

-- 데이터 개수 3,000,000개
CREATE TABLE bulkinsert
(
num INT PRIMARY KEY
);
-- 1. 테이블 레코드 전체 개수 조회
SELECT COUNT(*)
FROM bulkinsert;
-- 80-100ms
-- 2. 테이블 레코드의 전체가 걸리는 조건으로 조회
SELECT COUNT(*)
FROM bulkinsert
WHERE num >= 0;
-- 350-400ms
-- 3. 테이블 레코드의 대부분을 조회
SELECT COUNT(*)
FROM bulkinsert
WHERE num < 2950000;
-- 350-400ms
-- 4. 테이블 레코드의 소수만 조회
SELECT COUNT(*)
FROM bulkinsert
WHERE num < 10;
-- 30-50ms
쿼리 유형조건실행 시간
1. 테이블 레코드 전체 개수 조회없음80-100ms
2. 테이블 레코드의 전체가 걸리는 조건으로 조회num >= 0350-400ms
3. 테이블 레코드의 대부분을 조회num < 2950000350-400ms
4. 테이블 레코드의 소수만 조회num < 1030-50ms

테이블 레코드 전체 개수 조회가 비슷한 양의 데이터를 조회하는 2,3번 쿼리보다 훨씬 빠르게 처리되는 것을 확인할 수 있는데,
이전에는 메타 데이터를 통해 조회하기 때문에 더 빠르다고 생각했지만, 아래의 이유로 이는 틀린 사실임을 알 수 있다.

  • InnoDB에서는 MVCC 지원을 위해 실제 스캔을 통해 레코드 건수를 조회
  • 메타 데이터를 통해 조회하는 것이라면, 소수만 조회하는 4번 쿼리보다 빠르게 처리되어야 함

전체 레코드를 조회하게 되는 1, 2번 두 쿼리의 EXPLAIN ANALYZE 결과를 확인해보면 아래와 같다.

-- 1. 테이블 레코드 전체 개수 조회
EXPLAIN ANALYZE
SELECT COUNT(*)
FROM bulkinsert;
-- -> Count rows in bulkinsert (actual time=58.3..58.3 rows=1 loops=1)
-- 2. 테이블 레코드의 전체가 걸리는 조건으로 조회
EXPLAIN ANALYZE
SELECT COUNT(*)
FROM bulkinsert
WHERE num >= 0;
-- -> Aggregate: count(0) (cost=408276 rows=1) (actual time=627..627 rows=1 loops=1)
-- -> Filter: (bulkinsert.num >= 0) (cost=272398 rows=1.36e+6) (actual time=0.0403..546 rows=3e+6 loops=1)
-- -> Covering index range scan on bulkinsert using PRIMARY over (0 <= num) (cost=272398 rows=1.36e+6) (actual time=0.0377..412 rows=3e+6 loops=1)
  • 1번 쿼리: 전체 행을 카운트하는데, 2번보다 비교적 빠르게 처리(58.3ms vs 627ms)
  • 2번 쿼리: num >= 0 지점을 찾고 필터링을 수행하는 부분 뿐만 아니라, 집계하는 부분에서도 많은 시간이 소요됨

필터링하는 부분과 집계하는 부분에서 예상보다 많은 시간이 소요되는 것을 확인할 수 있었는데, 아래의 이유들로 인해 두 쿼리의 처리 속도 차이가 발생한 것으로 추측해보았다.

  • 오라클 진영의 Single/Multi Block IO 개념과 같이, 1번 쿼리는 MBR(Multi-Block-Read)의 방식처럼 데이터 블록을 빠르게 읽어옴
  • 전체 레코드를 조회할 때는 다른 COUNT 쿼리와 다른 읽기 방식 사용(Count rows in ..., Aggregate: count(0))
  • 2번 쿼리는 WHERE 조건 필터링 과정을 거치지만(Filter: (bulkinsert.num >= 0)), 1번 쿼리는 이러한 과정이 생략됨

결국 옵티마이저가 최적의 방법을 찾아 수행하는 것이기 때문에, 정확한 처리 방법과 그 차이는 알 수 없었지만,
이러한 결과가 나온 이유를 설명할 수 있는 부분을 공식 문서에서 찾을 수 있었다.(실제 개선 내용은 SELECT COUNT(*) 성능 향상에서 확인 가능)

As of MySQL 8.0.13, SELECT COUNT() FROM tbl_name query performance for InnoDB tables is optimized for single-threaded workloads if there are no extra clauses such as WHERE or GROUP BY.
MySQL 8.0.13부터 InnoDB 테이블에 대한 tbl_name의 SELECT COUNT(
) FROM 쿼리 성능은 WHERE 또는 GROUP BY와 같은 추가 절이 없는 경우 단일 스레드 워크로드에 최적화되어 있습니다.

COUNT() 함수는 단일 row를 반환하기 때문에 단순하고 빠르게 처리하는 것으로 보일 수 있지만, 예상치 못한 성능 이슈가 발생할 수 있으니, 유의해서 사용하는 것이 좋다.

  1. 인덱스 활용 최적화: COUNT(*) 쿼리는 옵티마이저에서 가능한 가장 작은 세컨더리 인덱스를 사용하여 처리되므로, 적절한 인덱스를 추가하여 성능을 향상시킬 수 있음
  2. 데이터 타입: COUNT(column_name)을 사용할 때는 데이터 타입을 고려해야 하며, 특히, TEXTBLOB과 같은 대용량 데이터 타입은 주의 필요
  3. 조건이 없는 COUNT 최적화: 전체 레코드를 조회할 땐 조건이 없는 COUNT(*) 쿼리를 사용하여 빠르게 처리할 수 있음
  4. 캐싱과 추정치 사용: COUNT 쿼리는 많은 비용이 발생하기 때문에, 정확한 행 수를 계산이 필요한 요구사항이 아니라면, 캐싱이나 추정치를 계산하는 방법을 고려할 수 있음

트랜잭션 범위 최소화를 통한 성능 및 안정성 향상

실행 환경: Java 17, Spring Boot 3.1.5, MySQL 8.0.33
!! 해당 내용은 복구 로직 적용 전의 내용을 담고 있으며, 현재 적용된 내용은 마지막 부분에 기술되어 있습니다.

프로젝트 진행 중 MySQL 관련 공부를 하던 중, 트랜잭션 범위의 중요성에 대해 학습하게 되었다.
특히, 트랜잭션 범위가 넓을 경우 발생하는 성능 문제를 인식하게 되었고, 이를 개선하기 위해 트랜잭션 범위 최소화 작업을 진행하였다.

트랜잭션 범위가 넓어지면 다음과 같은 문제점이 발생할 수 있다.

  • 커넥션 풀 부족
    • 커넥션 풀은 데이터베이스와 연결된 커넥션을 미리 생성해두고, 요청이 들어올 때마다 커넥션을 빌려주고 반납받는 방식으로 동작한다.
    • 커넥션 풀에는 최대 커넥션 수가 정해져 있으며, 이를 초과하게 되면 대기열에 들어가게 된다.
    • 트랜잭션 범위가 넓어지면 커넥션을 오래 사용하게 되고, 이로 인해 커넥션 풀의 커넥션 수가 부족해지게 되어 대기열에 들어가게 된다.
    • 이로 인해 다른 요청들이 대기하게 되고, 이는 서비스의 응답 시간을 느리게 하게 된다.
  • 잠금 대기
    • 만약 트랜잭션에서 레코드의 잠금을 거는 경우, 넓어진 트랜잭션 범위만큼 점유하는 시간이 길어지게 된다.
    • 해당 레코드를 다른 트랜잭션에서 사용하려고 할 때, 다른 트랜잭션들이 락을 대기하게 되고, 이는 데드락이 발생할 확률을 높이게 된다.

위와 같은 이유로 많은 책이나 컬럼에서는 트랜잭션 범위를 최소화하라고 권장하고 있다.
특히, 내가 최근에 읽었던 Real MySQL 8.0 서적에서도 트랜잭션 범위를 최소화하라고 권장하고 있으며, 특히 네트워크 작업이 있는 요청은 반드시 트랜잭션에서 배제해야 한다고 말하고 있다.

이전에 개발한 결제 연동 시스템에서 하나의 트랜잭션에서 네트워크 요청을 포함한 많은 작업을 수행하고 있었다.
때문에 트랜잭션 범위가 넓어지게 되었는데, 대략적인 코드와 로직은 아래와 같다.
(자세한 로직 및 전체적인 플로우는 링크 참고)

public class OrderService {
// ...
@Transactional // 해당 메서드 전체에 걸친 트랜잭션 범위
public OrderConfirmResponse confirmOrder(OrderConfirmRequest orderConfirmRequest) {
// 1. OrderInfo + Product + User Fetch Join 조회(X-LOCK)
OrderInfo orderInfo =
this.getOrderInfoByOrderPessimisticLock(orderConfirmRequest.getOrderId());
// 2. 재고 감소
productService.reduceStock(orderInfo.getProduct().getId(), orderInfo.getQuantity());
// 3. TossPayment 측 결제 정보 조회(외부 API)
TossPaymentResponse paymentInfo = paymentService.getPaymentInfoByOrderId(
orderConfirmRequest.getOrderId()
);
// 4. 결제 정보 검증
orderInfo.validateInProgressOrder(paymentInfo, orderConfirmRequest);
// 5. TossPayment 측 결제 승인(외부 API)
TossPaymentResponse confirmPaymentResponse =
paymentService.confirmPayment(
TossConfirmRequest.createByOrderConfirmRequest(orderConfirmRequest)
);
// 6. 주문 확정 상태 변경
OrderInfo confirmedOrderInfo = orderInfo.confirmOrder(
confirmPaymentResponse,
orderConfirmRequest
);
return new OrderConfirmResponse(confirmedOrderInfo);
}
// ...
}

전체를 감싸고 있는 트랜잭션 범위

해당 로직을 그림으로 표현하면 위와 같으며, 문제점은 다음과 같다.

  1. 하나의 트랜잭션 범위에 포함된 모든 로직
    • 실질적으로 데이터베이스의 트랜잭션이 필요한 부분은 초록색으로 표시된 부분 뿐이지만 전체 로직을 트랜잭션 범위로 감싸고 있음
  2. 불필요한 테이블의 레코드까지 X-LOCK을 통해 조회
    • 재고 감소 동시성 처리를 위해 필요한 테이블은 Product 레코드 뿐이지만, OrderInfoUser의 레코드까지 잠금이 걸리게 됨
    • 여기서 얻은 LOCK을 로직이 끝나는 시점까지 유지하게 되어 다른 트랜잭션들이 대기하게 되어 데드락 발생 확률이 높아짐
  3. 외부 API 요청을 트랜잭션 범위 내에서 수행
    • 외부 API 호출은 네트워크 작업을 수반하여 시간적 범위가 길어질 수 있음
    • 그 시간만큼 트랜잭션을 유지하는 시간이 길어지게 되어 다른 트랜잭션들의 대기 시간 증가하여 전체 서비스 응답 시간 증가
    • 최악의 경우 외부 API에서 응답이 늦게 오거나 Time Out 되면 기하급수적으로 서비스 응답 시간 증가하게 됨

외부 API 응답이 늦은 경우 발생하는 문제

Section titled “외부 API 응답이 늦은 경우 발생하는 문제”

가장 문제가 될 수 있는 부분은 외부 API 응답이 늦게 오거나 Time Out 되는 경우인데, 결제 승인 요청 API 응답이 늦게 오는 경우를 시뮬레이션하여 확인해보았다.

// Mock Server의 승인 요청에 대한 응답 수신 시 3초 대기하도록 설정
@RestController
@RequiredArgsConstructor
public class MockController {
// ...
@PostMapping("/confirm")
public TossPaymentResponse confirmPayment(@RequestBody TossConfirmRequest tossConfirmRequest) {
// time out
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// ...
}
}

그리고 동시에 10개의 주문 승인 요청을 보내는 동시성 테스트를 수행해보니 다음과 같은 결과를 얻을 수 있었다.

Lock 해제를 대기하는 나머지 트랜잭션들

Lock을 가진 트랜잭션이 네트워크 지연을 기다리는 동안 다른 스레드들도 Lock 해제를 대기하는 것을 확인할 수 있었고,

동시에 10개의 주문 승인 요청 테스트 결과

테스트 수행 시간은 3초씩 대기하게 되어 10개 밖에 안 되는 요청이 30초가 넘게 걸리게 되었으며,
10개보다 조금 더 많은 개수인 20개로만 설정했을 땐 Time Out이 발생하여 마지막 몇 개의 요청은 실패하게 되었다.

트랜잭션 범위 내에 있는 외부 API 요청 배제

Section titled “트랜잭션 범위 내에 있는 외부 API 요청 배제”

우선 위의 문제를 해결하기 위해 네트워크에 영향을 받는 외부 API 요청을 트랜잭션 범위 밖으로 배제하고,
Database에 대한 작업만 트랜잭션 범위 내에서 수행하도록 변경하였다.

public class OrderService {
// ...
// @Transactional 어노테이션 제거하여 트랜잭션 X
public OrderConfirmResponse confirmOrder(OrderConfirmRequest orderConfirmRequest) {
// 1. OrderInfo + Product + User Fetch Join 조회(락 없이 단순 조회)
OrderInfo orderInfo = this.getOrderInfoByOrderId(orderConfirmRequest.getOrderId());
// 2. 재고 감소 및 Database Commit
productService.reduceStockWithCommit(
orderInfo.getProduct().getId(),
orderInfo.getQuantity()
);
// 3. TossPayment 측 결제 정보 조회(외부 API)
TossPaymentResponse paymentInfo = paymentService.getPaymentInfoByOrderId(
orderConfirmRequest.getOrderId()
);
// 4. 결제 정보 검증
orderInfo.validateInProgressOrder(paymentInfo, orderConfirmRequest);
// 5. TossPayment 측 결제 승인(외부 API)
TossPaymentResponse confirmPaymentResponse =
paymentService.confirmPayment(
TossConfirmRequest.createByOrderConfirmRequest(orderConfirmRequest)
);
// 6. 주문 확정 상태 변경
OrderInfo confirmedOrderInfo = orderInfo.confirmOrder(
confirmPaymentResponse,
orderConfirmRequest
);
orderInfoRepository.save(confirmedOrderInfo); // @Transactional이 없으므로 명시적으로 저장
// WARNING!! 2번 이후에 에러 발생 시, 2번에서 발생한 재고 감소 건이 롤백되지 않음
return new OrderConfirmResponse(confirmedOrderInfo);
}
// ...
}

위와 같이 변경을 한 덕분에, 목표로 한 외부 API 요청을 트랜잭션 범위 밖으로 배제할 수 있게 되었다.

외부 API 요청을 트랜잭션 범위 밖으로 배제

하지만 큰 문제가 있는데, 2번에서 재고 감소 이후 에러가 발생하게 되면 재고 감소 건이 롤백되지 않게 되어 결국 재고만 감소된 상태로 남게 된다.
이렇게 되면 하나의 요청이 원자성을 보장하지 못하게 되어, 결국 데이터의 불일치가 발생하게 된다.

로직 순서 변경을 통한 위험 범위 최소화

Section titled “로직 순서 변경을 통한 위험 범위 최소화”

다음으론 위와 같은 문제점을 최소화 위해 로직 순서를 변경하여, 에러 발생 위험 범위를 줄일 수 있게 변경하였다.

public class OrderService {
// ...
public OrderConfirmResponse confirmOrder(OrderConfirmRequest orderConfirmRequest) {
// 1. OrderInfo + Product + User Fetch Join 조회(락 없이 단순 조회)
OrderInfo orderInfo = this.getOrderInfoByOrderId(orderConfirmRequest.getOrderId());
// 2. TossPayment 측 결제 정보 조회(외부 API)
TossPaymentResponse paymentInfo = paymentService.getPaymentInfoByOrderId(
orderConfirmRequest.getOrderId()
);
// 3. 결제 정보 검증
orderInfo.validateInProgressOrder(paymentInfo, orderConfirmRequest);
// 4. 재고 감소 및 Database Commit
productService.reduceStockWithCommit(
orderInfo.getProduct().getId(),
orderInfo.getQuantity()
);
// 5. TossPayment 측 결제 승인(외부 API)
TossPaymentResponse confirmPaymentResponse =
paymentService.confirmPayment(
TossConfirmRequest.createByOrderConfirmRequest(orderConfirmRequest)
);
// 6. 주문 확정 상태 변경
OrderInfo confirmedOrderInfo = orderInfo.confirmOrder(
confirmPaymentResponse,
orderConfirmRequest
);
orderInfoRepository.save(confirmedOrderInfo);
// WARNING!! 4번 이후에 에러 발생 시, 2번에서 발생한 재고 감소 건이 롤백되지 않음
return new OrderConfirmResponse(confirmedOrderInfo);
}
// ...
}

TossPayment 측 결제 정보 조회결제 정보 검증재고 감소 및 Database Commit 수행 전으로 변경하였는데,
이와 같이 변경할 수 있는 이유는 아래와 같다.

  • TossPayment 측 결제 정보 조회: 주문 승인 전 검증을 위한 외부 API 요청으로, 재고 감소 및 데이터베이스 커밋 이전에 수행해도 무방
  • 결제 정보 검증: 마찬가지로 주문 승인 전 검증을 위한 로직이므로, 재고 감소 및 데이터베이스 커밋 이전에 수행해도 무방

두 개의 로직을 분리함으로써 에러 발생 범위를 최소화할 수 있게 되었다.

에러 발생 범위 최소화

하지만 여전히 에러 발생 시 재고 감소 건이 롤백되지 않는 근본적인 문제점은 해결되지 않았다.

보상 트랜잭션 명시적 추가를 통한 롤백

Section titled “보상 트랜잭션 명시적 추가를 통한 롤백”

근본적인 문제를 해결하기 위해 재고를 다시 증가시키는 보상 트랜잭션을 명시적으로 추가하였다.
여기서 결제 승인이 아닌 재고 감소를 먼저 수행하고, 보상 트랜잭션의 대상으로 재고 증가를 선정한 이유는 다음과 같다.

  • 재고 증감 작업은 서버에서 직접 제어가 가능하므로, 핸들링하는데 있어 비교적 더 안전함
  • 결제 승인 요청을 먼저 수행하는 경우, 승인에 대한 보상 트랜잭션으로 결제 취소라는 외부 API 요청을 수행해야 함
    • 결국 외부 API를 통한 작업이기 때문에, 네트워크 의존성이 더 커지면서 위험 요소가 증가함
    • 돈이 실제로 빠져나가고(승인 API) 다시 채워지게 되어(취소 API) 사용자 경험 측면에서 좋지 않음
public class OrderService {
// ...
public OrderConfirmResponse confirmOrder(OrderConfirmRequest orderConfirmRequest) {
// 1. OrderInfo + Product + User Fetch Join 조회(락 없이 단순 조회)
OrderInfo orderInfo = this.getOrderInfoByOrderId(orderConfirmRequest.getOrderId());
// 2. TossPayment 측 결제 정보 조회(외부 API)
TossPaymentResponse paymentInfo = paymentService.getPaymentInfoByOrderId(
orderConfirmRequest.getOrderId()
);
// 3. 결제 정보 검증
orderInfo.validateInProgressOrder(paymentInfo, orderConfirmRequest);
// 4. 재고 감소 및 Database Commit
productService.reduceStockWithCommit(
orderInfo.getProduct().getId(),
orderInfo.getQuantity()
);
// 5. TossPayment 측 결제 승인(외부 API) + 6. 주문 확정 상태 변경
OrderInfo confirmedOrderInfo = this.confirmPaymentAndOrderInfoWithStockRollback(
orderConfirmRequest,
orderInfo
);
return new OrderConfirmResponse(confirmedOrderInfo);
}
// 이후 로직 하나의 메서드로 추출
private OrderInfo confirmPaymentAndOrderInfoWithStockRollback(
OrderConfirmRequest orderConfirmRequest,
OrderInfo orderInfo
) {
try {
TossPaymentResponse confirmPaymentResponse =
paymentService.confirmPayment(
TossConfirmRequest.createByOrderConfirmRequest(orderConfirmRequest)
);
OrderInfo confirmedOrderInfo = orderInfo.confirmOrder(
confirmPaymentResponse,
orderConfirmRequest
);
orderInfoRepository.save(confirmedOrderInfo);
return confirmedOrderInfo;
} catch (Exception e) { // 에러 발생 시 롤백 수행
// 재고 증가 및 Database Commit
productService.increaseStockWithCommit(
orderInfo.getProduct().getId(),
orderInfo.getQuantity()
);
throw e;
}
}
// ...
}

4번 재고 감소 이후 에러가 발생할 수 있는 나머지 로직을 하나의 메서드로 추출하고,
해당 메서드 내에서 에러 발생 시 재고 증가 및 데이터베이스 커밋을 명시적으로 수행하도록 변경하였다.

보상 트랜잭션을 통한 롤백 수행

결과적으로 주문 승인 처리 흐름은 위와 같이 변경되었으며, 트랜잭션 범위도 최소화되었고, 에러 발생 시 원자성을 보장할 수 있게 되었다.

주문 승인 처리의 큰 로직 수정 없이 단순 순서 변경과 추가된 보상 트랜잭션을 통해 트랜잭션 범위 최소화하여 아래의 결과를 얻을 수 있었다.

  • 네트워크 지연 설정 X, 트랜잭션 범위 최소화 전/후 수행시간 비교(11s -> 6s)

트랜잭션 범위 최소화 전(네트워크 지연 설정 X) - 11s 트랜잭션 범위 최소화 후(네트워크 지연 설정 X) - 6s

트랜잭션 범위 최소화를 통해 레코드에 잠금이 걸리는 범위를 줄여 성능 향상을 가져올 수 있었으며,

  • 네트워크 지연 설정 3,000ms, 트랜잭션 범위 최소화 후 1,000개 요청 성공

트랜잭션 범위 최소화 후(네트워크 지연 설정 3,000ms) - 1m 40s

외부 API의 지연으로 잠금 대기 시간이 무한정 늘어나는 장애를 방지해 안정성을 향상 시켜, 1,000개의 요청을 모두 성공시킬 수 있었다.
이전에 20개의 요청을 보냈을 때 Time Out이 발생했던 것과는 달리, 네트워크 지연이 발생하더라도 이전보다 더 안정적으로 서비스를 기대할 수 있게 되었다.

에러 처리 및 복구 로직 적용에 따른 트랜잭션 범위 변화

Section titled “에러 처리 및 복구 로직 적용에 따른 트랜잭션 범위 변화”

결제 상태의 추가와 결제 복구 로직 추가로 인해 트랜잭션 범위 정책과 복구 로직도 함께 변경되었다.

에러 종류에 맞는 복구 로직 수행

결제 시작 처리를 먼저 수행하고, 재고 감소를 수행하며, 결제 검증 및 외부 API 요청을 수행하는 로직은 동일하게 유지하였다.
시작 처리를 먼저 데이터베이스에 반영하기 때문에 그 이후에 발생하는 예외 상황들에 대해 맞는 복구 로직을 각각 작성할 필요가 있었다.

  • 재시도 가능과 불가능한 에러로 구분
  • 재시도 가능한 에러는 UNKNOWN 상태로 변경하고, 재고는 그대로 유지
  • 재시도 불가능한 에러는 FAIL 상태로 변경하고, 재고를 복구
@Service
@RequiredArgsConstructor
public class PaymentConfirmServiceImpl implements PaymentConfirmService {
// ...
@Override
public PaymentConfirmResult confirm(PaymentConfirmCommand paymentConfirmCommand) {
// 1. 결제 이벤트 조회
PaymentEvent paymentEvent = paymentLoadUseCase.getPaymentEventByOrderId(
paymentConfirmCommand.getOrderId()
);
try {
// 2. 재고 차감 - 재고 부족 에러 발생 가능
orderedProductUseCase.decreaseStockForOrders(paymentEvent.getPaymentOrderList());
} catch (PaymentOrderedProductStockException e) { // 에러 1: 재고 부족 에러 발생
handleStockFailure(paymentEvent); // FAIL 처리(재고 중 일어난 에러로, 감소가 일어나지 않았으므로 복구 필요 없음)
throw PaymentTossConfirmException.of(PaymentErrorCode.ORDERED_PRODUCT_STOCK_NOT_ENOUGH);
}
try {
// 3. 결제 시작 처리 - 결제 시작 상태 검증 중 에러 발생 가능
paymentProcessorUseCase.executePayment(paymentEvent, paymentConfirmCommand.getPaymentKey());
// 4. 결제 검증 - 결제 검증 중 에러 발생 가능
// 5. 토스 결제 승인 요청 - 토스 측 재시도 가능/불가능한 에러 발생 가능
// 6. 결제 완료 처리
PaymentEvent completedPayment = processPayment(paymentEvent, paymentConfirmCommand);
// 7. 결제 완료 결과 반환
// ...
} catch (PaymentStatusException e) { // 에러 2: 이미 요청이 진행 된 건에 대한 요청
handleNonRetryableFailure(paymentEvent); // FAIL 처리 + 재고 복구
throw e;
} catch (PaymentTossRetryableException e) { // 에러 3: 토스 측 재시도 가능한 에러 발생
handleRetryableFailure(paymentEvent); // UNKNOWN 상태로 변경 + 재고 그대로 유지
throw PaymentTossConfirmException.of(PaymentErrorCode.TOSS_RETRYABLE_ERROR);
} catch (PaymentTossNonRetryableException e) { // 에러 4: 토스 측 재시도 불가능한 에러 발생
handleNonRetryableFailure(paymentEvent); // FAIL 처리 + 재고 복구
throw PaymentTossConfirmException.of(PaymentErrorCode.TOSS_NON_RETRYABLE_ERROR);
} catch (Exception e) { // 에러5: 결제 검증 중 에러 등 기타 에러 발생
handleUnknownException(paymentEvent); // FAIL 처리 + 재고 복구
throw e;
}
}
// ...
}