해당 챕터는 사용자 수에 따른 규모 확장성을 이루기위한 방법을 소개한다.
들어가며
어느 날 갑자기 서비스의 사용자가 100배로 늘어난다면?
대규모 시스템 설계는 단순히 "성능 좋은 서버를 구축"하는 문제가 아니다. 사용자가 늘어남에 따라 발생하는 병목 지점을 파악하고, 비용과 복잡도 사이의 균형을 맞추며 시스템을 발전시켜 나가는 과정이다.
본 포스팅에서는 "가상 면접 사례로 배우는 대규모 시스템 설계 기초" 1장의 내용을 바탕으로, 단일 서버에서 시작해 수백만 사용자를 감당할 수 있는 전역적 서비스로 나아가기 위한 핵심 아키텍처 원리들을 정리한다.
웹 계층의 무상태(Stateless)화
서버 확장의 첫걸음은 웹 계층을 무상태로 만드는 것이다.
서버를 한 대에서 두 대, 세 대... 이렇게 수평적으로 확장(Scale-out)할 때 가장 먼저 마주치는 벽은 바로 상태(State) 다. 서버가 사용자의 로그인 정보나 장바구니 데이터를 메모리에 들고 있는 순간, 확장은 고통이 된다.
상태 유지 시 벌어지는 일
점심시간, 갑자기 트래픽이 몰려 서버 1대에 과부하가 걸렸다고 가정해보자. 급하게 서버 2를 추가했지만, 정작 기존 사용자들은 서버 1에 저장된 세션 정보 때문에 서버2를 이용하지 못한다. (이를 해결하기 위해 Sticky Session 을 사용하기도 하지만, 이는 특정 서버에 부하를 몰리게 하는 또 다른 원인이 된다.)
더 큰 문제는 장애 발생이다. 서버 1이 갑자기 다운되면, 해당 서버에 접속해 있던 모든 사용자의 세션이 증발한다. 사용자들은 강제 로그아웃을 경험하게 되고, 이는 곧 서비스 신뢰도 하락으로 이어진다.
어떻게 무상태로 만들까?
핵심은 상태를 서버 밖으로 밀어내는 것 이다. 세션 데이터나 상태 정보는 웹 서버의 메모리가 아닌, 공유 저장소(Redis, NoSQL, DB 등)에 저장한다.
// Spring Boot 환경에서 외부 저장소(Redis)를 이용한 세션 관리 예시
@Configuration
@EnableRedisHttpSession // 이 어노테이션 하나로 상태를 서버 밖(Redis)으로 분리한다.
public class SessionConfig {
@Bean
public LettuceConnectionFactory connectionFactory() {
return new LettuceConnectionFactory();
}
}
/* 이렇게 설정하면 서버 1에서 로그인해도
상태는 Redis에 저장되므로 서버 2, 3에서도 즉시 인지할 수 있다.
*/
트레이드 오프
- 장점:
- 무한 확장: 어떤 서버로 요청이 가도 상관없으므로 로드밸런서가 자유롭게 트래픽을 분산할 수 있다.
- 장애 격리: 특정 서버가 죽어도 사용자는 다른 서버를 통해 서비스를 계속 이용할 수 있다.
- 무중단 배포: 서버를 하나씩 껐다 켜면서 배포해도 사용자는 불편을 느끼지 않는다.
- 단점
- 네트워크 레이턴시: 매번 외부 저장소(Redis 등)를 조회해야 하므로 미세한 속도 저하가 발생한다.
- 의존성 증가: Redis 서버 자체가 죽으면 전체 서비스의 인증 시스템이 마비될 수 있다. (따라서 Redis도 다중화가 필요하다.)
필자의 짧은 생각
현재 회사는 트래픽이 폭발적으로 발생하는 환경은 아니다. 따라서 "규모 확장성"에 대한 절실함이 상대적으로 낮을 수 있다. 그럼에도 불구하고 우리가 무상태 계층과 로드밸런서를 고집하는 이유는 무엇일까?
내가 느낀 가장 큰 효용은 '배포의 심리적 안정감' 이다. 트래픽 분산도 중요하지만, 무상태 계층이 구축되어 있으면 대낮에 운영 서버를 배포해도 사용자 세션이 끊길 걱정을 하지 않아도 된다. "확장성"은 단순히 사용자 수의 대응을 넘어, 개발자가 언제든 시스템을 주무를 수 있는 유연함 을 의미한다는 것을 다시금 깨달았다.
모든 계층의 다중화
시스템의 가용성을 결정짓는 것은 "가장 약한 고리"다.
DB 서버가 한대뿐이라면?
주말에 서비스의 메인 DB 서버에 하드웨어 결함이 발생했다고 가정해보자. DB 서버가 단 한 대뿐이라면 어떻게 될까?
- 전체 서비스 마비: API 서버가 살아있어도 데이터를 읽거나 쓸 수 없으므로 사실상 서비스 불능 상태가 된다.
- 복구 지연: 새로운 서버를 사고, OS를 깔고, 백업 데이터를 복구하는 동안 서비스는 계속 멈춰 있다.
- 손실 가중: 복구하는 수 시간 동안 발생하는 매출 손실과 사용자 신뢰도 하락은 복구 비용보다 훨씬 뼈아프게 다가온다.
어떻게 다중화를 도입해야 할까?
가용성(Availability)을 99.9%에서 99.99%로 올리기 위해서는 각 계층별로 정교한 다중화 전략이 필요하다.
- 웹 계층 (WAS): 로드밸런서 뒤에 최소 2대 이상의 서버를 배치한다. 트래픽에 따라 서버를 자동으로 증설하는 Auto Scaling 을 설정하여 유연하게 대응한다.
- 데이터베이스 계층: Master-Slave(주-부) 구조를 도입한다. Master는 쓰기(Write)를, Slave는 읽기(Read)를 담당하며, Master 장애 시 Slave 중 하나가 새로운 Master가 되는 Failover(자동 전환) 환경을 구축한다.
- 캐시 계층 (Redis): 단일 노드가 아닌 Redis Cluster 나 Sentinel 구성을 통해 최소 3대 이상의 노드를 운영하며 데이터의 가용성을 확보한다.
- 로드밸런서 자체: 로드밸런서가 SPOF가 되지 않도록, L4 스위치 등을 Active-Standby 구조로 이중화하여 관리한다.
트레이드 오프
다중화는 공짜가 아니며, 확실한 '보험료'를 지불해야 한다.
- 비용 증가: 서버 대수가 최소 2배 이상 늘어나므로 인프라 비용이 정비례하여 증가한다.
- 관리 복잡도: 서버 간의 데이터 동기화(Replication) 문제, 장애 발생 시 자동으로 전환되는 메커니즘을 관리하기 위한 운영 인력이 필요하다. 하지만 서비스 중단으로 인한 기회비용이 인프라 비용보다 훨씬 크기 때문에, 일정 규모 이상의 서비스라면 선택이 아닌 필수다.
필자의 짧은 생각
실무에서 다중화가 잘 되어 있는 시스템을 운영한다는 것은 "밤에 잠을 잘 수 있다" 는 것을 의미한다. 서버 한 대가 죽어도 시스템이 스스로 알아서 복구(Self-healing)하고 사용자는 아무런 불편을 느끼지 않는 환경은 엔지니어에게 엄청난 심리적 안정감을 준다.
또한, 기술 면접이나 이직 시에도 "서버를 여러 대 띄웠다"는 사실보다 "장애 상황에서 트래픽이 어떻게 우회되는지, 데이터 정합성은 어떻게 유지했는지" 를 설명할 수 있는 능력이 엔지니어의 몸값을 결정짓는 핵심 지표가 되기도 한다. 현재 회사의 인프라가 단일 서버라면, "만약 여기서 서버 한 대가 죽으면 어떻게 될까?"라는 질문부터 시작해 다중화 계획을 세워보는 것이 좋은 공부가 될 것같다.
가능한 많은 데이터를 캐시할 것
데이터베이스는 최후의 보루다. 캐시는 그 보루를 지키는 가장 강력한 방어선이다.
데이터를 캐싱하지 않으면 벌어지는 일
초당 1,000건의 조회가 발생하는 상품 상세 페이지를 상상해 보자. 캐시가 없다면 1,000번의 요청이 고스란히 DB로 향한다.
- DB 커넥션 고갈: DB가 처리할 수 있는 연결 수를 초과하여 신규 요청들이 대기 상태에 빠진다.
- 도미노 현상: 응답 시간이 길어지면 타임아웃 에러가 증가하고, 결국 전체 서비스 서버의 자원까지 고갈시켜 서비스가 마비된다.
어떻게 캐시를 활용할까?
조회가 잦고 수정이 적은 데이터부터 캐시 계층(Redis 등)으로 옮긴다.
// 1. Spring Cache를 이용한 간단한 추상화
@Cacheable(value = "products", key = "#productId")
public Product getProduct(Long productId) {
return productRepository.findById(productId);
}
// 2. 전략적 TTL(Time To Live) 설정
// 상품 정보: 1시간 (변경이 적음)
// 재고 정보: 10초 (실시간성이 중요)
- 읽기 전략(Look-Aside): 캐시에 데이터가 있으면 반환하고, 없으면 DB에서 가져와 캐시에 저장한다.
- 쓰기 전략(Write-Through): 데이터 변경 시 DB와 캐시를 동시에 업데이트하여 정합성을 유지한다.
트레이드 오프
- 장점: DB 부하를 80~90% 이상 줄일 수 있으며, 응답 속도가 획기적으로 개선된다. (ms 단위)
- 단점: 데이터 불일치(Inconsistency) 이슈가 발생할 수 있다. 캐시 무효화(Invalidation) 로직이 복잡해지며, Redis 운영 비용이 추가된다.
필자의 짧은 생각
캐시는 가성비가 가장 좋은 튜닝 도구다. 비싼 DB 사양을 올리는(Scale-up) 것보다 Redis 한 대를 앞에 두는 것이 훨씬 저렴하고 효과적일 때가 많다. 다만, '정합성'이라는 괴물을 잘 다뤄야 한다. 캐시 데이터가 실제 DB와 달라 서비스에 치명적인 영향을 주지 않는지 항상 경계해야 한다.
정적 콘텐츠는 CDN을 통해 서비스할 것
WAS(웹 서버)는 비즈니스 로직 처리에만 집중해야 한다.
CDN을 쓰지 않으면 벌어지는 일
1MB 크기의 상품 이미지를 초당 100명이 조회한다고 가정하자. 캐시가 없다면 WAS는 비즈니스 로직을 처리하는 와중에 100MB/s의 네트워크 트래픽을 이미지 전송에 소모해야 한다.
- 자원 낭비: WAS가 이미지 전송에 매달리느라 실제 중요한 API 응답이 늦어진다.
- 해외 사용자 경험 저하: 한국 서버에 있는 이미지를 미국 사용자가 내려받으려면 수 초가 소요되어 사용자 이탈로 이어진다.
어떻게 도입할까?
정적 콘텐츠(이미지, CSS, JS, 동영상)를 사용자와 지리적으로 가까운 엣지 서버(Edge Server)에서 제공하도록 설정한다.
<img src="https://api.myapp.com/images/logo.png" />
<img src="https://cdn.myapp.com/images/logo.png" />
사용자가 이미지를 요청하면 CDN 업체(Cloudfront, Akamai 등)가 자신의 캐시 서버에서 데이터를 즉시 응답하고, 없을 때만 우리 서버(Origin)에 물어본다.
트레이드 오프
- 장점: WAS의 대역폭 비용과 부하를 획기적으로 낮춘다. 전 세계 어디서든 빠른 로딩 속도를 보장한다.
- 단점: CDN 사용료가 발생한다. 이미지가 변경되었을 때 CDN 캐시를 강제로 비워주는(Purge/Invalidation) 과정이 필요하다.
필자의 짧은 생각
중소규모 서비스에서 가장 빠르게 사용자 경험을 개선하는 방법 중 하나가 CDN이다. 특히 프론트엔드 정적 파일들을 CDN에 올리는 것만으로도 메인 서버의 부담이 눈에 띄게 줄어든다. "왜 우리 사이트는 느릴까?"라는 고민이 들 때 가장 먼저 검토해 볼 만한 선택지다.
데이터 계층의 규모 확장: 샤딩(Sharding)
한 대의 DB가 모든 데이터를 감당할 수 없는 순간, 쪼개야 산다.
샤딩이 필요한 임계점
사용자 테이블이 1억 건을 넘어가면 인덱스 크기가 서버 메모리를 초과하기 시작한다.
- 성능 급락: 쿼리마다 디스크 I/O가 폭증하며 성능이 저하된다.
- 쓰기 병목: Master DB 한 대가 모든 INSERT 요청을 감당하지 못해 처리 한계(TPS)에 도달한다.
어떻게 구현할까?
데이터를 나누는 기준인 샤딩 키(Sharding Key) 를 정하고 여러 DB 서버에 분산 저장한다.
public User getUser(Long userId) {
// 유저 ID를 기준으로 4개의 샤드 중 하나를 결정
int shardIndex = (int)(userId % 4);
DataSource targetShard = shardDataSources.get(shardIndex);
// 결정된 샤드 DB에서 데이터 조회
return jdbcTemplate.query(targetShard, "SELECT ...");
}
트레이드 오프
- 장점: 데이터 저장 용량과 처리 성능을 이론상 무한대로 확장할 수 있다.
- 단점: 여러 샤드에 걸친 조인(Join) 쿼리가 불가능해지며, 트랜잭션 관리가 매우 어려워진다. 샤드를 다시 나누는(Resharding) 작업은 엄청난 운영 비용을 초래한다.
필자의 짧은 생각
샤딩은 시스템 설계의 '최종 단계'에 가깝다. 웬만한 규모에서는 DB 튜닝, 캐싱, 읽기 복제(Read Replica)만으로 충분히 버틸 수 있다. 주니어 개발자인 필자는 샤딩을 직접 구축할 기회는 흔치 않겠지만, 기술의 한계를 이해하고 "왜 이 복잡한 기술을 최대한 늦게 도입해야 하는지"를 설명할 수 있는 능력이 더 중요하다고 생각한다.
각 계층은 독립적인 서비스로 분할할 것 (MSA로의 여정)
거대한 하나보다 작고 단단한 여러 개가 낫다.
모놀리식(Monolithic)의 위험성
추천 알고리즘의 사소한 버그 때문에 결제 시스템이 멈춘다면? 이것이 모든 기능이 하나로 뭉쳐진 모놀리식 구조의 가장 큰 위험이다.
- 장애 전파: 특정 기능의 메모리 누수가 전체 서버를 다운시킨다.
- 배포 병목: 사소한 코드 수정에도 전체 시스템을 재빌드하고 배포해야 하므로 배포 주기가 길어진다.
어떻게 분리할까?
비즈니스 도메인을 기준으로 서비스를 나누고(DDD), 서비스 간에는 API나 메시지 큐(Kafka)를 통해 통신한다.
- 데이터 분리: 서비스 분리 시 데이터베이스도 함께 분리하여 의존성을 완전히 제거한다. (User DB, Order DB 등)
트레이드 오프
- 장점: 팀별 독립 배포가 가능해져 개발 속도가 빨라진다. 서비스별로 최적화된 기술 스택(Java, Python 등)을 선택할 수 있다.
필자의 짧은 생각
무작정 MSA를 쫓는 것은 위험하다. 팀 규모가 작을 때는 오히려 모놀리식이 생산성이 높다. 하지만 서비스가 커지면서 팀 간의 협업이 배포의 병목이 되는 순간이 온다. 그때가 바로 "왜 서비스를 나누어야 하는가?"에 대한 답을 내놓고 구조를 개선해야 할 시점이다.
모니터링과 자동화: 시스템의 눈과 손
측정할 수 없으면 관리할 수 없고, 자동화하지 않으면 확장할 수 없다.
눈과 손이 없을 때의 상황
사용자가 "사이트 안 돼요"라고 말하기 전까지 장애를 모르는 상황, 상상만 해도 끔찍하다.
- 원인 분석 지연: 로그 서버가 없어 각 서버에 접속해 일일이 로그를 뒤지느라 복구 골든타임을 놓친다.
- 휴먼 에러: 사람이 새벽에 20대 서버에 수동으로 파일을 복사하다 한 대를 빠뜨리는 실수가 발생한다.
어떻게 구축할까?
- 모니터링: Prometheus + Grafana로 지표를 수집하고, ELK 스택으로 로그를 중앙 관리한다. 에러율이 높아지면 Slack 알림이 오도록 설정한다.
- 자동화: CI/CD 파이프라인(GitHub Actions, Jenkins)을 통해 테스트와 배포를 자동화한다. 인프라도 코드(Terraform)로 관리하여 버튼 하나로 서버를 늘린다.
트레이드 오프
- 장점: 장애 감지 및 복구 시간이 획기적으로 줄어든다. 반복 작업이 사라져 엔지니어가 더 가치 있는 일에 집중할 수 있다.
- 단점: 모니터링 도구와 파이프라인 구축에 적지 않은 시간과 학습 비용이 든다.
필자의 짧은 생각
모니터링과 자동화는 '엔지니어의 삶의 질' 과 직결된다. 새벽에 걸려 오는 전화를 줄여주고, 배포 버튼을 누를 때 손을 떨지 않게 해준다. 시스템 규모 확장성만큼이나 중요한 것은 '운영의 확장성'이다. 사람이 일일이 개입해야 하는 시스템은 결코 대규모가 될 수 없다.