최근에 legacy code 를 보다가 필자가 안타깝게도 기초가 부족해 코드가 이해가 잘 되지 않았다.
그래서 복습할 겸 필자가 이를 정리하고자 한다.
시작전에 이 코드가 이해가 되는가? 그러면 해당 글을 안봐도 될 것이다.
// 직접 변환
public static String Sha256data(String str){
String rtnData = "";
try{
MessageDigest md = MessageDigest.getInstance("SHA-256");
md.update(str.getBytes(StandardCharsets.UTF_8));
byte byteData[] = md.digest();
StringBuffer sb = new StringBuffer();
for(int i = 0 ; i < byteData.length ; i++){
sb.append(Integer.toString((byteData[i]&0xff) + 0x100, 16).substring(1));
}
rtnData = sb.toString().toUpperCase();
}catch(NoSuchAlgorithmException e){
e.printStackTrace();
rtnData = "";
}
return rtnData;
}
목표
1. Java에서의 2, 8, 10, 16진법 표현에 대해 이해한다.
2. & 연산에 대해 이해한다.
2. Java에서 byte 계산시 이슈를 이해한다.
3. 예시 코드를 통한 코드 작동 원리를 이해한다.
1. Java의 진법과 리터럴 표기법
필자는 중국 소림무술의 진법이 먼저 떠올랐다.

진법이란 수를 표기하는 기수법의 하나로 임의의 숫자를 사용하여 수를 표현하는 방법이다.
일상에서 사용하는 것은 10진법이다.
가장 많이 사용하는 진법은 2,8,10,16진법이다.
1.1 10진수의 2진수, 8진수, 16진수 표현
10101011 (10진수 171)
│
┌─────────────┴─────────────┐
│ │
3비트씩 끊기 4비트씩 끊기
│ │
2진수: 10 101 011 2진수: 1010 1011
└┘ └┬┘ └┬┘ └┬─┘ └┬─┘
8진수: 2 5 3 16진수: a b
→ 0253 → 0xAB
8진수는 linux 환경에서 파일의 권한을 줄때 chmod 를 설정할 때 여전히 사용되고 있다.
- r (Read, 4): 파일 읽기 또는 디렉터리 목록 보기
- w (Write, 2): 파일 수정/저장 또는 디렉터리 내 파일 생성/삭제
- x (Execute, 1): 파일 실행 또는 디렉터리 접근(cd)
표현하면 다음과 같다.
chmod 777 test111 //사용자,그룹,다른사용자 : 읽기/쓰기/실행 권한
chmod 755 test222 //사융자 :읽기/쓰기/실행 권한, 그룹 : 읽기/실행 권한, 모든유저: 읽기/실행 권한
chmod 421 test333 //사용자 :읽기 권한 , 그룹 : 쓰기 권한, 모든유저: 실행 권한
1.2 진법 접두어 4종
| 진법 | 접두어 | 예시 | 값(10진수) | 도입시기 |
| 10진수 | 없음 | 255 | 255 | 처음부터 |
| 2진수 | 0b 또는 0B (숫자 영) | 0b11111111, 0B11111111 | 255 | Java 7+ |
| 8진수 | 0 (숫자 영) | 0377 | 255 | 처음부터 |
| 16진수 | 0x 또는 0X (숫자 영) | 0xff , 0XFF | 255 | 처음부터 |
표로 정리하면 위와 같은데, 코드는 사실 크게 어렵지 않다.
RadixDemo.java
public class RadixDemo {
public static void main(String[] args) {
int a = 255; // 10진수
int b = 0xff; // 16진수
int c = 0377; // 8진수
int d = 0b11111111; // 2진수
System.out.println(a);
System.out.println(b);
System.out.println(c);
System.out.println(d);
System.out.println(a == b && b == c && c == d);
}
}
다만 맨 앞에 숫자 영(0)을 붙이게 되면 8진수가 되어버리니 주의해야 한다.
대부분의 경우 2진수와 16진수가 궁합이 잘 맞아 많이 사용하고 있다. 깔끔하게 2진수 4자리가 16진수 한자리로 떨이지니 말이다.
1.3 언더스코어 구분자와 확대변환
긴 숫자가 너무 다닥다닥 붙어있다면 이를 눈으로 보기 힘들다.
그래서 Java 7+ 버전 이상에서는 언더스코어 구분자를 지원한다.
int million = 1_000_000; // 1000000
long card = 1234_5678_9012_3456L; // 카드번호 자릿수 구분
int mask = 0xFF_FF_FF_FF; // 바이트 단위 구분
int bits = 0b1010_1011; // 니블(4비트) 단위 구분
double pi = 3.141_592_653;
다만 숫자 사이에만 언더스코어를 넣을 수 있다는 점에 유의하자!
또한, 계산시 widening primitive conversion(확대 변환)에 유의해야 한다.
int 는 32bit인데 long은 64bit이다. 큰 수의 경우 overflow가 발생해 이상한 값이 출력되는 경우가 있을 수 있다.
long ms = 1000L * 60 * 60 * 24 * 30; // OK
// ↑ 하나만 long이면 전체가 long 연산으로 승격
10진수 표현시에는 int 범위를 초과하거나 이것이 음수로 이상하게 표현되는지 모른다.
하지만 2진수, 16진수를 사용하면 32비트를 전부 채워야 음수 표현이 가능하기에, 이를 보고 아 이게 음수구나! 하는 의도를 바로 파악할 수 있다.
너무 큰 수의 경우, 혹은 소수점이 들어가는 경우 일반적으로 정확한 숫자를 표현하기 위해서는 (특히 금액)
Java에서는 BigDecimal을 99.9% 쓰게 된다.
1장 요약
// 진법
255 // 10진수
0xff 0XFF // 16진수
0377 // 8진수 (주의!)
0b11111111 // 2진수 (Java 7+)
// 구분자 (Java 7+)
1_000_000 // 숫자 사이에만
// 접미사
255L // long
3.14f // float
3.14d 3.14 // double
// 조합
0xFF_FF_FF_FFL // 16진수 + 구분자 + long
보면 크게 어렵지 않다.
2. Java에서 byte 이해하기(signed 와 unsigned)
2.1) signed와 unsigned 그리고 byte
1bit가 표현 가능한 수는 2개다. 0과 1!
8bit가 표현 가능한 수는 2^8이다. 8bit는 1byte다.
어느 프로그램 언어를 가더라도 이는 달라지지 않는다. 0 ~ 255까지 표현이 가능한데 Java에서는 byte는 singed이다.
signed 부호가 있으면 -128 ~ 127 까지 표현이 가능하며 unsigned으로 표현하면 0 ~ 255까지 표현하는 식이다.
원래 초기에는 signed, unsigned 같은 것을 고려하지 않도록 설계를 했는데,
기능을 하나 추가할 때마다 프로그래머가 배워야 할 부담과 컴파일러가 지원해야 할 부담이 함께 늘어난다는 문제가 있으니
unsigned 타입을 없앰으로써 signed-unsigned 변환 과정에서 생기는 미묘한 버그의 가능성과 복잡도를 줄이려하였다.
자바의 핵심 철학인 "Write Once, Run Anywhere"을 위해서는 이식성(portability)도 고려를 해야하는데, signed/unsigned를 섞은 코드는 플랫폼마다 처리가 갈릴 여지가 있기 때문이다.
하지만 byte단위로 작업을 해야 하는 경우 이것을 일일히 신경쓰고 작업을 해야하는게 최악이지 않나?
(정말 환장할 노릇입니다. 이때 한 개발자가 엄청난 아이디어를 떠올립니다. 정말 천재적이죠.)
1010 1011 → unsigned로 읽으면: 171
→ signed로 읽으면: -85
자바는 안타깝게도 byte는 무조건 signed이다.
그런데 SHA-256이 뱉어내는 digest의 경우에는 부호 개념이 없는 0~255값이다. 왜 이게 문제인지는 아래를 더 읽어보자.
2.2) SHA-256과 MessageDigest
갑자기 SHA-256이 등장한다.
SHA-256은 단방향 암호화 알고리즘의 하나로
어떤 입력값이라도 256비트(64자리 16진수) 고정 길이의 결과물로 바꾸어주는 단방향 암호학적 해시 알고리즘이다.
(256비트는 32바이트이다!)
비유를 하자면 SHA256은 특정 정보의 지문을 뜨는 것이다. 한 글자를 입력하건 100GB의 파일을 넣건 무조건 256바이트로 결과를 반환한다.
SHA-256은 데이터를 64바이트(512비트) 블록 단위로 끊어서 처리한다.
암호학에서는 이렇게 결과를 반환하는 것을 digest라고 한다. 무언가를 먹고 소화시켜 새로운 결과로 반환한다고 이해하면 좋다.
(아 다이제 먹고싶네...)
이를 구현하기 위해서는 굳이 우리가 직접 코딩할 필요는 없고 Java에서 MessageDigest라는 클래스는 제공한다.

사용법도 이미 위에 알려주고 있다.
MessageDigest.java 주석
MessageDigest md = MessageDigest.getInstance("SHA-256");
try {
md.update(toChapter1);
MessageDigest tc1 = md.clone();
byte[] toChapter1Digest = tc1.digest();
md.update(toChapter2);
...etc.
} catch (CloneNotSupportedException cnse) {
throw new DigestException("couldn't make digest of partial content");
}
(1) 원하는 알고리즘인 SHA-256을 설정하고,
(2) 입력받은 값을 md.update에 넘겨준 다음,
(3) 결과를 누적해서 암호화하기 위해서 clone을 통해 원하는만큼 암호화를 진행한다.
MessageDigest md = MessageDigest.getInstance("SHA-256");
// SHA-256 계산기 객체 하나 생성. 아직 아무것도 넣지 않음.
md.update(toChapter1);
// 1장 내용을 md에 먹임. md의 내부 상태가 "1장까지 반영된 상태"가 됨.
MessageDigest tc1 = md.clone();
// 지금 이 순간의 상태(1장까지 반영된 상태)를 복사해서 tc1이라는 별도 객체를 만듦.
// md와 tc1은 지금 이 순간 완전히 똑같은 상태를 가진 별개의 객체.
byte[] toChapter1Digest = tc1.digest();
// tc1을 마무리해서 다이제스트를 뽑음.
// → "1장만의 해시값"이 toChapter1Digest에 담김.
// tc1은 이 호출로 리셋되어 이제 쓸모없어짐 (더 이상 안 씀).
// 중요: md는 이 digest() 호출의 영향을 전혀 받지 않음! md는 여전히 "1장까지 반영된 상태" 그대로.
md.update(toChapter2);
// md에 2장 내용을 이어서 먹임.
// md는 이제 "1장 + 2장까지 반영된 상태"가 됨.
clone()은 중간 지점마다 부분 검증이 필요한 아주 특수한 경우에만 쓰는 고급 기능이다.
하나의 파일에 대해서 한 번에 검증을 한다면? 예를 들어 5GB파일을 한 번에 다 읽는다면 사실 clone을 쓸 일이 없다.
MessageDigest에서 제공하는 update를 봐보자.
MessageDigest.java
// Copyright (c) 1996, 2020, Oracle and/or its affiliates. All rights reserved.
// MessageDigest.java 중 update 메소드
/**
* Updates the digest using the specified array of bytes.
*
* @param input the array of bytes.
*/
public void update(byte[] input) {
engineUpdate(input, 0, input.length);
state = IN_PROGRESS;
}
오잉? engineUpdate가 또 있네?
engineUpdate는 MessageDigestSpi 라는 추상클래스의 engineUpdate를 가리킨다.

그런데 추상클래스이니 구현을 어디서 해야하지 않은가?
이를 이미
MessageDigest.getInstance("SHA-256");
선언 시에 다음과 같이 이미 등록되어있는 java.security에서 적절한 파일을 provider로 제공한다.
// * Copyright (c) 1996, 2020, Oracle and/or its affiliates. All rights reserved.
/**
* Returns a MessageDigest object that implements the specified digest
* algorithm.
*
* <p> This method traverses the list of registered security Providers,
* starting with the most preferred Provider.
* A new MessageDigest object encapsulating the
* MessageDigestSpi implementation from the first
* Provider that supports the specified algorithm is returned.
*
* <p> Note that the list of registered providers may be retrieved via
* the {@link Security#getProviders() Security.getProviders()} method.
*
* @implNote
* The JDK Reference Implementation additionally uses the
* {@code jdk.security.provider.preferred}
* {@link Security#getProperty(String) Security} property to determine
* the preferred provider order for the specified algorithm. This
* may be different than the order of providers returned by
* {@link Security#getProviders() Security.getProviders()}.
*
* @param algorithm the name of the algorithm requested.
* See the MessageDigest section in the <a href=
* "{@docRoot}/../specs/security/standard-names.html#messagedigest-algorithms">
* Java Security Standard Algorithm Names Specification</a>
* for information about standard algorithm names.
*
* @return a {@code MessageDigest} object that implements the
* specified algorithm
*
* @throws NoSuchAlgorithmException if no {@code Provider} supports a
* {@code MessageDigestSpi} implementation for the
* specified algorithm
*
* @throws NullPointerException if {@code algorithm} is {@code null}
*
* @see Provider
*/
public static MessageDigest getInstance(String algorithm)
throws NoSuchAlgorithmException
{
Objects.requireNonNull(algorithm, "null algorithm name");
MessageDigest md;
GetInstance.Instance instance = GetInstance.getInstance("MessageDigest",
MessageDigestSpi.class, algorithm);
if (instance.impl instanceof MessageDigest messageDigest) {
md = messageDigest;
md.provider = instance.provider;
} else {
md = Delegate.of((MessageDigestSpi)instance.impl, algorithm,
instance.provider);
}
if (!skipDebug && pdebug != null) {
pdebug.println("MessageDigest." + algorithm +
" algorithm from: " + md.provider.getName());
}
return md;
}
그렇게 나오는 코드는 DigestBase.java이다.
java.security 관련 알고리즘을 provider형태로 제공을 하는데, 우리는 "SHA-256" 처럼 알맞은 타입의 알고리즘을 사용하겠다고 넣어주면 되는 것이다.
결론적으로 값을 넘겨서 어느 알고리즘으로 특정 값을 변환해서 반환해주는지는 알아서 다 해준다.
다만! 여기서 byte는 signed라고 하지 않았는가? MessageDigest에서 반환해주는 값은 각각 0~255인데 java에서는 byte는 signed이다.
앞뒤가 안맞기에 이를 잘 변환해 줘야 한다!
3. 단계적인 메시지 변환
그렇다면 signed 되어있는 값을 unsinged처리해주고 직접 16진수로 변환해주고자 한다면 어떻게 해야할까?
| 단계 | 코드 | 값 | 타입 | 설명 |
| 1 | byteData[i] | -85 | byte | Java가 signed로 읽어서 음수 |
| 2 | & 0xff | 171 | int | 부호 확장 비트를 마스킹으로제거 |
| 3 | + 0x100 | 427 | int | 자릿수를 3자리로 강제 |
| 4 | Integer.toString(_, 16) | "1ab" | String | 16진수 문자열 |
| 5 | .substring(1) | "ab" | String | 패딩 제거 |
2단계에서 음수를 마스킹으로 원래 숫자로 되돌려준다.
그다음 두자리씩 끊기 위해서 맨 앞에 무조건 1을 붙인다. 5와 이를 붙인다면 105가 되는 것이다.
이 숫자를 16진수string으로 변환해서, 마지막에는 강제로 붙인 패딩인 맨 앞의 1을 제거하면 끝이다.
3.1) 직접 변환 예시 코드
필자의 회사에서 legacy 코드에서는 위에서 쭈욱 설명한 일련의 과정을 다음과 같이 처리했다.
// 직접 변환
public static String Sha256data(String str){
String rtnData = "";
try{
MessageDigest md = MessageDigest.getInstance("SHA-256");
md.update(str.getBytes(StandardCharsets.UTF_8));
byte byteData[] = md.digest();
StringBuffer sb = new StringBuffer();
for(int i = 0 ; i < byteData.length ; i++){
sb.append(Integer.toString((byteData[i]&0xff) + 0x100, 16).substring(1));
}
rtnData = sb.toString().toUpperCase();
}catch(NoSuchAlgorithmException e){
e.printStackTrace();
rtnData = "";
}
return rtnData;
}
3.2) hex 변환 트릭 제거
public static String Sha256data(String str){
String rtnData = "";
try{
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] byteData = md.digest(str.getBytes(StandardCharsets.UTF_8)); // 변경: update+digest 한 번에
StringBuilder sb = new StringBuilder(64); // 변경: StringBuffer -> StringBuilder
for (byte b : byteData) { // 변경: 향상된 for
sb.append(String.format("%02X", b)); // 변경: 트릭 제거, & 0xff 도 불필요
}
rtnData = sb.toString(); // 변경: toUpperCase() 불필요
}catch(NoSuchAlgorithmException e){
e.printStackTrace();
rtnData = "";
}
return rtnData;
}
3.3) 오류 반환 코드 변경
public static String sha256(String str){
try{
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] byteData = md.digest(str.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder(64);
for (byte b : byteData) {
sb.append(String.format("%02X", b));
}
return sb.toString();
}catch(NoSuchAlgorithmException e){
throw new IllegalStateException("SHA-256 unavailable", e); // 변경: 감추지 않고 전파
}
}
3.4) HexFormat (Java 17+)
public static String sha256(String str){
try{
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] byteData = md.digest(str.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().withUpperCase().formatHex(byteData); // 변경: 루프 제거
}catch(NoSuchAlgorithmException e){
throw new IllegalStateException("SHA-256 unavailable", e);
}
}
초기에 코드가 어떤 방식이엇는지, 이것이 어떻게 발전할 수 있는지, 그리고 위 코드들을 보면서 기초 내용이 떠오르는가?
필자는 이를 위해 열심히 빌드업 했고 이번 글에서는 기초를 다시 정리했다.
마스킹의 경우에는 초기 코딩을 배울 때 이게 필요한가 시큰둥했다.
SI를 하게 되는 경우에서도 Class로 선언해서 Collection에 담는다던지, 혹은 내가 byte계산을 하지 않는 이상 기억하고 있을 필요가 없기 때문이다.
또한 Java 기초를 다시 떠올리려니 선명하게 기억이 안나서 이번 기회에 복습했다.
그러다보니 Java기초 내용도 있으면서 MessageDigest코드도 있고 짬뽕이 되어버렸다.
MessageDigest관련해서 세부적으로 코드를 들여다보려 했으나 너무 양이 많으며 코드가 복잡했다. 전부 다 까서 볼 수는 없는 노릇이니 적당한 선에서 멈췄다.
마지막으로 예시 코드와 많은 정보를 AI를 통해 확인하고 글을 읽어보고 필자의 말로 정리를 많이 했다.
이렇게 코드는 AI와 검색은 정말 빠르게 AI가 작성해준다.
다만 이를 온전히 이해하는건 전혀 다른 차원의 문제이니 학습을 게을리 하지 말자.
하여간... 오랜만에 들여다보니 재미있었다 하.하.하.

참고:
Codemia : Why doesn't Java support unsigned ints?