이 글은 Spring Boot로 SNS 플랫폼을 만들면서 남기는 학습 일지입니다.
이런 저런 설계 과정부터 개발, 배포까지 꾸준히 글을 남겨보려고 합니다!
경력은 4.X년 차지만, 솔직히 Spring은 입사 전 포트폴리오를 만들면서 사용해 본 이후 거의 다뤄보지 못했다. 현재 회사에서는 Java와 자체 프레임워크를 사용하고 있어 Spring Boot, JPA, Gradle 멀티모듈 같은 Spring 생태계를 제대로 경험할 기회가 많지 않았다.
그렇다고 백엔드 개발 경험이 부족했던 건 아니다. 최근에는 현업에서 Hexagonal Architecture를 직접 도입하며 프로젝트 구조를 설계하고 개발을 주도했다. LLM과 LangChain4j를 활용해 기존 제품에 AI 기능을 접목하는 업무도 진행하고 있다.
다만 설계 역량과 Spring 생태계 경험은 서로 대체할 수 있는 게 아니었다. 아는 것과 직접 구현해 보는 것은 분명 다르기 때문이다. Spring Boot, JPA, 멀티모듈 프로젝트, 테스트 전략, 트랜잭션 설계, 아키텍처까지 직접 고민하며 구현해 보고 싶었다. 이번 프로젝트는 그 공백을 메우기 위해 시작했다.
주제는 텍스트와 사진 기반 SNS 플랫폼이다. Threads처럼 단순한 글을 작성하는 서비스지만, 사용자 관계, 피드 조회, 좋아요, 댓글, 인증 등 실제 서비스에서 고민해야 할 다양한 요구사항을 자연스럽게 녹여낼 수 있는 도메인이라고 생각했다.
이 프로젝트의 목표는 단순히 CRUD를 구현하는 것이 아니다. Spring Boot와 JPA, 멀티모듈 구조, 테스트, 아키텍처를 직접 설계하고 구현하며 Spring 생태계를 깊이 있게 경험하는 것이 첫 번째 목표다.
여기에 한 가지를 더하고 싶었다. 현재 실무에서 LLM과 LangChain4j로 AI 기능을 개발하고 있는 만큼, 이번 프로젝트에서도 AI를 자연스럽게 녹여보고자 한다. 단순히 "AI를 붙였다"는 수준이 아니라 추천 피드, 관심사 기반 콘텐츠 추천, 검색 품질 개선처럼 실제 사용자 경험을 높이는 기능을 직접 설계하고 구현해 볼 예정이다. 궁극적으로는 Spring 기반 백엔드와 AI를 함께 활용해 서비스를 설계하고 개발할 수 있는 개발자가 되는 것, 그것이 이번 프로젝트의 목표다.
처음엔 "그냥 모놀리식으로 빠르게 만들면 안 되나?" 싶었다.
근데 SNS를 뜯어보니까 자연스럽게 서비스가 쪼개진다.
사실 성격이 다른 일이다. 피드 조회가 폭발적으로 늘어난다고 해서 알림 서비스를 같이 스케일 아웃할 필요는 없다.
MSA를 선택한 이유를 정리하면:
물론 단점도 있다. 서비스 간 통신 복잡도, 분산 트랜잭션, 데이터 일관성 문제. 하지만 이걸 직접 부딪혀보는 게 목적이었다.
일반적인 레이어드 아키텍처(Controller → Service → Repository)의 문제를 먼저 이해했다.
[레이어드 아키텍처의 흔한 문제]
Controller
↓
Service ──── 여기서 JPA Entity를 직접 쓰기 시작함
↓
Repository (JPA)
→ 비즈니스 로직이 DB 기술에 종속됨
→ DB를 바꾸면 서비스 로직도 손대야 함
→ 테스트할 때 DB 없으면 못 돌림
헥사고날(포트 & 어댑터) 아키텍처는 이 문제를 의존성 역전으로 푼다.

핵심 규칙은 딱 하나다:
의존성은 항상 안쪽으로. 도메인은 아무것도 모른다.
도메인 모델(User)은 JPA도, Spring도, HTTP도 모른다. 순수한 자바 객체다.
덕분에 DB를 H2에서 MySQL로 바꿔도, HTTP를 gRPC로 바꿔도 도메인은 건드리지 않아도 된다.
DDD(도메인 주도 설계)를 전술 패턴 수준에서 적용하기로 했다.
전략 패턴(바운디드 컨텍스트, 유비쿼터스 언어 등)까지 하면 범위가 너무 넓어지니까.
전술 패턴에서 챙긴 것들:
| 패턴 | 내가 적용한 방식 |
|---|---|
| Entity | User — 순수 자바, 식별자로 동일성 판단 |
| Repository (포트) | UserRepository — 인터페이스로 추상화 |
| Use Case | RegisterUseCase, GetUserUseCase — 행동을 인터페이스로 선언 |
| Command | RegisterCommand — 상태 변경 요청을 데이터 객체로 표현 |
DDD를 적용하면서 가장 크게 달라진 건 "어디에 뭘 둬야 하는가"가 명확해진다는 거다.
비즈니스 규칙은 도메인에, 흐름 조율은 서비스에, 기술 연결은 어댑터에.
SNS에서 피드를 어떻게 만들지가 가장 큰 설계 결정이었다.
Fan-out on Write (쓰기 시 분산)
게시글 작성 → 팔로워 모두의 타임라인에 미리 복사
장점: 읽기 빠름
단점: 팔로워 100만 명인 계정이 글 쓰면 100만 번 INSERT
Fan-out on Read (읽기 시 조합)
피드 요청 → 그때그때 팔로잉 목록의 최신 글 조합
장점: 쓰기 가벼움
단점: 읽기 느림, 팔로잉 많으면 복잡
현실적인 답: 하이브리드
일반 유저 → Fan-out on Write
팔로워 10만 이상 셀럽 → Fan-out on Read
지금 단계에선 단순하게 가고, 나중에 개선하는 방향으로 결정했다.
UUID를 쓰면 완전 랜덤값이라 DB B-Tree 인덱스에서 Page Split이 빈번하게 발생한다.
ULID는 앞 48비트가 타임스탬프라 시간순으로 정렬된 값이 들어온다.
UUID: 550e8400-e29b-41d4-a716-446655440000 (완전 랜덤)
ULID: 01HQ2MXYZ0000000000000AAAA (시간 + 랜덤)
실제 코드에서는 getMonotonicUlid()를 쓴다.
UlidCreator.getMonotonicUlid().toString();
// 같은 밀리초 안에도 단조 증가 보장 (항상 이전 것보다 큼)
일반 getUlid()는 같은 밀리초 안에서 랜덤값이 들어갈 수 있어서 순서가 역전될 수 있다. Monotonic은 같은 밀리초 안에서도 직전 값보다 크다는 걸 보장해줘서 정렬 안정성이 더 높다.
ULID를 쓰면 커서 페이지네이션도 단순해진다.
-- UUID였으면: 복합 커서 필요
WHERE (created_at, id) < (:cursorTime, :cursorId)
-- ULID는: id 하나로 시간 순서 + 유일성 모두 보장
WHERE id < :cursorId ORDER BY id DESC
실제 SNS들은 어떻게 하는지 찾아봤다.
deleted_at 컬럼)edit_history JSONB로 이력 보관
각 서비스는 독립된 헥사고날 구조를 갖는다.
서비스 간 통신은 나중에 다루려고 한다.
아키텍처를 선택하는 건 "뭐가 더 좋아 보이냐"가 아니라 트레이드오프를 이해하고 결정하는 것이라는 걸 알게 됐다.
MSA는 복잡도가 올라가는 대신 독립성을 얻는다.
헥사고날은 파일이 많아지는 대신 변경에 강해진다.
DDD는 코드량이 늘어나는 대신 의도가 명확해진다.
세 가지를 함께 쓰면 배울 게 많아진다. 그게 목적이었으니까 맞는 선택이었다.
다음 글에서는 실제로 Spring Boot 프로젝트 스켈레톤을 잡고, 헥사고날 폴더 구조를 어떻게 잡았는지 기록할 예정이다.