스프링 부트와 AWS로 혼자 구현하는 웹 서비스(10)
📌 24시간 365일 중단없는 서비스를 만들자
- 새로운 Jar가 실행되기 전까지 기존 Jar를 종료시켜 놓기 때문에 서비스가 중단됨
- 카톡이나 네이버같이 정지되지 않는 서비스를 만들자
⚡ 무중단 배포 소개
과거에는 다 같이 코드를 합치는 날 배포하는 날을 정하고 진행했음.
특히 배포일에는 사용자가 적은 새벽 시간에 개발자들이 모두 남아 배포를 준비하곤 했음
-> 배포를 하고 문제가 생기면 긴급 점검 공지를 하고 수정을 해야 했음
-> 무중단 배포를 통해 이를 해결할 수 있음!
✔ AWS에서 블루 그린(Blue-Grean) 무중단 배포
✔ 도커를 이용한 웹서비스 무중단 배포
엔진엑스(Nginx)
- 무중단 배포 방법 중 하나(가장 저렴하고 쉬움)
- 웹 서버, 리버스 프록시, 캐싱, 로드 밸런싱, 미디어 스트리밍 등을 위한 오픈소스 소프트웨어
- 리버스 프록시 : 엔진엑스가 외부의 요청을 받아 백앤드 서버로 요청을 전달하는 행위
- 기존에 쓰던 EC2에 그대로 적용하면 되므로 배포를 위해 AWS EC2 인스턴스가 하나 더 필요하지 않음
- 꼭 AWS와 같은 클라우드 인프라가 구축되어 있지 않아도 사용할 수 있는 범용적인 방법
- 개인 서버 혹은 사내 서버에서도 동일한 방식으로 구축할 수 있으므로 사용처가 많음
✔ 하나의 EC2 혹은 리눅스 서버에 엔진엑스 1대와 스프링 부트 Jar를 2대 사용
- 엔진엑스는 80(http), 443(https)포트를 할당
- 스프링 부트 1는 8082포트로 실행
- 스프링 부트 2는 8083포트로 실행
<엔진엑스 무중단 배포1 구조>(나는 8082, 8083으로 함)
- 사용자는 서비스 주소로 접속(80 or 443)
- 엔진엑스는 사용자의 요청을 받아 현재 연결된 스프링 부트로 요청을 전달
- 스프링 부트1 즉 8082 포트로 요청을 전달한다고 가정
- 스프링 부트2는 엔진엑스와 연결된 상태가 아니니 요청받지 못함
✔ 1.1 버전으로 신규 배포가 필요하면, 엔진엑스와 연결되지 않은 스프링 부트 2(8083)으로 배포함
<엔진엑스 무중단 배포2> (8082, 8083)
- 배포하는 동안에도 서비스는 중단되지 않음
- 엔진엑스는 스프링 부트1을 바라보기 때문
- 배포가 끝나고 정상적으로 스프링 부트2가 구동 중인지 확인함
- 스프링 부트2가 정상 구동 중이면 nginx reload 명령어를 통해 8082 대신에 8083을 바라보도록 함
- nginx reload는 0.1초 이내 완료됨
✔ 1.2 버전 배포가 필요하면 이번에는 스프링 부트1로 배포함
<엔진엑스 무중단 배포3> (8082, 8083)
- 현재는 엔진엑스와 연결된 것이 스프링 부트2임
- 스프링 부트1의 배포가 끝났다면 엔진엑스가 스프링 부트1을 바라보고 변경하고 nginx reload를 실행함
- 이후 요청부터는 엔진엑스가 스프링 부트1로 요청 전달
⚡ 엔진엑스 설치와 스프링 부트 연동하기
✔ 엔진엑스 설치
- EC2에 접속하여 명령어 입력
sudo yum install nginx
✔엔진엑스 실행
sudo service nginx start
✔ 잘 실행되었다면 아래 메시지가 뜸
Starting nginx: [ ok ]
✔ 나는 이 메시지가 뜸,, 다른 사람들 보면 이것도 잘 실행된거라곤 하는데 일단 넘어가본다
Redirecting to /bin/systemctl start nginx
✔ 보안 그룹 추가
- 엔진엑스의 포트번호를 보안 그룹에 추가
- 엔진엑스의 포트번호 : 80
- EC2 -> 보안그룹 -> EC2 보안그룹 선택 -> 인바운드 편집
- 80으로
0.0.0.0/0
,::/0
선택
- 80으로
✔ 리다이렉션 주소 추가
- 8081이 아닌 80포트로 주소가 변경되니 구글과 네이버 로그인에도 변경된 주소를 등록해야만 함
- 기존에 등록된 리다이렉션 주소에서 8080부분을 제거하여 추가 등록
- EC2의 도메인으로 접근하되, 포트번호 없이 도메인만 입력해서 접속하면 엔진엑스 웹페이지를 볼 수 있음
✔ 엔진엑스와 스프링 부트 연동
- 엔진엑스 설정파일 오픈
sudo vim /etc/nginx/nginx.conf
✔ 설정 내용 중 server 아래의 location / 부분을 찾아서 다음과 같이 추가
location / {
proxy_pass http://localhost:8080; # 1
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # 2
proxy_set_header Host $http_host;
}
🔶 proxy_pass
- 엔진엑스로 요청이 오면
http://localhost:8081
로 전달
🔶 proxy_set_header XXX
- 실제 요청 데이터를 header의 각 항목에 할당
- ex) proxy_set_header X-Real-IP $remote_addr: Request Header의 X-Real-IP에 요청자 IP를 저장
✔ 엔진엑스 재시작
sudo service nginx restart
✔ 브라우저로 접속해 엔진엑스 시작 페이지가 보이면 새로고침
- 엔진엑스가 스프링 부트 프로젝트를 프록시하는게 보이면 성공!
⚡ 무중단 배포 스크립트 만들기
✔ 무중단 배포 스크립트 작업 전에 API를 하나 추가
- 이 API는 이후 배포 시에 8082를 쓸지, 8083을 쓸지 판단하는 기준이 됨!
profile API 추가
✔ ProfileController 생성
🔶 main/.../web
아래에 생성
package com.yerin.book.springbootwebservice.web;
import lombok.RequiredArgsConstructor;
import org.springframework.core.env.Environment;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Arrays;
import java.util.List;
@RequiredArgsConstructor
@RestController
public class ProfileController {
private final Environment env;
@GetMapping("/profile")
public String profile(){
List<String> profiles = Arrays.asList(env.getActiveProfiles());
List<String> realProfiles = Arrays.asList("real", "real1", "real2");
String defaultProfile = profiles.isEmpty()? "default" : profiles.get(0);
return profiles.stream().filter(realProfiles::contains).findAny().orElse(defaultProfile);
}
}
🔶 env.getActiveProfiles()
- 현재 실행 중인 ActiveProfile을 모두 가져옴
- 즉, real, oauth, real-db 등이 활성화되어 있다면(active) 3개가 모두 담겨있음
- 여기서 real, real1, real2는 모두 배포에 사용될 profile이라 이 중 하나라도 있으면 그 값을 반환하도록 함
- 실제로 이번 무중단 배포에서는 real1과 real2만 사용되지만, step2를 다시 사용해볼 수 있으니 real도 남겨둠
✔ ProfileControllerUnitTest 코드 생성
🔶 test/.../web
아래에 생성
package com.yerin.book.springbootwebservice.web;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.mock.env.MockEnvironment;
import static org.junit.jupiter.api.Assertions.*;
class ProfileControllerUnitTest {
@Test
public void real_profile이_조회된다(){
//given
String expectedProfile = "real";
MockEnvironment env = new MockEnvironment();
env.addActiveProfile(expectedProfile);
env.addActiveProfile("oauth");
env.addActiveProfile("real-db");
ProfileController controller = new ProfileController(env);
//when
String profile = controller.profile();
//then
Assertions.assertThat(profile).isEqualTo(expectedProfile);
}
@Test
public void real_profile이_없으면_첫_번째가_조회된다(){
//given
String expectedProfile = "ouath";
MockEnvironment env = new MockEnvironment();
env.addActiveProfile(expectedProfile);
env.addActiveProfile("real-db");
ProfileController controller = new ProfileController(env);
//when
String profile = controller.profile();
//then
Assertions.assertThat(profile).isEqualTo(expectedProfile);
}
@Test
public void active_profile이_없으면_default가_조회된다(){
//given
String expectedProfile = "default";
MockEnvironment env = new MockEnvironment();
ProfileController controller = new ProfileController(env);
//when
String profile = controller.profile();
//then
Assertions.assertThat(profile).isEqualTo(expectedProfile);
}
}
🔶 ProfileController
나 Environment
모두 자바 클래스(인터페이스)이기 때문에 쉽게 테스트 가능
Environment
: 인터페이스라 가짜 구현체인 MockEnvironment(스프링에서 제공)을 사용해서 테스트- 생성자 DI가 얼마나 유용한지 알 수 있음
- 만약
Environment
를@Autowired
로 DI 받았다면 이런 테스트 코드를 작성하지 못했음
SecurityConfig 코드 수정
AntPathRequestMatcher.antMatcher("/profile")
추가해줌
SecurityConfig 관련 Test 코드
🔶 test/.../web
아래에 ProfileControllerTest
클래스 생성
package com.yerin.book.springbootwebservice.web;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.junit.jupiter.SpringExtension;
@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class ProfileControllerTest {
@LocalServerPort
private int port;
@Autowired
private TestRestTemplate restTemplate;
@Test
public void profile은_인증없이_호출된다() throws Exception {
String expected = "default";
ResponseEntity<String>response = restTemplate.getForEntity("/profile", String.class);
Assertions.assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
Assertions.assertThat(response.getBody()).isEqualTo(expected);
}
}
- 성공했다면 깃허브로 푸시하여 배포
- 배포가 끝나면
/profile
로 접속해서 잘 나오는지 확인
해당 화면이 뜸
real1, real2 profile 생성
- 현재 EC2 환경에서 실행하는 profile은 real밖에 없음
- 해당 profile은 Travis CI 배포 자동화를 위한 profile이니 무중단 배포를 위한 profile 2개(real1, real2)을 src/main/resources 아래에 추가
application-real1.yml
server:
port: 8082
spring:
profiles:
include:
- oauth
- real-db
jpa:
properties:
hibernate:
dialect: org.hibernate.dialect.MySQL57Dialect
dialect.storage_engine: innodb
session:
store-type: jdbc
application-real2.yml
server:
port: 8083
spring:
profiles:
include:
- oauth
- real-db
jpa:
properties:
hibernate:
dialect: org.hibernate.dialect.MySQL57Dialect
dialect.storage_engine: innodb
session:
store-type: jdbc
엔진엑스 설정 수정(무중단 배포의 핵심)
- 배포 때마다 엔진엑스의 프록시설정(스프링부트로 요청을 흘려보내는)이 순식간에 교체됨
- 엔진엑스 설정이 모여있는
/etc/nginx/conf.d/
에service-url.inc
라는 파일을 하나 생성
- 엔진엑스 설정이 모여있는
sudo vim /etc/nginx/conf.d/service-url.inc
- 다음 코드 입력 후 저장 종료
set $service_url http://127.0.0.1:8081;
- 해당 파일은 엔진엑스가 사용할 수 있게 설정
- nginx.conf파일 오픈
sudo vim /etc/nginx/conf.d/service-url.inc;
- location / 부분 코드 수정
✔ 재시작
sudo service nginx restart
배포 스크립트들 작성
✔ step2와 중복되지 않기 위해 EC2에 step3 디렉토리 생성
mkdir ~/app/step3 && mkdir ~/app/step3/zip
✔ appspec.yml -> step3으로 배포되도록 수정
version: 0.0
os: linux
files:
- source: /
destination: /home/ec2-user/app/step3/zip/
overwrite: yes
무중단 배포를 진행할 스크립트는 총 5개
- stop.sh : 기존 엔진엑스에 연결되어 있진 않지만, 실행 중이던 스프링 부트 종료
- start.sh : 배포할 신규 버전 스프링 부트 프로젝트를 stop.sh로 종료한 'profile'로 실행
- health.sh : 'start.sh'로 실행시킨 프로젝트가 정상적으로 실행됐는지 체크
- switch.sh : 엔진엑스가 바라보는 스프링 부트를 최신 버전으로 변경
- profile.sh : 앞선 4개 스크립트 파일에서 공용으로 사용할 'profile'과 포트 체크 로직
✔ appespec.yml에 앞선 스크립트 사용
hooks:
AfterInstall:
- locaion: stop.sh #엔진엑스와 연결되어 있지 않은 스프링 부트를 종료
timeout: 60
runas: ec-user
ApplicationStart:
- location: start.sh # 엔진엑스와 연결되어 있지 않은 Port로 새 버전의 스프링 부트를 시작함
timeout: 60
runas: ec-user
ValidateService:
- location: health.sh # 새 스프링 부트가 정상적으로 실행됐는지 확인
timeout: 60
runas: ec-user
✔ Jar파일이 복사된 이후부터 차례로 앞선 스크립트들이 실행된다고 보면 됨
- scripts 디렉토리 안에 각 스크립트들 추가
✔ profile.sh
#!/usr/bin/env bash
# 쉬고 있는 profile 찾기: real1이 사용 중이면 real2가 쉬고 있고, 반대면 real1이 쉬고 있음
function find_idle_profile() {
# 현재 엔진엑스가 바라보고 있는 스프링 부트가 정상적으로 수행 중인지 확인. 정상이면 200, 오류면 400~503
RESPONSE_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost/profile)
if [ ${RESPONSE_CODE} -ge 400 ] # 400보다 크면 (즉, 40x/50x 에러 포함)
then
CURRENT_PROFILE=real2
else
CURRENT_PROFILE=$(curl -s http://localhost/profile)
fi
if [ ${CURRENT_PROFILE} == real1 ]
then
IDLE_PROFILE=real2
else
IDLE_PROFILE=real1
fi
# 스프링 부트 프로젝트를 이 profile로 연결하기 위해 반환
echo "${IDLE_PROFILE}"
}
# 쉬고 있는 profile의 port 찾기
function find_idle_port() {
IDLE_PROFILE=$(find_idle_profile)
if [ ${IDLE_PROFILE} == real1 ]
then
echo "8082"
else
echo "8083"
fi
}
🔶 $(curl -s -o /dev/null -w "%{http_code}" http://localhost/profile)
- 현재 엔진엑스가 바라보고 있는 스프링 부트가 정상적으로 수행 중인지 확인
- 응답값을 HttpStatus로 받음
- 정상이면 200, 오류가 발생한다면 400~503 사이로 발생하니 400 이상은 모두 예외로 보고 real2를 현재 profile로 사용
🔶 IDLE_PROFILE
- 엔진엑스와 연결되지 않은 profile
- 스프링 부트 프로젝트를 이 profile로 연결하기 위해 반환함
🔶 echo "${IDLE_PROFILE}"
- bash라는 스크립트는 값을 반환하는 기능은 없음
- 제일 마지막 줄에 echo로 결과를 출력 후, 클라이언트에서 그 값을 잡아서 ($(find_idle_profile))사용
- 중간에 echo를 사용하면 안됨
stop.sh
#!/usr/bin/env bash
ABSPATH=$(readlink -f $0)
ABSDIR=$(dirname $ABSPATH)
source ${ABSDIR}/profile.sh
IDLE_PORT=$(find_idle_port)
echo "> $IDLE_PORT 에서 구동 중인 애플리케이션 pid 확인"
IDLE_PID=$(lsof -ti tcp:${IDLE_PORT})
if [ -z ${IDLE_PID} ]
then
echo "> 현재 구동 중인 애플리케이션이 없으므로 종료하지 않습니다."
else
echo "> kill -15 $IDLE_PID"
kill -15 ${IDLE_PID}
sleep 5
fi
🔶 ABSDIR=$(dirname $ABSPATH)
- 현재 stop.sh가 속해 있는 경로를 찾음
- 하단의 코드와 같이 profile.sh의 경로를 찾기 위해 사용됨
🔶 source ${ABSDIR}/profile.sh
- 자바로 보면 일종의 import 구문
- 해당 코드로 인해 stop.sh에서도 profile.sh의 여러 function을 사용할 수 있게 됨
start.sh
#!/usr/bin/env bash
ABSPATH=$(readlink -f $0)
ABSDIR=$(dirname $ABSPATH)
source ${ABSDIR}/profile.sh
REPOSITORY=/home/ec2-user/app/step3
PROJECT_NAME=springboot-webservice
echo "> Build 파일 복사"
echo "> cp $REPOSITORY/zip/*.jar $REPOSITORY/"
cp $REPOSITORY/zip/*.jar $REPOSITORY/
echo "> 새 애플리케이션 배포"
JAR_NAME=$(ls -tr $REPOSITORY/*.jar | tail -n 1)
echo "> JAR Name: $JAR_NAME"
echo "> $JAR_NAME 에 실행 권한 추가"
chmod +x $JAR_NAME
echo "> $JAR_NAME 실행"
IDLE_PROFILE=$(find_idle_profile)
echo "> $JAR_NAME 를 profile=$IDLE_PROFILE 로 실행합니다."
nohup java -jar \
-Dspring.config.location="classpath:/application.yml","classpath:/application-$IDLE_PROFILE.yml","/home/ec2-user/app/application-oauth.yml","/home/ec2-user/app/application-real-db.yml" \
-Dspring.profiles.active=$IDLE_PROFILE \
$JAR_NAME > $REPOSITORY/nohup.out 2>&1 &
- 기본적인 스크립트는 step2의 deploy.sh와 유사함
- 다른 점이라면 IDLE_PROFILE을 통해 properties 파일을 가져오고(application-$IDLE_PROFILE.yml), active profile을 지정하는 것 뿐임
(-Dspring.profiles.active=$IDLE_PROFILE)
- 여기서도 IDLE_PROFILE을 사용하니 profile.sh을 가져와야 함
health.sh
#!/usr/bin/env bash
ABSPATH=$(readlink -f $0)
ABSDIR=$(dirname $ABSPATH)
source ${ABSDIR}/profile.sh
source ${ABSDIR}/switch.sh
IDLE_PORT=$(find_idle_port)
echo "> Health Check Start!"
echo "> IDLE_PORT: $IDLE_PORT"
echo "> curl -s http://localhost:$IDLE_PORT/profile "
sleep 10
for RETRY_COUNT in {1..10}
do
RESPONSE=$(curl -s http://localhost:${IDLE_PORT}/profile)
UP_COUNT=$(echo ${RESPONSE} | grep 'real' | wc -l)
# 엔진엑스와 연결되지 않은 포트로 스프링 부트가 잘 수행되었는지 체크
if [ ${UP_COUNT} -ge 1 ]
then # $UP_COUNT >= 1 ("real" 문자열이 있는지 검증)
echo "> Health check 성공"
switch_proxy # 잘 떴는지 확인 되면, 엔진엑스 프록시 설정을 변경 (switch.sh의 switch_proxy 메서드)
break
else
echo "> Health check의 응답을 알 수 없거나 혹은 실행 상태가 아닙니다."
echo "> Health check: ${RESPONSE}"
fi
if [ ${RETRY_COUNT} -eq 10 ]
then
echo "> Health check 실패"
echo "> 엔진엑스에 연결하지 않고 배포를 종료합니다."
exit 1
fi
echo "> Health check 연결 실패. 재시도..."
sleep 10
done
- 엔진엑스와 연결되지 않은 포트로 스프링 부트가 잘 수행되었는지 체크
- 잘 떴는지 확인되어야 엔진엑스 프록시 설정을 변경(switch_proxy)함
- 엔진엑스 프록시 설정 변경은 switch.sh에서 수행
switch.sh
#!/usr/bin/env bash
ABSPATH=$(readlink -f $0)
ABSDIR=$(dirname $ABSPATH)
source ${ABSDIR}/profile.sh
function switch_proxy() {
IDLE_PORT=$(find_idle_port)
echo "> 전환할 Port: $IDLE_PORT"
echo "> Port 전환"
echo "set \$service_url http://127.0.0.1:${IDLE_PORT};" | sudo tee /etc/nginx/conf.d/service-url.inc # 하나의 문장으로 만들어 파이프 라인으로 넘겨주기 위해 echo 사용 -> 앞에서 넘겨준 문장을 service-url.inc에 덮어씀
echo "> 엔진엑스 Reload"
sudo service nginx reload # restart는 잠시 끊김 현상, reload는 끈김 없이 다시 불러옴.
}
🔶 echo "set \$service_url http://127.0.0.1:${IDLE_PORT};"
- 하나의 문장을 만들어 파이프라인(|)으로 넘겨주기 위해 echo사용
- 엔진엑스가 변경할 프록시 주소를 생성
- 쌍따옴표(")를 사용해야 함
- 사용하지 않으면 $service_url을 인식하지 못하고 변수를 찾게 됨
🔶 | sudo tee /etc/nginx/conf.d/service-url.inc
- 앞에서 넘겨준 문장을 service_url.inc에 덮어씀
🔶 sudo service nginx reload
- 엔진엑스 설정을 다시 불러옴
- restart와는 다름
- restart는 잠시 끊기는 현상이 있지만 reload는 끊김없이 다시 불러옴
- 다만, 중요한 설정들은 반영되지 않으므로 restart를 사용해야 함
- 여기선 외부의 설정 파일인 service-url을 다시 불러오는 거라 reload로 가능함
⚡ 무중단 배포 테스트
- 잦은 배포로 Jar 파일명이 겹칠 수 있음
- 매번 버전을 올리는 것은 귀찮으므로 자동으로 버전값이 변경될 수 있도록 조치
build.gradle
version '1.0.1-SNAPSHOT-' + new Date().format("yyyyMMddHHmmss")
- build.gradle은 Groovy 기반의 빌드툴
- Groovy 언어의 여러 문법을 사용할 수 있는데, 여기서는 new Data()로 빌드할 때마다 그 시간이 버전에 추가되도록 구성
✔ 배포가 완료되면 CodeDeploy 로그로 잘 진행되는지 확인
tail -f /opt/codedeploy-agent/deployment-root/deployment-logs/codedeploy-agent-deployments.log
✔ 스프링 부트 로그도 보고싶다면
vim ~/app/step3/nohup
✔ 자바 애플리케이션 실행 여부 확인
ps -ef | grep java
✔ 2개의 애플리케이션이 실행되고 있음을 확인(real1, real2)
role 역할이 guest로 되어있어서 설정해줌
- member 엔티티
public void updateRoleKey(Role role) {
this.role = role;
}
- CustomOAuth2UserService에
private Member saveOrUpdate(OAuthAttributes attributes) {
Member member = userRepository.findByEmail(attributes.getEmail())
.map(entity -> entity.update(attributes.getName(), attributes.getPicture()))
.orElse(attributes.toEntity());
member.updateRoleKey(Role.USER);
return userRepository.save(member);
}
완성 !!
스프링 부트와 AWS로 혼자 구현하는 웹 서비스_이동욱 지음 책을 읽고 정리하는 글입니다
![](https://t1.daumcdn.net/keditor/emoticon/friends1/large/008.gif)
PREV
스프링 부트와 AWS로 혼자 구현하는 웹 서비스(9)
📌 Travis CI배포 자동화 코드가 푸시되면 자동으로 배포한다. 여러 개발자의 코드가 실시간으로 병합되고 테스트가 수행되는 환경, master 브랜치가 푸시되면 배포가 자동으로 이루어지는 환경을
nyeroni.tistory.com
'프로젝트 > 스프링 부트와 AWS로 혼자 구현하는 웹 서비스' 카테고리의 다른 글
스프링 부트와 AWS로 혼자 구현하는 웹 서비스(9) (1) | 2024.01.22 |
---|---|
스프링 부트와 AWS로 혼자 구현하는 웹 서비스(8) (1) | 2024.01.22 |
스프링 부트와 AWS로 혼자 구현하는 웹 서비스(7) (1) | 2024.01.22 |
스프링 부트와 AWS로 혼자 구현하는 웹 서비스(6) (0) | 2024.01.22 |
스프링 부트와 AWS로 혼자 구현하는 웹 서비스(5) (1) | 2024.01.22 |