next api app router가 동시 요청 시 비동기 처리를 정상적으로 수행하는지 궁금해서 아래와 같은 코드를 작성한 적이 있다.
import { NextResponse, type NextRequest } from "next/server";
function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
export async function GET(req: NextRequest) {
await sleep(3000);
const response = NextResponse.json({ message: "ok" });
return response;
}
const data = Promise.all([
fetch("/api/sleep"),
fetch("/api/sleep"),
fetch("/api/sleep"),
]);
Promise.all을 통해서 한번에 비동기 요청을 수행했는데, 아래와 같은 결과가 나왔다.

아니 분명 동시 요청을 했는데, api가 순차적으로 처리되는 것이 아닌가 ?
내 예상은 분명 모두 3초가 걸려서 한번에 응답을 받을 줄 알았는데..
이거이거 Next가 의심스럽구먼.. 하면서 Next 관련자료를 찾아보았지만 Next API에 대한 직접적인 관련 이슈는 찾을 수 없었다.
음 터미널로 한번 동시요청을 해볼까 ?
time (curl http://localhost:3343/api/sleep & curl http://localhost:3343/api/sleep & curl http://localhost:3343/api/sleep)
real 0m 3.578s
결과는 모든 결과를 응답받는데 3초가 걸렸다.
브라우저에서만 순차적으로 처리된다는 것을 알았고 집요한 성격이 발동돼서 이거 원인 규명을 해봐야겠다고 생각했다.
마침 주말이기도 하고 할 일도 없어서 크롬의 뼈대 오픈소스인 Chromium에서 어떻게 동작하는지 파악해보기로 했다.
우선 무엇과 관련이 있는지 단서부터 찾기위해서 GPT 형님과 여러 시간의 토론 끝에 Chromium의 캐시 매커니즘과 연관이 있다는 것을 알게되었다.
Chromium의 경우 내부적으로 url, post body, origin 등을 기반으로 캐시 키를 생성하여 http 요청에 대한 캐시를 관리한다.
캐시 키는 아래 GenerateCacheKeyForRequestWithAlternateURL 메서드를 통해서 생성된다.
std::optional<std::string>
HttpCache::GenerateCacheKeyForRequestWithAlternateURL(
const HttpRequestInfo* request,
const GURL& url) {
CHECK(request);
if (!CanGenerateCacheKeyForRequest(request)) {
return std::nullopt;
}
const int64_t upload_data_identifier =
request->upload_data_stream ? request->upload_data_stream->identifier()
: int64_t(0);
return GenerateCacheKey(
url, request->load_flags, request->network_isolation_key,
upload_data_identifier, request->is_subframe_document_resource,
request->is_main
Chromium은 상태 전이를 통해서 Http 요청의 생명주기를 관리하는 데, 상태 전이 루프 로직은 Doloop라는 메서드를 보면 된다.
int HttpCache::Transaction::DoLoop(int result) {
...
int rv = result;
State state = next_state_;
do {
state = next_state_;
next_state_ = STATE_UNSET;
base::AutoReset<bool> scoped_in_do_loop(&in_do_loop_, true);
switch (state) {
case STATE_GET_BACKEND:
DCHECK_EQ(OK, rv);
rv = DoGetBackend();
break;
case STATE_GET_BACKEND_COMPLETE:
rv = DoGetBackendComplete(rv);
break;
case STATE_INIT_ENTRY:
DCHECK_EQ(OK, rv);
rv = DoInitEntry();
break;
...
default:
NOTREACHED() << "bad state " << state;
}
DCHECK(next_state_ != STATE_UNSET) << "Previous state was " << state;
} while (rv != ERR_IO_PENDING && next_state_ != STATE_NONE);
...
return rv;
}
int HttpCache::Transaction::DoGetBackend() {
cache_pending_ = true;
TransitionToState(STATE_GET_BACKEND_COMPLETE);
net_log_.BeginEvent(NetLogEventType::HTTP_CACHE_GET_BACKEND);
return cache_->GetBackendForTransaction(this);
}
먼저 실행되는 DoGetBackend 함수의 경우 캐시 백엔드를 가져오는 함수인데, TransitionToState 함수를 통해서 state를 STATE_GET_BACKEND_COMPLETE로 변경한다.
그럼 Loop문에서 STATE_GET_BACKEND_COMPLETE 상태를 잡아 해당 함수를 실행하고 또 다음 상태로 넘어가고 이런식이다.
모든 코드를 볼 필요없이 크게 상태 전이를 통한 흐름을 보게 되면 아래와 같다.
자 위 흐름을 토대로 왜 순차적으로 처리되는 현상이 발생했는가를 살펴보면 원인을 알 수 있다.
우선 동일한 리소스로 요청을 하게되면 캐시키가 동일해진다.
그리고 캐시키가 동일하면 락 메커니즘(큐를 통한)에 따라서 순차적으로 처리된다.
자 이 때 의문이 들 수 있다.
처음에 요청한 게 응답이 오면 그 응답이 캐시에 있을텐데.. 왜 3초 3초 3초 씩 12초나 걸린거지 ??
그 부분에 대한 답은 캐시 저장 조건과 관련이 있다.
일단 Chromium은 캐시를 저장하는 것이 기본 옵션(Default)이다.
HttpResponseHeaders::FreshnessLifetimes
HttpResponseHeaders::GetFreshnessLifetimes(const Time& response_time) const {
...
if (HasCacheRestriction() || HasHeaderValue("pragma", "no-cache")) {
return lifetimes;
}
auto [must_revalidate, max_age, stale_while_revalidate] =
ParseCacheControlDirectivesForFreshness();
// Cache-Control directive must_revalidate overrides stale-while-revalidate.
lifetimes.staleness =
must_revalidate ? base::TimeDelta()
: stale_while_revalidate.value_or(base::TimeDelta());
if (max_age) {
lifetimes.freshness = max_age.value();
return lifetimes;
}
Time date_value = GetDateValue().value_or(response_time);
std::optional<Time> expires_value = GetExpiresValue();
if (expires_value) {
if (expires_value > date_value) {
lifetimes.freshness = expires_value.value() - date_value;
return lifetimes;
}
DCHECK_EQ(base::TimeDelta(), lifetimes.freshness);
return lifetimes;
}
...
if ((response_code_ == HTTP_OK ||
response_code_ == HTTP_NON_AUTHORITATIVE_INFORMATION ||
response_code_ == HTTP_PARTIAL_CONTENT) &&
!must_revalidate) {
std::optional<Time> last_modified_value = GetLastModifiedValue();
if (last_modified_value) {
if (last_modified_value.value() <= date_value) {
lifetimes.freshness = (date_value - last_modified_value.value()) / 10;
return lifetimes;
}
}
}
if (response_code_ == HTTP_MULTIPLE_CHOICES ||
response_code_ == HTTP_MOVED_PERMANENTLY ||
response_code_ == HTTP_PERMANENT_REDIRECT ||
response_code_ == HTTP_GONE) {
lifetimes.freshness = base::TimeDelta::Max();
lifetimes.staleness = base::TimeDelta(); // It should never be stale.
return lifetimes;
}
DCHECK_EQ(base::TimeDelta(), lifetimes.freshness);
return lifetimes;
}
위 함수는 Http 응답에서 헤더를 기반으로 freshness_lifetimes를 계산하는 함수인데, 이게 응답 바디를 캐시 처리할 때 사용된다.
위에 보면 cache-control 헤더에서 max_age를 추출해서 freshness를 설정하기도 하고 Expires를 추출해서 freshness를 설정하기도 하고, LastModified를 추출해서 freshness를 설정하기도 한다.
"freshness" 이게 캐시의 유효 시간에 사용된다.
이제 어느정도 원인 규명이 되었다.

위는 3개의 동시요청을 보내고 난 뒤 추출한 응답헤더 인데, cache-control 키도 없고 당연히 max-age 와 같은 value도 없다. 즉 freshness를 갱신할만한 키가 없다.
그럼 freshness 기본값인 0으로 설정된다.
캐시 설정이 기본이라고 하더라도 freshness가 0이라면 당연히 캐시를 저장하지 않는 것과 같고 캐시가 없으니 계속 신규 요청을 반복하게 되는 것이다.
진짜 그런건지 궁금해서 아래처럼 코드를 수정한 뒤 Chromium을 빌드해서 테스트해 봤다.
참고로 아래 코드는 freshness가 0이면 기본 10초를 부여하도록 수정한 것이다.
HttpResponseHeaders::FreshnessLifetimes
HttpResponseHeaders::GetFreshnessLifetimes(const Time& response_time) const {
...
if (lifetimes.freshness.is_zero()) {
lifetimes.freshness = base::Seconds(10);
}
return lifetimes;
}
~/chromium/src $ autoninja -C out/Default chrome
Chromium 빌드 하는데, 코드가 워낙 방대해서 하루 종일 걸리더라.. 10시간정도.. 다만 한번 빌드하면 그다음부터는 수정한 부분만 빌드해서 좀 빠르다.
빌드가 완료되었고 실행해서 다시 테스트 해보았따.
out/Default/Chromium.app/Contents/MacOS/Chromium --enable-logging --v=1 --log-file=$HOME/chromium_log.txt

모두 3초가 걸렸다 !
자 이제 알게되었다.
동일한 리소스로 요청하면 Chromium 캐시 메커니즘에 의해서 순차 처리되고, cache가 기본이긴하나, Http 응답에서 freshness을 갱신할 만한 헤더를 주지않으면 freshness는 기본 0이기때문에 캐시가 즉시 만료되어 요청이 계속 신규 요청될 수 있다.
그래서 동시 요청하더라도 순차 처리하고 싶지않다면,
캐시 키를 다르게 하기 위해 요청시 뒤에 추가 파라미터를 아래와 같이 붙여서 순차처리를 우회하면 된다.
const data = Promise.all([
fetch("/api/sleep?t=1"),
fetch("/api/sleep?t=2"),
fetch("/api/sleep?t=3"),
]);
그리고 캐시를 사용하고 싶다면 서버 응답에 캐시 처리를 위한 헤더를 포함해야한다.