Recent Posts
Recent Comments
Link
«   2025/04   »
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
Archives
Today
Total
관리 메뉴

개발 일기

Redis Pipelining 으로 26만개 데이터 한번에 Insert 하기 본문

Tech/배워서 남주기

Redis Pipelining 으로 26만개 데이터 한번에 Insert 하기

flow123 2024. 11. 4. 21:35

문제

2024년 한 해 동안 발생한 결제 건을 연말 정산 하여, 국세청에 제출한다. 

이때 정산 내역을 만드는 배치의 소요 시간이 매우 길었다.  

참고로 복잡한 비즈니스 로직을 수행하는 processor 에서 소모한 시간이 대부분이다. Reader/writer는 I/O 작업 위주라서 영향이 거의 없다.

 

  • Read Time: 20초
  • Process Time: 6시간 10분 
  • Write Time: 4분

긴 배치 시간은 

 

1)지속적인 모니터링으로 작업 효율성을 떨어트리며,

2) 최악의 상황에서는 다른 배치가 실행되지 못하는 위험이 있다 (현재 여러 배치가 한 EC2 인스턴스에서 실행되기 때문) 

로직

1)올해 국내 결제 내역 조회(Reader)

*2-5는 Processor

2)결제 건의 가맹점 번호를 기반으로 공제 타입 확인

3)이미 연말정산 된 거래인지 중복 확인

4)할인,그룹, 거래 타입 분류 등 기타 상세 내역 확인

5)정산에 필요한 정보를 여러 테이블에서 조합

6)저장 (Writer)

 

4가지 Processor 로직 중에서, 2-5번은 여러 테이블을 조합하며 여러 조건이 포함된다. 

이번 블로그에서는 2번 로직을 개선하는 과정을 정리했다.

 


접근

As-Is

공제 가맹점 테이블을 스캔하여, 결제 건 가맹점의 공제 정보 확인 (where card_acceptor_number = ‘’)

공제 가맹점은 전통시장 처럼, 세액 공제에 해당하는 가맹점들을 적재한 테이블이다. 따라서, 결제 건이 매칭되지 않을 가능성도 매우 높다.

 

To-Be

새로운 Step 추가 (Job 맨 앞에 가맹점 테이블 전체를 읽고  Redis 에 저장)

Redis 를 선택한 이유는 빠른 key-value 검색 때문이었다. 가맹점 번호가 유일한 검색 조건이기도 하다. 

가맹점 번호가(=key) "012345"라면, 아래 형태로 객체가 저장될 것이다 (=value) 

{
  "name": "영동시장 반찬가게",
  "businessRegisterNumber": "100100100",
  "type": "market"
}

 

하지만, 26만건을 하나씩 적재하게 되면, 26만 번의 네트워크 왕복이(Round-Trip-Time) 발생하여 속도가 느려졌다 

이를 해결하기 위해 Redis Pipeline 을 적용했고, 3초로 시간을 단축할 수 있었다. 


Redis Pipelining 이란?

improving performance by issuing multiple commands at once without
waiting for the response to each individual command.

각 command 실행 → 응답 대기를 반복하는 대신, 한번에 여러 명령을 실행하여 성능을 향상한다.

 

Redis 는 클라이언트-서버 모델, 즉 요청/응답 프로토콜을 사용하는 TCP 서버이다.

따라서, 클라이언트가 서버에 요청을 보내고, 서버로 부터 응답을 받는데 이때 대개 동기/블로킹 방식으로 작동한다.

(참고로 레디스는 기본적으로 블로킹 I/O 방식을 사용하지만, 비동기/논블로킹 옵션도 제공)

Pipelining 의 장점

RTT 개선 -> 네트워크 비용 감소

 

요청-응답을 할 때 걸리는 시간을 RTT(Round Trip Time) 이라고 한다. 파이프라이닝을 사용할 때, 요청/응답 서버는 클라이언트가 이전 응답을 아직 읽지 않았더라도, 새 요청을 처리할 수 있다. 즉 응답을 아예 받지 않고서도 서버에 여러 명령을 보낼 수 있고, 응답을 한번에 읽을 수 있다

Redis 의 동작

레디스에서 명령을 실행하면, 뒷단에서는 아래 과정이 진행된다.   

  1. 자료 구조 접근 (list, set 등 value 로 쓰인 자료 구조를 read, update, or delete)
  2. 응답 생성
  3. Socket I/O 

이때 1/2번은 비용이 매우 적다. 레디스는 인메모리 스토리지 이므로, 디스크를 오가는 것보다 비용이 적기 때문이다. 

하지만 3번은 network 비용, 즉 socket I/O 가 일어나기 때문에 비용이 크다.

 

실제 소켓 통신이 일어나는 과정 https://helloworld-88.tistory.com/215


Socket I/O 를 이용하면, 시스템이 운영 체제 커널과 소통한다.

네트워크를 통해 데이터를 보내거나 받을 때, 프로세스는 시스템 콜을 보낸다 (read(), write())

이때 user space (프로그램이 실행되는) ←—→ Kernel space (OS 가 low-level 운영하는)

사이의 컨텍스트 스위칭이 발생하면서, System 이 잠시 멈춰야 하기 때문에, 속도 지연이 된다.

 

Pipelining 을 하면, 시스템 콜 한번에, 여러개의 command 가 적용되니 같은 시간동안 많은 쿼리를 보낼 수 있다.


적용

(1) 적재하기 

 

이때, 많은 명령을 한 번에 보내는 것보다는 적절한 수로 나누어 보내야 한다.

클라이언트가 Pipelining 으로 명령을 보낼 때, 서버는 응답을 큐에 넣어야 한다.

따라서 적당한 양으로 나눠서(ex. 10000개) 배치로 보내야 하는 것이다

이렇게 하면 속도는 큰 차이가 없지만, 10000개 만큼의 응답만 queue 하면 되므로, 추가 메모리 소모가 적다.

    @Bean
    @JobScope
    fun preloadMerchantStep(
    ) = stepBuilderFactory["preloadMerchantStep"]
        .tasklet { _, _ ->
            for (i in merchants.indices step redisProperties.pipelineChunkSize) {
                val chunkedMerchants = merchants.subList(i, Math.min(merchants.size, i + redisProperties.pipelineChunkSize))

                redisTemplate.executePipelined { connection ->
                    val stringRedisConn = connection as StringRedisConnection
                    chunkedMerchants.forEach { merchant ->
                        val key = taxReturnTypeService.createCardAcceptorCodeKeyForMerchant(cardAcceptorCode = merchant.cardAcceptorCode)
                        stringRedisConn.setEx(
                            key, redisProperties.timeToLive, objectMapper.writeValueAsString(MerchantDetailDto.from(merchant)))
                    }
                }
                logger.info { "Index ${i + 1} -${i + chunkedMerchants.size} 가맹점이 적재되었습니다" }
            }
            RepeatStatus.FINISHED
        }.build()

 

(2) 삭제하기 

 

해당 Redis 인스턴스는 여러 프로젝트가 공용으로 사용하고 있다. 배치 실행 시에만 쓰이는 데이터이니, 사용 후에는 리소스 최적화를 위해 지워준다.  

 

저장 할 때는, 키를 알고 있기 때문에 setEx 를 호출해서 키와 TTL 을 설정한다. 

적재 시와 달리, 삭제할 때는 키를 찾아서 지워줘야 한다. Scan 명령어를 쓰면, 패턴 기반으로 검색이 가능하다. 

Redis의  Keys * 는 모든 키를 한번에 반환해서, 서버 부하가 발생할 수 있다. 반면 Scan 은 Cursor 기반으로 지정된 count 만큼 점진적으로 삭제한다.  

 

1)do-while 블록에서, cursor가 0 이 될 때까지 반복한다. (시간 복잡도는 O(N). 즉 30만이다) 

2)Redis 커넥션을 통해 Scan 명령어를 실행하고, Cursor 를 반환한다. 

    fun deleteKeysAfterJob() {
        val patterns = listOf("\\${MERCHANT_BY_CARD_ACCEPTOR_CODE_CACHE}_*", "\\${MERCHANT_BY_CARD_CATEGORY_CODE_CACHE}_*")
        var deletedCount = 0
        patterns.forEach { pattern ->
            var cursor: String
            do {
                val scanOptions = ScanOptions.scanOptions().count(redisProperties.pipelineChunkSize.toLong()).match(pattern).build()
                val scanResult: Cursor<ByteArray> = redisTemplate.execute { connection ->
                    val stringRedisConn = connection as StringRedisConnection
                    stringRedisConn.scan(scanOptions)
                } as Cursor<ByteArray>

                val keysToDelete = mutableListOf<String>()

                scanResult.forEachRemaining { keyBytes -> keysToDelete.add(keyBytes.toString(Charsets.UTF_8)) }
                if (keysToDelete.isNotEmpty()) {
                    redisTemplate.executePipelined { connection ->
                        val stringRedisConn = connection as StringRedisConnection
                        keysToDelete.forEach { key ->
                            stringRedisConn.del(key)
                        }
                        null
                    }
                    deletedCount += keysToDelete.size
                }
                cursor = scanResult.cursorId.toString()
                logger.info { "deleted key size: ${keysToDelete.size} " }
                scanResult.close()
            } while (cursor != "0")
        }
        logger.info { "Total keys deleted: $deletedCount" }
    }

결과

  • Redis 적재 소요 시간:결제 건당 가맹점 매칭 로직 시간만 비교했을 때, V1 (0.03 초대) V2 (0.01 초대)

MySQL에서 인덱스 걸린 조건 조회 -> Redis key 로 조회 시 개발 환경에서 3배 개선되었다. 

 

하지만 실제 운영 환경 테스트 시 전체 성능은 크게 개선되지 않았다.

이유는 전체 결제 건들이 주어진 가맹점과 매칭 되는 확률이 적었기 때문이다. (캐시 hit 이 적음)

공제 가맹점이라는 테이블 특성 상, Hit 가 적을 수 밖에 없기에 여기서는 성능 개선의 여지가 적다고 판단했다.

 

따라서 2차로 병렬을 적용하여, 성능을 개선했다. 

 

출처 

https://redis.io/docs/latest/develop/use/pipelining/

https://docs.spring.io/spring-data/redis/reference/redis/pipelining.html

https://toss.tech/article/25301

https://helloworld-88.tistory.com/215

 

Comments