팀 프로젝트를 진행하며 재밌는 경험을 해서 기록해봅니다 😃
모든 종류의 Slack Event는 하나의 URL로 전달된다
Since your application will have only one Events Request URL,
you'll need to do any additional dispatch or routing server-side after receiving event data.
Slack App을 워크스페이스에 설치하고 권한을 부여하면,
특정 상황이 발생할 때마다 이를 이벤트로 감지하여 Slack App에 설정된 URL로 이 정보를 보내줍니다.
가령 채널에 메시지가 전송되었다거나, 편집 혹은 삭제되었다거나, 심지어 이모지가 달린 것까지.
사용자에 관련해서는 채널 입장, 프로필 업데이트도 모두 이벤트로 정보를 받을 수 있습니다.
다만 문제는 모든 이벤트를 하나의 URL로 보내준다는 것입니다.
이벤트가 달라지면 담기는 HTTP Request Body의 내용도 당연히 달라집니다.
그로 인해 어딘가에선 body의 값을 확인하고, 이를 분기 처리해야 하는데요,
이 방법에 대해서 저희 팀은 각자 방법을 하루 정도 고민해보고 다음 날 만나서 이야기해보기로 했습니다.
핸들러 이전에 분기 or 핸들러에서 분기
옵션1) 각 이벤트를 컨트롤러 엔드포인트에서부터 분리해서 DTO로 받는다
@PostMapping(headers = "type=user_profile_update")
public ResponseEntity<String> profileUpdate(@ProfileUpdate ProfileUpdateRequest profileUpdateRequest) {
eventService.execute(profileUpdateRequest);
return ResponseEntity.ok("");
}
@PostMapping(headers = "type=message")
public ResponseEntity<String> profileUpdate(@MessagePost MessagePostRequest messagePostRequest) {
eventService.execute(messagePostRequest);
return ResponseEntity.ok("");
}
옵션2) 하나의 컨트롤러 엔드포인트에서 Map으로 받은 뒤, 나머진 구현체 Finder에게 맡긴다
@PostMapping
public ResponseEntity<String> save(@RequestBody Map<String, Object> requestBody) {
if (isUrlVerificationRequest(requestBody)) {
return ResponseEntity.ok((String) requestBody.get(CHALLENGE));
}
slackEventServiceFinder.findBySlackEvent(SlackEvent.of(requestBody))
.execute(requestBody);
return ResponseEntity.ok(EMPTY_STRING);
}
위처럼 두 가지 아이디어가 제안되었습니다.
옵션 1의 컨셉은 이렇습니다.
- 하나의 핸들러에 하나의 이벤트를 매핑하고 싶다
- 핸들러의 매개변수로 완성된 DTO를 받고 싶다
- RequestMappingHandlerMapping의 기본 분기 처리 기능을 활용하고 싶다
- HTTP Method와 RequestURI가 동일하고,
- Body에 담긴 값으로 구분해야 하기에 headers 옵션을 사용하자
- Filter에서 HTTP Reqeust의 body에 있는 값을 꺼내서 header에 넣어주자
- 애너테이션과 DTO, 아규먼트 리졸버를 활용해서 완전히 이벤트마다 핸들러를 분리해내자
- URL Verification도 주된 관심사가 아니므로
- 필터에서 처리함으로써 핸들러에서 완전 빼낼 수 있다
옵션 2의 컨셉은 이렇습니다.
- 하나의 컨트롤러에서 모든 이벤트의 요청을 일단 받는다
- URL Verification의 경우만 Early Return 한다
- SlackEvent Enum에 이벤트 종류들을 선언하고, 값을 확인해 이벤트 종류를 찾게 한다
- SlackEventServiceFinder는 List<SlackEventService>를 가지고 있고,
- 모든 구현체가 자동 주입되어 들고 있다.
- SlackEventServiceFinder에게 SlackEvent Enum을 전달하면,
- 이를 처리할 SlackEventService 구현체가 반환된다.
- 반환된 SlackEventService 구현체에게 body를 그대로 전달해서 이후 처리를 위임하자.
옵션 1 상세보기
public class SlackEventFilter implements Filter {
@Override
public void doFilter
(
final ServletRequest request,
final ServletResponse response,
final FilterChain chain
) throws IOException, ServletException {
final HeaderModifiableHttpServletRequest headerModifiableHttpServletRequest =
new HeaderModifiableHttpServletRequest((HttpServletRequest) request);
final ServletInputStream inputStream = headerModifiableHttpServletRequest.getInputStream();
final String s = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
final EventTypeFilterDto eventTypeFilterDto = new ObjectMapper().readValue(s, EventTypeFilterDto.class);
headerModifiableHttpServletRequest.addHeader("type", eventTypeFilterDto.getType());
chain.doFilter(headerModifiableHttpServletRequest, response);
}
}
저는 다른 크루들의 도움을 얻어 옵션 1을 구현하고 제안했었습니다.
구현하는 과정에서 Filter에서 RequestBody를 한 번 읽어야 했기에,
InputStream이 사라지는 이슈를 해결해야 했습니다.
추가로 Request Header 값을 넣어줘야 했기에 이 또한 구현이 필요했습니다.
HttpServletRequestWrapper를 이용해, getInputStream() 메서드를 재정의하고
상태 값으로 바이트 배열을 갖게 하여 이를 활용하여 body값이 캐싱되도록 했습니다.
또한 headerMap, getHeader 등의 메서드도 재정의하여 header도 추가가 가능하게 했습니다.
그 뒤엔 이벤트별 애너테이션을 선언하고, DTO를 선언하고,
아규먼트 리졸버에서 HttpServletRequest에서 캐싱된 body값을 읽어 DTO를 만들게 했습니다.
마지막으로 핸들러에선 headers 값만 넣어주면 이벤트 완전 분기가 완료됩니다.
이를 구현하면서 Filter와 인터셉터의 차이 중 하나인
HttpServletRequest를 조작할 수 있다는 점을 확실히 체감할 수 있었습니다.
또한 Http Request를 톰캣이 ServletRequest로 만드는 과정에서
HttpRequest Body를 InputStream 타입으로 만들기에 한 번 읽고 나면 휘발된다는 점과,
HttpServletRequest 기능을 확장하기 위해 제공되는 HttpServletRequestWrapper,
ContentCachingRequestWrapping 등이 제공된다는 점을 알게 되었습니다.
옵션 2 상세보기
public static SlackEvent of(final Map<String, Object> requestBody) {
Map<String, Object> event = (Map<String, Object>) requestBody.get("event");
String type = String.valueOf(event.get("type"));
String subtype = String.valueOf(event.getOrDefault("subtype", ""));
return Arrays.stream(values())
.filter(slackEvent -> isSameType(slackEvent, type, subtype))
.findAny()
.orElseThrow(SlackEventNotFoundException::new);
}
@Component
public class SlackEventServiceFinder {
public final List<SlackEventService> slackEventServices;
public SlackEventServiceFinder(final List<SlackEventService> slackEventServices) {
this.slackEventServices = slackEventServices;
}
public SlackEventService findBySlackEvent(final SlackEvent slackEvent) {
return slackEventServices.stream()
.filter(service -> service.isSameSlackEvent(slackEvent))
.findAny()
.orElseThrow(SlackEventNotFoundException::new);
}
}
옵션 2는 다른 크루의 탐색 결과였습니다.
컨트롤러에서 일단 받은 뒤, 그다음 분기를 한다 가 기본 컨셉이었고,
분기 방법은 서비스 리스트들을 필드로 갖는 Finder 컴포넌트를 선언하여
이 컴포넌트를 통해 해당 요청을 처리할 구현체를 반환받아 RequestBody를 전달하는 것이었습니다.
위처럼 인터페이스를 리스트로 선언하게 되면,
해당 타입의 구현체들이 리스트로 주입되는 점을 한 번도 활용해본 적이 없어서
배웠음에도 떠올리지 못했습니다.
옵션 2가 옵션 1 보다 나은 이유
아무리 봐도 옵션 2가 훨씬 나은 방법입니다.
아래와 같은 이유에서 그렇다고 생각합니다.
- 구현하기 쉽다
- 옵션 1은 비록 일회성일지라도 필터, HttpServletRequest를 조작하기 위한 구현이 필요하다
- 이벤트가 추가될 때마다 핸들러, 애너테이션, 아규먼트 리졸버를 구현해야 한다
- 반면 옵션 2는 이벤트가 추가된다면 Enum 선언 추가 및 서비스 구현체만 추가하면 된다
- 옵션 1은 HttpServletRequest를 조작해야 한다
- 비록 값 자체를 변경하는 건 아니지만 본 요청에 없었던 header를 강제 주입하고 있다
- body를 한 번 읽고 재사용하기 위해 추가적인 변조까지 진행된다
- 혹시 이렇게 요청 값을 변경, 조작하는 행위는 안티 패턴이 아닌가 우려된다
- 이해하기 쉽다
- 옵션 2는 새로운 사람이 팀에 합류했을 때, 핸들러, Enum, 구현체만 보고도 구조를 파악할 수 있다
- 반면 옵션 1은 필터부터 확인해야 하기 때문에 허들로 작용할 수 있다.
그런데, 재밌게도 이를 납득하기가 굉장히 어려웠습니다.
그거 뭐 핸들러에서 이벤트마다 분기하고 DTO로 매개변수 받는 게 뭐 그리 중요한 일일까요?
(음 뭐 중요할 수도 있겠습니다만..;;)
어찌 됐든 요청 값에 따른 분기 처리라는 요구사항을 달성하면서도 코드가 간결하고 이해하기 쉽다면,
현재 주어진 옵션 중에선 2번이 확실히 더 나은 선택지입니다.
그럼에도 불구하고 - 인지부조화
인정은 되는데 납득이 되지 않았습니다.
이성적으로 분명 옵션 2가 나은데도 쉽게 받아들여지질 않았습니다.
시간이 지나면서 점차 인지부조화가 완화되고, 결국 받아들여졌습니다.
이제는 현재 저희 팀 코드에 반영된 옵션2가 정말 우아하다고 느껴집니다.
그럼 대체 왜!
왜.. 더 빨리 옵션 2에 대해 납득이 되지 않았을까요?
협업에 걸림돌이 되는 현상이라는 관점에서 회고해봤습니다.
다른 크루들과 같이 이야기하고 고민하며 방법을 찾아간 과정이 너무 즐거웠다.
찾아낸 방법론을 코드로 구현해냈을 때 성취감이 컸다.
지금 돌이켜보면 왜 꼭 그렇게 하고 싶었을까 싶은데
당시엔 이벤트마다 핸들러를 분기해내고 싶었다. 매우.
아마 List로 인터페이스를 주입받을 수 있음을 알지 못해서
처음부터 핸들러를 모두 분리해야만 하는 상황으로
상황을 좁혀서 인식한 채 접근하고 있었기 때문인지 모르겠다.
아이디어를 너무 많이 빌드업하는 과정에서 애착이 생겨버린 것 같다.
그래서 아이디어에 대한 반대가 나에 대한 반대로 느껴지고
다른 옵션이 더 나음을 받아들이기가 어려워졌던 것 같다.
기획자들은 어떨까?
끊임없이 제품과 서비스에 대한 아이디어를 고민해야 하는 직업일텐데,
아이디어에 대한 애착이 생기지 않을까?
이 애착이 협업에 장애물로 동작할 땐 없을까?
이것 역시 의식적인 연습으로 아이디어와 나를 분리해내는 걸
훈련하여 완화시킬 수 있을 것도 같은데..
인지적 구두쇠의 기억 편집
협업의 장애물이 되었던 인지부조화 현상 내면에
어떤 심리적 기저가 깔려있는지 궁금해서 이런저런 내용을 둘러보다가
김경일 교수님의 강의에서 유사한 내용을 찾을 수 있었습니다.
면접을 본다면 오후보다 오전이 더 유리하다 라는 이야기가 있습니다.
오전에 면접을 본 후 점심식사를 하는 과정에서,
인간의 인지적 구두쇠 성질이 동작하며
오전에 면접 본 사람들의 인상적이었던 장면, 장점들이 편집되어
하나의 아주 우월한 사람으로 편집되어 묶어서 기억하게 된다는 것입니다.
그리고 이 편집된 훌륭한 가상의 존재가 오후 면접자들의 경쟁자가 되어버리는 것이죠.
그로 인해 면접관들에게 이러한 측면이 있으니 주의하라고 미리 일러두더라도
정말 오전 지원자들이 더 나았다고 한결같이 인터뷰를 하기에
미리 인지하더라도 이러한 현상을 완전히 없애지는 못한다고 합니다.
아마 제 머릿속에서도 옵션 1의 방식 중 안 좋은 점은 모두 편집해서 날려버리고 ㅋㅋ
다른 크루들과의 행복했던 대화, 구현했을 때의 성취감,
몇 가지 소소한 장점들만 편집해서 아주 훌륭한 무언가로 존재하고 있었을지도 모르겠습니다.
그리고 이를 냉정하게 바라볼 수 있게 되기까지는 꽤나 에너지와 시간이 필요했습니다.
액션 플랜
스스로에게 적용하고자 도출한 액션 플랜입니다.
- 아이디어는 아이디어일 뿐, 내가 아니다
- 내가 제안한 아이디어에 반대하더라도 나에 대한 반대가 아니다
- 상대방의 의견에 다른 의견을 제시하고 싶을 때,
"그 의견의 이러이러한 부분이 장점으로 느껴진다.
하지만 개인적으로 저러저러한 점이 우려되는데 이 점에 대해서는 어떻게 생각하는지 궁금하다"
라는 표현으로 상대의 의견에 대한 장점과 우려 지점을 함께 명시함으로써
상대에 대한 반대가 아님을 분명히 한다 - 각자 아이디어를 가져와서 회의를 하게 될 경우,
이미 나의 머릿속에서 내 아이디어는 어벤저스로 편집되어 있는 상태이므로
지속적으로 냉정하게 다시 돌아보고자 노력해야 한다.
레벨 3의 목표, "진한 협업 경험" 중 한 조각을 발견한 걸까요? 재밌습니다 ㅎ
팀 프로젝트 기간 동안 여러 가지를 배울 수 있을 것 같습니다.
'우아한테크코스 4기' 카테고리의 다른 글
Git Flow 가 CI/CD 와 어울리지 않는 이유 by David Farley (0) | 2022.07.14 |
---|---|
🥄 Git Flow 한 스푼 (0) | 2022.07.14 |
📖 배민다움을 읽었습니다 (0) | 2022.07.06 |
🧑💻 UX 워크샵 후기 (2) | 2022.07.02 |
💿 Response Header 와 브라우저를 이용한 캐싱 (0) | 2022.06.19 |