프레임워크 · Spring · 데이터 접근 · 트랜잭션

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 메서드에 기본 적용되고, 메서드에 따로 붙이면 그 메서드만 별도 설정으로 덮어씁니다.

속성 전부

속성기본값설명
propagationREQUIRED전파 방식 (아래 표 참고)
isolationDEFAULT격리 수준. DB 기본값을 따름. REQUIRED/REQUIRES_NEW에만 의미가 있음
readOnlyfalsetrue 면 읽기 전용 힌트 → 조회 최적화
timeout시스템 기본초 단위 제한 시간. 넘으면 롤백
rollbackFor없음이 예외(들)면 롤백하도록 추가 지정
noRollbackFor없음이 예외(들)면 롤백하지 말도록 지정

전파(propagation) — 7가지

"이미 진행 중인 트랜잭션이 있을 때, 이 메서드는 어떻게 합류/분리할지"를 정합니다.

동작
REQUIRED (기본)진행 중 트랜잭션이 있으면 합류, 없으면 새로 시작
REQUIRES_NEW항상 새 트랜잭션을 시작. 기존 것은 잠시 멈춤(suspend)
NESTED기존 트랜잭션 안에 중첩(세이브포인트). 일부만 롤백 가능
SUPPORTS있으면 합류, 없으면 트랜잭션 없이 실행
NOT_SUPPORTED트랜잭션 없이 실행. 있으면 잠시 멈춤
MANDATORY반드시 기존 트랜잭션이 있어야 함. 없으면 예외
NEVER트랜잭션이 있으면 안 됨. 있으면 예외
REQUIRED는 "택시 합승" 같아요. 가는 차가 있으면 같이 타고, 없으면 새로 부릅니다. REQUIRES_NEW는 "무조건 내 전용 차"라서 옆 사람 사정과 무관하게 따로 굴러갑니다.

기본 롤백 규칙 — 매우 중요

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() { ... }
}
프록시는 가게 입구의 안내데스크 같아요. 손님(외부 호출)이 입구로 들어오면 안내데스크가 "어서 오세요(트랜잭션 시작)"를 해 줍니다. 그런데 직원이 가게 안에서 옆 직원을 부르면(this 호출) 입구를 거치지 않으니 안내데스크가 모릅니다.

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-invocationthis.method() 내부 호출은 적용 안 됨
메서드 가시성public 권장, final 금지
예외 변환@RepositoryDataAccessException(런타임)
경계보통 Service 메서드에 부여
이 프로젝트와의 관계

이 프로젝트의 서비스 계층에서 트랜잭션을 어떻게 거는지, 그리고 JPA·QueryDSL·jOOQ·MyBatis로 같은 데이터를 어떻게 다루는지는 백엔드 트랙에서 실제 코드로 이어집니다: be-03 서비스 계층be-04 데이터 접근 을 보세요.

다음 단계