Metadata
# N계층 서비스와 시스템 복잡성
*늘 그렇듯, 이 글은 제 개인적인 견해가 잔뜩 들어간 에세이입니다. 아마존에서 돌아가는 일들을 제 나름의 다소 엉뚱한 시각으로 바라봤고, 충분히 덜 익은 주장도 여기저기 섞여 있습니다.*
*다중 계층 아키텍처를 글로 다루는 일은 쉽지 않습니다. 몇 달씩 들여 책 한 권을 쓸 수도 있겠지요. 표지에는 [우호적인 화성인(Friendly Martians)](https://www.amazon.com/o/asin/0471316156)이 그려져 있을지도 모릅니다. 아니면 몇 마디 가볍게 비꼬고는, 다시 서버를 이리저리 튕기며 운영하는 본업으로 돌아갈 수도 있습니다. 저는 후자를 택했습니다.*
오늘 글은 주로 다음 질문에 관한 것입니다. 그러니 계속 읽기 전에, 먼저 스스로 답이 무엇일지 생각해 보시기 바랍니다.
> 아마존의 내부 데이터 서비스와 그에 대응하는 객체 지향(object-oriented) API는 우리 시스템 전체의 복잡성을 *줄이는가*, 아니면 *늘리는가*?
제가 이 글을 쓰는 이유는, 솔직히 말해 답이 너무도 자명하다고 생각했기 때문입니다. 서비스는 분명히 우리 시스템 전체의 복잡성을 키웠다고 저는 봅니다.
그런데 똑똑한 몇몇 사람들이 서비스의 핵심 목적 자체가 복잡성을 줄이는 데 있다고 말하는 걸 들었습니다. 그들의 주장은 이렇습니다. 서비스가 "잘 정의된 인터페이스" 뒤에 코드의 복잡성을 숨겨 줄 것이라는 겁니다. 이 말은 이제 회사 전체의 구호가 되었습니다. 모든 것이 더 이해하기 쉽고 유지보수하기 쉬워질 것이고, 그 결과 품질 지표, 즉 가용성, 데이터 무결성, 유연성도 마침내 좋아질 것이라는 이야기입니다. 적어도 이론상으로는 그렇습니다.
저는 지난 4년 동안 애플리케이션 그룹, 그중에서도 특히 고객 서비스 앱(Customer Service Apps)에 몸담아 온 애플리케이션 개발자의 관점에서 이 문제를 봅니다. 우리 쪽에서는 전혀 그렇게 느끼지 않습니다. 아마존의 개발 대부분은 앱 개발자를 지원하거나 감시하는 인프라 그룹이 아니라, 비즈니스 목표를 지원하려는 애플리케이션 그룹에서 이뤄진다고 생각합니다.
그래서 여기서 애플리케이션을 만들어 내는 일이 얼마나 어려운지는 꽤 중요한 주제입니다.
**왜 서비스인가?**
그렇다면 애초에 우리는 왜 서비스를 시작했을까요? 기억하는 분 있습니까? 여기서 말하는 서비스란 Customer Master Service(CMS), Order Master Service(OMS), Item Master Service(IMS), Customer Account Management Plus Something(CAMPS) 같은 대형 서비스를 뜻합니다. 이런 발상은 아마존의 암흑기였던 1997년 초 무렵에 시작됐고, 구현은 대체로 2007년쯤에 마무리되었습니다. (2004년에 쓴 글이므로, 3년 뒤의 날짜를 과거형으로 적고 있습니다.)
1997년 당시 이곳에 없었다면, 서비스를 시작한 이유가 시스템 복잡성을 줄이기 위해서였다고 짐작할 수도 있습니다. 하지만 그건 이유가 아니었습니다.
우리가 서비스를 도입한 이유는 코드 조직 방식에 있던 근본적인 문제, 즉 *2계층 문제(2-tier problem)* 때문이었습니다. 2계층이란 클라이언트 코드 안에 SQL이 박혀 있는 시스템을 가리키는 이름입니다. 대체로 정말 그게 전부입니다.
**가상의 예시(Hypothetical Example)**
왜 우리에게 서비스가 필요했는지 설명하기 위해, 고객의 머리카락 색깔이라는 예를 계속 사용해 보겠습니다.
고객을 머리카락 색으로 조회해야 하는 절박한 비즈니스 요구가 있다고 가정해 봅시다. 고객 정보가 들어 있는 데이터베이스가 있고, 그 정보 중 하나가 머리카락 색입니다. 누군가 빨간 머리인 모든 고객에게 홍보 우편을 보내고 싶어 합니다. 그래서 여러분은 다음과 같은 SQL 조각이 들어간 스크립트를 하나 작성합니다.
```sql
SELECT customer_name FROM customers WHERE hair_color="red"
```
쉽죠! 어떤 언어로 이 질의를 날리든, 빨간 머리 고객 이름 목록이 돌아옵니다. 이 코드를 설정할 때는 어느 데이터베이스에 접속할지, 사용자 이름은 무엇인지, 비밀번호는 무엇인지 지정해야 하고, 이 정보들은 모두 친절하게 코드 안에 박혀 있습니다.[^1]
이제 빨간 머리 고객 중 일부가 홍보 우편에 답장을 보내 "저기요, 제 프로모션 코드가 안 먹어요"라고 말한다고 해 봅시다. 물론 실제로는 이런 일은 거의 없습니다.[^2] 하지만 일단 생기면 문의가 한꺼번에 몰리고, 고객 서비스(CS) 담당자들이 조사해야 합니다. 담당자들은 무슨 일이 있었는지 알아보려고 고객 주문을 조회해야 할 가능성이 큽니다.
문제는 고객이 언제나 우리가 바라는 만큼 상황을 잘 파악하고 있지는 않다는 점입니다. 그래서 고객이 CS 담당자에게 줄 수 있는 정보라고 해 봐야 "음, 저 빨간 머리예요" 정도일 수도 있습니다. 그러면 CS는 CS Apps에, 물론 임시 페이지라는 명목으로, 빨간 머리 고객이 한 모든 주문을 끌어오는 Arizona 페이지를 하나 만들어 달라고 요청합니다. 그래야 어떤 주문인지 추적할 수 있으니까요.
CS Apps는 성실하게 다음 SQL 질의를 작성합니다.
```sql
SELECT customer_name, order_id FROM customers, orders
WHERE blah blah blah AND hair_color="red"
```
*(참고: 몇몇 세부 사항은 생략했습니다.)*
자, 이제 여기서 문제가 시작됩니다. `customers`와 `orders`는 서로 다른 데이터베이스 테이블입니다. 아주 오래전에는 둘 다 같은 데이터베이스, 즉 ACB("Amazon.Com Books")에 살고 있었습니다. 하지만 1998년에 우리는 무어의 법칙(Moore's Law)보다 빨리 성장했고, 결국 그 머신이 "갑자기 뻗어 버리기" 전에[^3] 데이터베이스를 반으로 쪼개야 했습니다.
고통받는 데이터베이스를 쪼개는 고전적인 해법 가운데 하나가 *수직 분할(vertical partitioning)* 입니다. 뜻은 "테이블 묶음을 다른 데이터베이스로 옮긴다"는 겁니다. 간단해 보이죠? 물론 그렇습니다. 하지만 그렇게 하면 우리 CS Apps 질의는 깨집니다. 사실 `customers` 테이블을 ACB에서 꺼내 예를 들어 CUST라는 새 데이터베이스로 옮기기로 했다면, 홍보 우편 질의도 함께 깨집니다.
이것이 2계층 아키텍처의 문제입니다. 데이터 모델러나 데이터베이스 관리자(DBA)가 하부 스키마를 바꿔야 할 때마다, 그 안에 SQL이 들어 있는 모든 도구가 멈출 수 있습니다. 그러면 빨간 머리 고객들이 모두 화를 낼 텐데, 그건 당연히 좋지 않습니다.
**서비스가 구하러 온다**
이건 업계 전반에 널리 알려진 문제이고, 편리한 표준 해법도 함께 따라옵니다. 바로 *3계층 아키텍처(3-tier architectures)* 입니다.[^4]
3계층 아키텍처에서는 데이터베이스와 클라이언트 코드 사이에 한 층을 더 넣습니다. 클라이언트는 더 이상 SQL을 직접 호출할 수 없습니다. 대신 *서비스 API(Service API)* 라는 것을 호출합니다. API는 대략 이렇게 생겼을 수 있습니다.
```java
List getCustomersByHairColor("red")
```
혹은:
```java
List getAllCustomerOrdersWhereCustomerHairColorIs("red")
```
이것은 *객체 지향 API 호출(Object-Oriented API Call)* 이며, 서비스 아키텍처의 핵심입니다. 현대 언어는 물론이고 C++ 같은 다소 낡은 언어들조차 "객체 지향 설계(Object-Oriented Design)"를 사용하기 때문입니다. 그런데 이것은 관계형 데이터베이스 설계와 정말, 정말 많이 다릅니다. 관계형 쪽이 테이블, 행, 열을 쓴다면, 객체 지향(OO) 프로그램은 클래스, 객체, 필드를 씁니다. 이 전장에서 이미 피를 많이 흘렸으니, 일단은 깊이 들어가지 맙시다. 세상은 그냥 그렇게 되어 있습니다.
여기서 중요한 점은, 제가 만들 수 있는 가능한 모든 SQL 문에 대해, 그와 정확히 같은 내용을 알려 주는 대응 OO API 호출을 만들어야 한다는 것입니다. 차이는 서비스 계층을 거치면 클라이언트 코드가 더 이상 데이터베이스 테이블 스키마에 직접 묶이지 않는다는 데 있습니다.[^5]
구체적인 예를 들어 보겠습니다. 제 질의가 의사 SQL로 다음과 같다고 합시다.
```
GET ME ALL customers WHO ARE worth THEIR WEIGHT IN pesos
```
그러면 제가 선택한 언어에서 처리할 수 있는[^6] 서비스 호출을 하나 만들어 같은 일을 하게 할 수 있습니다.
```java
List getMeAllCustomersWhoAreWorthTheirWeightIn("pesos", "please")
```
이 과정을 "객체/관계 매핑(Object/Relational Mapping, O/R mapping, 또는 ORM)"이라고 부릅니다. 많은 개발자의 시간을 잡아먹고, 이 글의 상당 부분도 차지합니다.
그 이유는 아주 간단합니다.
정말 어렵기 때문입니다.
**구조라기엔 영 시원치 않다**
SQL은 관계형 데이터베이스와 상호작용하도록 특별히 설계된 언어입니다. 풍부하고 표현력이 뛰어나며, 거의 영어처럼 느껴집니다. 또 데이터에 대해 아주 세밀한 질문을 던질 수 있게 해 줍니다.
그래서 옛날에는 클라이언트 작성자가 코드 안에서 한 줄짜리 SQL 질의를 던지기만 하면 됐습니다. 델포이의 신탁(Oracle at Delphi)에 묻는 것과 비슷했죠. 그러면 즉시 답이 돌아왔습니다.[^7]
하지만 서비스가 도입되면서 사정은 조금 더 복잡해졌습니다. 이제 클라이언트 작성자는 아마 아직 존재하지도 않는 API를 호출해야 합니다. 여기에는 새로운 의존성이 생깁니다. 데이터는 바로 저기 데이터베이스 안에 있는데, 우리는 이제 거기에 직접 닿을 수 없습니다. 서비스 소유자에게 API를 추가해 달라고 요청해야 합니다. 이 얘기는 뒤에서 더 하겠습니다.
슬프게도, 설령 API가 존재한다고 해도 그것을 호출하는 일은 만만치 않은 장벽입니다. 코드에 SQL 문 하나 넣는 것처럼 간단하지 않습니다. 서비스 API를 호출하려면 클라이언트 작성자로서 믿기 어려울 정도로 많은 일을 해야 합니다. 다음은 그 *불완전한* 단계 목록입니다.
1. 먼저 그 서비스가 어떤 프로토콜을 쓰는지 알아낸다. 분산 컴퓨팅 프로토콜은 세상에 수두룩하고, 이 주제는 다른 글로 빼야 할 정도지만, 우리는 바벨탑(Tower of Babel) 전체를 사내로 들여와 버렸다. 우리 서비스는 제각기 다른 프로토콜을 말한다. CORBA, Publish/Subscribe, Iquitos, Peru, SOAP, XML/RPC, NFS, 그리고 또 무엇이 있는지 누가 알겠는가.
- 자기 언어에서 그 서비스의 프로토콜로 연결하는 언어 바인딩(language binding)을 알아낸다. 운이 좋다면 그 바인딩이 실제로 존재할 것이다.
- 일종의 서비스 로케이터(Service Locator)를 호출해, 호출하려는 서비스의 서비스별 URL을 찾는다.
- 실제로 서비스에 접속할 수 있을 때까지 서브넷 패브릭 설정이든, 방화벽이든, 리눅스 버전이든, 뭐든 끝없이 만지작거린다.
- 이미 호출 대상 서비스가 마셜링과 언마셜링을 해 놓은 매개변수를, 지금 쓰는 언어에서 다시 쓸 수 있게 또 마셜링/언마셜링해 주는 프록시 객체를 만든다. C++를 쓴다고 해도 예외는 아니다. 서비스는 IDL, XDR, PilferGrommet 같은 이상하고 도움 안 되는 무언가를 쓸 것이고, 여러분은 프로그램에서 쓸 수 있게 또 한 번 변환 과정을 거쳐야 한다.
- 반환된 데이터 속을 이리저리 뒤지며, 내가 찾는 정보가 어딘가에 들어 있기를 바란다.
아, 좋은 옛날이여. 데이터베이스를 그렇게 쪼갠 게 *정말로* 그럴 만한 가치가 있었을까요?
어쨌든 사용 중인 프로토콜을 설명하는 온갖 두꺼운 책과 마법서를 며칠 동안 헤쳐 나간 끝에 드디어 연결에 성공합니다. 그리고 이제서야 비로소 "API를 찾을 수 없음" 메시지를 받을 준비가 끝납니다.
그래서 서비스 소유자에게 메일을 보냅니다. "안녕하세요, CS Apps입니다. 최근 망가진 프로모션 때문에 고객 코 크기로 고객 주문을 조회해야 하는 강한 비즈니스 요구가 생겼습니다. API 호출 하나 추가해 주실 수 있을까요?"
그러면 그들의 표준 반응은, 당연히 갈비뼈가 나갈 정도로 웃는 것입니다. 왜냐하면 다른 누구도 코 크기로 뭔가를 조회할 필요를 전혀 느끼지 않기 때문입니다. 게다가 그들은 자기 서비스의 버전 1.7062부터 21.9738까지를 폐기(deprecate)하느라[^8] 바쁘고, *당신* 같은 사람과 이야기할 시간은 없습니다.
**2계층이 구하러 온다**
하하, 농담입니다. 우리가 서비스 소유자 몰래 직접 데이터베이스에 접근하진 않겠지요. 차라리 코가 긴 고객들에게 상품권이나 보내는 편이 낫겠습니다. 그런데 그것도 못 합니다. 서비스 소유자가 API 호출을 제공해 주지 않으면 데이터에 접근할 수 없으니까요.
그래서 문제가 생깁니다. 이 문제를 두고 서비스 소유자들은 적어도 두 개의 철학적 진영으로 갈립니다. 문제는 이것입니다.
*그 많은 API 호출을 누가 다 추가할 것인가?*
그리고 더 정확히 말하면, 어떻게 해야 그 일에 자기 시간을 전부 다 써 버리지 않을 수 있을까요? 클라이언트 작성자는 수십 명, 아니 수백 명일 수 있습니다. 각자 수십 개, 아니 수백 개의 맞춤 SQL 질의를 작성합니다. 그리고 *누군가*는 그 질의들을 모두 OO 서비스 API로 바꿔야 합니다. 그렇지 않습니까?
현재 아마존에서는 이 문제를 푸는 방법 두 가지가 쓰이고 있습니다. 둘 다 형편없습니다. 물론 그건 서비스 소유자 탓이 아닙니다. 앞에서 서비스 작성은 어려운 문제라고 말했는데, 정말 그렇습니다. 이건 우리가 가진 문제 중 *가장 어려운* 문제입니다. 이유는 이 글에서 지적할 것보다 훨씬 많습니다. 다만 여기서 제 목적은 서비스가 우리의 복잡성 문제를 치유하고 있지 않다는 점을 보여 주고, 고통을 조금 덜어 줄 단순화 방법을 한두 가지 제안하는 데 있습니다. 그 얘기는 나중에 하겠습니다.
현재 아마존에서 쓰이는 두 가지 해법은 다음과 같습니다.
1. 결코 다 작성되지 않는 수천 개의 자잘한 텔러 호출(Teensy Teller Calls). (OMS)
2. 모든 것을 한꺼번에 들이마시는 거대한 데이터베이스 슬러프(Database Slurp). (CMS)
흥미로운 점은, 우리 최대 규모의 서비스 둘이 이렇게 전혀 다른 접근을 택했다는 사실입니다. 서비스 소유자는 클라이언트 작성자가 쏟아내는 수요를 도저히 따라갈 수 없고, 두 해법 모두 적어도 서비스 팀 입장에서는 깔끔한 해결책처럼 보입니다. 앱 팀 입장에서는 아닐지 몰라도요.
이제 둘을 자세히 살펴보고, 무엇이 드러나는지 봅시다.
**텔러 호출(Teller Calls)**
여기서 *텔러 호출(teller call)* 이라는 용어를 다소 단순화해서 쓰고 있습니다. 이 문제를 제대로 다루려면 긴 책이 몇 권은 필요할 것입니다.
우리 목적상 텔러 호출이란, 세밀한 SQL 질의 하나에 직접 대응하는 세밀한 API 호출입니다. 예를 들어 지난주 목요일에 슈퍼세이버 배송(super-saver shipping)으로 코끼리를 주문한 모든 고객을 찾는 질의가 있다면, API 호출은 당연히 다음과 같겠지요.
```java
findAllCustomersWhoOrderedElephantsLastThursdayUsing("super-saver shipping")
```
혹은 이렇게:
```java
findAll("customers", WHO_ORDERED, new Date("last Thursday"), SHIP_METHOD_SSS)
```
심지어 이렇게도 될 수 있습니다.
```java
doQuery("SELECT name FROM customers, orders WHERE date=today()-5 AND ...")
```
하. 마지막 것은 농담입니다.
요점은 이렇습니다. 이렇게 아주 세밀한 질의는, 그리고 우리는 실제로 이런 세밀도의 질의를 정기적으로 필요로 합니다. 특히 대규모 정리 작업에서 그렇습니다. 그런데 이런 질의를 재사용 가능하게[^9] 메서드 구조로 묶는 편리한 방법이 없습니다.
게다가 이런 질의는 일회성인 경우가 많습니다. 업데이트나 기타 백필(backfill) 작업도 마찬가지입니다. 한 번만, 혹은 가끔만 쓸 질의를 위해 복잡한 서비스 호출을 인터페이스에 추가하는 것은 전혀 의미가 없습니다.
가끔은 데이터베이스에 SQL로 *간헐적으로* 접근할 수 있으면 참 좋겠다는 생각이 점점 강해집니다. 아시다시피, 그런 코끼리급 비상사태를 위해서 말입니다. 하지만 불행히도 클라이언트 작성자에게 1인치를 주면 1마일을 가져갑니다. 머지않아 그들은 `/bin` 디렉터리를 "thursday-elaphants"\[원문 오타 그대로\], "UPS-truck-caught-fire", "sorry-ruined-christmas" 같은 명령줄 도구와, CS가 수습해야 했던 온갖 사건사고용 스크립트로 가득 채울 것입니다. 그리고 그 소중한 도구 하나라도 깨지면 피를 토하듯 난리를 칠 겁니다.
이제 왜 OMS가 아직도 "완성"되지 않았는지 이해할 수 있을 겁니다. 앞으로도 결코 완성되지 않을 것입니다. 설령 우리 비즈니스가 더 이상 변하지 않는다 해도, 우리가 만들어 내는 시나리오는 멈추지 않을 테니까요.
서비스 소유자는 도대체 어떻게 해야 할까요?[^10]
**요구한 것보다 더 많이 받기**
이 문제에는 다른 접근법도 있습니다. 여러분이 식당 주인이라고 상상해 보십시오. 아마 스테이크하우스일 겁니다. 메뉴에는 여러 선택지가 있습니다. 손님은 완전히 태운 수준부터 아직 울음소리가 날 것 같은 수준까지 원하는 굽기로 스테이크를 주문할 수 있습니다. 여러분은 나름대로 괜찮은 선택지를 제공했다고 생각합니다.
그런데 손님들은 메뉴에 없는 미묘한 변형을 계속 요구합니다. 부위, 무게, 조리 방식, 소스 등을 고르고 싶어 합니다. 문제가 커집니다. 웨이터들은 셰프에게 전달할 메모를 적느라 시간을 다 보내고, 셰프들은 이제 주방 청소를 하거나 채식주의 손님을 끌 새로운 요리를 개발할 시간이 없습니다.
그러다 기발한 해결책을 떠올립니다. 살아 있는 소 한 마리를 끈에 묶어 손님 앞으로 끌고 와서 말하는 겁니다. "여기 소가 있습니다. 알아서 드십시오."
물론 이 접근에도 몇 가지 사소한 단점은 있습니다.
- 소가 식당 안을 돌아다닐 공간이 없을 수 있다.
- 지금 상태로는 먹기가 다소 불편하다.
- 손님이 소 한 마리를 전부 먹을 만큼 배고프지 않을 수 있다.
하지만 근본 문제를 해결하긴 합니다. 손님은 자기가 원하는 방식대로 스테이크를 먹을 수 있고, 식당 주인은 거의 개입하지 않아도 됩니다.
이것이 Customer Master Service(CMS)가 쓰는 방식입니다. 물론 이것도 약간 단순화한 설명입니다. 그들은 칼과 포크만으로 소를 제압하지 않아도 되도록 마체테도 함께 줍니다. 그래도 비유로는 제법 잘 맞습니다. 기존 API 호출이라는 "메뉴"에 없는 고객 정보를 요청하면, *고객 전체*가 통째로 돌아옵니다. 모든 주소, 신용카드, 위시리스트 설정, 무시된 수신 거부 옵션까지 전부 말입니다. 거대한 트리 구조 하나로 다 넘어오고, 그러면 여러분은 컴퓨터 과학 데이터 구조와 알고리즘 지식을 총동원해 테이블 조인 비슷한 것을 흉내 내기 시작할 수 있습니다. 마음껏 "원하는 대로" 하라는 겁니다.
물론 이것은 엔드투엔드 성능에 영향을 줍니다.
- 모든 데이터를 긁어모으려면 비용이 크고 잠재적으로 복잡한 데이터베이스 조인이 필요하다.
- 세밀한 호출 하나면 충분할 상황에서도 훨씬 더 많은 메모리와 네트워크 대역폭을 쓴다.
- 클라이언트는 원래 SQL 질의를 "흉내 내기" 위해 계산 비용이 큰, 그리고 아마 오류도 많을 트리 순회를 해야 한다.
하지만 큰 그림에서 보면, 성능이 우리를 죽이고 있지는 않습니다. 매번 같은 질의를 다시 하지 않도록 정교한 캐시를 만들 수도 있고, 기가비트 네트워크와 빠른 머신도 돌리고 있습니다. 성능은 분명 문제이지만, 적어도 다룰 수 없는 종류의 문제로 보이진 않습니다.
진짜 문제는 아마존에서 애플리케이션 개발이 훨씬 더 어려워졌다는 데 있습니다. 고객 정보를 조회해야 하는 새 기능을 출시하려 할 때마다, 프로그래머는 정보를 가져오기 위해 훨씬 더 많은 일을 해야 합니다. 이제 SQL이 제공하던 표현력을 더 이상 쓸 수 없기 때문입니다.
게다가 클라이언트가 이런 맞춤 질의와 트랜잭션을 위해 작성하는 코드는 서로 공유되기 어렵습니다. 즉, 그 코드가 모든 애플리케이션에, 늘 약간씩 다른 변형으로, 중복되고 있다는 뜻입니다. 그 결과 코드에서 버그를 발견하면 여러 팀의 여러 위치에서 따로따로 찾아 고쳐야 할 수 있습니다.
이것은 숨은 비용입니다. 측정하기 어렵습니다. 눈에는 잘 보이지 않지만, 분명히 존재합니다.
**그래서 어느 쪽이 더 나은가?**
지금까지 데이터베이스 접근 방식 세 가지를 설명했습니다.
1. *클라이언트 코드에서 직접 SQL에 접근한다.* 이것이 나쁜 이유는 데이터베이스 구조가 바뀌면 수백 개의 클라이언트 애플리케이션이 깨질 수 있기 때문이다.
2. *가능한 모든 클라이언트 질의마다 API 호출을 만든다.* 이것이 나쁜 이유는 확장되지 않기 때문이다. 서비스 소유자는 새 호출 수요를 감당할 수 없고, 일회성 호출을 서비스 인터페이스에 넣는 것도 말이 안 된다.
3. *데이터베이스 전체를 클라이언트에게 돌려준다.* 이것이 나쁜 이유는 추가 복잡성을 공유 불가능한 형태로 앱 개발자에게 떠넘기기 때문이다. 또 성능 문제 가능성도 있다. 어떤 사람들은 이것이 어떤 면에서는 2계층 모델로의 회귀라고 주장하지만, 저는 거기까지는 확신하지 못하겠습니다.
네 번째 모델도 있을 수 있습니다. 현재 아마존에서 실제로 쓰이고 있다고는 생각하지 않지만, 클라이언트 작성자가 자기 손으로 서비스에 API 호출을 추가할 수 있게 하는 방식입니다. 말하자면 셀프서비스 서비스 서비스(Self-Service Service Service)입니다. 실제로 이런 일은 여기저기에서 임시방편으로 일어나긴 합니다. 정말 절박한 클라이언트 그룹은 서비스를 로컬에서 빌드하고, 필요한 호출 코드를 추가한 뒤, 서비스 팀에 코드 리뷰를 요청하는 방법을 알아낼 수 있습니다. 코드 자체를 며칠 들여 작성하는 것보다 코드 리뷰에 몇 시간을 배정하는 편이 서비스 팀에는 더 쉽습니다. 하지만 여러 이유로, 이 방식은 자주 일어나지 않습니다. 그 이유는 이 글의 범위를 벗어납니다.[^11]
**요약과 권고**
우선, 서비스가 시스템 복잡성을 줄이기 위해 도입됐다는 식의 FUD가 많이 퍼지고 있습니다. 사실이 아닙니다. 우리는 명확하게 2계층 문제를 해결하려고 서비스를 도입했습니다. 적어도 애플리케이션 개발자 입장에서는 서비스가 시스템을 훨씬 더 복잡하게 만들고 있습니다.
이 말은, 서비스를 갖추었다고 해서 시스템 가용성 문제가 마법처럼 해결되지는 않는다는 뜻입니다. 본질적으로 더 유연해지는 것도 아닙니다. 그런 문제는 따로 직접 해결해야 합니다.
서비스를 만드는 일은 정말 어렵습니다. 특히 아마존처럼 규모가 막대한 곳에서는 더 그렇습니다. 딱 하나의 이상적인 접근법은 없어 보입니다. 어떤 접근을 택하든 심각한 상충 관계가 뒤따릅니다. 이건 업계에서도 가장 까다로운 문제 중 하나입니다.
SQL 같은 질의 언어, 그리고 XML 세계의 대응 언어들도, 풍부하고 아름답습니다. 트랜잭션을 매끄럽게 처리하고, 사용자 질의의 세밀도를 거의 무한하게 조절할 수 있게 하며, 빌드나 링크 사이클도 필요 없고, 문서화도 아주 잘 되어 있습니다.
그런데 전 세계적으로나 아마존 내부에서나, 대부분의 개발자는 객체 지향 서비스 API가 질의 언어를 대체하기에는 상당히 형편없는 수단이라는 사실을 잘 모르는 듯합니다. 하지만 현실을 직시합시다. API는 서비스 소유자와 클라이언트 양쪽 모두에게 훨씬 더 많은 일을 요구합니다.
따라서 데이터베이스 집합에 대해 어느 정도 잘 정의된 서비스 인터페이스를 만들고, 캐싱과 트랜잭션 의미론까지 운영 환경에서 다 정리했다면, 그 다음 논리적인 단계는 클라이언트를 위해 API를 추상화해 주는 새로운 질의 언어를 만드는 일입니다. 적어도 저에게는 그렇게 보입니다. 2012년 페이스북이 GraphQL을 내놓았을 때 실제로 그런 일이 벌어졌습니다. 바로 여기서 지적한 두 가지 실패 모드, 즉 과도한 데이터 가져오기(over-fetching)와 수다스러운 세밀 호출(chatty fine-grained calls)을 바탕으로 만들어졌습니다.
N계층 서비스를 힘들게 구축한다면, 거기서 얻을 수 있는 잠재적 이점을 정말로 다 실현하고 있는지 확인해야 합니다. 그 잠재적 이점 가운데 하나는 언어 중립성(language neutrality)입니다. 하지만 그것은 스스로 챙겨야 합니다. CORBA든 Tibco publish-subscribe든 다른 어떤 프로토콜이든 선택했다면, 언어 바인딩을 만들 방법이 있는지 반드시 확인해야 합니다.
저는 이 말을 정말 진심으로 합니다. 왜냐하면 한밤중에 대형 참사를 수습하면서 데이터베이스 데이터를 고치는 프로그램을 급히 작성해야 할 때, 그 정리 코드를 C++로 쓰는 건, 심지어 자바(Java)로 쓰는 것조차, 정말 지독하게 멍청한 일이기 때문입니다.
데이터 저장소를 감싸는 서비스를 만든다면, 작은 인터페이스는 결국 커질 수밖에 없다는 사실을 알아야 합니다. 왜냐고요? 범용 질의 언어를 만든 게 아니니까요. 그리고 API가 수백, 수천 개 메서드를 가진 거대한 것이 되면, 여러분은 그것을 늘 조금씩 수정하게 될 것입니다. 버전 관리(versioning), 배포(deployment), 품질 보증(QA), 기타 운영 반영 작업이 악몽이 되기 시작할 겁니다. 그러니 서비스를 끊임없이 리팩터링(refactoring)해야 합니다! 큰 서비스를 밀어 넣는 것보다 작은 서비스를 배포하는 편이 언제나 낫습니다.
그리고 이름에 "Master"가 들어가는 새 서비스는 만들지 마십시오! "Kitchen Sink"도 마찬가지입니다. 그런 것들은 결국 통제 불가능한 거대 괴물로 불어나기만 할 테니까요.
더 많은 팁도 얼마든지 할 수 있겠지만, 대충 감은 잡으셨을 겁니다. 아무리 신중하게 서비스를 설계해도 결국 그것은 엉망이 될 것이라는 점만 알아 두십시오. 그래도 괜찮습니다. 아직 아무도 이 문제를 제대로 푸는 방법을 찾아내지 못했으니까요. *아무도* 못했습니다.
이건 어려운 문제입니다.
## 주석
[\[1\]](#ref1) 보안 엔지니어 여러분이 심장마비를 일으키기 전에 고백하자면, 지금은 우리도 이걸 거의 하지는 않습니다.
[\[2\]](#ref2) *\[편집자 주: 이건 아주 심한 반어법이었다. 실제로는 아마존이 돌리는 거의 모든 프로모션에서 이런 일이 일어난다. 적어도 예전에는 그랬다.\]*
[\[3\]](#ref3) DBA 용어로 말하면 그렇다.
[\[4\]](#ref4) "N계층 아키텍처(N-tier architectures)"라고도 부른다. 우리 모두 알다시피 3보다 N이 훨씬 더 멋지기 때문이다.
[\[5\]](#ref5) 은행에 창구 직원(teller)을 하나 추가하는 것과 비슷하다. 금고에서 내 돈을 직접 꺼내는 대신, 창구 직원에게 가져다 달라고 하는 것이다. 은행이 내 돈을 남미의 부실 채권에 몽땅 투자해 버려도, 나는 여전히 창구 직원하고만 이야기하므로 절대 모를 수 있다. 이 비유는 뒤에서 다시 다룰 것이다.
[\[6\]](#ref6) 아니면 C++로도, 뭐, 할 수는 있겠지.
[\[7\]](#ref7) "데이터베이스를 찾을 수 없습니다. TNSNAMES.ORA가 없거나 손상되었습니다."
[\[8\]](#ref8) *디프리케이션(Deprecation)* 은 소프트웨어 용어로, "이제 더는 그렇게 하지 말아 달라고 요청하는 것"을 뜻한다. 대충 주류 판매점 주인이 "우리 가게를 터는 행위는 이제 deprecated되었습니다. 감사합니다."라는 안내문을 붙여 두는 것만큼 효과가 있다.
[\[9\]](#ref9) 아니면, 굳이 못되게 말하자면, 아예 쓸 수 있기나 한 수준으로도 말이다.
[\[10\]](#ref10) 좋아, 이번엔 진짜 각주다. 텔러 모델에는 클라이언트가 요구하는 API 호출 수가 기하급수적으로 늘어난다는 점 외에도 문제가 더 있다. 우선 API를 잘게 쪼개면, 각 호출이 트랜잭션 문맥 밖에서 일어날 수 있으므로 캐시 일관성과 트랜잭션 무결성을 유지하기가 훨씬 어려워진다. API 호출 사이에서 상태를 유지하기도 더 어렵고, 얌전하지 않은 클라이언트가 호출을 수천만 번 날려 "아군 오사(friendly fire)"로 서비스를 다운시키기도 더 쉬워진다. 골칫거리는 사실상 끝이 없다. 하지만 이 글에서 그것까지 다루기에는 범위가 너무 커진다. 그러니 클라이언트 작성자와 서비스 작성자 모두를 그냥 가엾게 여기시라. 그러면 당신의 업보 빚도 깨끗이 갚은 셈이 될 것이다.
[\[11\]](#ref11) 이유가 무엇인지는 충분히 짐작할 수 있을 것이다. 우선 클라이언트 작성자는 서비스 소유자보다 서비스 호출 하나를 작성하는 데 훨씬 더 오래 걸릴 것이다. 시작하기도 전에 서비스 특유의 온갖 "잡다한 것"을 익혀야 하기 때문이다. 그리고 협의 과정도 단순히 코드 리뷰만 받는 것으로 끝나지 않는 경우가 많다. 서비스 소유자는 새 코드의 운영 책임을 떠안을지 동의해야 하고, 그것이 단순하지 않은 패스스루 호출(pass-through call) 이상이라면 설계에도 참여해야 할 수 있다. 보통은 클라이언트 작성자가 요청을 큐에 넣고, 그것이 추가될 때까지, 아마도 아주 오랫동안, 기다리는 편이 더 편하다.
[^1]: [\[1\]](#ref1) 보안 엔지니어 여러분이 심장마비를 일으키기 전에 고백하자면, 지금은 우리도 이걸 거의 하지는 않습니다.
[^2]: [\[2\]](#ref2) *\[편집자 주: 이건 아주 심한 반어법이었다. 실제로는 아마존이 돌리는 거의 모든 프로모션에서 이런 일이 일어난다. 적어도 예전에는 그랬다.\]*
[^3]: [\[3\]](#ref3) DBA 용어로 말하면 그렇다.
[^4]: [\[4\]](#ref4) "N계층 아키텍처(N-tier architectures)"라고도 부른다. 우리 모두 알다시피 3보다 N이 훨씬 더 멋지기 때문이다.
[^5]: [\[5\]](#ref5) 은행에 창구 직원(teller)을 하나 추가하는 것과 비슷하다. 금고에서 내 돈을 직접 꺼내는 대신, 창구 직원에게 가져다 달라고 하는 것이다. 은행이 내 돈을 남미의 부실 채권에 몽땅 투자해 버려도, 나는 여전히 창구 직원하고만 이야기하므로 절대 모를 수 있다. 이 비유는 뒤에서 다시 다룰 것이다.
[^6]: [\[6\]](#ref6) 아니면 C++로도, 뭐, 할 수는 있겠지.
[^7]: [\[7\]](#ref7) "데이터베이스를 찾을 수 없습니다. TNSNAMES.ORA가 없거나 손상되었습니다."
[^8]: [\[8\]](#ref8) *디프리케이션(Deprecation)* 은 소프트웨어 용어로, "이제 더는 그렇게 하지 말아 달라고 요청하는 것"을 뜻한다. 대충 주류 판매점 주인이 "우리 가게를 터는 행위는 이제 deprecated되었습니다. 감사합니다."라는 안내문을 붙여 두는 것만큼 효과가 있다.
[^9]: [\[9\]](#ref9) 아니면, 굳이 못되게 말하자면, 아예 쓸 수 있기나 한 수준으로도 말이다.
[^10]: [\[10\]](#ref10) 좋아, 이번엔 진짜 각주다. 텔러 모델에는 클라이언트가 요구하는 API 호출 수가 기하급수적으로 늘어난다는 점 외에도 문제가 더 있다. 우선 API를 잘게 쪼개면, 각 호출이 트랜잭션 문맥 밖에서 일어날 수 있으므로 캐시 일관성과 트랜잭션 무결성을 유지하기가 훨씬 어려워진다. API 호출 사이에서 상태를 유지하기도 더 어렵고, 얌전하지 않은 클라이언트가 호출을 수천만 번 날려 "아군 오사(friendly fire)"로 서비스를 다운시키기도 더 쉬워진다. 골칫거리는 사실상 끝이 없다. 하지만 이 글에서 그것까지 다루기에는 범위가 너무 커진다. 그러니 클라이언트 작성자와 서비스 작성자 모두를 그냥 가엾게 여기시라. 그러면 당신의 업보 빚도 깨끗이 갚은 셈이 될 것이다.
[^11]: [\[11\]](#ref11) 이유가 무엇인지는 충분히 짐작할 수 있을 것이다. 우선 클라이언트 작성자는 서비스 소유자보다 서비스 호출 하나를 작성하는 데 훨씬 더 오래 걸릴 것이다. 시작하기도 전에 서비스 특유의 온갖 "잡다한 것"을 익혀야 하기 때문이다. 그리고 협의 과정도 단순히 코드 리뷰만 받는 것으로 끝나지 않는 경우가 많다. 서비스 소유자는 새 코드의 운영 책임을 떠안을지 동의해야 하고, 그것이 단순하지 않은 패스스루 호출(pass-through call) 이상이라면 설계에도 참여해야 할 수 있다. 보통은 클라이언트 작성자가 요청을 큐에 넣고, 그것이 추가될 때까지, 아마도 아주 오랫동안, 기다리는 편이 더 편하다.