이 글은 Today I Learn 시리즈의 100번째 기록입니다. (총 125개)

빌딩 회의실 예약 시스템을 하루에 세우면서 판단이 필요했던 지점들을 정리했다.


검사가 통과했다는 말이 무슨 뜻인가

검사 셋 중 둘이 통과인데 화면이 하얬다

이 프로젝트에는 검사가 세 종류 돈다. 보는 자리가 다 다르다.

  • 타입 검사 — 코드끼리 아귀가 맞는지만 본다. 서버도 브라우저도 안 띄운다
  • API 점검 — 서버에 실제로 요청을 보내 답이 맞는지 본다. 53개 항목이 돌아간다. 화면은 안 본다
  • 브라우저 검사 — 크롬을 실제로 띄워 페이지를 열어 본다

어느 날 앞의 둘이 전부 통과인 상태에서 브라우저를 열었더니 사이트가 하얗게 비어 있었다. 둘이 거짓말을 한 게 아니다. 코드 아귀는 맞았고 서버도 정상이었다. 화면을 보는 검사가 그때는 없었을 뿐이다.

그러니까 “검사 통과”라는 보고를 받으면 그 자리에서 “그래서 뭘 본 검사인가”를 물어야 한다. 통과가 보증하는 범위는 그 검사가 보는 자리까지고, 그 바깥에 대해서는 아무 말도 하지 않은 것이다. 셋 다 통과여야 의미가 있는데, 둘만 돌려 놓고 통과라고 부르기 쉽다.

화면을 건드리면 브라우저 검사를 반드시 돌린다는 걸 작업 지침에 규칙으로 박았다. 도입이 무거울 줄 알았는데 puppeteer-core — 브라우저를 따로 내려받지 않고 이미 깔려 있는 크롬을 조종하는 도구 — 를 쓰면 돼서 부담이 거의 없었다. 검사를 하나 더 두는 판단에서 진짜로 물어야 할 건 “필요한가”가 아니라 “얼마나 무거운가”였다. 필요한지는 대개 이미 답이 나와 있다.

화면이 깨져도 에러는 안 난다

푸터 위치를 고치다가 홈 카드가 가로 네 칸에서 한 줄에 하나씩으로 바뀌었다. 그런데 이건 에러가 아니다. 코드도 서버도 멀쩡하고 화면도 멀쩡히 그려진다. 배치만 달라졌다.

문제는 이게 스크린샷으로 안 잡힌다는 것이다. 카드가 세로로 쌓인 화면을 보면 “이렇게 디자인했나” 하고 넘어가게 된다. 기능이 고장 나면 화면이 소리를 지르는데, 배치가 틀어지면 조용히 그럴듯해 보인다.

그래서 눈으로 보는 대신 숫자를 박아 뒀다. 본문 폭이 1180px인가, 카드가 4열인가. 눈은 본 것을 의도로 해석하고 숫자는 해석하지 않는다.

“128건 통과”라고 적힌 순간 검사는 죽어 있었다

커밋 메시지에 브라우저 검사 128건 통과라고 적혀 나갔는데, 그 시점에 검사 스크립트는 중간에 죽어 있었다.

검사 스크립트는 다 돌고 나서 맨 끝에 통과 128 · 실패 0 같은 요약 줄을 한 줄 찍는다. 그런데 결과가 길어서 실패한 줄만 골라 보려고 걸러서 출력했고, 걸러진 화면에는 아무것도 안 나왔다. 그 빈 화면을 통과로 읽었다.

아무것도 안 나온 화면은 두 가지를 뜻한다. 실패가 하나도 없었거나, 스크립트가 마지막 줄에 닿기 전에 죽어서 찍을 게 없었거나. 화면에서는 똑같이 보인다.

이건 코드 밖에서도 그대로 통한다. “문제 없었습니다”만 오는 보고는 아직 아무 말도 안 한 보고다. 몇 건 중 몇 건인지가 붙어야 뜻이 생긴다. 그래서 요약 줄을 눈으로 보고 옮겨 적는다는 걸 지침에 규칙으로 넣었다.


규율로 막을 것과 구조로 막을 것

같은 시간에 두 팀이 들어오는 사고

이 프로젝트의 출발점 중 하나가 예약 장부가 둘로 갈려 어긋나는 문제였다. 그러니 중복 예약은 이 제품에서 절대 나면 안 되는 사고다.

보통은 신청이 들어올 때 그 시간에 예약이 있는지 조회하고 없으면 넣는다. 그런데 조회와 저장은 두 번의 왕복이고, 그 사이에 다른 신청이 끼어들면 둘 다 통과한다. 코드를 아무리 꼼꼼히 써도 그 틈은 안 없어진다.

대신 데이터를 애초에 두 번 들어갈 수 없는 모양으로 잡았다. 회의실·날짜·시간대 조합이 한 번만 존재할 수 있게 하면, 나중에 온 신청은 데이터베이스가 거절한다.

PRIMARY KEY (room_id, date, slot_min)

원리로 적으면 이렇다. 규율로 막는 것은 사람이 지켜야 유지되고, 구조로 막는 것은 사람이 실수해도 유지된다. 절대 나면 안 되는 사고는 구조 쪽으로 옮긴다.

공짜는 아니다. 구조가 거절하면 그 거절이 데이터베이스 말로 올라와서, 사용자가 읽을 문장으로 번역하는 일이 새로 생긴다. “이미 예약된 시간입니다”가 나오게 하려면 한 겹을 더 얹어야 한다. 그래도 새는 것보다 낫다.

잘못 눌렀을 때 빠져나올 방법이 있는가

시간대를 첫 클릭으로 시작, 둘째 클릭으로 끝을 정하게 만들었는데, 한 번 정하고 나면 다시 고를 방법이 없었다. 되돌리기를 안 만든 게 아니라 되돌리는 상태를 아예 정의하지 않은 것이다.

화면을 만들 때 “이걸 어떻게 하는가”는 반드시 정하는데 “잘못했을 때 어떻게 빠져나오는가”는 자주 빠진다. 앞은 만들면서 자연히 생각하게 되고 뒤는 의식해서 물어야 나온다. 그래서 조작을 검수할 때 질문을 하나 고정으로 넣기로 했다.

단계마다 안내 문구를 바꾼 것도 같은 이유다. 조작을 설명하지 않으면 사용자는 화면이 고장 났는지 자기가 틀렸는지 구분하지 못한다.

검사 스크립트도 제품이다

API 점검이 처음엔 통과하고 두 번째 실행에서 네 개가 실패했다. 앞선 실행이 남긴 예약이 그대로 있어서였다.

“돌리기 전에 데이터베이스를 초기화하세요”는 규칙이 아니라 미완성이다. 사람이 매번 지켜야 결과가 맞는 검사는, 언젠가 안 지켜진 채로 통과 보고를 낸다. 검사 스크립트가 시작할 때 스스로 자리를 비우게 고치고, 연속 세 번 돌려 같은 결과가 나오는 것까지 확인했다.


남에게 편집 권한을 주면서 사고는 막기

이 제품의 핵심 판단

받은 불편 다섯 가지 중 둘이 “문구, 사진을 고치려면 업체에 요청해야 한다”였다. 그러니 관리자가 직접 고치게 하는 건 타협할 수 없는 요구다. 그런데 고친 게 바로 공개 사이트에 뜨면 오타 하나가 그대로 나간다. 실무자가 매일 쓰는 화면에서 그건 시간 문제다.

권한을 주되 위험은 낮추는 방법으로 문서를 두 벌 들고 가기로 했다. 저장은 초안만 바꾸고, 미리보기는 초안을 입힌 진짜 화면을 관리자에게만 보여주고, 배포를 눌러야 공개된다.

이게 좋은 이유는 기술이 아니라 심리에 있다. 저장이 무섭지 않으면 사람은 손을 댄다. 지금 이 건물 담당자가 문구 하나를 못 고치고 있는 이유도 실은 권한이 없어서가 아니라 요청-대기-확인이 귀찮아서다. 마찰을 없애는 게 기능이다.

미리보기는 로그인한 사람에게만 준다. 이걸 빼면 아직 배포하지 않은 초안이 주소만 알면 새어 나간다.

콘텐츠를 남이 넣는 순간, 콘텐츠에 기댄 최적화는 깨진다

폰트가 2MB라 줄여야 했다. 예전 앱에서는 화면에 나오는 글자를 전부 모아서 그 글자만 남기는 식으로 잘랐다. 여기서는 그 방법을 못 썼다. 관리자가 문구를 직접 고치는 제품이라 어떤 글자가 들어올지 미리 셀 수 없기 때문이다.

그래서 글자가 아니라 범위로 잘랐다. KS X 1001(한글 완성형 표준)의 2350자를 기준으로 삼았다. 현대 한국어는 사실상 다 덮인다. 2009KB가 436KB가 됐다.

일반화하면, 콘텐츠를 우리가 다 쥐고 있을 때만 통하는 최적화가 꽤 많다. 편집 권한을 남에게 넘기는 설계를 택하는 순간 그 최적화들은 전부 다시 봐야 한다. 권한을 넘기는 결정에는 이런 파급이 딸려 온다는 걸 계산에 넣는다.


데모가 넘어야 할 선

받아 본 사람이 설정부터 해야 하는 데모는 실패한다

이미지 업로드에는 오브젝트 스토리지가 필요하다. 그런데 그걸 전제로 만들면, 저장소를 만들기 전까지는 화면에 사진이 하나도 안 뜬다. 제안용 물건에서 그건 치명적이다. 보는 사람은 “설정을 안 해서 그렇겠지”라고 생각해 주지 않는다.

기본 이미지는 코드와 함께 배포되는 정적 자산에 두고, 관리자가 올린 것만 저장소에서 읽게 갈랐다. 설정 없이도 화면이 그림까지 다 차고, 업로드를 실제로 쓸 때만 저장소가 필요해진다.

기준으로 남기면 이렇다. 데모는 첫 실행에서 완성된 모습이 나와야 하고, 준비가 필요한 기능은 준비가 없을 때 우아하게 물러나야 한다.

이름이 내용이면 캐시 문제가 사라진다

업로드 이미지에 캐시를 길게 걸면 교체했을 때 옛 그림이 남고, 짧게 걸면 매번 다시 받는다. 흔한 딜레마인데, 파일 이름을 파일 내용의 SHA-256 해시(내용이 1비트만 달라도 완전히 다른 값이 나오는 지문 같은 것)로 지으면 딜레마 자체가 없어진다. 내용이 바뀌면 이름이 바뀌므로 옛 캐시가 남을 수가 없다.

같은 성질에서 중복 업로드도 덤으로 사라진다. 같은 파일을 다시 올리면 같은 이름이 나오니 이미 있는 걸 돌려주면 된다.

문제를 푸는 대신 문제가 생기지 않는 자리로 옮기는 종류의 해법이라, 비슷한 상황에서 먼저 떠올릴 만하다.

가상 브랜드라면 실존 랜드마크는 뺀다

가상 건물이라 쓸 사진이 없어서 저작권이 완전히 풀린 사진만 골라 받았다. 고를 때 기준을 하나 더 뒀는데, 알아볼 수 있는 실제 랜드마크가 찍힌 건 제외했다.

라이선스가 허락해도 맥락이 어긋나면 못 쓴다. 가상 건물 소개에 실제 도시의 상징이 박혀 있으면 보는 사람이 먼저 이상하게 여긴다. 소재를 고르는 기준은 권리와 맥락 두 개다.


요약

  • 검사가 통과했다는 보고에는 “그래서 뭘 본 검사인가”가 생략돼 있다. 보증 범위는 그 검사가 보는 자리까지다
  • 배치가 틀어진 화면은 에러를 안 내고 그럴듯해 보인다. 눈으로 보지 말고 폭과 열 수를 숫자로 박아 둔다
  • “문제 없었습니다”만 오는 보고는 아직 아무 말도 안 한 보고다. 몇 건 중 몇 건인지가 붙어야 뜻이 생긴다
  • 규율로 막는 건 사람이 지켜야 유지되고 구조로 막는 건 실수해도 유지된다. 절대 나면 안 되는 사고는 구조로 옮긴다
  • 조작을 만들 때 “어떻게 하는가”는 자연히 정해지고 “잘못했을 때 어떻게 빠져나오는가”는 의식해야 나온다
  • 사람이 매번 지켜야 결과가 맞는 검사는 언젠가 안 지켜진 채로 통과 보고를 낸다
  • 저장이 무섭지 않아야 사람이 손을 댄다. 초안과 발행본을 나누면 권한은 주면서 사고는 막는다
  • 편집 권한을 남에게 넘기면 콘텐츠에 기댄 최적화는 전부 다시 봐야 한다
  • 데모는 첫 실행에서 완성된 모습이 나와야 한다. 준비가 필요한 기능은 준비가 없을 때 물러난다
  • 파일 이름을 내용의 해시로 지으면 캐시 무효화 문제 자체가 사라진다. 문제를 푸는 대신 안 생기는 자리로 옮기는 해법을 먼저 찾는다
  • 소재는 권리와 맥락 두 기준으로 고른다. 라이선스가 허락해도 맥락이 어긋나면 못 쓴다

Series: Today I Learn

1 C++ 자료형(Data Type) 2 MD5 vs pHash 3 C++에서 함수의 선언과 정의 4 Tkinter padx, pady 5 메모리와 포인터 변수 6 Call by Value, Call by Reference, Call by Pointer 비교 7 const 8 Gemfile — Jekyll 프로젝트의 의존성 파일 9 kramdown-parser-gfm — Jekyll의 GFM 파서 10 파서(Parser) 11 AHU vs OHU 12 I might try it vs I'll try it 뉘앙스 차이 13 SESSION_EXPIRE_AT_BROWSER_CLOSE=True 14 configuration key 15 Git stash vs discard 16 subprocess.Popen으로 Windows 탐색기에 명령어를 전달 17 Post 잔디 분석하기 18 Google Sheets Sync 최적화 19 DSL (Domain Specific Language)과 GPL (General Purpose Language) 20 마크다운 표 그리는 방법 21 쿼리 파라미터(Query Parameter). 기존 QR코드 재활용 22 Django 보안 취약점 점검 및 수정 23 OOP Object-Oriented Programming 객체 지향 프로그래밍 24 Fernet 대칭 암호화 25 Jekyll 코드블록 안의 Liquid 태그 26 insertOnConflictUpdate vs DoUpdate(target) 27 세션 필터 28 아코디언(Accordiaon) UI를 펼친상태로 만들기 29 input의 step 30 Word Cloud 31 Google Sheets를 데이터 버스로(with AppSheet) 32 Django 모델 텍스트 필드 자동 수집 패턴 33 localStorage로 섹션 토글 상태 유지 34 순차 ID 생성(`select_for_update()` + `max()` 조합) 35 역참조 검색과 distinct() 36 xlsx 다운로드와 로딩 오버레이 충돌 37 Android 파일 공유 MIME 타입 38 AssetManifest — Flutter 빌드 타임 asset 목록 런타임 조회 39 UTF-8 BOM과 PowerShell 파일 쓰기 40 소리꽃 KeyBloom TIL 1 41 소리꽃 KeyBloom TIL 2 42 소리꽃 KeyBloom TIL 3 43 메트로놈 Simple Metronome TIL 1 44 소리꽃 KeyBloom TIL 4 45 메트로놈 Simple Metronome TIL 2 46 소리꽃 KeyBloom TIL 5 47 메트로놈 Simple Metronome TIL 3 48 정적 블로그 SEO 정비와 Pagefind 검색 도입 49 메트로놈 Simple Metronome TIL 4 50 안드로이드 AudioTrack 연속 재생, 실측 피커 정렬, 카메라 토치 플래시 51 메트로놈 Simple Metronome TIL 5 52 모래게임 Sandrop TIL 1 53 메트로놈 Simple Metronome TIL 6 54 모래게임 Sandrop TIL 2 55 모래게임 Sandrop TIL 3 56 메트로놈 Simple Metronome TIL 7 57 모래게임 Sandrop TIL 4 58 모래게임 Sandrop TIL 5 59 모래게임 Sandrop TIL 6 60 소리꽃 KeyBloom TIL 6 61 모래게임 Sandrop TIL 7 62 모래게임 Sandrop TIL 8 63 소리꽃 KeyBloom TIL 7 64 모래게임 Sandrop TIL 9 65 소리꽃 KeyBloom TIL 8 66 모래게임 Sandrop TIL 10 67 소리꽃 KeyBloom TIL 9 68 소리꽃 KeyBloom TIL 10 69 소리꽃 KeyBloom TIL 11 70 소리꽃 KeyBloom TIL 12 71 소리꽃 KeyBloom TIL 13 72 온실 GreenHouse TIL 1 73 소리꽃 KeyBloom TIL 14 74 온실 GreenHouse TIL 2 75 소리꽃 KeyBloom TIL 15 76 소리꽃 KeyBloom TIL 16 77 소리꽃 KeyBloom TIL 17 78 소리꽃 KeyBloom TIL 18 79 소리꽃 KeyBloom TIL 19 80 소리꽃 KeyBloom TIL 20 81 로그스톤 상표 셀프 출원 TIL 1 82 소리꽃 KeyBloom TIL 21 83 소리꽃 KeyBloom TIL 22 84 소리꽃 KeyBloom TIL 23 85 소리꽃 KeyBloom TIL 24 86 MiniMacro TIL 1 87 Astro가 무엇인지, 왜 옮기는지 88 MiniMacro TIL 2 89 메트로놈 Simple Metronome TIL 8 90 메트로놈 Simple Metronome TIL 9 91 블로그 Astro 이관 TIL 92 오픈데이 Openday TIL 1 93 오픈데이 Openday TIL 2 94 Unreal Engine MCP TIL 1 95 콘티온 Conti On TIL 11 96 오픈데이 Openday TIL 3 97 오픈데이 Openday TIL 4 98 오픈데이 Openday TIL 5 99 ScorePlayer TIL 1 100 BuildingHub TIL 1 읽는 중 101 ScorePlayer TIL 2 102 BuildingHub TIL 2 103 ScorePlayer TIL 3 104 Unreal Engine MCP TIL 2 105 궁 미로 PalaceMaze TIL 1 106 메트로놈 Simple Metronome TIL 10 107 메트로놈 Simple Metronome TIL 11 108 오픈데이 Openday TIL 6 109 BuildingHub TIL 3 110 Obsidian vault에서 코드만 빼기 — directory junction 111 ScorePlayer TIL 4 112 오픈데이 Openday TIL 7 113 URL은 바뀔 수 있는 것에 묶지 않는다 114 도구는 기능이 아니라 내 일에 맞는지로 고른다 115 돌이키기 힘든 변경은 작게 먼저 확인한다 116 궁 미로 PalaceMaze TIL 3 117 궁 미로 PalaceMaze TIL 2 118 소리꽃 KeyBloom TIL 29 119 오픈데이 Openday TIL 8 120 BuildingHub TIL 4 121 ScorePlayer TIL 5 122 BuildingHub TIL 5 123 MiniMacro TIL 3 124 소리꽃 KeyBloom TIL 30 125 궁 미로 PalaceMaze TIL 4