1.HATEOAS 설정
라이브러리 추가
implementation 'org.springframework.boot:spring-boot-starter-hateoas' implementation 'com.fasterxml.jackson.core:jackson-databind' implementation 'org.springframework.security:spring-security-core' implementation 'org.springframework.security:spring-security-web' implementation 'org.springframework.security:spring-security-config'
build.gradle 전체예
plugins { id 'java' id 'org.springframework.boot' version '3.3.2' id 'io.spring.dependency-management' version '1.1.4' id 'org.jetbrains.kotlin.jvm' } group = 'net.macaronics' version = '0.0.1-SNAPSHOT' java { toolchain { languageVersion = JavaLanguageVersion.of(17) } } repositories { mavenCentral() } //Gradle에서 빌드하여 생성하는 jar 파일명 변경하기 bootJar { archivesBaseName = 'ROOT' archiveFileName = 'ROOT.jar' archiveVersion = "0.0.0" } dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6' implementation group: 'nz.net.ultraq.thymeleaf', name: 'thymeleaf-layout-dialect', version: '3.3.0' implementation group: 'org.modelmapper', name: 'modelmapper', version: '3.2.0' implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'org.springframework.boot:spring-boot-starter-hateoas' implementation 'com.fasterxml.jackson.core:jackson-databind' implementation 'org.springframework.security:spring-security-core' implementation 'org.springframework.security:spring-security-web' implementation 'org.springframework.security:spring-security-config' //jwt version: '0.12.4' implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5' runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5' implementation group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.h2database:h2' runtimeOnly 'org.mariadb.jdbc:mariadb-java-client' runtimeOnly 'com.mysql:mysql-connector-j' developmentOnly 'org.springframework.boot:spring-boot-devtools' developmentOnly group: 'com.github.gavlyukovskiy', name: 'p6spy-spring-boot-starter', version: '1.9.1' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' // ⭐ Spring boot 3.x이상에서 QueryDsl 패키지를 정의하는 방법 implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta" annotationProcessor "jakarta.annotation:jakarta.annotation-api" annotationProcessor "jakarta.persistence:jakarta.persistence-api" // com.google.guava implementation group: 'com.google.guava', name: 'guava', version: '33.0.0-jre' implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8" //Swagger UI 접속 관련 설정 implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0' } //-plain.jar 를 생성 금지 jar { enabled = false } tasks.named('test') { useJUnitPlatform() } def querydslSrcDir = 'src/main/generated' clean { delete file(querydslSrcDir) } tasks.withType(JavaCompile) { options.generatedSourceOutputDirectory = file(querydslSrcDir) options.compilerArgs.add("-parameters") } kotlin { jvmToolchain { languageVersion = JavaLanguageVersion.of(17) } } // 테스트 코드를 제외한 빌드 수행 또는 gradle build -x test //https://velog.io/@may_yun/빌드시-테스트-코드-제외하기 tasks.withType(Test) { enabled = false } // release 샘플 1 => 명령어 ./gradlew release // 다음 릴리즈가 성공하면 build/libs/macaronics-app-0.1.0.jar 파일이 생성된다. task release(type: Jar) { // ./gradlew build 를 먼저 실행 dependsOn build archiveBaseName = 'macaronics-app' archiveVersion = '0.1.0' duplicatesStrategy = DuplicatesStrategy.EXCLUDE from { configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) } } with jar println "릴리즈 성공.....!!!!" } //1.AWS 엘라스틱 빈스토어 배포 => ./gradlew release //$ eb create --database --elb-type application --instance-type t2.micro //2.환경설정 //$ eb setenv SPRING_PROFILES_ACTIVE=prod //3.프로젝트 클린및 빌드처리 //$ gradlew clean build //4.AWS 엘라스틱 빈스토어 재배포 //$ eb deploy //5. RDS 확인 //$ aws rds describe-db-instances --region ap-northeast-2 // 릴리즈 과정 /** //task release() { /* 3.프로젝트 클린및 빌드처리 ./gradlew build를 먼저 실행하라는 의미다. */ // dependsOn("build") // doLast { // def stdout = new ByteArrayOutputStream() // /* exec - 커맨드 라인 명령어; 파일시스템에서 실행하는 것과 같다. */ // exec { // /* 2.환경설정 $eb setenv SPRING_PROFILES_ACTIVE=prod */ // commandLine 'eb', 'setenv', 'SPRING_PROFILES_ACTIVE=prod' // standardOutput = stdout // } // /* 결과 출력을 위한 작업 */ // println "eb setenv SPRING_PROFILES_ACTIVE=prod : \n$stdout"; // exec { // /* 3.배포 $eb deploy */ // //commandLine 'eb', 'deploy' // //standardOutput = stdout // } // // println "eb deploy : \n$stdout"; // println "Release succeeded." // } //}
2. Hateos 코드 적용 예
1)ApiMemberController
package net.macaronics.controller.api.member; import net.macaronics.config.auth.dto.TokenDto; import net.macaronics.config.auth.jwt.JwtTokenProviderService; import net.macaronics.dto.ResponseDTO; import net.macaronics.dto.shop.MemberDTO; import net.macaronics.dto.shop.MemberFormDTO; import net.macaronics.entity.member.Member; import net.macaronics.service.member.MemberService; import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; import org.springframework.hateoas.EntityModel; import org.springframework.http.ResponseEntity; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.web.bind.annotation.*; import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo; import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn; @RestController @RequestMapping("/api/auth") @RequiredArgsConstructor @Log4j2 public class ApiMemberController { private final MemberService memberService; private final PasswordEncoder passwordEncoder; private final JwtTokenProviderService tokenProvider; /** * API 회원 가입 처리 * @param memberFormDto * @return */ @PostMapping("/signup") public ResponseEntity<?> registerUser(@RequestBody MemberFormDTO memberFormDto) { try { // 요청을 이용해 저장할 유저 만들기 memberFormDto.setPassword(passwordEncoder.encode(memberFormDto.getPassword())); Member member = Member.createMember(memberFormDto); // 서비스를 이용해 리포지터리에 유저 저장 MemberDTO memberDto = MemberDTO.of(memberService.saveMember(member)); // HATEOAS 적용 EntityModel<MemberDTO> resource = createHateoasResource(memberDto); return ResponseEntity.ok(ResponseDTO.builder().code(1).message("success").data(resource).build()); } catch (Exception e) { return ResponseEntity.badRequest().body(ResponseDTO.builder().code(-1).errorCode(e.getMessage()).build()); } } /** * 로그인 * @param memberFormDto * @return */ @PostMapping("/signin") public ResponseEntity<?> authenticate(@RequestBody MemberFormDTO memberFormDto) { Member memberEntity = memberService.getMemberUsername(memberFormDto.getUsername()); if (memberEntity != null && passwordEncoder.matches(memberFormDto.getPassword(), memberEntity.getPassword())) { log.info("3. signIn================>{}", memberFormDto.getUsername()); // 토큰 생성 final TokenDto tokenDto = tokenProvider.create(memberEntity); MemberDTO memberDto = MemberDTO.of(memberEntity); memberDto.setToken(tokenDto); // HATEOAS 적용 EntityModel<MemberDTO> resource = createHateoasResource(memberDto); return ResponseEntity.ok(ResponseDTO.builder().code(1).message("success").data(resource).build()); } else { return ResponseEntity.badRequest().body(ResponseDTO.builder().code(-1).message("아이디 또는 비밀번호가 일치하지 않습니다.").errorCode("not match").build()); } } /** * 갱신토큰 재발행 * @param refreshToken * @return */ @PostMapping("/reissue") public ResponseEntity<?> reissue(@RequestHeader(value = "refreshToken") String refreshToken) { log.info("갱신 토큰 발행 =======================>"); try { MemberDTO tokenDto = tokenProvider.reissue(refreshToken); EntityModel<MemberDTO> resource = EntityModel.of(tokenDto); resource.add(linkTo(methodOn(ApiMemberController.class).reissue(refreshToken)).withSelfRel()); return ResponseEntity.ok(ResponseDTO.builder().code(1).message("success").data(resource).build()); } catch (Exception e) { // 갱신토큰이 유효하지 않을 경우 code 값을 -1로 주고, 프론트에서 "refreshToken is invalid" 메시지 확인 후 로그아웃 처리한다. return ResponseEntity.badRequest().body(ResponseDTO.builder().code(-1).message("갱신 토큰이 유효하지 않습니다.").errorCode("INVALID_REFRESH_TOKEN").build()); } } /** * 로그 아웃 처리 * redis에서 저장된 memberId + refresh token 삭제 처리 한다. */ @PostMapping("/logout") public ResponseEntity<?> logout(@RequestHeader(value = "refreshToken") String refreshToken) { log.info("로그 아웃 처리 =======================>"); try { tokenProvider.logout(refreshToken); return ResponseEntity.ok(ResponseDTO.builder().code(1).message("success").build()); } catch (Exception e) { return ResponseEntity.ok(ResponseDTO.builder().code(-1).message("로그아웃 처리 오류").errorCode(e.getMessage()).build()); } } // 토큰으로 유저 정보 가져오기 @PostMapping("/memberInfo") public ResponseEntity<?> memberInfo(@RequestHeader(value = "accessToken") String accessToken) { log.info("1. 접근 토큰으로 회원정보 가져오기 =======================> {}", accessToken); try { MemberDTO memberDto = tokenProvider.getMember(accessToken); log.info("2. 접근 토큰으로 회원정보 가져오기 =======================> {}", memberDto.toString()); // HATEOAS 적용 EntityModel<MemberDTO> resource = createHateoasResource(memberDto); return ResponseEntity.ok(ResponseDTO.builder().code(1).message("success").data(resource).build()); } catch (Exception e) { return ResponseEntity.badRequest().body(ResponseDTO.builder().code(-1).message("갱신 토큰이 유효하지 않습니다.").errorCode("INVALID_REFRESH_TOKEN").build()); } } // HATEOAS 링크를 추가하는 유틸리티 메서드 private EntityModel<MemberDTO> createHateoasResource(MemberDTO memberDto) { EntityModel<MemberDTO> resource = EntityModel.of(memberDto); resource.add(linkTo(methodOn(ApiMemberController.class).registerUser(null)).withRel("signup")); resource.add(linkTo(methodOn(ApiMemberController.class).authenticate(null)).withRel("signin")); resource.add(linkTo(methodOn(ApiMemberController.class).reissue(null)).withRel("reissue")); resource.add(linkTo(methodOn(ApiMemberController.class).logout(null)).withRel("logout")); resource.add(linkTo(methodOn(ApiMemberController.class).memberInfo(null)).withRel("memberInfo")); return resource; } }
2)ApiTodoController
package net.macaronics.controller.api.todo; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import net.macaronics.config.auth.PrincipalDetails; import net.macaronics.dto.ResponseDTO; import net.macaronics.dto.todo.TodoDTO; import net.macaronics.entity.member.Member; import net.macaronics.entity.todo.TodoEntity; import net.macaronics.service.member.MemberService; import net.macaronics.service.todo.TodoService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import java.util.List; import java.util.Map; import java.util.stream.Collectors; import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo; import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn; @RestController @RequestMapping("/api/todo") @Slf4j @RequiredArgsConstructor @Tag(name = "Todo", description = "Todo 생성, 목록 출력, 검색, 수정, 삭제 기능 제공 API") public class ApiTodoController { private final TodoService todoService; private final MemberService memberService; /** * 1. Todo 생성 * * @param principalDetails 인증된 사용자 정보 * @param todoDTO 생성할 Todo 정보 * @return 생성된 Todo 목록 */ @Operation(summary = "Todo 생성", description = "Todo 를 생성하고 목록을 반환합니다.") @PostMapping public ResponseEntity<?> createTodo( @AuthenticationPrincipal PrincipalDetails principalDetails, @RequestBody TodoDTO todoDTO) { try { Member member = memberService.getMemberUsername(principalDetails.getUsername()); TodoEntity entity = TodoDTO.toEntity(todoDTO, member); Map<String, Object> responseData = createHateoasResources(todoService.create(entity)); return ResponseEntity.ok(ResponseDTO.builder().code(1).message("success").data(responseData).build()); } catch (Exception e) { return ResponseEntity.badRequest().body( ResponseDTO.<TodoDTO>builder().code(-1).message("Todo 생성 오류").errorCode(e.getMessage()).build()); } } /** * 2. Todo 목록 출력 * * @param principalDetails 인증된 사용자 정보 * @param page 페이지 번호 * @param size 페이지 크기 * @return 사용자별 Todo 목록 */ @Operation(summary = "Todo 목록 출력", description = "Todo 목록을 출력을 제공합니다.") @GetMapping public ResponseEntity<?> retrieveTodoList( @AuthenticationPrincipal PrincipalDetails principalDetails, @RequestParam(defaultValue = "0") Integer page, @RequestParam(defaultValue = "15") Integer size) { try { Map<String, Object> responseData = createHateoasResources(todoService.retrieve(principalDetails.getId(), page, size)); return ResponseEntity.ok(ResponseDTO.builder().code(1).message("success").data(responseData).build()); } catch (Exception e) { return ResponseEntity.badRequest().body( ResponseDTO.builder().code(-1).message("목록 출력 오류").errorCode(e.getMessage()).build()); } } /** * 3. Todo 수정하기 * * @param principalDetails 인증된 사용자 정보 * @param dto 수정할 Todo 정보 * @return 수정된 Todo 목록 */ @Operation(summary = "Todo 수정하기", description = "Todo를 업데이트합니다.") @PutMapping public ResponseEntity<?> updateTodo( @AuthenticationPrincipal PrincipalDetails principalDetails, @RequestBody TodoDTO dto) { try { TodoEntity entity = TodoDTO.toEntity(dto, memberService.getMemberUsername(principalDetails.getUsername())); Map<String, Object> responseData = createHateoasResources(todoService.update(entity)); return ResponseEntity.ok(ResponseDTO.builder().code(1).message("success").data(responseData).build()); } catch (Exception e) { return ResponseEntity.badRequest().body( ResponseDTO.builder().code(-1).message("Todo 수정 오류").errorCode(e.getMessage()).build()); } } /** * 4. Todo 삭제하기 * * @param principalDetails 인증된 사용자 정보 * @param dto 삭제할 Todo 정보 * @return 삭제 후 Todo 목록 */ @Operation(summary = "Todo 삭제하기", description = "Todo를 삭제합니다.") @DeleteMapping public ResponseEntity<?> deleteTodo( @AuthenticationPrincipal PrincipalDetails principalDetails, @RequestBody TodoDTO dto) { try { TodoEntity entity = TodoDTO.toEntity(dto, memberService.getMemberUsername(principalDetails.getUsername())); Map<String, Object> responseData = createHateoasResources(todoService.delete(entity)); return ResponseEntity.ok(ResponseDTO.builder().code(1).message("success").data(responseData).build()); } catch (Exception e) { return ResponseEntity.badRequest().body( ResponseDTO.<TodoDTO>builder().code(-1).message("Todo 삭제 오류").errorCode(e.getMessage()).build()); } } /** * HATEOAS 리소스를 생성하는 유틸리티 메서드 * * @param todoDTOMap TodoDTO 리스트와 총 카운트를 포함한 맵 * @return HATEOAS 링크가 추가된 Map */ private Map<String, Object> createHateoasResources(Map<String, Object> todoDTOMap) { // Map에서 List<TodoDTO>를 추출 @SuppressWarnings("unchecked") List<TodoDTO> todoDTOList = (List<TodoDTO>) todoDTOMap.get("todoList"); // HATEOAS 링크 생성 List<Map<String, Object>> todoResources = todoDTOList.stream() .map(todo -> Map.of( "todoId", todo.getTodoId(), "title", todo.getTitle(), "done", todo.isDone(), "_links", Map.of( "self", linkTo(methodOn(ApiTodoController.class).retrieveTodoList(null, 0, 15)).toUri().toString(), "update", linkTo(methodOn(ApiTodoController.class).updateTodo(null, todo)).toUri().toString(), "delete", linkTo(methodOn(ApiTodoController.class).deleteTodo(null, todo)).toUri().toString() ) )) .collect(Collectors.toList()); // 전체 링크 추가 return Map.of( "todoList", todoResources, "totalCount", todoDTOMap.get("todoTotalCount"), "_links", Map.of( "createTodo", linkTo(methodOn(ApiTodoController.class).createTodo(null, null)).toUri().toString(), "retrieveTodoList", linkTo(methodOn(ApiTodoController.class).retrieveTodoList(null, 0, 15)).toUri().toString() ) ); } }
3.REST API의 Full 적용 여부를 판단하려면
몇 가지 사항을 확인해야 합니다.
1. Self-descriptive Messages (자기 설명적 메시지)
- data 필드 내에 응답에 대한 정보가 잘 구조화되어 있으며, 각 필드는 그 자체로 의미를 전달합니다.
2. HATEOAS (Hypermedia as the Engine of Application State)
- data 필드 내에 links 배열이 있어, 클라이언트가 가능한 액션과 연관된 리소스를 탐색할 수 있습니다.
- 각 rel 속성은 해당 링크의 의미를 잘 전달합니다.
3. Stateless Communication (무상태 통신)
- JWT 토큰을 통해 무상태성을 유지하며, 각 요청은 필요한 인증 정보를 함께 전송합니다.
4. Client-Server Architecture (클라이언트-서버 아키텍처)
- 클라이언트는 서버에서 리소스를 요청하고, 서버는 클라이언트에게 리소스를 제공하는 전형적인 클라이언트-서버 구조를 따릅니다.
5. Cacheable (캐시 가능성)
- 이 예제에서는 캐싱 관련 정보는 포함되어 있지 않지만, 일반적으로 REST API는 적절한 HTTP 헤더를 통해 응답을 캐싱할 수 있습니다.
6. Layered System (계층화된 시스템)
- 서비스 계층, 비즈니스 로직 계층, 데이터 접근 계층이 분리되어 있습니다.
7. Code on Demand (선택적)
- 일반적으로 REST API에서는 자바스크립트 등의 코드를 클라이언트에 전송하여 실행할 수 있지만, 이는 선택 사항입니다. 이 예제에서는 사용되지 않습니다.
HATEOA 적용
1) ApiMemberController 회원가입 처리 반환 JSON
{ "code": 1, "message": "success", "errorCode": null, "data": { "id": null, "token": { "grantType": "Bearer ", "accessToken": "eyJhbGciOiJIUzUxMiJ9.eyJtZW1iZXJJZCI6Mywic3ViIjoiMyIsImlzcyI6Im1hY2Fyb25pY3MgYXBwIiwiaWF0IjoxNzIxNTczOTU3LCJleHAiOjE3MjE1NzM5ODd9.hvHX5iIA1lHDyBTE9cBWupUktMtcHu2f3zsiBwpKLlHi7BYZiqUVTChpAv2XlejeX2QYuWjNIQp5MzAfCro5lg", "refreshToken": "eyJhbGciOiJIUzUxMiJ9.eyJtZW1iZXJJZCI6Mywic3ViIjoiMyIsImlzcyI6Im1hY2Fyb25pY3MgYXBwIiwiaWF0IjoxNzIxNTczOTU3LCJleHAiOjE3MjE1NzQwNzd9.ePC3tWc2ukRj76TB0L4KwzqWmYq9zv80DJNxHrS1Z2bzbLXKSn2S6u7aB3wlP5qM2-1XUAa71Mjw15AxNqom0Q" }, "username": "test99", "email": "test99@gmail.com", "role": "USER", "links": [ { "rel": "signup", "href": "http://localhost:5000/api/auth/signup" }, { "rel": "signin", "href": "http://localhost:5000/api/auth/signin" }, { "rel": "reissue", "href": "http://localhost:5000/api/auth/reissue" }, { "rel": "logout", "href": "http://localhost:5000/api/auth/logout" }, { "rel": "memberInfo", "href": "http://localhost:5000/api/auth/memberInfo" } ] } }
2) ApiTodoController todo 목록반환 JSON
{ "code": 1, "message": "success", "errorCode": null, "data": { "totalCount": 11, "todoList": [ { "_links": { "delete": "http://localhost:5000/api/todo", "update": "http://localhost:5000/api/todo", "self": "http://localhost:5000/api/todo?page=0&size=15" }, "id": 38, "done": false, "title": "그래" }, { "_links": { "delete": "http://localhost:5000/api/todo", "update": "http://localhost:5000/api/todo", "self": "http://localhost:5000/api/todo?page=0&size=15" }, "id": 33, "done": false, "title": "234234" }, { "_links": { "delete": "http://localhost:5000/api/todo", "update": "http://localhost:5000/api/todo", "self": "http://localhost:5000/api/todo?page=0&size=15" }, "id": 32, "done": false, "title": "=====" }, { "_links": { "delete": "http://localhost:5000/api/todo", "update": "http://localhost:5000/api/todo", "self": "http://localhost:5000/api/todo?page=0&size=15" }, "id": 31, "done": false, "title": "dasd" }, { "_links": { "delete": "http://localhost:5000/api/todo", "update": "http://localhost:5000/api/todo", "self": "http://localhost:5000/api/todo?page=0&size=15" }, "id": 30, "done": false, "title": "dswad" }, { "_links": { "delete": "http://localhost:5000/api/todo", "update": "http://localhost:5000/api/todo", "self": "http://localhost:5000/api/todo?page=0&size=15" }, "id": 26, "done": false, "title": "1" }, { "_links": { "delete": "http://localhost:5000/api/todo", "update": "http://localhost:5000/api/todo", "self": "http://localhost:5000/api/todo?page=0&size=15" }, "id": 25, "done": false, "title": "0006666" }, { "_links": { "delete": "http://localhost:5000/api/todo", "update": "http://localhost:5000/api/todo", "self": "http://localhost:5000/api/todo?page=0&size=15" }, "id": 24, "done": false, "title": "00" }, { "_links": { "delete": "http://localhost:5000/api/todo", "update": "http://localhost:5000/api/todo", "self": "http://localhost:5000/api/todo?page=0&size=15" }, "id": 23, "done": false, "title": "11" }, { "_links": { "delete": "http://localhost:5000/api/todo", "update": "http://localhost:5000/api/todo", "self": "http://localhost:5000/api/todo?page=0&size=15" }, "id": 22, "done": false, "title": "77" }, { "_links": { "delete": "http://localhost:5000/api/todo", "update": "http://localhost:5000/api/todo", "self": "http://localhost:5000/api/todo?page=0&size=15" }, "id": 21, "done": false, "title": "88" } ], "_links": { "createTodo": "http://localhost:5000/api/todo", "retrieveTodoList": "http://localhost:5000/api/todo?page=0&size=15" } } }
JSON 데이터를 분석해보면 다음과 같은 내용이 포함되어 있습니다:
기본 응답 구조:
- code: 상태 코드 (1: 성공, -1: 실패 등)
- message: 상태 메시지
- errorCode: 에러 코드 (없을 때는 null)
- data: 실제 데이터
HATEOAS:
- data 필드 내부의 links 배열은 다양한 연관 리소스에 대한 링크를 포함합니다.
- 각 링크는 rel(관계)과 href(URL) 속성을 가지고 있습니다.
4. Spring Boot 프로젝트에 Swagger(OpenAPI)를 적용
1). Gradle 의존성 추가
build.gradle 파일에 다음 의존성을 추가합니다:
dependencies { implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.1.0' }
2) Swagger 설정 클래스 추가
package net.macaronics.config; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.info.Info; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class SwaggerConfig { @Bean public OpenAPI customOpenAPI() { return new OpenAPI() .info(new Info() .title("API Documentation") .version("1.0.0") .description("Spring Boot REST API documentation with Swagger")); } }
3) Controller에 Swagger 어노테이션 추가
package net.macaronics.controller.api.member; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import net.macaronics.config.auth.dto.TokenDto; import net.macaronics.config.auth.jwt.JwtTokenProviderService; import net.macaronics.dto.ResponseDTO; import net.macaronics.dto.shop.MemberDTO; import net.macaronics.dto.shop.MemberFormDTO; import net.macaronics.entity.member.Member; import net.macaronics.service.member.MemberService; import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; import org.springframework.hateoas.EntityModel; import org.springframework.http.ResponseEntity; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.web.bind.annotation.*; import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo; import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn; @RestController @RequestMapping("/api/auth") @RequiredArgsConstructor @Log4j2 @Tag(name = "회원 인증 API", description = "회원 가입, 로그인, 토큰 재발행, 로그아웃, 회원 정보 조회 API") public class ApiMemberController { private final MemberService memberService; private final PasswordEncoder passwordEncoder; private final JwtTokenProviderService tokenProvider; @Operation(summary = "회원 가입", description = "새로운 회원을 가입시킵니다.") @PostMapping("/signup") public ResponseEntity<?> registerUser(@RequestBody MemberFormDTO memberFormDto) { try { memberFormDto.setPassword(passwordEncoder.encode(memberFormDto.getPassword())); Member member = Member.createMember(memberFormDto); MemberDTO memberDto = MemberDTO.of(memberService.saveMember(member)); EntityModel<MemberDTO> resource = createHateoasResource(memberDto); return ResponseEntity.ok(ResponseDTO.builder().code(1).message("success").data(resource).build()); } catch (Exception e) { return ResponseEntity.badRequest().body(ResponseDTO.builder().code(-1).errorCode(e.getMessage()).build()); } } @Operation(summary = "로그인", description = "회원 로그인을 처리합니다.") @PostMapping("/signin") public ResponseEntity<?> authenticate(@RequestBody MemberFormDTO memberFormDto) { Member memberEntity = memberService.getMemberUsername(memberFormDto.getUsername()); if (memberEntity != null && passwordEncoder.matches(memberFormDto.getPassword(), memberEntity.getPassword())) { log.info("로그인 시도: {}", memberFormDto.getUsername()); final TokenDto tokenDto = tokenProvider.create(memberEntity); MemberDTO memberDto = MemberDTO.of(memberEntity); memberDto.setToken(tokenDto); EntityModel<MemberDTO> resource = createHateoasResource(memberDto); return ResponseEntity.ok(ResponseDTO.builder().code(1).message("success").data(resource).build()); } else { return ResponseEntity.badRequest().body(ResponseDTO.builder().code(-1).message("아이디 또는 비밀번호가 일치하지 않습니다.").errorCode("not match").build()); } } @Operation(summary = "갱신 토큰 재발행", description = "갱신 토큰을 재발행합니다.") @PostMapping("/reissue") public ResponseEntity<?> reissue(@RequestHeader(value = "refreshToken") String refreshToken) { log.info("갱신 토큰 발행"); try { MemberDTO tokenDto = tokenProvider.reissue(refreshToken); EntityModel<MemberDTO> resource = EntityModel.of(tokenDto); resource.add(linkTo(methodOn(ApiMemberController.class).reissue(refreshToken)).withSelfRel()); return ResponseEntity.ok(ResponseDTO.builder().code(1).message("success").data(resource).build()); } catch (Exception e) { return ResponseEntity.badRequest().body(ResponseDTO.builder().code(-1).message("갱신 토큰이 유효하지 않습니다.").errorCode("INVALID_REFRESH_TOKEN").build()); } } @Operation(summary = "로그아웃", description = "회원 로그아웃을 처리합니다.") @PostMapping("/logout") public ResponseEntity<?> logout(@RequestHeader(value = "refreshToken") String refreshToken) { log.info("로그아웃 처리"); try { tokenProvider.logout(refreshToken); return ResponseEntity.ok(ResponseDTO.builder().code(1).message("success").build()); } catch (Exception e) { return ResponseEntity.ok(ResponseDTO.builder().code(-1).message("로그아웃 처리 오류").errorCode(e.getMessage()).build()); } } @Operation(summary = "회원 정보 조회", description = "토큰을 이용하여 회원 정보를 조회합니다.") @PostMapping("/memberInfo") public ResponseEntity<?> memberInfo(@RequestHeader(value = "accessToken") String accessToken) { log.info("접근 토큰으로 회원정보 가져오기: {}", accessToken); try { MemberDTO memberDto = tokenProvider.getMember(accessToken); log.info("회원정보: {}", memberDto.toString()); EntityModel<MemberDTO> resource = createHateoasResource(memberDto); return ResponseEntity.ok(ResponseDTO.builder().code(1).message("success").data(resource).build()); } catch (Exception e) { return ResponseEntity.badRequest().body(ResponseDTO.builder().code(-1).message("갱신 토큰이 유효하지 않습니다.").errorCode("INVALID_REFRESH_TOKEN").build()); } } private EntityModel<MemberDTO> createHateoasResource(MemberDTO memberDto) { EntityModel<MemberDTO> resource = EntityModel.of(memberDto); resource.add(linkTo(methodOn(ApiMemberController.class).registerUser(null)).withRel("signup")); resource.add(linkTo(methodOn(ApiMemberController.class).authenticate(null)).withRel("signin")); resource.add(linkTo(methodOn(ApiMemberController.class).reissue(null)).withRel("reissue")); resource.add(linkTo(methodOn(ApiMemberController.class).logout(null)).withRel("logout")); resource.add(linkTo(methodOn(ApiMemberController.class).memberInfo(null)).withRel("memberInfo")); return resource; } }
댓글 ( 0)
댓글 남기기