반응형

10-1 다중 접속 서버의 구현 방법들

※ 클라이언트의 (여러명)다중 접속을 허용(concurrent server).

리눅스 기반의 다중 접속 서버 구현 방법들

1. 프로세스 생성을 통한 멀티태스킹(Multitasking) 서버의 구현

2. select 함수에 의한 멀티플렉싱(Multiplexing) 서버의 구현

3. 쓰레드 기반으로 하는 멀티쓰레딩(Multithreading) 서버의 구현


10-2. 프로세스의 생성

프로세스에 대한 이해

1. 실행되고 있는 프로그램의 기본 단위

2. 생성된 프로세스는 운영체제에 의해 할당된 고유한 ID를 가진다.

3. 하나의 프로그램 내에서 여러 개의 프로세스가 동시에 실행 될 수 있다.


ps -u : 어떤 프로세스가 수행되고 있는 지 보여준다. (shell 명령)

pid : 프로세스 아이디


fork 함수 호출을 통한 프로세스 생성


#include<sys/types.h>

#include<unistd.h>


pid_t fork(void);

성공시 프로세스 ID(자식프로세스면 0), 실패시 -1


프로세스 생성 예제

소스내용

a=10;

(... 중략 ...)

pid = fork();

// fork()를 호출하자마자 메모리 상태.

(... 중략...)

printf("fork 성공, 프로세스 id : %d\n",pid);

if(pid==0)

    data +=10;

else

    data -=10;

printf("data : %d",pid);

(...중략...)


10-3 프로세스&좀비(Zombie) 프로세스

좀비 프로세스 : 프로세스 종료후 메모리 상에서 사라지지 않는 프로세스

첫번째 그림

자식 프로세스는 부모 프로세스가 (종료)리턴 값을 받을 때까지 메모리 상에서 사라지지 않는다.

(죽지 않는다.)


두번째 그림

자식 프로세스가 (종료)리턴해 준 값을 커널이 부모 프로세스에게 주고 자식 프로세스를 종료한다.

부모 프로세스는 자식 프로세스의 (종료)리턴 값을 달라고 요구해야 좀비 프로세스가 생성되지 않는다.


※ int main()에서 return 0;하는 이유

프로그램의 main은 프로세스 생성을 요구한다.

리턴의 의미는 커널이 제대로 종료했는 지 알기 위해 필요한 값이다.


좀비 프로세스의 생성 이유.

자식 프로세스는 부모 프로세스에게 실행 결과에 대한 값을 반환해야 한다.

부모 프로세스가 반환받지 않으면 자식 프로세스는 좀비 프로세스가 된다.


wait함수의 사용

#include<sys/types.h>

#include<sys/wait.h>

pid_t wait(int *status);

자식 프로세스를 기다린다. 자식 프로세스가 종료하지 않으면 무한 대기할 수도 있다.

리턴 : 성공시 자식 프로세스 ID, 실패시 -1

status : 다음 매크로 함수 이용

WIFEXITED(status) : 정상 종료시 0을 반환

WEXITSTATUS(status) : 종료시 return하거나 exit함수의 인자로 넘겨진 값을 반환한다.


좀비 프로세스 소멸

소멸 방법 : 부모 프로세스에서 자식 프로세스의 반환 값을 요구한다.


waitpid함수의 사용

#include<sys/types.h>

#include<sys/wait.h>

pid_t waitpid(pid_t pid, int *status, int options);

참고주소 : http://linux.die.net/man/2/waitpid

리턴 : 성공시 종료된 자식 프로세스 ID, 실패시 -1

option이 WNOHANG이고 상태가 변한 프로세스가 없으면 0을 리턴.

pid 

-1이면 임의의 자식 프로세스가 종료하기를 기다리게 된다.

0이면 호출한 프로세스의 아이디와 같은 그룹일 경우.

0이상이면 기다리길 원하는 자식 프로세스의 ID. 

status : wait 함수의 status와 동일.

WNOHANG : 종료한 자식 프로세스가 없는 경우 대기 상태로 들어가지 않고 바로 리턴.


10-4. 시그널(Signal) 핸들링 & 좀비(Zombie) 프로세스

어떻게 효율적으로 좀비 프로세스를 처리할까?

부모 프로세스 입장에서 좀비(자식) 프로세스가 발생하는 시점을 모르기 때문에 (시스템은 알고 있음.)

운영체제가 시그널을 보내 부모 프로세스가 그 시그널을 읽어서 (시그널 핸들러를 통해) 자식 프로세스를 처리.


시그널

시스템 내의 특정상황 발생을 알리기 위해서 커널이 전달하는 신호. (운영체제에 의해 약속된 상수.)

시그널 핸들러

적절한 처리를 해 주는 함수

시그널 핸들링

시그널이 발생함에 따라 이에 대한 적절한 처리를 해 주는 것.


1. 자식 프로세스가 종료되었을 때(특정 상황) 부모 프로세스에게 시그널을 보냄.

2, 부모 프로세스는 시그널을 받으면 시그널 핸들러를 실행


signal 함수를 이용한 시그널 핸들링

signal함수

#include<signal.h>

void (*signal(int signum, void (*func)(int)))(int);

시그널(signum)과 시그널 핸들러(func)를 연결시켜주는 기능.

(해당 시그널 번호를 받으면 시그널 핸들러 함수가 실행된다.)

리턴이 void (*)(int); 함수 포인터 타입

리턴 : 오류시 SIG_ERR

signum

 시그널 발생상황 
 SIGALRM 시간을 예약해 놓고 그 시간이 되었을 경우 발생한다. 
 SIGINT Ctrl-C를 누를 경우 인터럽트 발생 
 SIGCHLD 자식 프로세스가 종료된 경우 발생. 


※ 시그널 핸들러에서 signal함수를 넣는 이유

운영체제가 시그널을 등록하면 딱 한 번만 signal함수를 호출하는 경우가 있기 때문에

※ signal로 시그널을 등록하지 않으면 운영체제에서 기본적으로 정의한 signal 핸들러가 호출된다.


sigaction함수

#include<signal.h>

시그널(signum)과 시그널 핸들러(func)를 연결시켜주는 기능. 이 함수를 사용하는 것을 추천.

int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);


struct sigaction

{

    void (*sa_handler)(int);

    sigset_t sa_mask;    

    int sa_flags;

};


sa_mask

동시에 signal이 발생해도 처리할 수 있도록 한다. 

pending된 signal을 block, unblock시키는 용도로 사용.


sigemptyset : 시그널 마스크 비트(sa_mask)를 0으로 설정.


10-5. fork 함수를 이용한 다중 접속 서버의 구현


1. 클라이언트가 서버에게 연결 요청

2. 서버 프로세스가 연결 요청을 받을 때마다 자식 프로세스 생성 -> 자식 프로세스는 부모 프로세스가 전달한 디스크립터(client_sock)을 받는다.

3. 클라이언트와 자식 프로세스와 통신

※ 소켓은 운영체제가 관리하기 때문에 fork를 해도 복사되지 않는다. 파일 디스크립터는 복사가 된다.


파일 디스크립터의 복사

하나의 소켓에 대한 파일 디스크립터가 둘 이상 존재하는 경우, 모든 파일 디스크립터를 종료해 줘야 해당 소켓이 종료된다.

※ 커널에서 소켓은 참조카운트를 가지고 있을 듯...


부모 프로세스는 클라이언트 소켓이 필요없고 자식 프로세스는 서버 소켓이 필요없으므로 복제된 후 미리 삭제해 준다.


10-6. TCP 입출력 루틴(Routine) 분할하기

에코 클라이언트의 경우 적용 가능(그냥 하나의 모델로서... 소스가 복잡해 지는 단점이 있다.)

※ 송수신이 잦은 프로그램에 한해 효율성이 증가할 수 있다.


입력과 출력을 실행하는 루틴을 부모-자식 프로세스로 분리한다.

=> 입출력이 독립되어 다음과 같은 통신이 가능하다.


반응형
반응형

9-1 소켓의 옵션

1. 소켓의 옵션 조작 : 소켓의 기본적인 특성(다양함)을 변경하는 것.

※ 운영체제에 의해 관리. 운영체제는 소켓의 특성에 대한 정보를 보관하고 있다.

2. 소켓의 기본적인 특성

 - 입력 및 출력 버퍼의 크기

 - 데이터 전송 방식(TCP 또는 UDP)

 - TTL(Time To Live) : 건너뛸 수 있는 라우터의 개수. hop과 관련됨.

3. 옵션은 대부분 변경(set) 가능하지만 참조(get)만 가능한 것도 있다.


소켓의 옵션 관련 함수

1. 현재 설정 상태 정보를 가져오는 함수.

#include <sys/types.h>

#include <sys/socket.h>

int getsockopt(int sock, int level, int optname, void *optval, socklen_t *optlen);

sock : 소켓을 나타내는 디스크립터

level : 확인할 옵션의 프로토콜 레벨

optname : 확인할 옵션의 이름

optval : 원하는 옵션의 이름의 값을 얻음.

optlen : optval 포인터가 가리키는 버퍼의 크기.


#include<winsock2.h>

int getsockopt(SOCKET s, int level, int optname, char FAR *optval, int FAR *optlen);

s : 소켓 핸들


2. 옵션을 변경하는 함수.

#include<sys/types.h>

#include<sys/socket.h>

int setsockopt(int sock, int level, int optname, const void *optval, socklen_t optlen);

sock : 소켓을 나타내는 디스크립터

level : 변경할 옵션의 프로토콜 레벨

optname : 변경할 옵션의 이름

optval : 원하는 옵션의 값을 저장한 포인터 전달.

optlen : optval 포인터가 가리키는 버퍼의 크기.


#include<winsock2.h>

int setsockopt(SOCKET s, int level, int optname, const char FAR * optval, int optlen);

s : 소켓 핸들


소켓의 옵션

Protocol Level(프로토콜 레벨) : 옵션이름을 그룹짓기 위해 필요.

SOL_SOCKET : 일반적인 소켓의 옵션.

IPPROTO_IP : IP에 대한 소켓의 옵션.

IPPROTO_TCP : TCP에 대한 소켓의 옵션


※ SO_TYPE 옵션 : 소켓의 타입 정보(TCP(1)/UDP(2))값을 얻는다. 다시 재설정할 수 없다.

윈도우즈용 프로그램


9-2 SO_SNDBUF & SO_RCVBUF

getsockopt함수를 통해서 디폴트 버퍼의 크기를 구할 수 있다.

※ 리눅스 시스템 마다 입력 버퍼, 출력 버퍼의 크기는 다르다.

내 컴퓨터 우분투의 경우

입력버퍼 : 87380

출력버퍼 : 16384


setsockopt함수를 통해서 버퍼의 크기를 변경할 수 있다.

※ 바꿀 수 있는 최소 버퍼의 크기는 정해져 있다

※ 버퍼가 없으면 TCP를 할 수 없기 때문에 0으로 설정해도 최소값으로 바뀌어서 버퍼의 크기가 설정된다. 성능과 직결되는 문제이다.


9-3. SO_REUSEADDR

TIME_WAIT 상태?

연결 종료시 마지막 패킷 전송 실패를 대비하기 위한 상태


종료를 먼저 요청한 호스트가 나중에 소켓을 소멸시킨다. 

마지막 ACK 신호가 잘 전달되지 않았을 때를 대비해서 TIMEOUT이 존재한다.

이유 : 상대방(B)의 종료 요청(FIN)을 기다리는 데 걸리는 TIMEOUT이다.


서버의 연결 종료

서버에서 먼저 종료 요청 후, 다시 서버를 가동시키면?

서버가 종료 메세지를 보내고 TIMEOUT 시간동안은 서버 재가동 불가. 

이유 : 그 아이피와 포트가 사용중이기 때문.


TIME_WAIT 타이머의 재시작

TIME-WAIT 상태는 우리의 생각보다 더 길어 질 수 있다.


마지막 ACK신호를 못 보낼 경우 다시 FIN을 보낼 때 다시 TIMEOUT을 설정해야 되기 때문이다.


※ TIMEOUT이 걸린 소켓이라고 그 주소와 포트에 다시 소켓을 만들 수 있게 설정한다.

server_socket = socket(PF_INET, SOCK_STREAM, 0);

option = sizeof(option);

option = TRUE;

setsockopt(server_socket, SOL_SOCKET, SO_REUSEADDR, &option, sizeof(option));


9-4. TCP_NODELAY

네트워크 상의 패킷 수를 줄이기 위해 제안된 알고리즘

ACK을 수신해야만 다음 전송을 진행하는 알고리즘.

※ ACK이 올 때까지 보낼 내용은 버퍼에 쌓아 둔다. (전송버퍼가 필요한 이유)

예시) "agle"은 버퍼에 쌓여 있다가 보내진다.

※ nagle 알고리즘이 off면 데이터가 오면 무조건 상대방에게 보낸다.


Nagle 알고리즘의 장점과 단점

장점 : 네트워크의 효율성이 높아진다. (적은 패킷의 양)

단점 : 전송 속도가 느리다. (ACK 수신 후 패킷 전송)

생각해 볼 문제 : Nagle 알고리즘의 중단이 데이터 전송 속도를 무조건 향상 시켜주는 것은 아니다.

※ 네트워크 트래픽 양이 많다면 (차가 많으므로)속도가 꼭 빨라지지는 않을 수 있음.


TCP_NODELAY

serv_sock = socket(PF_INET, SOCK_STREAM, 0);

opt_val = TRUE;    // option 값이 TRUE면 nagle알고리즘 off 됨.

setsockopt(serv_sock, IPPROTO_TCP, TCP_NODELAY, &opt_val, sizeof(opt_val));

반응형
반응형

8-1. DNS(Domain Name System)

1) 도메인 이름이란? 

1. 영문으로 표현되는 계층적 주소 체계 방식.

※ IP 이후에 도메인이 나왔음. 중복되면 안 된다.

2. 각 나라마다 존재하는 Network Information Center에서 관리. => 약속

한국은 KRNIC(http://www.nic.or.kr, Korea Network Information Center)에서 담당.

3. 도메인 이름 = 호스트 이름 + 도메인 이름

예시) academy.freelec.co.kr = academy + freelec.co.kr

4. TCP/IP는 도메인 이름을 인식 못함.

※ IP와 도메인의 중계 역할을 하는 서버 : 도메인 서버


현실세계의 주소체계


인터넷 상에서의 주소체계

root 도메인

com : 회사, zw : 짐바브웨, kr : 한국

2차 도메인

co : 나라 안의 기업, ac : 나라 안의 대학, or : 행정기관, 


2) DNS 서버

1. 모든 도메인 이름은 해당 IP와 함께 DNS 서버에 등록되어야 한다.

※ DNS 서버는 테이블 형식으로 정보를 가지고 있다.

2. 도메임 이름을 IP 주소로 변환해 주는 작업을 한다.


1. host는 DNS 서버에 도메인 주소 질의

2. DNS 서버는 테이블 정보를 검색하여 IP를 답변해 준다.

※ 분산 데이터베이스 : 모르면 다른 DNS 서버에 물어본다.

3. host는 받은 IP정보를 통해 원하는 도메인 주소로 접속한다.


도메인 이름과 IP변환 과정

※ DNS 서버는 계층적 구조로 되어 있다. 모를 경우 부모에게 물어본다. 

루트 DNS는 모든 주소에 대한 정보를 어느 자식에 있는 지 알고 있다. (자손까지는 아님.)


8-2 IP 주소와 도메인 이름 사이의 변환

변환(도메인 이름 <-> IP주소)의 필요성

상대적으로 잦은 IP 변화에 대한 능동적인 문제 해결

※ IP를 하드코딩하면 IP주소가 바뀌었을 때는 접속할 수 없기 때문에 도메인 이름을 하드 코딩하여 IP가 바뀌더라도 도메인 이름으로 접속할 수 있도록 한다.

※ 한 도메인에 대해 IP를 여러 개 두고 상황에 따라 분산하여 접속할 수 있게 한다.


도메인 이름->IP주소 함수

※ 함수 자체가 DNS 서버에게 물어보고 IP주소등의 정보가 리턴된다.


#include<netdb.h>

struct hostent* gethostbyname(const char* name);

name : 도메인 이름 문자열


#include<winsock2.h>

struct hostent FAR *gethostbyname(const char FAR *name);

name : 도메인 이름 문자열


struct hostent 구조체 


struct hostent

{

char *h_name;

char **h_aliases;

int h_addrtype;

int h_length;

char **h_addr_list;

};


공식 이름(official name)

별명 목록(alias list)

호스트 주소 형태(host address type)

주소의 길이(IPV4 : 4, IPV6 : 16)

주소 목록(list of addresses)

struct hostent

{

char FAR * h_name;

char FAR * FAR * h_aliases;

short h_addrtype;

short h_length;

char FAR * FAR * h_addr_list;

}


※ IP주소를 일반화 하기 위해 char*를 사용하였다. IN_ADDR 구조체 형태로 IP주소가 저장되기 때문에 void* 포인터 형태가 더 좋았을 듯.


struct hostent 구조체 변수


반응형
반응형

※ 연결에 대한 내용이므로 TCP/IP 프로토콜에 대한 이야기.

※ 우아한 종료 = graceful close. (MSDN 참고)


7-1 소켓 연결 종료의 문제점

2개의 가상의 통로가 생성 (각 통로는 단방향)


스트림 : 연결된 상태 (데이터를 주고 받을 수 있도록 연결된 시스템의 내부적인 상황)

입력 스트림 : 데이터 수신을 위한 스트림

출력 스트림 : 데이터 전송을 위한 스트림


※ TCP 소켓은 데이터를 전송하면서 동시에 수신도 가능하다.


소켓 연결 종료의 문제점

1. close, closesocket 함수 호출 : 입력, 출력 스트림 완전 종료

 => 데이터를 송수신 할 수 없다. 데이터가 소멸된다.

2. 일방적인 방식의 완전 종료는 경우에 따라서 문제가 될 수 있다.



7-2 우아한 소켓의 연결 종료

half close : 입력 혹은 출력 스트림 중 하나의 스트림만 종료하는 행위

예시) A 호스트의 출력 스트림만 종료

#include <sys/socket.h>

int shutdown(int s, int how);

리턴 : 성공시 0, 실패시 -1

s : 종료하고자 하는 소켓의 파일 디스크립터

how : 종료 모드를 인자로 전달한다.

 상수값 모드 정의 
 0  SHUT_RD  입력 스트림 종료 
 1  SHUT_WR 출력 스트림 종료 
 2  SHUT_RDWR  입출력 스트림 종료


출력 스트림의 종료의 필요성

※ Thank you를 받기 위해 shutdown함수를 사용한다.


1. 출력 스트림을 종료하게 되면, 연결되어 있던 호스트로 EOF 메세지 전달.

※ 출력 스트림을 종료하는 방법 : close(closesocket), shutdown 함수.

2. EOF 전송으로 데이터 전송의 끝을 알려 줄 수 있다.

3. EOF 전송 시, 상대 호스트의 데이터 수신 함수(read, recv)는 0을 리턴.


※ EOF를 생략하고 보내면 서버도 read() 무한 대기, 클라이언트도 파일의 끝을 모르므로 read() 무한대기.


※ EOF의 값 : -1 (int type) -> 1바이트를 4바이트로 casting하면 -1이 아닐 수 있다.


7-3. 윈도우즈 기반으로 구현하기

※ 윈도우즈 기반에서는 파일 열기를 위해 표준 라이브러리를 사용하였다.


#include <winsock2.h>

int shutdown(SOCKET s, int how);

리턴 : 성공시 0, 실패시 SOCKET_ERROR

s : 종료하고자 하는 소켓의 파일 디스크립터

how : 종료 모드를 인자로 전달한다.

 상수값 모드 정의 
 0  SD_RECEIVE 입력 스트림 종료 
 1  SD_SEND 출력 스트림 종료 
 2  SD_BOTH 입출력 스트림 종료

반응형
반응형

※ TCP와 UDP를 특징을 비교하면서 공부하는 것이 좋다.

6-1. UDP의 이해

1. IP를 기반으로 데이터를 전송한다. (TCP와 공통점)

2. 흐름제어(flow control)을 하지 않기 때문에 데이터 전송을 보장 받지 못한다. (TCP와 차이점)

3. 연결설정 및 연결 종료 과정도 존재하지 않는다. (TCP와 차이점)

4. 연결 상태가 존재하지 않는다. (TCP와 차이점)


UDP의 역할

포트 정보에 의한 프로세스의 구분

UDP 패킷 = 데이터 그램(Datagram)


6-2. UDP 기반 서버/클라이언트의 구현

※ 소켓을 생성하고 bind까지만 하면 바로 입출력함수를 사용하면 된다. 

(socket~bind : 서버, socket : 클라이언트)

일반적으로 연결 설정 과정을 거치지 않는다.

데이터를 주고 받기 위한 소켓(우체통에 비유)은 하나만 생성해도 된다. (패킷은 우편)


※ UDP는 비연결 프로토콜이기 때문에 주소 정보를 알아야 한다.

데이터 전송함수

int sendto(int sock, const void* msg, int len, unsigned flags, const struct sockaddr *addr, int addrlen);

int sendto(SOCKET s, const char FAR *buf, int len, int flags, const struct sockaddr FAR *to, int tolen);

1~4번째 인자는 TCP send함수와 비슷.

addr : 목적지 주소 정보

addrlen : 목적지 주소 정보 구조체 크기


데이터 수신함수

int recvfrom(int sock, void *buf, int len, unsigned flags, struct sockaddr *addr, int *addrlen);

int recvfrom(SOCKET s, char FAR *buf, int len, int flags, struct sockaddr FAR *from, int FAR *fromlen);

1~4번째 인자는 TCP recv함수와 비슷.

addr : 목적지 주소 정보

addrlen : 목적지 주소 정보 구조체 크기


※ uecho_client_win.c : 윈도우 UDP 에코 클라이언트는 리눅스 용과 다르게 제작되어 있음.

※ UDP에서 클라이언트의 주소는 언제 할당될까? 6-4에 있음.


6-3. 데이터의 경계(boundary)가 존재하는 UDP 소켓

UDP 소켓은 데이터를 송수신하는데 필요한 함수 호출의 수를 정확히 일치 시켜야 한다.


6-4. connect 함수 호출을 통한 성능의 향상

1. TCP 소켓에서의 connect 함수의 의미

 - IP와 포트의 할당

 - Three-way handshaking

2. UDP 소켓에서의 connect 함수의 의미

 - IP와 포트의 할당

※ connect함수가 없으면 sendto 함수가 제일 처음 호출되는 시점에 IP와 Port가 할당된다. 

클라이언트에서 IP와 Port가 한 번 할당되면 close()함수 호출시까지 변하지 않는다.


TCP/UDP 소켓에서 공통적으로 가지는 connect의 의미


connect함수의 의미 

커널과 소켓이 논리적으로 연결하고 그것을 유지한다.


connect함수를 호출하지 않을 때 UDP 클라이언트의 데이터 송수신

1. sendto, recvfrom 함수를 호출하면 커널과 소켓이 연결되고 소켓과 호스트와 통신할 준비를 한다.

2. 호출이 끝나면 커널과 소켓의 연결이 해제된다.

※ 위의 1~2를 반복하는 데 많은 시간을 잡아먹고 커널과 소켓을 연결했다 해제했다 하기 때문에 성능 효율이 떨어진다.


connect 함수 호출이 주는 이점

1. 데이터를 주고 받는 속도가 빨라진다.

2. TCP 소켓 기반의 데이터 입출력 함수를 그대로 사용할 수 있다.


반응형
반응형

5-1. 에코 클라이언트! TCP 기반에서의 완벽 구현

TCP 기반의 데이터 전송 특징

한 번의 데이터 전송함수 호출이 늘 하나의 패킷을 형성하는 것은 아니다.

※ 버퍼 : 서버 프로그램 상의 문자 배열.

※ ABCD를 클라이언트에서 서버로 보냈지만 A,B,C,D와 같이 패킷이 나누어져서 서버가 클라이언트로 보낼 수 있다. 

※ echo server에서는 클라이언트에서 문제가 된다.

※ 구현 : 클라이언트는 여러번 read(recv)함수를 호출해야 한다. -> for문에서 읽어들인 바이트수가 보낸 바이스만큼 읽을 때까지 반복한다.


패킷이 나누어져 보내지는 이유? 뒤에 밝힘


5-2. 경계가 없는 TCP 기반의 데이터 전송

데이터 송수신 함수의 호출 회수는 큰 의미를 지니지 않는다.

예시) 위의 내용을 증명하기 위한 예제 프로그램

※ 서버에서는 한 번에 메세지를 읽기 위해서 sleep(5);를 한다.

※ 클라이언트에서는 4번에 나누어서 메세지 수신을 하기 위해 sleep(10);을 추가하였다.


이미 전송된 데이터는 어디에 존재하고 있었는가?

예시) 서버가 전송하면 클라이언트 (소켓)버퍼에 서버에서 받은 내용을 저장하고 있다.


서버에서 accept 했을 때 생기는 소켓에서 입력버퍼와 출력버퍼가 생성된다.

클라이언트는 소켓에 입출력버퍼가 있다.

※ read함수는 입력버퍼에 있는 내용을 읽는다. 바로 읽지 못할 수 있기 때문에 버퍼가 존재한다.


출력버퍼의 역할?

※ (서버에서) 송신할 때 (클라이언트의) 입력버퍼보다 큰 양을 보낼 수 없다.

=> 가능한 이유 : 클라이언트가 입력버퍼의 양만큼만 보내라는 신호를 보내기 때문에...

※ 이 이유때문에 출력(송신)버퍼가 필요하다.


※ 흐름제어를 위해서 입출력 버퍼가 존재한다.


TCP 기반의 전송제어

1. 버퍼가 수용할 수 있는 크기 이상의 데이터 전송은 이루어지지 않는다.

2. 따라서 TCP 기반의 데이터 전송 함수는 여러 개의 패킷을 생성하기도 한다.

3. 슬라이딩 윈도우 프로토콜 : 남은 버퍼양만큼만 데이터 전송을 하도록 돕는다.


5-3 TCP의 내부 구조

TCP의 데이터 전송 과정

(서버와 클라이언트는 서로 의견을 존중한다.)


1. 연결 설정 단계

클라이언트가 connect 함수 호출 시 진행

Three-way handshaking

① SYN(C) : 동기화. 싱크. 데이터를 주고 받을 수 있습니까?

② SYN + ACK(S) : 대화가 가능(OK)합니다. SYNchronize(동기화)에 대한 ACKnowgement(확인)의 기능.

③ ACK(C) : 잘 받았다라는 뜻으로 확인한다.

※ ③을 보내야 하는 이유 : 클라이언트가 ACK을 보내지 않으면 서버 입장에서 확인 메세지를 못받았다고 생각한다.


SEQ : 시퀀스 번호. 메세지를 잘 받았는 지 확인하기 위해서 붙이는 번호.

(메세지 번호) 내가 보낸 번호를 ACK으로 대답해 달라.

ACK : 확인 번호. 잘 받았다는 것을 응답하기 위해 붙이는 번호.

다음 SEQ 번호를 보내라는 뜻.


2. 데이터 송수신 단계

서버/클라이언트 간 데이터 송수신 함수 호출 과정에서 진행.

1. SEQ:1301,100바이트를 보낸다.

2. 100바이트를 잘 받았다는 것을 알리기 위해 SEQ번호+100을 해서 ACK을 보낸다.

※ SEQ번호는 응답받은 ACK번호와 같아야 한다.

3. SEQ:1401, 100바이트를 보냈는데 손실되었다. 보내는 동시에 타이머를 작동.

손실되었으므로 응답이 없다. 상대방이 못받았다고 생각하므로

4. SEQ:1401, 100바이트를 다시 보낸다.

5. ACK을 보낸다.


3. 연결 종료 단계

클라이언트 혹은 서버가 close(closesocket) 함수 호출 시 진행

four-way handshaking

1. FIN(A->B) : 종료 요청의 메세지를 담은 패킷을 보낸다. FINish

2. ACK(B->A) : 단순히 패킷을 잘 받았다는 신호만 보낸다. 아직 종료할 상황이라는 뜻은 아니다.

3. FIN(B->A) : 종료해도 좋다는 의미의 FINish를 보낸다.

4. ACK(A->B) : 최종적인 수신 응답 메세지 ACK을 전송한다.

반응형
반응형

4-1 TCP/UDP에 대한 이해

TCP/IP 프로토콜 스택

응용프로그램 계층      : 응용프로그램     응용프로그램

전송(Transport)  계층 :       TCP                UDP

네트워크 (Network)계층:                  IP

데이터링크 계층 :                          LINK

물리 계층 


데이터링크 계층

LAN, WAN, MAN과 같은 네트워크 표준과 관련된 프로토콜의 정의한다.


IP 계층

어떻게 길을 찾아 갈 것인가?

라우터끼리는 (도로사정)이 어떤지 서로 통신하여 패킷을 보낼 최적의 길을 찾는다.

신뢰할 수 없는 프로토콜(데이터가 손실 될 수 있고) 경로가 일정치 않으며 패킷을 보낸 순서도 보장하지 못한다. 비연결 지향 프로토콜이다.


TCP/UDP

(IP 계층을 기반으로 하여 ) 데이터를 어떻게 전송할 것인가?

TCP/UDP(2가지 프로토콜) 중에 원하는 프로토콜을 선택한다.


TCP와 IP의 관계

IP를 기반으로 길을 찾고 TCP를 통해 연결 지향 및 신뢰성 있는 통신을 제공한다.

호스트대 호스트가 어떻게 데이터를 주고 받을 것인지 약속.

IP(편지에 비유) -> 패킷(편지)을 잃어버림


TCP의 역할 

A 호스트가 B 호스트에게 패킷을 하나 전송한다.

1. B가 잘 받았을 경우 : 응답용 패킷을 A에게 전송한다.

2. B가 잘 못 받았을 경우 : A는 B가 데이터를 수신하지 못했다고 간주하고 임의의 시간 후 재전송.


4-2. TCP 기반 서버의 구현

socket - 소켓 생성

bind - 주소할당

listen - 연결 요청 대기 상태 (친구에게 전화가 올 수 있는 상태)

accept - 연결 허용(수화기를 든다.)

read & write - 데이터 송수신

close - 연결 종료


'연결 요청 대기 상태'로의 진입

1. listen 함수는 전달되는 인자의 소켓을 '서버 소켓'이 되게 한다.

2. listen 함수는 backlog의 수(대기실의 크기)만큼 '연결 요청 대기 큐'를 생성 한다.

3. 성공하면 '연결 요청 대기 상태'가 된다.

#include<sys/type.h>

int listen(int s, int backlog);

#include<winsock2.h>

int listen(SOCKET s, int backlog);

 - socket : 서버소켓으로 만들 핸들 혹은 파일 디스크립터.

 - backlog : 대기 큐(실)의 크기, 클라이언트 수.


서버의 역할과 연결요청 대기상태

서버 소켓은 일종의 '문지기'이다. (클라이언트의 연결 요청을 감지하고 받는다.)

클라이언트 - 서버로의 연결요청 - 서버 소켓 -> 대기실

※ 여러 사람의 요청을 받게 하기 위해 '큐(대기실)'를 만든다.


연결요청 수락하기

연결요청 대기 큐에 존재하는 클라이언트의 연결 요청 수락.


#include<sys/type.h>

#include<sys/socket.h>

int accept(int s, struct sockaddr *addr, int *addrlen);

#include<winsock2.h>

SOCKET accept(SOCKET s, struct sockaddr FAR *addr, int FAR *addrlen);

리턴값 : 클라이언트와 연결하기 위한 (밑의 그림의)New 소켓

addr : 클라이언트의 주소 정보가 채워진다.

addrlen : 정보구조체의 크기 정보가 채워진다.

 

listen ~ accept 과정

※ New 소켓은 자동적으로 만들어지며 이 New 소켓으로 클라이언트와 데이터 송수신이 가능하다.


4-3 TCP 기반 클라이언트의 구현


#include<sys/types.h>

#include<sys/socket.h>

int connect(int sockfd, struct sockaddr *serv_addr, int addrlen);

sockfd : 연결 요청을 할 소켓

serv_addr : 주소정보

addrlen : 구조체 크기


※ 소켓은 연결하기 전에 미리 운영체제(커널)에 의해 임의로 할당된다.


4-4 서버/클라이언트 함수 호출 관계




※ listen()에서 대기 큐를 만들고 연결 요청을 받을 수 있는 서버 소켓을 만든다.

※ connect 함수는 서버의 IP, 포트를 설정해 주면 자동으로 클라이언트 컴퓨터 내의 임의의 포트에 운영체제가 통신하기 위한 소켓을 생성한다.

※ accept() 함수는 연결 요청 대기 큐가 비어있을 때까지 혹은 클라이언트가 연결할 때까지 block되어 있다.

=> 리턴 되면서 연결된다.


4-5 Iterative 서버의 구현

Iterative 서버 : 반복해서 클라이언트의 요청을 처리한다.

여러 클라이언트가 서버로 연결 요청을 한다. -> 연결 요청 대기 큐로 들어간다. -> 서버가 accept를 호출할 때마다 대기 큐에서 하나의 소켓을 꺼낸다. -> 그 소켓과 통신을 하고 끝나면 반복해서(Iterative) accept를 호출한다. 


Iterative Server Template

※ 리턴 클라이언트 소켓은 하나다.


4-6 에코(echo) 서버/클라이언트의 구현

concurrent 서버(서버 하나와 클라이언트 여러 개 관계)

※ 버퍼 : 여기서는 프로그램 상의 버퍼. 문자열을 저장할 수 있는 배열.

※ 클라이언트에서 close(소켓핸들);을 호출하면 서버의 read함수의 리턴 값은 0이 된다.

반응형
반응형

3-1 Internet Address

IP(Internet Address) 

인터넷에 존재하는 호스트들을 구분하기 위한 32비트 주소 체계

점이 찍힌 십진수 표현 방식(Dotted-decimal Notation) : 211.217.10.9


클래스

Class A : 0.0.0.0 ~ 127.255.255.255, 1바이트는 네트워크 ID, 3바이트는 호스트 ID

Class B : 128.0.0.0 ~ 191.255.255.255, 2바이트는 네트워크 ID, 2바이트는 호스트 ID

Class C : 192.0.0.0 ~ 223.255.255.255, 3바이트는 네트워크 ID, 1바이트는 호스트 ID

Class D : 멀티캐스트 주소. 224.0.0.0 ~ 239.255.255.255

Class E : 예약됨. 240.0.0.0 ~ 255.255.255.255


IP 주소 : 네트워크 주소(네트워크를 구분지음) + 호스트 주소(호스트를 구분지음)

subnet mask에 따라서 네트워크 주소(ID)와 호스트 주소(ID)로 나누어진다.


※ 라우터는 네트워크 ID(주소)만 참조한다.

※ 루프백 주소 : 127.x.x.x, 네트워크 상으로 패킷을 전송하지 않고 자기자신에게 돌려준다.


3-2 Port란 무엇인가?

Port

호스트 내에서 실행되고 있는 프로세스를 구분 짓기 위한 16비트의 논리적인 값. (소켓에 할당)

논리적인 값 : 소프트웨어 적으로 구현. 

Well-known ports : 0~1023


3-3 주소 정보의 표현

IPV4의 주소 체계를 나타내는 구조체

※ 하나의 프로토콜 내에 2개 이상 프로토콜이 있을 것을 대비해서 만든 구조체. ()는 윈도우즈용.

※ 모든 데이터는 네트워크 바이트 순서로 저장해야 한다.

struct sockaddr_in

{

sa_family_t(short) sin_family; // 주소 체계(address_family) - AF_INET

uint16_t(unsigned short) sin_port; // 16비트 TCP 혹은 UDP 포트

struct in_addr sin_addr; // 32비트 IPv4 주소

char  sin_zero[8]; // 사용되지 않음. padding 용도

};


struct in_addr

{

uint32_t(unsigned long) s_addr; // 32비트 IPv4 인터넷 주소

};



3-4 네트워크 바이트 순서

0x12345678을 Big-Endian으로는 메모리 순서대로 0번지 0x12, 1번지 0x34, 2번지 0x56, 3번지 0x78

0x12345678을 Little-Endian으로는 메모리 순서대로 0번지 0x78, 1번지 0x56, 2번지 0x34, 3번지 0x12

형태로 저장.


호스트 바이트 순서 

어떤 시스템(motorola, sun사 기계)은 Big-Endian, 어떤 시스템(intel사)은 Little-Endian을 사용하므로 일정하지 않다.

※ 데이터 표현 방식이 다르므로 다른 플랫폼끼리 문제가 발생한다.


네트워크 바이트 순서

Big-Endian 방식을 적용하기로 하였다.


바이트 순서 변환 함수

※ 어느 시스템에서 돌아갈 지 모르기 때문에 반드시 사용해야 한다.

unsigned short htons(unsigned short); // 호스트 -> 네트워크 short

unsigned short ntohs(unsigned short); // 네트워크 -> 호스트 short

unsigned long htonl(unsigned long); // 호스트 -> 네트워크 long

unsigned long ntohl(unsigned long); // 네트워크 -> 호스트 long


'h' : host byte order

'n' : network byte order

's' : short(16비트)

'l' : long(32비트)


3-5 인터넷 주소 조작하기

1. 점이 찍힌 십진수 방식(Dotted-Decimal Notation)을 Big-Endian 32비트 정수형 데이터로 바꿔주는 함수(네트워크)

#include<sys/socket.h>

#include<netinet/in.h>

#include<arpa/inet.h>

unsigned long inet_addr(const char *string);

리턴 : 성공시 32비트 big-endian값, 실패시 INADDR_NONE


#include<sys/socket.h>

#include<netinet/in.h>

#include<arpa/inet.h>

int inet_aton(const char* string, struct in_addr *addr);
리턴 : 성공시 true, 실패시 false
※ 바로 struct in_addr에 값을 제공할 수 있다.


2. Big-Endian 32비트 정수형 데이터를 점이 찍힌 십진수 방식로 바꿔주는 함수(네트워크)

#include<sys/socket.h>

#include<netinet/in.h>

#include<arpa/inet.h>

char* inet_ntoa(struct in_addr addr);

※ 따로 inet_ntoa 내부적으로 static 배열을 통해 주소에 대한 문자열이 존재한다.


3-6 인터넷 주소 초기화

struct sockaddr_in addr;

char *serv_ip = "...";

char *serv_port = "...";

memset(&addr, 0, sizeof(addr_len)); // 주소에 해당하는 구조체 값을 0으로 초기화. 좋은 습관.

addr.sin_family = AF_INET;

addr.sin_addr.s_addr = inet_addr(serv_ip);

addr.sin_port = htons(atoi(serv_port));


INADDR_ANY?

addr.sin_addr.s_addr = htons(INADDR_ANY);

// 서버의 IP가 뭐더라? 답은 INADDR_ANY : 내 시스템의 IP 주소를 찾아 알아서 할당.


3-7 주소 정보 할당하기

리눅스

#include<sys/types.h>

#include<sys/socket.h>

int bind(int sockfd, struct sockaddr *myaddr, int addrlen);

윈도우즈

#include<winsock2.h>

int bind(SOCKET s, const struct sockaddr FAR *name, int namelen);


sockfd/s : 주소를 할당하고자 하는 소켓의 파일 디스크립터 인자/핸들 인자

myaddr : 할당하기를 원하는 주소 정보를 지닌 sockaddr_in 구조체 변수의 포인터. 

※ sockaddr 형태로 형변환 해서 넘겨야 한다. 

※ 프로토콜에 독립적으로 사용하기 위해서. void 포인터 보다 먼저 이 함수가 개발되었다. 

※ FAR 포인터는 과거의 잔재. 현재는 무시한다.

addrlen/namelen : 주소 정보 구조체의 길이.


3-8 윈도우즈 기반으로 구현하기

※ SOCKADDR_IN = struct sockaddr_in

※ SOCKADDR = struct sockaddr

※ 서버 프로그래밍을 할 때는 htons(INADDR_ANY);를 사용한다.


3-9 WSAStringToAddress & WSAAddressToString

주소 정보를 나타내는 문자열을 가지고 주소 정보 구조체 변수를 채운다.

※ 윈도우즈 기반 주소 변환 함수.

#include<winsock2.h>

INT WSAStringToAddress(

LPTSTR AddressString, // 점이 찍힌 십진수 표현(포트정보 포함)과 같은 주소 정보 문자열 포인터.

INT AddressFamily,      // 주소 정보 문자열이 속한 주소 체계(AF_INET)

LPWSAPROTOCOL_INFO lpProtocolInfo,   // 프로토콜 제공자를 설정. 일반적으로 NULL.

LPSOCKADDR lpAddress,  // 주소 정보 구조체 변수 포인터

LPINT lpAddressLength);  // lpAddress 포인터가 가리키는 버퍼의 크기.

리턴 : 성공시 0, 실패시 SOCKET_ERROR


#include<winsock2.h>

INT WSAAddressToString(

LPSOCKADDR lpsaAddress, // 문자열로 변환할 주소 정보를 가진 구조체 포인터

dwAddressLength,              // lpsaAddress 포인터가 가리키는 변수의 크기

LPWSAPROTOCOL_INFO lpProtocolInfo,   // 프로토콜 제공자를 설정. 일반적으로 NULL.

LPTSTR lpszAddressString,  // 문자열로 변경된 결과를 저장할 버퍼 포인터.

LPDWORD lpdwAddressStringLength);     // lpszAddressString 버퍼의 크기.

리턴 : 성공시 0, 실패시 SOCKET_ERROR




반응형
반응형

1-1. 네트워크 프로그래밍의 이해

네트워크 : 호스트(End-system)들을 연결하는 시스템

호스트 : PC, workstation, PDA

인터넷 : 멀리 떨어진 둘 이상의 네트워크가 연결되 이뤄진 거대한 네트워크 -> 라우터 : 이기종 네트워크를 연결하는 장비


1.2 소켓 이해하기

클라이언트/서버 모델

기계 아님. 

서버 : 연결 요청을 기다린다.

 - Iterative(반복적인) Server : 한 순간에 하나의 클라이언트에게 응담

 - Concurrent Server : 동시에 여러 클라이언트에게 응답한다.

클라이언트 : 서버에 요청하고 응답을 기다리는 호스트.


네트워크 프로그래밍

네트워크로 연결된 두 호스트 간의 데이터 송수신

소켓 : 원격에 존재하는 두 호스트를 연결시켜 주는 매개체. 운영체제에서 제공한다.

(소켓을 꽂으면 전원을 받는다.)


서버 소켓(리눅스 함수)

소켓 생성(전화기 구입) - socket


#include<sys/types.h>

#include<sys/socket.h>

int socket(int domain, int type, int protocol);

리턴 : 성공시 파일 디스크립터 실패시 -1


IP 주소, 포트 할당(전화번호 할당) - bind

#include<sys/socket.h>

int bind(int sockfd, struct sockaddr *myaddr, int addrlen);

리턴 : 성공시 0, 실패시 -1


연결 요청 대기 상태(케이블에 연결) - listen

#include<sys/socket.h>

int listen(int sockfd, int backlog);

리턴 : 성공시 0, 실패시 -1


연결 수락(수화기를 든다) - accept

#include<sys/socket.h>

int accept(int sockfd, struct sockaddr *addr, int *addrlen);

리턴 : 성공시 파일 디스크립터 실패시 -1


클라이언트 소켓

소켓 생성(전화기 구입) - socket


연결 요청(전화 걸기) - connect

int connect(int sockfd, struct sockaddr *addr, int addrlen);

리턴 : 성공시 0, 실패시 -1


리눅스 프로그램 컴파일(링크) 하는 법 : gcc ?.c -o ?

실행하는 법 : ./?

※ 로컬 컴퓨터의 IP : 127.0.0.1

※ 당연히 서버부터 수행한다.


1.3 파일 조작하기

리눅스에서는 모든 것(소켓등)을 파일로 간주한다.

파일의 생성, 삭제, 데이터 입력 및 출력.

관리는 운영체제가 한다.


저수준 파일 입출력 

저수준은 시스템이 직접 제공해 준다는 뜻.

표준입력 : 기본은 키보드, fd 0번

표준출력 : 기본은 모니터, fd 1번

표준에러출력 : 기본은 모니터, 버퍼가 없음. fd 2번

※ 전송은 출력, 수신은 입력은 같은 의미.

※ 표준입출력함수 : ANSI 표준에서 제공해 주는 함수. printf, scanf등...


파일 디스크립터(file descriptor) 

시스템이 만든 것을 가리키기 좋게 하기(포인터) 위해 시스템이 사용자에게 건내주는 숫자값

윈도우의 핸들과 비슷.

모든 파일을 관리하기 위해 운영체제에서 파일 디스크립터를 할당한다.

※ 파일 디스크립터는 redirection가능.


파일 열기

#include <fcntl.h>

#include<sys/types.h>

#include<sys/stat.h>


int open(const char *path, int flag);

리턴 : 성공시 파일 디스크립터, 실패시 -1

path : 파일에 대한 경로

flag : 모드 설정. |(bit wise)를 통해 연산가능.

O_CREAT : 파일이 없을 때 파일 생성. 

O_TRUNC : 파일이 있다면 새로 생성.

O_RDONLY : 읽기 전용 모드


파일 닫기

#include<unistd.h>

int close(int filedes);

리턴 : 성공시 0, 실패시 -1

filedes : 닫아줄 파일의 파일 디스크립터


데이터 쓰기

#include<unistd.h>

ssize_t write(int filedes, const void * buf, size_t nbytes);

filedes : 데이터 전송 영역의 파일 디스크립터.

buf : 전송할 데이터를 가지고 있는 버퍼(데이터)의 포인터. 

nbytes : 전송할 데이터의 바이트수.

ssize_t = signed int, size_t = unsigned int

※ 타입이름을 새로 정의하는 이유는 다른 시스템에서 실행시키기 위해(코드 확장성)

=> 소스 코드를 바꾸지 않고 컴파일만 다시 하면 새로운 시스템에서 잘 돌아간다.


데이터 읽기

#include<unistd.h>

ssize_t read(int filedes, void *buf, size_t nbytes);

filedes : 데이터를 수신 받을 대상을 가리키는 파일 디스크립터.

buf : 수신한 데이터를 저장할 버퍼의 포인터

nbytes : 수신할 최대 바이트수.


1-4 윈도우즈 기반으로 구현하기

WinSock을 위한 헤더 및 라이브러리 설정.

1. #include<winsock2.h>

2. #pragma comment(lib,ws2_32.lib)

ws2_32.lib을 위와 같이 라이브러리를 링크

3. winsock 라이브러리 초기화(standby) 및 해제(리소스 반환)


Winsock 초기화하기

#include<winsock2.h>

int WSAStartup(WORD wVersionRequested, LPWSADATA lpWSAData);

wVersionRequested : 프로그램에서 요구하는 winsock 최상위 버젼을 알려준다.

WORD : 16비트 unsigned int

예) Version 3.4 = MAKEWORD(3 /*주버젼*/,4 /*부버젼*/) =  0x0403 

lpWSAData : WSADATA 타입의 변수 포인터.


Winsock 해제하기

#include<winsock2.h>

int WSACleanup();


기본적인 Template

int main(int argc, char **argv)

{

    WSADATA wsaData;

    if(WSAStartup(MAKEWORD(2,2), &wsaData) !=0)

         error_handling("WSAStartup() error!");

    ...

    WSACleanup();

    return 0;

}


소켓의 생성

#include<winsock2.h>

SOCKET socket(int af, int type, int protocol);

리턴 : (정수형 데이터 타입)성공시 소켓 핸들, 실패시 INVALID_SOCKET 리턴.


주소와 포트 할당

#include<winsock2.h>

int bind(SOCKET s, const struct sockaddr FAR *name, int namelen);

리턴 : 성공시 0, 실패시 SOCKET_ERROR


연결요청대기상태로의 진입

#include<winsock2.h>

int listen(SOCKET s, int backlog);

리턴 : 성공시 0, 실패시 SOCKET_ERROR


연결 수락

#include<winsock2.h>

SOCKET accept(SOCKET s, struct sockaddr FAR *addr, int FAR *addrlen);

리턴 : (정수형 데이터 타입)성공시 소켓 핸들, 실패시 INVALID_SOCKET 리턴.


연결 요청

#include<winsock2.h>

int connect(SOCKET s, const struct sockaddr FAR *name, int namelen);

리턴 : (정수형 데이터 타입)성공시 소켓 핸들, 실패시 INVALID_SOCKET 리턴.


데이터 출력

#include<winsock2.h>

int send(SOCKET s, const char FAR *buf, int len, int flags);

리턴 : 성공시 전송한 바이트 수, 실패시 SOCKET_ERROR

s : 전송할 호스트에 연결된 소켓의 핸들

buf : 전송할 데이터를 저장하고 있는 버퍼의 포인터

len : 전송할 바이트 수를 인자로 전달

flags : 여러가지 옵션을 설정.


데이터 입력

#include<winsock2.h>

int recv(SOCKET s, char FAR *buf, int len, int flags);

리턴 : 성공시 수신한 바이트 수, 실패시 SOCKET_ERROR

s : 수신할 영역을 나타내는 소켓의 핸들.

buf : 수신할 데이터를 저장한 버퍼의 포인터

len : 수신할 최대 바이트수

flags : 여러가지 옵션을 설정.


※ 리눅스에도 send, recv함수가 있지만 리눅스에서는 소켓도 파일로 처리한다는 것을 강조하기 위해 read, write함수를 사용하였고 윈도우즈에서는 read, write함수가 없기 때문에 send, recv함수로 소켓 입출력을 수행한다.

반응형

+ Recent posts