들어가며
오늘은 서버 개발에 대해 알아 보자. 특히 서버 성능과 직결 되는 서버의 스레딩 모델에 대해 알아 보도록 하겠다.
본격적인 내용을 다루기 전에 서버에서 말하는 성능에 대한 정의를 먼저 해보록 하자. 보통 서버에서 주요한 성능 측정 요소는 응답시간이다. 클라이언트로 부터 요청을 받고 결과를 가공하여 응답 하기 까지 시간이 얼마나 걸리는지가 서버의 성능을 측정하는데 주요 요소로 사용 된다.
그렇다면 이 응답 시간에 가장 영향을 미치는 요소는 무엇일까?
첫번째는 IO 연산이다. 파일이든 네트워크든 IO 연산이 발생하면 아래 그림과 같은 과정이 발생한다.
서버 어플리케이션이 IO를 요청하면 OS를 거쳐 하드웨어 까지 전달되고, 하드웨어에서 작업이 완료 될때까지 작업은 블로킹 된다. 이 시간을 디바이스 타임(device time)이라고 한다. 디바이스 타임이 길다는 것은 어플리케이션이 아무것도 하지 않고 놀고 있는 시간이 길다는 뜻이다.
두번째는 메모리 복사다. 네트워크 드라이브에 도착한 데이터를 OS에 복사하고 OS는 다시 어플리케이션에 복사하고, 어플리케이션 내부에서도 복사가 발생할지도 모른다. 여러분도 시간나면 서버를 프로파일링 해보길 권한다. 어지간한 작업 하나당 소모 되는 cpu타임은 작업 자체 보다도 하드웨어와 OS, 어플리케이션간 메모리 복사에서 가장 많이 소모 된다. 물론 서버 로직에서 cpu를 많이 쓰는 작업을 한다면 비율은 바뀔 수 있겠지만 어쨌거나 절대적으로 메모리 복사하는데 많은 cpu 타임을 할애 하고 있는 것은 사실이다.
그리고 마지막 서버에서 처리하는 작업 그 자체가 응답시간에 영향을 미친다. 각 작업들을 처리하기 위해서는 cpu를 사용해야 하는데, cpu라는 자원은 한정적이기에 언제나 자기 순서를 기다려야 한다. 만일 작업이 길고 많다면 자신의 순서가 돌아 오기 까지 한참을 기다려야 할 것이다.
그럼 서버의 성능을 극한으로 끌어 올리기 위해서는 어떻게 해야 할까? 당연히 위 요소들을 최소한으로 만들면 된다. IO가 완료 될 때까지 기다리지 않고, 메모리 복사를 최소화하고, cpu가 할당 되길 기다리는 시간을 최소화 하면된다.
말은 쉽지만 어떻게 해야 할지는 막막하다. 이제 부터 그 방법들을 알아 보자.
싱글 스레드, 싱글 프로세스 모델
싱글 스레드는 동시에 처리 할 수 있는 작업이 하나라는 뜻이다. 때문에 동기화로 인한 대기 시간이라던지 데드락에 대한 이슈가 없다. 서버가 처리하는 작업중의 대부분이 공유 되는 자원에 대한 업데이트가 필요해, 전역 락을 걸어 줘야 할 필요가 있어서 멀티 스레드를 사용해도 별 효과가 없는 서비스이며, DB나 다른 서버와의 연결 또는 파일 억세스 같은 디바이스 타임이 없는 경우 사용하면 좋은 구조다.
주로 select, poll 등을 이용해 간단히 구현되며, 서버보다는 클라이언트에서 자주 채택하는 모델이다.
- 장점 : 구조가 간단하고 만들기 쉽다.
- 단점 :
블로킹 작업이 많다면 cpu가 노는 시간이 많아지고 그만큼 성능이 떨어진다.
서버 프로세스를 여러개 띄울 수 있는 분산 서버로 개발 된 것이 아니라면 멀티 코어 환경의 이점을 살릴 수 없다.
싱글 스레드, 멀티 프로세스 모델
싱글 스레드, 싱글 프로세스 모델의 단점인 멀티 코어를 활용하지 못한다는 것을 해결해 줄 수 있는 모델이다.
부모 프로세스가 요청을 받으면 자식 프로세스를 fork하여 해당 요청을 처리한다. 자식 프로세스들은 각각 싱글 스레드 기반 모델이기 때문에 싱글 스레드, 싱글 프로세스의 장단점을 그대로 가지고 간다.
주로 프로세스 컨텍스트 스위칭 비용이 비교적 저렴한 리눅스 운영 체제에서 구동 되는 서버 모델 이다.
- 장점 : 구조가 간단하고 만들기 쉽다.
- 단점 : 프로세스로 나뉘어 있어 자원 공유(메모리)가 어려우며, 이로 인해 전체 시스템에서 사용하는 자원을 컨트롤 하기 어려워 심하면 디스크 스와핑 상태 까지 빠질 수 있다.
생성가능 프로세스 보다 많은 요청이 들어오는 경우 처리 불가 상태에 빠지게 된다.
연결에 대한 상태를 유지 할 필요가 없고, 짧은 시간에 완료 되며, 각 요청들이 자원을 공유 할 필요가 없는 웹서버 같은 서비스에 적합한 형태다. 웹서버로 유명한 아파치가 싱글 스레드 멀티 프로세스 모델로 구현 되어 있다.
싱글 로직 스레드, 멀티 IO 스레드
전역적으로 동기화가 필요한 데이터를 주로 다루거나, 모든 요청이 순서대로 처리 되어야 하는데 DB나 다른 서버와의 연결이 필요한 경우 적합한 모델이다. 서비스 로직은 단일 스레드에서 처리하지만, Network IO, File IO, Timer 등 블로킹 작업들은 개별 스레드들이 각자 처리한다.
동기화를 위해 메인 로직 스레드와 연결된 큐와 그 큐에 대한 동기화가 필요하다.
nodejs.와 같은 싱글 스레드 기반이라고 하는 서버 프레임 워크가 이런 모델을 가지고 있다.
- 장점
동기화 과정이 필요하지 않다. 따라서 싱글 스레드와 동일한 장점을 가질 수 있다.
IO와 같은 디바이스 타임이 필요한 작업을 별도의 멀티 스레드에서 처리하므로 어플리케이션은 쉬지 않고 계속 일을 할 수 있다. - 단점
동기화가 필요 없는 작업들도 강제로 동기화 되기 때문에 멀티 코어 환경에서도 로직 스레드가 돌고 있는 cpu만 열심히 일하고 나머지는 논다.
별도 스레드에서 완료 된 작업을 계속 처리 해줄 콜백 함수들이 필요하다. 이로 인해 콜백 지옥이 만들어 질 수도 있다.
풀(full) 멀티 스레드 방식
요청을 처리함에 있어서 모든 과정을 개별 스레드에서 처리하는 방식. 각 요청이 개별 데이터에 접근하고, 각 요청별로 처리 순서가 중요하지 않을 때 적합한 방식이다.
- 장점 :
멀티 코어의 성능을 최대한 끌어 낼 수 있는 모델이다. 여러 코어를 사용하므로 작업의 CPU 할당 대기시간을 최소화 할 수 있다.
단일 프로세스이므로 스레드간 자원 공유가 가능하다. - 단점 : 만들기 어렵다.
공유 자원에 접근 할 때 레이스 컨디션에 항상 신경 써야 하며, 각 연결 내에서 작업 처리 순서가 중요하다면 개별 큐도 만들어줘야 한다.
스레드가 너무 많아지게 되면 오히려 컨텍스트 스위칭 때문에 성능이 더 떨어 질 수 있다.
비동기 IO(async IO)
비동기 IO는 요즘 시대 대부분에서 지원한다. 어플리케이션은 IO작업을 OS에게 요청 후 완료를 기다리지 않고 바로 리턴 후 다른 작업을 계속한다.
운영 체제로는 윈도우의 IOCP(IO Completion Port), 리눅스의 epoll이 있고, C#과 자바 스크립트에서 async 라는 키워드로 지원하고 있다.
- 장점 : IO가 완료 될 때까지 프로그램이 블로킹 되지 않고 다른 작업을 처리 할 수 있으므로 CPU활용도가 올라간다.
- 단점
OS로 부터 완료 통보를 받아야 하는 콜백을 등록 해야 하므로 콜백 지옥을 경험 할 수 있다. 하지만 언어 레벨에서 지원하는 async IO를 이용하면 IO작업이 완료 되었을 때 IO를 요청하고 중단 되었던 부분 이후 부터 다시 프로그램을 진행 시켜주므로 꼭 콜백 지옥을 경험하는 것은 아니다. 다만 이 때는 OS에 의해 백그라운드 스레드가 호출 되므로 작업을 할 스레드를 명시적으로 지정 할 수 없다.
코루틴(Coroutine)
코루틴은 여러 개의 진입점과 중단점을 가지는 특수한 함수다.
일반적인 함수의 진입점은 함수의 시작 부분, 딱 하나다. 어떠한 경우라도 함수를 호출하면 함수의 가장 처음 부터 시작하게 된다. 그리고 도중에 return문을 만나거나 함수의 끝까지 실행 하면 함수를 종료한다.
하지만 코루틴은 여러개의 진입 점과 여러 개의 중단점을 가질 수 있고, 루틴이 종료되기 전까지 몇 번이든 진입과 중단을 할 수 있다. 여기서 '리턴(return)' 이라는 말 대신 '중단(suspend)'이라는 용어를 사용한것을 주목하자.
리턴은 함수를 완전히 종료하고 스택 메모리에 할당된 모든 리소스를 해제하는 것을 의미하지만 중단은 힙 메모리 영역에 다시 재개(resume)하기 위해 필요한 모든 정보 - 코루틴 스테이트(coroutine state)라 합니다 - 를 저장하고 제어권을 호출자에게 다시 넘기는 것을 의미 한다.
그리고 호출자가 다시 재개(resume)를 요청을 하면 힙 메모리 영역에 저장되어 있던 코루틴 스테이트의 정보를 가져와 복구하여, 중단 되었던 바로 다음 부터 함수를 다시 진행 할 수 있다.
코루틴은 앞에서 말한 프로그램의 주요 성능 요소들에 직접적으로 영향을 끼치진 않지만 다양한 방법으로 프로그램을 더 쉽게 만들 수 있도록 도울 수 있다.
예를 들어 게임 엔진으로 유명한 유니티의 경우 싱글 스레드 기반으로 동작한다. 일반적으로 싱글 스레드 환경에서는 비동기 작업이 불가능 하지만(작업이 완료 되었을 때 OS에서 호출 해줄 스레드가 없으므로) 유니티에서는 코루틴으로 그 문제를 해결 한다. 유니티의 코루틴 함수 내에서 비동기 작업을 시작하면 해당 코루틴을 중단하고 호출자로 돌아가 다음 다음 작업을 계속 진행하다. 그리고 매번 업데이트 시점마다 해당 코루틴의 비동기 작업이 완료되었는지를 체크하고 완료 된 경우 다시 코루틴을 재개시키는 방법으로 비동기 작업을 지원한다.
다른 예로 "싱글 로직 스레드, 멀티 IO 스레드" 모델에서도 유용하게 쓰일 수 있다. 로직 스레드에서 다른 스레드로 비동기 작업을 요청하고 완료된 결과를 돌려 받기 위해서는 콜백 함수를 등록해야만 한다(그리고 이 콜백 함수 등록 때문에 복잡한 프로그램의 경우 콜백 지옥을 맛 볼수 있다고도 했다). 하지만 콜백 함수 대신 코루틴 객체를 등록하고 완료 시 해당 - 로직 스레드에서 - 콜백의 재개(resume)을 호출 하도록 한다면 단일 함수 내에서 비동기 작업을 순서대로 처리 할 수 있다. 보다 자세한 설명은 [여기]에 정리 되어 있다.
마치며
이상 서버 개발함에 있어서 성능에 영향을 미치는 부분과 문제를 해결하기 위한 다양한 스레드 모델들의 장단점을 살펴 보았다. 서버 아키텍쳐를 설계함에 있어서 모든 조건에서 최상의 결과를 만들어 내는 소위 '정답'은 없는 것 같다(최소한 나는 아직 찾지 못 했다).
최선의 정답은 개발하려는 서비스의 요구 사항과 주어진 환경을 보고 최선을 선택을 하는 것이라 생각한다.