PortOne V2 기반 결제 시스템 설계 및 검증 로직 구현기의 개선기입니다.
결제 시스템을 설계 구현하는 과정에서 다음 세 가지 문제를 명확하게 인식하게 되었습니다.
1. 결제와 같은 민감한 작업의 동시성 제어 (분산 락)
2. 로그 저장 등의 부가 작업은 비동기 처리 (비즈니스 로직 영향 제거)
3. 결제 검증 로직을 단계별로 트랜잭션 분리 (롤백 범위 최소화)
기존에는 모든 작업이 하나의 트랜잭션에 묶여 있어, 어느 하나라도 실패하면 전체가 롤백되는 구조였습니다.
이를 개선하고자 아키텍처와 코드 구조를 전면 리팩토링했습니다.
물론 실제로 발생 확률은 낮지만, 이런 위험은 사전에 차단되어야 합니다.
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 결제검증하기(...) {
// 락 획득 후 로직 수행
}
락 획득 실패 시 → 바로 예외 반환
락 획득 성공 시 → 로직 수행
기존 방식에서는 결제에 실패한다면 try/catch문을 통해 결제 상태를 실패로 변경하고, 결제DB에 저장하려고 하였지만, 기존 결제의 영속성 문제 등으로 인해 DB가 Lock에 걸리는 문제가 발생하였고, 이를 해결하기 위해 finally 블록에서 데이터를 수정해보려고했지만 하나의 트랜잭션으로 묶여있어 롤백되는 문제가 발생했습니다.
이를 해결하기 위해 3단계로 분리하여 각각의 책임과 롤백 범위를 명확하게 나눴습니다
@Transactional(propagation = Propagation.REQUIRES_NEW)
public Payment 결제_미검증_저장(...)
PortOne API 응답을 기반으로 결제 정보를 UNVERIFIED 상태로 저장합니다.
이 단계는 반드시 성공해야 하며, 검증 결과와 무관하게 저장됩니다.
@DistributedLock(key = "#reservationId")
@Override
public void 결제검증하기(...) {
try {
// 예약 상태, 금액, 중복 여부, 사용자 검증, TTL 등 확인
...
// 검증 성공 시
결제상태변경_성공(...);
예약상태변경_실패(...);
} catch (BusinessException e) {
// 검증 실패 시
결제상태변경_실패(...);
예약상태변경_실패(...);
throw e;
}
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void 결제상태변경_성공(...) {
// 결제 상태 변경
...
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void 예약상태변경_실패(...) {
// 예약 상태 변경
...
}
이 단계에서 모든 상태 변경은 서로 독립된 트랜잭션으로 수행되며,
검증 실패 시에도 저장된 UNVERIFIED → FAILED 상태는 반드시 기록됩니다.
finally 블록에서 상태를 저장해도 기존 트랜잭션이 rollback-only 상태였다면 저장되지 않습니다.
그래서 REQUIRES_NEW를 사용해 rollback 영향을 받지 않는 별도 트랜잭션을 만들어야 했습니다.
<eventPublisher.publishEvent(로그_저장());
| 항목 | 개선 전 | 개선 후 |
|---|---|---|
| 동시 결제 처리 | 중복 결제 발생 가능 | @DistributedLock으로 선점 제어 및 안전한 결제 처리 가능 |
| 트랜잭션 구조 | 하나의 트랜잭션에 모든 로직이 포함되어 실패 시 전체 롤백 | 단계별 트랜잭션 분리 |
| 검증 실패 처리 | 상태 없이 전체 롤백 → 추적 어려움 | 상태를 FAILED로 업데이트하여 추적 및 재시도 가능 |
| 로그 저장 방식 | 동기 저장 → 응답 지연, 실패 시 전체 트랜잭션 영향 | Spring Event 기반 비동기 저장 → 성능 개선 및 예외 격리 |
| 시스템 회복력 | 예외 발생 시 전체 흐름 실패 | 핵심 로직과 로그 저장 분리로 오류 전파 최소화 |
이번 개선을 통해 결제 시스템의 안정성과 확장성을 동시에 확보할 수 있었습니다.
앞으로도 중요한 비즈니스 흐름일수록 단계적 트랜잭션 + 비동기 이벤트 기반 처리를 적극적으로 활용할 예정입니다.