카테고리 보관물: IMDG

Hazelcast Client XML Fails with cvc-complex-type.2.3: A Hidden U+FEFF in Indentation

세션 클러스터링이 멈춘 원인은 보이지 않는 문자 하나

Hazelcast 세션 클러스터링을 붙인 WAS에서 클라이언트 설정 XML이 cvc-complex-type.2.3 검증 오류로 거부되고, 그 여파로 세션 저장이 NullPointerException으로 실패했던 기록입니다. 설정 값 자체는 맞았습니다.

원인은 눈에 보이지 않는 U+FEFF 문자였습니다. 파일 첫머리의 BOM이 아니라 태그 앞 들여쓰기에 섞여 있었기 때문에, 흔히 쓰는 BOM 제거 방법으로는 해결되지 않았습니다.

WAS 로그에 남은 두 줄의 경고

로그에는 경고 두 줄이 연달아 남았습니다. 첫 줄은 Hazelcast 클라이언트가 설정을 읽다가 실패한 것이고, 둘째 줄은 연결되지 않은 클라이언트로 세션을 쓰려다 난 NullPointerException입니다.

09:52:06,504 WARNING [com.hazelcast.web.ClusteredSessionService] (default task-1) Cannot connect to Hazelcast server: cvc-complex-type.2.3: Element 'near-cache' cannot have character [children], because the type's content type is element-only.
09:52:06,962 WARNING [com.hazelcast.web.HazelcastHttpSession] (default task-1) Unexpected error occurred.: java.lang.NullPointerException
 at com.hazelcast.web.ClusteredSessionService.updateAttributes(ClusteredSessionService.java:285)
 at com.hazelcast.web.HazelcastHttpSession.sessionDeferredWrite(HazelcastHttpSession.java:300)
 at com.hazelcast.web.WebFilter.doFilter(WebFilter.java:303)
 at io.undertow.servlet.core.ManagedFilter.doFilter(ManagedFilter.java:61)
 at io.undertow.servlet.handlers.FilterHandler$FilterChainImpl.doFilter(FilterHandler.java:131)
 ...

첫 경고만 보면 Hazelcast 서버에 연결하지 못한 네트워크 문제처럼 읽힙니다. XML 스키마 검증 오류 코드가 메시지 뒤쪽에 붙어 있다는 점이 단서였습니다. 클라이언트가 설정을 읽는 단계에서 멈췄기 때문에 연결은 시도조차 못 한 것입니다. Undertow 기반 WAS였다는 것은 스택의 io.undertow에서 알 수 있습니다.

오류 문구가 가리키는 것

“Element ‘near-cache’ cannot have character [children]”은 near-cache 요소 안에 자식 요소가 아닌 글자가 들어 있다는 뜻입니다. XML 스키마에서 element-only로 정의된 요소는 자식 요소와 공백만 가질 수 있습니다. 여기서 공백은 XML 규격이 정한 스페이스, 탭, CR, LF 네 가지뿐입니다.

U+FEFF(Zero Width No-Break Space)는 이 네 가지에 속하지 않습니다. 화면에는 아무것도 보이지 않지만 검증기에게는 엄연한 문자 내용입니다. 그래서 들여쓰기에 하나만 섞여도 같은 오류가 납니다. Java 검증기는 이 규칙 위반을 cvc-complex-type.2.3으로 표시하고, libxml2는 “Character content other than whitespace is not allowed”로 표시합니다.

cvc-complex-type.2.3 오류 개념도: hazelcast-client.xml의 near-cache 들여쓰기에 숨은 U+FEFF가 element-only 규칙을 어겨 클라이언트 설정 로드가 실패하는 과정
들여쓰기에 섞인 U+FEFF가 cvc-complex-type.2.3 오류로 이어지는 과정과, 흔한 확인 방법이 이를 놓치는 이유입니다.

BOM 없는 UTF-8로 저장해도 남아 있던 이유

처음에는 Windows 에디터가 붙이는 BOM을 의심했습니다. 그런데 에디터에서 BOM 없는 UTF-8로 저장하는 방법이 통하지 않았고, 태그 앞 여백을 모두 지운 뒤에야 오류가 사라졌습니다.

BOM은 파일 맨 앞 3바이트(EF BB BF)를 가리키고, 에디터의 “BOM 없이 저장”도 그 3바이트만 뺍니다. U+FEFF가 줄 중간에 있으면 BOM이 아니라 평범한 문자로 취급되어 그대로 남습니다. HTML 문서나 웹 페이지에서 설정 예제를 복사해 붙일 때 이런 문자가 함께 딸려 오기도 합니다.

숨은 문자를 찾고 지우는 방법

아래는 같은 상황을 재현한 예제 파일로 확인한 결과입니다. 4번째 줄의 들여쓰기에 U+FEFF를 하나 넣었습니다.

$ file hazelcast-client.xml
hazelcast-client.xml: XML 1.0 document, Unicode text, UTF-8 text

$ grep -c $'\xEF\xBB\xBF' hazelcast-client.xml
1

$ grep -nP '\x{FEFF}' hazelcast-client.xml | cat -A
4:    M-oM-;M-?<in-memory-format>OBJECT</in-memory-format>$

file은 BOM이 파일 첫머리에 있을 때만 “with BOM”이라고 알려 줍니다. 그래서 이 경우는 그냥 UTF-8 텍스트로 보고합니다. EF BB BF 바이트 열을 grep으로 직접 찾는 편이 확실하고, cat -A로 보면 M-oM-;M-? 표시로 위치까지 드러납니다.

지울 때는 첫 줄만 고치는 sed '1s/^\xEF\xBB\xBF//'로는 부족합니다. 파일 전체에서 바꿔야 줄 중간의 문자까지 사라집니다.

$ sed -i 's/\xEF\xBB\xBF//g' hazelcast-client.xml
$ grep -c $'\xEF\xBB\xBF' hazelcast-client.xml
0

제거 전의 예제 파일을 최소 스키마로 검증하면 libxml2가 near-cache 요소에서 element-only 위반을 보고했습니다. 제거한 뒤에는 같은 스키마로 검증을 통과했습니다.

다시 겪지 않으려면

  • 설정 파일을 배포하기 전에 grep -rl $'\xEF\xBB\xBF' <설정 디렉터리>로 한 번 훑습니다. CI 단계에 넣어 두면 사람이 놓쳐도 걸러집니다.
  • VS Code는 보이지 않는 유니코드 문자를 강조하는 editor.unicodeHighlight.invisibleCharacters 설정을 제공합니다. Windows에서 설정 파일을 편집한다면 이 설정이 켜져 있는지 확인합니다.
  • .editorconfig의 charset = utf-8은 파일 첫머리 BOM만 막아 줍니다. 줄 중간의 문자는 따로 검사해야 합니다.

참고

관련 포스트:

참고 문서: XML 1.0 공백 정의 · XML Schema cvc-complex-type 규칙 · Unicode BOM FAQ · 바이트 순서 표식(위키백과)

Hazelcast WEB Session Clustering

WAS 장애에도 세션을 잃지 않는 IMDG 공유 방식

Hazelcast WEB Session Clustering 구성은 IMDG(In-Memory Data Grid)를 이용해 로드밸런서로 묶인 WAS 그룹 간에 세션 정보를 공유합니다. 특정 WAS 노드에 장애가 발생해도 세션 데이터를 잃지 않고 다른 노드에서 세션을 이어받을 수 있어 고가용성(HA) 웹 서비스 환경에서 필수 구성 요소입니다. 이 글에서는 Hazelcast 3.12 기준으로 web.xml과 hazelcast-client.xml 설정 방법을 정리합니다.

버전 범위 안내: 이 글은 Hazelcast 3.12와 JDK 8에서 검증한 레거시 구성 기록입니다. 현재 Hazelcast 5.x 계열은 모듈과 설정 방식에 차이가 있으므로 신규 구성에는 이 예제를 그대로 적용하지 말고 Hazelcast 최신 공식 문서를 기준으로 검토하시기 바랍니다.

개념 (Concept)

기본 목적은 로드 밸런싱으로 묶인 WAS 그룹간의 세션 정보의 공유이며 그룹내의 임의의 WAS가 Fail over되어도 세션 정보를 유실하지 않고 다른 WAS에서 세션 연결을 가능케 하는 것입니다.

그로 인해 부가적으로 이기종 WAS간에도 세션 공유를 가능케 할 수 있다는 큰 이점이 있습니다.

구동 방식

  1. 사용자의 request가 was에 도달(servlet container)
  2. request의 session id를 대조 하는 절차 진행시(WAS에서 세션 정보 조회 전)
  3.  webfilter를 통해 request의 쿠키가 가진 session id를 IMDG의 맵에서 조회
  4. 조회 값을 반환 할 경우 기존 session으로 할당 처리( 후 request 응답 처리)
  5. 조회 값이 없을 경우 webfilter를 통해 지정된 형식으로 session id 생성 후 그 id로 sessionlistener를 통해 httpsession 생성
  6. request가 완료되면 생성된 session 정보를 IMDG 맵에 저장

IMDG 기반 분산 세션 관리

노드들을 클러스터 구성 하여 여러 서버의 메모리를 파티셔닝 또는 샤딩이라는 기술을 통해 하나의 메모리 공간처럼 활용, 데이터를 임의의 노드 메모리에 분산 저장시 실시간 또는 비동기로 생성된 백업 데이터는 원본 데이터를 소유한 노드 이외의 다른 노드에 분산 저장됩니다.
이 기능을 이용하여 세션 정보의 손실을 방지 하며 빠른 I/O 성능을 보장합니다.

L4 로드밸런서 뒤 WAS A와 WAS B의 WebFilter가 Hazelcast IMDG 맵에서 세션을 조회하고 저장하는 Hazelcast Session Clustering 구성도와 처리 순서
Hazelcast Session Clustering 흐름 — WebFilter가 쿠키의 session id로 IMDG 맵을 조회하고, 요청이 끝나면 세션을 맵에 저장합니다.

구성 (hazelcast 3.12, JDK 1.8)

  1. WEB-INF에 web.xml 및 hazelcast-client.xml 설정
  2. WEB-INF/lib 또는 Classpath에 hazelcast-wm.jar(hazelcast-all.jar 가능) 위치

web.xml 설정

<!-- hazelcast filter start -->
<filter>
    <filter-name>hazelcast-filter</filter-name>
    <filter-class>com.hazelcast.web.WebFilter</filter-class>
    <init-param>
        <param-name>map-name</param-name>
        <param-value>hazelcast-sessions</param-value>
    </init-param>
    <init-param> <!-- Embedded only -->
        <param-name>session-ttl-seconds</param-name>
        <param-value>0</param-value>
    </init-param>
    <init-param>
        <param-name>keep-remote-active</param-name>
        <param-value>false</param-value>
    </init-param>
    <init-param>
        <param-name>sticky-session</param-name>
        <param-value>true</param-value>
    </init-param>
    <init-param>
        <param-name>cookie-name</param-name>
        <param-value>hazelcast.sessionId</param-value>
    </init-param>
    <init-param> <!-- 도메인 없을 때 비울것 -->
        <param-name>cookie-domain</param-name>
        <param-value>www.example.com</param-value>
    </init-param>
    <init-param>
        <param-name>cookie-secure</param-name>
        <param-value>false</param-value>
    </init-param>
    <init-param>
        <param-name>cookie-http-only</param-name>
        <param-value>false</param-value><!-- default false -->
    </init-param>
    <init-param>
        <param-name>debug</param-name>
        <param-value>true</param-value><!-- default false -->
    </init-param>
    <init-param>
        <param-name>shutdown-on-destroy</param-name>
        <param-value>false</param-value>
    </init-param>
    <init-param>    <!--    이부분이 설정파일의 위치(Embaded 설정)    -->
        <param-name>config-location</param-name>
        <param-value>/WEB-INF/config/spring/my-hazelcast.xml</param-value>
    </init-param>
    <init-param>
        <param-name>instance-name</param-name>
        <param-value>hazel-ses</param-value>
    </init-param>
    <init-param> <!-- Client mode only -->
        <param-name>use-client</param-name>
        <param-value>true</param-value>
    </init-param>
    <init-param>  <!-- Client Configuration(or hazelcast-client.xml) -->
        <param-name>client-config-location</param-name>
        <param-value>/WEB-INF/classes/hazelcast-client.properties</param-value>
    </init-param>
    <init-param>
        <param-name>deferred-write</param-name>
        <param-value>true</param-value><!-- default false -->
    </init-param>
    <init-param>
        <param-name>cookie-path</param-name>
        <param-value>/</param-value>
    </init-param>
    <init-param>
        <param-name>cookie-max-age</param-name>
        <param-value>-1</param-value>
    </init-param>
    <init-param>
        <param-name>use-request-parameter</param-name>
        <param-value>false</param-value>
    </init-param>
</filter>

<filter-mapping>
    <filter-name>hazelcast-filter</filter-name>
    <url-pattern>/*</url-pattern>
    <dispatcher>FORWARD</dispatcher>
    <dispatcher>INCLUDE</dispatcher>
    <dispatcher>REQUEST</dispatcher>
</filter-mapping>

<listener>
    <listener-class>com.hazelcast.web.SessionListener</listener-class>
</listener>
<!-- hazelcast filter end -->
Topic Desc Default
map-name 세션 오브젝트가 저장될 분산맵의 이름 선언
session-ttl-seconds 세션 오브젝트가 저장될 분산맵의 수명(초) 설정, 0이상의 정수형(최대2147483647)으로 설정 1800(30분)
sticky-session true 설정시, 모든 세션 요청이 세션이 처음 만들어진 멤버로 라우팅 됨. 더 좋은 성능을 제공함. false 설정시, 세션이 멤버상에서 업데이트 될 때, 모든 멤버상의 이 세션 엔트리는 무효화 됨.
이 파라메터를 설정하기 전에 로드 밸런서(L4) 구성상황을 알 필요가 있음
true
Generic Parameter 의미
  • Client config 구성 ( hazelcast-client.xml )
<hazelcast-client>
...
    <group>
        <name>dev</name>
        <password>dev-pass</password>
    </group>

    <network>
        <cluster-members>
            <!--MEMBERS-->
            <address>192.168.x.x</address>
            <address>192.168.x.x</address>
            <address>192.168.x.x</address>
        </cluster-members>
    </network>
    <load-balancer type="round-robin"/>

...
</hazelcast-client>

참고

관련 포스트:

참고 문서: Hazelcast Web Session Replication 공식 문서 · Hazelcast IMDG 개요