콘티온 ContiOn TIL 1
한 팀만 쓰는 도구를 처음 세운 날, 기능보다 먼저 “누가 들어오고 무엇을 지키나”를 정해야 했다. 그날 내린 판단들을 모았다.
아무나 들어오면 안 되는 도구였다
콘티온은 한 팀만 쓰는 도구라 누구나 가입해서 쓰게 둘 수 없었다. 로그인은 구글 계정으로 하되(OAuth — 비밀번호를 우리가 보관하지 않고 구글이 “이 사람이 맞다”고 확인해 주는 방식), 로그인했다고 바로 쓰게 하지 않고 관리자가 승인해야 들어오는 승인제로 하기로 했다. 오래 안 들어온 계정은 잠기게 해서, 떠난 사람의 계정이 열린 채 남지 않게 했다.
이렇게 두면 앱이 비밀번호를 아예 갖지 않는다. 저장한 비밀번호가 없으니 샐 비밀번호도 없다. 대신 문을 지키는 일이 구글 계정으로 넘어간다. 이게 뒤의 급소 이야기로 이어진다.
여럿이 고치다 보니 “이게 최종이야?”가 흐려졌다
콘티(곡 순서표)를 여럿이 만지다 보면 어느 게 확정본인지 흐려진다. 그래서 콘티에 초안과 승인 두 상태를 두고, 승인권이 있는 사람이 확정하면 그 콘티는 잠기게 했다. 잠긴 콘티는 PPT 내보내기만 되고, 다시 고치려면 승인권자가 잠금을 풀어야 한다.
여럿이 쓰는 문서에는 “지금 고쳐도 되는 상태인가”가 눈에 보여야 한다. 상태가 없으면 확정했다는 사실이 사람들 기억에만 남고, 누군가 무심코 한 곡을 바꿔도 알 길이 없다.
지운 걸 되돌릴 수 있어야 했다
곡이나 콘티를 지우면 정말 없어지게 두지 않기로 했다. 여럿이 쓰는 도구에서는 누군가 실수로 지우는 일이 반드시 생기고, 그때 되돌릴 길이 없으면 그 곡을 처음부터 다시 입력해야 한다.
그래서 지우기를 소프트 삭제(soft delete — 데이터를 실제로 없애지 않고 “지운 시각”만 표시해 목록에서 빼는 방식)로 했다. 쓰는 사람 눈에는 사라진 것처럼 보이지만 데이터는 그대로 있어서, 휴지통에서 표시만 지우면 돌아온다.
이와 별개로 쓰는 데이터베이스(Cloudflare D1)는 최대 30일 전 시점으로 통째로 되돌리는 기능(Time Travel)을 기본으로 준다. 한 곡을 잘못 지운 일상적인 실수는 휴지통으로, 데이터베이스 전체가 잘못된 사고는 시점 복원으로 층을 나눴다. 되돌리기에도 크기가 있다.
두 사람이 같은 곡을 동시에 고치면
같은 곡을 두 사람이 동시에 열어 고치면 나중에 저장한 쪽이 앞사람의 수정을 덮어쓴다. 그래서 누가 곡 편집을 열면 그 곡을 잠가, 다른 사람은 “지금 누가 편집 중”이라는 안내를 보고 못 열게 하기로 했다.
그런데 잠근 사람이 저장하지 않고 브라우저를 그냥 닫으면 그 곡은 영영 잠긴다. 그래서 잠금에 만료 시간을 붙였다. 몇 분 동안 아무 신호가 없으면 풀린 것으로 보고, 편집 화면을 열어 둔 동안에는 1분마다 “아직 보고 있다”는 신호(하트비트 heartbeat — 살아 있음을 알리려고 주기적으로 보내는 신호)를 보내 살아 있는 편집은 안 풀리게 했다.
잠금은 거는 것보다 풀리는 조건을 먼저 정해야 한다. 사람이 잠금을 풀 거라고 믿으면, 그 사람이 창을 닫는 순간 아무도 못 쓰는 곡이 생긴다.
곡 구분을 관리자가 늘릴 수 있게
곡의 구분(카테고리) 목록은 처음에 코드에 적어 두었다. 구분이 하나 늘 때마다 코드를 고쳐 다시 배포해야 하니, 관리자가 화면에서 직접 추가·수정하게 데이터베이스로 옮기기로 했다.
옮기고 보니 짝을 맞춰야 할 곳이 생겼다. 곡들은 구분을 이름으로 들고 있어서, 구분 이름만 바꾸면 곡들은 옛 이름을 든 채 남는다. 그래서 이름을 바꾸면 그 구분에 속한 곡들의 값도 같이 바꾸고, 구분을 지우면 곡은 지우지 않고 “없음”으로 옮기게 했다.
목록을 코드 밖으로 빼면 바꾸는 사람이 바뀐다. 그 대신 그 목록을 이름으로 가리키던 곳들이 같이 따라가게 챙겨야 한다.
팀원 표가 한 줄에 안 들어갔다
관리자 화면의 팀원 표에 이름·이메일·부서·권한·상태·마지막 로그인·작업 버튼까지 다 넣었더니 한 줄에 안 들어가 줄이 꺾였다. 칸을 줄이는 대신 목록과 상세로 나눴다. 목록에는 이름·부서·권한 표시만 두고, 사람을 누르면 상세 화면에서 권한을 바꾸고 그 사람의 접속 기록까지 본다.
이런 구성을 마스터-디테일(master-detail — 목록에서 하나를 고르면 그 항목의 전체 내용을 따로 보여주는 배치)이라 부른다. 관리자 도구에서 흔한 모양인 데는 이유가 있다. 표는 칸이 늘수록 한 줄이 무너지고, 한 사람에 대해 할 일이 많아질수록 그 일은 그 사람을 고른 뒤의 화면에 모으는 편이 낫다.
실명을 어디서 가리나
콘티 기록·검수 목록·휴지통처럼 팀 전체가 보는 화면에는 실명 대신 가운데를 가린 이름을 보여주기로 했다(홍길동 → 홍*동). 실명은 관리자 화면에서만 보인다.
가리는 자리는 화면이 아니라 서버다. 화면에서만 가리면 보기엔 가려져 있어도 서버가 내려준 데이터에는 실명이 그대로 들어 있어서, 브라우저 개발자도구(웹페이지가 주고받는 데이터를 열어 볼 수 있는 브라우저 내장 도구)로 열면 다 보인다. 그래서 팀이 보는 데이터는 서버가 가린 채로 내보내고, 실명이 담긴 데이터는 관리자에게만 준다.
“안 보이게 했다”와 “안 보냈다”는 다르다. 보내 놓고 가린 건 가린 척일 뿐이다.
뚫린다면 어디부터 뚫릴까
해킹당한다면 원인이 뭘지 물었더니, 답은 코드 버그가 아니었다. 급소는 계정과 시크릿(비밀키 — 서버만 알고 있어야 하는 값)이었다.
- 로그인을 구글에 맡겼으니 관리자의 구글 계정이 사실상 이 앱의 마스터키다. 그 계정이 털리면 앱도 열린다. 그래서 1순위 방어는 코드가 아니라 관리자 계정의 2단계 인증(2FA — 비밀번호에 더해 휴대폰 확인을 한 번 더 거치는 것)이었다.
- 서버는 “이 사람은 로그인했다”는 표식에 비밀키로 서명한다. 이 비밀키가 새면 누구로든 로그인한 척할 수 있다. 그래서 비밀키는 코드·커밋·로그 어디에도 남기지 않고, 실수로 저장소 기록에 들어간 적이 없는지도 점검해 두었다.
코드 쪽에서 흔히 노리는 공격은 기본 방어가 이미 되어 있었다.
- 주입(injection) — 입력창에 명령문을 섞어 넣어 데이터베이스가 그걸 명령으로 착각하게 만드는 수법. 사용자가 넣은 값을 명령문에 이어 붙이지 않고 “값”으로 따로 넘기면 막힌다.
- XSS — 누가 이름 칸에 화면 조작 코드를 적어 넣으면 그게 다른 사람 화면에서 실행되는 공격. React(화면을 만드는 도구)는 사용자가 넣은 글자를 코드가 아니라 글자로만 그려서 기본으로 막는다.
- CSRF — 로그인된 사람이 낚시 링크를 누르면 본인도 모르게 우리 서버로 요청이 날아가게 만드는 수법. 다른 사이트에서 시작된 요청에는 로그인 표식이 따라가지 않게 막아 두었다.
정리하면 이 도구의 보안은 팀 구글 계정들의 2단계 인증과 비밀키 관리, 이 두 가지로 모였다. 지킬 곳이 분명해지니 “지금 더 조일 곳은 없다”는 판단도 설 수 있었다. 어디를 지킬지 모르면 모든 곳을 조금씩 걱정하게 되고, 정작 급소는 비어 있기 쉽다.
자동 배포를 붙이려니 안 됐다
Cloudflare Pages(정적 사이트와 작은 서버 기능을 함께 올려 주는 호스팅)에 올리는 방식은 두 가지다. 직접 업로드(direct upload — 내 컴퓨터에서 명령으로 올리는 것)와 Git 연동(저장소에 push하면 알아서 빌드·배포되는 것). 처음엔 직접 업로드로 만들었는데, 나중에 push하면 자동으로 배포되게 붙이려 하니 안 됐다. 이 선택은 프로젝트를 만들 때 정해지고 서로 바꿀 수 없어서, 바꾸려면 프로젝트를 새로 만들고 시크릿도 다시 넣어야 했다.
그래서 자동 배포는 접고 둘을 떼어 운영하기로 했다. push는 코드 백업(GitHub), 배포는 따로 명령으로. push해도 사이트는 안 바뀌고, 배포해도 GitHub은 안 바뀐다. 배포가 잦지 않은 도구라 이 편이 오히려 단순했다.
나중에 못 바꾸는 선택은 처음 만들 때 조용히 지나간다. 무엇이 되돌릴 수 없는 선택인지 만들기 전에 한 번 물어 두는 편이 싸다.
요약
- 닫힌 팀 도구는 구글 로그인에 승인제를 얹어 문을 닫는다. 비밀번호를 안 가지니 샐 비밀번호도 없고, 대신 문지기가 구글 계정이 된다.
- 여럿이 고치는 문서는 초안·승인 상태를 두고 확정본을 잠가야 “이게 최종인가”가 흐려지지 않는다.
- 지우기는 소프트 삭제로 되돌릴 수 있게 한다. 일상 실수는 휴지통, 큰 사고는 데이터베이스 시점 복원으로 층을 나눈다.
- 편집 잠금은 풀리는 조건(만료 시간)부터 정한다. 창을 닫고 떠난 사람 때문에 영영 잠기는 곡이 없어야 한다.
- 목록을 코드에서 데이터베이스로 빼면 관리자가 직접 바꾼다. 대신 그 이름을 가리키던 곡들도 같이 따라가게 한다.
- 표의 칸이 넘치면 칸을 줄이지 말고 목록과 상세(마스터-디테일)로 나눈다.
- 실명은 서버에서 가린다. 보내 놓고 화면에서 가리면 개발자도구로 다 보인다.
- 이런 도구의 급소는 코드가 아니라 계정(관리자 구글 계정의 2FA)과 시크릿(서명 비밀키)이다. 지킬 곳이 분명해야 “더 조일 곳이 없다”는 판단도 선다.
- Cloudflare Pages의 직접 업로드와 Git 연동은 만들 때 정해지고 못 바꾼다. 되돌릴 수 없는 선택은 만들기 전에 묻는다.