From a3ddfb5bb62248dda8e4f6628bcd2ea3846ce34c Mon Sep 17 00:00:00 2001 From: kymin Date: Sun, 6 Sep 2026 22:48:54 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=EB=A6=AC=EC=8A=A4=EB=84=88=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/internal/listener/AuthMemberEventListener.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/routee-auth/src/main/java/org/sopt/routee/auth/internal/listener/AuthMemberEventListener.java b/routee-auth/src/main/java/org/sopt/routee/auth/internal/listener/AuthMemberEventListener.java index bb24727a..d89ed641 100644 --- a/routee-auth/src/main/java/org/sopt/routee/auth/internal/listener/AuthMemberEventListener.java +++ b/routee-auth/src/main/java/org/sopt/routee/auth/internal/listener/AuthMemberEventListener.java @@ -2,8 +2,9 @@ import org.sopt.routee.auth.internal.service.AuthService; import org.sopt.routee.member.api.event.MemberWithdrawnEvent; -import org.springframework.modulith.events.ApplicationModuleListener; +import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionalEventListener; import lombok.RequiredArgsConstructor; @@ -13,7 +14,8 @@ class AuthMemberEventListener { private final AuthService authService; - @ApplicationModuleListener + @Async + @TransactionalEventListener(fallbackExecution = true) void handleMemberWithdrawnEvent(MemberWithdrawnEvent event) { authService.revokeTokens(event.accessTokenHash(), event.refreshTokenHash()); } From a0d385e07ff3f743082fae706dd70b0344e98dfa Mon Sep 17 00:00:00 2001 From: kymin Date: Sun, 6 Sep 2026 22:50:34 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=EC=A4=91=EB=B3=B5=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=20=EC=8B=9C=20=EB=82=99=EA=B4=80=EC=A0=81=20=EB=9D=BD?= =?UTF-8?q?=20=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/service/MemberService.java | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java b/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java index fd2f4995..421e7dce 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java @@ -44,6 +44,7 @@ import org.sopt.routee.member.internal.service.validator.ProfileImageFileNameValidator; import org.sopt.routee.util.TimeZoneUtils; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionTemplate; @@ -109,17 +110,21 @@ private void validateRequiredAgreements(AgreementCommand agreement) { } public void withdraw(long memberId, String accessTokenHash, String refreshTokenHash) { - transactionTemplate.executeWithoutResult(status -> { - Member member = memberRepository.findById(memberId) - .orElseThrow(MemberNotFoundException::new); + try { + transactionTemplate.executeWithoutResult(status -> { + Member member = memberRepository.findById(memberId) + .orElseThrow(MemberNotFoundException::new); - memberAgreementRepository.deleteByMember_Id(memberId); - memberRepository.delete(member); + memberAgreementRepository.deleteByMember_Id(memberId); + memberRepository.delete(member); - activityUseCase.deleteForMemberWithdrawal(memberId); + activityUseCase.deleteForMemberWithdrawal(memberId); + }); + } catch (ObjectOptimisticLockingFailureException e) { + throw new MemberNotFoundException(); + } - applicationEventPublisher.publishEvent(new MemberWithdrawnEvent(memberId, accessTokenHash, refreshTokenHash)); - }); + applicationEventPublisher.publishEvent(new MemberWithdrawnEvent(memberId, accessTokenHash, refreshTokenHash)); Thread.startVirtualThread(() -> deleteMemberImages(memberId)); } From c4f46d43a389892509555ae9aa78bb99d4241484 Mon Sep 17 00:00:00 2001 From: kymin Date: Tue, 8 Sep 2026 16:36:19 +0900 Subject: [PATCH 3/3] =?UTF-8?q?test:=20=ED=9A=8C=EC=9B=90=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=20=EB=8F=99=EC=8B=9C=20=EC=9A=94=EC=B2=AD=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/service/MemberServiceTest.java | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 routee-member/src/test/java/org/sopt/routee/member/internal/service/MemberServiceTest.java diff --git a/routee-member/src/test/java/org/sopt/routee/member/internal/service/MemberServiceTest.java b/routee-member/src/test/java/org/sopt/routee/member/internal/service/MemberServiceTest.java new file mode 100644 index 00000000..30ea1862 --- /dev/null +++ b/routee-member/src/test/java/org/sopt/routee/member/internal/service/MemberServiceTest.java @@ -0,0 +1,142 @@ +package org.sopt.routee.member.internal.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.Optional; +import java.util.function.Consumer; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.sopt.routee.activity.api.usecase.ActivityUseCase; +import org.sopt.routee.external.api.port.FileDeletePort; +import org.sopt.routee.external.api.port.FileImageAccessUrlPort; +import org.sopt.routee.external.api.port.FileUploadPresignPort; +import org.sopt.routee.external.api.port.OidcVerifyPort; +import org.sopt.routee.member.api.event.MemberWithdrawnEvent; +import org.sopt.routee.member.internal.entity.Member; +import org.sopt.routee.member.internal.exception.MemberNotFoundException; +import org.sopt.routee.member.internal.repository.MemberAgreementRepository; +import org.sopt.routee.member.internal.repository.MemberRepository; +import org.sopt.routee.member.internal.service.validator.ProfileImageFileNameValidator; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.TransactionTemplate; + +@ExtendWith(MockitoExtension.class) +class MemberServiceTest { + + private static final long MEMBER_ID = 1L; + private static final String ACCESS_TOKEN_HASH = "access-hash"; + private static final String REFRESH_TOKEN_HASH = "refresh-hash"; + + @Mock + private OidcVerifyPort oidcVerifyPort; + + @Mock + private ActivityUseCase activityUseCase; + + @Mock + private MemberRepository memberRepository; + + @Mock + private MemberAgreementRepository memberAgreementRepository; + + @Mock + private ApplicationEventPublisher applicationEventPublisher; + + @Mock + private FileUploadPresignPort fileUploadPresignPort; + + @Mock + private FileImageAccessUrlPort fileImageAccessUrlPort; + + @Mock + private FileDeletePort fileDeletePort; + + @Mock + private ProfileImageFileNameValidator profileImageFileNameValidator; + + @Mock + private TransactionTemplate transactionTemplate; + + private MemberService memberService; + + @BeforeEach + void setUp() { + memberService = new MemberService( + oidcVerifyPort, + activityUseCase, + memberRepository, + memberAgreementRepository, + applicationEventPublisher, + fileUploadPresignPort, + fileImageAccessUrlPort, + fileDeletePort, + profileImageFileNameValidator, + transactionTemplate + ); + } + + @SuppressWarnings("unchecked") + private void stubTransactionTemplateToRunCallback() { + doAnswer(invocation -> { + Consumer callback = invocation.getArgument(0); + callback.accept(mock(TransactionStatus.class)); + return null; + }).when(transactionTemplate).executeWithoutResult(any(Consumer.class)); + } + + @Test + @DisplayName("withdraw: 정상적으로 회원을 탈퇴시키고 토큰 무효화 이벤트를 발행한다") + void withdraw_success_publishesMemberWithdrawnEvent() { + // given + stubTransactionTemplateToRunCallback(); + Member member = mock(Member.class); + when(memberRepository.findById(MEMBER_ID)).thenReturn(Optional.of(member)); + + // when + memberService.withdraw(MEMBER_ID, ACCESS_TOKEN_HASH, REFRESH_TOKEN_HASH); + + // then + verify(memberAgreementRepository).deleteByMember_Id(MEMBER_ID); + verify(memberRepository).delete(member); + verify(activityUseCase).deleteForMemberWithdrawal(MEMBER_ID); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(MemberWithdrawnEvent.class); + verify(applicationEventPublisher).publishEvent(eventCaptor.capture()); + assertThat(eventCaptor.getValue()) + .extracting( + MemberWithdrawnEvent::memberId, + MemberWithdrawnEvent::accessTokenHash, + MemberWithdrawnEvent::refreshTokenHash) + .containsExactly(MEMBER_ID, ACCESS_TOKEN_HASH, REFRESH_TOKEN_HASH); + } + + @Test + @DisplayName("withdraw: 동시 탈퇴 요청으로 낙관적 락 예외가 발생하면 회원 없음 예외로 변환한다") + void withdraw_concurrentWithdrawal_throwsMemberNotFoundException() { + // given + stubTransactionTemplateToRunCallback(); + Member member = mock(Member.class); + when(memberRepository.findById(MEMBER_ID)).thenReturn(Optional.of(member)); + doThrow(new ObjectOptimisticLockingFailureException(Member.class, MEMBER_ID)) + .when(memberAgreementRepository).deleteByMember_Id(MEMBER_ID); + + // when + Throwable thrown = catchThrowable( + () -> memberService.withdraw(MEMBER_ID, ACCESS_TOKEN_HASH, REFRESH_TOKEN_HASH)); + + // then + assertThat(thrown).isInstanceOf(MemberNotFoundException.class); + verify(activityUseCase, never()).deleteForMemberWithdrawal(anyLong()); + verify(applicationEventPublisher, never()).publishEvent(any()); + } +}