3394 단어
17 분
Resona SNS 시스템 재구축

개요#

2주가 넘는 시간 동안 SNS 시스템을 재구축하였다. 이 과정에서 겪었던 고민과 기술적 선택을 기록으로 남기고자 한다. 모듈형 모놀리스(Modular Monolith) 구조를 채택하고, CQRS(Command and Query Responsibility Segregation) 패턴을 도입하여 피드 시스템을 설계한 이유와 그 과정을 공유하려 한다.

왜 모듈형 모놀리스를 선택했는가?#

결론부터 말하면, MSA의 장점은 취하면서도 모놀리스의 단순성을 유지하고 싶었기 때문이다.

초기 시스템은 채팅, 알림, 회원, SNS 기능이 하나의 거대한 모듈 안에서 뒤섞여 개발되었다. 프로젝트 초기에는 빠른 개발 속도를 낼 수 있었지만, 사용자가 늘어나게 되면 어떻게 될지 고민하다 몇 가지 명확한 문제에 부딪혔다.

가장 큰 문제는 비효율적인 확장이었다. 예를 들어, 채팅 시스템에 트래픽이 몰릴 때 서버를 증설하면, 부하가 없는 SNS나 알림 기능까지 불필요하게 함께 확장될 수 있다고 생각했다. 이는 심각한 자원 낭비로 이어질 것이라고 보았다. 또한, 여러 기능이 강하게 결합(Tightly Coupled)되어 있어 코드의 가독성이 떨어지고, 작은 수정이 다른 기능에 영향을 미치는 유지보수의 어려움도 커졌다.

그래서, 만약 채팅 시스템 혹은 SNS 시스템이 부하가 심하다면 각각의 모듈만 따로 분리하여 사용할 수 있게 해야 했다. 배포 환경에서도 분리는 충분히 가능하지만, 혼자 서버를 개발하는 입장에서 실제 프로덕트 환경에서 MSA로 전환하는 것은 시간 소요를 너무 많이 할 것 같았다. 그래서, 특정 모듈(예: 채팅)에 부하가 집중되면, 해당 모듈만 별도의 마이크로서비스로 쉽게 분리하여 유연하게 전환할 수 있게 하였다.

SNS 시스템#

기존 피드 시스템은 단순한 CRUD 기반의 계층형 아키텍처(Layered Architecture)로, 데이터베이스에서 피드를 최신순으로 가져오는 간단한 구조였다. 하지만 여러 기술적 조언과 실제 기업 사례를 접하며 중요한 사실을 깨달았다. 바로 SNS 피드의 핵심인 읽기(Read)와 쓰기(Write)의 요구사항이 근본적으로 다르다는 점이었다.

또한, Pull 방식이었던 현재 구조에서는 기능이 추가되고 데이터 관계가 복잡해질수록 MySQL의 조회(Read) 부담이 기하급수적으로 커질 수밖에 없었다. 이는 결국 피드 로딩 속도를 저하시켜 사용자 경험에 치명적인 영향을 줄 것이라 판단했다. 따라서, WriteDB(MySQL), ReadDB(MongoDB), Cache(Redis)를 도입하여 CQRS 패턴을 도입하였다.

모듈 분리#

가장 먼저 손을 댄 것은 뒤엉킨 코드의 경계를 명확히 나누는 일이었다. 기존의 단일 모듈 구조에서는 책임과 역할이 불분명했고, 이는 결국 유지보수 비용 증가로 이어질 것이 뻔했다. 그래서 각 기능의 역할을 기준으로 시스템을 여러 개의 논리적 모듈로 분리했다.

resona-architecture

  • Member (회원): 인증, 인가, 프로필 관리 등 사용자 계정에 관한 모든 책임을 담당한다.
  • Social Media (소셜 미디어): 피드, 댓글, 좋아요, 스크랩 등 SNS의 핵심 기능을 맡는다.
  • Chat (채팅): 실시간 메시징 기능을 독립적으로 처리한다.
  • Matching (매칭): 사용자 간의 연결을 담당하는 모듈이다.
  • Notification (알림): FCM 푸시 알림과 같이 사용자에게 전달되는 모든 알림을 관리한다.
  • (분리 예정)External (외부 연동): 이메일 발송, 파일 스토리지(OCI) 연동 등 외부 서비스와의 통신을 전담한다.

각 모듈은 자신만의 명확한 책임을 갖게 되었고, 다른 모듈과의 의존성(Coupling)은 최소화되었다. 예를 들어, socialMedia 모듈은 member 모듈의 존재는 알지만, 그 내부 구현 방식까지 알 필요가 없게 되었다. 오직 정의된 인터페이스를 통해서만 상호작용할 뿐이다.

이제 특정 기능을 수정하거나 개선할 때 다른 모듈에 미치는 영향을 최소화할 수 있게 되었다. 무엇보다, MSA로의 전환을 염두에 둔 이 설계 덕분에 미래에 특정 모듈, 예를 들어 트래픽이 폭주하는 채팅 시스템만 별도의 마이크로서비스로 분리해야 할 때, 전체 아키텍처를 뒤엎지 않고도 해당 모듈만 독립적으로 떼어내 배포할 수 있는 길을 열어두었다.

SNS Feed Command Flow#

resona-feed-command-flow

위 다이어그램은 사용자가 새로운 피드를 생성했을 때, 시스템 내부에서 발생하는 일련의 과정을 보여준다. 이 흐름은 크게 동기 처리(Synchronous) 단계와 비동기 처리(Asynchronous) 단계로 나뉘며, CQRS 패턴과 이벤트 기반 아키텍처의 장점을 활용하여 설계되었다.

사용자가 새로운 피드를 생성하면, 시스템은 먼저 동기 방식으로 핵심 작업을 처리한다. 피드 생성 요청을 받은 서비스는 데이터를 검증한 후, 원본 데이터를 쓰기 전용 DB(MySQL)에 저장하고 즉시 FeedCreatedEvent를 발행한다.

이벤트가 발행된 후부터는 비동기 방식으로 여러 후속 작업이 동시에 진행된다.

  1. Timeline 전파: FanoutService가 팔로워들의 개인 타임라인(Redis)에 새 피드를 전파(Fan-out)하여 다른 사용자들이 피드를 볼 수 있도록 한다.
  2. 조회 모델 동기화: CQRS 패턴에 따라, 피드 본문을 번역하고 조회/검색에 최적화된 문서(Document) 형태로 가공하여 읽기 전용 DB(MongoDB)에 저장한다. 이를 통해 빠르고 유연한 조회가 가능해진다.
  3. 데이터 정합성 유지: 번역된 데이터는 데이터 정합성을 위해 원본이 저장된 쓰기 전용 DB(MySQL)에도 업데이트된다.

SNS Feed Retrieval(Query) Flow#

resona-feed-retrieval-flow

위 다이어그램은 사용자가 피드를 요청했을 때, 개인화된 결과를 효율적으로 제공하기 위한 전체 조회 과정을 보여준다. 이 흐름의 핵심은 적절한 타임라인을 선택하고, 두 단계에 걸친 필터링을 통해 사용자에게 최적화된 피드를 빠르게 반환하는 것이다.

NOTE

여기서 개인화 피드는 팔로잉이나 팔로우한 사람의 새로운 피드를 말한다.(추후에 개인화 알고리즘은 넣을 수 있다면.?? 넣을 예정이다.) 공용 피드는 모든 사람이 공통적으로 보는 피드를 말한다.

  1. 타임라인 선택 및 후보군 조회: 사용자가 피드를 요청하면, 시스템은 먼저 개인화(Home) 피드인지 공용(Explore) 피드인지 판단한다. 요청 유형에 맞는 타임라인(Redis에 저장된)을 선택하여 피드 ID 목록(후보군)을 가져온다. 만약 개인화 타임라인이 비어있는 신규 사용자(Cold Start)라면, 공용 타임라인에서 데이터를 가져오는 것으로 대체하여 빈 화면이 보이지 않도록 처리한다.
  2. 1단계 사전 필터링: 가져온 피드 ID 목록을 대상으로 1차 필터링을 수행한다. 이 단계에서는 DB를 조회하기 전에 최대한 많은 대상을 걸러내는 것이 목적이다. 사용자가 이미 봤거나 숨긴 피드 ID를 제거하고, 무한 스크롤을 위한 페이지 커서(Cursor)를 적용하여 필요한 만큼의 ID만 남긴다.
  3. 상세 정보 조회 및 2단계 필터링: 1차 필터링을 통과한 ID들을 사용해 DB(MongoDB 등)에서 실제 피드 상세 정보를 조회한다. 그 후, 조회된 전체 콘텐츠를 대상으로 2차 필터링을 진행한다. 여기서는 사용자가 차단한 유저의 글이나 자기 자신이 작성한 글 등을 최종적으로 제거하여 개인에게 맞춰진 결과만 남긴다.
  4. 결과 반환 및 후처리: 모든 필터링이 끝난 데이터는 사용자에게 보여주기 좋게 번역, 가공된 후 최종적으로 반환된다. 동시에, 어떤 피드를 사용자에게 보여주었는지 비동기적으로 기록하여 다음 요청 시 ‘이미 본 피드’로 처리될 수 있도록 한다.

회고 (Retrospective)#

2주가 넘는 시간 동안 몰입하며 얻은 교훈과 솔직한 감정을 공유해보려 한다.

가장 어려웠던 점: 모듈간의 의존성 이슈#

재구축 과정에서 가장 어려웠던 것은 모듈 간의 의존성을 끊어내는 일이었다. 논리적으로는 분리했지만, 코드 수준에서는 여전히 서로를 암묵적으로 의존하는 경우가 많았다. 특히 여러 모듈이 공통으로 사용하는 로직을 core 모듈로 분리했는데, core 모듈과 application 모듈에 설정코드를 어떻게 분리해야하는지 헷갈리는 것과 고민이 많았다. “어설픈 모듈 분리는 안 하느니만 못하다”는 말을 뼈저리게 느끼며, 모듈 간의 경계를 명확히 하기 위해 끊임없이 코드를 다시 보고 생각하고 분리해야 했다.

또한, 모듈을 분리하자 기존의 테스트 코드가 모두 깨지는 예상치 못한 난관에 부딪혔다. 각 모듈이 독립적으로 테스트될 수 있도록 환경을 재구성하고, 모듈 간의 의존성을 Mocking으로 처리하는 과정은 또 다른 작은 프로젝트나 다름없었다. 이 과정에서 테스트 의존성을 관리하는 것이 얼마나 중요한지 다시 한번 깨닫게 되었다.(테스트 코드를 다시 짜야 하는 부분도 많다… 이건 찬찬히)

과거로 돌아간다면?#

만약 과거의 저에게 조언할 기회가 생긴다면, 주저 없이 “처음부터 모듈형 모놀리스로 시작하라”고 말할 것이다. 프로젝트 초기에는 빠른 개발 속도를 위해 단일 모놀리스 구조가 매력적으로 보일 수 있다. 하지만 서비스가 조금만 복잡해져도 그 대가는 혹독하게 돌아온다. 처음부터 각 기능의 경계를 명확히 나누고, 미래의 확장을 염두에 둔 설계를 했다면 이번 재구축에 들어간 시간과 노력을 더 가치 있는 곳에 쓸 수 있었을 거라는 아쉬움이 남는다.

앞으로의 계획#

이번 재구축을 통해 시스템의 뼈대는 견고해졌지만, 여전히 가야 할 길이 남아있다. 앞으로의 몇 가지 계획을 공유하며 이 여정을 계속 이어나가고자 한다.

CQRS 데이터 정합성 강화#

현재는 Write DBRead DB 간의 데이터 동기화를 이벤트 기반으로 처리하고 있어 최종적 일관성(Eventual Consistency)을 따른다. 대부분의 경우에는 문제가 없지만, 이벤트 발행에 실패하는 경우 일시적으로 데이터 불일치가 발생할 수 있다.

이를 보완하기 위해 재시도(Retry) 로직을 도입하여 이벤트 처리의 안정성을 높일 계획이다.

또한, 당분간은 아니겠지만 주기적으로 두 데이터베이스의 정합성을 검증하고 보정하는 배치(Batch) 프로그램을 도입하여 데이터 신뢰도를 한 단계 더 끌어올리려 한다.

피드 랭킹 시스템 도입#

현재 피드는 최신순, 팔로잉, 국가 및 항목별 같은 단순한 기준으로만 정렬되고 있다. 하지만 사용자에게 더 매력적인 콘텐츠를 제공하기 위해서는 정교한 랭킹 시스템이 필수적이다. 이제는 CQRS 아키텍처 덕분에 Read DB에 랭킹 점수나 추천 여부 같은 데이터를 유연하게 추가하고, 이를 조회 시에 활용할 수 있어 복잡한 랭킹 로직을 비교적 쉽게 통합할 수 있는 기반이 이미 마련되어 있다.

그래서 다음 단계로, 좋아요 수, 댓글 수, 조회 수 등 다양한 지표를 종합하여 인기 피드를 선정하고, 사용자의 관심사나 활동 이력을 기반으로 한 추천 피드를 제공하는 기능을 도입할 계획이다.

Resona SNS 시스템 재구축
https://blog-full-of-desire-v3.vercel.app/posts/resona/sns-system-struct/sns-system-redestructure/
저자
SpeculatingWook
게시일
2025-10-13
라이선스
CC BY-NC-SA 4.0