11-2. ORM과 LAZY/EAGER 전략

박은서's avatar
Feb 06, 2026
11-2. ORM과 LAZY/EAGER 전략

1. ORM (Object-Relational Mapping)

1️⃣ ORM의 개념

ORM은 객체지향 언어의 객체관계형 데이터베이스의 테이블을 자동으로 매핑해주는 기술
  • 클래스 ↔ 테이블
  • 객체 ↔ 행(Row)
  • 필드 ↔ 컬럼
  • 객체 참조 ↔ 외래키(FK)
SQL 중심이 아니라 객체 중심으로 DB를 다룰 수 있게 해주는 계층

2️⃣ ORM이 필요한 이유

객체지향과 RDB는 근본적으로 사고방식이 다름
  1. 객체는 참조(reference)를 사용
  1. DB는 외래키(FK)를 사용
  1. 상속, 다형성 개념이 DB에는 없음
➡️ ORM은 이 패러다임 불일치를 해결해줌

3️⃣ ORM의 장단점

1) 장점

  1. 반복적인 CRUD SQL 감소
  1. 객체 그래프 탐색 가능
  1. DB 벤더 독립성
  1. 유지보수성 향상

2) 단점

  1. 내부 동작을 모르면 성능 이슈 발생
  1. 복잡한 쿼리 튜닝이 어려울 수 있음
  1. 잘못 사용 시 쿼리 폭발(N+1 문제)

4️⃣ 실습

package com.example.boardv1.board; import java.sql.Timestamp; import org.hibernate.annotations.CreationTimestamp; import com.example.boardv1.user.User; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import lombok.Data; import lombok.NoArgsConstructor; /** * 데이터베이스 세상의 테이블을 자바 세상에 모델링한 결과 = 엔티티 */ @NoArgsConstructor // 디폴트 생성자 @Data // getter, setter, toString @Entity // 해당 어노테이션을 보고 컴퍼넌트 스캔 후 데이터베이스에 테이블을 생성 @Table(name = "board_tb") // 테이블명 설정 public class Board { // user 1, Board N @Id // id를 primary key로 설정 @GeneratedValue(strategy = GenerationType.IDENTITY) // auto increment 설정 private Integer id; private String title; private String content; // private Integer userId; @ManyToOne // FK지정 / Board가 many, User가 one private User user; // user_id = 1 (select * from user_tb where id = 1) @CreationTimestamp private Timestamp createdAt; // import 주의!! (java.sql) }
Board.java
insert into user_tb (username, password, email, created_at) values ('ssar', '1234', 'ssar@nate.com', now()); insert into user_tb (username, password, email, created_at) values ('cos', '1234', 'cos@nate.com', now()); insert into board_tb (user_id, title, content, created_at) values (1, 'title1', 'content1', now()); insert into board_tb (user_id, title, content, created_at) values (1, 'title2', 'content2', now()); insert into board_tb (user_id, title, content, created_at) values (1, 'title3', 'content3', now()); insert into board_tb (user_id, title, content, created_at) values (2, 'title4', 'content4', now()); insert into board_tb (user_id, title, content, created_at) values (2, 'title5', 'content5', now()); insert into board_tb (user_id, title, content, created_at) values (2, 'title6', 'content6', now());
data.sql 수정
notion image
Board 테이블에는 user_id(FK)만 저장되고,
JPA는 그 user_id를 이용해 User 객체를 “필요한 시점에” 조회해서 User user 필드에 매핑해줌

2. LAZY 전략과 EAGER 전략

1️⃣ LAZY 로딩 전략 (지연 로딩)

1) LAZY의 개념

연관된 엔티티를 실제로 사용할 때 조회하는 전략입니다.
@ManyToOne(fetch = FetchType.LAZY) private Team team;

2) LAZY의 동작 방식

  1. Member 조회
  1. Team은 실제 객체가 아닌 프록시 객체로 대체
  1. member.getTeam() 호출 시 SQL 실행
SELECT * FROM member WHERE id = 1; -- team 접근 시 SELECT * FROM team WHERE id = 10;

3) LAZY의 장점

  1. 불필요한 데이터 조회 방지
  1. 초기 쿼리가 가벼움
  1. 성능 최적화에 매우 유리
  1. 실무 기본 전략

4) LAZY의 단점

  1. 트랜잭션 종료 후 접근 시 예외 발생
      • LazyInitializationException
  1. N+1 문제 발생 가능

5) 실습

package com.example.boardv1.board; import java.sql.Timestamp; import org.hibernate.annotations.CreationTimestamp; import com.example.boardv1.user.User; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import lombok.Data; import lombok.NoArgsConstructor; /** * 데이터베이스 세상의 테이블을 자바 세상에 모델링한 결과 = 엔티티 */ @NoArgsConstructor // 디폴트 생성자 @Data // getter, setter, toString @Entity // 해당 어노테이션을 보고 컴퍼넌트 스캔 후 데이터베이스에 테이블을 생성 @Table(name = "board_tb") // 테이블명 설정 public class Board { // user 1, Board N @Id // id를 primary key로 설정 @GeneratedValue(strategy = GenerationType.IDENTITY) // auto increment 설정 private Integer id; private String title; private String content; // private Integer userId; @ManyToOne(fetch = FetchType.LAZY) // FK지정 / Board가 many, User가 one private User user; // user_id = 1 (select * from user_tb where id = 1) @CreationTimestamp private Timestamp createdAt; // import 주의!! (java.sql) }
package com.example.boardv1.board; import java.util.List; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; import org.springframework.context.annotation.Import; import jakarta.persistence.EntityManager; @Import(BoardRepository.class) @DataJpaTest // EntityManger가 ioc에 등록됨 public class BoardRepositoryTest { @Autowired // 어노테이션 DI 기법 private BoardRepository boardRepository; @Autowired private EntityManager em; @Test public void orm_test() { int id = 1; Board board = boardRepository.findById(id).get(); System.out.println("board -> user -> id : " + board.getUser().getId()); } }
BoardRepositoryTest.java
user_tb는 select 안 됨
user_tb는 select 안 됨
package com.example.boardv1.board; import java.util.List; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; import org.springframework.context.annotation.Import; import jakarta.persistence.EntityManager; @Import(BoardRepository.class) @DataJpaTest // EntityManger가 ioc에 등록됨 public class BoardRepositoryTest { @Autowired // 어노테이션 DI 기법 private BoardRepository boardRepository; @Autowired private EntityManager em; @Test public void orm_test() { int id = 1; Board board = boardRepository.findById(id).get(); System.out.println("board -> user -> id : " + board.getUser().getId()); System.out.println("----------------------------------------------"); System.out.println("board -> user -> username : " + board.getUser().getUsername()); } }
user_tb의 username을 호출했을 때 select됨
user_tb의 username을 호출했을 때 select됨

2️⃣ EAGER 로딩 전략 (즉시 로딩)

1) EAGER의 개념

엔티티를 조회할 때 연관 엔티티도 즉시 함께 조회하는 전략
@ManyToOne(fetch = FetchType.EAGER) private Team team;

2) EAGER의 동작 방식

Member를 조회하는 순간 Team도 함께 조회
SELECT m.*, t.* FROM member m LEFT JOIN team t ON m.team_id = t.id;

3) EAGER의 장점

  1. 연관 엔티티가 항상 보장됨
  1. 단순한 구조에서는 편리

4) EAGER의 단점

  1. 필요 없는 JOIN 증가
  1. 성능 저하 위험
  1. 쿼리 예측이 어려움
  1. 튜닝 및 유지보수 난이도 상승

5) 실습

package com.example.boardv1.board; import java.sql.Timestamp; import org.hibernate.annotations.CreationTimestamp; import com.example.boardv1.user.User; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import lombok.Data; import lombok.NoArgsConstructor; /** * 데이터베이스 세상의 테이블을 자바 세상에 모델링한 결과 = 엔티티 */ @NoArgsConstructor // 디폴트 생성자 @Data // getter, setter, toString @Entity // 해당 어노테이션을 보고 컴퍼넌트 스캔 후 데이터베이스에 테이블을 생성 @Table(name = "board_tb") // 테이블명 설정 public class Board { // user 1, Board N @Id // id를 primary key로 설정 @GeneratedValue(strategy = GenerationType.IDENTITY) // auto increment 설정 private Integer id; private String title; private String content; // private Integer userId; @ManyToOne(fetch = FetchType.EAGER) // FK지정 / Board가 many, User가 one private User user; // user_id = 1 (select * from user_tb where id = 1) @CreationTimestamp private Timestamp createdAt; // import 주의!! (java.sql) }
Board.java
package com.example.boardv1.board; import java.util.List; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; import org.springframework.context.annotation.Import; import jakarta.persistence.EntityManager; @Import(BoardRepository.class) @DataJpaTest // EntityManger가 ioc에 등록됨 public class BoardRepositoryTest { @Autowired // 어노테이션 DI 기법 private BoardRepository boardRepository; @Autowired private EntityManager em; @Test public void orm_test() { int id = 1; Board board = boardRepository.findById(id).get(); System.out.println("board -> user -> id : " + board.getUser().getId()); } }
BoardRepositoryTest.java
join되서 select
join되서 select

3️⃣ LAZY vs EAGER 요약 비교

구분
LAZY
EAGER
조회 시점
접근 시
즉시
성능 제어
좋음
나쁨
실무 사용
매우 많음
거의 없음
권장 여부

4️⃣ JPA 기본 Fetch 전략

1) 연관관계별 기본값

  1. @ManyToOneEAGER
  1. @OneToOneEAGER
  1. @OneToManyLAZY
  1. @ManyToManyLAZY
⚠️ 기본값이 EAGER인 관계도 실무에서는 LAZY로 변경하는 것이 일반적

5️⃣ 실무 권장 전략 (핵심)

1) 기본 원칙

  1. 모든 연관관계는 LAZY로 설정
  1. 필요한 경우에만 쿼리에서 제어

2) Fetch Join 사용

SELECT m FROM Member m JOIN FETCH m.team WHERE m.id = :id
  1. LAZY 전략 유지
  1. 한 번의 SQL로 연관 엔티티 조회
  1. 성능 + 제어력 모두 확보

3) 실습

package com.example.boardv1.board; import java.sql.Timestamp; import org.hibernate.annotations.CreationTimestamp; import com.example.boardv1.user.User; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; /** * 데이터베이스 세상의 테이블을 자바 세상에 모델링한 결과 = 엔티티 */ @NoArgsConstructor // 디폴트 생성자 @Getter @Setter @Entity // 해당 어노테이션을 보고 컴퍼넌트 스캔 후 데이터베이스에 테이블을 생성 @Table(name = "board_tb") // 테이블명 설정 public class Board { // user 1, Board N @Id // id를 primary key로 설정 @GeneratedValue(strategy = GenerationType.IDENTITY) // auto increment 설정 private Integer id; private String title; private String content; // private Integer userId; @ManyToOne(fetch = FetchType.LAZY) // FK지정 / Board가 many, User가 one private User user; // user_id = 1 (select * from user_tb where id = 1) @CreationTimestamp private Timestamp createdAt; // import 주의!! (java.sql) @Override public String toString() { return "Board [id=" + id + ", title=" + title + ", content=" + content + ", user=" + user + ", createdAt=" + createdAt + "]"; } }
Board.java
package com.example.boardv1.board; import java.util.List; import java.util.Optional; import org.springframework.stereotype.Repository; import jakarta.persistence.EntityManager; import jakarta.persistence.Query; import lombok.RequiredArgsConstructor; /** * 하이버네이트 기술 */ @RequiredArgsConstructor // final이 붙어 있는 모든 필드를 초기화하는 생성자를 만들어줌. @Repository public class BoardRepository { private final EntityManager em; // DI = 의존성 주입 (의존하고 있는게 IoC에 떠있어야됨) // public BoardRepository(EntityManager em) { // this.em = em; // } public Optional<Board> findByIdJoinUser(int id) { Query query = em.createQuery("select b from Board b join fetch b.user u where b.id = :id", Board.class); query.setParameter("id", id); try { Board board = (Board) query.getSingleResult(); return Optional.of(board); } catch (Exception e) { return Optional.ofNullable(null); } } }
BoardRepository.java

6️⃣ 전략별 비교 실습

1) EAGER 전략 - 게시글 목록 보기

EAGER전략에서 게시글 목록 보기 했을 때
→ 쿼리 3번 전송됨
EAGER전략에서 게시글 목록 보기 했을 때 → 쿼리 3번 전송됨
1. 내가 직접 실행한 쿼리 Hibernate: select b1_0.id, b1_0.content, b1_0.created_at, b1_0.title, b1_0.user_id from board_tb b1_0 order by b1_0.id desc List<Board>(Board Table) 6건 ~~ (id, title, content, user_id, created_at) Eager전략 - 한 건일 때는 join 여러 건일 때는 user_id를 모아서 em.find로 User클래스를 다 조회함 user_id -> (1, 1, 1, 2, 2, 2) em.find(User.class, 1); em.find(User.class, 1); em.find(User.class, 1); em.find(User.class, 2); em.find(User.class, 2); em.find(User.class, 2); 튜닝전략 (1) Lazy -> select * from board_tb (2) in query -> select * from user_tb where id in(1,2); 2. 내가 실행한 쿼리 X Hibernate: select u1_0.id, u1_0.created_at, u1_0.email, u1_0.password, u1_0.username from user_tb u1_0 where u1_0.id=? Hibernate: select u1_0.id, u1_0.created_at, u1_0.email, u1_0.password, u1_0.username from user_tb u1_0 where u1_0.id=? 캐싱 됐기 때문에 1번 아이디, 2번 아이디 한번씩만 select (만약에 서로 다른 유저 6명이라면 6번 select될 것!)

2) LAZY 전략 - 게시글 목록 보기

쿼리 1번 전송
쿼리 1번 전송

3) in query (EAGER 전략 + application.properties에 내용 추가)

spring.jpa.properties.hibernate.default_batch_fetch_size=10
application.properties에 아래와 같이 추가
notion image
notion image
Share article