심화 3 예외처리📚 전체 맵심화 5 @Builder
05d · 3차 심화과정 · STEP 4 / 5

@Transactional

ACID 원칙, 더티체킹, readOnly 최적화, 롤백 조건, 전파 — 우리 프로젝트 전체 메서드 적용 기준까지

이 파일의 학습 목적
STEP 4 — @Transactional을 왜 지금 배우나?

3차 프로젝트에서 @Transactional을 이미 일부 메서드에 쓰고 있었지만, 언제 붙이고 언제 안 붙이는지 기준이 없었다. STEP 4는 기존 Service 코드에 @Transactional 적용 기준을 완성하고, 특히 조회 메서드에 readOnly=true를 붙여 성능을 최적화한다. 코드 추가는 없고, 기존 Service 파일에 어노테이션만 달면 끝이다.

심화 5단계 중 지금 여기 — STEP 4
STEP 1 ✅
AOP
로깅 자동화
STEP 2 ✅
컬렉션
이론 정리
STEP 3 ✅
예외처리
전역 핸들러
📌 지금 여기
STEP 4
@Transactional
STEP 5
@Builder
DTO 생성 개선
3차까지의 문제
  • · 일부 메서드에만 @Transactional — 기준 없음
  • · 조회 메서드에도 readOnly 없이 일반 트랜잭션
  • · 더티체킹이 조회 메서드에서도 동작 → 불필요한 스냅샷 생성
  • · DB 연결 최적화 전혀 없음
STEP 4가 해결하는 것
  • · 조회 메서드 전체 → @Transactional(readOnly=true)
  • · CUD 메서드 → @Transactional (기존 유지)
  • · 더티체킹 비활성화로 불필요한 스냅샷 제거
  • · 실수로 조회 메서드에서 데이터 변경되는 것 방지
수정 파일: service/BoardService.java · service/UserService.java · service/AdminService.java — 신규 생성 파일 없음, 어노테이션만 추가
Flow
@Transactional이 실제로 하는 일

@Transactional은 STEP 1에서 배운 AOP처럼 프록시로 동작한다. 우리가 직접 DB 연결을 열고 닫고 커밋하는 코드를 짜지 않아도, Spring이 메서드 앞뒤에 자동으로 처리해준다.

@Transactional 메서드 호출 흐름
Controller
boardService.write() 호출
실제로는 Spring이 만든 프록시 객체가 먼저 받음
Proxy
① DB 연결(Connection) 획득 + 트랜잭션 시작
connection.setAutoCommit(false) — 이후 쿼리들을 묶음 처리 시작
Service
② 실제 BoardService.write() 실행
boardRepository.save(board) → INSERT 쿼리 실행 INSERT INTO board ... 아직 DB에 확정 안 됨
Proxy
③-A 정상 완료 → 커밋
connection.commit() → INSERT가 DB에 확정됨 → Connection 반납
Proxy (오류 시)
③-B 예외 발생 → 롤백
RuntimeException 발생 시 connection.rollback() → INSERT 취소됨 → Connection 반납
📌 @Transactional은 AOP 프록시로 동작한다

STEP 1에서 배운 LogAspect와 완전히 같은 원리다. Spring이 BoardService 앞에 트랜잭션 처리용 프록시를 자동으로 생성한다. 메서드 실행 전 = 트랜잭션 시작, jp.proceed() = 실제 메서드, 메서드 정상 완료 = commit, 예외 발생 = rollback. 우리가 직접 connection.commit() / connection.rollback()을 짜지 않아도 되는 이유가 바로 이것이다.

✅ @Transactional(readOnly=true) 효과 3가지
  • 더티체킹 비활성화 — 스냅샷을 찍지 않음 → 메모리 절약, 성능 향상
  • 실수 방지 — readOnly 트랜잭션에서 INSERT/UPDATE/DELETE 시 예외 발생
  • DB 최적화 힌트 — 일부 DB 드라이버는 읽기 전용 복제본(replica)에 자동으로 라우팅
⚠️ @Transactional 자기 호출 주의

같은 클래스 안에서 @Transactional 메서드가 다른 @Transactional 메서드를 호출하면, 프록시를 거치지 않기 때문에 트랜잭션이 새로 시작되지 않는다. 이를 "자기 호출(self-invocation) 문제"라고 한다. 해결 방법은 별도 클래스로 분리하는 것이다.

Concepts
@Transactional 핵심 개념
ACID — 트랜잭션의 4가지 성질
원칙의미예시
Atomicity
원자성
전부 성공하거나 전부 실패해야 한다 계좌이체: A에서 출금 성공 + B에 입금 성공이 동시에 되어야 함. 중간에 실패하면 A 출금도 취소
Consistency
일관성
트랜잭션 전후 데이터 무결성 규칙이 항상 유지되어야 한다 계좌 잔액은 항상 0 이상이어야 함 — 규칙 위반하는 변경은 거부
Isolation
격리성
동시에 실행되는 트랜잭션들이 서로 영향을 주지 않아야 한다 두 사람이 같은 게시글을 동시에 수정해도 서로 엉키면 안 됨
Durability
지속성
커밋된 데이터는 시스템 장애가 나도 사라지지 않아야 한다 게시글 저장 성공 후 서버가 꺼져도 DB에 남아 있어야 함
더티체킹(Dirty Checking) — @Transactional의 핵심 기능

JPA는 트랜잭션 내에서 조회한 엔티티의 상태를 기억(스냅샷)해두고, 트랜잭션이 끝날 때 변경된 필드가 있으면 자동으로 UPDATE 쿼리를 실행한다. 즉, boardRepository.save(board)를 명시적으로 호출하지 않아도 된다.

더티체킹 동작 — update()
@Transactional   // 필수!
public void update(Long id, BoardDto dto, User user) {
    Board board = boardRepository.findById(id)...;
    // 스냅샷 찍음: {title="구제목", content="구내용"}

    board.setTitle(dto.getTitle());    // 변경!
    board.setContent(dto.getContent()); // 변경!

    // save() 없어도 됨!
    // 트랜잭션 끝날 때 JPA가 스냅샷과 비교
    // → 변경 감지 → 자동 UPDATE 쿼리 실행
}
readOnly=true — 더티체킹 비활성화
@Transactional(readOnly = true)
public Page<BoardDto> getBoardList(int page) {
    // readOnly=true 효과:
    // ① 더티체킹 비활성화 → 스냅샷 안 찍음 → 메모리 절약
    // ② DB에 읽기 전용으로 연결 → 실수로 데이터 변경 방지
    // ③ 일부 DB 드라이버는 읽기 전용 복제본에 연결 → 성능 향상
    Pageable pageable = PageRequest.of(page, 10,...);
    return boardRepository.findAllByOrderByCreatedAtDesc(pageable);
}
롤백 조건 — 언제 자동 취소되나
예외 종류롤백 여부이유
RuntimeException (미체크 예외)✅ 롤백됨Spring의 기본 설정 — 예상치 못한 오류이므로 취소
Error✅ 롤백됨OutOfMemoryError 등 심각한 오류
Exception (체크 예외)❌ 롤백 안 됨 (기본값)개발자가 예측 가능한 오류로 간주
CustomException (우리 프로젝트)✅ 롤백됨RuntimeException 상속이므로 자동 롤백
⚠️ rollbackFor 설정

체크 예외도 롤백하고 싶다면 @Transactional(rollbackFor = Exception.class)로 명시적 설정 가능. 우리 프로젝트에서는 CustomException이 RuntimeException 상속이므로 별도 설정 불필요.

전파(Propagation) — 트랜잭션 중첩 시 동작
전파 옵션동작언제 씀
REQUIRED (기본값)기존 트랜잭션 있으면 참여, 없으면 새로 생성대부분의 경우
REQUIRES_NEW기존 트랜잭션 일시 중단, 무조건 새 트랜잭션 생성독립적으로 커밋/롤백되어야 할 때 (예: 로그 저장)
NOT_SUPPORTED트랜잭션 없이 실행트랜잭션이 오히려 성능 저하를 유발할 때
NEVER트랜잭션 있으면 예외 던짐절대 트랜잭션 안에서 실행되면 안 될 때
💡 우리 프로젝트에서 전파 설정이 필요한 경우

현재 단계에서는 기본값 REQUIRED로 충분하다. AdminService.deleteUser()처럼 Service가 다른 Repository를 순서대로 호출할 때, 하나의 트랜잭션 안에서 전부 처리되므로 중간에 실패해도 전체 롤백된다. 이게 REQUIRED의 장점이다.

Apply
우리 프로젝트 전체 메서드 적용 기준
✅ 적용 기준 — 간단하게 기억하기

조회만 하는 메서드@Transactional(readOnly = true)
INSERT / UPDATE / DELETE 하는 메서드@Transactional (readOnly 없음)
DB 안 건드리는 메서드 → @Transactional 불필요

Service메서드적용 어노테이션이유
BoardServicegetBoardList()@Transactional(readOnly=true)SELECT만 — 더티체킹 불필요
getDetail()@Transactional(readOnly=true)SELECT + 조회수 증가는 별도 처리 또는 그냥 @Transactional
write()@TransactionalINSERT
update()@TransactionalUPDATE — 더티체킹 사용
delete()@TransactionalDELETE
UserServicelogin()@Transactional(readOnly=true)SELECT만 (JWT 발급은 DB 작업 아님)
register()@TransactionalINSERT
AdminServicegetAllUsers()@Transactional(readOnly=true)SELECT만
getAllBoards()@Transactional(readOnly=true)SELECT만
deleteUser()@TransactionalDELETE 2개 (게시글 + 유저) — 원자성 필수
deleteBoard()@TransactionalDELETE
Full Code
전체 코드 — @Transactional 적용 완성본
service/BoardService.java — @Transactional 완성본
@Service @RequiredArgsConstructor
public class BoardService {
    private final BoardRepository boardRepository;

    @Transactional(readOnly = true)   // 조회 → readOnly
    public Page<BoardDto> getBoardList(int page) {
        Pageable pageable = PageRequest.of(page, 10, Sort.by("createdAt").descending());
        Page<Board> boardPage = boardRepository.findAllByOrderByCreatedAtDesc(pageable);
        List<BoardDto> dtoList = boardPage.getContent().stream()
            .map(this::toDto).collect(Collectors.toList());
        return new PageImpl<>(dtoList, pageable, boardPage.getTotalElements());
    }

    @Transactional   // 조회수 UPDATE 포함이므로 readOnly 없음
    public BoardDto getDetail(Long id, User loginUser) {
        Board board = boardRepository.findById(id)
            .orElseThrow(() -> new CustomException("없는 게시글입니다.", 404));
        if (loginUser == null || !board.getUser().getId().equals(loginUser.getId()))
            board.setViewCount(board.getViewCount() + 1); // 더티체킹으로 UPDATE
        return toDto(board);
    }

    @Transactional   // INSERT
    public void write(BoardDto boardDto, User user) {
        Board board = new Board();
        board.setTitle(boardDto.getTitle());
        board.setContent(boardDto.getContent());
        board.setUser(user);
        boardRepository.save(board);
    }

    @Transactional   // UPDATE — 더티체킹 사용
    public void update(Long id, BoardDto boardDto, User user) {
        Board board = boardRepository.findById(id)
            .orElseThrow(() -> new CustomException("없는 게시글입니다.", 404));
        if (!board.getUser().getId().equals(user.getId()))
            throw new CustomException("수정 권한이 없습니다.", 403);
        board.setTitle(boardDto.getTitle());   // save() 없어도 더티체킹으로 UPDATE
        board.setContent(boardDto.getContent());
    }

    @Transactional   // DELETE
    public void delete(Long id, User user) {
        Board board = boardRepository.findById(id)
            .orElseThrow(() -> new CustomException("없는 게시글입니다.", 404));
        if (!board.getUser().getId().equals(user.getId()))
            throw new CustomException("삭제 권한이 없습니다.", 403);
        boardRepository.delete(board);
    }

    // toDto() — 변경 없음 (STEP 5에서 @Builder 방식으로 변경)
    private BoardDto toDto(Board board) {
        BoardDto dto = new BoardDto();
        dto.setId(board.getId());
        dto.setTitle(board.getTitle());
        dto.setContent(board.getContent());
        dto.setViewCount(board.getViewCount());
        dto.setUserId(board.getUser().getId());
        dto.setNickname(board.getUser().getNickname());
        dto.setUsername(board.getUser().getUsername());
        dto.setCreatedAt(board.getCreatedAt()
            .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")));
        return dto;
    }
}
service/UserService.java — @Transactional 완성본
@Service @RequiredArgsConstructor
public class UserService {
    private final UserRepository userRepository;
    private final BCryptPasswordEncoder bCryptPasswordEncoder;
    private final JwtUtil jwtUtil;

    @Value("${admin.code}")
    private String adminCode;

    @Transactional   // INSERT
    public void register(UserDto userDto) {
        if (userRepository.existsByUsername(userDto.getUsername()))
            throw new CustomException("이미 사용중인 아이디입니다.", 409);
        if (userRepository.existsByNickname(userDto.getNickname()))
            throw new CustomException("이미 사용중인 닉네임입니다.", 409);
        User user = new User();
        user.setUsername(userDto.getUsername());
        user.setPassword(bCryptPasswordEncoder.encode(userDto.getPassword()));
        user.setNickname(userDto.getNickname());
        if (adminCode.equals(userDto.getAdminCode())) user.setRole("ROLE_ADMIN");
        else user.setRole("ROLE_USER");
        userRepository.save(user);
    }

    @Transactional(readOnly = true)   // SELECT만 — JWT 발급은 DB 작업 아님
    public Map<String, String> login(String username, String password) {
        User user = userRepository.findByUsername(username)
            .orElseThrow(() -> new CustomException("아이디 또는 비밀번호가 틀렸습니다.", 401));
        if (!bCryptPasswordEncoder.matches(password, user.getPassword()))
            throw new CustomException("아이디 또는 비밀번호가 틀렸습니다.", 401);
        String token = jwtUtil.createToken(
            user.getUsername(), user.getNickname(), user.getRole()
        );
        return Map.of("token", token, "role", user.getRole());
    }
}
service/AdminService.java — @Transactional 완성본
@Service @RequiredArgsConstructor
public class AdminService {
    private final UserRepository userRepository;
    private final BoardRepository boardRepository;

    @Transactional(readOnly = true)   // 조회만
    public List<UserDto> getAllUsers() {
        return userRepository.findAll().stream().map(user -> {
            UserDto dto = new UserDto();
            dto.setId(user.getId());
            dto.setUsername(user.getUsername());
            dto.setNickname(user.getNickname());
            dto.setRole(user.getRole());
            return dto;
        }).collect(Collectors.toList());
    }

    @Transactional(readOnly = true)   // 조회만
    public List<BoardDto> getAllBoards() {
        return boardRepository.findAll().stream().map(board -> {
            BoardDto dto = new BoardDto();
            dto.setId(board.getId());
            dto.setTitle(board.getTitle());
            dto.setNickname(board.getUser().getNickname());
            dto.setViewCount(board.getViewCount());
            return dto;
        }).collect(Collectors.toList());
    }

    @Transactional   // DELETE 2개 — 원자성 필수. 하나 실패하면 둘 다 롤백
    public void deleteUser(Long userId) {
        User user = userRepository.findById(userId)
            .orElseThrow(() -> new CustomException("없는 유저입니다.", 404));
        boardRepository.deleteByUser(user);   // FK 제약 → 게시글 먼저 삭제
        userRepository.delete(user);
    }

    @Transactional   // DELETE
    public void deleteBoard(Long boardId) {
        Board board = boardRepository.findById(boardId)
            .orElseThrow(() -> new CustomException("없는 게시글입니다.", 404));
        boardRepository.delete(board);
    }
}
📌 @Transactional은 AOP로 구현된다

Spring의 @Transactional 자체가 내부적으로 AOP 프록시로 동작한다. STEP 1에서 배운 LogAspect처럼, @Transactional도 메서드 앞뒤에 트랜잭션 시작/커밋/롤백 코드를 자동으로 끼워넣는 방식이다. 우리가 직접 connection.commit(), connection.rollback()을 쓰지 않아도 되는 이유가 바로 이것.

심화 3 예외처리📚 전체 맵심화 5 @Builder