1️⃣ 연관관계 매핑은 왜 필요한가?
JPA 에서 연관관계 매핑은 영속성 컨텍스트와 함께 가장 중요한 두 가지 개념에 속한다.
RDB와 객체지행 프로그래밍의 패러다임 차이로 인해 연관관계에 대한 매핑작업를 개발자가 직접해야했고, ORM(JPA/Hibernate)가 이 부분을 대신 해줌.

Team과 Member는 1:N 관계로 하나의 팀에 여러 가입자가 존재할 수 있다.
우리는 위 테이블에 저장된 데이터를 조회해서 사용하려면 어떻게 해야할까?
1. SQL 쿼리를 통해 필요한 데이터를 조회
SELECT * FROM team WHERE team_id = 1;
SELECT * FROM member WHERE team_id = 1;
2. 조회된 데이터를 객체로 생성해 우리가 쓰기 편한 상태로 만든다.
- Team ➡ Member 로 참조하는 객체
public class Team {
private Long id;
private String name;
private String logoImageUrl;
private List<Member> members; // Team -> Member 참조
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
- Member ➡ Team 로 참조하는 객체
public class Member {
private Long id;
private String name;
private int age;
private Team team; // Member -> Team 참조
private LocalDateTime createdAt;
}
public class Member {
private Long id;
private String name;
private int age;
private String teamName;
private LocalDateTime createdAt;
}
이 과정은 매우 번거롭다!!
그래서 ORM(JPA/Hibername) 가 “연관관계 매핑” 이라는 것을 통해 이 부분을 자동으로 해준다!!
연관관계 매핑” 이라는 작업이 필요하게 되었을까?
- RDB 테이블에는 연관관계에 있는 두 테이블간의 참조라는 개념이 없기 때문
SELECT * FROM team JOIN member ON team.team_id = member.team_id;
SELECT * FROM member JOIN team ON team.team_id = member.team_id;
- RDB에서 연관관계에 있는 두 테이블을 JOIN 해 한번에 조회할 수 있고, 여기에는 방향 개념이 없음.
- 하지만, 우리가 사용하는 OOP에서는 "의존성", "참조" 라는 방향이라는 개념이 존재
- 이러한 패러다임의 불일치를 해결하기 위해 "연관관계 매핑"이 필요한 것임.
✅ Team → Member 방향으로의 참조를 만들지 Member → Team 방향으로의 참조를 만들지는 내가 해당 객체를 어떻게 사용하는지에 따라 달라질 수 있다. (방향이 정해진건 없다!)
2️⃣ 단방향 연관관계
연관관계에 있는 두 객체사이에 한 방향으로의 참조가 존재하는 상태를 말한다.
- Team → Member
- Member → Team
Team 입장에서는 Member 와 1:N 연관관계를 가지지만, Member 입장에서는 Team 과 N:1 연관관계이고 어떤 방향이든 두 객체 사이의 한 방향의 참조만 존재한다면 모두 단방향 연관관계
✅ @OneToOne
- 두 테이블/객체가 1:1 연관관계일 때 사용함
- 1:1 연관관계에 있는 객체간 참조 방향을 어떻게할지 잘 결정해야함.
- 정말 물리적으로 분리되어야하는 테이블인지 고민 해봐야함. ( 데이터 생명주기 혹은 사용패턴을 토대로 고민 )
✅ @ManyToOne
@Entity
public class Team {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "team_id")
private Long id;
private String name;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@Entity
public class Member {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "member_id")
private Long id;
private String name;
private int age;
@ManyToOne
@JoinColumn(name = "team_id")
private Team team;
private LocalDateTime createdAt;
}
우리가 @ManyToOne 을 기본으로 사용하고 있을까?
- 테이블상의 외래키 위치와 객체상의 참조 위치가 동일해 헷갈리지 않는다.
- 별다른 설정 없이도 우리가 생각한대로 SQL 쿼리가 잘 동작한다. (이 부분은 @OneToMany 쪽에 있는 문제를 봄으로써 어떤 문제가 생길수 있는지 확인하자!)
✅ @OneToMany
@Entity
public class Team {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "team_id")
private Long id;
private String name;
@OneToMany
@JoinColumn(name = "team_id")
private List<Member> members = new ArrayList<>();
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@Entity
public class Member {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "member_id")
private Long id;
private String name;
private int age;
private LocalDateTime createdAt;
}
@OneToMany 의 사용을 지양하라는 포스팅을 많이 봤을 것이다.
- RDB의 외래키의 위치랑 객체 참조의 위치가 달라져 헷갈릴 수 있음.
- 1번 문제로 인해 INSERT 쿼리 요청시 불필요한 UPDATE 쿼리가 발생함.
- 단방향 연관관계로 사용할 때 @JoinColumn 을 반드시 사용하며, 사용하지 않을때 불필요한 연결 테이블이 생성될수 있음.
- 연관관계에 있는 Entity(Member)를 save() 해주지 않으면 제대로 저장이 안됨.
- 이런 경우 반드시 cascade 옵션중 PERSIST를 사용해야함.
- 단방향 연관관계의 방향으로 @OneToMany 를 사용할때 주의할 것들이 존재할 뿐 명확한 단점이 있는것은 아님.
- @OneToMany
연관관계는 사용하면 안돼! 이런 포인트에서의 주의해야 할 부분이 있으니 Trade-Off 하자!로 접근- 도메인을 정의할 때 더 자연스러운 방향으로 설계하는 비즈니스 로직을 개발하는 과정에서 더 자연스럽고 더 잘 읽히는 코드를 만들 수 있다.
- 항상 같이 조회되는 상황에서는 @OneToMany로 조회 할때 코드가 자연스러워 진다.
- Post ㅡ Hashtag처럼 항상 Post ➡ Hashtag 로만 참조하는 조회패턴을 가진다면 @OneToMany를 적극 고려
💡 사연이 있는 코드를 만들기위해 노력하자!
취업후 개발은 피드백, 설득, 논쟁의 연속임. 이런 상황에 사연이 없는 코드로는 다른 사람에게 충분한 설득력을 제공할 수 없음을 명심! 뿐만 아니라 내 역량이 부족함을 드러내는 상황을 마주할 수 있고 더 나아가 팀원에게 신뢰를 잃게 될수도 있다.
3️⃣ 양방향 연관관계
@Entity
public class Team {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "team_id")
private Long id;
private String name;
@OneToMany(mappedBy = "team") // 연관관계의 주인 명시!⭐
private List<Member> members = new ArrayList<>();
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@Entity
public class Member {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "member_id")
private Long id;
private String name;
private int age;
@ManyToOne
@JoinColumn(name = "team_id")
private Team team;
private LocalDateTime createdAt;
}
✅ mappedBy
양방향 매핑할 때 반드시 한 쪽의 객체에는 mappedBy를 옵션을 설정해야 한다.
- 양방향 매핑을 적용한다면 Member 객체뿐만 아니라 Team 객체도 members 필드를 통해 Member 테이블에 접근이 가능해지기 때문에 혼란이 생길수 있다.
- 만약 Member 객체와 Team 객체가 서로 Member 테이블의 데이터 값을 변경하려 한다면 무결성을 해칠 가능성도 생길 것
- 그래서, 두 객체 중 하나의 객체만 테이블을 관리 하도록 정하는 것이 MappedBy 옵션임
- Team 객체에 mappedBy가 적용 되어 있는데, Team 객체는 Member 테이블을 관리 할수 없고, Member 객체만이 권한을 받고 주인이 아닌 쪽은 읽기(조회)만 가능
- mappedBy가 정의되지 않은 객체가 주인(Owner)이 되는 것
- 일반적으로 외래키를 가진 객체를 주인으로 정의하는 것이 좋음.
객체 사이에는 "양방향" 이라는 개념이 ❌
- Team → Member , Member → Team 두 단방향 연관관계가 존재하는 상황을 양방향 연관관계라고 한다!
💡 하나의 단방향 연관관계만으로도 이미 연관관계 매핑은 완성됐다. 또다른 단방향 연관관계를 추가함으로써 양방향 연관관계를 만드는 것은 편의를 위해 반대 방향의 조회 기능이 추가된 것일 뿐이다.
✅ 지양해야하는 이유
- 불필요한 연관 관계로 인해 설계의 복잡도가 증가
- 양방향 의존성은 순환참조 및 데이터 동기화 등에서 문제가 생길 가능서이 존재
- 객체 사이의 과도한 결합으로 서로 변경을 전파하게 되어 변경에 유연하지 못한 구조가 됨 ( "OOP의 의존성" )
- 객체의 독립성이 저하되어 테스트 코드를 작성하기 어렵다.
✅ 단점만 있는 기술은 없다!
어떠한 상황에서 고려해볼수 있을까?
- 이미 @ManyToOne 연관관계를 사용하고 있는 상황에서 @OneToMany 방향으로 조회(객체 참조) 패턴이 많이 필요한 상황➡ 단순 조회 목적
- @OneToMany 단방향 연관관계를 매핑을 사용할 때 발생하는 "INSERT 쿼리시 연관관계를 처리를 위한 UPDATE 쿼리가 함께 발생하는 문제"를 해소하기 위한 상황
양방향 연관관계는 "필요"에 의해 추가되어야하는 것을 꼭 기억하기! ⭐
우선 하나의 단방향 연관관계로 시작!! 이후 반대 방향의 객체 참조가 필요시 양방향 연관관계를 추가해도 테이블에 영향을 주지 않기 때문에 충분이 유연함.
4️⃣ N+1 문제
@Entity
public class Todo {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "todo_id")
private Long id;
private String contents;
private LocalDate date = LocalDate.now();
private boolean isComplete = false;
@OneToMany(mappedBy = "todo", fetch = FetchType.EAGER)
public List<Comment> comments = new ArrayList<>();
}
@Entity
public class Comment {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "comment_id")
private Long id;
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "todo_id")
private Todo todo;
private String contents;
}
- Todo 와 Comment 는 1:N 연관관계를 가지는 테이블이다.
- Todo 와 Comment 객체는 양방향 연관관계 매핑을 맺고 있다.
- 양방향 연관관계를 사용한 이유는 @OneToMany, @ManyToOne 에 대한 N+1 문제를 하나의 예제코드에서 모두 살펴보기 위함
- 두 연관관계 매핑에서 모두 FetchType.EAGER 를 적용했다.
✅ N+1 문제란?
N+1 문제는 JPA를 이용해 연관관계에 있는 두 테이블의 데이터를 조회할 때 JOIN을 이용한 SELECT 쿼리 1개가 발생할거라 기대했지만, JOIN을 이용하지 않은 1개의 SELECT 쿼리 + 연관관계에 있는 데이터를 조회하기 위한 N개의 SELECT 쿼리로 총 N+1개의 SELECT 쿼리가 발생하는 문제를 말한다.
- 예를 들어 모든 Comment 를 조회하는 상황에서 발생하는 N+1 문제를 살펴보자.
- 내가 의도한 SELECT 쿼리 :
- SELECT * FROM comment c JOIN todo t ON c.todo_id = t.todo_id
- 하지만 JPA 는 내 의도와 다르게 Comment 를 먼저 조회하고, 조회된 N개의 Comment 에 대한 Todo 를 별도의 N번의 쿼리로 조회해 N+1 문제를 발생시킨다.
- JOIN 을 사용하지 않는 1개의 쿼리 : SELECT * FROM comment
- 연관관계에 있는 데이터를 조회하기 위한 N개의 쿼리 :
- SELECT * FROM todo WHERE todo_id = 1
- SELECT * FROM todo WHERE todo_id = 2
- SELECT * FROM todo WHERE todo_id = 3
- …
- SELECT * FROM todo WHERE todo_id = N-2
- SELECT * FROM todo WHERE todo_id = N-1
- SELECT * FROM todo WHERE todo_id = N
- 내가 의도한 SELECT 쿼리 :
💡 ORM 이 가지는 고질적인 문제인 N+1 문제는 왜 발생하는걸까?
JPA 입장에서 연관관계에 있는 두 테이블을 JOIN 해서 가지고오고 싶은지 확신할 수 없기 때문!!
만약, “연관관계 = JOIN” 이라고 가정해버릴 경우 너무 많은 연관관계 그래프가 한번에 조회될 수 있다. (load-on-demand 전략)
즉, 어떤 시점에 어떤 연관관계 그래프를 한번에 조회해야하는지 JPA 입장에서 알 수 없기 때문에 N+1 문제가 발생하고 우리는 Fetch Join, Entity Graph 와 같은걸 통해 명시적으로 알려주는 것이다!
✅ N+1 문제를 발견하다!
public List<TodoResponse> retrieveAllTodos() { // OneToMany
return todoRepository.findAll()
.stream()
.map(TodoResponse::from)
.toList();
}
public List<CommentResponse> retrieveAllComments() { // ManyToOne
return commentRepository.findAll()
.stream()
.map(CommentResponse::from)
.toList();
}
✅ 이유는 EAGER 때문?
- 예제 코드에서 Todo, Comment 는 서로에 대한 연관관계 Fetch 전략을 모두 EAGER 로 설정했다.
- LAZY Fetch 전략을 사용하면 N+1 문제가 해결될거라 오해하기 쉽다. 그렇다면 Fetch 전략을 LAZY 로 설정하면 N+1 문제가 해결될까?
- 아니다!!
- 경우에 따라 Lazy Loading 이 발생하지 않는 로직일 경우, 즉 연관관계를 참조하지 않는 경우 N개의 쿼리가 생략될 수는 있다.
- 하지만, 연관관계에 있는 Entity 를 사용하는 시점에 결국 N개의 SELECT 쿼리가 발생해 N+1 문제가 여전히 발생하는걸 알 수 있다.
- 즉, LAZY Fetch 전략을 통해 N+1 문제를 완전히 해결할 수는 없다!
✅ N+1 해결해보자! ⭐
- N+1 문제를 해결하는 방법에는 3가지가 있다.
- Batch Size 조절
- Fetch Join
- EntityGraph
Batch Size 조절을 통해 N+1 문제를 해결
- Batch Size 를 조절하는 것은 사실 N+1 문제를 근본적으로 해결하는 것은 아니다.
- N개(N/100})의 쿼리가 너무 많으니까 특정 개수씩 묶어 In Query 를 날려서 쿼리의 개수를 줄여주는 것이다.
- application.yml 에 spring.jpa.properties.hibernate.defalt_batch_fetch_size: 1000 설정을 통해 Batch Size 를 조절할 수 있다.
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 1000
- N개의 쿼리가 하나의 In Query 로 합쳐진 것을 확인할 수 있다.
- N+1 문제를 근본적으로 해결해 JOIN 을 통해 한번에 조회하도록 하진 못했지만, 불필요한 쿼리가 기하급수적으로 늘어나는 것은 막을 수 있다.
- N+1 문제를 근본적으로 해결하는 것이 우선이나, 내가 놓친 부분에 대해서 최소한의 방어책이 되어줄 수 있기 때문에 해당 옵션에 대한 사용을 권장
⭐ Fetch Join 을 통해 N+1 문제를 해결
- Fetch Join 은 JPA(JPQL) 에서 지원하는 JOIN 방식이다. (데이터베이스에 있는 개념 x)
- Fetch Join 을 사용할 경우 연관관계에 있는 Entity 까지 한번에 조회해 영속화해준다.
- Fetch Join 은 JPQL 로 JOIN FETCH 구문을 사용해 구현할 수 있다. JPA 뿐만 아니라 QueryDSL 에서도 Fetch Join 을 위한 메소드를 제공
@Repository
public interface TodoRepository extends JpaRepository<Todo, Long> {
@Query("SELECT todo FROM Todo todo JOIN FETCH todo.comments")
List<Todo> findAllWithFetchJoin();
}
위와같이 JOIN FETCH 이후에 연관관계에 대한 정보를 넣어주면 끝
- 최초 의도한대로 JOIN 을 이용한 단 1개의 SELECT 쿼리만 발생했다.
- 즉, N+1 문제를 근본적으로 해결했다고 할 수 있다.
EntityGraph 를 이용해 N+1 문제를 해결
- @EntityGraph 를 이용해 해당 Entity 의 연관관계에 대한 정보를 넣어주는 방식이다.
@Repository
public interface TodoRepository extends JpaRepository<Todo, Long> {
@EntityGraph(attributePaths = ["comments"])
@Query("SELECT todo FROM Todo todo")
List<Todo> findAllWithEntityGraph();
}
- 마찬가지로 최초 의도한대로 JOIN 을 이용한 단 1개의 SELECT 쿼리만 발생했다.
- 즉, N+1 문제가 근본적으로 해결
💡 @EntityGraph 를 이용한 결과에서 꼭 기억해야할 것은 Outer Join 이 발생했다는 것이다.
- Left Join 은 Left Outer Join 의 약자다.
- Outer Join 특성상 상황에 따라 중복 데이터가 나올 수 있다.
- Set<> 자료구조를 사용하거나 distinct 키워드를 통해 중복을 제거할 수 있다.
뿐만 아니라 연관관계가 복잡해질 경우 @EntityGraph 를 사용하게 되었을 때 복잡도가 급증하는 문제도 존재
🔍 (부록) 꼭 연관관계 매핑을 사용 해야 하는것은 🙅♂️
RDB 에 외래키를 통한 연관관계가 있다고 항상 @JoinColumn 을 이용해 연관관계 매핑을 해야하는 것은 아니다! 실제로 실무에서 @JoinColumn 방식이 아니라 ID 참조 방식을 사용하는 케이스도 많다.
@Entity
public class Member {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "member_id")
private Long id;
private String name;
private int age;
private Long teamId;
private LocalDateTime createdAt;
}
- 이렇게하면 ORM(JPA/Hibernate) 장점 다 갖다 버리는거 아니야? 라고 생각할 수 있다.
- 버리는게 맞다!!
- JPA 가 기껏 객체지향 프로그래밍 하기 편하라고 연관관계 매핑을 해주고 있는데 그걸 극구 사양하는 것과 동일한 코드다.
- 그렇다면 왜(Why) 이런 방식의 참조를 사용하는걸까?
- 관련도 없는 너무 많은 Entity 객체들이 한번에 변경될 수 있다. 특히 DDD 에서는 Aggregate 단위로 Domain 개념을 묶어서 정의하는데, 모든 연관관계를 @JoinColumn 을 사용해 연결지을 경우 이 경계를 명확히 정의하기가 어려워진다.
- Aggregate 외부의 변경을 원천차단하고, Aggregate 자체의 응집도를 높이기 위한 목적!
- Aggregate 내부의 연관관계는 @JoinColumn 을 이용하고, 외부와의 연관관계는 ID 참조 방식을 이용하도록 이야기된다.
- 연관관계 그래프가 길어지면 길어질수록 N+1 문제와 같은 쿼리들을 제어하기 어려워진다.
- MSA, Multi Module Project 로의 시스템 확장에 있어서 유연하지 못한 구조가 만들어진다.
- 관련도 없는 너무 많은 Entity 객체들이 한번에 변경될 수 있다. 특히 DDD 에서는 Aggregate 단위로 Domain 개념을 묶어서 정의하는데, 모든 연관관계를 @JoinColumn 을 사용해 연결지을 경우 이 경계를 명확히 정의하기가 어려워진다.
🔍 (부록) 외래키(FK)를 안쓰기도 함 ❓
- RDB 에서 데이터 정합성, 무결성을 보장하기 위해 반드시 사용해야하는 개념이라고 생각했기 때문이다. (실제로도 그렇다)
- 아직도 왜 외래키를 사용하지 않는지 모두를 설득할만한 답을 찾진 못했다. 그래도 그동안 깨달은 나름대로의 이유를 소개해볼까한다!
- 가장 먼저 떠오르는 것은 귀찮아서(생산성)다!!
- 외래키를 설정하게되면 개발하면서 귀찮은 일이 굉장히 많이 발생한다.
- 개발 후 테스트를 하는 과정에서 데이터를 INSERT 하는데 외래키가 걸려있으면 관련 데이터들을 “순서대로” 다 넣어줘야만한다.
- 반대로 데이터를 삭제하는데도 “순서대로” 데이터를 삭제해줘야하는 번거로움이 존재한다.
- 만약 데이터베이스를 확장하는 과정에서 마이그레이션하는데 외래키가 걸려있다면 여기서도 귀찮음이 발생하게 됨
- 데이터의
무결성과 정합성보다 개발 편의성, 확장성을 더 중요하게 생각하기 때문이다.
- 가장 먼저 떠오르는 것은 귀찮아서(생산성)다!!
- 이성적으로는 당연히 외래키를 사용해야하는것 아니야..? 이럴거면 RDB 왜쓰지..? 라고 생각할 수 있지만, 현실적인 부분을 고려해 Trade-Off 할 수 있는 부분이라는 사실만 알고 넘어가면 좋을 것 같다!
개발의 많은 부분에서 정답이 없다는 사실을 인정해야한다.
정답을 찾기 위해 공부한다면 시간이 지날수록 상황이 안좋아질 수 있다!
'[내일배움캠프-Sparta] > Spring 6기' 카테고리의 다른 글
| 테스트 코드 ( 베이직 ) (0) | 2025.05.06 |
|---|---|
| ⚓Spring Security ( 스탠다드 ) (0) | 2025.05.06 |
| 🔍 영속성과 Transactional (0) | 2025.05.04 |
| TIL 50 [ JPA 심화 - ( 테이블 객체 다루는법, JpaRepository 쿼리,페이징처리, JPQL ,QueryDSL, Auditing, 필요한부분 갱신 ) , 트랜잭션 복습 ] (0) | 2025.05.02 |
| 🚧 트랜잭션 (스탠다드) (0) | 2025.05.02 |