밍쎄의 코딩공간

[스프링 부트 핵심 가이드] - 09. 연관관계 매핑 본문

개발서적/IT

[스프링 부트 핵심 가이드] - 09. 연관관계 매핑

밍쎄 2023. 9. 25. 16:29

RDBMS를 사용할 때는 테이블 하나만 사용해서 애플리케이션의 모든 기능을 구현하기란 불가능하다. 

대체로 설계가 복잡해지면 각 도메인에 맞는 테이블을 설계하고 연관관계를 설정해서 조인 등으로 표현할 수 있다.

JPA를 사용하는 애플리케이션에서도 테이블의 연관관계를 엔티티 간의 연관관계로 표현할 수 있다.

다만 객체와 테이블의 성질이 달라 정확한 연관관계를 표현할 수는 없다. 

이번 9장에서는 JPA에서 이러한 제약을 보완하면서 연관관계를 매핑하고 사용하는 방법을 알아볼 것이다.


연관관계 매핑 종류와 방향

1. 일대일 [1:1]

일대일 단방향

주 테이블이나 대상 테이블 중에 아무데서나 외래키를 선택할 수 있다. 일대일 관계라서 양쪽이 동등하기 때문이다. 이때 외래키에는 데이터베이스 유니크 (UNI) 제약 조건을 추가한다.

JoinColumn() 을 사용하거나, @OneToOne 에서 mappedBy 로 매핑할 컬럼을 지정해줄 수 있다.

외래키의 위치

1) 주 테이블에 외래키

- 주 객체가 대상 객체의 참조를 가지는 것처럼, 주 테이블에 외래키를 두고 대상 테이블을 찾는다.

- 객체지향 개발자 선호하는 방식으로 JPA 에서 매핑이 편리하다.

- 주 테이블만 조회해도 대상 테이블에 데이터가 있는지 확인이 가능하다. 하지만 값이 없는 경우에 외래키에 null 값을 허용하게 된다.

 

2) 대상 테이블에 외래키

- 대상 테이블에 외래키가 존재하는 구조로 전통적인 데이터 베이스 구조이다.

- 주 테이블과 대상 테이블을 일대일에서 일대다로 변경할 때 테이블 구조를 유지할 수 있다.

- JPA 프록시 기능의 한계로 지연 로딩을 설정해도 항상 즉시 로딩되는 문제가 있다.

일대일 양방향

public class Member {
    @Id @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

    @OneToOne
    @JoinColumn(name = "LOCKER_ID")
    private Locker locker;

    // ...
}

public class Locker {
    @Id @GeneratedValue
    @JoinColumn(name = "LOCKER_ID")
    private Long id;

    @OneToOne
    @JoinColumn(name = "MEMBER_ID")
    private Member member;

    // ...
}

 

2. 다대일 [N:1]

다대일 단방향

 

여러개의 객체가 하나의 객체를 참조하는 구조이다.

데이터베이스 테이블의 관계에서는 N 에 해당하는 테이블이 1 에 해당하는 테이블의 키를 외래키로 가진다. 객체의 연관관계에서는 외래키가 있는 테이블의 객체가 1 에 해당하는 테이블을 참조하여 매핑한다.

@ManyToOne, @JoneColumn 등의 어노테이션을 사용하여 구현한다.

 

@Entity
public class Team {
    @Id @GeneartedValue
    @Column(name = "TEAM_ID")
    private Long id;

    // ...
}

@Entity
public class Member {
    // ...

    @ManyToOne
    @JoinColumn(name = "TEAM_ID")
    private Team team;
}

 

다대일 양방향

다대일 단방향에서 N 에 해당하는 테이블의 객체만 반대편 객체를 참조했다. 다대일 양방향에서는 반대편 객체에 리스트형의 객체 참조 필드를 추가하여 양쪽에서 서로 조회할 수 있다.

반대편 객체에서 @OneToMany(mappedBy = "객체 이름") 어노테이션을 통해 구현한다.

 

@Entity
public class Team {
    @Id @GeneartedValue
    @Column(name = "TEAM_ID")
    private Long id;

    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();

    // ...
}

@Entity
public class Member {
    // ...

    @ManyToOne
    @JoinColumn(name = "TEAM_ID")
    private Team team;
}

 

3. 일대다 [1:N]

일대다 단방향

객체 연관관계에서 "1" 의 객체만 "N" 을 참조하는 구조이다.

데이터베이스 테이블 관계에서는 다대일 관계와 동일하게 "N" 쪽의 테이블이 반대편 테이블의 키를 외래키로 가진다. 하지만 객체 쪽에서는 "1" 쪽의 객체만 반대편 객체들을 참조하고 있다. 이 경우에 "1" 쪽의 참조가 변경되면 데이터베이스에서는 "N" 쪽의 테이블의 키가 변경된다.

 

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

    @OneToMany
    @JoinColumn(name = "TEAM_ID")
    private List<Member> members = new ArrayList<>();

    // ...
}

 

이 구조에서는 "1" 의 객체가 연관관계의 주인이 된다. 객체와 테이블의 차이로 본인 테이블이 아닌 반대편 테이블의 외래키를 관리하는 특이한 구조가 된다. 이러한 연관관계를 처리하기 위해서 JPA 는 쿼리를 작성할 때 반대편 외래키를 변경해주는 UPDATE SQL 을 추가로 실행한다.


구현시에는 @JoinColumn 을 무조건 사용해서 매핑할 컬럼을 지정해주어야 한다. 그렇지 않으면 JoinTable 을 사용하여 중간 테이블이 하나 자동으로 추가되게 된다.

 

구조가 복잡해지기 때문에 실무에서는 권장하지 않는 구조이다. 실무에서는 일대다 단방향 매핑보다는 다대일 양방향 매핑을 권장한다.

일대다 양방향

JPA 실제 공식 스펙으로 지원하는 매핑은 아니다. 다만 구현시에 @JoinColumn 에 insertable=false, updatable=false 를 주어서 읽기 전용으로 매핑하여 이와같은 구조로 사용할 수 있다.

 

이 구조도 일대다 단방향과 동일하게 다대일 양방향으로 변경하여 사용하는 것을 권장한다.

 

@Entity
public class Member {
    // ...

    @ManyToOne
    @JoinColumn(name = "TEAM_ID", insertable = false, updatable = false)
    private Team team;
}

 

 

4. 다대다 [N:M]

관계형 데이터베이스는 정규화된 테이블 2개로 다대다 관계를 표현할 수 없다. 그렇기 때문에 두 테이블 간에 연결 테이블을 추가해서 일대다, 다대일 관계로 풀어내야한다. 객체는 컬렉션을 사용하여 객체 2개로 다대다 관계를 구현할 수 있다.

구현 시에는 @ManyToMany, @JoinTable 어노테이션을 사용하여 구현할 수 있다.

 

실제 실무에서 사용하지 않는 것이 좋다. 다대다 대신에 중간에 연결 테이블 객체를 만들어서 다대일 - 일대다의 형식으로 만드는 것이 좋다.

 

 

영속성 정의

특정 엔티티를 영속 상태로 만들 때 연관된 엔티티도 함께 영속 상태로 만들고 싶으면 영속성 전이 기능을 사용하면 됩니다. JPA는 CASCADE 옵션으로 영속성 전이를 제공합니다. 쉽게 말해서 영속성 전이를 사용하면 부모 엔티티를 저장할 때 자식 엔티티도 함께 저장 할 수 있습니다.

Parent.java

@Setter
@Getter
@Entity
public class Parent {

    @Id 
    @GeneratedValue
    @Column(name = "PARENT_ID")
    private Long id;

    private String name;

    @OneToMany(mappedBy = "parent")
    private List<Child> children = new ArrayList<Child>();
}

Child.java

@Getter
@Setter
@Entity
public class Child {

    @Id @GeneratedValue
    @Column(name = "CHILD_ID")
    private Long id;
    private String name;

    @ManyToOne
    private Parent parent;

}

위의 코드는 부모 엔티티가 여러 자식 엔티티를 가진다고 가정해보겠습니다.

테스트 코드

@Test
@Transactional
public void printUser() throws Exception {
    // 부모 저장
    Parent parent = new Parent();
    parent.setName("임종수");
    entityManager.persist(parent);
    
    // 1번 자식 저장
    Child child1 = new Child();
    child1.setName("임준영");
    child1.setParent(parent); // 자식 -> 부모 연관관계 설정
    parent.getChildren().add(child1); // 부모 -> 자식
    entityManager.persist(child1);

    // 2번 자식 저장
    Child child2 = new Child();
    child2.setName("임주리");
    child2.setParent(parent); // 자식 -> 부모 연관관계 설정
    parent.getChildren().add(child2); // 부모 -> 자식
    entityManager.persist(child2);       
}

JPA에서 엔티티를 저장할 때 연관된 모든 엔티티는 영속 상태어야 합니다.
따라서 위의 코드를 보면 부모 엔티티를 영속 상태로 만들고 자식 엔티티도 각각 영속 상태로 만듭니다. 이럴 때 영속성 전이를 사용하면 부모 엔티티만 영속 상태로 만들면 연관된 자식까지 한번에 영속 상태로 만들 수 있습니다.

1. 영속성 전이: 저장

영속성 전이를 활성화하는 CASCADE 옵션을 적용해보겠습니다.

@Setter
@Getter
@Entity
public class Parent {

    @Id @GeneratedValue
    @Column(name = "PARENT_ID")
    private Long id;

    private String name;


    @OneToMany(mappedBy = "parent", cascade = CascadeType.PERSIST)
    private List<Child> children = new ArrayList<Child>();
}

부모를 영속화 할 때 자식들도 함께 영속화하라고 cascade = CascadeType.PERSIST 옵션을 설정했습니다. 이 옵션을 적용하면 아래 코드처럼 간편하게 부모와 자식 엔티티를 한 번에 영속화 할 수 있습니다.

@Test
@Transactional
@Rollback(false)
public void printUser() throws Exception {

    // 1번 자식 저장
    Child child1 = new Child();
    // 2번 자식 저장
    Child child2 = new Child();

    Parent parent = new Parent();
    parent.setName("임종수");
    
    child1.setName("임준영");
    child2.setName("임주리");
  
    child1.setParent(parent); // 자식 -> 부모 연관관계 설정
    child2.setParent(parent); // 자식 -> 부모 연관관계 설정
    
    parent.getChildren().add(child1); // 부모 -> 자식
    parent.getChildren().add(child2); // 부모 -> 자식

    // 부모 저장
    entityManager.persist(parent);
}

CASCADE 실행

부모만 영속화하면 CascadeType.PERSIST로 설정한 자식 엔티티까지 함께 영속화해서 저장합니다.

이 코드의 쿼리 결과를 보면 데이터가 정상적으로 2건 입력된 것을 확인할 수 가 있습니다.

영속성 전이는 연관관계를 매핑하는 것과 아무 관련이 없습니다. 단지 엔티티를 영속화 할 때 연관된 엔티티도 같이 영속화하는 편리함을 제공할 뿐입니다.

2. 영속성 전이: 삭제

방금 저장한 부모와 자식 엔티티를 모두 제거하려면 다음 코드와 같이 각각의 엔티티를 하나씩 제거해야 합니다.

Parent findParent = em.find(Parent.class, 1L);
Child findChild1 = em.find(Child.class, 1L);
Child findChild2 = em.find(Child.class, 2L);

em.remove(findChild1);
em.remove(findChild2);
em.remove(findParent);

영속성 전이는 엔티티를 삭제할 때도 사용할 수 있습니다. CascadeType.REMOVE로 설정하고 다음 코드처럼 부모 엔티티만 삭제하면 연관된 자식 엔티티도 함께 삭제 됩니다.

Parent findParent = em.find(Parent.class, 1L);
em.remove(findParent);

코드를 실행하면 DELETE SQL을 3번 실행하고 부모는 물론 연관된 자식도 모두 삭제합니다. 삭제 순서는 외래키 제약조건을 고려해서 자식을 먼저 삭제하고 부모를 삭제합니다.

만약 CascadeType.REMOVE를 설정하지 않고 이 코드를 실행하면 부모 엔티티만 삭제 됩니다. 하지만 데이터베이스의 부모 로우를 삭제하는 순간 자식 테이블에 걸려 있는 외래 키 제약조건으로 인해, 데이터베이스에서 외래 키 무결성 예외가 발생합니다.

3. CASCADE의 종류

public enum CascadeType{

    ALL, // 모두적용
    PERSIST, // 영속
    MERGE, // 병합
    REMOVE, // 삭제
    REFRESH, // REFRESH
    DETACH // DETACH
}

다음처럼 여러 속성을 같이 사용할 수 있습니다.

cascade = {CascadeType.PERSIST, CascadeType.REMOVE}

참고로 CascadeType.PERSIST, CascadeType.REMOVE는 em.persist(), em.remove()를 실행 할 때 바로 전이가 발생하지 않고 플러시를 호출 할 때 전이가 발생합니다.

4. 고아 객체

JPA는 부모 엔티티와 연관관계가 끊어진 자식 엔티티를 자동으로 삭제하는 기능을 제공하는데 이것을 고아 객체 제거라고 합니다.
이 기능을 사용해서 부모 엔티티의 컬렉션에서 자식 엔티티의 참조만 제거하면 자식 엔티티가 자동으로 삭제하도록 코드를 작성해 보겠습니다.

@Setter
@Getter
@Entity
public class Parent {

    @Id @GeneratedValue
    @Column(name = "PARENT_ID")
    private Long id;

    private String name;

    @OneToMany(mappedBy = "parent", orphanRemoval = true)
    private List<Child> children = new ArrayList<Child>();
}

고아 객체 제거 기능을 활성화하기 위해 컬렉션에 orphanRemoval = true를 설정합니다. 이제 컬렉션에서 제거한 엔티티는 자동으로 삭제됩니다.

Parent parent1 = em.find(Parent.lcass, id);
parent1.getChildren().remove(0); //자식 엔티티를 컬렉션에서 제거

실행결과

사용 코드를 보면 컬렉션에서 첫 번째 자식을 제거합니다. 고아 객체 제거 기능은 영속성 컨텍스트를 플러시할 때 적용되므로 플러시 시점에 DELETE SQL이 실행됩니다.

모든 자식 엔티티를 제거하려면 다음 코드처럼 컬렉션을 비우면 됩니다.

parent1.getChildren().clear();

고아 객체를 정리하면 참조가 제거된 엔티티는 다른곳에서 참조하지 않는 고아 객체로 보고 삭제하는 기능입니다. 따라서 이 기능은 참조하는 곳이 하나일 때만 사용해야 합니다. 쉽게 말하자면 특정 엔티티가 개인 소유하는 엔티티에만 이 기능을 적용해야 합니다. 만약 삭제한 엔티티를 다른 곳에서도 참조한다면 문제가 발생할 수 있습니다. 이런 이유로 orphanRemovel은 @OneToOne, @OneToMany에만 사용할 수 있습니다.

5. 영속성 전이 + 고아객체, 생명주기

CascadeType.ALL + orphanRemoval = true를 동시에 사용하면 부모 엔티티를 통해서 자식의 생명주기를 관리 할 수 있습니다.

//자식을 저장하려면 부모에 등록만 하면 됩니다.
Parent parent = em.find(Parent.class , parentId);
parent.addChild(child);

//자식을 삭제하려면 부모에서 제거하면 됩니다.
Parent parent = em.find(Parent.class , parentId);
parent.getChildren().remove(removeObject);

 


https://jammdev.tistory.com/175

 

[JPA] 연관관계 매핑 종류

객체간의 연관관계를 매핑할 때는 다음의 요소들을 고려해야한다. 1. 다중성 - 1:1, 1:N, N:1, N:M 2. 방향성 - 단방향, 양방향 3. 연관관계의 주인 위의 요소들을 고려하여 JPA 에서는 다양한 연관관계

jammdev.tistory.com

 

728x90