JPA 객체 지향형 연관관계 매핑

강정우·2024년 3월 3일
0

JPA

목록 보기
10/12

객체와 테이블 연관관계의 차이

• 객체의 참조와 테이블의 외래 키를 매핑

엔티티 설계순서 (데이터 중심 설계)

  1. 요구사항 분석
  2. 기능 목록 명세화
  3. 도메인 모델 분석
    1) 회원과 주문의 관계: 회원은 여러 번 주문할 수 있다. (일대다)
    2) 주문과 상품의 관계: 주문할 때 여러 상품을 선택할 수 있다. 반대로 같은 상품도 여러 번 주문될 수 있다.
    주문상품 이라는 모델을 만들어서 다대다 관계를 일다대, 다대일 관계로 풀어낸다.
  4. 테이블 설계
  5. 엔티티 설계와 매핑

그럼 위와 같은 요구사항에 맞춰 순서대로 DB를 설계하면 아래와 같은 테이블 구조가 나올 것이다.

문제) 데이터 중심 설계의 문제점

이 방식은 객체 설계를 테이블 설계에 맞춘 방식이다.

  1. 테이블의 외래키를 객체에 그대로 가져옴
  2. 객체 그래프 탐색이 불가능
  3. 참조가 없으므로 UML도 잘못됨
  4. 비즈니스 로직이 굉장히 복잡해짐
    • 예를들어 find 하여 Order를 갖고온 다음 예를 다시 넣어서 Member를 찾아봐야 주문자를 찾을 수 있다.

방법 1. 객체를 테이블에 맞추어 모델링

그래서 위 사진을 비즈니스 로직을 대충 작성해보자면 다음과 같다.

//팀 저장
Team team = new Team();
team.setName("TeamA");
em.persist(team);

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

// 사용자의 팁을 알고싶을 때
Member findMember = em.find(Member.class, member.getId());
// 여기서 문제 발생 => 연관관계가 없음
Team findTeam = em.find(Team.class, team.getId());

// 이때 sql을 보냄.
tx.commit();

위 코드는 foreign 키를 직접 다루는 것이 문제이다.

방법 1. 의 문제점.

  1. 객체를 테이블에 맞추어 데이터 중심으로 모델링하면 협력 관계를 만들 수 없다.
  2. 패러다임의 불일치 " 테이블 " 은 외래 키로 조인을 사용해서 연관된 테이블을 찾는 반면 " 객체 " 는 참조를 사용해서 연관된 객체를 찾는다.

해결) 객체 지향 모델링

객체 지향형 연관관계

방향(Direction): 단방향, 양방향
다중성(Multiplicity): 다대일(N:1), 일대다(1:N), 일대일(1:1), 다대다(N:M)
연관관계의 주인(Owner): 객체 양방향 연관관계는 관리 주인이 필요

연관관계 매핑 어노테이션

@ManyToOne
@JoinColumn

방법 2-1. 단방향 연관관계

방법 1 과의 차이점은 Member 객체에서 참조값으로 teamId가 아닌, team 그 자체를 가져왔다.

@Data
@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;
    
    @Column(name = "USERNAME")
    private String name;
    private int age;
    
    // @Column(name = "TEAM_ID")
    // private Long teamId;
    
    @ManyToOne
    @JoinColumn(name = "TEAM_ID")
    private Team team;
}

이때 어노테이션들은 db에 적용되는 값들을 표시해주기 때문에 연관관계도 @ManyToOne 이라고 다대일 관계라고 명시해줘야한다.
그리고 @JoinColumn을 이용하면 나중에 비즈니스 로직을 짤 때 알아서 해당 PK 값을 가져와서 넣어준다.

이렇게 하면 추후 비즈니스로직을 동작시킬 때도 상대적으로 편리하게 Member 가 속한 Team을 확인할 수 있고 DB에는 JPA가 알아서 PK 값만 가져와서 넣어준다.

방법 2-2. 양방향 연관관계

객체의 변화는 Team class에 " List members " 가 추가되었다는 것을 알 수 있다.
그러나 매우 중요한 것은 테이블 연관관계는 변한것이 하나도 없다는 것이다.

이는 사실 db에서는 연관관계에 " 방향 " 이라는 개념 자체가 없다. 어차피 그냥 fk를 등록해두면 양쪽으로 조회가 가능하기 때문이다.

@Data
@Entity
public class Team {
    @Id @GeneratedValue
    private Long id;
    private String name;
    @OneToMany(mappedBy = "team")
    List<Member> members = new ArrayList<>();
}

이렇게 하면 단대 방향으로도 객체 그래프 탐색이 가능해준다.

//조회
Team findTeam = em.find(Team.class, team.getId());

//역방향 조회
int memberSize = findTeam.getMembers().size();

연관관계의 주인

그러면 여기서 중요한 것이 있다.
위 " 양뱡향 연관관계 " 의 사진을 잘 보면 " 객체 연관관계 " 는 사실 "Memeber->Team"으로 또 "Team->Member" 로 이렇게 2개의 단방향 연관관계를 묶어서 DB의 양방향 연관관계를 "흉내"낸 것이다.

객체 지향이 DB의 양방향 연관관계를 흉내내며 생기는 문제점


여기서 문제는 DB에 있는 Team_ID(FK) 를
1. Member 객체의 Team 속성값이 바뀔 때 바꿔준다.
2. Team 객체의 List Members 속성값이 바뀔 떄 바꿔준다.
이렇게 둘 중 어는 객체가 바뀔 때 쿼리문을 날려줘야할지 문제가 생겨버린다. 즉, 둘 중 하나로 외래 키를 관리해야한다.

양방향 매핑 규칙

• 객체의 두 관계중 하나를 연관관계의 주인으로 지정
연관관계의 주인만이 외래 키를 관리(등록, 수정)
주인이 아닌쪽은 읽기만 가능
• 주인은 mappedBy 속성 사용X => mappedBy는 read 만 됨.
• 주인이 아니면 mappedBy 속성으로 주인 지정

그럼 위 사진의 정답은 나왔다.
DB를 기준으로 MEMBER 테이블에 fk가 존재하니까 Member 객체의 team 속성이 바로 주인이 되는 것이다.
즉, 정답은 1번 " Member 객체의 Team 속성값이 바뀔 때 바꿔준다. " 가 정답이 되는 것이다.

보편적으로 외래키가 있는 곳이 N : 1 외래기가 없는 곳 이렇게 돌아간다.
연관관계 주인: N 쪽 즉, 외래키가 있는 곳

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

연관관계의 주인에 값을 입력하지 않음.
이때 Mapped By 는 어차피 DB에 영향이 가지 않기 때문에 나중에 값을 넣어도 되지만 (그런데 넣어주는게 좋음)
연관관계의 주인 즉, @ManyToOne 어노테이션을 사용하는 속성값은 반드시 값을 넣어줘야 Member 테이블에서 FK 가 비어있는 불상사를 피할 수 있다. ( 물론 애초에 에러가 뜨겠지만 )

그리고 Mapped By도 신경써서 값을 넣어주는게 좋다. 왜냐하면 비즈니스 로직을 작성할 때 em.find 메서드로 값을 요청해도 아무것도 안 나와버리기 때문이다.
따라서 양방향 연관관계 세팅할 땐 양쪽에 값을 다 세팅해주는 것이 좋다.

이때 꿀팁은 아래 코드와 같이 연관관계 주인의 값을 세팅할 때 종속되는 값을 함께 세팅해버리는 것이다.

@Data
@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;

	...

    @ManyToOne
    @JoinColumn(name = "TEAM_ID")
    private Team team;
    
    public void setTeam(Team team){
        this.team = team;
        team.getMembers().add(this);
    }
}

이때 또 주의할 점은 그냥 setTeam 하지말고 접두어를 다른 무언가(ex|change) 이런식으로 바꾸면 일반 setter 메서드와 이름도 다르고 다른 개발자로 하여금 여기는 또 다른 메서드구나 하고 인식을 시켜줄 수 있다.

물론 이때 연관관계의 주인말고 종속되는 객체에 대하여 연관관계 메서드를 생성해도 된다.
다만, 연관관계 세팅 메서드는 양쪽에 있으면 안 되고, 어느 객체를 기준으로 잡을지는 팀 내에서 정하면 된다.

  • 정리
    순수 객체 상태를 고려해서 항상 양쪽에 값을 설정하자
    연관관계 편의 메소드를 생성하자
    양방향 매핑시에 무한 루프를 조심하자 (예: toString(), lombok, JSON 생성 라이브러리) => toString()이 양쪽 객체를 자꾸 호출하여 무한 루프에 빠진다. 답은 아래 1, 2번이다.

    1. 컨트롤러에서 엔티티를 반환하지 마라. => entity가 바뀌면 api spec이 바뀌어버림 그래서 DTO를 만들어서 반환하라.
    2. toString, 혹은 @ToString 혹은 @ToString을 상속받은 어노테이션들을 사용하지 마라.
  • 양방향 매핑 정리
    단방향 매핑만으로도 이미 연관관계 매핑은 완료 => 처음엔 단반향 매핑으로 설계를 끝낸다. => 복잡도만 늘어난다.
    다음 양방향 매핑은 반대 방향으로 조회(객체 그래프 탐색) 기능이 추가된 것 뿐이다.
    JPQL에서 역방향으로 탐색할 일이 많음
    단방향 매핑을 잘 하고 양방향은 필요할 때 추가해도 됨 (테이블에 영향을 주지 않음)

예제

테이블 구조가 위 사진과 같이 생겼다면 위 사진을 바탕으로 아래와 같은 객체 구조를 짤 수 있다.

하지만 여기서 Member에 orders 가 ListArray 형태로 들어가는 것은 좋지 못 한 설계라고 볼 수 있다.
코드의 복잡도가 굉장히 많이 올라가기 때문이다. 그래서 앞서 단방향 연관관계도 연관관계라고 하였다.
이를 판단하는 것은 비즈니스 적으로 혹은 로직상으로 반드시 양방향 연관관계가 필요할 때 생성해주면 되는 것이다.

그리고 사용법은 위와 같은 테이블 구조가 있다고 가정할 때 entity의 @ManyToOne과 @JoinColumn는 아래 와 같이 사용하면 된다.

@Entity
public class OrderItem {
	@Id @GeneratedValue
    @Column(name = "order_item_id")
    private Long id;
    
    ...
    
    @ManyToOne
    @JoinColumn(name = "order_id")
    private Order order;
    
    @ManyToOne
    @JoinColumn(name = "item_id")
    private Item item;
    
    ...
}
@Entity
@Table(name = "ORDERS")
public class Order {
	@Id @GeneratedValue
    @Column(name = "order_id")
    private Long id;
    
    ...
    
    @ManyToOne
    @JoinColumn(name = "member_id")
    private Member member;
    
    @Enumerated(EnumType.STRING)
    private OrderStatus status;
    
    ...
}

그리고 하다보니 비즈니스로직에서 뭔가 꼬여서 반드시 Member에서 Order를 조회하는 양방향이 생겼다 하면 아래와 같이 엔티티를 수정해주고

@Entity
public class Member {
	@Id @GeneratedValue
    @Column(name = "member_id")
    private Long id;
    
    ...
    
    @OneToMany(mappedBy = "member")
    private List<Order> orders = new ArrayList<>();
}

여기에 추가로 앞서 언급했듯 연관관계 편의(세팅) 메서드를 생성하여 양쪽에 값을 동시에 넣어줄 수 있도록 해준다.

profile
智(지)! 德(덕)! 體(체)!

0개의 댓글