알림: 이 글은 Tuning ZIO for high performance의 한국어 번역본입니다.
안녕하세요, 스튜디오 킹덤에서 서버 소프트웨어 엔지니어로 일하고 있는 삐에르입니다.
Disclaimer
우선, 이 글에서 논의하는 내용이 절대적인 진리는 아니라는 점을 밝힙니다. 무엇을 수행하는 애플리케이션인지에 따라 애플리케이션을 빠르게 만드는 방법도 크게 좌우됩니다. 사용 사례에 따라 ZIO의 오버헤드는 완전히 무시할 수 있는 수준일 수도 있지만, 상당히 클 수도 있습니다. 이를 알 수 있는 유일한 방법은 프로파일러와 함께 부하 테스트를 수행하여 애플리케이션 성능을 분석하고 측정하는 것입니다. 하지만 이 글을 통해 ZIO 앱을 프로덕션에 배포할 때 생각할 법한 몇 가지 흥미로운 점과 고려할 점을 알려드리고자 합니다.
런타임 플래그
우선 쉬운 것부터 시작하겠습니다. ZIO에는 애플리케이션 실행 시 쉽게 켜거나 끌 수 있는 몇 가지 플래그가 있습니다. 이 중 모든 플래그를 상세히 설명하지는 않겠지만, 프로덕션에서 어느 정도 영향을 미칠 수 있는 두 가지 플래그에 주목하고자 합니다.
첫 번째 플래그는 FiberRoots
라는 이름을 가지고 있으며 기본적으로 활성화되어 있습니다. 이 플래그가 활성화되면 루트 fiber가 생성될 때마다(일반적으로 forkDaemon
연산자를 사용할 때) 이 fiber는 모든 루트 fiber를 추적하는 목록에 추가됩니다. 이 메커니즘은 두 가지 목적을 가지고 있습니다:
- Fiber 덤프를 수행할 수 있습니다. 스레드 덤프와 마찬가지로 애플리케이션 프로세스에
kill -s INFO
를 보내면 루트 fiber의 전체 목록, 상태, 현재 수행 중인 작업(실행 중인 함수)을 출력합니다. - 애플리케이션을 중지할 때 ZIO는 모든 루트 fiber를 중단하려고 시도합니다. 이를 추적하지 않으면 메인 fiber에서 생성된 "자식" fiber만 중단되지만,
forkDaemon
을 통해 생성된 다른 루트 fiber는 깨끗하게 종료되지 않을 것입니다.
그러나 많은 루트 fiber를 생성하는 경우 이러한 추적에는 비용이 발생할 수 있습니다. 가장 극단적인 사례로, 아무 작업도 하지 않는 루트 fiber만 포크하는 경우 FiberRoots
플래그를 비활성화하면 성능이 2.5배 향상됩니다(벤치마크 결과는 여기 참조). 비록 코드에서 forkDaemon
을 많이 사용하지 않더라도 사용하는 라이브러리에 의해 호출될 수 있습니다. 예를 들어, zio-grpc는 모든 gRPC 요청에서 forkDaemon
을 호출합니다. 일부 ZIO 연산자도 내부적으로 이를 사용합니다.
개인적으로 로컬 환경에서는 가끔 fiber 덤프를 사용하지만, 프로덕션에서는 정말로 필요하지 않기 때문에 불필요한 추적을 피하기 위해 해당 플래그를 비활성화했습니다. 이를 비활성화하려면 ZIO 앱의 bootstrap
함수에 다음 코드를 추가하면 됩니다:
val bootstrap = Runtime.disableFlags(RuntimeFlag.FiberRoots)
기본적으로 비활성화되어 있는 또 다른 흥미로운 플래그가 있습니다: RuntimeMetrics
. 이 플래그를 활성화하면 몇몇 성능 메트릭이 직접 ZIO 메트릭으로 노출됩니다. Prometheus, Datadog 등 원하는 백엔드로 메트릭을 보내고 있다면, 이들은 자동으로 송출될 것입니다. 수집되는 메트릭은 다음과 같습니다:
zio_fiber_failure_causes
는 fiber 실패의 개략적인 원인을 수집합니다.zio_fiber_fork_locations
는 코드에서 fiber를 가장 많이 포크하는 위치를 알려줍니다.zio_fiber_started
,zio_fiber_successes
,zio_fiber_failures
는 시작된 fiber의 수와 성공하거나 실패한 fiber의 수를 계산합니다.zio_fiber_lifetimes
는 fiber가 얼마나 오래 실행되는지를 측정합니다.
프로덕션 시스템을 모니터링할 때 이러한 메트릭은 매우 유용할 수 있으므로 런타임 오버헤드를 감안하더라도 이를 고려해볼만 합니다. 그 오버헤드는 매우 작겠지만, 적용하기 전에 항상 테스트하고 측정해야 한다는 점을 명심해주세요.
이를 활성화하려면 bootstrap
함수에 다음 코드를 추가하면 됩니다:
val bootstrap = Runtime.enableRuntimeMetrics
병렬 처리
이제 ZIO를 사용할 때 흔히 발생하는 문제인 무제한 병렬 처리에 대해 이야기해보겠습니다. ZIO의 일반적인 연산자 중 하나는 ZIO.foreach
로, 컬렉션의 각 항목에 대해 효과를 실행할 수 있게 해줍니다. 결과를 수집할 필요가 없을 때는(예: 효과가 Unit
을 반환하는 경우) 더 빠른 ZIO.foreachDiscard
도 있습니다. 병렬로 작업을 수행하려면 ZIO.foreachPar
및 ZIO.foreachParDiscard
와 같은 병렬 대응 연산자가 있습니다.
하지만 어떤 상황에서는 foreachPar
가 foreach
보다 느릴 수 있습니다. 어떻게 그럴 수 있을까요?
1,000개 요소가 있는 컬렉션에서 foreachPar
를 실행하면 ZIO는 1,000개의 fiber를 생성하여 모두 CPU 시간을 놓고 경쟁하게 됩니다. 각 fiber가 수행하는 작업이 CPU 중심적이라면 코어 수보다 많은 fiber를 생성하는 것은 이점이 없습니다. 반대로, 모든 fiber를 생성하고 연결하는 데 필요한 기계적 오버헤드로 인해 성능 저하가 발생할 것입니다. Fiber가 I/O를 수행하는 경우 한 번에 많은 수를 생성하는 것이 의미가 있을 수 있지만, 다른 제한(예: DB 연결, 네트워크 등)에 부딪힐 가능성이 높으므로 제한을 두는 것이 안전할 수 있습니다.
foreachPar
를 사용할 때 생성되는 fiber 수를 제어하려면 ZIO
의 withParallelism
연산자를 사용할 수 있습니다.
ZIO
.foreachPar(1 to 1000)(_ => doSomething)
.withParallelism(16)
이 예제에서는 16개의 fiber가 생성되어 myList
의 요소를 동시에 처리합니다. doSomething
이 CPU 중심적이고 16개의 코어가 있다고 가정하면, 1,000개의 fiber가 동일한 작업을 수행하는 것보다 훨씬 더 나은 성능을 발휘할 것입니다.
withParallelism
연산자는 그 범위 내에서 실행되는 코드의 병렬 처리 수준을 조정합니다. 여기에는 부모로부터 이 설정을 상속받는 포크된 모든 자식 fiber가 포함됩니다. 따라서 애플리케이션 전반에 걸쳐 합리적인 기본 병렬 처리 수준을 설정하기 위해 메인 함수에서 withParallelism
을 사용할 수 있으며, 필요에 따라 지역적으로 조정할 수 있습니다.
재미있는 사실: 이 글을 작성하면서 foreachPar
의 구현을 확인했는데, 컬렉션 크기보다 높은 숫자 n
으로 withParallelism
을 사용할 때 여전히 n
개의 fiber를 생성하고 있음을 발견했습니다. 이는 불필요한 작업입니다. 컬렉션의 항목 수만큼 fiber를 생성하면 됩니다. 이를 최적화하기 위해 PR을 열었으며, 다음 ZIO의 마이너 릴리스에 반영될 것입니다.
Executor
Fiber를 생성하고 작업을 할당하면 이 작업은 결국 실제 스레드에서 실행됩니다. ZIO에서는 이러한 스레드를 생성하고 작업을 할당하여 CPU 효율성을 최대화하는 논리를 담당하는 것이 executor의 역할입니다. 여기서 선택할 수 있는 다양한 옵션을 살펴보겠습니다.
ZIO 2.1.0부터 기본 executor는 ZScheduler
라고 불리며, 이는 본질적으로 Rust의 Tokio 스케줄러의 포트입니다. 이 글은 알고리즘을 자세히 설명하지만, 간략하게 요약하자면:
- 사용 가능한 코어 수와 동일한 수의 스레드를 생성합니다.
- 각 스레드는 실행할 작업의 로컬 큐를 가지며, 모든 스레드가 공유하는 전역 큐도 존재합니다.
- Fiber가 effect를 포크할 때 새 fiber를 생성하여 현재 스레드의 로컬 큐에 추가합니다. 현재 스레드가 ZIO 스레드가 아닌 경우(예: 외부 스레드에서
unsafe.run
을 호출하는 경우), 전역 큐에 추가합니다. 로컬 큐가 꽉 차면(크기가 제한됨) 작업의 절반을 가져와 전역 큐로 이동합니다. - 그런 다음 슬리핑 중인 스레드가 있는지 확인하고, 찾으면 깨웁니다.
- 깨어나거나 현재 작업을 마친 후 스레드는 다음 작업을 다음과 같은 방식으로 찾습니다:
- 먼저 로컬 큐에서 가져옵니다.
- 로컬 큐가 비어 있으면 전역 큐에서 가져옵니다.
- 전역 큐가 비어 있으면 다른 스레드의 로컬 큐에서 작업을 훔쳐올 수 있는지 확인합니다. 훔칠 때는 다른 스레드의 로컬 큐의 절반을 가져옵니다.
이 프로세스는 입증되었고 ZIO 2가 출시된 이후로 효율적으로 작동하고 있습니다. 최근에 향상되기까지 했습니다. 그렇다면 왜 변경해야 할까요? 알아봅시다.
블로킹
ZIO 2.0.x에서 기본 스케줄러는 기본적으로 자동 블로킹 기능이 활성화되어 있었습니다. 2.1.x에서는 비활성화되어 있으며 명시적으로 선택해야 합니다:
val bootstrap = Runtime.enableAutoBlockingExecutor
이 기능은 fiber가 블로킹 작업을 수행하고 있는지 감지하려고 하며, 감지되면 그 코드 위치를 기억해 두었다가, 나중에 그 코드가 다시 실행되면 자동으로 fiber 를 블로킹 스레드풀로 이동시켜 줍니다. 사용자가 블로킹 코드를 ZIO.blocking
또는 ZIO.attemptBlocking
으로 래핑하는 것을 잊었을 때 executor가 대신 처리해주는 것이 목표입니다.
기본 executor에서 블로킹 코드를 실행하는 것은 좋지 않습니다. 왜냐하면 제한된 수의 스레드를 사용하므로 쉽게 모든 스레드가 블로킹되어 아무 작업도 수행하지 못하는 상황에 처할 수 있기 때문입니다.
이 기능의 문제는 fiber가 블로킹 코드를 실행하고 있는지 여부를 감지하는 것이 완벽하지 않은 휴리스틱이라는 점입니다. 간단히 말해서, 작업이 특정 임계값보다 오래 실행되면 "블로킹"으로 간주됩니다. 그러나 이는 실제로 블로킹된 것이 아니라 단순히 CPU 중심적일 수 있으며, 다른 이유로 인해 느릴 수도 있습니다(시작 직후 JVM 이 warm-up 이 되지 않았을 때 클래스 로딩이 시간이 걸려 블로킹 스레드풀로 이동된 경우를 본 적이 있습니다). 또 다른 문제는 블로킹 위치를 추적하고 매번 작업을 실행할 때 이를 확인하는 것이 오버헤드를 유발한다는 것입니다.
가능한 최고의 성능을 얻으려면 이 모드를 사용하지 않고 블로킹 코드를 블로킹 스레드풀로 명시적으로 이동하는 것이 좋습니다. 의심이 간다면 스레드 상태를 살펴보고 잠재적인 블로킹을 찾아내기 위해 프로파일러를 사용하 십시오. 하지만 당신의 코드나 사용하는 라이브러리에 대해 확신이 없고, 더 안전하게 하기 위해 성능 페널티를 기꺼이 감수할 수 있다면, 자동 블로킹을 활성화하는 것도 충분히 괜찮은 선택지입니다.
Executor 오버라이드
ZIO에서 꽤 놀랍고 많은 사람들이 알지 못하는 동작이 하나 있습니다. 블로킹 스레드풀로 작업을 이동할 때, fiber는 해당 코드를 실행한 후 기본 executor로 자동으로 돌아오지 않는다는 것입니다. 한번 fiber 가 블로킹 스레드풀에 들어가면, 그 fiber 는 끝날 때까지, 또는 내부 루프를 충분히 많이 실행하여 기본 executor 로 돌아갈 (yield) 때까지 블로킹 스레드풀에서 계속 실행됩니다.
사용 사례에 따라 이러한 동작이 좋을 수도 나쁠 수도 있습니다. Fiber가 블로킹 호출 후 즉시 종료되는 경우(예: DB 호출을 수행하고 클라이언트에 반환하는 경우), 스레드 이동을 피하는 것이 더 효율적입니다. 그러나 fiber가 장기 실행되는 경우(예: Shardcake의 엔터티 동작), 블로킹 스레드풀에서 많은 코드가 실행되어 많은 수의 스레드가 생성되고 성능이 저하될 수 있습니다. 그에 반해 Cats Effect는 기본적으로 자동으로 돌아오도록 선택한 것으로 보입니다.
하지만 이를 해결하는 간단한 방법이 있으며, executor를 명시적으로 설정하는 것입니다. 기본 executor를 오버라이드하면 블로킹 코드가 실행된 후 작업이 항상 해당 executor로 돌아옵니다. 그러나 기본 executor를 유지하고 싶다면 어떻게 해야 할까요? 간단히 말해서 기본 executor를 자체적으로 오버라이드할 수 있습니다!
val bootstrap =
Runtime.setExecutor(Executor.makeDefault(autoBlocking = false))
이 간단한 줄은 블로킹 코드 실행 후 항상 기본 executor로 돌아오도록 보장합니다.
ZIO 2.1.2부터는 블로킹 후 이전 executor로 돌아갈지를 제어하는 플래그가 생겼습니다. 위의 해결책 대신 다음과 같이 할 수 있습니다:
val bootstrap =
Runtime.enableFlags(RuntimeFlag.EagerShiftBack)
대안 Executor
기본 executor를 변경하는 또 다른 이유는 새로운 대체 executor를 시도해보는 것입니다. 첫째, ZIO 2.1.x에서는 Loom에 기반한 새로운 executor가 추가되었습니다 (JDK21+에서만 사용 가능). 이 executor는 각 fiber 작업에 Loom의 가상 스레드를 할당합니다. 실제 스레드에서 이러한 가상 스레드를 실행하는 효율성은 Loom에 맡겨집니다.
이를 사용하려면 bootstrap
함수에 다음 코드를 추가하십시오:
val bootstrap = Runtime.enableLoomBasedExecutor
이는 반드시 테스트해봐야 할 흥미로운 대안입니다. 그러나 많은 fiber를 생성하고 연결하는 것을 테스트하는 ZIO 벤치마크에서 기본적으로 Tokio 기반 스케줄러가 Loom 기반 스케줄러보다 상당히 빠르다는 점을 참고하십시오.
최근 또 다른 가능성은 Kyo의 스케줄러입니다. 이는 최근에 kyo 0.9.3에서 독립적인 의존성으로 추출되었습니다. 아주 최근에 릴리즈 되었고 아직 실전에서 증명된 물건은 아니지만, 그 창시자 Flavio Brasil은 JVM에서 다양한 effect 시스템을 사용한 경험을 바탕으로 한 전문가이며 그의 스케줄러는 이러한 경험을 바탕으로 만들어졌습니다. 향후에 이를 시도해볼 계획입니다.
Datadog에 대한 마지막 주의 사항
Datadog을 사용하는 사용자에게 마지막 팁을 드리겠습니다. Datadog은 코드를 자동으로 프로파일링하여 별다른 오버헤드 없이 작동하는 에이전트를 제공합니다. 저는 프로덕션에서 ZIO와 관련된 두 가지 플래그를 변경했습니다:
-Ddd.integration.throwables.enabled=false
는 예외 프로파일링을 비활성화합니다. 왜 필요할까요? ZIO 2.0.x 버전부터 ZIO는 내부적으로 제어 흐름을 위한 예외를 사용합니다. 이는 매우 많은 예외를 발생시켜 프로파일러를 과부하시킬 수 있습니다. ZIO 2.1.x에서는 이러한 사용이 감소했지만 완전히 제거되지는 않았기 때문에 여전히 유효합니다.-Ddd.integration.zio.experimental.enabled=true
는 성능을 향상시키지는 않지만 Datadog 추적기가 ZIO fiber 간에 컨텍스트를 전달할 수 있게 해줍니다. 이를 통해 더 나은 추적을 할 수 있으며, 예를 들어 DB 호출을 API 호출에 연결할 수 있습니다. 저희도 프로덕션에서 이를 사용하고 있으며, 오버헤드가 없음을 확인했습니다. 아직 실험적인 옵션이고 공식 문서에도 없지만 잘 작동하고 있어 추천합니다.
오늘은 여기까지입니다! 도움이 되었기를 바랍니다.