[Kotlin] Byte to Hex String을 구현해 보자

해시함수에서 반환되는 Byte를 Hex로 변환하는 방법과 원리

시작하며

이전 글에서 코틀린 해시 결과인 byte를 string으로 변환하는 과정을 탐구하던 중에, 2의 보수까지 알아야 해서 2의 보수에 관해 공부했다. 이번 포스팅에는 왜 2의 보수를 공부해야 했는지 설명하면서, 어떻게 byte를 hex string으로 변환하는지 알아보자.

문제

val b = 244.toByte()를 실행하면, 어떤 값이 나올까? 답은 -12이다. 왜 그럴까??

답은 2의 보수다. byte는 8bit이고 244는 $1111\ 0100_2$이다. 최상위 비트가 1이므로 2의 보수를 취하면 $0000\ 1100_2$이고 이 값은 12이다. 그러므로 결괏값은 -12가 된다.

val b = 244.toByte()
println(b.toString(16)) // -C

여기서 문제는 244는 hex로 F4이다. 그런데 Kotlin에서는 244.toByte().toStrint(16)-C로 표현한다. 같은 입력값인데 서로 다른 결괏값을 표현하게 된다.

이유가 뭘까?

이는 toString() 함수의 구현 방식에 문제가 있다.

public final class Integer extends Number
        implements Comparable<Integer>, Constable, ConstantDesc {
        
    ... 중략

    static final char[] digits = {
        '0' , '1' , '2' , '3' , '4' , '5' ,
        '6' , '7' , '8' , '9' , 'a' , 'b' ,
        'c' , 'd' , 'e' , 'f' , 'g' , 'h' ,
        'i' , 'j' , 'k' , 'l' , 'm' , 'n' ,
        'o' , 'p' , 'q' , 'r' , 's' , 't' ,
        'u' , 'v' , 'w' , 'x' , 'y' , 'z'
    };

    ... 중략

    public static String toString(int i, int radix) {
        if (radix < Character.MIN_RADIX || radix > Character.MAX_RADIX)
            radix = 10;

        /* Use the faster version */
        if (radix == 10) {
            return toString(i);
        }

        if (COMPACT_STRINGS) {
            byte[] buf = new byte[33];
            boolean negative = (i < 0);
            int charPos = 32;

            if (!negative) {
                i = -i;
            }

            while (i <= -radix) {
                buf[charPos--] = (byte)digits[-(i % radix)];
                i = i / radix;
            }
            buf[charPos] = (byte)digits[-i];

            if (negative) {
                buf[--charPos] = '-';
            }

            return StringLatin1.newString(buf, charPos, (33 - charPos));
        }
        return toStringUTF16(i, radix);
    }
    
    ... 중략
}

구현 방식을 살펴보면, i는 변환하고자 하는 값이고, radix는 n진수를 의미한다. 이때 i의 타입이 integer이므로 byte가 들어와도 integer로 형변환된다. 그리고 radix씩 나눈 나머지를 합친 것이 n진수 변환 string이다.

앞서 i가 byte가 integer로 형변환 된다고 했다. 낮은 비트 타입에서 높은 비트 타입으로 형변환 될 때 낮은 비트의 최상위 비트로 확장되는 부분을 채운다.

  • 예시) $1111\ 0100_2$(-12)를 32비트 integer로 변환
  • 결과) $1111\ 1111\ 1111\ 1111\ 1111\ 1111\ 1111\ 0100_2$

확장된 나머지 24개의 비트가 최상위 비트인 1로 모두 채워져 있는 것을 알 수 있다.

즉, 244는 byte로 -12로 표현되는데, 이를 integer로 확장해도 -12로 표현되는 것이다.

그럼 byte -12를 inteager -12가 아니라 integer 244로 표현하고 싶다면 어떻게 해야 할까?

  • 예시) $1111\ 0100_2$(-12)를 32비트 integer로 변환
  • 결과) $0000\ 0000\ 0000\ 0000\ 0000\ 0000\ 1111\ 0100_2$(244) 로 변환하고 싶다면?

비트 연산을 사용하자

비트 연산을 이용하면 된다.

val b = 244.toByte()
println(b.toInt() and 0xFF) // 244 출력
  • b.toInt() = $1111\ 1111\ 1111\ 1111\ 1111\ 1111\ 1111\ 0100_2$
  • 0xFF = $0000\ 0000\ 0000\ 0000\ 0000\ 0000\ 1111\ 1111_2$

애초에 0xFF는 integer이기 때문에 부호 확장이 일어나지 않는다. 이 두개의 값을 and 연산하면, $0000\ 0000\ 0000\ 0000\ 0000\ 0000\ 1111\ 0100_2$를 얻을 수 있다.

이를 한번 hex 변환해보자.

val b = 244.toByte()
println((b.toInt() and 0xFF).toString(16)) // f4 출력

정상적으로 f4가 나오는 것을 알 수 있다.

하지만 여기서 끝이 아니다.

뭔가 이상하게 출력되는데…?

val blist = arrayOf(244.toByte(), 0.toByte(), 12.toByte(), 255.toByte())
blist.forEach{ println((it.toInt() and 0xFF).toString(16)) }

/* 결과
f4
0
c
ff
*/

뭔가 좀 이상하다. byte를 hex로 바꾸면 f4 00 0c ff가 출력되어야 하는데, 그렇지 않은 것을 알 수 있다. toString()가 integer의 나눗셈을 기반으로 변환하다 보니 $0000_2$으로 표현되는 부분은 모두 생략하게 된다. 이를 어떻게 해결해야 할까?

다시 비트연산을 이용하자

val b = 12.toByte()
println(((b.toInt() and 0xFF) + 0x100).toString(16)) // 10c 출력

+ 0x100이 추가되었다. 이렇게 되면 $0000\ 0000\ 0000\ 0000\ 0000\ 0001\ 0000\ 1100_2$이 되기 때문에 $0001\ 0000\ 1100_2$을 가지고 string으로 변환하게 된다. 그렇다면 항상 3개의 문자를 가진 문자열로 변환할 수 있다.

이제 다 왔다. 3개의 문자중 첫 문자인 1만 제거하면 byte를 hex로 변환할 수 있다.

val blist = arrayOf(244.toByte(), 0.toByte(), 12.toByte(), 255.toByte())
blist.forEach{ println(((it.toInt() and 0xFF) + 0x100).toString(16).substring(1)) }

/* 결과
f4
00
0c
ff
*/

끝이다.

마무리하며

자료형 간의 차이를 명확하게 알지 않는 이상, 해결하기 쉽지 않은 문제였다고 생각한다. 비트 연산 또한 잘 이용하지 않았던 것이었는데, 비트 연산을 활용해서 문제를 해결하는 방법이 꽤 인상적이었다. 홀수/짝수 구분도 최하위비트로 판단할 수 있는 것처럼 비트 연산이 최적화에 도움이 많이 될 것 같다.