배경
파일 업로드 서비스를 개발하다 보면 자연스럽게 다음과 같은 구조를 만들게 된다.
- 사용자가 파일을 업로드한다
- 모든 파일이 업로드되면 크레딧을 차감하고 주문 상태를 변경한다
단순해 보이는 이 흐름에는 동시성 문제(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에서 비즈니스 로직을 분리하는 것이다.
기존 구조
개선된 구조
클라이언트는 파일을 모두 올린 뒤, 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_DONE과 WAITING을 구분하지 않으면 클라이언트는 언제 폴링을 멈춰야 할지 알 수 없다.
최종 정리
| 문제 | 해결 방법 |
|---|---|
| 파일 업로드 타이밍 Race Condition | 업로드 API에서 비즈니스 로직 분리 |
| 완료 확인 API 중복 호출 | 조건부 UPDATE (WHERE status = 'WAITING') |
| 크레딧 차감 중 예외 발생 | @Transactional로 원자적 처리 |
| 클라이언트 폴링 종료 시점 | 상태값 세분화 응답 (WAITING / PROCESSING / ALREADY_DONE) |
핵심은 상태 전이를 검문소로 만드는 것이다. DB의 행 락과 조건부 UPDATE를 활용하면, 아무리 많은 중복 요청이 들어와도 비즈니스 로직은 정확히 한 번만 실행된다.