요즘IT
위시켓
새로 나온
인기요즘 작가들컬렉션
물어봐
새로 나온
인기
요즘 작가들
컬렉션
물어봐
개발
AI
IT서비스
기획
디자인
비즈니스
프로덕트
커리어
트렌드
스타트업
서비스 전체보기
위시켓요즘IT
고객 문의
02-6925-4867
10:00-18:00주말·공휴일 제외
yozm_help@wishket.com
요즘IT
요즘IT 소개작가 지원
기타 문의
콘텐츠 제안하기광고 상품 보기
요즘IT 슬랙봇크롬 확장 프로그램
이용약관
개인정보 처리방침
청소년보호정책
㈜위시켓
대표이사 : 박우범
서울특별시 강남구 테헤란로 211 3층 ㈜위시켓
사업자등록번호 : 209-81-57303
통신판매업신고 : 제2018-서울강남-02337 호
직업정보제공사업 신고번호 : J1200020180019
제호 : 요즘IT
발행인 : 박우범
편집인 : 노희선
청소년보호책임자 : 박우범
인터넷신문등록번호 : 서울,아54129
등록일 : 2022년 01월 23일
발행일 : 2021년 01월 10일
© 2013 Wishket Corp.
로그인
요즘IT 소개
콘텐츠 제안하기
광고 상품 보기
개발

좋은 시스템 설계에 대해 내가 아는 모든 것

안영회
12분
3시간 전
283
에디터가 직접 고른 실무 인사이트 매주 목요일에 만나요.
newsletter_profile0명 뉴스레터 구독 중

본문은 션 고데크(Sean Goedecke)의 글 <Everything I know about good system design>을 번역한 글입니다. 션 고데크는 현재 깃허브의 수석 소프트웨어 엔지니어로 일하며, 코파일럿 관련 프로젝트를 주도하고 있습니다. 시스템 설계의 모든 영역에 대한 그의 진솔한 조언입니다. 필자에게 허락받고 번역과 게재를 진행했습니다.


나는 잘못된 시스템 설계 조언들을 많이 본다. 교과서적인 사례는 링크드인(LinkedIn)에 딱 맞춰 “너는 큐(queue)에 대해 들어본 적 없을걸”과 같은 스타일로, 주로 업계에 새로 온 사람들의 관심을 노리는 글이다. 또 다른 예는 트위터(Twitter) 맞춤인데 “데이터베이스에 불리언(boolean) 값을 저장하면 망할 엔지니어”처럼 익살스럽게 포장한 말이다.[1] 또 다른 면에서, 나는 ‘데이터 중심 애플리케이션 설계’를 좋아하지만, 대부분의 엔지니어들이 맞닥뜨릴 시스템 설계 문제에 크게 유용하지는 않다고 생각한다.

[1] 대신 타임스탬프를 저장하고, 타임스탬프가 존재하는 것을 true로 간주하는 방식이 일반적이다. 나도 가끔은 그렇게 하지만, 항상 그러는 건 아니다. 내 생각에는 데이터베이스 스키마를 즉시 읽기 쉽게 유지하는 데에도 일정한 가치가 있다고 본다.

 

시스템 설계란 무엇인가? 내 생각에 소프트웨어 설계가 코드 라인을 조립하는 방법이라면, 시스템 설계는 서비스를 조립하는 방법이다. 소프트웨어 설계의 기본 요소는 변수, 함수, 클래스 따위이다. 한편 시스템 설계의 기본 요소는 앱 서버, 데이터베이스, 캐시, 큐, 이벤트 버스, 프록시 따위이다.

 

이 글은 좋은 시스템 설계에 대해 내가 아는 모든 것을 대략적으로 기록하려는 시도다. 많은 구체적인 판단은 경험에서 나오기 때문에 이 글에서는 그것까지 모두 전달할 수는 없겠지만, 내가 쓸 수 있는 것을 최대한 적어 보려 한다.

 

좋은 설계에 대한 인식

좋은 시스템 설계는 어떤 모습인가? 나는 이전에 좋은 설계는 눈에 띄지 않을 만큼 단순해 보인다고 썼다. 실제로, 이런 설계는 오랫동안 아무 문제 없이 잘 작동하는 것처럼 보인다. “아, 예상보다 훨씬 쉬웠네” 혹은 “시스템의 이 부분에 대해 신경 쓸 필요가 없네, 잘 작동해”라는 생각이 든다면 좋은 설계를 경험하는 것이다. 이처럼 역설적으로 좋은 설계는 자기 자신을 드러내지 않는다: 반면 나쁜 설계가 종종 더 인상적이다. 나는 인상적으로 보이는 시스템을 항상 의심한다. 분산 합의 메커니즘, 다양한 형태의 이벤트 기반 통신, CQRS와 같은 복잡한 기교가 많다면 근본적인 설계 문제가 보완된 것이 맞는지, 아니면 단순히 과도하게 설계된 것은 아닌지 의심한다.

 

나와 같은 생각을 하는 사람은 드문 편이다. 엔지니어들은 복잡하고 흥미로운 요소가 많은 시스템을 보고 “와, 여기서 아주 복잡한 시스템 설계가 이루어지고 있구나!”라고 생각한다. 실제로 복잡한 시스템은 보통 좋은 설계의 부재를 반영한다. “보통”이라고 말하는 이유는 때로 복잡한 시스템 역시 필요하기 때문이다. 나는 그 복잡성을 정당화하는 많은 시스템에서 일해봤다. 그러나 제대로 작동하는 복잡한 시스템은 항상 단순한 시스템에서 진화한다. 처음부터 복잡한 시스템을 만들겠다는 것은 정말로 나쁜 생각이다.

 

 

상태 관리와 무상태성

소프트웨어 설계에서 어려운 부분은 상태(state)를 다루는 일이다. 이를테면, 정보를 일정 기간 저장한다면 어떻게 저장하고 서비스할지 같은 까다로운 결정을 많이 해야 한다. 정보를 저장하지 않는다면 애플리케이션은 “무상태(stateless)”이다.[2] 복잡한 예로 깃헙(GitHub)은 PDF 파일을 받아 HTML로 변환해주는 내부 API가 있다. 이것은 진짜 무상태 서비스다. 그 외 데이터베이스에 쓰기 작업을 하는 모든 것은 상태를 가진다.

[2] 기술적으로 모든 서비스는 적어도 메모리 내에서 일정 기간 어떤 형태로든 정보를 저장한다. 여기서 말하는 정보의 저장은 전형적으로 요청-응답 주기를 넘어선 저장을 뜻한다(예를 들어, 데이터베이스 같은 디스크 상에 영구적으로 저장하는 경우다). 만약 애플리케이션 서버를 단순히 구동하는 것만으로 새로운 버전을 바로 띄울 수 있다면, 그것은 무상태 앱(stateless app)이다.

 

시스템에서 상태를 가진 컴포넌트 수를 최소화해야 한다. (모든 컴포넌트 수를 줄여야 한다는 것은 당연한 얘기지만, 상태 있는 컴포넌트는 더 위험하다.) 그 이유는 상태가 있는 컴포넌트가 나쁜 상태에 빠질 수 있기 때문이다. 무상태 PDF 렌더링 서비스는 합리적으로 운용한다면 계속해서 안전하게 실행된다: 예를 들어, 문제가 생기면 자동으로 재시작되는 컨테이너에서 구동하면 그만이다. 그러나 상태가 있는 서비스는 이런 식의 자동 복구가 불가능하다. 만약 데이터베이스에 문제가 되는 항목이 들어가면 (예: 앱 크래시를 유발하는 형식) 수동으로 수정해야 한다. 데이터베이스 용량이 부족하면 불필요한 데이터를 제거하거나 확장하는 방법을 찾아야 한다.

 

실무적으로는 상태를 아는 서비스 하나(데이터베이스와 통신)와 무상태 작업을 하는 다른 서비스들로 분리하는 방법이 있을 것이다. 다섯 개 서비스가 같은 테이블에 쓰기 작업을 하는 경우를 피하라. 대신 네 개 서비스는 첫 번째 서비스에 API 요청이나 이벤트를 보내는 형태로 쓰기 로직은 한 서비스에만 유지하라. 읽기 로직에도 이 방식을 적용하면 좋지만, 나는 이 경우는 덜 엄격하게 다룬다. 어떤 경우에는 내부 세션 서비스에 2배 느린 HTTP 요청을 하는 것보다 user_sessions 테이블을 빠르게 읽는 게 낫다.

 

 

데이터베이스

상태 관리가 시스템 설계에서 가장 중요하기에, 가장 중요한 구성 요소는 보통 상태가 저장되는 데이터베이스다. 나는 주로 SQL 데이터베이스(MySQL, PostgreSQL)로 일해왔기에 이에 대해 이야기하겠다.

 

스키마와 인덱스

데이터를 저장하려면 먼저 필요한 스키마로 테이블을 정의해야 한다. 이때 스키마 설계는 유연해야 한다. 수천, 수백만 레코드가 있으면 스키마 변경이 매우 어려워진다. 하지만 너무 유연하게 하면(예: 모두 “value” JSON 컬럼에 넣은 후에 매번 “keys”와 “values” 테이블로 데이터를 추적하는 식), 애플리케이션 코드가 복잡해지고 성능 문제도 초래한다. 어디까지 유연할지는 판단 문제지만, 보통 테이블은 사람이 읽기 쉬워야 하고 어떤 데이터를 왜 저장하는지 대략 파악할 수 있어야 한다.

 

무엇보다 테이블에 데이터가 몇 행 이상 있을 것으로 예상한다면 인덱스를 꼭 만들어야 한다. 인덱스는 가장 빈번한 쿼리와 맞춰야 한다(예: email과 type으로 검색한다면 두 필드를 포함한 인덱스를 만든다). 인덱스는 중첩된 사전처럼 동작하니, 유일한 값이 가장 많은 필드를 먼저 두어야 한다(그렇지 않으면 매번 인덱스를 탐색할 때 하나뿐인 email을 찾기 위해 같은 type의 모든 사용자를 스캔하게 된다). 그 대신 생각할 수 있는 모든 항목에 인덱스를 생성하면 안 된다. 인덱스는 쓰기 작업에서는 부하를 주기 때문이다.

 

병목 현상

트래픽이 많은 애플리케이션에서는 대개 데이터베이스 접근이 병목이다. 심지어 비효율적인 컴퓨터 연산(예: Unicorn 같은 프리포킹 서버에서 돌리는 Ruby on Rails)과 비교해도 그렇다. 복잡한 애플리케이션은 요청 하나에도 수백 번의 데이터베이스 호출을 해야 하며, 순차적으로 실행하는 경우도 많기 때문이다.(사용자가 악의적인 행동을 하지 않는다는 것을 먼저 확인한 후에야, 그 사용자가 조직의 구성원인지 확인할 것인가 알 수 있는 식으로.) 어떻게 하면 병목 현상을 피할 수 있을까?

 

우선, 데이터베이스 조회는 데이터베이스 쿼리를 활용하라. 데이터를 프로그램에서 처리하는 것보다 데이터베이스가 해주는 게 거의 항상 효율적이다. 예를 들어 여러 테이블에서 데이터를 얻으려면 별도 쿼리 후 메모리에서 결합하지 말고, JOIN을 사용하라. 특히 ORM을 쓴다면 실수로 반복문 안에서 쿼리를 만들지 않도록 유의해야 한다. 그렇게 되면 select id, name from table이라는 쿼리가 select id from table이나 id 값마다 수십, 수백 번 실행하는 select name from table where id = ?  쿼리로 바뀔 수 있다.

 

또, 가끔은 쿼리를 분리하는 것이 낫다. 자주 있는 일은 아니지만, 너무 복잡해서 단일 쿼리로 실행하는 것보다 여러 쿼리로 쪼개는 것이 데이터베이스 입장에서 더 쉬웠던 경우도 겪어봤다. 나는 항상 데이터베이스가 더 잘할 수 있도록 인덱스와 힌트를 구성할 수 있다고 확신하지만, 가끔 전략적으로 쿼리를 분리하는 것은 도구 상자에 꼭 필요한 기술이다.

 

읽기 쿼리는 가능한 한 데이터베이스 복제본으로 보내라. 일반적인 데이터베이스 구성은 하나의 쓰기 노드와 여러 읽기 복제본으로 이루어진다. 그렇기에 쓰기 노드에서 읽기를 피할수록 좋다. 쓰기 노드는 쓰기 작업만으로도 이미 과부하 상태다. 예외는 복제 지연을 정말, 정말로 견딜 수 없는 경우다(읽기 복제본은 항상 쓰기 노드보다 수 밀리초 이상 뒤처져 있기 때문이다). 하지만 대부분의 경우 복제 지연은 간단한 기법으로 우회할 수 있다. 예를 들어, 레코드를 업데이트한 후 바로 그 값을 사용해야 할 때는, 쓰기 후 즉시 다시 읽지 말고 업데이트된 세부 정보를 메모리 내에서 채워 넣을 수 있다.

 

마지막으로, 쿼리 급증(특히 쓰기 쿼리와 트랜잭션)에 주의하라. 데이터베이스가 과부하 상태가 되면 속도가 느려지고, 이는 다시 과부하를 가중시킨다. 트랜잭션과 쓰기 작업은 데이터베이스에 많은 작업을 요구하기 때문에 과부하를 일으키기 쉽다. 대량 쿼리 급증 가능 서비스(예: 대량 수입 API)를 설계한다면 쿼리 제한을 고려하라.

 

 

빠르게 처리할 작업과 미루어 처리할 작업

서비스에서 어떤 작업은 빠르게 처리해야 한다. 사용자가 API나 웹페이지에 접근할 때, 응답은 수백 밀리초[3] 안에서 처리해야 한다. 그러나 미루어 처리해야 하는 작업도 있다. 예를 들어 매우 용량이 큰 PDF를 HTML로 변환하는 작업은 오래 걸린다. 그래서, 사용자가 유용하게 느낄 최소한 작업은 즉시 처리하고 나머지는 백그라운드에서 수행하는 것이 일반적인 패턴이다. 예를 들어 첫 페이지를 먼저 변환하고 나머지는 백그라운드 작업으로 처리하는 식이다.

[3] 트위터에서 게임 개발자들은 10ms보다 느린 응답은 용납할 수 없다고 말하곤 한다. 실제로 그래야 할지를 떠나서, 성공적인 기술 제품들에 대한 사실과는 다르다. 앱이 사용자에게 유용한 작업을 하고 있다면, 사용자는 더 느린 응답도 충분히 받아들인다.

 

그렇다면 백그라운드 작업이란 무엇인가? 백그라운드 작업은 시스템 설계에서 핵심 요소이므로 자세히 설명할 가치가 있다. 모든 기술 회사는 백그라운드 작업을 돌리는 시스템을 가지고 있다. 두 가지 주요 구성 요소로 이루어지는데 하나는 큐 모음(예: Redis)이고, 다른 하나는 큐에서 작업을 받아 실행하는 작업 실행 서비스다. {job_name, params} 같은 항목을 큐에 넣어 백그라운드 작업을 등록한다. 게다가 설정한 시간에 백그라운드 작업을 예약할 수도 있다(주기적 정리나 요약에 유용하다). 느린 작업이 있다면 보통 백그라운드 작업으로 하는 것이 첫 번째 선택이다.

 

때로는 자체 큐 시스템이 필요할 때도 있다. 예를 들어 한 달 후 실행할 작업을 예약하는데, Redis 큐에 넣는 것은 적절하지 않다. Redis는 장기 영속성(persistence)을보장하지 않으며, 먼 미래 작업을 조회하는 것도 Redis 큐로는 어렵다. 이럴 때는 각 파라미터와 scheduled_at 컬럼을 가진 대기 작업용 데이터베이스 테이블을 만든다. 매일 작업으로 scheduled_at <= 오늘인 항목을 체크해 작업 완료 후 삭제하거나 완료 표시를 한다.

 

 

캐싱

가끔 작업이 느린 것은 여러 사용자가 시간이 오래 걸리는 작업을 반복하기 때문이다. 예를 들어 청구 서비스에서 사용자 비용을 계산할 때 현재 가격을 조회하는 API 호출이 필요하다고 해보자. 만약, 오픈AI의 토큰당 과금과 같이 사용량 기준 과금이라면 (a) 너무 느리고 (b) 가격 제공 서비스에 트래픽 부담을 준다. 이럴 때 전형적인 해결책이 캐싱이다. 가격은 5분마다 한 번만 조회하고, 그 사이 값은 저장해둔다. 메모리 캐시가 간단하지만, Redis나 Memcached 같은 빠른 외부 키-값 저장소를 쓰는 경우도 많다.(여러 앱 서버에서 캐시를 함께 쓸 수 있기 때문이다)

 

보통 주니어 엔지니어는 모든 것을 캐싱하려 하고, 시니어 엔지니어는 최대한 적게 캐싱하려 한다. 왜 그럴까? 그 이유는 앞서 말한 상태 유지의 위험 때문이다. 캐시는 또 하나의 상태를 만드는 것이기 때문에 이상한 데이터나 동기화되지 않은 데이터, 오래된 데이터로 인한 버그를 유발할 수 있다. 캐시를 적용하기 전에 먼저 성능 개선 노력을 해야 한다. 그러니까, 인덱스 없는 비싼 SQL 쿼리를 캐싱하는 건 어리석은 일이다. 인덱스를 추가하는 게 맞다!

 

그래도 나는 캐싱을 많이 활용한다. 유용한 캐싱 기법 중 하나는 예약 작업과 S3나 Azure Blob Storage 같은 문서 저장소를 대규모 영속 캐시로 사용하는 것이다. 정말 비싼 작업 결과(예: 대형 고객의 주간 사용량 보고서)를 캐시해야 한다면, Redis나 Memcached에 넣기 어려울 수 있다. 대신 타임스탬프가 포함된 결과 파일을 문서 저장소에 넣고 직접 제공한다. 앞서 언급한 데이터베이스 지원 장기 큐와 같이, 특정 캐시 기술 없이 캐싱이란 아이디어만 활용하는 사례다.

 

 

이벤트

캐싱 인프라와 백그라운드 작업 시스템 외에, 대부분 기술 회사는 이벤트 허브를 갖고 있다. 가장 흔한 구현체는 Kafka이다. 이벤트 허브는 백그라운드 작업 큐와 비슷하지만, “이 작업을 실행해” 대신 “이 일이 발생했다”는 이벤트를 큐에 넣는다. 예를 들어 새로운 계정 생성 이벤트를 발생시키면 여러 서비스가 받아서 “환영 이메일 발송”, “부정 사용 검사”, “계정별 인프라 설정” 등의 작업을 한다.

 

다만, 이벤트를 너무 과용하지 말라. 보통은 한 서비스가 다른 서비스에 API 요청을 보내는 것이 더 낫다. 로그가 한곳에 있고 이해하기 쉬우며 응답을 바로 확인할 수 있다. 이벤트는 이벤트를 보내는 쪽이 소비자가 무엇을 하는지는 신경 안 쓰거나, 이벤트가 대량이고 시간에 민감하지 않을 때 좋다. 예를 들면 트위터에 올라온 새 글마다 부정 사용 검사를 하는 일과 같은 경우에 좋다.

 

 

푸시와 풀

데이터를 한 곳에서 여러 곳으로 보내야 할 때, 두 가지 방법이 있다. 가장 단순한 방법은 풀(pull) 방식이다. 대부분 웹사이트가 이렇게 작동한다. 서버가 데이터를 가지고 있고, 사용자가 요청해 데이터를 가져간다. 문제는 사용자가 같은 데이터를 반복 요청할 수 있다는 점이다. 예를 들어 새 메일을 확인하려면, 전체 웹 앱을 다시 불러와야 하는 것이다.

 

대안은 푸시(push) 방식이다. 사용자가 요청하는 대신, 클라이언트로 등록하게 하고 데이터 변경 시 서버가 데이터를 푸시 형식으로 제공한다. 지메일(Gmail)의 방식으로, 새 메일이 도착하면 페이지 새로고침 없이 바로 표시된다.

 

백그라운드 서비스라면, 푸시가 왜 좋은지 쉽게 이해할 수 있다. 매우 큰 시스템에서도 같은 데이터가 필요한 서비스는 대략 100여 개 정도다. 변동이 적은 데이터라면 데이터 변경 시 100번 요청하는 게 초당 수천 번 조달하는 것보다 낫다.

 

100만 명의 클라이언트에게 최신 데이터를 제공해야 한다고 가정하자. 클라이언트가 푸시해야 할까, 풀해야 할까? 상황에 따라 다르다. 어쨌든 단일 서버로는 처리할 수 없어 시스템 내의 다른 컴포넌트로 분산해야 한다. 푸시라면 이벤트 큐에 쌓고 여러 이벤트 처리기가 큐에서 꺼내 푸시한다. 풀이라면 빠른[4] 읽기 복제 캐시 서버 여러 개(예: 100개)를 메인 애플리케이션 앞에 두고 읽기 부하를 처리한다.[5]

[4] 이 방법이 빠른 이유는 메인 서버처럼 데이터베이스와 직접 통신할 필요가 없기 때문이다. 이론적으로 이는 요청 시 제공하는 디스크 상의 정적 파일이거나, 메모리에 저장된 데이터일 수도 있다.

[5] 참고로, 이러한 캐시 서버들은 메인 서버에 주기적으로 데이터를 요청하는 방식(즉, 풀링)이나 메인 서버가 새 데이터를 캐시 서버로 보내는 방식(즉, 푸싱)을 사용한다. 어느 방식을 쓰는가는 크게 중요하지 않다고 생각한다. 푸싱은 더 최신 데이터를 제공하지만, 풀링이 더 단순하다.

 

 

주요 경로

시스템을 설계할 때 사용자 상호작용이나 데이터 흐름 사이에는 여러 경로가 있다. 이게 다소 복잡하게 느껴질 수 있지만, 핵심은 시스템에서 가장 중요하고 데이터가 많이 흐르는 “주요 경로(hot path)”에 집중하는 것이다. 예를 들어 계량 결과로 과금을 청구하는 시스템이라면 고객 과금 여부를 결정하는 부분, 그리고 플랫폼 내 모든 사용자 행동을 감지해 비용을 산정하는 부분에 해당한다.

 

주요 경로는 다른 설계 영역보다 해결 방법의 선택지가 적은 탓에 중요하다. 과금 설정 페이지는 수천 가지 방법으로 동작하게 할 수 있지만 사용자 행동 데이터의 폭주를 합리적으로 처리하는 방법은 극히 제한적이다. 또한, 주요 경로는 잘못되었을 때 문제가 더 심각해지기 마련이다. 단순한 설정 페이지에서의 실수 하나로 제품 전체가 다운되기는 어렵지만, 모든 사용자 행동마다 실행되는 코드는 큰 문제를 낼 수 있다.

 

 

로깅과 메트릭

시스템에 문제가 있는지 어떻게 알 수 있을까? 가장 조심스러운 동료들로부터 배운 것은 실패 경로를 적극적으로 로깅하라는 것이다. 사용자 엔드포인트가 HTTP 응답코드 422를 반환해야 하는 조건을 검사하는 함수라면, 어떤 조건에 걸렸고, 문제가 있었는지 로그를 남겨야 한다. 예로 들었던 과금 코드라면 결정 사항 전체(예: “X 때문에 이 이벤트 비용 청구 안 함”)를 기록하라. 개발자 대다수는 로깅이 코드 복잡도와 유지보수 난이도를 높여 싫어하지만 그래도 해야 한다. 중요한 고객이 422 응답 코드로 불평한다면 설사 고객의 실수라 할지라도, 무엇이 문제인지 알아내기 위해 꼭 필요하다.

 

시스템 운영 파트를 위한 기본적인 모니터링 기능도 갖춰야 한다. 호스트/컨테이너 CPU, 메모리, 큐 크기, 요청 또는 작업 당 평균 시간 따위를 포함한다. 사용자 응대 지표인 요청 시간은 가장 느린 5%와 가장 느린 1% 요청도 모니터링해야 한다. 설사 한두 번이라고 해도 과하게 느린 요청은 조심해야 하며, 이는 대규모 핵심 사용자에게서 불편을 초래한다. 평균만 보면 일부 사용자가 서비스를 못 쓰고 있다는 사실을 놓치기 쉽다.

 

 

종료 버튼, 재시도, 우아한 장애 처리

종료 버튼(killswitches)은 다른 글(Every service should have a killswitch)을 썼으니 여기서 반복하지는 않겠다. 요점은 시스템 장애 시 어떻게 대응할지 고민해야 한다는 것이다.

 

한편, 재시도(retries)는 만능 해결책이 아니다. 실패한 요청을 무작정 재시도해 다른 서비스에 부하를 더 주지 않도록 해야 한다. 가능하면 고빈도 API 호출에 “회로 차단기”를 두어 응답코드 500으로 시작하는 오류가 연속되면 잠시 요청을 중단하게 하라. 예를 들어 과금 요청 후에 성공 여부를 모르는 상태에서 5xx 응답받는 경우와 같이, 성공 여부를 모르는 쓰기 이벤트의 재시도를 조심해야 한다. 전형적인 해결책은 “멱등 키”라는 UUID를 사용해 중복 실행을 방지하는 것이다. 키를 저장하고 같으면 무시한다.

 

시스템 일부에 장애가 났을 때 어떻게 할지도 중요하다. 예를 들어 Redis 버킷으로 사용자 요청 횟수를 제한하는 코드가 있다고 치자. Redis 버킷이 작동하지 않으면 어떻게 할까? 두 가지 선택지가 있다: 실패 시 허용(요청 통과)하거나, 실패 시 차단(429 오류 반환).

 

코드에 문제가 생겨도 서비스 흐름에 장애가 없도록 할 것인지, 안전을 우선하여 접근을 차단하거나 시스템을 멈출지는 기능에 따라 다르게 선택한다. 나는 요청 제한(rate limiting)은 거의 항상 오픈에  실패해야 한다고 본다. 요청 제한 문제는 사용자에게 대단한 장애가 아닐 수 있다. 하지만 인증은 전혀 다르다: 설사 자신의 데이터에 접근을 못 하는 일이 생기더라도 다른 사람이 자신의 데이터에 접근하도록 허용하는 것보다 낫다. 많은 경우 올바른 동작은 불명확하다. 이들은 종종 어렵고 복잡한 절충안이 필요하다.

 

 

마치며

여기서 일부 주제는 일부러 다루지 않았다. 예를 들어 모놀리스를 언제 분리할지, 컨테이너나 VM 사용 시점, 추적(tracing)과 좋은 API 설계 등이다. 일부는 내 경험상 크게 중요하지 않아서(모놀리스도 괜찮음), 너무 당연해서(추적을 써야 함), 혹은 시간이 없어서다(API 설계는 복잡함).

 

이 글의 핵심은 처음에 말했듯이, 좋은 시스템 설계는 영리한 트릭이 아니라 지루하지만 검증된 컴포넌트를 적절히 사용하는 것에 있다는 점이다. 나는 배관공이 아니지만 좋은 배관 설계도 비슷할 것이다. 지나치게 재주를 부리면 일을 그르치기 마련이다.

 

특히 대형 기술 회사에는 이벤트 버스, 캐싱 서비스 따위가 이미 있어서 좋은 설계는 오히려 별거 없어 보일 것이다. 콘퍼런스에서 발표할 만한 설계를 할 일은 매우 드물다. 물론 그런 경우가 아예 없지는 않다! 누군가 직접 만든 자료구조로 활용해 그전에는 불가능했던 기능을 구현한 적도 있다. 하지만 10년 동안 한두 번 본 정도다. 매일 만나는 것은 뻔한 시스템 설계다.

 

©위 번역글의 원 저작권은 Sean Goedecke에게 있으며, 저작권법의 보호를 받는 바, 무단 전재와 복사, 배포 등을 금합니다