구리

[Spring Boot] RESTful Service 기능 확장해보기 본문

SPRING BOOT

[Spring Boot] RESTful Service 기능 확장해보기

guriguriguri 2021. 10. 9. 22:22

Rest API가 대충 어떤건지만 알고 정확한 개념이나 설계하는 법을 자세히 몰랐기에 인프런에서 들은 강의를 토대로 배운 내용을 정리한 것입니다. 참고로 강의는 Dowon Lee 님의 Spring Boot를 이용한 RESTful Web Services 개발 입니다.

 

지난 번에 썼던 블로그에 나와있는 User Service API에서 기능을 확장해보겠습니다.

https://jy-beak.tistory.com/122

 

[Spring Boot] REST API 에 대해서..

Rest API가 대충 어떤건지만 알고 정확한 개념이나 설계하는 법을 자세히 몰랐기에 인프런에서 들은 강의를 토대로 배운 내용을 정리한 것입니다. 참고로 강의는 Dowon Lee 님의 Spring Boot를 이용한 RE

jy-beak.tistory.com

 


[유효성 체크를 위한 Validation API 사용]

  • User 클래스에 어노테이션 추가
    • @Size : 문자열, 배열 등의 크기기 만족하는가? 의 제약조건
    • @Past : 현재 보다 과거인가? 의 제약조건
  • UserController의 사용자 추가하는 메소드에 @Valid 어노테이션 추가
    • 먼저 JSON 타입의 User 객체 가져오 후 @Valid 어노테이션을 통해 User가 유효한 객체인지 검사 
package com.example.restful.user;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import javax.validation.constraints.Past;
import javax.validation.constraints.Size;
import java.util.Date;

@Data
@AllArgsConstructor
public class User {
    private Integer id;

    @Size(min=2, message = "Name은 2글자 이상 입력해주세요.")
    private String name;
    @Past
    private Date joinDate;
}
package com.example.restful.user;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;

import javax.validation.Valid;
import java.net.URI;
import java.util.List;

@RestController
public class UserController {
    .. 중략

	@PostMapping("/users")
    public ResponseEntity<User> createUser(@Valid @RequestBody User user){
    User savedUser = service.save(user);

        URI location = ServletUriComponentsBuilder.fromCurrentRequest()
                .path("/{id}")
                .buildAndExpand(savedUser.getId())
                .toUri();

        return ResponseEntity.created(location).build();
    }
}

잘못된 요청이라고만 뜨고 body에 예외 내용은 전달되지 않음

  • 사용자가 입력한 값에 문제가 생겼을 때 사용하는 예외 메소드 재정의
package com.example.restful.exception;

import com.example.restful.user.UserNotFoundException;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;

import java.util.Date;

@RestController
@ControllerAdvice  
public class CustomizedResponseEntityExceptionHandler extends ResponseEntityExceptionHandler {

    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex,
                                                                  HttpHeaders headers,
                                                                  HttpStatus status,
                                                                  WebRequest request) {
        ExceptionResponse exceptionResponse = new ExceptionResponse(new Date(),
                "Validation Failed", ex.getBindingResult().toString());

        return new ResponseEntity(exceptionResponse, HttpStatus.BAD_REQUEST);
    }
}

exceptionResponse 객체에 설정한 message와 User 객체에 설정한 유효성 검사 실패 메세지가 정상적으로 출력됩니다


[다국어 처리를 위한 Internationalization 구현]

  • 하나의 출력 값을 여러 가지 언어로 표시해주는 다국어 처리 기능을 사용하는 에제
  • MessageSource와 언어 변경에 사용될 Locale Resolver를 활용합니다.

1. 먼저 Spring Boot Application에LocaleResolver를 bean으로 등록

package com.example.restful;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.web.servlet.LocaleResolver;
import org.springframework.web.servlet.i18n.SessionLocaleResolver;

import java.util.Locale;

@SpringBootApplication
public class RestfulApplication {

	public static void main(String[] args) {
		SpringApplication.run(RestfulApplication.class, args);
	}

	@Bean
	public LocaleResolver localeResolver(){
		SessionLocaleResolver localeResolver = new SessionLocaleResolver();
		localeResolver.setDefaultLocale(Locale.KOREA);
		return localeResolver;
	}
}

 

2. application.yml에 사용할 다국어 파일 등록

# 사용할 다국어 파일명을 messages로 사용한다는 의미 
spring: 
	messages: 
    	basename: messages

3. Resource에 다국어 파일(properties) 생성

// messages.properties 
greeting.message=안녕하세요 

// messages_en.properties 
greeting.message=Hello 

// messages_fr.properties 
greeting.message=Bonjour

4. Controller에 메세지 주입

  • locale 값이 정해지면 Spring에서는 해당 locale의 messagesource의 값으로 변환해서 돌려줌
package com.example.restful.helloworld;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.MessageSource;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;

import java.util.Locale;

@RestController
public class HelloWorldController {
    @Autowired
    private MessageSource messageSource;

    @GetMapping("/hello-world-internationalized")
    public String helloWorldInternationalized(@RequestHeader(name="Accept-Language", required = false) Locale locale){
        return messageSource.getMessage("greeting.message", null, locale);
    }

}

locale값을 따로 지정하지 않았을 때 default_locale값 (한국어) 리턴
header에 지정한 key, value 값 설정 후 조회시 원하는 언어로 리턴
사전에 설정하지 않는 locale로 send시 defalut_locale값(한국어)로 리턴된다


[Response 데이터 형식 변환 - XML format]

  • SpringBoot의 기본 데이터 설정은 JSON 포맷이기에 지금까지는 클라이언트가 요청한 결과값을 JSON 포맷으로 전달해옴
  • 하지만 정상적인 요청에도 서버측에서 준비되있지 않은 XML 데이터값으로 요청시 클아이언트 오류 발생 (406)
  • header에서 accpet : application/xml로 설정 (xml 데이터값으로 문서 반환을 받겠다는 선언)

1. pom.xml에서 response 데이터를 XML 형식으로 변환해주는 dependency 추가 후 서버 재구동

<dependency>
			<groupId>com.fasterxml.jackson.dataformat</groupId>
			<artifactId>jackson-dataformat-xml</artifactId>
			<version>2.10.2</version>
</dependency>

xml 포맷으로 데이터가 조회되는 것을 확인할 수 있음 (물론 JSON 타입도 가능)


[Response 데이터 제어를 위한 Filtering]

  • domain 클래스가 가진 정보 중 외부에 노출을 원치 않는 정보가 있을 경우 Filtering 적용

 

  •  User 도메인에서 어노테이션을 활용한 Filtering

 (1) @JsonIgnore : 필드 레벨에서 무시될 수 있는 속성을 표시하는데 사용

package com.example.restful.user;

import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import javax.validation.constraints.Past;
import javax.validation.constraints.Size;
import java.util.Date;

@Data
@AllArgsConstructor
public class User {
    private Integer id;

    @Size(min=2, message = "Name은 2글자 이상 입력해주세요.")
    private String name;
    @Past
    private Date joinDate;
    @JsonIgnore
    private String password;
    @JsonIgnore
    private String ssn;
}

전체 사용자 조회시 @JsonIgnore를 적용한 필드값은 보이지 않게 된다

   (2) @JsonIgnoreProperties : 클래스 블록에 어노테이션 적용 (무시할 속성이나 속성 목록을 표시하는데 사용)

import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import javax.validation.constraints.Past;
import javax.validation.constraints.Size;
import java.util.Date;

@Data
@AllArgsConstructor
@JsonIgnoreProperties(value = {"password","ssn"})
public class User {
    private Integer id;

    @Size(min=2, message = "Name은 2글자 이상 입력해주세요.")
    private String name;
    @Past
    private Date joinDate;
    private String password;
    private String ssn;
}

마찬가지로 설정한 필드값은 보여지지 않는 것을 알 수 있다.

  • 프로그래밍으로 제어하는 Filtering 방법 - 개별 사용자 조회 케이스

1. User 도메인에 @JsonFilter 어노테이션 적용

  • 원하는 이름으로 id를 정의하며 후에 Filter 등록시 해당 id 을 참조하여 사용
import com.fasterxml.jackson.annotation.JsonFilter;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import javax.validation.constraints.Past;
import javax.validation.constraints.Size;
import java.util.Date;

@Data
@AllArgsConstructor
// 원하는 이름으로 설정 (컴트롤러나 서비스 클래스에서 사용)
@JsonFilter("UserInfo")
public class User {
    private Integer id;

    @Size(min=2, message = "Name은 2글자 이상 입력해주세요.")
    private String name;
    @Past
    private Date joinDate;

    private String password;
    private String ssn;
}

2. 새로운 Controller에서 Filter 생성 후 해당 클래스에 Filter 적용하여 값 반환

  • SimpleBeanPropertyFilter : 지정된 필드들만 JSON 변환, 알 수 없는 필드들은 무시
  • MappingJacksonValue 클래스로 User 도메인을 filter 적용할 수 있는 다른 타입으로 변경 (해당 타입으로 데이터 반환!!)
  • FilterProvider를 통해 @JsonFilter에서 지정한 id 값, 생성한 Filter를 매개변수로 하여 우리가 사용할 수 있는 필터로 변경
  • MappingJacksonValue 타입 객체에 타입 변경한 filter 적용
import com.fasterxml.jackson.databind.ser.FilterProvider;
import com.fasterxml.jackson.databind.ser.impl.SimpleBeanPropertyFilter;
import com.fasterxml.jackson.databind.ser.impl.SimpleFilterProvider;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.json.MappingJacksonValue;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;

import javax.validation.Valid;
import java.net.URI;
import java.util.List;

@RestController()
@RequestMapping("/admin")
public class AdminUserController {
    private UserDaoService service;

    public AdminUserController(UserDaoService service){
        this.service = service;
    }
    
    @GetMapping("/users/{id}")
    public MappingJacksonValue retrieveUser(@PathVariable int id){
        User user = service.findOne(id);

        if(user == null){
            throw new UserNotFoundException(String.format("ID[%s] not found", id));
        }

        SimpleBeanPropertyFilter filter = SimpleBeanPropertyFilter
                .filterOutAllExcept("id","name","joinDate","ssn");

        // filter가 적용될 수 있는 다른 타입으로 반환
        MappingJacksonValue mapping = new MappingJacksonValue(user);

        // 우리가 사용할 수 있는 filter로 변환
        FilterProvider filters = new SimpleFilterProvider().addFilter("UserInfo",filter);

        mapping.setFilters(filters);

        return mapping;
    }
}

개별사용자 조회시 지정한 필터만 보여지게 된다

 

 

 

  • 프로그래밍으로 제어하는 Filtering 방법 - 전체 사용자 조회 케이스
    • 개별 사용자와 마찬 가지로 Filtering을 하여 데이터를 반환하면 되는데 꼭 MappingJacksonValue 타입으로 반환해야 한다는 것을 잊지말자
package com.example.restful.user;

import com.fasterxml.jackson.databind.ser.FilterProvider;
import com.fasterxml.jackson.databind.ser.impl.SimpleBeanPropertyFilter;
import com.fasterxml.jackson.databind.ser.impl.SimpleFilterProvider;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.json.MappingJacksonValue;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;

import javax.validation.Valid;
import java.net.URI;
import java.util.List;

@RestController()
@RequestMapping("/admin")
public class AdminUserController {
    private UserDaoService service;

    public AdminUserController(UserDaoService service){
        this.service = service;
    }

    @GetMapping("/users")
    public MappingJacksonValue retrieveAllUsers(){
        List<User> users = service.findAll();
        SimpleBeanPropertyFilter filter = SimpleBeanPropertyFilter
                .filterOutAllExcept("id","name","joinDate","ssn");

        MappingJacksonValue mapping = new MappingJacksonValue(users);
        FilterProvider filters = new SimpleFilterProvider().addFilter("UserInfo",filter);
        mapping.setFilters(filters);
        return mapping;
    }
}
결과적으로
@JsonFilter => 응답하고자 하는 결과에 대해, 데이터(객체)가 가지고 있는 필드를 다시 제어(조건에 맞는 필드만)하여 전달하기 위해 설정하는 방법
@JsonIgnore => 아에 해당 필드의 데이터 존재 유무에 상관없이 무조건 제외 시켜버리는 설정
@JsonIgnore가 설정되면, 해당 객체를 사용하는 모든 곳에 영향을 주는 반면, @JsonFilter는 적절하게 필요한 부분에서만 제어해서 사용할 수 있을거라 생각된다.

[REST API Version 관리]

 1) URI Versioning - 일반 브라우저에서 실행가능

    - Twitter

 

 2) Request Parameter Versioning - 일반 브라우저에서 실행 가능

    - Amazon

 

 3) (Custom) Headers Versioning - 일반 브라우저에서 실행 불가

    - Microsoft

 

 4) Media Type Versioning - 일반 브라우저에서 실행 불가

    (a.k.a "content negotiation" or "accept header")

    - GitHub




- URI를 통한 버전 관리

  • User를 상속받는 UserV2 클래스 생성 
package com.example.restful.user;

import com.fasterxml.jackson.annotation.JsonFilter;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import javax.validation.constraints.Past;
import javax.validation.constraints.Size;
import java.util.Date;

@Data
@AllArgsConstructor
@NoArgsConstructor
@JsonFilter("UserInfoV2")
public class UserV2 extends User{
    private String grade;
}

 

(1) URI에 버전을 포함시켜 버전을 관리하는 방법

  • AdminUserController에서 개별 사용자 조회 메소드 버전 1
  • 위에 나와있는 메소드에서 맵핑 URI만 변경했기에 실행이 잘 되는 것을 볼 수 있음
package com.example.restful.user;

import com.fasterxml.jackson.databind.ser.FilterProvider;
import com.fasterxml.jackson.databind.ser.impl.SimpleBeanPropertyFilter;
import com.fasterxml.jackson.databind.ser.impl.SimpleFilterProvider;
import org.springframework.beans.BeanUtils;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.json.MappingJacksonValue;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;

import javax.validation.Valid;
import java.net.URI;
import java.util.List;

@RestController()
@RequestMapping("/admin")
public class AdminUserController {
    ... 중략
    
    @GetMapping("/v1/users/{id}")
    public MappingJacksonValue retrieveUser1(@PathVariable int id){
        User user = service.findOne(id);

        if(user == null){
            throw new UserNotFoundException(String.format("ID[%s] not found", id));
        }

        SimpleBeanPropertyFilter filter = SimpleBeanPropertyFilter
                .filterOutAllExcept("id","name","joinDate","ssn");

        MappingJacksonValue mapping = new MappingJacksonValue(user);
        FilterProvider filters = new SimpleFilterProvider().addFilter("UserInfo",filter);
        mapping.setFilters(filters);

        return mapping;
    }
}

 

  • AdminUserController에서 개별 사용자 조회 메소드 버전 2
  • 새로운 필드를 추가한 UserV2 도메인으로 개별 사용자 조회한 결과로 필터 적용도 정상적으로 된 것을 볼 수 있다
package com.example.restful.user;

import com.fasterxml.jackson.databind.ser.FilterProvider;
import com.fasterxml.jackson.databind.ser.impl.SimpleBeanPropertyFilter;
import com.fasterxml.jackson.databind.ser.impl.SimpleFilterProvider;
import org.springframework.beans.BeanUtils;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.json.MappingJacksonValue;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;

import javax.validation.Valid;
import java.net.URI;
import java.util.List;

@RestController()
@RequestMapping("/admin")
public class AdminUserController {
    ... 중략
    
    @GetMapping("/v2/users/{id}")
    public MappingJacksonValue retrieveUser2(@PathVariable int id){
        User user = service.findOne(id);

        if(user == null){
            throw new UserNotFoundException(String.format("ID[%s] not found", id));
        }

        UserV2 userV2 = new UserV2();

        // 두 인스턴스 간에 공통 필드가 존재할 경우 copy
        BeanUtils.copyProperties(user,userV2);
        userV2.setGrade("VIP");

        SimpleBeanPropertyFilter filter = SimpleBeanPropertyFilter
                .filterOutAllExcept("id","name","joinDate","grade");

        MappingJacksonValue mapping = new MappingJacksonValue(userV2);
        FilterProvider filters = new SimpleFilterProvider().addFilter("UserInfoV2",filter);
        mapping.setFilters(filters);

        return mapping;
    }
}

(2) RequestParameter를 이용한 버전 관리 

  • 맵핑 어노테이션에 params="" 추가하여 버전관리 진행
package com.example.restful.user;

import com.fasterxml.jackson.databind.ser.FilterProvider;
import com.fasterxml.jackson.databind.ser.impl.SimpleBeanPropertyFilter;
import com.fasterxml.jackson.databind.ser.impl.SimpleFilterProvider;
import org.springframework.beans.BeanUtils;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.json.MappingJacksonValue;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;

import javax.validation.Valid;
import java.net.URI;
import java.util.List;

@RestController()
@RequestMapping("/admin")
public class AdminUserController {
   ... 즁략

    @GetMapping(value= "/users/{id}/", params = "version=1")
    public MappingJacksonValue retrieveUser1(@PathVariable int id){
               // 위 메소드 구현부와 동일하므로 생략
    }

    @GetMapping(value= "/users/{id}/", params = "version=2")
    public MappingJacksonValue retrieveUser2(@PathVariable int id){
                // 위 메소드 구현부와 동일하므로 생략

    }
}

?version=1
?version=2

 

(3) header를 통한 버전 관리

package com.example.restful.user;

import com.fasterxml.jackson.databind.ser.FilterProvider;
import com.fasterxml.jackson.databind.ser.impl.SimpleBeanPropertyFilter;
import com.fasterxml.jackson.databind.ser.impl.SimpleFilterProvider;
import org.springframework.beans.BeanUtils;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.json.MappingJacksonValue;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;

import javax.validation.Valid;
import java.net.URI;
import java.util.List;

@RestController()
@RequestMapping("/admin")
public class AdminUserController {
 	...중략
    
    @GetMapping(value = "/users/{id}", headers = "X-API-VERSION=1")
    public MappingJacksonValue retrieveUser1(@PathVariable int id){
        // 위 메소드 구현부와 동일하므로 생략
    }

    @GetMapping(value = "/users/{id}", headers = "X-API-VERSION=2")
    public MappingJacksonValue retrieveUser2(@PathVariable int id){
         // 위 메소드 구현부와 동일하므로 생략

    }
}

X-API-VERSION=1
X-API-VERSION=2

(4) Mime Type를 통한 버전 관리

package com.example.restful.user;

import com.fasterxml.jackson.databind.ser.FilterProvider;
import com.fasterxml.jackson.databind.ser.impl.SimpleBeanPropertyFilter;
import com.fasterxml.jackson.databind.ser.impl.SimpleFilterProvider;
import org.springframework.beans.BeanUtils;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.json.MappingJacksonValue;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;

import javax.validation.Valid;
import java.net.URI;
import java.util.List;

@RestController()
@RequestMapping("/admin")
public class AdminUserController {
 	...중략
    
	@GetMapping(value = "/users/{id}", produces = "application/vnd.company.appv1+json")
    public MappingJacksonValue retrieveUser1(@PathVariable int id){
        // 위 메소드 구현부와 동일하므로 생략
    }

	@GetMapping(value = "/users/{id}", produces = "application/vnd.company.appv2+json")
    public MappingJacksonValue retrieveUser2(@PathVariable int id){
         // 위 메소드 구현부와 동일하므로 생략

    }
}

application/vnd.company.appv1+json
application/vnd.company.appv2+json