grep

천만 MAU를 지탱하는 커뮤니티 시스템을 소개해요

당근

2026년 7월 14일

원문에서 보기 ↗

안녕하세요. 커뮤니티실에서 엔지니어링 매니저로 일하고 있는 아라운(Arawn)이에요. 커뮤니티실은 취미와 관심사로 이웃을 연결하는 당근 모임, 같은 관심사를 가진 이웃이 모이는 당근 카페, 우리 단지 이웃과 연결해주는 당근 아파트 등 당근의 커뮤니티 탭을 통해 이웃이 알려주는 진짜 동네 정보부터 소소한 일상과 취미까지, 지금 우리 동네에서 일어나고 있는 일들을 공유하고 소통할 수 있는 커뮤니티 서비스들을 만들고 있어요.

이런 커뮤니티 서비스들이 자리잡고 있는 커뮤니티 시스템을 여러분에게 소개해보려고 펜을 들었어요. 여기까지는 당근다운 해요체로 먼저 인사를 건넸어요. 이제부터 이어질 시스템의 구조와 기술적 선택에 관한 이야기는 합니다체로 풀어갈게요.

천만이라는 숫자는 자랑이기 전에 신호다

2026년 상반기 결산에서 당근모임의 월간 활성 사용자(MAU)가 천만을 넘었습니다. 커뮤니티 서비스 전체로도 체류시간과 이용자 간 소통이 크게 늘었고, 카페와 아파트 같은 새 커뮤니티 서비스도 빠르게 성장하고 있습니다.

이 소식을 보며 숫자 뒤에 있는 시스템의 이야기도 한 번 해보면 좋겠다는 생각이 들었습니다. 당근 커뮤니티가 어떻게 성장했는지는 몇 차례 소개되었지만, 그 트래픽을 처리하는 시스템이 어떤 구조이고 왜 그런 구조가 되었는지는 제대로 이야기한 적이 없었기 때문입니다.

천만은 자랑할 숫자입니다. 하지만 시스템을 만드는 사람에게 이 숫자는 자랑이기 전에 신호입니다. 이제는 기능이 동작하는지만 볼 수 없습니다. 사용자가 필요할 때 계속 사용할 수 있는지, 부하와 장애 상황에서도 정해진 규칙에 따라 동작하는지, 빠르게 응답하는지 함께 봐야 합니다. 더 많은 트래픽과 데이터를 받아들이는 데 드는 노력도 감당할 수 있어야 합니다. 문제가 생겼을 때 이해하고 고치고 배포하기 쉬운지, 그 모든 선택의 비용을 조직이 감당할 수 있는지도 중요해집니다.

말하자면 가용성, 신뢰성, 성능, 확장성, 변경 용이성, 운영 비용을 함께 봐야 하는 규모가 된 것입니다. 초당 백 건을 처리할 때는 눈에 띄지 않던 쿼리 하나가 초당 수천 건에서는 데이터베이스 부하를 키워 전면 장애로 이어질 수 있습니다. 비율로는 희박해 보이는 실패도 요청의 절대량이 커지면 매일 반복되는 운영 문제가 됩니다.

이 변화는 커뮤니티만의 일은 아니었습니다. 당근은 2020년 무렵부터 성장과 함께 커진 시스템 복잡도를 다루기 위해 제품 단위로 시스템을 나누기 시작했습니다. 처음에는 루비온레일즈(Ruby on Rails)와 포스트그레스큐엘(PostgreSQL) 중심의 모놀리스 시스템이 당근의 많은 문제를 해결했습니다. 서비스가 성장하고 조직이 커지면서 각 서비스는 자기 문제에 맞는 기술과 운영 구조를 갖추기 시작했고, 커뮤니티도 그 흐름 위에서 자기 시스템을 갖게 되었습니다.

그 출발점이 2021년 가을이었습니다. 기존 모놀리스에서 커뮤니티 영역을 분리해 커뮤니티 시스템을 만들기 시작했습니다. 처음에는 몇 사람이 시작한 시스템이었지만, 지금은 여러 팀이 함께 만들고 운영하는 시스템이 되었습니다. 커뮤니티 영역을 분리해 운영하기 시작했을 때 커뮤니티 서버앱이 처리하는 초당 요청 수(Requests Per Second, RPS)는 약 100건이었습니다. 당근모임 전국 오픈은 한 번의 큰 도약이었습니다. 이때 약 4,000 RPS까지 늘었고, 이후 여러 커뮤니티 서비스가 함께 자라면서 지금은 약 20,000 RPS를 처리합니다.

이 글에서는 커뮤니티 시스템이 빠른 제품 변화를 이어가면서도 피할 수 없는 소프트웨어의 복잡성을 어떻게 다뤄왔는지 이야기하려 합니다. 여러 서비스와 사용자 접점이 함께 자라는 구조와 그 안에서 모듈과 계약, 테스트와 운영 규칙으로 경계를 관리해온 과정을 담았습니다. 더 큰 성장을 준비하며 웹앱과 서버앱의 역할을 다시 나누고, 개인화와 전달 속도를 높이려는 목표도 함께 이야기합니다.

이 여정을 이어오며 저희가 계속 붙잡은 개발 원칙이 있습니다. 샌디 메츠(Sandi Metz)는 루비로 배우는 객체지향 디자인에서 오늘 완성해야 하는 기능을 구현하는 동시에 내일 쉽게 변경할 수 있는 코드를 작성해야 한다고 말합니다. 저희는 이를 위해 복잡성을 이름 붙은 경계에 두고, 그 경계를 도구와 운영 규칙으로 관리해왔습니다.

왜 이런 선택을 하게 되었는지 이해하려면, 먼저 커뮤니티 시스템이 어떤 특성을 가진 시스템인지부터 짚어야 합니다.

서비스마다 푸는 문제는 달라도 필요한 구성 요소는 반복된다

커뮤니티 시스템이 다뤄야 했던 복잡성은 한 종류가 아니었습니다. 서비스마다 다른 지역성과 인증 정책처럼 사용자 문제 자체에서 나오는 본질적 복잡성이 있었습니다. 반면 빠르게 제품을 만들며 쌓인 코드 중복과 높은 결합도, 충분히 다듬지 못한 모델은 우리가 더한 우발적 복잡성이었습니다. 둘을 구분하지 않으면 제품에 꼭 필요한 규칙과 걷어내야 할 기술 부채를 같은 문제로 다루게 됩니다.

커뮤니티 시스템을 오래 만들고 운영하면서, 저는 이 시스템의 구조를 결정한 특성이 다섯 가지라고 정리하게 되었습니다. 서비스마다 다른 문제를 풀면서도 반복되는 구성 요소를 함께 다뤄야 했습니다. 이 다섯 가지는 사실상 아키텍처의 요구사항이었습니다.

첫째, 당근 커뮤니티는 서비스마다 다른 지역성과 인증 정책을 다룹니다. 하나의 지역 커뮤니티가 아니라, 이웃의 범위를 서로 다른 기준으로 정하는 여러 커뮤니티를 함께 다룹니다. 동네생활은 같은 생활권의 이웃이 동네 질문과 일상, 생활 정보를 나누는 공간입니다. 당근모임은 가까운 이웃이 공통 관심사를 바탕으로 함께 활동하는 공간이고, 당근카페는 관심사와 생활 정보를 글로 쌓는 온라인 공간입니다. 당근아파트는 같은 단지에 실제로 사는 이웃들이 생활 정보와 질문, 나눔을 주고받는 공간입니다.

모두 이웃을 연결하지만, "가깝다"의 기준은 서비스마다 다릅니다. 동네생활은 생활권을 기준으로 보고, 모임은 실제로 만날 수 있는 거리와 모집 범위를 봅니다. 카페는 관심사가 모일 수 있는 더 넓은 지역 범위를 쓰고, 아파트는 단지와 실거주 인증을 중요하게 봅니다. 그래서 시스템은 콘텐츠와 사용자를 어느 지역 범위에 묶을지, 어떤 인증을 믿을지, 어떤 피드와 검색 결과에 노출할지 서비스마다 다르게 결정해야 합니다.

둘째, 서비스가 풀려는 문제는 다르지만 필요한 제품 구성 요소는 무척 닮았습니다. 동네생활, 모임, 카페, 아파트가 사용자에게 주려는 가치는 각기 다릅니다. 하지만 구현해야 하는 기능을 뜯어보면 게시글, 댓글, 멤버십, 알림, 피드, 검색, 채팅, 프로필, 운영 정책처럼 반복되는 것이 많습니다. 이 닮음은 공유의 기회입니다. 동시에 경계가 흐려지면 서비스별 구성 요소와 규칙이 뒤죽박죽 섞이고, 어느 기능이 어느 서비스의 책임인지 알기 어려워지는 위험이기도 합니다.

셋째, 커뮤니티 시스템은 당근의 여러 전사 플랫폼 위에서 동작합니다. 계정, 채팅, 미디어, 지역, 검색 같은 플랫폼이 없으면 커뮤니티는 제대로 동작하기 어렵습니다. 다만 전사 플랫폼에 의존한다는 말이 전사 플랫폼 모델에 강하게 결합한다는 뜻은 아닙니다. 그래서 커뮤니티 시스템은 공유 플랫폼 모듈을 둡니다. 이 모듈은 외부 시스템의 모델을 그대로 도메인 안에서 쓰기보다, 내부 모델로 바꿔서 사용합니다. 그래야 외부의 변화가 커뮤니티 도메인 규칙 안으로 바로 번지지 않습니다.

연동 구간에는 실패가 안으로 길게 전파되지 않도록 호출별 타임아웃과 요청 취소, 예외 변환을 둡니다. 필요한 경로에는 회로 차단기도 사용합니다. 실제로 서비스 초기에 연동 장애를 겪은 뒤로 운영 환경에 내보내기 전에 외부 의존의 실패가 어디까지 전파되는지 정하고, 그에 맞는 차단 장치를 갖춘다는 원칙이 생겼습니다.

넷째, 작은 팀이 만들고 운영합니다. 서비스 수에 비해 엔지니어는 늘 적었습니다. 같은 사람이 만들기와 운영을 함께 감당해야 하므로, 새로운 구조를 선택할 때는 구축할 수 있는지만 보지 않았습니다. 누가 이 구조를 계속 관리하고 운용할 수 있는지가 늘 중요한 판단 기준이었습니다.

다섯째, 당근 커뮤니티는 장기전으로 키워야 하는 제품입니다. 한 번 치고 빠지는 단거리 경주가 아니라, 사용자 관계와 콘텐츠와 운영 경험을 오래 쌓아가야 하는 서비스입니다. 그래서 그저 빠른 방법만으로는 부족합니다. 오래 잘할 수 있는 방법을 찾아야 합니다.

이 다섯 가지 특성은 결국 하나의 질문으로 모였습니다. 여러 서비스가 닮은 기능을 공유하고, 적은 인원이 오래 운영해야 한다면, 우리는 시스템을 어떻게 나누고 어디까지 하나로 둘 것인가. 커뮤니티실은 당시 제품과 팀의 조건을 기준으로 여러 서비스의 서버 기능을 하나의 서버앱에 두되, 그 안의 경계를 명확히 관리하는 쪽을 선택했습니다.

이 글에서는 그 과정을 모듈화와 관심사의 분리, 리팩토링, 디자인 패턴과 모델링, 자동화된 테스트라는 네 가지 방법으로 살펴보려 합니다. 이 가운데 가장 중요했던 것은 모듈화와 관심사의 분리였습니다.

여러 웹앱과 하나의 서버앱이 커뮤니티 시스템을 이룬다

커뮤니티 시스템의 구조는 한 문장으로 요약할 수 있습니다. 사용자 앞단에는 커뮤니티 탭과 프로필, 동네생활, 모임, 카페, 아파트의 경험을 맡는 여러 웹앱이 있고, 그 뒤에는 하나의 서버앱이 있습니다. 동네생활, 모임, 카페와 아파트는 서로 다른 사용자 문제와 도메인 규칙을 다루는 서비스입니다. 커뮤니티 탭과 프로필은 이 서비스들의 콘텐츠와 사용자를 연결하는 공통 사용자 접점입니다. 이 가운데 카페는 앱 안의 웹뷰와 사용자가 직접 접근할 수 있는 일반 웹사이트를 함께 가진 유일한 서비스입니다.

도식에서 중요한 점은 사용자 접점과 도메인 영역이 역할을 나눠 가진다는 것입니다. 커뮤니티 탭과 프로필, 동네생활, 모임, 아파트는 당근 앱 안의 웹뷰로 접근됩니다. 카페는 웹뷰와 직접 접근 가능한 일반 웹사이트를 함께 가집니다. 각 웹앱은 궁극적으로 같은 서버앱의 API와 연결되지만, 서비스별 규칙은 서버앱 안의 도메인 모듈에 나뉘어 있습니다. 그리고 전사 플랫폼 통합은 공유 플랫폼 모듈 뒤로 숨겨두었습니다.

이 글에 사용된 이미지는 모두 생성형 인공지능(AI)으로 제작했습니다. 이미지 속 세부 텍스트와 표현을 완전히 바로잡기 어려워, 내용상 큰 오류가 없는 것으로 확인한 이미지만 사용했습니다. 다만 세부 표현에는 부정확한 부분이 남아 있을 수 있으니, 이미지보다 본문의 설명을 기준으로 읽어주시기 바랍니다.

웹앱은 사용자 경험의 앞단을 책임진다

이 계층은 커뮤니티 시스템의 일급 구성 요소입니다. 단순한 표현 계층에 머물지 않고, 렌더링 전략과 사용자 접점의 속도, 채널별 경험을 함께 책임집니다. 피드와 프로필, 동네생활, 모임, 아파트는 앱 안의 웹뷰를 중심으로 동작하고, 카페는 웹뷰와 검색 유입을 위한 일반 웹사이트를 함께 운영합니다.

처음부터 이런 모습이었던 것은 아닙니다. 빠른 실험을 위해 네이티브 화면을 웹앱으로 전환했지만, 웹뷰 기반 클라이언트 사이드 렌더링(Client-Side Rendering, CSR)은 웹 리소스를 내려받고 자바스크립트를 실행한 뒤 API 응답까지 받아야 주요 콘텐츠를 표시할 수 있습니다. 특히 웹뷰를 처음 실행할 때는 초기 기동 비용까지 더해져 첫 렌더링이 늦어졌습니다. 이 과정이 차례로 이어지다 보니 기기 성능과 네트워크 환경의 영향을 크게 받았고, 빠른 실험에 유리했던 선택이 서비스 규모가 커지면서 초기 렌더링 지연이라는 새로운 성능 문제로 이어졌습니다.

그래서 서버 사이드 렌더링(Server-Side Rendering, SSR)을 도입했습니다. 하지만 API 응답이 늦어지면 빈 화면이 오래 노출되는 문제는 남았습니다. 이를 줄이기 위해 스트리밍 서버 사이드 렌더링(Streaming Server-Side Rendering, Streaming SSR)으로 옮겨왔습니다. API 호출이 필요 없는 화면 뼈대를 먼저 보내고, API가 필요한 본문은 준비되는 대로 이어 보냅니다. 렌더링에 문제가 생기거나 일정 시간을 넘기면 스트리밍을 중단하고 CSR로 전환하는 안전장치도 함께 두었습니다. 이 흐름은 커뮤니티실 구성원인인 리디아(Lydia)가 쓴 Streaming SSR 온보딩 글과 로토(Roto)의 Streaming SSR 발표에서도 더 자세히 볼 수 있습니다.

웹앱도 내부의 복잡성을 모듈로 관리합니다. 각 웹앱은 독립적으로 빌드하고 배포하면서, Streaming SSR과 공통 API 클라이언트처럼 반복되는 기반은 함께 사용합니다. 카페는 웹뷰와 일반 웹사이트로 나뉜 구현을 하나의 웹 저장소로 모으는 중입니다. 채널마다 다른 렌더링과 인증 문제는 나누고, 공통 제품 로직은 더 많이 공유하기 위한 변화입니다.

커뮤니티 시스템에서 기술 선택은 언제나 사용자가 겪는 문제를 해결하는 데서 시작했습니다.

하나의 서버앱도 네 개의 실행 단위로 나눠 운영한다

사용자 접점의 뒷단에는 커뮤니티 서버앱 하나가 있습니다. 동네생활, 모임, 카페와 아파트의 서버 기능을 서비스별 서버로 나누지 않고 하나의 서버앱으로 배포하고 운영한다는 점에서는 모놀리스입니다. 대신 서버앱 안에서는 서비스별 도메인과 여러 서비스가 함께 쓰는 기능을 모듈로 나누고, 모듈 사이의 의존 방향과 인터페이스를 관리합니다. 그래서 이 구조를 모듈형 모놀리스(Modular Monolith)라고 부릅니다. 다만 실행 목적이 다른 코드까지 하나의 프로세스에서 같은 방식으로 동작시키지는 않습니다.

커뮤니티 핵심 도메인 로직은 하나의 코드베이스에 있고, 실행 단위는 목적에 따라 넷으로 나눕니다. 웹 요청을 받는 api, 이벤트를 처리하는 worker, 스케줄 작업을 수행하는 batch, 내부 운영 도구인 console입니다. 같은 저장소에서 만들고 하나의 서버앱 릴리스로 함께 배포합니다. 런타임에서는 역할별로 컨테이너를 나눠 실행하고, 역할에 맞춰 자원과 확장 기준을 다르게 둡니다.

이 분리는 성능만을 위한 선택이 아닙니다. 사용자 요청, 후속 작업, 정기 작업, 운영자 도구는 실패 방식과 권한 모델과 관측 기준이 다릅니다. api는 사용자 응답 시간을 중심으로 운영하고, worker는 이벤트 처리량과 적체를 봅니다. batch는 작업마다 새 프로세스를 시작해 한 번 실행한 뒤 종료합니다. console은 사용자 트래픽과 분리된 내부 경로에서 운영자 작업을 처리합니다.

특히 콘솔을 분리한 이유는 보호에 가깝습니다. 운영자의 실수가 사용자 트래픽을 받는 프로세스에 영향을 주지 않게 하고, 권한과 감사 기록을 명확히 남기기 위해서입니다. console은 사용자 트래픽과 분리된 프로세스에서 도메인 모듈을 사용합니다. worker도 서비스 도메인별로 큐와 소비 경로를 나눠, 한 큐의 지연과 실패가 다른 큐의 처리를 곧바로 막는 문제를 줄입니다.

코드베이스 안은 수십 개의 모듈로 나뉘어 있습니다. 모듈 경계를 넘는 의존과 외부 연동에는 내부 인터페이스를 먼저 둡니다. 인프라 연동이 필요한 구현은 별도 모듈로 둡니다. 검색 엔진이나 대규모 언어 모델(Large Language Model, LLM)처럼 장애 처리와 테스트, 버전 변화의 영향을 격리해야 하는 기술을 도메인 규칙 안으로 밀어 넣지 않기 위해서입니다.

하나의 서버앱에서도 상태의 경계는 나눈다

하나의 서버앱이라고 해서 모든 상태를 한곳에 넣지는 않습니다. 원본 데이터는 아마존 관계형 데이터베이스 서비스(Amazon Relational Database Service, RDS)에 저장하고, 서비스 도메인에 따라 논리적인 데이터 경계를 나눕니다. 일부 서비스는 같은 물리 데이터베이스를 공유하지만, 코드에서는 각 도메인의 데이터 접근 모듈과 인터페이스로 경계를 지킵니다. 다른 모듈은 테이블을 직접 읽지 않고 소유 모듈이 제공하는 인터페이스를 통해 접근합니다.

캐시는 원본 데이터를 대신하지 않습니다. 자주 읽는 데이터는 로컬 캐시와 발키(Valkey)에 둡니다. 발키를 사용할 수 없을 때 주요 조회는 원본 데이터베이스로 돌아갑니다. 캐시는 원본 데이터베이스로 향하는 요청을 줄이는 완충 역할을 합니다.

메시징은 동기 요청에서 떼어낸 후속 작업을 연결합니다. 래빗엠큐(RabbitMQ)는 커뮤니티 내부 도메인 이벤트를 `worker`에 전달하고, 카프카(Kafka)는 전사 시스템과 이벤트를 주고받는 데 사용합니다. 외부 메시지 형식은 어댑터에서 내부 이벤트로 바꿔 도메인 코드로 바로 들어오지 않게 합니다.

검색은 원본 저장소가 아니라 RDS에서 파생된 조회 모델입니다. 오픈서치(OpenSearch)에는 검색과 유사 콘텐츠 조회에 필요한 데이터를 색인합니다. 색인이 늦거나 실패해도 원본 데이터는 RDS에 남아 있고, 재색인과 보정 작업으로 검색 데이터를 다시 만들 수 있습니다.

후속 작업은 이벤트로 나눠 서로의 실패를 격리한다

커뮤니티 서버앱은 요청을 저장하고 응답하는 데서 끝나지 않습니다. 글 하나가 저장되는 순간부터 여러 후속 작업이 시작됩니다. 동네생활 게시글 작성 경로에서는 api가 게시글을 저장합니다. 커밋이 끝나면 작성 결과를 응답하는 일과 생성 이벤트를 통한 후속 처리가 갈라집니다. 그 이벤트를 받은 worker에서는 검색 색인, 본문과 이미지의 임베딩 생성, 콘텐츠 검수, 배지 발급과 알림 발송 같은 후속 작업이 이어집니다.

이 연쇄에서 중요한 것은 격리입니다. 검색 색인이 실패해도 배지 발급은 진행되고, 어느 한 후속 작업의 실패도 글쓰기 자체를 막지 않습니다. 일부 작업은 경로별 정책에 따라 재시도하고, 검색 같은 파생 데이터는 재색인과 보정 작업으로 다시 맞춥니다.

메시징의 역할도 나뉩니다. 커뮤니티 내부의 도메인 이벤트와 전사 시스템 연계 이벤트를 구분하고, 외부 시스템의 메시지 형식이 도메인 코드로 스며들지 않게 내부 이벤트로 변환합니다. 동기 호출의 복잡성을 OpenAPI 계약으로 관리한다면, 비동기 연결의 복잡성은 이벤트 타입과 재시도, 보정 규칙으로 관리합니다.

동네생활 게시글 작성을 통해 아키텍처 흐름을 살펴본다

동네생활 게시글 작성은 이 구조가 실제로 어떻게 움직이는지 보여주는 흐름입니다. 웹앱은 인증된 사용자의 요청을 api로 보냅니다. 서버앱은 사용자와 지역, 작성 가능한 주제, 콘텐츠 정책을 확인합니다. 게시글 본문과 투표, 해시태그, 장소와 구독 상태처럼 함께 확정해야 하는 값은 하나의 데이터베이스 트랜잭션으로 저장합니다. 하나라도 실패하면 모두 되돌립니다.

트랜잭션이 끝나면 사용자는 작성된 글을 바로 볼 수 있습니다. 검색과 알림, 임베딩과 검수는 그 뒤의 이벤트로 시작합니다. 게시글 본문과 함께 즉시 확정해야 하는 데이터는 하나의 트랜잭션으로 저장하고, 검색과 알림처럼 늦게 처리해도 되는 작업은 이벤트로 분리한 것입니다.

장소 자동 태깅은 후속 작업 안에서도 관심사를 나눈 사례입니다. 외부 LLM은 게시글에서 장소 후보를 뽑고, 장소 검색 플랫폼은 실제 장소를 찾습니다. 거리와 지역 범위처럼 결과를 채택할 기준은 커뮤니티 도메인이 결정합니다. 외부 모델의 비결정적인 결과를 그대로 저장하지 않고, 플랫폼 모듈에서 변환하고 도메인 규칙으로 다시 판단합니다. 이 과정이 느리거나 실패해도 게시글 저장과 응답을 막지 않습니다.

검색 색인과 임베딩은 데이터베이스에서 파생된 데이터입니다. 후속 작업이 늦어지면 검색이나 유사 콘텐츠 노출은 지연될 수 있지만, 상세 조회는 원본 데이터로 계속 동작합니다. 재색인과 보정 작업은 이 차이를 다시 맞춥니다. 모든 데이터를 같은 시점에 맞추려 하지 않고, 상세 조회와 검색에 필요한 일관성 수준을 다르게 정한 것입니다.

후보 목록은 추천과 정책을 거쳐 개인화된 피드가 된다

읽기 흐름에서는 다른 복잡성이 나타납니다. 커뮤니티 홈 피드는 웹앱의 SSR 요청에서 시작합니다. 서버앱은 사용자와 지역 정보를 바탕으로 피드에 사용할 후보 목록을 구성합니다. 이 목록을 추천 엔진에 보내 사용자별로 순서를 정하고, 노출 범위와 콘텐츠 정책을 적용한 뒤 화면에 필요한 카드 목록으로 조립해 웹앱에 전달합니다. 다음 페이지에 사용할 후보 목록은 캐시에 남겨 같은 계산을 반복하지 않습니다.

추천 엔진이 늦거나 실패하면 서버앱이 만든 후보 목록의 기본 순서를 사용합니다. 노출 범위와 콘텐츠 정책은 그대로 적용합니다. SSR 자체가 일정 시간을 넘기거나 렌더링에 실패하면 웹앱은 CSR로 전환합니다. 완벽한 결과를 기다리게 하기보다, 개인화를 생략하거나 렌더링 방식을 바꿔 사용할 수 있는 화면을 먼저 보여주는 방식입니다.

현재 서버앱은 이런 화면 단위 데이터 조립을 많이 담당합니다. 서비스와 화면이 늘어나면서 비슷하지만 조금씩 다른 API도 함께 늘었습니다. 이 비용은 뒤에서 설명할 웹앱과 서버앱의 다음 경계로 이어집니다.

여러 서비스에서 반복되는 기능은 컴포넌트로 만들어 함께 쓴다

서버앱 안에는 여러 서비스가 함께 쓰는 제품 기능이 있습니다. 저희는 이런 재사용 단위를 컴포넌트(Component)라고 부릅니다. 컴포넌트는 코드에서 모듈로 구현되지만, 여기서는 여러 서비스가 함께 쓰는 기능과 책임을 뜻합니다. 서비스마다 반복해서 필요한 기능을 매번 새로 만들지 않기 위한 선택입니다.

예를 들어 게시글, 모임, 카페와 아파트가 사용하는 위치 정보 컴포넌트는 좌표를 저장하고 조회하는 공통 기능을 맡고, 거리와 노출 범위 같은 정책은 각 서비스가 결정합니다. 투표나 레퍼럴(Referral)처럼 여러 서비스에서 반복되는 기능도 같은 방식으로 공통 부분만 컴포넌트로 둡니다. 인증과 알림, 검색, 임베딩과 콘텐츠 메타데이터 같은 기반도 여러 서비스가 함께 사용합니다.

이 컴포넌트가 있어서 새 서비스가 시작될 때마다 같은 기능과 공통 연동을 처음부터 다시 만들지 않습니다. 다만 두 번째 사용처가 생겼다는 이유만으로 곧바로 공통화하지는 않습니다. 실제로 같은 이유로 변하는 규칙인지, 서비스마다 다른 부분을 공통 코드와 분리해 표현할 수 있는지 확인합니다. 여러 서비스가 함께 쓰는 만큼 도메인 경계와 계약, 운영 책임도 더 중요해집니다.

계약은 웹앱과 서버앱의 약속을 코드 생성과 자동 검사에 연결한다

마지막으로 이 구조를 붙잡아주는 것은 계약입니다. 초기에는 API 명세를 문서로 관리했습니다. 빠르게 시작하기에는 좋았지만 곧 한계가 왔습니다. 문서가 최신인지 믿기 어려웠고, 명세를 타입스크립트(TypeScript)와 코틀린(Kotlin) 코드로 옮기는 과정에서도 실수가 생겼습니다.

그래서 명세를 OpenAPI 계약으로 옮기고, 저장소에서 풀 리퀘스트(Pull Request, PR)로 검토하게 했습니다. 계약이 병합되면 중앙에서 코틀린 서버 스텁을 생성합니다. 대상 웹 저장소에는 계약 변경을 자동으로 알리고, 각 웹앱은 같은 계약에서 타입스크립 클라이언트를 생성합니다. 하위 호환을 깨는 변경은 지속적 통합(Continuous Integration, CI) 과정에서 걸러냅니다. 계약 모델 하나를 고치면 이를 참조하는 API에도 변경이 반영됩니다. 명세와 구현이 어긋날 가능성을 줄인 것입니다.

이 변화는 API 명세를 함께 다루는 방식도 바꿨습니다. 웹앱 엔지니어는 필요한 화면과 사용자 흐름을 바탕으로 응답 모델을 제안하고, 서버앱 엔지니어는 도메인 규칙과 데이터 일관성, 하위 호환성을 함께 검토합니다. 같은 계약을 PR에서 다루면서 API 명세는 한쪽이 작성해 전달하는 문서가 아니라, 사용자 경험과 도메인 규칙을 함께 맞추는 설계 도구가 되었습니다.

코드 생성과 스키마 비교가 필드의 의미와 정책 변화까지 자동으로 보장하지는 않습니다. 이 부분은 리뷰와 통합 테스트에서 확인합니다. 저는 명세를 잘 작성하는 것만큼, 명세가 개발 도구 체인에 얼마나 강하게 연결되어 있느냐가 중요하다고 생각합니다. 계약이 문서에 머물면 부채가 되기 쉽습니다. 계약이 코드 생성과 CI에 연결되면 구조를 지키는 장치가 됩니다. 이 전환 과정은 하이디(Heidi)가 쓴 API Design-First 접근방식 정착기에도 정리되어 있습니다.

단순함은 공짜가 아니다

이 정도 규모에서도 왜 하나의 서버앱으로 운영하느냐는 질문을 받을 수 있습니다. 천만 MAU라는 숫자를 보면 자연스럽게 더 잘게 나눠야 하는 것 아니냐고 생각할 수 있습니다. 저도 그 질문이 필요하다고 생각합니다. 당시 제품과 팀의 조건을 놓고 보면, 하나의 서버앱을 운영 가능한 형태로 유지하는 편이 더 적합했습니다.

2021년 가을 커뮤니티 시스템을 시작할 때의 판단은 단순했습니다. 서버앱은 단순하게 두자는 것이었습니다. 웹앱과 전사 플랫폼까지 포함한 전체 런타임은 여러 사용자 접점과 외부 시스템이 계약으로 연결된 분산 시스템입니다. 당시에는 핵심 도메인을 맡는 서버앱까지 여러 독립 서비스로 나눌 필요는 없다고 판단했습니다. 작은 팀이 서버앱을 만들고 운영해야 한다면, 구축과 테스트, 배포와 운영의 조율 비용이 낮은 모듈형 모놀리스가 더 현실적이었습니다. 지금 돌아봐도 당시 제품과 팀의 조건에는 적절한 선택이었다고 생각합니다.

이 선택이 주는 장점은 구체적입니다. 여러 저장소와 서비스 호출을 오가지 않고도 관련 도메인과 변경 영향을 한 코드베이스 안에서 추적할 수 있습니다. 핵심 흐름의 상당 부분을 하나의 프로세스와 데이터베이스 트랜잭션 안에서 처리할 수 있고, 여러 서비스 호출에 걸친 조율을 줄일 수 있습니다. 새로 합류한 동료도 하나의 시스템 안에서 제품과 코드의 연결을 따라가며 전체 구조를 빠르게 익힐 수 있습니다. 여러 서비스가 닮은 기능을 공유하고, 작은 팀이 오래 운영해야 하는 상황에서는 이 단순함이 강한 운영상의 이점이 됩니다.

하지만 모놀리스의 힘은 코드베이스가 하나라는 사실에서 나오지 않습니다. 하나의 코드베이스 안에서도 모듈 경계를 만들고, 모듈 사이의 의존 방향과 인터페이스를 규칙으로 지켜야 합니다. 그 규칙을 테스트와 배포 구조로 계속 검증할 때 단순함이 유지됩니다. 이렇게 지킨 경계는 향후 독립 배포가 필요해졌을 때 분리 비용과 위험을 낮춰줍니다. 다만 모듈만 나눴다고 독립 배포가 가능해지는 것은 아닙니다. 데이터 소유권과 트랜잭션, 호출 방향과 운영 책임도 함께 나눌 수 있어야 합니다. 반대로 이런 장치가 없다면 모놀리스는 단순한 구조가 아니라 커다란 진흙 덩어리(Big Ball of Mud)가 됩니다.

소프트웨어는 복잡할 수밖에 없습니다. 사용자 문제는 늘어나고, 제품은 서로 닮은 기능을 공유하며, 전사 플랫폼과 외부 시스템은 계속 연결됩니다. 그래서 중요한 것은 복잡성을 없애겠다는 선언이 아니라, 어떤 경계에서 어떤 복잡성을 다룰지 정하는 일입니다.

단순함은 복잡성이 없는 상태가 아닙니다. 복잡성이 숨어 있지 않고, 이름 붙은 경계 안에서 다뤄지는 상태에 가깝습니다.

커뮤니티 시스템은 이 복잡성을 네 가지 방법으로 관리해왔습니다. 모듈화와 관심사의 분리로 책임을 나누고, 리팩토링으로 경계를 계속 바꿨습니다. 디자인 패턴과 모델링으로 의존 방향과 협력 방식을 드러냈고, 자동화된 테스트로 그 규칙을 실행했습니다. 각각은 독립된 활동이 아니라 같은 구조를 계속 바꿀 수 있게 만드는 방법입니다.

하나의 코드베이스에서도 필요한 곳은 물리적으로 분리한다

하나의 코드베이스는 커뮤니티 서버앱의 기본 단위입니다. 여러 서비스가 같은 저장소 안에서 함께 자라기 때문에, 기능을 공유하기 쉽고 시스템 전체의 맥락을 한 번에 살필 수 있습니다.

그렇다고 모든 것을 같은 곳에 밀어 넣지는 않습니다. 성격이 다른 워크로드는 밖으로 분리합니다. 머신러닝 모델 서빙처럼 자바 가상 머신(Java Virtual Machine, JVM) 안에 품기보다 별도 생태계에서 다루는 편이 자연스러운 일도 있습니다. 기존 도메인과 성격이 다른 기능은 데이터베이스와 배포 단위를 격리하기도 합니다. 반대로 네트워크 왕복과 외부 운영이 오히려 복잡성을 키우고, 저희가 직접 통제해야 하는 의존은 내부 모듈로 가져오기도 합니다.

분산과 단일의 문제가 아니라 필요에 따른 경계의 문제입니다. 그 필요를 판단할 때마다 돌아가는 질문은 같습니다.

누가 이것을 관리하고 운용할 수 있는가?

좋은 구조는 이 질문을 피하지 않습니다.

하나의 릴리스 안에서도 실행 단위는 목적별로 나눈다

앞에서 살펴본 api, worker, batch, console은 같은 서버앱 릴리스로 함께 배포합니다. 다만 런타임에서는 역할별로 컨테이너를 나눠 실행하고, 각 워크로드에 맞춰 확장합니다. 도메인 코드는 함께 두되 실행의 성격은 나누는 방식입니다.

이 분리는 릴리스 경계를 나누지는 않습니다. 작은 변경도 네 실행 단위에 미치는 영향을 함께 검증하고 서버앱 전체의 배포 과정을 거쳐야 합니다. 대신 여러 서비스의 버전과 배포 순서를 따로 조율하는 복잡성은 만들지 않습니다. 실행의 격리를 얻는 대신 하나의 릴리스가 갖는 결합을 받아들인 선택입니다.

모듈화와 관심사의 분리로 책임을 나눈다

커뮤니티 서버앱의 서비스 도메인은 비즈니스 기능을 중심으로 수직으로 나뉩니다. 모임, 카페와 아파트처럼 충분히 커진 도메인은 다시 도메인 규칙, 데이터 접근과 관리 기능을 별도 모듈로 나눕니다. 도메인 모듈은 데이터베이스나 외부 클라이언트의 구현을 직접 알지 않습니다. 필요한 기능은 인터페이스로 요청하고, 인프라 모듈이 구현합니다. 애플리케이션 모듈은 이 둘을 조립합니다.

이 관점은 클린 아키텍처(Clean Architecture)의 원칙과도 맞닿아 있습니다. 중요한 것은 특정 계층 이름을 그대로 따르는 것이 아니라, 도메인 규칙과 외부 기술 관심사를 분리해 나중에 경계를 다시 배치할 수 있게 만드는 일입니다.

웹앱도 같은 문제를 다른 형태로 만납니다. 서비스별 애플리케이션을 독립적으로 운영하되, 렌더링 기반과 디자인 시스템, API 클라이언트처럼 함께 바뀌는 기능은 공유합니다. 카페는 웹뷰와 웹사이트로 나뉜 구현을 하나의 저장소로 모으면서, 채널별 렌더링과 인증은 나누고 공통 제품 로직은 함께 관리하는 방향으로 검토하고 있습니다.

제품과 사용자 접점, 애플리케이션의 경계는 서로 꼭 일치하지 않습니다. 서비스는 사용자 문제와 도메인을 책임지는 단위이고, 애플리케이션은 코드를 실행하는 단위입니다. 예를 들어 동네생활을 맡는 `web-agora`와 커뮤니티 탭의 `web-feed`, 프로필의 `web-profile`은 같은 저장소 안의 서로 다른 애플리케이션입니다. 서버에서도 여러 서비스의 도메인 모듈이 하나의 api 애플리케이션에 함께 담깁니다. 그래서 제품 경계만으로 코드와 배포 경계를 정하지 않고, 각 코드가 함께 바뀌는 이유와 운영 조건을 함께 봅니다.

모듈화는 코드를 많이 나누는 일이 아닙니다. 같은 이유로 바뀌는 코드를 묶고, 다른 이유로 바뀌는 코드를 나누는 일입니다. 물리적인 모듈 수보다 각 모듈이 무엇을 책임하고 누구와 어떤 계약으로 협력하는지가 더 중요합니다.

리팩토링으로 모듈을 붙이고, 떼고, 다시 붙인다

단순한 구조는 저절로 유지되지 않습니다. 하나의 시스템 안에서도 모듈은 계속 태어나고, 나뉘고, 사라집니다. 새 서비스가 시작되면 모듈 경계가 생깁니다. 이웃과 함께 사고 나누는 같이사요가 공개되었을 때처럼 제품이 새로 태어나면 시스템 안에도 새로운 책임이 필요해집니다. 같이해요가 반짝모임으로 리뉴얼된 것처럼 제품의 방향이 바뀌면 이미 있던 코드와 모듈도 다시 정리합니다.

도메인이 커지면 나누기도 합니다. 커진 도메인 모듈은 도메인 로직, 데이터 접근, 관리 기능처럼 더 작은 단위로 분해합니다. 여러 서비스가 함께 쓰게 된 기능은 임베딩, 위치 정보, 투표, 레퍼럴, 인증과 알림 같은 공통 컴포넌트로 끌어올립니다. 다만 두 서비스에서 비슷하게 보인다는 이유만으로 서둘러 공통화하지 않습니다. 실제로 같은 이유로 변하는 규칙이 확인될 때 공통 기반으로 올립니다.

반대로 다시 붙이는 일도 좋은 아키텍처의 일부입니다. 한때 분리했던 운영 도구의 경계를 다시 합치거나, 외부 왕복이 오히려 운영 복잡성을 키우는 의존을 내부 모듈로 가져오는 결정도 있었습니다. 과거에 선택한 기술을 정리하는 일도 같은 맥락입니다. 서로 다른 시기에 시작한 두 서비스가 다른 객체 관계 매핑(Object-Relational Mapping, ORM) 기술을 사용했지만, 두 기술의 혼용이 다른 개선을 가로막는 지점이 오자 여러 번의 작은 변경을 거쳐 하나로 수렴시켰습니다.

좋은 아키텍처는 모놀리스에서 출발해도 나중에 독립 배포 가능한 단위로 성장할 여지를 남겨야 합니다. 상황이 바뀌면 다시 모놀리스 형태로 돌아올 수도 있어야 합니다. 커뮤니티 시스템의 모듈은 논리적인 개념 경계이면서, 필요할 때 물리적인 분리를 시작할 수 있는 단위입니다.

목표는 항상 나누는 것이 아닙니다. 필요할 때 나누고, 필요할 때 다시 붙이고, 역할이 끝나면 제거하고, 계속 남겨야 할 경계는 도구로 유지하는 것입니다. 경계는 한 번 정하고 끝내는 것이 아니라, 서비스와 운영 조건이 바뀔 때마다 계속 조정해야 합니다.

디자인 패턴과 모델링은 협력과 의존 방향을 드러낸다

모듈 경계를 나눈 뒤에는 모듈이 어떻게 협력할지 정해야 합니다. 조회와 변경의 의도를 인터페이스 이름에서 구분하고, 트랜잭션은 하나의 사용자 작업을 조율하는 애플리케이션 계층에서 시작합니다. 외부 플랫폼은 어댑터 뒤에 두고, 외부 모델을 내부 모델로 변환합니다. 동기 API는 OpenAPI로, 비동기 작업은 이벤트 타입과 처리 규칙으로 계약을 드러냅니다.

앞에서 본 장소 자동 태깅은 이 모델링의 한 예입니다. LLM은 장소 후보를 만들고 장소 플랫폼은 실제 장소를 찾지만, 거리와 지역 범위를 판단해 결과를 채택하는 책임은 커뮤니티 도메인에 남깁니다. 외부 기술의 모델과 도메인 모델을 같은 것으로 취급하지 않으니, 모델 제공자나 검색 방식이 바뀌어도 지역 규칙을 함께 바꿀 필요가 없습니다. 디자인 패턴은 이름을 적용하는 일이 아니라 이런 책임과 의존 방향을 반복해서 드러내는 도구에 가깝습니다.

자동화된 테스트는 경계를 지키는 규칙을 실행한다

마지막 장치는 자동화된 테스트입니다. 경계는 문서에만 있으면 쉽게 흐려집니다. 그래서 모듈 사이의 의존 방향과 패키지 구조를 아키텍처 테스트로 검사합니다. 트랜잭션을 열 때는 어느 데이터베이스로 향하는지 명시하도록 강제합니다. 현재 시각과 난수처럼 실행할 때마다 값이 달라지는 요소는 외부에서 주입합니다. 테스트가 언제 실행되더라도 같은 조건을 재현할 수 있게 하기 위해서입니다.

모든 원칙을 CI에서 같은 방식으로 막는 것은 아닙니다. 조회와 변경 인터페이스의 이름, 캐시 키의 만료 시간처럼 설계 의도를 봐야 하는 원칙은 코드 작성 규칙과 리뷰로 지킵니다. 데이터베이스를 지정하지 않은 트랜잭션이나 현재 시각과 난수를 직접 호출하는 코드처럼 분명히 찾을 수 있는 위반은 CI가 차단합니다. 계약의 하위 호환성과 웹앱의 시각 회귀도 자동화된 검사로 확인합니다.

일부 규칙은 이미 쌓인 위반을 기준선으로 동결하고 새로운 위반만 차단합니다. 큰 코드베이스에 규칙을 한 번에 적용하려 하면 변화가 멈춥니다. 기존 현실은 인정하되, 새로 들어오는 위반을 막고 손대는 영역의 부채를 함께 줄이면 구조는 조금씩 나아집니다. 사람의 합의는 흐려질 수 있지만, CI에서 실행되는 규칙은 매번 같은 기준으로 시스템을 붙잡습니다.

물론 모든 경계가 완벽하지는 않습니다. 새 서비스가 기존 서비스의 기반을 이어받으며 생긴 예외도 있고, 충분히 커진 도메인은 지금도 다시 나누는 중입니다. 그래서 단순함은 완성된 상태가 아니라 계속 지불해야 유지되는 비용입니다.

모듈을 나눠도 복잡성과 기술 부채는 사라지지 않는다

이처럼 경계를 나눠 관리하는 모듈형 모놀리스도 공짜가 아닙니다. 모듈 수가 늘면 의존 관계와 빌드, 소유권을 관리하는 비용도 커집니다. 플랫폼 모듈을 너무 큰 단위로 두면 여러 서비스의 변경이 한곳에 몰립니다. 반대로 기능과 사용처마다 너무 잘게 나누면 이름만 보고 책임을 알기 어려워지고, 코드를 열어봐야 모듈의 역할을 이해할 수 있습니다. 경계를 긋는 일 자체가 새로운 설계 문제가 됩니다.

동네생활 도메인은 제품이 빠르게 성장하던 시기에 만들어져, 도메인과 데이터, 관리 기능을 물리적인 모듈로 충분히 나누지 못한 부분이 남아 있습니다. 이후 만든 서비스에는 이 경험을 반영해 책임별 모듈 구조를 적용했습니다. 일부 서비스도 도메인 모듈은 나뉘어 있지만 물리적인 데이터 저장소를 함께 사용합니다. 제품의 성장 속도를 먼저 선택하며 남은 과거의 유산이고, 지금은 조직의 소유권과 코드, 데이터 경계를 더 잘 맞추는 방향으로 다듬고 있습니다.

공유 모듈도 같은 양면을 가집니다. 한 번 만든 검색과 캐시, 메시징 기반을 여러 서비스가 함께 쓰면 개발과 운영을 반복하지 않아도 됩니다. 하지만 한 서비스의 부하가 공유 자원을 압박하면 다른 서비스까지 영향을 받을 수 있습니다. 논리적인 모듈 경계만으로 런타임의 장애 영향 범위가 자동으로 줄어들지는 않습니다. 그래서 실행 단위와 큐, 타임아웃과 폴백, 기능 제어와 재처리 같은 운영 경계를 함께 둬야 했습니다.

서비스를 물리적으로 분리하면 배포와 네트워크, 소유권과 장애 경계가 더 명시적으로 보입니다. 하지만 충분히 이해하지 못한 도메인 경계를 네트워크 너머로 옮긴다고 책임이 선명해지는 것은 아닙니다. 잘못된 모델과 강한 결합을 그대로 둔 채 분리하면 네트워크 실패, 데이터 일관성, 배포 순서와 운영 부담이라는 더 큰 복잡성을 만듭니다. 분리가 필요한 문제를 미루는 것도 위험하지만, 분리를 문제 해결 자체로 보는 것도 위험합니다.

커뮤니티 시스템은 제품을 빠르게 키우는 과정에서 수많은 트레이드오프를 선택했습니다. 그 선택 덕분에 필요한 제품을 사용자에게 더 빨리 전달할 수 있었지만, 동시에 기술 부채도 쌓였습니다. 저희가 해온 일은 처음부터 완벽한 경계를 설계하는 것이 아니었습니다. 지금 필요한 제품을 내보내되, 이후 변경의 비용을 크게 만드는 부채부터 갚아왔습니다.

부하는 성장하기 좋은 동기였다

성장은 부하로, 부하는 장애로 모습을 바꿔가며 우리를 찾아왔습니다. 그때마다 커뮤니티 시스템은 더 단단해졌고, 운영 장치가 하나씩 더해졌습니다.

초기의 장애는 빠른 실행의 대가에 가까웠습니다. 배포 직후 문제 쿼리로 데이터베이스 CPU 사용률이 100%까지 올라갔고, 쿼리를 강제로 종료해도 유입이 계속되면서 데이터베이스 부하가 전면 장애로 이어진 날이 있었습니다. 이 시기에 만들어진 중요한 습관은 회고였습니다. 장애의 타임라인을 분 단위로 복기하고, 원인과 대응을 모두가 보는 채널에 남겼습니다. 빠르게 움직이되 같은 실패를 반복하지 않도록 학습을 시스템 안에 남기는 방식이었습니다.

시스템이 커지자 장애는 용량과 런타임의 문제로 옮겨갔습니다. 데이터베이스 자체는 여유가 있는데 커넥션 풀이 소진되면서 전체 API 요청 처리에 지연과 실패가 생긴 적이 있었습니다. JVM 메이저 버전을 올린 직후 서버가 몇 시간 주기로 죽는 문제를 겪기도 했습니다. 이 시기에 확인한 것은 증상을 지우는 것과 원인을 아는 것은 다르다는 점입니다. 관측과 프로파일링 없이 증상만 없애면 같은 장애를 다시 만납니다.

최근의 장애는 시스템 사이의 상호작용에서 더 자주 옵니다. 이벤트 랜딩 페이지가 무거운 화면을 함께 열도록 만들어져 검색 부하가 크게 뛴 적이 있었고, 장애 상황에서 웹앱의 재시도가 트래픽 증폭기가 된다는 것도 확인했습니다. SSR 타임아웃 뒤에도 취소되지 않은 백엔드 요청의 소켓 연결이 누적돼 렌더링 서버가 불안정해진 적도 있었습니다. 그래서 대응은 요청 취소, 폴백, 회로 차단기, 기능 제어, 선제 스케일링처럼 아키텍처 수준으로 올라왔습니다.

실패를 없애려 하기보다 영향 범위를 줄이고 빠르게 복구할 수 있는 장치를 쌓았습니다. 호출 경계에는 타임아웃과 취소를 두고, 피드 조립에는 부분 응답과 폴백을 둡니다. 필요한 비동기 경로에는 실패 큐와 재처리 절차를 두고, 운영 중에는 기능 단위로 부하를 끊을 수 있게 합니다. 회고에서 발견한 문제는 문서에만 남기지 않고 코드와 테스트, 대시보드와 장애 대응 절차서로 옮깁니다.

관측도 시스템 아키텍처의 일부가 되었습니다. 애플리케이션 오류와 메트릭, 분산 추적을 함께 보고, 느린 데이터베이스 쿼리에서 원래 사용자 요청까지 거슬러 올라갈 수 있게 했습니다. 예상된 사용자 오류와 요청 취소는 서버 장애와 구분해 알림의 노이즈를 낮췄습니다. 장애 대응 자동화도 사람이 따르는 절차서의 기준에 맞춰 동작하게 해, 상황을 판단하는 기준을 일치시키고 있습니다.

이 변화는 웹앱을 만드는 사람들의 역할도 넓혔습니다. 커뮤니티 웹앱은 사용자 경험의 앞단을 책임지는 동시에, SSR을 운영하며 서버 사이드의 문제도 함께 다루게 되었습니다. 웹앱 엔지니어가 서버 로그를 읽고, 이벤트 루프 지연을 보고, 메모리 사용량과 프로세스 생명주기를 이해해야 하는 순간이 늘어났습니다. 메모리 누수 문제를 파고들고, 리액트(React)의 서스펜스(Suspense) 경계 하나가 SSR 중단과 CSR 전환, 프로세스 안정성에 어떤 영향을 주는지 재현하고 검증하는 일도 그 과정에 있었습니다.

이 경험은 웹앱과 서버앱의 역할을 다시 생각하게 만들었습니다. 사용자가 만나는 경험은 웹앱에서 시작하지만, 그 경험을 안정적으로 전달하려면 렌더링과 네트워크, 서버 프로세스, 장애 대응이 함께 맞물려야 합니다. 그래서 웹앱 엔지니어에게도 서버 사이드 트러블슈팅 역량이 중요해졌습니다. 부하는 시스템만 바꾼 것이 아니라, 팀이 문제를 바라보는 범위도 넓혔습니다.

운영에는 리듬이 필요합니다. 급할 때는 돈으로 시간을 사고, 이후 엔지니어링으로 되찾습니다. 명절 연휴를 앞두고 검색 클러스터를 미리 스케일업해 비용이 47% 늘어난 적이 있었지만, 연휴가 지난 뒤 무거운 쿼리 하나를 가벼운 쿼리 여러 개로 나누는 튜닝으로 그 비용을 회수했습니다. 데이터베이스 오토스케일링도 실제로 운영해 보고 실측 비용을 근거로 기각했습니다.

반대로 큰 비용을 들이지 않고 얻은 것도 있습니다. 메시지 브로커를 같은 사양의 새 세대 인스턴스로 교체하는 것만으로 CPU 사용률을 40% 줄였고, 메모리 저장소를 발키로 전환해 비용을 20% 넘게 줄였습니다. 이런 개선은 일회성 이벤트가 아닙니다. 피드 쿼리를 개선해 데이터베이스 CPU 사용률을 낮춰도, 서비스가 성장해 트래픽이 늘면 확보했던 여유는 다시 줄어듭니다. 그래서 개선은 프로젝트가 아니라 운영의 일상이어야 합니다.

돌아보면 부하는 우리가 무엇을 먼저 고쳐야 하는지 알려줬고, 팀을 성장시켰고, 아키텍처를 진화시켰습니다. 단순한 모놀리스를 운영 가능한 형태로 유지하는 장치들은 대부분 이런 부하와 장애를 지나며 생겼습니다.

시스템의 마찰을 줄여야 제품 변화를 사용자에게 더 빨리 전달할 수 있다

커뮤니티 시스템을 키우는 일은 사용자에게 보이는 기능을 만드는 일만은 아니었습니다. 팀이 매일 코드를 바꾸고, 배포하고, 장애를 추적하고, 서로의 영역을 넘나들 수 있게 만드는 개발 시스템도 함께 돌봐야 했습니다.

천만 MAU를 작은 팀이 운영하려면 제품 코드만 좋아져서는 부족합니다. 빌드가 느리면 작은 변경도 미뤄지고, 배포가 번거로우면 릴리스가 커지며, 디버깅 정보가 부족하면 장애 대응이 사람의 기억에 의존하게 됩니다. 웹 쪽 변경이 서버 쪽 변경을 기다리고, 반대로 API 변경이 화면 요구사항을 다시 확인하게 만드는 왕복도 규모가 커질수록 비용이 됩니다.

댄 노스(Dan North)는 Are we nearly there yet?에서 소프트웨어 전달의 목표를 비즈니스 임팩트까지 걸리는 시간을 줄이는 일이라고 말합니다. 여기에는 코드 작성만 들어 있지 않습니다. 문제를 이해하고, 설계하고, 구현하고, 검증하고, 배포하고, 사용자가 실제로 변화를 경험하기까지의 시간이 모두 들어 있습니다. 시스템의 마찰은 이 흐름을 방해하거나 늦추는 모든 저항입니다.

그래서 빌드와 배포 시간을 줄이고, 큰 모듈을 더 작게 나눠 병렬화할 수 있는 구조를 검토하고, 스택트레이스와 관련 소스 코드 위치, 관측과 알림을 정비하는 일도 시스템 운영의 일부로 다뤄왔습니다. 프론트엔드와 백엔드라는 직무 구분보다, 웹앱과 서버앱을 누가 끝까지 만들고 운영할지에 맞춰 역할을 다시 나누려는 시도도 같은 맥락입니다. 각자의 전문성은 유지하되, 옆 영역의 작은 변경을 이해하고 끝까지 가져갈 수 있어야 병목이 줄어듭니다.

의존성을 꾸준히 올리는 일도 같은 범주에 있습니다. 서버앱과 웹앱 모두 런타임, 프레임워크, 빌드 도구, 디자인 시스템, 관측 도구를 계속 업그레이드해 왔습니다. 이 일은 새 기능처럼 눈에 잘 띄지 않지만, 오래 미루면 모든 변경의 비용을 높입니다. 빌드 도구가 낡으면 개발 속도가 느려지고, 검색 클라이언트나 런타임이 뒤처지면 성능 개선과 장애 대응의 선택지가 줄어듭니다. 보안 패치나 지원 종료 일정이 다가오면 원래 하고 싶었던 제품 개발보다 급한 일이 되어버리기도 합니다.

중요한 것은 무작정 최신 버전을 따라가는 것이 아니었습니다. 한 서비스에서 먼저 올려보고, 테스트 환경에서 지표와 오류를 관찰하고, 롤백 경로를 확보하고, 문제가 된 동작은 테스트로 고정한 뒤 다음 단계로 넘어갔습니다. 웹앱에서는 스냅샷 기반의 시각 회귀 검사를 붙여 디자인 시스템과 번들러 업그레이드의 작은 깨짐을 더 빨리 찾으려 했습니다. 반복되는 버전업은 사람이 매번 기억해서 처리하기보다 규칙과 자동화로 옮기고, 일부 점검과 수정은 AI 도구에 맡기는 방향으로 바꾸고 있습니다.

정책 일관성도 중요한 마찰입니다. 사용자는 클라이언트와 서버의 기술 구분을 느끼지 않습니다. 게시글 글자 수 제한이나 모임 일정 유효 범위 같은 정책이 화면과 서버에서 다르게 동작하면, 사용자가 만나는 것은 시스템 내부의 경계가 아니라 제품의 불일치입니다. 그래서 정책은 한 계층의 문제가 아니라 시스템 전체의 문제로 봐야 합니다.

이런 마찰을 줄이는 일은 눈에 잘 띄지 않을 수 있지만, 더 적은 인원이 더 큰 시스템을 안전하게 바꾸기 위해 꼭 필요한 엔지니어링입니다. 단순함은 코드 구조에서만 생기지 않습니다. 팀이 매일 변경을 끝까지 가져갈 수 있는 작업 흐름에서도 생깁니다.

더 큰 성장은 시스템의 경계와 개발 방식에도 변화를 요구한다

천만이라는 기준점 위에서 당근 커뮤니티는 더 큰 성장을 준비하고 있습니다. 사용자가 늘고 콘텐츠와 상호작용이 많아지면 시스템의 규모와 복잡성도 함께 커집니다. 그래서 웹앱과 서버앱의 책임, 데이터와 실행 경계를 다시 살펴보고 있습니다. 무엇을 하나로 두고 무엇을 분리할지는 제품에 필요한 변화를 사용자에게 더 빠르고 안전하게 전달하는 데 어떤 구조가 나은지 보고 결정합니다.

The Nature of Software Development에서는 새 기능이 추가될 때마다 설계도 함께 개선해야 하며, 그 일을 리팩토링이라고 설명합니다. 저는 이 관점이 코드 내부에만 머물지 않는다고 생각합니다. 제품이 커지고, 채널이 늘고, 운영 책임이 넓어지면 시스템 경계도 함께 리팩토링해야 합니다. 커뮤니티 시스템이 웹앱과 서버앱의 역할을 다시 나누려는 이유도 여기에 있습니다.

지금도 웹앱과 서버앱은 계약 기반으로 연결되어 있습니다. 문제는 계약의 유무가 아니라 API의 단위입니다. 서비스가 작을 때는 특정 화면에 맞춘 API를 만드는 편이 개발 속도를 내기 쉽습니다. 하지만 화면이 많아지면 비슷하지만 조금씩 다른 API를 계속 만들고 연결하게 됩니다. 게시글을 조회한다는 같은 문제도 피드, 상세, 카페, 아파트 화면마다 다른 API로 늘어날 수 있습니다. 이 반복은 개발 속도를 늦추고, 화면을 바꿀 때마다 관련 API를 함께 만들고 수정하게 합니다.

첫 번째 과제는 화면 단위 API 중심의 구조에서 리소스 중심의 API로 이동하는 일입니다. 앞으로는 서버앱이 게시글, 댓글, 멤버십, 알림 같은 리소스와 도메인 규칙을 더 선명하게 제공하고, 웹앱이 화면에 맞는 데이터를 조립하는 책임을 더 가져가려 합니다. 이렇게 해야 피드나 프로필, 동네생활, 모임, 카페, 아파트의 화면이 더 빠르게 바뀔 수 있습니다.

모든 화면 API를 없애겠다는 뜻은 아닙니다. 지연 시간과 일관성 때문에 서버에서 한 번에 조립하는 편이 나은 화면에는 집계 API를 유지할 수 있습니다. 조회는 재사용 가능한 리소스 API를 기본으로 하되, 변경은 게시글 작성이나 모임 가입처럼 사용자의 행동 단위로 설계합니다. 웹앱은 채널별 표현과 화면 조립을 맡고, 권한과 검증, 트랜잭션과 핵심 도메인 규칙의 최종 책임은 서버앱에 둡니다.

웹앱의 역할이 커진다는 것은 단순히 화면 코드를 더 많이 쓴다는 뜻이 아닙니다. 채널별 경험과 화면 조립 책임을 더 분명히 갖는다는 뜻입니다. 카페처럼 검색으로 들어오는 일반 웹과 앱 안의 웹뷰를 함께 운영하는 서비스는 무엇을 공유하고 무엇을 나눌지 더 자주 판단해야 합니다.

두 번째 과제는 AI 기반 개인화입니다. 콘텐츠가 늘어날수록 중요한 것은 더 많은 콘텐츠를 저장하는 일이 아니라, 사용자에게 맞는 콘텐츠를 이해하고 연결하는 일입니다. 임베딩, 콘텐츠 메타데이터, 검색 역량은 결국 이 문제를 풀기 위한 토대가 됩니다. 이런 역량은 이미 동네생활, 모임, 카페와 아파트의 일부 유사 콘텐츠, 의미 검색, 피드와 AI 기능에서 활용되고 있습니다.

커뮤니티실에 전문 머신러닝 엔지니어가 따로 있지 않더라도 개인화는 피할 수 없는 과제입니다. 그렇다고 전문성 없이 추진한다는 뜻은 아닙니다. 개인화를 특정 전문 조직만의 일로 두지 않고, 제품 팀도 데이터 품질과 평가, 실험 결과를 이해하고 운영할 수 있는 역량을 쌓아야 합니다. 모델을 직접 만드는 역량만을 뜻하지 않습니다. 데이터를 모으고 품질을 평가하고, 프롬프트와 모델 호출을 제품 흐름에 연결하고, 실험 결과를 운영 가능한 시스템으로 만드는 힘이 필요합니다. 그래서 저희는 서비스마다 쌓아온 이런 역량을 검색과 추천, AI 기능에 재사용할 수 있는 공통 기반으로 연결하는 콘텐츠 플랫폼을 구축하고 있습니다. 이미 운영 중인 기능을 한데 모으고 적용 범위를 넓혀, 더 큰 성장을 준비하는 기반으로 만드는 일입니다.

세 번째 과제는 AI로 늘어난 코드 생산량을 어떻게 더 빠르게 사용자에게 전달할지입니다. AI 도구는 코드를 더 많이, 더 빠르게 만들 수 있게 합니다. 그러면 병목은 코드 작성에서 검증, 리뷰, 배포, 관측, 롤백으로 옮겨갑니다. 앞으로 커뮤니티 시스템은 더 많은 변경을 더 작은 단위로 검증하고, 더 빠르게 배포하고, 문제가 생겼을 때 더 빨리 되돌릴 수 있어야 합니다.

물론 이 변화는 엔지니어에게 더 넓은 책임을 요구합니다. 웹 쪽은 화면과 채널의 복잡성을 더 깊게 이해해야 하고, 서버 쪽은 도메인 경계를 더 선명하게 제공해야 합니다. 그래서 계약, 테스트, 관측, 페어링, 점진적인 책임 확대 같은 안전망이 함께 가야 합니다. 설계를 바꾸는 일은 한 번의 구조 변경이 아니라, 팀이 감당할 수 있는 속도로 경계를 다시 배치하는 일입니다.

결국 앞으로의 과제도 같은 질문으로 돌아옵니다. 무엇을 하나로 두고, 무엇을 나누며, 그 경계를 누가 책임질 수 있는가. 저희는 이 질문에 계속 답하며 더 큰 성장을 감당할 수 있는 시스템을 만들어가려 합니다.

단순함은 구조만으로 지켜지지 않는다

천만 MAU를 지탱한 것은 단순한 구조 그 자체가 아니라, 그 단순함을 운영 가능한 형태로 지켜온 방식이었습니다. 하나의 서버앱 안에서 경계를 계속 다시 긋고, 계약으로 웹앱과 서버앱을 연결하고, 테스트와 운영 리듬으로 규칙을 지켜온 시간이 지금의 커뮤니티 시스템을 만들었습니다.

천만 MAU는 끝점이 아니라 새로운 기준점입니다. 앞으로 당근 커뮤니티는 더 많은 콘텐츠, 더 정교한 개인화, 더 다양한 사용자 접점, 더 높은 안정성에 대한 요구를 감당해야 합니다. 저희가 중요하게 보는 것은 특정 아키텍처를 따르는 일이 아니라, 제품의 변화를 더 빠르게 사용자에게 전달해 실제 임팩트로 이어지게 하는 일입니다. 이를 위해 단순함의 힘을 잃지 않으면서 웹앱과 서버앱, 데이터와 실행의 책임을 계속 조정하려 합니다.

이 정도 규모의 시스템을 작은 팀이 감당할 수 있었던 것은 구조가 좋아서만은 아닙니다. 커뮤니티실 동료들이 그 구조를 매일 이해하고, 고치고, 지켜왔기 때문입니다. 장애를 복기하고, 계약을 고치고, 테스트를 세우고, 오래된 흔적을 걷어내고, 새로운 경계를 논의하며 시스템을 운영해왔습니다. 이 글에 담은 구조와 운영의 선택은 문제를 발견하고 고치며 더 나은 방향을 함께 찾아온 동료들의 일입니다. 이 멋진 동료들과 함께 일할 수 있어 즐겁습니다.

그리고 이 여정에 함께할 동료를 더 만나고 싶습니다. 저희가 찾는 사람은 제품 흐름 전체를 보고 필요한 경계를 넘나드는 프로덕트 엔지니어입니다. 서버앱 개발에 전문성을 두고, 제품 문제를 끝까지 해결하기 위해 웹앱, 데이터, 운영 도구와 AI까지 필요한 인접 영역으로 확장해 갈 백엔드 엔지니어와, 사용자 경험의 앞단에서 웹앱과 SSR을 운영하고 서버앱의 계약과 경계를 함께 다듬어갈 프론트엔드 엔지니어를 기다리고 있습니다.


천만 MAU를 지탱하는 커뮤니티 시스템을 소개해요 was originally published in 당근 기술 블로그 on Medium, where people are continuing the conversation by highlighting and responding to this story.