추천 시스템에서 거의 동시에 여러 유저가 비슷한 시점에 추천 요청을 보냈을 때 발생하는 이슈를 해결했다. 기존 구조에서는 추천 요청이 들어오면 tag
, receiver
, reason
키워드를 조합하여 KeywordGroup
테이블에 저장하거나 조회하고 이후 이를 기반으로 상품 검색 및 추천을 수행하는 흐름이다.
하지만 실제 상황에서 두 요청이 거의 동시에 들어오면 다음과 같은 문제가 발생했다.
KeywordGroup
테이블에 동시에 저장하려고 시도main_keyword
컬럼의 unique 제약 조건에 의해 하나는 저장 성공, 다른 하나는 예외 발생이 문제는 특히 추천 키워드가 둘 다 새로 들어온 경우에만 발생하며 동시에 삽입되려는 시점에서 충돌이 발생한다는 점에서 동시성 이슈였다.
List<KeywordGroup> newGroups = allKeywords.stream()
.filter(k -> !groupMap.containsKey(k))
.map(KeywordGroup::new)
.toList();
List<KeywordGroup> saved = keywordGroupRepository.saveAll(newGroups);
문제는 saveAll()
이 단순한 insert만 수행하며, DB 차원에서 동시성 제어가 없다는 것이다. 동시에 저장되는 요청은 중복 키워드 삽입 시 충돌하며 DataIntegrityViolationException
이 발생한다.
가장 먼저 적용한 방식은 MySQL의 upsert 기능을 활용해 충돌 자체를 무시하고 안정적으로 통과시키는 방법이다. 이를 위해 Native Query를 사용하여 중복 시 무시하는 쿼리를 정의했다
@Modifying
@Query(value = """
INSERT INTO keyword_group (main_keyword)
VALUES (:keyword)
ON DUPLICATE KEY UPDATE main_keyword = main_keyword
""", nativeQuery = true)
void upsertIgnore(@Param("keyword") String keyword);
이 방식은 동시 요청 간 충돌을 DB 차원에서 처리할 수 있도록 설계되었다. 단, insert 결과를 그대로 사용하는 것이 아니라 반드시 재조회를 통해 정확한 KeywordGroup
객체를 확보해야 한다.
추가로 키워드가 매우 자주 재사용되는 특성을 고려해 메모리 캐시 + 키워드별 동기화 전략을 도입했다. 초기에는 단순히 ConcurrentHashMap
캐시를 사용하고 전체 캐시 객체에 대해 synchronized
블록을 걸어 동시성 제어를 했지만, 이 경우 서로 다른 키워드라도 동시에 접근할 수 없다는 단점이 있었다. 결과적으로 병렬 처리에 병목이 발생했다. 이를 해결하기 위해 키워드당 별도의 락 객체를 사용하는 구조로 개선했다. lockMap
이라는 ConcurrentHashMap<String, Object>
을 따로 관리하며 각 키워드마다 고유한 락을 생성하고 해당 키워드에 대해서만 동기화를 수행하도록 변경했다.
@Service
@RequiredArgsConstructor
public class KeywordCacheService {
private final KeywordGroupRepository keywordGroupRepository;
private final Map<String, KeywordGroup> cache = new ConcurrentHashMap<>();
private final Map<String, Object> lockMap = new ConcurrentHashMap<>();
@Transactional
public KeywordGroup getOrCreate(String keyword) {
KeywordGroup existing = cache.get(keyword);
if (existing != null) return existing;
// 키워드마다 개별 락 생성
Object lock = lockMap.computeIfAbsent(keyword, k -> new Object());
synchronized (lock) {
// 다시 캐시 확인 (경쟁 쓰레드가 먼저 처리했을 수도 있음)
existing = cache.get(keyword);
if (existing != null) return existing;
// 중복 삽입 무시하는 upsert 처리
keywordGroupRepository.upsertIgnore(keyword);
// 반드시 재조회
KeywordGroup saved = keywordGroupRepository.findByMainKeywordIn(Set.of(keyword))
.stream().findFirst()
.orElseThrow();
cache.put(keyword, saved);
return saved;
}
}
}
그리고 추천 서비스에서는 다음과 같이 캐시를 활용해 KeywordGroup
리스트를 구성했다.
Map<String, KeywordGroup> groupMap = allKeywords.stream()
.distinct()
.collect(Collectors.toMap(
k -> k,
keywordCache::getOrCreate
));
List<KeywordGroup> finalGroups = allKeywords.stream()
.map(groupMap::get)
.filter(Objects::nonNull)
.toList();
이 구조를 통해 다른 키워드 간 요청은 병렬로 처리하면서도 동일 키워드 요청 간에는 안정적인 동기화를 보장할 수 있었다. 또한 캐시를 적극 활용하면서 DB 접근 횟수를 최소화하여 성능 향상도 기대할 수 있다.
2개의 서로 다른 요청을 거의 동시에 실행했다.
요청 1:
{
"keywords": ["여자친구", "5~10만원", "생일", "러닝화", "스마트워치", "선물"]
}
요청 2:
{
"keywords": ["남자친구", "5~10만원", "생일", "디자이너지갑", "패션모자", "선물"]
}
동일한 시점에 위 두 요청을 거의 동시에 보냈을 때 기존에는 하나만 성공하고 다른 하나는 500 에러가 났던 반면 수정 이후에는 두 요청 모두 정상적으로 추천 결과를 반환했다. 단, 후속으로 들어온 요청은 캐시 생성 및 DB 조회로 인해 조금 느리게 응답되었는데 이는 서버 쓰레드/CPU 자원 점유와 Redis 기반 API 제한(QuotaManager)에 따른 병목 영향으로 판단된다.
이번 경험은 "정상 동작하던 로직도 동시성 환경에서는 전혀 다르게 동작할 수 있다"는 중요한 교훈을 줬다. 특히 JPA의 saveAll()
이나 save()
는 별다른 보호 없이 insert만 수행하기 때문에 unique 제약이 걸린 컬럼에 대해 race condition이 발생할 여지가 매우 크다. 처음에는 단순 try-catch로 예외를 잡고 재시도하면 될 줄 알았지만 구조적인 해결 없이 동시성 문제는 되풀이될 수밖에 없었다. 그래서 DB 자체에 on duplicate key
처리를 맡기고 비즈니스 레이어에서는 반드시 재조회 후 활용하는 방향으로 변경했다. 또한 KeywordCacheService
를 별도로 만들면서 캐시의 힘이 얼마나 큰지도 실감했다. 자주 쓰이는 키워드를 매번 DB에 조회하거나 저장하는 건 엄청난 낭비이며 캐시 하나로 네트워크 IO와 DB 트래픽을 동시에 줄일 수 있었다. 마지막으로 synchronized
를 반드시 최소화된 범위 내에서 사용하는 것의 중요성도 체감했다. synchronized
범위가 너무 넓으면 쓰레드 블로킹이 발생해 전체 처리 속도를 저하시킨다. 따라서 캐시를 우선 활용하고 그 후 fallback으로 synchronized-DB 흐름을 구성하는 것이 현실적인 타협점이었다.