Search

[성능 최적화 시리즈 #2] 캐시와 메모리

생성일
2025/06/10
URL
안녕하세요, 장동호입니다!
지난 시리즈에 이어 백엔드 개발자가 꼭 알고 있어야 할 성능 개념들을 정리해보려고 합니다.
오늘은 서버 캐시와 메모리에 대해 이야기해보려고 합니다.

캐시

응답 시간을 줄이고 처리량을 높이기 위해 DB 서버를 수직 확장하거나 수평 확장할 수 있습니다. 하지만 DB 서버를 확장하려면 비용이 많이 듭니다. 또한 DB 서버를 수평 확장하더라도 처리량은 늘릴 수 있지만 실행 시간이 획기적으로 줄어들지는 않습니다. 왜냐하면, DB에 접근하는 데에는 네트워크 I/O와 디스크 I/O가 반드시 발생하기 때문입니다.
DB 서버를 확장하지 않고도 응답 시간과 처리량을 개선하고 싶다면 캐시(cache) 사용을 고려할 수 있습니다. 캐시는 일종의 (키, 값) 쌍을 저장하는 Map과 같은 형태의 데이터 저장소입니다. 캐시에 데이터를 저장해두면 동일한 데이터를 요청할 때 DB가 아닌 캐시에서 데이터를 읽어와 응답할 수 있습니다. 데이터 특성에 따라 차이는 있지만 일반적으로 캐시에서 데이터를 읽는 속도가 DB보다 빠르기 때문에 자주 조회되는 데이터를 캐시에 보관하면 응답 시간을 줄일 수 있습니다.
캐시의 동작 방식
캐시의 동작 방식은 위 그림과 같습니다.
1.
캐시에서 키에 해당하는 값을 조회합니다.
2.
값이 존재하면 바로 사용합니다.
3.
존재하지 않으면 DB 쿼리를 실행해 값을 조회한 후, 해당 값을 캐시에 저장하고 사용합니다.
4.
이후 동일한 요청이 들어오면 캐시에 저장된 값을 바로 사용합니다.
캐시에서 데이터를 읽어오면 DB와의 연동 시간이 줄어 응답 시간이 단축됩니다.
DB뿐만 아니라 복잡한 계산 결과나 외부 API 연동 결과도 캐시에 보관하여 응답 시간을 줄이는 데 활용할 수 있습니다.
키 선택
캐시는 (키, 값) 형태로 데이터를 저장하는 대상에 따라 적절한 캐시 키를 선택해야 합니다. 예를 들어 게시글 상세 정보는 “articles:번호” 형태의 캐시 키를 사용하고 최신 인기 글은 “articles:hot10”을 키로 사용하는 식입니다. 또한 캐시를 사용할 때는 캐시 키가 겹치지 않도록 주의해야 합니다.

캐시 적중률

캐시가 얼마나 효율적으로 사용되는지는 적중률(hit rate)로 판단할 수 있습니다.
적중률(hit rate) = 캐시에 존재한 건수/캐시에서 조회를 시도한 건수
캐시에서 데이터를 100번 조회했는데 그중 87번은 해당 데이터가 존재한다고 가정해보겠습니다. 이때 캐시 적중률은 0.87, 즉 87%가 됩니다. 적중률이 높을수록 DB와의 연동이 줄어들고 곧 응답 시간 감소, 처리량 증가, DB 부하 감소로 이어집니다.
적중률을 높이는 가장 간단한 방법은 캐시에 최대한 많은 데이터를 저장하는 것입니다. 예를 들어 총 5,000개의 상품이 존재할 때 캐시에 100개의 상품 정보를 저장하는 것보다 4,500개의 상품 정보를 저장하는 편이 적중률이 높아질 것입니다. 5,000개의 상품 정보 전체를 모두 캐시에 저장하면 이론적으로 캐시 적중률은 100%가 됩니다.

캐시 삭제 규칙

하지만 캐시에 모든 데이터를 무작정 저장할 수는 없습니다. 캐시는 메모리 자원을 사용하기 때문입니다. 사용할 수 있는 메모리의 용량은 한계가 있기 때문에 캐시에 저장할 수 있는 데이터 개수나 크기도 제한됩니다. 따라서 캐시가 가득 차 있는 상태에서 새로운 데이터를 캐시에 저장하면 기존에 있는 데이터 중 하나를 제거해야 합니다. 삭제할 대상을 선택할 때 주로 사용하는 규칙은 다음과 같습니다.
LRU(Least Recently Used): 가장 오래전에 사용된 데이터를 제거합니다.
LFU(Least Frequently Used): 가장 적게 사용된 데이터를 유지합니다.
FIFO(First In First Out): 먼저 추가된 데이터를 먼저 삭제합니다.
많은 서비스에서는 오래된 데이터보다 최신 데이터를 더 자주 조회하는 경향이 있습니다. 따라서 캐시가 가득 차 있지 않더라도 오래된 데이터는 미리 삭제하는 것이 좋습니다. 이를 위해 캐시에는 유효 시간(만료 시간)을 설정하는 방식도 함께 사용합니다. 일정 시간이 지나면 캐시에서 해당 데이터를 자동으로 제거하여 메모리를 효율적으로 관리할 수 있습니다.

로컬 캐시와 리모트 캐시

로컬 캐시(왼쪽)과 리모트 캐시(오른쪽)
서버가 사용하는 캐시에는 크게 두 종료가 있습니다.
로컬(local) 캐시: 서버 프로세스와 동일한 메모리를 캐시 저장소로 사용합니다.
리모트(remote) 캐시: 별도 프로세스를 캐시 저장소로 사용합니다.

로컬 캐시

구현 기술: Caffeine(자바), go-cache(Go), node-cache(Node.js)
장점
서버 프로세스와 캐시가 동일한 메모리 공간을 사용하므로 캐시 데이터에 빠르게 접근할 수 있습니다.
별도의 외부 연동이 필요하지 않아 구조를 단순하게 유지할 수 있습니다.
단점
저장할 수 있는 데이터 크기가 제한됩니다.
서버 프로세스를 재시작하면 메모리에 존재하던 캐시 데이터가 모두 삭제되어 일시적으로 캐시 적중률이 순간적으로 떨어질 수 있습니다.
캐시에 보관할 데이터 규모가 작고 변경 빈도가 매우 낮은 서비스에 적합 (ex. 홈 화면에 표시할 최신 공지글 목록)

리모트 캐시

구현 기술: Redis
장점
캐시 크기를 유연하게 확장할 수 있습니다. (수평 확장)
서버 프로세스가 재시작되더라도 저장된 캐시 데이터는 그대로 유지됩니다.
단점
서버 프로세스 캐시 프로세스 사이에 네트워크 통신이 필요하므로 속도가 상대적으로 느립니다.
별도의 서버 장비와 프로세스가 필요하기 때문에 시스템 구조가 복잡해집니다.
캐시에 보관할 데이터가 규모가 큰 서비스에 적합 배포 빈도가 높은 서비스(하루에 몇 번씩 배포)에 적합 (ex. 대형 쇼핑 사이트 개별 제품 정보)

캐시 사전 적재

트래픽이 순간적으로 급증하는 패턴을 보인다면 캐시에 데이터를 미리 저장하는 것도 고려할 필요가 있습니다.
다음은 캐시에 미리 데이터를 저장하면 큰 효과를 볼 수 있는 가상의 사례입니다.
N 앱 사용자는 300만 명입니다.
N 앱 서비스는 사용자에게 매달 정해진 날에 이달의 요금 정보를 보여줍니다.
해당 일자가 되면 전체 회원을 대상으로 요금 안내 푸시 알림을 발송합니다.
푸시를 받은 사용자 중 일부는 N 앱을 통해 이달의 요금 정보를 조회합니다.
이 사례에서 푸시 알림을 받은 사용자는 N 앱을 실행해 즉시 요금 정보를 조회합니다. 푸시를 받자마자 50%의 사용자가 바로 확인한다면, 단시간에 150만 명이 동시에 접속하게 됩니다. 이때 요금 정보에 대한 캐시 적중률은 순간적으로 0%에 가까워질 수 있습니다. 왜냐하면 아직 사용자의 개별 요금 정보가 캐시에 저장되어 있지 않기 때문입니다. 사용자가 한 번이라도 이달의 요금 정보를 조회해야만 캐시에 저장되므로 푸시 알림 직후에 처음으로 요금 정보를 조회하는 시점에는 캐시에 데이터가 존재하지 않습니다.
캐시 적중률이 낮아지면 전체 응답 시간이 느려질 뿐만 아니라 DB에 전달되는 부하도 급격히 증가합니다. 이런 상황을 방지하는 방법 중 하나는 캐시에 데이터를 미리 넣어두는 것입니다. 300만 명의 사용자에게 푸시 알림을 보내기 전에 각 사용자의 요금 정보를 캐시에 저장해두면 푸시를 받은 사용자가 한꺼번에 몰려올 때도 캐시 적중률을 99%에 가깝에 유지할 수 있습니다. 이를 통해 순간적으로 트래픽이 몰렸을 때도 응답 시간을 안정적으로 유지할 수 있으며, DB에 부하가 집중되는 현상도 효과적으로 방지할 수 있습니다.

캐시 무효화

캐시를 사용할 때 반드시 신경 써야 할 점은 유효하지 않은 데이터를 적절한 시점에 캐시에서 삭제하는 것입니다. 캐시에 보관된 데이터의 원본이 바뀌면, 그에 맞춰 캐시에 보관된 데이터도 함께 변경하거나 삭제해야 합니다. 원본이 변경됐는데 캐시에 저장된 데이터가 갱신되지 않으면 사용자는 오래된 잘못된 정보를 확인하게 되는 문제가 발생할 수 있습니다.
캐시에 저장된 데이터의 특성에 따라 캐시를 무효화하는 시점을 달리 설정해야 합니다. 가격 정보, 게시글 내용처럼 민감한 데이터는 변경되는 즉시 캐시를 무효화해야 합니다. 게시글 내용을 수정했는데도 캐시가 그대로 유지되면 사용자는 수정 전 게시글 내용을 보게 되어 혼란을 겪을 수 있습니다.
변경에 민감한 데이터는 로컬 캐시가 아닌 리모트 캐시에 보관해야 합니다. 로컬 캐시는 자신의 데이터만 변경하지 다른 서버의 로컬 캐시는 변경하지 않기 때문입니다. A 서버에 연결한 사용자는 변경된 가격 정보를 보지만 B 서버에 연결한 사용자는 B 서버의 로컬 캐시에 보관된 변경 전 가격 정보를 보게 되는 문제가 발생할 수 있어 서비스 신뢰성에 큰 영향을 줄 수 있습니다.
변경에 민감하지 않고 데이터 크기가 작다면 캐시의 유효 시간을 설정하여 주기적으로 갱신하는 방식을 사용해도 됩니다. 예를 들어 최근 인기 글 목록을 캐시에 저장한 경우, 최근 인기 글 목록이 바뀌고 몇 분 뒤에 캐시 데이터가 변경되더라도 서비스에 심각한 문제는 일어나지 않습니다. 인기 글 목록을 저장하는 캐시의 유효 시간을 10분으로 지정하면 10분 주기로 최신 인기 게시글 목록을 갱신하는 효과를 얻을 수 있습니다.

가비지 컬렉터

자바, 파이썬, 자바스크립트 등은 가비지 컬렉터(GC)를 사용하여 메모리를 자동으로 관리합니다. GC는 사용이 끝난 객체를 바로 삭제하지 않고, 일정 조건에 따라 사용하지 않는 객체를 찾아 메모리를 회수합니다. 예를 들어 힙 메모리 사용량이 일정 수준을 넘거나, 일정 주기가 지나면 GC가 실행됩니다.
GC의 가장 큰 장점은 개발자가 메모리를 직접 해제하지 않아도 된다는 점입니다. 이는 개발 부담을 줄이고, 메모리 해제 실수로 인한 보안 문제도 예방할 수 있습니다. 그러나 GC는 실행 중 애플리케이션의 성능에 영향을 줄 수 있으며, 자바의 경우 GC가 실행되는 동안 애플리케이션이 일시 중단되기도 합니다.
GC 시간은 메모리 사용량과 객체 수에 비례합니다. 메모리를 많이 사용하거나 객체가 많으면, GC가 불필요한 객체를 탐지하고 제거하는 데 더 많은 시간이 걸립니다. 따라서 메모리 사용량을 줄이면 GC 시간도 단축될 가능성이 높습니다.
예를 들어, JVM의 최대 힙 크기를 4GB에서 2GB로 줄이면, GC가 검사할 객체 수가 줄어 GC 시간이 단축될 수 있습니다. 단, 실제 애플리케이션이 4GB에 가까운 메모리를 요구한다면 2GB로는 메모리 부족 오류가 발생할 수 있으므로, 최대 힙 크기는 실제 사용 패턴에 맞게 설정해야 합니다.
또한 한 번에 많은 객체를 생성하는 것도 주의해야 합니다. 예를 들어, 콘텐츠 조회 API가 한 번에 10만 개의 게시글을 반환하고, 게시글 하나가 0.5KB의 메모리를 사용한다면 총 50MB가 필요합니다. 만약 100명이 동시에 요청하면 약 4.9GB의 메모리가 소모되며, 시스템의 최대 메모리가 4GB라면 곧바로 메모리 부족 현상이 발생할 수 있습니다.
이러한 문제를 예방하려면 다음과 같이 조회 범위나 데이터 수를 제한해야 합니다.
거래 내역 조회는 최대 3개월까지만 가능하도록 제한
한 번에 조회 가능한 게시글 수 제한
이처럼 GC의 특성과 애플리케이션의 메모리 사용 패턴을 고려한 설계가 중요합니다.

파일 다운로드

파일 다운로드 기능을 구현할 때는 스트림(stream) 을 활용하는 것이 중요합니다. 다음과 같이 파일 전체를 한 번에 메모리에 로딩해서 응답하는 방식은 피해야 합니다.
파일 크기와 동시 사용자 수가 많아질수록 메모리 사용량이 급증할 수 있기 때문입니다.
예를 들어, 30MB 파일을 100명이 동시에 다운로드하면 약 3GB의 메모리가 필요합니다.
잘못된 예시 (전체 파일을 한 번에 읽음):
const fs = require('fs'); const path = require('path'); const filePath = path.join(__dirname, 'path/to/file'); const data = fs.readFileSync(filePath); // 파일 전체를 메모리에 로드 res.end(data);
Java
복사
스트림을 사용하면 메모리를 훨씬 효율적으로 사용할 수 있습니다.
아래는 Node.js에서 스트림을 활용한 파일 다운로드 예시입니다.
스트림을 활용한 방식:
const fs = require('fs'); const path = require('path'); const filePath = path.join(__dirname, 'path/to/file'); const readStream = fs.createReadStream(filePath); // 스트림 방식으로 파일 읽기 readStream.pipe(res); // 클라이언트로 스트림 전송
Java
복사
이 방식은 파일을 16KB~64KB(기본값) 단위로 조금씩 읽어서 전송하기 때문에, 동시에 100명이 다운로드를 요청해도 수 MB 수준의 메모리만 필요합니다. 따라서 파일 처리나 동시 사용자 요청이 많은 상황에서는 항상 스트림 방식을 사용하는 것이 안전합니다.

참고 자료