주요 charset 소개 및 mybatis typehandler를 통한 한글 다루기

오민혁
SSG TECH BLOG
Published in
51 min readMar 24, 2023

안녕하세요. SSG.COM 상품플랫폼팀 오민혁입니다.

제가 속해있는 파트는 SSG의 상품 재고 관리 및 재고 데이터 연동 등의 업무를 맡고 있는데요, 저희가 맡은 업무중에는 SSG뿐만 아니라 다른 관계사와 데이터를 송수신하는 것 또한 포함되어있습니다.

관련하여 지난 2022년 12월, 관계사로 재고 데이터 연동 배치를 개발 및 테스트하던 중에 한글 깨짐 현상이 발생하였습니다.

입사한지 얼마 안되었으나, charset과 관련한 문제를 해결해야 했습니다.
이러한 현상을 단순하게 해결하는 것 뿐만이 아니라, 발생하는 원인도 정확하게 학습해야 앞으로 좋은 개발자로 성장할수 있다고 생각하였고, 이슈 해결을 위해 주요 charset의 원리와 동작방식, Java와 mybatis typehandler 클래스를 조사해보았습니다.

관련하여, 이를 해결하기 위해 분석하고 적용했던 내용을 소개하고자 합니다.

1. 배경

SSG에서 관계사로 재고 데이터 연동 배치를 개발하던 중에 데이터 적재 자체는 정상적으로 되었으나, 한글이 포함된 값은 깨져서 저장되는 문제가 발생하였습니다.

이슈사항

위 표와 같이 한글깨짐 현상이 발생한 원인은 SSG DB와 관계사 DB간 charset이 달랐고, 이로 인해 한글을 포함한 값에 대해 인코딩 방식이 달랐기 때문입니다.

원인을 구체적으로 분석하고 문제해결을 위해서는 먼저 각 개발환경의 charset을 알고 넘어가야했습니다. 관계사에서는 UTF-8 DB를 사용한다고 전달받았고, 제 local PC 역시 UTF-8 방식을 사용하고 있었습니다.

따라서, SSG의 Oracle DB가 사용하는 charset이 달라 문제가 발생했다고 판단하여 어떤 charset을 사용하는지 조회하기 위해 다음과 같은 쿼리를 실행하였습니다.

조회 쿼리

조회 쿼리 및 실행 결과는 아래와 같았습니다.
(2023/02 기준 결과이며, 현재 기술부채 해결을 위해 UTF-8 전환 작업을 진행중입니다.)

-- NLS_DATABASE_PARAMETERS 테이블에서 캐릭터셋만 조회
SELECT * FROM NLS_DATABASE_PARAMETERS WHERE PARAMETER = 'NLS_CHARACTERSET';
조회 결과
환경별 charset

2. 주요 캐릭터셋

문제 해결을 위한 구체적인 해결방안을 도출하기위해서 앞서, 주요 charset별 특징과 동작 원리를 짚고 넘어가야 했습니다.

모든 charset을 구체적으로 분석하기보다는, (각 개발환경에서 사용하는) US7ASCII와 UTF-8 그리고 한글을 표현하기 위해서 사용되는 주요 인코딩을 위주로 특장과 동작 원리를 분석하였습니다.

2.1. 아스키(ASCII)코드

먼저, charset 개념은 컴퓨터는 문자든 숫자든 모두 0과 1로만 기억 한다는 점에서 등장하였는데요, 0과 1로 이루어진 바이트 공간에 문자 코드표를 매칭하여 문자를 정의한 것이 가장 기본적인 문자 표현 방식이였습니다.

하지만 , 어떤 바이트 공간을 어떤 문자에 대응시키는가에 따라 다양한 인코딩 방식이 존재했고, 대응방식이 다르다면 데이터 송수신 중 호환이 되지않아 문자깨짐 등 다양한 이슈가 발생하였습니다.
이러한 문제를 해결하고자, 1960년 ASA(ANSI의 전신)가 이에 대한 표준화작업을 시작하였고 그렇게 바이트 공간에 대응하는 문자를 정의한 것이 아스키코드였습니다.

이러한 ASCII코드 기반을 채택하는 charset은 다음과 같습니다.

2.1.1. US7ASCII

  • US 7-bit ASCII character set (7비트 인코딩)의 약자
  • SSG Oracle DB가 채택하는 charset (2023/03 기준)

ASA에서 표준화한 인코딩 방식이며 7bit만을 사용하여 표현하며, 나머지 1비트는 오류체크 목적으로 사용(= parity bit)하였습니다.

간단한게 문자표현 방식을 살펴보자면 아래와 같고, 7bit만을 사용하였기 때문에 숫자, 알파벳, 일부 기호만 표현가능하다는 한계가 존재하였습니다.

  • 33개의 제어문자(출력불가능), 52개의 영문 알파벳 대소문자, 10개의 숫자, 32개의 특수문자 1개의 공백문자(NULL)(출력가능)
    → 총 128개로 구성

US7ASCII에서 문자를 표현하는 방식은 아래 아스키표와 같습니다.
(간단하게 예시를 들어보자면, ‘O’라는 문자 4F byte 공간(= 1001111 bit 공간)에 매칭되어 표현되는 방식입니다.)

아스키표

2.1.2. ISO 8859–1

US7ASCII로는 7bit만을 사용하여 문자를 표현하였기 때문에, 모든 문자를 표현하지 못한다는 한계가 있었고 이를 극복하기위해 등장한 charset입니다. ASCII에서 남은 1bit까지 포함하여 8비트로 확장한 아스키코드이며, 256(= 2⁸)개를 표현가능하게 되었습니다.

00~7F까지 바이트 공간에 매칭되는 문자는 US7ASCII와 동일하여 완전히 호환가능하였고, 그 이후 공간에 어떤 문자가 매칭되는지에 따라 ISO-8850–1, ISO-8850–2, ISO-8850–3으로 나뉘었습니다.

이 3가지 charset의 차이는 아래와 같습니다.

  • ISO-8859–1 : 서유럽언어(네덜란드어, 노르웨이어, 덴마크어, 독일어 등..)를 표현가능
  • ISO-8859–2 : 중앙유럽언어 ~ 동유럽언어(라틴문자언어)를 표현가능
  • ISO-8859–3 : 남유럽언어(튀르키예어, 몰타어 등..)을 표현가능

위와 같이 확장된 1bit에 대해 128(0x80)범위부터 캐릭터셋간 인코딩이 충돌하여 문자표현에 한계가 존재하였고, 여전히 한글이 대응되는 바이트 공간은 없으므로 한글은 표현할 수 없었습니다.

2.1.3. EUC-KR

당시 ASCII 기반으로 한글을 표현하기 위한 charset은 EUC-KR과 MS949 두가지 방식이 존재하였습니다. 두 charset은 거의 동일하고 특정 바이트 공간까지는 매핑되는 문자가 동일하여 완전히 호환가능하나, 일부 표현이 가능한 문자가 다르다는 차이가 존재하였습니다.

먼저 EUC-KR은 2byte(=16bit)로 한글을 표현하였고,
한글/숫자/특수문자/영문/한문/일어를 표현할 수 있었습니다(이외의 문자는 표현 불가).

EUC-KR charset에서 문자와 바이트 공간을 매핑하는 방식은 아래 행열과 같은데요. 가령, ‘가’ 라는 한글 문자는 B0A1(hex) byte 공간에 매핑하여 표현하는 방식입니다.
(B0A1 = B0A0(행) + 1(열) = 10110000 10100001(bin) = 176 + 161(dec))

EUC-KR 문자 대응표

EUC-KR의 바이트공간과 문자 대응 표를 그림으로 나타내면 다음과 같습니다. ([00–7F]는 16진법으로 나타낸 bit 공간입니다)

EUC-KR 문자 대응 공간 1

00~7F 비트 공간에는 KS X 1003을 배당하였는데, 이 KS X 1003는 ASCII기반 체계의 charset이며 역슬래시(‘\’)기호에 원화(‘₩’)가 들어간 것 빼고는 ASCII와 동일하다는 특징이 있습니다.

이후 [A1~FE][A1~FE] 비트 공간에는 KS X 1001을 배당하였고, 이 때 행과 열에 128을 더한 코드값을 사용하여 2바이트로 특수문자, 한글, 한자를 표현하였습니다.

EUC-KR 문자 대응 공간 2

한글 글자영역(초록색음영)에 자주 쓰이는 2350자만 가나다 순서대로 배열하여 한글을 표현하였으나, 이로인해 이 2350자에 포함되지 않은 글자는 정확히 인식하지 못하는 문제가 발생하였습니다.

실제로 관련 내용을 찾던 중에, 주문 요청사항에김뺴주세요’ 라고 오타가 났으나, EUC-KR charset에서는 ‘뺴’라는글자를 표현하지 못하여 아래와 같이 출력되었고 김만 받았다는 해프닝도 있었습니다 😂

출처 : https://story.kakao.com/ch/today_issue/h1shUOR50yA

한글 표현 방식은 아래와 같이 3가지가 존재하는데, EUC-KR은 완성형 인코딩 방식을 통해 한글을 표현하였기 때문에 발생하는 이슈였습니다.

한글 인코딩 방식

조합형
-
한글 자음 & 모음을 즉석에서 조합하여 한글을 표현
- MS-DOS 환경에서 사용
- 11172자의 모든 현대 한글 표현 가능

완성형(EUC-KR의 방식)
-
한글 글쓰기에서 자주 사용되는 한글(e.g. ‘가’, ‘나’, ‘다’, ‘라’ 등..)을 미리 만들어서 사용하는 방식
- 과거 윈도우 환경에서 사용
- 자모를 다시 분리하지 못함
- 미리 만들어진 일부 한글만을 표현가능

확장 완성형
-
기존 완성형의 빈공간에 “랔”, “똠”, “홥”, “쓩” 등 자주 사용되지 않는 글자를 억지로 추가해놓은 것
- 이진법상으로 글자들이 순서대로 정리되어있지 않음
-윈도우98에서 사용

2.1.4. x-windows-949(= MS949, CP949)

x-windows-949 문자 대응 공간 1

EUC-KR charset에서는 일부 한글은 표현되지 않는다는 단점을 보완하기 위해 마이크로소프트사가 개발한 EUC-KR의 확장 버전 charset이며, 확장 완성형 한글 인코딩 방식을 따릅니다.

[00–7F]와 [A1-FE][A1-FE] btye 공간은 EUC-KR과 동일한 문자와 매핑되어, 두 charset간 완전히 호환가능하다는 특징이 있습니다.
여기에 추가적인 (위 그림에 분홍색으로 CP-949 확장 이라고 색칠해둔) byte공간에 EUC-KR에는 표현할 수 없었던 한글을 억지로 추가하여 더 많은 한글을 표현하도록 하였습니다.

예를들어, 두 charset간 ‘위’라는 글자와 ‘갘’ 이라는 문자의 표현 방식은 다음과 같습니다.

‘위’
-
매핑되는 바이트 공간 : C0A7(hex)
- EUC_KR과 x-winodws-949에서 매핑되는 비트 공간이 동일(= 하위호환성을 가짐)

‘갘’
- 매핑되는 바이트공간 : 814A(hex)
- EUC_KR은 이 매핑공간에 문자가 매핑되지 않으므로 표현 불가
- CP949만 매핑되는 비트공간(위 그림의 분홍색 부분)이 존재하므로 표현 가능

2.2. 유니코드(Unicode)

ASCII코드를 채택할 경우, 동일한 문자를 사용하는 한 국가내에서는 문자를 제대로 표현가능하여 큰 이슈가 없었으나,
문자코드가 다른 국가끼리 이메일 혹은 웹 통신시에 비트 공간에 매핑되는 문자가 다른 경우 여전히 문자가 깨지는 현상이 발생하였습니다.

이에 따라, 서로 다른 모든 언어를 사용하는 컴퓨터들이 문제없이 통신하기 위해서 2~3 바이트의 넉넉한 공간에 세상의 모든 문자를 할당해서 만든 통일된 캐릭터셋이 필요하여 등장한 방식입니다.

유니코드에서는 코드포인트(code point)를 통해서 문자에 대응되는 비트 공간을 정의하였는데요. 코드포인트는 유니코드의 값을 나타내기 위한 숫자이며, prefix로 U+를 붙여 표현한다는 특징이 있습니다. (예를들어, ‘A’의 유니코드 값은 U+0041(ghrdms \u0041) 으로 표현됩니다.)

유니코드 표

세계의 각 문자(영어/숫자/기호/한글/한자/일본어 등…)마다 code point를 할당하여 사용하였습니다.
초기에는 16bit(=65536)개의 글자를 표현가능할 수 있었으나, 유니코드2.0 이후로 21bit를 사용하여 약 110만개의 글자를 표현가능하게 되었습니다.

가장 널리 사용되는 Java의 문자열 역시 내부적으로 UTF-16 인코딩으로 저장하며, 직렬화를 위해 UTF-8로 변형하여 사용합니다.(변형된 UTF-8)

이처럼 Unicode부터는 11172자의 모든 현대 한글을 표현할 수 있게 되었습니다.

2.2.1. UTF-8

8-bit Unicode Transformation Format의 약자이며, 가장 많이 사용되는 유니코드기반의 인코딩 방식입니다.

1바이트 영역은 기존에 영어를 표현하던 ASCII와 호환가능하여 유니코드 적용이 서구권을 중심으로 퍼졌기에 자연스레 대세가 된 charset입니다.

3. 이슈

3.1. 이슈 및 요구사항

US7ASCII와 UTF-8을 비롯한 Charset별 특징과 동작원리를 간략하게 살펴보았는데요.

다시 이슈 상황으로 돌아가보면, 아래와 같이 표현되어야할 한글 문자열 값이 의도와 다른 문자로 표현되어 목적지인 관계사 DB에 적재된다는 것이 문제였습니다.

이슈 상황

이러한 이슈상황에 대해 다음과 같은 요구사항을 도출하였습니다.

  1. 데이터베이스별로 캐릭터셋이 상이함
  2. 이에 따라 한글이 포함되는 문자열을 받을시 캐릭터셋을 포함하여 관리가 요구됨

이에따른 요구사항을 기반으로 다음과 같은 해결방안을 도출하였습니다.

  1. 데이터베이스별로 캐릭터셋이 상이 : myabtis의 typehandler 클래스를 활용하여 DB별로 인코딩방식을 각각 관리
  2. 캐릭터셋을 포함하여 문자열 관리가 요구됨 : 자바의 getBytes 메소드를 활용하여 바이트 배열을 통한 캐릭터셋 관리

3.2. 해결방안

앞서 소개한 한글깨짐 이슈를 해결하기 위해서 mybatis의 typehandler 클래스, Java의 getBytes 메소드와 String 생성자를 활용하여 로직을 개선하였습니다. 각 클래스와 메소드에 대해 먼저 소개를 한 뒤, 최종적으로 코드를 수정하도록 하겠습니다.

먼저 mybatis typehandler 클래스에 대한 내용은 다음과 같습니다.

1. mybatis typehandler 클래스

DB에 접근하여 CRUD 작업시에, JDBC type에 맞는 Java 타입을 매핑하기 위해 적절한 java type을 찾고 변경해주는 역할을 하는 클래스입니다.
interface를 구현하거나 baseTypeHadler 클래스를 상속받아 구현할 수 있으며, 인코딩 변환/암호화/ENUM 코드 매핑등 다양하게 활용할 수 있습니다.

저는 SSG DB와 ECVAN DB별로 호환되는 인코딩 방식으로 전환해주는 Custom Typehandler를 구현하여 사용하도록 하였습니다.

mybatis typehandler 클래스는 다음과 같이 3가지 방식으로 적용할 수 있습니다.

적용 방법

1) mybatis-config.xml 설정

<typeHandlers>
<typeHandler handler="com.sample.mybatis.enums.UserTypeHandler"/>
</typeHandlers>

2) mapper.xml에 직접 명시

<resultMap id="selectDataResultMap" type="dataSendDto>
...
<result property="strCode" column="STR_CODE"/>
<result property="strNm" column="STR_NM" typeHandler="com.sample.mybatis.enums.UserTypeHandler"/>
...
</resultMap>

3) SqlSessionFactory를 통한 설정

  • DB Connection 설정 파일
public class DBConnection {

private static final Logger LOG = LoggerFactory.getLogger(DBConnection.class);
private SqlSessionFactory sqlSessionFactory;
private TypeHandler<?> typeHandlers;

public void setTypeHandlers(TypeHandler<?> typeHandlers) {this.typeHandlers = typeHandlers;}

public SqlSessionFactory getSqlSessionFactory() throws IOException {

try {

this.sqlSessionFactory = new SqlSessionFactoryBuilder().build(Resources.getResourceAsReader("mybatis-config.xml"), environmentId);

// 외부로부터 주입받은 Typehandler객체를 SqlSessionFactory에 등록
sqlSessionFactory.getConfiguration().getTypeHandlerRegistry().register(typeHandlers);

} catch (IOException e) {
LOG.info("DB Connection failed" + e.getMessage());
throw e;
}

return this.sqlSessionFactory;
}
}
  • 사용 예시
public ExampleJob() {
...

selfClassName = this.getClass().getSimpleName();
dbConnection = new DBConnection();

// DB Connection 생성시에 접근할 DB에 대해 어떤 Typehandler을 적용할지 명시
dbConnection.setTypeHandlers(new UserTypeHandler());

...
}

구현 방법

typehandler을 구현하는 방식으로는 2가지가 있습니다.

  • Interface(org.apache.ibatis.type.TypeHandler)를 구현

출처 : https://amagrammer91.tistory.com/115

public enum UserType {

ADMIN("USER000"),
USER("USER001"),
GUEST("USER002");

private String code;

UserType(String code) {
this.code = code;
}

public String getCode() {
return code;
}
}
@MappedTypes(UserType.class) // type handling을 할 java type을 명시
@MappedJdbcTypes(JdbcType.VARCHAR) // type handling 대상이 되는 JDBC type을 명시
public class UserTypeHandler implements TypeHandler<UserType> {

@Override
public void setParameter(PreparedStatement ps, int i, UserType parameter, JdbcType jdbcType) throws SQLException {
ps.setString(i, parameter.getCode());
}

@Override
public UserType getResult(ResultSet rs, String columnName) throws SQLException {
String code = rs.getString(columnName);
return getCodeEnum(code);
}

@Override
public UserType getResult(ResultSet rs, int columnIndex) throws SQLException {
String code = rs.getString(columnIndex);
return getCodeEnum(code);
}

@Override
public UserType getResult(CallableStatement cs, int columnIndex) throws SQLException {
String code = cs.getString(columnIndex);
return getCodeEnum(code);
}

private UserType getCodeEnum(String code) {
switch (code) {
case "0":
return UserType.ADMIN;
case "1":
return UserType.USER;
case "2":
return UserType.GUEST;
}
return null;
}
  • org.apache.ibatis.type.BaseTypeHandler를 상속받아 구현
@MappedTypes(UserType.class) // type handling을 할 java type을 명시
@MappedJdbcTypes(JdbcType.VARCHAR) // type handling 대상이 되는 JDBC type을 명시
public class UserTypeHandler extends BaseTypeHandler<String> {

@Override
public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) throws SQLException {
String str = parameter;
ps.setString(i, str);
}

@Override
public String getNullableResult(ResultSet rs, String columnName) throws SQLException {
String str = rs.getString(columnName);

try {
if (str != null) str = new String(str.getBytes("8859_1"), "x-windows-949");
} catch (UnsupportedEncodingException e) {
str = rs.getString(columnName);
}

return str;
}

@Override
public String getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
String str = rs.getString(columnIndex);

try {
if (str != null) str = new String(str.getBytes("8859_1"), "x-windows-949");
} catch (UnsupportedEncodingException e) {
str = rs.getString(columnIndex);
}

return str;
}

@Override
public String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
String str = cs.getString(columnIndex);
try {
if (str != null) str = new String(str.getBytes("8859_1"), "x-windows-949");
} catch (UnsupportedEncodingException e) {
str = cs.getString(columnIndex);
}

return str;
}
}

@MappedTypes(UserType.class) Annotaion을 통해 type handling 대상이 되는 java type을 명시하고,
@MappedJdbcTypes(JdbcType.VARCHAR) Annotaion을 통해 type handling 대상이 되는 JdbcType을 명시해주었습니다.

또한, typehandler의 메서드들의 동작 원리는 다음과 같습니다.

setParameter(), setNonNullParameter()

  • java type → JDBC type으로 전환(예를들어, DB에 적재, where절에 매개변수 세팅 등..)할 때 호출

getResult(), getNullableResult()

  • JDBC type → java type으로 전환(예를들어, DB에서 데이터 조회시 등..)할 때 호출

두 방식의 차이라고 한다면, BaseTypeHandler는 null를 다를수 있는 setNonNullParameter 메소드와 getNullableResult 메소드를 지원한다는 점이 있습니다.

2. Java getBytes() 메서드

다음으로 Java에서 제공하는 getBytes 메소드 입니다.

getBytes public byte[] getBytes(Charset charset)
Encodes this String into a sequence of bytes using the given charset, storing the result into a new byte array.This method always replaces malformed-input and unmappable-character sequences with this charset’s default replacement byte array. The CharsetEncoder class should be used when more control over the encoding process is required.Parameters:charset — The Charset to be used to encode the StringReturns:The resultant byte arraySince:1.6

getBytes public byte[] getBytes()
Encodes this String into a sequence of bytes using the platform’s default charset, storing the result into a new byte array.The behavior of this method when this string cannot be encoded in the default charset is unspecified. The CharsetEncoder class should be used when more control over the encoding process is required.Returns:The resultant byte arraySince:JDK1.1

이 메서드에 대해 Java 공식문서에는 위와 같이 나와있는데요, 기본형과 사용예시를 통해 설명하겠습니다.

기본형

public byte[] getBytes(Charset charset)

사용 예시 1)

String str = "캐릭터셋 소개";
str.getBytes("UTF-8");

기본적인 호출 방식은 위와 같이 Java String 타입인 str 변수에 대해 인자로 지정한 “charset” 에서 사용하는 바이트 배열로 문자에 대응되는 byte 공간을 반환하여 줍니다.

인자를 지정하지 않을 경우 default charset(System.properties의 file.encoding에 설정된 방식)의 바이트 배열로 반환해주며, 반환되는 각 byte 배열은 bit를 signed decimal로 반환됩니다.

사용 예시 2)

String str_nm = "EM김포몰가상점";

str_nm.getBytes("iso-8859-1"); // str_nm을 "iso-8859-1에서 대응되는 바이트 배열을 반환
>> 반환값 : {69, 77, -79, -24, -58, -9, -72, -12, -80, -95, -69, -13, -63, -95}

str_nm.getBytes("utf-8"); // str_nm을 "UTF-8"에서 대응되는 바이트 배열을 반환
>> 반환값 : {69, 77, -62, -79, -61, -88, -61, -122, -61, -73, -62, -72, -61, -76, -62, -80, -62, -95, -62, -69, -61, -77, -61, -127, -62, -95}

두번째 사용예시를 보면 getBytes 메소드의 반환값을 출력하였을때, str_nm이 동일한 “EM김포몰가상점” 일지라도 인자로 지정한 charset에 따라 다르게 출력되었습니다. 이는, iso-8859–1과 utf-8은 각 글자에 매칭되는 비트 공간이 상이하기 때문입니다.

예를들어 ‘김’이라는 글자는
iso-8859–1에서 1011000111101000 비트공간에 대응되고, utf-8 방식으로는 11000010101100011100001110101000 비트공간에 대응되기 때문에 getBytes 메소드가 반환해주는 값이 서로 상이하게 됩니다.

이처럼 간단한 예시를 통해 getBytes 메서드에 대해 짚어보았는데요, 해당 메서드가 반환한 byte 공간을 활용하여 문자열 객체를 생성하도록 하겠습니다.

3. String 생성자

Java에서 제공하는 String 생성자는 다음과 같이 3가지가 존재합니다.

public String(byte[] bytes)
public String(char[] chars)
public String(String name)

1) 일반적인 String 객체 생성 방식

String str_nm = "EM김포몰가상점";
String str_nm = new String("EM김포몰가상점");

일반적으로 String 객체를 생성하는 방법은 위와 같이 문자열이나 문자 배열을 전달하여 생성하였습니다.

2) Charset을 활용한 String 생성자

public String(byte[] bytes, String charset)

하지만 한글깨짐 이슈를 해결하기 위해서는 문자열이나 문자배열이 아닌, 바이트 공간을 매개변수로 넘겨주어 이에 대응되는 문자가 한글인 문자열 객체를 생성하는 방식으로 사용하였습니다.

첫번째 인자로 주어진 bytes 배열을 두번째 인자로 지정된 charset으로 간주하여 String 객체를 생성하는데, 이 때 charest을 지정하지 않으면 default charset으로 String 객체를 생성하게됩니다.
(이를 통해 최종적으로 생성되는 String 객체는 자바 내부적으로 UTF-8을 기반으로 생성되게 됩니다.)

최종 해결방안

앞서 살펴본 charset 별 특징typehandlerjava의 getBytes 메소드 및 String 생성자를 통해 한글깨짐 이슈를 해결하고자 하였고, 이를 위해서 다음과 같은 과정을 단계적으로 수행하였습니다.

  1. 출발지인 SSG DB 내에서 문자가 매핑된 byte 공간을 확인(in DB)
  2. SSG DB와 목적지 DB별로 호환되는 charset 인코딩 방식으로 전환해주기 위한 Custom Typehandler를 구현하여 사용(in Java)
  3. SSG DB로부터 조회해온 값을 SSG DB가 사용하는 charset을 적용하여 byte 공간을 분석(in Java)
  4. byte 공간에 대응되는 문자가 한글인 charest을 찾고, 이를 인자로 지정하여 한글이 정상적으로 노출되는 String 객체를 생성(in Java)

3.3. 사례

앞서 소개드린 내용을 기반으로 발생했던 이슈와 해결 방안을 사례를 통해 소개하도록 하겠습니다.

이슈 상황 당시 EM김포몰가상점 라는 한글 값이 적재되어야했으나, SSG DB에서 단순 조회 후 적재시에 EM±èÆ÷¸ô°¡»óÁ¡ 라는 문자로 적재되었습니다.

이에 대해 앞서 설명한 최종 해결방안을 단계적으로 수행하여 해결하기위해서 먼저 SSG Oracle DB에서 문자열에 대응되는 raw bit를 조회하였습니다.

이 때는 Oracle에서 제공하는 함수인 UTL_RAW.CAST_TO_RAW(문자열을 raw(hex)값으로 반환)를 사용하였습니다.

-- 조회쿼리
SELECT UTL_RAW.CAST_TO_RAW('EM김포몰가상점') AS RAW_COL
FROM DUAL

-- 실행결과
454DB1E8C6F7B8F4B0A1BBF3C1A1

쿼리 실행 결과 SSGDB에서 ‘EM김포몰가상점’ 이라는 문자에 매핑된 byte공간은 454DB1E8C6F7B8F4B0A1BBF3C1A1라는 것을 알 수 있었는데, 이를 각 문자별로 끊어서 나타내면 다음과 같습니다.

위 표와 같이 영어는 1Byte, 한글은 2byte의 공간으로 표현되는데,
가령 두개의 1byte를 조합한 값을 김’ 이라는 한글에 대응시켜 표현한 방식입니다.

매핑된 구체적인 bit 공간은 다음과 같습니다.

  • B1(hex) ↔ 10110001(bin)
  • E8(hex) ↔ 11101000(bin)
  • ‘김’(문자열) ↔ 1011000111101000 (오라클에서 ‘김’이라는 문자에 대응되는 bit 값)

여기서 한 가지 짚고 가야할 점은 SSG DB는 ascii기반 캐릭터셋을 채택(iso-8859–1, us7ascii)하고 있다는 것인데, 이러한 캐릭터셋은 영어 + 유럽언어를 표현하기위해서 제공되는 charset이므로, 한글을 제공하지 않는 것입니다.

따라서, SSG DB에서 앞서 조회했던 ‘EM김포몰가상점’ 을 나타내는 bit 공간인 454DB1E8C6F7B8F4B0A1BBF3C1A1은 한글을 제공하지 않는 캐릭터셋으로 대응해서 조회하면 깨진채로 보여지게(e.g. EM±èÆ÷¸ô°¡»óÁ¡)됩니다.

다시 말해 목적지 DB에 한글이 아닌 문자가 적재된 이유는 순차적으로

1. SSG oracle DB가 ascii 기반 캐릭터셋을 채택(iso-8859–1, us7ascii)
2. ‘EM김포몰가상점’이라는 문자열이 DB에 EM±èÆ÷¸ô°¡»óÁ¡ 라는 문자로 저장(orange Oracle Client를 사용해서 조회했을 때에는 한글이 정상적으로 노출되는 것처럼 보였으나)
3. Java로 구현한 데이터 전송 로직에서는 이 값을 그대로 가져와서 전송

하였기 때문입니다.

3.3.1.2.SSG DB에서 가져온 문자열을 표현할 수 있는 charset

이처럼 SSG Oracle DB에 실제로 저장되어있던 EM±èÆ÷¸ô°¡»óÁ¡를 한글로 노출하기 위해서는, Oracle에서 이 문자열을 표현하는데 대응되었던 byte 공간에 매핑되는 문자가 한글인 charset을 찾아야했습니다.

이를 위한 과정은 순서대로 다음과 같습니다.

  1. EM±èÆ÷¸ô°¡»óÁ¡(SSG Oracle DB에 저장된 값)을 조회
  2. iso-8859–1 charset의 인코딩 방식대로 1번의 문자열에 대응되는 byte 공간을 조회
  3. 2번 단계에서 조회한 byte 공간에 대응되는 문자가 한글인 charset을 분석
  4. 3번 단계에서 찾은 charset의 디코딩 방식으로 한글 문자열 객체를 생성

위 과정의 2번단계를 수행하기위해서, 앞서 소개한 Java의 getBytes() 메소드를 활용하였습니다.

  • charset별로 문자열에 대응되는 byte 공간을 배열로 반환
private void checkCharset(String word) throws UnsupportedEncodingException {
// word : EM±èÆ÷¸ô°¡»óÁ¡ (SSG DB에서 조회해온 문자열)

// us-ascii1 캐릭터셋 방식으로 EM±èÆ÷¸ô°¡»óÁ¡ 에 대응되는 byte배열을 출력
for(byte b : word.getBytes(StandardCharsets.US_ASCII)) {
System.out.print("[" + b + "] ");
}

// iso-8859-1 캐릭터셋 방식으로 EM±èÆ÷¸ô°¡»óÁ¡ 에 대응되는 byte배열을 출력
for(byte b : word.getBytes("iso-8859-1")) {
System.out.print("[" + b + "] ");
}

// euc-kr 캐릭터셋 방식으로 EM±èÆ÷¸ô°¡»óÁ¡ 에 대응되는 byte배열을 출력
for(byte b : word.getBytes("euc-kr")) {
System.out.print("[" + b + "] ");
}

// x-windows-949 캐릭터셋 방식으로 EM±èÆ÷¸ô°¡»óÁ¡ 에 대응되는 byte배열을 출력
for(byte b : word.getBytes("x-windows-949")) {
System.out.print("[" + b + "] ");
}

// utf-8 캐릭터셋 방식으로 EM±èÆ÷¸ô°¡»óÁ¡ 에 대응되는 byte배열을 출력
for(byte b : word.getBytes("utf-8")) {
System.out.print("[" + b + "] ");
}
}

위 코드의 실행결과는 다음과 같습니다.

US-ASCII 캐릭터셋 바이트배열로 변환
[69] [77] [63] [63] [63] [63] [63] [63] [63] [63] [63] [63] [63] [63]


iso-8859-1 캐릭터셋 바이트배열로 변환
[69] [77] [-79] [-24] [-58] [-9] [-72] [-12] [-80] [-95] [-69] [-13] [-63] [-95]

euc-kr 캐릭터셋 바이트배열로 변환
[69] [77] [-95] [-66] [63] [-88] [-95] [-95] [-64] [-94] [-84] [63] [-95] [-58] [-94] [-82] [63] [63] [63] [-94] [-82]

x-windows-949 캐릭터셋 바이트배열로 변환
[69] [77] [-95] [-66] [63] [-88] [-95] [-95] [-64] [-94] [-84] [63] [-95] [-58] [-94] [-82] [63] [63] [63] [-94] [-82]

utf-8 캐릭터셋 바이트배열로 변환
[69] [77] [-62] [-79] [-61] [-88] [-61] [-122] [-61] [-73] [-62] [-72] [-61] [-76] [-62] [-80] [-62] [-95] [-62] [-69] [-61] [-77] [-61] [-127] [-62] [-95]

5가지의 charset 별로 문자에 대응되는 byte 공간을 조회해봤을때 iso-8859–1 캐릭터셋방식으로 bytes 배열을 반환한 값 SSG Oracle DB에서 ‘EM김포몰가상점’이라는 문자열을 raw bit로 표현한 값과 동일하였습니다.

이렇게 SSG Oracle DB와 동일한 bit 공간을 찾아보았고, 위 과정의 3번 단계인

2번 단계에서 조회한 byte 공간에 대응되는 문자가 한글인 charset을 분석

을 수행하여 이 byte 공간에 대응되는 문자가 한글인 charset을 찾아보도록 하겠습니다.

이때는 String 생성자의 인자로 charset을 넣어 한글이 올바르게 노출되는 문자열을 생성하고, 이를 콘솔에 출력하여 확인하였습니다.

  • 위에서 조회한 바이트배열에 대응되는 문자가 한글인 charset을 분석
private void printDecoding(String word) throws UnsupportedEncodingException {

// iso-8859-1 캐릭터셋 방식으로 EM±èÆ÷¸ô°¡»óÁ¡ 에 대응되는 byte배열을 출력
for(byte b : word.getBytes("iso-8859-1")) {
System.out.print("[" + b + "] ");
}

// byte 배열에 대응되는 문자가 한글인 charset을 분석
System.out.println();
System.out.println("iso-8859-1 -> euc-kr : " + new String(word.getBytes("iso-8859-1"), "euc-kr"));
System.out.println("iso-8859-1 -> x-windows-949 : " + new String(word.getBytes("iso-8859-1"), "x-windows-949"));
System.out.println("iso-8859-1 -> utf-8 : " + new String(word.getBytes("iso-8859-1"), "utf-8"));
System.out.println();
}

출력 결과는 다음과 같습니다.

[69] [77] [-79] [-24] [-58] [-9] [-72] [-12] [-80] [-95] [-69] [-13] [-63] [-95]
iso-8859-1 -> euc-kr : EM김포몰가상점
iso-8859-1 -> x-windows-949 : EM김포몰가상점
iso-8859-1 -> utf-8 : EM������������

이를 통해, euc-kr과 x-windows-949로 디코딩을 수행해야 한글이 정상적으로 노출됨을 확인할 수 있었습니다.

이 과정에 대해 간단한 예시를 들어보자면, ‘Æ’ 라는 문자가 노출된 이유는 다음과 같습니다.
- iso-8859–1 캐릭터셋에서 D8(hex) 비트 공간에 대응 되는 문자 -> ‘Æ’
- euc-kr과 x-windows-949에서 D8 비트 공간에 대응되는 문자 -> ‘몰’

여기까지의 과정을 순차적으로 정리해보면 다음과 같습니다.

문자열이 저장된 바이트공간을 찾는다. (in DB)
→ DB와 동일한 바이트공간을 가져올 수 있는 charset을 찾는다. (in Java)
→ 대응되는 비트를 구한다.
→ 비트에 대응되는 문자가 한글인 charset을 찾고 이를 기반으로 String 객체를 생성한다.

위 과정까지 수행하여, 이슈를 해결..!
한 줄 알았으나..

아래의 완성형 한글 표현방식의 4번째 특징인 미리 만들어진 일부 한글만을 표현가능 하다는 특징으로 인해 추가적인 이슈가 발생하였습니다.

완성형(EUC-KR의 방식)

- 한글 글쓰기에서 자주 사용되는 한글(e.g. ‘가’, ‘나’, ‘다’, ‘라’ 등..)을 미리 만들어서 사용하는 방식
- 과거 윈도우 환경에서 사용
- 자모를 다시 분리하지 못함
- 미리 만들어진 일부 한글만을 표현가능

발생한 이슈는 올바른 한글 상품명이라면 [오르랔베이커리]피비핏 유기농 초코맛 그린피스 프로틴 파우더 라고 적재가 되어야했으나, 실제 목적지 DB에는 오르�逼@箝옇�]피비핏 유기농 초코맛 그린피스 프로틴 파운더 라고 적재되는 현상이였습니다.

이를 해결하기 위해서 위와 동일하게 이 비트 공간에 대응되는 문자가 한글인 charset을 다시 분석하였데요.
SSG Oracle DB에 대응되는 비트 공간을 구하는 방식은 위와 동일하기 때문에 차치하고, 바로 코드로 넘어가겠습니다.

  • 위와 동일한 메소드로 조회
private void printDecoding(String word) throws UnsupportedEncodingException {

// iso-8859-1 캐릭터셋 방식으로 [¿À¸£ùº£ÀÌÄ¿¸®]ÇǺñÇÍ À¯±â³ó ÃÊÄÚ¸À ±×에 대응되는 byte배열을 출력
for(byte b : word.getBytes("iso-8859-1")) {
System.out.print("[" + b + "] ");
}

// byte 배열에 대응되는 문자가 한글인 charset을 분석
System.out.println();
System.out.println("iso-8859-1 -> euc-kr : " + new String(word.getBytes("iso-8859-1"), "euc-kr"));
System.out.println("iso-8859-1 -> x-windows-949 : " + new String(word.getBytes("iso-8859-1"), "x-windows-949"));
System.out.println("iso-8859-1 -> utf-8 : " + new String(word.getBytes("iso-8859-1"), "utf-8"));
System.out.println();
}

이때의 출력 결과는 다음과 같습니다.

[91] [-65] [-64] [-72] [-93] [-115] [-7] [-70] [-93] [-64] [-52] [-60] [-65] [-72] [-82] [93] [-57] [-57] [-70] [-15] [-57] [-51] [32] [-64] [-81] [-79] [-30] [-77] [-13] [32] [-61] [-54] [-60] [-38] [-72] [-64] [32] [-79] [-41]

iso-8859-1 -> euc-kr : [오르�逼@箝옇�]피비핏 유기농 초코맛 그
iso-8859-1 -> x-windows-949 : [오르랔베이커리]피비핏 유기농 초코맛 그
iso-8859-1 -> utf-8 : [����������Ŀ��]�Ǻ��� ����� ���ڸ� ��

콘솔 출력 결과를 통해, euc-kr로 디코딩을 할 경우 ‘랔’ 글자를 표현하지 못하나, x-winodws-949는 ‘랔’까지 정상적으로 표현 가능함을 알 수 있었습니다.

이와 같은 현상이 발생한 이유는 두 charset의 특정 byte공간에 대응되는 문자가 달랐기 때문입니다.

앞서 소개드린 내용중에 2. 주요 캐릭터셋 > 2.1.4. x-windows-949(= MS949, CP949)에 적혀있는 내용과 동일한 원인인데,

charset 별로 차이가나는 특정 문자의 표현방식을 살펴보자면,
‘랔’ 에 해당하는 바이트배열이
— euc-kr 기준으로는 한글이 아닌 �逼@箝옇�라는 문자에 대응
되었으나
x-winodws-949 기준으로는 ‘랔’이라는 글자에 대응

되어 위와 같은 차이가 나타나게 되었습니다.

두번째 이슈까지 확인하여, 아래와 같은 내용을 확인할 수 있었습니다.

  • SSG Oracle DB와 동일한 byte 공간을 가져올 수 있는 charset은
  • iso-8859–1을 기반으로 byte 공간을 조회해봤을때 byte 공간에 대응되는 문자가 한글인 charset은 x-windows-949

4. 최종 구현한 Typehandler

이렇게 분석한 내용을 기반으로 최종 구현한 Typehandler class는 다음과 같습니다.

총 2벌의 Typehandler 클래스를 구현하였는데요, 두 DB의 인코딩 방식이 다르기 때문에 필연적으로 비트에 대응되는 문자가 다를 수밖에 없었고, 이러한 이유로 DB 별로 각각의 typehandler를 구현해주었습니다.

  • SsgTypeHandler.class
@MappedJdbcTypes(JdbcType.VARCHAR)
public class SsgTypeHandler extends BaseTypeHandler<String> {

@Override
public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) throws SQLException {

String str = parameter;
try {
if(str != null) str = new String(str.getBytes("x-windows-949"), "8859_1");
} catch (UnsupportedEncodingException ignored) {

}
ps.setString(i, str);
}

@Override
public String getNullableResult(ResultSet rs, String columnName) throws SQLException {

String str = rs.getString(columnName);

try {
if (str != null) str = new String(str.getBytes("8859_1"), "x-windows-949");
} catch (UnsupportedEncodingException e) {
str = rs.getString(columnName);
}

return str;
}

@Override
public String getNullableResult(ResultSet rs, int columnIndex) throws SQLException {

String str = rs.getString(columnIndex);

try {
if (str != null) str = new String(str.getBytes("8859_1"), "x-windows-949");
} catch (UnsupportedEncodingException e) {
str = rs.getString(columnIndex);
}

return str;
}

@Override
public String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {

String str = cs.getString(columnIndex);
try {
if (str != null) str = new String(str.getBytes("8859_1"), "x-windows-949");
} catch (UnsupportedEncodingException e) {
str = cs.getString(columnIndex);
}

return str;
}
}
  • DestinationTypeHandler.class
@MappedJdbcTypes(JdbcType.VARCHAR)
public class DestinationTypeHandler extends BaseTypeHandler<String> {

@Override
public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) throws SQLException {

String str = parameter;
ps.setString(i, str);
}

@Override
public String getNullableResult(ResultSet rs, String columnName) throws SQLException {

String str = rs.getString(columnName);

try {
if (str != null) str = new String(str.getBytes("8859_1"), "x-windows-949");
} catch (UnsupportedEncodingException e) {
str = rs.getString(columnName);
}

return str;
}

@Override
public String getNullableResult(ResultSet rs, int columnIndex) throws SQLException {

String str = rs.getString(columnIndex);

try {
if (str != null) str = new String(str.getBytes("8859_1"), "x-windows-949");
} catch (UnsupportedEncodingException e) {
str = rs.getString(columnIndex);
}

return str;
}

@Override
public String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {

String str = cs.getString(columnIndex);
try {
if (str != null) str = new String(str.getBytes("8859_1"), "x-windows-949");
} catch (UnsupportedEncodingException e) {
str = cs.getString(columnIndex);
}

return str;
}
}

목적지 DB로 전송할때 호출되는 DestinationTypeHandler의 setNonNullParameter 메소드는 charset에 대한 아무 처리 없이 그대로 전송되는 것에 대해 의구심을 품으셨을텐데요, 이 부분과 그 이유에 대해서 String 생성자 호출시에 수행되는 로직에 대해서 자세한 분석은 하지 못하였습니다. 😢

하지만 new String(str.getBytes(“8859_1”), “x-windows-949”) 를 호출하여 String 객체를 생성하였음에도, 그 과정을 디버깅을 해봤을때
String 객체 생성 전후로 최종적으로 각 문자별로 대응되는 byte 공간이 아래와 같음을 확인할 수 있었습니다.

new String(str.getBytes(“8859_1”), “x-windows-949”) 호출 전 str 문자열 확인
str = new String(str.getBytes(“8859_1”), “x-windows-949”) 호출 후 str 문자열 확인

String 객체 생성 이후 ‘가’ 라는 글자에 매핑된 비트공간이 AC00임을 알 수 있었는데, 이는 유니코드 표에서 ‘가’의 코드포인트와 동일하기 때문에
String 생성자를 통해 최종 생성된 Java 문자열은 UTF-8 기반임을 추측할 수 있었습니다.

유니코드 표

5. 결과

여기까지의 과정을 통해 찾은 두 charset을 기반으로 Java String 생성자를 통해 문자열 객체를 생성를 생성하여 목적지 DB로 전송하였고,
Java에서 콘솔 출력 혹은 디버깅시에 한글이 정상적으로 노출되었다면 목적지인 관계사 DB에 정상 적재됨을 확인할 수 있었습니다.

전송 결과

‘주요 charset 소개 및 mybatis typehandler를 통한 한글 다루기’ 대해 제가 준비한 내용은 여기까지입니다.
작성하다보니 내용이 많아졌는데요 😅 귀중한 시간 할애하여 긴 글 읽어주셔서 정말 감사드립니다.

참고

--

--