Spring/JPA

[Spring/JPA] JPA 연관관계

오잎 클로버 2023. 2. 13. 00:00
728x90

※ 해당 글은 Spring Data JPA 를 기준으로 삽질을 하여, 결과물을 도출해 내어 작성하는 글입니다.

 dependency 버전에 따라 결과가 다를 수 있음을 알립니다.

 

application properties (yml)은 다음과 같이 설정한 상태로 진행

spring:
  jpa:
    show-sql: true
    database: mysql
    generate-ddl: true
    hibernate:
      ddl-auto: update
      naming:
        implicit-strategy: org.springframework.boot.orm.jpa.hibernate.SpringImplicitNamingStrategy
    database-platform: org.hibernate.dialect.MySQL8Dialect
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/database_lab_db
    username: root
    password: ${PASSWORD}

 

일대일 (단방향)

현재 위와 같은 ERD가 설계되었다고 가정해보자. 여권은 시민이 소유한다. 하지만, 시민이 반드시 여권을 알아야 할 필요는 없다. 즉, 시민 <- 여권을 이루는 일대일 단방향임을 알 수 있다.

Spring Data JPA를 사용하면 다음과 같이 구성이 가능하다.

/**
 * 주민 엔티티 클래스
 * 성명과 거주지를 포함하고 있다.
 */
@Entity
@Getter
@Builder
@AllArgsConstructor @NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Citizen {

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

    /**
     * 성명
     */
    @Column(nullable = false)
    private String name;

    /**
     * 거주지
     */
    @Column(nullable = false)
    private String address;

}
/**
 * 여권 엔티티 클래스
 * 어떤 국가의 여권인지와 누구의 여권인지를 파악할 수 있도록 설계
 */
@Entity
@Getter
@Builder
@AllArgsConstructor @NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Passport {

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

    /**
     * 국가
     */
    @Column(nullable = false)
    private String nation;

    /**
     * 해당 여권의 한 명의 시민이 소유한다.
     */
    @OneToOne
    private Citizen citizen;

}

연관관계 로직 상황 가정

위와 같이 엔티티 클래스들이 정의되었으니, 이제 여권 발급을 한다고 가정해보자.

@Service
@RequiredArgsConstructor
public class PassportOffice {

    private final PassportDao passportDao;

    @Transactional
    public Passport registrationPassport(final Citizen citizen, final String nation) {
        // 이미 등록된 여권이 있으면, 발급하면 안된다. (물론 재발급은 가능하겠지만, 해당 케이스는 지금은 제외한다.)
        if (isDuplicatePassport(citizen)) {
            throw new IllegalArgumentException("이미 여권을 발급받은 적이 있습니다. 재발급 신청을 해주세요!");
        }

        return passportDao.save(createPassport(citizen, nation));
    }

    public boolean validatePassport(final Passport passport, final Citizen citizen,
                                     final String nation) {
        // 발급되지 않은 여권 -> 조작된 여권
        if (!isDuplicatePassport(citizen)) {
            throw new IllegalArgumentException("발급되지 않은 여권입니다.");
        }

        return validate(passport, citizen, nation);
    }


    @Transactional(readOnly = true)
    protected boolean isDuplicatePassport(Citizen citizen) {
        return passportDao.existsByCitizen(citizen);
    }

    private Passport createPassport(Citizen citizen, String nation) {
        return Passport.builder()
                .nation(nation)
                .citizen(citizen)
                .build();
    }

    private boolean validate(Passport passport, Citizen citizen, String nation) {
        return (passport.getCitizen() == citizen)
                && (passport.getNation().equals(nation));
    }
}

이미 발급한 적이 있다면 발급받지 못하도록 한다. (1인 1회 제한)

물론 재발급은 한다면 발급이 아예 불가능한 것은 아니겠지만, 일대일 단방향에 대해 알아보는 과정이므로 제외하였다.

발급 로직을 간단하게 요약하자면 다음과 같다.

  1. 시민과 국적을 매개변수로 받는다.
  2. 해당 시민이 여권을 발급받은 적이 있는 지를 검토한다. -> 해당 시민명의로 된 여권의 여부를 검사한다.
    1. 만일 있다면 예외를 발생시킨다.
  3. 여권을 매개변수들로 생성하고, 데이터베이스에 저장한다.
    1. 일대일 단방향이기에 여권은 시민을 알아야 하나, 시민은 여권을 알 필요가 없다.

요약 및 내용 추가

시민은 여권을 소유할 수도 있고, 소유하지 않을 수도 있습니다. 하지만 사람 정보가 없는 여권은 존재할 수 없습니다.

객체 지향 관점에서 보면, Citizen 이 Passport 필드를 갖는 게 자연스럽겠으나, Citizen 테이블에 passport 칼럼이 생기게 됩니다. 이 경우, Passport 를 발급받지 않은 Citizen 들은 모두 해당 칼럼이 null이 될 것입니다.

null값을 갖는 것은 데이터베이스 관점에서는 바람직하지 않기에 Passport 테이블에 Citizen 칼럼을 만들면 자연스럽게 null 데이터는 사라지게 됩니다. 

현재 예시는 일대일을 유도하는 예시이지만, 확장성을 고려해야 하는 상황이라면 상황은 얼마든지 바뀔 수 있기에 여러 가지 상황을 고려하여 설계하는 게 좋습니다.

 

 

일대일 (양방향)

기존 시민 엔티티(Citizen) 클래스에 전화번호 필드를 추가했다고 해보자.

/**
 * 전화번호
 */
@OneToOne(mappedBy = "citizen", cascade = CascadeType.REMOVE)
private ContactNumber contactNumber;

시민은 본인의 전화번호를 알아야 한다. 전화번호 엔티티 클래스는 다음과 같다.

@Entity
@Getter
@Builder
@AllArgsConstructor @NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ContactNumber {

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

    private String number;

    @OneToOne
    private Citizen citizen;

}

 

전화번호 역시 누구의 전화번호인지를 알아야 할 필요가 있다.

즉, 시민과 전화번호 둘 다 서로가 서로의 정보를 알아야 하는 상태이다. Citizen <-> ContactNumber

 

양 엔티티 클래스에 @OneToOne 으로 참조를 해주곤 주인이 될 엔티티에 mappedBy 로 설정해 주면 된다.

두 엔티티가 서로 참조할 수 있기에 객체지향 관점에서는 걱정하지 않아도 되며, 데이터베이스 관점 혹은 확장성을 고려하여 연관관계를 설정해주면 된다.

위의 예시에서는 Citizen 쪽에 mappedBy 가 있으므로 ContactNumber 의 주인은 Citizen 이다.

또, 주인이 아닌 쪽은 외래키를 관리하게 된다.

연관관계 로직 상황 가정

휴대전화를 개통해서 전화번호를 등록해야 한다고 가정해 보자.

우선 휴대전화를 개통하려는 시민을 매개변수로 받아야 한다.

그리고 위 가정 예시와 동일하게 변경은 불가하다고 해보자.

private final ContactNumberDao contactNumberDao;

@Transactional
public ContactNumber openAccount(Citizen citizen) {
    if (isAlreadyOpened(citizen)) {
        // 물론 전화번호를 바꾸는 것은 자기 마음이지만, 지금은 불가한 상황이다.
        throw new IllegalArgumentException("이미 개통을 완료한 고객입니다.");
    }

    final ContactNumber contactNumber = generateContactNumber(citizen);
    citizen.setContactNumber(contactNumber);
    return contactNumberDao.save(contactNumber);
}

위와 같이 간략하게 구현이 가능하다. 일대일 양방향 관계에서는 엔티티가 서로가 서로의 정보를 가지고 있어야 하므로

반드시 setter 혹은 생성자 등 주입을 해줄 수 있는 방법이 필요하다.

(물론 없어도 가능하도록 구현이 가능하다. Cascade 를 사용하는 방법인데, 이는 바로 다음에서 다루도록 하겠다.)

 

일대일 양방향 (w. Cascade)

우선 단도직입적으로 엔티티 클래스 코드부터 보기 전에 Cascade 가 무엇인지 간략하게 설명하겠다.

Cascade 는 (작은) 폭포라는 의미가 있다.
JPA 에서는 "영속성 전이"라고 생각하면 된다.
영속성 전이를 쉽게 설명하자면, 하나의 엔티티를 영속 상태로 만들 때, 이와 관련된 다른 엔티티 역시 영속 상태 만드는 것이라고 할 수 있다.

주민 등록 번호를 추가로 필요로 하게 되어 다음과 같이 엔티티 클래스를 설계하였다.

@Entity
@Getter
@Builder
@EntityListeners(value = AuditingEntityListener.class)
@AllArgsConstructor @NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ResidentCode {

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

    @CreatedDate
    @Column(nullable = false, updatable = false)
    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yy-MM-dd")
    private LocalDate birth;

    @Builder.Default
    @Enumerated(EnumType.STRING)
    @Column(nullable = false, updatable = false)
    private Gender gender = Gender.MALE;

    @Builder.Default
    @Column(nullable = false, updatable = false)
    private boolean foreigner = false;

    @OneToOne(cascade = CascadeType.PERSIST)
    private Citizen citizen;

    public enum Gender {
        MALE(1, 3, 5),
        FEMALE(2, 4, 6)
        ;

        private final int old_generation;       // 20세기
        private final int new_generation;       // 21세기
        private final int foreigner;            // 외국인

        Gender(int old_generation, int new_generation, int foreigner) {
            this.old_generation = old_generation;
            this.new_generation = new_generation;
            this.foreigner = foreigner;
        }

        public int getOld_generation() {
            return old_generation;
        }

        public int getNew_generation() {
            return new_generation;
        }

        public int getForeigner() {
            return foreigner;
        }
    }
}

주민 등록 번호(ResidentCode)는 시민(Citizen) 클래스가 저장됨과 동시에 반드시 저장되고, 한 번만 저장되어야 한다.

/**
 * 주민 등록 정보
 */
@OneToOne(mappedBy = "citizen", cascade = CascadeType.REMOVE)
private ResidentCode residentCode;

앞서 일대일 양방향에서 다루었으니, 자세한 내용은 생략하겠다. @OneToOne 어노테이션에 cascade 라는 옵션이 존재하는 데, 해당 옵션을 통해 영속성 전이를 할 수 있다. 영속성 전이는 되도록 그 엔티티의 관점에서 생각하여 설계해야 한다.

우선 주민 등록 번호 엔티티의 cascade 부터 설명하겠다.

@OneToOne(cascade = CascadeType.PERSIST)
private Citizen citizen;

cascade 을 PERSIST 로 지정함을 알 수 있는 데, 이는 주체(주민 등록 번호)가 최초로 영속 상태로 돌입할 때, 피주체(시민) 엔티티 역시 영속 상태로 함께 돌입하도록 하는 타입이다.

즉, 주민 등록 번호 엔티티를 저장할 때, (정확히는 JPA를 통해 조회/저장 등을 통해 나오게 되는 경우) 영속 상태가 되고,

그와 동시에 시민 엔티티 역시 영속 상태가 된다.

영속 상태가 되기 전의 엔티티는 참조할 수 없지만, PERSIST 키워드를 사용한 경우는 예외이다.

이어서 REMOVE 키워드를 설명하자면, 주체(시민 엔티티)가 삭제될 때, 하위 엔티티(주민 등록 번호 등) 역시 영향을 미치는 키워드이다. 

cascade 키워드는 이 외에도 3가지 정도 더 있다. (ALL 제외)

 

요약

연관관계가 일대일 양방향인 경우, 서로가 서로의 정보를 가지고 있는 상태이다.

두 엔티티가 서로 참조할 수 있기에 객체지향 관점에서는 걱정하지 않아도 되며, 데이터베이스 관점 혹은 확장성을 고려하여 연관관계를 설계하면 된다. 

양방향이라 할지라도 데이터베이스 관점에서는 외래키를 한쪽에서만 가지는 것이 바람직하므로 "주인"을 필요로 한다.

이때, 주인을 설정하기 위해서는 mappedBy 로 넘겨주면 된다.

 

Cascade는 영속성 전이로 굉장히 강력한 키워드이지만, 잘못 사용할 경우 엄청난 파장을 불러올 수도 있다.

상황에 맞게 잘 사용해야 한다.

 

다대일 단방향

이제부터 시민들도 계좌를 관리할 수 있도록 구현해야 한다고 가정해 보자. 계좌 엔티티는 매우 복잡하겠으나, 다음과 같이 단순하게만 작성해 보았다.

@Entity
@Getter
@Builder
@AllArgsConstructor @NoArgsConstructor(access = AccessLevel.PROTECTED)
public class BankAccount {

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

    @OneToOne
    private Money money;

    @ManyToOne
    private Citizen citizen;

}

(Money 엔티티는 balance 칼럼만을 가진 엔티티이나, 가독성을 높이기 위해 해놓은 것이니 큰 신경은 쓰지 말자.)

ManyToOne을 시민과 매핑한 것을 볼 수 있다. 이는 계좌 : 시민은 다 : 일 임을 알 수 있다.

즉, 한 시민은 여러 개의 계좌를 생성할 수 있음을 의미한다. 단, 시민 본인은 총 몇 개의 계좌를 직접적으로 알 수 있는 방법은 없다. (객체지향 관점적으로 문제가 발생할 수 있음)

또한, 다대일(ManyToOne)을 사용하는 쪽은 반드시 주인이 될 수 없다.

 

다대일 양방향

다른 반대쪽 엔티티(참조하고 있는 엔티티)에 OneToMany 를 사용하여 주인을 등록해 주어, 주인을 지정해 줄 수 있다.

위의 기존 코드(BankAccount) 는 그대로 둔 채, 시민(Citizen) 엔티티 클래스에만 @OneToMany를 지정해 주면 된다.

이때, 시민이 단방향과 달리 직접적으로 몇 개의 계좌를 알 수 있으며, 접근이 가능하기에 객체지향적으로 여러 개를 관리해야 하기에 Collections 를 상속한 인터페이스(List, Set 등)를 사용하여 관리할 수 있다.

@Builder.Default
@OneToMany(mappedBy = "citizen", cascade = CascadeType.REMOVE)
private Set<BankAccount> accounts = new HashSet<>();

 

일대다 단방향

필자의 개인적으로 가장 이해하기 어려웠던 연관관계 매핑 방식이다. 테이블 간 칼럼들과 연관관계는 변하지 않았지만,
  객체지향적 코드는 변하였기에 이해하기 다소 어렵다.

시민 엔티티 클래스의 @OneToMany 코드를 다음과 같이 변경했다. 하지만 데이터베이스 관점에서는 동일하다는 것을
  알아야 한다. (계좌 엔티티 클래스의 @ManyToOne 칼럼은 지우면 된다.)

@Builder.Default
@JoinColumn(name = "citizen_id")
@OneToMany(cascade = CascadeType.REMOVE)
private Set<BankAccount> accounts = new HashSet<>();

위 코드에서는 @JoinColumn을 통해 직접적으로 명시해 주었지만, 만일 하지 않을 경우, 자동으로 두 연관관계를 관리하는
   Join Table 전략을 사용하여 매핑하게 된다.

일대다 단방향 글들을 여러 찾아보면, 일대다 단방향보다는 다대일 양방향을 권장하는 데 이 이유는 매핑한 객체가
  관리하는 FK가 다른 테이블에 있기 때문이라고 한다. 즉, 기존에는 한 번의 insert 이었던 것이 별도의 update 쿼리를

  요구하기 때문이다. 

개인적인 경험이지만, 이를 다대일 양방향이라 잘못 알고선 1개월 후에야 이를 눈물을 머금고 로직들을 전부 뜯어고쳤던      경험이 있다. 

 

일대다 양방향

우선, 양방향 매핑에서 OneToMany 는 주인이 될 수 없기 때문에 불가능한 매핑입니다. (FK는 항상 다(N) 쪽에 있기 때문)

물론, 읽기 전용 매핑을 이용하여 구현이 가능합니다. (insertable = false, updatable = false 를 적용한 FK)

더보기

Citizen 에는 @OneToMany 를 적용하여 관리하도록 하고, BankAccount 에는 @ManyToOne 에 @JoinColumn 을
  통해 insertable = false, updatable = false 를 설정하여, 매핑이 가능합니다.

하지만 이 방식 역시, 다대일 양방향을 사용하는 것을 추천합니다.

 

다대다 단방향

우선, 다대다(N:M)는 사용하지 않는 것을 권장한다. 관계형 데이터베이스는 정규화를 거친 테이블 2개로 다대다 관계를

  표현할 수 없다. 중간에 연결 테이블을 통해 구현하는 방식이다.

@ManyToMany
@JoinTable(name = "client_products")
private List<Product> products = new ArrayList<>();

N 엔티티 클래스에 @ManyToMany 어노테이션과 함께 @JoinTable 어노테이션을 사용하여 Collections로 관리할 수

  있도록 한다.

외래키 제약 조건 2가지가 설정된 조인 테이블이 하나 생성된다.

 

다대다 양방향

M 엔티티 클래스에 @ManyToMany 와 함께 mappedBy 로 주인을 설정한다.

@ManyToMany(mappedBy = "products")
private List<Client> clients = new ArrayList<>();

다대다 요약

편리해 보이지만, 가능한 사용하지 않기를 권장한다. 연결 테이블(브릿지 테이블)이 단순히 연결만 하고 끝나지 않고,

  조인 테이블 자체에 주문 시간, 수량과 같은 칼럼들이 여러 들어가게 될 수 도 있다.

하지만 매핑 정보만 넣는 것이 가능하고, 추가 정보를 넣는 것 자체가 불가능하다. (물론 직접 브릿지 테이블을 구현하는
  경우에는 가능하다.) 또, 브릿지 테이블은 숨겨져있기 때문에 예상하지 못한 쿼리들이 나가게 된다.

 

다대다 한계 극복

앞서 설명한 것과 같이 브릿지 테이블을 직접 엔티티로 승격시켜 사용하는 방식이다.

그리곤 @ManyToMany 를 각각 @OneToMany, @ManyToOne 으로 연결한다.

하지만 비즈니스적 로직에서 문제가 생길 수 있다는 한계점의 가능성은 여전히 존재한다.

 

 

이상입니다. (추가로 hibernate 에만 존재하는 @Any 에 대한 내용은 https://www.concretepage.com/hibernate/hibernate-any-manytoany-and-anymetadef-annotation-example 를 참조하길 바랍니다.)