일반적으로 9천조 이상의 큰 정수를 다룰일이 많지 않기 때문에 Javascript 변수에 숫자를 저장할 때, 크게 신경쓰지 않았을 수도 있다.
하지만, 다른 언어로 개발된 서버와9천조 이상의 정수를 주고 받을 때는 어떨까? 예를들어, Java 로 개발된 서버에서 Javascript 로 개발된 클라이언트로 9천조 이상의 숫자를 전달한다면 정확하게 주고 받을 수 있을까?
Number
Javascript 에서는 일반적으로 숫자를 저장할 때, Number 라는 자료형에 담는다. Number 에 대해 MDN 문서를 살펴보면 아래와 같다.
Number 는 37이나 -9.25와 같은 숫자를 표현하고 다룰 때 사용하는 원시 래퍼 객체입니다.
Number 생성자는 숫자를 다루기 위해 상수와 메소드를 가지고 있습니다. 다른 타입의 값은 Number() 함수를 사용하여 숫자로 바꿀 수 있습니다.
JavaScript Number 타입은 Java 혹은 C#의 double 타입처럼 IEEE 754 64비트 바이너리 배정밀도 값입니다. 즉, 분수 값을 나타낼 수 있지만 저장할 수 있는 값에는 몇 가지 제한이 있습니다. Number는 소수점 이하 17자리 정도만 유지하며 산술은 반올림의 대상이 됩니다. Number가 가질 수 있는 가장 큰 값은 1.8E308 입니다. 그보다 더 큰 값은 특별한 Number 상수인 Infinity으로 대체됩니다.
JavaScript 코드에서 37과 같은 숫자 리터럴은 정수가 아니라 부동 소수점 값입니다. 일상적으로 흔히 사용되는 별도의 정수형은 없습니다. (JavaScript에는 이제 BigInt 타입이 있지만 일상적인 사용을 위해 Number를 대체하도록 설계되지 않았습니다. 37은 여전히 Number일 뿐, BigInt가 아닙니다.)
실제로 콘솔에서 확인해보면, 아래와 같다.

여기서 눈여겨 볼 점은 Number.MAX_SAFE_INTEGER + 1 === Number.MAX_SAFE_INTEGER + 2 의 결과가 true 라는 부분이다.
이는 Javascript 에서 배정밀도 64비트 부동소수점 숫자체계를 사용하고 있기 때문이다.
배정밀도 부동소수점 (Double-precision floating-point)
64비트 배정밀도 부동소수점을 이해하려면 32비트 단정밀도 부동소수점 부터 이해해야하는데, 설명이 길어지기에 생략하고, 핵심은 64비트의 한정적인 공간을 잘 활용하여 많은 수를 표현하고자 노력하여 정리된 숫자 표현 체계이다.
아래와 같이 64비트를 부호부 (1비트), 지수부 (11비트), 가수부 (52비트) 로 공간을 나누어 숫자를 표현한다.

가수부에는 유효숫자를 저장하는데, 52비트 공간이므로 모두 1로 채웠을 때의 숫자인 2의 52승 - 1 인 9,007,199,254,740,991 값까지는 지수부 사용없이 정수를 정확하게 표현할 수 있다. 부호는 맨 앞의 1비트 (0이면 양수, 1이면 음수) 로 표현하므로, 음수도 -9,007,199,254,740,991 값까지 표현할 수 있다.
그래서 Javascript 에서 Number.MAX_SAFE_INTEGER 값이 9,007,199,254,740,991 이고, Number.MIN_SAFE_INTEGER 값이 -9,007,199,254,740,991 이다.
해당 범위를 벗어나는 정수는 지수부를 사용하여 근사값으로 표현한다. 즉, 정확한 값이 아니다.
실제로 브라우저 개발자도구 콘솔 탭에서 확인해보면 알수 있다.

그렇다면, 처음으로 돌아가서 Java 로 개발된 서버에서 Javascript 로 개발된 클라이언트로 9천조 이상의 숫자를 전달한다면 정확하게 주고 받을 수 있을까?
Java 의 정수 표현
Java 에는 정수를 저장할 수 있는 자료형이 실수를 저장할 수 있는 자료형과 구분되어 있다.
즉, 64비트를 모두 유효숫자를 표현하는데 사용할 수 있다는 의미이다.
| 정수형 타입 | 할당되는 메모리의 크기 | 데이터의 표현 범위 |
| byte | 1바이트 | -128 ~ 127 |
| short | 2바이트 | -215 ~ (215 - 1) |
| -32,768 ~ 32,767 | ||
| int | 4바이트 | -231 ~ (231 - 1) |
| -2,147,483,648 ~ 2,147,483,647 | ||
| long | 8바이트 | -263 ~ (263 - 1) |
| -9,223,372,036,854,775,808 ~ 9,223,372,036,854,775,807 |
만약, Long 자료형에 2의 53승 + 1 값인 9,007,199,254,740,993 을 담아서 클라이언트로 전달하면 어떻게 될까?
Database 테이블의 데이터부터 시작해서 알아보자.
서버에서 클라이언트로 2의 53승 + 1 전달해보기
우선, MySQL 데이터베이스 테이블의 bigint 필드에 아래와 같이 2의 53승 + 1 값을 저장해보았다.

그리고, Java 에서는 Long 타입의 필드에 저장되도록 하였다.
데이터 조회 API 를 호출해보면, 아래와 같이 DB에 저장된 값이 조회된다.

MySQL 의 bigint 필드도 64비트 정수를 저장할 수 있기 때문이다.
그렇다면, fe 로 전달되면 어떻게 변할까?

네트워크 탭을 보면, 서버의 응답 (9007199254740993) 과 다르게 근사값 (9007199254740992) 으로 저장되어 있다.
Javascript 에서 보장하는 안전한 정수 범위를 벗어났기 때문이다.

만약, 이러한 사실을 알지 못했다면?
잘 돌아가던 서비스에 불규칙하게 의문의 버그가 발생할 것이다.
예를들어, DB 테이블의 PK 를 bigint 로 사용하며, Java 에서 Long 타입으로 사용하고 있다면, 특정 시점 이후부터 클라이언트에서 데이터를 조회 후 수정하는데, 다른 데이터가 수정되거나 엉뚱한 시퀀스로 데이터를 조회하게 될 것이다.
해결방안
여러가지 해결방안이 있을 수 있을 수 있겠지만, 개인적으로 문자열로 주고 받는게 좋아 보인다.
문자열로 변환한 후 JSON으로 반환하는 방법에 대한 고민
클라이언트에서 서버로 넘어올때, 문자열로 전달되어도 서버에서 Long 타입으로 필드 선언해두면, 문자열을 숫자로 파싱하여 Long 필드에 알아서 넣어주기 때문에 별도 처리가 필요 없다.
POST http://localhost:18080/api/user
Content-Type: application/json
{
"userSeq": "9007199254740995",
"name": "김길동"
}

그렇다면, 서버에서 클라이언트로 전달 될 때는 어떻게 할까?
타입이 Long 인 필드값을 JSON 데이터로 변환할때, 문자열로 바꿔버리면 된다.
objectMapper 에 serializer 를 추가하면 해결 가능하다. (다만, 모든 Long 필드값이 문자열로 변환된다.)

만약, 테이블 PK 와 같은 특정 필드에만 Serializer 를 설정해주려면 어노테이션 기반 Serializer 를 구현하면 되지만, 개발자가 매번 해당 필드에 어노테이션을 추가해주어야 한다는 번거로움이 생기며, 혹여 이를 놓치고 Response DTO 필드에 어노테이션을 누락하는 실수를 하면, 버그가 유입된다.
회고
개인적인 생각이지만, 21억 이상의 정수를 서버에서 클라이언트로 전달해서 숫자로써 의미있는 계산작업을 하거나 할 일이 있을까?
대부분 정수는 서버에서 Integer 로 표현할 수 있을 것 같다.
그렇다면, 서버에서 Long 으로 다루는 값들은 모두 JSON 데이터로 Serialize 할 때, 문자열로 변환해서 클라이언트로 전달하면 어떨까?
이렇게 되면, 개발자 실수를 막을 수 있다.
어차피 2의 53승부터 정확한 값을 보장하지 못할바에는 숫자로써의 의미는 포기하고 문자열로 정확하게 주고 받는게 좋을 것 같다는 생각이 든다.

'Back-end' 카테고리의 다른 글
| Oracle SQL을 MySQL로 전환하기 (0) | 2024.05.21 |
|---|---|
| Snowflake ID Generator (0) | 2023.04.16 |
| Java 메모장 (0) | 2021.05.16 |
| 스트림 (Stream) - JAVA 8 (0) | 2021.05.15 |
| JAVA... (0) | 2021.04.21 |
댓글