연관관계
RDBMS는 정해진 데이터 스키마에 따라 데이터를 저장한다.
NoSql은 json 형태의 도큐먼트 형식으로 데이터를 저장한다.
🌹 RDBMS의 꽃 “연관관계”에 대해서 알아본다.
연관관계 목차
💬 용어
- PK (Primary Key) : 고유값, 기본 키, 식별 자
- FK (Foreign Key) : 외래 키, 다른 테이블 레코드의 PK값을 참조한 값
FK 값을 갖도록 설정한다. = 연관관계 매핑
연관관계 매핑은 데이터베이스의 테이블 간의 관계를 객체 모델에 매핑하는 것을 의미한다.
연관관계 매핑에는 아래 4가지 설정이 있어야 정상적인 동작이 된다.
- 연관관계 매핑 종류
- 객체 설정
- 매핑 방향(앙뱡향, 단방향)
- 비즈니스 로직
연관관계 매핑 종류
OneToOne
(1대1)OneToMany
(1대N : 일대다)ManyToOne
(N대1 : 다대일)ManyToMany
(N대N : 다대다)
🐣 : 다대다는 실무에서 사용하면 안된다.
연관관계는 어떤 도메인 시점에서 보냐에 따라 달라진다.
ex) Team과 Member 관계에서 Team의 시점에서는 하나의 Team에 여러 멤버가 올 수 있어 일대다 관계이고,
Member 시점에서는 여러 멤버들이 한 Team에 소속될 수 있기때문에 다대일 관계이다.
일대일 매핑
Member와 Profile로 연관관계 매핑을 해볼 것이다.
1
2
3
4
5
6
7
8
9
10
@Entity
@Getter @Setter
@ToString
public class Profile {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private int age;
}
1
2
3
4
5
6
7
8
9
@Entity
@Getter @Setter
@ToString
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String email;
}
Member와 Profile이 있다.
멤버 한 명 당 하나의 프로필 정보를 가진다.
Member - Profile : 멤버 도메인은 하나의 프로필을 갖는다. (@OneToOne
: 일대일)
매핑 방향
- 단방향
- 양방향
매핑방향에는 단방향 매핑과 양방향매핑이 있다.
단방향 매핑은 한쪽의 도메인이 다른 한쪽의 도메인을 참조하고 있는 것(FK를 가지고 있는 것)이고,
양방향 매핑은 두 도메인 서로 참조하고 있는 것이다.
member.getProfile()
만 가능 : 단방향
profile.getMember()
도 가능 : 양방향
양방향 매핑 규칙
객체의 두 관계중 하나를 연관관계의 주인으로 지정한다.
연관관계의 주인만이 외래 키를 관리(등록, 수정)한다.
🐣 수정, 접근을 양쪽 모두 가능하게 하고 싶다면 양방향으로 설정하면 된다.
양방향으로 설정하기 위해서는 “mappedBy” 를 작성해줘야한다.
mappedBy 설정은 주인이 누구인지 설정해주는 속성이다.
만약 mappedBy 설정을 해주지 않으면, 단방향 2개와 같다.
ex) @OneToOne(mappedBy = "profile")
🐣 양방향 매핑시 주인만 FK를 가지고 있다.
주인
연관관계를 맺으면 주인이라는 개념이 적용된다.
일반적으로 FK키를 가지고 있는 도메인을 주인으로 보고,
주인은 FK에 접근하여 읽고 쓰기가 가능하나 상대 도메인은 읽기만 가능하다.
🐣1:N 관계에서는 N이 주인이다.
MySQL 같은 RDBMS에 여러 개 데이터가 들어갈 수 없어서 N이 주인이 될 수밖에 없다.
*자바에서 객체로 불러올땐 가능하다.
“주인” 테이블에 세트로 매핑종류와 @JoinColumn을 작성해준다.
@JoinColumn의 name은 pk를 fk로 설정해서 저장해둘 이름을 작성한다.
위의 Member로 예시를 들면 관계의 주인은 Member 도메인이다.
1
2
3
@OneToOne
@JoinColumn(name = "profile_id")
private Profile profile;
1대1 매핑이므로 OneToOne을, Profile id를 Member의 fk 이름으로 profile_id로 설정했다.
단방향 연관관계
일대일 단방향 매핑
1
2
3
4
5
6
7
8
9
10
11
12
13
@Entity
@Getter @Setter
@NoArgsConstructor
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String email;
@OneToOne
@JoinColumn(name = "profile_id")
private Profile profile;
}
🐣 Profile에 있는 필드를 모두 작성하기 보다 객체 하나로 연관관계를 맺는게 가장 좋다.
@OneToOne
- 일대일 관계 매핑 어노테이션은 주인 도메인에서 사용한다.
- 참조할 도메인 컬럼에 붙여준다.
속성
fetch : 연관관계가 있는 도메인 로딩 전략 설정 옵션
- EAGER[이거 로딩] : 즉시 로딩
- LAZY[레이지 로딩] : 지연 로딩
ex)
@OneToOne(fetch = FetchType.LAZY)
Member를 불러올 때 연관관계인 Profile도 같이 즉시 조회하고 싶으면 EAGER를 사용한다.
LAZY는 필요로 할 때만 쓰인다.default값
@OneToOne
,@ManyToOne
: EAGER@OneToMany
,@ManyToMany
: LAZY
🐣 EAGER를 사용해 많은 데이터가 로딩되면 부하가 심해서 실무에서는 LAZY로 사용하는 것을 추천한다.
🐣 연관관계 맺은것이 많이 써먹는 메소드가 많다면 EAGER를 사용하고 연관관계 맺은 것이 써먹는 게 없으면 LAZY 사용
ex) User와 Registry의 1:N 관계에서 EAGER와 LAZY 쿼리문 차이
optional : null 값을 넣을 수 있게할 것인지, 아닌지를 설정하는 옵션
true : nullable
false : not null@OneToOne(optional = false)
or@JoinColumn(optional = false)
or@Column(nullable = false)
@JoinColumn
- 매핑할 외래키를 설정한다.
- 어노테이션을 붙여주지않으면 엔티티를 매핑하는 중간 테이블이 생겨 관리 포인트가 늘어나 좋지 않다.
test
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
@DataJpaTest
public class RelationshipMappingTest {
@Autowired
private MemberRepository memberRepository;
@Autowired
private ProfileRepository profileRepository;
@Test
@DisplayName("멤버 및 프로필 저장 테스트")
void memberSaveTest01() {
/*
* 멤버 저장시 프로필 정보가 필요하다.
* 따라서, 프로필을 먼저 저장한 후 그 프로필을 멤버에 넣어주어야한다.
*/
// 프로필 객체 생성
Profile profile = new Profile();
profile.setId(1L);
profile.setName("coco");
profile.setAge(10);
// 프로필 저장.
profileRepository.save(profile);
// 멤버 객체 생성
Member member = new Member();
member.setId(1L);
member.setEmail("coco@gmail.com");
// 멤버 프로필 설정
member.setProfile(profile); // ←
// 멤버 저장.
memberRepository.save(member);
assertEquals(1, memberRepository.countById(1L));
assertEquals(1, profileRepository.countById(1L));
}
}
양방향 연관관계
양방향 매핑 규칙
- 객체의 두 관계중 하나를 연관관계의 주인으로 지정한다.
- 주인은 mappedBy 속성을 사용하지 않는다.
- 주인이 아니면 mappedBy 속성으로 주인을 지정한다.
- 연관관계의 주인만이 외래 키를 관리(등록, 수정)한다.
일대일 양방향 매핑
1
2
3
4
5
6
7
8
9
10
11
12
13
@Entity
@Getter @Setter
@NoArgsConstructor
public class Profile {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private int age;
@OneToOne(mappedBy = "profile") // 👈🏻
private Member member;
}
mappedBy 설정은 주인이 누구인지 설정해주는 속성이다.
주인이 아닌 엔티티에 설정해주며 반대쪽 필드 명을 적으면 된다.
ex) Member.java의 private Profile profile;
에서 profile을 적은 것이다.
mappedBy 설정을 해주지 않으면, 단방향 2개와 같다.
🐣 양방향 매핑시 주인만 FK를 가지고 있다.
값 설정하기
profile에 member FK가 없지만, 양방향 관계를 맺었기 때문에 member 값을 가져올 수 있다.
하지만, profile에 member를 설정해 주지 않았기 때문에 member를 가져올 수 없다.
기존 Profile의 @Setter
1
2
3
public void setProfile(Profile profile) {
this.profile = profile;
}
🔽 양방향에서 Profile에 Member를 가져올수는 있는데 값을 설정해주지 않아서 가져오려면 로직 작성이 필요하다.
Profile 도메인의 setMember()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Entity
@Getter @Setter
@NoArgsConstructor
public class Profile {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private int age;
@OneToOne(mappedBy = "profile")
private Member member;
public void setMember(Member member) {
member.setProfile(this); // 👈🏻
this.member = member;
}
}
🐣 Member에 적든 Profile에 적든 상관없다.
Member 도메인에서 setProfile()
메서드 호출 시 profile에 member를 설정하던
Profile 도메인에서 setMember()
메서드 안에서 member에 profile을 설정하던 상관없다.
Member 도메인의 setProfile()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Entity
@Getter
@Setter
@NoArgsConstructor
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String email;
@OneToOne
@JoinColumn(name = "profile_id")
private Profile profile;
public void setProfile(Profile profile) {
profile.setMember(this); // 👈🏻
this.profile = profile;
}
}
양방향 매핑 시 주의사항
@ToString, @ResponseBody
@ToString
java.lang.StackOverflowError: null
해당 에러는 Lombok이 자동으로 만들어낸 ToString 메소드로 인해 발생하는 순환참조 때문이다.
이 문제를 해결하기 위해서는 해당 클래스에서 참조하고 있는 클래스를 ToString에서 제외해 주어야 한다.
Member를 문자열로 표현하기 위해 그 안에 Profile의 toString 을 실행한다.
Profile안에 Member가 있기때문에 Member의 toString 실행 → Member안에 Profile이 있기때문에 Profile의 toString 실행….
이러한 무한 루프에 빠지게 되는데 이 무한 루프를 순환참조라고 한다.
따라서, 양방향 매핑 시 @ToString에서 필드를 제외 시켜줘야한다. (→ @ToString.Exclude
)
@ResponseBody
또한, @RestController의 경우 @ResponseBody로 객체를 반환해 줄 때 JSON 형태로 변환해주는데
이때도 마찬가지로 매핑된 필드를 제외해줘야한다. (→ @JsonIgnore
)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Entity
@Getter @Setter
@ToString
@NoArgsConstructor
public class Profile {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private int age;
@OneToOne(mappedBy = "profile")
@ToString.Exclude
@JsonIgnore
private Member member;
public void setMember(Member member) {
member.setProfile(this);
this.member = member;
}
public Profile(String name, int age) {
this.name = name;
this.age = age;
}
}
DTO 활용
이렇게 순환참조 발생을 막기위해 여러 어노테이션을 붙이다보면 가독성이 떨어지고 코드가 지져분해진다.
DTO를 활용하면 필요한 필드만 유저에게 전달해줄 수 있기 때문에 순환참조를 방지할 수 있고
어노테이션을 붙여주지 않아도 되기 때문에 코드가 깔끔해진다는 장점도 있다.
DTO를 활용했을때 이러한 장점 이외에도 도메인 객체를 그대로 유저에게 전달해주지 않고
필터링된 정보만 유저에게 전달해준다는 장점도 있다.
test
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@Test
@DisplayName("멤버 및 프로필 저장 필드 설정 테스트")
void memberSaveTest02() {
/*
* 멤버 저장시 프로필 정보가 필요하다.
* 따라서, 프로필을 먼저 저장한 후 그 프로필을 멤버에 넣어주어야한다.
*/
// 프로필 객체 생성
Profile profile = new Profile();
profile.setId(1L);
profile.setName("coco");
profile.setAge(10);
// 프로필 저장.
profileRepository.save(profile);
// 멤버 객체 생성
Member member = new Member();
member.setId(1L);
member.setEmail("coco@gmail.com");
// 멤버 프로필 설정
member.setProfile(profile);
// 멤버 저장
memberRepository.save(member);
Member savedMember = memberRepository.findById(1L).get();
Profile savedProfile = savedMember.getProfile();
System.out.println(savedMember); // Member(id=1, email=coco@gmail.com, profile=Profile(id=1, name=coco, age=10), team=null)
System.out.println(savedProfile); // Profile(id=1, name=coco, age=10)
System.out.println(savedProfile.getMember()); // null
}
다대일 양방향 매핑
Member - Team : 한 팀에 여러 멤버가 속한다. (@ManyToOne
, @OneToMany
: 일대다)
팀에 속한 전체 멤버를 조회할 상황이 발생할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Entity
@Getter
@Setter
@NoArgsConstructor
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String email;
@OneToOne
@JoinColumn(name = "profile_id")
private Profile profile;
@ManyToOne
@JoinColumn(name = "team_id")
private Team team;
public void setProfile(Profile profile) {
profile.setMember(this);
this.profile = profile;
}
public void setTeam(Team team) {
team.addMember(this); // 👈🏻
this.team = team;
}
}
만약 Team의 addMember()
를 작성하지 않았다면
team.addMember(this);
대신 team.getMembers().add(this);
라고 작성하면 된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Entity
@Getter @Setter
@NoArgsConstructor
public class Team {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "team")
@JsonIgnore
private List<Member> members = new ArrayList<>(); // 👈🏻 초기화
public void addMember(Member member) {
member.setTeam(this);
this.members.add(member);
}
}
🐣 일대다 컬랙션 타입 필드는 “초기화”를 해줘야 한다.
하이버네이트는 엔티티를 영속 상태로 만들 때, 컬렉션 필드를 하이버네이트에서 준비한 컬렉션으로 감싸서 사용한다.
컬렉션을 효율적으로 관리하기 위해서이며, 이런 특징 때문에 컬렉션을 사용할 때 다음처럼 즉시 초기화해서 사용하는 것을 권장한다.
초기화를 시키지 않으면 NullPointerException이 발생한다.
일대다 혹은 다대일 매핑에서 일인 클래스에 다인 클래스를 컬렉션으로 적어준다.
members가 list이기 때문에 내장 함수인 add()
를 사용해서
this.members.add(member);
라고 붙여준 것이다.
collection은 List, Set, Map이 있다.
- Collection : 자바가 제공하는 최상위 컬렉션이다.
- List : 순서가 있고, 중복을 허용한다.
- Set : 순서가 없고, 중복은 허용하지 않는다
- Map : Key, Value로 되어있으며 키는 중복을 불허한다.
출처
연관관계 매핑 기초
[JPA] JPA가 지원하는 컬렉션
java.lang.StackOverflowError: null