[웹/프로그래밍] 웹 서버의 비동기 프로그래밍

동기 요청과 비동기 요청

웹 서버는 외부로부터 웹 요청을 받아 처리한다. 특정 URL에 대한 HTTP 요청을 특정 컨트롤러 메서드에 매핑하는 적절한 라우팅 설정으로 인해 요청이 들어오면 컨트롤러 메서드가 실행된다. 이 컨트롤러 메서드는 동기 호출을 통해 요청 작업들을 동기적으로(순차적으로) 처리하거나 비동기 호출을 통해 요청 작업들을 비동기적으로(순서에 관계 없이) 처리한다.

컨트롤러 메서드 내에서 코드 실행(작업)을 동기적으로 요청하면 해당 코드의 실행이 완료되어야 그다음 코드 실행이 가능하다. 이때 별도의 새로운 스레드 생성을 통해 코드 실행을 다른 스레드가 하도록 할 지는 멀티스레딩 구현에 따라 달려 있다. 웹 서버의 예에서 클라이언트의 웹 요청이 들어오면 서버의 스레드가 컨트롤러 메서드의 한 줄의 코드를 실행 완료한 후 그 다음 줄의 코드를 실행하는 작업 흐름이다. 요청 당 스레드가 할당되는 방식에서는 요청이 들어올 때마다 새로운 스레드가 생성되어 작업을 수행하거나 스레드 리소스를 효율적으로 사용하기 위해 스레드풀의 스레드가 재사용된다.

컨트롤러 메서드() {
  작업1을 수행하는 메서드(); // 동기적 요청
  작업2를 수행하는 메서드(); // 동기적 요청
}


이 컨텍스트에서 호출자 스레드의 코드 실행 흐름은 작업1 동기적 요청 -> 작업1 실행 -> 작업1 완료 시 까지 대기 -> 작업1 실행 완료 -> 작업2 동기적 요청 -> 작업2 실행 -> 작업2 완료 시 까지 대기 -> 작업2 실행 완료이며, 메서드를 호출한 스레드(호출자 스레드)가 직접 응답을 받는다.

컨트롤러 메서드의 모든 코드 실행이 완료되면 클라이언트에게 요청에 대한 응답을 보낸다. 요청 당 스레드가 할당되어 요청 작업을 수행하는 요청 처리 방식을 요청 당 스레드 모델(thread per request model)이라고 한다. 스레드풀의 스레드 개수는 제한되어 있기 때문에 모든 스레드가 클라이언트의 요청을 처리하고 있는 상황인 경우, 모든 요청 중 하나의 요청 작업이 완료되어 스레드가 반환되어야 그다음 요청이 처리될 수 있다. 요청 당 스레드가 할당되는 방식을 동기적 요청과 함께 사용하면 가용 스레드 수가 서버의 요청 처리 한계를 결정한다. 즉, 더 많은 요청을 처리하기 위해서는 더 많은 스레드가 필요하다. 요청 당 스레드 모델을 사용하는 대표적인 예로 서블릿 컨테이너인 톰캣이 있다.

요청 당 스레드 모델에서는 많은 요청을 처리하기 위해 많은 수의 스레드가 필요하지만 스레드는 한정된 자원이다. 하드웨어 성능에 맞게 최대 스레드 수를 설정해야 하므로(과도한 스레드 생성은 컨텍스트 스위칭으로 인한 오버헤드를 일으킨다) 요청 처리 성능이 하드웨어 성능에 비례함을 알 수 있다. 이처럼 CPU의 코어 수만큼 병렬처리를 수행하는 것에는 한계가 존재하므로 동시성이 필요하다.

작업에 대한 동기적 요청의 예로 데이터베이스 쿼리를 실행하여 결과를 응답으로 반환하는 JDBC 자바 API가 있다. 해당 API는 블로킹 API로 알려져 있다. 클라이언트의 데이터 조회 요청이 들어오면 서버는 API를 통해 데이터베이스에 데이터를 요청하게 되고 요청한 데이터를 응답 받을 동안 서버의 요청 스레드는 블로킹(차단)되므로 해당 작업이 완료될 때까지 다른 작업을 수행할 수 없다. 이렇게 작업이 완료되기를 기다리는 동안 또다른 클라이언트의 요청이 들어오면 스레드풀의 다른 가용 스레드가 요청 처리를 수행한다.

GUI 프로그램이나 모바일 네이티브 애플리케이션은 사용자와의 상호 작용을 위한 UI 스레드가 존재한다. 애플리케이션이 단일 스레드로 동작한다면 이 UI 스레드(기존 스레드)를 블로킹해서는 안 된다. UI 스레드가 블로킹 네트워크 IO 작업을 수행하게 만들면 해당 작업이 수행되는 동안 UI 동작이 중지되므로 사용자에게 애플리케이션이 중단된 것처럼 보일 것이다. 즉각적인 응답이 필요하지 않은 작업은 반드시 별도의 스레드(백그라운드 스레드 또는 작업자(워커) 스레드)에서 수행해야 한다. 이러한 이유로 기본(메인) 스레드와 작업자(워커) 스레드의 분리가 필요하다. 프로그램 실행 도중 항상 어떤 작업을 동기적으로 해야하는 작업은 별도의 스레드에서 수행하도록 하여 메인 스레드가 블로킹되지 않도록 주의해야 한다.

반면 컨트롤러 메서드 내에서 코드 실행(작업)을 비동기적으로 요청하면 해당 코드의 실행이 완료되지 않더라도 그다음 코드 실행이 가능하다. 메서드 내 한 줄의 코드가 비동기적으로 다른 메서드를 호출하는 경우 메서드가 완료되지 않더라도 그 다음 코드를 실행할 수 있다. 이때 별도의 새로운 스레드 생성을 통해 코드 실행을 다른 스레드가 하도록 할 지는 멀티스레딩 구현에 따라 달려 있다. 싱글 스레드 환경에서는 하나의 스레드가 여러 작업을 동시에 번갈아가며 수행하며 멀티 스레드 환경에서는 서로 다른 스레드가 각각의 작업을 독립적으로 수행할 것이다.

컨트롤러 메서드() {
  작업1을 수행하는 메서드(); // 비동기적 요청
  작업2를 수행하는 메서드(); // 비동기적 요청
}

이 컨텍스트에서 호출자 스레드의 코드 실행 흐름은 작업1 비동기적 요청 -> 작업1 실행 -> 작업2 비동기적 요청 -> 작업2 실행 -> 작업1(또는 작업2) 실행 완료 -> 작업2(또는 작업1) 실행 완료이며, 메서드를 호출한 스레드(호출자 스레드)가 직접 응답을 받는다.

서블릿 3 명세부터 HTTP 요청을 비동기로 처리할 수 있으며 스레드는 블로킹 없이 다른 작업을 수행하다가 콜백으로 요청 결과가 준비되면 응답으로 반환할 수 있게 되었다. 서블릿 3.1 부터는 NIO를 통해 IO 작업을 블로킹 없이 수행하여 요청에 대한 응답을 반환하는 전 과정을 완전한 비동기 및 논블로킹 방식으로 처리가 가능하다. 따라서 요청 당 스레드 모델을 비동기 요청과 함께 사용함으로써 애플리케이션을 좀더 리소스 효율적이고 반응성 있게 만들 수 있다.

요청 당 스레드 모델을 통한 요청 처리 방식을 성능면에서 개선하기 위한 이벤트 루프 모델(event loop model)은 비동기 요청과 스레드 논블로킹을 기반으로 한다. 이 모델에서는 요청에 대한 작업을 이벤트 큐라는 대기열에 쌓고 호출자 스레드는 이벤트들을 처리하기 위해 작업들을 비동기적으로 요청한다. 작업 요청 시 콜백 함수를 함께 전달함으로써 작업이 완료되었을 때 별도의 추가 작업을 수행하여 응답 결과를 호출자 스레드에 전달할 수 있다. 이벤트 루프 모델에 반응형 프로그래밍의 역압(backpressure) 특성을 도입하면 요청과 응답을 조율하여 시스템의 리소스 사용 효율을 높일 수도 있다.

비동기 프로그래밍의 올바른 구현

비동기 요청은 스레드 논블로킹을 위한 요청 방식이다. 요청 작업이 수행되는 동안 호출자 스레드가 다른 일을 수행할 수 있도록 함으로써 제한된 수의 가용 스레드가 리소스 낭비 없이 더 많은 일을 수행할 수 있도록 하는데 그 목적이 있다. 즉, 비동기적으로 작업을 요청하고 스레드가 논블로킹 되도록 프로그램을 구현한다는 것은 적은 수의 스레드로 코드를 실행하려는 의도가 담겨 있을 것이다.

하지만 비동기적으로 요청한 작업이 수행되는 동안 호출자 스레드가 블로킹되어 다른 일을 수행하지 못한 채로 작업이 완료되기를 기다린다면 비동기 프로그래밍의 이점이 없어진다. 제한된 수의 스레드를 사용하기 위해 비동기적으로 동작하도록 설계된 애플리케이션에 이렇게 스레드 블로킹 코드가 포함되면 성능 저하가 발생하게 될 것이다. 따라서 작업을 요청하는 요청자 스레드가 블로킹되는 상황은 피해야 한다.

모든 것을 비동기 요청으로

비동기 프로그래밍에서 또다른 주의점은 비동기 코드와 동기 코드를 혼용하지 않는 것이 권장된다는 것이다.

외부 메서드() {
  작업1을 수행하는 내부 메서드();
  작업2를 수행하는 내부 메서드();
}

작업1을 수행하는 내부 메서드() {
  작업1-1을 수행하는 메서드();
  작업1-2 수행;
}

작업2를 수행하는 내부 메서드() {
  작업2-1을 수행하는 메서드();
  작업2-2 수행;
}

위와 같은 코드에서 작업1은 동기적으로 요청하고, 작업1-1을 비동기적으로 요청하면 코드 실행 흐름은 다음과 같다. 내부 메서드가 비동기적으로 요청한 작업은 요청자 스레드를 차단하지 않는다고 가정한다(스레드 논블로킹).

외부 메서드() {
  작업1을 수행하는 내부 메서드(); // 1. 동기적 요청
  작업2를 수행하는 내부 메서드(); // 4. 작업1을 동기적으로 요청하였으므로 스레드는 작업1이 완료되어야 작업2를 수행할 수 있다. 작업 1-1이 완료되어야 작업1이 완료되므로 작업1-1이 완료될 때까지 스레드가 차단된다.
}

작업1을 수행하는 내부 메서드() {
  작업1-1을 수행하는 메서드(); // 2. 비동기적 요청
  작업1-2 수행; // 3. 작업1-1을 비동기적으로 요청하였으므로 스레드는 작업1-2를 수행할 수 있다.
}

작업2를 수행하는 내부 메서드() {
  작업2-1을 수행하는 메서드();
  작업2-2 수행();
}

이 경우 내부 메서드의 비동기적 요청 작업이 내부 메서드에서 스레드를 차단하지는 않았지만 외부(상위) 메서드에서 스레드를 차단한다.

반면 작업1과 작업1-1 모두 비동기적으로 요청하면 코드 실행 흐름은 다음과 같다.

외부 메서드() {
  작업1을 수행하는 내부 메서드(); // 1. 비동기적 요청
  작업2를 수행하는 내부 메서드(); // 4. 작업1을 비동기적으로 요청하였으므로 스레드는 작업1이 완료되지 않더라도 작업2를 수행할 수 있다. 작업1-1이 완료될 때까지 스레드가 차단되지 않는다. 따라서 스레드는 작업2 요청이 가능하다.
}

작업1을 수행하는 내부 메서드() {
  작업1-1을 수행하는 메서드(); // 2. 비동기적 요청
  작업1-2 수행; // 3. 작업1-1을 비동기적으로 요청하였으므로 스레드는 작업1-2를 수행할 수 있다.
}

작업2를 수행하는 내부 메서드() {
  작업2-1을 수행하는 메서드();
  작업2-2 수행();
}

이 경우 내부 메서드의 비동기적 요청 작업이 내부 메서드의 스레드와 외부(상위) 메서드의 스레드를 차단하지 않는다.

이러한 이유로 코드 내에 스레드가 작업을 비동기적으로 요청하는 메서드가 존재하면 최상위(top-level) 메서드에 이르기까지 실행되는 모든 메서드에 대해 메서드 내 모든 작업들을 비동기적으로 요청하도록 만드는 것이 좋다.

.NET 프레임워크에서 비동기 프로그래밍 구현

.NET 프레임워크는 병렬 연산을 위한 동기화 컨텍스트(SynchronizationContext)(ASP.NET 코어에서는 제거되었다)를 도입하였으며 이는 멀티스레딩 프로그래밍을 위한 요소이다. 멀티스레드 프로그램에서 스레드는 서로의 리소스 뿐만 아니라 공유 리소스에 함부로 접근할 수 없으며 스레드 간 적절한 경계가 존재해야 한다. 서로 다른 스레드는 서로가 소유하는 객체에 접근할 수 없기 때문에 기존 작업을 수행하던 스레드만 작업을 재개해야 하는 제약 사항이 있다. 이러한 스레드 간 경계로 인해 스레드 간 통신은 쉽지가 않다.

동기화 컨텍스트(SynchronizationContext)는 스레드 간 코드 실행 정보를 넘겨주기 위한 수단이며 서로 다른 스레드에서 작업을 이어나갈 수 있게 해준다. 요청 작업을 처리하는 스레드가 도중에 변경될 수 있다는 개념을 기반으로 동작하는, 비동기 요청을 위한 ISynchronizeInvoke라는 패턴이 있었지만 ASP.NET 비동기 페이지 동작에 적합하지 않아 동기화 컨텍스트로 대체되었다.

동기화 컨텍스트란 쉽게 말해 코드가 실행되는 환경 및 위치를 말한다. 여기서 컨텍스트를 동기화한다는 것은 한 컨텍스트 내에서 하나의 스레드만 실제로 코드를 실행할 수 있음을 의미한다. 동기화 컨텍스트에 의해 한번에 한 컨텍스트에 하나의 스레드만 존재할 수 있다. 수행되어야 할 작업들은 작업을 수행할 스레드가 아닌 컨텍스트를 대기하며 모든 스레드는 자신의 현재 컨텍스트를 가지고 있다. 스레드의 컨텍스트가 반드시 고유한 것은 아니며 컨텍스트는 다른 스레드에게 전달되어 공유될 수 있다.

작업을 수행하는 스레드가 도중에 변경될 수도 있다. 스레드가 변경될 때 원본 스레드가 대상 스레드로 수행할 작업을 전달한 후, 대상 스레드가 해당 작업을 완료할 때까지 대기하거나(동기적 요청) 다른 작업을 수행한다(비동기적 요청). 이때 스레드가 변경되더라도 컨텍스트는 공유될 수 있다. 수행되어야 하는 작업들은 캡처된 컨텍스트만 있으면 어느 스레드에서든 수행될 수 있다. 동기화 컨텍스트는 기존 코드의 실행 환경 정보와 실행할 작업 정보를 가지고 있는 객체라고 보면 된다.

.NET 4.5 부터 비동기 메서드 정의를 위한 async/await 키워드가 제공된다. 비동기 메서드(async 메서드)의 await 키워드를 만나면 컨텍스트를 캡처하고, 작업이 완료되면(대기가 끝나면) 캡처된 컨텍스트 내에서 비동기 메서드의 나머지 부분(await 이후의 코드)을 실행하려고 시도한다. 동기화 컨텍스트는 컨텍스트 캡쳐 전 작업을 수행하던 스레드와 캡쳐 후 작업을 수행할 스레드가 동일해야 하는 경우 이를 가능하게 해준다. 동기화 컨텍스트가 없었다면 스레드는 달라질 수 있다. Task.ConfigureAwait(false) 코드를 사용하면 현재 동기화 컨텍스트를 사용하지 않게 되어 대기 이후 작업은 요청자 스레드가 아닌 스레드풀의 스레드가 실행한다. 즉, 컨텍스트가 필요하지 않다면 Task.ConfigureAwait(false) 코드를 사용한다.

중요한 점은 asyncawait 키워드가 추가 스레드를 생성하지 않는다는 것이다. 비동기 메서드는 호출을 요청한 요청자 스레드에서 실행되지 않기 때문에 멀티스레딩이 필요하지 않으며, 이 메서드는 현재 동기화 컨텍스트에서 실행된다.

비동기 메서드를 동기적으로 요청하는 Task.Result(또는 Task.wait()) 코드를 작성하게 되면 해당 컨텍스트에는 (동기적으로) 비동기 메서드가 완료되기를 기다리는 요청자 스레드(블로킹된 스레드)가 존재하게 된다(컨텍스트에 존재하는 스레드를 차단한다). 비동기 메서드는 작업 수행 완료를 위해 컨텍스트를 기다리고 있지만, 컨텍스트의 스레드가 차단되어 있다. 즉, 비동기 메서드의 나머지 코드를 실행하기 위해 해당 컨텍스트가 필요하여 기다리고 있지만 컨텍스트의 스레드가 차단되어 컨텍스트가 사용 가능한 상태가 될 수 없는 상황이다. 교착상태의 원인은 비동기 메서드를 동기적으로 요청한 Task.Result 코드이다. 교착 상태를 막기 위해서는 비동기 코드를 동기적으로 실행하지 않음으로써 현재 컨텍스트의 스레드가 차단되지 않게 해야 한다. 스레드가 작업 수행 완료 후 컨텍스트가 사용 가능한 상태가 되어 그 다음 작업을 수행할 스레드가 컨텍스트에 접근 가능해야 한다. 다음 코드는 교착상태가 발생 가능한 코드이다.

작업을 수행하는 동기 메서드() {
  var task = 비동기 메서드(); // 1. 컨텍스트에서 스레드는 비동기 메서드 호출을 통해 작업을 요청한다.
  var result = task.Result; // 3. 컨텍스트에서 스레드는 작업이 완료될 때까지(작업1과 작업2가 완료될 때까지) 블로킹되어 다른 작업을 하지 못한다.
  작업3 수행; // 5. 교착 상태로 인해 작업3이 수행될 수 없다.
}

async Task 비동기 메서드() {
  await 작업1을 수행하는 메서드(); // 2. 컨텍스트의 캡쳐가 일어난다. 작업1이 완료되면 해당 컨텍스트에서 나머지 작업2가 수행될 수 있다.
  작업2 수행; // 4. 컨텍스트가 이용 가능한 상태여야 나머지 작업2 수행이 가능하지만 3번 코드로 인해 컨텍스트는 작업2가 완료되어야 이용 가능하다. 교착 상태가 발생하였다.
}

교착상태 방지를 위한 방법들을 살펴보자. 첫 번째 방법은 모든 비동기 요청은 await 키워드를 사용하여 스레드 논블로킹 방식으로 기다리는 것이다.

async Task 작업을 수행하는 비동기 메서드() {
  var task = 비동기 메서드(); // 1. 컨텍스트에서 스레드는 비동기 메서드 호출을 통해 작업을 요청한다.
  var result = await task(); // 3. 컨텍스트에서 스레드는 작업이 완료될 때까지(작업1과 작업2가 완료될 때까지) 블로킹되지 않으며(논블로킹) 다른 작업을 수행할 수 있다. 컨텍스트가 이용 가능하다.
  작업3 수행; // 5. 작업1과 작업2가 완료되면 작업3이 수행된다. 동기화 컨텍스트에 의해 작업1과 작업2를 요청한 스레드와 동일한 스레드가 작업3을 수행한다. 
}

async Task 비동기 메서드() {
  await 작업1을 수행하는 메서드(); // 2. 컨텍스트의 캡쳐가 일어난다. 작업1이 완료되면 해당 컨텍스트에서 나머지 작업2가 수행될 수 있다.
  작업2 수행; // 4. 컨텍스트가 이용 가능한 상태이므로 나머지 작업2 수행이 가능하다. 동기화 컨텍스트에 의해 작업1을 요청한 스레드와 동일한 스레드가 작업2를 수행한다. 교착 상태는 발생하지 않는다.
}

await 키워드를 사용하기 위해서는 해당 메서드도 async 키워드를 통해 비동기 메서드로 선언되어야 한다. 따라서 상위 레벨 메서드가 비동기 메서드로 변경되어야 한다. 이 방법은 상위 레벨 메서드에서 요청자 스레드가 요청하는 비동기 작업들을 동기적으로 요청하는 것이 아닌 비동기적으로 요청하며 컨텍스트 및 스레드가 차단되지 않도록 한다.

스레드가 작업을 수행하다가 컨텍스트를 캡쳐하고, 이후 캡처된 컨텍스트에서 나머지 작업을 동일한 스레드가 수행할지, 새로운 스레드가 수행할지는 동기화 컨텍스트에 달려있다. 스레드풀의 스레드가 아닌 동일한 스레드에서 수행해야 하는 것이 요구된다면 동기화 컨텍스트와 await를 사용한다.

두 번째 방법은 await Task.ConfigureAwait(false) 코드를 사용하는 것이다. 캡처된 동기화 컨텍스트를 사용하지 않음으로써 이후 작업은 요청자 스레드가 아닌 스레드풀의 스레드가 실행하도록 한다.

작업을 수행하는 동기 메서드() {
  var task = 비동기 메서드(); // 1. 컨텍스트에서 스레드는 비동기 메서드 호출을 통해 작업을 요청한다.
  var result = await task.ConfigureAwait(false); // 3. 컨텍스트 캡쳐가 일어나지 않는다. 컨텍스트에서 스레드는 작업이 완료될 때까지(작업1과 작업2가 완료될 때까지) 블로킹되지 않는다.
  작업3 수행; // 5. 작업1과 작업2가 완료되면 작업3이 수행된다. 요청자의 스레드가 아닌 스레드풀 스레드가 스레드풀 컨텍스트에서 나머지 작업3을 수행할 수 있다.
}

async Task 비동기 메서드() {
  await 작업1을 수행하는 메서드().ConfigureAwait(false); // 2. 컨텍스트의 캡쳐가 일어나지 않는다. 작업1이 완료되면 스레드풀의 스레드가 나머지 작업2를 수행할 수 있다.
  작업2 수행; // 4. 요청자의 스레드가 아닌 스레드풀 스레드가 나머지 작업2를 수행할 수 있다.
}

하지만 두 번째 방법을 사용하게 되면 모든 await 코드에 ConfigureAwait(false)를 사용해야 한다.

세 번째 방법은 Task.run().Result 코드를 사용하는 것이다. 이 코드는 비동기 요청을 동기적으로 요청하지만 Task.ConfigureAwait(false) 코드와 같이 캡처된 동기화 컨텍스트를 사용하지 않고 이후 작업은 요청자 스레드가 아닌 스레드풀의 스레드가 실행하도록 한다.

ASP.NET Core에서는 동기화 컨텍스트가 제거되어 더이상 await 키워드에 의해 컨텍스트가 캡쳐되지 않으며, 비동기 메서드를 동기적으로 실행하여도 교착상태가 발생하지 않는다고 한다.링크

참고

  • <>

Comments