Skip to content

Commit

Permalink
refactor: 분산락 적용 (#99)
Browse files Browse the repository at this point in the history
* chore: redisson 추가

* feat: 분산락에 사용될 key 정의

* feat: redisson 설정, 어노테이션, 애스펙트 작성

* fix: 수행 시간 출력하도록 수정

* refactor: RDBMS 락 대신 분산 락 사용

* feat: toString() 작성

* refactor: 이벤트 핸들러 분리

* fix: 테스트 환경에서 RedissonConfig 로드하지 않도록 수정

* remove: 불필요한 코드 삭제

* fix: test 프로퍼티 수정

* refactor: api 대신 yaml 파일 이용

* fix: view 중복으로 쌓이지 않도록 수정

* refactor: 반환형 수정

* refactor: Order 삭제

* fix: 빌드 실패 수정

* remove: 불필요한 중괄호 삭제

* remove: 불필요한 파일 삭제

* refactor: RedissonClient 생성 방식 변경

* remove: redisson 프로퍼티 제거

* refactor: 예외 변경
  • Loading branch information
kimyu0218 authored Nov 20, 2024
1 parent ba0c475 commit c607486
Show file tree
Hide file tree
Showing 19 changed files with 208 additions and 73 deletions.
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ dependencies {
// slack
implementation 'com.github.maricn:logback-slack-appender:1.6.1'

// redisson
implementation 'org.redisson:redisson-spring-boot-starter:3.38.1'

compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.nexters.goalpanzi.application.firebase.event.handler;

import com.nexters.goalpanzi.application.firebase.TopicGenerator;
import com.nexters.goalpanzi.application.mission.event.CompleteMissionEvent;
import com.nexters.goalpanzi.application.mission.event.JoinMissionEvent;
import com.nexters.goalpanzi.infrastructure.firebase.PushNotificationSender;
import lombok.RequiredArgsConstructor;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

import static com.nexters.goalpanzi.domain.firebase.PushNotificationMessage.MISSION_COMPLETED;
import static com.nexters.goalpanzi.domain.firebase.PushNotificationMessage.MISSION_JOINED;

@RequiredArgsConstructor
@Component
public class PushNotificationEventHandler {

private final PushNotificationSender pushNotificationSender;

@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
void handleJoinMissionEvent(final JoinMissionEvent event) {
pushNotificationSender.sendIndividualMessage(
MISSION_JOINED.getTitle(),
MISSION_JOINED.getBody(event.nickname()),
event.deviceToken()
);
}

@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
void handleCompleteMissionEvent(final CompleteMissionEvent event) {
String topic = TopicGenerator.getTopic(event.missionId());
pushNotificationSender.sendGroupMessage(
MISSION_COMPLETED.getTitle(),
MISSION_COMPLETED.getBody(),
topic
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import com.nexters.goalpanzi.application.mission.dto.response.MissionVerificationResponse;
import com.nexters.goalpanzi.application.mission.dto.response.MissionVerificationsResponse;
import com.nexters.goalpanzi.application.upload.ObjectStorageClient;
import com.nexters.goalpanzi.common.annotation.RedissonLock;
import com.nexters.goalpanzi.domain.common.BaseEntity;
import com.nexters.goalpanzi.domain.firebase.PushNotificationMessage;
import com.nexters.goalpanzi.domain.member.Member;
Expand Down Expand Up @@ -65,6 +66,7 @@ public MissionVerificationResponse getMyVerification(final MyMissionVerification
return MissionVerificationResponse.verified(verification.getMember(), verification, null);
}

@RedissonLock("MissionVerification")
@Transactional
public void createVerification(final CreateMissionVerificationCommand command) {
MissionMember missionMember = missionMemberRepository.getMissionMember(command.memberId(), command.missionId());
Expand Down Expand Up @@ -94,7 +96,10 @@ public void viewMissionVerification(final ViewMissionVerificationCommand command
MissionVerification missionVerification = missionVerificationRepository.findById(command.missionVerificationId())
.orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_VERIFICATION));

missionVerificationViewRepository.save(new MissionVerificationView(missionVerification, member));
MissionVerificationView missionVerificationView = missionVerificationViewRepository.getMissionVerificationView(command.missionVerificationId(), command.memberId());
if (missionVerificationView == null) {
missionVerificationViewRepository.save(new MissionVerificationView(missionVerification, member));
}
}

@Transactional
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,12 @@ public record CreateMissionVerificationCommand(
Long missionId,
MultipartFile imageFile
) {

@Override
public String toString() {
return "CreateMissionVerificationCommand{" +
"memberId=" + memberId +
", missionId=" + missionId +
'}';
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,10 @@
import com.nexters.goalpanzi.application.member.event.DeleteMemberEvent;
import com.nexters.goalpanzi.application.mission.MissionMemberService;
import com.nexters.goalpanzi.application.mission.MissionVerificationService;
import com.nexters.goalpanzi.application.mission.event.CompleteMissionEvent;
import com.nexters.goalpanzi.application.mission.event.CreateMissionEvent;
import com.nexters.goalpanzi.application.mission.event.DeleteMissionEvent;
import com.nexters.goalpanzi.application.mission.event.JoinMissionEvent;
import com.nexters.goalpanzi.domain.mission.InvitationCode;
import com.nexters.goalpanzi.infrastructure.firebase.PushNotificationSender;
import com.nexters.goalpanzi.infrastructure.firebase.TopicSubscriber;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
Expand All @@ -20,17 +17,17 @@
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

import static com.nexters.goalpanzi.domain.firebase.PushNotificationMessage.*;
import static com.nexters.goalpanzi.domain.firebase.PushNotificationMessage.MISSION_DELETED;

@Slf4j
@Component
@RequiredArgsConstructor
public class MissionMemberEventHandler {

private final MissionMemberService missionMemberService;
private final MissionVerificationService missionVerificationService;

private final PushNotificationSender pushNotificationSender;
private final TopicSubscriber topicSubscriber;

@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
void handleCreateMissionEvent(final CreateMissionEvent event) {
Expand All @@ -53,37 +50,12 @@ void handleDeleteMemberEvent(final DeleteMemberEvent event) {
void handleDeleteMissionEvent(final DeleteMissionEvent event) {
missionMemberService.deleteAllByMissionId(event.missionId());
missionVerificationService.deleteAllByMissionId(event.missionId());

String topic = TopicGenerator.getTopic(event.missionId());
pushNotificationSender.sendGroupMessage(
MISSION_DELETED.getTitle(),
MISSION_DELETED.getBody(),
TopicGenerator.getTopic(event.missionId())
topic
);

log.info("Handled DeleteMissionEvent for missionId: {}", event.missionId());
}

@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
void handleJoinMissionEvent(final JoinMissionEvent event) {
pushNotificationSender.sendIndividualMessage(
MISSION_JOINED.getTitle(),
MISSION_JOINED.getBody(event.nickname()),
event.deviceToken()
);

log.info("Handled JoinMissionEvent for missionId: {}", event.missionId());
}

@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
void handleCompleteMissionEvent(final CompleteMissionEvent event) {
pushNotificationSender.sendGroupMessage(
MISSION_COMPLETED.getTitle(),
MISSION_COMPLETED.getBody(),
TopicGenerator.getTopic(event.missionId())
);

log.info("Handled CompleteMissionEvent for missionId: {}", event.missionId());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.nexters.goalpanzi.common.annotation;

import java.lang.annotation.*;
import java.util.concurrent.TimeUnit;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RedissonLock {
String value() default "Unknown";

long waitTime() default 5L;

TimeUnit timeUnit() default TimeUnit.SECONDS;
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,6 @@ public void execute(final ProceedingJoinPoint joinPoint) throws Throwable {
}

stopWatch.stop();
log.info("{} finished. Elapsed time: {} ms", jobName, 0);
log.info("{} finished. Elapsed time: {} ms", jobName, stopWatch.getTime());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package com.nexters.goalpanzi.common.aop;

import com.nexters.goalpanzi.common.annotation.RedissonLock;
import com.nexters.goalpanzi.exception.BaseException;
import com.nexters.goalpanzi.exception.ErrorCode;
import com.nexters.goalpanzi.infrastructure.redisson.LockKey;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Component;

@RequiredArgsConstructor
@Slf4j
@Aspect
@Component
public class RedissonLockAspect {

private final RedissonClient redissonClient;

@Around("@annotation(com.nexters.goalpanzi.common.annotation.RedissonLock)")
public Object lockMissionVerification(final ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
String methodName = methodSignature.getName();
RedissonLock annotation = methodSignature.getMethod().getAnnotation(RedissonLock.class);
String lockTarget = annotation.value();

var args = joinPoint.getArgs();
LockKey lockKey = LockKey.of(lockTarget, args);
RLock lock = redissonClient.getLock(lockKey.toString());

boolean lockable = lock.tryLock(annotation.waitTime(), annotation.timeUnit());
if (!lockable) {
throw new BaseException(ErrorCode.FAILED_TO_ACQUIRE_REDISSON_LOCK);
}
log.info("{} acquired {} lock with key: {}.", methodName, lockTarget, lockKey);

joinPoint.proceed();

lock.unlock();
log.info("{} released {} lock with key: {}.", methodName, lockTarget, lockKey);

return joinPoint;
}
}
25 changes: 25 additions & 0 deletions src/main/java/com/nexters/goalpanzi/config/RedissonConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.nexters.goalpanzi.config;

import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.boot.autoconfigure.data.redis.RedisProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class RedissonConfig {

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

@Bean
public RedissonClient redissonClient(RedisProperties redisProperties) {
Config config = new Config();
config.useSingleServer().setAddress(makeAddress(redisProperties));
return Redisson.create(config);
}

private String makeAddress(RedisProperties redisProperties) {
return HOST_PREFIX + redisProperties.getHost() + ":" + redisProperties.getPort();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,8 @@
import com.nexters.goalpanzi.domain.mission.MissionMember;
import com.nexters.goalpanzi.exception.ErrorCode;
import com.nexters.goalpanzi.exception.NotFoundException;
import jakarta.persistence.LockModeType;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;

import java.time.Duration;
Expand All @@ -26,7 +24,6 @@ public interface MissionMemberRepository extends JpaRepository<MissionMember, Lo

Optional<MissionMember> findTop1ByMemberIdOrderByUpdatedAtDesc(final Long memberId);

@Lock(LockModeType.PESSIMISTIC_WRITE)
default MissionMember getMissionMember(final Long memberId, final Long missionId) {
return findByMemberIdAndMissionId(memberId, missionId)
.orElseThrow(() -> new NotFoundException(ErrorCode.NOT_JOINED_MISSION_MEMBER));
Expand Down
4 changes: 3 additions & 1 deletion src/main/java/com/nexters/goalpanzi/exception/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@ public enum ErrorCode {
FAILED_TO_UNSUBSCRIBE_FROM_TOPIC("토픽을 구독 취소하는 데 실패하였습니다."),

// ETC
FAILED_TO_GENERATE_HASH("해시값을 생성하는 데 실패하였습니다.");
FAILED_TO_GENERATE_HASH("해시값을 생성하는 데 실패하였습니다."),
FAILED_TO_ACQUIRE_REDISSON_LOCK("분산락을 획득하는 데 실패하였습니다."),
;

private String message;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.nexters.goalpanzi.infrastructure.redisson;

import lombok.RequiredArgsConstructor;

import java.util.Arrays;

@RequiredArgsConstructor
public class LockKey {

public static final String LOCK_PREFIX = "LOCK";

private final String target;
private final String value;

public static LockKey of(final String target, final Object... args) {
return new LockKey(target, Arrays.toString(args));
}

@Override
public String toString() {
return LOCK_PREFIX + ":" + target + ":" + value;
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
package com.nexters.goalpanzi;

import com.nexters.goalpanzi.config.redis.RedisInitializer;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ContextConfiguration;

@SpringBootTest
@ContextConfiguration(
initializers = {RedisInitializer.class}
)
class GoalpanziApplicationTests {

@Test
void contextLoads() {
}
@Test
void contextLoads() {
}

}
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
package com.nexters.goalpanzi.application.auth.apple;

import com.nexters.goalpanzi.config.redis.RedisInitializer;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ContextConfiguration;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertAll;

@SpringBootTest
@ContextConfiguration(
initializers = {RedisInitializer.class}
)
class AppleApiCallerTest {

@Autowired
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.nexters.goalpanzi.application.mission.dto.request.MissionBoardQuery;
import com.nexters.goalpanzi.application.mission.dto.response.MissionBoardsResponse;
import com.nexters.goalpanzi.config.redis.RedisInitializer;
import com.nexters.goalpanzi.domain.member.Member;
import com.nexters.goalpanzi.domain.member.repository.MemberRepository;
import com.nexters.goalpanzi.domain.mission.Mission;
Expand All @@ -15,6 +16,7 @@
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.domain.Sort;
import org.springframework.test.context.ContextConfiguration;

import java.util.List;

Expand All @@ -26,6 +28,9 @@
import static org.mockito.Mockito.when;

@SpringBootTest
@ContextConfiguration(
initializers = {RedisInitializer.class}
)
class MissionBoardServiceTest {

Member me;
Expand Down
Loading

0 comments on commit c607486

Please sign in to comment.