Pipeline 방식으로 젠킨스를 구축해 보자
이번 포스트에서는 설정과 사용 방법이 좀 더 복잡하지만 커스텀하기 좋고 세밀한 구성이 가능한 Pipeline 방식으로 Jenkins의 CI/CD를 모두 구현해보도록 한다. 이번에 적용시킬 CI/CD는 SpringBoot이다.
1. Jenkins CI(지속적 통합)를 위한 Script 추가 작성하기
1-1. 우선 Jenkins 대시보드 좌측 상단의 “새로운 Item” 버튼을 클릭해서 들어간다.
1-2. 아래와 같은 창이 나올것이고 여기서 Item의 이름을 적고 하단에서는 Pipiline을 선택한다.
1-3. Pipeline 작성하기
- 아이템을 생성하면 아래와 같이 생성한 파이프라인의 설정 페이지로 넘어갈 것이다. 위의 설정들은 skip하고 아래로 스크롤을 내려서 Pipeline 설정을 한다. 여기서 Definition 하단에서 "Pipeline script"를 선택하고 Script를 작성한다.
- Definition에서 선택하는 스크립트 작성 방법
- Pipeline Script(default):
- 젠킨스 웹 내에서 스크립트를 작성하여 관리한다.
- Pipeline Script from SCM:
- 프로젝트 내에서 Jenkinsfile에 스크립트를 작성하여 관리한다.
- Pipeline Script(default):
- 스크립트 작성 문법의 종류 설명
- Declarative Pipeline :
- 쉽게 작성이 가능하며 Groovy 문법 기반이지만 해당 문법을 몰라도 작성 가능하다. (최상단에 pipeline 이라고 되어 있으면 declarative 문법으로 작성된 것이다.)
- Scripted Pipeline :
- Groovy 문법 기반이며 Declarative 보다 효과적이고 많은 기능으로 포함해서 작성 가능한데 어렵다. (최상단에 node 지시어가 있으면 scripted 문법으로 작성된 것이다.)
- Groovy 문법 기반이며 Declarative 보다 효과적이고 많은 기능으로 포함해서 작성 가능한데 어렵다. (최상단에 node 지시어가 있으면 scripted 문법으로 작성된 것이다.)
- Declarative Pipeline :
1-4. 여기서는 Declarative Pipeline 방식으로 script를 작성한다.
- 아래와 같이 script를 작성하면 프로젝트 루트 디렉토리에서 직접 빌드가 수행된다. 첫번째 steps에서 git branch: 에 내가 클론할 프로젝트의 brach명을 적어주고 뒤에있는 url: 에는 clone받을 깃허브 repository 주소를 넣어준다. (맨뒤에 .git 을 꼭 넣어주자)
- git 스텝에서는 .git 확장자를 포함한 전체 Git URL을 사용해야 정확하게 리포지토리를 클론할 수 있다.
- git 스텝에서는 .git 확장자를 포함한 전체 Git URL을 사용해야 정확하게 리포지토리를 클론할 수 있다.
1-5. 오류가 발생한 스크립트 (테스트 코드로 인한 오류)
- 아래의 코드는 테스트 코드를 동작시키고 빌드하는 방식의 스크립트다. (EC2의 사양이 낮아서 timeout 오류로 빌드가 안된다.)
pipeline {
agent any
tools {
gradle 'jenkins_test'
}
stages {
stage('Git Clone') {
steps {
git branch: 'main', url: 'https://github.com/wlsdks/profile.git'
}
}
stage('Profile Build') {
steps {
sh "./gradlew clean build"
}
}
}
}
- 빌드 스크립트 작성시 주의사항
- 위의 스크립트의 tools안의 gradle 'jenkins_test' 이부분에 ‘jenkins_test’는 내가 jenkins의 자체적인 설정에 들어간 다음 tool에 들어가서 installation gradle에서 설정해준 gradle의 이름을 적어야 한다.
- 위의 스크립트의 tools안의 gradle 'jenkins_test' 이부분에 ‘jenkins_test’는 내가 jenkins의 자체적인 설정에 들어간 다음 tool에 들어가서 installation gradle에서 설정해준 gradle의 이름을 적어야 한다.
1-6. Jenkins 빌드오류 발생
- 위의 Pipeline Script를 적은 상태로 Jenkins 빌드를 시작했더니 아래와 같이 엄청난 대기시간이 이어졌고 내 EC2는 한계점을 초과해서 서버가 터져버렸다.
1-7. 테스트를 제거하고 빌드하는 방법 (빌드 성공)
- 테스트 코드를 제거하는 -x test를 하니까 성공했다.
- 내가 프로젝트에 80개가 넘는 상당히 많은 테스트코드를 작성했었는데 EC2의 사양이 안좋아서 Build가 안된것같다.
pipeline {
agent any
tools {
gradle 'jenkins_test'
}
stages {
stage('Git Clone') {
steps {
git branch: 'main', url: 'https://github.com/{myGit}/{myProject}.git'
}
}
stage('Profile Build') {
steps {
sh "./gradlew clean build -x test"
}
}
}
}
- 아래는 빌드에 성공한 사진이다.
2. Jenkins Item Pipeline 방식으로 CD 세팅하기
2-1. 가장 먼저 Jenkins 관리에서 SSH Agent를 설치한다.
- Jenkins 관리 → Plugins를 클릭한다.
- Available plugins 를 클릭하고 SSH Agent를 검색하고 Install한다.
2-2. Jenkins 재기동하기
- 설치가 완료되면 아래와 같은 페이지가 나온다. 하단의 “설치가 끝나고 실행중인 작업이 없으면 Jenkins 재시작”을 클릭해서 Jenkins를 재시작 해준다. (여기서 오류가 나서 서버가 멈춰서 EC2를 재기동했다.)
- 하단의 "설치가 끝나고 실행중인 작업이 없으면 Jenkins 재시작" 버튼을 눌렀더니 페이지가 바뀌면서 재시동 로딩을 하는데 이상하게 나는 Jenkins 재기동에 실패했다.(무한로딩이었다.) 그래서 EC2에 접속해서 docker 이미지를 삭제하고 다시 받았다. (어차피 마운트 해놔서 이전 데이터를 그대로 가지고 있다.)
- 다시 설정으로 들어와서 SSH Agent가 잘 설치되었는지 확인을 한다. (잘 설치되었다.)
2-3. AWS EC2 SSH 접속정보(키값) 추가하기
- 우리가 PC에서 AWS EC2에 접속을 할 때는 ssh -i {pem key 이름}.pem ubuntu@{ip주소}와 같은 명령어를 통해 pem 키를 통해 서 서버에 접속한다. Jenkins 서버에서 운영서버로 ssh를 통해 접속할때도 키가 필요하기에 해당 키를 먼저 Jenkins에 등록해준다.
- Jenkins관리에서 Security 하단의 메뉴에 있는 Credentials를 클릭한다.
- Credentials에서 “System”이라 적혀있는 파란 글씨를 클릭한다.
- "System"을 클릭해서 들어가서 내용을 보면 Domain하단의 "Global credentials (unrestricted)"에 마우스를 올린다.
- 그럼 맨 오른쪽에 아랫방향 화살표가 생길텐데 이걸 클릭하면 “Add credentials”가 나온다.
- Add credentails를 클릭하면 아래와 같은 페이지가 나온다.
New credential 정보를 아래와 같이 작성한다.
- kind에는 SSH Username with private key를 선택한다.
- key에는 pem 키 정보를 입력한다.
- 여기서 적어줄 pem키는 ec2를 만들때(접속할때)사용하는 pem키를 적는다.
- 나는 local의 경로에 pem키를 저장해 두었기에 이 경로에 찾아가서 아래의 명령어를 입력했다.
cat {키 이름}.pem
- 위의 명령어를 입력하면 나오는 키 값을 "-----BEGIN RSA PRIVATE KEY-----" 부터 "-----END RSA PRIVATE KEY-----"까지 모두 복사해서 입력하면 된다.
- New credential 입력하기: Username에는 Ec2서버명인 ubuntu를 넣어줬다.
- 조금 하단으로 내려오면 Private Key설정이 있는데 여기서 “Enter directly” 버튼을 클릭하면 아래처럼 Key를 “Add” 할 수 있는 버튼이 나온다.
- "Add"버튼을 누르면 나오는 영역에 방금 terminal에서 확인한 키값을 복사해서 입력한다.
- 완성본은 다음과 같다.
- 다 작성하고 하단의 "Create" 버튼을 누르면 아래와 같이 방금 만든 Credentials값이 저장된것을 확인할 수 있다.
3. Jenkins CD(지속적 배포)를 위한 Script 추가 작성하기
3-1. CD를 위한 Sciprt 추가 작성
pipeline {
agent any
tools {
gradle 'jenkins_test'
}
stages {
stage('Git Clone') {
steps {
git branch: 'main', url: 'https://github.com/{myGit}/{myProject}.git'
}
}
stage('Profile Build') {
steps {
sh "./gradlew clean build -x test"
}
}
stage('Deploy') {
steps {
sshagent(credentials: ['aws_ec2_pem_key']) {
sh '''
ssh -o StrictHostKeyChecking=no ubuntu@3.38.199.247 uptime
scp /var/jenkins_home/workspace/jenkins-pipeline-test/build/libs/profile-0.0.1-SNAPSHOT.jar ubuntu@3.38.199.247:/home/ubuntu/
ssh -t ubuntu@3.38.199.247 /home/ubuntu/deploy.sh
'''
}
}
}
}
}
3-2. 새로 작성한 stage(’Deploy’) 배포부분 스크립트 코드 설명
- sshagent(credentials: ['aws_ec2_pem_key']):
- 여기서 credentials옆에 적힌 “aws_ec2_pem_key”가 바로 위에서 작성한 credentials의 ID값이다.
- 여기서 credentials옆에 적힌 “aws_ec2_pem_key”가 바로 위에서 작성한 credentials의 ID값이다.
- 중간중간 보이는 ubuntu@3.38.xxx.xxx
- 만약 EC2 인스턴스의 IP 주소가 "3.38.xxx.xxx"라면 "ssh -t ubuntu@3.38.xxx.xxx "가 될것이다.
- 중간중간 보이는 ip를 내 EC2인스턴스 주소의 IP값으로 변경시켜주면 된다.
- scp옵션에 대한 설명:
- scp [옵션] [원본_파일_경로] [목적지_호스트]:[목적지_파일_경로]
- 원본_파일_경로: 복사할 로컬 파일의 경로다.
- 목적지_호스트: 파일을 복사할 원격 서버의 호스트명 또는 IP 주소다.
- 목적지_파일_경로: 원격 서버에서 파일을 저장할 경로입니다.
- 예를 들어, 다음 명령어는 로컬의 example.txt 파일을 원격 서버의 /home/ubuntu/ 디렉토리에 복사한다.
scp example.txt ubuntu@3.38.199.247:/home/ubuntu/
- scp [옵션] [원본_파일_경로] [목적지_호스트]:[목적지_파일_경로]
- scp (copy) 옵션에 작성되는 복사 경로(젠킨스의 빌드파일 저장 경로)에 대한 설명
- 아래와 같이 Jenkins에서 Job(Item) 이름이 "jenkins-pipeline-test"라면, 빌드 결과물은 일반적으로 "/var/jenkins_home/workspace/jenkins-pipeline-test" 이 디렉토리 내에 저장된다. 이 경로는 Jenkins의 워크스페이스 경로와 Job 이름을 합친 형태이다.
- 예를 들어, 만약 Gradle을 사용하여 Java 애플리케이션을 빌드한다면, 빌드 결과물인 JAR 파일은 다음 경로에 생성될 것이다.
/var/jenkins_home/workspace/jenkins-pipeline-test/build/libs/
- 이 글을 보는 독자가 이 파이프라인을 사용하려면, Job(Item) 이름을 변경해야 할 수 있다. 예를 들어, 지금 독자의 Job 이름이 my-new-pipeline이라면, 빌드 결과물의 경로는 /var/jenkins_home/workspace/my-new-pipeline/build/libs/가 될 것이다.
3-3. deploy.sh 실행 스크립트 설명:
- 만약 deploy.sh 파일이 배포할 원격 서버(EC2)의 /home/ubuntu/ 디렉토리에 위치한다면, ssh -t ubuntu@3.38.199.247 /home/ubuntu/deploy.sh로 작성해주면 된다.
3-4. Deploy 스크립트 동작 설명
- 원격 서버(3.38.199.247)의 uptime을 확인하여 서버가 정상적으로 작동하는지 확인한다.
- 젠킨스 서버에서 운영 서버에 접근할 수 있도록 StrictHostKeyChecking를 비활성화 시켜준다.
- 내 프로젝트의 gradle빌드 결과물인 profile-0.0.1-SNAPSHOT.jar 파일을 원격 서버의 /home/ubuntu/jar 디렉토리로 복사한다.
- 원격 서버에서 deploy.sh 스크립트를 실행하여 애플리케이션을 배포한다.
stage('Deploy') {
steps {
sshagent(credentials: ['aws_ec2_pem_key']) {
sh '''
ssh -o StrictHostKeyChecking=no ubuntu@3.38.199.247 uptime
scp /var/jenkins_home/workspace/jenkins-pipeline-test/build/libs/profile-0.0.1-SNAPSHOT.jar ubuntu@3.38.199.247:/home/ubuntu/
ssh -t ubuntu@3.38.199.247 /home/ubuntu/deploy.sh
'''
}
}
}
3-5. deploy.sh 작성하기
- 배포할 서버에서 vim 명령어로 deploy.sh를 작성할 에디터를 열어준다.
- vim 에디터를 열고 i를 입력해서 insert모드로 전환해서 아래의 코드를 작성하고 esc클릭 후 :wq!를 입력해서 저장하고 나온다.
#!/bin/bash
echo "Starting deploy script"
pid=$(pgrep -f profile)
if [ -n "${pid}" ]
then
kill -15 ${pid}
echo "Killed process ${pid}"
else
echo "No process found"
fi
echo "Setting execute permission for JAR"
chmod +x ./profile-0.0.1-SNAPSHOT.jar
echo "Running JAR"
nohup java -jar ./profile-0.0.1-SNAPSHOT.jar >> /home/ubuntu/profile/application.log 2> /dev/null &
echo "Deploy script ended"
- 작성하고 저장한 다음 "deploy.sh" 파일이 잘 만들어졌는지 확인한다.
- pwd로 설정한 deploy.sh경로와 같은지 확인하고 ls로 deploy.sh 파일이 존재하는지 확인한다.
4. CI/CD 확인하기
4-1. CI/CD 확인하기
- 대시보드로 돌아간 다음 CI/CD를 구성할 Job으로 들어간 다음 좌측 메뉴의 “구성” 을 클릭해서 들어간다.
4-2. "구성"에서 스크롤을 내려 "Pipeline"으로 가서 기존에 있던 Sciprt를 CD를 추가한 Script로 변경한다.
이제 빌드를 해보면 잘 되면 좋겠지만 나는 오류가 발생했다.
5. 배포 오류 발생 및 해결
5-1. 오류 발생
- 위의 Sciprt 추가 작업을 마친 후에 왼쪽 메뉴바에서 “지금 빌드” 버튼을 눌러서 빌드를 실행했는데 오류가 발생했다.
- 한번에 성공했으면 좋겠지만 오히려 "실패"가 생겨서 더 깊게 이해할 기회가 되었다.
5-2. 오류 파악
- 어떤 단계에서 오류가 생겼는지 UI로 봤더니 Deploy에서 생긴 오류였다.
5-3. 오류 로그 확인하기
- Error loading key "/var/jenkins_home/workspace/jenkins-pipeline-test@tmp/private_key_5474467624390718795.key": error in libcrypto
- 이 오류는 libcrypto 라이브러리와 관련된 문제로 보인다.
[ssh-agent] Using credentials ubuntu (aws_ec2_pem_key)
[ssh-agent] Looking for ssh-agent implementation...
[ssh-agent] Exec ssh-agent (binary ssh-agent on a remote machine)
$ ssh-agent
SSH_AUTH_SOCK=/tmp/ssh-XXXXXXw6gC7z/agent.981
SSH_AGENT_PID=984
Running ssh-add (command line suppressed)
Error loading key "/var/jenkins_home/workspace/jenkins-pipeline-test@tmp/private_key_5474467624390718795.key": error in libcrypto
5-4. GPT한테 해결방법 물어보기
- 환경 변수(SSH_AUTH_SOCK) 문제를 통해서 오류 해결을 시도함 (실패)
- SSH_AUTH_SOCK은 SSH 에이전트의 소켓 경로를 가리키는 환경 변수다. 이 변수는 SSH 에이전트가 시작될 때 자동으로 설정된다. 일반적으로 이 변수를 수동으로 설정할 필요는 없으며, SSH 에이전트가 올바르게 실행되고 있다면 이 변수도 자동으로 설정된다. 그러나 문제 해결을 위해 이 변수를 확인하거나 설정해야 하는 경우가 있을 수 있다. 이때는 다음과 같이 할 수 있다.
- SSH_AUTH_SOCK은 SSH 에이전트의 소켓 경로를 가리키는 환경 변수다. 이 변수는 SSH 에이전트가 시작될 때 자동으로 설정된다. 일반적으로 이 변수를 수동으로 설정할 필요는 없으며, SSH 에이전트가 올바르게 실행되고 있다면 이 변수도 자동으로 설정된다. 그러나 문제 해결을 위해 이 변수를 확인하거나 설정해야 하는 경우가 있을 수 있다. 이때는 다음과 같이 할 수 있다.
Jenkins 서버에서 확인/설정:
- Jenkins가 실행되는 서버에서 이 환경 변수를 확인하거나 설정할 수 있다. 터미널을 열고 다음 명령어를 실행하여 현재 설정된 값을 확인할 수 있다.
echo $SSH_AUTH_SOCK
- 만약 이 값이 설정되어 있지 않다면, 아래와 같이 SSH 에이전트를 수동으로 시작하고 이 변수를 설정할 수 있다.
eval $(ssh-agent -s)
- 이 명령어는 새로운 SSH 에이전트를 시작하고 SSH_AUTH_SOCK 및 SSH_AGENT_PID 환경 변수를 설정한다.
- 일반적으로 SSH_AUTH_SOCK은 자동으로 관리되므로, 이 변수를 수동으로 설정해야 하는 경우는 드물다고 한다. 문제가 지속되면 SSH 에이전트가 올바르게 실행되고 있는지 확인하는 것이 좋다.
나는 일단 이 설정을 해봤다. 아래와 같이 입력하면 된다.
5-5. AUTH_SOCK 설정 후 다시 빌드
- 아래와 같이 또 실패했다. 대신 이번에는 SSH_AUTH_SOCK에 관련된 로그는 사라졌다.
5-6. 새로운 시각으로 생각해보기
- 혹시나 내가 무언갈 잘못했는지 처음부터 과정을 역순으로 돌아가봤다. 여기서 중요한 것을 발견했는데 내가 Jenkins의 Credentials를 설정할때 private key값을 잘못 넣어준 것이었다.
- key값에 ----BEGIN RSA PRIVATE KEY---— , ----END RSA PRIVATE KEY-----% 이렇게 작성되어있었는데 맨 마지막에 %를 빼고 다시 저장한 후 실행했더니 이전과는 다른 오류가 발생했다.(일단 1차적으로 credentials 오류가 있었는데 그것은 해결되었다. 아마 오타를 넣지 않았으면 따라오시는 분은 credentials관련 오류는 발생하지 않을 것이다.)
- 이전까진 실패해도 ms였는데 이번에는 s가 나왔다. 당장 로그를 확인했다.
[ssh-agent] Using credentials ubuntu (aws_ec2_pem_key)
[ssh-agent] Looking for ssh-agent implementation...
[ssh-agent] Exec ssh-agent (binary ssh-agent on a remote machine)
$ ssh-agent
SSH_AUTH_SOCK=/tmp/ssh-XXXXXXV4E61Z/agent.1472
SSH_AGENT_PID=1475
Running ssh-add (command line suppressed)
Identity added: /var/jenkins_home/workspace/jenkins-pipeline-test@tmp/private_key_2364274029300666546.key (/var/jenkins_home/workspace/jenkins-pipeline-test@tmp/private_key_2364274029300666546.key)
[ssh-agent] Started.
[Pipeline] {
[Pipeline] sh
+ ssh -o StrictHostKeyChecking=no ubuntu@3.38.199.247 uptime
Warning: Permanently added '3.38.199.247' (ED25519) to the list of known hosts.
15:58:34 up 3:03, 1 user, load average: 1.24, 0.44, 0.20
+ scp /var/jenkins_home/workspace/jenkins-pipeline-test/build/libs/profile-0.0.1-SNAPSHOT.jar ubuntu@3.38.199.247:/home/ubuntu/profile
+ ssh -t ubuntu@3.38.199.247 /home/ubuntu/deploy.sh
Pseudo-terminal will not be allocated because stdin is not a terminal.
bash: line 1: /home/ubuntu/deploy.sh: Permission denied
[Pipeline] }
$ ssh-agent -k
unset SSH_AUTH_SOCK;
unset SSH_AGENT_PID;
echo Agent pid 1475 killed;
[ssh-agent] Stopped.
5-7. 에러 로그 분석
- "bash: line 1: /home/ubuntu/deploy.sh: Permission denied" 이 오류는 deploy.sh 스크립트에 실행 권한이 없어서 발생한 것으로 보인다.
- 원격 서버에서 아래의 명령을 실행하여 deploy.sh에 실행 권한을 부여해 봐라 이렇게 하면 deploy.sh 스크립트에 실행 권한이 부여되어 Jenkins 파이프라인에서 정상적으로 실행될 수 있을 것이다.
chmod +x /home/ubuntu/deploy.sh
5-8. 오류 해결 성공!
- 원격 서버(EC2)에 deploy.sh에 위에서 적은 명령어를 입력해서 실행 권한을 부여했더니 배포에 성공했다.
- EC2의 deploy.sh가 있는 경로로 이동해서 아래의 명령어를 입력하면 된다.
chmod +x deploy.sh
- 권한을 부여하고 다시 “지금 빌드”를 클릭했더니 배포까지 성공했다.
6. SpringBoot 실행 오류
6-1. SpringBoot 실행 오류 발생
- 배포를 성공하고 내 SpringBoot 서버에 접속하기 위해 8081번 포트로 EC2의 인바운드를 열어주고 접속을 시도했지만 8081번 포트로 실행되어야 할 SpringBoot가 실행되지 않았다. 대체 뭐가 문제인지 로그를 봐도 잘 모르겠어서 계속 jenkins 스크립트를 수정해보다 ec2서버에서 java -version을 입력해봤는데 자바가 없다고 나왔다.
ubuntu@ip-172-31-22-232:~/profile$ java -version
Command 'java' not found, but can be installed with:
sudo apt install openjdk-11-jre-headless # version 11.0.20.1+1-0ubuntu1~22.04, or
sudo apt install default-jre # version 2:1.11-72build2
sudo apt install openjdk-17-jre-headless # version 17.0.8.1+1~us1-0ubuntu1~22.04
sudo apt install openjdk-18-jre-headless # version 18.0.2+9-2~22.04
sudo apt install openjdk-19-jre-headless # version 19.0.2+7-0ubuntu3~22.04
sudo apt install openjdk-8-jre-headless # version 8u382-ga-1~22.04.1
6-2. AWS를 사용중이니 Amazon java17를 설치했다.
- 아래의 명령어를 입력해라
wget -O- https://apt.corretto.aws/corretto.key | sudo apt-key add -
sudo add-apt-repository 'deb https://apt.corretto.aws stable main'
sudo apt-get update; sudo apt-get install -y java-17-amazon-corretto-jdk
java --version
- 설치후 다시 java -version을 입력해서 확인한다.
java -version
6-3. 다시 Jenkins에 배포를 해봤다.
- 당연히 성공했다.
6-4. 이제 SpringBoot로 만든 url에 접속을 시도했다.
- 내 프로젝트가 실행되었다!
이번 포스트에서는 Jenkins를 활용하여 Pipeline 방식으로 SpringBoot를 CI/CD하는 방법을 알아봤다. 아직 남은 작업은 GitHub의 Hook을 활용하여 코드를 push하면 CI/CD가 동작하게 하는 부분이다. 이것은 추후 작성해서 남겨놓도록 하겠다.
2023.10.27 - [DevOps] - Pipeline 방식으로 Jenkins구축 - SpringBoot CI/CD 구축
2023.10.26 - [DevOps] - Jenkins로 시작하는 CI: Freestyle 프로젝트 구축 가이드
'DevOps' 카테고리의 다른 글
단일 장애 지점(SPOF)이란? (0) | 2023.11.21 |
---|---|
ECR Docker 이미지 Push 오류: M1 아키텍처와 exec format 문제 (0) | 2023.10.28 |
Jenkins로 시작하는 CI: Freestyle 프로젝트 구축 가이드 (2) | 2023.10.26 |
AWS EC2에서 Docker와 Jenkins로 CI/CD 환경 구축하기 (0) | 2023.10.25 |