한 줄 요약
컨트롤러 내 컨트롤러 호출 → 트랜잭션 분리 → 파일 누락 발생, 서비스 계층에서 통합 Tx로 해결
현대 웹 애플리케이션에서 파일 업로드는 게시판, 이미지 첨부, 견적서·계약서 등 다양한 기능에서 필수 요소입니다. 특히, 비즈니스 로직상 첨부 파일이 반드시 등록되어야 하는 경우, 파일 시스템 저장과 데이터베이스 메타 정보의 원자성(atomicity) 보장이 생명입니다.
이번 포스트에서는:
- Ajax 요청을 2회로 분리하던 기존 로직에서 발생한 부분 커밋(orphan record) 문제
- 선언적 @Transactional 적용만으로는 해결 불가능했던 이유
- 서비스 계층에서의 명시적 트랜잭션 관리(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에 남아 불일치가 발생했습니다.
한계 요약
- OrderController와 FileController가 각각 AOP 프록시
- 기본 전파(REQUIRED)임에도 프록시 분리로 인해 새 트랜잭션 생성
- 첫 트랜잭션 커밋 후 파일 트랜잭션 실패 → 전체 롤백 불가
- 컨트롤러 간 호출로 SRP(단일 책임 원칙) 및 레이어드 아키텍처 위반
결론: 선언적 트랜잭션만으로는 다중 HTTP 호출 시나리오에서 원자성을 보장할 수 없음
3. 근본 원인 분석
- HTTP 요청 ≠ 트랜잭션 경계
- @Transactional AOP는 메서드 진입 시에만 동작
- 서로 다른 컨트롤러 메서드 호출 시 프록시가 바뀌어 트랜잭션이 분리됨
- Propagation(REQUIRED)의 오해
- 기본 전파 전략이라도 OrderController 프록시와 FileController 프록시가 서로 다른 TxContext로 취급
- Checked vs Runtime Exception
- Spring은 RuntimeException만 기본 롤백, IOException 같은 Checked는 rollbackFor에 명시해야 하나 트랜잭션 분리 탓에 효과 없음
- 레이어 설계 부재
- 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. 정리
- 파일 저장 순서: disk → DB 순서를 지켜야 유령 레코드 방지
- 예외 통합: checked Exception은 내부에서 RuntimeException으로 래핑
- 진행률 표시: 대용량 파일 업로드 시 단일 Ajax라도 progress 이벤트 활용
- 로깅: MDC.put("orderId", orderId)로 트랜잭션 흐름 추적
- 통합 테스트: @SpringBootTest + @Transactional 조합으로 DB·파일 시스템 검증
8. 결론
- 트랜잭션 경계는 HTTP 요청이 아닌 비즈니스 시나리오 단위로 설정
- 선언적 @Transactional만으론 다중 호출 시나리오에서 원자성을 보장 X
- 서비스 계층에서의 프로그램적 트랜잭션 제어를 통해 글과 파일을 한 덩어리로 처리
컨트롤러 내에서 다른 컨트롤러를 호출하는 방식은 트랜잭션 관리 측면에서 매우 위험합니다.
대신 비즈니스 로직과 트랜잭션 관리를 서비스 계층에서 명확히 정의하면 데이터 일관성을 보장하고 코드 품질을 높일 수 있었습니다.
'🗄️ Backend > Spring' 카테고리의 다른 글
Spring과 Spring Boot의 차이점 (1) | 2025.02.08 |
---|---|
Spring Boot 아키텍처 : Entity, DTO, Repository, Service, Controller 흐름과 활용 (1) | 2025.01.28 |
SpringBoot 비동기(Async) 처리 (6) | 2025.01.17 |
Spring Boot 기초: 웹 애플리케이션 설정 (0) | 2024.11.10 |
HikariCP, MyBatis를 활용한 데이터베이스 연결 풀 설정 및 최적화 (1) | 2024.11.10 |