파이톨치

[sock programming] Loopback "hello world" 본문

대학수업/socket programming

[sock programming] Loopback "hello world"

파이톨치 2024. 4. 2. 16:10
728x90
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

C header 파일 for 소켓 프로그래밍

void error_handling(char *message) {
  fputs(message, stderr);
  fputc('\n', stderr);
  exit(1); 
}

에러가 생겼을 경우 해당 에러를 출력하게 함. stderr 파일 디스크립터를 사용함. 

참고 자료: (https://www.ibm.com/docs/ko/i/7.3?topic=functions-fputs-write-string)

 

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!"; 
  
  (생략) 
  
}

위 코드에서 serv_addr은 sockaddr_in 구조체이다. 이는 IPv4 기반의 주소표현을 위한 구조체이다. 아래와 같은 구조이다.

struct sockaddr_in
{
    sa_family_t		sin_family;
    uint16_t		sin_port;
    struct in_addr	sin_addr;
    char		sin_zero[8];
};

sin_family는 주소체계 (IPv4)를 의미하고,sin_port는 포트 번호, sin_addr은 4바이트 IP 주소, sin_zero[8]은 확장성을 위해 남겨둔 것이다. 위 코드에서는 이를 순서대로 할당해주고 있다. sin_zero는 반드시 0으로 초기화 해야하며, memset을 통해 초기화한다. 

더보기

sin_zero 멤버는 실제로 사용되지 않는 공간이지만, 구조체의 크기를 16바이트로 맞추는 데 사용됩니다. 이 멤버를 0으로 채우는 것은 미사용 공간을 명확하게 구분하고, 이전 데이터의 잔재로 인한 혼란을 방지하기 위함입니다.

sockaddr_in 구조체는 그대로 사용하는 것이 아니라 sockaddr로 형 변환을 해서 사용한다. 구조체 sockaddr은 다양한 주소체계의 주소정보를 담을 수 있도록 정의되었다. 이에 동일한 바이트 열을 구성하는 구조 체 sockaddr_in이 정의되었으며, 이를 이용해서 쉽게 IPv4의 주소정보를 담을 수 있다.

 

int main(int argc, char *argv[]) {
  
  (중간 생략)
  
  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");
    
  (중간 생략)
  
}

 

main: *argv[]는 인자로 넣어준다. 이때 인자가 원하는만큼 안 들어오면 에러를 출력한다. 이 경우 실행 인자 외에 추가로 1개의 인자를 넣어주어야 한다. 파일 이름이 server라면 ./server port_number로 만들어야 한다. (이건 크게 중요하지 않음.)

 

socket: int socket(int domain, int type, int protocol);을 인자로 받는 함수이다. 

domain은 소켓이 사용할 프로토콜 체계(Protocol Family) 정보를 전달한다. Protocol Family는 PF_INET(IPv4), PF_INET6(IPv6)등이 있다.

type은 소켓의 타입을 말하는데, 데이터 전송방식을 의미한다. PF_INET(IPv4)의 대표적인 소켓 타입은 연결 지향형 소켓 타입(SOCK_STREAM, TCP 소켓)과 비 연결지향형 소켓 타입(SOCK_DGRAM, UDP 소켓)이 있다.

protocol은 IPPROTO_TCP, IPPROTO_UDP를 결정하는데 앞에서 보통 결정되어서 0을 쓴다. 

 

 

TCP의 경우, 중간에 데이커가 소멸되지 않고, 순서대로 수신된다. 또한 데이터 경계(연속적인 데이터라서)가 존재하지 않으며, 소켓 대 소켓의 연결은 반드시 1대 1의 구조이다. 

UDP의 경우, 중간에 데이터 손실 및 파손의 우려가 있고, 전송 순서 상관 없이 빠름을 지향한다. 데이터의 경계가 존재하며, 한번에 전송할 수 있는 데이터의 크기가 제한된다. (UDP에서 데이터 경계(Data Boundary)란 각각의 UDP 데이터그램(Datagram)이 독립적인 개체라는 것을 의미)

 

TCP와 같이 데이터의 경계가 존재하지 않는 경우, read할 때 주의해야 한다. 한번만 read 하는 것이 아니라 message의 끝까지 read할 수 있게 반복문을 돌아야 한다. 

더보기

윈도우 운영체제의 socket 함수

프로토콜은 표준이다! 따라서 소켓의 타입에 따른 데이터의 전송특성은 운영체제와 상관없이 동일하다.

 

int main(int argc, char *argv[]) {

  (생략)

  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])); 

  (생략)

}

인터넷 주소(Internet Address): 인터넷 상에서 컴퓨터를 구분하는 목적으로 사용되는 주소이다. 우리가 쓰고 있는 인터넷 주소는 4바이트 체계인 IPv4이며, 차세대 주소로 16바이트 주소인 IPv6가 있다. 소켓을 생성할 때도 이 인터넷 주소 체계를 지정해 줘야 한다. 4바이트 내에서 네트워크 주소와 호스트 주소로 나뉜다.

 

클래스 A는 네트워크 주소를 1바이트 할당하며, 첫 번째 비트는 항상 0으로 시작한다. 그러면 8비트 중에서 7비트만 사용하며, 0이상 127이하가 된다. 123.xx.xx.xx가 되는 것이다. 클래스 B는 10으로 시작하며, 128~191 이하가 된다. 클래스 C는 110으로 시작해서 192~223이다. 때문에 첫 번째 바이트 정보만 참조해도 IP주소의 클래스 구분이 가능하며, 이로 인해서 네트워크 주소와 호스트 주소의 경계 구분이 가능하다. (클래스별 네트워크 주소 할당 다름. A는 1바이트, B는 2바이트, C는 3바이트) 

 

PORT 번호는 컴퓨터 내 소켓의 구분에 활용된다. (IP는 컴퓨터를 구분하는 용도이다.) 하나의 프로그램 내에서 둘 이상의 소켓이 존재, 둘 이상의 PORT가 하나의 프로그램에 의해 할당될 수 있다. PORT 번호는 16비트, 2바이트이다. 0~1023은 well-known port라 부르며, 용도가 이미 정해져 있다.

 

컴퓨터마다 정수를 표현하는 방식이 다르다.  Big Endian / Little Endian 이 있는데, 4바이트 정수로 1을 표현할 때 0000 0000 0000 0001로 표현(Big Endian) 하거나 0001 0000 0000 0000 로 표현(Little Endian) 한다. 

 

htonl은 host to network long 을 의한다. INADDR_ANY는 현재 내 컴퓨터의 ip 주소를 의미(특별한 상수로, IP 주소로 0.0.0.0을 나타냄. 이는 "현재 컴퓨터의 모든 IP 주소"를 의미) 하는데, 이를 host 바이트 순서(리틀 엔디안)에서 네트워크 바이트 순서(빅 엔디안)으로 변환하겠다는 의미이다. 이를 통해, 서버 소켓이 모든 IP 주소에서 들어오는 연결을 올바르게 처리할 수 있게 됨

port는 인자로 넘겨준 문자를 정수로 바꾼 다음에 , 네트워크 체계로 바꾸겠다는 의미이다. 

 

더보기

in_addr_t inet_addr(const char * string); 

// 성공 시 빅 엔디안으로 변환된 32비트 정수 값을 반환하며, 실패하면 INADDR_NONE을 반환한다. 

// "1.2.3.4" -> 0x04030201

// 반환형이 in_addr_t 임.

 

int inet_aton(const char * string, struct in_addr * addr);

// 성공 시 1(true), 실패 시 0(false) 반환 

// string 형태의 주소 값을 빅 엔디안(네트워크 바이트)으로 변환하여 addr에 저장한다.

 

char * inet_ntoa(struct in_addr adr); 

// 성공하면 변환된 문자열 주소 값을 반환, 실패 시 -1 반환 

// 네트워크 바이스 순서로 정렬된  정수현 IP 정보를 host 바이트 순서 문자열로 변환 

// xx.xx.xx.xx 형태의 문자열로 준다. 

 

struct sockaddr_in addr;
char *serv_ip = "211.217.168.13";
char *serv_port = "9190"; 
memset(&addr, 0, sizeof(addr)); 
addr.sin_family = AF_INET; 
addr.sin_addr.s_addr = inet_addr(serv_ip); //여기서 쓴다. big endian으로 변환시켜준다. 
addr.sin_port = htons(atoi(serv_port));

INADDR_ANY: 현재 실행 중인 컴퓨터의 IP를 소켓에 부여할 때 사용되는 것이 INADDR_ANY이다. 그렇기 때문에 server.c를 실행할 때, 서버의 리스닝 소켓 주소는 INADDR_ANY로 지정해서, 소켓의 PORT 번호만 인자를 통해 전달한다. 

int main(int argc, char *argv[]) {

  (생략)

  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"); 

  (생략)

}

bind 함수까지 호출되면 주소가 할당된 소켓을 얻게 된다. listen 함수의 호출을 통해 연결 요청이 가능한 상태가 된다.  

클라이언트의 경우 소켓을 생성하고, 이 소켓을 대상으로 연결의 요청을 위해서 connect 함수를 호출하는 것이 전부이 다. 그리고 connect 함수를 호출할 때 연결할 서버의 주소 정보도 함께 전달

 

연결요청  대기  상태로의  진입 (listen() 수행): 연결요청도 일종의 데이터 전송이다. 따라서 연결요청을 받아들이기 위해서도 하나의 소켓이 필요하다. 그리고 이 소켓을 가리켜 서버소켓 또는  리스닝  소켓이라  한다. listen 함수의  호출은 소켓을  리스닝  소켓(serv_sock)이  되게  한다.

1. 서버코드에서 listen()  수행 하면 server 소켓을 listening 소켓으로 바꿔준다 (연결요청 대기실 준비해준다).
2. 클라이언트  코드에서 connect() 수행하면  그  연결요청이  연결요청  대기실에 등록된다.

더보기
  • TCP / IP 프로토콜  스택이란?
    인터넷  기반의  데이터  송수신을  목적으로  설계된  스택
    큰  문제를  작게  나눠서  계층화  한  결과
    데이터  송수신의  과정을  네  개의  영역으로  계층화  한  결과
    각  스택  별  영역을  전문화하고  표준화  함
    7계층으로 세분화가 되며,  4계층으로도 표현함

 

  • LINK 계층의  기능  및  역할
    물리적인  영역의  표준화  결과
    LAN, WAN, MAN과  같은  물리적인  네트워크  표준  관련  프로토콜이  정의된  영역
    아래의  그림과  같은  물리적인  연결의  표준이  된다.

 

  • IP 계층의  기능  및  역할
    IP는  Internet protocol을  의미함
    경로의  설정과  관련이  있는  프로토콜

 

  • TCP / UDP 계층의  기능  및  역할
    실제  데이터의  송수신과  관련  있는  계층
    그래서  전송(Transport) 계층이라고도  함
    TCP는  데이터의  전송을  보장하는  프로토콜(신뢰성  있는  프로토콜), UDP는  보장하지 않는  프로토콜
    TCP는  신뢰성을  보장하기  때문에  UDP에  비해  복잡한  프로토콜이다

  • 프로그래머에  의해서  완성되는 APPLICATION 계층
    응용프로그램의  프로토콜을  구성하는  계층
    소켓을  기반으로  완성하는  프로토콜을  의미함
    소켓을  생성하면, 앞서  보인  LINK, IP, TCP/UDP 계층에  대한  내용은  감춰진다.
    그러니  응용  프로그래머는 APPLICATION 계층의  완성에  집중하게  된다.

 

int main(int argc, char *argv[]) {

  (생략) 

  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; 
}

 

clnt_sock을 만들어 줘야 하는데, accept 을 통해서 clnt와 serv 서로 연결을 해줘야 한다. clnt_sock이 연결 되어서 할당 받는 것이다. 이제 연결된 clnt_sock을 통해서 message를 쓰는 것이다. 

 

클라이언트의  연결요청  수락(accept() 수행): 연결요청 정보를 참조하여 클라이언트 소켓과의 통신을 위한 별도의 소켓을 추가로 하나 더 생성 한다. 그리고 이렇게 생성된 소켓을 대상으로 데이터의 송수신이 진행된다. 실제로 서버의 코드를 보면 실제로 소켓이 추가로 생성되는 것을 확인할  수  있다.

1. 서버 코드에서 accept() 수행하면 연결요청 대기큐에서 하나의 연결 요청을 선택
2. 해당  클라이언트와의 데이터  통신을  전담할 소켓 (서버코드 내 client  소켓)을  생성
3. 해당  클라이언트의  소 켓과  연결완료

 

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;
}

 

TCP 서버의 기본적인 함수호출 순서

socket() -> bind() -> listen() -> accept() -> read()/write() -> close() 

bind 함수 => 주소가 할당된 소켓

listen 함수 => 연결 가능한 상태 

반복적으로 accept 함수 호출 -> 여러개 가능? 

 

서버에서 listen(serv_sock, ??) -> listening socket 됨. 

클라에서 connect() -> 연결 요청 대기실에 등록, 연결 요청  

서버에서 accept(clnt_sock, addr, addrlen) 호출 -> 큐에서 하나 연결 요청 선택 , 소켓 생성, 연결 완료 

 

클라에서 connect 함수 호출 완료 되려면, 서버 연결 요청 접수 완료, 오류상황 발생해 연결 요청 중단. 

서버에서 listen 함수 수행 이후 => connect 함수 수행이면 정상동작. 

 

클라 소켓 주소 정보는 어디? socket() 함수 콜하여 서버 소켓 생성, bind해서 서버 소켓에 서버ip, port 주소 할당

클라에서, socket 콜해서 소켓 생성, connect해서 os가 클라 ip, port(임의)로 선택함. 내 정보만 넣어준다. 

서버에서 listen이후에 호출해야 클라 connect 유효함. 아 accept은 큐에 대기 중인 애 가져오는 거구나. 

 

TCP 클라이언트의  기본적인  함수호출  순서

socket() -> connect() -> read/write() -> close()

connect 함수 => 연결 요청, 서버 주소 전달

 

1. 클라이언트의 경우 socket() 을 생성.

2. 이 소켓을 대상으로 연결의 요청을 위해서 connect() 함수를 호출하는 것. connect 함수를 호출할 때 연결할 서버의 주소 정보도 함께 전달한다.

 

클라이언트  소켓의  주소  정보는  어디에?: 서버  코드를  보면  socket() 함수를  call 하여  서버  소켓을  생성하고, bind() 함수를  call 하여  서버  소켓에  서버의  IP 및  Port 주소를  할당한다. 클라이언트 코드를 보면 socket() 함수를 call 하여 클라이언드 소켓을 생성하고, bind() 함수 call 없이 바로 connect() 함수를 call 한다. 그렇다면 클라이언트 소켓에 클라이언트의 IP 및 Port 주소는 어떻게 할당되나?


클라이언트의  IP 및  Port 주소  할당? connect () 함수  call 할때, OS가, IP는  호스트(클라이언트)에  할당된  IP로, Port는  임의로  선택해서 할당. 

 

TCP 기반  서버, 클라이언트의  함수호출  관계

확인할 사항은, 서버의 listen 함수호출 이후에 야 클라이언트의 connect 함수호출이 유효하 다는 점이다. 더불어 그 이유까지도 설명할 수
있어야  한다.

소켓함수들  사용은  file IO시  open(), read(), write(), close() 사용과  흡사
- 26번줄  socket()는  open()와  동일: socket file 생성
- 35번줄  bind(), 38번줄  listen(), 42번줄  accept()는  read()/write 수행전  데이터 통신관련  사전  준비작업을  수행
- 두  종류의  소켓이  사용됨. serv_sock은  client(보통  여러  개)와의  connection 관리용도로만  사용. clnt_sock은  client와  데이터송수신용으로  사용. clnt_sock은 42번줄  accept()에  의해  자동  생성됨

 

# iterative 하게 만드려면?? 

한 순간에 하나의 클라와 연결되어야 함. for ( ~ ) { socket = ~; ~~~; close(socket); } 

서버: socket->bind->listen->accept->close 중에서 accept가 5번이 되어야 함. 

for(i=0; i<5; i++) {
	// 큐에 있는 clnt 소켓 할당, accept 함수 
    // clnt_adr은 뭐 어디서 할당된거임? accept
    // accept 함수는 연결된 클라이언트 주소 정보 저장
	socket = accept(serv_sock, (struct sockaddr*)&clnt_adr, &clnt_adr_sz); 
    
    if(clnt_sock==-1) 
    	error_handling("accept eror");
        
    while((str_len=read(clnt_sock, message, BUF_SIZE))!=0)
    	write(clnt_sock, message, str_len); 
    
    close(clnt_sock);
}

요렇게 하는데 이해 안되는게 accept 함수임. 클로드한테 물어보니까 걍 새로운 소켓 만들고, 그 소켓 파일 디스크립터 반환해주는 애임. 서버 소켓에서 클라의 연결 요청이 올 때까지 대기함. 연결 요청이 오면 connect 하면, 동작함. clnt_adr, clnt_adr_sz 구조체를 채운다. 

 

while(1) {
	// stdout이 뭐지. 
    fputs("input message(Q to quit): ", stdout);
    // stdin으로 보내주는건가. 
    fgets(message, BUF_SIZE, stdin); 
    
    if(!strcmp(message, "q\n") || !strcmp(message, "Q\n")) 
    	break; 
    
    // sock이 연결된 애
    write(sock, message, strlen(message);
    str_len = read(sock, message, BUF_SIZE-1);
	// 문자열 완성, null
	message[str_len] = 0;
    printf("message from server: %s", message);
    
}

stdout, stdin은 내 키보드와 모니터이다. write는 소켓의 파일 디스크립터라 연결된 소켓으로 쏜다. read로 서버로부터 응답 메시지를 받는다. message 배열에 저장이 되고 문자열 완성시켜준다. 

근데, 이게 문제가 있는게 TCP는 데이터 송수신 경계가 없다. 그래서 일부만 읽혀질 수 있다.

서버는 데이터 경계 구분하지 않고, 수신 된 데이터를 그대로 전송하는 의무가 있다. 

while((str_len=read(clnt_sock, message, BUF_SIZE)) != 0)
	write(clnt_sock, message, str_len);

 

이러면 별 문제가 없다. 

클라는 문장 단위로 데이터를 송수신하기 때문에, 구분해야한다. 때문에 좀 문제가 있다. 

그럼 어카나? write 함수 호출을 통해서 반복문으로 길이만큼 읽어. 

str_len = write(sock, message, strlen(message)); 

recv_len = 0;
while(recv_len < str_len)
{
	recv_cnt = read(sock, &message[recv_len], BUF_SIZE-1);
    if(recv_cnt==-1)
    	error_handling("read() error");
    recv_len += recv_cnt;
}
message[recv_len] = 0;
printf("message from server: %s", message);

 

요래 해준다. 읽었을 때 길이를 보고 좀 짧으면 다시 실행해서 받는다. 

일단 클라에서 버퍼에 저장된 메시지를 write한다. 그 후에, 다시 에코로 받게 되는데, 그 때 모자라면 더 받는다. 

근데 write할 때는 문제 없는거야? 그렇다네? 보낼 때는 그냥 한번에 보내나봐. 버퍼보다 적으면?

 

서버에서도 read할 때 반복문 조지면 좋음. 서버에서도 whlie 쓰는데, 클라이언트가 연결을 종료할 때까지 반복한다. 받은 데이터의 길이가 0이 될 때까지 하는거임. 

 

 

# 서버 코드
  for(i=0; i<5; i++) {
    clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_addr, &clnt_adr_sz);
    if (clnt_sock==-1)
      error_handling("accept() error"); 
    else 
      printf("Conneted clint %d \n", i+1); 
    
    while((str_len=read(clnt_sock, message, BUF_SIZE))!=0)
      write(clnt_sock, message, str_len); 

    close(clnt_sock);
  }

 

더보기
C이거 뭔가 이상한데 나중에 한번 더 보라.

 

# 윈도우로 할 때 필요한 것. 

1. WSAStartup, WSACleanup 함수호출을 통한 소켓 라이브러리의 초기화와 해제

2. 자료형과 변수의 이름을 윈도우 스타일로 변경하기

3. 데이터 송수신을 위해서 read, write 함수 대신 recv, send 함수 호출하기

4. 소켓의 종료를 위해서 close 대신 closesocket 함수 호출하기

 

# 소켓을 이용해 통신이 연결되는 과정 

1. 서버에서 serv_socket 만들어 TCP, port 80, Ip 127.0.0.2

2. 클라에서 clnt_socket 만들어 TCP, port 80000 IP 127.0.0.1

3. HTTP/GET/127.0.0.2:80 으로 연결 요청, connect()

4. 서버에서 accept() 수행 해 생성되는 clnt_socket 만들어 

5. 새로 만들어진 소켓과 클라 소켓 연결 성립해. 

6. close 하면 통신용 소켓은 사라지고 서버 소켓은 남아. 

 

# TCP 내부 동작 원리 (더 자세히 보세요) 

1: 상대 소켓과 연결함. 

1-1 : 내가 지금 보내는 패킷에 1000이라는 번호 (SEQ) 부여, 다음에 1001 패킷 전달하라고 말하셈. (SYN)

1-2: ㅇㅇ, 내가 지금 1001 요청 (ACK)하고 2000(SEQ) 보내니깐 다음에 받으면 2001 요구하셈.  (SYN + ACK)

1-3: 좀 전에 종송한 SEQ 2000 패킷 잘 받았다. SEQ 2001패킷 보내라. (ACK)

 

ACK의 값을 전송된 바이트 크기 만큼 증가 시키는 이유 -> 패킷의 전송 유무 + 데이터 손실 유무 확인 

SEQ 전송시 타이머 작동 -> ACK가 안 오면 재전송 

 

(4-way) 일방적 종료로 인한 데이터 손실을 막기 위해. 끊나는 것을 알려주는거임. 

 

728x90