문제 발생

Spring Rest Docs + Swagger를 써보려고 하였다.

어느정도 설정이 다 끝나서 기분 좋게 마무리 하려고 배포 환경에서 실행하는것 처럼 jar 파일로 실행해보았다.

하지만 jar 파일로 빌드 후 java -jar 명령어로 실행하니 아래처럼 Swagger UI에서 openapi3.yaml을 읽지 못하였다.

분명 IDE에서는 몇번을 해봐도 잘 되는데 jar 파일로 실행해보면 openapi3.yaml 파일을 읽지 못하는 현상이 발생하였다.


문제 해결

jar 파일로 패키징 시 static 파일을 jar 파일에 추가해주지 않아서 발생한 문제였다.

jar 파일로 패키징하면 static 경로의 파일들은 jar 파일 내의 /BOOT-INF/classes/static 경로에서 읽는다.

IDE로 실행할 때는 static 파일을 읽을 수 있었지만, jar 파일로 패키징 할 때 openapi3.yaml 파일을 넣어준 적이 없다.

 

그래서 아래처럼 jar 파일을 만드는 단계인 bootJar 단계에서 openapi3.yaml 파일을 함께 패키징 하도록 설정하였다.

또한 openapi3.yaml 파일이 생성되는 단계인 copyOasToSwagger 다음에 실행되게 하였다.

// build.gradle.kts
tasks {
    bootJar {
        dependsOn("copyOasToSwagger")
        from("build/api-spec") {
            into ("BOOT-INF/classes/static/swagger-ui")
        }
    }
}

 

또한 swagger-initializer.js 의 url 경로를 아래처럼 수정해주었다.

... 생략
window.ui = SwaggerUIBundle({
  url: "/swagger-ui/openapi3.yaml", 
  dom_id: '#swagger-ui',
... 생략

 

task 실행 목록을 보면 원하는 순서대로 동작함을 알 수 있다.

 

Swagger UI 도 잘 동작한다.

문제 발생

배포 환경에서 모니터링을 추가하기 위해 스프링부트 엑추에이터를 적용해보고 있었다.

로컬에서는 /actuator/health 로 접근했을 때는 status가 UP으로 잘 뜨고 있었다.

localhost:8080/actuator/health

 

하지만 EC2 배포 환경에서도 잘 동작하리라 생각했지만 아래처럼 DOWN으로 뜨는 문제가 발생했다.

api.pawland.store/actuator/health

 

네트워크 탭을 확인해보면 아래처럼 상태코드가 503 으로 오는걸 확인할 수 있었다.


문제 해결

결국 로그를 확인하고 나니 금방 해결되는 문제였다.

스프링부트 애플리케이션의 로그를 확인해보니 아래와 같았다.

 

필요한 부분만 살펴보면 아래처럼 레디스 연결에 실패했다는 에러 메시지를 확인할 수 있었다.

[oundedElastic-7] o.s.b.a.d.r.RedisReactiveHealthIndicator : Redis health check failed org.springframework.data.redis.RedisConnectionFailureException: Unable to connect to Redis

 

레디스를 설치하고 다시 헬스 체크 URL로 들어가보니 아래처럼 정상적으로 동작했다.


원인 분석

비록 Redis 서버는 안떴지만, 스프링부트 애플리케이션은 잘 동작하고 있는데 어째서 헬스 체크에 실패했을까?

이는 스프링부트 액추에이터의 동작원리를 뜯어보면 알 수 있다.

간단하게 요약하자면 액추에이터의 헬스 체크는 여러 HealthIndicator 가 수집한 상태를 토대로 status 를 판단한다.

여기서 DataSource의 헬스 체크도 하기 때문에 Redis가 연결되지 않았을 때 DOWN 이라는 status를 반환한 것이다.

로그 저장용 DB 처럼 운영 환경과 크게 상관없는 DB를 운용할 때, 이것 때문에 운영환경에 문제가 생기지 않도록 조심해야겠다.

단순하게 상태코드 200만 반환하는게 헬스 체크인줄 알았는데 동작 원리를 모르면 이런 상황도 만날 수 있단걸 느꼈다.

 

이 글에 스프링부트 액추에이터의 동작 원리가 잘 정리 되어 있으니 참고하자.

 

 

참고자료

 

Spring Boot Actuator의 헬스체크 살펴보기

서버의 상태를 알려주는 헬스 체크에 대해 알고 계시나요? 단순히 200 OK만 내려주겠거니 하고 별로 신경을 안 쓰고 계셨나요? 해당 포스트에서는 Spring Boot Actuaor가 제공해주는 헬스 체크는 어떤

toss.tech

 

문제 발생

GitHub Actions와 CodeDeploy로 CI/CD를 구현하고 있었다.

GitHub Actions 워크 플로우는 아래와 같다.

name: CI/CD with S3 and CodeDeploy

on:
  pull_request:
    branches: [ "main" ]

env:
  AWS_REGION: ap-northeast-2
  S3_BUCKET_NAME: midcondria-cicd
  CODE_DEPLOY_APPLICATION_NAME: midcon-codedeploy-app
  CODE_DEPLOY_DEPLOYMENT_GROUP_NAME: midcon-codedeploy-deploy-group

jobs:
  build:

    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v4
    - name: Set up JDK 17
      uses: actions/setup-java@v4
      with:
        java-version: '17'
        distribution: 'temurin'  

    ## application.yml 파일 생성
    - name: make application.yml
      run: |
        echo ">> mkdir & touch"
        mkdir -p ./src/main/resources
        cd ./src/main/resources
        touch ./application.yml
        echo ">> copy application.yml"
        echo "${{ secrets.PROPERTIES }}" >> ./application.yml
        
    - name: Grant execute permission for gradlew
      run: chmod +x gradlew
      shell: bash

    - name: Build with Gradle Wrapper
      run: ./gradlew build -x test
      shell: bash

    - name: Make zip file
      run: zip -r ./myapplication.zip appspec.yml scripts/* ./build/libs/team-0.0.1-SNAPSHOT.jar
      shell: bash

    - name: Configure AWS credentials
      uses: aws-actions/configure-aws-credentials@v1
      with:
        aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }}
        aws-secret-access-key: ${{ secrets.AWS_SECRET_KEY }}
        aws-region: ${{ env.AWS_REGION }}

    - name: Upload to AWS S3
      run: |
        aws s3 cp \
          --region $AWS_REGION ./myapplication.zip s3://$S3_BUCKET_NAME/$GITHUB_SHA.zip

    - name: Deploy to AWS EC2 from S3
      run: |
        aws deploy create-deployment \
          --application-name ${{ env.CODE_DEPLOY_APPLICATION_NAME }} \
          --deployment-group-name ${{ env.CODE_DEPLOY_DEPLOYMENT_GROUP_NAME }} \
          --deployment-config-name CodeDeployDefault.AllAtOnce \
          --s3-location bucket=$S3_BUCKET_NAME,key=$GITHUB_SHA.zip,bundleType=zip \
          --region $AWS_REGION

 

위 워크플로우에서 Upload to AWS S3 까지는 성공했으나, 아래와 같은 에러가 발생했다.

GitHub Actions 로그

EC2 CodeDeploy Agent 로그


문제 해결

appspec.yml was not found 라는 에러 메시지에서도 알 수 있듯 appspec.yml 이라는 파일이 없어서 생긴 문제였다.

결국 나도 오타로 인한 에러이긴 했는데 검색 결과들은 확실한 오타들이 있었다.

appsec.yml 이라던가 appsepc.yml 이라던가...

나는 AppSpec.yml 이라고 의도적으로 대문자로 작성했던거라 설마 이거 문제겠나 싶었다.

 

하지만 로그는 정확한가보다.

CodeDeploy를 사용할 때는 꼭 파일명을 appspec.yml 으로 사용하자.

문제 발생

로컬에 MySQL을 설치하고 DBeaver로 잘 접속되는지 확인해보는 도중 발생한 문제이다.

MySQL 계정을 만들고 접속하려하니 Public Key Retrieval is not allowed 라는 에러가 뜨면서 접속이 안됐다.

검색해보니 MySQL 8.0 버전 이상부터 발생하는 문제라고 한다.


문제 해결

몇 가지 추가 설정만 해주면 금방 해결됐다.

1-1. Driver properties 탭 이동

1-2. Driver properties 탭 이동

스크롤을 쭉 내리면 아래처럼 User Properties가 뜬다.

여기에 우클릭을 하면 Add new property 버튼이 나온다.

1-3. 추가 설정

아래 그림처럼 설정해준다.

allowPublicKeyRetrieval: true

useSSL: false

1-4. 결과 확인

이제 잘 된다.

문제 발생

프론트엔드 공부를 하면서 스프링 서버로 CORS 관려해서 이것저것 연습해봐서

이제 CORS에 관해서는 어느정도 잘 알고 있다고 생각했고 나름 자부하고 있었다.

솔직히 아래 설정만 하면 웬만한 CORS 설정은 다 끝나는줄 알았다.

또한 팀 프로젝트 기간 중 그동안 API를 만들어두고 프론트 측에서 아무 말도 없길래 문제 없다고 생각했다.

하지만 갑자기 CORS 문제가 생기는 이슈가 발생했다.

WebMvcConfigurer

@Configuration
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {

    private final AppConfig appConfig;

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
            .allowedOrigins(appConfig.getFrontTestUrl(), appConfig.getFrontDeployUrl(), appConfig.getBackUrl())
            .allowCredentials(true);
    }
}

문제 해결

사실 결론만 보자면 별거는 없었다.

기본적으로 allowedMethods는 GET, POST 요청만 허용하고 있기 때문이었다.

프론트에서도 기존 만들어둔것 중 GET, POST 요청만 이용했었기에 별 다른 이슈가 없었던 것이다.

그래서 PUT 요청을 하면서부터 문제가 생기기 시작했다.

연습할 때도 PUT 요청까지 연습해보지 않고 GET, POST로만 연습했기 때문에 처음 겪어봤던 것이다.

따라서 아래처럼 PUT, DELETE 요청도 허용하게끔 설정하면 문제 없이 해결되었다.

WebMvcConfigurer

@Configuration
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {

    private final AppConfig appConfig;

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
            .allowedOrigins(appConfig.getFrontTestUrl(), appConfig.getFrontDeployUrl(), appConfig.getBackUrl())
            .allowedMethods("GET", "POST", "PUT", "DELETE")
            .allowCredentials(true);
    }
}

문제 발생

Nginx, certbot을 이용하여 HTTPS 적용을 마쳤다.

그리고 기분 좋게 스웨거로 잘 되는지 확인해보려 했는데 갑자기 아래처럼 CORS 에러가 발생했다.

문제 해결

분명 HTTPS 적용 전에는 잘 됐는데 무엇이 문제인지 생각해보았다.

바뀐건 HTTPS, Nginx 설정 뿐이어서 이 두가지를 위주로 검색을 해보고 답을 찾았다.

결론은 스웨거 서버 URL 설정 기본 값이 http://localhost:8080 이라서 발생한 문제였다.

HTTPS 적용 전에는 프로토콜이 같으므로 문제없었지만 HTTPS 적용 후 프로토콜이 달라져서 CORS 에러가 발생한듯 하다.

그래서 SwaggerConfg를 아래처럼 수정하여 문제를 해결했다.

SwaggerConfig

AppConfg로 yml 파일로 백엔드 도메인을 환경변수로 분리하였다.

yml 파일은 이 글에서와 동일하다.

이러면 로컬에선 서버 URL이 localhost:8080이고 배포 시에는 배포 도메인으로 바뀌므로 CORS 문제를 해결할 수 있다.

@Configuration
@RequiredArgsConstructor
@SecurityScheme(
    name = "jwt-cookie",
    type = SecuritySchemeType.APIKEY,
    in = SecuritySchemeIn.COOKIE
)
public class SwaggerConfig {

    private final AppConfig appConfig;

    @Bean
    public OpenAPI serverApiConfig() {
        Server server = new Server();
        server.setUrl(appConfig.getBackUrl());
        server.description("백엔드 도메인");
        return new OpenAPI()
            .addServersItem(server)
            .info(new Info().title("PAWLAND API")
                .description("PAWLAND API SWAGGER UI입니다."));
    }
}

문제 발생

레디스 사용도 끝내고 기분 좋게 깃헙에 PR을 날리는 중 발생한 에러이다.

PR을 올리면 깃헙액션이 동작하여 빌드 테스트를 하도록 설정해두었다.

yml 파일 변경이 있어서 그것도 업데이트 해주었고 빌드는 문제없이 진행될거라고 생각했다.

로컬에서는 빌드와 테스트 모두 잘 됐기 때문에 문제될 부분은 없다고 생각했다.

하지만 아래와 같은 에러가 발생했다.


문제 해결

결론부터 말하자면 깃헙 액션에서 빌드하는 우분투 환경에 레디스가 설치되지 않아서 생긴 문제이다.

에러 메시지를 확인하면 아래와 같이 제법 명시적으로 어떤 문제인지 알려준다.

org.springframework.data.redis.RedisConnectionFailureException at AuthControllerTest.java:164

 

따라서 깃헙액션 워크플로우에 아래처럼 레디스 환경을 조성해주면 된다.

또한 레디스를 사용한다면 EC2에서도 레디스를 설치해둬야 쓸 수 있다.

name: Java CI with Gradle

on:
  pull_request:
    branches: [ "develop" ]

jobs:
  build:

    runs-on: ubuntu-latest

    # Github Actions 환경에서 레디스 연결
    services:
      redis:
        image: redis:latest
        ports:
          - 6379:6379

    ... 이후는 자바 빌드 확인 코드

 

참고자료

 

[OpenRoadmaps] Github Actions를 통한 CI/CD 구축하기

테스트 코드와 AWS를 통한 배포환경이 준비되었으니 이제 CI/CD를 구축할 수 있다. CI/CD의 간단한 정의는 다음과 같다. CI (Continuous Integration): 레포지트로의 코드가 변경될 때, 자동으로 빌드와 테스

devjaewoo.tistory.com

 

문제 발생

기존에 gradle-groovy 방식을 주로 썼는데 이번에 gradle-kotlin 방식을 써보려고 했다.

또한 스프링부트 3.0 이상을 쓰면서 QueryDSL을 써본적이 없었기에 겸사겸사 build.gradle.kts로 세팅하고 있었다.

의존성 추가 코드는 아래와 같다.

// build.gradle.kts
...
dependencies {
    // QueryDSL
    implementation("com.querydsl:querydsl-jpa")
    implementation ("com.querydsl:querydsl-core")
    implementation ("com.querydsl:querydsl-collections")
    annotationProcessor ("com.querydsl:querydsl-apt:5.0.0")
...
}

QueryDslConfig

또한 아래처럼 JPAQueryFactory의 생성자 파라미터로에 엔티티 매니저를 넣어줘야하는데

생성자에 엔티티 매니저를 넣으려 하니 컴파일 에러가 발생했다.


문제 해결

결론부터 말하자면 의존성 추가 부분을 아래처럼 버전을 명시해주면 해결된다.

본인은 기존 의존성 추가 코드를 안지우고 아래에 코드만 추가해서 의미없는 시간을 너무 허비했다.

꼭 기존 QueryDSL의존성 추가 코드는 지우고 쓰자.

// build.gradle.kts
...
val queryDslVersion = "5.0.0" // QueryDSL Version Setting

dependencies {
    // QueryDSL
    implementation("com.querydsl:querydsl-jpa:${queryDslVersion}:jakarta")
    implementation ("com.querydsl:querydsl-core")
    implementation ("com.querydsl:querydsl-collections")
    annotationProcessor("com.querydsl:querydsl-apt:${queryDslVersion}:jakarta")
    annotationProcessor("jakarta.annotation:jakarta.annotation-api") // java.lang.NoClassDefFoundError (javax.annotation.Entity) 방지
    annotationProcessor("jakarta.persistence:jakarta.persistence-api") // java.lang.NoClassDefFoundError (javax.annotation.Entity) 방지
...
}

 

기존의 의존성을 추가 코드는 아래처럼 그림 JPAQueryFactory 구현체가 javax에서 엔티티 매니저를 탐색한다.

위처럼 세팅하고 나면 아래와 같이 엔티티 매니저를 jakarta에서 찾기 때문에 오류가 해결된다.

변경 전

변경 후

 

혹시 필요한 사람이 있을지 모르니 build.gradle.kts 전문은 아래와 같다.

// build.gradle.kts
plugins {
	java
	id("org.springframework.boot") version "3.2.4"
	id("io.spring.dependency-management") version "1.1.4"
}

group = "com.commerce"
version = "0.0.1-SNAPSHOT"
val queryDslVersion = "5.0.0" // QueryDSL Version Setting

java {
	sourceCompatibility = JavaVersion.VERSION_17
}

configurations {
	compileOnly {
		extendsFrom(configurations.annotationProcessor.get())
	}
}

repositories {
	mavenCentral()
	maven(url="https://jitpack.io")
}

dependencies {
	implementation("org.springframework.boot:spring-boot-starter-actuator")
	implementation("org.springframework.boot:spring-boot-starter-data-jpa")
	implementation("org.springframework.boot:spring-boot-starter-data-redis")
	implementation("org.springframework.boot:spring-boot-starter-web")
	implementation("org.springframework.boot:spring-boot-starter-validation")

	// 아임포트
	implementation("com.github.iamport:iamport-rest-client-java:0.2.23")

	// 웹소켓
	implementation("org.springframework.boot:spring-boot-starter-websocket")

	// JWT
	implementation("io.jsonwebtoken:jjwt-api:0.12.1")
	runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.1")
	runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.1")
	implementation("org.springframework.security:spring-security-crypto")
	implementation("org.bouncycastle:bcprov-jdk15on:1.70")

	// QueryDSL
	implementation("com.querydsl:querydsl-jpa:${queryDslVersion}:jakarta")
	implementation ("com.querydsl:querydsl-core")
	implementation ("com.querydsl:querydsl-collections")
	annotationProcessor("com.querydsl:querydsl-apt:${queryDslVersion}:jakarta")
	annotationProcessor("jakarta.annotation:jakarta.annotation-api")
	annotationProcessor("jakarta.persistence:jakarta.persistence-api")

	annotationProcessor("org.projectlombok:lombok")
	compileOnly("org.projectlombok:lombok")
	runtimeOnly("com.h2database:h2")
	runtimeOnly("com.mysql:mysql-connector-j")
	testImplementation("org.springframework.boot:spring-boot-starter-test")
}

tasks.withType<Test> {
	useJUnitPlatform()
}

val querydslDir = layout.buildDirectory.dir("generated/querydsl").get().asFile

sourceSets.getByName("main") {
	java.srcDir(querydslDir)
}

tasks.withType<JavaCompile> {
	options.generatedSourceOutputDirectory.set(file(querydslDir))
}

// clean 이후에 querydsl 폴더를 지움
tasks.named("clean") {
	doLast {
		file(querydslDir).delete()
	}
}

+ Recent posts