6장 끝에서 작은 약속을 하나 남겼다. 분명 save()를 부르지 않았는데 값이 바뀌어 저장되더라는 그 수상한 일 말이다. 직접 한번 겪어 보자.
@Service
public class TaskService {
private final TaskRepository taskRepository;
public TaskService(TaskRepository taskRepository) {
this.taskRepository = taskRepository;
}
@Transactional
public void completeTask(Long id) {
Task task = taskRepository.findById(id)
.orElseThrow(() -> new TaskNotFoundException(id));
task.markDone(); // done = true 로 바꾸기만 했다
// save() 를 부르지 않았다!
}
}
completeTask(1)을 호출한 뒤 DB에서 task 1번 행의 done 값은?
처음 보면 묘하다. 좋게 보면 똑똑한 것이고 나쁘게 보면 통제를 벗어난 것이다. 감각이 좋은 개발자라면 여기서 살짝 불안해야 한다. 내가 시키지 않은 일을 프레임워크가 알아서 해 주는 건, 그 원리를 모르는 한 언제 어떻게 터질지 모르는 시한폭탄과 같다. 그러니 이 장에서 할 일은 분명하다. JPA가 보이지 않는 곳에서 부리는 “마법”의 정체를 똑똑히 들여다보자. 정체를 알고 나면, 마법은 더 이상 마법이 아니라 예측 가능한 동작이 된다.
ch07-1 save() 없이 저장되는 더티 체킹명령과 기대 출력 펼치기
실행해 보기
./gradlew bootRun
curl -X POST http://localhost:8080/api/tasks \
-H "Content-Type: application/json" \
-d '{"title": "더티 체킹 보기"}'
curl -i -X PATCH http://localhost:8080/api/tasks/1/completePATCH를 보낸 직후의 콘솔을 본다.
Hibernate: select t1_0.id,t1_0.created_at,t1_0.description,t1_0.done,t1_0.title from task t1_0 where t1_0.id=?
Hibernate: update task set created_at=?,description=?,done=?,title=? where id=?
save()를 부르지 않았는데 UPDATE가 나갔다. 트랜잭션이 끝날 때 JPA가 처음 읽어 온 모습과 지금 모습을 비교해(더티 체킹), 바뀐 엔티티의 UPDATE를 대신 보낸다.
다른 요청으로 다시 읽어 DB에 반영됐는지 본다.
curl http://localhost:8080/api/tasks/1{"id":1,"title":"더티 체킹 보기","description":null,"done":true}
직접 깨뜨려 보기: @Transactional을 지우면
TaskService.completeTask의 @Transactional을 지우고 다시 띄워 같은 순서로 요청한다. PATCH는 여전히 204인데, 콘솔에는 SELECT만 있고 UPDATE가 없다. 다시 읽으면 "done":false다.
더티 체킹은 트랜잭션이 커밋될 때 일어난다. 트랜잭션 경계가 없으면 바뀐 객체를 DB에 반영할 순간도 없다. 확인했으면 git checkout -- .으로 되돌린다.
출석부를 든 선생님 — 영속성 컨텍스트
마법의 한복판에는 영속성 컨텍스트(persistence context)라는 것이 있다. 이름이 거창하니 일단 비유부터 들자.
학창 시절 교실을 떠올려 보자. 선생님은 수업이 시작되면 출석부를 펼친다. 출석부에는 그 시간에 들어온 학생들의 명단이 적혀 있고, 선생님은 누가 자리를 옮겼는지, 누가 졸고 있는지를 그 명단에 기록해 둔다. 수업이 끝나면 선생님은 출석부에 적힌 변동 사항을 정리해 학적부에 한꺼번에 반영한다.
영속성 컨텍스트가 바로 이 출석부다. 트랜잭션이 시작되면(수업이 시작되면) JPA는 출석부를 하나 펼친다. findById로 데이터베이스에서 Task를 꺼내 오면 JPA는 그 객체를 출석부에 적어 넣는다. “이 시간에 이 학생이 들어왔다”고 명단에 올리는 셈이다. 그 뒤로 JPA는 출석부에 올라온 객체들을 계속 지켜본다. 그리고 트랜잭션이 끝날 때(수업이 끝날 때) 바뀐 내용을 데이터베이스에 한꺼번에 반영한다.
이 출석부는 또 하나 영리한 일을 한다. 1차 캐시 역할이다. 한 트랜잭션 안에서 같은 Task를 두 번 조회한다고 해 보자.
Task t1 = taskRepository.findById(1L).orElseThrow(...);
Task t2 = taskRepository.findById(1L).orElseThrow(...);
// t1 == t2 ? 그렇다, 같은 객체다
같은 트랜잭션 안에서 findById(1L)를 두 번 부르면 SELECT는 몇 번 나갈까?
프런트 다리 · 원문프런트에서 React Query나 SWR을 써 봤다면, “한 번 가져온 데이터를 키로 캐싱해 두고 같은 키면 다시 안 부른다”는 감각이 익숙할 것이다. 영속성 컨텍스트의 1차 캐시도 결이 비슷하다. 다만 범위가 다르다. React Query의 캐시는 화면(컴포넌트 트리)이 사는 동안 유지되지만, 영속성 컨텍스트의 캐시는 딱 한 트랜잭션 동안만 살아 있다가 끝나면 통째로 사라진다. 이 “수명”의 차이가 뒤에서 중요해지니 기억해 두자.
ch07-2 출석부(영속성 컨텍스트)를 테스트로 확인하기명령과 기대 출력 펼치기
실행해 보기
./gradlew test --tests '*PersistenceContextTest' -i --rerun-i로 자세히 출력하면 SQL이 찍힌다. 저장할 때의 INSERT 다음, 두 번의 findById에 해당하는 SELECT는 한 줄뿐이다. (--rerun은 이미 통과한 테스트도 다시 돌린다. 없으면 두 번째 실행부터는 Gradle이 "이미 최신"이라며 건너뛴다.)
Hibernate: select t1_0.id,t1_0.created_at,t1_0.description,t1_0.done,t1_0.title from task t1_0 where t1_0.id=?
BUILD SUCCESSFUL
두 번째 findById는 DB에 가지 않고 출석부에서 같은 객체를 꺼냈다.
직접 깨뜨려 보기: 출석부를 중간에 비우면
두 findById 사이에 em.clear();를 한 줄 넣고 다시 돌린다. 첫 단언(isSameAs)에서 깨진다. 출석부가 비었으니 두 번째 조회도 DB에 가고, 새로 만든 다른 객체를 돌려준다. (SELECT도 두 번이 되지만, 첫 단언에서 멈춰 둘째 단언까지 가지 않는다.)
확인했으면 git checkout -- .으로 되돌린다.
save() 없이도 저장되는 이유 — 더티 체킹
이제 맨 앞의 수수께끼로 돌아가자. save()를 안 했는데 어떻게 UPDATE가 나갔을까?
출석부 비유를 이어 가자. 선생님은 수업 시작 때 학생들의 상태를 출석부에 적어 둔다. 철수는 1분단, 깨어 있음. 이게 스냅샷(snapshot), 즉 처음 모습의 사진이다. 수업이 끝날 무렵 선생님은 지금 교실 상태와 처음 적어 둔 스냅샷을 하나하나 비교한다. 철수가 2분단으로 옮겨 앉았네? 그럼 학적부에 “철수 자리 변경”을 반영한다. 아무도 안 옮겼으면 아무것도 안 한다.
JPA의 더티 체킹(dirty checking), 우리말로 변경 감지가 정확히 이것이다. JPA는 findById로 엔티티를 출석부에 올릴 때, 그 엔티티의 처음 값을 따로 복사해 스냅샷으로 보관해 둔다. task.markDone()으로 done을 바꾸는 건 평범한 자바 객체의 필드를 바꾸는 일일 뿐이라, 이 순간엔 아무 SQL도 나가지 않는다. 그러다 트랜잭션이 끝나기 직전, JPA는 출석부에 올라온 모든 엔티티를 한 바퀴 돌며 지금 값과 처음 스냅샷을 비교한다. done이 false에서 true로 바뀌었네? 그럼 그 필드를 반영하는 UPDATE SQL을 만들어 데이터베이스에 보낸다.
그래서 save()를 부를 필요가 없었다. 더 정확히 말하면, 영속성 컨텍스트가 관리하는 엔티티는 값을 바꾸는 것만으로 변경이 추적되고, 트랜잭션이 커밋될 때 자동으로 반영된다. 이 동작이 처음엔 “어, 편한데?” 싶다가도, 원리를 모르면 정반대 함정이 된다. 별생각 없이 조회한 엔티티의 필드를 잠깐 만졌을 뿐인데 의도치 않은 UPDATE가 슬그머니 나가는 일이 생긴다. 운영 데이터가 나도 모르게 바뀌어 있다면 아찔한 일이다. 이렇게 기억해 두자. 조회한 엔티티는 “살아 있다.” 건드리면 흔적이 남는다.
그렇다면
save()는 언제 쓰나?save()는 출석부에 아직 없는 새 엔티티를 처음 등록할 때(즉INSERT) 필요하다. 6장에서create가 새Task를 만들어save()로 저장했던 게 그 경우다. 반면 이미 출석부에 올라온(조회해 온) 엔티티를 수정할 때는, 위에서 봤듯 굳이save()를 부르지 않아도 더티 체킹이 알아서 반영한다. “조회한 걸 고칠 땐 save가 필수”라고 착각하진 말자.
버튼은 서비스 메서드 안에서 한 줄씩 실행하는 코드입니다. 출석부(영속성 컨텍스트), 내 변수, DB, 콘솔이 어떻게 변하는지 보세요.
내 코드의 변수
DB · task 테이블
| id | title | done |
|---|---|---|
| 1 | 장보기 | false |
- 해볼 것
- save()를 한 번도 부르지 않고 UPDATE를 내보기
- SQL 없이 같은 객체를 다시 받기 (1차 캐시)
- 값을 바꿨는데 DB에 반영되지 않는 상황 만들기
- new로 만든 객체를 save()해서 출석부에 올리기
엔티티에도 생애가 있다 — transient, persistent, detached
여기까지 오면 한 가지가 또렷해진다. 같은 Task 객체라도, 출석부에 올라와 있느냐 아니냐에 따라 JPA가 대하는 태도가 전혀 다르다. 그래서 JPA는 엔티티가 처한 상태를 세 가지로 구분한다. 어려워 보이지만 출석부 비유로 풀면 쉽다.
- 비영속(transient) —
new Task("장보기")로 방금 만든 객체. 아직 출석부에 이름이 없어 JPA는 그 존재조차 모른다. 교실 문밖에 서 있는, 출석 부르기 전의 학생이다. - 영속(persistent) —
save()로 저장하거나findById로 조회해 출석부에 올린 순간의 상태. 이제 더티 체킹의 대상이 되고, 1차 캐시에 살고, 값을 바꾸면 흔적이 남는다. 출석 불린 학생이다. 지금까지 이야기한 “마법”은 전부 이 상태에서 일어난다. - 준영속(detached) — 트랜잭션이 끝나 출석부 자체가 닫히면, 그 안에 있던 엔티티들은 전부 떨어져 나와 준영속이 된다. 교실 밖으로 나간 학생이다. 준영속 엔티티는 값을 아무리 바꿔도 더티 체킹이 일어나지 않는다. 출석부 밖의 일이라 JPA가 모르기 때문이다.
new Task(...) → 비영속(transient) : 출석부에 없음
save() / findById() → 영속(persistent) : 출석부에 있음, 추적됨
트랜잭션 종료 → 준영속(detached) : 출석부에서 떨어짐, 추적 안 됨
커밋으로 트랜잭션이 끝난 뒤, 손에 들고 있던 task에 markDone()을 호출했다. UPDATE가 나갈까?
객체가 객체를 가리키게 하기 — 연관관계 매핑
지금까지 Task 하나만 다뤘다. 그런데 현실의 데이터는 혼자 살지 않는다. 할 일에는 그 일을 맡은 사람이 있고, 사람은 여러 할 일을 맡는다. TaskBoard에도 이제 그런 관계를 들여오자. Task에 담당자(User)를 붙이는 것이다. 사용자 한 명이 할 일 여러 개를 맡는 User(1)—Task(N) 관계다.
6장에서 짚었듯, 표의 세계에서 다른 표를 가리키는 방법은 외래 키(foreign key) 하나뿐이다. task 테이블에 user_id라는 칼럼을 두고 거기에 사용자의 기본 키 값을 적어 두는 식이다. 그런데 자바에서 다루고 싶은 건 숫자가 아니라 객체 참조다. task.getUser().getName()처럼 객체를 타고 들어가고 싶다. 표는 외래 키로, 객체는 참조로 관계를 나타낸다. 이 간극을 메워 주는 게 연관관계 매핑이다.
먼저 간단한 User 엔티티를 하나 만들자.
package com.example.taskboard;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
protected User() {}
public User(String name) {
this.name = name;
}
public Long getId() { return id; }
public String getName() { return name; }
}
원문 보정 · 앱 보충이 코드를 그대로 실행하면 테이블 이름이
user가 되는데, Spring Boot 3.x에 딸려 오는 H2 2.x에서USER는 예약어라create table user …가 문법 오류로 실패한다. 이 장과 다음 장의 SQL 로그가users테이블을 가리키는 것처럼, 클래스에@Table(name = "users")(jakarta.persistence.Table)를 붙여 두자.
이제 Task가 User를 가리키게 하자. Task 쪽에서 보면 여러(Many) 할 일이 한(One) 사용자를 향한다. 이걸 @ManyToOne이라고 적는다.
import jakarta.persistence.ManyToOne;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
@Entity
public class Task {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private boolean done;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
// 생성자·getter 등은 6장과 동일, 지면상 생략
}
@ManyToOne은 “이쪽이 Many, 가리키는 저쪽이 One”이라는 선언이다. @JoinColumn(name = "user_id")는 “이 관계를 task 테이블의 user_id라는 외래 키 칼럼으로 표현하라”는 지시다. 이렇게 해 두면 task.getUser()로 객체를 자연스럽게 타고 들어갈 수 있다. 외래 키 숫자를 객체 참조로 바꾸는 일은 JPA가 대신 해 준다.
단방향, 양방향, 그리고 “연관관계의 주인”
여기까지는 Task만 User를 안다. User는 자기에게 어떤 할 일이 달려 있는지 모른다. 한쪽만 상대를 가리키니 이걸 단방향(unidirectional)이라 한다. 대부분은 단방향만으로 충분하다.
그런데 가끔은 반대쪽에서도 타고 들어가고 싶다. user.getTasks()로 그 사람의 할 일 목록을 통째로 보고 싶을 때다. 그러려면 User에도 Task 목록을 두어 서로가 서로를 가리키게 해야 한다. 이걸 양방향(bidirectional)이라 한다.
import jakarta.persistence.OneToMany;
import java.util.ArrayList;
import java.util.List;
@Entity
public class User {
// ... 앞의 필드들 ...
@OneToMany(mappedBy = "user")
private List<Task> tasks = new ArrayList<>();
}
여기서 입문자를 가장 자주 헷갈리게 하는 mappedBy = "user"가 등장한다. 무슨 뜻일까? 다시 표를 떠올려 보자. Task와 User의 관계를 나타내는 외래 키는 task 테이블의 user_id 칼럼, 딱 하나뿐이다. 그런데 자바에서는 Task.user와 User.tasks, 두 군데서 같은 관계를 들여다본다. 표에는 외래 키가 하나인데 객체에는 참조가 둘이다. 여기서 JPA가 난감해한다. “둘 중 누구의 변경을 보고 외래 키를 갱신해야 하지?”
그래서 JPA는 둘 중 하나를 연관관계의 주인(owner)으로 정하게 한다. 주인은 외래 키를 직접 들고 있는 쪽, 즉 @JoinColumn이 붙은 Task.user다. 주인이 아닌 쪽(User.tasks)에는 mappedBy를 붙여 “나는 거울일 뿐, 외래 키의 진짜 주인은 저쪽”이라고 선언한다. 그러니 외래 키를 바꾸려면 반드시 주인 쪽(task.setUser(...))을 건드려야 한다. 거울인 user.getTasks().add(task)만 해서는 데이터베이스에 반영되지 않는다. “분명 user.getTasks().add(...)로 추가했는데 DB에 반영이 안 돼요!” 하고 한참 헤매는 입문자가 많은데, 원인은 거의 항상 거울 쪽만 건드렸기 때문이다. 외래 키를 가진 쪽이 주인이고, 데이터베이스를 바꾸려면 주인을 건드려야 한다.
트랜잭션 안에서 task 1번과 user 1번(영희)을 조회해 둔 상태입니다. 한쪽 또는 양쪽을 바꾸고 커밋해 보세요.
Task.user주인 · @JoinColumn
User.tasks거울 · mappedBy
DB · task 테이블
| id | title | user_id |
|---|---|---|
| 1 | 장보기 | null |
- 해볼 것
- 거울 쪽만 바꾸고 커밋해서, DB가 그대로인 것 확인하기
- 주인 쪽을 바꿔 user_id 채우기
솔직히 덧붙이면, 양방향은 편한 만큼 신경 쓸 게 늘어난다. 실무에서도 “정말 양쪽에서 다 타고 들어가야 하나?”를 먼저 자문하고, 꼭 필요할 때만 양방향으로 가는 편이 낫다. 한쪽 방향으로 충분하면 단방향이 단순하고 안전하다.
ch07-3 User와 연관관계, 그리고 실습용 초기 데이터명령과 기대 출력 펼치기
실행해 보기
./gradlew bootRun시작 로그에서 테이블 두 개와 외래 키를 찾는다.
Hibernate: create table task (done boolean not null, created_at timestamp(6), id bigint generated by default as identity, user_id bigint, description varchar(255), title varchar(255), primary key (id))
Hibernate: create table users (id bigint generated by default as identity, name varchar(255), primary key (id))
Hibernate: alter table if exists task add constraint FK… foreign key (user_id) references users
user_id 칼럼은 task 쪽에만 있다. 외래 키를 가진 쪽(Task)이 주인이 되는 이유다. users 테이블에는 할 일 목록 칼럼이 없다.
curl http://localhost:8080/api/tasks초기 데이터 5개가 보인다.
직접 깨뜨려 보기: @Table을 빼면
User.java의 @Table(name = "users") 줄을 지우고 띄운다. 테이블 이름이 user가 되는데, H2 2.x에서 USER는 예약어다.
Error executing DDL "create table user (id bigint generated by default as identity, name varchar(255), primary key (id))" via JDBC [Syntax error in SQL statement …]
DDL 실패는 경고(WARN)로만 남고 시작은 계속된다. 그러다 DataInitializer가 사용자를 넣는 순간 멈춘다.
Syntax error in SQL statement "insert into [*]user (name,id) values (?,default)"
확인했으면 git checkout -- .으로 되돌린다.
어떤 필드는 접근할 때 쿼리가 나간다 — 지연 로딩
이제 또 다른 “마법”을 들여다볼 차례다. 6장 끝에서 예고했던, “객체 하나를 꺼냈을 뿐인데 연결된 다른 객체까지 슬그머니 따라온다”는 그 일이다. 정확히는 따라올 수도 있고 안 따라올 수도 있다. 그걸 가르는 게 로딩 전략이다.
상황을 가정해 보자. taskRepository.findById(1L)로 Task 하나를 꺼냈다. 이 Task는 User를 가리키고 있다. 그렇다면 Task를 꺼낼 때, 그에 딸린 User까지 지금 당장 함께 읽어 와야 할까? 아니면 실제로 task.getUser()를 부르는 그 순간까지 미뤄 뒀다가 읽어 와야 할까?
전자가 즉시 로딩(EAGER), 후자가 지연 로딩(LAZY)이다. 즉시 로딩은 “엔티티를 꺼낼 때 연관된 것도 즉시 다 가져와라”, 지연 로딩은 “연관된 건 진짜 쓸 때까지 게으르게(lazy) 미뤄 둬라”다. 코드로 따라가 보자.
@Transactional
public void printUserName(Long taskId) {
Task task = taskRepository.findById(taskId).orElseThrow(...);
// 이 시점엔 select * from task ... 만 나갔다. user 는 안 읽었다.
System.out.println(task.getUser().getName());
// 바로 이 순간! getUser() 를 실제로 건드리니
// 그제서야 select * from users where id = ? 가 나간다.
}
findById로 Task를 꺼낼 때는 task 테이블만 조회한다. 그러다 task.getUser().getName()으로 사용자 이름을 실제로 꺼내려는 그 순간, JPA가 비로소 users 테이블에 추가 쿼리를 날려 데이터를 채운다.
그렇다면 어떻게 이게 가능할까? findById 시점엔 user를 안 읽었다면서, task.getUser()에는 도대체 뭐가 들어 있던 걸까? 비밀은 프록시(proxy)다. JPA는 진짜 User 객체 대신, 겉모습만 User인 가짜 대역 객체를 슬쩍 넣어 둔다. 이 대역은 속이 텅 비어 있다가, 누군가 getName()을 부르는 순간 “아, 이제 진짜 데이터가 필요하구나” 하고 그제야 데이터베이스에 쿼리를 날려 속을 채운다. 6장에서 봤던 리포지토리 프록시와 같은 발상이다.
ch07-4 접근할 때 쿼리가 나가는 지연 로딩명령과 기대 출력 펼치기
실행해 보기
./gradlew bootRun
curl http://localhost:8080/api/tasks/3/ownerbogum
요청 직후의 콘솔을 본다. SELECT가 두 번, 따로 나갔다.
Hibernate: select t1_0.id,t1_0.created_at,t1_0.description,t1_0.done,t1_0.title,t1_0.user_id from task t1_0 where t1_0.id=?
Hibernate: select u1_0.id,u1_0.name from users u1_0 where u1_0.id=?
findById는 task 테이블만 읽는다. user 자리에는 user_id만 아는 가짜 객체(프록시)를 채워 둔다. 이름을 실제로 꺼내는 순간 그제야 users를 조회한다. FetchType.LAZY가 이 뜻이다.
직접 깨뜨려 보기: 쿼리가 나가는 순간 보기
TaskController.getOwner의 return 바로 위에 한 줄을 넣고 다시 요청한다.
System.out.println(">>> getName() 부르기 직전");Hibernate: select … from task t1_0 where t1_0.id=?
>>> getName() 부르기 직전
Hibernate: select u1_0.id,u1_0.name from users u1_0 where u1_0.id=?
출력이 두 SELECT 사이에 끼었다. 확인했으면 git checkout -- .으로 되돌린다.
그런데 왜 됐을까
findTaskEntity에는 @Transactional이 없다. 리포지토리 호출이 끝나면 출석부(영속성 컨텍스트)도 닫혀야 할 것 같은데, 컨트롤러에서 지연 로딩이 됐다.
시작 로그의 이 경고가 답이다.
spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed during view rendering.
Spring Boot는 기본으로 OSIV(Open Session In View)를 켜서, 요청이 끝날 때까지 출석부를 열어 둔다. 그래서 컨트롤러에서도 지연 로딩이 된다. 다음 단계에서 이걸 끈다.
LAZY를 기본으로, 그런데 함정이 하나
그렇다면 즉시 로딩과 지연 로딩 중 무엇을 골라야 할까? 연관관계는 기본적으로 지연 로딩(LAZY)으로 두는 편이 낫다. 왜냐고? 즉시 로딩이면 Task 하나를 꺼낼 때마다 딸린 User가 항상 끌려온다. 당장 사용자 정보가 필요 없는데도 말이다. 할 일 100개를 목록으로 조회하는데 쓰지도 않을 사용자 100명분이 매번 따라온다면 그건 낭비다. 게다가 즉시 로딩은 예상하기 어려운 쿼리를 뒤에서 뭉텅뭉텅 만들어내, 다음 장에서 다룰 N+1 문제의 단골 원인이 되기도 한다. 그래서 @ManyToOne의 기본값은 즉시 로딩이지만, 위 코드에서는 일부러 fetch = FetchType.LAZY를 명시했다. (참고로 @OneToMany는 기본값이 이미 지연 로딩이다.)
그런데 지연 로딩에는 입문자를 거의 반드시 한 번은 울리는 함정이 있다. 앞에서 영속성 컨텍스트는 트랜잭션 동안에만 살아 있다고 했던 걸 기억하는가? 지연 로딩은 그 컨텍스트가 살아 있을 때만 작동한다. 진짜 데이터가 필요한 순간 추가 쿼리를 날리려면, 쿼리를 날릴 무대(트랜잭션과 컨텍스트)가 아직 열려 있어야 하기 때문이다.
트랜잭션이 끝난 뒤 컨트롤러에서, 아직 안 읽은 task.getUser().getName()을 부르면?
지금은 이 함정의 존재와 원리만 또렷이 알아 두자. 지연 로딩은 영속성 컨텍스트가 살아 있어야 작동하고, 컨텍스트는 트랜잭션과 운명을 같이한다. 그러니 트랜잭션 밖에서 지연 로딩을 건드리면 터진다. 이 함정이 운영에서 어떻게 모습을 드러내는지, 어떻게 길들이는지는 8장에서 N+1 문제와 함께 정면으로 다룬다. 원리를 알면 8장의 해법이 암기가 아니라 납득이 된다.
@ManyToOne의 fetch 전략을 고르고 위에서부터 순서대로 실행해 보세요. 중간에 트랜잭션을 끝내면 어떻게 될까요? (spring.jpa.open-in-view: false 기준)
- 해볼 것
- LAZY에서 프록시가 SELECT를 날리는 순간 보기
- LazyInitializationException 터뜨리기
- EAGER로 바꿔 findById 한 번에 무엇이 나가는지 보기
ch07-5 OSIV를 끄면 터지는 LazyInitializationException명령과 기대 출력 펼치기
실행해 보기
./gradlew bootRun시작 로그에서 spring.jpa.open-in-view is enabled by default 경고가 사라졌다.
curl -i http://localhost:8080/api/tasks/3/ownerHTTP/1.1 500
{"timestamp":"…","status":500,"error":"Internal Server Error","path":"/api/tasks/3/owner"}
서버 로그:
org.hibernate.LazyInitializationException: Could not initialize proxy [com.example.taskboard.User#2] - no session
findTaskEntity가 끝나면서 출석부가 닫혔다. 컨트롤러가 받은 task는 출석부에서 떨어진 준영속(detached) 객체이고, 그 안의 user는 아직 채워지지 않은 프록시다. 프록시를 채우려 해도 물어볼 출석부(세션)가 없다.
이 단계는 /owner가 500을 돌려주는 것이 정상이다. 다음 단계에서 고친다.
OSIV를 켜 두면 이 예외는 사라지지만, 요청이 끝날 때까지 DB 커넥션을 붙잡고 있고 컨트롤러·뷰 어디서든 쿼리가 나갈 수 있다. 이 레포는 이후 단계에서도 OSIV를 끈 채로 간다. 필요한 데이터는 트랜잭션 안에서 다 꺼내 DTO로 바꿔 내보낸다.
모든 마법의 무대 — @Transactional이라는 경계
출석부, 더티 체킹, 1차 캐시, 지연 로딩. 지금까지 이야기한 모든 것에는 공통된 전제가 하나 있었다. 트랜잭션이 열려 있어야 한다는 것이다. 영속성 컨텍스트는 트랜잭션과 함께 태어나 트랜잭션과 함께 죽는다. 출석부는 수업 시간에만 펼쳐져 있다.
그 트랜잭션의 경계를 긋는 게 @Transactional 어노테이션이다. 이 장 맨 앞 코드에서 completeTask 위에 슬쩍 붙어 있던 그 녀석이다. 메서드에 @Transactional을 붙이면, Spring은 그 메서드가 시작될 때 트랜잭션을 열고(출석부를 펼치고), 정상적으로 끝나면 커밋한다(변동을 학적부에 반영하고 출석부를 닫는다). 도중에 예외가 터지면 롤백한다(변동을 없던 일로 돌린다).
@Transactional
public void completeTask(Long id) {
// ── 여기서 트랜잭션 시작, 출석부 펼침 ──
Task task = taskRepository.findById(id).orElseThrow(...);
task.markDone();
// ── 메서드 끝, 커밋: 더티 체킹이 UPDATE 를 날리고 출석부 닫음 ──
}
이제 맨 앞의 수수께끼가 완전히 풀린다. completeTask에 @Transactional이 붙어 있었으니 그 안에서 출석부가 펼쳐졌다. findById로 꺼낸 task는 출석부에 올라 영속 상태가 됐다. markDone()으로 바꾼 값은 메서드가 끝나는 순간 더티 체킹에 감지되어 자동 UPDATE로 반영됐다. save()가 없어도 저장된 이유가 여기 있었다.
completeTask에서 @Transactional 한 줄을 지우면?
ch07-6 지연 로딩은 트랜잭션 안에서 끝낸다명령과 기대 출력 펼치기
실행해 보기
./gradlew bootRun
curl -i http://localhost:8080/api/tasks/3/ownerHTTP/1.1 200
bogum
SQL은 ch07-4와 똑같이 두 번 나간다. 바뀐 건 두 번째 SELECT가 나가는 자리다. 이제 컨트롤러가 아니라 서비스의 트랜잭션 안에서 나간다.
Hibernate: select … from task t1_0 where t1_0.id=?
Hibernate: select u1_0.id,u1_0.name from users u1_0 where u1_0.id=?
없는 id는 6장에서 만든 404 응답을 받는다. POST로 만든 할 일은 주인이 없어서 빈 응답(200)이 온다.
@Transactional을 둘러싼 더 미묘한 함정도 있다. 예컨대 같은 클래스 안에서 메서드를 호출하면 트랜잭션이 안 먹는다. 이런 함정은 8장에서 함께 정리하자. 지금은 이 한 가지가 핵심이다. @Transactional은 영속성 컨텍스트라는 무대의 막을 올리고 내리는 경계다. 이 경계를 의식하는 순간, JPA의 모든 동작이 “언제 무대가 열려 있나”라는 하나의 질문으로 환원된다. 기억해 두자.
AI에게 연관관계를 맡길 때 — 방향과 주인을 되물어라
AI에게 “Task와 User를 연관관계로 묶어줘”라고 부탁하면 그럴듯한 매핑 코드가 순식간에 나온다. 하지만 그대로 받아들이기 전에 세 가지는 반드시 직접 되물어 보자.
첫째, 이게 단방향이야, 양방향이야? 그리고 왜? AI는 묻지도 않았는데 양방향으로 만들어 놓는 경우가 많다. 하지만 앞서 봤듯 양방향은 연관관계의 주인을 신경 써야 하고 관리할 지점이 늘어난다. 필요 없으면 단방향으로 단순하게 가자. AI는 일반론으로 풍성한 쪽을 택하기 쉽지만, 단순함의 가치는 우리가 판단해야 한다.
둘째, 로딩 전략이 LAZY로 돼 있나? AI가 @ManyToOne을 만들면서 fetch 설정을 생략하면 기본값인 즉시 로딩(EAGER)이 된다. 앞서 이야기했듯 이건 다음 장 N+1 문제의 씨앗이 되기 쉽다. 생성된 @ManyToOne에 fetch = FetchType.LAZY가 명시돼 있는지 눈으로 확인하고, 없으면 채워 넣자.
셋째, 무엇보다 이 연관관계로 목록을 조회하면 어떤 SQL이 나가는지 직접 확인하자. 6장에서 켜 둔 show-sql이 여기서 진가를 발휘한다. AI에게 물어 답을 받되 곧이곧대로 믿지 말고, 직접 실행해 콘솔에 찍히는 SQL과 대조하자. AI의 설명과 실제 동작이 어긋나는 순간이 우리가 배우는 순간이고, 그 순간이 다음 장 N+1의 출발점이다. 잊지 말자. AI는 매핑의 스캐폴드를 만들고, 그 매핑이 어떤 SQL로 번역되는지 검증하는 건 끝까지 우리 몫이다.
“Task와 User를 연관관계로 묶어줘. 할 일 목록 화면에 담당자 이름만 보여주면 돼.” (Spring Boot 3.5 기준)
의심스러운 줄을 모두 눌러 표시한 뒤 “검토 끝”을 누르세요. 함정은 3개입니다.
마무리
이번 장에서는 JPA의 “보이지 않는 일꾼”의 정체를 들여다봤다. 핵심은 영속성 컨텍스트라는 출석부 하나였다. 트랜잭션이 열리면 펼쳐지고 닫히면 사라지는 이 출석부가, 우리가 본 모든 마법의 무대였다. 출석부 하나로 더티 체킹(save() 없는 저장), 1차 캐시(같은 트랜잭션 안 같은 객체), 엔티티의 생애(비영속·영속·준영속), 지연 로딩(프록시로 미뤄 둔 쿼리)이 전부 설명된다. 그리고 이 모든 무대의 막을 올리고 내리는 경계가 @Transactional이었다. 여기에 객체가 객체를 가리키는 연관관계 매핑까지 더하면서, 외래 키를 가진 쪽이 주인이라는 점도 새겼다.
여기까지 오면 JPA는 더 이상 마법이 아니다. “언제 출석부가 펼쳐져 있나, 이 엔티티는 출석부에 있나, 이 필드는 진짜 읽혔나”를 묻는 순간, 모든 동작이 예측 가능해진다.
그런데 솔직히 털어놓자면, 아직은 마법의 편안한 면만 봤다. 개발할 땐 데이터가 적어 멀쩡하던 코드가, 운영에 올라가 데이터가 쌓이는 순간 갑자기 느려 터지는 일이 있다. 조회 한 번에 SQL이 수십, 수백 개씩 줄지어 나가는 그 악명 높은 N+1 문제, 그리고 방금 예고한 LazyInitializationException이 운영에서 모습을 드러내는 순간 말이다. 다음 장에서는 마침내 그 어두운 면을 정면으로 마주한다. 오늘 만든 User(1)—Task(N) 연관관계가 그 N+1 해부의 첫 표본이 된다.
1. save()가 꼭 필요한 경우는?
2. User.tasks에 mappedBy가 붙어 있다. task의 담당자를 DB에 반영하려면?
3. @Transactional(readOnly = true)를 붙이면 무엇이 가벼워지나?
4. 모든 JPA 동작을 예측할 때 가장 먼저 물어야 할 질문은?
이 장의 실습은 단계마다 커밋으로 준비해 두었고, 각 단계는 본문의 해당 자리에 있습니다. 처음이라면 레포부터 받습니다.
git clone https://github.com/younggeun0/toby-react-to-spring-taskboard.git
cd toby-react-to-spring-taskboard
git checkout ch07-1