Post

메세지 재전송 트러블슈팅기

메세지 재전송 트러블슈팅기

서론

예약 메세지 전송 프로젝트를 실제 production 환경에 배포한 이후에서야 문제점을 발견하였습니다.

Screenshot 2024-08-24 at 5.55.53 PM

한 명의 사용자가 의도적으로 메세지 전송을 실패시키는 요청을 보낸 것이 아님에도 불구하고, 문제가 발생하는 하나의 메세지가 수 많은 에러 로그들을 내뱉고 있었습니다.

원인은 재전송 최대 횟수 제한

기존에 작성했던 로직에 재전송의 최대 횟수 제한을 두지 않았던 것이 문제가 되었습니다.

언젠가는 오류가 복구되어 재전송이 가능한 메세지 라는 섣부른 가정에서 시작한 문제였습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private async publish() {
    newrelic.incrementMetric('scheduler')
    await newrelic.startBackgroundTransaction('scheduler', async () => {
        const now = new Date()
        let page = 1
        while (page) {
            const orbits = await OrbitModel.findByExecutionTime(page, now)
            for (const orbit of orbits.docs) {
                this.queue.push(orbit)
            }
            page = orbits.nextPage
        }
    })
}

기존의 코드에서는 scheduler module에 의해서 매 분마다, db에서 전송 대상이 되는 메세지들을 가져와서 message queue에 push하고

이를 worker가 처리하는 형식으로 처리됩니다.

이 과정에서 오류가 복구되지 못하고 잔재될 수 있는 메세지에 대해서 별도의 처리를 하지 않고 큐에 지속적으로 삽입하는 것에서 문제가 발생한 것입니다.

재전송의 제한 기준

이를 해결하기 위해서는 재전송 메커니즘을 적용할 대상을 식별하는 것이 중요했는데요.

현재 production 환경의 서버의 부하가 그렇게 크지 않았기 때문에, 어느 정도 유연한 기준을 채택할 수 있었습니다.

추후에 정책의 변경이 발생할 수도 있으므로 외부 모듈이 직접적으로 의존성을 가지지 못하도록 private 하게 선언하였습니다.

채택한 기준은 아래와 같습니다.

  • 에러가 발생한 시점을 기준으로 하루 전까지 누적된 에러의 총 횟수가 \(n\) 번이 넘지 않도록

또한 \(n\) 값을 환경변수로 관리하는 것이 적합하다 판단하여 dotenv 를 이용하여 관리를 하였습니다.

1
2
3
# .env
# ... 중략 ...
MESSAGE_RESEND_THRESHOLD=5
  • 5라는 기준은, 기존의 서버의 에러 로그들을 종합하여 확인한 결과 통계적으로 5번 미만에서 90% 이상의 메세지가 성공적으로 재전송됨을 확인하였기 때문입니다.

왜 Redis?

  • 기존의 메세지 모델에 에러일자를 추가하는 것은 메세지 모델의 목적과 의미와는 거리가 있다고 판단하여 별도의 모듈로 관리하는 것이 적절하다고 판단하였습니다.
  • 에러에 따른 재전송에 제한을 두는 정책 자체가 엄격하지 않기 때문에, 에러 로그를 영속시키는 것은 불필요하다고 판단하였습니다.
  • 매번 메세지 모델을 검사할 때마다 이전에 누적된 에러 정보를 구하는 연산의 비용이 크면 안될 것이라 판단하여 메모리 기반의 db를 생각하게되었습니다.

array 형식으로 이전에 발생했던 에러의 시점 을 관리 하고자 하였는데, Redis의 TTL 기능을 활용할 수 있다면 에러 로그들을 별도로 처리하지 않아도 된다는 점이 매력적으로 느껴졌습니다.

하지만, redis에서 array 계열 중 원소 각각에 대한 TTL를 설정할 수 있는 방법은 존재하지 않아서 대신 ZSET 을 이용하여 log 시간에 24시간 이내에 발생한 에러일시 의 개수를 구하는 것으로 대체하였습니다.

에러 로그들에 대해 메모리에서 적절한 삭제가 이루어지지 않으면 문제가 될 수 있기에, 24시간을 초과한 에러로그들을 삭제하는 로직을 포함시켰습니다.

1
2
3
4
5
6
7
private async isOrbitCanBeSent(clientId: string): Promise<boolean> {
    const now = new Date().getTime()
    const yesterday = now - 60 * 60 * 24

    await redisClient.zRemRangeByScore(clientId, '-inf', yesterday)
    return (await redisClient.zCount(clientId, '-inf', '+inf')) < MESSAGE_RESEND_THRESHOLD
}

Redis를 적용할 수 없었던 이유

여러 시나리오에 대한 테스트 코드를 작성하였고, 성능적으로도 괜찮았던 Redis를 활용한 limiting 기능을 프로젝트에 실제 적용할 수는 없었습니다.

해당 프로젝트는 Jetbrains Space Platform 위에서 동작하고 있는데, Jetbrains가 점차 Space의 지원을 중단한다고 발표하였고 이에 따라서 저희 서비스 서버의 인프라적인 확장은 과하다는 리뷰를 받았기 때문입니다.

대안

Redis가 기능을 구현하기에 적합하다고 판단하였지만, 앞 서 언급했던 이슈로 인해

실제로는 mongoDB 를 이용하여 해당 이슈를 해결하였습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private async getResendOrbits(docs: Orbit[]): Promise<Orbit[]> {
    const YESTERDAY = Date.now() - 60 * 60 * 24
    const orbits = docs.map(orbit => {
        const filteredResendErrors = orbit.resendErrors.filter(errorTime => {
            return errorTime.getTime() > YESTERDAY
        })
        return {
            ...orbit,
            resendErrors: filteredResendErrors,
        }
    })

    const promises = orbits.map(async orbit => await OrbitModel.updateOne({ _id: orbit._id }, { resendErrors: orbit.resendErrors }))
    await Promise.all(promises)
    return orbits.filter(orbit => orbit.resendErrors.length <= MESSAGE_RESEND_THRESHOLD)
}

기존 메세지 모델에 에러 일시들을 담는 property를 추가적으로 선언하였습니다.

1
2
3
4
...
@prop()
   public resendErrors: Date[]
...

실제 메세지 전송에 실패하였을 경우, 최근 메세지 전송 현황과 함께 에러 일자들을 기록하는 로직은 아래와 같습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
public async updateStatus(success: boolean): Promise<UpdateResult> {
    const status = success ? 'success' : 'fail'
    if (status === 'fail') {
        return await OrbitModel.updateOne(
            { _id: this._id },
            {
                status,
                resendErrors: [...this.resendErrors, Date.now()],
            },
        ).exec()
    }
    return await OrbitModel.updateOne({ _id: this._id }, { status: status }).exec()
}

결론

Redis를 활용한 방식보다는 역할과 책임의 경계가 모호해진 특성이 있지만, 프로젝트의 현황을 고려하여 기술을 도입시켰습니다.

영속시켜야할 필요가 없는 데이터지만, 현재 프로젝트의 방향에서는 적절한 해답이 아니었나 싶습니다!

This post is licensed under CC BY 4.0 by the author.