0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

BaaSを卒業して自前バックエンドへ。〜スリープ対策からJWT認証、フロントとの命名規則のズレまで、フルスタック開発で直面した壁〜

Last updated at Posted at 2026-02-02

はじめに

BaaS(Backend as a Service)を利用して作成したアプリを、学習のために「フロントとバックを疎結合にして、自分でバックエンドを構築してみたい」と思い、Ruby on Railsへの書き換えに挑戦しました。

移行にあたって直面した「3つの壁」とその解決策を、技術ドキュメントとしてまとめます。

壁①:サーバーの「スリープ問題」をどう叩き起こすか(スリープ対策)

バックエンドのホスティングには Render の無料プランを採用しました。しかし、Renderには「15分間リクエストがないとスリープする」という仕様があります。
スリープ状態でフロント(Vercel/Angular)を操作すると、起動まで30秒〜1分ほど待たされ、ユーザー体験が著しく低下してしまいます。
そこで、ユーザーが具体的な操作を始める前、アプリを開いた瞬間にバックグラウンドでリクエストを飛ばし、サーバーを叩き起こす仕組みを導入しました。

1. Rails側に軽量な「Health Check」を用意
DBアクセスを介さない、最も負荷の低いエンドポイントを作成します。
DBへのクエリが走らないため、サーバー本体が立ち上がった瞬間に 200 OK を返せます。

rb
# config/routes.rb
namespace :api do
  namespace :v2 do
    get 'health_check', to: ->(env) { [200, {}, ['ok']] }
    # ... 他のルート
  end
end

2. Angularの起動時にリクエストを飛ばす
ユーザーが「お気に入り登録」などの操作をする前に、アプリが開いた瞬間にバックグラウンドで1発リクエストを飛ばします。
app.component.tsngOnInit で実行します。

// app.component.ts
export class AppComponent implements OnInit {
  constructor(private http: HttpClient) {}

  ngOnInit() {
    // レスポンスを待つ必要はないので、単に1発叩くだけ
    // Rails側に GET /api/health_check などを作っておくと理想的
    this.http.get('https://your-api.com/api/health_check')
      .subscribe({
        next: () => console.log('Backend is awake!'),
        error: () => console.log('Backend is waking up...')
      });
  }
}

これでユーザーがアプリを起動させると、サーバーが勝手に起きるという体験を作れます。

壁②:JWT(JSON Web Token)による安全な認証設計

自前サーバーにする以上、認証も自分で設計する必要があります。今回は JWT を採用し、Railsの CurrentAttributes を使って「今誰がアクセスしているか」を管理しました。

なぜJWT?
今回はAngularとRailsを別々のドメイン(VercelとRender)で動かす「完全な疎結合」を目指しました。Cookieベースの認証だとクロスドメインの設定が複雑になりがちですが、JWTであればHTTPヘッダーにトークンを載せるだけで済むため、アーキテクチャをシンプルに保てるためJWTを採用しました。
またJWTによるトークン認証は、Cookieを扱いにくいモバイルアプリとの相性が非常に良く、拡張性も高いこともポイントです。

JWTとCookie(セッション認証)の詳しい比較については、以下の記事などが非常に参考になります。

仕組みの全体像

1. ログイン時: Railsが authToken を発行し、Angularへ送る。
2. 保存: Angularがそれを localStorage に保管する。
3. リクエスト: Angularがヘッダーにカギ(トークン)を載せて送信。
4. 認証: Railsが解読し、Current.user にユーザーをセット。

実装のポイント

1. Rails:UserモデルとJWT生成
(app/models/user.rb) まず、パスワードのハッシュ化と、ログイン時に「カギ(トークン)」を発行するメソッドを用意します。
キー名を authToken にしています。

class User < ApplicationRecord
  has_secure_password

  validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }
  validates :fullname, presence: true

  # JWTトークンを生成するメソッド
  def generate_jwt_token
    payload = { user_id: id }
    # 署名には RAILS_MASTER_KEY (または JWT_SECRET_KEY) が使われる
    JwtService.encode(payload)
  end
end

2. Rails:ログイン処理(SessionsController)
(app/controllers/api/v2/sessions_controller.rb) ユーザーを検証し、Angular側で扱いやすい authToken というキー名でトークンを返却します。

class Api::V2::SessionsController < ApplicationController
  skip_before_action :require_authentication, only: [:create]

  def create
    if user = User.authenticate_by(email: params[:email], password: params[:password])
      token = user.generate_jwt_token
      render json: {
        message: "ログインしました",
        user: user.as_json(only: [:id, :fullname, :email]),
        authToken: token, # Angular側の変数名に合わせる
        token_type: "Bearer"
      }, status: :created
    else
      render json: { error: "メールアドレスまたはパスワードが正しくありません" }, status: :unauthorized
    end
  end
end

3. Rails:共通の認証ガード(ApplicationController)(app/controllers/application_controller.rb) すべてのリクエストの前にトークンを解読し、Current.user という「どこからでもアクセスできる箱」にユーザーを入れます。

class ApplicationController < ActionController::API
  before_action :require_authentication

  private
  def require_authentication
    token = request.headers["Authorization"]&.split(" ")&.last
    decoded = JwtService.decode(token) if token
    if decoded && user = User.find_by(id: decoded[:user_id])
      Current.user = user # 「Currentという箱」にユーザーを入れる
    else
      render json: { error: "認証が必要です" }, status: :unauthorized
    end
  end
end

これにより、各コントローラーでは current_user を引数で回す必要がなくなり、以下のようにスマートに記述できます。

@favorite_item = Current.user.favorite_items.new(favorite_item_params)

4. Angular:トークンのlocalStorage への保存(AuthService)
(src/app/services/auth.service.ts) Railsから届いた authToken を、ブラウザの「財布(localStorage)」に大切に保管します。

login(credentials: any) {
  return this.http.post<AuthResponse>(`${this.apiUrl}/session`, credentials).pipe(
    tap(response => {
      if (this.isBrowser) {
        const token = response.authToken || response.token; // Railsから届いたトークン
        if (token) {
          localStorage.setItem('authToken', token);
          console.log('✅ ログイン成功、トークンを保存しました');
        }
      }
    })
  );
}

5. Angular:カギを自動で載せる(HttpInterceptor)
リクエストのたびに手動でヘッダーを書くのは大変です。Angularの HttpInterceptor を使うと、すべての通信に自動でトークンを忍ばせることができます。

// src/app/interceptors/auth.interceptor.ts
import { HttpInterceptor, HttpRequest, HttpHandler, HttpEvent } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { AuthService } from '../services/auth.service';
import { Observable } from 'rxjs';

@Injectable()
export class AuthInterceptor implements HttpInterceptor {
  constructor(private authService: AuthService) {}

  intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    const token = this.authService.getToken();

    // トークンがあれば、ヘッダーに 'Authorization: Bearer <token>' を追加する
    if (token) {
      const cloned = req.clone({
        headers: req.headers.set('Authorization', `Bearer ${token}`)
      });
      return next.handle(cloned);
    }

    return next.handle(req);
  }
}

壁③:フロントとバックの「命名規則」のズレ

Angular(TypeScript)は camelCase、Rails(Ruby)は snake_case が標準です。これをそのまま通信させると、バリデーションエラーの原因になります。

APIとの通信時に、データのキー名を相互に変換するレイヤーを用意しました。

ts
// 取得時:API(snake_case) -> Angular(camelCase)
private transformFromApi(data: any): FavoriteItem {
  return {
    id: data.id,
    itemName: data.item_name,
    brandName: data.brand_name,
    // ...
  };
}

// 保存時:Angular(camelCase) -> API(snake_case)
private transformToApi(item: FavoriteItem): any {
  return {
    item_name: item.itemName,
    brand_name: item.brandName,
    // ...
  };
}

このメソッドを pipe(map(...)) の中で通すことで、フロントのコード内ではキャメルケースを保ちつつ、バックエンドとも正しく会話できるようになりました。

  // 全お気に入りアイテムを取得
  getFavoriteItems(): Observable<FavoriteItem[]> {
    return this.http.get<any[]>(`${this.apiUrl}/favorite_items`, {
      headers: { 'Content-Type': 'application/json' }
    }).pipe(
      map(items => items.map(item => this.transformFromApi(item)))
    );
  }

おわりに

BaaSを使えば一瞬で終わることも、自前で実装すると多くの「壁」にぶつかりました。ですが、自前でバックエンドを構築し、サーバーサイドのロジックを一つひとつ実装したことで、システム全体を自分でコントロールできている実感があり、非常に楽しく感じました。

この経験を糧に、これからも「フロントとバックの両面から最適な設計」を心がけていきたいと思います。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?