[스프링부트] 연관관계 설정

김기연·2022년 3월 1일
1

스프링부트

목록 보기
1/3

연관관계

데이터베이스의 테이블은 외래키를 사용하여 join을 하면 다른 테이블을 참조할 수 있지만, 객체는 그럴 수 없다.
그래서 객체간의 연관관계를 설정하여 객체를 참조하게 할 수 있다.

스프링에서 객체가 연관관계를 맺을 때에는 방향이 존재하고, 다양한 연관관계를 맺을 수 있다.

  • 데이터베이스는 방향이 없다. 항상 양방향!

방향

단방향

객체들이 연관관계를 맺을 때, 한쪽에서만 참조가 가능한 경우

예시

Member 엔티티

@Entity
public class Memeber {
    @Id @GeneratedValue
    private Long id;
    
    private String username;
    
    @ManyToOne
    @JoniColumn(name = "team_id")
    private Team team;
}

Team 엔티티

@Entity
public class Team {
    @Id @GeneratedValue
    private Long id;
    
    private teamname;
}

Member 필드 중 private Team team은 Team 객체를 말한다.
@ManyToOne 어노테이션으로 Member와 Team을 다대일 관계로 지정해준다.
@JoinColumn 어노테이션으로 외래키를 매핑해주면 된다.

Member는 Team을 조회할 수 있지만, Team은 Member를 조회할 수 없으므로
Member와 Team은 단방향 연관관계를 갖는다.

양방향

객체들이 연관관계를 맺을 때, 양쪽에서 참조가 가능한 경우 (서로 참조 가능)
단방향이 두 개라고 생각하면 좋다!

예시

Member 엔티티

@Entity
public class Memeber {
    @Id @GeneratedValue
    private Long id;
    
    private String username;
    
    @ManyToOne
    @JoniColumn(name = "team_id")
    private Team team;
}

Team 엔티티

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

엔티티에서 List를 사용할 경우, 초기화해주는것이 관례이다.
초기화를 하지 않을 경우, add() 사용할 때 null이 발생한다.

mappedBy에는 참조 객체(매핑될 객체)를 적어준다.


연관관계의 주인

양방향 연관관계를 설정할 경우, 연관관계의 주인을 설정해주어야 한다.
양방향 연관관계는 서로를 참조하고 있기 때문에 한 객체가 변경되었을 경우 어떤 객체를 변경시켜야할지 정해야 한다.

예를 들어 Member가 속한 Team이 변경되었을 경우,

  • Member 객체에서 Team을 변경할지
  • Team 객체에서 members를 변경할지

정해주어야 한다.

변경을 담당하는 곳을 연관관계의 주인이라고 하며, 테이블의 외래키를 관리한다.

  • 연관관계의 주인
    데이터베이스 연관관계와 매핑
    외래키를 관리(등록, 수정, 삭제)

  • 주인이 아닐 경우
    읽기만 가능, mappedBy 설정

연관관계의 주인은 테이블의 외래키에 매핑한다.
보통 N(많은 쪽)이 연관관계의 주인이 된다.
따라서 Member의 Team이 연관관계의 주인이 된다.

  • Team의 members가 연관관계의 주인이 될 경우
    Team에 있는 members를 수정하면 Member 테이블에 쿼리가 발생한다.
    ➡️ 객체와 다른 테이블에서 쿼리가 발생한다!


양방향 연관관계에서의 변경

양방향 연관관계에서 변경이 일어날 경우, 연관관계 주인만 변경해주는 것이 아니라 양쪽 다 변경해주는 것이 바람직하다.

member.setTeam();
team.getMembers().add(member)
  • team.getMembers().add(member) 생략했을 때의 문제점
  1. flush(), clear() 생략했을 경우
Team team = new Team();
team.setName("teamA");
em.persist(team);

Member member = new Member();
member.setUsername("memberA");
member.setTeam(team);
em.persist(member);

/*
team.getMembers().add(member);

em.flush();
em.clear();
*/

Team findTeam = em.find(Team.class, team.getId()); //1차캐시
List<Members> members = findTeam.getMembers();

for (Member m : members) {
	System.out.println("m = " + m.getUsername());
}

flush(): 영속성 컨텍스트에 있는 데이터들을 DB에 커밋
clear(): 영속성 컨텍스트에 있는 데이터들 삭제

flush()와 clear()를 하지 않을 경우,
영속성 컨텍스트에 있는 데이터들이 DB에 커밋되지 않고 메모리에 있는 상태이다.
Team 객체를 가져올 때, 데이터베이스가 아닌 메모리에 있는 곳(1차 캐싱되어 있는 곳)에서 데이터를 가져온다.

Member 객체의 team 필드에만 설정해주고,
Team 객체에 있는 members 필드에 member를 넣어주지 않았기 때문에(영속성 컨텍스트에 있음, DB에는 반영 X)
콘솔에 출력해도 나오지 않는다.


2. 테스트 케이스 작성시
테스트 케이스는 JPA에 의존하지 않는 순수 자바 코드로 작성하는 경우가 많다.
이때 양쪽에 객체를 넣어주지 않으면 null이 발생한다.

  • 해결방법

어떻게 하면 양방향 주인관계를 잊지 않고 설정해줄 수 있을까?!
➡️ 연관관계 편의 메서드를 생성하자!

Member 객체의 Team 설정 메서드에 Team 객체의 members 설정 메서드도 추가한다.

setTeam(Team team) {
	team.getMembers().add(this); //this -> 자기 자신 인스턴스 넣어주기
}

혹은 Team 객체에 addMember() 메서드를 생성해준다.

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

편의 메서드를 양쪽에서 사용하면 문제가 발생하기 때문에 하나만 사용하면 된다.

+번외💡)
setTeam()보다 로직이 변경되는 함수라면 changeTeam()처럼 메서드 이름을 바꿔주는게 좋다!
다른 사람이 코드를 봤을 때 어떤 함수인지 알 수 있고, 중요한 로직인 것을 알 수 있다!

지연로딩

Team findTeam = em.find(Team.class, team.getId());
List<Members> members = findTeam.getMembers();

이 코드에서는 쿼리가 두 번 발생한다.

select team from Team
select members from Member

처음 Team을 select 해올 때, Team 객체에 있던 members를 가져오지 않고,
members가 필요할 때 (실제로 사용할 때) 쿼리가 발생한다.

무한루프

양방향 연관관계의 경우, 무한루프에 빠질 수 있다.
예를 들어, toString()의 경우
member도 toString() 호출, team도 toString() 호출을 하면 서로를 호출하게 된다 ➡️ 무한 호출

따라서 lombok에서는 toString()을 쓰지 않거나, 쓰더라도 서로 호출하는 부분은 삭제해야한다.

양방향 연관관계는 언제 사용할까?

조회를 편하게, JPQL을 편하게 작성하고 싶을 때 양방향 관계를 설정한다.
(Member에서도 Team을 조회, Team에서도 Member를 조회하면 어느 객체를 조회해도 모두 조회할 수 있다.)
하지만 단방향 연관관계에서 설계를 끝내는 것이 좋다!

연관관계 어노테이션

@ManyToOne

가장 많이 사용하는 N:1 연관관계이다.

단방향 다대일

@Entity
public class Memeber {
    @Id @GeneratedValue
    private Long id;
    
    private String username;
    
    @ManyToOne
    @JoniColumn(name = "team_id")
    private Team team;
}

Team 엔티티

@Entity
public class Team {
    @Id @GeneratedValue
    private Long id;
    
    private teamname;
}

Member 객체만 Team 객체를 참조하고 있고 Member와 Team의 관계는 N:1이다.

양방향 다대일

Member 엔티티

@Entity
public class Memeber {
    @Id @GeneratedValue
    private Long id;
    
    private String username;
    
    @ManyToOne
    @JoniColumn(name = "team_id")
    private Team team;
}

Team 엔티티

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

Member 객체와 Team 객체가 서로를 참조하고 있고 Member와 Team의 관계는 N:1이다.
Team에 members를 추가해도 테이블에는 전혀 영향을 주지 않는다. (양방향이 되어도 테이블에는 영향을 주지 않는다.)
Member의 team이 이미 외래키를 관리하고 있기 때문!

@OneToMany

보통 연관관계의 주인은 많은 쪽이 가진다 했지만, @OneToMany의 경우 1인 쪽이 연관관계의 주인이 된다.
하지만 테이블은 많은 쪽(N)이 외래키를 갖는다 ➡️ 객체와 테이블의 차이 발생
(@JoinColumn을 반드시 사용해야한다. 사용하지 않으면 중간에 새로운 테이블(조인 테이블)이 생성된다.)

단방향 일대다

Member 엔티티

@Entity
public class Memeber {
    @Id @GeneratedValue
    private Long id;
    
    private String username;
    
}

Team 엔티티

@Entity
public class Team {
    @Id @GeneratedValue
    private Long id;
    
    private teamname;
    
    @OneToMany
    @JoinColumn(name = "TEAM_ID")
	List<Member> members = new ArrayList<Member>();}

멤버, 팀 생성 코드

Member member = new Member();
member.setUsername("memberA);
em.persist(member); //insert member

Team team = new Team();
team.setName("teamA");
team.getMembers().add(member); //update member
em.persist(team); //insert team

팀에서 외래키를 관리할 경우, 데이터베이스에 값은 잘 들어가지만 쿼리가 세 번 발생한다.

  • insert member
  • insert team
  • update member

team이 생성되어야 TEAM_ID가 생성되기 때문에 member에 TEAM_ID 값을 update를 하는 쿼리가 부가적으로 발생한다.

일대다 단방향의 문제점
1. 엔티티가 관리하는 외래 키가 다른 테이블에 있다.
2. 연관관계 관리를 위해 update 문을 추가적으로 실행한다.
➡️ 일대다 단방향 보다는 다대일 양방향을 사용하자!


양방향 일대다

Member 엔티티

@Entity
public class Memeber {
    @Id @GeneratedValue
    private Long id;
    
    private String username;
    
    @ManyToOne
    @JoinColumn(name="TEAM_ID", insertable = false, updatable = false)
    private Team team;
    
}

insertableupdatable을 false처리로 해서 읽기 전용 필드로 사용할 수 있다.

Team 엔티티

@Entity
public class Team {
    @Id @GeneratedValue
    private Long id;
    
    private teamname;
    
    @OneToMany
    @JoinColumn(name = "TEAM_ID")
	List<Member> members = new ArrayList<Member>();}

다대일 양방향을 사용하자!

@OneToOne

1:1 연관관계로, 주 테이블과 대상 테이블 중 외래 키를 선택할 수 있다.
외래키에 데이터베이스 Unique 제약 조건이 필요하다.

주 테이블 외래 키 단방향

Member 엔티티

@Entity
public class Memeber {
    @Id @GeneratedValue
    private Long id;
    
    private String username;
    
    @OneToOne
    @JoinColumn(name="LOCKER_ID")
    private Locker locker;
    
}

Locker 엔티티

@Entity
public class Locker {
    @Id @GeneratedValue
    private Long id;
    
    private lockername;

}

주 테이블 외래 키 양방향

Member 엔티티

@Entity
public class Memeber {
    @Id @GeneratedValue
    private Long id;
    
    private String username;
    
    @OneToOne
    @JoinColumn(name="LOCKER_ID")
    private Locker locker;
    
}

Locker 엔티티

@Entity
public class Locker {
    @Id @GeneratedValue
    private Long id;
    
    private lockername;
    
    @OneToOne(mappedBy="locker")
    private Member member

}

대상 테이블 외래 키 단방향


Member 엔티티 안에 Locker가 연관관계 주인이면서 Locker 엔티티 안에 외래키가 있을 수 없다.
JPA에서 지원해주지 않는다.

대상 테이블 외래 키 양방향

주 테이블 외래 키 양방향과 매핑 방법은 같다.
엔티티에 있는 외래 키를 직접 관리해야한다.

주 테이블 외래 키 단방향 vs 대상 테이블 외래 키 단방향

(Member가 외래키 갖기 vs Locker가 외래키 갖기)

Member가 locker를 가지고 있는 것이 유리하다. (주 테이블 외래 키 단방향)
주 테이블을 로딩하는 시점에 외래키 컬럼을 보고 null인지 확인할 수 있다.
외래키를 가지고 로직을 처리할 때 (ex. locker가 없다면 locker 할당)
주 테이블이 외래키를 가지고 있으면 대상 테이블과의 join을 하지 않아도 된다.

만약에 로직이 변경된다면?
하나의 회원이 여러 Locker를 가질 수 있는 로직 ➡️ 대상 테이블 외래 키 단방향
하나의 Locker를 여러명의 회원이 가질 수 있는 로직 ➡️ 주 테이블 외래 키 단방향

주 테이블 외래 키
객체지향 개발자 선호
JPA 매핑 편리
장점: 주 테이블만 조회해도 대상 테이블에 데이터가 있는지 확인 가능
단점: 값이 없으면 외래 키에 null 허용

대상 테이블 외래 키
전통적인 데이터베이스 개발자 선호
장점: 주 테이블과 대상 테이블을 일대일에서 일대다 관계로 변경할 때 테이블 구조 유지
단점: 프록시 기능의 한계로 지연 로딩으로 설정해도 항상 즉시 로딩

@ManyToMany

사용을 지양하자
관계형 데이터베이스는 정규화된 테이블 2개로 다대다 관계를 표현할 수 없다.
➡️ 연결 테이블을 추가해서 일대다, 다대일 관계로 풀어야 한다.

객체는 컬렉션을 사용하여 객체 2개로 다대다 관계가 가능하다.
Member도 ProductList를 가질 수 있고, Product도 MemberList도 가질 수 있다.
@JoinTable로 연결 테이블을 지정한다.

단방향 다대일

Member 엔티티

@Entity
public class Memeber {
    @Id @GeneratedValue
    private Long id;
    
    private String username;
    
    @ManyToMany
    @JoinTable(name = "MEMBER_PRODUCT")
    private List<Product> products = new ArrayList<>();
    
}

Product 엔티티

@Entity
public class Product {
    @Id @GeneratedValue
    private Long id;
    
    private name;

}

양방향 다대일

Member 엔티티

@Entity
public class Memeber {
    @Id @GeneratedValue
    private Long id;
    
    private String username;
    
    @ManyToMany
    @JoinTable(name = "MEMBER_PRODUCT")
    private List<Product> products = new ArrayList<>();
    
}

Product 엔티티

@Entity
public class Product {
    @Id @GeneratedValue
    private Long id;
    
    private name;
    
    @ManyToMany(mappedBy = "products")
    private List<Member> members = new ArrayList<>();

}

해결방법 1

중간 테이블 생성한다.

Member 엔티티

@Entity
public class Memeber {
    @Id @GeneratedValue
    private Long id;
    
    private String username;
    
    @OneToMany(mappedBy = "member")
    private List<MemberProduct> memberProducts = new ArrayList<>();
    
}

Product 엔티티

@Entity
public class Product {
    @Id @GeneratedValue
    private Long id;
    
    private name;
    
    @OneToMany(mappedBy = "product")
    private List<MemberProduct> memberProducts = new ArrayList<>();

}

MemberProduct 엔티티

@Entity
public class MemberProduct {
	@Id @GeneratedValue
    private Long id;
    
    @ManyToOne
    @JoinColumn(name = "MEMBER_ID")
    private Member member; 
   
    @ManyToOne
    @JoinColumn(name = "PRODUCTED_ID")
    private Product product;
}

해결방법 2


이 해결 방법은 전통적인 DB 설계에 가까운 방식이고 복합키를 따로 설정해주어야 한다.
이 방법보다는 위의 방식이 운영할 때 더 유연하게 사용할 수 있다고 한다.
PK 값은 의미 없는게 좋기도 하고, 모든 테이블에 일괄로 @GeneratedValue 적용할 수 있다.
ID가 종속적이면 시스템 변경이 쉽지 않다고 한다!



참고

인프런 김영한 강사님 자바 ORM 표준 JPA 프로그래밍편 - 기본편

0개의 댓글