서버 프로그램을 개발하고 운영하다 보면 서버의 응답이 지연되는 문제를 만날 수 있습니다. 다양한 문제의 원인이 있을 수 있지만, 이 글에서는 서버 애플리케이션 외부에서 발생하는 문제의 원인에 집중해 보려 합니다. (모든 설명은 TCP 프로토콜 위에서 동작하는 애플리케이션임을 가정합니다)
저는 최근에 TCP 기반 파일 서버를 구현하면서 파일 서버의 응답이 지연되는 문제를 만났습니다. 서버가 1GB 파일을 읽어서 응답하는데 1분이 넘는 시간이 걸렸고 원인을 분석하면서 서버의 응답 전송 동작 자체가 지연되는 두 가지 케이스를 발견했습니다. 하나는 의외로 클라이언트가 데이터를 늦게 읽어가기 때문이었고, 다른 하나는 느린 네트워크 때문이었습니다. 그럼 하나씩 살펴보겠습니다.
먼저 클라이언트 애플리케이션이 TCP 수신 버퍼로부터 데이터를 느리게 읽어가면 서버의 전송 동작까지 함께 느려질 수 있습니다. 이유는 간단합니다. TCP는 수신 윈도우(Receive Window)라는 개념을 사용해 내가 수신할 수 있는 버퍼의 가용량을 TCP 응답 헤더에 실어 상대방에게 알립니다. 그러면 상대방(송신측)은 절대 내가 알린 나의 수신 윈도우 크기 이상의 데이터를 보내지 않습니다.
이 수신 윈도우는 네트워크로부터 데이터를 수신하면 버퍼가 채워져 가용량이 줄어들고, 애플리케이션이 데이터를 읽어가면 버퍼가 비워져 가용량이 다시 커집니다. 따라서 네트워크로부터 데이터를 수신하는 속도(즉 상대방이 전송하는 속도)보다 애플리케이션이 데이터를 읽어가는 속도가 느리면 시간이 지나면서 수신 윈도우가 점점 작아지거나 가득차게 됩니다. 그러면 전송측에서는 데이터를 조금만 보낼 수 있거나 아예 보내지 못하는 상황이 발생합니다. 따라서 서버의 전송 동작이 결과적으로 함께 지연됩니다. 대게 N바이트에 대한 쓰기 API 호출 결과가 0바이트를 썻다는 응답을 주는 경우가 있다면 상대방 수신측에서 데이터를 읽어가지 않고 있음을 의심해 볼 수 있습니다. 제가 경험한 파일 서버의 응답 지연 문제에서도 간헐적으로 N바이트 전송 요청 결과가 0 바이트를 썻다는 결과를 받았는데 확인 결과 클라이언트에서 대용량 버퍼 할당으로 인해 I/O 쓰레드에 지연이 계속 발생하고 있었습니다. 서버와 클라이언트가 대용량 파일을 다루는 구조를 개선하여 클라이언트의 버퍼 할당 지연을 제거한 후 1분이상 걸리던 1GB 파일 패치 시간을 5~10초로 개선할 수 있었습니다.
위에서 만난 문제를 개선한 뒤 저는 파일 서버를 네트워크로 가져가 테스트 해보기로 합니다. 그 전까지는 제 컴퓨터 내부에서 서버와 클라이언트를 모두 띄우고 루프백 주소(127.0.0.1)로 통신하고 있었습니다. 아무래도 네트워크를 통하면 조금 느려질거라 예상은 했는데 테스트를 해보니 생각보다 훨씬 느립니다. 5~10초 걸리던 1GB 파일 패치가 끝날 생각을 하지 않았습니다. 그래서 파일 크기를 10MB로 줄여서 테스트를 해보았는데 15초가 걸립니다. 뭐가 문제일까 생각해 보다가 현재 서버와 클라이언트가 저희 집 무선 공유기를 통해 WIFI로 통신을 하고 있다는 사실을 깨달았습니다. 사실 조금 오래된 공유기라 네트워크 대역폭 자체가 낮아서 문제가 되었을거란 의심이 들었습니다. 그래서 노트북 두대를 랜 케이블로 다이렉트로 연결하고 테스트를 해보았습니다. 결과는 10MB 파일 패치에 0.2초, 100MB에 1.2초, 1GB에 9초라는 시간을 측정할 수 있었습니다. 루프백 통신할 때와 거의 유사한 성능이 나옵니다.
저희집 오래된 공유기를 통한 WIFI 통신이 느릴거란 건 추측이었기 때문에 실제로 네트워크 자체의 성능을 측정해 보기로 합니다. 도구로는 마이크로소프트에서 제공하는 PsPing이라는 도구를 활용했습니다. 성능 측정을 위해서 8KB바이트 크기의 데이터를 10,000번 반복 전송하며 응답되는데 까지의 RTT(Round Trip Time) 시간을 측정해 최소, 최대, 평균치에 대한 통계를 내보았습니다. 아래는 무선 공유기를 통하는 WIFI 채널의 결과입니다. 최소 5.64ms, 최대 888.36ms, 평균 12.23ms 라는결과를 보여줍니다. 실제로 파일 서버에서 10MB인 1000배 이상으로 큰 데이터를 보냈으니까 평균치인 12ms x 1000을 하면 12초로 실제 측정 결과인 15초와 유사합니다.
그리고 다이렉트로 랜케이블을 연결한 유선 채널을 측정해 보았습니다. 최소 0.22ms, 최대 1.36ms, 평균 0.41ms 라는결과를 보여줍니다. 실제로 파일 서버에서 10MB 파일에 대해 계산해 보면 0.4ms x 1000을 하면 0.4초로 실제 측정 결과인 0.2초와 유사합니다.
그럼 위와 같이 네트워크의 대역폭에 차이가 있을 때 애플리케이션 전송 코드 실행에는 어떤 일이 일어나는지 추가로 살펴보았습니다. 살펴본 코드는 프레임워크(Netty, JDK)를 포함한 자바 어플리케이션 가장 저수준에서 쓰기가 일어나는 sun.nio.ch 패키지의 SocketDispatcher 클래스의 native 메서드인 write0() 메서드입니다. write0() 메서드에 요청되는 데이터 크기와 실제로 전송된 데이터 크기를 로그로 남겨 모니터링해 보겠습니다.
package sun.nio.ch;
class SocketDispatcher extends NativeDispatcher {
...
private static native int write0(FileDescriptor fd, long address, int len)
throws IOException;
}
아래는 WIFI로 10MB 파일을 전송 요청했을 때의 결과입니다. 처음에는 요청한 5MB의 데이터를 한 번에 쓰지만 점점 요청한 데이터 보다 작은 데이터를 쓰기 시작합니다. 약 128KB 까지 작아진 후에 쓰기 실패와 재시도가 빈번히 일어납니다.
다음은 다이렉트로 연결된 유선 랜 채널의 결과입니다. 처음에 5MB 데이터 쓰기를 요청하고, 연이은 5MB 쓰기 요청에서 점점 작은 데이터 쓰기가 일어나기는 하지만 총 4번의 쓰기 요청을 통해 10MB 데이터 쓰기가 완료되는 것을 볼 수 있습니다.
위와 같은 차이는 네트워크의 대역폭 때문에 데이터가 느리게 전송되기 때문이기도 하지만, TCP 프로토콜의 혼잡제어 메커니즘에 큰 영향을 받는다고 할 수 있습니다. 혼잡제어란 간단히 말해 네트워크가 혼잡하다고 판단되면 단위 시간 당 전송 할 수 있는 데이터의 양을 줄여 나가는 것입니다. 네트워크 혼잡을 판단하는데는 데이터 전송 후 응답까지의 RTT(Round Trip Time) 시간이나 응답 실패 등의 지표가 활용됩니다. 따라서 살펴본 것 처럼 대역폭이 낮은, 응답이 느리게 오는 네트워크라면 TCP에 의해 단위 시간 당 전송하는 데이터의 양이 줄어들게 되고 결국 서버의 응답 속도도 느려지게 됩니다.
서버의 응답이 지연되는 경우 서버 외부의 두 요인(클라이언트의 읽기 지연, 네트워크 지연)에 의해 지연이 발생할 수 있음을 살펴보았습니다. 물론 경우에 따라서 서버 내부 로직에서 원인을 찾을 수도 있겠지만 오늘 살펴본 두 가지 중요한 외부 원인을 함께 모니터링하는 것도 잊지 말아야 겠습니다. 문득 글을 마치며 애플리케이션을 잘 설계하고 구현하는 것 만큼, 네트워크를 잘 구성하는 것이 중요하겠다는 생각이 듭니다.
파일서버가 이미 많은 종류가 있고 CDN도 여러 서비스가 있는데 굳이 파일서버를 개발할 이유가 있나요?