
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.Environment의Environment객체.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 Layer가Slack을의존하는 대신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 Layer와Slack을 분리
▶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 에러발생 확인