사용자가 늘어날 때 시스템은 어디부터 무너지는가
참고자료
- 알렉스 쉬, 가상 면접 사례로 배우는 대규모 시스템 설계 기초
- AWS - 로드 밸런서란 무엇인가
- AWS - CDN이란 무엇인가
- MDN - HTTP 캐싱
- MySQL 8.0 Reference - Replication
배경
시스템 설계 책을 읽는데 첫 장이 “사용자 한 명에서 백만 명까지”였다. 서버 한 대로 시작해서 단계별로 늘려가는 내용이다.
읽으면서 걸린 것은 각 단계가 왜 그 순서인가였다. 캐시를 먼저 넣을 수도 있고 DB를 먼저 나눌 수도 있는데, 왜 그 순서로 소개하는지가 궁금했다.
정리하면서 확인하고 싶었던 것들이다.
- 서버 한 대에서 처음 무너지는 곳은 어디인가?
- 서버를 늘리기 전에 반드시 해결해야 하는 것은 무엇인가?
- 캐시와 CDN은 같은 문제를 푸는가?
- DB를 나누는 것을 마지막에 하는 이유가 있는가?
단일 서버
가장 단순하게 사용자의 요청을 처리할 수 있는 구조이다.
- 사용자: 웹 브라우저, 모바앨 앱
- 서버: 웹 서버
위 상황에서 요청 흐름은 다음과 같다.
- 사용자는 DNS에 질의하여 서버의 IP를 얻는다.
- 해당 IP 주소로 HTTP 요청이 전달된다.
- 요청 받은 웹 서버는 HTML 혹은 JSON과 같은 응답을 전달한다.
단일 서버 + DB 서버
- 사용자: 웹 브라우저, 모바앨 앱
- 서버: 웹 서버, DB
사용자가 늘어나면 트래픽을 처리하기 위한 용도의 서버와 데이터를 관리할 서버인 데이터베이스가 필요해진다.
이 때 어떤 방식의 데이터베이스를 선택할지도 결정해야한다.
- RDBMS(MySQL, Oracle, PostgreSQL, …)
- NoSQL(MongoDB, Neo4j, DynamoDB, …)
- Key-Value
- Graph
- Column
- Document
NoSQL 방식은 아래와 같은 요구사항이 있을 때 고려해볼만하다. 아래는 PostgreSQL과 MongoDB를 비교한 내용이다.
- 낮은 지연시간이 필요한 경우
- MongoDB
- 메모리 매핑 방식으로 빠른 데이터 접근
- 문서 기반 구조로 복잡한 조인 연산 불필요
- 샤딩을 통한 부하 분산 용이
- PostgreSQL
- 디스크 기반 저장으로 상대적으로 느림
- 트랜잭션과 ACID 보장을 위한 오버헤드 발생
- 복잡한 조인 연산시 지연시간 증가
- MongoDB
- 비정형 데이터를 다루는 상황
- MongoDB
- 스키마리스 특성으로 빠른 데이터 입출력
- 단순 CRUD 작업에 최적화된 성능
- 자동 샤딩과 복제로 확장 용이
- PostgreSQL
- 정규화된 스키마가 오히려 오버헤드 발생
- 불필요한 관계 정의와 제약조건 검사
- 스키마 변경시 마이그레이션 필요
- MongoDB
- 데이터 Serialization/Deserialization만 필요한 경우
- MongoDB
- BSON 형태로 네이티브 데이터 저장/조회
- 스키마 검증 없이 빠른 데이터 처리
- 애플리케이션 레벨에서 데이터 구조 관리 용이
- PostgreSQL
- JSON 타입 지원하지만 성능상 불이익
- 타입 변환과 검증 과정에서 오버헤드
- 트랜잭션 처리를 위한 추가 작업 필요
- MongoDB
- 대용량 데이터 처리시
- MongoDB
- 자동 샤딩과 분산 처리 기본 지원
- 수평적 확장이 간단하고 자동화
- 고가용성을 위한 복제셋 구성 용이
- PostgreSQL
- 단일 서버의 용량 한계
- 샤딩 구현이 복잡하고 관리 어려움
- 데이터 정합성 유지를 위한 오버헤드
- MongoDB
https://blog.teamtreehouse.com/should-you-go-beyond-relational-databases
위 링크는 RDB에서 벗어나야할 상황과 NoSQL의 어떤 종류가 있는지 기술되어있다.
관계형 데이터베이스의 한계 징후
구조적 문제
- 대부분의 열이 특정 행과 관련이 없는 희소 테이블이 빈번하게 발생한다
- 외래키, 속성명, 속성값으로 구성된 속성 테이블을 과도하게 사용하게 된다
- JSON, XML, YAML과 같은 구조화된 데이터를 단일 컬럼에 저장하게 된다
- 다대다 조인 테이블이나 재귀적 외래키를 포함한 복잡한 관계가 많이 발생한다
- 새로운 데이터 타입을 표현하기 위해 스키마 변경이 자주 필요하다
확장성 문제
- 단일 데이터베이스 서버의 쓰기 용량이 한계에 도달한다
- 데이터셋의 크기가 단일 서버의 저장 용량을 초과한다
- 배치 처리나 분석 쿼리로 인해 트랜잭션 성능이 저하된다
비관계형 데이터베이스의 유형
키-값 저장소
- 대표 제품: Redis, DynamoDB
- 적합한 용도: 캐싱, 세션 관리, 사용자 환경설정
- 특징: 낮은 지연시간과 높은 처리량 제공
문서형 데이터베이스
- 대표 제품: MongoDB, Couchbase
- 적합한 용도: 콘텐츠 관리 시스템, 상품 카탈로그, 사용자 프로필
- 특징: 문서 내부 필드 검색과 인덱싱 지원
그래프 데이터베이스
- 대표 제품: Neo4j, Amazon Neptune
- 적합한 용도: 소셜 네트워크, 추천 시스템, 부정 거래 탐지
- 특징: 복잡한 관계 데이터의 효율적인 쿼리 처리
데이터베이스 선택 시 고려사항
데이터 구조 측면
- 행과 열 구조가 적합한 경우 관계형 데이터베이스 선택
- 계층적, 희소, 상호연결된 데이터는 문서형 또는 그래프 데이터베이스 고려
확장성 요구사항
- 대규모 확장이 필요한 경우 Cassandra, DynamoDB 같은 분산 시스템 검토
쿼리 패턴
- 단순 키 기반 조회는 키-값 저장소 활용
- 복잡한 관계 데이터는 그래프 데이터베이스 활용
- 유연한 쿼리가 필요한 경우 문서형 데이터베이스 활용
최신 데이터베이스 트렌드
- 단일 시스템에서 여러 데이터 모델을 지원하는 다중 모델 데이터베이스의 증가
- 인프라 관리가 필요 없는 서버리스 데이터베이스 서비스의 확대
- 인공지능을 활용한 쿼리 최적화 기술의 등장
Scale-Up vs Scale-Out
애플리케이션 서버의 관점
Scale-Up은 서버에 더 좋은 하드웨어 장비를 추가하는 것을 말한다. 단순하게 처리량을 늘릴 수 있는 방법이다.
하지만 무한대로 리소스를 늘릴 수 없다는 점과 장애에 대한 자동복구(Failover), 다중화(re-dundancy)를 고려하지 않기 때문에 아무리 좋은 하드웨어를 장착해도 장애가 나면 서비스가 중단될 수 있다.
서버로 유입되는 트래픽의 양이 적을 때는 이 방식이 좋은 선택일 수도 있다.
Scale-Up의 단점으로 인해 애플리케이션 서버에서는 주로 Scale-Out을 하고자한다. 한 대의 서버가 죽어도 다른 서버가 이를 대체할 수 있기 때문인데, 이처럼 여러대의 서버를 고가용성있게 다루기 위해서 LB가 필요하다.
LB가 각 서버의 앞단에서 트래픽을 받아주고 사설 네트워크 망에 있는 애플리케이션 서버에 트래픽을 전달한다.
데이터베이스 서버의 관점
데이터베이스 또한 Scale-Out 혹은 다중화를 위한 기술에 친화적이다.
여러 데이터베이스 서버를 두고 역할을 나눠 쓰기 작업은 Master, 읽기 작업은 Slave로 수행하도록 하는 것이 그 예시이다.
데이터베이스 다중화의 장점 - 성능 개선
보통 데이터베이스는 쓰기작업보다 읽기작업이 빈번하게 일어난다. 그렇기 때문에 Slave의 노드를 수평적으로 확장시켜 보다 적은 리소스를 가진 컴포넌트만을 늘려 처리량을 늘릴 수 있게 된다.
데이터베이스 다중화의 장점 - 안정성/가용성
데이터베이스 서버가 불용상태로 된다면 지역적으로 떨어뜨려놓은 다른 서버가 있다면 데이터를 보존할 수 있다.
또한 현재 처리중인 서버가 문제가 있다면 다른 서버에 있는 데이터를 가져와 계속 서비스할 수 있게 된다.
캐시
캐시 사용 시 유의할 점
- 쓰기작업은 자주 일어나지 않지만 읽기 작업이 빈번하게 일어나는 경우에 적합하다.
- 영속성이 보장되지 않아도 되는 데이터를 보관해야한다.
- 캐시 만료정책을 수립해야한다.
- 만료기한이 너무 짧으면 데이터베이스를 자주 읽게 되고 너무 길게되면 원본값과 차이가 클 수 있다.
- 원본과의 일관성을 어떻게 지킬 것인지 정해야한다.
- 캐시에 쓰는 트랜잭션과 DB에 쓰는 트랜잭션이 분리되면 일관성이 깨질 수 있다.
- 캐시 서버를 한대만 뒀을 때 장애에 어떻게 대응할 것인지 고려해야한다. (Single Point of Failure)
- 캐시 메모리의 크기를 지정해야한다.
- 메모리가 너무 작으면 잦은 Eviction으로 성능이 떨어지게 될 것이다.
- Overprovision을 하는 것으로 캐시에 보관될 데이터가 갑자기 많아질 때 문제를 방지할 수 있다.
- Eviction 정책을 결정해야한다.
- LRU, LFU, FIFO 등 적절한 사용사례에 맞게 정책을 지정해야한다.
캐싱 방식
Cache-Aside (사이드 캐시)
애플리케이션에서 먼저 캐시를 확인한 후 캐시 히트 시 즉시 데이터를 반환한다. 캐시 미스 시 데이터베이스를 조회하고, 결과를 캐시에 저장한다. 이 과정을 모두 개발자가 직접 구현하는 방식이다.
sequenceDiagram
participant App as 애플리케이션
participant Cache as 캐시
participant DB as 데이터베이스
Note over App,DB: Cache-Aside Pattern
App->>Cache: 1. 데이터 조회
alt 캐시 히트
Cache-->>App: 데이터 반환
else 캐시 미스
Cache-->>App: 캐시 미스
App->>DB: 2. DB 조회
alt DB 조회 성공
DB-->>App: 데이터 반환
App->>Cache: 3. 캐시 업데이트
Cache-->>App: 완료
else DB 조회 실패
DB-->>App: 오류 발생
Note over App: 에러 처리 로직 실행
end
end
alt 캐시 장애 상황
App->>Cache: 데이터 조회
Cache-->>App: 타임아웃/에러
App->>DB: 직접 DB 조회
DB-->>App: 데이터 반환
end
특징
- 읽기가 많은 워크로드에 최적화
- Memcached, Redis 등이 대표적인 구현체
- 개발자가 직접 캐시 제어가 가능하여 세밀한 캐시 정책 구현 가능
- 장애 격리(Fault Isolation) 지원: 캐시 장애 시에도 DB를 통한 시스템 운영 가능
- 캐시와 데이터베이스의 데이터 모델을 독립적으로 구성 가능
- TTL을 통한 데이터 일관성 관리
장점
- 캐시 계층 장애 시에도 시스템 가용성 보장
- 세밀한 캐시 제어 가능
- 캐시와 DB 스키마 독립적 설계 가능
단점
- Cache Stampede 현상 발생 가능 (동시에 많은 캐시 미스 발생 시 DB 과부하)
- 개발자가 직접 캐시 로직을 모두 구현해야 함
- 캐시 일관성 관리를 위한 추가 로직 필요
구현 예제 (Spring)
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
@Service
public class UserService {
private final RedisTemplate redisTemplate;
private final UserRepository userRepository;
public User getUser(String userId) {
// 1. 캐시 확인
String cacheKey = "user:" + userId;
User cachedUser = (User) redisTemplate.opsForValue().get(cacheKey);
if (cachedUser != null) {
return cachedUser;
}
// 2. DB 조회
User user = userRepository.findById(userId)
.orElseThrow(() -> new UserNotFoundException(userId));
// 3. 캐시 업데이트
redisTemplate.opsForValue().set(cacheKey, user, 1, TimeUnit.HOURS);
return user;
}
}
Read-Through Cache (읽기 전용 캐시)
캐시 라이브러리나 제공자를 통해 캐시 미스 시 DB에서 데이터를 로드하고 캐시에 적재하는 과정을 자동으로 처리한다.
sequenceDiagram
participant App as 애플리케이션
participant Cache as 캐시
participant DB as 데이터베이스
Note over App,DB: Read-Through Pattern
App->>Cache: 1. 데이터 요청
alt 캐시 히트
Cache-->>App: 데이터 반환
else 캐시 미스
Cache->>DB: 2. 데이터 로드
alt DB 조회 성공
DB-->>Cache: 3. 데이터 반환
Cache-->>App: 4. 데이터 반환
else DB 조회 실패
DB-->>Cache: 오류 발생
Cache-->>App: 예외 전파
end
end
alt 시스템 장애 상황
Note over Cache: 캐시 장애
Cache-->>App: 서비스 불가 응답
Note over App: 백업 전략 실행
end
특징
- 캐시 로직이 라이브러리나 제공자가 담당
- Cache-Aside에 비해 애플리케이션 코드가 단순화됨
- DB와 동일한 데이터 모델 필요
- 첫 요청은 항상 미스 발생 (캐시 예열 필요)
장점
- 애플리케이션 코드가 캐시 로직으로부터 분리됨
- 캐시 제공자의 최적화된 구현 활용 가능
- 일관된 캐시 정책 적용 가능
단점
- 캐시 라이브러리/제공자에 대한 종속성 발생
- 캐시 커스터마이징의 제한
- 캐시 장애 시 전체 시스템에 영향
구현 예제 (EhCache)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Configuration
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
CacheConfiguration<String, User> configuration = CacheConfiguration
.builder(String.class, User.class)
.withLoaderWriter(new CacheLoaderWriter<String, User>() {
@Override
public User load(String key) throws Exception {
return userRepository.findById(key).orElse(null);
}
@Override
public void write(String key, User value) throws Exception {
userRepository.save(value);
}
})
.build();
return CacheManager.newCacheManager(configuration);
}
}
Write-Through Cache (동기식 쓰기 캐시)
캐시를 먼저 갱신한 후 DB를 업데이트한다. 캐시와 DB의 강력한 일관성을 보장할 수 있다.
sequenceDiagram
participant App as 애플리케이션
participant Cache as 캐시
participant DB as 데이터베이스
Note over App,DB: Write-Through Pattern
App->>Cache: 1. 데이터 쓰기
Cache->>DB: 2. DB 동기 업데이트
alt DB 업데이트 성공
DB-->>Cache: 3. 업데이트 완료
Cache-->>App: 4. 쓰기 완료 응답
else DB 업데이트 실패
DB-->>Cache: 오류 발생
Cache-->>App: 쓰기 실패 응답
Note over Cache: 캐시 데이터 롤백
end
Note over App,DB: 지연 시간 측정
Note right of App: 요청 시작
Note right of Cache: +캐시 쓰기 시간
Note right of DB: +DB 쓰기 시간
Note right of App: 총 지연 시간
특징
- Read-Through와 결합 시 데이터 일관성 보장
- 강력한 데이터 일관성이 필요한 금융, 결제 시스템에 적합
- DynamoDB Accelerator(DAX)가 대표적 사례
- 모든 쓰기 작업에 추가 지연 발생
장점
- 데이터 일관성 보장
- 캐시와 DB의 동기화 보장
- 읽기 성능 최적화
단점
- 쓰기 지연 증가
- 시스템 복잡도 증가
- DB 장애 시 쓰기 작업 불가
구현 예제 (Spring Cache)
1
2
3
4
5
6
7
8
9
10
11
12
@Service
@CacheConfig(cacheNames = "users")
public class UserService {
@CachePut(key = "#user.id")
@Transactional
public User updateUser(User user) {
// DB 업데이트와 캐시 업데이트가 동시에 처리됨
User savedUser = userRepository.save(user);
return savedUser;
}
}
Write-Around (우회 쓰기)
DB에 직접 쓰기 작업을 수행한 후 읽기 요청이 있는 데이터만 캐시에 저장한다. 한 번 쓰고 덜 읽는 데이터에 적합하다.
sequenceDiagram
participant App as 애플리케이션
participant Cache as 캐시
participant DB as 데이터베이스
Note over App,DB: Write-Around Pattern
App->>DB: 1. 직접 데이터 쓰기
alt DB 쓰기 성공
DB-->>App: 2. 쓰기 완료
else DB 쓰기 실패
DB-->>App: 오류 발생
end
Note over App,Cache: 나중에 읽기 요청시
App->>Cache: 3. 데이터 읽기
alt 캐시 히트
Cache-->>App: 캐시된 데이터 반환
else 캐시 미스
Cache->>DB: 4. 캐시 미스시 로드
DB-->>Cache: 5. 데이터 반환
Cache-->>App: 6. 데이터 반환
end
특징
- 실시간 로그 수집, IoT 센서 데이터 수집 등에 적합
- 읽기 시 최초 지연 발생 가능
- 쓰기 성능 최적화
- 캐시 리소스 효율적 사용
장점
- 자주 접근하지 않는 데이터의 캐시 낭비 방지
- 쓰기 지연 최소화
- 캐시 용량 효율적 사용
단점
- 최초 읽기 시 지연 발생
- 데이터 일관성 보장 어려움
- 읽기 성능 저하 가능성
구현 예제 (Redis + JPA)
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
@Service
public class LogService {
public void saveLog(Log log) {
// DB에 직접 저장
logRepository.save(log);
}
public Log getLog(String logId) {
// 캐시 확인
String cacheKey = "log:" + logId;
Log cachedLog = (Log) redisTemplate.opsForValue().get(cacheKey);
if (cachedLog != null) {
return cachedLog;
}
// DB에서 조회 후 캐시에 저장
Log log = logRepository.findById(logId)
.orElseThrow(() -> new LogNotFoundException(logId));
redisTemplate.opsForValue().set(cacheKey, log, 1, TimeUnit.HOURS);
return log;
}
}
Write-Back/Write-Behind (비동기식 쓰기 캐시)
캐시에 데이터를 쓰고 즉시 응답한다. DB에는 비동기적으로 업데이트된 값이 된다.
sequenceDiagram
participant App as 애플리케이션
participant Cache as 캐시
participant DB as 데이터베이스
Note over App,DB: Write-Back Pattern
App->>Cache: 1. 데이터 쓰기
Cache-->>App: 2. 즉시 응답
Note over Cache,DB: 비동기 처리
Cache->>Cache: 3. 데이터 버퍼링
alt 정상 동기화
Cache->>DB: 4. 일정 시간 후 DB 업데이트
DB-->>Cache: 5. 업데이트 완료
else 동기화 실패
Cache->>DB: 4. DB 업데이트 시도
DB-->>Cache: 오류 발생
Note over Cache: 재시도 큐에 저장
end
alt 캐시 장애 복구
Note over Cache: 캐시 재시작
Cache->>DB: 미처리 데이터 동기화
DB-->>Cache: 동기화 완료
end
특징
- 쓰기 성능 향상
- DB 장애에 대한 내구성
- 배치 처리로 DB 부하 감소
- 캐시 장애 시 데이터 손실 위험
- 메모리 사용량 증가
- 주기적인 데이터 동기화 실패 시 복구 전략 필요
장점
- 빠른 쓰기 응답 시간
- DB 부하 분산
- 네트워크 효율성 향상
단점
- 데이터 손실 가능성
- 구현 복잡도 증가
- 메모리 부하 증가
- 데이터 정합성 보장의 어려움
구현 예제 (Spring + Redis)
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
46
47
48
49
50
51
52
53
54
55
56
57
58
@Service
public class WriteBackService {
private final RedisTemplate redisTemplate;
private final ScheduledExecutorService scheduler;
private final UserRepository userRepository;
public WriteBackService(RedisTemplate redisTemplate, UserRepository userRepository) {
this.redisTemplate = redisTemplate;
this.userRepository = userRepository;
this.scheduler = Executors.newScheduledThreadPool(1);
// 주기적 동기화 작업 스케줄링
scheduler.scheduleWithFixedDelay(
this::synchronizeCache,
0,
5,
TimeUnit.MINUTES
);
}
public void updateUser(User user) {
// 캐시에 즉시 저장
String cacheKey = "user:" + user.getId();
redisTemplate.opsForHash().put("pending_updates", cacheKey, user);
redisTemplate.opsForValue().set(cacheKey, user);
}
private void synchronizeCache() {
try {
// pending_updates에서 모든 대기중인 업데이트 조회
Map<String, User> pendingUpdates = redisTemplate.opsForHash()
.entries("pending_updates");
if (pendingUpdates.isEmpty()) {
return;
}
// 배치로 DB 업데이트
List<User> users = new ArrayList<>(pendingUpdates.values());
userRepository.saveAll(users);
// 성공적으로 동기화된 항목 제거
redisTemplate.delete("pending_updates");
} catch (Exception e) {
log.error("캐시 동기화 실패", e);
// 실패 시 재시도 로직 구현
}
}
// 애플리케이션 종료 시 실행
@PreDestroy
public void flushCache() {
synchronizeCache();
scheduler.shutdown();
}
}
캐시 패턴 선택 시 고려사항
1. 데이터 접근 패턴
- 읽기 비중이 높은 경우: Cache-Aside, Read-Through
- 쓰기 비중이 높은 경우: Write-Around, Write-Back
- 읽기/쓰기 비율이 비슷한 경우: Write-Through
2. 데이터 일관성 요구사항
- 강한 일관성 필요: Write-Through
- 최종 일관성 허용: Write-Back, Cache-Aside
- 일관성보다 성능 중요: Write-Around
3. 시스템 복원력
- 캐시 장애 대응 필요: Cache-Aside
- DB 장애 대응 필요: Write-Back
- 높은 가용성 필요: Cache-Aside + Write-Through
4. 성능 요구사항
- 읽기 레이턴시 중요: Cache-Aside, Read-Through
- 쓰기 레이턴시 중요: Write-Back
- 리소스 효율성 중요: Write-Around
일반적인 캐시 운영 이슈와 해결 방안
1. Cache Stampede
동시에 많은 캐시 미스가 발생하여 DB에 과부하가 발생하는 현상
해결방안:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Service
public class StampedePreventionService {
private final LoadingCache<String, User> cache;
public StampedePreventionService() {
cache = Caffeine.newBuilder()
.maximumSize(10_000)
.refreshAfterWrite(1, TimeUnit.HOURS)
// 동시 요청 방지를 위한 세마포어 적용
.build(key -> {
Thread.sleep(250); // 인위적인 지연
return userRepository.findById(key)
.orElseThrow(() -> new UserNotFoundException(key));
});
}
}
2. 캐시 데이터 일관성
캐시와 DB 간의 데이터 불일치 발생
해결방안:
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
@Service
public class ConsistencyService {
public void updateUserWithConsistency(User user) {
// 낙관적 락을 이용한 버전 관리
String cacheKey = "user:" + user.getId();
redisTemplate.execute(new SessionCallback<>() {
@Override
public Object execute(RedisOperations operations) {
operations.watch(cacheKey);
User cachedUser = (User) operations.opsForValue().get(cacheKey);
if (cachedUser != null && cachedUser.getVersion() > user.getVersion()) {
throw new OptimisticLockException();
}
operations.multi();
operations.opsForValue().set(cacheKey, user);
return operations.exec();
}
});
userRepository.save(user);
}
}
3. 캐시 예열
시스템 시작 시 캐시가 비어있는 콜드 스타트 문제
해결방안:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Component
public class CacheWarmer implements ApplicationRunner {
@Override
public void run(ApplicationArguments args) {
// 자주 접근되는 데이터 미리 로드
List<User> frequentUsers = userRepository
.findTop1000ByOrderByAccessCountDesc();
for (User user : frequentUsers) {
String cacheKey = "user:" + user.getId();
redisTemplate.opsForValue().set(cacheKey, user);
}
}
}
모니터링 및 운영 지표
주요 모니터링 지표
- 캐시 히트율 (Cache Hit Ratio)
- 캐시 미스율 (Cache Miss Ratio)
- 평균 응답 시간 (Average Response Time)
- 캐시 메모리 사용량
- 캐시 제거율 (Eviction Rate)
모니터링 구현 예제
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
@Aspect
@Component
public class CacheMonitoringAspect {
private final MeterRegistry meterRegistry;
@Around("@annotation(Cacheable)")
public Object monitorCache(ProceedingJoinPoint joinPoint) throws Throwable {
Timer.Sample sample = Timer.start(meterRegistry);
try {
Object result = joinPoint.proceed();
if (result != null) {
meterRegistry.counter("cache.hits").increment();
} else {
meterRegistry.counter("cache.misses").increment();
}
sample.stop(meterRegistry.timer("cache.request.latency"));
return result;
} catch (Exception e) {
meterRegistry.counter("cache.errors").increment();
throw e;
}
}
}
결론
각 캐싱 패턴은 시스템의 요구사항과 특성에 맞는 패턴을 선택하는 것이 중요하다. 특히 다음 사항들을 고려해야 한다.
- 데이터 일관성 vs 성능
- 시스템 복잡도
- 운영 및 유지보수 용이성
- 장애 대응 방안
- 모니터링 및 운영 전략
실제 구현 시에는 하나의 패턴만을 사용하기보다는 여러 패턴을 조합하여 사용하는 것이 일반적이며, 시스템의 요구사항 변화에 따라 유연하게 패턴을 조정할 수 있어야 한다.
CDN
정적 컨텐츠를 지리적으로 분산된 서버에 캐시할 수 있는 시스템이다.
동적 컨텐츠는 요청 경로, 쿼리 스트링, 쿠키, 요청 헤더 등에 기반하여 HTML 페이징을 캐싱하는 방법이지만 CDN은 일관된 정적 컨텐츠를 다룬다.
사용자가 웹 사이트를 방문하면 해당 사용자에 가장 가까운 리전에 있는 서버에서 정적 파일을 제공한다.
CDN역시 캐시의 일종으로 볼 수 있어 적절한 만료시간과 CDN 장애에 대한 대처방안이 중요하다.
적절한 만료시간을 지정해서 컨텐츠의 신선도를 관리하고, CDN 불용상태 시 직접 서버로부터 컨텐츠를 가져갈 수 있는 조치가 필요하다.
Stateless 웹 계층
상태 정보를 웹 계층에서 제거해야 수평적으로 확장이 가능하다. 상태를 DB와 같은 영속성 저장소에 보관하도록 하여 무상태 웹 계층을 구성할 수 있다.
정리하며
처음 던진 질문들에 대한 답이다.
서버 한 대에서 처음 무너지는 곳. DB다. 애플리케이션 서버는 CPU와 메모리가 부족해지면 사양을 올리거나 대수를 늘리면 되지만, DB는 상태를 갖고 있어서 그렇게 못 한다.
그래서 첫 단계가 웹 서버와 DB 서버를 분리하는 것이다. 이유는 두 가지다. 서로 다른 자원을 쓰기 때문에 한 대에 두면 서로를 방해하고, 각각을 다른 방식으로 확장해야 하기 때문이다.
서버를 늘리기 전에 반드시 해결할 것. 상태를 웹 계층에서 빼는 것이다.
세션을 서버 메모리에 두면 사용자가 다른 서버로 가는 순간 로그인이 풀린다. 로드밸런서에서 같은 서버로 보내는 방식(sticky session)으로 우회할 수 있지만, 그 서버가 죽으면 그쪽 사용자들만 세션을 잃고, 서버마다 부하가 고르지 않게 된다.
세션을 외부 저장소로 빼면 어느 서버로 가든 같은 결과가 나온다. 그러면 서버를 마음대로 늘리고 줄일 수 있다. 이게 무상태 웹 계층이고, 나머지 확장의 전제 조건이다.
캐시와 CDN이 같은 문제를 푸는가. 둘 다 “같은 것을 반복해서 만들지 않는다”는 원리지만 대상이 다르다.
캐시는 계산 결과를 아낀다. DB 조회나 연산의 결과를 메모리에 두고 재사용한다. 줄이는 것은 DB 부하다.
CDN은 거리를 줄인다. 이미지나 스크립트 같은 정적 파일을 사용자 가까운 곳에 둔다. 줄이는 것은 지연 시간과 원본 서버의 대역폭이다.
둘 다 무효화가 진짜 문제라는 점은 같다. 언제 버릴지를 정하지 않으면 낡은 것을 계속 내보낸다.
DB를 나누는 것을 마지막에 하는 이유. 되돌리기가 가장 어렵기 때문이다.
앞 단계들은 되돌릴 수 있다. 캐시는 빼면 되고, 서버는 줄이면 되고, CDN은 끄면 된다. 샤딩은 데이터가 흩어진 뒤에는 되돌리기가 거의 불가능하다.
그리고 잃는 것이 크다. 샤드를 넘나드는 조인이 안 되고, 트랜잭션도 한 샤드 안에서만 성립한다. 샤드 키를 잘못 고르면 특정 샤드에만 부하가 몰리는데, 그때는 키를 바꾸는 것이 곧 전체 재배치다.
그래서 읽기 복제본, 캐시, 수직 분할처럼 되돌릴 수 있는 방법을 다 써보고 나서 마지막에 간다. 책의 순서는 효과 순이 아니라 되돌릴 수 있는 순서였다.
정리하고 나서 남은 감각은 각 단계가 문제를 없애는 것이 아니라 옮긴다는 것이었다. 서버를 늘리면 세션 문제가 생기고, 캐시를 넣으면 무효화 문제가 생기고, DB를 나누면 조인 문제가 생긴다. 그래서 지금 아픈 곳이 어디인지 확인하지 않고 단계를 앞서가면, 안 겪어도 될 문제를 미리 들여오게 된다.