다국어 처리를 위한 몇가지 고민 - 2

김나쁜 Kimbad·2023년 4월 25일
0

서비스 확장

목록 보기
2/3

타임존

이번엔 시간이 문제다.

한 사용자가 독일에서 접속해서 물건을 구매하다 문제가 생겼다.
관리자가 문제가 생긴 해당 구매건에 대해 정보를 조회하려 한다.
만약 관리자가 독일이 아닌 다른 국가라면? 타임존이 다른 국가에서 조회한다면?

사용자의 구매 요청 데이터에 타임존 기반 날짜시간 데이터를 넣고
조회할 때 조회 요청자의 타임존 기반 시각으로 변환하면 되긴 하는데, 번거로워보인다.

그래서 DB에 날짜 데이터가 INSERT 될 때, 기준시(UTC-협정 세계시)로 변환하기로 했다.
Java에서 해도 되고, DB에서 해도 된다.
다만 하나는 Java에서 하고 하나는 DB에서 하고 하면 번거로워 지니 한가지로 통일해야한다.

Java(in Mybatis)에서 모든 SQL 실행 결과를 수정 할 경우

SELECT~로 쿼리 실행 결과를 받아오거나,
INSERT~될 때 들어갈 날짜 값을 Java에서 변경하는 경우
MyBatis Interceptor를 활용할 수 있다.

@Intercepts({
        @Signature(type = Executor.class, method = "query", args = { MappedStatement.class, Object.class,
                RowBounds.class, ResultHandler.class })
})
public class MybatisConvertTimeZoneInterceptor  implements Interceptor {

    private final List<String> excludeMapperIdList = new ArrayList<>();

    public MybatisConvertTimeZoneInterceptor() {
        // UTC to TimeZone 변환 제외 mapper List
        excludeMapperIdList.add("com.kwp.api.common.baseinfo.mapper.StSiteInfoMapper.selectSiteInfoList");
    }

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        Object[] args = invocation.getArgs();
        MappedStatement mappedStatement = (MappedStatement) args[0];
        String mapperId = mappedStatement.getId();
        Object param = args[1];
        ComParamVO comParam = new ComParamVO();

        if (param instanceof Map) {
            Map<String, Object> paramMap = (Map<String, Object>) param;
            if (paramMap.containsKey("__param")) {
                comParam = (ComParamVO) paramMap.get("__param");
            } else {
                comParam.setTimeZone(Constant.UTC);
            }
        }

        Object resultSet = invocation.proceed();

        if (args != null) {
            // 제외 mapper 확인
            if (checkExcludeMapper(mapperId)) {
                return resultSet;
            };
        }

        if (resultSet instanceof List) {
            List<Object> resultList = (List<Object>) resultSet;
            if (!resultList.isEmpty() && resultList.get(0) instanceof Map) {
                for (Object obj : resultList) {
                    Map<String, Object> resultMap = (Map<String, Object>) obj;
                    for (Map.Entry<String, Object> entry : resultMap.entrySet()) {
                        Object value = entry.getValue();
                        if (value instanceof Date) {
                            entry.setValue(ComUtils.utcToLocalTimeZone((Date) value, comParam.getTimeZone()));
                        } else if (value instanceof Timestamp) {
                            entry.setValue(ComUtils.utcToLocalTimeZone((Timestamp) value, comParam.getTimeZone()));
                        }
                    }
                }
            }
        }
        return resultSet;
    }

    @Override
    public Object plugin(Object target) {
        return Plugin.wrap(target, this);
    }

    @Override
    public void setProperties(Properties properties) {

    }

    protected boolean checkExcludeMapper(String mapperId) {
        return excludeMapperIdList.stream()
                .map(s -> s.replace(".", "\\.")  // '.' 문자를 이스케이프 문자로 바꿔줌
                        .replace("*", "[^.]*"))  // '*' 문자를 정규표현식 와일드카드로 바꿔줌
                .map(Pattern::compile)
                .anyMatch(p -> p.matcher(mapperId).matches());
    }
}

처음엔 INSERT, UPDATE, SELECT 전부 적용 하려고 했다.
Local to UTC, UTC to Local 두가지 경우 다 고려했지만
INSERT와 UPDATE 단계에서는 PostgreSQL 내에서 CURRENT_TIMESTAMP AT TIME ZONE 'UTC' 구문으로
현재 시간이 들어가는 부분은 전부 UTC 기준으로 넣고,
사용자에게 노출될 때만 (즉 SELECT) 해당 사용자의 TimeZone 기반으로 변경해준다.

걱정되는 점은 쿼리 실행 결과 Object를 하나 하나 전부 Class를 검사하게 되어 있어
처리 속도가 늘어진다는 점인데, 현재 서비스에서는 결과 행을 많이 리턴해봐야 수십개 정도라
큰 지장이 없을 것 같다.

원래 참고했던 방식은 VO의 날짜 필드 중 타임존 변환이 필요한 필드에 @특정어노테이션을 선언해서
MyBatis 인터셉터에서 특정 어노테이션을 가졌는지 검사 후 변환해주는 방식이었는데
지금 프로젝트는 대부분의 resultType을 커스텀 해쉬맵을 사용 중이라 부득이하게 이렇게 만들었다.

통화

기본적인 Spring i18n 적용 후 페이지를 보다가 어? 싶은 부분이 생겼다.
상품의 금액을 표현하는 부분이었는데, 이게 나라별로 통화기호의 위치나 단어 위치가 달라질 수가 있다.
금액으로 예시를 들면,

price = 1234.00; // 가격 데이터

한국 -> 1,234 ₩
영국(유로화) -> €1,234.56
불가리아 -> 1 234,00 лв
체코 -> 1 234,00 Kč
노르웨이 -> kr 1 234,00

위와 같이 통화 기호가 앞에 오는지, 뒤에 오는지
각 자리 단위수 표기 방식 등 국가별로 판이했다.

국내 서비스에서 다국어 확장을 위해
numeral.js라는 라이브러리를 추가해 놓았지만
이 라이브러리는 numeral.locale.js(config 역할)에서 각 국가의 Locale 정보가 다른 경우도 있었고, 통화 기호가 현재와 다른 경우도 있었다(영국은 현재 유로화를 쓰지만 파운드로 나온다던지).

다른 방법을 찾아보던 중 발견한 것이 Javscript Intl이다.

JavaScript Intl은 ECMAScript 6에서 추가된 내장 라이브러리로, 숫자뿐만 아니라 문자열, 날짜 및 시간에 대한 지역화 기능을 제공합니다. 이는 Numeral.js보다 더 많은 지역화 기능을 제공하지만, API가 복잡하고 번거롭습니다. 또한, 크기가 더 크기 때문에 로딩 속도가 느릴 수 있습니다.

해당 국가의 Locale 정보만 가지고 있으면, 해당 값을 Locale 정보 기반으로 변환을 해준다. 위에서 예시를 들었던

한국 -> 1,234 ₩
영국(유로화) -> €1,234.56
불가리아 -> 1 234,00 лв
체코 -> 1 234,00 Kč
노르웨이 -> kr 1 234,00

이 값들이 해당 국가 Locale 기반의 Intl로 변환한 결과 값이다.

지금은 통화 기호를 포함한 금액 표기로 설정 해놓았기 때문에
옵션에 통화 코드가 필요한 데, 어차피 금액을 표기하는 부분은
상품 정보를 불러오는 부분이라 다국어 테이블과 조인해서 통화 코드를 상품 정보에 포함시켜서 가져오게 만들었다.
통화코드는 ISO 4217 국제 기준을 사용한다.
조금 귀찮은 점은 Java Locale은 해당 국가의 언어코드_국가코드 ex)ko_KR 형태로 되어있는데
Intl에서 인식하는 Locale은 언어코드-국가코드로 되어 있어서 바로 사용할 수 없다.
언더바를 하이픈으로 replace 해주어야한다.

var IntlFormatter = {
    currFormat : function (price, currency) {
        const formatter = new Intl.NumberFormat(GV_LOCALE, {
            style: 'currency',
            currency: currency,
            currencyDisplay: 'narrowSymbol',
        });
        return formatter.format(price)
    },
    dateFormat : function (date) {
        const formatter = new Intl.DateTimeFormat(GV_LOCALE, {
            dateStyle: 'full',
            timeStyle: 'long',
        });
      	return formatter.format(date);
    }
}

공통 함수 부분에 임시로 만들어놓았다.

profile
Bad Language

0개의 댓글