코드스쿼드에서 미션을 진행하면서 Spring Data JDBC 를 사용하게 되었다. 이전까지는 스프링 프레임워크에서 기본적으로 제공하는 JDBCTemplate 만을 사용했지만, 처음으로 자바 애플리케이션 내의 도메인 객체와 관계형 데이터베이스의 테이블을 매핑하는 모듈을 다루게 되었다. 그 과정에서 특히 어려움을 겪었던 Spring Data JDBC 에서의 1:1, 1:N, N;1, N:M 연관관계 매핑에 대해, 스스로 시행착오를 겪으면서 알게된 방법을 정리해보고자 한다.
연관관계 매핑을 연습하면서 정리한 GitHub 리포지토리의 링크는 아래와 같다.
https://github.com/rxdcxdrnine/relation-spring-data-jdbc
GitHub - rxdcxdrnine/relation-spring-data-jdbc: Spring Data JDBC 의 연관관계 매핑 연습
Spring Data JDBC 의 연관관계 매핑 연습. Contribute to rxdcxdrnine/relation-spring-data-jdbc development by creating an account on GitHub.
github.com
Spring Data JDBC 와 DDD
연관관계에 대해 이야기하기 전에, 먼저 Spring Data JDBC 의 전반적인 철학과 함께 DDD (Domain Driven Design) 의 구성 요소에 대해 알아보고자 한다. Spring Data JDBC 는 DDD 에서 repository 와 aggregate, aggregate root 라는 개념으로부터 영감을 얻었는데, 사실 이는 관계를 가진 테이블 사이에 기본키 (Primary Key, PK) 와 외래키 (Foreign Key, FK) 를 통해 양방향으로 접근 가능한 관계형 데이터베이스와는 반대되는 개념이다.
aggregate
연관 객체의 묶음을 말한다.
e.g. 유저가 상품을 주문하는 애플리케이션에서 Order
와 OrderItem
은 하나의 aggregate 에 속한다.
aggregate root
연관 객체의 묶음인 aggregate 에서 root 에 해당하는 엔티티로, aggregate 에 대해서 aggregate root 에 해당하는 엔티티의 Repository 로만 aggregate 를 가져와야 한다. 즉, aggregate root 하나 당 하나의 repository 를 만들어야 한다.
e.g. Order
와 OrderItem
이 속한 aggregate 에서 Order
는 aggregate root 에 해당하며 OrderItem
은 Order
객체에 종속적이다. 그리고 OrderItem
을 데이터베이스로부터 가져올 때 Order
의 repository 를 통해서만 가져올 수 있다.
(참고 : https://docs.spring.io/spring-data/jdbc/docs/current/reference/html/#jdbc.why)
하나 특이한 점은 Spring Data JPA 에서 연관관계의 주인이 테이블에 외래키가 있는 객체인 반면, Spring Data JDBC 에서는 외래키가 없는 객체라는 것이다. 외래키가 있는 테이블은 참조하는 테이블에 종속적이고, 테이블을 객체에 대응했을 때 외래키가 있는 객체는 참조하는 객체에 종속적이다. 따라서 aggregate root 는 참조하는 객체, 외래키가 없는 객체이며 해당 객체의 repository 를 통해서만 aggregate 의 데이터베이스 CRUD가 가능하므로, 연관관계의 주인은 외래키가 없는 객체이어야 한다.
위의 예에서 Order
과 OrderItem
객체 간에 연관관계의 주인은 Order
에 해당하며, OrderItem
은 Order
객체의 items
필드에 추가된 뒤 OrderRepository
를 통해서만 데이터베이스 CRUD가 가능하다. 즉, 아래와 같이 연관관계가 매핑되어야 한다.
(개인적인 의견이므로 틀린 내용일 수 있습니다. 피드백 주시면 감사하겠습니다.)
1:1 연관관계 매핑
먼저 movie
테이블과 rental
테이블이 아래와 같이 1:1 관계를 가지고 있다고 가정하자.
위의 테이블 내 필드와 연관관계에 따라 아래와 같이 도메인 객체를 추가한다. 여기서 연관관계의 주인은 외래키가 없는 테이블에 해당하는 객체이자 aggregate root 인 Movie
에 해당하고, 해당 객체에 non-root aggregate 에 해당하는 객체의 필드에 Rental
을 추가해 연관관계를 매핑한다.
그리고 아래와 같이 Movie
객체의 속성으로 Rental
객체를 추가하고 MovieRepository
에 저장했을 때 Movie
객체와 함께 Rental
객체가 연관관계 매핑이 되고 데이터베이스에 저장되는 것을 확인할 수 있다.
해당 소스코드는 아래의 커밋에서 확인할 수 있다.
one-to-one 을 @Column
을 이용해 연관관계 매핑 · rxdcxdrnine/relation-spring-data-jdbc@4026714
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
github.com
혹은 2개의 테이블에 대응하는 객체를 매핑하기보다, @Embedded
를 이용해 한 객체에서 다른 값 객체를 포함해 하나의 테이블에 대응할 수 있다. 자세한 내용은 아래를 참고하자.
1:N 관계 매핑
먼저 Order
테이블과 OrderItem
테이블이 아래와 같이 1:N 관계를 가지고 있다고 가정하자.
위의 테이블 내 필드와 연관관계에 따라 아래와 같이 도메인 객체를 추가한다. 여기서 연관관계의 주인은 외래키가 없는 테이블에 해당하는 객체이자 aggregate root 인 Order
에 해당하고, 해당 객체에 non-root aggregate 에 해당하는 객체의 필드에 OrderItem
을 추가해 연관관계를 매핑한다.
연관관계 매핑 시에는 One 에 해당하는 테이블의 컬럼을 @MappedCollection
의 idColumn
에 추가한다. 그리고 Many 에 해당하는 객체의 컬렉션을 Set
타입으로 추가한다. Many 에 해당하는 객체 컬렉션을 추가할 때 Set
외에도 Many
와 List
를 사용할 수 있는데, 이 때 @MappedCollection
의 keyColumn
에 별도의 컬럼을 추가해야한다. 자세한 내용은 아래 링크를 참고하자.
https://docs.spring.io/spring-data/jdbc/docs/current/reference/html/#jdbc.entity-persistence.types
그리고 아래와 같이 Order
객체의 속성으로 OrderItem
객체를 추가하고 OrderRepository
에 저장했을 때 Order
객체와 함께 OrderItem
객체가 연관관계 매핑이 되고 데이터베이스에 저장되는 것을 확인할 수 있다.
해당 소스코드는 아래의 커밋에서 확인할 수 있다. (테이블의 경우 이후 커밋에서 기본 키를 다시 설정했다.)
one-to-many 를 @MappedCollection
을 이용해 연관관계 매핑 · rxdcxdrnine/relation-spring-data-jdbc@2d2d3df
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
github.com
Spring Data JDBC 는 1:1 과 1:N 관계 매핑을 지원하지만, N:1 과 N:M 관계 매핑을 지원하지 않는다. 따라서 N:1 관계 매핑은 별도의 RowMapper
를 정의해 조회만 가능하고, N:M 관계 매핑은 결합 테이블을 생성한 뒤 1:N 관계 2개를 사용해 매핑해야한다.
N:1 관계 매핑
1:N 관계에서 Order
와 OrderItem
에 대해, Order
에서 OrderItem
을 조회하는 대신 OrderItem
에서 Order
를 조회하는 경우를 생각해보자. 1:1 관계와 비슷하게 OrderItem
에서 Column("order_id")
으로 Order
필드를 추가한다.
그리고 아래의 테스트를 진행하면, 다음과 같이 동일한 쿼리가 반복해서 호출되며 스택 오버플로가 발생한다.
정확한 이유는 찾기 어렵지만, 객체의 순환 참조로 인해 해당 문제가 발생한 것으로 보인다. OrderItem
에서 참조하는 Order
를 조회하고, Order
객체에서는 다시 참조하는 OrderItem
컬렉션을 조회하면서 순환 참조가 발생하고 따라서 위와 같이 서로 참조가 일어난 것으로 추측된다. 하지만 순환 참조를 제거하기 위해 이미 연관관계의 주인인 Order
에서 OrderItem
을 참조하고 있으므로 해당 속성을 제거하기는 어렵다.
순환 참조 문제를 해결하는 대신, 별도의 조회 쿼리를 작성하고 RowMapper
를 이용해 조회 결과를 객체로 매핑하는 방법을 선택하자.
이 때 OrderItem
에는 Order
객체를 속성으로 추가하지만 객체를 테이블로 매핑하지 않도록 @Transient
을 추가한다. 즉, Spring Data JDBC 에서 객체를 테이블로 매핑할 때 Order
객체는 포함되지 않고, JDBCTemplate 을 이용해 RowMapper
로 조회 결과를 객체로 매핑할 때 수동으로 Order
객체를 포함시킨다.
이제 해당 방법으로 조회했을 때 순환 참조가 발생하지 않고, 테이블 간의 조인을 사용했으므로 1번의 쿼리 실행 만으로 연관관계가 매핑된 객체를 조회할 수 있다.
해당 소스코드는 아래의 커밋에서 확인할 수 있다.
many-to-one 에 대해 @transient
와 RowMapper
를 이용해 연관관계 매핑 · rxdcxdrnine/relation-spring-data-jdbc@5
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
github.com
M:N 관계 매핑
M:N 관계는 관계형 데이터베이스에서 결합 테이블이라는 별도의 테이블을 만들고, 1:N 관계 2개로 나눠 생각해야한다. 예를 들어 book
과 author
테이블이 M:N 관계를 가지고 있다고 가정하자. book
과 author
테이블 사이에 결합 테이블 book_author
를 만들면, book
과 book_author
는 1:N 의 관계, author
와 book_author
는 1:N 관계를 가진다.
여기서 book
과 author
테이블에 대응하는 Book
과 Author
객체는 각각 다른 aggregate 에 속한다. 서로 다른 aggregate 에 속하는 엔티티가 있을 때 엔티티 간에 직접적으로 참조하기보다, 다른 엔티티의 Id 를 가지고 접근하는 방식을 사용해야 한다. 따라서 book
과 author
는 서로 직접적으로 참조할 수 없고, 서로의 Id 를 가지고만 접근해야한다.
먼저 Book
에서 Author
를 접근한다고 가정해보자. Book
과 BookAuthor
객체 사이에 1:N 관계 매핑을 하면 되는데, 여기서 Book
이 Author
객체를 참조하지만 직접 참조하지 못하고 Author
의 Id 를 가진 객체만 참조한다. 따라서 BookAuthor
대신에 AuthorRef
를 참조하는 것으로 생각하는 쪽이 더 편하다. (여기서 Book
과 Author
는 같은 패키지에 있으면 더 좋을 것이다.) 즉 Book
과 AuthorRef
는 같은 aggregate 에 속하고, 1:N 관계에 있으므로 Book
에서 AuthorRef
의 컬렉션을 속성으로 가진다.
그리고 Author
는 AuthorRef
와 독립적으로 author
테이블의 컬럼을 속성으로 가지는 객체이다. AuthorRef
는 여기에서 Id 필드만 가지는 객체이며, author
테이블이 아닌 book_author
테이블에 매핑되는 점이 다르다.
그리고 Book
조회 시 AuthorRef
와 함께 조회된 author
테이블의 Id 를 가지고 별도로 Author
를 조회해야한다. 다시 말해서 N:M 관계 매핑은 한 객체를 조회 시에 관계에 있는 다른 객체의 컬렉션을 조회할 수 없고, 별도의 쿼리로 데이터베이스를 조회한 뒤, 자바 애플리케이션 코드에서 직접 관계를 매핑해야 한다.
하지만 위의 코드로는 단순히 관계에 있는 Author
를 조회했을 뿐, Book
과 관계에 있는 Author
를 매핑하지 않았다. 관계에 있는 객체를 매핑하기 위해서는 좀 더 복잡한 처리가 필요한데, 자바 8의 스트림을 이용한 코드는 아래 커밋을 참고하자.
자바 애플리케이션에서 many-to-many 연관관계 매핑 · rxdcxdrnine/relation-spring-data-jdbc@179791f
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
github.com
이상으로 Spring Data JDBC 에서 1:1, 1:N, N:1, N:M 관계 매핑을 알아보았다. 아직 부실한 내용이 많아, 공부하면서 내용을 더 보충해야겠다.
'Java' 카테고리의 다른 글
코드로 스프링 트랜잭션 API 구조 보기 (0) | 2022.05.12 |
---|---|
열거형을 이용해 if-else 블록 제거하기 (0) | 2022.02.19 |
주소 변환 (address translation) 의 원리 (0) | 2022.01.20 |
JVM 과 스택 프레임 (Stack Frame) (0) | 2022.01.19 |
스트림 (Stream) API (0) | 2022.01.13 |