Coderoad

연관관계 매핑이란?

2023-07-06 at JPA category

연관관계?

JPA와 같은 ORM 기술을 공부하다 보면, '연관관계'라는 단어를 자주 마주칠 수 있습니다. 이 단어는 먼저 객체지향 설계의 목표를 알아야 완벽하게 이해할 수 있습니다. 객체지향의 사실과 오해라는 책에선 객체지향 설계의 목표를 다음과 같이 설명합니다.

객체지향 설계의 목표는 자율적인 객체들의 협력 공동체를 만드는 것이다.

만약 우리가 객체지향 설계 목표를 잘 지키며 서비스를 만들고자 한다면 자율적인 객체들의 협력을 통해 사용자가 원하는 결과를 도출해내야합니다. 당연하게도 우리가 데이터베이스 테이블과 매핑한 엔티티들 역시 객체이며, 협력을 통해 우리가 원하는 결과를 만들어내도록 해야합니다. 즉, 객체지향 설계를 위해서 우리는 엔티티들에게 적절한 연관관계를 부여하여 엔티티들끼리 협력하게 해야 합니다. 그런데, 우리가 데이터베이스 테이블에 맞춰서 매핑한 엔티티들 간에는 연관관계가 전혀 없습니다. 아래는 연관관계가 없이 설계한 테이블 스키마와 엔티티입니다.

object
@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;

    @Column(name = "USERNAME")
    private String name;

    @Column(name = "TEAM_ID")
    private Long teamId;

}
데이터베이스 테이블에 맞춰서 설계한 Member 엔티티

table
@Entity
public class Team {
    @Id @GeneratedValue
    private Long id;
    private String name;
}
데이터베이스 테이블에 맞춰서 설계한 Team 엔티티

얼핏 보면 Member 엔티티에 있는 외래키 속성 덕분에 연관관계가 있는 것처럼 보일 수도 있지만 두 엔티티 객체 사이에는 아무런 연관관계도 없습니다. Member에서 Team을 바로 참조할 방법도, Team에서 Member를 바로 참조할 방법도 없습니다. 유일하게 두 엔티티가 연관될 수 있는 방법은 MemberteamId 값을 통해 데이터베이스에서 Team을 찾는 것입니다. 데이터베이스 테이블 스키마대로 엔티티를 설계했기 때문에 데이터 저장에는 문제가 없지만 이 방법은 두 객체가 협력할 방법이 전혀 없는 객체지향스럽지 않은 설계입니다. 객체지향 설계의 목표는 자율적인 객체들의 협력 공동체를 만드는 것인데, 협력은 커녕 불필요한 추가 작업만 늘어나는 설계가 되어버렸습니다. 즉, 객체를 테이블에 맞춰서 데이터를 중심으로 설계하면 객체 간의 협력 관계를 구축할 수 없습니다. 이러한 문제가 발생한 이유는 객체와 테이블이 서로 자신과 연관된 요소를 찾는 방법에 큰 차이가 있기 때문입니다.

단방향 연관관계

따라서, 테이블과 객체 사이의 차이를 줄이기 위해 우리는 객체에도 연관관계를 부여하고 이 연관관계를 테이블과 매핑해야합니다. 즉, 연관관계 매핑이 이뤄져야 합니다.

연관관계 매핑에서 매핑(Mapping)은 한자로 사상(寫像)이라고 합니다. 단어만 보면 이해하기 어려울 수 있지만, 매핑은 어떠한 값에 다른 값을 대응시키는 모든 과정을 말합니다.

연관관계 매핑은 참조 필드(Reference Field)를 통해 객체에 연관관계를 부여하고 이 참조 필드를 테이블의 외래 키(Foreign Key)에 매핑하는 것입니다. 아래 코드는 객체와 테이블의 연관관계를 매핑한 후, 다시 설계한 엔티티입니다.

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;

    @Column(name = "USERNAME")
    private String name;

    // @Column(name = "TEAM_ID")
    // private Long teamId;

    @ManyToOne // 다대일 연관관계 매핑
    @JoinColumn(name = "TEAM_ID") // Member 테이블의 외래 키인 TEAM_ID 컬럼과 매핑
    private Team team; // 참조 필드
}

이전에 테이블에 맞춰 설계한 엔티티는 Team의 ID만 가지고 있어 바로 Team의 여러 정보들을 접근할 수 없었습니다. 즉, 두 객체는 협력하는 객체가 아니었습니다. 그러나, 연관관계 매핑 이후에는 참조 필드를 통해 바로 원하는 데이터를 얻을 수 있게 되었고 비로소 협력 관계를 갖게 되었습니다. 데이터베이스 테이블이 외래 키를 활용한 JOIN 연산을 통해 자신과 연관된 다른 테이블을 바로 조회하는 것처럼, 객체도 자신과 연관된 다른 객체를 바로 참조할 수 있게 된 것입니다. 우리는 연관관계 매핑을 통해 객체와 테이블 사이의 차이를 좁혔습니다!

사실, ORM 기술의 핵심이 바로 이 연관관계 매핑입니다. ORM(Object-Relational Mapping)이라는 이름에서부터 알 수 있듯이, ORM은 객체와 관계형 데이터베이스를 매핑하여 둘 사이의 차이로 발생하는 여러 문제를 해결하는 것이 목표입니다. 그러니 연관관계 매핑에 대해 제대로 이해하고 JPA를 사용하는 것과 이해하지 못하고 사용하는 것에는 객체와 테이블과 같이 큰 차이가 있습니다.

그렇다면 연관관계에는 어떤 것들이 있을까요? 먼저 알아볼 연관관계는 단방향 연관관계입니다. 위의 Member 엔티티가 바로 단방향 연관관계를 설정한 엔티티입니다. 단방향 연관관계는 참조 필드를 통해 연관관계를 설정한 엔티티에서만 반대편 엔티티를 조회할 수 있는 연관관계를 말합니다. '단방향'이기 때문에 반대편 엔티티인 Test에는 Member를 향한 참조 필드가 존재하지 않습니다.

// Test에서는 Member를 참조할 방법이 없다...
@Entity
public class Team {
    @Id @GeneratedValue
    private Long id;
    private String name;
}

하지만 데이터베이스 테이블은 외래 키와 JOIN 연산을 통해 어느 쪽에서든 조회가 가능합니다.

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
두 SQL 쿼리 모두 JOIN 연산을 수행하고 같은 결과를 얻을 수 있습니다.

다시 객체와 데이터베이스 간의 차이가 나타났습니다. 그러나 외래 키 하나로 두 테이블의 연관관계를 간단하게 관리하는 데이터베이스처럼 객체도 양쪽 모두 연관관계를 가지게 할 수 있습니다.

양방향 연관관계

두 엔티티가 서로를 참조할 수 있게 하려면 양방향 연관관계를 객체에 설정하면 됩니다. 반대편 엔티티에도 참조 필드를 통해 연관관계를 설정해주면 간단하게 양방향 연관관계를 가질 수 있습니다.

@Entity
public class Team {
    @Id @GeneratedValue
    private Long id;
    private String name;

    @OneToMany(mappedBy = "team") // 일대다 연관관계 매핑
    private List<Member> members = new ArrayList<>(); // 한 팀에는 여러 멤버가 속한다.
}

이렇게 엔티티를 설계하면 Team에서도 Member들의 정보를 조회할 수 있습니다. 양방향 연관관계가 완성된 것입니다. 그러나 엄밀히 말하자면 '양방향' 연관관계가 아니라 2개의 '단방향' 연관관계가 만들어진 것입니다. 잘 생각해보면 양방향 연관관계를 위해 2개의 서로 다른 참조 필드가 필요하다는 것을 알 수 있습니다. 1개의 외래 키를 통해 두 연관된 테이블의 양방향 연관관계를 설정할 수 있었던 데이터베이스와 달리 객체는 2개의 단방향 연관관계로 양방향 연관관계처럼 만든 것입니다.

그런데, 앞서 연관관계 매핑은 객체의 참조 필드를 테이블의 외래 키에 매핑하는 것이라고 설명했습니다. 여기서 정말 너무나 중요한 개념이 등장하게 됩니다. 도대체 외래 키는 어느 참조 필드가 관리해야할까요?

연관관계의 주인

우리는 두 개의 참조 필드 중 하나를 선택해서 외래 키와 매핑해야합니다. 이때, 외래 키를 관리하는 참조 필드를 가진 엔티티연관관계의 주인이라고 합니다. 연관관계의 주인만이 외래 키를 관리하고 연관관계의 주인이 아닌 엔티티는 참조 필드를 통해 데이터의 조회만 가능합니다. 이런 이유로 연관관계의 주인의 매핑을 진짜 매핑이라 하고, 주인의 반대 쪽의 매핑을 가짜 매핑이라고 합니다.

우리는 데이터베이스 테이블을 보면 외래 키가 존재하는 테이블을 알 수 있습니다. 해당 테이블을 객체로 나타낸 엔티티가 연관관계의 주인이 됩니다. 즉, 외래 키의 위치가 연관관계의 주인을 결정합니다. 우리가 작성한 코드에서는 Member가 연관관계의 주인인 것입니다.

// Member 테이블의 외래 키 TEAM_ID와 매핑한 참조 필드
// 외래 키를 가지고 있으므로 Member 엔티티가 연관관계의 주인이 된다.
@ManyToOne
@JoinColumn(name = "TEAM_ID")
private Team team;
// 연관관계의 주인 반대편은 mappedBy 속성을 통해 자신을 참조하는 필드의 이름을 지정해야한다.
@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<>(); // 이 필드는 오직 조회만 가능하다.

@OneToMany 어노테이션의 mappedBy가 바로 연관관계의 주인을 지정하는 속성이었습니다. 따라서 연관관계의 주인 쪽에는 mappedBy 사용할 일도 없고 사용해서도 안됩니다.

@ManyToOne@OneToMany는 연관관계의 다중성을 나타내는 어노테이션입니다. 다양한 연관관계 매핑에 정리했습니다.

이렇게 양방향 연관관계의 연관관계의 주인도 지정하며 참조 필드와 외래 키와의 매핑도 잘되었습니다. 이제 몇가지 실수하기 쉬운 상황만 유의한다면 객체와 테이블 간의 차이는 거의 없다시피 할 수 있습니다. 첫번째 상황은 연관관계의 주인에 아무런 값도 입력하지 않는 것입니다.

Team team = new Team();
team.setName("Team CodeRoad");
em.persist(team);

Member member = new Member();
member.setName("CodeRoad");
// 주인이 아닌 방향만 연관관계 설정
team.getMembers().add(member);
em.persist(member);

위의 코드와 같이 객체들을 생성하고 데이터베이스에 저장하게 되면 CodeRoad 멤버는 어떠한 팀에도 속하지 않는 상황이 발생합니다. 위 코드의 어느 곳에도 연관관계의 주인에 값을 넣어주는 부분이 없습니다. 연관관계 매핑은 객체와 테이블을 매핑해줄 뿐이지 값을 자동으로 입력해주는 것이 아닙니다!

Member member = new Member();
member.setName("CodeRoad");
team.getMembers().add(member);
// 연관관계의 주인에 값 입력
member.setTeam(team);
em.persist(member);

이렇게 연관관계의 주인에도 값을 입력해주면 CodeRoad 멤버가 속한 팀의 TEAM_ID가 데이터베이스에 제대로 저장됩니다. 양방향 연관관계에서는 양쪽 객체가 순수한 객체 상태에서 서로를 제대로 참조할 수 있도록 양쪽 모두에 값을 입력해줘야 합니다. 만약, 어느 한 쪽을 깜빡 잊고 입력하지 않는 상황을 피하고 싶다면 연관관계 편의 메소드를 작성해 한 쪽이 저장될 때 다른 쪽도 자동으로 저장될 수 있도록 할 수 있습니다.

또한, 양방향 매핑 시에는 무한 루프를 주의해야 합니다. 예를 들어, toString()을 사용하거나 JSON 생성 시에 엔티티의 필드들을 모두 읽는데 이때, 참조 필드를 타고 들어가 반대편 엔티티의 필드도 모두 읽게 됩니다. 그런데, 반대편에서도 원래의 엔티티를 참조할 수 있기 때문에 또 다시 같은 작업을 무한히 반복하는 대참사가 발생할 수도 있습니다.

저는 REST API 서버를 구축할 때, 사용자가 요구하는 내용을 DTO를 통해 전달하는 과정에서 엔티티의 참조 필드가 가리키는 객체의 내용까지 모두 JSON String으로 변환되어 불필요하거나 감춰야 하는 정보가 외부로 노출되었던 경험이 있습니다. 양방향 연관관계를 사용했을 때는 너무 많은 참조로 인해 StackOverFlow가 발생하기도 했습니다.

정리하자면, 단방향 연관관계 매핑만으로도 충분히 연관관계 매핑을 완료할 수 있습니다. 양방향 연관관계는 반대 방향으로(연관관의 주인의 반대편에서) 참조하는 로직이 자주 필요한 상황이 아니면 굳이 사용할 이유가 없습니다.

거기다 연관관계의 주인의 반대편은 조회만 가능하기 때문에 데이터베이스에 영향도 주지 않습니다. 반대편의 엔티티의 참조 필드와 매핑 되는 컬럼이 테이블에 존재하지도 않을 뿐더러, 엔티티에서 해당 참조 필드의 값을 수정해도 테이블에는 아무런 변화가 없습니다.

이 말은 곧, 서비스 로직에서 양방향 연관관계를 위해 작성한 반대편 참조 필드의 값만 수정하고 정작 연관관계의 주인의 값을 변경하지 않게 되면 예기치 못한 문제가 발생할 수 있다는 것입니다. 양방향 연관관계는 객체 그래프 탐색을 위해 반대 방향 조회 기능만 추가하는 용도로 사용하는 것이 좋습니다.

실제로 제가 프로젝트를 진행할 때, 연관관계의 주인 쪽에 단방향 연관관계만 설정하고 데이터베이스 테이블의 JOIN 연산처럼 연관관계의 주인만으로 여러 조회 기능을 설계했던 것을 생각해보면 단방향 연관관계로 엔티티를 설계하고 필요할 때만 양방향 연관관계를 추가하는 방식이 훨씬 코드를 이해하기 쉽고 데이터베이스와의 괴리감도 적었던 것 같습니다.

요약

  • 연관관계 매핑에는 단방향 연관관계 매핑과 양방향 연관관계 매핑이 있다.
  • 양방향 연관관계 매핑은 사실 2개의 단방향 연관관계 매핑을 활용한 것이다.
  • 연관관계의 주인은 외래 키 보유 여부 즉, 외래 키의 위치를 기준으로 결정한다.
  • 연관관계 매핑은 단방향 연관관계 매핑만으로도 충분히 완료할 수 있다.
hangillee

Personal blog by hangillee.

Road to good developer.