Spring Boot #3 (JPA 로 PostgreSQL 연결) 에서 엔티티 하나를 DB 에 붙였다. 이번 #4 는 엔티티를 둘로 늘려 연관관계로 묶고, 그 과정에서 마주친 두 가지 — 트랜잭션과 N+1 문제 — 를 정리한다.
Table of contents
Open Table of contents
트랜잭션 — 전부 되거나, 전부 안 되거나
먼저 트랜잭션. 한 문장으로 요약하면 “전부 다 되거나, 전부 다 안 되거나” 다. 중간까지만 반영되는 어정쩡한 상태가 없다. 여러 작업을 하나로 묶어서, 하나라도 실패하면 통째로 되돌린다(rollback).
두 테이블을 연결 — @ManyToOne · @JoinColumn
엔티티 사이에 다대일(N:1) 관계를 맺었다. 예를 들어 할일(Todo) 여러 개가 사용자(User) 한 명에 속하는 구조다.
@Entity
public class Todo {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@ManyToOne(fetch = FetchType.LAZY) // 다(Todo) : 일(User)
@JoinColumn(name = "user_id") // 외래키 컬럼 지정
private User user;
}
@ManyToOne— 이 엔티티(Todo)가 “다” 쪽이고 상대(User)가 “일” 쪽임을 표시@JoinColumn— 두 테이블을 잇는 외래키 컬럼을 지정
여기서 fetch = FetchType.LAZY, 즉 지연 로딩을 걸었다. Todo 를 조회할 때 연결된 User 를 곧바로 다 불러오지 않고, 실제로 todo.getUser() 를 건드리는 순간에야 User 를 조회하는 방식이다. 이게 뒤에서 N+1 의 씨앗이 된다.
record 로 DTO 만들기 (from 정적 팩토리)
응답용 DTO 를 만들었는데, 여기서 하나 처음 봤다. public class 가 아니라 public record 로도 DTO 가 된다.
public record TodoResponse(Long id, String title, String userName) {
public static TodoResponse from(Todo todo) {
return new TodoResponse(todo.getId(), todo.getTitle(), todo.getUser().getName());
}
}
from 이라는 정적 팩토리 메서드로 엔티티 → DTO 변환을 담았다. (#3 에서 JPA 엔티티는 값을 바꿔야 해서 record 가 안 맞는다고 했는데, DTO 는 반대다 — 한 번 만들고 안 바꾸는 불변 응답 객체라 record 가 딱 맞는다.)
그런데 저 from 안의 todo.getUser().getName() — LAZY 로 걸어둔 User 를 꺼내는 이 한 줄이, 반복문에서 돌 때 문제를 일으킨다.
@Transactional — 기본 read-only, 쓰기에만 권한
Service 에 @Transactional 을 붙여 트랜잭션을 걸었다. 전략은 클래스 기본은 읽기 전용(read-only), 쓰기가 필요한 메서드에만 따로 권한을 주는 식이다.
@Service
@Transactional(readOnly = true) // 기본: 읽기 전용
public class TodoService {
public List<TodoResponse> findAll() { ... } // 읽기 → 기본값 그대로
@Transactional // 이 메서드만 쓰기 허용
public void updateTitle(Long id, String title) { ... }
}
읽기 전용을 기본으로 두면 실수로 쓰기가 새는 걸 막고, 성능 면에서도 이점이 있다. 쓰기가 필요한 곳에만 명시적으로 @Transactional 을 덧붙여 권한을 연다.
전체 조회를 했더니 쿼리가 6번 — N+1 문제
코드를 다 짜고 전체 조회를 돌렸다. 그런데 로그를 보니 쿼리가 이상하게 많이 나갔다.
Todo 5개를 가져오는 조회는 분명 SELECT 1번이면 된다. 그런데 그 뒤로 SELECT 가 5번 더 나갔다. 총 6번.
N+1 이 뭐지?
이게 바로 N+1 문제다. 이름 그대로 쿼리가 1 + N 번 나가는 상황이다.
- 1번 — Todo 목록을 가져오는 조회 (
SELECT * FROM todos) → 5개 확보 - N번 — DTO 로 변환하면서
todo.getUser().getName()을 호출할 때마다, LAZY 로 미뤄뒀던 User 를 지금 조회 (SELECT * FROM users WHERE id = ?) → 5개면 5번
즉 LAZY 로딩 때문이다. 연관 엔티티를 그때그때 따로 불러오다 보니, 목록 개수(N)만큼 추가 쿼리가 터진다. 데이터가 5개라 6번이지만, 100개면 101번이 된다. 목록이 커질수록 재앙이다.
해결 — fetch join (LEFT JOIN FETCH)
이런 경우엔 fetch join 으로 잡는다. 예제에서는 Repository 인터페이스 안에 @Query 로 직접 넣었다.
public interface TodoRepository extends JpaRepository<Todo, Long> {
@Query("SELECT t FROM Todo t LEFT JOIN FETCH t.user")
List<Todo> findAllWithUser();
}
평범한 JOIN 인데 FETCH 가 붙었다. 이게 핵심이다. JOIN FETCH 는 Todo 를 가져올 때 연관된 User 까지 한 방에 다 긁어모은다. 그래서 나중에 getUser() 를 호출해도 추가 쿼리가 안 나간다 — 이미 다 들고 왔으니까. 6번이던 쿼리가 1번으로 준다.
save 를 지웠는데 UPDATE 가 된다 — 더티 체킹
마지막으로 하나 더 신기한 걸 봤다. update 로직에서 원래 쓰던 save() 를 지웠는데도 수정이 잘 반영됐다.
@Transactional
public void updateTitle(Long id, String title) {
Todo todo = todoRepository.findById(id).orElseThrow();
todo.setTitle(title); // 값만 바꾼다. save() 안 함.
}
save() 를 부르지 않았는데 DB 에 UPDATE 가 나갔다. 알고 보니 @Transactional 덕분이었다. 트랜잭션 안에서 조회한 엔티티는 JPA 가 계속 감시하고 있다가, 값이 바뀐 걸 발견하면 트랜잭션이 끝날 때 알아서 UPDATE 쿼리를 날린다. 이걸 더티 체킹(dirty checking) 이라고 한다.
회고
오늘의 한 줄 정리(TIL):
LAZY 연관 엔티티를 반복문에서 꺼내면 N+1(1+N 번 쿼리)이 터진다.
LEFT JOIN FETCH로 한 번에 가져오면 쿼리 1번으로 준다.@Transactional은 전부 성공 / 전부 취소를 보장하고, 그 안에서 조회한 엔티티는 더티 체킹으로save없이도 수정이 반영된다.
엔티티 하나일 땐 안 보이던 것들이, 둘을 관계로 묶자마자 쏟아졌다. 연관관계는 편한 만큼 뒤에서 조용히 쿼리를 늘리고 있었고, 트랜잭션은 편한 만큼 조용히 UPDATE 를 대신 날려주고 있었다. “편하다”의 뒷면을 같이 봐야 한다는 걸 배웠다.
더 공부해볼 것
- fetch join 의 한계 — 컬렉션(일대다,
@OneToMany)을 fetch join 하면 페이징이 깨지거나 중복 row 가 생긴다고 한다. 오늘은 다대일(단일 연관)이라 안 만났지만, 컬렉션일 때 어떻게 달라지는지. → Hibernate: Fetching @EntityGraph·@BatchSize— fetch join 말고 N+1 을 푸는 다른 길. 각각 언제 쓰는 게 나은지 비교.- 더티 체킹의 비용 — JPA 가 엔티티를 감시하려면 스냅샷을 들고 있어야 한다는데, 대량 업데이트에서 이게 부담이 되는지. 벌크 연산(
@Modifying @Query)과의 차이. @Transactional(readOnly = true)가 실제로 뭘 최적화하나 — 그냥 쓰기 금지 플래그인지, 아니면 더티 체킹 스냅샷을 생략하는 등 성능 이점이 따로 있는지.- 트랜잭션 전파(propagation) · 격리 수준(isolation) — 트랜잭션 안에서 다른 트랜잭션 메서드를 호출하면 어떻게 되는지, 동시 접근 시 격리 수준별 차이. → Spring: Transaction Management