App Programming/JAVA2009.05.25 21:43


자바 1.4의 새로운 입출력, NIO API 2부 - Charset을 이용한 인코딩/디코딩처리
NIO의 Charset 클래스를 이용한 캐릭터셋 처리에 대해서 살펴본다.
프로바이더: 최범균
Charset 클래스와 캐릭터셋변환

지난 1부에서는 NIO API의 버퍼와 채널에 대해서 살펴보았다. NIO의 채널은 자바 1.3 까지의 입출력스트림(또는 Reader와 Writer)라는 것을 알게 되었을 것이며, 이 채널은 버퍼를 데이터 저장소로 사용한다는 것도 알게 되었을 것이다. 이번 2부에서는 채널을 통해서 읽어온 데이터의 캐릭터셋을 변환할 때 사용되는 Charset과 논블럭킹 데이터 입출력을 위해서 사용되는 Selector에 대해서 살펴보도록 하겠다.

자바는 애초에 나올때부터 유니코드를 지향해왔다. 하지만, 자바가 제 아무리 유니코드를 지향한다해도 모든 곳에서 유니코드가 통용되는 것은 아니다. 예를 들어, 우리가 매일같이 접속하는 인터넷만하더라도 유니코드 보다는 각 페이지에 알맞은 캐릭터셋을 사용한다. 예를 들어, 대부분의 한글 웹사이트에서 사용되는 캐릭터셋은 EUC-KR(또는 KSC5601)이다. 물론, 윈도우즈 2000이나 윈도우즈 XP와 같이 비교적 최근에 나온 운영체제들은 유니코드를 지원하고 있긴 하다. 하지만, 실제로 사용되는 캐릭터셋은 대부분 그 나라에 알맞은 것들이다.

자바 1.3의 경우 한글로 된 텍스트 파일을 읽어올 때는 InputStreamReader를 간접적으로 사용했었다. InputStreamReader는 바이트 배열을 특정 캐릭터셋에 따라서 알맞게 유니코드로 변환해주기 때문이다. 예를 들어, 한글 윈도우의 MS949 캐릭터셋(EUC_KR과 거의 유사하며 윈도우즈 한글 코드를 처리할 때 사용된다)을 사용하여 작성된 문서를 InputStreamReader를 사용하여 읽어올 경우, InputStreamReader는 MS949 캐릭터셋으로 구성된 바이트 배열을 유니코드로 알맞게 변환해준다. 반대의 경우, 즉 OutputStreamWriter의 경우는 반대의 과정인 유니코드를 해당 캐릭터셋에 알맞은 바이트 배열로 변환해주는 처리를 해준다.

NIO API 역시 IO API와 마찬가지로 캐릭터셋의 변환 처리를 할 수 있는 방법을 제공하고 있는데, 그것이 바로 Charset 클래스이다. Charset 클래스를 포함해서 캐릭터셋 변환과 관련된 클래스들은 java.nio.charset 패키지에 정의되어 있으며, 주요 클래스는 다음과 같다.

  • Charset - 캐릭터셋을 나타내는 클래스
  • CharsetEncoder - 캐릭터를 바이트로 변환해주는 인코더
  • CharsetDecoder - 바이트 데이터로부터 캐릭터를 생성해주는 디코더

Charset 클래스

Charset 클래스는 캐릭터셋 자체를 나타내며, 이 클래스의 인스턴스가 나타내는 캐릭터셋과 유니코드 사이의 변환을 처리해주는 클래스이다. Charset 클래스의 인스턴스는 생성자를 통해서 생성하지 않고 static으로 제공되는 forName() 메소드를 사용해서 생성한다. 예를 들어, 유니코드와 아스키코드 사이의 인코딩/디코딩을 하는 Charset 인스턴스는 다음과 같이 생성할 수 있다.

    Charset cset = Charset.forName("US-ASCII");

위에서 cset은 유니코드와 아스키코드(US-ASCII) 사이의 인코딩/디코딩을 처리해주는 Charset 인스턴스가 된다. JDK1.4는 다음에 표시한 8개의 캐릭터셋을 기본적으로 지원하고 있다.

    ISO-8859-1, ISO-8859-15, US-ASCII
    UTF-16, UTF-16BE, UTF-16LE, UTF-8
    windows-1252

하지만, 위에 없는 캐릭터셋 이외에 EUC-KR이나 EUC-JP와 같은 캐릭터셋에 해당하는 Charset 인스턴서의 지원여부는 사용하는 자바 가상 머신에 따라 다르다. 현재 썬에서 제공하는 가상 머신은 위의 8가지 캐릭터셋에 대한 Charset 인스턴스만을 제공하고 있다. 이 얘기는 썬에서 제공한 JDK로는 EUC-KR 캐릭터셋을 사용하여 표현한 한글문자열을 Charset 클래스를 사용하여 인코딩하거나 디코딩 할 수 없다는 것을 의미한다. 따라서, 앞의 8가지 기본 캐릭터셋으로 표현할 수 없는 글자들에 대해서는 다른 방법으로 인코딩/디코딩 처리를 해야만 한다. 이에 대해서는 뒤에서 설명하기로 하겠다. (앞으로 출시될 1.4.1 버전에서는 EUC-KR을 거의 모든 캐릭터셋에 대해서 Charset을 지원할 예정이다. 따라서 1.4.1 버전을 사용할 경우에는 EUC-KR 캐릭터셋을 처리하기 위해서 별도의 방법을 사용할 필요가 없다.)

Charset 클래스는 유니코드와 지정된 캐릭터셋 사이에 변환을 할 수 있도록 encode() 메소드와 decode() 메소드를 제공하고 있다. 다음은 encode() 메소드를 사용하여 자바의 문자열을 지정한 캐릭터셋으로 인코딩하여 파일로 저장하는 예제 코드이다.

    import! java.nio.charset.*;
    import! java.nio.channels.*;
    import! java.nio.*;
    import! java.io.*;
    
    public class CharsetTest {
       public static void main(String[] args) {
          if (args.length != 2) {
             System.out.println("[사용] java CharsetTest 캐릭터셋 문장");
             System.exit(0);
          }
          
          FileChannel channel = null;
          
          try {
             Charset charset = Charset.forName(args[0]);
             ByteBuffer buff = charset.encode(args[1]);
             
             FileOutputStream out = new FileOutputStream("temp.tmp");
             channel = out.getChannel();
             channel.write(buff);
          } catch(IllegalCharsetNameException ex) {
             System.out.println("잘못된 캐릭터셋 이름: " + args[0]);
          } catch(UnsupportedCharsetException ex) {
             System.out.println("지원하지 않는 캐릭터셋: " + args[0]);
          } catch(IOException ex) {
             System.out.println("입출력 예외: " + ex.getMessage());
          } finally {
             if (channel != null) try { channel.close(); } catch(IOException ex) {}
          }
       }
    }

위 프로그램은 매우 간단하지만, 위 프로그램을 통해서 유니코드를 앞에서 언급한 8가지 캐릭터셋에 알맞은 바이트배열로 변경하는 방법을 알 수 있을 것이다. 예를 들어, CharsetTest를 실행해보자.

    d:\test>java CharsetTest UTF-16 한글과Alphabet의조합
    

이때 생성되는 temp.tmp 파일은 30바이트를 차지하게 된다. 30바이트가 생성되는 이유는 "한글과Alphabet의조합"은 14글자이고 UTF-16(유니코드)에서 한글자는 2바이트를 차지하며, 그리고 추가적으로 2바이트가 어떤 순서로 구성되는지를 나타내기 위해 2바이트가 추가되기 때문이다. 위와 같이 실행한 결과 화면을 도스창의 type 명령어를 사용하여 보면 다음과 같이 출력된다.

    D:\test>type temp.tmp
    ?? ? A l p h a b e t???

이와 같이 출력되는 이유는 도스창의 type 명령어가 유니코드를 지원하지 않기 때문이다. (UTF-16은 한글자가 2바이트를 차지하기 때문에 알파벳도 2바이트를 사용하여 저장되는 것을 알 수 있다.) 유니코드를 지원하는 윈2000이나 윈XP의 메모장에서 temp.tmp 파일을 열어보면 글자가 깨지지 않고 올바르게 보일 것이다.

특정 캐릭터셋의 바이트 배열을 다시 유니코드로 디코딩하는 과정도 인코딩만큼이나 간단하다. 예를 들어, CharsetTest 클래스를 사용하여 인코딩한 바이트 배열을 다시 유니코드로 변환하여 화면에 출력해주는 프로그램은 다음과 같다.

    import! java.nio.charset.*;
    import! java.nio.channels.*;
    import! java.nio.*;
    import! java.io.*;
    
    public class CharsetTest2 {
       public static void main(String[] args) {
          if (args.length != 2) {
             System.out.println("[사용] java CharsetTest 캐릭터셋 파일명");
             System.exit(0);
          }
          
          FileChannel channel = null;
          
          try {
             Charset charset = Charset.forName(args[0]);
             ByteBuffer buff = ByteBuffer.allocate(32);
             
             FileInputStream in = new FileInputStream(args[1]);
             channel = in.getChannel();
             channel.read(buff);
             buff.flip();
             CharBuffer charBuffer = charset.decode(buff);
             System.out.println(charBuffer.toString());
          } catch(IllegalCharsetNameException ex) {
             System.out.println("잘못된 캐릭터셋 이름: " + args[0]);
          } catch(UnsupportedCharsetException ex) {
             System.out.println("지원하지 않는 캐릭터셋: " + args[0]);
          } catch(IOException ex) {
             System.out.println("입출력 예외: " + ex.getMessage());
          } finally {
             if (channel != null) try { channel.close(); } catch(IOException ex) {}
          }
       }
    }

CharsetTest2 클래스는 앞에서 작성한 CharsetTest 클래스와는 정반대로 파일로부터 byte 데이터를 읽어와 ByteBuffer에 저장한 후, 그 ByteBuffer에 있는 데이터를 지정한 캐릭터셋으로 디코딩하여 CharBuffer에 저장한다. 디코딩 작업은 위 코드에서 보다시피 Charset 클래스의 decode() 메소드를 사용하여 수행한다. 디코딩할 바이트 데이터가 저장된 ByteBuffer를 파라미터로 전달하면, 알맞게 유니코드로 디코딩된다.

CharsetEncoder와 CharsetDecoder

앞에서 살펴본 Charset 클래스의 encode() 메소드와 decode() 메소드는 내부적으로는 CharsetEncoder와 CharsetDecoder 클래스를 사용한다. CharsetEncoder와 CharsetDecoder는 이름에서 알 수 있듯이 각각 인코딩처리와 디코딩처리를 해 주는데 이 둘은 Charset 클래스의 newEncoder() 메소드와 newDecoder() 메소드를 사용하여 구할 수 있다. 예를 들어, CharsetEncoder를 사용하여 인코딩 처리를 하려면 다음과 같이 하면 된다.

    Charset charset = Charset.forName(charsetName);
    CharsetEncoder encoder = charset.newEncoder();
    ByteBuffer byteBuff = encoder.encode(charBuff);

CharsetEncoder 클래스는 위 코드에서 보다시피 encode() 메소드를 제공한다. 앞서 살펴봤던 Charset.encode() 메소드도 내부적으로 CharsetEncoder의 encode() 메소드를 사용한다. 실제로 Charset.encode() 메소드는 다음과 같다.

    cs.newEncoder()
      .onMalformedInput(CodingErrorAction.REPLACE)
      .onUnmappableCharacter(CodingErrorAction.REPLACE)
      .encode(bb); 

여기서 onMalformedInput() 메소드와 onUnmappableCharacter() 메소드는 모두 CharsetEncoder 자신을 리턴하는데, 이 두 메소드에 대해서는 뒤에서 설명하도록 하겠다.

CharsetDecoder도 CharsetEncoder를 구하는 방법과 비슷하게 Charset 클래스의 newDecoder() 메소드를 사용하여 구할 수 있다. 즉, 다음과 같이 CharsetDecoder를 구하면 된다.

    Charset charset = Charset.forName(charsetName);
    CharsetDecoder decoder = charset.newDecoder();
    CharBuffer charBuff = decoder.decode(byteBuff);

CharsetDecoder 클래스는 decode() 메소드는 사용하는데, Charset.decode() 메소드는 Charset.encode() 메소드와 마찬가지로 CharsetDecoder.decode() 메소드를 내부적으로 사용한다. 실제 Charset.decode() 메소드는 다음과 동일하다.

    cs.newDecoder()
      .onMalformedInput(CodingErrorAction.REPLACE)
      .onUnmappableCharacter(CodingErrorAction.REPLACE)
      .decode(bb); 

캐릭터 변환 처리 방법 지정하기

바이트 데이터에 해당하는 유니코드가 없는 경우에는 예외를 발생시키거나 또는 처리하지 않고 넘어간다던가 하는 등의 별도 처리가 필요할 것이다. 이처럼 예외나 에러 상황이 발생할 때 어떻게 처리할지의 여부를 지정해주는 메소드가 있는데, 그 메소드가 바로 CharsetEncoder 클래스와 CharsetDecoder 클래스는 모두 onMalformedInput() 메소드와 onUnmappableCharacter() 메소드이다.

onMalformedInput() 메소드는 잘못된 데이터를 만났을 때 어떻게 처리할지를 지정한다. 예를 들어, CharsetEncoder.encode() 메소드에 전달한 문자열중의 일부 글자가 유니코드가 아니거나 또는 반대로 CharsetDecoder.decode() 메소드에 전달한 바이트 데이터가 잘못된 경우 onMalformedInput() 메소드에서 지정한 방식에 따라서 에러 처리를 하게 된다. 예를 들어, 잘못된 데이터를 만났을 때 CharacterCodingException(또는 유니코드가 아닌 경우 MalformedInputException)을 발생시키고자 한다면 다음과 같이 onMalformedInput() 메소드를 호출하면 된다.

    csEncoder = cs.newEncoder();
    csEncoder.onMalformedInput(CodingErrorAction.REPORT);

onMalformedInput() 메소드에는 CodingErrorAction 클래스에 정의되어 있는 상수값이 파라미터로 전달되는 데, CodingErrorAction 클래스에 정의되어 있는 상수는 다음과 같이 세 가지가 존재한다.

  • CodingErrorAction.IGNORE : 에러를 발생시킨 글자(또는 바이트)를 무시하고 다음 글자(또는 바이트)를 인코딩(디코딩)한다.
  • CodingErrorAction.REPLACE : 에러를 발생시킨 글자(또는 바이트) 대신에 지정한 데이터를 삽입하고 인코딩(디코딩)작업을 계속 진행한다.
  • CodingErrorAction.REPORT : 인코딩/디코딩 작업을 중단하고 CharacterCodingException(또는 CharacterCodingException을 상속받은 하위 클래스) 예외를 발생시킨다.

onUnmappableCharacter() 메소드도 onMalformedInput() 메소드와 마찬가지로 CodingErrorAction 클래스에 정의된 상수인 IGNORE, REPLACE, REPORT 중 하나의 값을 파라미터로 전달받는다. onUnmappableCharacter() 메소드는 인코딩/디코딩 작업시에 변환할 수 없는 글자를 만났을 때 어떻게 처리할지를 나타낸다. 예를 들어, A 캐릭터셋에는 존재하지만 B 캐릭터셋에는 존재하지 않는 글자를 인코딩하거나 디코딩하려 할 때에는 변환을 할 수 없을 것이며, 이런 경우 onUnmappableCharacter() 메소드를 사용하여 onMalformedInput()과 마찬가지로 그냥 무시할지 아니면 다른 글자로 변환할지 아니면 예외를 발생할지의 여부를 지정할 수 있다.

onMalformedInput() 메소드와 onUnmappableCharacter() 메소드에 CodingErrorAction.REPLACE를 지정하면 인코딩/디코딩 작업을 할 수 없는 글자나 문자에 대해서는 지정한 데이터로 변환을 한다고 했었는데, 이때 변환될 데이터는 replaceWith() 메소드를 사용하여 지정할 수 있다. CharsetEncoder와 CharsetDecoder는 각각 다음과 같이 repalceWith() 메소드를 정의하고 있다.

  • CharsetEncoder: replaceWith(byte[] newReplacement)
  • CharsetDecoder: replaceWith(String newReplacement)

replaceWith() 메소드에 전달할 수 있는 값에는 몇가지 제약사항이 존재한다. CharsetEncoder.replaceWith() 메소드에 전달되는 byte 배열의 경우 길이가 0보다 커야 하고 maxBytesPerChar()가 리턴하는 값보다 길어서는 안 되며, 인코딩할 캐릭터셋에 존재하는 바이트 배열이어야 하고 유니코드로 디코드 할 수 있어야만 한다. CharsetDecoder.replaceWith() 메소드에 전달되는 String은 null이 아니어야 하고 길이가 0보다 길어야 한다.

CoderResult를 사용하여 인코딩/디코딩 결과 처리하기

CharsetEncoder 클래스와 CharsetDecoder 클래스는 앞에서 살펴본 encode()/decode() 메소드 뿐만 아니라, 다음과 같이 인코딩/디코딩을 할 수 있는 메소드를 추가로 제공하고 있다.

  • CharsetEncoder: CoderResult encode(CharBuffer in, ByteBuffer out, boolean endOfInput)
  • CharsetDecoder: CoderResult decode(ByteBuffer in, CharBuffer out, boolean endOfInput)

두 메소드는 모두 CoderResult를 리턴하는데, CoderResult는 인코딩/디코딩 결과를 저장하고 있다. 위의 encode()/decode() 메소드는 앞에서 살펴봤던 encode(CharBuffer in)/decode(ByteBuffer in) 메소드와 달리 캐릭터변환 과정에서 캐릭터 매핑 에러가 있거나 입력이 잘못된 경우 예외를 발생시키지 않는다. 대신 에러가 발생했다는 사실을 리턴하는 CoderResult 객체에 표시한다. CoderResult는 다음과 같은 메소드를 제공하고 있으며, 이들 메소드를 사용하여 처리가 올바르게 되었는지 여부를 알려준다.

메소드 설명
isError() 처리 과정에서 에러가 발생한 경우 true를 리턴한다.
isMalformed() 잘못된 입력(Malformed Input) 데이터가 있을 경우 true를 리턴한다.
isUnmappable() 매핑할 수 없는 데이터를 입력한 경우 true를 리턴한다.
isOverflow() 오버플로우가 발생한 경우 true를 리턴한다.
isUnderflow() 언더플로우가 발생한 경우 true를 리턴한다.
throwException() 인코딩/디코딩 처리 결과에 알맞은 예외를 발생시킨다. 발생하는 예외 종류는 다음과 같다.
  • MalformedInputException
  • UnmappableCharacterException
  • CharacterCodingException
  • BufferOverflowException
  • BufferUnderflowException

위 코드에서 오버플로우와 언더플로우는 에러라기 보다는 계속해서 인코딩/디코딩 작업이 필요하다는 것을 의미한다. CharsetEncoder의 encode(CharBufff, ByteBuffer, boolean) 메소드에서 오버플로우와 언더플로우 그리고 잘못된 입력, 매핑불가능은 다음과 같은 의미를 나타낸다.

  • CoderResult.UNDERFLOW - 입력 버퍼(CharBuff)에서 최대한 많은 양의 데이터를 인코딩했음을 나타낸다. 만약 입력 버퍼에 글자가 남아 있지 않고 입력 데이터가 더 이상 존재하지 않는다면 인코딩 처리가 완료된다. 그렇지 않고 입력 데이터가 불충하다면 입력 데이터를 추가적으로 받아서 인코딩 처리를 해야 한다는 것을 나타내기도 한다.
  • CoderResult.OVERFLOW - 출력 버퍼가 다 찼음을 의미한다. 따라서 다차있지 않은 출력 버퍼를 사용하여 다시 한번 인코딩 처리를 해 주어야 한다.
  • 잘못된 입력(malfored-input) - 잘못된 입력 데이터가 있음을 나타낸다. 버퍼의 현재 위치(position)는 잘못된 글자에 위치한다. 단, onMalformedInput() 메소드에 CodingErrorAction.REPORT를 지정한 경우에만 동작한다.
  • 매핑할 수 없는 글자 - 지정된 캐릭터셋으로 인코딩할 수 없는 글자가 있음을 나타낸다. 버퍼의 현재 위치는 매핑할 수 없는 글자에 위치한다. 단, onUnmappableCharacter() 메소드에 CodingErrorAction.REPORT를 지정한 경우에만 동작한다.

이 중, 오버플로우와 언더플로우는 버퍼를 사용할 때 반드시 필요한 정보중의 하나이다. 이 두 정보를 어떻게 사용할 수 있는 지에 대해서는 뒤에서 살펴볼 것이다.



Posted by BAGE