포스트

2022-11-05-TIL

2022-11-05-TIL

Today I Learned

Spring Data JPA Projections

QueryDSL에서만 가능한 줄 알았던 쿼리 프로젝션이 JPA에서도 가능하다는 사실을 알게 되었다. 실제로 쿼리가 해당 컬럼만 SELECT 하는지는 실제로 확인해 보아야한다.

Spring Data JPA 문서 기준으로 프로젝션은 세 가지다(Spring Data JPA: Projections).

  • 인터페이스 기반 닫힌(closed) 프로젝션: 엔티티 속성과 이름이 같은 getter만 가진 인터페이스다. 필요한 속성을 정확히 알 수 있으므로 Spring Data가 쿼리를 최적화해서 해당 컬럼만 조회할 수 있다.
  • 인터페이스 기반 열린(open) 프로젝션: @Value("#{target.firstname + ' ' + target.lastname}")처럼 SpEL로 값을 계산한다. 어떤 속성이 쓰일지 알 수 없어서 엔티티 전체를 읽으므로 최적화가 적용되지 않는다. 단순 계산은 인터페이스의 default 메서드로 쓰면 닫힌 프로젝션을 유지할 수 있다.
  • 클래스 기반(DTO, record) 프로젝션: 생성자 파라미터 이름으로 조회할 필드가 정해진다. 프록시가 없고 중첩 프로젝션은 쓸 수 없다.
1
2
3
4
5
6
7
interface NamesOnly {
    String getFirstname();
    String getLastname();
}

List<NamesOnly> findByLastname(String lastname);
<T> List<T> findByLastname(String lastname, Class<T> type);  // 동적 프로젝션

메모에 적었던 “정말 해당 컬럼만 SELECT 하는가”에 대한 문서의 답은 조건부다. 닫힌 프로젝션과 DTO는 최적화 대상이지만, 열린 프로젝션은 아니고, 연관 엔티티의 중첩 속성을 조회하면 조인 전체가 구체화되어 최적화 효과가 사라진다고 적혀 있다. 그래서 spring.jpa.show-sql이나 SQL 로그로 실제 쿼리를 확인하는 것이 맞다.

  • https://www.baeldung.com/spring-data-jpa-projections
  • https://docs.spring.io/spring-data/jpa/docs/current/reference/html/

Connection이 많아지면 OOM이 발생할 수 있는가?

가능하다. 다만 힙이 부족한 OOM만 있는 것이 아니라서 어떤 메모리가 고갈되는지를 나눠 봐야 한다.

  • 커넥션마다 스레드를 하나씩 쓰는 구조라면 커넥션이 늘수록 스레드가 늘고, 스레드마다 스택 메모리(힙 밖)와 OS 자원을 쓴다. OS나 프로세스의 한도에 닿으면 java.lang.OutOfMemoryError: unable to create new native thread가 난다. IBM 문서의 사례는 RMI TCP 연결 처리 스레드가 600개 넘게 쌓인 상태에서 이 에러가 난 경우다.
  • DB 커넥션 풀의 유휴 커넥션도 드라이버가 버퍼와 statement 캐시를 들고 있어 힙을 쓴다. Stack Overflow 질문은 Tomcat 커넥션 풀의 유휴 DB 커넥션이 OOM의 원인이 된 사례다.
  • 반납하지 않은 커넥션(누수)은 풀을 고갈시키고 관련 객체를 계속 붙잡아 둔다.

그래서 커넥션 수는 풀의 최대 크기, 서버 스레드 수와 함께 상한을 두고 관리해야 한다.

  • https://stackoverflow.com/questions/36198102/oom-due-to-idle-db-connections-in-tomcats-connectionpool
  • https://www.ibm.com/support/pages/unable-create-new-native-thread-out-memory-oom-large-amount-rmi-tcp-connections-threads-being-created

OOM killed pod

Kubernetes에서 컨테이너의 메모리 limit은 커널의 OOM kill로 강제된다. CPU limit은 넘으려 하면 스로틀링되는 하드 리밋이지만, 메모리는 limit을 넘겨 쓰다가 커널이 메모리 압박을 감지하면 컨테이너 프로세스가 종료되는 방식이다(Kubernetes: Resource Management for Pods and Containers). 파드 상태에 OOMKilled가 보이면 이 경우다.

JVM 애플리케이션이라면 Java의 OutOfMemoryError와 구분해야 한다. 힙이 -Xmx 안에서 부족하면 JVM이 예외를 던지고, 힙과 비힙(스레드 스택, Metaspace, 다이렉트 버퍼)을 합친 프로세스 전체 메모리가 컨테이너 limit을 넘으면 예외 없이 커널에 의해 죽는다. Istio 포럼의 사례처럼 사이드카 프록시의 커넥션 누수가 원인이 되는 경우도 있다.

  • https://discuss.istio.io/t/ingress-gateway-connection-leaks-causes-oom-killed-pod/12946

Caffeine Cache

Caffeine은 Java용 고성능 로컬(인메모리) 캐시 라이브러리로, Spring의 CaffeineCacheManager로 캐시 추상화와 함께 쓸 수 있다. 축출 정책으로 Window TinyLFU를 쓴다. 새 항목은 작은 LRU 영역(admission window)에 먼저 들어가고, 4비트 Count-Min Sketch로 추정한 과거 접근 빈도를 비교해 메인 영역(Segmented LRU)에 들일지 정한다. 최근성과 빈도를 모두 고려하면서도 항목당 메모리가 작고, 작업 패턴에 따라 창 크기를 스스로 조절한다(Caffeine Wiki: Efficiency).

로컬 캐시는 네트워크 왕복이 없어 빠르지만 서버마다 따로 갖기 때문에, 한 서버에서 갱신한 값이 다른 서버 캐시에는 남아 있을 수 있다. 짧은 TTL이나 변경이 드문 데이터에 쓰는 것이 안전하다.

  • https://velog.io/@soongjamm/Caffeine-Cache-%EB%A5%BC-%EC%A0%81%EC%9A%A9%ED%95%B4%EB%B3%B8-%EA%B2%BD%ED%97%98
  • https://blog.yevgnenll.me/posts/spring-boot-with-caffeine-cache
  • https://wave1994.tistory.com/182

JVM

JVM 성능 튜닝에 관한 글이다. JVM 메모리 영역의 구분은 10월 17일 TIL, 컨테이너 환경에서 힙과 비힙을 나누는 문제는 8월 24일 TIL에 정리했다.

  • https://www.holaxprogramming.com/2017/10/09/java-jvm-performance/

HikariCP

HikariCP는 Spring Boot의 기본 JDBC 커넥션 풀이다. 주요 설정과 기본값은 다음과 같다(HikariCP README).

설정기본값의미
maximumPoolSize10풀의 최대 커넥션 수
minimumIdlemaximumPoolSize와 같음유지할 최소 유휴 커넥션 수
connectionTimeout30000ms커넥션을 얻기 위해 기다리는 최대 시간
idleTimeout600000ms유휴 커넥션을 정리하기까지의 시간 (minimumIdle < maximumPoolSize일 때만)
maxLifetime1800000ms커넥션의 최대 수명

README는 minimumIdle을 따로 정하지 말고 고정 크기 풀로 쓰기를 권하고, maxLifetime은 DB나 네트워크 장비가 커넥션을 끊는 시간보다 몇 초 짧게 두라고 한다. 그렇지 않으면 DB가 이미 끊은 커넥션을 풀이 빌려줄 수 있다.

  • https://www.baeldung.com/hikaricp
  • https://jhparkkk.tistory.com/10

Max Connections and Max Threads in Tomcat

Tomcat HTTP 커넥터의 세 설정은 요청이 들어오는 단계별 한도다(Tomcat 9: The HTTP Connector).

  • maxConnections(기본 8192): 동시에 받아들여 처리하는 연결 수. 이 수에 닿으면 추가 연결은 OS가 받기만 하고 처리하지 않는다.
  • acceptCount(기본 100): maxConnections에 닿았을 때 OS 큐에 대기시킬 연결 수. 이것까지 차면 OS가 연결을 거부하거나 타임아웃시킨다.
  • maxThreads(기본 200): 요청을 실제로 처리하는 워커 스레드의 최대 수. 동시에 처리할 수 있는 요청 수의 상한이다.

NIO 커넥터는 유휴 keep-alive 연결을 스레드 없이 들고 있을 수 있어서 maxConnections가 maxThreads보다 훨씬 크다. Spring Boot에서는 server.tomcat.threads.max, server.tomcat.max-connections, server.tomcat.accept-count로 바꾼다. 스레드 수를 늘릴 때는 그 스레드들이 동시에 쓸 DB 커넥션 풀 크기와 함께 봐야 한다. 스레드만 늘리면 커넥션을 기다리는 스레드만 많아진다.

  • https://serverfault.com/questions/1070372/maxconnections-or-maxthreads-on-tomcat
  • https://stackoverflow.com/questions/24678661/tomcat-maxthreads-vs-maxconnections
  • https://serverfault.com/questions/1054410/how-to-increase-maxconnections-on-tomcat
  • https://serverfault.com/questions/405893/apache-tomcat-chokes-after-300-connections
  • https://bcho.tistory.com/788
  • https://d2.naver.com/helloworld/1321

Key Metrics

Tomcat 같은 WAS를 운영할 때 보는 지표는 크게 세 묶음이다. 스레드 풀(사용 중인 스레드 수와 maxThreads 대비 비율), 요청(초당 요청 수, 에러 수, 처리 시간), JVM(힙 사용량, GC 횟수와 시간)이다. Datadog 글이 Tomcat 구조와 함께 이런 지표를 다룬다. 수집은 Prometheus처럼 애플리케이션이 노출한 메트릭 엔드포인트를 주기적으로 긁어 가는 방식(Spring Boot Actuator와 Micrometer)이나, JMX로 MBean 값을 읽는 방식을 쓴다.

  • https://www.datadoghq.com/blog/tomcat-architecture-and-performance/
  • https://lob-dev.tistory.com/entry/Monitoring-01-%ED%94%84%EB%A1%9C%EB%A9%94%ED%85%8C%EC%9A%B0%EC%8A%A4%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%98%EC%97%AC-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EB%A9%94%ED%8A%B8%EB%A6%AD-%EC%88%98%EC%A7%91%ED%95%98%EA%B8%B0
  • https://www.ibm.com/docs/ko/cognos-analytics/11.2.0?topic=metrics-how-metric-data-is-gathered
  • https://docs.aws.amazon.com/ko_kr/prometheus/latest/userguide/AMP-secure-metric-ingestion.html

Java 19 - Virtual Threads

JDK 19는 2022년 9월 20일 GA되었고, 가상 스레드(JEP 425)가 프리뷰로 들어갔다. 같은 릴리스에 Structured Concurrency(JEP 428, 인큐베이터), Record Patterns(JEP 405, 프리뷰)도 포함되었다(OpenJDK: JDK 19). 가상 스레드와 Kotlin 코루틴의 비교, pinning 같은 주의점은 10월 15일 TIL에 정리했다. 앞의 Tomcat 스레드 이야기와 이어 보면, 요청당 스레드 모델에서 스레드 수가 곧 동시 처리 한도라는 제약을 푸는 것이 가상 스레드의 목표다.

  • https://www.infoworld.com/article/3653331/jdk-19-the-new-features-in-java-19.html
  • https://itnext.io/kotlin-coroutines-vs-java-virtual-threads-a-good-story-but-just-that-91038c7d21eb

Web Sockets vs Long Polling

롱 폴링은 클라이언트가 HTTP 요청을 보내면 서버가 새 데이터가 생기거나 타임아웃이 날 때까지 응답을 미루는 방식이다. 응답을 받으면 클라이언트는 바로 다시 요청한다. 일반 HTTP라 호환성이 좋지만 재연결이 잦고 지연과 서버 자원 소모가 크다. 웹소켓은 하나의 TCP 연결 위에서 양방향(full-duplex)으로 계속 통신하는 프로토콜이라 지연이 작고 효율적이다. 대신 재연결 처리를 직접 해야 하고, 연결을 오래 유지하므로 서버 확장이 더 복잡하다. Ably 글은 웹소켓을 기본 선택으로, 롱 폴링을 대체 수단으로 본다. 다만 이 글은 실시간 메시징 서비스 업체의 글이라 그 관점을 감안해서 읽어야 한다.

연결 하나를 오래 붙잡는다는 점에서 앞의 Tomcat 설정과도 이어진다. 롱 폴링을 블로킹 방식으로 구현하면 대기 중인 요청마다 워커 스레드를 점유한다.

  • https://ably.com/blog/websockets-vs-long-polling

JMX

JMX(Java Management Extensions)는 실행 중인 JVM과 애플리케이션의 상태를 MBean이라는 관리 객체로 노출하고, 원격에서 읽거나 조작하게 해 주는 Java 표준이다. JVM은 메모리, 스레드, GC 정보를, Tomcat은 커넥터와 스레드 풀 정보를 MBean으로 제공한다. JConsole이나 VisualVM으로 접속해서 보거나, 모니터링 에이전트가 JMX로 값을 수집한다. 원격 JMX 포트를 열 때는 인증과 접근 제한을 반드시 설정해야 한다.

  • https://www.ibm.com/docs/ko/was-zos/9.0.5?topic=scripting-using-wsadmin-java-management-extensions-jmx
  • https://www.lesstif.com/java/apache-tomcat-jmx-monitoring-20776824.html
  • https://narup.tistory.com/23
참고한 자료외부 출처 7

외부 출처

이 글은 저작권자의 CC BY 4.0 라이선스를 따릅니다.

댓글

아직 댓글이 없습니다