Home 부하 조절-Polling
Post
Cancel

부하 조절-Polling

1. 글을 작성하게 된 계기


선착순 쿠폰 이벤트에서 사용자 요청이 한 번에 들어올 때, 이를 효율적으로 관리/처리하는 방법에 대해 학습하며 작성한 글입니다. 학습 과정에서 작성한 글이기 때문에 잘못된 내용이 있을 수 있으며, 이에 대한 지적/피드백은 언제든 환영합니다.








2. 아이디어


쿠폰 선착순 이벤트의 전체 플로우는 다음과 같습니다.

  1. X시 00분에 쿠폰 발급 이벤트 시작.
  2. 사용자 요청이 오면 애플리케이션 내부 큐에 저장
  3. 일정 크기의 데이터를 일정 시간마다 큐에서 풀링
  4. 풀링한 데이터를 레디스에 저장
  5. 10분 후 당첨자 발표.





여기서 핵심 아이디어는 풀링(Polling)인데, 이에 대해 조금 더 상세히 살펴보겠습니다.

Polling, or interrogation, refers to actively sampling the status of an external device by a client program as a synchronous activity. Polling is most often used in terms of input/output (I/O), and is also referred to as polled I/O or software-driven I/O. A good example of hardware implementation is a watchdog timer.







사용자 요청이 올 때, 각 요청을 매번 레디스에 전송하게 되면 많은 부하가 발생하게 됩니다. 예를 들어, 초당 만 명의 사용자가 동시에 요청을 보낼 때, 모든 요청을 레디스에 전송하면 초당 만 번의 요청이 레디스에 가게 됩니다.

image







부하를 줄이기 위해서는 전송하는 데이터를 일정 크기로 나눈 뒤, 일정 주기로 전송해야 합니다. 아래와 같이요. 이렇게 되면 사용자 요청이 아무리 많이 오더라도 일정량의 데이터를 일정 주기로 전송하기 때문에 레디스가 받는 부하를 줄일 수 있습니다.

image








이를 코드 레벨에서 보면 다음과 같은데, Producer는 이벤트를 큐에 넣고, Consumer는 이를 사용합니다.

1
2
3
4
5
6
7
8
9
@Service
class EventJoinService(
    private val eventManager: EventManager<CouponEvent>
) : EventJoinUseCase {

    override fun joinEvent(event: CouponEvent) {
        eventManager.add(event)
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Component
class EventScheduler(
    private val eventManager: EventManager<CouponEvent>,
    private val redisService: RedisService
) {

    private val log = logger()

    @Scheduled(fixedRate = QUARTER_OF_SECONDS)
    fun pollEvents() {
        val events = eventManager.poll()
        log.info("events:{}", events.size)

        if (events.isEmpty()) {
            return
        }

        try {
            redisService.saveAll(events)
        } catch (ex: Exception) {
            eventManager.addDeadLetter(events)
        }
    }

    companion object {
        private const val QUARTER_OF_SECONDS = 250L
        private const val TWO_SECONDS = 2_000L
    }
}








데이터를 일정 크기로 레디스로 보내는 것은 반복문과 청크(chunk) 개수를 조절해 구현할 수 있으며, 일정 주기로 데이터를 풀링하는 것은 @Scheduled을 사용하면 됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Component
class EventManager<T> {
    private val events = ConcurrentLinkedQueue<T>()
    private val deadLetters = ConcurrentLinkedQueue<T>()

    fun add(event: T) {
        events.add(event)
    }

    fun addDeadLetter(event: List<T>) {
        deadLetters.addAll(event)
    }

    fun poll(): List<T> {
        val events = mutableListOf<T>()
        var count = 5000
        while (count > 0) {
            count--
            val event = this.events.poll()
            if (event != null) {
                events.add(event)
                continue
            }
            return events
        }
        return events
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Component
class EventScheduler {

    ......

    @Scheduled(fixedRate = QUARTER_OF_SECONDS)
    fun pollEvents() {
        val events = eventManager.poll()
        log.info("events:{}", events.size)

        if (events.isEmpty()) {
            return
        }
    }

    ......

}








3. 고려할 점


해당 방법을 사용할 때, 메모리 관리, 메시지 유실, 풀링 주기와 같은 고려 사항이 존재하는데, 이에 대해 살펴보겠습니다.

  1. 메모리 관리
  2. 메시지 유실
  3. 풀링 주기







3-1. 메모리

레디스에 데이터를 저장하기 때문에 하나의 데이터가 얼마만큼의 크기를 가지는지 살펴야 합니다. 아래 명령어를 통해 각 데이터의 크기를 알 수 있는데, 이보다는 모니터링 툴을 사용해 현재 메모리 사용량을 주기적으로 체크해 주는 것이 좋습니다.

1
2
127.0.0.1:6379> MEMORY USAGE coupon_event::string::userId::9156341696544095464
(integer) 1104







3-2. 메시지 유실

메시지 큐를 사용하다 보면 이벤트가 유실되는 경우가 발생할 수 있습니다. 즉, 레디스에 이벤트가 저장되지 않는 경우도 있는데, 이를 별도로 보관한 후 처리해 줘야 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Component
class EventScheduler(
    private val eventManager: EventManager<CouponEvent>,
    private val redisService: RedisService
) {

    private val log = logger()

    @Scheduled(fixedRate = QUARTER_OF_SECONDS)
    fun pollEvents() {
        val events = eventManager.poll()
        log.info("events:{}", events.size)

        if (events.isEmpty()) {
            return
        }

        try {
            redisService.saveAll(events)
        } catch (ex: Exception) {
            // 이벤트가 유실됐을 경우 대비
            eventManager.addDeadLetter(events)
        }
    }

    ......

}








이번 포스팅에서는 애플리케이션 내부에 이벤트를 저장했기 때문에 유실된 메시지도 간략히 애플리케이션 내부에 저장해두었습니다. 만약 중요한 데이터일 경우, 반드시 외부 저장소에 이를 보관해야 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
@Component
class EventManager<T> {

    private val events = ConcurrentLinkedQueue<T>()
    private val deadLetters = ConcurrentLinkedQueue<T>()

    fun add(event: T) {
        events.add(event)
    }

    fun addDeadLetter(event: List<T>) {
        deadLetters.addAll(event)
    }

    fun poll(): List<T> {
        val events = mutableListOf<T>()
        var count = 1000
        while (count > 0) {
            count--
            val event = this.events.poll()
            if (event != null) {
                events.add(event)
                continue
            }
            return events
        }
        return events
    }

    fun pollDeadLetters(): List<T> {
        val events = mutableListOf<T>()
        var count = 1000
        while (count > 0) {
            count--
            val event = this.deadLetters.poll()
            if (event != null) {
                events.add(event)
                continue
            }
            return events
        }
        return events
    }
}








3-3. 풀링 주기

스케줄러를 사용해 풀링을 하게 되면, 이 주기를 신경 써야 합니다. 주기가 너무 짧을 경우, 애플리케이션이 매번 이를 체크하며, 너무 길 경우, 사용자 요청이 많을 때 병목 점이 될 수 있기 때문입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
@Component
class EventScheduler(
    private val eventManager: EventManager<CouponEvent>,
    private val redisService: RedisService
) {

    private val log = logger()

    @Scheduled(fixedRate = QUARTER_OF_SECONDS)
    fun pollEvents() {
        val events = eventManager.poll()
        log.info("events:{}", events.size)

        if (events.isEmpty()) {
            return
        }

        try {
            redisService.saveAll(events)
        } catch (ex: Exception) {
            eventManager.addDeadLetter(events)
        }
    }

    @Scheduled(fixedRate = TWO_SECONDS)
    fun pollDeadLetters() {
        val deadLetters = eventManager.pollDeadLetters()
        log.info("deadLetters:{}", deadLetters.size)

        if (deadLetters.isEmpty()) {
            return
        }

        try {
            redisService.saveAll(deadLetters)
        } catch (ex: Exception) {
            eventManager.addDeadLetter(deadLetters)
        }
    }

    companion object {
        private const val QUARTER_OF_SECONDS = 250L
        private const val TWO_SECONDS = 2_000L
    }
}








4. 성능


테스트 정보는 다음과 같은데, 로그인과 비즈니스 로직을 제외하고 가볍게 테스트했습니다.

  • AWS: t3.small
  • Docker





가상 사용자를 1,000, 1,500, 2,000명으로 테스트했는데, 핫스팟을 사용하다보니 2,000명 이후에는 더 이상 테스트가 불가능했습니다. 데이터를 다 써버려서요. 여튼 최소한의 자원으로 생각보다 성능이 잘 나오는 것을 볼 수 있습니다.

image

image

image








5. 한계


여기에는 데이터 유실에 대한 문제가 존재합니다. 사용자 요청을 애플리케이션 메모리에 저장하기 때문에 서버가 다운되면 모든 정보가 날아가기 때문입니다. 예를 들어, 사용자 요청을 레디스에 전송하기 직전 서버가 다운됐다고 가정해 보겠습니다.

  1. 이벤트 풀링
  2. 풀링한 정보를 레디스에 저장하기 직전 상태
  3. 서버가 다운







이를 코드 레벨에서 보면 다음과 같은데요, 2번 이후가 코드가 실행되지 않기 때문에 모든 데이터가 유실되는 것입니다. 사용자는 정상적으로 요청을 했는데도 말이죠.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Component
class EventScheduler(
    private val eventManager: EventManager<CouponEvent>,
    private val redisService: RedisService
) {

    private val log = logger()

    @Scheduled(fixedRate = QUARTER_OF_SECONDS)
    fun pollEvents() {
        // 1. 풀링 완료
        val events = eventManager.poll()
        log.info("events:{}", events.size)

        if (events.isEmpty()) {
            return
        }

        // 2. 데이터가 레디스에 저장되기 직전 서버 다운
        redisService.saveAll(events)
    }

    companion object {
        private const val QUARTER_OF_SECONDS = 250L
    }
}







이를 위해서는 사용자 요청을 외부에 저장하거나 실패한 사용자 요청을 기록할 수 있는 다른 수단이 있어야 합니다. 레디스 서버가 다운됐을 경우에도요. 해당 아키텍처를 사용할 경우, 데이터 유실에 대해 인지하고 대비할 수 있도록 합니다.

In message queueing a dead letter queue (DLQ) is a service implementation to store messages that the messaging system cannot or should not deliver. Although implementation-specific, messages can be routed to the DLQ for the following reasons.







6. 정리


사용자 요청이 한 번에 들어왔을 때, 어떻게 대처해야 하는지에 대해 살펴보았습니다. 풀링을 사용하면 구현은 간단하지만 데이터 유실이 발생할 수 있다는 점을 항상 인지하고 있어야 합니다. 다음 번에는 이벤트 유실을 고려한 설계에 대해서도 살펴보도록 하겠습니다.

  1. 사용자 요청을 애플리케이션 내부 큐에 저장
  2. 일정 주기로 큐에서 데이터 풀링
  3. 레디스에 저장

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