Skip to content

Commit

Permalink
feat: 알림 삭제 스케줄러 분산 락 적용
Browse files Browse the repository at this point in the history
- 레디스 분산 락을 적용하기 위해 redisson 등록
- AlertScheduler 인터페이스 분리
- 기존 AlertService 스케줄러 메서드 삭제 및 이동
- RedissonAlertSchedulerTest 테스트 작성
  • Loading branch information
devholic22 committed Jul 18, 2024
1 parent dab7f33 commit 72806c7
Show file tree
Hide file tree
Showing 9 changed files with 174 additions and 30 deletions.
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ dependencies {
runtimeOnly 'com.h2database:h2'
runtimeOnly 'com.mysql:mysql-connector-j'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.redisson:redisson-spring-boot-starter:3.33.0'

// flyway
implementation 'org.flywaydb:flyway-core'
Expand Down
6 changes: 6 additions & 0 deletions src/main/java/com/atwoz/alert/application/AlertScheduler.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.atwoz.alert.application;

public interface AlertScheduler {

void deleteExpiredAlerts();
}
8 changes: 0 additions & 8 deletions src/main/java/com/atwoz/alert/application/AlertService.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
import com.atwoz.alert.domain.vo.AlertGroup;
import com.atwoz.alert.exception.exceptions.AlertNotFoundException;
import lombok.RequiredArgsConstructor;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
Expand All @@ -16,8 +15,6 @@
@Service
public class AlertService {

private static final String MIDNIGHT = "0 0 0 * * ?";

private final AlertRepository alertRepository;
private final AlertTokenRepository tokenRepository;
private final AlertManager alertManager;
Expand All @@ -39,9 +36,4 @@ public Alert readAlert(final Long memberId, final Long id) {
alert.read();
return alert;
}

@Scheduled(cron = MIDNIGHT)
public void deleteExpiredAlerts() {
alertRepository.deleteExpiredAlerts();
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.atwoz.alert.exception;

import com.atwoz.alert.exception.exceptions.AlertLockException;
import com.atwoz.alert.exception.exceptions.AlertNotFoundException;
import com.atwoz.alert.exception.exceptions.AlertSendException;
import com.atwoz.alert.exception.exceptions.ReceiverTokenNotFoundException;
Expand All @@ -26,6 +27,11 @@ public ResponseEntity<String> handleAlertNotFoundException(final AlertNotFoundEx
return getExceptionWithStatus(e, HttpStatus.NOT_FOUND);
}

@ExceptionHandler(AlertLockException.class)
public ResponseEntity<String> handleAlertLockException(final AlertLockException e) {
return getExceptionWithStatus(e, HttpStatus.INTERNAL_SERVER_ERROR);
}

private ResponseEntity<String> getExceptionWithStatus(final Exception exception, final HttpStatus status) {
return ResponseEntity.status(status)
.body(exception.getMessage());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.atwoz.alert.exception.exceptions;

public class AlertLockException extends RuntimeException {

public AlertLockException() {
super("알림 락 획득 과정에서 예외가 발생하였습니다.");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.atwoz.alert.infrastructure;

import com.atwoz.alert.application.AlertScheduler;
import com.atwoz.alert.domain.AlertRepository;
import com.atwoz.alert.exception.exceptions.AlertLockException;
import lombok.RequiredArgsConstructor;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.util.concurrent.TimeUnit;

@RequiredArgsConstructor
@Component
public class RedissonAlertScheduler implements AlertScheduler {

private static final String MIDNIGHT = "0 0 0 * * ?";
private static final String DELETE_ALERT_LOCK = "delete_alert_lock";
private static final long WAIT_TIME = 0L;
private static final long HOLD_TIME = 40L;

private final AlertRepository alertRepository;
private final RedissonClient redissonClient;

@Scheduled(cron = MIDNIGHT)
@Override
public void deleteExpiredAlerts() {
RLock lock = redissonClient.getLock(DELETE_ALERT_LOCK);
boolean isLocked = false;
try {
isLocked = lock.tryLock(WAIT_TIME, HOLD_TIME, TimeUnit.SECONDS);
if (isLocked) {
alertRepository.deleteExpiredAlerts();
return;
}
throw new AlertLockException();
} catch (InterruptedException e) {
throw new AlertLockException();
} finally {
if (isLocked) {
lock.unlock();
}
}
}
}
14 changes: 14 additions & 0 deletions src/main/java/com/atwoz/global/config/RedisConfig.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package com.atwoz.global.config;

import lombok.RequiredArgsConstructor;
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.redisson.config.SingleServerConfig;
import org.springframework.boot.autoconfigure.data.redis.RedisProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
Expand All @@ -14,13 +18,23 @@
@Configuration
public class RedisConfig {

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

private final RedisProperties redisProperties;

@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(redisProperties.getHost(), redisProperties.getPort());
}

@Bean
public RedissonClient redissonClient() {
Config redissonConfig = new Config();
SingleServerConfig singleServer = redissonConfig.useSingleServer();
singleServer.setAddress(REDISSON_HOST_PREFIX + redisProperties.getHost() + ":" + redisProperties.getPort());
return Redisson.create(redissonConfig);
}

@Bean
public RedisTemplate<String, Long> redisTemplate() {
RedisTemplate<String, Long> redisTemplate = new RedisTemplate<>();
Expand Down
22 changes: 0 additions & 22 deletions src/test/java/com/atwoz/alert/application/AlertServiceTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,7 @@
import org.junit.jupiter.api.Test;

import java.util.Optional;
import static com.atwoz.alert.fixture.AlertFixture.알림_생성_id_없음;
import static com.atwoz.alert.fixture.AlertFixture.알림_생성_id_있음;
import static com.atwoz.alert.fixture.AlertFixture.옛날_알림_생성;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.SoftAssertions.assertSoftly;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
Expand Down Expand Up @@ -177,24 +175,4 @@ class 알림_단일_조회_관리 {
.isInstanceOf(AlertNotFoundException.class);
}
}

@Test
void 생성된_지_60일을_초과한_알림은_삭제_상태로_된다() {
// given
Long memberId = 1L;
Alert savedAlert = alertRepository.save(알림_생성_id_없음());
Alert savedOldAlert = alertRepository.save(옛날_알림_생성());

// when
alertService.deleteExpiredAlerts();

// then
Optional<Alert> foundSavedAlert = alertRepository.findByMemberIdAndId(memberId, savedAlert.getId());
Optional<Alert> foundSavedOldAlert = alertRepository.findByMemberIdAndId(memberId, savedOldAlert.getId());

assertSoftly(softly -> {
softly.assertThat(foundSavedAlert).isPresent();
softly.assertThat(foundSavedOldAlert).isEmpty();
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package com.atwoz.alert.infrastructure;

import com.atwoz.alert.domain.Alert;
import com.atwoz.alert.domain.AlertRepository;
import com.atwoz.helper.IntegrationHelper;
import org.junit.jupiter.api.DisplayNameGeneration;
import org.junit.jupiter.api.DisplayNameGenerator;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.auditing.AuditingHandler;

import java.time.LocalDateTime;
import java.util.Optional;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import static com.atwoz.alert.fixture.AlertFixture.알림_생성_id_없음;
import static com.atwoz.alert.fixture.AlertFixture.옛날_알림_생성;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.SoftAssertions.assertSoftly;

@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
@SuppressWarnings("NonAsciiCharacters")
class RedissonAlertSchedulerTest extends IntegrationHelper {

@Autowired
private RedissonAlertScheduler redissonAlertScheduler;

@Autowired
private AuditingHandler auditingHandler;

@Autowired
private AlertRepository alertRepository;

@Test
void 생성된_지_60일을_초과한_알림은_삭제_상태로_된다() {
// given
Long memberId = 1L;

LocalDateTime pastTime = LocalDateTime.now()
.minusDays(61);
auditingHandler.setDateTimeProvider(() -> Optional.of(pastTime));
Alert savedOldAlert = alertRepository.save(옛날_알림_생성());

auditingHandler.setDateTimeProvider(() -> Optional.of(LocalDateTime.now()));
Alert savedAlert = alertRepository.save(알림_생성_id_없음());

// when
redissonAlertScheduler.deleteExpiredAlerts();

// then
Optional<Alert> foundSavedAlert = alertRepository.findByMemberIdAndId(memberId, savedAlert.getId());
Optional<Alert> foundSavedOldAlert = alertRepository.findByMemberIdAndId(memberId, savedOldAlert.getId());

assertSoftly(softly -> {
softly.assertThat(foundSavedAlert).isPresent();
softly.assertThat(foundSavedOldAlert).isPresent();
Alert recentAlert = foundSavedAlert.get();
Alert oldAlert = foundSavedOldAlert.get();
softly.assertThat(recentAlert.getDeletedAt()).isNull();
softly.assertThat(oldAlert.getDeletedAt()).isNotNull();
});
}

@Test
void 분산_락으로_중복호출을_막는다() throws InterruptedException {
// given
int numberOfThreads = 5;
ExecutorService executorService = Executors.newFixedThreadPool(numberOfThreads);
CountDownLatch latch = new CountDownLatch(numberOfThreads);
AtomicLong atomicLong = new AtomicLong();

// when
for (int i = 0; i < numberOfThreads; i++) {
executorService.submit(() -> {
try {
redissonAlertScheduler.deleteExpiredAlerts();
atomicLong.incrementAndGet();
} finally {
latch.countDown();
}
});
}

latch.await(40, TimeUnit.SECONDS);
executorService.shutdown();

// then
assertThat(atomicLong.get()).isEqualTo(1);
}
}

0 comments on commit 72806c7

Please sign in to comment.