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
8 changes: 5 additions & 3 deletions docs/tactical-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
| Post / Content | `Post` | `PostKeyword`, `PostDocument`, `ContentChunk` | URL은 유일해야 한다. 요약/짧은 요약은 `updateSummaries()`로만 교체된다. 키워드는 `clearKeywords() + addKeyword()` 조합으로만 교체된다. 임베딩 완료 시각은 `markAsEmbedded(LocalDateTime)`으로만 기록된다. 조회수 증가는 `PostCommandService`/`PostRepository`의 SQL atomic UPDATE 경로로만 처리한다. | `Post`가 핵심 애그리거트 루트다. `PostKeyword`는 `Post` 내부 컬렉션으로 보는 것이 자연스럽다. 조회수 증가는 aggregate 필드 증가가 아니라 command/repository 경로를 canonical write path로 본다 (§1.2 참조). |
| User Account | `User` | `UserInterestCategory`, `UserInterestKeyword` | `socialType + socialId` 조합은 유일해야 한다. 상태 전이는 `PENDING → ACTIVE → WITHDRAWN → PENDING(재활성화)` 경로만 허용된다. 관심 키워드는 반드시 선택된 관심 카테고리에 속해야 한다. 관심사 교체는 `replaceInterests()`로 단일 트랜잭션 내 불변식 검증과 함께 처리된다. | `User`가 루트다. 계정/온보딩/관심사 불변식을 소유한다. **`replaceInterests()` 도메인 메서드 누락** — 불변식 검증이 서비스 레이어에 산재. |
| Personalization Profile | 명시적 쓰기 애그리거트 없음 | `PersonalizationProfileDocument`, `UserActivityData` | 같은 `userId` 기준 현재 개인화 프로필 projection은 하나만 유지된다. 프로필 텍스트, 벡터, 핵심 키워드는 함께 재생성된다. | Personalization Profile은 aggregate보다 read model / application service 중심 컨텍스트다. 현재 `PersonalizationProfileService`가 생성 책임을 가진다. |
| Activity | `ReadPost`, `Bookmark`, `SearchHistory` | 없음 | `Bookmark`는 `userId + postId` 조합이 유일해야 한다. `ReadPost`는 같은 사용자+게시글 중복 저장을 허용하되 `ReadPostFirstReadPolicy.isFirstRead()`로 최초 읽기를 구분한다. `SearchHistory`는 같은 검색어를 중복 저장한다 (동일 검색어의 반복 횟수 자체가 개인화 관심 신호가 된다). 행동 기록은 삭제되지 않고 보존된다 (북마크 제외). | 각 행동 기록이 독립 record aggregate처럼 동작한다. 현재 브랜치 기준으로 `Bookmark`, `ReadPost`, `SearchHistory`는 모두 `activity/<slice>` 아래에서 `presentation / application / domain / infrastructure` 구조로 정리되었다. `ReadPost`는 `SaveReadPostCommand`, `GetReadPostsQuery`, `ReadPostConverter`, `BookmarkLookupService`를 통해 저장/조회/북마크 여부 조합을 분담하고, 목록 조회 `size`는 HTTP layer에서 `1..100`으로 검증한다. `SearchHistory`는 `SearchHistoryRequest`, `SaveSearchHistoryCommand`, `ReadHistoryCommandService`로 저장 흐름을 분리했다. 또한 Activity application 서비스의 cross-context 조회는 `UserLookupService`, `PostLookupService`, `PostKeywordLookupService`, `BookmarkLookupService`를 통해 application 간 의존으로 정리되었다. aggregate/value object 강화, hexagonal port/adaptor 적용, `ManyToOne -> id reference` 같은 경계 재설계는 후속 단계로 미룬다. |
| Activity | `ReadPost`, `Bookmark`, `SearchHistory` | `FirstReadPost` | `Bookmark`는 `userId + postId` 조합이 유일해야 한다. `ReadPost`는 같은 사용자+게시글 중복 저장을 허용한다. `FirstReadPost`는 `userId + postId` 유니크 제약으로 "조회수 증가 자격"을 한 번만 부여하는 dedupe ledger 역할을 한다. `SearchHistory`는 같은 검색어를 중복 저장한다 (동일 검색어의 반복 횟수 자체가 개인화 관심 신호가 된다). 행동 기록은 삭제되지 않고 보존된다 (북마크 제외). | 각 행동 기록이 독립 record aggregate처럼 동작한다. 현재 브랜치 기준으로 `Bookmark`, `ReadPost`, `SearchHistory`는 모두 `activity/<slice>` 아래에서 `presentation / application / domain / infrastructure` 구조로 정리되었다. `ReadPost`는 `SaveReadPostCommand`, `GetReadPostsQuery`, `ReadPostConverter`, `BookmarkLookupService`를 통해 저장/조회/북마크 여부 조합을 분담하고, `ReadPostFirstReadPolicy.markFirstRead()` + `first_read_posts` 유니크 제약으로 최초 조회수 증가를 보호한다. 목록 조회 `size`는 HTTP layer에서 `1..100`으로 검증한다. `SearchHistory`는 `SearchHistoryRequest`, `SaveSearchHistoryCommand`, `ReadHistoryCommandService`로 저장 흐름을 분리했다. 또한 Activity application 서비스의 cross-context 조회는 `UserLookupService`, `PostLookupService`, `PostKeywordLookupService`, `BookmarkLookupService`를 통해 application 간 의존으로 정리되었다. aggregate/value object 강화, hexagonal port/adaptor 적용, `ManyToOne -> id reference` 같은 경계 재설계는 후속 단계로 미룬다. |
| 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 @@ -55,7 +55,9 @@
```
- `@Version` 낙관적 락은 재시도 비용이 발생하고 조회수 같은 통계성 필드에는 부적합하므로 채택하지 않는다.
- production 경로에서는 `Post.incrementViewCount()` 같은 엔티티 필드 증가를 사용하지 않고 `PostCommandService`/`PostRepository` 경로를 canonical write path로 둔다.
- 현재 `isFirstRead` 체크로 사용자 중복 카운트는 방지하고 있으나, 다수 사용자 동시 접근 시 레이스 컨디션은 여전히 존재한다.
- 사용자별 중복 카운트 방지는 `activity/readpost`의 `first_read_posts(user_id, post_id)` 유니크 제약으로 처리한다.
- `ReadPostCommandService`는 `ReadPostFirstReadPolicy.markFirstRead()`가 성공한 경우에만 조회수를 증가시킨다.
- `markFirstRead()`는 duplicate key만 "이미 읽음"으로 번역하고, 조회수 증가가 실패하면 `ReadPostErrorCode.READ_POST_VIEW_COUNT_INCREMENT_FAILED`를 던져 `first_read_posts` 마킹과 `read_posts` 저장이 함께 롤백되도록 한다.

**누락된 도메인 메서드**

Expand Down Expand Up @@ -159,7 +161,7 @@ createSocialUser() → PENDING
| P0 | 개인화 프로필이 생성됨 | `PersonalizedProfileGenerated` | `PersonalizationProfileService.generatePersonalizationProfileSync` | 추천 생성, 개인화 검색 준비 완료 | 현재 `PersonalizationProfileService`가 추천 생성을 직접 호출한다. 이벤트 분리 우선순위가 높다. |
| P0 | 추천이 생성됨 | `RecommendationsGenerated` | `LlmRecommendationService.generateRecommendationsForUser` | Notification, Analytics | 사용자에게 보여줄 현재 추천 목록이 바뀌는 핵심 이벤트다. |
| P1 | 기술 게시글을 읽음 | `TechnicalPostRead` | `ReadPostCommandService.saveReadPost` | 개인화 프로필 갱신, 추천 정책 | 읽기 행동은 개인화 프로필과 읽은 게시글 제외 정책의 핵심 입력이다. |
| P1 | 기술 게시글을 처음 읽음 | `TechnicalPostFirstRead` | `ReadPostFirstReadPolicy.isFirstRead` + `PostCommandService.incrementViewCount` | 인기순 정렬, 분석 | 조회수 증가와 인기순 정렬에 직접 연결된다. |
| P1 | 기술 게시글을 처음 읽음 | `TechnicalPostFirstRead` | `ReadPostFirstReadPolicy.markFirstRead` + `PostCommandService.incrementViewCount` | 인기순 정렬, 분석 | `first_read_posts` dedupe ledger를 통과한 최초 읽기에서만 발생하며 조회수 증가와 인기순 정렬에 직접 연결된다. |
| P1 | 기술 게시글을 북마크함 | `TechnicalPostBookmarked` | `BookmarkCommandService.addBookmark` | 개인화 프로필 갱신, 추천 튜닝 | 강한 선호 신호로 개인화 품질에 중요하다. |
| P1 | 북마크가 해제됨 | `BookmarkRemoved` | `BookmarkCommandService.deleteBookmark` | 개인화 프로필 갱신, 추천 튜닝 | 선호 신호 제거로 볼 수 있다. |
| P1 | 검색어가 기록됨 | `SearchQueryRecorded` | `saveSearchHistory` | 개인화 프로필 갱신, 검색 분석 | 검색 의도는 개인화 프로필의 주요 입력이다. |
Expand Down
3 changes: 2 additions & 1 deletion docs/ubiquitous-language/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@
| `markAsisClicked → markAsClicked` 오타 수정 | [`recommendation.md`](./recommendation.md) | 미반영 | 메서드명/호출부 수정 |
| `TechBlog.markCrawled()` 추가 | [`source-ingestion.md`](./source-ingestion.md) | 미반영 | 도메인 메서드 추가 + 호출부 연결 |
| `User.replaceInterests()` 추가 | [`user-account.md`](./user-account.md) | 미반영 | aggregate 불변식 검증을 도메인 메서드로 이동 |
| `Post viewCount` SQL atomic UPDATE 경로 정리 | [`post-content.md`](./post-content.md) | 반영 | `isFirstRead` race / idempotency는 별도 이슈로 해결 |
| `Post viewCount` SQL atomic UPDATE 경로 정리 | [`post-content.md`](./post-content.md) | 반영 | `first_read_posts` 기반 dedupe ledger와 함께 유지 |
| `ReadPost` 최초 읽기 dedupe / 조회수 증가 롤백 보호 | [`activity.md`](./activity.md), [`../tactical-design.md`](../tactical-design.md) | 반영 | `first_read_at` 의미 정교화가 필요하면 별도 검토 |
| `EDifficultyLevel` 제거 | [`post-content.md`](./post-content.md) | 반영 | 필요 시 정책과 함께 재도입 검토 |

---
Expand Down
13 changes: 10 additions & 3 deletions docs/ubiquitous-language/activity.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
| 읽은 게시글 | `ReadPost` | 사용자가 기술 게시글을 읽은 기록 |
| 읽은 시간 | `readDurationSeconds` | 사용자가 기술 게시글을 읽은 지속 시간 |
| 읽기 몰입도 | `convertReadingDurationToNaturalLanguage` | 읽은 시간을 `가볍게 훑어봄`, `빠르게 읽음`, `읽음`, `정독함`, `깊게 읽음`으로 해석한 값 |
| 첫 읽기 | `ReadPostFirstReadPolicy.isFirstRead` | 특정 기술 게시글을 처음 읽은 경우 |
| 첫 읽기 | `ReadPostFirstReadPolicy.markFirstRead` / `FirstReadPost` | 특정 기술 게시글에 대해 조회수 증가 자격을 처음 획득한 경우 |
| 검색 기록 | `SearchHistory` | 사용자가 입력한 검색어와 검색 시각 |
| 검색어 | `query` (legacy alias: `searchWord`) | 검색 기록에 저장되는 사용자 입력 |
| 북마크 | `Bookmark` (legacy alias: `ScrabPost`) | 사용자가 기술 게시글을 저장하는 행위 |
Expand All @@ -33,8 +33,9 @@
| 내부 용어 | 코드상 표현 | 설명 |
|---|---|---|
| 읽기 기록 | `ReadPost` | 사용자와 기술 게시글의 읽기 이벤트 레코드 |
| 최초 읽기 판별 | `ReadPostFirstReadPolicy.isFirstRead` | 조회수 증가 여부를 결정하는 정책 |
| 최초 읽기 마킹 | `ReadPostFirstReadPolicy.markFirstRead` | `first_read_posts` 유니크 제약을 이용해 조회수 증가 여부를 결정하는 정책 |
| 조회수 증가 위임 | `PostCommandService.incrementViewCount` | 첫 읽기일 때 Post 컨텍스트에 조회수 증가를 위임하는 command 경로 |
| 최초 읽기 ledger | `FirstReadPost`, `first_read_posts` | 사용자별 게시글 조회수 dedupe를 담당하는 보조 record |
| 북마크 레코드 | `Bookmark` (legacy name: `ScrabPost`) | 북마크 저장 레코드의 현재 표준 이름과 과거 이름을 함께 설명한다 |
| 검색 기록 레코드 | `SearchHistory` | 사용자 검색어를 시간순으로 남기는 레코드 |
| 북마크 여부 조합값 | `isBookmarked` | Search/Post/Recommendation 응답 조립 시 붙는 파생 값 |
Expand All @@ -57,7 +58,9 @@
## 현재 구조 메모

- 현재 브랜치 기준으로 `Bookmark`, `ReadPost`, `SearchHistory`는 모두 `presentation / application / domain / infrastructure` 기준으로 정리되어 있다.
- `ReadPost` 저장은 `UserLookupService`, `PostLookupService`, `ReadPostFirstReadPolicy`, `PostCommandService`를 조합해 첫 읽기 판별과 조회수 증가를 분리한다.
- `ReadPost` 저장은 `UserLookupService`, `PostLookupService`, `ReadPostFirstReadPolicy`, `PostCommandService`를 조합해 읽기 이력 저장과 조회수 증가를 분리한다.
- `ReadPostFirstReadPolicy.markFirstRead()`는 `first_read_posts(user_id, post_id)` 유니크 제약을 first-read 판정의 단일 진실 원천으로 사용한다.
- `ReadPostCommandService`는 first-read 마킹이 성공했을 때만 `PostCommandService.incrementViewCount()`를 호출하고, 조회수 증가 실패 시 예외를 던져 전체 트랜잭션을 롤백한다.
- `ReadPost` 조회는 `bookmark.infrastructure.BookmarkRepository`를 직접 참조하지 않고 `bookmark.application.query.lookup.BookmarkLookupService`를 통해 북마크 여부를 조합한다.
- `ReadPost` 목록 조회 `size`는 HTTP layer에서 `1..100`으로 검증한다.
- `SearchHistory` 저장은 `SearchHistoryRequest -> SaveSearchHistoryCommand -> ReadHistoryCommandService` 흐름을 따른다.
Expand All @@ -67,11 +70,15 @@
## 주요 근거 파일

- `src/main/java/com/techfork/activity/readpost/domain/ReadPost.java`
- `src/main/java/com/techfork/activity/readpost/domain/FirstReadPost.java`
- `src/main/java/com/techfork/activity/readpost/domain/ReadPostErrorCode.java`
- `src/main/java/com/techfork/activity/readpost/domain/ReadPostFirstReadPolicy.java`
- `src/main/java/com/techfork/activity/readpost/application/command/ReadPostCommandService.java`
- `src/main/java/com/techfork/activity/readpost/application/command/SaveReadPostCommand.java`
- `src/main/java/com/techfork/activity/readpost/application/query/ReadPostQueryService.java`
- `src/main/java/com/techfork/activity/readpost/application/query/GetReadPostsQuery.java`
- `src/main/java/com/techfork/activity/readpost/infrastructure/FirstReadPostRepository.java`
- `src/main/java/com/techfork/activity/readpost/infrastructure/FirstReadPostRepositoryImpl.java`
- `src/main/java/com/techfork/activity/readpost/presentation/ReadPostConverter.java`
- `src/main/java/com/techfork/activity/readpost/presentation/ReadPostController.java`
- `src/main/java/com/techfork/activity/readhistory/domain/SearchHistory.java`
Expand Down
1 change: 1 addition & 0 deletions docs/ubiquitous-language/post-content.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
- `PostDocument`, `ContentChunk`는 aggregate가 아니라 **검색/추천용 projection**이다.
- `Post.company`는 Source 컨텍스트의 출처명을 복사한 조회용 스냅샷이다.
- production 경로에서는 `Post.incrementViewCount()` 같은 엔티티 필드 증가를 사용하지 않고 `PostCommandService`/`PostRepository`의 SQL atomic update를 canonical write path로 둔다.
- Activity 컨텍스트에서는 `first_read_posts(user_id, post_id)` dedupe ledger를 통과한 최초 읽기에서만 `PostCommandService.incrementViewCount()`를 호출한다.
- `PostCommandService.incrementViewCount()`는 DB 값을 원자적으로 증가시키지만, 이미 로드된 managed `Post`의 `viewCount`를 같은 트랜잭션 안에서 최신 상태로 동기화하지는 않는다.
- `EDifficultyLevel`은 실제 사용처가 없어 제거되었다. 필요해지면 정책과 함께 재도입한다.

Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
package com.techfork.activity.readpost.application.command;

import com.techfork.activity.readpost.domain.ReadPost;
import com.techfork.activity.readpost.domain.ReadPostErrorCode;
import com.techfork.activity.readpost.domain.ReadPostFirstReadPolicy;
import com.techfork.activity.readpost.infrastructure.ReadPostRepository;
import com.techfork.domain.post.entity.Post;
import com.techfork.domain.post.service.PostCommandService;
import com.techfork.domain.post.service.PostLookupService;
import com.techfork.domain.useraccount.entity.User;
import com.techfork.domain.useraccount.service.UserLookupService;
import com.techfork.global.exception.GeneralException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
Expand All @@ -29,10 +31,13 @@ public void saveReadPost(SaveReadPostCommand command) {
User user = userLookupService.getUserOrThrow(command.userId());
Post post = postLookupService.getPostOrThrow(command.postId());

boolean isFirstRead = readPostFirstReadPolicy.isFirstRead(user, post);
boolean firstReadMarked = readPostFirstReadPolicy.markFirstRead(user, post, command.readAt());
boolean viewCountIncremented = false;
if (isFirstRead) {
if (firstReadMarked) {
viewCountIncremented = postCommandService.incrementViewCount(post.getId());
if (!viewCountIncremented) {
throw new GeneralException(ReadPostErrorCode.READ_POST_VIEW_COUNT_INCREMENT_FAILED);
}
}

ReadPost readPost = ReadPost.create(
Expand All @@ -43,7 +48,7 @@ public void saveReadPost(SaveReadPostCommand command) {
);

readPostRepository.save(readPost);
log.info("Saved read post for user {} and post {} (firstRead: {}, viewCount incremented: {})",
command.userId(), command.postId(), isFirstRead, viewCountIncremented);
log.info("Saved read post for user {} and post {} (firstReadMarked: {}, viewCount incremented: {})",
command.userId(), command.postId(), firstReadMarked, viewCountIncremented);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.techfork.activity.readpost.domain;

import com.techfork.domain.post.entity.Post;
import com.techfork.domain.useraccount.entity.User;
import com.techfork.global.common.BaseEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import java.time.LocalDateTime;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.PersistenceCreator;

@Entity
@Table(
name = "first_read_posts",
uniqueConstraints = {
@UniqueConstraint(name = "uk_first_read_posts_user_post", columnNames = {"user_id", "post_id"})
}
)
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class FirstReadPost extends BaseEntity {

@Column(name = "first_read_at", nullable = false)
private LocalDateTime firstReadAt;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "post_id", nullable = false)
private Post post;

@PersistenceCreator
@Builder
FirstReadPost(User user, Post post, LocalDateTime firstReadAt) {
this.user = user;
this.post = post;
this.firstReadAt = firstReadAt;
}

public static FirstReadPost create(User user, Post post, LocalDateTime firstReadAt) {
return FirstReadPost.builder()
.user(user)
.post(post)
.firstReadAt(firstReadAt)
.build();
}
}
Loading