기본적인 틀은 MSA Batch Server이다. 하지만 API 서버에도 동일하게 적용할 수 있다.
위 그림에서는 이해를 돕기 위해 Aggregate에서 전달, 반환용 객체는 Aggregate의 범위에서 외부로 걸쳐있는 것처럼 묘사하였다.
순번대로 설명을 하면
1) 스케쥴 예약이 된 Batch 서버에서 요청을 담은 Queue로부터 요청을 Pop하여 추출한다.
2) Pop한 요청에 해당하는 Service Method를 호출한다.
3) Service Method는 Domain Aggregate에 Primary Key를 전달한다.
4) 전달된 Primary Key를 가지고 Aggregate 내에 구현되어있는 실질적인 Service Logic을 실행한다.
5) 실행된 Service Logic의 결과를 정해진 반환 객체 형태로 변환한다.
6) 반환 객체 형태로 변환된 결과값을 반환한다.
7) 3)의 과정을 다른 Aggregate에서 진행한다.
8) 4)의 과정을 다른 Aggregate에서 진행한다.
9) 5)의 과정을 다른 Aggregate에서 진행한다.
10) 6)의 과정을 다른 Aggregate에서 진행한다.
11) 3~6, 7~10의 과정으로 반환받은 결과를 정리하여 실행 성공 여부를 반환한다. (API 서버였다면 정리된 결과를 클라이언트에게 반환하면 된다.)
이전의 최소 설계와 다른 점은?
이번에 중점을 둔 구간은 3~6번의 과정을 다루는 Aggregate의 구간이다. 최소 적용했던 이전 설계는 Service Class에서 모든 Service Logic을 담고 있기 때문에, Business Logic 별 Service Logic의 정리가 되어 있지 않았다. 그로 인한 Service Logic의 재활용도가 낮았고, Service Class 간의 호출에도 유동적으로 대처할 수 없었다.
하지만 위 구조로 재설계하면 실질적인 Service Logic은 Domain Aggregate내에 위치하게 되며, Service Class는 Domain으로부터 빌려오기만 하면 된다.
단점은 없을까?
1) 개발 단계에서 Domain, Service Class간 설계에 신경을 많이 써야 한다.
2) 설계의 실수가 발생한 채로 운영까지 배포하게 되면 되돌리기 위한 추가 공수가 발생하게 된다.
3) Domain과 Service Class 사이의 명확한 경계를 제안하는 명세가 필요하게된다.
서버 프로젝트의 구성은?
Aggregate를 적용하기 전에는 Service Package들이 각각 Service Logic과 그에 해당하는 sql들을 보유했다.
하지만 그 역할을 각 Domain이 맡게 되면서 Service Class는 필요한 기능을 명세하는 수준의 코드가 되고, Domain Aggregate의 영역에 Service Logic이 포함되기 때문에 명세와 로직의 구분을 눈으로 볼 수 있도록 위와 같이 분리 시켰다.
위 구상의 적용은 가능할까?
개인 프로젝트로는 위 구성으로 만들어봤자, 이 구조를 위한 프로젝트를 만들어질 뿐이다. 그래서 현재 실무에서 적용할 수 있을지 생각해봤는데, 마침 딱 좋은 프로젝트가 있다.
이벤트를 위주로 비지니스 로직을 구현하고, 애그리거트를 일련의 이벤트로 DB에 저장하는 기법이다.
뭐라구요?
위 설명으로 이해할 수 있을리가 없다.
일반적인 RDB의 활용과 구현법을 생각해보자.
회원 정보 혹은 상품 정보 등을 저장할 때는 주로
1개의 table은 서버 입장에서 객체의 집합체(저장소)
1개의 row는 1개의 객체
의 구조로 사용하거나 혹은
사용자가 특정 API를 호출했다는 이력을 저장하기 위한 이력형 table
로 사용한다.
여기서 우리가 사용할 것은 두번째 방법인 이력형 테이블(historical table)이다.
이벤트의 이력을 기준으로 애그리거트를 활용한다!
애그리거트는 어디까지나 객체의 정책이기 때문에 그에 맞춰서 실행하면 되지만, 약간 다른 방식을 사용한다.
순서는
호출자는 원하는 애그리거트의 상태 변화를 이벤트 DB 테이블에 저장한다.
이벤트 DB 테이블의 변경점을 발견한 앱이 이벤트를 읽는다.
읽어들인 이벤트에 속하는 애그리거트의 인스턴스를 생성한 후 이력 정보에 있는 기능을 수행한다.
라는 3개의 과정을 통해 실행된다.
ex) 주문 정보 애그리거트가 있다고 하자.
발생한 요청은 주문 정보 애그리거트가 가지고 있는 주문 생성 기능이다.
호출자는 애그리거트의 상태가 [없음]에서 [주문 생성]으로 변경되는 작업을 요청하기 때문에, 해당 이벤트를 DB에 저장한다. 이후 해당 이벤트 DB를 모니터링하고 있던 소프트웨어는 이벤트 로그에 기록된 애그리거트의 작업을 실행시킨다.
그렇구나... 하고 지나갈 법 하지만 매우 무책임한 과정이다. 저장은 하면 되지만 모니터링은 어떻게 할 것인가? DB에 저장하면 신규 이벤트인지 알게 뭔가?
이벤트 감지는?
참고 서적에서 제시한 방법은 아래 두 가지이다.
폴링(Pollin)
주기적으로 이벤트 발행 테이블을 조회한다.
미쳤냐?
트랜잭션 로그 테일링(참고 서적에선 이 방법을 추천)
해당 테이블의 트랜잭션 로그에서 insert를 감지하여 메시지 브로커로 이벤트를 전달한다.
메시지 브로커를 구독하고 있던 앱에서는 전달 받은 이벤트를 실행한다.
그리고 둘 다 내 취향은 아니다.
폴링은 비용 소모가 너무 크고, 트랜잭션 로그 테일링은 설명만 들어도 어렵다. 교수님은 분명 소프트웨어에서 Simple is beautiful만큼 중요한 덕목은 없다고 하셨다.
내가 원하는 방식은 트랜잭션 로그 테일링에서 나온 메시지 브로커를 데이터의 저장과 전달의 역할이 가능한 MQ로 구현하여 메시지 브로커만을 사용하는 것이다. 이벤트 이력 저장은 이벤트가 전달된 다음에 하면 되잖아!
적용 범위는 서버 프로젝트 내부
위 과정은 사실 MSA가 적용된 서버 간 통신 방식이 서버 프로젝트 내에 적용된 방식이다. 때문에 위 내용만 봐서는 단일 프로젝트 내에서 객체 간 호출을 하려고 DB에 저장하고 조회해야 하는 심각한 뻘짓이다. 하지만 뻘짓이라고 위 과정을 뺄 순 없다.
왜 이렇게 해야 하는가?
애그리거트는 도메인의 철저한 관리를 주목표로 한 정책과 주고를 가지고 있다. (비록 뇌피셜이지만, Root Entity와 PK만을 통한 참조 정책은 이것을 노린 것이 분명하다.) 애그리거트 간 호출은 하나의 프로젝트 내에서 작동할지 몰라도, 역할은 다른 서버에서 작동하는 것처럼 구현하여 철저한 이력 관리를 할 수 있어야 한다는 것이다.
* 위 내용을 책으로 보기 전에, 실무에서 유사한 구조를 직접 설계/구현한 적이 있었다. 그 당시 목적은 데이터의 소실을 우려하여 모든 이벤트를 이력으로 남기는 것이었다. 이후 이력을 베이스로 정확한 데이터 오차 검증을 로직으로 구현하였다. 솔직히 처음에만 반짝 뜬 후 빛의 속도로 꼬라박아 사라진 서비스지만, 분명 꽤 많은 이벤트가 발생했음에도 단 하나의 오차도 발생하지 않은 성과를 이루었다. 이런 경험 때문에 위 내용을 책에서 읽으면서 뿌듯하기도 했고, 이 방식을 쓰는 이유를 이해하는데 큰 시간이 걸리지도 않았다.
설명하지 않은 내용들
책에는 이벤트의 전달 파라미터 변경, api 포맷의 변경 등의 조치를 위한 이벤트 버전 관리, 이벤트 이력 용량이 커짐에 따른 방안, 이벤트 중복 발생에 대한 처리 등 여러 예외 처리 과정을 추가적으로 언급하고 있다.
하지만 이런 부분들은 당장 내가 위에서 취향에 안맞아 다른 방식을 쓰는 것과 같이 책의 저자가 구현한 방식에 맞춘 방식이기 때문에, 여기서 언급하지는 않았다.
백엔드 개발 중 해야할 작업을 나중으로 미루는 것은 성능 및 서비스 안전성에 긍정적인 효과를 줄 때가 많다. 마이크로서비스 패턴(MSA)의 구조 역시 이러한 철학을 바탕으로 되어있고, 메시징 큐(MQ)를 통한 비동기 구조로 채택하는 경우가 많다.
하지만 아주 작은 작업을 위해 별도의 서버와 프로젝트를 구성하지 못하는 경우도 있다. 그래서 기능 중 일부를 위와 같은 기능으로 비동기화하여 그 기능을 동일 서버에서 처리하도록 하는 경우가 있다. 그리고 이런 기능은 아마 당장은, 혹은 그냥저냥 잘 굴러갈 수 있다.
잘 굴러가는데 왜 뻘글을 쓰고있냐면, 이게 장기적으로 매우 나쁜 선택지가 됐기 때문이다.
발단은 서버의 성능 확인을 위한 부하 테스트 작업이었다.
비동기로 등록된 작업의 스레드 작업 우선순위는 당장 서버로 진입하는 request보다 후순위로 밀리는 것이 당연하다. 이는 실제 테스트를 통해 확인하였다. 하지만 결국 예약된 비동기 작업 또한 처리가 되어야 하고, 서버 OS의 스케쥴링 규칙과 작업의 지연 시간 증가로 작업 실행 우선 순위는 점점 상승하게 된다. 이 순위가 당장 처리되어야 할 진입 request의 우선순위보다 밀리게 될 때 문제가 발생했다.
0.00x~0.x 초 사이로 빠르게 처리되어야 할 reqeust의 작업이 대량의 비동기 작업들에 밀려 x초 단위로 지연이 발생한 것이다.
쉬벌
서버의 부하가 많을 때 발생하는 조건부 지연이지만, 백엔드 개발이란게 원래 부하 받을 때 안전하게 돌아가라고 하는 개발인데 이게 고려가 안된 것이다.
그리고 사실 이건 해결책을 내기도 힘든게 별도 비동기 처리용 서버를 당장 새로 만들 수도 없고, 그렇다고 이제와서 동기(synchronous) 처리를 하기에도 애매하다.
Redis는 single thread 동작을 하기 때문에, 어떠한 single command라고 해도 명령어 실행이 끝나기 전까지는 다른 redis client에 대한 block이 발생한다. 이런 현상은 list 단위의 key 조회 명령어인 mget에도 동일하게 적용된다. 반면에 pipeline 방식은 일괄 처리를 위한 명령어일 뿐, mget과 같은 단일 동작을 위한 기능을 가진 명령어가 아니기 때문에 block이 일어나지 않는다.
어느 정도의 일괄 조회는 상관 없으나 만약 대량의 key 조회가 필요하다면, pipeline을 사용한 일괄 조회가 더 낫다.
* 그런데 공식 문서에서 언급된 부분은 아직 찾지 못했다. 하지만 꽤 많은 글에서 위와 같은 한계점이 있다고 언급하고 있다.
사진 5. controller war 파일 실행 및 프로세스 확인사진 6. Controller 화면이 나오는 것을 확인!
기본 계정은
아이디 : admin
비밀번호 : admin
이다
그리고 이하 설명은 한국어 베이스로 진행할 것이기 때문에 언어는 한국어로 설정할 것이다.
사진 7. 정상적으로 진행 됐다면, 이렇게 나온다. 아무코토 없다.
5-2. nGrinder-Agent
Controller가 준비됐으니, 부하 발생기인 Agent를 준비해보자.
Agent의 실행 파일은 Controller를 실행시키면 얻을 수 있다.
사진 8. 우측 상단 계정을 (여기선 admin) 누르면, 여러 기능을 볼 수 있다.
사진 8을 따라 우측 상단의 계정을 클릭하면, 플로팅 메뉴가 나오는 것을 알 수 있다.
해당 메뉴에서 "에이전트 다운로드"를 클릭하면, ngrinder-agent-(Controller의 IP).tar 압축 파일이 다운로드 되는 것을 알 수 있다.
* 해당 압축 파일은 실행 가능한 자바 프로젝트의 압축 파일이기 때문에, 사용할 위치에서 압축만 풀어주면 된다.
사진 9. Agent 파일을 원하는 위치에 놓고 압축을 풀어준다.사진 10. 압축을 푼 후 보면 ngrinder-agent 폴더가 형성된 것을 확인할 수 있다. 사진 11. 10번 사진에서 생성된 폴더에 들어가보면, 실행 파일이 들어가있는 lib 폴더와 리눅스/윈도우 용 스크립트 파일, 그리고 초기 설정용 __agent.conf파일이 있다.
사진 10과 11을 보면 압축을 풀었을 때 nGrinder-agent 폴더가 생성된 것과 폴더 하위 경로에 실행 스크립트/실행 파일/__agent.conf 파일이 있는 것을 확인할 수 있다.
여기서 먼저 해야할 것은 __agent.conf 파일의 수정이다.
사진 12. __agent.conf 파일 내부 내용
하단에 주석이 되어있는 것은 사용하지 않는다. (추가적인 기능이 있는 것이겠지만, 기본적으로 사용하지 않았다. 자료도 적고 테스트 진행에 있어서 필요하지 않았다.)
필요한 것은 상단의 agent.controller_host 이다.
현재 작성하고 있는 예시는 시험용 예시이기 때문에 Controller와 Agent가 하나의 기기(노트북)에서 함께 실행하지만,
본래의 목적 달성을 위해서는 Controller와 Agent는 다른 서버 머신에 있어야 한다. 이 때 실행되는 Agent가 Controller를 바라보게 되는데 그것을 위해 Controller_host, 즉 Controller의 IP와 Controller_port를 필요로 하게 된다.
* Controller가 설정 수정 없이 디폴트로 수정되었다면, Web 접근용 포트 이외에 Agent 접근용 16001 포트와 부하 테스트 사용을 위한 12000~12009 포트가 함께 개방된다.
지금 예시에서는 localhost로 실행되기 때문에 그냥 실행하겠다.
실행 방법은 윈도우의 경우 run_agent.bat 혹은 run_agent_bg.bat을 실행하고, 리눅스의 경우 run_agent.sh 혹은 run_agent_bg.sh를 실행하면 된다. (_bg는 백그라운드로 실행하겠다는 의미이다. 실제 동작 확인을 하고 싶다면 run_agent를, 아니면 run_agent_bg를 실행시키면 된다.)
사진 13. nGrinder-agent의 실행 및 프로세스 확인
예시에서는 _bg파일로 스크립트를 실행하였고, Agent 자바 프로세스가 정상적으로 실행된 것을 확인할 수 있다.
그럼 Controller에서 정상적으로 인식했는지 확인해보자
사진 14. 우측 상단 계정을 클릭 후 에이전트 관리
우측 상단 계정 클릭 후, 에이전트 관리로 들어가보자
사진 15. Agent가 등록되어 있는 것을 확인
Agent의 정보가 등록된 것을 확인할 수 있다.
주의) 내 경우 실무 적용 시 aws subnet 환경에서 해당 작업을 진행했는데, Controller의 외부 접근용 IP를 __agent.conf에 설정 후 실행 시 Connection refused 에러가 발생했었다. 정확한 이유는 모르겠지만 내부 IP로 재설정 후 실행했더니 해결되었다.
여기까지만 해도 부하 테스트를 충분히 진행할 수 있기 때문에 보통 여기서 설명이 끊긴 경우가 대부분이었다.
하지만 어림도 없지. 팀장님께선 리소스의 모니터링도 볼 수 있어야 한다고 하셨다.
팀장님은 모니터가 좋다고 하셨어
5-3. nGrinder-Monitor
마지막 Monitor를 보자.
Monitor는 nGrinder-architecture 구성 중에서 target server, 부하를 받을 서버에 설치되어야 한다.
Agent에 의해 부하를 받는 동안에 부하 대상의 CPU, Memory, Network receive/send Queue를 시각화하여 함께 볼 수 있기 때문에 적용하여 손해보는 것은 단 하나도 없다.
하지만 개인적으로 처음 설정할 때 가장 고통 받았던 것이 Monitor다.
설정 이전에 이해를 돕기 위한 설명!
Agent는 Agent가 Controller를 보는 것이다. 그렇기에 Agent에 Controller를 등록했다.
그렇다면 Monitor는 어떨까? 사진 1의 nGrinder architecture를 보면 알 수 있듯이, Controller가 Monitor를 본다고 되어있다.
그럼 이제 Monitor 설치를 해보자
사진 15. 사진 14와 똑같은 사진이다.
* 사진은 위에 올려보기 싫으니까 똑같은거 다시 올렸다.
플로팅 메뉴에서 모니터 다운로드를 확인할 수 있다. 다운받자.
사진 16. Agent와 동일하게, 압축 파일을 받았다
이후 Agent와 동일하게 tar -xvf 명령어로 압축을 풀어서 생성되는 ngrinder-monitor 폴더로 들어가보자.
사진 17. Agent를 잘못 들어온 것이 아니다. Monitor 맞다
Agent와 거의 비슷해보이고 __agent.conf 파일이 있어 잘못들어왔나 싶겠지만, Monitor경로로 제대로 들어온 것이 맞다!
사진 18. Monitor의 __agent.conf 내용
수정하지 않은 __agent.conf의 내용이다.
실행 모드는 monitor로 되어있는데, 이것은 Monitor가 원래 Agent와 동일한 프로젝트였던 것으로 추측한다.
* 증거 없이 이렇게 추측하는 이유는, 구버전의 설정을 설명한 다른 글들에서 볼 때 Monitor의 __agent.conf 포맷이 사진 18과는 다르게 Agent의 그것과 포맷이 동일하기 때문이다. 뻘소리니 그냥 Monitor설정이구나 하고 넘어가자
그 아래 주석 처리 되어있는 monitor.binding_ip는 리소스 모니터링을 할 대상 서버 머신의 IP이다. 주석처리가 되어있다면 디폴트로 Monitor가 실행되고 있는 서버 머신의 리소스를 보게된다.
monitor.binding_port는 monitor가 실행될 때 개방하는, nGrinder-Controller가 nGrinder-Monitor로 접근하기 위한 port이다. nGrinder-Controller의 기본 설정으로 지정되어있기 때문에 수정은 하지 않겠다.
그럼 실행을 해보자
사진 19. Controller, Agent, Monitor 모두 실행 중인 것을 확인
여기까지 했으면 거의 다 된 것이다.
마지막으로 남은 Controller에 Monitor를 등록하는 과정을 진행해보자.
사진 20. Controller 성능 테스트 페이지
Controller 상단의 "성능 테스트" 탭을 클릭하면, 위와 같은 페이지에 진입하게 된다.
우측에 있는 "테스트 생성" 파랑색 버튼을 눌러보자
사진 21. 테스트 설정 페이지
위와 같은 테스트 설정 페이지가 나오는데, 우리는 여기서 테스트를 하는 방법은 보지 않을 것이다.
사실 작성은 하고 싶은데, 부하 테스트에 사용할 서버를 올려놓은게 없다.
여기서 봐야하는 것은 왼쪽 아래에 "테스트 대상 서버"라고 쓰여진 텍스트 박스이다. 텍스트 박스 오른쪽 아래 "+ 추가"를 눌러보면 도메인과 IP를 추가할 수 있는데, IP만 추가해주면 설정이 끝나게 된다.
여기까지 설정한 후 테스트를 진행하면, 상세 보고서에서 좌측 하단에 타겟 서버 당 시각화된 리소스 모니터 데이터를 확인할 수 있다.
AWS에서 권장하는 데이터를 AZ(Availability Zone) 여러 곳에 분산하여 저장하는 정책이다. ex) AWS에서 제공하는 Cache 서버를 구성하는데 단일 클러스터로 구성하였다. 이런 상황에서 AWS 오류로 Cache 서버가 죽어서 서비스에 차질이 생길 수 있으니, 서울-Region-a와 서울-Region-b 두 곳에 분산시켜 Fail Over 대책을 세워야 한다는 것이다
서비스 안정성을 위해서 HA를 위해 Multi-AZ를 시키는 것은 맞지만 이 정책에는 일단 함정이 있다. AWS의 서비스 안정성은 99.99%(서비스 별로 다름)로 AWS측에서 일부 서비스를 재시작하거나 내부 오류가 발생하여 서비스 기능이 일시 중지 될 수 있다. 해당 내용은 공식 매뉴얼에 기재된 것으로 실제 발생할 수 있는 상황이다. 이 때 AWS 오류로 인해 일부 기능이 작동을 하지 않는 경우, Multi-AZ를 적용하지 않아 피해를 본 고객사에게는 피해 보상을 하지 않는다. (물론 Multi-AZ-AZ는 추가적인 인스턴스 혹은 AWS EBS를 대여하는 것이므로 비용이 늘어난다.)
AWS가 죽어봤자 얼마나 죽겠어! 라고 생각할 수 있지만 의외로 잘 죽는다. 2019년 AWS 도쿄 리전의 일부 AV에 속한 캐싱 서버가 오류를 일으켜, 해당 Region과 AZ에 단일 캐싱을 두고 있던 기업들이 피해를 본 적이 있다. (내 기억으론 그 때 넥슨 일부 게임들도 오류가 났었던 꽤 큰 이슈로 알고있다.)