잘 만든 시스템과 그렇지 못한 시스템의 차이는 성공할 때가 아니라 실패할 때 드러난다. 요청이 순조롭게 처리될 때야 어떻게 응답하든 티가 나지 않지만, 무언가 잘못되었을 때 그 실패를 어떻게 알려주느냐가 그 시스템을 다루는 사람의 하루를 좌우한다. 나는 실패를 대충 뭉뚱그려 알려주는 시스템 앞에서 원인을 짐작하느라 여러 밤을 태웠고, 그 경험이 에러 응답을 성공 응답만큼, 어쩌면 그보다 더 정성껏 설계해야 한다는 믿음을 남겼다.
이번 편에서는 요청이 실패했을 때 그 실패를 어떻게 응답으로 표현할지를 다룬다. 실패의 갈래를 상태 코드로 어떻게 나누는지, 무엇이 왜 잘못되었는지를 응답 본문에 어떻게 담는지, 그리고 그 메시지를 사람과 프로그램이 모두 다룰 수 있게 하려면 무엇이 필요한지를 살펴본다. 에러 응답을 소홀히 하면 실패가 조용히 숨거나 원인이 뭉개져, 문제를 고치기는커녕 찾는 데만 긴 시간을 쓰게 된다.
실패도 하나의 응답이다
에러 응답 설계의 첫걸음은 실패 역시 정상적인 응답의 한 종류로 대하는 것이다. 요청이 뜻대로 처리되지 못하는 일은 예외적 사고가 아니라 늘 일어나는 흔한 상황이다. 잘못된 값이 들어오고, 없는 자원을 찾고, 권한이 모자란 요청이 온다. 이 모든 실패는 시스템이 마땅히 답해야 할 정상적인 경우이지, 감추거나 얼버무릴 비상사태가 아니다. 나는 실패를 예외가 아니라 설계의 정식 대상으로 놓는다.
가장 흔한 잘못은 실패를 성공인 척 포장하는 것이다. 처리가 안 되었는데도 성공을 뜻하는 응답을 돌려주면서, 본문 어딘가에 슬쩍 잘못되었다는 표시를 끼워두는 방식이다. 이러면 겉으로는 성공이니 클라이언트는 응답을 성공으로 받아들이고, 정작 본문 속 실패 표시는 놓치기 쉽다. 실패가 성공의 옷을 입고 있으니 자동화된 처리는 그것을 걸러내지 못한다. 나는 이 방식을 가장 위험한 습관으로 꼽는다.
실패에는 실패를 뜻하는 신호가 분명히 붙어야 한다. 응답의 첫머리에서 이미 이것이 실패임을 알 수 있어야, 클라이언트는 본문을 뜯어보기 전에 성공과 실패의 갈림길에서 바른 쪽으로 향한다. 이 신호 역할을 하는 것이 바로 상태 코드다. 성공과 실패를 코드 단계에서 갈라놓으면, 클라이언트의 처리 흐름이 성공 경로와 실패 경로로 깔끔하게 나뉜다.
실패를 정직하게 드러내는 것은 방어의 문제이기도 하다. 실패가 성공으로 위장되면 잘못된 데이터가 아무 저항 없이 시스템 안쪽으로 흘러든다. 클라이언트는 받은 것을 성공으로 믿고 다음 단계로 넘기고, 그렇게 오염된 값이 여러 곳으로 번진다. 실패를 실패라고 분명히 알리면, 그 자리에서 흐름이 멈추고 잘못이 더 퍼지지 않는다. 정직한 실패 신호는 그 자체로 하나의 안전장치다.
또한 실패 응답은 클라이언트가 다음에 무엇을 할지 판단할 근거가 된다. 같은 실패라도 그것이 잠깐 뒤 다시 시도하면 풀릴 일인지, 요청을 고쳐야 할 일인지, 아예 손쓸 수 없는 일인지에 따라 클라이언트의 대응이 완전히 달라진다. 잘 설계된 에러 응답은 이 판단에 필요한 정보를 담아, 클라이언트가 무작정 재시도하거나 성급히 포기하지 않도록 이끈다. 실패 응답은 곧 다음 행동의 안내판이다.
나는 그래서 에러 응답을 만들 때, 이 응답을 받은 쪽이 무엇을 알고 싶어 할지를 먼저 그려본다. 무엇이 잘못되었는지, 왜 그런지, 어떻게 하면 풀리는지. 이 세 물음에 답할 수 있는 응답이 좋은 에러 응답이다. 실패를 그저 막다른 벽으로 두지 않고, 그 벽에 다음으로 가는 길을 적어두는 것이 설계자의 몫이라고 나는 생각한다.
상태 코드로 실패의 갈래를 나눈다
실패를 알리는 첫 신호인 상태 코드는 실패의 성격을 큰 갈래로 나눈다. 크게 보면 요청을 보낸 쪽의 잘못으로 실패한 경우와, 요청을 받은 쪽의 잘못으로 실패한 경우가 갈린다. 앞쪽은 400번대로, 뒤쪽은 500번대로 표현한다. 이 구분은 단순해 보여도 실전에서 대단히 중요하다. 잘못이 어느 편에 있느냐에 따라 고칠 사람과 고칠 자리가 완전히 달라지기 때문이다.
400번대는 요청 자체에 문제가 있다는 뜻이다. 값의 형식이 틀렸거나, 필요한 항목이 빠졌거나, 존재하지 않는 자원을 가리켰거나, 권한이 모자란 경우다. 이때 잘못은 요청을 보낸 쪽에 있으므로, 같은 요청을 그대로 다시 보내봐야 결과는 똑같다. 클라이언트가 요청을 고쳐야 비로소 풀린다. 나는 400번대를 마주하면 재시도가 아니라 요청을 뜯어고칠 신호로 받아들인다.
이 400번대 안에서도 갈래가 갈린다. 요청의 형식이 아예 잘못되어 해석할 수 없는 경우, 인증이 되지 않아 누구인지 알 수 없는 경우, 누구인지는 알지만 권한이 없어 막힌 경우, 가리킨 자원이 존재하지 않는 경우가 각기 다른 코드로 나뉜다. 이 세분된 코드를 정확히 골라 쓰면, 클라이언트는 본문을 열어보기도 전에 무엇을 손봐야 할지 큰 방향을 잡는다. 코드 하나가 이미 상당한 정보를 준다.
500번대는 반대로 요청을 받은 쪽이 처리 중에 넘어진 경우다. 요청 자체는 멀쩡했는데 서버 내부에서 문제가 터졌거나, 의존하는 다른 시스템이 응답하지 않아 처리를 마치지 못한 상황이다. 이때 잘못은 서버 쪽에 있으므로 클라이언트가 요청을 고쳐도 소용이 없고, 서버가 회복되면 같은 요청이 잠시 뒤 성공할 수도 있다. 그래서 500번대는 대개 잠시 뒤 다시 시도해볼 만한 신호가 된다.
이 두 갈래를 정확히 나누는 것이 왜 중요한지는 대응 방식이 갈리는 데서 드러난다. 클라이언트 잘못을 서버 잘못으로 잘못 표시하면, 클라이언트는 요청을 고칠 생각은 않고 부질없이 재시도만 반복한다. 반대로 서버 잘못을 클라이언트 잘못으로 표시하면, 멀쩡한 요청을 보낸 쪽이 자기 잘못이 아닌 문제를 붙들고 헤맨다. 나는 코드를 고를 때 잘못이 정말 어느 편에 있는지를 먼저 냉정하게 따진다.
다만 상태 코드만으로는 부족하다. 코드는 실패의 큰 갈래를 알려줄 뿐, 구체적으로 어떤 값이 왜 잘못되었는지까지는 담지 못한다. 같은 코드라도 그 뒤에 숨은 사연은 제각각이다. 형식이 틀렸다는 하나의 코드 아래에도 어떤 항목이 어떻게 틀렸는지는 수없이 갈린다. 그래서 코드는 첫 신호로 삼되, 그 구체적인 내막은 본문에 따로 실어야 한다. 코드와 본문은 역할이 다르다.
본문에 실패의 내막을 담는다
상태 코드가 실패의 큰 갈래를 알렸다면, 본문은 그 실패의 구체적인 내막을 담는 자리다. 나는 에러 본문에 최소한 세 가지를 넣으려 한다. 프로그램이 갈래를 구분할 수 있는 기계용 식별 코드, 사람이 읽고 상황을 파악할 수 있는 설명, 그리고 어느 부분이 왜 문제였는지를 짚는 세부 정보다. 이 셋이 있으면 실패를 사람도 프로그램도 다룰 수 있게 된다.
기계용 식별 코드는 실패의 종류를 프로그램이 정확히 갈라내게 해준다. 상태 코드는 갈래가 성기어서, 같은 상태 코드 아래 여러 다른 실패가 뭉쳐 있다. 그 안에서 이것이 어떤 실패인지를 구분하려면, 응답마다 고유한 식별 문자열을 함께 주는 것이 좋다. 클라이언트는 이 식별 코드를 보고 상황별로 다른 처리를 분기한다. 나는 이 코드를 사람이 아니라 프로그램을 위한 실패의 이름표로 여긴다.
사람이 읽을 설명은 그 실패를 마주한 개발자나 사용자를 위한 것이다. 무엇이 잘못되었는지를 일상의 문장으로 풀어, 응답을 들여다본 사람이 곧바로 상황을 짐작할 수 있게 한다. 이 설명은 프로그램의 분기 조건으로 쓰라고 두는 것이 아니라, 사람의 이해를 돕는 안내다. 나는 이 문장을 쓸 때, 이것을 처음 보는 사람이 다른 문서를 뒤지지 않고도 무슨 일인지 알 수 있는지를 기준으로 삼는다.
세부 정보는 실패의 지점을 더 좁혀준다. 여러 항목을 담은 요청이 형식 검증에서 걸렸다면, 정확히 어떤 항목이 어떤 규칙을 어겼는지를 항목별로 나열해주는 것이다. 이 세부 정보가 있으면 클라이언트는 사용자에게 어느 칸이 잘못되었는지를 짚어 보여줄 수 있다. 실패를 뭉뚱그리지 않고 어느 지점에서 왜 걸렸는지를 조목조목 밝히는 것이, 세부 정보가 하는 일이다.
이런 에러 본문의 형태는 시스템 전체에서 하나로 통일하는 것이 중요하다. 어떤 실패는 이런 모양으로, 다른 실패는 저런 모양으로 온다면 클라이언트는 실패마다 다른 방식으로 그것을 해석해야 한다. 나는 성공 응답의 형태를 맞추는 것 못지않게, 실패 응답의 형태도 하나의 정해진 틀로 못박는다. 어떤 요청이 실패하든 같은 자리에서 같은 방식으로 원인을 꺼낼 수 있게 하는 것이 핵심이다.
본문에 담지 말아야 할 것도 있다. 실패의 원인을 밝힌답시고 시스템 내부의 세세한 사정이나 민감한 정보를 그대로 노출하면, 그것이 공격자에게 시스템을 들여다보는 창이 된다. 내부에서 어떤 일이 어떻게 어긋났는지 상세히 기록하는 것은 서버 안쪽의 기록으로 남기고, 밖으로 나가는 응답에는 클라이언트가 대응하는 데 필요한 만큼만 담아야 한다. 나는 이 안팎의 경계를 늘 의식한다.
사람과 프로그램을 함께 배려한다
좋은 에러 응답은 두 종류의 독자를 동시에 배려한다. 하나는 그 실패를 코드로 처리해야 하는 프로그램이고, 다른 하나는 그 실패를 눈으로 읽고 원인을 짚어야 하는 사람이다. 이 둘은 원하는 것이 다르다. 프로그램은 갈래를 정확히 나눌 안정된 신호를 원하고, 사람은 상황을 이해할 풀어쓴 설명을 원한다. 에러 응답은 이 서로 다른 요구를 한 응답 안에 함께 담아야 한다.
프로그램을 위한 신호는 변하지 않아야 한다. 클라이언트가 어떤 실패를 특정해 분기하도록 만들어두었는데, 그 실패를 나타내는 식별 코드가 조용히 바뀌면 클라이언트의 분기가 깨진다. 그래서 기계가 읽는 식별 코드는 한번 정하면 함부로 바꾸지 않고 안정되게 유지해야 한다. 나는 이 식별 코드를 클라이언트와 맺은 약속으로 여기고, 성공 응답의 형태를 지키듯 신중히 다룬다.
반대로 사람을 위한 설명은 상황에 맞게 다듬어도 된다. 설명 문장은 분기 조건이 아니라 이해를 돕는 안내이므로, 더 알기 쉽게 고치거나 사용자의 언어에 맞게 바꿔도 클라이언트의 처리가 깨지지 않는다. 여기서 중요한 원칙 하나가 나온다. 프로그램은 절대 이 사람용 설명 문장을 조건으로 삼아 분기해서는 안 된다는 것이다. 문장은 언제든 바뀔 수 있으니, 그것에 기대면 처리가 무너진다.
이 원칙을 지키려면 두 신호의 역할을 처음부터 분리해두어야 한다. 프로그램은 안정된 식별 코드로 갈래를 나누고, 사람은 유연한 설명 문장으로 상황을 읽는다. 각자 자기 몫의 신호만 쓰면, 설명 문장을 아무리 다듬어도 프로그램은 흔들리지 않고 식별 코드가 그대로인 한 클라이언트의 분기도 안전하다. 나는 이 분리를 에러 응답 설계에서 가장 먼저 잡는 뼈대로 삼는다.
사람용 설명을 쓸 때는 그 문장을 읽을 사람의 처지를 헤아리는 것이 좋다. 개발자가 볼 설명이라면 어느 요청의 어느 부분이 문제였는지를 기술적으로 짚어주고, 최종 사용자에게 그대로 노출될 문장이라면 겁주지 않으면서도 다음에 무엇을 하면 되는지를 담담히 안내한다. 같은 실패라도 그것을 누가 읽느냐에 따라 문장의 결이 달라야 한다. 나는 이 독자를 늘 염두에 두고 문장을 고른다.
결국 잘 설계된 에러 응답은 하나의 응답으로 두 세계에 말을 건다. 프로그램에게는 코드와 식별 문자열이라는 또렷한 신호로, 사람에게는 풀어쓴 설명과 세부 정보라는 안내로 말이다. 이 두 갈래의 소통이 한 응답 안에서 어긋나지 않게 나란히 놓일 때, 실패는 더 이상 막막한 벽이 아니라 다음으로 가는 길잡이가 된다. 나는 이 두 독자를 함께 만족시키는 응답을 좋은 에러 응답의 기준으로 둔다.
에러 응답 설계의 원칙
지금까지의 이야기를 관통하는 첫째 원칙은, 실패에는 실패의 신호를 정직하게 붙이라는 것이다. 처리가 안 되었으면 성공의 옷을 입히지 말고 실패를 뜻하는 상태 코드로 답해야 한다. 실패를 성공으로 위장하는 순간, 그 실패는 시스템 곳곳으로 소리 없이 번진다. 나는 어떤 응답을 만들든 이것이 성공인지 실패인지부터 코드 단계에서 분명히 가른다.
둘째 원칙은 잘못이 어느 편에 있는지를 코드로 바르게 가르는 것이다. 요청을 보낸 쪽의 잘못과 받은 쪽의 잘못은 고칠 사람도 대응도 다르므로, 이 둘을 뒤섞으면 엉뚱한 곳에서 헛수고가 벌어진다. 나는 코드를 고를 때마다 이 실패가 정말 어느 편의 책임인지를 자문한다. 편의로 아무 코드나 붙이는 것은, 실패를 알리는 척하면서 실은 오답을 알리는 것과 같다.
셋째 원칙은 기계용 신호와 사람용 설명을 분리해 함께 담는 것이다. 프로그램이 분기할 안정된 식별 코드와 사람이 읽을 유연한 설명을 각각 두고, 프로그램이 사람용 문장에 의존하지 않게 한다. 이 분리가 지켜지면 설명을 자유롭게 다듬으면서도 클라이언트의 처리는 흔들리지 않는다. 나는 이 둘을 섞어 하나로 뭉뚱그리는 설계를 오래 유지되기 어려운 구조로 본다.
넷째 원칙은 실패의 형태를 시스템 전체에서 통일하는 것이다. 모든 실패가 같은 틀로 오면 클라이언트는 한 가지 방식만 익혀 어떤 실패든 다룰 수 있다. 반대로 실패마다 모양이 다르면, 클라이언트는 실패의 수만큼 해석 방식을 늘려야 한다. 나는 성공 응답의 형태를 통일하듯 실패 응답의 형태도 하나의 정해진 틀로 고정해, 실패를 다루는 쪽의 부담을 줄인다.
다섯째 원칙은 밖으로 내보내는 실패에 필요 이상을 담지 않는 것이다. 클라이언트가 대응하는 데 필요한 정보와, 시스템 내부의 은밀한 사정은 다른 것이다. 앞의 것은 응답에 담고, 뒤의 것은 서버 안쪽의 기록으로 남긴다. 실패의 원인을 밝히려다 시스템의 속살까지 드러내면, 그 친절이 곧 약점이 된다. 나는 응답에 담을 것과 기록에만 남길 것을 늘 나누어 다룬다.
정리하면 에러 응답은 실패를 정직한 신호로 알리고, 잘못의 편을 코드로 바르게 가르며, 기계용 신호와 사람용 설명을 분리해 하나의 통일된 틀에 담고, 밖으로는 필요한 만큼만 내보내는 설계다. 실패를 성공만큼 정성껏 다룰 때 비로소 시스템은 문제 앞에서도 다룰 만한 것이 된다. 다음 편에서는 이런 요청과 응답 앞에서 상대가 누구인지를 확인하는 문제, 곧 인증 방식의 큰 그림을 다뤄 보려 한다.