📌 서론
우리는 현재 redis에 정보를 넣어놓고 거기서 조회하고 삭제하고 저장하는 작업을 하고 있다. 그런데 동일한 프로젝트를 2개의 인스턴스로 띄우기 때문에 redis에 동시성 이슈가 생기는 것을 방지해야 했다. 여기서 동시성 이슈를 해결하기 위해 적용한 방법을 간단하게 소개해보려고 한다.
동시성 이슈 해결을 위한 Redis Lock
동시성 이슈는 여러 프로세스나 스레드가 동일한 자원에 동시에 접근할 때 발생할 수 있는 문제를 의미한다. 이러한 문제를 해결하기 위해 Redis를 사용한 잠금 메커니즘을 활용할 수 있다.
동시성 이슈란?
동시성 이슈는 여러 프로세스나 스레드가 동시에 같은 자원에 접근할 때 발생할 수 있는 문제를 말한다. 예를 들어, 두 개의 인스턴스가 같은 Redis 데이터베이스에 접근하여 데이터를 수정하려고 할 때, 데이터 일관성이 깨지거나 예기치 않은 오류가 발생할 수 있다.
동시성 이슈가 발생하는 상황
데이터 경합
두 개 이상의 프로세스가 동시에 같은 데이터를 읽고 수정하려고 할 때.
상태 불일치
한 프로세스가 데이터를 수정하는 동안 다른 프로세스가 그 데이터를 읽으면 데이터의 상태가 일관되지 않을 수 있다.
동시성 이슈 방지 방법
- Redis Lock
- Shed Lock
Redis Lock
Redis Lock을 사용하면 여러 인스턴스가 동일한 자원에 동시에 접근하는 것을 막을 수 있다. 이를 통해 데이터 경합과 상태 불일치를 방지할 수 있다.
Shed Lock
Shed Lock은 주로 Spring Boot 환경에서 사용되며, 여러 인스턴스가 실행되는 분산 시스템에서 작업의 동시성을 제어하기 위해 사용된다.
현재 우리 프로젝트는 Kafka Listener에서 프로세스가 처리되기 때문에 ShedLock을 적용하지 않았다.
ShedLock은 주로 스케줄된 작업(예: cron job)에서 중복 실행을 방지하기 위해 사용된다. Kafka Listener와 같은 비동기 메시지 소비에서는 ShedLock을 적용하기 어려웠다. ShedLock은 특정 시간에 작업이 실행되는 것을 보장하지만, 메시지 소비는 이벤트 기반으로 발생하기 때문이다.
따라서, 메시지 소비 시점에 자원 접근을 제어하는 Redis 락 방식이 더 적합하다고 판단했다. Redis Lock은 분산 환경에서 동시에 발생할 수 있는 자원 접근을 효율적으로 제어할 수 있다. Kafka Listener가 이벤트를 소비할 때마다 락을 획득하고 해제하는 방식으로 동시성 문제를 해결할 수 있다.
Redis Lock
내가 사용한 방법은 Kafka 메시지를 소비하는 시점에 해당 메시지에 담겨있는 유니크 값을 Redis에 저장해 놓고 프로세스가 끝나면 Redis에서 해당 키를 제거하는 방식으로 진행했다. 동일한 프로세스가 다른 인스턴스에서 실행되고 동일한 자원에 접근할 때, Redis에서 해당 키로 먼저 조회를 요청하고 키가 존재하면 에러를 발생시키고, 존재하지 않으면 Redis에 저장하고 정상 프로세스를 진행하는 방식을 선택했다.
RedisLock 클래스: Redis에서 락을 표현할 엔티티 클래스
import lombok.Getter;
import lombok.ToString;
import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;
@Getter
@ToString
@RedisHash(value = "redisLock")
public class RedisLock {
@Id
private String key;
public RedisLock(String key) {
this.key = key;
}
}
- @RedisHash(value = "redisLock"): Redis에서 해시로 저장될 객체임을 나타낸다.
- key: 락을 식별하는 키.
👇 Redis 엔티티 관련해서 @RedisHash과 @Id 어노테이션에 대한 자세한 설명은 다음 링크에 자세히 설명되어 있다! 👇
Redis에서 @RedisHash, @Id, @Indexed의 쓰임새
RedisLockRepository 인터페이스
import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface RedisLockRepository extends CrudRepository<RedisLock, String> {
RedisLock findRedisLockByKey(String keyname);
}
RedisLockService 클래스
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.Objects;
@RequiredArgsConstructor
@Service
public class RedisLockService {
private final RedisLockRepository redisLockRepository;
public void save(String key) {
if (Objects.nonNull(this.selectKey(key))) {
throw new BadRequestException(ErrorCode.ENTITY_ALREADY_EXIST);
}
redisLockRepository.save(new RedisLock(key));
}
public RedisLock selectKey(String key) {
return redisLockRepository.findRedisLockByKey(key);
}
public void delete(String key) {
redisLockRepository.deleteById(key);
}
}
- save(String key): 새로운 락을 저장한다. 이미 해당 키로 락이 존재하면 예외를 발생시킨다.
- selectKey(String key): 특정 키로 락을 조회한다.
- delete(String key): 특정 키의 락을 삭제한다.
Service에서 락 사용 예시
private final RedisLockService redisLockService;
@KafkaListener(topics = "my-topic")
public void onMessage(@Payload String message) {
String partnerId = "";
try {
// 로직 처리
redisLockService.save(PartnerConstant.ADD_ITEM_KEY_NAME + items.getPartnerId());
} catch (Exception e) {
// 에러 핸들
} finally {
redisLockService.delete(PartnerConstant.ADD_ITEM_KEY_NAME + partnerId);
}
}
우리는 프로세스가 Kafka에서 메시지를 컨슘하면서 발생하기 때문에 Kafka Listener에 해당 로직을 추가했다.
메시지를 받아서 파싱하고 성공하면 RedisLock에 해당 메시지에서 추출한 키값을 저장한다. 로직이 종료되면 finally 블록에서 Redis에서 해당 키값을 삭제한다.
이 방법을 사용하려면 추가적으로 TTL(Time To Live)을 수동으로 설정해줘야 한다. 이렇게 진행하는 것이 Redis Lock의 기본 방식이다.
TTL과 수동으로 설정하는 개념
TTL(Time-To-Live)은 락의 유효 기간을 의미한다. TTL을 설정하지 않으면 서버 오류로 인해 delete가 호출되지 않는 경우, 해당 키는 Redis에 계속 남아있게 되어 잠금 상태가 지속된다. 따라서 TTL을 설정하여 일정 시간 후 자동으로 삭제되도록 하는 것이 중요하다.
현재까지 보여준 코드에서는 따로 TTL을 설정해주지 않았다. 아직까진 크리티컬 하지 않을 것 같아서 추가하진 않았는데 일단 TTL을 수동으로 설정하는 방법도 보여주겠다.
TTL을 설정하기 위해 @RedisHash 어노테이션에 timeToLive 속성을 추가하고, 엔티티 클래스에서 TTL 값을 설정할 수 있도록 수정해야 한다.
RedisLock 클래스: TTL 적용
import lombok.Getter;
import lombok.ToString;
import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;
@Getter
@ToString
@RedisHash(value = "redisLock", timeToLive = 60) // TTL 설정 (60초)
public class RedisLock {
@Id
private String key;
public RedisLock(String key) {
this.key = key;
}
}
- @RedisHash(value = "redisLock", timeToLive = 60): Redis에 저장될 객체임을 나타내며, TTL을 60초로 설정한다. 이 설정은 Redis가 해당 객체를 60초 후에 자동으로 삭제하도록 한다.
이 방법을 사용하면 RedisLock 엔티티가 Redis에 저장될 때 자동으로 TTL이 설정되므로, 추가적인 TTL 설정 작업이 필요하지 않다. TTL이 만료되면 Redis가 자동으로 해당 키를 삭제하여 잠금이 해제된다.
이렇게 하면 락이 설정된 후 일정 시간이 지나면 자동으로 해제될 수 있다.
위 내용을 통해 동시성 이슈와 이를 해결하기 위한 Redis Lock 설정 방법, 각 코드의 역할과 의미를 이해할 수 있을 것이다. 이 방법 이외에도 쉐드락을 실제로 적용해 보는 방법도 나중에 프로젝트에서 접하게 되면 정리해 봐야겠다.
'Spring > Spring Boot' 카테고리의 다른 글
Redis에서 @RedisHash, @Id, @Indexed의 쓰임새 (0) | 2024.06.10 |
---|---|
객체 간 매핑을 도와주는 MapStruct 라이브러리(2) - 추가 어노테이션 (0) | 2024.04.09 |
객체 간 매핑을 도와주는 MapStruct 라이브러리(1) - 기본 어노테이션 (0) | 2024.04.02 |
Lombok의 @Builder 어노테이션으로 객체 생성 (0) | 2024.04.02 |
RequestDto에서 MultipartFile 필드 사용 방법: 객체 바인딩 방법부터 테스트 코드까지 (3) | 2024.01.13 |