스터디/JPA 스터디

[JPA] 연관 관계 매핑 기초

제이온 (Jayon) 2021. 12. 9.

jpa-study에서 스터디를 진행하고 있습니다.

단방향 연관 관계

순수한 객체 연관 관계

public class Member {

    private String id;
    private String username;
    private Team team;

    // Getter, Setter
}

public class Team {

    private String id;
    private String name;

    // Getter, Setter
}

 

JPA를 쓰지 않은 순수한 Java 코드에서 멤버를 팀에 소속하게 만들기 위해 다음과 같이 코드를 작성할 수 있다.

 

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

 

 

이렇게 객체는 참조를 통해서 연관 관계를 탐색할 수 있고, 이를 객체 그래프 탐색이라고 한다.

 

테이블 연관 관계

테이블은 위와 비슷하게 SQL을 통해 테이블을 만들 수 있고, 팀에 회원도 소속하게 만들 수 있다. (ex. INSERT INTO MEMBER(MEMBER_ID, TEAM_ID, USERNAME)

 

중요한 점은 테이블은 Join 쿼리를 통해 특정 회원이 소속된 팀을 조회할 수 있다. 반대로 특정 팀에는 어떠한 회원이 있는 지도 알 수 있다. 아래 코드는 전자를 나타낸다.

 

SELECT T.*
FROM MEMBER M
    JOIN TEAM T ON M.TEAM_ID = T.ID
WHERE M.MEMBER_ID = 'member1'

 

이처럼 데이터베이스는 외래 키를 사용해서 연관 관계를 탐색할 수 있는데 이것을 조인이라고 한다.

 

객체 관계 매핑

Untitled

 

지금까지 객체만 사용한 연관 관계와 테이블만 사욯안 테이블 연관 관계를 살펴 보았다. 이제 JPA를 사용해서 둘을 매핑해 보자.

 

@Entity
public class Member {

    @Id
    @Column(name= "MEMBER_ID")
    private String id;
    private String username;

    **// 연관 관계 매핑**
    @ManyToOne
    @JoinColumn(name = "TEAM_ID")
    private Team team;

    // Getter, Setter
}

@Entity
public class Team {

    @Id
    @Column(name = "TEAM_ID")
    private String id;
    private String name;

    // Getter, Setter
}

 

@ManyToOne

  • 이름 그대로 다대일 관계라는 매핑 정보이다. 회원과 팀은 다대일 관계이고, 연관 관계를 매핑할 때 이렇게 다중성을 나타내는 어노테이션을 사용해야 한다.

 

Untitled

 

@JoinColumn

  • 조인 컬럼은 외래 키를 매핑할 때 사용한다.
  • name 속성에는 매핑할 외래 키 이름을 지정한다. 회원과 팀 테이블은 TEAM_ID로 외래 키를 맺으므로 이 값을 지정하면 된다. 생략도 가능하다.

 

Untitled

 

연관 관계 사용

저장

public void testSave() {
    Team team1 = new Team("team1", "팀1");
    em.persist(team1);

    Member member1 = new Member("member1", "회원");
    member1.setTeam(team1);
    em.persist(member1);

    Member member2 = new member("member2", "회원2");
    member2.setTeam(team1);
    em.persist(member2);

 

JPA에서 엔티티를 저장할 때 연관된 모든 엔티티는 영속 상태이어야 한다.

 

조회

객체 그래프 탐색

member.getTeam() 과 같은 방식을 통해 member와 연관된 team 엔티티를 조회할 수 있다.

 

객체지향 쿼리 사용 (JPQL)

select m from Member m join [m.team](http://m.team) t where t.name=:teamName 과 같은 방식으로 쿼리를 작성할 수 있고, 자세한 내용은 추후 상세히 다루고자 한다.

 

수정

private static void updateRelation(EntityManager em) {
    Team team2 = new Team("team2", "팀2");
    em.persist(team2);

    Member member = em.find(Member.class, "member1");
    member.setTeam(team2);

 

JPA에서 지원하는 변경 감지 기능을 통해 자동으로 Team이 업데이트 된다.

 

연관 관계 제거

private static void deleteRelation(EntityManager em) {
    Member member1 = em.find(Member.class, "member1");
    member1.setTeam(null);

 

마찬가지로 변경 감지에 의해 Team 연관 관계가 끊어진다.

 

연관된 엔티티 삭제

연관된 엔티티 자체를 삭제하려면 기존에 있던 연관 관계를 먼저 제거하고 삭제해야 한다. 그렇지 않으면 외래 키 제약 조건에 의해 데이터베이스 단에서 에러가 발생한다.

 

member1.setTeam(null);
member2.setTeam(null);
em.remove(team);

 

양방향 연관 관계

양방향 연관 관계 매핑

// Member 코드는 변경할 부분이 없다.

@Entity
public class Team {

    @Id
    @Column(name = "TEAM_ID")
    private String id;
    private String name;

    **@OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();**

    // Getter, Setter
}

 

이제 Team에서도 Member 방향으로 객체 그래프 탐색이 가능하게 되었다.

 

연관 관계의 주인

@OneToMany는 직관적으로 팀 하나가 여러 명의 멤버를 소유하므로 직관적으로 이해가 가지만, mappedBy 속성이 왜 필요한지 이해가 가지 않을 것이다.

 

객체와 테이블 간의 패러다임 불일치

객체의 연관 관계

  • 회원 → 팀 연관 관계 1개 (단방향)
  • 팀 → 회원 연관 관계 1개 (단방향)

 

테이블의 연관 관계

  • 회원 ↔ 팀의 연관 관계 1개 (양방향)

 

테이블은 외래 키 하나로 두 테이블의 연관 관계를 관리하지만, 객체는 두 객체의 연관 관계를 관리하는 것이 2개이다. 따라서 엔티티를 양방향 연관 관계로 설정하면 객체의 참조는 둘인데 외래 키는 하나라는 패러다임 불일치 현상이 발생한다.

 

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

위와 같은 차이로 인해 JPA에서는 두 객체의 연관 관계 중 하나를 정해서 테이블의 외래 키를 관리해야 하는데 이것을 연관 관계의 주인이라고 한다.

 

규칙

  • 연관 관계의 주인만 외래 키를 관리 (등록, 수정, 삭제)할 수 있다.
    • 주인은 mappedBy 속성을 사용하지 않는다.
  • 주인이 아닌 쪽은 읽기만 할 수 있다.
    • 주인이 아니면 mappedBy 속성을 사용해서 속성의 값으로 연관 관계의 주인을 지정한다.

 

연관 관계의 주인은 외래 키가 있는 곳

연관 관계의 주인은 테이블의 외래키가 있는 곳으로 정해야 한다. 회원 테이블이 외래 키를 가고 있으므로 Member.team 이 주인이 된다.

 

Untitled

 

그래서 @ManyToOne은 mappedBy 속성 자체가 없다. 연관 관계 주인은 외래키 관리자라고 생각하자.

 

양방향 연관 관계 저장

public void testSave() {
    Team team1 = new Team("team1", "팀1");
    em.persist(team1);

    Member member1 = new Member("member1", "회원");
    member1.setTeam(team1);
    em.persist(member1);

    Member member2 = new member("member2", "회원2");
    member2.setTeam(team1);
    em.persist(member2);

 

team1.getMembers().add(member1) 과 같은 코드가 있어야 할 것 같지만, Team.members 는 연관 관계의 주인이 아니므로 외래 키에 영향을 주지 않는다.

 

양방향 연관 관계의 주의점

양방향 연관 관계를 설정하고 연관 관계의 주인에 값을 반드시 입력해야 한다. 주인이 아닌 곳에만 값을 입력하면 문제가 생긴다.

 

public void testSaveNonOwner() {
    Member member1 = new Member("member1", "회원1");
    em.persist(member1);

    Member member2 = new Member("member2", "회원2");
    em.persist(member2);

    Team team1 = new Team("team1", "팀1");
    team1.getMembers().add(member1);
    team1.getMembers().add(member2);

    em.persist(team1);
}

 

이를 실제 데이터베이스에서 조회한 결과는 다음과 같다. (member 테이블)

 

Untitled

 

이는 연관 관계의 주인이 아닌 대상에만 값을 저장했기 때문이다.

 

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

객체 관점에서 양쪽 방향에 모두 값을 입력해 주는 것이 가장 안전하다.

 

public void testSaveNonOwner() {
    Member member1 = new Member("member1", "회원1");
    em.persist(member1);

    Member member2 = new Member("member2", "회원2");
    em.persist(member2);

    Team team1 = new Team("team1", "팀1");

    member1.setTeam(team1);
    team1.getMembers().add(member1);

    member2.setTeam(team1);
    team1.getMembers().add(member2);

    em.persist(team1);
}

 

연관 관계 편의 메소드

하지만 매번 비슷한 작업을 매번 2번씩 수행하면 하나씩 빼 먹기 마련이다. 그래서 연관 관계 편의 메소드라는 것을 사용한다. 그래서 두 코드는 하나인 것처럼 사용하는 것이 안전하다.

 

public class Member {

    private Team team;

    public void setTeam(Team team) {
    this.team = team;
    team.getMembers().add(this);
    }
}

 

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

member1.setTeam(teamA);
member1.setTeam(teamB);
Member findMember = teamA.getMember();

 

위와 같은 코드를 작성하면 분명 teamA 의 연관 관계를 제거한 것 같은데, 여전히 연관 관계가 유지되어 teamA 를 통해 member1 을 조회할 수 있다.

 

Untitled

 

그래서 멤버의 팀을 변경할 때는 기존 팀과의 연관 관계를 제거해 주어야 한다.

 

public void setTeam(Team team) {
    if (this.team != null) {
    this.team.getMembers().remove(this);
    }
    this.team = team;
    team.getMembers().add(this);

 

그런데 위 로직은 연관 관계의 주인인 Member 입장에서 Team과 연관 관계를 맺어주었다. 반대로 Team에서 Member의 연관 관계를 맺어주고 싶을 수 있다.

 

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

 

위와 같이 연관 관계 편의 메소드를 작성하면 될 것 같지만, 이렇게 하면 setTeam()team.getMembers().add() 로직 때문에 중복된 Member가 Team.members에 저장된다. 그래서 setTeam() 을 아래와 같이 수정해야 한다.

 

public void setTeam(Team team) {
    if (this.team != null) {
    	this.team.getMembers().remove(this);
    }
    this.team = team;

    if (!team.getMembers().contains(this)) {
        team.addMember(this);
    }

 

그리고 이미 Member과 Team가 의존 관계를 맺고 있을 수 있다. 따라서 Team 쪽에도 아래 로직을 추가해 주었다.

 

public void addMember(Member member) {
    this.members.add(member);

    if (member.getTeam() != this) {
    	member.setTeam(this);
    }

 

이렇게 하면 완벽하게 양방향에서 연관 관계 편의 메소드를 안전하게 사용할 수 있게 된다.

 

기타 주의 사항

무한 루프 이슈

대표적으로 @toString() 재정의를 조심해야 한다. Member.toString() 에서 getTeam()을 호출하고, Team.toString()에서 getMember()를 호출하면 무한 루프에 빠질 수 있다.

 

일대다 쪽을 연관 관계의 주인으로 설정

성능과 관리 측면에서 권장하지 않는다. 자세한 내용은 추후 설명한다.

 

출처

김영한 - 자바 ORM 표준 JPA 프로그래밍

'스터디 > JPA 스터디' 카테고리의 다른 글

[JPA] 고급 매핑  (0) 2021.12.15
[JPA] 다양한 연관 관계 매핑  (0) 2021.12.11
[JPA] 엔티티 매핑  (0) 2021.12.04
[JPA] 영속성 관리  (0) 2021.11.28
[JPA] JPA 소개  (0) 2021.11.28

댓글

추천 글