JPA 연관 관계

k_bell·2025년 4월 16일

데이터베이스

목록 보기
11/12
post-thumbnail

관계형 데이터베이스에서는 테이블 간의 여러 관계 유형이 있습니다. 우리가 실제 서비스를 개발할 때도 여러 엔티티들이 서로 관계를 맺고 있는 경우가 많습니다.

예를 들어 회원과 주문의 관계를 생각해봅시다. 회원이 여러 개의 주문을 가질 수 있다고 할 때, 이들은 1 : N 관계에 있다고 할 수 있습니다. 이런 관계를 관계형 데이터베이스에서는 JOIN 을 통해 연결할 수 있지만, JPA 에서는 객체 지향적인 방식으로 모델링하게 됩니다.

이번 글에서는 대표적인 연관 관계인 1 : 1, 1: N, N : 1, N : M 관계를 JPA 에서는 어떻게 표현하고 다루는지에 대해서 알아보겠습니다.


단방향 One-To-One

먼저 단방향 OneToOne 입니다. 사람과 여권으로 예시를 들어보겠습니다. 사람은 자신이 소유하고 있는 여권 정보를 가지지만, 여권은 누구에게 소유되었는지 정보를 가지지 않습니다. 이것이 바로 단방향 OneToOne 입니다.

@Entity
public class Person {

    @Id
    @GeneratedValue (strategy = GenerationType.IDENTITY)
    private int id;

    private String name;

    @OneToOne
    @JoinColumn(name = "passport") // person 테이블의 컬럼 명시
    private Passport passport;
}

Entity
public class Passport {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private int id;

    private String number;
}

personpassport
idid
namenumber
passport (id)

Person 엔티티는 Passport 엔티티를 필드로 가집니다. 그리고 해당 엔티티와의 관계를 @OneToOne 어노테이션으로 표현합니다. 이때 @OneToOne 어노테이션을 가지고 있는 Person 이 Ownership 을 가지게 됩니다.

따라서 person 테이블에는 passport 컬럼이 생성되고, passportId 를 저장하게 됩니다.

그러므로 아래와 같은 코드는 오류가 발생합니다.

Person person = new Person();
person.setName("홍길동");

Passport passport = new Passport();
passport.setNumber("KOR-123");
        
person.setPassport(passport); // Person 과 Passport 연결

em.persist(person); // Person 만 persist
        
em.getTransaction().commit();
em.close();
   

`person` 객체와 `passport` 객체를 연결하였지만, `person` 만 `persist` 하는 경우, `person` 테이블에 들어갈 `passport` 가 영속성 컨텍스트에서 관리되지 않기 때문에 오류가 발생합니다.

personpassport 를 연결하지 않은 상태에서, personpersist 하는 것은 가능합니다.

위와 같은 코드가 가능하게 하려면 OneTonOne 어노테이션에서 CascadeType 옵션을 지정해줘야 합니다.

@OneToOne(cascade = CascadeType.PERSIST) // CascadeType 옵션 지정
@JoinColumn(name = "passport") 
private Passport passport;

CascadeType.PERSIST 는 관계를 가지고 있는 엔티티를 함께 persist 하는 옵션입니다. 따라서 CascadeType.PERSIST 으로 설정하면 위의 예제에서 personpersist 할 때, passport 도 함께 persist 됩니다.

다음으로는 find 예제에 대해서 알아보겠습니다.

Person person = em.find(Person.class, 1);

위와 같이 find 를 실행하면 JPA 내부에서 SQL 쿼리는 어떻게 생성될까요? 정답은 바로 다음과 같습니다.

Hibernate: 
    select
        p1_0.id,
        p1_0.name,
        p2_0.id,
        p2_0.number 
    from
        Person p1_0 
    left join
        Passport p2_0 
            on p2_0.id=p1_0.passport 
    where
        p1_0.id=?

우리는 personfind 로 가져오려고 했는데, 어째서 passport 까지 join 으로 함께 가져온 것일까요? 그 이유는 바로 FetchType 의 default 값 때문입니다. FetchType 이란 엔티티를 조회할 때, 관계를 맺고 있는 엔티티를 언제 조회할지 지정하는 옵션입니다.

기본적으로 @OneTOne 에서 FetchType 의 default 속성은 EAGER 입니다. 말그대로 즉시 가져오는 것입니다. 따라서 person 만 조회해도 그와 연관된 passport 까지 함께 가져오게 됩니다.

하지만, passport 엔티티가 지금 바로 필요한 것이 아니라면 굳이 이렇게 가져올 필요가 있을까요? 무분별한 join 은 성능을 저하시킬 것입니다.

따라서 이런 경우에는 FetchTypeLAZY 로 지정하는 것이 좋습니다. LAZY지연 로딩 으로서, 해당 엔티티를 실제로 사용하는 순간에 조회하게 됩니다.

@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "passport") 
private Passport passport;
    
Person person = em.find(Person.class, 1); // person 만 select
    
System.out.println(person.getPAssport()); // 실제 passport 를 사용할 때 조회

이제, 이 코드를 실행하면 SQL 쿼리는 아래와 같이 실행됩니다.

Hibernate: 
    select
        p1_0.id,
        p1_0.name,
        p1_0.passport 
    from
        Person p1_0 
    where
        p1_0.id=?
        
Hibernate: 
    select
        p1_0.id,
        p1_0.number 
    from
        Passport p1_0 
    where
        p1_0.id=?

처음 personfind 할 때는, Person 테이블만 조회하고, 이후 passport 를 실제로 사용할 때, 쿼리를 한번 더 날리는 것을 확인할 수 있습니다.

FetchTypeEAGER 로 지정하게 되면 불필요한 join 이 발생할 수 있습니다. 특히 OneToMany 같은 경우 하나의 엔티티를 조회하면서 연관 관계에 있는 엔티티를 모두 미리 가져오기 때문에 성능면에서 좋지 않을 가능성이 큽니다. 따라서 대부분의 경우 FetchTyp.LAZY 로 지정하고 상황에 따라 조율하는 것을 권장합니다.

@OneToOne 관계에서 FetchType 의 default 값이 EAGER 인 이유

@OneToOne 에서 Ownership 엔티티가 관계를 맺고 있는 객체는 단 하나 입니다. 그렇다면, 관계가 설정된 엔티티를 함께 조회한다고 해도 하나의 엔티티만 추가로 조회하게 되는 것입니다. 그리고 관계를 맺고 있는 엔티티를 언젠가는 사용할 가능성이 높다면, 굳이 쿼리를 두 번 날릴 바에 한 번에 가져오는 것이 낫다는 것입니다. 하지만 실무에서는 대부분의 상황에서 LAZY 로 설정하는 것을 권장하고 있습니다.


양방향 One-To-One

양방향 OneToOne 관계는 두 엔티티가 서로 1:1로 연결되어 있고, 양쪽에서 서로를 참조할 수 있는 관계를 말합니다.

@Entity
Public class Person {

	@Id
	@GeneratedValue (strategy = GenerationType.IDENTITY)
    private int id;

    private String name;

    @OneToOne
    @JoinColumn(name = "passport") 
    private Passport passport;
}
    
@Entity
Public class Passport {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private int id;

    private String number;

    @OneToOne(mappedBy = "passport") 
    private Person person;
}

단방향 예제와는 다르게 Pssport 에서도 @OneToOne 어노테이션을 가지고 있습니다. 그리고 속성값으로 mappedBy 를 지정하는데, 이는 현재 관계에서 '주인은 내가 아니야!' 라고 표시하는 것입니다. 즉, Person 이 Ownership 객체, Passport 는 non-owning side 를 의미합니다.

이렇게 되면, 외래키는 항상 Person 에서 관리하게 됩니다. JPA 가 내부적으로 혼란스러울 수 있는 상태를 방지하는 것입니다.

따라서 이때, Passport 테이블은 따로 Person 엔티티와 관련된 컬럼을 가지지 않습니다.

personpassport
idid
namenumber
passport (id)

양방향 관계에서는 아래와 같이 두 엔티티 모두 연결할 경우, 어느 하나만 persisit 하는 것이 불가능합니다.

person.setPassport(passport);
passport.setPerson(person);
  
// em.persist(person); // person 만 persist 불가능
em.persist(passport); // passport 만 persist 불가능
  

그러므로 두 가지 모두 persist 하거나, CascadeType.PERSIST 옵션을 지정해줘야 합니다. 이는 non-owning-sidepassport 도 해당됩니다.

@OneToOne(mappedBy = "passport", cascade = CascadeType.PERSIST) 	
private Person person;

하지만 non-owning-sidepassport 에는 FetchType.LAZY 옵션을 지정해도 제대로 동작하지 않습니다.

// LAZY 가 아닌 EAGER 처럼 동작함 
@OneToOne(mappedBy = "passport", cascade = CascadeType.PERSIST, fetch = FetchType.LAZY) 
private Person person;

그 이유는 바로 프록시 때문입니다. JPA 의 구현체인 Hibernate 는 LAZY loading 구현을 위해 프록시 객체를 사용합니다. 프록시 객체는 실제 대상 객체의 대리인으로, 대상 객체의 정보를 가지고 대리인 객체를 생성해 그 대리인 객체를 사용합니다. 따라서 passport 의 프록시 객체를 생성하기 위해서는, Passport.class 와 대상 객체의 Id 값이 필요합니다. 하지만 passport 테이블에는 person 외래키가 포함되어 있지 않기 때문에, 프록시 객체 생성이 불가능한 것입니다.

그러므로 Hibernate 가 어떤 person 객체를 프록시로 감싸서 loading 할 것인지 알 수 없기 때문에, @OneToOne 관계의 non-owning-side 에서는 LAZY loading 이 불가능한 것입니다.


Many-To-One

@ManyToOne 은 N:1 관계를 표시하는 어노테이션입니다. 하나의 블로그 글에 달린 여러 개의 여러 개의 댓글을 예시로 생각할 수 있을 것 같습니다. 특이하다고 생각할 수 있겠지만, @ManyToOne 에서 Ownership 을 가지는 주체는 바로 Many 입니다.

하지만 사실 당연한 것이 주체가 되는 쪽에서 외래키를 관리하게 되는데, 그렇다면 게시글보다는 댓글에서 게시글의 Id 를 외래키로 갖는 것이 합리적이라고 생각합니다.

@Entity
public class Post {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private int id;

    private String title;

    private String content;
}

@Entity
public class Comment {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private int id;

    private String content;
    
    @ManyToOne
    private Post post;
}

Post 엔티티는 Comment 에 대한 관계를 가지지 않는 단방향 ManyToOne 관계입니다. 따라서 테이블 구성은 아래와 같습니다.

PostComment
idid
titlecontent
contentpost_id

@ManyToOne 의 기본 FetchTypeEAGER 입니다. 위에서 말했듯이 연관된 객체가 하나인 관계에서는 성능 부담이 적고, 객체 지향적인 관점에서 같이 조회하는 것이 더 적합하다고 판단했기 때문에 EAGER 를 기본값으로 설정한 것입니다.

따라서 자신이 판단했을때, 해당 엔티티를 EAGER loading 할 필요가 없다면 LAZY 속성을 부여해야 합니다. 사실 대부분의 실무에서는 LAZY 속성을 부여하는 경우가 더 많다고 합니다.


One-To-Many

@OneToMany 는 위의 예제에서 게시글에 붙을 수 있는 관계입니다. 하나의 게시글에 여러 개의 댓글이 달릴 수 있기 때문이죠. 이번에는 특이하게 단방향 @OneToMany 에 대해서 알아보도록 하겠습니다.

@Entity
public class Post {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private int id;

    private String title;

    private String content;
    
    @OneToMany
    List<Comment> comments;
}

@Entity
public class Comment {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private int id;

    private String content;
}

Post 에서는 여러 개의 Comment 와 관계를 가지기 때문에 List<Comment> 를 필드로 가집니다. 단방향이기 때문에 Comment 는 별도의 관계를 설정하지 않습니다. 실제로는 많이 활용하는 방식은 아니지만, 설명을 위해서 단방향으로 @OneToMany 예시를 들었습니다.

이렇게 되면 실제 DB 에는 어떤 구조로 테이블이 생성될까요? 정답은 아래와 같습니다.

PostCommentPost_Comment
ididPost_id
titlecontentcomments_id
content

관계를 맺고 있는 Post 에서 별도의 외래키를 가지지 않습니다. 당연하게도 여러 개의 Comment 와 연관될 수 있기 때문에, CommentIdPost 에서 가지는 것은 불가능합니다. 따라서 JPA (Hibernate) 는 Post_Comment 라는 별도의 테이블을 생성합니다. 해당 테이블은 PostCommentId 를 외래키로 가집니다.

@OneToMany 의 기본 FetchTypeLAZY 입니다. 연관된 객체가 여러 개이기 때문에 먼저 가져오는 것은 성능적으로 부담이 커서 기본값은 LAZY 로 지정되어 있습니다. 따라서 Postfind 로 가져올 때는 join 이 발생하지 않고, 연관된 Comment 를 실제로 사용할 때 조회하게 됩니다.


Many-To-One <-> One-To-Many

이번에는 위의 예제에서 PostComment 에 모두 관계를 설정해주도록 하겠습니다. 실제로는 위와 같은 단방향보다는 지금과 같은 양방향 관계를 더 많이 사용할 것입니다.

@Entity
public class Post {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private int id;

    private String title;

    private String content;
    
    @OneToMany(mappedBy = "post")
    List<Comment> comments;
}

@Entity
public class Comment {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private int id;

    private String content;
    
    @ManyToOne
    private Post post;
}

이번에는 PostComment 에 각각 @OneToMany, @ManyToOne 어노테이션을 설정해주었습니다. 그리고 이런 관계에서 Ownership 을 가지는 주체는 ManyToOne 입니다. Comment 객체가 Post 에 대한 외래키를 저장하는 것이 더욱 합리적이기 때문입니다. 따라서 Post@OneToMany 어노테이션에는 mappedBy 를 설정해서 자신이 주인이 아니라는 것을 명시해야 합니다.

따라서 해당 관계에서 테이블 구조는 아래와 같습니다.

PostComment
idid
titlecontent
contentpost_id

단방향 @ManyToOne 때와 같은 구조입니다. FetchType 은 각각의 관계에 모두 설정해줄 수 있으며, 기본값은 각각 단방향일 때와 같습니다. 그래서 DEFAULT 상황일 때, Post 를 조회하면 Comment 를 함께 조회하지 않지만, Comment 를 조회할 때는 Post 를 함께 가져오게 됩니다.

그렇다면 @OneToMany 는 외래키를 가지지 못하는데 EAGER 로 설정하면 어떻게 연관 객체를 가져올까요?

@OneToMany(fetch = FetchType.EAGER) 로 설정하면, JPA는 Many 쪽에서 외래 키를 기준으로 연관된 데이터를 쿼리로 조회해서 가져옵니다. 즉, OneToMany외래키를 직접 갖고 있지는 않지만, 반대편(Many) 의 외래키를 활용해 select 쿼리를 날리게 됩니다.


Many-To-Many

@ManyToManyN : M 의 관계를 표현합니다. 하나의 팀에 여러 명의 선수가 등록될 수 있고, 하나의 선수가 여러 팀에 등록되는 관계를 N : M 이라고 할 수 있습니다. N : M 의 관계에서는 하나의 테이블만 외래키를 소유하는 구조로는 양방향 다대다 관계를 표현할 없기 때문에, 관계를 표현하는 별도의 테이블이 필요합니다.

JPA 의 @ManyToMany 에서는 관계 테이블의 구조를 다음과 같이 명시할 수 있습니다.

@Entity
public class Team {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private int id;

    private String name;

    // 관계 테이블 구조
    // team & user FK 
    @ManyToMany
    @JoinTable(
            name = "teams_users",
            joinColumns = @JoinColumn(name = "team_id"),
            inverseJoinColumns = @JoinColumn(name = "user_id")
    )
    private List<User> users;
}

@Entity
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private int id;

    private String name;

    @ManyToMany(mappedBy = "users")
    private List<Team> teams;
}

@JoinTable관계 테이블을 명시적으로 정의할 때 사용하는 어노테이션입니다. 각각의 옵션의 정의는 아래와 같습니다. 관계 테이블이 이미 존재한다면 JPA 에서 자동으로 매핑합니다.

속성명설명
name생성될 (또는 사용할) 중간 테이블 이름
joinColumns현재 엔티티 (소유자) 의 외래 키 설정
inverseJoinColumns반대쪽 엔티티의 외래 키 설정

@JoinTableOwnership 을 가진 주체에서 명시하게 됩니다. 따라서 non-owning-side 에서는 mappedBy 를 명시해 주어야 합니다.

이렇게 관계 테이블의 구조를 명시해주면, 최종적으로는 아래와 같은 테이블 구성을 가지게 됩니다.

TeamUserteams_users
ididteam_id
namenameuser_id

@ManyToMany 의 기본 FetchType 은 당연하게도 LAZY 입니다.


마무리

오늘은 JPA 에서 어떻게 연관 관계를 표현하고 다루는지 알아보았습니다. 각각의 관계에서 객체 연결 여부, 영속화 여부, FetchType, CascadeType 따라 어떻게 동작하는지 직접 실습해본다면, 개념을 더욱 명확하게 이해하실 수 있을 것입니다 !!

0개의 댓글