본문 바로가기
Spring

Spring Data R2DBC 연관관계 구현 - OneToMany (2)

by Heesu.lee 2021. 3. 31.

지난 번 작성된 R2DBC 에서 OneToMany 를 구현하는 부분을 이어서 작성한다.

 

지난 첫 게시글에선 DatabaseClient 로 별도의 Custom Repository 를 정의하였고,

해당 부분에서 Member 와 Order 를 JOIN 하여 처리하였다.

 

참고 - heesutory.tistory.com/33

 

Spring Data R2DBC 연관관계 구현 - OneToMany

R2DBC 는 ORM 이 아니기 때문에 JPA 와는 다르게 지원되지 않는 기능들이 몇가지 있다. 대표적으로 연관관계 매핑이 그러하다. Spring Data R2DBC aims at being conceptually easy. In order to achieve this it..

heesutory.tistory.com

하지만 위와 같이 OneToMany 관계에서 JOIN 하는 경우 Many 쪽 데이터 개수에 맞게 ROW 개수가 늘어나게 된다. (일명 뻥튀기)

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

 

SQL 실행 결과

위와 같은 중복은 SQL DISTINCT 키워드로도 제거되지 않지만,

JPA 와 같은 ORM 을 사용하여(Entity Graph, QueryDSL) OneToMany 관계를 요청할 시 해당 부분을 보다 손쉽게 해결할 수 있다.

 

하지만, R2DBC 는 ORM 이 아니기에 해당 부분을 개발자가 직접 작성해주지 않으면 안된다.

이번 게시글에선 WHERE 절 IN 을 활용하여 OneToMany 로직을 서비스 계층에서 구현하는 것을 공유하고자  한다.

 

사용된 Entity 는 앞서 소개한 게시글과 동일하게 사용하였다. (Order 와 Member - OneToMany 관계)

 

변경된 OrderRepository 만 소개하도록 하겠다.

OrderRepository

public interface OrderRepository extends ReactiveCrudRepository<Order, Long>, CustomOrderRepository {

    @Query("select orders.* from orders where member_id in (:memberIds)")
    Flux<Order> findAllWithMembers(Set<Long> memberIds);
    
}

 

지난번과 다르게 @Query 어노테이션을 통해 작성된 메서드가 추가되었다.

해당 메서드를 살펴보면 필요한 Member ID 들을 컬렉션으로 모아 IN 절의 파라미터로 넘기는 방식이다.

 

그렇다면 Member 는 어떤 식으로 데이터를 가져오는지 살펴보도록 하겠다.

MemberDomainService

@Service
@RequiredArgsConstructor
public class MemberDomainService {

    private final MemberRepository memberRepository;
    private final OrderRepository orderRepository;

    @Transactional(readOnly = true)
    public Flux<Member> findAllMembers() {
        return memberRepository.findAll()
                .collectMap(Member::getId)
                .flatMapMany(members -> orderRepository.findAllWithMembers(members.keySet())
                    .bufferUntilChanged(Order::getMember)
                    .map(orders -> members.get(orders.get(0).getMember()).update(orders)));
    }
}

 

로직은 다음과 같이 수행되어진다.

Member 를 조회하는 서비스 로직에서 1차적으로 필요한 Member 를 모두 SELECT 한 뒤 해당 Member 들의 ID 를 모아, 각 Member 를 참조하는 Order 를 WHERE IN 문을 활용하여 받아와 Member 에 추가해준다.

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 Roles roles;

    @Transient
    private List<Order> orders;

    public Member update(List<Order> orders) {
        this.orders = orders;
        return this;
    }
}

 

자신에게 필요한 Order 리스트를 업데이트한 후 변경된 자신을 반환할 수 있도록 구성하였다.

Test Code

@SpringBootTest
class MemberDomainServiceTest {

    @Autowired MemberDomainService domainService;

    @DisplayName("전체 멤버 조회 with 주문 (WHERE IN)")
    @Test
    void findAllMembersTest() {
        // given

        // when
        List<Member> members = domainService.findAllMembers()
                .collectList()
                .block();

        // then
        assertNotNull(members);

        members.forEach(member -> System.out.println("member = " + member));
    }
}

테스트 결과

각 Member 에 필요한 Orders(주문 리스트) 가 같이 조회되는 것을 확인할 수 있다.

 

결론적으로, 지난번 소개한 방식과 결과만 보았을 땐 동일하지만, 실제 동작하는 원리는 다르다.

 

지난번은 데이터가 뻥튀기 되더라도 JOIN 하여 쿼리 한번에 받아서 처리를 하였지만, 이번 로직은 쿼리를 2번 요청하여 필요한 데이터를 받아 처리를 하였다.

 

ManyToOne 관계라면 JOIN 을 사용하는게 맞지만 OneToMany 에서의 JOIN 은 불필요한 데이터 중복이 일어나기에, 개인적으론 해당 방식이 쿼리를 두번 사용하더라도 OneToMany 에 보다 더 적합한 방식이라 생각한다.

 

그리고 가장 크게 마음에 들었던 부분은 정말 정말 귀찮은 필드 맵핑 로직을 따로 작성해주지 않은 점이다...

 

 

주니어 개발자이기에 부족한 점이 많습니다. 틀린 부분이나 보충할 내용에 대해 공유주시면 감사하겠습니다.

 

댓글