[TIL] EHcache 캐시 오염 트러블슈팅 - 캐시 객체

2026. 6. 27. 15:40·[TIL]

1. 개요

이슈 예시

운영자 A : ??님, 해당 광고는 A라는 광고 식별값이 내포되어있어야 하는데 B의 광고 식별값이 내포되어 있는게 발견됐습니다. 확인 가능하실까요?

본인 : (테스트 후) 오잉? 아무리 봐도 흘러들어갈 문제가 없는데.. 도대체 어떻게 그 정보가 흘러 들어간거지? 확실한 결과값인가요?

운영자 A : (일정 시간이 흐르고) 이번엔 D의 광고 식별값이 붙어나왔어요! 도대체 왜 이런거죠?

본인 : ??? 문제가 확실해 보이니, 확인 한 번 해보겠습니다.

개발 환경에서 아무런 문제가 없었던 특정 식별값 정보가, 일정 시간이 흐르고 운영 환경에서 잘못된 광고 식별값이 붙어 나온다는 아이러니한 상황을 마주했습니다.

운영팀측의 세팅값에서도, 개발팀측의 로직상에서도 아무런 문제가 없었기 때문에 한참을 해맸으나, 결국 고트래픽 환경을 견디기 위해 사용하고 있던 로컬 캐시 - EHCahce 가 해당 문제의 원흉이라는 것을 발견하게 되었습니다. 이에 따라 트러블슈팅 문서로 기록을 남기게 되었습니다.

이슈명

Ehcache(copyOnRead=false) 환경에서 캐시 조회 객체를 변경해 캐시 원본이 오염되는 문제

발생 환경

  • 로컬캐시: Ehcache
  • 저장 방식: 힙 전용 (디스크가 아닌 메모리에만 할당) (overflowToDisk="false")
  • 트래픽 특성: 고트래픽 서버 (동시 요청 다수)

문제 정의

핵심 : EHCahce 는 기본적으로 참조 객체로써 데이터를 메모리에 저장합니다.

그로 인해, 만일 캐시에서 꺼낸 객체를 복사없이 변경(set/add/remove 등)하면 Ehcache 내부에 저장된 데이터도 함께 바뀝니다.

이 시스템은 캐시에서 받은 객체가 캐시 내부에 저장된 바로 그 객체(동일 참조) 입니다. 따라서 받은 쪽에서 객체를 변경하면 캐시가 오염되고, 이후 같은 키로 조회하는 다른 요청들도 변경된 데이터를 받게 됩니다. 고트래픽 환경에서는 요청별로 달라야 할 값이 캐시에 박히면서 동시 요청 간 교차 오염이 발생합니다.


2. 발생 문제

  1. 캐시 매니저에서 리스트나 VO를 조회합니다.
  2. 조회한 객체를 그대로 shuffle/sort 하거나, VO의 세터를 발생시킵니다.
  3. 같은 키로 다시 조회할 경우, 1단계와 다른(오염된) 데이터가 반환됩니다.
public T cacheDataToClient(Element element) {
    return (T) element.getObjectValue();   // 복사 없이 저장된 참조를 그대로 반환
}

copyOnRead=false + 힙 전용 + 복사 없는 반환이 겹치면, get 으로 받은 객체 == 캐시 내부 객체 가 됩니다.

참고: overflowToDisk=true 인 캐시라도, 디스크로 밀려나기 전 힙에 있는 동안에는 동일하게 참조가 공유됩니다.

문제 지점과 재현

  • 간단한 동일 환경을 구성하여 문제지점과 재현 결과 사례입니다.

케이스 A — 캐시 리스트를 그대로 in-place shuffle

// list 가 캐시 내부 리스트의 참조 그대로입니다.
List<Item> list = service.getShopList();   // = cache.get(key).getObjectValue()
Collections.shuffle(list);                  // 캐시 리스트 자체를 매 요청마다 섞음

케이스 B — 캐시 VO의 필드를 세터로 변경 (요소 단위 오염)

// 리스트는 새로 만들었지만, 요소는 캐시된 VO 참조입니다.
AdConfigData adConfigData = selAdinfo();     // 캐시 VO 참조 반환
adConfigData.setKno(no);                     // 캐시 참조객체 오염
adConfigData.setNo(no);                      // 캐시 참조객체 오염

케이스 C — 공유 캐시 객체에 요청별 값을 stamp

// ms 는 캐시 원본정보 입니다.
MediaScriptData ms = mediaService.retrieveMediaScriptData(siteCode);
ms.setMediaProdcuctCode(param.getMediaProdcuctCode());           // 요청별 서로 다른 언론사 광고 정보가 캐시 지면에 박힘

문제 발생

데이터 측면에서는 예외 없이도 "다른 요청자의 특별한 요구사항 변동이 모두에게 적용" 같은 조용한 데이터 정합성 오류로 나타날 수 있습니다.


3. 원인 분석 (Root Cause Analysis)

3-1. 배경 지식 — Ehcache의 copyOnRead / copyOnWrite

캐시에서 조회한 객체를 변경하더라도 캐시 원본은 그대로 유지되어야 하며, 동시 요청 간에 데이터가 섞이지 않아야 합니다.

Ehcache 2.x는 캐시 조회/저장 시 객체를 복사할지 여부를 두 옵션으로 제어합니다.

  • copyOnRead : get 할 때 저장된 객체를 복사해서 반환할지
  • copyOnWrite : put 할 때 객체를 복사해서 저장할지

이 두 옵션의 기본값은 모두 false 입니다. 즉 별도 설정이 없으면 Ehcache는 저장할 때도, 꺼낼 때도 복사하지 않고 동일한 참조를 그대로 다룹니다. 복사가 켜져 있을 때만 캐시 내부 객체와 호출부 객체가 분리됩니다.

3-2. 기본값 적용

모든 설정 프로필에 copy 관련 설정이 전혀 없었으므로, 전 캐시에 기본값 false 가 적용되었습니다. 여기에 대부분의 캐시가 힙 전용이라, 직렬화/역직렬화를 거치는 디스크 계층조차 개입하지 않습니다. 아마, 고트래픽 환경의 특성상 무분별한 리소스 낭비를 위한 트레이드오프로 생각됩니다.

3-3. 복사 없는 반환 → 동일 참조 공유

이에 따라, 공통 캐시 매니저의 cacheDataToClient() 가 element.getObjectValue() 를 복사 없이 그대로 반환합니다. 설정상 복사가 꺼져 있고 코드상으로도 복사하지 않으므로, 호출부가 받는 객체는 캐시 내부 객체와 물리적으로 동일한 인스턴스가 탄생됩니다.

[캐시 내부 객체] ←── 동일 참조 ──→ [호출부가 받은 객체]
        ▲                              │
        └────── 호출부가 변경하면 ─────────┘
                캐시 내부도 함께 바뀜

3-4. 변경 지점에서 오염 발생

이 동일 참조 상태에서 호출부가 객체를 변경하면 캐시가 오염됩니다. 오염은 크게 두 층위로 나뉩니다.

  1. 리스트 구조 변경 : shuffle/sort/add/remove 로 캐시 리스트의 순서·구성이 바뀝니다.
  2. 요소(VO) 필드 변경 : 리스트는 새로 만들었더라도, 요소가 캐시된 VO 참조라면 그 VO에 세터를 호출하는 순간 캐시 VO가 오염됩니다.

3-5. 얕은 복사의 함정

리스트를 복사하는 new ArrayList<>(list) 나 list.clone() 은 리스트 구조만 복사하고, 요소는 여전히 같은 참조를 가리킵니다.

  • 순서·구성 변경(shuffle/sort/add/remove)에는 충분합니다.
  • 하지만 요소 VO의 필드를 세터로 변경하면, 그 VO는 여전히 캐시와 공유되므로 막지 못합니다. 이 경우 깊은 복사 가 필요합니다.

3-6. 동시성으로 인한 추가 위험

단순 오염보다 더 심각한 부작용은 동시성입니다. 캐시 리스트를 in-place로 shuffle 하는 것은 공유 리스트를 동기화 없이 변경하는 것입니다. ArrayList 는 thread-safe가 아니므로, 고트래픽에서 여러 스레드가 같은 리스트를 동시에 섞거나 순회하면 ConcurrentModificationException, IndexOutOfBoundsException, 순서 깨짐 등이 발생할 수 있습니다.


4. 해결 과정 (Troubleshooting Steps)

4-1. 시도해 볼 수 있는 접근들과 한계

접근 1 — 전역 copyOnRead/copyOnWrite + CopyStrategy 일괄 적용 (비권장)

가장 단순해 보이는 방법은 캐시 설정에서 copy를 켜는 것입니다. 하지만 다음 한계가 있습니다.

  • Ehcache 2.x의 copy 전략은 기본적으로 Java 직렬화로 매 get/put 마다 깊은 복사를 수행합니다.
  • 고트래픽 서버에서는 모든 조회에 직렬화 비용(CPU/GC)이 붙습니다.
  • 저장 타입이 모두 Serializable 이어야 합니다.
  • 대부분이 read-only인 조회까지 불필요하게 비용을 부담합니다.

즉 "전부 복사"는 안전하지만, 변경하지도 않는 다수의 조회까지 손해를 보는 과한 처방입니다.

접근 2 — 얕은 복사만 적용 (부분적 한계)

new ArrayList<>(cached) 로 리스트만 복사하는 방법은 순서·구성 변경에는 충분하지만, 요소 VO의 필드를 변경하는 케이스(3-4의 2번)는 막지 못합니다.

4-2. 최종 해결책 — 변경 지점에서의 방어적 복사 (권장)

복사 비용을 실제로 변경이 일어나는 그 지점에만 국한시키는 방식이 권장됩니다. 의도가 코드에 명시적으로 드러나는 장점도 있습니다.

리스트 구조를 변경하는 경우 (케이스 A)

// 수정 전
List<Item> list = service.getShopList();   // 캐시 리스트 참조
Collections.shuffle(list);                  // 캐시 오염 + 동시성 위험

// 수정 후
List<Item> list = new ArrayList<>(service.getShopList());  // 방어적 복사
Collections.shuffle(list);                                  // 복사본만 변경 → 캐시 안전

이미 같은 시스템 내 일부 DAO에서 return new ArrayList<>(adConfigList); 형태로 이렇게 방어하는 좋은 선례가 있었습니다.

요소 VO의 필드를 변경하는 경우 (케이스 B, C)

리스트 복사로는 막을 수 없으므로, 요소 자체를 깊은 복사하거나 요청 스코프 값을 별도 객체/파라미터로 분리해야 합니다.

// 수정 전 — 캐시 VO에 직접 set
AdConfigData adConfigData = selAdinfo();
adConfigData.setKno(no);

// 수정 후 — 깊은 복사 후 변경 (개념을 보여주는 단순화 코드입니다)
AdConfigData copy = deepCopy(adConfigData);  // 중첩 컬렉션까지 복사
copy.setKno(no);                              // 복사본만 변경

위 deepCopy 는 개념을 보여주기 위한 의사 코드입니다. 실제로는 복사 생성자, clone() 오버라이드 + 중첩 컬렉션 별도 복사, 또는 요청별 값을 캐시 객체가 아닌 요청 스코프 VO에 담는 방식 등 상황에 맞는 구현이 필요합니다.

요청별 값은 캐시 객체에 stamp하지 않기 (케이스 C)

지면 정보처럼 공유되는 캐시 객체에는 요청별로 달라지는 값(언론사 코드, 상품 타입 등)을 절대 set 하지 않고, 요청 단위 VO나 파라미터로 분리하는 것이 근본 해결입니다.


5. 학습 및 회고 (Lesson Learned & Next Steps)

원인 한 줄 요약

copyOnRead=false 기본값 + 복사 없는 캐시 반환이 겹쳐, 캐시에서 꺼낸 객체가 캐시 내부 객체와 동일 참조였고, 호출부의 변경이 캐시 원본을 그대로 오염시켰습니다.

기본적으로 캐시 데이터는 무결성이라는 대원칙을 무너뜨린 사례라고 볼 수 있었습니다.

개선 사항

  • 변경하는 곳에서 복사한다는 원칙을 규약화합니다. 캐시에서 받은 객체는 기본적으로 read-only로 취급하고, 변경이 필요하면 그 지점에서 복사합니다.
  • 리스트 구조 변경에는 얕은 복사, 요소 VO 변경에는 깊은 복사가 필요하다는 점을 구분합니다.
  • 공유 캐시 객체에 요청별 값을 set 하지 않습니다. 요청 스코프 데이터는 별도 객체로 분리합니다.

마치며

캐시는 "읽기 전용 정합성이 보장된 공유 데이터"라는 암묵적 전제 위에서 동작합니다. 이 전제가 강제되지 않으면, 단 한 줄의 shuffle 이나 세터 호출이 전체 사용자에게 퍼지는 오염원이 됩니다.

이번에 문제 지점을 파악하는것이 오래 걸렸던 이유도, 캐시데이터는 정합성이 보장되어야 한다는 대원칙을 어긴 사례였기 때문이라고 생각합니다.

'[TIL]' 카테고리의 다른 글

[TIL] Class 핫리로드와 톰캣의 오해 - 톰캣이 올라오고 class 파일을 교체해도 적용될까?  (0) 2026.06.16
[TIL] HTTP 커넥션 풀 고갈 트러블슈팅 - EntityUtils.consume()  (0) 2026.05.23
트러블슈팅 - OpenFeign과 @Configuration: 빈 등록의 함정과 FeignContext의 이해  (0) 2025.10.17
TIL - CDC로 향하는 가는 첫 번째 과정(1) : OracleDB 트랜잭션 로그(Redo log)를 읽어서 Kafka에 적재하기  (0) 2025.01.10
[ TIL ] RECOVER_YOUR_DATA : RDS 해킹 일지  (1) 2024.11.13
'[TIL]' 카테고리의 다른 글
  • [TIL] Class 핫리로드와 톰캣의 오해 - 톰캣이 올라오고 class 파일을 교체해도 적용될까?
  • [TIL] HTTP 커넥션 풀 고갈 트러블슈팅 - EntityUtils.consume()
  • 트러블슈팅 - OpenFeign과 @Configuration: 빈 등록의 함정과 FeignContext의 이해
  • TIL - CDC로 향하는 가는 첫 번째 과정(1) : OracleDB 트랜잭션 로그(Redo log)를 읽어서 Kafka에 적재하기
7.06com
7.06com
우당탕탕 코딩하기
  • 7.06com
    우당탕탕 개발자의 이야기
    7.06com
  • 전체
    오늘
    어제
    • 분류 전체보기 (67)
      • [Spring] (7)
      • [JAVA] (3)
      • [디자인패턴] (1)
      • [TIL] (11)
      • [CI,CD] (5)
      • [협업] (1)
      • [Database] (5)
      • [CS] (3)
      • [코딩테스트] (15)
      • [알고리즘] (0)
      • [후기-회고] (2)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.1
7.06com
[TIL] EHcache 캐시 오염 트러블슈팅 - 캐시 객체
상단으로

티스토리툴바