🗄️ Backend/Spring

[트러블슈팅] Spring Tx 이중 Ajax 업로드 트랜잭션 처리 로직 해결

hjwjo 2025. 7. 10. 10:21

한 줄 요약

컨트롤러 내 컨트롤러 호출 → 트랜잭션 분리 → 파일 누락 발생, 서비스 계층에서 통합 Tx로 해결


현대 웹 애플리케이션에서 파일 업로드는 게시판, 이미지 첨부, 견적서·계약서 등 다양한 기능에서 필수 요소입니다. 특히, 비즈니스 로직상 첨부 파일이 반드시 등록되어야 하는 경우, 파일 시스템 저장과 데이터베이스 메타 정보의 원자성(atomicity) 보장이 생명입니다.

이번 포스트에서는:

  1. Ajax 요청을 2회로 분리하던 기존 로직에서 발생한 부분 커밋(orphan record) 문제
  2. 선언적 @Transactional 적용만으로는 해결 불가능했던 이유
  3. 서비스 계층에서의 명시적 트랜잭션 관리(programmatic transaction management) 도입으로 완벽히 문제를 해결한 과정

순서대로 상세히 정리합니다.

1. 기존 문제 상황

기존의 공지사항 등록 로직은 두 번의 Ajax 요청으로 나누어 진행했습니다.

  • 첫 번째 요청(Ajax): 게시글 텍스트 데이터만 DB에 저장
  • 두 번째 요청(Ajax): 첨부파일을 서버에 저장하고 파일 정보를 DB에 저장

공지사항과 같은 기능에서는 첨부파일이 필수가 아니어서 이 방식이 문제가 없었지만, 파일이 필수인 신규 기능에서 문제가 발생했습니다. 파일 업로드 도중 오류가 나면 텍스트 데이터만 DB에 남고 파일은 누락되는 데이터 불일치가 발생한 것입니다.

1.1 기존 아키텍처

// 1) 텍스트 저장
POST /order/register
FormData: { title, content, userId }

// 2) 파일 업로드
POST /order/register/file
FormData: { orderNo, files[] }
  • Text Endpoint: order_table에 글 정보만 INSERT
  • File Endpoint: 실제 파일 시스템에 저장 후 file_table에 메타 INSERT

1.2 문제 상황

  • 파일 저장 중 IOException 등 예외 발생 시
    • 두 번째 Ajax(#2) 실패 → rollback 시도
    • 하지만 첫 번째 Ajax(#1)로 커밋된 글 정보는 삭제되지 않음
  • 결과: 글만 남고 파일은 누락된 Orphan Record 생성
// 발생 예시
{ orderNo: "12345", title: "견적 요청", content: "내용..." }
// 파일 누락 → 트랜잭션 불일치

첨부가 선택 사항인 게시판 기능에서는 눈치채지 못했으나, 첨부가 필수인 신규 기능(예: 계약서 업로드)에서는 서비스 장애로 직결됨

 


2.초기 대응 시도와 한계

선언적 트랜잭션(@Transactional) 사용 및

기존 컨트롤러에서 아래와 같이 공통 컨트롤러(FileController)를 직접 호출했습니다.

@RestController
public class OrderController {

    @Autowired
    private FileController fileController;

    @PostMapping("/order/register")
    @Transactional(rollbackFor = Exception.class)
    public ResponseEntity<?> register(
            @RequestParam Map<String,Object> params,
            MultipartHttpServletRequest request) {
        try {
            // 1) 글 저장
            String orderId = orderService.saveText(params);

            // 2) 파일 처리 (다른 컨트롤러 직접 호출)
            boolean ok = fileController.uploadFiles(orderId, request);
            if (!ok) throw new RuntimeException("파일 처리 실패");

            return okResponse(orderId);
        } catch (Exception e) {
            return errorResponse();
        }
    }
}

겉으로는 트랜잭션 하나로 묶인 것처럼 보였지만, 실제로는 FileController 호출 시 새로운 트랜잭션이 생성되어 텍스트 저장과 파일 저장이 서로 별도의 트랜잭션으로 동작했습니다. 이로 인해 파일 저장 단계에서 예외가 발생하면 텍스트 데이터만 DB에 남아 불일치가 발생했습니다.

한계 요약

  1. OrderControllerFileController각각 AOP 프록시
  2. 기본 전파(REQUIRED)임에도 프록시 분리로 인해 새 트랜잭션 생성
  3. 첫 트랜잭션 커밋 후 파일 트랜잭션 실패 → 전체 롤백 불가
  4. 컨트롤러 간 호출로 SRP(단일 책임 원칙) 및 레이어드 아키텍처 위반

결론: 선언적 트랜잭션만으로는 다중 HTTP 호출 시나리오에서 원자성을 보장할 수 없음


3. 근본 원인 분석

  1. HTTP 요청 ≠ 트랜잭션 경계
    • @Transactional AOP는 메서드 진입 시에만 동작
    • 서로 다른 컨트롤러 메서드 호출 시 프록시가 바뀌어 트랜잭션이 분리됨
  2. Propagation(REQUIRED)의 오해
    • 기본 전파 전략이라도 OrderController 프록시와 FileController 프록시가 서로 다른 TxContext로 취급
  3. Checked vs Runtime Exception
    • Spring은 RuntimeException만 기본 롤백, IOException 같은 Checked는 rollbackFor에 명시해야 하나 트랜잭션 분리 탓에 효과 없음
  4. 레이어 설계 부재
    • Controller ↔ Controller 호출은 계층 간 책임이 불분명해지고, 유지보수·테스트·가독성 모두 악화

 

4. 해결 방법: 서비스 계층에서 통합 트랜잭션 관리

설계 원칙 재정의

  • 트랜잭션 경계는 도메인 시나리오 단위
  • 컨트롤러: 요청/응답 처리, 파라미터 유효성 검사
  • 서비스: 비즈니스 로직 + 트랜잭션 제어
  • 저장소/파일 서비스: 실제 데이터 저장

서비스 레이어에서 트랜잭션을 명시적으로 정의하여 두 작업(텍스트 저장, 파일 저장)을 하나의 트랜잭션으로 묶어 해결했습니다.

서비스 계층 코드

@Service
public class OrderService {

    @Autowired
    private PlatformTransactionManager txManager;

    @Autowired
    private OrderRepository orderRepo;

    @Autowired
    private FileStorageService fileService;

    /**
     * 주문 및 파일 동시 처리
     * @return 주문ID
     */
    public String registerOrder(
            Map<String,Object> params,
            List<MultipartFile> files) {

        // 1) 트랜잭션 시작
        DefaultTransactionDefinition def = new DefaultTransactionDefinition();
        def.setPropagationBehavior(
            TransactionDefinition.PROPAGATION_REQUIRED);
        TransactionStatus status = txManager.getTransaction(def);

        try {
            // 2) 텍스트 저장
            String orderId = orderRepo.save(params);

            // 3) 파일 저장 + 메타
            fileService.storeFiles(orderId, files);

            // 4) 커밋
            txManager.commit(status);
            return orderId;

        } catch (Exception ex) {
            // 5) 전체 롤백
            txManager.rollback(status);
            throw new RuntimeException(
                "주문 등록 중 오류 발생", ex);
        }
    }
}

간소화된 컨트롤러 코드

@RestController
public class OrderController {

    @Autowired
    private OrderService orderService;

    @PostMapping("/order/register")
    public ResponseEntity<?> register(
            @RequestParam Map<String,Object> params,
            MultipartHttpServletRequest request) {
        try {
            String orderId = orderService.registerOrder(
                params,
                request.getFiles("files")
            );
            return okResponse(orderId);
        } catch (Exception e) {
            return errorResponse();
        }
    }
}

한 번의 서비스 호출이 글 + 파일 전체를 원자적으로 처리

4. 비교 및 장단점

구분초기 방식 (Controller Tx)최적화 방식 (Service Tx)

Tx 경계 컨트롤러별 분리 서비스 단일
원자성 보장 부분 커밋 (Orphan 발생) 완전 보장
레이어 분리 위반 (Controller 간 호출) 준수 (Controller→Service→Repo)
코드 복잡도 ↓ (직관적) ↑ (TxManager 사용)
테스트 용이성 어려움 용이 (단위 테스트 가능)
 

5. 컨트롤러 직접 호출 방식의 문제점

  • 트랜잭션 분리: 컨트롤러 내에서 다른 컨트롤러 호출 시 트랜잭션이 분리되어 데이터 일관성이 깨짐
  • 예외 처리 혼란: Checked Exception 처리 시 롤백이 제대로 안 될 가능성
  • 레이어 위반: 컨트롤러는 요청 처리만 담당해야 하며, 비즈니스 로직 호출이나 다른 컨트롤러 직접 호출은 레이어드 아키텍처 위반

6. 서비스 계층 Tx 방식의 장점

  • 명확한 트랜잭션 경계: 비즈니스 로직의 시작과 끝을 하나의 트랜잭션으로 명확히 묶음
  • 예외 처리 명료: 서비스 계층에서 명시적 롤백 처리가 가능해 예외 상황 관리가 용이
  • 테스트 및 유지보수 용이: 서비스 레이어만 별도로 테스트가 가능하고 코드 가독성이 높아짐

7. 정리

  1. 파일 저장 순서: disk → DB 순서를 지켜야 유령 레코드 방지
  2. 예외 통합: checked Exception은 내부에서 RuntimeException으로 래핑
  3. 진행률 표시: 대용량 파일 업로드 시 단일 Ajax라도 progress 이벤트 활용
  4. 로깅: MDC.put("orderId", orderId)로 트랜잭션 흐름 추적
  5. 통합 테스트: @SpringBootTest + @Transactional 조합으로 DB·파일 시스템 검증

8. 결론

  • 트랜잭션 경계HTTP 요청이 아닌 비즈니스 시나리오 단위로 설정
  • 선언적 @Transactional만으론 다중 호출 시나리오에서 원자성을 보장 X
  • 서비스 계층에서의 프로그램적 트랜잭션 제어를 통해 글과 파일을 한 덩어리로 처리

컨트롤러 내에서 다른 컨트롤러를 호출하는 방식은 트랜잭션 관리 측면에서 매우 위험합니다.
대신 비즈니스 로직과 트랜잭션 관리를 서비스 계층에서 명확히 정의하면 데이터 일관성을 보장하고 코드 품질을 높일 수 있었습니다.