들어가며 링크 복사
안녕하세요. 저는 카카오모빌리티에서 카카오내비를 개발하고 있는 개발자 카딘입니다. 현재 카카오내비의 백엔드 서버를 담당하고 있습니다.
카카오내비는 카카오모빌리티 서비스에서 길 안내를 담당하며, 대규모 트래픽을 처리하는 핵심 서비스로 자리 잡았습니다. 길 안내는 모빌리티 서비스의 핵심 기능인 만큼, 안정적인 운영을 위한 다양한 노력을 구상하고 있습니다.
이 글에서는 카카오내비 서비스를 안정적으로 제공하기 위해 어떤 방법을 적용하고, 그 과정에서 고민했던 점들을 공유하려고 합니다.
안정적인 서비스 제공하기 1. 응답 값 작게 만들기 링크 복사
내비 서비스 사용자는 주로 모바일 환경에서 서버와 응답을 주고받습니다. 모바일 네트워크는 대역폭이 제한적이고, 불안정한 경우가 많으며, 배터리 소모 또한 중요한 고려 요소입니다. 따라서, 서버와 스마트폰 간 주고받는 데이터 크기가 작을수록 네트워크 비용을 절약하고, 더 안정적인 서비스를 제공할 수 있어 사용자 경험을 향상시킬 수 있습니다.
커스텀 바이너리 형식 직렬화 적용 링크 복사
경로 안내 시 카카오내비 서버의 응답 데이터 크기는 경로에 따라 수십 KB에서 수 MB까지 다양합니다. 이렇게 큰 데이터를 클라이언트로 전송할 때, JSON 형식은 데이터 크기 자체의 부담과 함께 높은 파싱 비용이라는 문제가 발생합니다. 이는 서비스 성능에 병목점으로 작용했습니다.
이 문제를 해결하기 위해, 저희는 JSON을 대체할 커스텀 바이너리 포맷을 설계하고 직렬화/역직렬화 로직을 구현했습니다. 그 결과, JSON 대비 응답 크기를 크게 줄이고 클라이언트의 데이터 처리 부담 또한 낮출 수 있었습니다.
이해를 돕기 위해, 저희가 정의한 커스텀 바이너리 형식이 기존 JSON 데이터와 비교했을 때 실제 데이터상에서 어떤 모습으로 표현되고 크기 차이는 어느 정도인지 예시를 통해 먼저 보여드리겠습니다. 다음은 카카오모빌리티 디벨로퍼스에 있는 길찾기 API의 응답 예시 중 일부를 JSON 형식으로 나타낸 것과, 동일한 데이터를 커스텀 바이너리로 직렬화하여 16진수로 표현한 예시입니다.
{
"name": "문형산안길1번길",
"distance": 85,
"duration": 19,
"traffic_speed": 16.0,
"traffic_state": 0,
"vertexes": [
127.17352998262038, 37.36708073181059,
127.17357513799075, 37.36708104732421,
127.17372149772216, 37.367118113235804,
127.1739231164781, 37.36726369503417,
127.17434992149448, 37.36746491368941
]
}
// 데이터 크기: 367 Byte
{
"name": "문형산안길1번길",
"distance": 85,
"duration": 19,
"traffic_speed": 16.0,
"traffic_state": 0,
"vertexes": [
127.17352998262038, 37.36708073181059,
127.17357513799075, 37.36708104732421,
127.17372149772216, 37.367118113235804,
127.1739231164781, 37.36726369503417,
127.17434992149448, 37.36746491368941
]
}
// 데이터 크기: 367 Byte
0016ebacb8ed9895ec82b0ec9588eab8b831ebb288eab8b800000055000000134030000000000000000000000000000a405fcae9df9fa2484042afbc4deece81405fcaea2cd7d6cf4042afbc509cb036405fcaee24d109dd4042afbd64cedc26405fcaf0ccf4d3114042afbf2fefe447405fcaf5b60749f54042afc1cd516025
// 데이터 크기: 128 Byte
0016ebacb8ed9895ec82b0ec9588eab8b831ebb288eab8b800000055000000134030000000000000000000000000000a405fcae9df9fa2484042afbc4deece81405fcaea2cd7d6cf4042afbc509cb036405fcaee24d109dd4042afbd64cedc26405fcaf0ccf4d3114042afbf2fefe447405fcaf5b60749f54042afc1cd516025
// 데이터 크기: 128 Byte
위 예시에서 볼 수 있듯이, 텍스트 기반의 JSON 형식은 사람이 읽기에는 편리하지만, 키 이름이 반복될 수 있고 구조를 표현하기 위한 문자들로 인해 데이터 크기가 상대적으로 큽니다. 반면 바이너리 형식은 미리 약속된 형식에 따라 데이터를 바이너리로 표현하므로 훨씬 압축적인 것을 확인할 수 있습니다.
커스텀 바이너리를 사용했을 때 응답 크기가 얼마나 줄어드는지 확인하기 위해, 실제 내비 서비스에서 호출된 경로 탐색 응답 100건을 샘플링하여, JSON 형식과 바이너리 형식의 데이터 크기를 비교해 보았습니다.
| 직선 거리(m) | JSON 크기(KB) | Byte stream 크기(KB) | 압축 후 크기(%) |
|---|---|---|---|
| 1,000 | 31.75 | 8.22 | 25.89 |
| 5,000 | 40.53 | 9.09 | 22.43 |
| 10,000 | 214.32 | 42.81 | 19.97 |
| 68,500 | 493.71 | 112.52 | 22.79 |
| 191,000 | 1069.87 | 242.91 | 22.70 |
샘플링된 데이터에서 바이너리 형식은 JSON 대비 평균 77.5%의 데이터 크기 감소 효과를 보였습니다. 이로 인해 네트워크 전송량이 줄어들어 지연 시간이 단축되고, 사용자 단말의 배터리 소모가 감소하며, 데이터 사용량 절약 등의 긍정적인 효과를 얻을 수 있었습니다. 특히 사용자가 한 번 주행할 때에도 경로 탐색 API를 여러 차례 호출하는 경우가 많아, 바이너리 형식의 적용으로 얻는 효과는 더욱 컸습니다. 또한 클라이언트-서버 간 통신뿐만 아니라 서버 간 통신에서도 바이너리를 적용함으로써, 서버 부하 감소와 전체적인 시스템 안정성 향상까지 기대할 수 있었습니다.
바이너리 형식의 단점과 대응 방안 링크 복사
바이너리 형식으로 변환함으로써 생기는 단점에는 어떤 게 있을까요?
첫째, 사람이 읽기 어려운 형식이기 때문에 API 응답을 디버깅하기 어렵습니다. 클라이언트에서 받은 바이너리만으로는 문제 파악이 어려워, 경로 응답을 디버깅할 때 백엔드 서버와의 협업이 필수적이었습니다. 이를 해결하기 위해 JSON 응답을 제공하는 디버깅 옵션과 바이너리를 디코딩하는 별도의 API를 추가 제공했습니다.
둘째, 바이너리 형식 업데이트 시 해당 형식을 사용하는 서버와 클라이언트 모두 변경이 필요하다는 점입니다. 현재는 사내 라이브러리를 만들어 배포하는 방식으로 사용하고 있습니다.
안정적인 서비스 제공하기 2. 웜업(Warm-up) 수행하기 링크 복사
API 응답 크기를 줄이는 것뿐만 아니라, 서버의 응답 시간(response time) 자체를 최소화하는 것도 사용자 경험에 큰 영향을 미치는 요소입니다. 특히, 대용량 트래픽을 처리하는 서버에서는 응답 속도가 서비스의 안정성을 좌우할 수 있습니다.
응답 시간이 길어지면 다음과 같은 문제가 발생할 수 있습니다.
- 요청이 서버 리소스를 더 오래 점유함
- 동시에 처리 가능한 요청 수 감소
- 트래픽 급증 시 서버 과부하 위험 증가
특히 카카오내비 서비스는 경로 탐색 API 호출뿐만 아니라 추가로 필요한 인증, 로깅, 위치 업데이트 등 여러 API를 매일 대규모로 호출하고 있습니다. 각 API의 응답 지연이 누적되면 최종 응답 시간이 급격히 증가할 수 있습니다.
경로 탐색의 기본 흐름 링크 복사
내비 사용자가 경로 탐색 응답을 받기까지 서버의 전체적인 흐름은 다음과 같습니다.
내비 백엔드 서버는 사용자로부터 출발지, 도착지 좌표 등의 요청 정보를 받아 별도의 경로 탐색 엔진 서버에 전달합니다. 그리고 엔진 서버에서 오는 응답을 가공하여 사용자에게 전달하는 역할을 합니다.
문제: 초기 응답 지연 현상 링크 복사
서버 개발자에게 모니터링은 필수적인 업무입니다. 여느 때와 다름없이 서버를 모니터링하던 중, 간헐적으로 응답 시간이 증가하는 현상을 확인하였습니다. 이에 대한 원인을 분석한 결과, 서버 재시작 시 일시적인 지연이 발생하고 있었으며, 특히 DB와의 통신 또는 외부 API를 호출하는 과정에서 응답 지연이 더욱 심화되는 것을 확인하였습니다.
초기 지연의 원인 분석 링크 복사
그렇다면 DB와 통신하는 로직이 있을 때, 왜 처음에만 일시적으로 응답 시간이 길어지고 이후부터는 짧아지는 걸까요?
서버는 DB와의 통신 성능을 높이기 위해 커넥션 풀(Connection Pool)을 사용합니다. 서버가 처음 시작된 직후, 첫 번째 요청이 들어오면 새로운 커넥션을 생성해야 하므로 응답 시간이 길어질 수 있습니다. 하지만, 생성된 커넥션이 커넥션 풀에 누적되면서 이후 요청부터는 기존 커넥션을 재사용할 수 있어 응답 시간이 점차 안정화됩니다.
외부 API를 호출할 때도 동일한 현상이 발생합니다. 첫 번째 요청에서는 DNS 조회, 핸드셰이크(handshake) 등 초기 과정에서 추가적인 비용이 발생하기 때문에 응답 시간이 길어질 수 있습니다.
또한 서버는 이러한 초기 지연을 최소화하기 위해 캐시를 활용하지만, 서버가 처음 시작될 때는 캐시가 비어 있어 캐시로 인한 응답 시간 단축 효과를 얻지 못합니다.
이 외에도 Spring bean의 초기 로딩 과정, Jackson의 ObjectMapper를 이용한 JSON 직렬화/역직렬화 과정 등 다양한 요소가 초기 응답 지연에 영향을 미칠 수 있습니다.
해결 방안: 웜업 작업 추가 링크 복사
앞서 언급한 문제를 해결하기 위해, 서버가 트래픽을 받기 전에 미리 DB에 쿼리를 실행하고 외부 API를 호출하여 커넥션 풀을 예열하고 캐시를 로드하는 웜업 작업을 추가했습니다. 이를 통해 서버가 시작된 직후에도 안정적인 성능을 유지하며, 첫 번째 요청부터 최적화된 응답 속도를 제공할 수 있습니다. 웜업을 수행하면 미리 커넥션을 생성하여 풀이 준비되므로, 실제 요청이 들어왔을 때 추가적인 지연 없이 빠르게 응답할 수 있습니다.
스프링 부트(Spring Boot) 환경에서는 이러한 웜업 로직을 매우 간편하게 적용할 수 있는 방법을 제공합니다. 가장 대표적인 방법은 ApplicationRunner 인터페이스를 구현한 컴포넌트를 만드는 것입니다.
이 인터페이스는 스프링 애플리케이션 컨텍스트가 완전히 로드된 후, 그리고 애플리케이션이 외부 요청을 받기 시작하기 전에 실행되는 run 메서드를 가지고 있습니다. 따라서 이 run 메서드 내부에 웜업에 필요한 로직, 예를 들어 특정 DB 쿼리를 여러 번 호출하거나, 주요 외부 API를 호출하여 초기 연결을 설정하고 관련 데이터를 캐시에 미리 로드하는 등의 작업을 구현하면 됩니다.
@Component
public class AppWarmUpRunner implements ApplicationRunner {
...
@Override
public void run(ApplicationArguments args) throws Exception {
log.info("Warm up Start");
// 데이터베이스 커넥션 풀 예열 및 간단한 쿼리 실행
// 캐시 데이터 예열 (자주 사용하는 데이터 로드)
// 외부 API 호출 테스트
// 주요 로직 실행으로 JIT 컴파일 유도
log.info("Warmup finished");
}
}
@Component
public class AppWarmUpRunner implements ApplicationRunner {
...
@Override
public void run(ApplicationArguments args) throws Exception {
log.info("Warm up Start");
// 데이터베이스 커넥션 풀 예열 및 간단한 쿼리 실행
// 캐시 데이터 예열 (자주 사용하는 데이터 로드)
// 외부 API 호출 테스트
// 주요 로직 실행으로 JIT 컴파일 유도
log.info("Warmup finished");
}
}
이처럼 스프링 부트의 ApplicationRunner를 활용하면, 복잡한 설정 없이 애플리케이션 생명 주기(lifecycle)의 적절한 시점에 웜업 코드를 손쉽게 통합할 수 있습니다.
웜업 작업을 추가한 결과, 아래 그래프에서 확인할 수 있듯이, 호출을 반복할수록 API의 응답 지연 시간이 현저히 감소하는 효과를 확인할 수 있었습니다.
첫 번째 호출에서는 1,000ms 이상의 응답 시간이 소요되었지만, API를 반복 호출할수록 응답 시간이 급격히 감소하여 6번째 시도에서는 172ms로 80% 이상 단축되었습니다.
웜업이 실제 트래픽을 받을 때 응답 시간에 미치는 영향을 확인하기 위해, 100 QPS 환경에서 부하 테스트를 진행하였습니다.
아래 그래프는 웜업을 수행한 경우와 수행하지 않은 경우의 응답시간 차이를 나타냅니다.
웜업을 수행한 경우, 트래픽을 받기 시작하는 순간부터 50ms 이하의 안정적인 응답 시간을 기록했습니다. 반면, 웜업 없이 테스트를 진행한 경우 초기 응답 시간이 70~100ms로 높게 측정되었으며, 시간이 지남에 따라 점진적으로 감소하는 패턴을 보였습니다. 이는 테스트가 진행될수록 DB 커넥션 풀이 채워지고, 캐시가 로드되면서 응답 시간이 점차 안정화되었기 때문으로 분석됩니다.
또한, 두 경우 모두 테스트가 진행될수록 응답 시간이 점차 감소하는 경향을 보였는데요. 이는 JVM의 JIT(Just-In-Time) 컴파일러가 반복 실행되는 코드를 최적화된 기계어 코드로 변환하면서 성능이 향상되었기 때문으로 해석됩니다.
그렇다면, 모든 서버에서 웜업을 수행하는 것이 항상 최선의 선택일까요?
웜업은 초기 트래픽 처리 성능을 향상시키는 데 효과적이지만, 모든 환경에서 항상 유리한 것은 아닙니다. 서비스 특성과 운영 방식에 따라 웜업이 필요한 경우도, 그렇지 않은 경우도 존재합니다.
웜업을 적용할 때 고려해야 할 사항은 다음과 같습니다.
- 웜업 작업이 과도하게 무거우면, 배포 시 오히려 서버 부하를 증가시킬 수 있음
- 트래픽 패턴이 불규칙한 서비스에서는 웜업의 효과가 제한적일 수 있음
- DB 커넥션 풀, JIT 최적화, 캐시 로딩 등 다양한 요인이 초기 응답 속도에 영향을 미침
따라서, 서비스 특성과 운영 방식에 맞는 웜업 전략을 신중하게 설계하는 것이 중요합니다.
지금까지 우리는 클라이언트와 서버 간의 통신 최적화를 통해 더 빠르고 효율적인 응답을 제공하는 방법을 살펴보았습니다. 응답 크기를 줄여 네트워크 비용을 절감하고, 웜업을 통해 초기 응답 속도를 개선하는 방식은 사용자 경험과 서비스 성능에 직접적인 영향을 미치는 핵심 요소입니다.
하지만 안정적인 서비스 운영을 위해서는 단순히 응답 속도 최적화에 그치지 않고, 서버 내부의 데이터 처리 및 관리 방식 또한 효율적으로 개선할 필요가 있습니다. 특히, 서비스 규모가 커질수록 효율적인 로그 관리는 필수적이며, 적절한 저장소를 선택하는 것이 서비스 운영의 복잡성을 줄이고 안정성을 확보하는 데 중요한 역할을 합니다.
이제, 서비스의 건강 상태를 지속적으로 확인하고 문제 발생 시 빠르게 대응하기 위한 모니터링의 중요성과 카카오내비에서 적용하고 있는 방식에 대해 이야기해 보겠습니다.
안정적인 서비스 제공하기 3. 지속적인 서비스 상태 모니터링 링크 복사
앞선 챕터에서는 초기 응답 속도를 개선하기 위한 웜업의 중요성에 대해 이야기했습니다. 하지만 안정적인 서비스 운영은 단순히 시작 시점의 최적화만으로는 완성되지 않습니다. 서비스가 실제 트래픽을 처리하는 동안 발생하는 다양한 상황에 신속하게 대응하고, 잠재적인 문제를 미리 감지하기 위해서는 시스템의 상태를 지속적으로 관찰하는 ‘모니터링’이 필수적입니다.
카카오내비와 같이 대규모 트래픽을 처리하는 서비스에서 모니터링은 단순히 시스템이 동작하는지 확인하는 것을 넘어, 서비스 품질을 유지하고 개선하는 데 핵심적인 역할을 합니다. 잘 구축된 모니터링 시스템을 통해 다음과 같은 이점을 얻을 수 있습니다.
- 문제 조기 발견 및 신속한 대응: 시스템 이상 징후나 성능 저하, 특히 예상치 못한 애플리케이션 오류를 Sentry와 같은 도구를 통해 실시간으로 감지하고, Slack이나 카카오톡으로 알림을 받아 장애 발생 초기에 즉각적으로 대응할 수 있습니다. 다만, 모든 알림이 즉각적인 조치를 요구하는 것은 아니므로, 실제 문제 해결에 도움이 되지 않는 불필요한 알림(예시: 보안 모니터링으로 인한 알림, 이미 알려진 이슈 등)은 꾸준히 식별하고 무시(ignore) 처리하여 더욱 중요한 이슈에 집중할 수 있도록 관리합니다.
- 시스템 부하 및 성능 병목 지점 파악: 어떤 부분에서 리소스가 많이 사용되고 응답 시간이 길어지는지 분석하여 최적화 지점을 찾습니다. 특히, 한 번의 사용자 경로 요청이 내부적으로 여러 API 호출과 다수의 서버를 거치는 경우가 많아, 전체 요청 흐름을 추적하기 위해 고유한 식별자를 발급하여 사용하고 있습니다. 이를 통해 복잡하게 얽힌 호출 관계 속에서도 병목 구간을 효과적으로 식별합니다.
- 배포 및 변경 사항의 영향도 측정: 새로운 버전 배포나 설정 변경 후 서비스 상태 변화를 주의 깊게 관찰하여 배포 안정성을 검증합니다.
- 자원 사용량 추이 분석 및 예측: CPU, 메모리, 네트워크 등의 사용량 변화를 추적하여 향후 필요한 자원 규모를 예측하고 대비합니다.
- 사용 빈도가 낮거나 불필요한 API 식별 및 제거 유도: 각 API 엔드포인트의 호출 빈도 및 트래픽 패턴을 분석하여, 거의 사용되지 않는 API를 식별하고 제거를 유도함으로써 불필요한 자원 낭비를 막고 시스템을 효율화합니다.
지속적인 모니터링을 위한 가독성 확보 노력 링크 복사
지속적인 모니터링을 효과적으로 수행하기 위해서는 수집된 데이터를 보기 쉽게 만드는 것이 매우 중요합니다. 복잡하거나 여러 곳에 흩어져있는 데이터는 시스템 상태를 빠르게 파악하는 데 방해가 되며, 특히 장애 상황에서는 신속한 원인 분석을 어렵게 만듭니다. 아무리 많은 데이터를 수집하더라도 그 의미를 빠르게 파악할 수 없다면 모니터링의 효과는 반감될 수밖에 없습니다. 따라서 모니터링 데이터의 가독성과 접근성을 높이는 것은 필수적입니다.
이를 위해 저희는 다음과 같은 노력을 기울이고 있습니다.
- 핵심 지표 중심의 대시보드 구성: 서비스 상태를 한눈에 파악할 수 있도록 지연 시간, 에러율 등 가장 중요한 지표들을 선별하여 직관적인 대시보드를 구성합니다.
- 로깅 데이터 표준화: 로그 발생 시 일관된 스키마를 따르도록 하여 여러 서버 및 시스템 로그 간의 통일성을 확보하고 분석을 용이하게 합니다.
- 지속적인 대시보드 개선 및 관리: 팀 피드백과 실제 운영 경험을 바탕으로 대시보드를 지속적으로 개선하고, 변화하는 서비스 요구사항에 맞춰 모니터링 항목을 업데이트하며 가독성을 유지합니다.
이러한 모니터링 데이터를 효과적으로 수집, 분석하기 위해 저희는 다양한 내부 시스템과 Prometheus, Grafana, Kibana 등의 오픈소스 도구들을 적극 활용하고 있습니다. 이를 통해 서비스 상태를 한눈에 파악하고 이상 상황 발생 시 즉각적으로 대처할 수 있는 체계를 갖추었습니다.
결국, 꾸준한 모니터링과 그 결과를 쉽게 파악하려는 노력은 우리가 적용한 최적화가 효과를 내고 있는지 검증하고, 예기치 못한 문제에 선제적으로 대응하며, 시스템을 효율적으로 유지 관리하는 핵심 활동입니다. 이는 서비스 안정성의 기반이 됩니다.
마치며 링크 복사
이러한 과정을 통해 우리는 단순한 경로 탐색 성능의 향상을 넘어, 서비스의 안정성과 확장성을 개선하는 다양한 방안을 모색할 수 있었습니다. 특히, API 응답 최적화, 웜업 작업 도입, 그리고 지속적인 모니터링 체계 구축을 통해 작은 변화와 꾸준한 관찰이 사용자 경험과 전체 시스템 운영에 얼마나 큰 영향을 미치는지 다시 한번 실감했습니다.
안정적인 서비스 제공을 위해서는 지속적인 모니터링과 꾸준한 개선 노력이 필수적입니다. 한 번의 최적화로 끝나는 것이 아니라, 시스템의 변화에 발맞춰 지속적으로 점검하고 보완하는 것이 중요하다는 것을 다시금 깨달았습니다. 앞으로도 이러한 고민을 바탕으로 더 나은 서비스를 제공하기 위해 노력하겠습니다. 감사합니다.