문제 상황

팀원이 spring filter가 이상하다고 도움을 요청했다. 확인해보니 swagger에서 실행한 API 요청이 filterChain을 2번 타는 상황이었다. 더 자세히 살펴보니 AccessDeniedHandler를 거친 요청이 다시 필터를 거쳐서 AuthenticationEntryPoint로 도달하는 현상이다. 403이 발생해야 하는 요청이 401이 발생한 것이다.

그래서 내가 진행하던 프로젝트에서 실행해보니 이런 문제는 발생하지 않았다. 팀원의 spring boot 버전은 3.x이고, 내가 테스트해본 환경은 spring boot 2.x 버전이다.

뿐만 아니라 custom filter를 추가하고, 해당 필터에서 exception을 던져도 다시 그 filter를 호출한다. doFilter를 하지 않았는데 어떻게 다시 요청이 들어오지?

 

원인 파악

디버그 모드로 request의 uri를 찍어보면 처음은 요청한 url(/users)이 잘 나오는데, 두번째에서는 에러 url(/error)이 나타난다. 처음 요청에서 문제가 발생했고, 이를 해결하기 위해 /error로 rediect 한 것으로 보인다. 즉 실제로 요청이 2번 있었기 때문에 filter chain을 2번 탄 것이다.

 

그럼 왜 redirect를 하고 있을까?

HttpServletResponse  sendError() 함수를 사용하기 때문에 발생했다. 문제가 발생한 코드를 살펴보자.

@Component
class JwtAccessDeniedHandler : AccessDeniedHandler {
    @Throws(IOException::class, ServletException::class)
    override fun handle(
        request: HttpServletRequest,
        response: HttpServletResponse,
        accessDeniedException: AccessDeniedException
    ) {
        response.sendError(HttpServletResponse.SC_FORBIDDEN)
    }
}

 

AccessDeniedHandler를 custom하게 등록했는데, 이 때 sendError 함수를 사용했다. 해당 함수의 주석을 보면 다음과 같이 나와있다.

sendError 코드의 주석 내용

Sends an error response to the client using the specified status code and clears the output buffer. The server defaults to creating the response to look like an HTML-formatted server error page containing the specified message, setting the content type to "text/ html", leaving cookies and other headers unmodified. If an error-page declaration has been made for the web application corresponding to the status code passed in, it will be served back in preference to the suggested msg parameter. If the response has already been committed, this method throws an IllegalStateException. After using this method, the response should be considered to be committed and should not be written to.

 

또한 Spring 공식 문서에도 잘 나와있는데, /error 경로를 맵핑하여 JSON 응답 혹은 WhiteLabel 에러 페이지를 보여준다고 설명한다.

 

즉 Spring에서는 HttpServletContainer의 에러를 처리하기 위해 /error 경로를 활용하고 있고, sendError() 함수 호출 시 /error 경로로 redirect 하여 등록된 WhiteLabel 페이지를 보여주거나 error json을 내려준다.

 

그럼 왜 spring boot 2.x에서는 잘 됐을까?

stackoverflow를 확인해보면, spring boot 2.x부터 error endpoint가 bypass 되던 설정을 제거하고, secure 설정을 default로 넣는다고 되어있다.

Spring Boot 2.0 doesn’t deviate too much from Spring Security’s defaults, as a result of which some of the endpoints that bypassed Spring Security in Spring Boot 1.5 are now secure by default. These include the error endpoint and paths to static resources such as /css/**, /js/**, /images/**, /webjars/**, /**/favicon.ico. If you want to open these up, you need to explicitly configure that.

 

여기서 알 수 있는 점은, /error 경로에 대해서 spring boot 2 부터는 bypass가 아니기 때문에 filterChain을 거친다는 의미이다.

 

Spring Security 5.7 공식문서를 확인해보면, DispatcherType.ERROR 요청은 AuthorizationFilter를 타지 않는다고 한다.

By default, the AuthorizationFilter does not apply to DispatcherType.ERROR and DispatcherType.ASYNC. We can configure Spring Security to apply the authorization rules to all dispatcher types by using the shouldFilterAllDispatcherTypes method

 

그러나 Spring Security 6 부터는 모든 Dispatch에 대해서는 AuthorizationFilter를 타게 변경되었다. [공식문서]

All Dispatches Are Authorized

The AuthorizationFilter runs not just on every request, but on every dispatch. This means that the REQUEST dispatch needs authorization, but also FORWARDs, ERRORs, and INCLUDEs.

 

추가로 filter chain이 2번 탈 수 있기 때문에 주의하라는 내용도 있다.

then Boot will dispatch it to the ERROR dispatch.

In that case, authorization also happens twice; once for authorizing /endpoint and once for dispatching the error.

For that reason, you may want to permit all ERROR dispatches.

 

정리하자면 Authorization에서 해당 요청에 대한 권한 처리를 수행하는데, DispatcherType.ERROR 인 경우 5.7에서는 AuthorizationFilter를 타지 않고, 6.0 부터는 AuthorizationFilter를 타게 되므로 /error 에 대한 권한 처리가 필요하다.

 

결론

  1. request에서 에러가 발생하면 whitelabel 페이지를 보여주거나, error json을 내려주는 작업을 위해서 /error 로 요청을 다시 보낸다. (DispatcherType.ERROR)
  2. Spring Boot 2.x 부터 error endpoint에 대해서 bypass 설정은 없어지고, secure가 기본 설정이 되었다
  3. Spring Security 5.7.9에서는 DispatcherType.ERROR 는 AuthorizationFilter 를 거치지 않기 때문에 filter를 타긴 하지만 권한 에러는 발생하지 않는다
  4. Spring Security 6.3.2 에서는 모든 Dispatcher에 대해서 AuthorizationFilter 를 타기 때문에 /error 요청에 대한 권한 에러가 발생하게 된다
    • 에러발생 케이스 흐름
    • AccessDeniedHandler 에서 403 후 sendError → /error → AuthenticationEntryPoint에서 401 발생 (인증정보가 없기 때문)

 

해결방법

1. DispatcherType.ERROR 를 통과시키기

/error 로 요청이 갔을 때 필터를 타는걸 막을 순 없으므로, 해당 DispatcherType을 permitAll 해준다.

이렇게 추가해준다면, 결국 filterChain은 2번 호출되게 된다.

http {
    authorizeHttpRequests {
        authorize(DispatcherTypeRequestMatcher(DispatcherType.FORWARD), permitAll)
        authorize(DispatcherTypeRequestMatcher(DispatcherType.ERROR), permitAll)
        authorize("/endpoint", permitAll)
        authorize(anyRequest, denyAll)
    }
}

 

 

2. sendError() 를 쓰지 않기

ExceptionFilter, AuthenticationEntryPoint , AccessDeniedHandler 등 에러를 처리하는 곳에서 sendError 함수를 사용하지 않는다.

filter를 2번 탔을 때 filter를 거치면서 발생하는 오버헤드가 존재하기 때문에, /error 로 에러처리를 하지 않는 이 방법을 추천한다.

  • AS-IS
    class JwtAuthenticationEntryPoint : AuthenticationEntryPoint {
        @Throws(IOException::class)
        override fun commence(
            request: HttpServletRequest,
            response: HttpServletResponse,
            authException: AuthenticationException
        ) {
            response.sendError(HttpServletResponse.SC_UNAUTHORIZED)
        }
    }
  • TO-BE
    class JwtAuthenticationEntryPoint : AuthenticationEntryPoint {
        @Throws(IOException::class)
        override fun commence(
            request: HttpServletRequest,
            response: HttpServletResponse,
            authException: AuthenticationException
        ) {
            response.status = HttpServletResponse.SC_UNAUTHORIZED
        }
    }

 

비고

FilterChain에서 AuthorizationFilter 가 안보임

spring security 5.7에서는 가능한 현상이다.

만약 security 설정에서 authorizeRequests 를 사용했다면, FilterChainProxy 가 맨 마지막에 생성되어 있을 것이다.

spring 공식문서에서는 AuthorizationFilter를 사용하고 싶다면, authorizeRequests → authorizeHttpRequest 로 바꾸어서 사용하라고 권장한다. [공식문서]

 

그럼 바꿔서 얻는 이점은 무엇일까?

spring은 이렇게 설명하고 있습니다

1. Uses the simplified AuthorizationManager API instead of metadata sources, config attributes, decision managers, and voters. This simplifies reuse and customization.
2. Delays Authentication lookup. Instead of the authentication needing to be looked up for every request, it will only look it up in requests where an authorization decision requires authentication.

3. Bean-based configuration support.

 

migration을 하려면 [공식문서]를 참고하면 된다.

 

관련 문서

'Spring' 카테고리의 다른 글

PermitAll의 함정  (0) 2024.08.16
Spring @RequestParam 주의할 점  (0) 2024.08.16
테스트 환경 설정하기  (1) 2024.08.08

이번 글은 굉장히 짧다. 잘 동작하던 문자메세지 전송 기능이 간헐적으로 문자가 전송되지 않는다는 문의를 받아 해당 이슈를 찾으며, 비즈플러스 사용 시 주의해야 할 점을 정리해봤다.

 

우선 내가 겪은 문제는 다음과 같다.

  1. 이벤트 당첨자 50명에게 기프티콘을 보내야함
  2. 사용자마다 기프티콘이 다르기 때문에 사용자별로 사진이 필요함
  3. 50개의 메세지를 한번에 전송했는데, 이 중 6명에게 사진이 오지 않았다는 문의가 옴
  4. 파일 이름을 "디저트세트 1" 으로 설정해서 전송했을 때 사진이 누락되는 문의도 추가로 인입됨

파일 업로드

비즈플러스는 파일 업로드 하여 MMS 전송 시 같이 보낼 수 있다. 이 때 몇가지 유의사항을 지키지 않으면 사진이 전송되지 않거나 사진 등록이 실패할 수 있다.

  • 확장자 jpg/jpeg 만 가능하고, 해상도 2000*2000, 300KB 제한
  • API 요청 당 파일 업로드 수는 1개만 가능. 분당 최대 30회 업로드 가능
  • 파일명과 크기가 같은 이미지는 1시간에 1건만 등록 가능. 파일 업로드 시 파일을 사용할 수 있는 키를 발급하는데, 만료시간 전까지 사용 가능
  • 파일 만료시간은 7일
  • 파일명 길이 제한은 40자. UTF-8 인코딩 가능한 문자는 사용이 가능하지만, 알파벳/숫자 사용 권장

내가 만난 문제는 분당 30회 제한으로 인해 파일 업로드가 실패한 케이스이다. 파일 업로드 API 요청을 try/catch로 에러 핸들링을 하고 있어서 메세지 전송은 가능했지만 파일 전송은 실패했던 것이다.

 

또한 메세지에 전송하기 위한 파일은 S3에서 다루고 있는데, S3에 파일을 업로드할 때 파일 원본의 이름을 S3 object의 metadata에 저장한다. 그리고 이를 불러와서 사용하는데, 이 때 문제가 발생했다. 한글 이름으로 된 이미지를 사용했을 경우, URL 인코딩하여 파일 이름 length 제한을 체크하는데, 이 때 url encoding으로 인해 파일 이름이 길어져 에러가 발생한 것으로 예상한다.

해당 내용은 비즈플러스 개발문서에 존재하지 않아 이메일로 문의했다.

위 문제는 파일 업로드 시 랜덤 영문+숫자 문자열을 만들어 넣는 방법으로 해결했다.

메세지 전송

  • 전송 요청 시 TPS 100 내외 권장
  • 1개의 메세지를 최대 200개의 수신번호로 발송 가능하며, 수신번호 별 치환문구는 5개까지 사용 가능

위 제약사항으로 현재 프로젝트에서는 100개씩 chunking해서 api 요청을 보내고 있다.

 

인포뱅크 비즈플러스를 사용하면서 만난 문제와 주의할 점을 정리해보았다.

개발 문서를 봐도 눈에 띄게 나와있지 않기도 하고, 개발 문서에 나와있지 않은 제약도 있기 때문에 해당 문제를 만난 사람들에게 도움이 되었으면 한다.

graphQL을 한번도 안써본 사람이 graphQL API와 연동하기까지의 과정과 그 속에서 했던 고민들을 공유하려고 한다.

 

GraphQL이란?

GraphQL은 Facebook에서 개발된 오픈소스 기술로 데이터 질의 (Query + Schema) 언어이다. [출처]

sql은 데이터베이스 시스템에 저장된 데이터를 효율적으로 가져오는 것이 목적이고, gql은 웹 클라이언트가 데이터를 서버로 부터 효율적으로 가져오는 것이 목적입니다. [출처]

GraphQL은 REST같은 API 디자인에 관한 새로운 관점의 스펙이다. GraphQL의 핵심은, 리소스를 url이 아니라 Query를 통해 표현하는 것이다. [출처]

 

적합한 설명을 찾다가 제일 이해하기 쉽고 직관적인 3가지 문장을 가져와봤다.

결국 GraphQL은 REST의 단점을 보완하기 위해 개발된 기술이기 때문에, REST와 동일하게 HTTP 프로토콜 위에서 동작한다.

  REST GraphQL
endpoint 리소스 별 여러 endpoint 단일 endpoint
data 고정된 구조 클라이언트가 정의한 구조
error status code로 구분 에러 메세지로 구분
cache 쉬움 어려움

 

이렇게 단정지어서 표로 구분하면 무조건 이런 특성을 갖는다는 오해가 있을 수 있지만(마치 checked exception에 대한 백기선님의 따끔한 영상처럼), 처음 맛보는 GraphQL이기 때문에 직관적으로 이해하는 것이 제일 중요하다고 생각해서 가져와봤다.

 

기본 개념

Query / Mutation

데이터를 읽을 때 REST에서는 GET, POST, PUT, DELETE 등의 Method로 행위를 구분한다.

GraphQL에서는 행위를 2가지로 구분하는데 Query, Mutation이다. REST와 비교한다면 다음과 같다.

 

REST GraphQL
GET Query
POST / PUT / PATCH / DELETE Mutation

 

  • Query
    조회하려는 필드(객체)를 입력하고, 중괄호에 원하는 필드를 추가하면 된다. 아래 예제는 user의 id, name을 조회하는 요청이다.
    # request
    {
        user {
            id
            name
        }
    }
    
    # resposne
    {
        "data": {
            "user": {
                "id": "1",
                "name": "kyun"
            }
        }
    }
  • Mutation
    사용하려는 함수(?)와 함께 input을 넣는다. 그리고 중괄호에 return 받을 필드를 입력한다. 아래는 name으로 유저를 생성하는 요청이다.
    # request
    mutation {
        createUser(name: "kyun") {
            id
            name
        }
    }
    
    # resposne
    {
        "data": {
            "createUser": {
                "id": "1",
                "name": "kyun"
            }
        }
    }

Query, Mutation 모두 이름을 가질 수 있다. 예시를 보면 쉽게 이해할 수 있는데, 아래 쿼리는 형식만 다르고 결과는 모두 같다.

# 기본 쿼리 형태
{
    user {
        id
        name
    }
}

# query 키워드 추가
query {
    user {
        id
        name
    }
}

# 쿼리 이름(findUserQuery)이 포함된 형태
query findUserQuery {
    user {
        id
        name
    }
}

 

# 기본 형태
mutation {
    createUser(name: "kyun") {
        id
        name
    }
}

# 쿼리 이름(createUser)이 추가된 형태
mutation createUser {
    createUser(name: "kyun") {
        id
        name
    }
}

 

Schema / Type / Input

  • Type
    GraphQL의 기본적인 구성 요소는 타입이다. 객체라고 생각하면 된다. 타입은 api에서 가져올 객체와 그 필드를 나타낸다.
    type User {
    	id: ID!
    	name: String!
    	address(city: String): String
    }
    

    타입의 모든 필드는 argument를 가질 수 있다. 이는 Query 예제에서 봤던 argument와 동일하지만 의미는 다르다.

    query에 존재하는 인자는 필터링, 검색 등 쿼리를 제어하는 역할을 수행하는 반면, type의 field에 존재하는 인자는 필드의 결과를 수정하거나 데이터 처리 방식을 제어하는 역할을 수행한다. (추측하기론 datetime format 과 같은 역할을 할 것으로 예상된다)

  • Schema
    Schema는 API의 구조의 기능을 정의하는 것이다. 위 query와 mutation을 보면서 api 스펙을 어디에 정의했길래 이게 가능한건지? 라는 생각이 들었다면, graphQL에서는 이를 schema라고 한다.

    graphQL에는 3가지의 최상위 타입이 존재하고, 이 타입에 클라이언트가 호출할 수 있는 필드 및 API 구조를 정의한다. 타입은 Query, Mutation, Subscription이 존재하는데 Query와 Mutation만 다루도록 하겠다.
    schema {
      query: Query
      mutation: Mutation
    }

    • Query 정의
      type Query {
          user(id: ID): User
          users: [User!]!
      }

      위 예제는 조회 진입점이 2개인 것을 의미한다. REST로 이야기 하자면 endpoint가 2개인 것과 비슷하다(graphQL에서는 진입점을 한번에 여러개를 사용 가능하다).

      user 에서 소괄호 안의 값은 인자이다. 조회 조건으로, id로 유저를 조회하는 것과 동일하다. 뒤의 : 는 return 타입을 정의한다.
      users 에서 보이는 ! 는 not null 타입을 뜻한다.
      위 스키마를 사용한 예제는 다음과 같다.
      # request
      {
          user(id: "1") {
              id
              name
          }
          users {
              id
              name
              email
          }
      }
      
      # response
      {
          "data": {
              "user": {
                  "id": "1",
                  "name": "kyun"
              },
              "users": [
                  {
                      "id": "1",
                      "name": "kyun",
                      "email": "kyun@example.com"
                  }
              ]
          }
      }
  • Mutation 정의
    데이터를 수정하기 위한 진입점을 정의한다.
    type Mutation {
        createUser(name: String!): User
        deleteUser(id: ID): Boolean
    }

    위 mutation 스키마를 사용하는 예제는 다음과 같다.
    # request
    mutation {
        createUser(name: "kyun") {
            id
            name
        }
        deleteUser(id: "1")
    }
    
    # response
    {
        "data": {
            "createUser": {
                "id": "1",
                "name": "kyun"
            },
            "deleteUser": true
        }
    }
  • Input
    인자로 객체를 전달하고 싶을 때 사용하는 타입이다. DTO라고 생각하면 이해가 쉽다. 주로 Mutation에서 사용한다.
    input UserInput {
        name: String!
        age: Int
    }
    input의 필드는 인자를 가질 수 없다. 입력 데이터를 구조화하는 역할이다.

    ❓ type과 input의 차이는 무엇이지?
    → DTO와 객체의 차이와 비슷하다.
      Type Input
    역할 데이터 모델 정의 데이터 입력
    용도 응답 타입 정의 mutation 입력 데이터 구조화

 

Spring Boot에서 GraphQL 호출하기

GraphQL로 만들어진 외부 API를 호출하기 위한 방법은 3가지가 있다.

HTTP Request

graphQL도 REST와 마찬가지로 http 프로토콜 위에서 동작하는 API이다. 따라서 OkHttp , OpenFeign 과 같은 http client를 사용해서 요청하면 된다. Content-Type 헤더는 application/json 과 application/graphql 모두 사용 가능한 것으로 알고 있지만, request body가 다르고, graphQL api에 따라 허용되는 content type이 다를 수 있다.

내가 연동해야하는 지갑 API의 경우에는 application/json 만 사용 가능한 것으로 확인된다.

val query = """
        {"query": "{ user(id: \"$id\") }"
    """.trimIndent()

val headers = HttpHeaders().apply {
    contentType = APPLICATION_JSON
}

val response = httpPost {
    url("https://example.com/graphql")
    header {
        headers.toSingleValueMap().forEach { (k, v) ->
            k to v
        }
    }

    body {
        body?.let {
            json(it)
        } ?: json("")
    }
}
...

 

spring-boot-starter-graphql

  1. 의존성 추가
    spring에서 제공하는 graphQL의 client를 활용하기 위해서는 webClient를 사용해야 한다. 따라서 webflux가 없다면 추가가 필요하다.
    implementation("org.springframework.boot:spring-boot-starter-graphql")
    // WebClient 사용하기 위함
    implementation("org.springframework:spring-webflux")
  2. client 생성
    val client = WebClient.buidler()
        .baseUrl("https://example.com/graphql")
        .build()
    
    val graphQlClient = HttpGraphQlClient.builder(client).build()
  3. api call
    val query = """
        query {
            user {
                id
                name
            }
        }
    """
    
    val response = graphQlClient.document(query)
        .executeSync()

⚠️ HttpGraphQlClientMediaType.APPLICATION_GRAPHQL_RESPONSE 을 사용한다. 이는 spring 6.0.3 버전부터 사용이 가능하므로, spring version 확인이 필요하다. [Spring GraphQL 공식문서] [MediaType 공식문서]

 

Apollo GraphQL (추천)

apollo는 client와 server 모두 사용 가능한, graphQL을 편하게 사용하도록 도와주는 라이브러리이다. [공식 문서]

 

최신 버전은 v4 이지만, spring 적용하기 위해선 Gradle과 Kotlin version 문제로 v3 를 기준으로 작성하였다. requirements에 부합하는 환경에서는 v4를 사용하는 것을 권장한다. (적용하려는 프로젝트에서는 gradle major version을 올려야 했고, kotlin 버전도 minor 버전을 올려야했기 때문에 바로 v4를 적용하지는 않았다.)

  •  

아래 스텝을 따라가면 쉽게 적용할 수 있을 것이다.

  1. 버전 확인 [공식문서]
  2. schema.graphqls 파일 추가
    사용할 schema가 정의된 파일이다. 해당 파일이 존재하지 않을 경우, 다음과 같은 에러가 발생한다. (gradle sync 작업에서도 에러 발생함) 확장자가 graphqls 임을 주의하자.
    No schema file found in:
    src/main/graphql
    
     
    graphql 쿼리 파일들 및 스키마 파일을 둘 directory 를 만든다. 나는 src/main/resources/graphql 경로에 추가하였다. 기본 경로는 src/main/graphql 이다.
    아래는 schema.graphqls 예시 코드이다.
    type Query {
        user(id: ID): User
    }
    
    type User {
        id: ID!
        name: String!
    }
  3. 의존성 추가 및 build.gradle.kts 수정
    plugins {
        id("com.apollographql.apollo3") version "3.8.3"
    }
    
    dependencies {
        implementation("com.apollographql.apollo3:apollo-runtime:3.8.5")
    }
    
    apollo {
        service("service") {
            packageName.set("com.example.example")
            // schema 경로를 변경했기 때문에 추가한 설정임
            srcDir("src/main/resources/graphql")
        }
    }
  4. graphql 쿼리 파일 추가 및 빌드
    당연히 해당 쿼리는 schema의 Query 에 추가되어 있어야 한다.
    query FindUserQuery($id: ID!) {
        user(id: $id!){
            id
            name
        }
    }

    파일을 추가한 뒤 꼭 빌드를 해야한다. 빌드를 해야 FindUserQuery.kts 파일이 만들어지고, 이를 소스코드에 사용할 수 있기 때문이다.

    💡 Tip: intellj apollo graphql plugin을 받으면 빌드 안하고 apollo source를 만들 수 있음

  5. ApolloClient를 사용하여 api 요청
    val client = ApolloClient.Builder()
        .serverUrl("https://example.com/graphql")
        .addHttpHeader("Authorization", "kyun")
        .build()
    
    val response = runBlocking {
        client.query(FindUserQuery(id = 1).execute()
    }
    
    val result = response.data.findUserQuery

    runBlocking을 사용한 이유는, ApolloClient.query().execute 함수가 suspend이기 때문에 coroutine에서 실행하고 결과를 받아오기 위함이다.

 

TroubleShooting

multi-module project

multi module에서는 하나의 모듈에서만 스키마가 포함되어야 한다. 그리고 다른 모듈이 해당 스키마를 재사용해야한다. (마치 flyway처럼) 따라서 gradle 설정이 달라진다.

  • root 경로의 build.gradle.kts 파일
    해당 gradle 파일에서는 subproject의 의존성을 관리하기 때문에 apollo 관련 의존성은 여기서 처리한다. plugin은 필요 없기 때문에 제거한다.
    subprojects {
        dependencies {
            implementation("com.apollographql.apollo3:apollo-runtime:3.8.5")
            implementation("com.apollographql.apollo3:apollo-api:3.8.5")
        }
    }
  • core의 build.gradle.kts 파일
    apollo plugin 기능을 사용해야하기 때문에 plugin을 추가한다.
    generateApolloMetadata.set(true) 설정을 추가해야 한다.
    plugins {
        id("com.apollographql.apollo3") version "3.8.5"
    }
    
    apollo {
        service("service") {
            packageName.set("com.example.example")
            generateApolloMetadata.set(true)
            srcDir("src/main/resources/graphql")
        }
    }
  • public-api의 build.gradle.kts 파일
    apollo plugin 기능을 사용해야하기 때문에 plugin을 추가한다.
    core 프로젝트의 의 apollo 의존성 추가 해야 사용 가능하다.
    plugins {
        id("com.apollographql.apollo3") version "3.8.5"
    }
    
    dependencies {
        implementation(project(":udc-core"))
        apolloMetadata(project(":udc-core"))
    }
    
    
    apollo {
        service("service") {
            packageName.set("com.example.example")
        }
    }

 

 

Multi Modules codegen

For multi-modules projects, Apollo Kotlin enables you to define queries in a feature module and reuse fragments and types from another module dependency. This helps with better separation of concerns and build times. Note: this page is for sharing a schema

www.apollographql.com

 

 

NoSuchFieldError: Companion 에러

에러 로그는 다음과 같다.

java.util.concurrent.CompletionException: java.lang.NoSuchFieldError: Companion
    at org.springframework.aop.interceptor.AsyncExecutionAspectSupport.lambda$doSubmit$3(AsyncExecutionAspectSupport.java:281)
    at java.base/java.util.concurrent.CompletableFuture$AsyncSupply.run(CompletableFuture.java:1700)
    at org.springframework.cloud.sleuth.instrument.async.TraceRunnable.run(TraceRunnable.java:64)
    at com.equinix.crh.asset.common.config.ThreadConfig$ContextDecorator.lambda$decorate$0(ThreadConfig.java:84)
    at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1128)
    at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:628)
    at java.base/java.lang.Thread.run(Thread.java:829)
Caused by: java.lang.NoSuchFieldError: Companion
    at com.apollographql.apollo3.network.http.DefaultHttpEngine$execute$2$httpRequest$2$2.contentType(OkHttpEngine.kt:56)
    at okhttp3.internal.http.BridgeInterceptor.intercept(BridgeInterceptor.java:53)
    at okhttp3.internal.http.RealInterceptorChain.proceed(RealInterceptorChain.java:142)
    at okhttp3.internal.http.RetryAndFollowUpInterceptor.intercept(RetryAndFollowUpInterceptor.java:88)
    at okhttp3.internal.http.RealInterceptorChain.proceed(RealInterceptorChain.java:142)
    at okhttp3.internal.http.RealInterceptorChain.proceed(RealInterceptorChain.java:117)
    at okhttp3.RealCall.getResponseWithInterceptorChain(RealCall.java:229)
    at okhttp3.RealCall.execute(RealCall.java:81)
    at com.apollographql.apollo3.network.http.DefaultHttpEngine.execute(OkHttpEngine.kt:75)
Caused by: java.lang.NoSuchFieldError: Companion

 

 

해당 에러가 발생한 경우는, okhttp 라이브러리가 충돌해서 발생하는 이슈다. [github issue]

 

apolloClient는 okhttp의 Companion의 toMediaType() 함수를 사용한다. 그러나 okhttp 버전에 따라 해당 함수가 없는 경우가 존재한다. 내 프로젝트의 경우에는 3.14.9 버전으로 dependency 고정되었고, 해당 라이브러리는 kohttp 라이브러리가 원인이었다.

implementation(group = "io.github.rybalkinsd", name = "kohttp", version = "0.12.0")

 

해결 방법은 okhttp를 고정하는것이다.

subprojects {
    dependencyManagement {
        dependencies {
            dependency("com.squareup.okhttp3:okhttp:4.9.0")
        }
    }
}

 

이렇게 했을 때, okhttp major 버전이 바뀌었기 때문에, okhttp를 사용하는 곳에서 코드 수정이 필요하다. 수정해야하는 코드 예시는 다음과 같다.

  • As-Is
    sendPost(url, headers, body).user {
        it.body()?.string()
    }
  • To-Be
    sendPost(url, headers, body).user {
        it.body?.string()
    }

참고자료

🔕 배경 상황

지금하는 프로젝트에는 메일 구독 서비스 기능이 있다. 개인정보보호 팀에서 해당 기능 검토 후 보완 요청이 들어왔다. 기능 요건과 보완 요건을 같이 나열하면 다음과 같다.

  1. 구독취소 및 수신차단 기능이 존재 해야한다.
  2. 구독 취소 메일 주소를 내부에서 관리하면 안된다.
  3. 광고성/정보성 메일을 구분해서 관리해야한다. 즉 광고성 정보 수신 차단과 모든 메일 수신 차단은 구분되어야 한다.
  4. 해당 프로젝트는 사용자, 즉 로그인이 존재하지 않기 때문에 사용자의 수신차단 설정은 없다.

해당 요건을 만족하기 위해 여러가지 시도를 했는데, 이 방법들 중 어떤 방법이 다른 사람에게 도움이 될 수도 있지 않을까 해서 공유 목적으로 글을 쓰기로 마음 먹었다.

 

1️⃣ 첫 번째 시도

첫 번째 시도의 의도는 어플리케이션에서 메일 수신차단 기능을 신경 쓰고 싶지 않다는 것이다. 내부 DB에서 차단 목록과 같이 차단한 사용자의 이메일을 관리하면 안되기 때문에 사용하고 있는 메일 Third-Party인 SendGrid에서 관리하는 것이 목적이다.

 

    1. sendgrid 설정 페이지에서 Unsubscribe Groups 를 추가한다

    2. Subscription Tracking 기능을 OFF 로 설정한다.

 

    3. sendgrid의 send API에서 asm 필드를 추가한다. [참고 문서]

 

val asm = ASM().apply {
	groupId = 33868
}

val mail = Mail().apply {
	this.from = from
	this.subject = "local-test"
	this.content = mutableListOf(content)
	this.asm = asm
}

val sendGrid = SendGrid(sendGridKey)
val request = Request().apply {
	method = Method.POST
	endpoint = "mail/send"
	body = mail.build()
}

val response = sendGrid.api(request)

 

    4. mail message에 unsubscribeglobal-unsubscribe 태그를 추가한다. [참고 문서]

<a href='<%asm_unsubscribe_raw_url%>'>구독취소</a>
<a href='<%asm_global_unsubscribe_raw_url%>'>수신거부</a>

 

이렇게 설정하면 1번 단계에서 만든 group을 광고성 메일 수신차단 그룹으로, global 수신차단 그룹을 모든 메일 수신 차단 그룹으로 관리할 수 있다.

 

결과물은 아래 그림이다.

왼쪽은 수신차단 기능, 오른쪽은 광고성 메일 차단 기능이다.

 

해당 이메일 템플릿을 변경 해보려고 시간을 꽤 썼지만, sendgrid에서 만들어서 보내주는 것이라 커스텀하게 설정할 수 없다. 아무래도 B2C 서비스이다 보니 너무 못생기고 영어로 되어 있어서 선택 받지 못하였다.

 

2️⃣ 두 번째 시도

두 번째 시도부터는 수신 동의자를 DB에서 관리하는 것이 기본 전제이다. 이렇게 정한 이유는 개인정보보호 팀에서 써드파티에서 관리되는 이메일도 존재해서는 안된다고 했기 때문이다. 그래서 수신차단 이메일을 관리하지 않고, 수신동의한 이메일을 관리하는 것을 선택하였다. (수신동의자는 이메일 수집을 선택하였기 때문에 법적 문제가 없다.)

 

따라서 티켓을 구매할 때 광고성 수신동의를 선택하거나, 광고성 정보 구독을 한 사용자는 해당 수신동의자 테이블에 이메일이 저장된다. 그럼 광고성 이메일을 전송할 때 해당 테이블에 존재하는 사용자에게만 전송하면 된다.

 

그럼 수신차단이 문제인데, 광고성 정보 수신 차단은 API를 호출하여 수신 동의자를 지우는 방법을 선택하였고, 모든 메일 수신 차단 기능은 제거하기로 하였다. 사용자가 스팸 메일함으로 보내는 것과 크게 다르지 않아 의미가 없다고 판단하였다. (메일 송신자의 평가 점수가 떨어질 수 있다고 공유했지만 신경 안쓴다고 했다...!)

 

두 번째 시도의 목적은 사용자가 링크만 누르면 자동으로 수신차단이 되도록 하는 것이다.

 

    1. 예약된 대체문자 사용

 

sendgrid가 사용자 별로 구독취소 시 다른 url을 줄 수 있다는 것(첫 번째 시도 참고)은 이메일 본문에 사용자 이메일을 넣을 수 있다고 판단하였다.

send API 사용 시 personalizations 값을 같이 전송하여 사용자 별로 다른 값을 전달할 수 있다. substitution을 사용해서 대체문자도 가능하다. [관련 문서]

val personalizations = toEmails.map {
    Personalization().apply {
        addTo(Email(it))
        addSubstitution(":email:", it)
    }
}

val mail = Mail().apply {
    this.from = from
    this.subject = "local-test"
    this.content = mutableListOf(content)
    this.personalization = personalizations
}

 

    2. mail message에 대체문자 삽입 후 전송

<a href='https:/test.com/?email=:email:'>구독취소</a>
:email: 이 맞습니까?

 

결과는 다음과 같다.

좀 더 이쁘게 꾸미면 이대로 사용해도 될 것 같다. 만약 email이 url에 query string으로 노출되는 것이 걱정된다면 버튼을 만들어서 POST 요청으로 request body에 넣어서 보내도록 설정하면 된다.

 

🔔 마무리

허무하게도 이것저것 시도해봤지만 결국 메일 컨텐츠 안에 수신차단 할 수 있는 프론트 페이지 URL을 링크로 넣어서 보내는 것으로 결정 되었다. 이유는, 메일 전송 기능을 대행사에서 사용하는데 이 때 대체문자 같은 복잡한 작업은 리스크가 커서 어렵다는 의견이 있었다. 따라서 프론트에서 수신차단 등록할 수 있는 페이지를 만들고, 해당 페이지로 redirect 하여 사용자가 본인의 이메일을 입력하여 수신차단 할 수 있도록 하였다.

 

비록 멋지게 기능을 만들진 못했지만, 이 여정 중에 sendgrid 기능에 대해 찾기 어렵고 파편화 된 정보를 어딘가 모아 주면 좋겠다라는 생각을 하였다. 그래서 이렇게 블로그 글이라도 써서 남겨두기로 했다. 끝!

문제상황

UDC 프로젝트에서 핸드폰 번호의 형식이 도메인 별로 다르게 사용되고 있었다. 예를 들면 다음과 같다.

  • 핸드폰 본인인증 확인 → +821012345678
  • 행사 참여자 → +82, 01012345678
  • MMS 발송 이력 → 01012345678

이렇게 관리되면, "핸드폰 번호"를 서로 다르게 표현하고 있기 때문에 통일성이 떨어지고, 관리 포인트가 여러 곳으로 나눠지기 때문에 응집성도 떨어지게 된다. 또한 핸드폰 번호로 행사 참여자의 발송이력을 확인하려고 할 때 형식이 달라 검색에 어려움이 있고, join으로 데이터 조회 시 값 변환이 필요하여 index를 활용하기 어려울 수 있다. (물론 가상컬럼, 함수 인덱스 등 방법은 있지만.)

 

원하는 방향

따라서 Phone 클래스를 만들어서 문제를 해결 하려고 한다. Phone 클래스에게 바라는 기능을 나열해보았다.

  1. Phone 클래스를 사용하면 converting, validation 걱정 없이 사용 가능하다.
    즉 핸드폰 번호와 관련된 로직을 하나로 모아 응집성을 높힐 수 있어야한다.
  2. RequestBody, RequestParma, Entity 모두 쉽게 사용이 가능해야 한다.
    사용하는데 불편함이 없어야하고 DB 질의에도 문제가 없어야한다.

Phone을 만드는 이유는 결국 핸드폰 번호를 다르게 사용하여 발생하는 휴먼에러를 줄이고, 편하게 사용 가능하여 Phone 클래스를 사용하도록 유도하는 것이다.

 

이를 만족하는 3가지 방법을 생각해 보았고, 이 글을 보는 사람들은 각각 독립적인 방법이니 상황에 맞게 선택하면 될 것이다.

 

해결 방법

1. Kotlin의 value class 활용하기

value class를 사용하면 타입을 감싸줌으로써 원하는 기능을 한 곳에 모아 응집성을 높힐 수 있다. 또한 컴파일 시 기존 타입으로 변환(inline) 되기 때문에 별도의 converter가 필요 없다. [공식 문서]

@JvmInline
value class Phone private constructor(
    private val phoneNumber: String
) {
    companion object {
        fun of(phoneNumber: String): Phone {
            val formatted = phoneNumber
                .replace("-", "")
                .replace(" ", "")
                .removeZeroPrefix()
                .replace(",", "")

            return Phone(formatted)
        }
    }

    init {
        validateNumber(phoneNumber)
    }

    val formattedNumber: String
            get() = phoneNumber
            .replace("-", "")
            .replace(" ", "")
            .removeZeroPrefix()
            .replace(",", "")

    private fun validateNumber(phoneNumber: String) {}
}

 

이 클래스를 DTO와 entity에 적용하는 것은 크게 어렵지 않다. 그러나 만약 value class 생성 시 converting까지 원한다면 약간의 변형이 필요하다. 예시 코드를 한번 살펴보자.

 

// DTO
data class UserCreateRequest(
    val name: String,
    val phone: String,
) {
    fun toDomain(): User {
        return User(name = name, phone = Phone.of(phone))
    }
}

// Entity
@Entity
class User(
    val name: String,
    phone: Phone,
) {
    val phone: String = phone.formattedNumber
}

 

DTO와 entity를 보면 실제로 field에 Phone이라는 value class를 바로 적용하지 않았음을 알 수 있다.

이유는 value class가 wrapping만 해주기 때문이다. value class는 field를 수정하는 것이 불가능하여 var로 선언하지 못하는 것처럼, 생성자를 통한 값 변환이 불가능하다. 따라서 팩토리 메서드 패턴을 통해 formatting을 하거나 value class의 getter와 함수를 사용하여 변환된 값을 전달하는 방법만 사용이 가능하다.

만약 값을 value class에 그대로 대입한다면, 핸드폰 번호가 converting 되지 않고 형식에 맞지 않는 값이 그대로 저장될 것이다.

 

  • 장점
    • 컴파일 시 새로운 class가 아닌, string으로 컴파일 되기 때문에 heap 메모리의 부담을 줄일 수 있다.
    • 컴파일 시 string값으로 변환되기 때문에 entity에서도 converter없이 사용 가능하고 queryParam, requestBody에서도 변환없이 사용 가능하다.
    • init을 활용하여 value class 생성 시 형식에 맞지 않는 핸드폰 번호를 검증할 수 있다.
  • 단점
    • property를 변환할 수 없어서, 정적 팩토리 메서드 패턴을 사용하여 formatting 해야한다.
    • init은 생성자를 통해 객체가 생성될 때만 동작하기 때문에, jackon 등에 의해서 생성된 value class는 init을 거치지 않아 잘못된 형식을 담고 있을 수 있다.
    • 컴파일 후에는 string으로 변환되기 때문에, converter가 정상 동작하지 않는다.
      converter가 string -> string 으로 변환할 수 없기 때문에 정상 동작하지 않는 것이다. 따라서 database에 정제된 값을 넣고 싶다면 getter를 사용해야 한다.

 

2. 도메인 객체 (VO) 만들기

일반적으로 쉽게 생각할 수 있고 직관적인 방법이다. 도메인 객체를 만들어서 서비스 내부를 견고하게 만들 수 있다. 예시 코드를 확인해보자.

@Embeddable
class Phone(
    number: String
) {
    val phone: String = format(number)

    init {
        validateNumber(number)
    }

    private fun format(number: String) { ... }		
    private fun validateNumber(number: String) { ... }

    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (javaClass != other?.javaClass) return false
        other as Phone
        return phone == other.phone
    }

    override fun hashCode(): Int {
        return phone.hashCode()
    }
}

 

@Embeddable 은 entity에서 VO로 사용할 수 있도록 추가해두었다. 즉 entity에서 해당 클래스를 사용하더라도 별도의 converter를 사용하지 않게 하기 위함이다.

 

생성 시점에 formatting 하도록 주 생성자에서 property를 할당하지 않고 init 시점에서 할당하도록 하였다. 또한 VO이기 떄문에 property 만으로 동등 비교 및 hash 값을 갖는게 자연스럽기 때문에 equalshashCode를 재정의 하였다.

 

이 방법은 Phone을 도메인 객체로 보기 때문에 DTO에서는 직접 사용하는 것은 지양하는 것이 좋다. entity와 dto에서 어떻게 사용하면 좋은지 예시를 확인해보자.

// entity
@Entity
class User(
    val name: String,
    val phone: Phone,
) { 
    val phoneNumber: String
            get() = phone.phone
}

// DTO
class CreateUserRequest(
    val name: String,
    val phone: String,
) {
    fun toDomain(): User {
        return User(name = name, phone = Phone(phone))
    }
}

class UserResponse(
    val name: String,
    val phone: String,
) {
    constructor(user: User): this(
        name = user.name,
        phone = user.phoneNumber
    )
}

 

entity는 embeddable 설정이 적용되어 있기 때문에 별다른 설정은 필요 없다.

DTO에서는 entity class를 그대로 사용하지 않는 것처럼, request DTO에서는 phone으로 변환하여 도메인으로 만들고, response DTO에서는 도메인 객체의 속성값을 추출하여 사용하는 것을 권장한다.

 

⚠️ 주의할 점은 @Embeddable 은 생성자를 거치지 않기 때문에 DB 데이터는 검증이 안된다. 만약 DB 데이터를 신뢰할 수 없어서 formatting을 하고 싶다면, 별도의 converter를 등록해야 한다. 그러나 DB 데이터를 신뢰하기 어려운 상황이 있을까 싶다. 결국 서비스 로직을 거쳐 DB에 저장되기 때문에 DB 데이터 자체는 신뢰하는게 맞지 않을까라고 생각한다. DB 데이터를 신뢰하기 어렵다면 migration 작업을 별도로 하는것이 어떤가 싶다.

 

  • 장점
    • 직관적이고 이해하기 쉽다.
    • converter를 사용하지 않기 때문에 사이드 이펙트가 가장 적다.
    • value class와 같은 제약이 없기 때문에 확장이 편리하고 변화에 유연하게 대응할 수 있다.
  • 단점
    • view 영역, 즉 dto 혹은 controller에서 데이터 변환 작업(String → Phone)이 필요하다.
      사용하는 모든 곳에서 필요하기 때문에 손이 많이 간다.

 

3. 새로운 타입으로 만들기

새로운 타입으로 만든다는 뜻은 어디서 사용하던지 다른 타입들(String, Int)과 동일한 효과를 기대한다는 뜻의 표현이다. value class처럼 String으로 사용되지만 value class의 한계(생성자에서 값 변환 등)를 해결할 수 있는 방법이다. 그러나 class로 만들어지기 때문에 컴파일 단계에서 value class처럼 string으로 변환되지 않는 단점이 있다.

 

class Phone(
    number: String
) {
    val number: String = format(number)

    init {
        validateNumber(number)
    }

    private fun format(number: String) { ... }		
    private fun validateNumber(number: String) { ... }

    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (javaClass != other?.javaClass) return false
        other as Phone
        return number == other.number
    }

    override fun hashCode(): Int {
        return number.hashCode()
    }
}

 

사용할 클래스는 도메인 객체로 만들때와 크게 다르지 않다(embeddable 설정만 제거하였다).

타입으로 만들기 위해서는 entity에서 해당 타입의 property가 존재한다면 DB data → Class로 변환이 가능해야한다. 이를 해결하기 위해서는 database converter가 필요하다.

@Converter(autoApply = true)
class PhoneAttributeConverter : AttributeConverter<Phone, String> {
    override fun convertToDatabaseColumn(attribute: Phone?): String? {
        return attribute?.number
    }

    override fun convertToEntityAttribute(dbData: String?): Phone? {
        return dbData?.let { Phone(it) }
    }
}

 

 

💡Tip: autoApply = true 로 설정하면 converter가 글로벌로 등록된다.

 

RequestParam에서도 Phone 클래스를 타입으로 설정하면 변환이 가능해야한다. RequestParam에 타입 변환을 추가하기 위해서는 spring servlet 설정에 converter를 추가해야 한다.

class PhoneConverter : Converter<String, Phone> {
    override fun convert(source: String): Phone {
        return Phone(source)
    }
}

// WebConfig 설정파일에 추가
@Configuration
class WebConfig() : WebMvcConfigurer {
    override fun addFormatters(registry: FormatterRegistry) {
        registry.addConverter(PhoneConverter())
    }
}

 

마찬가지로 RequestBody와 Response에서도 타입처럼 동작하기 위해서는 변환 작업이 필요하다. spring에서는 기본 설정으로 jackson을 사용하기 떄문에 jackson serializer/deserializer 를 추가해야 한다.

 

class PhoneSerializer : JsonSerializer<Phone>() {
    override fun serialize(value: Phone?, gen: JsonGenerator, serializers: SerializerProvider?) {
        gen.writeString(value?.number)
    }
}

class PhoneDeserializer : JsonDeserializer<Phone>() {
    override fun deserialize(p: JsonParser, ctxt: DeserializationContext?): Phone {
        val number = p.text
        return Phone(number)
    }
}

// Phone 클래스에 등록
@JsonSerialize(using = PhoneSerializer::class)
@JsonDeserialize(using = PhoneDeserializer::class)
class Phone(
        ...
)

 

여기까지 설정을 완료했다면, String 타입과 동일하게 사용할 수 있다. 어느 곳에서든 Phone 타입을 사용할 수 있고, phone 타입을 사용하는 곳에서는 형식을 항상 보장받게 된다.

 

  • 장점
    • 어느곳에서든 Phone을 타입처럼 사용 가능하여 사용하기 편리하다.
    • Phone을 사용하는 곳에서는 핸드폰 형식 검증/변환 작업이 따라오기 때문에 휴먼에러가 발생할 확률이 현저히 줄어들 것으로 예상된다.
  • 단점
    • 일반적으로 자주 쓰는 방법은 아니기 때문에 코드를 처음 보는 사람은 이해하기 어렵다. 또한 이렇게 class를 활용하기 위해서는 팀 내 합의 및 컨벤션이 갖추어져야 한다.
    • JSON 직렬화/역직렬화 라이브러리를 jackson이 아닌 다른 라이브러리를 사용한다면 추가 대응이 필요하다.
    • 많은 converter를 사용하기 때문에 사이드 이펙트 가능성이 제일 높다.

 

최종선택

이렇게 3가지 개선 방향을 팀에게 공유했고, 2번 방법으로 작업하기로 결정하였다.

결정 이유는 익숙함이 제일 컸다. 익숙한 코드이기 때문에 사이드 이펙트가 제일 적을 것으로 생각하였고, value class는 변환이 안되는 제약이 너무 크게 다가왔다. 3번 방법은 너무 많은 변환 작업이 필요하기 때문에 사이드 이펙트의 위험성을 극복하지 못하였다.

 

핸드폰 번호 형식 통일을 편하게 할 수 있는 방법 없을까?에서 출발한, 좋은 사용성을 가진 코드를 만드려는 노력이 다양한 방법들을 생각할 수 있게 한 것 같아서 재밌는 시간이었다.

🤯 문제 상황

운영에서 insert 쿼리에 timestamp 컬럼에 NULL 을 넣지만, not null 타입이라 cannot be null 에러가 발생했다.

언뜻보면 당연한 결과인 것 같지만, 인프라 이관 작업에서 발생한 이슈라는 점에서 봤을때 잘 동작하는 코드가 환경이 변해서 에러가 발생하는 상황인 것이다.

따라서 환경에 따른 문제인 것으로 보여서, 이를 힌트로 잡고 문제를 찾아 나갔다.

 

👨‍⚕️ 원인 분석

  1. 인프라 이관 전 환경에서 에러 flow를 따라 동일하게 실행 (MySQL 5.7)
    → 정상 동작
  2. 인프라 이관 후 환경에서 동일하게 실행 (MySQL 5.7)
    → 에러 발생
    INSERT INTO BSI_BENEFIT_BASES
    (
    	...
        , CREATION_DATE
        , LAST_UPDATE_DATE
    )
    VALUES
    (
    	...
        , CURRENT_TIMESTAMP()
        , null
    )
  3. Local MySQL에서 insert 쿼리 동일하게 실행 (MySQL 8.0)
    동일하기 cannot be null 에러 발생

위 단계만으로는 원인을 알기 어려워 DBA 분께 문의 넣었다.

################ DBA와의 대화 넣기

DBA 답변
두 DB 상에서 explicit_defaults_for_timestamp 변수 값이 다르네요. 오류가 안나는 곳은 값이 OFF이고, 오류가 나는 곳은 ON입니다. mysql 서버 버전이 5.7.12 → 5.7.40으로 변경하며 변수 기본값이 다른 것 같습니다.
https://minsql.com/mysql/MySQL-explicit_defaults_for_timestamp/

라고 하시며 친절히 설명 블로그와 사진까지 보내주셨다.

 

정리하자면, 이전 환경(MySQL 5.7.12)에서는 OFF가 기본값으로 설정되어 있어서 NULL을 insert 하면 current timestamp가 입력된다.

이관된 후 환경(MySQL 5.7.40)에서는 기본값이 ON으로 설정되어 NULL을 똑같이 입력하더라도 에러가 발생하는 것이다.

 

여기서 한가지 의문이 생긴다.

왜 Local MySQL(8.0)에서는 안됐을까?

 

찾아보니, 5.7.40 버전과 동일하게 8.0에서는 기본값이 ON으로 설정되어 있다. [공식 문서]

MySQL 8.0 문서에서는 8.0부터 해당 변수의 default값이 변경 되었다고 나와있다. 아마 인프리 이관하면서 해당 변수 설정이 누락되지 않았나 예상한다.

 

🔏 해결방법

해결방법은 매우 간단하다. explicit_defaults_for_timestamp 변수 값을 OFF로 설정해 주거나, mysql 설정에 의존하지 않고 CURRENT_TIMESTAMP() 를 넣어주도록 쿼리를 수정하면 된다.

 

나는 DB 버전에 따른 기본값 설정을 따라가는 쿼리는 좋지 않다고 느꼈다. 시간이 지나면 DB 버전은 올리게 될 것이고, 버전 업그레이드에 따른 리스크를 안고 가고 싶지는 않았다. 따라서 쿼리에 default 값을 직접 명시해주는 방법을 선택하였다.

'MySQL' 카테고리의 다른 글

MySQL의 collation이라는 마법  (1) 2023.06.06

⚠️ 문제 상황

  1. 로그인 시 user entity의 lastLoginAt 필드를 update 후 repository.save()
  2. hibernate의 PostUpdateEventListener 구현한 EntityUpdateEventListener에서 에러 발생
    • Reflection을 사용할 때, stackTrace의 모든 경로를 찾는데, 동적 클래스 로딩으로 생성된 클래스는 JVM 메소드 영역에 저장되며 GC 대상이 되기 때문에 GC에 의해 제거되어 Class.forName 에서 클래스를 찾지 못하여 발생한 exception이라고 예상됨.
      @Component
      class EntityUpdateEventListener(
          private val logService: LogService,
      ) : PostUpdateEventListener {
          private fun isScheduledMethodInvoked(): Boolean {
              Thread.currentThread().stackTrace.map { stackTraceElement ->
                  runCatching {
                      val methods = ReflectionUtils.getUniqueDeclaredMethods(Class.forName(stackTraceElement.className))
                      methods.map { method ->
                          if (method.isAnnotationPresent(Scheduled::class.java)) {
                              return true
                          }
                      }
                  }
              }
              return false
          }
      
          override fun onPostUpdate(
              event: PostUpdateEvent
          ) {
              if (isScheduledMethodInvoked()) {
                  return
              }
      
              ...
      
              }
      }
  3. 재 로그인 시, pessimistic lock 에러 발생함. hikariCP에서 maxLifeTime의 default 값인 30분이 지나도 에러는 유지됨.

 

org.springframework.dao.PessimisticLockingFailureException: could not execute statement; SQL [n/a]; nested exception is org.hibernate.PessimisticLockException: could not execute statement
...
Caused by: org.hibernate.PessimisticLockException: could not execute statement
...
Caused by: com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: Lock wait timeout exceeded; try restarting transaction
...

 

🔎 원인 분석

1. 문제 시점 분석

  • 서버 restart 직후는 정상동작
    • 그러나 4번째 로그인 시도 시 에러 발생 후 재 로그인 시 PessimisticLockingFailureException이 발생한다.
  • 에러 시점 분석
    • EntityUpdateEventListener 에서 에러가 발생하고, 이후에 database에서 SELECT ... FOR UPDATE 로 조회 시 pending으로 lock을 잡고 있다.
SELECT *
FROM users
WHERE id = {USER_ID}
FOR UPDATE;

2. 문제 코드 분석

  • EntityListener를 Hibernate의 공식문서에 명시된 방법으로 추가했을 때는 정상 동작함. [공식 문서]

        1. Entity에 @PostUpdate 추가하는 방법

@PostUpdate
fun postUpdate() {
    throw Exception()
}

 

       2. Entity에 @EntityListeners 추가하는 방법

@EntityListeners(TestUserListener::class)
class UserEntity(

 

 

  • PostUpdateEventListener 를 구현한 Listener를 global로 (억지로) 추가했을 때만 문제가 생김
@Component
class HibernateListenerConfig(
    private val entityUpdateEventListener: EntityUpdateEventListener
) {
    @PersistenceUnit
    private val emf: EntityManagerFactory? = null

    @PostConstruct
    private fun init() {
        val sessionFactory = emf!!.unwrap(SessionFactoryImpl::class.java)
        val registry = sessionFactory.serviceRegistry.getService(
            EventListenerRegistry::class.java
        )

        registry.getEventListenerGroup(EventType.POST_UPDATE).appendListener(entityUpdateEventListener)
    }
}

 

3. 원인 분석

  1. 첫번째 가설
    • 가설
      • update query가 commit 되지 않아서 lock을 잡고 있는 것 아닌가? 어떤 이유로 commit을 실패하나?
    • 검증
      • EntityListener 가 동작하는 시점 분석
        • UserService.save()
          → JpaTransactionManager.doCommit()
          → JdbcResourceLocalTransactionCoordinatorImpl.commit()
          → JdbcCoordinatorImpl.beforeTransactionCompletion()
          → DefaultFlushEventListener.onFlush()
          → EntityUpdateAction.postUpdate()
          → PostUpdateEventListener.onPostUpdate()
      • 분석하면, repository.save() 실행 시, update 쿼리를 생성하고 실행한다. 이후에 commit을 찍는 작업을 진행하는데, 이를 JpaTransactionManager 가 수행한다.
        // JdbcResourceLocalTransactionCoordinatorImpl.java 281 lines
        ...
        @Override
        public void commit() {
        ...
            JdbcResourceLocalTransactionCoordinatorImpl.this.beforeCompletionCallback();
            jdbcResourceTransaction.commit();
            JdbcResourceLocalTransactionCoordinatorImpl.this.afterCompletionCallback( true );
            }
            catch (RollbackException e) {
                throw e;
            }
            catch (RuntimeException e) {
                try {
                    rollback();
                }
                catch (RuntimeException e2) {
                    log.debug( "Encountered failure rolling back failed commit", e2 );
                }
                throw e;
            }
        ...
      •  
      • 위 코드를 보면, 실제 transaction에 commit을 찍기 전에 어떤 작업이 있다는 것을 확인해볼 수 있다. 예상하듯이, beforeCompletionCallback 은 entity가 flush 될 때 동작을 핸들링하는 DefaultFlushEventListener 를 호출하고, 이로 인해 PostUpdateEventListener 에 정의한 onPostUpdate() 함수가 실행된다.

        그렇다면 jdbcResourceTransaction.commit(); 코드는 정말 database에 commit 요청하는 함수가 맞는가?
        해당 함수의 구현 코드를 확인하면 알 수 있다.
      • // AbstractLogicalConnectionImplementor.java 83 lines
        @Override
        public void commit() {
            try {
                log.trace( "Preparing to commit transaction via JDBC Connection.commit()" );
                getConnectionForTransactionManagement().commit();
                status = TransactionStatus.COMMITTED;
                log.trace( "Transaction committed via JDBC Connection.commit()" );
            }
            catch( SQLException e ) {
                status = TransactionStatus.FAILED_COMMIT;
                throw new TransactionException( "Unable to commit against JDBC Connection", e );
            }
        
            afterCompletion();
        }
      •  
      • 위 코드에서 getConnectionForTransactionManagement().commit(); 코드가 commit 담당으로 보인다. 해당 코드의 구현체를 확인해보자.
      • // ConnectionImpl.java 795 lines
        @Override
        public void commit() throws SQLException {
        ...
            this.session.execSQL(null, "commit", -1, null, false, this.nullStatementResultSetFactory, null, false);
        ...

        위 코드에서 알 수 있듯이, commit SQL을 요청하는 것을 확인할 수 있다.
    • 정리
      JPA에서 commit을 찍을 때 3가지 단계를 거친다.
      1. 사전작업
        commit 전, 수행되어야 하는 작업을 실행한다.
        ex) PostUpdateEventListener 실행
      2. commit
        database에 commit SQL을 실제로 요청한다.
      3. connection 정리
        리소스를 release 하는 등의 작업을 한다
    • 해석
      사전작업, 즉 EntityEventListener 등에서 에러가 발생하면 commit 단계까지 도달하지 못하고, transaction을 종료하지 못한다.

      라고 결론을 짓기에는, JPA와 Hibernate가 이렇게 허술하게 코드를 짜지 않을 것 같아서 찜찜하다..
      그래서 좀 더 찾아본 결과, JdbcResourceLocalTransactionCoordinator에서 에러가 발생하면 try/catch로 에러를 잡아 rollback() 함수를 실행하는 것을 확인할 수 있다. 그럼 어디가 문제인걸까?
  2. 두 번째 가설
    • 가설
      • RuntimeException이 아니라, Exception 클래스로 에러가 발생하여 에러 핸들링을 하지 못하나? 위 코드에서도 RuntimeException만 핸들링 하는 것으로 보이네!
    • 검증

      실제 코드에서 발생하는 에러는 ClassNotFoundException으로, RuntimeException을 상속하지 않는 클래스이다.

      따라서 try/catch에 잡히지 않기 때문에 rollback이 되지 않고 에러를 뱉어서 commit까지 가지 않는 것이다. 그래서 lock을 계속 잡고 있는 것이다.

      그럼 여기서 한가지 의문이 든다. 왜 Entity에 PostUpdate 혹은 Listener를 추가했을 때 동일하게 ClassNotFoundException 으로 exception을 발생시켜도 문제가 되지 않는가? (실제로 해봤는데 정상 동작한다!)

      그 이유는 JPA에서 exception을 랩핑하여 전파하기 때문이다. 코드를 확인해보자.
      // ListenerCallback.java 52 lines
      @Override
      public boolean performCallback(Object entity) {
          try {
              callbackMethod.invoke( listenerManagedBean.getBeanInstance(), entity );
              return true;
          }
          catch (InvocationTargetException e) {
              //keep runtime exceptions as is
              if ( e.getTargetException() instanceof RuntimeException ) {
                  throw (RuntimeException) e.getTargetException();
              }
              else {
                  throw new RuntimeException( e.getTargetException() );
              }
          }
          catch (RuntimeException e) {
              throw e;
          }
          catch (Exception e) {
              throw new RuntimeException( e );
          }
      }

      여기서 주목할 점은 Exception 클래스도 RuntimeException으로 변환하여 다시 throw 한다는 것이다.
      그럼 모든 exception이 RuntimeException으로 변환될 것이고, 변환된 exception들은 모두 정상 동작하기 때문에 문제가 없는 것이다.

✏️ 해결 방법

PostUpdateEventListener의 onPostUpdate() 함수를 예외처리 문으로 감싸서 에러를 방지하도록 하면 문제는 쉽게 해결된다.

override fun onPostUpdate(
	event: PostUpdateEvent
) {
    runCatching {
    	...
    }
}

 

번외

그렇다면 왜 Lock이 계속 유지될까? 에러 상황에서 언급했듯이, hikariCP에서 maxLifeTime의 default 값인 30분이 지나도 lock은 계속 유지되고 있었다. connection이 생존시간을 넘어가면 자연스럽게 lock도 풀려야하지 않나?

 

이는 hikariCP 동작 방식을 보면 이해할 수 있다. hikari는 실행 중인 connection은 동작이 완료될 때까지 종료하지 않는다. 위 상황처럼 에러가 발생해 connection을 제대로 종료하지 못한 경우에는 connection이 계속 유효하다고 판단한다.

따라서 lock이 계속 유지되는 상황이 발생하는 것이다.

 

그럼 hibernate 버그 아닌가?

개인적인 생각으로는 코틀린이기 때문에 발생했다고 생각한다. java에서는 RuntimeException을 상속하지 않는 Exception들은 checked Exception으로 반드시 예외처리가 되어야 한다. 그러나 kotlin에서는 checked Exception과 uncheckedException의 구분이 없기 때문에 핸들링 하지 않았고, 해당 에러가 발생할 수 있었던 것이다.

문제가 됐던 Class.forName 함수 코드를 확인해보면 ClassNotFoundException을 상위 메서드로 던지고 있다.

public static Class<?> forName(String className)
            throws ClassNotFoundException {
    Class<?> caller = Reflection.getCallerClass();
    return forName0(className, true, ClassLoader.getClassLoader(caller), caller);
}

 

java 였으면 에러 핸들링이 없어서 컴파일 단계에서 에러가 발생 할 것이다.

'JPA' 카테고리의 다른 글

무서운 @OneToMany  (0) 2024.08.16
@UpdateTimestamp의 비밀  (1) 2023.04.30

🤨 문제 상황

Spring Security 5.6.2 에서 permitAll()로 권한 설정한 api를 요청했을 때 403 exception이 발생했다. permitAll인데 왜 403이 발생하지? 라는 생각이 들어 자세히 찾아보기로 했다.

@Configuration
@EnableWebSecurity
class WebSecurity {
    @Bean
    fun filterChain(http: HttpSecurity): SecurityFilterChain {
        http
            .cors().and()
            .csrf().disable()
            .anonymous().disable()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .authorizeRequests()
            .antMatcher(HttpMethod.GET, "/v{\\\\d+}/users**").permitAll()

        return http.build()
    }
}

 

위와 같이 security 설정을 하고 Debug 모드로 실행해보니, controller까지 요청이 들어오지 않았다.

→ security에서 403을 내려주는 것으로 보인다.

 

오잉? permitAll()은 해당 api의 요청을 모두 허용하는 것이 아닌가?

🧐 원인 분석

permitAll의 함수를 따라들어가보면,

public ExpressionInterceptUrlRegistry permitAll() {
    return access(permitAll);
}

ExpressionUrlAuthorizationConfigurer 클래스의 함수라는 것을 확인할 수 있다.

 

그런데 이름을 보다 보면, Authorization?? 인가를 확인하는 클래스네? 인증이 아닌 인가를 처리하는 곳에서 작업이 일어나는구나!

그럼 인증이 안돼서, 즉 SecurityContextHoder에 인증정보가 없어서 인증 안된 사용자라고 판단하는것인가? 라는 가설을 세울 수 있다.

 

FilterChain에서 발생한 에러는 ExceptionTranslation에서 처리할 것이다. 그러니 ExceptionTranslationFilter에서 어떤 에러가 발생하는지 확인하면서 올라가며 가설을 검증하는 것이 좋아보인다..

  • 참고
ExceptionTranslatorFilter 주석
Handles any AccessDeniedException and AuthenticationException thrown within the filter chain. This filter is necessary because it provides the bridge between Java exceptions and HTTP responses. It is solely concerned with maintaining the user interface. This filter does not do any actual security enforcement. If an AuthenticationException is detected, the filter will launch the authenticationEntryPoint. This allows common handling of authentication failures originating from any subclass of org. springframework. security. access. intercept. AbstractSecurityInterceptor.
// AbstractSecurityInterceptor.java 199 lines
...
if (SecurityContextHolder.getContext().getAuthentication() == null) {
    credentialsNotFound(this.messages.getMessage("AbstractSecurityInterceptor.authenticationNotFound",
    "An Authentication object was not found in the SecurityContext"), object, attributes);
}
...

 

ExceptionTranslationFilter 부터 쭉 타고 올라가다 보면, AbstractSecurityInterceptor 에서 위 코드와 같이 SecurityContextHolder에서 authentication을 찾고, 없으면 AuthenticationCredentialsNotFoundException 을 발생시킨다.

 

이 에러를 ExceptionTranslationFilter에서 try-catch로 잡아서 처리한다. 이 때, 아래 코드를 보면 되는데

// ExceptionTranslationFilter.java 178 lines
private void handleAuthenticationException(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
            AuthenticationException exception) throws ServletException, IOException {
    this.logger.trace("Sending to authentication entry point since authentication failed", exception);
    sendStartAuthentication(request, response, chain, exception);
}

 

sendStartAuthentication 으로 보내게 된다. 그럼 해당 함수를 보자.

// ExceptionTranslationFilter.java 208 lines
protected void sendStartAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
            AuthenticationException reason) throws ServletException, IOException {
    // SEC-112: Clear the SecurityContextHolder's Authentication, as the
    // existing Authentication is no longer considered valid
    SecurityContext context = SecurityContextHolder.createEmptyContext();
    SecurityContextHolder.setContext(context);
    this.requestCache.saveRequest(request, response);
    this.authenticationEntryPoint.commence(request, response, reason);
}

 

여기서 authenticationEntryPoint로 보내는 것을 볼 수 있다. 즉 기본값인 Http403ForbiddenEntryPoint 로 보내져서 403이 발생하는 것이다.

 

즉, 어떤 인증도 수행하지 않아서 SecurityContext에는 인증정보가 없는 것이고, 그래서 authenticationEntryPoint로 보내져 403에러가 발생했던 것이다.

 

그럼 인증이 필요없는 API는 어떻게 처리하나?

 

그럴줄 알고, Spring Security에서는 AnonymousAuthenticationFilter를 만들어 두었다. 재밌는건, AnonymousAuthenticationFilter에서 isAuthenticated()는 true가 된다. 다른 말로는 SecurityContextHolder의 authentication에 익명사용자라는 AnonymousAuthenticationToken 을 설정해준다.

어떻게 보면 당연한 이야기인게, 인증하지 않은 사용자를 인가처리(permitAll 과 같은 작업) 하기 위해 익명사용자라는 것으로 감싸는 것이기 때문에, 인증이 되었다고 보지 않으면 위와 같은 403 에러가 발생할 것이다.

 

그럼 발생했던 문제는 어떻게 해결할 수 있나?

 

🖋️ 해결 방법

해결 방법은 단순하다.

.anonymous().disable() // 제거

anonymous를 disable하지 않으면 된다.

 

❓ 궁금한 점

그럼 authenticated()는 anonymous 사용자도 통과되나? isAuthenticated()가 true니까 통과될 것 같은데?
→ 실제로 해보면 안된다. 사실 되면 이상한거다.(spring이 그렇게 못 만들었을리 없다)

위 내용을 읽으면 눈치 챘겠지만, authenticated()는 인가의 역할이다. AnonymousAuthenticationToken 의 isAuthenticated()는 인증의 역할이다. 2개의 역할은 다르기 때문에 다른 시각에서 봐야한다.

인가는 AuthorizationFilter 에서 처리하는데, 코드를 한번 보자.

// AuthorizationFilter.java 54 lines
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
        throws ServletException, IOException {

    this.authorizationManager.verify(this::getAuthentication, request);
    filterChain.doFilter(request, response);
}


인가는 authorizationManager가 관리하고 있고, verify함수에서 처리한다.

// AuthorizationManager.java 41 lines
default void verify(Supplier<Authentication> authentication, T object) {
    AuthorizationDecision decision = check(authentication, object);
    if (decision != null && !decision.isGranted()) {
        throw new AccessDeniedException("Access Denied");
    }
}


유저가 authenticated 되어있는지 판단하는 manger는 AuthenticatedAuthorizationManager 이다. AuthorizationManger의 주석에는 다음과 같이 설명한다.
| An AuthorizationManager that determines if the current user is authenticated.

// AuthenticationAuthorizationManager.java 53 lines
@Override
public AuthorizationDecision check(Supplier<Authentication> authentication, T object) {
    boolean granted = isGranted(authentication.get());
    return new AuthorizationDecision(granted);
}

private boolean isGranted(Authentication authentication) {
    return authentication != null && isNotAnonymous(authentication) && authentication.isAuthenticated();
}

 

위 코드를 보면, anonymous일 때는 isGanted 가 false가 되는 것을 확인할 수 있다. 이렇게 인가작업인 authenticated()는 anonymous를 통과하지 못하도록 막는다.

 

잠시만, 그럼 permitAll에서도 막히는거 아니야??

 

Authority(hasRole, permitAll 등)을 판단하는 manager는 AuthorityAuthorizationManager 이다. 두 매니저가 다르기 때문에 로직도 다르다.

// AuthorityAuthorizationManager.java 125 lines
@Override
public AuthorizationDecision check(Supplier<Authentication> authentication, T object) {
    boolean granted = isGranted(authentication.get());
    return new AuthorityAuthorizationDecision(granted, this.authorities);
}

private boolean isGranted(Authentication authentication) {
    return authentication != null && authentication.isAuthenticated() && isAuthorized(authentication);
}

 

여기서는 authentication의 isAuthenticated() 를 확인하기 때문에 anonymous user는 통과할 수 있는 것이다.

 

또 잠시만, 그럼 hasRole() 설정에서 anonymousUser는 ExceptionTranslationFilterhandleAuthenticationException 로 가는게 아니고 handleAccessDeniedException로 처리되겠네? AuthorizationManager에서 걸리면 AccessDenied 아니여?

 

당연히 아니다. (Spring이 그렇게 멍청하지 않다니까?)

아래 코드를 봐보자.

// ExceptionTranslationFilter.java 184 lines
private void handleAccessDeniedException(HttpServletRequest request, HttpServletResponse response,
            FilterChain chain, AccessDeniedException exception) throws ServletException, IOException {
    Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
    boolean isAnonymous = this.authenticationTrustResolver.isAnonymous(authentication);
    if (isAnonymous || this.authenticationTrustResolver.isRememberMe(authentication)) {
        if (logger.isTraceEnabled()) {
            logger.trace(LogMessage.format("Sending %s to authentication entry point since access is denied",
                authentication), exception);
        }
        sendStartAuthentication(request, response, chain,
            new InsufficientAuthenticationException(
                this.messages.getMessage("ExceptionTranslationFilter.insufficientAuthentication",
                    "Full authentication is required to access this resource")));
        }
        else {
            if (logger.isTraceEnabled()) {
                logger.trace(
                    LogMessage.format("Sending %s to access denied handler since access is denied", authentication),
                        exception);
            }
        this.accessDeniedHandler.handle(request, response, exception);
    }
}

 

코드를 유심히 보면, anonymous일 때 sendStartAuthentication() 를 호출하는 것을 확인할 수 있다. 실제로 테스트해보면 AuthenticationException으로 처리되고, entrypoint를 설정하면 401로 내려간다.

 

  • 유저가 로그인하지 않았을 때는 403이 아닌, 401을 내려주고 싶은데 어떻게 해?
    → 위에서 봤듯이, filterchain에서 AuthenticationException이 발생하고, 이를 잡아서 authenticationEntryPoint를 호출하는 것을 보았다. default값인 Http403ForbiddenEntryPoint 대신에 HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED) 으로 설정해주면 된다.
    http
        .exceptionHandling().authenticationEntryPoint(HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
     

 

📒 정리

  1. webSecurity의 permitAll, authenticated, hasRole 등은 인가 관련 설정이다.
  2. 인증하지 않은 사용자도 해당 인가 설정을 적용하기 위해 spring security는 Anonymous 라는 개념을 사용한다.
  3. AuthenticationEntryPoint로 인증, AccessDeniedHandler로 인가 Exception을 custom하게 처리할 수 있다.

spring은 설정이 간편하지만, 그만큼 설정에 따라 많은것이 바뀐다. 그래서 잘 알고 쓰는게 확실히 중요하다고 느낀다.

'Spring' 카테고리의 다른 글

FilterChain을 2번 호출한다고?  (1) 2024.09.14
Spring @RequestParam 주의할 점  (0) 2024.08.16
테스트 환경 설정하기  (1) 2024.08.08

새로운 프로젝트를 받으면서, 레거시 코드로 인해 발생한 간단한 해프닝을 공유하려고 한다. 레거시 코드라 PUT method에서 RequestParam으로 데이터를 받는 케이스가 있었다..!

문제 상황

  • UDC에서 POST , PUT method에서 body로 보내지 않고, RequestParam 으로 데이터를 보냄
  • 프론트 요청으로 queryParam에 Boolean? 값을 추가함
    @PutMapping
    fun update(
    	@RequestParam(required = false) name: String?,
    	@RequestParam(required = false) isEnabled: Boolean?,
    )
    
  • 프론트에서 “null” 을 넣어서 보내니 400에러가 발생한다고 문의가 들어옴. 값을 넣지 않으면 정상 동작(null로 들어감)
  • 뿐만 아니라 string value에 null을 집어 넣으면 "null" 로 들어가서 db까지 null string으로 저장되는 문제 발생

원인 파악

  1. string value (”null”)을 boolean으로 converting하지 못하는 것으로 예상
    • spring의 @RequestParam 에서 사용하는 converter 확인
      • StringToBooleanConverter 코드
        final class StringToBooleanConverter implements Converter<String, Boolean> {
        
            private static final Set<String> trueValues = new HashSet<>(8);
        
            private static final Set<String> falseValues = new HashSet<>(8);
        
            static {
                trueValues.add("true");
                trueValues.add("on");
                trueValues.add("yes");
                trueValues.add("1");
        
                falseValues.add("false");
                falseValues.add("off");
                falseValues.add("no");
                falseValues.add("0");
            }
        
        
            @Override
            @Nullable
            public Boolean convert(String source) {
                String value = source.trim();
                if (value.isEmpty()) {
                    return null;
                }
                value = value.toLowerCase();
                if (trueValues.contains(value)) {
                    return Boolean.TRUE;
                }
                else if (falseValues.contains(value)) {
                    return Boolean.FALSE;
                }
                else {
                    throw new IllegalArgumentException("Invalid boolean value '" + source + "'");
                }
            }
        
        }
    • 코드를 보면, string "null" 값에 대한 처리는 없음
  2. string의 경우에는 별도의 convert 없이 받는 데이터 그대로 string으로 넘겨줌
  3. @RequestBody 의 경우 jackson converter로 json 객체를 parsing하고, json 객체에는 null과 "null" 을 구분할 수 있기 때문에 큰 문제가 없었음

해결 방법

  1. 별도의 converter 등록하기
    위 converter에서 null string 체크 로직을 추가한 custom converter를 등록하면 된다. 그러나 requestParam은 url에서 파싱한 string 값이고, 이미 요청할 때 string 값을 넣은 것이기 때문에 null 변환이 있는게 더 어색해 보인다.
  2. RequestParam → RequestBody 변환하기
    PUT 요청에서는 requestBody를 쓰는게 일반적이기 때문에 더 자연스러워 보인다.

 

레거시 코드라서 볼 수 있었던 일종의 해프닝이다. 이 계기로 여러 converter들의 코드를 확인해보고, query string을 파싱방법을 리마인드 할 수 있어서 재밌었다.

'Spring' 카테고리의 다른 글

FilterChain을 2번 호출한다고?  (1) 2024.09.14
PermitAll의 함정  (0) 2024.08.16
테스트 환경 설정하기  (1) 2024.08.08

문제상황

  1. Mysql 8.0를 사용하고 있었고, B table에 A 테이블에 대한 FK 걸려있었다. 즉 A가 부모, B가 자식 관계이다.
  2. @OneToMany 를 사용하는 A Entity에서 B Entity에 CascadeType.ALL 설정으로 A Entity를 Aggregate Root로 사용하고 있다.
  3. 동시에 같은 A Entity를 update 시도하한다. 두 요청 모두 A Entity의 자식은 B Entity를 변경하는 작업이다.
  4. 이 때 DeadLockException 발생한다.
org.springframework.dao.CannotAcquireLockException: could not execute statement; SQL [n/a]; nested exception is org.hibernate.exception.LockAcquisitionException: could not execute statement
...
Caused by: com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: Deadlock found when trying to get lock; try restarting transaction
    at com.mysql.cj.jdbc.exceptions.SQLError.createSQLException(SQLError.java:123)
    at com.mysql.cj.jdbc.exceptions.SQLExceptionsMapping.translateException(SQLExceptionsMapping.java:122)
    at com.mysql.cj.jdbc.ClientPreparedStatement.executeInternal(ClientPreparedStatement.java:953)

 

 

  • 예시 코드
class A(
    var name: String,

    @OneToMany(mappedBy = "a", cascade = [CascadeType.ALL], orphanRemoval = true)
    val bList = MutableList<B>,
) {
    fun update(name: String, bList: List<B>) {
        this.name = name
        this.bList.clear()
        this.bList.addAll(bList)
    }
}
class B(
    val name: String,

    @ManyToOne
    @JoinColumn(name = "a_id")
    val a: A,
)
@Service
class AService {
    @Transactional
    fun updateById(id: Int, name: String, bNameList: List<String>): A {
        val a = aRepository.findById(id)!!
	
        a.update(
            name = name,
            bList = bNameList.map { B(it, a) }
        )
				
        return aRepository.save(a)
    }
}

 

  • 테스트코드
@SpringBootTest
class ATest(
    private val aService: A,
    private val aRepository: ARepository,
) : BehaviorSpec({
    Given("A entity가 주어지고") {
        val A = aRepository.save(A("test", mutableListOf()))
	
        When("동시에 A entity를 수정 시도하면") {
            val try1 = CoroutineScope(Dispatchers.IO).async {
                aService.updateById(a.id, "update1", listOf("b1", "b2"))
            }
            val try2 = CoroutineScope(Dispatchers.IO).async {
                aService.updateById(a.id, "update2", listOf("b3", "b4"))
            }
						
            listOf(try1, try2).awaitAll()
						
            Then("동시성 이슈 발생함") {
                ...
            }
        }
    }
})

 

원인 분석

결론부터 말하자면, FK 때문에 발생하는 DeadLock이다.

MySQL에서는 FK가 걸린 데이터를 조회하면 FK에 해당하는 테이블의 row에 S-Lock을 건다. FK 데이터의 일관성 유지를 위함인데, 이것이 문제 발생의 원인이 된다. [공식 문서]

 

일반 조회 상황이나, 자식 테이블의 데이터만 수정하는 경우에는 문제가 없는데, 부모 데이터까지 같이 수정하는 경우에 문제가 발생한다. 어떻게 Deadlock이 발생할 수 있는지 아래 흐름으로 자세히 보자.

 

  1. 1번 Thread에서 aRepository.save(a) 코드 실행할 때, B entity를 insert 먼저 시도
    • MySQL에서는 자식 table에 insert 시 FK에 해당하는 부모 row에 S Lock 을 검
  2. 2번 Thread에서도 aRepository.save(a) 코드 실행할 때, B entity를 insert 시도함.
    • 동일하게 S Lock 을 걸기 때문에 문제 X
  3. 1번 Thread에서 A Entity에 update 시도 함
    • 본인이 건 S Lock을 X Lock 으로 바꾸려고 시도하지만, 2번 Thread가 건 S Lock 으로 인해 대기
  4. 2번 Thread에서 A Entity에 update 시도 함
    • 본인이 건 S Lock을 X Lock 으로 바꾸려고 시도하지만, 1번 Thread가 건 S Lock 으로 인해 대기
  5. DeadLock 발생

 

결국에는 FK가 있는 데이터만 수정하는 것이 아닌, 부모 데이터까지 수정하는 것이 문제인 것이다.

그러나 JPA에서는 ID 생성 전략을 IDENTITY로 설정하여 DB에 위임할 경우, save 호출 시 insert는 불가피하다. 따라서 부모 entity를 aggregate root로 설정할 경우, 자식 entity가 먼저 save 되고 부모 entity는 transactional이 종료될 때 저장 된다.

 

즉, FK가 걸려있는 entity에서 OneToMany 설정에 cascade.ALL 설정이 들어가 있으면 반드시 발생할 수 있는 케이스이기 때문에 주의가 필요하다.

해결 방법

1. A Entity 조회 단계에서 X Lock 을 걸고 조회하기

  • JPA의 @Lock 활용
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT e FROM A e WHERE e.id = :id")
fun findByIdWithLock(id: Int): A?

 

실제로 나간 조회 쿼리를 보면, FOR UPDATE 를 사용하여 조회 시 X LOCK 을 명시적으로 획득하는 것을 확인할 수 있다.

SELECT
    ...
FROM
    A
WHERE
    a.id = ? for update

 

OneToMany 사용시 이렇게 컨벤션을 가져가자고 하는 것도 어색하고, 개발자가 빠뜨리고 실수할 확률이 높아 보인다. 또한 X Lock을 잡아놓는 것이기 때문에 잘못 사용하면 성능에 큰 영향을 미칠 수 있다.

따라서 이 방법은 추천하지 않는다.

2. FK 제거하기

지금 상황을 회사 DBA 분께 설명하고, 어떻게 하는 것이 좋은지 조언을 구하였다.

질문
위 상황에서 FK로 인해 DeadLock이 발생하고 있습니다. 여러 블로그 글에서 FK를 제거하라는 솔루션을 많이 제안하는데, RDB에서 FK를 제거하는 것이 맞는 판단인지 모르겠습니다. RDB에서 FK라는 연관관계가 없다면 RDB의 의미가 많이 퇴색되어 RDB의 철학과 멀어지는 것 아닌가 하는 생각도 드는데 DBA분께서는 어떻게 생각하시나요? 다른 곳에서는 어떻게 FK를 다루나요?

 

DBA 답변
실제 많은 서비스에서는 데이터 무결성이 매우 중요해서 성능을 포기해도 되는 상황이 아니면 대부분 외래키를 사용하지 않습니다. 데이터 모델링 단계에서도 실제 물리 모델링시에는 관계를 표현만하고 외래키를 제거하는 것이 거의 표준처럼 이뤄집니다... 제가 있던 환경에서는 스키마 변경가능성, 락 발생, 성능과 DML 작업시 편의성을 위해 외래키를 없애는 것이 표준이었고 추가적으로도 락 범위를 최소화시키기 위해 MSA 구조의 전체 DB가 read-commited 격리레벨을 사용합니다. 락이 그만큼 무서운 것 같네용 RDBMS를 사용하면서 FK를 사용하지 않는 것이 조금 어색해보이긴 하지만... 물리레벨의 관계는 아니더라도 application 단에서 논리적 관계를 맺는 것도 데이터 무결성은 소스코드단에서 더 신경써야겠지만 넓은 의미에서는 여전히 논리적인 관계는 존재하기 때문에 RDBMS 철학과 크게 벗어나지는 않는다고 생각합니다.

 

이 질문으로 생각보다 많은 것을 깨달았고 사고가 조금 바뀌는 계기가 되었다. 결국 트레이드 오프이고, 성능이 일반적인 서비스에서 제일 중요한 가치가 될 것이기 때문에, RDB를 쓰기 때문에 FK는 포기못해! 이런 생각은 위험한 생각일 수 있겠다고 느꼈다.

또한 물리적인 관계 뿐만 아니라 코드로 생기는 논리적인 관계도 RDB 철학과 벗어나지 않는다는 말씀에, 철학과 개념에 나 스스로 가두는 느낌을 받았다. 좀 더 실용적이고 유연한 사고를 가지도록 생각을 바꿔야겠다.

 

FK 얘기가 나온 김에 좀 더 찾아보았는데, 이런 비슷한 고민을 한 사람들과 회사들이 많았다. 재밌었던 것은, Github에서는 FK를 어디에서도 사용하지 않는다고 한다. 정확하는 MySQL용 스키마 이관 도구인 gh-ost 의 얘기라 github 전체를 대변하지는 않는다. [관련 글] 

 

비고

  • 실제로 해보니 thread1은 성공하고 thread2는 실패하는데?
    맞다. 하나의 쓰레드는 성공하고 다른 하나는 deadlock 에러를 뱉으며 실패할 것이다.
    mysql에서 deadlock 감지를 통해 문제되는 transaction 하나를 kill하기 때문에, 하나는 deadlock에서 풀려나서 성공하고, 나머지 하나는 kill되어 exception이 발생하는 것이다.

 

  • 자매품으로 서브쿼리가 있다.
    JPA가 아닌, MyBatis를 사용하는 다른 프로젝트에서도 동일하게 발생 했었다. INSERT 쿼리에 서브 쿼리가 들어가 있었고, 서브쿼리는 조회한 데이터에 S LOCK을 잡는다. 
    그리고 transaction가 끝나지 않고 S LOCK을 잡은 데이터에 update를 시도하며 발생하였다.

 

  • 해당 이슈 원인 분석에 도움이 많이 된 글이 있다. 한 번씩 읽어보면 도움이 될 것이다. [블로그 글]

'JPA' 카테고리의 다른 글

JPA EntityEventListener의 위험성  (0) 2024.08.16
@UpdateTimestamp의 비밀  (1) 2023.04.30

어느날, 회사에서 테스트코드를 만들고 있었다. 테스트에서는 DB, Kafka 등의 모든 외부 연결은 mocking하여 사용하고 있었다. 그러나 DB에서 json 타입의 필드를 저장한다던가, converter를 활용하여 DB에 저장되는 값을 변경하는 등의 작업이 많아지면서 외부연동, 특히 DB까지의 테스트가 필요하다고 느끼고 있었다. 그래서 DB까지 연동하여 테스트하는 방법과 연동하는 방법을 찾아보았고 이를 정리해보았다.

 

H2 Database

DB 테스트를 한다고 했을 때, spring에서는 가장 쉽게 도입할 수 있는 것이 h2 database이다.

  • h2 database 설정하기
    • 테스트 어플리케이션 설정
      application-test.yaml 설정에서 datasource에 h2로 설정하고, driver를 h2.Driver 로 변경하면 h2 database를 사용할 수 있다.
      spring:
        datasource:
          url: jdbc:h2:mem:mata;MODE=MySQL;DB_CLOSE_DELAY=-1
          driver-class-name: org.h2.Driver
      ...
      
    • flyway 데이터 / 초기 데이터
      spring boot는 기본적으로 CLASSPATH 에 존재하는 schema.sql , data.sql 파일을 읽어서 초기 데이터를 세팅한다.
      spring.sql.init.mode=always
      
      위와 같은 설정이 필요하지만, h2에서는 기본값이 always 다.
      DROP TABLE IF EXISTS `user`;
      
      CREATE TABLE `user`
      (
          `id` int(11) NOT NULL AUTO_INCREMENT,
      ...
      )
      물론 flyway 설정을 추가해서 초기 데이터 작업을 할 수 있다. 초기 데이터를 넣어서 테스트하는 방법은 여러가지가 있으니 [관련자료] 에서 확인할 수 있다.

장점

    • 설정하기 쉽다. 초기 데이터 설정도 하지 않을것이라면 test용 spring 설정 파일에 datasource만 h2로 넣으면 된다.
    • in-memory database라서 추가로 고려해야할 점이 많지 않다. (docker 컨테이너를 띄우려면 docker engine이 실행되어 있어야하고, port도 설정해야하고..)
    • 또한 h2 는 다양한 데이터베이스와의 호환성을 제공한다. [공식문서]

 

단점

  • in-memory라서 많은 데이터를 넣으면 성능에 큰 영향을 미칠 수 있다. 테스트 환경에 따라 테스트가 정상 실행되지 않을 수도 있는 것이다.(경험상 이런적은 없긴 하다.)
  • mysql과 100% 호환되지 않는다.
    • 🚨 실제로 mysql과 collation이 달라서, 데이터 검색 시 대소문자 구분으로 인해 테스트는 실패하고 로컬서버에서는 성공하는 문제가 있었다.
In MySQL text columns are case insensitive by default, while in H2 they are case sensitive. However H2 supports case insensitive columns as well. To create the tables with case insensitive texts, append IGNORECASE=TRUE to the database URL (example: jdbc:h2:~/test;IGNORECASE=TRUE).

[공식문서]

 

 

TestContainter

H2 DB와 같이 테스트에서의 환경이 달라 발생하는 문제를 해결하기 위해 나온 오픈소스 프레임워크로, 테스트 환경에서 독립적으로 실행되는 컨테이너를 구축할 수 있다.

장점

  • mysql 서버를 띄우는 것이기 때문에 운영 환경과 동일한 테스트 환경을 구축할 수 있다. mysql 버전 및 변수까지 동일하게 설정 가능하다.
  • container를 띄우는 것이기 때문에 테스트별로 컨테이너를 실행하여 테스트 독립성을 높힐 수 있고, 여러 datasource 환경에서도 적용이 가능하다.
  • database 뿐만 아니라 MQ 등 다양한 기능들이 존재한다. (컨테이너만 띄우면 됨)
  • h2 보다는 추가 설정이 필요하지만, 단순한 설정으로도 실행이 가능하다.
  • mysql 서버를 띄우면서 초기 데이터를 삽입하여 테스트를 더 편하게 할 수 있다.

단점

  • 실행하는 머신에서 docker engine이 존재해야한다.
    • github action 시 self-hosted-runner 를 사용하면, 해당 머신에 docker engine이 설치돼 있어야 한다.
    • 로컬에서 실행하려면 docker engine을 켜야한다.

사용 방법

1. build.gradle에 의존성 추가

testImplementation platform('org.testcontainers:testcontainers-bom:1.19.1') // 선언하면 testcontainer 모듈들의 버전을 통합 관리해줌
testImplementation "org.testcontainers:testcontainers"
testImplementation "org.testcontainers:junit-jupiter"
testImplementation "org.testcontainers:mysql"

 

2. 테스트 어플리케이션 설정 추가

 

datasource와 driver를 원하는 database에 맞게 변경해준다.

mysql 외에 다른 DB는 [공식문서] 를 참고하면 된다.

spring:
  datasource:
    url: jdbc:tc:mysql:8.0.26:///user&TC_DAEMON=true&autoReconnect=true&serverTimezone=UTC&characterEncoding=utf8&useSSL=false
    driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver
...

 

3. 컨테이서 실행 시 초기 데이터 설정 추가

 

TC_INITSRIPT 에 contianer가 실행될 때 실행할 sql 파일을 넣어주면, MySQL 컨테이너가 실행되면서 해당 SQL 파일이 실행된다.

(mysql에서는 spring.sql.init.mode 설정 기본값이 never 이다. 따라서 schema.sql 파일로 인해 초기 데이터가 세팅되는 일은 설정을 추가하지 않는 한 없다. 따라서 schema.sql로 초기 세팅 SQL 파일을 만들었다.)

 

DROP TABLE IF EXISTS `user`;

CREATE TABLE `user`
(
    `id` int(11) NOT NULL AUTO_INCREMENT,
...
)
spring:
  datasource:
    url: jdbc:tc:mysql:8.0.26:///incident?TC_INITSCRIPT=file:src/test/resources/schema.sql&TC_DAEMON=true&autoReconnect=true&serverTimezone=UTC&characterEncoding=utf8&useSSL=false
    driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver
...

 

flyway 설정을 추가해서 테이블을 생성하는 것도 가능하다. 다만 테스트마다 flyway 파일들을 하나씩 실행해야 하므로 테스트 시간이 더 길어진다.

 

참고

  • 테스트마다 새로운 컨테이너를 띄우는 것도 가능하다. [관련 자료]
  • mysql 외에 redis, kafka 등 다양한 컨테이너 설정 예시들이 있으니, 공식문서에서 확인해보면 친절하게 설명해준다. [공식 문서]

여러 테스트 설정

이렇게 끝내려고 했지만, 내가 테스트할 때 설정하는 방법들을 공유하면 좋을 것 같아서, 여러 테스트를 위해 내가 부리는 기교들을 공유하려고 한다.

Integration 테스트하기

integration 테스트라고 하니 논란이 있을 수 있지만, 여기서 얘기하는 것은 spring application을 실행하고 이를 테스트하는 것을 뜻한다. 모든 테스트마다 @ActiveProfiles("test") , @SpringBootTest 를 붙히는건 귀찮으니, @IntegrationTest 라는 어노테이션을 만들고, spring을 띄워서 테스트할거야 라는 의미를 줄 수 있도록 만들었다.

@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@ActiveProfiles("test")
@SpringBootTest
annotation class IntegrationTest

Redis 설정하기

RDB 뿐만 아니라, Redis 또한 많이 사용할 것이다. 마찬가지로 나도 session 방식 로그인을 위해 사용하고 있다. 이를 테스트하기 위해서는 redis pod를 띄워야하는데, mysql 처럼 application 설정 만으로 띄우기는 어렵다.

따라서 redis container를 정적 변수로 만들고, application context에 추가하여 redis.port 와 같은 설정값에 잘 적용되도록 설정하였다.

또한 redis 설정을 @IntegrationTest 어노테이션에 추가하여 해당 어노테이션 사용 시 자동으로 redis가 실행되도록 하였다.

 

class TestRedisConfiguration : ApplicationContextInitializer<ConfigurableApplicationContext> {
    companion object {
        val redisContainer = GenericContainer<Nothing>("redis:alpine")
            .apply {
                withExposedPorts(6379)
                withReuse(true)
            }
    }

    override fun initialize(applicationContext: ConfigurableApplicationContext) {
        redisContainer.start()

        TestPropertyValues.of(
            "spring.redis.host=${redisContainer.host}",
            "spring.redis.port=${redisContainer.firstMappedPort}"
        ).applyTo(applicationContext.environment)
    }
}
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@ActiveProfiles("test")
@SpringBootTest
@ContextConfiguration(initializers = [TestRedisConfiguration::class])
annotation class IntegrationTest

Mock으로 객체 만들어서 Bean에 넣기

테스트를 하다보면 mock의 필요성을 많이 느끼게 될 것이다. 예를 들면, 외부 API 연동까지 테스트를 해야하나? 이걸로 인해 테스트까지 영향 받는 것이 맞나? 싶은 생각이 들 것이다. 이 때 mocking을 통해 쉽게 해결할 수 있다.

(개인적으로는 외부 연동까지 테스트코드를 통해 테스트 하는 것은 과하다고 생각한다.)

  • mockkBean 사용하기
    Mockito 환경에서 테스트 코드를 짜 본 사람이라면 @MockBean을 사용하면 되는데? 라고 생각할 것이다. 그러나 kotest와 mockk를 사용하는 코틀린 테스트 환경에서는 MockBean을 사용하면 정상 동작하지 않는다. mockk와 mockito가 충돌이 발생하여 함수가 잘 mocking 되지 않는다. 정확히는 mockito의 mockBean을 사용하면, 해당 객체의 설정은 mockito 기능을 사용해야한다.

    두 라이브러리 모두 every 라는 함수를 통해 객체의 함수를 mocking하기 때문에, 각 객체가 어떤 라이브러리에 의해 mocking 되었는지를 찾아서 해당하는 라이브러리의 함수를 사용해야 된다는 의미이다.

    그러나 이를 하나하나 비교하며 다른 기능을 쓰기에는 무리가 있다. 그래서 나는 어떤 사람이 만들어 놓은 mockk를 위한 mockbean을 사용한다.
    mockk에서 정식으로 지원하는 라이브러리는 아니지만, 481 stars 를 받을 정도로 인기있는(?) 라이브러리이다. [Github]
    1. gradle 의존성 추가
      testImplementation("com.ninja-squad:springmockk:3.1.1")
      
    2. @MockkBean 사용
      @IntegrationTest
      @DisplayName("User Service Test")
      class UserServiceTest(
          private val userService: UserService,
          @MockkBean(relaxed = true)
          private val userRepository: UserRepository,
      ) : BehaviorSpec({
      ...
      
    3. mock 데이터 추가 
      every {
          userRepository.save(user)
      } returns user
      
  • @Primary 사용하기
    만약 위 방법이 마음에 들지 않는다면, test 환경에서 사용하기 위한 객체를 만들고 bean에 등록하면 된다. 그리고 @Primary 어노테이션을 활용하여, 해당 객체를 테스트 코드에서 주입받을 수 있도록 설정한다. 
    • 예시 코드
      S3에 upload하는 클래스를 mocking하는 코드이다. 클래스의 각 함수들이 원하는 방식대로 동작하도록 mocking 해 두고 이를 Bean에 등록한다.
    @Configuration
    @Profile("test")
    class MockUploaderConfiguration {
        @Bean
        @Primary
        fun mockPrivateUploader(): PrivateUploader {
            val s3Object = mockk<S3Object>(relaxed = true)
            val uploader = mockk<PrivateUploader>(relaxed = true)
            
            every { uploader.getS3Object(any()) } returns s3Object
    
            return uploader
        }
    }

Multi module에서 테스트 관련 설정 한곳에 몰아넣기

multi module 환경에서는 코드들이 분리되어 있기 때문에 테스트 관련 설정을 각 모듈별로 가지고 있어야 한다. 이런 불편함을 없애기 위해, 나는 해당 설정을 한 곳에서 모아 관리하는 방법을 사용한다.

  1. core 모듈의 build.gradle.kts에서 testJar task 추가
    tasks.register<Jar>("testJar") {
        from(sourceSets["test"].output)
        archiveClassifier.set("test")
    }
    
  2. testArchives 추가 및 testJar task 추가
    configurations {
        create("testArchives") {
            extendsFrom(configurations["testImplementation"])
        }
    }
    
    artifacts {
        add("testArchives", tasks.named("testJar"))
    }
    
  3. API 모듈(사용하는쪽) 에서 test 의존성 추가
    dependencies {
        testImplementation(project(":core", configuration = "testArchives"))
    }

Controller 테스트하기

controller 테스트는 mockMVC를 사용하여 테스트하고, api 요청과 동일한 것처럼 보이지만 filter까지 테스트하지는 않는다. filter까지 테스트하는 방법은 여러 블로그에 잘 나와 있으니 참고하면 좋을 것이다. [관련 블로그]

controller 테스트를 하는 이유는 api로 들어오는 값의 type 혹은 형식검사 등의 기능을 검사하기 위함이라고 생각하기 때문에, service 클래스를 mocking해서 bean에 등록하기도 한다.

@ActiveProfiles("test")
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
class UserControllerTest(
    val mockMvc: MockMvc,
) : StringSpec({
    val objectMapper = ObjectMapper()
		
    "User 생성 controller 테스트" {
        val request = CreateUserRequest()
        val result = mockMvc.perform(
            post("/v1/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content(
                    objectMapper.writeValueAsString(request)
                )
            )
	
            result.andExpect(status().isOk)
    }
})

 

번외

kotest에서 @Trasnactional이 동작하지 않는 버그를 만났다. 테스트가 완료되어도 데이터가 삭제되지 않은 것인데, 이는 위에서 얘기한 mockbean과 비슷한 이유이다.

@SpringBootTest에 존재하는 SpringExtension 설정은 jupiter의 SpringExtension이기 때문에 kotest를 지원하지 않는 것이다. 따라서 해결 방법은 KoTest의 SpringExtension을 설정하는 것이다. 아래 두 코드 중 원하는 방법을 선택하면 된다.

@ExtendWith(SpringExtension::class)
@Transactional
@SpringBootTest
class Test(): DescribeSpec ({
    extension(SpringExtension)
    ...
})

 

[관련 stackoverflow]

[관련 블로그]

 

마무리

테스트와 관련된 내용을 찾아보다가 재미있는 논쟁이 있어서 공유하면서 마무리하려고 한다.

Spring 테스트에서 @Transactional 을 사용해도 되느냐에 대한 논쟁인데, spring 쪽 거물들이 나와서 논쟁하는 것이 매우 흥미롭다. 한번씩 읽어보면 좋을 것 같다. [향로님 블로그]

 

개인적인 생각으로는... 테스트의 목적이 시스템 안정화와 버그찾기 라는 관점을 가지고 있어서, 간편한 테스트 설정으로 인해 잡지 못한 버그가 운영까지 이어지는 것이 더 위험하다고 생각된다. 결국 트레이드 오프가 있는 것이고, 어떤 것이 더 크리티컬하냐 라고 봤을때는 향로님 관점이 좀 더 공감된다. 일개 주니어의 생각.. 끝

 

 

 

 

'Spring' 카테고리의 다른 글

FilterChain을 2번 호출한다고?  (1) 2024.09.14
PermitAll의 함정  (0) 2024.08.16
Spring @RequestParam 주의할 점  (0) 2024.08.16

🔍 목적, 배경

내가 있었던 팀의 백엔드는 MSA 구조를 가지고 있다. 많은 곳에서 레포까지 나누기도 하지만, 우리팀은 모듈만 나누어 멀티모듈 구조로 운영하고 있다.

여러 모듈을 관리하다 보니 중복코드가 발생할 수 밖에 없었고, 중복코드 관리의 필요성을 느끼고 있었다.

중복코드가 뭐가 있냐? 싶을 수 있지만, security 설정, 인증필터, swagger 설정 등 모든 모듈에서 적용되는 설정부터 시작해서
거래소 조회 기능, 파일 업로드 기능, 엑셀 생성 및 다운로드 기능 등 기능 단위로 중복 코드가 발생했다.

중복코드는 많아질수록 기능을 수정할 때 모든 모듈에 들어가서 확인을 해야하기 때문에 수정을 놓치기 쉽다. 하나라도 놓치면 장애로 이어질 수 있는 폭탄과 같은 존재이다! 💣

 

중복 코드를 관리해야할 필요성은 충분히 느낀 것 같고, 그럼 어떻게 관리하는게 좋을까?

  1. Core 모듈
    multi module 프로젝트에 core 모듈을 두는 것에 대한 찬반 논란이 꽤 있는 것으로 알고 있다.
    통합어드민을 구축할 때도 마찬가지로 많은 논쟁과 토론이 있었지만, 결국 core 모듈을 만들기로 했었다.

    그로 인해 우리팀은 core 모듈을 이미 가지고 있었고, 모든 모듈에 공통적으로 적용되어야만 하는 핵심적인 기능들만 모아두었다.
    (core 모듈에 코드가 추가되는 PR이 올라오면, 그 날은 토론의 장이 열린다 🤣)

    그러나 엑셀 생성과 같은 기능을 core에 넣기는 부담스럽다고 생각했다. 엑셀의 기능이 바뀐다고 모든 모듈이 영향을 받아야하나? 라는 생각을 해보면 모든 사람들이 그건 좀.. 이런 생각을 할 것이다.

    그럼 핵심 기능을 제외한 공통 코드는... 어디에 넣지...?

  2. Common 모듈
    common 모듈은 이름 그대로 공통되는 코드를 모으는 모듈이다.
    내가 생각했던 사용 예시는 다음과 같다.

    멀티 모듈에서는 모듈끼리 통신이 필수적이기 때문에, User 모듈 호출에 사용할 VO는 다른 모듈에서도 공통적으로 사용할 법 하다.
    그럼 UserVO 클래스는 common에 넣어야지~

    그러나 이런 모듈이 생기게 되면 굉장히 위험한 일이 발생한다. "공통코드 모음"이기 때문에 모든 모듈은 common 모듈을 import하고 있을 수 밖에 없다. 그럼 common에 기능 하나가 변경되어도 모든 모듈에 영향이 발생하게 된다!

    그럼 이런 의문이 들 수 있다. core도 코드 바뀌면 모든 모듈 영향 받는건 똑같잖아? 뭐가 다른데?
    다르다. core는 이름에서 주는 "핵심" 이라는 압박감과, "필수 공통 기능만 추가"라는 팀 컨벤션으로 맞춰진 hard한 코드리뷰로 정제된다.
    이와 반대로 common은 쉽게 수정할 수 있으며, core와 같이 팀원들의 심리적 압박감도 없기 때문에 잘못된 코드가 발생할 확률이 높다.

    common은 리스크가 너무 크다!

  3. Library 모듈
    library는 이름 그대로, 기능 단위의 코드를 묶어서 제공하는 모듈이다.
    기능을 제공하기 위함이 목적이니만큼, 무차별적인 변경은 일어나지 않을 것이다.
    또한 기능별로 제공하기 때문에 코드 응집성도 높일 수 있고, 원하던 공통 코드를 관리하기에 적합하다!


이러한 흐름으로 우리팀은 핵심코드는 Core 모듈에, 기능단위 중복코드는 Library 모듈에 만들기로 하였다!

그렇게 파일 업로드, 엑셀, 암호화 등 다양한 기능이 개발되었다.

신나게 사용하던 중, 한 팀원이 JPA 기능의 sort 기능을 편하게 사용할 수 있도록 library 모듈에 코드를 추가하였다.
library 코드가 수정되면 library를 사용하는 모든 모듈이 재빌드되어 배포되는 방식인데, 한 서버가 뜨지 못하고 계속 죽고 있었다.

원인을 살펴보니, library에 JPA 의존성이 추가되었는데, 해당 서버에서는 DB를 사용하지 않아 datasource 설정이 없어서 발생하는 문제였다.

코드하나 추가했는데 어딘지 모르는 어떤 서버에서 문제가 생긴다는건 굉장히 큰 문제라고 느껴졌다.

프로젝트 구조를 바꿔야겠다...!

 

문제점

프로젝트 구조를 바꾸기 전, 라이브러리 모듈의 문제점을 나열해보았다.

  1. 라이브러리 기능끼리 의존성이 강하다
    라이브러리 구조를 바꾸려고 코드를 확인해보니, 기능단위로 끊어 놓았다고 생각했던 코드들이 스파게티처럼 엮여 있었다.
    기능끼리 의존성을 가지다 보니 library를 더더욱 덜어내기 어려워졌고, 기능끼리의 의존성을 끊어야겠다고 생각했다.
  2. 라이브러리가 변경되면 모든 모듈에 영향을 미친다.
    공통 기능을 하나의 모듈로 묶다 보니, 모든 모듈이 library에 의존성을 갖게 되었다.
    그래서 library에 코드를 추가하면 모든 모듈이 배포 되어야하고, 모든 모듈이 영향을 받는다.

해결책

  1. Library를 멀티모듈 프로젝트로 분리하기
    library를 github repository로 분리하고, 그 안에서 기능들을 멀티모듈로 만드는 것이다!
    repo를 분리하면서 다른 모듈들과 물리적인 의존성을 끊어내고, 기능끼리의 의존성은 멀티모듈로 끊어내는 방법이다.
  2. Github Maven Registry를 활용하여 버저닝하기
    코드 추가 및 수정에 기존 모듈들이 영향을 받지 않게 하려면 버저닝 외에는 방법이 없다.
    손쉽게 할 수 있는 방법 없을까 찾다가 maven repository에 올리면 좋겠다고 생각하였고, github에도 존재하길래 냉큼 사용하였다.

🛠️ 프로젝트 구성하기

왜 Github Maven Registry인가?

  • private maven repository가 필요하다
  • github에 대한 이해도가 높다
  • 보안성 검토가 필요 없다.

이 3가지 이유로 채택하였다.

 

GIthub Action 구성하기

모듈 이름과 버전을 받고, github registry에 publish하는 재사용 workflow 예시이다.

on:
  workflow_call:
    inputs:
      module_name:
        required: true
        type: string
      version:
        required: true
        type: string

jobs:
  publish-package:
    runs-on: [ self-hosted ]
    steps:
      - name: Checkout Repository
        uses: actions/checkout@v3

      - name: Set up JDK 17
        uses: actions/setup-java@v3
        with:
          distribution: 'corretto'
          java-version: '17'
          cache: 'gradle'

      - name: Publish to Github Registry
        run: ./gradlew :${{ inputs.module_name }}:publish
        env:
          MAVEN_USER: ${{ github.repository_owner }}
          MAVEN_PASSWORD: ${{ secrets.GITHUB_TOKEN }}
          VERSION: ${{ inputs.version }}

 

Gradle 설정하기

  • Library 프로젝트 Gradle
    소스코드를 같이 포함해서 빌드하여 사용하는 쪽에서 코드를 확인할 수 있도록 구성하였다.
import org.springframework.boot.gradle.tasks.bundling.BootJar

plugins {
	`maven-publish`
}

allprojects {
	apply(plugin = "maven-publish")
	group = "com.test.library"
}

subprojects {
    val sourceSets = the<SourceSetContainer>()
    val sourcesJar = task<Jar>("sourcesJar") {
        from(sourceSets["main"].allSource)
        archiveClassifier.set("sources")
    }

    publishing {
        repositories {
            maven {
                name = "GitHubPackages"
                url = uri("https://maven.pkg.github.com/<ORG_NAME>/library")
                credentials {
                    username = System.getenv("MAVEN_USER")
                    password = System.getenv("MAVEN_PASSWORD")
                }
            }
        }
        publications {
            create<MavenPublication>(project.name) {
                version = System.getenv("VERSION") ?: project.property("VERSION").toString()

                artifacts {
                    artifact(tasks.getByName("jar").outputs.files.singleFile) {
                        builtBy(tasks.getByName("jar"))
                    }
                    artifact(sourcesJar)
                }
            }
        }
    }
}
  • API 프로젝트 Gradle
    사용하는 곳의 gradle에는 maven에 github 주소를 넣으면 된다.
allprojects {
	repositories {
        mavenCentral()
        maven {
            url = uri("https://maven.pkg.github.com/<ORG_NAME>/library")
            credentials {
                username = System.getenv("MAVEN_USER")
                password = System.getenv("MAVEN_PASSWORD")
            }
        }
        mavenLocal()
    }
}

 

만든 모듈을 import하려면, 다른 라이브러리 설정하듯이 하면 된다.

implementation("<groupId>:<artifactId>:<version>")

ex) implementation("com.test.library:excel:1.0.0")

 

브랜치 전략 설정하기

다양한 브랜치 전략이 있는데, 나는 Github-flow 전략을 채택하였다. [공식 문서]

라이브러리가 메인 프로젝트가 아니기 때문에 git-flow와 같은 너무 많은 step을 두고 싶지는 않았다. 그러나 버전별로 코드를 관리는 해야하기 때문에 비교적 단순한 github-flow가 적절하다고 생각했다.

내가 생각한 개발 스텝은 다음과 같다.

  1. main branch에서 새로운 branch 생성
    브랜치 명 컨벤션은 다음과 같다.
    <module_name>/<버전>
    ex) excel/1.1.0

  2. 해당 브랜치에서 개발 후 PR 생성
  3. main branch에 merge 시 github action으로 maven registry에 publish

 

로컬 개발 방법

로컬에서 개발할 경우 잘 동작하는지 확인이 필요하다. 그럴 땐 local maven으로 publish 한 뒤 사용하는 모듈에서 확인해보면 된다.

./gradlew :{{ module_name }}:publishToMavenLocal

ex) ./gradlew :excel:publishToMavenLocal

---

gradle :{{ module_name }}:publishToMavenLocal

ex) gradle :excel:publishToMavenLocal

 

로컬에서는 주의해야할 점이 있다. 로컬에서 개발하기 위해 Local Maven Registry에 이미 publish가 되어있다면, API 개발할 때 Github에서 가져온 것이 아니기 때문에 문제가 발생할 수 있다. 이럴땐 intellij에서 local maven을 찾아 해당 라이브러리를 지우면 된다.

+ Recent posts