Home 연관관계
Post
Cancel

연관관계

연관관계

RDBMS는 정해진 데이터 스키마에 따라 데이터를 저장한다.

NoSql은 json 형태의 도큐먼트 형식으로 데이터를 저장한다.


🌹 RDBMS의 꽃 “연관관계”에 대해서 알아본다.



연관관계 목차


💬 용어

  • PK (Primary Key) : 고유값, 기본 키, 식별 자
  • FK (Foreign Key) : 외래 키, 다른 테이블 레코드의 PK값을 참조한 값


FK 값을 갖도록 설정한다. = 연관관계 매핑



연관관계 매핑은 데이터베이스의 테이블 간의 관계를 객체 모델에 매핑하는 것을 의미한다.

연관관계 매핑에는 아래 4가지 설정이 있어야 정상적인 동작이 된다.


  1. 연관관계 매핑 종류
  2. 객체 설정
  3. 매핑 방향(앙뱡향, 단방향)
  4. 비즈니스 로직




연관관계 매핑 종류

  • OneToOne (1대1)
  • OneToMany (1대N : 일대다)
  • ManyToOne (N대1 : 다대일)
  • ManyToMany (N대N : 다대다)

🐣 : 다대다는 실무에서 사용하면 안된다.



연관관계는 어떤 도메인 시점에서 보냐에 따라 달라진다.


ex) Team과 Member 관계에서 Team의 시점에서는 하나의 Team에 여러 멤버가 올 수 있어 일대다 관계이고,

Member 시점에서는 여러 멤버들이 한 Team에 소속될 수 있기때문에 다대일 관계이다.



일대일 매핑

MemberProfile로 연관관계 매핑을 해볼 것이다.

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;
}


image

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 관계에서 EAGERLAZY 쿼리문 차이

image image






















  • 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 : 일대다)

image

팀에 속한 전체 멤버를 조회할 상황이 발생할 수 있다.

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

This post is licensed under CC BY 4.0 by the author.