이 포스트는 VSCode에서 Node.js 설치 후 npm, tsc 등 명령을 실행했을 경우 "'npm' 용어가 cmdlet, 함수, 스크립트 파일 또는 실행할 수 있는 프로그램 이름으로 인식되지 않습니다. 이름이 정확한지 확인하고..." 오류가 발생하는 현상에 대한 해결입니다.

 

아래 그림과 같이 기본적으로 Windows VSCode는 기본 터미널이 Windows powershell로 되어 있습니다.

 

옆에 [+] 버튼을 클릭해서 Command Prompt를 띄우셔서 작업하시면 됩니다.

 

 

VSCode의 기본 터미널의 종류를 변경하는 방법은 아래와 같습니다.

 

1. CTRL + SHIFT + P

위 단축키를 눌러 창을 띄운 다음 Terminal: 로 검색하여 "Terminal: Select Default Profile"을 선택합니다.

 

2. Command Prompt 선택

터미널 종류 중 Command Prompt를 선택합니다.

 

3. 새 터미널 실행

[터미널] > [새 터미널] 을 실행하면 Command Prompt 터미널이 실행됩니다.

 

300x250

PC 홈페이지와 모바일 홈페이지를 같이 운영하다 보면 사용자가 어느 쪽으로 접속하던 사용하는 매체에 맞는 환경으로 이동시켜 주기 위해 user-agent 속성으로 현재 접속한 환경을 판단합니다.

 

 

iOS의 같은 경우 보통 아래와 같은 로직으로 판단합니다.

const isIOS = navigator.userAgent.match(/iPhone|iPad|iPod/i) === null ? false : true;

iOS 디바이스의 브라우저나 애플리케이션의 웹뷰에서는 "iPhone" 이나 "iPad" 등이 포함되어 있기 때문에 위와 같이 판단할 수 있었습니다만 iOS 13으로 올라오면서 iPad의 user-agent 값이 Mac과 동일하게 아래와 같이 바뀌어 버렸습니다.

Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/605.1.15 (KHTML, like Gecko)

그래서 해당 디바이스를 사용하는 사용자는 모바일 도메인으로 접근해도 PC 홈페이지로 이동해 버리는 현상이 발생하였는데, user-agent를 봐도 Mac과 구분할 수 있는 방법이 없습니다.

 

navigator.maxTouchPoints

navigator 속성 중 디바이스의 최대 동시 터치 수를 return 하는 속성이 있고, iPad의 경우 5를 return 합니다. 일단 또 다른 형태의 디바이스가 나오면 이 방법도 무용지물이 될 수 있겠지만 아직까지는 iOS 13 이상의 iPad를 구분할 때는 해당 속성을 사용하여 구분할 수 있습니다.

const isIOS = navigator.userAgent.match(/iPhone|iPad|iPod/i) === null && navigator.maxTouchPoints != 5 ? false : true;

navigator.maxTouchPoints 의 속성의 상세 내용에 대해서는 아래 링크를 참고합니다.

https://developer.mozilla.org/en-US/docs/Web/API/Navigator/maxTouchPoints

 

Navigator.maxTouchPoints - Web APIs | MDN

The maxTouchPoints read-only property of the Navigator interface returns the maximum number of simultaneous touch contact points are supported by the current device.

developer.mozilla.org

 

300x250

본 포스트는 제우스에서 세션 확인 및 유지를 위해 jSessionId를 사용할 때 주의사항에 대한 포스트입니다.

 

HTTP 프로토콜은 기본적으로 Stateless 합니다. 클라이언트에서 서버에 요청할 때마다 새로운 연결을 생성하고 응답을 받은 후에는 연결을 끊어 버리기 때문에 상태를 유지할 수가 없습니다.

 

jSessionId

그래서 대부분의 WAS Container에서는 jSessionId라는 값을 발급합니다. 브라우저나 혹은 앱의 HttpClient 등 클라이언트가 최초에 JEUS에 접근하면 Response Header에 jSessionId 값을 발급하여 응답합니다. 클라이언트는 이후 요청부터는 이 jSessionId를 Request Header의 Cookie에 넣어서 요청합니다.

 

이 값을 Key로 서버에서는 세션 영역을 사용하고 유지할 수 있고, Request Header에 jSessionId가 전달되었을 경우에는 새로운 jSessionId를 발급하지 않습니다.

 

중복 로그인 방지

보통 서비스에서 동시 로그인을 허용하지 않을 경우 [사용자 아이디 + jSessionId] 값으로 동일한 사용자가 여러 경로로 접속했는지를 판단합니다. 사용자가 로그인을 하면 테이블에 현재 로그인한 jSessionId 값을 저장하고 매 트랜잭션에서 현재 동일한 사용자 아이디로 다른 jSessionId에서 접속하고 있지 않은지 검사합니다.

 

아래와 같은 테이블을 사용합니다. (별도의 테이블을 사용할 수도 있고, 사용자 원장을 사용할 수도 있습니다.)

-- USER table
USER_ID VARCHAR(20),
SESSION_ID VARCHAR(100)

사용자가 로그인에 성공하면 SESSION_ID를 현재 Request 영역에 전달된 jSessionId로 Update 합니다.

UPDATE  USER
   SET  SESSION_ID = ${jSessionId}
 WHERE  USER_ID = ${userId}

애플리케이션에서 비교 로직은 테이블에서 해당 사용자의 SESSION_ID를 읽어 현재 Request 영역의 jSessionId와 비교합니다.

String sessionId = userVo.getSessionId();
String jSessionId = request.getSession().getId();

if (!jSessionId.equals(sessionId)) {
	throw new Exception(~~~);
}

 

이중화 환경일 경우

AP 인스턴스가 여러 대일 경우에도 세션 클러스터링 설정을 해두었으므로 세션을 잘 복제가 됩니다. 하지만 위 로직으로 중복 로그인 방지를 구현했을 경우 접속하는 인스턴스가 변경될 경우 로그인이 풀리는 현상이 발생했습니다.

부하 분산을 Sticky Round Robin 방식으로 하여, 사용자가 접속하는 인스턴스의 변경이 거의 발생하지는 않지만 그래도 인스턴스 별 reboot 등의 작업을 수행할 때 사용자들이 불편을 겪곤 했습니다.

 

JEUS의 경우 세션 클러스터링을 하면 동일한 jSession 아이디로 잘 복제가 되지만 아래와 같이 인스턴스 구분자가 postFix로 추가됩니다. 

17:31:33.406 jSession ID : aaaaaaaaaaaaaaaaaaaaa.xyz01 -> inst01 접속 시
17:31:33.406 jSession ID : aaaaaaaaaaaaaaaaaaaaa.vwy01 -> inst03 접속 시

하여 JEUS의 세션 클러스터링 환경에서 중복 로그인 방지 등을 구현할 때는 위의 로직이 아니라 postFix 앞 쪽만 비교하는 로직으로 변경이 필요합니다. 예를 들면 아래와 같습니다.

String sessionId = (userVo.getSessionId().indexOf(".") > -1) ? userVo.getSessionId().substring(0, userVo.getSessionId().indexOf(".")) : userVo.getSessionId();
String jSessionId = (request.getSession().getId().indexOf(".") > -1) ? request.getSession().getId().substring(0, request.getSession().getId().indexOf(".")) : request.getSession().getId();

if (!jSessionId.equals(sessionId)) {
	throw new Exception(~~~);
}
300x250

JEUS 7에서 운영 중인 솔루션에 기능을 add-on 하면서 솔루션의 Spring framework 버전을 4.1.6에서 4.3.18로 올렸더니 아래와 같은 오류가 나며 WAS가 구동되지 않는 현상이 있었습니다.

java.lang.illegalStateException: zip file closed
	at java.util.zip.ZipFile.ensureOpen(ZipFile.java:634)
    at java.util.zip.ZipFile.getEntry(ZipFile.java:305)
    at java.util.jar.JarFile.getEntry(JarFile.java:227)
    ...

처음엔 신규 솔루션 인스턴스를 추가하면서 WAS 설정이 잘못된 게 아닌가 한참을 찾았지만 혹시나 하고 Spring framework 버전을 4.2.x 버전으로 낮추어 보았더니 Container가 정상 구동되었습니다.

 

구글링으로는 관련 내용이 잘 나오지 않았고, JEUS 제조사인 티맥스 온라인 메뉴얼을 찾아보니 아래와 같은 내용을 발견할 수 있었습니다.

설치되어 있는 JEUS 버전을 확인해 보니, Fix #4 였고 엔지니어의 협조를 받아 Fix #5로 업데이트 한 후에는 문제를 해결할 수 있었습니다.

 

JEUS 온라인 매뉴얼 링크입니다.

https://technet.tmaxsoft.com/upload/download/online/jeus/pver-20171211-000001/release-note/chapter_jeus_7_5.html#d4e1595

 

제2장 JEUS 7 Fix#5

본 장에서는 JEUS 7 Fix#5 릴리즈에서 추가된 새로운 기능과 변경된 기능에 대해 간략히 설명한다. 본 절에서는 JEUS의 신규 추가사항에 대하여 설명한다. deploy target이 all-target인 경우 DAS에는 deploy되

technet.tmaxsoft.com

 

300x250

본 포스트는 트랜잭션 설정에 따른 데드락이 발생하는 원인과 Spring의 Transaction Propagation Level (전파 레벨)에 대한 글입니다.

 

 

프로젝트 오픈 후 특정 인원 몇몇 한정으로 로그인을 시도하면 Timeout이 발생하곤 했습니다. 모니터링 툴에서 보면  해당 트랜잭션이 timeout이 발생할 때까지 active 상태로 있다가 좀비 트랜잭션(?)이 되어 버리는 현상이었습니다.

(인스턴스를 재기동 하기 전까지는 사라지지 않고 계속 문제 트랜잭션으로 표시됩니다.)

 

운좋게 내부 개발자 중 한 명에게 해당 현상이 발생해서 어렵지 않게 원인을 찾을 수 있었습니다. 로그인 시 비밀번호를 틀리거나 다른 사유로 로그인을 실패하게 되면 실패 횟수를 Update 합니다.

UPDATE  TB_USER
   SET  ERR_CNT = ERR_CNT + 1
 WHERE  USER_ID = #{userId}

이렇게 한 번 로그인을 실패한 후 다시 로그인을 해서 성공하게 되면 ERR_CNT를 0으로 Update하는 부분이 있는데 해당 Query에서 트랜잭션이 멈추어 있었습니다. (비밀번호를 틀린 적이 없는 사용자는 해당 Update를 수행하지 않습니다.)

UPDATE  TB_USER
   SET  ERR_CNT = 0
 WHERE  USER_ID = #{userId}

위 Query를 수행하는 Service 및 Dao를 찾아보니 아래 Annotation으로 별도의 트랜잭션으로 설정되어 있었습니다.

@Transactional(propagation = Propagation.REQUIRES_NEW)

Propagation Level

Spring framework에서는 크게 두 가지 방법으로 트랜잭션을 제어합니다. AOP 설정을 사용하는 선언적 트랜잭션 설정과, 위와 같이 별도의 annotation을 사용하여 트랜잭션을 제어하는 방법이 있습니다. 그 중 위와 같이 annotation을 사용하는 방법의 트랜잭션 전파 레벨(Propagation Level)의 종류는 아래와 같습니다.

Propagation.REQUIRED
- default 값이기 때문에 생략할 수 있습니다.
- 부모  트랜잭션 내에서 실행하며, 부모 트랜잭션이 없을 경우 새로운 트랜잭션을 생성합니다.

Propagation.REQUIRES_NEW
- 매번 새로운 트랜잭션을 시작합니다.(새로운 연결을 생성하여 실행합니다.)

Propagation.NESTED
- 해당 method가 부모 트랜잭션에서 진행될 경우 commit 되거나 rollback 될 수 있습니다.
- 부모 트랜잭션이 없을 경우 Propagation.REQUIRED와 동일하게 작동합니다.

Propagation.MANDATORY
- 부모 트랜잭션 내에서 실행되며, 부모 트랜잭션이 없을 경우 Exception이 발생합니다.

Propagation.SUPPORT
- 부모 트랜잭션이 존재하면 부모 트랜잭션으로 동작하고, 없을 경우 non-transactional 하게 동작합니다.

Propagation.NOT_SUPPORT
- non-transactional 로 실행되며 부모 트랜잭션이 존재하면 일시 정지합니다.

Propagation.NEVER
- non-transactional 로 실행되며 부모 트랜잭션이 존재하면 Exception이 발생합니다.

문제점

정상적으로 로그인을 완료하게 되면, 다른 Session에서 중복 로그인하는 것을 방지하기 위해 아래와 같이 해당 테이블의 SESS_ID를 현재 Session의 jSessionID로 Update 합니다.

UPDATE  TB_USER
   SET  SESS_ID = #{jSessionId}
 WHERE  USER_ID = #{userId}

로그인 프로세스를 진행하고 있는 부모 트랜잭션에서 해당 Record를 Update 중이고 아직 commit 되지 않은 상태에서 REQUIRES_NEW annotation이 붙어 있는 Update가 실행되면서 두 트랜잭션 간 교차 상태가 발생한 내용 이었습니다.

 

해결

오류 횟수를 0으로 Update하는 부분을 별도 처리하게 된 히스토리가 명확하지 않았지만, 충분히 영향도 검토를 진행한 후에 해당 annotation을 삭제하여 문제를 해결하였습니다.

300x250

webpack-cli 4.9.x 버전을 사용하고 있는데 아래와 같은 오류를 만난다면 webpack-cli 버전을 4.10.0 으로 재설치하시면 됩니다.

 

 

 

[webpack-cli] TypeError: cli.isMultipleCompiler is not a function
    at Command.<anonymous> (D:\workspace\searchNaverApi\node_modules\@webpack-cli\serve\lib\index.js:146:35)
    at async Promise.all (index 1)
    at async Command.<anonymous> (D:\workspace\searchNaverApi\node_modules\webpack-cli\lib\webpack-cli.js:1672:7)
Note: This command was run via npm module 'win-node-env'
error Command failed with exit code 2.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.

webpack-cli 를 4.10.0 으로 업데이트 한 후에는 해당 오류가 발생하지 않습니다.

 

300x250

본 포스트는 오라클이나 티베로에서 시퀀스의 cache 및 order 옵션 조합에 따른 성능 개선에 대한 글입니다.

 

운영하고 있는 시스템이 여러가지 사유들로 거래량이 제법 큰 폭으로 증가했었습니다. 증가한 범위가 어느 정도는 용량 산정 시 계산한 여분이 허용하는 범위로 큰 문제는 없어야 하지만, 거래가 집중되는 시간에  간혹 DB의 CPU가 임계치를 조금씩 초과하기 시작했습니다.

 

Maxgauge로 분석해보니 특정 Sequence를 채번하는 nextval Query의 CPU Time이 전체 CPU Time의 많은 비율을 점유하고 있었습니다. 정상적이지 않은 시그널임에는 분명했지만 거래가 몰리는 시점에만 발생하는 현상이라 우선순위를 나중으로 좀 미루어 둔 상태였습니다.

하인리히의 법칙 (1: 29: 300의 법칙)
어떤 대형 사고가 발생하기 전에는 같은 원인으로 수십 차례의 경미한 사고와 수백 번의 징후가 반드시 나타난다는 것을 뜻하는 통계적 법칙

잠시 미루어 둔 사이 여러가지 사유로 다시 제법 거래량이 증가하면서 바로 문제가 수면 위로 노출되었습니다. 거래가 몰리는 시간대에 DB CPU가 100%를 치면서 nextval의 속도가 느려지기 시작했습니다.

 

해당 Sequence는 기간 시스템과 인터페이스하는 전문의 추적번호를 채번하기 위해 사용하는 Sequence로, nextval의 성능이 저하되면서 전체 시스템의 성능이 저하되고 응답 시간이 지연되는 큰 문제로 전개되었습니다.

 

임시 조치

결국 잠시 유량 제어 (일정 비율로만 거래를 통과시킴)를 통해 시스템의 과부하를 방지 했습니다.

 

원인 분석

메타 정보인 dba_sequences의 정보를 조회해보니 해당 sequence의 옵션은 cache + order 옵션으로 구성되어 있었습니다. cache 및 order 옵션에 대한 설명은 아래를 참고합니다.

Cache
설정되어 있는 수만큼 해당 sequence의 NEXT_VAL을 증가시킨 후 cache로 가져오고, nextval 명령을 수행할 때에는 미리 채번해 둔 cache의 값을 사용합니다.

 

Order
nextval 명령의 호출 순서에 따라 채번되는 sequence의 순서를 보장합니다. 단순히 Unique 한 값을 채번하는 것이 목적이 아닌 순서를 보장해야 하는 업무의 경우 Order 옵션을 사용해야 합니다.

 

이중화 환경에서 cache와 order 옵션에 따라 sequence는 아래와 같이 동작합니다. 운영 중인 시스템은 티베로를 사용하므로 TAC 환경에서의 동작이며 Oracle RAC도 동일하게 동작합니다.

Cache + Order
전체 node에서 하나의 sequence cache를 동기화하여 사용하므로 순서가 보장됩니다. 매번 순서 보장을 위해 wait lock을 잡고 순번을 채번합니다.

 

Cache + NoOrder
각자 node에서 따로 sequence cache를 사용하므로 각 node 개별적으로 순서가 보장되지만 전체 node에서는 순서를 보장하지 않았습니다. 각 node에서 미리 채번해 둔 cache를 모두 소진하여 다시 cache로 가져올 때만 wait lock을 잡으며, 다른 경우에는 잡지 않습니다.

 

NoCache
cache를 사용하지 않습니다. 매번 메타 정보를 변경하므로 항상 순서가 보장됩니다.

문제 해결

현재는 Cache + Order 방식으로 동작하므로 매번 wait lock을 잡고 채번하므로 거래가 집중될 경우 CPU 점유율이 증가하는 형태였습니다. 하지만 전문 추적번호라는 업무 성격은 Unique 한 성격만 요구할 뿐 반드시 순서가 보장되어야 하는 형태가 아니므로 Cache + NoOrder 옵션으로 변경하기로 하였습니다.

 

현재 운영 중인 시스템이고 거래가 많은 시스템이라 온라인 중에는 Sequence의 속성을 변경하기고 힘들어서 별도의 Sequence를 생성한 후 애플리케이션에서 사용할 sequence를 변경하는 형태로 작업을 진행했습니다.

 

대용량 시스템은 거래가 많이 발생하기 때문에 언제나 세세한 최적화가 필요해 보입니다.

300x250

본 포스트는 파티션 테이블을 사용할 때 글로벌 인덱스와 로컬 인덱스 구성에 따른 차이를 정리한 글입니다.

 

프로젝트 요건 중 고객별 개인화 마케팅을 위해 전체 고객의 구매나 성향 패턴에 대한 데이터를 Weekly 배치 작업으로 반영해 달라는 요건이 있었습니다. 배치 작업이 수행되는 중에도 온라인 거래에서는 개인화 마케팅을 위한 데이터를 참조한 결과를 화면에 표시해 달라는 요건이었습니다.

 

전체 고객의 데이터를 다루기 때문에 데이터가 수천만 건에 달하는 양이었습니다. 해당 부분에 대해 수행사에서 설계, 개발한 내용을 보니 아래와 같았습니다.

1. 테이블을 List Partition으로 구성한다.
2. 첫 번째 주는 0번째 Partition에 데이터를 적재한다.
3. 두 번째 주는 1번째 Partition에 데이터를 적재하고, 0번째 Partition은 truncate 한다.
4. 2-3번 과정을 매주 반복한다.

수행사에서 오픈 후 해당 내용을 적용하고 첫 번째 주 작업은 무사히 진행되었습니다. 하지만 두 번째 주 작업 때는 작업이 수행되는 시간에 해당 테이블을 사용하는 온라인 거래가 지연이 되는 문제가 발생하였고 결국 그 주 배치 작업은 중단 처리했습니다.

 

Global Partitioned Index

해당 테이블은 List Partition으로 구성한 테이블이었는데 Index를 Global Index로 생성하여 사용하고 있던 부분이 문제였습니다. Partition 테이블에서 Global Index를 사용하면 truncate가 발생할 때 Index 영역이 unusable 상태가 되기 때문에 반드시 Rebuild 가 필요하고, Rebuild를 하는 동안 해당 테이블을 사용하는 온라인 거래가 모두 지연된 것이었습니다.

 

Global Index vs Local Index

설명을 작성해볼까 하고 참고할만한 자료를 찾던 중 굳이 설명을 다시하는 것보다 아래 링크에 아주 설명이 잘되어 있어서 링크를 첨부하겠습니다.

http://dbcafe.co.kr/wiki/index.php/%ED%8C%8C%ED%8B%B0%EC%85%94%EB%8B%9D_%EC%9D%B8%EB%8D%B1%EC%8A%A4

 

파티셔닝 인덱스 - DB CAFE

1 PARTITIONED INDEX의 종류 1.1 partitioned index의 종류 1.1.1 LOCAL INDEX local index란 index를 생성한 table과 partitioned index가 equi-partition된 경우를 나타낸다. 즉, index와 table은 같은 컬럼에 의해 partition 되며, 하

dbcafe.co.kr

일반적으로 위와 같은 Rebuild 이슈나 성능 등을 고려하여, 기본적으로 Local Index 사용을 고려합니다. 어떤 사유로 Local Index를 사용할 수 없을 경우 Global Index 사용을 고려하는 순서로 진행합니다.

 

 

300x250

본 포스트는 WAS에서 커넥션 풀 부족 현상을 개선하는 방법 및 스프링(Spring Framework)에서 트랜잭션을 설정하는 방법에 대한 글입니다.

 

 

'22년 초부터는 마이데이터 사업자(토스나 뱅크샐러드 등)의 스크래핑이 금지되어 각 금융사의 Open API 서비스로 연결하도록 전환한 상태이지만 '21년까지는 스크래핑에 대한 규정이나 법률이 없었던 관계로 자산 관리 앱들은 각 금융사의 서버를 직접 스크래핑하는 구조였습니다.

 

특히나 해당 자산 관리 서비스들이 나름대로 성공했기 때문에 제법 많은 DAU를 보유하고 있었고, 그래서 해당 서비스들이 무언가 이벤트를 한다던가 PUSH를 발송한다던가 하면 그 트래픽은 고스란히 원천 데이터를 서비스하는 은행, 카드 등의 금융사들로 몰리게 됩니다.

 

사실 각 금융사에서는 시스템을 구축하기 위한 용량 산정을 할 때 이 스크래핑에 대한 부분을 잡지 않고 산정한 금융사가 대부분이므로  예상 TPS보다 150%, 200%의 거래가 유입되면 재미있는(?) 순간들이 많이 발생합니다.

 

그 중 하나였던 WAS Connection Pool 부족 현상에 대한 이야기입니다.

 

WAS Connection Pool 동작

WAS Connection Pool은 인스턴스를 시작할 때 설정한 개수만큼의 Connection Pool을 생성하여 가지고 있습니다. DB를 사용하는 거래가 유입되면 이 때 생성해 둔 Connection을 빌려서 사용하고 (Active), commit이나 rollback을 수행하면 해당 Connection을 Pool에 반납합니다.

 

지금 IDLE이 없고 전체 Connection이 모두 Active라는 이야기는 DB를 사용하는 거래가 유입되어도 Pool에 있는 모든 Connection이 사용 중이니 다른 거래가 완료되어야 현재 대기 중인 거래를 수행할 수 있다는 의미가 됩니다.

 

상태 확인

이런 현상들이 처음 발생했을 때는 특정 Query가 지연되거나 DB 사용률이 높아서 모든 Connection이 Active 상태인가? 라고 생각해서 DB 모니터링 툴을 확인했지만 전체적으로 CPU 도 2-30% 수준에 Running Session이 쌓이는 현상도 없었습니다.

 

일단 조치가 필요했으므로 유입되는 거래랑 특정 비율로만 통과시켜서 지연 상태를 해소시킨 후 Framework의 Transaction 설정을 확인했는데 아래와 같이 설정되어 있었습니다.

<aop:config>
	<aop:pointcut id="requiredTx"
    	expression="execution(* com..impl.*ServiceImpl.*(..))" />
    <aop:advisor advice-ref="txAdvice"
    	pointcut-ref="requiredTx" />
</aop:config>

위 설정은 전체 ServiceImpl 하위의 모든 method를 Transaction 처리한다는 설정입니다. 보통 SI를 하게 되면 특별히 요건으로 요청하지 않는 이상 기본적으로 저런 형태로 초기 설정을 많이들 하게 됩니다.

 

문제점

사실 대고객의 접점에 있는 채널 시스템(홈페이지나 앱 등)은 원천 데이터를 가지고 있지 않습니다. 기본적인 로그인에 필요한 정보나 부가 정보 등은 가지고 있지만 대부분의 데이터는 계정계나 승인 시스템에서 보유하고, 채널 시스템은 해당 시스템들과 연계하여 고객에게 정보를 제공합니다.

 

무슨 이야기인고 하니, 생각보다 자체적으로 DB Transaction 처리를 할 필요가 있는 업무가 많지 않다는 의미입니다. 하지만 타 시스템과 인터페이스를 할 때 DB를 늘 사용하게 되어 해당 거래들이 언제나 하나의 Transaction으로 묶이게 됩니다.

1. 전문 추적번호 채번 및 Logging을 위해 DB를 사용합니다.
2. ServiceImpl class에서 수행합니다.

위와 같은 이유로 타 시스템 인터페이스는 모두 Transaction으로 묶이므로 거래가 급증하여 내부에 있는 타 시스템들이 거래가 지연되어 응답이 늦게 되면 Pool에 있는 모든 Connection들이 Active 상태로 유지되어 전체 채널 거래의 지연으로 이어집니다.

 

해결 방법

해결은 단순히 '분리' 밖에 없습니다. Transaction으로 묶어야 하는 서비스와 Transaction으로 묶지 않아도 되는 서비스를 분리하여 설정해야 합니다. 예를 들어 채널 DB에서 관리하는 로그인이나 회원가입 등, INSERT 및 UPDATE가 있는 서비스는 All or Nothing이 보장되어야 하므로 Transaction 처리가 필요합니다.

 

하지만 타 시스템에서 값을 조회한다거나, 타 시스템에 데이터를 처리 요청하는 서비스의 경우에는 타 시스템에서 Transaction 처리한 후 결과를 응답하므로 채널 시스템에서는 Transaction 처리가 필요하지 않습니다. 이런 부분을 초기 설계에 반영해서 구축했다면 이런 지연 현상들은 많이 방지할 수 있었을테지만 보통 이런 문제는 '운영' 단계에 접어들어야 발생하곤 합니다.

 

운영 중인 시스템은 영향도 검토도 정말 만만치 않은 작업이고 전체 시스템에 영향을 주는 이런 설정을 변경하기는 정말 손 떨리는 작업이 됩니다.

 

분리 방향

분리 방향은 크게 두 가지로 나눌 수 있습니다.

1. 전체 ServiceImpl transaction을 해제 후
   transaction 처리가 필요한 ServiceImpl만 TxServiceImpl로 변경
2. 전체 ServiceImpl transaction 설정을 유지한 상태에서
   transaction이 불필요한 ServiceImpl만 aop 설정에서 제외 처리

먼저 1번 방향을 목표로 하고, 분석을 위해 프로젝트를 최신 상태로 Sync 해보니 현재 사용 중인 ServiceImpl이 500본 정도가 되었습니다. (ㅎㄷㄷ)  ServiceImpl 하나 당 method도 여러 개가 있으니 분석이 가능할까 싶었지만 팀원들과 공유하여 분석해 보기로 하였습니다.

 

각자 담당하고 있는 업무 영역이나 담당하지 않는 부분까지도 다들 바쁜 시간을 쪼개어서 분석에 참여하였기에 생각보다 잘 정리가 되는 듯 했지만 분석된 내용을 샘플링해서 검토해 보니, 잘못된 분석들이 꽤나 있었습니다. 예를 들면 이벤트 응모 같은 서비스는 채널의 DB에서 자체적으로 응모 데이터를 관리하므로 Transaction 처리가 필요하지만, 타 시스템과의 인터페이스도 있어 Transaction 불필요 업무로 분류되어 있었습니다.

 

시스템의 안정성이 위협을 받고 있는 상황에서 전체를 시니어들만으로 재분석 할 수는 없었기에 2번으로 방향을 급선회 했습니다.

 

Default는 Transaction 처리. 일부만 제외.

일단 일부만 제외하는 것으로 방향을 잡았으니 Impact가 있는 대상으로 선정이 필요했습니다. 하여 아래 기준을 적용하여 제외할 대상을 선정하고 진행했습니다.

1. 거래량 순위 TOP 40 안에 드는 거래 중
2. 타 시스템 인터페이스만 사용하고 DB 거래가 불필요한 서비스

Spring 설정은 아래와 같은 식으로 제외할 대상을 선언했습니다.

<aop:pointcut id="requiredTx"
	expression="execution(* com..impl.*ServiceImpl.*(..))
           and !execution(* com.a.AServiceImpl.*(..))
           and !execution(* com.b.BServiceImpl.*(..))" />

 전체 ServiceImpl은 기본적으로 Transaction 처리 후 !execution으로 원하는 ServiceImpl만 제외하는 설정입니다.

 

결과

효과는 정말 드라마틱 했습니다. 전체 3,000개 정도의 서버 인터페이스 중 TOP 40이 차지하는 비중은 80%가 넘었기에, 대부분의 거래에서 Connection을 점유하는 시간 자체를 줄였기 때문에 거래가 집중되어도 Active Connection이 증가하지 않았습니다.

 

집중이 아닌 시간 대에도 평소 Active Connection이 인스턴스 당 20-30 정도였지만 설정 변경 후에는 인스턴스 당 5-7 정도로 아주 안정적인 시스템으로 변신해 버렸습니다.

300x250

'Trouble shooting' 카테고리의 다른 글

Sequence order 옵션에 따른 성능  (0) 2022.06.25
Partition truncate 시 온라인 거래 지연 이슈  (0) 2022.06.24
MAX + 1 채번 이슈  (0) 2022.06.22
중복 로그인 체크 오류  (0) 2021.10.07
JS UI Rendering, Data bind  (0) 2021.10.06

보통 순번 채번이 필요한 경우 대부분 Sequence를 사용합니다.

하지만 아래와 같은 사유로 시스템을 운영하다 보면 MAX + 1로 처리된 순번도 눈에 많이 보입니다.

 

1. 오래 전에 작성했거나,

2. 컬럼 조합에 따른 순번이어야 하는 경우 (ex> 접수일자 + 접수순번)

 

위와 같은 경우이거나, 프로젝트를 한 시스템을 인계 받았을 때 개발 가이드를 준수하지 않은 코드 등에서는

아래와 같이 작성된 MAX + 1 Query도 종종 있습니다.

INSERT  INTO  TBL_A (
	REQ_DT
     ,  SEQ
     ,  ...
     )  VALUES (
     	'20220622'
     , (SELECT  MAX(SEQ) + 1
          FROM  TBL_A
         WHERE  REQ_DT = '20220622')
     ,  ...

대강 위와 같은 형태입니다.

 

위와 같은 형태도 사실 거래가 많지 않은 시스템은 크게 문제가 없습니다. 순서대로 채번도 잘되고 데이터 무결성에도 문제가 없습니다.

하지만 거래량이 어느 정도 이상이 되는 대용량 시스템의 경우에는 언제든지 해당 Query로 인해 이슈가 발생할 수 있습니다.

 

문제점

해당 Query를 사용하는 transaction이 단순히 저 Query 하나로만 동작을 한다면 그나마 문제가 좀 덜하겠지만 해당 transaction에서 타 시스템과의 인터페이스 등 시간이 소요되는 다른 코드들이 존재한다면 Unique constraints violation이 발생하게 됩니다.

 

현재 MAX(SEQ)의 값이 1이라고 하면

transaction A 00:01:01.111(start) 00:01:567(commit)
transaction B 00:01:01.234(start) 00:01:789(commit)

위 순서로 유입되는 transaction A, B가 있다면 A와 B는 모두 MAX(SEQ) + 1 값으로 2를 얻어갑니다. 하여 둘 중에 먼저 commit이 일어나는 A는 SEQ 값을 2로 하여 문제 없이 commit 되고 transaction이 종료되지만,

B는 동일한 REQ_DT에 동일한 SEQ 값이 이미 생성되었으므로 unique constraints violation이 발생합니다.

(물론 REQ_DT, SEQ가 Composit PK 혹은 Unique Index라는 가정입니다.)

 

Sequence로 변경

이런 내용을 가장 깔끔하게 처리하는 방법은 Sequence 입니다. Sequence의 경우는 nextval을 한 후 바로 DD 테이블의 값이 증가하고 rollback이 불가능하므로 데이터의 무결성이 유지됩니다.

 

하지만 위와 같이 REQ_DT 하위의 순번으로 데이터가 관리되어야 한다면 Sequence는 사용할 수가 없습니다.

또한 현재 운영 중인 시스템의 영향도를 모두 파악하여 재구성한 후 Sequence로 변경하는 것도 결코 쉬운 일은 아닙니다.

(해당 테이블을 타 시스템으로 전달하여 해당 시스템도 해당 순번을 Key 값으로 사용하고 있는 경우 등등)

 

채번 테이블 사용

이런 경우에는 좀 귀찮기는 해도 채번 테이블을 사용할 수 있습니다. 채번 테이블은 MAX 순번을 관리하는 별도의 테이블을 운영하고 해당 테이블에서 값을 읽을 때 SELECT ~ FOR UPDATE로 Lock 처리를 통해 무결성을 유지하는 방법입니다.

REQ_DT VARCHAR2(8)
MAX_SEQ Number

위와 같은 형태의 채번 테이블이 있다면 transaction을 시작할 때 채번 테이블에서 순번을 가져오면서 Row Lock을 획득합니다.

SELECT MAX_SEQ
  FROM TBL_SEQ
 WHERE REQ_DT = #{reqDt}
   FOR UPDATE

 가져온 순번으로 원장에 데이터를 INSERT 합니다.

INSERT INTO TBL_A (
       REQ_DT
     , SEQ
     , ...
     ) VALUES (
       #{reqDt}
     , #{maxSeq}
     , ...

채번 테이블의 MAX_SEQ 값에 +1 한 후에 transaction을 종료 처리합니다.

UPDATE TBL_SEQ
   SET MAX_SEQ = MAX_SEQ + 1
 WHERE REQ_DT = #{reqDt}

위와 같은 방법으로 동시성 문제를 해결하고 데이터 무결성을 유지할 수 있습니다.

300x250

+ Recent posts