거북이의 쉼터

(2022.04.02) Subdirectories 구현 본문

코딩 삽질/KAIST PINTOS (CS330)

(2022.04.02) Subdirectories 구현

onlim 2022. 4. 2. 18:10

1. 서론 및 필요 내용 설명

잠시 쉬다왔다... 닌텐도 + 딸기 파티의 효과는 굉장했다!

 


2. 구현해야 하는 것

  • 필요한 field 추가
  • open/remove 수정
  • 새로운 syscall 5개 구현

 


3. 구현 과정

3-0. field 추가 

일단 구현 전에 부모 디렉토리에 어떻게 접근할 것인가를 생각해보았다. 일반 파일이나 디렉토리를 만들때, inode가 생성된다. 해당 특성을 고려해서 생성시에 disk에 저장되는 내용에 해당 파일 또는 디렉토리가 생성되는 디렉토리의 섹터 번호를 넣어놓으면 추후에 inode로 불러오더라도 부모 디렉토리 정보를 수월하게 가져올 수 있을 것이라 생각했다. 이를 설계에 반영하여 inode_disk 구조를 다음과 같이 수정하자.

struct inode_disk {
	unsigned is_dir;                    /* 4 byte bool variable 0 : file 1 : dir */  
	off_t length;                       /* File size in bytes. */
	unsigned magic;                     /* Magic number. */
	cluster_t start_clst;               /* First data sector in cluster */
	disk_sector_t parent_sector;        /* Parent directory sector */
	uint32_t unused[123];               /* Not used. */
};

is_dir은 가장 처음 inode를 생성할 때인 filesys_create, 또는 mkdir에서 해당 inode가 디렉토리 용도로 만들어진 것인지, 아니면 단순 파일 용도로 만들어진 것인지를 나타내기 위한 field이며, start는 이제 필요가 없어진 field라 삭제하였다. 

 

다음으로 dir_readdir의 코드를 보면

bool
dir_readdir (struct dir *dir, char name[NAME_MAX + 1]) {
	struct dir_entry e;

	while (inode_read_at (dir->inode, &e, sizeof e, dir->pos) == sizeof e) {
		dir->pos += sizeof e;
		if (e.in_use) {
			strlcpy (name, e.name, NAME_MAX + 1);
			return true;
		}
	}
	return false;
}

pos를 계속 살려 두어야 모든 사항을 읽을 수 있다는 특성상 dir을 계속 열어두어야 했기에 fildes 구조체에 directory field를 다음과 같이 추가하여, 해당 fd를 닫기 전까지 dir을 살려두고, fd로 직접 열려있는 dir에 접근할 수 있도록 만들기로 하였다.

struct fildes {
	int fd; // file descriptor
	struct dup2_union *dup; // short for dup2 union pointer
	struct file *fp;
	struct list_elem fd_elem;

	struct dir *dir; // added to open directories
};

일반 파일을 열 경우에는 해당 field는 NULL로 맞춰두고, 디렉토리 inode를 연다면 해당 필드를 dir_open을 통해 열도록 한다.

 

cwd를 위해 thread와 구조체는 다음과 같이 수정한다.

struct thread {
	...
	struct dir *cwd;

	/* Owned by thread.c. */
	struct intr_frame tf;               /* Information for switching */
	unsigned magic;                     /* Detects stack overflow. */
};

이제 해당 field를 초기화하는 루틴을 추가한다.

3-1. open & is_dir 필드 관련 수정

먼저 open 관련 루틴부터 수정해보자. open에서 주요하게 사용되는 함수는 fd_allocate이며 여기서 dir에 대한 초기 설정이 이루어지도록 하고, 

int fd_allocate (struct file *fp)
{
	struct list_elem *e;
	struct fildes *tmp, *fd_entry;
	struct thread *curr = thread_current();

	int fd = 0;

	for (e = list_begin(&curr->fd_list); e != list_end(&curr->fd_list); e = list_next(e))
	{
		tmp = list_entry(e, struct fildes, fd_elem);
		if (tmp->fd == fd)
			fd++;
		else
			break;
	}

	fd_entry = (struct fildes *)malloc(sizeof(struct fildes));
	fd_entry->fd = fd;
	fd_entry->fp = fp;
	fd_entry->dup = NULL;
	if (inode_is_dir (file_get_inode (fp))) {
		fd_entry->dir = dir_open (inode_reopen (file_get_inode (fp)));
	}
	else {
		fd_entry->dir = NULL;
	}

	list_insert (e, &fd_entry->fd_elem);

	return fd;
}

여기서 사용된 inode_is_dir은 아래와 같으며,

bool
inode_is_dir (struct inode *inode) {
	return (inode->data.is_dir != 0);
}

fildes를 사용하는 fork와 exit, process_init등의 코드도 이에 따라 바꿔주도록 한다. 코드 길이가 너무 길어 모두 수록하지는 않았다. 해당 방식으로 open을 하게 되면 fd_entry->dir이 NULL인지 아닌지로 isdir 검사를 할 수 있다. 그럼 애초에 inode의 is_dir 설정은 어떻게 할 것인가를 결정해야 한다. 이는 filesys_create를 수정하고 mkdir을 만들 때 고려하도록 하자.

3-2. 디렉토리 파싱

아래와 같이 주어진 전체 디렉토리를 최후의 파일 또는 디렉토리와 그 부모 디렉토리의 string으로 나누는 함수와 나눠진 string을 이용해서 directory를 여는 함수를 만들어보자. 기본적으로 부모 디렉토리까지 열 수 있으면 나머지 기능들은 기존에 구현된 함수들을 사용할 수 있기 때문에 해당 기능으로도 충분하다. 메모리 할당 및 해제 문제를 해결하기 위해 파싱하는 함수는 함수를 호출하기 전에 확보된 공간에 파싱 결과를 넣도록 한다.

void
parse_dir_name (char *path, char *dir, char *name) {
	size_t cnt = 0, i;
	char *path_copy = (char *) malloc (strlen (path) + 1);
	char *token, *ptr = NULL;

	strlcpy(path_copy, path, strlen(path) + 1);
	for (token = strtok_r(path_copy, "/", &ptr); token != NULL; token = strtok_r(NULL, "/", &ptr), cnt++);

	if (cnt == 0) {
		strlcpy (dir, "/", 2);
		strlcpy (name, "", 1);
	}

	else {
		strlcpy(path_copy, path, strlen(path) + 1);
		for (token = strtok_r(path_copy, "/", &ptr), i = 0; i < cnt - 1; token = strtok_r(NULL, "/", &ptr), i++);

		size_t diff = token - path_copy;
		strlcpy (name, token, strlen(token) + 1);
		
		strlcpy (dir, path, diff + 1);
		dir[diff] = '\0';
	}

	return;
}

struct dir *
dir_traverse (char *dir_path)
{
	struct thread *curr = thread_current ();
	char *path_copy = (char *) malloc (strlen (dir_path) + 1);
	char *token, *ptr = NULL;

	strlcpy(path_copy, dir_path, strlen(dir_path) + 1);
	struct dir *cur_dir;
	struct inode *inode;

	if (path_copy[0] == '/') {
		cur_dir = dir_open_root ();
	}

	else {
		cur_dir = dir_reopen (curr->cwd);
	}

	for (token = strtok_r(path_copy, "/", &ptr); token != NULL; token = strtok_r(NULL, "/", &ptr))
	{
		if (strcmp (token, ".") == 0) {
			continue;
		}

		else if (strcmp (token, "..") == 0) {
			// open parent inode
			inode = inode_open_parent (dir_get_inode (cur_dir));
			dir_close (cur_dir);
			cur_dir = dir_open (inode);
		}

		else {
			if (dir_lookup (cur_dir, token, &inode))
			{
				if (inode_is_dir (inode)) { // confirm that it is dir
					dir_close (cur_dir);
					cur_dir = dir_open (inode);
				}

				else {
					dir_close (cur_dir);
					free (path_copy);
					return NULL;
				}
			}
			
			else {
				dir_close (cur_dir);
				free (path_copy);
				return NULL;
			}
		}
	}

	free (path_copy);
	return cur_dir;
}

이 때 사용된 inode_open_parent는 다음과 같이 구현하였고,

struct inode *
inode_open_parent (struct inode *inode) {
	if (inode->data.parent_sector != 0)
		return inode_open (inode->data.parent_sector);
	return NULL;
}

data의 parent_sector도 filesys_create와 mkdir에서 설정해줄 것이다.

3-3. filesys_create 및 mkdir

이제 inode의 parent_sector와 is_dir field를 설정하기 위한 함수인 filesys_create와 mkdir을 수정, 구현해보자. 일단 기본적으로 inode_create에서는 is_dir은 0으로, parent_sector은 ROOT_DIR_CLUSTER의 섹터로 설정하도록 한다.

bool
inode_create (disk_sector_t sector, off_t length) {
	struct inode_disk *disk_inode = NULL;
	bool success = false;

	ASSERT (length >= 0);

	/* If this assertion fails, the inode structure is not exactly
	 * one sector in size, and you should fix that. */
	ASSERT (sizeof *disk_inode == DISK_SECTOR_SIZE);

	disk_inode = calloc (1, sizeof *disk_inode);
	if (disk_inode != NULL) {
		size_t sectors = bytes_to_sectors (length);
		disk_inode->length = length;
		disk_inode->magic = INODE_MAGIC;
		disk_inode->is_dir = 0;
		disk_inode->parent_sector = cluster_to_sector(ROOT_DIR_CLUSTER);

		disk_inode->start_clst = 0;
		if (!inode_append_chain (sectors, &disk_inode->start_clst)) {
			// if allocation fails, unchain
			if (disk_inode->start_clst != 0) {
				fat_remove_chain (disk_inode->start_clst, 0);
			}
			goto fat_chain_fail;
		}

		// chain allocation done
		disk_write (filesys_disk, sector, disk_inode);
		success = true;

fat_chain_fail:
		free (disk_inode);
	}
	return success;
}

기본적으로 생성할 때 뭔가를 추가하려면 함수 원형을 건드려야 해서 추후 수정으로 변경하는 다음의 함수를 만든다.

bool
inode_set_parent (disk_sector_t cur_sec, disk_sector_t parent_sec)
{
	struct inode *inode = inode_open (cur_sec);
	inode->data.parent_sector = parent_sec;
	disk_write (filesys_disk, cur_sec, &inode->data);
	inode_close (inode);

	return true;
}

bool
inode_set_dir (disk_sector_t cur_sec, bool is_dir) {
	struct inode *inode = inode_open (cur_sec);
	inode->data.is_dir = (is_dir? 1 : 0);
	disk_write (filesys_disk, cur_sec, &inode->data);
	inode_close (inode);

	return true;
}

이 함수들을 사용해 구현할 것이다. filesys_create에서는 is_dir을 설정할 필요는 없으나, 혹시 모르니 부모 디렉토리 설정은 해 주도록 하자.

bool
filesys_create (const char *name, off_t initial_size) {
	cluster_t inode_clst = fat_create_chain (0); // newly create cluster(sector) to hold file metadata
	
	char filename[NAME_MAX + 1] = {0, };
	char dir_path[FULLPATH_MAX + 1] = {0, };

	parse_dir_name (name, dir_path, filename);
	struct dir *dir = dir_traverse (dir_path);

	bool success = (dir != NULL
			&& inode_clst != 0
			&& inode_create (cluster_to_sector (inode_clst), initial_size)
			&& dir_add (dir, filename, cluster_to_sector (inode_clst))
			&& inode_set_parent (cluster_to_sector (inode_clst), inode_get_inumber (dir_get_inode(dir))));
	if (!success && inode_clst != 0)
		fat_remove_chain (inode_clst, 0);
	dir_close (dir);

	return success;
}

다음으로는 mkdir이다. mkdir에서는 필수적으로 is_dir과 parent_sector를 모두 설정할 필요가 있어서 다음과 같이 구현하였다.

bool
make_directory (const char *name) {
	char final_dir[NAME_MAX + 1] = {0, };
	char dir_path[FULLPATH_MAX + 1] = {0, };

	parse_dir_name (name, dir_path, final_dir);
	struct dir *parent_dir = dir_traverse (dir_path);
	
	cluster_t inode_clst = fat_create_chain (0); // newly create cluster(sector) to hold file metadata
	
	bool success = (parent_dir != NULL
					&& inode_clst != 0
					&& dir_create (cluster_to_sector (inode_clst), 4)
					&& dir_add (dir_path, final_dir, cluster_to_sector (inode_clst))
					&& inode_set_parent (cluster_to_sector (inode_clst), inode_get_inumber (dir_get_inode(dir_path)))
					&& inode_set_dir (cluster_to_sector (inode_clst), true));
	if (!success && inode_clst != 0)
		fat_remove_chain (inode_clst, 0);
	dir_close (parent_dir);

	return success;
}

bool _mkdir (const char *dir)
{
	filesys_enter ();
	bool res = make_directory (dir);
	filesys_exit ();
	return res;
}

구조 자체는 filesys_create와 비슷하다.

3-4. 나머지 새로운 syscall 구현

위의 함수들을 모두 구현한 뒤에는 간단간단한 내용이라 비교적 빨리 구현할 수 있었다. 

bool _chdir (const char *dir)
{
	// parse the directory
	// confirm the result is directory
	filesys_enter ();
	struct dir *new_dir = dir_traverse (dir);
	
	if (new_dir == NULL) {
		filesys_exit ();
		return false;
	}

	struct thread *curr = thread_current();
	dir_close (curr->cwd);
	curr->cwd = new_dir;

	filesys_exit ();
	return true;
}

bool _readdir (int fd, char *name)
{
	bool res = false;

	filesys_enter();
	struct fildes *fd_entry = fd_search (fd);

	if (fd_entry != NULL && fd_entry->dir != NULL) {
		res = dir_readdir (fd_entry->dir, name);
	}
	
	filesys_exit ();
	return res;
}

bool _isdir (int fd)
{
	bool res = false;
	filesys_enter();
	struct fildes *fd_entry = fd_search (fd);
	if (fd_entry != NULL)
		res = (fd_entry->dir != NULL);
	filesys_exit ();
	return res;
}

int _inumber (int fd)
{
	int res = -1;
	filesys_enter ();
	struct fildes *fd_entry = fd_search (fd);
	if (fd_entry != NULL && fd_entry->fp != 0 && fd_entry->fp != 1)
		res = inode_get_inumber (file_get_inode (fd_entry->fp));
	filesys_exit ();
	return res;
}

3-5. remove 수정

directory를 삭제할 때는 무조건 해당 디렉토리는 비어있어야 하고, 열려있지 않아야 한다. inode를 지울 때는 해당 inode가 디렉토리인지 우선 검사를 하도록 한다. 만약 해당 조건을 충족한다면 열려있는 것은 open_cnt로 검사하도록 하고, 디렉토리가 비었는지는 dir_readdir로 검사하도록 하자.

bool
inode_safe_to_del (struct inode *inode) {
	if (inode->open_cnt > 1) {
		return false;
	}

	// only remove routine opens the inode, check emptiness
	struct dir *tmp = dir_open (inode_reopen(inode));
	char name[NAME_MAX + 1] = {0, };
	bool res = !dir_readdir (tmp, name);
	
	dir_close (tmp);
	return res;
}

bool
dir_remove (struct dir *dir, const char *name) {
	struct dir_entry e;
	struct inode *inode = NULL;
	bool success = false;
	off_t ofs;

	ASSERT (dir != NULL);
	ASSERT (name != NULL);

	/* Find directory entry. */
	if (!lookup (dir, name, &e, &ofs))
		goto done;

	/* Open inode. */
	inode = inode_open (e.inode_sector);
	if (inode == NULL)
		goto done;

	if (inode_is_dir (inode)) {
		if (!inode_safe_to_del (inode))
			goto done;
	}

	/* Erase directory entry. */
	e.in_use = false;
	if (inode_write_at (dir->inode, &e, sizeof e, ofs) != sizeof e)
		goto done;

	/* Remove inode. */
	inode_remove (inode);
	success = true;

done:
	inode_close (inode);
	return success;
}

bool
filesys_remove (const char *name) {
	char filename[NAME_MAX + 1] = {0, };
	char dir_path[FULLPATH_MAX + 1] = {0, };

	parse_dir_name (name, dir_path, filename);
	struct dir *dir = dir_traverse (dir_path); // dir_open_root ();
	bool success = dir != NULL && dir_remove (dir, filename);
	dir_close (dir);

	return success;
}

이러면 대충 끝난 것 같다.

 


4. 디버깅

make check를 하니 userprog에 들어서자마자 프로세스들이 죽어버린다. 이러면 파싱에서 문제가 생긴 것 같은데 살펴본다. 분석해보면 filesys_create 내에서 dir_traverse를 하면서 터지는 것 같다. 맨 처음에 kernel thread에서 필요한 프로그램을 파일 시스템에 넣기 위해 filesys_create와 filesys_open을 하는데, 해당 과정에서 dir_traverse가 일어나면 설정되지 않은 curr->cwd인 NULL을 참조하면서 프로세스가 터지는 것 같다. 따라서 dir_traverse를 아래와 같이 수정을 해주도록 한다.

struct dir *
dir_traverse (char *dir_path)
{
	struct thread *curr = thread_current ();
	char *path_copy = (char *) malloc (strlen (dir_path) + 1);
	char *token, *ptr = NULL;

	strlcpy(path_copy, dir_path, strlen(dir_path) + 1);
	struct dir *cur_dir;
	struct inode *inode;

	if (path_copy[0] == '/') {
		cur_dir = dir_open_root ();
	}

	else {
		if (curr->cwd == NULL) {
			cur_dir = dir_open_root ();
		}
		else {
			cur_dir = dir_reopen (curr->cwd);
		}
	}
	...
}

이제 기존 프로그램은 다시 돌아가기 시작한다. 근데 아직 문제가 있었다. make_directory에서 parent_dir로 적어야 할 것을 dir_path로 적어서 생긴 문제였다. 바로 수정하였다. 또한, 현재 이름이 비어있는 케이스에 대한 핸들링이 되지 않고 있어서 해당 경우 또한 고려하도록 filesys_create와 mkdir의 코드를 수정하였다.

bool
filesys_create (const char *name, off_t initial_size) {
	cluster_t inode_clst = fat_create_chain (0); // newly create cluster(sector) to hold file metadata
	
	char filename[NAME_MAX + 1] = {0, };
	char dir_path[FULLPATH_MAX + 1] = {0, };

	parse_dir_name (name, dir_path, filename);
	struct dir *dir = dir_traverse (dir_path);
	
	bool success = (dir != NULL && strcmp (filename, "") != 0
			&& inode_clst != 0
			&& inode_create (cluster_to_sector (inode_clst), initial_size)
			&& dir_add (dir, filename, cluster_to_sector (inode_clst))
			&& inode_set_parent (cluster_to_sector (inode_clst), inode_get_inumber (dir_get_inode(dir))));
	if (!success && inode_clst != 0)
		fat_remove_chain (inode_clst, 0);
	dir_close (dir);

	return success;
}

bool
make_directory (const char *name) {
	char final_dir[NAME_MAX + 1] = {0, };
	char dir_path[FULLPATH_MAX + 1] = {0, };

	parse_dir_name (name, dir_path, final_dir);
	struct dir *parent_dir = dir_traverse (dir_path);
	cluster_t inode_clst = fat_create_chain (0); // newly create cluster(sector) to hold file metadata
	
	bool success = (parent_dir != NULL && strcmp (final_dir, "") != 0
					&& inode_clst != 0
					&& dir_create (cluster_to_sector (inode_clst), 4)
					&& dir_add (parent_dir, final_dir, cluster_to_sector (inode_clst))
					&& inode_set_parent (cluster_to_sector (inode_clst), inode_get_inumber (dir_get_inode(parent_dir)))
					&& inode_set_dir (cluster_to_sector (inode_clst), true));
	if (!success && inode_clst != 0)
		fat_remove_chain (inode_clst, 0);
	dir_close (parent_dir);

	return success;
}

다시 make check를 돌린다. 이제 create_long이 터진다. parse_dir_name을 할 때 파일 이름이 너무 길어 버퍼 오버플로우가 터지는 것 같은데, 이를 방지하기 위해 파일의 길이를 검사하고, 만약 길이 초과라면 빈 파일이름을 반환함으로서, 위의 루틴에서 잡아내도록 한다.

void
parse_dir_name (char *path, char *dir, char *name) {
	size_t cnt = 0, i;
	char *path_copy = (char *) malloc (strlen (path) + 1);
	char *token, *ptr = NULL;

	strlcpy(path_copy, path, strlen(path) + 1);
	for (token = strtok_r(path_copy, "/", &ptr); token != NULL; token = strtok_r(NULL, "/", &ptr), cnt++);

	if (cnt == 0) {
		if (path[0] == '/') {
			strlcpy (dir, "/", 2);
			name[0] = '\0';
		}

		else {
			dir[0] = '\0';
			name[0] = '\0';
		}
	}

	else {
		strlcpy(path_copy, path, strlen(path) + 1);
		for (token = strtok_r(path_copy, "/", &ptr), i = 0; i < cnt - 1; token = strtok_r(NULL, "/", &ptr), i++);

		size_t diff = token - path_copy;

		if (strlen(token) > NAME_MAX)
			name[0] = '\0';
		else
			strlcpy (name, token, strlen(token) + 1);
		
		strlcpy (dir, path, diff + 1);
		dir[diff] = '\0';
	}

	return;
}

이러면 create_long은 다시 정상궤도에 돌아간다. 다시 make check를 한다. 이젠 dir_open에서 터진다. 아직 디렉토리를 연 fd에 대한 핸들링이 없어서 터지는 것 같다. 귀찮아서 나중에 일괄적으로 해주려고 했는데 이를 검사할 줄은 몰랐지... 고쳐준다.

 

이제 남은 것은 dir_rm_cwd와 dir_vine 두 개이다. 두 가지 모두에서 위 구현의 한계가 드러난다. 최종적으로 "."이나 "..", root 디렉토리를 열지 못한다는 것인데, 이를 수정하도록 한다.

struct file *
filesys_open (const char *name) {
	char filename[NAME_MAX + 1] = {0, };
	char dir_path[FULLPATH_MAX + 1] = {0, };

	parse_dir_name (name, dir_path, filename);
	struct dir *dir = dir_traverse (dir_path); //dir_open_root ();
	struct inode *inode = NULL;

	if (dir != NULL) {
		if (name[0] != '\0' && filename[0] == '\0') { // implying root
			inode = inode_open (cluster_to_sector (ROOT_DIR_CLUSTER));
		}

		else if (strcmp (filename, ".") == 0) {
			inode = inode_reopen (dir_get_inode (dir));
		}

		else if (strcmp (filename, "..") == 0) {
			inode = inode_open_parent (dir_get_inode (dir));
		}

		else {
			dir_lookup (dir, filename, &inode);
		}
	}
	dir_close (dir);

	return file_open (inode);
}

다시 검사한다. 이제 모두 symbolic link와 관련된 케이스를 제외한 모든 테스트가 통과한다. 엑스트라는 신경쓰지 말자.

SUMMARY BY TEST SET

Test Set                                      Pts Max  % Ttl  % Max
--------------------------------------------- --- --- ------ ------
tests/threads/Rubric.alarm                      7/  7   2.0%/  2.0%
tests/threads/Rubric.priority                  25/ 25   3.0%/  3.0%
tests/userprog/Rubric.functionality            40/ 40  10.0%/ 10.0%
tests/userprog/Rubric.robustness               40/ 40   5.0%/  5.0%
tests/vm/Rubric.functionality                  82/ 82   8.0%/  8.0%
tests/vm/Rubric.robustness                     29/ 29   2.0%/  2.0%
tests/filesys/base/Rubric                      17/ 17  10.0%/ 10.0%
tests/filesys/extended/Rubric.functionality    34/ 49  17.3%/ 25.0%
tests/filesys/extended/Rubric.robustness       10/ 10  15.0%/ 15.0%
tests/filesys/extended/Rubric.persistence      23/ 26  17.7%/ 20.0%
tests/filesys/mount/Rubric                      0/  1   0.0%/ 20.0%
tests/filesys/buffer-cache/Rubric               0/  1   0.0%/ 20.0%
--------------------------------------------- --- --- ------ ------
Total                                                  90.0%/140.0%

이제 나머지 매뉴얼을 보고 플젝 4도 끝내자.

 


5. 후기

사실 플젝 4를 진행하면서 이걸 지금 진행하는 것이 맞을까라는 의문이 들었다. 현재 매뉴얼도 그렇고 테스트 상태도 그렇고 제대로 된 것이 없어보였기 때문이다. 플젝 3까지는 길이 잘 닦여있었는데, 플젝 4는 아직 공사가 덜 마무리된 느낌? 그래도 엑스트라를 구현하지 않는다면 적당히 끝낼 수는 있을 것 같아서 일단 코딩은 진행하고 있다. 다행스럽게도, buffer cache부터 엑스트라라고 규정한 것 같으니 다음에 진행할 것이 마지막이 될 것이다.

Comments