[SpringBoot] 뉴스피드 시스템 아키텍처 디자인하기

2025. 12. 11. 17:39SpringBoot

 

 

이번 포스팅은 뉴스피드 시스템 아키텍처를 설계하기 위한 구현 계획을 작성하고 이를 위한 팔로우 기능을 구현하겠습니다.

뉴스피드 시스템 아키텍처란?

SNS에서 팔로우중인 다른 유저가 만든 여러 피드들을 발행된 순서대로 볼 수 있는 시스템을 의미합니다.

여기서 피드는 다른 유저가 생성한 게시물 뿐 아니라, 광고, 유저 상태 등 넓은 범위의 컨텐츠를 포함할 수 있습니다.

필자의 경우엔 기존에 만들던 책 인용구 SNS 서비스를 뉴스피드 시스템으로 디자인하여 유저가 작성한 게시글을 서빙할 예정입니다.

 

Fan-Out Model

"내가 생성한 피드를 팔로워에게 보여준다"라는 행위를 구현하기 위해서, "확장한다"라는 의미를 가진 Fan-out 모델을 사용합니다. 즉, 팬아웃(Fan-out)은 어떤 사용자의 새 포스팅을 그 사용자와 친구 관계에 있는 모든 사용자에게 전달하는 과정을 의미합니다.

 

팬아웃은 쓰기 시점의 Fan-out On Write와 읽기 시점의 Fan-out On Read로 두개의 모델로 나뉩니다.

 

Fan-out On Write

이름처럼 그대로 포스트가 wirte 될 때 Fan-out 한다는 의미입니다. 

유저가 새로운 포스트를 포스팅하는 시점에, 팔로워들의 뉴스피드를 갱신하는 방법입니다.

 

장점

  • 뉴스 피드가 실시간으로 갱신 됩니다. 따라서 팔로워들이 바로 확인 가능합니다.
  • 팔로워가 뉴스피드를 조회 시, 지연 시간이 적습니다.

단점

  • 팔로워가 많은 유저의 경우 핫 키(hotkey) 문제가 발생합니다.
    • 핫 키 문제란, 일론 머스크와 같은 팔로워가 매우 많은(약 1억) 유저가 피드 포스팅 시 매우 많은 뉴스피드를 동시에 갱신해야하는 문제입니다.
  • 서비스 접속이 적은 유저들(비활성 유저)의 뉴스피드도 갱신합니다.

 

Fan-out On Read

유저가 뉴스피드를 읽는(호출) 시점에 갱신하는 방법입니다.

 

장점

  • 비활성 유저가 다시 활성활 될 때까지, 불필요한 갱신을 하지 않습니다.
  • 핫키 문제가 발생하지 않습니다.

단점

  • 뉴스피드를 읽는 시점에 많은 시간이 지연 됩니다.

 

구현 계획

현재 필자의 백엔드 서버는 모놀리식 아키텍처로 구축되어 있습니다.

여타 일반적인 뉴스피드 시스템 아키텍처 디자인에서는 MSA(Microservices Architecture)를 적용하지만, 이 프로젝트를 이미 단일 서버로 기획하고 구현한만큼 굳이 서비스를 분리하여 재구성하지 않겠습니다. 모놀리식 서버 상황에 맞게 뉴스피드 시스템 아키텍처를 구현할 예정입니다.

 

또한, 일반적으로 뉴스피드 시스템 아키텍처 내에서 서버 간 메시지 전달이 필요한 경우, 메시지 큐 시스템을 활용합니다.

그러나 현재 상황에선 Spring Boot의 이벤트 리스너 기능을 활용하여 단일 서버 안에서 내부적으로 메시지를 처리할 예정입니다. 이를 통해 불필요한 인프라 추가 없이도 이벤트 기반 뉴스피드 시스템을 구현할 예정입니다.

 

추가적으로 Fan-out On Write 형태로 구현할 예정입니다. 보통은 On Write + On Read 형태로 혼합해서 사용하지만 이 서비스의 유저 수가 많지 않을 것으로 예상되기 때문에 팔로워가 많은 유저로 인한 hot key 문제가 일어날 가능성을 적게 잡고 그 대신, 실시간 갱신을 높이는 방향에 초점을 두어 구현할 예정입니다.

 

포스트 생성부는 다음과 같이 구현할 예정입니다.

클라이언트가 포스트 생성 API를 호출해 포스트를 생성하면 RDBMS에 저장하고 Redis(aka. 포스트 캐시)에 캐싱합니다.

Redis에는  {post.id : post.content} 와 같은 형태로 저장합니다.

포스트의 id를 key로 하고 value에는 해당 포스트의 다양한 컨텐트(책제목,작성글.. 등등)를 저장합니다.

 

팬아웃부는 다음과 같이 구현합니다.

포스트 생성부에서 데이터들을 저장소에 저장하고 나면 이벤트를 발행합니다.

이벤트 메세지는 {post.id : user.id} 형태로 발행합니다.

여기서 postId는 포스트 생성 API로 생성된 포스트 아이디, userId는 포스트 생성 API를 요청한 유저의 아이디를 의미합니다. 

 

이벤트를 발행하고 나면 팬아웃 서비스에서 그 이벤트를 소비합니다. 

이벤트에 담긴 userId를 바탕으로 유저 서비스에서 해당 유저의 팔로워 목록을 불러옵니다.

 

불러온 팔로워 목록과 이벤트에 담긴 postId를 Redis(aka. 뉴스피드 캐시)에 저장합니다.

저장 형태는 {user.id : [post.id]}로 key에는 유저 서비스에서 조회해온 팔로워의 유저 아이디 값을 설정하고, value에는 뉴스피드에 들어갈 포스트의 아이디를 리스트로 저장합니다.

최신 포스트만 유지할 수 있도록 Redis에 값을 쓸 때, LPUSH를 이용해 포스트 아이디 값을 저장하며 리스트 길이를 40~50 정도로 trim하여 관리합니다.

 

뉴스피드 생성부는 다음과 같이 구현합니다.

 

클라이언트가 뉴스피드 조회 API를 호출하면 뉴스피드 서비스에서 이를 처리합니다.

뉴스피드 서비스에서는 뉴스피드 조회 API를 호출한 유저 아이디를 바탕으로, 뉴스피드 캐시에서 뉴스피드 생성에 사용할 포스트 아이디를 로드합니다.

 

로드한 포스트 아이디를 바탕으로 포스트 캐시에서 해당 포스트의 컨텐트를 로드합니다.

최종적으로 로드한 포스트 컨텐트를 가지고 최종 뉴스피드를 생성한 후 이를 JSON으로 변환하여 클라이언트에게 내려줍니다.

 

이러한 뉴스피드 시스템을 만들기 위해서는 첫번째로 팔로우 관계가 필요합니다.

따라서 다음과 같이 팔로우 관계를 구현해보겠습니다.

 

팔로우 관계 구현하기

먼저 팔로우 엔티티를 생성하고 팔로우 하는 사람과 받는 사람을 일대다 관계로 매핑합니다.

 

팔로우 생성 API를 구현합니다. 

(컨트롤러)

(서비스)

 

팔로우 삭제 API도 구현합니다.

(컨트롤러)

(서비스)

 

팔로워 목록을 조회하는 API를 구현합니다.

(컨트롤러)

(서비스)

(리포지토리)

 

추가로, 팔로잉 목록을 조회하는 API도 구현합니다.

(컨트롤러)

(서비스)

(리포지토리)

 

 

 

+ 뉴스피드 Fan-out On Write를 설계하면서 이해하는데 도움이 되었던 글이라 같이 공유합니다!

https://medium.com/peppermint100/%EB%89%B4%EC%8A%A4-%ED%94%BC%EB%93%9C-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%84%A4%EA%B3%84-3abf1ad38f4f