우아한테크코스 4기

아이디어 매몰 주의 - (인지적 구두쇠, 기억 편집)

리차드 2022. 7. 9. 20:09

 

팀 프로젝트를 진행하며 재밌는 경험을 해서 기록해봅니다 😃

 

모든 종류의 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 Evnt API 문서

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 상세보기


 

옵션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로 인터페이스를 주입받을 수 있음을 알지 못해서
처음부터 핸들러를 모두 분리해야만 하는 상황으로
상황을 좁혀서 인식한 채 접근하고 있었기 때문인지 모르겠다.

아이디어를 너무 많이 빌드업하는 과정에서 애착이 생겨버린 것 같다.
그래서 아이디어에 대한 반대가 나에 대한 반대로 느껴지고
다른 옵션이 더 나음을 받아들이기가 어려워졌던 것 같다.

기획자들은 어떨까?
끊임없이 제품과 서비스에 대한 아이디어를 고민해야 하는 직업일텐데,
아이디어에 대한 애착이 생기지 않을까?
이 애착이 협업에 장애물로 동작할 땐 없을까?

이것 역시 의식적인 연습으로 아이디어와 나를 분리해내는 걸
훈련하여 완화시킬 수 있을 것도 같은데..
 
 

 

 

인지적 구두쇠의 기억 편집


https://youtu.be/ustW_lwl624

김경일 교수님의 판단과 의사결정에 숨은 심리 강연


협업의 장애물이 되었던 인지부조화 현상 내면에
어떤 심리적 기저가 깔려있는지 궁금해서 이런저런 내용을 둘러보다가
김경일 교수님의 강의에서 유사한 내용을 찾을 수 있었습니다.
면접을 본다면 오후보다 오전이 더 유리하다 라는 이야기가 있습니다.
오전에 면접을 본 후 점심식사를 하는 과정에서,
인간의 인지적 구두쇠 성질이 동작하며
오전에 면접 본 사람들의 인상적이었던 장면, 장점들이 편집되어
하나의 아주 우월한 사람으로 편집되어 묶어서 기억하게 된다는 것입니다.
그리고 이 편집된 훌륭한 가상의 존재가 오후 면접자들의 경쟁자가 되어버리는 것이죠.


그로 인해 면접관들에게 이러한 측면이 있으니 주의하라고 미리 일러두더라도
정말 오전 지원자들이 더 나았다고 한결같이 인터뷰를 하기에
미리 인지하더라도 이러한 현상을 완전히 없애지는 못한다고 합니다.

 

아마 제 머릿속에서도 옵션 1의 방식 중 안 좋은 점은 모두 편집해서 날려버리고 ㅋㅋ
다른 크루들과의 행복했던 대화, 구현했을 때의 성취감,
몇 가지 소소한 장점들만 편집해서 아주 훌륭한 무언가로 존재하고 있었을지도 모르겠습니다.
그리고 이를 냉정하게 바라볼 수 있게 되기까지는 꽤나 에너지와 시간이 필요했습니다.

 

 

 

액션 플랜


스스로에게 적용하고자 도출한 액션 플랜입니다.

  • 아이디어는 아이디어일 뿐, 내가 아니다
  • 내가 제안한 아이디어에 반대하더라도 나에 대한 반대가 아니다
  • 상대방의 의견에 다른 의견을 제시하고 싶을 때,
    "그 의견의 이러이러한 부분이 장점으로 느껴진다.
    하지만 개인적으로 저러저러한 점이 우려되는데 이 점에 대해서는 어떻게 생각하는지 궁금하다"
    라는 표현으로 상대의 의견에 대한 장점과 우려 지점을 함께 명시함으로써
    상대에 대한 반대가 아님을 분명히 한다
  • 각자 아이디어를 가져와서 회의를 하게 될 경우,
    이미 나의 머릿속에서 내 아이디어는 어벤저스로 편집되어 있는 상태이므로
    지속적으로 냉정하게 다시 돌아보고자 노력해야 한다.
 


레벨 3의 목표, "진한 협업 경험" 중 한 조각을 발견한 걸까요? 재밌습니다 ㅎ
팀 프로젝트 기간 동안 여러 가지를 배울 수 있을 것 같습니다.