はじめに (対象読者・この記事でわかること)
この記事は、Spring BootでWebアプリケーションを開発しているが、データベースから取得したデータに外部キーしか含まれていない場合に、それを元に関連テーブルのデータを取得して画面表示したい方を対象としています。
具体的には、注文一覧画面で「顧客ID」しか持っていないデータから、顧客名を取得して表示するといったケースを想定しています。
この記事を読むことで、Spring BootのRepository層でのJOIN処理、Service層でのデータ変換、Thymeleafでの表示まで一連の流れを実装できるようになります。特に、N+1問題を避けながら効率的にデータを取得する方法を身につけることができます。
前提知識
この記事を読み進める上で、以下の知識があるとスムーズです。
- Javaの基本的な文法とオブジェクト指向の概念
- Spring Bootの基本的なアプリケーション構造(Controller、Service、Repository)
- MyBatisまたはJPAの基本的な使用方法
- Thymeleafの基本的な構文
外部キーだけを持つListから関連データを取得する課題
実務でよくあるシナリオとして、「注文テーブル」には「顧客ID」しか保存されていないが、画面表示時には「顧客名」も一緒に表示したいという要件があります。
単純に考えると、Listの各要素に対して顧客情報を個別に取得する処理を書いてしまいがちですが、これはデータ件数が増えるほどパフォーマンスが劣化するN+1問題を引き起こします。
この記事では、効率的に関連データを取得する方法と、それをThymeleafで表示する実装パターンを解説します。
Spring Boot + MyBatisで効率的に関連データを取得する実装
データモデルの準備とDTOの設計
まず、データベースのテーブル構造と対応するDTOを定義します。
Java// 注文テーブル用DTO @Data public class OrderDto { private Long orderId; private LocalDate orderDate; private Long customerId; // 外部キーのみ private BigDecimal totalAmount; } // 顧客テーブル用DTO @Data public class CustomerDto { private Long customerId; private String customerName; private String email; } // 画面表示用の結合DTO @Data public class OrderWithCustomerDto { private Long orderId; private LocalDate orderDate; private Long customerId; private String customerName; // 外部キーから取得したデータ private BigDecimal totalAmount; }
RepositoryでのJOINクエリ実装
MyBatisを使用した場合、一発で関連データを取得するMapperを実装します。
Java@Mapper @Repository public interface OrderMapper { // 効率的に一発でデータを取得 @Select(""" SELECT o.order_id, o.order_date, o.customer_id, c.customer_name, o.total_amount FROM orders o INNER JOIN customers c ON o.customer_id = c.customer_id WHERE o.order_date >= #{startDate} ORDER BY o.order_date DESC """) @Results({ @Result(property = "orderId", column = "order_id"), @Result(property = "orderDate", column = "order_date"), @Result(property = "customerId", column = "customer_id"), @Result(property = "customerName", column = "customer_name"), @Result(property = "totalAmount", column = "total_amount") }) List<OrderWithCustomerDto> selectOrdersWithCustomer(LocalDate startDate); }
Service層でのビジネスロジック実装
Serviceクラスでは、Repositoryから取得したデータを加工します。
Java@Service @RequiredArgsConstructor public class OrderService { private final OrderMapper orderMapper; private final CustomerMapper customerMapper; // 方法1: JOINを使った一発取得 public List<OrderWithCustomerDto> getOrderListWithCustomer(LocalDate startDate) { return orderMapper.selectOrdersWithCustomer(startDate); } // 方法2: 後から関連データを設定(キャッシュ活用) @Cacheable("orderCustomerCache") public List<OrderDto> getOrdersAndSetCustomer(List<OrderDto> orders) { // 顧客IDの重複を除去 Set<Long> customerIds = orders.stream() .map(OrderDto::getCustomerId) .collect(Collectors.toSet()); // IN句で一発取得 List<CustomerDto> customers = customerMapper.selectByIds(customerIds); // IDをキーにしたMapに変換 Map<Long, CustomerDto> customerMap = customers.stream() .collect(Collectors.toMap(CustomerDto::getCustomerId, Function.identity())); // 注文データに顧客情報を設定 return orders.stream() .map(order -> { CustomerDto customer = customerMap.get(order.getCustomerId()); if (customer != null) { order.setCustomerName(customer.getCustomerName()); } return order; }) .collect(Collectors.toList()); } }
ControllerでのModelへの設定
Controllerでは、Serviceから取得したデータをModelに設定します。
Java@Controller @RequestMapping("/orders") @RequiredArgsConstructor public class OrderController { private final OrderService orderService; @GetMapping("/list") public String showOrderList( @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate startDate, Model model) { // 日付が指定されない場合は本日から1ヶ月前をデフォルトに if (startDate == null) { startDate = LocalDate.now().minusMonths(1); } // 注文一覧を取得(顧客情報含む) List<OrderWithCustomerDto> orders = orderService.getOrderListWithCustomer(startDate); model.addAttribute("orders", orders); model.addAttribute("startDate", startDate); return "order/list"; } }
Thymeleafでの表示実装
Thymeleafテンプレートでは、取得したデータを効率的に表示します。
Html<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <head> <title>注文一覧</title> <link rel="stylesheet" href="/css/style.css"> </head> <body> <div class="container"> <h1>注文一覧</h1> <form th:action="@{/orders/list}" method="get" class="search-form"> <label>開始日:</label> <input type="date" name="startDate" th:value="${startDate}"> <button type="submit">検索</button> </form> <table class="order-table"> <thead> <tr> <th>注文ID</th> <th>注文日</th> <th>顧客ID</th> <th>顧客名</th> <th>合計金額</th> </tr> </thead> <tbody> <tr th:each="order : ${orders}"> <td th:text="${order.orderId}">1</td> <td th:text="${#temporals.format(order.orderDate, 'yyyy/MM/dd')}">2024/01/01</td> <td th:text="${order.customerId}">100</td> <td th:text="${order.customerName}">山田太郎</td> <td th:text="${#numbers.formatCurrency(order.totalAmount)}">¥10,000</td> </tr> <tr th:if="${#lists.isEmpty(orders)}"> <td colspan="5" class="no-data">該当する注文がありません</td> </tr> </tbody> </table> </div> </body> </html>
ハマった点やエラー解決
実装中に遭遇した代表的な問題をいくつか紹介します。
1. LazyInitializationExceptionの発生 JPAを使用していて、Serviceで取得したエンティティの関連オブジェクトにアクセスしようとすると、トランザクションが終了しているためこのエラーが発生します。
2. N+1問題の隠蔽化 JOINせずに個別に取得していると、最初は件数が少ないため気づかないが、本番データで動かすと表示が異常に遅くなることがあります。
3. Thymeleafでのnullポインター例外 関連データが存在しないケースを考慮していないと、思わぬところでnullポインター例外が発生します。
解決策
上記の問題に対する解決策です。
LazyInitializationExceptionへの対処
Java// Service層で詰め直す @EntityGraph(attributePaths = {"customer"}) // JPAの場合 @Query("SELECT o FROM Order o JOIN FETCH o.customer") // JPQLでFETCH JOIN
N+1問題の検出
Yaml# application.yml logging: level: org.springframework.jdbc: DEBUG # SQLログを出力 com.example.mapper: DEBUG # MyBatisのログ
null安全なThymeleaf記述
Html<td th:text="${order?.customer?.customerName ?: '不明'}">顧客名</td>
まとめ
本記事では、Spring Bootで外部キーしか持たないListから関連データを効率的に取得し、Thymeleafで表示する方法を解説しました。
- JOINクエリを活用することで、N+1問題を回避しながら一発でデータを取得
- DTOの責務を明確に分離することで、画面表示用のデータ構造を最適化
- キャッシュ機能を活用することで、パフォーマンスをさらに向上
この記事を通して、実務でよくある「関連テーブルのデータを表示したい」という要件に対して、スケーラブルで保守性の高い実装方法を身につけることができました。
今後は、より複雑な条件での絞り込みや、ページネーション機能を追加した実装についても記事にする予定です。
参考資料
