[JPA] 연관관계 매핑 - 단방향, 양방향, 연관 관계 주인

dooboocookie·2022년 10월 28일
0
post-thumbnail

목표

  • 프로젝트에서 수많은 연관관계가 있다.
  • 이 글에서는 병원병원 이미지 두 엔티티의 연관관계를 통해서 양방향, 단방향, 연관관계의 주인에 대해서 알아볼 예정이다.

테이블 vs 객체

  • 관계형 DB 테이블의 연관관계
    • 테이블은 위의 ERD와 같이 병원의 PK병원 이미지에서 FK로 사용하여 연관관계를 나타냄
    • 특징은 FK를 가지고 부모인 병원을 조회할 수 있다는 점
  • 객체(엔티티)의 연관관계
    • 객체에서도 연관관계를 잇는 컬럼이 Long타입으로 병원의 @Id컬럼을 가질 수도 있지만, 이는 객체지향적이지 않다!!
    • 즉, 객체의 연관관계는 참조주소, 해당 엔티티를 컬럼으로 가져야한다.
    • 이 상황에서 문제점은 병원 이미지측에서는 참조주소로 연관관계가 이어져 있으므로, 참조 주소를 안다고 해서 병원을 조회할 수 있지는 않다. FK와의 차이가 분명하다.

단방향 연관관계

사실 모든 엔티티의 연관관계는 단방향이다.

연관관계 매핑

  • Hospital.java
@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Hospital extends TimeStamped {
	
    @Id 
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long hosId;

    private String hosName;

    private String hosPhone;

    @Enumerated(EnumType.STRING)
    private HosStatus hosStatus;

    @Enumerated(EnumType.STRING)
    private HosBooking hosBooking; // 예약 가능 상태를 나타내는 값

    @Embedded
    private Address hosAddress;

    private String hosOpenhour;
    
}
// TimeStamped는 등록일, 수정일 관련 공통필드
  • HosImg.java
@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class HosImg extends TimeStamped {

    @Id 
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long himId;
    
    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "hosId") //hosImg 테이블의 컬럼명
    private Hospital hospital; //DB의 FK
    
    private String himPath;
    
    private boolean himMain;
    
    private String himOrigin;
    
}
// fetch = LAZY, 엔티티 생성, 수정 방법은 추후에 알아보도록 하자
  • 단방향 연관관계는 DB 테이블 입장에서 봤을 때, FK가 있는 쪽에 연관관계를 지어준다.
  • 이 상황의 경우, N:1이다.
    • 병원 이미지가 N / 병원이 1
  • 따라서 @ManyToOne 어노테이션으로 매핑을 한다.
    • 1:1 관계의 경우 @OneToOne으로 연결할 수 있겠다.

저장

해당 프로젝트에선 엔티티에 setter를 제거해서 생성 메소드나, 변경하는 메소드를 사용하지만, 이 글에서는 setter를 쓰겠다.

tx.begin();

// 병원 엔티티 생성
Hospital hospital = new Hospital();
hospital.setHosName("test병원");
hospital.setHosPhone("010-1234-1234");
// 병원 엔티티 영속 상태
em.persist(hospital);

// 병원 이미지 엔티티 생성
HosImg hosImg = new HosImg();
hosImg.setPath("이미지 파일 경로");
// 연관관계 설정
hosImg.setHospital(hospital);
// 병원 이미지 엔티티 영속 상태
em.persist(hoImg);

// 트랜젝션 커밋 -> flush() -> INSERT 병원, 병원이미지
tx.commit();
  1. 먼저 부모가 되는 엔티티를 영속화
  2. 자식 엔티티에 해당 엔티티의 참조값을 필드값으로 넣어줘서 영속화
  3. INSERT문이 날라갈 때는 DB에 FK로 부모의 PK 값을 가진다.

조회

  • 위의 저장하는 과정에서는 두 엔티티 다 1차 캐시에 있는 상태이므로 조회하는 쿼리가 따로 나가진 않을 것이다.
  • flush(), clear()를 한 상태(1차 캐시에 없는 상태)라 가정
// 비지니스 로직 상, 사진 한장을 조회하는 경우는 없을 것이지만, 일단 설명을 위한 코딩이다

// 1. 사진을 find해오는 과정
HosImg findHosImg = em.find(HosImg.class, 1L);

// 2. 사진을 통해 그래프 탐색으로 참조된 병원을 가져오는 과정
Hospital findHospital = findHosImg.getHospital();
  • 위에 엔티티 매핑시 설정했던 옵션에 따라 나가는 쿼리문이 차이가 있다.
    • fetch = LAZY
      • 지연로딩, .getHospital() 2번 과정에서 병원을 찾는 SQL문이 따로 나간다.
    • fetch = EAGER
      • 즉시로딩, .find() 1번 과정에서 병원과 이미지를 JOIN하는 쿼리문을 통해 두 엔티티 정보를 다 가져온다.
      • (N+1)문제가 생길 수 있음, 이는 나중에 심도있게 공부해보자

연관관계 수정

  • 특정 사진을 다른 병원의 사진으로 옮기고 싶은 수정
    • 비지니스 로직 상 이런 과정은 없다, 하지만 설명상 연관관계를 수정하는 코딩을 소개
// 1. 사진을 find해오는 과정
HosImg findHosImg = em.find(HosImg.class, 1L);
log.info("병원 : {}", findHosImg.getHospital().getName()); // 병원 : test병원

// 2. 새로운 병원
Hospital hospital2 = new Hospital();
hospital2.setHosName("test병원22");
hospital2.setHosPhone("010-5678-5678");
em.persist(hospital2);

// 3. 병원 수정
findHosImg.setHospital(hospital2);
log.info("병원 : {}", findHosImg.getHospital().getName()); // 병원 : test병원22 > flush()되면 UPDATE문 날라감
  • 병원 이미지병원컬럼의 참조를 바꾸면, 변경감지(링크)에 의해서, flush()될 때 UPDATE 문날라감

양방향 연관관계

  • 위에서 설명했듯이, 테이블은 FK가 있으면 양쪽 테이블을 모두 원하는 값을 조회할 수 있다.
  • 하지만 객체에서는 병원 객체의 참조값을 안다고 해서, 그걸 참조하는 병원 이미지를 알 수 있는 것은 아니다.
  • 그래서, 병원병원 이미지를 조회할 수 있는 단방향 연관관계를 하나 더 추가해 양방향 연관관계를 만드는 것이다.
    • 양방향 관계를 해야되는지, 단방향 관계를 해야되는지는 뒤에서 알아볼 예정

연관관계 매핑

  • 기존에 @ManyToOne 연관관계는 유지하고
  • 반대쪽 엔티티에 @OneToMany를 추가해준다.
  • Hospital.java
@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Hospital extends TimeStamped {
	
    @Id 
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long hosId;

    private String hosName;

	//...
    
    @OneToMany(mappedBy = "hospital");
    List<HosImg> hosImgs = new ArrayList<>();
    
}
  • 병원이 1이고 병원 이미지가 N이므로 @OneToMany의 연관관계 추가
  • 이렇듯, 양방향 관계는 단방향 관계를 2개 만들어 양쪽에서 서로를 관계지어주는 것 뿐이다.
  • List<> 필드에는 new ArrayList<>();를 미리 초기화해두는 것이 관례 (NullPointException 방지)
  • mappedBy
    • 연관관계의 주인을 나타낸다.
    • HosImg의 hospital 필드가 연관관계의 주인이라는 뜻이다.
    • 뒤에서 조금 더 자세히 살펴보자..

조회

  • 병원 객체를 통한 병원 이미지들 역방향 조회
// 병원 조회
Hospital findHospital = em.find(Hospital.class, 1L);

// 병원을 통한 병원 이미지 그래프 탐색
List<HosImg> findHosImgs = findHospital.getHosImgs();

for (HosImg hosImg : findHosImgs) {
	log.info(hosImg.getHimPath());
}

양방향 연관관계 주인

  • 양방향 연관관계는 양쪽에서 서로를 참조하고 있다.
  • DB에 적용되는 FK에 대해서 관리해주는 주체가 필요하다!
  • 이를 연관관계의 주인이라 한다.

연관관계의 주인은 외래키가 있는 쪽으로 한다.

  • 외래키가 없는 쪽은 역방향으로 조회를 하기 위한 연관관계이므로 주인키가 될 수 없다.

mappedBy

  • 연관관계 주인을 매핑해주는 속성
  • 연관관계 주인이 아닌 쪽에서 속성을 넣어주면 된다.
    • mappedBy=hospital
    • HosImg에 hospital 필드를 연관관계 주인으로 설정하는 속성
  • 주인 쪽에는 mappedBy 설정하지 않는다.

양방향 연관관계 저장

tx.begin();

// 병원 엔티티 생성
Hospital hospital = new Hospital();
hospital.setHosName("test병원");
hospital.setHosPhone("010-1234-1234");
// 병원 엔티티 영속 상태
em.persist(hospital);

// 병원 이미지 엔티티 생성
HosImg hosImg = new HosImg();
hosImg.setPath("이미지 파일 경로");
// 연관관계 설정
hosImg.setHospital(hospital);
// 병원 이미지 엔티티 영속 상태
em.persist(hoImg);


// 트랜젝션 커밋 -> flush() -> INSERT 병원, 병원이미지
tx.commit();
  • 단방향 연관관계와 마찬가지로 이와 같이 연관관계 주인(외래키가 있는 쪽)에 필드 값으로 참조 값을 설정해주면 된다.

추가 조치

  • 이렇게 연관관계 주인 쪽에만 참조 값을 설정해 주면 문제점이 있다.
    • 병원 엔티티 생성 단계
      • 이때 병원 엔티티가 1차 캐시에 저장이 된다.
      • 아직 INSERT문을 날라가지 않은 상태
    • 병원 이미지 엔티티 생성하고 연관관계 설정해주는 단계
      • 병원 이미지도 1차 캐시에 저장이 된다.
    • commit()전에 병원 엔티티를 조회 시, 1차 캐시에서 조회하기 떄문에 hospital.getHosImgs()에는 병원 이미지가 있을 수 없다.

따라서, 양쪽에 둘다 값을 설정해주는 것이 좋다.

  • 그냥 .setXXX(), .add() 코드를 비지니스 로직 상 적어도 좋으나, 편의 메소드를 등록 해두는 것이 좋을 것이다.
    • 현재는 병원을 등록하면서 병원 이미지를 등록하는 경우가 많으므로, 병원 엔티티에 메소드를 만들겠다.
    • 반대로 해도 크게 상관은 없다. 상황에 따라 선택
@Entity
public class Hospital extends TimeStamped {
	
    @Id 
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long hosId;

    private String hosName;

	//...
    
    @OneToMany(mappedBy = "hospital");
    List<HosImg> hosImgs = new ArrayList<>();
    
    
    //편의 메소드
    public addHosImg(HosImg hosImg) {
    	// 병원 이미지에 연관관계를 설정
        hosImg.setHospital(this);
        // 병원에 병원 이미지(역방향)을 추가
        this.hosImgs.add(hosImg);
    }
    
}

수정 관련 주의점

  • 양방향 연관관계는 주인에 의해서만 FK가 관리될 수 있다.
  • 주인이 아닌 쪽에서는 조회만 할 수 있다.
tx.begin();

// 병원 엔티티 생성
Hospital hospital = new Hospital();
hospital.setHosName("test병원");
hospital.setHosPhone("010-1234-1234");
// 병원 엔티티 영속 상태
em.persist(hospital);

// 병원 이미지 엔티티 생성
HosImg hosImg = new HosImg();
hosImg.setPath("이미지 파일 경로");

// 연관관계 주인에 대해서 설정을 하지 않고
// hosImg.setHospital(hospital);
// 역방향으로만 연관관계를 지어주는 척하면 이건 FK설정이 되지 않는다.
// 이렇게되면 hosImg테이블에 FK 는 null이 된다.
hospital.getHosImgs.add(hosImg);

// 병원 이미지 엔티티 영속 상태
em.persist(hoImg);

// 트랜젝션 커밋 -> flush() -> INSERT 병원, 병원이미지
tx.commit();

양방향 연관관계 무한 루프

  • 양방향 연관관계에서 Lombok에서 제공하는 @ToString 같은 경우 무한 루프가 일어날 수 있다.

  • hospital.java

@Override public String toString() {
	return "..." + this.hosImgs
    //이 과정에서 HosImg의 toString을 호출한 것이다.
}
  • HosImg.java
@Override public String toString() {
	return "..." + this.hospital
    //이 과정에서 Hospital의 toString을 호출한 것이다.
}
  • 이렇게 계속 서로의 toString을 호출 하므로 무한 루프에 빠진다.
  • 이는 JSON라이브러리를 사용할 때 많이 발생됨
    • 따라서, 컨트롤러에서는 엔티티 자체를 반환하지 않는것이 중요하다.
    • 응답하는 DTO를 만들어서 그것으로 반환값을 설정하는게 좋음

단방향 vs 양방향

  • 양방향 연관관계는 여러모로 객체보단 테이블과 좀 더 유사하게, 엔티티를 바라볼 수 있게 해준다는 점에서 이점이 있지만,
  • 여러가지 문제점을 야기할 수 있다.

단방향만으로도 연관관계 매핑 자체는 완성이다.

  • 가장 큰 핵심은, 양방향 연관관계는 조회에 있어 편의를 도와준다는 점이다.
    • JPQL로 JOIN통하면 충분히 단방향으로만 설계할 수 있다.
  • 현재의 경우, 병원을 조회할 때 이미지는 거의 필수적으로 조회하게 되고, 이미지 자체도 병원에 의해서만 조회되므로, 양방향 연관관계를 설정하였다.
  • 하지만, 회원-주문과 같은 연관관계에서는 회원 엔티티에 List<Order>를 가지고 있어야되는 것은 잘 생각해볼 문제다.
    • (필요 없다고 생각함)
  • 웬만하면, 단방향으로 설계하고 필요 시에 양방향을 추가하는 방향으로 설계하는 것이 좋은 설계
profile
1일 1산책 1커밋

0개의 댓글