사이드 프로젝트 서버 OOM 문제 삽질한 내용들
- -
최근 사이드 프로젝트 서버가 유난히 자주 죽어 몇가지 처리를 해주었다. 그 과정에서 처리한 방법과 느낀 점들을 몇가지 정리해 보았다.
일단 로그를 읽어 문제를 살펴보니, Jackson에서 문제가 발생하는 듯 싶었다. 로그는 다음과 같다.
```json
[2023-09-28 04:58:17:441494769] ^[[32m[reactor-http-epoll-2]^[[0;39m ^[[31mWARN ^[[0;39m ^[[1;37m[io.netty.channel.AbstractChannelHandlerContext.invokeExceptionCaught:^[[33m311^[[0;39m]^[[0;39m - An exception 'java.lang.OutOfMemoryError: Java heap space' [enable DEBUG level for full stacktrace] was thrown by a user handler's exceptionCaught() method while handling the following exception:
java.lang.OutOfMemoryError: Java heap space
at java.base/java.lang.StringUTF16.compress(StringUTF16.java:160)
at java.base/java.lang.String.<init>(String.java:3214)
at java.base/java.lang.String.<init>(String.java:276)
at co m.fasterxml.jackson.core.util.TextBuffer.contentsAsString(TextBuffer.java:453)
at co m.fasterxml.jackson.core.json.UTF8StreamJsonParser._getText2(UTF8StreamJsonParser.java:457)
at co m.fasterxml.jackson.core.json.UTF8StreamJsonParser.getText(UTF8StreamJsonParser.java:338)
at co m.fasterxml.jackson.core.base.ParserMinimalBase.getValueAsString(ParserMinimalBase.java:495)
at co m.fasterxml.jackson.core.json.UTF8StreamJsonParser.getValueAsString(UTF8StreamJsonParser.java:384)
at co m.fasterxml.jackson.databind.deser.std.StringDeserializer.deserialize(StringDeserializer.java:68)
at co m.fasterxml.jackson.databind.deser.std.StringDeserializer.deserialize(StringDeserializer.java:11)
at co m.fasterxml.jackson.databind.deser.impl.FieldProperty.deserializeAndSet(FieldProperty.java:138)
at co m.fasterxml.jackson.databind.deser.BeanDeserializer.deserializeFromObject(BeanDeserializer.java:391)
at co m.fasterxml.jackson.databind.deser.BeanDeserializer.deserialize(BeanDeserializer.java:184)
at co m.fasterxml.jackson.databind.deser.impl.FieldProperty.deserializeAndSet(FieldProperty.java:138)
at co m.fasterxml.jackson.databind.deser.BeanDeserializer.deserializeFromObject(BeanDeserializer.java:391)
at co m.fasterxml.jackson.databind.deser.BeanDeserializer.deserialize(BeanDeserializer.java:184)
at co m.fasterxml.jackson.databind.deser.std.ObjectArrayDeserializer.deserialize(ObjectArrayDeserializer.java:214)
at co m.fasterxml.jackson.databind.deser.std.ObjectArrayDeserializer.deserialize(ObjectArrayDeserializer.java:24)
at co m.fasterxml.jackson.databind.deser.impl.FieldProperty.deserializeAndSet(FieldProperty.java:138)
at co m.fasterxml.jackson.databind.deser.BeanDeserializer.deserializeFromObject(BeanDeserializer.java:391)
at co m.fasterxml.jackson.databind.deser.BeanDeserializer.deserialize(BeanDeserializer.java:184)
at co m.fasterxml.jackson.databind.deser.std.ObjectArrayDeserializer.deserialize(ObjectArrayDeserializer.java:214)
at co m.fasterxml.jackson.databind.deser.std.ObjectArrayDeserializer.deserialize(ObjectArrayDeserializer.java:24)
at co m.fasterxml.jackson.databind.deser.impl.FieldProperty.deserializeAndSet(FieldProperty.java:138)
at co m.fasterxml.jackson.databind.deser.BeanDeserializer.deserializeFromObject(BeanDeserializer.java:391)
at co m.fasterxml.jackson.databind.deser.BeanDeserializer.deserialize(BeanDeserializer.java:184)
at co m.fasterxml.jackson.databind.deser.DefaultDeserializationContext.readRootValue(DefaultDeserializationContext.java:323)
at com.fasterxml.jackson.databind.ObjectReader._bindAndClose(ObjectReader.java:2051)
at com.fasterxml.jackson.databind.ObjectReader.readValue(ObjectReader.java:1427)
at org.springframework.http.codec.json.AbstractJackson2Decoder.decode(AbstractJackson2Decoder.java:193)
at org.springframework.http.codec.json.AbstractJackson2Decoder.lambda$decodeToMono$1(AbstractJackson2Decoder.java:179)
at org.springframework.http.codec.json.AbstractJackson2Decoder$$Lambda$1382/0x0000000840b7a040.apply(Unknown Source)
Exception in thread "http-nio-8080-Poller" Exception in thread "http-nio-8080-exec-1334" Exception in thread "http-nio-8080-exec-1337" Exception in thread "http-nio-8080-Acceptor" Exception in thread "http-nio-8080-exec-1338"
Exception: java.lang.OutOfMemoryError thrown from the UncaughtExceptionHandler in thread "http-nio-8080-Poller"
java.lang.OutOfMemoryError: Java heap space
java.lang.OutOfMemoryError: Java heap space
java.lang.OutOfMemoryError: Java heap space
java.lang.OutOfMemoryError: Java heap space
```
로그에서 문제가 되는 부분을 deserializer라고 집어줬기 때문에 API들 중, 요청의 크기가 큰 경우들을 찾아봤다. 우선 가장 의심되는 부분인 match request는 하나가 약 1.8kb 정도 되었다. 그런데 이러한 요청을 10개 보내어 match 정보를 page로 response해주는 로직이 있기 때문에, 18kb라고 봐도 좋을 듯 싶었다.
즉, 한 요청 당 18kb 크기의 json data를 객체로 변환하는 과정이 존재하고, 해당 요청이 완전히 처리되기 전까지는 heap에 남아있어야 한다는 의미이다.
Jackson이 데이터를 객체로 만들면 어느정도의 크기를 가질지 조사를 몇가지 해보다가 이러한 글을 보았다.
파싱하는 과정에서 18kb의 데이터를 객체로 만든다 할지라도, 그대로 18kb로 넘기는 건 아닌 것 같았고, Jackson이 아니더라도 객체 자체의 메모리도 생각과는 좀 달랐다. (자세한 내용을 알고 싶다면 다음을 참고하자 : https://www.baeldung.com/java-memory-layout)
여기까지 왔을 때 처리할 수 있는 방법에 대해서 생각해 보았는데 다음과 같이 4가지 방법이 떠올랐다.
1. 캐싱
2. ObjectMapper를 직접 수정한다
3. bytearray로 전달한다
4. gc를 명시적으로 수행한다.
일단 1. 캐싱은 불가능하다고 결론을 내렸다. 사용자들마다 매치 정보가 계속해서 달라지는데, 그 매치 정보들에 대한 정합성을 어떠한 주기로 맞춰야 할지에 대해 어려움이 있었고, 무엇보다 가용 가능한 메모리가 적은데 히트율을 체크할 수 없는 상황에서 무작정 캐시하기가 어렵다고 판단되었기 때문이다.
2. ObjectMapper를 직접 수정하는 방법을 찾다 보니 Jackson Streaming API라는 걸 발견할 수 있었다. 자세하게 본 건 아니지만, 기존 Jackson이 객체를 한 번에 만드는 것이라면, Streaming API는 setter와 같은 방식으로 값을 채워나가는 방식이라고 이해했다.
Streaming API로 serialize 로직을 바꾼다면, OOM 문제를 어느정도 타파 가능할 것으로 예상되어 몇가지 조사를 해보았다.
일단 VisualVM으로 Streaming API를 적용하기 전과 적용한 후에 대해서 메모리 사용량을 비교해 보려 했는데, 요청이 발생할 때 Serialize 혹은 Deserialize 하는 과정에서 메모리 사용량이 생각보다 적은 것을 발견했다. 즉, 이걸 변경하더라도 큰 이득이 없을 것이라는 판단이 들었다.
따라서 이건 메모리 누수 같은 문제가 발생하는 것은 아니고 트래픽이 늘어남에 따라 리소스가 많이 필요해진 것으로 판단했다.
3. bytearray로 client에 직접 전달하는 방법은 객체로 변환하고 다시 json으로 변환하는 과정에서 리소스를 많이 소모한다면, 그냥 변환하지 않고 bytearray를 수정해서 전달하면 되지 않을까? 하는 생각이 들었다. 단적인 예로, Java는 UTF-16을 사용하는데 HTTP 표준은 UTF-8이다. 객체로 변환하는 것 뿐만 아니라 데이터를 인코딩 디코딩 하는 과정이 추가적으로 들어가는 것이 아닌가? 하는 생각이 들어 리소스가 더 들어가는 건 아닐까 하고 의심이 들었다. 그러나 이 방법은 유지보수하기 너무 나빠지기 때문에 우선순위에서 밀려났다.
4. System 패키지를 보면 gc()라는 메서드가 있다. 명시적으로 gc를 수행시키는 건데, stw가 발생하므로 주의해서 사용해야 하며, 애플리케이션 단에서 직접 사용하는 건 권장하지 않는다고 한다. 또한 System.gc()를 수행시킨다고 해서 곧바로 gc가 실행될 거라는 보장도 없고 악영향만 줄 것 같다는 판단이 들어 이것도 보류했다.
그래서 결국 어떻게 처리했느냐? 하면 다음과 같다.
처리한 방법
1. 우선 min heap과 max heap을 default 설정으로 두었었는데, 이러면 4분의 1만큼만 사용한다고 한다. 현재 서버 인스턴스는 T2 micro를 사용하기 때문에 가용 메모리가 1GB다. JVM이 heap 혹은 stack 등 다른 메모리를 사용할 것이기도 하고, nginx나 추후 설정할 헬스체크 cron도 있기 때문에 512mb로 주어 처리했다.
2. scale up. 현재 AWS EC2 T2 micro를 사용하고 있는데 더 나은 환경으로 이전하기로 했다.
3. 헬스체크. WS에서 cron tab으로 3분 주기로 WAS의 API를 호출하게 하고, 실패하거나 타임아웃 발생하면 SES로 관리자들에게 이메일을 발송하도록 하고, 자동으로 WAS를 다시 띄우도록 만들었다.
이 로직을 만든 친구가 이 과정에서 백업 로그도 따로 떠서 저장하도록 만들었는데, 파일명에 timestamp를 함께 명시해서 문제 상황에 대해 훨씬 더 잘 추적할 수 있어져 좋은 아이디어라고 생각했다.
느낀 점
1. 외부 API를 사용하는 경우 모킹하자
부하테스트를 수행하는 도중, API를 너무 많이 호출해서 API 키 요청제한에 걸렸다. 생각해 보니 이건 요청하는 측에 대해 큰 비매너인 것 같다는 생각이 들어 외부 client의 경우 그냥 다 interface로 뽑아서 스터빙하기 좋게 만들어야겠다는 생각을 했다.
2. 자바 기본기에 대한 공부 필요성
이 문제를 해결하기 위해 공부하고 조사하다 보니 생각보다 내가 모르는 키워드들이 많았다.
- Jackson이 어떻게 동작하는 지도 잘 모르고 있었다.
- https://d2.naver.com/helloworld/0473330
- 객체의 메모리 레이아웃도 잘 모르고 있었다.
- https://tangoblog.tistory.com/14
- https://yaboong.github.io/java/2018/06/09/java-garbage-collection/
- https://www.baeldung.com/java-memory-layout
- 무작정 gc / heap size를 튜닝한다고 해서 도움이 되는 건 아니라는 걸 배웠다.
- 그래도 예전에 기선님 자바 스터디때 공부했던 내용이 크게 도움이 되었다.
- https://hexagonal-boot-acb.notion.site/1-JVM-e5fa82d079a34ef38c6b116dfc550089?pvs=4
- https://www.youtube.com/watch?v=w4fWgLgop5U
- 생각보다 메모리 누수 발생 포인트가 별거 아닐 수도 있다는 걸 배웠다.
- https://techblog.woowahan.com/2628/
- 백엔드 개발자들이 왜 HW / OS에 대해 이해해야 하는지에 대한 중요성을 배웠다.
- https://blog.yevgnenll.me/posts/jvm-hardware-operating-system
기본기가 참 중요한 듯 하다... 요즘 느끼는 점인데 문제 발생시 기본기가 부족하면 기본적으로 문제가 발생하는 곳을 파악하기가 힘들고, 파악하더라도 해결하는데 어려움을 겪을 수밖에 없는 것 같다. 하루에 1시간 정도씩은 기본기를 쌓는 시간을 가져야겠다는 생각이 든다.
'개발일기' 카테고리의 다른 글
static 영역은 동적 로딩 과정에서 초기화되지 않는다 (1) | 2023.11.01 |
---|---|
InnoDB에서 clustred-index 기반 COUNT 쿼리의 Disk I/O 줄이기 (1) | 2023.10.16 |
WAN에서 한 private network에 접근하는 방법 (0) | 2023.09.18 |
Isolation Level과 MVCC (1) | 2023.09.16 |
알라딘 최저가 계산기 회고 (1) | 2023.09.13 |
소중한 공감 감사합니다