🙌

AWS SDK for Java v2移行をClaude Codeでやってみた

に公開

はじめに

私たちが提供しているプロダクト「Cariot」ではメインのワークロードをAmazon ECS上のJavaアプリケーションで実行しており、AWSサービスとの通信にAWS SDK for Javaを使っています。
AWS SDK for Javaにはv1v2があり、v1はすでに2025年12月31日にサポートが終了しています。そのため、v2に移行する必要がありました。

この記事では主にClaude Codeを使ってAWS SDK for Javaをv1からv2に移行してみたので、共有します。
Cariotではこのような感じだった、という内容なので、移行ガイドではなく読み物として読んでいただければと思います。

環境

実施環境は以下のとおりです。
(足掛け2ヶ月ほどのスパンで実施したため変動があります。)

  • Claude Code 2.0〜2.1
    • Claude Opus 4.5
    • 追加の設定はなく、デフォルトのままです。
    • 会社経費でMax planを使っています(宣伝)
  • Java 21
  • Spring Boot 3.5

進め方

以下の流れで実施しました。

  1. 利用AWSサービスごとにフェーズを分ける
  2. ユニットテスト追加
  3. 移行させてみる
  4. 個別の修正
  5. 動作確認
  6. コードリーディング(レビュー)

(大枠の流れは下記と同じです)
https://zenn.dev/cariot_dev/articles/926ff799eadeb1

1. 利用AWSサービスごとにフェーズを分ける

AWS SDK for Javaはサービスやインポートごとに段階的に移行できるため、まずはサービス単位で移行することにしました。
やり方に慣れるために簡単にできるものから移行することにして、Claudeに下記の指示でプランを作ってもらいました。

このリポジトリで使っているAWS SDK for Javaを1から2に移行したいです。サービス単位での利用状況を調査し、移行計画を立ててください

結果、Cariotの場合は以下のような計画が得られました。

⏺ 調査と移行計画が完了しました。

要約

- 現状: AWS SDK v1 (1.12.770) が95%、v2 (2.31.68) が5%(Athena/STS/HTTP署名のみ)

移行の優先順位:

- Phase 1 (低リスク): SSM → CloudWatch
- Phase 2 (中リスク): SQS → SNS
- Phase 3 (中~大): S3(TransferManager含む)
- Phase 4 (中): Kinesis(KPL依存に注意)
- Phase 5 (最大): DynamoDB(25+リポジトリ、アノテーション全面変更)

最大の課題: DynamoDBの移行

25以上のテーブルクラスでアノテーション変更が必要
@DynamoDBTable → @DynamoDbBean、Getter/Setter必須化
AbstractDynamoRepository の設計見直し
特定のPhaseから着手したい、または詳細な実装手順が必要な場合はお知らせください。

実際この手順で良さそうと判断したため、そのまま実施しています。(AbstractDynamoRepositoryは自前のラッパークラスです)
各サービスでの移行内容については、後ほど少し触れます。

2. ユニットテスト追加

移行前後で動作が変わっていないかを確認するために、ユニットテストを追加しています。
特に古いコードにはユニットテストがないことが多かったため、デグレ防止の「蓋をする」イメージでテストを追加しました。

基本的にはテストを追加したいクラスを指定してユニットテストを追加してというだけで、正常値・異常値・境界値をカバーしたテストを作ってくれます。
しかし、以下の点は状況によって要件が変わってくるので個別に指示したりしています。

  • 実DBを使ってほしいシーンでモックを使っていないか
  • 既存のユーティリティクラスを再作成していないか

3. 移行させてみる

SDKを移行します。
はじめに計画を立ててもらっているので、それに従って移行してくださいと指示してフェーズごとに移行しました。
計画通り、前半のフェーズはこれだけで完了するくらいシンプルでした。
後半は機械的に移行しづらかったり、構成が変わったりするものもあったため、都度手を入れる必要がありました。

4. 個別の修正

後述しますが、単純な移行では完了しないものもしばしばあったため、サービスごとに個別に修正しています。
また、機能側のコードは移行できていても、テストコードが移行できていないことがあり、追加指示で修正しました。
ついでに、使っていないコードの削除も行っています。

5. 動作確認

サービス単位で通しの動作確認を行いました。
ここで、サービス・インポート単位に移行できる点が活きており、動作確認観点のスコープを狭く保つことができました。

6. コードリーディング(レビュー)

プルリク単位でコードをレビューします。一通り読んで「全くわからない」点が無い状態にすることを目標にしています。

個人的には、AI生成したコードはまだ人が内容を把握しておかないと保守できない状態に感じています。初回生成時はいいのですが、その次に変更したくなったとき、どう変えていくかの目処が立てづらくなることがあったためです。
GitHub Copilotもレビュアーとして使用しているのですが、なぜか質がブレることがあり、これだけでは完結しない印象です。(よく使われているCodexなどを使えばもっと変わるのかもしれません)

進め方としては、GitHubのプルリクエストのviewedを使って1ファイルずつ見ていく感じです。
ここで疑問点があれば、Claudeに聞いたり、ドキュメントを当たったりしながら進めました。
また、意外とAIもタイポするので、気づいたりIDEが指摘した箇所は都度修正します。

分量はPhase 5: DynamoDBがプラン通り多く、機械的に見ていける部分もありながら大変でした。

DynamoDB移行のプルリクエスト

ここで問題や抜け漏れに気づいた場合、2. ユニットテスト追加などに戻って再度実施します。

個別のサービスごとの移行

以下では、個別の移行がどのような感じだったか紹介します。

Phase 1: SSM, CloudWatch

規模は以下くらいで、シンプルな移行でした。


Phase 1: SSM, CloudWatch

SSM

いきなりイレギュラーですが、SSMはSDKを移行せずに削除しています。
用途として、Parameter Storeから取得したSecure StringをConfigに設定していたのですが、ecspressosecretsで環境変数からプロパティに設定したほうがシンプルになったためです。

CloudWatch

CloudWatchはカスタムメトリクス記録のために使用しています。
以下のような感じで、1対1で移行できています。

- import com.amazonaws.services.cloudwatch.AmazonCloudWatch;
- import com.amazonaws.services.cloudwatch.model.Dimension;
+ import software.amazon.awssdk.services.cloudwatch.CloudWatchClient;
+ import software.amazon.awssdk.services.cloudwatch.model.Dimension;
- private AmazonCloudWatch cloudWatch;
+ private CloudWatchClient cloudWatchClient;
- Dimension dimension = new Dimension()
-     .withName("TaskType")
-     .withValue(getTaskNameForCWMetrics());
+ Dimension dimension = Dimension.builder()
+     .name("TaskType")
+     .value(getTaskNameForCWMetrics())
+     .build();

getTaskNameForCWMetrics()は自前のメソッドです。

Phase 2: SQS, SNS

こちらもPhase1と同じく、シンプルな移行でした。

Phase 2: SQS, SNS

(diffが多いですが、ほぼユニットテストの追加です)

SQS

SQSはシンプルなメッセージ送信に使っています。
これもCloudWatchと同じような1対1移行だけで済んでいます。

- import com.amazonaws.services.sqs.AmazonSQS;
- import com.amazonaws.services.sqs.model.SendMessageRequest;
+ import software.amazon.awssdk.services.sqs.SqsClient;
+ import software.amazon.awssdk.services.sqs.model.SendMessageRequest;
- private AmazonSQS amazonSQS;
+ private final SqsClient sqsClient;
- amazonSQS.sendMessage(new SendMessageRequest()
-     .withQueueUrl(queueUrl)
-     .withMessageBody(messageBody));
+ sqsClient.sendMessage(SendMessageRequest.builder()
+    .queueUrl(queueUrl)
+    .messageBody(messageBody)
+    .build());

SNS

SQSと同じく、シンプルなメッセージ送信に使っています。
移行もSQSと同じように1対1移行です。

- import com.amazonaws.services.sns.AmazonSNS;
- import com.amazonaws.services.sns.model.PublishRequest;
+ import software.amazon.awssdk.services.sns.SnsClient;
+ import software.amazon.awssdk.services.sns.model.PublishRequest;
- private final AmazonSNS sns;
+ private final SnsClient snsClient;
- PublishRequest request = new PublishRequest()
-     .withTargetArn(targetArn)
-     .withMessage(messageJson);
- sns.publish(request);
+ PublishRequest request = PublishRequest.builder()
+     .targetArn(targetArn)
+     .message(messageJson)
+     .build();
+ snsClient.publish(request);

Phase 3: S3

S3の移行はPhase1,2と異なり、いくつか手を入れる必要がありました。

Phase 3: S3

1対1移行で完了した部分

基本的なAPI部分は1対1で移行できる印象でした。

- import com.amazonaws.services.s3.AmazonS3;
- import com.amazonaws.services.s3.iterable.S3Objects;
+ import software.amazon.awssdk.services.s3.S3Client;
+ import software.amazon.awssdk.services.s3.model.ListObjectsV2Request;
- private final AmazonS3 s3;
+ private final S3Client s3Client;
- S3Objects.inBucket(s3, bucketName).spliterator();
+ ListObjectsV2Request listRequest = ListObjectsV2Request.builder()
+    .bucket(bucketName)
+    .build();
+ s3Client.listObjectsV2Paginator(listRequest).contents();

S3Linkの移行

DynamoDBと絡んでいるのですが、CariotではDynamoDBのitemにS3のオブジェクト情報を格納するのにS3Linkを使っていました。
これはv2ではS3とDynamoDBの密結合を理由にサポートされなくなっているため、何らかの対応を取る必要があります。
幸い、DynamoDBに保存されるフォーマット自体は単純なため、自前で同じフォーマットで保存する仕組みを作りました。

単体のS3LinkはDynamoDB上では以下のように保存されていました。

   {
    "S": "{\"s3\":{\"bucket\":\"bucket-name\",\"key\":\"key/of/object\",\"region\":\"ap-northeast-1\"}}"
   }

そのため、これに対応するクラスを作って、保存・取得時にシリアライズするようにしました。

public class S3KeyInfo {

    /**
     * S3バケット名
     */
    private String bucket;

    /**
     * S3オブジェクトキー
     */
    private String key;

    /**
     * AWSリージョン (例: "ap-northeast-1")
     */
    private String region;
    // (略)
}

署名付きURL

v1ではクライアントで生成していた署名付きURLが、v2ではS3Presignerという個別のクラスを使うようになりました。

- import com.amazonaws.services.s3.AmazonS3;
+ import software.amazon.awssdk.services.s3.S3Client;
+ import software.amazon.awssdk.services.s3.presigner.S3Presigner;
+ import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;
+ import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest;
- private final AmazonS3 s3;
+ private final S3Client s3Client;
+ private final S3Presigner s3Presigner;
- URL url = s3.generatePresignedUrl(bucketName, bucketKey, status.getExpiredDate());
+ GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder()
+     .signatureDuration(status.signatureDuration())
+     .getObjectRequest(GetObjectRequest.builder()
+         .bucket(bucketName)
+         .key(bucketKey)
+         .build())
+     .build();
+ PresignedGetObjectRequest presignedRequest = s3Presigner.presignGetObject(presignRequest);
+ URL url = presignedRequest.url();

Phase 4: Kinesis

利用サービスはKinesis Data StreamsとData Firehoseです。
基本的に1対1移行だけで完了しました。
ClaudeのプランニングでKPL依存に注意と書かれていますが、こちらは事前にv2に対応したKPL 1.xに移行していたため、特に修正は不要でした。


Phase 4: Kinesis

※追加が多いのはユニットテストです。

1対1移行で完了した部分

クラス名などもほぼ同じだったため、変更は少なかったです。

- import com.amazonaws.services.kinesis.AmazonKinesis;
- import com.amazonaws.services.kinesis.model.PutRecordsRequest;
- import com.amazonaws.services.kinesis.model.PutRecordsRequestEntry;
- import com.amazonaws.services.kinesis.model.PutRecordsResult;
+ import software.amazon.awssdk.services.kinesis.KinesisClient;
+ import software.amazon.awssdk.services.kinesis.model.PutRecordsRequest;
+ import software.amazon.awssdk.services.kinesis.model.PutRecordsRequestEntry;
+ import software.amazon.awssdk.services.kinesis.model.PutRecordsResponse;
- private AmazonKinesis client;
+ private KinesisClient client;
private void putRecords(String streamName, List<PutRecordsRequestEntry> reqEntries) {
-    PutRecordsResult result = client.putRecords(new PutRecordsRequest()
-        .withStreamName(streamName)
-        .withRecords(reqEntries)
-    );
+    PutRecordsRequest request = PutRecordsRequest.builder()
+        .streamName(streamName)
+        .records(reqEntries)
+        .build();
+    PutRecordsResponse result = client.putRecords(request);
// (略)

Phase 5: DynamoDB

もっとも差分が多かったのがDynamoDBです。
CariotではDynamoDBの64テーブルを今回移行したJavaアプリケーションのリポジトリから使っており、その数がそのまま差分の量に反映されました。


Phase 5: DynamoDB

機械的に移行できる部分

DynamoDBの移行は変更が大きく見えますが、テーブル定義のアノテーションについてや、クライアントからテーブルへのアクセスについてはかなり機械的に移行できます。
参考: SDK for Java のバージョン 1 とバージョン 2 での DynamoDB マッピング API の変更

例えばv1で下記のようにクラスとテーブルをマッピングしていたとします。

  // Before (v1): フィールドにアノテーション
  @DynamoDBTable(tableName = "cariot-route")
  public class Route {

      @DynamoDBHashKey
      @DynamoDBIndexHashKey(globalSecondaryIndexName = "Index")
      private String path;

      @DynamoDBRangeKey(attributeName = "reqTime")
      private long requestTime;

      @DynamoDBAttribute(attributeName = "finishedReqTime")
      @DynamoDBIndexRangeKey(globalSecondaryIndexName = "Index")
      private Long finishedRequestTime;

      @DynamoDBAttribute
      private String id;

      public String getId() { return id; }
  }

この場合、以下のように置き換わります。
各アノテーションは役割はあまり変わっていないものの、アノテーション名は刷新されています。
また、アトリビュートのアノテーションはフィールドからgetterに設定するように変わっています。
クライアントからテーブルアクセスする際にテーブル名が必要なため、Cariotではクラス内で定数として定義するようにしました。

  // After (v2): getterにアノテーション + TABLE_NAME定数
  @DynamoDbBean
  public class Route {

      public static final String TABLE_NAME = "cariot-route";

      private String path;
      private long requestTime;
      private Long finishedRequestTime;
      private String id;
      ...

      @DynamoDbPartitionKey
      @DynamoDbSecondaryPartitionKey(indexNames = {"Index"})
      @DynamoDbAttribute("path")
      public String getPath() { return path; }

      @DynamoDbSortKey
      @DynamoDbAttribute("reqTime")
      public long getRequestTime() { return requestTime; }

      @DynamoDbSecondarySortKey(indexNames = {"Index"})
      @DynamoDbAttribute("finishedReqTime")
      public Long getFinishedRequestTime() { return finishedRequestTime; }

      @DynamoDbAttribute("id")
      public String getId() { return id; }
      ...
  }

クライアント作成自体はほぼ変更はありません。(ドキュメントから引用)

// v1
AmazonDynamoDB standardClient = AmazonDynamoDBClientBuilder.standard()
    .withCredentials(credentialsProvider)
    .withRegion(Regions.US_EAST_1)
    .build();
DynamoDBMapper mapper = new DynamoDBMapper(standardClient);
//v2
DynamoDbClient standardClient = DynamoDbClient.builder()
    .credentialsProvider(ProfileCredentialsProvider.create())
    .region(Region.US_EAST_1)
    .build();
DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder()
    .dynamoDbClient(standardClient)
    .build();

クライアントでのテーブル操作はざっくり言うと、v1では汎用的なマッパーを使っていたのに対して、v2ではテーブル単位のインスタンスを作成して使うようになっています。
(ドキュメントから引用)

// v1
mapper.save(item)

// v2
DynamoDbTable<Customer> table = enhancedClient.table("Customer",
    TableSchema.fromBean(Customer.class));
table.putItem(item)

Boolean型で起きた問題

v2のEnhanced ClientはJava Beanの命名規則に従うメソッドをDynamoDBのアトリビュートとして解釈します。
その際、boolean型のフィールドに対してisXXXgetXXXが存在する場合、isXXXがgetterとして解釈されます。(JavaBean 仕様セクション8.3.2)
そのため、v1時isXXXをビジネスロジックとして定義し、DynamoDBIgnoreでアトリビュートとして解釈しない定義にしていたクラスで、v2移行後にアトリビュートが検出できない問題が発生しました。
例えば、以下のようなクラスです。

@DynamoDbBean
public class Log {

  private Boolean skipped;

  // 手書きのgetter(DynamoDB属性用)
  @DynamoDbAttribute("skipped")
  public Boolean getSkipped() { return skipped; }

  // 手書きのビジネスロジック
  @DynamoDbIgnore
  public boolean isSkipped() { return this.skipped != null && this.skipped; }
}

この場合、Enhanced ClientはisSkippedをboolean型の正式なgetterとして優先するため、getSkippedはアトリビュートのgetterとして認識されません。そしてisSkipped@DynamoDbIgnoreで除外されるため、結果的にskippedアトリビュートがマッピングされなくなります。
対応方法はBeanの命名規則に当たらないようにビジネスロジックをリネームすれば大丈夫です。

  // 手書きのビジネスロジック
  public boolean hasBeenSkipped() { return this.skipped != null && this.skipped; }

その他つまづいたこと

SDKの移行で間接的につまづいた部分がいくつかあるので紹介します。
大半が、v1にあったユーティリティメソッドが無くなったことによるものです。

com.amazonaws.util.IOUtils#の削除

v1にはcom.amazonaws.util.IOUtils#toStringというユーティリティがあるのですが、v2で該当するメソッドが無くなっています。
そのため、Spring Frameworkのユーティリティで代用しました。

// Before: AWS SDK v1のユーティリティ
import com.amazonaws.util.IOUtils;
json = IOUtils.toString(request.getInputStream());

// After: Springのユーティリティに置換
import org.springframework.util.StreamUtils;
json = StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8);

この問題に遭遇したのは、AWSのサービスに直接アクセスしていないアプリケーションからSDKを取り除いたときで、意外なところでも問題が起きるんだなという印象でした。

com.amazonaws.util.json.Jacksonの削除

v1にはcom.amazonaws.util.json.Jacksonというクラスがありますが、v2では削除されています。
内部的にはcom.fasterxml.jacksonを使っているため、直接ObjectMapperで実装しました。

// Before: AWS SDK v1のJacksonラッパー
import com.amazonaws.util.json.Jackson;
Jackson.toJsonString(list);

// After: FasterXML ObjectMapperを直接使用
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
try {
    return objectMapper.writeValueAsString(list);
} catch (JsonProcessingException e) {
    throw new RuntimeException(e);
}

commons-codecの明示的追加

commons-codecをプロジェクト内で使っていたのですが、これはv1の推移的依存として解決されていたもので、明示的に依存対象として記載がありませんでした。
このような依存関係の問題はビルド時に気づくため、依存関係に追加して解消しました。

おわりに

最後にざっくばらんな所感を言っていきたいと思います。

まず、DynamoDBの移行はテーブル単位に分割したほうがよかったかなと思っています。
量が多かったため、Claudeのコンテキスト容量を超過しがちですし、人によるレビューも大変でした。GitHub Copilotのレビューも併用していたのですが、こちらも100ファイルまでしか読めないようでした。
サービス単位で分割したのはいい案で、Phase 4まではかなり軽い修正で済んでいました。またAWS SDK for Javaの移行はインポート単位でできるので、特定テーブルのクラスと、その利用箇所だけ修正することが可能です。DynamoDBも小さい差分になるように、小さめのテーブルから始めてだんだん大きい修正にしたほうが、トータルで早く終わったかも……と思います。

その他については、Claude Codeが機械的な移行に対してうまくハマったと思います。
実は公式の移行ツールもあるのですが、OpenRewriteの追加が必要だったり、DynamoDBの一部の移行が対応していなかったりしたので、使いませんでした。
余裕があれば、試しに使ってAIと比較してみるのもよかったかもしれません。

あとは、SDKと直接関係のないリファクタリングもかなり行いました。リポジトリ全体のコードを細かく追う機会だったので、色々な部分で修正点が見つけられて良かったです。

以上です。自分が体験した部分のみの紹介ですが、今後移行を行う方の参考になれば幸いです。

株式会社キャリオット

Discussion