Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
342 changes: 262 additions & 80 deletions docs/ddd-test-refactoring-roadmap.md

Large diffs are not rendered by default.

18 changes: 9 additions & 9 deletions docs/tactical-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@
| Source / Ingestion | `TechBlog` | `RssFeedItem`은 DTO/ACL 결과 | `blogUrl`과 `rssUrl`은 유일해야 한다. `lastCrawledAt`은 `markCrawled(LocalDateTime)`으로만 갱신된다. 기술 블로그는 RSS 수집 대상의 기준이다. | `TechBlog`가 Source 컨텍스트의 루트로 적절하다. **`markCrawled(LocalDateTime)` 도메인 메서드 누락** — 현재 Anemic Model 위험. |
| Post / Content | `Post` | `PostKeyword`, `PostDocument`, `ContentChunk` | URL은 유일해야 한다. 요약/짧은 요약은 `updateSummaries()`로만 교체된다. 키워드는 `clearKeywords() + addKeyword()` 조합으로만 교체된다. 임베딩 완료 시각은 `markAsEmbedded(LocalDateTime)`으로만 기록된다. `incrementViewCount()`는 비원자적 연산이므로 SQL atomic UPDATE 정책 적용 필요. | `Post`가 핵심 애그리거트 루트다. `PostKeyword`는 `Post` 내부 컬렉션으로 보는 것이 자연스럽다. **`incrementViewCount()` 동시성 정책 미결정** (§1.2 참조). |
| User Account | `User` | `UserInterestCategory`, `UserInterestKeyword` | `socialType + socialId` 조합은 유일해야 한다. 상태 전이는 `PENDING → ACTIVE → WITHDRAWN → PENDING(재활성화)` 경로만 허용된다. 관심 키워드는 반드시 선택된 관심 카테고리에 속해야 한다. 관심사 교체는 `replaceInterests()`로 단일 트랜잭션 내 불변식 검증과 함께 처리된다. | `User`가 루트다. 계정/온보딩/관심사 불변식을 소유한다. **`replaceInterests()` 도메인 메서드 누락** — 불변식 검증이 서비스 레이어에 산재. |
| Personalization Profile | 명시적 쓰기 애그리거트 없음 | `UserProfileDocument`, `UserActivityData` | 같은 `userId` 기준 현재 개인화 프로필 projection은 하나만 유지된다. 프로필 텍스트, 벡터, 핵심 키워드는 함께 재생성된다. | Personalization Profile은 aggregate보다 read model / application service 중심 컨텍스트다. 현재 `UserProfileService`가 생성 책임을 가진다. |
| Activity | `ReadPost`, `Bookmark`, `SearchHistory` | 없음 | `Bookmark`는 `userId + postId` 조합이 유일해야 한다. `ReadPost`는 같은 사용자+게시글 중복 저장을 허용하되 `isFirstRead`로 최초 읽기를 구분한다. `SearchHistory`는 같은 검색어를 중복 저장한다 (동일 검색어의 반복 횟수 자체가 개인화 관심 신호가 된다). 행동 기록은 삭제되지 않고 보존된다 (북마크 제외). | 각 행동 기록이 독립 record aggregate처럼 동작한다. 현재 코드명 `ScrabPost`는 표준 용어 `Bookmark`로 통일 대상이다 (코드 미반영, 유비쿼터스 언어 README의 문서-코드 동기화 상태 참조). |
| Personalization Profile | 명시적 쓰기 애그리거트 없음 | `PersonalizationProfileDocument`, `UserActivityData` | 같은 `userId` 기준 현재 개인화 프로필 projection은 하나만 유지된다. 프로필 텍스트, 벡터, 핵심 키워드는 함께 재생성된다. | Personalization Profile은 aggregate보다 read model / application service 중심 컨텍스트다. 현재 `PersonalizationProfileService`가 생성 책임을 가진다. |
| Activity | `ReadPost`, `Bookmark`, `SearchHistory` | 없음 | `Bookmark`는 `userId + postId` 조합이 유일해야 한다. `ReadPost`는 같은 사용자+게시글 중복 저장을 허용하되 `isFirstRead`로 최초 읽기를 구분한다. `SearchHistory`는 같은 검색어를 중복 저장한다 (동일 검색어의 반복 횟수 자체가 개인화 관심 신호가 된다). 행동 기록은 삭제되지 않고 보존된다 (북마크 제외). | 각 행동 기록이 독립 record aggregate처럼 동작한다. `Bookmark`는 `domain/activity/bookmark` slice 아래로 분리되었고 `BookmarkCommandService`/`BookmarkQueryService`/`BookmarkConverter`와 `bookmarks`/`bookmarked_at` rename migration까지 반영되어 있다. `ManyToOne -> id reference` 같은 aggregate 경계 재설계는 별도 이슈로 다루는 편이 안전하다. |
| Search | 명시적 쓰기 애그리거트 없음 | `SearchResult` DTO, `PostDocument` read model | 검색어를 기반으로 검색 결과를 계산한다. 검색 결과는 저장되는 도메인 상태가 아니라 조회 결과다. | Search는 애그리거트보다 query service/read model 중심 컨텍스트다. |
| Recommendation | **표준: `RecommendationSet`** (현재 코드: `RecommendedPost` 단건) | `RecommendedPost`, `RecommendationHistory` | 같은 `userId + rankOrder` 조합은 유일해야 한다. 새 추천 저장 전 기존 추천은 모두 `RecommendationHistory`로 이동해야 한다. `rankOrder`는 1..N 연속이어야 한다. | 현재 `RecommendedPost` 단건이 루트 역할을 하지만 `RecommendationSet` 개념으로 리팩터링 대상이다 (코드 미반영, 유비쿼터스 언어 README의 문서-코드 동기화 상태 참조). |
| Auth / Security | 독립 애그리거트 없음 | Refresh Token 저장소, `UserPrincipal` | 토큰 발급/검증/갱신을 수행한다. 사용자 자체는 User Account 컨텍스트에 속한다. | Auth / Security는 도메인 애그리거트보다 보안 애플리케이션/인프라 컨텍스트다. |
Expand Down Expand Up @@ -86,11 +86,11 @@ createSocialUser() → PENDING

- `replaceInterests(List<EInterestCategory> categories, List<EInterestKeyword> keywords)` — 관심사 교체 시 "키워드는 선택된 카테고리에 속해야 한다"는 불변식을 Aggregate 내에서 검증하고 단일 트랜잭션으로 처리해야 한다. 현재 `InterestCommandService`가 `UserInterestCategory`/`UserInterestKeyword` 리포지토리를 직접 조작하며 불변식 검증이 서비스 레이어에 산재되어 있다.

#### `UserProfileDocument` / Personalization Profile
#### `PersonalizationProfileDocument` / Personalization Profile

- Personalization Profile 컨텍스트의 핵심 projection/read model.
- 활동 데이터, 관심사, 게시글 신호를 바탕으로 검색/추천용 개인화 프로필을 생성한다.
- 현재는 독립 write aggregate보다 `UserProfileService`가 생성하는 파생 모델에 가깝다.
- 현재는 독립 write aggregate보다 `PersonalizationProfileService`가 생성하는 파생 모델에 가깝다.
- 장기적으로는 `OnboardingCompleted`, `UserInterestsChanged`, `PersonalizedProfileGenerated` 이벤트 기반으로 분리할 후보다.

#### `ReadPost`, `Bookmark`, `SearchHistory`
Expand Down Expand Up @@ -130,7 +130,7 @@ createSocialUser() → PENDING
| `User` | `socialType + socialId` 조합 | `SocialIdentity` | 두 필드가 항상 함께 쓰이고, 조합 유일성이 불변식이다. VO로 묶으면 `equals`/유일성 검증이 명확해진다. |
| `User` | `nickName`, `email`, `profileImage`, `description` | `AccountProfile` | 계정 프로필 수정(`updateProfile()`)과 탈퇴 시 null 처리(`withdraw()`)가 같은 필드 묶음에 적용된다. |
| `RecommendedPost` | `rankOrder` | `RankOrder` | 1..N 연속 불변식을 생성 시점에 검증하는 VO로 표현할 수 있다. |
| `UserProfileDocument` | `profileVector` (float[]) | `EmbeddingVector` | 벡터 차원 수 검증, 유사도 계산 메서드를 VO에 위치시킬 수 있다. |
| `PersonalizationProfileDocument` | `profileVector` (float[]) | `EmbeddingVector` | 벡터 차원 수 검증, 유사도 계산 메서드를 VO에 위치시킬 수 있다. |
| `Post` | `summary`, `shortSummary` | `PostSummary` | `updateSummaries()` 단일 메서드로만 교체되는 두 필드를 VO로 묶으면 불변식이 명확해진다. |

> 주의: VO 추출은 "불변식 검증 책임이 어디에 있어야 하는가"를 기준으로 판단한다. 단순히 필드를 묶기 위한 추출은 오히려 복잡도를 높인다.
Expand All @@ -155,7 +155,7 @@ createSocialUser() → PENDING
| P0 | 기술 게시글이 색인됨 | `TechnicalPostIndexed` | `PostEmbeddingWriter` | Search, Recommendation | 검색/추천 가능한 콘텐츠가 되었음을 나타내는 핵심 이벤트다. |
| P0 | 온보딩이 완료됨 | `OnboardingCompleted` | `UserCommandService.completeOnboarding` | 개인화 프로필 생성 | 사용자 상태가 ACTIVE가 되고 관심사가 저장되어 개인화 프로필 생성이 가능해진다. |
| P0 | 사용자 관심사가 변경됨 | `UserInterestsChanged` | `InterestCommandService.updateUserInterests` | 개인화 프로필 재생성, 추천 재생성 | 현재도 관심사 변경 후 개인화 프로필 생성이 호출된다. 이벤트로 분리하기 좋은 지점이다. |
| P0 | 개인화 프로필이 생성됨 | `PersonalizedProfileGenerated` | `UserProfileService.generateUserProfileSync` | 추천 생성, 개인화 검색 준비 완료 | 현재 `UserProfileService`가 추천 생성을 직접 호출한다. 이벤트 분리 우선순위가 높다. |
| P0 | 개인화 프로필이 생성됨 | `PersonalizedProfileGenerated` | `PersonalizationProfileService.generatePersonalizationProfileSync` | 추천 생성, 개인화 검색 준비 완료 | 현재 `PersonalizationProfileService`가 추천 생성을 직접 호출한다. 이벤트 분리 우선순위가 높다. |
| P0 | 추천이 생성됨 | `RecommendationsGenerated` | `LlmRecommendationService.generateRecommendationsForUser` | Notification, Analytics | 사용자에게 보여줄 현재 추천 목록이 바뀌는 핵심 이벤트다. |
| P1 | 기술 게시글을 읽음 | `TechnicalPostRead` | `ActivityCommandService.saveReadPost` | 개인화 프로필 갱신, 추천 정책 | 읽기 행동은 개인화 프로필과 읽은 게시글 제외 정책의 핵심 입력이다. |
| P1 | 기술 게시글을 처음 읽음 | `TechnicalPostFirstRead` | 첫 읽기일 때 `Post.incrementViewCount` | 인기순 정렬, 분석 | 조회수 증가와 인기순 정렬에 직접 연결된다. |
Expand Down Expand Up @@ -187,7 +187,7 @@ createSocialUser() → PENDING

### 3.1 결정

현재 코드는 JPA `@ManyToOne`으로 다른 Aggregate의 Entity를 직접 참조하고 있다 (예: `Post → TechBlog`, `ScrabPost/Bookmark → User, Post`, `RecommendedPost → User, Post`).
현재 코드는 JPA `@ManyToOne`으로 다른 Aggregate의 Entity를 직접 참조하고 있다 (예: `Post → TechBlog`, `Bookmark → User, Post`, `RecommendedPost → User, Post`).

**결정: 현재 모놀리스 구조에서는 JPA 직접 참조를 유지한다. 단, 서비스 레이어에서 다른 Aggregate를 변경하는 코드는 금지한다.**

Expand All @@ -205,7 +205,7 @@ createSocialUser() → PENDING

### 3.3 예외 케이스

- `UserProfileService`는 현재 Personalization Profile 쪽 생성 책임을 맡는 Application Service다. `User`, `Activity`, `Recommendation` 등 여러 컨텍스트를 조합하지만, 각 Aggregate의 상태 변경은 해당 Aggregate의 도메인 메서드를 통해서만 수행해야 한다.
- `PersonalizationProfileService`는 현재 Personalization Profile 쪽 생성 책임을 맡는 Application Service다. `User`, `Activity`, `Recommendation` 등 여러 컨텍스트를 조합하지만, 각 Aggregate의 상태 변경은 해당 Aggregate의 도메인 메서드를 통해서만 수행해야 한다.
- 향후 컨텍스트 분리가 필요해지면 그 시점에 직접 참조를 ID 참조로 전환하고 ACL 또는 Anti-Corruption Layer를 도입한다.

---
Expand Down Expand Up @@ -277,7 +277,7 @@ skip count 임계값 초과 시 Step을 `FAILED`로 끝내는 skip limit 설정

### 4.4 Elasticsearch Projection 일관성 정책

`PostDocument`, `UserProfileDocument`는 RDB Aggregate의 read model projection이다.
`PostDocument`, `PersonalizationProfileDocument`는 RDB Aggregate의 read model projection이다.

**현재 정책: 동기 즉시 갱신 (RDB 저장과 같은 트랜잭션 외부에서 ES 저장 직접 호출)**

Expand Down
Loading