청년벙커는 공유주방, 라운지, 의사당 같은 공간을 온라인으로 신청하고 관리자가 승인하는 공공기관 웹 서비스다.
서비스를 개발하고 운영하면서 관리자 페이지에서 공간 목록을 불러오는게 유독 느리다는 걸 느꼈다.
코드를 확인해보니
// 현재 코드
const itemsRaw = await rentalCol.find(filter).sort(...).toArray();
for (const rental of itemsRaw) {
const user = await userCol.findOne({ _id: rental.userId }); // 😱
rental.userName = user?.username;
rental.userEmail = user?.email;
}
대여 목록을 불러온 뒤, 각 대여 건마다 유저 정보를 따로 조회하고 있었다.
대여 100건이면 DB를 101번 호출하는 구조였다.
문제 1: N+1 쿼리
N+1이 뭔지
대여 목록 조회 1회
↓
대여 건마다 유저 조회 N회
총 1 + N회 DB 호출
→ N+1 쿼리 문제
처음에 왜 이렇게 짰는지 생각해보면, 당시엔 "대여 가져오고 → 유저 정보 붙이면 되지"라는 생각이었다. 기능은 동작하니까 문제를 인식하지 못했다.
데이터가 적을 땐 체감이 안 됐다. 하지만 대여 건수가 쌓이면서 관리자 페이지가 점점 느려졌고, 원인이 바로 이 코드였다.
해결: $lookup으로 한 번에 조인
MongoDB의 $lookup 파이프라인을 사용하면 DB 1회 호출로 해결된다.
// 변경 후
const items = await rentalCol.aggregate([
{ $match: filter },
{ $sort: { date: 1, spaceType: 1, startTime: 1 } },
{ $skip: skip },
{ $limit: limit },
{
$lookup: {
from: 'users',
localField: 'userId',
foreignField: '_id',
as: '_user'
}
},
{ $unwind: { path: '$_user', preserveNullAndEmptyArrays: true } },
{
$addFields: {
userName: '$_user.username',
userEmail: '$_user.email'
}
},
{ $project: { _user: 0 } }
]).toArray();
$lookup은 SQL의 JOIN과 같은 역할을 한다. 별도의 반복 조회 없이 한 번의 파이프라인으로 대여 정보와 유저 정보를 함께 가져온다.
SQL로 보면
-- 기존 방식 (N+1)
SELECT * FROM rental; -- 1번
SELECT * FROM user WHERE id = 1; -- 2번
SELECT * FROM user WHERE id = 2; -- 3번
...
-- 개선 방식 (JOIN)
SELECT rental.*, user.username, user.email
FROM rental
JOIN user ON rental.userId = user.id; -- 1번으로 끝
변경 전: DB 101회 호출 (대여 100건 기준)
변경 후: DB 2회 호출 (목록 1회 + 전체 카운트 1회)
문제 2: 누락된 인덱스
인덱스가 없으면 풀스캔이 발생한다
코드를 더 들여다보니 인덱스 문제도 있었다.
settings 컬렉션: 공휴일, 휴무일 조회 시 매번 풀스캔
// 매번 이렇게 조회하는데
const doc = await settings.findOne({ key: 'closedDays', year: 2026 });
// settings 컬렉션에 인덱스가 없어서 전체를 뒤진다
users 컬렉션: 관리자 유저 검색 시 regex 풀스캔
// 검색할 때 이렇게 regex로 조회하는데
{ username: { $regex: keyword, $options: 'i' } }
// 인덱스 없이 전체를 순차 탐색한다
인덱스를 걸면 된다... 근데 어떻게?
단순히 "인덱스 추가하면 되겠지"가 아니라, 어떤 인덱스를 어떻게 걸어야 하는지가 중요했다.
settings 컬렉션: 항상 key와 year를 함께 조회하므로 복합 인덱스가 적합
await settings.createIndex(
{ key: 1, year: 1 },
{ name: 'settings_key_year', background: true }
);
background: true 옵션은 인덱스 생성 중에도 다른 작업이 가능하게 해준다. 운영 중인 서비스에서 인덱스를 추가할 때 중요한 옵션이다.
users 컬렉션: 텍스트 검색에는 텍스트 인덱스가 효과적
await users.createIndex(
{ username: 'text', email: 'text' },
{
name: 'user_text_search',
background: true,
weights: { username: 10, email: 5 } // username 검색이 더 중요
}
);
weights로 각 필드의 검색 가중치를 설정했다. username으로 검색하는 게 email보다 더 중요하다고 판단했기 때문이다.
문제 3: 반복되는 I/O
인덱스를 추가해도 해결 안 되는 문제가 있었다.
캘린더 열 때마다 DB 조회
예약 가능 여부 확인할 때마다 DB 조회
공휴일 조회할 때마다 외부 API 호출
자주 조회되는데 잘 안 바뀌는 데이터들이었다. 이런 데이터를 매번 DB에서 가져오는 건 낭비다.
Redis 캐싱 도입
캐시 레이어를 만들어서 모든 캐싱 로직을 통일했다.
// cacheService.js
async function cacheOrFetch(key, ttlSeconds, fallbackFn) {
try {
const cached = await redis.get(key);
if (cached) return JSON.parse(cached); // 캐시 히트
const data = await fallbackFn(); // 캐시 미스 → DB 조회
await redis.setex(key, ttlSeconds, JSON.stringify(data));
return data;
} catch (e) {
// Redis 장애 시 DB 직접 조회 (서비스 중단 방지)
console.error('캐시 오류, DB 직접 조회:', e.message);
return fallbackFn();
}
}
Redis가 죽어도 서비스는 계속 동작한다. catch 블록에서 DB 직접 조회로 fallback하기 때문이다.
데이터 성격에 따라 TTL을 다르게
모든 데이터를 같은 TTL로 캐싱하는 건 옳지 않다.
예약 가능 여부 → 10분 TTL
(대여 승인/취소 시 자주 바뀔 수 있음)
캘린더 데이터 → 30분 TTL
(한 번 불러오면 꽤 오래 유효)
공휴일 → 24시간 TTL
(1년에 한 번 업데이트됨)
공지/프로그램 목록 → 5~10분 TTL
(가끔 바뀜)
캐시 무효화도 중요하다
TTL 외에도 데이터가 바뀌는 시점에 캐시를 직접 무효화해야 한다.
// 대여 승인/취소 시
await invalidate('rental:avail:*'); // 예약 가능 여부 캐시 삭제
await invalidate('rental:calendar:*'); // 캘린더 캐시 삭제
캐시가 있는데 데이터가 바뀌면 오래된 정보를 보여주는 정합성 문제가 발생한다. 변경이 일어나는 시점에 관련 캐시를 함께 지워줘야 한다.
그런데 데이터가 아예 없는 경우는?
캐싱을 적용하다가 한 가지 더 고민이 생겼다.
특정 연도의 휴무일 설정이 아직 등록되지 않은 경우를 생각해보자.
2026년 휴무일 조회 요청
↓
Redis에 없음 → DB 조회
↓
DB에도 없음 (아직 설정 안 됨)
↓
캐시에 저장 안 됨
↓
다음 요청도 똑같이 DB 두드림
→ 요청마다 DB 조회 반복
이걸 캐시 관통(Cache Penetration) 이라고 한다. 데이터가 없는 경우 캐시를 우회해서 계속 DB에 부하를 주는 문제다.
해결: "없음"도 캐싱한다
해결은 단순했다. DB에서 데이터를 찾지 못해도 그 결과 자체를 캐싱하는 것이다.
async function getClosedDays(year, db) {
return cacheOrFetch(`holidays:closed:${year}`, 3600, async () => {
const settings = db.collection('settings');
const doc = await settings.findOne({ key: 'closedDays', year });
// null이어도 빈 배열로 캐싱
// "이 연도 휴무일 설정 없음" 자체를 저장
return doc?.dates || [];
});
}
doc?.dates || [] 이 한 줄이 핵심이다. DB에 데이터가 없어도 빈 배열 []을 반환하고, 이 빈 배열이 Redis에 저장된다. 다음 요청은 DB까지 가지 않고 캐시에서 []를 바로 반환한다.
물론 나중에 실제로 휴무일이 등록되면 캐시를 무효화해줘야 한다.
// 휴무일 설정 변경 시
await invalidate('holidays:closed:*');
TTL도 짧게 설정하는 게 중요하다. 나중에 실제 데이터가 생길 수 있으니, 빈 배열이 너무 오래 캐시에 남아있으면 안 된다.
전체 흐름
사용자 요청
↓
[브라우저 캐시] → hit: 응답 (0ms, 서버 무부하)
↓ miss
[Redis 캐시] → hit: 응답 (~1ms)
↓ miss
[MongoDB 인덱스 스캔] → 결과 → Redis 저장 → 응답
↓
[캐시 무효화] ← 데이터 변경 시
캘린더 조회 요청
↓
Redis에 'rental:calendar:2026-04:공유주방' 있어?
↓ 있으면
바로 반환 ⚡ (~1ms)
↓ 없으면
MongoDB 조회 → Redis에 저장 (30분) → 반환
나중에 대여 승인되면?
→ rental:calendar:* 캐시 삭제
→ 다음 요청 시 최신 데이터로 다시 캐싱
개선 효과
구간 변경 전 변경 후
| 관리자 대여 목록 (100건) | DB 101회 | DB 2회 |
| 캘린더 로드 | 매번 DB + JS 그루핑 | Redis 히트 (~1ms) |
| 예약 가능 여부 확인 | 매번 DB 조회 | Redis 히트 (~1ms) |
| 공휴일 조회 | 서버 재시작마다 외부 API | Redis 24시간 유지 |
배운 것
1. 기능이 동작한다고 끝이 아니다
N+1 쿼리는 기능상 문제없이 동작한다. 데이터가 적으면 느린지도 모른다. 하지만 데이터가 쌓이면 서비스가 버티질 못한다. 코드가 어떻게 DB를 호출하는지 항상 의식해야 한다.
2. 인덱스는 왜 거는지가 중요하다
단순히 "느리니까 인덱스 걸자"가 아니라, 어떤 쿼리가 어떤 패턴으로 실행되는지 파악하고 그에 맞는 인덱스를 설계해야 한다. 복합 인덱스, 텍스트 인덱스는 상황에 따라 다르게 쓰인다.
3. "없음"도 캐싱해야 한다
데이터가 없는 경우를 캐싱하지 않으면, 없는 데이터를 조회할 때마다 DB를 계속 두드리는 캐시 관통 문제가 발생한다. 빈 배열이나 특수값으로 "없음" 자체를 캐싱하고, 데이터가 실제로 생기면 캐시를 무효화하는 방식으로 처리해야 한다.
4. 캐시는 성능 도구지 생명줄이 아니다
Redis가 죽어도 서비스는 계속 동작해야 한다. fallback을 반드시 구현하자. 그리고 데이터가 바뀌는 시점에 캐시 무효화를 빠뜨리지 말자. 오래된 데이터를 보여주는 게 아무것도 보여주지 않는 것보다 더 나쁠 수 있다.
'project > 지역 청년 플랫폼' 카테고리의 다른 글
| 트러블슈팅: 분산 서버 환경에서 발생하는 예약 이중 승인 문제 해결 과정 (0) | 2026.03.30 |
|---|



