조회에 실패했을 때 "차단 없음"으로 넘겨짚지 않기
랜챗(랜덤 1:1 화상채팅)에는 차단 기능이 있다. 사용자가 상대를 차단하면 다시 매칭되지 않아야 한다는, 아주 단순한 요구다. 구현도 그만큼 단순했다 — 입장 시점에 그 사용자와 얽힌 차단 관계를 DB에서 읽어 메모리에 올리고, 매칭 필터가 그 목록을 참고한다.
그런데 이 흐름을 다시 들여다보다가 조회 함수 하나의 계약이 마음에 걸렸다. 실패했을 때 무엇을 돌려주고 있었나 하는 질문이었다.
fail-open과 fail-closed, 두 가지 실패 방식
안전이나 보안과 관련된 판단을 내리는 코드에는 항상 두 갈래의 실패 방식이 있다. 판단에 필요한 정보를 얻지 못했을 때 일단 통과시키는 쪽이 fail-open이고, 반대로 일단 막는 쪽이 fail-closed다. 어느 쪽이 맞는지는 상황마다 다르다. 가용성이 최우선인 시스템이라면 fail-open이 합리적일 수 있고, 안전이 최우선인 시스템이라면 fail-closed가 맞다. 문제는 이 선택이 명시적으로 이뤄지지 않고, 반환값의 타입을 얼버무리는 과정에서 슬쩍 한쪽으로 정해져 버릴 때다. 이번 조회 함수가 그런 경우였다 — 아무도 fail-open을 하겠다고 결정한 적이 없는데, 코드는 이미 fail-open으로 짜여 있었다.
빈 배열이라는 함정
문제의 조회 함수는 best-effort였다. 성공하면 차단 관계 배열을, 실패해도 빈 배열 []을 돌려주도록 짜여 있었다. 호출부만 보면 편했을 것이다. 에러 분기를 따로 안 둬도 되고, 항상 배열 하나만 받으면 끝이었으니까.
문제는 그 편의가 의미를 지워버렸다는 점이다. 호출부 입장에서 "차단 관계가 하나도 없다"와 "조회에 실패해서 알 수 없다"가 완전히 같은 값으로 도착한다. 둘을 구분할 방법이 없다. 그러면 DB가 잠깐 흔들리는 순간 입장한 사용자는 차단 목록이 비어 있는 상태로 매칭 큐에 들어간다. 차단해 둔 상대와 다시 만날 수 있다는 뜻이다. 전형적인 fail-open이었다.
이런 계약은 사실 API 설계에서 흔히 나타나는 함정이기도 하다. "실패하면 안전한 기본값을 준다"는 문장 자체는 그럴듯하게 들린다. 하지만 무엇이 안전한 기본값인지는 그 반환값이 나중에 어디에 쓰이는지에 따라 완전히 달라진다. 단순히 로그에 남기는 용도라면 빈 배열이 무해할 수 있지만, 매칭 여부를 가르는 필터의 입력이라면 빈 배열은 전혀 안전하지 않다. 함수를 작성하는 시점에는 그 반환값이 나중에 어떤 판단의 근거로 쓰일지 다 예측할 수 없다. 그래서 애초에 "성공"과 "실패"를 같은 타입으로 뭉뚱그리지 않는 편이 더 안전하다.
고친 것 1 — 실패를 값으로 구분한다
가장 먼저 바꾼 건 반환 계약이었다. 성공하면 배열(빈 배열도 정상적인 성공이다), 실패하면 null을 돌려주도록 나눴다. 호출부는 null을 받으면 더 이상 "차단이 없다"로 넘겨짚지 않는다. 대신 입장 자체를 막는다.
이게 fail-closed다. 차단 목록을 확정하지 못한 채로 매칭시키느니, 그 순간엔 입장을 막는 편이 낫다는 판단이었다. 사용자 입장에서는 잠깐 불편할 수 있지만, 차단해 둔 사람과 다시 얼굴을 마주치는 것보다는 훨씬 낫다.
고친 것 2 — 재시도는 짧고, 딱 한 번만
fail-closed로 바꾸면 그만큼 실패가 사용자에게 직접 영향을 준다. 그러니 일시적인 흔들림 정도는 재시도로 흡수해야 했다. 여기서 간격이 은근히 까다로웠다.
지연 없이 곧바로 다시 던지면 연결 수준에서 난 실패는 거의 그대로 재현된다. 재시도의 의미가 없다. 반대로 간격을 길게 잡으면 입장 처리 전체에 걸려 있는 시간 예산을 재시도 하나가 잠식한다. 그래서 200ms 간격으로 딱 한 번만 재시도하고, 그래도 실패하면 미련 없이 포기하고 null을 반환하도록 정리했다.
재시도 설계에서 흔히 부딪히는 트레이드오프이기도 하다. 재시도가 의미 있으려면 실패의 원인이 순간적인 흔들림이어야 한다. 연결이 잠깐 끊기는 것처럼 짧게 스쳐 지나가는 문제라면 아주 짧은 간격을 두고 다시 시도하는 것만으로도 회복될 여지가 있다. 하지만 설정 오류나 권한 문제처럼 근본적인 원인이라면 몇 번을 재시도해도 매번 같은 이유로 실패한다. 그런 경우 재시도 횟수를 늘리는 건 회복을 돕는 게 아니라, 실패가 확정되기까지 걸리는 시간만 늘릴 뿐이다. 그래서 재시도 횟수를 무작정 늘리기보다, 실패가 확정됐을 때 어떤 안전한 기본값으로 넘어갈지를 먼저 정해두는 편이 더 중요하다는 결론이었다.
고친 것 3 — 차단을 저장할 때 FK 창을 닫는다
이건 조회가 아니라 저장 쪽에서, 리뷰 과정에서 따로 잡힌 구멍이었다. 차단 기록을 DB에 넣는 코드가 사용자 행이 생성되기를 기다리지 않고 곧바로 INSERT를 시도하고 있었다. 마침 익명 사용자 행은 매칭 시점에 지연 생성되도록 바뀐 참이라, 이 창이 더 좁아진 상태였다.
외래키 위반으로 차단 저장이 실패하면 메모리상의 차단만 남는다. 그런데 메모리 상태는 재접속하면 사라진다. 즉 사용자는 분명히 차단 버튼을 눌렀고 그렇게 믿고 있지만, 다음 접속부터는 다시 매칭될 수 있는 상태였다. 양쪽 사용자 행의 생성이 끝난 뒤에 차단을 저장하도록 순서를 고치고, 이 경로에 회귀 테스트를 붙였다.
이 문제는 사실 순서 의존성이라는 훨씬 일반적인 버그 패턴의 한 사례이기도 하다. 두 자원 사이에 참조 관계가 있을 때, 그 생성 순서를 코드 어디에도 명시적으로 강제하지 않으면 어느 한쪽이 먼저 실행되는 경로가 언젠가는 반드시 나타난다. 특히 지연 생성처럼 한쪽의 생성 시점이 이벤트에 따라 달라지는 구조에서는 이 창이 더 넓어지기 쉽다. 두 자원의 생성 시점이 서로 다른 조건에 매여 있을수록, 그 순서를 코드로 명시하지 않는 한 우연히 맞아떨어지는 순서에만 의존하게 된다.
같은 김에 손본 것이 하나 더 있다. 서버가 에러를 통지했을 때 좌석을 반납하도록 만들었다. 실패한 입장이 매칭 큐의 자리를 계속 붙잡고 있으면 안 되기 때문이다.
교훈
best-effort 계약, 그러니까 "실패하면 빈 값을 준다"는 편한 설계다. 하지만 그 반환값이 보안이나 안전 관련 결정의 근거로 쓰이는 순간 이 편의는 성립하지 않는다. "없음"과 "모름"은 반드시 다른 값이어야 한다. 같은 값으로 뭉뚱그리는 순간 호출부는 둘을 구분할 도리가 없다.
fail-open과 fail-closed는 취향의 문제가 아니다. 무엇을 보호하고 있는가로 정해진다. 차단은 사용자가 "이 사람과 다시 마주치지 않겠다"고 명시적으로 한 요청이다. 여기서 실패의 기본값은 통과가 아니라 차단이어야 맞다.
그리고 메모리에만 반영되고 영속화에는 실패한 상태가 가장 나쁜 종류의 버그를 만든다는 것도 다시 확인했다. 지금 당장은 아무 문제 없이 동작하는데, 재접속하면 조용히 사라지기 때문이다. 사용자도 개발자도 한참 뒤에야 알아챈다.