SYDESYDE
SYDESYDE
  • 피드
  • 쇼케이스
  • 블로그
  • 모임
SYDESYDE© 2026 SYDE. All rights reserved.
SYDE 소개문의하기
Kakao
SYDE 오픈채팅
Instagram
Instagram
Threads
Threads
커뮤니티 가이드라인협업문의이용약관개인정보처리방침
커뮤니티 가이드라인협업문의이용약관개인정보처리방침

© 2026 SYDE. All rights reserved.

AI 생성 비용을 지키는 UPSERT와 선점 트랜잭션

h하루들· 8일 전· 기술
목차
  • “조회하고 1 더하기”는 왜 뚫렸을까
  • 해결 1: 확인과 증가를 PostgreSQL 한 문장으로 묶기
  • 그런데 카운터만 정확하면 끝일까
  • 해결 2: 비용을 쓰기 전에 생성 권한부터 선점하기
  • MANDATORY는 왜 사용했을까
  • 같은 요청이 동시에 처음 들어오면
  • 느린 Gemini와 S3 호출은 트랜잭션 밖에서
  • 실제 PostgreSQL에서 확인한 것
  • 이 설계가 포기한 것
  • 마치며
  • 실제 구현과 참고 자료

목차

  • “조회하고 1 더하기”는 왜 뚫렸을까
  • 해결 1: 확인과 증가를 PostgreSQL 한 문장으로 묶기
  • 그런데 카운터만 정확하면 끝일까
  • 해결 2: 비용을 쓰기 전에 생성 권한부터 선점하기
  • MANDATORY는 왜 사용했을까
  • 같은 요청이 동시에 처음 들어오면
  • 느린 Gemini와 S3 호출은 트랜잭션 밖에서
  • 실제 PostgreSQL에서 확인한 것
  • 이 설계가 포기한 것
  • 마치며
  • 실제 구현과 참고 자료

광클에도 하루 3회 제한을 지키고, 느린 Gemini와 S3 호출을 DB 트랜잭션 밖으로 분리한 과정

하루들은 사용자가 쓴 일기를 AI 그림일기로 바꿔 주는 서비스다
버튼 한 번 뒤에는 Gemini로 스토리보드를 만들고 이미지를 생성한 다음 S3에 저장하는 과정이 숨어 있어, 단순한 저장 요청보다 오래 걸리고 호출할 때마다 비용도 발생한다

그래서 회원당 하루 세 건이라는 제한을 두었다
여기서 세 건은 Gemini HTTP 호출 횟수가 아니라 신규 생성 작업 수로, 작업 하나 안에서도 AI 호출과 재시도가 여러 번 일어날 수 있으니 사용자에게 매일 생성 이용권 세 장을 나눠 주는 정책에 가깝다

처음에는 카운터만 잘 올리면 될 줄 알았다
문제는 마지막 이용권 한 장을 두 요청이 동시에 가져가려 할 때 시작됐다

“조회하고 1 더하기”는 왜 뚫렸을까

처음 구현한 로직은 단순했다

GenerationUsage usage = repository.find(userId, today)
        .orElse(GenerationUsage.empty(today));
if (!usage.canIncrement()) {
    throw new DailyGenerationLimitExceededException();
}
usage.increment();
repository.save(usage);

오늘 사용량이 한도보다 작을 때만 1을 더한다
요청이 차례로 들어오는 동안에는 이 정도로 충분해 보였다

하지만 현재 사용량이 2/3일 때 두 요청이 거의 동시에 들어오면 이야기가 달라진다

요청 A: 현재 값 2 조회 → 한도 미만이므로 통과
요청 B: 현재 값 2 조회 → 한도 미만이므로 통과
요청 A: 3 저장 → 생성 시작
요청 B: 3 저장 → 생성 시작
DB에 남은 값: 3
실제로 허용된 누적 생성 작업: 4

두 요청 모두 “이용권이 한 장 남았다”고 본 셈이다
DB에는 마지막 값인 3이 남지만 그사이 신규 생성은 두 건이 시작되어, 숫자는 정상인데 비용은 이미 한 번 더 나간다
이른바 lost update다

원인은 JPA 자체가 아니라 조회, 한도 확인, 증가가 서로 다른 시점에 실행되는 구조에 있었다

해결 1: 확인과 증가를 PostgreSQL 한 문장으로 묶기

이 경쟁을 애플리케이션의 재시도로 풀기보다, 판단이 일어나는 DB 안에서 끝내기로 했다
행이 없으면 INSERT하고 이미 있으면 UPDATE하는 PostgreSQL UPSERT에 사용량 생성과 한도 확인, 증가를 함께 담았다

INSERT INTO daily_generation_usage (
    user_id, usage_date, used_count, limit_count
)
VALUES (:userId, :usageDate, 1, 3)
ON CONFLICT (user_id, usage_date)
DO UPDATE
   SET used_count = daily_generation_usage.used_count + 1
 WHERE daily_generation_usage.used_count
       < daily_generation_usage.limit_count
RETURNING used_count, limit_count;

위 코드는 설명을 위해 CTE와 시간 갱신 부분을 덜어낸 버전으로, 동시성 제어의 핵심인 충돌 기준과 조건부 증가, 반환 결과만 남겼다

오늘의 사용량 행이 없다면 1/3으로 만들고, 이미 있다면 DB에 저장된 현재 값을 기준으로 1을 더한다
단, 3/3에 도달한 행은 WHERE 조건을 통과하지 못해 값도 결과도 반환되지 않으며, 애플리케이션은 이 빈 결과를 한도 초과로 해석한다

여러 직원이 각자 남은 표를 확인하던 방식에서, 하나의 개찰구가 이용권 확인과 사용 처리를 함께 맡는 구조로 바꾼 셈이다

PostgreSQL은 ON CONFLICT DO UPDATE의 충돌 판정과 조건부 갱신을 원자적으로 처리한다
여기서 원자적이라는 말은 이 SQL이 중간 상태로 쪼개지지 않는다는 뜻이다
PostgreSQL 공식 문서

스키마에는 used_count <= limit_count라는 CHECK 제약조건도 뒀다
정상 경로는 UPSERT가 맡고, CHECK는 잘못된 값이 저장되는 일을 막는 마지막 방어선이다

그런데 카운터만 정확하면 끝일까

카운터를 고치고 나니 다음 문제가 보였다
생성 요청 한 건에서 함께 바뀌어야 할 DB 상태가 세 가지였던 것이다

  1. 사용자가 작성한 일기 저장

  2. 생성 작업을 PROCESSING 상태로 저장

  3. 오늘 사용량 1 증가

셋이 따로 커밋되면 아래와 같은 중간 상태가 남을 수 있다

이렇게 되면 실행할 수 없는 일기와 작업만 DB에 남고, 순서가 반대라면 결과도 없이 이용권만 사라질 수 있다
그래서 외부 비용을 쓰기 전에 세 변경을 하나의 선점 트랜잭션으로 묶기로 했다

해결 2: 비용을 쓰기 전에 생성 권한부터 선점하기

여기서 선점은 Gemini 자원을 예약하는 일이 아니다
AI를 부르기 전에 “이 요청은 비용을 쓸 자격을 얻었다”는 도장을 DB에 먼저 찍는 것, 즉 외부 생성을 진행할 권리를 기록하는 과정이다

회원 생성 흐름의 트랜잭션 경계는 다음 메서드가 담당한다

@Transactional
MemberDiaryCreationClaim claim(
        CreateDiaryCommand command,
        boolean generationAvailable
) {
    DiaryCreationClaim claim =
            claimService.claim(command, generationAvailable);
    GenerationUsage usage = claim.newlyCreated()
            ? generationUsageService.incrementTodayUsage(command.userId())
            : generationUsageService.getTodayUsage(command.userId());
    return new MemberDiaryCreationClaim(claim, usage);
}

신규 요청은 일기와 PROCESSING 작업 생성, 사용량 증가를 같은 트랜잭션에서 처리한다
사용량이 이미 3/3이면 세 변경 모두 롤백되는 반면, 재요청은 기존 선점을 재사용하므로 이용권을 추가로 소비하지 않는다

MANDATORY는 왜 사용했을까

이 구조의 전제는 하나다
공통 선점 로직이 사용량 증가와 같은 상위 트랜잭션에 반드시 참여할 것
이를 개발자의 주의에만 맡기지 않기 위해 전파 속성을 MANDATORY로 지정했다

@Transactional(propagation = Propagation.MANDATORY)
DiaryCreationClaim claim(...) {
    // 일기와 PROCESSING 작업을 선점한다.
}

두 설정의 차이는 호출자가 트랜잭션을 준비하지 않았을 때 드러난다

  • REQUIRED: 상위 트랜잭션이 있으면 참여하고, 없으면 스스로 새 트랜잭션을 만든다

  • MANDATORY: 상위 트랜잭션이 있으면 참여하지만, 없으면 IllegalTransactionStateException을 던지고 실행을 거부한다

우리의 정상 흐름은 다음과 같다

[MemberDiaryCreationTransactionService @Transactional]
  ├─ claimService.claim() @MANDATORY → 일기 + PROCESSING 저장
  └─ usageService.increment()        → 사용량 증가
위 작업 전체가 하나의 트랜잭션으로 커밋 또는 롤백

REQUIRED였다면 상위 @Transactional이 빠졌을 때 선점 로직만 별도 트랜잭션으로 커밋되고, 뒤이은 사용량 증가가 실패해 일기와 PROCESSING 작업만 남을 수 있다
반면 MANDATORY는 상위 트랜잭션이 없는 호출을 DB 작업 전에 거부하므로 이런 부분 성공을 구조적으로 막아 준다

도장 비유로 풀면, “이 도장은 전체 신청서 안에서만 찍을 수 있고, 도장만 따로 찍어서는 안 된다”는 규칙을 프레임워크에 새겨 둔 셈이다
비트랜잭션 오케스트레이터와 트랜잭션 서비스도 별도 Bean으로 나눠 실제 호출이 Spring 프록시를 지나도록 했다
Spring 트랜잭션 공식 문서

같은 요청이 동시에 처음 들어오면

버튼 한 번이 서버 요청 한 번을 보장하지는 않는다
응답 전에 네트워크가 끊기면 클라이언트가 같은 요청을 다시 보낼 수 있는데, 이를 신규 생성으로 처리할 때 생기는 사용량과 비용 중복을 막기 위해 Idempotency-Key를 받았다

키가 이미 저장돼 있다면 SELECT FOR UPDATE로 해당 행에 락을 걸어 상태를 확인하면 된다
문제는 두 요청이 같은 새 키로 처음 들어오는 경우다
둘 다 조회 결과가 없으니 아직 생성되지 않은 행에는 락을 걸 방법도 없다
빈 사물함에 자물쇠를 걸 수 없는 것과 비슷하다

이 최초 경합은 DB의 UNIQUE 제약조건에 맡겼다

UNIQUE (idempotency_key)

신규 생성 작업을 saveAndFlush하면 같은 키로 만든 작업 중 하나만 커밋된다
UNIQUE 충돌을 받은 요청은 실패한 트랜잭션을 끝낸 뒤, 새 트랜잭션에서 이미 만들어진 선점을 조회한다

saveAndFlush를 사용한 목적은 하나였다
충돌을 사용량 증가 전에 확인할 것
save만 호출하면 INSERT가 커밋 직전까지 미뤄질 수 있는 반면, flush는 SQL을 즉시 DB로 보낸다
물론 커밋은 아니므로 이후 작업이 실패하면 INSERT도 같은 트랜잭션과 함께 롤백된다
역할을 나누면 원자성은 트랜잭션이 보장하고, UNIQUE 제약조건은 중복을 막으며, flush는 충돌을 발견하는 시점만 앞당긴다

try {
    return memberTransactionService.claim(command, generationAvailable);
} catch (DataIntegrityViolationException exception) {
    return memberTransactionService.findExistingClaim(command)
            .orElseThrow(() -> exception);
}

키만 같다고 무조건 같은 요청으로 취급하지는 않는다
사용자 ID, 일기 날짜, 정규화한 본문을 SHA-256으로 해시해 요청 핑거프린트를 만들었다

  • 같은 키 + 같은 핑거프린트: 재시도로 보고 기존 상태 재사용

  • 같은 키 + 다른 핑거프린트: 잘못된 키 재사용으로 보고 409 Conflict

즉, 멱등성은 “비슷한 요청을 모두 합치는 기능”이 아니라 같은 작업의 안전한 재시도를 위한 계약이다

느린 Gemini와 S3 호출은 트랜잭션 밖에서

선점이 필요하다고 해서 생성의 처음부터 끝까지 하나의 DB 트랜잭션으로 묶는 것은 적절하지 않았다
Gemini와 S3 호출은 수 초 이상 걸릴 수 있고 네트워크 실패도 발생하는데, 그동안 트랜잭션을 유지하면 DB 커넥션을 점유한 채 행 락까지 오래 붙잡게 된다

더 근본적인 문제는 DB를 롤백하더라도 이미 발생한 Gemini 비용이나 완료된 S3 업로드까지 되돌릴 수는 없다는 점이다
그래서 DB가 책임질 구간과 외부 작업 구간을 세 단계로 나눴다

[짧은 선점 트랜잭션]
멱등성 확인 → 일기 저장 → PROCESSING 저장 → 사용량 증가 → COMMIT
[트랜잭션 밖]
Gemini 스토리보드 → Gemini 이미지 → S3 업로드
[짧은 완료 트랜잭션]
PROCESSING → SUCCEEDED

외부 작업이 실패하면 별도의 짧은 트랜잭션에서 생성 상태를 FAILED로 바꾸고 미완성 일기를 숨긴다
성공 처리와 실패 처리가 겹치는 경우에는 생성 행에 락을 건 뒤, 아직 PROCESSING인 작업만 최종 상태로 전환한다

이 방식이 외부 작업의 정확히 한 번 실행까지 보장하는 것은 아니다
대신 DB가 책임질 수 있는 선점과 상태 전이의 범위를 분명히 하고, 그 밖의 실패는 저장된 상태와 복구 정책으로 다루도록 했다

실제 PostgreSQL에서 확인한 것

설계가 그럴듯해 보이는 것과 실제 경합 상황에서 버티는 것은 별개의 문제다
PostgreSQL 전용 UPSERT와 트랜잭션 롤백을 그대로 확인하기 위해 postgres:18-alpine Testcontainers 환경에서 같은 사용자와 날짜를 대상으로 10개의 요청을 동시에 실행했다

List<Optional<GenerationUsage>> results =
        executeConcurrently(incrementTasks());
assertThat(results)
        .filteredOn(Optional::isPresent)
        .hasSize(3);
assertThat(repository.find(USER_ID, USAGE_DATE))
        .contains(new GenerationUsage(USAGE_DATE, 3, 3));

결과는 예상한 경계 안에 머물렀다

  • 빈 상태에서 동시 요청 10개: 정확히 3개만 성공하고 최종 값은 3/3

  • 기존 값이 2/3인 상태에서 동시 요청 10개: 정확히 1개만 성공

  • 한도 초과로 선점 실패: 앞서 만든 일기와 PROCESSING 작업도 함께 롤백

  • 같은 멱등 요청의 재시도: 사용량 증가와 AI 실행을 반복하지 않음

  • 이미 성공이나 실패로 확정된 상태: 늦게 도착한 결과가 덮어쓰지 않음

실제 동시 DB 트랜잭션으로 확인한 범위는 사용량 증가와 선점 과정의 롤백까지다
멱등성 키 경합과 성공 및 실패 처리의 경합은 상태별 단위 테스트로 검증했지만, 운영 환경의 락 대기 시간과 처리량은 아직 측정하지 못했다
모든 동시성 문제가 해결됐다고 말하기보다 지금 확인한 경계를 분명히 남겨 두려 했고, Docker 환경의 전체 백엔드 테스트 결과는 GitHub Actions에 기록해 두었다

이 설계가 포기한 것

물론 트랜잭션을 나눴다고 해서 모든 문제가 사라진 것은 아니다

  • PostgreSQL 전용 UPSERT에 대한 의존

  • 같은 사용자에게 요청이 몰릴 때 한 사용량 행에 생기는 락 대기

  • 외부 작업 동안 유지되는 동기 HTTP 연결과 서버 스레드

  • 생성 실패에도 사용량을 돌려주지 않는 정책

  • 복구 스케줄러가 실행되기 전까지 남을 수 있는 PROCESSING 작업

비동기 Queue와 Outbox도 후보에 올렸지만, 현재 제품은 생성 결과를 같은 HTTP 응답으로 돌려주는 구조다
MVP 단계에서는 응답 계약과 운영 환경까지 함께 바꾸기보다 동기 흐름을 유지하고 DB 트랜잭션만 짧게 가져가기로 했으며, 동시 생성량이나 타임아웃이 늘어나면 이 결정부터 다시 검토할 예정이다

마치며

출발점은 단순한 일일 카운터였지만, 구현을 따라가 보니 비용 한도를 지키는 일은 숫자를 정확히 세는 것만으로 끝나지 않았다
어떤 요청에 생성 권한을 줄지 먼저 정하고 관련된 DB 변경을 함께 확정한 뒤에야 외부 API를 호출할 수 있었다

UPSERT는 같은 사용자의 동시 요청을 DB 한 문장 안에서 정리했고, 짧은 선점 트랜잭션은 일기와 생성 작업, 사용량이 서로 어긋나지 않도록 묶었다
Gemini와 S3 호출을 그 밖으로 꺼낸 덕분에 느린 외부 작업이 DB 자원을 오래 붙잡는 부담도 줄었다

모든 외부 작업을 원자적으로 만든 것은 아니다
대신 DB가 보장할 수 있는 범위를 분명히 하고 경계 밖의 실패에는 상태와 복구 정책을 붙였으며, 이번 작업에서 얻은 기준은 한 문장으로 정리할 수 있다

비용이 발생하는 요청에서는 “호출했는가”보다 “누가 호출할 권리를 얻었는가”를 먼저 기록해야 한다


실제 구현과 참고 자료

  • JpaGenerationUsageRepository: PostgreSQL UPSERT 실행

  • MemberDiaryCreationTransactionService: 회원 선점 트랜잭션

  • DiaryCreationClaimService: 멱등성 확인과 신규 선점

  • DiaryCreationService: UNIQUE 충돌 복구와 오케스트레이션

  • JpaGenerationUsageRepositoryTest: PostgreSQL 동시성 테스트

  • DiaryCreationPersistenceTest: 선점 커밋과 롤백 검증


  • #upsert
  • #트랜잭션
  • #백엔드
  • #ai
45
하
하루들우리끼리 통하는 네컷만화

하루들님의 다른 글

  • 기록에서 공유로, 하루들 리브랜딩 이야기5일 전
  • 새로고침해도 네컷만화는 한 번만 만들어져야 하니까6일 전
  • 추석 연휴를 하루 앞두고, 모든 그림일기가 갑자기 사라졌다.8일 전

댓글 0

My avatar