Skip to content
34 changes: 29 additions & 5 deletions docs/ddd-test-refactoring-roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,10 @@ docs/test-gap-analysis.md
- `PersonalizationProfileServiceTest`
- `PostSummaryProcessorTest`
- `PostSummaryReaderTest`
- `PostSummaryReaderDataJpaTest`
- `PostSummaryWriterTest`
- `PostSummaryWriterDataJpaTest`
- `SummaryExtractionServiceTest`

### 2.1 P0 테스트 후보

Expand Down Expand Up @@ -521,22 +524,40 @@ ContentChunk = 검색/추천용 projection 내부 값
PostTest
- RssFeedItem으로 기술 게시글을 생성한다.
- 요약과 짧은 요약을 갱신한다.
- 게시글 키워드를 추가한다.
- 게시글 키워드를 초기화한다.
- 게시글 키워드를 새 목록으로 재구성한다.
- 빈 키워드 목록이면 기존 키워드를 모두 제거한다.
- 조회수를 증가시킨다.
```

```text
SummaryExtractionServiceTest
- LLM 응답에서 summary, shortSummary, keywords를 파싱한다.
- 잘못된 LLM 응답을 처리한다.
- 잘못된 LLM 응답이면 예외를 던진다.
```

```text
PostSummaryReaderDataJpaTest
- summary가 null이거나 빈 문자열인 게시글만 읽는다.
- keyword fetch join 계약을 유지한다.
```

```text
PostSummaryWriterDataJpaTest
- H2에서 summary/shortSummary 저장을 검증한다.
- 기존 keyword 삭제 후 새 keyword 재구성을 검증한다.
```

```text
PostEmbeddingProcessorTest
- 제목/요약/본문 청크 임베딩으로 PostDocument를 생성한다.
```

##### summary pipeline 현재 우려사항

- summary 단계와 embedding 단계 상태가 아직 `summary IS NULL OR ''`, `embeddedAt IS NULL` 조합으로 암묵적으로 표현된다.
- malformed LLM JSON은 이제 fail-fast로 막히지만, 예외 타입은 `LlmException`을 재사용해 transport 실패와 response-format 실패를 같은 버킷으로 본다.
- `PostSummaryReader`는 여전히 미요약 backlog를 한 번에 메모리로 읽는 구조라, 데이터가 커지면 paging/streaming reader로 후속 전환이 필요하다.

##### 리팩터링 후보

- 도메인 문서에서 `Post`를 기술 게시글로 설명
Expand Down Expand Up @@ -966,8 +987,11 @@ TechnicalPostIndexed
- Bookmark / ReadPost / SearchHistory slice를 `presentation / application / domain / infrastructure` 기준으로 정리
[완료] 5-1. Activity 4.1 2차 정리
- application 서비스의 direct cross-context repository 접근을 application 간 의존으로 전환
[다음] 6. Post aggregate 테스트 작성
- PostTest, PostEmbeddingProcessorTest, PostEmbeddingWriterTest
[완료] 6. Post aggregate / summary pipeline 안전망 확장
- PostTest, SummaryExtractionServiceTest
- PostSummaryReaderDataJpaTest, PostSummaryWriterDataJpaTest
[다음] 6-1. Post embedding pipeline 테스트 작성
- PostEmbeddingProcessorTest, PostEmbeddingWriterTest
[다음] 7. User aggregate 관심사 불변식 정리
[다음] 8. Recommendation 생성 테스트 작성
- MmrServiceTest, LlmRecommendationServiceTest
Expand Down
30 changes: 19 additions & 11 deletions docs/test-gap-analysis.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,38 +202,46 @@ Source는 DDD 전환 초반에는 큰 변경을 피하고, 나중에 이벤트
| `PostControllerV2IntegrationTest` | integration | v2 회사 목록, 다중 회사, 커서 페이징, 최신/인기 정렬 |
| `PostRepositoryTest` | JPA | company/recent/popular/detail 쿼리, cursor, company detail |
| `PostQueryServiceTest` | unit/mock | 목록/상세 조회, 키워드/북마크 여부 조합 |
| `PostSummaryProcessorTest` | untracked | 요약/키워드 반영 후보 |
| `PostSummaryReaderTest` | untracked | lazy load 후보 |
| `PostSummaryWriterTest` | untracked | summary/keyword JDBC binding 후보 |
| `PostTest` | unit | RssFeedItem 생성, summary 갱신, keyword 재구성 |
| `PostSummaryProcessorTest` | unit/mock | summary/keyword 반영, 예외 전파 시 기존 상태 유지 |
| `PostSummaryReaderTest` | unit/mock | load-once reader, 순차 반환, 빈 결과 처리 |
| `PostSummaryReaderDataJpaTest` | JPA | `summary IS NULL OR ''` query contract, keyword fetch join |
| `PostSummaryWriterTest` | unit/mock | summary update / keyword delete / insert JDBC binding |
| `PostSummaryWriterDataJpaTest` | JPA | H2에서 summary 저장과 keyword 재구성 검증 |
| `SummaryExtractionServiceTest` | unit/mock | LLM JSON parsing, invalid JSON fail-fast, 긴 본문 제한 |

#### 평가

Post 조회 API와 repository는 강하다.
하지만 DDD 전환 관점에서 필요한 **`Post` 애그리거트 단위 테스트**가 없다.
또한 검색/추천의 핵심 입력인 **embedding pipeline** 거의 보호되지 않는다.
이제 `Post` aggregate와 summary pipeline 핵심 경계(`Processor` / `Reader` / `Writer` / `SummaryExtractionService`)는 기본 안전망이 생겼다.
다만 검색/추천의 핵심 입력인 **embedding pipeline**은 여전히 거의 보호되지 않는다.

#### 남은 갭

| 우선순위 | 갭 | 이유 |
|---|---|---|
| P0 | `PostTest` | `Post = 기술 게시글` 애그리거트 루트의 기본 불변식 보호 필요 |
| P0 | `PostEmbeddingProcessorTest` | 제목/요약/청크 임베딩으로 `PostDocument` 생성하는 핵심 pipeline 보호 필요 |
| P0 | `PostEmbeddingWriterTest` | Elasticsearch bulk index + `embeddedAt` update 회귀 위험 큼 |
| P1 | `ContentChunkerServiceTest` | semantic search 품질에 직접 영향 |
| P1 | `PostDocumentTest` | publishedAt serialization, embedding/chunk projection 보호 |
| P1 | `SummaryExtractionServiceTest` | LLM 응답 파싱/실패 처리 보호 필요 |
| P1 | untracked `PostSummary*Test` tracked 반영 여부 결정 | 현재 작성 중 테스트를 공식 안전망에 포함할지 판단 필요 |
| P1 | `PostSummaryWriter` rollback integration test | update/delete/insert 3단계 JDBC write의 원자성 확인 필요 |
| P2 | `PostKeywordRepositoryTest` | 키워드 bulk 조회가 여러 서비스 응답 조합에 쓰임 |

#### 현재 우려사항 (merge blocker 아님)

- summary pipeline 상태가 아직 `summary IS NULL OR ''`, `embeddedAt IS NULL` 조합으로 암묵적으로 표현된다.
- malformed LLM JSON은 이제 fail-fast로 막히지만, 예외 타입은 여전히 `LlmException`을 재사용해 transport 실패와 response-format 실패를 같은 버킷으로 본다.
- `PostSummaryReader`는 여전히 backlog를 한 번에 `List<Post>`로 올리는 구조라, 미요약 게시글이 크게 늘면 paging/streaming 전환 검토가 필요하다.

#### 추천 추가 테스트

```text
PostTest
- RssFeedItem으로 기술 게시글을 생성한다.
- 생성 시 company는 TechBlog.companyName 또는 RssFeedItem.company 스냅샷을 가진다.
- 요약과 짧은 요약을 갱신한다.
- 게시글 키워드를 추가한다.
- 게시글 키워드를 초기화한다.
- 게시글 키워드를 새 목록으로 재구성한다.
- 빈 키워드 목록이면 기존 키워드를 모두 제거한다.
- 조회수를 증가시킨다.
```

Expand Down Expand Up @@ -543,7 +551,7 @@ src/main/java/com/techfork/domain/notification/entity/NotificationToken.java
| 테스트 | 목적 |
|---|---|
| `ContentChunkerServiceTest` | semantic search 품질 입력 보호 |
| `SummaryExtractionServiceTest` | LLM 요약 응답 parsing 보호 |
| `PostSummaryWriter` rollback integration test | summary/keyword JDBC write 원자성 보호 |
| `UserTest` | 사용자 애그리거트 상태 전이 보호 |
| `RecommendationCommandServiceTest` | 추천 재생성 use case 보호 |
| `RecommendationSchedulerTest` | 일일 추천 생성 스케줄 보호 |
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
package com.techfork.domain.post.batch;

import com.techfork.domain.post.dto.SummaryWithKeywordsDto;
import com.techfork.domain.post.entity.Post;
import com.techfork.domain.post.entity.PostKeyword;
import com.techfork.domain.post.service.SummaryExtractionService;
import com.techfork.domain.post.dto.SummaryWithKeywordsDto;
import com.techfork.domain.post.entity.Post;
import com.techfork.domain.post.service.SummaryExtractionService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.configuration.annotation.StepScope;
Expand All @@ -28,18 +27,12 @@ public Post process(Post post) {
SummaryWithKeywordsDto result = summaryExtractionService.extractSummary(
post.getTitle(),
post.getPlainContent()
);

post.updateSummaries(result.summary(), result.shortSummary());

// 기존 키워드 삭제 후 새 키워드 추가
post.clearKeywords();
result.keywords().forEach(keyword -> {
PostKeyword postKeyword = PostKeyword.create(keyword, post);
post.addKeyword(postKeyword);
});

log.debug("요약 및 키워드 추출 완료: {} (키워드 {}개)", post.getTitle(), result.keywords().size());
return post;
}
}
);

post.updateSummaries(result.summary(), result.shortSummary());
post.replaceKeywords(result.keywords());

log.debug("요약 및 키워드 추출 완료: {} (키워드 {}개)", post.getTitle(), result.keywords().size());
return post;
}
}
25 changes: 15 additions & 10 deletions src/main/java/com/techfork/domain/post/entity/Post.java
Original file line number Diff line number Diff line change
Expand Up @@ -107,16 +107,21 @@ public static Post create(RssFeedItem item, TechBlog techBlog) {
}


public void updateSummaries(String summary, String shortSummary) {
this.summary = summary;
this.shortSummary = shortSummary;
}

public void addKeyword(PostKeyword keyword) {
this.keywords.add(keyword);
}

public void clearKeywords() {
public void updateSummaries(String summary, String shortSummary) {
this.summary = summary;
this.shortSummary = shortSummary;
}

public void replaceKeywords(List<String> keywords) {
clearKeywords();
keywords.forEach(keyword -> addKeyword(PostKeyword.create(keyword, this)));
}

private void addKeyword(PostKeyword keyword) {
this.keywords.add(keyword);
}

private void clearKeywords() {
this.keywords.clear();
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
package com.techfork.domain.post.service;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.techfork.domain.post.dto.SummaryWithKeywordsDto;
import com.techfork.global.llm.LlmClient;
import com.techfork.global.util.ContentCleaner;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.techfork.domain.post.dto.SummaryWithKeywordsDto;
import com.techfork.global.llm.LlmClient;
import com.techfork.global.llm.exception.LlmException;
import com.techfork.global.util.ContentCleaner;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
Expand Down Expand Up @@ -49,9 +50,9 @@ public SummaryWithKeywordsDto extractSummary(String title, String content) {

log.debug("LLM API 응답 (제목: {}): {}", title, response);

// JSON 응답 파싱 (파싱 실패 시 빈 DTO 반환)
return parseResponse(response.trim());
}
// JSON 응답 파싱 (파싱 실패 시 예외 전파)
return parseResponse(response.trim());
}

private SummaryWithKeywordsDto parseResponse(String response) {
try {
Expand All @@ -66,11 +67,11 @@ private SummaryWithKeywordsDto parseResponse(String response) {
}

return new SummaryWithKeywordsDto(summary, shortSummary, keywords);
} catch (Exception e) {
log.error("JSON 응답 파싱 실패: {}", response, e);
return new SummaryWithKeywordsDto("", "", List.of());
}
}
} catch (Exception e) {
log.error("JSON 응답 파싱 실패: {}", response, e);
throw new LlmException("LLM summary response parsing failed", e);
}
}

private String buildUserPrompt(String title, String content) {
return String.format("""
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package com.techfork.domain.post.batch;

import com.techfork.domain.post.dto.SummaryWithKeywordsDto;
import com.techfork.domain.post.entity.Post;
import com.techfork.domain.post.entity.PostKeyword;
import com.techfork.domain.post.fixture.PostFixture;
import com.techfork.domain.post.service.SummaryExtractionService;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.verify;

@ExtendWith(MockitoExtension.class)
class PostSummaryProcessorTest {

@Mock
private SummaryExtractionService summaryExtractionService;

@Nested
@DisplayName("process")
class Process {

@Test
@DisplayName("추출 결과로 summary를 갱신하고 keyword를 재구성한다")
void updatesSummariesAndRebuildsKeywordsFromExtractionResult() {
PostSummaryProcessor postSummaryProcessor = new PostSummaryProcessor(summaryExtractionService);
Post post = createPostWithExistingKeywords();
PostKeyword oldKeyword1 = post.getKeywords().get(0);
PostKeyword oldKeyword2 = post.getKeywords().get(1);
SummaryWithKeywordsDto summaryWithKeywordsDto = new SummaryWithKeywordsDto(
"새 요약",
"새 짧은 요약",
List.of("AI", "Batch")
);
given(summaryExtractionService.extractSummary("요약 대상 글", "평문 본문"))
.willReturn(summaryWithKeywordsDto);

Post result = postSummaryProcessor.process(post);

assertThat(result).isSameAs(post);
assertThat(post.getSummary()).isEqualTo("새 요약");
assertThat(post.getShortSummary()).isEqualTo("새 짧은 요약");
assertThat(post.getKeywords()).doesNotContain(oldKeyword1, oldKeyword2);
assertThat(post.getKeywords())
.extracting(PostKeyword::getKeyword)
.containsExactlyInAnyOrder("AI", "Batch");
assertThat(post.getKeywords())
.allSatisfy(keyword -> assertThat(keyword.getPost()).isSameAs(post));
verify(summaryExtractionService).extractSummary("요약 대상 글", "평문 본문");
}

@Test
@DisplayName("추출 결과 keyword가 비어 있으면 기존 keyword를 모두 제거한다")
void clearsExistingKeywordsWhenExtractionReturnsNoKeywords() {
PostSummaryProcessor postSummaryProcessor = new PostSummaryProcessor(summaryExtractionService);
Post post = createPostWithExistingKeywords();
given(summaryExtractionService.extractSummary("요약 대상 글", "평문 본문"))
.willReturn(new SummaryWithKeywordsDto("새 요약", "새 짧은 요약", List.of()));

Post result = postSummaryProcessor.process(post);

assertThat(result).isSameAs(post);
assertThat(post.getSummary()).isEqualTo("새 요약");
assertThat(post.getShortSummary()).isEqualTo("새 짧은 요약");
assertThat(post.getKeywords()).isEmpty();
}

@Test
@DisplayName("요약 추출 서비스 예외를 그대로 전파한다")
void propagatesExtractionServiceFailure() {
PostSummaryProcessor postSummaryProcessor = new PostSummaryProcessor(summaryExtractionService);
Post post = createPostWithExistingKeywords();
PostKeyword oldKeyword1 = post.getKeywords().get(0);
PostKeyword oldKeyword2 = post.getKeywords().get(1);
given(summaryExtractionService.extractSummary("요약 대상 글", "평문 본문"))
.willThrow(new IllegalStateException("LLM error"));

assertThatThrownBy(() -> postSummaryProcessor.process(post))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("LLM error");
assertThat(post.getSummary()).isEqualTo("기존 요약");
assertThat(post.getShortSummary()).isEqualTo("기존 짧은 요약");
assertThat(post.getKeywords()).containsExactly(oldKeyword1, oldKeyword2);
}

private Post createPostWithExistingKeywords() {
return PostFixture.createPostWithKeywords(
1L,
"요약 대상 글",
"원문 본문",
"평문 본문",
"TechFork",
"기존 요약",
"기존 짧은 요약",
List.of("기존키워드1", "기존키워드2")
);
}
}
}
Loading