여러분은 혹시 “와, 이 시스템 정말 복잡하고 대단해 보인다!”라고 감탄한 적이 있나요? 사실 그런 반응이 나올수록 그 시스템은 좋지 않은 설계일 가능성이 높습니다. 진정한 좋은 시스템 설계는 눈에 띄지 않고, 오랫동안 별다른 문제없이 조용히 작동하는 것입니다.
좋은 시스템 설계란 무엇인가
눈에 띄지 않는 것이 최고의 설계
시스템 설계의 세계에서 가장 역설적인 진실은 좋은 설계일수록 평범해 보인다는 것입니다. 개발자들이 “생각보다 쉽게 끝났네”, “이 부분은 신경 쓸 필요가 없어”라고 말할 때, 그것이 바로 훌륭한 설계의 증거입니다.
반면 분산 합의 메커니즘, 다양한 이벤트 기반 통신, CQRS 등 복잡한 기술들로 가득한 시스템을 보면 의심부터 해야 합니다. 근본적인 잘못된 결정을 보완하려다 보니 복잡해진 것일 수도 있고, 아니면 단순히 과도한 설계일 가능성이 높습니다.
단순함에서 복잡함으로의 진화
복잡한 시스템이 필요한 경우도 분명 있습니다. 하지만 성공하는 복잡한 시스템은 항상 단순한 시스템에서 진화한 것입니다. 처음부터 복잡한 시스템을 구축하려는 시도는 거의 실패로 끝납니다.
상태 관리: 시스템 설계의 핵심 과제
상태가 없는 것이 가장 안전하다
소프트웨어 설계에서 가장 어려운 부분이 바로 상태(state) 관리입니다. GitHub의 PDF 렌더링 서비스처럼 정보를 저장하지 않고 즉시 결과를 반환하는 무상태 서비스는 영원히 안전하게 작동할 수 있습니다. 컨테이너가 문제가 생기면 자동으로 재시작되어 원래 상태로 복구되기 때문입니다.
반면 데이터베이스에 쓰기를 수행하는 상태 저장 서비스는 완전히 다른 이야기입니다. 잘못된 데이터가 들어가면 수동으로 수정해야 하고, 저장 공간이 부족하면 데이터를 정리하거나 확장해야 합니다.
상태 저장 컴포넌트 최소화 전략
실무에서는 하나의 서비스만 상태를 관리하고, 나머지 서비스들은 API 호출이나 이벤트 발생 같은 무상태 역할에 집중하는 구조가 이상적입니다. 다섯 개의 서비스가 모두 같은 테이블에 쓰기를 하는 대신, 네 개의 서비스가 첫 번째 서비스에 요청을 보내고, 실제 쓰기 로직은 한 곳에만 두는 것이죠.
데이터베이스: 상태가 살아가는 집
사람이 읽기 쉬운 스키마 설계
데이터베이스 스키마는 유연해야 하지만, 너무 유연하면 안 됩니다. 모든 것을 JSON 컬럼에 저장하거나 키-값 테이블로 임의의 데이터를 추적하는 방식은 애플리케이션 코드에 복잡성을 전가하고 성능 문제를 야기합니다.
좋은 스키마는 데이터베이스를 보기만 해도 애플리케이션이 무엇을 저장하고 왜 저장하는지 대략적으로 파악할 수 있어야 합니다.
인덱스: 성능의 열쇠
테이블이 몇 개 행 이상 가질 것으로 예상된다면 반드시 인덱스를 설정해야 합니다. 가장 자주 사용되는 쿼리에 맞춰 인덱스를 만드세요. 예를 들어 이메일과 타입으로 쿼리한다면, 이 두 필드에 인덱스를 생성하는 것입니다.
인덱스는 중첩된 딕셔너리처럼 작동하므로 카디널리티가 높은 필드를 먼저 배치해야 합니다. 모든 것에 인덱스를 거는 것은 쓰기 오버헤드만 증가시킬 뿐입니다.
성능 병목 해결하기
데이터베이스가 일을 하게 하라
복잡한 애플리케이션에서는 요청당 수백 개의 데이터베이스 호출이 필요할 수 있습니다. 이때 데이터베이스에서 작업을 처리하는 것이 애플리케이션에서 처리하는 것보다 거의 항상 효율적입니다.
여러 테이블의 데이터가 필요하다면 별도 쿼리로 나누어 메모리에서 연결하는 대신 JOIN을 사용하세요. 특히 ORM을 사용할 때는 루프 안에서 쿼리를 실행하지 않도록 주의해야 합니다.
읽기 복제본 활용
일반적인 데이터베이스 설정은 하나의 쓰기 노드와 여러 개의 읽기 복제본으로 구성됩니다. 읽기 쿼리를 최대한 복제본으로 보내면 쓰기 노드의 부하를 줄일 수 있습니다. 복제 지연을 견딜 수 없는 경우가 아니라면 이 전략을 적극 활용하세요.
빠른 작업과 느린 작업의 분리
사용자 대면 작업은 빨라야 한다
사용자가 직접 상호작용하는 작업은 수백 밀리초 내에 응답해야 합니다. 하지만 모든 작업이 그럴 필요는 없습니다. 대용량 PDF 변환 같은 시간이 오래 걸리는 작업은 사용자에게 최소한의 유용한 정보만 즉시 제공하고, 나머지는 백그라운드에서 처리하는 패턴이 효과적입니다.
백그라운드 작업 시스템
백그라운드 작업은 일반적으로 큐(Redis 등)와 작업 실행기가 결합된 형태로 동작합니다. 작업명과 매개변수를 큐에 넣으면, 작업 실행기가 이를 가져와서 실행하는 방식입니다.
원거리 예약 작업의 경우 Redis보다는 데이터베이스 테이블을 별도로 만들어 관리하고, 스케줄러를 이용해 실행하는 것이 더 실용적입니다.
캐싱: 양날의 검
신중한 캐시 도입
주니어 엔지니어는 모든 것을 캐시하고 싶어하지만, 시니어 엔지니어일수록 캐시 도입에 신중합니다. 캐시는 새로운 상태를 도입하므로 동기화 문제, 오류, 오래된 데이터 등의 위험이 존재합니다.
비싼 SQL 쿼리를 캐시하기 전에 먼저 데이터베이스 인덱스를 추가해보세요. 캐싱은 마지막 수단이어야 합니다.
대용량 캐시 전략
대용량 캐시가 필요한 경우 Redis나 Memcached 대신 S3나 Azure Blob Storage 같은 문서 저장소에 주기적으로 저장하는 방식도 고려할 수 있습니다. 예를 들어 대형 고객의 주간 사용량 보고서 같은 경우 말이죠.
이벤트 기반 아키텍처의 적절한 사용
언제 이벤트를 사용할 것인가
대부분의 기업은 Kafka 같은 이벤트 허브를 갖추고 있습니다. 하지만 이벤트를 남발하기보다는 단순한 요청-응답 API 설계가 로깅과 문제 해결 면에서 더 유용한 경우가 많습니다.
이벤트는 발신자가 수신자의 동작에 신경 쓰지 않아도 될 때, 또는 고용량·지연 허용 시나리오에 적합합니다.
데이터 전달: Push vs Pull
Pull 방식의 단순함
Pull 방식은 대부분의 웹사이트가 작동하는 방식입니다. 사용자가 데이터를 원할 때 서버에 요청하는 것이죠. 단순하지만 같은 데이터를 반복적으로 요청하는 문제가 있습니다.
Push 방식의 효율성
Push 방식은 Gmail처럼 데이터가 변경될 때 클라이언트에 즉시 전달하는 방식입니다. 효율적이고 최신 데이터 유지에 유리하지만, 구현이 더 복잡합니다.
핫패스: 시스템의 심장부
핵심 경로에 집중하라
시스템에서 가장 중요하고 데이터가 많이 흐르는 핫패스에 설계와 테스트 자원을 집중해야 합니다. 핫패스는 선택지가 적고, 설계 실패 시 서비스 전체에 심각한 문제를 일으킬 수 있습니다.
예를 들어 과금 시스템에서는 고객에게 요금을 부과할지 결정하는 부분과 모든 사용자 행동을 감지해 요금을 계산하는 부분이 핫패스입니다.
관측성과 로깅
비정상 경로의 상세 로깅
사용자 대면 엔드포인트가 422 응답을 해야 하는 조건들을 확인하는 함수를 작성한다면, 어떤 조건이 충족되었는지 로그를 남겨야 합니다. 과금 코드를 작성한다면 모든 결정 사항을 로그로 남기세요.
분포 지표의 중요성
평균값만 보지 말고 p95, p99 지연 시간 같은 분포 지표도 반드시 관찰해야 합니다. 소수의 매우 느린 요청이 가장 중요한 사용자들의 문제일 수 있습니다.
장애 대응: 우아한 실패
재시도의 함정
재시도는 만능 해결책이 아닙니다. 실패한 요청을 무작정 재시도하면 다른 서비스에 부담만 줍니다. 연속된 5xx 응답이 너무 많으면 잠시 요청을 중단하는 회로 차단기를 사용하세요.
Fail Open vs Fail Closed
시스템 일부가 실패했을 때의 행동을 미리 결정해야 합니다. 요청 제한 시스템은 대부분 fail open(허용)해야 하지만, 인증 시스템은 반드시 fail closed(차단)해야 합니다.
지루함이 최고의 덕목
진정한 시스템 설계는 컨퍼런스에서 발표할 만큼 흥미롭지 않습니다. 10년 동안 손으로 만든 데이터 구조가 불가능한 기능을 가능하게 만든 경우를 한두 번 본 적이 있지만, 지루할 정도로 단순한 설계를 매일 보고 있습니다.
좋은 시스템 설계의 본질은 눈에 띄지 않으면서도 충분히 입증된 방법론을 안전하게 조합하는 것입니다. 여러분의 다음 프로젝트에서는 “와, 대단해!”라는 감탄보다는 “아, 별거 아니네”라는 반응을 목표로 해보세요. 그것이 바로 성공한 시스템 설계의 증거일 테니까요.
참고 자료: Sean Goedecke, “Everything I know about good system design”