들어가며
폴백을 기본으로 두자는 쪽과, 조용한 실패를 키우니 걷자는 쪽으로 팀에서 대치했습니다.
폴백은 값이 없거나 이상할 때 대신 쓰는 값입니다. 이름이 안 오면 "이름 없음"을 보여주고, 사진이 없으면 회색 상자를 띄우는 그런 것들이죠. 화면이 통째로 터지는 걸 막아주는 안전망이지만, 너무 많으면 뭔가 잘못됐다는 사실 자체가 안 보이게 됩니다.
말로는 안 끝났습니다. 그런데 마침 그날 작업에 양쪽 증거가 다 있었어요. 폴백이 버그를 감춘 사례와, 폴백이 없어 화면이 깨진 사례를 하루에 다 만났습니다.
대치한 지점
상대의 논거는 두 개였고 둘 다 반박하기 어려웠습니다.
하나는 장애를 막는 게 우선이라는 것. 폴백이 없으면 값 하나 없을 때 화면이 통째로 터지고, 깨진 화면보다 조금 이상한 화면이 낫다는 겁니다. 다른 하나는 오픈 일정이 급하다는 것. 지금은 안전망을 걷을 때가 아니고 나중에 정리하자는 거였고요.
제 논거는 하나였습니다. 폴백은 실패를 삼킨다. 값이 안 와도 화면이 그럴듯하게 나오면 아무도 모르고, 그 상태로 오픈하면 문제가 사용자 쪽에서 발견된다는 것이었습니다.
폴백이 감춘 것
학생이 캔버스에 뭔가를 했는지 판정하는 함수가 있습니다. 조작 기록을 세어서 판단해요.
const hs = projectState.historyStack
if (!Array.isArray(hs) || hs.length === 0) return false
return hs.some((item: any) => String(item?.type).toLowerCase() !== 'init')
String(item?.type) 이 폴백입니다. 조작 종류가 없어도 터지지 않게 문자열로 감싼 거죠.
조작 기록은 용량을 아끼려고 압축해서 저장합니다. 압축하면 통째로 노드 하나가 되고, 그 노드엔 조작 종류가 없어요.
[{ __hv: 3, z: 'H4sIAAAAA...' }]
String(undefined).toLowerCase() // "undefined"
"undefined" !== "init" // → true
캔버스에 들어가기만 해도 "활동 있음"이 됐습니다. 실제 데이터를 찍어보니 학생 7명 전원이 그랬어요.
문제는 이게 에러를 안 냈다는 겁니다. 버튼이 활성화되고, 눌러서 들어가면 "활동 내역이 없어요"가 떴습니다. 두 화면이 같은 함수를 쓰면서 반대 답을 냈는데 — 다른 쪽은 이미 풀린 기록을 넘기고 있었어요.
폴백이 없었다면
undefined.toLowerCase()에서 바로 터졌을 겁니다. 개발 중에 알았겠죠.
고칠 때 폴백을 좁혔습니다. String() 으로 감싸는 대신 타입을 확인하고, 아니면 세지 않게요.
return stack.some((item: any) => {
const t = item?.type
return typeof t === 'string' && t.toLowerCase() !== 'init'
})
터지지도 않고 잘못 세지도 않습니다.
폴백이 없어서 깨진 것
같은 날, 반대 방향 문제도 나왔습니다.
학생 화면은 조작이 있을 때만 캔버스 사진을 찍습니다. 의도된 설계예요.
/**
* → 조작 없이 보기만 했거나 갓 진입한 슬라이드는 캡처를 건너뛰고 원본 썸네일을 유지.
*/
function isCanvasUntouched(root: CaptureRoot): boolean { ... }
주석이 "원본 썸네일을 유지"라고 말하는데, 유지할 원본을 아무도 연결하지 않았습니다. 학생이 손대지 않은 페이지는 리포트에서 백지가 됐어요.
썸네일 컴포넌트에는 대체 그림 단계가 이미 있었습니다. 학생 그림이 없으면 대체 그림, 그것도 없으면 안내 문구. 리포트만 대체 그림을 안 넘기고 있었고, 연결하니 선생님이 만든 원본 캔버스가 나왔습니다.
여기선 폴백이 정답이었어요. 값이 없는 게 정상인 상황이고 — 조작을 안 했으니까요 — 대신 보여줄 게 명확히 있었습니다.
재미있는 건 같은 함수 안에 잘 만든 폴백이 이미 있었다는 겁니다. 조작 신호를 못 읽으면 false 로, 그러니까 "일단 찍는다"로 기울게 해뒀어요. 잘못 찍으면 사진 한 장이 남고, 잘못 건너뛰면 기록이 사라지니까요.
모를 때 덜 잃는 쪽으로 기우는 것. 폴백을 쓸 줄 아는 사람이 쓴 코드였습니다.
그런데 그 아래 단계에서 대체물을 연결하는 걸 잊었습니다. 문제는 폴백의 유무가 아니라 폴백 사슬이 끝까지 이어졌는지였어요.
무엇이 갈랐나
두 사례를 나란히 놓으니 차이가 보였습니다.
압축 기록 쪽은 값이 없는 게 비정상이었어요. 있어야 할 게 안 온 거니까요. 그런데 폴백이 "활동 있음"이라는 틀린 사실을 만들었고, 에러도 로그도 없어서 아무도 몰랐습니다. 결과는 전원 오판.
손대지 않은 캔버스 쪽은 값이 없는 게 정상이었습니다. 조작을 안 했으니 없는 게 맞죠. 폴백이 만든 건 "원본 캔버스"라는 맞는 대체물이었고, 실패를 알 필요조차 없었습니다.
폴백이 "없어도 되는 값"을 채우는가, "잘못된 사실"을 만드는가.
값이 없는 게 정상 상태이고 대체물이 그 상태를 정직하게 표현하면 폴백을 둡니다. 값이 없는 게 이상 신호인데 폴백이 그럴듯한 값을 지어내면 두지 않고요. 차라리 터지게 두거나, 최소한 "모른다"를 표현할 자리를 만듭니다.
"많이 두느냐 적게 두느냐"가 아니라 무엇을 채우느냐의 문제였습니다.
"모른다"를 놓을 자리
같은 날 세 번째 사례가 이 기준을 확인해 줬습니다.
서버는 채점 결과를 세 값으로 보냅니다. true 는 정답, false 는 오답, 그리고 null 은 해당 없음 — 채점 대상이 아니거나 아직 안 풀었다는 뜻이에요.
화면 쪽에는 null 에 해당하는 상태가 없었습니다. 정답·오답·대기 셋뿐이라 모르는 것을 전부 어딘가로 밀어 넣고 있었어요.
표현할 수 있는 상태가 부족하면 폴백은 반드시 거짓말을 합니다.
값이 없다는 걸 말할 방법이 없으니 아무 값이나 고르게 되니까요. 그래서 폴백을 줄이는 작업은 대개 상태를 늘리는 작업과 짝입니다. null 을 그대로 받을 자리를 만들고 나니 폴백이 필요 없어졌습니다.
일정 논거는 어떻게 됐나
"오픈이 급하니 나중에 정리하자"는 반박하기 어려웠습니다. 실제로 폴백을 걷는 작업은 시간이 들어요. 걷어낸 자리마다 "그럼 이 경우엔 뭘 보여줄 건가"를 정해야 하니까요.
다만 그날 걷어낸 폴백들이 만든 결과를 보고 판단이 조금 기울었습니다. 압축 기록 문자열 변환은 학생 전원 오판을 드러냈고, 답안 생김새로 슬라이드 종류를 짐작하던 코드는 서버가 준 종류를 추측이 이기고 있다는 걸 보여줬습니다. 흔적 여덟 가지로 열람 여부를 추측하던 코드는 서버가 "안 봤다"고 한 걸 뒤집고 있었고요.
셋 다 오픈 전에 발견됐습니다. 안 걷었으면 사용자 쪽에서 나왔을 것들이에요.
결론은 전부 걷자도 전부 두자도 아니었습니다. 위 기준으로 갈라서, 거짓을 만드는 쪽부터 걷기로 했습니다.
배운 것
틀린 질문
개수의 문제로 놓고 이야기하니 대치가 안 풀렸습니다. 한쪽은 안전을, 한쪽은 정직함을 말하는데 같은 축 위에 있지 않았어요.
무엇을 채우는 폴백인가로 바꾸니 둘 다 답이 나왔습니다. 같은 날 코드에서 한쪽은 두는 게 맞고 한쪽은 걷는 게 맞았으니, 애초에 하나의 답이 있는 질문이 아니었던 거죠.
방어와 침묵은 다르다
String(undefined) 자체는 나쁘지 않았습니다. 그게 "undefined"라는 멀쩡해 보이는 문자열을 만들어 아무도 이상한 줄 모르게 한 게 나빴어요. 같은 방어를 typeof t === 'string' 으로 쓰면 터지지도 않고 지어내지도 않습니다.
둘 다 "폴백"이라는 한 단어로 묶여 있어서 대화가 엉켰습니다. 용어를 나누고 나서야 논점이 잡혔어요.
상태를 먼저 늘리는 순서
서버는 null 로 "모른다"를 말하는데 화면 쪽엔 그 자리가 없었습니다. 이 경우 폴백을 아무리 잘 짜도 정답 아니면 오답 중 하나를 골라야 합니다.
순서를 바꾸면 폴백만 걷어내고 화면이 더 이상해집니다.
일정 논거는 비용으로만 반박된다
"급하다"에 "그래도 해야 한다"로 답하면 대치가 계속됩니다. 그날 걷어낸 폴백 셋이 오픈 전에 드러낸 오판을 근거로 들 수 있었어요.
미루는 비용을 구체적인 사례로 말할 수 있을 때만 일정 논거를 넘어설 수 있습니다.
남은 것
기준은 잡혔지만 경계가 애매한 자리는 여전히 있습니다.
서버가 "곧 보내주기로 한" 값이 그렇습니다. 지금은 폴백이 필요하고 나중엔 없어야 하는데, 걷는 시점을 어떻게 기억할지가 문제예요. 옛 데이터 호환용 폴백도 언제까지 둘지 정해지지 않았습니다.
앞의 것은 폴백 옆에 왜 두었는지와 언제 걷을지를 주석으로 남기기로 했습니다. 그날 걷어낸 코드들이 전부 "서버가 안 주던 시절의 방어책"이었는데, 그 사실이 어디에도 안 적혀 있어서 살아 있는 코드인지 판단하는 데 시간이 걸렸거든요.
어떻게 확인했나
학생 7명의 저장 기록을 직접 찍어 압축 노드가 전원 "활동 있음"으로 세어지는 걸 봤습니다. 추론이 아니라 관측이에요.
판정 함수에 8건, 폴백 연결에 3건, 걷어낸 추측에 10건의 테스트를 붙였습니다. 마지막 것은 걷어낸 자리에 같은 추측이 다시 들어오는 걸 막는 게 목적입니다.
그리고 고친 걸 되돌려서 회귀 케이스가 무너지는지 확인했습니다. 통과하는 테스트는 그 자체로는 아무것도 증명하지 않으니까요.