여러가지 이야기

[Spring] Lock 동시성 해결 중 트랜잭션의 관리, Transaction 원리 본문

study/Spring

[Spring] Lock 동시성 해결 중 트랜잭션의 관리, Transaction 원리

jimddong 2026. 2. 11. 01:52

2026.01.23 - [study/Spring] - 객체 중복 생성 방지를 위한 Redis 분산 Lock 도입

 

객체 중복 생성 방지를 위한 Redis 분산 Lock 도입

최근 진행 중인 프로젝트에서, AI를 활용하여 사용자가 원하는 주제의 요약글과 퀴즈를 생성하는 기능을 개발하는 역할을 맡았다.사용자는 원하는 목표 기간 동안 최대 하루 한 번씩 생성된 요

9oongoguma.tistory.com

 

문제 발생

이전 글에서 하루에 최대 하나만 생성되어야 할 요약글 객체가, 해당 생성 API가 여러 번 호출되며 중복되는 문제가 있었다. 이에 따라 Lock 로직을 도입하였다.

// DistributedLockFacade.java
public <T> Optional<T> tryExecuteWithLock(String lockKey, long waitTime, long leaseTime, TimeUnit timeUnit, Supplier<T> action) {
    RLock lock = redissonClient.getLock(lockKey);
    try {
        // 락 획득 시도
        if (!lock.tryLock(waitTime, leaseTime, timeUnit)) {
            log.warn("Failed to acquire lock: {}", lockKey);
            return Optional.empty();
        }
        try {
            return Optional.ofNullable(action.get());
        } finally {
            // 락 해제
            if (lock.isLocked() && lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    } catch (InterruptedException e) {
        // 예외 처리
        Thread.currentThread().interrupt();
        throw new BaseException(CommonResponseCode.INTERNAL_SERVER_ERROR);
    }
}

 

 

요약글뿐만 아니라 퀴즈 세트도 사용자에게 하루에 최대 하나 생성되어야하므로, 퀴즈에도 Redis 분산 Lock을 도입하려 했다. 하지만 코드를 수정하여 실제 운영 서버에 반영하고, 서버 로그를 확인해보니 다음과 같은 에러가 발생했다.

2026-01-19T19:57:04.478+09:00  WARN 1 --- [Project1] [nio-8080-exec-9] i.s.t.g.component.DistributedLockFacade  : Failed to acquire lock: lock:category_document:generation:1526:2026-01-19
2026-01-19T19:57:04.482+09:00 ERROR 1 --- [Project1] [nio-8080-exec-9] i.s.t.g.e.GlobalExceptionHandler         : BaseException: 
.
.
.
im.project1.global.exception.BaseException: 서버 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.
	at im.project1.global.component.DistributedLockFacade.executeWithLock(DistributedLockFacade.java:38) ~[!/:0.0.1-SNAPSHOT]
	at im.project1.domains.categoryDocument.application.usecase.CategoryDocumentUseCase.createNewDailyDocument(CategoryDocumentUseCase.java:153) ~[!/:0.0.1-SNAPSHOT]
.
.
.
2026-01-19T19:58:42.361+09:00 ERROR 1 --- [Project1] [nio-8080-exec-6] i.s.t.g.e.GlobalExceptionHandler         : Exception: 

org.springframework.transaction.UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only
	at org.springframework.transaction.support.AbstractPlatformTransactionManager.processCommit(AbstractPlatformTransactionManager.java:804) ~[spring-tx-6.2.14.jar!/:6.2.14]
	at

 

하나만 생겨야할 요약글이 중복 저장되고, Lock이 제대로 지켜지지 않는 것이다.

 

왜 이런 문제가 발생했을까?

 

해결 과정

참고로 현재 프로젝트에서는 GitHub Flow에 따라 개발 브랜치의 작업을 운영 브랜치에 머지하면, 배포 서버에 반영되어 즉각적으로 수정 사항을 볼 수 있다. 즉 QA용 서버가 존재하지 않는데, 이미 해당 프로젝트에서 앱을 배포하여 운영 중이었기에 무턱대고 코드를 수정하여 바로 바로 운영 서버에 반영하기에는 앱 이용자에게도 에러가 즉각적으로 감지될 수 있는 위험이 있었다.

코드를 수정하여도 로컬에서 API를 동시에 여러 번 호출하여 생기는 동시성 문제를 Postman이나 Swagger로 확인하기에는 무리가 있기에, 따라서 로컬에서도 동시성 문제를 테스트해볼 수 있는 스크립트를 작성하여 테스팅을 진행했다.

 

아래 스크립트는 요약글 요청 후 퀴즈를 요청하는 내용이며, 동시에 여러 번 요청이 들어 왔을 때 Lock이 잘 처리되는지 보기 위해서 요약글→퀴즈 flow를 여러 번(CONCURRENT_REQUESTS만큼) 반복하게 된다.

 

스크립트: concurrency_test.sh

더보기
#!/bin/bash

# 명령행 인자에 JWT access token, categoryId 포함
JWT_TOKEN=$1
CATEGORY_ID=$2

CONCURRENT_REQUESTS=5 # Lock이 걸리는 API를 거의 동시에 똑같이 5번 호출 

if [ -z "$JWT_TOKEN" ] || [ -z "$CATEGORY_ID" ]; then
  echo "Usage: $0 <JWT_TOKEN> <CATEGORY_ID>"
  exit 1
fi

URL_DOC="http://localhost:8080/api/v1/categories/$CATEGORY_ID/documents/daily" # 요약글 생성(이미 존재 시 조회) 요청
URL_QUIZ_BASE="http://localhost:8080/api/v1/user-quizzes"

echo "🚀 Starting $CONCURRENT_REQUESTS concurrent user flows (Doc -> Quizzes)..."
echo "Target: Category ID $CATEGORY_ID"

run_flow() {
  local id=$1
  
  # 1. Request: GET api/v1/categories/{categoryId}/documents/daily
  RESPONSE=$(curl -s -w "\n%{http_code}" -X GET "$URL_DOC" \
    -H "Authorization: Bearer $JWT_TOKEN" \
    -H "Content-Type: application/json")

  HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
  BODY=$(echo "$RESPONSE" | sed '$d')

  echo "User $id Step 1 (Doc): HTTP $HTTP_CODE"

  if [ "$HTTP_CODE" -eq 200 ]; then
      # Document ID로 JSON parsing
      # Structure: { "data": { "documentId": 123, ... } }
      DOC_ID=$(echo "$BODY" | python3 -c "import sys, json; print(json.load(sys.stdin)['data']['documentId'])" 2>/dev/null)
      
      if [ -n "$DOC_ID" ]; then
          # 2. Request: 퀴즈 요청
          QUIZ_URL="$URL_QUIZ_BASE?documentId=$DOC_ID&documentType=CATEGORY"
          QUIZ_RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" -X GET "$QUIZ_URL" \
            -H "Authorization: Bearer $JWT_TOKEN" \
            -H "Content-Type: application/json")
          echo "User $id Step 2 (Quiz): HTTP $QUIZ_RESPONSE (DocId: $DOC_ID)"
      else
          echo "User $id Step 2 (Quiz): SKIPPED (No Doc ID)"
      fi
  else
      echo "User $id Step 2 (Quiz): SKIPPED (Doc Failed)"
  fi
}

# Export (함수 및 변수)
export -f run_flow
export JWT_TOKEN CATEGORY_ID URL_DOC URL_QUIZ_BASE

# Run requests
for i in $(seq 1 $CONCURRENT_REQUESTS); do
  run_flow $i &
done

# 작업 끝
wait
echo "✅ All flows completed."

 

시도 1) tryLock(waitTime, leaseTime, timeUnit)의 waitTime 늘리기

우선 지식(요약글+퀴즈) 생성 과정에서 lock의 점유가 풀리지 않았을 경우를 고려해보았다. 참고로 지식은, 요약글이 생겨야 해당 내용을 바탕으로 퀴즈 세트 내용이 구성되어 생성되는 로직이다. 즉 다음과 같다.

1️⃣ 요약글(CategoryDocument 또는 DocumentSummary) 생성 위해 Lock 점유 → 생성 완료 시 Unlock
2️⃣ 퀴즈 생성 위해 Lock 점유 → 생성 완료 시 Unlock

 

여기서 나는 '1️⃣ → 2️⃣ 사이 간극이 너무 짧아서, 아직 앞선 점유 1️⃣이 Unlock 하지도 않았는데 2️⃣가 Lock을 시도해서 그런 것 아닐까?'하는 의문을 가졌다. 이에 따라 tryLock 메서드 내 파라미터에서의 waitTime, 즉 Wait Time을 증가(기존 3초 → 변경 뒤 30초) 시켰다. 즉, 요약글과 퀴즈 생성 로직의 락 대기 시간을 넉넉하게 늘려, 앞선 요청이 끝날 때까지 기다렸다가 결과를 받아갈 수 있게 한 것이다.

 

그러나 여전히 에러가 발생했다.

  • 로컬에서 실행한 스크립트 에러 결과
🚀 Starting 10 concurrent user flows (Doc -> Quizzes)...
Target: Category ID 104
User 8 Step 1 (Doc): HTTP 200
User 8 Step 2 (Quiz): SKIPPED (No Doc ID)
User 3 Step 1 (Doc): HTTP 500
User 3 Step 2 (Quiz): SKIPPED (Doc Failed)
User 1 Step 1 (Doc): HTTP 500
User 1 Step 2 (Quiz): SKIPPED (Doc Failed)
User 5 Step 1 (Doc): HTTP 500
User 5 Step 2 (Quiz): SKIPPED (Doc Failed)
User 7 Step 1 (Doc): HTTP 500
User 7 Step 2 (Quiz): SKIPPED (Doc Failed)
User 9 Step 1 (Doc): HTTP 500
User 9 Step 2 (Quiz): SKIPPED (Doc Failed)
User 6 Step 1 (Doc): HTTP 500
User 6 Step 2 (Quiz): SKIPPED (Doc Failed)
User 2 Step 1 (Doc): HTTP 500
User 2 Step 2 (Quiz): SKIPPED (Doc Failed)
User 10 Step 1 (Doc): HTTP 500
User 10 Step 2 (Quiz): SKIPPED (Doc Failed)
User 4 Step 1 (Doc): HTTP 200
User 4 Step 2 (Quiz): SKIPPED (No Doc ID)
✅ All flows completed.

 

참고로 요약글 GET API는 그 날 요약글이 생성되지 않았다면 생성 후 반환, 이미 있다면 바로 반환한다.

위 결과에서 HTTP 200으로 처리된 쪽을 보면 GET 요청은 성공적이었지만, 처음에 요약글 1회 생성에 성공한 뒤 후반에 조회로 된 게 아니라 요약글이 2번 생성된 것이었다. (DB에서 확인 시 중복 생성된 걸 확인함)

 

시도 2) 트랜잭션 시점 문제를 해결

그렇다면 Lock이 분명 있는데도, 대기 시간을 LLM 소요 시간보다 길게 잡았는데도, 요약글이 중복 생성 되는 이유는 무엇일까?

초반에 보여줬던 운영 서버 에러 로그에서 'Transaction'이라는 단어가 있었다.

 

참고로 DB 트랜잭션 개념은 다음과 같다.

DB Transaction 트랜잭션
데이터베이스 트랜잭션(Database Transaction)은 데이터베이스의 상태를 변화시키기 위해 수행하는 하나의 논리적인 작업 단위

1️⃣ Active (활성): 트랜잭션이 시작되어 읽기(Read)나 쓰기(Write) 연산을 수행 중인 초기 상태

2️⃣ Partially Committed (부분 완료): 마지막 연산까지 실행을 마쳤지만, 아직 데이터베이스에 최종적으로 반영(Commit)되기 직전의 상태

3️⃣ Committed (완료): 트랜잭션이 성공적으로 완료되어 변경 내용이 데이터베이스에 영구적으로 반영된 상태

4️⃣, 5️⃣ Failed (실패): 연산 수행 중 오류가 발생하거나 명시적으로 중단(Abort)되어 더 이상 정상적인 수행이 불가능한 상태

6️⃣ Terminated (종료): 트랜잭션이 커밋되거나 롤백(Rollback)된 후 완전히 종료된 상태

 

만약 요약글 생성 메서드가 시작되고 연산을 수행 중인데, 아직 생성된 요약글을 저장하려 했지만 DB에 반영되지 않은 상태에서 다시 한번 요약글 생성 API를 호출하면 어떻게 될까? 프록시 입장에서는 확인되는 오늘의 요약글이 없을 테니, 문제가 없을 거라 판단하고 한 번 더 요약글 생성을 하게 될 것이다. 즉 트랜잭션 커밋 시점 차이로 인해 문제가 생길 수 있는 것이다!

 

1. 서버(Thread A): 요약글 생성 API 호출 & 락 점유 🔒 (아직 Transaction commit이 되지 않아 DB에 완전히 저장 x)
2. 프론트엔드: 버튼을 모르고 연타하는 등, 요약글 생성 API 중복 호출됨
3. 서버(Thread A): (조금 뒤) 락 해제 🔓
4. 서버(Thread B): 요약글 생성 API 호출 & 락 점유 🔒, DB 조회 했지만 아직 Thread A에서의 Transaction이 끝나지 않아 저장된 요약글이 없기에 또 요약글을 중복 생성
5. 서버(Thread A): 뒤늦게 트랜잭션 커밋 완료. 이제 DB에 요약글이 진짜 save
6. 서버(Thread B): (조금 뒤) 락 해제 🔓, 이후 트랜잭션 커밋 완료, 또 다시 요약글이 save
.
.
.
=> 결과적으로 요약글이 중복 되어 여러 개가 생김 
또한 이는 요약글 생성 이후의 퀴즈 생성에서도 발생할 수 있는 문제이다.
만약 요약글 데이터 저장 호출은 했는데 아직 DB에 반영(Commit)이 안 된 순간에 프론트엔드가 퀴즈 생성 API를 요청한다면 다음과 같을 것이다.
  1. 서버(Thread A): 요약글 만들고 퀴즈 생성함. (아직 트랜잭션 안 끝남, DB에 완전히 저장 x)
  2. 프론트엔드: 요약글 생겼다고 판단 후 GET /quizzes
  3. 서버(Thread B): DB 조회 했지만 아직 커밋 안 돼서 퀴즈가 없음 -> 빈 화면 반환 (Empty List)
  4. 서버(Thread A): (조금 뒤) 트랜잭션 커밋 완료. 이제 DB에 퀴즈가 진짜로 보임.
  5. 유저: (앱 나갔다 들어옴) 다시 퀴즈 요청 -> 서버가 이제는 퀴즈 반환 가능

 

이런 문제를 트랜잭션 커밋 시점 차이(Race Condition / Visibility Issue)라고 한다.

 

이 문제를 해결하려면 "트랜잭션이 완전히 커밋된 후에 락이 해제되도록" 코드를 수정해야한다. 트랜잭션 관련 코드를 수정하기 앞서, 스프링에서의 트랜잭션 전략에 대해서 잠시 알아보자.

 

💡 Spring에서 트랜잭션 활용하기

✔️ @Transactional 어노테이션: 메서드나 클래스에 붙여, 메서드 시작 시 트랜잭션을 열고 메서드가 성공적으로 종료될 시 commit, 런타임 예외 발생 시 rollback을 자동 수행한다.

✔️ 동작 원리 (AOP Proxy)
Spring에서 @Transactional이 동작하는 방식은 내부적으로 AOP(Aspect Oriented Programming)를 활용한다.
1. Spring이 해당 객체를 상속받거나 인터페이스를 구현한 프록시(Proxy, 가짜 스프링 Bean) 객체를 생성
2. 사용자가 메서드를 호출하면 프록시가 먼저 호출을 가로챔.
3. 프록시 내부에서 TransactionManager를 통해 트랜잭션을 시작
4. 실제 비즈니스 로직을 수행한 후, 결과에 따라 Commit 또는 Rollback이 호출

이에 따라 기존 요약글 관련 UseCase 메서드의 @Transactional에서 Propagation.NOT_SUPPORTED 조건을 넣어 트랜잭션의 범위를 수정하기로 결정했다. 즉, 큰 트랜잭션의 범위를 무시하는 것이다.

// CategoryDocumentUseCase.java
// 1. 외부 트랜잭션 없이 진입 (락보다 넓은 범위의 트랜잭션 방지)
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public CategoryDocumentResponse generateDocument(Long categoryId, Long userId) {
    // ... 검증 로직 ...
    // 2. 락 획득 시도 및 로직 수행
    CategoryDocument targetDocument = distributedLockFacade.tryExecuteWithLock(
        lockKey, 
        30, 
        60, 
        TimeUnit.SECONDS, 
        () -> {
            // 3. 락 내부에서 이중 체크 (Double Check)
            if (categoryDocumentService.existsByGoalIdAndDate(goal.getId(), LocalDate.now())) {
                return categoryDocumentService.getDocumentsByGoalId(...);
            }
            // 4. 실제 생성 및 저장 (여기서 내부적으로 새로운 트랜잭션이 시작되고 커밋됨)
            return createDocumentInternal(goal);
        }
    ).orElseGet(() -> {
        // 락 획득 실패 시 재조회 등 처리
    });
}

 

즉 정리해보자면 다음과 같다.

기존 트랜잭션
@Transactional
✅ 트랜잭션
@Transactional(propagation = Propagation.NOT_SUPPORTED))
: 메서드가 시작될 때 트랜잭션을 열고 메서드가 완전히 끝나서 리턴될 때 닫는(커밋) 구조

기존 로직

  1. UseCase 메서드 시작 (트랜잭션 시작 🏁)
  2. 락 점유 🔒
  3. 요약글/퀴즈 생성 & DB save 호출 -> 하지만 아직 커밋 x, 메모리에만 있음
  4. 락 해제 🔓 (메서드 로직 상 finally 블록에서 해제됨)
  5. 메서드 종료 및 프록시가 트랜잭션 커밋 (DB 반영) 📝
: 큰 트랜잭션 없이 작은 트랜잭션을 관리하는 구조

변경된 흐름

  1. UseCase 메서드 시작 (트랜잭션 없음)
  2. 락 점유 🔒
  3. 요약글/퀴즈 생성
  4. DB save resultRepository.save(...) 호출  -> 이 순간 트랜잭션이 열리고, 바로 저장하고, 바로 커밋됨 📝 (작은 트랜잭션 종료)
    • DB 저장 메서드 등은 자체 트랜잭션을 가짐
  5. 락 해제 🔓
변경 전(왼쪽의 기존 트랜잭션): 전체 로직이 하나의 트랜잭션으로 묶여 락이 먼저 풀림, DB 반영은 나중에 됨.
➡️ 변경 후(오른쪽의 트랜잭션 전략): 락 점유 중 내부에서 개별적인 작은 트랜잭션(Repository의 save 등)이 실행되고 즉시 커밋됨. 락이 풀리는 시점에는 이미 DB에 데이터가 저장된 상태이도록 함.

 

오른쪽 방법처럼 주요 메서드에서 트랜잭션 옵션을 바꾸어, 락 점유 중에 생성된 요약글을 바로 DB에 저장하여 락이 풀리자마자 다른 요청들이 요약글이 생성된 사실을 인지하게끔 하였다.

 

  • 스크립트 실행 결과(성공)
🚀 Starting 5 concurrent user flows (Doc -> Quizzes)...
Target: Category ID 107
User 3 Step 1 (Doc): HTTP 200
User 2 Step 1 (Doc): HTTP 200
User 5 Step 1 (Doc): HTTP 200
User 4 Step 1 (Doc): HTTP 200
User 1 Step 1 (Doc): HTTP 200
User 3 Step 2 (Quiz): HTTP 200 (DocId: 79)
User 2 Step 2 (Quiz): HTTP 200 (DocId: 79)
User 5 Step 2 (Quiz): HTTP 200 (DocId: 79)
User 4 Step 2 (Quiz): HTTP 200 (DocId: 79)
User 1 Step 2 (Quiz): HTTP 200 (DocId: 79)
✅ All flows completed.

 

 

다만, 트랜잭션 없이 접근하는 부분에서 엮여있는 기존의 지연 로딩 접근 메서드에서는 LazyInitializationException이 발생하기도 했다.

이런 부분(findWithCurrentGoalById, findWithGoalById 등...)에는 해결책으로 Fetch Join을 사용하는 전용 조회 메서드를 추가하여, 필요한 데이터를 한 번에 안전하게 가져오도록 수정하였다.

// UserRepository.java
@Query("select u from UserEntity u left join fetch u.currentGoal g left join fetch g.category where u.id = :id")
Optional<UserEntity> findWithCurrentGoalById(Long id);

 


 

이번 트러블 슈팅을 통해 단순히 Redis Lock을 건다고 해서 모든 동시성 문제가 해결되는 것은 아닌 걸 깨달았다. 처음에는 Lock 관점에서만 생각했는데, 에러 로그의 Transaction이라는 키워드를 통해 DB 저장 시점 즉 DB 커밋 시점과 락 해제 시점의 불일치에서 문제가 있었다는 걸 파악할 수 있었다.

 

즉, Lock의 범위와 트랜잭션의 범위(생명주기) 관계를 정확히 이해하고 설계해야 데이터 정합성을 보장할 수 있다는 게 핵심이었다.

또한 Spring에서 다루는 DB의 트랜잭션 범위나, @Transactional에 걸 수 있는 조건은 readOnly 외에는 잘 몰랐었는데 프록시와 엮어 AOP 기반으로 어떻게 관리되는지까지 확장해서 공부할 수 있었다!

 

@Transactional과 AOP 기반의 락이 만났을 때, 트랜잭션 커밋 시점이 락 해제보다 늦어질 수 있다는 점은 잊지 않아야겠다고 다짐했다.

앱 서비스를 출시하였지만, 계속 고도화하면서 아마 광고를 본 사용자에게는 요약글과 퀴즈를 더 제공하는 기능 등이 생길 예정인데 해당 Lock을 어떻게 리팩토링할지 고민해보아야겠다.