Release 의 모든 것
35년 경력의 소프트웨어 엔지니어가 실제 경험을 바탕으로 시스템 안정성에 대한 고민과 해결책을 사례 중심으로 제시하는 책을 소개합니다.
Contents
- 운영 환경의 현실
 - 시스템 안정화
 - 안정성 안티 패턴
 - 블록 지점 파악
 - 자기 부정 공격
 - 척도 효과
 - 공유 자원
 - 처리 능력 불균형
 - 응답 지연
 - 제한 없는 결과
 - 안정성 패턴
 - 시간 제한
 - 서킷 브레이커
 - 빠른 실패
 - 테스트 하네스
 

이 책은 35년의 경력을 지닌 시니어 소프트웨어 엔지니어가 자신의 경험을 통해 습득한 엔지니어링 지식들을 사례 중심으로 정리한 결과이다. 소프트웨어 제품의 생명주기를 "개발" 과 "운영" 두 단계로 나눈다면, 기존의 소프트웨어 관련 개발 서적은 주로 "개발" 에 초점을 맞추는 경우가 많이 있다. 이 책은 개발 뿐만아니라 운영, 시스템 안정성에 대한 고민들을 다룬다. 사례로 나오는 예시들이 실무에서 실제로 있을법한 예제들이 나와서, 몰입하면서 책을 읽을 수 있었다. 이 책은 특히 시스템의 안정성에 대해 많은 설명을 하고 있어서, 예시들이 장애와 관련된 내용들이 많이 나온다. 장애에 대한 예시들을 읽다보니, 독자들도 긴장하면서 읽게 된다. (사례들의 대부분은 절대 직접 겪고싶지 않은 내용들이 많았다)
운영 환경의 현실
장애의 원인을 조사하는 것은 마치 살인사건을 수사하는 것과 비슷하다는 말에 많이 공감되었고, 흥미로웠다. 다음의 항공사 키오스크 검색 서버의 예시를 살펴보자.
package com.example.cf.flightsearch;
...
public class FlightSearch implements SessionBean {
    private MonitoredDataSource connectionPool;
    public List lookpupByCity(...) throws SQLException, RemoteException {
        Connection conn = null;
        Statement stmt = null;
        try {
            conn = connectionPool.getConnection();
            stmt = conn.createStatement();
            // 조회 로직 수행 
            // 결과 리스트 반환
        } finally {
            if(stmt != null) {
                stmt.close();
            }
            if(conn != null) {
                conn.close();
            }
        }
    }
}
위의 코드는 단순한 코드이고, 실제로 JAVA 의 데이터베이스 연결 관련 예시 코드로 쉽게 확인해 볼 수 있는 코드이다. 그러나 이 코드는 한 항공사 키오스크를 몇시간 동안 장애를 일으키고, 수백만 달러의 손실을 발생시킨 코드이다. 과연 어떠한 부분이 장애를 유발하는 코드였을까 ?
힌트를 주자면, 해당 장애는 데이터베이스의 시스템 마이그레이션을 진행하다 발생하였다.
정답은 finally 절 안에 있다. 이 코드를 읽어보면, 비즈니스 로직을 수행하는 과정에서 에러가 발생하여도 DB Connection 자원을 해제하려는 개발자의 의도를 읽을 수 있다. 하지만 위의 코드는 개발자의 의도를 충분히 이행하고 있지 않다. stmt.close() 함수를 시행하는 과정에서도 에러가 발생할 수 있기 때문이다. 예시에서는 DB 마이그레이션 작업이 진행된 후 장애가 발생하였다. DB 마이그레이션이 완료된 후, DB 인스턴스의 IP 정보 등이 변경되면서 stmt.close() 가 정상적으로 수행되지 않았기 때문이다. 문제는 conn.createStatement(); 는 계속 수행되게 되면서, OOM이 발생하게 되었다.
시스템 안정화
  트랜잭션 은 시스템이 처리하는 추상적인 작업 단위다. 이는 데이터베이스 트랜잭션과는 다르다. 예를 들어 온라인 쇼핑몰의 사이트에서 흔히 볼 수 있는 트랜잭션 유형은 "주문 진행"이다. 단일 시스템은 한 가지 유형의 트랜잭션만 처리할 수 있으므로 전용 시스템이 된다. 혼합 작업 부하(mixed workload) 는 시스템에서 처리되는 서로 다른 트랜잭션 유형의 조합이다.
  시스템 이라는 단어는 완결적이면서 상호 의존적인 하드웨어, 어플리케이션, 서비스의 집합이다. 이는 사용자의 트랜잭션을 처리하는 데 필요하다. robust 한 시스템은 일시적인 충격이나 장애가 생겨도, 계속 트랜잭션을 처리하는 시스템이다. 이것이 대부분의 사람이 말하는 안정성 이다. 예를 들어, 커뮤니티에 올라온 유저의 홍보글로 인해 시스템에 충격을 줄 수 있는데, 이 부하를 잘 견디는 시스템이 안정성 있는 시스템이다. 반대로, 신용카드 VAN 시스템의 응답이 느린 것은 쇼핑몰에 가해지는 변형력이다. 신용카드 VAN 서버의 응답이 느려지면, 이와 연동된 쇼핑몰 서버의 RAM에도 부하를 일으킬 수 있다. 
  시스템 장애는 시스템의 일부 구성 요소의 작은 부분에서부터 시작된다. 작은 부분의 균열은 빠르게 퍼지고 폭발적으로 부서지게 되는데, 이러한 방식을 장애 모드(failure mode) 라고 한다. 자동차 엔지니어가 크럼플 존(crumple zone) 을 만드는 것 같이 안전한 장애 모드를 만들어 피해를 억제하고 시스템의 다른 부분을 보호할 수 있다. 이러한 보호 장치를 균열 차단기(crackstopper) 라고 부른다.
  앞의 항공사의 예시에서 장애 모드가 어떻게 적용되었는지를 살펴보자. 장애는 SQLException 라는 작은 균열에서 시작되었다. 시스템에 만약 적절한 crackstopper 가 있었다면, 심각한 장애로 이어지는 것을 막을 수 있었을 것이다. 서비스가 EJB 로 노출되어있기 때문에, 호출 시간에 제한이 없었다. 다시 말해, 호출하는 쪽에서 EJB 가 응답할 때까지 계속 Pending 되어 아무 일도 못하게 되었고, 호출이 블록되기 시작했다. 만약 Socket 연걸에 타임아웃이 설정되었거나, EJB 가 아닌 HTTP 기반 웹 서비스로 설계되었다면 이러한 장애를 방지할 수 있었다. 
  조금 더 넓은 관점에서 보면, 서버들은 하나의 이상의 서비스 그룹으로 분할될 수 있었다. MSA 환경과 같이 구성되었다면, 모든 시스템이 중단되는 대신 한 서비스 그룹에만 장애가 발생했을 것이다. 또는 요청/응답 메세지 큐를 사용했다면, 호출하는 쪽에서 응답이 영원히 오지 않을 수 있다는 것을 알 수 있었을 것이다. 
 
위의 사례에서 알 수 있듯이, 장애는 연쇄적으로 발생하는 경우가 많다. 다음은 연쇄적으로 일어나는 사건들을 정확히 설명하는 데 쓰이는 몇 가지 일반적인 용어이다.
| 용어 | 설명 | | --- | --- | | 결함 | 소프트웨어에 잘못된 내부 상태가 만들어지는 조건. 결함은 잠재적인 버그가 발단이 될 수 있다. | | 오류 | 눈에 띄게 잘못된 작동. 거래 시스템이 갑자기 100억 달러 어치의 포켓몰 선물을 구매한다면 오류이다. | | 장애 | 시스템이 응답을 하지 않는 것. 장애의 기준은 주관적이다. 전원이 켜져 있는데도 어떤 요청에도 응답하지 않을 수 있다. |
강한 결합은 균열을 가속화한다. 예를 들어 EJB 호출의 강한 결합은 서버 자원 고갈 문제로 이어져, 호출한 서버에 더 큰 문제가 발생하도록 만들었다. 시스템의 모든 결함을 알 수 없고, 모든 결함을 없애려면 소프트웨어 개발 속도가 매우 느려질 것이다. 우리는 시스템의 장애와 오류의 위험을 어느 정도 감수할 것인지 선택해야 한다.
안정성 안티 패턴
소켓 기반 프로토콜: 원격 애플리케이션이 포트를 수신하지만, 연결 요청이 한꺼번에 엄청나게 몰린 상황을 가정해 보자. 수신 대기열이 가득 차면, 새로 들어오는 연결 시도는 거부된다. 하지만 수신 대기열에 있는 연결들이 문제다. 소켓이 완전히 연결되지 않아 중간 상태에 머물면 원격 애플리케이션이 연결을 허용할 때까지 운영체제 커널 내부에서 블록된다.느린 응답: 빠른 네트워크 장애는 호출 코드에서 즉각적인 예외를 일으킨다. 이에 비해 블록된 스레드는 다른 트랜잭션을 처리하지 못하기 때문에 전체 처리 능력이 줄어든다. 분명히, 늦은 응답이 무응답보다 훨씬 나쁘다.오전 5시 문제: 새벽 5시마다 완전히 정지되는 패턴이 있었다. 새벽 시간에는 유저의 트래픽이 사라지는 시간대인데, 이로 인해 서버에서 사용하고 있던 방화벽 관련 캐싱 정보가 만료되어 장애가 발생하기도 한다.HTTP 프로토콜: HTTP 통신은 소켓 연결을 사용하기 때문에, 위의 소켓 기반 프로토콜의 문제점을 모두 지닌다. HTTP 는 추가로 자체적인 문제를 더 지닌다. 다음의 몇 가지 상황을 살펴보자.- 수신 측에서 TCP 연결을 수락했지만 HTTP 요청에는 전혀 답하지 않는다.
 - 수신 측에서 연결을 수락했지만 요청을 읽지 않는다. 요청에 포함된 데이터가 크다면 수신 측 TCP 윈도우가 가득 찰 것이다. 이는 호출하는 측의 TCP 버퍼를 가득 차게 만들어 소켓 쓰기 블록이 발생할 것이다. 이 경우 요청을 전송하는 작업조차 절대로 끝나지 않게 된다.
 - 서비스를 제공하는 측은 호출하는 측에서 어떻게 처리해야 할지 모르는 상태를 응답으로 반환할 수 있다. RFC 232 "하이퍼텍스트 커피포트 제어 프로토콜"처럼 만우절 장난으로 제출된 인터넷 표준의 "418 커피포트가 아님"이 올 수도 있고, "451 법적 이유로 이용 불가"처럼 나중에 표준으로 추가된 상태가 올 수 있다.
 - JSON 응답 대신 HTML 로 된 일반 웹서버 404 페이지를 보내는 등 서비스를 제공하는 측에서 호출하면서 예상하지 못했거나 어떻게 처리할지 모르는 콘텐츠 유형을 응답으로 반환할 수 있다. (예를 들어 DNS 조회를 실패했을 때 인터넷 서비스 제공자가 HTML 페이지를 끼워 넣을 수 있는데, 이런 상황은 특히 위험하다).
 - 서비스를 제공하는 측에서 JSON 을 보내겠다고 평문 데이터를 보낼 수 있다.
 업체 제공 API 라이브러리: 기업용 소프트웨어 제공 업체가 많이 판매되었다고 해서 버그가 없는 것이 아니다. 이러한 라이브러리에서 가장 나쁜 점은 제어할 수 없다는 점이다.
블록 지점 파악
다음의 코드를 보고 블록되는 호출을 찾아보자.
String key = (String) request.getParameter(PARAM_ITEM_SKU);
Availability avl = globalObjectCache.get(key);
자바는 상위 클래스 또는 인터페이스 정의에서 synchronized 가 아닌 메서드를 하위 클래스에서 synchronized 메서드로 선언할 수 있다. 엄밀히 말하면 리스코프 치환 법칙(Liskov Substitution Principle) 을 위반한 것이다. 만약 globalObjectCache.get() 메서드가 다움과 같이 구현되어 있다고 가정해 보자.
public synchronized Object get(String id) {
  Object obj = items.get(id);
  if(obj == null) {
    obj = create(id);
    items.put(id, obj);
  }
  return obj;
}
synchronized 예약어는 한 시점에 한 스레드만 해당 메서드를 실행시킬 수 있으므로, 다른 스레드는 대기해야 한다. 트래픽이 폭주할 때에, obj 를 생성하는 create 함수가 응답하지 않으면서, 쓰레드들이 캐시에 접근할 수 없었기 때문이다.
자기 부정 공격
  자기 부정 공격(self-denial attack) 은 시스템이나 인간을 포함하는 확장된 시스템이 스스로를 공격하는 모든 상황을 가리킨다. 자기 부정 공격의 전형적인 예는 고급 정보가 포함된 이메일이다. Xbox 360 예약 주문이 시작될 무렵, 예약 관련 정보를 이메일로 발송하였다. 이메일에 포함된 딥링크가 실수로 CDN 서비스를 우회하는 바람에 구매 페이지의 이미지, JS파일, CSS 파일을 원본 서버에서 직접 가져오게 되었다. 결국 Xbox 예약 페이지는 엄청난 트래픽을 견디지 못하고 장애가 발생하였다. 
  무공유(shared-nothing) 아키텍처를 구축하여 기술적인 문제로 인한 자기 부정을 방지 할 수 있다. 무공유가 비현실적인 경우, 미들웨어를 사용하여 자원을 다중화하고 이면에서 동기화하여, 자원 자체를 수평적으로 확장할 수 있도록 만든다. 또한 트래픽 관리를 위해 로드밸런싱을 사용하고 있다면, 새로운 클라우드 자원을 provisioning 하여 프로모션 또는 트래픽 급증을 처리할 수 있다. 
척도 효과
생물학의 제곱-세제곱 법칙(square-cube law) 은 코끼리 크기의 거미를 볼 수 없는 이유를 설명한다. 벌레의 몸무게는 부피에 비례하므로 O(n^3)이 된다. 다리 힘은 단면적에 비례하므로 O(n^2)이 된다. 동물의 덩치를 10배 키우면 힘과 몸무게 비율이 작을 때의 1/10이 되어 다리가 버틸 수 없다. 다대일(many-to-one) 관계에서는 언제나 한쪽이 증가하면 척도 효과에 의해 문제를 겪을 수 있다. 예를 들어 서버 10대가 호출할 때는 잘 유지되던 데이터베이스 서버가 서버 50대 추가하면 무너질 수 있다. 이러한 모습은 개발/QA 의 환경과 실제 서버의 환경의 차이에서도 많이 나타난다.
공유 자원
  안전성을 위협할 수 있는 또다른 척도 효과는 공유 자원(Shared Resource)효과다. 흔히 서비스 지향 아키텍처 (service oriented architecture) 또는 공통 서비스 프로젝트의 형태로 볼 수 있는 공유 자원은 수평 확장 가능한 계층의 모든 구성 요소가 사용해야 하는 infrastructure 이다. 클러스터 관리자, lock 관리자 등이 공유 자원이다. 공유 자원이 과부하되면 처리량을 제한하는 병목 현상이 발생한다. 공유 자원이 다중화되고 비독점적이라면 (한 번에 여러 대상에게 서비스를 제공할 수 있다면) 문제가 없다. 또한 임계치까지 포화되더라도 자원을 추가하여 병목 현상을 줄일 수 있다.
  가장 확장성이 뛰어난 아키텍처는 무공유 아키텍처 이다. 아무것도 공유하지 않는 아키텍처에서는 처리 능력이 서버 수에 따라 선형적으로 확장되는 편이다. 공유 자원을 사용해야 하는 경우에는, 공유 자원을 호출해야 하는 서버의 수를 줄임으로써 무공유 아키텍처에 가까워질 수 있다.
  하지만 클라이언트가 단위 작업을 처리하는 동안 공유 자원이 독점적으로 할당되는 일이 너무 흔하다. 공유 자원이 포화되면 TCP 수신 대기열이 차기 시작하고, 수신 대기열 한도가 초과하면 트랜잭션이 실패한다.

처리 능력 불균형
서로 다른 계층 간 프로비저닝하는 과정에서 계층 간 처리량의 비율이 맞지 않을 수 있다. 속도 제한이 있거나 최대 처리량이 조절되는 API 를 호출할 때 특히 그렇다.

QA 환경에서는 예산 환경상 서버당 1~2대가 준비되어 있는 경우가 많다. 각 서비스당 1:1의 비율로 환경이 구성되어 있는데, 실제 서비스에서는 10:1 또는 그보다 더 심한 비율일 수도 있다. 어떻게 이러한 불균형 문제를 해결할 수 있을까 ? 운영 환경과 동일한 테스트 환경을 구축하는 것은 비효율적이고, 테스트 하네스를 적용시킬 수 있다. 일상적인 범위보다 높은 부하로 테스트 해보는 것도 좋은 방법이다. 또한 트랜잭션이 정상적으로 처리되지 않는 경우에 어떠한 일이 생기는지 살펴보는 것도 좋은 방법이다.
응답 지연
응답 지연은 흔히 요청이 너무 많아서 일어난다. 사용 가능한 요청 처리 스레드가 이미 모두 일하는 중이라면 새 요청을 받을 여력이 없기 때문이다. 동시에 응답 지연은 어떤 근본적인 문제의 증상으로 나타날 수도 있다. 메모리 릭이 생기면 가상 머신이 트랜잭션을 처리하기에 충분한 공간을 확보하기 위해 점점 더 열심히 일하게 되어 응답이 느려지곤 한다.이때 CPU 사용률이 높게 나타나는데 모두 garbae collection 때문이지 트랜잭션 자체를 처리하고 있기 때문이 아니다.
제한 없는 결과
가장 흔한 백엔드 시스템을 가정해 보자. 데이터베이스에 쿼리를 보낸 다음 루프를 돌면서 반환된 결과의 각 행을 처리한다. 평소 100개 정도 반환하던 데이터베이스가 갑자기 500만 행을 반환하면 어떻게 될까? 메모리가 모두 소진되어 버리거나, 사용자가 응답을 기다리다 떠난 후에도 한참동안 while 루프를 돌 수 있다. SQL 을 직접 작성하는 경우 다음 중 하나를 사용해 가져올 행 수를 제한하자.
-- Microsoft SQL Server
SELECT TOP 15 columns FROM table;
-- Oracle (since 8i)
SELECT columns FROM table WHERE rownum <= 15;
-- Mysql and PostgreSQL
SELECT columns FROM table LIMIT 15;
안정성 패턴
앞에서 살펴봤든 안티 패턴들을 해결할 수 있는 방법들을 살펴보자. 
시간 제한
  사실상 오늘날 모든 시스템은 분산 시스템이다. 모든 어플리케이션은 네트워크의 근본적인 특성과 씨름해야 한다. 이 글의 첫 번째 예시에서도 나왔듯이 시간 제한이 없는 기능은 소켓의 큐를 가득 차게 만들고, 서버 메모리에 좋지 않은 영향을 미치게 된다. 네트워크 통신 간에 직접 시간 제한 및 재시도 로직을 걸거나, 아마존의 API Gateway 와 같은 제품을 이용하는 것도 방법이 될 수 있다.
서킷 브레이커
  시스템이 정상이 아닐 때 호출을 방해하는 구성 요소이다. 서킷 브레이커는 작업을 재실행하는 것이 아니라 작업이 실행되지 않도록 하기 위해 존재한다는 점에서 재시도와는 다르다. 정상적인 폐쇄(closed) 상태에서는 회로 차단기가 평소처럼 작업을 수행한다. 작업이 성공하면 별다른 일은 일어나지 않는다. 그러나 실패하면 서킷 브레이커가 실패를 기록해둔다. 실패 횟수(또는 실패 빈도)가 임계치를 초과하면 서킷 브레이커가 작동하여 회로를 개방(open) 한다.
  서킷 브레이커가 개방되면 작업을 수행하지 않고 바로 실패 처리한다. 일정 시간이 지나면 작업이 성공할 가능성이 있다고 판단해 반개방(half-open) 상태가 된다. 이 상태에서 서킷 브레이커가 호출되면 아직 정상인지 확인되지 않은 작업을 실행할 수 있다. 작업이 성공하면 서킷 브레이커가 폐쇄되어 다시 일상적인 작동을 할 준비가 된다. 작업이 실패하면 또 다시 일정 시간이 지날 때까지 서킷 브레이커가 개방 상태로 돌아간다.
  서킷 브레이커는 오류 유형을 분류하고 개별적인 처리를 할 수 있다. 예를 들어, 연결 거부 오류보다 원격 시스템 호출 시간 초과 오류에 대해 더 낮은 임계값을 설정할 수 있다.
  서킷 브레이커가 개방되면 작업이 호출될 때 무언가를 수행해야 한다. 가상 쉬운 방법은 즉시 작업을 실패 처리하는 것이다. 또한 서킷 브레이커는 fallback 전략을 가질 수 있다. 대개 마지막 정상 응답이나 캐시된 값을 반환할 것이다. 개인화되지 않은 일반적인 답변을 반환할 수도 있다.
  서킷브레이커는 시스템이 압박을 받을 때 자동으로 기능을 저하시키는 방법이다. fallback 이 무엇이든 시스템이 사업에 영향을 미칠 수 있기 때문에, 시스템의 이해관계자와 논의가 필요하다. 
빠른 실패
  느린 응답이 응답이 없는 것보다 더 나쁘다면, 최악은 느린 실패 응답이다. 시스템이 작업 실패를 미리 예측할 수 있다면 빨리 실패하는 것이 항상 좋다. 그렇다면 시스템이 실패할지 어떻게 알 수 있을까 ? 
  자원을 사용할 수 없다는 유형의 오류는 매우 많다. 예를 들어 로드밸런서가 연결 요청을 받았지만, 서버들이 하나도 작동하지 않는다면 바로 연결을 거부해야 한다. 또한 작업을 시작하기 전에 트랜잭션을 끝낼 수 있을지 확인해보는 것도 도움이 된다. 예를 들어, 우리가 흔하게 작성하는 validation 로직이 이에 해당한다.
테스트 하네스
QA 환경에서 일으키기 어려운 장애 상황이 있다. 통합 테스트 환경은 보통 대상 서비스가 정상 작동할 때의 시스템 작동만 확인할 수 있다. 하지만 시스템이 불안정하게 작동할 때 로컬 시스템의 작동을 테스트하는 것이 중요하다. 이를 위해 각 통합 지점의 상대편에서 원격 시스템을 모방하는 테스트 하네스를 만들 수 있다. 좋은 테스트 하네스라면 악독해야 한다. 실제 시스템이 그렇듯 심술궃고 잔인해야 한다. 예를 들어, TCP를 사용하는 원격 테스트 하네스는 다음과 같은 응답을 할 수 있다.
- 거부된다
 - 실패 초과 전까지 수신 대기열에서 대기한다
 - 원격 종단이 SYN/ACK로 응답하고 나서 아무런 데이터도 보내지 않는다
 - 원격 종단이 아무것도 보내지 않고 RESET 패킷만 보낸다
 - 원격 종단은 수신 윈도가 가득 찼다고 알린 후 데이터를 비우지 않는다
 - 연결은 맺어지지만 원격 종단이 데이터를 한 바이트도 보내지 않는다
 - 연결은 맺어지지만 패킷이 손실되어 재전송으로 인한 지연이 발생한다
 - 연결은 맺어지지만 원격 종단에서 패킷을 수신했다는 ACK 신호를 보내지 않아 끝없이 재전송된다
 - (HTTP인 경우) 서비스는 요청을 수락하고 응답 헤더를 보내지만 응답 본문을 보내지 않는다
 - 서비스가 30초마다 1바이트씩 띄엄띄엄 응ㄷ바을 보낸다
 - 서비스가 예상했던 XML 대신 HTML을 응답으로 보낸다
 - 서비스가 킬로바이트 정도를 예상했는데 메가바이트의 데이터를 보낸다
 - 서비스가 모든 인증 자격 증명을 거부한다
 
이것도 읽어보세요