겨리

혼자 끄는 쟁기는 호리, 둘이 끄는 쟁기는 겨리

모른다고 말할 자리가 없었다

웹게임에서 실기기 첫 결제가 터졌다. 서버 로그에 필요한 값이 없다고 찍혀 있었다. 서명 검증은 통과한 상태였고 틀린 건 필드 이름뿐이었다.

애플이 내려주는 영수증의 키는 단어를 붙여 쓰는 방식이고, 그걸 감싸는 파이썬 라이브러리는 그 키를 그대로 속성 이름으로 쓴다. 나는 파이썬 관례대로 밑줄을 넣어 읽고 있었다. 남의 데이터에 우리 집 이름표를 붙인 셈.

읽던 필드는 셋이었다. 거래 번호, 상품 번호, 그리고 환불 시각.

앞의 둘은 시끄럽게 죽었다. 없으면 거래를 못 만드니까 그 자리에서 요청이 멈춘다. 세 번째는 안 죽었다. 속성이 없으면 기본값을 주라고 적어뒀는데 그 기본값이 비어 있음이었고, 코드는 비어 있음을 환불된 적 없음으로 읽는다. 앞의 두 줄만 고쳤다면 환불받은 영수증이 그대로 다시 지급을 받았을 것이다. 에러도 안 나고 로그도 안 남는 종류.

내가 물은 건 이 거래가 환불됐느냐였다. 돌아온 답은 아니오가 아니라 그 질문을 못 알아들었음이었다. 두 답이 같은 모양으로 생겼을 뿐.

기본값을 뺐다. 없으면 그 줄에서 터지게. 다음에 라이브러리가 이름을 또 바꾸면 조용히 지나가는 대신 그 자리에서 멈출 것. 돈이 오가는 자리에서 조용한 통과는 고를 만한 선택지가 아니다.

같은 날 주식 앱에서도 결제 쪽을 만졌다. 이쪽은 안드로이드. 서명된 원문을 서버로 보낼 때 어느 칸에 담을지를 앱이 직접 지목하는 구조였다. 그 칸 이름을 찾느라 며칠 돌았다. 네이티브 코드에서 값을 담는 줄도 봤고 타입 선언도 봤고 실제 배포된 번들을 뜯어 그 이름이 들어 있는 것까지 확인했다. 셋 다 같은 이름. 그런데 실기기에서 올라온 요청에는 그 칸이 비어 있었다.

이름을 한 번 더 찍는 대신 방식을 바꿨다. 앱은 결제 객체를 통째로 보내고, 서버가 그 안을 훑어 값의 모양으로 찾는다. 문자열이고, 풀어보면 구조가 있고, 그 안에 결제 토큰이 들어 있으면 그게 원문이다. 이름이 뭐든 상관없이. 찾은 다음 서명 검증을 붙여 통과하는 첫 후보를 쓴다.

이름으로 찾으면 상대가 이름을 바꿀 때마다 끊긴다. 모양으로 찾으면 상대가 이름을 바꿔도 안 끊긴다. 대신 우리가 값의 생김새를 알아야 하고, 그건 이름보다 덜 변한다.

여기까지 정리하고 나서 그 앞 커밋을 다시 봤다. 서명 원문을 못 찾았을 때 서버가 뭐라고 답했는지. 무효한 결제라고 답하고 있었다. 그러면 앱은 이 거래가 끝났다고 보고 닫아버린다. 결제는 이미 됐고 지급은 안 되고 거래는 닫힌 상태로 남는다.

우리가 못 찾은 걸 상대가 잘못한 걸로 답한 셈. 아직 확인 못 했다는 답으로 바꿨다. 그러면 거래가 살아 있어서 다음 시도에 지급되거나, 아무도 안 건드리면 며칠 뒤 스토어가 알아서 환불한다. 어느 쪽이든 돈만 가져가는 결말은 안 난다.

밤에 하나가 더 나왔다. 웹게임에서 이미 결제를 마친 사람이 구매 복원을 누르면 복원할 구매 내역이 없다고 뜬다는 제보. 복원 함수가 돌려주는 건 이번에 새로 지급한 개수 하나뿐. 이미 다 지급된 사람은 새로 줄 게 없으니 0이고, 화면은 0을 아무것도 못 찾았다로 읽는다. 안심시키려고 만든 버튼이 정확히 반대를 말했다.

본 것과 새로 준 것을 나눠서 돌려주게 고쳤다. 세는 김에 남의 계정에 이미 지급된 영수증은 뺐다 — 그건 이 사람이 산 게 아니니까.

오늘 나온 세 자리가 다 같은 모양이었다. 속성이 없다는 걸 환불이 아니다로, 칸을 못 찾았다는 걸 무효한 결제다로, 새로 준 게 없다는 걸 산 게 없다로 읽었다. 모른다는 답을 담을 칸이 없어서 아니다 칸에 들어간 것.

프로그램은 대개 두 칸으로 답한다. 맞다와 아니다. 모른다를 세 번째 칸으로 두려면 일부러 만들어야 하고, 안 만들면 자동으로 아니다 쪽에 붙는다. 그리고 아니다는 조용하다. 아무 일도 안 일어나는 게 아니다의 생김새니까.

오늘 고친 건 전부 아니다를 시끄럽게 만드는 쪽이었다. 없으면 터지게, 못 찾았으면 보류라고 말하게, 0이면 어느 0인지 밝히게.