Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
7efc0e8
[Search][Pref] jsoup -> webclient로 변경
sunwon12 Jan 4, 2026
b4101b3
[Book][Refactor] book 인덱스 명
sunwon12 Jan 4, 2026
a1a10ec
[Book][Pref] 이미 DB에 있는 책들을 한 번에 조회 및 커넥션 트랜잭션 최소화
sunwon12 Jan 4, 2026
f0c6a26
[Book][Refactor] 크롤러 웹클라이언트 10초 타임아웃 설정
sunwon12 Jan 5, 2026
06cb0a9
[Book][Refactor] 알라딘 스펙에 맞게 세마포어로 크롤러 동시 요청 제
sunwon12 Jan 5, 2026
6b9227f
[Book][Fix] 책 저장 에러 시 영속성 컨텍스트 더렵혀지고 find 시 에러 해결
sunwon12 Jan 5, 2026
b420516
[Book][Test] 데이터셋에 카테고리 추
sunwon12 Jan 5, 2026
ad8299a
[Test] 리퍼지토리 테스트에서도 Mysql mode h2 쓰도
sunwon12 Jan 5, 2026
77fe5a6
[Book][Test] insert ignore 테스트 추가
sunwon12 Jan 5, 2026
d5f356e
[Book][Refactor] 크롤 후 챕터 업데이트 시 jdbc 벌크로 챕터 insert, update, delete
sunwon12 Jan 5, 2026
0c46736
[Book][Refactor] webclient 재시도 로직 추
sunwon12 Jan 5, 2026
48ff793
[Quiz][Refactor] 리프 엔티티들은 삭제 시 jpql로 벌크 삭
sunwon12 Jan 5, 2026
c06aebc
[Book][Refactor] 검색시 동시성 똑같이 고려하되 조회-저장 패턴 단순화
sunwon12 Jan 5, 2026
a8d7f8d
[Book][Refactor] 책이 저장되어있다면 베스트 셀러에 저장 안 함
sunwon12 Jan 5, 2026
6c0d32a
[Book][Remove] 안 쓰는 코드 삭
sunwon12 Jan 5, 2026
2ca548b
[Book][Refactor] 네이버 크롤링 책 저장도 조회 - 저장 패턴 분리
sunwon12 Jan 5, 2026
fd62b56
[Book][Chore] SearchService 코드 정리
sunwon12 Jan 5, 2026
d6de01e
[Book][Refactor] 웹클라이언트 재시도 추가 및
sunwon12 Jan 7, 2026
a9692fd
[Book][Refactor] 카테고리 관련 메소드명 변경 메소드 분리
sunwon12 Jan 7, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@

public interface BestSellerRepository extends JpaRepository<BestSeller, Long> {

boolean existsByBook(Book book);

void deleteByBookId(Long bookId);

@Query("SELECT b FROM BestSeller b JOIN FETCH b.book ORDER BY b.id ASC")
List<BestSeller> findTop(Pageable pageable);

List<BestSeller> findAllByBookIn(List<Book> books);
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ public class BestsellerScheduler {
*/
@Scheduled(cron = "0 0 2 * * *") // 매일 새벽 2시
public void fetchBestsellers() {
int totalSaved = 0;
// 1페이지부터 20페이지까지 (페이지당 50권)
for (int page = 1; page <= 20; page++) {
try {
Expand All @@ -36,19 +35,12 @@ public void fetchBestsellers() {
continue;
}

for (AladinSearchResponse.SearchItem item : response.getItem()) {
try {
bestsellerService.saveBestSeller(item);
totalSaved++;
} catch (Exception e) {
log.error("베스트셀러 저장 중 오류 발생: {}", item.getTitle(), e);
}
}
bestsellerService.saveBestSellers(response.getItem());
} catch (Exception e) {
log.error("알라딘 베스트셀러 API 호출 중 오류 발생 (page={})", page, e);
}
}
log.info("일요일 베스트셀러 수집 완료. 신규 저장: {}권", totalSaved);
log.info("일요일 베스트셀러 수집 완료");
}

/**
Expand Down
49 changes: 42 additions & 7 deletions src/main/java/book/book/bestseller/service/BestsellerService.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,19 @@
import book.book.bestseller.repository.BestSellerRepository;
import book.book.book.entity.Book;
import book.book.book.entity.Chapter;
import book.book.book.repository.BookRepository;
import book.book.book.repository.ChapterRepository;
import book.book.book.service.BookService;
import book.book.quiz.service.QuizGenerationService;
import book.book.search.dto.aladin.AladinSearchResponse;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;

Expand All @@ -23,16 +29,45 @@ public class BestsellerService {
private final BookService bookService;
private final QuizGenerationService quizGenerationService;
private final ChapterRepository chapterRepository;
private final BookRepository bookRepository;

private static final int CHUNK_SIZE = 60;

public void saveBestSeller(AladinSearchResponse.SearchItem item) {
Book book = bookService.findOrElseSaveBook(item);
if (!bestSellerRepository.existsByBook(book)) {
bestSellerRepository.save(BestSeller.builder()
.book(book)
.retryCount(0)
.build());
@Transactional
public void saveBestSellers(List<AladinSearchResponse.SearchItem> items) {
// 1. 이미 저장된 책 조회 (Bulk Read)
List<Integer> itemIds = items.stream()
.map(AladinSearchResponse.SearchItem::getItemId)
.toList();

Map<Integer, Book> existingBooksMap = bookRepository.findByAladingBookIdIn(itemIds).stream()
.collect(Collectors.toMap(Book::getAladingBookId, Function.identity()));

// 2. 없는 책만 선별
List<AladinSearchResponse.SearchItem> missingItems = items.stream()
.filter(item -> !existingBooksMap.containsKey(item.getItemId()))
.toList();

// 3. 없는 책 병렬 저장
List<Book> savedBooks = bookService.saveBooksParallel(missingItems);

// 4. 이미 베스트셀러로 등록된 책 조회 (Bulk Read)
// savedBooks 중에서도 Race Condition 등으로 이미 BestSeller에 있을 수 있으므로 체크
Set<Book> existingBestSellers = bestSellerRepository.findAllByBookIn(savedBooks).stream()
.map(BestSeller::getBook)
.collect(Collectors.toSet());

// 5. 없는 베스트셀러만 선별 및 저장
List<BestSeller> newBestSellers = savedBooks.stream()
.filter(book -> !existingBestSellers.contains(book))
.map(book -> BestSeller.builder()
.book(book)
.retryCount(0)
.build())
.toList();

if (!newBestSellers.isEmpty()) {
bestSellerRepository.saveAll(newBestSellers);
}
}

Expand Down
8 changes: 7 additions & 1 deletion src/main/java/book/book/book/entity/Book.java
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@
*/
@Entity
@Table(name = "book", indexes = {
@Index(name = "idx_book_quiz_status_updated_date", columnList = "quizStatus, updatedDate")
@Index(name = "idx_book_quiz_status_updated_date", columnList = "quizStatus, updatedDate"),
@Index(name = "idx_book_alading_book_id", columnList = "aladingBookId"),
@Index(name = "idx_book_category_id", columnList = "category_id")
})
@Getter
@Builder
Expand Down Expand Up @@ -84,6 +86,10 @@ public class Book extends BaseTimeEntity {
@Builder.Default
private Integer generatedQuizCount = 0; // 퀴즈가 생성된 챕터 수

public void resetQuizStatus() {
this.quizStatus = null;
this.generatedQuizCount = 0;
}
public void updateQuizStatus(QuizStatus quizStatus) {
this.quizStatus = quizStatus;
}
Expand Down
16 changes: 7 additions & 9 deletions src/main/java/book/book/book/repository/BookLikeRepository.java
Original file line number Diff line number Diff line change
@@ -1,25 +1,23 @@
package book.book.book.repository;

import book.book.book.entity.BookLike;
import book.book.member.entity.Member;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface BookLikeRepository extends JpaRepository<BookLike, Long>, BookLikeRepositoryCustom {

@Modifying
@Query("delete from BookLike bl where bl.member.id = :memberId and bl.book.id = :bookId")
void deleteByMemberIdAndBookId(Long memberId, Long bookId);

@Modifying
@Query("delete from BookLike bl where bl.member.id = :memberId")
void deleteAllByMemberId(Long memberId);

@Query("SELECT bl FROM BookLike bl JOIN FETCH bl.book WHERE bl.member = :member")
List<BookLike> findAllByMemberWithBook(@Param("member") Member member);

@Query("SELECT bl FROM BookLike bl JOIN FETCH bl.book b JOIN FETCH bl.member m")
List<BookLike> findAllWithBookAndMember();

boolean existsByMemberIdAndBookId(Long memberId, Long bookId);

@Modifying
@Query("delete from BookLike bl where bl.book.id = :bookId")
void deleteByBookId(Long bookId);
}
20 changes: 20 additions & 0 deletions src/main/java/book/book/book/repository/BookRepository.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@
import book.book.quiz.domain.QuizStatus;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;

public interface BookRepository extends JpaRepository<Book, Long> {
Expand All @@ -24,6 +27,13 @@ default Book findByIdOrElseThrow(Long id) {

Optional<Book> findByAladingBookId(int aladingBookId);

default Map<Integer, Book> getBookMapByAladinBookIds(List<Integer> aladingBookIds) {
List<Book> books = findByAladingBookIdIn(aladingBookIds);
return books.stream().collect(Collectors.toMap(Book::getAladingBookId, book -> book));
}

List<Book> findByAladingBookIdIn(List<Integer> aladingBookIds);

default Book findByIsbn13OrElseThrow(String isbn) {
return findByIsbn13(isbn)
.orElseThrow(() -> new CustomException(ErrorCode.BOOK_NOT_FOUND));
Expand All @@ -46,4 +56,14 @@ default Book findByIsbn13OrElseThrow(String isbn) {

@Query("SELECT DISTINCT b FROM Book b JOIN Chapter c ON b.id = c.book.id LEFT JOIN Quiz q ON c.id = q.chapter.id WHERE c.hasQuiz = true AND q.id IS NULL")
List<Book> findBooksWithMissingQuizzes();

@Modifying
@Query(value = "INSERT IGNORE INTO book (alading_book_id, title, author, isbn, isbn13, category_id, description, publisher, published_date, image_url, aladin_url, aladin_star_rating, chapter_count, diary_count, book_size, weight, generated_quiz_count, created_date, updated_date, page) "
+
"VALUES (:#{#book.aladingBookId}, :#{#book.title}, :#{#book.author}, :#{#book.isbn}, :#{#book.isbn13}, :#{#book.category.id}, :#{#book.description}, :#{#book.publisher}, :#{#book.publishedDate}, :#{#book.imageUrl}, :#{#book.aladinUrl}, :#{#book.aladinStarRating}, :#{#book.chapterCount}, 0, :#{#book.bookSize}, :#{#book.weight}, 0, NOW(), NOW(), :#{#book.page})", nativeQuery = true)
int saveIfAbsent(Book book);

@Modifying
@Query("UPDATE Book b SET b.quizStatus = null, b.generatedQuizCount = 0 WHERE b.id IN :bookIds")
void updateQuizStatusNullByBookIds(List<Long> bookIds);
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,22 @@
import book.book.common.ErrorCode;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;

public interface ChapterRepository extends JpaRepository<Chapter, Long> {
public interface ChapterRepository extends JpaRepository<Chapter, Long>, ChapterRepositoryCustom {

default Chapter findByIdOrElseThrow(Long id) {
return findById(id).orElseThrow(() -> new CustomException(ErrorCode.CHAPTER_NOT_FOUND));
}

List<Chapter> findByBookIdOrderByChapterNumber(Long bookId);

@Modifying
@Query("delete from Chapter c where c.book.id = :bookId")
void deleteByBookId(Long bookId);

@Modifying
@Query("UPDATE Chapter c SET c.hasQuiz = false WHERE c.id IN :chapterIds")
void updateHasQuizFalseByChapterIds(List<Long> chapterIds);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package book.book.book.repository;

import book.book.book.entity.Chapter;
import java.util.List;

public interface ChapterRepositoryCustom {
void batchInsertChapters(List<Chapter> chapters);

void batchUpdateChapters(List<Chapter> chapters);

void batchDeleteChapters(List<Chapter> chapters);
}
77 changes: 77 additions & 0 deletions src/main/java/book/book/book/repository/ChapterRepositoryImpl.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package book.book.book.repository;

import book.book.book.entity.Chapter;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.BatchPreparedStatementSetter;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;

@Repository
@RequiredArgsConstructor
public class ChapterRepositoryImpl implements ChapterRepositoryCustom {

private final JdbcTemplate jdbcTemplate;

@Override
public void batchInsertChapters(List<Chapter> chapters) {
String sql = "INSERT INTO chapter (book_id, chapter_number, title, has_quiz, created_date, updated_date) " +
"VALUES (?, ?, ?, ?, NOW(), NOW())";

jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
@Override
public void setValues(PreparedStatement ps, int i) throws SQLException {
Chapter chapter = chapters.get(i);
ps.setLong(1, chapter.getBook().getId());
ps.setInt(2, chapter.getChapterNumber());
ps.setString(3, chapter.getTitle());
ps.setBoolean(4, chapter.getHasQuiz());
}

@Override
public int getBatchSize() {
return chapters.size();
}
});
}

@Override
public void batchUpdateChapters(List<Chapter> chapters) {
String sql = "UPDATE chapter SET title = ?, chapter_number = ?, updated_date = NOW() WHERE id = ?";

jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
@Override
public void setValues(PreparedStatement ps, int i) throws SQLException {
Chapter chapter = chapters.get(i);
ps.setString(1, chapter.getTitle());
ps.setInt(2, chapter.getChapterNumber());
ps.setLong(3, chapter.getId());
}

@Override
public int getBatchSize() {
return chapters.size();
}
});
}

@Override
public void batchDeleteChapters(List<Chapter> chapters) {
String sql = "DELETE FROM chapter WHERE id = ?";

jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
@Override
public void setValues(PreparedStatement ps, int i) throws SQLException {
Chapter chapter = chapters.get(i);
ps.setLong(1, chapter.getId());
}

@Override
public int getBatchSize() {
return chapters.size();
}
});
}
}
Loading
Loading