diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 0095ac5..46a0ef6 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -2,7 +2,7 @@ name: CI/CD Pipeline on: push: - branches: [ develop, main ] + branches: [ develop, feat/98-load-test ] jobs: build: @@ -46,12 +46,26 @@ jobs: runs-on: ubuntu-latest steps: + - uses: actions/checkout@v3 + - name: Download build artifact uses: actions/download-artifact@v4 with: name: digitalTok path: build/libs/ + # 3. Docker Hub 로그인 + - name: Log in to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + # 4. 도커 이미지 빌드 및 푸시 + - name: Build and Push Docker Image + run: | + docker build -t ${{ secrets.DOCKER_USERNAME }}/digitaltok-server:latest . + docker push ${{ secrets.DOCKER_USERNAME }}/digitaltok-server:latest # env 추가 및 run.sh 생성 - name: Deploy to EC2 env: @@ -61,43 +75,40 @@ jobs: run: | echo "$EC2_SSH_KEY" > private_key.pem chmod 600 private_key.pem - jar_file=$(find build/libs -name '*.jar' ! -name '*plain.jar' | head -n 1) - - # 파일 전송 - scp -i private_key.pem -o StrictHostKeyChecking=no "$jar_file" $EC2_USERNAME@$EC2_HOST:/home/$EC2_USERNAME/digitalTok.jar - # 실행 스크립트 생성 (run.sh) + # EC2에서 실행할 스크립트 (run.sh) 작성 cat < run.sh #!/bin/bash - # 1. 기존 자바 프로세스 강제 종료 - pgrep java | xargs -r kill -9 - sleep 5 + # 1. 최신 이미지 다운로드 + docker pull ${{ secrets.DOCKER_USERNAME }}/digitaltok-server:latest - # DB 및 AWS 환경변수 설정 - export DB_URL='${{ secrets.DB_URL }}' - export DB_USERNAME='${{ secrets.DB_USERNAME }}' - export DB_PASSWORD='${{ secrets.DB_PASSWORD }}' - export AWS_ACCESS_KEY='${{ secrets.AWS_ACCESS_KEY }}' - export AWS_SECRET_KEY='${{ secrets.AWS_SECRET_KEY }}' - export SPRING_CLOUD_AWS_REGION_STATIC=ap-northeast-2 + # 2. 기존 실행 중인 컨테이너가 있다면 중지 및 삭제 + docker stop digitaltok-server || true + docker rm digitaltok-server || true - # 이메일 환경변수 설정 - export MAIL_USERNAME='${{ secrets.MAIL_USERNAME }}' - export MAIL_PASSWORD='${{ secrets.MAIL_PASSWORD }}' + # 3. 새 컨테이너 백그라운드 실행 (-e 로 기존 Secret 주입) + # --network host 옵션: EC2 서버의 네트워크를 그대로 사용하여 로컬 DB(3306)와 통신하기 쉽게 만듭니다. + docker run -d --name digitaltok-server \ + --network host \ + -e DB_URL='${{ secrets.DB_URL }}' \ + -e DB_USERNAME='${{ secrets.DB_USERNAME }}' \ + -e DB_PASSWORD='${{ secrets.DB_PASSWORD }}' \ + -e AWS_ACCESS_KEY='${{ secrets.AWS_ACCESS_KEY }}' \ + -e AWS_SECRET_KEY='${{ secrets.AWS_SECRET_KEY }}' \ + -e SPRING_CLOUD_AWS_REGION_STATIC='ap-northeast-2' \ + -e MAIL_USERNAME='${{ secrets.MAIL_USERNAME }}' \ + -e MAIL_PASSWORD='${{ secrets.MAIL_PASSWORD }}' \ + -e JWT_SECRET_KEY='${{ secrets.JWT_SECRET_KEY }}' \ + -e TZ='Asia/Seoul' \ + ${{ secrets.DOCKER_USERNAME }}/digitaltok-server:latest - # 서버 실행 명령어 - # -Djwt.secret 옵션으로 스프링이 찾는 이름에 맞춰서 값을 직접 넣어줌 - nohup java -Duser.timezone=Asia/Seoul \ - -Dapp.server-url='https://diring.site' \ - -Djwt.secret='${{ secrets.JWT_SECRET_KEY }}' \ - -Djwt.access-token-validity-in-seconds=1800 \ - -Djwt.refresh-token-validity-in-seconds=604800 \ - -jar /home/$EC2_USERNAME/digitalTok.jar > app.log 2>&1 & + # 4. 쌓여있는 안 쓰는 옛날 도커 이미지 삭제 (EC2 용량 확보) + docker image prune -a -f EOF # 스크립트 전송 및 실행 scp -i private_key.pem -o StrictHostKeyChecking=no run.sh $EC2_USERNAME@$EC2_HOST:/home/$EC2_USERNAME/run.sh ssh -i private_key.pem -o StrictHostKeyChecking=no $EC2_USERNAME@$EC2_HOST "chmod +x /home/$EC2_USERNAME/run.sh && sh /home/$EC2_USERNAME/run.sh" - + rm -f private_key.pem run.sh \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f92363d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +# 1. Java 17 환경을 베이스로 사용 (가볍고 많이 쓰는 alpine 버전) +FROM eclipse-temurin:17-jdk-alpine + +# 2. 작업할 디렉토리 설정 +WORKDIR /app + +# 3. 로컬에서 빌드된 jar 파일을 도커 컨테이너 안의 /app.jar로 복사 +COPY build/libs/digitalTok.jar app.jar + +# 4. 서버가 사용할 8080 포트 개방 +EXPOSE 8080 + +# 5. 도커 컨테이너가 켜질 때 실행할 명령어 (java -jar app.jar) +ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/build.gradle b/build.gradle index 578b482..00d405c 100644 --- a/build.gradle +++ b/build.gradle @@ -62,6 +62,10 @@ dependencies { // 이메일 발송 라이브러리 implementation 'org.springframework.boot:spring-boot-starter-mail' + + // 부하 테스트 및 모니터링 + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'io.micrometer:micrometer-registry-prometheus' } tasks.named('test') { diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0878452 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,37 @@ +version: '3.8' + +services: + mysql: + image: mysql:8.0 + container_name: digitaltok-mysql + ports: + - "3307:3306" + environment: + MYSQL_DATABASE: digitaltok + MYSQL_USER: localuser + MYSQL_ROOT_PASSWORD: rootpassword + MYSQL_PASSWORD: localpassword + volumes: + - ./local-mysql-data:/var/lib/mysql # 컨테이너를 지워도 데이터가 날아가지 않도록 내 컴퓨터 폴더와 연결 (영속성) + + mysql-exporter: + image: prom/mysqld-exporter + container_name: mysql-exporter + environment: + - DATA_SOURCE_NAME=localuser:localpassword@(mysql:3306)/digitaltok + ports: + - "9104:9104" + + prometheus: + image: prom/prometheus + container_name: prometheus + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml + ports: + - "9090:9090" + + grafana: + image: grafana/grafana + container_name: grafana + ports: + - "3000:3000" \ No newline at end of file diff --git a/load-test.js b/load-test.js new file mode 100644 index 0000000..f01dda5 --- /dev/null +++ b/load-test.js @@ -0,0 +1,80 @@ +// import http from 'k6/http'; +// import { check, sleep } from 'k6'; +// +// export const options = { +// vus: 1000, // 동시에 접속하는 가상 유저 +// duration: '1m', // 1분 동안 계속 요청 +// }; +// +// export default function () { +// // 현재 프로젝트에 구현된 HealthController의 경로를 테스트합니다. +// const res = http.get('http://localhost:8080/health'); +// +// check(res, { +// 'status is 200': (r) => r.status === 200, +// }); +// +// sleep(1); // 1초 쉬고 다시 요청 +// } + +import http from 'k6/http'; +import { check, sleep } from 'k6'; + +export const options = { + vus: 100, + duration: '1m', +}; + +export function setup() { + const loginUrl = 'http://localhost:8080/api/v1/auth/login'; + const payload = JSON.stringify({ + email: 'user@example.com', // 1. 반드시 DB에 있는 계정이어야 함 + password: 'string', // 2. 비밀번호가 맞는지 확인 + }); + + const params = { + headers: { 'Content-Type': 'application/json' }, + }; + + const res = http.post(loginUrl, payload, params); + + // [디버깅 코드 추가] 응답이 비어있거나 에러가 났는지 확인 + console.log(`[Setup] Status: ${res.status}`); + console.log(`[Setup] Body: ${res.body}`); + + if (res.status !== 200 || !res.body) { + console.error('로그인 실패! 서버 로그(IntelliJ)를 확인하세요.'); + return { token: null }; + } + + try { + const responseBody = res.json(); + // ApiResponse 구조에 맞게 접근: responseBody.result.accessToken + if (responseBody && responseBody.result && responseBody.result.accessToken) { + return { token: responseBody.result.accessToken }; + } + } catch (e) { + console.error(`JSON 파싱 실패: ${e.message}. 응답 내용: ${res.body}`); + } + + return { token: null }; +} + +export default function (data) { + if (!data || !data.token) { + // 토큰이 없으면 테스트를 수행하지 않음 + return; + } + + const url = 'http://localhost:8080/api/v1/templates/subway'; + const params = { + headers: { + 'Authorization': `Bearer ${data.token}`, + 'Content-Type': 'application/json', + }, + }; + + const res = http.get(url, params); + check(res, { 'status is 200': (r) => r.status === 200 }); + sleep(0.5); +} \ No newline at end of file diff --git a/prometheus.yml b/prometheus.yml new file mode 100644 index 0000000..ef8c150 --- /dev/null +++ b/prometheus.yml @@ -0,0 +1,8 @@ +global: + scrape_interval: 5s # 5초마다 데이터 수집 + +scrape_configs: + - job_name: 'digitaltok-server' + metrics_path: '/actuator/prometheus' + static_configs: + - targets: ['host.docker.internal:8080', 'localhost:8080'] # Spring 서버 주소 (network host 모드 기준) \ No newline at end of file diff --git a/src/main/java/com/digital_tok/global/config/SecurityConfig.java b/src/main/java/com/digital_tok/global/config/SecurityConfig.java index 8c29417..1ace531 100644 --- a/src/main/java/com/digital_tok/global/config/SecurityConfig.java +++ b/src/main/java/com/digital_tok/global/config/SecurityConfig.java @@ -61,7 +61,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // ★ 1. Preflight(OPTIONS) 요청은 인증 없이 무조건 허용 (CORS 해결 핵심) .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() - .requestMatchers("/health").permitAll() + .requestMatchers("/health", "/actuator/prometheus").permitAll() // 인증 없이 접근 가능한 경로 (로그인, 회원가입, 스웨거 등) .requestMatchers("/api/v1/auth/**", "/swagger-ui/**", "/v3/api-docs/**", "/swagger-resources/**").permitAll() diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 686180c..06b8df4 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,5 +1,5 @@ server: - forward-headers-strategy: framework # Nginx 헤더 신뢰 설정 + forward-headers-strategy: framework app: server-url: ${SERVER_URL:http://localhost:8080} @@ -15,9 +15,10 @@ spring: username: ${DB_USERNAME} password: ${DB_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver + jpa: hibernate: - ddl-auto: update # create는 매번 데이터를 날리므로 update 추천 + ddl-auto: update properties: hibernate: format_sql: true @@ -25,7 +26,6 @@ spring: storage: type: s3 - # 최신 버전에서 cloud는 spring 밑에 있어야함 cloud: aws: s3: @@ -35,31 +35,15 @@ spring: stack: auto: false credentials: + # 기본값을 임시로 넣어 에러 방지 access-key: ${AWS_ACCESS_KEY} secret-key: ${AWS_SECRET_KEY} - #profiles: - # active: prod - # ... ?? ?? ... - # security: - # oauth2: - # client: - # registration: - # kakao: - # client-authentication-method: client_secret_post - # client-id: { ?? client-id } - # client-secret: { ?? client-secret } - # redirect-uri: http://{ ?? ec2 public ip ?? }:8080/login/oauth2/code/kakao - # authorization-grant-type: authorization_code - # scope: profile_nickname - # client-name: Kakao - # ... ?? ?? ... - #이메일 서버 설정 (Gmail 예시) mail: host: smtp.gmail.com port: 587 - username: ${MAIL_USERNAME} # 발송할 이메일 주소 - password: ${MAIL_PASSWORD} # 구글 앱 비밀번호 + username: ${MAIL_USERNAME} + password: ${MAIL_PASSWORD} properties: mail: smtp: @@ -67,13 +51,18 @@ spring: starttls: enable: true required: true - connectiontimeout: 5000 - timeout: 5000 - writetimeout: 5000 - # JWT 설정 jwt: - # 시스템 환경 변수 중 JWT_SECRET_KEY 값을 가져오고, 없으면 기본값(뒷부분)을 씁니다. + # JWT 시크릿 키는 최소 32글자 이상의 긴 문자열이어야 합니다. secret: ${JWT_SECRET_KEY} access-token-validity-in-seconds: 3600 - refresh-token-validity-in-seconds: 1209600 \ No newline at end of file + refresh-token-validity-in-seconds: 1209600 + +management: + endpoints: + web: + exposure: + include: prometheus, health, info, metrics + metrics: + tags: + application: digitaltok-server \ No newline at end of file