PortOne V2 기반 결제 시스템 설계 및 검증 로직 개선기 - Workaway

chaean·2025년 5월 13일

Workaway

목록 보기
5/11
post-thumbnail

PortOne V2 기반 결제 시스템 설계 및 검증 로직 구현기의 개선기입니다.

✅ 도입 배경

결제 시스템을 설계 구현하는 과정에서 다음 세 가지 문제를 명확하게 인식하게 되었습니다.

1. 결제와 같은 민감한 작업의 동시성 제어 (분산 락)
2. 로그 저장 등의 부가 작업은 비동기 처리 (비즈니스 로직 영향 제거)
3. 결제 검증 로직을 단계별로 트랜잭션 분리 (롤백 범위 최소화)

기존에는 모든 작업이 하나의 트랜잭션에 묶여 있어, 어느 하나라도 실패하면 전체가 롤백되는 구조였습니다.
이를 개선하고자 아키텍처와 코드 구조를 전면 리팩토링했습니다.


⚠️ 문제점

1. 동시 결제 문제

물론 실제로 발생 확률은 낮지만, 이런 위험은 사전에 차단되어야 합니다.

  • 동일 예약 ID에 대해 두 사용자가 동시에 결제 요청 시, 중복 결제 발생 가능

2. 로그 저장 지연

  • 로그 저장 로직이 동기 처리되어 API 응답 시간이 지연됨
  • 로그 저장 중 예외 발생 시, 결제 로직 전체가 롤백되는 문제 발생

3. 트랜잭션 커플링 문제

  • 결제 정보 저장, 검증, 상태 업데이트, 로그 저장이 하나의 트랜잭션에 묶여 있음
  • 한 부분에서 예외 발생 시, 전체가 롤백되어 의도한 상태가 저장되지 않음

🛠️ 해결 전략

1. Redisson 기반 분산락

MySQL 기반 락은 DB 부하가 크고, Redis를 직접 사용할 경우 락 구현, 타임아웃, 재시도 등 많은 로직을 수작업으로 작성해야 합니다.

Redisson은 Lock 인터페이스를 지원하며, 이를 AOP로 감싸 어노테이션 기반 분산락을 손쉽게 구현했습니다.

도움많이받았습니다! - 마켓컬리 기술블로그
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DistributedLock {
    String key();
    long waitTime() default 500L;
    long leaseTime() default 5000L;
}
@Aspect
@Component
@RequiredArgsConstructor
public class DistributedLockAspect {

    private final RedissonClient redissonClient;
    private final ExpressionParser parser = new SpelExpressionParser();

    @Around("@annotation(경로/DistributedLock)")
    public Object redissonLock(ProceedingJoinPoint joinPoint) throws Throwable {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();

        DistributedLock annotation = method.getAnnotation(DistributedLock.class);
        String lockKey = parseKey(annotation.key(), method, joinPoint.getArgs(), signature);
        String fullKey = "lock:" + lockKey;

        RLock rLock = redissonClient.getLock(fullKey);

        boolean acquired = false;

        try {
            acquired = rLock.tryLock(annotation.waitTime(), annotation.leaseTime(), TimeUnit.MILLISECONDS);
            if (!acquired) {
                log.warn("동시 요청 감지. [Key = {}]", fullKey);
                throw new BusinessException(ErrorCode.LOCK_CONFLICT, "동시 요청 key = " + fullKey);
            }

            return joinPoint.proceed();
        } finally {
            if (acquired && rLock.isHeldByCurrentThread()) {
                rLock.unlock();
            }
        }
    }


    private String parseKey(String keySpEL, Method method, Object[] args, MethodSignature signature) {
        if (!StringUtils.hasText(keySpEL)) {
            throw new BusinessException(ErrorCode.LOCK_CONFLICT, "SpEL 파싱 결과가 null 또는 공백입니다.");
        }

        EvaluationContext context = new StandardEvaluationContext();
        String[] paramNames = signature.getParameterNames();

        for (int i = 0; i < paramNames.length; i++) {
            context.setVariable(paramNames[i], args[i]);
        }

        return parser.parseExpression(keySpEL).getValue(context, String.class);
    }
}

사용 방식은 직관적입니다!

@DistributedLock(key = "#reservationId")
@Override
public void 결제검증하기(...) {
	// 락 획득 후 로직 수행
}	

락 획득 실패 시 → 바로 예외 반환
락 획득 성공 시 → 로직 수행


2. 결제 검증 트랜잭션 분리 전략

기존 방식에서는 결제에 실패한다면 try/catch문을 통해 결제 상태를 실패로 변경하고, 결제DB에 저장하려고 하였지만, 기존 결제의 영속성 문제 등으로 인해 DB가 Lock에 걸리는 문제가 발생하였고, 이를 해결하기 위해 finally 블록에서 데이터를 수정해보려고했지만 하나의 트랜잭션으로 묶여있어 롤백되는 문제가 발생했습니다.
이를 해결하기 위해 3단계로 분리하여 각각의 책임과 롤백 범위를 명확하게 나눴습니다

🔸 1단계. 결제 정보 저장 (미검증 상태)
@Transactional(propagation = Propagation.REQUIRES_NEW)
    public Payment 결제_미검증_저장(...)

PortOne API 응답을 기반으로 결제 정보를 UNVERIFIED 상태로 저장합니다.
이 단계는 반드시 성공해야 하며, 검증 결과와 무관하게 저장됩니다.

🔸 2단계. 결제 정보 검증
@DistributedLock(key = "#reservationId")
@Override
public void 결제검증하기(...) {
    try {
    	// 예약 상태, 금액, 중복 여부, 사용자 검증, TTL 등 확인
        ...
        
        // 검증 성공 시
        결제상태변경_성공(...);
        예약상태변경_실패(...);
    } catch (BusinessException e) {
        // 검증 실패 시
        결제상태변경_실패(...);
        예약상태변경_실패(...);
        
        throw e;
    }
}	
  • 분산락으로 동시성 제어
  • 예약 상태, 결제 금액, 중복 여부 등을 검증
  • 검증 성공 시 → 결제 및 예약 상태를 각각 PAID, CONFIRMED로 변경
  • 검증 실패 시 → 상태를 FAILED로 변경하고 예외 전파
3단계. 결제/예약 상태 업데이트
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void 결제상태변경_성공(...) {
    // 결제 상태 변경
    ...
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void 예약상태변경_실패(...) {
	// 예약 상태 변경
    ...
}

이 단계에서 모든 상태 변경은 서로 독립된 트랜잭션으로 수행되며,
검증 실패 시에도 저장된 UNVERIFIED → FAILED 상태는 반드시 기록됩니다.

finally 블록에서 상태를 저장해도 기존 트랜잭션이 rollback-only 상태였다면 저장되지 않습니다.
그래서 REQUIRES_NEW를 사용해 rollback 영향을 받지 않는 별도 트랜잭션을 만들어야 했습니다.


💡 위와 같은 3가지 단계를 통해 검증 실패 시 전체가 롤백되는 문제를 해결하고,
이후 재시도, 고객 응대 등 에도 필요한 상태 정보가 DB에 남게 됩니다.

3. 로그 저장 - Event 기반 비동기 처리

<eventPublisher.publishEvent(로그_저장());
  • 로그 저장은 비즈니스 핵심 로직과 분리하기 위해 별도 쓰레드에서 비동기로 처리합니다.
  • 실패하더라도 비즈니스 흐름에는 영향이 없습니다.

✅ 개선 효과 및 결론

항목개선 전개선 후
동시 결제 처리중복 결제 발생 가능@DistributedLock으로 선점 제어 및 안전한 결제 처리 가능
트랜잭션 구조하나의 트랜잭션에 모든 로직이 포함되어 실패 시 전체 롤백단계별 트랜잭션 분리
검증 실패 처리상태 없이 전체 롤백 → 추적 어려움상태를 FAILED로 업데이트하여 추적 및 재시도 가능
로그 저장 방식동기 저장 → 응답 지연, 실패 시 전체 트랜잭션 영향Spring Event 기반 비동기 저장 → 성능 개선 및 예외 격리
시스템 회복력예외 발생 시 전체 흐름 실패핵심 로직과 로그 저장 분리로 오류 전파 최소화

💬 마무리

이번 개선을 통해 결제 시스템의 안정성과 확장성을 동시에 확보할 수 있었습니다.

앞으로도 중요한 비즈니스 흐름일수록 단계적 트랜잭션 + 비동기 이벤트 기반 처리를 적극적으로 활용할 예정입니다.

profile
백엔드 개발자

0개의 댓글