동시성 이슈 해결기: KeywordGroup 저장 시 중복 삽입 문제와 캐시 기반 최적화

송현진·2025년 6월 11일
0

프로젝트 회고

목록 보기
7/17

문제 상황

추천 시스템에서 거의 동시에 여러 유저가 비슷한 시점에 추천 요청을 보냈을 때 발생하는 이슈를 해결했다. 기존 구조에서는 추천 요청이 들어오면 tag, receiver, reason 키워드를 조합하여 KeywordGroup 테이블에 저장하거나 조회하고 이후 이를 기반으로 상품 검색 및 추천을 수행하는 흐름이다.

하지만 실제 상황에서 두 요청이 거의 동시에 들어오면 다음과 같은 문제가 발생했다.

  1. 여러 요청이 같은 키워드(ex. “생일”, “선물”)를 KeywordGroup 테이블에 동시에 저장하려고 시도
  2. MySQL에 설정된 main_keyword 컬럼의 unique 제약 조건에 의해 하나는 저장 성공, 다른 하나는 예외 발생
  3. 저장 실패한 요청은 이후 키워드 조회 결과가 null로 이어지고 추천 로직도 실패하여 400 응답 발생

이 문제는 특히 추천 키워드가 둘 다 새로 들어온 경우에만 발생하며 동시에 삽입되려는 시점에서 충돌이 발생한다는 점에서 동시성 이슈였다.

병목 지점 파악

List<KeywordGroup> newGroups = allKeywords.stream()
    .filter(k -> !groupMap.containsKey(k))
    .map(KeywordGroup::new)
    .toList();

List<KeywordGroup> saved = keywordGroupRepository.saveAll(newGroups);

문제는 saveAll()이 단순한 insert만 수행하며, DB 차원에서 동시성 제어가 없다는 것이다. 동시에 저장되는 요청은 중복 키워드 삽입 시 충돌하며 DataIntegrityViolationException이 발생한다.

해결 과정

해결 전략 1 - Upsert + 재조회 방식으로 구조 변경

가장 먼저 적용한 방식은 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 객체를 확보해야 한다.

해결 전략 2 - 캐시 적용 (KeywordCacheService 도입)

추가로 키워드가 매우 자주 재사용되는 특성을 고려해 메모리 캐시 + 키워드별 동기화 전략을 도입했다. 초기에는 단순히 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 흐름을 구성하는 것이 현실적인 타협점이었다.

profile
개발자가 되고 싶은 취준생

0개의 댓글