[JPA] 연관관계[1/2] - 양방향

2021. 12. 3. 19:24Spring/JPA

객체의 연관관계

객체에서는 참조를 통해서 객체 간의 연관관계를 맺는다. 아래와 같은 경우에는 서로에 대해 참조값을 가지고 있으므로 양방향 연관관계라고 볼 수 있다.

@Entity
class Person {

	@Id
	@GeneratedValue
	private Long id;

	private Address address;
}
@Entity
class Address {

	@Id
	@GeneratedValue
	private Long id;

	private Set<Person> persons = new HashSet<>();
}

Person에서 Address에 관심이 없는 경우에는 단방향 연관관계가 되며 다음과 같이 작성할 수 있다.

@Entity
class Person {

	@Id
	@GeneratedValue
	private Long id;
}
@Entity
class Address {

	@Id
	@GeneratedValue
	private Long id;

	private Set<Person> persons = new HashSet<>();
}

데이터베이스의 연관관계

데이터베이스에서 테이블 간 관계는 외래 키를 통해 맺는다. 위의 예제를 테이블로 변환한 쿼리는 다음과 같다. 외래 키 제약 조건은 편의상 적용하지 않았다.

create table Person (
	id bigint not null primary key
		generated by default as identity,
	address_id bigint not null
);
create table Address (
	id bigint not null primary key
		generated by default as identity
);

보면 Address의 기본 키가 각 Person에 해당하는 외래 키로 들어가 있게 된다. 따라서 Person에서도 외래 키를 통해 Address를 조회할 수 있고 Address도 Person에 들어있는 자신의 키를 통해 Person을 조회할 수 있다. 즉 DB의 테이블 관계는 항상 양방향이다.

객체와 DB의 연관관계 관점 차이

객체는 참조값을 통해 연관관계를 맺으며 단방향, 양방향으로 가능하다. 하지만 DB에서는 연관관계를 외래 키로 맺으며 항상 양방향이다. 이러한 관점 차이를 잘 이해하고 있어야 JPA를 통해 연관관계를 맺어줄 때 오류가 발생하지 않도록 개발할 수 있다. 이번 글에서는 양방향을 기준으로 설명한다.

JPA로 연관관계 매핑

연관관계의 종류는 다음과 같다.

  • 일대일
  • 다대일
  • 일대다
  • 다대일

하나씩 살펴보도록 하자. 먼저 다대일부터 살펴본다.

다대일

@Entity
class Person {

	@Id
	@GeneratedValue
	private Long id;

	@ManyToOne
	@JoinColumn(name = "address_id")
	private Address address;
}
@Entity
class Address {

	@Id
	@GeneratedValue
	private Long id;

	@OneToMany(mappedBy = "address")
	private Set<Person> persons = new HashSet<>();
}

외래 키를 갖고 있는 쪽은 다대일의 관계이므로 @ManyToOne 어노테이션을 선언한다. 그리고 외래 키로 참조할 필드를 name 속성에 명시한다.

일대다인 쪽은 @OneToMany로 선언하며 mappedBy 속성으로 자신이 참조되는 필드명을 적어준다. mappedBy를 적용하는 이유는 DB와의 관점 차이 때문이다.

연관관계의 주인

DB에서는 외래 키 하나로 두 테이블의 연관관계를 관리한다. 그러나 엔티티에서는 위처럼 두 엔티티에서 관리하는 경우가 있다. 이러한 경우 어떤 테이블을 기준으로 관리해야 하는지를 명시해주어야 한다. 여기서 mappedBy가 사용된다. mappedBy를 적용한 쪽은 키를 수정할 수 없으며 읽기만 가능하다. 위 예제의 경우 Address에 mappedBy가 적용되었기 때문에 Person에서 외래 키를 관리하게 된다.

이렇게 연관관계의 주도권을 정하는 것을 'JAVA ORM 표준 프로그래밍'에서는 연관관계의 주인을 정한다고 표현한다.

다대일, 일대다인 관계의 경우는 항상 다대일인 쪽이 연관관계의 주인이 된다. 즉 일인 쪽에는 항상 mappedBy가 적용되고 다인 쪽에는 항상 @JoinColumn이 적용된다.

다대다

Person과 Address를 다대다 관계로 표현한 엔티티 클래스는 다음과 같다.

@Entity
class Person {

	@Id
	@GeneratedValue
	private Long id;

	private Set<Address> addresses = new HashSet<>();
}
@Entity
class Address {

	@Id
	@GeneratedValue
	private Long id;

	private Set<Person> persons = new HashSet<>();
}

객체에서는 이렇게 컬렉션으로 다대다 관계를 표현할 수 있지만 DB에서는 이러한 다대다 관계가 불가능하다. 그래서 DB에서는 중간 테이블을 하나 만들고 이를 통해 일대다 - 중간테이블 - 다대일의 관계로 풀어낸다.

create table Person (
	id bigint not null primary key
		generated by default as identity,
);
create table Address (
	id bigint not null primary key
);
create table Person_Address (
	person_id bigint not null,
	address_id bigint not null,
	primary key (person_id, address_id)
);

엔티티에서 다대다는 다음과 같이 작성할 수 있다. 그리고 연관관계의 주인은 원하는 쪽으로 정해도 무방하다. 아래는 주인을 Person으로 정한 것이다.

@Entity
class Person {

	@Id
	@GeneratedValue
	private Long id;

	@ManyToMany
	@JoinTable(
		name = "person_address",
		joinColumns = @JoinColumn(name = "person_id"),
		inverseJoinColumns = @JoinColumn(name = "address_id")
	)
	private Set<Address> addresses = new HashSet<>();
}
@Entity
class Address {

	@Id
	@GeneratedValue
	private Long id;

	@ManyToMany(mappedBy = "addresses")
	private Set<Person> persons = new HashSet<>();
}

이렇게 하면 간단하게 다대다를 구현할 수 있지만 문제가 있다. 저렇게 만들어진 중간 테이블은 단순히 person의 id와 address의 id를 복합 키로 가지는 테이블이 된다. 하지만 실제로 서비스를 운영할 때는 중간 테이블에 넣어야 할 데이터가 더 있을 수 있다.

이러한 문제를 해결하려면 중간 테이블을 엔티티로 승격시키면 된다. 즉 PersonAddress라는 엔티티를 하나 만들고 그 엔티티와 연관관계를 맺을 수 있다.

@Entity
class Person {

	@Id
	@GeneratedValue
	private Long id;

	@OneToMany(mappedBy = "persons")
	private Set<PersonAddress> addresses = new HashSet<>();
}
@Entity
class Address {

	@Id
	@GeneratedValue
	private Long id;

	@OneToMany(mappedBy = "addresses")
	private Set<PersonAddress> persons = new HashSet<>();
}
@Entity
class PersonAddress {
	
	@Id
	@GeneratedValue
	private Long id;

	@ManyToOne
	@JoinColumn(name = "person_id")
	private Set<Person> persons = new HashSet<>();

	@ManyToOne
	@JoinColumn(name = "address_id")
	private Set<Address> addresses = new HashSet<>();
    
	// 중간 테이블에 추가된 필드
	private String road;
}

 

만약 중간 테이블의 키로 복합 키를 사용하고 싶다면 @MapsId와 @EmbeddedId, @Embeddable를 사용하는 방법이 있다. Embedded 개념은 나중에 다룰 내용이므로 모른다면 지금은 넘어가도 된다.

@Entity
class Person {

	@Id
	@GeneratedValue
	private Long id;

	@OneToMany(
		mappedBy = "persons",
		cascade = CascadeType.ALL, orphanRemoval = true
	)
	private Set<PersonAddress> addresses = new HashSet<>();
}
@Entity
class Address {

	@Id
	@GeneratedValue
	private Long id;

	@OneToMany(
		mappedBy = "addresses"
		cacade = CascadeType.ALL, orphanRemoval = true
	)
	private Set<PersonAddress> persons = new HashSet<>();
}
@Entity
class PersonAddress {
	
	@EmbeddedId 
	private PersonAddressId id;

	@ManyToOne
	@MapsId(personId)
	private Set<Person> persons = new HashSet<>();

	@ManyToOne
	@MapsId(addressId)
	private Set<Address> addresses = new HashSet<>();
    
	// 중간 테이블에 추가된 필드
	private String road;
}
@Embeddable
static class PersonAddressId implements Serializable {

	@Column(name = "person_id")
	private Long personId;
    
	@Column(name = "address_id")
	private Long address_id;
    
	// Getters and Setters
}

일대일

일대일의 경우는 다대다와 비슷하다. 연관관계의 주인도 더 중요하다고 판단되는 것으로 정하면 된다.

create table Person (
	id bigint not null primary key
		generated by default as identity,
	address_id bigint not null
);
create table Address (
	id bigint not null primary key
		generated by default as identity,
);
@Entity
class Person {

	@Id
	@GeneratedValue
	private Long id;

	@OneToOne
	@JoinColumn(name = "person_id")
	private Address address;
}
@Entity
class Address {

	@Id
	@GeneratedValue
	private Long id;

	@OneToOne(mappedBy = "address")
	private Person person;
}

Person과 Address의 키를 같도록 저장하는 방법도 있다. 외래 키와 매핑한 연관관계를 기본 키에도 매핑한다는 말이다.

@Entity
class Person {

	@Id
	@GeneratedValue
	private Long id;

	@OneToOne(mappedBy = "person")
	private Address address;
}
@Entity
class Address {

	@Id
	@GeneratedValue
	private Long id;

	@OneToOne
	@MapsId
	private Person person;
}

이 경우에는 실제 DB에는 외래 키가 매핑되지 않는다. 대신 JPA가 데이터를 입력할 때 자동으로 Person의 id를 가지고 Address의 id를 맞춘다.

create table Person (
	id bigint not null primary key
);
create table Address (
	id bigint not null primary key
);

양방향 매핑 시 가장 많이 하는 실수

한 쪽에만 데이터를 입력하는 실수를 가장 많이 한다. 위에서 설명한대로 연관관계의 주인에 의해 키가 관리되므로 연관관계의 주인에 데이터를 입력하지 않으면 ID가 입력되지 않는다. 아래는 설명을 위해 Address와 Person에 name을 추가한 상태라고 가정하고 작성했다.

Address address = new Address();
address.setName("Daejeon");
em.persist(address);

Person person = new Person();
person.setName("John");

// Address에만 값을 입력했다.
address.getPersons().add(person);

em.persist(person);

따라서 양방향 매핑 시에는 연관관계의 주인에 값을 입력해주어야 한다.

Address address = new Address();
address.setName("Daejeon");
em.persist(address);

Person person = new Person();
person.setName("John");

address.getPersons().add(person);
person.setAddress(address);

em.persist(person);

그리고 순수한 객체 관계를 고려하면 항상 양쪽에 값을 다 입력해야 한다. 위의 경우 person.getAddress()로 Address 객체를 가져올 경우 Address는 영속성 컨텍스트의 1차 캐시에 있는 name이 Daejeon인 객체를 가져오게 된다. 다시 말하면 가져온 Address에서 Person을 조회할 수 없다. 따라서 편의 메서드를 생성하여 사용하는 편이 좋다.

@Entity
public class Address {

	@Id
	@GeneratedValue
	private Long id;
    
	private String name;
    
	@OneToMany(mappedBy = "person")
	private Set<Person> persons = new HashSet<>();
    
	public void addPerson(Person person) {
		persons.add(person);
		person.setAddress(this);
    }
}

양방향 매핑 시 주의할 점

  • 순수 객체 상태를 고려해서 항상 양쪽에 값을 설정하자
  • toString(), lombok과 같은 경우 순환 참조가 발생할 수 있다.
    • 예를 들어 Address를 toString()을 통해 출력하려고 한다.
    • 그러면 persons에 들어있는 모든 Person들 또한 toString()이 호출된다.
    • 그런데 Person의 필드에 address가 있으므로 다시 address의 toString()을 호출한다.
    • 이렇게 순환 참조가 발생할 수 있으므로 주의해야 한다.
  • 컨트롤러에서 엔티티를 JSON으로 반환하지 마라
    • 대신 DTO를 사용해서 JSON으로 변환 후 전송

양방향 매핑 정리

  • 단방향 매핑만으로도 이미 연관관계 매핑은 완료될 확률이 높다
  • 양방향 매핑은 반대 방향으로 조회(객체 그래프 탐색) 기능이 추가된 것 뿐이다.
  • 처음 설계할 때는 단방향 매핑으로 최대한 설계한다.
  • 다만 나중에 JPQL에서 역방향으로 탐색할 일이 많아서 양방향 매핑이 필요할 경우 추가해도 늦지 않다.
  • 즉 단방향 매핑을 잘 하고 양방향 매핑은 필요할 때 추가해도 된다.
    • 테이블에 영향을 주지 않기 때문이다.

'Spring > JPA' 카테고리의 다른 글

[JPA] 코틀린으로 엔티티 작성 시 고려할 점  (0) 2022.02.08
[JPA] Fetch - Eager, Lazy  (0) 2021.12.04
[JPA] 연관관계[2/2] - 단방향  (0) 2021.12.03
[JPA] JPA 사용 시 주의할 점  (0) 2021.12.03
[JPA/Hibernate] Hibernate 기초  (0) 2021.10.12