You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
[refactor/#369] Activity Bookmark 슬라이스 분리 및 테스트 구조 정리 (#372)
* docs: 본격적인 DDD 적용 전 문서 최신화
* test: Bookmark 단위 테스트 보강
* refactor: Bookmark 서비스를 별도로 분리
* refactor: 북마크 관련 파일들을 bookmark 패키지 아래로 이동
* docs: 변경 내역 문서 최신화
* docs: 엔티티 객체간 연결을 끊는 것은 별도 이터레이션으로 분리
* refactor: 에러도 별도 애그리게이트 에러로 분리
* refactor: 통합 테스트도 별도 패키지로 분리
* refactor: 테스트를 Nested 구조로 변경
* chore: 로컬에서 사용하기 위한 ES 없는 ITBase 생성
Copy file name to clipboardExpand all lines: docs/tactical-design.md
+9-9Lines changed: 9 additions & 9 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -15,8 +15,8 @@
15
15
| Source / Ingestion |`TechBlog`|`RssFeedItem`은 DTO/ACL 결과 |`blogUrl`과 `rssUrl`은 유일해야 한다. `lastCrawledAt`은 `markCrawled(LocalDateTime)`으로만 갱신된다. 기술 블로그는 RSS 수집 대상의 기준이다. |`TechBlog`가 Source 컨텍스트의 루트로 적절하다. **`markCrawled(LocalDateTime)` 도메인 메서드 누락** — 현재 Anemic Model 위험. |
16
16
| Post / Content |`Post`|`PostKeyword`, `PostDocument`, `ContentChunk`| URL은 유일해야 한다. 요약/짧은 요약은 `updateSummaries()`로만 교체된다. 키워드는 `clearKeywords() + addKeyword()` 조합으로만 교체된다. 임베딩 완료 시각은 `markAsEmbedded(LocalDateTime)`으로만 기록된다. `incrementViewCount()`는 비원자적 연산이므로 SQL atomic UPDATE 정책 적용 필요. |`Post`가 핵심 애그리거트 루트다. `PostKeyword`는 `Post` 내부 컬렉션으로 보는 것이 자연스럽다. **`incrementViewCount()` 동시성 정책 미결정** (§1.2 참조). |
17
17
| User Account |`User`|`UserInterestCategory`, `UserInterestKeyword`|`socialType + socialId` 조합은 유일해야 한다. 상태 전이는 `PENDING → ACTIVE → WITHDRAWN → PENDING(재활성화)` 경로만 허용된다. 관심 키워드는 반드시 선택된 관심 카테고리에 속해야 한다. 관심사 교체는 `replaceInterests()`로 단일 트랜잭션 내 불변식 검증과 함께 처리된다. |`User`가 루트다. 계정/온보딩/관심사 불변식을 소유한다. **`replaceInterests()` 도메인 메서드 누락** — 불변식 검증이 서비스 레이어에 산재. |
18
-
| Personalization Profile | 명시적 쓰기 애그리거트 없음 |`UserProfileDocument`, `UserActivityData`| 같은 `userId` 기준 현재 개인화 프로필 projection은 하나만 유지된다. 프로필 텍스트, 벡터, 핵심 키워드는 함께 재생성된다. | Personalization Profile은 aggregate보다 read model / application service 중심 컨텍스트다. 현재 `UserProfileService`가 생성 책임을 가진다. |
19
-
| Activity |`ReadPost`, `Bookmark`, `SearchHistory`| 없음 |`Bookmark`는 `userId + postId` 조합이 유일해야 한다. `ReadPost`는 같은 사용자+게시글 중복 저장을 허용하되 `isFirstRead`로 최초 읽기를 구분한다. `SearchHistory`는 같은 검색어를 중복 저장한다 (동일 검색어의 반복 횟수 자체가 개인화 관심 신호가 된다). 행동 기록은 삭제되지 않고 보존된다 (북마크 제외). | 각 행동 기록이 독립 record aggregate처럼 동작한다. 현재 코드명 `ScrabPost`는 표준 용어 `Bookmark`로 통일 대상이다 (코드 미반영, 유비쿼터스 언어 README의 문서-코드 동기화 상태 참조). |
18
+
| Personalization Profile | 명시적 쓰기 애그리거트 없음 |`PersonalizationProfileDocument`, `UserActivityData`| 같은 `userId` 기준 현재 개인화 프로필 projection은 하나만 유지된다. 프로필 텍스트, 벡터, 핵심 키워드는 함께 재생성된다. | Personalization Profile은 aggregate보다 read model / application service 중심 컨텍스트다. 현재 `PersonalizationProfileService`가 생성 책임을 가진다. |
19
+
| 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 경계 재설계는 별도 이슈로 다루는 편이 안전하다. |
20
20
| Search | 명시적 쓰기 애그리거트 없음 |`SearchResult` DTO, `PostDocument` read model | 검색어를 기반으로 검색 결과를 계산한다. 검색 결과는 저장되는 도메인 상태가 아니라 조회 결과다. | Search는 애그리거트보다 query service/read model 중심 컨텍스트다. |
21
21
| Recommendation |**표준: `RecommendationSet`** (현재 코드: `RecommendedPost` 단건) |`RecommendedPost`, `RecommendationHistory`| 같은 `userId + rankOrder` 조합은 유일해야 한다. 새 추천 저장 전 기존 추천은 모두 `RecommendationHistory`로 이동해야 한다. `rankOrder`는 1..N 연속이어야 한다. | 현재 `RecommendedPost` 단건이 루트 역할을 하지만 `RecommendationSet` 개념으로 리팩터링 대상이다 (코드 미반영, 유비쿼터스 언어 README의 문서-코드 동기화 상태 참조). |
22
22
| Auth / Security | 독립 애그리거트 없음 | Refresh Token 저장소, `UserPrincipal`| 토큰 발급/검증/갱신을 수행한다. 사용자 자체는 User Account 컨텍스트에 속한다. | Auth / Security는 도메인 애그리거트보다 보안 애플리케이션/인프라 컨텍스트다. |
@@ -86,11 +86,11 @@ createSocialUser() → PENDING
86
86
87
87
-`replaceInterests(List<EInterestCategory> categories, List<EInterestKeyword> keywords)` — 관심사 교체 시 "키워드는 선택된 카테고리에 속해야 한다"는 불변식을 Aggregate 내에서 검증하고 단일 트랜잭션으로 처리해야 한다. 현재 `InterestCommandService`가 `UserInterestCategory`/`UserInterestKeyword` 리포지토리를 직접 조작하며 불변식 검증이 서비스 레이어에 산재되어 있다.
- Personalization Profile 컨텍스트의 핵심 projection/read model.
92
92
- 활동 데이터, 관심사, 게시글 신호를 바탕으로 검색/추천용 개인화 프로필을 생성한다.
93
-
- 현재는 독립 write aggregate보다 `UserProfileService`가 생성하는 파생 모델에 가깝다.
93
+
- 현재는 독립 write aggregate보다 `PersonalizationProfileService`가 생성하는 파생 모델에 가깝다.
94
94
- 장기적으로는 `OnboardingCompleted`, `UserInterestsChanged`, `PersonalizedProfileGenerated` 이벤트 기반으로 분리할 후보다.
95
95
96
96
#### `ReadPost`, `Bookmark`, `SearchHistory`
@@ -130,7 +130,7 @@ createSocialUser() → PENDING
130
130
|`User`|`socialType + socialId` 조합 |`SocialIdentity`| 두 필드가 항상 함께 쓰이고, 조합 유일성이 불변식이다. VO로 묶으면 `equals`/유일성 검증이 명확해진다. |
131
131
|`User`|`nickName`, `email`, `profileImage`, `description`|`AccountProfile`| 계정 프로필 수정(`updateProfile()`)과 탈퇴 시 null 처리(`withdraw()`)가 같은 필드 묶음에 적용된다. |
132
132
|`RecommendedPost`|`rankOrder`|`RankOrder`| 1..N 연속 불변식을 생성 시점에 검증하는 VO로 표현할 수 있다. |
133
-
|`UserProfileDocument`|`profileVector` (float[]) |`EmbeddingVector`| 벡터 차원 수 검증, 유사도 계산 메서드를 VO에 위치시킬 수 있다. |
133
+
|`PersonalizationProfileDocument`|`profileVector` (float[]) |`EmbeddingVector`| 벡터 차원 수 검증, 유사도 계산 메서드를 VO에 위치시킬 수 있다. |
134
134
|`Post`|`summary`, `shortSummary`|`PostSummary`|`updateSummaries()` 단일 메서드로만 교체되는 두 필드를 VO로 묶으면 불변식이 명확해진다. |
135
135
136
136
> 주의: VO 추출은 "불변식 검증 책임이 어디에 있어야 하는가"를 기준으로 판단한다. 단순히 필드를 묶기 위한 추출은 오히려 복잡도를 높인다.
@@ -155,7 +155,7 @@ createSocialUser() → PENDING
155
155
| P0 | 기술 게시글이 색인됨 |`TechnicalPostIndexed`|`PostEmbeddingWriter`| Search, Recommendation | 검색/추천 가능한 콘텐츠가 되었음을 나타내는 핵심 이벤트다. |
156
156
| P0 | 온보딩이 완료됨 |`OnboardingCompleted`|`UserCommandService.completeOnboarding`| 개인화 프로필 생성 | 사용자 상태가 ACTIVE가 되고 관심사가 저장되어 개인화 프로필 생성이 가능해진다. |
157
157
| P0 | 사용자 관심사가 변경됨 |`UserInterestsChanged`|`InterestCommandService.updateUserInterests`| 개인화 프로필 재생성, 추천 재생성 | 현재도 관심사 변경 후 개인화 프로필 생성이 호출된다. 이벤트로 분리하기 좋은 지점이다. |
158
-
| P0 | 개인화 프로필이 생성됨 |`PersonalizedProfileGenerated`|`UserProfileService.generateUserProfileSync`| 추천 생성, 개인화 검색 준비 완료 | 현재 `UserProfileService`가 추천 생성을 직접 호출한다. 이벤트 분리 우선순위가 높다. |
158
+
| P0 | 개인화 프로필이 생성됨 |`PersonalizedProfileGenerated`|`PersonalizationProfileService.generatePersonalizationProfileSync`| 추천 생성, 개인화 검색 준비 완료 | 현재 `PersonalizationProfileService`가 추천 생성을 직접 호출한다. 이벤트 분리 우선순위가 높다. |
159
159
| P0 | 추천이 생성됨 |`RecommendationsGenerated`|`LlmRecommendationService.generateRecommendationsForUser`| Notification, Analytics | 사용자에게 보여줄 현재 추천 목록이 바뀌는 핵심 이벤트다. |
160
160
| P1 | 기술 게시글을 읽음 |`TechnicalPostRead`|`ActivityCommandService.saveReadPost`| 개인화 프로필 갱신, 추천 정책 | 읽기 행동은 개인화 프로필과 읽은 게시글 제외 정책의 핵심 입력이다. |
161
161
| P1 | 기술 게시글을 처음 읽음 |`TechnicalPostFirstRead`| 첫 읽기일 때 `Post.incrementViewCount`| 인기순 정렬, 분석 | 조회수 증가와 인기순 정렬에 직접 연결된다. |
@@ -187,7 +187,7 @@ createSocialUser() → PENDING
187
187
188
188
### 3.1 결정
189
189
190
-
현재 코드는 JPA `@ManyToOne`으로 다른 Aggregate의 Entity를 직접 참조하고 있다 (예: `Post → TechBlog`, `ScrabPost/Bookmark → User, Post`, `RecommendedPost → User, Post`).
190
+
현재 코드는 JPA `@ManyToOne`으로 다른 Aggregate의 Entity를 직접 참조하고 있다 (예: `Post → TechBlog`, `Bookmark → User, Post`, `RecommendedPost → User, Post`).
191
191
192
192
**결정: 현재 모놀리스 구조에서는 JPA 직접 참조를 유지한다. 단, 서비스 레이어에서 다른 Aggregate를 변경하는 코드는 금지한다.**
193
193
@@ -205,7 +205,7 @@ createSocialUser() → PENDING
205
205
206
206
### 3.3 예외 케이스
207
207
208
-
-`UserProfileService`는 현재 Personalization Profile 쪽 생성 책임을 맡는 Application Service다. `User`, `Activity`, `Recommendation` 등 여러 컨텍스트를 조합하지만, 각 Aggregate의 상태 변경은 해당 Aggregate의 도메인 메서드를 통해서만 수행해야 한다.
208
+
-`PersonalizationProfileService`는 현재 Personalization Profile 쪽 생성 책임을 맡는 Application Service다. `User`, `Activity`, `Recommendation` 등 여러 컨텍스트를 조합하지만, 각 Aggregate의 상태 변경은 해당 Aggregate의 도메인 메서드를 통해서만 수행해야 한다.
209
209
- 향후 컨텍스트 분리가 필요해지면 그 시점에 직접 참조를 ID 참조로 전환하고 ACL 또는 Anti-Corruption Layer를 도입한다.
210
210
211
211
---
@@ -277,7 +277,7 @@ skip count 임계값 초과 시 Step을 `FAILED`로 끝내는 skip limit 설정
277
277
278
278
### 4.4 Elasticsearch Projection 일관성 정책
279
279
280
-
`PostDocument`, `UserProfileDocument`는 RDB Aggregate의 read model projection이다.
280
+
`PostDocument`, `PersonalizationProfileDocument`는 RDB Aggregate의 read model projection이다.
281
281
282
282
**현재 정책: 동기 즉시 갱신 (RDB 저장과 같은 트랜잭션 외부에서 ES 저장 직접 호출)**
0 commit comments