시작: 간헐적으로 발생하는 이상한 버그

개발이 완료됐다고 생각한 기능을 테스트하던 중이었다. 파일을 업로드하면 주문 상태가 PROCESSING으로 바뀌어야 하는데, 간헐적으로 상태가 바뀌지 않는 현상이 발생했다.

처음에는 내가 변경한 코드 어딘가에 실수가 있는 줄 알았다. 변경사항이 많았기 때문에 여러 부분을 의심했고, 디버깅을 이어가다 보니 흥미로운 사실을 발견했다.

  • DB에는 파일 정보가 정상적으로 저장되어 있었다.
  • AWS S3에도 파일이 정상적으로 업로드되어 있었다.
  • 그런데 주문 상태값만 변경되지 않은 상태였다.

파일 자체는 문제없이 저장됐는데, 후속 처리만 누락된 것이었다. 더 자세히 살펴보니 프론트엔드에서 파일 업로드 retry가 내부적으로 발생하고 있었다. 두 개의 파일 업로드 요청이 거의 동시에 들어왔을 때, 백엔드 내부에서 해당 정보값이 동일한 시간대에 처리되면서 정상적으로 완료되지 않는 문제가 있었던 것이다.

처음에는 "DB는 데이터를 수정하거나 가져올 때 내부적으로 락 기능을 제공하니까 DB 문제는 아닐 것"이라고 생각했다. 그런데 상태값을 조회하는 시점이 문제였다. DB 락은 특정 시점의 쓰기 작업을 보호해주지만, "조회 후 업데이트"라는 두 단계 사이의 틈은 막아주지 않는다.

⚠️ 이것이 Race Condition이었다. DB와 S3에는 데이터가 멀쩡히 있는데 상태값만 안 바뀐다면 동시성 문제를 먼저 의심해야 한다.


문제의 코드

당시 파일 업로드 API는 다음과 같이 동작하고 있었다.

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

    User user = userService.getUser(userId);
    Order order = getOrderByModelId(modelId);

    // 파일 확장자 검증
    if (!FileValidator.isValidExtension(file)) {
        throw new ModelingFileException(ModelingFileExceptionType.INVALID_EXTENSION);
    }

    // 모델링 파일 객체 생성 및 저장
    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);
    }

    return ModelingFileUploadResult.builder()
            .toothNumber(toothNumber)
            .uuid(uuid)
            .allFilesUploaded(allFilesUploaded)
            .build();
}

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

_3D_FILES_CNT = 2인 경우, 두 파일 업로드 요청이 거의 동시에 들어오면 다음 두 가지 문제가 발생한다.

케이스 1 — 상태 업데이트 누락

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

파일 저장과 카운트 조회 사이의 아주 짧은 타이밍 차이만으로도 비즈니스 로직이 무너질 수 있는 구조였다. 실제로 발생한 버그는 케이스 1이었다.


리팩토링의 시작: 너무 많은 것을 하는 하나의 API

Race Condition을 인지한 뒤 코드를 다시 들여다보니, 파일 업로드 API 하나가 너무 많은 일을 하고 있었다.

// 컨트롤러
@PostMapping("/orders/{orderId}/3D")
public ResponseEntity register3DModelingFile(...) {

    // 1단계: DB 트랜잭션 (검증 + 저장 + 크레딧 차감 + 상태 업데이트)
    ModelingFileUploadResult result = orderService.processModelingFileUpload(...);

    // 2단계: 트랜잭션 밖 — S3 업로드
    s3Service.upload3DModelingFileToUserDataBucket(...);

    // 3단계: 모든 파일 업로드 완료 시 SQS 전송
    if (result.isAllFilesUploaded()) {
        sqsService.sendModelingTaskMessage(...);
    }
}

파일 저장, S3 업로드, 크레딧 차감, 주문 상태 변경, SQS 메시지 전송을 단 하나의 API가 처리하고 있었다. 이 구조가 Race Condition을 더 복잡하게 만들고 있었다. 파일 저장이라는 단순한 작업에 크레딧 차감이라는 민감한 비즈니스 로직이 섞여 있으니, 동시성 문제를 해결하기 위해 업로드 API 전체에 락을 걸어야 하는 상황이 되어버린 것이다.

코드를 보면서 추가적인 문제도 발견했다.

// 생성하고 updateFileType 호출하지만 반환도 안 하고 사용도 안 함 → dead code
ModelingFileDTO modelingFileDTO = modelingFileService.getModelingFileDTO(modelingFile);
modelingFileDTO.updateFileType(toothNumber);

실제로 사용되지 않는 코드가 그대로 남아있었고, 반환 DTO의 이름도 역할을 제대로 반영하지 못하고 있었다. ModelingFileUploadResult라는 이름인데 실제로 담긴 값은 S3 업로드 경로 구성에 필요한 toothNumberuuid였다.


설계 개선: 업로드와 완료 처리의 분리

리팩토링의 방향은 명확했다. 파일 업로드 API는 파일 저장만 담당하고, 완료 처리는 별도의 API로 분리한다.

POST /orders/{orderId}/3D — 파일 업로드

  • 파일 확장자 검증
  • 파일 중복 요청 검증
  • DB 저장
  • S3 업로드
↓ 프론트엔드 setInterval 폴링

POST /orders/{orderId}/3D/confirm — 완료 확인 신규

  • 모든 파일 완비 검증
  • 조건부 상태 전이 (PENDING → PROCESSING)
  • 크레딧 차감
  • SQS 전송

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


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

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

"상태 조회 후 업데이트"라는 두 단계가 분리되어 있는 한 항상 이 틈이 생긴다.


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

해결책은 상태 전이를 원자적(atomic) 연산 하나로 만드는 것이다.

DB UPDATE의 원자성 활용

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

DB는 이 UPDATE 실행 시 행(row) 단위 락을 자동으로 건다.

💡 조건부 UPDATE 하나가 조회와 업데이트를 원자적으로 처리해서, "조회 후 업데이트" 사이의 틈 자체를 없애버린다.

affected rows로 처리 주체 구분

int updated = orderRepository.updateStatusIfMatch(
        modelId,
        OrderStatus.PROCESSING,
        OrderStatus.PENDING
);
// updated == 1 : 내가 상태를 바꾼 주인공 → 크레딧 차감
// updated == 0 : 이미 다른 요청이 처리함 → 아무것도 안 함

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

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

DB 락으로 중복 실행을 막았다고 해서 끝이 아니다.

⚠️ 크레딧 차감 도중 예외가 발생하면 상태는 PROCESSING인데 크레딧은 차감 안 된 불일치 상태가 된다. @Transactional로 묶어야 크레딧 차감 실패 시 상태 전이도 함께 롤백된다.

DB 행 락트랜잭션
목적 중복 실행 방지 부분 실패로 인한 데이터 불일치 방지
결과 updated 0 또는 1 전체 성공 또는 전체 롤백

최종 구현

OrderRepository — 조건부 상태 전이 쿼리

// 조건부 상태 전이 — expectedStatus 일 때만 newStatus 로 변경
// 반환값: 1 = 변경 성공 (처리 주체), 0 = 이미 처리됨 (중복 요청)
@Modifying(clearAutomatically = true)
@Query("UPDATE Order o SET o.orderStatus = :newStatus " +
       "WHERE o.modelId = :modelId AND o.orderStatus = :expectedStatus")
int updateStatusIfMatch(
    @Param("modelId") String modelId,
    @Param("newStatus") OrderStatus newStatus,
    @Param("expectedStatus") OrderStatus expectedStatus
);

💡 clearAutomatically = true를 추가한 이유: @Modifying 쿼리는 영속성 컨텍스트를 무시하고 DB에 직접 쿼리를 날린다. 같은 트랜잭션 안에 이미 1차 캐시에 올라온 Order 엔티티가 있다면 DB 상태와 불일치가 생길 수 있어서, 쿼리 실행 후 1차 캐시를 자동으로 초기화하도록 설정했다.

ConfirmUploadResult DTO

@Getter
@Builder
public class ConfirmUploadResult {

    // 모든 파일이 준비됐는지 여부 — false 시 클라이언트 폴링 계속
    private final boolean ready;

    // 이미 다른 요청이 처리했는지 여부 — true 시 SQS 전송 건너뜀
    private final boolean alreadyProcessed;

    private final short toothNumber;
    private final String uuid;

    public static ConfirmUploadResult notReady() {
        return ConfirmUploadResult.builder()
                .ready(false)
                .alreadyProcessed(false)
                .build();
    }
}

OrderService — confirmModelingUpload

@Transactional
public ConfirmUploadResult confirmModelingUpload(String modelId, Long userId) {

    User user = userService.getUser(userId);
    Order order = getOrderByModelId(modelId);

    // 모든 파일이 정상적으로 업로드됐는지 검증
    boolean allFilesUploaded = modelingFileService.isUploadCompletedForAllFiles(order);
    if (!allFilesUploaded) {
        return ConfirmUploadResult.notReady();
    }

    // 조건부 상태 전이: PENDING → PROCESSING
    // 동시 요청이 들어와도 updated == 1 을 받는 요청은 단 하나
    int updated = orderRepository.updateStatusIfMatch(
            modelId,
            OrderStatus.PROCESSING,
            OrderStatus.PENDING
    );

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

    short toothNumber = toothService.getStartToothNumber(order);
    String uuid = StableUUIDGenerator.generateUUIDFromString(modelId).toString();

    return ConfirmUploadResult.builder()
            .ready(true)
            .alreadyProcessed(updated == 0)
            .toothNumber(toothNumber)
            .uuid(uuid)
            .build();
}

OrderController — 완료 확인 엔드포인트

/**
 * 3D 모델링 파일 업로드 완료 확인
 * - 프론트엔드 setInterval 폴링 대상
 * - 모든 파일 완비 확인 + 크레딧 차감 + 상태 전이 + SQS 전송
 */
@PostMapping("/orders/{orderId}/3D/confirm")
public ResponseEntity<ConfirmUploadResult> confirmModelingFileUpload(
        @PathVariable(name = "orderId") String modelId,
        @AuthenticationPrincipal PrincipalDetails principalDetails) {

    UserLoginDTO userLoginDTO = principalDetails.getUserLoginDTO();
    ConfirmUploadResult result = orderService.confirmModelingUpload(modelId, userLoginDTO.getId());

    // 파일 미완비 — 클라이언트 폴링 계속
    if (!result.isReady()) {
        return new ResponseEntity<>(result, HttpStatus.ACCEPTED); // 202
    }

    // 이번 요청이 처리 주체일 때만 SQS 전송
    if (!result.isAlreadyProcessed()) {
        sqsService.sendModelingTaskMessage(
                modelId,
                result.getUuid(),
                userLoginDTO.getId(),
                result.getToothNumber());
    }

    return new ResponseEntity<>(result, HttpStatus.OK); // 200 → 클라이언트 폴링 중단
}

HTTP 상태 코드 설계

상태 코드의미클라이언트 동작
202 Accepted 파일 아직 미완비 폴링 계속
200 OK 처리 완료 폴링 중단

202 Accepted를 사용한 이유는 단순히 성공/실패를 넘어서, "아직 처리 중"이라는 상태를 명시적으로 클라이언트에 전달하기 위해서다.


프론트엔드 연동

기존에 이미 verifyAndRetryUploadIfNeeded 함수가 존재했고, 내부적으로 setInterval로 주문 상태를 폴링하는 구조였다. 기존 방식과 변경된 방식을 비교하면 다음과 같다.

// 기존: GET /orders/{orderId} 로 상태가 PROCESSING 이 됐는지 확인
async function fetchOrderStatus(orderId) {
  const response = await fetch(Config.backendServerPath(`/orders/${orderId}`), fetchData);
  const responseData = await response.json();
  return responseData.status;
}
// 변경: POST /orders/{orderId}/3D/confirm 으로 완료 처리까지 한번에
async function pollUntilProcessing() {
  return new Promise((resolve, reject) => {
    const startedAt = Date.now();

    const timer = setInterval(async () => {
      try {
        const result = await confirmModelingUpload(getModelIdFromUrl());

        if (result.ready) {
          clearInterval(timer);
          resolve();
          return;
        }

        if (Date.now() - startedAt >= VERIFY_TIMEOUT_MS) {
          clearInterval(timer);
          reject(new Error('Upload verification timed out.'));
        }
      } catch (err) {
        clearInterval(timer);
        reject(err);
      }
    }, VERIFY_POLL_INTERVAL_MS);
  });
}

async function confirmModelingUpload(modelId) {
  const response = await fetch(
    Config.backendServerPath(`/orders/${modelId}/3D/confirm`),
    { method: 'POST', headers: new Headers({ 'Authorization': 'Bearer ' + getAccessToken() }) }
  );

  if (response.status === 202) return { ready: false };
  if (response.ok) return await response.json();

  throw new Error('Upload confirmation failed.');
}

전체 흐름 정리

[최종 버튼 클릭]uploadMesh(stl) + uploadMesh(bds) ← Promise.allSettled 병렬 업로드 ↓ 둘 다 성공 verifyAndRetryUploadIfNeeded() └─ pollUntilProcessing() └─ setInterval: POST /orders/{orderId}/3D/confirm 폴링 ├─ 202 → 파일 미완비, 3초 대기 후 재시도 └─ 200 → ready: true, 폴링 중단 └─ 서버: 조건부 UPDATE ├─ updated == 1 → 크레딧 차감 + SQS 전송 └─ updated == 0 → 이미 처리됨, SQS 건너뜀 ↓ 타임아웃 시 └─ retryUploadFromDB() ← IndexedDB에서 파일 꺼내 재전송 ↓ dbDelete → showAlert → postMessage

마무리

이번 작업을 통해 얻은 핵심 교훈을 정리했다.

Lesson 01

간헐적인 버그는 동시성 문제일 가능성이 높다

재현이 잘 안 되고 로그도 깔끔하게 남는데 결과만 이상하다면, 여러 요청이 겹치는 타이밍을 먼저 의심해봐야 한다. DB와 S3에는 데이터가 멀쩡히 있는데 상태값만 안 바뀐다면 더욱 그렇다.

Lesson 02

DB 락이 모든 동시성 문제를 해결해주지는 않는다

DB 행 락은 특정 행에 대한 쓰기를 직렬화해주지만, "조회 후 업데이트"라는 두 단계 사이의 틈은 막아주지 않는다. 이 틈을 없애려면 조건부 UPDATE 하나로 조회와 업데이트를 원자적으로 처리해야 한다.

Lesson 03

하나의 API가 너무 많은 책임을 지면 동시성 문제를 해결하기 어렵다

파일 저장이라는 단순한 작업에 크레딧 차감이라는 민감한 로직이 섞여 있으면, 문제를 해결하기 위해 업로드 API 전체에 락을 걸어야 하는 상황이 된다. 책임을 분리하면 각 API가 해결해야 할 동시성 문제의 범위가 명확해진다.

Lesson 04

DB 락과 트랜잭션은 함께 사용해야 한다

DB 락은 중복 실행을 막고, 트랜잭션은 부분 실패로 인한 데이터 불일치를 막는다. 크레딧처럼 민감한 데이터를 다룰 때는 두 가지를 모두 챙겨야 한다.