ACID 원칙, 더티체킹, readOnly 최적화, 롤백 조건, 전파 — 우리 프로젝트 전체 메서드 적용 기준까지
3차 프로젝트에서 @Transactional을 이미 일부 메서드에 쓰고 있었지만, 언제 붙이고 언제 안 붙이는지 기준이 없었다. STEP 4는 기존 Service 코드에 @Transactional 적용 기준을 완성하고, 특히 조회 메서드에 readOnly=true를 붙여 성능을 최적화한다. 코드 추가는 없고, 기존 Service 파일에 어노테이션만 달면 끝이다.
@Transactional(readOnly=true)@Transactional (기존 유지)@Transactional은 STEP 1에서 배운 AOP처럼 프록시로 동작한다. 우리가 직접 DB 연결을 열고 닫고 커밋하는 코드를 짜지 않아도, Spring이 메서드 앞뒤에 자동으로 처리해준다.
STEP 1에서 배운 LogAspect와 완전히 같은 원리다. Spring이 BoardService 앞에 트랜잭션 처리용 프록시를 자동으로 생성한다. 메서드 실행 전 = 트랜잭션 시작, jp.proceed() = 실제 메서드, 메서드 정상 완료 = commit, 예외 발생 = rollback. 우리가 직접 connection.commit() / connection.rollback()을 짜지 않아도 되는 이유가 바로 이것이다.
같은 클래스 안에서 @Transactional 메서드가 다른 @Transactional 메서드를 호출하면, 프록시를 거치지 않기 때문에 트랜잭션이 새로 시작되지 않는다. 이를 "자기 호출(self-invocation) 문제"라고 한다. 해결 방법은 별도 클래스로 분리하는 것이다.
| 원칙 | 의미 | 예시 |
|---|---|---|
| Atomicity 원자성 |
전부 성공하거나 전부 실패해야 한다 | 계좌이체: A에서 출금 성공 + B에 입금 성공이 동시에 되어야 함. 중간에 실패하면 A 출금도 취소 |
| Consistency 일관성 |
트랜잭션 전후 데이터 무결성 규칙이 항상 유지되어야 한다 | 계좌 잔액은 항상 0 이상이어야 함 — 규칙 위반하는 변경은 거부 |
| Isolation 격리성 |
동시에 실행되는 트랜잭션들이 서로 영향을 주지 않아야 한다 | 두 사람이 같은 게시글을 동시에 수정해도 서로 엉키면 안 됨 |
| Durability 지속성 |
커밋된 데이터는 시스템 장애가 나도 사라지지 않아야 한다 | 게시글 저장 성공 후 서버가 꺼져도 DB에 남아 있어야 함 |
JPA는 트랜잭션 내에서 조회한 엔티티의 상태를 기억(스냅샷)해두고, 트랜잭션이 끝날 때 변경된 필드가 있으면 자동으로 UPDATE 쿼리를 실행한다. 즉, boardRepository.save(board)를 명시적으로 호출하지 않아도 된다.
@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 쿼리 실행 }
@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 상속이므로 자동 롤백 |
체크 예외도 롤백하고 싶다면 @Transactional(rollbackFor = Exception.class)로 명시적 설정 가능. 우리 프로젝트에서는 CustomException이 RuntimeException 상속이므로 별도 설정 불필요.
| 전파 옵션 | 동작 | 언제 씀 |
|---|---|---|
| REQUIRED (기본값) | 기존 트랜잭션 있으면 참여, 없으면 새로 생성 | 대부분의 경우 |
| REQUIRES_NEW | 기존 트랜잭션 일시 중단, 무조건 새 트랜잭션 생성 | 독립적으로 커밋/롤백되어야 할 때 (예: 로그 저장) |
| NOT_SUPPORTED | 트랜잭션 없이 실행 | 트랜잭션이 오히려 성능 저하를 유발할 때 |
| NEVER | 트랜잭션 있으면 예외 던짐 | 절대 트랜잭션 안에서 실행되면 안 될 때 |
현재 단계에서는 기본값 REQUIRED로 충분하다. AdminService.deleteUser()처럼 Service가 다른 Repository를 순서대로 호출할 때, 하나의 트랜잭션 안에서 전부 처리되므로 중간에 실패해도 전체 롤백된다. 이게 REQUIRED의 장점이다.
조회만 하는 메서드 → @Transactional(readOnly = true)
INSERT / UPDATE / DELETE 하는 메서드 → @Transactional (readOnly 없음)
DB 안 건드리는 메서드 → @Transactional 불필요
| Service | 메서드 | 적용 어노테이션 | 이유 |
|---|---|---|---|
| BoardService | getBoardList() | @Transactional(readOnly=true) | SELECT만 — 더티체킹 불필요 |
getDetail() | @Transactional(readOnly=true) | SELECT + 조회수 증가는 별도 처리 또는 그냥 @Transactional | |
write() | @Transactional | INSERT | |
update() | @Transactional | UPDATE — 더티체킹 사용 | |
delete() | @Transactional | DELETE | |
| UserService | login() | @Transactional(readOnly=true) | SELECT만 (JWT 발급은 DB 작업 아님) |
register() | @Transactional | INSERT | |
| AdminService | getAllUsers() | @Transactional(readOnly=true) | SELECT만 |
getAllBoards() | @Transactional(readOnly=true) | SELECT만 | |
deleteUser() | @Transactional | DELETE 2개 (게시글 + 유저) — 원자성 필수 | |
deleteBoard() | @Transactional | DELETE |
@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 @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 @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); } }
Spring의 @Transactional 자체가 내부적으로 AOP 프록시로 동작한다. STEP 1에서 배운 LogAspect처럼, @Transactional도 메서드 앞뒤에 트랜잭션 시작/커밋/롤백 코드를 자동으로 끼워넣는 방식이다. 우리가 직접 connection.commit(), connection.rollback()을 쓰지 않아도 되는 이유가 바로 이것.