dev notes

Dayner 결제 데이터의 수명주기와 접근 권한을 나눈 이유

2024-08-3113 min read
공유

결제 기능과 함께 정한 데이터 관리 기준#

Dayner에서 기프트카드와 쿠폰 결제를 준비하면서 사장님 측 법률대리인과 결제 내역의 보관과 열람 조건을 함께 검토했습니다. 결제가 완료됐다는 결과만 저장하는 것으로는 부족했고, 거래가 발생한 시점과 결제 내용을 남겨 사용자가 자신의 내역을 다시 확인할 수 있어야 했습니다.

이 글을 다시 정리한 현재 기준으로도 전자상거래법 제6조는 거래기록의 보존과 소비자가 이를 열람하고 보존할 수 있는 방법을 규정하고 있습니다. 같은 법 시행령 제6조는 대금결제와 재화 공급에 관한 기록의 보존 기간을 5년으로 두고 있습니다.

보존 기간이 끝난 뒤에는 다른 기준이 적용됩니다. 개인정보 보호법 제21조는 불필요해진 개인정보의 파기를 규정하고, 다른 법령에 따라 계속 보존하는 정보는 다른 개인정보와 분리해 관리하도록 정합니다. Dayner에 적용할 범위는 법률대리인과 확인했고, 시스템에서는 보관과 파기를 같은 수명주기의 서로 다른 단계로 다뤘습니다.

결제 데이터 거버넌스를 수명주기로 정리하기#

검토 내용을 시스템에 반영하려면 결제 데이터를 누가 생성하고 조회하는지, 언제 장기 보관 대상으로 전환하는지, 보존 기간 안에 어떤 변경을 허용하는지, 기간이 끝난 데이터는 어느 경로에서 파기할지를 정해야 했습니다.

단계담당 경로데이터 위치처리 내용
결제 생성Spring 애플리케이션결제 내역 테이블결제 결과 저장
사용자 조회결제 내역 API결제 내역 테이블사용자 내역 조회
장기 보관보관 적재 작업PaymentArchive보관 대상 추가
보관 데이터 확인제한된 운영 경로PaymentArchive정산과 과거 거래 조회
보존 기간 종료정기 정산 작업PaymentArchive파기 또는 식별 정보 제거

사용자 API와 일반 관리 화면은 결제 내역 테이블을 사용하고, PaymentArchive는 제한된 운영 경로에서만 다뤘습니다. 보존 기간이 끝난 데이터를 처리하는 정산 작업도 상시 API와 분리했습니다.

사용자 조회와 장기 보관을 분리한 구조#

결제 직후의 조회는 운영 테이블에서 처리하고, 일정 기간이 지나거나 보관 대상으로 분류된 데이터는 PaymentArchive로 분리했습니다. 사용자 화면은 현재 결제 상태를 빠르게 읽는 데 초점을 맞추고, PaymentArchive는 정산이나 과거 거래 확인에 필요한 당시 정보를 유지했습니다.

PaymentArchive에는 보관에 필요한 값을 함께 저장해 과거 거래를 확인할 때 결제수단과 관련 엔티티를 다시 조합하지 않도록 했습니다. 저장 공간은 더 사용하지만 사용자 조회와 보관 조회가 같은 테이블 관계에 묶이지 않아, 두 경로의 변경 주기와 접근 권한을 따로 관리할 수 있었습니다.

PaymentArchive의 직접 접근 경로도 일반적인 Spring 애플리케이션 운영과 분리했습니다. 인프라 접근은 AWS IAM으로 제한했고, 일반 개발자와 애플리케이션 운영 계정에는 보관 데이터로 직접 들어가는 경로를 열지 않았습니다. 정산과 보관 데이터를 다루는 제한된 운영 경로에서만 접근했으며, 사용자 조회 API와 일반 관리 화면에도 PaymentArchive를 연결하지 않았습니다.

보관 테이블과 불변성의 차이#

결제 내역 테이블과 PaymentArchive를 분리하고 접근 주체를 제한하는 구조를 선택한 이유는 이후 보관 테이블의 변경 가능성을 다시 검토하면서, 이 구성만으로 기존 행의 불변성까지 확보되는 것은 아니라는 점을 확인했기 때문입니다.

애플리케이션에 수정 API가 없어도 PaymentArchive는 일반 DB 테이블입니다. 더 넓은 권한을 가진 계정에서는 다음 SQL을 실행할 수 있습니다.

sql
UPDATE payment_archive
SET amount = 1
WHERE payment_id = 'pay-1';
 
DELETE FROM payment_archive
WHERE payment_id = 'pay-1';

금액을 덮어쓰면 변경 전 값을 확인할 수 없고, 행을 삭제하면 거래가 있었다는 사실도 함께 사라집니다. 테이블 분리와 접근 통제는 일반적인 변경 경로를 줄이지만, 모든 변경 주체로부터 기존 기록을 보호하는 장치는 아니었습니다.

당시에는 결제 취소나 금액 정정 과정까지 보존하는 변경안을 검토하지 않았습니다. 만약 보존 기간 안의 일반 처리 경로에는 INSERT만 허용하고 기존 행의 UPDATEDELETE는 차단하는 append-only 규칙을 적용했다면, 보존 기간이 끝난 후 정기 정산 과정에서 payment_id를 기준으로 UPDATEDELETE가 필요할 때마다 별도 경로를 설계해야 했습니다.

text
event-1  pay-1  PAYMENT_RECORDED   amount=10000
event-2  pay-1  PAYMENT_CORRECTED  amount=9000  corrects=event-1

절대 변경 금지와 파기 절차의 충돌#

검토한 append-only 규칙을 테이블 전체에 영구 적용하면 보존 기간이 끝난 데이터를 파기할 수 없습니다. 식별 정보를 제거하는 작업도 기존 값을 바꾸는 UPDATE이므로 같은 규칙에 막힙니다. 보관 기록을 보호하려고 추가한 제약이 수명주기 마지막 단계의 처리를 차단하는 셈입니다.

변경안에서는 통제 범위를 보존 기간 안의 일반 처리 경로로 한정했습니다. 애플리케이션과 일반 운영 계정은 기존 보관 기록을 변경하지 못하게 하고, 보존 기간이 끝난 데이터는 별도의 수명주기 관리 작업에서 파기하거나 식별 정보를 제거할 수 있게 구분했습니다. 정정은 새 기록으로 남기고, 파기는 정해진 시점과 권한에서만 기존 데이터에 적용하는 구조입니다.

이 변경안에서 append-only는 영구 보존을 뜻하지 않습니다. 보존 기간 안의 일반 업무 경로에서 과거 기록을 덮어쓰지 않되, 수명주기가 끝난 데이터는 제한된 작업에서 처리하는 정책입니다.

SQLite trigger를 운영 정책으로 사용하지 않은 이유#

기존 행의 UPDATEDELETE를 DB에서 차단할 수 있는지는 SQLite trigger로 재현했습니다. 운영 DBMS의 성능이나 권한 구성을 검증하려는 테스트가 아니라, 두 SQL을 테이블 수준에서 모두 거절하는 동작만 격리해 확인하기 위해 SQLite를 사용했습니다.

sql
CREATE TRIGGER payment_archive_no_update
BEFORE UPDATE ON payment_archive
BEGIN
    SELECT RAISE(ABORT, 'payment_archive is append-only');
END;
 
CREATE TRIGGER payment_archive_no_delete
BEFORE DELETE ON payment_archive
BEGIN
    SELECT RAISE(ABORT, 'payment_archive is append-only');
END;

Python 3.9.6과 SQLite 3.43.2에서 pay-1, 10000을 저장한 뒤 금액 변경과 행 삭제를 실행했습니다. 두 쿼리는 모두 거절됐고 최초 행은 그대로 남았습니다.

text
update: rejected (payment_archive is append-only)
delete: rejected (payment_archive is append-only)
remaining=[('pay-1', 10000)]

이 trigger는 직접 변경을 차단했지만 파기와 식별 정보 제거도 함께 막았기 때문에 Dayner의 운영 정책으로 사용하지 않았습니다. 실제 경계는 테이블 전체에 영구적인 변경 금지를 거는 방식이 아니라, 일반 처리 경로와 수명주기 관리 경로의 접근을 분리하는 데 두었습니다.

trigger 문법도 DBMS마다 다릅니다. SQLite의 RAISE(ABORT, ...)를 다른 DBMS에 그대로 사용할 수 없고, 예를 들어 MySQL은 trigger 안에서 오류를 발생시킬 때 SIGNAL SQLSTATE를 사용합니다. 이 재현 결과는 SQLite의 동작 범위로만 해석했습니다.

DB 소유자의 변경까지 추적해야 한다면 접근 권한과 trigger만으로는 부족합니다. 소유자는 trigger를 제거하거나 backup을 복원할 수 있으므로, 이 범위에서는 외부 감사 로그나 별도의 변경 검증 저장소가 필요합니다.

마무리하며#

처음에는 결제 데이터를 오래 남기는 문제로 시작했습니다. 사장님 측과 보관과 열람 조건을 검토하면서 결제 데이터의 생성, 사용자 조회, 장기 보관, 파기 단계를 나누게 됐고, 각 단계의 접근 주체와 허용할 작업도 함께 정했습니다.

PaymentArchive는 조회와 접근 경계를 분리했지만 기존 기록의 불변성까지 자동으로 만들지는 않았습니다. 반대로 변경을 전면 차단하면 보존 기간 이후의 파기까지 막혔습니다. Dayner에서는 일반 애플리케이션과 PaymentArchive의 접근 경로를 나누고, 보존 기간이 끝난 데이터는 정기 정산 과정에서 처리했습니다. 여기에 append-only를 확장한다면 변경 금지를 테이블 전체에 영구 적용하지 않고, 보존 기간 안의 일반 업무 경로에 한정해야 한다는 결론을 얻었습니다.

이후에는 보관 테이블을 설계할 때 컬럼과 보존 기간만 정하지 않고, 데이터가 생성된 뒤 파기되기까지 누가 읽고 추가하며 변경할 수 있는지를 단계별로 함께 확인하고 있습니다.

Connected Notes