Published on

Optimizing Java 챕터14

Authors
  • avatar
    Name
    ywj9811
    Twitter

들어가며

저지연, 고성능 시스템에서 핵심적인 고려 사항 두 가지는 로깅과 메시징이다.

지연이 주 관심사인 개발자에게 로깅은 더 각별한 의미가 있을 것이다. 또한 메시징 역시 저지연 시스템에서 중요한 요소로 이에 대해 살펴보며 마지막에는 최신 메시징 기술 또한 살펴보도록 하자.

로깅

제품급 로깅 시스템 선정 시 바람직하지 않은 안티패턴을 살펴보자.

10년짜리 로거

누군가 이미 로거를 잘 설정해놨는데, 왜 새로 만들어야 하지?

프로젝트 전체 로거

누군가 프로젝트 각 파트마다 따로 로거를 재구성하지 않아도 되게끔 로거를 감싸놓았다.

전사 로거

누군가 전사적으로 사용 가능한 로거를 만들었다.


여기서 누군가가 고의로 나중에 물의를 빚으려 하지는 않았을 것이다.

하지만 그럼에도 불구하고 로거는 실제로 모든 어플리케이션에 가장 중요한 부분이다.

따라서 이를 그저 시스템에 있는 여느 컴포넌트처럼 로거를 단순히 비용으로 취급해서는 곤란하다.

로깅 벤치마크

다양한 로그 패턴을 이용하여 가장 많이 사용하는 세 로거(Logback, Log4j, java.util.logging)의 성능을 공정하게 비교하는 벤치 마크를 살펴보자.

이를 위해서 마이크로벤치마크 방식으로 접근하였는데, 이유는 같은 코드를 여러 가지 어플리케이션에서 실행할 경우 원하는 정도의 설정을 이용해서는 유의미하게 비교할 수 있는 결과를 얻는 일은 지극히 어렵다.

따라서 이 경우에는 마이크로벤치마킹을 하면 그 코드가 얼마나 성능을 내는지 추정해볼 수 있다.

로깅 없음

이는 현재 로거가 켜져 있고 어떤 한계치 이하로 메시지가 로깅되고 있는 상태에서 무동작 로그의 비용을 측정하는 벤치마크 테스트이다. 즉, 실험 결과를 비교할 대조군에 해당한다.

위에서 언급한 세가지 로거를 측정하여 비교한 결과를 살펴보자.

로깅 없음Logbackjava.util.loggingLog4j
자바 유틸 로거158.051
(+-0.762)42,404.202
(+-541.229)86,054.783
(+-541.229)74,794.026
(+-2,244.146)
Log4j138.495
(+-94.490)8,056.299
(+-447.815)32,755.168
(+-27.054)5,323.127
(+-47.160)
Logback214.032
(+-2.260)5,507.546
(+-258.971)27,420.108
(+-37,054)3,501.858
(+-47.873)

자바 유틸 로거는 건당 42,404ns ~ 86,054ns 속도로 로그를 남긴다.

이는 java.util.logging을 사용할 때 가장 성능이 나쁜데 Log4j의 2.5배 이상 성능이 나쁘다.

Logback이 전반적으로 성능이 가장 우수하고 로깅 포맷이 Log4j일 때 가장 좋다는 것을 살펴볼 수 있다.

(이 경우 아이맥으로 테스트한 결과임)

로깅 없음Logbackjava.util.loggingLog4j
자바 유틸 로거1,376.597
(+-106.613)54,658.098
(+-516.184)14,4661.388
(+-10,333.854)109,895.219
(+-5,457.031)
Log4j1,699.774
(+-111.222)5,835.090
(+-27.592)34,605.770
(+-38.816)5,809.098
(+-27.792)
Logback2,440.952
(+-159.290)4,786.511
(+-29.526)30,550.569
(+-39.951)5,485.938
(+-38.674)

Logback이 Log4j보다 약간 더 빠르게 나왔다. 좀 전의 아이맥 결과와 전반적으로 패턴은 비슷하지만, 일부 눈에 띄는 대목이 있었다.

전체적으로 AWS가 실행 속도는 더 빨랐는데, 이는 아이맥의 절전 기능이나 포착되지 않은 다른 요인들이 개입한 것으로 추측된다.

(EC2 t2.2xlarge 인스턴스로 테스트한 결과임)

로거 결과

실행 결과는 환경과 설정값에 따라 다양하게 나왔지만, 실행 시간 측면에서 Logback 성능이 대체적으로 가장 좋았고 자바 유틸 로거가 제일 나빴다.그리고 Log4j는 전반적으로 가장 일관된 결과를 보여주었다는 것을 알 수 있다.

물론 이외에도 어플리케이션 전체에 미치는 영향도 및 가비지 또한 잘 따져봐야 하는 점이 남아있기도 하다.

성능에 영향이 적은 로거 설계하기

이에 대해서는 Log4j의 2.6 버전을 살펴볼 수 있다.

Log4j 2.6을 실행하니 놀랍게도 이전과 같은 기간 동안 GC 사이클이 발생하지 않은 것을 볼 수 있다.

Log4j 2.6에서 성능이 향상된 비결은, 각 로그 메시지마다 임시 객체를 생성했던 로직을 객체를 재사용하는 방식으로 수정한 것이다.

객체 풀 패턴(Object poll pattern) : 필요한 객체를 바로 생성하지 않고 풀에 요청해서 반환받는 방식으로 작업을 수행하는 패턴으로, JDBC Connection Pool, WAS Thread Pool 모두 이 패턴에 따라 구현한 결과물이다.

Log4j 2.6은 ThreadLocal 필드를 이용해 스트링에서 바이트 변환 시 버퍼를 재사용하는 식으로 객체를 재사용한다.

ThreadLocal 객체는 웹 컨테이너에서 문제가 될 수 있는데, 특히 웹 어플리케이션과 웹 컨테이너 사이에 로드/언로드 하는 시점이 문제이다. Log4j 2.6은 웹 컨테이너 내부에서 실행시 ThreadLocal을 사용하지 않지만, 성능 향상을 도모하고자 일부 공유된/캐시된 구조체를 사용한다.

따라서 만약 구버전 Log4j를 사용한다면 2.6이상으로 업데이트를 하는 것을 추천하며, 만약 Log4j를 SLF4j로 감싸면 매개변수를 2개만 지원하기 때문에, 가비지 프리한 방식을 응용하거나 Log4j2 라이브러리를 직접 사용해서 코드 베이스를 리팩토링할 필요가 없다.

리얼 로직 라이브러리를 이용해 지연 줄이기

리얼 로직은 저수준 세부의 이해가 고성능 설계에 영향을 미친다는 기계 공감 접근 방식을 주장한 마틴 톰슨이 설립한 영국 회사이다.

아티오

탄력적인 고성능 FIX 게이트웨이이다.

아그로나

진정한 저지연 어플리케이션용 라이브러리 세트를 제공하고 있다.

버퍼

ByteBuffer의 가장 큰 문제점인 일반화한 유스케이스에 대한 해결책으로 독특한 특성을 지닌 버퍼 4개를 지원한다.

  • DirectBuffer 인터페이스 : 버퍼에서 읽기만 가능하며 최상위 상속 계층에 위치
  • MutableDirectBuffer 인터페이스 : DirectBuffer를 상속하며 버퍼 쓰기도 가능
  • AtomicBuffer 인터페이스 : MutableDirectBuffer를 상속하며 메모리 액세스 순서까지 보장
  • UnsafeBuffer 클래스 : Unsafe를 이용해 AtomicBuffer를 구현한 클래스

리스트, 맵, 세트

표준 컬렉션에서 기본형 아닌 객체를 사용하라고 강요하는 부작용으로 객체 자체의 크기 오버헤드도 있지만 Autoboxing과 Unboxing도 발생한다.

아그로나는 ArrayListUtil을 제공하는데 이는 리스트 순서는 안맞지만 ArrayList에서 신속하게 원소를 제거할 수 있다.

아그로나의 맵,세트 구현체는 키/값을 해시 테이블 자료 구조에 나란히 저장하며 키가 충돌하면 다음 값은 해시 테이블의 해당 위치 바로 다음에 저장된다. 따라서 동일한 캐시 라인에 있는 기본형 매핑을 재빠르게 액세스할 때 딱 맞는 자료구조이다.

이는 표준 인터페이스를 준수하므로 표준 큐 구현체 대신 쓸 수 있고, 순차 처리용 컨테이너 지원 기능이 부가된 org.agrona.concurrent.Pipe 인터페이스 또한 함께 구현되어 있어서 큐를 소비하는 코드와 원활하게 상호작용 할 수 있다.

또한 큐는 모두 락-프리하고 Unsafe를 사용하므로 저지연 시스템에 안성맞춤이다.

또한 org.agrona.concurrent.AbstractConcurrentArrayQueue 는 서로 다른 생산자/소비자 모델을 제공하는 일련의 큐를 1차적으로 수행하는 추상 클래스로 생상자, 소비자가 큐를 동시에 액세스할 경우 잘못도니 공유를 방지하고자 큐 메모리를 영리하게 배치한다.

생산자, 소비자를 각자 별개의 캐시 라인에 놓아두면 저지연,고처리율 상황에서 만족할 성능을 기대할 수 있다.

이어서 구현체 세가지를 살펴보자.

OneToOneConcurrentArrayQueue

하나의 생산자, 하나의 소비자는, ‘유일한 동시 액세스는 생산자, 소비자가 자료 구조에 동시 액세스할 때만 발생한다’는 장책을 선택하는 것과 같다.

여기서 중요한 것은 한번에 하나의 스레드에 의해서만 업데이트되는 헤드, 테일의 위치이다.

헤드는 큐에서 poll() , drain() 할 때에만, 테일은 put() 할 때만 업데이트 할 수 있다. 따라서 부수적인 조정 체크를 하느라 쓸데없이 성능 누수를 유발할 일이 없다.

ManyToManyConcurrentArrayQueue

소비자가 하나밖에 없으니 문제 없지만, 생산자가 다수일 경우에는 테일 위치를 업데이트 할 때(다른 생산자가 업데이트 했을 수 있으니) 부가적인 제어 로직이 필요하다.

while 루프에서 Unsafe.compareAndSwapLong 을 사용하면 꼬리가 업데이트될 때 까지 큐 테일을 안전하게, 락-프리하게 업데이트 할 수 있다.

ManyToOneConcurrentArrayQueue

생산자, 소비자가 모두 다수일 경우, 머리/테일 양쪽을 업데이트 해야한다.

따라서 이정도 수준으로 조정/제어 하려면 compareAndSwap을 감싼 while루프가 필요하다.

이는 과정이 가장 복잡하기 때문에 그만큼 안전이 보장되어야 할 경우에만 사용해야 한다.

링 버퍼

아그로나가 제공하는 org.agrona.concurrent.RingBuffer 는 프로세스 간 통신용 바이너리 인코딩 메시지를 교환하는 인터페이스이다.

이는 DirectBuffer를 이용해 메시지 오프-힙 저장소를 관리한다.

이에 대한 구현체는 OneToOneRingBuffer, ManyToOneRingBuffer 이렇게 두가지이다.

쓰기 작업은 소스 버퍼를 전달 받아 그 메시지를 별도의 버퍼에 써 넣는 반면, 읽기 작업은 메시지 핸들러의 onMessage() 메소드로 콜백된다. 또한 ManyToOneRingBuffer 에서 여러 생산자가 동시 쓰기를 하고 있는 상황에서 Unsafe.storeFence() 를 호출하면 수동으로 메모리 동기화를 통제할 수 있다.

storeFence() 는 ‘펜스를 치기 전의 스토어를, 펜스를 친 이후의 로드 또는 스토어와 순서를 바꾸지 못하게 하는’ 메소드이다.

단순 바이너리 인코딩

단순 바이너리 인코딩(SBE)는 저지연 성능에 알맞게 개발된 바이너리 인코딩 방식으로 금융 시스템에서 쓰이는 FIX프로토콜에 특화되어 있다.

SBE는 GC를 유발하지 않고 메모리 액세스 같은 문제를 최적화하지 않고도 효율적인 자료 구조를 통해 저지연 메시지를 전달할 수 있다.

카피-프리, 네이티브 타입 매핑

복사는 비용이 든다.

하지만 카피-프리의 기술은 중간 버퍼를 쓰지 않고 메시지를 인코딩/디코딩 하도록 설계되었다.

어셈블리 명령어에 네이티브하게 매핑되는 타입 역시 카피-프리하게 작업하는 것이 좋다.

이를 잘 선택해 매핑하면 필드 검색 성능이 현저히 향상될 것이다.

정상 상태 할당

자바의 객체 할당 방식은 저지연 어플리케이션 설계에서 문제가 된다.

이는 CPU를 사용하는 것 뿐만 아니고 사용 이후 객체를 지우는 것 또한 문제가 된다.

GC는 STW(즉, 중단)을 자주 일으키며 거의 동시에 작동하는 고급 수집기도 마찬가지다. 중단 시간 수치를 제한하면 GC 프로세스는 성능 모델에 유의미한 편차를 가져온다.

SBE는 하부 버퍼에 플라이트웨이트 패턴을 사용하므로 할당-프리 하다.

스트리밍/단어 정렬 액세스

SBE는 메시지를 진행 방향으로 인코딩/디코딩하도록 설계되어 있어서 정확하게 단어를 정렬할 수 있는 틀이 잡혀있다.

정렬이 엉망이라면 프로세서 수준에서 성능 문제가 붉어질 수 있다.

에어론

이는 SBE와 아그로나에 기반한 툴로 UDP 유니캐스트, 멀티캐스트, IPC 메시지를 전송하는 수단이다.

기본적으로 이는 어플리케이션이 같은 머신에서 또는 네트워크를 넘나들며 IPC를 통해 서로 통신할 수 있게 해주는 것들을 망라한, 일반적인 메시지 프로토콜로 취고의 처리율을 지향하는 에어론은 지연을 예측 가능한 방향으로 가장 낮게 유지하는 것을 목표로 한다.

발행자

우선, 구성 컴포넌트를 고수준에서 살펴보도록 하자.

Untitled

  • 미디어
    • 에어론이 통신하는 매개체이다. (UDP, IPC, 인피티니밴드, 혹은 기타 매체) → 클라이언트로서 에어론이 이 매체들을 모두 추상화 했다.
  • 미디어 드라이버
    • 미디어와 에어론 사이에 연결 통로로 원하는 전송 구성을 세팅해 통신할 수 있다.
  • 감독자(Conductor)
    • 전체 흐름을 관장하는 것으로 버퍼를 설정하거나 새 구독자/발행자 요청을 리스닝하며 재전송을 하기도 한다.
  • 송신자
    • 생산자로부터 데이터를 읽어 소켓으로 전송한다.
  • 수신자
    • 소켓에서 데이터를 읽고 해당 채널/세션으로 내보낸다.

이때 미디어 드라이버는 다양하게 준비되어 있어서 하드웨어 배포 환경에 맞게 최적화할 수 있다.

MediaDriver.Context 는 미디어 드라이버별 최적 설정값이 보관된 구성 클래스이다.

동작 방식은 다음과 같다.

  1. 발행자나 구독자, 어느 한가지로 미디어 드라이버에 접속한다.
  2. publication에 접속해서 주어진 채널/스트림으로 통신한다.
  3. 메시지를 보내기 위해 발행자에게 버퍼를 제공하고 그 결과 메시지 상태값을 반환 받는다.
  4. 구독자는 동일한 미디어 드라이버를 리스닝하고 있는다.

위의 방식은 구독자와 발행자 모두 비슷하게 동작한다.

에어론의 설계 개념

전송 요건

에어론은 OSI 4계층에서 메시징을 하므로 반드시 준수해야 할 요건들이 있다.

  • 정렬
    • 무작위로 패킷을 받기 때문에 순서가 뒤섞인 메시지는 다시 정렬해야 한다.
  • 신뢰성
    • 데이터가 누락되면 재전송을 요청해야 한다.
  • 배압
    • 부하가 높아지면 구독자는 압박을 받기 때문에 흐름 제어 및 배압 측정 서비스가 지원되어야 한다.
  • 혼잡
    • 네트워크가 포화되면 혼잡이 일어날 수 있는데 저지연 어플리케이션에서는 혼잡이 주 관심사가 되면 안된다. 따라서 에어론은 혼잡 제어 기능을 옵션으로 제공한다.
  • 다중화
    • 전체 성능을 떨어뜨리지 않고 단일 채널에서 다중 정보 스트림을 처리할 수 있어야 한다.

지연 및 어플리케이션 원칙

  • 정상 상태에서 가비지-프리 실현
    • GC 중단은 지연과 불가측성을 일으키는 주원인이다. 따라서 이를 방지해야 한다.
  • 메시지 경로에 스마트 배칭 적용
    • 수신 메시지가 폭주하는 상황을 감안하여 설계된 알고리즘으로 적절한 자료 구조를 이용해 생산자가 공유 리소스에 쓰는 것을 지연시키지 않고도 배칭을 수행할 수 있다.
  • 메시지 경로의 락-프리 알고리즘
    • 락킹은 스레드를 블로킹하는 경합을 일으키며, 파킹이나 락에서 깨어나는 과정 또한 어플리케이션을 느리게 만들기에 락을 없애야 한다.
  • 메시지 경로의 논블로킹 I/O
    • 블로킹 I/O는 스레드를 블로킹하며 깨우는 비용도 무시할 수 없으니 논블로킹 I/O로 진행해야 한다.
  • 메시지 경로의 비예외 케이스
    • 소수 특이 케이스가 아닌 기본 시나리오를 처리하는 것에 시간을 투자해야 한다.
  • 단일 출력기 원칙을 적용
    • 다중 출력기는 큐 액세스를 고도로 정교하게 제어/조정하는 작업이 수반된다. 따라서 단일 출력기를 사용하여 이 과정을 단순화 시키고 쓰기 경합 또한 줄인다.
  • 공유 안하는 상태가 더 좋다
    • 단일 출력기는 큐의 경합 문제를 해결하지만, 가변 데이터를 공유해야 하는 문제를 유발한다. 따라서 프라이빗 혹은 로컬 상태를 유지하는 것은 모든 소프트웨어 설계 계층에서 매우 바람직하고 데이터 모델이 엄청나게 단순화될 수 있다.
  • 쓸데 없이 데이터를 복사하지 말라
    • 데이터 복사 비용은 비싸지 않지만, 문제가 발생할 수 있으니 가급적 줄이면 우발적인 메모리 변경을 방지할 수 있다.

내부 작동 원리

에어론은 기존 프로토콜과 다르게 최대한 깔끔하고 단순한 방식으로 자료 구조에 메시지 시퀀스를 생성한다. 에어로는 파일 개념을 폭넓게 활용하는데, 파일은 서로 연관된 프로세스 끼리 공유 가능한 매개체임을 이용하는 것이다.이벤트를 시퀀싱 하는 과정이 아주 흥미로운데, 테일 포인터는 파일 내부에 메시지 공간을 예약하고 테일 증분 작업은 아토믹하므로 출력기는 자기 영역의 처음과 끝이 어디인지 잘 알 수 있다.

덕분에 다중 출력기가 락-프리하게 파일을 업데이트 할 수 있고, 파일 쓰기 프로토콜을 효율적으로 작동시킬 수 있다.

또한 헤더는 제일 마지막으로 아토믹하게 파일에 출력되므로 그 존재 여부로 메시지가 완성 되었다는 것을 알 수 있다.

그럼에도 파일을 놔두기만 한다면 한없이 커질 수 있고 부수적인 문제가 발생할 수 있다.

이에 대해서는 파일을 액티브, 더티, 클린 세가지로 두어 해결한다.

  • 액티브
    • 현재 쓰기 있는 파일
  • 더티
    • 이전에 쓰인 파일
  • 클린
    • 바로 다음에 쓸 파일

큰 파일에 의해 지연되지 않도록 계속 파일을 순환시키는 것이다.

이 외에도 누락된 메시지를 처리하는 매커니즘 또한 굉장히 영리하여 메시지 헤더의 순서를 파악하여 유실된 메시지가 다시 오면 정확한 위치에 삽입할 수 있다.

이러한 로그파일은 속도 및 상태를 유지하는 에어론의 핵심 이론으로 단순하고 우아하게 실행될 수 있도록 설계되었다.