본문 바로가기
JPA

[JPA] 연관관계 매핑

by jinjin98 2022. 10. 24.

JPA 는 엔티티와 테이블을 매핑시켜주는 ORM 기술입니다. 데이터베이스에서 테이블을 외래키로 관계를

연결해주듯이 엔티티도 다른 엔티티와 관계를 맺어줄 수 있습니다. 이 때 엔티티 객체는 참조(주소)를 사용해서

관계를 맺습니다. 이렇게 서로 관계를 맺는 방법이 다르기 때문에 객체 관계 매핑(ORM) 에서 가장 어려운 부분이

객체 연관관계와 테이블 연관관계를 매핑하는 일입니다. 이번 포스팅에서는 객체의 참조와 테이블의 외래 키를

매핑하는 방법에 대해서 알아보겠습니다.

 

먼저 키워드에 대해서 알아보겠습니다.

 

̱ 방향

 

객체관계에서만 존재하며 테이블 관계는 항상 양방향입니다.

단방향: 두 개의 엔티티 관계에서 둘 중 한 쪽만 참조하는 것을 말합니다.

ex) 회원 -> 주문 또는 주문 -> 회원

양방향: 두 개의 엔티티 관계에서 서로 참조하는 것을 말합니다.

ex) 회원 -> 주문, 주문 -> 회원

 

̱ 다중성

 

다대일(N:1), 일대다 (1:N), 일대일(1:1), 다대다(N:N) 다중성이 있습니다.

ex) 한 명의 회원이 여러 개의 주문을 할 수 있다. 회원과 주문은 일대다 (1:N) 관계

 

̱ 연관관계의 주인

 

객체를 양방향 연관관계로 만들면 연관관계의 주인을 정해야 합니다.

 

조금 더 자세히 알아보겠습니다

 

̱ 단방향 연관관계

 

연관관계 중에서 다대일(N:1) 단방향 관계를 가장 먼저 이해해야 하는데요.

 

 

여기 다대일 관계를 가진 회원과 팀이 있고 회원의 하나의 팀에만 소속될 수 있습니다.

 

객체 연관관계

회원 객체는 Member.team 필드(멤버변수로)로 팀 객체와 연관관계를 맺습니다. 현재 회원 객체와 팀 객체는

단방향 관계이므로 회원은 Member.team 필를 통해 팀의 정보를 알 수 있지만 팀은 회원을 알 수 없습니다.

예를 들어 member -> team 의 조회는 member.getTeam() 으로는 가능하지만  team -> member 를 접근하는

필드는 없습니다.

 

테이블 연관관계

회원 테이블은 TEAM_ID 외래키로 팀 테이블과 연관관계를 맺습니다. 회원 테이블과 팀 테이블은 양방향 관계

이기 때문에 회원 테이블의 TEAM_ID 외래키를 통해 회원과 팀을 조인을 할 수 있고 반대로 팀과 회원도 조인을

할 수 있습니다. 예를 들어 MEMBER 테이블의 TEAM_ID 외래키로 MEMBER JOIN TEAM 과 

TEAM JOIN MEMBER 둘 다 가능합니다.

회원과 팀을 조인하는 SQL 문

SELECT * FROM MEMBER M JOIN TEAM t ON M.TEAM_ID = T.TEAM_ID;

SELECT * FROM TEAM t JOIN MEMBER M ON T.TEAM_ID = M.TEAM_ID ;

 

객체 연관관계와 테이블 연관관계의 가장 큰 차이점!

참조를 통한 연관관계는 언제나 단방향입니다. 객체간에 연관관계를 테이블 연관관계처럼 양방향으로 만들고

싶다면 반대쪽 엔티티에도 필드를 추가해서 참조를 보관해야 합니다. 결국에는 연관관계를 하나 더 만들어야

합니다. 이렇게 양쪽에서 서로 참조하는 것을 양방향 연관관계라고 합니다. 사실 더 명확하게 말하자면

서로 다른 단방향 관계 2개를 만드는 것입니다.

 

순수한 객체 연관관계

 

@Getter @Setter
public class Member{

    private Long id;

    private String username;

    private Team team;
}

 

@Getter @Setter
public class Team {

    private Long id;

    private String name;
}

 

JPA 를 사용하지 않고 순수한 회원과 팀 클래스 코드입니다.

 

Member member1 = new Member("member1", "홍길동");
Member member2 = new Member("member1", "임꺽정");
Team team1 = new Team("team1", "1팀");

member1.setTeam(team1);
member2.setTeam(team1);

Team findTeam = member1.getTeam();

 

회원 객체를 2개 만들고 팀 객체를 1개 만들어 회원을 팀에 소속시켜주었습니다. 그리고 회원 객체아 속한 팀을

조회했는데요. 이렇게 객체는 참조 변수를 통해서 연관관계를 탐색할 수 있는데 이것을 객체 그래프 탐색이라고 합니다.

 

테이블 연관관계

 

CREATE TABLE MEMBER (
MEMBER_ID VARCHAR(255) NOT NULL,
TEAM_ID VARCHAR(255),
USERNAME VARCHAR(255) ,
PRIMARY KEY (MEMBER_ID),
FOREIGN KEY (TEAM_ID) REFERENCES TEAM(TEAM_ID)
);
CREATE TABLE TEAM (
TEAM_ID VARCHAR(255) NOT NULL,
NAME VARCHAR(255),
PRIMARY KEY (TEAM)
);

INSERT INTO TEAM(TEAM_ID, NAME) VALUES("team1", "1팀");
INSERT INTO MEMBER(MEMBER_ID, TEAM_ID, USERNAME) VALUES("member1", "team1", "홍길동");
INSERT INTO MEMBER(MEMBER_ID, TEAM_ID, USERNAME) VALUES("member2", "team1", "임꺽정");

SELECT * FROM MEMBER M JOIN TEAM T ON M.TEAM_ID = T.TEAM_ID WHERE M.MEMBER_ID = "member1";

 

이번엔 데이터베이스에서 회원과 팀 테이블을 생성하고 회원 테이블의 TEAM_ID 에 외래키 제약조건을 설정

했습니다. 객체 연관관계처럼 데이터를 넣고 회원1이 소속된 팀을 조회해봤습니다. 가장 마지막줄 쿼리처럼

외래 키를 사용해서 연관관계를 탐색할 수 있는 것을 조인이라고 합니다.

 

이제 JPA 를 사용해서 엔티티와 테이블을 매핑해보겠습니다.

 

@Entity
@Getter @Setter
public class Member extends BaseEntity {

    @Id
    @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

    private String username;

    @ManyToOne
    @JoinColumn(name = "team_id")
    private Team team;
}

 

@Entity
@Getter @Setter
public class Team {

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

    private String name;
}

 

객체 연관관계에서는 회원 객체의 Member.team 필드를 사용하고 테이블 연관관계에서는 회원 테이블의 

MEMBER.TEAM_ID 외래키 컬럼을 사용합니다. Member.team 와 MEMBER.TEAM_ID 를 매핑하는 것이 

연관관계 매핑입니다. Member 엔티티 클래스에서 team 필드 위에 붙은 어노테이션이 연관관계 매핑 부분인데요.

@ManyToOne 어노테이션은 이름 그대로 다대일(N:1) 이라는 매핑 정보를 나타냅니다. 연관관계를 매핑할 때는 

이렇게 다중성을 나타내는 어노테이션을 필수로 사용해야 합니다. @JoinColumn 은 외래키를 매핑할 때

사용합니다. name 속성에는 매핑할 외래키 이름을 지정합니다. 이 어노테이션은  생략이 가능합니다.

 

@JoinColumn 주요 속성

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

 

@ManyToOne 주요 속성

속성 기능 기본값
optional false 로 설정하면 연관된 엔티티가 항상
있어야합니다.
true
fetch 글로벌 페치 전략을설정합니다. @XToOne= FetchType.Eager
@XToMany=FetchType.Lazy
cascade 영속성 전이 기능을 사용합니다.  
targetEntity 연관된 엔티티의 타입 정보를 설정합니다. 이 기능은 거의 사용하지 않습니다.
컬렉션을 사용해도 제네릭으로 타입 정보를 알 수 있습니다.
 

 

연관관계를 사용해 등록, 수정, 삭제, 조회하는 예제를 간단히 살펴보겠습니다.

 

등록

 

EntityManager em;

Team team1 = new Team("team1", "1팀");
em.persist(team1);

Member member1 = new Member("member1", "홍길동");
member1.setTeam(team1);
em.persist(member1);

Member member2 = new Member("member1", "임꺽정");
member2.setTeam(team1);
em.persist(member2;

 

member1.setTeam(); em.persist(member1); 으로 회원 엔티티가 팀 엔티티를 참조하고 저장했습니다.

JPA 에서는 참조한 팀의 식별자(tean_id)를 외래키로 사용해서 적절한 등록 쿼리를 생성합니다.

 

조회

조회는 객체 그래프 탐색과 JPQL(객체지향 쿼리) 를 이용해서 할 수 있습니다.

 

객체 그래프 탐색

 

EntityManager em;

Member member = em.find(Member.class, "member1");
Team team = member.getTeam();

 

em.find() 메서드로 식별자를 가지고 Member 엔티티를 조회하고 조회한 Member 엔티티에서 getTeam() 메서드를

사용해서 member 와 연관된 team 엔티티를 조회할 수 있습니다. 이렇게 객체를 통해 연관된 엔티티를 조회하는 것을

객체 그래프 탐색이라고 합니다.

 

JPQL

회원을 대상으로 조회하는데 1팀에 소속된 회원만 조회할 때는 회원과 연관된 팀 엔티티를 검색조건으로 사용해야

합니다. SQL 은 연관된 테이블을 조인해서 검색조건을 사용하면 됩니다. JPQL 도 조인을 지원하지만 테이블이 

아닌 엔티티를 이용해 쿼리를 생성하며 문법이 살짝 다릅니다.

 

EntityManager em;

String jpql = "select m from Member m join m.team t where " +
        "t:name=t:teamName";

List<Member> resultList = em.createQuery(jpql, Member.class)
        .setParameter("teamName", "1팀")
        .getResultList();

for(Member member : resultList)
    System.out.println(member.getUsername());

 

JPQL 에서 from Member m join m.team t 부분을 보면 회원이 팀과 관계를 가지고 있는 필드(m.team) 을 통해서

Member 와 Team 을 조인했습니다. 그리고 where 절에서 조인할 t.name 을 검색조건을 사용해서 1팀에 속한

회원만 검색했습니다.

 

수정

 

EntityManager em;

Team team2 = new Team("team2", "2팀");
em.persist(team2);

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

 

새로운 팀을 생성해 조회한 멤버 객체가 새로운 팀을 참조하도록 했습니다. JPA 에서는 em.update() 같이 수정

메서드가 따로 없습니다. 조회한 엔티티의 값만 변경해두면 트랜잭션을 커밋할 때 플러시가 일어나면서 변경 감지

기능이 작동해 변경사항을 데이터베이스에 자동으로 반영합니다. 이 변경감지는 연관관계 수정에서도 동일하게

작동합니다.

 

제거

 

EntityManager em;

Team team = em.find(Team.class, "team1");

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

em.remove(team);

 

연관된 엔티티를 삭제할꺼면 기존에 있던 연관관계를 먼저 제거하고 삭제해야 합니다.

그렇지 않으면 외래키 제약조건으로 인해 데이터베이스에서 오류가 발생합니다.

 

이번에는 반대 방향인 팀에서 회원으로 접근하는 관계를 추가해서 회원과 팀의 관계를 양방향 연관관계로

만들어보겠습니다.

 

̱ 양방향 연관관계

 

 

회원과 팀은 다대일 관게이며, 팀과 회원은 일대다 관계입니다. 일대다 관계는 여러 건과 연관관계를 맺을 수

있으므로 컬렉션을 사용해야 합니다. 즉 하나의 팀은 여러 명의 회원을 소속시킬 수 있으니 Team 엔티티에서는

Member 엔티티를 리스트로 갖고있어야 하는 것이죠.

회원 -> 팀 Member.team

팀 -> 회원 Team.members

데이터베이스에서 테이블은 외래키 하나로 양방향으로 조회할 수 있습니다. 즉 데이터베이스에서 회원

테이블과 팀 테이블의 처음부터 양방향 관계입니다. 따라서 데이터베이스에서 추가할 내용은 없습니다.

 

@Entity
@Getter @Setter
public class Member{

    @Id
    @Column(name = "member_id")
    private Long id;

    private String username;

    @ManyToOne
    @JoinColumn(name = "team_id")
    private Team team;
}

 

@Entity
@Getter @Setter
public class Team {

    @Id 
    @Column(name = "team_id")
    private Long id;

    private String name;

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

 

양방향 관계로 매핑을 했습니다. Member 엔티티에는 변경사항이 없지만 Team 엔티티에 members 필드가 추가

되었습니다. 팀 엔티티 기준에서 회원 엔티티와는 일대다 관계이므로 @OneToMany 매핑 정보를 사용했습니다.

mappedBy 속성은 양방향 매핑일 때 사용하는데 반대쪽 매핑의 필드이름을 값으로 주게 됩니다.

 

주의!

양방향 매핑 시에는 무한 루프에 빠지지 않도록 주의해야 합니다. 예를 들면 Members.toString() 에서 getTeam() 을

호출하고 Team.toString() 에서도 getMember() 를 호출하면 무한루프에 빠질 수 있습니다. 이런 문제는 엔티티를 

json 으로 변환할 때 자주 발생하는데요. 이런 이유로 API 통신을 할 떄는 엔티티를 직접 반환하지 않고 

DTO(Data Tresation Object) 로 변환해서 반환합니다. 이 방법 이외에도 JSON 라이브러리에서 무한루프에 

빠지지 않도록 하는 어노테이션이나 기능을 사용하거나. Lombok 라이브러리 @ToString 어노테이션으로

toString() 메서드를 생성할 때 필드를 지정, toString() 메서드를 직접 오버라이딩해서 사용하는 방법도 있습니다.

 

EntityManager em;

Team team1 = em.find(Team.class, "team1");
List<Member> members = team1.getMembers();

for (Member member : members) {
    System.out.println(member.getUsername());
}

 

이렇게 양방향 매핑을 하면 팀에서도 회원 컬렉션으로 객체 그래프 탐색을 할 수 있습니다.

 

연관관계의 주인

 

앞서 설명했듯이 객체에는 양방향 연관관계는 사실 서로 다른 단방향 관계 2개를 만드는 것이라고 했는데요. 

이 연관관계 2개를 어플리케이션 로직으로 묶어서 양방향인 것처럼 보이게하는 것입니다. 반면에 데이터베이스

테이블은 외래키 하나로 양쪽이 서로 조인할 수 있어 외래키 하나만으로 양방향 연관관계를 맺고  두 테이블의

연관관계를 관리하게 되는데요. 그런데 엔티티를 양방향으로 매핑하게 되면 회원 -> 팀, 팀 -> 회원 두 곳에서

서로를 참조하므로 객체의 연관관계를 관리하는 부분이 2곳으로 늘어나게 됩니다. 이렇게 엔티티를 양방향

연관관계로 설정하면 객체의 참조는 둘, 외래키는 하나입니다. 따라서 둘 사이에 차이가 발생하게 되고 둘 중

어떤 관계를 사용해서 외래키를 관리해야할지 정해야합니다. 이것을 연관관계의 주인이라고 합니다.

연관관계의 주인을 정하면 연관관계의 주인만이 데이터베이스 연관관계와 매핑되고 외래키를 관리(등록, 수정

삭제)할 수 있습니다. 반면에 주인이 아닌 쪽은 읽기만 할 수 있습니다. 어떤 연관관계를 주인으로 정할지는

mappedBy 속성을 사용하면 됩니다.

 

mappedBy 사용법

연관관계 주인은 mappedBy 속성을 사용하지 않습니다.

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

 

연관관계의 주인을 정한다는 것은 외래키 관리자를 선택하는 것이기 때문에 외래키가 있는 곳이 연관관계의

주인이 되어야합니다. 즉 테이블에 외래키가 있는 곳으로 정해야 하는 것이죠. 여기서는 회원 테이블에 

외래키를 가지고 있으므로 Member.team 이 주인이 됩니다. 그래서 Team 엔티티 클래스에서

주인이 아닌 team.members 에 mappedBy = "team" 속성을 사용해 주인이 아님을 설정한겁니다.

 

간략하게 다시 정리하면 연관관계의 주인만이 데이터베이스 연관관계와 매핑되고 외래키를 관리할 수 있습니다.

주인이 아닌 반대편은 읽기만 가능하며 외래키를 변경할 수 없습니다. 데이터베이스 테이블의 일대다, 다대일 

관계에서는 항상 다 쪽이 외래키를 가집니다. 다 쪽인 @ManyToOne 이 항상 연관관계의 주인이 되므로

mappedBy 를 설정할 수 업어 mappedBy 속성이 없습니다.

 

이번에는 양방향 연관관계를 사용해서 엔티티를 저장해보겠습니다.

 

EntityManager em;

Team team1 = new Team("team1", "1팀");
em.persist(team1);

Member member1 = new Member("member1", "회원1");

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

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

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

 

Team 엔티티에 members 필드가 추가되면서 team1.getMembers().add() 코드도 추가되었는데요.

회원과 팀의 관계에서는 Member.team 이 연관관계 주인이므로 엔티티 매니저는 이곳에 입력된 값을 사용해서

외래키를 관리하며 team1.getMembers().add() 코드는 데이터베이스에 저장될 때 사용하지 않습니다.  사실

members 는 주인이 아니기 때문에 값을 설정하지 않아도 데이터베이스에 외래키 값이 정상 입력되어 이 코드가

없어도 문제될게 없습니다. 그래도 members 에 Member 객체를 추가한 이유는 객체 관점에서 양쪽 방향에 모두 값을

입력해주는 것이 안전하기 때문입니다. 양쪽 방향 모두 값을 입력하지 않는다면 JPA 를 사용하지 않는 순수한

객체 상태에서 심각한 문제가 발생할 수 있습니다. ORM 기술은 객체와 관계형 데이터베이스 둘 다 모두 중요하므로

데이터베이스 뿐만 아니라 객체도 함꼐 고려해야 합니다.  만약 team1.getMembers().add() 추가해주지 않고

members.size() 메서드로 1팀에 소속된 회원들의 수를 구한다면 원하는 결과 2가 아닌 0이 나올 것입니다.

양방향은 양쪽 모두 관계를 설정해야 합니다. 회원 -> 팀을 설정하면 팀 -> 회원도 설정해야 합니다. 이렇게 해야

순수한 객체 상태에서도 동작, 테이블의 외래키도 정상 입력됩니다.

 

양방향 연관관계는 결국 양쪽 다 신경써야 하므로 member.setTeam(team) 과

team.getMembers().add(member) 를 각각 호출하다 보면 둘 중 하나만 호출하는실수를 해서 양방향 연관관계가

성립되지 않을 수 있습니다. 그렇기 때문에 두 메서드를 하나의 메서드로 묶어줘서 사용하는 것이 안전합니다. 

 

public void setTeam(Team team) {

    if(this.team != null) {
    	this.team.getMembers().remove(this);
    }
    
    this.team = team;
    team.getMembers().add(this);
}

 

Member 엔티티 클래스에 위와 같은 메서드를 생성해 사용하면  양방향 관계를 모두 설정할 수 있습니다.

if 문은 기존 팀이 존재한다면 기존 팀과 회원의 연관관계를 삭제하는 코드입니다. 이 코드를 작성하지 않으면

기존 팀에 속한 회원들을 조회할 때 해당 회원이 여전히 조회됩니다. 즉 회원이 하나의 팀이 아닌 두 개의 팀에 소속

됩니다. 이렇게 한 번에 양방향 관계를 설정하는 메서드를 연관관계 편의 메서드라고 합니다.

 

EntityManager em;

Team team1 = new Team("team1", "1팀");
em.persist(team1);

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

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

 

양방향 연관관계를 사용해서 엔티티를 저장하는 코드에서 연관관계 편의 메서드를 적용해봤습니다.

 

정리

연관관계가 하나인 단방향 매핑은 언제나 연관관계의 주인입니다.

양방향은 여기에 주인이 아닌 연관관계를 하나 추가한 것입니다.

양방향의 장점은 반대방향으로도 객체 그래프 탐색이 가능하다는 것입니다.

주인의 반대편은 mappedBy 로 주인을 지정해야 하며 주인의 반대편은 단순히 객체 그래프 탐색만 할수 있습니다.

양방향 연관관계를 매핑하려면 객체에서 양쪽 방향을 모두 관리해야 합니다.

연관관계의 주인은 외래키의 위치를 기준으로 정해야 합니다.

 

이 포스팅은 자바 ORM 표준 JPA 프로그래밍의 내용을 참고하여 작성하였습니다.

댓글