JPA 연관관계, 양방향을 사용하면 위험할까?

smj_716·2025년 5월 29일

한이음 드림업

목록 보기
2/9

이번 글에서는 최근 프로젝트를 하면서 실제로 겪은 연관관계 이슈와
공부하면서 정리하게 된 JPA 연관관계의 기초 개념과 주의할 점들을 간단하게 기록한다.

📢 연관관계란?

DB 테이블은 서로 연결되어 있고, 이 연결을 관계(연관관계)라고 부른다.
예를 들어, 강의(course)와 학생(student)이 있을 때 "한 명의 학생이 여러 강의를 수강할 수 있다"면 1:N 관계(일대다 관계)인 것이다.
이런 관계를 코드 안에서도 표현해줘야 하는데 JPA에서는 @OneToMany, @ManyToOne, @OneToOne, @ManyToMany 같은 어노테이션을 사용하여 관계를 표현한다.

🌟주의
연관관계를 이해할 때 가장 헷갈리는 부분은 코드에서는 객체끼리 연결되고, DB에서는 테이블끼리 외래키(FK)로 연결된다는 점이다.
JPA는 이 둘을 자동으로 매핑해주지만 항상 “객체는 참조”, “DB는 외래키”라는 관점을 구분해서 바라보아야한다.


📢 단방향 vs 양방향 연관관계

👉 단방향 연관관계

한 쪽에서만 다른 객체를 참조하는 방식
ex) CourseTimeCourse만 알고 있고, CourseCourseTime을 모름

//CourseTime
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "course_id")
private Course course;

👉 양방향 연관관계

서로 참조하는 구조
ex) CourseTimeCourse를 알고, CourseCourseTime 목록을 가지고 있음

//Course
@OneToMany(mappedBy = "course")
private List<CourseTime> courseTimes = new ArrayList<>();

Course에서도 CourseTime을 탐색할 수 있어서 단방향보다 더 많은 정보를 가져올 수 있지만, 주의해야할 것들이 있음 (아래에 🔽)

⚠️ 양방향 연관관계는 주인 꼭 지정!!

양방향 연관관계를 만들면 JPA가 헷갈릴 수 있기 때문에, 둘 중 한 쪽을 '주인'으로 정해야 한다.
연관관계의 주인은 실제로 외래키를 갖고 있는 객체이다.
mappedBy는 주인이 아닌 쪽에서 사용하는 속성으로, "나는 반대편 필드를 따라갈게"라는 뜻이다.
그래서 mappedBy = "course"CourseTime 엔티티 안의 course 필드를 기준으로 관계를 맺는다는 뜻이다.
이걸 정하지 않으면 JPA는 관계를 두 번 맺으려고 하고 쓸데없는 쿼리가 날아가게된다.


➕식별관계 vs 비식별관계

추가로 DB 설계 쪽 얘기를 잠깐해보겠다.
우리가 JPA로 연관관계를 맺으면, 결국 DB에도 테이블 간 관계가 생기는데 이걸 식별관계와 비식별관계로 나눌 수 있다.

📌 식별관계

자식 테이블이 부모 테이블의 기본키(PK)를 포함해서 자기 PK를 만드는 경우
즉, 부모 없이는 자식이 존재할 수 없다. 잘 쓰면 정합성이 좋아지지만 JPA에서는 복잡해져서 실무에서는 많이 쓰지 않는다고 한다.

📌 비식별관계

자식 테이블이 자기만의 PK를 갖고, 부모의 PK는 외래키로만 사용
우리 프로젝트의 CourseTimeCourse 관계도 여기에 해당한다.

📘 Course
 └─ 🔑 course_id (PK)

📗 CourseTime
 ├─ 🔑 course_time_id (PK)
 └─ 🔗 course_id (FK, 비식별관계)

이렇게 설계하면 구조가 단순하고, 나중에 확장하거나 수정하기도 쉽다.
그래서 JPA에서는 대부분 비식별관계로 설계하는게 기본이라고 한다.


❗ 양방향 연관관계, 실제로 겪은 문제

  • 처음에는 CourseCourseTime 사이를 양방향으로 설정했다.
    수강신청한 강의의 시간을 가져오려면 Enrollment -> Course -> CourseTime 순으로 접근해야한다고 생각했기 때문이다.
  • 이 구조는 보기에는 편하지만 문제가 생기기 시작한 건 조회 쿼리에서였다.CourseTime을 단순히 조회하려고 했을 뿐인데 예상치 않게 Course까지 함께 불러오는 쿼리가 나갔다.
    (즉시 로딩처럼 작동) 이로 인해 불필요한 join과 데이터 로딩이 발생했다.
  • 게다가 Course에서 course.getCourseTimes()를 호출하는 경우,
    강의마다 수업 시간이 여러 개 있을 때 반복적으로 지연 로딩이 일어나면서 N+1 문제로 확산될 수 있다는 점도 고려해야 했다.
  • 결국 Course 쪽에 있던 List<CourseTime>을 제거하고, 필요한 경우에만 JPQL의 fetch join을 통해 명시적으로 조회하도록 구조를 변경했다.

💡 단방향으로도 충분할 때가 많다
JPA는 단방향만으로도 기능을 구현할 수 있는 경우가 많다.
화면에서 역방향으로 탐색할 일이 없다면 굳이 양방향으로 만들어 성능이 떨어지는 상항을 만들지 않아야겠다는 생각이 들었다.


🧭 LAZY vs EAGER/ Fetch Join

연관된 객체를 언제 불러올지도 중요한 설정이다.

👉 LAZY (지연 로딩)

  • 객체를 처음 조회할 땐 연관 객체를 가져오지 않는다.
  • 진짜 필요할 때(DB를 터치할 때) 불러온다.
  • 기본값으로 많이 사용된다.

👉 EAGER (즉시 로딩)

  • 처음 객체를 가져올 때 연관 객체도 바로 같이 불러온다.
  • 객체가 여러 개 연결돼 있다면 성능이 급격히 떨어질 수 있다.
    ex) CourseTime이 여러개일 경우

👉 Fetch Join

  • LAZY로 설정해도 JPQL에서 직접 join fetch를 써서 한 번에 가져올 수 있다.
  • 원하는 상황에서만 불러올 수 있어서 성능 튜닝에 좋다.
  • SELECT ct FROM CourseTime ct JOIN FETCH ct.course

이번에 수강신청 현황 조회 기능을 리팩토링하면서 '편하려고 만든 연관관계가 오히려 나중에 발목을 잡을 수 있다'는 걸 확실히 느꼈다. 앞으로는 연관관계를 만들 때 무조건 편한 쪽보다 정확하고 단순한 쪽을 먼저 고려하는 습관을 들여야겠다.

Github 수강신청현황조회 PR

0개의 댓글