diff --git a/.github/workflows/dev-cd.yaml b/.github/workflows/dev-cd.yaml index 13879c5..5b0e4da 100644 --- a/.github/workflows/dev-cd.yaml +++ b/.github/workflows/dev-cd.yaml @@ -74,7 +74,7 @@ jobs: --memory="700m" \ --memory-swap="1400m" \ --cpus="1.5" \ - --health-cmd="wget --no-verbose --tries=1 --spider http://localhost:8080/api/v1/actuator/health || exit 1" \ + --health-cmd="wget --no-verbose --tries=1 --spider http://localhost:8080/api/actuator/health || exit 1" \ --health-interval=30s \ --health-timeout=5s \ --health-retries=3 \ diff --git a/build.gradle.kts b/build.gradle.kts index d796b0a..e2dd69e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -24,6 +24,7 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.boot:spring-boot-starter-restclient") + implementation("org.springframework.boot:spring-boot-starter-validation") // implementation("org.springframework.boot:spring-boot-starter-security") // implementation("org.springframework.boot:spring-boot-starter-security-oauth2-client") implementation("org.springframework.boot:spring-boot-starter-webmvc") diff --git a/infra/Dockerfile b/infra/Dockerfile index 8ea4ea4..62c9146 100644 --- a/infra/Dockerfile +++ b/infra/Dockerfile @@ -34,9 +34,6 @@ USER spring:spring EXPOSE 8080 -HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:8080/actuator/health || exit 1 - ENV JAVA_OPTS="-XX:+UseContainerSupport \ -XX:MaxRAMPercentage=75.0 \ -XX:InitialRAMPercentage=50.0 \ diff --git a/infra/dev/Caddyfile b/infra/dev/Caddyfile index 209f0ce..2e3a4df 100644 --- a/infra/dev/Caddyfile +++ b/infra/dev/Caddyfile @@ -5,7 +5,7 @@ lokit.co.kr { } reverse_proxy localhost:8080 { - health_uri /api/v1/actuator/health + health_uri /api/actuator/health health_interval 10s health_timeout 5s diff --git a/src/main/kotlin/kr/co/lokit/api/common/dto/ApiResponse.kt b/src/main/kotlin/kr/co/lokit/api/common/dto/ApiResponse.kt new file mode 100644 index 0000000..2948dee --- /dev/null +++ b/src/main/kotlin/kr/co/lokit/api/common/dto/ApiResponse.kt @@ -0,0 +1,73 @@ +package kr.co.lokit.api.common.dto + +import jakarta.servlet.http.HttpServletRequest +import kr.co.lokit.api.common.exception.ErrorCode +import org.springframework.http.HttpStatus + +data class ApiResponse( + val code: Int, + val message: String, + val data: T, +) { + companion object { + data class ErrorDetail( + val errorCode: String, + val detail: String, + val instance: String, + val errors: Map? = null, + ) + + fun success( + code: Int, + data: T, + ): ApiResponse = + ApiResponse( + code = code, + message = "success", + data = data, + ) + + fun failure( + exception: Exception, + request: HttpServletRequest, + errorCode: ErrorCode, + errors: Map? = null, + ): ApiResponse { + val errorDetail = + ErrorDetail( + errorCode = errorCode.code, + detail = exception.message ?: errorCode.message, + instance = request.requestURI, + errors = errors, + ) + + return ApiResponse( + code = errorCode.status.value(), + message = errorCode.status.reasonPhrase, + data = errorDetail, + ) + } + + fun failure( + status: HttpStatus, + detail: String, + request: HttpServletRequest, + errorCode: String, + errors: Map? = null, + ): ApiResponse { + val errorDetail = + ErrorDetail( + errorCode = errorCode, + detail = detail, + instance = request.requestURI, + errors = errors, + ) + + return ApiResponse( + code = status.value(), + message = status.reasonPhrase, + data = errorDetail, + ) + } + } +} diff --git a/src/main/kotlin/kr/co/lokit/api/common/exception/BusinessException.kt b/src/main/kotlin/kr/co/lokit/api/common/exception/BusinessException.kt new file mode 100644 index 0000000..6a0387a --- /dev/null +++ b/src/main/kotlin/kr/co/lokit/api/common/exception/BusinessException.kt @@ -0,0 +1,37 @@ +package kr.co.lokit.api.common.exception + +sealed class BusinessException( + val errorCode: ErrorCode, + override val message: String = errorCode.message, + override val cause: Throwable? = null, +) : RuntimeException(message, cause) { + class InvalidInputException( + message: String = ErrorCode.INVALID_INPUT.message, + cause: Throwable? = null, + ) : BusinessException(ErrorCode.INVALID_INPUT, message, cause) + + class ResourceNotFoundException( + message: String = ErrorCode.RESOURCE_NOT_FOUND.message, + cause: Throwable? = null, + ) : BusinessException(ErrorCode.RESOURCE_NOT_FOUND, message, cause) + + class ResourceAlreadyExistsException( + message: String = ErrorCode.RESOURCE_ALREADY_EXISTS.message, + cause: Throwable? = null, + ) : BusinessException(ErrorCode.RESOURCE_ALREADY_EXISTS, message, cause) + + class UnauthorizedException( + message: String = ErrorCode.UNAUTHORIZED.message, + cause: Throwable? = null, + ) : BusinessException(ErrorCode.UNAUTHORIZED, message, cause) + + class ForbiddenException( + message: String = ErrorCode.FORBIDDEN.message, + cause: Throwable? = null, + ) : BusinessException(ErrorCode.FORBIDDEN, message, cause) + + class BusinessRuleViolationException( + message: String = ErrorCode.BUSINESS_RULE_VIOLATION.message, + cause: Throwable? = null, + ) : BusinessException(ErrorCode.BUSINESS_RULE_VIOLATION, message, cause) +} diff --git a/src/main/kotlin/kr/co/lokit/api/common/exception/ErrorCode.kt b/src/main/kotlin/kr/co/lokit/api/common/exception/ErrorCode.kt new file mode 100644 index 0000000..5251570 --- /dev/null +++ b/src/main/kotlin/kr/co/lokit/api/common/exception/ErrorCode.kt @@ -0,0 +1,29 @@ +package kr.co.lokit.api.common.exception + +import org.springframework.http.HttpStatus + +enum class ErrorCode( + val status: HttpStatus, + val code: String, + val message: String, +) { + // Common + INVALID_INPUT(HttpStatus.BAD_REQUEST, "COMMON_001", "잘못된 입력값입니다"), + INVALID_TYPE(HttpStatus.BAD_REQUEST, "COMMON_002", "잘못된 타입입니다"), + MISSING_PARAMETER(HttpStatus.BAD_REQUEST, "COMMON_003", "필수 파라미터가 누락되었습니다"), + METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "COMMON_001", "지원하지 않는 HTTP 메서드입니다"), + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON_001", "서버 내부 오류가 발생했습니다"), + + // Authentication & Authorization + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "AUTH_001", "인증이 필요합니다"), + INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH_002", "유효하지 않은 토큰입니다"), + EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH_003", "만료된 토큰입니다"), + FORBIDDEN(HttpStatus.FORBIDDEN, "AUTH_001", "접근 권한이 없습니다"), + + // Resource + RESOURCE_NOT_FOUND(HttpStatus.NOT_FOUND, "RESOURCE_001", "요청한 리소스를 찾을 수 없습니다"), + RESOURCE_ALREADY_EXISTS(HttpStatus.CONFLICT, "RESOURCE_001", "이미 존재하는 리소스입니다"), + + // Business + BUSINESS_RULE_VIOLATION(HttpStatus.BAD_REQUEST, "BUSINESS_001", "비즈니스 규칙 위반입니다"), +} diff --git a/src/main/kotlin/kr/co/lokit/api/config/advice/ApiResponseAdvice.kt b/src/main/kotlin/kr/co/lokit/api/config/advice/ApiResponseAdvice.kt new file mode 100644 index 0000000..7d7f07a --- /dev/null +++ b/src/main/kotlin/kr/co/lokit/api/config/advice/ApiResponseAdvice.kt @@ -0,0 +1,55 @@ +package kr.co.lokit.api.config.advice + +import kr.co.lokit.api.common.dto.ApiResponse +import org.springframework.core.MethodParameter +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ProblemDetail +import org.springframework.http.ResponseEntity +import org.springframework.http.converter.HttpMessageConverter +import org.springframework.http.server.ServerHttpRequest +import org.springframework.http.server.ServerHttpResponse +import org.springframework.http.server.ServletServerHttpResponse +import org.springframework.web.bind.annotation.RestControllerAdvice +import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice + +@RestControllerAdvice(basePackages = ["kr.co.lokit.api"]) +class ApiResponseAdvice : ResponseBodyAdvice { + companion object { + private val EXCLUDED_TYPES = + setOf( + ProblemDetail::class.java, + ApiResponse::class.java, + ResponseEntity::class.java, + String::class.java, + Void.TYPE, + Unit::class.java, + ) + } + + override fun supports( + returnType: MethodParameter, + converterType: Class>, + ): Boolean = returnType.parameterType !in EXCLUDED_TYPES + + override fun beforeBodyWrite( + body: Any?, + returnType: MethodParameter, + selectedContentType: MediaType, + selectedConverterType: Class>, + request: ServerHttpRequest, + response: ServerHttpResponse, + ): Any? { + val statusCode = + if (response is ServletServerHttpResponse) { + response.servletResponse.status + } else { + HttpStatus.OK.value() + } + + return ApiResponse.success( + code = statusCode, + data = body, + ) + } +} diff --git a/src/main/kotlin/kr/co/lokit/api/config/advice/ErrorControllerAdvice.kt b/src/main/kotlin/kr/co/lokit/api/config/advice/ErrorControllerAdvice.kt new file mode 100644 index 0000000..fdc5e58 --- /dev/null +++ b/src/main/kotlin/kr/co/lokit/api/config/advice/ErrorControllerAdvice.kt @@ -0,0 +1,155 @@ +package kr.co.lokit.api.config.advice + +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import kr.co.lokit.api.common.dto.ApiResponse +import kr.co.lokit.api.common.dto.ApiResponse.Companion.ErrorDetail +import kr.co.lokit.api.common.exception.BusinessException +import kr.co.lokit.api.common.exception.ErrorCode +import org.slf4j.LoggerFactory +import org.springframework.http.HttpStatus +import org.springframework.http.converter.HttpMessageNotReadableException +import org.springframework.validation.BindException +import org.springframework.web.HttpRequestMethodNotSupportedException +import org.springframework.web.bind.MethodArgumentNotValidException +import org.springframework.web.bind.MissingServletRequestParameterException +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestControllerAdvice +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException +import org.springframework.web.servlet.resource.NoResourceFoundException + +@RestControllerAdvice +class ErrorControllerAdvice { + private val log = LoggerFactory.getLogger(javaClass) + + @ExceptionHandler(BusinessException::class) + fun handleBusinessException( + ex: BusinessException, + request: HttpServletRequest, + response: HttpServletResponse, + ): ApiResponse { + response.status = ex.errorCode.status.value() + + return ApiResponse.failure( + exception = ex, + request = request, + errorCode = ex.errorCode, + ) + } + + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(MethodArgumentNotValidException::class) + fun handleMethodArgumentNotValidException( + ex: MethodArgumentNotValidException, + request: HttpServletRequest, + ): ApiResponse = + ApiResponse.failure( + status = HttpStatus.BAD_REQUEST, + detail = ErrorCode.INVALID_INPUT.message, + request = request, + errorCode = ErrorCode.INVALID_INPUT.code, + errors = + ex.bindingResult.fieldErrors.associate { + it.field to (it.defaultMessage ?: ex::class.java.name) + }, + ) + + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(BindException::class) + fun handleBindException( + ex: BindException, + request: HttpServletRequest, + ): ApiResponse { + val errors = + ex.bindingResult.fieldErrors.associate { + it.field to (it.defaultMessage ?: ex::class.java.name) + } + + return ApiResponse.failure( + status = HttpStatus.BAD_REQUEST, + detail = ErrorCode.INVALID_INPUT.message, + request = request, + errorCode = ErrorCode.INVALID_INPUT.code, + errors = errors, + ) + } + + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(MissingServletRequestParameterException::class) + fun handleMissingServletRequestParameterException( + ex: MissingServletRequestParameterException, + request: HttpServletRequest, + ): ApiResponse = + ApiResponse.failure( + status = HttpStatus.BAD_REQUEST, + detail = "${ex.parameterName} 파라미터가 필요합니다", + request = request, + errorCode = ErrorCode.MISSING_PARAMETER.code, + ) + + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(MethodArgumentTypeMismatchException::class) + fun handleMethodArgumentTypeMismatchException( + ex: MethodArgumentTypeMismatchException, + request: HttpServletRequest, + ): ApiResponse = + ApiResponse.failure( + status = HttpStatus.BAD_REQUEST, + detail = "${ex.name} 파라미터의 타입이 올바르지 않습니다", + request = request, + errorCode = ErrorCode.INVALID_TYPE.code, + ) + + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(HttpMessageNotReadableException::class) + fun handleHttpMessageNotReadableException( + ex: HttpMessageNotReadableException, + request: HttpServletRequest, + ): ApiResponse = + ApiResponse.failure( + status = HttpStatus.BAD_REQUEST, + detail = "요청 본문을 읽을 수 없습니다. JSON 형식을 확인해주세요", + request = request, + errorCode = ErrorCode.INVALID_INPUT.code, + ) + + @ResponseStatus(HttpStatus.METHOD_NOT_ALLOWED) + @ExceptionHandler(HttpRequestMethodNotSupportedException::class) + fun handleHttpRequestMethodNotSupportedException( + ex: HttpRequestMethodNotSupportedException, + request: HttpServletRequest, + ): ApiResponse = + ApiResponse.failure( + status = HttpStatus.METHOD_NOT_ALLOWED, + detail = "${ex.method} 메서드는 지원하지 않습니다", + request = request, + errorCode = ErrorCode.METHOD_NOT_ALLOWED.code, + ) + + @ResponseStatus(HttpStatus.NOT_FOUND) + @ExceptionHandler(NoResourceFoundException::class) + fun handleNoResourceFoundException( + ex: NoResourceFoundException, + request: HttpServletRequest, + ): ApiResponse = + ApiResponse.failure( + status = HttpStatus.NOT_FOUND, + detail = "요청한 리소스를 찾을 수 없습니다", + request = request, + errorCode = ErrorCode.RESOURCE_NOT_FOUND.code, + ) + + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + @ExceptionHandler(Exception::class) + fun handleException( + ex: Exception, + request: HttpServletRequest, + ): ApiResponse = + ApiResponse.failure( + status = HttpStatus.INTERNAL_SERVER_ERROR, + detail = ErrorCode.INTERNAL_SERVER_ERROR.message, + request = request, + errorCode = ErrorCode.INTERNAL_SERVER_ERROR.code, + ) +} diff --git a/src/main/kotlin/kr/co/lokit/api/config/web/WebMvcConfig.kt b/src/main/kotlin/kr/co/lokit/api/config/web/WebMvcConfig.kt new file mode 100644 index 0000000..5939735 --- /dev/null +++ b/src/main/kotlin/kr/co/lokit/api/config/web/WebMvcConfig.kt @@ -0,0 +1,15 @@ +package kr.co.lokit.api.config.web + +import org.springframework.context.annotation.Configuration +import org.springframework.web.servlet.config.annotation.ApiVersionConfigurer +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer + +@Configuration +class WebMvcConfig : WebMvcConfigurer { + override fun configureApiVersioning(configurer: ApiVersionConfigurer) { + configurer.apply { + useRequestHeader("X-API-VERSION") + setDefaultVersion("1.0") + } + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 65af616..15cf506 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -5,10 +5,11 @@ spring: active: ${SPRING_PROFILES_ACTIVE} jackson: time-zone: Asia/Seoul + default-property-inclusion: non_null server: servlet: - context-path: /api/v1 + context-path: /api logging: pattern: diff --git a/src/test/resources/appliction.yaml b/src/test/resources/appliction.yaml index 6d22b8b..4a11ff4 100644 --- a/src/test/resources/appliction.yaml +++ b/src/test/resources/appliction.yaml @@ -34,4 +34,4 @@ logging: server: servlet: - context-path: /api/v1 + context-path: /api