Spring Controller의 세계

Composite·2023년 1월 27일
14

Spring의 세계

목록 보기
1/1

스프링 프레임워크는 엔터프라이즈 스케일까지 엔터프라이즈급 비즈니스 프레임워크가 무엇인지 보여주는 최고의 프레임워크다. 비록 세팅은 복잡하지만, 비즈니스 로직을 자유로우면서도, 구조적으로 잡아주는 게 무엇인지 보여주어 전 세계 자바 개발 시장에 당당히 많은 자리를 꿰차게 만들었다. 특히 스프링 MVC는 MVC 패턴의 모범 사례를 아주 재대로 보여주는 프레임워크라 하겠다.

물론 이렇게 스프링을 발전시켜준 계기가 병신같은 EJB 덕분이기도 하고...

오늘은 스프링 컨트롤러의 다양한 구현 방법을 소개하고자 한다.
초보들이 많이들 혼란스러워 하는게 바로 반환 타입이 한정적이 아닌 너무 자유로워서 뭘 쓰는지 해매는 개발자들이 많은데, 특히 국비 너네들. 특히 너네들 보라고 만든 글이니 감사히 읽도록.

단, 클래스는 @Controller 어노테이션 선언한 컨트롤러 클래스 기준이니 미리 혼동하지 않도록.

ModelAndView 반환

가장 많이 쓰고 가장 정석인 방식이 바로 ModelAndView 반환하는 방식이다.

@RequestMapping("do/something")
public ModelAndView doSomething() {
    var mav = new ModelAndView(); // 이거 자바스크립트 아니다. 자바 11이다.
    mav.addObject("모델키", "난 빠쑝모델");
    mav.setView("어딘/가에/뷰파일");
    return mav;
}

모델과 뷰를 바로 한 객체에 담을 수 있고 가독성 또한 장점으로 치부되어 많이들 쓰고 있는 구현 방식이라 하겠다.

String 반환

그다음 많이 쓰고 있는 방식이 문자열 반환 방식인데, 보통 문자열은 "뷰 이름"으로 스프링이 취급한다.
그럼 모델은 어떻게 전달하느냐? 인자에 선언해주면 된다. 스프링은 알아서 인자 타입을 추적하여 Model 이나 ModelMap 타입이 감지되면 뷰에 모델을 전달해줄 것이다.

@RequestMapping("do/something")
public String doSomething(Model model) {
    model.addAttribute("모델키", "난 빠쑝모델");
    return "어딘/가에/뷰파일";
}

여기서 햇갈리는 부분이 바로 REST API 만들 때인데, 만약 컨트롤러 클래스를 @RestController 어노테이션으로 설정했다면, 반환하는 String 은 뷰 이름을 해석하려 하지 않고 반환하는 문자열을 그대로 API 클라이언트에게 뿌려줄 것이다.

만약, @RestContoller 를 클래스에 매달면, 스프링은 모든 컨트롤러 메소드를 REST API 로 취급하기 때문에 반환하는 객체가 View, ModelAndView 가 아니면 모두 @ResponseBody 어노테이션 단 메소드로 취급하여 반환한 객체를 "직렬화"하여 클라이언트에게 뿌려주게 된다.

만약 그냥 @Controller 어노테이션을 단 클래스인데, 문자열 그대로 출력하고 싶다면, 메소드 위에 @ResponseBody 어노테이션을 매달아주면 된다.

@ResponseBody
@RequestMapping("do/something")
public String doSomething() {
    return "이거 호출하면 내가 나옴";
}

여기서 또하나 궁금한 게 바로 뷰 식별하는 문자열은 무엇을 가리키냐면,
대부분은 JSP 같은 템플릿 뷰를 우선순위로 두기 때문에, 문자열을 입력하면, Resolver 순서를 찾아서 거기에 맞는 뷰 문자열을 대입한 뒤, 찾아서 있으면 출력하고, 없으면 다른 Resolver 를 찾는다. jsonView 같은 다른 뷰가 있을 수도 있고, 다양하다. 하지만 결국 뷰를 찾지 못하면, 404 오류 뷰를 출력하고 끝내버린다.
Config 설정을 담은 XML이나 Java를 보면, InternalResourceViewResolver 클래스가 보일 것이다. 문자열이나 뷰 식별자를 던지면, ViewResolver 가 가져와 시도하고 없으면 다른 ViewResolver가 가져와 시도... 없으면 결국 404 오류가 담긴 뷰를 던지게 된다.
ViewResolver 에 관한 주제는 여기서 다루지 않으니 직접 검색해서 찾도록.

View 반환

이번엔 View 객체로도 반환할 수 있는데, 말 그대로 순전히 뷰다. 이녀석은 뷰 클래스를 반환하기 때문에 ViewResolver 에 없는 빈을 지정해서 반환하는 게 보통 시나리오다. 물론 이는 ModelAndView 에서도 가능하다.
그럼 모델은? 위 String 반환할 때처럼 인자에 모델을 선언하면 된다.

@Autowired
private View jsonView;

@RequestMapping("do/something")
public View doSomething(Model model) {
    model.addAttribute("모델키", "난 빠쑝모델");
    return jsonView;
}

주로 위의 예제처럼 JSP 경로찾는 게 아닌 아예 ViewResolver 없는 뷰 클래스를 통해 뷰를 출력할 때, 특히 JSON이나 XML 같은 다른 응답 값을 주고자 할 때 사용한다.
뷰와 REST API를 모두 사용하는 시나리오에서 볼 수 있다.

Map, Model, ModelMap 반환

자, 다음은 모델 객체를 반환했을 때에 대한 시나리오다. 그러면 뷰를 못 뱉어내니 뷰를 어떻게 정의를 하냐인데, 여기서 그 방식을 알려주도록 하겠다.
반환 클래스가 모델 위주인 경우, 뷰가 어떻게 결정되냐면, 바로 API 매핑 주소가 바로 뷰 이름으로 결정된다. 예를 들면,

@RequestMapping("do/something") // 이녀석이 곧 뷰 이름이다.
public Model doSomething() {
    var model = new Model();
    model.addAttribute("모델키", "난 빠쑝모델");
    return model;
}

이렇게 하면 당연히 동작하며, 스프링은 요청 주소인 do/something 문자열을 ViewResolver 에게 던져서 시도할 것이다. 예를 들어 JSP 템플릿을 찾는다면, 스프링 부트 기준의 기본값에 따라src/main/resources/views/do/something.jsp 파일을 찾으려 할 것이다. 그러니까 API 주소가 곧 뷰 식별자가 되는 것이다. 이는 후술할 void 에서도 별도의 응답 스트림을 지정하지 않는다면 똑같이 적용된다.

void 반환

이번엔 void 반환인데, 보통 반환값이 없는 시나리오는 직접 응답 스트림을 가공해서 출력하는 시나리오에 많이 쓴다. 가장 많이 사용하는 예로 파일 다운로드가 되겠다.
대충 이렇게들 많이 쓴다.

@RequestMapping("do/something")
public void doSomething(HttpServletRequest request, HttpServletResponse response) {
    var what = request.getParameter("what");
    var path = Paths.get(what);
    if (Files.exists(path)) {
        response.setContentType("application/octet-stream");
        response.setContentLength((int)Files.size(path));
        response.setHeader("Content-disposition", "attachment;filename=\"download.bin\"");
        OutputStream os = response.getOutputStream();
        FileInputStream fis = Files.newInputStream(Path);
        int read = 0;
        byte[] buffer = new byte[1024];
        while ((read = fileInputStream.read(buffer)) != -1) {
            out.write(buffer, 0, read);
        }
        fis.close();
        os.close();
    } else {
        throw new ResponseStatusException(NOT_FOUND, "그딴 파일 없다.");
    }
}

이런 식으로 응답 내용을 직접적으로 다루는 시나리오에 많이 쓴다고 보면 되겠다.

ResponseEntity<T> 반환

이녀석은 정말 편리하고 강력한데 실무에서는 왜이렇게 안쓰는지 모르겠다. 물론 이거 아니어도 @ResponseBody 와 반환 클래스만 있으면 REST API 뚝딱 만들 수 있으니. 그렇다. REST API를 위한 클래스다.

사용법은 간단하다. 원하는 반환 타입 메소드를 ResponseEntity 제네릭에 감싸고 제공하는 직관적인 API를 사용하면 된다.

@RequestMapping("do/something")
public ResponseEntity<String> doSomething() {
    return ResponseEntity.ok("오옷 성공");
}

이녀석의 이점은 HttpServletResponse 클래스를 인자로 둘 필요 없이 원하는 HTTP 상태와 원하는 헤더 추가가 쉽게 가능하다는 장점이 있다. 이런 식으로.

@RequestMapping("do/something")
public ResponseEntity<String> doSomething(@RequestParam("what") String what) {
    if ("notfound".equals(what)) {
        return ResponseEntity.notFound().header("Why-Not", "못찾은헤더").body("못찾음"); // 404
    } else if ("forbidden".equals(what)) {
        return ResponseEntity.status(403).header("Why-Not", "안찾은헤더")body("안찾음"); // 403
    } else
    return ResponseEntity.ok("난 성공"); // 200 OK
}

만약 RestTemplate 를 써봤다면 생각보다 쓰는 게 꽤 익숙할 것이다.

DeferredResult<T> 반환

위 클래스도 잘 안쓰는데 이거 쓰는 꼴은 더욱 찾아보기 힘들다. 나온지 꽤 됐고 자바 6도 지원했던 걸 말이다. 당연하다. 한국에서 스프링 활용 용도는 그저 DB의 미들웨어 역할 그 이상도 아니었으니까. 어쨌든, NIO 쓴다면, 스레드 적극 활용한다면 고민하지 말고 DeferredResult 클래스로 해결하자.

사용법은 반환할 클래스를 제네릭으로 덮고 이런 식으로 작성하면 된다.

@RequestMapping("do/something")
public DeferredResult<String> doSomething() {
    DeferredResult<String> output = new DeferredResult<>();
    
    ForkJoinPool.commonPool().submit(() -> {
        output.setResult("다른 쓰레드에서 응답한 나야!");
    });
    
    return output;
}

그렇다. 다른 스레드에서 결과를 받을 때 사용한다. 따라서 @Async 어노테이션 붙은 서비스와 궁합이 아주 잘 맞다고 보면 된다. 어자피 자바 8 쓸 테고 Rx나 reactor 를 쓴다면 더할나위 없는 클래스로 보면 된다.

하지만 진짜 좋은 점은 바로 Timeout 과 callback이 있다. 간단히 알아보도록 하자.

응답 타임아웃

DeferredResult 클래스 초기화 시 첫번째 인자에 언제까지 응답을 받아줄지 ms 단위로 넣을 수 있다. 따라서 이런 식으로 타임아웃 응답을 시험해볼 수 있다.

@RequestMapping("do/something")
public DeferredResult<String> doSomething() {
    DeferredResult<String> output = new DeferredResult<>(5000l);
    // 응답 시간 초과 시 콜백
    output.onTimeout(() ->
        deferredResult.setErrorResult(new ResponseStatusException(REQUEST_TIMEOUT, "땡! 시간 초과."))
    );
    
    ForkJoinPool.commonPool().submit(() -> {
        try {
            Thread.sleep(6000);
        } catch (InterruptedException e) {
            // 에이 설마 나겠어?
        }
        output.setResult("다른 쓰레드에서 응답한 나야!");
    });
    
    return output;
}

성공 및 오류 콜백

DeferredResult 클래스에서는 성공 및 오류 응답을 받을 경우에 대한 콜백도 지정 가능하다.

@RequestMapping("do/something")
public DeferredResult<String> doSomething() {
    DeferredResult<String> output = new DeferredResult<>();
    output.onCompletion(() ->
        LOG.info("응답 끝났다!");
    );
    output.onError(e ->
        LOG.warn("어? 오류났네?", e);
    );
    
    ForkJoinPool.commonPool().submit(() -> {
    	if (new Random().nextInt(1) == 1) {
            deferredResult.setErrorResult(new RuntimeException("넌 운이 없군."));
        }
        output.setResult("넌 참 운이 좋아.");
    });
    
    LOG.info("요청 끝났으니 응답 시작!");
    return output;
}

ResponseEntity 와의 궁합

DeferredResult<ResponseEntity<?>>, 이 둘의 궁합은 최고다. ResponseEntity 결과를 setResult 메소드에 전달하면 된다. HTTP 상태, 필요한 헤더 등등 자유롭고 스레드까지 유연한 REST API가 완성된다.

@RequestMapping("do/something")
public DeferredResult<ResponseEntity<String>> doSomething(@RequestParam("what") final String what) {

    DeferredResult<ResponseEntity<String>> output = new DeferredResult<>();

    ForkJoinPool.commonPool().submit(() -> {
        if ("notfound".equals(what)) {
            output.setResult(ResponseEntity.notFound().header("Why-Not", "못찾은헤더").body("못찾음")); // 404
        } else if ("forbidden".equals(what)) {
            output.setResult(ResponseEntity.status(403).header("Why-Not", "안찾은헤더")body("안찾음")); // 403
        } else
        output.setResult(ResponseEntity.ok("난 성공"));
    });

	return output;
}

마치며

자, 눈치챘겠지만, 사실은 REST API라면 ResponseEntity 좀 쓰고, 병렬 스레드 작업이 활발한 작업이 필요하다면 주저없이 DeferredResult 클래스를 쓰라고 홍보하기 위한 글이어따.

만약 JSP 같은 템플릿 엔진과 함께하는 풀스택 앱이라면... 음... Ajax 요청에 한해서만 ResponseEntity 쓰면 되고, 나머진 위에 기존 방법을 쓰는 것을 추천한다. 그냥 그게 편하니까.

물론 기존에 개발했던 거 유지보수일 경우에 바꾸는 미친 짓은 하지 말고, 스프링 4 이상, 자바 8 이상이라면 새로운 API 생성 시, 신규 프로젝트나 고도화 시, 이 둘의 사용을 적극 검토하여 편리하고 유연한 REST API 서버를 만들어 보도록 하자. 스프링이 신경 많이 써줬다.

여기에 파일 다운로드에 대해서도 여러 방법을 쓰려고 했는데 생각보다 내용이 꽤 길고 다양한 방법이 있어서 이는 별도로 다루도록 하겠다.

끗.

profile
지옥에서 온 개발자

0개의 댓글