エンドユーザー向けDVDレンタルアプリを作っている話 — dvdrental 管理アプリと対になる Vue 3 + Spring Boot 構成と記事マップ
はじめに
以前、PostgreSQL のサンプル DB dvdrental をベースにした管理画面アプリを作りました。
PostgreSQL のサンプル DB dvdrental をベースに Spring Boot + Thymeleaf で DVD レンタル管理アプリを作った話
管理アプリは「スタッフが使う業務システム」です。顧客管理、在庫管理、レンタル管理、支払い管理、売上レポートを一通り備えています。
ただ、これだけでは「管理する側の道具」しかありません。
実際に DVD を借りる側のアプリがない。
この記事はその続きです。管理アプリと対になるエンドユーザー向けアプリ — つまり「顧客が自分で作品を探し、オンラインでレンタルを申し込めるアプリ」を作っている話です。
このアプリは現在も開発中です。 この記事は完成報告ではなく、進行中の設計判断や技術選定の記録として書いています。 機能の追加・変更に合わせて随時更新していく予定です。
アプリの位置づけ
管理アプリとエンドユーザーアプリは、同じ dvdrental データベースを共有しながら、完全に別のアプリケーションとして作っています。
┌─────────────────────┐
│ PostgreSQL │
│ (dvdrental) │
└──────────┬──────────┘
│ 共通DB
┌──────────────────┴───────────────────┐
│ │
┌──────────▼──────────┐ ┌────────────▼────────────┐
│ 管理アプリ │ │ エンドユーザーアプリ │
│ Spring Boot │ │ Spring Boot (REST API) │
│ + Thymeleaf │ │ + Vue 3 + TypeScript │
│ │ │ │
│ スタッフ専用 │ │ 顧客向け │
│ 社内ネットワーク │ │ インターネット公開 │
└─────────────────────┘ └─────────────────────────┘
| アプリ | 技術スタック | 対象ユーザー | 公開範囲 |
|---|---|---|---|
| 管理アプリ | Spring Boot + Thymeleaf | スタッフ | 社内ネットワーク |
| エンドユーザーアプリ | Spring Boot (REST API) + Vue 3 + TypeScript | 顧客 | インターネット公開 |
両アプリは同じ dvdrental PostgreSQL データベースを共有し、それぞれ独立したアプリケーションとして動作します。
管理アプリは「業務を回す側」、エンドユーザーアプリは「利用する側」。 役割が違うので、技術スタックも認証も分けています。
2 つのアプリが共有する技術と、この記事シリーズの読み方
| 管理アプリ | エンドユーザーアプリ | |
|---|---|---|
| 言語 | Java | Java |
| バックエンドフレームワーク | Spring Boot | Spring Boot |
| フロントエンド | Thymeleaf(サーバーサイドレンダリング)+ TypeScript | Vue 3 + TypeScript |
| API 形式 | MVC(Controller → View) | REST API(Controller → JSON) |
バックエンドは両アプリとも Java + Spring Boot が共通です。フロントエンドのアーキテクチャが分かれています。管理アプリは Thymeleaf でサーバーサイドレンダリング、エンドユーザーアプリは Vue 3 による SPA です。どちらも TypeScript を使っており、型安全な UI 実装という点では同じ方針です。
この 2 本柱の構成を作ったことで、Spring Boot / Java / Vue 3 / TypeScript の実装パターンを両面から記録できるようになりました。この記事シリーズは「作った記録」であると同時に、これら 4 つの技術について後で読み返す参考書・教科書として機能するよう書いています。各記事はテーマを絞っているので、特定の技術や実装パターンを調べたいときに単独で参照できます。
管理アプリとの大きな違い:ログイン不要で使える公開エリアがある
管理アプリはすべての機能がログイン必須です。スタッフしかアクセスできない前提で設計されており、未ログイン状態で到達できる画面は存在しません。
一方、エンドユーザーアプリにはログインしなくても使える公開エリアがあります。
| 管理アプリ | エンドユーザーアプリ | |
|---|---|---|
| 未ログインで使える機能 | なし(全画面ログイン必須) | 作品一覧・詳細・在庫確認・おすすめ表示 |
| ログイン後に使える機能 | 顧客管理・在庫管理・売上レポートなど | マイページ・レンタル申込・決済・履歴確認 |
| 対象ユーザー | 社内スタッフ | 一般ユーザー(会員登録前も含む) |
「まず作品を見てから、借りたくなったら会員登録する」という導線を成立させるために、この公開エリアは必須の設計でした。検索・閲覧・在庫確認までは認証なしで完結できます。
なぜ同じリポジトリに入れず分けたのか、その判断の詳細は別記事に書いています。 → 管理画面と顧客向けアプリを「別リポジトリ」に分けた理由と、その判断で得たもの・失ったもの
技術スタックの選択
| 層 | 技術 |
|---|---|
| バックエンド | Spring Boot 3 + MyBatis + Spring Security |
| フロントエンド | Vue 3 + TypeScript + Vite |
| データベース | PostgreSQL(dvdrental ベース + Flyway で拡張) |
| 決済 | PayPay Dynamic QR Code API / PAY.JP |
| デプロイ構成 | mvn package 一発でフロントを jar に同梱 |
管理アプリが Spring Boot + Thymeleaf だったのに対して、このアプリでは Spring Boot を REST API に徹させ、UI は Vue 3 で構築する分離構成を選んでいます。なぜそうしたかは次の記事で詳しく書いています。
→ Spring Boot バックエンド + Vue 3 フロントエンドの分離構成を選んだ理由と、実際の繋ぎ方
画面構成
画面遷移の全体は単一の App.vue が currentPage ステートで管理しています。
Vue Router は使わず、コンポーネントの出し分けで画面切り替えを実現しています。
著作権への配慮について
UI の構成・レイアウトは、EC サイトや動画配信サービスといった実際の大手サービスの画面を参考にしながら、既存サービスのデザインに依存しない独自実装としています。タイトル画像・プレビュー動画も同様の方針で、既存作品・既存キャラクターを使わず AI 生成素材を採用しています。
① 共通ヘッダー・ナビゲーション

ヘッダーは 3 層構造(トップバー・マストヘッド・グローバルタブ)で構成されています。決済画面に入ると通常ヘッダーが非表示になり、シンプルな「Isolated Checkout」ヘッダーに切り替わります。
<!-- App.vue(抜粋) -->
<header class="masthead" v-if="!isCheckoutFlow">
<!-- 通常ヘッダー -->
</header>
<header class="checkout-masthead" v-if="isCheckoutFlow">
<div class="brand">CINEMA DAYS</div>
<div class="secure-note">🔒 安全な通信で決済処理を行っています</div>
</header>
→ 決済画面を「Isolated Checkout」にすべき理由と実装
currentPage の型は union literal で定義しており、Vue Router は使っていません。タブ遷移の際にルーティング設計をしなくて済む分、小さなアプリでは状態遷移がシンプルになります。
// App.vue(currentPage 型定義 抜粋)
type PageType =
| 'films' | 'cd-rentals' | 'streaming'
| 'ranking' | 'feature' | 'detail'
| 'auth' | 'member-register' | 'member-register-confirm'
| 'mypage' | 'checkout'
const currentPage = ref<PageType>('films')
→ App.vue のページ状態管理を、単純な currentPage で回した設計判断
② 作品一覧画面(FilmsView.vue)

作品フィルタは Vue 3 の computed だけで実装しており、API の追加呼び出しは発生しません。
サイドバーの開閉状態は localStorage で保持しています。
<!-- FilmsView.vue(フィルタ部分 抜粋) -->
<ul class="filter-list">
<li
v-for="cat in categoryOptions"
:key="cat"
:class="{ 'filter-active': selectedCategory === cat }"
@click="applyCategoryFilter(cat)"
>{{ cat }}</li>
</ul>
→ Vue 3 computed だけでフロント完結フィルタ検索を実装した話
→ localStorage でサイドバーの開閉状態を保持しつつ、初回描画のちらつきを防ぐ実装
③ 作品詳細画面(FilmDetailView.vue)

タイトル画像・プレビュー動画は Gemini Ultra で生成
作品詳細に表示しているタイトル画像(ポスタービジュアル)とプレビュー動画は、Google Gemini Ultra の画像生成・動画生成機能で作りました。
- 著作権に配慮して、既存作品・既存キャラクターに依存しない条件で生成
- 生成プロンプトを細かく固定せず、いわゆる「お任せ」に近い形で実行
- 結果として、整いすぎない・どこか独特な素材が揃い、UI としての存在感が強くなった
開発中の画面映えと記事の読み物としての面白さを両立させる手段として、AI 生成素材はかなり実用的でした。
ただし、画像・動画が欠けるケースを前提にフォールバック構成も入れています。
// FilmDetailView.vue(タイトル画像フォールバック 抜粋)
const buildTitleImageCandidates = (filmId: number): string[] => [
`/media/film-${filmId}-title.jpg`,
`/media/film-${filmId}-title.png`,
]
候補を順に試し、すべて失敗した場合はプレースホルダ表示に落とす構成です。
→ Gemini Ultra で映画ポスターとプレビュー動画を生成してDVDレンタルアプリに使った話
作品詳細には「今すぐレンタル」と「カートへ追加」の 2 つのボタンがあります。この 2 つを分ける理由は UX 設計上の判断です。
<!-- FilmDetailView.vue(CTA ボタン 抜粋) -->
<template v-if="isRentalSection">
<button class="btn-primary" @click="emit('direct-checkout', film)">
今すぐレンタル(決済)
</button>
<button v-if="isAuthenticated" class="btn-secondary"
@click="emit('add-to-cart', film)">
カートへ追加
</button>
</template>
→ EC サイトで「カートに入れる」と「今すぐ購入」を分けるべき理由と実装
詳細画面への遷移時は、前画面のスクロール位置が残らないよう resetDetailViewport で先頭への強制スクロールとフォーカス解除を行っています。film.filmId の watch と onMounted の両方で呼ぶことで、初回マウント時と同一コンポーネントが再利用されるケースの両方をカバーしています。
// FilmDetailView.vue(スクロール・フォーカスリセット 抜粋)
const resetDetailViewport = () => {
window.scrollTo({ top: 0, left: 0, behavior: 'auto' })
const activeElement = document.activeElement
if (activeElement instanceof HTMLElement) {
activeElement.blur()
}
}
watch(() => film.filmId, () => {
resetDetailViewport()
void resolveInitialMedia()
})
onMounted(() => {
resetDetailViewport()
void resolveInitialMedia()
})
→ 詳細遷移時に scrollTo(0, 0) と blur() を入れた理由
④ ログイン・会員登録画面(AuthView.vue)

ログイン処理はフロント → バックエンド API という通常の REST 呼び出しです。
Spring Security のフィルタチェーンは使わず、BCryptPasswordEncoder で手動照合します。
// AuthController.java(ログイン処理 抜粋)
@PostMapping("/login")
public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest request) {
AuthMapper.AuthRecord record = authMapper.findByEmail(request.email());
if (record == null) {
// メールアドレスが存在しない場合も同じメッセージを返す(列挙防止)
return ResponseEntity.status(401)
.body(new LoginResponse(false, "メールアドレスまたはパスワードが正しくありません。", ""));
}
if (!passwordEncoder.matches(request.password(), record.passwordHash())) {
return ResponseEntity.status(401)
.body(new LoginResponse(false, "メールアドレスまたはパスワードが正しくありません。", ""));
}
authMapper.updateLastLoginAt(record.customerId());
return ResponseEntity.ok(new LoginResponse(true, "ログインに成功しました。", "/films"));
}
⑤ 会員登録フォーム(MemberRegistrationView.vue)

「入力 → 確認 → 登録完了」の 3 ステップ構成です。確認画面では入力値を読み取り専用で表示し、戻って修正できます。
ステップ管理の仕組み
画面遷移は Vue Router を使わず、App.vue の currentPage ref で管理しています。
フォームの入力値は registerDraft に一時保存し、確認画面にそのまま props で渡します。
// App.vue(会員登録フロー 抜粋)
const registerDraft = ref<RegisterRequest | null>(null)
const openMemberRegisterConfirm = (payload: RegisterRequest) => {
registerDraft.value = payload // 入力値を一時保存
currentPage.value = 'member-register-confirm'
}
<!-- App.vue(テンプレート 抜粋) -->
<MemberRegistrationView
v-else-if="currentPage === 'member-register'"
@confirm="openMemberRegisterConfirm"
@back-auth="currentPage = 'auth'"
/>
<MemberRegistrationConfirmView
v-else-if="currentPage === 'member-register-confirm' && registerDraft"
:payload="registerDraft"
@back-edit="currentPage = 'member-register'"
@registered="currentPage = 'auth'"
/>
MemberRegistrationView は入力値を emit('confirm', { ...form }) で親に渡すだけで、「どこへ遷移するか」は自分では知りません。画面遷移の制御を親コンポーネントに集約するパターンです。
入力バリデーション
パスワードの検証は @submit.prevent="goConfirm" 内で行います。HTML の required に頼らず、パスワード一致・桁数・英数字混在を TypeScript で明示的にチェックします。
// MemberRegistrationView.vue(バリデーション 抜粋)
const goConfirm = () => {
passwordError.value = ''
if (form.password !== form.passwordConfirmation) {
passwordError.value = 'パスワードが一致しません。'
return
}
if (form.password.length < 8) {
passwordError.value = 'パスワードは8文字以上で入力してください。'
return
}
if (!/[a-zA-Z]/.test(form.password) || !/[0-9]/.test(form.password)) {
passwordError.value = 'パスワードには英字と数字を含めてください。'
return
}
emit('confirm', { ...form })
}
確認画面から API 呼び出し
確認画面(MemberRegistrationConfirmView.vue)が「登録する」ボタンを持ち、ここで API を呼びます。
loading フラグで二重送信を防ぎ、エラーは画面内に表示します。
// MemberRegistrationConfirmView.vue(登録処理 抜粋)
const submit = async () => {
loading.value = true
try {
const result = await registerMember(props.payload)
message.value = result.message
emit('registered') // 親が 'auth' ページへ切り替える
} catch (error) {
isError.value = true
message.value = error instanceof Error ? error.message : '登録に失敗しました。'
} finally {
loading.value = false
}
}
⑥ カートドロワー(CartDrawer.vue)
右端からスライドインするドロワー UI です。作品一覧・詳細画面でカートアイコンをクリックすると開きます。

カート状態は localStorage に永続化しており、ページを閉じても内容が保持されます。
// useCart.ts(カート追加 抜粋)
function addToCart(film: PublicFilmSummary): boolean {
ensureInitialized()
const exists = itemsRef.value.some((item) => item.filmId === film.filmId)
if (exists) return false // 重複追加を防ぐ
const next: CartItem = { ...film, addedAt: new Date().toISOString() }
itemsRef.value = [next, ...itemsRef.value]
saveToStorage(itemsRef.value)
return true
}
⑦ マイページ(MyPageView.vue)

マイページのデータは dvdrental 既存テーブルを JOIN して取得します。レンタルステータスは SQL の CASE 式で動的に計算します。
// MyPageMapper.java(レンタル履歴 抜粋)
@Select("""
SELECT
r.rental_id,
f.title AS film_title,
CASE
WHEN r.return_date IS NOT NULL THEN 'RETURNED'
WHEN r.rental_date < CURRENT_TIMESTAMP - INTERVAL '7 days' THEN 'OVERDUE'
ELSE 'OPEN'
END AS rental_status,
COALESCE(SUM(p.amount), 0) AS billed_amount
FROM rental r
JOIN inventory i ON i.inventory_id = r.inventory_id
JOIN film f ON f.film_id = i.film_id
LEFT JOIN payment p ON p.rental_id = r.rental_id
WHERE r.customer_id = #{customerId}
GROUP BY r.rental_id, r.rental_date, r.return_date, f.title
ORDER BY r.rental_date DESC
LIMIT #{size} OFFSET #{offset}
""")
List<RentalHistoryItemResponse> selectRentalHistory(
@Param("customerId") int customerId,
@Param("size") int size,
@Param("offset") int offset);
⑧ 決済画面(CheckoutView.vue)
決済画面は通常ヘッダー・ナビを非表示にした「Isolated Checkout」構成です。

PAY.JP と PayPay の 2 つの決済方法を実装しています。
PayPay の場合は Dynamic QR Code を生成し、PayPay アプリに飛ばします。
決済完了はポーリングで検知します。
// PayPayService.java(QR コード生成 抜粋)
public QRCodeDetails createQRCode(int amount, String merchantPaymentId, String redirectUrl)
throws ApiException {
QRCode qrCode = new QRCode();
qrCode.setMerchantPaymentId(merchantPaymentId);
qrCode.setAmount(new MoneyAmount().amount(amount).currency(MoneyAmount.CurrencyEnum.JPY));
qrCode.setCodeType("ORDER_QR");
qrCode.setOrderDescription("DVD レンタル決済");
qrCode.setRequestedAt(Instant.now().getEpochSecond());
return paymentApi.createQRCode(qrCode);
}
PAY.JP の場合は Payjp.js をフロント側でマウントし、カード情報は自サーバーを一切経由しません(PCI DSS 準拠)。
dvdrental への DB カスタマイズ
dvdrental はサンプル DB なので、エンドユーザーアプリを動かすために必要なテーブルを追加しています。
スキーマ変更は Flyway で管理しています。
→ Flyway でスキーマ migration を段階的に育てていく運用方法
追加テーブル ① customer_authentication
dvdrental の customer テーブルは業務データ(氏名・住所など)しか持っておらず、認証に必要なパスワードハッシュがありません。そこで認証専用テーブルを別に作りました。
CREATE TABLE public.customer_authentication (
customer_id INTEGER PRIMARY KEY
REFERENCES public.customer(customer_id),
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
auth_status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
last_login_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
MyBatis の Mapper はこのテーブルを直接参照します。
// AuthMapper.java(抜粋)
@Select("""
SELECT customer_id, email, password_hash, auth_status
FROM public.customer_authentication
WHERE LOWER(email) = LOWER(#{email})
LIMIT 1
""")
AuthRecord findByEmail(@Param("email") String email);
追加テーブル ② rental_tier
dvdrental の film テーブルにある rental_rate はフラットな数値です。
「新作」「準新作」「スタンダード」のような区分で絞り込めるようにするため、区分マスタを追加しました。
CREATE TABLE public.rental_tier (
rental_tier_id SERIAL PRIMARY KEY,
code VARCHAR(20) NOT NULL UNIQUE, -- 'NEW', 'SEMI_NEW', 'STANDARD'
label VARCHAR(50) NOT NULL,
default_rate NUMERIC(5,2) NOT NULL
);
-- film テーブルへ FK カラムを追加
ALTER TABLE public.film
ADD COLUMN rental_tier_id INTEGER REFERENCES public.rental_tier(rental_tier_id);
作品一覧 API ではこの rental_tier と JOIN して tier_code を返し、フロント側のフィルタに使います。
// PublicFilmMapper.java(抜粋)
@Select("""
SELECT
f.film_id, f.title, f.release_year,
c.name AS category_name,
COALESCE(f.rental_rate, rt.default_rate) AS rental_rate,
rt.code AS tier_code
FROM film f
JOIN film_category fc ON fc.film_id = f.film_id
JOIN category c ON c.category_id = fc.category_id
JOIN rental_tier rt ON rt.rental_tier_id = f.rental_tier_id
ORDER BY f.film_id
LIMIT #{size} OFFSET #{offset}
""")
List<PublicFilmSummaryResponse> selectPublicFilms(@Param("size") int size, @Param("offset") int offset);
追加カラム ③ film.taste_tags
LLM(Ollama / OpenAI)で映画の雰囲気タグを自動生成し、film テーブルの taste_tags カラムに保存しています。
ALTER TABLE film ADD COLUMN IF NOT EXISTS taste_tags TEXT[];
Spring Boot バッチが映画タイトルと説明文を LLM に渡し、返ってきたタグ配列を TEXT[] で保存します。
PostgreSQL の配列型と Spring Boot のマッピング、バッチ処理の実装は別記事で書いています。
→ LLM(Ollama/OpenAI)で映画タグを自動生成するバッチを Spring Boot に組み込んだ話
→ PostgreSQL の配列型(text[])を Spring Boot + JPA でマッピングする方法
バックエンドの API 構成
REST API は公開エンドポイントと認証必須エンドポイントを分けています。
Public API(認証不要)
GET /api/public/health ヘルスチェック
GET /api/public/films 作品一覧(ページング)
Authenticated API(ログイン済み)
POST /api/auth/login ログイン
GET /api/me/profile 自分の会員情報
GET /api/me/rentals 自分のレンタル履歴
GET /api/me/payments 自分の支払い履歴
POST /api/payments/paypay PayPay QR コード生成
GET /api/payments/paypay/{id} 決済ステータス確認(ポーリング)
POST /api/payments/payjp PAY.JP カード登録・課金
開発時は localhost:5173(Vue)→ localhost:8082(Spring Boot)の別オリジン構成です。
本番では mvn package 一発でフロントのビルド成果物を static/ に同梱し、Spring Boot が配信します。
→ Vue 3 + Vite + Spring Boot で CORS と Proxy を突破するまでの記録
→ Spring Boot で Vue3/React のフロントビルドを同梱して自動配信する構成
実装済みの機能
公開エリア(ログイン不要)
- 作品一覧(カテゴリ・レンタル区分・上映時間フィルタ、フリーワード検索)
- 作品詳細(タイトル画像・プレビュー動画・在庫状況・出演俳優)
- 未ログイン向けおすすめ表示
- LLM 自動生成の雰囲気タグによる絞り込み(実装中)
会員エリア(ログイン後)
- 会員登録(入力 → 確認 → 完了の 3 ステップ)
- ログイン・ログアウト
- マイページ(利用サマリー、レンタル履歴、支払い履歴)
- 会員情報編集・メールアドレス変更
- 退会フロー(確認画面あり)
- ログイン会員向けおすすめ表示・あとで借りるリスト
決済・レンタル
- 作品をカートに追加する「カートに入れる」フロー(カートドロワー)
- 即時レンタルに進む「今すぐレンタル」フロー
- PAY.JP クレジットカード決済(カード登録・使い回し対応)
- PayPay Dynamic QR Code 決済(ポーリングによる完了検知)
開発中に起きたトラブル
テキスト一括編集によるエンコーディング事故
作業中、テキスト一括置換ツールの書き込み時にエンコーディング起因の文字化けが発生し、複数の Vue ファイルの構文が崩れました。
- 「部分修正で追う」より「正しい版を全文で再生成する」方が速かった
- 復旧後に一覧・特集・ランキング・詳細を順に再確認し、最終的に develop へ統合した
文字列置換中心の作業では、エンコーディング事故を前提に復旧手順を決めておくことが重要だと学びました。
→ 一括テキスト編集で起きたエンコーディング事故と、全文再生成で復旧した話
開発中・未着手の機能
この項目は開発進捗に合わせて更新します。
-
customer_authenticationテーブルとの会員登録フローの完全結合 - 在庫確保ロジックの本番対応(現状はサンプルデータを参照するのみ)
- レンタル期間管理(延長・返却フロー)
- メールによる申込確認通知
- AWS へのデプロイ(管理アプリと同様の ECS/Fargate 構成)
記事マップ(このシリーズで書いた記事・書いている記事)
このアプリを作る過程で書いた記事を一覧にします。各記事はテーマを絞っており、Spring Boot / Java / Vue 3 / TypeScript の特定のトピックを調べたいときに単独でも参照できます。
タイトルが末尾に ※作成中 とあるものはドラフトまたは投稿前の状態です。
■ プロジェクト全体の設計判断
| テーマ | 記事 |
|---|---|
| 管理アプリと別リポジトリにした理由 | 管理画面と顧客向けアプリを「別リポジトリ」に分けた理由と、その判断で得たもの・失ったもの |
| プラットフォームとしての将来像 | DVDレンタルシステムをDMMのようなプラットフォームへ昇華させる構想 |
| ゼロからDBを設計しなかった理由 | 「ゼロからテーブル設計を起こさない」という選択 — dvdrental を土台にした開発 |
■ バックエンド構成
| テーマ | 記事 |
|---|---|
| Vue 3 + REST API 分離構成を選んだ理由 | Spring Boot バックエンド + Vue 3 フロントエンドの分離構成を選んだ理由と、実際の繋ぎ方 |
| JPA ではなく MyBatis を選んだ理由 | Spring Boot API サーバーに JPA ではなく MyBatis を選んだ理由 |
| Flyway で DB スキーマを段階的に育てる | Flyway でスキーマ migration を段階的に育てていく運用方法 |
| LLM で映画タグを自動生成するバッチ | LLM(Ollama/OpenAI)で映画タグを自動生成するバッチを Spring Boot に組み込んだ話 |
| PostgreSQL 配列型と Spring Boot のマッピング | PostgreSQL の配列型(text[])を Spring Boot + JPA でマッピングする方法 |
■ フロントエンド構成
| テーマ | 記事 |
|---|---|
| 開発時の CORS / Proxy 問題をどう解決したか | Vue 3 + Vite + Spring Boot で CORS と Proxy を突破するまでの記録 |
| 本番ビルドでフロントを jar に同梱する構成 | Spring Boot で Vue3/React のフロントビルドを同梱して自動配信する構成 |
| フィルタ・検索を computed だけで実装した話 | Vue 3 computed だけでフロント完結フィルタ検索を実装した話 |
| localStorage でサイドバー状態を保持しつつちらつきを防ぐ | localStorage でサイドバーの開閉状態を保持しつつ、初回描画のちらつきを防ぐ実装 |
| デスクトップとモバイルを「レスポンシブ CSS で一本化」しない選択 | デスクトップとモバイルを「レスポンシブ CSS で一本化」しない選択とその確認手順 |
| Gemini Ultra で映画ポスター・動画を生成した話 | Gemini Ultra で映画ポスターとプレビュー動画を生成してDVDレンタルアプリに使った話 |
| App.vue の currentPage による画面状態管理 | App.vue のページ状態管理を、単純な currentPage で回した設計判断 |
| 詳細遷移時に scrollTo + blur を入れた理由 | 詳細遷移時に scrollTo(0, 0) と blur() を入れた理由 |
| 一括テキスト編集で起きたエンコーディング事故と復旧 | 一括テキスト編集で起きたエンコーディング事故と、全文再生成で復旧した話 |
■ UX 設計・決済フロー
| テーマ | 記事 |
|---|---|
| 「カートに入れる」と「今すぐ購入」を分けるべき理由 | EC サイトで「カートに入れる」と「今すぐ購入」を分けるべき理由と実装 |
| 決済画面を「Isolated Checkout」にすべき理由 | 決済画面を「Isolated Checkout」にすべき理由と実装 |
■ 開発環境
| テーマ | 記事 |
|---|---|
| Windows で Maven / Node を PATH 依存なく動かす | Windows で Maven / Node / Java を PATH 依存なく動かすプロジェクト内完結構成 |
■ 対になる管理アプリの記事
管理アプリ側で書いた記事もこのエンドユーザーアプリと直接関係があるものを掲載します。
| テーマ | 記事 |
|---|---|
| 管理アプリの全体構成(dvdrental + Spring Boot + Thymeleaf) | PostgreSQL のサンプル DB dvdrental をベースに Spring Boot + Thymeleaf で DVD レンタル管理アプリを作った話 |
| 管理アプリを AWS ECS/Fargate + RDS にデプロイした話 | Spring Boot + Thymeleaf + PostgreSQL の管理画面アプリを AWS ECS/Fargate + RDS にデプロイするときの構成・運用・セキュリティ |
| dvdrental を日本語化した方法 | PostgreSQL のサンプル DB dvdrental を日本語化した方法 SQL と CSV を併用して管理画面向けデータを作る |
| 検索条件を画面遷移後も保持する(@SessionAttributes) | Spring MVC の @SessionAttributes で検索条件を画面遷移後も保持する |
| ECS Fargate の固定費(ALB + NAT Gateway)に気づいた話 | ECS Fargate の固定費(ALB + NAT Gateway)に気づいて構成を見直した話 |
| ECS で動く Spring Boot のログを CloudWatch Logs で確認する | AWS ECS で動く Spring Boot アプリのログを CloudWatch Logs で確認する方法 |
| CDK で誤リージョンにデプロイした話と後始末 | AWS CDK + PowerShell + SSO で誤リージョンにデプロイした話と後始末 |
| Spring Boot の active profile を Docker/ECS で切り替える | Spring Boot の active profile を docker-compose と ECS で切り替える設計 |
| PostgreSQL エンコーディングと Windows の関係 | PostgreSQL のエンコーディングと Windows の関係 |
おわりに
管理アプリを作ったとき、「スタッフが顧客データを管理できる場所はできたけど、顧客自身が使える場所がない」という問題がずっと気になっていました。
このエンドユーザーアプリは、その問いに答えるために始めたプロジェクトです。
ただし、まだ完成していません。
決済フローや在庫確保ロジックなど、「本当に動くサービス」として成立させるために必要なパーツはまだ揃っていない部分があります。設計判断や実装の記録を各記事に残しながら、少しずつ完成に近づけています。
この記事シリーズ全体は、Spring Boot / Java / Vue 3 / TypeScript の実装パターンを実際のアプリ開発を通じてまとめた参考書・教科書として機能するよう書いています。特定の技術やトピックを調べたいときは、記事マップから該当カテゴリを直接参照してください。
各記事のリンクは投稿済みのものから順次埋めていきます。