R2DBC 는 ORM 이 아니기 때문에 JPA 와는 다르게 지원되지 않는 기능들이 몇가지 있다.
대표적으로 연관관계 매핑이 그러하다.
Spring Data R2DBC aims at being conceptually easy. In order to achieve this it does NOT offer caching, lazy loading, write behind or many other features of ORM frameworks. This makes Spring Data R2DBC a simple, limited, opinionated object mapper.
JPA 사용자였다면, 다양한 연관관계를 어노테이션을 통해 명시해주고 JPQL, EntityGraph, QueryDSL 을 이용하여 조인하여 편리하게 사용해왔지만, R2DBC 는 해당 기능을 구현하기 위해 개발자가 수동적으로 작성해줘야 하는 것들이 많다.
이번 글을 통해서 기존 OneToMany 관계를 R2DBC 로 구현할 수 있는지 작성해보도록 하겠다.
이번 게시글에서 사용될 테이블은 다음 아래와 같다.
위에서 Member 와 Orders 는 일대다 (OneToMany) 관계를 갖는다.
이번 게시글에선 R2DBC 에서 제공되는 DatabaseClient 를 이용한 Custom Repository 를 정의하여 해당 기능을 구현한다.
DatabaseClient 사용에 있어 주의해야할 점은 Spring Boot 2.4 버전부터는
기존 spring data 에서 제공되는 DatabaseClient 는 deprecated 됐기 때문에 spring r2dbc 에서 제공되는 DatabaseClient 를 사용해야한다.
With spring-boot 2.4 (and spring 5.3 and spring-data-r2dbc 1.2), org.springframework.data.r2dbc.core.DatabaseClient from spring-data-r2dbc is deprecated in favor of org.springframework.r2dbc.core.DatabaseClient of spring-r2dbc - which has a different API.
우선 작성된 Entity 와 Repository 를 살펴보도록 하겠다.
Member
@Table
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class Member {
@Id
@Column("member_id")
private Long id;
@Column("member_name")
private String name;
@Column("member_role")
private Role role;
@Transient
private List<Order> orders;
}
public interface MemberRepository extends ReactiveCrudRepository<Member, Long>, CustomMemberRepository {
}
여기 Member 에서 살펴볼 부분은 자신을 참조하는 Order 객체 리스트를 가지고 있다는 점이다.
다만 이 필드는 실제 DB 상에서 노출되지 않기 때문에 @Transient 어노테이션을 달아준다.
Member Repository 의 경우 OneToMany 를 직접 구현해줄 Custom Repository 를 사용할 수 있도록 추가해주었다.
Order
@Table("orders")
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class Order {
@Id
@Column("order_id")
private Long id;
@Column("member_id")
private Long member;
@Column("order_name")
private String name;
@Column("order_status")
private OrderStatus status;
@Transient
private Member user;
}
public interface OrderRepository extends ReactiveCrudRepository<Order, Long>, CustomOrderRepository {
}
Order 에서 살펴볼 부분은 실제 DB 에서 order 란 키워드가 사용되기 때문에 DB 에선 orders 란 이름을 참조할 수 있도록 명시하였다.
이외에 양방향 매핑을 위하여 실제 DB 엔 반영되지 않지만, 자신이 참조하는 Member 객체를 필드로 가지고 있다. (@Transient)
다만, 이번 게시글은 OneToMany 만을 다루기에 해당 부분에 대해선 더이상 사용하지 않겠습니다.
Order Repository 의 경우에도 ManyToOne 관계를 구현하기 위하여 Custom Repository 를 추가하였지만 해당 게시글에선 따로 다루지 않도록 하겠습니다.
다음으로는 실제 DatabaseClient 를 활용한 Member Custom Repository 를 살펴보도록 하겠다.
Custom Member Repository
public interface CustomMemberRepository {
Mono<Member> findByIdWithOrders(Long id);
}
@Repository
@RequiredArgsConstructor
public class CustomMemberRepositoryImpl implements CustomMemberRepository {
private final DatabaseClient client;
@Override
public Flux<Member> findAllWithOrders() {
String query =
"SELECT " +
"m.member_id, m.member_name, m.member_role, " +
"o.order_id, o.order_name, o.order_status " +
"FROM member as m " +
"LEFT OUTER JOIN orders as o " +
"ON m.member_id = o.member_id";
return client.sql(query)
.fetch()
.all()
.bufferUntilChanged(result -> result.get("member_id"))
.map(rows ->
Member.builder()
.id((Long) rows.get(0).get("member_id"))
.name(String.valueOf(rows.get(0).get("member_name")))
.roles(Roles.valueOf(String.valueOf(rows.get(0).get("member_role"))))
.orders(
rows.stream()
.map(row -> Order.builder()
.id((Long) row.get("order_id"))
.member((Long) row.get("member_id"))
.name(String.valueOf(row.get("order_name")))
.status(OrderStatus.valueOf(String.valueOf(rows.get(0).get("order_status"))))
.build())
.collect(Collectors.toList())
)
.build()
);
}
}
DatabaseClient 를 활용하여 OneToMany 관계를 구현하는 경우 직접 쿼리를 정의하여 요청 후 맵핑을 직접 해주는 방식으로 구성되게 된다.
참고한 글들에선 직접적인 Mapper, Converter 를 정의하여 같이 활용하였지만, 현재의 경우 필드가 적어 따로 사용하지 않았다.
해당 과정에서 SELECT 문에서 만약 직접 필요한 모든 컬럼을 명시하지 않는 경우 재대로 매핑이 되지 않는 이슈가 발생하였다.
비단, 조인 쿼리 뿐만 아니라 단순 SELECT 쿼리 작성 시에도 해당 문제가 발견되었다. ( SELECT * FROM 등 )
Test Code
@DataR2dbcTest
class MemberRepositoryTest {
@Autowired MemberRepository memberRepository;
@DisplayName("전체 멤버 조회 with 주문 리스트")
@Test
void findAllWithOrdersTest() {
// given
// when
List<Member> members = memberRepository.findAllWithOrders()
.collectList()
.block();
// then
assertNotNull(members);
assertAll(() -> {
members.forEach(member -> assertNotNull(member.getOrders()));
});
members.forEach(member -> System.out.println("member = " + member));
}
}
결과
DatabaseClient 를 이용하여 직접 추가적인 Repository 를 구성하여 OneToMany 연관관계를 구현해보았다.
문자열 쿼리를 이용한다는 부분 (만약, 쿼리 잘못 작성 시 컴파일 단계, 로딩 시점에서 에러를 알 수 없음), 가장 불편했던 엔티티 맵핑 작업 등 (필드가 별로 없음에도 현재도 굉장히 불편하다) 실사용하기엔 사실 불편한 부분들이 있다.
그저 R2DBC 를 통해 연관관계를 직접 구현할 수 있음으로 알고 있고, 정말 필요한 경우에 사용하면 좋을 것 같다.
참조
※ 주니어 개발자이기에 부족한 점이 많습니다. 틀린 부분이나 보충할 내용에 대해 공유주시면 감사하겠습니다.
'Spring' 카테고리의 다른 글
Spring Data R2DBC 연관관계 구현 - ManyToOne (0) | 2021.04.02 |
---|---|
Spring Data R2DBC 연관관계 구현 - OneToMany (2) (0) | 2021.03.31 |
Spring Data R2DBC 사용 (0) | 2021.03.28 |
Custom Validation 사용해보기 (0) | 2021.03.20 |
빈 스코프 (0) | 2020.12.26 |
댓글