ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 스프링에서 재처리를 위한 @Retryable 사용하기
    Spring Framework 2022. 8. 22. 23:10
    반응형

    1) 서론

    혹시 어떠한 일을 할 때 배보다 배꼽이 더 커졌던 경험이 있으신가요? 

     

    예전에 문득 해물탕이라는 요리가 너무 먹고 싶었고, 집 근처에서 해물탕 가게를 찾을 수 없었습니다. 그래서 직접 해물탕을 만들겠다는 생각을 했었는데요.

     

    하지만 해물탕 한 그릇을 먹기 위해 비싼 해산물들을 구매해야 했습니다.

     

    그리고 구매한 해산물들을 다듬는 시간은 꽤 오래 걸렸는데요. 혹시 잘 못 해감을 해서 모래가 씹히지 않을까, 이 부분은 먹어도 되는 걸까? 고민을 했었습니다.

     

    어찌어찌 요리 후 맛있게 먹었습니다.

     

    하지만 여전히 남는 아쉬움은 있었습니다. 15분 만에 먹고 끝내는 이 한 그릇을 위해서 이렇게 많은 돈과 정성을 쏟는 것이 정말로 효율적인 건가? 식당을 찾아가서 먹거나, 추가의 배달비를 내고 배달을 시키는 것이 더 효율적이지는 않을까 고민했었습니다.

     

    업무에서도 마찬가지입니다. 외부 API를 호출해야 하는 경우가 있습니다. 이때 IOException이 발생했고, 재처리를 해야 하는 상황이었습니다.

     

    그리고 단순히 재처리를 위한 비효율적인 코드를 변경하는 과정을 공유드립니다.


    2) 장애 상황

    Server returned HTTP response code: 500 for URL:
    • 내부 애플리케이션 서버에서 외부의 특정 API를 조회
    • 특정 URL 기반으로 호출 후 XML 파일을 stream으로 읽음
    • 파일을 읽는 과정에서 IOException을 응답 받음

     

    보통의 IOException 발생 원인은 아래와 같습니다.

    • 특정 파일을 읽어들이다가 네트워크 연결이 끊어졌을 때
    • 파일은 존재하지만, 특정한 이유로 읽을 수 없을 때 (e.g. 권한)
    • 기타 등등

     

    IOException은 말 그대로 Input/Output을 하는 과정에서 어떠한 문제가 있어서 발생합니다. 해당 URL의 XML 다시 조회했을 때에는 문제없이 동작합니다. 

     

    파일을 읽어들이는 과정에서 외부 서버에서의 일시적인 오류가 전파된 것으로 추측합니다.


    3) 고민

    외부 서버와의 통신이 많은 서비스를 담당할 때는 항상 고민입니다.

     

    내부적으로는 예외를 핸들링하느것 외에는 무엇을 할 수가 없는데요. 특정한 원인을 파악할 수 없는 경우도 많습니다. 특히 CRUD 중 CUD에 해당하는 것은 무작정 재처리를 할 수도 없습니다.

    • 외부 서버로 CRUD 중 CUD에 해당하는 작업 요청
    • 외부 서버에서 network 관련된 오류 발생

     

    요청한 서버에서는 매우 혼란스럽습니다. 

    • 요청한 작업은 '완료했지만', 단순히 네트워크 오류인지?
    • 요청한 작업도 '완료못했고', 네트워크 오류도 발생인지?

     

    이러한 경우에 데이터가 변하는 CUD를 무작정 재요청할 수도 없습니다. 즉 데이터의 일관성을 보장할 수 없는데요.

     

    단순히 Read 작업도 마찬가지입니다.

    • 단순히 일시적인 오류로 network 관련 exception이 발생했는지?
      • 그렇다면 곧바로 요청하면 해결이 되는 것인지?
      • 일정한 시간을 가진 뒤 재요청해야 하는 것인지?
    • 다른 오류인데 IOException으로 뭉쳐서 던지는 건지?

     

    엄격히 서로의 인터페이스가 정의된 환경이 아니라면, 매우 혼란스러울 수밖에 없는데요.

     

    해당 IOException을 최근 세 달치를 분석했을 때는 한 건도 발생하지 않았습니다. 즉 이것은 매우 이례적이며, 드물게 발생하는 일시적인 오류라고 판단했습니다.

     

    그리고 데이터의 변경이 발생하는 CUD가 아닌, read 요청이므로 재처리하기로 결정합니다.


    4) 기존의 재처리 방식

        fun sayHello() {
            var count = 0
            do {
                try {
                    return requestExternalServer()
                } catch (e: IOException) {
                    logger.error("IOExeption occurs!")
                }
            } while (++count < 2)
        }

     

    그동안 선택했던 재처리 방식입니다.

     

    do - while 문을 활용해서 exception 발생 시 2회까지 재처리하는 로직입니다.

     

    다만 위의 로직에는 문제가 있어 보입니다.

     

    일시적인 네트워크 오류로 인해서 단순히 한 번쯤 더 재시도를 하고 싶은데요. 이것을 위한 로직이 너무 길고 가독성이 떨어져 보입니다.

    do - while, try - catch, logging, count 변수까지 너무 복잡하게 구성되어 있습니다.

     

    그저 한번 더 해보고 싶었을 뿐인데 필요한 게 많습니다.


    5) @Retryable

    Spring Retry는 특정한 상황에서 재시도를 쉽게 할 수 있는 라이브러리입니다.

    5. 1) 설정

    // build.gradle.kts
    implementation("org.springframework.retry:spring-retry:1.3.3")
    implementation("org.springframework:spring-aspects:5.3.22")
    • retry와 AOP 관련 의존성을 추가합니다.
    @Configuration
    @EnableRetry // SpringApplication.class에 추가해도 무방
    class RetryConfig
    • @EnableRetry 애노테이션을 추가합니다.
    • 명시적으로 설정 클래스들을 정의 후 세부적인 내용을 추가하는 것을 선호하여, 위와 같이 정의합니다.
    • SpringApplication.class에 추가해도 무방합니다.
        @Retryable(
            value = [IOException::class, TimeoutException::class],
            maxAttempts = 2,
            backoff = Backoff(delay = 1000)
        )
        fun sayHello() {
            println("request extrnal server")
            return requestExternalServer() // IOExeption 발생
        }
    • @Retryable: 재시도 함수에 추가
    • value: 재시도될 exception 타입 정의
    • maxAttempts: 최대 시도 횟수 (defautlt 3회)
    • backoff: 재시도 전 기다리는 시간에 대한 설정
      • delay: 재시도 전 대기 시간. default 1000ms지만, 명시적으로 작성
      • multiplier: 다음 재시도 시의 현재 대기 시간의 배수
      • maxDelay: 재시도 간의 최대로 기다릴 수 있는 시도

    5. 2) Exception 발생 테스트

        @Retryable(
            value = [IOException::class, TimeoutException::class],
            maxAttempts = 2,
            backoff = Backoff(delay = 1000)
        )
        fun sayHello() {
            println("request extrnal server")
            return requestExternalServer() // IOExeption 발생
        }
    
        // IOExeption 발생
        fun requestExternalServer() {
            throw IOException()
        }
    • requestExternalServer(): 무조건 exception을 발생
        @Test
        @DisplayName(value = "IOException이 발생면, 재시도 되어야 한다.")
        fun `Should retry When IOException occurs`() {
            // when
            val result = assertThrows<IOException> {
                sayHelloService.sayHello()
            }
    
            // then
            Assertions.assertNotNull(result)
        }

    IOException은 발생했고, 예상대로 재시도됩니다.

    5. 3) 결국엔 AOP

     

    스프링 AOP 관련 의존성을 추가해준 이유입니다.

     

    Exception이 발생하면 intercept 후 재시도 로직을 시도하게 됩니다.


    6) 결론

    외부 서버와 통신을 할 때 단순한 네트워크 관련 오류인 경우에는 재처리를 해야 하는 경우가 있습니다.

     

    이때 단순히 한번 더 실행하기 위해서 복잡하고, 가독성이 떨어지는 코드가 추가되었는데요.

     

    이러한 문제점을 해결하기 위해 @Retryable을 사용했고, 조금 더 깔끔하고 automatic 하게 처리할 수 있었습니다.

     

    불필요한 로직을 단순화한 것과 더불어, 항상 사용하던 방법에서 탈피해서 더 나은 생각을 시도했다는 점에서 좋은 결과인 것 같습니다.

    반응형

    댓글

Designed by Tistory.