C
Spring Boot/運用/Lesson 10

運用コア — @Transactional · Spring Security

60分·theory

運用コア — @Transactional · Spring Security

🎯 このレッスンを読み終えたら

このレッスンを最後まで読み終えると、以下の3つを自信を持って行えるようになります。

  • ✅ Spring Security Filter Chain の図 + JWT 認証フィルターの位置
  • ✅ @PreAuthorize によるメソッドレベルの権限制御
  • ✅ BCryptPasswordEncoder が標準である理由(MD5/SHA-1 は禁止)

学習目標をチェックリストとして手元に置き、すべて答えられるようになったらレッスンを閉じましょう。

@Transactional — *Spring で最も強力な一行*

核心の一行

@Transactional アノテーション一行で、そのメソッドを DB トランザクションの中で実行します。例外発生時は自動ロールバック成功時はコミット。送金・決済のように原子性が重要な処理の標準です。

最もシンプルな使い方

java
@Service
public class TransferService {
    @Transactional
    public void transfer(Long fromId, Long toId, BigDecimal amount) {
        Account from = accountRepo.findById(fromId).get();
        Account to   = accountRepo.findById(toId).get();
        from.withdraw(amount);
        to.deposit(amount);
        // メソッド終了 → コミット
        // 例外 → 自動ロールバック
    }
}

DB の BEGIN・COMMIT・ROLLBACK を Spring が自動的にラップしてくれます。書き忘れる心配がありません。

プロキシベース — 外部呼び出しにのみ適用

Spring の @TransactionalAOP プロキシで動作します。メソッドが外部から呼び出されたときにのみトランザクションが適用されます。

java
@Service
public class OrderService {
    @Transactional
    public void create() {
        process();        // ❌ 内部呼び出し — トランザクション適用なし!
    }

    @Transactional
    public void process() { ... }
}

同じクラス内のメソッド呼び出しはプロキシを迂回 → アノテーションが無視されます。

解決策:

  • 別の Bean に分離する
  • または AopContext.currentProxy() で自己プロキシを呼び出す

伝播 (Propagation) — トランザクションの中でさらにトランザクション

java
@Service
public class OrderService {
    @Transactional
    public void create() {
        logService.save();     // logService も @Transactional だったら?
    }
}

すでにトランザクションの中にいるのに、さらに @Transactional なメソッドを呼び出すとどうなるのでしょうか? 伝播オプションによって決まります。

オプション意味
REQUIRED (デフォルト)既存があれば参加、なければ新しく開始
REQUIRES_NEW常に新しいトランザクションを開始(既存を一時停止)
NESTED既存の中に セーブポイントを作成 — 部分ロールバックが可能
MANDATORY既存のトランザクションが必要(なければエラー)
SUPPORTS既存があれば参加、なければトランザクションなしで実行
NEVER既存のトランザクションがあればエラー

最もよく使う: デフォルトの REQUIRED。ただし、ログ・監査記録のように親がロールバックされても記録は残さなければならない場合は REQUIRES_NEW を使います。

java
@Transactional(propagation = REQUIRES_NEW)
public void writeAuditLog(...) { ... }

ロールバックルール — RuntimeException のみ自動

java
@Transactional
public void process() {
    riskyOperation();      // throws IOException (Checked)
}

デフォルト: RuntimeException とそのサブクラス(Unchecked)のみが自動ロールバックの対象です。IOException のような Checked 例外はコミットされます。

これは Java 特有の設計 — 他の言語にはほとんどない区別です。知らないとデータ破損事故を起こしやすくなります。

解決策:

java
@Transactional(rollbackFor = Exception.class)
public void process() throws IOException { ... }

すべての例外でロールバック。通常これが安全なデフォルトです。Kotlin や最近の Spring コードではほぼ常に明示されています。

readOnly = true — 読み取り専用の最適化

java
@Transactional(readOnly = true)
public List<User> findAll() { ... }

参照のみを行うメソッドなら readOnly=true を設定します。JPA がダーティチェックをスキップしてパフォーマンスが向上します。Hibernate の FlushMode も自動的に NEVER になります。

クラス単位:

java
@Service
@Transactional(readOnly = true)        // デフォルトは readOnly
public class UserService {
    public User findById(Long id) { ... }

    @Transactional                      // 書き込みメソッドのみオーバーライド
    public User update(Long id, ...) { ... }
}

CQRS パターンと自然に調和します。

よくある落とし穴 5 つ

1. 内部呼び出し — 上記参照。同じクラス内のメソッド呼び出しはトランザクションを無視します。
2. Checked 例外のコミットrollbackFor = Exception.class を明示する
3. private メソッド — プロキシは適用されません。public でなければなりません。
4. トランザクション内での外部 API 呼び出し — DB ロックが外部のレスポンス時間の分だけ保持されます。トランザクションので呼び出すことを推奨します。
5. トランザクションのスコープが広すぎる — メソッド全体をラップするとロックが長くなります。最小限のスコープに留めましょう。

まとめ

  • @Transactional 一行で自動トランザクション
  • プロキシベース → 外部呼び出しにのみ適用
  • 伝播オプションでネストの動作を制御
  • デフォルトは RuntimeException のみロールバック → rollbackFor = Exception.class を推奨
  • 読み取りメソッドは readOnly = true を使用

Spring Security — *認証と認可*

認証 vs. 認可

よく混同される2つの言葉:

  • 認証 (Authentication) = あなたは誰ですか? 本人確認(ログイン)
  • 認可 (Authorization) = それを行う権限がありますか? 権限確認(管理者ページへのアクセス)

Spring Security は両方を処理します。

最小構成 (Spring Security 6 / Spring Boot 3)

java
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .csrf(csrf -> csrf.disable())           // SPA・API では通常 disable
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/public/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            .sessionManagement(s -> s.sessionCreationPolicy(STATELESS))
            .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
            .build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder(12);
    }
}

URL ごとの権限、セッションポリシー、JWT フィルターの登録 — すべて一つのチェーンで。

ログインフロー — 最もよく使われるパターン

1. パスワード検証 (Authentication Manager)

java
@RequiredArgsConstructor
public class LoginService {
    private final UserRepository userRepo;
    private final PasswordEncoder encoder;
    private final JwtProvider jwt;

    public LoginResponse login(String email, String password) {
        User u = userRepo.findByEmail(email)
            .orElseThrow(() -> new BadCredentialsException("メールアドレスが見つかりません"));

        if (!encoder.matches(password, u.getPassword())) {
            throw new BadCredentialsException("パスワードが一致しません");
        }

        String accessToken  = jwt.createAccess(u.getId(), u.getRole());
        String refreshToken = jwt.createRefresh(u.getId());

        return new LoginResponse(accessToken, refreshToken);
    }
}

核心:bcrypt によるパスワードのハッシュ比較。平文は絶対 NG。

2. JWT 発行 + レスポンス

  • Access Token — 15 分間、API 呼び出しのたびにヘッダーに付与
  • Refresh Token — 7 日間、httpOnly クッキー(XSS 対策)

3. 以降のリクエスト — JwtAuthenticationFilter

java
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    private final JwtProvider jwt;

    @Override
    protected void doFilterInternal(HttpServletRequest req, ...) {
        String token = extractToken(req.getHeader("Authorization"));
        if (token != null && jwt.validate(token)) {
            Authentication auth = jwt.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(auth);
        }
        chain.doFilter(req, res);
    }
}

リクエストごとにトークン検証 → SecurityContext にユーザー情報を保存 → 他の場所から @AuthenticationPrincipal でアクセス。

メソッドレベルの認可

java
@PreAuthorize("hasRole('ADMIN')")
@DeleteMapping("/users/{id}")
public void delete(@PathVariable Long id) { ... }

@PreAuthorize("#userId == authentication.principal.id or hasRole('ADMIN')")
@PutMapping("/users/{userId}")
public UserDto update(@PathVariable Long userId, ...) { ... }

URL 単位ではなくメソッド単位での権限制御。SpEL (Spring Expression Language) で複雑なルールを表現できます。

有効化:設定クラスに @EnableMethodSecurity を追加。

OAuth 2.0 — ソーシャルログイン

自前で実装せず、Spring Security OAuth2 Client を使用:

yaml
spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: ${GOOGLE_CLIENT_ID}
            client-secret: ${GOOGLE_CLIENT_SECRET}
            scope: email,profile
          kakao:
            client-id: ...

Spring がOAuth フロー全体(リダイレクト・トークン交換・ユーザー情報取得)を処理します。コールバックで自分たちの DB のユーザーとのマッピングだけ行えば OK。

よくあるセキュリティの落とし穴

1. CSRF disable の乱用 — REST API + JWT なら OK。セッションクッキーベースなら有効にする必要があります。
2. CORS が緩すぎる* は禁止。明示的なオリジンリストを使用。
3. パスワードの平文保存 — 事故寸前。bcrypt または argon2 は必須。
4. JWT シークレットの露出 — 環境変数を使用。絶対にハードコードしない。
5. SQL インジェクション — JPA またはパラメータ化クエリを常に使用。

まとめ

  • Spring Security = 認証・認可の標準
  • JWT がモダントレンド(ステートレス・モバイルフレンドリー)
  • パスワードは bcryptJWT シークレットは環境変数CSRF・CORS は適切に設定
  • メソッドレベルの権限制御は @PreAuthorize
  • ソーシャルログインは OAuth2 Client で一行

Spring Security フィルターチェーン · JWT フィルター実装

Security の核心 — フィルターチェーン

Spring Security は数十個のフィルターをチェーンで連結します。HTTP リクエストが来ると、順番にすべてのフィルターを通過してからコントローラーに到達します。

code
[HTTP リクエスト]
    ↓
[SecurityContextPersistenceFilter]   ← 認証情報のロード
    ↓
[UsernamePasswordAuthenticationFilter]   ← ログインフォーム
    ↓
[BearerTokenAuthenticationFilter]   ← JWT(カスタム)
    ↓
[ExceptionTranslationFilter]   ← 認証失敗の処理
    ↓
[FilterSecurityInterceptor]   ← 権限チェック
    ↓
[DispatcherServlet → Controller]

JWT 認証を追加するには、チェーンに自分のフィルターを挿入する必要があります。

JWT フィルター — OncePerRequestFilter を継承

java
@Component
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {

    private final JwtTokenProvider provider;

    @Override
    protected void doFilterInternal(HttpServletRequest req,
                                     HttpServletResponse res,
                                     FilterChain chain) throws ServletException, IOException {
        String header = req.getHeader("Authorization");
        if (header != null && header.startsWith("Bearer ")) {
            String token = header.substring(7);
            try {
                Authentication auth = provider.getAuthentication(token);
                SecurityContextHolder.getContext().setAuthentication(auth);
            } catch (JwtException e) {
                log.debug("JWT 検証失敗: {}", e.getMessage());
            }
        }
        chain.doFilter(req, res);
    }
}

OncePerRequestFilter1 リクエストにつき 1 回だけ実行されることを保証します。Spring の forward・include による重複実行を防止します。

SecurityFilterChain の登録

java
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtAuthFilter jwtAuthFilter;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .csrf(csrf -> csrf.disable())                 // JWT はステートレス → CSRF 不要
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/auth/**", "/public/**").permitAll()
                .requestMatchers("/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
            .build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();    // bcrypt — *絶対に MD5/SHA-1 は使わないでください*
    }
}

.addFilterBefore(...)任意の位置にフィルターを挿入できます。

メソッドレベルの権限制御 — @PreAuthorize

java
@EnableMethodSecurity   // SecurityConfig に追加

@RestController
class AdminController {

    @PreAuthorize("hasRole('ADMIN')")
    @DeleteMapping("/users/{id}")
    public void deleteUser(@PathVariable Long id) { ... }

    @PreAuthorize("#id == authentication.principal.id or hasRole('ADMIN')")
    @GetMapping("/users/{id}")
    public UserResponse get(@PathVariable Long id) { ... }
}

SpEL (Spring Expression Language)複雑な権限条件を表現できます。「本人か ADMIN のみ」といったパターンが一行で書けます。

まとめ

  • フィルターチェーン — リクエスト処理のパイプライン
  • OncePerRequestFilter — JWT のようなカスタムフィルターの標準ベースクラス
  • @PreAuthorize — コントローラー・サービスレベルの権限制御
  • BCryptPasswordEncoder — パスワードは必ず bcrypt を使用

面接で「Security をどのように適用しましたか?」と聞かれたら、この 4 つを挙げられるようにしておきましょう。

🤖 AI にこう依頼してみましょう

このレッスンの概念を知っていると、AI に具体的に指示できます。漠然とした「直して」ではなく、語彙を持ったリクエスト — それがトークン節約の出発点です。

  • 「この SecurityConfig に JWT 認証フィルター (OncePerRequestFilter) を追加して」
  • 「このメソッドを @PreAuthorize('hasRole(ADMIN)') で保護して」
  • 「BCryptPasswordEncoder を Bean として登録して」

なぜこれがトークンを減らすのか

概念を知らないと、AI の回答を受け取っても「それって何ですか?」と再度聞かなければなりません。その「再質問」がトークンを消費します。概念を一度身につければ、会話が一度で終わります

運用コア — @Transactional · Spring Security - Spring Boot