크래프톤 정글 주제별 탐구 - 프록시 서버 만들기
서론 - 테스트
본격적으로 proxy 서버 제작 과정에 들어가기에 앞서, 다음과 같은 공부과정을 진행하고자 한다.
- CSAPP 11장 학습
- 웹 서버의 대한 학습
- HTTP 프로토콜의 이해
- 소켓 및 소켓을 이용한 통신(클라이언트/서버) 제작
- 클라이언트의 request를 받고, response를 내어주는 웹 서버 제작
- proxy 서버 과제에 도전
이에 따라, 이 글의 순서 역시 이에 따라 진행될 예정이다.
본론
CSAPP 11장
웹 서버
서버는 자원을 관리하고, 그 자원을 통제하여 클라이언트에게 서비스를 제공한다.
클라이언트란 서비스를 사용하는 사용자 혹은 사용자의 단말기를 이야기한다. 책에서는 근데 이걸 프로세스로 봐야 한다고 했던것 같은데, 확인되면 추가하도록 하겠다.
반대로 서버란, 서비스를 제공하는 컴퓨터(프로토콜)이며, 다수의 클라이언트를 위해 존재하기 때문에 일반적으로 매우 큰 용량과 성능을 가지고 있었다. 하지만, 최근에는 서버의 역할을 하면서 동시에 클라이언트 기능을 하는(P2P) 환경들이 다수 생겨나고 있다.
기본적인 클라이언트 및 서버의 트랜잭션을 확인하자.
- 클라이언트(단수 혹은 복수)는 서버에 request를 전송, transaction을 시작한다.
- 서버는 request를 수신하고, 이를 통해 특정 함수 등을 실행하여 서버의 resource를 통제한다.
- 서버는 이후 response를 클라이언트에게 발신하고, 대기한다.
- 클라이언트는 response를 수신하고 이에 따른 결과를 처리한다.
HTTP 프로토콜
HTTP 프로토콜(혹은 HTTP 통신)이란,
인터넷 연결
인터넷 클라이언트와 서버는 연결을 통해서 바이트 스트림을 주고 받는다. 이 연결은 두개의 프로세스를 연결하기 때문에, point-to-point 연결이다. 또한, 데이터가 동시에 양방향으로 흐를 수 있다는 의미에서 이는 ‘완전 양방향’이다. 소스 프로세스가 보낸 바이트 스트림이 최종적으로 동일한 순서로 목적지 프로세스에 수신된다는 것을 생각하면 이는 안정적이다.(중간에 끊겨서, 혹은 다 들어오지 못한 바이트 스트림이 존재한다면, 수신자 프로세스는 이를 통채로 버려버린다.)
이후 설명할 소켓은 이러한 연결의 종단점이다.
소켓 통신
소켓이란?
소켓이란, 인터넷 연결에서의 종단점이다. 각 소켓은 인터넷 주소와 16비트 정수 포트로 이루어진 소켓 주소를 가지고 있다. 이는 address:port 로 나타낸다. 클라이언트의 소켓 주소 내의 포트는 클라이언트가 연결 요청을 할 때 커널이 자동으로 할당하고, 이것을 단기(ephemeral) 포트라고 한다. 하지만 서버의 소켓 주소에 있는 포트는 대개 영구적이고 이 서비스에 연결되는 잘 알려진 포트이다. 대게 웹 서버는 포트 80을 사용하고, 이메일 서버는 포트 25를 사용한다. 잘 알려진 포트를 갖는 각 서비스에 연관되어 이에 대응되는 잘 알려진 서비스 이름들이 존재한다. 웹 서비스는 http, 이메일은 smtp 같은 경우이다. 일반적으로 이러한 포트들 간의 매핑은 /etc/services 파일에 보관되어 있다.
연결은 두개의 종단점의 소켓 주소에 의해 유일하게 식별된다. 이 두 개의 소켓 주소는 소켓 쌍이라고 알려져 있고, tuple로 나타낸다.
(cliaddr : cliport, servaddr: servport)
cliaddr(client address)는 클라이언트의 IP주소, cliport는 클라이언트의 포트, servaddr(server address)은 서버의 IP주소이고, servport는 서버의 포트이다.
소켓 인터페이스
소켓 인터페이스란, 네트워크 응용을 만들기 위한 Unix I/O 함수들과 함께 사용되는 함수들의 집합이다.
소켓 주소 구조체
리눅스 커널 관점에서 소켓은 통신을 위한 끝 지점이고, Unix 프로그램 관점에서 소켓은 해당 식별자를 가지는 열린 파일이다.
소켓 구조체는 다음과 같다.
1
2
3
4
5
6
7
8
9
10
11
12
13
/* IP socket address structure */
struct sockaddr_in {
uint16_t sin_family; // Protocol family (always AF_INET)
uint16_t sin_port; // Port number in network byte order
struct in_addr sin_addr; // IP address in network byte order
unsigned char sin_zero[8]; // Pad to sizeof(struct sockaddr)
};
/* Generic socket address structure (for connect, bind, and accept) */
struct sockaddr{
uint16_t sa_family; // Protocol family
char sa_data[14]; // Address data
};
위 코드 스니펫과 함께 소켓 구조체를 알아보자.
먼저, 인터넷 소켓 주소는 sockaddr_in 타입의 16바이트 구조체에 저장된다. 인터넷 응용에 대해서 sin_family 필드는 AF_INET(IPv4의 주소 패밀리. Windows Document 참고)이고, sin_port필드는 16비트 포트 번호이며, sin_addr 필드는 32비트 IP 주소이다. IP주소와 포트 번호는 항상 네트워크 바이트 순서(빅 엔디안)로 저장된다.
이에 사용되는 함수들은 CONNECT, BIND, ACCEPT 함수가 존재한다. 이들은 프로토콜에 특화된 소켓 주소 구조체를 가리키는 포인터를 필요로 한다. 오늘날 우리는 포괄적인 void * 포인터를 이용하고, 과거에는 이것이 존재하지 않기 때문에, sockaddr 구조체의 포인터를 이용해서 캐스팅하는 방식을 사용하였다.
이는 위의 소켓 구조체의 대한 코드 스니펫을 참고하자.
socket 함수
socket 함수는 소켓 식별자를 생성하기 위해서 사용하며, 이는 서버와 클라이언트가 사용한다.
1
2
3
4
#include <sys/types.h>
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
다음과 같은 방식으로 사용한다.
clientfd = Socket(AF_INET, SOCK_STREAM, 0);
AF_INET는 우리가 32bit IP 주소를 사용함을, SOCK_STREAM은 소켓이 인터넷 연결의 끝점이 될 것임을 나타낸다. 가장 좋은 습관은, 이 미개변수들을 자동으로 생성해서 코드가 프로토콜에 무관하게 작동할 수 있도록, getaddrinfo
함수를 사용하는 것이다.
socket함수에 의해 반환되는 clientfd 식별자는 겨우 부분적으로 열렸으며, 아직 읽거나 사용할 수 없다. 소켓을 여는 과정의 완료는 클라이언트/서버에 따라 다르다.
connect 함수
client는 connect함수를 호출하여 서버와의 연결을 수립한다.
1
2
3
#include <sys/socket.h>
int connect(int clientfd, const struct sockaddr *addr, socklen_t addrlen);
connect함수는 소켓 주소 addr의 서버와 인터넷 연결을 시도한다. addrlen은 sizeof(sockaddr_in)이 된다. connect함수는 연결이 성공할 때까지 블록되어 있거나, 에러가 발생한다. 성공한다면 clientfd 식별자는 이제 읽거나 쓸 준비가 되었으며, 이 연결은 다음과 같은 소켓 쌍으로 규정된다.
(x:y, addr.sin_addr:addr.sin_port)
여기서 x는 클라이언트의 IP주소, y는 클라이언트 호스트의 클라이언트 프로세스를 유일하게 식별하는 단기 포트이다. socket에서처럼 가장 좋은 방법은 getaddrinfo를 이용해서 connect의 인자들을 제공하도록 하는 것이다.
bind 함수
남아있는 소켓 함수 bind, listen, accept는 서버가 클라이언트와 연결을 수립하기 위해 사용한다.
1
2
3
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
bind 함수는 커널에게 addr에 있는 서버의 소켓 주소를 소켓 식별자 sockfd와 연결하라고 물어본다. addrlen 인자는 sizeof(sockaddr_int)이다
socket, connect와 마찬가지로, getaddrinfo를 이용해서 인자를 제공하는 것이 좋다.
listen 함수
listen 함수는 서버가 클라이언트에서 전달될 것들을 대기하는 함수이다. 다시 말해, client에서 connection request가 올 때까지 listen이 이를 대기하고, 이것이 오게 되면 listen에게 전달된다.
1
2
3
#include <sys/socket.h>
int listen(int sockfd, int backlog);
listen함수는 sockfd를 능동 소켓에서 듣기 소켓으로 변환하고, 듣기 소켓은 클라이언트로부터의 연결 요청을 승낙할 수 있다. backlog 인자는 커널이 요청들을 거절하기 전에 큐에 저장해야 하는 연결의 수에 대한 정보를 제공한다. 일반적으로 1024로 설정한다.
accept 함수
서버는 accept함수를 호출해서 클라이언트로부터의 연결 요청을 기다린다.
1
2
3
#include <sys/socket.h>
int accept(int listenfd, struct sockaddr *addr, int *addrlen);
accept함수는 클라이언트로부터의 연결 요청이 듣기 식별자인 listenfd에 도달하기를 기다리고, 그 후에 addr 내의 클라이언트의 소켓 주소를 채우고, Unix I/O 함수들을 사용해서 클라이언트와 통신하기 위해 사용될 수 있는 연결 식별자를 반환해준다.
- 듣기 식별자: 클라이언트 연결 요청에 대해 끝점으로서의 역할. 한 번만 서버에서 생성되고, 서버가 살아있는 동안 계속 존재한다.
- 연결 식별자: 클라이언트와 서버 사이에 성립된 연결의 끝점. 서버가 연결 요청을 수락할 때마다 생성, 서버가 클라이언트에 서비스하는 동안에만 존재한다.
즉, 처음에 클라이언트가 서버에 연결 요청을 할 때에만 듣기 식별자로 가고, 이후 서비스를 사용할 때에는 연결 식별자에게 간다고 이해하면 될 것 같다.
호스트와 서비스 변환(getaddrinfo, getnameinfo)
getaddrinfo, getnameinfo는 리눅스에서 제공되는 함수이며, 이들은 이진 소켓 주소 구조체들과 호스트 이름, 호스트 주소, 서비스 이름, 포트번호들 사이에 앞뒤로 변환해준다. 이들을 통해, 우리는 특정 IP 프로토콜에 구애되지 않는 네트워크 프로그램을 작성할 수 있도록 한다.
getaddrinfo 함수
getaddrinfo 함수는 호스트 이름, 호스트 주소, 서비스 이름, 포트번호의 스트링 표시를 소켓 주소 구조체로 변환한다.
host와 service가 주어지면, getaddrinfo는 각각이 host와 service에 대응되는 소켓 주소 구조체를 가리키는 addrinfo구조체의 연결 리스트를 가리키는 result를 반환한다.
getnameinfo함수
getnameinfo 함수는 getaddrinfo 함수의 역이다. 이는 소켓 주소 구조체를 대응되는 호스트와 서비스이름 string으로 변환한다.
ECHO 서버
위 함수들을 기반으로 ECHO 서버와 클라이언트를 작성하였다.
Echo_server
일단, 11장의 내용을 기반으로 작성한 ECHO_SERVER 이다.
main 함수는 argc와 argv를 받는다.
먼저, echo server는 listener가 존재해야 하고 이에 따라 클라이언트는 listener를 소켓으로 패킷을 보낸다.
listener를 통해서 listenfd(fd는 file descriptor)에 포트에서 클라이언트에서 보낸 값이 들어오게 된다(CSAPP에 정의된 것).
이후, while문을 통해 생성된 connfd(connection file descriptor)가 생성되어 연결 식별자가 된다. 이 연결 식별자가 서비스가 진행되는 동안 서버의 소켓이 된다.
근데 왜 while문 안에 있어야 할까? 그 이유는, 듣기 식별자가 확인한 후에 연결 식별자를 생성하게 되는데, 연결 식별자는 서비스를 지속하는동안 유지되고, 매번 listener로 올 때마다 새로 생성되야 하기 때문이다.
이후, connfd가 정해지게 되면, getnameinfo를 통해, 클라이언트의 호스트와 서비스이름을 클라이언트 소켓 주소 구조체를 통해 알아내게 된다. 이를 진행한 뒤에, client hostname, address를 printf로 서버의 콘솔창에 찍고, echo를 진행한 다음, connfd를 닫는다.
echo 함수는 Rio_readinitb를 통해 클라이언트에서 온 string을 읽고, 이를 다시 Rio_writen을 통해 작성한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#include "csapp.h"
void echo(int connfd);
// 책 940페이지를 참고하여 수정해야 할 수도 있다.
int main(int argc, char **argv)
{
int listenfd, connfd;
socklen_t clientlen;
struct sockaddr_storage clientaddr;
char client_hostname[MAXLINE], client_port[MAXLINE];
if(argc != 2){
fprintf(stderr, "usage: %s <port>", argv[0]);
exit(0);
}
listenfd = Open_listenfd(argv[1]);
while(1){
clientlen = sizeof(struct sockaddr_storage);
connfd = Accept(listenfd, (SA *)&clientaddr, &clientlen);
Getnameinfo((SA*) &clientaddr, clientlen, client_hostname, MAXLINE, client_port, MAXLINE, 0);
printf("Connected to (%s, %s)\n", client_hostname, client_port);
echo(connfd);
Close(connfd);
}
exit(0);
}
void echo(int connfd)
{
size_t n;
char buf[MAXLINE];
rio_t rio;
Rio_readinitb(&rio, connfd);
while((n = Rio_readlineb(&rio, buf, MAXLINE)) != 0) {
printf("server received %d bytes\n", (int)n);
Rio_writen(connfd, buf, n);
}
}
Echo_client
echo client의 경우에는, 포트 번호를 통해 listener에게 전달한다.
이후, 버퍼를 통해 input을 확인하고, 이를 보내준다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include "csapp.h"
int main(int argc, char **argv)
{
int clientfd;
char *host, *port, buf[MAXLINE];
rio_t rio;
// 인자의 수가 정상적으로 전달되었는지 확인
if(argc != 3){
fprintf(stderr, "usage: %s <host> <port>\n", argv[0]);
exit(0);
}
host = argv[1];
port = argv[2];
clientfd = Open_clientfd(host, port);
Rio_readinitb(&rio, clientfd);
// 버퍼에서 input을 확인한다.
while(Fgets(buf, MAXLINE, stdin) != NULL){
Rio_writen(clientfd, buf, strlen(buf));
Rio_readlineb(&rio, buf, MAXLINE);
Fputs(buf, stdout);
}
Close(clientfd);
exit(0);
}
Tiny 서버
타이니 서버의 경우, GET 요청만 받는 HTTP 프로토콜을 기반으로 돌아가는 서버이다.