이전에 배운 모든 과정을 통해 웹 서버에 패킷이 도착했다. 이젠 웹 서버는 패킷을 수신하여 도착한 패킷을 처리한 뒤 응답메시지를 보내면 패킷의 송/수신이 종료되며 네트워크 공부 또한 끝날 것이다.
서버 머신은 용도에 따라 다양한 종류가 있으며 하드웨어나 OS 부분은 클라이언트와 다른 것이 있다.
클라이언트로는 Windows를 많이 활용하지만 서버에는 다중 유저가 접속 가능한 Linux를 쓰는 것이 대표적이다.
하지만 네트워크 측면에서 보았을 때는 서버와 클라이언트에는 차이가 거의 존재하지 않는다.
이는 둘이 사용하는 네트워크 머신이나 기능(LAN 어댑터, 프로토콜 스택, Socket 라이브러리 등)이 동일하기 때문이며 더욱 크게 보면 TCP/IP 기능은 하드웨어나 OS에 영향을 받지 않기 때문이다.
만약 머신에 따라 성능이나 동작이 다르다면 그 동작을 동기화시키는 과정이 있어야 하는데, 이 경우 네트워크는 사용하지 못할 정도로 복잡해질 것이므로 이렇게 하드웨어나 OS와 관계없이 기능을 통일해 놓은 것이다.
아마 네트워크에 대해 이론적으로 공부했다면 TCP/IP 계층이라거나 OSI 7 계층이라는 걸 들어보았을 텐데 바로 이 계층이 기능을 통일시켜 놓은 규격이라고 할 수 있다.
하지만 가지고 있는 네트워크 기능이 같다고 하더라도 사용 방법까지 같지는 않다.
야구 글러브가 공을 받는다는 기능은 같지만 그 크기나 규격에 따라 포수용, 1루수용, 내야수용 등으로 나뉜 것과 같다.
접속 동작을 수행할 때 클라이언트는 접속 동작을 먼저 요청하는 주체이며 서버는 클라이언트 측에서 접속 동작을 수행하기를 기다리는 수동적인 입장이므로 Socket 라이브러리의 사용법이 조금 다르다.
또한 서버의 애플리케이션은 동시에 다수의 클라이언트 PC와 대화해야한다는 점에서 클라이언트와 차이가 존재한다.
따라서 서버 애플리케이션과 클라이언트 애플리케이션은 구조가 살짝 다를 수밖에 없다.
먼저 "멀티태스킹"에 대해 알아보자.
멀티 태스킹이란 다수의 작업(Task)을 운영체제의 스케쥴링에 의해 번갈아 가며 수행하는 것을 의미한다.
프로세스는 특정 순간에 1개의 작업밖에 수행할 수 없다. 하지만 우리는 컴퓨터를 할 때 게임을 하면서 동시에 브라우저 검색을 하는 등 다양한 작업을 수행할 수 있다.
이는 OS가 다수의 작업을 스케쥴링하여 우리가 눈치 채지 못할 정도의 빠른 속도로 작업을 번갈아가며 수행하기 때문에 우리 눈에는 동시에 수행되는 것처럼 보이는 것이다.
마치 사진이 매우 빠른 속도로 이어지면 영상처럼 보이는 것과 같은 것이다.
멀티 태스킹은 멀티 프로그래밍 방식(Multi-Programming), 시분할 방식(Time-Sharing), 실시간 시스템 방식(Real-time)을 사용하여 스케줄링을 수행할 수 있다.
하지만 이 스케쥴링 방식은 OS에 관련된 이론으로 네트워크를 배울 때는 중요하지 않으므로 넘어가자.
멀티 스레딩은 하나의 프로세스를 다수의 스레드로 구분하여 자원을 공유함으로써 자원의 생성과 관리의 중복성을 최소화하여 수행능력을 향상하는 것을 의미한다.
즉, 1개의 프로세스가 동시에 여러 개의 스레드를 수행함으로써 한 번에 여러 개의 동작을 수행하는 것이다.
이 멀티태스킹과 멀티스레딩은 한 번에 여러 개의 작업을 수행시킨다는 공통점을 가지고 있으나 실현 방식에 있어 큰 차이가 있다.
멀티 태스킹은 동시에 여러 개의 프로그램을 실행 시켜 다수의 동작을 수행하는 것이고 멀티 스레드는 실행되는 프로그램은 1개이지만 그 프로그램을 여러 기능으로 나누어 동시에 실행시키는 것이다.
조금 더 이해 쉽게 말하자면 멀티태스킹은 다수의 작업을 수행할 때 모든 작업을 다른 회사에 수주를 맡기는 것이고 멀티스레딩은 1개의 회사에 수주를 맡기면 수주를 맡은 회사가 그 회사에 존재하는 여러 개의 팀(부하 직원들)들에게 작업을 분배하는 것이다.
멀티태스킹 같은 경우 다수의 작업을 다른 프로세스에 의뢰하는 구조이므로 OS가 알아서 처리해 준다.
하지만 멀티스레딩 같은 경우 OS는 단순히 1개의 프로세스에 다수의 작업을 의뢰하는 것으로써 역할이 종료된다. 이후 다수의 작업을 의뢰받은 프로세스가 알아서 다수의 작업을 분배해야 하므로 프로그램 설계 시 프로그래머가 직접 이 과정을 구현해줘야 한다.
서버는 동시에 복수의 클라이언트와 통신 동작을 실행하는데 각각의 클라이언트와 통신 동작을 수행할 때마다 대화가 어디까지 진행되고 있는지를 전부 파악해야 한다.
클라이언트별로 다른 대화 진행 과정을 기억하기 위해서는 하나의 프로그램으로 모든 요청을 처리하기보다는 클라이언트가 접속할 때마다 새로 서버 프로그램을 작동하여 서버 애플리케이션이 클라이언트와 1:1로 대화하는 방법을 선택하는 것이 일반적이다.
1개의 프로그램을 요청이 들어올 때마다 독립적으로 실행시킴으로써 사실상 다수의 프로그램을 동시에 기동시켜야 한다?
위에서 배웠던 "멀티태스크" 또는 "멀티스레드"라는 기능을 통해 이를 실현할 수 있을 것이다.
즉, 서버 애플리케이션은 서버 OS가 멀티태스크 또는 멀티스레드 기능에 의하여 다수의 프로그램을 동시에 함께 작동할 수 있는 성질을 이용한 프로그램 기법을 사용하는 애플리케이션이라는 것을 알 수 있다.
이 방법은 클라이언트가 접속했을 때 새로 프로그램을 기동해야 하므로 기동 시간 만큼 응답 시간이 추가로 소요된다는 단점이 있다.
따라서 미리 클라이언트와 대화할 수 있는 몇 개 부분을 작동시켜 놓고 클라이언트가 접속했을 떄 작동된 프로그램 중 클라이언트와 연결되어 있지 않은 부분을 찾아 연결시켜 줌으로써 클라이언트와의 대화를 계속하는 방법도 있다.
서버 애플리케이션의 구조와 대략적인 개념은 알았으므로 이젠 Socket라이브러리를 호출하는 구체적인 동작을 통해 서버 애플리케이션의 동작 과정을 알아보자.
위에서 말했듯 클라이언트와 서버는 네트워크 측면에서 보자면 큰 차이가 없다.
이렇기 때문에 클라이언트도 서버가 될 수 있으며 반대로 서버가 클라이언트의 역할을 수행할 수 있다.
이런 측면에서 보았을 때 데이터 송/수신 동작에서 클라이언트와 서버라는 상태로 역할을 고정시키는 것은 좋은 방법은 아니다. 왜냐하면 데이터 송/수신 구조를 지원하기 위해선 서버와 클라이언트로 차이를 두는 것이 아닌 좌우 대칭으로 어디에서나 자유롭게 데이터를 송신할 수 있도록 하는 것이 좋기 때문이다.
하지만 데이터 송/수신이 아닌 접속 동작은 어떻게 해도 좌우 대칭으로 만들 수가 없다.
접속 동작의 경우 한쪽은 한없이 연결을 기다려야 하는 상태이며, 한쪽은 연결을 기다리고 있는 상태인 대상에게 연결을 수행해야 하는 주체가 돼야 하기 때문이다.
양쪽이 동시에 접속해도 안되고, 동시에 기다려도 안되며, 기다리지 않는데 접속을 시도하는 것도 안된다.
따라서 접속 동작에서는 "접속하는 측"과 "기다리는 측"이라는 역할 분담이 필요하며 우리는 주로 "접속하는 측"을 클라이언트, "기다리는 측"을 서버로 본다.
그리고 이러한 차이가 있기 때문에 접속을 수행할 때는 클라이언트에서 사용했던 Socket 라이브러리 활용 방식을 그대로 사용할 수 없는 것이다.
이론적인 서버 애플리케이션 동작 과정에 대해 배웠으니 이젠 실제 애플리케이션 동작 과정에 대해 알아보자.
<디스크립터1> = socket(<IPv4>, <TCP>,...);
이 부분의 개념은 클라이언트에서 소켓을 생성한 것과 유사하다.
단지 알아야 할 차이점은 2가지가 존재한다.
먼저, 클라이언트 측에서는 "연결을 수행할 때" socket 메서드를 수행했지만 서버 측에는 "서버 애플리케이션을 시작시킬 때" socket 메서드를 수행하여 소켓을 만든다.
이는 서버 측의 소켓은 클라이언트 측의 소켓을 기다리는 상태로 미리 존재해 있어야 하기 때문에 서버 측 소켓은 미리 만들어져 있어야 하기 때문이다.
두 번째로 "디스크립터1"이다.
클라이언트 측에서는 socket 메서드를 통해 만들어진 디스크립터를 단순히 "디스크립터"라고 표현했다.
그런데 서버 애플리케이션에선 "디스크립터1"로 넘버링, 즉 번호를 붙였다. 이는 서버 측 애플리케이션에선 디스크립터2가 존재한다는 의미이며 이는 나중에 서버와 클라이언트의 중요한 차이점이 된다.
bind(<디스크립터1>, <포트번호>,...)
1번 과정에서 만든 소켓에 포트 번호를 기록하는 과정이다.
클라이언트 측에서 접속 동작을 수행할 때 bind를 통해 할당한 포트 번호로 접속 동작을 수행할 것이다.
구체적인 포트 번호는 규칙에 의해 서버 애플리케이션마다 결정되어 있으며 웹 서버의 경우 80번으로 되어 있다.
하지만 이 규칙은 필수적인 것은 아니고 서버 설정을 통해 웹 서버를 81처럼 다른 값으로도 설정할 수도 있다.
단지, 헷갈리거나 오류를 막기 위하여 규칙에 의해 포트 번호를 할당하는 것이 편하다.
listen(<디스크립터1>,...)
소켓에 포트 번호까지 할당했다면 이젠 소켓이 접속하기를 기다리는 상태라는 제어 정보를 소켓에 기록한다.
이는 Socket 라이브러리의 listen 메서드를 통해 기록할 수 있다.
이 과정까지 수행했다면 소켓은 클라이언트 측에서 접속 동작의 패킷이 도착하는 것을 기다리는 상태가 된다.
<디스크립터2> = accpet(<디스크립터1>, ...)
클라이언트와 대화하는 부분을 호출한다(<디스크립터2>)
클라이언트 측에서 신청하는 접속을 접수하는 accept 메서드는 서버 애플리케이션을 기동한 후 즉시 실행되는 함수이다.
하지만 accept 메서드가 실행될 때는 서버 애플리케이션이 기동 된 직후이므로 아직 클라이언트의 접속 패킷이 도착하지 않았을 것이다.
패킷이 도착하지도 않았는데 accept가 호출되어 실행된다는 것이 이상해 보일 수도 있지만 이는 accept 함수가 가진 특성 때문에 이런 방식으로 동작이 가능한 것이다.
accept 함수는 접속 패킷이 도착하지 않았을 경우 접속 요청을 기다리는 상태가 되어 접속 패킷이 도착할 때까지 서버 애플리케이션을 쉬는 상태로 만든다.
이렇게 서버 애플리케이션이 쉬는 상황에서 접속 패킷이 도착한다면 서버 애플리케이션이 활성화된 뒤 응답 패킷을 반송하여 접속 접수 동작을 실행하는 것이다.
정리하자면 accept는 접속 패킷이 올 때까지 서버 애플리케이션을 쉬는 상태로 만들었다가 접속 패킷이 도착하면 서버 애플리케이션이 활성화 된 뒤 accept 메서드를 다시 실행시켜 실제 accept 함수가 해야 하는 업무를 진행하는 것이다.
accept가 끝나면 접속을 기다리는 동작은 끝나므로 이전에 배웠던 3-way Handshake 과정을 통해 클라이언트 측과 커넥션을 연결할 것이다.
이후 커넥션을 통해 패킷의 송/수신 동작을 실행하는데 이 과정은 클라이언트와 마찬가지이므로 설명을 생략하겠다.
여기에서 중요한 점이 "디스크립터2"이다.
위 코드를 보면 accept 함수가 실행될 경우 디스크립터1, 즉 처음에 설정한 소켓이 인자(Parameter)가 되며 디스크립터2라는 새로운 소켓이 만들어졌다.
그리고 클라이언트와 대화하는 부분을 호출할 때 디스크립터2를 활용함을 알 수 있다.
이는 서버 애플리케이션에서 접속 동작을 할 때 접속 대기의 소켓(디스크립터1)을 복사하여 새 소켓(디스크립터2)을 만든 뒤 이를 클라이언트 측의 소켓과 접속시키기 때문이다.
조금 더 자세히 알아보자.
접속 동작을 수행할 때 accept를 호출하면 접속 접수 동작을 실행한다.
이때 접속 대기의 소켓을 복사하여 새 소켓을 만들고 새 소켓을 클라이언트 측의 소켓과 접속한 후 원래 소켓은 그대로 접속 대기인 상태로 둔다.
그리고 다른 클라이언트가 다시 접속을 시도하면 접속 대기의 소켓을 또 복사하여 새 소켓을 만든 뒤 새롭게 만들어진 소켓에 접속시키는 것이다.
즉, 처음에 서버 애플리케이션이 시작될 때 만들어진 소켓(디스크립터1)은 몇 개의 클라이언트와 접속하든 항상 접속 대기 상태를 유지하며, 클라이언트가 접속할 때마다 접속 대기 상태인 소켓을 복사하여 새 소켓(디스크립터2, 디스크립터3, ...) 을 만든 뒤 그곳으로 각 클라이언트와의 커넥션을 연결하도록 하는 것이다.
새 소켓을 만들지 않고 접속 대기 상태인 소켓에 그대로 접속한다면 접속 대기 상태의 소켓이 없어져버리므로 다음 클라이언트가 접속할 때 접속할 소켓이 없는 곤란한 상황이 발생하므로 이렇게 새 소켓을 만드는 방식으로 접속하는 것이다.
새 소켓을 만들 때 포트 번호도 중요하게 봐야 한다.
서버 애플리케이션에서도 소켓 식별을 위해 소켓마다 다른 포트 번호 값을 할당한다는 개념을 고수하면 상당히 곤란해진다.
이 개념을 고수한다면 새롭게 만들어진 소켓마다 원래 접속 대기 소켓과는 다른 포트 번호가 할당되어야 할 것이다.
이렇게 될 경우 만약 클라이언트 측이 80이라는 포트 번호 소켓으로 접속 패킷을 보냈다면 접속 대기 상태 소켓이 이 패킷을 받은 뒤 새 소켓을 만들 때 81이라는 다른 포트 번호를 할당해야 할 것이다.
이렇게 될 경우 접속 패킷을 보낸 상대로부터 정상적으로 회답이 돌아온 것인지 아니면 다른 상대로부터 잘못된 회답이 돌아온 것인지 판별할 수 없다.
따라서 새로 만들어진(복사된) 소켓에도 접속 대기 소켓과 같은 포트 번호를 할당해야 한다.
그런데 복사된 소켓에도 접속 대기 소켓과 같은 포트 번호를 할당할 경우 포트 번호로 소켓을 지정할 수 없다는 문제가 발생한다.
왜냐하면 서버 애플리케이션에서는 포트 번호가 소켓 고유의 값이 아니기 때문에 포트 번호를 통해 소켓 1개를 지정할 수 없기 때문이다.
이럴 경우 클라이언트 측에서 보낸 패킷 TCP 헤더 중 수신처 포트만을 통해서는 어떤 소켓에 보내져야 하는 패킷인지 판단할 수 없을 것이다.
따라서 서버 측에서는 소켓을 지정할 때 "클라이언트 측 IP 주소", "클라이언트 측 포트 번호", "서버 측 IP 주소", "서버 측 포트 번호" 총 4가지 정보를 활용한다.
일단 위에서 배운 개념에 따르자면 "서버 측 IP 주소"와 "서버측 포트 번호"는 동일할 것이다.
왜냐하면 다수의 클라이언트는 1개의 서버에 접속하는 것일 테니 서버 측 IP 주소는 동일할 것이며, 소켓을 복사할 때 복사한 소켓들은 모두 포트 번호가 동일하므로 서버측 포트 번호 또한 동일할 것이다.
하지만 클라이언트 측 소켓은 모두 다른 포트 번호를 할당하기 떄문에 클라이언트측 포트 번호에 따라 소켓을 지정할 수 있을 것이다.
하지만 클라이언트측 포트 번호만을 활용할 수는 없다.
클라이언트측 소켓에 다른 포트 번호를 할당한다는 것은 어디까지나 "1개의 클라이언트"에 해당하는 말이다.
이를 반대로 말하자면 다수의 클라이언트가 있을 경우 동일한 포트 번호가 할당될 수 있다는 의미이다.
예를 들어 "1.2.3.4" IP 주소를 가진 PC의 경우 100 포트 번호를 가진 2개의 소켓을 만들 수는 없다.
하지만 "1.2.3.4"와 "1.2.3.5" IP 주소를 가진 2개의 PC가 있을 경우 각 PC가 100 포트 번호를 가진 소켓을 1개씩 생성해도 아무 문제가 없는 것이다.
따라서 포트 번호에 더해 클라이언트 측 IP 주소도 판단 근거로 넣는 것이다.
그렇다면 소켓을 식별하기 위해 "클라이언트 측 IP 주소", "클라이언트 측 포트 번호", "서버 측 IP 주소", "서버 측 포트 번호" 4가지 정보를 활용해도 될 텐데 왜 굳이 디스크립터 값을 활용하는 걸까?
이는 이전에도 설명했는데 일단 4개의 정보를 애플리케이션에 전달하여 식별하는 것보다는 디스크립터라는 1개의 정수 값 정보를 통해 식별하는 것이 훨씬 간단하기 때문이다.
그리고 서버 측 애플리케이션에서 디스크립터를 사용하는 이유는 한 가지가 더 있다.
왜냐하면 서버측 애플리케이션은 소켓을 만든 직후 아직 접속하지 않은 상태에서는 "클라이언트 측 IP 주소", "클라이언트 측 포트 번호"가 준비되어 있지 않다.
즉, 접속 대기 소켓에는 4가지의 정보가 모두 준비되어 있지 않으므로 소켓을 식별할 때는 디스크립터를 활용하는 것이 더 용이한 것이다.