본문 바로가기
Programming/TDD Project

Pull Request 030. 바버샵 등록 기능 구현하기 - Slack WebHook 연동 및 비동기 이벤트 처리 해보기 + 계층 간 통신 고민하기

by JKROH 2024. 5. 20.
반응형

 처음 구상에는 바버샵을 네이버 지도 같은 데에서 크롤링해서 한 번에 쫙 등록하는 걸 생각했다. 그런데 직접 네이버 지도에 바버샵을 검색해보니 샵마다 등록된 정보가 너무 제각각이더라, 데이터를 하나의 형태로 통합해서 저장하는게 어려워보였다. 그래서 일단 등록은 바버들이 마음대로 할 수 있도록 진행했다.

 

 이렇게 진행하면 문제는 한 명의 바버가 너무 많은 샵을 등록할 수도 있고, 샵 자체를 장난으로 등록할 수도 있다. 따라서 서버측에서는 바버샵이 등록되었다는 정보를 알아야 할 필요가 있다. 이러한 문제를 해결하기 위해 일차원적인 해결 방안으로 서버측에 알람을 보내는 방법을 생각했다. Slack WebHook을 이용하면, 서버에서 발생한 상황을 Slack에 메세지로 전달하여 확인할 수 있다. 오늘은 바버샵을 등록하는 과정에서 Slack WebHook을 통해 서버에서 발생한 상황을 알림으로 보내는 형태의 구현을 진행했다.

 

 그런데 또 하나의 문제가 있다. Slack은 외부 API다. 이 외부 API와 연결되는 과정이 지나치게 길어지면 어떨까? 예를 들어서, 바버샵을 등록했는데 Slack에 메세지를 보내는 데 걸리는 시간이 엄~청나게 오래 걸리면 모든 과정이 멈추게 된다. 따라서 외부 API를 이용해 정보를 전달하는 과정은 비동기적으로 처리하여 바버샵 등록 과정과 등록 메세지를 날리는 과정을 분리하는 것이 더 좋은 방법일 것이다.

 

 이것을 구현하는 과정에서 한 가지 고민 사항이 생겼다. 그래서 메시지를 보내는 역할은 누가 시키는가? 일단 서버에서 무언가 처리하는 일이니, 이는 Service 계층에서 처리하는 것까지는 오케이다. 그런데, 그럼 이 역할을 수행하는 과정은 누가 시킬 것인가?에서 고민하게 됐다. 크게 두 가지 방법이 있다.

  1. Service Layer 간 통신을 통해 진행한다.
  2. Controller Layer에서 다른 Service Component를 호출해 진행한다.

 나는 첫 번째 방법을 사용했는데, 이유는 다음과 같다.

  • Controller Layer는 Client - Server 간 통신만을 담당해야지 내부 프로세스 처리를 담당하면 안된다.
    • 그러니까, Controller 클래스에서는 외부에서 뭔가 요청이 오면 그 요청을 기반으로 Service 계층에 툭 던져놓고 기다리다가 그 결과만 받아서 다시 넘겨줘야 한다는 것이다.
    • 그런데 1번 클래스에 저장을 요청하고 2번 클래스에 이벤트 발행을 요청하면, Controller의 역할을 넘어선 것이 된다. Controller가 서버의 요청 처리 흐름을 이해해버리게 된다.
  • 꼭 Controller만 Service 계층의 클래스를 호출할 필요는 없다. 같은 도메인 영역의 같은 계층 내에서는 충분히 통신이 가능하다.
    • 이벤트 발행은 특정 도메인에 속하지 않는다. 모든 도메인 영역에서 global하게 사용되는 영역이다.
    • 따라서 이벤트 도메인 -> 특정 도메인으로의 의존성만 설정되지 않는다면, 특정 도메인 -> 이벤트 도메인으로의 의존은 얼마든지 가능하다.

 이러한 이유로 Service 계층 내부에서 이벤트 처리를 담당하게 되었다.

 

 그러고 나면 또 다른 문제가 생긴다. 아주 그냥 문제의 연속이다. 만약 비동기 이벤트 처리를 진행했는데, 트랜잭션 수행 과정에서 오류가 발생해 롤백이 생긴다면? 당연히 이벤트도 진행되면 안된다. 만일 Controller 계층에서 이벤트 발행 처리를 담당한다면 이를 크게 고민할 필요는 없다. Service 계층부터는 Transactional을 전파해 DB 트랜잭션을 한 단위로 처리하기 때문이다. 그러나 Service 계층에서 이벤트를 발행한다면, 이는 고려해야할 사항이다. 이는 Spring Event의 기능을 이용해 처리할 수 있다.

 

 코드를 통해 알아보자.

 

PostBarberShopController

@RestController
@RequestMapping("/barber-shop")
public class PostBarberShopController {

    private final PostBarberUseCase postBarberUseCase;

    public PostBarberShopController(PostBarberUseCase postBarberUseCase) {
        this.postBarberUseCase = postBarberUseCase;
    }

    @PostMapping
    public ResponseEntity<BarberShopApiDto.Response> postBarberShop(@AuthenticationPrincipal UserDetails writer,
                                                                    @RequestBody BarberShopApiDto.Post dto) {
        PostBarberShopCommand command = PostBarberShopCommand.of(writer.getUsername(), dto);
        SingleBarberShopResponse commandResponse = postBarberUseCase.postBarberShop(command);
        BarberShopApiDto.Response responseDto = BarberShopApiDto.Response.of(commandResponse);
        return new ResponseEntity<>(responseDto, HttpStatus.CREATED);
    }
}

 

 Controller 계층의 코드다. 별 건 없다. 여타 Controller 코드와 같다.

 

PostBarberShopService

@Service
public class PostBarberShopService implements PostBarberUseCase {
    private final LoadMemberPort loadMemberPort;
    private final SaveBarberShopPort saveBarberShopPort;
    private final CreateBarberShopEventPublisher eventPublisher;

    public PostBarberShopService(LoadMemberPort loadMemberPort, SaveBarberShopPort saveBarberShopPort, CreateBarberShopEventPublisher eventPublisher) {
        this.loadMemberPort = loadMemberPort;
        this.saveBarberShopPort = saveBarberShopPort;
        this.eventPublisher = eventPublisher;
    }

    @Override
    @Transactional
    public SingleBarberShopResponse postBarberShop(PostBarberShopCommand command) {
        Member requestMember = loadMemberPort.findMemberByEmail(command.requestMemberEmail());
        if(requestMember.isCustomer()){
            throw new BusinessException(ExceptionCode.UNAUTHORIZED);
        }
        BarberShop barberShop = BarberShop.builder()
                .barberShopId((long) CommonEnums.NEW_INSTANCE.getValue())
                .owner(requestMember)
                .name(command.name())
                .phoneNumber(command.shopPhoneNumber())
                .address(new Address(command.zipCode(), command.streetAddress(), command.detailAddress()))
                .build();
        BarberShop savedBarberShop = saveBarberShopPort.save(barberShop);
        CreateBarberShopEvent event = CreateBarberShopEvent.of(savedBarberShop);
        eventPublisher.publishEvent(event);
        return SingleBarberShopResponse.of(savedBarberShop);
    }
}

 

 요청을 보낸 사용자를 찾아오고, 해당 사용자가 만일 고객 사용자라면 권한이 없다고 예외처리한다. 바버샵이 등록되고나면, 바버샵이 등록되었다는 이벤트를 발행한다.

 

CreateBarberShopEventPulisher

@Service
public class CreateBarberShopEventPublisherImpl implements CreateBarberShopEventPublisher{

    private final ApplicationEventPublisher eventPublisher;

    public CreateBarberShopEventPublisherImpl(ApplicationEventPublisher eventPublisher) {
        this.eventPublisher = eventPublisher;
    }
    
    @Override
    public void publishEvent(CreateBarberShopEvent event) {
        eventPublisher.publishEvent(event);
    }
}

 

 Spring의 이벤트 발행은 ApplicationEventPublisher 인터페이스가 담당한다. 특정 이벤트를 전송하는 객체는 역할을 나누고 싶어 바버샵 생성 이벤트를 발행하는 컴포넌트를 새롭게 만들어 사용했다. 현재는 하나의 인터페이스로 추상화했다. 아마 추후에 Slack을 이용해 알림을 전달하는 일을 추가로 진행할 수도 있을 것 같은데, 이 과정에서 추가적인 추상화 과정이 필요하지 않을까 싶다.

 

 예를 들어, SlackEventPublisher의 publishEvent(SlackEvent event); 정도로 추상화한 뒤, 이를 각각 구현한 콘크리트 클래스들을 사용하지 않을까 싶다. 이 때는 @Qualifier를 사용해 필요한 Service 객체를 주입해 줄 수 있을 것이다.

 

 CreateBarberShopEventListener

@Component
@Slf4j
public class CreateBarberShopEventListener {
    public static final String SLACK_ATTACHMENT_COLOR_CODE = "ff0000";
    @Value("${slack.url}")
    private String webHookUrl;

    @Async
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void sendSlackAlert(CreateBarberShopEvent event) {
        Slack slack = Slack.getInstance();
        String eventMessage = event.message();
        String barberShopName = event.barberShopName();
        String barberShopPhoneNumber = event.barberShopNumber();
        String ownerNickName = event.barberShopOwnerNickName();
        String ownerPhoneNumber = event.barberShopOwnerPhoneNumber();

        try {
            slack.send(webHookUrl, payload(p -> p.text(event.message())
                    .attachments(List.of(generateSlackAttachments(eventMessage, barberShopName, barberShopPhoneNumber, ownerNickName, ownerPhoneNumber)))));
            log.info(eventMessage);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    private Attachment generateSlackAttachments(String eventMessage, String barberShopName, String barberShopPhoneNumber, String ownerNickName, String ownerPhoneNumber) {
        return Attachment.builder()
                .color(SLACK_ATTACHMENT_COLOR_CODE)
                .title(eventMessage)
                .fields(List.of(
                        generateSlackField("바버샵 이름", barberShopName),
                        generateSlackField("바버샵 전화번호", barberShopPhoneNumber),
                        generateSlackField("원장 바버 닉네임", ownerNickName),
                        generateSlackField("원장 바버 휴대폰 번호", ownerPhoneNumber)
                ))
                .build();
    }

    private Field generateSlackField(String title, String value) {
        return Field.builder()
                .title(title)
                .value(value)
                .build();
    }
}

 

 이벤트 리스너 클래스 코드다. 별 건 없는데, @Async와 @TransactionalEventLister에 집중해야 할 것이다. @Async애너테이션을 이용해 비동기 이벤트 처리가 가능해지고, @TransactionEventListner 애너테이션의 phase 옵션을 통해 트랜잭션 수행 전에 이벤트를 발행할 것인지, 트랜잭션이 끝나고 발행할 것인지를 선택할 수 있다. 즉, 트랜잭션이 커밋되어야 이 이벤트는 수행된다. 이를 통해 우리는 트랜잭션 수행 결과의 신뢰도와 이벤트 간 결합을 깔끔하게 분리할 수 있다. 물론 phase를 통해 트랜잭션이 롤백 됐을 때 이벤트가 수행되게 설정할 수도 있다.

 

 비동기 이벤트 처리를 @Async를 통해 진행할 때는 스레드 풀 관리에도 신경써야 한다. 하나의 동작은 하나의 스레드에서 이루어지는데, 비동기 처리를 진행하면 다른 스레드에 일을 떠넘기는 것이다. 기본적으로는 클라이언트 요청 -> 바버샵 등록 -> 슬랙에 메세지 전달 -> 클라이언트에 응답이라는 프로세스를 동기적으로 수행하기 때문에 하나의 스레드에서 이뤄진다. 그래서 앞서 언급한 외부 API랑 통신이 엄청 오래 걸리면 어떡해? 같은 문제가 발생하는 것이다. 그러나 비동기 처리를 진행하면 중간의 슬랙에 메세지 전달을 다른 스레드에 떠넘기고 기존의 스레드는 나머지 업무를 담당한다.

 

 @Async를 사용하면 기본적으로 매 요청마다 새로운 스레드를 생성해 해당 이벤트를 처리한다. 즉, 동시에 1000개의 요청이 오면 1000개의 스레드를 만들어 사용한다. 자원을 효율적으로 사용하려다가 오히려 자원을 고갈시키는 결과를 야기할 수 있다. 따라서 가용한 ThreadPool을 관리하는 설정을 통해 위와 같은 사태를 미연에 방지해야 한다. 이번 포스트에서는 다루지 않겠지만, 관련 링크를 아래에 남겨놓을테니 꼭 읽어보자.

 

@EnableAsync
@SpringBootApplication
public class GroomingzoneApplication {

    public static void main(String[] args) {
       SpringApplication.run(GroomingzoneApplication.class, args);
    }

}

 

 마지막으로 Main Application 클래스에 @EnableAsync를 붙여줘야 비동기 처리를 사용할 수 있다.

 

 이번 포스팅에서는 굳이 슬랙이랑 어떻게 연동하고, 비동기 어떻게 사용하고 이런 것들은 다루지 않았다. 이거는 뭐 검색하면 다 나오니까. 대신 참고한 링크를 남겨놓겠다. 아 참, 그래서 슬랙으로 어떻게 오는지는 봐야겠지?

 

바버샵 등록 요청을 보냈을 때

 

  바버샵 등록 요청을 보내면 요렇게 응답이 온다. 나중에 다음 주소 API를 사용하기 위해 주소는 저렇게 설정했다.

Slack WebHook을 통한 메세지 수신

 앞서 EventPublisher에서 설정한 값들이 예쁘게 넘어온다. 휴대폰 번호는 내 번호를 입력해서 지웠다 ㅋㅋ;

 

 포스팅 마지막에 약간 푸념을 적어보자면 최근에 개발이 너무 재미없어졌었다. 이게 단순히 좋아서 취미로만 하는거면 모르겠는데, 어쨋든 개발자를 목표로 이것저것 해보고있는데 결과가 너무 안나오니까 목표 의식이 좀 사라져간다고 해야하나... 그래서 좋아하는 야구 관련 칼럼을 포스팅하는 블로그도 개설해보고 이것저것 하고 있는데도 여전히 너무 개발은 하기 싫더라.

 

 그러던 중 최근 면접을 한 번 봤었는데, 너무 좋은 회사같아 꼭 가고 싶었고, 내 나름대로 최선을 다해 어느정도 질문에 응답도 잘했다. 그런데 코딩 테스트 과정에서 SQL을 작성하지 못해 아무것도 하지 못하고 나오니, 나는 왜 이렇게 게으르게 바뀌었지?라는 생각이 들었다. 뭔가 아무것도 안하는게 관성적으로 자리잡아 버린 기분이었다. 주말을 푹 쉬고 오늘부터 다시 정신차리고 살려고 한다. 푸념은 여기까지고, 열심히 하자.

 

 전체 코드는 링크에서 확인할 수 있다.

 

참고 자료

Slack 연동 : https://developer-youn.tistory.com/149

 

spring + slack webhook 연동하기

0. 이 글을 작성하는 이유 서비스를 운영 중 문제가 발생했을 때 slack으로 알람을 주기 위해 webhook이 가장 구현이 편해서 공유하고자 함 1. 준비 과정 실은 아래 2개의 문서를 참고하면 잘 된다. 역

developer-youn.tistory.com

비동기 처리와 쓰레드

https://xxeol.tistory.com/44#ThreadPoolTaskExecutor%EB%A5%BC%C2%A0%ED%99%9C%EC%9A%A9%ED%95%9C%20%EC%8A%A4%EB%A0%88%EB%93%9C%20%EA%B4%80%EB%A6%AC-1

 

[Spring] @Async와 스레드풀

들어가며 페스타고 서비스에서는 특정 학교의 재학생임을 인증하기 위해, 학교 웹메일 인증 방식을 채택하였다. 사용자가 자신의 학교웹메일 주소를 작성하고 ‘인증 메일 전송’ 버튼을 클릭

xxeol.tistory.com

https://mangkyu.tistory.com/292

 

[Spring] 스프링에서 이벤트의 발행과 구독 방법과 주의사항, 이벤트 사용의 장/단점과 사용 예시

이벤트(Event)는 매우 유용하지만 상당히 간과되는 기능 중 하나입니다. 작년에 아마존 CTO는 이벤트 드리븐 아키텍처로 가야 한다고 기조 연설을 하기도 했는데, 이번에는 스프링 프레임워크에서

mangkyu.tistory.com

 

반응형

댓글