[TIL] Spring Events, @Async, Redis, STOMP — #148·#149에서 배운 백엔드 포인트

Spring Events(@TransactionalEventListener, AFTER_COMMIT), @Async와 LazyInitializationException, Redis ZSET·Lua·TOCTOU, STOMP WebSocket 사용자별 전송, 스케줄러 설계 등 #148·#149 구현에서 정리한 백엔드 포인트입니다.

📝 TIL (Today I Learned)
트랜잭션 경계를 이해하고, Redis에서 원자성을 보장하며, WebSocket 세션 상태를 고려한 분산 환경의 실시간 알림을 다루면서 정리한 내용이다.


들어가며

이번에 의도한 구현은 AI 통화 매칭·알림 흐름을 다룬다. DB 저장과 WebSocket 알림, Redis 큐, 스케줄러가 한꺼번에 엮이면서 트랜잭션 타이밍, 비동기 스레드와 영속성 컨텍스트, Redis 원자성, 분산 TOCTOU 같은 백엔드에서 자주 만나는 주제가 그대로 나왔다. 이번 TIL은 그때 정리한 내용을, “백엔드 개발자가 알아두면 좋은 것” 위주로 풀어 쓴 것이다.


1. Spring Events — 트랜잭션 커밋 이후에만 이벤트 처리

왜 중요한가
DB에 저장한 직후 WebSocket으로 “AI 통화가 시작됐다”고 알리면, 클라이언트가 바로 상태를 조회할 때 아직 커밋되지 않아 데이터가 없을 수 있다. 그래서 “트랜잭션이 완전히 커밋된 뒤에만 이벤트를 처리한다”는 게 중요하다.

Spring에서는 @TransactionalEventListener(phase = AFTER_COMMIT) 으로 이 타이밍을 보장한다. 일반 @EventListener는 트랜잭션과 무관하게 이벤트가 발행되자마자 실행되지만, @TransactionalEventListener트랜잭션의 특정 단계(커밋 후) 에만 리스너를 실행한다.

@Async("taskExecutor")
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleAiCallStarted(AiCallStartedEvent event) { ... }
  • AFTER_COMMIT: DB 커밋이 끝난 다음에만 실행되므로, 프론트가 상태를 조회해도 데이터가 이미 반영된 상태다.
  • @Async: 이벤트 처리 자체를 비동기로 돌리면, 응답 속도와 트랜잭션 점유 시간을 줄일 수 있다. 단, @Async 메서드는 별도 스레드에서 돌기 때문에 영속성 컨텍스트가 없다는 점은 다음 섹션에서 다룬다.

함께 보면 좋은 것
ApplicationEventPublisher로 이벤트 발행, @EventListener@TransactionalEventListener의 차이, fallbackExecution = true 옵션(트랜잭션 없이 호출됐을 때도 실행할지 여부).


2. @Async와 LazyInitializationException

왜 중요한가
@Async 메서드는 호출 스레드와 다른 스레드에서 실행된다. 그 스레드에는 영속성 컨텍스트가 없고, 트랜잭션도 없을 수 있다. 이때 LAZY 로딩된 연관 엔티티(예: AiCall.getUser())를 접근하면 LazyInitializationException 이 발생한다. “세션이 없다”는 뜻이다.

백엔드 개발자 입장에서는, 비동기 메서드 안에서 엔티티를 다룰 때는 “이 스레드에 트랜잭션이 있는가?” 를 항상 생각해야 한다. 없으면 LAZY는 풀리지 않는다.

@Transactional(readOnly = true)  // ← 없으면 aiCall.getUser()에서 LazyInitializationException
public void startAiCall(Long aiCallId) {
    AiCall aiCall = aiCallRepository.findById(aiCallId)...;
    webSocketEventService.sendAiCallStartNotification(aiCall.getUser().getId(), ...);
}

핵심
비동기 컨텍스트에서 LAZY 관계를 사용할 때는, 그 메서드에 새 트랜잭션을 열어 주어야 한다. @Transactional(readOnly = true) 로 읽기 전용 트랜잭션만 있어도, 해당 스레드에서 영속성 컨텍스트가 생기므로 LAZY가 정상적으로 초기화된다.


3. Redis ZSET — score·jitter·목적별 키 분리

ZSET(Sorted Set)은 멤버–점수(score) 쌍으로 순서를 유지하는 자료구조다. 백엔드에서는 “대기 큐를 시간 순으로 관리한다”, “N초 이상 대기한 사용자만 조회한다” 같은 요구에 자주 쓴다.

이번 구현에서는 같은 큐를 두 가지 목적으로 쓰기 위해 ZSET을 두 개로 나눴다.

ZSET 키score목적
queue:{categoryId}timestamp + random*1000 (jitter)매칭 순서(fairness)
queue:{categoryId}:tstimestamp (순수)장기 대기 감지용

jitter가 필요한 이유
동시에 많은 사용자가 들어오면, 같은 초 단위 timestamp로 score가 겹친다. 그러면 순서가 거의 결정론적으로 고정되어, 특정 사용자만 유리해질 수 있다. 여기에 작은 랜덤 오프셋(jitter) 을 더하면 순서가 더 고르게 분산된다.
반대로, “60초 이상 대기한 사용자”처럼 실제 대기 시간으로 조회하려면 score가 순수 timestamp여야 한다. jitter를 넣은 score로는 “언제 들어왔는지”를 정확히 역산할 수 없으므로, 목적에 맞는 별도 ZSET (queue:{categoryId}:ts)을 두고 순수 timestamp만 넣었다.

-- 60초 이상 대기 사용자 조회 (순수 timestamp ZSET 사용)
ZRANGEBYSCORE queue:{id}:ts 0 {now_ms - 60000}

백엔드 포인트
“순서 공정성”과 “시간 기반 쿼리”를 한 ZSET으로 동시에 만족시키기 어렵다면, 역할별로 키를 나누는 것이 난이도와 유지보수에 유리하다.


4. Redis 원자성 — check-then-set vs Lua

왜 중요한가
Redis는 단일 스레드로 명령을 처리하지만, 여러 명령을 순서대로 보내면 그 사이에 다른 클라이언트의 명령이 끼어들 수 있다. 그래서 “키가 없을 때만 설정” 같은 조건부 쓰기는 반드시 한 번에 처리해야 한다.

비원자적 패턴 (문제)
hasKey로 확인한 뒤 set을 하면, 그 사이에 다른 스레드가 같은 키를 세팅할 수 있다. 분산 락이나 “한 번만 보내기” 같은 요구에는 부적합하다.

Boolean exists = redisTemplate.hasKey("ai-call-offer:" + userId);
redisTemplate.opsForValue().set("ai-call-offer:" + userId, "1", Duration.ofSeconds(ttl));

원자적 패턴 (해결)
Redis의 SET NX(Not eXists) 의미를 쓰면, “없을 때만 설정”을 한 번에 처리할 수 있다. Spring에서는 setIfAbsent가 이에 해당한다.

Boolean set = redisTemplate.opsForValue()
    .setIfAbsent("ai-call-offer:" + userId, "1", Duration.ofSeconds(ttl));

여러 키를 한 번에 다룰 때
한 개의 키가 아니라 “큐에 넣고 + 사용자별 키 세팅하고 + 타임스탬프 ZSET에 넣고”처럼 여러 연산을 하나의 단위로 실행해야 할 때는 Lua 스크립트로 묶는 것이 안전하다. Redis는 Lua 스크립트를 원자적으로 실행하므로, 스크립트 안에서는 다른 명령이 끼어들지 않는다.

redis.call('ZADD', queueKey, score, userId)
redis.call('SETEX', userQueueKey, ttlSeconds, userQueueValue)
redis.call('ZADD', queueTsKey, tsScore, userId)

함께 보면 좋은 것
Redis 트랜잭션(MULTI/EXEC)과 Lua 스크립트의 차이, Spring의 DefaultRedisScript 사용법.


5. 분산 환경에서 TOCTOU와 Try-Mark-Then-Send

TOCTOU(Time-Of-Check-Time-Of-Use)는 “확인한 시점”과 “사용한 시점” 사이에 다른 스레드나 서버가 끼어들어 상태가 바뀌는 문제다. 스케줄러가 여러 인스턴스에서 돌아가는 환경에서는, “한 번만 처리해야 하는 작업”을 두 인스턴스가 동시에 수행하지 않도록 원자적 마킹이 필요하다.

이번 구현에서는 Try-Mark-Then-Send with Rollback 패턴을 썼다.

// (1) 원자적 마킹 — 다른 인스턴스와 경쟁해도 한 번만 true
if (!redisMatchingQueueService.tryMarkAiCallOfferSent(userId, ttl)) {
    continue;
}

// (2) 카테고리 검증 실패 시 마크 롤백
if (!queueStatusInfo.categoryId().equals(category.getId())) {
    redisMatchingQueueService.deleteAiCallOfferMark(userId);
    continue;
}

// (3) 전송 실패 시에도 롤백 — 다음 주기에 재시도 가능
boolean sent = webSocketEventService.sendAiCallOfferNotification(...);
if (!sent) {
    redisMatchingQueueService.deleteAiCallOfferMark(userId);
}
  • Try: Redis에서 “이 사용자에게 아직 제안을 보내지 않았다”는 조건으로 원자적으로 마킹한다(setIfAbsent 등).
  • Mark: 마킹에 성공한 인스턴스만 실제 전송을 수행한다.
  • Rollback: 이후 검증(카테고리 일치 등)이나 전송이 실패하면, 마크를 지워서 다음 스케줄 주기에 다시 시도할 수 있게 한다.

백엔드 개발자에게 중요한 점은, 분산 환경에서는 “한 번만”을 보장하려면 반드시 Redis 같은 외부 저장소에서 원자적 연산으로 마킹하고, 실패 경로에서는 그 마크를 되돌리는 설계를 명시적으로 하는 것이다.


6. STOMP WebSocket — 사용자별 메시지와 세션 확인

STOMP를 쓰면 “특정 사용자에게만” 메시지를 보내는 destination을 쉽게 쓸 수 있다. Spring에서는 SimpMessagingTemplate.convertAndSendToUser 로 사용자별 큐에 보낸다.

messagingTemplate.convertAndSendToUser(
    String.valueOf(userId),
    "/queue/ai-call",
    response
);
// 실제 STOMP destination: /user/{userId}/queue/ai-call

세션 사전 확인
코드 리뷰를 통해, “실제로 해당 사용자가 WebSocket에 붙어 있는지”를 먼저 확인하고, 없으면 전송을 시도하지 않도록 바꿨다. 불필요한 메시지 생성과 로직을 줄일 수 있다.

SimpUser user = userRegistry.getUser(String.valueOf(userId));
if (user == null || user.getSessions().isEmpty()) {
    return false;
}

백엔드 포인트
실시간 알림을 보낼 때는 “메시지를 만들기 전에 대상이 현재 접속 중인지”를 한 번 확인하는 패턴이 유용하다. STOMP에서는 SimpUserRegistry로 현재 구독·세션 정보를 조회할 수 있다.


7. 스케줄러 설계 — 기능별 설정 분리

여러 스케줄 작업이 있을 때, 공용 지연 값 하나를 모든 작업에 쓰면, 한 기능만 조정하려 해도 다른 기능에 영향을 준다. 그래서 기능별로 설정을 나누는 것이 운영과 튜닝에 유리하다.

// 나쁜 예: 매칭용 delay를 AI 통화 제안에도 그대로 사용
@Scheduled(fixedDelayString = "#{@matchingSchedulerProperties.matchingDelay}")

// 좋은 예: AI 통화 제안 전용 delay — 독립적으로 조정 가능
@Scheduled(fixedDelayString = "#{@matchingSchedulerProperties.aiCallOfferDelay}")

@ConfigurationProperties로 묶고, @Min 등으로 검증을 두면 잘못된 값은 기동 시점에 걸러낼 수 있다. 백엔드에서는 “스케줄 주기”를 단순한 숫자가 아니라 의미 단위(기능 단위) 로 나누어 두는 습관이 중요하다.


8. Dead Code 탐지와 코드 리뷰 대응

Dead code
FIND_MATCHES 라는 Lua 스크립트가 정의만 되어 있고, 프로젝트 어디에서도 호출되지 않는 상태였다. 비슷한 이름의 FIND_MATCH_CANDIDATES가 실제로 쓰이고 있어서, 이름만 보고 착각하기 쉬운 부분이었다. 이런 경우 코드베이스 전체에서 참조를 검색해 보는 것이 안전하다.

grep -rn "FIND_MATCHES" src/ --include="*.java"
# 선언 파일 1곳만 나오면 → 사용처 없음, 삭제 검토 가능

코드 리뷰 대응
이번 세션에서는 3라운드에 걸쳐 9개 정도의 리뷰 포인트를 반영했다. 공통으로 적용한 원칙은 다음과 같다.

  1. 먼저 현재 코드로 검증 — 리뷰 내용이 맞는지 코드/동작을 확인한 뒤 수정한다.
  2. 영향 범위 파악 — Lua 스크립트 시그니처나 키 구조를 바꾸면, 호출하는 Java 쪽의 keys/args 등도 함께 수정해야 한다.
  3. 원자성 우선 — 분산 환경에서 여러 상태를 바꿀 때는, 가능한 한 Redis/Lua나 트랜잭션으로 원자적으로 처리한다.

정리

이번 개발을 통해 정리한 내용을 한 문장으로 요약하면 다음과 같다.

  • 트랜잭션 경계: 이벤트는 AFTER_COMMIT 이후에, 비동기 메서드에서는 새 트랜잭션으로 LAZY를 해소한다.
  • Redis: ZSET은 목적별로 키를 나누고, 조건부 쓰기는 setIfAbsent나 Lua로 원자적으로 처리한다.
  • 분산: TOCTOU를 피하기 위해 Try-Mark-Then-Send와 롤백을 명시적으로 설계한다.
  • 실시간 알림: STOMP에서는 사용자별 destination과 세션 존재 여부 확인을 함께 고려한다.
  • 운영: 스케줄러는 기능별 설정으로 나누고, dead code와 이름이 비슷한 상수는 사용처를 한 번씩 검색해 본다.

이렇게 정리해 두면, 비슷한 “실시간 알림 + DB + Redis + 스케줄러” 구조를 다룰 때 다시 참고하기 좋다.