목차
여기까지 아무 설명 없이 JSON을 써왔다. 왜 하필 JSON인지, 그리고 그 선택이 무엇을 대가로 치르는지 본다.
큰 정수가 클라이언트에서 조용히 깨졌다
주문 ID를 스노플레이크 같은 방식으로 큰 수를 쓰도록 바꿨다. 서버는 long으로 잘 다룬다. 응답도 정확하다.
{"orderId": 9007199254740993, "amount": 12000}그런데 웹 프런트에서 주문 상세를 열면 “없는 주문”이라고 나온다. 브라우저 콘솔에서 파싱 결과를 찍어보면 값이 이렇다.
9007199254740992끝자리가 달라졌다. 요청도 응답도 정확했는데 읽는 쪽에서 바뀌었다.
자바스크립트의 기본 숫자 타입은 IEEE 754 배정밀도 부동소수점이다. 이 타입이 정수를 정확히 표현할 수 있는 한계가 9007199254740991, 즉 2의 53승에서 1을 뺀 값이다. 그 위로 넘어가면 표현 가능한 값으로 반올림된다. 그래서 ...993이 ...992가 됐다.
여기서 중요한 건 에러가 안 났다는 것이다. 예외도 경고도 없다. 값만 조용히 달라진다. 그리고 JSON에는 “이 숫자는 64비트 정수다”라고 말할 방법이 없다.
자바스크립트에서 나와 언어 밖으로 퍼졌다
이름부터가 JavaScript Object Notation이다. 자바스크립트에서 객체를 적는 문법을 데이터 형식으로 떼어낸 것이다.
그전에 API 응답의 기본은 XML이었다. XML은 스키마(XSD)로 구조를 검증할 수 있고, 네임스페이스가 있고, 주석도 달린다. 기능만 보면 JSON보다 많다. 그런데도 웹 API의 기본 자리를 JSON이 가져갔다.
이유를 하나만 꼽으면 읽는 쪽이 브라우저였다는 것이다. 브라우저에서 XML을 다루려면 DOM을 파싱하고 노드를 타고 들어가야 했지만, JSON은 파싱 한 번이면 그대로 자바스크립트 객체가 된다.
const user = JSON.parse(text);
user.name; // 바로 쓴다그리고 사람이 읽기 쉽다. 여는 태그와 닫는 태그가 없으니 눈으로 훑기 좋고, 같은 데이터를 담아도 짧다.
<user><id>1024</id><name>김개발</name></user>{"id": 1024, "name": "김개발"}여기서 짚고 갈 게 있다. JSON이 이긴 이유는 기능이 많아서가 아니라 적어서다. 배우는 데 걸리는 시간이 짧고, 어느 언어에나 파서가 있고, 눈으로 읽힌다. 대신 XML이 갖고 있던 것들을 잃었고, 그 잃은 것이 지금부터 볼 함정들이다.
타입이 여섯 개뿐이다
JSON이 표현할 수 있는 값은 이게 전부다.
이 목록에 없는 것을 보는 게 더 중요하다.
| 없는 것 | 그래서 어떻게 하나 |
|---|---|
| 날짜·시각 | 문자열로 적는다. 형식은 우리가 정해야 한다 |
| 정수와 실수의 구분 | 둘 다 그냥 “숫자”다 |
| 큰 수 · 정확한 소수 | 표현 방법이 없다. 문자열로 우회한다 |
| 이진 데이터 | Base64 문자열로 인코딩한다 |
| 주석 | 못 단다 |
| 스키마 | 형식 자체에는 없다. 별도 도구가 필요하다 |
없는 것들은 사라지지 않고 약속으로 옮겨간다. 날짜를 어떤 문자열로 적을지, 금액을 어떻게 담을지를 서버와 클라이언트가 따로 합의해야 한다. 그리고 그 합의는 앞 글들에서 본 대로 한 번 정하면 되돌리기 어려운 계약이다.
숫자에는 정수도 소수점 자릿수도 없다
첫 장면으로 돌아간다. 큰 정수가 깨지는 문제의 해법은 대체로 하나다. 정확성이 필요한 식별자는 문자열로 보낸다.
{"orderId": "9007199254740993", "amount": 12000}ID는 계산에 쓰이지 않는다. 화면에 표시하고, URL에 넣고, 다음 요청에 그대로 실어 보낸다. 그 용도에는 문자열이 오히려 자연스럽다. 처음부터 문자열로 시작하면 나중에 ID 체계를 바꿔도 클라이언트가 안 깨진다.
숫자였던 필드를 나중에 문자열로 바꾸는 건 깨는 변경이다. 1024와 "1024"는 클라이언트에게 완전히 다른 값이라 파싱이나 비교에서 터진다. ID가 커질 가능성이 조금이라도 있으면 시작할 때 정하는 게 훨씬 싸다.
돈도 같은 계열의 문제다. 부동소수점으로 소수를 다루면 정확히 떨어지지 않는 값이 생긴다.
0.1 + 0.2 // 0.30000000000000004이건 JSON 탓이 아니라 이진 부동소수점의 성질이지만, JSON에 “이건 십진 소수다”라고 말할 방법이 없어서 그대로 노출된다. 실무에서 쓰는 우회는 둘이다.
- 최소 단위 정수로 담는다. 원 단위 나라면 그냥 정수로, 센트를 쓰는 통화면 센트 단위 정수로. 위 예의
"amount": 12000이 이 방식이다. - 문자열로 담는다.
"amount": "12000.50". 받는 쪽에서 십진 타입으로 파싱한다.
어느 쪽이든 통화 단위를 같이 보내는 게 안전하다. 12000이 원인지 센트인지는 숫자만 봐서 알 수 없다.
날짜라는 타입이 없다
날짜는 문자열로 적어야 하는데, 형식을 우리가 정해야 한다. 후보가 여럿이라 팀마다 다르게 고른다.
{"createdAt": "2026-05-26T14:30:00Z"} // ISO 8601 · UTC
{"createdAt": "2026-05-26 14:30:00"} // 시간대가 없다
{"createdAt": 1779805800} // 유닉스 초
{"createdAt": "26/05/2026"} // 어느 게 월인가권할 만한 기본은 ISO 8601에 시간대를 붙인 문자열이다.
{"createdAt": "2026-05-26T14:30:00Z"}이유가 셋이다. 사람이 읽을 수 있고, 문자열 정렬이 곧 시간 순서가 되고, 시간대가 값 안에 들어 있다. 마지막이 특히 크다. 시간대 없는 "2026-05-26 14:30:00"은 서버가 어느 지역에서 도는지, 클라이언트가 어느 지역에서 읽는지에 따라 뜻이 달라진다. 서버를 다른 리전으로 옮기는 날 값이 아홉 시간 밀리는 사고가 여기서 나온다.
시각은 UTC로 보내고 표시할 때 클라이언트가 현지 시간으로 바꾸는 것이 기본형이다. 다만 예외가 있다. 생일이나 정산 기준일처럼 시각이 아니라 날짜 자체가 의미인 값은 시간대를 붙이면 오히려 하루가 밀린다. 그런 값은 "1996-03-15"처럼 날짜만 보내고, 필드 이름도 birthDate처럼 시각이 아님을 드러낸다.
null과 없음은 다르다
JSON에는 null이 있고, 필드를 아예 안 보내는 것도 가능하다. 이 둘이 같은 뜻인지 다른 뜻인지를 정해두지 않으면 애매한 자리가 생긴다.
{"nickname": null} // 별명이 없다
{} // 별명 정보가 이 응답에 안 담겼다응답에서는 대체로 둘을 구분하지 않아도 큰 문제가 없다. 오히려 필드를 늘 보내는 쪽이 클라이언트에 친절하다. 값이 없을 때 필드가 통째로 사라지면, 클라이언트는 “값이 없는 건지 서버가 안 준 건지”를 매번 방어해야 한다.
문제는 PATCH 요청이다. 여기서는 둘이 확실히 다른 뜻이어야 한다.
PUT과 PATCH는 다르다에서 PUT을 PATCH처럼 구현하면 “필드를 비우는 방법”을 잃는다고 했는데, 그 문제가 여기서 정확히 풀린다. 필드가 없으면 안 건드리고, null이면 지운다. 이 규칙을 정해두면 부분 수정이 애매해지지 않는다.
다만 구현에 손이 간다. Java에서 nickname 필드가 null인 DTO를 받았을 때, 그게 “안 보냈다”인지 “null을 보냈다”인지 구분하려면 Optional을 한 겹 더 쓰거나 원본 JSON을 들여다봐야 한다. 그래서 부분 수정이 복잡한 자원이면 아예 필드별 엔드포인트를 두는 쪽이 단순할 때도 있다.
스키마가 없다는 대가
XML이 갖고 있다가 JSON이 버린 것 중 가장 큰 게 스키마다. JSON 자체에는 “이 응답에 어떤 필드가 어떤 타입으로 들어 있어야 한다”를 적을 곳이 없다.
그래서 이런 일이 생긴다.
{"id": 1024, "isActive": "true"}isActive가 불리언이 아니라 문자열이다. JSON 문법상 아무 문제 없다. 파싱도 성공한다. 클라이언트가 if (user.isActive)로 분기하면 문자열 "false"도 참이 되어, 비활성 사용자가 활성으로 보인다.
이게 API는 약속이다에서 본 계약 문제와 이어진다. 계약은 어딘가에 있어야 하는데, JSON이 그걸 안 갖고 있으니 다른 곳으로 옮겨간다.
가장 흔한 게 마지막이다. 계약이 아무 데도 안 적혀 있고, 클라이언트 개발자가 실제 응답을 열어보고 짐작해서 코드를 짠다. 그 상태에서 서버가 필드 하나를 바꾸면 이 시리즈를 여는 장면이 반복된다.
응답 모양을 정하는 건 DTO다
그래서 이 시리즈의 척추가 여기서 다시 나온다. 스키마가 형식에 없다면 코드 안에 명시적인 자리를 만들어야 한다. 그 자리가 응답 DTO다.
public record OrderResponse(
String orderId, // 큰 정수라 문자열로
long amount, // 원 단위 정수
String currency, // 단위를 같이 준다
OffsetDateTime createdAt // 직렬화 형식은 아래에서 고정한다
) { }이 클래스가 곧 계약 문서다. 여기 없는 필드는 안 나가고, 여기 있는 필드의 타입이 응답의 타입이다. DTO 글의 DTO는 계층을 건너는 전용 그릇 대목이 이 구조를 다룬다.
반대로 엔티티를 그대로 내보내면 이 자리가 사라진다. 필드 이름도 타입도 테이블이 정하게 되고, 테이블이 바뀌면 API 계약도 바뀐다.
그리고 요청과 응답은 다른 그릇이다라는 것도 JSON 관점에서 한 번 더 걸린다. 요청 DTO에는 검증이 붙어서, 에러 바디는 코드로 말한다에서 본 필드별 에러 응답이 거기서 나온다.
필드 이름 규칙도 여기서 정해진다. JSON 필드는 카멜케이스(createdAt)가 가장 흔하고, 스네이크케이스(created_at)를 쓰는 곳도 많다. 어느 쪽이든 한 API 안에서 섞이지 않는 게 전부다. Spring에서는 Jackson 설정으로 전체 규칙을 한 번에 정할 수 있다.
모르는 필드를 어떻게 대할 것인가
API는 약속이다에서 “필드 추가는 대체로 안전하다”고 하면서 조건을 하나 달았다. 클라이언트가 모르는 필드를 무시해야 한다는 조건이다. 그 조건이 실제로 어디서 정해지는지가 여기다.
Jackson은 기본적으로 모르는 필드를 만나면 예외를 던진다. Spring Boot가 이 설정을 꺼둬서 평소에는 안 겪을 뿐이고, 직접 ObjectMapper를 만들어 쓰면 원래 기본값이 살아난다.
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);이걸 꺼두면 서버가 응답에 필드를 추가해도 이 클라이언트는 안 깨진다. 반대로 켜져 있으면 남이 필드 하나 늘리는 것만으로 우리 쪽이 죽는다.
여기서 방향에 따라 판단이 갈린다.
- 남의 API 응답을 읽을 때는 관대한 쪽이 낫다. 모르는 필드는 무시한다. 상대가 언제 필드를 늘릴지 우리가 통제할 수 없다.
- 우리가 요청을 받을 때는 엄격한 쪽도 선택지다. 클라이언트가
emial이라고 오타를 낸 채 보내면, 무시하고 넘어가는 것보다 400으로 알려주는 게 디버깅이 빠르다. 다만 나중에 파라미터를 늘릴 때 옛 클라이언트가 보내던 값과 부딪히지 않는지 같이 봐야 한다.
이 원칙을 흔히 “보낼 때는 엄격하게, 받을 때는 관대하게”라고 부른다. 다만 무조건적인 규칙은 아니다. 관대함은 오류를 늦게 발견하게 만드는 대가를 치른다. 위처럼 방향별로 나눠 정하는 편이 실용적이다.
그래서 실무에선 어떻게 쓰이나
직렬화 설정을 앱 전체에서 한 번 정한다. 날짜 형식, 필드 이름 규칙, null 필드를 뺄지 말지를 엔드포인트마다 정하면 반드시 어긋난다.
spring:
jackson:
serialization:
write-dates-as-timestamps: false # 숫자 대신 ISO 8601 문자열로
default-property-inclusion: always # null 필드도 내보낸다순정 Jackson은 WRITE_DATES_AS_TIMESTAMPS가 켜져 있어서, 그대로 두면 OffsetDateTime이 숫자 배열이나 에포크 값으로 나가서, 앞에서 정한 날짜 규칙이 조용히 무너진다. 설정 한 줄이 계약을 바꾼다는 게 JSON 다루기의 특징이다.
명세를 파일로 남긴다. OpenAPI 문서에 응답 스키마를 적어두면 스키마 없음의 대가를 일부 되찾는다. 필드 타입이 문서에 있고, 리뷰에서 diff로 보이고, 클라이언트 코드를 생성할 수도 있다. 완벽하진 않다. 명세와 실제 응답이 어긋날 수 있기 때문이라, 응답을 검증하는 테스트를 같이 두는 게 안전하다.
로그에 본문을 통째로 찍지 않는다. JSON은 사람이 읽기 쉬워서 요청·응답 본문을 그대로 로그에 남기고 싶어지는데, 그 안에 개인정보나 비밀번호가 섞여 있다. 남길 필드를 고르거나 마스킹을 거친다.
응답 크기를 본다. JSON은 필드 이름이 매 항목마다 반복되는 형식이라 목록이 길면 같은 키가 수천 번 들어간다. 대개는 gzip 압축으로 충분히 줄고, 그보다 먼저 Endpoint 설계 편에서 본 페이징과 상한이 효과가 크다.
정리
| 왜 표준이 됐나 | 기능이 많아서가 아니라 적어서. 파싱이 쉽고 눈으로 읽히고 어디에나 파서가 있다 |
| 타입 | 문자열·숫자·불리언·null·객체·배열 여섯 개뿐 |
| 숫자 | 정수 구분도 정밀도 표시도 없다. 큰 ID는 문자열, 금액은 최소 단위 정수나 문자열 |
| 날짜 | 타입이 없다. ISO 8601 + 시간대. 날짜 자체가 의미인 값은 시각을 빼고 |
| null vs 없음 | 응답은 늘 보내는 게 친절하다. PATCH에서는 “지워라”와 “건드리지 마라”로 갈린다 |
| 스키마 없음 | 계약이 형식 밖으로 밀려난다. 아무 데도 없으면 실제 응답이 계약이 된다 |
| 계약이 사는 자리 | 응답 DTO. 필드 이름·타입·포함 여부를 여기가 정한다 |
| 모르는 필드 | 무시해야 “추가는 안전”이 성립한다. 읽을 때는 관대하게, 받을 때는 방향에 따라 |
여기까지가 REST와 JSON으로 API를 만드는 이야기였다. 그런데 이 조합에는 잘 안 풀리는 상황이 있다. 화면 하나를 그리는 데 API를 다섯 번 부르거나, 반대로 필드 스무 개를 받아 둘만 쓰는 경우다.
다음 글은 그 문제를 다르게 푼 접근을 본다. GraphQL이다.