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)
댓글 남기기