포스트

[업무일지] 무중단 컬럼 암호화를 위한 이중쓰기 전환

이미 운영 중인 테이블의 평문 컬럼을 암호화해야 했습니다. 읽고 쓰는 서비스가 여럿이라 한 번에 못 바꿉니다. 그래서 평문 옆에 암호문 컬럼을 하나 더 두고, 저장할 때 양쪽에 같이 쓰는 이중쓰기로 1단계를 잡았습니다. 정작 발목을 잡은 건 암호화가 아니라, 컬럼을 추가하는 DDL 과 CI 환경의 스키마 설정이었습니다.

민감정보 컬럼을 DB 에 평문으로 두고 있었습니다. 계좌번호, 휴대폰번호, 이메일 같은 것들이요. 이걸 암호화해서 저장하도록 바꾸는 작업입니다. 문제는 이 컬럼들이 이미 운영 중이라는 점입니다. 여러 서비스가 같은 테이블을 읽고 쓰고 있어서, 컬럼 하나를 그 자리에서 암호화된 값으로 바꿔치기할 수가 없습니다. 읽는 쪽이 아직 평문을 기대하고 있는데 값이 암호문으로 바뀌면 그 순간 다 깨지거든요. Java 21, Spring Boot 3.3.x, JPA(Hibernate 6), MariaDB, 마이그레이션은 Flyway 를 쓰는 환경입니다.

설명을 위해 payout_accounts 테이블의 account_number(계좌번호) 컬럼 하나로 예를 듭니다. 실제로는 여러 테이블에 흩어진 수십 개 컬럼입니다.

컬럼을 그 자리에서 못 바꾼다

운영 중인 컬럼을 암호화하는 건 값 하나 바꾸는 일이 아닙니다. 지금 account_number 를 읽는 코드는 전부 평문을 기대합니다. 이 컬럼의 값을 암호문으로 덮으면, 그 코드들이 암호문을 계좌번호로 알고 쓰게 됩니다. 그렇다고 읽는 코드를 먼저 다 고칠 수도 없습니다. 컬럼은 아직 평문이니까요. 어느 쪽을 먼저 바꿔도 그 사이가 깨지는 겁니다.

그래서 한 번에 바꾸지 않고 단계로 쪼갭니다. 새 컬럼을 옆에 추가하고, 한동안 평문과 암호문을 둘 다 쓰고, 기존 행을 채우고, 읽는 쪽을 암호문으로 옮기고, 마지막에 평문 컬럼을 버립니다. 흔히 expand-migrate-contract 라고 부르는 그 순서입니다. 이번에 한 건 그중 첫 단계, 평문 옆에 암호문 컬럼을 세우고 둘 다 쓰게 만드는 데까지입니다.

평문 옆에 암호문 컬럼을 두고 둘 다 쓴다

먼저 account_number_enc 컬럼을 추가합니다. 전환 기간이라 NULL 을 허용합니다. 아직 이 컬럼이 빈 행이 많을 테니까요.

엔티티에는 평문 필드 옆에 암호문 필드를 하나 둡니다. 암호문 필드에는 JPA AttributeConverter 를 붙여서, DB 로 나갈 때 암호화하고 읽어올 때 복호화하게 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Java 21, JPA(Hibernate 6)
@Converter
@RequiredArgsConstructor
public class CryptoConverter implements AttributeConverter<String, String> {

    private final CryptoUtil cryptoUtil;

    @Override
    public String convertToDatabaseColumn(String plain) {
        return cryptoUtil.encrypt(plain); // 저장 시 암호화
    }

    @Override
    public String convertToEntityAttribute(String encrypted) {
        return cryptoUtil.decrypt(encrypted); // 조회 시 복호화
    }
}

컨버터는 값을 암호화해줄 뿐, 평문 필드의 값을 암호문 필드로 옮겨주지는 않습니다. 그 복사는 누가 해야 합니다. 저장할 때마다 accountNumberEnc = accountNumber 를 손으로 써주면 되는데, 컬럼이 수십 개라 그걸 엔티티마다 세터에 박아 넣고 싶지 않았습니다. 빠뜨리면 그 컬럼만 조용히 안 채워지고요.

리스너가 평문을 암호문 필드로 미러링한다

그래서 애노테이션과 리스너로 자동화했습니다. 평문 필드에 @EncryptSync 로 짝이 될 암호문 필드명을 적고, @PrePersist/@PreUpdate 시점에 리스너가 그 값을 복사합니다.

1
2
3
4
5
6
7
@EncryptSync("accountNumberEnc")
@Column(name = "account_number")
private String accountNumber;

@Convert(converter = CryptoConverter.class)
@Column(name = "account_number_enc")
private String accountNumberEnc;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Slf4j
public class EncryptedFieldSyncListener {

    @PrePersist
    @PreUpdate
    public void syncEncryptedFields(Object entity) {
        // @EncryptSync 가 달린 필드를 찾아 값을 짝 필드로 복사 (리플렉션)
        for (FieldMapping m : resolveMappings(entity.getClass())) {
            Object plain = m.source().get(entity);
            m.target().set(entity, plain);
        }
    }
    // resolveMappings 는 클래스별로 한 번 스캔해 캐시한다
}

저장 직전에 리스너가 평문을 암호문 필드에 복사하고, 컨버터가 그 필드를 DB 로 내보내면서 암호화합니다. 엔티티에 @EntityListeners(EncryptedFieldSyncListener.class) 를 달고 평문 필드에 애노테이션 한 줄만 붙이면 끝이라, 컬럼이 몇 개든 같은 방식으로 붙습니다. 여기까지는 순조로웠어요. 문제는 이 다음이었습니다.

정작 깨진 건 컬럼 추가 DDL 이었다

암호문 컬럼을 추가하는 Flyway 마이그레이션을 썼습니다. 평문 컬럼 바로 뒤에 붙이려고 AFTER 를 줬고, 컬럼 설명도 달았습니다.

1
2
3
4
-- MariaDB. 문법 오류로 실패한 버전
ALTER TABLE `payout_accounts`
    ADD COLUMN `account_number_enc` VARCHAR(512) NULL
        AFTER `account_number` COMMENT 'account_number 암호화 컬럼';

이게 문법 오류로 죽습니다. MariaDB 에서 컬럼 정의의 위치 지정(AFTER, FIRST)은 맨 뒤에 와야 합니다. COMMENT 뒤에 AFTER 가 오는 거지, AFTER 뒤에 COMMENT 가 오면 안 되는 거죠. 읽는 순서로는 “이 컬럼 뒤에 붙이고, 설명은 이거”가 자연스러운데 파서는 그 순서를 안 받아줍니다.

1
2
3
4
-- 순서를 바로잡은 버전
ALTER TABLE `payout_accounts`
    ADD COLUMN `account_number_enc` VARCHAR(512) NULL
        COMMENT 'account_number 암호화 컬럼' AFTER `account_number`;

한 테이블이면 금방 알아챘을 텐데, 이 마이그레이션이 수십 개 테이블을 한 파일에서 한꺼번에 건드리고 있었고, 첫 번째 문장에서 바로 문법 오류로 터지니까 그 아래 수십 줄은 실행조차 안 돼서 로그만 봐선 어디부터 잘못됐는지 한눈에 들어오지도 않았습니다. 같은 실수를 모든 문장에 똑같이 해놨던 터라, 결국 전 테이블의 절 순서를 한 번에 고쳤습니다.

CI 에서만 깨진 schema vs catalog

DDL 을 고치고 나니 이번엔 CI 테스트가 깨졌습니다. 로컬도 운영도 멀쩡한데 CI 에서만요.

운영 DB 는 Flyway 가 방금 그 SQL 로 컬럼을 만듭니다. 그런데 CI 테스트는 Testcontainers 로 띄운 MariaDB 에 ddl-auto: update 로 스키마를 잡습니다. 엔티티 매핑을 보고 Hibernate 가 직접 컬럼을 만드는 거죠. 여기서 엔티티 하나가 @Table(schema = "appdb") 로 선언돼 있었습니다. 나머지는 전부 @Table(catalog = "appdb") 였고요.

MySQL·MariaDB 에서 Hibernate 는 catalog 와 schema 를 다르게 다루는데, 이 한 엔티티만 지정이 달라서 자동 DDL 이 그 테이블을 엉뚱한 위치에서 찾다가 암호문 컬럼을 끝내 추가하지 못했고, 그 바람에 그 컬럼을 읽는 테스트만 콕 집어 CI 에서 빨갛게 떴습니다. 운영은 ddl-auto 를 안 쓰고 Flyway 로 만드니 이 차이가 안 드러났고요.

여기서 저는 근본 원인을 그 자리에서 고치지 않았습니다. schemacatalog 로 맞추는 건 그 엔티티 하나로 끝나는 문제가 아닐 수 있어서, 이번 PR 에서는 실패하던 테스트만 걷어내고 넘어갔습니다. 스키마 지정 불일치 자체는 그대로 남겨뒀습니다. 급한 건 암호화 컬럼을 올리는 거였고, 매핑 정합성은 이 작업의 범위가 아니라고 봤거든요. 대신 이건 부채로 남았습니다.

@DynamicUpdate 도 확인해야 했다

한 가지 더 걸리는 게 있었습니다. 일부 엔티티는 @DynamicUpdate 를 씁니다. 바뀐 컬럼만 골라 UPDATE 문을 만드는 옵션인데, 암호문 필드는 리스너가 @PreUpdate 시점에 값을 채웁니다. 콜백에서 채운 필드가 그 동적 UPDATE 에 잘 실리는지, 반대로 평문과 무관한 다른 필드만 바꿨을 때 암호문 컬럼이 날아가진 않는지 확신이 안 섰습니다.

그래서 그 조합을 테스트로 박아뒀습니다. 신규 저장 때 암호문이 실제로 암호화돼 들어가는지, @DynamicUpdate 엔티티에서 다른 필드만 바꿔 flush 했을 때 암호문 컬럼이 그대로 남는지를 네이티브 쿼리로 DB 값을 직접 읽어 확인했습니다. 동작은 했지만, 확신이 없어서 확인한 거지 처음부터 안 게 아닙니다.

맺음말

이번에 한 건 딱 1단계, 이중쓰기까지입니다. 새로 저장되거나 수정되는 행은 이제 암호문 컬럼이 채워집니다. 하지만 남은 게 더 많습니다. 이미 쌓여 있는 기존 행들은 암호문 컬럼이 여전히 비어 있고, 그걸 뒤늦게 채우는 백필도, 읽는 쪽을 평문에서 암호문으로 옮기는 것도, 다 옮긴 뒤 쓸모없어진 평문 컬럼을 실제로 버리는 것도 아직 하나도 손대지 못했습니다. 지금은 두 값이 같이 쓰이기만 하는 상태입니다.

남겨둔 것도 둘 있습니다. 암호문 컬럼을 전부 NULL 허용으로 열어놨습니다. 전환 기간이라 어쩔 수 없지만, 어딘가 이 컬럼이 항상 차 있다고 가정하는 코드가 생기면 그때 문제가 됩니다. 그리고 CI 를 깨뜨린 schema/catalog 불일치는 테스트만 걷어냈지 원인은 그대로입니다. 그 엔티티는 ddl-auto 로는 여전히 컬럼이 안 만들어질 거고요.

돌아보면 시간을 먹은 건 암호화가 아니었습니다. 컨버터와 리스너로 값을 암호화하는 부분은 오히려 단순했고, 정작 붙잡힌 건 컬럼을 추가하는 DDL 문법과 CI 환경의 스키마 설정이었습니다. 운영 중인 데이터를 건드리는 작업은 늘 이런 식이더라고요. 본론보다 그 주변이 더 성가십니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.