안녕하세요👋! 개발자 갈레입니다!
오늘은 filter를 사용하여 반복되는 응답 로직 제거하는 방법에 대해 알아보겠습니다!
들어가며
제 인사말을 듣고 딱! 궁금해하시는 분이 있으실거에요!
Q.반복되는 응답 로직을 제거 하는 방법은 filter로 해야하나요? 다른 방법은 없나요?
아주😃! 좋은 질문입니다👍👍👍!
저희는 반복되는 (응답) 로직을 제거하는 방법들에 대해서 알아본 후, 반복되는 '응답' 로직을 제거하려면 어떤 방법들을 쓰는게 좋을지 상황에 따라 비교 분석할겁니다.
궁금증(호기심)을 가지면 좋은 컨텐츠
- 반복되는 응답 로직을 왜 제거하나요? 이점은?
- 제거 방법들엔 뭐가 있나요? 비교 분석?
- 언제 사용 해야하나요?
- 예외 사항은?
글을 읽으시면서 핵심 부분(노란)과 꼬리질문(초록) 부분에 집중하며 읽어주세요! 개인적으로 키워드에서 꼬리질문을 하는게 개념을 깊이있게 이해하는 과정이라고 생각합니다:)
목차
1. 문제 상황 분석
- 기존 코드
- 요구 사항
- 반복되는 코드
2. 선택 가능한 기술 분석
- Filter
- Interceptor
- AOP
3. 기술 선택 및 구현
- Filter
4. 문서화
- WIKI
1. 문제 상황 분석
기존 코드
<그림1>는 제 spring 프로젝트에서 사용되는 UserController의 findUsername 메소드입니다. 전화번로를 주면 그에 맞는 응답을 만들어서 리턴하죠! 이때 리턴하는 방식이 특이하죠?! 저희 팀은 프론트엔드가 조금 더 유의미한 응답 데이터를 받으면서도, 일관적인 방식으로 응답 메세지에 접근하기를 원했습니다. 리턴 방식이 조금 특이하죠! 스크롤 다운 해보시죠!
응답 데이터에는 body에 custom 응답 문자열 메세지와 각종 프론트엔드에게 필요할 수 있는 객체들의 정보가 들어있는 Map이 들어있습니다. 실제로 Postman으로 응답을 받으면 <그림2>와 같이 나옵니다! 프론트엔드는 Body로 들어가서 메세지를 보고 응답의 성격을 파악할수 있고, 데이터 내에 객체들의 정보를 가지고 다시 유의미한 UI를 생성할 수 있을겁니다!
메세지와 데이터 말고도 다른 객체를 넣을 필요가 있을 때, 즉 StandardResponse의 규격이 달라지는 것을 대비하여 클래스로 응답 body를 구성했습니다(<그림3> 참조) . 빌더 패턴을 사용하여 새 필드 추가가 기존 코드에 영향을 주지 않습니다.
오케이 이해했습니다! 앞으로 모든 팀원들은 Controller 단에서 응답을 만들어주면 됩니다. Controller을 용도에 맞게 사용하고 있기도하죠! Controller는 Ouput을 return device 혹은 url에 맞게 mapping 역할을 하기 때문입니다. 응답 데이터를 만드는 역할은 Controller 컴포넌트 한곳에서 하니 SRP도 잘 지켜진다고할 수 있습니다.
요구 사항
요구 사항이 생겼습니다! JWT 토큰을 이용해서 로그인을 구현해야합니다! 팀에선 로그인 인가 관리를 모두 Spring security 프레임워크에 맡기고 싶어서 JwtAuthenticationFilter 클래스를 사용하여 JWT 토큰을 이용한 로그인을 구현했습니다. 근데 조그마한 문제가 생겼습니다. <그림4>를 보면 알 수 있듯이 jwt.io에 의하면, jwt 토큰은 http 메세지의 헤더에 설정하는 것을 권장합니다(by jwt specification). response가 Spring security 필터 내에서 수정될 필요가 생긴겁니다. <그림5>는 사용자 인증이 성공하면 jwt 토큰을 생성해서 response에 담아주는 필터입니다. response는 Controller에서 전부 만드는 것아닌가요? 자연스러운 질문입니다. 이에 대한 대답은 '부분적으로 맞다'라고 할 수 있을 것 같네요. 현재 Controller에서는 http 상태 코드와 body만 수정하고 있습니다. SRP를 유연하게 적용하면, 필터에서 헤더를 바꿔주는 것은 괜찮다고 생각할 수 있습니다. Controller에서 하는 일은 아니니까요! (그렇지만 더 큰 문제가 곧 생깁니다😨)
더 큰 문제에 대해 설명드리겠습니다! Refresh token은 어디에 저장될까요? Refresh token은 jwt specification에는 나와있지 않습니다. refresh token은 Oauth2 컨셉이죠. 따라서 RFC 6749를 보면 refresh token을 보낼 때 아래와 같은 형식으로 응답하라고 합니다. 문제가 보이시나요? refresh token을 body에 실어 보내고 있군요! 문제가 보이지만, 우린 귀찮습니다. 한번 아무 생각 없이 filter에서 refresh token을 생성해봅시다!
For example: HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
Cache-Control: no-store
Pragma: no-cache
{
"access_token":"2YotnFZFEjr1zCsicMWpAA",
"token_type":"example",
"expires_in":3600,
"refresh_token":"tGzv3JOkF0XG5Qx2TlKWIA",
"example_parameter":"example_value"
}
출처: https://www.rfc-editor.org/rfc/rfc6749#section-5.1
<그림6>은 JwtAuthorizationFilter 클래스 내 refresh token 검증 로직입니다(다 완성된 코드는 아닙니다). <그림6> 맨 아래줄을 보면, 토큰을 검증하던 도중 TokenExpiredException이 터졌습니다! Refresh token을 새로 달라고 응답을 생성해야 겠네요. turnBackUnAuthorizedResponse 함수 안으로 들어가보죠!
반복되는 코드
<그림6>의 turnBackUnAuthorizedResponse 함수인 <그림7>이 아주 큰 문제입니다. response의 body를 변경해주고 있군요! 심지어 컨트롤러의 메소드와 비교했을 때 반복되는 코드들도 제법 보입니다! 무엇보다 Controller와 Filter가 둘다 response, 더 나아가 response body를 변경해주고 있다는게 문제입니다. SRP가 잘 지켜지고 있지 않습니다.유지 보수하기가 어려워졌습니다. 반복된 코드가 역할과 위치가 다른 모듈에 존재합니다.
Q. 이를 어떤 방식으로 제거할까요?
A. 우린 객체지향 프로그래밍 언어인 자바를 이용하고 있습니다. 따라서 객체를 만들어야겠군요!
Q. 그럼 어떤 객체를 만들까요?
A. 선택 가능한 기술을 분석해봅시다:)
2. 선택 가능한 기술 분석
반복되는 로직이 있고 이를 다른 모듈은 건드리지 않으면서 새로 추가하고 싶을 때 우리는 Filter, Interceptor 혹은 AOP를 사용합니다(<그림8> 참조). 세가지의 특징과 차이점은 웹을 검색하면 바로 나오므로 여기서 따로 정리하진 않겠습니다. 이 글에서는 제 프로젝트에 한해 어느 상황에 어떤 것을 사용하는게 좋을지, 좋았을지에 대해 얘기 해보겠습니다.
- Spring 사용 O, Spring security 사용 X
- 사용자 인증: 필터와 인터셉터 둘다 가능합니다.
- 사용자 인가: 인터셉터에서 하는게 좋습니다. url 정보를 가지고 UrlResolve 제어를 통한 메소드 인가 여부를 판별해야 하는데, 이는 Dispatchet Servlet 이후에 가능하기 때문입니다.
- Custom 응답 리턴: 필터와 인터셉터 둘다 가능합니다. 사용자 인증보다 앞에만 있으면 됩니다.
- Spring을 사용 O, Spring security 사용 O
- 사용자 인증: Filter에서 하는게 좋습니다. Spring security는 인가를 Filter에서 합니다. 인가 기능을 이용하기 위해선 인증도 같은 level에 있는게 좋습니다.
- 사용자 인가: Spring security의 인가 기능을 그대로 사용할 예정입니다. 인가 기능은 Filter에서 작동합니다.
- Custom 응답 리턴: Spring security에서 응답을 수정할 일이 있으므로 Filter에서 해야합니다.
-> 우린 Spring security를 사용하고 spring security 필터 내에서 응답을 생성할 일이 있기 때문에 Filter에서 Custom 응답을 생성해야합니다.
누군가가 질문할 수 있습니다🙋♀️!
Q. 필터를 사용하면 application.properties를 이용하지 못하지 않나요? Custom message를 돌려준다면, message_ko_KR.properties도 이용해야할텐데 spring 설정 정보는 어떻게 가져올거죠?
A. 좋은 질문입니다! <그림6>을 보면 우린 실제로 messageSource를 사용하고 있죠. 그렇다면 필터에서 messageSource를 사용할 일이 있을까요? 없습니다. 필터에선 이미 저장되있는 정보를 바탕으로 custom 응답을 꺼내주는 공통 로직만 수행합니다. 설정 정보 또한 이미 저장되있는 정보이기 때문에 messageSource가 필요 없죠. 잘 이해가 안되신다구요? 다음 파트를 읽으면 이해가 되실겁니다! 스크롤 다운!
MessageSource를 가져오고 싶다고요? 가능하긴 합니다! spring에서는 이를 하기 위해 DelegatingFilterProxy를 제공합니다. 프록시 역할을 하는 필터를 필터들 사이에 끼워 준 다음에, 해당 프록시가 빈으로 등록된 필터들을 대리해서 호출하는 방식이죠! 이를 이용하면 필터에서도 빈 정보를 사용할 수 있습니다!
3. 기술 선택 및 구현
우리는 필터를 이용해서 반복된 코드를 제거하여 유지보수에 용이한 형태로 리팩토링 하기로 결정했습니다. 그런데 구현 계획을 세우다 보니 문제가 생깁니다. 필터 객체에서 필터와 컨트롤러에서 설정한 응답 데이터를 custom 응답 데이터로 전부 바꿔줘야 하는데, 서로 다른 역할이 있고 다른 위치에 존재하는 Filter와 Controller가 어떻게 하나의 데이터 공간을 가지고 있을까요? 구체적으로 얘기하자면, 특정 클라이언트의 요청이 처리되는 동안 해당 요청만이 접근할 수 있는 메모리 공간이 존재할 수 있을까요? 클라이언트의 요청은 한 스레드에 의해 처리될겁니다. 그렇다면 한 스레드가 계속 유지하는 변수 공간이 있을까요? Java에서는 ThreadLocal을 제공합니다. 스레드 자신만이 접근할 수 있는 공간이죠. ThreadLocal 내에 StandardResponse 객체(custom 응답 데이터)를 넣어 놓으면 Filter와 Controller 어디서든 해당 공간에 접근할 수 있겠군요! StandardResponse를 저장하기 위한 ThreadLocal Holder를 만들어봅시다!
<그림9>에서 우린 static 필드로 지정된 ThreadLocal 객체에 스레드 정보를 가지고 접근할겁니다. 스레드 정보로 Map에서 StandardResponseBucket을 꺼내기 때문에 동시성 문제도 해결될겁니다.
<그림9>의 ThreadLocalStandardResponseBucketHolder 클래스의 public static 메소드를 사용하면 thread에서 독립적으로 어디에서든(Controller, Filter...) StandardResponseBucket에 접근할 수 있습니다. 그렇다면 앞에서 반복되던 코드인 <그림1,6,7>을 리팩토링해보죠!
<그림10>이 리팩토링의 결과입니다! 컨트롤러에서는 어떻게 돌려줄지 ThreadLocal내 객체에 저장해놓기만 하면 됩니다. 추후 필터에서 해당 객체 내 데이터를 꺼내서 Response를 만들어줄겁니다!
아이디 찾기 메소드에 이어 JWT 토큰 인증 필터 또한 리팩토링 해줍시다! 우리가 해야 하는 것은 응답 하고 싶은 데이터를 ThreadLocal 공간에 저장해주기만 하면 됩니다! (<그림11>의 catch 구문 참조)
필터
주석1: ThreadLocalStandardResponseBucketHolder에서 데이터를 가져옵니다.
주석2: 이후에 StandardResponseBucket에서 데이터를 꺼내서 응답 body에 적어줍니다. 이 곳에 우리가 줄이려 했던 반복된 로직이 존재하는군요!
주석3: 마지막으로 스레드 컨텍스트를 비워줍니다! 스레드를 요청마다 새로 생성하는 구조리만 상관 없지만, 스레드 풀에서 스레드를 재활용 하는 구조에서는 요청이 끝나면 스레드 컨택스트를 비워줘야 스레드간 독립성이 보장됩니다.
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class StandardResponseConvertFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
chain.doFilter(request, response);
// 주석1. get data to be used in response
StandardResponseBucket standardResponseBucket = ThreadLocalStandardResponseBucketHolder.getResponse();
HttpStatus httpStatus = standardResponseBucket.getHttpStatus();
StandardResponse standardResponse = standardResponseBucket.getStandardResponse();
HttpServletResponse httpServletResponse = (HttpServletResponse) response;
// 주석2. write the data in a response
httpServletResponse.setStatus(httpStatus.value());
response.getWriter().write(
new ObjectMapper().writeValueAsString(
standardResponse));
response.getWriter().flush();
// 주석3. clear thread context
ThreadLocalStandardResponseBucketHolder.clearContext();
}
}
결과
<그림12>를 보면 아이디 생성 응답 결과가 초기 설계했던 것과 똑같이 생성되는 것을 볼 수 있습니다! 자 제대로 되는 것을 확인했습니다! 우리 팀은 앞으로 모든 응답 데이터를 ThreadLocalStandardResponseBucketHolder에 넣어주면 됩니다. 그렇다면 Filter가 이를 StandardResponse로 바꿔서 응답 body에 삽입해줄 것입니다.
팀 규약이 생겼군요! 규약이 생겼으므로 문서화를 해줍시다!
- 모든 응답 데이터는 ThreadLocalStandardResponseBucketHolder에 넣어라!
- StandardResponseConvertFilter는 Order(Ordered.HIGHEST_PRECEDENCE)을 유지해야한다!
4. 문서화
마지막으로 팀 규약을 업데이트 하도록 하죠! 저만 '비밀번호 encoding은 service layer에서 한다'를 하는 것은 의미 없습니다! 팀 work이기 때문이죠! (사실 혼자 개발해도 규약 문서는 필요하다 생각합니다. 기억력은 신뢰할 수 없는 수단이기 때문이죠😢)
저희 팀의 WIKI에 있는 Coding convention입니다:)
저희 팀이 미리 작성한 팀 규약에 따라 서비스 목차에 번호 순으로 규약을 업데이트 하겠습니다.
드디어 Filter를 활용한 중복 로직 제거 밑 객체 생성의 모든 사고 과정과 구현 결과까지 끝냈습니다😃!
긴 여정을 따라 와주셔서 감사합니다🙇♂️.
부디 여러분들이 해당 글을 읽으셨을 때 초기에 제가 말씀 드렸던, 왜? Bullet point는? 색깔의 의미를 잘 이해하며 읽으셨으면 합니다. 사실상 두개를 하는 능력이 전 정말 중요하다고 생각합니다. 개념을 제대로 이해할 수 있기 때문이죠.
그럼 다음 번에 또 봅시다👋👋!
Thanks, im 김민석(갈레)!
'프레임워크 > Spring' 카테고리의 다른 글
도커와 Testcontainer를 활용한 서버와 동일한 환경에서의 로컬 테스트 구현 (0) | 2022.09.04 |
---|---|
@Transactional, @Cacheable 및 Redis 호환 문제 해결 (0) | 2022.09.04 |