Spring · 데이터 접근과 트랜잭션
서버는 결국 DB에서 데이터를 읽고 쓰는 일을 합니다. Spring은 그 일을 하는 여러 도구를 제공하고, 여러 작업을 한 묶음(트랜잭션)으로 묶어 "전부 성공 아니면 전부 취소"를 보장해 줍니다. 이 프로젝트(Spring Boot 3.3.1 / Spring Framework 6.x / Java 17 / Jakarta EE 10)가 쓰는 데이터 접근 방식과 @Transactional? 을 순서대로 봅니다.
1. 데이터 접근 방식 개요
같은 DB라도 접근하는 방법은 여럿입니다. 이 프로젝트는 학습 목적상 아래 도구들을 두루 씁니다. 모두 ORM? 이거나, SQL을 직접 다루는 방식입니다.
| 방식 | 용도 (한 줄) | 장점 | 단점 |
|---|---|---|---|
| JPA? | 객체(엔티티?) ↔ 테이블 매핑 표준 | SQL 거의 안 써도 됨, 객체 중심 | 복잡 쿼리·성능 튜닝은 어려움 |
| Spring Data JPA | 리포지토리? 인터페이스만 선언하면 구현 자동 생성 | CRUD·메서드 이름 쿼리 자동, 코드 최소 | 메서드 이름이 길어지고 동적 쿼리에 약함 |
| QueryDSL 5.1.0 | 타입 세이프한 동적 쿼리 빌더 | 컴파일 단계에서 오타·타입 오류 잡힘 | Q클래스 생성 빌드 설정 필요 |
| jOOQ | Java 코드로 SQL을 쓰는 SQL DSL | SQL에 가까워 복잡 쿼리에 강함 | 스키마 기반 코드 생성 필요 |
| MyBatis 3.0.3 | XML/애너테이션에 적은 SQL을 메서드에 매핑 | SQL을 손으로 완전 제어 | SQL을 직접 다 작성해야 함 |
| JdbcTemplate / data-jdbc | 가장 낮은 수준의 JDBC 보일러플레이트 제거 | 가볍고 단순, 의존성 적음 | 매핑·쿼리를 직접 다뤄야 함 |
실무에서는 보통 한두 가지로 통일하지만, 여기서는 같은 화면을 다른 도구로 만들어 보며 차이를 익힙니다. 어떤 방식이든 트랜잭션 경계는 똑같이 Service 계층에서 잡습니다.
2. 레이어와 트랜잭션 경계
컨트롤러 → 서비스 → 리포지토리 구조에서, 트랜잭션은 보통 Service 메서드에 겁니다. 한 비즈니스 동작(예: 주문 생성)이 여러 DB 작업으로 이뤄지더라도, 그 묶음 전체가 하나의 트랜잭션이 되도록 하기 위해서입니다.
@RestController ← 요청/응답 (트랜잭션 없음) │ ▼ @Service ← @Transactional 으로 트랜잭션 시작·커밋/롤백 │ ▼ Repository ← DB 접근 (JPA / QueryDSL / jOOQ / MyBatis) │ ▼ PostgreSQL
서비스 메서드가 시작될 때 트랜잭션이 열리고, 정상 종료되면 커밋, 예외로 끝나면 롤백됩니다. 컨트롤러나 리포지토리가 아니라 서비스에 거는 이유는, "하나의 업무 단위"의 경계가 서비스 메서드와 일치하기 때문입니다.
3. @Transactional 상세
@Transactional 은 메서드 또는 클래스에 붙일 수 있습니다. 클래스에 붙이면 그 클래스의 모든 public 메서드에 기본 적용되고, 메서드에 따로 붙이면 그 메서드만 별도 설정으로 덮어씁니다.
속성 전부
| 속성 | 기본값 | 설명 |
|---|---|---|
propagation | REQUIRED | 전파 방식 (아래 표 참고) |
isolation | DEFAULT | 격리 수준. DB 기본값을 따름. REQUIRED/REQUIRES_NEW에만 의미가 있음 |
readOnly | false | true 면 읽기 전용 힌트 → 조회 최적화 |
timeout | 시스템 기본 | 초 단위 제한 시간. 넘으면 롤백 |
rollbackFor | 없음 | 이 예외(들)면 롤백하도록 추가 지정 |
noRollbackFor | 없음 | 이 예외(들)면 롤백하지 말도록 지정 |
전파(propagation) — 7가지
"이미 진행 중인 트랜잭션이 있을 때, 이 메서드는 어떻게 합류/분리할지"를 정합니다.
| 값 | 동작 |
|---|---|
| REQUIRED (기본) | 진행 중 트랜잭션이 있으면 합류, 없으면 새로 시작 |
| REQUIRES_NEW | 항상 새 트랜잭션을 시작. 기존 것은 잠시 멈춤(suspend) |
| NESTED | 기존 트랜잭션 안에 중첩(세이브포인트). 일부만 롤백 가능 |
| SUPPORTS | 있으면 합류, 없으면 트랜잭션 없이 실행 |
| NOT_SUPPORTED | 트랜잭션 없이 실행. 있으면 잠시 멈춤 |
| MANDATORY | 반드시 기존 트랜잭션이 있어야 함. 없으면 예외 |
| NEVER | 트랜잭션이 있으면 안 됨. 있으면 예외 |
기본 롤백 규칙 — 매우 중요
Spring의 기본값은 직관과 다를 수 있어 꼭 기억해야 합니다.
| 예외 종류 | 기본 동작 |
|---|---|
언체크 예외 (RuntimeException, Error) | 롤백 |
체크 예외 (Exception 계열, 예: IOException) | 커밋 (롤백 안 함!) |
즉 체크 예외가 터져도 기본으로는 롤백되지 않고 커밋됩니다. 이게 의도와 다르다면 rollbackFor 로 바꿔야 합니다.
// 체크 예외에도 롤백하고 싶을 때
@Transactional(rollbackFor = Exception.class)
public void process() throws IOException { ... }
// 특정 런타임 예외는 롤백에서 제외하고 싶을 때
@Transactional(noRollbackFor = NotFoundException.class)
public void find() { ... }서비스 안에서 예외를 try/catch 로 삼켜버리면(다시 던지지 않으면) 트랜잭션은 예외를 보지 못해 롤백되지 않고 커밋됩니다. 롤백이 필요하면 예외를 다시 던지거나 처리 방식을 바꾸세요.
프록시 기반 동작 — 함정 주의
@Transactional 은 Spring이 만든 프록시(대리 객체)가 메서드 호출을 가로채서 앞뒤로 트랜잭션을 시작·종료하는 방식으로 동작합니다. 이 구조 때문에 다음 함정이 있습니다.
- 같은 클래스 내부 호출(self-invocation)은 적용 안 됨 —
this.otherMethod()처럼 자기 자신을 직접 부르면 프록시를 거치지 않아 그 메서드의@Transactional이 무시됩니다. - 메서드 가시성 — 인터페이스 기반(JDK) 프록시에서는
public메서드만 적용됩니다. 다만 Spring Framework 6.0부터 클래스 기반(CGLIB) 프록시에서는protected·package-private 메서드도 트랜잭션이 적용됩니다(Boot 3.3은 6.x). 그래도 혼선을 줄이려면public으로 두는 편이 안전합니다. - final 금지 — CGLIB 프록시는 상속(서브클래스)으로 동작하므로
final메서드/클래스는 오버라이드할 수 없어 트랜잭션이 적용되지 않습니다. @PostConstruct같은 초기화 코드에서는 프록시가 아직 준비되지 않아 의존하면 안 됩니다.
@Service
public class OrderService {
public void outer() {
this.inner(); // ❌ 같은 객체 직접 호출 → inner 의 @Transactional 무시됨
}
@Transactional
public void inner() { ... }
}4. 선언적 vs 프로그래밍적 트랜잭션
위에서 본 @Transactional 처럼 애너테이션으로 선언만 하는 방식을 선언적 트랜잭션이라 하고, 대부분 이 방식을 씁니다. 더 세밀한 제어가 필요하면 코드로 직접 다루는 프로그래밍적 트랜잭션도 있습니다.
// TransactionTemplate — 콜백 안에서 실행, 예외 시 자동 롤백
transactionTemplate.execute(status -> {
repo.save(a);
repo.save(b);
return null;
});
// PlatformTransactionManager — 가장 저수준, 직접 begin/commit/rollback보통은 선언적(@Transactional)으로 충분하고, 한 메서드 안에서 부분적으로만 트랜잭션을 걸고 싶을 때 프로그래밍적 방식을 고려합니다.
5. DataSource와 커넥션 풀(HikariCP)
DB에 연결하려면 매번 새 연결을 여는 대신, 미리 만들어 둔 연결들을 빌려 쓰고 반납하는 커넥션 풀을 씁니다. DataSource 가 그 연결을 내주는 창구이고, Spring Boot는 기본 커넥션 풀로 HikariCP 를 자동 설정합니다.
트랜잭션이 시작될 때 풀에서 연결 하나를 빌려와 메서드가 끝날 때까지 같은 연결을 쓰고, 커밋/롤백 후 풀에 반납합니다. 별도 설정 없이도 부트 기본값(HikariCP)으로 동작합니다.
6. 예외 변환 — @Repository
JPA·JDBC·MyBatis 등은 저마다 다른 예외를 던집니다. Spring은 @Repository? 가 붙은 빈의 영속성 예외를, 기술에 무관한 공통 계층인 DataAccessException(스프링의 런타임 예외) 계층으로 변환해 줍니다.
덕분에 서비스 코드는 "JPA 예외냐 MyBatis 예외냐"를 신경 쓰지 않고 한 종류로 다룰 수 있습니다. 또 DataAccessException 은 언체크(런타임) 예외라서, 위의 기본 롤백 규칙에 따라 자동으로 롤백됩니다. Spring Data 리포지토리는 이 변환이 기본 적용됩니다.
7. readOnly 트랜잭션과 조회 성능
조회만 하는 메서드에는 @Transactional(readOnly = true) 를 거는 게 좋습니다. "이 트랜잭션은 데이터를 바꾸지 않는다"는 힌트라서 다음 이점이 있습니다.
- JPA는 변경 감지(dirty checking)용 스냅샷을 만들지 않아 메모리·연산을 아낍니다.
- 불필요한 flush를 줄여 조회가 가벼워집니다.
- 읽기 전용 의도가 코드에 드러나 안전합니다(실수로 쓰기 방지).
예제 — 쓰기와 읽기 트랜잭션
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepo userRepo;
// 쓰기: 기본 트랜잭션. 예외(런타임) 시 전체 롤백
@Transactional
public Long register(UserDTO dto) {
User user = User.of(dto.getName(), dto.getEmail());
userRepo.save(user); // 한 묶음으로 처리
return user.getId();
}
// 읽기 전용: 조회 최적화
@Transactional(readOnly = true)
public List<UserDTO> findAll() {
return userRepo.findAll().stream()
.map(UserDTO::from)
.toList();
}
}쓰기 메서드는 중간에 런타임 예외가 나면 그때까지의 변경이 모두 취소됩니다. 읽기 메서드는 readOnly = true 로 불필요한 처리를 생략합니다.
한눈에 — @Transactional 속성/주의
| 항목 | 요점 |
|---|---|
propagation | 기본 REQUIRED. 독립 실행은 REQUIRES_NEW |
isolation | 기본 DEFAULT(DB 따름) |
readOnly | 조회 메서드는 true 로 최적화 |
timeout | 초 단위 제한, 초과 시 롤백 |
| 기본 롤백 | 언체크 예외만 롤백, 체크 예외는 커밋 |
rollbackFor | 체크 예외도 롤백시키려면 지정 |
| self-invocation | this.method() 내부 호출은 적용 안 됨 |
| 메서드 가시성 | public 권장, final 금지 |
| 예외 변환 | @Repository → DataAccessException(런타임) |
| 경계 | 보통 Service 메서드에 부여 |
이 프로젝트의 서비스 계층에서 트랜잭션을 어떻게 거는지, 그리고 JPA·QueryDSL·jOOQ·MyBatis로 같은 데이터를 어떻게 다루는지는 백엔드 트랙에서 실제 코드로 이어집니다: be-03 서비스 계층 과 be-04 데이터 접근 을 보세요.
다음 단계
- 입력값을 안전하게 검사하기 → Spring · 검증(Validation)
- 객체로 테이블을 다루는 JPA 깊이 보기 → JPA