[Java] 28. 문자 타입(CharacterSet)과 엔디언(endian)으로 변환하는 방법


Study/Java  2020. 6. 3. 19:43

안녕하세요. 명월입니다.


이 글은 Java에서 사용되는 문자 타입(CharacterSet)과 엔디언(endian)으로 변환하는 방법에 대한 글입니다.


이전에 IO와 Socket에 대해 설명한 적이 있습니다.

링크 - [Java] 26. 파일(IO)를 다루는 방법(파일의 작성, 수정, 사용 날짜 변경과 close를 하는 이유, Closable 사용법)

링크 - [Java] 27. 네트워크 통신(Socket)을 하는 방법


Java에서는 이 IO와 Socket을 InputStream과 OutputStream을 이용해서 파일을 읽거나 쓰고, 통신에서는 메시지를 전송하거나 수신을 합니다.

이 InputStream과 OutputStream에서 데이터를 다루는 자료형은 byte배열(byte[])입니다.

이 byte는 무엇인가 하면 데이터의 바이너리라라고 해서 데이터의 8bit로 되어있는 2진 데이터입니다.

파일 정보를 보시면 파일의 사이즈를 구성할 때 위 이미지처럼 몇 바이트로 나타냅니다.


그럼 우리가 파일 전송 프로그램을 만든다고 하면 어떻게 할까요?

그렇습니다. IO로 파일을 읽어서 byte[] 형식으로 만들고 그대로 Socket을 통해서 전송합니다. 수신 측에서는 byte[] 형식으로 받고 그대로 IO로 파일을 쓰면 그게 파일 전송 프로그램이 되는 것입니다.


그럼 프로그램에서 사용되는 문자는 어떻게 파일로 만들까요?

Java에는 String 객체가 있기 때문에 String을 byte[]로 변환해서 저장하면 text 문서가 되는 것입니다.

import java.io.File;
import java.io.FileOutputStream;
import java.io.OutputStream;
public class Example {
  // 실행 함수
  public static void main(String[] args) {
    try {
      // 파일로 저장할 test 변수 선언
      String test = "hello world";
      // String 타입을 byte 형식으로 변환
      byte[] binary = test.getBytes();
      // 파일 인스턴스 생성
      File file = new File("d:\\work\\test.txt");
      // Stream 인스턴스 생성
      OutputStream stream = new FileOutputStream(file);
      // OutputStream에 test의 바이너리를 작성
      stream.write(binary);
      // Stream 리소스 닫기
      stream.close();
    } catch (Throwable e) {
      e.printStackTrace();
    }
  }
}

그런데 이 문자 열은 취급하는 타입이 정해져 있습니다.

과거 C언어 시절에는 PC가 영어만 사용할 수 있었습니다.

영어는 대소문자 합쳐서 약 52개 숫자, 특수 문자까지해서 총 128개의 문자만 사용했었습니다. 그것이 ASCII 코드입니다.

ASCII 코드는 총 128개로 char(볌위: -128~127)의 정수 부분에 들어가는 문자입니다. 그래서 문자열을 취급할 때 char 타입을 다루었었습니다.

그런데 현재는 영어 뿐아니라 한글 등 세계 여러 공통 언어를 사용하게 되었습니다.

세계 공통 언어를 데이터 타입에 넣으려고 하면 char로는 부족합니다. byte(unsigned char - 즉 부호없는 char, 범위: 0 ~ 255(2^8))로도 부족하네요. 한글만 해도 개수가 약 5만에서 6만자인데 255가지고는 택도 없겠네요.

그래서 각자 나라마다 byte를 조합해서 각국의 언어 코드를 만들게 됩니다.

그런데 이게 표준이 되지 않으니 문제가 발생합니다. 한국에서 한글을 작성한 문서를 일본 OS에서 열면 열리지가 않는다던가 에러가 발생한다던가 하는 문제입니다.


그래서 나온 표준 문자열 코드가 Unicode입니다.

import java.io.File;
import java.io.FileOutputStream;
import java.io.OutputStream;
public class Example {
  // 실행 함수
  public static void main(String[] args) {
    try {
      // 파일로 저장할 test 변수 선언
      String test = "한글입니다.";
      // String 타입을 byte 형식으로 변환 (unicode로 변환)
      byte[] binary = test.getBytes("unicode");
      // 파일 인스턴스 생성
      File file = new File("d:\\work\\test.txt");
      // Stream 인스턴스 생성
      OutputStream stream = new FileOutputStream(file);
      // OutputStream에 test의 바이너리를 작성
      stream.write(binary);
      // Stream 리소스 닫기
      stream.close();
    } catch (Throwable e) {
      e.printStackTrace();
    }
  }
}

이 Unicode는 기본적으로 2~4byte를 사용합니다. 4byte라면 2의 32승이니 총 4,294,967,296자를 다룰 수 있겠네요.

총 6글자에 unicode를 알리는 2byte해서 6*2+2=총 14byte가 나옵니다.

위 예제를 보면 한글입니다 뒤에 점(.)이 있습니다. 이 점은 ascii코드에도 있는 문자이기 합니다만 unicode에서는 2byte로 계산이 됩니다.

이 뜻은 영어도 2byte를 사용하게 되는데 이게 문제가 기존 ascii코드로만 작성되었던 문서들이 unicode로 넘어오면서 문서 크기가 최소 2배 이상 커지게 된 것입니다.


즉, 문자열 하나 바꾸므로 해서 하드웨어 용량이 2배로 필요하게 된 것입니다.


그래서 나온 문자열이 UTF-8로 1byte부터 6byte까지의 가변 데이터 코드입니다.

솔직히 최근의 모든 문자열 코드는 이 UTF-8로 전부 표현이 가능합니다. 새로운 언어를 창시하지 않는 이상 UTF-8이 가볍고 자주 사용합니다.


자바에서 이 UTF-8를 다루는 방법은 간단합니다.

String에서 byte로 변환할 때 파라미터로 UTF-8를 넣고, byte에서 string으로 변환할 때 파라미터로 UTF-8를 넣으면 됩니다.

public class Example {
  // 실행 함수
  public static void main(String[] args) {
    try {
      // 파일로 저장할 test 변수 선언
      String test = "한글입니다.";
      // String 타입을 utf-8 형식의 바이너리로 변환
      byte[] binary = test.getBytes("UTF-8");
      
      // UTF-8코드를 ASCII코드로 String으로 변환
      String test1 = new String(binary,"ASCII");
      // 당연히 깨질 듯!
      System.out.println(test1);
      
      // UTF-8코드를 String으로 변환
      String test2 = new String(binary,"UTF-8");
      // 콘솔 출력
      System.out.println(test2);
      
    } catch (Throwable e) {
      e.printStackTrace();
    }
  }
}

UTF-8 타입을 ASCII로 변환하니 문자열이 깨지네요... 당연한 것입니다. 보통 프로그램 안에서 문자열이 깨지는 원인은 100%로 이 문자 타입(CharacterSet) 맞지 않아서 입니다.


문자열은 여기까지 되었고 이번에는 숫자입니다.

프로그램에서 가장 많이 사용되는 숫자형 데이터 타입은 int형입니다. int형은 기본적으로 4byte입니다.

즉, int의 수를 byte로 나타내면 4byte 배열이 나오는 것입니다.


이 int형을 byte로 변환하는 클래스는 ByteBuffer가 있습니다.

import java.nio.ByteBuffer;
public class Example {
  // 실행 함수
  public static void main(String... args) {
    // int의 크기만큼 버퍼 생성
    ByteBuffer data = ByteBuffer.allocate(Integer.BYTES);
    // int형 값 10을 넣음
    data.putInt(10);
    // byte형으로 출력
    byte[] binary = data.array();
    // 콘솔 출력
    for (byte b : binary) {
      System.out.print(b < 0 ? b + 256 : b);
      System.out.print("\t");
    }
  }
}

byte[4]에 int형 값이 들어 갔습니다. 참고로 이대로 IO로 파일을 만들면 10이라는 값이 나오는 게 아닙니다. IO로 만들어지는 10은 String 타입의 10으로 ASCII코드라면 49, 48입니다.

int형을 byte로 변환을 하는 것일까?

우리가 파일 전송 프로그램을 작성한다고 할 때, 파일의 사이즈를 수신 측에 알려줘야 수신 측에서 버퍼를 만들고 대기를 하게 됩니다. 그런데 이 값을 String으로 보내게 되면 이 크기의 값을 구하는 길이도 제각각이게 됩니다. 500이라면 3글자 5000이라면 4글자.. 같이 말입니다.

그런데 4바이트로 딱 정해 놓고 int형으로 전송을 하게 되면, 받는 측에서 4byte로 대기를 하고 파일의 길이를 받아 알 수 있게 되는 것입니다.


그런데 이 Java에서는 이 int형을 빅 엔디언이라고 byte[3]부터 수를 채우게 됩니다. 그런데 프로그램 규격에 따라 이게 반드시 빅 엔디언으로 하는 것은 아닙니다. (참고로 C#은 리틀 엔디언입니다.)

리틀 엔디언이라고 앞에서 채우는 방식도 있습니다.

import java.nio.ByteBuffer;
import java.nio.ByteOrder;
public class Example {
  // 실행 함수
  public static void main(String... args) {
    // int의 크기만큼 버퍼 생성
    ByteBuffer data = ByteBuffer.allocate(Integer.BYTES);
    // 리틀 엔디언 순서로 설정
    data.order(ByteOrder.LITTLE_ENDIAN);
    // int형 값 10을 넣음
    data.putInt(10);
    // byte형으로 출력
    byte[] binary = data.array();
    // 콘솔 출력
    for (byte b : binary) {
      System.out.print(b < 0 ? b + 256 : b);
      System.out.print("\t");
    }
  }
}

위 소스는 리틀 엔디언으로 만들어서 byte[0] 부터 수를 채우게 됩니다. (이것도 좀 표준으로 하지....)

import java.nio.ByteBuffer;
public class Example {
  // 실행 함수
  public static void main(String... args) {
    // double의 크기만큼 버퍼 생성
    ByteBuffer data = ByteBuffer.allocate(Double.BYTES);
    // double형 값 10을 넣음
    data.putDouble(100.52d);
    // byte형으로 출력
    byte[] binary = data.array();
    // 콘솔 출력
    for (byte b : binary) {
      System.out.print(b < 0 ? b + 256 : b);
      System.out.print("\t");
    }
    // 개행
    System.out.println();
    // binary를 버퍼로 생성
    ByteBuffer ret = ByteBuffer.wrap(binary);
    // 값 출력
    System.out.println(ret.getDouble());
  }
}

ByteBuffer는 int형 취급하는 건 아닙니다. double이나 float 등도 변환이 가능합니다.


여기까지 Java에서 사용되는 문자 타입(CharacterSet)과 엔디언(endian)으로 변환하는 방법에 대한 글이었습니다.


궁금한 점이나 잘못된 점이 있으면 댓글 부탁드립니다.