Container 내부가 궁금해서 뜯어보았습니다.
최근 쿠버네티스에 대한 관심이 높아지면서 컨테이너를 사용하는 기업들이 점차 늘어나고 있습니다. 컨테이너는 독립적인 공간을 따로 사용할 수 있고, 이식성이 좋으며, 무엇보다도 재기동이 상당히 빠르다는 장점이 있습니다. 마이크로 서비스를 구성하는 경우에는 컨테이너가 더더욱 좋은 옵션이 될 수 있습니다.
그래서 컨테이너를 꼭 배워야 하나요?
저는 개인적으로 컨테이너를 배우면 좋다고 생각합니다. 많이 사용하는 솔루션이라서도 있지만, 개발을 하는 분이나 운영을 하는 분이나 컨테이너를 활용해서 할 수 있는 것들이 상당히 많아집니다. 찾아보시면 컨테이너의 개념과 docker 사용법에 대해서 쉽게 설명해놓은 블로그들이 많습니다. 온라인 강의 사이트에서 기본 강의만 들어도 손쉽게 docker를 설치하고 사용하실 수 있게 됩니다.
그런데.. 실제로 컨테이너를 자세히 뜯어보는 경우는 드뭅니다. 내부를 자세히 뜯어보지 않아도 컨테이너를 사용하는데 지장이 없기 때문입니다. 그러나, 내부 구조를 정확히 이해하게 되면 트러블 슈팅이나 시스템 고도화 시에 상당히 유용할 수 있습니다.
따라서 본 포스트에서는 여러 블로그 + 문서 + 너튜브 강의 등을 뒤지면서 공부한 컨테이너 내부구조를 한 번 알아보도록 하겠습니다.
먼저, 컨테이너를 띄우기 위해서 AWS EC2에 VM 한 대를 설치하겠습니다.
| 인스턴스 사양
- 타입: t3.large (2vCPU, 8 GiB Mem)
- 디스크: 300GB
- 운영체제: Ubuntu 18.04
- 설치 스크립트
$ curl -fsSL https://get.docker.com/ | sudo su
$ sudo usermod -aG docker ubuntu
$ sudo reboot
컨테이너는 쉽게 말하면 독립된 환경을 의미합니다.
그렇다면 독립된 환경이라는 건 도대체 무엇을 의미하는 걸까요?
VM도 일종의 독립된 환경입니다. 제가 클라우드에 서버를 한 대 띄우면, 다른 사용자들이 만든 인스턴스와 완전히 독립된 서버 환경을 제공받게 됩니다. 즉, VM도 크게 보면 일종의 컨테이너라고 볼 수 있습니다.
구체적으로 독립된 환경은 리눅스의 cgroup과 namespace를 통해서 구현됩니다. cgroup은 가용할 수 있는 자원의 제한을 설정하는 것이고, namespace는 볼 수 있는 자원의 범위를 의미합니다. 말로 이해하는 건 그리 어렵지 않지만, 실제로 작동하는 걸 확인해보는 건 쉽지 않습니다.
천천히 하나씩 해보겠습니다.
먼저 네임스페이스입니다. 네임스페이스의 원리를 손쉽게 확인할 수 있는 방법은 바로 chroot를 통해서 root 파일시스템을 변경해보는 것입니다.
먼저 샘플용 rootfs를 다운로드 받습니다.
$ wget https://github.com/ericchiang/containers-from-scratch/releases/download/v0.1.0/rootfs.tar.gz
다운로드를 받고 앞축을 풀면 아래와 같이 root 디렉토리와 같은 구조의 디렉토리가 생성됩니다.
자, 이제 이 rootfs 디렉토리를 루트 디렉토리로 변경해보겠습니다. 이 작업이 완료되면, rootfs 디렉토리가 / (루트 경로)로 변경됩니다.
실제로 들어가보니 rootfs가 루트 경로로 변경된 것을 보실 수 있습니다. 이 작업이 완료되면, 해당 bash 프로세스에서는 rootfs 디렉토리를 제외하고는 그 외의 디렉토리를 볼 수가 없습니다. 왜냐하면 rootfs 디렉토리가 루트 디렉토리로 변경되었고, 루트 디렉토리보다 상위 디렉토리는 존재하지 않기 때문입니다. 오해하시면 안되는게, 실제로는 rootfs 디렉토리보다 상위 디렉토리가 존재합니다. 그러나 chroot를 통해서 실행한 bash 프로세스 내에서는 그것들이 보이지 않을 뿐입니다. 다시 말해, 파일시스템에 있어서 별도의 namespace가 적용되었음을 알 수 있습니다.
namespace는 다시 말씀드리면, 특정 프로세스가 볼 수 있는 자원들을 규정해놓은 설정입니다. 위의 예시에서는 파일시스템에만 적용되어 있습니다.
그러면, 위 프로세스에서 시스템의 전체 프로세스를 볼 수 있을까요?
정답은, yes입니다
실제로 한 번 해보겠습니다. 먼저 proc 디렉토리를 마운트합니다. proc 디렉토리는 프로세스에 대한 정보가 들어있는 공간이고 특정 메모리에 들어있지만 mount를 통해서 디렉토리 형태로 나타납니다.
전체 프로세스가 다 보이는 것을 확인하실 수 있습니다. 이것이 가능한 이유는 프로세스에 대해서는 네임스페이스를 따로 적용하지 않았기 때문입니다. 즉, 자식 프로세스 생성 시에 아무런 네임스페이스를 주지 않으면 부모의 네임스페이스를 따라가는 것을 보실 수 있습니다.
그렇다면, 이번에는 한 번 프로세스에도 네임스페이스를 적용해보겠습니다. 기존의 bash 프로세스에서 나와서 host VM에서 다시 시작하겠습니다. 프로세스에 네임스페이스를 적용하기 위해서 기존에 마운트 시켰던 /proc이 아니라, 다운로드 받은 rootfs/proc을 프로세스 볼륨으로 마운트 시키겠습니다. 이 말인 즉슨, 호스트에 있던 프로세스를 볼 수가 없고, rootfs/proc에 생긴 프로세스만 볼 수 있다는 의미입니다.
$ sudo unshare -p -f --mount-proc=$PWD/proc chroot . /bin/bash
보이시나요!?
ps -ef 명령어(전체 프로세스 보기)를 사용해도 프로세스가 두 개 밖에 보이지 않습니다. 그리고 잘 보시면 /bin/bash 프로세스가 PID 1번이 되어 있음을 보실 수 있습니다. 이 말은 /bin/bash가 init 프로세스라는 것을 의미합니다. 하지만, 실제로 host에서 보면 해당 프로세스는 init 프로세스가 아닙니다. 일반적인 프로세스와 다를 바가 없는데, 해당 프로세스 내에서는 마치 init 프로세스처럼 보이는 것입니다.
이를 증명해보겠습니다.
다른 터미널 창을 켜서, 다시 VM에 접속하도록 하겠습니다. 왼쪽이 unshare를 통해서 프로세스의 네임스페이스를 변경한 프로세스이고, 오른쪽은 host VM에 접속한 bash 프로세스 입니다.
오른쪽 결과를 자세히 보시면 15266번 프로세스에 /bin/bash 프로세스가 보이실 겁니다. 부모 프로세스는 15265번으로 unshare~ 명령어를 실행한 프로세스이고, 그의 부모는 15264번으로 sudo unshare~ 명령어 프로세스입니다. 저희가 위 실습에서 사용한 명령어로부터 파생된 /bin/bash는 15266번임을 알 수 있습니다.
즉, 왼쪽의 PID 1번이 결국 오른쪽의 PID 15266번이 되는 것입니다.
지금까지 네임스페이스가 달라지는 것이 어떤 의미를 가지는지 실습을 통해서 알아보았습니다.
이제는 cgroup에 대해서 한 번 알아보겠습니다. cgroup은 다시 한 번 말씀드리면, 사용할 수 있는 자원에 대한 제한(max)을 의미합니다.
위의 프로세스는 종료하고 새로운 bash 창을 열어서 실습하겠습니다.
| 과정은 아래와 같습니다.
1. root권한으로 cgroup을 생성합니다. (이름은 dayone으로 하겠습니다)
2. 해당 cgroup의 memory 할당 최대치를 100MB를 입력하고, swap은 0으로 설정합니다. 테스트 시에 100M이상을 할당하려고 하면 에러를 내도록 하기 위함입니다.
3. 해당 cgroup의 task에 현재 bash 프로세스의 PID를넣습니다. 즉, 해당 프로세스에 위의 제약사항을 적용하는 것입니다.
4. 그리고 샘플 프로그램을 돌려봅니다.
5. 다시 memory 할당 최대치를 200MB으로 변경하고 다시 4번을 진행합니다.
그러면 한 번 시작해보겠습니다.
1,2번 작업이 마무리 되었습니다. 여기까지가 cgroup을 생성해서 메모리 제한을 설정하는 부분입니다. 이제 프로세스에 적용해서 테스트해보겠습니다.
echo $$ 는 현재 쉘의 프로세스 ID를 볼 수 있는 명령어입니다. ps를 통해서 살펴본 것처럼 현재 bash의 PID는 15503번이고 해당 번호가 /sys/fs/cgroup/memory/dayone/task에 들어가 있는 것을 확인하실 수 있습니다.
테스트를 돌려보면 아래와 같이 100M가 될 때쯤 테스트가 종료됨을 알 수 있습니다.
이제, memory를 200MB로 높이고 테스트해보겠습니다.
이번에는 200MB에서 Killed 된 것을 보실 수 있습니다.
이렇게 /sys/fs/cgroup 에서 메모리의 limit을 적용함으로써 특정 프로세스에 독립적인 리소스 제한을 걸어줄 수 있습니다.
지금까지 namespace와 cgroup을 통해서 어떻게 특정 프로세스가 독립된 환경을 가질 수 있는지 알아보았습니다. 여기서 중요한 것은 프로세스에 namespace와 cgroup을 적용한다는 점입니다. 즉, 컨테이너도 하나의 프로세스에 불과합니다. 호스트에서 실행되고 namespace와 cgroup이 적용된 프로세스가 곧 컨테이너입니다.
이제 컨테이너가 무엇인지, 어떤 원리로 만들어지는지 이해하셨나요?
그러면 지금부터는 실제 Docker엔진을 통해서 컨테이너를 만들고 위의 적용사항을 확인해보겠습니다.
흔히, 우리는 컨테이너와 도커를 혼용해서 쓰는 경우가 많습니다. 둘이 비슷하게 말해도 대부분 제대로 알아듣겠지만, 실제로 둘은 엄연히 다릅니다.
Docker는 containerd를 활용하여 컨테이너를 생성, 관리하는 서비스로 docker.sock을 통해 통신합니다. 도커 서비스는 docker cli를 docker.sock을 통해 받아서 containerd로 요청을 보내주는 역할을 합니다. 이는 docker service를 열어보면 확인하실 수 있습니다.
위의 그림을 보시면 --containerd (컨테이너 데몬)을 명시하는 부분과 docker.sock을 통해서 API를 리스닝하고 있는 것을 보실 수 있습니다. 따라서 docker engine자체가 컨테이너를 생성해주는 것은 아닙니다.
컨테이너를 직접 생성하고 관리해주는 것은 바로 containerd입니다. docker와 마찬가지로 containerd 서비스를 조회해보시면 아래와 같이 정보를 보실 수 있습니다.
Containerd는 container runtime을 관리하는 매니저 서비스입니다. docker의 경우에는 runtime으로 runc를 사용합니다. runc는 실제로 컨테이너를 생성하는데, 이때 cgroup과 namespace를 만들어주고 값을 넣어줍니다. 즉, containerd는 docker로부터 요청을 받아서 runc를 통해 컨테이너(=프로세스)를 생성하는 구조가 됩니다.
그런데, containerd가 직접 runc랑 소통하는 경우에는 문제가 발생합니다. 대표적으로, 컨테이너 매니저인 containerd는 여러가지 이유로 재시작을 할 수도 있는데 이때 runc 프로세스까지 모두 재시작이 되어버릴 수 있습니다. 이러한 사태(?)를 막기 위해서 소통을 위한 중간 매개체를 넣어서 관리하는데, 그것이 바로 shim입니다. shim은 컨테이너 runtime과 containerd 사이에서 소통을 담당하고, runtime이 독립적으로 돌 수 있도록 만들어줍니다.
그림으로 구조를 정리해보면 아래와 같습니다.
이제 docker, containerd, shim, runc에 대해서 구조가 이해되셨나요?
이해가 어렵더라도 괜찮습니다. 아래 실습을 통해서 실제로 작동하는 것을 확인해보시면 훨씬 이해하기 수월하실 겁니다.
이제부터는 진짜 docker 명령어를 통해서 컨테이너를 띄우는 실습을 해보도록 하겠습니다.
docker를 설치하시게 되면 systemd의 자식 프로세스로 docker service가 생기고 시작됩니다. (참고로 systemd가 init 프로세스입니다.)
이 상태에서 docker 명령어를 치면 해당 docker 명령어는 /var/run/docker.sock을 통해서 docker service에 API 요청을 전달하고, docker 엔진은 containerd에 요청을 보내게 됩니다.
그러면 한 번 컨테이너를 띄워보겠습니다.
docker run -it -d ubuntu:16.04 /bin/bash
docker run 명령어는 컨테이너를 실행하는 명령어입니다. i 옵션은 interactive 모드를 의미하고, t는 tty 할당을 의미합니다. d는 컨테이너를 background에서 실행해달라는 의미입니다.
위의 예시에서 보시면 컨테이너 059181bf2b47이 떠 있는 것을 보실 수 있습니다. 이미지는 ubuntu16.04를 사용하였고, init 커맨드로는 /bin/bash를 사용했습니다. 즉, 해당 컨테이너 내부로 들어가면, os는 ubuntu16.04가 되고 /bin/bash가 init 프로세스가 되게 되는 것입니다.
직접 확인해보겠습니다.
/etc/os-release를 보시면 해당 서버의 운영체제 정보를 보실 수 있습니다.
아래 예시를 보시면, 호스트의 경우에는 Ubuntu18.04를 쓰고 있는데 컨테이너 내부에 들어가보면 Ubuntu16.04를 사용하고 있는 것을 확인하실 수 있습니다.
또, init 프로세스(PID 1)이 /bin/bash로 설정되어 있는 것도 보실 수 있을 겁니다.
그런데 컨테이너에서 exit을 하니까 컨테이너가 사라졌습니다. 이유는 위에서 언급했듯이 docker attach를 통해 접속했던 /bin/bash 프로세스가 init이었기 때문입니다. init프로세스를 종료했으니 당연히 그 하위 프로세스가 모두 종료되고, 결국 컨테이너(= 프로세스)가 종료된 것입니다.
(다시 컨테이너 한 대를 띄우고... )
이번에는 프로세스 리스트를 확인해보겠습니다.
프로세스를 자세히 보시면 containerd-shim으로 시작하는 프로세스가 있고, runtime으로 runc를 사용하는 것을 보실 수 있습니다.
감 좋으신 분들은 이미 눈치 채셨겠지만, 실제 runc가 생성한 프로세스는 14082번이고 부모 프로세스가 바로containerd-shim(PID 14052)입니다. 굳이 해보지는 않겠지만 당연히 내부에 들어가면 /bin/bash가 init프로세스가 될 겁니다.
그러면 namespace와 cgroup을 한 번 확인해보겠습니다
컨테이너는 네임스페이스가 분리되어 있는 독립적인 프로세스라고 말씀드렸습니다. 진짜로 다른지 한 번 확인해보겠습니다.
네임스페이스의 경우에는 /proc/<프로세스 ID>/ns에 가시면 확인하실 수 있습니다. 따라서 init 프로세스와 컨테이너의 프로세스의 ns를 확인하시면 됩니다.
위의 예시를 보시면 cgroup과 user를 제외하고 다른 것들은 전부 값이 다른 것을 확인하실 수 있습니다. cgroup과 user도 사실은 다르지만, 무슨 이유때문인지 같게 나올 뿐입니다.
cgroup의 경우 /proc/<프로세스 ID>/cgroup을 보면 명확히 다르다는 것을 확인하실 수 있습니다.
cgroup의 각 항목에 매핑된 경로는 해당 프로세스가 적용된 cgroup의 경로입니다. 여기서 보면 도커가 어떤 규칙으로 cgroup을 생성하는지 확인하실 수 있습니다.
컨테이너를 띄우면 각각 고유한 컨테이너 ID를 부여받습니다. runc는 이 ID를 기반으로 cgroup내의 디렉토리를 구성합니다. (위에서 보신 것처럼 /docker/<container ID> 에 매핑되어 있습니다.)
베이스 디렉토리는 /sys/fs/cgroup/<항목> 입니다. 예를 들어서 메모리 제한을 보고 싶은 경우에는 /sys/fs/cgroup/memory/docker/<container ID>/memory.limit_in_bytes 의 값을 확인하시면 됩니다.
구분되는 건 확인했으니 제대로 적용되는지를 한 번 확인해보겠습니다.
위에서 했던 것과 동일하게 메모리 제한을 주면서 컨테이너를 띄워보겠습니다. docker를 실행할 때 메모리를 지정할 때는 --memory 옵션을 사용하시면 됩니다. 테스트를 위해 swap은 0으로 설정합니다.
메모리를 100MB로 지정했더니, 해당 컨테이너의 cgroup에 메모리 제한 값으로 104,857,600이 들어간 것을 보실 수 있습니다. 해당 값이 Byte단위이기 때문에 약 100MB가 됩니다.
이 상태에서 컨테이너로 들어가 이전에 돌렸던 mem_test.py를 다시 돌려보겠습니다.
실행에 앞서, 컨테이너 내에는 파이썬이 설치되어 있지 않으므로 아래 명령어로 파이썬을 설치합니다.
$ apt update && apt install -y python vim
아까와 동일하게 100MB가 될 쯔음 죽는 것을 보실 수 있습니다.
이번에는 200MB로 설정하고 다시 돌려보겠습니다.
해당 프로세스에서 exit을 하면 init프로세스가 죽어버리기 때문에, 다른 창을 열어서 설정값을 넣어보겠습니다.
이번에는 예상하신대로 200MB에서 죽은 것을 확인하실 수 있습니다.
결론적으로 아까 했던 실습이랑 동일합니다. 차이라고 하면, 아까는 제가 직접 cgroup을 만들어서 값을 넣어주었지만, 여기서는 docker->containerd->runc에 이르는 구조를 통해서 runc가 알아서 cgroup을 만들어주고 값을 넣어준다는 점입니다.
지금까지 docker로 컨테이너를 생성하고, 컨테이너의 cgroup, namespace를 확인하면서 컨테이너의 내부 구성을 살펴보았습니다.
마지막으로 컨테이너 이미지에 대해서 알아보겠습니다.
docker 컨테이너가 프로세스라면 이미지는 프로그램이라고 볼 수 있습니다. 컨테이너는 반드시 기본 베이스로 사용할 이미지를 지정해야 합니다. 하나의 이미지로 서로 다른 여러 컨테이너를 띄울 수도 있습니다.
도커는 이런 이미지를 어떻게 효율적으로 관리할까요?
도커는 내부적으로 overlay2 스토리지 드라이버를 사용합니다.(도커 버전에 따라 다를 수 있습니다.) overlay2는 여러 레이어를 쌓아서 디렉토리를 관리합니다. 도커로 컨테이너를 생성하면 아래와 같이 4개의 레이어가 생성됩니다.
LowerDir : 베이스 이미지로부터 생성된 레이어들의 경로. 베이스 이미지의 레이어 수만큼 생깁니다.
MergedDir: 실제 컨테이너에 적용된 레이어들의 합. 즉, 최신 변경사항까지 전부 적용된 디렉토리
UpperDir: 해당 레이어에서 변경사항이 발생하는 최상단 부분
WorkDir: 내부적으로 관리되는 디렉토리
위의 예시를 보시면 LowerDir에 해당하는 레이어가 총 5개가 있습니다.
6d09b64fd1b9ddbf4d6ac92432c5aed43a721b1d2ede793e0a413f1bd197cd41-init/diff
dda0c5369010e4e129789126422c93960ba6f1ddbb1ebfaa2431e9931f4957bc/diff
46393cd0a896cb59c84516cc4df1f264baf58e30b44e814ffd48ae36b3955d84/diff
1da56f9c096a79f6aeaaaef7a5dd454f608e3a6cc458a95a2baff9d66bc4c3b9/diff
2b8c953498cf5bf8d3159a6729a608996e5da8fa980e889ccd7822085c61d20b/diff
이 레이어들은 베이스 이미지 생성 시에 쌓인 레이어들을 의미합니다.
예를 들어서 mkdir -p /run/systemd 명령어를 실행한 부분을 위의 레이어에서 찾아보면 반드시 존재합니다. 레이어와 순서가 일치하기 때문에 위에서 두번째를 찾아보시면 /run/systemd 와 관련된 내용을 확인하실 수 있습니다.
그리고 자세히 보시면 LowerDir의 맨 상단 레이어는 UpperDir이기도 합니다. 결국 가장 나중에 쌓인 레이어 위에 변경사항을 적용하겠다는 의미입니다.
한 번 변경사항을 만들어보겠습니다.
아래와 같이 컨테이너에 접속해서 test 디렉토리를 만들었습니다.
docker diff 명령어를 사용해보니 test 디렉토리를 Add(A)한 것을 확인하실 수있습니다.
이제 UpperDir 레이어의 내용을 한 번 살펴보겠습니다. 위에서 변경사항을 발생시켰으니 UpperDir에 변경사항이 적용되어야 합니다.
test 디렉토리가 정상적으로 생성된 것을 확인하실 수 있습니다.
한 번 더 변경사항을 발생시키도록 하겠습니다. 이번에는 tmp 디렉토리를 삭제해보록 하겠습니다.
이번에도 예상하신 것처럼 변경사항이 UpperDir 경로에 저장되는 것을 확인하실 수 있습니다.
그러면 UpperDir에 있는 내용을 삭제하면 어떻게 될까요?
해보기 전에.. 이런 작업은 하지 않는 것을 권장드립니다. 왜냐하면 /var/lib/docker는 docker에서 직접 관리하는 부분이기 때문에 수동으로 작업을 진행하는 경우에 작업에 오류가 발생할 수 있습니다.
호기심 충족을 위해서 저는 한 번 건드려보겠습니다.
아까 지웠던 tmp 부분을 살리기 위해서 UpperDir에서 tmp디렉토리를 삭제해보겠습니다.
UpperDir에서 변경사항을 삭제하니 컨테이너 안에서 다시 tmp가 보이는 것을 확인하실 수 있습니다. 그러나 디렉토리가 보이더라도, 이는 정상적으로 복구된 것이 아닙니다. 에러 내용과 같이 cannot access 'tmp': No such file or directory가 나오고, 실제 diff 명령에서도 D /tmp 부분은 사라지지 않았습니다.
그러면 test를 추가로 지우면 어떻게 될까요?
test 디렉토리는 제가 임의로 추가한 것이라서 변경사항에도 A로 표시되어 있습니다. 이 test 디렉토리를 같은 방식으로 한 번 지워보도록 하겠습니다.
이번에는 변경사항이 정상적으로 적용된 것을 보실 수 있습니다. diff에서도 test를 생성한 부분이 사라졌고, 실제 컨테이너에서도 test 디렉토리는 찾을 수가 없습니다.
위 실험을 통해서 UpperDir의 변경 사항이 컨테이너에 직접적인 영향을 준다는 것을 확인하실 수 있습니다. 다만, tmp 사례와 같이 100% 예상된 결과를 얻으리라는 보장이 없습니다. 내부적으로 복잡한 매커니즘으로 파일을 관리하기 때문에 이를 전부 수동으로 작업하기는 힘듭니다.
따라서 /var/lib/docker 부분은 함부로 건드리지 않는 것을 권장드립니다!
본 장에서는 컨테이너의 내부 생성 원리를 리눅스 namespace 및 cgroup의 관점에서 살펴보았고, 도커 이미지가 관리되는 원리에 대해서도 알아보았습니다. 실제 서비스에서 컨테이너를 사용하는 경우에 디버깅이 훨씬 어렵습니다. 컨테이너 하나를 띄우기 위해서 docker -> containerd -> shim -> runc 까지 복잡한 과정을 거쳐야 하는데 어디서 에러가 발생할지 모르고, 또한 컨테이너가 여러대가 올라가 있는 경우에는 어떤 컨테이너가 문제인지 찾기 더욱 복잡해집니다.
다만, 컨테이너를 다른 일반 프로세스와 동일하다는 것을 이해하고 어떤 특별한 점을 가지고 있는 프로세스인지를 인지한다면 디버깅에 도움이 되실거라 믿습니다.
| 참고자료
https://ericchiang.github.io/post/containers-from-scratch/
https://docs.docker.com/storage/storagedriver/overlayfs-driver/#how-the-overlay2-driver-works
https://medium.com/better-programming/docker-for-front-end-developers-c758a44e622f
## 잘못된 부분이 있으면 언제든 피드백 부탁드립니다.
더 나은 글로 보답하겠습니다!
감사합니다