본문 바로가기
SSR

제한된 리소스에서 살아남기

by oncerun 2022. 12. 31.
반응형

 

항상 더 좋은 방법을 찾기 위해 노력한다.

최근 제한된 리소스에서 웹 애플리케이션과 모바일 앱을 위한 API를 주는 하나의 웹 애플리케이션을 개발했다.

 

제한된 리소스

t3.micro 2Vcore 1 Gib

 

개발을 마무리한 후 스트레스 테스트에서 메모리 1 GiB는 정말 가혹했다. 

 

기본으로 JVM 할당 값을 계산해 보자.

initial HeapSize는 메모리의 1/64이며 이는 약 15~16MB 사이이다.

Max HeapSize는 메모리의 1/4이며 따라서 250MB이다. 

 

애플리케이션을 구동시킨 이후 간단하게 테스트를 진행해 보았다. 

 

위 사진은 급하게 HeapMemory를 512MB로 늘리고 swap 메모리 2GB로 설정한 후 진행한 것이다. 

그 전에는 한숨자고와도 세상이 멈춰있었다.

 

정말 말도 안 되는 TPS (20~ 30)가 결과로 나왔고 미친듯한 Minor GC, Major GC로 인한 Stop-the-world가 발생하고  결국 Full GC가 발생해 초기 요청에만 응답을 하고 STW의 시간이 길어짐에 따라 모든 스레드가 정지함에 따라 커넥션 풀에서 커넥션을 기다리는 시간의 기본값인 30초를 초과해 Connection Time Out이 발생해 응답이 줄줄이 터져버렸다. 

 

처음에는 Connection 에러를 보고 커넥션이 부족해서 발생하는 문제인 줄 알았지만 고민 끝에 Full GC의 시간이 상상도 못 할 정도로 오래 걸려서 발생하는 에러라고 판단했다. 

 

그래서 조금은 개선해보기로 했다.

 

서버의 주 목표는 두 가지이다.

 

1. 모바일 앱에 전달될 JSON 형식의 데이터이다. 현재 400KB 정도 값으로 추후 더 증가된다. 

2. 실제 앱에 반영될 데이터를 등록하고 S3에 업로드하는 기능.

 

실제 걱정되는 부분은 1번이었다. 

 

앱 특성상 특정 기간에 몇만 명의 트래픽이 몰릴 가능성이 있었는데 해당 API 요청은 모든 데이터를 끌어다가 가져가는 쿼리였기 때문이다.  

 

상황을 파악하고 개선할 수 있는 여러 가지 방법을 고민해 보았다.

 

1. 클라이언트에서 요청 수를 줄이도록 요청하기. 

 

API 호출을 줄일 수 있는 방법을 고민해보았다. 클라이언트 입장에서 불필요한 호출은 없는가?

응답에서 사용하지 않는 부분은 없는가? 등등..  

모바일에는 notification 같은 건 어떻게 만들지? 이걸로 해결할 수 있나 등등 다양하게 고민해보고 있다.

 

담당자분께 API 호출에 있어서 요청 수를 줄일 수 있는 지 이야기를 해보았는데  상태 변경에 대해 서버에 직접 호출하는 방법밖에 생각이 나지 않았던 것같다. 

 

내가 플러터를 공부한지 별로 안되 아직 기술성숙도가 낮아 별다른 대안책을 찾아내지 못했다.

관련 기능을 더 찾아보고 다시 한번 요청드려보자

 

2. JSON 생성 로직 최적화하기.

 

최대한 리팩토링 해보았다.

 

요청 -> (debug, production 분기) -> 쿼리 1 -> 쿼리 2 

 

쿼리 1에서 조회한 최상단 루트 도메인에서 쿼리 2에서 가져온 정보를 조립

 

1. 기존 API 호환을 위한 호환성 작업

 

2. 새로운 API 스펙에 맞게 응답 객체 생성

 

3. 해당 과정에서 많은 컬렉션 객체가 생성될 수 있다.

 

결국 해당 생성과정에서 필요한 객체들이 생성되면서 메모리를 차지하게 되는데, 과정을 생각해 보면

각 밸류객체든, 도메인 엔티티든 각각 응답에 매칭될 객체들이 따로 존재하여 변환해주는 과정이 필요.

 

해당 과정을 조금 더 생각해서 리팩터링 할 부분을 찾아보아야겠다.

 

3. Batch size -> @Entitygraph + Fetch Join 변경

 

보통 1:N 조회 쿼리에서 조건문이 존재할 때  Fetch join을 추천하지 않는다. 해당 이유는 데이터 정합성 때문인데 1:N인 경우 데이터가 N에 맞춰져서 결과 값이 나오기 때문이다.

feth join이 여러 개 존재하고 여기에 조건을 사용하는 경우에는  fetch join 보다는 batch size 값을 통해 성능을 최적화한다고 알고 있다. 

 

8개를 전체 조회하는데 batchsize로 하면 결국 8번의 쿼리가 나간다. 나는 해당 쿼리를 하나로 처리하는 게 더 효율적이라고 생각했고 그대로 @EntityGraph + 조건을 사용하는 엔티티까지는 fetch join을 사용해 즉시 로딩하였다.  

 

사실 1:N의 다중 Fetch join에 조건문을 사용하는 경우에 데이터 정합성, 무결성이 깨질 수 있는 조건은 해당 쿼리로 조회한 엔티티를 변경하는 경우에 해당하기 때문에 단지 조회용으로 사용하는 쿼리에서는 문제가 없다고 판단했다. 

 

4. 쿼리 분석 후 튜닝시도해보기

 

그다음으로 jpql 쿼리를 보고 실제 발생하는 쿼리 수행시간과 분석을 해보았다. 

 

API 요청에 대한 응답 시간은 311ms 약 0.3초이다. 쿼리를 통해 질의하는 시간은 280ms 정도였다.

 

8개 정도의 테이블을 조인하는데, 전체 중첩구조로 1:N구조라는 점이 걸렸고, 원래 N:1 관계를 가져오는 쿼리는 매우 제약

 

사항이 없는데 1:N으로 가져오게 되면 중복을 제거하는 부분이 걸렸고, 8번이나 연속으로 조인한다는 것도 걸렸다.

 

이 API 자체가 최상단 도메인 루트로 부터 모든 하위 도메인의 정보를 가져오는 쿼리이기 때문인데 더 성능적으로 개선할 부분이 없나 해서 해당 쿼리를 디비툴에 옮겨서 실행 계획을 보기로 했다. 

 

여기서 인덱스를 통해 개선할 수 있는 부분은 개선하고 쿼리 자체를 개선할 수 있으면 jpql을 통해 개선하려고 했다.

 

EXPLAIN을 통해 실행계획을 확인했을 때 대략적인 Indent와 특성은 다음과 같았다.

 

Unique

 -> Sort

     Sort Key : ... 조회되는 컬럼이 있던 것 같다.

     -> Hash Join

        -> Hash Right Join

             -> Hash Right Join

                 -> Hash Right Join
                     ->  Seq Scan

                 ->  Hash

                       -> Seq Scan

             -> Hash

                 ->  Hash Join

                       ->Seq Scan

                       -> Hash

                            -> Seq Scan

                                Filter: ...(text)

             ->   Hash

                  -> Hash Right Join

                       -> Hash Right Join

                           -> Seq Scan

                           -> Hash

                               ->  Seq Scan

                       -> Hash

                           -> Seq Scan

                               Filter

 

 

 

급한 대로 필요한 키워드만 정리해 봤다.

 

우선 읽는 방법은 안쪽 인덴트에서 바깥으로 실행계획의 흐름을 따라가야 한다고 한다. 

 

 

Unique

 

Unique는 쿼리의 결과에서 중복 항목을 제거하는 데 사용되는 작업의 한 유형이라고 한다. 

해당 작업은 정렬 키에 따라 정렬한 다음 정렬 키를 기준으로 중복된 행을 제거하여 작동한다. 

나머지 행은 고유 작업의 결과로 반환한다.  

중복 항목을 제거하려는 경우 유용하지만 전체 결과 집합을 정렬해야 하므로 비용이 많이 들 수 있다. 

 

자료구조를 Set으로 변경하고 dinstinct 키워드를 제거하는 것도 어찌 보면 약간의 성능 개선에 도움이 될지도 모르겠다.

 

실제로 변경 후 쿼리를 실행하면 Sort Operation이 발생한다.  해당 작업은 정렬 키에 따라 정렬하는 작업만 진행한다는 것을 의미한다. Unique는 여기에 추가적으로 중복행을 제거하는 작업을 진행한다는 것이다.

 

Hash 

 

해시 연산은 테이블의 행에서 해시 테이블을 만드는 데 사용되는 작업의 한 유형이다. 

해시 연산은 테이블의 행에서 해시 테이블을 만들고 해시 함수를 기반으로 행을 해시 테이블에 저장함으로써 동작한다. 

해시 함수는 테이블에 있는 하나 이상의 열 값을 해시 테이블의 슬롯에 매핑한다.

해시 연산은 해시 테이블을 사용하여 두 테이블의 행을 비교하고 일치하는 행을 반환하는 조인 유형인 해시 조인과 다르다.

 

Hash Join

 

해시 조인은 두 테이블의 행을 결합하는 데 사용되는 조인의 한 유형입니다. 

 

조인 칼럼을 기준으로 해쉬함수를 수행하여 서로 동일한 해쉬값을 갖는 것들 사이에서 실제 값이 같은지 비교하면서 조인을 수행한다. 

이 말은 별도의 해쉬 테이블을 생성하는 과정이 필요하다는 것으로 이는 실행 계획에서 다음과 같이 표현된다.

           

 ->  Hash  (cost=33.88..33.88 rows=988 width=83) (actual time=0.322.. 0.322 rows=988 loops=1)

 

선행 테이블의 조인키를 기준으로 해쉬 함수를 적용하여 해쉬 테이블을 생성한다.

이러한 작업을 모든 행에 대해 반복 수행한다.  즉 선행 테이블의 로우 수에 영향을 받는다. 

 

이후 후행 테이블에서 조인 키를 기준으로 해싱 함수에 대입해서 선행 테이블의 조인 키와 같은 해쉬 값을 갖는 버킷을 찾는다.  조인에 성공하면 별도의 추출 버퍼에 넣는다. 

 

이 방법은 해시 테이블의 빠른 조회 시간을 활용할 수 있기 때문에 대규모 데이터 세트에 효율적이다. 하지만 이는 해시 테이블을 저장하기 위해 추가 메모리가 필요하다. 즉 데이터베이스 서버의 메모리에 영향을 받는다는 것이다.

 

아마 데이터의 수가 별로 없어 옵티마이져가 Hash Join을 사용하는 것이 효율적이라고 판단하고 진행한 것으로 보인다.

 

Hash Join은 조인 칼럼의 인덱스가 존재하지 않을 경우에도 사용할 수 있는 기법으로 동등 조건에서만 적용할 수 있다.

다만 해쉬 테이블을 메모리에 생성해야 한다. 만약 메모리에 적재할 수 있는 영역의 크기보다 커지면 임시 영역인 보조기억 장치에 저장한다.  그렇기에 해쉬 테이블의 버켓수를 줄이는 과정이 필요하기 때문에 선행 테이블의 데이터 크기가 작을수록 유리하다. 

 

실제 실행 계획을 보면 다음과 같이 버켓을 생성하는 데 사용된 메모리 사용량을 볼 수 있다.                             

->  Hash  (cost=13.78..13.78 rows=478 width=59) (actual time=0.162..0.163 rows=478 loops=1)
      Buckets: 1024  Batches: 1  Memory Usage: 51kB

 

 

 

Seq Scan

 

Sequential Scan은 테이블의 모든 데이터를 하나씩 확인하는 방법으로 주로 인덱스가 없는 column을 조건으로 검색할 경우에 사용된다.

 

따라서 선행 테이블 전체를 읽은 후에 해시 테이블을 만들기 위해 선행 테이블 모두를 Seq Scan으로 처리한 것을 알 수 있다.

이후 후행 테이블도 Seq Scan으로 읽고  Hash Right Join을 진행했다고 이해했다.

 

->  Hash Right Join  (cost=46.23..77.38 rows=1041 width=115) (actual time=0.594..1.157 rows=1041 loops=1)
      Hash Cond: 
      ->  Seq Scan on a_version av  (cost=0.00..28.41 rows=1041 width=32) (actual time=0.008..0.133 rows=1041 loops=1)
      ->  Hash  (cost=33.88..33.88 rows=988 width=83) (actual time=0.581..0.582 rows=984 loops=1)
            Buckets: 1024  Batches: 1  Memory Usage: 121kB
            ->  Seq Scan on a a  (cost=0.00..33.88 rows=988 width=83) (actual time=0.005..0.158 rows=984 loops=1)

 

아니 그런데 실행 순서를 보면 참 이상하다. 

내가 실행 계획을 읽는 순서를 보았을 때는  동일 레벨의 ->에서는 첫 번째를 먼저 진행하는 것으로 알고 있는데, 실제 해시 테이블은 a 테이블을 기반으로 만든다. 

 

잠시 헷갈렸지만 찾아보니. 실제로는 a 테이블을 우선 스캔하여 해시 테이블을 만들고 이후에 a_version 테이블을 스캔하여 조인을 수행한다고 한다. 

 

그렇다면 Seq Scan을 사용하는 게 맞을까?

 

Seq Scan은 주로 조인 칼럼이 아닌 테이블 전체의 레코드를 순차적으로 스캔하는 기법이라고 했다. 

조인 조건으로 사용된 칼럼이 인덱스가 없거나 칼럼 값의 분포가 일정하지 않을 경우이다.

하지만 조인 조건은 전부 PK값으로 PK 인덱스가 기본적으로 생성된다. 그리고 분포가 일정하지 않을 수 없는 게, PK 칼럼

이라 매우 일정하게 분포된다. 

 

더 찾아보니. 조인 조건에 의해 검색되는 행의 수가 적은 경우에 발생할 수 있다고 한다. 

 

만약 데이터가 더 많다면 해당 쿼리는 인덱스 스캔을 사용한다는 의미가 된다고 생각한다.

 

Rows Removed by Filter

 

"Rows Removed by Filter" 값은 주어진 조건을 만족하지 않는 행이 제거된 경우에만 출력됩니다.

이 값은 쿼리 실행 계획을 분석하는 데 유용할 수 있습니다.

예를 들어, 일부 행이 특정 조건을 만족하지 않기 때문에 제거된 경우,

이러한 행이 몇 개가 제거되었는지 확인할 수 있습니다. 일반적으로 이 값이 작을수록 쿼리 성능이 더 좋아집니다.

 

이 값이 매우 작았다. 4~10 정도였다. 

 

 

Planning Time: 0.896 ms

 

Planning Time은 쿼리 최적화 과정에서 소비된 시간을 의미한다. 보통 쿼리 최적화는 쿼리가 실행되기 전에 쿼리가 어떻게 실해 될지 계획하고, 각 연산을 어떻게 수행할지 결정하는 과정이다. 

 

Execution Time: 8.015 ms

 

Execution Time은 쿼리가 실행되는 데 걸린 시간을 나타낸다. 이 시간은 쿼리가 실행될 때 계산된 결과가 아닌, 쿼리 자체가 실행되는 데 걸린 시간을 의미한다. 

 

 

그렇다 쿼리의 문제는 아니라고 판단했다. 실제 API 요청을 통한 응답 속도도 250~300ms으로 준수하다고 생각했다. 

 

그럼 다음은?

 

5. 캐시

 

특정 트래픽이 몰리는 경우 데이터를 수정할 가능성이 큰가? No

그렇다면 해당 JSON으로 파싱 된 결과를 캐싱을 해야겠다고 생각이 들었다. 

 

그렇다면 어떻게 매우 빠르게 캐시를 적용해야 할까?  

 

Spring 3.1부터 제공되는 기능을 사용하기로 했다.

 

Spring에서 캐시 추상화는 메서드를 통해 기능을 지원한다. 메서드의 실행 시점에 파라미터에 대한 캐시 존재 여부를 판단하여 캐시를 등록하게 되고, 캐시가 있으면 메서드를  실행시키지 않고 캐시 데이터를 return 해준다. 

 

캐시를 위한 캐시 로직을 작성하지 않아도 된다. (빠르게 할 수 있다.)

캐시를 저장하는 저장소만 설정해주면 된다. (CacheManager Interface)

 

별다른 의존성을 추가하지 않으면 default 값으로 LocalMemory에 저장이 가능한 ConcurrentMap 기반인 ConcurrentMapCacheManager가 Bean으로 등록된다. 

 

 

추가적으로 별도의 저장소를 사용하지 않아도 될 것 같다. redis는 너무 과하고 EhCache는 사용한 적이 없어 불안했다. 

 

따라서 상황에 맞게 구현하고 캐시를 지워야 하는 상황에 대해 정리하여 반영했다.

 

주의사항은 조금 기억해둘 만하다.

 

내부적으로 Spring AOP를 이용한다 따라서 public 접근자를 가진 method에만 사용이 가능하다. 

따라서 같은 클래스에서 메서드를 호출할 시 캐싱적용이 제대로 되지 않는다.

 

Spring AOP는 Bean에서만 사용이 가능하기 때문에 캐싱적용될 대상도 Bean으로 등록되어야 한다.

 

 

캐시를 설정함에 따라 TPS가 300까지 증가하고 동시 요청에 대해서도 매우 빠른 응답속도를 제공할 수 있게 되었다.  

 

그래도 최대 힙메모리가 250M인건 맘에 안 든다.

 

6. JVM 튜닝.

 

매우 많은 삽질을 했다. 이해가 잘 되지 않았다.. 

 

jdk11 버전 기준 기본 GC는 G1 GC로  가비지가 몰린 영역에 대한 우선 회수를 하는 GC이다. 

 

실제 G1 GC의 모든 동작원리를 설명하기에는 글이 길어질 것 같아.  짧게만 정리한다.

 

G1 GC가 default로 설정되는 jdk9 이전의 GC들은 Heap 영역을 크게 두 가지 작게는 4가지 + 2가지의 여유공간으로 사용했다. 

 

이러한 영역에 대한 개념들을 G1 GC는 가져가지만 기존 구조를 그대로 차용하지는 않았다.

 

1 / 2048 나눈 region을 사용했으며 g1의 cycle에 따라서 region의 상태를 4 가지의 개념 + 추가 2개를 변경하는 식으로 가져간다. 

 

이후 cycle이 돌면서 minor GC, major GC, mixed GC를 수행하는 구조이다. 

 

목표를 잡았다. 빠른 응답이 우선이다. 따라서 STW의 값을 줄이고 싶었다. 

 

우선 해당 부분에 대한 heap 덤프와 gc 로그를 수집하는 것이 우선 진행할 작업이었다. 

기본적으로 사용해야 하는 메모리 값은 얼마 정도일지 확인하기 위해 Full GC 이후 heap 메모리를 확인해 보았다.

 

 

이는 급하게 최대 메모리를 512M 정도로 할당해서 테스트하였다. 

 

Full GC 시간이 2.8초이다. 그리고 순간적인 트래픽이 몰릴 때 suvivor 공간 0, 1이 전부 100%로 되는 마법을 경험했다. 

 

덤프를 떠서 메모리 누수가 혹시 있나 확인해 봤는데, 누수되는 부분은 없고 단지. 요청에 대해 수많은 객체를 찍어내서 발생한 문제인 것 같다.

 

혹시 몰라서 gc로그를 추가하고 heap dump를 뜨기로 했다.

이를 위한 설정 -Xlog:gc*를 통해 gc 로그를 남기도록 했다.

 

그리고 부하를 주면서 jstat을 통해 각 영역에 객체가 생성되는 것과 minor gc, major gc 실행 시간을 모니터링했다.

 

이후 분석이 필요할 때 툴을 이용했다.

 

https://gceasy.io/

 

gceasy.io

How to enable Java GC Logging? For Java 1.4, 5, 6, 7, 8 pass this JVM argument to your application: -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc: For Java 9, pass the JVM argument: -Xlog:gc*:file= file-path: is the location where GC log file will be

gceasy.io

 

메모리 분석에는 다음 툴도 사용했다.

https://www.eclipse.org/mat/

 

Eclipse Memory Analyzer Open Source Project | The Eclipse Foundation

The Eclipse Foundation - home to a global community, the Eclipse IDE, Jakarta EE and over 415 open source projects, including runtimes, tools and frameworks.

www.eclipse.org

 

G1 GC가 메모리 용량이 큰 애플리케이션에 최적화되어있다는 소리에 CMS GC도 사용해봤지만 STW 시간만 길어졌다. 

 

-XX:-G1 UseAdaptiveIHOP

-XX:InitiatingHeapOccupancyPercent

 

아직 튜닝이 마무리되었다고는 할 수 없다.  IHOP percent를 조정하면서 지속적인 관찰을 하고 있다.

 

사실 웹 애플리케이션 같은 경우 실제 객체의 생존 시간이 매우 짧다.  그리고 현재 메모리는 매우 작다.

 

그래서 현실적으로 생각해 봤다.

 

모바일에서 접근 가능한 API EndPoint는 하나이다. 이를 캐싱을 적용했다. 따라서 순간적인 요청에 메모리가 갑작스럽게 증가되어 Major GC가 연속적으로 호출되거나 STW 매우 길어질 가능성을 줄였다. 

 

그래도 순간 트래픽에 대해서 스프링에서 처리하는 객체에 대한 메모리 사용률은 무시할 수 없다. 

 

사실상 해당 애플리케이션에서는 G1 GC가 어울리지 않을지도 모른다. 

 

왜냐하면 G1(Garbage First) GC는 메모리 공간이 큰(4GB 이상) 다중 프로세서 환경의 앱용으로 설계되었기 때문이다...

 

 

 

 

 

 

 

G1은 두 페이즈 Young-only, Space Reclamation을 순회하면서 GC 작업을 한다고 한다. 

 

Young-only는 Old 객체를 새로운 공간으로 옮긴다.

 

space-reclamation는 공간 회수를 한다.

 

XX:InitiatingHeapOccupancyPercent의 설정 값은 threshold이며 old gen의 점유율이 해당 임계값을 넘어가면

Young-only 페이즈가 시작된다. 

 

이 단계에서 Concurrent Start, Remark, CleanUP 단계를 거치는데

  • Concurrent Start는 Root으로부터 가까운 객체들 ( 도달할 수 있는) 에게 마킹 작업을 한다.
  • Remark: STW가 발생하며 최종적으로 마킹을 마무리하고 가비지 영역을 해지한다.
  • Cleanup: space-reclamaton 페이즈로 진입해야 할지 고민한다. 

 

만약 Space-Reclamation 영역으로 진입한다면 G1 GC는 young/old 영역을 가리지 않고 live objects를 전부 대피시킨다. (Evacuation) 

 

G1 GC가 CMS GC가 하지 못하는 일중 하나가 compaction을 지원한다는 것이다. 대피시킨다는 개념이 조금 헷갈렸는데 정리하면 다음과 같다. 

 

G1 GC는 쓰레기가 쌓여 꽉 찬 영역을 우선적으로 청소한다. 또한 멀티 스레드로 각각의 로컬 저장소를 가지고 수행된다. 

즉 space-reclamation이 시작되면 그냥 살아있는 모든 객체들을 빈 공간으로 이주시키고 (압축) 이전 공간들을 싹 청소해버린다는 것 같다.

 

시나리오를 짜보자.

메모리에 객체가 생성된다. 이는 Eden 영역에 배치된다.

 

또 메모리에 객체들이 들어온다. Eden 영역이 꽉 찼다. Minor GC 과정이 진행된다. 

 

참조가 살아있다면 survivor로 이동 아니면 소멸, 기존 Eden영역 클리어.

 

해당 과정을 반복하고 있다가 갑자기 많은 객체들이 생성된다. 

 

임계점을 넘는다.  young-only 페이즈 진입한다.(Major GC)가 시작된다.

 

Concurrent Start가 시작되어 도달할 수 있는 객체들에게 마킹을 시작한다.

 

Remark 단계가 되어 STW가 발생하고 최종적으로 마킹을 마무리하고 마킹되지 않은 영역을 해지한다. 

 

CleanUp 단계에 진입하여 Space-Reclamation 페이즈로 진입하기로 결정했다.

 

Space-Reclamation 페이즈에 진입하여 live objects을 조각모음하기 위해 evacuation(move & copy)한다.

이후 가비지의 공간을 회수한다.

 

이때는 mark 단계가 없어 STW 빈도가 줄었다. 이때 하는 GC를 Mixed GC라고 한다. 

 

이후 다시 Young Only Phase로 돌아가 Minor GC작업을 수행한다.

 

아 용어를 조금 정리해야 할 것 같다. 

 

Minor GC : Young 공간(Eden 및 Survivor 공간으로 구성됨)에서 쓰레기를 수집하는 것

 

Major GC : Tenured 공간을 청소하고 있습니다.

 

Full GC : 전체 힙(Young 및 Tenured 공간 모두)을 청소합니다.

 

https://plumbr.io/blog/garbage-collection/minor-gc-vs-major-gc-vs-full-gc

 

Minor GC vs Major GC vs Full GC | Plumbr – User Experience & Application Performance Monitoring

Do you know what differentiates Minor GC, Major GC and Full GC events within the JVM? Is this separation even necessary? The post busts some myths about the GC behavior while explaining the way GC…

plumbr.io

 

정리하면 다음과 같다.

 

G1 GC를 cycle을 돌면서 Minor GC를 지속적으로 수행하고 임계점이 넘으면 Major GC를 수행한다.

그래도 애플리케이션 메모리가 부족하면 Full GC를 수행한다.

 

현재 내가 개선해야 하는 부분이 어딜까?

 

1. Full GC를 최대한 제거한다. ( Minor GC, Major GC를 더 수행하도록 작업한다.)

GG

 

Full GC가 발생하는 이유는 애플리케이션이 너무 많은 객체를 할당하는 바람에 회수가 빨리 이루어지지 못하기 때문이다.

 

concurrent marking을 끝내지 못하고 허겁지겁 space-reclamation 단계를 시작하기도 한다.

 

또한 커다란 객체를 많이 할당하는 것도 Full GC 발생 확률을 높인다.

 

concurrent marking이 정시에 끝난다면 Full GC 발생 확률을 낮출 수 있다.

 

 

Full GC 발생 확률을 낮추기 위해 다음 방법들을 시도해 보도록 하자.

  • gc+heap=info로 로깅을 하면 커다란 객체가 있는 지역의 번호를 볼 수 있다.
  • -XX:G1 HeapRegionSize로 영역 크기를 늘려주면 커다란 객체 수도 줄어들게 될 것이다.
    • 커다란 객체 관련 문제는 이것 외에는 딱히 답이 없다고 한다. 다만 현재 내 상황에서 커다란 객체는 없다.
  • heap 사이즈를 늘려주면 마킹 완료까지의 시간도 같이 늘어난다.

  • -XX:ConcGCThreads를 설정해서 동시 마킹 스레드의 수를 늘려준다.

      이 값은 -XX:Paralle1 GCThreads를 4로 나눈 값이라고 한다.  paralle1 GCThread는 일시 정지 중
      paralle1 작업에 사용되는 최대 스레드 개수인데, 프로세서 수가 8보다 작으면 그대로 지정한 값을 사용하       고 그 외의 경우에는 5/8 만큼을 더 사용하면 된다는데 나는 vCore 2개라서 제외

  • G1이 더 미리미리 마킹을 시작하게 한다.
    • -XX:G1 ReservePercent를 설정해서 초기 시점의 Adaptive IHOP 계산에 영향을 준다.
    • -XX:-G1UseAdaptiveIHOP, -XX:InitiatingHeapOccupancyPercent를 설정해서 Adaptive IHOP 기능을 끈다.

 

이후 처리율을 늘려야 하기 때문에 다음 옵션을 튜닝하면서 모니터링한다.

 

  • -XX:MaxGCPauseMillis로 최대 일시 정지 시간을 늘려준다 기본 값은 200ms이다.

 

조금 헷갈린 부분이 있다. G1 GC는 다른 GC와 다르게 Young Gen 영역이 별도로 분리되어있지 않은데  다음 옵션들의 설명은 다음과 같다. G1 GC는 새로운 객체를 Eden 영역에 할당하기 전에 Suvivor 영역으로 복사를 진행하기 때문에 사이즈라는 말이 헷갈린다.

  • -XX:G1 NewSizePercent로 young gen의 최소 사이즈를 늘려준다.
  • -XX:G1MaxNewSizePercent로 young gen의 최대 사이즈를 늘려준다.

it is related to the number of live objects to be copied in the collection set of the young area

 

위에 설명에 따르면 young GC가 소비하는 시간은 일반적으로 young 영역의 set 컬렉션에서 복사할 live objects의 수와 관련이 있다고 한다.

 

eden 영역이 가득 차서 해당 eden 영역 객체들을 복사하는 크기가 영향을 미친다는 이야기로 들린다. 

 

그러면 처리율을 높이기 위해 young gen의 최소, 최대 사이즈를 늘리면 그만큼 Eden 영역이 많아지도록 구성된다는 것 같고 대신 Minor, Major GC에 발생하는 시간이 증가한다는 것 같다. 

 

찾아보니 -XX:G1NewSizePercent의 기본 값은 5이고 -XX:G1MaxNewSizePercent는 60이다.

그리고 young gen의 총사이즈는 두 값 사이에서 변화한다고 한다.

 

최소를 최댓값과 동일하게 맞추어 오버헤드가 없도록 튜닝해볼 생각이다. 

STW 시간을 계산한 결과 평상시와 적절한 트래픽에는 매우 좋은 성능을 보여주었기에 STW 시간을 살짝 늘려도 평상시에는 문제가 없다고 판단했다. 트래픽이 몰릴 경우에 대비하여 평상시의 성능을 조금 낮춰도 되지 않을까?

 

힙 사이즈를 키우고, Young gen영역의 사이즈를 키워 STW 시간을 조금 더 걸리도록 수정한다.

 

따라서 튜닝에 적용할 옵션을 정리하면 다음과 같다.

 

-server

 

-Xms=512MB

 

-Xmx=512MB

 

-XX:+AlwaysPreTouch

 

-XX:+UseLargePages( 고려)

 

-XX:G1NewSizePercent=60

 

-XX:G1MaxNewSizePercent=60

 

--XX:MaxGCPauseMillis=250ms

 

-XX:+G1UseAdaptiveIHOP, -XX:+InitiatingHeapOccupancyPercent는 기본 값보다 높이거나, 낮춰서 테스트를 진행해볼 생각이다.

 

뭐 결국 2주씩 모니터링해보면서 가장 좋은 튜닝옵션을 찾거나, 성능을 위해 애플리케이션 코드를 지속적으로 리팩터링 하는 수밖에 없어 보인다.

 

찐 막...

 

aws 인스턴스 왜.. 메모리 swap default로 설정 안 되어 있나요.. 

 

처음에 swap도 없어서 애플리케이션이 자동으로 죽어서 얼마나 놀랬던지... 

 

swap 파일 생성해서 swap 설정해주고 2GB로 할당해 주었다

 

정리

 

 

코드도 수정하고, 클라이언트에서 API 요청에 대해 건의도해 보고, 캐시도 처음으로 적용해보고 메모리 누수도 확인해보고 JVM 튜닝하려고 여러 공부도 해보고 swap 메모리도 할당해보고 여러 가지 경험을 짧은 시간에 했다. 

 

힘들다라기 보단 2022년의 마지막을 재밌게 보낸 것 같아 기분이 좋다. 

 

확실히 처음보단 성능이 많이 좋아졌기에 보람도 느낀다. 

 

근데 마지막에 느낀 감정으로는 그냥 돈 조금 더 쓰고 메모리 늘려주면 안 되나?

 

 

 

 

반응형

'SSR' 카테고리의 다른 글

Redis 활용하여 동시성 문제해결  (0) 2022.12.31
데이터베이스를 이용한 동시성 이슈 해결하기  (0) 2022.12.25
아파치 지시어  (0) 2022.11.13
Https 적용  (0) 2022.11.12

댓글