서론

설날을 대비해서 쿠폰을 이용한 프로모션 계획이 있었습니다.

프로모션 기능을 원활하게 진행하기 위해 대량 쿠폰 등록 기능을 빠르게 만들어줄 사람이 필요했고, 제가 해당 업무에 할당되어서 개발을 진행했습니다.

 

문제 사항

- 기존 쿠폰은 수동으로 최대 50개씩 상품이나, 브랜드를 등록할 수 있다.

- 수천개의 쿠폰을 일괄 등록할 시, (상품 개수 / 50) 번의 작업을 반복해야한다. (수천개라 생각하면...)

- 업무가 효율적이지 않고, 사람의 실수가 많이 발생할 수 있다.

 

 

개선 사항

- csv 파일 업로드를 통해 쿠폰을 일괄 등록을 할 수 있어야한다.


본론

 

해당 기능을 개발하는 도중 다른 서비스간의 통신이 필요했습니다.  그림으로 나타내면 다음과 같습니다.

MSA 환경에서는 각자의 DB를 갖고 있기 때문에, 필요한 데이터는 다른 서비스에 요청해서 받거나, CQRS 패턴을 이용해서 데이터를 조합하는 방법이 있습니다.

 

여기서는 다른 서비스에 HTTP를 통해 데이터를 요청, 응답 받았습니다.

그 후 응답 받은 데이터를 이용해서 벨리데이션이나, 기타 로직을 수행합니다.

 

여기서 고려할 점은, 어떻게 통신하느냐 였습니다. 대량의 데이터를 한 번에 처리하다보니까, 데이터를 너무 많이 보내서 응답하는 서비스에서 처리가 늦어져서, Read Timeout이 발생하거나, 메모리 리소스 등의 문제를 고려해야했습니다.

 


한 번에 많은 데이터 요청하기

 

http 요청을 할때 헤더 사이즈가 너무 클 경우, 데이터를 받는 서버측 톰켓에서는 Request header is too large 오류 메세지가 나옵니다.



이 크기에 대한 설정은 스프링 공식 문서에서 확인해보면, 스프링 부트에서 기본 설정으로 8KB로 설정이 되어있는 걸 확인할 수 있습니다.

이는 application.yml에서 다음과 같이 변경할 수 있습니다.

server.max-http-header-size=

하지만  지금 당장 이 설정을 다른 서비스에 요청해서 변경하거나, 이렇게 많은 데이터를 한 번에 요청해서 처리하는 것은 좋은 방향이 아닌 것 같습니다.

 

저는 데이터를 특정 개수로 청킹해서 분할해서 요청하는 방식을 선택했습니다.

 


동기 VS 비동기

 

동기

우선 일반적인 동기식으로 API를 호출하면 다음과 과정으로 수행됩니다.

HTTP 요청을 할당받은 스레드로 API 요청을 호출한 뒤, 해당 스레드는 응답을 받을 때 까지 블록킹됩니다.

응답을 받으면 그 다음 청킹 데이터를 보냅니다.

즉 시간 복잡도가 O(n)이 소요됩니다. 청킹 데이터가 많거나, 응답 시간이 길어질 수록 비효율적입니다.

 

 

비동기 동시 처리

 

때문에 저는 비동기 통신을 하기 위해서 CompletableFuture를 활용해서 병렬 처리했습니다.

코루틴을 활용하지 않았던 이유는 간단합니다. 이때 코틀린을 처음 써봤는데 기능일 빨리 배포해야돼서 익숙한 자바를 이용했습니다.

 

예시 코드를 보면 다음과 같습니다.

    fun foo(itemIds: List<Long>): List<Long> {
    	// 설정해 놓은 청크 사이즈로 분할
        val chunkedItemIds = requestItemIds.chunked(LIST_CHUNK_SIZE)

        // 비동기로 호출
        val futures = chunkedItemIds.stream()
            .map { CompletableFuture.supplyAsync({ itemApiCaller.getItemInfos(it) }) }
            .collect(Collectors.toList())

        val items: List<ItemResponse> = futures.stream()
            .map(CompletableFuture<List<ItemResponse>>::join)
            .flatMap(List<ItemDto.CouponInfoDetail>::stream)
            .collect(Collectors.toList())
            
       ...
       // do somheting
	}

 

CompletableFuture는 기본적으로 스레드 풀을 ForkJoinPool.commonPool()을 이용합니다.
기존 작업에서는 스레드 하나를 이용해서 요청 -> 블록킹 -> 응답을 반복 했지만, 이제는 스레드풀을 이용해서 병렬적으로 처리하게 됩니다. 물론 스레드 역시 블록킹되지 않습니다.

 

기존 동기식 처리에서는 총합 O(n)의 시간이 소요가 되지만, 이제는 스레드 풀을 이용해서 병렬적으로 한 번에 요청을 보내니, 체감상 O(1)에 가까운 퍼포먼스 개선을 할 수 있었습니다.

 

일정에 차질없이 배포를 진행했고, 덕분에 MD분들의 수고를 덜어줄 수 있어서 좋았습니다.