GYUD-TECH

[뉴젯] Redis 도입과 분산 락을 활용한 동시성 문제 해결 본문

프로젝트

[뉴젯] Redis 도입과 분산 락을 활용한 동시성 문제 해결

GYUD 2025. 2. 1. 23:22

뉴젯은 업무메일과 뉴스레터가 섞여 뉴스레터를 읽지 못하고 쌓아 놓기만 하는 문제를 해결하기 위해, 뉴스레터만을 위한 독립된 서비스를 제공합니다. 현재 뉴젯의 누적 사용자는 약 2,600명으로 하루 평균 8명 정도 지속적으로 서비스 사용자가 늘고 있다.

 

사용자가 늘어남에 따라 메일 수신 시스템의 성능 저하 문제가 발견되었다. 뉴스레터는 발행자가 전송을 누른 시점 일괄적으로 발송되기 때문에, 동시에 많은 요청이 서버로 전달된다. 뉴젯에서 가장 인기있는 뉴스레터 뉴닉의 경우 현재 637명이 구독하기 때문에 한번에 637개의 메일 수신 요청을 처리해야 한다.

 

메일 수신 모듈의 경우 이전에 분리를 하였지만, 수신한 메일에 대한 구독 관리 및 알림 전송을 위해 수행하는 뉴스레터 테이블 조회에서 성능 저하 문제가 발생하였다. 이를 해결하기 위해 Redis 캐싱을 도입하고, Redisson 분산 락 활용한 동시성 문제를 해결한 과정에 대해 자세히 소개한다.

(메일 수신 모듈 분리 관련 아티클: https://gyuwon-tech.tistory.com/59)

 


문제 상황

동시에 많은 요청이 서버에 도착했을 때 서버의 응답 시간을 측정하기 위해 nGrinder를 활용해 성능 테스트를 진행하였다.

(nGrinder 테스트 환경 구축 관련 아티클: https://gyuwon-tech.tistory.com/65)

 

테스트는 뉴닉의 구독자가 637명인 것을 고려하여 데이터베이스에서 메일 수신 시 사용되는 뉴스레터 조회 서비스를 1,000 번 동시에 호출한 상황을 가정하여 진행하였다.  테스트 결과 아래 이미지와 같이 TPS는 226.65, MTT는 1347ms로 측정되었다.

  • TPS (Transactions Per Second): 초당 처리량
  • MTT (Mean Test Time): 평균 응답 시간

메일 수신 시 뉴스레터 조회 외에 구독 목록 추가 및 알림 전송 로직도 추가로 진행되는데, MTT가 약 1.3초로 높게 측정되어 조회 성능을 개선해 MTT 지표를 500ms 이하로 떨어뜨리고자 하였다.

 


왜 Redis 인가?

MTT 지표 개선을 위해서 캐싱을 도입해 성능 향상을 이루고자 하였다.

현재 방식에서는 뉴스레터가 발송되면 한가지 뉴스레터 발행자로부터 동일한 뉴스레터가 도착하기 때문에 동일한 뉴스레터 조회 쿼리가 발생한다. 동일한 데이터를 짧은 시간에 반복 조회하기 때문에 캐시 히트율이 매우 높은 특징이 있다. 따라서, 캐시 도입 시 성능 개선 폭이 클 것이라고 예상하여 캐시 도입을 결정하였다.

 

가장 먼저 간단하게 구현할 수 있는 Local Cache를 고려하였지만, 추후 메일 서버 뿐만 아니라 API 서버에서도 캐시를 활용할 수 있기 때문에 분산 환경에 적합한 외부 캐시를 사용하였다. 추후, 다양한 방식으로 캐시를 활용하고, 데이터 영속성을 통한 캐시 데이터의 안정성을 보장하기 위하여 Redis를 활용해 캐시를 구현하기로 결정하였다.

 

Redis 도입 과정

Redis를 도입하며 캐시의 다양한 전략 패턴 중 Look Aside - Write Around 패턴을 도입하여 캐시와 DB를 분리하고자 하였다. 이를 통해 캐시 서버가 죽더라도 데이터에 접근이 가능하도록 하고, 필요한 데이터만 캐시로 옮겨 불필요한 캐시 저장을 방지하고자 하였다.

 

처음에는 캐시 관련 로직을 Infrastructure Layer에 구현하였다. 캐시 역시 데이베이스와 같이 데이터를 읽는 작업이기 때문에 Infrastructure Layer에 구현하여 데이터 저장 역할을 부여하였다.

@Repository
@RequiredArgsConstructor
public class NewsletterRedisRepository implements NewsletterCacheRepository {

	private static final Long NEWSLETTER_DURATION = 1000L * 60 * 60;
	private static final String DOMAIN_PREFIX = "newsletter:domain";
	private static final TimeUnit TIME_UNIT = TimeUnit.MILLISECONDS;

	private final RedisTemplate<String, String> redisTemplate;
	private final ObjectMapper objectMapper;

	@Override
	public void saveByDomain(String domain, NewsletterEntity newsletterEntity) {
		redisTemplate.opsForValue().set(DOMAIN_PREFIX + domain,
			serialize(newsletterEntity), NEWSLETTER_DURATION, TIME_UNIT);
	}

	@Override
	public Optional<NewsletterEntity> findByDomain(String domain) {
		String value = redisTemplate.opsForValue().get(DOMAIN_PREFIX + domain);
		return deserialize(value);
	}
    
	private String serialize(NewsletterEntity newsletterEntity) {
		try {
			return objectMapper.writeValueAsString(newsletterEntity);
		} catch (JsonProcessingException e) {
			throw new RedisSerializationException("newsletterEntity 직렬화 오류", e);
		}
	}

	private Optional<NewsletterEntity> deserialize(String value) {
		if (!StringUtils.hasText(value)) {
			return Optional.empty();
		}
		try {
			return Optional.of(objectMapper.readValue(value, NewsletterEntity.class));
		} catch (JsonProcessingException e) {
			throw new RedisSerializationException("newsletterEntity 역직렬화 오류", e);
		}
	}
}
public Optional<Newsletter> findByDomainOrMailingList(String domain, String mailingList) {
	Optional<NewsletterEntity> cachedNewsletter = 
    		findByDomainOrMailingListOnCache(domain, mailingList);
	if (cachedNewsletter.isPresent()) {
		return cachedNewsletter.map(NewsletterEntity::toModel);
	}
	return findByDomainOrMailingListOnDatabase(domain, mailingList)
		.map(NewsletterEntity::toModel);
}

하지만 이렇게 구현하니 캐시의 TTL값이 바뀌었을 때 Infrastructure Layer를 직접 수정해야하는 문제가 발생했다.

또한, findByDomainOrMailingList를 호출하면 항상 캐시 조회 후 데이터베이스를 조회하는 전략 패턴을 사용해야만 했기 때문에 코드의 재사용에 유리하지 않았다.

 

이러한 이유로 Business Layer에서 캐시에 접근하도록 코드를 구현하였다. 또한 공통적인 캐시 저장, 조회, 로직은 CacheUtil 인터페이스의 역할로 분리하여 NewsletterService에서 redisTemplate과 objectMapper에 대한 의존성을 제거하고 역할을 분리해 코드의 재사용성을 높였다.

@Component
@RequiredArgsConstructor
public class RedisUtil implements CacheUtil {

	private static final TimeUnit TIME_UNIT = TimeUnit.MILLISECONDS;

	private final RedisTemplate<String, String> redisTemplate;
	private final ObjectMapper objectMapper;

	@Override
	public <T> Optional<T> get(String key, Class<T> classType) {
		String cachedValue = redisTemplate.opsForValue().get(key);
		return deserialize(cachedValue, classType);
	}

	@Override
	public void set(String key, Object object, long ttl) {
		String value = serialize(object);
		redisTemplate.opsForValue().set(key, value, ttl, TIME_UNIT);
	}
    
	private <T> String serialize(T object) {
		try {
			return objectMapper.writeValueAsString(object);
		} catch (JsonProcessingException e) {
			throw new RedisSerializationException(object.getClass().getSimpleName() + " 직렬화 오류", e);
		}
	}

	private <T> Optional<T> deserialize(String value, Class<T> classType) {
		if (!StringUtils.hasText(value)) {
			return Optional.empty();
		}
		try {
			return Optional.of(objectMapper.readValue(value, classType));
		} catch (JsonProcessingException e) {
			throw new RedisSerializationException(classType.getSimpleName() + " 역직렬화 오류", e);
		}
	}
}
@Service
@RequiredArgsConstructor
public class NewsletterService {

	private static final Long CACHE_LOCK_WAIT_TIME = 1000 * 5L;
	private static final Long CACHE_LOCK_LEASE_TIME = 1000 * 3L;
	private static final Long CACHE_DURATION = 1000 * 60 * 5L;
	private static final String CACHE_DOMAIN_PREFIX = "newsletter:domain";

	private final NewsletterRepository newsletterRepository;
	private final CacheUtil cacheUtil;

	public Newsletter findOrCreateNewsletter(String name, String domain, String mailingList) {
		return findByDomainOnCache(domain)
			.orElseGet(() -> findOrCreateByDomainOrMailingList(name, domain, mailingList));
	}

	private Optional<Newsletter> findByDomainOnCache(String domain) {
		return cacheUtil.get(CACHE_DOMAIN_PREFIX + domain, NewsletterCacheDto.class)
			.map(NewsletterCacheDto::toModel);
	}
}

Redis 도입 후 성능 측정

Redis 도입 이후 nGrinder를 활용해 부하테스트를 진행하였지만, 20개 ~ 30개의 중복된 쿼리가 발생하는 문제를 다시 발견하였다.

Cache miss가 발생하였을 때는 데이터베이스에서 데이터를 가져와 캐시에 데이터를 저장하는 로직은 올바르게 수행되지만, 처음 Cache miss가 발생한 데이터가 데이터베이스 조회 및 캐시 저장을 수행하는 도중에 다른 데이터도 Cache miss가 발생하여 Cache에 데이터를 저장하기 이전까지 데이터베이스 I/O 요청이 발생하는 것이다. 이러한 문제를 Cache Stampede 문제라고 한다.

 

작성한 테스트 코드에서는 단일 요청에 대해 캐시 동작이 올바르게 수행되는지를 확인하고 있었기 때문에 해당 오류를 잡지 못하는 문제가 발생하였다. 이를 해결하기 위해, Thread를 활용해 동시 요청을 발생시키고, DB 조회 메서드를 1번만 수행했는지 확인하는 테스트 코드를 작성하여 CI/CD 과정에서 에러를 미리 탐지하도록 하였다.

@Test
void 뉴스레터_캐시_조회_동시성100명_테스트() throws InterruptedException {
    int numberOfThreads = 100;
    ExecutorService executorService = Executors.newFixedThreadPool(numberOfThreads);
    CountDownLatch latch = new CountDownLatch(numberOfThreads);

    for (int i = 0; i < numberOfThreads; i++) {
        executorService.submit(() -> {
            try {
                NewsletterService.findOrCreateNewsletter("테스트", "test@example.com", "test123");
            } finally {
                latch.countDown();
            }
        });
    }
    latch.await();

    // 다른 테스트 코드 생략
    verify(newsletterRepository, times(1)).findByDomainOrMailingList(domain, mailingList);
}

 


Redission 분산 락을 활용한 동시성 문제 해결

현재 발생하는 Cache Stampede 문제는 짧은 시간에 발생하는 중복 요청 상황에서 캐시에 데이터가 저장되기 이전에 캐시 조회가 일어나서 캐시된 데이터를 활용하지 못하는 상황이다. 따라서 분산 락을 활용해 첫번째 요청에 대해 캐시 미스가 발생하면, 다른 데이터들의 캐시 조회를 차단하여 동시성 문제를 해결하고자 하였다.

 

분산 락을 사용하였을 때 단점 중 데드락 문제를 해결하기 위해서 SETNX 방식이 아닌 Redisson을 활용해 메시지를 활용한 락 획득으로 Redis의 부하를 줄이고, 예상치 못한 상황으로 오류가 났을 때도 락 해제나 연장이 가능하도록 하였다.

 

가장 먼저 Redisson 의존성을 추가 후 RedissonClient 사용을 위한 Config 설정을 빈으로 등록한다.

@Configuration
public class RedissonConfig {

	@Value("${spring.redis.host}")
	private String redisHost;

	@Value("${spring.redis.port}")
	private int redisPort;

	private static final String REDISSON_HOST_PREFIX = "redis://";

	@Bean
	public RedissonClient redissonClient() {
		Config config = new Config();
		config.useSingleServer()
			.setAddress(REDISSON_HOST_PREFIX + redisHost + ":" + redisPort);

		return Redisson.create(config);
	}
}

다음으로 아래 과정으로 락 획득 및 해제를 수행하는 코드를 RedisUtil.java에 추가한다.

@Component
@RequiredArgsConstructor
public class RedisUtil implements CacheUtil {

	private static final String LOCK_PREFIX = "lock:";
	private static final TimeUnit TIME_UNIT = TimeUnit.MILLISECONDS;

	private final RedissonClient redissonClient;

	@Override
	public Optional<RLock> tryLock(String lockKey, long waitTime, long leaseTime) {
		RLock lock = redissonClient.getLock(LOCK_PREFIX + lockKey);
		try {
			boolean acquired = lock.tryLock(waitTime, leaseTime, TIME_UNIT);
			return acquired ? Optional.of(lock) : Optional.empty();
		} catch (InterruptedException e) {
			Thread.currentThread().interrupt();
			return Optional.empty();
		}
	}

	@Override
	public void unlock(RLock lock) {
		if (lock.isHeldByCurrentThread()) {
			lock.unlock();
		}
	}
}

캐시 데이터 조회 시에는 Lock으로 인한 성능 오버헤드를 방지하기 위하여, 캐시 조회 이후 Lock을 획득하고, 캐시 미스 시 Lock 생성 후 한번 더 캐시를 확인하는 Double-Check 방식을 도입하여 성능을 개선하고자 하였다. 데이터 조회 및 캐시 저장이 완료된 후에는 finally 구문에서 unlock을 호출하여 캐시 데이터에 접근이 가능하도록 설정하였다.

private Newsletter findOrCreateByDomainOrMailingListWithLock(String name, String domain,
	String mailingList) {
	RLock lock = cacheUtil.tryLock(CACHE_DOMAIN_PREFIX + ":" + domain,
			CACHE_LOCK_WAIT_TIME, CACHE_LOCK_LEASE_TIME)
		.orElseThrow(
			() -> new NewsletterLockAcquisitionException("newsletter: waitTime 내에 lock 획득 실패"));
	try {
		Optional<Newsletter> cachedNewsletter = findByDomainOnCache(domain);
		if (cachedNewsletter.isPresent()) {
			return cachedNewsletter.get();
		}

		Newsletter newsletter = newsletterRepository
			.findByDomainOrMailingList(domain, mailingList)
			.orElseGet(() -> newsletterRepository
				.save(name, domain, mailingList, NewsletterStatus.UNREGISTERED));

		cacheUtil.set(CACHE_DOMAIN_PREFIX + domain, newsletter.toCacheDto(), CACHE_DURATION);
		return newsletter;
	} finally {
		cacheUtil.unlock(lock);
	}
}

코드 작성 이후 다시 부하 테스트를 진행한 결과, 1,000개의 동시 요청을 보내도 한개의 SQL 쿼리만이 발생하며 동시성 문제가 해결된 것을 확인하였다.

또한 TPS는 499.18, MTT는 499ms로 성능이 측정되어 캐시 도입 이전 대비 조회 시간이 848ms 단축되었다.

 


느낀점

메일 수신 모듈을 모니터링하며 병목 지점을 발견한 이후에 다양한 성능 개선 실험을 진행하고 있다. 메일 파싱 속도 개선을 위해 Lambda와 SQS를 활용해 메일 파싱 속도를 개선하였고, 데이터베이스 I/O 시간 단축을 위해 캐싱을 도입하고 동시성 문제를 해결하였다.

 

이 과정에서 다양한 설계 방식을 적용하고, 테스트를 진행하고, 다시 설계를 변경하는 식으로 실험을 진행하면서 서버 기술에 대해 깊이 이해하며 문제 해결력이 길러진다는 느낌이 들어 성취감도 느껴진다.

 

예상보다 개선 사항이 많아 메일 모듈 분리에 많은 시간이 걸리고 있지만, 이 과정에서 좋은 설계에 대해 고민하면서 많이 배우고 있다.

 


참고자료

Redisson 분산 락 AOP 적용 관련: https://helloworld.kurly.com/blog/distributed-redisson-lock/

Redisson 공식 문서: https://jforj.tistory.com/424

Redis 통합 테스트를 위한 EmbeddedRedis 구축: https://jojoldu.tistory.com/297

Redis 전략 패턴: https://inpa.tistory.com/entry/REDIS-%F0%9F%93%9A-%EC%BA%90%EC%8B%9CCache-%EC%84%A4%EA%B3%84-%EC%A0%84%EB%9E%B5-%EC%A7%80%EC%B9%A8-%EC%B4%9D%EC%A0%95%EB%A6%AC

Redis Tempate 및 직렬화/역직렬화 설정: https://jforj.tistory.com/424