1. 서론
이번에는 예외 모니터링 기술을 활용하여 운영 과정에서 발생한 문제를 빠르게 해결한 경험과 서버 리소스 및 성능 모니터링 기술을 활용하여 개선점을 찾아낸 경험을 공유하고자 한다!
2. 서버 장애에 신속히 대응하기 위해: Sentry
작년 10월, 서비스 배포 직후 Sentry로부터 서버에서 500 에러가 발생했다는 알림을 받았다.
- API End-point 및 사용자의 위치정보는 일부 가렸습니다!
개발 과정에서 충분히 케이스를 세분화하여 테스트를 했다고 생각했는데,,
정말 예상치 못한 케이스가 있었다!
사진에서 표시된 위도와 경도 값을 보면, ‘해외에서 전달된’ 요청을 처리하는 과정에서 문제가 발생했음을 알 수 있다. 한류에 관심있는 외국인들이 많은 것 같아서 감사하다. 🫡
에러의 가장 근본적인 원인인 calculateDistance() 메소드를 살펴보자.
public String calculateDistance(String distance) { // 거리에 따라 km, m로 변환
int meter = Integer.parseInt(distance);
if (meter >= 1000) {
return String.format("%.1fkm", meter / 1000.0);
} else {
return meter + "m";
}
}
즉, 이 메소드의 파라미터로 빈문자열이 전달된 것이 문제의 원인임을 알 수 있다.
위 로직은 ‘사용자의 위치’를 외부 API에 전송하여 얻은 응답값에 적용된다. 즉, 외부 API 에서 해외에서 전송된 요청일 경우 distance 필드를 빈 문자열로 응답했기 때문에 발생하는 문제였다.
직접 테스트를 해보니 아래와 같은 응답을 받았다.
{
"address_name": "...",
" . . . 일부 생략 . . .",
"distance": "", # 빈 문자열 응답
" . . . 일부 생략 . . .",
}
또한, 사용자의 피드백을 바탕으로 나의 분석을 확신할 수 있었다!
해결방안은 다들 예상하시겠지만, 해당 케이스를 고려한 로직을 추가하여 운영환경에 반영하는 것으로 매우 간단하다.
결론보다는 과정에 의미가 있는 경험이라고 생각한다.
또한, Third party API 를 사용할때는 케이스를 조금 더 세분화 할 필요가 있음을 느끼게 되었다!
3. 리소스 최적화를 위해: Prometheus & Grafana
주기적으로 데이터를 동기화하여 데이터베이스에 저장하는 기능이 있다.
이 기능의 서버 리소스 사용 추이를 파악하기 위해, 장시간 실행시켜보았고 ‘JVM 힙 영역’을 눈여겨보게 되었다.
JVM Eden 영역 ( 개선 전 )
이 그래프로부터 아래와 같은 결론을 도출했다.
- 약 100MB인 Eden의 크기에 비해, 한번의 GC에서 너무 적은 메모리를 수거한다. (약 40MB)
- GC의 주기는 약 4초로, 워크로드에 비해 빠른 편에 속하는 것 같다.
이 두가지 근거를 바탕으로 ‘객체를 빠르게 생성하는’ 코드의 존재 여부를 확인을 했고, 아래와 같은 원인을 파악할 수 있었다!
class TourTimeUtil {
public static LocalDate parseLocalDate(String date) {
if (date == null || date.isEmpty()) {
return null;
}
return LocalDate.parse(date,
DateTimeFormatter.ofPattern("yyyyMMdd"));
}
}
class DateTimeFormatter{
// ... 중략
public static DateTimeFormatter ofPattern(String pattern) {
return new DateTimeFormatterBuilder()
.appendPattern(pattern)
.toFormatter();
}
}
해결 방안은 매우 간단하다. 아래와 같이 객체를 static으로 선언하면 된다.
class TourTimeUtil {
private static final DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyyMMdd");
public static LocalDate parseLocalDate(String date) {
if (date == null || date.isEmpty()) {
return null;
}
return LocalDate.parse(date, dateFormatter);
}
이제 다시 메모리 사용 현황을 살펴보자.
JVM Eden 영역 ( 개선 후 )
기존에 비해 GC 주기가 늘어났으며, 메모리 수거 양이 약 두 배 정도 증가했음을 알 수 있다!
성능지표 정리
항목 | 개선 전 | 개선 후 |
GC 실행 주기 | 약 4초 | 약 9초 |
GC 실행 시점의 메모리 사용량 | 약 40MB | 약 80MB |
GC 실행 후 회수된 메모리 | 약 40MB | 약 80MB |
그런데 아직 의문이 하나 남아있다!
개선전에는 왜 Eden 영역이 약 40MB까지 차게되면 GC가 실행됐을까?
Eden 영역의 크기는 변함 없는데, 동일하게 80MB까지 더 채운후 GC를 수행해도 되지 않았을까?
GC는 객체를 할당할때 이용할 수 있는 메모리가 충분하지 않으면 실행되는거 아니었나 . . . ?
이에 대한 근거를 찾고자 여러 도서를 참고하였고, 아래와 같이 정리해보았다.
"JVM 밑바닥까지 파헤치기" ( 142페이지 참고 )
G1 을 시작으로 최신 가비지 컬렉터 대다수는 자바 힙 전체를 한 번에 청소하는 대신 , 애플리케이션의 메모리 할당 속도에 맞춰 회수하는 방향으로 변화했다. 애플리케이션은 쓰레기를 버리고 동시에 컬렉터는 청소를 한다. 객체가 버려지는 속도를 컬렉터가 따라갈 수만 있다면 모든 것이 완벽하게 동작하는 모델이다.
G1 가비지 컬렉터는 JVM 9부터 디폴트 GC로 사용되며, 해당 프로젝트의 JVM도 가비지 컬렉터로 G1을 사용함을 확인하였다. 아래 명령어를 통해 확인할 수 있다.
java -XX:+PrintCommandLineFlags -version
즉, 개선 후에는 Eden 영역에서 객체가 생성되는 속도가 느려지면서 가비지가 더 오래 쌓이게 되었고, 이에 따라 GC 실행 빈도가 줄어들며 한 번의 GC에서 더 많은 메모리를 회수하는 효과가 나타난 것이었다.
물론, Minor GC가 자주 발생하는 것은 성능에 큰 영향을 미치지 않는다. 그럼에도, 개선점을 도출해냈다는 것에 의의를 두고싶다!
추후에 개선점을 발견한다면 보완해보겠습니다.
4. 후기
백엔드 개발자의 핵심 역량 중 하나는 서버를 안정적이고 효율적으로 운영하는 것이라고 생각한다. 예외 모니터링 기술을 활용하여 문제를 빠르게 대응하고, 서버 리소스 모니터링을 통해 개선점을 찾아내며 한층 성장할 수 있었다 👍
'Backend > Kori' 카테고리의 다른 글
Batch Insert 를 통해 JPA의 한계를 극복하다 (Spring JDBC ) (1) | 2025.01.31 |
---|---|
멀티스레드와 스레드 풀을 활용한 축제 업데이트 성능 개선 (0) | 2025.01.13 |