← 모든 글
Security

평문과 암호문을 두 벌로 들고 있었다

운영 중 암호화로 넘어가려고 만든 이중 쓰기 장치가 전환이 끝난 뒤에도 남아 있어서, 엔티티마다 같은 값을 두 필드로 관리하던 구조를 한 컬럼으로 합친 과정을 적었다.

계좌번호를 다루는 엔티티를 열었더니 필드가 두 개였다. 하나는 평문이고 하나는 암호문이었다. 같은 값인데 자바 코드 안에서 두 번 존재했다.

처음에는 실수인 줄 알았다. 다른 엔티티를 열어 보니 전부 그랬다. 스물한 개가 같은 모양이었다.

이건 이행 장치였다

왜 이렇게 됐는지는 금방 알 수 있었다. 서비스를 멈추지 않고 평문에서 암호문으로 넘어가려면 두 값이 한동안 함께 있어야 한다. 새로 들어오는 데이터는 암호화해서 쌓고, 과거 데이터는 배치로 옮기는 동안 읽는 쪽은 양쪽을 다 처리할 수 있어야 한다.

그 공존을 자동으로 맞춰 주는 장치가 붙어 있었다. 저장할 때 평문을 암호문 필드로 복사하고, 읽을 때 반대로 되돌리는 리스너였다. 덕분에 나머지 코드는 평문 필드만 보면 됐다.

여기까지는 좋은 설계다. 문제는 이행이 끝난 뒤에도 장치가 그대로 남았다는 것이다. 임시로 만든 것이 임시라는 표시를 잃으면 영구 구조가 된다.

자동 동기화가 닿지 않는 곳이 있었다

리스너는 엔티티를 통째로 읽고 쓸 때만 동작한다. 필요한 컬럼만 골라 오는 조회에서는 그 훅이 걸리지 않는다.

그래서 그런 조회마다 값을 손으로 풀어 주는 유틸이 따로 있었다. 암호문과 평문 중 있는 쪽을 골라 반환하는 함수였고, 조회를 새로 쓸 때마다 그걸 부르는 것을 잊으면 안 됐다.

엔티티 조회   -> 리스너가 자동 처리
컬럼 지정 조회 -> 유틸을 직접 호출해야 함 (잊으면 암호문이 그대로 나감)

잊었을 때 무엇이 나오는지가 나빴다. 예외가 아니라 암호문 문자열이 그대로 화면이나 응답에 실린다. 값이 있으니 코드는 정상으로 보인다.

같은 코드가 네 서비스에 복사돼 있었다

동기화 어노테이션, 리스너, 조회용 유틸이 서비스 네 곳에 같은 모양으로 들어 있었다. 공용 모듈로 뽑히지 않은 채 각자 복사돼 있었다.

하나를 고치면 나머지 셋에도 같은 수정을 해야 했다. 실제로 어느 한 곳만 고쳐진 흔적도 있었다. 공통 모듈로 뽑지 않은 이유를 찾아보려 했는데 기록이 없었다. 각 서비스가 다른 시기에 암호화를 붙이면서 그때그때 가져다 쓴 것으로 보였다.

지우기 전에 끝났는지부터 확인했다

이행 장치를 지우려면 이행이 끝났다는 것이 먼저 확실해야 한다. 평문 컬럼에 아직 값이 남아 있는데 지우면 그 데이터를 읽을 방법이 사라진다.

대상 테이블마다 평문 컬럼에 값이 남아 있는지, 암호문 컬럼이 전부 채워져 있는지 확인했다. 이 확인이 통과한 컬럼만 정리 대상에 넣었다.

확인이 생각보다 오래 걸렸다. 이력 테이블처럼 오래된 행이 많은 곳은 훑는 데 시간이 걸렸고, 새로 들어오는 데이터가 계속 쌓이니 한 번 세고 끝나는 일도 아니었다. 그렇다고 표본만 보고 넘어갈 수는 없었다. 하나라도 남아 있으면 그 행은 읽을 방법이 사라진다.

이 단계에서 실제로 남아 있는 값을 발견한 컬럼도 있었다. 이행 배치가 특정 조건의 행을 건너뛰고 있었던 것인데, 정리를 시작하지 않았으면 계속 몰랐을 일이다. 걷어내는 작업이 이행이 정말 끝났는지를 검사하는 역할도 했다.

필드 하나만 남기고 이름을 물려줬다

정리 방식은 단순했다. 평문 필드를 지우고, 암호문 필드가 원래 이름을 물려받게 했다. 매핑은 암호문 컬럼을 가리키고, 값의 변환은 컨버터가 맡는다.

이전
  평문 필드   -> 컬럼 phone
  암호문 필드 -> 컬럼 phone_enc  (컨버터)
  리스너가 둘을 맞춰 줌

이후
  필드 하나   -> 컬럼 phone_enc  (컨버터)

나머지 코드는 그대로 뒀다. 필드 이름이 유지되니 부르는 쪽은 바뀔 것이 없었다. 안에서 암호화가 일어나는지 아닌지를 신경 쓰지 않아도 되는 상태로 돌아간 것이다.

부분 검색이 원래 성립하지 않고 있었다

바꾸다가 걸린 것이 조회 조건이었다. 계좌번호로 사용자를 찾는 조회 몇 개가 부분 일치를 쓰고 있었다.

암호문에는 부분 일치가 성립하지 않는다. 원문의 일부가 들어 있는지를 암호문끼리 비교해서는 알 수 없다. 즉 그 조회들은 이미 평문 컬럼에 기대고 있었고, 평문이 사라지면 함께 무너진다.

전부 완전 일치로 바꿨다. 기능이 줄어든 것처럼 보이지만 실제로는 원래 되면 안 되는 조회였다. 개인정보를 조각으로 훑을 수 있다는 것 자체가 보호 수준을 낮춘다.

제약과 인덱스를 암호문 컬럼으로 옮겼다

유일 제약과 인덱스도 평문 컬럼에 걸려 있어서 함께 옮겨야 했다. 이 부분은 앞선 회사에서 겪은 것과 결과가 달랐다.

서비스를 멈추지 않고 개인정보를 전부 암호화했다에서는 같은 값이 저장할 때마다 다른 암호문이 되는 바람에 유일 제약이 통과해 버리는 문제가 있었다. 여기서는 같은 원문이 항상 같은 암호문이 되는 방식이라 제약을 암호문 컬럼에 그대로 걸 수 있었다.

매번 다른 암호문항상 같은 암호문
유일 제약무력화됨암호문 컬럼에 그대로
완전 일치 조회불가가능
부분 검색·정렬불가불가
같은 값 노출드러나지 않음암호문이 같으면 원문도 같다는 것이 드러남

편한 쪽이 항상 나은 것은 아니다. 같은 원문이 같은 암호문이 된다는 것은 암호문만 봐도 두 행의 값이 같다는 사실이 드러난다는 뜻이다. 제약과 조회를 얻는 대신 그만큼을 내준 선택이다.

지운 코드가 더 많았다

flowchart TD
  A["이행 장치"] --> B["동기화 어노테이션"]
  A --> C["엔티티 리스너"]
  A --> D["조회용 해석 유틸"]
  A --> E["관련 테스트 4종"]
  A --> F["DTO의 이중 생성자"]
  B --> G["전부 삭제"]
  C --> G
  D --> G
  E --> G
  F --> G

어노테이션과 리스너, 조회용 유틸, 그리고 그것들을 검증하던 테스트 네 개가 함께 사라졌다. 데이터 전송 객체에 반복되던 생성자 두 벌짜리 패턴도 하나로 줄었다.

테스트를 지우는 것이 마음에 걸렸는데, 그 테스트들이 검증하던 동작 자체가 없어진 것이라 남길 이유가 없었다. 사라진 동작을 지키는 테스트를 남기면 다음 사람이 그 구조가 아직 필요하다고 읽는다.

이행 장치는 걷을 조건까지 같이 적어야 한다

끝난 것은 한 서비스뿐이다. 같은 패턴이 다른 세 서비스에도 그대로 있고, 합쳐서 서른 개가 넘는 엔티티가 남았다. 여러 서비스가 같은 테이블을 보고 있어서 한쪽만 바꾸면 매핑이 어긋난다.

그래서 순서를 정하는 일이 남았다. 같은 테이블을 쓰는 서비스끼리 묶어서 한 번에 옮겨야 하고, 그 사이에 배포 시점이 벌어지면 그 틈에 어느 컬럼을 읽을지 정해 둬야 한다.

운영 중 전환을 하려면 임시 구조가 필요하고, 그건 잘못이 아니다. 다만 만들 때 함께 정해 두지 않으면 아무도 지우지 않는다. 무엇이 충족되면 이 장치를 걷을 수 있는지를 그때 같이 적어 뒀어야 했다.