전자서명 구현: 손글씨보다 문서 동결이 먼저인 이유
전자서명 서비스를 처음 보면 가장 눈에 띄는 것은 화면에 손가락이나 마우스로 사인을 그리는 부분입니다. 계약이 그 그림으로 성립하는 것처럼 보입니다.
그런데 그 그림은 마음만 먹으면 누구나 비슷하게 그립니다. 종이 서명은 필적이라는 물리적 흔적이 남지만, 캔버스에 그린 선은 좌표의 나열일 뿐입니다. 그림이 증거의 핵심이라면 전자서명은 종이보다 약한 물건이어야 합니다. 실제로는 그렇지 않습니다.
손글씨 이미지가 증명하지 못하는 것
흔히 전자서명을 "종이 서명을 화면으로 옮긴 것"으로 이해합니다. 이 그림에서는 서명 이미지가 증거의 본체이고 나머지는 부속입니다.
여기서 어긋납니다. 다툼이 생겼을 때 실제로 문제가 되는 것은 "이 사인이 저 사람 것이 맞나"가 아니라 "내가 서명한 문서가 지금 이 문서가 맞나" 입니다. 계약서 한 줄이 바뀌었는지, 금액에 0 이 하나 붙었는지가 훨씬 자주 다투어집니다.
그래서 서명 이미지는 증거의 한 조각일 뿐이고, 무게는 다른 곳에 실려 있습니다. 문서가 그때 그대로라는 것, 누가 언제 무엇을 열어서 눌렀는지가 남아 있다는 것입니다. 저희가 만든 서명 서비스도 그 순서로 짜여 있습니다. 서명을 받기 전에 하는 일이 따로 있습니다.
서명보다 먼저 하는 일, 문서 동결
계약서 원본은 문서 시스템에서 계속 고쳐집니다. 오탈자를 잡고 조항을 다듬고 금액을 바꿉니다. 그게 정상입니다.
문제는 그 문서를 그대로 서명 링크에 걸었을 때입니다. 고객이 화요일에 본 문서와 목요일에 열리는 문서가 다를 수 있고, 그러면 "고객이 서명한 것"이 무엇인지 아무도 말할 수 없게 됩니다. 증거력이 무너지는 자리가 여기입니다.
그래서 서명 요청을 만드는 순간 문서를 PDF 로 받아 바이트 그대로 저장하고 SHA-256 해시를 박습니다. 그 뒤로 원본이 아무리 바뀌어도 서명 화면은 얼린 판만 봅니다. 서명 화면은 문서 시스템에 아예 접속하지 않습니다.
부수 효과가 하나 있습니다. 문서 시스템이 꺼져 있어도 고객은 서명을 마칠 수 있습니다. 우리 쪽 사정으로 고객의 서명이 멈추는 일이 없어집니다.
같은 문서를 두 번 얼리면 같은 해시가 나옵니다. 문서를 PDF 로 그리는 과정이 결정적이라, 동일한 내용에서는 언제나 동일한 바이트가 나온다는 뜻입니다. 나중에 "이게 그때 그 문서냐"를 해시 한 줄로 대조할 수 있는 근거가 이것입니다.
링크와 접근코드를 나눠 보내는 이유
서명 링크에는 256비트 토큰이 붙습니다. 추측으로 맞힐 수 있는 크기가 아닙니다.
그런데도 링크만으로 서명이 되게 두지 않았습니다. 링크는 생각보다 자주 잘못 갑니다. 메일 주소를 잘못 적고, 단체 대화방에 붙여 넣고, 전달을 누릅니다. 토큰이 아무리 길어도 링크를 받은 사람이 엉뚱한 사람이면 소용이 없습니다.
그래서 6자리 접근코드를 다른 경로로 보냅니다. 링크는 메일로 가고 코드는 문자로 가는 식입니다. 메일 하나가 잘못 전달되는 사고와 문자까지 같이 잘못 가는 사고는 확률이 다릅니다.
6자리 숫자가 성립하는 근거는 시도 제한
숫자 여섯 자리는 백만 가지입니다. 컴퓨터에게 백만 번은 시간이 아닙니다. 이것만 놓고 보면 형편없는 비밀번호입니다.
그런데도 쓸 수 있는 이유는 두 가지가 겹쳐 있기 때문입니다. 이 코드는 토큰을 이미 가진 사람에게만 묻고, 틀린 시도를 5회로 막습니다. 백만 분의 5 는 다른 이야기가 됩니다.
이 순서를 뒤집어 생각하면 위험이 어디 있는지 분명해집니다. 시도 제한을 빼는 순간 접근코드는 없는 것과 같습니다. 코드의 강도가 자릿수에서 나오는 것이 아니라 제한에서 나오기 때문입니다. 저는 이 자리가 보안 설계에서 가장 자주 무너지는 곳이라고 봅니다. 겉보기에는 "6자리 인증"이 그대로 남아 있어서, 제한을 없앤 쪽도 무엇을 잃었는지 모릅니다.
토큰과 접근코드 모두 원문을 저장하지 않고 해시만 남깁니다. 데이터베이스가 새어도 그것만으로는 남의 서명을 대신할 수 없습니다. 같은 이유로 API 키도 원문을 저장하지 않는데, 그쪽 이야기는 API 키는 왜 다시 볼 수 없을까에 적어 두었습니다.
서명 접수와 봉인이 갈리는 자리
고객이 서명 버튼을 누르면 두 가지 일이 일어나야 합니다. 서명했다는 사실을 남기는 것과, 원본에 증명서를 붙여 최종 PDF 를 만드는 것입니다.
이 둘을 한 번에 처리하면 문제가 생깁니다. PDF 를 합치는 작업은 헤드리스 브라우저에 기대고 있어서 실패할 수 있습니다. 메모리가 모자라거나 프로세스가 죽으면 그만입니다. 그 실패가 고객의 서명까지 없던 일로 만들면 안 됩니다.
그래서 순서를 나눕니다. 먼저 서명 사실을 데이터베이스에 확정하고, 그다음에 봉인을 큐에 넘깁니다. 봉인이 실패하면 관리 화면에 "다시 봉인" 버튼이 남습니다. 서명은 일어난 사건이고 봉인본은 그 사건을 담은 산출물이라, 둘의 값어치가 다릅니다.
서명 확정은 조건부 갱신으로 처리합니다. 고객이 두 탭을 열어 두고 양쪽에서 눌러도 한 번만 성사됩니다.
원본에 도장을 찍지 않는 이유
많은 서비스가 서명 이미지를 계약서 본문 위에 겹쳐 넣습니다. 종이 계약서와 비슷해 보여서 자연스럽게 느껴집니다.
그렇게 하지 않았습니다. 본문에 무언가를 찍으려면 우리가 원본 PDF 를 열어 고쳐야 하고, 그 순간 산출물이 스스로 증명하게 됩니다. 이 문서는 서비스 제공자가 수정할 수 있다는 사실을요. 그리고 픽셀 하나만 얹어도 바이트가 달라지므로, 처음에 박아 둔 원본 해시와 최종본을 대조하는 길이 막힙니다.
대신 원본은 받은 그대로 두고 뒤에 증명서를 덧붙입니다. 증명서에는 원본 해시, 서명 시각, 접속 정보, 그동안 무슨 일이 있었는지가 들어갑니다. 원본은 원본대로 대조되고, 무슨 일이 있었는지는 뒷장이 말합니다.
해시 체인이 못 하는 것
서명 과정의 모든 사건은 순서대로 기록되고, 각 기록은 직전 기록의 해시를 품습니다. 중간의 한 줄을 지우거나 끼워 넣으면 그 뒤가 전부 어긋나므로, 검증이 어디서 끊겼는지까지 짚어 냅니다.
여기서 정직해야 하는 부분이 있습니다. 이 체인은 우리가 못 고치게 만들지 않습니다. 데이터베이스를 통째로 다시 쓸 수 있는 사람은 체인도 함께 다시 계산할 수 있습니다. 서비스 제공자가 마음먹고 전부 위조하는 경우를 막지는 못합니다.
체인의 값어치는 "우리도 못 고친다"가 아니라 "고치면 표가 난다" 입니다. 행 하나를 조용히 손보는 일이 불가능해지는 것이고, 실무에서 실제로 벌어지는 조작은 대개 그런 종류입니다. 그 이상이 필요하면 외부 시각인증기관에서 타임스탬프를 받아야 합니다. 그건 제3자가 시각을 보증하는 것이라 성질이 다릅니다.
해시 값을 넣을 때 필드 사이에 구분자를 넣습니다. 안 넣으면 ("ab", "c") 와 ("a", "bc") 가 같은 해시가 되어, 서로 다른 두 기록이 같은 값을 갖게 됩니다. 사소해 보이지만 체인 전체가 여기에 걸립니다.
여기까지가 확실한 부분
문서를 얼리는 것, 원문 대신 해시를 저장하는 것, 원본을 건드리지 않고 증명서를 덧붙이는 것은 전자서명을 다루는 곳이면 대체로 같은 모양입니다. 이유가 분명하고 대안이 마땅치 않습니다.
갈리는 것은 그다음입니다. 접근코드를 몇 자리로 할지, 시도를 몇 번까지 허용할지, 외부 타임스탬프를 받을지는 문서의 금액과 다툼 가능성에 따라 판단이 달라집니다. 소액 견적서와 부동산 계약서에 같은 장치를 쓸 이유는 없습니다.
그리고 이 글은 기술이 무엇을 하는지에 대한 것이지 법적 효력에 대한 것이 아닙니다. 전자서명법상 효력과 증거력의 한계는 조문을 짚어 가며 따로 정리할 만한 주제라, 여기서는 다루지 않았습니다.
함께 읽기
- 전자서명의 효력과 증거력: 유효한 것과 증명되는 것의 차이전자서명 도입을 검토하면 거의 항상 같은 질문이 먼저 나옵니다. "이거 법적으로 효력 있나요?"
- API 키는 왜 다시 볼 수 없을까: 발급·검증·권한이 갈리는 자리GitHub 에서 토큰을 만들면 화면에 한 번 뜨고 다시는 볼 수 없습니다. Stripe 도 AWS 도 같습니다. 복사를 놓치면 방법이 없고, 새로 만들라는 안내만 나옵니다.
- 개인 액세스 토큰의 한계: 토큰은 권한이 아니라 신원- run: eas build --platform all --non-interactive
- Google Analytics 도입: 코드보다 처리방침에서 먼저 막힌 이유Google Analytics를 붙이는 코드를 다 짜고, 배포 직전에 개인정보 처리방침을 열었습니다. 이 문장이 있었습니다.
- Next.js 보안 헤더와 색인 차단: 새 프로젝트마다 같은 설정을 두는 이유Vercel 로 옮기고 나서 응답 헤더를 다시 봤더니 보안 헤더가 하나도 없었습니다. 예전에는 오리진 앞의 nginx 가 붙여 주고 있었는데, 그 nginx 가 경로에서 빠지면서 전 경로에서 같이 사라진 것입니다. 앱은 그대로였고 오류도 없었습니다. 헤더는 없어져도 화면이 멀쩡해서, 재보기 전에는 모릅니다.