班長ノートのバックエンドを開発する中で、さまざまなドメインに携わりましたが、個人情報の監査ログを扱う際にはかなり悩みました。
初期は求人・求職などの主要なビジネス機能の開発に追われていましたが、サービスの機能開発が終盤に差しかかると、これまで先送りにしていた個人情報保護法に準拠した監査ログの仕組みをきちんと整備すべき時期になりました。特に班長ノートは、求人・求職の過程で必要となる膨大な個人情報がやり取りされるプラットフォームであるため、ログファイルに数行残すだけで済ませるのは難しい状況でした。
いざ作業を始めようとすると、遵守すべき法的基準は厳しく、300を超えるAPIの間で、人が一つひとつ覚えながらログを仕込む方法では、その後システムの開発が続くにつれて、記録漏れが発生するのは明らかでした。そこで、監査ログの記録漏れを防ぎ、大量のトラフィックでもAPIの応答速度を維持できるように、パイプラインを構築した過程をまとめてみます。
法令の条文をコードの仕様に落とし込むところから
個人情報保護法第29条には、システムへのアクセス記録を保存し、改ざんを防止するための安全管理措置義務が定められています。さらに詳細な基準は、「個人情報の安全性確保措置基準」告示第8条で確認できます。
法令が求める個人情報のアクセス記録として残すべき内容を整理すると、次のとおりです。
まず、アクセス記録に必ず含めなければならない5つの必須項目があります。
-
誰がアクセスしたかを特定できる識別子(IDやアカウント)
-
いつアクセスしたかを示す日時
-
どこからアクセスしたかを確認できる接続元IPアドレス
-
閲覧、修正、削除、ダウンロードなど、システム上で実際にどのような操作を行ったかを示す業務内容
-
そして、誰の情報を照会したかを示す情報主体識別子
これに加えて、いくつかの条件があります。
-
5万人以上の情報または固有識別情報を取り扱う場合は、最低2年以上保存しなければなりません。
-
一度保存した記録は、誰も恣意的に手を加えられないようにしなければなりません。
-
Excelやファイル形式で個人情報をダウンロードするときは、ダウンロードの理由を記録しなければなりません。
-
大量照会や、通常とは異なる不審なアクセスの流れがないか、定期的に監視できなければなりません。
要件自体は明確でしたが、これをコードにどう組み込むか考えてみると、いくつかの課題が見えてきました。
実際に開発して直面した課題
Spring環境で監査ログと聞くと、通常はインターセプターやAOPをまず思い浮かべます。設計初期には、リクエストの入口でインターセプターを使って処理を捕捉し、記録する方法を考えましたが、実際に設計してみると、そう単純ではありませんでした。
1. エンドポイントが数百もあるのに、人がすべて覚えていられるだろうか?
班長ノートには、班長用、チーム長用、管理者用など、役割ごとに分かれたAPIが300個以上ありました。開発者が新しいAPIを作るたびに、「これは個人情報を扱うから、ログを残さないと」と思い出すのは現実的に困難です。開発に追われていると漏れが起きやすく、コードレビューだけで見つけるにも明らかな限界がありました。また逆に、既存のAPIを削除したりエンドポイントを変更したりした際に、以前登録した設定がそのまま放置される問題もありました。
2. インターセプターでは「誰の情報」を見たのか分からない
インターセプターは、クライアントから送られたURLやリクエストヘッダー、ログインユーザーのトークンまでは簡単に取得できます。しかし、法律が求める「情報主体識別子」、つまり「この人は誰の個人情報を閲覧したのか?」を知る方法はありません。たとえば、特定の現場の班長名簿を照会するAPIの場合、照会結果に含まれる実際の作業員10人の会員IDは、サービスロジックがデータベースを検索して初めて確定します。Web層のインターセプターとビジネスロジック層の間で、データが途切れてしまうのです。
3. 照会のたびにDBへINSERTして、処理しきれるだろうか?
建設現場の特性上、早朝の出勤時間帯に照会が集中すると予想されました。個人情報を含む一覧を取得するたびに、監査ログテーブルへ同期的にINSERTクエリを送ると、DBコネクションプールにすぐに負荷がかかりそうでした。監査ログを残そうとした結果、肝心のビジネスAPIの速度が低下する事態も起こり得ます。また、監査ログの書き込み中に一時的なDBロックや遅延が発生したからといって、ユーザーが求職情報を照会したり、工数を確認したりする本来の処理まで一緒にロールバックされることは避けなければなりません。
4. ログ自体による二次漏えい
監査ログを漏れなく残そうとして、レスポンスオブジェクト全体をシリアライズしてログテーブルに書き込むと、そのテーブル自体が住民登録番号や口座番号の平文が集まる危険な保存先になってしまいます。データの本文は隠し、どの種類の項目(氏名、電話番号、口座情報など)にアクセスしたかだけをメタデータとして残す必要がありました。
課題の解決
1. 監査記録の漏れを防止する — テストコードで全エンドポイントの登録を必須にする
人の記憶力に頼らないためには、登録漏れがあったときにビルド自体を失敗させるしかないと考えました。
まずレジストリークラスを用意し、システム内のすべてのエンドポイントを、個人情報の取り扱い対象か対象外かに漏れなく登録するようにしました。対象外のエンドポイントもそのままにせず、ヘルスチェック用なのか、単なる本人情報の変更なのか、理由コードも添えて登録しました。
// 대상 엔드포인트 등록
AccessLogTargetRegistry.register(
"/feature/cm/find-somebodys-detail/fetch",
true,
AccessTaskType.Read,
List.of(
PersonalDataItem.Name,
PersonalDataItem.Phone,
PersonalDataItem.BirthDate,
PersonalDataItem.Certificate
)
);
// 비대상 엔드포인트도 사유와 함께 명시
AccessLogNonTargetDeclarations.registerNonTarget(
"/actuator/health",
NonTargetReason.HealthCheck
);
そして、Springのメタデータリーダーを使い、プロジェクト内のすべての @RestController のURLパスを取得するテストを作成しました。
class AccessLogTargetRegistryArchitectureTest {
@Test
@DisplayName("모든 Resource 엔드포인트가 레지스트리에 등록되어 있어야 한다")
void allRoleResourceEndpointsMustBeRegistered() throws Exception {
Set<String> discoveredEndpoints = scanAllRoleResourceEndpoints();
// 레지스트리에 대상이든 비대상이든 등록되지 않은 엔드포인트를 찾는다
List<String> missing = discoveredEndpoints.stream()
.filter(ep -> !AccessLogTargetRegistry.contains(ep))
.toList();
// 하나라도 빠져 있으면 테스트 실패
assertTrue(missing.isEmpty(),
"AccessLogTargetRegistry에 등록되지 않은 엔드포인트가 있습니다: " + missing);
}
@Test
@DisplayName("코드에서 삭제된 엔드포인트가 레지스트리에 남아있으면 안 된다")
void registryMustNotContainStaleEndpoints() throws Exception {
Set<String> realEndpoints = scanAllRoleResourceEndpoints();
List<String> stale = AccessLogTargetRegistry.getAll().keySet().stream()
.filter(ep -> !realEndpoints.contains(ep))
.sorted()
.toList();
assertTrue(stale.isEmpty(), "실제 코드에 없는 엔드포인트가 레지스트리에 남아있습니다: " + stale);
}
}
このようにしておけば、新しいAPIを作成してレジストリーに登録しなかった場合、回帰テストやCIの過程でAPIの監査記録が漏れていることを検出できます。また、削除したAPIがレジストリーに残ったままでも、テストで検出できます。開発者が個別に気を配らなくても、エンドポイントの登録状態が常に100%最新に保たれるようにしました。
2. ThreadLocalによるビジネスコンテキストの受け渡し
インターセプターとビジネスロジックの間のデータの断絶は、ThreadLocalベースのコンテキスト(AccessLogContext)を作成して埋めました。
リクエストが届くと、インターセプターのpreHandleで、呼び出し元の情報、クライアントIP、リクエスト時刻などをAccessLogContextにあらかじめ設定します。
その後、コントローラーを経てサービスロジックに入ったら、DBから実際のデータを取得した後、コンテキストに情報主体と対象となる個人情報を記録するメソッドを呼び出します。
@Service
@Transactional
public class WorkLdrFlow {
//
public WorkCrewMemberDetailRdo findMemberDetail(FindWorkCrewMemberDetailUdo udo) {
//
// 조회 대상이 된 정보주체 식별자를 컨텍스트(AccessLogContext)에 전달
AccessLogContext.reportTargetSubject(udo.getMemberId(), AccessLogSubjectKind.Member);
// 실제 업무 로직 수행
return workCrewTask.findMemberDetail(udo.getMemberId());
}
}
ビジネスロジックにこのような1行を追加してcontextに記録できるようにし、複雑なロギング処理がサービスレイヤーに侵入しないようにしました。
そしてリクエストが終了する時点で、インターセプターのafterCompletionにおいて、これらの情報を組み合わせて最終的なログオブジェクトを作成します。
3. 非同期バッチで応答速度を維持
ビジネスを処理するAPIの応答速度を維持するため、インターセプターからDBに直接クエリを送るのではなく、メモリバッファ(AccessLogBatchAccumulator)にオブジェクトを渡して、すぐに処理を抜けるようにしました。
@Component("accessLogBatchAccumulator")
@RequiredArgsConstructor
public class AccessLogBatchAccumulator {
private final AccessLogBulkInsertStore bulkInsertStore;
private final List<AccessLogCdo> buffer = new ArrayList<>();
private final Object bufferLock = new Object();
private int batchSize = 200; // 200건 기준
private long flushIntervalMs = 1000; // 1초 주기
private volatile long lastFlushTime = System.currentTimeMillis();
public void add(AccessLogCdo cdo) {
if (cdo == null) return;
boolean shouldFlush = false;
// 버퍼에 담을 때만 빠르게 락을 잡고 빠진다
synchronized (bufferLock) {
buffer.add(cdo);
if (buffer.size() >= batchSize) {
shouldFlush = true;
}
}
// 실제 무거운 DB 작업은 락 바깥에서 수행
if (shouldFlush) {
drainAndInsert();
}
}
@Scheduled(fixedDelayString = "1000")
public void scheduledFlush() {
// 200건이 안 차더라도 1초가 지나면 남아있는 로그를 비워준다
boolean hasItems;
long elapsed;
synchronized (bufferLock) {
hasItems = !buffer.isEmpty();
elapsed = System.currentTimeMillis() - lastFlushTime;
}
if (hasItems && elapsed >= flushIntervalMs) {
drainAndInsert();
}
}
private void drainAndInsert() {
List<AccessLogCdo> toInsert;
synchronized (bufferLock) {
if (buffer.isEmpty()) return;
toInsert = new ArrayList<>(buffer);
buffer.clear();
lastFlushTime = System.currentTimeMillis();
}
// 별도 트랜잭션으로 JDBC Batch Insert 실행
bulkInsertStore.insertAllInNewTransaction(toInsert);
}
@PreDestroy
public void onShutdown() {
// 서버가 내려갈 때 버퍼에 남은 로그를 비우고 종료
drainAndInsert();
}
}
また、メモリバッファではJPAのsaveAllではなくネイティブのbatchUpdateを使用し、200件ずつまとめてinsertすることで、DBとnetworkへの負荷を最小限に抑えました。
同時に、バッファに格納する瞬間だけ短時間ロックをかけ、重いDB I/Oはロックの外で実行するように分けて、スレッド同士が待ち合って停止するボトルネックを防ぎました。サーバーの再デプロイ時や停止時に、メモリ上で待機しているログが失われないよう、@PreDestroyを使って残りのデータをすべて保存してから終了できるようにしました。
個人情報の監査ログはサービスのブラックボックス
開発をしていると、セキュリティや法的規制に関する作業は後回しになりがちです。すぐに目に見える機能でもなく、気を配るべきルールも多いため、正直なところ面倒で厄介な作業に感じることも少なくありません。
しかし、私たちが扱うデータは、データベースに入っている単なるテキストではありません。インターネットの世界で誰かを表す姿であり、現実世界を生きる誰かを特定するための札です。こうした情報がずさんに管理され、外部に漏れれば、その被害を受けるのはほかでもないユーザーです。ユーザーが私たちのサービスを信頼し、個人情報を預けてくれている以上、その信頼にシステムで応えることが開発者の義務だと感じました。
そして監査ログは、ユーザーだけでなく、私たちのサービスや開発者自身を守る仕組みでもあります。外部から不審なアクセスがあったり、情報漏えいの疑いが生じたりしたときに、誰が、いつ、どこで、どのデータに触れたのかを明確に示す記録がなければ、開発者は何も防御できません。アクセス記録を丁寧に残してこそ、「私たちのシステムは正規の手順を守っており、問題となったアカウントのアクセス範囲はここまででした」と明確に説明できるはずです。
コンプライアンスを守り、安全装置を整えることは、サービス開発の速度を落とすためではなく、後にさらに速いペースで進むときに事故が起きないよう備えることに近いと考えています。頼れるブラックボックスを作っておけば、成長を続けるサービスの機能を安心して開発できるようになるでしょう。
informalife