연관관계 매핑 기초

Posted by yunki kim on July 18, 2022

  테이블들은 FK를 통해 관계를 맺고 객체는 참조를 통해 관계를 맺는다. 이 둘은 완전히 다른 특징을 가진다. 그때문에 ORM에서 가장 어려운 부분이 객체 연관 관계와 테이블 연관관계를 매핑하는 일이다.

용어 정리

  객체 참조와 테이블 FK를 매핑하기 전에 용어 정리부터하자.

방향(direction): 양방향, 단방향이 존재한다.

  단방향: 하나의 관계에서 한 쪽만 참조한다. x -> y 이거나 y -> x 이다.

  양방향: 하나의 관계에서 양쪽이 모두 참조하고 있다. x -> y 임과 동시에 y -> x이다.

  방향은 객체관계에만 존재하고 테이블 관계는 항상 양방향이다.

다중성(Multiplicity): N:1, 1:N, 1:1, N;M

연관관계의 주인(owner): 객체를 양방향 연관관계로 만들면 연관관계의 주인을 정해야 한다.

단방향 연관관계

  가장 먼저 N:1 관계부터 이해해 보자. 예시로 팀과 회원의 관계를 살펴보면 객체와 테이블은 각각 다음과 같다.

Member 객체와 Team 객체 관계 UML
member table과 team table 관계 ERD

  위 용어 정리에서 언급했듯 객체는 단방향 관계이고 테이블은 양방향 관계이다. 따라서 테이블은 FK 하나를 통해 다음과 같이 회원과 팀, 팀과 회원을 모두 join할 수 있다.

1
2
3
4
5
// 회원과 팀
SELECT * FROM member m JOIN team t ON m.team_id t.id;
 
// 팀과 회원
SELECT * FROM team t JOIN member m ON t.team_id = m.team_id;
cs

  객체의 참조를 통한 연관관계는 단방향이다. 따라서 테이블 처럼 양방향 연관관계를 만들고 싶다면, 서로 다른 단향향 관계 2개를 만들면 된다. 하지만 이 방식은 단방향 2개를 사용해 양방향을 비슷하게 구현한 것일 뿐, 양방향 연관관계는 아니다.

1
2
3
4
5
6
7
class A {
    B b;
}
 
class B {
    A a;
}
cs

  객체 연관관계와 테이블 연관관계의 차이를 이해했으니 이제 이 둘은 매핑해보자.

순수한 객체 연관관계

  위에서 예시로 사용한 멤버와 팀의 관계를 코드로 나타내면 다음과 같다.

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
public class Member {
 
    private String id;
    private String username;
 
    private Team team;
 
    public void setTeam(Team team) {
        this.team = team;
    }
 
    // getter, setter
}
 
public class Team {
 
    private String id;
    private String name;
 
    // getter, setter
}
 
public static void main(String[] args) {
    Member member1 = new Member("member1""회원1");
    Member member2 = new Member("member2""회원2");
    Team team1 = new Team("team1""팀1");
    member1.setTeam(team1);
    member2.setTeam(team1);
 
    Team findTeam = member1.getTeam();
}
 
cs

  위 코드 중 30번째 줄은 참조를 하용해 연관관계를 탐색하고 있다. 이를 객체 그래프 탐색이라 한다.

테이블 연관관계

위의 멤버와 팀 예시를 SQL로 작성하면 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
CREATE TABLE member (
    member_id VARCHAR(255NOT NULL,
    team_id VARCHAR(255),
    username VARCHAR(255),
    PRIMARY KEY (member_id)
);
 
CREATE TABLE team (
    team_id VARCHAR(255NOT NULL,
    name VARCHAR(255),
    PRIMARY KEY (team_id)
);
 
ALTER TABLE member ADD CONSTRAINT fk_member_team
    FOREIGN KEY (team_id)
    REFERENCES team;
 
INSERT INTO team(team_id, name) VALUES('team1''팀1');
INSERT INTO member(member_id, team_id, username) VALUES('member1''team1''회원1');
INSERT INTO member(member_id, team_id, username) VALUES('member2''team1''회원2');
 
SELECT t.*  FROM member m JOIN team t ON m.team_id = t.id WHERE m.member_id = 'member1';
 
cs

  위 코드 중 22번째 줄은 FK를 사용해 연관관계를 탐색하고 있고 이를 join 이라 한다.

객체 관계 매핑

  이제 JPA를 사용해 객체와 테이블을 연관관계를 매핑해 보자

  이 매핑을 코드로 풀어내면 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Entity
public class Member {
 
    @Id
    @Column(name = "member_id")
    private String id;
 
    private String username;
 
    // 연관관계 매핑
    @ManyToOne
    @JoinColumn(name="team_id")
    private Team team;
 
    // 연관관계 설정
    public void setTeam(Team team) {
        this.team = team;
    }
 
    // getter, setter
}
cs
1
2
3
4
5
6
7
8
9
10
11
@Entity
public class Team {
 
    @Id
    @Column(name = "team_id")
    private String id;
 
    private String name;
 
    // getter, setter;
}
cs

  위에서 사용한 어노테이션 중 @JoinColumn의 주요 속성은 다음과 같다.

속성 기능 기본값
name 매핑할 외래 키 이름 "필드명_참조하는테이블의 PK명"
referencedColumnName FK가 참조하는 대상 테이블의 컬럼명 참조하는 테이블의 기본키 컬럼명
foreignKey(DDL) FK 제약조건을 직접 지정할 수 있다. 테이블을 생성할 때만 사용한다.  
unique, nullable, insertable, updatable, columnDefinition, table @Column의 속성과 같다

  @Column의 속성은 다음과 같다.

속성 기능 기본값
name 필드와 매핑할 테이블의 컬럼 이름 객체의 필드 이름
insertable false로 지정 시 SQL insert 문에 해당 컬럼을 포함시키지 않는다 true
updatable false로 지정하면 SQL update 문에 해당 컬럼을 포함시키지 않는다 true
columnDefinition 해당 컬럼에 대한 정의를 직접 내릴 수 있다 필드의 자바 타입과 방언 저올르 사용해 적절한 컬럼 타입을 생성한다.
table 하나의 두 개 이상의 테이블에 매핑할 때 사용한다. 지정한 필드를 다른 테이블에 매핑할 수 있다. 현재 클래스가 매핑된 테이블
nullable null 허용 여부 true
length 문자 길이 제약 조건, String 타입에만 사용된다. 255
precision, scale BigDecimal 타입에서 사용한다. precision은 소수점을 포함한 전체 자릿수, scale은 소수의 자릿수다. double, float에는 적용되지 않는다. precision = 19, scale = 2

  @ManyToOne의 주요 속성은 다음과 같다.

속성 기능 기본값
optional false로 설정하면 연관된 엔티티가 항상 있어야 한다. true
fetch 글로벌 페치 전략을 설정한다. @ManyToOne = FetchType.EAGER
@OneToMany = FetchType.LAZY
cascade 영속성 전이 기능을 사용한다.  
targetEntity 연관된 엔티티의 타입 정보를 설정한다. 이 속성 대신 컬렉션을 사용해도 제네릭으로 타입 정보를 알 수 있다.  

연관관계 사용

  연관관계를 CRUD하는 방식은 다음과 같다.

저장

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public void testSave() {
 
    // team1 저장
    Team team1 = new Team("team1""팀1");
    em.persist(team1);
 
    // member1 저장
    Member member1 = new Member("memeber1""회원1");
    member1.setTeam(team1);
    em.persist(member1);
 
    // member2 저장
    Member memeber2 = new Member("member2""회원2");
    member2.setTeam(team1);
    em.persist(member2);
}
cs

  이때 실행되는 SQL은 다음과 같다.

1
2
3
4
INSERT INTO team(team_id, name) VALUES('team1''팀1');
INSERT INTO member(member_id, name, team_id) VALUES('member1''회원1''team1');
INSERT INTO member(member_id, name, team_id ) VALUES('member2''회원2''team1');
 
cs

조회

1
2
Member member = em.find(Member.class"member1");
Team team = member.getTeam(); // 객체 그래프 탐색
cs

 이때 실행되는 SQL은 다음과 같다.

1
2
SELECT M.* FROM member member INNER JOIN team team ON member.team_id = team1_.id WHERE team1_.name='팀1';
 
cs

수정

  다음과 같이 연관관계를 수정할 수 있다.

1
2
3
4
5
6
7
8
private static void updateRelatino(EntityManager em) {
    Team team2 = new Team("team2""팀2");
    em.persist(team2);
 
    // 회원1에 새로운 팀2 설정
    Member member = em.find(Member.class"member1");
    member.setTeam(team2);
}
cs

  위와 같이 단순히 불러온 엔티티의 값만 변경하면 트랜잭션을 커밋할 때 플러시가 일어나 변경이 감지되고 변경사항이 DB에 반영된다.

  실행되는 SQL은 다음과 같다.

1
2
UPDATE member SET team_id='team2', ... WHERE id='member1';
 
cs

연관관계 제거

  연관관계 제거는 다음과 같이 할 수 있다.

1
2
3
4
private static void deleteRelation(EntityManager em) {
    Member member1 = em.find(Member.class"memeber1");
    member1.setTeam(null); // 연간관계 제거
}
cs

  실행되는 SQL은 다음과 같다.

1
2
UPDATE member SET team_id=null, ..., WHERE id='member1';
 
cs

  연관된 엔티티를 삭제하기 위해선 기존 연관관계를 먼저 제거하고 삭제해야 FK 제약 조건에 걸리지 않는다.

1
2
3
member1.setTeam(null); // 회원1 연관관계 제거
member2.setTeam(null); // 회원2 연관관계 제거
em.remove(team);
cs

양방향 연관관계

  이번엔 팀에서 회원으로, 회원에서 팀으로 모두 접근 가능하게 양방향 연관관계로 매핑해보자. 그러면 기존 객체간의 관계는 다음과 같이 바뀐다.

  그에 반해 테이블은 FK하나로 양방향 조회가 가능하므로 바뀐 것이 없다.

  이 관계를 코드로 나타내면 각 엔티티는 다음과 같다.

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
@Entity
public class Member {
 
    @Id
    @Column(name = "member_id")
    private String id;
 
    private String username;
 
    // 연관관계 매핑
    @ManyToOne
    @JoinColumn(name="team_id")
    private Team team;
 
    // 연관관계 설정
    public void setTeam(Team team) {
        this.team = team;
    }
 
    // getter, setter
}
 
@Entity
public class Team {
 
    @Id
    @Column(name = "team_id")
    private String id;
 
    private String name;

// 1(Team) : N(Member) 이므로 컬랙션 사용
    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<Member>();
 
    // getter, setter;
}
 
cs

  위 코드에서 mappedBy 속성은 양방향 매핑일 떄 반대쪽 매핑의 필드 이름을 값으로 주면 된다. 이제 팀에서 회원 컬랙션으로 객체 그래프를 탐색할 수 있다.

일대다 컬렉션 조회

1
2
3
4
5
6
7
8
9
public void biDirection() {
 
    Team team = em.find(Team.class"team1");
    List<Member> members = team.getMembers(); // 객체 그래프 탐색
 
    for (Member member : members) {
        System.out.println(member.getUsername());
    }
}
cs

연관관계 주인

  객체에는 양방향 관계가 존재하지 않는다. 따라서 양방향을 비슷하게 구현하기 위해 서로 다른 단방향 2개를 묶어서 양방향인것 처럼 보이게 한다. 즉, 테이블은 FK 하나로 두 테이블의 연관관계를 관리하는 반면, 엔티티의 양방향 매핑은 entity1 -> entity2, entity2 -> entity1 두 곳에서 서로를 참조한다.따라서 객체 연관관계의 관리 포인트가 2곳이 된다. 결국 객체 참조는 둘이고 FK는 하나인 문제가 발생해 둘간의 차이가 생겨난다. 이런 차이를 극복하기 위해 두 객체 연관관계 중 하나를 정해 테이블의 FK를 관리해야 한다. 이를 연관관계의 주인(Owner)이라 한다.

양방향 매핑의 규칙: 연관관계의 주인

  양방향 연관관계 매핑 시 두 연관관계 중 하나를 연관관계의 주인으로 정해야 한다. 연관관계의 주인은 DB 연관관계와 매핑되고 FK를 관리할 수 있다. 반대 쪽은 읽기만 할 수 있다. 연관관계 주인은 mappedBy 속성으로 정하면 된다.

  mappedBy 속성은 다음과 같은 규칙을 지키며 사용해야 한다.

    1. 주인은 mappedBy 속성을 사용하지 않는다.

    2. 주인이 아니면 mappedBy 속성을 사용해서 속성의 값으로 연관관계의 주인을 지정해야 한다.

  그럼 두개의 관계 중 어느 관계를 주인으로 선택해야 할까? 답은 FK가 있는 곳을 주인으로 선택하면 된다. 그렇지 않은 경우를 생각해 보자. 위에서 든 멤버, 팀 예시에서 FK는 Member에 존재하고 있다. 이 때, Team.members를 연관관계 주인으로 선택한다면 물리적으로 다른 테이블의 FK를 관리해야 한다. 따라서 Team.members에 mappedBy 속성을 사용하면 된다. 속성 값으로는 연관관계 주인을 주면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Entity
public class Member {
 
    ...
 
    // 연관관계 매핑
    @ManyToOne
    @JoinColumn(name="team_id")
    private Team team;
 
    ...
}
 
@Entity
public class Team {
 
    ...
 
    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<Member>();
 
    ...
}
 
cs

  DB table의 N:1, 1:N 관계에서는 항상 N 쪽이 FK를 가진다.

양방향 연관관계 저장

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public void testSave() {
 
    // team1 저장
    Team team1 = new Team("team1""팀1");
    em.persist(team1);
 
    // member1 저장
    Member member1 = new Member("memeber1""회원1");
    member1.setTeam(team1);
    em.persist(member1);
 
    // member2 저장
    Member memeber2 = new Member("member2""회원2");
    member2.setTeam(team1);
    em.persist(member2);
}
cs

  이 코드는 위에서 설명한 단방향 연관관계에서의 회원과 팀을 저장하는 코드와 완전히 같다.

  양방향 연관관계는 연관관계 주인이 FK를 관리한다. 따라서 주인이 아닌 방향은 값을 설정하지 않아도 DB의 FK값이 정상 입력된다. 만약 주인이 아닌 곳에 값이 입력된다면, 이 값은 무시된다. 이 부분이 양방향 연관관계에서 주의해야 하는 점이다.

순수 객체까지 고려한 양방향 연관관계

  위에서 볼 수 있듯이 JPA에서는 연관관계의 주인에만 값을 저장하면 된다. 하지만, 객체 관점에서 양방향에 모두 값을 입력하는 것이 안전하다. 그렇지 않으면 JPA에 의존적인 코드가 된다. ORM은 객체와 RDB 둘 다 중요하다. 따라서 DB와 객체 모두를 고려해야 한다.

  객체와 DB 모두를 고려한 코드 예시는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public void testORM() {
 
    // 팀1 저장
    Team team1 = new Team("team1""팀1");
    em.persist(team1);
 
    Member member1 = new Member("member1""회원1");
 
    // 양방향 연관관계 설정
    member1.setTeam(team1); // 연관관계 설정 member1 -> team1
    team1.getMembers().add(member1); // 연관관계 설정 team1 -> member1
    em.persist(member1);
 
    Member member2 = new Member("member2""회원2");
 
    // 양방향 연관관계 설정
    member2.setTeam(team1); // 연관관계 설정 member2 -> team1
    team1.getMembers().add(member2); // 연관관계 설정 team1 -> member2
    em.persist(member1);
}
cs

연관관계 편의 메서드

  위와 같이 양쪽을 모두 신경쓰다 보면 실수로 둘 중 하나만 호출하게 된다. 따라서 양방향 연관관계 설정 코드를 다음과 같이 하나인 것처럼 사용는 편이 안전하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Entity
public class Member {
 
    ...
 
    // 기존 코드
    public void setTeam(Team team) {
        this.team = team;
    }
 
    // getter, setter
}
 
@Entity
public class Member {
    ...
 
    // 개선한 연관관계 편의 메서드
    public void setTeam(Team team) {
        this.team = team;
        team.getMembers().add(this);
    }
    // getter, setter
}
cs

  위와 같이 연관관계 편의 메서드를 사용하면 객체와 DB를 모두 고려한 코드를 단순화 할 수 있다. 멤머, 팀 예시 코드는 다음과 같이 리펙터링 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public void testORM() {
 
    // 팀1 저장
    Team team1 = new Team("team1""팀1");
    em.persist(team1);
 
    Member member1 = new Member("member1""회원1");
    member1.setTeam(team1); // 연관관계 설정 member1 -> team1
    em.persist(member1);
 
    Member member2 = new Member("member2""회원2");
    member2.setTeam(team1); // 연관관계 설정 member2 -> team1
    em.persist(member1);
}
cs

연관관계 편의 메서드 작성 시 주의사항

  사실 위에서 설명한 setTeam()에는 버그가 존재한다. 다음과 같은 코드를 보자.

1
2
3
member1.setTeam(TeamA);
member1.setTeam(teamB);
Member foundMember = teamA.getMember(); // member1이 여전히 조회된다.
cs

  위 코드에서 member1이 여전히 조회되는 이유는 Member의 setTeam()에서 Team.member에 add()만 하고 있기 때문이다. 따라서 다음과 같이 기존 관계를 제거하는 코드를 넣어줘야 한다.

1
2
3
4
5
6
7
public void setTeam(Team team) {
    if (this.team != null) {
        this.team.getMembers().remove(this);
    }
    this.team = team;
    team.getMembers().add(this);
}
cs

 

출처 - 자바 ORM 표준 JPA 프로그래밍