비동기 모니터링 (Asynchronous Monitoring)
제니퍼소프트가 쉽게 설명하는 알아두면 쓸모 있는 모니터링 잡학사전 <비동기 모니터링>이란 주제로 그 두 번째 이야기를 시작합니다. 👨💻
서버 응용 프로그램 환경의 변화
본격적인 인터넷 서비스 시대로 접어들면서 서버 응용 프로그램은 대부분 JVM 환경의 대표 언어인 JAVA와 닷넷 환경의 대표 언어였던 C#을 사용해 개발했습니다. 그러다 근래 들어서는 업무 환경의 변화를 빠르게 수용할 수 있어야 한다는 요구 사항과 함께 지속적으로 이뤄진 서버 성능의 발전 덕분에 그동안 느리다는 이유로 인해 기피해왔던 동적 언어, 예를 들어 파이썬과 JavaScript 등이 점차로 서버 응용 프로그램에도 적용되기 시작했습니다.
이와 함께 서비스를 처리하는 방식에서도 변화가 일기 시작했습니다. MSA(Micro Service Architecture)가 대두되면서 가볍고 독립적인 응용 프로그램을 지향하기 시작했고, 이로 인해 그동안 고성능 서버의 처리 능력을 전제로 다중 스레드를 활용한 동기 방식의 처리를 주로 하던 것에서 가능한 적은 수의 스레드로 다중 요청을 처리하는 비동기 방식으로 바뀌게 된 것입니다.
이러한 변화에는 재미있는 대비점이 하나 있습니다. 사실 언어 환경의 변화는 일면 수긍할 수 있습니다 왜냐하면 일반적인 상황에서 개발자들은 기왕이면 좀 더 쉬운 언어로 작성하는 것을 선호하기 때문에 기존의 정적 언어가 가진 딱딱한 제약을 벗어난 동적 언어의 채택은 어렵지 않게 예측할 수 있습니다. 반면 동기 방식이 비동기로 바뀌고 있는 것은 다른 면모를 보입니다. 인간의 뇌는 동기 방식의 처리는 쉽게 이해할 수 있지만 비동기 방식의 처리는 그렇지 않으므로 오히려 쉬운 것에서 어려운 방식으로의 변화를 하고 있는 것입니다.
물론, 비동기로의 변화가 언어의 변화에 비하면 그리 빠르진 않습니다. 아직도 많은 응용 프로그램들은 여전히 동기 방식으로 작성되고 그것들의 비동기화 작업은 더딥니다. 그리고 그런 변화를 막는 주된 원인은 비동기 방식이 상대적으로 더 어렵기 때문입니다.
그렇다면 도대체 그 둘 간에는 어떤 차이가 있는 걸까요? 동기와 비동기를 이해하기 위해 우선 핵심 요소인 ‘스레드’를 언급해야 할 필요가 있습니다.
스레드
스레드는 운영체제가 제공하는 처리 단위로써 CPU의 코어 하나가 운영체제가 만들어 둔 스레드 1개를 실행하는 관계입니다.
따라서 만약 4코어 CPU라고 한다면 운영체제의 스레드 4개가 동시에 실행될 수 있음을 의미합니다. 여기서 중요한 점은 “동시 실행”이 4개까지 가능한 것이지, 스레드의 수가 최대 4개일 필요는 없다는 점입니다.
실제로 여러분의 운영체제에서 실행하는 프로그램은 기본적으로 1개의 스레드를 생성하며 필요에 따라 추가로 스레드를 더 생성합니다. 코어보다 많은 스레드가 생성 및 실행될 수 있는 이유는 운영체제가 주기적으로 CPU에서 실행될 스레드를 선택하기 때문입니다. 아래의 그림은 A ~ H까지 8개의 스레드가 있다고 가정했을 때 CPU에서 어떤 식으로 실행되는지를 보여주고 있습니다.
일반적으로 저러한 스레드 교체는 매우 빠른 속도로 처리되기 때문에 컴퓨터 사용자는 스레드의 실행이 느리다는 인식을 하지 못합니다.
이러한 스레드에는 몇 가지 단점이 있는데요, 우선 생성 및 삭제 비용이 크다는 점입니다. 이런 문제점을 극복하기 위해 일부 응용 프로그램들은 스레드를 미리 생성해 놓은 Pool을 만들어 필요할 때만 그 Pool로부터 스레드를 빌려서 사용하고 다시 반환하는 식으로 운영을 합니다. 또 다른 단점은 CPU에서 실행될 스레드를 운영체제가 제어하는 작업 역시 부하가 크다는 점입니다. 64비트 시대인 지금 사실상 스레드의 최대 수 제한이 없어졌지만 현실적으로는 너무 많은 스레드를 운영하면 운영체제 측의 스레드 스케줄링 비용이 커지는 문제가 있습니다. 이것이 심해지면 CPU로 하여금 사용자의 코드를 수행하는 시간보다 스레드 운영을 위한 운영체제 코드에 더 시간을 쓰게 돼 성능이 급속도로 저하될 수 있습니다. 이런 문제는 역시 이전에 설명한 스레드 풀(Thread Pool)에 최대 스레드를 제한하는 방법으로 우회적으로 해결할 수 있습니다.
정리하면, 활성 스레드의 수가 적을수록 CPU는 그만큼 더 효율적으로 일을 할 수 있습니다. 이 점을 인지하고 왜 동기 처리에서 비동기 처리로 바꿔야 하는지를 차례대로 알아보겠습니다.
동기 처리를 위한 스레드 운영
전통적으로 웹 응용 프로그램에서 서비스 요청에 대한 처리는 보통 동기 방식으로 처리를 했습니다. 여기서 동기 방식이란 단일 스레드가 서비스 요청의 처음부터 끝까지 담당한다는 것을 의미합니다.
간단하게 예를 들어볼까요? 전자책 서비스를 하는 웹 사이트에서 사용자가 e-book 한 권을 다운로드 요청을 하면 서버에서는 [그림 2]와 같이 처리한다고 가정해 보겠습니다.
보는 바와 같이, 스레드 하나가 전담해서 사용자가 요청한 웹 페이지의 기능을 순차적으로 처리해 끝날 때까지 묶여 있게 됩니다.
바로 여기서 문제가 시작됩니다. 위의 과정에서 만약 “파일 읽기” 과정이 1초가 걸리면 어떻게 될까요? 그럼, 스레드 한 개가 1초 동안 “사용 중”인 상태로 됩니다. 따라서, 만약 동시에 1,000명의 사용자가 다운로드 요청을 하면 총 1,000개의 스레드가 있어야만 1초 만에 그 요청을 모두 처리할 수 있게 됩니다. 그런 와중에 만약 “파일 읽기”가 1초에서 10초까지 시간이 지연된다면 어떻게 될까요? 밀려오는 사용자의 요청은 스레드와 1:1로 연동해 10초까지 계속해서 누적될 것이고 만약 초당 1,000명의 사용자가 계속 다운로드 요청을 했다면 단 10초 만에 10,000개 가까운 스레드가 활성화될 수 있습니다.
종종 사용자가 일시에 몰린 탓에 소위 말하는 먹통이 되는 사이트를 봤을 것입니다. 만약 특정 웹 서버의 경우 웹 요청을 위한 스레드의 최댓값을 5,000으로 설정했다면 위와 같은 상황에서 5초 이후에 접속한 사용자들은 서비스 거부 화면을 보게 될 것입니다. 물론, 이런 경우 서버를 늘리는 만큼(scale-out), 또는 서버를 좀 더 고성능으로 업그레이드(scale-up) 하는 방법을 통해 추가적인 요청을 처리할 수 있게 되지만 최대 사용자에 대한 수요 예측에 빗나간다면 전자 상거래 사이트의 경우 고스란히 매출 손실로 이어질 수 있습니다.
2가지 유형으로 나뉘는 스레드 동기 처리
지금까지 알아본 바에 따르면, 스레드가 동기 처리 방식에 의해 운영되는 경우 처리 시간을 가능한 단축시킬수록 동시에 사용되는 스레드의 수를 줄일 수 있다는 것을 알 수 있습니다. 웹 사이트의 경우라면 사용자의 요청을 빠르게 처리할 수 있어야 한다는 것입니다. 만약 대규모 사용자가 이용하는 웹 사이트라면 특정 웹 사이트의 요청을 처리하는데 단 몇 초만 더 걸려도 금방 순간적인 서비스 장애까지 발생할 수 있는 것입니다.
자, 그럼 처리 시간은 어떤 작업을 하는데 소요되는 것일까요?
웹 사이트의 요청이라면, 크게 2가지 관점에서 처리 시간을 바라볼 수 있습니다.
1.CPU가 실제 사용자 코드를 수행하느라 걸리는 시간
// 1부터 n까지 더하는 코드 long sum = 0; for (long i = 1; i <= n; i++) { sum += i; }
2.다른 서버로 원격 호출을 하고 결과를 기다리는 시간
// HTTP 호출을 하는 경우 WebClient wc = new WebClient(); wc.DownloadData(“http://www.nave.com”); // 응답을 받기까지의 시간 소요
여기서 1번 사항은 개선의 여지가 많지 않습니다. 작성된 사용자 코드를 실행하느라 소비된 순수 CPU 시간이기 때문에 그 시간을 줄이기 위해 사용자 코드를 없앨 수는 없습니다. 그래도 경우에 따라서는 사용자 코드가 비효율적인 구조로 작성돼 좀 더 빠른 시간 안에 끝내는 코드로 개선하는 것은 가능합니다. 예를 들어, 위에서 예를 든 1~n까지 더하는 코드를 다음과 같이 개선하면 됩니다.
// 1부터 n까지 더하는 코드 long sum = (n * (n + 1)) / 2; // n까지 루프를 돌지 않고 단 한 번의 연산으로 계산
반면 2번의 경우라면 어떨까요? 네트워크를 좀 더 빠른 회선으로 교체한다든가, 대상 서버의 성능을 높여 결과를 빨리 반환하게끔 개선할 수는 있습니다. 하지만 단일 시스템 내의 상황을 벗어나는 이런 외부적인 요인들은 제어 범위를 넘어섭니다. 결국 현실적인 이유로 인해 이것에 대한 개선도 쉽지 않습니다.
그런데, 2번 상황에는 특별한 점이 하나 있습니다. 비록 처리 시간을 단축시킬 수는 없지만 CPU가 외부 서버에서 응답을 받기까지 쉴 수 있다는 점입니다. 비교를 위해 1번 상황을 볼까요? CPU는 더하기 코드를 모두 수행할 때까지 연산 작업을 계속해야 합니다. 반면 2번 상황은 [그림 3]에서 보는 것처럼 네이버에 요청을 보낸 후 그 응답이 돌아오기까지 스레드가 멈추므로 CPU가 할 작업이 없게 됩니다.
운영체제는 저 “스레드 대기” 동안 CPU가 쉬지 않도록 [그림 4]와 같이 보통은 다른 스레드의 작업을 수행하도록 만듭니다.
위의 그림을 보면, “스레드 A”가 네트워크로부터 데이터를 수신하기까지 대기하는 동안 운영체제는 CPU 자원을 “스레드 B”에 할당해 “작업 B”를 실행시키고 있습니다. 또한, LAN 카드로부터 네트워크 수신이 완료되었다고 신호를 받으면 그때 “스레드 A”의 다음 작업들을 이어서 실행합니다.
그렇다면 혹시, “스레드 B”를 사용할 필요 없이 “스레드 A”가 “대기 시간” 동안 단순히 대기 모드로 빠지지 않고 “작업 “B”를 수행하도록 만들 수 있다면 어떨까요? 그렇게만 할 수 있다면 기존에는 2개의 스레드로 작업하던 것을 1개의 스레드만으로 운영이 가능합니다.
바로 그 생각을 구현한 것이 “비동기 처리”입니다.
스레드 효율을 향상시키는 비동기 처리
비동기 처리의 핵심이 되는 근간은 컴퓨터의 입/출력 장치들이 각각의 데이터를 송신 또는 수신을 완료할 때마다 알림을 보낼 수 있다는 것입니다. 위의 [그림 4]에서는 네트워크 어댑터를 예로 들었는데요, 컴퓨터의 대표적인 입/출력 장치 중 하나인 디스크 역시 이와 같은 알림 동작을 지원합니다.
사용자 코드의 ReadFile API 호출은 디스크에게 읽기 작업을 시키고 스레드를 중지시킵니다. 이로 인해 CPU는 다른 스레드의 작업을 할 수 있게 되고, 이후 디스크 장치는 “읽기 작업”이 완료되었다는 신호를 CPU에게 보내 중지시켰던 스레드의 후속 코드를 실행할 수 있게 합니다.
자, 그럼 위와 같은 과정을 “비동기” 처리로 개선해 볼까요?
이를 위해서는 [그림 6]과 같이 스레드가 수행할 코드를 I/O 장치 사용에 따른 기준으로 작업을 나눠 데이터 송/수신 때마다 발생하는 스레드 대기 시간에 다른 작업들을 선택해 실행하는 방식으로 코딩해야 합니다.
위의 원칙에 기반해 [그림 2]에서 다룬 전자책 다운로드를 처리하는 웹 페이지의 코드를 다음과 같은 2개의 작업으로 나눌 수 있습니다.
- 작업 A: 사용자 인증 확인, 요청한 책의 구매 확인, 책 파일 읽기
- 작업 B: 파일 전송
“작업 A”의 경우 “책 파일 읽기”를 위한 디스크 I/O 시간이 10초가 걸리지만, 그 이전까지의 코드를 수행하는 데 1밀리 초가 걸린다고 가정해 보겠습니다. 그럼, 스레드를 담당한 CPU는 “작업 A”를 수행해 파일 읽기를 시도하고 대기하는 동안 또 다른 요청을 1ms * 10,0000개까지 받아서 처리할 수 있습니다. 즉 단일 스레드 하나로 사용자의 요청을 일만 개까지 받아들일 수 있게 된 것입니다.
만약 파일 전송에 해당하는 “작업 B”가 마찬가지로 1ms 걸린다고 가정하고 그것을 위해 또 하나의 스레드를 배정해 운영한다면 이상적인 환경에서 단 2개의 스레드만으로 10,000개의 요청을 처리할 수 있는 능력을 갖게 됩니다.
근래 들어 단일 컴퓨터에서 컨테이너 기술을 활용해 다중 서비스를 운영하는 환경으로 바뀌고 있는데, 소수의 스레드로 다중 요청을 처리할 수 있는 이러한 비동기 처리는 웹 서비스의 필수 요건이 되고 있습니다.
비동기 처리의 문제점
지금까지 설명한 바에 따르면 비동기 처리를 하지 않을 이유가 없어 보입니다. 하지만 그렇게 좋은 데도 왜 그동안 일반적인 웹 서비스에서 동기 방식으로 코드가 작성되었을까요?
비동기 처리의 가장 큰 문제점은, 동기 방식에서 단일하게 순차적으로 처리하던 코드가 작업 단위로 쪼개져 비-순차적으로 실행된다는 점입니다. 이 말이 의미하는 바를 사용자 2명이 전자책 다운로드를 요청한 경우로 예를 들어 설명해 보겠습니다. 우선, 동기 방식으로는 다음과 같이 2개의 스레드(T1과 T2)에서 간단하게 처리할 수 있습니다.
- 스레드 T1 => 작업A, B를 순차적으로 실행 (사용자 1로부터 요청)
- 스레드 T2 => 작업 A, B를 순차적으로 실행 (사용자 2로부터 요청)
하지만, 이것을 비동기로 처리하게 되면 다음과 같이 단일 스레드 T1에서 복잡하게 비-순차적으로 실행됩니다.
- 스레드 T1 => 작업 A를 실행 (사용자 1로부터 요청)
- 스레드 T1 => 작업 A를 실행 (사용자 2로부터 요청)
- (…약 10초 후…)
- 스레드 T1 => 작업 B를 실행 (사용자 1의 ReadFile API 요청에서 발생한 읽기 완료 시점)
- 스레드 T1 => 작업 B를 실행 (사용자 2의 ReadFile API 요청에서 발생한 읽기 완료 시점)
심지어 위의 경우는 비동기 알림을 하나만 받는 경우에 불과합니다. 실제 업무에서는 수십 개의 I/O 호출이 있음을 감안하면 비-순차적으로 실행되는 프로세스가 훨씬 더 복잡해지므로 개발자가 이해하기 어려운 구조가 됩니다.
그나마 다행이라면 근래의 프로그래밍 언어들은 비동기 처리를 위한 특별 문법을 채택해 그러한 복잡성을 추상화시켜 개발자로 하여금 쉽게 코딩할 수 있게 도와줍니다. 하지만, 추상화로 인해 내부 동작이 숨겨진 만큼 그것을 이해하지 못하는 개발자가 작성한 코드에서 버그가 발생하는 부작용도 함께 발생하는 추세입니다.
비동기를 도입했을 때 문제가 되는 또 하나의 경우는, 원인 분석(troubleshooting)이 쉽지 않다는 점입니다. 예를 들어, 전자책 다운로드의 ReadFile에서 10초가 걸려 서비스 장애 현상을 겪었다고 가정해 보겠습니다. 한창 장애 현상이 발생하는 시점에 해당 프로세스의 덤프를 남겨 사후 분석을 할 수 있는데요, 기존의 동기 방식이라면 이때 시스템에는 약 10,000개의 스레드가 생성된 것을 확인할 수 있고 다수의 스레드에서 호출 스택의 가장 상단에 ReadFile이 있게 됩니다. 즉, 해당 API 호출이 늦어 장애가 발생했음을 쉽게 유추할 수 있습니다.
반면 비동기로 만들어졌다면 어떻게 될까요? ReadFile에 대한 비동기 호출 자체는 빠르게 수행되므로 스레드의 콜 스택에 남는 순간을 포착해 덤프 파일을 남길 확률은 높지 않습니다. 또한 기껏해야 2개 정도의 스레드가 하나는 “사용자 인증 확인, 요청한 책의 구매 확인, 책 파일 읽기”에 해당하는 코드의 중간 어디 쯤을 가리킬 것이고, 또 하나는 “파일 전송” 코드의 어느 부분을 가리키고 있을 것입니다. 즉, 해당 서비스가 ReadFile로 인해 느려진 것인지 단정 지을 수 있는 근거가 없으므로 시스템의 종합적인 다른 성능 수치들, 이런 경우 디스크 I/O 성능 데이터까지 함께 있어야만 문제의 원인을 추측할 수 있게 됩니다. 현업에서 만들어지는 웹 서비스의 코드가 하나의 비동기 I/O만 포함하는 경우는 거의 없다는 것을 고려해 보면 어떤 I/O 단계에서 문제가 발생한 것인지를 밝혀내는 것은 당연히 더 어려울 것입니다.
성능 모니터링 제품에서의 비동기 처리…
이번 글에서는 동기와 비동기 방식에 대한 차이점을 설명했습니다. 비동기 방식 나름대로의 단점도 무시할 수 없지만 그것을 만회할 수 있는 충분한 장점이 있으므로 현재 개발되는 웹 서비스들은 프로그래밍 언어 및 개발 프레임워크가 제공하는 기능을 빌어 비동기 방식으로 구현되는 추세입니다. 그리고 이러한 변화는 해당 서비스를 모니터링하는 제품에게도 영향을 주게 되는데요, 이에 대해서는 별도의 글에서 좀 더 깊이 있게 다뤄보겠습니다.