성공 코드 뒤의 계약 위반
110분 안팎
학습 목표
상태 코드 외의 본문·권한 결과를 검증합니다.
개념
성공처럼 보이는 응답에서 무엇을 확인합니까
프로필 조회가 HTTP 200이고 화면에도 오류가 없다는 사실만으로는 본인 정보가 맞게 왔다고 판단할 수 없습니다. nickname이 숫자로 왔거나 요청한 본인 이메일 대신 타인의 이메일이 왔을 수 있습니다. 가입 결과의 count가 문자열이라면 다음 소비 코드가 예상과 다르게 동작할 수 있습니다. 이번 레슨은 전달 성공과 데이터 계약 만족을 분리하고 응답 배열에서 성공 상태 뒤에 숨은 계약 위반의 테스트 ID를 찾아내는 연습입니다.
테스트의 정답은 실제 응답에서 가져오지 않습니다. 테스트 표에서 정한 expectedResult와 expectedCount를 독립 기준으로 사용합니다. 실제 객체에 matches:true 또는 expectedCount:99가 있어도 판정 근거로 삼지 않습니다. 응답이 스스로 정상이라고 선언하는 값은 관찰 대상입니다. 판정기가 잘못된 응답을 거절하는지는 정상 fixture의 타입과 값을 하나씩 바꾸어 확인합니다. 단순히 정상 입력을 수용하는 것보다 결함 검출 능력까지 증명합니다.
형태 검사가 의미 비교를 안전하게 만듭니다
JSON 본문은 null이나 배열일 수도 있습니다. body.result부터 읽으면 null에서 TypeError가 나므로 먼저 배열 아닌 객체인지 확인합니다. 그 다음 result 문자열, count의 비음수 안전 정수, message와 requestId의 공백 아닌 문자열을 확인합니다. Number.isSafeInteger는 숫자 타입과 정수 표현 범위를 함께 검사하며 0은 정상 회원 수입니다. 참 같은 값만으로 필수 여부를 판단하면 0을 누락으로 오해합니다.
타입이 맞으면 result와 count를 독립 기대값과 엄격 비교합니다. 문자열 숫자를 Number로 변환하지 않습니다. 타입 계약 위반을 편의 변환으로 없애면 서버가 잘못 보낸 사실을 잃기 때문입니다. 공백 아닌 message 검사는 설명 문자열이 있다는 뜻이며 문구 전체가 제품의 요구사항대로 표시됐다는 뜻은 아닙니다. 필드의 존재·자료형·업무 의미를 어떤 층에서 확인했는지 각각 말할 수 있어야 합니다.
프로필 응답에는 email과 nickname을 추가로 확인합니다. nickname은 문자열이어야 하고 기대 닉네임과 같아야 합니다. email은 이 테스트가 요청한 본인 주소와 같아야 합니다. 검사 대상 응답의 email을 그대로 expected로 복사하면 타인 조회 결함을 정상이라고 만들 수 있습니다. 세션 주체와 대상 자원을 독립적으로 두는 것이 소유권 테스트의 핵심입니다. 허용된 본인 조회와 거절할 타인 조회를 같은 시나리오로 뭉개지 않습니다.
권한 거절에서도 본문은 검사 대상입니다
403을 반환하면서 타인의 닉네임을 본문에 붙이면 상태 코드가 맞아도 개인정보 미노출 기준을 위반합니다. 401·403 응답에서는 email과 nickname 필드가 없어야 한다는 이번 합의를 적용합니다. 단순히 빈 문자열인지 보는 대신 필드 자체의 존재를 검사합니다. password와 secret은 어느 정상 응답에도 노출하지 않습니다. 상태 코드 검증과 정보 노출 검증을 함께 두어 서로 다른 결함을 잡습니다.
이번 브라우저 문제는 상태 200인 성공 응답만 선별하여 계약을 확인합니다. 401·403 사례를 생략하는 이유는 이미 정상이어서가 아니라 문제의 출력 범위를 성공 상태의 계약 위반으로 한정했기 때문입니다. 로컬 API 검사에서는 거절 상태와 본문의 미노출도 검사합니다. 이번 출력이 NONE이라도 권한 정책 전체가 안전하다는 결론을 내리지 않습니다. 검사 대상에서 제외한 응답과 아직 실행하지 않은 경로를 구별합니다.
테스트 ID를 원인 조사에 연결합니다
입력의 id는 어떤 사례가 위반했는지 찾는 이름입니다. 이번 실습은 위반 ID를 입력 순서대로 한 줄씩 출력하고 위반이 없으면 NONE 한 줄을 출력합니다. 동일한 ID가 반복되면 각 입력 행은 별도 관찰이므로 중복 출력합니다. 행의 순서를 정렬하거나 Set으로 중복 제거하지 않습니다. 테스트 러너와 달리 이 문제는 실패 이유 전체가 아니라 범위에 맞는 ID 목록을 제출하는 작은 판정 도구입니다.
실제 보고에서는 ID만으로 충분하지 않습니다. 상태·실패 필드·독립 기대값·관찰값과 requestId를 연결합니다. 요청별로 생성하는 ID는 응답이 같은 요청에 속하는지 확인하는 근거입니다. 타입은 맞지만 다른 requestId가 돌아오는 결함도 이후 validateHttp에서 잡습니다. 이번 브라우저 문제는 requestId가 공백 아닌 문자열인지까지 확인하며 정확한 echo 검사는 로컬 API 레슨으로 넘깁니다. 목표마다 단언의 범위를 분명하게 유지합니다.
짧은 검사 코드를 읽는 순서
function valid(row)는 한 행을 검사하고 참 또는 거짓을 반환합니다. row.status !== 200이면 이 문제의 판정 범위 밖이므로 ID를 출력하지 않습니다. status 200이면 body의 형태를 좁힌 뒤 필드 타입과 expected 값을 대조합니다. profile 사례는 kind가 profile일 때만 email과 nickname 규칙을 추가합니다. 일반 가입 응답에 프로필 필드를 요구하면 합법적인 응답까지 실패시키므로 종류 조건을 읽습니다.
조건을 나누어 작성하면 TypeError와 계약 실패를 구분하기 쉽습니다. typeof body.message가 string인지 확인하기 전 trim()을 호출하지 않습니다. Number.isSafeInteger(body.count)와 body.count가 0 이상인지 확인한 뒤 expectedCount를 비교합니다. 여러 검사를 한 줄로 연결하더라도 짧은 회로 평가가 속성 접근을 안전하게 보호하는지 확인합니다. 판단 함수를 만드는 이유는 입력 해석과 출력 반복을 분리하여 같은 기준을 각 행에 적용하기 위해서입니다.
거절된 입력을 다루는 의미를 설명합니다
null, 빈 배열, 누락 result, 숫자 message, 문자열 count는 표현 형태 위반입니다. result:'INVALID'지만 expectedResult:'VALID'인 객체는 표현 형태를 만족하면서 업무 의미를 위반합니다. 두 유형을 섞어 한 사례만 만들면 어떤 단언이 없었는지 찾기 어려워집니다. 정상 객체의 한 필드만 바꾼 변형은 특정 검사가 존재하는지 확인하는 데 좋습니다. 정상 count 0도 넣어 경계를 과하게 거절하지 않는지 확인합니다.
Unexpected token 같은 JSON.parse 오류는 본문 계약 판정 이전의 입력 형식 문제입니다. 입력 배열 자체와 id·kind·독립 expected 필드는 유효하게 주어진다는 문제 조건이 있습니다. 그 조건 밖의 입력은 이 문제에서 자동 검증한 범위가 아닙니다. 임의 데이터까지 처리하는 범용 검증기가 필요하다면 입력 스키마 검사를 별도로 설계합니다. 이번 요구를 넘어 구현을 크게 늘리기보다 어떤 조건을 전제로 안전한지 설명하는 것이 먼저입니다.
실습 제출의 완료 기준을 정합니다
상태 200의 정상 일반 응답은 출력 목록에 없어야 합니다. null 본문, 문자열 count, 틀린 result, 타인 email, 숫자 nickname은 각 ID가 나와야 합니다. 상태 403은 이 문제 목록에 없으며 그 응답에 대한 보안 검증은 아직 완료되지 않은 것으로 설명합니다. 빈 배열은 NONE을 출력합니다. 이 경계 사례를 실행하면 입력이 없을 때 무출력으로 끝나 기대 형식을 깨는 실수를 발견할 수 있습니다.
starter의 NONE 고정 출력은 정상 자료만 있는 테스트에서는 통과할 수 있지만 계약 위반 자료를 놓칩니다. 출력 한 개가 맞았다는 사실보다 어떤 변형에서 실패하는지 확인합니다. 실습을 마치면 HTTP 200에도 타입·판정·회원 수·소유자·닉네임 오류가 존재할 수 있는 예를 한 개씩 말합니다. 그 후 어떤 필드를 어느 계약에서 기대했고 무엇은 확인하지 않았는지 짧게 정리하여 다음 로컬 검증 함수의 기준으로 사용합니다.
따라하기
성공 코드와 의미를 분리합니다
실제 응답을 흉내 낸 값의 판정만 실행합니다. 200이어도 count 기대값이 다릅니다.
const r={status:200,body:{result:'VALID',count:0}};
console.log(r.status===200, r.body.count===1);실행 결과
true false
정상 경계를 보존합니다
숫자 0과 문자열 0의 타입 계약 차이를 확인합니다.
for(const count of [0,'0',-1,1.5]) console.log(JSON.stringify(count),Number.isSafeInteger(count)&&count>=0);실행 결과
0 true "0" false -1 false 1.5 false
소유자와 본문을 대조합니다
동일한 닉네임을 가진 두 회원도 이메일이 다르면 요청 대상이 다릅니다.
const expected={email:'a@example.test',nickname:'회원'};
const actual={email:'b@example.test',nickname:'회원'};
console.log(actual.email===expected.email,actual.nickname===expected.nickname);실행 결과
false true
거절 응답의 노출을 확인합니다
필드 값이 비어 있어도 필드가 존재합니다. 이번 미노출 계약은 필드 존재를 검사합니다.
for(const body of [{result:'FORBIDDEN'},{result:'FORBIDDEN',nickname:''}]) console.log('nickname' in body?'LEAK':'NO_FIELD');실행 결과
NO_FIELD LEAK
확인 문제
실습
표준 입력은 행 배열입니다. id·status·kind·expectedResult·expectedCount는 유효하게 주어집니다. profile 행은 expectedEmail·expectedNickname도 있습니다. status가 숫자 200인 행만 검사하여 위반 id를 입력 순서대로 한 줄씩 출력합니다. 위반이 없으면 NONE입니다. body는 배열 아닌 객체이며 result 문자열, count 비음수 안전 정수, message·requestId 공백 아닌 문자열이어야 합니다. result·count는 독립 expected와 엄격 비교합니다. password·secret 필드는 없어야 합니다. kind가 profile이면 email은 expectedEmail과 같고 nickname은 문자열이며 expectedNickname과 같아야 합니다. 다른 status는 이번 출력 범위에서 제외합니다. 중복 id는 그대로 출력하고 matches 등 관찰 요약은 무시합니다.
모범 답안
const fs=require('fs');
const rows=JSON.parse(fs.readFileSync(0,'utf8'));
function valid(r){
const b=r.body;
if(b===null||typeof b!=='object'||Array.isArray(b))return false;
if(typeof b.result!=='string'||!Number.isSafeInteger(b.count)||b.count<0)return false;
if(typeof b.message!=='string'||!b.message.trim()||typeof b.requestId!=='string'||!b.requestId.trim())return false;
if(b.result!==r.expectedResult||b.count!==r.expectedCount)return false;
if('password' in b||'secret' in b)return false;
if(r.kind==='profile'&&(b.email!==r.expectedEmail||typeof b.nickname!=='string'||b.nickname!==r.expectedNickname))return false;
return true;
}
const failed=rows.filter(r=>r.status===200&&!valid(r)).map(r=>r.id);
console.log(failed.length?failed.join('\n'):'NONE');
더 읽기
면접 질문
- API 응답 코드가 성공이어도 테스트가 실패할 수 있는 상황을 설명해 주시면 됩니다.