본문 바로가기
Back-end/java spring

[JPA] 연관관계 매핑 기초

by kkkdh 2023. 1. 30.
728x90

객체가 지향하는 패러다임과 관계형 데이터베이스의 패러다임 간의 차이에서 오는 어려움을 해결하는 첫 번째 단계이다.

차근차근하게 정리해 보자.

 

이번 단계의 목표

  • 객체와 테이블 연관관계의 차이를 이해하자
  • 객체의 참조와 테이블의 외래 키를 매핑
  • 용어 이해
    • 방향(Direction): 단방향, 양방향
    • 다중성(Multiply): 다대일(N:1), 일대다(1:N), 일대일(1:1), 다대다(N:M) 이해
    • 연관관계의 주인(Owner): 객체 양방향 연관관계는 관리 주인이 필요(???, 처음 보면 c언어의 포인터와 같은 느낌이라고 한다..)

 

연관관계가 필요한 이유

예제 시나리오

  • 회원과 팀이 있다.
  • 회원은 하나의 팀에만 소속 가능
  • 회원과 팀은 다대일 관계이다. (한 팀에는 여러 명이 속하기 때문)

이런 식의 스키마가 그려질 것이다..

하지만, 위의 스키마에 따른 객체의 설계는 뭔가 객체 지향 설계라고 하기에는 부족해 보인다.

 

그 이유는 연관이 있는 다른 객체를 바로 접근할 수 없고, teamId라는 식별자를 이용해서 한 번 더 찾아가야 하는 구조 때문일 것이다.

설계된 객체의 코드는 이러할 것

이렇게 객체를 구현하면

//팀 저장
Team team = new Team();
team.setName("TeamA");
em.persist(team);
//회원 저장
Member member = new Member();
member.setName("member1");
member.setTeamId(team.getId());
em.persist(member);

위 코드와 같은 방식으로 객체와 연관 있는 객체를 EntityManager를 거쳐서 찾아야 하는 구조가 된다.

 

이런 식으로 외래 키의 식별자를 직접 다루는 방식은 객체 지향 설계에는 걸맞지 않은 방법이다.

 

객체를 테이블에 맞춰 데이터 중심으로 모델링을 하면, 객체의 협력 관계(객체 지향의 핵심 패러다임)를 만들 수 없다.

  • 테이블은 외래 키(foreign key)로 조인을 사용해서 연관된 테이블을 찾는다.
  • 객체는 참조를 사용해서 연관된 객체를 찾는다.
  • 따라서 테이블과 객체 사이에는 이런 큰 간격이 있다.

 

이렇게 team 객체와 TEAM_ID FK를 연관관계 매핑한다.

이렇게 되면, 매핑이 끝나서 객체 지향의 방식에 맞게 마음대로 사용할 수 있게 된다.

ManyToOne 어노테이션으로 다대일 관계임을 매핑하고, JoinColumn 어노테이션을 이용해 join 할 때 사용해야 하는 칼럼의 이름을 명시할 수 있다.

 

이렇게 설정하면, 객체에 대한 참조를 테이블에서 FK를 이용한 참조와 같이 사용할 수 있다.

setter로 참조 관계만 설정하면, 연관관계가 설정된다.

위와 같이 entity 객체의 참조 관계를 설정만 하면, JPA가 알아서 FK를 이용한 연관관계 설정을 해준다.


양방향 연관관계와 연관관계의 주인 1 - 기본

앞서 공부한 영속성 컨텍스트의 동작 메커니즘 이해와 이번 챕터가 JPA를 혼자 공부하면서 이해하기 가장 어려운 부분이라고 한다..

 

어떻게 JPA를 사용하는지 아는 것에 앞서서 왜 이렇게 사용하고 왜 중요한지 이해하는 게 우선!!

 

양방향 매핑

양방향 연관관계 설정

객체의 경우에는 Team에 List members 필드가 추가되었지만, 테이블에서의 연관관계는 기존에도 양방향으로 설정되기 때문에, 단방향일 때와 달라지는 것이 없다!

 

테이블은 외래 키(FK) 하나로 한 번에 양방향의 연관관계가 표현되는 반면, 객체는 연관된 객체를 참조하기 위해서 반대로 Team entity 객체에서도 member 리스트를 저장해야 하기 때문이다.

(테이블에서는 사실상 방향이라는 개념이 없는 것)

 

위 코드와 같이 Team Entity에서 member 객체의 리스트를 매핑

주석에 작성된 바와 같이 @OneToMany 어노테이션을 사용해서 team의 입장에서 member와의 연관관계 차수를 매핑하고, 하나의 팀에는 여러 명의 멤버가 속하기 때문에, list 형태로 member를 저장하는 list 컬렉션을 필드로 만들어준다.

 

@OneToMany 어노테이션의 mappedBy 속성은 mapping 되는 반대편 객체에서 해당 객체가 어떤 필드로 매핑되어 있는지 명시하기 위해 사용한다. (Member entity에서 team 필드가 Team entity와의 연관관계를 매핑하기 위해 사용되고 있다.)

이제 영속성 컨텍스트를 거치지 않고, 객체의 참조를 통해 연관관계 상의 객체 조회가 가능하다!!

이렇게 Member ↔ Team (양방향으로 참조 가능한) 연관관계를 양방향 연관관계라고 부른다.

 

그런데, 다시 돌아가서 앞서 나온 mappedBy 이게 굉장히 중요한 것이라고 한다..

 

연관관계의 주인과 mappedBy

  • mappedBy = JPA를 어렵게 하는 주요 요인
  • 처음에는 이해하기 어렵다고 한다
  • 객체와 테이블간에 연관관계를 맺는 부분에 있어서 차이점을 이해하는 게 우선이라고 한다!!

 

객체와 테이블이 관계를 맺는 부분에서의 차이점 때문이다!

  • 객체의 연관관계 (2종류)
    • 회원 → 팀 (단방향 연관관계 1개)
    • 팀 → 회원 (단방향 연관관계 1개)
  • 테이블 연관관계 (1종류)
    • 회원 ↔ 팀 (양방향 연관관계 1개)
    • 사실 양방향도 만든 말이고, 그냥 1개의 연관관계면 끝

이는 객체가 서로를 참조하기 위해서는 양쪽에 서로를 참조하는 필드가 각각 들어가야 하는 특징 때문, 테이블에서는 하나의 외래 키만 있으면, 서로를 참조할 수 있음

 

객체의 양방향 관계

  • 객체의 양방향 관계는 사실 양방향 관계가 아니라 서로 다른 단방향 관계 2개이다!!!
  • 객체를 양방향으로 참조하려면, 단방향 연관관계 2개를 만들어야 한다.
    • A → B (ex. "a.getB()")
    • B → A (ex. "b.getA()")

테이블의 양방향 연관관계

  • 테이블은 하나의 외래 키로 두 테이블의 연관관계를 관리한다.
  • MEMBER.TEAM_ID(FK) 하나로 양방향 연관관계 표현 (양쪽으로 조인하면 된다.)
    • SELECT * FROM MEMBER M JOIN TEAM T ON M.TEAM_ID = T.TEAM_ID
    • SELECT * FROM TEAM T JOIN MEMBER M ON T.TEAM_ID = M.TEAM_ID

 

따라서 객체 특성상 연관관계상에 있는 두 개의 객체 중 하나를 변경하여 DB에 반영하고 싶을 때, 누구를 기준으로 변경 사항을 업데이트할지 헷갈리게 된다.

둘 중의 뭘 기준으로 연관관계를 관리해야 할까..?

그래서 둘 중 하나로 외래 키를 관리해야 한다는 규칙이 등장했다. (이걸 연관관계의 주인을 정한다고 말한다)

 

연관관계의 주인 (Owner)

양방향 매핑의 규칙

  • 연관관계상의 두 객체의 필드 중 하나를 연관관계의 주인으로 지정한다.
  • 연관관계의 주인만이 외래 키를 관리한다 (등록, 수정)
  • 주인이 아닌쪽은 읽기만 가능 (read-only)
  • 주인은 mappedBy 속성을 사용 X → 의미 자체가 ~에 의해 매핑된다는 뜻
  • 주인이 아니면, mappedBy 속성을 이용해서 연관관계 주인을 지정해줘야 한다.

JPA에서는 주인으로 설정한 필드만 값이 수정되었을 때, 업데이트하거나의 상황이 발생한다.

 

지금 예제에서 members list 같은 필드는 오직 조회의 목적으로만 사용된다.

 

여기에 값을 아무리 집어넣어도 DB에 반영되지 않는다!!

 

 

그래서 누구를 주인으로??

외래 키가 있는 곳을 주인으로 정하자!! (데이터베이스 스키마를 설계한 대로 따라가자)

 

지금 예제에서는 Member.team이 연관관계의 주인이다.

MEMBER 테이블에 TEAM_ID를 FK로 가져오도록 DB 스키마를 설계한 상황

위 그림과 같이 데이터베이스가 설계되어 있으므로 Member 객체의 team이 연관관계의 주인이 되는 것이다.

 

✅✅ 그리고 데이터베이스를 설계할 때, TEAM_ID가 외래 키로 MEMBER 테이블에 들어간 것은 다대일 관계(Many to one, N : 1)의 경우 N 쪽에 1의 PK를 FK로 가져와서 사용하는 설계 상의 규칙 때문이라고 생각한다. ✅✅

 

이는 DBMS 자체에서 배열을 칼럼의 값으로 사용할 수 없기 때문이다. (N 쪽에 1의 PK를 FK로 사용하면 배열을 칼럼의 값으로 사용해야 하니깐)

 

설계 상에서도 Team 객체의 필드 값을 수정했는데, MEMBER 엔티티와 매핑된 테이블에 update query가 날라간다❓❓❓ → 이것도 이상한 문제, 이해가 매우 어려울 것이다.

 

<따라서 김영한 님이 정해준 기준!!>

  • 외래 키가 있는 곳을 연관관계의 주인으로 정해라
  • 외래 키가 있는 곳이 무조건 다, 반대편이 일 (다대일 관계에서)
  • 고로 다대일 관계에서 '다'쪽이 연관관계의 주인이다.
  • 연관관계의 주인이라고 비즈니스 로직에서 중요하다?? 이건 아니다.

이러한 기준만 설정하면, JPA에서 연관관계 주인을 설정하는 문제는 큰 어려움이 아니라고 하셨다.


양방향 연관관계와 연관관계의 주인 2 - 주의점, 정리

양방향 매핑 시 가장 많이 하는 실수

연관관계의 주인에 값을 입력하지 않는 실수가 가장 많이 발생하는 실수라고 한다.

코드의 순서를 조금 바꿨다.

위 코드와 같이 연관관계의 주인인 member에 값을 입력한 것이 아니라, 반대편의 team의 members에 값을 입력한 결과

team_id가 NULL로 기입된다.

이처럼 연관관계의 주인에 값을 입력하지 않으면, 테이블에 연관관계가 작성되지 않음을 확인할 수 있다.

이렇게 연관관계 주인에 값을 세팅해줘야 한다.

연관관계 주인에 값을 입력하면, 반대편의 members에 넣어주는 건 상관없다. (어차피 JPA는 연관관계 주인을 기준으로 테이블을 갱신한다.)

연관관계의 주인에 값을 입력하고, 영속성 컨텍스트에서 새로이 조회를 해보자.

위와 같은 경우에는 리스트에 값을 세팅하지 않았더라도, 영속성 컨텍스트에서 조회한 객체의 연관관계가 리스트에 매핑되어 있음을 확인할 수 있다.

 

이건 JPA에서 연관관계 주인의 값을 기반으로 연관관계를 테이블에 설정해 주기 때문이다.

JPA가 연관관계를 바탕으로 사용 시점에서 Join을 알아서 해서 정보를 전달해 준다.

값을 굳이 세팅 안 해줘도 JPA가 알아서 세팅해 준다.

 

하지만, 이렇게 사용하는 경우 EntityManager를 flush, clear 해주지 않는다면 entity 객체의 정보가 메모리 상에만 머물러있기 때문에, 연관관계 정보가 반영되지 않은 Team entity가 영속성 컨텍스트에 있는 상태일 것이고

 

해당 team 객체를 조회하면, 그대로 연관관계 정보가 없는 객체가 반환된다.

이러면, 연관관계 주인의 반대편 객체에서는 참조를 통한 접근이 불가하다.

그래서 객체 지향적인 설계를 위해서 양방향 매핑 시에 양쪽에 값을 모두 입력해줘야 한다!!!

 

정리: flush, clear를 통해 영속성 컨텍스트의 1차 캐시를 모두 비워서 다시 가져오면 JPA가 연관관계를 반영해서 객체를 반환해 주겠지만, 그렇지 않은 경우 반대로는 연관관계 정보가 매핑되지 않은 상태의 객체가 영속성 컨텍스트의 1차 캐시에서 반환될 것이기 때문이다.

 

양방향 연관관계 주의 - 실습

  • 순수 객체 상태를 고려해서 항상 양쪽에 값을 설정하자!!
  • 편하게 양쪽에 값을 설정하기 위해서 연관관계 편의 메서드를 생성하자.
  • 양방향 매핑 시에 무한 루프를 조심!!
    • 예) toString(), lombok, JSON 생성 라이브러리

연관관계 편의 메서드를 코딩을 통해 알아보자.

member의 team을 설정하는 시점에, team에 member 객체까지 등록을 한 번에 하자.

이런 걸 연관관계 편의 메서드라고 부른다고 한다.

이제 단순하게 setter의 역할이 아닌 로직이 들어가기 때문에 김영한 님은 메서드의 이름을 바꿔준다고 하신다.

양쪽의 연관관계 값을 한 번에 작성해 주니까 조금 더 의미에 맞는 이름을 부여하도록 하자.

public void addMember(Member member){
    member.setTeam(this);
    members.add(member);
}

이렇게 반대편의 Team 객체에서 연관관계 편의 메서드를 사용해도 되는데, 양쪽에서 사용하는 것만 피하면 된다고 한다.

 

무한 루프를 조심하자

toString method를 overriding 해서 선언해보자.

이렇게 되면, Member 객체의 toString에서 team의 toString을 또 호출하게 된다.

컬랙션 안에 있는 애들을 하나하나 toString 다 호출한다.

그런데, team의 toString을 또 선언한다면?

 

이렇게 되면, 무한 루프에 빠지는 문제가 발생한다.

 

lombok 같은 걸 사용해서 toString을 만들면, 자동적으로 이렇게 처리한다고 함 (양방향에 의해서 무한 루프에 빠져버린다.)

 

Spring에서 Entity를 바로 json으로 바꾸려고 할 때에도, Entity 안에 Entity가 있어서 이렇게 양방향 연관관계에 의한 무한루프에 빠지는 경우가 많다고 한다. (서로 toString을 계속해서 호출하는 문제)

  • controller에서 entity를 json으로 반환하지 말자
  • entity를 API로 반환하는 것은 API 스펙을 관리하는 입장에서도 문제가 된다. (entity가 변경되면 스펙이 변함)
  • DTO를 사용해서 반환하도록
  • 이것만 안 해도 대부분의 문제들이 해결된다고..

 

양방향 매핑 정리

  • 단방향 매핑 만으로도 JPA에서 이미 연관관계 매핑은 완료될 것이다.
  • 양방향 매핑은 반대 방향으로 조회(객체 그래프 탐색 측면에서) 기능이 추가된 것뿐이다.
  • JPQL에서 역방향으로 탐색할 일이 많다!!
  • 일단 단방향 매핑으로 잘 설계하고, 양방향은 필요할 때 추가해도 된다. (테이블에 영향을 주지 않으므로, 안 쓰면 그만)

연관관계의 주인을 정하는 기준!!

  • 비즈니스 로직을 기준으로 연관관계의 주인을 선택하면 안 된다!!
  • 연관관계의 주인은 외래 키의 위치를 기준으로 정해야 한다!!

 

728x90

댓글