Java

Spring - Controller 간단 요약하기

한휘용 2023. 7. 26. 01:27
728x90

Spring의 Controller를 설명하려면 먼저 애플리케이션 구현에 사용되는 Spring MVC에 대해서 알아야합니다.

 

Spring MVC 

Spring 모듈 중에는 웹 계층을 담당하는 몇 가지 모듈이 있습니다.

 

그 중 서블릿(Servlet) API를 기반으로 클라이언트의 요청을 처리하는 모듈이 있는데, 이 모듈 이름이 바로 spring-webmvc입니다. 이 spring - webmvc를 줄여서 Spring MVC라고 부릅니다. 

서블릿은 클라이언트의 요청을 처리하도록 특정 규약에 맞추어서 Java 코드로 작성하는 클래스 파일입니다.

아파치 톰캣(Apache Tomcat)은 이러한 서블릿들이 웹 애플리케이션으로 실행이 되도록 해주는 서블릿 컨테이너(Servlet Container) 중 하나입니다.

 Spring MVC 내부에서는 서블릿을 기반으로 웹 애플리케이션이 동작한다는 사실을 기억을 하고 있으면 좋습니다.

 

Spring MVC를 떠올릴 때 구체적인 모습이 그려지지 않는다면 2가지만 기억하면 됩니다.

  • Spring MVC는 클라이언트의 요청을 편리하게 처리해 주는 프레임워크이다.
  • 우리가 만들게 될 샘플 애플리케이션은 Spring MVC가 제공해 주는 기능을 이용해서 만든다.

이제 Spring MVC에서 MVC가 무엇인지 간단하게 살펴보도록 하겠습니다.

 

 

Model

Model은 Spring MVC에서 M에 해당됩니다.

 

Spring MVC 기반의 웹 애플리케이션의 클라이언트의 요청을 전달받으면 요청 사항을 처리하기 위한 작업을 합니다.

 

이렇게 처리한 작업의 결과 데이터를 클라이언트에게 응답으로 돌려줘야 하는데, 이때 클라이언트에게 응답으로 돌려주는 작업의 처리 결과 데이터 Model이라고 한다.

클라이언트의 요청 사항을 구체적으로 처리하는 영역을 서비스계층(Service Layer)이라고 하며, 실제로 요청 사항을 처리하기 위해 Java 코드로 구현한 것을 비즈니스 로직(Business Logic)이라고 합니다.

 

View

View는 Spring MVC에서 V에 해당된다.

 

View는 앞에서 설명한 Model 데이터를 이용해서 웹브라우저 같은 클라이언트 애플리케이션의 화면에 보이는 리소스(Resource)를 제공하는 역할을 한다.

 

Spring MVC에는 다양한 View 기술이 포함되어 있는데 View의 형태는 아래와 같이 나눌 수 있다.

  • HTML 페이지의 출력
    • 클라이언트 애플리케이션에 보이는 HTML 페이지를 직접 렌더링 해서 클라이언트 측에 전송하는 방식입니다.
    • 기본적인 HTML 태그로 구성된 페이지에 Model 데이터를 채워 넣은 후, 최종적인 HTML 페이지를 만들어서 클라이언트 측에 전송해줍니다.
  • PDF, Excel 등의 문서 형태로 출력
    • Model 데이터를 가공해서 PDF 문서나 Excel 문서를 만들어서 클라이언트 측에 전송하는 방식입니다.
    • 문서 내에서 데이터가 동적으로 변경되어야 하는 경우 사용할 수 있는 방식입니다.
  • XML, JSON 등 특정 형식의 포맷으로의 변환
    • Model 데이터를 특정 프로토콜 형태로 변환해서 변환된 데이터를 클라이언트 측에 전송하는 방식입니다.
    • 이 방식의 경우 특정 형식의 데이터만 전송하고, 프론트엔드 측에서 이 데이터를 기반으로 HTML 페이지를 만드는 방식입니다.
    • 프론트엔드 영역과 백엔드 영역이 명확하게 구분되며 개발 및 유지보수가 상대적으로 용이하다는게 장점입니다.

 

JSON(JavaScript Object Notation)

 

JSON은 Spring MVC에서 클라이언트 애플리케이션과 서버 애플리케이션이 주고 받는 데이터 형식입니다.

 

과거에는 XML 형식의 데이터가 많이 사용되었으나 현재는 XML보다 상대적으로 가볍고, 복잡하지 않은 JSON 형식을 대부분 사용하고 있는 추세입니다.

 

 

JSON의 기본 포맷

  • {”속성”:”값”} 형태로 구성됩니다.

 

예를 들어 Coffee 라는 클래스가 있다고 가정하면,

public class Coffee {
    private String korName;
    private String engName;
    private int price;

    public Coffee(String korName, String engName, int price) {
        this.korName = korName;
        this.engName = engName;
        this.price = price;
    }
}

 

위 예시 코드는 Coffee를 클래스로 표현한 코드이다.

고객이 아메리카노 한 잔을 주문하기 위해서 아메리카노의 정보를 요청한다면 서버 애플리케이션 쪽에서 아메리카노 정보를 JSON 형식으로 변환해서 전송해주어야 합니다.

 

이 경우 서버 애플리케이션 쪽에서 클라이언트 쪽으로 전송하는 아메리카노 정보는 JSON으로 어떻게 표현되는지 확인해 보자.

public class JsonExample {
        public static void main(String[] args) {
            Coffee coffee = new Coffee("아메리카노", "Americano", 3000);
            Gson gson = new Gson();
            String jsonString = gson.toJson(coffee);
    
            System.out.println(jsonString);
        }
    }

위 코드는 Gson이라는 라이브를 사용해서 Coffee 클래스의 객체를 JSON 포맷 형태로 출력하는 예시코드입니다.

 

이렇게 Gson을 사용하여 Java의 객체를 JSON 포맷으로 변환 할 수 있다.

ex) 카페 음료 JSON
{
"korName":"아메리카노",
"engName":"Americano",
"price":3000
}

이 처럼 JSON 포맷은 기본적으로{”속성”:”값”} 형태로 구성이 됩니다.

 

Controller

Controller는 Spring MVC에서 C에 해당됩니다.

 

Controller는 클라이언트 측의 요청을 직접적으로 전달받는 엔드포인트(Endpoint)로써 Model과 View의 중간에서 상호 작용을 해주는 역할을 합니다.

 

즉, 클라이언트 측의 요청을 전달받아서 비즈니스 로직을 거친 후에 Model 데이터가 만들어지면, 이 Model 데이터를 View로 전달하는 역할을 하게됩니다.

@RestController
@RequestMapping(path = "/v1/coffee")
public class CoffeeController {                 
    private final CoffeeService coffeeService;

    CoffeeController(CoffeeService coffeeService) {
        this.coffeeService = coffeeService;
    }

    @GetMapping("/{coffee-id}")  // (1)
    public Coffee getCoffee(@PathVariable("coffee-id") long coffeeId) {
        return coffeeService.findCoffee(coffeeId); // (2)
    }
}

위 코드 예시는 Spring MVC에서 Controller에 해당되는 영역을 코드로 작성한 예시입니다. 동작의 흐름을 파악해봅시다.

  • (1)의 @GetMapping 애노테이션을 통해 클라이언트 측의 요청을 수신합니다.
  • (2)에서 CoffeeService 클래스의 findCoffee() 메서드를 호출해서 비즈니스 로직을 처리합니다.

(2)에서 비즈니스 로직을 처리한 다음 리턴 받는 Coffee가 여기서는 Model 데이터가 됩니다.

 

그리고 getCoffee()에서 이 Model 데이터를 리턴하는데, 리턴되는 이 Model 데이터는 우리가 코드 상에서는 확인할 수 없지만 내부적으로 Spring의 View가 전달받아서 JSON 포맷으로 변경한 후에 클라이언트 측에 전달합니다.

 

[Spring MVC의 동작 방식] Model, View, Controller 간의 처리 흐름은 최종적으로 아래와 같숩니다.

 

Client가 요청 데이터 전송

→ Controller가 요청 데이터 수신 → 비즈니스 로직 처리 → Model 데이터 생성
→ Controller에게 Model 데이터 전달 → Controller가 View에게 Model 데이터 전달
→ View가 응답 데이터 생성

 

정리하자면,

Spring MVC는 Spring 모듈 중 서블릿(Servlet) API를 기반으로 클라이언트의 요청을 처리하는 spring-webmvc를 말합니다.

 

Spring MVC에서 MModel을 의미합니다.

클라이언트에게 응답으로 돌려주는 작업의 처리 결과 데이터Model이라고 합니다.

 

Spring MVC에서 VView를 의미합니다.

View는 Model 데이터를 이용해서 웹브라우저 같은 클라이언트 애플리케이션의 화면에 보이는 리소스(Resource)를 제공합니다. 앞으로 학습하게 되는 View는 JSON 포맷의 데이터를 생성합니다.

 

Spring MVC에서 CController를 의미합니다.

Controller는 클라이언트 측의 요청을 전달받아 Model과 View의 중간에서 상호 작용을 해주는 역할을 담당합니다.

 

Spring MVC의 전체적인 동작흐름은 아래와 같습니다.

Client가 요청 데이터 전송 → Controller가 요청 데이터 수신 → 비즈니스 로직 처리 → Model 데이터 생성 →

Controller에게 Model 데이터 전달 → Controller가 View에게 Model 데이터 전달 → View가 응답 데이터 생성

 

 

 

Spring MVC 동작 방식과 구성요소

다음은 클라이언트가 요청을 서버에 보냈을 때 Spring MVC가 내부적으로 어떻게 요청을 처리하는 보여주는 그림입니다.

번호의 흐름을 설명하자면 다음과 같습니다.

 

  1. 클라이언트가 요청을 서버에 전송하면, DispatcherServlet이라는 클래스에게 요청이 전달됩니다.
  2. DispatcherServlet은 클라이언트 요청을 처리할 Controller에 대한 검색을 HandlerMapping 인터페이스에게 요청합니다.
  3. HandlerMapping은 클라이언트 요청에 해당하는 컨트롤러를 찾고 해당 컨트롤러 정보를 다시 DispatcherServlet에게 응답합니다.
    • Controller 정보는해당 Controller 안에 있는 Handler 메서드 정보도 가지고 있습니다. 
    • Handler 메서드란 Controller 클래스 안에 구현된 요청을 처리해주는 메서드입니다.
  4. 요청을 처리할 Controller를 찾았으니 이제 클라이언트 요청을 처리할 Handler 메서드를 찾아서 호출해야 합니다.  DispatcherServlet은 Handler 메서드를 직접 호출하지 않고 HandlerAdapter에게 Handler 메서드 호출 책임을 넘깁니다.
  5. HandlerAdapter은 DispatcherServlet의 요청과 더불어 Controller 정보를 가지고 해당 Controller의 Handler 메서드를 호출합니다.
  6. Controller의 Handler 메서드는 비즈니스 로직을 처리한 후 리턴받은 Model 데이터를 HandlerAdapter에게 반환합니다.
  7. HandlerAdapter은 반환받은 Model 데이터와 View 이름을 다시 DispatcherServlet에게 반환합니다.
  8. DispatcherServlet은 반환받은 2개의 데이터 중 View 이름을 가지고 ViewResolver에게 해당 View를 달라고 요청합니다.
  9. ViewResolver는 View 정보에 해당하는 View를 찾아서 DispatcherServlet에게 반환합니다.
  10. DispatcherServlet은 ViewResolver에게 받은 View 객체에게 Model 데이터를 넘겨주면서 클라이언트에게 전달할 응답 데이터 생성을 요청합니다.
  11. View는 응답 데이터를 생성해서 다시 DispatcherServlet에게 반환합니다.
  12. DitpatcherServlet은 View로부터 받은 응답데이터를 마지막으로 클라이언트에게 응답합니다.
DispatcherServlet의 역할

DIspatcherServlet은 바빠보이지만 직접적으로 요청을 처리하지 않고 다른 구성요소에게 요청을 위임(Delegate)합니다.이처럼 애플리케이션 가장 앞단 배치되어 다른 구성요소들과 상호작용하면서 클라이언트 요청을 처리하는 패턴을 Front Controller Pattern이라고 합니다

 

 

Controller

이제 Spring MVC에 대해 간단하게 알아봤으니, 드디어 Controller에 대해 알아보겠습니다.

 

Spring에서의 Controller는 웹 애플리케이션에서 클라이언트의 요청을 받고, 해당 요청을 처리하는 역할을 합니다.

좀 더 쉽게 설명하자면, 웹 애플리케이션의 '입구' 라고 생각할 수 있습니다.

 

이제 Controller를 학습하기 위해 Spring Boot 기반의 애플리케이션을 만들어 보겠습니다.

 

이때, Spring Boot 기반의 애플리케이션에서 주로 사용되는 Java 패키지 구조는 기능 기반 패키지 구조(package-by-feature)계층 기반 패키지 구조(package-by-layer)가 있습니다.

 

 

기능 기반 패키지 구조(package-by-feature)란 말 그대로 애플리케이션의 패키지를 애플리케이션에서 구현해야 하는 기능을 기준으로 패키지를 구성하는 것입니다.

나누어진 패키지 안에는 하나의 기능을 완성하기 위한 계층별(API 계층, 서비스 계층, 데이터 액세스 계층) 클래스들이 모여있습니다.

회원을 관리하기 위한 회원 기능과 커피를 관리하기 위한 커피 기능을 각각 coffeemember라는 패키지로 나누었으며, 각각의 패키지 안에 레이어 별 클래스들이 존재합니다.

 

 

계층 기반 패키지 구조(package-by-layer)란 패키지를 하나의 계층(Layer)으로 보고 클래스들을 계층별로 묶어서 관리하는 구조를 말합니다.

계층(Layer)을 기반으로 패키지를 구성합니다. ‘controller, dto’ 패키지는 API 계층에 해당되고, ‘model, service’ 패키지는 비즈니스 계층에 해당되며, repository는 데이터 액세스 계층에 해당됩니다.

 

 

이렇게 Spring Boot 기반의 애플리케이션을 생성할 때 애플리케이션의 요구 사항이나 특성에 따라 상황에 맞게 두가지 패키지 구조 중 선택하여 사용하면 됩니다.

 

이번 학습에서 만들 애플리케이션은 커피 주문 애플리케이션입니다.

이번 학습에선 테스트와 리팩토링이 용이하고, 향후에 마이크로 서비스 시스템으로의 분리가 상대적으로 용이기능 기반 패키지 구조를 사용하겠습니다.

 

 

 

커피 주문 애플리케이션의 Controller 설계

커피 주문 애플리케이션의 Controller 클래스 코드를 작성하기전에 생각해보아야 할 부분이 있습니다.

바로 클라이언트로부터 발생할 요청에는 어떤 것들이 있을까' 입니다.

 

이 말을 서버 애플리케이션의 입장에서 생각하면 '클라이언트 요청을 처리할 서버 애플리케이션의 기능으로 뭐가 필요할까’와 같습니다.

 

그럼 커피 주문 애플리케이션의 기능 요구 사항을 살펴보겠습니다.

 

  • 주인이 커피 정보를 관리하는 기능
    • 커피 정보 등록 기능
    • 등록한 커피 정보 수정 기능
    • 등록한 커피 정보 삭제 기능
    • 등록한 커피 정보 조회 기능
  • 고객이 커피 정보를 조회하는 기능
    • 커피 정보 조회 기능
  • 고객이 커피를 주문하는 기능
    • 커피 주문 등록 기능
    • 커피 주문 취소 기능
    • 커피 주문 조회 기능
  • 고객이 주문한 커피를 주인이 조회하는 기능
    • 커피 주문 조회 기능
    • 고객에게 전달 완료한 커피에 대한 주문 완료 처리 기능

위 기능들에 대한 Controller 클래스를 작성하려면 총 몇 개의 Controller 클래스가 필요할까요?

 

 

커피 주문 애플리케이션에 필요한 리소스

REST API 기반의 애플리케이션에서는 일반적으로 애플리케이션이 제공해야 될 기능을 리소스(Resource, 자원)로 분류합니다. 기능 요구사항 기반으로 나누어 보자면 회원, 커피, 주문이 되겠습니다.

커피 주문 애플리케이션에 기본적으로 필요한 리소스

따라서 학습을 애플리케이션의 리소스에 해당하는 Controller 클래스를 작성해보겠습니다.

 

먼저 리소스의 Controller 구조를 만들어 보겠습니다.

 

 

MemberController 구조 작성

컨트롤러의 작성은 매우 심플합니다.

위 코드 예시에서 2가지만 알아두면 됩니다.

package com.codestates.member;

import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController           //  (1)
@RequestMapping("/v1/members")         //  (2)

public class MemberController {
}

 

(1) @RestController

  • Spring MVC에서는 특정 클래스에 @RestController를 추가하면 해당 클래스가 REST API의 리소스(자원, Resource)를 처리하기 위한 API 엔드포인트로 동작함을 정의합니다.
  • 또한 @RestController가 추가된 클래스는 애플리케이션 로딩 시, Spring Bean으로 등록해 줍니다.

 

(2) @RequestMapping

  • @RequestMapping 은 클라이언트의 요청과 클라이언트 요청을 처리하는 핸들러 메서드(Handler Method)를 매핑해 주는 역할을 합니다.

같은 방식으로 CoffeeControler도 작성해보겠습니다.

package com.codestates.coffee;

import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/v1/coffees")

public class CoffeeController {
}

이번엔 OrderController입니다.

package com.codestates.order;

import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/v1/orders")

public class OrderController {
}

 

이렇게 3가지 리소스의 Controller 구조를 작성했습니다.

 

그런데 만약 Controller의 구조만 작성한 상태에서 MemberController에 요청을 전송하면 Postman에서 status:404 오류메세지를 출력할 것입니다.

404 오류 메세지는 요청 페이지 또는 요청 리소스를 찾을 수 없다는 의미입니다.

이처럼 에러 응답을 받는 이유는 MemberController클라이언트의 요청을 처리할 핸들러 메서드(Handler Method)가 아직 없기 때문입니다.

 

이제 위에서 작성한 Controller 클래스에 클라이언트의 요청을 처리할 핸들러 메서드를 추가해 봅시다

 

MemberController의 핸들러 메서드를 작성하기위해 요청에 필요한 회원(Member)의 정보를 설정하겠습니다.

 

요청에 필요한 회원(Member) 정보

 

회원 이메일 주소: email

회원 이름: name

회원 전화번호: phoneNumber

 

이제 이 정보를 가지고 MemberController의 핸들러 메서드를 작성해 보겠습니다.

package com.codestates.member;

import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping(value = "/v1/members", produces = {MediaType.APPLICATION_JSON_VALUE}) //(1)
public class MemberController {
    @PostMapping 
    public String postMember(@RequestParam("email") String email,
                             @RequestParam("name") String name,
                             @RequestParam("phone") String phone) {
        System.out.println("# email: " + email);
        System.out.println("# name: " + name);
        System.out.println("# phone: " + phone);

        String response =
                "{\"" +
                        "email\":\""+email+"\"," +
                        "\"name\":\""+name+"\",\"" +
                        "phone\":\"" + phone+
                        "\"}";
        return response;
    }

    @GetMapping("/{member-id}")
    public String getMember(@PathVariable("member-id")long memberId) {
        System.out.println("# memberId: " + memberId);
        
        return null;
    }

    @GetMapping
    public String getMembers() {
        System.out.println("# get Members");
        
        return null;
    }
}

위 코드 예시에서 MemberController에 추가된 부분을 살펴 봅시다.

 

1. 클래스 @RequestMapping에 추가된 produces

produces애트리뷰트(Attribute)는 응답 데이터를 어떤 미디어 타입으로 클라이언트에게 전송할지를 설정합니다. 여기서는 JSON 형식의 데이터를 응답 데이터로 전송하겠다는 의미로 MediaType.APPLICATION_JSON_VALUE 값을 설정했습니다.

 

만약 이 값을 설정하지 않는다면 코드는 JSON 형식의 데이터를 응답으로 전송하지 않고, 문자열 자체를 전송합니다.

 

2. postMember(), getMember(), getMembers() 메소드 추가

 

 

2-1. postMember() 메서드

postMember() 메서드는 회원의 정보를 등록해 주는 핸들러 메서드입니다.

 

@PostMapping 은 클라이언트의 요청 데이터(request body)를 서버에 생성할 때 사용하는 애너테이션입니다.

위의 예시 코드와 같이 클라이언트 쪽에서 요청 전송 시, HTTP Method 타입을 동일하게 맞춰주어야 합니다.

 

@RequestParam은 핸들러 메서드의 파라미터 종류 중 하나입니다.

주로 클라이언트 쪽에서 전송하는 요청 데이터를 쿼리 파라미터(Query Parmeter 또는 Query String), 폼 데이터(form-data), x-www-form-urlencoded 형식으로 전송하면 이를 서버 쪽에서 전달받을 때 사용하는 애너테이션입니다.

쿼리 파라미터(Query Parameter 또는 QueryString)
요청 URL에서 ‘?’를 기준으로 붙는 key/value 쌍의 데이터를 말합니다.

ex) http://localhost:8080/coffees/1?page=1&size=10

이렇게 작성된 postMember() 핸들러 메서드의 리턴 타입은 현재 String입니다.

단, 클라이언트 쪽에서 JSON 형식의 데이터를 전송받아야 하기 때문에 응답 문자열을 JSON 형식에 맞게 작성했습니다.

 

 

2-2. getMember() 메서드

getMember() 메서드는 특정 회원의 정보를 클라이언트 쪽에 제공하는 핸들러 메서드입니다.

 

@GetMapping 은 클라이언트가 서버에 리소스를 조회할 때 사용하는 애너테이션입니다.

 

@GetMapping 애너테이션의 괄호 안에는 몇 가지 애트리뷰트(Attribute)를 사용할 수 있지만 여기서는 전체 HTTP URI의 일부를 지정했습니다.

클라이언트 쪽에서 getMember() 핸들러 메서드에 요청을 보낼 경우, 최종 URI는 형태는 아래와 같습니다. ”/v1/members/{member-id}”
이 URI는 클래스 레벨의 @RequestMapping 설정 URI와 @GetMapping 설정 URI가 합쳐진 형태가 됩니다.
{member-id}는 회원 식별자를 의미하며 클라이언트가 요청을 보낼 때 URI로 어떤 값을 지정하느냐에 따라서 동적으로 바뀌는 값입니다.

@PathVariable은 핸들러 메서드의 파라미터 종류 중 하나입니다.

 

@PathVariable의 괄호 안에 입력한 문자열 값은 @GetMapping("/{member-id}")처럼 중괄호({ }) 안의 문자열과 동일해야 합니다. 여기서는 두 문자열 모두 “member-id” 로 동일하게 지정해 주었습니다. 만약 두 문자열이 다르다면 MissingPathVariableException이 발생합니다.

 

 

2-3. getMembers() 메서드

getMembers() 메서드는 회원 목록을 클라이언트에게 제공하는 핸들러 메서드입니다.

 

@GetMapping 에는 별도의 URI를 지정해주지 않았기 때문에 클래스 레벨의 URI(“/v1/members”)에 매핑됩니다.

 

 

지금 현재의 코드에선 개선 해야할 부분들이 많이 있습니다.

package com.codestates.member;

import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping(value = "/v1/members", produces = {MediaType.APPLICATION_JSON_VALUE})
public class MemberController {
    @PostMapping
    public String postMember(@RequestParam("email") String email,
                             @RequestParam("name") String name,
                             @RequestParam("phone") String phone) {
        System.out.println("# email: " + email);
        System.out.println("# name: " + name);
        System.out.println("# phone: " + phone);

        // 코드 개선이 필요한 부분
        String response =
                "{\"" +
                   "email\":\""+email+"\"," +
                   "\"name\":\""+name+"\",\"" +
                   "phone\":\"" + phone+
                "\"}";
        return response;
    }

    @GetMapping("/{member-id}")
    public String getMember(@PathVariable("member-id") long memberId) {
        System.out.println("# memberId: " + memberId);

        // not implementation
        return null;
    }

    @GetMapping
    public String getMembers() {
        System.out.println("# get Members");

        // not implementation
        return null;
    }
}

 

1. 수작업으로 JSON 문자열을 만들어 주는 부분

가장 개선이 되어야 하는 부분이 바로 JSON 문자열을 개발자가 직접 수작업으로 작성하고 있는 것입니다.

개발자가 일일이 직접 JSON 형식에 맞추어 문자열을 작성하는 일은 번거롭고, 오타로 인해 에러가 발생할 가능성이 높습니다.

 

2. @RequestParam 애너테이션을 사용한 요청 파라미터 수신

클라이언트 쪽에서 전달받아야 되는 요청 파라미터들이 다섯 개라면 핸들러 메서드의 파라미터로 총 다섯 개의 @RequestParameter를 사용해서 파라미터로 입력해야 됩니다. 이 부분도 개선이 필요합니다.

 

 

먼저 JSON 형식의 응답 문자열을 수작업으로 작성하는 부분을 개선하겠습니다.

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.HashMap;
import java.util.Map;

@RestController
@RequestMapping("/v1/members") // (1) produces 설정 제거됨
public class MemberController {
    @PostMapping
    public ResponseEntity postMember(@RequestParam("email") String email,
                                     @RequestParam("name") String name,
                                     @RequestParam("phone") String phone) {
        // (2) JSON 문자열 수작업을 Map 객체로 대체
        Map<String, String> map = new HashMap<>();
        map.put("email", email);
        map.put("name", name);
        map.put("phone", phone);

        // (3) 리턴 값을 ResponseEntity 객체로 변경
        return new ResponseEntity<>(map, HttpStatus.CREATED);
    }

    @GetMapping("/{member-id}")
    public ResponseEntity getMember(@PathVariable("member-id") long memberId) {
        System.out.println("# memberId: " + memberId);

        // not implementation

        // (4) 리턴 값을 ResponseEntity 객체로 변경
        return new ResponseEntity<>(HttpStatus.OK);
    }

    @GetMapping
    public ResponseEntity getMembers() {
        System.out.println("# get Members");

        // not implementation

        // (5) 리턴 값을 ResponseEntity 객체로 변경
        return new ResponseEntity<>(HttpStatus.OK);
    }
}

이전 예시 코드를 개선한 코드입니다.

어떤 부분이 달라졌는지 확인해보겠습니다.

 

 

1. 클래스 레벨의 @RequestMapping의 ‘produces’ 애트리뷰트가 사라졌습니다.

개발자가 수작업으로 작성하던 문자열 형식으로 작성된 데이터를 JSON 형식의 응답 데이터로 전송하겠다는 의미로

MediaType.APPLICATION_JSON_VALUE 값을 설정했었습니다. 하지만 이부분이 Map 객체로 대체되며 더 이상 필요가 없어졌습니다. 그래서 사라졌습니다.

 

 

2. JSON 문자열을 개발자가 직접 수작업으로 작성하던 부분이 Map 객체로 대체되었습니다.

이를 통해서 @RequestMapping의 ‘produces’ 애트리뷰트를 생략할 수 있게 되었습니다.

Map 객체를 리턴하게 되면 내부적으로 JSON 형식으로 자동 변환해 주기 때문입니다.

 

 

3. 리턴 값으로 JSON 문자열을 리턴하던 부분이 ResponseEntity 객체를 리턴하는 것으로 바뀌었습니다.

코드를 보면, new ResponseEntity<>(map, HttpStatus.CREATED);처럼 ResponseEntity 객체를 생성하면서 생성자 파라미터로 응답 데이터(map)와 HTTP 응답 상태를 함께 전달하고 있습니다.

 

이처럼 HTTP 응답 상태를 명시적으로 함께 전달하면 클라이언트의 요청을 서버가 어떻게 처리했는지를 쉽게 알 수 있습니다.

 

클라이언트 쪽에서는 이 HTTP 응답 상태를 기반으로 정상적으로 다음 처리를 할지 에러 처리를 할지 결정하면 되는 것입니다.

 

getMember(), getMembers() 핸들러 메서드 역시 ResponseEntity 객체를 리턴하는 걸로 수정하였으며, HttpStatus.OK 응답 상태를 전달하도록 개선했습니다.

 

 

 

DTO(Data Transfer Object)

DTO는 Data Transfer Object의 약자로, 데이터 전송을 위한 객체입니다.

비즈니스 로직을 포함하지 않으며, 순수하게 데이터 전송을 위한 용도로 사용됩니다.

 

DTO는 데이터 전송 시에 효율적이며, 데이터를 담고 있는 포장지와 같은 역할을 합니다.

비즈니스 로직을 갖지 않고 데이터만 전송하는 간단하고 단순한 객체이기 때문에 이해하기 쉽고 사용하기 편리합니다.

 

우리는 이 DTO를 이용해서 아까 만들었던 컨트롤러의 데이터부분을 개선해 보려고합니다.

 

 

DTO 클래스를 이용한 코드의 간결성

 

이전에 작성했던 MemberControllerpostMember() 핸들러 메서드입니다.

@RestController
@RequestMapping("/v1/members")
public class MemberController {
    @PostMapping
    public ResponseEntity postMember(@RequestParam("email") String email,
                                     @RequestParam("name") String name,
                                     @RequestParam("phone") String phone) {
        Map<String, String> map = new HashMap<>();
        map.put("email", email);
        map.put("name", name);
        map.put("phone", phone);

        return new ResponseEntity<Map>(map, HttpStatus.CREATED);
    }

		...
		...
}

postMember()에서 개선해야 될 부분을 살펴보면서 DTO가 필요한 이유를 확인해 보도록 하겠습니다.

위 예시 코드에선 회원 정보를 저장하기 위해서 총 세 개의 @RequestParam 애너테이션을 사용하고 있습니다.

 

지금은 학습용으로 생성한 컨트롤러이기 때문에 요청데이터가 3개 밖에 없지만 실제로는 회원의 주소 정보, 로그인 패스워드, 패스워드 확인 정보 등 더 많은 정보들이 회원 정보에 포함되어 있을 수 있습니다.

 

따라서 postMember()에 파라미터로 추가되는 @RequestParam의 개수는 계속 늘어날 수밖에 없습니다.

 

이러한 경우 클라이언트의 요청 데이터를 하나의 객체로 모두 전달 받을 수 있게 해서 코드를 간결하게 만드는게 좋습니다. 바로 이럴 때 DTO클래스를 이용할 수 있습니다.

 

만약 DTO 클래스를 적용하여 postMember()메서드의 코드를 개선한다면 아래와 같은 모습이 될 것 입니다.

@RestController
@RequestMapping("/v1/members")
public class MemberController {
    @PostMapping
    public ResponseEntity postMember(MemberDto memberDto) {
        return new ResponseEntity<MemberDto>(memberDto, HttpStatus.CREATED);
    }

		...
		...
}

위 코드 예시를 보면 postMember()에서 @RequestParam을 사용하는 부분이 사라지고 MemberDto memberDto가 추가되었습니다.

 

그리고 아직 비즈니스 로직이 없긴 하지만 어쨌든 @RequestParam을 통해 전달받은 요청 데이터들을 Map에 추가하는 로직이 사라지고, MemberDto 객체를 ResponseEntity 클래스의 생성자 파라미터로 전달하도록 변경되었습니다.

 

이렇게 DTO 클래스를 사용해서 코드를 간결하게 만들어 보았습니다.

 

이제 DTO클래스를 전부 적용하여 코드들을 간결하게 만들겠습니다.

 

먼저 DTO 클래스들을 생성합니다.

 

MemberPostDto 클래스

public class MemberPostDto {
    private String email;
    private String name;
    private String phone;

    public String getEmail() {
        return email;
    }

    public void setEmail(String email) {
        this.email = email;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getPhone() {
        return phone;
    }

    public void setPhone(String phone) {
        this.phone = phone;
    }
}

 

MemberPatchDto 클래스

public class MemberPatchDto {
    private long memberId;
    private String name;
    private String phone;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getPhone() {
        return phone;
    }

    public void setPhone(String phone) {
        this.phone = phone;
    }

    public long getMemberId() {
        return memberId;
    }

    public void setMemberId(long memberId) {
        this.memberId = memberId;
    }
}

DTO 클래스를 만들 때, 주의해야 할 부분은 멤버 변수 이외에 각 멤버 변수에 해당하는 getter 메서드가 있어야 한다는 것입니다.

getter 메서드가 없으면 Response Body에 해당 멤버 변수의 값이 포함되지 않는 문제가 발생합니다.

setter 메서드는 필수 항목은 아니지만 개발자의 필요에 의해서 있을 수도 있고, 없을 수도 있습니다.

 

이제 만들어 놓은 DTO를 MemberController에 적용시키겠습니다.

@RestController
@RequestMapping("/v1/members")
public class MemberController {
    // 회원 정보 등록
    @PostMapping
    public ResponseEntity postMember(@RequestBody MemberPostDto memberPostDto) {
        return new ResponseEntity<>(memberPostDto, HttpStatus.CREATED);
    }

    // 회원 정보 수정
    @PatchMapping("/{member-id}")
    public ResponseEntity patchMember(@PathVariable("member-id") long memberId,
                                      @RequestBody MemberPatchDto memberPatchDto) {
        memberPatchDto.setMemberId(memberId);
        memberPatchDto.setName("홍길동");
        

        return new ResponseEntity<>(memberPatchDto, HttpStatus.OK);
    }

    // 한명의 회원 정보 조회
    @GetMapping("/{member-id}")
    public ResponseEntity getMember(@PathVariable("member-id") long memberId) {
        System.out.println("# memberId: " + memberId);

        return new ResponseEntity<>(HttpStatus.OK);
    }

    // 모든 회원 정보 조회
    @GetMapping
    public ResponseEntity getMembers() {
        System.out.println("# get Members");


        return new ResponseEntity<>(HttpStatus.OK);
    }

    // 회원 정보 삭제
    @DeleteMapping("/{member-id}")
    public ResponseEntity deleteMember(@PathVariable("member-id") long memberId) {

        return new ResponseEntity(HttpStatus.NO_CONTENT);
    }
}

@RequestParam을 사용하는 대신에 DTO 클래스를 사용해서 postMember()에서는 MemberPostDto, patchMember()에서는 MemberPatchDto 클래스의 객체를 통해서 Request Body를 한 번에 전달받을 수 있도록 개선했습니다.

 

DTO 적용 이전의 코드의 경우 @RequestParam을 각각 세 번 사용하여 Request Body를 전달받은 것과 달리 MemberPostDto 객체로 Request Body를 한 번에 전달받음으로써 코드가 간결해졌습니다.

 

 

DTO 유효성 검증(Validation)

그럼 이제 MemberController에서 사용된 MemberPostDto 클래스와 MemberPatchDto 클래스에 유효성 검증을 적용해 보도록 하겠습니다.

 

유효성 검증을 위해서는 의존 라이브러리를 추가해야합니다.

DTO 클래스에 유효성 검증을 적용하기 위해서는 Spring Boot에서 지원하는 Starter가 필요합니다.

build.gradle 파일의 dependencies 항목에 'org.springframework.boot:spring-boot-starter-validation’을 추가하세요.

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-validation'
	...
	...
}

이제 준비는 끝났습니다. 먼저 MemberPostDto의 유효성을 검증해 봅시다.

 

 

 

✔️ MemberPostDto 유효성 검증 제약 사항

  • email (이메일 주소)
    • 값이 비어있지 않거나 공백이 아니어야 합니다.
    • 유효한 이메일 주소 형식이어야 합니다
  • name (이름)
    • 값이 비어있지 않거나 공백이 아니어야 합니다.
  • phone (휴대폰 번호)
    • 값이 비어있지 않거나 공백이 아니어야 합니다.
    • 아래와 같이 010으로 시작하는 11자리 숫자와 ‘-’로 구성된 문자열이어야 합니다.
import javax.validation.constraints.Email;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Pattern;

public class MemberPostDto {
    @NotBlank
    @Email
    private String email;

    @NotBlank(message = "이름은 공백이 아니어야 합니다.")
    private String name;

    @Pattern(regexp = "^010-\\d{3,4}-\\d{4}$",
            message = "휴대폰 번호는 010으로 시작하는 11자리 숫자와 '-'로 구성되어야 합니다.")
    private String phone;

    public String getEmail() {
        return email;
    }

    public void setEmail(String email) {
        this.email = email;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getPhone() {
        return phone;
    }

    public void setPhone(String phone) {
        this.phone = phone;
    }
}

위 코드 예시는 유효성 검증이 적용된 MemberPostDto의 코드입니다.

 

회원 등록을 위해 클라이언트에서 전달받는 Request Body의 데이터인 emil, name, phone 정보에 유효성 검증을 위한 애너테이션이 추가되었습니다.

 

MemberPostDto의 멤버 변수에 적용된 유효성 검증 내용은 다음과 같습니다.

  • email
    • @NotBlank
      • 이메일 정보가 비어있지 않은지를 검증합니다.
      • null 값이나 공백(””), 스페이스(” “) 같은 값들을 모두 허용하지 않습니다.
      • 유효성 검증에 실패하면 에러 메시지가 콘솔에 출력됩니다.
    • @Email
      • 유효한 이메일 주소인지를 검증합니다.
      • 유효성 검증에 실패하면 내장된 디폴트 에러 메시지가 콘솔에 출력됩니다.
  • name
    • @NotBlank
      • 이름 정보가 비어있지 않은지를 검증합니다.
      • null 값이나 공백(””), 스페이스(” “) 같은 값들을 모두 허용하지 않습니다.
      • 유효성 검증에 실패하면 @NotBlank의 message 애트리뷰트에 지정한 문자열이 에러 메시지로 콘솔에 출력됩니다.
  • phone
    • @Pattern
      • 휴대폰 정보가 정규표현식(Reqular Expression)에 매치되는 유효한 번호인지를 검증합니다.
      • 유효성 검증에 실패하면 내장된 디폴트 에러 메시지가 콘솔에 출력됩니다.

 

요청으로 전달받는 MemberPostDto 클래스의 각 멤버 변수에 유효성 검증을 위한 애너테이션을 추가함으로써 MemberController의 핸들러 메서드에 별도의 유효성 검증을 추가하지 않고, 깔끔하게 유효성 검증 로직이 분리되었습니다.

 

 

이번엔 MemberPatchDto 유효성 검증을 검증해 봅시다.

 

✔️ MemberPatchDto 유효성 검증 제약 사항

  • name (이름)
    • 값이 비어있을 수 있습니다.
    • 값이 비어있지 않다면 공백이 아니어야 합니다.
  • phone (휴대폰 번호)
    • 값이 비어있을 수 있습니다.
    • 아래와 같이 010으로 시작하는 11자리 숫자와 ‘-’로 구성된 문자열이어야 합니다.
import javax.validation.constraints.Pattern;

public class MemberPatchDto {
    private long memberId;

    // 공백 아닌 문자 1개 이상((공백인 문자 0개 또는 1개)(공백이 아닌 문자 1개 이상)) -> 마지막 맨 바깥 쪽 괄호 조건이 0개 이상(즉, 있어도 되고 없어도 된다)
    @Pattern(regexp = "^\\S+(\\s?\\S+)*$", message = "회원 이름은 공백이 아니어야 합니다.")
    private String name;

    @Pattern(regexp = "^010-\\d{3,4}-\\d{4}$",
            message = "휴대폰 번호는 010으로 시작하는 11자리 숫자와 '-'로 구성되어야 합니다.")
    private String phone;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getPhone() {
        return phone;
    }

    public void setPhone(String phone) {
        this.phone = phone;
    }

    public long getMemberId() {
        return memberId;
    }

    public void setMemberId(long memberId) {
        this.memberId = memberId;
    }
}

위 코드 예시는 유효성 검증이 적용된 MemberPatchDto의 코드입니다.

 

회원 등록을 위해 클라이언트에서 전달받는 Request Body의 데이터인 emil, name, phone 정보에 유효성 검증을 위한 애너테이션이 추가되었습니다.

 

MemberPatchDto 클래스의 멤버 변수에 적용된 유효성 검증 내용은 다음과 같습니다..

  • memberId
    • Request Body에 포함되는 데이터가 아니므로 유효성 검증이 필요하지 않습니다.
  • name
    • @Pattern
      • 정규 표현식으로 다음 내용을 체크합니다.
        • 이름 정보가 비어있으면(null) 유효성 검증을 하지 않습니다.
        • 이름 정보가 비어 있지 않고(not null), 공백 문자열이라면 검증에 실패합니다.
        • 시작 문자가 공백이면 검증에 실패합니다.
        • 끝 문자가 공백이면 검증에 실패합니다.
        • 문자와 문자 사이 공백이 1개를 초과하면 검증에 실패합니다.
  • phone
    • @Pattern
      • 정규 표현식으로 다음 내용을 체크합니다. 
      • 휴대폰 정보가 비어있으면(null) 유효성 검증을 하지 않습니다. 
      • 휴대폰 정보가 비어 있지 않고, 010으로 시작하는 11자리 숫자와 ‘-’로 구성된 문자열이 아니라면 검증에 실패합니다.

 

유효성 검증은 컨트롤러의 쿼리 파라미터 및 @Pathvariable에서도 가능합니다.

 

patchMember() 핸들러 메서드의 URI path에서 사용되는 @PathVariable("member-id") long memberId 변수를 검증해보겠습니다.

 

일반적으로 수정이 필요한 데이터의 식별자는 0 이상의 숫자로 표현을 합니다.

patchMember() 핸들러 메서드에서 사용되는 memberId에 ‘1 이상의 숫자여야 한다’라는 제약 조건을 걸어보도록 하겠습니다.

@RestController
@RequestMapping("/v1/members")
@Validated   // (1)
public class MemberController {
		...
		...

    @PatchMapping("/{member-id}")
    public ResponseEntity patchMember(@PathVariable("member-id") @Min(1) long memberId,
                                    @Valid @RequestBody MemberPatchDto memberPatchDto) {
        memberPatchDto.setMemberId(memberId);

        // No need Business logic

        return new ResponseEntity<>(memberPatchDto, HttpStatus.OK);
    }
}

@PathVariable("member-id") long memberId1 이상의 숫자일 경우에만 유효성 검증에 통과하도록 @Min(1)이라는 검증 애너테이션을 추가했습니다.

 

여기서 주의해야 할 점은 @PathVariable이 추가된 변수에 유효성 검증이 정상적으로 수행되려면 (1)과 같이 클래스 레벨에 @Validated 애너테이션을 반드시 붙여주어야 한다는 사실입니다.

 

이렇게 DTO 와 DTO의 유효성 검증도 알아봤습니다. 

 

이 외에도  Custom Validator를 사용해서 원하는 목적에 맞는 애너테이션을 직접 만들어서 유효성 검증에 적용할 수 있습니다.

728x90