[TroubleShooting]공연 등록 API 타임아웃과 비동기 처리 도입

2026. 4. 12. 23:18·Projects/Side Project

1. 배경

관리자 화면에서 공연을 등록할 때, 회차나 이미지가 늘어나면 클라이언트에서 30초 타임아웃이 발생했다.

서버 로그와 DB를 확인해보니 S3 업로드와 DB 반영은 타임아웃 이후에도 계속 진행되어 결국 완료되는 경우가 있었다. 즉, "요청은 실패로 보이는데 데이터는 들어가 있다" 는 불일치 현상이 발생했고, 이를 해결하는 과정을 정리했다.

2. 원인 정리

2.1 긴 요청 처리 시간

createShow() 하나를 호출하면 아래 작업들이 순차적으로, 하나의 트랜잭션 안에서 실행되고 있었다.

[단일 @Transactional 범위]
①  S3 포스터 업로드              ← 네트워크 I/O
②  DB: Show INSERT
③  S3 상세이미지 업로드 × N장    ← 네트워크 I/O × N (순차 처리 시 누적)
④  DB: ShowSchedule.save() × 회차 수   ← 개별 호출, N번 왕복
⑤  DB: ShowSeatGrade.save() × 구역 수  ← 개별 호출, M번 왕복
[트랜잭션 커밋]

이 전체가 하나의 트랜잭션 안에서 돌아가면, S3 업로드를 기다리는 동안에도 DB 커넥션을 계속 붙잡고 있게 된다. 이미지가 4장 이상되거나, 공연 스케줄이 많아지면 상당한 시간이 누적됐다.

2.2 클라이언트 타임아웃과의 불일치

Axios에 고정 타임아웃(30초)이 설정되어 있으면, 서버가 아직 처리 중이어도 클라이언트만 먼저 연결을 끊는다. 그러나 서버 스레드는 끊기지 않고 계속 동작하므로 결국 DB 저장까지 완료된다.

이것이 "타임아웃인데 등록은 됐다"는 현상의 원인이었다.

타임아웃 값만 늘리는 건 임시방편에 불과하다. 사용자 경험과 운영 안정성 측면 모두에서 응답을 빨리 돌려주고 무거운 작업은 뒤로 미루는 구조로 가는 것이 맞다고 판단했다.

3. 해결 방향

3.1 동기 구간 최소화 — 즉시 202 Accepted 반환

DB에 "등록 진행 중(PROCESSING)" 상태만 먼저 저장하고, HTTP 응답은 즉시 반환했다. 무거운 작업(S3 업로드, 스케줄/좌석 생성)은 이후 비동기로 처리했다.

POST /api/v1/admin/shows
   │
   ▼
① Show 저장 (processingStatus = PROCESSING)
② 202 Accepted + { showId } 즉시 반환
   │
   ▼ (비동기 시작)
③ S3 업로드 → DB 업데이트 → processingStatus = DONE

3.2 트랜잭션 커밋 이후 비동기 실행

@Async를 트랜잭션 안에서 바로 호출하면, 외부 트랜잭션이 커밋되기 전에 비동기 스레드가 먼저 실행될 수 있다. 이 경우 비동기 스레드에서 showId로 조회해도 아직 DB에 레코드가 없는 레이스 컨디션이 발생한다.

TransactionSynchronization#afterCommit 훅을 사용하면 커밋이 확정된 이후에 비동기 작업을 시작할 수 있어 안전하다.

// 잘못된 패턴: 커밋 전에 비동기 호출
@Transactional
public void createShow(...) {
    Show show = showRepository.save(newShow); // 아직 커밋 안 됨
    asyncService.doHeavyWork(show.getId());   // DB에 show가 없을 수 있음
}

// 올바른 패턴: 커밋 이후 비동기 트리거
TransactionSynchronizationManager.registerSynchronization(
    new TransactionSynchronization() {
        @Override
        public void afterCommit() {
            asyncService.doHeavyWork(show.getId()); // 커밋 확정 후 실행
        }
    }
);

3.3 MultipartFile과 비동기 경계

MultipartFile은 HTTP 요청이 끝나면 임시 파일 스트림이 닫힌다. 비동기 스레드에서 그대로 사용하면 이미 닫힌 스트림에 접근하게 되어 예외가 발생할 수 있다. 비동기로 넘기기 전에 반드시 byte[] 등으로 내용을 복사해두어야 한다.

// 요청 처리 시점에 미리 복사
byte[] posterBytes = poster.getBytes();
List<byte[]> detailBytes = detailImages.stream()
    .map(MultipartFile::getBytes)
    .toList();

// 복사된 데이터를 비동기 메서드에 전달
asyncService.doHeavyWork(show.getId(), posterBytes, detailBytes);

3.4 S3 병렬 업로드

기존에는 포스터와 상세 이미지를 하나씩 순차적으로 업로드했다. CompletableFuture로 병렬화하면 이미지 장수와 관계없이 가장 느린 한 장의 업로드 시간만 걸린다.

// 기존: 순차 업로드 (이미지 3장 = 3배 시간)
for (byte[] imageBytes : detailBytesList) {
    fileService.uploadBytes(imageBytes, "details");
}

// 개선: 병렬 업로드 (이미지 3장 = 가장 느린 1장 시간)
List<CompletableFuture<String>> futures = detailBytesList.stream()
    .map(bytes -> CompletableFuture.supplyAsync(
        () -> fileService.uploadBytes(bytes, "details"), uploadExecutor))
    .toList();

List<String> urls = futures.stream()
    .map(CompletableFuture::join)
    .toList();

3.5 배치 INSERT

스케줄, 좌석 등급 등을 개별 save()로 호출하면 row 수만큼 DB 왕복이 발생한다. saveAll()과 Hibernate JDBC batch 설정을 함께 적용하면 여러 row를 한 번의 왕복으로 처리할 수 있다.

// 기존: N번 DB 왕복
for (ScheduleRequest req : scheduleRequests) {
    showScheduleRepository.save(buildSchedule(req, show));
}

// 개선: 1번 DB 왕복
List<ShowSchedule> schedules = scheduleRequests.stream()
    .map(req -> buildSchedule(req, show))
    .toList();
showScheduleRepository.saveAll(schedules);
 
# application.properties — 이 설정이 없으면 saveAll()도 batch로 동작하지 않는다
spring.jpa.properties.hibernate.jdbc.batch_size=50
spring.jpa.properties.hibernate.order_inserts=true
spring.jpa.properties.hibernate.order_updates=true
spring.jpa.properties.hibernate.jdbc.batch_versioned_data=true

 @GeneratedValue(strategy = IDENTITY) 전략은 Hibernate가 기본적으로 batch insert를 비활성화한다. 대량 INSERT가 필요한 엔티티는 SEQUENCE 전략으로 전환하는 것이 근본적인 해결이다.

3.6 처리 상태 필드 (processingStatus) 추가

기존 ShowStatus(WAITING, ON_SALE, SOLD_OUT …)는 비즈니스 상태다. 비동기 처리가 완료됐는지를 나타내는 것은 시스템 처리 상태이므로 관심사가 다르다. Show 엔티티에 별도 컬럼으로 분리했다.

@Enumerated(EnumType.STRING)
private ProcessingStatus processingStatus; // PROCESSING, DONE, FAILED

관리자 목록·상세 API 응답에 processingStatus를 포함시키면, 프론트엔드에서 "준비 중" 배지 표시, 클릭 제한, 폴링 등에 활용할 수 있다.

4. 구현 시 주의한 점

실패 시 보상 처리

비동기 메서드에서 발생한 예외는 일반 try-catch로 전파되지 않는다. 내부에서 반드시 잡아서 상태를 업데이트해야 한다.

@Async
public void doHeavyWork(Long showId, byte[] posterBytes, List<byte[]> detailBytes) {
    try {
        // S3 업로드, 스케줄 생성, 좌석 생성 ...
        updateProcessingStatus(showId, ProcessingStatus.DONE);
    } catch (Exception e) {
        log.error("공연 등록 비동기 작업 실패: showId={}", showId, e);
        updateProcessingStatus(showId, ProcessingStatus.FAILED);
        rollbackUploadedS3Files(showId); // 이미 올라간 S3 파일 삭제
    }
}

기존 데이터 호환

processingStatus 컬럼이 없는 기존 데이터는 DTO 매핑 시 null이 내려올 수 있다. null인 경우 DONE으로 간주하는 방식으로 하위 호환성을 유지했다.

ProcessingStatus status = show.getProcessingStatus();
return status != null ? status : ProcessingStatus.DONE;

5. API 변경

항목 변경 전 후
POST /api/v1/admin/shows 응답 코드 201 Created 202 Accepted
응답 메시지 등록 완료 "공연 등록이 시작되었습니다"
관리자 목록·상세 응답 processingStatus 없음 processingStatus 필드 추가

프론트엔드와 상태 코드 및 필드 계약을 명확히 맞추는 것이 중요했다. 202를 받으면 곧바로 완료로 처리하지 않고, processingStatus를 폴링해서 DONE이 되면 완료 처리하는 흐름으로 변경됐다.

6. 정리

  • "느린 작업을 한 HTTP 요청에 모두 실어 보내는" 패턴은 타임아웃·커넥션 점유·UX 모두에 불리하다.
  • 202 + 비동기 + 처리 상태 폴링은 관리자 기능처럼 무거운 작업을 다룰 때 유효한 패턴이다.
  • S3 병렬화와 배치 INSERT를 함께 적용하면 실제 처리 시간도 줄일 수 있다.
  • @Async와 @Transactional을 함께 쓸 때는 커밋 타이밍을 반드시 신경 써야 하고, MultipartFile은 비동기 경계에서 미리 복사해두어야 한다.
  • 프론트엔드와는 상태 코드·응답 필드 계약을 명확히 맞추는 것이 중요하다.

'Projects > Side Project' 카테고리의 다른 글

ALB 없이 구현하는 무중단 서비스: NAT 인스턴스와 Route 53 Private DNS 조합  (0) 2026.04.06
[V2]카프카(Kafka) 비동기 예매 도입기: 0.05%의 에러율을 0%로 만들다  (1) 2026.03.01
[V2]동시에 예매를 눌렀을 때, 서버는 살아남을 수 있을까? — 부하 테스트와 트랜잭션 최적화 기록  (0) 2026.02.28
[Project] 공연 예매 시스템 대용량 트래픽 대응기: 해결 방안 설계 (V1 → V2)  (0) 2026.02.03
대규모 트래픽 좌석 예매 시스템 설계②_대기열, Redis  (0) 2025.12.27
'Projects/Side Project' 카테고리의 다른 글
  • ALB 없이 구현하는 무중단 서비스: NAT 인스턴스와 Route 53 Private DNS 조합
  • [V2]카프카(Kafka) 비동기 예매 도입기: 0.05%의 에러율을 0%로 만들다
  • [V2]동시에 예매를 눌렀을 때, 서버는 살아남을 수 있을까? — 부하 테스트와 트랜잭션 최적화 기록
  • [Project] 공연 예매 시스템 대용량 트래픽 대응기: 해결 방안 설계 (V1 → V2)
Dev히다
Dev히다
Java 백엔드 개발자입니다. 안정적인 서비스 운영과 효율적인 인프라 구축에 몰입합니다. 코드가 돌아가는 환경까지 이해하는 엔지니어를 지향합니다. Architecture, TroubleShooting, Tech Log.
  • Dev히다
    Java to Cloud : Dev Note
    Dev히다
  • 전체
    오늘
    어제
    • 분류 전체보기 (186)
      • AI & Future Tech (2)
        • AI Workspace (0)
        • AI Weekly News (0)
        • AI Agent & Automation (0)
        • LLM & RAG (2)
      • Backend Engineering (20)
        • Java & Spring (15)
        • JPA & QueryDSL (5)
      • Data Engineering (4)
        • DBMS & Tuning (3)
        • Redis & Cache (1)
      • Cloud & DevOps (5)
        • AWS Infrastructure (3)
        • Docker & CI CD (2)
      • Algorithm & CS (6)
        • CodingTest (5)
        • Computer Science (1)
      • Projects (12)
        • Side Project (10)
        • Work Experience (2)
      • Troubleshooting (9)
        • Error Log (0)
        • Review (9)
      • Log (0)
        • 내 맘대로 (0)
        • 여행 (0)
        • 요즘 (0)
      • Archive (125)
        • 기술면접 (33)
        • Project (9)
        • Spring (29)
        • Spring_Boot (2)
        • JAVA (5)
        • Servlet_JSP (12)
        • SQL (6)
        • JavaScript (1)
        • HTML_CSS (6)
        • Jquery (3)
        • Mybatis (1)
        • Vue.js (3)
        • 기타 (3)
        • 기타2 (2)
        • 코테대비 (10)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    기술 대비
    공부기록
    뉴렉처
    김영한
    CORS
    MVC
    Terraform
    thread
    프레임워크
    토이프로젝트
    코테
    MVC2
    AOP
    자바
    SQL
    인텔리제이
    코딩테스트
    @RestController
    redis
    프로그래머스
    인프런
    select
    Join
    @Controller
    대용량 트래픽
    docker
    폐쇄망
    JSP
    aws
    스프링
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
Dev히다
[TroubleShooting]공연 등록 API 타임아웃과 비동기 처리 도입
상단으로

티스토리툴바