Skip to content

[FEAT] Search API 리팩터링 및 중복 요청 500 에러 해결 #7

Description

@millkk04

1. Search API 도메인 통합

  • 기존 /api/v1/books/search 엔드포인트를 /api/v1/search/books로 변경
  • 검색 관련 API를 독립적인 search 도메인으로 통합
  • 작가 검색(/search/authors), 추천 검색어(/search/recommendations) 등은 추후 구현 예정

2. 중복 요청 500 에러 해결

  • 문제: GET 요청으로 도서 검색 시 DB에 저장 후, 동일한 검색을 다시 수행하면 500 내부 서버 오류 발생
  • 원인: JPA orphan removal 타이밍 이슈로 인한 유니크 제약조건 위반(delete는 flush가 된 후에 작동되는 연산이라, 기존 orphan이 쓰레기 데이터로 남아있어서, insert 연산이 우선 실행되기에 중복이 발생함. 그래서 유니크 조건이 위배되는 경우가 생김)
  • 해결: 명시적 2단계 처리 (기존 매핑 삭제 → 새 매핑 추가)

자세한 설명(위만 봐도 됩니다.)

# 첫 번째 요청
GET /api/v1/search/books?query=채식주의자&page=1&size=5
→ 200 OK ✅ (새 데이터 DB 저장)

# 두 번째 요청 (동일한 검색)
GET /api/v1/search/books?query=채식주의자&page=1&size=5
→ 500 Internal Server Error ❌

근본 원인

1. DB 유니크 제약조건

BookAuthors 테이블에 다음과 같은 유니크 제약조건 존재:

UNIQUE KEY uk_book_authors_book_author_role (book_id, author_id, role)

2. JPA Orphan Removal 타이밍 문제

@OneToMany(mappedBy = "book", cascade = CascadeType.ALL, orphanRemoval = true)
private List<BookAuthors> bookAuthors = new ArrayList<>();
  • orphanRemoval=true는 트랜잭션 커밋 시점에 실행됨
  • replaceBookAuthors() 메서드가 clear() 후 새 엔티티 추가 시, 기존 매핑이 아직 DB에서 삭제되지 않은 상태에서 같은 조합의 새 매핑 추가 시도
  • → 유니크 제약조건 위반 → 500 에러

3. 에러 발생 흐름

1. findOrCreateBook() → 기존 Books 조회 (book_id=1)
2. replaceBookAuthors(mappings)
   → bookAuthors.clear() (삭제 예약, DB 미반영)
   → 새 BookAuthors 추가 (메모리에만)
3. booksRepository.save(book)
   → INSERT INTO book_authors (book_id=1, author_id=한강, role=AUTHOR)
   ❌ DB에는 아직 기존 매핑이 존재
   → 유니크 제약조건 위반 (book_id, author_id, role)
   → 500 Internal Server Error

✅ 해결 방법

핵심 전략: 명시적 2단계 처리

수정 전 (BookImportService.java)

book.replaceBookAuthors(mappings);
Books saved = booksRepository.save(book);
items.add(bookSearchConverter.toResponse(saved, doc));

수정 후 (BookImportService.java)

// ✅ 1단계: 기존 Books인 경우 매핑 삭제를 먼저 DB에 반영
if (book.getId() != null) {
    book.getBookAuthors().clear();
    booksRepository.save(book);
    entityManager.flush(); // DELETE 즉시 실행
}

// ✅ 2단계: 새 매핑 추가
book.replaceBookAuthors(mappings);
Books saved = booksRepository.save(book);
items.add(bookSearchConverter.toResponse(saved, doc));

해결 원리

수정 후 실행 순서

1. findOrCreateBook() → 기존 Books 조회 (book_id=1)

2. book.getId() != null 체크
   → bookAuthors.clear()
   → booksRepository.save(book)
   → entityManager.flush() ✅
   → DELETE FROM book_authors WHERE book_id=1 (즉시 실행)

3. replaceBookAuthors(mappings)
   → 새 BookAuthors 추가 (메모리)

4. booksRepository.save(book)
   → INSERT INTO book_authors (book_id=1, author_id=한강, role=AUTHOR) ✅
   → 성공! (기존 데이터는 이미 삭제됨)

추가 의존성

@Service
@RequiredArgsConstructor
public class BookImportService {
    // ...기존 필드...
    private final EntityManager entityManager; // ✅ 추가
}

📁 변경된 파일 목록

🆕 신규 생성 파일 (4개)

Search 도메인

src/main/java/com/example/booklog/domain/search/
├── controller/
│   └── SearchController.java              # 통합 검색 컨트롤러 (신규)
├── service/
│   └── BookSearchService.java             # 도서 검색 서비스 (신규)
└── dto/
    ├── BookSearchResponse.java            # 검색 응답 DTO (신규)
    └── BookSearchItemResponse.java        # 검색 항목 DTO (신규)

✏️ 수정된 파일 (2개)

1. BookImportService.java

// 위치: domain/library/books/service/BookImportService.java

// ✅ 추가된 의존성
private final EntityManager entityManager;

// ✅ 수정된 로직 (searchAndUpsert 메서드 내부)
if (book.getId() != null) {
    book.getBookAuthors().clear();
    booksRepository.save(book);
    entityManager.flush();
}
book.replaceBookAuthors(mappings);
Books saved = booksRepository.save(book);

2. BookSearchController.java 삭제


🏗️ 디렉토리 구조 변경

Before (변경 전)

src/main/java/com/example/booklog/
├── domain/
│   ├── library/
│   │   ├── books/
│   │   │   ├── controller/
│   │   │   │   └── BookSearchController.java    # 검색 API
│   │   │   ├── service/
│   │   │   │   └── BookImportService.java       # 검색 + 저장 로직
│   │   │   ├── dto/
│   │   │   │   ├── BookSearchResponse.java
│   │   │   │   └── BookSearchItemResponse.java
│   │   │   └── ...
│   │   └── shelves/
│   ├── tags/
│   └── users/
└── global/

After (변경 후)

src/main/java/com/example/booklog/
├── domain/
│   ├── search/                                   # 🆕 신규 도메인
│   │   ├── controller/
│   │   │   └── SearchController.java            # 🆕 통합 검색 컨트롤러
│   │   ├── service/
│   │   │   └── BookSearchService.java           # 🆕 도서 검색 서비스
│   │   └── dto/
│   │       ├── BookSearchResponse.java          # 🆕 검색 응답 DTO
│   │       └── BookSearchItemResponse.java      # 🆕 검색 항목 DTO
│   ├── library/
│   │   ├── books/
│   │   │   ├── controller/
│   │   │   │  
│   │   │   ├── service/
│   │   │   │   └── BookImportService.java       #  500 에러 수정
│   │   │   ├── dto/
│   │   │   │   ├── BookSearchResponse.java      # 유지 (Legacy)
│   │   │   │   └── BookSearchItemResponse.java  # 유지 (Legacy)
│   │   │   └── ...
│   │   └── shelves/
│   ├── tags/
│   └── users/
└── global/

🔄 API 엔드포인트 변경

엔드포인트 매핑

구분 엔드포인트 컨트롤러 상태
신규 GET /api/v1/search/books SearchController

도메인 경계 명확화

  • 검색 기능이 독립적인 search 도메인으로 분리
  • Books 도메인은 도서 데이터 관리에만 집중

확장성 향상

  • 작가 검색, 통합 검색 등 새로운 검색 기능 추가 용이
  • RESTful API 명세와 구현 일치

안정성 개선

  • 중복 요청 시 500 에러 해결
  • 데이터 무결성 보장

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request
No fields configured for Feature.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions