Spring Boot + JOOQ を利用した開発環境で安全に機能をクローズするための運用方法についてのメモ


概要

長くアプリケーションを運用していると、かつて必須であった機能が不要になるタイミングがあります。
このときの戦略については下記が考えられます。

不要になったリソースに手を入れない

影響範囲を極小化するために、不要になったリソースをそのままに残したままにしておく手法です。
私の肌感ですが、10 年くらい前私が投入されたほぼすべての SI の現場では、単体テストを作る・メンテナンスをする習慣がまったくなかったので、すでにテストしているものを再度テストする工数を捻出するのが困難であったためこの手法が好まれていました。

メリット

手を入れる必要がないので、機能が不要になるときに、なにかのはずみで他の機能が使えなくなるということがありません。
また、なにかのはずみでまた利用したくなる場合、再利用できます。

デメリット

経緯がわからないリソースが肥大化していきます。
私の場合、途中参画する現場で、なにが必要で何が不要なのかを完全に把握している人がいない事態が多々発生していました。
新規機能の開発や通常業務の保守において、使っているのか使われてないのかよくわからない項目の面倒を見る羽目になったり、やたらと列のあるテーブルから苦労して必要な項目を見つける必要が生じていました。

不要になったリソースを削除する

解決していた問題が問題ではなくなった段階で、問題解決の手段を破棄します。

メリット

必要なものがなにであるかについて、アプリケーションのリソースを見ることでアプリケーションの関心事を適切に確認できます。
なにかの新規機能追加・改修があった際に、今あるリソースがすべてであるため、ブラックボックスになったリソースの面倒をどうみるかという悩みが発生しません。

デメリット

リソースを削除する以上、参照元への影響を考慮する必要があります。自動テストを作って影響がないことを可能な限り担保したいところです。
また、今後必要になる可能性があるかどうかについての判断が必要になるかもしれません。
ただ、個人的な経験では不要になった機能が必要になる可能性はとても低く、かつ、仮に必要になったとしても現在のビジネスに於いて、ということになるので、昔の設計に引きづられないほうが却って工数が減る気がしています。

今回の投稿では不要になった機能はソースコード上から削除する戦略を採用する際、テーブルのカラムをドロップするシナリオを想定して、いかに安全にカラムをドロップすることができるかについて注意したい点をメモしておきます。

技術スタック

アプリケーションが参照するデータベースの場所は一つだけで、切り替えなどは一切行わない素朴な構成です。
自動化についてはある程度するものの、基本的に人間が面倒を見切れるレベルの規模なので、人間の手が少々入ることを想定します。
データの永続化については、RDB を採用しています。
データへのアクセスについては、Spring Boot を使いつつ、JOOQ を利用します。
JOOQ では、jooq-codegen を使って、DB から JOOQ のスキーマクラスを自動生成させたクラスを利用しています。
また、RDB のマイグレーションは、Flyway を使います。

Spring Boot 2.5.6
JOOQ codegen 3.14.15 (Spring Boot の JOOQ と同じバージョン)

手順

プログラムから機能を削除したバージョンのアプリケーションのデプロイ、カラムのドロップを、順番に実行します。
カラムのドロップを最初に行いつつ、機能削除したアプリケーションをデプロイすると、カラムがドロップされても、旧バージョンのプログラムがまだ稼働しているため、アプリケーション上でドロップしたカラムを参照するちぐはぐな状態が一時的にできあがります。
このときにドロップしたカラムにアクセスしている機能が利用されると、ドロップしたカラムはすでにテーブルに存在していないので、SQL が失敗してしまいます。
これを避けるため、まずは、プログラムからドロップするカラムへの参照をすべて削除して、プログラムの参照がないことが確認できたのちに、カラムをドロップするマイグレーションを実行します。

アプリケーションからの機能削除

アプリケーションのソースコードからドロップするカラムに関わるドメインオブジェクト・レスポンス用の DTO などから、ドロップするカラムの参照を削除します。
Map を使っているような場合も想定して、コンパイルが通ったあとも、文字列で参照がないかを確認します。

削除し終わったら JOOQ からドロップするカラムの参照を削除します。手で削除する事もできますが、JOOQ のスキーマを構成する Record クラスが、テーブルに定義された列の連番で、自分自身のプロパティに get/set を実行しています。

TestTableRecord.java
public class TestTableRecord extends UpdatableRecordImpl<TestTable> implements Record2<Integer, String> {

    private static final long serialVersionUID = 1L;

    /**
     * Setter for <code>test_table.id</code>. ID
     */
    public void setId(Integer value) {
        set(0, value);
    }

    /**
     * Getter for <code>test_table.id</code>. ID
     */
    public Integer getId() {
        return (Integer) get(0);
    }

    /**
     * Setter for <code>test_table.remove_column</code>. 不要になるカラム
     */
    public void setRemoveColumn(String value) {
        set(1, value);
    }

    /**
     * Getter for <code>test_table.remove_column</code>. 不要になるカラム
     */
    public String getRemoveColumn() {
        return (String) get(1);
    }


    /**
     * Setter for <code>test_table.use_column</code>. 必要なカラム
     */
    public void setUseColumn(String value) {
        set(2, value);
    }

    /**
     * Getter for <code>test_table.use_column</code>. 必要なカラム
     */
    public String getUseColumn() {
        return (String) get(2);
    }

Record クラスのカラムの順番を、カラム削除後のテーブルを想定して人間で調整すると事故が発生する危険性があるので、pom.xml に記述されている JOOQ の定義に、excludes の機能を追加します。

pom.xml
<!-- Generator parameters -->
<generator>
  <database>
    <name>org.jooq.meta.postgres.PostgresDatabase</name>
    <includes>.*</includes>
    <excludes>test_table.remove_column</excludes>
    <includeExcludeColumns>true</includeExcludeColumns>
  </database>
</generator>

これで、mvn jooq-codegen:generate -f ./my-jooq-project を実行したら、Record クラスを含めて自動生成するファイルからきれいに該当カラムが消えます。

pom.xml の修正と jooq-codegen の実行

ローカル DB など、影響範囲が 0 の DB にてマイグレーションを実行するなどして、実際にカラムをドロップします。
その後、pom.xml に記述した、excludes を削除して、jooq-codegen を実行します。
jooq-codegen にて生成されたリソースが前回と変わっていないことが確認します。

Flyway の実行

ドロップするカラムへの参照が削除されたプログラムのデプロイを完了後、問題なくアプリケーションが稼働していることを確認したら、カラムをドロップします。
マイグレーションの動作は、アプリケーションの動作の有無に関わらず任意のタイミングで行うことができるよう、別のプロジェクトを作成します。(別のアプリケーションにすると、今後は関連性が低すぎてリソースを見失う可能性があるため。)

MigrationApplication.kt
fun main(args: Array<String>) {
    val stopWatch = StopWatch()
    stopWatch.start()
    val context = SpringApplication(MigrationApplication::class.java)
    context.webApplicationType = WebApplicationType.NONE

    try {
        context.run(*args)
    } catch (e: Throwable) {
        println("マイグレーション時に例外が発生しました。")
        // Slack などにて異常を通知
        stopWatch.stop()
        throw e
    }
    stopWatch.stop()

上記のメインクラスを持つプロジェクトの、デフォルトの flyway のマイグレーションの配置場所である,
resources/db/migration 配下に、V1_1__drop_remove_column.sql を配置して、メインクラスをビルドし、マイグレーションを実行します。

メモ

テーブルのカラムドロップ時にテーブルのロックが入るため、ほんの一瞬本番影響が出てきます。これはカラム追加時も同様です。通常のカラムの追加・削除であればロックはほんの一瞬なので、影響については無視することができるものと判断しています。
しかしながら、外部キー制約が入っているカラムをドロップしようとするとき、DB 内で内部検証が発生する場合があるようで、かつて、マイグレーション中にカラムドロップのための長時間のテーブルロックが入ってしまったときあります。
こういう場合は、おそらく、外部キーをはずすなどの事前準備が必要になるものと思われます。
(われわれは、インパクトが大きすぎたのでその時マイグレーションを断念してしまいました。)
マイグレーション前は、本番相当、あるいは相当とまではいかずとも大容量のデータベースにて必ずテストする必要があります。