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

콘티온의 자막 템플릿과 콘티 편집 화면을 하루 종일 손봤다. 저장 경고, 로그, 설정 배치, 잠긴 콘티의 예외까지 그날 판단이 필요했던 것들을 모았다.


저장 안 한 편집을 두고 나가는 길이 셋이었다

자막 템플릿이나 곡 정보를 고치다 저장하지 않고 다른 화면으로 가 버리면 고친 게 그대로 사라진다. 그래서 저장 안 한 변경이 있으면 나가기 전에 경고를 띄우기로 했다. 막상 붙이려고 보니 “화면을 벗어난다”가 한 가지가 아니었다.

  • 앱 위쪽 메뉴를 눌러 다른 화면으로 이동
  • 편집 화면 자체의 닫기·취소 버튼
  • 브라우저 새로고침이나 탭 닫기

앞의 둘은 앱 안의 동작이라 우리가 만든 확인창을 끼울 수 있다. 세 번째는 브라우저가 페이지를 통째로 떠나는 것이라 앱이 끼어들 틈이 좁다. 브라우저가 떠나기 직전에 보내 주는 신호(beforeunload 이벤트)로만 막을 수 있고, 그때 뜨는 경고창은 브라우저 기본 문구라 우리 말로 바꿀 수 없다. 같은 “나가기”라도 누가 그 길을 쥐고 있느냐에 따라 막는 방법과 보여 줄 수 있는 말이 다르다.

그리고 콘티 편집(곡 담기·순서 바꾸기)에는 이 경고를 붙이지 않았다. 바꿀 때마다 바로 저장되는 화면이라 “저장 안 한 상태”가 애초에 없기 때문이다. 경고는 모든 화면에 기계적으로 붙이는 게 아니라 실제로 잃을 게 있는 화면에만 붙인다. 잃을 게 없는 화면에서 경고가 뜨면 사람들은 경고 자체를 무시하는 버릇이 든다.


수정 요청 기록이 곡 로그에 안 보였다

곡마다 “누가 언제 무엇을 했나”를 보여 주는 곡 로그 화면이 있다. 그런데 곡 수정 요청과 그 승인 기록은 분명히 쌓이고 있는데 이 화면에는 나타나지 않았다.

원인은 분류하는 방식에 있었다. 로그에는 대상 번호만 들어 있었는데, 곡 3번과 콘티 3번은 숫자가 같아서 번호만으로는 어느 쪽 기록인지 알 수 없다. 그래서 화면 쪽에서 “이 동작들은 곡에 대한 것”이라는 목록을 따로 들고 한 번 더 거르고 있었다. 수정 요청 기능을 새로 만들면서 그 목록에 올리는 걸 빠뜨렸고, 기록은 쌓이는데 화면에는 안 보이게 된 것이다.

기록하는 곳과 분류하는 곳이 떨어져 있으면 새 기능을 만들 때마다 두 곳을 고쳐야 하고, 한 곳은 반드시 언젠가 빠진다. 그래서 로그를 남길 때 “무엇에 대한 기록인지”(entity — 곡인지 콘티인지 템플릿인지)와 “그중 어느 것인지”(entity_id — 그 대상의 번호)를 칼럼(데이터베이스 표의 세로 칸)으로 같이 적기로 했다. 분류가 기록하는 바로 그 자리에서 정해지니, 새 기능을 붙이는 순간 분류도 따라온다. 따로 관리할 목록이 없으니 빠뜨릴 것도 없다.

곁들여 정리된 것이 하나 있다. 전에 콘티 이름 변경 기록은 번호와 “이전 이름 → 새 이름”을 한 칸에 붙여 적고 있었다. 한 칸이 거르는 기준과 사람이 읽을 설명을 동시에 맡고 있던 셈인데, 번호가 제 칸을 얻으니 설명 칸은 설명만 담게 됐다. 값 하나에 두 역할을 시키면 거를 때도 읽을 때도 지저분해진다.

다만 로그를 우리말로 보여 주는 이름표(“곡 등록”, “수정 요청” 같은 것)는 자동으로 정할 수 없다. 번역은 사람이 정해야 한다. 이건 세 화면에 흩어져 있던 것을 한 곳으로 모으는 데서 멈췄다. 자동으로 만들 수 있는 것과 사람이 정해야 하는 것을 가르고, 뒤쪽은 한 곳에 모아 두는 게 현실적인 선이었다.


자막 위치만 바꿨는데 로그엔 배경과 글꼴이 찍혔다

템플릿 수정 기록을 보니, 자막 위치만 바꿨는데 “배경 그라데이션 · 글꼴 G마켓 산스”가 떠 있었다. 배경은 건드린 적이 없다.

화면은 저장할 때 모든 설정값을 한꺼번에 서버로 보낸다. 로그는 그렇게 받은 값을 그대로 나열하고 있었다. “무엇을 보냈나”를 적은 셈인데, 로그를 읽는 사람이 알고 싶은 건 “무엇이 바뀌었나”다. 그래서 저장 직전 값과 비교해 달라진 항목만 남기도록 바꿨다. 이제 자막 위치만 바꾸면 “자막 위치”만 남는다.

로그가 실제와 한 번 어긋나면 사람은 그 로그 전체를 믿지 않게 된다. 로그의 값어치는 정확함에서 나온다. 대신 자막 바 이미지처럼 큰 값은 내용을 일일이 비교하지 않고 “바꿨다”만 남겼다. 사람이 알아야 할 수준까지만 정확하면 된다.


자막 위치 칸이 좌석 안내 안에 들어 있었다

같은 날 자막 세로 위치를 조절하는 칸을 만들었는데, 좌석 안내(곡마다 켜면 자막 줄에 함께 나가는 착석 안내 문구) 설정 묶음 안에 넣어 두었다. 좌석 안내 작업을 하다 같이 만든 거라 그 자리가 자연스러워 보였다.

다시 보니 이 값은 좌석 안내만이 아니라 제목과 가사까지 자막 줄 전체를 움직인다. 그런데 좌석 안내 묶음 안에 있으니 “좌석 안내 문구의 위치”로 읽힌다. 기능은 맞는데 놓인 자리가 틀린 뜻을 만들고 있었다. 동작은 하나도 안 바꾸고, 화면에서 “자막 위치”라는 별도 묶음으로 빼는 것으로 끝냈다.

설정이 무엇에 영향을 주는지는 이름표만이 아니라 어느 묶음에 들어 있느냐로도 전달된다. 같은 입력칸이라도 어디에 두느냐에 따라 보는 사람이 이해하는 범위가 달라진다.


둘 다 동작은 멀쩡했다

로그는 잘 쌓였고 자막 위치 조절도 제대로 움직였다. 틀린 건 사람에게 전달되는 쪽이었다. 로그는 틀린 내용을 말했고, 설정은 틀린 범위를 암시했다.

기능이 맞게 도는지만 보면 이런 걸 놓친다. 만든 쪽은 맥락을 다 알아서 로그가 좀 부정확해도, 설정이 엉뚱한 묶음에 있어도 알아본다. 그 화면을 처음 보는 사람 기준으로 다시 봐야 드러난다. 앞선 TIL 3에서 로그는 남기는 시점이 아니라 읽는 시점 기준으로 쓴다고 적었는데, 그게 로그만이 아니라 화면 전체에 해당하는 이야기였다.


값을 바꿀 때마다 미리보기를 찾아 스크롤을 올렸다

자막 위치 값을 조절하는데 설정칸은 아래에 있고 미리보기는 위에 있어서, 값을 하나 바꿀 때마다 스크롤을 올려 결과를 보고 다시 내려와야 했다. 미리보기가 스크롤을 따라오게 하기로 했다.

평소엔 제자리에 있다가 스크롤이 화면 위 끝에 닿으면 거기 붙어 따라오는 배치(sticky — CSS의 position: sticky)를 썼다. 이때 미리보기에 배경색을 채워 두지 않으면 그 뒤로 지나가는 설정칸이 비쳐 보인다. 조절하는 값과 그 결과가 한 화면에 같이 있어야 조절이 된다. 둘이 떨어져 있으면 사람은 기억에 의지해 비교하게 되고, 미세조정은 거기서 무너진다.


같은 곡을 또 담으려 할 때

이미 콘티(곡 순서표)에 담긴 곡을 또 담으려 하면 실수일 수 있다. 그런데 현장에서는 같은 곡을 일부러 두 번 넣기도 한다(앞에서 부르고 뒤에서 한 번 더 부르는 식). 막아 버리면 이 정당한 반복이 불편해진다. 그래서 막지 않고 “이미 이 콘티에 있다, 그래도 추가할까”라고 묻기로 했다.

곡을 담는 길은 목록의 버튼과 끌어다 놓기 두 가지다. 확인은 버튼마다 붙이지 않고 두 길이 공통으로 거치는 “담기” 동작 한 곳에 두었다. 길마다 붙이면 언젠가 하나를 빠뜨린다.

실수 방지와 정당한 반복이 부딪힐 때, 막는 건 한쪽만 만족시키고 묻는 건 둘 다 만족시킨다.


잠긴 콘티에서 착석 문구만은 바꿔야 했다

콘티는 승인되면 잠긴다. 곡 구성과 순서가 확정됐으니 더는 못 바꾸게 하는 것이다. 그런데 자막 담당자가 잠긴 콘티에서도 곡마다 착석 문구만은 켜고 끌 수 있게 해 달라고 했다. 처음엔 “잠긴 걸 왜 열지” 싶었다.

물어보니 자막 담당자가 이유를 설명했다. 하단 자막 바는 실제 방송에 나가는 자막 영역이고, 착석 문구도 그 자막의 일부다. 그리고 자막은 자막 담당자 소관이다. 듣고 나서 납득했다.

그러고 보니 잠금의 뜻이 갈렸다. 승인 잠금은 “곡 구성과 순서를 확정했다”는 뜻이지 “자막까지 못 건드린다”는 뜻이 아니었다. 곡 구성은 승인하는 사람 소관이고 자막은 자막 담당자 소관이라, 같은 콘티 안에서도 누가 무엇을 건드릴 수 있는지가 다르다. 권한을 “이 화면이 잠겼나”로만 보면 이게 안 보인다. “이 값은 누구 소관인가”로 봐야 잠금을 넘는 예외가 자연스럽게 나온다.


잠금을 풀지 않고 좁은 문을 하나 냈다

착석 설정은 원래 콘티 전체를 한꺼번에 저장하는 길에 얹혀 있었고, 그 길을 승인 잠금이 막고 있었다. 착석만 통과시키려고 그 잠금을 느슨하게 하면 곡 구성까지 같이 열려 버린다.

그래서 잠금은 그대로 두고, 착석 하나만 바꿀 수 있는 좁은 길을 따로 냈다. 잠긴 콘티에서는 자막 담당자만 그 길을 쓸 수 있다. 예외를 만들 때 기존 규칙을 느슨하게 하면 그 규칙이 지키던 다른 것까지 새어 나간다. 규칙은 그대로 조여 두고 예외는 별도 문으로 내는 게 안전하다. 화면에서 체크박스를 막는 것과 서버가 거절하는 것을 짝으로 둔 이유는 앞선 TIL 2와 같다.

이 좁은 길을 내면서 앞 절의 결정이 다시 걸렸다. 같은 곡을 두 번 담을 수 있게 했으니, 한 콘티 안에 같은 곡이 둘 있을 수 있다. “몇 번 곡의 착석을 켜라”고 보내면 둘 다 바뀐다. 곡 번호는 “무엇인가”를 말할 뿐 “어느 것인가”를 말하지 못한다. 그래서 곡이 아니라 콘티 안의 그 항목 자체에 붙은 번호로 가리키게 했다. 중복을 허용하는 순간, 이름이나 곡 번호처럼 내용에서 온 값으로는 개별 항목을 짚을 수 없게 된다.


정리는 하루 끝에 몰아서 시켰다

같은 날 여러 기능을 붙이면서 비슷한 코드가 곳곳에 생겼다. 날짜를 보기 좋게 바꾸는 처리가 여섯 군데, 편집 화면의 상태 값이 수십 개였다. 이걸 기능을 붙이는 도중이 아니라 하루 끝에 몰아서 정리하게 했다.

만드는 중에는 무엇이 진짜 중복이고 무엇이 우연히 비슷한 건지 아직 안 보인다. 너무 일찍 묶으면 잘못된 추상화(여러 곳을 하나로 합쳐 공통 부품으로 만든 것)를 만들고, 너무 늦게 묶으면 고칠 때 빠뜨린 사본이 버그를 낸다. 같은 것이 세 번쯤 보이면 그때가 묶을 때다.

정리는 겉보기 동작이 하나도 안 바뀌어야 한다. 이런 정리를 리팩터링(refactoring — 동작은 그대로 두고 코드 구조만 다듬는 일)이라 부르는데, 기능이 일단락된 자리에서 해야 “안 바뀌었다”를 확인할 기준이 선다.


요약

  • “나가기”는 앱 안 이동·닫기 버튼·브라우저 이탈 셋이고, 브라우저 이탈은 경고 문구조차 우리가 정할 수 없다. 경고는 잃을 게 있는 화면에만 붙인다.
  • 기록하는 곳과 분류하는 곳이 떨어져 있으면 새 기능마다 한 곳이 빠진다. 무엇에 대한 기록인지를 기록하는 그 자리에서 적는다.
  • 한 칸에 두 역할을 시키지 않는다. 자동으로 정할 수 있는 것과 사람이 정할 것(번역)을 가르고, 뒤쪽은 한 곳에 모은다.
  • 로그는 “무엇을 보냈나”가 아니라 “무엇이 바뀌었나”를 남긴다. 한 번 어긋나면 로그 전체를 안 믿게 된다.
  • 설정의 영향 범위는 이름표만이 아니라 어느 묶음에 들어 있느냐로도 전달된다.
  • 동작이 맞아도 사람에게 전달되는 것이 틀릴 수 있다. 처음 보는 사람 기준으로 다시 본다.
  • 조절하는 값과 그 결과는 한 화면에 같이 있어야 한다.
  • 정당한 반복이 있는 동작은 막지 말고 묻는다. 확인은 모든 길이 거치는 한 곳에 둔다.
  • 권한은 “화면이 잠겼나”가 아니라 “이 값은 누구 소관인가”로 본다.
  • 예외는 규칙을 느슨하게 해서 내지 말고 좁은 별도 문으로 낸다.
  • 중복을 허용하면 내용에서 온 값으로는 개별 항목을 못 짚는다. 항목 자체의 번호로 가리킨다.
  • 정리(리팩터링)는 만드는 도중이 아니라 기능이 일단락된 뒤에 한다. 같은 것이 세 번쯤 보이면 묶을 때다.

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 습관만들기 Pawbit TIL 1 27 insertOnConflictUpdate vs DoUpdate(target) 28 세션 필터 29 아코디언(Accordiaon) UI를 펼친상태로 만들기 30 input의 step 31 습관만들기 Pawbit TIL 2 32 습관만들기 Pawbit TIL 3 33 Word Cloud 34 Google Sheets를 데이터 버스로(with AppSheet) 35 Django 모델 텍스트 필드 자동 수집 패턴 36 localStorage로 섹션 토글 상태 유지 37 순차 ID 생성(`select_for_update()` + `max()` 조합) 38 역참조 검색과 distinct() 39 xlsx 다운로드와 로딩 오버레이 충돌 40 Android 파일 공유 MIME 타입 41 AssetManifest — Flutter 빌드 타임 asset 목록 런타임 조회 42 크레센도 Phaser + TypeScript TIL 1 43 크레센도 Phaser + TypeScript TIL 2 44 크레센도 Phaser + TypeScript TIL 3 45 크레센도 Phaser + TypeScript TIL 4 46 크레센도 Phaser + TypeScript TIL 5 47 여둘까 Office Layout TIL 1 48 습관만들기 Pawbit TIL 4 49 여둘까 Office Layout TIL 2 50 여둘까 Office Layout TIL 3 51 크레센도 Phaser + TypeScript TIL 6 52 UTF-8 BOM과 PowerShell 파일 쓰기 53 소리꽃 KeyBloom TIL 1 54 습관만들기 Pawbit TIL 5 55 여둘까 Office Layout TIL 4 56 소리꽃 KeyBloom TIL 2 57 소리꽃 KeyBloom TIL 3 58 메트로놈 Simple Metronome TIL 1 59 소리꽃 KeyBloom TIL 4 60 메트로놈 Simple Metronome TIL 2 61 소리꽃 KeyBloom TIL 5 62 습관만들기 Pawbit TIL 6 63 크레센도 Phaser + TypeScript TIL 7 64 메트로놈 Simple Metronome TIL 3 65 소설 타임라인 관리 도구 Lumior TIL 1 66 습관만들기 Pawbit TIL 7 67 정적 블로그 SEO 정비와 Pagefind 검색 도입 68 크레센도 Phaser + TypeScript TIL 8 69 메트로놈 Simple Metronome TIL 4 70 습관만들기 Pawbit TIL 8 71 안드로이드 AudioTrack 연속 재생, 실측 피커 정렬, 카메라 토치 플래시 72 메트로놈 Simple Metronome TIL 5 73 모래게임 Sandrop TIL 1 74 습관만들기 Pawbit TIL 9 75 메트로놈 Simple Metronome TIL 6 76 모래게임 Sandrop TIL 2 77 모래게임 Sandrop TIL 3 78 습관만들기 Pawbit TIL 10 79 습관만들기 Pawbit TIL 11 80 메트로놈 Simple Metronome TIL 7 81 모래게임 Sandrop TIL 5 82 모래게임 Sandrop TIL 4 83 습관만들기 Pawbit TIL 12 84 모래게임 Sandrop TIL 6 85 소리꽃 KeyBloom TIL 6 86 모래게임 Sandrop TIL 7 87 모래게임 Sandrop TIL 8 88 소리꽃 KeyBloom TIL 7 89 모래게임 Sandrop TIL 9 90 소리꽃 KeyBloom TIL 8 91 모래게임 Sandrop TIL 10 92 소리꽃 KeyBloom TIL 9 93 콘티온 ContiOn TIL 1 94 소리꽃 KeyBloom TIL 10 95 콘티온 ContiOn TIL 2 96 습관만들기 Pawbit TIL 13 97 콘티온 ContiOn TIL 3 98 모래게임 Sandrop TIL 11 99 소리꽃 KeyBloom TIL 11 100 여둘까 Office Layout TIL 5 101 모래게임 Sandrop TIL 12 102 모래게임 Sandrop TIL 13 103 콘티온 ContiOn TIL 4 읽는 중 104 모래게임 Sandrop TIL 14 105 소리꽃 KeyBloom TIL 12 106 습관만들기 Pawbit TIL 14 107 모래게임 Sandrop TIL 15 108 모래게임 Sandrop TIL 16 109 소리꽃 KeyBloom TIL 13 110 온실 GreenHouse TIL 1 111 콘티온 ContiOn TIL 5 112 모래게임 Sandrop TIL 17 113 소리꽃 KeyBloom TIL 14 114 온실 GreenHouse TIL 2 115 콘티온 ContiOn TIL 6 116 모래게임 Sandrop TIL 18 117 콘티온 ContiOn TIL 7 118 모래게임 Sandrop TIL 19 119 소리꽃 KeyBloom TIL 15 120 모래게임 Sandrop TIL 20 121 모래게임 Sandrop TIL 21 122 소리꽃 KeyBloom TIL 16 123 소리꽃 KeyBloom TIL 17 124 소리꽃 KeyBloom TIL 18 125 여둘까 Office Layout TIL 6 126 소리꽃 KeyBloom TIL 19 127 소리꽃 KeyBloom TIL 20 128 콘티온 ContiOn TIL 8 129 콘티온 ContiOn TIL 9 130 로그스톤 상표 셀프 출원 TIL 1 131 소리꽃 KeyBloom TIL 21 132 로그스톤 샵 LogStone Shop TIL 1 133 소리꽃 KeyBloom TIL 22 134 로그스톤 샵 LogStone Shop TIL 2 135 소리꽃 KeyBloom TIL 23 136 모래게임 Sandrop TIL 22 137 모래게임 Sandrop TIL 23 138 소리꽃 KeyBloom TIL 24 139 로그스톤 샵 LogStone Shop TIL 3 140 모래게임 Sandrop TIL 24 141 소리꽃 KeyBloom TIL 25 142 모래게임 Sandrop TIL 25 143 MiniMacro TIL 1 144 모래게임 Sandrop TIL 26 145 콘티온 ContiOn TIL 10 146 Astro가 무엇인지, 왜 옮기는지 147 모래게임 Sandrop TIL 27 148 소리꽃 KeyBloom TIL 26 149 MiniMacro TIL 2 150 로그스톤 샵 LogStone Shop TIL 4 151 메트로놈 Simple Metronome TIL 8 152 모래게임 Sandrop TIL 28 153 여둘까 Office Layout TIL 7 154 메트로놈 Simple Metronome TIL 9 155 블로그 Astro 이관 TIL 156 여둘까 Office Layout TIL 8 157 로그스톤 샵 LogStone Shop TIL 5 158 모래게임 Sandrop TIL 29 159 오픈데이 Openday TIL 1 160 로그스톤 샵 LogStone Shop TIL 6 161 모래게임 Sandrop TIL 30 162 오픈데이 Openday TIL 2 163 모래게임 Sandrop TIL 31 164 Unreal Engine MCP TIL 1 165 모래게임 Sandrop TIL 32 166 소리꽃 KeyBloom TIL 27 167 콘티온 ContiOn TIL 11 168 오픈데이 Openday TIL 3 169 로그스톤 샵 LogStone Shop TIL 7 170 모래게임 Sandrop TIL 33 171 소리꽃 KeyBloom TIL 28 172 오픈데이 Openday TIL 4 173 여둘까 Office Layout TIL 9 174 오픈데이 Openday TIL 5 175 ScorePlayer TIL 1 176 모래게임 Sandrop TIL 34 177 BuildingHub TIL 1 178 ScorePlayer TIL 2 179 여둘까 Office Layout TIL 10 180 BuildingHub TIL 2 181 ScorePlayer TIL 3 182 Unreal Engine MCP TIL 2 183 궁 미로 PalaceMaze TIL 1 184 메트로놈 Simple Metronome TIL 10 185 메트로놈 Simple Metronome TIL 11 186 오픈데이 Openday TIL 6 187 BuildingHub TIL 3 188 Obsidian vault에서 코드만 빼기 — directory junction 189 ScorePlayer TIL 4 190 여둘까 Office Layout TIL 11 191 오픈데이 Openday TIL 7 192 URL은 바뀔 수 있는 것에 묶지 않는다 193 도구는 기능이 아니라 내 일에 맞는지로 고른다 194 돌이키기 힘든 변경은 작게 먼저 확인한다 195 궁 미로 PalaceMaze TIL 2 196 궁 미로 PalaceMaze TIL 3 197 소리꽃 KeyBloom TIL 29 198 오픈데이 Openday TIL 8 199 BuildingHub TIL 4 200 ScorePlayer TIL 5 201 로그스톤 샵 LogStone Shop TIL 8 202 여둘까 Office Layout TIL 12 203 오픈데이 Openday TIL 9 204 BuildingHub TIL 5 205 MiniMacro TIL 3 206 소리꽃 KeyBloom TIL 30 207 오픈데이 Openday TIL 10 208 여둘까 Office Layout TIL 13 209 소리꽃 KeyBloom TIL 31 210 궁 미로 PalaceMaze TIL 4 211 로그스톤 샵 LogStone Shop TIL 9 212 로그스톤 샵 LogStone Shop TIL 10 213 소리꽃 KeyBloom TIL 32