はじめに
未経験からエンジニアへのキャリアチェンジを目指してJavaを学習中のゆうきと申します。
REST APIによるCRUD処理やWebアプリのMVC構造、AWSを活用した本番環境へのデプロイなどを実践的に学ぶために学習記録アプリを開発したため、紹介させていただきます。
長くなりますが、お読みいただけると嬉しいです。
学習記録アプリ 「えらいコレクト」
🌐 アプリURL
公開URL: https://www.awesome-collect.com
GitHub: https://github.com/yk00pg/awesome_collect
- ゲストログイン機能により、ユーザー登録をすることなくアプリをお試しいただけます
公開URLにアクセスした直後は、サーバーの起動や接続遅延により一時的にエラーが表示される場合があります。
数秒ほどでログイン画面が表示されますので、そのままお待ちいただけますと幸いです。
❓ 制作背景
学習を続けていると、唐突に、そして定期的に訪れる落ち込み期を経験される方は少なくないと思います。
- 「自分は本当に頑張れているのだろうか」「このままで良いのだろうか」 と落ち込む
- 学習継続日数や累計学習時間が積み上がっていっても、学習の実感が湧かず、数字だけが増えていく気がして 不安になる
- 同じように学習を続けている方のことは「すごいな」「頑張っているな」と思えるのに、自分のことはそう思えずもやもや
そんな思いに囚われて学習の手が止まってしまわないように、学習日数や学習時間のほかに自分の日々の頑張りを実感できる指標のようなものがあればモチベーションを維持する一助になるのではないかと考えました。
本アプリを通して自分の日々の行動を褒めることで、「よし、ちゃんと頑張れているぞ! この調子で引き続き頑張ろう!」 と自分の頑張りを認め、自身を鼓舞するきっかけになれば良いなと思い、作成しました。
🎯 コンセプト・概要
- 学習にまつわるアクション(やること・できたこと・目標・メモ・記事ストック)を登録することでえらい!ポイントが貯まり、えらい!メッセージが表示されます
- 獲得したえらい!ポイント、学習日数、学習時間を数値やグラフで確認することができます
- 自分の行動によって獲得したえらい!が増えていく様子や、自分を褒めるメッセージを日常的に目にするといった小さな成功体験の積み重ねにより、学習者の自己肯定感を高めて自信を育み、モチベーションの維持・学習の継続をサポートすることを目的としています
👥 想定ユーザー
- 何かしらの学習に取り組んでいる / 取り組もうとしている個人
- 学習中に落ち込み期を経験している / 経験したことのある個人
🛠️ 開発環境・使用技術
- 開発環境: Mac OS (Sequoia 15.7.1), IntelliJ IDEA
-
フロントエンド:
- 言語: HTML (Thymeleaf), CSS, JavaScript
- ライブラリ: Chart.js (グラフ描画), Tagify (タグ入力補助), EasyMDE (Markdownエディタ)
-
バックエンド:
- 言語: Java (Oracle OpenJDK 21.0.9)
- フレームワーク: Spring Boot (3.4.7)
- 認証: Spring Security
- データベース: MySQL (8.0.42 / Docker, MyBatis)
-
インフラ:
- AWS EC2 (Amazon Linux 2023)
- Docker Compose(開発環境: ローカルビルド / 本番環境: GHCR配布イメージ使用)
- AWS Elastic Load Balancer(SSL終端 & EC2転送・リダイレクト処理)
- AWS Route 53(独自ドメイン管理 & DNSルーティング)
- AWS Certificate Manager(SSL証明書の発行・管理)
-
CI
- GitHub Actions(自動ビルド)
- GitHub Container Registry(Dockerイメージ管理)
- バージョン管理: Git, GitHub
⚙️ 機能一覧
| No. | 機能 | |
|---|---|---|
| 1 | 👤 ユーザー新規登録機能 | 詳細 |
| 2 | 🚪 ログイン・ログアウト機能 | 詳細 |
| 3 | 👤 ゲストログイン機能 | 詳細 |
| 4 | 📝 学習アクション登録機能 | 詳細 |
| 5 | 🏷️ タグ付け機能 | 詳細 |
| 6 | 📊 ダッシュボード機能 | 詳細 |
| 7 | 👍 えらい!ポイント & えらい!メッセージ獲得機能 | 詳細 |
🟠 ER図
- ユーザー登録時に自動採番されるuser_info.idを他テーブルのuser_idにFKとして関連付け
- Spring SecurityのCustomUserDetailsのidとしてuser_info.idを設定することで、AuthenticationPrincipalからuser_idを参照可能
- 学習アクションとタグの関係を中間テーブルで管理
💡 これらの紐付けにより、ユーザー単位での容易なデータ取得、一貫性・整合性のあるデータ処理を実現しています
🟣 インフラ構成図
services:
app:
image: ghcr.io/yk00pg/awesome_collect:latest
restart: unless-stopped
depends_on:
- db
environment:
SPRING_DATASOURCE_URL: ${SPRING_DATASOURCE_URL}
SPRING_DATASOURCE_USERNAME: ${SPRING_DATASOURCE_USERNAME}
SPRING_DATASOURCE_PASSWORD: ${SPRING_DATASOURCE_PASSWORD}
ports:
- "8080:8080"
db:
image: mysql:8.0.42
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: awesome_collect
MYSQL_USER: ${MYSQL_USER}
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
TZ: Asia/Tokyo
volumes:
- db_data:/var/lib/mysql
- ./mysql/init.sql:/docker-entrypoint-initdb.d/init.sql
volumes:
db_data:
- 環境変数は.envにて管理
- 開発環境との差分(開発環境用はリポジトリ内のdocker-compose.yamlを参照)
- イメージをGHCRから取得
- 再起動ポリシーを設定
🚀 機能紹介
1. 👤 ユーザー新規登録機能
| ユーザー新規登録フロー |
|---|
- ログインID、ユーザー名、メールアドレス、パスワードで新規登録を行います(ユーザー名・メールアドレスの入力は任意)
- パスワードはハッシュ化してDBに保存されます
- マイページにて登録内容の変更(ゲストユーザーは除く)、ユーザーアカウントの削除を行うことができます
| ユーザー基本情報変更フォーム | パスワード変更フォーム |
|---|---|
✅ フォームバリデーション
| 項目 | 内容 |
|---|---|
| ログインID |
|
| ユーザ名 |
|
| メールアドレス |
|
| パスワード |
|
2. 🚪 ログイン・ログアウト機能
| ログインフロー | ログインエラー |
|---|---|
- 登録したログインID(大文字・小文字を区別して判定)とパスワードでログインします
- 認証・認可にはSpring Securityを使用しています
💡 セキュリティを考慮し、ログイン失敗時のエラーメッセージには詳細を記載していません
3. 👤 ゲストログイン機能
| ゲストログインフロー |
|---|
- ユーザー登録をすることなく、アプリをお試しいただくことができます
- ログイン時に新規ゲストユーザーアカウントが作成され、CSVファイルからダミーデータを読み込んでDBに登録します(処理中はローディングオーバーレイを表示)
- アカウント情報はログアウト時にすべて削除されます(ログアウト漏れに備え、定期的な削除処理も実行しています)
💡 開発者の2025年4月〜9月の学習記録を基に作成したダミーデータを使用しているため、リアルな使用感を体験していただくことができます
ログイン時
- LoginController でゲストユーザーアカウント作成処理を呼び出して実行し、作成したユーザー情報をSpring Security のSecurityContextHolder に渡して認証し、セッションに保存
- GuestUserService でゲストユーザーアカウントおよびユーザー進捗状況を作成し、ダミーデータ登録処理を呼び出して実行
- DummyDataService でCSVファイルからダミーデータを読み込み、DBに登録
@PostMapping(ViewNames.GUEST_LOGIN)
public String guestLogin(HttpServletRequest request){
UserInfo guestUser = guestUserService.createGuestUser();
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(guestUser.getLoginId(), GuestUser.PASSWORD);
Authentication auth = authenticationManager.authenticate(authToken);
SecurityContextHolder.getContext().setAuthentication(auth);
// SecurityContextをセッションに保存
request.getSession(true).setAttribute(
HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY,
SecurityContextHolder.getContext());
return RedirectUtil.redirectView(ViewNames.TOP_PAGE);
}
@Transactional
public UserInfo createGuestUser() {
String randomId = UUID.randomUUID().toString().substring(0, 8);
String loginId = GuestUser.LOGIN_ID + randomId;
while(userInfoRepository.findUserInfoByLoginId(loginId) != null){
randomId = UUID.randomUUID().toString().substring(0, 8);
loginId = GuestUser.LOGIN_ID + randomId;
}
UserInfo guestUser = UserInfo.builder()
.loginId(loginId)
.userName(GuestUser.NAME)
.email(loginId + GuestUser.EMAIL)
.password(passwordEncoder.encode(GuestUser.PASSWORD))
.isGuest(true)
.build();
userInfoRepository.registerNewUserInfo(guestUser);
int guestUserId = guestUser.getId();
userProgressService.createUserProgress(guestUserId);
dummyDataService.registerDummyData(guestUserId);
return guestUser;
}
public void registerDummyData(int guestUserId){
injectDummyTodo(guestUserId);
injectDummyDone(guestUserId);
injectDummyGoal(guestUserId);
injectDummyMemo(guestUserId);
injectDummyArticleStock(guestUserId);
}
private void injectDummyTodo(int guestUserId) {
InputStream inputStream;
try {
inputStream = new ClassPathResource(CsvFileName.DUMMY_TODO).getInputStream();
} catch (IOException e) {
throw new RuntimeException(e);
}
List<DummyTodoDto> recordList =
CsvLoader.load(inputStream, DummyTodoDto :: fromCsvRecord);
dailyTodoService.registerDummyTodo(guestUserId, recordList);
}
// 〜〜 以下、できたこと・目標・メモ・記事ストックも同様の処理を実行 〜〜
ログアウト時
- SecurityConfig.java でログアウト成功時の処理を実装したハンドラとしてLogoutSuccessHandler を設定(コード割愛)
- LogoutSuccessHandler を実装したGuestLogoutSuccessHandler にて、削除処理を呼び出して実行
@Transactional
@Override
public void onLogoutSuccess(
HttpServletRequest request, HttpServletResponse response,
Authentication authentication)
throws IOException {
if(authentication != null) {
CustomUserDetails user = (CustomUserDetails) authentication.getPrincipal();
if(user.isGuest()){
deleteUserDataService.deleteUserData(user.getId());
}
}
response.sendRedirect(ViewNames.LOGIN_PAGE);
}
定期実行
- メインクラスに
@EnableSchedulingアノテーションをつけてスケジュールされたタスク実行機能を有効化(コード割愛) - GuestUserCleanupScheduler でスケジュールを設定し、GuestUserService にて削除処理を呼び出して実行し、ログを出力
@Scheduled(cron = "0 0 12 * * *", zone = "Asia/Tokyo")
public void cleanupGuestUsers() {
guestUserService.cleanupGuestUsers();
}
@Transactional
public void cleanupGuestUsers(){
List<Integer> guestUserIdList = userInfoRepository.selectGuestUserId();
if(guestUserIdList==null || guestUserIdList.isEmpty()){
logger.info("No expired guest users found. Cleanup skipped.");
return;
}
logger.info("=== Guest User Cleanup Started. Target count: {} ===", guestUserIdList.size());
List<Integer> expiredGuestUserIdList =
userProgressRepository.searchExpiredUserIdByUserId(
new ExpiredUserParams(guestUserIdList, LocalDate.now().minusDays(1)));
for(int expiredGuestUserId: expiredGuestUserIdList) {
deleteUserDataService.deleteUserData(expiredGuestUserId);
logger.info("Deleted guest user data: userId={}", expiredGuestUserId);
}
logger.info("=== Guest User Cleanup Finished. Total deleted: {} ===", expiredGuestUserIdList.size());
}
4. 📝 学習アクション登録機能 & 5. 🏷️ タグ付け機能
| 登録フロー(できたこと ver.) |
|---|
| 編集フロー(できたこと ver.) | 削除フロー(できたこと ver.) |
|---|---|
- 学習にまつわる下記のアクションを登録・編集・削除することができます
- やること(日付、内容)
- できこと(日付、内容、学習時間、メモ、タグ)
- 目標(タイトル、内容、進捗状況、タグ)
- メモ(タイトル、内容、タグ)
- 記事ストック(タイトル、URL、メモ、閲覧状況、タグ)
- 学習アクション(できたこと・目標・メモ・記事ストック)にタグを紐付けて登録することができます
🟢 シーケンス図(例: 「目標」登録)
- ユーザーリクエスト(フォーム入力 & 送信)により、大まかに分けて「データ確認」「目標登録」、「タグ & 目標とタグの関係性登録」、「ユーザー進捗状況 & ボーナスえらい!獲得状況更新」の4つの処理を実行
- データ確認は2箇所で実施
- GoalController: DTOアノテーションとカスタムバリデータを通してバリデーションチェック(形式・内容が正しいか)を実施し、エラーを確認
- GoalService: DB登録済みデータと照合して重複があるか確認し、例外処理
🔵 クラス図(ユーティリティークラスは除く)
※ 属性、操作はそれぞれ当該処理に関連するもののみ抜粋
🚩 目標登録
🏷️ タグ登録 & 目標とタグの関係性登録
※ GoalTagJunctionService, BaseActionJunctionServiceのregisterNewRelationsの引数のBiFunctionは出力上正しくパースされませんが、中身がわかるように"~"
で囲って記載してます
- GoalService でJSON形式のタグリストを文字列形式に置換し、TagService に渡してタグIDを解決(登録されていれば取得、されていなければ登録して取得)する
- BaseActionTagJunctionService を継承したGoalTagJunctionService に目標IDとタグIDリストを渡し、関係性を中間テーブルに登録する
public SaveResult saveGoal(int userId, GoalRequestDto dto) {
List<String> pureTagList = JsonConverter.extractValues(dto.getTags());
List<Integer> tagIdList = tagService.resolveTagIdList(userId, pureTagList);
int goalId = dto.getId();
SaveResult saveResult;
if (goalId == 0) {
saveResult = registerGoal(userId, dto, tagIdList);
} else {
saveResult = updateGoal(userId, dto, tagIdList, goalId);
}
sessionManager.setHasUpdatedRecordCount(true);
return saveResult;
}
@Transactional
private SaveResult registerGoal(
int userId, GoalRequestDto dto, List<Integer> tagIdList) throws DuplicateException {
if (isDuplicateTitle(dto.getId(), userId, dto.getTitle())) {
throw new DuplicateException(DuplicateType.TITLE);
}
Goal goal = dto.toGoalForRegistration(userId);
goalRepository.registerGoal(goal);
goalTagJunctionService.registerNewRelations(goal.getId(), GoalTagJunction :: new, tagIdList);
userProgressService.updateUserProgress(userId);
return new SaveResult(goal.getId(), false);
}
@Override
public void registerNewRelations(
int goalId, BiFunction<Integer, Integer, GoalTagJunction> relationFactory,
List<Integer> tagIdList) {
super.registerNewRelations(goalId, relationFactory, tagIdList);
}
public void registerNewRelations(
int actionId, BiFunction<Integer, Integer, T> relationFactory,
List<Integer> tagIdList) {
if (tagIdList == null || tagIdList.isEmpty()) {
return;
}
for (int tagId : tagIdList) {
T relation = relationFactory.apply(actionId, tagId);
registerRelation(relation);
}
}
protected void registerRelation(T relation) {
repository.registerRelation(relation);
}
6. 📊 ダッシュボード機能
| ダッシュボード確認フロー |
|---|
- ダッシュボードページにて、下記の情報を数値やグラフで確認することができます
- 累計えらい!ポイント(ポイント数、イラストグラフ)
- 累計学習日数
- 連続学習日数(日数、最後に学習した日)
- 累計学習時間
- 学習時間グラフ
- 日別学習時間(過去7日分)
- 曜日別平均学習時間
- 月別学習時間(過去6ヶ月分)
- タグ別学習時間(上位10件、全件)
🌸 えらい!ポイント・イラストグラフ
- えらい!ポイントの獲得数に応じて植物が成長し、100ポイントごとに数が増えていきます
| ポイント数 | 1〜9 | 10〜29 | 30〜69 | 70〜99 | 100 |
|---|---|---|---|---|---|
| イラスト | ![]() |
![]() |
![]() |
![]() |
![]() |
| 成長過程 | 種まき | 水やり | 双葉 | 咲いた花 | 花瓶 |
7. 👍 えらい!ポイント & えらい!メッセージ獲得機能
| えらい!メッセージ(できたこと・登録ver.) |
|---|
- 学習にまつわるアクションを登録することで、えらい!ポイントを獲得することができます
- アクション登録時(やること・できたことは日付あたり1件目の登録時)にえらい!メッセージが表示されます
- アクション登録の継続日数に応じてボーナスえらい!ポイントを獲得することができます
👑 ノーマルえらい!獲得条件
| 条件 | 1件あたりのポイント | メッセージ |
|---|---|---|
| やることを登録 | 1えらい! | やることを決めてえらい! |
| できたことを登録 | 3えらい! | 実行できてえらい! |
| 目標を登録 | 5えらい! | 目標を立ててえらい! |
| 目標を達成 | 10えらい! | 達成できてえらい! |
| メモを登録 | 5えらい! | メモに残してえらい! |
| 記事ストックを登録 | 3えらい! | 情報を集めてえらい! |
| 登録した記事を読了 | 5えらい! | 知識を深めてえらい! |
👑 ボーナスえらい!獲得条件
| 条件(n日ごと) | 1日あたりの追加ポイント |
|---|---|
| いずれかのアクションを登録 | 1えらい! |
| 3日連続でいずれかのアクションを登録 | 3えらい! |
| 7日連続でいずれかのアクションを登録 | 7えらい! |
| 30日連続でいずれかのアクションを登録 | 10えらい! |
✨ 工夫したところ
💖 ユーザーを意識した工夫
ユーザーの心に寄り添う設計
- 入力内容の多いユーザー・少ないユーザー双方が登録しやすいように、やること・できたことの入力枠を可変にしました
- えらい!を貯めること自体が目的となってしまわないように、過度な演出やゲーム要素は省きつつ、達成感は得られるようにバランスを調整しました
- 登録時に表示するポップアップウィンドウにアニメーションを加えて動きをつけることで、特別感を演出
- えらい!ポイントが貯まっていく様子をイラストグラフで表示
- イラストグラフには、マウスホバー時にアニメーションを加えて動きをつけることで、えらい!が育っていく様子を演出
- ユーザーの心理的負担を減らせるように、エラーページやエラーメッセージを適切に表示しています
- 入力エラー時にどこがどのように間違っているかを表示することで、入力のストレスを軽減(セキュリティを考慮し、ログインフォームでは詳細は表示しない)
- アプリのデザインに合わせたエラーページを用意し、ユーザーの不安を緩和し、その後の行動を案内
努力を可視化し、学習の振り返りをサポート
- 数値だけでなく、累計えらい!ポイントはイラストグラフ、学習時間はChart.jsを活用して横棒・縦棒グラフとして可視化することで、直感的に理解できるようにしています
- Chart.jsのグラフ描画において、不要なラベルを削除し、ツールチップを調整することで視覚的なノイズを排除しました
直感的に操作できるUI
- メインメニューやサイドメニュー、編集・削除などをアイコンで表現し、直感的に操作できるようにしました
- メモの入力フォームにEasyMDE(マークダウンエディタ)を導入し、構造的な文章作成の補助を実現しています
🔨 技術的な工夫
可読性・保守性・拡張性・再利用性を意識した設計
- 可読性・保守性を意識し、各クラスの責任や役割が明確になるように設計しました
- Controller / Service / Repositoryの3層構造を採用し、責務を明確化
- クラス内においても処理ごとにメソッドを細分化あるいはまとめることで、可読性を向上し、各メソッドの役割を明確化
- バリデーションチェック、エラーチェックを種類ごとに実施し、適切なデータ登録を実現しています(シーケンス図参照)
- DTO: アノテーションで「入力必須」「文字数」「形式」などの単純なバリデーションを制御
- カスタムバリデータクラス: 「未来の日付不可」「他フィールドの入力に応じて入力必須」などの複雑なバリデーションを制御
- サービスクラス: 「重複不可」「現在のパスワードと入力パスワードの照合」などのDBに登録されたデータとの照合が必要なバリデーションを制御
- 中間テーブルへの関係性の登録処理を共通化し、保守性・再利用性の高いコード設計を心がけました(クラス図参照)
- 抽象クラスBaseActionJunctionServiceを用意し、BiFunctionを引数に取ることで柔軟な拡張を実現
DBアクセス負荷軽減 / 一貫性・整合性のあるデータ処理
- えらい!ポイント・学習日数・学習時間の集計をダッシュボードアクセス時に限定し、かつ算出したデータをセッションに保持することで、DBへのアクセスを最小限にしています
- Spring Securityの認証情報とデータ取得を紐付けることにより、一貫性のあるデータ取得を実現しています(ER図参照)
- ユーザー登録時にDBで自動採番されるuser_info.idをCustomUserDetailsのidとして設定することでAuthenticationPrincipalからidを参照可能
- user_info.idを他テーブルの外部キーとして関連付けることで、データの整合性を保証
- セキュリティを考慮し、認証情報とデータ取得を厳密に紐付け、ログイン中のユーザー以外のデータアクセスを防止
本番環境の読込遅延対策
- ゲストログインの場合、ゲストユーザーアカウント作成時のダミーデータ注入に時間を要するため、ローディングオーバーレイを実装
- CSSや画像などの静的ファイルにキャッシュヘッダーを設定し、ブラウザが2回目以降に再取得しないようにすることで体感速度を改善
💭 今後実装予定の機能
- 登録済みタグの名称変更・削除機能
- 獲得したえらい!ポイントの内訳・獲得履歴等確認機能
- 登録済み学習アクションの検索・絞り込み・並び替え・ページネーション機能
- メールアドレス認証によるログインID照会 / パスワード再設定機能
- CSVファイルをアップロードによる過去の学習記録(できたこと)インポート機能
- CSVファイルのエクスポート機能
📎 Appendix
- 開発メモ(アイディア出しや要件定義など)
おわりに
「Webアプリとは?」からスタートし、なんとかひとつのアプリを完成させることができました。
今後もインプットとアウトプットを重ねてスキルを向上させていきたいと思います。
最後までお読みいただきありがとうございました。




