[Spring] 전시 티켓 환불 로직 개선기

전시 티켓 환불 로직 개선기 

안녕하세요.

 

저는 지난 분기에 티켓의 환불 로직을 개선하는 작업을 맡았습니다.

기존에는 관리자가 환불을 진행할 때, 동기 처리로 요청 중 하나라도 실패하면 환불이 모두 실패한 것처럼 보였습니다.

PG사에서 정상적으로 취소된 티켓도 환불 요청 상태로 남아있어 관리자가 수동으로 상태를 변경해야 했습니다. 

 

문제 해결을 위해 아래와 같이 개선 작업을 진행했습니다.

  • 환불 요청 서비스는 티켓들이 환불이 가능한지 유효성 검사만 진행하며 각 환불은 이벤트를 통해 비동기로 처리했습니다.
  • 환불이 진행될 때 레디슨을 활용하여 환불 고유 번호를 기준으로 분산락을 적용했습니다.
  • SSE와 Redis Pub/Sub을 활용하여 관리자가 환불에 대한 실시간 알림을 적용했습니다.

환불 로직을 개선한 경험을 공유드리고자 합니다.

 

환불 로직 개선

기존 비즈니스 로직

기존 로직에서는 환불 요청 유효성 검사 → PG사 취소 요청 → 취소 완료시 환불 및 아이템 상태 값 변경 → 알림 발송을 순차적으로 진행합니다. 환불 진행 중 n번째 PG 응답에서 "[2026] 취소금액이 미정산 금액보다 큽니다."라는 메세지와 함께 실패 응답을 받았을 때, 실제로 n-1 번째까지는 결제 취소 및 환불 알림이 발송되었지만 모든 환불 상태 값은 "환불 요청" 으로 남아있게 됩니다. 

 

실제로 7개의 환불을 진행할 때 100초 이상이 소요되었고, 기다리는 과정에서 다른 분이 같은 티켓에 대해 환불을 진행해 동시성 이슈로 7건 모두 환불되었지만 7건 모두 "환불 요청" 상태로 남아 문제가 된 케이스도 있습니다.

 

환불 비동기 처리 

위 문제를 개선하기 위해 환불 요청을 진행할 때 환불 요청건들에 대해서 유효성 검사만 진행한 후, 각각의 환불은 비동기 이벤트 처리하도록 변경했습니다.

 

 

@Transactional(readOnly = true)
public void approveRefund(RefundRequest request) {
	// ..
	List<Refund> refunds = refundRepository.findAllByIds(request.getRefundIds());
	refunds.forEach(refund -> validateRequestRefund(refund));

	for (Refund refund : refunds) {
		publisher.publishEvent(RefundEvent.create(refund, admin, request));
	}
}

 

@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void refund(final RefundEvent event) {
    refundService.refund(event.getRefund, event.getAdmin);
}

 

@Override
@DistributedLock(key = "#refund.id")
@Transactional(propagation = Propagation.REQUIRES_NEW, timeout = 7)
public void refund(Refund refund, Admin admin) {

    // ..

    final Bootpay bootpay = getBootpayWithValidateToken(product, productOrder, payment);

    // ..
    cancelPayment(bootpay, targetRefund, payment, productOrder, admin);
    sendProductRefundNotification(targetRefund, productOrder);
    
}

approveRefund 메소드에서는 요청들어온 환불건에 대해 환불이 가능한 티켓인지 유효성 검사만 진행하고 publisher 이벤트 호출을 통해 각각의 환불 건은 비동기로 진행됩니다.

 

이 각각의 refund 메소드에서는 동시에 같은 refund의 환불을 진행하여 생기는 동시성 이슈를 방지하기 위해 refund의 id를 기준으로 분산락을 적용합니다. PG 결제 취소 테스트를 진행했을 때 인터넷 연결에 따라 평균적으로 3~5초 이내의 작업이 진행됨을 확인했고, 트랜잭션의 타임아웃은 최악을 가정했을 때를 7초로 가정했습니다. 분산락의 대기 시간은 12초, 락의 유효 시간은 8초로 두었습니다.

 

refund 메소드는 부트페이 객체를 조회한 이후, cancelPayment 메소드를 호출해 결제 취소를 요청합니다. cancelPayment 메소드는 "전체 취소" 또는 "부분 취소"를 진행하며, 취소가 정상적으로 이루어졌다면 엔티티 데이터의 상태값을 변경하도록 개선했습니다. (회차가 존재하는 티켓에 대한 원복 처리와 알림은 다루지 않겠습니다.)

 

그러나 "각각의 결제 취소건의 응답을 받을 수 없다."라는 문제가 생겼습니다. 이전에는 동기로 처리했기 때문에 예매 티켓에서 이슈가 발생했는지 확인할 수 있었지만, approveRefund 메소드는 유효성 검사만 진행한 이후 응답을 내려주고, 각각의 환불은 비동기로 처리되어 응답을 받을 수 없었습니다.

 

SSE + Redis pub/sub 환불 응답 받기 

SSE, Server-Sent Event

각각의 결제 취소건의 응답을 받기 위해 SSE를 사용했습니다. SSE는 클라이언트가 HTTP 연결을 통해 서버로부터 자동 업데이트를 수신할 수 있도록 하는 서버 푸시 기술입니다. 웹소켓과 같은 양방향 통신 기술을 사용하지 않은 이유는, 환불 처리 결과는 서버에서 프론트로 보내는 푸시성 메세지로 양방향 통신이 필요하지 않으며, 또한 SSE는 브라우저에서 지원되기 때문에 프론트 공수가 크게 들지 않는것도 선택의 이유가 되었습니다.

 

환불 플로우

관리자가 로그인을 진행하면 SSE 연결 API를 호출하여 SSE 연결 생성 및 Redis 구독을 진행하게 됩니다. 로그아웃을 진행했을 때 SSE 연결을 해지할 수 있도록 unsubscribe API를 작성했습니다.

 

환불이 진행되면 성공 또는 실패에 대한 알림이 Redis subscribe를 통해 어드민에게 전달됩니다. 어드민 서버도 이중화가 예정되어 Redis pub/sub을 사용했습니다. 서버가 이중화되었을 경우엔 A 서버(8080)와 B 서버(8081)에 관리자가 중복 로그인이 되어있다고 가정한다면 A서버에서 환불한 응답 메세지를 B서버는 받을 수 없기 때문에 이를 고려하여 Redis를 사용했습니다.

 

@Service
@RequiredArgsConstructor
public class NotificationServiceImpl implements NotificationService {

    private final EmitterService emitterService;
    private final RedisMessageService redisMessageService;

    @Override
    public SseEmitter subscribe(LoginUserInfo adminInfo, String lastEventId) {

        // ..
        
        String emitterKey = emitterService.createEmitterKey(adminInfo.getId());
        SseEmitter emitter = emitterService.createEmitter(emitterKey);

        emitterService.send(MsgFormat.SUBSCRIBE, emitterKey, emitter);
        redisMessageService.subscribe(getTopic(adminId));

        emitter.onCompletion(() -> emitterService.deleteEmitter(emitterKey));
        emitter.onError(e -> emitter.complete());
        emitter.onTimeout(() -> {
            emitter.complete();
            emitterService.deleteEmitter(emitterKey);
            redisMessageService.removeSubscribe(String.valueOf(adminId));
        });
        
        // ..

        return emitter;
    }
    
    //..
    
}

 

 

@Service
@RequiredArgsConstructor
public class RedisMessageServiceImpl implements RedisMessageService {

    private final RedisMessageListenerContainer container;
    private final RedisSubscriber subscriber;
    private final RedisTemplate<String, Object> redisTemplate;

    @Override
    public void subscribe(String channel) {
        container.addMessageListener(subscriber, ChannelTopic.of(getChannelName(channel)));
    }

    @Override
    public void publish(String channel, NotificationDto notification) {
        redisTemplate.convertAndSend(getChannelName(channel), notification);
    }

    @Override
    public void removeSubscribe(String channel) {
        container.removeMessageListener(subscriber, ChannelTopic.of(getChannelName(channel)));
    }

    // ..

}

 

@Slf4j
@Component
@RequiredArgsConstructor
public class RedisSubscriber implements MessageListener {

    private final ObjectMapper objectMapper;
    private final EmitterService emitterService;

    @Override
    public void onMessage(Message message, byte[] pattern) {
        try {
            String channel = getChannel(message.getChannel());

            NotificationDto notification = objectMapper.readValue(message.getBody(), NotificationDto.class);

            // 해당 채널에 연결된 클라이언트에게 알림을 전송
            emitterService.sendNotificationToClient(channel, notification);
        } catch (IOException e) {
            log.error("IOException occurred while processing the message.", e);
        }
    }
    
}

subscribe 메소드를 통해 SSE 연결을 진행할 수 있습니다. 환불에 대한 알림이 publish 메소드를 통해 발행되면 해당 채널을 구독하고 있는 관리자들은 RedisSubscriber의 onMessage 메소드를 통해 알림을 받을 수 있게 됩니다.

 


지금까지 티켓의 환불 로직을 개선하는 작업 과정을 적어보았습니다. 

 

이번 개선 작업을 통해 기존에 발생하던 환불 상태 및 동시성 이슈를 해결하고, 비동기 처리를 통해 응답 시간 개선과 알림 처리 등 서비스를 발전시킬 수 있었습니다. 이번 개선 작업으로 얻은 경험은 개발자로서 성장하기 위해 큰 도움이 된 것 같습니다.

 

앞으로도 사용자가 사용할 때 더 사용하고 싶은 서비스를 만들기 위해 최선을 다하겠습니다. 

감사합니다.