🧩 DB 복제 지연 (Replication Lag)
무슨 문제인가?
읽기/쓰기 DB를 분리한 구조에서, 쓰기 DB(마스터)에 데이터를 저장한 직후 읽기 DB(레플리카)에서 조회하면 방금 저장한 데이터가 없을 수 있다.
[앱] --쓰기--> [마스터 DB]
|
(복제 지연)
↓
[레플리카 DB] <--읽기-- [앱] ← 이전 데이터가 반환됨예를 들어 회원가입 직후 프로필 페이지로 리다이렉트했는데 "존재하지 않는 사용자"가 뜨는 버그가 이 패턴에서 자주 발생할 수 있다.
해결 전략
복제 지연 자체를 없앨 수는 없다. 애플리케이션 레이어에서 이를 인식하고 설계해야 한다.
| 전략 | 설명 | 적합한 상황 |
|---|---|---|
| Read-after-Write | 쓰기 직후의 읽기는 마스터에서 직접 수행 | 사용자 본인의 데이터 조회 |
| Lag Compensating | 쓰기 후 잠시 대기 후 재시도 | 일관성 요구가 낮은 경우 |
| Semi-sync Replication | 복제 확인 후 쓰기 완료 처리 | 복제 지연 자체를 줄이고 싶을 때 |
| 캐시 갱신 | 쓰기 후 캐시를 즉시 업데이트하고 이후 읽기는 캐시에서 제공 | 읽기가 매우 빈번한 경우 |
| CQRS | 쓰기/읽기 모델을 아키텍처 수준에서 분리 | 복잡한 도메인, 대규모 시스템 |
🧩 메모리 릭 (Memory Leak)
무슨 문제인가?
더 이상 필요하지 않은 메모리를 해제하지 않아 메모리 사용량이 계속 증가하는 현상이다. JVM 기반의 Spring 애플리케이션에서 자주 발생하는 원인은 다음과 같다.
- 이벤트 리스너 등록 후 미해제
- 전역 변수에 대용량 객체 참조 유지
- 캐시 객체의 무한 증가
ThreadLocal미정리 ← 가장 흔한 함정
ThreadLocal 메모리 릭 패턴
스레드 풀을 사용하는 환경에서 ThreadLocal을 사용하고 remove()를 호출하지 않으면, 스레드가 재사용될 때 이전 스레드의 데이터가 그대로 남아있는다.
// 위험한 코드
executorService.submit(() -> {
threadLocal.set("someValue");
// threadLocal.remove() 호출 없이 작업 종료
// → 스레드가 풀에 반환된 후에도 메모리 점유 지속
});
// 안전한 코드
executorService.submit(() -> {
try {
threadLocal.set("someValue");
// ... 처리 로직
} finally {
threadLocal.remove(); // 항상 finally에서 정리
}
});
테스트 환경에서는 메모리 릭이 에러로 드러나지 않고, 장기 운영 후 메모리 사용량이 점진적으로 증가하는 형태로만 나타난다.
Spring에서 주의할 점
- 싱글톤 빈 내부에 컬렉션을 필드로 두고 계속 추가만 하면 릭이 된다.
@Autowired순환 참조는 의도치 않은 객체 생존을 일으킬 수 있다.- 요청 스코프 빈을 ThreadLocal로 처리할 때는 요청 종료 시 반드시 정리한다.
🧩 캐시 압력 (Cache Pressure)
무슨 문제인가?
캐시가 가득 차서 새로운 데이터를 저장하지 못하거나, 자주 쓰이는 데이터가 밀려나는 상황이다. 사용자별 페이징 데이터를 캐시에 통째로 저장하는 패턴이 대표적인 원인이다.
// 안티패턴: 페이지 전체 데이터를 키로 저장
{
"user123_page_1": { "data": [ {...item1}, {...item2}, ... ] },
"user456_page_1": { "data": [ {...item1}, {...item2}, ... ] }
}
사용자 수 × 페이지 수만큼 캐시 항목이 폭발적으로 증가하고, 히트율은 낮아지는 최악의 조합이다.
해결: 캐시를 분리하자
페이지 ID 리스트와 아이템 상세 데이터를 분리해서 캐싱하면 중복 저장을 줄이고 재사용성을 높일 수 있다.
// 페이징 캐시: 어떤 아이템이 있는지만 기록
{ "user123_page_1": ["item1", "item2", "item3"] }
// 아이템 캐시: 실제 데이터는 공유
{ "item1": { "id": "item1", "name": "Item One", ... } }
조회 시 ① 페이지 ID 리스트 → ② 각 ID로 아이템 상세 조회 → ③ 조합하여 응답
다른 해결 전략
- TTL 설정: 오래된 캐시가 자동 만료되어 공간 확보
- 최대 캐시 크기 제한: LRU 등의 정책으로 오래된 항목 자동 제거
- 분산 캐시(Redis): 단일 서버 메모리 한계 극복
- 캐시 우회: 세분화된 요청은 캐시 없이 DB에서 직접 조회
캐시 백오프(Backoff) 주의
캐시가 일시적으로 비활성화되면 모든 트래픽이 DB로 쏟아지는 캐시 스탬피드(Cache Stampede) 가 발생할 수 있다. 캐시 우회 전략을 쓸 때는 DB 부하 급증 상황을 항상 염두에 두어야 한다.
🧩 설정 버전 관리 (Configuration Versioning)
무슨 개념인가?
애플리케이션 설정에 버전(또는 타임스탬프)을 부여해서, 시간 순으로 이력을 관리하는 방법이다. 항상 최신 버전의 설정을 사용하되, 문제 발생 시 이전 버전으로 즉시 롤백할 수 있다.
Spring + JPA 구현 예시
@Entity
public class CoreSetting {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(columnDefinition = "jsonb")
private String configData;
private LocalDateTime version; // 이 버전 기준으로 정렬
@CreationTimestamp
private LocalDateTime createdAt;
}
// 최신 설정 조회: version 내림차순으로 첫 번째 항목
@Query(value = "SELECT * FROM CoreSetting ORDER BY version DESC LIMIT 1", nativeQuery = true)
Optional<CoreSetting> findLatestCoreSetting();
새 설정을 저장할 때 기존 행을 수정하지 않고 새 행을 추가하는 것이 핵심이다. 덮어쓰면 이력이 사라진다.
장단점 요약
장점
- 장애 시 이전 설정으로 즉시 롤백 가능
- 설정 변경 이력 추적으로 원인 분석 용이
- 운영 배포 전 테스트 환경에서 사전 검증 가능
단점
- 버전이 쌓일수록 저장 공간 증가
- 관리 복잡도 상승 (정기적인 오래된 버전 정리 필요)
- 잘못된 버전 롤백 시 인적 오류 가능성
여담으로 설정 행에 description 컬럼을 추가해두면 "왜 이 설정으로 바꿨는지"를 기록할 수 있어 나중에 이력을 추적하기 훨씬 편하다.
🧩 정리
| 주제 | 핵심 교훈 |
|---|---|
| DB 복제 지연 | 지연은 제거할 수 없다. 애플리케이션이 이를 인식하고 설계해야 한다. |
| 메모리 릭 | ThreadLocal은 반드시 finally에서 remove(). 테스트에서는 릭이 보이지 않는다. |
| 캐시 압력 | 전체 데이터를 캐시하지 말고, ID 리스트와 상세 데이터를 분리해서 캐시하자. |
| 설정 버전 관리 | 설정을 덮어쓰지 말고 버전별로 누적 저장하면 롤백과 이력 추적이 쉬워진다. |
'내일배움캠프' 카테고리의 다른 글
| [내일배움캠프] RAG (0) | 2026.06.10 |
|---|---|
| [내일배움캠프] Spring AI (0) | 2026.06.09 |
| [내일배움캠프] DB Lock (0) | 2026.06.04 |
| [내일배움캠프] 시큐어 코딩 (0) | 2026.06.04 |
| [내일배움캠프] 모니터링 시스템 (0) | 2026.06.02 |