Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
7aab517
[#315] refactor: dto 패키지 application 패키지 하위로 이동
move-hoon Mar 9, 2025
3fe3d08
[#315] refactor: 응답 dto response 패키지로 이동
move-hoon Mar 9, 2025
7ea24d8
[#315] chore(build.gradle): slack webhook 및 @ConfigurationProperties을…
move-hoon Mar 10, 2025
82dbafd
[#315] chore: slack 웹훅 경로 및 스레드 풀 사이즈 설정 추가
move-hoon Mar 10, 2025
df20e9a
[#315] feat(MemberRegistrationService): 회원가입 이벤트를 보내는 publisher 추가
move-hoon Mar 10, 2025
472394a
[#315] feat(MemberRegisteredEvent): 회원가입 시 이벤트로 유저의 닉네임을 담는 DTO 추가
move-hoon Mar 10, 2025
e1c6b84
[#315] feat(MemberRegisteredEventListener): 회원가입 이벤트를 받아서 비동기로 슬랙 서비스…
move-hoon Mar 10, 2025
503ac2b
[#315] feat: openFeign을 이용해 slack webhook URL로 요청을 보내는 기능 추가
move-hoon Mar 10, 2025
f5653b4
[#315] feat: 회원가입 유저 수를 측정하는 서비스 로직 구현
move-hoon Mar 10, 2025
227660a
[#315] feat(GlobalAsyncExceptionHandler): 비동기 예외 핸들러 구현
move-hoon Mar 10, 2025
f358757
[#315] feat(AsyncThreadConfig): 스레드 풀 관련 Config 클래스 구현
move-hoon Mar 10, 2025
97ec4c7
[#315] feat(ThreadPoolProperties): 스레드 coreSize, namePrefix를 관리하는 Con…
move-hoon Mar 10, 2025
09ac955
[#315] chore(MemberRegisteredEventListener): 환영 메시지 변경
move-hoon Mar 10, 2025
1a1dcc2
[#315] chore(GlobalAsyncExceptionHandler): import문 최적화
move-hoon Mar 10, 2025
2ccdb4a
[#315] chore(JwtAuthenticationFilter): 토큰 없는 API 요청 로깅 레벨 변경
move-hoon Mar 10, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/com/beat/BeatApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -14,6 +15,7 @@
@EnableScheduling
@EnableAsync
@EnableAspectJAutoProxy
@ConfigurationPropertiesScan
@ImportAutoConfiguration({FeignAutoConfiguration.class})
public class BeatApplication {

Expand Down
4 changes: 2 additions & 2 deletions src/main/java/com/beat/domain/member/api/MemberApi.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -30,7 +30,6 @@
@Service
@RequiredArgsConstructor
public class AuthenticationService {
private static final String BEARER_PREFIX = "Bearer ";
private final JwtTokenProvider jwtTokenProvider;
private final TokenService tokenService;

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;

Expand All @@ -18,6 +20,7 @@
@RequiredArgsConstructor
public class MemberRegistrationService {

private final ApplicationEventPublisher eventPublisher;
private final UserRepository userRepository;
private final MemberRepository memberRepository;

Expand All @@ -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();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,18 @@ 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)
.orElseThrow(() -> new NotFoundException(MemberErrorCode.MEMBER_NOT_FOUND));

userRepository.delete(users);
}
}

@Override
@Transactional(readOnly = true)
public long countMembers() {
return memberRepository.count();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -116,4 +115,4 @@ private Long findOrRegisterMember(final MemberInfoResponse memberInfoResponse) {

return memberRegistrationService.registerMemberWithUserInfo(memberInfoResponse);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.beat.domain.member.application.dto.event;

public record MemberRegisteredEvent(
String nickname
) {
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.beat.domain.member.dto;
package com.beat.domain.member.application.dto.response;

public record AccessTokenGenerateResponse(
String accessToken
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.beat.domain.member.dto;
package com.beat.domain.member.application.dto.response;

public record LoginSuccessResponse(
String accessToken,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.beat.domain.member.dto;
package com.beat.domain.member.application.dto.response;

public record MemberLoginResponse(
String accessToken,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,6 @@ public interface MemberUseCase {
Member findMemberBySocialIdAndSocialType(Long socialId, SocialType socialType);

void deleteUser(Long id);

long countMembers();
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -109,4 +109,4 @@ private String getJwtFromRequest(HttpServletRequest request) {
}
return null;
}
}
}
40 changes: 40 additions & 0 deletions src/main/java/com/beat/global/common/config/AsyncThreadConfig.java
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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<String, String> payload);
}
Original file line number Diff line number Diff line change
@@ -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<String, String> payload) {
slackClient.sendMessage(payload);
}
}
Original file line number Diff line number Diff line change
@@ -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<String, String> 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);
}
Comment on lines +35 to +39
Copy link

@coderabbitai coderabbitai bot Mar 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Improve error handling for non-critical notification failures

The current implementation throws a RuntimeException when Slack notification fails, which might not be appropriate for a non-critical background task. Consider logging the error instead of throwing an exception, since a failed notification shouldn't impact the core application flow.

try {
    slackService.sendMessage(payload);
} catch (Exception e) {
-   throw new RuntimeException(SLACK_TRANSFER_ERROR);
+   log.error(SLACK_TRANSFER_ERROR, e);
}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try {
slackService.sendMessage(payload);
} catch (Exception e) {
throw new RuntimeException(SLACK_TRANSFER_ERROR);
}
try {
slackService.sendMessage(payload);
} catch (Exception e) {
log.error(SLACK_TRANSFER_ERROR, e);
}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't just log.error() make it go to GlobalAsyncExceptionHandler and miss exception handling?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@hoonyworld Actually, it would be the opposite - using log.error() would prevent the exception from reaching the GlobalAsyncExceptionHandler.

Here's how it works:

  1. The GlobalAsyncExceptionHandler only handles uncaught exceptions from @Async methods
  2. In your current code, you're catching the exception but then throwing a new RuntimeException, which becomes an uncaught exception that will be handled by GlobalAsyncExceptionHandler
  3. If you replace it with log.error(), you're fully handling the exception within the method, so nothing propagates to GlobalAsyncExceptionHandler

If you want both logging and to have the exception handled by GlobalAsyncExceptionHandler, you could do:

try {
    slackService.sendMessage(payload);
} catch (Exception e) {
    log.error(SLACK_TRANSFER_ERROR, e);
    throw new RuntimeException(SLACK_TRANSFER_ERROR, e);
}

But for non-critical notifications, simply logging the error is usually sufficient since you don't want notification failures to impact core functionality.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);
	}
}
  • 'GlobalAsyncExceptionHandler' is logging errors, so can I just throw RuntimeException without using log.error?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@hoonyworld Yes, you're absolutely right. Since your GlobalAsyncExceptionHandler is already set up to log uncaught exceptions from @Async methods, you can simply throw the RuntimeException without adding a redundant log.error() call.

When you throw:

throw new RuntimeException(SLACK_TRANSFER_ERROR);

The exception will propagate to the GlobalAsyncExceptionHandler, which will log the error with method name, parameters, and exception details.

This approach has the advantage of providing consistent error logging across all your async operations. Just be aware that when throwing the exception, you're marking the entire async operation as failed, but since this is a non-critical notification feature running after transaction commit, that's a reasonable approach.

}
}
8 changes: 8 additions & 0 deletions src/main/resources/application-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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-
8 changes: 8 additions & 0 deletions src/main/resources/application-prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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-