CS:APP 네트워크 프로그래밍: Echo 클라이언트 연결 흐름
echo 클라이언트에서 open_clientfd가 서버 주소를 찾고, 소켓을 만들고, connect까지 이어지는 흐름을 정리한다.
CS:APP의 echo 예제를 보면 클라이언트 쪽 핵심은 open_clientfd()에 모여 있다.
이 함수는 단순히 파일 디스크립터 하나를 반환하는 것처럼 보이지만, 내부에서는 다음 흐름을 거친다.
```plain text hostname, port 입력 ↓ getaddrinfo(hostname, port) ↓ connect에 사용할 수 있는 주소 후보 리스트 생성 ↓ 후보마다 socket() 생성 후 connect() 시도 ↓ 성공한 소켓 디스크립터 반환
즉, `open_clientfd()`는 클라이언트가 서버에 연결하기 위해 필요한 준비 과정을 한 함수 안에 묶어 둔 것이다.
## open_clientfd의 전체 흐름
대표적인 구현은 아래와 같다.
```c
int open_clientfd(char *hostname, char *port)
{
int clientfd, rc;
struct addrinfo hints, *listp, *p;
memset(&hints, 0, sizeof(struct addrinfo));
hints.ai_socktype = SOCK_STREAM;
hints.ai_flags = AI_NUMERICSERV;
hints.ai_flags |= AI_ADDRCONFIG;
if ((rc = getaddrinfo(hostname, port, &hints, &listp)) != 0) {
fprintf(stderr, "getaddrinfo failed (%s:%s): %s\n",
hostname, port, gai_strerror(rc));
return -2;
}
for (p = listp; p; p = p->ai_next) {
if ((clientfd = socket(p->ai_family,
p->ai_socktype,
p->ai_protocol)) < 0) {
continue;
}
if (connect(clientfd, p->ai_addr, p->ai_addrlen) != -1) {
break;
}
close(clientfd);
}
freeaddrinfo(listp);
if (!p) {
return -1;
}
return clientfd;
}
이 코드는 크게 세 단계로 읽으면 된다.
getaddrinfo()로 서버 주소 후보를 얻는다.- 후보마다
socket()으로 통신 endpoint를 만든다. connect()가 성공하는 후보를 찾으면 그 소켓을 사용한다.
getaddrinfo는 왜 필요할까
클라이언트는 보통 "localhost", "example.com", "8000" 같은 문자열을 가지고 있다.
하지만 connect()는 문자열을 직접 받지 않는다. connect()가 필요로 하는 것은 struct sockaddr 계열의 실제 주소 구조체와 그 길이다.
getaddrinfo()는 이 간격을 메워 준다.
getaddrinfo(hostname, port, &hints, &listp);
이 함수의 역할은 두 가지다.
- 문자열 형태의
hostname,port를 실제 네트워크 주소 구조체로 바꾼다. - 연결 가능한 주소 후보들을 linked list 형태로 돌려준다.
예를 들어 localhost 하나만 넘겨도 실제 후보는 여러 개일 수 있다.
```plain text localhost ↓ 127.0.0.1 IPv4 ::1 IPv6
그래서 `open_clientfd()`는 `listp`부터 시작해서 `p = p->ai_next`로 후보를 순회한다.
## hints는 어떤 주소를 원하는지 알려준다
`hints`는 `getaddrinfo()`에게 어떤 종류의 주소 후보를 받고 싶은지 알려주는 설정값이다.
```c
memset(&hints, 0, sizeof(struct addrinfo));
hints.ai_socktype = SOCK_STREAM;
hints.ai_flags = AI_NUMERICSERV;
hints.ai_flags |= AI_ADDRCONFIG;
각 설정의 의미는 다음과 같다.
| 설정 | 의미 |
|---|---|
SOCK_STREAM |
TCP 연결용 소켓을 원한다. |
AI_NUMERICSERV |
port가 "80" 같은 숫자 문자열임을 알려준다. |
AI_ADDRCONFIG |
현재 시스템에서 사용 가능한 주소 계열 위주로 후보를 받는다. |
여기서 중요한 점은 echo 클라이언트가 UDP가 아니라 TCP 연결을 사용한다는 것이다. SOCK_STREAM은 이 방향을 명확히 잡아 준다.
socket은 통신 endpoint를 만든다
socket()은 실제 통신에 사용할 파일 디스크립터를 만든다.
clientfd = socket(p->ai_family, p->ai_socktype, p->ai_protocol);
여기서 인자를 직접 정하지 않고 p에서 꺼내 쓰는 이유가 있다. 주소 후보마다 IPv4인지 IPv6인지, 어떤 프로토콜 정보를 가져야 하는지가 다를 수 있기 때문이다.
따라서 socket()은 현재 후보 주소 p에 맞는 소켓을 만든다.
connect는 서버에 실제로 연결한다
connect()는 방금 만든 클라이언트 소켓을 특정 서버 주소에 연결한다.
connect(clientfd, p->ai_addr, p->ai_addrlen);
성공하면 반복문을 빠져나오고, 실패하면 해당 소켓을 닫은 뒤 다음 주소 후보로 넘어간다.
if (connect(clientfd, p->ai_addr, p->ai_addrlen) != -1) {
break;
}
close(clientfd);
이 구조 때문에 하나의 주소 후보가 실패해도 바로 전체 연결이 실패하지 않는다. 가능한 후보들을 순서대로 시도하고, 그중 성공한 소켓을 최종 clientfd로 반환한다.
클라이언트가 서버 주소를 정해 주는 것은 아니다
여기서 헷갈리기 쉬운 지점이 있다.
클라이언트가 hostname, port를 넘긴다고 해서 서버의 주소를 새로 정하는 것은 아니다.
서버는 이미 어떤 주소와 포트에서 listen() 중이다. 클라이언트는 그 목적지를 선택해서 연결을 시도할 뿐이다.
```plain text 서버: “나는 이 포트에서 기다릴게”
클라이언트: “나는 저 주소와 포트로 연결할게”
`getaddrinfo()`도 서버 주소를 새로 만드는 함수가 아니다. 클라이언트가 받은 문자열을 `connect()`가 사용할 수 있는 주소 구조체로 변환해 주는 함수다.
## rio_t는 왜 필요할까
echo 예제에서는 연결 이후 RIO 패키지를 사용해 읽기와 쓰기를 처리한다.
```c
rio_t rio;
Rio_readinitb(&rio, clientfd);
Rio_readlineb(&rio, buf, MAXLINE);
Rio_writen(clientfd, buf, strlen(buf));
rio_t는 소켓에서 읽어 온 데이터를 관리하기 위한 버퍼 구조체다.
typedef struct {
int rio_fd;
int rio_cnt;
char *rio_bufptr;
char rio_buf[RIO_BUFSIZE];
} rio_t;
핵심 목적은 소켓에서 데이터를 넉넉히 읽어 둔 뒤, 사용자가 요청한 만큼 조금씩 꺼내 주는 것이다.
그래서 Rio_readlineb(&rio, buf, MAXLINE)는 단순히 fd만 받지 않고 rio_t의 주소를 받는다. 내부에서 다음 상태를 계속 관리해야 하기 때문이다.
rio_fd: 어떤 파일 디스크립터에서 읽을지rio_cnt: 내부 버퍼에 남은 바이트 수rio_bufptr: 다음에 읽을 위치rio_buf: 실제 내부 버퍼
read/write를 직접 쓰는 것과 RIO를 쓰는 것
네트워크 I/O에서는 한 번의 read()나 write()가 원하는 만큼 정확히 처리된다고 가정하면 안 된다. 일부만 읽히거나 일부만 쓰일 수 있다.
RIO 패키지는 이 반복 처리를 감싸서 코드 흐름을 단순하게 만든다.
| 구분 | read / write 직접 사용 |
RIO 패키지 사용 |
|---|---|---|
| 수준 | 낮은 수준의 시스템콜 | 시스템콜을 감싼 헬퍼 함수 |
| 한 줄 읽기 | 직접 \n, \r\n을 찾아야 함 |
Rio_readlineb로 한 줄씩 읽음 |
| 일부만 쓰이는 경우 | 남은 바이트를 직접 다시 써야 함 | Rio_writen이 끝까지 쓰려고 반복 |
| 내부 버퍼 | 직접 관리해야 함 | rio_t가 내부 버퍼를 관리 |
echo 클라이언트에서는 이 구조 덕분에 연결 자체와 데이터 입출력을 분리해서 이해할 수 있다.
정리
open_clientfd()는 클라이언트 연결 과정을 다음 순서로 추상화한다.
plain text
문자열 주소
↓
getaddrinfo()
↓
주소 후보 리스트
↓
socket()
↓
소켓 디스크립터
↓
connect()
↓
서버와 연결된 clientfd
그리고 연결 이후에는 RIO 패키지가 소켓 I/O를 더 안정적으로 다룰 수 있게 해 준다.
따라서 echo 클라이언트를 볼 때는 getaddrinfo(), socket(), connect(), rio_t를 따로 외우기보다, 서버에 연결하고 데이터를 안전하게 주고받기 위한 하나의 흐름으로 묶어서 이해하는 것이 좋다.