티스토리 뷰

study/Java

TCP/IP Socket 프로그램 구현시 고려사항

알 수 없는 사용자 2008. 4. 18. 00:00
CPU나 memory와 같은 자원은 항상 한계점이 있기 마련입니다. 그 한계점에 도달했을 때,
어떻게 동작케 하도록 조정하겠느냐의 문제가 매우 중요합니다. 이러한 고민의 여부가
때론 프로그래머의 수준이 실무적인 경험이 있느냐 그렇지 않느냐의 차이로 나타납니다.

TCP/IP Socket 프로그램을 짤때, 크게 수준에 따라 네가지 방법이 있습니다.

첫째, ServerSocket에서 accept()상태에서 대기하다가 accept()에서 요청이 떨어지면
그제서야 new YourThread(client)를 생성하여 해당 Socket 요청을 처리하는 Thread를
만들고, ServerSocket 은 다시 accept()의 while loop로 돌아가는 것이지요.
이것의 문제는 두가지인데, 하나는 매 요청마다 Thread가 생성되고 처리가 끝나면
GC에 의해 사라지므로 부하를 매우 많이 받는 구조라는 것입니다. 두번째 문제는, 이렇게
짰다는 것은 한계상황에 대한 처리구조가 없다는 것입니다. 몇개의 Thread가 Job을
동시에 처리하면 해당 H/W의 한계점에 이르를 것인지를 확인하고, 그 개수제한을 두어야
하는데, 전혀 이러한 고려가 없으니, 일정한 부하 이상의 상황에 직면하게 되면 요청이
지속적으로 큐잉된다거나 혹은 생각지 못했던 Exception들을 만나게 될 수도 있습니다.
어떤 자원이 고갈되어 한계상황에 직면했을땐, 추가적인 요청들에 대해서는 fail을
발생하여 곧바로 return케 하여야만이 장애가 일어나지 않습니다.
PS : 이를 PPC(Peek Point Conrol) 기법이라고 명명하였었습니다.

두번째 방법은 Thread Pooling을 사용하는 방식입니다. 대부분의 웹어플리케이션서버의
엔진소스에서 서블렛/JSP를 처리하는 내부적인 방식이 이렇게 되어 있습니다.
요청을 처리하는 Thread는 해당 프로그램이 처음 뜰 때 이미 50개면 50개, 100개면
100개가 모두 기동됩니다. 그 Thread들은 run()메소드에서 while loop를 돌며 무한루프에
빠져 있는데, run() 메소드 첫부분에서 모든 Thread들이 어떤 lock 객체에 synchronized
되어 wait()상태에 빠져 있습니다.
만약, ServerSocket의 accept()에서 요청이 들어오면, 기본적인 정보만 추출하여 이를
적당한 queue에 해당 client socket을 큐잉하고, Thread들이 wait()되어 있는 lock을
notify()/notifyAll() 시킵니다. 그러면 수 많은 Thread들 중 한 Thread가 깨어나
queue의 값을 꺼내어 SocketClient 요청을 처리하고 자신은 다시 무한루프에 의해
다시 wait() 됩니다.
이 같은 구조는 앞서의 문제를 모두 해결합니다. 즉, 매번 Thread를 생성하지 않으니
부하가 없고, 또 초기에 몇개의 Thread들을 기동시켜두느냐는 결국 동시에 수행할
개수제한의 효과가 되는 것이지요. 그 개수는 Performance Tuning의 방법론에 따라
해당 H/W와 S/W환경에 맞게 적정값으로 조정되는 것이지요.

세번째 방법은 Socket Server측에서 Thread Pooling을 사용하는 것은 두번째 방법과
동일한데, Socket Client와 Server 사이의 TCP/IP Socket 연결 자체를 어떻게 Pooling
할 것인가의 문제가 추가됩니다. 지금까진 매번 Socket을 open하고 close하는 구조로
가져갔을 겁니다.그러나, socket open은 부하가 걸리는 작업이므로 미리 socket을
열어두고 이를 pool에 넣어 둔 후, 필요히 pool에서 꺼내와 사용하는 것이 보다 효율적일
것입니다. 이는 Socket client와 server 측 모두에서 고려되어야 합니다. 5개면 5개,
10개면 10개 미리 Socket들이 맺어져 있고, 필요시 가져다 사용하고 모두 사용하고 난 뒤
곧바로 close하는것이 아니라 pool로 돌려보내는 방법이지요.
이 방법은 socket을 매전 open/close하지 않으니 성능향상을 볼 수 있습니다. 그러나
단점은 Pool에 연결되어 있는 Socket들이 어떤 N/W 장애로 인해 물리적인 단절이 됐을
경우, 이를 어떻게 Recovery 할 것인가의 이슈가 추가로 고려되어야 합니다.

네번째 방법은 앞서 세가지의 기법이 모두 적용된 상태에서 다시 추가 됩니다.
즉, 앞서의 방법은 socket pool을 사용하므로, send를 날린 후 아직 receive를 받지 않는
동안 이 TCP/IP socket은 멍청하게 놀리고 있는 결과가 되고 맙니다. 즉, 실질적인
데이타는 이용되고 있지 않으면서도 매우 많은 수의 TCP/IP Socket이 필요하게 됩니다.
그래서 send 전용 socket과 receive전용 Socket을 별도로 구분하게 됩니다. 결국, send용
queue와 receive용 queue가 만들어져야 하고, client는 send용 queue에 넘길 데이타를
채운 후, send용 Thread들을 notify시킵니다. 곧바로 receive queue에서 자신의 데이타가
오기를 기다려야 겠지요. Receive용 Socket에 달려있는 receive용 Thread는 데이터가
오는 즉시 receive용 queue에 값을 채워넣고, 자신의 데이타가 오기를 기다리는 Thread
의 lock을 notify 시킵니다. (잘 깨워야겠지요, 모두 깨울것이냐, 혹은 해당 데이타에
관련된 것만 깨울 것인가가 고민되겠지요)
이 경우는 또한, send후 receive하기까지의 Timeout옵션을 지정할 수 있는 잇점도 가질
수 있습니다.
단, 이 경우의 단점은 성능은 가장 빠르지만, 서로 다른 데이터들이 영향을 미칠 수
있습니다. 앞서 보낸 데이타에 이용된 send socket 이 장애를 일으키면 뒤따로 보낸 데이터
역시 깨어질 수 있는 것이지요.

또, 자칫 구현을 잘못하면, 동일한 Socket으로만 계속 데이터를 보내려는 시도를 하게
됩니다. OutputStream의 write()는 순식간에 끝나지만, 실질적인 데이터는 OS나 네트웍
카드의 Send Queue에 대기중에 있을 수 있으며 아직 상대방에 받지 않았을 수 있습니다.
이 때, 같은 Socket에다 또 다시 데이터를 날리면 계속 큐잉만 일어나고, send용과
receive용을 구분했던 장점들을 제대로 살리지 못하게 되는 것이지요.

주고 받는 데이타는 반드시 header와 body로 구분된 약정된 프로토콜을 정의해서
사용해야 합니다. 예를 들면 10byte을 먼저받아서 기본적인 정보와 body에 따라올
실제 데이타의 길이를 확인하여 해당 길이만큼의 body 데이타를 다시 읽는 것과 같은
동작이지요. 만약, header가 부정확한 정보들로 채워져 있거나 header에서 명시된 것보다
body 데이타가 짧다면 적절한 에러처리를 해야 겠지요. OutputStream으로 단 한번에
write()를 한 byte[] 데이타들이, 받는 측에서 InputStream의 read(buf)를 통해 곧바로
받을 것이라고 여기는 경향이 있습니다.
N/W의 상황에 따라 몇 byte씩 나눠서 날아가지요. 에러가 나기전까지, timeout이 되기
전까지, 그리고 모든 데이타가 올 때까지 loop를 돌면서 끝까지 받아내야 한다는 것을
아셔야 합니다.