Spring Data JPA 실무 완전 정복 PART 1

들어가며 — SQL을 없애려는 기술이 아니라, 객체 흐름을 맞추는 기술

JPA는 SQL을 감추는 기술이 아니다. JPA의 목적은 객체 모델과 관계형 모델 사이의 불일치를 관리하는 것이다. SQL은 여전히 중요하고, 실제로 JPA를 잘 쓸수록 SQL을 더 정확히 이해해야 한다. 다만, 반복적인 CRUD와 객체 상태 관리를 프레임워크에 위임함으로써 도메인 로직의 흐름을 코드 중심으로 유지할 수 있게 해준다.

이 글은 다음 원칙을 기준으로 정리한다.

  • 왜 이 개념이 필요한가
  • JPA 내부에서 실제로 무슨 일이 일어나는가
  • 실무에서 어디서 터지는가
  • 그래서 어떻게 써야 하는가

이번 파트(1)에서는 JPA 기초, Entity 매핑, 값 타입까지를 다룬다.


1. JPA란 무엇인가

1.1 ORM과 JPA의 역할

ORM(Object-Relational Mapping)은 객체와 테이블을 매핑하는 기술이다. 여기서 중요한 점은 매핑 자체보다 생명주기와 상태 관리다.

JPA는 자바 ORM의 표준 인터페이스이며, 대표 구현체는 Hibernate다. 즉, JPA가 명세라면 Hibernate는 구현이라는 관계를 가진다.

JPA를 사용하면 다음 책임을 프레임워크에 위임한다.

  • INSERT / UPDATE / DELETE SQL 생성
  • 객체 상태 변경 추적
  • 트랜잭션 단위 쓰기 지연
  • 1차 캐시를 통한 동일성 보장

개발자는 대신 엔티티의 상태 변화에만 집중한다.


2. JPA 기본 구조

2.1 EntityManagerFactory와 EntityManager

JPA의 시작점은 EntityManagerFactory다. 이는 애플리케이션 전체에서 하나만 생성된다.

1
EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");

EntityManager는 실제 작업 단위다.

1
EntityManager em = emf.createEntityManager();
  • EntityManager스레드에 안전하지 않다
  • 요청 단위, 트랜잭션 단위로 생성/폐기한다

Spring 환경에서는 이 과정을 @Transactional과 프록시가 대신 관리한다.


3. Entity란 무엇인가

3.1 @Entity의 의미

@Entity는 단순한 마커가 아니다. 이 어노테이션이 붙은 클래스는 다음 제약을 가진다.

  • 기본 생성자 필수 (public 또는 protected)
  • final 클래스 불가
  • enum, interface 불가
  • 영속 필드에 final 사용 불가
1
2
3
4
5
@Entity
public class Member {
    @Id
    private Long id;
}

JPA는 리플렉션 기반으로 엔티티를 생성하고 조작한다. 그래서 생성자와 클래스 구조에 제약이 생긴다.


3.2 엔티티와 테이블 매핑

1
2
3
4
@Entity
@Table(name = "members")
public class Member {
}
  • @Entity → 객체를 엔티티로 인식
  • @Table → 매핑될 테이블 지정

@Table을 생략하면 엔티티 이름을 테이블 이름으로 사용한다.


4. 기본 키 매핑 전략

4.1 @Id와 @GeneratedValue

1
2
3
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

주요 전략은 다음과 같다.

  • IDENTITY : DB에 위임 (AUTO_INCREMENT)
  • SEQUENCE : 시퀀스 사용 (Oracle, PostgreSQL)
  • TABLE : 키 테이블 사용
  • AUTO : DB 방언에 따라 자동 선택

실무 기준

  • MySQL 계열 → IDENTITY
  • Oracle / PostgreSQL → SEQUENCE

IDENTITY 전략은 INSERT 시점에만 PK를 알 수 있기 때문에 쓰기 지연이 제한된다. 이 차이는 성능 튜닝에서 중요하다.


5. 필드와 컬럼 매핑

5.1 @Column

1
2
@Column(nullable = false, length = 10)
private String name;

@Column의 주요 속성:

  • nullable : NOT NULL
  • length : VARCHAR 길이
  • unique : 단일 컬럼 유니크
  • columnDefinition : DDL 직접 지정

주의할 점은, DDL 옵션은 스키마 생성에만 영향을 주며, 런타임 로직에는 영향이 없다는 것이다.


5.2 Enum 매핑

1
2
@Enumerated(EnumType.STRING)
private RoleType roleType;

반드시 EnumType.STRING을 사용해야 한다.

  • ORDINAL → 순서 저장 → enum 변경 시 데이터 깨짐

이건 선택이 아니라 규칙에 가깝다.


5.3 날짜 타입 매핑

과거:

1
2
@Temporal(TemporalType.TIMESTAMP)
private Date createdDate;

현재 권장:

1
private LocalDateTime createdAt;

Java 8+에서는 java.time 타입을 그대로 사용하면 된다.


6. 값 타입(Value Type)

6.1 값 타입과 엔티티 타입의 차이

JPA는 타입을 크게 두 가지로 나눈다.

  • 엔티티 타입: 식별자 있음, 생명주기 관리
  • 값 타입: 식별자 없음, 소속 개념
1
2
3
4
5
6
7
8
9
10
11
@Embeddable
public class Address {
    private String city;
    private String street;
}

@Entity
public class Member {
    @Embedded
    private Address address;
}

Address는 단독으로 의미를 가지지 않는다. 항상 Member에 소속된다.


6.2 값 타입은 불변으로 설계하라

값 타입은 공유될 수 있기 때문에 불변 객체로 설계해야 한다.

1
2
3
4
5
6
7
@Embeddable
public class Address {
    protected Address() {}

    private final String city;
    private final String street;
}

값 타입을 변경하고 싶다면, 새 객체를 생성해서 교체해야 한다.


6.3 값 타입 컬렉션의 위험성

1
2
@ElementCollection
private List<Address> addresses;

값 타입 컬렉션은 내부적으로 별도 테이블을 사용하며,

  • 수정 시 DELETE + INSERT
  • 추적 단위가 컬렉션 전체

이라는 특성이 있다.

실무에서는 값 타입 컬렉션 대신 엔티티로 승격하는 경우가 많다.


여기까지 요약

  • JPA는 SQL 제거 기술이 아니라 객체 상태 관리 기술이다
  • Entity는 제약 조건을 가진다
  • ID 전략은 성능과 직결된다
  • Enum은 STRING으로
  • 값 타입은 불변 + 소속 개념

7. JPA의 절반은 연관관계다

JPA를 어렵게 만드는 핵심은 문법이 아니라 연관관계에 대한 사고 전환이다. 객체 세계에서는 참조(reference)가 자연스럽지만, 관계형 데이터베이스에서는 외래 키(FK)가 중심이다. 이 차이를 제대로 이해하지 못하면 다음 문제가 반복된다.

  • 왜 값은 저장됐는데 연관관계가 안 보일까?
  • 왜 조회만 했는데 쿼리가 수십 개 나갈까?
  • 왜 수정하지 않았는데 UPDATE가 나갈까?

이 파트에서는 연관관계 매핑의 원리와 실무 기준을 코드 중심으로 정리한다.


8. 객체 연관관계와 테이블 연관관계의 차이

8.1 객체는 방향이 있고, 테이블은 방향이 없다

객체의 연관관계는 방향성을 가진다.

1
2
3
class Member {
    Team team; // 단방향
}

반면 테이블은 외래 키 하나로 양쪽 조인이 가능하다.

1
2
SELECT * FROM member m JOIN team t ON m.team_id = t.id;
SELECT * FROM team t JOIN member m ON m.team_id = t.id;

즉, 테이블에는 방향 개념이 없고 조인 조건만 존재한다.

이 차이 때문에 JPA에서는 객체 연관관계와 테이블 연관관계를 동시에 고려해야 한다.


9. 단방향 연관관계 — 실무의 기본값

9.1 다대일 단방향 매핑

가장 기본적이고 안정적인 매핑이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "team_id")
    private Team team;
}

@Entity
public class Team {
    @Id @GeneratedValue
    private Long id;
    private String name;
}
  • 객체: Member -> Team
  • 테이블: member.team_id (FK)

이 구조는 객체 모델과 테이블 모델이 자연스럽게 일치한다.

실무 기준

  • 연관관계의 시작은 항상 다대일
  • 단방향부터 설계
  • 양방향은 반드시 필요할 때만 추가

10. 양방향 연관관계 — 주인 개념의 등장

10.1 양방향은 사실 단방향 2개다

1
2
3
4
5
@Entity
public class Team {
    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();
}

이제 객체는 양방향이다.

  • Member.team
  • Team.members

하지만 테이블은 여전히 외래 키 하나만 가진다.

그래서 JPA는 외래 키를 관리하는 쪽을 하나 정해야 한다.


10.2 연관관계의 주인(Owner)

  • 외래 키를 관리하는 객체
  • @JoinColumn이 있는 쪽
1
2
3
@ManyToOne
@JoinColumn(name = "team_id")
private Team team; // 연관관계의 주인
1
2
@OneToMany(mappedBy = "team")
private List<Member> members; // 주인 아님

핵심 규칙

  • 주인만이 외래 키를 변경한다
  • mappedBy는 읽기 전용

10.3 양방향 연관관계에서 가장 많이 하는 실수

1
2
3
4
5
6
Team team = new Team("A");
Member member = new Member("m1");

team.getMembers().add(member);
em.persist(team);
em.persist(member);

이 코드는 외래 키가 저장되지 않는다.

왜냐하면 Team.members는 연관관계의 주인이 아니기 때문이다.

정답:

1
2
member.setTeam(team);
team.getMembers().add(member); // 편의 메서드

그래서 실무에서는 연관관계 편의 메서드를 반드시 만든다.

1
2
3
4
public void changeTeam(Team team) {
    this.team = team;
    team.getMembers().add(this);
}

11. 다양한 연관관계 매핑

11.1 일대다 단방향 — 실무에서 피하라

1
2
3
@OneToMany
@JoinColumn(name = "team_id")
private List<Member> members;

문제점:

  • 외래 키가 반대편 테이블에 있음
  • INSERT 후 UPDATE 추가 발생
  • SQL 제어가 어려움

실무에서는 거의 사용하지 않는다.


11.2 일대일 매핑

외래 키 위치에 따라 성격이 달라진다.

  • 주 테이블 FK
  • 대상 테이블 FK

일반적으로는 다대일 구조로 풀 수 있는지 먼저 검토한다.


11.3 다대다 매핑 — 사용 금지에 가깝다

1
2
@ManyToMany
private List<Product> products;

문제:

  • 중간 테이블에 컬럼 추가 불가
  • 확장 불가
  • 실무 요구사항 대응 불가

대신:

1
2
3
4
5
6
7
8
9
10
11
@Entity
public class OrderProduct {
    @Id @GeneratedValue
    private Long id;

    @ManyToOne
    private Order order;

    @ManyToOne
    private Product product;
}

중간 엔티티로 승격하는 것이 정답이다.


12. 고급 매핑 — 상속 관계

12.1 상속 관계 매핑 전략

  • JOINED
  • SINGLE_TABLE
  • TABLE_PER_CLASS

실무에서는 주로 JOINED 또는 SINGLE_TABLE을 사용한다.

1
2
3
4
5
6
@Entity
@Inheritance(strategy = InheritanceType.JOINED)
public abstract class Item {
    @Id @GeneratedValue
    private Long id;
}

SINGLE_TABLE은 성능은 좋지만 NULL 컬럼이 많아진다.


13. 연관관계와 성능

13.1 FetchType의 중요성

1
@ManyToOne(fetch = FetchType.LAZY)

모든 연관관계는 LAZY가 기본이다.

EAGER는 다음 문제를 만든다.

  • 예상 못한 조인
  • N+1 발생
  • 튜닝 불가능한 SQL

여기까지 요약

  • 연관관계는 객체/테이블 관점 모두 고려해야 한다
  • 다대일 단방향이 기본
  • 양방향은 주인 개념이 핵심
  • 일대다 단방향, 다대다는 실무에서 위험
  • 연관관계 편의 메서드는 필수

13. JPA가 어려운 이유는 SQL이 보이지 않기 때문이다

JPA를 처음 배우면 CRUD는 금방 익힌다. 문제는 그 다음이다. 코드에는 단순히 get() 한 줄만 있는데, 로그를 보면 갑자기 SELECT가 튀어나온다. 트랜잭션이 끝난 뒤에는 분명히 조회해 둔 엔티티인데 LazyInitializationException이 발생한다. 이 시점부터 JPA는 마법처럼 느껴진다.

하지만 JPA는 마법이 아니다. JPA는 SQL을 숨기는 기술이 아니라, 언제 SQL을 실행할지를 규칙으로 관리하는 기술다. 그 규칙의 중심에는 항상 영속성 컨텍스트가 있고, 그 위에서 프록시지연 로딩이 동작한다.

이 파트의 목표는 단순하다.
JPA는 언제 실제 객체를 만들고, 언제 DB 접근을 미루며, 언제 더 이상 아무것도 할 수 없게 되는가를 실행 관점에서 이해하는 것이다.


14. 영속성 컨텍스트는 실행 환경이

영속성 컨텍스트 1차 캐시를 코드로 검증

1
2
3
4
Member m1 = em.find(Member.class, 1L);
Member m2 = em.find(Member.class, 1L);

System.out.println(m1 == m2); // true

의미

  • 같은 트랜잭션
  • 같은 EntityManager
  • 같은 식별자

완전히 같은 객체

DB를 두 번 조회하지 않는 이유도 여기 있다.

영속성 컨텍스트는 캐시가 아니라 실행 환경이다

영속성 컨텍스트를 흔히 1차 캐시라고 설명하지만, 이 표현은 반만 맞다. 캐시라는 말만으로는 JPA의 동작을 설명하기에 부족하다. 영속성 컨텍스트는 엔티티를 보관하는 공간인 동시에, 엔티티의 생명주기를 관리하고 SQL 실행 시점을 결정하는 실행 환경에 가깝다.

Spring Data JPA 환경에서는 보통 하나의 트랜잭션이 하나의 영속성 컨텍스트를 가진다. 트랜잭션이 시작되면 영속성 컨텍스트가 생성되고, 트랜잭션이 종료되면 함께 닫힌다. 이 범위를 벗어나면 JPA는 더 이상 엔티티를 관리하지 않는다.

이 사실 하나만 제대로 이해해도 이후 대부분의 문제가 설명된다.


15. 동일성 보장은 DB가 아니라 영속성 컨텍스트가 한다

다음 코드를 보자.

1
2
3
4
5
6
@Transactional
public void test() {
    Member m1 = em.find(Member.class, 1L);
    Member m2 = em.find(Member.class, 1L);
    System.out.println(m1 == m2);
}

출력 결과는 항상 true다. 이유는 단순하다. 첫 번째 find가 실행되면 JPA는 DB에서 데이터를 조회한 뒤, 그 결과를 영속성 컨텍스트에 저장한다. 두 번째 find는 DB를 다시 보지 않고, 이미 관리 중인 동일 엔티티 인스턴스를 그대로 반환한다.

이 동일성 보장은 단순한 최적화가 아니다. 동일 트랜잭션 안에서 엔티티 비교가 안정적으로 동작하게 만들고, 변경 감지가 정확히 수행될 수 있는 전제가 된다. JPA가 객체 모델을 신뢰할 수 있게 만드는 핵심 장치다.


16. find와 getReference는 전혀 다른 의도를 가진 API다

findgetReference는 같은 엔티티 타입을 반환하지만, 그 목적과 동작은 완전히 다르다.

find는 즉시 DB에 접근한다. 결과가 있으면 실제 엔티티 인스턴스를 만들어 반환한다. 반면 getReference는 DB에 접근하지 않는다. 대신 프록시 객체를 반환하고, 실제 데이터 접근을 나중으로 미룬다.

1
2
Member a = em.find(Member.class, 1L);
Member b = em.getReference(Member.class, 1L);

여기서 a는 이미 초기화된 실제 엔티티고, b는 아직 내용이 채워지지 않은 프록시다. 이 차이를 이해하지 못하면, JPA의 SQL 실행 타이밍은 절대 예측할 수 없다.


17. 프록시는 가짜 객체가 아니라 지연을 위한 대리인이다

1
Member member = em.getReference(Member.class, 1L);

이 시점에서 중요한 사실은 단 하나다.

  • 이 줄에서는 SQL이 나가지 않는다
  • member는 실제 Member가 아니라 프록시 객체

다음 코드를 살펴보자.

1
System.out.println(member.getClass());

출력 예시는 다음과 같다.

1
class com.example.Member$HibernateProxy$AbCdEf

즉, JPA는 Member를 상속한 가짜 클래스를 하나 더 만들어서 돌려준다. 이 객체는 내부에 다음 두 가지를 들고 있다.

  • 식별자(id)
  • 실제 엔티티를 로딩하는 로더

프록시는 엔티티 클래스를 상속한 하위 클래스다. 타입은 엔티티와 동일하지만, 실제 구현은 Hibernate가 런타임에 생성한 클래스다. 이 프록시 객체는 식별자 값만 가지고 있고, 나머지 필드는 비어 있다.

프록시의 역할은 명확하다. 아직 필요 없는 데이터를 대신 들고 있다가, 정말 필요한 순간에만 DB를 조회한다.

이 때문에 프록시를 다룰 때 중요한 질문은 항상 이것이다.
지금 이 코드에서, 실제 데이터 접근이 발생하는가.


프록시는 언제 초기화되는가

JPA에서 프록시는 단순한 트릭이 아니라, 지연 로딩(LAZY)을 가능하게 만드는 핵심 메커니즘이다. 엔티티를 즉시 로딩하지 않고, 필요해질 때까지 DB 접근을 미루기 위해 JPA는 실제 엔티티 대신 프록시 객체를 반환한다.

이 프록시는 엔티티 클래스를 상속한 가짜 객체이며, 내부에 EntityManager와 식별자(PK)를 들고 있다. 필드 접근이 발생하는 순간, 그때 SQL을 날려 실제 데이터를 채운다.

1-1. 프록시 생성 vs 초기화

프록시는 식별자 외의 필드에 접근하는 순간 초기화된다.

1
2
3
4
5
6
7
Member member = em.getReference(Member.class, 1L);

// 아직 SQL 안 나감
Long id = member.getId();   // 식별자는 프록시 내부에 이미 있음

String name = member.getName(); 
// 이 시점에서 SELECT 쿼리 실행 (프록시 초기화)

핵심은 이거다.

  • getId() : 프록시 초기화 ❌
  • 그 외 필드 접근 : 프록시 초기화 ⭕

그래서 프록시는 필드 접근 시점에 터진다고 기억하면 된다.

여기서 중요한 전제 조건이 있다. 영속성 컨텍스트가 살아 있어야 한다는 점이다. 프록시는 스스로 DB에 접근하지 못한다. 항상 영속성 컨텍스트를 통해 초기화를 요청한다.

따라서 getId()는 안전하다

프록시는 식별자 값은 이미 알고 있기 때문이다. 반면, name은 실제 데이터이므로 DB 조회 없이는 알 수 없다. 이 차이를 이해하지 못하면 왜 여기서는 쿼리가 안 나가고, 저기서는 나가지? 같은 혼란이 생긴다.


2. 프록시와 equals/hashCode 문제

프록시는 실제 엔티티 클래스의 하위 타입이다. 그래서 getClass() 기반 equals 구현은 거의 항상 문제를 만든다.

1
2
3
4
5
6
7
@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || getClass() != o.getClass()) return false;
    Member member = (Member) o;
    return id != null && id.equals(member.id);
}

위 구현은 프록시와 비교 시 깨진다.

  • 실제 엔티티 클래스: Member
  • 프록시 클래스: Member$HibernateProxy$…

getClass() 비교는 무조건 false다.

안전한 구현

1
2
3
4
5
6
7
@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (!(o instanceof Member)) return false;
    Member member = (Member) o;
    return id != null && id.equals(member.getId());
}
  • instanceof 사용
  • 비즈니스 키가 아니라 식별자 기반
  • 프록시 초기화 유발하지 않도록 getId()만 사용

프록시를 고려한 equals는

  • instanceof 사용
  • id 기준 비교 이 두 가지가 핵심이다. —

18. 지연 로딩(LAZY)은 언제 위험해지는가

지연 로딩 자체는 문제가 아니다. 문제는 영속성 컨텍스트가 닫힌 이후 프록시에 접근할 때다.

1
2
3
4
@Transactional
public Member find(Long id) {
    return em.find(Member.class, id);
}
1
2
Member member = service.find(1L);
member.getTeam().getName(); // LazyInitializationException

트랜잭션 종료 시 영속성 컨텍스트가 닫혔기 때문이다.

다음 예제도 살펴보자.

1
2
3
4
5
6
7
8
@Entity
class Member {
    @Id @GeneratedValue
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    private Team team;
}
1
2
3
4
5
6
7
8
Member member = em.find(Member.class, 1L);
// 여기까지는 Member만 조회

Team team = member.getTeam();
// 여기까지도 SQL 없음

String teamName = team.getName();
// 여기서 Team SELECT 발생

중요한 건, getTeam() 호출이 아니라 team 내부의 실제 필드 접근 시점이라는 점이다.


19. LAZY 로딩의 진짜 의미

LAZY는 연관 엔티티를 나중에 가져오겠다는 선언이다. 하지만 구현 관점에서 보면, 이는 연관 엔티티 대신 프록시를 넣어 두겠다는 의미에 가깝다.

1
2
@ManyToOne(fetch = FetchType.LAZY)
private Team team;

이 설정이 있다고 해서 Team을 절대 조회하지 않는 것은 아니다. team.getName() 같은 접근이 발생하는 순간, 프록시는 초기화되고 DB 조회가 실행된다.

즉, LAZY는 안전장치가 아니라 전략이다. 언제 조회될지는 코드 흐름에 따라 달라진다.

FetchType.LAZY가 기본이어야 하는 이유 (실무 관점으로 정리)

JPA에서 연관관계는 객체 그래프를 자연스럽게 만들기 때문에, 엔티티 하나를 조회하면 그 주변도 함께 가져오고 싶다는 유혹이 자주 생긴다. 하지만 이 유혹을 EAGER로 해결하면, 문제는 편한 조회가 아니라 예측 불가능한 쿼리 폭발로 바뀐다. 실무에서 LAZY를 기본으로 두는 이유는 단순하다. 쿼리의 실행 시점과 범위를 개발자가 통제할 수 있어야 하기 때문이다.

가장 위험한 순간은 어떤 레이어가 엔티티를 가져왔는지 모르는 상태에서, 어딘가에서 getter가 불린다는 상황이다. EAGER는 그 순간에 추가 쿼리를 날리지 않더라도(조인으로 한번에 가져오더라도), 조인 대상이 늘수록 중복 로우가 폭발하거나(특히 컬렉션 조인), 예상치 못한 Cartesian Product에 가까운 결과를 만들 수 있다. 반면 LAZY는 접근하기 전까지는 가져오지 않는다는 규칙으로, 필요한 지점에만 페치 전략을 선택할 수 있게 해 준다.

다시 말해 기본값은 LAZY가 맞다. 그리고 정말로 함께 필요하다가 확인된 특정 조회에만 fetch join이나 EntityGraph 같은 수단으로 명시적으로 로딩 범위를 결정하는 것이 실무적으로 안전하다.

EAGER의 실무적 위험: 내가 쿼리를 안 썼는데 쿼리가 나간다

EAGER가 무서운 이유는 성능 때문만이 아니다. 추론 불가능성 때문이다. 예를 들어 다음 코드를 보자.

1
2
3
4
5
6
7
8
9
10
@Entity
class Order {
    @Id @GeneratedValue Long id;

    @ManyToOne(fetch = FetchType.EAGER)
    private Member member;

    @OneToMany(mappedBy = "order", fetch = FetchType.EAGER)
    private List<OrderItem> orderItems = new ArrayList<>();
}

이 상태에서 단순 조회를 했다.

1
List<Order> orders = orderRepository.findAll();

개발자는 주문만 가져왔다고 생각하지만, 실제로는 member와 orderItems까지 한꺼번에 로딩하려고 시도한다. 문제는 컬렉션 EAGER가 조인으로 한 번에 풀리기도 하고, 상황에 따라 여러 쿼리로 풀리기도 하며, JPA 구현체/쿼리 형태/DB 옵션에 따라 실행 계획이 달라져서 일관된 예측이 어렵다는 점이다.

그래서 실무에서 기본 원칙은 다음 한 줄이다.

연관관계는 전부 LAZY로 두고, 조회 요구에 맞게 쿼리를 설계해서 가져온다.


LazyInitializationException은 왜 발생하는가

LazyInitializationException은 프록시가 초기화되어야 하는데, 이를 도와줄 영속성 컨텍스트가 이미 사라진 상태에서 발생한다.

1
2
3
Member member = repository.findById(1L).get();
Team team = member.getTeam();
team.getName(); // 트랜잭션 밖 → 예외

이 시점에서 문제는 DB 연결이 아니다. 영속성 컨텍스트가 이미 닫혀 있기 때문에, 프록시는 더 이상 자신을 초기화할 수 없다. 이 예외는 JPA가 잘못된 게 아니라, 엔티티를 사용하는 범위가 설계와 어긋났다는 신호다.


20. OSIV는 해결책이 아니라 타협이다

OSIV(Open Session In View)는 트랜잭션이 끝난 뒤에도 영속성 컨텍스트를 유지하는 전략이다. 덕분에 컨트롤러나 뷰 레이어에서도 LAZY 로딩이 가능해진다.

하지만 그 대가로 SQL 실행 시점이 코드에서 보이지 않게 된다. 화면 렌더링 도중 쿼리가 발생하고, 성능 문제를 추적하기 어려워진다. 실무에서는 OSIV를 끄고, 트랜잭션 범위 안에서 필요한 데이터를 명확히 로딩하는 설계가 더 안전한 경우가 많다.

1
2
3
4
5
@GetMapping("/members/{id}")
public String member(@PathVariable Long id) {
    Member member = memberService.find(id);
    return member.getTeam().getName();
}

OSIV가 켜져 있으면

컨트롤러 시점까지 영속성 컨텍스트가 살아있다. 그래서 team.getName()이 정상 동작한다

  • 컨트롤러까지 영속성 컨텍스트 유지
  • 프록시 초기화 가능
  • 대신 컨트롤러/뷰에서 SQL 발생 위험

OSIV를 끄면

  • 서비스 계층 종료 시점에 컨텍스트 종료
  • 컨트롤러에서 프록시 초기화 시도 → 예외
1
spring.jpa.open-in-view=false
  • 서비스 계층에서만 영속성 컨텍스트 유지
  • 컨트롤러에서 LAZY 접근 ❌
  • 대신 트랜잭션 경계가 명확

실무에서는 OSIV OFF + 서비스 계층에서 DTO 변환을 선호하는 경우가 많다.


21. 변경 감지(Dirty Checking)는 스냅샷 비교다

JPA가 UPDATE SQL을 자동으로 생성할 수 있는 이유는 변경 감지 덕분이다.

1
2
3
4
5
@Transactional
public void update(Long id) {
    Member member = em.find(Member.class, id);
    member.setName("newName");
}

엔티티를 조회할 때, JPA는 최초 상태의 스냅샷을 보관한다. 트랜잭션 커밋 또는 flush 시점에 현재 상태와 스냅샷을 비교해 변경된 필드가 있으면 UPDATE SQL을 생성한다. 개발자가 직접 UPDATE를 호출할 필요가 없는 이유가 여기에 있다.

1
2
3
4
5
@Transactional
public void changeName(Long id) {
    Member member = em.find(Member.class, id);
    member.setName("newName");
}

이 코드에서 중요한 점은 save를 호출하지 않았다는 것이다.

실행 흐름

  1. 트랜잭션 시작
  2. 엔티티 조회 → 영속 상태
  3. 필드 값 변경
  4. 트랜잭션 커밋
  5. flush 발생
  6. 변경 내역 감지 → UPDATE SQL 실행

이 흐름을 이해하지 못하면 왜 save 안 했는데 update가 나가지?라는 질문이 나온다. —

22. flush와 clear는 전혀 다른 의미를 가진다

flush는 영속성 컨텍스트의 변경 내용을 DB에 반영하는 동작이다. 하지만 트랜잭션을 종료하지는 않는다. 반면 clear는 영속성 컨텍스트를 비워, 더 이상 엔티티를 관리하지 않겠다는 선언이다.

clear 이후에는 동일성 보장도, 변경 감지도 동작하지 않는다. 이는 캐시 삭제가 아니라 관리 중단에 가깝다.

flush만 호출

1
2
3
member.setName("A");
em.flush();
// UPDATE SQL 실행
  • 영속성 컨텍스트 유지
  • 엔티티는 여전히 관리 상태

clear 호출

1
2
em.clear();
member.setName("B"); // 변경 감지 안 됨

clear 이후의 엔티티는 준영속 상태다. 이 상태에서는 변경해도 SQL이 나가지 않는다.

이는 PART 2 에서 더 자세히 다룰 예정.


23. merge가 위험하다고 말하는 이유

merge는 전달받은 객체를 그대로 영속 상태로 만드는 메서드가 아니다. merge는 새로운 영속 엔티티를 생성하고, 전달된 객체의 값을 복사한다.

1
2
3
4
5
Member detached = new Member();
detached.setId(1L);
detached.setName("new");

Member managed = em.merge(detached);

여기서 중요한 포인트는 이거다.

  • detached는 여전히 준영속
  • managed가 진짜 영속 엔티티
1
2
detached.setName("again"); // 반영 안 됨
managed.setName("final");  // 반영됨

merge를 무심코 쓰면 어떤 객체가 진짜인지 헷갈리는 구조가 만들어진다.

여기서 실제로 관리되는 객체는 managed다. detached는 여전히 detached 상태다. 반환값을 사용하지 않으면 변경 사항은 반영되지 않는다. 또한 null 값도 그대로 덮어쓰므로, 의도치 않은 데이터 손실로 이어질 수 있다.

실무에서는 merge보다 조회 후 수정 패턴이 더 안전하다.


24. orphanRemovalCascade를 애그리거트(Aggregate)로 이해하기

CascadeorphanRemoval은 기능 설명만 보면 간단하지만, 실무에서는 언제 켜야 하고 언제 끄면 안 되는가가 핵심이다. 이 둘은 JPA 기능이라기보다, 도메인 모델을 소유/생명주기 관점에서 어떻게 묶는지에 대한 설계 도구다.

24-1. CascadeType.ALL은 함께 살아야 하는가?에만 쓴다

예를 들어 주문(Order)과 주문상품(OrderItem)은 보통 함께 생성되고 함께 삭제된다. 주문상품이 주문 없이 독립적으로 존재하는 게 의미가 없다면, 이 관계는 도메인적으로 주문이 애그리거트 루트다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Entity
class Order {
    @Id @GeneratedValue Long id;

    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<OrderItem> items = new ArrayList<>();

    public void addItem(OrderItem item) {
        items.add(item);
        item.changeOrder(this);
    }

    public void removeItem(OrderItem item) {
        items.remove(item);
        item.changeOrder(null);
    }
}

@Entity
class OrderItem {
    @Id @GeneratedValue Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    private Order order;

    void changeOrder(Order order) { this.order = order; }
}

여기서 cascade = ALL은 Order를 persist/remove 하면 OrderItem도 함께 처리하라는 뜻이다. 즉, 루트에서 자식의 생명주기를 통제한다.

반대로 Member와 Team 같은 관계에서 Team을 지운다고 Member가 같이 삭제되면 안 된다. 이런 관계에 cascade remove를 걸면 사고가 난다. 요약하면 Cascade는 연관관계 편의가 아니라, 생명주기 소유권을 표현하는 것이다.

24-2. orphanRemoval=true는 컬렉션에서 빼면 DB에서도 지운다는 계약이다

orphanRemoval은 더 강하다. 컬렉션에서 엔티티를 제거하면, 연결만 끊는 게 아니라 실제로 delete가 나간다. 즉, 고아 객체는 DB에서도 존재하면 안 된다는 강한 규칙을 걸어버리는 것이다.

1
2
order.removeItem(item); 
// 트랜잭션 커밋 시점에 order_items row가 DELETE 될 수 있다.

실무 기준은 간단하다. 자식이 부모 없이 존재하면 데이터가 깨지는가?가 YES면 orphanRemoval을 고려한다. NO면 orphanRemoval은 위험하다. 특히 게시글(Post)과 댓글(Comment)처럼 댓글은 게시글 없으면 존재 의미가 없다는 구조에서는 자연스럽다. 반대로 파일(File) 같은 공유 가능한 리소스라면 orphanRemoval은 독이 된다.

24-3. 애그리거트 관점에서의 핵심 규칙

애그리거트를 기준으로 보면 결론이 단순해진다.

  • 루트가 아닌 엔티티를 직접 repository로 조작하지 않는다(가능하면).
  • 루트에서 자식의 추가/삭제를 수행하고, 연관관계를 동시에 관리한다.
  • Cascade + orphanRemoval은 루트가 자식 생명주기를 소유하는 경우에만 사용한다.

이렇게 하면 연관관계 편의 메서드가 단순한 코딩 습관이 아니라, 애그리거트 일관성을 강제하는 도구가 된다.


25. 양방향 연관관계에서 실무적으로 가장 중요한 것: 상태 일관성을 코드로 강제하기

양방향 매핑을 하면, 객체 그래프에는 두 방향의 포인터가 생긴다. 문제는 DB 외래키는 한 곳(연관관계 주인)에만 존재한다는 점이다. 그래서 실무에서 가장 자주 터지는 버그가 한쪽만 세팅하고 끝내서, 메모리 상태와 DB 상태가 갈라지는 것이다.

가장 안전한 방식은 연관관계 변경을 한 곳에서만 하게 만드는 것이다. 즉, setter를 열어두지 말고, 도메인 메서드로만 변경하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Entity
class Team {
    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();

    void addMember(Member m) {
        members.add(m);
        m.changeTeam(this);
    }
}

@Entity
class Member {
    @ManyToOne(fetch = FetchType.LAZY)
    private Team team;

    void changeTeam(Team team) { this.team = team; }
}

이렇게 하면 team.getMembers()에는 들어가 있는데 member.team은 null 같은 상태 불일치를 코드 레벨에서 막을 수 있다.


프록시와 LAZY는 ‘느리게’ 가져오는 기술이 아니라, 쿼리 실행 시점에 대한 통제권을 회복하는 방법이다. EAGER는 편해 보이지만, 실무에서는 예측 불가능한 비용으로 바뀌기 쉽다. 그리고 Cascade/orphanRemoval은 매핑 옵션이 아니라, 애그리거트 생명주기 소유권을 코드로 고정하는 수단이다.

JPA는 규칙을 아는 사람에게만 예측 가능하다

JPA의 모든 동작 기준은 영속성 컨텍스트다. 프록시는 지연을 위한 도구이고, LAZY는 전략일 뿐 만능 해결책이 아니다. 변경 감지는 강력하지만, 트랜잭션 경계가 명확하지 않으면 언제든 문제를 만든다.

이 파트를 이해하면, JPA는 더 이상 SQL을 숨기는 기술이 아니라, SQL 실행 시점을 통제하는 기술로 보이기 시작한다.