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
20 changes: 9 additions & 11 deletions src/main/java/book/book/book/entity/BookCategory.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,9 @@
@Entity
@Getter
@NoArgsConstructor
@Table(
name = "book_category",
uniqueConstraints = {
@UniqueConstraint(
columnNames = {"parent_id", "name"}
)
}
)
@Table(name = "book_category", uniqueConstraints = {
@UniqueConstraint(columnNames = { "parent_id", "name" })
})
@Builder
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class BookCategory {
Expand Down Expand Up @@ -56,7 +51,7 @@ public BookCategory(String name, BookCategory parent) {
*/
@Transient
public String getFullPath() {
if (parent == null) {
if (parent == null || "ROOT".equals(parent.getName())) {
return name;
}
return parent.getFullPath() + ">" + name;
Expand All @@ -67,10 +62,13 @@ public List<BookCategory> flatParentToChild() {
List<BookCategory> path = new ArrayList<>();
BookCategory current = this;

// current가 null이 아닐 때까지 반복 (최상위 부모의 부모는 null)
// current가 null이 아닐 때까지 반복
while (current != null) {
if ("ROOT".equals(current.getName())) {
break; // ROOT는 포함하지 않음
}
path.add(current);
current = current.getParent(); // 다음 순회 대상으로 부모를 지정
current = current.getParent();
}

Collections.reverse(path);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package book.book.book.initializer;

import lombok.RequiredArgsConstructor;
import org.springframework.boot.CommandLineRunner;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

@Component
@RequiredArgsConstructor
public class BookCategoryInitializer implements CommandLineRunner {

private final JdbcTemplate jdbcTemplate;

@Override
@Transactional
public void run(String... args) throws Exception {
jdbcTemplate.execute("INSERT IGNORE INTO book_category (id, name, parent_id) VALUES (0, 'ROOT', NULL)");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ public interface BookCategoryRepository extends JpaRepository<BookCategory, Long
@Query("SELECT bc FROM BookCategory bc LEFT JOIN FETCH bc.parent WHERE bc.id = :id")
Optional<BookCategory> findByIdWithParent(Long id);

@Modifying
@Query(value = "INSERT IGNORE INTO book_category (id, name, parent_id) VALUES (0, 'ROOT', NULL)", nativeQuery = true)
void insertRoot();

@Modifying
@Query(value = "INSERT IGNORE INTO book_category (name, parent_id) VALUES (:name, :parentId)", nativeQuery = true)
void insertIgnore(String name, Long parentId);
Expand Down
81 changes: 74 additions & 7 deletions src/main/java/book/book/book/service/BookCategoryService.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;

@Service
Expand All @@ -15,16 +16,82 @@ public class BookCategoryService {

private final BookCategoryRepository bookCategoryRepository;

@Transactional(readOnly = true)
public Optional<BookCategory> find(String name, BookCategory parent) {
return bookCategoryRepository.findByNameAndParent(name, parent);
@Transactional(isolation = Isolation.READ_COMMITTED)
public BookCategory getOrCreateByFullPath(String fullCategoryName) {
if (fullCategoryName == null || fullCategoryName.isEmpty()) {
return null;
}

String[] categorySegments = fullCategoryName.split(">");
BookCategory parentCategory = null;

for (String categoryName : categorySegments) {
String trimmedCategoryName = categoryName.trim();
Optional<BookCategory> existing = find(trimmedCategoryName, parentCategory);

if (existing.isPresent()) {
parentCategory = existing.get();
} else {
parentCategory = create(trimmedCategoryName, parentCategory);
}
}
return parentCategory;
}

@Transactional
public BookCategory create(String name, BookCategory parent) {
Long parentId = parent != null ? parent.getId() : null;
private Optional<BookCategory> find(String name, BookCategory parent) {
if ("ROOT".equals(name)) {
return bookCategoryRepository.findByNameAndParent("ROOT", null);
}

BookCategory effectiveParent = parent;
if (effectiveParent == null) {
effectiveParent = bookCategoryRepository.findByNameAndParent("ROOT", null)
.orElse(null);

if (effectiveParent == null) {
return Optional.empty();
}
}
return bookCategoryRepository.findByNameAndParent(name, effectiveParent);
}

private BookCategory create(String name, BookCategory parent) {
BookCategory effectiveParent = parent;
if (effectiveParent == null) {
Optional<BookCategory> root = bookCategoryRepository.findByNameAndParent("ROOT", null);
if (root.isPresent()) {
effectiveParent = root.get();
} else {
// [Concurrency Strategy for ROOT]
// ROOT 카테고리는 parent_id가 NULL입니다.
// MySQL Unique Constraint(parent_id, name)는 NULL 값을 중복으로 치지 않습니다.
// 즉, ('ROOT', NULL)은 여러 개 생길 수 있어 동시성 이슈가 발생합니다.
// 이를 방지하기 위해 ID를 1로 고정한 insertRoot()를 사용하여 PK 제약조건으로 중복을 막습니다.
bookCategoryRepository.insertRoot();
effectiveParent = bookCategoryRepository.findByNameAndParent("ROOT", null)
.orElseThrow(() -> new CustomException(ErrorCode.INTERNAL_SERVER_ERROR));
}
}

Long parentId = effectiveParent.getId();
// [Concurrency Strategy]
// 왜 Book과 달리 INSERT IGNORE를 사용하는가?
// 1. Transaction Safety: 카테고리는 계층 구조(국내>소설>한국소설)로 연쇄 생성됩니다.
// 중간에 하나라도 중복 예외(DataIntegrityViolationException)가 터지면
// 트랜잭션이 'Rollback-Only'로 마킹되어, 이후의 로직(책 저장 등)까지 전부 롤백됩니다.
// 이를 방지하기 위해 예외를 발생시키지 않는 INSERT IGNORE가 필수적입니다.
// 2. Isolation Level: READ_COMMITTED 사용 이유
// INSERT IGNORE로 무시된 데이터는 이미 커밋된 타 트랜잭션의 데이터일 수 있습니다.
// 기본 격리 수준(REPEATABLE_READ)에서는 스냅샷 때문에 이 데이터를 조회(Select)할 수 없어
// "넣었는데 없고, 조회해도 없는" 모순이 발생합니다.
// READ_COMMITTED를 사용하여 항상 최신 데이터를 조회할 수 있게 합니다.
// 3. Why Not REQUIRES_NEW?
// 처음에는 트랜잭션 분리를 위해 REQUIRES_NEW를 고려했으나, 이는 부모 트랜잭션 + 자식 트랜잭션으로
// 하나의 요청이 2개의 DB 커넥션을 점유하게 만듭니다.
// 동시 요청이 많을 경우 커넥션 풀(HikariCP) 고갈로 인한 데드락이 발생하여 제외했습니다.
bookCategoryRepository.insertIgnore(name, parentId);
return bookCategoryRepository.findByNameAndParent(name, parent)

return bookCategoryRepository.findByNameAndParent(name, effectiveParent)
.orElseThrow(() -> new CustomException(ErrorCode.CATEGORY_NOT_FOUND));
}
}
44 changes: 12 additions & 32 deletions src/main/java/book/book/book/service/BookSaveService.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.Optional;
import javax.annotation.Nullable;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.interceptor.TransactionAspectSupport;

Expand All @@ -42,14 +42,14 @@ private static LocalDate parsedDate(String dateString) {
}
}

@Transactional
@Transactional(isolation = Isolation.READ_COMMITTED)
public Book getOrCreateBookFromAladin(AladinSearchResponse.SearchItem item) {
Optional<Book> existing = bookRepository.findByAladingBookId(item.getItemId());
if (existing.isPresent()) {
return existing.get();
}

BookCategory category = getOrCreateFromFullCategoryName(item.getCategoryName());
BookCategory category = bookCategoryService.getOrCreateByFullPath(item.getCategoryName());
Book book = Book.builder()
.aladingBookId(item.getItemId())
.title(Optional.ofNullable(item.getTitle()).orElse(""))
Expand All @@ -67,6 +67,15 @@ public Book getOrCreateBookFromAladin(AladinSearchResponse.SearchItem item) {
.build();

try {
// [Concurrency Strategy]
// 왜 Insert Ignore가 아닌 Try-Catch를 사용하는가?
// 1. Edge Case: 위에서 이미 findByAladingBookId로 존재 여부를 확인했습니다.
// 동시성 이슈로 중복이 발생하는 건 극히 드문 엣지 케이스이므로,
// 대부분의 성공 케이스에서 RTT 1회(Insert)로 끝나는 save()가 유리합니다.
// 2. Entity Complexity: Book 엔티티는 연관관계와 필드가 많아 Native Query로 작성 시 유지보수가 어렵습니다.
// 3. Why Not REQUIRES_NEW?
// 트랜잭션을 분리하면 DB 커넥션을 2배로 소모하게 되어, 트래픽 급증 시 커넥션 풀 고갈(Deadlock) 위험이 있습니다.
// 따라서 현재 트랜잭션에 참여하되, 중복 발생 시에만 예외를 잡아서 처리(Select)하는 방식을 택했습니다.
return bookRepository.save(book);
} catch (DataIntegrityViolationException e) {
// 다른 사용자와 타이밍이 겹쳐 중복 저장이 발생할 수 있으므로, 예외 발생 시 기존 책을 조회하여 반환
Expand All @@ -77,35 +86,6 @@ public Book getOrCreateBookFromAladin(AladinSearchResponse.SearchItem item) {
}
}

/**
* 카테고리 생성 시 DB 커넥션 고갈 및 데드락 방지를 위해 Native Query(INSERT IGNORE)를 사용합니다.
* try-catch로 예외를 잡지 않는 이유는, 예외가 터진 순간 이미 트랜잭션은 커밋 불가 상태가 되었기 때문에, 이후에 책을 저장하려고
* 하면 실패하게 되기 때문입니다.
* 고려한 상황: 책은 중복되지 않았는데, 다른 책의 카테고리와 동시 저장으로 인해 예외가 발생하는 상황
*/
@Nullable
private BookCategory getOrCreateFromFullCategoryName(String fullCategoryName) {
if (fullCategoryName == null || fullCategoryName.isEmpty()) {
return null;
}

String[] categorySegments = fullCategoryName.split(">");
BookCategory parentCategory = null;

for (String categoryName : categorySegments) {
String trimmedCategoryName = categoryName.trim();
Optional<BookCategory> existing = bookCategoryService.find(trimmedCategoryName, parentCategory);

if (existing.isPresent()) {
parentCategory = existing.get();
} else {
parentCategory = bookCategoryService.create(trimmedCategoryName, parentCategory);
}
}

return parentCategory;
}

@Transactional
public void setDetail(Long bookId, AladinBookDetail detail) {
if (detail == null) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package book.book.book.service;

import static org.assertj.core.api.Assertions.assertThat;

import book.book.book.entity.BookCategory;
import book.book.book.repository.BookCategoryRepository;
import book.book.config.IntegrationTest;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.support.TransactionTemplate;

@IntegrationTest
class BookCategoryServiceConcurrencyTest {

@Autowired
private BookCategoryService bookCategoryService;

@Autowired
private BookCategoryRepository bookCategoryRepository;

@Autowired
private JdbcTemplate jdbcTemplate;

@Autowired
private TransactionTemplate transactionTemplate;

@Test
@DisplayName("5개의 스레드가 동시에 같은 계층 구조 카테고리를 생성해도 중복 없이 하나만 생성되어야 한다")
void concurrentCategoryCreationTest() throws InterruptedException {
// given
int threadCount = 5;
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
String categoryPath = "국내도서>소설>한국소설";

AtomicInteger successCount = new AtomicInteger();
AtomicInteger failCount = new AtomicInteger();

// when
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
bookCategoryService.getOrCreateByFullPath(categoryPath);
successCount.incrementAndGet();
} catch (Exception e) {
failCount.incrementAndGet();
e.printStackTrace();
} finally {
latch.countDown();
}
});
}
latch.await();

// then
assertThat(failCount.get()).isEqualTo(0);
assertThat(successCount.get()).isEqualTo(threadCount);

// Check Hierarchy
// 1. ROOT (ID 0)
// 2. 국내도서 (Parent 0)
// 3. 소설 (Parent 국내도서)
// 4. 한국소설 (Parent 소설)
// Total 4 categories including ROOT.

assertThat(bookCategoryRepository.count()).isEqualTo(4);

transactionTemplate.execute(status -> {
BookCategory root = bookCategoryRepository.findByNameAndParent("ROOT", null)
.orElseThrow(() -> new AssertionError("ROOT category not found"));
BookCategory domestic = bookCategoryRepository.findByNameAndParent("국내도서", root)
.orElseThrow(() -> new AssertionError("국내도서 category not found"));
BookCategory novel = bookCategoryRepository.findByNameAndParent("소설", domestic)
.orElseThrow(() -> new AssertionError("소설 category not found"));
BookCategory leaf = bookCategoryRepository.findByNameAndParent("한국소설", novel)
.orElseThrow(() -> new AssertionError("한국소설 category not found"));

assertThat(leaf.getFullPath()).isEqualTo("국내도서>소설>한국소설");
return null;
});
}
}
39 changes: 9 additions & 30 deletions src/test/java/book/book/book/service/BookSaveServiceTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,18 @@ class BookSaveServiceTest {
@Autowired
private BookCategoryRepository bookCategoryRepository;

@Autowired
private org.springframework.jdbc.core.JdbcTemplate jdbcTemplate;

@org.junit.jupiter.api.BeforeEach
void setUp() {
jdbcTemplate.execute("INSERT IGNORE INTO book_category (id, name, parent_id) VALUES (0, 'ROOT', NULL)");
}

@Test
void 동시에_같은_책을_저장를_시도하면_하나는_저장되고_나머지는_조회된다() throws InterruptedException {
// given
int threadCount = 10;
int threadCount = 5;
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
AladinSearchResponse.SearchItem item = createSearchItem(12345, "동시성 테스트 책", "국내도서>소설");
Expand All @@ -52,35 +60,6 @@ class BookSaveServiceTest {
assertThat(books.get(0).getAladingBookId()).isEqualTo(12345);
}

@Test
void 동시에_다른_책이지만_같은_카테고리를_저장하는_경우_카테고리는_중복생성되지_않는다() throws InterruptedException {
// given
int threadCount = 10;
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
String categoryName = "국내도서>과학>물리학";

// when
for (int i = 0; i < threadCount; i++) {
int finalI = i;
executorService.submit(() -> {
try {
AladinSearchResponse.SearchItem item = createSearchItem(1000 + finalI, "과학책 " + finalI,
categoryName);
bookSaveService.getOrCreateBookFromAladin(item);
} finally {
latch.countDown();
}
});
}
latch.await();

// then
long count = bookCategoryRepository.count();
// "국내도서", "과학", "물리학" 총 3개의 카테고리가 생성되어야 함
assertThat(count).isEqualTo(3);
}

private AladinSearchResponse.SearchItem createSearchItem(int itemId, String title, String categoryName) {
AladinSearchResponse.SearchItem item = new AladinSearchResponse.SearchItem();
item.setItemId(itemId);
Expand Down
Loading