네? Webflux에선 Pagination을 못쓴다고요? 그럼 만들지 뭐...

Eric·2023년 2월 18일
2
post-thumbnail

⚠️ 해당 게시글은 필자가 마이다스아이티 인턴십을 진행하며 겪은 문제들을 해결하는 내용을 담았으며, 보안 상 문제되지 않는 선의 내용까지만 공개한다는 조건으로 멘토님께 작성/게시 허가를 받았습니다.

🤔 문제 상황

개발 도중 발생한 문제

마이다스아이티 인턴십 과제를 수행하던 도중, 페이지네이션을 구현해야 하는 상황이 생겼다.

Spring MVC를 사용하던 평소라면 JPA에서 제공하는 JpaRepository를 이용해 손쉽게 구현할 수 있는 기능이었지만, 이번에는 Webflux와 R2DBC를 이용하는 프로젝트였기에 페이지네이션에 대한 자료를 검색해 보았다.

그런데...

공식 문서와 각종 블로그, Stack Overflow 등지에서 찾아본 결과, R2DBC에서는 PagingAndSortRepository와 같은 레포지터리 인터페이스가 존재하지 않으며 Mono<Page<T>>와 같은 반환값도 지원하지 않는다고 한다.

각종 개발 커뮤니티에서는 (findAll 쿼리의 경우) count() 메서드를 사용하거나, 이외의 경우 카운트해주는 메서드를 레포지터리에 추가로 생성하는 방법만을 대안으로 제시했다.

결국, 그 방식대로 코드를 작성했고 잘 작동하는 것까지 확인했으나...

이런 방식은 생산성을 망치는 주범이었고, 심지어 메서드가 2개가 되니 WHERE 문과 같은 조건이라도 변경되는 날에는 메서드 내용을 모두 변경하는 것을 까먹어서 버그를 발생시킬 수도 있는 것이었다.

다른 해결책을 만들어 내야만 했다.

왜 R2DBC는 Page<T>를 지원하지 않을까?

해결책을 만들기에 앞서, 나는 R2DBC에서 제공하는 레포지터리가 Page<T> 반환을 지원하지 않는 것에 대해 몇가지 가설들을 세워보았다.

가설 1. Page<T> 인터페이스는 Reactive Programming과 어울리지 않는다.

Page<T> 인터페이스는 Spring Data Commons라는 종속성에 포함되어 있다. 그런데 이 라이브러리는 기존 MVC에서든 Webflux에서든 모두 공통적으로 포함된 종속성이다. 즉, 이론대로라면 Webflux에서도 Page<T> 인터페이스를 지원해 주는게 정상처럼 보인다.

하지만, Page<T> 인터페이스의 Javadoc과 그 구현체인 PageImpl<T> 클래스의 Javadoc에서 주요 메서드들을 찾아보면...

  • List<T> getContent()
  • long getTotalElements()
  • int getTotalPages()

당연하지만 Mono나 Flux를 사용하지 않는다.

여기서 "아니, R2DBC에서 카운트 쿼리랑 데이터 조회 쿼리를 다같이 날린 뒤 zipWith을 사용하면 되는 거 아니냐"라고 생각할 수 있다. 사실 나도 처음에는 그렇게 생각했지만, 이내 그렇게 해서 Mono<Page<T>가 지원될 경우 오히려 처리가 곤란해질 수 있음을 깨달았다.

예시를 하나 들어보겠다. 모든 부서들을 조회한 뒤, DTO로 변환하여 반환하는 Service 로직을 작성해야 한다 가정해 보자. 위와 같이 페이지네이션이 지원된다면 아마도 이런 코드가 작성될 것이다.

@Getter
@Builder
public class PageResponse<T> {
	private int allPages; // 전체 페이지 갯수
	private int allItems; // 모든 아이템 갯수
    private List<T> list; // 결과값
}

@Getter
@Builder
public class DepartmentResponse {
	private long id; // 부서 아이디
	private String name; // 부서명
    ...
}

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class DepartmentService {
	private final DepartmentRepository departmentRepository;
    
    public Mono<PageResponse<DepartmentResponse>> getAllDepartments(int page) {
    	Mono<Page<DepartmentEntity>> departments = departmentRepository.findAll(PageRequest.of(page, 30)); // 한 페이지 당 30개씩 조회
        
        return departments
        	.map(page -> PageResponse.<DepartmentResponse>builder()
            	.allPages(page.getTotalPages())
                .allItems(page.getTotalElements())
                .list(page.getContent().map(department ->
                	DepartmentResponse.builder()
                    	.id(department.getId())
                        .name(department.getName())
                        ...
                    	.build()
                ))
            	.build());
    }
}

이런, 간단한 연산인데도 이중 람다함수가 나와버렸다. 메서드를 추출해 내는 방식으로 메서드 내에서의 가독성은 향상할 수 있을지 몰라도, 결국 귀찮은 일임이 틀림없다.

이 정도면 할만 하다고? 사실 맞는 말이다. 하지만 조회된 값을 다른 것들과 Reactive Operation을 통해 합쳐야 한다면?

Mono.just()Flux.just()를 이용해 다시 Reactive Stream으로 변환하는 등... 귀찮은 작업들에 벌써부터 머리가 아파올 것이다.

가설 2. 성능 문제

사실 가설 1번의 연장선이다.

R2DBC는 Reactive Programming을 위해 개발되었다. 즉 성능을 중요시하는 측면이 있는데, 해당 기능을 지원하려면 다음과 같은 과정을 거쳐야 한다.

  1. 데이터 조회 쿼리와 카운트 쿼리를 생성한 뒤, 논블로킹으로 DB에다 요청한다.
  2. 데이터 조회 쿼리는 Flux<T>, 카운트 쿼리는 Mono<Integer/Long>으로 반환될텐데 Page<T>는 각각 List<T>Integer/Long을 요구하므로 대충 이런 코드가 나올 것이다.
    public <T> Mono<Page<T>> resultsToPage(Flux<T> entityFlux, Mono<Long> countMono) {
    	entityFlux.collectList() // Flux<T>가 Mono<List<T>>가 된다
        	.zipWith(countMono) // Mono<Tuple<List<T>, Long>>으로 만든다.
            .map(tuple -> new MyPageImpl(tuple.getT1(), tuple.getT2()));
    }

여기까지는 아무런 문제가 없어 보이지만, 가설 1에서 언급한 코드와 같은 동작들을 수행하면 데이터는 Flux<T>, Mono<Long> -> Page<T> -> Mono<PageResponse>와 같이 불필요한 변환 과정을 유발하게 된다.

이런 방식이 불필요한 성능 저하를 일으키는 것은 물론, Reactive Programming의 취지와도 맞지 않다고 생각되어 개발자들이 지원을 하지 않는 것일수도 있다.

가설 3. 그냥 귀찮아서

설마 아니겠지...?

💻 해결책

데이터 조회 쿼리와 카운트 쿼리 직접 작성하기

사실 가장 간단하면서도 귀찮은 방법이다.

바로 데이터를 조회하는 쿼리와 row의 갯수를 카운트하는 쿼리를 일일히 작성하는 것이다. 예를 들어, department 테이블에 페이지네이션을 적용하고 싶다면:

SELECT * FROM department LIMIT 0, 3; # 0번째부터 10개의 row 조회
SELECT COUNT(*) FROM department; # 전체 row 조회

위처럼 2개의 쿼리를 @Query 어노테이션을 써서 Native Query로 구현하든, 아니면 메서드명을 이용해서 R2DBC가 쿼리를 생성하게 만들든 해야 한다는 것이다.

어찌 되었든 조회 기능을 만들 때마다 메서드가 2개씩 생기는 상황이니, 생산성 측면에서든 가독성 측면에서든 좋지 않은 방법인 것이다.

레포지터리 구현체를 직접 만들기

가설 1번과 2번을 생각하던 도중, "그러면 Flux와 Mono를 담은 객체를 반환하도록 레포지터리를 만들면 되는 거 아냐?"라는 생각을 했다.

그렇게, 나만의 레포지터리 구현체 만들기가 시작되었다.

1. JPA의 동작원리 찾아보기

일단 JPA나 R2DBC가 레포지터리 구현체를 어떻게 만드는지 찾아봐야 했다.

리플렉션과 Dynamic Proxy를 이용한다는 건 이미 알고 있었지만, 구현을 위해서는 더 자세한 정보가 필요했다.

찾아보니, JPA는 다음과 같은 순서로 구현체를 만든다고 한다.

  1. 프록시 만들때 쓸 연장(?) 챙기기
    • Java에서는 프록시를 만들 대상에 따라서 사용해야 하는 연장이 달라진다. Interface의 경우에는 JDK에서 제공하는 Dynamic Proxy를 쓸 수 있지만, Class의 경우에는 CGLIB라는 외부 라이브러리를 사용해야 한다.
    • Spring AOP에서는 대상이 인터페이스인지 클래스인지를 자동으로 인식해서, 프록시를 만들 때 사용할 연장을 고른다.
  2. 프록시 만들기
    • 프록시를 만드려면, 메서드가 호출되었을 때 이에 응답해 줄 수 있는 핸들러가 있어야 한다.
    • InvocationHandler라는 인터페이스가 요구된다.
    • Spring에서는 JdkDynamicAopProxy라는 구현체가 들어가는데, 이 구현체를 생성할 때 AdvisedSupport라는 녀석을 통해서 Advice(동작)들을 설정하게 해준다고 한다.
    • 아마 쿼리문 호출에 필요한 종속성(DB Connection 등)도 이 때 Advice를 통해 반입되는 듯?
  3. 프록시를 Bean으로 등록하기
    • 이렇게 만들어진 Proxy는 레포지터리의 구현체로서 IoC Container에 등록된다.
    • 내 생각에는 BeanFactoryPostProcessor를 사용해서 등록하지 않을까 추측하는데.. 정확히는 잘 모르겠다.

이때 "뭐야, 생각보다는 간단하잖아?" 이 생각을 했었는데, 나중에 보니 절대 해서는 안되는 생각이었다...

2. 구현하기

예제에 있는 코드는 마이다스아이티 인턴십에서 사용된 코드와는 다른 코드입니다. (아무리 과제라지만 코드 유출하면 보안팀에 잡혀갈지도?)

JPA의 작동 방식을 토대로 나만의 레포지터리 구현체 만들기에 들어갔다.

먼저, 나만의 레포지터리 인터페이스 하나를 만들었다. 참고로 여기에는 메서드 정의를 하거나 다른 Repository 인터페이스를 상속할 필요가 없다. 굳이 해봤자 프록시 생성할 때 귀찮아지기 때문이다. (사실 null 반환시키거나 예외 던지면 되기는 한데, 깔끔하지 않은 방법이다)

interface ReactivePagingRepository<T> {}

이후, BeanFactoryPostProcessor의 구현체를 만들어서 Bean 등록 과정에 관여할 수 있도록 설정했다.

@Configuration
public class ReactiveRepositoryBeanConfiguration implement BeanFactoryPostProcessor {
	@Override
	protected void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
    	...
    }
}

그런 뒤 Reflections 라이브러리를 사용하여 ReactivePagingRepository<T>를 상속하는 모든 인터페이스들을 스캔했다. (이거 없이도 가능하긴 한데, 대신 난이도가 급상승한다)

이제, InvocationHandler의 구현체를 작성하고 프록시를 만들어 주기만 하면 되는데...

3. 근데.. DB Connection은 어디서?

구현체를 작성하려면 R2DBC의 ConnectionFactory가 필요했다. 그래서 생성자를 이용한 DI 코드를 구성하였다. 근데.. 생각해보니 한가지 교착상태에 빠졌다.

해당 기능을 구현하려면 BeanFactoryPostProcessor를 이용해야 하는데, 이 타입은 특이하게도 다른 Bean들과는 달리 다른 Bean들을 DI받을 수 없다. (애초에 Bean을 Initialize할 때 사용하는 녀석이라서 그렇다)

어떻게 해도 ConnectionFactory을 DI받지 못하는 상황이 된 것이다. R2DBC나 JPA의 코드를 뜯어보기도 했지만 너무 양이 방대해 찾기도 어려웠다.

AbstractDependsOnBeanFactoryPostProcessor라는 추상 클래스가 있길래 혹시나 하고 사용해 보았지만, 작동하지 않았다. (거기에다 구글에서 관련 레퍼런스가 JavaDoc밖에 없었다;;)

좌절에 빠진 채로 1시간 정도 지나고 보니, 한가지 좋은 아이디어가 떠올랐다.

"어차피 InvocationHandler의 invoke()가 실행되는 시점에는 Bean이 초기화 되었을텐데, 그때 BeanFactory에서 가져오면 안되나?"

InvocationHandler에 ConnectionFactory를 넘기는 대신, 그 빈을 가져올 수 있는 BeanFactory를 넘기는 방법이었다. 실험해보니 놀랍게도 아주 잘 작동했다.

그래도 BeanFactory를 그대로 넘기는 건 좀 그런 거 같아, ConnectionFactory만 가져올 수 있는 Wrapper 클래스를 제작하고 그 클래스를 InvocationHandler에다가 넘기는 방식으로 구현했다.

4. 구현체 코드 작성

겨우 어려운 난관을 해쳐나간 뒤, 본격적인 구현체 작성에 들어갔다.

원래는 메서드 이름으로 쿼리를 만들어주는 기능도 구현해야 했었지만, 그렇게 하기에는 시간이 부족해서 @Query(String sql) 어노테이션만 지원하기로 했다.

Method.getDeclaredAnnotation()을 이용해 SQL 쿼리를 가져오고, 그 쿼리를 바탕으로 Count 쿼리를 추가로 만드는 코드를 작성했다.

이후, 아래와 같은 클래스를 만들어 카운트 Mono와 데이터 Flux를 반환하도록 했다.

@Getter
@AllArgsConstructor
public class ReactivePage<T> {
	@Getter
    @AllArgsConstructor
	public static class ReactivePageInfo {
    	private final int allPages;
        private final int allElements;
        private final boolean hasNext;
    }
    
    private final Mono<ReactivePageInfo> pageInfo;
    private final Flux<T> content;
}

5. 작동 테스트

보안 때문에 작동하는 결과를 이미지로 보여줄 수는 없지만, 성공적으로 작동하는 것을 확인할 수 있었다. 다만 리플렉션을 이용하는 방식이라 성능이 조금 저하된 느낌은 존재한다.

그래도 어노테이션과 같은 곳에 Map을 이용한 쿼리 캐싱을 적용한다면, 성능 향상이 가능할 것으로 보인다.

👋 마무리하며

마이다스아이티 인턴십을 진행하면서 겪은 문제점, 그리고 해결책에 대해서 정리해 보았다. 사실 이 문제에 앞서 몇가지 문제들이 있긴 했지만, 이게 가장 기억에 남는지라 먼저 올리게 되었다.

다음 시간에는 CodeDeploy 사용 경험이나, SMTP 서버 설정에 대한 이야기를 올려보려 한다.

profile
Backend Engineer | 코드로 우리의 세상을 어떻게 바꿀 수 있는지 고민합니다

3개의 댓글

comment-user-thumbnail
2023년 2월 19일

잘 읽었는데 한 가지 궁금한 사항이 있습니다. (제가 직접 해본 건 아니라서 확신은 없는데요) 이 글 과 같은 방법을 사용하지 않으신 이유가 궁금합니다.

1개의 답글