Chuyển đến nội dung chính

レッスン 8: エンティティの関係、ページネーション、仕様

@OneToMany、@ManyToOne、@ManyToMany、@OneToOne。フェッチ戦略 (LAZY 対 EAGER)、N+1 問題。ページング可能、ソート、スライス。動的クエリの JPA 仕様。

💻 プログラミング — レッスン 7 レッスン 8: エンティティの関係、ページネーションと 仕様

Spring Boot 4: 基本から上級まで

パート 2: REST API の構築

xdev.asia

はじめに

実際のアプリケーションでは、エンティティは相互に複雑な関係を持っています。リレーションシップをマッピングする方法を理解し、N+1 問題を回避し、ページネーションを実装することは、バックエンド開発者に必要なスキルです。この記事では、JPA リレーションシップと動的クエリについて詳しく説明します。


1. エンティティの関係

1.1 @ManyToOne および @OneToMany

// Category (1) ←→ (N) Product
@Entity
@Table(name = "categories")
public class Category extends BaseEntity {

    @Column(nullable = false, unique = true)
    private String name;

    private String slug;

    @OneToMany(mappedBy = "category", cascade = CascadeType.ALL)
    private List<Product> products = new ArrayList<>();

    // Convenience method
    public void addProduct(Product product) {
        products.add(product);
        product.setCategory(this);
    }
}

@Entity
@Table(name = "products")
public class Product extends BaseEntity {

    private String name;
    private BigDecimal price;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "category_id", nullable = false)
    private Category category;
}

1.2 @ManyToMany

// Product (N) ←→ (N) Tag
@Entity
@Table(name = "products")
public class Product extends BaseEntity {

    // ...other fields

    @ManyToMany
    @JoinTable(
        name = "product_tags",
        joinColumns = @JoinColumn(name = "product_id"),
        inverseJoinColumns = @JoinColumn(name = "tag_id")
    )
    private Set<Tag> tags = new HashSet<>();

    public void addTag(Tag tag) {
        tags.add(tag);
        tag.getProducts().add(this);
    }

    public void removeTag(Tag tag) {
        tags.remove(tag);
        tag.getProducts().remove(this);
    }
}

@Entity
@Table(name = "tags")
public class Tag extends BaseEntity {

    @Column(nullable = false, unique = true)
    private String name;

    @ManyToMany(mappedBy = "tags")
    private Set<Product> products = new HashSet<>();
}

1.3 @OneToOne

@Entity
@Table(name = "users")
public class User extends BaseEntity {
    private String name;
    private String email;

    @OneToOne(mappedBy = "user", cascade = CascadeType.ALL,
              fetch = FetchType.LAZY, optional = false)
    private UserProfile profile;
}

@Entity
@Table(name = "user_profiles")
public class UserProfile extends BaseEntity {
    private String bio;
    private String avatarUrl;
    private String phone;

    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", unique = true)
    private User user;
}

2. フェッチ戦略と N+1 問題

2.1 怠け者 vs 熱心な者

// LAZY (Default cho @OneToMany, @ManyToMany)
// → Query phụ chỉ chạy khi access field
@OneToMany(fetch = FetchType.LAZY)
private List<Product> products;

// EAGER (Default cho @ManyToOne, @OneToOne)
// → Luôn JOIN khi query entity
@ManyToOne(fetch = FetchType.EAGER)
private Category category;

ベスト プラクティス: 常に使用します FetchType.LAZY すべての関係について、必要に応じて JOIN FETCH を使用してフェッチします。

2.2 N+1 問題

// ⚠️ N+1 Problem
List<Product> products = productRepository.findAll();
// Query 1: SELECT * FROM products → 100 products
for (Product p : products) {
    System.out.println(p.getCategory().getName());
    // Query 2..101: SELECT * FROM categories WHERE id = ?
}
// Total: 1 + 100 = 101 queries! 💀

2.3 N+1 ソリューション

// Solution 1: JOIN FETCH
@Query("SELECT p FROM Product p JOIN FETCH p.category")
List<Product> findAllWithCategory();

// Solution 2: @EntityGraph
@EntityGraph(attributePaths = {"category", "tags"})
List<Product> findAll();

// Solution 3: Batch fetching
@Entity
public class Category {
    @OneToMany(mappedBy = "category")
    @BatchSize(size = 20) // Fetch 20 categories per query
    private List<Product> products;
}

// Solution 4: DTO Projection (tốt nhất cho read-only)
@Query("""
    SELECT new com.example.dto.ProductWithCategory(
        p.id, p.name, p.price, c.name)
    FROM Product p JOIN p.category c
    """)
List<ProductWithCategory> findAllProjected();

3. ページネーションと並べ替え

3.1 リポジトリ内でページング可能

public interface ProductRepository extends JpaRepository<Product, Long> {

    // Tự động hỗ trợ pagination
    Page<Product> findByActiveTrue(Pageable pageable);

    Page<Product> findByCategorySlug(String slug, Pageable pageable);

    @Query("SELECT p FROM Product p WHERE p.price > :minPrice")
    Page<Product> findExpensive(@Param("minPrice") BigDecimal minPrice,
                                 Pageable pageable);
}

3.2 ページネーションを備えたコントローラー

@GetMapping
public ResponseEntity<Page<ProductResponse>> getProducts(
        @RequestParam(defaultValue = "0") int page,
        @RequestParam(defaultValue = "20") int size,
        @RequestParam(defaultValue = "createdAt") String sortBy,
        @RequestParam(defaultValue = "desc") String sortDir) {

    Sort sort = sortDir.equalsIgnoreCase("asc")
        ? Sort.by(sortBy).ascending()
        : Sort.by(sortBy).descending();

    Pageable pageable = PageRequest.of(page, size, sort);
    Page<Product> products = productRepository.findByActiveTrue(pageable);

    Page<ProductResponse> response = products.map(ProductResponse::from);
    return ResponseEntity.ok(response);
}

3.3 ページの応答

{
    "content": [
        {"id": 1, "name": "Product A", "price": 100},
        {"id": 2, "name": "Product B", "price": 200}
    ],
    "pageable": {
        "pageNumber": 0,
        "pageSize": 20,
        "sort": {"sorted": true, "direction": "DESC"}
    },
    "totalElements": 150,
    "totalPages": 8,
    "first": true,
    "last": false,
    "numberOfElements": 20
}

3.4 スライスとページ

// Page: Chạy COUNT query → biết total elements (tốn performance)
Page<Product> findByActiveTrue(Pageable pageable);

// Slice: Không chạy COUNT → chỉ biết hasNext (tốt cho infinite scroll)
Slice<Product> findByActiveTrue(Pageable pageable);

4. JPA 仕様 — 動的クエリ

4.1 仕様はいつ必要ですか?

API にオプションの複数のフィルター パラメーターがある場合:

GET /api/products?category=electronics&minPrice=100&maxPrice=500&status=ACTIVE&keyword=phone

すべての組み合わせに対して派生クエリ メソッドを記述することは現実的ではありません。この仕様により、クエリを動的に構築できます。

4.2 実装

// Repository extends JpaSpecificationExecutor
public interface ProductRepository extends
        JpaRepository<Product, Long>,
        JpaSpecificationExecutor<Product> {
}

// Specification builder
public class ProductSpecification {

    public static Specification<Product> hasCategory(String categorySlug) {
        return (root, query, cb) -> {
            if (categorySlug == null) return null;
            Join<Product, Category> category = root.join("category");
            return cb.equal(category.get("slug"), categorySlug);
        };
    }

    public static Specification<Product> priceBetween(BigDecimal min, BigDecimal max) {
        return (root, query, cb) -> {
            if (min == null && max == null) return null;
            if (min != null && max != null) {
                return cb.between(root.get("price"), min, max);
            }
            if (min != null) {
                return cb.greaterThanOrEqualTo(root.get("price"), min);
            }
            return cb.lessThanOrEqualTo(root.get("price"), max);
        };
    }

    public static Specification<Product> hasStatus(ProductStatus status) {
        return (root, query, cb) -> {
            if (status == null) return null;
            return cb.equal(root.get("status"), status);
        };
    }

    public static Specification<Product> nameContains(String keyword) {
        return (root, query, cb) -> {
            if (keyword == null || keyword.isBlank()) return null;
            return cb.like(
                cb.lower(root.get("name")),
                "%" + keyword.toLowerCase() + "%"
            );
        };
    }
}

4.3 サービスでの使用

@Service
public class ProductService {

    private final ProductRepository productRepository;

    public Page<ProductResponse> search(
            String category, BigDecimal minPrice, BigDecimal maxPrice,
            ProductStatus status, String keyword, Pageable pageable) {

        Specification<Product> spec = Specification
            .where(ProductSpecification.hasCategory(category))
            .and(ProductSpecification.priceBetween(minPrice, maxPrice))
            .and(ProductSpecification.hasStatus(status))
            .and(ProductSpecification.nameContains(keyword));

        return productRepository.findAll(spec, pageable)
            .map(ProductResponse::from);
    }
}

4.4 コントローラー

@GetMapping("/search")
public ResponseEntity<Page<ProductResponse>> searchProducts(
        @RequestParam(required = false) String category,
        @RequestParam(required = false) BigDecimal minPrice,
        @RequestParam(required = false) BigDecimal maxPrice,
        @RequestParam(required = false) ProductStatus status,
        @RequestParam(required = false) String keyword,
        @RequestParam(defaultValue = "0") int page,
        @RequestParam(defaultValue = "20") int size) {

    Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
    Page<ProductResponse> results = productService.search(
        category, minPrice, maxPrice, status, keyword, pageable);

    return ResponseEntity.ok(results);
}

5. カスケードとオーファンの削除

@Entity
public class Order extends BaseEntity {

    @OneToMany(mappedBy = "order",
               cascade = CascadeType.ALL,    // Cascade tất cả operations
               orphanRemoval = true)          // Xóa item khi remove khỏi list
    private List<OrderItem> items = new ArrayList<>();

    public void addItem(OrderItem item) {
        items.add(item);
        item.setOrder(this);
    }

    public void removeItem(OrderItem item) {
        items.remove(item);
        item.setOrder(null);
        // orphanRemoval = true → item bị DELETE khỏi DB
    }
}

概要

  • JPA 関係 (@OneToMany、@ManyToOne、@ManyToMany) はエンティティ間の関係をマップし、常に FetchType.LAZY を使用します。
  • JOIN FETCH、@EntityGraph、@BatchSize、または DTO プロジェクションによって解決される N+1 問題
  • Spring Data ページネーション (Page、Slice、Pageable) は自動ページネーションとソートをサポートします
  • JPA 仕様では、多くのオプションのフィルター パラメーターを使用して動的クエリを構築できます

演習

  1. 3 つのエンティティを作成します: 著者 (1-N) 書籍 (N-N) タグ。便利なメソッドを使用してリレーションシップを完全に実装する
  2. N+1 を使用せずに著者のすべての書籍を取得するクエリを作成します (@EntityGraph または JOIN FETCH を使用)。ログ内のクエリ数で検証する
  3. JPA 仕様を使用して書籍の検索 API を実装します。著者、タグ、価格帯、タイトルのキーワードでフィルターします。ページネーションと組み合わせる