레디스를 활용한 스케일아웃 레시피


제가 예전에 수행했던 어떤 프로젝트는 웨어러블 장비를 통해 수집된 심전도 데이터를 안드로이드 기기로 전송하여 24시간에서 72시간 정도 취합한 뒤 서버로 업로드 하면 별도의 딥러닝 분석 모듈을 통해서 사용자의 심장 건강에 문제가 있는지 체크해주는 애플리케이션들을 만드는 프로젝트 였습니다. 심전도, 웨어러블, 딥러닝 등 뭔가 최신기술의 집약체를 나타내는 듯한 단어들이 많이 나온 것에 비해서 전체적인 아키텍처는 단순한 편이었습니다. 웨어러블 장비와 안드로이드 장비간의 연결은 블루투스, 안드로이드 장비는 수집한 데이터를 Rest API와 Multipart Request를 이용하여 서버에 업로드 하고, 서버는 이 데이터를 일부 정제하여 분석 서버에 넘기고, 분석 서버는 쉘 프로그램을 수행하여 분석을 수행하고 그 결과를 다시 DB에 집어넣습니다.

ECG Analytics Flow

당시에는 마이크로 서비스 아키텍처에 대한 논의가 활발히 이루어지고 있었고, 저희 프로젝트도 MSA에 대한 생각을 하지 않을 수는 없는 상황이었습니다. 하지만 전체 프로젝트를 적절히 분할하고 다양한 기술들을 적용하여 MSA의 좋은 사례를 만들어내는 것은 개발이 진행중인 상황에서는 부담이 많이 가는 일이었고, 그래서 저희 프로젝트는 시범적으로 데이터를 분석하는 분석 서버만 마이크로 서비스 형태로 분리하는 형태의 파일럿을 수행해보기로 결정했습니다.

사실 기존 아키텍처에서도 분석 서버는 별도로 분리된 서버이기는 했습니다. 하지만 모바일 애플리케이션으로부터 데이터를 업로드 받는 애플리케이션 서버와 환자 및 분석 데이터들을 보여주기 위한 웹 애플리케이션 서버, 그리고 데이터를 분석하는 분석 서버가 모두 같은 DB를 사용하고 있었기 때문에 이를 이미 마이크로 서비스로 구성되었다고 주장하기는 다소 어려웠습니다. 그래서 분석 서버를 위한 별도의 저장소를 구성하여 애플리케이션 서버와 분석 서버를 분리한 뒤 그 사이를 일반적인 Rest API 형태의 HTTP 호출로 연결하는 것이 마이크로 서비스 파일럿의 주요 목표였습니다.

사실 당시 저희 분석 서버는 배치 프로그램(Batch Program)과 크론 스케줄러(Cron Scheduler)를 기반으로 데이터를 가져와서 분석을 수행하도록 구성되었기 때문에 웹 서비스를 위한 별도의 인터페이스등을 가지고 있지는 않았습니다. 때문에 Rest API 호출이 가능한 분석 서버를 만들기 위해서는 스프링 프레임워크 등의 백엔드 프레임워크를 이용하여 모종의 서버를 구성한 뒤, 서버 프로그램에서 각 배치 프로그램이나 분석 프로그램을 실행해주는 형태로 구성하는 것이 일반적인 형태가 되었습니다.

물론 일반적인 백엔드 프레임워크에서 Rest API를 제공하도록 만드는 것은 크게 어려운 일이 아니었습니다. 저는 스프링 프레임워크를 이용하여 서버를 만들었는데, 이 경우 컨트롤러에 몇 가지 Annotation을 추가하는 것만으로도 내가 만든 자바 코드를 웹 서비스 형태로 노출시킬 수 있었습니다. 파일 업로드나 API 문서화 역시 파라메터를 추가하거나 Annotation을 추가하는 것으로 쉽게 만들 수 있었는데, 이렇게 만든 서버에서 이미 돌아가고 있던 분석 프로그램을 exec 메소드로 실행해주면 큰 어려움 없이 마이크로 서비스 모양새를 갖출 수 있었습니다.

Running analytics program with shell

이렇게 단순하고 쉽고 빠른 방법으로 목표를 달성할 수도 있겠지만, 사실 실제로는 고민할 부분이 더 많았습니다. 일반적인 개발 프로세스를 생각해보면, 이렇게 새로운 무언가를 만들때는 매우 단순한 구현체를 만들어서 예상되는 문제점이나 어려움도 파악해보고, 피드백도 받고, 계획도 조금씩 수정하면서 점진적으로 개선하여 어느 정도 수준에 올랐을 때 최종 릴리즈를 내놓는 것이 이상적인 프로세스라고 할 수 있습니다.

하지만 명확한 일정과 자원투입 없이 시험삼아서 이루어지는 프로젝트는 안타깝게도 단순한 구현 단계에서 프로젝트가 끝나거나, 상황의 변화에 따라 급하게 반영되어서 릴리즈 되어 버릴 위험성을 항상 가지고 있습니다. 그럴때 남아있는 기술부채(Techinal Debt)는 명시적인 이슈가 터지기 전까지는 항상 그 이자로 개발자의 마음속에 불안감을 쌓아가기 때문에 파일럿 프로젝트의 첫번째 릴리즈를 만드는 작업이라고 해도 꼭 확인해봐야 할 잠재적인 문제점들은 생각하고 넘어갈 필요가 있었습니다.

제가 이 프로젝트를 시작할 때 가장 우선적으로, 그리고 유일하게 고민던 부분은 확장성(Scalability) 이었습니다. 프로젝트의 시작 자체가 마이크로 서비스에 대한 파일럿이었기 때문에 제가 만들고자 하는 서버가 독립적으로 확장될 수 없다면 프로젝트의 의미 자체가 없는 상황이었고, 시간이 오래 걸리는 분석 작업을 수행한다는 비즈니스적인 요구사항을 생각해봐도 확장성이 매우 중요한 상황이었습니다. 개발의 관점에서 보더라도 서버를 하나만 구동할 것을 전제하고 개발하는 것과, 하나가 아닐 것을 고려하고 개발하는 것은 차이가 매우 컸습니다. 그리고 개인적인 경험으로는 하나로만 돌아가게 만든 서버를 N개가 구동될 수 있도록 바꾸는 작업은 이후 개선작업으로 남겨두기에는 조금 기초적인 부분들을 많이 건드리는 작업이었습니다.

Downside of tightly coupled server

이 프로젝트 같은 경우에는 의료정보를 다루는 프로젝트이다 보니까 서버를 이중화 할 때 서로 물리적으로 다른 공간에 위치해야 한다는 법적 규제가 있었습니다. 꼭 법에 명시되어있지 않더라도 안전한 서비스를 위해서 여러 개의 서버를 서로 다른 가용영역에 배치하거나, 다른 나라에 백업 서버를 두거나, 아니면 특정 클라우드 서비스 제공자에 종속되지 않고 여러 클라우드 서비스를 같이 사용하는 등의 구조가 필요할 때가 있습니다. 이럴 때, 서버가 서로 같은 LAN으로 묶이지 않고 조금 더 복잡한 네트워크를 통해서 연결되는 점, 최근에는 서버가 사용량에 따라 자동으로 생성되거나 종료되는 등의 오토 스케일링(Auto Scaling)이 중요하게 부각되고 있는 점, 서버의 IP 주소를 특정하기 어려운 컨테이너 환경의 등장 등등으로 각각의 서버가 IP나 멀티캐스트 기반으로 강하게 엮이는 구조는 최근 트랜드에서는 다소 확장성에 대해 고민이 많아지는 구조라고 할 수 있습니다. 보통 소프트웨어 공학에서 강한 결합을 지양하라고 하는 것과 마찬가지로 서버간의 결합도 되도록 느슨해져야 조금 더 적극적인 확장이 가능할 것 같다는 생각을 했습니다.

하지만 서버간의 연결을 완전히 배제할 수 있을까요? 저희 프로젝트가 엄청나게 흥해서 전세계에서 분석 요청이 마구마구 들어오는 행복한 상황을 가정해서 사고실험을 해보면, 분석 서버는 필요한 만큼 스케일 아웃(Scale-Out) 될 것이고 그 앞에 뭔가 작업을 분배할 수 있는 스위치나 로드 밸런서가 서버들에게 작업을 넘겨줄 것입니다. 그런데 분석을 열심히 수행하던 서버 중 하나에 문제가 생겨서 터져나갈 경우 작업까지 같이 유실될 위험이 있고, 당연하지만 고객의 분석요청이 무시되는 상황은 고객을 화나게 할 수 있는 상황이었습니다. 아시겠지만 저희 직업은 절대 고객분들을 놀라게 하거나 하면 안되는 미션을 가지고 있기 때문에, 서버가 터져나갈 때 작업이 같이 터지는 것을 막으려면 수행하고 있던 작업을 다른 분석 서버로 스무스하게 넘겨줄 필요가 있었습니다. 하지만 이 때 다시 서버끼리 강하게 결합되어야 하는 것이 아닐까? 하는 고민이 생겨나게 됩니다. 개체간의 느슨한 결합이 개발자의 이상향이라면 고객들의 VOC는 개발자의 현실이기 때문에 이런 고민에 해답을 찾지 못한다면 대부분 꿈을 포기하고 현실을 택하는 경우, 그러니까 서버간의 강한 결합을 그냥 선택하는 경우가 많을겁니다. 확장성을 다소 포기하더라도요.

그 외에도 고민해야 할 잠재적인 문제점들은 정말 많았습니다. 당장 데이터에 대한 보안 같은 현실적인 문제점부터 새로운 프로그래밍 언어를 써서 개발한다면 제가 만에 하나 이 부서를 떠나더라도 누군가가 이 프로그램을 계속 발전시켜 나갈 수 있을지등의 군걱정에 이르기까지 다양한 문제점과 위험성들이 있었습니다.

제가 주로 고민했던 확장성은 주로 서버가 수평적으로 확장되는, 그러니까 스케일 아웃 되는 상황이었습니다. 최근 클라우드와 컨테이너 환경등의 발전으로 인하여 더더욱 중요해진 부분이기도 했고, 이 프로젝트는 딥러닝 분석 모듈을 돌린다는 특성 상 서버를 수직적으로 확장하는 것은 한계가 비교적 뚜렷한 편이었습니다. 물론 수평적인 확장성을 위한 아키텍처는 전혀 새로운 것이 아니었고 이미 많은 오픈소스 소프트웨어들이 이 환경에서 생길 수 있는 잠재적인 문제점을 해결하기 위해 개발되어 있었습니다. 하지만 당장의 현실적인 문제들을 생각 안 할 수 없었습니다. 다수의 미들웨어를 도입해서 개발할 경우 그 미들웨어들의 사용법과 구조, 안정성등을 고려하는데 걸리는 시간과 노력, 추후 운영계에 서버가 올라갔을 떄 해당 미들웨어들을 이중화하고 가용성을 보장하기 위한 방법들과 투입되는 자원들, 그리고 하나하나를 사용해봤다가 목적에 맞지 않았을 때 되돌리는 비용 등등이 다소 문제가 되었습니다.

그래서 저는 가급적이면 아키텍처를 감당 가능한 수준으로 단순하게 구성하고 싶었고, 스스로의 능력을 겸허한 자세로 검토해보고 나서 제가 현재 시점에 감당 가능한 것은 저장소(Persistence) 단 하나밖에 없다는 결론을 내렸습니다. 따라서 저장소 역할을 해줄 단 하나의 소프트웨어를 통해서 확장성을 보장할 수 있도록 프로그램을 만들 수 있다면 그것이 현재 상황에서는 환상적인 해결책이 될 수 있을 것이라 생각했습니다.

레디스(REDIS, Remote Dictionary Service)는 메모리에 데이터를 저장하는 키 - 밸류 기반의 저장소(Key-Value Store) 입니다. 키 - 밸류 기반의 저장소라는 말은 다시 말하면 저희가 일반적으로 생각하는 관계형 데이터베이스 처럼 스키마가 존재하는 완전히 구조적인 형태의 데이터 저장소가 아닌, 모든 데이터가 각 데이터를 표현하는 유일한 키(Key)와 그와 매칭되는 밸류(Value)로 표현된다는 것입니다. 보통 생각하는 사전형 자료구조(Dictionary ADT)나 맵(Map)과 동일한 형태로 데이터를 저장한다고 생각하시면 이해하기 쉽습니다. 또한 데이터의 저장과 검색이 메모리에서 이루어지기 때문에 Key - Value 기반의 컨셉에 충실하게 프로그래밍 하면 굉장히 빠르게 동작한다는 장점도 있습니다.

사실 서버의 확장성을 위해서 각 서버들이 서로 연결되지 않고 별도의 저장소만 바라보도록 하는 구조는 그렇게 특별한 것은 아니고, 꼭 레디스를 이용해서만 구현할 수 있는 것도 절대 아닙니다. 다만 제가 당시 프로젝트를 수행할 때 모든 것을 해결해 줄 환상적인 해결책으로 레디스를 선택한 이유는 저희가 다루는 중요한 데이터, 그러니까 사용자들의 심전도 데이터는 파일 형태로 존재하기 때문에 어차피 별도로 관리하고 있으며 저장소가 수행하는 ‘데이터 저장’의 역할은 기초적인 메타데이터 정도로 다소 단순한 형태였기 때문입니다. 또한 레디스는 특정 키에 EXPIRE 명령을 주어서 일정 시간 후 자동으로 삭제되도록 하거나 연결된 서버간의 이벤트 발행 / 구독 기능을 지원하여 메시지 큐 역할을 수행하는 등 지금 상황에서 가장 적합한 해결책이 될 수 있을 것 같다는 생각을 했습니다.

어떤 하나의 공통 저장소를 두고 서버들이 그 쪽만 바라보게 만드는 디자인 컨셉은 기초적으로는 서버를 상태가 없는(stateless) 서버로 만들기 위한 작업입니다. 서버에 어떤 사용자의 요청이 들어온다면 그 요청은 추상적으로는 ‘계속 기억해야 할 내용’과 ‘당장 뭔가 수행해야 할 내용’으로 나눌 수 있는데 이러한 구분은 객체지향 프로그래밍에서 무언가를 추상화 할 때 멤버 변수(Member variable)와 멤버 함수(Member function)로 구분하는 것과 동일합니다.

예를 들어서 서버 내에서 처리하는 행동들은 계산이나 검증, 혹은 웹 서비스를 호출하고 프로그램을 실행하는 등 그 자신의 상태에 변경이 없는 작업들이 될 수 있습니다. 상태를 가지지 않는다는 말은 해당 동작이 일종의 함수처럼 동작한다는 말인데, 작업을 한 번도 하지 않거나 100번을 수행해도 서버 자체는 변하지 않아야 한다는 말입니다. 보통은 서버가 하나의 요청이 끝나면 메모리나 파일에 아무것도 저장하지 않도록 하여 구현할 수 있습니다.

이러한 특성을 다시 생각해보면, 서버가 일종의 함수처럼 동작한다고 볼 수도 있습니다. 예를 들어서 f(x)=x+1 같은 단순한 함수에서 부터 프로그래밍 언어의 함수나 메소드에 이르기까지, 함수 자체는 그 수행으로 인하여 변하는 것 없이 불변(immutable)의 특성을 가지게 됩니다. 수학 함수는 말할 것도 없고, 프로그래밍 언어 역시 글로벌 변수를 사용하지 않고 함수 내에서 정의된 지역적인 변수만 사용하여 수행된다면, 함수가 1번에서 N번 실행되거나, 1개에서 N개가 동시에 실행되어도 그 동작에는 아무 문제가 없음을 생각해볼 수 있습니다. 그리고 동시에 실행되어도 아무 문제가 없다는 말은 바꿔 말하면 서버가 스케일 아웃되어서 N개의 서버가 만들어지더라도 문제가 발생할 여지가 없다는 말이 됩니다. 즉, 저희는 서버 내의 메모리 혹은 저장소 등에 무언가 다음 수행까지 이어지는 데이터, 그러니까 상태(state)를 저장하지 않도록 구성함으로써 스케일 아웃 상황에 대비하기 위한 가장 중요한 기초를 마련할 수 있습니다. 이러한 컨셉을 염두해두고 조금 더 레디스를 활용해서 실질적인 문제를 해결해보도록 하겠습니다. 제 경우에는 평범한 Rest 요청을 통해 새로운 분석작업이 생성되었을 때, 이를 어떻게 노는 서버에 적당히 분배해줄 것인가가 최초의 고민이었습니다. 당시에는 서버를 여러 종류로 만들어서 각각 이중화하는 비용이 다소 부담이 되었기 때문에 가급적이면 별도의 Master 서버를 두지 않는 구조를 만들고 싶었는데, 이 경우 작업이 여러개의 서버에서 각각 생성될 수도 있고 이 작업을 일단 어딘가에서 대기하다가 자기 순서가 되면 분석 서버로 배치되어야 했습니다.

Simple Queue

먼저 들어온 작업을 먼저 처리하는 구조는 저희가 익히 아는 큐(Queue)를 이용해서 처리할 수 있습니다. 문제는 각각의 서버가 독자적인 작업 큐를 가지고 있으면 고민할 것이 많아진다는 점입니다. 예를 들어서 작업 12개가 들어왔을때 서버 3개에 4개씩 분배하는 것과, 서버 3개에 3개만 분배하고 9개를 대기시켰다가 작업이 끝난 서버에 하나씩 분배해주는 것은 차이가 조금 있습니다. 전자의 경우 작업의 수행속도가 균일하지 않을 경우 어떤 서버는 운 좋게 짧은 작업만 가져와서 빨리 끝내고 쉴 수도 있고 어떤 서버는 매우 수행시간이 긴 작업만 할당받아서 한참 동안 수행할 수도 있습니다. 우리 일에서 자주 보이는 패턴이기는 하지만, 이렇게 될 경우 클라이언트의 입장에서도 노는 서버가 있음에도 작업이 빨리 끝나지 않아서 매우 골치아퍼지는 문제가 있습니다. 그렇다고 노는 서버에 밀린 작업을 이전시켜주는 기능을 만드는 것은 쉬운 일이 아니구요. 그 외에도 앞에서 말했던 것처럼 서버가 작업을 가진채로 터져나가는 경우 유실되는 작업이 많아져서 처리가 복잡해지는 문제도 있습니다.

Blocking Queue

자바에는 블로킹 큐 (BlockingQueue)라는 인터페이스가 있는데, 이 인터페이스는 일반적인 큐의 동작을 동일하게 수행하면서 동시에 큐가 비었을 때 원소를 꺼낼 경우 원소가 추가적으로 들어올 때 까지 기다리는(blocking) 동작, 그리고 큐가 가득찼을 때 원소를 추가하면 큐에 빈 자리가 날 때 까지 마찬가지로 기다리는 (blocking) 동작이 추가된 클래스입니다. Blocking 상태로 기다리고 있는 스레드간의 순서 보장은 약간 다른 문제이기는 하지만 기본적으로 블로킹 큐는 당연히 Thread-Safe 하게 만들어졌기 때문에 같은 작업이 2개 스레드에 동시에 할당되거나 하는 등의 이상 동작은 발생하지 않습니다.

Redis blocking queue

저는 이러한 블로킹 큐의 동작을 서버 환경에서도 동일하게 수행할 수 있다면 작업 분배에 있어서 많은 이득이 있을 것 같다고 생각했는데, 다행히도 이러한 동작은 레디스를 이용해서도 구현할 수 있었습니다. 레디스는 내부적으로는 데이터의 추가 / 삭제등에 대한 처리를 싱글 스레드로 수행하는 특성이 있는데 이로 인해서 여러 서버가 동시에 붙었을 때 처리 속도에 약간 의문을 가지게 하지만 서버 단위로 안전한 로직, 별다른 경쟁조건(Race Condition)이 발생하지 않는 동작을 만드는데 매우 유리하기도 합니다. 어떤 데이터에 대해서 엑세스 하고 있을 때 동시에 접근하는 요소가 전혀 없다는 말이니까요. 이러한 분산 블로킹 큐(Distributed Blocking Queue)는 레디스의 이러한 싱글 스레드 특성, 이벤트의 발행 및 구독 기능, 레디스 쪽에서 작업을 원자적으로 처리할 수 있게 만들어주는 루아 스크립트(Lua Script)등을 이용하여 직접 구현할 수도 있지만 저는 그냥 자바용 레디스 클라이언트인 Redisson을 이용하였습니다. 여기에서는 자바 BlockingQueue 인터페이스의 구현체를 제공하기 때문에 자바에서 스레드 단위로 큐에 접근하듯이 다수의 스케일 아웃된 서버에서 하나의 큐에 쉽게 접근하실 수 있습니다.

저는 분석 서버 내에서는 분석 작업을 단일 스레드로 돌릴 필요가 없었기 때문에 여러 개의 스레드를 생성해서 작업을 동시에 돌렸는데, 예를 들어서 4개의 서버에서 각각 4개의 스레드를 생성할 경우 16개의 스레드에서 동시에 레디스에 위치한 블로킹 큐에 접근하게 됩니다. 각각의 작업이 충분히 독립적으로 수행되는 경우 (다른 작업의 수행에 영향을 받지 않는 경우) 이러한 구조는 서버가 스케일 아웃 되거나 스케일 인 되더라도 그 수행에 영향을 받지 않으며 심지어 서버 내에서 스레드 숫자를 동적으로 조절하더라도 잘 동작함을 알 수 있습니다. 물론 서버 / 스레드가 종료되는 시점에 수행하고 있던 작업을 잘 마무리해줘야 겠지만요.

제 경우에는 평범한 자바 스레드의 러너(Runner)를 만들고 이 안에서 마찬가지로 자바의 BlockingQueue를 이용하여 작업이 들어올 때 까지 대기하고 있다가 작업이 들어오면 받아서 처리하는 클래스를 만들었습니다. 이 구현은 레디스와 연결되어 있지 않기 때문에 아직 서버 환경이나 개발 환경이 완벽하게 구축되지 않은 상태에서도, 혹은 여러 가지 상황(레디스 클라이언트의 교체 혹은 레디스 자체를 쓰지 않고 다른 큐를 쓰는 경우)으로 BlockingQueue의 구현체가 바뀌는 경우에도 변경이 없도록 느슨하게 만들어져 있습니다.

물론 스프링을 사용하는 경우 BlockingQueue의 구현체를 Bean으로 등록하여 각 클래스에서 Injection 받도록 만들 수 있습니다. 제 경우 Redisson을 사용하여 생성하였는데, 실제 구현은 크게 고민할 것 없이 단순했지만, 단순한 구조는 보통 그 단순함 만큼 견고하게 동작합니다. 실제로는 Worker 들을 스레드 풀을 이용하여 관리해주거나 동작중인 스레드 숫자를 동적으로 조정하는 등의 고려사항이 더 있었지만 이는 BlockingQueue를 단순한 코드로 활용하면서 얻을 수 있는 일종의 보너스에 가까웠습니다.

사실 이 프로젝트 같은 경우에는 딥러닝을 이용한 분석을 수행하는 프로젝트였고, 분석 대상이 되는 관측 데이터의 집합은 적어도 30MB 정도에서 많으면 100MB 이상으로 어마어마한 고민이 필요한 대용량 파일은 아니지만 REST API 본문에 담아서 보내고 처리하기에는 다소 부담되는 크기였습니다. 사실 클라이언트가 분석서버에 파일을 올리는 동작은 평범한 멀티파트 요청(Multipart Request)를 통해서 이루어지는데, 이렇게 파일이 올라가면 그 순간에는 서버의 로컬 저장소에 분석 파일이 위치하게 됩니다. 저희는 상태가 없는 서버를 만들어야 하기 때문에 이 파일은 계속 여기에 놔둘 수 없었습니다. 위에서 만든 작업 큐의 존재때문에 이 서버가 받은 데이터 파일은 저 쪽 서버가 필요로 할 수도 있으니까요.

사실 인프라에 구애받지 않도록 하기 위해서 서버간에 HTTP를 통해 데이터 파일을 주고 받는 코드도 구현하기는 했는데, 보다 올바른 구현은 분석 파일들을 저장해두는 별도의 저장소 서버를 두는 것입니다. 최초에는 여러 서버가 공유하는 유일한 저장소였던 레디스에 데이터 파일을 작업과 같이 올려둘까를 고민해봤었는데, 레디스의 경우 메모리에 데이터를 저장하기 때문에 많아야 몇 백 MB에서 2~4GB 정도만 저장할 수 있었습니다. 데모용으로는 적당할 수 있어도 실제로 사용하기에는 무리가 있는 구현이었습니다. HDFS나 NFS를 쓰는 법등도 고려 대상이었으나 당시 저희 프로젝트는 AWS에 인프라를 두고 있었기 때문에 사실 큰 고민없이 아마존에서 제공하는 Object Storage 서비스인 S3를 이용하였습니다.

Object Storage는 파일을 저장하거나 가져오는 기본적인 동작을 제공함은 물론이고 그 파일을 하나의 객체로 취급하여 버전 관리나 백업, 접근 로그 관리 등이 가능한 저장소입니다. 자바 프로그램을 만드는 경우 S3 API를 매우 간단하게 호출하여 파일을 저장하거나 가져다 쓸 수 있는데, 이는 LAN 환경이나 접속 지역에 구애받지 않기 때문에 상대적으로 인프라 구성을 조금 더 자유롭게 할 수 있도록 해줍니다. 다만 S3 API를 사용할 경우 프로그램이 아마존 환경에만 종속되어버리는 문제가 있습니다. 다행히도 Object Storage의 경우 모든 클라우드 서비스 제공자 가 제공하고 있으므로 클라우드 환경을 이전하는 경우에도 큰 변경 없이 저장소를 사용할 수 있습니다.

사실 별도의 Master 서버가 없이 모든 서버가 평등한 구조를 만들 경우 서버간의 커뮤니케이션에 신경이 쓰이지 않을 수 없습니다. 서버간에 서로 연락을 하지 않는 쿨한 관계를 유지하는 것이 확장성 확보에 도움이 되겠지만 여러 비즈니스 로직을 구현하다보면 여러 서버가 동시에 수행할 필요가 없는 스케줄 처리된 배치 작업, 어떤 서버가 작업을 처리하지 못하고 종료될 때 다른 서버에 이전하는 작업, 서버의 숫자를 카운트하거나 상태를 확인하는 것, 로그를 남길 서버를 정하는 작업 등 생각보다 서버간에 커뮤니케이션을 해서 정보를 교환할 일이 많습니다.

사실 레디스가 메시지의 Publish / Subscribe 기능을 지원해서 이벤트 기반 프로그래밍이 가능하도록 도와준다는 요소는 이러한 커뮤니케이션의 부담을 상당히 줄여줍니다. 이러한 메시지 전달은 Redisson의 Topic 클래스를 이용해서 구현 가능했는데, API에서 아실 수 있듯이 대부분의 메시지 큐와 비슷한 형태로 메시지를 주고 받을 수 있으며 매우 단순하게 처리할 수 있습니다. 한가지 문제라면 레디스는 그 컨셉상 트랜잭션 처리가 쉽지 않다는 점인데요, 레디스는 명령어를 일괄로 실행하는 기능은 있지만 중간에 수행 결과에 따라 명령을 롤백(rollback)하거나 하는 기능은 없습니다. 이는 빠른 성능에 집중하기 위해서라고 공식 문서에도 명시되어 있으므로 앞으로도 롤백 기능이 추가될 가능성은 낮다고 할 수 있습니다.

어떤 비즈니스 로직을 트랜잭션으로 묶을 수 없다면 다음과 같은 문제가 발생할 수 있습니다. 같은 작업에 대해서 2개의 서버가 동시에 상태를 업데이트 한다고 했을 때, 한 쪽에서 상태를 변경한 뒤 다시 레디스에 업데이트 하기 전에 다른 서버에서 작업을 레디스에서 읽어올경우, 첫번째 서버에서 변경된 상태의 업데이트와 두번째 서버에서 변경된 상태의 업데이트가 순차적으로 이루어져서, 개념상 존재할 수 없는 상태 변화가 이루어질 수도 있습니다. 이는 동기화를 이야기 할 때 흔하게 나올 수 있는 예외상황이기도 하지만 비즈니스 로직을 어긴 것이므로 실제로는 발생해서 안되는 상황이었고, 이를 막기 위한 고민이 필수적으로 따라와야 했습니다.

만약에 여러 개의 서버가 아닌 단일 서버의 자바 프로그램에서 이런 문제를 해결하고자 한다면 보통은 synchronized 키워드를 통해서 단일 스레드만 접근할 수 있는 코드 부분을 동기화 시켜서 Critical Section을 정의하는 방식을 사용할 것입니다. 만약에 이 개념을 서버 단위로 확장시켜서 전체 서버 중 하나의 서버만 접근할 수 있는 Critical Section을 만들 수 있다면 어떨까요?

Critical Section example

이러한 발상을 분산 락킹 프로토콜 (Distributed Locking Protocol)이라고 부르는데, 저희가 일반적으로 생각하는 락, 모니터, 세마포어 등등 다양한 동기화 기법들을 서버 단위로 수행하는 것을 말합니다. 위와 같은 다수의 접근으로 구성된 저장소 업데이트 동작에서 리더 선출에 이르기까지 다양한 비즈니스 로직에 활용될 수 있습니다.

Distributed locking

이러한 동작 역시 레디스를 이용해서 구현할 수 있습니다. 위에서 블록킹 큐의 구현체를 레디스를 이용해서 분산 환경에서 사용했던 것 처럼, 다양한 동기화 기법 역시 레디스에 락을 걸고 푸는 방식으로 구현할 수 있습니다. 모든 서버가 바라보고 있는 하나의 레디스에서 락을 걸고 풀기 때문에 다른 서버들 역시 모두 같은 락을 사용하여 서로 간의 동기화를 - 서로 메시지를 주고 받는 일 없이 - 할 수 있습니다.

이러한 분산 락은 마찬가지로 레디스의 저장소를 이용하여 직접 구현할 수도 있지만 레디스는 조금 더 안전한 방식으로 Redlock 이라는 알고리즘은 제안하고 있습니다. 이 알고리즘은 레디스가 제공하는 또 다른 기능인 일정 시간이 지나면 키가 삭제되는 Expire 기능과 타임스탬프를 이용하여 Deadlock이나 Livelock이 발생하지 않는 안전한 알고리즘입니다. 실제로 레디스는 그 자체도 분산환경으로 클러스터링 될 수 있고 데이터가 나누어서 저장되거나 서로 동기화 되는 등 복잡한 요소가 많기 때문에 Redlock 알고리즘 역시 다소 복잡한 스펙을 가지고 있습니다.

레디스에서는 Redlock을 제안만 하고 실제 구현은 알아서 하도록 하였는데, 다행히도 Redisson을 비롯한 여러 언어의 레디스 클라이언트들이 Redlock의 구현체를 제공하고 있으므로 저희는 API를 이용하여 쉽게 분산 서버에 Critical Section을 만들 수 있습니다. 다만 락 획득을 요청한 순서대로 락을 부여하는 Fair Lock 기능의 경우 구현이 다소 복잡해지고 일정한 성능 손해를 감수해야 하므로 비즈니스 로직 상 Fairness가 반드시 필요한지는 다소 따져볼 필요가 있습니다.

그외에도 레디스를 이용하여 RMI (Remote Method Invocation)를 수행하거나, 하나의 객체를 여러 서버가 나누어서 사용하거나 여러 서버의 시간 동기화를 레디스를 이용해서 수행하는 등, 레디스를 이용하면 서버가 하나이거나 하나가 아닐때 모두 견고하게 동작하는 아키텍처를 구성하실 수 있습니다. 사실 여기에서는 레디스를 이용해서 설명드렸지만 실제로는 다양한 미들웨어와 라이브러리를 이용하여 구현할 수 있으므로 손에 익으신 도구들을 이용하여 앞으로 클라우드 환경으로 인프라가 계속 넘어가고 확장성이 점점 중요해지는 상황에서도 단단하게 적응할 수 있는 애플리케이션을 개발하실 수 있었으면 좋겠습니다.