TCP와 UDP에 대한 이해
인터넷 프로토콜 기반의 소켓의 경우 데이터 전송방법에 따라서 TCP 소켓과 UDP 소켓으로 나눈다
특히 TCP 소켓의 경우 연결을 지향하기 때문에 스트림 기반의 소켓이라고도 이야기한다.
TCP는 Transmission Control Protocol의 약자 데이터 전송과정의 컨트롤 이라는 뜻을 담고 있다.
TCP/IP 프로토콜 스택
TCP/IP은 스텍이 총 4개의 계층 -> 이는 데이터 송수신의 과정은 네 개의 영역으로 계층화 했다는 의미
다음은 UDP 소켓을 생성해서 데이터를 송수신 하는 경우이다.
즉, '인터넷 기반의 효율적인 데이터 전송'이라는 커다란 하나의 문제를 하나의 프로토콜 설계로 해결한 것이 아니라 그 문제를 작게 나눠서 계층화하려는 노력이 시도 되었고 그 결과로 탄생한 것이 TCP/IP 프로토콜 스택이며 TCP/UDP 소켓을 생성해서 데이터를 송수신할 경우에는 위의 FLOW를 따라 데이터를 송수신하게 된다.
★참고 OSI7 Layer
네크워크의 기본 OSI7 계층
개념
- 개방형 시스템 상호 연결 모델의 표준임
- 실제 인터넷에서 사용되는 TCP/IP 는 OSI 참조 모델을 기반으로 상업적이고 실무적으로 이용될 수 있도록 단순화한 것임
탄생 배경
- 초기 여러 정보 통신 업체 장비들은 자신의 업체 장비들끼리만 연결이 되어 호환성이 없었음
- 모든 시스템들의 상호 연결에 있어 문제없도록 표준을 정한것이 OSI 7계층
- 표준(호환성)과 학습도구에 의미로 제작
작동 원리
- OSI 7계층은 응용, 표현, 세션, 전송, 네트워크, 데이터링크, 물리계층으로 나뉨.
- 전송 시 7계층에서 1계층으로 각각의 층마다 인식할 수 있어야 하는 헤더를 붙임(캡슐화)
- 수신 시 1계층에서 7계층으로 헤더를 떼어냄(디캡슐화)
- 출발지에서 데이터가 전송될 때 헤더가 추가되는데 2계층에서만 오류제어를 위해 꼬리부분에 추가됌
- 물리계층에서 1, 0 의 신호가 되어 전송매체 (동축케이블, 광섬유 등)을 통해 전송
물리계층(Physical Layer)
- 7계층 중 최하위 계층.
- 주로 전기적, 기계적, 기능적인 특성을 이용해 데이터를 전송.
- 데이터는 0과 1의 비트열, 즉 On, Off의 전기적 신호 상태로 이루어져 해당 계층은 단지 데이터를 전달.
- 단지 데이터 전달의 역할을 할 뿐이라 알고리즘, 오류제어 기능이 없음
- 장비로는 케이블, 리피터, 허브가 있음
데이터링크 계층(Data-Link Layer)
- 물리적인 연결을 통하여 인접한 두 장치 간의 신뢰성 있는 정보 전송을 담당(Point-To-Point 전송)
- 안전한 정보의 전달이라는 것은 오류나 재전송하는 기능이 존재
- MAC 주소를 통해서 통신
- 데이터 링크 계층에서 데이터 단위는 프레임(Frame)
- 장비로는 브리지, 스위치가 있음
네트워크 계층(Network Layer)
- 중계 노드를 통하여 전송하는 경우 어떻게 중계할 것인가를 규정
- 라우팅 기능을 맡고 있는 계층으로 목적지까지 가장 안전하고 빠르게 데이터를 보내는 기능을 가지고 있음(최적의 경로를 설정가능)
- 컴퓨터에게 데이터를 전송할지 주소를 갖고 있어서 통신가능(=우리가 자주 듣는 IP 주소가 바로 네트워크 계층 헤더에 속함)
- 네트워크 계층에서 데이터 단위는 패킷(Packet)
- 장비로는 라우터, L3 스위치가 있음
전송 계층(Transport Layer)
- 종단 간 신뢰성 있고 정확한 데이터 전송을 담당
- 송신자와 수신자 간의 신뢰성있고 효율적인 데이터를 전송하기 위하여 오류검출 및 복구, 흐름제어와 중복검사 등을 수행
- 데이터 전송을 위해서 Port 번호를 사용함.(대표적인 프로토콜로 TCP와 UDP가 있음)
- 전송 계층에서 데이터 단위는 세그먼트(Segment)
세션 계층(Session Layer)
- 통신 장치 간 상호작용 및 동기화를 제공
- 연결 세션에서 데이터 교환과 에러 발생 시의 복구를 관리
표현 계층(Presentation Layer)
- 데이터를 어떻게 표현할지 정하는 역할을 하는 계층
- 표현 계층은 세가지의 기능을 갖고 있습니다.
- 송신자에서 온 데이터를 해석하기 위한 응용계층 데이터 부호화, 변화
- 수신자에서 데이터의 압축을 풀수 있는 방식으로 된 데이터 압축
- 데이터의 암호화와 복호화
(MIME 인코딩이나 암호화 등의 동작이 표현계층에서 이루어짐. EBCDIC로 인코딩된 파일을 ASCII 로 인코딩된 파일로 바꿔주는 것이 한가지 예임)
응용 계층(Application Layer)
- 사용자와 가장 밀접한 계층으로 인터페이스 역할
- 응용 프로세스 간의 정보 교환을 담당
- ex) 전자메일, 인터넷, 동영상 플레이어 등
개방형 시스템
프로토콜을 계층화해서 얻게 되는 장점은 어떤 것이 있을까 ?
표준화 작업을 통한 "개방형 시스템 (Open System)"의 설계이다.
표준이라는 것은 감추는 것이 아니라 활짝 열고 널리 알려서 많은 사람이 따르도록 유도하는 것이다.
따라서 여러 개의 표준을 근거로 설계된 시스템을 가리켜 "개방형 시스템"이라 하며, TCP/IP 프로토콜 역시 개방형 시스템이다.
예를 들어 IP 계층을 담당하는 라우터가 있다.
A사의 라우터를 B사의 라우터로 교체가 가능하겠는가 ?
당연히 가능하다. 반드시 같은 회사의 같은 모델로 교체해야 하는 것이 아니기 때문이다.
모든 라우터 제조사들이 IP 계층의 표준에 맞춰서 라우터를 제작하기 때문이다.
이와 같이 표준이 존재한다는 것은 그만큼 빠른 기술발전이 가능하도록 할 수 있게 한다.
표준이라는 것은 기술의 발전에 있어서 중요한 요소이다.
LINK 계층
- 물리적인 영역의 표준화 결과
- LAN, WAN, MAN과 같은 물리적인 네트워크 표준 관련 프로토콜이 정의된 영역
- 물리적인 연결의 표준이 된다.
IP 계층
- IP는 Internet protocol을 의미
- IP 자체는 비 연결 지향형이며 신뢰할 수 없는 프로토콜
- 데이터를 전송할 때마다 거쳐야 할 경로를 선택해주지만 그 경로는 일정하지 않음.
- 데이터 전송중 오류가 발생해도 이를 해결해주지않음 즉 오류발생에 대비가 되어있지 않은 프로토콜 IP
TCP/UDP 계층
- 실제 데이터의 송수신과 관련 있는 계층
- 전송(Transport) 계층이라고도 한다.
- TCP는 데이터의 전송을 보장하는 프로토콜(신뢰성 있는 프로토콜)
- UDP는 데이터의 전송을 보장하지 않는 프로토콜(신뢰성이 없는 프로토콜)
- TCP는 신뢰성을 보장하기 때문에 UDP에 비해 복잡한 프로토콜이다.
TCP의 역활을 간단히 표현한 것.
결론적으로 IP의 상위계층에서 호스트 대 호스트의 데이터 송수신 방식을 약속하는것이 TCP/UDP 이며 TCP는 확인절차를 걸쳐서 신뢰성 없는 IP에게 신뢰성을 부여한 프로토콜
APPLICATION 계층
- 응용프로그램의 프로토콜을 구성하는 계층
- 소켓을 기반으로 완성하는 프로토콜을 의미
- 소켓을 생성하면 위에 서술한 계층에 대한 내용은 감춰진다.
- 프로그래머는 APPLICATION 계층의 완성에 집중하면 된다.
◈ 분석
지금까지 설명한 계층에 대한 내용은 소켓을 생성하면 데이터 송수신과정에서 자동으로 처리되는 것들이다.
데이터의 전송경로를 확인하는 과정이라든가 데이터 수신에 대한 응답의 과정이 소켓이라는 것 하나에 감춰져 있기 때문.
즉 프로그래밍에 있어서 이러한 과정을 우리들이 신경 쓰지 않아도 된다는 뜻이다.
하지만 우리가 신경을 쓰지 않아도 될 뿐 몰라도 된다는 뜻은 아니다. 이론적으로 공부를 해야 필요에 맞는 네트워크 프로그램을 작성 가능하다. 소켓이라는 도구가 우리에게 주어졌고 우리들은 이 도구들을 이용해 무엇인가 만들면 된다.
무엇인가를 만드는 과정에서 프로그램의 성격에 따라 서버와 클라이언트간의 데이터 송수신에 대한 약속들이 정해지는데 이를 가리켜 APPLICATION 프로토콜이라고 한다.
대부분의 네트워크 프로그래밍은 APPLICATION 프로토콜 설계 및 구현이 상당부분을 차지한다.
TCP 기반 Server의 구현
TCP 서버 구현을 위한 기본적인 함수 호출 순서.
socket 함수의 호출을 통해서 소켓을 생성
↓
주소정보를 담기 위한 구조체 변수를 선언 및 초기화해서 bind 함수를 호출해 소켓에 주소 할당
↓
listen 함수호출을 통해 연결 요청 대기상태로의 진입
(listen 함수가 호출되어야 클라이언트는 연결요청을 위해 connect 함수 호출 가능)
↓
accept 함수를 통해 클라이언트의 연결 요청 수락
socket과 bind는 이전에 설명을 했으니 listen부터 다시한번 알아보도록 하겠다.
연결요청 대기상태로의 진입(Listen)
#include <sys/typeh.h>
int listen(int sock, int backlog);
// 성공시 0, 실패 시 -1 반환
/*
sock : 연결요청 대기상태에 두고자 하는 소켓의 파일 디스크립터 전달,
이 함수의 인자로 전달된 디스크립터의 소켓이 서버 소켓(리스닝 소켓)이 된다.
backlog : 연결요청 대기 큐(Queue)의 크기정보 전달,
5가 전달되면 큐의 크기가 5가 되어 클라이언트의 연결요청을 5개까지 대기시킬 수 있다.
*/
bind 함수호출을 통해서 소켓에 주소까지 할당했다면 listen 함수를 통해 연결요청 대기상태로 들어가야 한다.
listen 함수가 호출되어야 클라이언트가 연결요청을 할 수 있는 상태가 된다.
즉, listen 함수가 호출되어야 클라이언트는 연결요청을 위해서 connect 함수를 호출할 수 있다.
(이 전에 connect 함수가 호출되면 error)
- 클라이언트의 연결요청도 일종의 데이터 전송이기 때문에 이것을 받으려면 소켓이 있어야 한다.
- 서버 소켓의 역할이 이것이며 연결 요청을 맞이하는 문지기라고 생각하면 편하다.
- 클라이언트가 연결요청을 요구하면 서버 소켓에서 알려준다. 만약 현재 연결중인 클라이언트가 있으면
대기실(연결요청 대기 큐)에 넣어준다.
- 즉, 매개 변수 sock는 문지기, backlog는 대기실이다.
클라이언트의 연결요청 수락 (accept)
#include <sys/socket.h>
int accept(int sock, struct sockaddr * addr, socklen_t * addlen);
//성공 시 생성된 소켓의 파일 디스크립터, 실패시 -1 반환
/*
sock: 서버 소켓의 파일 디스크립터 전달
addr: 연결요청 한 클라이언트의 주소정보를 담을 변수의 주소 값 전달,
함수 호출이 완료되면 인자로 전달된 주소의 변수에는 클라이언트의 주소정보가 채워진다.
addrlen: 두 번째 매개변수 addr에 전달된 주소의 변수 크기를 바이트 단위로 전달,
단 크기 정보를 변수에 저장한 다음에 변수의 주소 값을 전달한다.
그리고 함수호출이 완료되면 크기정보로 채워져 있던 변수에는 클라이언트의 주소정보 길이가
바이트 단위로 계산되어 채워진다.
*/
accept 함수는 대기실(연결요청 대기 큐)에서 대기중인 클라이언트의 연결요청을 수락하는 기능의 함수이다.
accept 함수는 호출 성공 시 내부적으로 데이터 입출력에 사용할 소켓을 생성하고, 그 소켓의 파일 디스크립터를 반환한다.
중요한 점은 소켓이 자동으로 생성되어, 연결요청을 한 클라이언트 소켓에 연결까지 이뤄진다는 점이다.
연결요청 정보를 참조하여 클라이언트 소켓과의 통신을 위한 별도의 소캣을 생성한다.
그리고 이렇게 생성한 소켓을 대상으로 데이터 송수신이 진행된다
실제로 서버의 코드를 살펴보면 추가로 소켓이 생성되는걸 일 수 있다.
지금까지 서버 프로그램의 구현 과정 전체에 대한 설명을 완료 하였다.
이제 하나하나 뜯어보며 분석 해보자
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
void error_handling(char *message);
int main(int argc, char *argv[])
{
int serv_sock;
int clnt_sock;
struct sockaddr_in serv_addr;
struct sockaddr_in clnt_addr;
socklen_t clnt_addr_size;
char message[]="Hello World!";
if(argc!=2){
printf("Usage : %s <port>\n", argv[0]);
exit(1);
}
serv_sock=socket(PF_INET, SOCK_STREAM, 0);
if(serv_sock == -1)
error_handling("socket() error");
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family=AF_INET;
serv_addr.sin_addr.s_addr=htonl(INADDR_ANY);
serv_addr.sin_port=htons(atoi(argv[1]));
if(bind(serv_sock, (struct sockaddr*) &serv_addr, sizeof(serv_addr))==-1 )
error_handling("bind() error");
if(listen(serv_sock, 5)==-1)
error_handling("listen() error");
clnt_addr_size=sizeof(clnt_addr);
clnt_sock=accept(serv_sock, (struct sockaddr*)&clnt_addr,&clnt_addr_size);
if(clnt_sock==-1)
error_handling("accept() error");
write(clnt_sock, message, sizeof(message));
close(clnt_sock);
close(serv_sock);
return 0;
}
void error_handling(char *message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
이 코드는 간단한 TCP 에코 서버를 구현한 C 프로그램이다.
클라이언트로부터 연결 요청을 받아들이고, "Hello World!" 메시지를 전송한 후 연결을 종료합니다.
각 부분을 자세히 살펴보겠다.
1. 헤더 파일 포함
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
stdio.h: 표준 입출력 함수 (printf, fputs 등)를 사용하기 위해 포함합니다.
stdlib.h: 일반적인 유틸리티 함수 (atoi, exit 등)를 사용하기 위해 포함합니다.
string.h: 문자열 처리 함수 (memset 등)를 사용하기 위해 포함합니다.
unistd.h: POSIX 운영 체제에서 제공하는 기본적인 시스템 호출 (close, write 등)을 사용하기 위해 포함합니다.
arpa/inet.h: 인터넷 주소 변환 함수 (htonl, htons 등)를 사용하기 위해 포함합니다.
sys/socket.h: 소켓 관련 함수 (socket, bind, listen, accept 등)를 사용하기 위해 포함합니다.
2. 에러 처리 함수 정의
void error_handling(char *message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
error_handling 함수는 에러 메시지를 표준 에러 출력(stderr)에 출력하고 프로그램을 종료합니다. 에러 처리를 간단하게 처리하기 위해 사용됩니다.
3. main 함수
int main(int argc, char *argv[])
{
// ... (서버 소켓 생성, 바인딩, 연결 수신, 데이터 송신, 소켓 닫기)
}
main 함수는 프로그램의 시작점입니다.
4. 변수 선언
int serv_sock;
int clnt_sock;
struct sockaddr_in serv_addr;
struct sockaddr_in clnt_addr;
socklen_t clnt_addr_size;
char message[]="Hello Worl
d!";
serv_sock: 서버 소켓 파일 디스크립터를 저장합니다.
clnt_sock: 클라이언트 소켓 파일 디스크립터를 저장합니다.
serv_addr: 서버의 주소 정보를 저장하는 구조체입니다.
clnt_addr: 클라이언트의 주소 정보를 저장하는 구조체입니다.
clnt_addr_size: clnt_addr 구조체의 크기를 저장합니다.
message: 클라이언트에게 전송할 메시지를 저장합니다.
5. 명령행 인자 확인
if(argc!=2){
printf("Usage : %s <port>\n", argv[0]);
exit(1);
}
프로그램 실행 시 포트 번호를 인자로 전달해야 합니다. 그렇지 않으면 사용법을 출력하고 프로그램을 종료합니다.
6. 서버 소켓 생성
serv_sock=socket(PF_INET, SOCK_STREAM, 0);
if(serv_sock == -1)
error_handling("socket() error");
socket 함수를 호출하여 IPv4 (PF_INET), TCP (SOCK_STREAM) 소켓을 생성합니다. 성공하면 소켓 파일 디스크립터를 반환하고, 실패하면 -1을 반환합니다.
한마디로 서버 프고그램의 구현 과정에서 제일먼저 해야 할 일이 소켓의 생성 따라서 여기에선 소켓을 생성하고 있지만 아직 소켓이라 부르기엔 이른 상태이다
7. 서버 주소 정보 설정
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family=AF_INET;
serv_addr.sin_addr.s_addr=htonl(INADDR_ANY);
serv_addr.sin_port=htons(atoi
(argv[1]));
memset 함수를 사용하여 serv_addr 구조체를 0으로 초기화합니다.
sin_family를 AF_INET으로 설정하여 IPv4 주소 체계를 사용함을 나타냅니다.
sin_addr.s_addr를 htonl(INADDR_ANY)로 설정하여 모든 로컬 IP 주소에서 연결을 수신할 수 있도록 합니다.
sin_port를 htons(atoi(argv[1]))으로 설정하여 명령행 인자로 전달된 포트 번호를 네트워크 바이트 순서로 변환하여 저장합니다.
한마디로 소켓의 주소 할당을 위해 구조체 변수를 초기화 하고 bind 함수를 호출하는 중이다.
8. 소켓 바인딩
if(bind(serv_sock, (struct sockaddr*) &serv_addr, sizeof(serv_addr))==-1 )
error_handling("bind() error");
bind 함수를 호출하여 생성된 소켓(serv_sock)을 특정 주소(serv_addr)에 바인딩합니다.
9. 연결 수신 대기
if(listen(serv_sock, 5)==-1)
error_handling("listen() error");
listen 함수를 호출하여 소켓을 연결 수신 대기 상태로 변경하고, 최대 5개의 연결 요청을 대기 큐에 저장할 수 있도록 합니다.
여기까지 와야 아까 생성한 소켓을 가리켜 서버 소켓이라고 부를 수 있다.
10. 연결 수락
clnt_addr_size=sizeof(clnt_addr);
clnt_sock=accept(serv_sock, (struct sockaddr*)&clnt_addr,&clnt_addr_size);
if(clnt_sock==-1)
error_handling("accept() error
");
accept 함수를 호출하여 클라이언트의 연결 요청을 수락하고, 새로운 소켓(clnt_sock)을 생성하여 클라이언트와의 통신에 사용합니다.
clnt_addr에는 연결된 클라이언트의 주소 정보가 저장됩니다.
accept 함수가 호출 되었으니 대기 큐에서 첫 번째로 대기 중에 있는 연결요청을 참조하여 클라이언트와의 연결을 구성하고 이 때 생성된 소켓의 파일 디스크립터를 반환한다. 참고로 이 함수가 호출되옸을 때 대기 큐가 비어있는 상태라면 대기 큐가 찰 때까지 , 다시말해서 클라이언트의 연결요청이 들어올 때까지 accept 함수는 반환하지 않는다.
11. 데이터 송신
write(clnt_sock, message, sizeof(message));
write 함수를 호출하여 clnt_sock 소켓을 통해 클라이언트에게 message 데이터를 전송합니다.
12. 소켓 닫기
close(clnt_sock);
close(serv_sock);
close 함수를 호출하여 클라이언트 소켓과 서버 소켓을 닫습니다.
13. 프로그램 종료
return 0;
main 함수는 0을 반환하여 프로그램이 정상적으로 종료되었음을 나타냅니다.
TCP 클라이언트의 기본적인 함수호출 순서
이번엔 클라이언트의 구현순서에 대해서 이야기 해 보겠다. 앞에서도 언급했듯 클라이언트의 구현과정은 서버에 비해 매우 간단하다. 소켓의 생성, 그리고 연결 요청이 끝
클라이언트의 경우 소켓을 생성하고, 이 소켓을 대상으로 연결의 요청을 위해서 connect 함수를 호출하는 것이 전부이다.
connect 함수를 호출할 때 연결할 서버의 주소 정보도 함께 전달한다.
#include <sys/socket.h>
int connect(int sock, const struct sockaddr * servaddr, socklen_t addrlen);
// 성공 시 생성된 소켓의 파일 디스크립터, 실패시 -1 반환
/*
sock : 클라이언트 소켓의 파일 디스크립터 전달
servaddr : 연결요청한 클라이언트의 주소정보를 담을 변수의 주소 값 전달,
함수 호출이 완료되면 인자로 전달된 주소의 변수에는 클라이언트의 주소 정보가 채워진다.
addrlen : 두 번째 매개변수 servaddr에 전달된 주소의 변수
크기를 바이트 단위로 전달,크기 정보를 변수에 저장한 다음에 변수의 주소 값을 전달한다.
그리고 함수 호출이 완료되면 크기정보로 채워져 있던 변수에는
클라이언트의 주소정보 길이가 바이트 단위로 계산되어 채워진다.
*/
클라이언트에 의해서 connect 함수가 호출되면 둘 중 한가지 상황이 되어야 함수가 반환된다.
- 서버에 의해 연결요청이 접수되었다.
- 네트워크 단절 등 오류상황이 발생해서 연결요청이 중단되었다.
여기서 연결요청이 접수되었다는 뜻은 클라이언트의 연결요청 정보가 서버의 연결요청 대기 큐에 등록된 상황을 의미하는 것이다.
때문에 connect 함수가 반환되어도 서비스가 당장 이루어지지 않을 수 있다.
서버를 구현하면서 거쳤던 과정은 bind를 통해서 서버 소켓에 IP와 PORT를 할당하는 것이였다.
클라이언트는 소켓의 주소할당 과정이 없었다.
그저 소켓을 생성하고 서버로의 연결을 위해서 connect 함수를 호출한 것이 전부였다.
네트워크를 통해 데이터를 송수신하려면 IP와 PORT는 반드시 할당되어야 한다.
◈ Client 소켓의 IP, PORT 할당
언제 ? connect 함수가 호출될 때
어디서 ? 운영체제에서, 정확히 커널에서
어떻게 ? IP는 컴퓨터(호스트)에 할당된 IP, PORT는 임의로 선택
즉, bind 함수를 통해서 소켓에 IP와 PORT를 직접 할당하지 않아도
connect 함수호출 시 자동으로 소켓에 IP와 PORT가 할당된다.
따라서 클라이언트 프로그램을 구현할 때에는 bind 함수를 명시적으로 호출할 필요가 없다.
앞서 서버 프로그램을 하나하나 뜯어 봤으니 이번엔 클라이언트 프로그램을 뜯어 보도록 하겠다.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
void error_handling(char *message);
int main(int argc, char* argv[])
{
int sock;
struct sockaddr_in serv_addr;
char message[30];
int str_len;
if(argc!=3){
printf("Usage : %s <IP> <port>\n", argv[0]);
exit(1);
}
sock=socket(PF_INET, SOCK_STREAM, 0);
if(sock == -1)
error_handling("socket() error");
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family=AF_INET;
serv_addr.sin_addr.s_addr=inet_addr(argv[1]);
serv_addr.sin_port=htons(atoi(argv[2]));
if(connect(sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr))==-1)
error_handling("connect() error!");
str_len=read(sock, message, sizeof(message)-1);
if(str_len==-1)
error_handling("read() error!");
printf("Message from server: %s \n", message);
close(sock);
return 0;
}
void error_handling(char *message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
이번에 제공된 코드는 간단한 TCP 클라이언트를 구현한 C 프로그램입니다. 서버에 연결하여 "Hello World!" 메시지를 수신하고 출력하는 역할을 합니다. 각 부분을 자세히 분석해 보겠습니다.
1. 헤더 파일 포함
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
- stdio.h: 표준 입출력 함수 (printf, fputs 등)를 사용하기 위해 포함합니다.
- stdlib.h: 일반적인 유틸리티 함수 (atoi, exit 등)를 사용하기 위해 포함합니다.
- string.h: 문자열 처리 함수 (memset 등)를 사용하기 위해 포함합니다.
- unistd.h: POSIX 운영 체제에서 제공하는 기본적인 시스템 호출 (close, read 등)을 사용하기 위해 포함합니다.
- arpa/inet.h: 인터넷 주소 변환 함수 (inet_addr, htons 등)를 사용하기 위해 포함합니다.
- sys/socket.h: 소켓 관련 함수 (socket, connect 등)를 사용하기 위해 포함합니다.
2. 에러 처리 함수 정의
void error_handling(char *message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
- error_handling 함수는 에러 메시지를 표준 에러 출력(stderr)에 출력하고 프로그램을 종료합니다. 에러 처리를 간단하게 처리하기 위해 사용됩니다.
3. main 함수
int main(int argc, char* argv[])
{
// ... (소켓 생성, 연결, 데이터 수신, 출력, 소켓 닫기)
}
- main 함수는 프로그램의 시작점입니다.
4. 변수 선언
int sock;
struct sockaddr_in serv_addr;
char message[30];
int str_len;
- sock: 소켓 파일 디스크립터를 저장합니다.
- serv_addr: 서버의 주소 정보를 저장하는 구조체입니다.
- message: 서버로부터 수신한 메시지를 저장할 버퍼입니다.
- str_len: read 함수를 통해 수신한 데이터의 길이를 저장합니다.
5. 명령행 인자 확인
if(argc!=3){
printf("Usage : %s <IP> <port>\n", argv[0]);
exit(1);
}
- 프로그램 실행 시 서버의 IP 주소와 포트 번호를 인자로 전달해야 합니다. 그렇지 않으면 사용법을 출력하고 프로그램을 종료합니다.
6. 소켓 생성
sock=socket(PF_INET, SOCK_STREAM, 0);
if(sock == -1)
error_handling("socket() error");
- socket 함수를 호출하여 IPv4 (PF_INET), TCP (SOCK_STREAM) 소켓을 생성합니다. 성공하면 소켓 파일 디스크립터를 반환하고, 실패하면 -1을 반환합니다.
- 이 때 생성하는 건 TCP 소켓이여야한다.
7. 서버 주소 정보 설정
- memset 함수를 사용하여 serv_addr 구조체를 0으로 초기화합니다.
- sin_family를 AF_INET으로 설정하여 IPv4 주소 체계를 사용함을 나타냅니다.
- sin_addr.s_addr를 inet_addr(argv[1])로 설정하여 명령행 인자로 전달된 IP 주소를 네트워크 바이트 순서로 변환하여 저장합니다.
- sin_port를 htons(atoi(argv[2]))으로 설정하여 명령행 인자로 전달된 포트 번호를 네트워크 바이트 순서로 변환하여 저장합니다.
8. 서버 연결
if(connect(sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr))==-1)
error_handling("connect() error!");
- connect 함수를 호출하여 생성된 소켓(sock)을 서버(serv_addr)에 연결합니다.
9. 데이터 수신
str_len=read(sock, message, sizeof(message)-1);
if(str_len==-1)
error_handling("read() error!");
- read 함수를 호출하여 sock 소켓을 통해 서버로부터 데이터를 수신하고 message 버퍼에 저장합니다.
- sizeof(message)-1를 통해 마지막 바이트는 널 문자('\0')를 위해 남겨둡니다.
- 수신한 데이터의 길이를 str_len에 저장하고, 에러 발생 시 에러 처리 함수를 호출합니다.
10. 메시지 출력
printf("Message from server: %s \n", message);
- 서버로부터 수신한 메시지를 출력합니다.
11. 소켓 닫기
close(sock);
- close 함수를 호출하여 소켓을 닫습니다.
12. 프로그램 종료
return 0;
- main 함수는 0을 반환하여 프로그램이 정상적으로 종료되었음을 나타냅니다.
TCP 기반 서버 클라이언트의 함수 호출 관계
전체적인 흐름을 정리하면 서버는 소켓 생성 이후에 bind, listen 함수의 연이은 호출을 통해 대기상태에 들어가고
클라이언트는 connect 함수호출을 통해 연결요청을 하게 된다. 특히 클라이언트는 서버 소켓으 listen 함수호출 이후에
connect 함수호출이 가능하다는 사실을 기억해야한다.
뿐만 아니라 클라이언트가 connect 함수를 호출하기에 앞서 서버가 accept 함수를 먼저 호출 할 수 있다는 사실도 함께 기억해야 한다.
물론 이때는 클라이언트가 connect 함수를 호출할 때까지 서버는 accept 함수가 호출된 위치에서 블로킹 상태에 놓이게 된다.
Iterative 기반의 서버, 클라이언트 구현
Iterative 서버의 구현
지금까지 우리가 보아온건 한 클라이언트의 요청에만 응답을 하고 바로 종료되어 버렸다.
때문에 연결요청 대기 큐의 크기도 사실상 의미가 없었다 그런데 이는 우리가 생각해 오던 서버의 모습이 아니다.
큐의 크기까지 설정해 놓았다면 연결요청을 하는 모든 클라이언트에게 약속되어 있는 서비스를 제공해야한다.
그렇다면 계속해서 들어오는 클라이언트의 연결요청을 수락하기 위해서는 서버의 코드 구현을 어떤 식으로 확장해야 될까?
반복문을 사용해서 accept 함수를 반복 호출하면 된다!
위 의 흐름도를 보충 설명 하면 accept 함수가 호출된 다음 입출력 함수인 read ,write 함수를 호출하고 있다
이어서 close 함수를 호출 하고 있는데 이는 서버 소켓을 대상으로 하는 것이 아니라 accept 함수의 호출 과정에서
생성된 소켓을 대상으로 하는 것
close 함수까지 호출되었다면 한 클라이언트에 대한 서비스가 완료된 것이다
다른 클라이언트에게 서비스 하기 위해선 또 다시 accept 함수부터 호출 해야한다.
즉 명색이 서버인데 한 순간에 하나의 클라이언트에게만 서비스를 제공할 수 있다...
Iterative 에코 서버, 에코 클라이언트
앞서 설명한 형태의 서버를 가리켜 Iterative 서버라고 한다.
그리고 서버가 Iterative 형태로 동작한다 해도 클라이언트 코드엔 차이가 없음을 이해 할 수 있다.
이번엔 Iterative 형태로 동작하는 에코 서버 그리고 이와 함께 동작하는 에코 클라이언트를 작성해 보겠다.
요구조건
1. 서버는 한 순간에 하나의 클라이언트와 연결되어 에코 서비스를 제공한다.
2. 서버는총 다섯 개의 클라이언트에게 순차적으로 서비스를 제공하고 종료한다.
3. 클라이언트는 프로그램 사용자로부터 문자열 데이터를 입력 받아서 서버에 전송한다.
4. 서버는 전송 받은 문자열 데이터를 클라이언트에게 재전송한다. 즉, 에코 시킨다.
5. 서버와 클라이언트간의 문자열 에코는 클라이언트가 Q를 입력할 때까지 계속한다.
위 요구 사항에 맞춰 동작하는 에코 서버의 소스코드.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#define BUF_SIZE 1024
void error_handling(char *message);
int main(int argc, char *argv[])
{
int serv_sock, clnt_sock;
char message[BUF_SIZE];
int str_len, i;
struct sockaddr_in serv_adr;
struct sockaddr_in clnt_adr;
socklen_t clnt_adr_sz;
if(argc!=2) {
printf("Usage : %s <port>\n", argv[0]);
exit(1);
}
serv_sock=socket(PF_INET, SOCK_STREAM, 0);
if(serv_sock==-1)
error_handling("socket() error");
memset(&serv_adr, 0, sizeof(serv_adr));
serv_adr.sin_family=AF_INET;
serv_adr.sin_addr.s_addr=htonl(INADDR_ANY);
serv_adr.sin_port=htons(atoi(argv[1]));
if(bind(serv_sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr))==-1)
error_handling("bind() error");
if(listen(serv_sock, 5)==-1)
error_handling("listen() error");
clnt_adr_sz=sizeof(clnt_adr);
for(i=0; i<5; i++)
{
clnt_sock=accept(serv_sock, (struct sockaddr*)&clnt_adr, &clnt_adr_sz);
if(clnt_sock==-1)
error_handling("accept() error");
else
printf("Connected client %d \n", i+1);
while((str_len=read(clnt_sock, message, BUF_SIZE))!=0)
write(clnt_sock, message, str_len);
close(clnt_sock);
}
close(serv_sock);
return 0;
}
void error_handling(char *message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
for(i=0; i<5; i++)
{
clnt_sock=accept(serv_sock, (struct sockaddr*)&clnt_adr, &clnt_adr_sz);
if(clnt_sock==-1)
error_handling("accept() error");
else
printf("Connected client %d \n", i+1);
while((str_len=read(clnt_sock, message, BUF_SIZE))!=0)
write(clnt_sock, message, str_len);
close(clnt_sock);
}
총 5개의 클라이언트에게 서비스를 제공하기 위한 반복문이다. 결과적으로 accept 함수가 총 5회 호출되어 5개의 클라이언트에게 순서대로 에코 서비스를 제공한다.
while((str_len=read(clnt_sock, message, BUF_SIZE))!=0)
write(clnt_sock, message, str_len);
실제 에코 서비스가 이뤄지는 부분 읽어 들인 문자열 그대로 전송하고 있다.
close(clnt_sock);
소켓을 대상으로 close 함수가 호출되면, 연결되어있던 상대방 소켓에게 EOF가 전달된다.
즉 클라이언트 소켓이 close 함수를 호출하면 while((str_len=rea.... 부분은 거짓이 되어
close 함수가 실행된다.
※ EOF : 컴퓨팅에서, 파일 끝(End of File, EOF)는 데이터 소스로부터 더 이상 읽을 수 있는 데이터가 없음을 나타낸다.
close(serv_sock);
총 5개의 클라이언트에게 서비스를 제공하고 나면 마지막으로 서버 소켓을 종료하면서 프로그램을 종료한다.
위를 실행 시켜보면 클라이언트와의 연결 정보가 출력되도록 예제가 작성되어 있다.
이어서 에코 클라이언트를 보여주겠다.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#define BUF_SIZE 1024
void error_handling(char *message);
int main(int argc, char *argv[])
{
int sock;
char message[BUF_SIZE];
int str_len;
struct sockaddr_in serv_adr;
if(argc!=3) {
printf("Usage : %s <IP> <port>\n", argv[0]);
exit(1);
}
sock=socket(PF_INET, SOCK_STREAM, 0);
if(sock==-1)
error_handling("socket() error");
memset(&serv_adr, 0, sizeof(serv_adr));
serv_adr.sin_family=AF_INET;
serv_adr.sin_addr.s_addr=inet_addr(argv[1]);
serv_adr.sin_port=htons(atoi(argv[2]));
if(connect(sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr))==-1)
error_handling("connect() error!");
else
puts("Connected...........");
while(1)
{
fputs("Input message(Q to quit): ", stdout);
fgets(message, BUF_SIZE, stdin);
if(!strcmp(message,"q\n") || !strcmp(message,"Q\n"))
break;
write(sock, message, strlen(message));
str_len=read(sock, message, BUF_SIZE-1);
message[str_len]=0;
printf("Message from server: %s", message);
}
close(sock);
return 0;
}
void error_handling(char *message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
if(connect(sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr))==-1)
error_handling("connect() error!");
else
puts("Connected...........");
connect 함수가 호출되고 있다 앞서 언급했듯 이 함수호출로 인한 연결요청 정보가 서버의 대기 큐에 등록이 되면 connect 함수는 정상적으로 호출을 완료한다. 때문에 else에 의해서 연결되었다는 문자열 정보가 출력 되더라도 서버에선 accept 함수를 호출 하지 않은 상황이라면 실제 서비스가 이뤄지지않는다.
close(sock);
이렇게 close 함수가 호출되면 상대 소켓으로 EOF가 전송된다.
에코 클라이언트의 문제점
다음은 클라이언트에 삽입된 입출력 문장이다.
위의 코드는 잘못된 가정이 존재한다.
"read, write 함수가 호출될 때마다 문자열 단위로 실제 입출력이 이뤄진다"
언급했듯이 TCP는 데이터의 경계가 존재하지 않는다.
첫 번째 문제는 위에서 구현한 클라이언트는 TCP 클라이언트이기 때문에 둘 이상의 write 함수 호출이 이루어진 뒤 문자열이 묶여서 한 번에 서버로 전송될 수 있다.
그러한 상황이 발생하면 클라이언트는 한 번에 둘 이상의 문자열을 서버로부터 받아야 하기 때문에 원하는 결과를 받지 못한다.
두 번째 문제는 버퍼의 크기이다.
문자열의 길이가 긴 편이라면 OS는 두 개의 패킷, 혹은 그 이상의 패킷으로 나눠서 보낸다.
서버는 한 번의 write 함수호출로 데이터 전송을 명령했지만, 전송할 데이터의 크기가 크다면
OS는 내부적으로 이를 여러 개의 조각으로 나눠서 클라이언트에게 전송할 수 있다.
그리고 이 과정에서 데이터의 모든 조각이 클라이언트에게 전송되지 않았음에도 불구하고,
클라이언트는 read 함수를 호출할 지도 모른다.
물론 우리가 구현한 에코 서버와 에코 클라이언트는 별 문제 없이 제대로 된 서비스 결과를 보이고 있다.그러나 이는 운이 좋았던 것이다! 송수신하는 데이터의 크기가 작고 실제 실행환경이 하나의 컴퓨터 또는 근거리에 놓여있는두 개 의 컴퓨터이다 보니 오류가 발생하지 않는 것 일뿐, 오류의 발생확률은 여전히 존재한다.
오류를 해결한 에코 서버와 에코 클라이언트는 다음 챕터에 소개하도록 하겠다.
'스터디 > TCP 와 IP' 카테고리의 다른 글
6. 소켓의 우아한 연결 종료 (0) | 2024.09.03 |
---|---|
5. TCP 기반 서버/ 클라이언트 2 (0) | 2024.09.03 |
3. 주소체계와 데이터 정렬 (1) | 2024.09.03 |
2. 소켓의 타입과 프로토콜의 설정 (0) | 2024.09.02 |
1. 네크워크 프로그래밍과 소켓의 이해 (0) | 2024.09.02 |