소켓에서 발생하는 이벤트 - 소켓에서 읽기와 쓰기(2/2)
소켓으로부터 데이터의 읽기, 쓰기 작업은 데이터를 수신, 송신하는 동작입니다.
소켓의 특성에 따라 두 가지로 나눌 수 있는데, Blocking socket과 Non-Blocking socket 입니다. 어쩌면 Non-Blocking 소켓을 처음듣는 분이 계실지도 모르겠네요. 소켓 프로그래밍을 다룬다고 하면 Blocking 소켓을 의미하는 경우가 많습니다. 이제 두 가지 소켓 종류에 따라 읽기, 쓰기 작업의 구현이 어떻게 달라져야하는지 알아보겠습니다.
Blocking 소켓
소켓에서 처리되기를 원하는 작업이 완료된 이후 함수가 반환되어 다음 작업이 수행됩니다. 예를 들어, 쓰기(send) 작업의 경우에 내가 전송하려는 데이터가 모두 전송되면 send() 함수가 리턴됩니다.
Non-Blocking 소켓
소켓에서 현재 처리될 수 없는 작업이면 기다리지 않고 즉시 반환됩니다. 반환값에 따라 적절한 처리가 필요합니다.
읽기, 쓰기 함수의 동작을 구체적으로 알아보고 구현상 주의해야할 점에 대해서 적어보겠습니다.
1. 읽기작업(recv 함수)
수신 구현을 알아보기 전에 먼저 수신 버퍼의 존재를 이해 해야합니다. 앞의 블로그(링크)에서 설명한 Receive Buffer에 대한 설명을 가져왔습니다.
====
Receive Buffer: 원격으로부터 전송받은 데이터를 임시로 저장하는 공간입니다. 데이터를 수신하게 되면 임시로 이 공간에 저장합니다. 이 공간은 커널영역의 메모리입니다. 어플리케이션에서 소켓으로부터 읽기를 시도하면 이 버퍼로부터 데이터를 가져오는 동작을 수행합니다. (즉, 커널영역의 메모리 공간에서 유저모드의 메모리 공간으로 복사가 발생함)
====
Blocking Socket를 다룰 때, recv() 함수의 동작은 아래와 같습니다.
recv() 함수를 호출하는 시점에,
(1) Receive Buffer에 수신된 데이터가 이미 있다면, 그 데이터를 가져온 후에 반환.
(2) Receive Buffer가 비어있고 연결이 유지된 상태라면 Receive Buffer에 데이터가 수신될 때까지 무한대기(함수 반환 안됨/무한대기가 기본값이며 이를 변경하는 것은 가능함)
(3) 연결이 종료된 상태라면 0 값을 반환
(4) 소켓 에러가 발생상태라면 -1를 반환(실제 에러가 무엇인지 errno 값 확인 필요)
(1)번 동작에 대해서 추가로 유의해야할 사항이 있습니다. recv() 함수를 호출할 때, 저장될 공간(유저 메모리 공간)의 사이즈를 파라미터로 넘기는데, Receive Buffer에 수신된 데이터의 사이즈가 이 공간의 크기보다 크다면 공간의 사이즈를 꽉 채워서 반환됩니다. 즉, 반환값이 파라미터의 공간 사이즈만큼 최대치로 반환될 경우, Receive Buffer에 데이터가 남아 있는 상태이고, 이 시점에 다시 recv()를 호출하여 읽어올 것인지, 다른 비지니스 로직 수행 후 나중에 읽어올지 여부를 구현상 고려해야합니다. 중요한 것은 Receive Buffer에 읽어올 데이터가 남아있다는 것이고, 이 상황을 이해하고 내가 원하는 로직에 따라 적절히(이번에 더 읽을지, 다음에 읽을지) 처리해야 한다는 점입니다.
Non-Blocking Socket를 다룰 때, recv() 함수의 동작은 아래와 같습니다.
recv() 함수를 호출하는 시점에,
(1) Receive Buffer에 수신된 데이터가 이미 있다면, 그 데이터를 가져온 후에 반환.
(2) Receive Buffer가 비어있고 연결이 유지된 상태라면 즉시 함수가 반환되고 반환값은 -1 입니다. errno 값은 EAGAIN 또는 EWOULDBLOCK 입니다.
(3) 연결이 종료된 상태라면 0 값을 반환
(4) 소켓 에러가 발생상태라면 -1를 반환(실제 에러가 무엇인지 errno 값 확인 필요)
여기에서도 주의해야할 사항이 있습니다. -1이 리턴된 경우에 반드시 errno 값을 확인하고 적절히 에러처리를 해야합니다. errno 값은 EAGAIN 또는 EWOULDBLOCK 이 아니라면 소켓에러이므로 연결 종료에 해당하는 처리를 해야합니다. errno 값은 EAGAIN 또는 EWOULDBLOCK 이면 수신된 데이터가 없음에도 recv()를 호출한 것이고, 이런 호출은 수행하지 말았어야합니다. 다시한번 이전 블로그(링크) 내용을 참고할 필요가 있습니다. Non-Blocking 소켓을 다루고 있다면 반드시 Receive Buffer에 수신된 데이터가 있는지 먼저 검사(이벤트 트리거 사용)한 후에 해당 이벤트(수신된 데이터 있음) 상태일 때, recv() 함수를 호출해야합니다. 추가로, recv()를 통해서 읽어온(반환된 수신 데이터 사이즈) 데이터의 사이즈를 체크하여 파라미터로 입력한 공간의 사이즈의 최대치일 경우, 추가 처리는 Blocking Socket 일때와 동일합니다.
2. 쓰기작업(send 함수)
송신 구현도 마찬가지로 Send Buffer에 대한 이해가 필요합니다.
====
Send Buffer: 원격으로 데이터를 전송하기 전에 임시로 이 버퍼에 저장합니다. (이 공간도 커널영역의 메모리 입니다.) 어플리케이션에서 데이터 전송(send)를 수행하면 실제로는 이 버퍼에 데이터 쓰기 작업을 진행합니다. 네트워크 스택은 이 버퍼의 데이터를 전송함으로써 실제로 데이터 전달이 수행되는 것입니다.
====
Blocking Socket를 다룰 때, send() 함수의 동작은 아래와 같습니다.
send() 함수를 호출하는 시점에,
(1) Send Buffer에 비어있는 공간이 전송하려는 데이터보다 크다면 즉시 Send Buffer에 복사되고 함수 반환.
(2) Send Buffer에 여유 공간이 부족하면, 버퍼에 공간이 생길 때까지 대기합니다. 공간이 확보되면(즉, 원격 서버에 실제로 데이터를 전송하면 비워짐, 전송 작업 자체는 커널에서 처리합니다.) 나머지 데이터를 복사한 뒤 send()가 반환됩니다. 즉, 데이터 전체가 송신 버퍼에 복사되기 전까지 함수는 반환되지 않습니다.
(3) 연결이 끊긴 소켓이었다면 -1를 반환하고 errno가 EPIPE 또는 ECONNRESET 입니다. 끊어진 소켓에 send()를 호출하면 SIGPIPE 시그널이 발생합니다. 이 시그널을 처리하는 로직을 구현해야하는 이유가 있는데, SIGPIPE를 처리하는 구현이 없다면, SIGPIPE를 프로세스 자체적으로 처리하게 되고, 이 시그널의 기본처리는 프로세스 종료입니다.(개발언어나 런타임 환경에 따라 SIGPIPE 처리가 내장된 경우도 있고, send() 함수 자체에 SIGPIPE가 발생하지 않도록 호출하는 방법도 있습니다. 즉, SIGPIPE에 대한 상황을 이해하고 적절히 처리하는 것이 필요합니다.)
(2)번 동작에 대해서 약간의 주의가 필요합니다. 네트워크가 느리거나, 수신하는 서버가 이전에 수신한 데이터의 처리 지연으로 인해 이후 데이터의 수신을 수행할 수 없을 때, 송신측의 Send Buffer는 꽉찬 상태로 유지되면서 send() 함수의 반환이 매우 늦어질 수도 있습니다. 즉, 수신 서버의 구현(의도했던 의도하지 않았던...)에 따라 나의 send() 함수의 반환이 늦어질 수 있다는 점을 이해하고 있어야합니다.
Non-Blocking Socket를 다룰 때, send() 함수의 동작은 아래와 같습니다.
send() 함수를 호출하는 시점에,
(1) Send Buffer에 비어있는 공간이 전송하려는 데이터보다 크다면 즉시 Send Buffer에 복사되고 함수 반환.(Blocking socket과 동일)
(2) Send Buffer에 비어있는 공간이 전송하려는 데이터보다 작다면, Send Buffer의 빈공간만큼 데이터를 복사하고, 함수는 즉시 반환됩니다. 따라서, send 한번 호출로 Send Buffer에 복사된 크기를 체크해야합니다(일반적으로, 리턴값으로 확인 가능). 나머지 데이터를 전송하기 위해서 다시 send를 호출할 수 있지만, 그 사이에 Send Buffer가 비워지지 않았다면 아무런 소용없이 호출만 낭비된 것입니다. 함수도 -1를 반환하고, 즉시 실패(errno 값은 EAGAIN 또는 EWOULDBLOCK)로 반환되므로 재시도 타이밍이 중요하다고 할 수 있습니다. 따라서, Send Buffer의 상태를 체크하는 처리에 대한 이전 블로그(링크) 내용을 참고해서 전송할 수 있는 시점에 send를 호출하도록 구현해야합니다.
(3) 끊어진 소켓의 경우는 Blocking Socket 일때와 동일합니다.
요약하자면...
소켓 프로그래밍에서는 소켓의 동작 특성에 대한 이해가 필수입니다. 송수신 함수의 파라미터 특성과 송수신 버퍼의 상태, 함수 리턴값 등을 확인하면서 추가 처리 구현 또는 에러처리를 포함한 구현이 되어야합니다. Blocking Socket 에서 의도치 않은 무한대기가 발생하지 않도록 해야하고, Non-Bloking Socket 에서 수신 또는 송신 가능한 버퍼의 상태를 파악하여 구현해야합니다.