ELK 환경에서 좀 더 정교한 이슈 트래킹 Part2 - Thread Context 적극 활용하기

ELK 환경에서 좀 더 정교한 이슈 트래킹 Part2 - Thread Context 적극 활용하기

요약: 이 글은 ELK 환경에서의 이슈 트래킹을 개선하기 위해 ThreadContext와 Sentry를 적극 활용하는 방법을 설명합니다. Part1에서 다룬 문제점들, 특히 로그 추적의 복잡성과 부족한 정보 문제를 해결하기 위한 구체적인 전략을 제시하며, 예외 발생 시 더 빠르고 정교하게 로그를 확인할 수 있는 방안을 소개합니다. RequestLoggingFilter와 Sentry Tag 주입을 통해 로그 추적을 자동화하고, requestId를 활용하여 로그를 한 번에 찾아볼 수 있는 방법을 구현합니다.

💡 리뷰어 한줄평

bread.young 고유한 ID를 전파하여 다양한 모니터링 도구에서 손쉽게 확인할 수 있는 아이디어가 정말 좋네요!!

rain.drop 로그가 너무 많아서 탐색이 어려운 경험, 혹은 막상 찾아도 정보가 부족해서 슬픈 경험.. 시간이 없는 이슈 상황에서 이러면 너무 아찔한데요. 😭 이슈 트래킹 속 이런 불편함을 포도는 ThreadContext와 MDC를 활용해서 이를 개선했어요!! 같은 고민을 가지신 분들께 추천드립니다~

시작하며

안녕하세요. 해외결제서비스유닛에서 서버 개발 업무를 맡고 있는 포도입니다.

지난 포스팅인, Part1 - 이슈 트래킹 기반 마련하기에서는 ELK를 활용한 Request 요청 로깅과 Sentry를 적용한 이슈 트래킹 전략의 기반을 구성하는 내용을 설명하였습니다. 그리고 서비스를 운영하면서 Part1의 전략만으로 겪은 몇 가지 문제점도 설명하였습니다.

이번 포스팅인, Part2에서는 Part1의 문제점을 개선하기 위해서 Thread Context를 적극 활용하여 이슈 트래킹을 발전시킨 과정을 설명드립니다. 좀 더 빠르게 Request 요청 로그를 확인하는 방법과, 로그에는 담을 수 없었던 필요로 하는 정보들을 Thread Context를 활용하여 확보한 방법을 공유합니다.

Part2의 내용은 Part1의 내용과 예시를 기반으로 작성되었습니다. Part2의 읽기에 앞서, Part1의 내용을 복기하고 읽는 것을 권장드립니다.

Part2는 hello-elk-thread 레포지터리에 part2 브랜치를 참조하시기를 바랍니다.

Part1 전략 기반의 이슈 트래킹 문제점 복기

본문에 앞서 Part1의 문제점을 간단하게 복기하면 다음과 같습니다.

첫 번째 문제점은 수많은 로그에서 예외 발생 로그를 찾기가 쉽지 않다는 점입니다. 예외가 발생하면 이슈 원인 분석을 위해 Kibana에 접근해 로그를 확인해야 하지만, 빠르게 대응해야 하는 상황에서 이 과정은 큰 방해 요소가 될 수 있습니다.

두 번째 문제점은 Request 요청 로그가 이슈 원인을 분석하기에는 충분하지 않다는 점입니다. Request 요청 로그에는 요청, 응답에 대한 정보를 포함하고 있지만 이 정보만으로 이슈 원인을 분석하기에는 어려운 경우가 발생합니다. Part2에서는 위의 문제점을 Thread Context를 적극 활용하여 개선하는 전략을 공유합니다.


Thread Per Request

Spring Boot, 그중에서도 Spring MVC는 일반적으로 Tomcat을 WAS로 사용하고 있습니다. 따라서 본 포스팅에서의 Spring 애플리케이션의 명시는 Tomcat을 WAS로 사용하는 것을 가정합니다.

Tomcat은 Thread Per Request 모델을 사용합니다. 말 그대로 Thread Pool에서 하나의 Request 요청 당 하나의 Thread를 할당하는 메커니즘입니다. 이 해석은 Request 요청에 대한 로직을 처리하는 비즈니스 흐름에 별도의 비동기 로직이 없다면 할당받은 Thread를 계속 사용하는 것을 의미합니다. 이번 이슈 트래킹 전략은 위의 해석을 적극적으로 활용해보려고 합니다.


이슈 발생 시, 로그까지의 지름길

RequestId

Part1에서는 Servlet Filter인 RequestLoggingFilter를 정의하여 ELK 환경에서 Request 요청에 대한 로깅을 남기는 전략을 사용하였습니다. 이번 일반편에서는 RequestLoggingFilter를 고도화하려고 합니다.

먼저 RequestLoggingFilter의 시작 지점에서 requestId라는 고유한 값을 생성하였습니다. requestId는 Request 요청에 대한 식별자 역할을 수행합니다.

class RequestLoggingFilter : OncePerRequestFilter() {

    override fun doFilterInternal(
        servletRequest: HttpServletRequest,
        servletResponse: HttpServletResponse,
        filterChain: FilterChain
    ) {

        // RequestId 생성
        val requestId = UUID.randomUUID().toString()
//...

앞으로 소개될 내용에서 requestId는 예외 정보와 로그에 추가되며, 서로 흩어져 있지만 이슈와 관련된 정보들을 하나로 이어주는 역할로 사용될 것입니다. 이렇게 이어진 로그와 예외 정보는 예외 발생 시 로그를 찾는 여정에 지름길을 마련합니다.

Sentry Context 이해하기

requestId를 예외 정보에 주입하는 내용에 앞서 Sentry Context에 대한 설명을 하려고 합니다.

Sentry는 Context를 지원합니다. Context(문맥)은 하나의 흐름 안에서 이벤트와 데이터를 공유할 수 있습니다. Spring 애플리케이션에서 Context(문맥)의 개념을 생각해 보면 요청 시작부터 응답까지를 하나의 Context로 볼 수 있을 것입니다.

재밌는 부분은 Sentry Context For Spring은 하나의 Context를 Thread-Local로 구분하여 정의합니다. 앞서 말씀드렸듯이, Spring 애플리케이션으로의 Request 요청은 Thread Per Request 모델을 사용함으로써, Request 요청 시 하나의 Thread를 할당받습니다. 비즈니스에서 별도의 비동기 작업이 없다면, 하나의 요청은 하나의 Thread를 응답까지 사용하는 것입니다.

이 해석은 Request 요청 시 할당받은 Thread는 Sentry Context와 동일한 라이프 사이클을 가지는 것을 의미합니다. 좀 더 쉬운 해석은 다음과 같이 할당받은 Thread안에 Sentry Context의 자리가 한켠 마련되어 있는 것입니다.

Sentry Tag를 사용한 로그 추적

코드의 구현으로 다시 돌아오면, RequestLoggingFilter의 시작 지점에 다음과 같이 Sentry Tag에 requestId를 주입하는 코드를 추가하였습니다.

class RequestLoggingFilter : OncePerRequestFilter() {

    override fun doFilterInternal(
        servletRequest: HttpServletRequest,
        servletResponse: HttpServletResponse,
        filterChain: FilterChain
    ) {

        // RequestId 생성
        val requestId = UUID.randomUUID().toString()
        Sentry.setTag(SentryTag.REQUEST_ID, requestId) // Sentry Tag 주입
        ...
}

Sentry Tag는 Sentry Context 안에 주입될 것입니다. 즉, 다음과 같이 할당받은 Thread에 requestId값이 저장되는 것입니다.

Sentry Report With Tag

이제 하나의 Request 흐름을 예시로 살펴보겠습니다. 결제 요청 API인 /api/pay 가 호출되면 결제 비즈니스 흐름을 진행할 것입니다. Request 요청은 RequsetLoggingFilter 코드를 호출하며, Sentry Tag에 requestId를 주입하고, Filter를 지나 Controller로 진입할 것입니다.

@RestController
class PayController(
    private val payService: PayService
) {

    @GetMapping("/api/pay")
    fun pay(txId: String): PayResponse =
        payService.pay(txId).let { PayResponse.of(it) }
}

이후에는 복잡한 비즈니스 흐름을 거치게 됩니다. 이 과정에서 예측할 수 없는 예외가 발생할 가능성도 있습니다.

@Service
class PayService {

    fun pay(txId: String): PayResult {

        // 비즈니스 흐름 ~~
        throw RuntimeException("알 수 없는 예외 발생.")
        // 비즈니스 흐름 ~~

        return PayResult(true)
    }
}

이슈 트래킹 전략의 기반을 마련했다면, 예외 발생 시 Sentry Report가 발생할 것입니다. 여기서 재밌는 부분이 있습니다. 이때, Sentry Context에 있는 Tag 정보들을 Sentry Report시 포함시키는 것입니다.

RequestLoggingFilter에서 주입한 requestId가 Sentry Report시 같이 전송되고, 이를 Sentry 대시보드에서 예외 정보와 함께 확인할 수 있습니다.

RequestLog 데이터 클래스, with RequestId

예외 정보와 로그 정보를 이어 주기 위해서는 로그 데이터에도 requestId를 추가해야 합니다. RequestLoggingFilter에서 다음과 같은 구현을 추가합니다. RequestLog 데이터 클래스에, requestId를 주입하는 코드를 추가하는 것입니다.

class RequestLoggingFilter : OncePerRequestFilter() {
        ...
            ElkLogger.info(
                "Request Log",
                RequestLog(
                    requestId = requestId, // RequestId 주입
                    request = createRequestLog(request, requestAt),
                    response = createResponseLog(response, requestAt, responseAt),
                )
            )
        }
        ...
}
data class RequestLog(
    val requestId: String, // requestId 주입
    val request: Request,
    val response: Response,
)

RequestLog 데이터 클래스는 Request 요청에 대한 로그의 형태를 정의한 클래스입니다. RequestLog 데이터 클래스에 필드를 추가하는 것은, Kibana에서 확인할 수 있는 로그에 정보를 추가하는 것입니다. 따라서 requestId를 주입하는 것은, Kibana에서 다음과 같이 필드가 추가되는 것을 의미합니다.

Kibana에서 RequestId로 로그 한 번에 찾기

이제 예외 발생 시, 예외가 발생한 Request 요청 로그를 찾는 것은 상당히 간단해졌습니다.

방법은 간단합니다. 예외 발생 시 Sentry 대시보드에서 확인한 requestId를 복사하고, Kibana에서 검색하는 것입니다. 앞서 로그 형태인 RequestLog 데이터 클래스에 요청의 고유한 requestId가 주입되어 있기 때문에 requestId 검색 시, 단 하나의 찾고자 하는 요청 로그를 확인할 수 있을 것입니다.

이로써 Part1에서 설명한 하나의 문제점을 개선했습니다. 예외 발생 시 수많은 로그에서, 예외가 발생한 로그를 찾는 과정을 한 번의 검색으로 해결할 수 있게 됐습니다.

로그까지의 지름길을 완성하였습니다.

Kibana URL 활용하여 로그 한 번에 찾기

여기서 조금 더 재밌는 발전을 한번 더 할 수 있습니다. 매번 requestId를 복사하고, Kibana에 접근해서 붙여 넣고 검색하는 것도 번거로운 작업입니다. 따라서 RequestLoggingFilter에서 Kibana URL을 주입하는 것도 아이디어일 것입니다.

Kibana에서의 검색 조건은, Kibana URL의 QueryParamter에 바로 반영되는 것을 확인할 수 있습니다.

http://kibana.local/app/kibana#/discover?_g=(time:(from:now-7d,to:now))&_a=(columns:!(_source),index:'103c2e10-77da-11ef-a17a-07241150b3ca',interval:auto,query:(language:kuery,query:'requestId%20:%20%220b17f43f-3389-4160-9c89-4c75e7005b64%22'),sort:!('@timestamp',desc))

QueryParamter를 보면 다음과 같은 검색 조건들을 확인할 수 있습니다.

  • 인덱스 패턴 조건: index:'103c2e10-77da-11ef-a17a-07241150b3ca'
  • 검색 시간 조건: from:now-7d,to:now
  • requestId 검색 조건: query:'requestId%20:%20%220b17f43f-3389-4160-9c89-4c75e7005b64%22'

RequestLoggingFilter에서 Filter 시작 지점에, 위 조건에서 requestId값 만 변경하여 kibanaUrl을 생성해서 주입하는 것입니다.

class RequestLoggingFilter : OncePerRequestFilter() {

//..
    Sentry.setExtra(SentryExtra.KIBANA_URL, createKibanaUrl(requestId))
//..

여기서는 Sentry Tag가 아닌 Sentry Extra를 주입하였습니다. Sentry Tag는 글자수 200자 제한이 있어 URL이 길어질 경우 정상적으로 Sentry Report에 포함되지 않습니다. 따라서 Sentry Extra를 사용하여 값을 주입하였습니다. Sentry Extra도 마찬가지로 Sentry Context 안에 주입되는 값으로 Sentry Report시 DSN 주소로 포함되어 전송됩니다.

이제 예외가 발생하여 Sentry 대시보드로 접근하면 kibanaUrl을 확인할 수 있습니다. 그리고 클릭 한 번으로 예외가 발생한 요청 로그를 찾을 수 있습니다.

지름길의 완성

정리하면 Request 요청 시 requestId라는 고윳값을 RequestLoggingFilter의 시작점에서 로그, Sentry Context에 주입하였습니다. 이는 예외 정보와 로그를 연결 짓는 역할을 합니다.

Sentry Context는 하나의 문맥인 Thread-Local 안에서 예외가 발생하면 requestId를 포함한 Report를 진행합니다. 그리고 Sentry에 포함된 requestId로 요청에 대한 로그를 찾는 지름길로 활용합니다. 이제 예외 발생 시, 클릭 한 번으로 좀 더 빠르게 이슈의 분석에 필요한 정보를 확보하여 좀 더 기민한 이슈 대응을 할 수 있을 것입니다.


그리고, 로그에 원인 분석이 필요한 정보가 (있)습니다

이제 예외 발생 시, 수많은 로그에서 예외가 발생한 요청 로그를 빠르게 찾을 것입니다. 하지만 이렇게 빨리 찾은 로그에서 획득한 요청, 응답 정보들만으로 이슈의 원인을 분석하기 쉽지 않을 때도 있습니다. 이를 마찬가지로 Thread Context를 적극 활용하여 개선하려고 합니다.

MDC란?

MDC는 org.slf4j 패키지에 있는 로그 메타데이터 (key/value) 저장소입니다. MDC도 하나의 Context(문맥) 안에서 로그 메타데이터를 공유하는데, Sentry Context와 마찬가지로 Thread-Local 한 라이프 사이클을 가집니다. 이 해석은 앞서 말씀드린 Sentry Context와 동일한 메커니즘으로 동작합니다. 즉, 요청 시 할당받은 Thread에 MDC도 한켠으로 자리를 잡고 있는 것입니다.

MDC Helper

MDC는 싱글톤 클래스로 단순히 key/value 저장소입니다. 따라서 put(), get(), clear()와 같은 단순한 함수를 사용하여 할당받은 Thread에 값을 저장, 삭제합니다.

앞으로 설명드릴 내용은 추가로 정의한 MDCHelper 싱글톤 클래스를 활용하여 설명하려고 합니다. MDCHelper는 좀 더 다양한 기능들을 포함하는 함수를 정의하여 MDC를 사용하는 용도의 클래스입니다.

그럼 다시 RequesetLoggingFilter의 구현을 추가하려고 합니다. Filter 시작지점에 이번에는 MDCHepler.init(requestId) 함수가 호출된 코드를 추가하였습니다.

class RequestLoggingFilter : OncePerRequestFilter() {
    //...
    override fun doFilterInternal(
        servletRequest: HttpServletRequest,
        servletResponse: HttpServletResponse,
        filterChain: FilterChain
    ) {

        // RequestId 생성
        val requestId = UUID.randomUUID().toString()
        Sentry.setTag(SentryTag.REQUEST_ID, requestId)
        MDCHelper.init(requestId) // MDC 주입
//...

MDCHelper.init() 함수는 MDCrequestId를 주입합니다.

object MDCHelper {

    fun init(requestId: String) {
        clear()
        MDC.put(Key.REQUEST_ID, requestId)
    }

    object Key {
        const val REQUEST_ID = "requestId"
    }
}

따라서 Filter 시작지점이 끝나는 시점엔 할당받은 Thread에는 다음과 같은 모습일 것입니다.

appendDebug(), alsoAppendDebug()

좀 더 정교한 이슈 트래킹을 위해서는 운영 데이터에서 궁금한 부분을 자유롭게 볼 수 있어야 합니다. 그러기 위해서는 보고 싶은 데이터를 RequsetLog 데이터 클래스에 자유롭게 덧붙일 수 있는 구조가 필요할 것입니다.

하지만 RequestLog 데이터 클래스는 RequesetLoggingFilterdoFilterInternal() 함수에서 생성됩니다. doFilterInternal()의 코드를 살펴보면, 함수 내에 존재하는 인스턴스들 내에서 추출할 수 있는 정보들은 제한적임을 알 수 있습니다. 요청, 응답 정보들 외에는 추출할 수 있는 데이터들이 없는 것입니다.

class RequestLoggingFilter : OncePerRequestFilter() {

    override fun doFilterInternal(
        servletRequest: HttpServletRequest,
        servletResponse: HttpServletResponse,
        filterChain: FilterChain
    ) {

        //..

        try {
            filterChain.doFilter(request, response)
        } finally {
            val responseAt = LocalDateTime.now()

            // request, response 에서 정보를 추출하는것 밖에는 할 수 없다..
            ElkLogger.info(
                "Request Log",
                RequestLog(
                    requestId = requestId,
                    request = createRequestLog(request, requestAt),
                    response = createResponseLog(response, requestAt, responseAt)
                )
            )

            response.copyBodyToResponse()
        }
        //..
    }

이런 한계점을 MDC를 사용하여 즉, Thread Context를 활용하여 극복하는 것입니다.

MDCHelper 클래스에는 다음과 같이 appendDebug(), alsoAppendDebug() 함수를 생성하였습니다.

object MDCHelper {
    fun appendDebug(clazz: KClass<*>, message: String) {
        val debugMessage = MDC.get(Key.DEBUG) ?: ""
        val time = LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME)

        MDC.put(
            Key.DEBUG,
            "${debugMessage}\n${time} ${clazz.java.canonicalName ?: clazz.java.name} $message"
        )
    }

    fun <T : Any> T.alsoAppendDebug(
        clazz: KClass<*>,
        block: (T) -> String,
    ): T {
        val message = block(this)
        appendDebug(clazz = clazz, message)
        return this
    }
}

appendDebug(), alsoAppendDebug() 함수는 싱글톤 클래스의 함수로 비즈니스 흐름의 어느 곳에서도 사용할 수 있습니다. appendDebug() 함수의 코드를 살펴보면, 역할은 매우 간단합니다.

  1. MDC에서 debug 키의 값을 꺼내줍니다. 이 값은 문자열입니다.
  2. 그리고 이 문자열에 인자로 받은 \n${시간}-${클래스명}-${메시지}를 덧붙입니다.
  3. 다시 MDCdebug 키값에 주입하는 것입니다.

코드의 흐름에서 appendDebug(), alsoAppendDebug() 함수가 계속 호출되면 문자열은 계속 증가할 것입니다.

한 가지 예시로 다음 Request 요청 흐름을 다시 살펴보겠습니다. 결제 API인 /api/pay Request 요청을 받으면 결제 비즈니스 흐름을 진행합니다. 단순하게 결제 흐름을 이렇게 상상해 볼 수 있습니다.

  1. 결제 히스토리 저장
  2. 결제 검증
  3. 결제 진행
  4. 결제 결과 응답

그리고 다음과 같이 코드를 구현하였습니다.

@Service
class PayService(
    private val payHistoryRepository: PayHistoryRepository,
    private val payValidateAdapter: PayValidateAdapter,
    private val bankAdapter: BankAdapter,
) {

    fun pay(txId: String): PayResult {

        payHistoryRepository.save(PayHistory(null, txId))
            .alsoAppendDebug(this::class) {
                "PayHistory 생성, id : ${it.id}"
            }

        payValidateAdapter.validateTx(txId)
            .alsoAppendDebug(this::class) {
                "결제 트랜잭션 검증 결과 : ${it}"
            }

        bankAdapter.use(txId)
            .alsoAppendDebug(this::class) {
                "은행 사용 요청 결과 : $it"
            }

        return PayResult(true)
    }
}

코드 중간에 alsoAppendDebug() 함수를 필요한 곳마다 추가하였습니다. 필요한 곳은 데이터를 보고 싶은 간지러운 부분입니다. 물론 위의 예시는 하나의 함수 내에서만 사용하였지만, 필요한 곳 어디든지 여러 클래스, 함수 내에서 alsoAppendDebug()는 호출할 수 있습니다.

위의 코드를 그림으로 표현하며 다음과 같이 debug 에는 문자열이 계속 쌓이는 것입니다.

이렇게 MDCdebug에 쌓인 정보들을 이제는 가시성을 확보해야 합니다. 다시 그럼 RequestLoggingFilter로 돌아와서, RequestLog 데이터 클래스를 생성하는 부분에 구현을 추가하려고 합니다.

RequestLog 데이터 클래스는 로그의 형태입니다. 그리고 metadata 필드를 추가하였습니다. 이 필드의 값은 MDCHelper.getMetadata() 함수를 호출하여 MDCdebug 값을 주입합니다.

class RequestLoggingFilter : OncePerRequestFilter() {

    override fun doFilterInternal(
        servletRequest: HttpServletRequest,
        servletResponse: HttpServletResponse,
        filterChain: FilterChain
    ) {

        //..
        ElkLogger.info(
            "Request Log",
            RequestLog(
                requestId = requestId,
                request = createRequestLog(request, requestAt),
                response = createResponseLog(response, requestAt, responseAt),
                metadata = MDCHelper.getMetadata()
            )
        )
//..
object MDCHelper {
    //..
    internal fun getMetadata(): Map<String, Any> =
        mapOf(
            "debug" to (MDC.get(Key.DEBUG) ?: "")
        )
}

앞서 설명하였듯이, RequestLog 데이터 클래스의 필드의 추가는 Kibana에서 볼 수 있는 추가적인 필드를 의미합니다. 그리고 다음과 같이 Kibana에서 metadata.debug 필드와 값을 확인할 수 있습니다.

각 줄의 개행은 코드의 흐름을 의미합니다. 따라서 코드의 흐름을 어떻게 진행하였는지 알 수 있습니다. 익숙해진다면 중간의 예외가 발생 시 어느 코드의 흐름까지 진행하였는지도 파악될 것입니다.

각 줄에는 개발자가 필요로 하는 정보를 담고 있습니다. 메시지를 추가한 시간과, 메시지를 추가한 곳인 클래스 정보를 확인할 수 있습니다. 그리고 메시지로 예시와 같이 데이터베이스의 데이터를 저장 시 채번 된 ID 값이나, 외부 요청의 결괏값들을 담을 수 있습니다. 문자열은 따로 제한을 두지 않기 때문에, 필요하다면 개발자는 어느 정보든지 담을 수 있을 것입니다.

이제, 로그에 원인 분석에 필요한 정보가 (있)습니다

이제 로그의 원인 분석에 필요한 정보들을 담을 수 있는 구조를 완성하였습니다. 이 구조는 별도의 비동기 로직이 없다면, 제약을 받지 않는 것이 중요한 포인트라고 생각이 듭니다.

이슈 분석을 위한 어떠한 정보가 필요하다고 생각되는 어느 곳이든 appendDebug(), alsoAppenDebug() 함수를 호출하고 정보를 담습니다. 그리고 Kibana에 접근하여 metadata.debug 필드에 쌓인 정보들을 가지고 이슈 원인을 분석하는 것입니다. 이제 요청, 응답 정보들뿐만 아닌 개발자가 의도한 곳에 정보의 가시성이 확보가 된 것입니다.

실제로 해외결제서비스에서는 하나의 흐름에 이 정도의 metdata.debug가 누적이 됩니다.

설명하지 않았지만 metdata.exception과 같은 비즈니스 흐름에서 try/catch로 감싸져서 노출되지 않는 예외의 정보들도 같은 메커니즘으로 담아내서 사용하고 있습니다. 해당 부분은 공유드린 레포지토리에 코드를 보시면 확인할 수 있습니다.


마치며

이번 포스팅인 Part2에서는 Thread Context를 적극 활용하여 Part1의 이슈 트래킹 전략의 기반에서 겪은 문제점을 극복하였습니다. 따라서 Part2의 전략을 서비스에서 적용한다면 다음과 같은 발전을 이룰 수 있습니다.

  • 예외 발생 시, requestId로 요청에 대한 로그를 클릭 한 번으로 찾을 수 있습니다.
  • 비즈니스 흐름에서 추가적인 정보들을 Request 요청 로그에 쌓아 올려, 이슈 분석을 위한 정보를 확보할 수 있습니다.

개인적으로 Part2에서 가장 짚고 싶은 부분은 MDC를 사용하여 서비스의 운영에 필요한 어느 곳이든 debug 수준에 로그를 남길 수 있는 것이라 생각됩니다.

지금까지의 서비스 운영에서도 debug를 쌓아 올리는 과정을 최대한 활용하고 있습니다. 필요한 정보들을 배포 시에 꾸준히 개선하며 이슈 트래킹을 위한 적중률 높은 정보들을 쌓아가고 있는 것입니다. 이렇게 애플리케이션은 경험치를 쌓아 올리며 점점 더 안정적인 운영을 하도록 자연스럽게 발전하고 있습니다. 이제는 Sentry 알림을 받고 원클릭으로 로그에 접근하여 debug를 확인하는 것은 자연스러운 흐름이 되었습니다.

아쉽게도 Part2의 전략에서도 운영하면서 몇 가지 한계점을 겪었습니다. 마지막 포스팅인 Part3에서는 이를 Multi Thread Context를 활용하여 극복하는 과정을 공유합니다. 배치성 API와, 비동기 환경에서의 이슈 트래킹 전략의 극복 과정에 대한 내용을 담으려고 합니다.

마지막으로 이슈 트래킹의 고민을 하고 있는 개발자라면, 본 포스팅이 좀 더 정교한 이슈 트래킹에 도움이 되기를 희망하며 마치겠습니다.

감사합니다. 🙂

podo.c
podo.c

문제 해결에 희열을 느끼는 서버 개발자입니다. 🙂

태그