이번주 학습한 REST API 방식을 활용해서 기존의 프로젝트를 REST API 형식으로 변환하는 절차에 대해 개념정리를 하고자 한다.
변환하려는 대상은 점프 투 스프링부트 2-3장에서 구현하는 질의응답 게시판이다.
우선 기존에 프로젝트는 Question, Answer, siteUser 이렇게 3개의 패키지로 구성되어있다.
REST API 아키텍쳐 스타일에 따라 하나씩 변경하는 절차를 정리해보면 다음과 같다.
기존 스프링부트 기반 컨트롤러는 @Controller 어노테이션을 사용한다.
HTML 매핑주소를 리턴하고, 기존 입력값을 받아오기 위해 Model 클래스를 사용한다.
@Controller
public class QuestionController {
@GetMapping("/question/detail/{id}")
public String detail(Model model, @PathVariable("id") Integer id) {
model.addAttribute("question", questionService.getQuestion(id));
return "question_detail";
}
}
REST API의 컨트롤러는 @RestController 어노테이션을 사용한다.
기존과 달리 HTTP 상태코드를 리턴한다.
// REST API 컨트롤러로 변경
@RestController // @Controller + @ResponseBody
RequestMapping("/api")
public class QuestionApiController {
@GetMapping("/question/{id}")
public ResponseEntity<QuestionDto> getQuestion(@PathVariable("id") Integer id) {
return ResponseEntity.ok(questionService.getQuestionDto(id));
}
}
기존 프로젝트에서는 Entity를 이용해 데이터 교환이 이루어졌지만 REST API에선 DTO가 대신 외부와의 데이터 교환을 담당한다.
@Getter
@Builder
public class QuestionDto {
private Integer id;
private String subject;
private String content;
private LocalDateTime createDate;
// Entity -> DTO 변환 메서드
public static QuestionDto from(Question question) {
return QuestionDto.builder()
.id(question.getId())
.subject(question.getSubject())
.content(question.getContent())
.createDate(question.getCreateDate())
.build();
}
}
DTO를 사용하면서 얻는 이점은 다음과 같다.
public class UserDto {
// 1. 민감 정보 제외
private String username; // 포함
// private String password; // 제외
// 2. 응답 필드명 변경
@JsonProperty("user_email")
private String email;
// 3. 데이터 가공
@JsonFormat(pattern = "yyyy-MM-dd")
private LocalDateTime createDate;
// 4. 필요한 데이터만 선택
private List<String> roleNames; // Role 엔티티 대신 이름만 전송
}
컨트롤러에서의 작업이 성공/실패되는 경우의 작업을 표준화해줄 필요가 있다.
모든 작업에서 전역적으로 HTTP 상태코드를 전달하기 위해서 Global한 Response를 만들어줄 필요가 있다.
@Getter
@AllArgsConstructor
public class ApiResponse<T> {
private boolean success; // 성공/실패 여부
private T data; // 실제 데이터 (질문, 답변 등)
private String message; // 응답 메시지
private String error; // 에러 메시지
// 성공 응답 생성
public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>(true, data, "성공", null);
}
// 실패 응답 생성
public static <T> ApiResponse<T> error(String errorMessage) {
return new ApiResponse<>(false, null, null, errorMessage);
}
}
API에서 발생하는 Exception 역시 GlobalExceptionHandler를 만들어 관리한다.
주로 ExceptionHandler를 통해 예외를 관리하고, 특수한 예외가 발생하는 경우 try-catch문으로 따로 관리하기도 한다.
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ValidationException.class)
public ResponseEntity<ErrorResponse> handleValidationException(ValidationException e) {
ErrorResponse error = ErrorResponse.builder()
.status(HttpStatus.BAD_REQUEST.value())
.message(e.getMessage())
.timestamp(LocalDateTime.now())
.build();
return ResponseEntity.badRequest().body(error);
}
@ExceptionHandler(DuplicateEmailException.class)
public ResponseEntity<ErrorResponse> handleDuplicateEmailException(DuplicateEmailException e) {
ErrorResponse error = ErrorResponse.builder()
.status(HttpStatus.CONFLICT.value())
.message("이미 존재하는 이메일입니다.")
.timestamp(LocalDateTime.now())
.build();
return ResponseEntity.status(HttpStatus.CONFLICT).body(error);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleAllException(Exception e) {
ErrorResponse error = ErrorResponse.builder()
.status(HttpStatus.INTERNAL_SERVER_ERROR.value())
.message("서버 내부 오류가 발생했습니다.")
.timestamp(LocalDateTime.now())
.build();
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
}
}
API의 사용방법을 문서로 작성하는 작업을 해주어야 한다.
@Operation(summary = "사용자 조회", description = "ID로 사용자 정보를 조회합니다")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "조회 성공"),
@ApiResponse(responseCode = "404", description = "사용자 없음")
})
@GetMapping("/users/{id}")
public ResponseEntity<UserDto> getUser(
@Parameter(description = "사용자 ID") @PathVariable Long id
) {
// 메서드 구현
}
기존 프로젝트는 세션 기반 인증방식을 사용하는 경우 CSRF(Cross-Site Request Forgery)를 사용하게 된다.
하지만 REST API는 상태를 저장하지 않고, JWT(JSON Web Token)와 같은 토큰 기반 인증을 사용하기 때문에 CSRF 보호를 사용하지 않는다.
각 요청마다 인증 토큰이 헤더에 포함되기 때문에 토큰이 없다면 요청이 거부되어 CSRF 공격으로부터 안전하다.
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf().disable() // REST API에서는 CSRF 보호가 필요없음
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS) // JWT 사용시
.and()
.authorizeRequests()
.antMatchers("/api/**").authenticated()
.and()
.addFilterBefore(new JwtAuthenticationFilter(),
UsernamePasswordAuthenticationFilter.class);
}
}