diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/member/controller/AdminMemberController.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/member/controller/AdminMemberController.java index 5122dc2..677d635 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/member/controller/AdminMemberController.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/member/controller/AdminMemberController.java @@ -154,7 +154,7 @@ public ResponseEntity updatePhotoUrl(@PathVariable Long memberId, @Override @PostMapping("/{memberId}/photo/slack-sync") public ResponseEntity syncPhotoFromSlack(@PathVariable Long memberId) { - return ResponseEntity.ok(new PhotoUrlResponse(memberDirectoryService.syncProfileImageFromSlack(memberId))); + return ResponseEntity.ok(new PhotoUrlResponse(slackProfileSyncService.syncOne(memberId))); } @Override diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/member/service/MemberDirectoryService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/member/service/MemberDirectoryService.java index 695810e..2a7bc06 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/member/service/MemberDirectoryService.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/member/service/MemberDirectoryService.java @@ -6,7 +6,6 @@ import com.bcsdlab.bcsdinternalapiv2.member.controller.dto.response.AdminMemberDirectoryResponse; import com.bcsdlab.bcsdinternalapiv2.member.controller.dto.response.MemberDirectoryResponse; import com.bcsdlab.bcsdinternalapiv2.member.controller.dto.response.PhotoPresignedUrlResponse; -import com.bcsdlab.bcsdinternalapiv2.member.client.SlackClient; import com.bcsdlab.bcsdinternalapiv2.member.exception.MemberException; import com.bcsdlab.bcsdinternalapiv2.member.exception.MemberExceptionType; import com.bcsdlab.bcsdinternalapiv2.member.model.AcademicStatus; @@ -43,7 +42,6 @@ public class MemberDirectoryService { private final TrackMasterRepository trackMasterRepository; private final PositionRepository positionRepository; private final PhotoStorageService photoStorageService; - private final SlackClient slackClient; @Transactional(readOnly = true) public MemberDirectoryResponse getDirectory(MemberDirectoryQuery query, Pageable pageable) { @@ -140,16 +138,6 @@ public void updatePhotoUrl(Long memberId, String photoUrl) { member.updateProfileImageUrl(photoUrl); } - @Transactional - public String syncProfileImageFromSlack(Long memberId) { - Member member = memberRepository.findByIdForUpdate(memberId) - .orElseThrow(() -> new MemberException(MemberExceptionType.MEMBER_NOT_FOUND)); - String imageUrl = slackClient.findProfileImageUrlByEmail(member.getEmail()) - .orElseThrow(() -> new MemberException(MemberExceptionType.SLACK_PROFILE_NOT_FOUND)); - member.updateProfileImageUrl(imageUrl); - return imageUrl; - } - private boolean isBlank(String value) { return value == null || value.isBlank(); } diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/member/service/SlackProfileSyncService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/member/service/SlackProfileSyncService.java index ff5f65b..03b59b4 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/member/service/SlackProfileSyncService.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/member/service/SlackProfileSyncService.java @@ -38,6 +38,19 @@ public class SlackProfileSyncService { private final SlackClient slackClient; private final MemberDirectoryService memberDirectoryService; + /** + * 한 회원의 프로필 사진을 Slack(이메일 조회)에서 가져와 저장한다. Slack 호출은 트랜잭션과 행 잠금 밖에서 하고, + * 저장만 짧은 트랜잭션으로 한다 — 잠금을 쥔 채 외부 호출을 기다리면 같은 회원의 로그인·토큰 갱신이 막힌다. + */ + public String syncOne(Long memberId) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new MemberException(MemberExceptionType.MEMBER_NOT_FOUND)); + String imageUrl = slackClient.findProfileImageUrlByEmail(member.getEmail()) + .orElseThrow(() -> new MemberException(MemberExceptionType.SLACK_PROFILE_NOT_FOUND)); + memberDirectoryService.updatePhotoUrl(memberId, imageUrl); + return imageUrl; + } + public SlackProfileSyncResponse syncAll() { // 가입 전·잠김 회원도 홈페이지에 노출될 수 있으므로 탈퇴한 회원만 제외한다. List members = memberRepository.findAllByStatusNot(MemberStatus.WITHDRAWN); diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/member/MemberSlackIdIntegrationTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/member/MemberSlackIdIntegrationTest.java index 73558ec..f5eccbe 100644 --- a/src/test/java/com/bcsdlab/bcsdinternalapiv2/member/MemberSlackIdIntegrationTest.java +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/member/MemberSlackIdIntegrationTest.java @@ -27,13 +27,16 @@ import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackMasterRepository; import com.fasterxml.jackson.databind.ObjectMapper; import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.support.TransactionSynchronizationManager; @TestPropertySource(properties = "app.mail.transport=log") class MemberSlackIdIntegrationTest extends IntegrationTestSupport { @@ -343,4 +346,24 @@ private String login(String studentNumber) throws Exception { .getContentAsString(); return objectMapper.readTree(responseBody).get("accessToken").asText(); } + + @Test + @DisplayName("Slack 사진 동기화는 트랜잭션(회원 행 잠금) 밖에서 Slack을 부르고 사진을 저장한다") + void Slack_사진_동기화는_트랜잭션_밖에서_Slack을_부른다() throws Exception { + Member member = saveMember("20240021", "윤사진", MemberRole.MEMBER); + AtomicBoolean calledInTransaction = new AtomicBoolean(true); + when(slackClient.findProfileImageUrlByEmail(member.getEmail())).thenAnswer(invocation -> { + calledInTransaction.set(TransactionSynchronizationManager.isActualTransactionActive()); + return Optional.of("https://avatars.slack-edge.com/photo.png"); + }); + + mockMvc.perform(post("/v1/admin/members/" + member.getId() + "/photo/slack-sync") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.photoUrl").value("https://avatars.slack-edge.com/photo.png")); + + assertThat(calledInTransaction).isFalse(); + assertThat(memberRepository.findById(member.getId()).orElseThrow().getProfileImageUrl()) + .isEqualTo("https://avatars.slack-edge.com/photo.png"); + } }