부호를 뗄 자유: Java에는 왜 unsigned가 없을까

Java에는 왜 부호 없는 정수가 없을까? 그 설계 뒤에는 언어가 자유를 누구에게 맡기느냐는 질문이 숨어 있다.

  • java
  • c
  • programming-languages

Photo by Joshua Hoehne on Unsplash

“unsigned가 없다고?”

Java에서 처음 unsigned int를 찾아 헤맸을 때 느낀 감정은 배신감에 가까웠다. 2005년의 일이다.

사실 Java가 처음은 아니었다. 2003년부터 썼으니 벌써 2년째였다. 그런데 그 2년 동안 음수가 될 리 없는 숫자가 필요했던 적이 어쩐 일인지 한 번도 없었다. 없는 기능이란 게 원래 그렇다. 바닥에 구멍이 뚫려 있어도 밟지만 않으면 몇 년이고 모르고 지나다닌다.

2005년, 드디어 그 구멍을 밟았다. 당시 나는 IPC 프로토콜 문서와 씨름하고 있었다. 패킷 형식을 필드 단위로 정의한 문서였는데, 여기는 부호 없는 16비트, 저기는 부호 없는 4비트 하는 식으로 비트를 빈틈없이 채워 넣는 레시피 같았다. 필드마다 부호가 붙어 있었다. 아니, 정확히는 부호가 없었다. 할 일은 간단했다. 문서를 읽고, Java 코드를 짜고, 바이트가 제자리에 들어가는지 확인하면 끝이었다. 그런데 Java는 음수가 될 수 없는 숫자라는 걸 아예 모르는 언어였다.

“여기선 음수 쓸 일이 절대 없는데. 대체 어디 있는 거야?”

없다. Java에는 부호 없는 타입이 없다. byte, short, int, long 모두 부호 있는 타입이다. 유일한 예외가 16비트 부호 없는 타입인 char인데, 이것도 Java가 너그러워서가 아니라 유니코드 코드 포인트에 음수가 없기 때문이다. 어딘가에서 Java라고는 써 본 적도 없는 프로토콜 설계자가 “unsigned 4 bits”라고 적힌 반듯한 칸을 그려 넣었을 것이다. 그 칸 하나가 누군가를 이토록 괴롭힐 줄은 꿈에도 모른 채.

처음엔 실수려니 했다. 누가 넣는 걸 깜빡했겠지. 알고 보니 버그가 아니라 신념이었다. 제임스 고슬링과 초기 Java 팀은 부호 없는 타입을 버그의 온상으로 봤다. C를 써 본 사람이라면 부호 있는 값과 부호 없는 값을 한 식에 섞었다가, 멀쩡하던 정수가 영문도 모르게 40억 가까이로 치솟는 걸 본 경험이 한 번쯤은 있다. Java는 그럴 여지를 아예 없애는 쪽을 택했다.

덕분에 개발 경험은 조용히 우스꽝스러워졌다. byte는 -128부터 127까지만 담을 수 있으니, 파일에서 바이트를 읽으면 멀쩡한 데이터가 음수로 튀어나온다. 0xFF를 읽으면 Java는 -1을 내놓는다. 그래서 Java 개발자라면 누구나 같은 주문을 익힌다. & 0xFF. 저주 걸린 값을 원래 뜻으로 되돌리려고 부적처럼 붙이는 주문이다.

같은 바이트를 두고 두 언어는 이렇게 다르게 읽는다.

// C
unsigned char b = 0xFF;
printf("%d\n", b);   // 255 — 네트워크로 받은 그대로
// Java
byte b = (byte) 0xFF;
System.out.println(b);        // -1 — Java의 "친절한" 재해석
System.out.println(b & 0xFF); // 255 — 질서를 되찾는 주문

백문이 불여일견이라고 했다. 코드만 읽고 넘어가지 말고 아래 위젯을 직접 만져 보자. 비트를 하나씩 뒤집다 보면 두 언어가 같은 바이트를 두고 얼마나 다르게 우기는지 바로 보인다.

0xFF0b11111111비트를 클릭해보세요
MSB (비트 7)LSB (비트 0)
C · unsigned char
255
Java · byte
-1

C는 바이트에 무엇이 들어 있는지를 있는 그대로 알려 준다. Java는 그 바이트가 무엇을 뜻해야 한다고 보는지 알려 주고, 진짜 값을 되찾는 수고는 개발자에게 떠넘긴다.

그래도 받은 패킷을 읽을 때는 쉬운 편이었다. 정말 헷갈리는 건 패킷을 만들어 네트워크로 보낼 때였다. 프로토콜 문서에 어떤 필드가 부호 없는 4바이트로 정의돼 있다고 해 보자. Java에는 부호 없는 32비트 타입이 없으니 안전하게 long에 담는다. 8바이트면 공간도 넉넉하고 오버플로 걱정도 없다. 문제는 일주일 뒤 이 필드를 다시 볼 때다. 코드에는 long someField; 한 줄뿐이고, 이 값을 부호 있는 8바이트로 내보내야 하는지 부호 없는 4바이트로 내보내야 하는지는 선언 어디에도 적혀 있지 않다.

직렬화에 꼭 필요한 정보를 타입이 슬그머니 지워 버린 셈이다. 알아내려면 프로토콜 문서를 다시 뒤지는 수밖에 없다. C는 사정이 달랐다. struct 하나가 네트워크 패킷과 거의 필드 단위로 맞아떨어졌다. 네트워크 바이트 순서만 챙기면 됐고, 필드의 크기와 부호는 선언만 봐도 알 수 있었다. Java는 타입 안전성을 얻는 대신, 모든 걸 한눈에 보이게 해 주던 그 일대일 대응을 내줬다.

결국 부호 없는 정수가 있느냐 없느냐는 명세의 사소한 차이가 아니다. 누구에게 자유를 줄 것이냐의 문제다. 숫자인가, 그 숫자를 다루는 사람인가. C는 정수에게 자유를 줬다. 부호라는 짐 없이, 크기만 지닌 채 패킷에 실려 조용히 오가게 했다. Java는 그 자유를 나눠 주기엔 너무 위험하다고 봤다. 그래서 부호를 도로 붙이고 자유는 자기가 가졌다. 아무도, 바이트 하나조차 믿지 않을 자유를. 20년이 지난 지금도 & 0xFF를 칠 때마다 나는 두 자유 사이의 국경에서 작은 통행료를 낸다. 숫자는 끝내 부호를 떼지 못했다. 생각해 보면 나도 마찬가지다.