TIL/[TIL]

[TIL]JPA 연관관계의 주인, JPQL, 그리고 지연 로딩

namerong 2026. 6. 12. 14:14

1. 학습 주제

이번에는 JPA에서 엔티티끼리 관계를 맺는 방식과, 객체를 기준으로 조회하는 JPQL까지 이어서 정리했다.
핵심은 크게 세 가지였다.

  • 값 타입을 객체로 묶어 재사용하는 방식
  • 엔티티 간 연관관계를 ManyToOne, OneToMany, 양방향 관계로 표현하는 방식
  • SQL이 아니라 엔티티 기준으로 조회하는 JPQL과 파라미터 바인딩

이 범위에서 가장 중요한 감각은
DB의 외래키 관계를 객체 참조 관계로 바꿔서 생각하는 것
연관관계에서 누가 실제로 외래키를 관리하는지 구분하는 것이었다.


2. 값 타입을 객체로 묶는 내장 타입

엔티티 안에는 단순 필드만 들어가는 것이 아니라,
의미 있는 값 묶음을 하나의 객체로 구성해서 넣을 수도 있다.

예를 들어 책 가격 정보를 생각하면:

  • 정가
  • 할인율
  • 판매가

이 세 값은 서로 관련이 강하다.
이걸 그냥 낱개 필드로 흩어놓는 대신 하나의 가격 객체로 묶으면 구조가 더 분명해진다.

이럴 때 사용하는 것이 내장 타입이다.

@Embeddable

다른 엔티티 안에 들어갈 수 있는 값 객체를 정의한다.

@Embedded

정의된 값 객체를 엔티티 안에 실제로 포함시킨다.

즉 관계를 정리하면:

  • @Embeddable = 내장 가능한 값 타입 선언
  • @Embedded = 그 값을 실제 엔티티 안에 넣어서 사용

이다.


3. @Embedded는 상속이 아니라 조합이다

내장 타입은 상속처럼 보일 수 있지만 상속이 아니다.

상속은 “부모의 성질을 자식이 물려받는 구조”이고,
내장 타입은 “한 엔티티가 특정 값 객체를 가진 구조”이다.

즉 더 정확한 표현은:

  • 상속: is-a
  • 내장 타입: has-a

이다.

예를 들어 책은 가격 그 자체가 아니라,
가격 정보를 가진다.

그래서 내장 타입은
상속이 아니라 값 객체를 엔티티 안에 조합하는 방식이다.


4. 내장 타입을 쓰는 이유

내장 타입은 단순히 코드 예쁘게 보이려고 쓰는 기능이 아니다.

1. 의미를 묶는다

서로 관련 있는 값을 하나의 개념으로 표현할 수 있다.

2. 검증 로직을 모은다

가격이 음수면 안 된다거나, 할인율이 음수면 안 된다는 규칙을 값 객체 안에 둘 수 있다.

3. 재사용성이 좋아진다

다른 엔티티에서도 같은 값 구조를 사용할 수 있다.

즉 내장 타입은
필드를 묶는 것을 넘어서
값과 규칙을 함께 묶는 객체지향적 방식이다.


5. 연관관계 매핑이란 무엇인가

JPA에서 연관관계 매핑은
테이블 사이의 외래키 관계를 엔티티 객체 참조로 바꾸는 것이다.

DB에서는 보통 이런 구조다.

  • 메뉴 테이블
  • 카테고리 테이블
  • 메뉴 테이블 안에 카테고리 FK 존재

객체에서는 이걸 다음처럼 바꿔 생각한다.

  • 메뉴 객체가 카테고리 객체를 참조
  • 또는 카테고리 객체가 메뉴 목록을 참조

즉 연관관계 매핑은
외래키 중심의 DB 관계를, 객체 참조 관계로 모델링하는 것이라고 보면 된다.


6. ManyToOne과 OneToMany

이 둘은 방향부터 잡으면 훨씬 쉽다.

ManyToOne

많은 쪽이 하나를 참조하는 관계

예:

  • 메뉴 여러 개
  • 카테고리 하나

즉 메뉴 입장에서는:

  • “나는 하나의 카테고리를 가진다”

가 된다.

그래서 메뉴 엔티티 안에 카테고리 객체가 들어가는 구조가 ManyToOne이다.

OneToMany

하나가 여러 개를 참조하는 관계

예:

  • 카테고리 하나
  • 메뉴 여러 개

즉 카테고리 입장에서는:

  • “나는 여러 메뉴를 가진다”

가 된다.

그래서 카테고리 엔티티 안에 메뉴 리스트가 들어가는 구조가 OneToMany이다.

한 줄 정리

  • ManyToOne = 자식이 부모를 참조
  • OneToMany = 부모가 자식 목록을 참조

7. 외래키를 가진 쪽이 진짜 연관관계의 주인이다

이 부분이 정말 중요하다.

연관관계 매핑에서 실제로 외래키를 가진 쪽이 주인이다.

예를 들어 메뉴와 카테고리 관계에서:

  • 실제 DB 외래키는 메뉴 테이블에 있음
  • 즉 메뉴가 카테고리를 참조하는 구조가 진짜 주인 쪽이다

그래서:

  • ManyToOne 쪽이 보통 연관관계의 주인
  • OneToMany 쪽은 읽기용으로 보는 경우가 많다

왜냐하면 DB에서 실제 FK 값을 바꾸는 주체는
외래키를 가진 쪽이기 때문이다.

즉 연관관계 주인이라는 말은
객체 참조를 누가 가지고 있느냐보다
DB의 외래키를 누가 관리하느냐가 기준이다.


8. 양방향 연관관계에서 mappedBy가 뜻하는 것

양방향 연관관계는:

  • 메뉴도 카테고리를 알고
  • 카테고리도 메뉴 목록을 아는 구조

이다.

그런데 양방향이라고 해서 둘 다 주인이 되는 것은 아니다.

여전히 외래키를 가진 쪽만 진짜 주인이다.

이때 반대쪽에는 mappedBy를 사용한다.

mappedBy의 의미는:

  • 나는 진짜 FK를 관리하지 않는다
  • 저쪽 필드가 연관관계의 주인이다
  • 나는 그 관계를 읽기 편하게 보여주는 쪽이다

즉 mappedBy는
양방향 관계에서 주인이 아닌 쪽을 표시하는 장치다.


9. 왜 FK 가진 쪽이 중요하냐

양방향 관계에서 둘 다 주인처럼 다루면 혼란이 생긴다.

예를 들어:

  • 카테고리의 메뉴 리스트만 바꾸고
  • 정작 메뉴 쪽 카테고리 참조를 안 바꾸면

객체 그래프는 바뀐 것 같아 보여도
실제 외래키는 안 바뀔 수 있다.

즉 JPA에서 관계를 DB에 반영하려면
주인 쪽 값을 기준으로 생각해야 한다는 뜻이다.

그래서 연관관계는 항상 먼저 이렇게 생각하는 게 좋다.

  1. DB 외래키가 어디 있지?
  2. 그럼 그 엔티티가 주인이겠네
  3. 반대편은 mappedBy로 읽기 편하게 둔다

이 흐름으로 보면 훨씬 덜 헷갈린다.


10. 지연 로딩은 왜 하는가

지연 로딩은
연관된 객체를 지금 당장 다 가져오지 않고, 정말 필요할 때 조회하는 방식이다.

왜 필요하냐면, 연관 객체를 매번 즉시 다 가져오면 비효율적이기 때문이다.

예를 들어 메뉴 하나를 조회했다고 해서 항상 카테고리 전체 정보가 꼭 필요한 건 아니다.

필요한 건:

  • 메뉴 코드
  • 메뉴 이름
  • 가격

뿐일 수도 있다.

이때 카테고리까지 무조건 즉시 가져오면 불필요한 SQL이 실행될 수 있다.

그래서 지연 로딩은:

  • 지금 당장 안 쓰는 연관 객체 조회를 미루고
  • 실제 접근하는 순간에 가져오도록

만든 전략이다.

즉 목적은
불필요한 연관 객체 조회를 줄여 성능과 자원 낭비를 줄이는 것이다.


11. @Transactional과 지연 로딩 관계

@Transactional이 붙는다고 영속화되는 것은 아니다.
영속화는 persist()나 find() 등으로 엔티티가 영속성 컨텍스트에 올라갈 때 일어난다.

@Transactional의 역할은:

  • 트랜잭션 범위를 만들어주고
  • 그 범위 동안 영속성 컨텍스트를 안정적으로 유지하며
  • commit/rollback 시점을 관리하는 것

이다.

지연 로딩과 연결해서 보면 더 이해가 쉽다.

지연 로딩 객체는 실제로 접근하는 순간 데이터를 조회해야 한다.
그런데 이미 영속성 컨텍스트가 끝난 뒤라면 조회를 못 할 수 있다.

그래서 서비스 메서드에 @Transactional을 두면:

  • 메서드 실행 동안 영속성 컨텍스트가 유지되고
  • 그 안에서 지연 로딩 객체를 안전하게 접근할 수 있다

즉 @Transactional은 영속화를 만드는 기능이 아니라,
영속성 컨텍스트와 트랜잭션 경계를 유지해서 연관 객체 접근과 변경 반영이 가능하게 해주는 범위라고 이해해야 한다.


12. @Transactional은 언제 쓰는가

기본 기준은 다음과 같다.

저장, 수정, 삭제

거의 반드시 사용한다.

왜냐하면 DB 상태를 바꾸는 작업은 commit/rollback 경계가 필요하기 때문이다.

조회

단순 조회만 하면 없어도 되는 경우가 많다.
하지만 지연 로딩된 연관 객체를 메서드 안에서 접근해야 하거나,
같은 영속성 컨텍스트 범위를 유지해야 하면 사용하기도 한다.

즉 정리하면:

  • 쓰기 작업에는 기본적으로 필요
  • 조회 작업도 연관 객체 접근 구조에 따라 필요할 수 있음

이다.


13. JPQL이란 무엇인가

JPQL은
엔티티를 대상으로 조회하는 JPA용 객체지향 쿼리 언어이다.

중요한 점은 SQL과 대상이 다르다는 것이다.

SQL은 테이블과 컬럼을 대상으로 쓴다.

JPQL은:

  • 엔티티 이름
  • 엔티티 필드명

을 대상으로 쓴다.

예를 들어 SQL이라면:

  • tbl_menu
  • menu_name

을 쓰지만,

JPQL은:

  • Section02Menu
  • menuName

처럼 엔티티 이름과 필드명을 사용한다.

즉 JPQL은
테이블용 SQL이 아니라 객체용 쿼리라고 이해하면 된다.


14. SELECT m FROM Section02Menu m WHERE m.menuName = :menuName 해석

이 쿼리를 그대로 해석해보면:

  • Section02Menu 엔티티에서
  • m이라는 별칭을 주고
  • 그중 menuName이 특정 값과 같은 엔티티를 조회하라

는 뜻이다.

여기서 :menuName이 핵심이다.

이건 이름 기준 파라미터 바인딩이다.

즉 쿼리 안에 값을 직접 박아넣는 게 아니라,
나중에 menuName이라는 이름의 값으로 채워 넣겠다는 뜻이다.

그리고 실제 값은 이렇게 연결한다.

.setParameter("menuName", menuName)

즉:

  • 쿼리 안의 :menuName
  • 자바 코드의 .setParameter("menuName", menuName)

이 서로 연결되는 구조다.


15. :menuName을 쓰는 이유

이름 기준 파라미터 바인딩을 쓰는 이유는 다음과 같다.

1. 가독성이 좋다

숫자 위치보다 이름이 더 이해하기 쉽다.

2. 안전하다

문자열 더하기로 쿼리를 만드는 것보다 안전하게 값을 전달할 수 있다.

3. 유지보수가 쉽다

파라미터가 여러 개일 때 순서보다 이름이 훨씬 명확하다.

즉 JPQL에서도 SQL과 비슷하게
문자열 결합보다 파라미터 바인딩을 사용하는 것이 기본이다.


16. 이름 기준 바인딩과 위치 기준 바인딩

JPQL 파라미터 바인딩은 크게 두 가지가 있다.

이름 기준

WHERE m.menuName = :menuName
.setParameter("menuName", menuName)

위치 기준

WHERE m.menuName = ?1
.setParameter(1, menuName)

실무에서는 보통 이름 기준이 더 읽기 쉽고 많이 쓰인다.

즉 :menuName은
**“나중에 menuName이라는 이름으로 값이 들어갈 자리”**라고 이해하면 된다.


17. DTO와 엔티티를 다시 연결해서 보면

연관관계 매핑과 JPQL을 배우면 DTO와 엔티티 차이도 더 분명해진다.

엔티티

  • JPA가 관리함
  • 연관관계를 가질 수 있음
  • 영속성 컨텍스트 관리 대상
  • DB 반영과 직접 연결됨

DTO

  • 데이터 전달용
  • JPA가 관리하지 않음
  • 요청/응답이나 테스트 입력 데이터에 자주 사용

즉 서비스에서 DTO를 받아서 엔티티로 변환하고,
엔티티를 저장하거나 연관관계를 맺는 흐름이 자연스럽다.


18. 전체 흐름 정리

이번에 다룬 내용을 흐름으로 묶으면 이렇게 된다.

1. 값 타입 매핑

  • 공통 값은 @Embeddable로 묶고
  • 엔티티 안에서 @Embedded로 사용한다

2. 연관관계 매핑

  • 외래키를 가진 쪽이 연관관계의 주인
  • ManyToOne 쪽이 보통 주인
  • 양방향이면 반대쪽은 mappedBy로 읽기 편하게 둔다

3. 로딩 전략

  • 즉시 로딩은 바로 같이 조회
  • 지연 로딩은 필요할 때 조회
  • 지연 로딩은 불필요한 데이터 조회를 줄이기 위해 사용한다

4. 트랜잭션

  • 저장/수정/삭제에 기본적으로 필요
  • 지연 로딩 객체 접근 시에도 영속성 컨텍스트 유지 범위로 중요하다

5. JPQL

  • SQL이 아니라 엔티티 기준 조회 언어
  • 엔티티 이름과 필드명을 기준으로 쓴다
  • 파라미터는 :이름 방식으로 안전하게 바인딩할 수 있다

19. 핵심 정리

  1. @Embeddable은 내장 가능한 값 타입을 정의하고, @Embedded는 그 값을 엔티티 안에 포함시키는 방식이다.
  2. 내장 타입은 상속이 아니라 조합이며, 공통 값과 검증 로직을 묶는 데 적합하다.
  3. 연관관계 매핑은 DB 외래키 관계를 엔티티 객체 참조로 바꾸는 것이다.
  4. ManyToOne은 많은 쪽이 하나를 참조하고, 보통 외래키를 가진 연관관계의 주인이다.
  5. OneToMany는 하나가 여러 개를 참조하는 구조이며, 양방향에서는 보통 주인이 아니다.
  6. 외래키를 가진 쪽이 실제 연관관계를 관리하는 주인이다.
  7. mappedBy는 양방향 관계에서 주인이 아닌 쪽임을 표시하는 속성이다.
  8. 지연 로딩은 연관 객체를 필요할 때만 조회해서 불필요한 SQL 실행을 줄이기 위한 전략이다.
  9. @Transactional은 영속화를 만드는 기능이 아니라, 트랜잭션과 영속성 컨텍스트의 실행 범위를 안정적으로 유지하는 역할을 한다.
  10. 저장, 수정, 삭제는 기본적으로 @Transactional 대상이며, 지연 로딩 객체 접근 시에도 필요할 수 있다.
  11. JPQL은 테이블이 아니라 엔티티를 대상으로 작성하는 객체지향 쿼리 언어이다.
  12. :menuName은 이름 기준 파라미터 바인딩으로, 나중에 setParameter()로 실제 값을 넣는 자리 표시자이다.