Introduce
서비스를 개발하다 보면 단일 API 내에서 외부 API 호출이 N번 반복되는 구조가 자연스럽게 생겨난다. 최근에 장소 하나에 사진 10장을 보여주자 라는 요구사항이 있었다. 처음에는 단순히 순차 조회로 구현을 하였고, 로컬에서 테스트해 보니 1초 남짓한 응답 시간이 걸렸고 "좀 느리긴 하지만 괜찮겠지" 싶었다.
GitHub - YAPP-Github/NDGL-BE
Contribute to YAPP-Github/NDGL-BE development by creating an account on GitHub.
github.com
나도갈래 프로젝트는 유튜버의 여행 영상 속 동선을 AI가 분석하여 장소 정보를 제공하는 서비스이다. 장소 상세 정보를 조회해야 하는데 이때 Google Maps Places API를 사용하여 장소의 상세 정보와 이미지들을 가져온다. 장소 한 곳당 최대 10장의 사진을 가져오는데, 이때 10번의 외부 API 호출이 필요하다. 그리고 이는 완전히 순차적으로 이루어지고 있었다.
각 이미지 호출에 평균 약 100~200ms가 소요되므로 10장 기준으로 2,000ms까지 걸릴 수 있다. 이 2초동안 Tomcat 스레드는 아무 작업도 하지 못한 채 블로킹 상태로 멈춰 있다. 한 명의 사용자라면 UX 문제에 그치겠지만, 동시 요청이 늘어날 경우 스레드 풀이 고갈되어 시스템 전체가 마비 상태에 빠질 가능성이 있다.
본 글에서는 플랫폼 스레드에서 Blocking I/O의 순차 처리 방식이 왜 단순한 느린 응답 문제가 아닌 시스템의 마비를 위협하는 구조적 문제인지 살펴보고, 최종적으로 Virtual Thread를 통해 문제를 해결하는 과정을 공유하고자 한다.
벤치마크를 진행한 테스트 코드는 아래에서 확인할 수 있습니다.
GitHub - WooJJam/java-virtual-thread-deep-dive
Contribute to WooJJam/java-virtual-thread-deep-dive development by creating an account on GitHub.
github.com
Understanding the Problem
기존 코드는 아래와 같은 구조였다. `targets` 리스트를 순회하며 각 사진마다 Google Maps Photo API를 한 번씩, 순서대로 호출한다.
private List<PlacePhoto> fetchPhotos(String googlePlaceId, List<PhotoMeta> targets) {
List<PlacePhoto> result = new ArrayList<>();
for (PhotoMeta meta : targets) {
// 이 한 줄이 완료될 때까지 스레드는 블로킹 상태로 대기
String uri = googleMapsPlacePhotoClient.getPhotoUri(
PlacePhotoRequest.of(meta.name(), meta.heightPx(), meta.widthPx())
).uri();
result.add(PlacePhoto.create(googlePlaceId, meta.name(), uri, meta.widthPx(), meta.heightPx()));
}
return result;
}
`getPhotoUri()` 호출 한 번이 동기 HTTP 블로킹 호출이라는 점이 문제다. 해당 스레드는 네트워크 응답이 돌아올 때까지 아무 작업도 수행하지 못하고 멈춰 있다.
[사진 1 요청] → 블로킹 대기 200ms → 완료
[사진 2 요청] → 블로킹 대기 200ms → 완료
...
[사진 10 요청] → 블로킹 대기 200ms → 완료
총 2,000ms
10장을 가져오면 총 2,000ms 이다. 이 시간 내내 Tomcat 스레드는 1개가 점유된 채 잠들어 있다.
📌 스레드 풀 고갈
단 한 명의 사용자만 요청을 보내는 상황이라면 응답 시간 지연은 UX 문제에 그친다. 하지만 진짜 위험은 동시 요청이 늘어날 때 시작된다.
Tomcat의 기본 스레드 풀 크기는 200개다. 각 HTTP 요청은 스레드 1개를 점유하고, 그 스레드는 응답을 내보내기 전까지 반납되지 않는다.
요청 1개 → 스레드 1개가 2,000ms 동안 블로킹
동시 요청 200개 → 스레드 풀 200개 전부 점유
201번째 요청 → 큐(Queue)에서 대기, 앞선 요청이 끝날 때까지 응답 불가
202번째, 203번째 ... → 대기 시간 누적, 결국 타임아웃
스레드풀이 가득 차게 되면 CPU는 놀고 있지만 새로운 요청을 처리할 스레드가 없어 시스템 전체가 멈춰버린 것처럼 보인다. 이것이 스레드 풀 고갈이다.

실제로 VU 300으로 부하 테스트를 진행하니 p(95): 3020ms, p(99)가 3440ms까지 치솟는데, 이는 순수 처리 시간에 큐 대기 시간이 더해진 결과다. VU가 더 늘어나거나 블로킹 시간이 더욱 늘어나면 스레드 고갈 문제는 더욱 발생할 것이고, 응답 시간은 그만큼 지연될 것이다.
📌 @Async - 비동기의 함정
가장 먼저 떠오른 해결책은 `@Async` 이다. Spring의 `@Async` 어노테이션을 붙이면 해당 메서드를 별도 스레드 풀에서 비동기로 실행할 수 있다. 실제로 해당 방식을 구현하여 테스트해 보았다.
@Async("platformThreadAsyncExecutor")
public void fetchSequentialAsyncOnPlatform(final int count) {
log.info("[case2 PT @Async 순차] 시작. thread={}, isVirtual={}",
Thread.currentThread().getName(), Thread.currentThread().isVirtual());
List<String> results = IntStream.range(0, count)
.mapToObj(i -> photoApiSimulator.call(i + 1))
.filter(Objects::nonNull)
.toList();
PerfTestResponse response = PerfTestResponse.of("async-sequential", count, elapsed, results);
log.info("response = {}", response);
}
`fetchSequentialAsyncOnPlatform()`를 호출하면 작업은`platformThreadAsyncExecutor`의 다른 스레드에서 실행된다. 그리고 호출자에서는 해당 메서드의 응답을 받지 않고 HTTP 응답은 즉시 내려간다. 이전과 동일하게 부하 테스트를 진행하고 결과를 살펴보자.

응답 시간을 보면 대부분의 시간이 이전보다 빠르고, min과 p50에 대해서는 압도적으로 응답시간이 빠르다. 하지만 10% 요청에 대해서는 응답시간이 왜 2000ms까지 치솟은 걸까?
이것은 `CallerRunsPolicy`에 의해서 그렇다.
[VU 300, platformThreadAsyncExecutor: core=50, max=100, queue=200]
1단계: 스레드 50개 → 큐에 태스크 적재 시작
2단계: 스레드 100개 포화 → 큐에 계속 적재 (최대 200개)
3단계: 큐도 포화 (200개) → CallerRunsPolicy 발동
→ Tomcat 스레드가 직접 순차 처리 실행 (2,000ms 블로킹)
`CallerRunsPolicy` 는 스레드 풀이 가득 찼을 때 작업을 버리는 대신 호출한 스레드(Tomcat)가 직접 실행하도록 하는 재시도 정책이다. 그렇기에 풀이 가득 차 실행하지 못한 작업은 Tomcat 스레드가 2초짜리 순차 작업을 떠안는 것이다. 이것이 10% 요청들이 2초 이상 걸린 이유다.
하지만 더 심각한 문제는 이것이 모니터링에 잘 드러나지 않는다는 점이다. 수치상으로는 응답 시간이 줄어들었고 TPS도 증가되었기에 성능이 개선된 것으로 보이나 실제로는 ` platformThreadAsyncExecutor` 의 100개 스레드가 모두 2초짜리 순차 작업을 처리하고 있고, 큐에는 200개의 작업이 대기 중인 상태이다.
백그라운드에서 스레드 풀이 포화 상태임에도 불구하고, 응답 지표 자체는 괜찮아 보인다. 하지만 만약 이 상태에서 트래픽이 더욱 몰리게 되면 스레드 풀에서 처리할 수 없는 작업은 많아지고, 결국 CallerRunsPolicy의 동작 횟수는 증가하게 되어 지연시간이 급격히 나빠질 수밖에 없다.
결국 `@Async`는 작업을 백그라운드에서 처리할 수 있도록 미룰 뿐이지 스레드 풀 고갈 문제를 해결하지는 않는다. 오히려 문제를 뒤에 숨겨 디버깅을 더 어렵게 만들기만 할 수 있다.
물론 `@Async`가 안티 패턴이라는 의미는 아니다. 결과에 대한 응답이 필요 없고, 단순히 비동기만 필요한 경우에는 `@Async`가 적절할 것이다. 하지만 나는 장소의 이미지 정보를 조회해서 반환해주어야 하기 때문에 결과에 대한 응답이 필요하다. 따라서 `@Async`는 근본적인 해결책이 될 수가 없다.
진짜 해결책은 스레드를 효율적으로 사용하는 것이다. 10개의 API 호출을 동시에 실행시키고, I/O 대기 중인 스레드를 반환하는 구조가 필요하다. 이것이 바로 Virtual Thread이다.
Virtual Thread: A New Threading Paradigm
Java 21에서 정식으로 도입된 Virtual Thread는 JVM이 직접 관리하는 경량 스레드이다.
📌 AS-IS
Java의 전통적인 Thread 모델은 Native Thread로 JVM 내에서 Platform Thread를 생성할 때 Java Native Interface(JNI)를 통해 커널 영역을 호출한 뒤 OS(Kernel) Thread에 매핑하여 작업을 수행하는 구조이다.

그렇다 보니 여기서 문제가 발생한다.
- Thread의 최대 가용 개수는 OS에서 생성할 수 있는 최대 Thread 개수를 초과할 수 없다.
- Context Switcing이 기하급수적으로 증가한다.
예를 들어보면 서버가 4GB의 메모리를 사용할 때 Thread가 1MB라면 최대 4,000개까지 생성할 수 있다. 이처럼 메모리가 제한된 환경에서는 Thread의 수가 한정적이게 되고, Context Switcing이 급격히 증가하게 된다.
현대적인 Thread Model은 이러한 한계들을 겪게 되었고, Spring MVC 같은 Thread per request 구조의 프레임워크는 더욱 치명적이다. 따라서 더 많은 처리량과 적은 Context Switching의 비용을 위해서 경량 스레드 모델인 Virtual Thread가 탄생하게 되었다.
📌 TO-BE

Virtual Thread는 기존의 OS Thread 단위로의 생성이 아닌 JVM 내부에서 Virtual Thread를 생성하고, 미리 생성된 Carrier Thread로의 스케쥴링을 통해 기존 아키텍처에 비해 리소스 비용을 크게 절감하였다.
| AS-IS | TO-BE | |
| Stack 사이즈 | ~2MB | ~10KB |
| 생성 시간 | ~1ms | ~1µs |
| Context Switching | ~100µs | ~10µs |
Context Switching 시간은 약 10배가 절감되었으며 메모리는 약 200배가 절감되었다. 그리고 최대 가용 Thread 또한 기존에는 OS의 최대 Thread 수로 제한되어 있었지만 Virtual Thread는 JVM Heap이 허용하는 한 제한 없이 가용 가능하다.
📌 How It Works
Virtual Thread의 핵심 동작 메커니즘은 mount, unmount이다.

Virtual Thread를 관리하는 스케쥴러는 ForkJoinPool이다. 작업이 실행될 때 Virtual Thread는 Carrier Thread위에 올라타는데 이것을 mount라고 한다. 여기서 Carrier Thread는 OS 스레드이므로 보통 CPU 코어 수에 맞춰 제한된 수로 생성된다. 그리고 Virtual Thread는 blocking이 발생하면 JVM은 해당 Virtual Thread를 Carrier Thread에서 내려놓게 되는데 이것을 unmount라고 한다. 이후 비워진 Carrier Thread에는 또 다른 Virtual Thread가 다시 mount 되어 실행함으로써 CPU를 낭비 없이 계속 사용할 수 있는 것이다.
이것이 Blocking I/O 작업에서 Virtual Thread가 유리한 이유이다. 기존의 Thread 모델은 Blocking I/O 대기 중에도 OS 스레드가 점유된채 블로킹된다. CPU는 놀고 있지만 스레드 풀은 차지하고 있는 구조이다. Virtual Thread는 이러한 스레드풀 고갈 문제를 해결할 수 있는 해결책이다.
Google Maps Photo API처럼 외부 I/O 대기중에도 10개의 Virtual Thread가 동시에 블로킹되더라도 Virtual Thread는 unmount 되어 또 다른 Virtual Thread를 처리할 수 있는 것이다.
Performance Benchmarks
📌 Test environment
그렇다면 이제 직접 테스트해 보며 성능을 측정해 보자. 총 4가지 case로 나눠서 부하 테스트를 진행할 예정이다.
그리고 실제 Google Maps API를 호출하는 대신 `Thread.sleep(200ms)`로 외부 I/O를 대체하여 시뮬레이션하도록 진행하였다. 부하 테스트를 진행하는 환경은 다음과 같다.
이미지 요청 수: 10개 (장소당 최대 사진 수)
이미지당 지연: 200ms (Thread.sleep 시뮬레이션)
부하 도구: k6
모니터링: Grafana, Prometheus
동시 접속자(VU): 최대 300 (Tomcat 기본 스레드 풀 200개 초과)
k6 스테이지: 50 VU → 100 VU → 300 VU 단계적 증가
서버: 로컬 환경 (MacOS M1, 16GB RAM)
- K6 script
export const options = {
scenarios: {
smoke: {
executor: 'constant-vus',
vus: 1,
duration: '10s',
startTime: '0s',
},
load: {
executor: 'ramping-vus',
startVUs: 0,
startTime: '15s',
stages: [
{ target: 50, duration: '30s' }, // stage1
{ target: 100, duration: '30s' }, // stage2
{ target: 300, duration: '30s' }, // stage3
],
},
},
thresholds: {
'http_req_duration': ['p(95)<3000'],
'http_req_failed': ['rate<0.01'],
},
};
📌 case 1) 순차 + 동기 + 플랫폼 스레드 (기준선)
앞서 살펴본 순차 + 동기 처리 방식이다. Tomcat 플랫폼 스레드에서 이미지 10개를 하나씩 직렬로 호출한다.

스레드 1개가 2,000ms 동안 점유되므로, VU 300구간에서는 스레드 풀이 가득 차기 때문에 큐 대기가 발생하여 tail latency가 늘어나고 있다.
📌 case 2) @Async + 순차처리
case2의 경우에는 직접적인 응답시간으로만 수치를 분석하기에는 한계가 있다. 왜냐하면 `@Async`는 비동기로 메서드를 호출하고 바로 반환되기 때문에 HTTP 응답 자체가 그대로 내려오므로 응답 시간만을 확인하는 것은 큰 의미가 없다.
`RejectedExecutionHandler`는 스레드풀이 가득 찰 경우 해당 작업을 어떤 식으로 처리할지를 결정하는 방식인데, 여기서는 2가지 정책으로 테스트할 것이다.
1. Abort Policy
Abort Policy는 스레드 풀이 가득 차서 새 작업을 더 이상 받지 못할 때, 즉시 실패 처리하는 전략이다.

앞서 이야기했듯이 HTTP 응답 자체가 그대로 내려오므로 응답 시간은 빠를 수밖에 없다. 하지만 에러 비율이 무려 90% 이상을 차지한다. 즉, 대부분의 작업을 처리하지 못했다는 것인데 이는 VU가 증가함에 따라 대부분의 작업이 스레드 풀의 큐에도 못 들어가고 즉시 실패했기 때문이다.
2. CallerRunsPolicy
CallerRunsPolicy는 스레드 풀이 가득 차서 새 작업을 더 이상 받지 못할 때, 작업을 버리거나 예외를 던지지 않고 작업을 제출한 호출자 스레드에서 직접 실행하는 전략이다. 그러므로 여기서는 Tomcat Thread가 작업을 처리하게 되는 것이다.

CallerRunsPolicy 전략의 경우 새로운 작업을 스레드 풀에 할당되지 않더라도 호출자의 스레드에서 직접 실행되기 때문에 에러는 발생하지 않았다. 하지만 스레드 풀이 가득 찰 경우 호출자인 Tomcat Thread가 작업을 처리하게 되면서 tail latency가 2000ms 이상으로 급격하게 증가한 것을 확인할 수 있다. 만약 VU가 더 늘어나거나 I/O 대기 시간이 늘어난다면 응답 시간은 더욱 급격하게 지연될 것이다.
📌 case 3) 병렬 처리 + Platform Thread
병렬처리를 위해 CompletableFuture로 10개의 task를 동시에 제출하되, 스레드 풀은 `CachedThreadPool` 을 사용하였다.
`CachedThreadPool`은 무제한 플랫폼 스레드 풀로 요청마다 필요한 만큼 스레드를 생성하므로 고정적인 크기의 풀과 달리 스레드풀 골갈이 발생하지는 않는다. Virtual Thread의 특징을 고려하여 공정한 비교를 위해 스레드 수 제한을 두지 않고 테스트해보겠다.
// 플랫폼 스레드 무제한 풀 (CachedThreadPool) - 스레드 수 모니터링용
private static final ExecutorService CACHED_EXECUTOR = Executors.newCachedThreadPool();
public PerfTestResponse fetchParallelOnPlatform(final int count) {
List<CompletableFuture<String>> futures = IntStream.range(0, count)
.mapToObj(i -> CompletableFuture
.supplyAsync(() -> photoApiSimulator.call(i + 1), CACHED_EXECUTOR)
.<String>exceptionally(e -> {
log.warn("사진 {} 조회 실패: {}", i + 1, e.getMessage());
return null;
}))
.toList();
List<String> results = futures.stream()
.map(CompletableFuture::join)
.filter(Objects::nonNull)
.toList();
// ...
}

응답시간과 TPS를 보면 엄청나게 개선된 것을 확인할 수 있다. 그렇다면 해당 방식을 적용한다면 진정한 성능 향상을 이끌었다고 할 수 있을까?

프로메테우스로 실제 활성화된 스레드의 수를 확인해 보았다. 놀랍게도 작업이 가장 많을 때 OS 스레드가 3000개 이상으로 증가한 것을 확인할 수 있다.
스레드의 스택 크기는 보통 1MB이다. 즉 그렇다면 해당 기능을 실행하기 위해서 약 3GB의 메모리를 소비하는 것이다. 대규모 서비스의 경우 DAU가 몇십~몇백만까지 증가하는데 이때 플랫폼 스레드가 무한으로 생성된다면 서버는 절대 버티지 못할 것이다.
그렇다면 현실적으로 스레드가 무한으로 생성되지 않게 스레드 풀의 크기를 반드시 지정해주어야 한다.
// 플랫폼 스레드 고정 풀 (Tomcat 기본값 기준 200개)
private static final ExecutorService PLATFORM_EXECUTOR = Executors.newFixedThreadPool(200);
플랫폼 스레드 풀을 Tomcat과 동일하게 200개로 제한하고, 똑같이 테스트를 진행해 보자.

병렬 처리라고 하면 높은 응답 속도와 높은 처리량이 떠오른다. 그렇기에 여기서도 향상된 결과를 보일 것이라고 예상하였다. 하지만 결과는 내 예상과 다르게 나왔다. 테스트 결과 병렬 처리임에도 불구하고 응답 시간이 case1과 큰 차이가 없었다.
이에 대한 원인을 분석해 보자면 우리 서비스의 요구사항은 장소당 10개의 이미지를 조회해서 반환해주어야 한다.
List<String> results = futures.stream()
.map(CompletableFuture::join)
.filter(Objects::nonNull)
.toList();
그렇기에 다음과 같이 `join`을 통해 10개의 이미지 URI를 모두 모아서 한 번에 응답으로 반환하는 구조이다. 따라서 9개가 200ms에 끝나더라도 10번째 작업이 큐에서 2,000ms 기다리고 있다면 전체 응답이 2,200ms가 되는 것이다.
따라서 해당 시나리오에서 병렬 처리의 이점은 VU가 낮을 때만 실현된다. 3,000개의 task가 200개짜리 pool에 몰리게 되고, 요청의 응답 시간은 10개의 task 중 가장 늦게 완료되는 것에 의해 결정된다.
결과적으로 이 또한 스레드 풀이 가득 차 응답시간이 늘어지게 된 것이다.
📌 case 4) 병렬 처리 + Virtual Thread
이번에는 동일하게 CompletableFuture로 10개의 task를 동시에 실행하되, Virtual Thread Executor를 통해 가상 스레드를 사용할 것이다.
public PerfTestResponse fetchParallelOnVirtual(final int count) throws ExecutionException, InterruptedException {
log.info("[case4 VT 병렬] 시작. thread={}, isVirtual={}", Thread.currentThread().getName(), Thread.currentThread().isVirtual());
List<CompletableFuture<String>> futures = IntStream.range(0, count)
.mapToObj(i -> CompletableFuture
.supplyAsync(() -> photoApiSimulator.call(i + 1), VIRTUAL_EXECUTOR)
.<String>exceptionally(e -> {
log.warn("사진 {} 조회 실패: {}", i + 1, e.getMessage());
return null;
}))
.toList();
List<String> results = futures.stream()
.map(CompletableFuture::join)
.filter(Objects::nonNull)
.toList();
log.info("[case4 VT 병렬] {}개 처리 완료.", count);
return PerfTestResponse.of("case4-virtual-parallel", count, results);
}

테스트 결과를 살펴보면 TPS와 응답 시간 모두 개선된 것을 확인할 수 있다. Virtual Thread는 동시에 블로킹되더라도 Carrier Thread에 unmounte 되게 때문에 OS 스레드를 점유하지 않는다고 하였다.

Prometheus를 통해 활성화된 스레드를 확인해 보면 Case 3와 다르게 부하가 증가해도 OS 스레드 수가 일정하게 유지되는 것을 확인할 수 있다.
응답시간의 경우 case1, case3 대비 약 10배가 향상되었으며 평균 TPS의 경우에도 7~10배가 향상되었다.
이처럼 외부 Blocking I/O가 포함된 작업에서 Virtual Thread는 동시성 측면에서 뛰어난 효과를 보이며, 기존에 스레드 풀의 크기를 고민하던 과정 자체가 사라진다는 점도 매우 실용적인 이점이다.
Conclusion
외부 API 호출 10번이 만들어내는 문제는 단순히 2초짜리 느린 API가 아니었다. Tomcat 스레드가 2초 동안 아무 작업도 못한 채 붙잡혀 있고, 그게 동시에 200개를 넘는 순간 시스템 전체가 멈춘다. 즉, 스레드 풀 고갈이 진짜 문제였다.
`@Async`도, 플랫폼 스레드 기반 병렬 처리도 해당 문제를 근본적으로 해결하지 못하였다. 스레드 풀 고갈을 피하려면 스레드를 무제한으로 늘려야 했고, 그러면 OS 스레드가 수천 개 생겼기 때문이다. 그렇다고 스레드 풀의 크기를 제한하면 해당 문제가 다른 풀로 이동할 뿐이었다.
Virtual Thread는 전제 자체를 바꾸었다. I/O 를 기다리는 동안 Carrier Thread를 반납하기 때문에 수천 개의 태스크가 동시에 블로킹되어도 OS가 관리하는 스레드는 일정하게 유지된다. 따라서 이전에는 스레드 풀의 크기를 고민하던 과정 자체가 사라지게 된 것이다.
그렇다고 Virtual Thread가 마법처럼 항상 적합하고, 해결해 주는 것은 아니다. Virtual Thread는 I/O 바운드 작업에 최적화된 스레드이다. I/O 대기 없이 CPU 연산만 사용하는 작업이라면 오히려 Thread의 Context Switching 자체가 오버헤드가 될 수 있다.
따라서 성능 측정은 반드시 직접 하는 것을 추천한다. 이번 테스트는 Thread.sleep(200ms)으로 외부 API를 시뮬레이션했다. 실제 Google Maps API는 네트워크 레이턴시, TLS 핸드셰이크, 서버 처리 시간이 더해진다. 외부 API의 지연이 크고 분산이 심할수록 병렬 처리의 이점은 더 크지만, tail latency도 두꺼워진다. "Virtual Thread 쓰면 빠르다"는 I/O 바운드 환경에서의 일반론일 뿐, 자신의 서비스에서 어느 정도 개선되는지는 반드시 실제 부하 테스트로 확인해야 한다.
'Backend > Spring Boot' 카테고리의 다른 글
| RestClient는 어떻게 생성이 될까? (1) | 2026.01.31 |
|---|---|
| 자바와 스프링의 비동기 처리 - 2편: CompletableFuture의 예외 처리와 타임 아웃 (4) | 2025.08.01 |
| 자바와 스프링의 비동기 처리 - 1편: CompletableFuture 톺아보기 (3) | 2025.07.11 |
| 스프링 이벤트를 발행하여 트랜잭션과 관심사 분리하기 (2) | 2025.04.29 |
| 동시성 문제에 대한 고찰, 점진적으로 접근하기 (0) | 2025.03.17 |