(어플리케이션 개발) 이미지서버를 분산시키는 것.

*이미지 호스팅 시스템에서는 고려해야 할 다른 측면이 있다.
  • 저장될 이미지의 개수에 제한이 없다. 따라서 저장공간의 확장성에 대해서도 고려해야 한다.
  • 이미지 보기나 다운로드를 요청할 때 응답시간이 빨라야 한다.
  • 사용자가 이미지를 업로드하고 난 후, 해당 이미지는 항상 시스템에 저장되어 있어야 한다. (데이터에 대한 신뢰성)
  • 시스템을 운용하기 쉬워야 한다.(관리성)
  • 이미지 호스팅 서비스 자체의 이익율이 높지 않기 때문에, 시스템은 비용 효율적으로 운용될 필요가 있다.
다음은 이미지 호스팅 애플리케이션의 기능을 간단하게 도식화한 것이다.
webarchitecture1



                                     그림 1 이미지 호스팅 애플리케이션의 아키텍처 다이어그램



이미지 호스팅 시스템에서는 고려해야 할 다른 측면이 있다. 

서비스들(Services)

확장성있는 시스템을 설계할 때 각각의 명확한 인터페이스를 기반으로 나누어 생각하는 것은 좋은 방법이다. 이러한 방식으로 설계하는 시스템을 SOA(Service-Oriented Architecture)라고 부른다. 그리고 각각의 서비스는 다른 서비스와 상호작용을 위해 다른 서비스에서 공개하는 API형태인 추상화된 인터페이스를 사용한다.

시스템을 상호 보완적인 서비스로 분할한다는 것은 시스템을 기능단위로 분리시키는 것을 말한다. 이러한 추상화는 서비스와 서비스가 처한 환경 그리고 서비스와 서비스 사용자 사이의 명확한 관계를 수립하는데도 도움이 된다. 이러한 명확한 기술은 문제를 분리시키는데도 도움이 되지만, 각각을 독립적으로 확장시키는 것에도 효과적읻. 이런의미에서 시스템에서의 SOA는 객체 지향 프로그래밍과 아주 유사하다.

예제 애플리케이션에서 모든 이미지 업로드와 검색을 위한 요청은 같은 서버에서 처리되지만, 시스템을 확장시키기 위해서는 업로드와 검색 기능은 각각의 서비스로 분리되는 것이 합리적이다.


일반적으로 쓰기는 읽기보다 느리다.
한편 쓰기의 경우에는 업로드 동안 연결을 열어 놓은 상태로 유지해야 한다. 따라서 1MB를 업로드 하는 것이 1초 이상걸린다면 서버는 고작 500개의 동시적인 쓰기만 처리할 수 있을 뿐이다.(Apache에서 최대 커넥션 개수가 500으로 설정되어 있는 경우)

webarchitecture2


                                                           그림 2 읽기와 쓰기의 분리


 이런 병목 현상에 대한 대처로 <그림 2>와 같이 읽기와 쓰기를 각각의 서비스로 분리하는 것은 좋은 방법이다.
이 방법은 읽기와 쓰기를 각각 독립적으로 확장할 수 있게 하고(보통 사용자들은 쓰기보다는 읽기를 더 많이 한다), 시스템의 각 부분이 어떻게 돌아가고 있는지 명확히 확인하는 데도 도움이 된다. 결과적으로 읽기가 느린 현상이 발생할 때 문제를 해결하고 확장하는 것을 쉽게 한다.
이러한 접근법의 장점은 각각의 문제를 독립적으로 해결할 수 있다는 것이다. 같은 서비스 영역에서 쓰기와 조회를 걱정할 필요가 없다. 이 둘은 모두 이미지 제공 시스템을 위한 것이지만, 각각의 성능을 향상시키기 위해 각각 적합한 방법을 사용할 수 있다. 요청을 큐로 관리하거나 인기 있는 이미지를 캐시에 저장하는 것과 같은 방식으로 말이다. 유지보수와 비용 관점에서도 각각의 서비스는 필요한 대로 독립적으로 확장 가능하다. 만약 이 둘이 서로 독립적이지 않은 채 서로 결합된 형태라면 어떤 하나가 다른 하나의 성능에 영향을 주게 된다.

이중화(Redundancy)


장애에 대처하기 위한 웹 아키텍처에는 데이터와 서버에 대한 이중화가 고려되어야 한다. 예를 들어 어떤 한 파일이 어느 한 서버에만 저장되어 있는 상황이라면, 해당 서버에 장애가 발생할 때 그 파일을 잃어버리게 될 것이다. 데이터를 잃어버리지 않는 가장 보편적인 대처법은 여러 서버에 데이터를 복제해 두는 것이다.
같은 원칙이 서비스에도 적용된다. 애플리케이션에 중요한 핵심 기능이 있다면 같은 기능을 하는 여러 개가 동시에 동작하도록 하는 것이 필요하다.
시스템을 이중화하는 것은 단일 고장점(single point of failure)을 없애고, 장애 발생 시에도 백업하게 할 수 있거나 시스템이 계속 동작할 수 있게 한다. 예를 들어, 동일한 서비스를 위해 두 개의 인스턴스가 동작하고 있을 때 하나에 장애가 나면 시스템에서는 문제가 없는 다른 인스턴스만 동작하게 할 수 있다. 이러한 절체 작업은 자동으로 발생하게 할 수도 있고 운영자의 개입이 필요한 경우도 있다.
서비스를 이중화할 때 중요한 것은 Shared Nothing 아키텍처를 만드는 것이다. 이 아키텍처에서는 각각의 노드는 상태나 작업을 관리하는 중앙부 없이 독립적으로 동작 가능하다. 이러한 구조는 시스템이 확장성을 가지고자 할 때 매우 유용하다. 새로운 노드를 특별한 조건이나 지식 없이 추가할 수 있기 때문이다. 그보다 더 중요한 것은 시스템이 단일 고장점을 갖지 않게 된다는 것이다. 따라서 장애에 좀 더 잘 대처할 수 있게 된다.
이미지 서버 애플리케이션을 예로 들어 이러한 이중화를 설명해 보도록 하겠다. 모든 이미지는 각기 다른 하드웨어에(이상적으로는 다른 IDC처럼 지리적으로 다른 위치) 복제 저장이 되며, 요청을 처리하는 동일한 동작을 수행하는 서비스 역시 여러 개가 있을 것이다. <그림 3>이 이러한 아키텍처를 보여 주고 있다(이러한 것이 가능하기 위해서는 로드 밸런서가 필요하다)..
webarchitecture3
그림 3 이중화가 고려된 이미지 호스팅 애플리케이션




파티션(Partitions)


하나의 서버에서 감당할 수 없는 많은 데이터가 있을 수 있다. 또는 연산을 위해 많은 컴퓨팅 자원이 필요하게 되어 성능이 떨어지게 되는 경우가 있을 수 있다. 이러한 문제를 해결하기 위해서는 두 가지 선택이 있다. 하나는 수직적 확장이고, 다른 하나는 수평적 확장이다.
수직적 확장은 개개의 서버에 더 많은 자원을 추가하는 것을 말한다. 많은 데이터를 처리하기 위해 서버에 하드 디스크를 추가하는 것이 이에 해당한다. 더 빠른 계산 성능을 위해 더 빠른 CPU나 큰 용량의 메모리를 추가하는 것 역시 마찬가지다. 즉 수직적 확장은 각 자원의 처리 능력을 향상시키는 것을 말한다.
반면 수평적 확장이란 노드를 추가하는 것을 말한다. 데이터가 많을 경우에는 부분 데이터를 저장할 수 있는 노드를 추가하는 것이다. 많은 연산을 필요로 하는 경우에는 연산을 분리하여 추가한 노드에서 작업이 이루어지도록 한다. 수평적 확장의 장점을 모두 취하기 위해서는 시스템 아키텍처의 고유한 설계 원칙들을 따라야 한다. 그렇지 않으면 기능 단위를 수정하거나 분리하는 것이 굉장히 불편한 일이 되어 버릴 수도 있다.
수평적 확장을 하는 가장 보편적인 방법은 서비스를 파티션이나 샤드 단위로 분할하는 것이다. 파티션은 기능별 논리 집합으로 분산될 수 있다. 이러한 파티션은 특정 사용자나 데이터의 지리적인 위치에 따라 만들어질 수 있고, 혹은 무료 사용자냐 유료 사용자냐와 같은 기준에 따라 만들어질 수도 있다. 이러한 형태의 장점은 증설한 것을 바탕으로 서비스나 데이터 저장소를 제공할 수 있다는 것이다.
이미지 서버 예제 아키텍처에서는 이미지를 저장하기 위해 사용하는 하나의 파일 서버는 여러 개의 파일 서버로 대체 될 수 있다. 그리고 서버마다 처리하는 이미지들이 다르다(그림 4). 이런 아키텍처에서는 디스크가 가득 차는 상황이 발생할 때 다른 서버에 파일을 저장할 수 있게 한다. 이 설계에서는 이미지의 이름을 바탕으로 해당 이미지가 저장되어 있는 서버를 찾을 수 있는 방법이 필요하다. 이미지 이름은 서버를 가리킬 수 있는 Consistent Hashing 형태일 수도 있다. 혹은 이미지의 이름은 증가되는 ID가 될 수 있으며, 이 경우에는 이미지 조회 서비스는 각 서버에 저장되어 있는 ID 범위만 알고 있으면 된다(인덱스처럼).
webarchitecture4
그림 4 이중화와 수평 확장이 고려된 이미지 호스팅 애플리케이션


물론 여러 서버에 데이터나 기능을 분산시키는 것에는 기술적인 도전이 있다. 여러 핵심 이슈 중 하나는 데이터 로컬리티다. 연산하려는 데이터가 가까이 위치해 있을 수록 시스템의 성능은 향상된다. 따라서 필요한 데이터를 여러 서버에 분산시키는 것은 로컬에 있지 않을 수 있는 데이터를 얻기 위해 비용이 높은 네트워크를 이용한 읽기가 발생할 수 있어 잠재적인 성능 문제가 발생할 수 있다.
또 다른 잠재적 이슈는 비정합성이다. 공유된 자원으로부터 읽기와 쓰기를 하는 서로 다른 서비스가 있다고 가정해 보자. 여기서는 경합조건이 발생할 가능성이 있다. 어떠한 데이터가 업데이트되려 할 때, 읽기 요청이 업데이트 요청보다 먼저 발생했다면 해당 데이터는 비정합성 상태가 된다. 예를 들어 어떤 클라이언트가 어떤 이미지 이름을 Dog에서 Gizmo로 바꾸는 업데이트 요청을 보냈고, 동시에 다른 클라이언트가 해당 이미지를 읽고 있다면 경합조건이 발생한다. 이러한 상황에서 두 번째 사용자가 Dog라는 이름으로 받게 될지, Gizmo라는 이름으로 받아야 할지 명확하지 않다.
데이터를 파티셔닝하는 것에는 난관이 있지만 파티셔닝은 각각의 문제(데이터, 로드, 사용 패턴 등)를 관리할 수 있는 조그마한 단위로 분리시킬 수 있게 한다. 이러한 점은 확장성과 관리성에 도움을 주지만 위험이 없지는 않다. 이러한 위험 요소를 줄이고 장애를 해결하는 많은 방법이 있다.

빠르고 확장성 있는 데이터 액세스를 위한 빌딩 블록

지금까지는 분산 시스템을 설계하는 데에 있어서 핵심적인 고려 사항에 대해 알아 보았다. 이 장에서는 어려운 부분인 데이터 액세스 확장에 대해 설명하려 한다.
간단한 예를 통해 확장성 있는 데이터 액세스가 왜 어려운지 확인해 보도록 하자. LAMP 스택을 이용하여 만드는 애플리케이션같이 대부분의 간단한 웹 애플리케이션은 <그림 5>와 같은 구조를 가지고 있다.
webarchitecture5
그림 5 간단한 웹 애플리케이션
이렇게 간단한 형태의 웹 애플리케이션을 많은 사용자가 사용하게 되면 두 가지 기술적인 문제에 직면하게 된다. 하나는 애플리케이션 서버에 대한 데이터 액세스를 확장성 있게 하는 것이고, 다른 하나는 데이터베이스에 대한 데이터 액세스를 확장성 있게 하는 것이다. 확장성이 있도록 설계된 애플리케이션에서는 애플리케이션 서버(혹은 웹 서버)는 최소화되고 때로는 shared-nothing 아키텍처를 가진다. 이 때문에 애플리케이션 서버 레이어는 수평적 확장이 가능해지게 된다. 그렇기 때문에 데이터베이스 레이어를 확장성 있게 만들 숙제만이 남게 된다. 데이터베이스 레이어는 확장성과 성능 향상을 위하여 많은 고민과 도전이 이루어지는 부분이다.
이 글 나머지 부분에서는 빠른 데이터 액세스와 확장성을 위해 사용하는 일반적인 전략과 방법들에 대해 다룰 것이다.
webarchitecture6
그림 6 매우 단순화되어 있는 웹 애플리케이션 다이어그램

대부분은 시스템은 <그림 6>과 같이 단순화시킬 수 있다. 이러한 형태는 단순하지만 문제를 해결하기 위한 굉장히 좋은 출발점이 된다. 많은 데이터가 있을 때, 우리는 데이터의 빠르고 쉬운 액세스를 원한다. 책상의 맨 위 서랍에 평소 자주 먹는 캔디를 보관하는 것처럼 말이다. 이러한 예는 심하게 단순화되어 있음에도 확장성과 빠른 데이터 액세스라는 두 가지 어려운 문제에 대한 힌트를 제공하고 있다.
설명을 돕기 위해서 수 테라바이트 크기의 데이터가 있다고 가정해 보자. 그리고 우리는 사용자가 원하는(랜덤한) 데이터에 접근할 수 있도록 하고 싶다(그림 7). 이는 이미지 애플리케이션 예에서 이미지 파일을 특정한 파일 서버에 위치시키는 것과 유사한 구조다.
webarchitecture7
그림 7 특정 데이터에 접근
수 테라바이트 크기의 데이터를 메모리에 올리는 것은 매우 높은 비용이 필요하기 때문에, 모든 데이터를 메모리에 저장하지 않으면서도 빠른 액세스가 가능하도록 하는 것은 매우 어려운 도전 과제가 된다. 여기서 성능에 가장 영향을 미치는 것은 디스크 I/O다. 디스크에서 데이터를 읽는 것은 메모리에서 데이터를 읽는 것보다 훨씬 더 느리다. 데이터가 많을 때에는 이러한 속도 차이가 더 크게 나타난다. 실제로 메모리 액세스는 디스크 순차적 읽기에 비하여 최소 6배 빠르고, 디스크 랜덤 읽기에 대해서는 십만 배 더 빠르다(참고:http://queue.acm.org/detail.cfm?id=1563874). 게다가 유일 식별자(unique ID)처럼 크기가 작은 데이터를 찾는 것 또한 매우 어려운 일이 될 수도 있다. 이는 흡사 방안의 어딘가에 있는 사탕을 눈을 감고 찾는 것과 같다.
고맙게도 이런 것들을 쉽게 하기 위한 다양한 방법이 있다. 그 중 가장 중요한 네 가지 방법에는 캐시, 프락시, 인덱스, 그리고 로드 밸런서가 있다. 각각의 방법이 어떻게 데이터 액세스를 빠르게 하는지에 대해서 알아보려 한다.

캐시

캐시는 최근에 요청받은 데이터는 다시 요청받을 확률이 높다는 지역성의 원리(locality of reference)에 기반한 방법이다. 캐시는 하드웨어, 운영체제, 웹 브라우저, 웹 애플리케이션 등 다양한 곳에서 사용하고 있다. 캐시란 매우 짧은 시간 동안 유지되는 메모리와 같은 것이다. 캐시 용량은 매우 제한적이지만, 통상적으로 원래의 데이터 저장소보다는 매우 빠르고 자주 액세스되는 데이터를 보유하고 있다. 캐시는 아키텍처의 모든 단계에 위치할 수 있지만, 프런트엔드와 가까운 곳에 위치하는 경우가 많다. 왜냐하면 보통 캐시는 서비스의 백 엔드까지 가는 시간적인 비용을 줄이기 위해서 사용하는 경우가 많기 때문이다.
예제로 사용하는 이미지 서버 아키텍처에서 빠른 데이터 액세스를 가능하게 하기 위하여 캐시를 어디에서 사용할 수 있을까? 이 경우 캐시를 추가할 수 있는 두 가지 선택이 있다. 하나는 <그림 8>과 같이 캐시를 요청 노드에 추가하는 방법이다.
webarchitecture8
그림 8 요청 레이어 노드에 캐시 추가하기

캐시를 요청 노드에 배치하는 것은 응답 데이터를 로컬 저장 공간에서 가져올 수 있게 한다. 매번 요청은 서비스로 보내지고, 요청 노드에 데이터가 존재하면 그 노드는 재빠르게 로컬에서 캐싱된 데이터를 보낸다. 만약 캐시에 데이터가 없다면 요청 노드는 디스크에서 데이터를 질의할 것이다. 여기서 캐시는 요청 노드의 메모리에 있을 수도 있고(매우 빠를 것이다) 요청 노드의 로컬 디스크에 존재할 수 있다(네트워크 스토리지를 사용하는 것보다 빠르다).
webarchitecture9
그림 9 여러 개의 캐시

이러한 요청 노드를 여러 개로 확장하면 어떤 일이 일어날까? <그림 9>에서 볼 수 있듯 요청 노드를 여러 개로 확장시키면 각 노드가 각각의 캐시를 가질 수 있게 된다. 그러나 만약 로드 밸런서가 임의로 요청을 분산시키면, 같은 요청이 다른 노드로 가게 될 수도 있다. 즉, 캐시 미스가 증가하게 될 것이다. 캐시 미스를 줄이면서 여러 개의 캐시를 사용하기 위해 사용하는 방법이 전역 캐시와 분산 캐시다.

전역 캐시(Global Cache)

전역 캐시는 말 그대로 모든 노드가 오직 하나의 캐시 공간만을 사용한다. 전역 캐시는 서버나 어떤 종류의 파일 저장소를 추가해도 잘 동작하고, 원래의 저장소보다 빠르며 모든 요청 레이어 노드에서 접근이 가능하다.
요청 노드에서 각각의 요청은 로컬에 캐시를 가지고 있는 것과 마찬가지 방법으로 글로벌 캐시에 데이터를 질의한다. 이러한 종류의 캐시 사용은 클라이언트의 개수나 요청 개수가 급격하게 증가하면 하나의 캐시가 그 요청을 감당하지 못할 수도 있기 때문에 복잡해지기 쉽다. 하지만 이러한 아키텍처가 특정한 상황에서는 매우 유용하다(특화된 하드웨어를 써서 전역캐시를 빠르게 만들거나, 캐시가 필요한 데이터의 양이 고정된 일정량일 때).
전역 캐시에는 두 가지의 일반적인 방식이 있다. <그림 10>에서 설명하는 전역 캐시 아키텍처는 데이터 노드는 오직 캐시에만 데이터를 질의하고, 전역 캐시는 요청받은 데이터를 자기 자신에서 찾을 수 없을 때, 캐시 스스로가 저장 공간에 데이터를 질의하여 요청 노드에 데이터를 전달하도록 하는 방식이다. 반면 <그림 11>의 아키텍처는 요청 노드가 전역 캐시에서 데이터를 질의하여 데이터가 없음을 확인하였을 때는 직접 스토리지에 질의하여 데이터를 가져오는 방식이다.
webarchitecture10
그림 10 캐시가 검색을 책임지는 전역 캐시
webarchitecture11
그림 11 요청 노드가 검색을 하는 전역 캐시

전역캐시를 사용하는 대부분의 애플리케이션은 같은 요청이 여러 요청 노드로부터 발생하는 것을 막기 위해 캐시 스스로가 데이터 축출과 조회를 직접하는 <그림 10>과 같은 아키텍처를 사용한다.
그러나 <그림 11>과 같은 경우가 더 유용할 때도 있다. 예를 들어 큰 크기의 파일 제공을 위하여 캐시를 사용하는 경우에, 낮은 캐시 히트가 발생하면 전반적인 캐시 미스가 증가하게 된다. 이 경우에는 자주 사용되는 데이터만 캐시에 위치하게 하는 것이 도움이 된다. 또 다른 예로는 정적 파일을 캐시에 저장하는 경우다. 이 경우에는 정적 파일은 절대 캐시에서 지워지지 않는다(레이턴시에 대한 애플리케이션 요구사항 때문이다. 많은 데이터 처리를 위하여 데이터 일부는 빠르게 전송될 필요가 있을 수 있다. 이런 경우에는 캐시 자체보다는 애플리케이션 로직이 캐시 축출이나 핫 스팟 관리를 하는 것이 좋다)..

분산 캐시(Distributed Cache)

분산 캐시는 <그림 12>에서 보듯 각각의 노드가 캐시 데이터를 갖는 방식이다. 냉장고를 캐시, 그리고 식료품점을 저장 공간이라 비유해 보자. 그렇다면 분산 캐시는 식료품점에서 산 음식을 냉장고, 선반, 도시락과 같이 여러 곳에 나누어 두는 것과 유사하다. 가게에 갈 필요 없이 자주 먹는 것들을 집안에서 빠르게 찾는 것처럼 말이다. 일반적으로 분산 캐시는 consistent hashing 함수를 사용한다. 따라서 요청 노드가 어떤 특정한 데이터 조각을 찾으려 할 때 해시 함수를 이용해 분산 캐시 내의 어디에서 데이터를 찾을 수 있는지 알 수 있다. 각각의 노드는 각각의 조그마한 캐시를 가지고 있다. 그리고 요청이 들어오면 원본 저장 공간으로 요청을 보내기 전에 다른 노드에 요청을 보낸다. 분산 캐시의 이런 점 때문에 요청 풀에 노드를 추가하면 전체 캐시 크기를 증가시킬 수 있다.

분산 캐시의 단점은 장애가 발생한 노드를 처리하는 방법이 필요하다는 것이다. 다른 노드에 여러 개의 복제본을 가지는 방법으로 해결하기도 한다. 이런 방식을 사용하면 문제 노드를 처리하기 위한 로직이 복잡해지기 십상이다. 요청 레이어에 새로운 노드를 추가하거나 제거하려 할 때 특히 그렇다. 물론 노드가 사라지고 캐시의 일부분이 분실되더라도 원본 데이터에 요청함으로써 필요한 데이터를 가져올 수 있다. 그렇기 때문에 분산 캐시에 장애가 발생한다고 해서 총체적인 장애가 발생하는 것은 아니다.
다고 해서 총체적인 장애가 발생하는 것은 아니다.
webarchitecture12
그림 12 분산 캐시
캐시의 장점은 캐시가 올바르게만 구현되어 있다면 시스템을 더욱 빠르게 만들 수 있다는 것이다. 캐시를 이용해 더욱 더 많은 요청을 이전보다 더 빠르게 처리하게 할 수도 있다. 그러나 이러한 캐시 시스템에는 일반적으로 값 비싼 메모리와 같은 추가적인 저장 공간을 유지하기 위한 비용 문제가 항상 따른다. 공짜는 없다. 캐시는 빠른 처리를 가능하게 하는 굉장히 훌륭한 방법이다. 또한 캐시가 없다면 총체적인 성능 저하가 발생할 수 있는 심한 부하가 발생한 상황에서도 캐시가 있다면 완전하게 시스템의 기능들이 동작하게 하도록 할 것이다.
오픈소스 캐시 중 인기 있는 것 중 하나는 Memcached(http://memcached.org/) 다(로컬캐시나 분산캐시 두 가지 모드로 동작 가능하다). 또한, 언어와 프레임워크에 따라서 선택 가능한 다른 오픈소스도 많이 있다.
Memcached는 많은 웹 사이트에서 사용된다. Memcached는 간단한 메모리 키-값 저장소임에도 굉장히 우수한 성능을 보여 준다. 그리고 임의의 데이터 저장과 조회가 O(1)로 최적화되어 있다.

Facebook에서는 웹 사이트의 성능 향상을 위해서 몇 가지 종류의 캐시를 사용한다("Facebook caching and performance" 참고). PHP에서 함수 콜로 사용이 가능한 $GLOBALS와 APC 캐시를 사용한다(대부분의 언어 환경에서는 웹 페이지 성능을 향상시킬 수 있는 이런 라이브러들이 있으며, 이런 것들은 반드시 항상 사용되어야 한다). Facebook은 또한 많은 서버에 분산되어 있는 전역 캐시를 사용한다("Scaling memcached at Facebook" 참고). 단 한 번의 함수 콜로 서로 다른 Memcached 서버에 접근하는 병렬 요청을 만들어 낼 수 있다. 이러한 방식 덕분에 사용자의 프로필 정보를 볼 때 성능과 처리량을 향상시킬 수 있었다. 그리고 Facebook은 데이터를 업데이트하기 위해서 하나의 중앙 공간을 사용한다(이것이 중요한 이유는 수천 여 개의 서버가 동작하고 있을 때 캐시 유효성과 데이터 정합성을 유지하는 것이 기술적으로 굉장히 어려운 문제이기 때문이다).
이제는 데이터가 캐시에 없는 경우일 때에 대해서 이야기해 보자.

프락시

기본적으로 프락시 서버라 하면 클라이언트의 요청을 백엔드 서버에 전달하는 역할을 하는 중간의 하드웨어 혹은 소프트웨어를 의미한다. 프락시는 요청을 필터링, 로깅, 변환(헤더에 속성 더하고/빼고, 암호화/복호화, 압축)하는데 사용한다.
webarchitecture13
그림 13 프락시 서버
프락시는 여러 서버에서 오는 요청을 받아 정리하여, 전체 시스템 관점에서 요청 트래픽을 최적화시키는 데도 도움이 된다. 데이터 액세스를 빠르게 하기 위하여 프락시가 제공하는 방법 중의 하나로 Collapsed Forwarding이라 부르는 것이 있는데, 같거나 비슷한 요청들을 모아 단 하나의 요청을 만들어 내는 것을 말한다.
여러 개의 노드에서 littleB라고 하는 같은 데이터를 요청한다고 생각해보자. 그리고, 요청 데이터는 캐시에 없다. 만약 요청이 프락시를 지나가게 된다면 똑같은 요청들은 하나의 요청으로 바뀔 것이고 단 한번의 littleB 읽기 연산이 발생한다(그림 14). 물론 요청을 그룹화하는 데 드는 시간 때문에 각각의 요청에는 더 많은 레이턴시가 발생할 수도 있다. 그러나 부하가 높은 상황에서는 성능이 향상될 것이다. 특히 똑같은 데이터가 반복적으로 읽힐 때 말이다. 이것은 캐시와 비슷하지만, 캐시가 데이터나 문서를 저장하는 것과는 다르다. 프락시는 여러 클라이언트가 원하는 문서를 제공하기 위해 요청이나 콜을 최적화시키는 방법이다.

예를 들어 LAN 프락시에서는 클라이언트들이 인터넷에 연결하기 위해 모두 각각의 IP를 가질 필요가 없다. 그리고 LAN은 같은 내용을 요청하는 경우에는 호출은 한 번만 한다(collapse). 대부분의 프락시가 캐시이기도 하기 때문에 개념상 혼란스러울지 모르겠다. 하지만 모든 캐시가 프락시처럼 동작하는 것은 아니다.
webarchitecture14
그림 14 요청을 collapse하기 위해 프락시 서버 사용

그림 14 요청을 collapse하기 위해 프락시 서버 사용
프락시를 사용하는 다른 방법으로는 공간적으로 가까운 데이터에 대한 요청을 묶어주는 것이 있다. 이러한 전략은 요청의 데이터 로컬리티를 최대화하여, 요청 지연을 줄일 수 있다. 예를 들어, 수많은 요청이 B의 일부분을 요청하고 있다고 생각해보자. B:partB1, B:partB2와 같이 말이다. 우리는 공간적 로컬리티를 인식하는 프락시를 설치한다. 그리고, 프락시는 bigB를 요청 한다(그림 15). 이러한 방식은 클라이언트가 수 테라바이트 크기 데이터의 일부분을 랜덤하게 요청할 때 요청 시간을 굉장히 단축시킬 수 있다. 프락시는 여러 번의 요청을 한 번에 처리하기 때문에 높은 로드 상황이나 캐시 사용이 제한적인 상황에서 특히 유용하다.
webarchitecture15
그림 15 지역적으로 가까운 데이터 요청을 collapse하는 프락시
프락시와 캐시를 함께 사용하는 것은 무의미하지만, 같이 사용하게 될 때에는 캐시를 프락시 앞에 두는 것이 최선이다. 많은 사람들이 참여하는 마라톤 레이스에서 빠른 주자들이 먼저 출발하는 것처럼 말이다. 캐시는 데이터를 메모리에서 가져오고 보통은 매우 빠르다. 그리고 같은 결과를 반환하는 여러 개의 요청도 문제가 되지 않는다. 이 때문에 프락시가 캐시 앞에 있으면 캐시로 요청이 오기 전에 추가적인 지연만 생기고 성능이 저하된다.
만약 시스템에 프락시를 추가하고 싶다면 고려할 만한 몇 가지 옵션이 있다. Squid와 Varnish는 모두 다양한 웹 사이트에서 널리 사용되고 있다. 이러한 프락시 솔루션은 클라이언트와 서버 통신을 최대로 끌어내기 위해 많은 최적화 방법을 제공한다. 또한, 이 둘 중 하나를 웹 서버 레이어에 리버스 프락시(로드 밸런서에서 설명한다)로 설치하는 것도 웹 서버의 성능을 상당히 높인다.

인덱스

빠른 데이터 액세스를 위해서 인덱싱 전략을 사용하는 것은 굉장히 잘 알려져 있는 방법이다. 아마도 인덱스 하면 떠오르는 것은 데이터베이스일 것이다. 인덱스를 사용하게 되면 데이터 양이 증가할 때 쓰기가 느려지게 된다. 왜냐하면 쓰기를 할 때에는 데이터 기록과 아울러 빠른 읽기를 위해 인덱스를 업데이트해야 하기 때문이다.
일반적인 관계형 데이터베이스에서처럼 이러한 개념이 많은 데이터를 다룰 때에도 적용된다. 물론 인덱스를 이용할 때는 사용자들이 어떠한 식으로 데이터에 접근할지에 대한 충분한 고려가 필요하다. 데이터 크기가 수 테라바이트지만 전달해야 할 데이터 크기가 작을 때는(예를 들어 1KB정도), 데이터 액세스를 최적화하기 위해 인덱스는 필수적이다. 색인이 없이 엄청나게 많은 데이터셋에서 극히 일부를 찾는 것은 굉장한 도전이다. 왜냐하면 수용할 수 있을 만한 시간 안에 반복적으로 데이터를 액세스하여 원하는 데이터를 찾는 것이 불가능하기 때문이다. 더구나, 실제로 이러한 큰 데이터셋은 하나의 물리적 장치에 있는 것이 아니라 여러 곳 혹은 엄청나게 많은 물리적 장치에 나뉘어 위치해 있을 수 있다. 이 말은 우리가 원하는 데이터가 어디에 있는지 알 수 있는 방법이 필요하다는 것이다. 이를 해결하기 위한 가장 좋은 방법이 인덱스다.
는 것이다. 이를 해결하기 위한 가장 좋은 방법이 인덱스다.
webarchitecture16
그림 16 인덱스
인덱스는 목차와 같이 데이터가 어디에 위치하는지 알려주는 역할을 한다. 예를 들어 B의 part 2에 있는 데이터를 찾고 있을 때, 어디에서 찾을지 어떻게 알 수 있을까? 만약 데이터 타입에 따라서 정렬된 색인이 있다면(A, B, C라하는) 색인을 통해서 data B의 원본 위치를 알 수 있다. 그리고 나면 B에서 실제 원하는 데이터를 가져올 수 있게 된다(그림 16).
이러한 색인은 보통은 메모리에 있거나, 들어오는 클라이언트 요청과 굉장히 가까운 곳에 위치해 있다. BerkeleyDB와 트리 형태의 데이터 구조는 이러한 정렬된 리스트를 저장하고 색인을 사용하는 이상적이고 보편적인 방법이라 할 수 있다

때때로 맵의 형태를 가진 여러 개의 레이어로 이루어진 인덱스도 있다.이런 인덱스에서는 특정한 데이터를 얻을 때까지 한 위치에서 데이터가 있는 다음 위치로 이동할 수 있게 한다(그림 17).
webarchitecture17
그림 17 멀티 레이어 인덱스
인덱스는 같은 데이터를 다른 여러 개의 뷰로 만드는데 사용할 수 있다. 특히 많은 데이터를 다룰 때 추가적인 데이터 복사본을 사용하여 재정렬하지 않고, 다른 필터를 정의하고 정렬할 수 있다는 것은 인덱스의 엄청난 장점이다.

예를 들어, 이미지 호스팅 시스템에서 책의 페이지를 이미지로 호스팅한다고 생각해보자. 클라이언트는 책의 이미지를 텍스트로 찾고, 시스템은 그에 해당하는 책의 내용을 찾아 준다. 이때 모든 책의 이미지는 파일로 저장되기 위해서 많은 서버를 사용한다. 그리고 원하는 페이지를 찾아서 화면에 랜더링해서 보여줄 수도 있다. 임의의 단어나 단어 집합으로의 접근이 굉장히 쉬워야 하기 때문에 색인 뿐 아니라 인버티드 인덱스가 필요하다. 그리고 정확한 페이지의 위치로 가는 데 추가적인 방법이 필요하다. 그리고 그 페이지에 해당하는 정확한 이미지를 찾을 수 있어야 한다. 그림에서는 인버티드 인덱스가 책 B를 가리키는 데 사용되고, B는 모든 단어들의 위치와 등장 회수에 대한 인덱스를 가지고 있다.
인버티드 인덱스는 다이어그램에서 index1으로 표현될 수 있는데, 각각의 단어나 단어 집합을 가지고 있는 책 목록을 가리키고 있는 것으로 이해할 수 있다.
Word(s)
Book(s)
being awesome
Book B, Book C, Book D
always
Book C, Book F
believe
Book B
중간 인덱스(intermediate indexes)는 인버티드 인덱스와 비슷하게 보이겠지만, 인버티드 인덱스와 달리 단어들, 위치, 그리고 book B에 대한 정보만을 가지고 있다. 이러한 중첩된 인덱스(nested indexes) 구조는 하나의 큰 인버티드 인덱스를 사용하는 것보다 적은 공간을 사용할 수 있게 한다..

규모가 큰 시스템에서 중간 인덱스는 매우 중요하다. 데이터가 많다면 압축이 된다 하더라도 인덱스 크기는 꽤 클 수 있고 저장하는 것에도 그만큼의 비용이 필요하기 때문이다. 만약 전 세계에 약 1억 권의 책이 있다고 가정해 보자(Inside Google Books 참고). 그리고 (계산을 간단하게 하기 위해) 각 책은 10페이지 분량이라고 해보자. 각 페이지에는 250개의 단어가 있다면, 전 세계 책들에 쓰인 단어는 총 2500억 개가 된다. 각각의 단어가 평균 5개의 알파벳으로 구성되어 있다면 한 단어당 5바이트가 필요하게 된다(물론 2바이트 이상이 필요한 문자도 많기는 하지만). 그렇다면 각각의 단어에 대해서 인덱스를 만드는 것은 테라바이트 단위가 넘는 저장 공간이 필요하다는 결론이 내려진다. 여기에 단어 군에 대한 정보나, 데이터의 위치에 대한 정보, 단어 발생 횟수에 대한 정보를 만드는 것을 추가해야 한다면 더욱 많은 저장 공간이 필요하게 된다.

이렇게 중간 인덱스를 만들고 데이터를 더 작은 섹션으로 나타내는 것은 많은 데이터를 다루기 쉽게 해준다. 데이터는 많은 서버에 퍼져 있지만 인덱스를 이용하기 때문에 빠르게 접근 가능하다. 인덱스는 정보 검색의 초석이며 오늘날 검색 엔진의 기본이다. 이 글에서는 아주 기본적인 내용만 다루고 있지만, 인덱스를 어떻게 하면 작고, 빠르게, 더 많은 정보를 다룰 수 있게 하고 아주 매끄럽게 업데이트할지 (경합 조건이 발생하지 않도록 것과 검색 적합도 점수를 관리하는 상황에서 대량의 업데이트를 하기 위해 새 데이터를 추가하거나 기존 데이터를 바꿀 때에 대한 기술적인 문제가 남아 있다)에 대한 이슈에 대해서는 수많은 연구가 진행되고 있다.

데이터를 빠르고 쉽게 찾을 수 있게 하는 것은 중요하다. 그리고 인덱스는 이를 가능하게 하는 효과적이고 간단한 도구다.

로드 밸런서(Load Balancers)

마지막으로 분산 시스템에서 중요한 것 중에 하나로 로드 밸런서가 있다. 로드 밸런서는 어떤 아키텍처에서든 중요한 것이다. 로드 밸런서는 서비스 요청을 여러 노드에게 분배하는 일을 한다. 즉 로드 밸런서를 이용하여 하나의 시스템에서 여러 개의 노드가 투명하게 서비스 안에서 똑같은 기능을 수행할 수 있게 한다(그림 18). 로드 밸런서의 주 목적은 동시에 오는 수많은 커넥션을 처리하고 해당 커넥션이 요청 노드 중의 하나로 전달될 수 있게 하는 것이다. 그리고 단지 노드를 추가하는 것만으로 서비스가 확장성을 가질 수 있도록 한다.
webarchitecture18
그림 18 로드 밸런서
로드 밸런서에서 서비스 요청을 처리하는 방법에는 다양한 알고리즘이 있다. 랜덤, 라운드 로빈, CPU나 메모리 사용률 등과 같은 특정 범주에 따라 노드를 선택하는 등의 방법이 있다. 로드 밸런서는 소프트웨어로 구현될 수도 있고 하드웨어 제품이 될 수도 있다. 오픈소스 로드 밸런서 중 많이 사용되고 있는 것은 HAProxy이다.
분산 시스템에서 로드 밸런서는 들어오는 모든 요청이 거쳐가는 시스템의 프런트엔드에 위치하고는 한다. 복잡한 분산 시스템에서는 <그림 19>에서처럼 여러 개의 로드 밸런서를 사용하는 것이 드문 경우가 아니다.
webarchitecture19
그림 19 여러 개의 로드 밸런서
프락시처럼 어떤 로드 밸런서는 요청의 종류를 파악하고 해당 요청을 처리할 수 있는 노드에 전달하는 기능을 가지고 있다(기술적으로 이러한 형태를 리버스 프락시라고 부른다).

로드 밸런서를 사용할 때 어려운 문제 중 하나는 세션 데이터를 관리하는 것이다. 온라인 쇼핑 사이트에서 서비스를 이용하는 사용자가 단 한 명뿐이라면 쇼핑 카트에 담아 놓은 상품들을 해당 사용자가 다음에 방문할 때까지 유지하는 것은 그다지 어려운 문제가 아니다(사이트에 재방문하였을 때 쇼핑카트에 이전에 담아 놓은 상품이 있다면 구매 확률이 높아지기 때문에 이것은 매우 중요한 기능이다). 그러나 방문 때마다 다른 세션을 사용한다면 유저의 카트 정보를 일관성 있게 유지할 수 없게 된다. 이러한 문제를 해결하는 한 가지 방법은 세션을 고정하도록(session sticky) 하는 것이다. 이 방법으로 사용자의 요청이 전달될 노드를 고정시킬 수가 있다. 그러나 이 방식은 자동 절체와 같은 신뢰성과 관련된 기능을 사용할 수 없게 한다. 왜냐하면 사용자의 쇼핑카트에는 항상 같은 콘텐츠가 있어야 하지만 요청을 처리했던 노드에 문제가 생겨서 절체가 발생하면 해당 정보가 유지되지 않기 때문이다. 그렇기 때문에 해당 노드가 비활성화되면 해당 세션의 데이터는 더 이상 유효하지 않다고 판단하는 고려가 필요하다(가급적 애플리케이션 차원에서 이에 대한 고려를 하지 않는 것이 좋겠지만). 물론 이러한 세션 문제는 브라우저 캐시, 쿠키, URL Rewriting 같은 기술을 이용하여 해결할 수도 있다.

단지 몇 개의 노드만 있을 뿐이라면 라운드 로빈 DNS와 같은 방식이 합리적일 것이다. 로드 밸런서 자체의 비용이 높기도 하지만 불필요한 복잡함을 증가시킬 수도 있기 때문이다. 물론 대규모의 시스템에서는 랜덤이나 라운드 로빈같은 단순한 방식은 물론이고 시스템 사용률이나 처리량을 고려한 복잡한 방식을 사용하는 다양한 알고리즘과 스케쥴링을 사용하고 있다. 이러한 로드 밸런싱 알고리즘은 모두 네트워크 트래픽과 분산 요청을 제어하면서 자동 절체나 이상 노드 제거(응답이 없을 때)와 같은 신뢰성 관련한 기능을 제공하기도 한다. 그러나 이러한 향상된 기능이 장애 분석을 방해하기도 한다. 예를 들어 부하가 굉장히 높은 상황에서 로드 밸런서는 느리거나 타임아웃이 발생한 노드를 제거한다(요청이 많기 때문에). 그러나 이런 일은 다른 노드의 상황을 악화시킬 뿐이다. 이런 경우에 대비하기 위하여 시스템의 전체적인 상황을 파악할 수 있는 모니터링이 중요하다. 왜냐하면 노드가 줄어들어 전체 시스템 트래픽과 처리량이 줄어든 것으로 이해할 수 있지만, 실제로 각각의 노드는 최대 부하치에 이르고 있는 상황일 수 있기 때문이다.

로드 밸런서는 시스템 용량을 확장시키는 쉬운 방법이다. 그리고 이 글에서 다루는 다른 기술들과 마찬가지로 분산 시스템 아키텍처에서는 필수적인 것이라고 할 수 있다. 로드 밸런서는 노드에 대한 헬스체크 기능을 제공하기도 한다. 반응이 없거나 과부하 상태에 있는 노드를 풀에서 제거하여 시스템 이중화의 장점을 이용할 수 있게 한다.



댓글

이 블로그의 인기 게시물

(18장) WebSocekt과 STOMP를 사용하여 메시징하기

(네트워크)폴링방식 vs 롱 폴링방식

(ElasticSearch) 결과에서 순서 정렬