🧩 Redis 코어 아키텍처와 기초
캐시가 필요한 이유
- 자주 쓰는 데이터를 조회하기 위해 모든 데이터를 보관하는 DB를 매번 조회하면 접근 시간이 오래 걸림
- 단일 DB에 초과 트래픽이 발생하면 디스크 I/O의 한계로 인해 서버가 다운됨
Redis가 그래서 뭔데?
- 파이썬의
dict()나 자바의HashMap처럼 Key-Value 쌍으로 이루어진 데이터 구조 - In-Memory가 빠른 이유: RAM(메모리)은 순수하게 논리회로를 통해 전자의 이동만으로 데이터를 읽고 쓰므로 ns 단위의 접근 속도를 가지지만, 디스크의 경우 아무리 빨라도 μs 단위임
왜 싱글 스레드를 쓴대?
- 여러 스레드가 동시에 데이터에 접근하면 컨텍스트 스위칭 오버헤드와 락 경합 문제가 발생해서 데이터가 꼬일 수 있음
- Redis는 메모리 연산 자체가 빠르기 때문에 락 걸고 기다린 바에는 혼자서 빨리 처리하는 싱글 스레드 방식을 채택힘
캐싱 전략 시나리오
- Cache-Aside (Look-Aside)
- Redis에서 먼저 찾고 없으면 DB에서 가져와 캐시에 저장하는 대중적인 Read 전략
- Write-Back
- 모든 데이터를 Redis에 몰아서 저장하고 주기적으로 모아서 DB에 한 번에 쓰는(Batch) 전략
- 장애 발생 시 데이터 유실이 발생할 수 있음
- 캐시 무효화: 수동 무효화 + TTL 또는 Write-Through 활용
- TTL: 변동성이 낮은 데이터는 24시간~1주일, 변동이 잦은 데이터는 1~5초로 설정
- 캐시 스탬피드(Cache Stampede): 캐시 TTL을 일괄적으로 설정했을 때, 캐시가 한꺼번에 사라지면서 수 만명의 요청이 동시에 Cache Miss를 발생, 그대로 DB가 다운되는 현상. 막기 위해서는 기본 TTL 값에 1~5분 사이의 랜덤한 난수(Jitter)를 설정해야 함
Redis에서 사용하는 자료구조
- String: 단순 텍스트 또는 카운터 저장
- List: Queue 용도로 사용
- Set: 순서X, 중복X (※
SMEMBERS로 한번에 가져오는 대신SSCAN으로 쪼개서 가져오는 게 안전함) - Sorted Set (ZSET): Set에 Score를 추가해 자동으로 정렬하는 집합 (※ Score는 Double 타입으로 저장됨)
- Hash: 하나의 Key안에 여러개의 Field와 Value를 넣을 수 있는 구조
🧩 MSA 분산 환경의 동시성 제어와 비동기 통신
동시성 문제와 락
- 한정판 상품 재고가 1개 뿐인 상황에서 동시 결제 요청이 100건 발생하면? → N명에게 결제 완료가 승인되는 Over-sell이 발생함
- 분산 락(Distributed Lock): Redis에 설정된 열쇠를 가진 사람만 접근 가능
SENTX방식의 문제점: 서버 A에서 락이 걸린 상태에서 서버가 죽은 경우 키가 Redis에 영원히 남게 되어 다른 모든 서버들이 데드락에 걸림- 이러한 문제를 해결하기 위해 락 획득과 만료시간 설정을 원자적으로 처리하는
SET key value NX EX명령어가 도입됨
- TTL의 딜레마
- 너무 짧게 잡으면 락 시간보다 로직 수행 시간이 더 오래 걸려 락을 건 의미가 없어질 수 있음 (Mutual Exclusion 발생)
- 락을 너무 오래 잡으면 오류가 발생했을 때 그동안 다른 서버들은 아무것도 못하고 계속 실패함
- 이 문제를 해결하기 위해 Redisson의 Watchdog으로 해결 가능
💡 TTL이 3초고 결제 로직 수행이 5초면 서버 A와 B가 동시에 결제하는 문제 발생 가능
락의 value에 고유 UUID를 넣어 락을 지울 때 검증하는 로직이 필요함
동시성 제어 기술 비교
| 비교 항목 | DB 비관적 락 | DB 낙관적 락 | Redis 기본 분산 락 | Redisson (RLock) |
| 원리 | SELECT FOR UPDATE Version | 컬럼 확인 | 싱글 스레드 Spinlock | Pub/Sub 기반 이벤트 대기 |
| 성능 (처리량) | 최하 | 상 (충돌X) | 중~상 | 최상 |
| 데드락 위험 | 상 | X | 하 (TTL 방어 필요) | 하 (Watchdog 내장) |
| 구현 난이도 | 하 | 중 | 최상 | 하 |
| 대표 시나리오 | 금융 코어 | 회원정보 수정 | 가벼운 동시성 제어 | 선착순 쿠폰, 타임세일 |
- 일반적으로 재고 영역에 대해서는 낙관적 락을 사용하지만, 쿠폰 등의 도메인에서는 비관적 락을 사용함
- 물론 어떤 방법이든지 기술적인 부채가 있기 때문에 한가지 기술만으로 해결하려고 하면 안 됨
예시: 한정 수략 선착순 쿠폰 발급
- 직접 구현한 Redis 분산 락
- 락을 얻을 때까지 무한 루프를 도는 Spinlock: Thread.sleep 없이 돌면 Redis에 초당 수만 번 요청을 때려 Redis CPU가 터짐
- 락의 TTL(3초)보다 비즈니스 로직이 길어지면 락이 풀려버림
- try-if문 로직이 트랜잭션으로 묶여 있으면 락이 해제된 이후에 DB 커밋이 발생해 동시성이 깨질 수 있음
- Exception이 터져도 락이 풀리도록 반드시 finally 안에서 해제해야 함
- 남의 락을 내가 풀 수도 있기 때문에 Lua 스크립트로 value 검증이 필요함
public void issueCouponDirect(String userId, String couponId) {
String lockKey = "lock:coupon:" + couponId;
String uuid = UUID.randomUUID().toString();
while (!redisTemplate.opsForValue().setIfAbsent(lockKey, uuid, Duration.ofSeconds(3))) {
try { Thread.sleep(50); } catch (Exception e) {}
}
try {
if (couponRepository.countById(couponId) > 0) {
couponRepository.issue(userId, couponId);
}
} finally {
if (uuid.equals(redisTemplate.opsForValue().get(lockKey))) {
redisTemplate.delete(lockKey);
}
}
}
- Redisson을 활용한 분산 락
- Redisson은 위에서 언급된 문제점을 알아서 처리하기 때문에 많이들 사용함
public void issueCouponRedisson(String userId, String couponId) {
String lockKey = "lock:coupon:" + couponId;
RLock lock = redissonClient.getLock(lockKey);
try {
// 💡 [사용법 1] Watchdog이 동작하는 방식 (기본 권장)
// 매개변수: (최대 대기 시간, 시간 단위)
// 10초 동안 락 획득을 대기. 락 획득 시 작업이 끝날 때까지
// Redisson의 Watchdog이 기본 30초마다 만료 시간을 자동 연장함
boolean isLocked = lock.tryLock(10, TimeUnit.SECONDS);
/*
// 💡 [사용법 2] Watchdog이 동작하지 않는 방식 (주의 요망!)
// 매개변수: (최대 대기 시간, 임대 시간(leaseTime), 시간 단위)
// 10초 대기 후 락을 획득하면, 비즈니스 로직이 끝나든 말든 무조건 3초 뒤에 락이 해제됨
// 작업이 3초를 넘어가면 상호 배제가 깨질 수 있으므로, 장애 시 강제 해제용으로만 써야함
boolean isLocked = lock.tryLock(10, 3, TimeUnit.SECONDS);
*/
if (!isLocked) {
throw new RuntimeException("대기열 초과. 잠시 후 다시 시도해주세요.");
}
// 안전한 비즈니스 로직 수행
if (couponRepository.countById(couponId) > 0) {
couponRepository.issue(userId, couponId);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
// 현재 스레드가 락을 쥐고 있는지 자체 검증 후 안전하게 해제
if (lock.isLocked() && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
[분산 락 (Distributed Lock)]
여러 대의 서버(프로세스)가 공유 자원에 동시에 접근할 때 데이터의 정합성을 유지하기 위해 오직 하나의 프로세스만 작업할 수 있도록 제한하는 잠금 메커니즘
▪ Race Condition 해결: 한 번에 단 하나의 프로세스만 임계 영역에 진입하도록 보장함
▪ 동시성 이슈 해결: 여러 노드가 동시에 같은 DB를 수정하는 상황 방지
▪ Redis를 활용해서 락 획득 대기 관리 가능
▪ 주의점: 락 획득 타임아웃, 락 해제 보장(데드락 방지) 처리 중요
[Lettuce]
▪ SETNX, SETEX를 사용한 스핀 락 방식: 락을 획득할 때까지 Redis에 반복적으로 요청을 보냄
▪ 락을 얻을 때까지 계속 요청을 보내므로 CPU 자원을 많이 소모함
▪ 락을 가진 서버가 다운되면 Redis에 키가 유지되어 데드락 상태에 빠질 수 있음
▪ 타임아웃, 재시도, 락 해제 로직을 직접 구현해야 함
▪ 매우 간단한 작업 또는 저트래픽 환경 (예: 배치 작업 중복 실행 방지, API 중복 요청 차단 등)
[Redisson]
▪ Redis Pub/Sub을 사용하는 구조: 락이 해제되면 대기 중인 클라이언트에게 알림을 보냄
▪ 알림을 받을 때만 재시도를 하므로 Redis 서버 부담이 줄어듦
▪ 락 획득 대기 시간, 만료 시간 설정 및 자동 연장(Watchdog) 기능을 제공함
▪ 일반적인 기업형 서비스, 고트래픽 환경 (예: 선착순 이벤트 및 티켓팅, 주문/결제 시스템 등)
▪ 예: 마켓컬리에서도 Redisson을 사용함
[RedLock]
▪ 다중화 구조: 독립된 5대 이상의 Redis 노드에 순차적으로 락을 요청함
▪ 락 획득 한 번에 여러 대의 Redis 노드와 통신해야 하므로 서버 부하가 높음
▪ 과반수 이상의 노드가 살아 있으면 락의 안정성이 보장됨
▪ 여러 개의 독립된 Redis 클러스터를 관리해야 하므로 인프라가 복잡해짐
▪ 금융, 결제, 자산 관리 등 데이터 오류가 절대 용납되지 않는 환경
서비스 간 통신: Redis Pub/Sub vs Message Queue
- Redis Pub/Sub: Publisher가 채널에 메시지를 보내면, 연결된 Subscriber만 메시지를 받을 수 있음
- 배포를 위해 1분간 연결이 끊긴다면 그 1분동안 발송된 메시지는 영원히 사라짐 (Fire-and-Forget)
- 결제 완료 알림 발송 서비스에서 Redis Pub/Sub를 단독으로 쓰게 되면 유실 시 추적이 불가능해짐
- Message Queue (Kafka, RabbitMQ, Redis Stream): 메시지를 안전하게 보관하고 수신 확인(ACK)을 받아야만 삭제함
- 여기서는 Kafka를 메시지를 저장하는 디스크 정도로만 이해하면 됨
| 비교 항목 | Redis Pub/Sub | Kafka |
| 메시지 보관 여부 | X (즉시 휘발) | O (디스크 또는 메모리에 보관) |
| 수신 확인 (Ack) | X | O (Consumer가 Ack를 보내야 지움) |
| 처리보장 수준 | 낮음 (유실 가능성 큼) | 매우 높음 (At lease once) |
| 성능 속도 | 최상 | 상 |
| 운영 복잡도 | 최하 | 최상 |
| 사용 예시 | 실시간 채팅, 주식 호가 갱신 | 결제 완료 이벤트, 절대 유실되면 안 되는 데이터 스트림 |
우리 팀은 카프카를 어떻게 사용하고 있을까 | 우아한형제들 기술블로그
누가 읽으면 좋을까? 카프카(Kafka)가 무엇인지 알고 있는 독자를 대상으로 합니다. 기술적 구현방식을 다루기보단 카프카를 기반으로 한 다양한 기술적 개념에 대해서 얇고 넓게 소개하고, 우리
techblog.woowahan.com
실무 사용 예시
- 배민 창고 물류 동시성 제어
- Problem: 재고 할당과 취소가 동시 발생 시 실제 재고와 DB가 불일치됨. DB 락 사용 시 전체 시스템 마비 위험
- Solution: 가벼운 Redis 분산 락(
SETNX) 적용
- 토스 외화 예금 입출금
- Problem: 0.1초만에 여러 결제/환전 요청이 동일 계좌로 쏟아짐 → 초과 출금 발생
- Solution: Redis 분산 락(Redisson) + MySQL DB 락
- 올리브영 선착순 쿠폰
- 기존: Redis Pub/Sub + (Kafka 대신) Redis List 사용 (Pub/Sub은 비동기 Worker 트리거 용도로만 사용)
- 문제: 검증 지연 시간으로 인한 Time Gap 발생 및 이중 검증 오버헤드 발생
- 해결: 발급 요청 수량 체크 용도의 별도 Redis Key 추가 관리 (이중 카운터)
올영세일 선착순 쿠폰, 미발급 0%를 향한 여정 | 올리브영 테크블로그
Redis와 Message Queue로 구축한 비동기 시스템의 정합성 개선기
oliveyoung.tech
분산 락의 함정
- 분산 락이 항상 정답은 아님
- 락은 획득-작업-해제의 네트워크 왕복이 3번이나 발생하지만, 단순한 카운팅이나 재고 차감이라면 락 없이 Redis의 원자적 명령어(
INCR,DECR)를 사용하는 것이 훨씬 빠름 - 복잡한 로직도 Lua 스크립트를 쓰면 싱글 스레드 환경에서 원자적으로 실행되어 락 없이 제어가 가능함
🧩 운영과 장애 방지를 위한 인프라 생존 가이드
운영 환경에서 절대 사용하면 안되는 명령어들
KEYS *: 전체 키 스캔FLUSHALL,FLUSHDB: 전체 데이터 삭제SAVE: 동기식 스냅샷, 블로킹DEBUG SLEEP: 강제 일시정지
💡 운영 환경에서는 조금씩 나눠서 스캔하는 SCAN 명령어를 사용해야 한다.
메모리 절벽
- 메모리 모니터링의 핵심 지표
| 지표 | 의미 | 위험 신호 |
| used_memory | Redis가 실제 사용 중인 메모리 | maxmemory의 80% 초과 시 경고 |
| used_memory_rss | OS가 보는 실제 점유 메모리 (RSS) | used_memory와 큰 차이 시 단편화 의심 |
| mem_fragmentation_ratio | RSS / used_memory | 1.5 이상 시 단편화 심각 |
| swap | 디스크로 밀려난 메모리 | 0 이상이면 즉시 조치 |
💡 used_memory는 실제 사용 데이터만 보여주지만, RSS는 파편화된 빈 공간까지 포함한 실제 점유 메모리를 나타낸다.
- OS가 Redis의 일부 메모리를 디스크로 옮기는 순간(Swap) 해당 데이터에 접근할 때 디스크 I/O가 발생하며 응답시간이 100배 이상 느려짐
- Redis는 메모리 사용량이 높아서 OOM Killer의 주요 타겟이 될 수 있어 주의해야 함
Redis의 영속성 전략: RDB와 AOF
- RDB: 특정 시점의 메모리를 스냅샷 형태로 저장해 빠른 복구가 가능하지만 스냅샷 사이의 데이터는 유실 가능
- AOF: 모든 쓰기 명령을 순서대로 로그에 기록해 데이터 유실 최소화
- appendfsync 옵션
# redis.conf
# 옵션 1: 매 명령마다 fsync — 가장 안전하지만 가장 느림
appendfsync always
# 옵션 2: 1초마다 fsync — 균형 잡힌 기본값 (권장)
appendfsync everysec
# 옵션 3: fsync를 OS에 위임 — 빠르지만 위험
appendfsync no
- 실무에서는 RDB + AOF 혼합 모드를 권장함
aof-use-rdb-preamble yes: 하나의 파일에 두가지 형식을 이어 붙여 RDB 스냅샷은 0.1초만에 일괄 로딩, 남은 짧은 구간만 AOF 로그로 순차 진행
Sentinel
- Master Redis가 죽었을 때 Replica 중 하나를 자동으로 새 Master로 승격시키는 도구
- 일부 자료에서 사용된
SLAVEOF는 deprecated되었고REPLICA가 표준임 - Sentinel은 3~5대의 홀수 구성을 권장함 (1대만 있으면 그 Sentinel이 잘못 판단해도 제동을 걸 수단이 없음)
- 단계: 한 Sentinel이 Master에게 Ping을 보냈을 때 응답이 없으면, 다른 Sentinel들의 합의 하에 Master가 죽은 것으로 합의, 한 명의 리더를 선출해 그 리더가 새로운 Master를 선출함
🧩 Redis 심화 및 최적화 기법
Redis 내부 인코딩
Redis는 메모리 효율을 위해 같은 자료구조더라도 데이터 크기에 따라 내부 인코딩을 자동으로 바꾼다.
| 자료구조 | 작은 데이터 | 큰 데이터 | 전환 기준 |
| String | embstr (≤44바이트) | raw / int | 44바이트 초과 / 숫자 여부 |
| List | listpack (구 ziplist) | quicklist | 자동 |
| Hash | listpack | hashtable | hash-max-listpack-entries |
| Set | intset (숫자만) | hashtable | set-max-intset-entries |
| Sorted Set | listpack | skiplist + hashtable | zset-max-listpack-entries |
Skiplist
[자료구조] Skiplist란?
이번 학기에 자료구조론을 재수강하면서 열심히 이론을 익히고 있는데, 문득 이대로 흘려보내기엔 아쉽다는 생각이 들었다. 그래서 이미 후반부에 접어들었지만 자료구조론에서 배운 내용을
velog.io
- O(log N) 검색을 보장하는 자료구조로, Sorted Set이 큰 데이터에서 사용한다.
- Sorted Set이 빠른 이유는 Skiplist의 다층 구조 덕분이다.
| 연산 시간 | 복잡도 | 비고 |
| ZADD | O(log N) | 스킵리스트에 삽입 |
| ZRANGE | O(log N + M) | M은 반환 개수 |
| ZRANK | O(log N) | 순위 조회 |
| ZSCORE | O(1) | 해시 테이블 활용 |
Ziplist에서 Skiplist로의 전환 배경
- Ziplist의 문제점: 각 항목이 이전 항목의 길이를 저장 → 이전 항목의 길이가 변하면 다음 항목도 연쇄적으로 메모리 재할당이 발생하는 Cascade Update 문제 발생
- Listpack의 해결: 자신의 길이를 자신의 데이터 끝에 저장해서 변경 사항이 격리됨. 추가로 Ziplist에서 발생한 보안 취약점도 해결함
Lua 스크립트
- Lua: C/C++ 프로그램 내부에 임베드하기 위해 가볍게 제작된 스크립트 언어
- Lua 스크립트는 그 자체로 하나의 명령어처럼 취급되어(EVAL) 스크립트가 끝날 때까지 다른 명령어가 끼어들지 않아(싱글 스레드 블로킹) 원자성이 자동으로 보장됨
- Fuctions는 라이브러리 단위 관리와 영속성이 있는 저장이 장점이지만, 라이브러리 지원 및 호환성 측면에서 여전히 EVAL 기반 Lua 스크립트가 표준임
- Lua 스크립트 예시
-- KEYS[1]: 쿠폰 재고 Key, KEYS[2]: 유저 잔액 Key
-- ARGV[1]: 쿠폰 가격
local stock = tonumber(redis.call('get', KEYS[1]))
local funds = tonumber(redis.call('get', KEYS[2]))
local price = tonumber(ARGV[1])
if stock and stock > 0 and funds and funds >= price then
redis.call('decr', KEYS[1])
redis.call('decrby', KEYS[2], price)
return 1
else
return 0
end
- Spring Boot에서 Lua 사용하는 예시
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
script.setScriptText("... 위의 Lua 코드 ...");
script.setResultType(Long.class);
Long result = redisTemplate.execute(
script,
Arrays.asList("coupon:stock", "user:1000:balance"), // KEYS
"5000" // ARGV
);
- 잘못 작성한 Lua 스크립트(무한 루프, 무한 재귀 등)로 인해 Redis가 멈춰버리면, 스크립트가 한번이라도 데이터를 수정한 경우
SCRIPT KILL가 작동하지 않아 Redis를 강제 종료해야 함 - 이 문제를 사전에 방지하려면
lua-time-limit설정을 하고(기본 5초), 외부 입력으로 루프 조건을 만들면 안 됨
실전 최적화 기법
- Pipeline: 네트워크 왕복 최소화
- Pipeline을 사용하면 속도 측면에서 수십 배 이상 향상되지만, 트랜잭션이 아니기 때문에 명령어 사이에 다른 클라이언트의 명령어가 끼어들 수 있음
- Pipeline과 MULTI/EXEC의 차이점
- Pipeline은 명령어 묶음을 한번에 전송에 네트워크 RTT를 줄이는 것이 목적. 명령어 사이에 다른 명령어가 끼어들 수 있음
- MULTI/EXEC은 명령어들을 큐에 모은 후 EXEC 시 한꺼번에 실행해 원자성이 보장됨. 다만 실행 중 에러 발생 시 롤백되지 않음
# 일반 방식
Client → SET k1 v1 → Redis → OK → Client (1 RTT)
Client → SET k2 v2 → Redis → OK → Client (1 RTT)
Client → SET k3 v3 → Redis → OK → Client (1 RTT)
= 3 RTT
# Pipeline 방식
Client → {SET k1 v1, SET k2 v2, SET k3 v3} → Redis
Client ← {OK, OK, OK}
= 1 RTT
- Big Key: 단일 키의 데이터가 매우 크거나 항목 수가 매우 많은 키
- 위험성: 해당 키 삭제/조회 시 다른 명령어가 모두 대기, Cluster 환경에서 특정 노드만 과부하되므로 메모리 분산 불균형, 네트워크 대역폭 점유
- 진단법:
redis-cli --bigkeys으로 스캔 또는redis-cli MEMORY USAGE key:name으로 특정 키 메모리 사용량 측정 - 해결 방법
- 샤딩: 큰 Hash를 여러 작은 Hash로 분할 (예:
user:1→user:1:profile,user:1:posts) - UNLINK: 동기 삭제(블로킹)
DEL대신 비동기 백드라운드 삭제UNLINK사용 - TTL 분산: Jitter 추가로 Big Key들이 동시 만료되는 것을 방지
- 샤딩: 큰 Hash를 여러 작은 Hash로 분할 (예:
- Hot Key: 특정 키에만 트래픽이 집중되는 현상
- 해결 방법
- 로컬 캐시 도입: 애플리케이션 메모리에 1차 캐시
- 키 복제: 같은 데이터를 여러 키로 분산
- 읽기 전용 Replica로 트래픽 분산
- 해결 방법
실무 최적화 사례
- 메모리 효율 개선
- Problem: 유저당 Hash 1개 → 1억 명의 유저 설정 저장으로 메모리 200GB 소요
- Solution: 1000명 단위로 묶어 Hash 1개로 샤딩 +
hash-max-listpack-entries1024로 튜닝 → 80GB로 절감 - 원리: 작은 Hash는 listpack 인코딩을 사용해 압축적으로 저장됨
- 선착순 이벤트 QPS 향상
- Problem: WATCH/MULTI/EXEC 기반 → 1000 QPS 정도에서 재시도 폭발
- Solution: Lua 스크립트로 전환 후 50000 QPS를 안정적으로 처리
- 원리: WATCh는 낙관적 락이라 동시 요청이 많으면 재시도 폭발함. Lua 스크립트는 원자적이라 재시도 필요 없음
- 랭킹 보드 정밀도
- Problem: Sorted Set의 Score에
Long.MAX_VALUE를 넣었더니 순위가 뒤죽박죽이 됨 - Cause: Sorted Set의 Score는 double형이라 53비트까지만 정수를 정확히 표현할 수 있음. 반면 Long은 64비트 정수임
- Solution: Custom Epoch를 도입해 현재 연도 기준 밀리초로 Score 크기를 줄임
- Problem: Sorted Set의 Score에
'내일배움캠프' 카테고리의 다른 글
| [내일배움캠프] 이벤트 소싱과 CQRS, RabbitMQ와 Kafka (0) | 2026.05.13 |
|---|---|
| [내일배움캠프] 대규모 시스템에서의 DB 최적화와 분산 트랜잭션 (0) | 2026.05.12 |
| [내일배움캠프] Redis 실습 (0) | 2026.05.11 |
| [내일배움캠프] Spring Boot에 캐싱 적용하기 (0) | 2026.05.10 |
| [내일배움캠프] Redis 응용 (0) | 2026.05.08 |