현재 나는 독크독크라는 독서모임 플랫폼을 개발하는 프로젝트에 참여하고 있다.
독크독크는 독서모임을 진행하는 사람들을 위한 플랫폼으로,
모임 중 나눈 대화와 생각들이 모임이 끝난 뒤 단순한 기억으로 사라지지 않고
개인과 모임의 기록으로 남을 수 있도록 돕는 서비스이다.
사용자는 독서모임에 참여할 수 있고,
각 모임에서는 여러 회차별 약속이 생성된다.
모임원들은 자신이 원하는 회차에 신청해 참여할 수 있다.

이번 포스팅에서는 서비스에서 생성되는 약속 단위의 상세 정보를 조회하는 API를 중심으로 살펴보려고 한다.
아래 이미지는 약속 상세 정보를 조회하는 API에 대한 페이지이다.
사용자는 이 페이지에서 약속의 전반적인 정보와 함께
참여 멤버들의 프로필 정보를 한눈에 확인할 수 있다.
이 페이지의 특징은 다음과 같다.
이 페이지에서 성능상 문제가 될 수 있는 부분은
약속 멤버들의 프로필 이미지 presigned URL 생성 로직이다.
독크독크 플랫폼에서는 사용자 프로필 이미지를 MinIO 기반의 외부 스토리지에 저장하고 있으며,
클라이언트에서 이미지를 직접 조회할 수 있도록
서버에서 presigned URL을 생성해 전달하는 방식을 사용하고 있다.
이제 어떤 부분이 문제라고 느꼈는지 실제 코드 흐름을 통해 살펴보자.
멤버들의 정보를 가져오기 위해 약속ID 기준으로 멤버 정보를 가져오고
프로필 이미지 프로필 이미지 presigned URL을 생성하는 순서이다.

아래는 약속 상세 페이지 조회 시 실행되는 서비스 코드 중
약속 멤버들의 프로필 이미지 presigned URL을 생성하는 로직이다.
해당 메서드는 다음과 같은 흐름으로 동작한다.
1. 약속에 참여한 모든 멤버를 순회한다.
2. 각 멤버의 프로필 이미지 경로를 조회한다.
3. MinIO에 presigned URL 생성을 요청한다.
4. 생성된 URL을 사용자 ID 기준으로 Map에 담아 반환한다.
앞서 살펴본 코드 흐름을 성능 관점에서 분석해보면
몇 가지 주목할 만한 구조적 특징이 있다.
프로필 이미지 presigned URL 생성은 외부 스토리지(MinIO)에 대한 네트워크 I/O 작업이다.
이 작업은 멤버 수만큼 반복 호출되는데
각 호출은 서로 독립적이고, 실행 순서에 의존하지 않는다.
즉, 현재 구조는 순서가 필요 없는 외부 I/O 작업을 N번 순차적으로 실행하는 형태라고 볼 수 있다.
그럼 굳이 순차 처리할 필요가 있을까 ?
이 지점을 병렬 처리로 개선하면, 실제 사용자 체감 성능에도 차이가 날 수 있다는 생각이 들었다.
이러한 문제 인식을 바탕으로,
해당 로직을 비동기로 전환했을 때 어느 정도 성능 향상이 발생하는지,
그리고 사용자 경험 측면에서 의미 있는 개선으로 이어질 수 있는지를 직접 확인해보았다.
각 URL 생성 요청은 서로 의존성이 없는 독립적인 외부 I/O 작업이기 때문에,
동기 → 비동기 전환에 대표적인 방법인 @Async와 CompletableFuture를 활용해 병렬 처리 구조로 개선할 수 있다.
이를 적용하기 전 개선의 핵심이 되는 두 가지 개념을 간단히 정리해보자.
@Async와 CompletableFuture@Async
Spring에서 제공하는 비동기 처리 어노테이션으로,
메서드에 적용하면 별도의 스레드에서 비동기로 실행된다.
해당 메서드는 호출 즉시 반환되고
실제 로직은 Spring이 관리하는 스레드 풀에서 수행된다.
여러 개의 독립적인 작업을 동시에 실행하고 싶을 때 유용하다.
CompletableFuture
비동기 작업의 결과를 담는 객체로,
작업 완료 시점을 기준으로 후속 처리를 연결할 수 있다.
비동기 작업의 완료를 기다릴 수 있고,
여러 비동기 작업을 조합할 수 있으며,
모든 작업이 끝난 시점을 한 번에 처리할 수 있다
(1)
@Async적용
각 기록 타입을 조회하는 서비스 메서드에 @Async를 적용하여
동기 방식으로 실행되던 로직을 비동기 방식으로 변경한다.
@Async를 지정함으로써 해당 메서드는 비동기적으로 실행된다.
이로 인해 호출하는 쪽에서는

(2)
CompletableFuture적용
각 비동기 메서드는 CompletableFuture를 반환하도록 변경하였다.
생성한 CompletableFuture를 Map에 저장한 뒤,
CompletableFuture.allOf()를 사용해 한 번에 실행하고 결과를 취합한다.
이를 통해 여러 비동기 작업을 동시에 수행하고,
모든 작업이 완료된 시점에 결과를 한 번에 처리할 수 있다.
결과적으로 이전에는 순차적으로 수행되던 프로필 presigned URL 생성 로직이 병렬로 실행되도록 개선되었다.

(1) 테스트 데이터 설정
이번 테스트에서는 실제 서비스에서 발생할 수 있는 상황을 최대한 가깝게 가정하여 테스트 데이터를 구성하고자 했다.
독서모임이라는 도메인 특성상,
한 번의 모임에 수백 명이 동시에 참여하는 경우는 현실적으로 드물다고 판단했다.
따라서 단순히 데이터 양을 늘리기 위해 100명, 200명 단위의 데이터를 사용하는 것은 이번 테스트의 목적과는 맞지 않다고 생각했다.
실제 독서모임에서 원활한 대화와 참여가 가능할 만한 규모를 기준으로 고민한 결과,
약속에 참여하는 멤버 수를 최대 15명으로 설정하고 관련 데이터를 구성하였다.
테스트에 사용한 데이터 구성은 다음과 같다.
이와 같은 설정을 통해
실제 서비스 환경에서 충분히 발생할 수 있는 조건에서
약속 상세 조회 API의 동작과 성능을 확인하고,
동기 처리와 비동기 처리 방식의 차이를 비교해보고자 했다.
(2) 측정 환경 설정
네트워크 상태는 항상 일정하지 않기 때문에
실제 외부 스토리지를 그대로 호출할 경우,
동기 방식과 비동기 방식의 성능을 안정적으로 비교하기 어렵다.
요청 시점이나 네트워크 상황에 따라 응답 시간이 매번 달라질 수 있어,
측정 결과가 구조 차이가 아닌 외부 환경의 영향을 받게 되기 때문이다.
그렇기 때문에 이번 테스트에서는
네트워크 환경에 따른 변수를 최대한 제거하고,
순차 실행과 병렬 실행 구조의 차이만을 검증하는 데 초점을 맞췄다.
이를 위해서 Presigned URL 생성 로직을 Mock 처리하고,
모든 요청이 네트워크 I/O 비용이 50ms인 상황을 가정하도록 동일한 지연 시간을 부여했다.
이렇게 함으로써,
동기 방식과 비동기 방식 간의 성능 차이를 보다 명확하고 일관되게 비교할 수 있도록 하였다.

(3) 워밍업 + 반복 측정
성능 측정 시 초기 실행 비용에 영향 받지 않기 위해
워밍업 5회, 반복 측정 10회로 테스트를 구성했다.

이를 통해 일시적인 편차를 줄이고, 안정적인 평균 성능을 비교할 수 있도록 했다.
동기 방식 테스트에서는 기존의 순차 실행 메서드를 호출하고,
비동기 방식 테스트에서는 비동기 처리를 적용한 메서드를 호출한다.
응답 시간 측정은 Micrometer의 Timer를 사용하여
각 방식별 평균 실행 시간(mean)을 기준으로 비교했다.

동기 방식에서 비동기 방식으로 개선한 결과,
약속 상세 조회 API의 평균 응답 시간이 약 917ms → 153ms로 단축되었다.
비동기 방식이 동기 방식 대비 약 17% 시간만 소요한 것으로
약 83% 응답 시간 감소했다.
presigned URL 생성 로직이 멤버 수만큼 순차적으로 실행되던 구조에서
여러 개의 외부 I/O 요청이 병렬로 처리되도록 변경되면서
외부 스토리지 응답 대기 시간이 전체 응답 시간에 미치는 영향을 크게 줄일 수 있었다.
단일 요청 내에서 다수의 외부 I/O가 포함된 구조에서는
비동기 처리만으로도 충분히 의미 있는 성능 개선이 가능함을 확인할 수 있었다.
비동기 처리는 기존에 순차적으로 수행되던 작업을
동시에 얼마나 효율적으로 처리할 수 있는지를 고민하는 접근이다.
반면 캐시는 해당 작업을 사용자 요청마다 반복 수행해야 하는지,
재사용 가치가 있는지를 먼저 판단하는 방식이다.
이번 케이스에서 내가 비동기를 선택한 이유는
presigned URL 생성 자체의 연산 비용이 크다기보다는
직렬 구조에서 발생하는 불필요한 대기 시간이 병목의 핵심이라고 판단했기 때문이다.
Presigned URL은 본질적으로 만료 시간을 가지는 값이며,
만료 이후에는 반드시 재생성이 필요하다.
따라서 캐시를 적용하더라도 TTL 관리, 만료 시점 동기화 등의 추가적인 고려가 필요하다.
또한 약속 상세 페이지의 특성상 동일 약속을 짧은 시간 내에 반복적으로 조회할 가능성은 높지 않다고 보았다.
이 경우 캐시 히트율이 충분히 높지 않을 수 있으며, 결국 상당수 요청은 여전히 URL을 새로 생성해야 한다.
이러한 점을 종합했을 때,
이번 문제는 “계산을 줄이는 문제”라기보다
“대기 시간을 줄이는 문제”에 가까웠다고 판단했다.
따라서 캐싱보다는 직렬 I/O 구조를 병렬화하는 비동기 전환이 더 본질적인 개선 방향이라고 생각했다.
이번 포스팅을 위해 비동기에 대해 공부하면서
코드 상에서는 단순한 반복 작업처럼 보이더라도
"이 작업이 서로의 결과를 기다릴 필요가 있을까?" 라는 질문 하나로 개선 포인트를 발견할 수 있었다.
외부 I/O처럼 순서에 의존하지 않는 작업은 순차 실행할 이유가 없고,
비동기 전환만으로도 충분히 의미 있는 성능 개선이 가능하다는 것을 직접 수치로 확인할 수 있었다.
이 글을 작성하고 나서 구현 방식 자체를 재검토하게 되었다.
외부 스토리지에 대한 이해가 부족한 상태에서 성능 개선에만 집중했던 것인데, 돌아보니 프로필 이미지에는 애초에 공개 URL 방식이 더 적합했다.
presigned URL은 외부에 직접 노출하기 어려운 리소스에 일시적인 접근 권한을 위임하기 위한 방식이다.
그런데 독크독크의 프로필 이미지는 특정 사용자에게만 접근을 제한할 필요가 없는 공개 리소스다.
이 점을 뒤늦게 인식하고, 고정 URL을 직접 반환하는 방식으로 전환했다.

결과적으로 매 요청마다 URL을 생성하는 로직 자체가 사라졌다.
클라이언트가 고정 URL로 스토리지에 직접 접근하는 방식이라 URL 생성으로 인한 병목도 함께 제거되었다.
이번 경험을 통해 두 가지를 배웠다.