2025. 11. 19. 22:48ㆍJava/JSP
[LG U+ 유레카 3기] "JPA 연관관계 & Fetch 전략(OneToMany / ManyToMany) 실습 정리"
JPA 연관관계 & Fetch 전략 (어제+오늘 실습 총정리)
❶ 실습 환경 & 공통 흐름
이번 이틀 동안의 실습은 순수 JPA + Hibernate 조합으로 진행했다.
Spring 없이 직접 EntityManagerFactory와 EntityManager를 만들면서 내부 동작을 눈으로 확인하는 것이 목표였다.
public class TestTemplate {
public static void main(String[] args) {
Map<String, String> props = new HashMap<>();
props.put("hibernate.hbm2ddl.auto", "update");
props.put("hibernate.show_sql", "true");
EntityManagerFactory emf = new HibernatePersistenceProvider()
.createContainerEntityManagerFactory(new MyPersistenceUnitInfo(), props);
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
// 여기서 각종 실습 코드 실행
em.getTransaction().commit();
em.close();
}
}
- 핵심 포인트
MyPersistenceUnitInfo: persistence.xml 대신 자바 코드로 설정을 넘겨주는 역할hibernate.show_sql=true: JPA 코드 한 줄이 어떤 SQL로 바뀌는지 눈으로 확인</음>hibernate.hbm2ddl.auto=update: 테이블 구조만 자동 갱신, 기존 데이터는 유지

❷ OneToMany(Post ↔ Comment) 연관관계 & Fetch 전략
1) 엔티티 구조
게시글(Post) 하나에 댓글(Comment) 여러 개가 달리는 아주 익숙한 구조를 사용했다.
// Post.java
@Entity
public class Post {
@Id @GeneratedValue
private Integer id;
private String title;
private String content;
@OneToMany(mappedBy = "post", fetch = FetchType.LAZY)
private List<Comment> comments = new ArrayList<>();
// getters, setters, toString ...
}
// Comment.java
@Entity
public class Comment {
@Id @GeneratedValue
private Integer id;
private String content;
@ManyToOne(fetch = FetchType.LAZY)
private Post post;
// getters, setters, toString ...
}
- Post : Comment = 1 : N
@OneToMany(mappedBy = "post"): 연관관계의 주인은 Comment (FK를 가진 쪽)- 양쪽 모두 FetchType 기본값은 LAZY
2) LAZY에서의 조회 흐름
Post p = em.find(Post.class, 1); // Post 한 건만 select
List<Comment> comments = p.getComments(); // 이 시점에는 select 없음
// 실제로 데이터를 사용할 때 비로소 select 발생
comments.forEach(System.out::println);
처음에는 find() 호출 시 댓글까지 전부 가져올 줄 알았지만, 콘솔을 보니 Post만 select되고 댓글은 조회되지 않았다. 리스트를 실제로 출력하는 순간에야 Hibernate가 댓글을 가져오기 위한 SQL을 날리는 것을 확인했다.
즉, LAZY는 “필요할 때까지 DB 접근을 미룬다” 라는 개념을 눈으로 확인한 실습이었다.
3) EAGER로 바꿨을 때의 변화
@OneToMany(mappedBy = "post", fetch = FetchType.EAGER)
private List<Comment> comments;
Post p = em.find(Post.class, 1); // 이 한 줄에서 Post + Comment + 조인 테이블까지 한 번에 join
FetchType을 EAGER로 바꾼 후에는 게시글 하나를 조회하는 순간, JPA가 Post, Comment, 조인 테이블을 한 번에 join 해서 가져왔다. “편해 보이지만, 댓글이 수천 개라면 매번 join 지옥”이 되기 때문에 실무에서는 거의 사용하지 않는다는 것을 체감했다.
4) 컬렉션 변경 시 delete → insert 폭탄의 이유
Post p = em.find(Post.class, 1); // 기존 댓글 2개라고 가정
Comment c3 = new Comment();
c3.setContent("코멘트 3");
c3.setPost(p);
p.getComments().add(c3);
em.persist(c3); // c3 영속화
// 트랜잭션 커밋 시점
em.getTransaction().commit();
커밋 시점에 로그를 확인해보면 다음과 같은 순서로 SQL이 실행된다.
select: 기존 Post, Comment, 조인 테이블 조회insert into Comment ...delete from Post_Comment where post_id = ?insert into Post_Comment (post_id, comments_id) values (?,?)여러 번
처음에는 “댓글 하나 추가했는데 왜 전체 delete 후 insert를 다시 하지?” 라는 의문이 들었다.
원인은 Hibernate의 변경 감지(Dirty Checking) 방식 때문이다.
- 엔티티가 처음 영속 상태가 될 때 스냅샷(snapshot)을 저장해 둔다.
- 단순 필드(제목, 내용 등)는 이전 값과 지금 값을 비교해서 변경 여부를 쉽게 판단할 수 있다.
- 하지만 컬렉션(List<Comment>)은 “어떤 것이 추가/삭제/순서 변경되었는지” 비교 비용이 크다.
그래서 Hibernate는 컬렉션에 대해서는 똑똑하게 diff를 계산하는 대신,
“그냥 다 지우고 지금 리스트 상태대로 다시 넣자” 라는 단순한 전략을 택한다.
이 동작을 실제 SQL 로그로 확인하면서, 면접에서 자주 나오는 컬렉션 연관관계의 delete → insert 패턴을 몸으로 이해하게 된 실습이었다.
❸ ManyToMany(Team ↔ User) 저장 실습 – 연관관계의 주인 & Cascade
1) 엔티티 구조
// Team.java
@Entity
public class Team {
@Id @GeneratedValue
private Integer id;
private String name;
@ManyToMany(/* 필요 시 cascade, fetch 등 옵션 */)
private List<User> users = new ArrayList<>();
}
// User.java
@Entity
public class User {
@Id @GeneratedValue
private Integer id;
private String name;
@ManyToMany(mappedBy = "users")
private List<Team> teams = new ArrayList<>();
}
Team과 User 사이에는 다대다(N:N) 관계가 있고,
DB에는 teams, users, teams_users 3개의 테이블이 생성된다.
2) Team ↔ User를 어떻게 저장하느냐에 따른 결과 비교
(1) Team과 User를 각각 따로 저장
User u1 = new User(); u1.setName("회원 1");
User u2 = new User(); u2.setName("회원 2");
Team t1 = new Team(); t1.setName("팀 1");
Team t2 = new Team(); t2.setName("팀 2");
em.persist(u1);
em.persist(u2);
em.persist(t1);
em.persist(t2);
users테이블 : 2건 insertteams테이블 : 2건 insertteams_users: 아무 것도 insert 안 됨 (연관관계를 아직 안 걸었기 때문)
(2) Team에 User 리스트를 연결 후, Team만 persist 했을 때
t1.setUsers(List.of(u1, u2));
t2.setUsers(List.of(u2));
em.persist(t1);
em.persist(t2);
User가 아직 영속 상태가 아니기 때문에, 저장되지 않은(transient) 객체를 참조한다는 예외가 발생한다.
(3) Team에 User를 연결하고, Team과 User 모두 persist 했을 때
t1.setUsers(List.of(u1, u2));
t2.setUsers(List.of(u2));
em.persist(t1);
em.persist(t2);
em.persist(u1);
em.persist(u2);
teamsinsert 2건usersinsert 2건teams_usersinsert 3건 (t1-u1, t1-u2, t2-u2)
여기서 배운 것: 다대다 관계에서는 조인 테이블 저장을 누가 담당하는지가 중요하다.
(4) User에 Team을 설정하고, User만 persist 했을 때
u1.setTeams(List.of(t1, t2));
u2.setTeams(List.of(t2));
em.persist(u1);
em.persist(u2);
users: insert 2건teams: insert 없음teams_users: 역시 insert 없음
처음에는 “User에서 Team을 걸어줬는데 왜 조인 테이블이 비어 있지?” 라는 의문이 들었지만, 원인은 연관관계의 주인(Owning Side) 개념에 있다.
이 실습에서는 Team 쪽이 주인이라고 가정했기 때문에,
주인이 아닌 User에서 아무리 setTeams()를 해도 DB에는 반영되지 않는다.
(5) CascadeType.PERSIST를 사용한 Team 중심 저장
Team에 cascade = CascadeType.PERSIST를 설정하고 다음 코드를 실행했다.
t1.setUsers(List.of(u1, u2));
t2.setUsers(List.of(u2));
em.persist(t1);
em.persist(t2); // cascade 덕분에 User도 함께 persist
teams: insert 2건users: insert 2건 (cascade)teams_users: insert 3건
결론적으로 Cascade는 엔티티 저장 전파를 담당하고,
조인 테이블에 무엇이 들어가는지는 연관관계의 주인이 결정한다는 것을 실습으로 확인했다.
❹ ManyToMany 조회 & Fetch 전략 + toString() 순환 참조
1) Team 한 건만 find 했을 때 (LAZY)
Team t1 = em.find(Team.class, 1);
SQL 로그를 보면 teams 테이블만 select 되고, teams_users와 users는 조회되지 않는다. ManyToMany의 기본 fetch가 LAZY라는 것을 다시 한 번 확인한 부분이다.
2) Team을 출력하면서 toString() 호출 – StackOverflowError
Team t1 = em.find(Team.class, 1);
System.out.println(t1); // toString() 체인 호출
Team의 toString()에서 users를 출력하고,
User의 toString()에서 다시 teams를 출력하도록 구현되어 있으면, 아래와 같은 순환이 만들어진다.
- Team.toString() → users 출력
- User.toString() → teams 출력
- 다시 Team.toString() → users 출력
- … 무한 반복 → StackOverflowError
실제 실행 결과, 연관된 Team과 User를 번갈아가며 select 하다가 스택이 꽉 차서 에러가 발생했다.
그래서 실무에서는 다음과 같이 처리하는 것이 기본 패턴이다.
- toString()에서 연관 필드는 빼거나
- Lombok 사용 시
@ToString.Exclude적용 - JSON 변환 시에는
@JsonIgnore등으로 순환 참조 방지
3) User에서 teams를 사용할 때의 쿼리 흐름 (LAZY)
User u1 = em.find(User.class, 1); // users 한 건만 select
u1.getTeams().forEach(System.out::println); // 이 시점에 teams, users 추가 select
여기서는 다음과 같은 순서로 쿼리가 발생한다.
- user 1건 조회
- 해당 user가 속한 team 목록을 가져오기 위해
teams_users+teamsjoin - 각 team의 toString() 안에서 users를 출력하면 다시
teams_users + users조회
즉, LAZY를 잘못 쓰면 이런 식으로 N+1 문제가 자연스럽게 발생한다는 것을 몸으로 느낀 실습이었다.
4) Team의 FetchType을 EAGER로 바꿨을 때
@ManyToMany(fetch = FetchType.EAGER)
private List<User> users;
Team t1 = em.find(Team.class, 1);
이번에는 Team 한 건을 조회하는 순간, teams, teams_users, users를 모두 left join 해서 가져왔다.
“조회 한 번에 다 가져와서 편해 보이지만, join 폭탄이 될 수 있다”는 점 때문에
실무에서는 기본적으로 LAZY + 필요할 때 fetch join 사용이 정석이라는 것도 다시 정리했다.
❺ hbm2ddl.auto 옵션 실습 – create vs update
Product 예제에서 JPQL을 사용하기 전에, 미리 MySQL에서 데이터를 insert 해 두고 실행했는데 콘솔에 아무 것도 찍히지 않는 상황을 경험했다.
로그를 보니 다음과 같은 SQL이 실행되고 있었다.
drop table if exists Product;
create table Product (...);
원인은 설정값이 hibernate.hbm2ddl.auto = create였기 때문이었다.
create: 기존 테이블을 무조건 drop 후 다시 생성 → 사전에 넣어둔 데이터가 모두 사라짐update: 스키마를 변경할 필요가 있을 때만 alter, 데이터는 유지
그래서 실습용 DB에서 미리 데이터를 넣어두고 JPA로 조회하고 싶다면, 반드시 update로 설정해 두어야 한다는 점을 알게 되었다.
❻ 이번 이틀 실습에서 얻은 핵심 정리
- 연관관계 & Fetch 전략
- OneToMany, ManyToMany 모두 기본 fetch는 LAZY
- EAGER는 편해 보이지만 join 폭탄 → 실무에서는 지양
- 컬렉션 연관관계는 dirty checking 시 delete → insert 패턴이 자주 발생
- 연관관계의 주인(Owning Side)
- ManyToMany에서 조인 테이블에 무엇이 저장될지는 주인 엔티티에 달려 있다
- Non-owner 쪽에서
setTeams()를 해도 DB에는 반영되지 않는다 - CascadeType.PERSIST는 엔티티 저장을 전파할 뿐, 주인이 아닌 쪽의 연관관계를 대신 저장해주지 않는다
- toString(), LAZY, N+1 문제
- 양방향 연관관계에서 서로를 toString()에 넣으면 StackOverflowError 발생
- LAZY는 “필요할 때만 DB 가기”라서 좋지만, 부주의하면 N+1 문제를 낳는다
- 엔티티 설계 시 toString(), JSON 직렬화, fetch 전략을 함께 고민해야 한다
- DDL 자동 생성 옵션
create는 연습용/초기 개발 단계에서만 사용 (데이터 날아감)update를 이용하면 기존 데이터를 유지하면서 구조만 맞춰갈 수 있다
이번 이틀 동안의 실습은 단순히 코드를 따라 치는 수준을 넘어서, “JPA가 내부에서 어떤 전략으로 동작하는지”를 직접 SQL 로그로 체험하면서 이해하는 시간이었다.
특히 연관관계의 주인, LAZY/EAGER, dirty checking, cascade 같은 개념은 앞으로 Spring + JPA로 프로젝트를 진행할 때 성능과 안정성에 큰 영향을 주는 핵심 포인트들이라, 이번 정리를 통해 머릿속에 한 번 더 깊게 박아두는 계기가 되었다.
'Java > JSP' 카테고리의 다른 글
| [LG U+ 유레카 3기] JSP + Servlet + Ajax로 도서 관리 화면 만들기 (0) | 2025.10.31 |
|---|---|
| [LG U+ 유레카 3기] JSP + Servlet + DAO 기반 도서관리 CRUD 실습 (0) | 2025.10.30 |
| [LG U+ 유레카 3기] JSP Forward/Redirect MVC 흐름 (0) | 2025.10.30 |
| [LG U+ 유레카 3기]Web Server vs WAS 관련 정리 (0) | 2025.10.29 |
| [LG U+ 유레카 3기]Servlet, JSP, MVC 패턴 (+Postman)정리 (0) | 2025.10.29 |