diff --git a/build.gradle b/build.gradle index 41b4fc21..4f8a9592 100644 --- a/build.gradle +++ b/build.gradle @@ -106,6 +106,12 @@ dependencies { // AOP implementation 'org.springframework.boot:spring-boot-starter-aop' + + // Slack Webhook + implementation 'com.slack.api:slack-api-client:1.45.3' + + // @ConfigurationProperties auto bean register + annotationProcessor "org.springframework.boot:spring-boot-configuration-processor" } jar { diff --git a/src/main/java/com/beat/BeatApplication.java b/src/main/java/com/beat/BeatApplication.java index 3dc74923..7dfb7785 100644 --- a/src/main/java/com/beat/BeatApplication.java +++ b/src/main/java/com/beat/BeatApplication.java @@ -3,6 +3,7 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.ImportAutoConfiguration; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.cloud.openfeign.EnableFeignClients; import org.springframework.cloud.openfeign.FeignAutoConfiguration; import org.springframework.context.annotation.EnableAspectJAutoProxy; @@ -14,6 +15,7 @@ @EnableScheduling @EnableAsync @EnableAspectJAutoProxy +@ConfigurationPropertiesScan @ImportAutoConfiguration({FeignAutoConfiguration.class}) public class BeatApplication { diff --git a/src/main/java/com/beat/domain/member/api/MemberApi.java b/src/main/java/com/beat/domain/member/api/MemberApi.java index 1f2335b9..25bb3a81 100644 --- a/src/main/java/com/beat/domain/member/api/MemberApi.java +++ b/src/main/java/com/beat/domain/member/api/MemberApi.java @@ -5,8 +5,8 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; -import com.beat.domain.member.dto.AccessTokenGenerateResponse; -import com.beat.domain.member.dto.MemberLoginResponse; +import com.beat.domain.member.application.dto.response.AccessTokenGenerateResponse; +import com.beat.domain.member.application.dto.response.MemberLoginResponse; import com.beat.global.auth.annotation.CurrentMember; import com.beat.global.auth.client.dto.MemberLoginRequest; import com.beat.global.common.dto.ErrorResponse; diff --git a/src/main/java/com/beat/domain/member/api/MemberController.java b/src/main/java/com/beat/domain/member/api/MemberController.java index ba4206b0..309ba2e0 100644 --- a/src/main/java/com/beat/domain/member/api/MemberController.java +++ b/src/main/java/com/beat/domain/member/api/MemberController.java @@ -12,9 +12,9 @@ import com.beat.domain.member.application.AuthenticationService; import com.beat.domain.member.application.SocialLoginService; -import com.beat.domain.member.dto.AccessTokenGenerateResponse; -import com.beat.domain.member.dto.LoginSuccessResponse; -import com.beat.domain.member.dto.MemberLoginResponse; +import com.beat.domain.member.application.dto.response.AccessTokenGenerateResponse; +import com.beat.domain.member.application.dto.response.LoginSuccessResponse; +import com.beat.domain.member.application.dto.response.MemberLoginResponse; import com.beat.domain.member.exception.MemberSuccessCode; import com.beat.global.auth.annotation.CurrentMember; import com.beat.global.auth.client.dto.MemberLoginRequest; diff --git a/src/main/java/com/beat/domain/member/application/AuthenticationService.java b/src/main/java/com/beat/domain/member/application/AuthenticationService.java index 5984d7fb..94469954 100644 --- a/src/main/java/com/beat/domain/member/application/AuthenticationService.java +++ b/src/main/java/com/beat/domain/member/application/AuthenticationService.java @@ -1,7 +1,7 @@ package com.beat.domain.member.application; -import com.beat.domain.member.dto.AccessTokenGenerateResponse; -import com.beat.domain.member.dto.LoginSuccessResponse; +import com.beat.domain.member.application.dto.response.AccessTokenGenerateResponse; +import com.beat.domain.member.application.dto.response.LoginSuccessResponse; import com.beat.domain.user.domain.Role; import com.beat.domain.user.domain.Users; import com.beat.global.auth.client.dto.MemberInfoResponse; @@ -30,7 +30,6 @@ @Service @RequiredArgsConstructor public class AuthenticationService { - private static final String BEARER_PREFIX = "Bearer "; private final JwtTokenProvider jwtTokenProvider; private final TokenService tokenService; diff --git a/src/main/java/com/beat/domain/member/application/MemberRegistrationService.java b/src/main/java/com/beat/domain/member/application/MemberRegistrationService.java index 5f19fc65..d2f3b7d5 100644 --- a/src/main/java/com/beat/domain/member/application/MemberRegistrationService.java +++ b/src/main/java/com/beat/domain/member/application/MemberRegistrationService.java @@ -1,5 +1,6 @@ package com.beat.domain.member.application; +import com.beat.domain.member.application.dto.event.MemberRegisteredEvent; import com.beat.domain.member.dao.MemberRepository; import com.beat.domain.member.domain.Member; import com.beat.domain.user.dao.UserRepository; @@ -10,6 +11,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -18,6 +20,7 @@ @RequiredArgsConstructor public class MemberRegistrationService { + private final ApplicationEventPublisher eventPublisher; private final UserRepository userRepository; private final MemberRepository memberRepository; @@ -41,9 +44,10 @@ public Long registerMemberWithUserInfo(final MemberInfoResponse memberInfoRespon ); memberRepository.save(member); - log.info("Member registered with memberId: {}, role: {}", member.getId(), users.getRole()); + eventPublisher.publishEvent(new MemberRegisteredEvent(member.getNickname())); + return member.getId(); } -} \ No newline at end of file +} diff --git a/src/main/java/com/beat/domain/member/application/MemberService.java b/src/main/java/com/beat/domain/member/application/MemberService.java index 5fdb3a4b..2765589b 100644 --- a/src/main/java/com/beat/domain/member/application/MemberService.java +++ b/src/main/java/com/beat/domain/member/application/MemberService.java @@ -43,6 +43,7 @@ public Member findMemberBySocialIdAndSocialType(final Long socialId, final Socia .orElseThrow(() -> new NotFoundException(MemberErrorCode.MEMBER_NOT_FOUND)); } + @Override @Transactional public void deleteUser(final Long id) { Users users = userRepository.findById(id) @@ -50,4 +51,10 @@ public void deleteUser(final Long id) { userRepository.delete(users); } -} \ No newline at end of file + + @Override + @Transactional(readOnly = true) + public long countMembers() { + return memberRepository.count(); + } +} diff --git a/src/main/java/com/beat/domain/member/application/SocialLoginService.java b/src/main/java/com/beat/domain/member/application/SocialLoginService.java index 36b304c9..8c12b8f7 100644 --- a/src/main/java/com/beat/domain/member/application/SocialLoginService.java +++ b/src/main/java/com/beat/domain/member/application/SocialLoginService.java @@ -2,11 +2,10 @@ import com.beat.domain.member.domain.Member; import com.beat.domain.member.domain.SocialType; -import com.beat.domain.member.dto.LoginSuccessResponse; +import com.beat.domain.member.application.dto.response.LoginSuccessResponse; import com.beat.domain.member.exception.MemberErrorCode; import com.beat.domain.member.port.in.MemberUseCase; import com.beat.domain.user.domain.Users; -import com.beat.domain.user.port.in.UserUseCase; import com.beat.global.auth.client.application.KakaoSocialService; import com.beat.global.auth.client.application.SocialService; import com.beat.global.auth.client.dto.MemberInfoResponse; @@ -116,4 +115,4 @@ private Long findOrRegisterMember(final MemberInfoResponse memberInfoResponse) { return memberRegistrationService.registerMemberWithUserInfo(memberInfoResponse); } -} \ No newline at end of file +} diff --git a/src/main/java/com/beat/domain/member/application/dto/event/MemberRegisteredEvent.java b/src/main/java/com/beat/domain/member/application/dto/event/MemberRegisteredEvent.java new file mode 100644 index 00000000..acbfa714 --- /dev/null +++ b/src/main/java/com/beat/domain/member/application/dto/event/MemberRegisteredEvent.java @@ -0,0 +1,6 @@ +package com.beat.domain.member.application.dto.event; + +public record MemberRegisteredEvent( + String nickname +) { +} diff --git a/src/main/java/com/beat/domain/member/dto/AccessTokenGenerateResponse.java b/src/main/java/com/beat/domain/member/application/dto/response/AccessTokenGenerateResponse.java similarity index 78% rename from src/main/java/com/beat/domain/member/dto/AccessTokenGenerateResponse.java rename to src/main/java/com/beat/domain/member/application/dto/response/AccessTokenGenerateResponse.java index db4be892..7d99fc9f 100644 --- a/src/main/java/com/beat/domain/member/dto/AccessTokenGenerateResponse.java +++ b/src/main/java/com/beat/domain/member/application/dto/response/AccessTokenGenerateResponse.java @@ -1,4 +1,4 @@ -package com.beat.domain.member.dto; +package com.beat.domain.member.application.dto.response; public record AccessTokenGenerateResponse( String accessToken diff --git a/src/main/java/com/beat/domain/member/dto/LoginSuccessResponse.java b/src/main/java/com/beat/domain/member/application/dto/response/LoginSuccessResponse.java similarity index 85% rename from src/main/java/com/beat/domain/member/dto/LoginSuccessResponse.java rename to src/main/java/com/beat/domain/member/application/dto/response/LoginSuccessResponse.java index e8f19d22..9c037adf 100644 --- a/src/main/java/com/beat/domain/member/dto/LoginSuccessResponse.java +++ b/src/main/java/com/beat/domain/member/application/dto/response/LoginSuccessResponse.java @@ -1,4 +1,4 @@ -package com.beat.domain.member.dto; +package com.beat.domain.member.application.dto.response; public record LoginSuccessResponse( String accessToken, diff --git a/src/main/java/com/beat/domain/member/dto/MemberLoginResponse.java b/src/main/java/com/beat/domain/member/application/dto/response/MemberLoginResponse.java similarity index 82% rename from src/main/java/com/beat/domain/member/dto/MemberLoginResponse.java rename to src/main/java/com/beat/domain/member/application/dto/response/MemberLoginResponse.java index 38b67af2..e4be92c5 100644 --- a/src/main/java/com/beat/domain/member/dto/MemberLoginResponse.java +++ b/src/main/java/com/beat/domain/member/application/dto/response/MemberLoginResponse.java @@ -1,4 +1,4 @@ -package com.beat.domain.member.dto; +package com.beat.domain.member.application.dto.response; public record MemberLoginResponse( String accessToken, diff --git a/src/main/java/com/beat/domain/member/port/in/MemberUseCase.java b/src/main/java/com/beat/domain/member/port/in/MemberUseCase.java index 328a2f2f..36b19a34 100644 --- a/src/main/java/com/beat/domain/member/port/in/MemberUseCase.java +++ b/src/main/java/com/beat/domain/member/port/in/MemberUseCase.java @@ -11,4 +11,6 @@ public interface MemberUseCase { Member findMemberBySocialIdAndSocialType(Long socialId, SocialType socialType); void deleteUser(Long id); + + long countMembers(); } diff --git a/src/main/java/com/beat/global/auth/jwt/filter/JwtAuthenticationFilter.java b/src/main/java/com/beat/global/auth/jwt/filter/JwtAuthenticationFilter.java index 9177cca6..be49bd3e 100644 --- a/src/main/java/com/beat/global/auth/jwt/filter/JwtAuthenticationFilter.java +++ b/src/main/java/com/beat/global/auth/jwt/filter/JwtAuthenticationFilter.java @@ -42,7 +42,7 @@ protected void doFilterInternal(@NonNull HttpServletRequest request, final String token = getJwtFromRequest(request); if (!StringUtils.hasText(token)) { - log.info("JWT Token not found in request header. Assuming guest access or public API request."); + log.debug("JWT Token not found in request header. Assuming guest access or public API request."); filterChain.doFilter(request, response); return; } @@ -109,4 +109,4 @@ private String getJwtFromRequest(HttpServletRequest request) { } return null; } -} \ No newline at end of file +} diff --git a/src/main/java/com/beat/global/common/config/AsyncThreadConfig.java b/src/main/java/com/beat/global/common/config/AsyncThreadConfig.java new file mode 100644 index 00000000..8bf61512 --- /dev/null +++ b/src/main/java/com/beat/global/common/config/AsyncThreadConfig.java @@ -0,0 +1,40 @@ +package com.beat.global.common.config; + +import java.util.concurrent.Executor; +import java.util.concurrent.ThreadPoolExecutor; + +import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.AsyncConfigurer; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import org.springframework.security.concurrent.DelegatingSecurityContextExecutor; + +import com.beat.global.common.handler.GlobalAsyncExceptionHandler; + +import lombok.RequiredArgsConstructor; + +@Configuration +@EnableAsync +@RequiredArgsConstructor +public class AsyncThreadConfig implements AsyncConfigurer { + private final ThreadPoolProperties threadPoolProperties; + + @Override + @Bean(name = "taskExecutor") + public Executor getAsyncExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(threadPoolProperties.getCoreSize()); + executor.setThreadNamePrefix(threadPoolProperties.getThreadNamePrefix()); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + executor.initialize(); + return new DelegatingSecurityContextExecutor(executor.getThreadPoolExecutor()); + } + + @Override + @Bean + public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { + return new GlobalAsyncExceptionHandler(); + } +} diff --git a/src/main/java/com/beat/global/common/config/ThreadPoolProperties.java b/src/main/java/com/beat/global/common/config/ThreadPoolProperties.java new file mode 100644 index 00000000..5d896fe0 --- /dev/null +++ b/src/main/java/com/beat/global/common/config/ThreadPoolProperties.java @@ -0,0 +1,14 @@ +package com.beat.global.common.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@ConfigurationProperties(prefix = "thread-pool") +@RequiredArgsConstructor +@Getter +public class ThreadPoolProperties { + private final int coreSize; + private final String threadNamePrefix; +} diff --git a/src/main/java/com/beat/global/common/handler/GlobalAsyncExceptionHandler.java b/src/main/java/com/beat/global/common/handler/GlobalAsyncExceptionHandler.java new file mode 100644 index 00000000..481041a2 --- /dev/null +++ b/src/main/java/com/beat/global/common/handler/GlobalAsyncExceptionHandler.java @@ -0,0 +1,28 @@ +package com.beat.global.common.handler; + +import java.lang.reflect.Method; +import java.util.Arrays; + +import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler; +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class GlobalAsyncExceptionHandler implements AsyncUncaughtExceptionHandler { + + @Override + public void handleUncaughtException(@NonNull Throwable ex, @NonNull Method method, @Nullable Object... params) { + if (params == null) { + log.error("비동기 작업 중 예외 발생! Method: [{}], Params: [null], Exception: [{}]", + method.getName(), ex.getMessage(), ex); + return; + } + + String paramValues = Arrays.toString(params); + + log.error("비동기 작업 중 예외 발생! Method: [{}], Params: [{}], Exception: [{}]", + method.getName(), paramValues, ex.getMessage(), ex); + } +} diff --git a/src/main/java/com/beat/global/external/notification/slack/application/SlackClient.java b/src/main/java/com/beat/global/external/notification/slack/application/SlackClient.java new file mode 100644 index 00000000..754d70a4 --- /dev/null +++ b/src/main/java/com/beat/global/external/notification/slack/application/SlackClient.java @@ -0,0 +1,15 @@ +package com.beat.global.external.notification.slack.application; + +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + +import java.util.Map; + +@FeignClient(name = "slackClient", url = "${slack.webhook.url}") +public interface SlackClient { + + @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) + void sendMessage(@RequestBody Map payload); +} diff --git a/src/main/java/com/beat/global/external/notification/slack/application/SlackService.java b/src/main/java/com/beat/global/external/notification/slack/application/SlackService.java new file mode 100644 index 00000000..77e98fd4 --- /dev/null +++ b/src/main/java/com/beat/global/external/notification/slack/application/SlackService.java @@ -0,0 +1,18 @@ +package com.beat.global.external.notification.slack.application; + +import java.util.Map; + +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class SlackService { + + private final SlackClient slackClient; + + public void sendMessage(Map payload) { + slackClient.sendMessage(payload); + } +} diff --git a/src/main/java/com/beat/global/external/notification/slack/event/MemberRegisteredEventListener.java b/src/main/java/com/beat/global/external/notification/slack/event/MemberRegisteredEventListener.java new file mode 100644 index 00000000..8f0c9ff3 --- /dev/null +++ b/src/main/java/com/beat/global/external/notification/slack/event/MemberRegisteredEventListener.java @@ -0,0 +1,41 @@ +package com.beat.global.external.notification.slack.event; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import com.beat.domain.member.application.dto.event.MemberRegisteredEvent; +import com.beat.domain.member.port.in.MemberUseCase; +import com.beat.global.external.notification.slack.application.SlackService; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class MemberRegisteredEventListener { + private static final String TEXT_KEY = "text"; + private static final String WELCOME_MESSAGE = "번째 유저가 회원가입했띠예 🎉🎉 - "; + private static final String SLACK_TRANSFER_ERROR = "Slack 전송 실패"; + + private final MemberUseCase memberUseCase; + private final SlackService slackService; + + @Async("taskExecutor") + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void sendSlackNotification(MemberRegisteredEvent event) { + Map payload = new HashMap<>(); + payload.put(TEXT_KEY, memberUseCase.countMembers() + WELCOME_MESSAGE + event.nickname()); + + try { + slackService.sendMessage(payload); + } catch (Exception e) { + throw new RuntimeException(SLACK_TRANSFER_ERROR); + } + } +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index b7fe1bc4..afcf2920 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -114,3 +114,11 @@ springdoc: operations-sorter: alpha display-request-duration: true urls-primary-name: general + +slack: + webhook: + url: ${DEV_SLACK_WEBHOOK_URL} + +thread-pool: + core-size: 2 + thread-name-prefix: executor- diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 403f2df8..74166219 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -113,3 +113,11 @@ springdoc: operations-sorter: alpha display-request-duration: true urls-primary-name: general + +slack: + webhook: + url: ${PROD_SLACK_WEBHOOK_URL} + +thread-pool: + core-size: 2 + thread-name-prefix: executor-