Spring - Slack 연동 및 EDA 활용

TopOfTheHead·2025년 11월 24일
post-thumbnail

Slack 의존성
implementation 'com.slack.api:slack-api-client:1.43.1'

Slack 토큰 발급

  • Slack App채널 생성 및 채널 ID 복사

    ▶ 하단의 채널 ID 복사

  • Slack API 홈페이지 : SlackApi - YourApps 진입
    Create New App - From scratch - 워크스페이스 설정 및 이름 설정 후 Create App

  • OAuth & Permissions - Scopes 설정
    Bot Token Scopes : chat:write 설정

  • OAuth Tokens에서 Install to 워크스페이스명 클릭 후 허용
    。이후 토큰이 발급되므로 해당 토큰을 복사

  • Slack App에서 채널 - 통합 - 앱 추가 진입 후 사용할 Bot 지정

발급한 Slack토큰채널코드yml에 입력
。발급 이후 yml파일에 설정 후 @ConfigurationProperties를 통해 읽어오기

slack:
  bot-token: ${slack.token}
  log-channel: ${slack.logchannel}
@ConfigurationProperties("slack")
public record SlackProperties(
	String botToken,
	String logChannel
) {]
@Getter
@Component
@RequiredArgsConstructor
@EnableConfigurationProperties(SlackProperties.class)
public class SlackPropertiesPojo {
	private final SlackProperties slackProperties;
}

Slack을 통해 MethodsClient를 생성하는 @Configuration Class 정의
Slack.getInstance.methods(Slack토큰)을 통해 MethodsClient 구현체 생성 후 Spring Bean으로 등록

@Configuration
@RequiredArgsConstructor
public class SlackConfiguration {
	private final SlackPropertiesPojo slackProperties;
	@Bean
	public MethodsClient methodsClient() {
		return Slack.getInstance().methods(slackProperties.getSlackProperties().botToken());
	}
}

Slack API 구축
。해당 클래스메서드에 구현된 기능을 통해 메시지Slack채널로 전송

@Profile을 통해 dev , prod프로필일 경우 Slack으로 전송하고 local의 경우 로그만 남기도록 설정하기.

public interface SlackApi {
	void notify(String message);
}
  • 프로필 : dev, prod 인 경우
    Slack메시지를 전송하도록 설정
    // 커스텀 어노테이션
@Retention(RetentionPolicy.RUNTIME)
// 프로필 - dev, prod는 Slack에 메시지를 전송하도록 설정
@Profile({"dev", "prod"})
public @interface AppProfile { }
@Slf4j // 로그 남기기 용도
@Component
@RequiredArgsConstructor
@AppProfile
public class DefaultSlackApiImpl implements SlackApi {
	private final MethodsClient methodsClient;
	private final SlackPropertiesPojo slackPropertiesPojo;
	private final Environment environment;
	@Override
	public void notify(String message){
		try{
			// 슬랙으로 전송
			methodsClient.chatPostMessage(request->{
				request.username("spring-bot")
					.channel(slackPropertiesPojo.getSlackProperties().logChannel())
					// Slack에서 ```문자열```은 코드블럭으로 설정됨
					.text(String.format("```%s - shopping-%s```", message,getActiveProfile()))
					.build();
				return request;
			});
		} catch (Exception e){
			log.error(e.getMessage());
		}
	}
	private String getActiveProfile(){
		// 현재 Active Profile을 가져오고 없으면 default로 "local"을 가져옴
		return Arrays.stream(environment.getActiveProfiles()).findFirst().orElse("local");
	}
}

Slack에서 "```문자열```"을 출력하는 경우 코드블럭으로 설정됨.
org.springframework.core.env.EnvironmentEnvironment객체.getActiveProfiles()를 통해 현재 작동중인 프로필을 가져와서 해당 문자열에 표시

  • 프로필 : local, default, test 인 경우
    Slack에 전송하지않고 로그메시지를 출력하도록 설정
    // 커스텀 어노테이션
@Retention(RetentionPolicy.RUNTIME)
// 프로필 - local, default, test는 Slack에 전송하지않고 로그에 메시지를 출력하도록 설정
@Profile({"local", "default", "test"})
public @interface LocalProfile { }
 @Slf4j // 로그 남기기 용도
@Component
@RequiredArgsConstructor
@LocalProfile
public class LocalSlackApiImpl implements SlackApi {
	private final MethodsClient methodsClient;
	private final SlackPropertiesPojo slackPropertiesPojo;
	private final Environment environment;
	@Override
	public void notify(String message){
		log.info(message);
	}
}

이후 진입점 클래스에서 SlackApi를 주입 및 구현한 기능을 활용하여 연동 확인
Spring Boot 구동 시 Slack채널메시지 출력

@SpringBootApplication
@RequiredArgsConstructor
public class ShoppingApplication {
	private final SlackApi slackApi;
	@EventListener(ApplicationReadyEvent.class)
	public void started(){
		slackApi.notify("Shopping Application Started");
	}
	public static void main(String[] args) {
		SpringApplication.run(ShoppingApplication.class, args);
	}
}


。이후 구동 시 다음처럼 에 의해 메시지가 출력되는것을 확인가능

EDA : ( Event Driven Architecture )
분산어플리케이션 서비스들이 이벤트를 기반으로 통신하는 아키텍처 패턴

저수준 모듈고수준 모듈의존의존성 역전를 처리할때 사용

ApplicationEventPublisher에 의해 이벤트 발행@EventListener이 선언된 메서드구독하는 형태
옵저버 패턴

ApplicationEventPublisher
이벤트 발행을 수행하는 인터페이스
구현체Spring Context에 등록되어 Spring Bean으로서 제공

  • ApplicationEventPublisher객체.publishEvent(클래스객체)
    。특정 클래스객체를 기반으로 이벤트발행하는 메서드
    @EventListener(클래스.class)가 선언된 불특정 메서드에게 클래스객체와 함께 이벤트 발행
    수신메서드는 자동으로 호출되어 실행
applicationEventPublisher.publishEvent(
			new MessageEvent("명칭 : %s의 상품이 생성".formatted(name))
		);

@EventListener(클래스.class)
이벤트 수신 시 선언된 메서드를 동작시키는 메서드 레벨 어노테이션
ApplicationEventPublisher객체.publishEvent(클래스객체)에 의해 인자에 해당하는 이벤트발행되는 경우 선언된 메서드를 자동으로 호출

이벤트를 수신한 메서드인자로 해당 클래스객체를 전달

@EventListener는 기본적으로 동기처리이므로 비동기처리메서드@Async를 추가선언
Spring에서 비동기처리 사용 시 진입점 클래스@EnableAsync를 추가선언

	// 매개변수로 수신한 클래스객체를 전달
	@EventListener(MessageEvent.class)
	public void onMessage(MessageEvent messageEvent){
		System.out.println(messageEvent.message());
	}

Slack 기능에 EDA 적용하기
저수준 모듈고수준 모듈의존의존성 역전를 수행 시 사용
Service Layer외부서비스Slack을 참조하는것을 방지하기위해 EDA를 사용

  • 기존 서비스의 문제
@Service
@Transactional
@RequiredArgsConstructor
public class ProductServiceImpl implements ProductService {
	private final ProductRepository productRepository;
    // 외부 서비스 의존
	private final SlackApi slackApi;
	@Override
	public void create(String name, Long price, Long quantity) {
		productRepository.save(
			ProductEntity.of(name, price, quantity)
		);
		slackApi.notify("명칭 : %s의 상품이 생성".formatted(name));
	}
}

Service외부 서비스Slack에 의존하여 저수준고수준의존.

  • POJO 클래스 작성
    ApplicationEventPublisher에 의한 이벤트 발생 시 전달할 메시지를 포함하는 POJO
    ▶ 해당 클래스 타입으로 이벤트 발행을 수행.
public record MessageEvent(
	String message
) {}
  • Service Layer에서 Slack 대신 ApplicationEventPublisher 사용하여 이벤트 발행하도록 리팩토링
    Service LayerSlack의존하는 대신 ApplicationEventPublisher을 통해 POJO를 발행하여 저수준 POJO의존하도록 설정
    ▶ 해당 POJO이벤트 발행Slack으로 전송하는 Listener 클래스에서 이벤트 수신Slack으로 전송
@Service
@Transactional
@RequiredArgsConstructor
public class ProductServiceImpl implements ProductService {
	private final ProductRepository productRepository;
	private final ApplicationEventPublisher applicationEventPublisher;
	@Override
	public void create(String name, Long price, Long quantity) {
		productRepository.save(
			ProductEntity.of(name, price, quantity)
		);		
        // POJO 사용
        applicationEventPublisher.publishEvent(
			new MessageEvent("명칭 : %s의 상품이 생성".formatted(name))
		);
	}
}
@SpringBootApplication
@RequiredArgsConstructor
public class ShoppingApplication {
	private final ApplicationEventPublisher publisher;	
        // ApplicationReadyEvent 클래스로 이벤트 발행 시 메서드 실행
        @EventListener(ApplicationReadyEvent.class)
	public void started(){
		publisher.publishEvent(new MessageEvent("Shopping Application Started"));
	}
	public static void main(String[] args) {
		SpringApplication.run(ShoppingApplication.class, args);
	}
}

▶ 위에서 작성한 진입점 클래스Slack 대신 ApplicationEventPublisher을 통한 POJO에 의존하도록 설정

  • Slack을 사용하는 Listener 역할의 클래스 정의
    @EventListener(POJO.class)를 선언하여 applicationEventPublisher.publishEvent( new POJO("메시지명")으로 이벤트 발행수신하여 Slack으로 메시지 전송
@Component
@RequiredArgsConstructor
public class NotificationListener {
	private final SlackApi slackApi;
	// ApplicationEventPublisher에 의해 MessageEvent 클래스로 이벤트 발행 시 수신하여 해당 메서드 실행
	// 매개변수로 수신한 클래스객체를 전달
	@EventListener(MessageEvent.class)
	public void onMessage(MessageEvent messageEvent){
		slackApi.notify(messageEvent.message());
	}
}

해당 클래스Slack을 격리하여 Service LayerSlack을 분리
Service Layer 단순하게 특정 클래스 객체를 통한 이벤트 발행 시 해당 Listener 클래스이벤트를 수신하여 Slack으로 메세지를 전달

HTTP Status Code : 500 Error 발생 시 Advice를 통해 Slack으로 Error를 전달하기
Advice

  • 예외발생문 간결화 용도의 Supporter 클래스 정의
    예외 발생 시 발생하는 많은양의 Stack trace를 축약하는 용도로 사용
  // Exception 발생 시 예외발생문 간결화하는 용도
public class Exceptions {
	private static final String START_WITH = "at "; // 예외는 at으로 시작하므로
	// 해당 구문으로 시작하는 예외메시지 제외하는 용도
	private static final List<String> BLOCKED_PACKAGES_START_WITH =
		List.of(
			"jdk",
			"org.spring",
			"java.base",
			"org.hibernate",
			"org.apache",
			"com.sun",
			"javax.servlet",
			"jakarta.servlet",
			"SpringCGLIB",
			"com.fasterxml.jackson",
			"jdk.internal",
			"io.netty",
			"reactor.core",
			"reactor.netty",
			"com.kt.aspect"
		);
	// Stacktrace에서 가져와서 에러메시지 가공
	public static String simplify(Throwable throwable) {
		var sw = new StringWriter();
		throwable.printStackTrace(new PrintWriter(sw));
		return truncate(sw.toString());
	}
	private static String truncate(String message) {
		return Arrays.stream(message.split("\n"))
			.filter(it -> !(it.contains(START_WITH) && containsAny(it)))
			.collect(Collectors.joining("\n"));
	}
	private static boolean containsAny(String source) {
		for (String keyword : BLOCKED_PACKAGES_START_WITH) {
			if (source.contains(keyword)) {
				return true;
			}
		}
		return false;
	}
}
  • Advice Class에서 예외 발생 시 축약된 예외문Slack으로 전송
    ApplicationEventPublisher을 통해 예외문을 포함한 특정 클래스객체이벤트를 발행
    Listener Class에서 해당 클래스이벤트를 수신받아 Slack으로 전달
@Slf4j
@Hidden
@RestControllerAdvice
@RequiredArgsConstructor
public class ApiAdvice {
	private final ApplicationEventPublisher applicationEventPublisher;
	// 가장 후순위로 예외를 처리하는 메서드
	@ExceptionHandler(Exception.class)
	public ResponseEntity<ErrorData> internalServerError(Exception e){
		log.error(Exceptions.simplify(e));
		applicationEventPublisher.publishEvent(
			new MessageEvent(
				Exceptions.simplify(e)
			)
		);
		return ApiAdvice.of(
			HttpStatus.INTERNAL_SERVER_ERROR,
			"에러입니다."
		);
	}
}


500 에러발생 확인

profile
공부기록 블로그

0개의 댓글