본문으로 바로가기

목차

    0. 환경

    • Spring Boot 2.5.6 (Gradle)
    • JDK 11(Java 11)
    • IntelliJ
    • Postman

    [build.gradle dependencies] 

    dependencies {
    	implementation 'org.springframework.boot:spring-boot-starter-web'
    	implementation 'org.springframework.boot:spring-boot-starter-log4j2'
    	implementation group: 'commons-io', name: 'commons-io', version: '2.6'
    	implementation group: 'org.apache.commons', name: 'commons-lang3', version: '3.0'
    	implementation group: 'commons-codec', name: 'commons-codec', version: '1.5'
    	implementation group: 'javax.xml.bind', name: 'jaxb-api', version: '2.2.4' //DatatypeConverter
    	compileOnly 'org.projectlombok:lombok'
    	annotationProcessor 'org.projectlombok:lombok'
    	testImplementation 'org.springframework.boot:spring-boot-starter-test'
    }

    1. Request Body 핸들링 목적

    Request Body 핸들링 목적
    스프링 구조

    • API 서버에 통신 시 보안을 위해 클라이언트에서 Request Data(Request Body)를 암호화하여 보낸다는 가정
    • 스프링 구조에서 가장 먼저 Request를 받아서 처리하는 부분인 필터를 활용하여 암호화된 Request Body를 복호화 처리합니다.
    • Tomcat은 서블릿 기반으로 요청을 처리하므로 Http Request는 HttpServletRequest(javax.servlet)로 넘어오게 됩니다. 
    • HttpServletRequest를 래핑 하여 커스텀 가능한 HttpServletRequestWrapper를 상속받아서 Request Body를 획득하여 복호화하는 방식으로 구현하면 됩니다.

    2. 컨트롤러 생성

    • 복호화 된 데이터를 받을 컨트롤러를 생성해줍니다.
    • 호출 성공 시 success를 응답합니다.
    import com.test.api.service.TestService;
    import lombok.RequiredArgsConstructor;
    import lombok.extern.log4j.Log4j2;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.RequestBody;
    import org.springframework.web.bind.annotation.RestController;
    
    @Log4j2
    @RestController
    public class TestController {
    
        @GetMapping(value = "/test")
        public String test(@RequestBody(required = true) String requestBody) throws Exception {
            log.info("request Data: " + requestBody);
            return "success";
        }
    }

    3. 필터 생성

    • @WebFilter 어노테이션을 활용해 Spring Boot 프로젝트에 필터를 추가합니다.

    1. 필터 소스 위에 @WebFilter 어노테이션을 이용해 필터링할 패턴을 추가해줍니다.

    import lombok.extern.slf4j.Slf4j;
    
    import javax.servlet.*;
    import javax.servlet.http.HttpServletRequest;
    import javax.servlet.http.HttpServletResponse;
    import java.io.IOException;
    
    /*
    init()
    웹 컨테이너(톰캣)이 시작될 때 필터 최초 한 번 인스턴스 생성
    
    doFilter()
    클라이언트의 요청 시 전/후 처리
    FilterChain을 통해 전달
    
    public void destroy()
    필터 인스턴스가 제거될 때 실행되는 메서드, 종료하는 기능
     */
    
    @Log4j2
    @WebFilter(urlPatterns = "/*")
    public class apiFilter implements Filter {
    
    
        /*
            - 필터 인스턴스 초기화
         */
        @Override
        public void init(FilterConfig filterConfig) throws ServletException {
            log.info("---필터 인스턴스 초기화---");
        }
    
        /*
            - 전/후 처리
            - Request, Response가 필터를 거칠 때 수행되는 메소드
            - chain.doFilter() 기점으로 request, response 나눠집니다.
         */
        @Override
        public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
            HttpServletRequest req = (HttpServletRequest) request;
            HttpServletResponse res = (HttpServletResponse) response;
    
            String requestURI = req.getRequestURI();
    
            log.info("---Request(" + requestURI + ") 필터---");
            chain.doFilter(req, res);
            log.info("---Response(" + requestURI + ") 필터---");
        }
    
    
        /*
            - 필터 인스턴스 종료
         */
        @Override
        public void destroy() {
            log.info("---필터 인스턴스 종료---");
        }
    }

     

    2. @SpringBootApplication 어노테이션을 가지고 있는 스프링 부트 실행 파일에 @ServletComponentScan 어노테이션을 추가하면 필터 세팅이 완료됩니다.

    import org.springframework.boot.SpringApplication;
    import org.springframework.boot.autoconfigure.SpringBootApplication;
    import org.springframework.boot.web.servlet.ServletComponentScan;
    
    @ServletComponentScan //추가
    @SpringBootApplication
    public class ApiApplication {
    
    	public static void main(String[] args) {
    		SpringApplication.run(ApiApplication.class, args);
    	}
    
    }

    4. HttpServletRequestWrapper

    • Http RequestHttpServletRequest(javax.servlet)로 넘어오게 되는데 HttpServletRequest를 래핑 하여 커스텀 가능한 HttpServletRequestWrapper를 상속받아서 Request Body를 획득하여 복호화하는 방식으로 구현합니다.
    • 클래스명은 RequestBodyDecodingWrapper로 작성합니다. (편한 클래스명을 사용해도 됩니다.)
    • 편한 예시를 위해 Hex값을 받아 필터에서 Decode 하는 방식으로 구현했습니다. (복호화) 
    import lombok.extern.log4j.Log4j2;
    import org.apache.commons.codec.DecoderException;
    import org.apache.commons.codec.binary.Hex;
    import org.apache.commons.io.IOUtils;
    
    import javax.servlet.ReadListener;
    import javax.servlet.ServletInputStream;
    import javax.servlet.http.HttpServletRequest;
    import javax.servlet.http.HttpServletRequestWrapper;
    import java.io.*;
    import java.nio.charset.StandardCharsets;
    
    @Log4j2
    public class RequestBodyDecryptWrapper extends HttpServletRequestWrapper {
    
        // 가로챈 데이터를 가공하여 담을 final 변수
        private final String requestDecryptBody;
    
        public RequestBodyDecryptWrapper(HttpServletRequest request) throws IOException, DecoderException {
            super(request);
    
            String requestHashData = requestDataByte(request); // Request Data 가로채기
            String decodeTemp = requestBodyDecode(requestHashData); // Reqeust Data Hex 디코드
            
            log.info("인코딩 데이터: " + requestHashData);
            log.info("디코딩 데이터: " + decodeTemp);
            
            requestDecryptBody = decodeTemp;
        }
    
        @Override
        public ServletInputStream getInputStream() {
            final ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(requestDecryptBody.getBytes(StandardCharsets.UTF_8));
            return new ServletInputStream() {
                @Override
                public boolean isFinished() {
                    return false;
                }
    
                @Override
                public boolean isReady() {
                    return false;
                }
    
                @Override
                public void setReadListener(ReadListener listener) {
                }
    
                @Override
                public int read() {
                    return byteArrayInputStream.read();
                }
            };
        }
    
        @Override
        public BufferedReader getReader() {
            return new BufferedReader(new InputStreamReader(this.getInputStream()));
        }
    
        //==request Body 가로채기==//
        private String requestDataByte(HttpServletRequest request) throws IOException {
            byte[] rawData = new byte[128];
            InputStream inputStream = request.getInputStream();
            rawData = IOUtils.toByteArray(inputStream);
            return new String(rawData);
        }
    
        //==request Body Hex 디코딩==//
        private String requestBodyDecode(String requestHashData) throws DecoderException {
            return HexDecodeToString(requestHashData);
        }
    
        //==Request Data Decode (Hex Decode)==//
        public static String HexDecodeToString(String encodeText) throws DecoderException {
            return new String(Hex.decodeHex(encodeText.toCharArray()));
        }
    }

    5. 필터에 적용

    • HttpServletRequestWrapper를 상속받아 구현한 RequestBodyDecryptWrapper를 필터에 적용합니다.
    • Filter의 doFilter()에 적용합니다.
    • 필터에서 일어난 예외는 컨트롤러까지 닿지 않으므로 다음과 같은 형태로 직접 예외처리해줘야 합니다.
    @Log4j2
    @WebFilter(urlPatterns = "/*")
    public class apiFilter implements Filter {
    
        @Override
        public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException {
            HttpServletRequest req = (HttpServletRequest) request;
            HttpServletResponse res = (HttpServletResponse) response;
    
            try {
                // 커스텀 래퍼적용        
                RequestBodyDecryptWrapper requestWrapper = new RequestBodyDecryptWrapper(req);
                chain.doFilter(requestWrapper, res);
            } catch (Exception e) {
                // 디코딩 불가 예외 처리
                log.error(e);            
                log.error("디코딩 불가능 합니다.");            
                String ResponseMessage = "디코드 불가능한 데이터 입니다.";
                byte[] data = ResponseMessage.getBytes("utf-8");
                int count = data.length;
                res.setStatus(400);
                res.setContentLength(count);
                res.getOutputStream().write(data);
                res.flushBuffer();
            }
        }
    }

    6. 테스트

    • 테스트를 위해 작성한 스프링 부트를 실행 후 Postman을 이용해 테스트해 봅니다.

    [Test Hex Data]

    ec9594ed98b8ed9994eb909c20eb8db0ec9db4ed84b020ec9e85eb8b88eb8ba42e

    = 암호화된 데이터입니다.

    6.1 정상 호출

    • Request Body에 정상 Hex Hash 데이터를 입력하여 호출합니다.
    • Response Body에 success를 반환받은 것을 확인할 수 있습니다.

    정상 처리

    • 인텔리제이 로그에 정상적으로 데이터가 들어와서 인코딩 데이터, 디코딩 데이터, 그리고 컨트롤러로 정상적으로 들어간 것을 확인 할 수 있습니다.

    디코딩 성공 로그

    6.2 비 정상 호출

    • Request Body에 비정상 Test Hex Data를 입력하여 호출합니다.
    • Test Hex Data 뒤에 1을 붙여서 비정상적인 데이터를 만들어 줍니다.

    비정상 처리
    디코딩 실패 로그