Optimization

책 <대용량 아키텍처와 성능 튜닝>

팅리엔 2021. 2. 15. 10:27

성능 엔지니어링

언제 해야 하는가?

분석 단계

  • 성능에 대한 목표 세우기
    • 목표 응답 시간
    • 총 사용자 수
    • 동시 접속자 수
    • 성능 모델 (부하의 패턴)
  • 서비스의 종류 (웹, 게임, 기업 시스템, 쇼핑, 뱅킹 등), 사용자층, 지역 고려

디자인 단계

  • 목표 성능과 용량을 달성할 수 있는 규모의 시스템 설계를 진행한다.
  • 피크 타임에 맞춰서 디자인한다.
  • 부하가 일정하고 예측 가능할 땐 고정된 사이즈의 하드웨어, 부하가 갑자기 몰리는 시스템은 클라우드를 이용해 auto scale out 모델을 사용한다.
  • 100,000명 정도가 사용하는 시스템은 RDBMS를 사용해도 문제 없다.
    100,000,000명 정도가 사용하는 시스템은 샤딩이나 NoSQL 등을 사용해야 한다.
    또한 빠른 응답시간을 요구하는 경우 Redis, Memcached 등의 캐시 솔루션을 활용한다.
    톰캣과 같은 일반적인 WAS 보다는 Netty나 Vert.x, Node.js 같은 고성능 미들웨어를 고려해볼 수 있다.
  • 사용할 기술을 선택하고 간단한 프로토타입을 구현한 뒤 시나리오가 단순한 대규모 성능 및 용량 테스트(PoC Proof of Concept)를 수행한다.

개발 단계

  • 개발 초기: 리스크가 높은 부분, 아키텍처에 관련되는 부분, 난도가 높은 부분, 핵심 기능 개발
  • 스테이징: 성능 엔지니어링

최종 테스트 단계

  • 최종 시스템에 대한 성능, 용량 부분의 측정, 미세 튜닝 (병목을 찾아 부분적으로 수정하거나 하드웨어나 미들웨어를 설정하는 수준. JVM 튜닝, 톰캣 설정 튜닝, SQL 튜닝)

운영 단계

  • 모니터링
    • 웹 서버의 access 로그에서 응답 시간을 모니터링
    • APM (Application Performance Monitoring) 도구, 시스템 모니터링 도구 (e.g. Jennifer, Ganglia)
    • 피크 타임의 시스템 용량이 CPU 80% 정도에 다다르면 시스템 용량 증설을 고려한다.
    • 로그 수집

시스템 용량 산정

  • Response Time : 사용자가 서버에 요청을 한 시간부터 응답을 받을 때까지의 시간
  • Concurrent User : 현재 시스템을 사용하는 사용자
  • Active User : 현재 시스템에 트랜잭션을 실행하여 부하를 주는 사용자
  • TPS (Transaction per Second) : 초당 처리할 수 있는 트랜잭션의 양
    TPS = Active User / Response Time
  • HPS (Hit per Second) : 시스템이 처리할 수 있는 모든 웹 요청의 초당 처리량
    TPS가 비즈니스 트랜잭션에 대한 처리 시간만을 정의한다면 HPS는 리소스에 대한 요청 처리량을 포함

성능 엔지니어링의 절차

  1. 전체 성능 목표를 정의한다.
    e.g. 동시 사용자 1000명에 대해 응답 시간 1초 내가 목표 → 1,000TPS
  2. 한 사용자의 각 시나리오 사용 비중을 정의한다.
    e.g. 로그인 5%, 리스트 보기 50%, 업로드 10%, 디테일 25%, 로그아웃 5%
  3. 전체 시나리오를 함께 돌리는 부하 테스트를 수행했을 때 1,000TPS가 나와야 한다.
    e.g. 로그인은 1,000TPS의 5%인 50TPS, 리스트 보기는 500TPS가 나와야 한다.
  4. 부하를 생성한다. Apache AB(간단), Grinder, Jmeter(스크립트 지원), nGrinder(GUI 지원)
    부하 생성에 사용되는 스크립트는 복잡도가 높고 이후 회귀 테스트에서 재사용하기 때문에 반드시 형상 관리 시스템(VCS)을 통해 관리한다.
  5. 모니터링
    • Application
      • Response Time
      • TPS
    • Middleware (아파치 등 웹서버, 톰캣 등 WAS, RabbitMQ 등 메시지큐, MySQL등 디비)
      • WAS
        유휴 스레드의 수가 0, 큐에 메시지 적재 되어 있으면 서버가 용량을 초과했다는 뜻
        JMX(Java Management Extension) API을 사용해 모니터링
      • 디비
        슬로우 쿼리를 찾고, EXPLAIN 명령어로 수행 내용을 분석한 후 튜닝 수행
    • Infrastructure
      • Ganglia, Cacti 인프라 모니터링 도구
      • top, glance, sar 명령어
      • 부하 테스트 중엔 top를 띄어놓고 모니터링 한다.
      • CPU
      • 메모리
        리눅스는 가상 메모리 개념을 사용해 스와핑 공간에 자주 사용하지 않는 메모리의 내용을 덤프해 저장하여 다시 사용할 때 메모리에 로딩한다. 실제 디스크 IO를 발생시켜 성능이 매우 떨어진다. 전체 메모리 사용량을 줄이도록 튜닝을 하거나 물리 메모리를 늘린다.
      • 디스크 IO
        Ganglia에서 IOPS 모니터링
        iostat, sar 명령어
        iotop
        • 디스크 자체를 SSD로 변경한다.
        • 버퍼가 크거나 RPM이 높은 디스크로 변경한다.
        • 인터페이스를 SAS, SSD처럼 높은 IO를 제공하는 디스크 인터페이스로 변경한다.
        • 디스크 컨트롤러는 FC/HBA처럼 광케이블 기반의 고속 컨트롤러를 사용한다.
        • RAID 구성을 스트리핑 방식으로 변경해 IO를 여러 디스크로 분산한다.
        • 데이터베이스 앞에 캐싱을 사용한다.
        • 중간에 메시지큐를 써서 로그를 다른 서버에서 쓰도록해 IO를 분산한다.
        • back write 방식으로 로그를 20, 30개씩 한꺼번에 디스크로 flush 한다.
        • 디스크 IO가 많이 발생하는 로직은 동기 처리에서 메시지큐를 사용하는 비동기 방식으로 변경한다.
      • 네트워크 IO
        • 고용량의 파일이나 이미지 전송에서 많이 발생한다.
        • Reverse Proxy, NAT(Network Address Translator), 라우터, 로드 밸런서 등에서 많이 발생한다.
        • 여러가지 지점과 장비에 대해 모니터링 해야 해서 Cacti, Ganglia 같은 RRD 도구, OpenNMS 같은 NMS를 사용한다.
        • 그래프를 보면서 추이를 지켜본다.
        • 정적 콘텐츠는 CDN이나 분리된 웹서버를 이용해 서비스한다.
        • 여러개의 NAT를 사용해 로드를 분산하도록 한다.
        • 로드밸런서는 충분히 큰 용량을 사용하거나 두 개 이상의 로드밸런서를 배포하고 DNS 라운드 로빈을 사용한다.

튜닝

  1. 문제를 제대로 정의한다.
    e.g. "성능 목표가 350TPS에 1초 내 응답 시간인데 현재 60TPS에 5초의 응답 시간에 애플리케이션 서버의 CPU 점유율이 100%입니다"
  2. 문제가 발생하는 부분이 어디인지 판단한다.
  3. 문제가 되는 구간을 다른 요인으로부터 분리시킨다.
  4. 프로파일링을 하거나 코드에 디버그 정보를 걸어서 문제의 원인을 분석한다.
  5. 해결한다.
    비즈니스 시나리오 자체를 바꾸거나 UX 관점에서 해결할 수도 있다.
  6. 성능 목표에 도달할 때까지 반복한다.

필요한 것들

  • 부하 테스트기
  • 모니터링 도구
  • 프로파일링 도구
  • 프로파일링 도구들은 IDE에서 사용 가능하지만 대규모 부하 환경에서는 대부분 사용할 수 없다.
    그런 때에는 스냅샷을 추출할 수 있는 덤프 도구들을 사용한다.
    ptrace를 통해 System Call을 모니터링 하거나 pmap을 이용해 메모리샷을 추출할 수 있다. 자바는 스레드 덤프를 추출해 병목 당시 애플리케이션이 어떤 작동을 하고 있었는지 찾아낼 수 있다.

JVM 튜닝

GC의 동작 방법

  • Young 영역에 생성된지 얼마 안 된 객체들을 저장되고, Old 영역엔 생성된지 오래된 객체가 저장된다.
  • Perm 영역은 Class, Method 등의 코드가 저장되는 영역으로 JVM에 의해 사용된다.
  • Minor GC (Copy & Scavenge 알고리즘)
    • Young 영역의 GC를 Minor GC이라고 한다.
    • Young 영역은 다시 Eden, Survivor 영역으로 나뉜다. Eden 영역엔 자바 객체가 생성되자마자 저장되는 곳이고, 이 곳의 객체들은 Minor GC가 발생할 때 Survivor 영역으로 이동된다. Survivor 영역은 다시 Survivor1, Survivor2 두 영역으로 나뉘어 Minor GC가 발생하면 Eden과 Survivor1의 활성 객체를 Survivor2로 복사한 후 Eden과 Survivor1 영역을 클리어한다. 다음 번엔 Eden과 Survivor2의 활성 객체를 Survivor1로 복사한 후 Eden과 Survivor2 영역을 클리어한다. 이런 식으로 Minor GC를 수행하다가 Survivor 영역에서 오래된 객체는 Old 영역으로 옮긴다.
    • 시간이 짧고 자주 일어난다.
  • Full GC (Mark & Compact 알고리즘)
    • Old 영역의 가비지 콜렉션을 Full GC이라고 한다.
    • 전체 객체들의 참조를 확인하면서 참조가 연결되지 않은 객체를 표시하고, 표시된 객체를 삭제한다.
    • 속도가 매우 느리다.
  • Full GC의 경우 수초가 소요되고 Full GC 동안에는 자바 애플리케이션이 멈춰버린다. 그동안 사용자의 요청이 큐에 저장되었다가 Full GC가 끝난 후에 그 요청이 한꺼번에 들어온다.
  • JVM은 위 알고리즘 이외의 다양한 GC 방법을 제공한다.
    • Default Collector
      • 위에서 설명한 기본적인 알고리즘
      • GC가 일어날때 스레드들이 작업을 멈추고, GC를 수행하는 스레드만 GC를 수행한다.
    • Parallel GC for young generation
      • Minor GC를 동시에 여러개의 스레드를 이용해서 수행한다.
      • 1 CPU에서는 동시에 여러개의 스레드를 실행할 수 없어서 오히려 Default보다 느리다.
      • 최소한 4 CPU, 256M 메모리의 하드웨어에서 사용한다.
      • Low-pause 방식
        • -XX:+UseParNewGC
        • GC가 일어날 때 애플리케이션이 멈추는 현상을 최소화하는 데 중점을 둔다.
        • Concurrent GC 방법의 Full GC과 함께 사용
      • Throughput 방식
        • -XX:+UseParallerGC
        • GC가 신속히 수행되는 데 중점을 둔다.
        • Defatult GC (Mark & Compact) 방법의 Full GC과 함께 사용
    • Concurrent GC for old generation
      • Full GC 작업의 일부는 애플리케이션이 돌아가는 단계에서 수행하고 최소한의 작업만을 애플리케이션이 멈췄을 때 수행한다.
      • -XX:+UseConcMarkSweepGC
    • Incremental GC (Train GC)
      • Minor GC가 일어날때마다 Old 영역을 조금씩 GC 해서 Full GC가 발생하는 횟수나 시간을 줄인다.
      • -Xinc
      • 많은 자원을 소모하고 Minor GC를 자주 일으킨다.

GC 로그 수집

  • -verbosegc
    • stdout으로 출력돼서 '>'를 이용해 파일로 저장할 수 있다.

GC 관련 파라미터

  • 전체 힙 크기 조정 : -ms 512m -mx 1024m
    • 메모리가 부족할 때는 힙을 늘리고, 남을 때는 힙을 줄인다.
    • 메모리 변화량이 큰 애플리케이션이 아니라면 최소 힙 크기와 최대 힙 크기를 동일하게 맞춘다. (Growing과 Shrinking에 의한 로드를 막는다.)
  • Perm 크기 조정 : -XX:MaxPermSize=128m
    • Perm은 자바 애플리케이션 자체가 로딩되는 영역
    • 일반적으로 64-256m가 적절하다.
  • New, Old 영역 조정 : -XX:NewRatio=2
  • Survivor 영역 조정 : -XX:SurvivorRatio=64

GC 튜닝하기

  1. 튜닝의 목표를 설정한다.
    • 메모리를 적게 쓰는 게 목표인가? GC 횟수를 줄이는 게 목표인가? GC에 걸리는 시간을 줄이는 게 목표인가? 애플리케이션의 성능을 향상시키는 게 목표인가?
  2. 힙 크기와 Perm 크기를 설정한다.
  3. GC 로그를 수집하기 위한 -verbosegc 옵션을 적용한다.
  4. nGrinder 같은 스트레스 툴로 스트레스를 줘서 로그를 수집한다.
  5. Perm 크기를 조정한다.
  6. GC에 걸린 시간을 분석한다.
  7. Young 영역과 Old 영역을 적절하게 조절한다.
    • Full GC 수행에 드는 시간을 줄이고자 한다면 Old 영역을 줄인다. 그래서 Full GC 횟수는 늘리고 일어나는 시간을 줄일 수 있다.

JVM 모니터링 도구

  • Visual VM
  • JConsole

서버의 병목 발견하기

  • hang up : 서버 인스턴스는 실행되고 있지만 아무런 응답이 없는 상태
  • slow down : 서버 인스턴스의 응답 시간이 아주 느려지는 상태

애플리케이션의 병목

  • 스레드 덤프
    • kill -3 pid
    • 시스템이 행업이나 슬로다운에 걸렸을 때 3-5초 간격으로 5개 정도의 덤프를 추출한다.
    • 1-2개의 덤프 내에서 연속된 모습을 보이면 안 된다. 일반적으로 하나의 메서드에 1초 이상 머문다는 게 거의 있을 수 없는 일이기 때문. 그래서 진행되지 않고 멈춰있는 스레드를 찾는다.
    • 문제를 일으키는 유형
      • 락 경합
      • 데드 락
      • IO 응답 대기
      • 높은 CPU 사용률