이 글에서는 Redis와 Redisson 라이브러리를 사용해 사용자 출석 이벤트를 관리하는 두 가지 방법을 소개한다. 각 방법은 event라는 키의 값을 100으로 초기화하고, 사람들이 출석 체크할 때마다 값을 1씩 줄이며, 값이 0이 되면 더 이상 출석이 불가능하도록 한다. 두 방법은 각기 다른 장단점이 있으며, 이를 비교해 보겠다.
1. eventStart 메서드: 이벤트 초기화
public Void eventStart(AuthUser authUser) {
RAtomicLong eventCounter = redissonClient.getAtomicLong("event");
eventCounter.set(100); // 초기값 100 설정
eventCounter.expire(1, TimeUnit.HOURS); // 1시간 만료 시간 설정
return null;
}
설명 : event 키를 초기화하여 출석 이벤트를 시작하고, 100이라는 초기 포인트를 설정해 1시간 동안 유지되도록 만료 시간을 설정한다.
2. 출석 체크 방법 1: Lua 스크립트를 사용한 원자적 감소
@Transactional
public Void luaScriptEventPoints(AuthUser authUser) {
User user = userRepository.findById(authUser.getUserId()).orElseThrow(
() -> new ApiException(ErrorStatus.NOT_FOUND_USER)
);
if (user.getIsAttended()) {
throw new ApiException(ErrorStatus.ALREADY_ATTEND);
}
String eventKey = "event";
String luaScript = "local current = tonumber(redis.call('get', KEYS[1]))\n" +
"if not current then\n" +
" return false\n" +
"end\n" +
"if current > 0 then\n" +
" redis.call('decr', KEYS[1])\n" +
" return current - 1\n" +
"else\n" +
" return false\n" +
"end";
DefaultRedisScript<Long> script = new DefaultRedisScript<>(luaScript, Long.class);
Long result = redisTemplate.execute(script, Collections.singletonList(eventKey));
System.out.println("result = " + result);
if (result == null || result < 0) {
throw new ApiException(ErrorStatus.EVENT_END);
}
checkAttendance(user, 1000);
user.updateLastAttendDate();
return null;
}
로직 설명:
- 유저 조회: authUser의 ID를 통해 유저 정보를 불러온다.
- 출석 체크 여부 확인: 이미 출석한 유저인지 확인한다.
- Lua 스크립트 실행:
- event 키의 현재 값을 가져와 0보다 크면 값을 1 감소시킨다.
- 감소된 값이 반환되며, 값이 0이 되면 이벤트가 종료되었음을 의미한다.
- 결과 확인: 반환된 값이 null이거나 음수면 이벤트 종료 예외를 발생시킨다.
- 출석 처리: 성공적으로 감소했으면 출석을 기록하고 날짜를 갱신한다.
장점:
- 원자적 실행: Lua 스크립트는 Redis 내에서 단일 명령처럼 실행되어 데이터의 일관성을 보장한다.
- 간결한 코드: 코드가 비교적 간결하고 명료하다.
단점:
- 복잡성: Lua 스크립트 작성 및 관리가 어려울 수 있다.
- 유연성 부족: 스크립트에서 더 복잡한 로직을 구현할 때 수정이 필요할 수 있다.
3. 출석 체크 방법 2: Redisson의 분산락을 사용한 감소
@Transactional
public Void eventPoints(AuthUser authUser) {
User user = userRepository.findById(authUser.getUserId()).orElseThrow(
() -> new ApiException(ErrorStatus.NOT_FOUND_USER)
);
if (user.getIsAttended()) {
throw new ApiException(ErrorStatus.ALREADY_ATTEND);
}
RLock lock = redissonClient.getFairLock("event-lock");
try {
if (lock.tryLock(2, TimeUnit.SECONDS)) {
try {
RAtomicLong eventCounter = redissonClient.getAtomicLong("event");
long event = eventCounter.decrementAndGet(); // 값을 1 감소
if (event >= 0) {
checkAttendance(user, 1000);
user.updateLastAttendDate();
} else {
eventCounter.set(0); // 값이 음수가 되지 않도록 0으로 설정
throw new ApiException(ErrorStatus.EVENT_END);
}
return null;
} finally {
lock.unlock(); // 락 해제
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
return null;
}
로직 설명:
- 유저 조회 및 출석 체크 확인: Lua 스크립트 방식과 동일.
- 분산락 획득: event-lock이라는 분산락을 사용해 출석 체크 동시성 문제를 방지.
- 이벤트 값 감소: RAtomicLong으로 event 키의 값을 1 감소시킴.
- 결과 확인 및 처리: 값이 0 이하가 되면 이벤트 종료 예외 발생 및 값 초기화.
- 락 해제: 작업이 완료되면 락을 해제하여 다른 스레드가 접근할 수 있도록 함.
장점:
- 유연성: 복잡한 로직이 필요할 때 Java 코드에서 쉽게 관리 가능.
- 명확한 동작: 분산락으로 동시성 문제가 명확히 해결됨.
단점:
- 성능 저하: 락을 사용하는 방식은 성능에 영향을 미칠 수 있다.
- 복잡성 증가: 락 관리 및 예외 처리가 필요해 코드가 복잡해질 수 있다.
결론
- Lua 스크립트 방식: 데이터의 일관성을 보장하면서 더 빠르고 간결하다. Redis 내에서 원자적 실행을 보장하기 때문에 동시성 문제를 효과적으로 해결할 수 있다.
- Redisson 분산락 방식: 복잡한 로직을 처리하기 더 쉬우며 코드 가독성이 좋지만, 성능 저하의 가능성이 있다.
추천: 단순히 포인트 감소와 동시성 제어가 필요하다면 Lua 스크립트 방식이 더 효율적이다. 그러나 복잡한 비즈니스 로직이 포함되거나 락을 명확히 제어해야 한다면 Redisson의 분산락 방식이 유용할 수 있다.
이 코드들은 메인 이벤트 처리 로직을 보조하는 부가적인 코드로, 사용자가 출석 체크를 할 때 받은 포인트를 Redis에 기록하고, 그 정보를 나중에 데이터베이스(DB)로 동기화하는 기능을 담당한다.
1. checkAttendance 메서드 설명
이 메서드는 사용자가 출석 체크를 했을 때 포인트를 부여하고 이를 Redis에 저장하는 역할을 한다.
@Transactional
public void checkAttendance(User user, int points) {
String redisKey = "user:" + user.getId() + ":points";
// Redis의 RAtomicLong을 사용하여 점수를 누적하고 TTL 설정
RAtomicLong atomicLong = redissonClient.getAtomicLong(redisKey);
atomicLong.addAndGet(points); // 사용자 포인트 누적
atomicLong.expire(10, TimeUnit.MINUTES); // 10분 TTL 설정으로 일정 시간이 지나면 키가 만료됨
// 이벤트로 받은 포인트를 데이터베이스에 저장하는 작업 예약
syncPointsToDatabase();
}
로직 설명:
- redisKey: Redis에 사용자 점수를 저장하기 위해 user:{userId}:points 형식의 키를 생성.
- RAtomicLong: Redis에서 원자적으로 점수를 누적하기 위해 사용됨.
- TTL 설정: 점수 키는 10분 후 자동으로 만료되도록 설정.
- syncPointsToDatabase() 호출: 포인트를 Redis에 임시 저장한 후, 이를 DB에 동기화하는 메서드를 호출.
장점:
- Redis를 통해 빠르게 점수를 기록할 수 있어 실시간 성능이 향상됨.
- TTL을 설정해 Redis 메모리 사용을 최적화할 수 있음.
2. syncPointsToDatabase 메서드 설명
이 메서드는 Redis에 저장된 모든 사용자 점수를 조회하고, 이를 DB에 동기화하는 역할을 한다.
@Transactional
public void syncPointsToDatabase() {
// Redis에서 사용자 점수 키를 패턴 매칭으로 가져옴
RKeys keys = redissonClient.getKeys();
Iterable<String> matchingKeys = keys.getKeysByPattern("user:*:points");
for (String redisKey : matchingKeys) {
// Redis 키에서 사용자 ID 추출
String userIdString = redisKey.replaceAll("user:(\\d+):points", "$1");
long id = Long.parseLong(userIdString);
// Redis에서 점수를 가져오고 해당 키를 삭제
int points = (int) redissonClient.getAtomicLong(redisKey).getAndDelete();
// DB에 사용자 포인트 업데이트
userRepository.updatePoints(points, id);
}
}
로직 설명:
- getKeysByPattern: user:*:points 패턴으로 Redis의 모든 사용자 점수 키를 조회.
- getAndDelete: Redis에서 점수를 가져온 후 해당 키를 삭제.
- updatePoints: 추출한 사용자 ID와 점수를 이용해 DB에 점수를 업데이트.
장점:
- Redis의 임시 저장소에 있는 데이터를 주기적으로 DB에 동기화해 일관성을 유지.
- 키를 삭제하여 메모리 관리가 용이함.
요약:
- checkAttendance 메서드는 사용자 출석 체크 시 포인트를 Redis에 기록하는 메서드이다.
- syncPointsToDatabase 메서드는 Redis에 있는 사용자 포인트 정보를 DB로 동기화하는 메서드이다.
- 두 메서드는 Redis와 DB 간의 데이터 일관성을 유지하면서 실시간 성능을 향상시킨다.
이러한 구조를 통해 실시간 성능과 데이터 일관성을 동시에 유지할 수 있다. Redis는 일시적인 고속 처리 및 캐싱에 사용되고, 중요한 데이터는 주기적으로 DB에 동기화하여 영구 저장된다.
바로 DB에 저장하지 않는 이유는 레디스에 처리 속도를 DB가 쫒아갈 수 없어서 레디스에 일단 저장 시키고 그것을 DB에 다시 저장하는 방식으로 했다. 여기서 좀 더 개선 해야 할 점은 레디스를 너무 많이 호출 한다는 것이다. 이걸 피하기 위해서는 다양한 방법이 있지만 DB에 저장하는 메서드에 스케줄러를 사용해 지정해 놓은 시간에만 메서드가 동작하게 하여 DB와 Redis에 부하를 조금은 줄일 수 있지 않을까 생각된다.