



일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 |
- 필사
- gitbash
- 출처: 자바의 신 8장
- 비동기
- PID
- 카우치코딩 #couchcoding #6주포트폴리오 #6주협업프로젝트v
- 플젝후체크
- terminate
- Machine Learning
- 자바파이썬
- Morphological analysis #Corpus
- 마크다운
- Technical Writing
- khaiii
- address
- 파이콘
- 코딩온라인
- 클라이언트사이드렌더링
- #스파르타코딩클럽후기 #내일배움캠프후기
- Anaconda
- SSR
- taskkill
- Kakao
- 서버사이드렌더링
- 모바일웹스킨
- github
- 카우치코딩 #couchcoding #6주포트폴리오 #6주협업프로젝트
- github markdown
- 파이썬
- expression statement is not assignment or call html
- Today
- Total
개발 일기
Redis Pipelining 으로 26만개 데이터 한번에 Insert 하기 본문
문제
2024년 국내 결제 건을 연말 정산 하여, 국세청에 제출한다.
결제팀에서는 보통 매일 정산을 하지만, 연말정산은 한 번에 1년치를 정산하기 때문에 배치의 소요 시간이 매우 길다.
이때 정산 내역을 만드는 배치의 소요 시간이 매우 길었다.
참고로 복잡한 비즈니스 로직을 수행하는 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
현재는 DB 인덱스 칼럼 조회 방식이지만 Disk I/O 보다 빠른 메모리 조회로 변경하였다.
To-Be
- Job 시작 단계에서 전체 가맹점 데이터를 Redis에 미리 적재하는 Step 추가
- 각 결제 건에서는 인메모리에서 바로 조회하도록 구조를 변경
인메모리 스토리지 Redis 를 선택한 이유
1)가맹점 번호가 유일한 식별자여서 Redis key-value 구조와 잘 맞았다.
예시
key : 'merchant:012345'
value:'{"name":"영동시장 반찬가게","businessRegisterNumber":"100100100","type":"market"}'
2)데이터 속성 - 정합성 유지가 용이
가맹점 테이블은 원본 엑셀 데이터를 변환해서 DB 에 insert 하는 것으로, 한 번에 준비되며 이후에 변경되지 않는다. 데이터가 변경됨을 고려해 캐시 무효화 전략을 걱정할 필요가 없다.
3)높은 재사용과 캐시 효과 기대
1차 배치 실행 시 동일 가맹점에서 여러 결제 건이 발생한 경우가 많았다. 이에 캐시 효과도 있을 것으로 판단했다. 하지만, 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 의 동작
레디스에서 명령을 실행하면, 뒷단에서는 아래 과정이 진행된다.
- 자료 구조 접근 (list, set 등 value 로 쓰인 자료 구조를 read, update, or delete)
- 응답 생성
- Socket I/O
이때 1/2번은 비용이 매우 적다. 레디스는 인메모리 스토리지 이므로, 디스크를 오가는 것보다 비용이 적기 때문이다.
하지만 3번은 network 비용, 즉 socket I/O 가 일어나기 때문에 비용이 크다.
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 을 설정한다.
삭제할 때는 키를 찾아서 지워줘야 한다.
Redis의 Keys * 는 모든 키를 한번에 반환해서, 서버 부하가 발생할 수 있어서 운영환경에서 쓰면 안된다. 반면 Scan 은 Cursor 기반으로 지정된 count 만큼 점진적으로 삭제하며, 다음에 사용될 커서를 반환한다.
이때 match 를 써서 패턴 기반으로도 검색 할 수 있다.
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" }
}
결과
제약
운영 환경에서는 약 10만 건의 거래 데이터가 존재했지만, 보안 정책 상 개발 환경에서는 유사한 규모의 데이터를 준비할 수 없었다. 별도의 스테이징 환경도 없어서, 실질적인 성능 확인은 운영 환경에서 배포 후 한 번만 가능했다.
개발 환경 테스트 결과
결제 건당 가맹점 매칭 로직 기준,
V1: MySQL Inde 조건 조회 → 평균 0.03 초
V2: Redis Key 조회 → 평균 0.01 초 (3배의 성능 개선)
운영 환경 결과 및 회고
운영 환경 에서는 성능 개선 효과가 거의 없었다. 실제 결제 건 중 공제 대상 가맹점과 매칭 되는 비율이 낮아서, 캐시 hit 이 적었기 때문이다
따라서 2차로 병렬을 적용하여, 성능을 개선했다. (다음 편: https://writerroom.tistory.com/380 )
출처
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
'Tech > 배워서 남주기' 카테고리의 다른 글
스프링 배치 병렬 처리 가능하도록 변경하기. (0) | 2024.11.16 |
---|---|
Redis Clustering 를 통해 FailOver 구현하기 (도커 컴포즈 환경) (0) | 2023.01.25 |
Insert가 많을 때 소요 시간 개선하기 - (1) myBatis forEach 적용 (0) | 2023.01.16 |
[JPA/MYSQL] 재고에 동시 접근할 때 일어나는 갱신 분실 문제 해결하기 (0) | 2023.01.09 |
협업에 용이한 작업 환경 구성하기 (0) | 2022.11.30 |