소프트웨어 설계에서 "가능할지도 모르는 가장 단순한 것"을 일관되게 선택하라는 원칙을 설명하고, 단순함의 정의, 흔한 반론(빅 볼 오브 머드 우려, 단순함의 기준, 확장성 집착)과 실용적 예시를 통해 왜 이것이 효과적인지 논한다.
소프트웨어 시스템을 설계할 때는, 가능할지도 모르는 가장 단순한 일을 하라.
이 조언이 생각보다 멀리 간다. 나는 이 원칙을 항상 적용할 수 있다고 진심으로 믿는다. 버그 수정에도, 기존 시스템 유지보수에도, 새로운 시스템 설계에도 이 접근을 쓸 수 있다.
많은 엔지니어들이 “이상적인” 시스템을 머릿속에서 설계하려 든다. 잘 분해되어 있고, 사실상 무한히 확장 가능하며, 우아하게 분산된 그런 것 말이다. 나는 이것이 소프트웨어 설계를 대하는 완전히 잘못된 방식이라고 생각한다. 그 시간에 현재 시스템을 깊이 이해하는 데 쓰고, 그러고 나서 가능할지도 모르는 가장 단순한 일을 하라.
시스템 설계에는 다양한 도구에 대한 숙련이 필요하다. 앱 서버, 프록시, 데이터베이스, 캐시, 큐 등등. 이런 도구에 익숙해질수록 주니어 엔지니어들은 자연히 그것들을 쓰고 싶어 한다. 여러 컴포넌트로 시스템을 조립하는 건 재미있다! 화이트보드에 박스와 화살표를 그리면 진짜 엔지니어링을 하는 기분이 들기도 한다.
그러나 많은 기술이 그렇듯, 진정한 숙련은 더 많이가 아니라 언제 덜 할지를 배우는 데 있다. 무술 영화의 진부한 클리셰를 떠올려보자. 야심 찬 초보자는 눈에 보이지 않을 정도로 빠르게 뒤집고 회전한다. 고수는 대부분 정지해 있다. 그런데도 초보자의 공격은 좀처럼 제대로 맞지 않고, 고수의 최종 일격은 결정적이다.
소프트웨어에서도 마찬가지다. 훌륭한 소프트웨어 설계는 밋밋해 보인다. 겉으론 별일이 없어 보인다. 훌륭한 설계 앞에 서 있음을 알아차리는 방법은 이런 생각이 들기 시작할 때다. “아, 문제는 그렇게 쉬운 거였구나” 또는 “오, 좋다. 사실 어려운 일은 하나도 안 해도 되네.”
Unicorn은 훌륭한 소프트웨어 설계다. 유닉스의 원시 기능들에 기대어(프로세스 격리, 수평 확장, 크래시 복구)1 웹 서버에 가장 중요한 보장을 모두 제공하기 때문이다. 업계 표준인 Rails REST API도 훌륭한 설계다. CRUD 앱에 필요한 것을 가장 지루할 정도로 평범한 방식으로 정확히 제공한다. 나는 이들 중 어느 것도 소프트웨어 자체로서 인상적이라고 생각하지 않는다. 하지만 _설계_로서는 인상적이다. 왜냐하면 가능할지도 모르는 가장 단순한 일을 하기 때문이다.
당신도 그렇게 하라! 예를 들어, 어떤 Golang 애플리케이션에 레이트 리미팅(요청 제한)을 추가하고 싶다고 하자. 가능할지도 모르는 가장 단순한 일은 뭘까? 첫 생각은 아마 영속 저장소(예: Redis)를 추가해 누수 버킷(Leaky Bucket) 알고리즘으로 사용자별 요청 횟수를 추적하는 것일 수 있다. 그건 작동한다! 하지만 인프라를 하나 더 들여와야 할까? 대신 그 사용자별 요청 카운트를 메모리에 두면 어떨까? 물론 애플리케이션이 재시작되면 일부 레이트 리미팅 데이터는 잃게 된다. 그런데 그게 중요한가? 잠깐, 혹시 당신의 엣지 프록시2가 이미 레이트 리미팅을 지원하지 않나? 기능 구현 대신 설정 파일에 몇 줄만 쓰면 되는 건 아닐까?
물론 엣지 프록시가 레이트 리미팅을 지원하지 않을 수도 있다. 서버 인스턴스가 병렬로 너무 많이 떠 있어서 메모리 내 추적만으로는 제한이 너무 느슨해질 수도 있다. 혹은 사용자가 정말 심하게 두드리기 때문에 레이트 리미팅 데이터가 조금이라도 사라지면 곤란할 수도 있다. 그 경우 가능할지도 모르는 가장 단순한 일은 영속 저장소를 추가하는 것이다. 그러면 그걸 하면 된다. 하지만 더 쉬운 접근으로 충분하다면, 그걸 먼저 하고 싶지 않겠는가?
이 방식으로 애플리케이션 전체를 처음부터 끝까지 만들 수도 있다. 절대적으로 가장 단순한 것부터 시작하고, 새로운 요구사항이 정말로 밀어붙일 때에만 확장하라. 유치하게 들리지만, 효과가 있다. YAGNI를 궁극의 설계 원칙으로 삼는다고 생각하라. 단일 책임 원칙보다, “일에 가장 맞는 도구 고르기”보다, 그리고 “좋은 설계”보다도 우위에 두는 것이다.
물론, 항상 가능할지도 모르는 가장 단순한 일을 하는 데에는 큰 문제가 셋 있다. 첫째, 미래 요구사항을 예측하지 않으면 유연하지 않은 시스템이나 진흙 투성이 덩어리(big ball of mud)가 될 수 있다. 둘째, “가장 단순”이 무엇인지 불분명하므로, 최악의 경우 내가 하는 말은 “좋은 설계를 하려면 항상 좋은 설계를 하라”에 불과해진다. 셋째, 당장은 그냥 돌아가는 시스템이 아니라 확장 가능한 시스템을 만들어야 한다. 이 반론들을 차례로 보자.
어떤 엔지니어들에게 “가능할지도 모르는 가장 단순한 일을 하라”는 말은 공학을 그만두라는 소리처럼 들린다. 가장 단순한 일이 보통 급한 땜질이라면, 이 조언은 필연적으로 엉망진창으로 이어지는 게 아닐까? 우리 모두 꼼수가 꼼수 위에 쌓인 코드베이스를 본 적이 있고, 그런 것들은 확실히 좋은 설계처럼 보이지 않는다.
그런데 꼼수가 단순한가? 사실 나는 아니라고 본다. 꼼수(혹은 임시방편, kludge)의 문제는 바로 _단순하지 않다_는 데 있다. 즉, 코드베이스에 또 하나의 “항상 기억해야 할 것”을 도입하여 복잡성을 늘린다. 꼼수는 단지 떠올리기 쉽다. 제대로 된 해결책을 찾기는 어렵다. 코드베이스 전체(또는 큰 부분)를 이해해야 하기 때문이다. 실제로 올바른 해결책은 거의 언제나 꼼수보다 훨씬 더 단순하다.
가능할지도 모르는 가장 단순한 일을 하는 것은 쉽지 않다. 어떤 문제를 볼 때 처음 떠오르는 몇 가지 해법은 가장 단순한 해법일 가능성이 낮다. 가장 단순한 해법을 찾으려면 여러 접근을 숙고해야 한다. 다시 말해, 진짜 엔지니어링이 필요하다.
엔지니어들은 무엇이 단순한 코드인지에 대해 자주 의견이 갈린다. “가장 단순”이 이미 “좋은 설계”를 의미한다면, “가능할지도 모르는 가장 단순한 일을 하라”는 말은 결국 동어반복이 아닌가? 다시 말해, Unicorn이 정말로 Puma3보다 단순한가? 메모리 내 레이트 리미팅이 정말 Redis를 쓰는 것보다 단순한가? 여기에 대한 대략적이고 직관적인 단순함의 정의를 제시해 보겠다4:
유닉스 프로세스는 스레드보다 단순하다(따라서 Unicorn은 Puma보다 단순하다). 프로세스는 메모리를 공유하지 않아 결합이 덜하기 때문이다. 이것은 나에게 매우 타당하게 느껴진다! 다만 이것만으로 매번 어떤 것이 더 단순한지 판단할 도구가 생기지는 않는다.
그럼 메모리 내 레이트 리미팅과 Redis는 어떤가? 한편으로는 메모리 내 방식이 더 단순하다. 별도의 영속 서비스 구성에 관련된 모든 것을 생각하지 않아도 되기 때문이다. 다른 한편으로는 Redis가 더 단순하다. 제공하는 레이트 리미팅 보장이 더 직관적이어서, 어떤 서버 인스턴스는 사용자가 제한에 걸렸다고 보고 다른 인스턴스는 아니라고 보는 경우를 걱정하지 않아도 된다.
무엇이 “더 단순해 보이는지” 확신이 없을 때, 나는 이런 최종 판단 기준을 쓴다. 단순한 시스템은 안정적이다. 소프트웨어 시스템의 두 상태를 비교할 때, 요구사항이 변하지 않아도 지속적 작업이 더 많이 필요한 쪽이 있다면, 다른 쪽이 더 단순하다. Redis는 배포하고 유지보수해야 하며, 자체적인 장애가 날 수 있고, 자체 모니터링이 필요하고, 서비스가 새로운 환경에 배포될 때마다 별도 배포가 필요하다. 등등. 따라서 메모리 내 레이트 리미팅이 Redis보다 더 단순하다5.
어떤 유형의 엔지니어는 지금 속으로 이렇게 소리치고 있을 것이다. “하지만 메모리 내 레이트 리미팅은 확장되지 않아!” 가능할지도 모르는 가장 단순한 일을 하는 것은, 그야말로 웹 스케일의 시스템을 낳지 않는다. 다만 현재 스케일에서 잘 동작하는 시스템을 낳는다. 이것은 무책임한 엔지니어링일까?
아니다. 내 생각에 빅테크 SaaS 엔지니어링의 대죄는 스케일 집착이다. 나는 현재 스케일보다 몇 자릿수 더 큰 트래픽을 대비한다며 시스템을 과설계한 탓에 피할 수 없는 고통을 너무 많이 보았다.
이렇게 하지 말아야 할 주된 이유는 효과가 없기 때문이다. 내 경험상, 사소하지 않은 코드베이스라면 몇 자릿수 더 큰 트래픽에서 어떻게 동작할지 미리 예측할 수 없다. 병목이 어디일지 알 수 없기 때문이다. 잘해봐야 현재 트래픽의 2배나 5배까지는 대비하고, 문제가 들어오면 그때 대처할 준비를 하는 정도다.
또 다른 이유는 코드베이스를 경직시키기 때문이다. 서비스 둘로 쪼개서 독립적으로 확장 가능하게 만드는 일은 재미있다(이런 사례를 아마 열 번쯤 봤는데, 실제로 유의미하게 독립 확장된 경우는 아마 한 번 본 것 같다). 하지만 그러면 어떤 기능들은 구현이 매우 어려워진다. 이제는 네트워크를 통한 조율이 필요하기 때문이다. 최악의 경우 네트워크 너머의 _트랜잭션_이 필요해지는데, 이것은 정말로 어려운 공학 문제다. 대부분의 경우 이런 일을 할 필요가 전혀 없다!
테크 업계에서 일한 시간이 길어질수록, 나는 시스템이 어디로 갈지 예측하는 우리의 집단적 능력에 덜 낙관적이 된다. 시스템의 현재 상태를 머릿속에 넣는 것만도 충분히 어렵다. 사실, 좋은 설계를 하는 데 따르는 실질적 난점의 핵심은 거기에 있다. 시스템에 대한 정확한 큰 그림을 얻는 것. 대부분의 설계는 그 이해 없이 이뤄지고, 그래서 대부분의 설계는 꽤 형편없다.
소프트웨어를 개발하는 방식은 크게 두 가지다. 첫째, 앞으로 6개월 혹은 1년 뒤의 요구사항을 예측하고 그 목적에 가장 잘 맞는 시스템을 설계하는 방법. 둘째, 지금 실제 요구사항에 가장 잘 맞는 시스템을 설계하는 방법. 즉, 가능할지도 모르는 가장 단순한 일을 하는 것이다.
추가: 이 글에는 Hacker News에서 몇 가지 댓글이 달렸다.
흥미로운 스레드 하나는, 규모가 커지면 아키텍처의 단순함은 중요하지 않다고 말한다. “구현에서의 상태 공간 탐색(state space exploration in implementation)”의 복잡성이 다른 모든 복잡성을 압도한다는 주장이다(내가 여기에서 쓴 것과 비슷한 의미인 듯하다). 나는 동의하지 않는다. 기능 상호작용이 복잡해질수록 단순한 아키텍처는 더 중요해진다. 당신의 “복잡성 예산”은 거의 바닥나 있기 때문이다.
또한 이 표현을 만든 Ward Cunningham과 Kent Beck에게 공을 돌리고 싶다. 나는 진심으로 이 문구를 내가 방금 생각해냈다고 여겼는데, 거의 확실히 예전에 들었던 것을 기억해낸 것 같다. 이런! 지적해준 HN 사용자 ternaryoperator에게 감사한다. 표현 출처.
↩ 2. 모든 테크 회사에는 어떤 식으로든 엣지 프록시가 있다.
↩ 3. 나는 Puma도 좋아하고 좋은 웹 서버라고 생각한다. 상황에 따라 Unicorn 대신 Puma를 고를 때가 분명히 있다(그런 경우라면 개인적으로는 루비 대신 다른 언어를 쓸지 진지하게 고민할 것이다).
↩ 4. 여기서는 Rich Hickey의 훌륭한 발표 Simple Made Easy의 영향을 받았다. 나는 모든 내용에 동의하지는 않는다(실전에서는 익숙함이 단순함에 실제로 기여한다고 본다). 그래도 꼭 볼 가치가 있다.
↩ 5. 물론 시스템이 조금이라도 수평 확장을 해야 한다면, 메모리 내 레이트 리미팅은 통하지 않고 Redis 같은 것으로 대체해야 한다. 하지만 내 경험으로는 Golang 서비스는 복제본을 몇 개 정도로만 수평 확장해도 상당히 큰 트래픽까지 버틸 수 있다.