★스프링부트 3.0 RESTful API 개발 기본설정 - 1, Swagger 설정 , RESTful API 설정, HATEOAS 설정,
HATEOAS 코드 단축 리팩토링, 에러처리, 다국어설정
소스 :
https://github.dev/braverokmc79/springboot-restful-web
REST API 문서의 중요성 및 과제
- REST API 컨슈머의 이해: REST API 컨슈머는 API가 제공하는 리소스, 작업, 요청 및 응답 구조를 이해해야 함.
- 문서의 문제점:
- 문서의 정확성과 최신 버전을 유지하는 것이 어려움.
- 기업에는 수많은 REST API가 있어 문서의 일관성을 보장하기가 어려움.
REST API 문서 관리 방법
- 수동 관리: 문서를 수동으로 작성 및 관리하지만, 코드와 동기화하는 것이 어려움.
- 코드 기반 생성: 코드를 기반으로 자동으로 문서를 생성하는 방법으로, 이번 수업에서 집중적으로 다룸.
Swagger와 OpenAPI
- Swagger와 OpenAPI의 역사
- 2011년: Swagger Specification과 Swagger Tools가 도입되어 REST API 문서화의 표준이 됨.
- 2016년: Swagger Specification을 기반으로 OpenAPI 사양이 만들어져 표준화됨.
- Swagger와 OpenAPI의 차이점 및 역할
- OpenAPI: REST API를 문서화하기 위한 개방형 표준 사양.
- Swagger Tools: Swagger UI 등을 포함하며 REST API를 시각화하고 상호작용하는 도구.
Swagger UI
- OpenAPI 사양을 시각적으로 보여주고 REST API와 상호작용할 수 있도록 도와주는 도구.
- REST API를 이해하고 사용하는 데 편리함을 제공.
1.Swagger 설정
springdoc-openapi-starter-webmvc-ui:2.6.0는 springdoc-openapi-ui:1.7.0와는 약간 다른 패키지입니다.
springdoc-openapi-starter-webmvc-ui는 Spring Boot 3.x 및 최신 SpringDoc 버전에 최적화된 스타터 패키지로, 설정이 더 간편하고 최신 기능을 포함하고 있습니다.
따라서 최신 프로젝트에서는 springdoc-openapi-starter-webmvc-ui를 사용하는 것이 좋습니다.
springdoc-openapi-starter-webmvc-ui:2.6.0를 사용하여 Swagger를 설정하고 ApiUserController에 적용하는 방법입니다.
1. build.gradle 또는 pom.xml에 Dependency 추가
이미 언급하신 대로 springdoc-openapi-starter-webmvc-ui:2.6.0를 사용하시면 됩니다.
build.gradle 예시
dependencies { implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0' }
pom.xml 예시
org.springdoc springdoc-openapi-starter-webmvc-ui 2.6.0
2. Swagger UI 기본 설정
springdoc-openapi-starter-webmvc-ui를 추가하면 기본 설정으로 Swagger가 자동으로 설정됩니다. 별도의 추가 설정 없이도 기본적으로 Swagger UI가 활성화됩니다.
3. API Controller에 Swagger 설명 추가
Swagger를 사용해 API의 동작을 상세하게 설명하려면 @Operation과 @Parameter 등의 어노테이션을 사용할 수 있습니다.
아래는 ApiUserController에 Swagger 어노테이션을 추가한 예시입니다.
package com.springboot.webapp.api.controller; import java.util.List; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import com.springboot.webapp.dto.ResponseDTO; import com.springboot.webapp.dto.UserResponse; import com.springboot.webapp.service.UserService; import com.springboot.webapp.utils.PageMaker; import lombok.RequiredArgsConstructor; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @Controller @RequestMapping("/api/users") @RequiredArgsConstructor public class ApiUserController { private final UserService userService; @Operation(summary = "전체 사용자 목록 조회", description = "모든 사용자의 목록을 페이지네이션하여 조회합니다.") @GetMapping public ResponseEntity userListAll( @Parameter(description = "페이지 정보", example = "0") PageMaker pageMaker ){ int pageInt = pageMaker.getPage() == null ? 0 : pageMaker.getPage(); PageRequest pageable = PageRequest.of(pageInt, 6); Page pageUserResponse = userService.userListAll(pageable); List userList = pageUserResponse.getContent(); return ResponseEntity.ok( ResponseDTO.builder() .code(1) .message("success") .data(userList) .build() ); } }
4. Swagger UI 확인
Spring Boot 애플리케이션을 실행한 후, 브라우저에서 다음 URL로 접속하여 Swagger UI를 확인할 수 있습니다
http://localhost:8080/swagger-ui/index.html
또는 최신 springdoc-openapi-starter-webmvc-ui 패키지는 기본적으로 다음 URL을 사용할 수도 있습니다:
http://localhost:8080/swagger-ui.html
5. Spring Configuration
Swagger UI가 작동하려면 Spring Boot 애플리케이션이 올바르게 설정되어 있어야 합니다.
애플리케이션 클래스에 @SpringBootApplication 애노테이션이 있는지 확인하고,
다음과 같이 Spring Security 설정이 되어 있다면 Swagger UI에 대한 경로를 허용해야 할 수 있습니다:
@EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http .authorizeRequests() .antMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-resources/**", "/webjars/**").permitAll() .anyRequest().authenticated(); } }
추가 설정 (선택 사항)
application.properties 또는 application.yml 파일에서 Swagger의 기본 경로를 변경하거나 기타 설정을 추가할 수 있습니다.
application.properties 예시
springdoc.api-docs.path=/api-docs springdoc.swagger-ui.path=/swagger-ui.html
application.yml 예시
springdoc: api-docs: path: /api-docs swagger-ui: path: /swagger-ui.html
요약
- Dependency 추가: springdoc-openapi-starter-webmvc-ui:2.6.0를 build.gradle 또는 pom.xml에 추가합니다.
- Swagger 설정: 추가된 Dependency로 인해 기본 설정이 자동으로 적용됩니다.
- Controller 수정: @Operation과 @Parameter 어노테이션을 사용하여 API 문서를 상세하게 작성합니다.
- Swagger UI 확인: 애플리케이션을 실행하고 브라우저에서 Swagger UI를 통해 API 문서를 확인합니다.
2. RESTful API란?
**REST(Representational State Transfer)**는 웹 아키텍처 스타일 중 하나로, 다음과 같은 주요 원칙을 따릅니다:
1)클라이언트-서버 구조: 클라이언트와 서버는 독립적으로 동작하며, 서로의 내부 구조를 알 필요가 없습니다.
2)태성(Stateless): 각 요청은 독립적이어야 하며, 서버는 클라이언트의 상태를 유지하지 않습니다.
3) 가능성(Cacheable): 응답은 캐시될 수 있어야 하며, 이를 통해 성능을 향상시킬 수 있습니다.
4)화 시스템: 클라이언트는 중간 서버(예: 로드 밸런서, 프록시)를 통해 서버에 접근할 수 있습니다.
5)된 인터페이스: 일관된 방식으로 리소스에 접근하고 조작할 수 있어야 합니다.
6) 온 디맨드(Code on Demand) (선택적): 서버가 클라이언트에 코드를 전달할 수 있습니다.
1. 클라이언트-서버 구조 (Client-Server Architecture)
클라이언트와 서버는 독립적으로 동작하며, 서로의 내부 구조를 알 필요가 없습니다. 클라이언트는 서버의 API를 통해 데이터를 요청하고, 서버는 요청에 따라 데이터를 제공하거나 작업을 수행합니다.
예시:
서버 측 (Spring Boot Controller):
@RestController @RequestMapping("/api/users") public class UserController { @GetMapping("/{id}") public ResponseEntity<User> getUser(@PathVariable Long id) { User user = userService.findById(id); if(user != null) { return ResponseEntity.ok(user); } else { return ResponseEntity.notFound().build(); } } }
클라이언트 측 (JavaScript Fetch 예제):
fetch('http://localhost:8080/api/users/1') .then(response => response.json()) .then(data => { console.log('사용자 정보:', data); }) .catch(error => console.error('오류:', error));
2. 무상태성 (Stateless)
각 요청은 독립적이어야 하며, 서버는 클라이언트의 상태를 유지하지 않습니다. 모든 필요한 정보는 요청에 포함되어야 합니다.
예시:
서버 측 (JWT를 이용한 인증):
@RestController @RequestMapping("/api/protected") public class ProtectedController { @GetMapping public ResponseEntity<String> getProtectedResource(@RequestHeader("Authorization") String token) { if(jwtService.validateToken(token)) { return ResponseEntity.ok("보호된 리소스에 접근 성공!"); } else { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("인증 실패"); } } }
클라이언트 측 (JWT 포함 요청):
const token = 'your-jwt-token'; fetch('http://localhost:8080/api/protected', { method: 'GET', headers: { 'Authorization': `Bearer ${token}` } }) .then(response => response.text()) .then(data => console.log(data)) .catch(error => console.error('오류:', error));
3. 캐시 가능성 (Cacheable)
응답은 캐시될 수 있어야 하며, 이를 통해 성능을 향상시킬 수 있습니다. 적절한 HTTP 헤더를 설정하여 클라이언트나 중간 캐시 서버가 응답을 저장할 수 있도록 합니다.
예시:
서버 측 (캐시 헤더 설정):
@RestController @RequestMapping("/api/users") public class UserController { @GetMapping("/{id}") public ResponseEntity<User> getUser(@PathVariable Long id) { User user = userService.findById(id); if(user != null) { return ResponseEntity.ok() .cacheControl(CacheControl.maxAge(60, TimeUnit.SECONDS)) .body(user); } else { return ResponseEntity.notFound().build(); } } }
4. 계층화 시스템 (Layered System)
클라이언트는 중간 서버(예: 로드 밸런서, 프록시)를 통해 서버에 접근할 수 있습니다. 이를 통해 보안, 로드 밸런싱, 캐싱 등을 구현할 수 있습니다.
예시:
스프링 부트 설정에서 프록시 사용: 스프링 부트 애플리케이션이 로드 밸런서를 통해 접근될 수 있도록 설정할 수 있습니다. 예를 들어, application.properties에 로드 밸런서 관련 설정을 추가할 수 있습니다.
server.port=8080 spring.cloud.loadbalancer.ribbon.enabled=true
클라이언트 측 (프록시 설정): 프록시 서버를 통해 API 요청을 보낼 수 있습니다.
예를 들어, Nginx를 사용하여 스프링 부트 애플리케이션으로 트래픽을 전달할 수 있습니다.
server { listen 80; server_name yourdomain.com; location /api/ { proxy_pass http://localhost:8080/api/; } }
5. 통일된 인터페이스 (Uniform Interface)
일관된 방식으로 리소스에 접근하고 조작할 수 있어야 합니다. 이를 위해 표준 HTTP 메서드(GET, POST, PUT, DELETE)를 사용하고, 일관된 URI 구조를 유지합니다.
예시:
서버 측 (일관된 API 설계):
@RestController @RequestMapping("/api/users") public class UserController { @GetMapping public ResponseEntity<List<User>> getAllUsers() { List<User> users = userService.findAll(); return ResponseEntity.ok(users); } @PostMapping public ResponseEntity<User> createUser(@RequestBody User user) { User created = userService.save(user); return ResponseEntity.status(HttpStatus.CREATED).body(created); } @PutMapping("/{id}") public ResponseEntity<User> updateUser(@PathVariable Long id, @RequestBody User user) { User updated = userService.update(id, user); return ResponseEntity.ok(updated); } @DeleteMapping("/{id}") public ResponseEntity<Void> deleteUser(@PathVariable Long id) { userService.delete(id); return ResponseEntity.noContent().build(); } }
클라이언트 측 (일관된 요청):
// GET 모든 사용자 fetch('http://localhost:8080/api/users') .then(response => response.json()) .then(data => console.log(data)); // POST 새로운 사용자 생성 fetch('http://localhost:8080/api/users', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({name: '홍길동', email: 'hong@example.com'}) }) .then(response => response.json()) .then(data => console.log(data)); // PUT 사용자 정보 수정 fetch('http://localhost:8080/api/users/1', { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({name: '홍길동 수정', email: 'hongnew@example.com'}) }) .then(response => response.json()) .then(data => console.log(data)); // DELETE 사용자 삭제 fetch('http://localhost:8080/api/users/1', { method: 'DELETE' }) .then(() => console.log('삭제 완료'));
6. 코드 온 디맨드 (Code on Demand) (선택적)
서버가 클라이언트에 코드를 전달할 수 있습니다. 이는 클라이언트가 서버로부터 실행 가능한 코드를 받아와서 실행할 수 있도록 하는 기능입니다. 일반적으로 RESTful API에서는 많이 사용되지 않지만, 특정 상황에서 유용할 수 있습니다.
예시:
서버 측 (JavaScript 코드 전달):
@RestController @RequestMapping("/api/code") public class CodeController { @GetMapping("/script") public ResponseEntity<String> getJavaScript() { String script = "console.log('서버에서 전달된 코드 실행');"; return ResponseEntity.ok() .contentType(MediaType.APPLICATION_JSON) .body(script); } }
클라이언트 측 (코드 실행):
fetch('http://localhost:8080/api/code/script') .then(response => response.text()) .then(script => { eval(script); // 전달된 코드를 실행 }) .catch(error => console.error('오류:', error));
주의: eval 함수는 보안상의 이유로 사용을 권장하지 않습니다. 실제 애플리케이션에서는 신뢰할 수 없는 코드를 실행하지 않도록 주의해야 합니다.
종합 예제
아래는 위의 원칙들을 종합적으로 적용한 간단한 스프링 부트 RESTful API 예제입니다.
UserController.java:
@RestController @RequestMapping("/api/users") public class UserController { private final UserService userService; // 생성자 주입 public UserController(UserService userService) { this.userService = userService; } // 1. 통일된 인터페이스 - GET 모든 사용자, 캐시 가능 @GetMapping public ResponseEntity<List<User>> getAllUsers() { List<User> users = userService.findAll(); return ResponseEntity.ok() .cacheControl(CacheControl.maxAge(60, TimeUnit.SECONDS)) .body(users); } // 2. 통일된 인터페이스 - POST 사용자 생성, 무상태성 @PostMapping public ResponseEntity<User> createUser(@RequestBody User user) { User created = userService.save(user); return ResponseEntity.status(HttpStatus.CREATED).body(created); } // 3. 통일된 인터페이스 - PUT 사용자 수정, 클라이언트-서버 구조 @PutMapping("/{id}") public ResponseEntity<User> updateUser(@PathVariable Long id, @RequestBody User user) { User updated = userService.update(id, user); return ResponseEntity.ok(updated); } // 4. 통일된 인터페이스 - DELETE 사용자 삭제 @DeleteMapping("/{id}") public ResponseEntity<Void> deleteUser(@PathVariable Long id) { userService.delete(id); return ResponseEntity.noContent().build(); } // 5. 코드 온 디맨드 (선택적) - JavaScript 코드 전달 @GetMapping("/script") public ResponseEntity<String> getJavaScript() { String script = "console.log('서버에서 전달된 코드 실행');"; return ResponseEntity.ok() .contentType(MediaType.APPLICATION_JSON) .body(script); } }
UserService.java:
@Service public class UserService { private final UserRepository userRepository; // 생성자 주입 public UserService(UserRepository userRepository) { this.userRepository = userRepository; } public List<User> findAll() { return userRepository.findAll(); } public User save(User user) { return userRepository.save(user); } public User update(Long id, User userDetails) { User user = userRepository.findById(id) .orElseThrow(() -> new ResourceNotFoundException("User not found")); user.setName(userDetails.getName()); user.setEmail(userDetails.getEmail()); return userRepository.save(user); } public void delete(Long id) { userRepository.deleteById(id); } }
User.java:
@Entity public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; private String email; // Getters and Setters }
3. HATEOAS란?
HATEOAS는 REST의 제약 조건 중 하나로, Hypermedia as the Engine of Application State의 약자입니다. 이는 클라이언트가 애플리케이션의 상태를 전환할 수 있도록 필요한 모든 정보를 하이퍼미디어 링크를 통해 제공해야 한다는 개념입니다. 다시 말해, 클라이언트는 서버로부터 받은 응답 내에 다음에 취할 수 있는 행동에 대한 링크를 포함하여, 애플리케이션의 상태를 탐색할 수 있도록 해야 합니다.
예시:
사용자가 특정 사용자 정보를 요청할 때, 응답에 해당 사용자의 세부 정보뿐만 아니라 그 사용자를 수정하거나 삭제할 수 있는 링크도 함께 제공됩니다.
{ "id": 1, "name": "홍길동", "email": "hong@example.com", "_links": { "self": { "href": "http://localhost:8080/api/users/1" }, "update": { "href": "http://localhost:8080/api/users/1" }, "delete": { "href": "http://localhost:8080/api/users/1" } } }
4.RESTful API 구현 시 HATEOAS 사용 여부
HATEOAS는 RESTful API의 완전한 구현을 위해 권장되는 원칙이지만, 필수는 아닙니다. 다음과 같은 상황을 고려해보세요:
1 .간단한 CRUD 애플리케이션:
- HATEOAS를 도입하면 API 응답에 추가적인 링크 정보가 포함되어 복잡성이 증가할 수 있습니다.
- 이 경우, HATEOAS 없이도 충분히 RESTful한 API를 구현할 수 있습니다.
2.클라이언트가 다양한 상태 전이를 필요로 하는 복잡한 애플리케이션:
- HATEOAS를 사용하면 클라이언트가 서버로부터 받은 링크를 통해 쉽게 상태를 전환할 수 있습니다.
- 복잡한 비즈니스 로직이 있는 경우, HATEOAS가 유용할 수 있습니다.
3.표준화된 API 문서 및 클라이언트 생성 도구 사용:
- HATEOAS를 사용하면 API 문서화가 더 명확해지고, 클라이언트 코드 생성 도구와의 호환성이 향상될 수 있습니다.
5.스프링 부트에서 HATEOAS 사용하기
스프링 부트에서는 Spring HATEOAS 라이브러리를 통해 HATEOAS를 쉽게 구현할 수 있습니다. Spring HATEOAS를 사용하면 하이퍼미디어 링크를 포함한 응답을 간편하게 생성할 수 있습니다.
1. 의존성 추가
pom.xml에 Spring HATEOAS 의존성을 추가합니다:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-hateoas</artifactId> </dependency>
그래들
implementation 'org.springframework.boot:spring-boot-starter-hateoas'
2. 리소스 모델 생성
HATEOAS를 적용할 리소스 모델을 생성합니다. 예를 들어, User 엔티티에 대한 리소스 모델을 만들어 보겠습니다.
EntityModel<UserResponse>
import org.springframework.hateoas.EntityModel; @Data @Builder @NoArgsConstructor @AllArgsConstructor public class UserResponse extends EntityModel<UserResponse> { private Long id; private String username; private LocalDate birthDate; private Role role; // User 엔티티를 UserResponse DTO로 변환하는 메소드 public static UserResponse of(User user) { // 필드 값 매핑 로직 (ModelMapper를 사용) } }
3. 컨트롤러 수정
컨트롤러에서 HATEOAS 링크를 추가하여 응답을 반환합니다..
userListAll 메소드에서 모든 사용자의 리스트를 반환할 때, 각 사용자에게 자기 자신의 링크를 추가합니다.
이를 통해 각 사용자에 대해 개별적인 리소스를 조회할 수 있는 링크를 제공하게 됩니다.
List<UserResponse> userList = pageUserResponse.getContent().stream().map(user -> { user.add(WebMvcLinkBuilder.linkTo(WebMvcLinkBuilder.methodOn(ApiUserController.class).getUserById(user.getId())).withSelfRel()); return user; }).collect(Collectors.toList());
여기서 WebMvcLinkBuilder.linkTo와 methodOn을 사용하여 getUserById 메소드로 링크를 생성하고 있습니다.
이는 HATEOAS에서 핵심적인 부분으로, 클라이언트가 응답을 받으면 각 사용자의 self 링크를 통해 개별 사용자 정보를 조회할 수 있습니다.
그리고 사용자 리스트 전체에 대한 링크도 추가합니다:
collectionModel.add(WebMvcLinkBuilder.linkTo(WebMvcLinkBuilder.methodOn(ApiUserController.class).userListAll(pageMaker)).withSelfRel());
이렇게 전체 사용자 목록에 대한 링크도 포함시켜, 클라이언트가 페이지네이션된 목록을 탐색할 수 있습니다
링크 추가 방식
WebMvcLinkBuilder를 사용하여 메소드와 연결된 링크를 쉽게 생성할 수 있습니다.
WebMvcLinkBuilder.linkTo(WebMvcLinkBuilder.methodOn(ApiUserController.class).getUserById(user.getId())).withSelfRel()
API 컨트롤러에서 HATEOAS 링크를 추가합니다. 사용자 목록을 가져오는 메소드와 개별 사용자 조회 메소드에서 링크를 추가하는 방식은 다음과 같습니다.
1) 전체 사용자 목록 조회 메소드 GET - http://localhost:8080/api/users
@Operation(summary = "전체 사용자 목록 조회", description = "모든 사용자의 목록을 페이지네이션하여 조회합니다.") @GetMapping public ResponseEntity<?> userListAll( @Parameter(description = "페이지 정보", example = "0") PageMaker pageMaker ){ int pageInt = pageMaker.getPage() == null ? 0 : pageMaker.getPage(); PageRequest pageable = PageRequest.of(pageInt, 6); Page<UserResponse> pageUserResponse = userService.userListAll(pageable); //1. 각 사용자에 대한 HATEOAS 링크 추가 List<UserResponse> userList = pageUserResponse.getContent().stream().map(user -> { user.add(WebMvcLinkBuilder.linkTo(WebMvcLinkBuilder.methodOn(ApiUserController.class).getUserById(user.getId())).withSelfRel()); return user; }).collect(Collectors.toList()); CollectionModel<UserResponse> collectionModel = CollectionModel.of(userList); //2. CollectionModel을 사용하여 사용자 목록과 링크 추가 collectionModel.add(WebMvcLinkBuilder.linkTo(WebMvcLinkBuilder.methodOn(ApiUserController.class).userListAll(pageMaker)).withSelfRel()); return ResponseEntity.ok().cacheControl(CacheControl.maxAge(60, TimeUnit.SECONDS)) .body(ResponseDTO.builder() .code(1) .message("success") .data(collectionModel) .build() ); }
2)개별 사용자 조회 메소드 - GET http://localhost:8080/api/users/{id}
@GetMapping("/{id}") public ResponseEntity<?> getUserById(@PathVariable Long id) { UserResponse user = userService.getUserById(id); // 개별 사용자에 대한 HATEOAS 링크 추가 user.add(WebMvcLinkBuilder.linkTo(WebMvcLinkBuilder.methodOn(ApiUserController.class).getUserById(id)).withSelfRel()); return ResponseEntity.ok(user); }
3. 사용자 생성 (회원가입) 메소드 POST - http://localhost:8080/api/users/
// 생성된 사용자의 URI 생성
URI location = WebMvcLinkBuilder.linkTo(WebMvcLinkBuilder.methodOn(ApiUserController.class).getUserById(savedUser.getId())).toUri();
created(location)
return ResponseEntity.created(location) .body(ResponseDTO.builder() .code(1) .message("success") .data(userResponse) .build() );
/** http://localhost:8080/api/users/ * 3. 사용자 생성 (회원가입) 메소드 * @param registerFormDTO * @param bindingResult * @return */ @PostMapping @Operation(summary = "사용자 생성", description = "새 사용자를 등록합니다.") public ResponseEntity<?> createUser(@Valid @RequestBody RegisterFormDTO registerFormDTO, BindingResult bindingResult) { // 입력 검증 오류 처리 if (bindingResult.hasErrors()) { return ResponseEntity.badRequest().body(bindingResult.getAllErrors()); } // 중복 사용자 확인 if (userService.findByUsername(registerFormDTO.getUsername()) != null) { // 이미 존재하는 아이디일 경우 bindingResult.rejectValue("username", "error.username", "이미 사용 중인 아이디입니다."); return ResponseEntity.badRequest().body(bindingResult.getAllErrors()); } // DTO를 User 객체로 변환 후 저장 User user = RegisterFormDTO.toCreateUser(registerFormDTO); // DTO를 User 객체로 변환 User savedUser = userService.saveUser(user); // 사용자 저장 // 생성된 사용자의 URI 생성 URI location = WebMvcLinkBuilder.linkTo(WebMvcLinkBuilder.methodOn(ApiUserController.class).getUserById(savedUser.getId())).toUri(); // HATEOAS 링크 추가 UserResponse userResponse = UserResponse.of(savedUser); userResponse.add(WebMvcLinkBuilder.linkTo(WebMvcLinkBuilder.methodOn(ApiUserController.class).getUserById(savedUser.getId())).withSelfRel()); return ResponseEntity.created(location) .body(ResponseDTO.builder() .code(1) .message("success") .data(userResponse) .build() ); }
4. 삭제 처리 DELETE
/** http://localhost:8080/api/users/{id} * 4. 사용자 삭제 * @param id * @return */ @DeleteMapping("/{id}") @Operation(summary = "사용자 삭제", description = "특정 사용자를 삭제합니다.") public ResponseEntity<?> deleteUser(@Parameter(description = "아이디", example = "1") @PathVariable Long id){ // 사용자 삭제 userService.deleteUser(id); //삭제 후 가능한 다음 행동들에 대한 HATEOAS 링크 추가 CollectionModel<UserResponse> collectionModel = CollectionModel.empty(); collectionModel.add(WebMvcLinkBuilder.linkTo(WebMvcLinkBuilder.methodOn(ApiUserController.class).userListAll(new PageMaker())).withRel("all-users")); ResponseDTO<?> response = ResponseDTO.builder() .code(1) .message("성공적으로삭제 처리 되었습니다.") .data(collectionModel) .build(); return ResponseEntity.ok() .body(response); }
전체
package net.macaronics.springboot.webapp.api.controller; import java.net.URI; import java.util.List; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.hateoas.CollectionModel; import org.springframework.hateoas.server.mvc.WebMvcLinkBuilder; import org.springframework.http.CacheControl; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.validation.BindingResult; import net.macaronics.springboot.webapp.dto.ResponseDTO; import net.macaronics.springboot.webapp.dto.user.RegisterFormDTO; import net.macaronics.springboot.webapp.dto.user.UserResponse; import net.macaronics.springboot.webapp.entity.User; import net.macaronics.springboot.webapp.service.UserService; import net.macaronics.springboot.webapp.utils.PageMaker; import lombok.RequiredArgsConstructor; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import jakarta.validation.Valid; @RestController @RequestMapping("/api/users") @RequiredArgsConstructor public class ApiUserController { private final UserService userService; /** http://localhost:8080/api/users * 1. 전체 사용자 목록 조회 * @param pageMaker * @return */ @Operation(summary = "전체 사용자 목록 조회", description = "모든 사용자의 목록을 페이지네이션하여 조회합니다.") @GetMapping public ResponseEntity<?> userListAll(@Parameter(description = "페이지 정보", example = "0") PageMaker pageMaker){ int pageInt = pageMaker.getPage() == null ? 0 : pageMaker.getPage(); PageRequest pageable = PageRequest.of(pageInt, 6); Page<UserResponse> pageUserResponse = userService.userListAll(pageable); // 1. 각 사용자에 대한 HATEOAS 링크 추가 List<UserResponse> userList = pageUserResponse.getContent().stream().map(user -> { user.add(WebMvcLinkBuilder.linkTo(WebMvcLinkBuilder.methodOn(ApiUserController.class).getUserById(user.getId())).withSelfRel()); return user; }).collect(Collectors.toList()); CollectionModel<UserResponse> collectionModel = CollectionModel.of(userList); // 2. CollectionModel을 사용하여 사용자 목록과 링크 추가 collectionModel.add(WebMvcLinkBuilder.linkTo(WebMvcLinkBuilder.methodOn(ApiUserController.class).userListAll(pageMaker)).withSelfRel()); return ResponseEntity.ok() .cacheControl(CacheControl.maxAge(60, TimeUnit.SECONDS)) .body(ResponseDTO.builder() .code(1) .message("success") .data(collectionModel) .build() ); } /** http://localhost:8080/api/users/{id} * 2. 개별 사용자 조회 메소드 * @param id * @return */ @GetMapping("/{id}") @Operation(summary = "개별 사용자 조회 메소드", description = "개별 사용자의 정보를 제공합니다.") public ResponseEntity<?> getUserById(@Parameter(description = "아이디", example = "1") @PathVariable Long id){ UserResponse user = userService.getUserById(id); user.add(WebMvcLinkBuilder.linkTo(WebMvcLinkBuilder.methodOn(ApiUserController.class).getUserById(id)).withSelfRel()); return ResponseEntity.ok(user); } /** http://localhost:8080/api/users/ * 3. 사용자 생성 (회원가입) 메소드 * @param registerFormDTO * @param bindingResult * @return */ @PostMapping @Operation(summary = "사용자 생성", description = "새 사용자를 등록합니다.") public ResponseEntity<?> createUser(@Valid @RequestBody RegisterFormDTO registerFormDTO, BindingResult bindingResult) { // 입력 검증 오류 처리 if (bindingResult.hasErrors()) { return ResponseEntity.badRequest().body(bindingResult.getAllErrors()); } // 중복 사용자 확인 if (userService.findByUsername(registerFormDTO.getUsername()) != null) { // 이미 존재하는 아이디일 경우 bindingResult.rejectValue("username", "error.username", "이미 사용 중인 아이디입니다."); return ResponseEntity.badRequest().body(bindingResult.getAllErrors()); } // DTO를 User 객체로 변환 후 저장 User user = RegisterFormDTO.toCreateUser(registerFormDTO); // DTO를 User 객체로 변환 User savedUser = userService.saveUser(user); // 사용자 저장 // 생성된 사용자의 URI 생성 URI location = WebMvcLinkBuilder.linkTo(WebMvcLinkBuilder.methodOn(ApiUserController.class).getUserById(savedUser.getId())).toUri(); // HATEOAS 링크 추가 UserResponse userResponse = UserResponse.of(savedUser); userResponse.add(WebMvcLinkBuilder.linkTo(WebMvcLinkBuilder.methodOn(ApiUserController.class).getUserById(savedUser.getId())).withSelfRel()); return ResponseEntity.created(location) .body(ResponseDTO.builder() .code(1) .message("success") .data(userResponse) .build() ); } }
4. SpringDoc 설정 확인
HATEOAS를 사용하면서 Swagger가 제대로 작동하지 않는 경우가 있습니다. 이럴 때는 SpringDoc과 Spring HATEOAS의 호환성을 확인하고, 필요한 설정을 추가해야 합니다.
그러나 일반적으로 Spring HATEOAS와 SpringDoc은 호환되므로 특별한 설정 없이도 동작해야 합니다.
요약
- HATEOAS는 RESTful API의 권장 원칙 중 하나이지만, 필수는 아닙니다. 프로젝트의 요구사항과 복잡성에 따라 선택적으로 사용할 수 있습니다.
- Spring HATEOAS를 사용하면 하이퍼미디어 링크를 손쉽게 추가할 수 있어, 클라이언트가 API의 상태 전이를 쉽게 탐색할 수 있도록 도와줍니다.
- 간단한 CRUD 애플리케이션에서는 HATEOAS를 사용하지 않아도 충분히 RESTful한 API를 구현할 수 있습니다.
- 복잡한 비즈니스 로직이나 클라이언트의 상태 전이가 중요한 경우 HATEOAS를 도입하는 것이 유리할 수 있습니다.
6. HATEOAS 코드 단축 리팩토링
HATEOAS 코드를 단축할 수 있는 몇 가지 방법을 소개합니다. 중복되는 코드와 반복적인 부분을 줄이고, 공통 메서드로 분리할 수 있습니다.
- HATEOAS 링크 추가 메서드 분리: 중복되는 링크 생성 코드를 하나의 메서드로 추출합니다.
- 사용자 링크 추가 메서드: UserResponse 클래스에 링크를 추가하는 메서드를 정의하여 코드 중복을 줄입니다.
/** HATEOAS 링크 추가 메서드 */ private UserResponse addUserLinks(UserResponse userResponse) { userResponse.add(WebMvcLinkBuilder.linkTo(WebMvcLinkBuilder.methodOn(ApiUserController.class).getUserById(userResponse.getId())).withSelfRel()); return userResponse; }
package net.macaronics.springboot.webapp.api.controller; import java.net.URI; import java.util.List; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.hateoas.CollectionModel; import org.springframework.hateoas.server.mvc.WebMvcLinkBuilder; import org.springframework.http.CacheControl; import org.springframework.http.ResponseEntity; import org.springframework.util.StringUtils; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.validation.BindingResult; import net.macaronics.springboot.webapp.dto.ResponseDTO; import net.macaronics.springboot.webapp.dto.user.RegisterFormDTO; import net.macaronics.springboot.webapp.dto.user.UserResponse; import net.macaronics.springboot.webapp.entity.User; import net.macaronics.springboot.webapp.exception.NotFoundException; import net.macaronics.springboot.webapp.service.UserService; import net.macaronics.springboot.webapp.utils.PageMaker; import lombok.RequiredArgsConstructor; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import jakarta.validation.Valid; @RestController @RequestMapping("/api/users") @RequiredArgsConstructor public class ApiUserController { private final UserService userService; /** HATEOAS 링크 추가 메서드 */ private UserResponse addUserLinks(UserResponse userResponse) { userResponse.add(WebMvcLinkBuilder.linkTo(WebMvcLinkBuilder.methodOn(ApiUserController.class).getUserById(userResponse.getId())).withSelfRel()); return userResponse; } /** http://localhost:8080/api/users * 1. 전체 사용자 목록 조회 * @param pageMaker * @return */ @Operation(summary = "전체 사용자 목록 조회", description = "모든 사용자의 목록을 페이지네이션하여 조회합니다.") @GetMapping public ResponseEntity<?> userListAll(@Parameter(description = "페이지 정보", example = "0") PageMaker pageMaker){ int pageInt = pageMaker.getPage() == null ? 0 : pageMaker.getPage(); PageRequest pageable = PageRequest.of(pageInt, 6); Page<UserResponse> pageUserResponse = userService.userListAll(pageable); // 1. 각 사용자에 대한 HATEOAS 링크 추가 List<UserResponse> userList = pageUserResponse.getContent().stream().map(this::addUserLinks).collect(Collectors.toList()); CollectionModel<UserResponse> collectionModel = CollectionModel.of(userList); // 2. CollectionModel을 사용하여 사용자 목록과 링크 추가 collectionModel.add(WebMvcLinkBuilder.linkTo(WebMvcLinkBuilder.methodOn(ApiUserController.class).userListAll(pageMaker)).withSelfRel()); return ResponseEntity.ok() .cacheControl(CacheControl.maxAge(60, TimeUnit.SECONDS)) .body(ResponseDTO.builder() .code(1) .message("success") .data(collectionModel) .build() ); } /** http://localhost:8080/api/users/{id} * 2. 개별 사용자 조회 메소드 * @param id * @return */ @GetMapping("/{id}") @Operation(summary = "개별 사용자 조회 메소드", description = "개별 사용자의 정보를 제공합니다.") public ResponseEntity<?> getUserById(@Parameter(description = "아이디", example = "1") @PathVariable Long id){ UserResponse user = addUserLinks(userService.getUserById(id)); return ResponseEntity.ok(user); } /** http://localhost:8080/api/users/ * 3. 사용자 생성 (회원가입) 메소드 * @param registerFormDTO * @param bindingResult * @return * @throws MethodArgumentNotValidException */ @PostMapping @Operation(summary = "사용자 생성", description = "새 사용자를 등록합니다.") public ResponseEntity<?> createUser(@Valid @RequestBody RegisterFormDTO registerFormDTO, BindingResult bindingResult) throws MethodArgumentNotValidException { // 입력 검증 오류 처리 if (bindingResult.hasErrors()) { throw new MethodArgumentNotValidException(null, bindingResult); } // 중복 사용자 확인 if (userService.findByUsername(registerFormDTO.getUsername()) != null) { // 이미 존재하는 아이디일 경우 bindingResult.rejectValue("username", "error.username", "이미 사용 중인 아이디입니다."); throw new MethodArgumentNotValidException(null, bindingResult); } // DTO를 User 객체로 변환 후 저장 User user = RegisterFormDTO.toCreateUser(registerFormDTO); // DTO를 User 객체로 변환 User savedUser = userService.saveUser(user); // 사용자 저장 // 생성된 사용자의 URI 생성 URI location = WebMvcLinkBuilder.linkTo(WebMvcLinkBuilder.methodOn(ApiUserController.class).getUserById(savedUser.getId())).toUri(); UserResponse userResponse = addUserLinks(UserResponse.of(savedUser)); return ResponseEntity.created(location) .body(ResponseDTO.builder() .code(1) .message("success") .data(userResponse) .build() ); } /** http://localhost:8080/api/users/{id} * 4. 사용자 삭제 * @param id * @return */ @DeleteMapping("/{id}") @Operation(summary = "사용자 삭제", description = "특정 사용자를 삭제합니다.") public ResponseEntity<?> deleteUser(@Parameter(description = "아이디", example = "1") @PathVariable Long id){ // 사용자 삭제 userService.deleteUser(id); //삭제 후 가능한 다음 행동들에 대한 HATEOAS 링크 추가 CollectionModel<UserResponse> collectionModel = CollectionModel.empty(); collectionModel.add(WebMvcLinkBuilder.linkTo(WebMvcLinkBuilder.methodOn(ApiUserController.class).userListAll(new PageMaker())).withRel("all-users")); ResponseDTO<?> response = ResponseDTO.builder() .code(1) .message("성공적으로삭제 처리 되었습니다.") .data(collectionModel) .build(); return ResponseEntity.ok() .body(response); } }
7.에러 처리 설정
1)ErrorDetails
package net.macaronics.springboot.webapp.exception; import java.time.LocalDateTime; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @Data @AllArgsConstructor @NoArgsConstructor public class ErrorDetails { private LocalDateTime timestamp; private String message; private String details; }
2) NotFoundException
package net.macaronics.springboot.webapp.exception; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.ResponseStatus; @ResponseStatus(value = HttpStatus.NOT_FOUND, reason = "찾을 수 없습니다.") public class NotFoundException extends RuntimeException{ private static final long serialVersionUID = -2997057852151179119L; public NotFoundException(String message) { super(message); } }
3) 글로벌 예외 처리 클래스 생성하기
@ControllerAdvice와 @ExceptionHandler를 사용하여 글로벌 예외 처리를 구현하면, 모든 컨트롤러에서 발생하는 예외를 일관되게 처리할 수 있습니다.
GlobalExceptionHandler 클래스
package net.macaronics.springboot.webapp.exception; import java.time.LocalDateTime; import java.util.List; import java.util.stream.Collectors; import org.springframework.context.support.DefaultMessageSourceResolvable; 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.context.request.WebRequest; import lombok.extern.slf4j.Slf4j; import net.macaronics.springboot.webapp.dto.ResponseDTO; @ControllerAdvice @Slf4j public class GlobalExceptionHandler { /** * 모든 예외 처리 * @param ex * @param request * @return */ @ExceptionHandler(Exception.class) public final ResponseEntity<?> handleAllExceptions(Exception ex, WebRequest request){ log.error("Unhandled exception occurred", ex); ErrorDetails errorDetails = new ErrorDetails(LocalDateTime.now(), ex.getMessage(), request.getDescription(false)); ResponseDTO<?> response = ResponseDTO.builder() .code(-1) .message("Internal Server Error") .data(errorDetails) .errorCode("INTERNAL_SERVER_ERROR") .build(); return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR); } /** * 공통 404 예외 처리: NotFoundException * @param ex * @param request * @return */ @ExceptionHandler(NotFoundException.class) public final ResponseEntity<?> handleNotFoundException(NotFoundException ex, WebRequest request){ log.warn("Resource not found exception: {}", ex.getMessage()); ErrorDetails errorDetails = new ErrorDetails(LocalDateTime.now(), ex.getMessage(), request.getDescription(false)); ResponseDTO<?> response = ResponseDTO.builder() .code(-1) .message("Resource Not Found") .data(errorDetails) .errorCode("NOT_FOUND") .build(); return new ResponseEntity<>(response, HttpStatus.NOT_FOUND); } /** * bindingResult.hasErrors() 에러시 반환 처리한다 * 유효성 체크 에러 처리 * @param ex * @param request * @return */ @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity<?> handleValidationExceptions(MethodArgumentNotValidException ex, WebRequest request){ List<String> errors = ex.getBindingResult() .getAllErrors() .stream() .map(DefaultMessageSourceResolvable::getDefaultMessage) .collect(Collectors.toList()); ErrorDetails errorDetails = new ErrorDetails(LocalDateTime.now(), "Validation Failed", request.getDescription(false)); log.warn("Validation failed: {}", errorDetails.getMessage()); ResponseDTO<?> response = ResponseDTO.builder() .code(-1) .message(errors.get(0)) .data(errorDetails) .errorCode("VALIDATION_ERROR") .build(); return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST); } // 기타 특정 예외 핸들러 추가 가능 }
사용예
1) UserService
package net.macaronics.springboot.webapp.service; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import net.macaronics.springboot.webapp.dto.user.UserResponse; import net.macaronics.springboot.webapp.entity.User; import net.macaronics.springboot.webapp.exception.NotFoundException; import net.macaronics.springboot.webapp.repository.UserRepository; @Service public class UserService { @Autowired private UserRepository userRepository; // User 정보를 관리하는 Repository @Autowired private PasswordEncoder passwordEncoder; // 비밀번호를 암호화하기 위한 PasswordEncoder public UserResponse getUserById(Long id) { User user= userRepository.findById(id).orElseThrow(()->new NotFoundException(id+" 을 찾을 수 없습니다.")); return UserResponse.of(user); } }
사용자 검증 오류처리
// 입력 검증 오류 처리 if (bindingResult.hasErrors()) { throw new MethodArgumentNotValidException(null, bindingResult); } // 중복 사용자 확인 if (userService.findByUsername(registerFormDTO.getUsername()) != null) { // 이미 존재하는 아이디일 경우 bindingResult.rejectValue("username", "error.username", "이미 사용 중인 아이디입니다."); throw new MethodArgumentNotValidException(null, bindingResult); }
8.다국어 설정
1. application.properties 설정
먼저 application.properties 파일에 다국어 메시지 파일의 경로와 인코딩을 설정합니다.
# 메시지 파일 경로 및 인코딩 설정 spring.messages.basename=i18n/messages spring.messages.encoding=UTF-8 spring.messages.cache-duration=3600 # 1시간 캐시
2. 메시지 파일 생성
src/main/resources/i18n/ 디렉토리에 메시지 파일을 생성합니다.
- 기본 메시지 파일: messages.properties
- 한국어 메시지 파일: messages_ko.properties
messages.properties
good.morning.message=Good morning!
messages_ko.properties
good.morning.message=좋은 아침입니다!
3. MessageSourceConfig 클래스 설정 (application.properties 적용이 안될경우)
Spring Boot는 자동으로 MessageSource를 설정해 주지만, 필요한 경우 직접 설정할 수 있습니다.
package net.macaronics.springboot.webapp.config; import org.springframework.context.MessageSource; import org.springframework.context.annotation.Bean; import org.springframework.context.support.ReloadableResourceBundleMessageSource; import org.springframework.stereotype.Component; @Component public class MessageSourceConfig { @Bean public MessageSource messageSource() { ReloadableResourceBundleMessageSource messageSource = new ReloadableResourceBundleMessageSource(); messageSource.setBasename("classpath:i18n/messages"); messageSource.setDefaultEncoding("UTF-8"); messageSource.setCacheSeconds(3600); // 캐시를 1시간으로 설정 return messageSource; } }
4. 컨트롤러 설정
package net.macaronics.springboot.webapp.api.controller; import java.util.Locale; import org.springframework.context.MessageSource; import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @RestController @RequestMapping("/api/test") @RequiredArgsConstructor @Slf4j public class ApiHelloWorldController { private final MessageSource messageSource; @GetMapping public String index() { return "Hello Test"; } /** * 다국어 테스트 * @return */ // http://localhost:8080/api/test/multi-lang @GetMapping(path = "/multi-lang") public String multiLingualTest() { Locale locale = LocaleContextHolder.getLocale(); log.info("locale: " + locale); // locale이 ko_KR이면 한국어 메시지가 출력 return messageSource.getMessage("good.morning.message", null, "다국어 기본 설정값입니다!", locale); } }
api 테스트
정리
- application.properties에서 메시지 파일 경로와 인코딩 설정
- i18n 폴더에 다국어 메시지 파일 생성
- MessageSourceConfig 설정 (필요한 경우)
- MessageSource를 사용하여 다국어 메시지 출력
이렇게 하면 http://localhost:8080/api/test/multi-lang로 요청 시 Accept-Language 헤더 값에 따라 적절한 언어 메시지가 출력됩니다.
댓글 ( 0)
댓글 남기기