2년 전 진행했던 프로젝트에서 느꼈던 문제점들을 개선해보고자, 2025.05-07 개인 프로젝트를 진행했다.
기획은 대부분 이전 프로젝트와 동일하게 가져갔고, 초기 구현에서는
- 처음에는 따로 서비스 분리를 하지 않고 모놀리식으로 구현하되,
- 추후 필요해진다면 일부 서비스를 분리해내더라도 문제가 없도록 도메인 간 결합도를 낮춘 코드
를 지향하면서, 코드 컴플리트 스터디를 진행하며 학습했던 내용들을 녹여내고자 했다.
이전에 프로젝트들을 진행했을 때 항상 느꼈단 미묘한 이질감이 있다.
DB에 의존하지 않는 서비스 개발을 위해 JPA를 사용했지만,
1. 엔티티 코드에 JPA 관련 코드가 혼입되어, 도메인 모델이 JPA에 종속적이게 되고,
2. 서비스 계층이 모든 로직을 수행해, 매핑된 객체들이 단순 데이터 구조체 이상의 역할을 하지 않게 됨(객체 지향적이지 않음)
이 뭔가 찝찝하게 느껴지는 것들을 풀어내보고자, 다음과 같은 원칙을 세우고 계층 구조를 명확히 분리해 프로젝트를 진행했다.
presentation
,application
,domain
,infrastructure
의 네 계층으로 철저히 분리
- DB에서 불러온 JPA 엔티티를 바로 사용하지 않고, 조합 후 애그리거트 루트를 구성해 사용
- 도메인 객체에 가능한 한 많은 책임을 부여하고, 도메인 서비스는 단일 순수 객체만으로는 표현하기 어려운 로직에 한정
- 도메인 계층의 인프라 의존성을 최대한 배제하고, 인터페이스 및 순수 로직 위주로 설계
- 인프라 계층에는 되도록 구현체만 담기
- 서로 다른 도메인 간의 소통이 필요한 경우 직접 호출은 지양하고, 사용하는 쪽에 인터페이스를 정의하고 제공하는 쪽에서 구현체를 연결
이로써 DB 저장 방식이나 ORM 기술에 대한 의존은 인프라 수준에 숨기고, 애플리케이션 및 도메인 계층에서는 하위 인프라에 의존하지 않게 만들 수 있었다.
특히 객체 ID가 도메인 수준에서 객체를 유일하게 식별할 수 있게 하기 위한 식별자라는 판단 하에, 객체 ID 생성 또한 JPA @GeneratedValue
에 의존하던 방식에서 도메인의 책임으로 격상시켰다. 도메인 계층에 IdGenerator
인터페이스를 두고, 인프라에 구현체를 배치함으로써 ID 생성을 JPA 기술에 묶이지 않게 했고, 그 결과 JPA 환경 세팅이나 테스트 DB 없이 순수한 도메인 객체만으로도 비즈니스 로직을 검증할 수 있게 돼 훨씬 가볍고 빠르게 테스트 할 수 있었다.
☹️ 다만 JPA를 곧바로 사용하지 않고 한 단계 더 추상화해 사용하게 되면서 JPA의 더티 체킹 기능을 사용하지 못하게 되었고, 객체를 조합하고 분리하기 위한 코드량이 증가하게 됐다.
이전 프로젝트에서는 포스트 검색을 전처리 후 LIKE
절을 이용한 DB 조회로 처리했는데, 그 결과 글이 수십 개밖에 없음에도 체감할 만한 지연이 발생했고, 이에 따라 텍스트 검색에 특화된 검색 엔진 도입이 필요해졌다.
가장 먼저 후보로 생각했던 것은 Elasticsearch였는데, 업계 표준이기도 하고 가장 널리 사용되는, 검증된 검색 엔진이기에, 현재 학습해두면 이후 프로젝트에도 큰 도움이 될 것이라 생각했기 때문이다.
하지만 이번 프로젝트에서는 다음과 같은 제약 사항이 있었다.
제약 사항
- 구글 클라우드 무료 크레딧 사용(30만원 한도, 90일 기한)
- 16GB 메모리, 2코어 CPU, 100GB 스토리지의 단일 인스턴스 기준, 2달 반 ~ 3달 사용 가능
Elasticsearch의 경우
이기에, 이 프로젝트에서 사용하기에는 과한 투자라 판단되었고, 따라서 프로젝트에서 필요한, 가장 기본적인 검색 기능을 제공하면서도 경량화된 Meilisearch를 선택했다.
구분 | Elasticsearch | Meilisearch |
---|---|---|
기능 | 풍부함 | 상대적으로 제한적. 기본적인 검색 기능 지원 |
추후 사용 가능성 | 업계 표준. 학습 해두면 이후에 도움이 될 가능성이 높음 | 커뮤니티 규모가 작고, 잘 사용하지 않음. |
리소스 | 높은 메모리·CPU 요구 | 경량화되어 메모리·CPU 부담 적음 |
확장성 | 대규모 분산 시스템 지원 | 단일 인스턴스 중심, 대규모 분산은 제한적 |
학습 곡선 | 상대적으로 높음 | 상대적으로 낮음 |
프로젝트에서는 git과 비슷하게 unified format의 diff를 바탕으로 글의 변경 내역을 추적하는 기능을 추가했다.
프론트엔드에서 unified format의 diff 데이터를 포함해 요청을 보내면, 이를 조합해 원본 마크다운 문서를 재구성할 수 있고, 각 요청을 하나의 버전으로 DB에 저장하는 방식으로 관리.
이때 raw 마크다운을 그대로 저장하는 경우, 마크다운 문법이 검색 키워드 매칭에 영향을 미쳐 검색 정확도가 떨어질 수 있다고 판단해, 다음과 같은 저장 및 검색 플로우를 설계했다.
저장 플로우
검색 플로우
프로젝트에서 다뤘던 여러 데이터들은 그래프의 형태로 모델링할 있었는데, 구체적으로는 다음과 같다.
- 지식 그래프 : 글 간 관계 표현
- 단일 문서에 대한 git-like 브랜치 구조
- 폴더와 파일의 계층 구조
이전 프로젝트에서는 객체 그래프 탐색을 가능케하는 JPA의 특성을 살리고자 lazy fetch
를 이용했지만, 당연히 객체 그래프를 탐색할 때마다 DB I/O가 필요 이상으로 발생하는 문제가 발생하게 되고, 대안으로 애플리케이션 수준에서의 재귀적인 탐색 대신 RDB의 재귀 쿼리를 활용하는 방식도 가능하겠지만 성능 측면에서의 오버헤드가 크다.
제공하는 정보 형태와 저장되는 방식이 일관적이게 유지하는 것이 직관성과 유지보수성의 측면에서 이점이 있다고 판단해, 노드-관계형 데이터 저장 및 탐색에 특화된 Neo4j를 도입했다.
다만 NoSQL인 만큼 데이터 무결성의 측면에서 RDB 대비 약점을 가지기에 RDB(MySQL)을 SSOT로 두되, Neo4j에 중복 저장해 그래프 탐색이 필요한 경우에 활용하도록 했다.
도메인 간 호출이 필요한 경우에는 인터페이스를 통해 처리하도록 했다. 이벤트를 통해 조금 더 느슨한 결합을 가져갈 수도 있었지만, 모놀리식 프로젝트인만큼 호출 순서가 더 잘 드러나는 인터페이스가 흐름을 파악하기도 쉽고, 복잡도를 낮추는 데에도 도움을 주리라 생각했다.
이벤트는 특정 작업에 따르는 후속 작업이 필요한 경우(ex. 이종 데이터소스 중복 저장. 관심사가 아닌 부작용)에만 제한적으로 사용했고, 카프카 등의 메시지 브로커 대신 Spring ApplicationEvent
를 활용했다.
AggregateRoot
클래스를 만들어 이벤트를 생성, 수집하게 만들고, 각 도메인의 애그리거트 루트가 해당 클래스를 상속- 이벤트의 생성은 각 도메인 객체가, 이벤트의 발행은 애플리케이션 계층에서 도메인 객체의 이벤트 리스트를 참조, 발행
// 도메인 계층에서의 이벤트 생성 및 수집
abstract class AggregateRoot {
private val domainEvents = mutableListOf<DomainEvent>()
protected fun addEvent(event: DomainEvent) {
domainEvents.add(event)
}
fun pullEvents(): List<DomainEvent> {
val events = domainEvents.toList()
domainEvents.clear()
return events
}
}
//-------------------------------------------
// 애플리케이션 계층에서의 이벤트 발행
val events = domainObject.pullEvents()
events.forEach{ eventPublisher.publish(it) }
이렇게 모놀리식 프로젝트를 진행하면서 인증, 문서 CRUD 등의 기본적인 기능들의 구현이 완료되어 갈 때, 다음과 같은 의문이 들었다.
🤔 ‘이 정도면 충분히 도메인 간 결합도를 낮춘 코드인 것 같은데, 정말 곧바로 분리할 수 있을까?’
위와 같은 의문에서 실제로 서비스 분리를 해보았고, 결과적으로 프로젝트 완성에 있어서는 좋은 선택이 아니었다.