티스토리 뷰

Spring/Spring Data

JPA 연관관계 매핑 정리

woo'^'chang 2022. 7. 13. 13:58

JPA를 공부할 때 영속성 컨텍스트만큼 중요한 개념 중 하나가 연관관계입니다. 객체는 참조를 사용해 다른 객체와 연관관계를 가지고 연관된 객체를 조회합니다. 하지만 테이블은 외래 키를 사용해 다른 테이블과 연관관계를 가지고 조인을 통해 연관된 테이블을 조회합니다.

 

객체가 테이블처럼 외래 키를 가지도록 설계한다면 객체 지향적인 설계를 포기해야 합니다. 이러한 패러다임의 불일치를 중간에서 해결해 줄 매개체가 필요한데 JPA가 이 역할을 수행합니다.

객체 그래프 탐색

다음과 같은 객체 연관관계가 존재한다고 가정할 때, 연관되어 있으면 참조를 통해 탐색이 보장되어야 합니다. 이를 객체 그래프 탐색이라고 합니다. 아래의 코드의 신뢰성이 보장되어야 함을 의미합니다.

Professor professor = university.getProfessor();
Lab lab = professor.getLab();
Study study = lab.getStudy();

SQL에 의존적이라면 SQL 작성 시점에 그래프 탐색 범위가 정해지게 되고 확인이나 변경을 위해서는 SQL을 직접 건드리는 상황이 발생하여 변경에 대한 유연성을 잃어버립니다.

 

JPA 사용으로 위의 문제도 해결할 수 있습니다. 객체는 신뢰성을 가지게 되고 자유로운 그래프 탐색이 가능하게 만들어줍니다.

연관관계 매핑

자유로운 그래프 탐색도 중요하지만, 핵심은 설계에 맞춰 알맞은 연관관계를 매핑하는 것입니다. 연관관계 매핑에 대해 정리해보도록 하겠습니다.

연관관계의 주인

테이블에서는 PK, FK를 이용해 JOIN을 하게 되면 양방향 이동이 가능합니다. 하지만 참조를 사용하는 객체에서는 양방향으로 이동할 방법이 존재하지 않기에 데이터를 직접 넣어주어야 합니다.

 

여기서 한 가지 차이점을 확인할 수 있습니다. 테이블은 외래키 하나로 두 테이블의 연관관계를 관리하기에 양방향 연관관계를 가진다고 말할 수 있지만, 객체는 서로 다른 참조를 가지기에 사실 양방향 연관관계라기보다는 단방향 연관관계 2개를 만들었다고 볼 수 있습니다. 각각 다른 참조이기에 연관관계의 추가, 수정, 삭제가 발생했을 때 두 객체 모두 수정이 필요할까요? 만약 두 객체 모두 수정해야 한다면 테이블에서는 외래키를 가지는 테이블 추가, 수정, 삭제만으로 JOIN 했을 때 결과가 달라지는 것과 달리 불일치가 발생하게 됩니다.

 

둘 중 하나로 외래키를 관리해야 한다는 결론을 도출할 수 있습니다. 두 객체 중 하나를 연관관계의 주인으로 설정하고 추가, 수정, 삭제를 관리합니다. 연관관계의 주인이 아닌 쪽은 매핑된 결과를 읽기만 하여 불필요한 수정을 막습니다.

 

그렇다면 누구를 주인으로 설정해야 할까요? 테이블에서도 외래키를 가지는 쪽의 추가, 수정, 삭제가 발생하듯 객체에서도 외래 참조를 가지는 쪽을 주인으로 지정해야 합니다. 여기서 주인은 비즈니스적으로 중요한 것을 뜻하는 게 아니라 연관관계를 관리할 수 있다는 의미를 담고 있습니다.

다대일 [N:1]

다대일 단방향

가장 많이 사용하는 연관관계입니다. 테이블을 생각해본다면 외래키는 항상 다 쪽에서 가지고 있음을 알 수 있습니다. 그렇다면 연관관계의 주인은 다가 되어야 합니다.

연관관계 매핑을 코드로 작성해보며 사용되는 어노테이션을 하나씩 정리해 보겠습니다.

@Entity
public class Book {
    @Id @GeneratedValue(strategy = GenerationType.AUTO)
    @Column(name = "BOOK_ID")
    private Long id;

    @ManyToOne
    @JoinColumn(name = "LIBRARY_ID")
    private Library library;

    private String name;

    // Method ...
}
@Entity
public class Library {
    @Id @GeneratedValue
    @Column(name = "LIBRARY_ID")
    private Long id;

    private String name;

    // Method ...
}
  • @Entity : JPA를 사용해서 테이블과 매핑할 클래스는 @Entity 어노테이션을 필수로 붙어야 합니다. @Entity가 붙은 클래스는 JPA가 관리하는 것으로, 엔티티라 부르게 됩니다.
    • 파라미터가 없는 public 또는 protected 기본 생성자는 필수입니다.
    • final 클래스, enum, interface, inner 클래스에는 사용할 수 없습니다.
    • 저장할 필드에 final을 사용하면 안 됩니다.
  • @Id : 기본 키 매핑을 의미합니다.
  • @GeneratedValue : 키 생성 전략을 선택합니다. 기본값은 AUTO로 생략하면 자동으로 AUTO로 지정됩니다.
    • IDENTITY : Id값을 null로 보내면 DB가 알아서 AUTO_INCREMENT를 시켜주는 전략입니다. IDENTITY 전략은 entityManager.persist() 시점에 즉시 INSERT 쿼리를 실행하고 Id값을 DB로부터 받아옵니다. 따라서 Transaction 내부적으로 모아서 한 번에 INSERT를 하는 것이 불가능합니다. 하지만 하나의 Transaction 안에서 여러 INSERT 쿼리가 호출된다고 해서 비약적인 차이가 나진 않습니다.
    • SEQUENCE : 데이터베이스의 Sequence Object를 사용하여 DB가 자동으로 숫자를 generate 해줍니다. Sequence Object란 유일한 값을 순서대로 생성하는 특별한 데이터베이스 오브젝트 입니다. 매번 Sequence Object라는 테이블에 가서 새로운 값을 받아와야 하므로 네트워크 비용이 들 수 있습니다. 하지만 이를 위한 해결책으로 allocationSize라는 속성을 지원합니다. 이는 미리 allocationSize만큼의 PK를 받아오고, 메모리에서 꺼내 쓰는 방식을 의미합니다.
    • TABLE : TABLE 전략은 키 생성 전용 테이블을 하나 만들어서 위의 SEQUENCE 전략을 흉내 내는 방법입니다. 모든 DB에 적용할 수 있다는 장점이 있습니다. 하지만 SEQUENCE 전략처럼 최적화된 테이블을 사용하거나 그런 방식을 쓰지 않기 때문에 성능상의 이슈가 있습니다.
    • AUTO : 기본 설정값으로서 방언에 따라 세 가지 전략 중 하나를 선택하여 사용합니다.
  • @Column : 컬럼을 해당 정보들로 매핑합니다.
    • name : 필드와 매핑할 테이블의 컬럼 이름
    • insertable : 엔티티 저장 시 해당 필드 저장 여부
    • updatable : 엔티티 수정 시 해당 필드 수정 여부
    • nullable : null 값의 허용 여부를 설정
    • unique : 한 컬럼에 간단히 유니크 제약조건을 걸 때 사용
    • length : 문자 길이 제약조건, String 타입에만 사용
  • @ManyToOne : 다대일 관계를 의미합니다.
  • @JoinColumn : 해당 필드와 테이블 컬럼을 매핑합니다.

아래부터는 중복되는 어노테이션 정리는 생략하도록 하겠습니다. 여기에서 주목해야할 어노테이션은 @ManyToOne@JoinColumn입니다. 필드가 속해있는 쪽이 다 임을 명확하게 설명해주고 있고 테이블의 FK를 관리함을 나타내고 있습니다.

 

다대일 단방향은 연관관계의 주인이 아닌 쪽에서는 반대로 참조할 수 없다는 단점이 존재합니다.

다대일 양방향

연관관계의 주인이 아닌 쪽에서도 참조할 수 있도록 List를 추가합니다. Library 클래스를 다음과 같이 변경해야 합니다.

@Entity
public class Library {
    @Id @GeneratedValue
    @Column(name = "LIBRARY_ID")
    private Long id;

    @OneToMany(mappedBy = "library")
    private List<Book> books = new ArrayList<>();

    private String name;

    // Method ...
}
  • @OneToMany : 일대다 관계를 의미합니다.
    • mappedBy : 어느 것을 확인하여 동작하는지 명시합니다. Book의 library 변수가 연관관계의 주인이기에 이를 확인하여 동작하도록 합니다.

연관관계를 맺은 두 객체가 양쪽에서 참조할수 있도록 변경하였습니다. 연관관계의 관리는 연관관계 주인에게서 진행해야 하는 점을 주의하고 사용해야 합니다.

일대다 [1:N]

일대다 관계는 다대일의 반대 방향입니다.

일대다 단방향

이 매핑은 반대쪽 테이블에 있는 외래 키를 관리합니다. 따라서 자신의 테이블 컬럼을 조인하는 것이 아닌 반대쪽 테이블 FK 컬럼을 조인해서 사용하는 형태입니다.

BookLibrary 클래스는 아래와 같이 작성할 수 있습니다.

@Entity
public class Book {
    @Id @GeneratedValue(strategy = GenerationType.AUTO)
    @Column(name = "BOOK_ID")
    private Long id;

    private String name;

    // Method ...
}
@Entity
public class Library {
    @Id @GeneratedValue
    @Column(name = "LIBRARY_ID")
    private Long id;

    @OneToMany
    @JoinColumn(name = "LIBRARY_ID")
    private List<Book> books = new ArrayList<>();

    private String name;

    // Method ...
}

books 필드에 조인되는 컬럼은 Library 테이블의 컬럼이 아닌 Book 테이블의 컬럼임을 이해해야 합니다. 일대다 단방향 관계를 매핑할 경우 @JoinColumn 명시가 필요합니다. 그렇지 않으면 연관관계를 관리하는 조인 테이블 전략이 기본으로 사용됩니다.

 

외래키를 해당 테이블 객체가 관리하지 않기 때문에 연관관계 처리를 위한 UPDATE SQL을 추가로 실행해야 합니다. 예를 들어 새로운 BOOK을 추가할 때 LIBRARY_ID는 아무 값도 저장되지 않기에 LIBRARY에 BOOK을 추가하는 순간 UPDATE SQL이 추가로 실행되어 집니다.

 

이러한 단점 때문에 일대다 단방향 매핑을 사용하기보다는 다대일 양방향 매핑을 사용하는 것을 권장하고 있습니다.

일대다 양방향

일대다 양방향 매핑은 존재하지 않기에 다대일 양방향 매핑을 사용해야 합니다. 하지만 일대다 양방향 매핑처럼 보이도록 할 수 있습니다. 반대편 매핑을 읽기 전용으로 추가하면 됩니다. 방법은 아래와 같습니다.

@Entity
public class Book {
    @Id @GeneratedValue(strategy = GenerationType.AUTO)
    @Column(name = "BOOK_ID")
    private Long id;

    @ManyToOne
    @JoinColumn(name = "LIBRARY_ID", insertable = false, updatable = false)
    private Library library;

    private String name;

    // Method ...
}

insertable, updatable 속성을 사용하여 읽기 전용으로 만든다면 관리하는 주체가 한 곳이기에 일대다 양방향처럼 보이도록 만들 수 있습니다. 하지만 일대다 단방향 매핑이 가지는 단점을 그대로 가지기에 사용하지 않는 것을 권장하고 있습니다.

일대일 [1:1]

양쪽이 서로 하나의 관계만을 가지는 매핑을 의미합니다. 예를 들면 사람은 영화관 자석을 하나만 선택할 수 있고 영화관 자석 또한 한 사람에 의해서만 사용됩니다. 이러한 일대일 관계는 주 테이블과 대상 테이블 중 어느 곳에 외래키가 존재해도 상관없는 특징을 가지고 있습니다. 주 테이블에 외래키를 두는 방법과 대상 테이블에 외래키를 두는 방법에는 각각의 장단점이 존재하니 확인 후 적절하게 사용하도록 해야 합니다.

 

주 테이블에 외래키를 두는 방법은 마치 객체 참조처럼 사용할 수 있기에 객체지향 개발자들이 선호하는 방식입니다. 주 테이블만 확인해도 연관관계를 알 수 있습니다. 하지만 값이 없으면 외래키에 null 값을 허용하게 됩니다.

 

대상 테이블에 외래키를 두는 방법은 전통적인 데이터베이스 개발자들이 선호하는 방식입니다. 테이블 구조를 일대일에서 일대다로 변경할 때 테이블 구조를 그대로 유지할 수 있다는 장점이 존재합니다. 하지만 프록시 기능의 한계로 지연 로딩으로 설정해도 항상 즉시 로딩이 발생합니다.

일대일 단방향 (주 테이블 외래키)

@Entity
public class Person {
    @Id @GeneratedValue
    @Column(name = "PERSON_ID")
    private Long id;

    @OneToOne
    @JoinColumn(name = "SEAT_ID")
    private Seat seat;

    private String name;

    // Method ...
}

@Entity
public class Seat {
    @Id @GeneratedValue
    @Column(name = "SEAT_ID")
    private Long id;

    private String color;

    // Method ...
}

다대일 단방향을 일대일 단방향으로 바꿨다고 생각하시면 됩니다.

일대일 양방향 (주 테이블 외래키)

@Entity
public class Person {
    @Id @GeneratedValue
    @Column(name = "PERSON_ID")
    private Long id;

    @OneToOne
    @JoinColumn(name = "SEAT_ID")
    private Seat seat;

    private String name;

    // Method ...
}

@Entity
public class Seat {
    @Id @GeneratedValue
    @Column(name = "SEAT_ID")
    private Long id;

    @OneToOne(mappedBy = "seat")
    private Person person;

    private String color;

    // Method ...
}

다대일 양방향을 일대일 양방향으로 바꿨다고 생각하시면 됩니다.

일대일 단방향 (대상 테이블 외래키)

일대일 매핑 관계 중 대상 테이블에 외래키가 있는 단방향 관계는 JPA에서 지원하지 않습니다. 단방향 관계를 수정하거나 양방향 관계를 만들어야 합니다.

일대일 양방향 (대상 테이블 외래키)

@Entity
public class Person {
    @Id @GeneratedValue
    @Column(name = "PERSON_ID")
    private Long id;

    @OneToOne(mappedBy = "person")
    private Seat seat;

    private String name;

    // Method ...
}

@Entity
public class Seat {
    @Id @GeneratedValue
    @Column(name = "SEAT_ID")
    private Long id;

    @OneToOne
    @JoinColumn(name = "PERSON_ID")
    private Person person;

    private String color;

    // Method ...
}

연관관계의 주인이 Seat 으로 바뀐 것을 제외하고는 일대일 양방향 (주 테이블 외래키)와 동일합니다.

다대다 [N:M]

관계형 데이터베이스에서는 다대다 관계를 표현할 수 없기에 중간에 연결 테이블을 추가해야 합니다. 객체는 테이블과 다르게 다대다 관계를 생성할 수 있습니다. 두 객체 모두 내부 필드로 리스트를 가지면 다대다 참조가 가능합니다.

다대다 단방향

@Entity
public class Library {
    @Id @GeneratedValue
    @Column(name = "LIBRARY_ID")
    private Long id;

    @ManyToMany
    @JoinTable(
        name = "LIBRARY_BOOK",
        joinColumns = @JoinColumn(name = "LIBRARY_ID"),
        inverseJoinColumns = @JoinColumn(name = "BOOK_ID")
    )
    private List<Book> books = new ArrayList<>();

    private String name;
    
    // Method ...
}

@Entity
public class Book {
    @Id @GeneratedValue
    @Column(name = "BOOK_ID")
    private Long id;

    private String name;
    
    // Method ...
}
  • @JoinTable : 연결 테이블을 사용해서 매핑하는 것을 의미합니다.
    • name : 연결 테이블을 지정합니다.
    • joinColumns : 현재 클래스와 매핑할 컬럼 정보를 지정합니다.
    • inverseJoinColumns : 대상 클래스와 매핑할 컬럼 정보를 지정합니다.

다대다 양방향

@Entity
public class Library {
    @Id @GeneratedValue
    @Column(name = "LIBRARY_ID")
    private Long id;

    @ManyToMany
    @JoinTable(
        name = "LIBRARY_BOOK",
        joinColumns = @JoinColumn(name = "LIBRARY_ID"),
        inverseJoinColumns = @JoinColumn(name = "BOOK_ID")
    )
    private List<Book> books = new ArrayList<>();

    private String name;
    
    // Method ...
}

@Entity
public class Book {
    @Id @GeneratedValue
    @Column(name = "BOOK_ID")
    private Long id;

    @ManyToMany(mappedBy = "books")
    private List<Library> libraries = new ArrayList<>();

    private String name;
    
    // Method ...
}

양방향 매핑은 반대쪽에 mappedBy 속성을 지정하여 매핑될 필드를 지정해주기만 하면 됩니다.

다대다 매핑의 한계

다대다 매핑은 매우 편리해 보이지만 실무에서는 사용하지 않는 것을 권장합니다. 그 이유는 연결 테이블에 연결 정보 외의 데이터가 필요할 가능성이 매우 크기에 @ManyToMany의 연결 테이블로 사용할 수 없어지기 때문입니다.

 

이를 해결하기 위해서는 연결 테이블을 Entity로 승격시키고 N:M 구조에서 1:N, M:1 구조로 변경하면 해결할 수 있습니다.

마치며

프로젝트를 진행하며 JPA를 사용할 때는 이러한 연관관계 구조를 제대로 이해하지 못했기에 올바른 연관관계를 가진 Entity를 만들지 못했다고 생각합니다. 정리를 통해 연관관계에 대한 이해가 한층 높아지게 되었고 기존에 작성했던 코드 리팩토링을 진행해보려고 합니다. 앞으로는 객체와 관계형 데이터 패러다임을 이해하고 코드를 작성할 수 있게 꾸준하게 고민해 나갈 예정입니다.

참고

자바 ORM 표준 JPA 프로그래밍 - 기본편

'Spring > Spring Data' 카테고리의 다른 글

[JPA] 페치 조인(fetch join)이란?  (4) 2022.07.29
[Error] 엔티티 인식 에러 해결  (0) 2022.07.29
[JPA] 프록시와 로딩 전략  (0) 2022.07.20
영속성 컨텍스트  (0) 2022.07.05
댓글
최근에 올라온 글
최근에 달린 댓글
«   2025/01   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
Total
Today
Yesterday