이전 글이 궁금하시다면
https://agongstory.tistory.com/114
[프차천국] 랜덤 프랜차이즈 추천 API 3.7초 → 37ms까지 줄이기(후보 풀 캐싱 + Batch IN + Hibernate Batch
N+1 쿼리 문제 해결과 캐싱 전략 적용 과정 프랜차이즈 추천 플랫폼을 개발하는 과정에서, 랜덤 브랜드 10개 조회 API의 응답 속도가 지나치게 느리다는 문제가 있었습니다. 해당 API는 홈에서 호출
agongstory.tistory.com
랜덤 추천 API 최적화 이후, 두 번째 난관 지난 글에서는 추천 API 성능을 3.7초에서 37ms까지 줄였던 과정을 이야기 하였습니다.
그 과정에서 저는 상위 후보군(100개)을 캐싱하여 랜덤 10개를 추출하는 전략을 적용하였고, 캐싱된 엔티티 기반으로 Lazy 로딩된 컬렉션을 조회해도 문제없었습니다. 그러나 끝이 아니었습니다. 동시 요청이 증가하는 순간, 새로운 문제가 발생하기 시작했습니다.
이번 글에서는 “Spring Cache + JPA Lazy Loading” 환경에서 실제로 발생한 동시성 문제(LazyInitializationException) 를 어떻게 분석하고 해결했는지 정리해보고자 합니다.
1. 문제 상황: 캐싱된 엔티티를 여러 요청이 동시에 읽는 순간 발생합니다
서비스 구조는 다음과 같습니다.
- Spring Boot 3.5.4
- JPA / Hibernate
- Caffeine Cache (로컬 메모리 캐시)
- @Cacheable로 List<Franchisor> 엔티티 직접 캐싱
- 컬렉션(franchiseStatuses, franchiseAvgSales)은 모두 Lazy 로딩
- default_batch_fetch_size = 100으로 N+1 문제는 해결된 상태
순차 요청에서는 아무 문제가 없었습니다.
그러나 캐시가 적제되기 전 동시 요청이 들어오면 다음과 같은 예외가 터지기 시작했습니다.
LazyInitializationException:
could not initialize proxy - no Session
2. 원인 분석: 캐시가 "엔티티"를 그대로 저장하고 있었습니다
Caffeine 캐시는 객체를 복사하지 않고 그대로 참조로 저장합니다. 즉, 첫 번째 요청에서 다음과 같은 상황이 발생했습니다.
- DB에서 Franchisor 엔티티를 조회함 → Lazy 프록시 포함
- 캐시에 저장함 → 프록시 상태 그대로 저장됨
- 이후 비즈니스 로직에서 Lazy된 데이터를 조회함 -> DB에서 조회 -> 초기화 수행 → 객체 내부 상태 변경
문제는 바로 이 시점입니다. 캐시에 저장된 엔티티가 Lazy 초기화가 끝나기 전, 다른 쓰레드가 그 캐싱된 데이터를 읽는 경우가 발생했습니다.
[Thread1] 캐시 미스 → 엔티티 조회 → 프록시 포함 상태로 캐시에 저장
[Thread2] 캐시 히트 → 프록시 상태 엔티티 접근
→ Lazy 초기화 시도
→ 하지만 세션은 Thread1의 세션
→ Thread2는 세션이 다름
→ LazyInitializationException 발생
이 문제가 순차 요청에서는 나타나지 않은 이유는 간단합니다.
- 순차 요청의 경우,
캐시 저장 직후에는 Thread1이 이미 Lazy 초기화를 완료한 상태 - 따라서 캐시에 저장된 객체도 실제 컬렉션(ArrayList)로 변환 완료된 상태
3. 추가로 오해했던 부분: sync=true는 해결책이 아닙니다
synс=true를 사용하면 캐시 미스 시 동일 키로 다수 요청이 들어올 때 DB를 한 번만 조회하도록 만들어줍니다. (Thundering Herd 방지) 그러나 다음 문제는 해결하지 못합니다.
- 캐시 저장된 직후 프록시가 초기화되기 전 다른 스레드가 먼저 접근하는 문제
- JPA Session 범위가 다른 스레드 간 접근 문제
즉, sync=true는 동시성 문제의 근본 원인을 해결하지 않습니다.
4. 결론: “엔티티가 아니라 DTO를 캐싱해야 합니다”
근본적인 해결 방안은 다음과 같습니다.
캐시에 엔티티를 저장하지 않고, Lazy 로딩을 모두 끝낸 뒤 변환한 DTO를 저장한다.
즉, 캐시는 JPA 세션과 완전히 독립적인 구조여야 합니다.
5. 해결 전략: DTO 캐싱 방식으로 전환했습니다
캐싱 구조를 다음과 같이 변경했습니다.
엔티티 조회
→ 트랜잭션 내에서 Lazy 로딩 모두 수행
→ 필요한 값만 추출해서 DTO를 생성
→ DTO 리스트를 캐시에 저장 (불변 데이터)
서비스 레이어는 캐시된 DTO 기반으로 바로 랜덤 10개 선택
→ Lazy 로딩 없음
→ 세션 필요 없음
→ 동시성 문제 없음
6. 구현 방법
(1) DTO 정의
@Getter
@AllArgsConstructor
public class FranchisorCacheDTO {
private String registrationNumber;
private String brandName;
private String industry;
private Integer franchiseStoreCount;
private Integer directStoreCount;
private Long averageSales;
public static FranchisorCacheDTO from(Franchisor franchisor) {
FranchiseStatus status = franchisor.getFranchiseStatuses().stream()
.filter(fs -> "전체".equals(fs.getId().getRegion()))
.max(Comparator.comparing(fs -> fs.getId().getYear()))
.orElse(null);
FranchiseAvgSales avgSales = franchisor.getFranchiseAvgSales().stream()
.filter(fas -> "전체".equals(fas.getId().getRegion()))
.max(Comparator.comparing(fas -> fas.getId().getYear()))
.orElse(null);
return new FranchisorCacheDTO(
franchisor.getRegistrationNumber(),
franchisor.getBrandName(),
franchisor.getIndustry(),
status != null ? status.getNumberOfFranchiseStores() : null,
status != null ? status.getNumberOfDirectStores() : null,
avgSales != null ? avgSales.getAverageSales() : null
);
}
}
(2) 캐시 서비스에서 DTO로 변환 후 캐싱
@Cacheable(value = "topBrandCandidates", key = "#industry", sync = true)
@Transactional(readOnly = true)
public List<FranchisorCacheDTO> getTop30Candidates(String industry) {
List<Franchisor> franchisors =
franchisorRepository.findTop50ByStoreCountWithRelations(industry);
return franchisors.stream()
.map(FranchisorCacheDTO::from) // Lazy 로딩 수행 후 DTO 변환
.toList();
}
(3) 추천 API에서는 DTO 기반으로 랜덤 선택
@Transactional(readOnly = true)
public List<SameIndustryDTO> recommendSameIndustry(String industry) {
List<FranchisorCacheDTO> topCandidates =
franchisorCacheService.getTop30Candidates(industry);
List<FranchisorCacheDTO> shuffled = new ArrayList<>(topCandidates);
Collections.shuffle(shuffled);
return shuffled.stream()
.limit(10)
.map(this::toSameIndustryDTO)
.toList();
}
엔티티 접근이 없는 구조이기 때문에 LazyInitializationException은 원천적으로 발생할 수 없습니다.
7. 효과 분석
| LazyInitializationException | 100% 해결됨 |
| 동시 요청 처리 안정성 | 완전 보장됨 |
| 캐시 메모리 사용량 | 엔티티 대비 약 60~70% 절감 |
| Redis 전환 가능성 | DTO라 직렬화 이슈 없음 |
| 유지보수성 | 비즈니스 로직과 캐시 레이어 완전 분리됨 |
특히 "캐시된 엔티티를 사용하는 구조"에서 벗어나면서 캐싱 계층과 도메인 계층 간 결합도가 사라져 구조적으로도 크게 개선되었습니다.
마무리
이번 사례는 다음 사실을 명확히 보여주었습니다.
JPA 엔티티를 그대로 캐싱하는 것은
단일 요청에서는 문제 없지만,
동시성 환경에서는 반드시 문제가 발생할 수 있습니다.
캐시에는 세션 독립적인 데이터만 저장해야 하며, Lazy 로딩이 포함된 엔티티는 캐시 대상으로 관리되기에 복잡하고 예기치 못한 오류를 발생시킬 수 있습니다. 이 원칙을 기반으로 DTO 캐싱 구조로 전환하였고, 그 결과 추천 API는 성능과 안정성 모두를 확보할 수 있었습니다.
'개발 > 서버' 카테고리의 다른 글
| [ 프차연구소 ] blue-green 무중단 배포 적용 (0) | 2026.01.24 |
|---|---|
| [프차천국] SpringBoot에서 로컬 캐시 중 CaffeinCache를 사용한 이유 (0) | 2025.11.25 |
| [프차천국] 랜덤 프랜차이즈 추천 API 3.7초 → 37ms까지 줄이기(후보 풀 캐싱 + Batch IN + Hibernate Batch Fetch) (2) | 2025.11.25 |
| DTO를 구현할 때 Record로 정의해야 할까, 클래스로 정의해야할까? (0) | 2025.10.24 |
| 개발 회고 : 예외가 발생하면 로그 레벨은 error로 설정해야할까? (0) | 2025.10.18 |
댓글