From d0612b61e9e196063bbed05d3e3e962784ae9229 Mon Sep 17 00:00:00 2001 From: sohot8653 Date: Wed, 13 Aug 2025 17:02:40 +0900 Subject: [PATCH] =?UTF-8?q?[API=20=EC=9D=91=EB=8B=B5=20=ED=91=9C=EC=A4=80?= =?UTF-8?q?=ED=99=94]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 103 +++++++++++++++++- .../member/controller/MemberController.java | 18 +-- .../CustomAuthenticationFailureHandler.java | 4 +- .../exception/GlobalExceptionHandler.java | 25 ++--- .../global/security/JwtTokenFilter.java | 2 +- .../global/utils/ApiResponseCode.java | 65 ++++++----- 6 files changed, 158 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index 596ca76..3401e87 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,94 @@ src/main/java/com/bio/bio_backend/ └── BioBackendApplication.java ``` -### 2. API 문서화 (Swagger) +### 2. API 응답 표준화 (CustomApiResponse) + +#### 응답 구조 + +모든 API 응답은 `CustomApiResponse` 형태로 표준화되어 있습니다. + +```java +public class CustomApiResponse { + private int code; // HTTP 상태 코드 + private String message; // 응답 메시지 (ApiResponseCode enum 값) + private String description; // 응답 설명 + private T data; // 실제 데이터 (제네릭 타입) +} +``` + +#### 응답 예시 + +**성공 응답 (201 Created)** + +```json +{ + "code": 201, + "message": "COMMON_SUCCESS_CREATED", + "description": "Created successfully", + "data": { + "seq": 1, + "userId": "user123", + "name": "홍길동" + } +} +``` + +**실패 응답 (409 Conflict)** + +```json +{ + "code": 409, + "message": "USER_ID_DUPLICATE", + "description": "User ID already exists", + "data": null +} +``` + +#### 사용 방법 + +**Controller에서 응답 생성** + +```java +@PostMapping("/members") +public ResponseEntity> createMember(@RequestBody CreateMemberRequestDto requestDto) { + // ... 비즈니스 로직 ... + + // 성공 응답 + CustomApiResponse apiResponse = + CustomApiResponse.success(ApiResponseCode.COMMON_SUCCESS_CREATED, responseDto); + + return ResponseEntity.status(HttpStatus.CREATED).body(apiResponse); +} +``` + +**공용 응답 코드 사용** + +```java +// ApiResponseCode.java +public enum ApiResponseCode { + // 공용 성공 코드 + COMMON_SUCCESS_CREATED(HttpStatus.CREATED.value(), "Created successfully"), + COMMON_SUCCESS_UPDATED(HttpStatus.OK.value(), "Updated successfully"), + COMMON_SUCCESS_DELETED(HttpStatus.OK.value(), "Deleted successfully"), + COMMON_SUCCESS_RETRIEVED(HttpStatus.OK.value(), "Retrieved successfully"), + + // 공용 오류 코드 + COMMON_BAD_REQUEST(HttpStatus.BAD_REQUEST.value(), "Required request body is missing or Error"), + COMMON_UNAUTHORIZED(HttpStatus.UNAUTHORIZED.value(), "Unauthorized"), + COMMON_FORBIDDEN(HttpStatus.FORBIDDEN.value(), "Access is denied"), + COMMON_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "Resource is not found"), + COMMON_INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR.value(), "An error occurred on the server") +} +``` + +#### 핵심 규칙 + +- **모든 API 응답**: `CustomApiResponse`로 감싸서 반환 +- **공용 응답 코드**: `COMMON_` 접두사로 시작하는 범용 코드 사용 +- **일관된 구조**: `code`, `message`, `description`, `data` 필드로 표준화 +- **제네릭 활용**: ``를 통해 다양한 데이터 타입 지원 + +### 3. API 문서화 (Swagger) #### Swagger UI 접속 @@ -45,8 +132,12 @@ src/main/java/com/bio/bio_backend/ @Tag(name = "Member", description = "회원 관리 API") @Operation(summary = "회원 가입", description = "새로운 회원을 등록합니다.") @ApiResponses(value = { - @ApiResponse(responseCode = "201", description = "회원 가입 성공"), - @ApiResponse(responseCode = "400", description = "잘못된 요청 데이터") + @ApiResponse(responseCode = "201", description = "회원 가입 성공", + content = @Content(schema = @Schema(implementation = CustomApiResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 요청 데이터", + content = @Content(schema = @Schema(implementation = CustomApiResponse.class))), + @ApiResponse(responseCode = "409", description = "중복된 사용자 정보", + content = @Content(schema = @Schema(implementation = CustomApiResponse.class))) }) ``` @@ -55,7 +146,7 @@ src/main/java/com/bio/bio_backend/ - **SwaggerConfig.java**: OpenAPI 기본 정보 설정 - **application.properties**: Swagger UI 커스터마이징 -### 3. 트랜잭션 관리 +### 4. 트랜잭션 관리 #### 기본 설정 @@ -79,14 +170,14 @@ public class MemberServiceImpl { - **메서드별**: 데이터 수정 시에만 `@Transactional` 개별 적용 - **설정**: `spring.jpa.open-in-view=false` (성능 최적화) -### 4. 오류 등록 및 사용 +### 5. 오류 등록 및 사용 #### 오류 코드 등록 ```java // ApiResponseCode.java public enum ApiResponseCode { - USER_ID_DUPLICATE("400", "이미 존재하는 사용자 ID입니다."), + USER_ID_DUPLICATE(HttpStatus.CONFLICT.value(), "User ID already exists"), } ``` diff --git a/src/main/java/com/bio/bio_backend/domain/user/member/controller/MemberController.java b/src/main/java/com/bio/bio_backend/domain/user/member/controller/MemberController.java index f2efc0b..f37dbb4 100644 --- a/src/main/java/com/bio/bio_backend/domain/user/member/controller/MemberController.java +++ b/src/main/java/com/bio/bio_backend/domain/user/member/controller/MemberController.java @@ -1,6 +1,7 @@ package com.bio.bio_backend.domain.user.member.controller; import com.bio.bio_backend.global.dto.CustomApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; @@ -22,8 +23,9 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; +import com.bio.bio_backend.global.utils.ApiResponseCode; - +@Tag(name = "Member", description = "회원 관련 API") @RestController @RequiredArgsConstructor @Slf4j @@ -33,19 +35,21 @@ public class MemberController { private final MemberMapper memberMapper; private final BCryptPasswordEncoder bCryptPasswordEncoder; - @Operation(summary = "회원 가입", description = "새로운 회원을 등록합니다.", tags = {"Member"}) + @Operation(summary = "회원 가입", description = "새로운 회원을 등록합니다.") @ApiResponses({ - @ApiResponse(responseCode = "201", description = "회원 가입 성공", content = @Content(schema = @Schema(implementation = CreateMemberResponseDto.class))), - @ApiResponse(responseCode = "400", description = "잘못된 요청 데이터", content = @Content(schema = @Schema(implementation = CustomApiResponse.class))), - @ApiResponse(responseCode = "409", description = "중복된 사용자 정보", content = @Content(schema = @Schema(implementation = CustomApiResponse.class))) + @ApiResponse(responseCode = "201", description = "회원 가입 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 요청 데이터"), + @ApiResponse(responseCode = "409", description = "중복된 사용자 정보") }) @PostMapping("/members") - public ResponseEntity createMember(@RequestBody @Valid CreateMemberRequestDto requestDto) { + public ResponseEntity> createMember(@RequestBody @Valid CreateMemberRequestDto requestDto) { MemberDto member = memberMapper.toMemberDto(requestDto); MemberDto createdMember = memberService.createMember(member); CreateMemberResponseDto responseDto = memberMapper.toCreateMemberResponseDto(createdMember); - return ResponseEntity.status(HttpStatus.CREATED).body(responseDto); + CustomApiResponse apiResponse = CustomApiResponse.success(ApiResponseCode.COMMON_SUCCESS_CREATED, responseDto); + + return ResponseEntity.status(HttpStatus.CREATED).body(apiResponse); } // @PostMapping("/member/list") diff --git a/src/main/java/com/bio/bio_backend/global/exception/CustomAuthenticationFailureHandler.java b/src/main/java/com/bio/bio_backend/global/exception/CustomAuthenticationFailureHandler.java index 65b7563..0c52b84 100644 --- a/src/main/java/com/bio/bio_backend/global/exception/CustomAuthenticationFailureHandler.java +++ b/src/main/java/com/bio/bio_backend/global/exception/CustomAuthenticationFailureHandler.java @@ -40,10 +40,10 @@ public class CustomAuthenticationFailureHandler implements AuthenticationFailure if (exception instanceof UsernameNotFoundException) { apiResponse = CustomApiResponse.fail(ApiResponseCode.USER_NOT_FOUND, null); } else if (exception instanceof BadCredentialsException) { - apiResponse = CustomApiResponse.fail(ApiResponseCode.AUTHENTICATION_FAILED, null); + apiResponse = CustomApiResponse.fail(ApiResponseCode.COMMON_UNAUTHORIZED, null); } else { response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value()); - apiResponse = CustomApiResponse.fail(ApiResponseCode.INTERNAL_SERVER_ERROR, null); + apiResponse = CustomApiResponse.fail(ApiResponseCode.COMMON_INTERNAL_SERVER_ERROR, null); } String jsonResponse = objectMapper.writeValueAsString(apiResponse); diff --git a/src/main/java/com/bio/bio_backend/global/exception/GlobalExceptionHandler.java b/src/main/java/com/bio/bio_backend/global/exception/GlobalExceptionHandler.java index 349e4b1..2bba0c7 100644 --- a/src/main/java/com/bio/bio_backend/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/bio/bio_backend/global/exception/GlobalExceptionHandler.java @@ -2,6 +2,7 @@ package com.bio.bio_backend.global.exception; import java.util.Objects; +import lombok.Getter; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.web.HttpRequestMethodNotSupportedException; import org.springframework.web.bind.annotation.ExceptionHandler; @@ -17,7 +18,6 @@ import io.jsonwebtoken.MalformedJwtException; import io.jsonwebtoken.security.SignatureException; import com.bio.bio_backend.global.dto.CustomApiResponse; import com.bio.bio_backend.global.utils.ApiResponseCode; -import com.bio.bio_backend.domain.user.member.exception.UserDuplicateException; @RestControllerAdvice public class GlobalExceptionHandler { @@ -41,12 +41,12 @@ public class GlobalExceptionHandler { @ExceptionHandler(IllegalArgumentException.class) public CustomApiResponse handleIllegalArgumentException(IllegalArgumentException e){ - return CustomApiResponse.fail(ApiResponseCode.ARGUMENT_NOT_VALID, null); + return CustomApiResponse.fail(ApiResponseCode.COMMON_ARGUMENT_NOT_VALID, null); } @ExceptionHandler(IndexOutOfBoundsException.class) public CustomApiResponse handleIndexOutOfBoundsException(IndexOutOfBoundsException e){ - return CustomApiResponse.fail(ApiResponseCode.INDEX_OUT_OF_BOUND, null); + return CustomApiResponse.fail(ApiResponseCode.COMMON_INDEX_OUT_OF_BOUND, null); } @ExceptionHandler(SignatureException.class) @@ -71,7 +71,7 @@ public class GlobalExceptionHandler { @ExceptionHandler(JsonProcessingException.class) public CustomApiResponse handleExpiredJwtException(JsonProcessingException e) { - return CustomApiResponse.fail(ApiResponseCode.JSON_PROCESSING_EXCEPTION, null); + return CustomApiResponse.fail(ApiResponseCode.COMMON_JSON_PROCESSING_EXCEPTION, null); } @ExceptionHandler(ApiException.class) @@ -86,20 +86,9 @@ public class GlobalExceptionHandler { .map(error -> new ValidationError(error.getField(), error.getDefaultMessage())) .toList(); - return CustomApiResponse.fail(ApiResponseCode.ARGUMENT_NOT_VALID, errors); + return CustomApiResponse.fail(ApiResponseCode.COMMON_ARGUMENT_NOT_VALID, errors); } - + // 검증 오류 상세 정보를 위한 내부 클래스 - private static class ValidationError { - private final String field; - private final String message; - - public ValidationError(String field, String message) { - this.field = field; - this.message = message; - } - - public String getField() { return field; } - public String getMessage() { return message; } - } + private record ValidationError(String field, String message) { } } diff --git a/src/main/java/com/bio/bio_backend/global/security/JwtTokenFilter.java b/src/main/java/com/bio/bio_backend/global/security/JwtTokenFilter.java index ca48a7a..6f054f7 100644 --- a/src/main/java/com/bio/bio_backend/global/security/JwtTokenFilter.java +++ b/src/main/java/com/bio/bio_backend/global/security/JwtTokenFilter.java @@ -83,7 +83,7 @@ public class JwtTokenFilter extends OncePerRequestFilter { response.setHeader("Authorization", "Bearer " + newAccessToken); filterChain.doFilter(request, response); } else { - sendJsonResponse(response, CustomApiResponse.fail(ApiResponseCode.All_TOKEN_INVALID, null)); + sendJsonResponse(response, CustomApiResponse.fail(ApiResponseCode.ALL_TOKEN_INVALID, null)); } } catch (Exception e) { request.setAttribute("exception", e); diff --git a/src/main/java/com/bio/bio_backend/global/utils/ApiResponseCode.java b/src/main/java/com/bio/bio_backend/global/utils/ApiResponseCode.java index 0552807..beb0035 100644 --- a/src/main/java/com/bio/bio_backend/global/utils/ApiResponseCode.java +++ b/src/main/java/com/bio/bio_backend/global/utils/ApiResponseCode.java @@ -12,43 +12,58 @@ import org.springframework.http.HttpStatus; @AllArgsConstructor public enum ApiResponseCode { - // 200 OK - LOGIN_SUCCESSFUL(HttpStatus.OK.value(), "Login successful"), - LOGOUT_SUCCESSFUL(HttpStatus.OK.value(), "Logout successful"), - USER_INFO_CHANGE(HttpStatus.OK.value(), "User info update successful"), - USER_DELETE_SUCCESSFUL(HttpStatus.OK.value(), "User delete is successful"), - - // 409 Conflict - USER_ID_DUPLICATE(HttpStatus.CONFLICT.value(), "User ID already exists"), - /*공통 Code*/ + // 200 OK + COMMON_SUCCESS(HttpStatus.OK.value(), "요청 성공"), + COMMON_SUCCESS_CREATED(HttpStatus.CREATED.value(), "성공적으로 생성되었습니다"), + COMMON_SUCCESS_UPDATED(HttpStatus.OK.value(), "성공적으로 수정되었습니다"), + COMMON_SUCCESS_DELETED(HttpStatus.OK.value(), "성공적으로 삭제되었습니다"), + COMMON_SUCCESS_RETRIEVED(HttpStatus.OK.value(), "성공적으로 조회되었습니다"), + // 400 Bad Request - COMMON_BAD_REQUEST(HttpStatus.BAD_REQUEST.value(), "Required request body is missing or Error"), - COMMON_FORMAT_WRONG(HttpStatus.BAD_REQUEST.value(), "Request format is incorrect"), - ARGUMENT_NOT_VALID(HttpStatus.BAD_REQUEST.value(), "Argument is not valid"), + COMMON_BAD_REQUEST(HttpStatus.BAD_REQUEST.value(), "필수 요청 본문이 누락되었거나 오류가 발생했습니다"), + COMMON_FORMAT_WRONG(HttpStatus.BAD_REQUEST.value(), "요청 형식이 올바르지 않습니다"), + COMMON_ARGUMENT_NOT_VALID(HttpStatus.BAD_REQUEST.value(), "인자가 유효하지 않습니다"), // 401 Unauthorized - COMMON_UNAUTHORIZED(HttpStatus.UNAUTHORIZED.value(), "Unauthorized"), - USER_NOT_FOUND(HttpStatus.UNAUTHORIZED.value(), "User not found. Authentication failed"), - AUTHENTICATION_FAILED(HttpStatus.UNAUTHORIZED.value(), "Password is invalid"), - JWT_SIGNATURE_MISMATCH(HttpStatus.UNAUTHORIZED.value(), "JWT signature does not match. Authentication failed"), - JWT_TOKEN_NULL(HttpStatus.UNAUTHORIZED.value(), "JWT token is null"), - JWT_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED.value(), "Token is Expired"), - All_TOKEN_INVALID(HttpStatus.UNAUTHORIZED.value(), "Access and Refresh tokens are expired or invalid"), + COMMON_UNAUTHORIZED(HttpStatus.UNAUTHORIZED.value(), "인증에 실패했습니다"), // 403 Forbidden - COMMON_FORBIDDEN(HttpStatus.FORBIDDEN.value(), "Access is denied"), + COMMON_FORBIDDEN(HttpStatus.FORBIDDEN.value(), "접근이 거부되었습니다"), // 404 Not Found - COMMON_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "Resource is not found"), + COMMON_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "리소스를 찾을 수 없습니다"), // 405 Method Not Allowed - COMMON_METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED.value(), "Method not Allowed"), + COMMON_METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED.value(), "허용되지 않는 메소드입니다"), + + // 409 Conflict + COMMON_CONFLICT(HttpStatus.CONFLICT.value(), "충돌이 발생했습니다"), // 500 Internal Server Error - INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR.value(), "An error occurred on the server"), - INDEX_OUT_OF_BOUND(HttpStatus.INTERNAL_SERVER_ERROR.value(), "Index out of bounds for length"), - JSON_PROCESSING_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR.value(), "Check if it is a valid JSON format"); + COMMON_INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR.value(), "서버에서 오류가 발생했습니다"), + COMMON_INDEX_OUT_OF_BOUND(HttpStatus.INTERNAL_SERVER_ERROR.value(), "인덱스가 범위를 벗어났습니다"), + COMMON_JSON_PROCESSING_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR.value(), "유효한 JSON 형식인지 확인해주세요"), + + /*사용자 관련 Code*/ + // 200 OK + LOGIN_SUCCESSFUL(HttpStatus.OK.value(), "로그인에 성공했습니다"), + LOGOUT_SUCCESSFUL(HttpStatus.OK.value(), "로그아웃에 성공했습니다"), + USER_INFO_CHANGE(HttpStatus.OK.value(), "사용자 정보가 성공적으로 수정되었습니다"), + USER_DELETE_SUCCESSFUL(HttpStatus.OK.value(), "사용자가 성공적으로 삭제되었습니다"), + + // 409 Conflict + USER_ID_DUPLICATE(HttpStatus.CONFLICT.value(), "이미 존재하는 사용자 ID입니다"), + + // 401 Unauthorized + USER_NOT_FOUND(HttpStatus.UNAUTHORIZED.value(), "사용자를 찾을 수 없습니다. 인증에 실패했습니다"), + USER_PASSWORD_INVALID(HttpStatus.UNAUTHORIZED.value(), "비밀번호가 올바르지 않습니다"), + + // JWT 관련 + JWT_SIGNATURE_MISMATCH(HttpStatus.UNAUTHORIZED.value(), "JWT 서명이 일치하지 않습니다. 인증에 실패했습니다"), + JWT_TOKEN_NULL(HttpStatus.UNAUTHORIZED.value(), "JWT 토큰이 null입니다"), + JWT_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED.value(), "토큰이 만료되었습니다"), + ALL_TOKEN_INVALID(HttpStatus.UNAUTHORIZED.value(), "액세스 토큰과 리프레시 토큰이 모두 만료되었거나 유효하지 않습니다"); private final int statusCode; private final String description;