스프링

 

 

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 데이터를 분석해보면 다음과 같은 내용이 포함되어 있습니다:

  1. 기본 응답 구조:

    • 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;
    }
}

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

about author

PHRASE

Level 60  라이트

낙망은 청년의 죽음이요, 청년이 죽으면 민족이 죽는다. -안창호

댓글 ( 0)

댓글 남기기

작성