삶 가운데 남긴 기록 AACII.TISTORY.COM

JPA 연관 관계 본문

DEV&OPS/Java

JPA 연관 관계

ALEPH.GEM 2026. 7. 11. 13:51
728x90

 

JPA 연관관계 결정

데이터베이스에서는 PK와 FK으로 엔티티 간 관계를 표현합니다.

반면 JPA는 객체 지향이라서 데이터 연관관계의 방향성을 결정하는 것이 어렵습니다.

예를 들어, 회원이 여러 개의 아이템을 가지고 있다고 가정하면, 회원 객체가 아이템들을 참조할 것인지, 아이템이 회원을 참조할 것인지 판단해야 합니다.

JPA는 보통 변화가 많은 쪽을 기준으로 결정하거나 ERD에서 FK를 기준으로 결정합니다.

 

변화가 많은 쪽 기준

회원과 게시물의 관계를 예를 들면, 회원들이 활동을 통해 여러 게시물이 만들어지므로 연관 관계는 게시물 기준으로 판단하는 것이 좋습니다.

회원 아이디 회원 이름
user1 사용자1
user2 사용자2
user3 사용자3
user4 사용자4
번호 제목 작성자 내용
1 제목1 사용자1 내용...1
2 제목2 사용자2 내용...2
3 제목3 사용자3 내용...3

반대로 회원을 기준으로 시작하면 나중에 엔티티 클래스 설계를 감당 못할 만큼 연관 관계가 필요하게 됩니다.

예를 들어 회원이 게시물을 작성하고 게시물에 여러 파일과 댓글이 달리고 각 게시글에 좋아요 기능 추가되면 결국 회원 중심으로 이루어지는 시스템을 설계하게 됩니다.

ERD(엔티티 관계 다이어 그램) 에서 JPA의 연관 관계를  읽을 때 FK를 판단하는 것이 좋습니다.

 

단방향과 양방향

관계형 데이터베이스와 객체 지향의 다른 점은 객체가 다른 객체를 참조하는 방식이 다르다는 점입니다.

관계형 데이터베이스는 특정한 PK를 다른 테이블에서 FK로 참조해서 사용합니다.

하지만 객체지향에서는 A객체(데이터)가 B객체(데이터)의 참조를 가질 수도 있고 B객체가 A객체의 참조를 가질 수 있습니다.

JPA는 객체 지향을 통해 관계형 데이터베이스를 처리하므로 참조를 결정 할 때 다양한 방식이 존재할 수 있습니다.

A객체가 B객체를 참조하거나 B객체가 A를 참조하는 한쪽 방향으로만 참조를 유지하는 방식을 단방향(unidirectional)이라고 하고 양쪽 모두 참조하는 방식을 양방향(bidirectional)이라고 부릅니다.

단방향은 구현이 단순하고 에러 발생 여지를 줄일 수 있지만 조인 처리 같은 다른 엔티티 내용을 사용하는 작업이 어렵습니다.

양방향은 양쪽 객체를 모두 관리해야 하는 불편함이 있지만 JPA에서 필요한 데이터를 탐색하는 작업에서는 편리합니다.

보통 기본적으로 단방향 참조 관계를 유지하면서 다른 엔티티를 사용할 때는 JPQL을 통해(JOIN 등) 처리를 합니다.

양방향 처리시 트랜잭션을 신경 써야 합니다.

 

게시판과 댓글 연관 관계(ManyToOne) 예제

import jakarta.persistence.*;
import lombok.*;

/**
 * 댓글 관리용 Entity. 
 * 이 클래스가 데이터베이스 테이블과 매핑되는 '엔티티'임을 선언해서 JPA가 이 클래스를 관리하게 됨.
 * 기본적으로 클래스 이름과 테이블 이름은 같지만 @Table(name="") 어노테이션으로 테이블 이름을 지정할 수 있음.
 * @ToString할 때 참조 객체를 사용하지 않도록 exclude 속성값으로 board 객체를 지정.
 * @ManyToOne같은 연관 관계를 나타낼 때는 fetch 속성을 LAZY으로 지정.
 * 프로젝트 실행시 Reply관련 테이블이 자동으로 생성 됨. 
 * 생성된 테이블에서는 Board 관련 속성으로 board_bno 이름의 컬럼이 생성되고 자동으로 FK가 생성 됨.
 */
@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@ToString(exclude = "board")
public class Reply extends BaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long rno;	//rno 를 댓글 테이블에서 사용할 PK(id) 컬럼으로 지정

    @ManyToOne(fetch = FetchType.LAZY)
    private Board board;    //Reply Entity(댓글)는 Board Entity(게시글)와 다대일 관계

    private String replyText;
    private String replyer;
}

게시판과 댓글의 관계에서 댓글 Entity는 게시판 객체에 대해 ManyToOne 연관 관계에 있습니다.

즉 하나의 게시글에 여러개의 댓글이 달릴 수 있는 것입니다.

 

@OneToMany 관계

게시물과 첨부 파일의 관계는 게시글 중심으로 볼 것인지, 첨부 파일 중심으로 볼 것인지에 따라 다른데 보통 한 개 게시글 관점에서 첨부 파일 여러 개를 바라보는 관점으로 바라보도록 @OneToMany 관계로 처리합니다.

@ManyToOne은 다른 Entity 객체의 참조로 FK를 가지는 쪽(댓글 테이블)에서 하는 방식이고,

@OneToMany는 PK를 가진 쪽(게시글 테이블)에서 사용합니다.

@OneToMany는 각 엔티티에 해당하는 데이터베이스 테이블을 독립적으로 생성하고 중간에 매핑해주는 테이블이 생성됩니다.

  • 상위 엔티티에서 하위 엔티티를 관리하는 방식입니다.
  • JPA의 Repository를 상위 엔티티 기준으로 생성합니다. 하위 엔티티에 대한 Repository 생성을 하면 하위 엔티티들의 변경은 상위 엔티티에도 반영해야 합니다.
  • 상위 엔티티 생태가 변경되면 하위 엔티티들의 상태도 같이 처리 해야 합니다.

 

mappedBy

연관 관계를 지정한 엔티티 테이블 사이에서 JPA는 빌드 될 때 매핑 테이블을 DB에 자동으로 생성합니다.

매핑 테이블을 생성하지 않는 방법으로는

첫 번째로 단방향 @OneToMany를 이용하는 경우 @JoinColumn을 이용하거나

두 번째로 mappedBy 속성을 이용하는 방법입니다.

mappedBy의 경우 서로 참조를 유지하는 양방향 참조 상황에서 사용하는데, 어떤 엔티티의 속성으로 매핑되는지를 의미합니다.

게시물 관점에서 보면 첨부 파일은 하나의 첨부 파일이 여러 게시물에서 사용할 수 있는 구조를 가정하고 생성합니다.

반대로 첨부 파일의 관점에서는 하나의 게시물이 참조하는 구조가 필요하므로 mappedBy를 이용해서 첨부 파일쪽 관점을 적용할 것을 명시합니다.

 

영속성의 전이(cascade)

상위 엔티티에서 하위 엔티티의 연관 관계를 상위 엔티티에서 관리하는 경우,

상위 엔티티의 객체 상태가 변경되었을 때 하위 엔티티 객체들이 함께 영향을 받는다는 점을 주의해야 합니다.

JPA에서는 영속성의 전이 라는 용어로 이를 표현하는데, 게시판의의 게시글과 첨부 파일의 관계가 그렇습니다.

예를 들어, BoardImage(첨부이미지) 객체가 JPA에 의해 관리되면 BoardImage를 참조하고 있는 Board 객체도 같이 처리되어야 합니다.

반대로, Board 객체가 변경될 때 BoardImage 객체들 역시 영향을 받습니다.

JPA에서는 이 경우 연관 관계에서 cascade 속성을 부여해서 이를 제어 합니다.

cascade 속성 값 설명
PERSIST
REMOVE
상위 엔티티가 영속 처리(DB에 반영)될 때 하위 엔티티들도 같이 영속 처리
MERGE
REFRESH
DETACH
상위 엔티티의 상태가 변경될 때 하위 엔티티들도 같이 상태 변경
(merge, refresh, detach)
ALL 상위 엔티티의 모든 상태 변경이 하위 엔티티에 적용

 

 

@BatchSize

상위 엔티티에서 @OneToMany 연관 관계를 유지하면, 한 번에 게시물과 첨부 파일을 같이 처리할 수 있지만 목록을 처리할 때 문제가 발생할 수도 있습니다.

목록을 가져오는 쿼리 한 번과 하나의 게시물마다 첨부 파일에 대한 쿼리가 실행되는데 이를 'N+1' 문제라고 합니다.

(N은 게시물 마다 각각 실행되는 쿼리, 1은 목록을 가져오는 쿼리)

이 문제는 쿼리가 반복적으로 실행되기 때문에 @BatchSize를 이용합니다.

@BatchSize에는 size라는 속성을 지정하고 이를 이용해서 N번 실행되는 해당 쿼리를 모아서 한번에 실행할 수 있습니다.

    @OneToMany(mappedBy = "board", 
            cascade = {CascadeType.ALL}, 
            fetch = FetchType.LAZY, 
            orphanRemoval = true)  
    @Builder.Default
    @BatchSize(size = 20)
    private Set<BoardImage> imageSet = new HashSet<>();

목록과 관련된 처리는 반드시 limit 같은 페이징 처리가 실행되는 지 체크해야 합니다.

N+1 문제 때문에 테이블의 모든 데이터에 대한 처리가 이루어 지면 성능에 부하가 많이 걸리게 됩니다.

그래서 @BatchSize를 지정해서 반복적으로 실행되지 않도록 방지해 주는 것입니다.

 

 

@EntityGraph

JPA에서 연관 엔티티를 조회할 때, 어떤 연관 관계를 함께 로딩할지 지정하는 어노테이션입니다.

주로 LAZY 로딩 때문에 발생하는 N+1 조회 문제를 줄이기 위해 사용합니다.

필요한 특정 조회에서만 선택적으로 연관 엔티티를 로딩하기 위해 사용합니다.

Join이 필요 없이 간단하게 fetch 전략을 지정하고 엔티티의 기본 LAZY 설정을 유지할 수 있습니다.

주의할 점은 OneToMany 같은 페이징과 동시에 사용할 때 중복 문제를 주의해야 합니다.

 

예를들어 Board와 Member 가 다음 처럼 연관 되어 있다고 가정합니다.

@Entity
@Getter
public class Board {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long bno;
    private String title;

    @ManyToOne(fetch = FetchType.LAZY)
    private Member writer;
}

writer가 LAZY이므로 게시글(Board) 목록을 조회 후, 각 게시글의 작성자(Member)에 접근할 때 추가적으로 SQL이 실행될 수 있습니다.

List<Board> boards = boardRepository.findAll();

for (Board board : boards) {
    System.out.println(board.getWriter().getName());
}

게시글 100개를 조회하면 

게시글 목록은 1회 조회

작성자 조회는 최대 100회 조회

총 101번 조회 하므로 N+1 문제입니다.

이 때, @EntityGraph를 사용하면 작성자를 게시글 조회시 함께 로딩할 수 있습니다.

 

Repository에서 사용하는 예제

public interface BoardRepository extends JpaRepository<Board, Long> {
    @EntityGraph(attributePaths = {"writer"})
    List<Board> findAll();
}

이러면 조회할 때 작성자까지 함께 로딩합니다.

List<Board> boards = boardRepository.findAll();

Hibernate는 보통 조인을 사용해서 Board와 Member를 한 번에 조회합니다.

select
    b.*,
    m.*
from board b
left join member m
    on b.writer_id = m.id

attributePaths 속성은 함께 조회할 엔티티의 필드명을 적어줍니다.

@EntityGraph(attributePaths = {"writer"})

여러 연관관계를 지정할 수도 있습니다.

@EntityGraph(attributePaths = {
    "writer",
    "category",
    "attachments"
})
List<Board> findAll();

중첩된 연관 관계도 마침표 점(.) 으로 참조해서 지정할 수 있습니다.

@EntityGraph(attributePaths = {
    "writer",
    "writer.address"
})
List<Board> findAll();

참조할 때 필드 이름은 DB에서 컬럼 명이 아니라 엔티티 클래스의 필드명이어야 합니다.

@ManyToOne
@JoinColumn(name = "writer_id")
private Member writer;

위 처럼 조인할 때 컬럼 이름이 writer_id 으로 되어있어도 아래처럼 엔티티의 필드명으로 적어야 합니다.

attributePaths = {"writer"}

 

EnitytyGraphType 의 FETCH 속성과 LOAD 속성

@EntityGraph(
    attributePaths = {"writer"},
    type = EntityGraph.EntityGraphType.FETCH
)
List<Board> findAll();

FETCH 속성은 Graph에 포함되면 EAGER 처럼 조회하고 포함되지 않은 속성은 LAZY처럼 취급합니다.

기본값이 FETCH 속성이기 때문에 보통 다음 처럼 생략합니다.

@EntityGraph(attributePaths = {"writer"})
List<Board> findAll();

 

LOAD 속성은 Entity Graph에 포함된 속성은 EAGER로 조회하고 포함되지 않은 속성은 엔티티에 설정된 기본 fetch 전략을 따릅니다.

예를 들어 다음 필드가 EAGER라면

@ManyToOne(fetch = FetchType.EAGER)
private Category category;

LOAD에서는 Graph에 category가 없어도 EAGER 설정을 유지합니다.

대부분의 경우 기본값인 FETCH가 더 명확하고 예측하기 쉽습니다.

 

메서드 이름을 사용한 예제

JPA 쿼리 메서드에도 EntityGraph를 적용할 수 있습니다.

public interface BoardRepository extends JpaRepository<Board, Long> {
    @EntityGraph(attributePaths = {"writer"})
    List<Board> findByTitleContaining(String keyword);
}
List<Board> result = boardRepository.findByTitleContaining("JPA");

검색 조건을 적용하면서도  writer도 함께 로딩합니다.

 

JPQL 쿼리에 적용

public interface BoardRepository extends JpaRepository<Board, Long> {

    @EntityGraph(attributePaths = {"writer"})
    @Query("""
        select b
        from Board b
        where b.title like concat('%', :keyword, '%')
    """)
    List<Board> searchByTitle(@Param("keyword") String keyword);
}

@Query 로 조회(검색) 조건을 정의하고 @EntityGraph는 함께 로딩할 연관 관계를 정의합니다.

 

단건 조회에 적용

findById()와 동일한 이름으로 재정의(오버라이드)할 수 있습니다.

public interface BoardRepository extends JpaRepository<Board, Long> {
    @Override
    @EntityGraph(attributePaths = {"writer"})
    Optional<Board> findById(Long bno);
}

이 때, findById()를 실행하면 writer도 함께 조회됩니다.

Board board = boardRepository.findById(100L).orElseThrow();

다만, Repository의 모든 findById() 호출에서 항상 writer가 필요하지 않는다면 별도 메서드를 만드는 것이 좋습니다.

@EntityGraph(attributePaths = {"writer"})
Optional<Board> findWithWriterByBno(Long bno);

JPA가 메서드 이름을 해석할 수 있도록 실제 엔티티 필드명을 기준으로 작성합니다.

 

@OneToMany 컬렉션 연관 관계에 적용

@Entity
@Getter
public class Board {
    @Id
    private Long bno;
    @OneToMany(mappedBy = "board")
    private List<Reply> replies = new ArrayList<>();
}

Repository에서

@EntityGraph(attributePaths = {"replies"})
Optional<Board> findByBno(Long bno);

이렇게 하면 게스글과 댓글 목록을 함께 조회할 수 있습니다.

 

페이징과 컬렉션 조회 시 주의점

@EntityGraph(attributePaths = {"replies"})
Page<Board> findAll(Pageable pageable);

위 처럼 Pageable과 컬렉션 연관 관계를 함께 사용하면 문제가 발생할 수 있습니다.

게시글 하나에 댓글이 여러개 있으면 SQL 결과 행이 중복됩니다.

게시글 1에 댓글이 3개 있고 게시글2에 댓글이 1개 있으면, DB 기준으로는 4행이지만 실제 게시글은 2개입니다.

이 상태에서 DB가 먼저 페이징을 처리하면 게시글 개수가 나오지 않거나, Hibernate가 메모리에서 페이징 할 수 있습니다.

그래서 페이징 조회에서는 주의해야 합니다.

ManyToOne, OneToOne 처럼 단일 객체 연관 관계는 상대적으로 안전합니다.

하지만, OneToMany, ManyToMany 컬렉션은 별도 조회와 batch fetch를 고려하는 것이 좋습니다.

@BatchSize(size = 100)
@OneToMany(mappedBy = "board")
private List<Reply> replies = new ArrayList<>();

혹은 properties에서 지정할 수 있습니다.

spring.jpa.properties.hibernate.default_batch_fetch_size=100

 

@NamedEntityGraph

재사용할 Entity Graph를 엔티티에 미리 정의해놓고(@NamedEntityGraph) Repository에서 활용 할 수도 있습니다.

@Entity
@NamedEntityGraph(
    name = "Board.withWriter",
    attributeNodes = {
        @NamedAttributeNode("writer")
    }
)
public class Board {
    @Id
    private Long bno;

    @ManyToOne(fetch = FetchType.LAZY)
    private Member writer;
}

Repository에서는 이름(name)을 지정합니다.

public interface BoardRepository extends JpaRepository<Board, Long> {
    @EntityGraph(value = "Board.withWriter")
    Optional<Board> findByBno(Long bno);
}

여러 Repository 메서드에서 동일한 조회 그래프를 재사용할 때 유용합니다.

 

중첩된 @NamedEntityGraph

연관 엔티티 내부 연관 관계까지 함께 조회하려면 subgraph를 정의할 수 있습니다.

@Entity
@NamedEntityGraph(
    name = "Board.withWriterAndAddress",
    attributeNodes = {
        @NamedAttributeNode(
            value = "writer",
            subgraph = "writer-address"
        )
    },
    subgraphs = {
        @NamedSubgraph(
            name = "writer-address",
            attributeNodes = {
                @NamedAttributeNode("address")
            }
        )
    }
)
public class Board {

    @Id
    private Long bno;

    @ManyToOne(fetch = FetchType.LAZY)
    private Member writer;
}

Repository에서는

@EntityGraph(value = "Board.withWriterAndAddress")
Optional<Board> findByBno(Long bno);

만약 간단한 구조라면 다음처럼 attributePaths를 사용하는 것이 편합니다.

@EntityGraph(attributePaths = {"writer", "writer.address"})
Optional<Board> findByBno(Long bno);

 

JPQL의 fetch join 과 차이

JPQL 방식

@Query("""
    select b
    from Board b
    join fetch b.writer
    where b.bno = :bno
""")
Optional<Board> findWithWriter(@Param("bno") Long bno);

 

Entity Graph 방식

@EntityGraph(attributePaths = {"writer"})
Optional<Board> findByBno(Long bno);

 

Entity Graph 방식이 재사용성과 N+1문제 해결이 편리하지만,

JPQL 방식은 복잡한 조인 조건일 때 유리해서 세밀한 제어가 가능합니다.

즉, 단순한 연관 관계를 함께 조회할 때는 @EntityGraph가 편리하고, 복잡한 JOIN일 때는 JPQL이 편리합니다.

 

실무에서 권장하는 패턴

1. 엔티티 연관 관계는 기본적으로 LAZY로 설정합니다.

@ManyToOne(fetch = FetchType.LAZY)
private Member writer;

 

2. 필요한 특정 Repository 메서드에서만 @EntityGraph를 적용해서 함께 로딩합니다.

@EntityGraph(attributePaths = {"writer"})
List<Board> findByTitleContaining(String keyword);

모든 관계를 무조건 EAGER으로 선언하는 것보다 조회 범위를 명시적으로 통제할 수 있습니다.

 

 

 

 

 

 

 

 

 

 

728x90