배경

파일 업로드 서비스를 개발하다 보면 자연스럽게 다음과 같은 구조를 만들게 된다.

  1. 사용자가 파일을 업로드한다
  2. 모든 파일이 업로드되면 크레딧을 차감하고 주문 상태를 변경한다

단순해 보이는 이 흐름에는 동시성 문제(Race Condition)가 숨어 있다. 이 글에서는 실제 코드를 바탕으로 문제를 분석하고, 설계를 개선해나가는 과정을 공유한다.


문제 상황

아래는 초기 구현 코드다.

public ModelingFileUploadResult processModelingFileUpload(
        MultipartFile file,
        String modelId,
        Long userId,
        String fileType) {

    // 파일 저장
    ModelingFile modelingFile = modelingFileService.createModelingFile(file, order, FileType.find(fileType));
    modelingFileService.saveModelingFile(modelingFile);

    boolean allFilesUploaded = modelingFileService.isUploadCompletedForAllFiles(order);

    // 모든 파일이 업로드됐을 때만 크레딧 차감 + 상태 업데이트
    if (allFilesUploaded) {
        if (!hasPrivilegedRole(user)) {
            creditService.deductCredit(user);
        }
        updateOrderStatus(order.getModelId(), OrderStatus.PROCESSING);
    }
}

public boolean isUploadCompletedForAllFiles(Order order) {
    long count = modelingFileRepository.countByOrder(order);
    return count == _3D_FILES_CNT;
}

Race Condition 발생 시나리오

_3D_FILES_CNT = 2인 경우, 두 요청이 거의 동시에 들어오면 다음 두 가지 문제가 발생할 수 있다.

케이스 1 — 크레딧 차감 누락

케이스 2 — 크레딧 이중 차감

⚠️ 파일 저장과 카운트 조회 사이의 아주 짧은 타이밍 차이만으로도 비즈니스 로직이 무너질 수 있다.


설계 개선: 업로드와 비즈니스 로직의 분리

근본적인 해결책은 파일 업로드 API에서 비즈니스 로직을 분리하는 것이다.

기존 구조

파일 업로드 API 파일 저장 + 완료 검사 + 크레딧 차감 + 상태 변경 (모든 책임이 한 곳에)

개선된 구조

파일 업로드 API 파일 저장만 담당
완료 확인 API 검증 + 상태 전이 + 크레딧 차감

클라이언트는 파일을 모두 올린 뒤, setInterval 등으로 완료 확인 API를 반복 호출한다. 서버는 이 API에서 모든 파일이 정상적으로 존재하는지 검증하고, 이상이 없으면 처리를 진행한다.

이 구조는 Race Condition의 복잡도를 낮춰준다. 파일 업로드처럼 "저장 직후 카운트"라는 타이밍 문제가 사라지고, 완료 확인 API에서 중복 호출 방지만 처리하면 되기 때문이다.


남아있는 문제: 완료 확인 API의 중복 호출

설계를 분리했더라도, 완료 확인 API 자체가 동시에 여러 번 호출될 수 있다.

결국 크레딧 차감이 중복으로 실행될 수 있다는 문제는 동일하게 남는다.


핵심 해결 원리: 상태 전이를 검문소로

검증과 상태 전이가 분리되어 있으면 항상 이 틈이 생긴다. 해결책은 상태 전이 자체를 원자적(atomic) 검문소로 만드는 것이다.

DB UPDATE의 원자성 활용

UPDATE 쿼리에 WHERE 조건을 추가하면, 조건을 만족하는 경우에만 업데이트가 수행된다.

UPDATE orders
SET status = 'PROCESSING'
WHERE model_id = 'abc' AND status = 'WAITING'

DB는 이 UPDATE 실행 시 행(row) 단위 락을 자동으로 건다. 동시에 두 요청이 들어오면 다음과 같이 처리된다.

affected rows로 처리 주체 구분

UPDATE 쿼리는 실행 후 실제로 변경된 행의 수를 반환한다. 이를 활용해 "내가 상태를 바꾼 주인공인지"를 판단할 수 있다.

// OrderRepository
@Modifying
@Query("""
    UPDATE Order o SET o.status = 'PROCESSING'
    WHERE o.modelId = :modelId AND o.status = 'WAITING'
""")
int transitionToProcessing(@Param("modelId") String modelId);
int updated = orderRepository.transitionToProcessing(modelId);
// updated == 1 : 내가 상태를 바꾼 주인공 → 크레딧 차감
// updated == 0 : 이미 다른 요청이 처리함 → 아무것도 안 함

💡 동시에 100개의 요청이 와도, updated == 1을 받는 요청은 전체 서버에서 단 하나뿐이다.

동시 요청 흐름 재정리


트랜잭션으로 데이터 정합성 보장

DB 락이 중복 실행을 막아주긴 하지만, 또 다른 문제가 존재한다.

⚠️ updated == 1로 상태 변경 후 크레딧 차감 도중 예외가 발생하면, 상태는 PROCESSING인데 크레딧은 차감되지 않은 불일치 상태가 된다.

@Transactional로 묶으면 크레딧 차감 실패 시 상태 전이도 함께 롤백된다.

@Transactional
public void confirmOrder(String modelId, Long userId) {
    Order order = getOrderByModelId(modelId);

    // 모든 파일 검증
    validateAllFilesUploaded(order);

    // 원자적 상태 전이
    int updated = orderRepository.transitionToProcessing(modelId);

    if (updated == 1) {
        // 이 블록은 전체 서버에서 단 한 번만 진입
        if (!hasPrivilegedRole(user)) {
            creditService.deductCredit(user); // 실패 시 상태 전이도 롤백
        }
    }
}

DB 락 vs 트랜잭션: 해결하는 문제가 다르다

DB 행 락 트랜잭션
목적 중복 실행 방지 부분 실패(불일치 상태) 방지
결과 updated 0 또는 1 전체 성공 또는 전체 롤백

💡 둘은 서로 다른 문제를 해결하므로 함께 사용해야 한다.


클라이언트 응답 설계

setInterval로 폴링하는 클라이언트가 언제 폴링을 멈출지 알 수 있도록, 응답 상태를 명확히 구분해줘야 한다.

// 아직 파일이 부족한 경우
{ "status": "WAITING" }

// 이번 요청에서 처리 완료된 경우 (updated == 1)
{ "status": "PROCESSING" }

// 이미 이전 요청에서 처리된 경우 (updated == 0)
{ "status": "ALREADY_DONE" }

ALREADY_DONEWAITING을 구분하지 않으면 클라이언트는 언제 폴링을 멈춰야 할지 알 수 없다.


최종 정리

문제 해결 방법
파일 업로드 타이밍 Race Condition 업로드 API에서 비즈니스 로직 분리
완료 확인 API 중복 호출 조건부 UPDATE (WHERE status = 'WAITING')
크레딧 차감 중 예외 발생 @Transactional로 원자적 처리
클라이언트 폴링 종료 시점 상태값 세분화 응답 (WAITING / PROCESSING / ALREADY_DONE)

핵심은 상태 전이를 검문소로 만드는 것이다. DB의 행 락과 조건부 UPDATE를 활용하면, 아무리 많은 중복 요청이 들어와도 비즈니스 로직은 정확히 한 번만 실행된다.