# 블로그 검색 API 만들기

> 전체 소스 코드
>
> <https://github.com/jundragon/blog-searcher>

## 1. 요구사항

대규모 트래픽, 저장된 데이터가 많은 상황을 고려한 블로그 검색 API 만들기

### 기능 요구사항

1. 블로그 검색
   1. 키워드를 입력받아 OpenAPI 를 검색소스로 블로그를 검색한 응답을 제공
   2. Sorting (정확도순, 최신순), Pagination
   3. OpenAPI 는 카카오 외 추가될 수 있음
   4. (추가 요건) 카카오 블로그 API 장애 발생시, 네이버 블로그 API 를 통한 데이터 제공
2. 인기 검색어 목록
   1. 사용자들이 많이 검색한 순서대로 최대 10개의 키워드 제공
   2. 검색된 횟수도 함께 표기
   3. (추가 요건) 트래픽이 많고, 저장된 데이터가 많음을 염두해두고 동시성 이슈와 검색된 횟수의 정확도를 확보할 수 있는 설계 및 구현
3. 프로젝트 구성
   1. 테스트 케이스
   2. 예외 처리
   3. (추가 요건) 멀티 모듈 구성, 의존성 제약

### 제약 사항

* Java 11 이상 또는 Kotlin
* Spring Boot
* Gradle
* 인메모리 DB (h2), JPA 사용
* 외부 블로그 API 연동은 서버에서 처리

## 2. 문제 해결 전략

### 1) 카카오 블로그 API 장애 발생시, 네이버 블로그 API 를 통한 데이터 제공

* 외부 API의 장애 발생시 계속 대규모 트래픽으로 요청을 한다면 장애를 악화 시킬 수 있음
* 서킷 브레이커를 도입해서 재시도에 대한 전략을 추가 (횟수, 인터벌)
* 다만, 사용자 관점에서 생각해보면 메인 API 의 장애가 빈번할 경우 블로그 검색 결과 응답에 대한 일관성있는 응답을 주지 못하여 좋지 못한 사용자 경험을 줄 수 있을 것 같다.

### 2) 트래픽이 많고, 저장되어 있는 데이터가 많음을 염두에 둔 구현에 대한 고민

* 데이터가 많다고 가정한다면, Keyword 는 메모리 DB에 전부 저장하기 어려울 것으로 생각됨
* 동시성을 제어하기 위해 Kafka 로 키워드 수집을 비동기, 순차적으로 수행하도록 구현
* Keyword 에 긴문장이나 오타가 있다면 업무적으로 가치가 적은 데이터가 쌓일 것이라고 생각하여 문장을 단어 단위로 저장하는 기능을 추가 (이후 오타 보정기능 까지 추가한다면 좋을 것)

### 3) 요구사항 외 추가 고민

* 입력으로 keyword 가 단어로 잘 들어올까? 아닐껄...?
  * keyword 수집 시 문장이나 오타 등은 통계의 가치가 없다.
  * keyword 로 분해하여 저장되는 것이 좋을 것 같다.
  * 보정 전 원본 데이터와 보정 후 분해 처리된 결과 모두 저장, 어떻게 연관지어 저장하는 것이 좋을 까?
  * tokenizer 솔루션으로 간단하게 라이브러리를 도입해보고 나중에는 검색 전용 elastic search 같은 솔루션을 고려해서 고도화 시키면 좋겠다.
* core 모듈의 *'도메인과 유스케이스'* 는 최대한 자바 코드만으로 테스트를 할 수 있게 노력 (스몰 테스트)

## 3. 설계

### 특징

* 멀티 모듈 `Gradle`
* 메시지 큐 `Kafka`
* 캐싱 `Redis`
* 장애 회복 패턴 적용 `Resilience4j`

### 시스템 아키텍처

<img src="/files/btPflPJnDMKbOoAngE0C" alt="" class="gitbook-drawing">

### 소프트웨어 아키텍처

<img src="/files/MPkWQ9zdJDyepnrRq8PM" alt="" class="gitbook-drawing">

#### 클린 아키텍처

* 도메인 중심 설계
  * 도메인 모듈은 최대한 순수 자바로 구현
  * Usecase 는 편의상 구현 클래스로 작성
* 사용 기술에 따라 Port 의 인터페이스를 구현하는 Adapter 사용

#### 모듈 구성

* 도메인
  * `core`
    * 독립 모듈, 의존관계 없음
    * 유스 케이스와 도메인, 비즈니스 로직
* 입력 포트
  * `api`
    * Client 에게 제공하는 인터페이스 (Controller)
    * 애플리케이션 설정
    * 예외처리
  * `consumer-adapter`
    * message queue (kafka, stream)
    * keyword tokenizer (형태소 분석 엔진 추가 및 교체 가능, komoran, elastic search ...)
* 출력 포트
  * `persistence-adapter`
    * database (jpa, h2 rdb)&#x20;
    * message queue (kafka, stream)
  * `blogsource-adapter`
    * http-client (webflux)

## 4. 구현

### REST API

<mark style="color:blue;">`GET`</mark> `/api/v1/blogs`

#### Query Parameters

| Name                                      | Type    | Description                                                     |
| ----------------------------------------- | ------- | --------------------------------------------------------------- |
| keyword<mark style="color:red;">\*</mark> | String  | 검색어 (keyword)                                                   |
| sort                                      | String  | <p>정렬 방식</p><p>(default) ACCURACY (정확도순)</p><p>RECENCY(최신순)</p> |
| page                                      | Integer | <p>페이지 번호</p><p>(default) 1</p>                                 |
| size                                      | Integer | <p>페이지 크기</p><p>(default) 10</p>                                |

{% tabs %}
{% tab title="200: OK " %}

{% endtab %}
{% endtabs %}

```json
{
  "result": {
    "code": "200",
    "message": "성공",
    "description": "성공"
  },
  "body": {
    "documents": [
      {
        "title": "김호중 '<b>테스</b>형!!' 유튜브 조회 수 300만 뷰!",
        "contents": "김호중 '<b>테스</b>형!!' 유튜브 조회 수 300만 뷰! . 2023년 9월27일 수요일 포스팅주제 ​김호중 [불후의명곡 '<b>테스</b>형!'] 유튜브 조회 수 삼백만 뷰 돌파 축하 ’불후의 명곡 2023 상반기 왕중왕전' 최종 우승곡 <b>테스</b>형!!! ​ 김호중 가수님은 자기만의 스타일로 완벽하게 재해석, 독보적 천상의 목소리로 첫 소절 부터 관중을...",
        "url": "https://kimej004.tistory.com/1517",
        "blogName": "참사랑 블로그",
        "thumbnail": "https://search1.kakaocdn.net/argon/130x130_85_c/5vD1td4LEID",
        "createdAt": "2023-09-27T03:18:40"
      },
      ...
    ],
    "pagination": {
      "hasNextPage": true,
      "nextPage": 2,
      "currentPage": 1,
      "totalCount": 793,
      "size": 10
    }
  }
}
```

<mark style="color:blue;">`GET`</mark> `/api/v1/blogs/statistics/popular`

#### Query Parameters

| Name | Type    | Description       |
| ---- | ------- | ----------------- |
| top  | Integer | 검색 키워드 수 (최대 10개) |

{% tabs %}
{% tab title="200: OK " %}

{% endtab %}
{% endtabs %}

```json
{
  "result": {
    "code": "200",
    "message": "성공",
    "description": "성공"
  },
  "body": [
    {
      "keyword": "테라포밍마스",
      "count": 1001
    }
  ]
}
```

### 주요 코드

#### 1) 블로그를 검색 메인 기능은 키워드 순위 집계 기능과 독립적으로 수행되도록 함 (비동기 처리)&#x20;

```java
public Mono<BlogResponse> search(BlogSearchCommand command) {
    // 블로그 소스 검색 응답
    return blogSource.searchBlogDocuments(
            BlogSourceRequest.builder()
                .keyword(command.keyword())
                .sortType(command.sort())
                .page(command.page())
                .size(command.size())
                .build())
        .map(BlogResponse::from).cache()
        .doFinally(f -> {
            if (f.equals(SignalType.ON_COMPLETE)) {
                publish(command.keyword());
            }
        });
}
```

```java
/**
 * 인기검색어 키워드 통계용 키워드 카운트 이벤트 발생
 *
 * @param message
 */
private void publish(String message) {
    KeywordCountEvent keywordCountEvent = KeywordCountEvent.builder()
        .keyword(message)
        .build();
    keywordEventPublisher.publish(keywordCountEvent);
}
```

{% hint style="success" %}
시간이 지나고 코드를 보니 굳이 doFinally 에서 블로그 검색 응답을 확인하고 키워드 집계를 해야하는지 의문이다.

`blogSource.searchBlogDocuments()` 호출 전에 키워드 수집 이벤트를 발행하는 것이 더 간단한 처리가 될 것 같다.

추가 내용 : StreamBridge 에서 Kafka 장애시 Block 이 걸리는 것 같다. [#kafka](#kafka "mention")
{% endhint %}

#### 2) 페이지네이션 구현

* 카카오 API, 네이버 API 의 페이징 응답은 형식이 맞지 않는다.&#x20;
* next 페이지 계산하여 우리 도메인에서 필요한 페이지 응답을 일관되게 만든다. (간단하지만)
* 이 클래스는 도메인 패키지에 위치하는 것이 적절하다고 생각함

```java
@Getter
public class Pagination {

    private static final int END_PAGE = -1;

    private final boolean hasNextPage;
    private final Integer nextPage;

    private final Integer currentPage;
    private final Integer totalCount;
    private final Integer size;

    @Builder
    public Pagination(Integer currentPage, Integer totalCount, Integer size) {

        this.currentPage = currentPage;
        this.totalCount = totalCount;
        this.size = size;

        if (Objects.isNull(currentPage) || Objects.isNull(totalCount)) {
            this.hasNextPage = false;
            this.nextPage = END_PAGE;
            return;
        }

        this.hasNextPage = currentPage < calcTotalPage(totalCount, size);
        this.nextPage = hasNextPage ? currentPage + 1 : END_PAGE;
    }

    private double calcTotalPage(int totalCount, int size) {
        return (double) totalCount / size;
    }
}
```

#### 3) 서킷 브레이커 적용

```java
@CircuitBreaker(name = "blogsource", fallbackMethod = "searchBlogDocumentsFallback")
@Override
public Mono<Blog> searchBlogDocuments(BlogSourceRequest request) {
    return searchBlogDocuments(request, blogSourceOpenApiClient.get(0));
}

public Mono<Blog> searchBlogDocumentsFallback(BlogSourceRequest request, Throwable e) {
    log.warn("{}에 장애가 발생하여 {}로 변경합니다.",
        blogSourceOpenApiClient.get(0).getBlogSourceName(),
        blogSourceOpenApiClient.get(1).getBlogSourceName());
    log.warn("[{}] {}", UUID.randomUUID(), e.getMessage());
    return searchBlogDocuments(request, blogSourceOpenApiClient.get(1));
}
```

블로그 소스는 config 에서 등록한다.

```java
@Configuration
@RequiredArgsConstructor
public class BlogSourceOpenApiConfig {

    private final BlogSourceKakaoClientClientFactory blogSourceKakaoClientFactory;
    private final BlogSourceNaverClientFactory blogSourceNaverClientFactory;

    @Bean
    @Order(1) // 우선순위 가장 높음
    public BlogSourceOpenApiClient blogSourceKakaoClient() {
        return blogSourceKakaoClientFactory.create();
    }

    @Bean
    @Order(2)
    public BlogSourceOpenApiClient blogSourceNaverClient() {
        return blogSourceNaverClientFactory.create();
    }

}
```

어댑터에서는 List 로 빈을 주입 받는다. \
블로그의 우선 순위가 변경되어도 `BlogSourceOpenApiConfig` 에서만 변경하면 적용 된다.

```java
@Component
@RequiredArgsConstructor
public class BlogSourceAdapter implements BlogSource {

    private final List<BlogSourceOpenApiClient> blogSourceOpenApiClient;
    
    ...
}
```

{% hint style="info" %}
위와 같이 의도적으로 Bean 을 동적으로 읽어서 처리하려는 목적이 아니라면, `@Qualifier` 를 사용해서 정적이고, 명시적으로 사용하는 것이 좋다. (동적으로 사용할 때도 Map 으로 명시적으로 사용할 수 있음)
{% endhint %}

#### 4) (추가 작성중)

## 5. Trouble Shooting

### Kafka 장애시 대응

```java
@Component
@RequiredArgsConstructor
public class StreamBridgeKeywordEventPublisher implements KeywordEventPublisher {

    public static final String BIND = "keywords-out-0";
    private final ObjectMapper objectMapper;
    private final StreamBridge streamBridge;

    @SneakyThrows
    @Override
    public void publish(BlogStatisticEvent blogStatisticEvent) {
        String message = objectMapper.writeValueAsString(blogStatisticEvent);
        streamBridge.send(BIND, message);
    }
}

```

* StreamBridge 를 사용하면 Kafka 장애시 어떻게 동작하나?

## TBD (개선 사항)

* 현재 kafka 로 lock 없이 순서대로 처리량 조절을 통해 동시성을 제어하도록 설계하였는데, 결국 싱글 스레드로 동작해야만 동시성을 처리할 수 있음. 오히려 성능상 한계점이 있을 것으로 생각됨.
* 블로그 소스를 가져오는 요청에서 Redis 캐시를 도입해서 외부 API 접근 수를 줄이는 방법으로 성능 개선이 가능
  * 이 방법이 좋은 추가적인 이유는 사용성 관점에서 인기 검색어를 보고 검색을 하는 트래픽이 많을 것이므로 이것을 캐시로 제공하는 전략이 유리하다.
* 집계용 INSERT 전용 테이블을 만들고 주기적으로 통계하여 통계 테이블이나 Redis 에 통계 결과(Top 10)를 캐싱하여 서비스하는 방식으로 개선 <- Scheduled Batch + Cache
* 문제를 해결해가면서 구성이 점점 복잡해지는데, 통합 테스트 방법의 고민이 필요 (외부 설정에 따라 깨지기 쉬운 테스트라서 관리가 어려울 것 같기도 하지만..) <- docker container 를 통한 테스트 고려하자.
* 간단하게라도 성능 테스트를 수행하면서 솔루션 도입으로 인한 성능 향상을 테스트 해볼 것.


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://programmer-jjy.gitbook.io/second-brain/portfolio/blog-searcher.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
