-
14장. 고성능 로깅 및 메시징DevBook/Optimizing Java 2024. 7. 31. 08:24
개요
- 지연에 민감한 고성능 어플리케이션을 다룰 때 개발자가 해결해야 하는 일반적인 이슈 몇가지를 살펴보고, 이런 시스템에는 어떤 요건이 있고 어떠한 방식으로 설계해야 하는지 안내함
- 저지연, 고성능 시스템에서 핵심적인 고려사항 2가지는 로깅과 메시징
14.1 로깅
- 다른 라이브러리와 마찬가지로 로깅 라이브러리도 프로젝트 도입 전 신중한 확인이 필요하다.
14.1.1 로깅 벤치마크
- 가장 많이 쓰는 세가지 로거(Logback, Log4j, java.util.logging)의 성능을 비교하는 벤치마크를 살펴보자
방식
- 해당 자료를 사용해 벤치마크 수행
- 해당 프로젝트는 여러 가지 설정으로 다중 로거를 실행 가능한 벤치마크 제공함
로깅 포맷
1) Logback 포맷
14:18:17.635 [Name Of Thread] INFO c.e.NameOfLogger - Log message
2) java.util.logging 포맷
Feb 08, 2017 2:09:19 PM com.example.NameOfLogger nameOfMethodContainingLogStatement INFO: Log message
3) Log4j 포맷
2017-02-08 14:16:29,651 [Name Of Thread] INFO com.example.NameOfLogger - Log message
측정
1) 아이맥
- Logback이 전반적으로 성능이 가장 우수하고 로깅 포맷이 Log4j 일 때 최고임
2) AWS EC2 t2.2xlarge
- Logback이 Log4j보다 약간 더 빠르게 나옴
로거 결과
- 실행 시간 측면에서 대체로 Logback 성능이 가장 좋았고 자바 유틸 로거가 제일 나빴음
- Log4j 포맷은 전반적으로 가장 일관된 결과를 보였음
- 실제 시스템에서는, 결과 수치가 엇비슷할 경우, 운영 장비에서 직접 실행 성능을 테스트 해보는 게 좋음
- 로깅 프레임워크가 생성하는 가비지도 잘 따져봐야 함 (로깅하느라 소비한 CPU 시간만큼 핵심 업무를 병렬 처리할 기회를 잃어버리기 때문). 로깅 라이브러리의 설계와 작동 원리 역시 직선적인 마이크로벤치마크 실행 결과만큼 중요함
14.2 성능에 영향이 적은 로거 설계하기
- Log4j 2.6 버전은 정상 상태(steady-state)의 가비지-프리(garbage-free)한 로거로 해결하는 것을 목표로 출시됨
- 정상 상태 : 어플리케이션이 실행되는 동안 일반적인 운영 상태를 의미함. 시스템이 장기적으로 안정적인 상태에서 동작하는 것
- 가비지-프리 : GC의 필요성을 최소화하거나 없애는 것
- Log4j 2.6은 임시 배열을 생성해 로그문에 전달되는 매개변수를 담고 가변인수(varargs)를 사용해 할당 횟수를 줄임
- 모든 매개변수를 담는 단일 배열을 생성해 로그 메시지를 생성하는 과정에서 불필요한 객체 생성을 줄임
- 가변인수를 사용해 필요한 만큼의 메모리만 할당하여 메모리 사용을 효율적으로 관리함
- Log4j를 SLF4j로 감싸면 퍼사드가 매개변수를 2개만 지원하기 때문에, 가비지-프리한 방식을 응용하거나 Log4j2 라이브러리를 직접 사용해 코드 베이스를 리팩터링할 필요가 없음
Log4j 2.5와 2.6 비교
- 정확한 마이크로벤치마크가 아닌 로깅 동작을 대략 프로파일링한 것
- cf) https://logging.apache.org/log4j/2.x/manual/garbagefree.html
1) Log4j 2.5 샘플 실행
- 141번 수집 중 평균 중단 시간이 약 7ms, 최대 중단 시간이 52ms로 GC 사이클 정보가 표시돼 있음
2) Log4j 2.6 샘플 실행
- 같은 기간 동안 GC 사이클이 발생하지 않았음
- 특징
- Log4j 2.6에서 성능이 향상된 비결은, 각 로그 메시지마다 임시 객체를 생성했던 로직을 객체를 재사용하는 방향으로 수정한 것 (오브젝트 풀 패턴 적용)
- Log4j 2.6은 ThreadLocal 필드를 이용해 스트림 -> 바이트 변환 시 버퍼를 재사용하는 식으로 객체를 재사용함
- 단점
- ThreadLocal 객체는 웹 컨테이너에서 문제가 될 수 있음
- ThreadLocal 객체 : Java에서 각 스레드가 독립적으로 값을 저장하고 관리할 수 있게 함
- 문제점
- 스레드 풀의 사용
- 웹 컨테이너는 스레드 풀을 사용해 클라이언트 요청을 처리하고, 요청이 올 때마다 스레드 풀의 스레드를 재사용함
- ThreadLocal 객체 사용 시 해당 스레드에 저장된 데이터가 명시적으로 제거되지 않는 한, 새로운 요청이 이전 요청의 데이터를 볼 수 있게 됨
- 메모리 누수
- ThreadLocal에 저장된 객체는 명시적으로 제거되지 않으면 스레드가 생존하는 동안 메모리에서 제거되지 않음
- 스레드 풀의 사용
- 특히, 웹 어플리케이션과 웹 컨테이너 사이에 로드/언로드하는 시점이 문제가 됨
- Log4j 2.6은 웹 컨테이너 내부에서 실행 시 ThreadLocal을 안 쓰지만, 성능 향상을 도모하고자 일부 공유된/캐시된 구조체를 사용함
- ThreadLocal 객체는 웹 컨테이너에서 문제가 될 수 있음
14.3 리얼 로직 라이브러리를 이용해 지연 줄이기
- 리얼 로직(Real Logic)은 저수준 세부의 이해가 고성능 설계에 영향을 미친다는 기계 공감 접근 방식을 주장한 마틴 톰슨이 설립한 영국 회사
- 리얼 로직 깃허브 페이지 에는 다양한 유명 오픈소스 프로젝트가 존재함
- 아그로나(agrona) : 자바용 고성능 자료구조 및 유틸리티 메서드
- 단순 바이너리 인코딩(simple-binary-encoding) : 고성능 메시지 코덱(codec)
- 에어론(aeron) : 효율적/안정적인 UDP 유니캐스트, UDP 멀티캐스트, IPC 메시지 전송
- 아티로(artio) : 탄력적인 고성능 FIX 게이트웨이
- 이 책에서 해당 부분을 언급한 이유는 성능을 최대한 얻어내고자 라이브러리가 이 정도로 세부 수준까지 파고들었다~ 기존 자바의 성능보다 개선된 형태의 구현이 필요할 때 잘 구현된 오픈소스 프로젝트를 먼저 찾아보자~ 를 말하고 싶어서 인 듯함
14.3.1 아그로나
Buffer
- 기존 자바 버퍼의 문제점
- 자바에는 다이렉트/논다이렉트 버퍼를 추상화한 ByteBuffer 클래스가 있음
- 다이렉트 버퍼는 자바 힙 밖에 있기 때문에 온-힙(on-heap) 버퍼(논다이렉트 버퍼)보다 할당/해제율은 낮은 편임
- 다이렉트 버퍼의 장점은 중간 단계의 매핑 없이 직접 구조체에 명령어를 실행하는 것
- ByteBuffer는 일반화한 유스케이스가 가장 큰 문제로, 버퍼 타입별로 최적화를 적용할 수 없음
- 아토믹 연산을 지원하지 않아 생산자/소비자 방식의 버퍼 구축 시 제약 발생
- 매번 다른 구조체를 감쌀 때마다 하부 버퍼를 새로 할당해야 함
- 아그로나 방식
- 복사를 지양하며 저마다 독특한 특성을 지닌 버퍼 4가지 지원함
- 각 버퍼 객체마다 있음 직한 상호작용을 정의/제어할 수 있음
- DirectBuffer 인터페이스 : 버퍼에서 읽기만 가능하며 최상위 상속 계층에 위치
- MutableDirectBuffer 인터페이스 : DirectBuffer를 상속하며 버퍼 쓰기도 가능
- AtomicBuffer 인터페이스 : MutableDirectBuffer를 상속하며 메모리 액세스 순서까지 보장
- UnsafeBuffer 클래스 : Unsafe를 이용해 AtomicBuffer를 구현한 클래스
List, Map, Set
- 아그로나 ArrayListUtil을 이용하며 리스트 순서는 안 맞지만 ArrayList에서 신속하게 원소를 제거할 수 있음
- 아그로나 Map, Set 구현체는 키/값을 해시 테이블 자료 구조에 나란히 저장함
- 키가 충돌하며 다음 값은 해시 테이블의 해당 위치 바로 다음에 저장됨. 동일한 캐시 라인에 있는 기본형 매핑을 재빠르게 액세스할 때 적합한 자료구조임
Queue
- 아그로나의 동시성 패키지에 있음
- java.util.Queue 인터페이스를 준수하므로 표준 큐 구현체 대신 쓸 수 있고, 순차 처리용 컨테이너 지원 기능이 부가된 org.agrona.concurrent.Pipe 인터페이스도 함께 구현되어 있음
- Pipe는 원소를 카운팅하고, 수용 가능한 최대 원소 개수를 반환하고, 원소를 비우는 작업을 지원하므로 큐를 소비하는 코드와 원활하게 상호작용할 수 있음
- 큐는 모두 락-프리하고 Unsafe를 사용하므로 저지연 시스템에 적합함
- 큐 자료구조 구현할 때 락-프리 메커니즘과 Unsafe 클래스를 사용해 높은 성능을 달성함
- 구현체는 아래 3가지. 이런 식으로 구현체를 분리하면 필요할 때에만 코드에서 조정하며 쓸 수 있음
1) OneToOneConcurrentArrayQueue
- 하나의 생산자, 하나의 소비자는, '유일한 동시 액세스는 생산자, 소비자가 자료구조에 동시 액세스할 때에만 발생한다'는 것을 가정함
- 즉, 생산자와 소비자가 1개씩이므로 한번에 하나의 스레드에 의해서만 큐의 헤드, 테일의 위치가 업데이트됨
- 헤드는 큐에서 poll() or drain() 할 때에만, 테일은 put() 할 때에만 업데이트 할 수 있음
- 이 모드를 선택하면 아래 2가지 큐에서 꼭 필요한, 부수적인 조정 체크를 하느라 쓸데없이 성능 누수를 유발할 일이 없음
2) ManyToOneConcurrentArrayQueue
- 다수의 생산자, 하나의 소비자
- 생산자가 다수일 경우, 테일의 위치를 업데이트할 때 부가적인 제어 로직이 필요함 (다른 생산자가 업데이트했을 수도 있으므로)
- while 루프에서 Unsafe.compareAndSwapLong을 사용하면 테일이 업데이트될 때까지 큐 테일을 안전하게, 락-프리하게 업데이트할 수 있음
- 소비자는 하나이므로 소비자 쪽에는 이런 경합이 없음
3) ManyToManyConcurrentArrayQueue
- 생산자, 소비자가 모두 다수인 경우
- 이 정도 수준으로 조정/제어하려면 CAS 연산이 성공할 때까지 compareAndSwap을 감싼 while 루프가 필요함
- 셋 중 조정 과정이 가장 복잡하기 때문에 그만큼 안전이 보장돼야 할 경우에만 사용하는게 좋음
RingBuffer
- 프로세스 간 통신용 바이너리 인코딩 메시지를 교환하는 인터페이스
- DirectBuffer를 이용해 메시지 오프-힙(off-heap) 저장소를 관리함
- 아그로나에 내장된 링 버퍼 구현체는 OneToOneRingBuffer, ManyToOneRingBuffer 2가지
- 쓰기 작업은 소스 버퍼를 전달받아 그 메시지를 별도의 버퍼에 써넣고, 읽기 작업은 메시지 핸들러의 onMessage() 메서드로 콜백됨
- ManyToOneRingBuffer에서 여러 생산자가 쓰기하고 있는 상황에서 Unsafe.storeFence() 메서드를 호출하면 수동으로 메모리 동기화를 통제할 수 있음
14.3.2 단순 바이너리 인코딩 (SBE)
- 저지연 성능에 알맞게 개발된 바이너리 인코딩 방식으로, 금융 시스템에서 쓰이는 FIX 프로토콜에 특화되어 있음
- SBE는 메시지를 인코딩/디코딩하는 어플리케이션 계층의 관심사이고, 버퍼는 아그로나에서 빌려 사용함
- SBE는 GC를 유발하지 않고 메모리 액세스 같은 문제를 최적화하지 않고도 효율적인 자료구조를 통해 저지연 메시지를 전달할 수 있어 고빈도 거래 환경에 맞춤 설계되어 있음
SBE의 설계 원리와 저지연 시스템 설계와의 연관성
https://github.com/real-logic/simple-binary-encoding/wiki/Design-Principles
1) 카피-프리
- 카피-프리(복사 하지 않는) 기술은 중간 버퍼를 쓰지 않고 하부 버퍼에 바로 접근해 메시지를 인코딩/디코딩하도록 설계됨
- 버퍼에 집어넣지 못할 정도로 큰 메시지는 지원할 수 없어 메시지를 나누어 다시 조립하는 프로토콜을 구축해야 함
2) 네이티브 타입 매핑
- 어셈블리 명령어에 네이티브하게 매핑되는 타입도 카피-프리하게 작업하는 게 좋음
- 어셈블리 연산을 잘 선택해 매핑하면 필드 검색 성능이 향상됨
3) 정상 상태 할당
- 하부 버퍼에 플라이웨이트 패턴을 사용하여 할당-프리(allocation-free)함
- 객체들이 공유할 수 있는 데이터를 사용해 불필요한 메모리 할당을 줄이거나 없애 메모리 효율성을 높였음
3) 스트리밍/단어 정렬 액세스
- 자바 배열은 보통 레퍼런스 배열 형태로 메모리 순차 읽기가 불가능함
- SBE는 메시지를 진행 방향으로 인코딩/디코딩하도록 설계되어 있어 정확하게 단어를 정렬할 수 있는 틀이 잡혀 있음
14.3.3 에어론
- SBE와 아그로나에 기반한 툴
- 자바 및 C++ 용도로 개발된, UDP(User Datagram Protocol) 유니캐스트, 멀티캐스트, IPC(프로세스간 통신) 메시지를 전송하는 수단
- 어플리케이션이 같은 머신에서, 또는 네트워크를 넘나들며 IPC를 통해 서로 소통할 수 있게 해주는 것들을 망라한, 일반적인 메시지 프로토콜
- 지연을 예측 가능한 방향으로 가장 낮게 유지하는 것을 목표로 함
- 처음부터 컴포넌트 라이브러리 형태로 제작되어 저수준 자료구조만 필요한 경우 다른 디펜던시를 갖다 쓰지 않고 아그로나에 탑재된 기능을 그대로 이용하면 됨
에어론의 구성 컴포넌트
- 미디어(media) : 에어론이 통신하는 매개체 (ex: UDP, IPC 등)
- 미디어 드라이버(media driver) : 미디어와 에어론 사이의 연결 통로. 원하는 전송 구성을 세팅해 통신할 수 있음
- 감독자(conductor) : 전체 흐름을 관장함
- 버퍼를 설정하거나 새 구독자/발행자 요청을 리스닝, NAK(부정 응답 문자)를 감지해 재전송 준비하기 등의 일 수행
- 송신자/수신자는 바이트를 주고받는 일만 집중해서 최대 처리율을 높일 수 있음
- 송신자(sender) : 생산자로부터 데이터를 읽어 소켓으로 전송함
- 수신자(receiver) : 소켓에서 데이터를 읽고 해당 채널/세션으로 내보냄
14.3.4 에어론의 설계 개념
전송 요건
- 에어론은 OSI4 전송 계층에서 메시징 하므로 반드시 준수해야 할 요건들이 있음
- 정렬 : 전송 계층은 저수준에서 순서 없이 무작위로 패킷을 받기 때문에 순서가 뒤섞인 메시지는 다시 정렬해야 함
- 신뢰성 : 유실된 데이터는 재전송을 요청해야 함
- 배압 : 부하가 높아지면 구독자는 압박을 받음. 따라서 흐름 제어 및 배압 측정 서비스가 지원돼야 함
- 혼잡 : 네트워크 포화 시 혼잡이 일어날 수 있고, 에어론은 혼잡 제어 기능을 옵션으로 제공함
- 다중화 : 전체 성능 떨어뜨리지 않고 단일 채널에서 다중 정보 스트림을 처리할 수 있어야 함
지연 및 어플리케이션 원칙
- 에어론은 8개 설계 원칙을 따름
- 정상 상태에서 가비지-프리 실현
- GC 중단 방지를 위해 정상 상태를 보장하도록 설계되어 있어 이와 동일한 설계 결정을 따르는 어플리케이션에 에어론을 포함시킬 수 있음
- 메시지 경로에 스마트 배칭 적용
- 스마트 배칭은 수신 메시지가 폭주하는 상황을 감안해 설계된 알고리즘
- 메시지 처리 도중 다른 메시지가 들어오면 용량이 허락하는 한도 내에서 같은 네트워크 패킷 안에 집어넣을 수 있음
- 적절한 자료구조를 이용해 생산자가 공유 리소스에 쓰는 걸 지연시키지 않고 이렇게 배칭을 수행함
- 메시지 경로의 락-프리 알고리즘
- 메시지 경로의 논블로킹 I/O
- 메시지 경로의 비예외 케이스
- 특이 케이스 뿐 아니라 기본 시나리오의 실행 속도 신경써야 함
- 단일 출력기 원칙 적용
- 다중 출력기는 큐 액세스를 고도로 정교하게 제어/조정하는 작업이 수반됨
- 단일 출력기를 쓰면 이 과정이 단순해지고 쓰기 경합이 줄어듦
- 공유 안하는 상태가 더 좋다
- 단일 출력기는 큐의 경합 문제를 해결하지만 가변 데이터를 공유해야 하는 문제를 유발하기 때문에 데이터는 프라이빗 또는 로컬 상태를 유지해야 함
- 쓸데없이 데이터를 복사하지 말라
- 복사를 줄여 우발적인 메모리 변경을 방지해야 함
- 정상 상태에서 가비지-프리 실현
내부 작동 원리
- 에어론은 단순한 방식으로 자료구조에 메시지 시퀀스를 생성함
- 파일 개념을 폭넓게 활용함
- 파일은 서로 연관된 프로세스끼리 공유 가능한 매개체
- 리눅스의 메모리 맵 파일 아키텍처 덕분에 모든 파일 호출은 실제 물리적인 파일을 쓰는 게 아닌 메모리로 전달됨
- 테일 포인터는 최종 메시지가 쓰인 지점을 찾아가는 용도로 사용됨
- 위의 사진은 현재 파일에 헤더를 지닌 단일 메시지를 쓰는 장면
- 이벤트 시퀀싱 과정
- 테일 포인터는 파일 내부에 메시지 공간을 예약함
- 테일 증분 작업은 아토믹하므로 출력기는 자기 영역의 처음과 끝이 어디인지 알고 있음
- 덕분에 다중 출력기가 락-프리하게 파일을 업데이트할 수 있고 파일 쓰기 프로토콜을 효율적으로 작동시킬 수 있음
14.4 마치며
- 로깅
- 로깅은 필수 요소이지만 어떤 종류의 로거를 사용할지에 따라 전체 어플리케이션 성능이 영향을 받을 수 있음
- 로깅은 어플리케이션을 전체적으로 바라보며 스레드 사용, 가비지 수집 등의 다른 JVM 서브시스템에 미칠 영향까지 감안하여 설계해야 함
- 메시징
- 저지연 시스템에서는 가장 저수준의 큐부터 고수준의 소프트웨어 스택에 이르기까지 그 목표와 원리가 일관되게 적용돼야 함
- 저지연 시스템을 새로 구축할 때, 잘 만들어진 오픈소스 프로젝트를 고려해보자
- 자바/JVM은 추상화가 잘된 언어라서 저지연, 고처리율 어플리케이션을 개발하기 위해 관리를 잘 해야 하고 문제를 우회해야 함
- 하드웨어, JVM 성능, 저수준의 관심사도 반드시 고려해야 함
'DevBook > Optimizing Java' 카테고리의 다른 글
CH10. JIT 컴파일의 세계로 (0) 2024.06.24 CH2. JVM 이야기 (0) 2024.04.14 CH1 성능과 최적화 (0) 2024.04.09