LinuxIPC

Hamji·2022년 2월 25일
0
  1. 개요 / 기본 개념
  2. IPC 종류
    1. PIPE
    2. Named PIPE
    3. Message Queue
    4. Shared Memory
    5. Memory Map
    6. Semaphore
    7. Socket

개요 / 기본 개념


img

위의 사진은 리눅스 커널 구조 그림이다. 위의 사진을 보면 프로세스의 특징은 아래와 같다.

  • 운영체제로부터 시스템 자원을 할당 받는 작업의 단위
  • 프로세스간 완전히 독립된 메모리 영역을 할당 받는다(Code, Data, Stack, Heap)
  • 다른 프로세스의 영향을 받지 않는다. (다른 프로세스가 메모리에 함부로 접근할 수 없다.)
  • 쓰레드와 이런 차이점이 있다.
    1. 같은 프로세스 안에 있는 여러 쓰레드는 같은 힙공간을 공유한다. 하지만 프로세스는 직접 다른 프로세스에 직접 접근할 수 없다.
    2. 프로세스는 Code, Data, Stack, Heap영역을 독립적으로 할당 받지만. 쓰레드는 Stack 만 따로 할당 받고 나머지인 Code, Data, Heap은 공유한다.

위와 같은 이유로 별도의 설비가 없다면 통신이 어렵다.

이렇게 프로세스간 통신을 위해서 커널 영역에서는 다양한 IPC(Inter Process Communication) 을 제공한다.

IPC 종류


1.pipe


  1. 개념

    1. PIPE는 우리가 생각하는 파이프와 비슷하게 작동한다.

    2. 익명 파이프라고도 부른다.

    3. 보통 관련이 있는 프로세스끼리 사용한다(e.g 부모, 자식 프로세스)

    4. 작동 원리

      • 파이프는 두개의 프로세스를 연결한다.
      • 하나의 프로세스는 write, 나머지 하나는 read 만을 할 수 있다.
      • 한쪽 방향으로만 통신가능하기에 Half-duplex 통신이라 한다.
    5. 장점

      1. 매우 간단하다.
      2. 한쪽은 쓰기만, 한쪽은 읽기만 하는 단순한 데이터 흐름 시 사용하면 좋다.
    6. 단점

      1. 반이중 통신
      2. 프로세스가 읽기 쓰기 모두 해야한다면 PIPE를 두개 만들어야한다. 이는 구현이 복잡해 질 수 있다.
      3. read와 write가 block 작동이라서 프로세스가 read 대기중이라면 read가 끝나기 전엔 write할 수 없다. 두개의 프로세스 모두 read 라면 영원히 block된다.
        1. 해결을 위해 읽기 전용, 쓰기 전용 프로세스를 두개씩 만들거나 입출력 다중화 필요
    7. 사진

      img

      img

  2. 코드

    1. <unistd.h> 에 있는 pipe 함수를 통하여 2개의 Int 원소가 있는 배열을 pipe로 만들어 볼 것이다.

    2. 파이프는 실제로는 커널 영역에 생성되며 파이프를 생성한 프로세스는 file descriptor만을 가지고 pipe를 이용한다 이때 데이터를 [1]번 원소에 쓰게 되면 0번으로 그 데이터를 읽을 수 있다.

    3. fork를 하게된다면 자식 프로세스는 File descriptor를 그대로 사용할 수 있기 때문에 이를 통해 자식 프로세스와 부모 프로세스가 pipe로 통신할 수 있다.

    4. 아래의 코드는 부모 프로세스가 보내는 메시지를 자식 프로세스가 받아 출력하는 코드이다.

      #include <stdio.h>
      #include <unistd.h>
      #include <string.h>
      #include <errno.h>
      
      #define MAXBUF 1024
      #define READ 0
      #define WRITE 1
      
      int main(){
      	// file discriptor
      	int fd[2];
      	char buf[MAXBUF];
      
      	// pipe 에 실패할 경우 에러를 출력 후  종료
      	if (pipe(fd) < 0){
      		fprintf(stderr, "pipe error: %s\n", strerror(errno));
      		return -1;
      	}
      
      	// fork 에 실패할 경우 
      	pid_t pid = fork();
      	if (pid == -1){
      		fprintf(stderr, "fork error: %s\n", strerror(errno));
      		return 1;
      	}
      	
      	printf("\n");
      	// 부모 프로세스
      	if (pid > 0) {
      		// 부모 프로세스는 쓰기만 할 것이므로 PIPE의 READ 하는 부분을 닫는다
      		close(fd[READ]);
      		strcpy(buf, "message from parent\n");
      		write(fd[WRITE], buf, strlen(buf));
      	}else{
      		// 자식 프로세스는 읽기만 할 것이므로 PIPE의 WRITE 하는 부분을 닫는다.
      		close(fd[WRITE]);
      		read(fd[READ], buf, MAXBUF);
      		printf("child got message : %s\n", buf);
      	}
      
      
      	return 0;

2. Named PIPE


  1. 개념

    1. FIFO라고도 부른다.
      1. 생성된 PIPE에 대하여 WRITE 또는 READ만 가능
    2. 사용할 PIPE를 명명할 수 있다.
    3. 익명 pipe와 named pipe의 차이
      1. 익명파이프(PIPE)는 데이터 통신을 할 프로세스를 명확하게 알 때 사용(e.g. 부모, 자식 프로세스), 전혀 관련 없는 프로세스 사이에는 pipe를 통해 통신하려면 이름이 주어져야 한다.
      2. named pipe는 파일로 존재하기에 이 파일의 이름이 pipe의 이름이 된다.
    4. 쌍방 통신을 위해서는 pipe나 named pipe나 write용 read용을 가지고 있어야한다.
    5. named pipe를 사용하면 전형적인 서버/클라이언트 모델을 따르는 프로그램 작성이 가능하다.
      1. img
  2. 코드

    1. 아래의 코드는 별개의 프로세스가 이름을 가지고 named pipe를 이용해 통신하는 모습을 알 수 있다.

    2. client.c

      #include <fcntl.h>
      #include <sys/stat.h>
      #include <unistd.h>
      #include <stdio.h>
      #include <stdlib.h>
      
      #define MAXBUF 1024
      #define NAME "./named_pipe"
      
      int main(void) {
        char buf[MAXBUF];
        int fd;
        int nread, i;
      
        // write 전용 파이프 열기
        if ((fd = open(NAME, O_WRONLY)) < 0) {
          printf("fail to open named pipe\n");
          return -1;
        }
      
        // 데이터 전송
        for (i = 0; i < 3; i++) {
          snprintf(buf, sizeof(buf), "Send Message[%i]", i);
          if ((nread = write(fd, buf, sizeof(buf))) < 0 ) {
            printf("fail to call write()\n");
            return 0;
          }
        }
        return 0;
      }
    3. Server.c

      #include <fcntl.h>
      #include <sys/stat.h>
      #include <unistd.h>
      #include <stdio.h>
      #include <stdlib.h>
      
      
      #define MAXBUF 1024
      #define NAME "./named_pipe"
      
      int main(void) {
        char buf[MAXBUF];
        int fd;
        int nread, rc;
      
        // named pipe 가 있다면 삭제해주자
        if (access(NAME,F_OK) == 0) {
          unlink(NAME);
        }
      
        // named pipe 생성
        if ((rc = mkfifo(NAME,0666)) < 0) {
          printf("fail to make named pipe\n");
          return 0;
        }
      
        // named pipe 열기
        if ((fd = open(NAME, O_RDWR)) < 0) {
          printf("fail to open named pipe\n");
          return 0;
        }
      
        while (1) {
          if ((nread = read(fd, buf, sizeof(buf))) < 0 ) {
            printf("fail to call read()\n");
            return 0;
          }
          printf("recv: %s\n", buf);
        }
        return 0;
      }
    4. 결과창

      1. fifo

3. Message Queue


  1. 개념

    1. Queue는 선입 선출 자료구조를 가지는 설비로 커널에서 관리한다.
    2. 방식으론 Named pipe와 비슷하다
      1. FIFO 구조
    3. named pipe와 차이점
      1. Named pipe : Data flow
      2. message queue : 메모리 공간
    4. 메시지 큐는 데이터에 쓸 번호를 붙임으로써 여러개의 프로세스가 동시에 쉽게 다루기 가능
    5. 메시지큐는 커널에서 관리하는 자원으로 여러개의 메시지 큐를 가질 수 있다. 메시지 큐를 구별하기 위해 Key 를 가진다. key를 유일한 번호로 가지고 생성된다.
    6. 단점
      1. 메시지가 정말 잘 전달되었는지 알 수 없다.
      2. 오버헤드 발생 가능
      3. 데이터 많이 쌓일 수록 추가적인 메모리가 필요하다.
  2. 코드

    1. message queue 사용함수

      1. msgget : system 의 메시지 큐 id 를 얻어온다.
      2. msgsnd : 메시지 보내기
      3. msgrcv : 메시지 받기
      4. msgctl : 메시지 큐 제어 시스템콜
    2. 나이와 이름을 메시지 큐에 올리면 그 큐에 있는 데이터를 받아오는 코드이다

      1. data.h

        struct human{
        	short age;
        	char name[20];
        };
        
        struct message{
        	long msg_type;
        	struct human data;
        };
        
  2. sender.c

     ``` c
     #include <stdio.h>
     #include <sys/types.h>
     #include <sys/ipc.h>
     #include <sys/types.h>
     #include <sys/msg.h>
     #include <stdlib.h>
     #include <string.h>
     #include "data.h"
     
     int main(){
         key_t key=12345;
     	int msqid;
         struct message msg;
     	
     	// msg type은 무조건 0보다 커야한다.
     	msg.msg_type=1;
         msg.data.age=80;
     	strcpy(msg.data.name,"ghkim");
     
         //msqid를 가져오기.
     	//없다면 큐를 생성한다.
         if((msqid=msgget(key,IPC_CREAT|0666))==-1){
             printf("msgget failed\n");
             exit(0);
         }
     			
         //메시지보내기
         if(msgsnd(msqid,&msg,sizeof(struct human),0)==-1){
     		printf("msgsnd failed\n");
     		exit(0);
         }
          
     	printf("message sent\n");
     }
     
     ```

  3. Receiver.c

     ``` c
     #include <stdio.h>
     #include <sys/types.h>
     #include <sys/ipc.h>
     #include <sys/types.h>
     #include <sys/msg.h>
     #include <stdlib.h>
     #include <string.h>
     #include "data.h"
     
     int main(){
             key_t key=12345;
             int msqid;
             struct message msg;
     
             //sender  쪽의 msqid얻어오기
             if((msqid=msgget(key,IPC_CREAT|0666))==-1){
                     printf("msgget failed\n");
                     exit(0);
             }
             //메시지를 받기
             if(msgrcv(msqid,&msg,sizeof(struct human),0,0)==-1){
                     printf("msgrcv failed\n");
                     exit(0);
             }
     
             printf("name : %s, age :%d\n",msg.data.name,msg.data.age);
     
             //이후 메시지 큐 지우기.
             if(msgctl(msqid,IPC_RMID,NULL)==-1){
                     printf("msgctl failed\n");
                     exit(0);
             }
     }
     
     ```

  

4. Shared Memory


  1. 개념

    1. pipe, named pipe, message queue 가 통신을 이용한다면 shared memory는 말그대로 공유메모리가 데이터 자체를 공유하는 개념
    2. 프로세스가 공유메모리 할당을 커널에 요청하면 커널은 해당 프로세스에 메모리 공간을 할당
      1. 이후 어떤 프로세스건 해당 메모리에 접근 가능
    3. 장점
      1. 중개자 없이 곧바로 메모리에 접근 가능하기 때문에 IPC 중 가장 빠르게 작동한다.
        1. 커널 메모리 영역에서 관리하기 때문
    4. 단점
      1. 메시지 전달 방식이 아니라 데이터를 읽어야하는 시점을 알 수 없다.
      2. 커널 종속적이라 커널에서 사용하는 공유메모리 사이즈 확인 필요
  2. 코드

    1. 사용함수

      1. shmget : 인자로 전달된 Key 값으로 공유메모리를 얻고 shared memory segmentf를 돌려준다.

      2. shmat : 메모리의 위치에 이 프로세스를 묶는 시스템콜

      3. shmdt : 공유 메모리를 이 프로세스와 떼어내는 것

        1. 공유메모리를 제거하는 것은 아니다..
      4. shmctl : 공유메모리를 제어하기 위하여 사용

    2. 아래의 코드는 shared memory 가 없다면 새로운 메모리 세그먼트를 만들고 메모리 주소를 얻어와 값을 변경하는 코드이다. Remove_shm은 값을 변경하고 마지막엔 공유메모리를 종료시킨다

    3. shm.c

      #include <sys/ipc.h>
      #include <sys/shm.h>
      #include <unistd.h>
      #include <stdlib.h>
      #include <stdio.h>
      
      int main(){
      	int shmid;
      	int *num;
          key_t key=12345;
          void *memory_segment=NULL;
      	
      	// key 값을 전달하여 공유메모리를 얻고 공유메모리 조각의 Id 를 받아 shmid에 저장
      	if((shmid=shmget(key,sizeof(int),IPC_CREAT|0666))==-1){
      		printf("shmget failed\n");
              exit(0);
      	}
           
      	// 공유메모리를 얻었다면 메모리의 위치에 이 프로세스를 묶는다
      	if((memory_segment=shmat(shmid,NULL,0))==(void*)-1){
      		printf("shmat failed\n");
      		exit(0);
          }
      
      	// num에 공유메모리에서 얻어온 포인터를 이용해 값 증
      	num=(int*)memory_segment;
      	(*num)++;
      	printf("shared memory value :%d\n",(*num));
      	return 0;
      }
      
    4. remove_shm.c

      #include <sys/ipc.h>
      #include <sys/shm.h>
      #include <unistd.h>
      #include <stdlib.h>
      #include <stdio.h>
      
      int main(){
          int shmid;
          int *num;
      	key_t key=12345;
          void *memory_segment=NULL;
      		
      	if((shmid=shmget(key,sizeof(int),IPC_CREAT|0666))==-1){
              printf("shmget failed\n");
      		exit(0);
      	}
      	
      	if((memory_segment=shmat(shmid,NULL,0))==(void*)-1){
      		printf("shmat failed\n");
      		exit(0);
      	}
      	
      	num=(int*)memory_segment;
      	(*num)++;
      	printf("shared memory value :%d\n",(*num));
      	
      	// shared memory 를 제어하기위해서 사용한다
      	if(shmctl(shmid,IPC_RMID,NULL)==-1){
      		printf("shmctl failed\n");
      	}
      	return 0;
      }
      

5. Memory Map


  1. 개념

    1. 열린 파일을 프로세스의 메모리에 일정 부분에 맵핑 시켜서 사용하는 기법
    2. 파일로 대용량 데이터를 공유할때 사용하면 좋다.
    3. FILE IO 가 느릴 때
    4. 유의사항
      1. 메모리와 file sync가 안 맞을 수도 있다.
      2. 메모리 맵 파일은 파일의 크기를 바꿀 수는 없으며 메모맵 파일을 사용하기 전, 그 이후에만 파일크기를 바꿀 수 있다.
    5. img
    6. 단점
      1. 일반 파일 IO에 비해 상당히 많은 메모리 요구한다.
      2. 많은 데이터를 얼마나 오랫동안 메모리에 둘 것인지 컨트롤 할 수 없다.
  2. 코드

    1. 사용 함수

      1. mmap : 객체를 메모리에 맵핑
      2. Munmap : 맵핑된 메모리 영역해제
    2. main.c

      #include <stdio.h>
      #include <stdlib.h>
      #include <string.h>
      #include <errno.h>
      #include <sys/types.h>
      #include <sys/stat.h>
      #include <sys/mman.h>
      #include <unistd.h>
      #include <fcntl.h>
      #include <sys/mman.h>
      
      int main(int argc, char **argv) {
        int fd;
        char *file = NULL;
        struct stat sb;
        int flag = PROT_WRITE | PROT_READ;
      
        if (argc < 2) {
          fprintf(stderr, "Usage: input\n");
          return 0;
        }
      
        if ((fd = open(argv[1], O_RDWR|O_CREAT)) < 0) {
          perror("File Open Error");
          return 0;
        }
      
        if (fstat(fd, &sb) < 0) {
          perror("fstat error");
          return 0;
        }
      
        file = (char *)malloc(40);
      
        // mmap를 이용해서 열린 파일을 메모리에 대응시킨다.
        // file은 대응된 주소를 가리키고, file을 이용해서 필요한 작업을
        // 하면 된다.
        if ((file = (char *) mmap(0, 40, flag, MAP_SHARED, fd, 0)) == NULL) {
          perror("mmap error");
          return 0;
        }
        printf("%s\n", file);
        memset(file, 0x00, 40);
        munmap(file, 40);
        close(fd);
        return 0;
      }
      
      

6. Semaphore


  1. 개념

    1. pipe, message queue 등과 같은 IPC 들은 프로세스간 메시지 전송을 목적으로 하는데 반해 프로세스 간 데이터 동기화 및 보호를 목적으로 한다.
    2. shared memory를 통해 여러개의 프로세스가 동시에 접근하면 안되며 한 번에 하나의 프로세스만 접근 가능하도록 만들어 줘야하기에 이 때 사용되는 것이 semaphore이다.
    3. 만약에 세마포어가 적용되지 않는 상태의 프로그램일 경우 임계영역에 진입하여 공유자원에 접근할 때 문제가 생기게 된다.
      1. 예를들어 하나의 데이터에 여러개의 프로세스가 관여할 경우
        1. int count = 1
        2. a process read "count" 1
        3. b process read "count" 1
        4. B process add "count" 2
        5. A process add "count". 2
      2. 결과는 3이 2가 나오게 되는 결과가 있을 수 있다. 이를 막기위해 count에 A가 접근할 떄 B가 접근 못하도록 block 해주고 A가 작업이 끝난다면 B가 접근할 수 있도록 해주게 만들어야한다.
      3. 이때 우린 세마포어를 쓴다.
    4. 작동원리
      1. 차단을 원하는 자원에 대해서 세마포어를 생성
      2. 해당 자원을 가르키는 세마포어 값 할당
      3. 이 값이 0이면 자원에 접근할 수 있는 프로세스가 0이라는 뜻, 0보다 큰 정수면 정수의 크기만큼 프로세스가 자원에 접근 가능
      4. 자원을 접근하면 세마포어 값을 1 감소, 사용이 끝나면 1 증가
      5. 만약에 세마포어 값을 검사했는데 값이 0이라면 사용할 수 있게 될 때까지 기다리면(block) 된다.
  2. 코드

    1. 사용법

      1. 세마포어로 제어할 자원 설정
      2. 해당 자원 사용전 세마포어 값 확인
      3. 세마포어 값이 0보다 크면 자원을 사용하고 세마포어 값을 1 감소
      4. 세마포어 값이 0이면 값이 0보다 커질 때까지 block하게 되며 0보다 커지게 되면 2번부터 start
    2. main.c

      #include <pthread.h>
      #include <stdio.h>
      #include <stdlib.h>
      #include <unistd.h>
      #include <string.h>
      #include <semaphore.h>
      
      #define THREAD_NUM 4
      
      sem_t semaphore;
      
      void* routine(void* args) {
      	// 세마포어를 얻을 때 까지 기다린다.
          sem_wait(&semaphore);
          sleep(1);
          printf("Hello from thread %d\n", *(int*)args);
      	// 세마포어를 되돌려준다.
          sem_post(&semaphore);
          free(args);
      }
      
      int main(int argc, char *argv[]) {
          pthread_t th[THREAD_NUM];
      	// 세마포어 만들기 초기값 1
          sem_init(&semaphore, 0, 1);
          int i;
          for (i = 0; i < THREAD_NUM; i++) {
              int* a = malloc(sizeof(int));
              *a = i;
              if (pthread_create(&th[i], NULL, &routine, a) != 0) {
                  perror("Failed to create thread");
              }
          }
      
          for (i = 0; i < THREAD_NUM; i++) {
              if (pthread_join(th[i], NULL) != 0) {
                  perror("Failed to join thread");
              }
          }
          sem_destroy(&semaphore);
          return 0;
      }

7. Socket


  1. 개념

    1. Socket은 프로세스와 시스템의 기초적인 부분이다.
    2. 프로세스 간에 통신을 가능하게 한다.
    3. 같은 도메인에서 연결될 수 있다.
    4. 소켓을 사용하기 위해선...
      1. 생성
      2. domain, type, protocol 지정
      3. 서버에선 bind, listen, accept과정이 필요하다.
      4. 클라이언트 에선 connect를 통해 서버에 요청
      5. 연결 수립 후엔 소켓을 send 하여 데이터를 주고 받음
      6. 연결이 끝난 후에는 반드시 소켓을 close해야 한다.
  2. 코드

    1. 본 예제에서는 Unix Domain Socket을 통하여 내부 프로세스간에 통신을 한다.

    2. client.c

      #include <sys/types.h> 
      #include <sys/stat.h> 
      #include <sys/socket.h> 
      #include <unistd.h> 
      #include <sys/un.h> 
      #include <stdio.h> 
      #include <stdlib.h> 
      #include <string.h> 
      
      int main(int argc, char **argv) {
      
          int client_len;
          int client_sockfd;
      
          char buf_in[255];
          char buf_get[255];
      
          struct sockaddr_un clientaddr;
      
          if (argc != 2) {       
              printf("Usage: %s [UDS path]\n", argv[0]);
              exit(0);
          }       
      
      	// socket 생성
          client_sockfd = socket(AF_UNIX, SOCK_STREAM, 0);
          if (client_sockfd == -1) {
              perror("Failed to create socket: ");
              exit(0);
          }
      
      	// 
          memset(&clientaddr, 0, sizeof(clientaddr));
          clientaddr.sun_family = AF_UNIX;
          strcpy(clientaddr.sun_path, argv[1]);
          client_len = sizeof(clientaddr);
          
      	// connect
      	if (connect(client_sockfd, (struct sockaddr *)&clientaddr, client_len) < 0) {
              perror("Failed to connect: ");
              exit(0);
          }
      
          while(1) {
              memset(buf_in, 0, sizeof(buf_in));
              printf("입력 : ");
              fgets(buf_in, 255, stdin);
      
              write(client_sockfd, buf_in, strlen(buf_in));
              if (strncmp(buf_in, "quit", strlen("quit")) == 0) {
                  close(client_sockfd);
                  exit(0);
              }
      
              while(1) {
                  read(client_sockfd, buf_get, 255); 
                  if (strncmp(buf_get, "end", strlen("end")) == 0)
                      break;
      
                  printf("%s\n", buf_get);
              }
          }
      
          close(client_sockfd);
          exit(0);
      }
      
    3. server.c

      #include <sys/types.h> 
      #include <sys/stat.h> 
      #include <sys/socket.h> 
      #include <sys/un.h> 
      #include <unistd.h> 
      #include <stdio.h> 
      #include <stdlib.h> 
      #include <string.h> 
      
      int main(int argc, char *argv[]) {
          int server_sockfd, client_sockfd;
          int state;
      
          struct sockaddr_un clientaddr, serveraddr;
          int client_len = sizeof(client_len);
      
          char buf[255];
      
          state = 0;
      
          if (argc != 2) {
              printf("Usage: %s [UDS path]\n", argv[0]);
              exit(0);
          }
      
          if (access(argv[1], F_OK) == 0) {
              unlink(argv[1]);
          }
      
          if ((server_sockfd = socket(AF_UNIX, SOCK_STREAM, 0)) < 0) {
              perror("Failed to create socket: ");
              exit(0);
          }
          memset(&serveraddr, 0, sizeof(serveraddr));
          serveraddr.sun_family = AF_UNIX;
          strcpy(serveraddr.sun_path, argv[1]);
      
      
          state = bind(server_sockfd , (struct sockaddr *)&serveraddr, 
                  sizeof(serveraddr));
          if (state == -1) {
              perror("Failed to bind: ");
              exit(0);
          }
      
          state = listen(server_sockfd, 5);
          if (state == -1) {
              perror("Failed to listen: ");
              exit(0);
          }
      
      
          while(1) {
              client_sockfd = accept(server_sockfd, (struct sockaddr *)&clientaddr, 
                      (socklen_t*)&client_len);
              if (client_sockfd == -1) {
                  perror("Failed to accept: ");
                  exit(0);
              }
      
              while(1) {
                  memset(buf, 0, 255);
                  if (read(client_sockfd, buf, 255) <= 0) {
                      close(client_sockfd);
                      break;
                  }
                  printf("%s", buf);
      
                  if (strncmp(buf, "quit", strlen("quit")) == 0) {
                      write(client_sockfd, "Connection Closed\n", strlen("Connection Closed\n"));  
                      close(client_sockfd);
                      break;
                  }
      
                  write(client_sockfd, "IPC SUCCESS", strlen("IPC SUCCESS"));
              }
          }
      
          close(client_sockfd);
      }
profile
얕고 작은 내 지식 옹달샘

0개의 댓글