본문 바로가기
학부공부/OS_운영체제

17. IPC(2)

by sonpang 2021. 11. 24.
반응형

안녕하세요. 오늘은 저번 시간에 이어 IPC에 대해 알아보는 마지막 시간입니다. 저번 포스팅에서는 IPC Model과 각 model별 구체적 메커니즘 : 테크닉에 대해 알아보았었습니다. 특히 Signal에 대해 많은 부분 할애하여 설명드렸었죠. 컴퓨터 네트워크 과목, 컴퓨터 구조 과목과도 매우 밀접한 관련이 있는 topic이였습니다.

2021.11.21 - [학부공부/OS_운영체제] - 15. IPC(1)

 

15. IPC(1)

안녕하세요. 오늘은 IPC에 대해 알아보겠습니다. 저번 포스팅을 끝으로 프로세스에 대해 알아보았었는데요. 프로그램과 프로세스, 문맥전환, 프로세스 상태, linux에서의 프로세스 생성에 대해 설

ku320121.tistory.com

이번 포스팅에서는 각 메커니즘 별 구체적인 예제들을 알아보는 시간을 가질 것입니다. 각 메커니즘에 대한 개념과 장단점에 대한 내용은 이전 포스팅에서 다루었던 내용이니 기억나지 않으신 분들은 위 링크를 참고해주시면 감사하겠습니다.

 

 


16.01. Pipe

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#define MAXLINE 4096

int main(void)
{
    int n, fd[2];
    pid_t pid;
    char line[MAXLINE];
    if (pipe(fd) < 0) {
        perror("pipe error");
        exit(-1);
    }
    if ( (pid = fork()) < 0) {
        perror("fork error");
        exit(-1);
    } else if (pid > 0) {
        /* parent */
        close(fd[0]);
        write(fd[1], "hello, world.\n", 14);
    } else {
        /* child */
        close(fd[1]);
        n = read(fd[0], line, MAXLINE);
        write(STDOUT_FILENO, line, n);
        waitpid(pid, NULL, NULL);
    }
    exit(0);
}

여기서 pipe는 시스템 콜이고 fork()에서 parent code와 child code를 생성합니다. pid변수는 fork의 output이죠.(fork()와 관련해서 자세한 내용은 process와 system call관련 포스팅을 참고해주시면 됩니다.) exec을 안하고 fork만 했으니 생성된 2개의 프로세스는 pid만 다르고 정확하게 identical한 프로세스입니다. fd[1] > fd[0]로 write합니다.(이 부분은 pipe call의 convention입니다.)

pipe call에 대한 이해는 뭔가 handle을 받아온 것이다 정도만 이해하고 넘어가시면 되고 file system을 배우면 조금 더 정확한 이해가 될 것입니다. 사실 내부에서 socket처럼 연결되도록 하는 무언가가 file system에 있는 것은 아니고 convention이 그냥 여기서 이렇게 연결하면 pipe가 됩니다. 이 예제에서는 지금 half duplex(단방향 통신)으로 fd[1]을 보내는 end, fd[0]를 받는 end로 작동시키고 있습니다. 반대로 해도 동작은 제대로 하고 full duplex는 닫지 않고 child fd[1]에서 parent fd[0]로 보내면 됩니다. 

 

16.02. Signal

#include <stdio.h>
#include <stdbool.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
void SignalHandlerChild(int signo)
{
	printf("signal handler\n");
	fflush(stdout);
}

int main(void)
{
	pid_t pid;
	struct sigaction act_child;
	act_child.sa_handler = SignalHandlerChild;
	act_child.sa_flags = 0;
	sigemptyset( &act_child.sa_mask );
	sigaction( SIGUSR1, &act_child, 0 );
	switch ( pid = fork() )
	{
		case -1:
			perror("fork failed ");
			exit(-1);
		case 0:
			sigpause(SIGUSR1);
			return 0;
		default:
			sleep(3);
			kill(pid, SIGUSR1);
			waitpid(pid, 0, 0);
	}
	return 0;
}

Signal handler는 별도의 함수로 define합니다. struct sigaction은 구조체로 signal에 의해 정의됩니다. 여러 field를 넣게 되어 있고 그 중 하나가 handler입니다. SIGUSR1은 user define signal입니다. SIGUSR1이 도착하면 act signal > struct sigaction > handler : SignalHandlerChild 호출로 이어집니다. 

 

부모 프로세스와 자식 프로세스를 나누어 설명하자면, 자식 프로세스(pid = 0)는 그냥 기다립니다. 기다리다가 SIGUSR1이 도착하면 핸들러 호출 후 종료합니다. Default(양수)는 parent code로 3s 후 kill을 호출합니다. pid에 signal(SIGUSR1)을 보내는 함수라고 생각하시면 됩니다. 즉, parent가 child에게 SIGUSR1이라는 signal을 보내는 code로 SIGUSR1이 불리기 전에 handler를 install해야 하는거죠.(Install되기 전에 signal을 보내면 default action은 terminate되는 것입니다.) 실제 현업에서 linux coding을 하실 때 signal handler set을 해주어야 합니다.

 

Signal handler가 setting되어 있고 kill은 signal을 보내는 function이다.

 

위에 적은 한 문장만 기억하셔도 충분합니다. kill이 뭐냐고요?

kill의 argument인 pid로 SIGUSR1을 보내는 겁니다. 이 예제에서는 pid는 fork()로 만들어진 child입니다. Signal의 종류에 대해서는 저번 포스팅에서도 한번 언급한 바 있지만 signal.h를 보시면 됩니다.

 

중요해서 한가지 더 짚고넘어가자면 SIGUSR1이 도착하기 전에 handler를 install 후 fork합니다. 즉, parent에서 handler가 설치되었고 fork하면 만들어진 child에도 동일한 handler가 설치되어 있겠죠. Signal과 pipe 예제는 조금 이해하기 어려우실 수 있는데요. 이제부터 살펴볼 예제는 대부분 parent가 child를 만들고 communication하는 것입니다.

 

저번 포스팅에서 Signal이 어떻게 전달되는가라는 질문을 드렸는데요. 커널에서 실제로 어떻게 동작하는 지를 생각해보는 것은 사고를 유연하게 해줍니다. 실제로 어떻게 signal을 전달할까?

kill을 통해 시스템 콜을 하면 pid의 PCB에 SIGUSR1이라는 marking을 해줍니다. 그 후 자식 프로세스를 스케쥴링 할 때(context switching, execution), PCB의 signal이 mark(전달)되어 있는지 확인합니다. check되어 있으면 해당 signal의 handler를 탐색하고 PC(program counter)가 가리키게 합니다. 그러면 signal handler를 먼저 수행하게 되는거죠.

 

sigpause는요?

물론 다른 일을 할 수 있습니다. 예제를 단순하게 만들기 위해 sigpause를 한 것입니다. 꼭 사용해야 하는 건 아닙니다. 사용하지 않는다면 signal이 delivery되면 그때 해당되는 handler가 execution된다고 생각하시면 됩니다.

 

 

16.03. Shared Memory

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/wait.h>
#define SHM_SIZE 1024

void ChildRun(int shmid);

int main(int argc, char **argv)
{
	int shmId, pid;
	char *ptrShm;
	
	if ( (shmId = shmget(IPC_PRIVATE, SHM_SIZE, SHM_R |
	SHM_W)) < 0 ) {
		perror("shmget error");
		exit(-1);
	}
	if ( (ptrShm = shmat(shmId, 0, 0)) == (void *)-1 ) {
		perror("shmat error");
		exit(-1);
	}
	ptrShm[0] = 11;
	ptrShm[1] = 22;
	printf("Parent : %d, %d\n", ptrShm[0], ptrShm[1]);
	
	switch ( pid = fork() )
	{
		case 0:
			ChildRun(shmId);
			return 0;
		case -1:
			perror("fork error");
			exit(-1);
		default:
			break;
	}
	waitpid(pid, NULL, 0);
	printf("Parent : %d, %d\n", ptrShm[0], ptrShm[1]);
	if ( shmdt(ptrShm) < 0 ) {
		perror("shmdt error");
		exit(-1);
	}
	if ( shmctl(shmId, IPC_RMID, 0) < 0 ) {
		perror("shmctl error");
		exit(-1);
	}
	return 0;
}

void ChildRun(int shmid)
{
	int shmId;
	char *ptrShm;
	shmId = shmid;
	
	if ( (ptrShm = shmat(shmId, 0, 0)) == (void *)-1 ) {
		perror("shmat error");
		exit(-1);
	}
	
	printf("Child : %d, %d\n", ptrShm[0], ptrShm[1]);
	printf("Child : Modify value.\n");
	ptrShm[0] = 33;
	ptrShm[1] = 44;
	
	if ( shmdt(ptrShm) < 0 ) {
		perror("shmdt error");
		exit(-1);
	}
}

두 개의 프로세스가 있고 shared memory를 attach하는 예제입니다. shmget은 공유 메모리 생성 후 식별자를 return하는 것입니다. shmat은 공유 메모리를 attach하고  주소를 return하죠.(shmat를 호출하는 프로세스의 어느 주소인데 이는 커널이 정해줍니다. 즉, 공유 메모리가 프로세스에 attach되는 주소는 커널이 정해줍니다. argument를 통해 shmat를 호출하는 프로세스가 주소를 제한할 수 있습니다. 물론 커널 입장에서는 붙이지 못할 수도 있습니다. 여러 프로세스의 memory object로 구성되어 있기 때문이죠. 그래서 return값을 check해주는 것이 중요합니다.)

 

저번 포스팅에서도 말씀드렸지만 시스템 콜은 return value를 확인하는 것이 매우 중요한 습관입니다. Frame work를 사용하거나 직접 작성하지 않은 code를 호출해서 뭔가를 요청할 때는 항상 return값을 확인해야 합니다.

 

fork되는 시점부터 자식 프로세스는 ChildRun을 수행하고 부모 프로세스는 ChildRun code를 가지고 있지만 수행하진 않습니다.(2차원적으로 생각해보세요.) ChildRun에서는 shared memory의 id를 넘겨받아서 child에 붙는 주소를 한번 더 확인합니다. 여기서 값들을 출력하고 rewriting을 진행한 후 exit합니다.

 

부모 프로세스는 자식 프로세스가 rewriting한 값을 출력하겠죠. 마지막으로 shmdt는 shared memory detach로 분리하는 것입니다. 

 

 

ptrShm의 값은요?

앞서 설명드린 것처럼 argument를 통해 shmat를 호출하는 프로세스가 주소를 제한할 수 있다고 말씀드렸는데요. 호출한 프로세스가 제안한 주소일 수도 있고 아닐수도 있습니다. 커널은 memory map을 전부 볼 수 있기 때문이죠. call한 프로세스는 자신의 memory map을 충분히 보지 못할 수도 있습니다. 즉, 요청한 주소가 다른 object에 의해서 사용되고 있을 수 있습니다.

 

공유메모리 예제에서는 shmget > attach > fork 순으로 수행하고 있습니다. 즉, id를 받아놓고 fork를 하면 동일한 프로세스를 만들기 때문에 같은 id가 childrun의 id로 사용됩니다. 여기서는 fork를 했기 때문에 child와 parent에 relationship이 있는 상태인데요. 완전히 별개인 프로세스에서는 어떻게 해줄까요?

이 질문이 떠오르셨다면 굉장히 훌륭합니다. 또 생각해볼거리를 다양하게 제공하는 좋은 질문이고요. 다른 IPC 기법을 사용할 수 밖에 없습니다. 지금까지 나온 예제에서는 pipe나 signal이 선택지가 될 수 있겠군요. 다만 signal은 사용하기 어려울 수 있습니다. 왜그런지는 signal의 예제를 잘 살펴보시면 알 수 있습니다. Argument가 signal밖에 없습니다. id를 보낼 수 없다는 거죠. 기본적으로 signal 자체는 데이터를 주고받는 용도로 사용되지 않는다는 것을 짚고 넘어갈 수 있습니다.(IPC별 장단점을 비교해보는 것도 재미난 topic이 될 것 같습니다. Signal은 generic하긴 하지만 data를 보내지 못한다고 정리할 수 있겠군요.) 그래서 임의의 프로세스 간 communication은 socket을 사용합니다. 어떻게 보면 서버가 존재하는 이유도 요청을 받아서 뭔가를 전달해주기 위해서죠.(프로세스 A가 만들어서 서버에 ID를 전달하면 서버가 보관하다가 프로세스 B가 필요하면 내주는 형식입니다. 이때 필요한 것은 certification입니다. 프로세스 A와 B가 실제로 프로세스 A와 B인지 확인하는 겁니다. A가 공유메모리를 만들 수 있는 프로세스인지 B는 공유메모리의 ID를 받을 수 있는 qualification을 가지고 있는지 확인하는거죠.)

 

가상메모리에서 주소는요?

컴퓨터구조 과목을 배우셨다면 이런 질문을 하실수도 있을 것 같습니다. Memory management를 조금 더 배워야 이 질문에 대한 답을 할 수 있습니다. COW(Copy On Write)에 대해 공부해보시면 좋을 것 같습니다. fork를 하면 처음에 같은 page를 가리키고 있는 것이 메모리를 더 사용하지 않아도 되니 유리합니다. fork를 하는 순간 기본적으로 다른 프로세스가 됩니다.

반응형

 

16.04. Message Queue

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <stdbool.h>
#include <errno.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>
#include <sys/wait.h>

typedef struct _MSG {
	long type;
	char message[256];
} MSG, *PMSG, **PPMSG;

#define MSG_ sizeof(MSG)

int main(void)
{
	pid_t pid;
	key_t msg_id;
	MSG msg;
	
	if ( -1 == (msg_id = msgget(IPC_PRIVATE, 0660 | IPC_CREAT)) ) {
		perror( "msgget failed" );
		exit(1);
	}
	
	switch ( pid = fork() )
	{
		case -1:
			perror( "fork failed " );
			exit(-1);
		case 0:
			msg.type = 1;
			strcpy( msg.message, "Hello, world.");
			msgsnd( msg_id, &msg, MSG_-sizeof(long), 0 );
			return 0;
		default:
			waitpid(pid, 0, 0);
			memset( &msg, 0, MSG_ );
			msgrcv( msg_id, &msg, MSG_-sizeof(long), 1, 0 );
			printf("PARENT - message from child : %s\n", msg.message);
			fflush(stdout);
	}
	
	if ( -1 == msgctl(msg_id, IPC_RMID, 0) ) {
		perror( "msgctl failed" );
		exit(-1);
	}
	
	return 0;
}

Message Queue 예제는 code가 단순하여 이해하시기 편할겁니다. 기본적으로 메시지 ID를 받고 공유메모리 방식과 유사합니다. 메시지 ID를 받고 fork 한 후 send, receive하는 방식입니다.

 

 

16.05. Socket

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

#define PORT_NUM 53003

int main() {
	int serverSocketFd = 0;
	int clientSocketFd = 0;
	socklen_t clientAddrSize = 0;
	char ch[3];
	
	struct sockaddr_in serverAddress;
	struct sockaddr_in clientAddress;
	
	serverSocketFd = socket(AF_INET, SOCK_STREAM, 0);
	
	memset(&serverAddress, 0, sizeof(serverAddress));
	serverAddress.sin_family = AF_INET;
	serverAddress.sin_port = htons(PORT_NUM);
	serverAddress.sin_addr.s_addr = INADDR_ANY;
	
	bind(serverSocketFd, (struct sockaddr *)&serverAddress, sizeof(struct sockaddr));
	
	listen(serverSocketFd, 1);
	
	printf("waiting connection from client.\n");
	memset(&clientAddress, 0, sizeof(clientAddress));
	clientAddrSize = sizeof(clientAddress);
	clientSocketFd = accept(serverSocketFd, (struct sockaddr *)&clientAddress, &clientAddrSize);
	printf("connected with client.\n");
	
	while(1){
		int rtn;
		rtn = read(clientSocketFd, &ch, sizeof(ch));
		if(rtn <= 0){
			break;
		}
		printf("received data : %s \n", ch);
	}
	
	close(serverSocketFd);
	printf("disconnected with client.\n");
	
	return 0;
}

 

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

#define PORT_NUM 53003

int main() {
	int socketFd = 0;
	struct sockaddr_in serverAddress;
	int result = 0;
	char ch[3] = "HI";
	int i;
	
	socketFd = socket(AF_INET, SOCK_STREAM, 0);
	
	memset(&serverAddress,0, sizeof(serverAddress));
	serverAddress.sin_family = AF_INET;
	serverAddress.sin_port = htons(PORT_NUM);
	inet_pton(AF_INET, "127.0.0.1", &serverAddress.sin_addr.s_addr);
	
	connect(socketFd, (struct sockaddr *) &serverAddress, sizeof(serverAddress));
	
	for(i = 0; i < 5; i++){
		// write "HI" to server 5 times
		printf("Send Data to Server. \n");
		write(socketFd, &ch, sizeof(ch));
		sleep(1);
	}
	
	close(socketFd);
}

소켓은 일반적으로 네트워킹에 사용됩니다만 용도가 general해서 IPC로 사용하는 경우도 많습니다. 물론 socket 프로그래밍을 학부 2학년정도 level에서는 아직 하지 않았을 가능성이 높습니다.(물론 저도 마찬가지입니다.) 기본적으로 소켓이 포트로 동작한다는 것을 기억하시면 좋겠습니다.(포트와 관련해서 포스팅이 있으니 참고해주시면 좋을 것 같습니다.)

 

첫번째 code는 server이고 두번째 code는 client입니다. 소켓은 기본적으로 데이터를 주고받기 때문에 웹서버처럼 서버와 클라이언트 구조로 되어 있습니다.

 

Server와 같은 경우에는

소켓 ID를 생성

> port number임의 지정 : 53003 address 문제가 있어서 변환이 필요합니다.(컴퓨터 구조 과목에서 들어보셨을 수도 있을텐데 literal limit 이슈때문입니다.)

> bind : 소켓과 포트(프로세스가 보는 창이 포트라 말씀드렸습니다. 포트는 OS가 제공해주는 abstraction입니다.)

> port listen : ID로 소켓을 사용합니다.(서버에서 돌아가는 무언가도 프로세스죠.)

> accept call : 무언가 넘어왔을 때 > read : 들어온 data size만큼 해줌

순서로 진행될 것입니다.

 

Client같은 경우는 소켓을 만드는 것은 동일합니다. 포트를 53003으로 정하는 것은 클라이언트 서버가 정해야 하는 부분입니다. general하게 ID를 공유할 수 있는 조건입니다. 최소한 포트번호는 동일해야하는 거죠.(임의의 프로세스가 임의의 프로세스와 communication하기란 여간 쉬운일이 아닙니다. 공통점이 조금이라도 있어야 하죠. 물론 well-known 포트는 공유할 필요가 없습니다. 이미 표준으로 정해져 있기 때문이죠.)

 

Connect 시도 : server address(port number & IP address 필요)

> writing

순서로 진행됩니다.

 

 

하나의 프로세스가 여러 개의 소켓을 가질 수 있을까요?

의미가 없습니다. 어차피 소켓을 만든 다음에 그것을 port에 binding. client에 connect하기 때문에 여러개 소켓이라는 것이 의미가 없습니다. 

 

포트는 어느 프로세스나 소켓을 붙일 수 있습니다. 즉, 하나의 포트에 여러 프로세스가 binding할 수 있는거죠. 웹서버를 생각해보시면 브라우저를 여러개 띄우는 경우와 유사합니다. 

이 그림을 저번 포스팅에서도 소개해드린 바 있는데요. 이렇게 처리해주는 것이 커널 네트워킹입니다. 즉, 포트와 소켓은 완전히 다른 abstraction입니다. 포트는 OS가 직접 제공하는 것이고 소켓은 프로세스 안에서 존재합니다.(프로세스가 terminate되면 해당 소켓은 없어지지만 포트는 계속 존재합니다. 커널이 crash할 때등 제한된 경우에서만 포트도 없어질 수도 있습니다.) 다시 한번 정리하자면 소켓은 프로세스가 보는 것입니다. 소켓은 시스템 콜을 통해서 만들고요. 포트는 프로세스가 만들지 않습니다. 커널이 그 포트번호를 recognize해줍니다.(그런 점에서 포트는 OS가 제공하는 abstraction이라고 할 수 있습니다.)

 

 

마지막으로 앞서 잠깐 언급했던 literal limit만 짚고 넘어가겠습니다. 

엔디언(Endianness)은 컴퓨터의 메모리와 같은 1차원의 공간에 여러 개의 연속된 대상을 배열하는 방법을 뜻하며, 바이트를 배열하는 방법을 특히 바이트 순서(Byte order)라 한다. 엔디언은 보통 큰 단위가 앞에 나오는 빅 엔디언(Big-endian)과 작은 단위가 앞에 나오는 리틀 엔디언(Little-endian)으로 나눌 수 있으며, 두 경우에 속하지 않거나 둘을 모두 지원하는 것을 미들 엔디언 (Middle-endian)이라 부르기도 한다.

 

빅 엔디언은 최상위 바이트(MSB - Most Signficant Byte)부터 차례로 저장하는 방식이며, 리틀 엔디언은 최 하위 바이트(LSB - Least Significant Byte) 부터 차례로 저장하는 방식이다.

 

htons()함수는 short형의 데이터 즉, 포트번호를 호스트 바이트순서에서 네트워크 바이트순서로 바꾸어 줍니다.(네트워 크 바이트 순서는 빅 엔디언 방식을 사용, 보통 컴퓨터는 리틀 엔디언 방식을 사용)

 

Intel계열이 리틀 엔디언 방식을 사용합니다. 또한 사용하는 computer가 빅 엔디언 방식을 사용한다 하더라도 변환함수를 사용하는 이유는 실질적인 값의 변화는 없을 수 있지만 이식성을 좋게 하기 위함입니다.

 

 

실제 OS의 IPC tutorial에 대해서는 아래의 문서를 참고하시는 것도 좋을 듯 합니다. 조금 생소하실수도 있는 QNX입니다. QNX는 원자력 발전소, 산업용 로봇 차량과 같이 안정성과 보안을 요하는 임베디드 시스템에서 사용하고 있는데요. QNX의 소프트웨어는 1억 2천만대 이상의 자동차(메르세데스, 아우디, BMW, KIA, 현대 등)에 배포되었다고도 합니다. OS로 QNX Neutrino RTOS라고 하여 마이크로 커널과 모듈식 아키텍처로 구성되고 있습니다. QNX Hypervisor도 있네요.

http://www.qnx.com/developers/docs/6.5.0/index.jsp?topic=%2Fcom.qnx.doc.neutrino_sys_arch%2Fipc.html&resultof=%22%69%70%63%22%20 

 

Help - Eclipse SDK

 

www.qnx.com

 

 

오늘은 IPC 메커니즘의 예제를 알아보는 시간을 가졌습니다. Code가 상당히 많은데요. 흐름과 구현방식을 중점적으로 봐주시면 될 것 같습니다. 물론 독학하면서 정리한 내용이라 부족하거나 부정확한 부분이 있을수도 있습니다. 댓글로 그런 부분에 대해서는 지적해주시거나 궁금하신 부분은 질문해주시면 정말 감사하겠습니다. 이 글을 포스팅하는 날이 11월 24일인데, 아직 공부할 내용은 많이 남았고 복학 전에 공부를 마무리하려하니 빈틈이 많아지는 것 같아 고민이 많습니다. 특히 IPC에 대해서는 꼼꼼히 배워두면 나중에 도움이 될만한 topic인데 시간이 되면 각 메커니즘별로 자세히 알아보는 시간을 가지도록 하겠습니다.

 

 

 

반응형

'학부공부 > OS_운영체제' 카테고리의 다른 글

19. Socket programming basic [Interesting topic to study]  (2) 2021.11.28
18. Message Queue [Interesting topic to study]  (2) 2021.11.25
16. Port [Interesting topic to study]  (0) 2021.11.24
15. IPC(1)  (2) 2021.11.21
14. Process(3)  (0) 2021.11.17

댓글