From 052c575c1ff82cf701e3cb910971269b18fc47ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=95=98=EB=AF=BC?= Date: Mon, 13 Jul 2026 20:36:51 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat=20::=20=EA=B3=B5=ED=86=B5=20API=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EB=B0=8F=20=EC=98=88=EC=99=B8=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../team/cklob/arena/common/ApiResponse.kt | 58 +++++++++++++ .../arena/common/CommonResponseAdvice.kt | 34 ++++++++ .../cklob/arena/common/ExpectedException.kt | 6 ++ .../arena/common/GlobalExceptionHandler.kt | 87 +++++++++++++++++++ .../team/cklob/arena/common/OpenApiConfig.kt | 31 +++++++ 5 files changed, 216 insertions(+) create mode 100644 src/main/kotlin/team/cklob/arena/common/ApiResponse.kt create mode 100644 src/main/kotlin/team/cklob/arena/common/CommonResponseAdvice.kt create mode 100644 src/main/kotlin/team/cklob/arena/common/ExpectedException.kt create mode 100644 src/main/kotlin/team/cklob/arena/common/GlobalExceptionHandler.kt create mode 100644 src/main/kotlin/team/cklob/arena/common/OpenApiConfig.kt diff --git a/src/main/kotlin/team/cklob/arena/common/ApiResponse.kt b/src/main/kotlin/team/cklob/arena/common/ApiResponse.kt new file mode 100644 index 0000000..ab94046 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/common/ApiResponse.kt @@ -0,0 +1,58 @@ +package team.cklob.arena.common + +import org.springframework.http.HttpStatus + +data class CommonApiResponse( + val code: String, + val message: String, + val data: T? = null, +) { + companion object { + fun success(data: T): CommonApiResponse = + CommonApiResponse( + code = ErrorCode.SUCCESS.name, + message = ErrorCode.SUCCESS.message, + data = data, + ) + + fun error( + errorCode: ErrorCode, + message: String = errorCode.message, + data: Any? = null, + ): CommonApiResponse = + CommonApiResponse( + code = errorCode.name, + message = message, + data = data, + ) + } +} + +enum class ErrorCode( + val status: HttpStatus, + val message: String, +) { + SUCCESS(HttpStatus.OK, "성공했습니다."), + INVALID_REQUEST(HttpStatus.BAD_REQUEST, "잘못된 요청입니다."), + MALFORMED_JSON(HttpStatus.BAD_REQUEST, "요청 본문 형식이 올바르지 않습니다."), + INVALID_TYPE_VALUE(HttpStatus.BAD_REQUEST, "요청 값의 형식이 올바르지 않습니다."), + METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "지원하지 않는 HTTP 메서드입니다."), + RESOURCE_NOT_FOUND(HttpStatus.NOT_FOUND, "요청한 리소스를 찾을 수 없습니다."), + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "인증이 필요합니다."), + INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 토큰입니다."), + EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "만료된 토큰입니다."), + FORBIDDEN(HttpStatus.FORBIDDEN, "접근 권한이 없습니다."), + INSUFFICIENT_BALANCE(HttpStatus.CONFLICT, "잔액이 부족합니다."), + CHALLENGE_ALREADY_ENDED(HttpStatus.CONFLICT, "이미 종료된 챌린지입니다."), + RECOMMENDATION_ALREADY_REACTED(HttpStatus.CONFLICT, "이미 반응한 추천입니다."), + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 오류가 발생했습니다."), +} + +data class FieldErrorDetail( + val field: String, + val reason: String, +) + +data class ValidationErrorData( + val fieldErrors: List, +) diff --git a/src/main/kotlin/team/cklob/arena/common/CommonResponseAdvice.kt b/src/main/kotlin/team/cklob/arena/common/CommonResponseAdvice.kt new file mode 100644 index 0000000..b705d49 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/common/CommonResponseAdvice.kt @@ -0,0 +1,34 @@ +package team.cklob.arena.common + +import org.springframework.core.MethodParameter +import org.springframework.http.MediaType +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.web.bind.annotation.RestControllerAdvice +import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice + +@RestControllerAdvice +class CommonResponseAdvice : ResponseBodyAdvice { + override fun supports( + returnType: MethodParameter, + converterType: Class>, + ): Boolean = + returnType.parameterType != String::class.java && + !ResponseEntity::class.java.isAssignableFrom(returnType.parameterType) + + override fun beforeBodyWrite( + body: Any?, + returnType: MethodParameter, + selectedContentType: MediaType, + selectedConverterType: Class>, + request: ServerHttpRequest, + response: ServerHttpResponse, + ): Any? = + if (body is CommonApiResponse<*>) { + body + } else { + CommonApiResponse.success(body) + } +} diff --git a/src/main/kotlin/team/cklob/arena/common/ExpectedException.kt b/src/main/kotlin/team/cklob/arena/common/ExpectedException.kt new file mode 100644 index 0000000..2c92830 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/common/ExpectedException.kt @@ -0,0 +1,6 @@ +package team.cklob.arena.common + +class ExpectedException( + val errorCode: ErrorCode, + messageOverride: String? = null, +) : RuntimeException(messageOverride ?: errorCode.message) diff --git a/src/main/kotlin/team/cklob/arena/common/GlobalExceptionHandler.kt b/src/main/kotlin/team/cklob/arena/common/GlobalExceptionHandler.kt new file mode 100644 index 0000000..3537530 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/common/GlobalExceptionHandler.kt @@ -0,0 +1,87 @@ +package team.cklob.arena.common + +import com.fasterxml.jackson.databind.exc.InvalidFormatException +import jakarta.validation.ConstraintViolationException +import org.slf4j.LoggerFactory +import org.springframework.http.ResponseEntity +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.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException +import org.springframework.web.servlet.NoHandlerFoundException +import org.springframework.web.servlet.resource.NoResourceFoundException + +@RestControllerAdvice +class GlobalExceptionHandler { + private val log = LoggerFactory.getLogger(javaClass) + + @ExceptionHandler(ExpectedException::class) + fun handleExpectedException(exception: ExpectedException): ResponseEntity> = + response(exception.errorCode, exception.message ?: exception.errorCode.message) + + @ExceptionHandler(MethodArgumentNotValidException::class, BindException::class) + fun handleValidationException(exception: Exception): ResponseEntity> { + val fieldErrors = + when (exception) { + is MethodArgumentNotValidException -> exception.bindingResult.fieldErrors + is BindException -> exception.bindingResult.fieldErrors + else -> emptyList() + }.map { FieldErrorDetail(field = it.field, reason = it.defaultMessage ?: "유효하지 않은 값입니다.") } + + return response( + errorCode = ErrorCode.INVALID_REQUEST, + data = ValidationErrorData(fieldErrors), + ) + } + + @ExceptionHandler(ConstraintViolationException::class) + fun handleConstraintViolation(exception: ConstraintViolationException): ResponseEntity> = + response( + errorCode = ErrorCode.INVALID_REQUEST, + data = + ValidationErrorData( + exception.constraintViolations.map { + FieldErrorDetail(field = it.propertyPath.toString(), reason = it.message) + }, + ), + ) + + @ExceptionHandler(HttpMessageNotReadableException::class) + fun handleUnreadableMessage(exception: HttpMessageNotReadableException): ResponseEntity> = + response( + if (exception.cause is InvalidFormatException) { + ErrorCode.INVALID_TYPE_VALUE + } else { + ErrorCode.MALFORMED_JSON + }, + ) + + @ExceptionHandler(MethodArgumentTypeMismatchException::class) + fun handleTypeMismatch(exception: MethodArgumentTypeMismatchException): ResponseEntity> = + response(ErrorCode.INVALID_TYPE_VALUE) + + @ExceptionHandler(HttpRequestMethodNotSupportedException::class) + fun handleMethodNotSupported(exception: HttpRequestMethodNotSupportedException): ResponseEntity> = + response(ErrorCode.METHOD_NOT_ALLOWED) + + @ExceptionHandler(NoHandlerFoundException::class, NoResourceFoundException::class) + fun handleNotFound(exception: Exception): ResponseEntity> = response(ErrorCode.RESOURCE_NOT_FOUND) + + @ExceptionHandler(Exception::class) + fun handleUnexpectedException(exception: Exception): ResponseEntity> { + log.error("Unhandled exception", exception) + return response(ErrorCode.INTERNAL_SERVER_ERROR) + } + + private fun response( + errorCode: ErrorCode, + message: String = errorCode.message, + data: Any? = null, + ): ResponseEntity> = + ResponseEntity + .status(errorCode.status) + .body(CommonApiResponse.error(errorCode, message, data)) +} diff --git a/src/main/kotlin/team/cklob/arena/common/OpenApiConfig.kt b/src/main/kotlin/team/cklob/arena/common/OpenApiConfig.kt new file mode 100644 index 0000000..38501fe --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/common/OpenApiConfig.kt @@ -0,0 +1,31 @@ +package team.cklob.arena.common + +import io.swagger.v3.oas.models.Components +import io.swagger.v3.oas.models.OpenAPI +import io.swagger.v3.oas.models.media.ObjectSchema +import io.swagger.v3.oas.models.media.StringSchema +import io.swagger.v3.oas.models.security.SecurityScheme +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class OpenApiConfig { + @Bean + fun openApi(): OpenAPI = + OpenAPI().components( + Components() + .addSecuritySchemes( + "bearerAuth", + SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT"), + ).addSchemas( + "CommonErrorResponse", + ObjectSchema() + .addProperty("code", StringSchema().example(ErrorCode.INVALID_REQUEST.name)) + .addProperty("message", StringSchema().example(ErrorCode.INVALID_REQUEST.message)) + .addProperty("data", ObjectSchema().nullable(true)), + ), + ) +} From d228a270f00fbd5dc87b7ba959d6112f416c9f2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=95=98=EB=AF=BC?= Date: Mon, 13 Jul 2026 20:38:37 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat=20::=20JWT=20=EB=B3=B4=EC=95=88=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../team/cklob/arena/ArenaApplication.kt | 2 + .../arena/security/JwtAuthenticationFilter.kt | 51 +++++++++ .../cklob/arena/security/JwtProperties.kt | 14 +++ .../cklob/arena/security/JwtTokenProvider.kt | 36 ++++++ .../cklob/arena/security/SecurityConfig.kt | 46 ++++++++ .../arena/security/SecurityErrorHandler.kt | 41 +++++++ src/main/resources/application.yaml | 5 + .../arena/security/SecurityIntegrationTest.kt | 105 ++++++++++++++++++ src/test/resources/application.yaml | 4 + 9 files changed, 304 insertions(+) create mode 100644 src/main/kotlin/team/cklob/arena/security/JwtAuthenticationFilter.kt create mode 100644 src/main/kotlin/team/cklob/arena/security/JwtProperties.kt create mode 100644 src/main/kotlin/team/cklob/arena/security/JwtTokenProvider.kt create mode 100644 src/main/kotlin/team/cklob/arena/security/SecurityConfig.kt create mode 100644 src/main/kotlin/team/cklob/arena/security/SecurityErrorHandler.kt create mode 100644 src/test/kotlin/team/cklob/arena/security/SecurityIntegrationTest.kt create mode 100644 src/test/resources/application.yaml diff --git a/src/main/kotlin/team/cklob/arena/ArenaApplication.kt b/src/main/kotlin/team/cklob/arena/ArenaApplication.kt index eb81925..94b7912 100644 --- a/src/main/kotlin/team/cklob/arena/ArenaApplication.kt +++ b/src/main/kotlin/team/cklob/arena/ArenaApplication.kt @@ -1,9 +1,11 @@ package team.cklob.arena import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.context.properties.ConfigurationPropertiesScan import org.springframework.boot.runApplication @SpringBootApplication +@ConfigurationPropertiesScan class ArenaApplication fun main(args: Array) { diff --git a/src/main/kotlin/team/cklob/arena/security/JwtAuthenticationFilter.kt b/src/main/kotlin/team/cklob/arena/security/JwtAuthenticationFilter.kt new file mode 100644 index 0000000..4bf5371 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/security/JwtAuthenticationFilter.kt @@ -0,0 +1,51 @@ +package team.cklob.arena.security + +import io.jsonwebtoken.ExpiredJwtException +import io.jsonwebtoken.JwtException +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.http.HttpHeaders +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.web.filter.OncePerRequestFilter +import team.cklob.arena.common.ErrorCode + +class JwtAuthenticationFilter( + private val jwtTokenProvider: JwtTokenProvider, + private val securityErrorHandler: SecurityErrorHandler, +) : OncePerRequestFilter() { + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain, + ) { + val authorization = request.getHeader(HttpHeaders.AUTHORIZATION) + if (authorization == null) { + filterChain.doFilter(request, response) + return + } + + if (!authorization.startsWith(BEARER_PREFIX) || authorization.length == BEARER_PREFIX.length) { + securityErrorHandler.write(response, ErrorCode.INVALID_TOKEN) + return + } + + try { + val userId = jwtTokenProvider.getUserId(authorization.removePrefix(BEARER_PREFIX)) + val authentication = UsernamePasswordAuthenticationToken(userId, null, emptyList()) + SecurityContextHolder.getContext().authentication = authentication + filterChain.doFilter(request, response) + } catch (exception: ExpiredJwtException) { + securityErrorHandler.write(response, ErrorCode.EXPIRED_TOKEN) + } catch (exception: JwtException) { + securityErrorHandler.write(response, ErrorCode.INVALID_TOKEN) + } catch (exception: IllegalArgumentException) { + securityErrorHandler.write(response, ErrorCode.INVALID_TOKEN) + } + } + + companion object { + private const val BEARER_PREFIX = "Bearer " + } +} diff --git a/src/main/kotlin/team/cklob/arena/security/JwtProperties.kt b/src/main/kotlin/team/cklob/arena/security/JwtProperties.kt new file mode 100644 index 0000000..61bbdc4 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/security/JwtProperties.kt @@ -0,0 +1,14 @@ +package team.cklob.arena.security + +import jakarta.validation.constraints.NotBlank +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.validation.annotation.Validated +import java.time.Duration + +@Validated +@ConfigurationProperties("security.jwt") +data class JwtProperties( + @field:NotBlank + val secret: String, + val accessTokenExpiration: Duration, +) diff --git a/src/main/kotlin/team/cklob/arena/security/JwtTokenProvider.kt b/src/main/kotlin/team/cklob/arena/security/JwtTokenProvider.kt new file mode 100644 index 0000000..bbeb13a --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/security/JwtTokenProvider.kt @@ -0,0 +1,36 @@ +package team.cklob.arena.security + +import io.jsonwebtoken.Claims +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.io.Decoders +import io.jsonwebtoken.security.Keys +import java.time.Instant +import java.util.Date +import javax.crypto.SecretKey + +class JwtTokenProvider( + private val jwtProperties: JwtProperties, +) { + fun createAccessToken(userId: Long): String { + val now = Instant.now() + return Jwts + .builder() + .subject(userId.toString()) + .issuedAt(Date.from(now)) + .expiration(Date.from(now.plus(jwtProperties.accessTokenExpiration))) + .signWith(signingKey()) + .compact() + } + + fun getUserId(token: String): Long = parseClaims(token).subject.toLong() + + private fun parseClaims(token: String): Claims = + Jwts + .parser() + .verifyWith(signingKey()) + .build() + .parseSignedClaims(token) + .payload + + private fun signingKey(): SecretKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(jwtProperties.secret)) +} diff --git a/src/main/kotlin/team/cklob/arena/security/SecurityConfig.kt b/src/main/kotlin/team/cklob/arena/security/SecurityConfig.kt new file mode 100644 index 0000000..94cafc6 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/security/SecurityConfig.kt @@ -0,0 +1,46 @@ +package team.cklob.arena.security + +import com.fasterxml.jackson.databind.ObjectMapper +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.config.http.SessionCreationPolicy +import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter + +@Configuration +@EnableWebSecurity +class SecurityConfig { + @Bean + fun jwtTokenProvider(jwtProperties: JwtProperties): JwtTokenProvider = JwtTokenProvider(jwtProperties) + + @Bean + fun securityErrorHandler(objectMapper: ObjectMapper): SecurityErrorHandler = SecurityErrorHandler(objectMapper) + + @Bean + fun securityFilterChain( + http: HttpSecurity, + jwtTokenProvider: JwtTokenProvider, + securityErrorHandler: SecurityErrorHandler, + ): SecurityFilterChain = + http + .csrf { it.disable() } + .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } + .exceptionHandling { + it.authenticationEntryPoint(securityErrorHandler) + it.accessDeniedHandler(securityErrorHandler) + }.authorizeHttpRequests { + it.requestMatchers( + "/auth/**", + "/swagger-ui/**", + "/v3/api-docs/**", + "/actuator/health", + "/error", + ).permitAll() + it.anyRequest().authenticated() + }.addFilterBefore( + JwtAuthenticationFilter(jwtTokenProvider, securityErrorHandler), + UsernamePasswordAuthenticationFilter::class.java, + ).build() +} diff --git a/src/main/kotlin/team/cklob/arena/security/SecurityErrorHandler.kt b/src/main/kotlin/team/cklob/arena/security/SecurityErrorHandler.kt new file mode 100644 index 0000000..bda5e43 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/security/SecurityErrorHandler.kt @@ -0,0 +1,41 @@ +package team.cklob.arena.security + +import com.fasterxml.jackson.databind.ObjectMapper +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.http.MediaType +import org.springframework.security.access.AccessDeniedException +import org.springframework.security.web.AuthenticationEntryPoint +import org.springframework.security.web.access.AccessDeniedHandler +import team.cklob.arena.common.CommonApiResponse +import team.cklob.arena.common.ErrorCode + +class SecurityErrorHandler( + private val objectMapper: ObjectMapper, +) : AuthenticationEntryPoint, AccessDeniedHandler { + override fun commence( + request: HttpServletRequest, + response: HttpServletResponse, + authException: org.springframework.security.core.AuthenticationException, + ) { + write(response, ErrorCode.UNAUTHORIZED) + } + + override fun handle( + request: HttpServletRequest, + response: HttpServletResponse, + accessDeniedException: AccessDeniedException, + ) { + write(response, ErrorCode.FORBIDDEN) + } + + fun write( + response: HttpServletResponse, + errorCode: ErrorCode, + ) { + response.status = errorCode.status.value() + response.contentType = MediaType.APPLICATION_JSON_VALUE + response.characterEncoding = Charsets.UTF_8.name() + objectMapper.writeValue(response.writer, CommonApiResponse.error(errorCode)) + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 43959e8..7c79fff 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,3 +1,8 @@ spring: application: name: arena + +security: + jwt: + secret: ${JWT_SECRET} + access-token-expiration: PT15M diff --git a/src/test/kotlin/team/cklob/arena/security/SecurityIntegrationTest.kt b/src/test/kotlin/team/cklob/arena/security/SecurityIntegrationTest.kt new file mode 100644 index 0000000..3db812d --- /dev/null +++ b/src/test/kotlin/team/cklob/arena/security/SecurityIntegrationTest.kt @@ -0,0 +1,105 @@ +package team.cklob.arena.security + +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.extensions.spring.SpringExtension +import jakarta.validation.Valid +import jakarta.validation.constraints.NotBlank +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.context.annotation.Import +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.get +import org.springframework.test.web.servlet.post +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@SpringBootTest +@AutoConfigureMockMvc +@Import(SecurityIntegrationTest.TestApi::class) +class SecurityIntegrationTest( + private val mockMvc: MockMvc, + private val jwtTokenProvider: JwtTokenProvider, +) : DescribeSpec({ + extension(SpringExtension) + + describe("공통 응답 및 보안 설정") { + it("공개 API 응답을 공통 형식으로 감싼다") { + mockMvc.get("/auth/test") { + accept = MediaType.APPLICATION_JSON + }.andExpect { + status { isOk() } + jsonPath("$.code") { value("SUCCESS") } + jsonPath("$.data.value") { value("public") } + } + } + + it("인증 없이 보호 API를 호출하면 공통 401 응답을 반환한다") { + mockMvc.get("/test/protected") { + accept = MediaType.APPLICATION_JSON + }.andExpect { + status { isUnauthorized() } + jsonPath("$.code") { value("UNAUTHORIZED") } + } + } + + it("유효한 Bearer JWT로 보호 API를 호출할 수 있다") { + mockMvc.get("/test/protected") { + header("Authorization", "Bearer ${jwtTokenProvider.createAccessToken(1L)}") + accept = MediaType.APPLICATION_JSON + }.andExpect { + status { isOk() } + jsonPath("$.code") { value("SUCCESS") } + jsonPath("$.data.userId") { value(1) } + } + } + + it("변조된 JWT는 공통 401 응답을 반환한다") { + mockMvc.get("/test/protected") { + header("Authorization", "Bearer invalid-token") + accept = MediaType.APPLICATION_JSON + }.andExpect { + status { isUnauthorized() } + jsonPath("$.code") { value("INVALID_TOKEN") } + } + } + + it("검증 오류를 필드 오류 목록으로 반환한다") { + mockMvc.post("/auth/test") { + contentType = MediaType.APPLICATION_JSON + content = """{"name":""}""" + }.andExpect { + status { isBadRequest() } + jsonPath("$.code") { value("INVALID_REQUEST") } + jsonPath("$.data.fieldErrors[0].field") { value("name") } + } + } + } + }) { + override fun extensions() = listOf(SpringExtension) + + @TestConfiguration + @RestController + @RequestMapping + class TestApi { + @GetMapping("/auth/test") + fun publicApi() = mapOf("value" to "public") + + @GetMapping("/test/protected") + fun protectedApi(authentication: org.springframework.security.core.Authentication) = mapOf("userId" to authentication.principal) + + @PostMapping("/auth/test") + fun validate( + @Valid @RequestBody request: TestRequest, + ) = mapOf("name" to request.name) + } + + data class TestRequest( + @field:NotBlank + val name: String, + ) +} diff --git a/src/test/resources/application.yaml b/src/test/resources/application.yaml new file mode 100644 index 0000000..699f972 --- /dev/null +++ b/src/test/resources/application.yaml @@ -0,0 +1,4 @@ +security: + jwt: + secret: MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY= + access-token-expiration: PT15M From 07933d79315ef8353fd9285270bb9b66072b58ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=95=98=EB=AF=BC?= Date: Mon, 13 Jul 2026 20:48:10 +0900 Subject: [PATCH 3/6] =?UTF-8?q?refactor=20::=20=EB=8F=84=EB=A9=94=EC=9D=B8?= =?UTF-8?q?=EB=B3=84=20=EC=98=A4=EB=A5=98=20=EC=BD=94=EB=93=9C=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../arena/challenge/ChallengeErrorCode.kt | 11 ++++++++ .../team/cklob/arena/common/ApiResponse.kt | 28 ++----------------- .../cklob/arena/common/CommonErrorCode.kt | 15 ++++++++++ .../team/cklob/arena/common/ErrorCode.kt | 10 +++++++ .../arena/common/GlobalExceptionHandler.kt | 18 ++++++------ .../team/cklob/arena/common/OpenApiConfig.kt | 4 +-- .../recommendation/RecommendationErrorCode.kt | 11 ++++++++ .../arena/security/JwtAuthenticationFilter.kt | 9 +++--- .../cklob/arena/security/SecurityErrorCode.kt | 14 ++++++++++ .../arena/security/SecurityErrorHandler.kt | 4 +-- .../cklob/arena/trading/TradingErrorCode.kt | 11 ++++++++ 11 files changed, 93 insertions(+), 42 deletions(-) create mode 100644 src/main/kotlin/team/cklob/arena/challenge/ChallengeErrorCode.kt create mode 100644 src/main/kotlin/team/cklob/arena/common/CommonErrorCode.kt create mode 100644 src/main/kotlin/team/cklob/arena/common/ErrorCode.kt create mode 100644 src/main/kotlin/team/cklob/arena/recommendation/RecommendationErrorCode.kt create mode 100644 src/main/kotlin/team/cklob/arena/security/SecurityErrorCode.kt create mode 100644 src/main/kotlin/team/cklob/arena/trading/TradingErrorCode.kt diff --git a/src/main/kotlin/team/cklob/arena/challenge/ChallengeErrorCode.kt b/src/main/kotlin/team/cklob/arena/challenge/ChallengeErrorCode.kt new file mode 100644 index 0000000..fafa35e --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/challenge/ChallengeErrorCode.kt @@ -0,0 +1,11 @@ +package team.cklob.arena.challenge + +import org.springframework.http.HttpStatus +import team.cklob.arena.common.ErrorCode + +enum class ChallengeErrorCode( + override val status: HttpStatus, + override val message: String, +) : ErrorCode { + CHALLENGE_ALREADY_ENDED(HttpStatus.CONFLICT, "이미 종료된 챌린지입니다."), +} diff --git a/src/main/kotlin/team/cklob/arena/common/ApiResponse.kt b/src/main/kotlin/team/cklob/arena/common/ApiResponse.kt index ab94046..1ec3226 100644 --- a/src/main/kotlin/team/cklob/arena/common/ApiResponse.kt +++ b/src/main/kotlin/team/cklob/arena/common/ApiResponse.kt @@ -1,7 +1,5 @@ package team.cklob.arena.common -import org.springframework.http.HttpStatus - data class CommonApiResponse( val code: String, val message: String, @@ -10,8 +8,8 @@ data class CommonApiResponse( companion object { fun success(data: T): CommonApiResponse = CommonApiResponse( - code = ErrorCode.SUCCESS.name, - message = ErrorCode.SUCCESS.message, + code = "SUCCESS", + message = "성공했습니다.", data = data, ) @@ -21,33 +19,13 @@ data class CommonApiResponse( data: Any? = null, ): CommonApiResponse = CommonApiResponse( - code = errorCode.name, + code = errorCode.code, message = message, data = data, ) } } -enum class ErrorCode( - val status: HttpStatus, - val message: String, -) { - SUCCESS(HttpStatus.OK, "성공했습니다."), - INVALID_REQUEST(HttpStatus.BAD_REQUEST, "잘못된 요청입니다."), - MALFORMED_JSON(HttpStatus.BAD_REQUEST, "요청 본문 형식이 올바르지 않습니다."), - INVALID_TYPE_VALUE(HttpStatus.BAD_REQUEST, "요청 값의 형식이 올바르지 않습니다."), - METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "지원하지 않는 HTTP 메서드입니다."), - RESOURCE_NOT_FOUND(HttpStatus.NOT_FOUND, "요청한 리소스를 찾을 수 없습니다."), - UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "인증이 필요합니다."), - INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 토큰입니다."), - EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "만료된 토큰입니다."), - FORBIDDEN(HttpStatus.FORBIDDEN, "접근 권한이 없습니다."), - INSUFFICIENT_BALANCE(HttpStatus.CONFLICT, "잔액이 부족합니다."), - CHALLENGE_ALREADY_ENDED(HttpStatus.CONFLICT, "이미 종료된 챌린지입니다."), - RECOMMENDATION_ALREADY_REACTED(HttpStatus.CONFLICT, "이미 반응한 추천입니다."), - INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 오류가 발생했습니다."), -} - data class FieldErrorDetail( val field: String, val reason: String, diff --git a/src/main/kotlin/team/cklob/arena/common/CommonErrorCode.kt b/src/main/kotlin/team/cklob/arena/common/CommonErrorCode.kt new file mode 100644 index 0000000..16e7468 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/common/CommonErrorCode.kt @@ -0,0 +1,15 @@ +package team.cklob.arena.common + +import org.springframework.http.HttpStatus + +enum class CommonErrorCode( + override val status: HttpStatus, + override val message: String, +) : ErrorCode { + INVALID_REQUEST(HttpStatus.BAD_REQUEST, "잘못된 요청입니다."), + MALFORMED_JSON(HttpStatus.BAD_REQUEST, "요청 본문 형식이 올바르지 않습니다."), + INVALID_TYPE_VALUE(HttpStatus.BAD_REQUEST, "요청 값의 형식이 올바르지 않습니다."), + METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "지원하지 않는 HTTP 메서드입니다."), + RESOURCE_NOT_FOUND(HttpStatus.NOT_FOUND, "요청한 리소스를 찾을 수 없습니다."), + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 오류가 발생했습니다."), +} diff --git a/src/main/kotlin/team/cklob/arena/common/ErrorCode.kt b/src/main/kotlin/team/cklob/arena/common/ErrorCode.kt new file mode 100644 index 0000000..110ab5d --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/common/ErrorCode.kt @@ -0,0 +1,10 @@ +package team.cklob.arena.common + +import org.springframework.http.HttpStatus + +interface ErrorCode { + val status: HttpStatus + val message: String + val code: String + get() = (this as Enum<*>).name +} diff --git a/src/main/kotlin/team/cklob/arena/common/GlobalExceptionHandler.kt b/src/main/kotlin/team/cklob/arena/common/GlobalExceptionHandler.kt index 3537530..58d508b 100644 --- a/src/main/kotlin/team/cklob/arena/common/GlobalExceptionHandler.kt +++ b/src/main/kotlin/team/cklob/arena/common/GlobalExceptionHandler.kt @@ -32,7 +32,7 @@ class GlobalExceptionHandler { }.map { FieldErrorDetail(field = it.field, reason = it.defaultMessage ?: "유효하지 않은 값입니다.") } return response( - errorCode = ErrorCode.INVALID_REQUEST, + errorCode = CommonErrorCode.INVALID_REQUEST, data = ValidationErrorData(fieldErrors), ) } @@ -40,7 +40,7 @@ class GlobalExceptionHandler { @ExceptionHandler(ConstraintViolationException::class) fun handleConstraintViolation(exception: ConstraintViolationException): ResponseEntity> = response( - errorCode = ErrorCode.INVALID_REQUEST, + errorCode = CommonErrorCode.INVALID_REQUEST, data = ValidationErrorData( exception.constraintViolations.map { @@ -53,27 +53,29 @@ class GlobalExceptionHandler { fun handleUnreadableMessage(exception: HttpMessageNotReadableException): ResponseEntity> = response( if (exception.cause is InvalidFormatException) { - ErrorCode.INVALID_TYPE_VALUE + CommonErrorCode.INVALID_TYPE_VALUE } else { - ErrorCode.MALFORMED_JSON + CommonErrorCode.MALFORMED_JSON }, ) @ExceptionHandler(MethodArgumentTypeMismatchException::class) fun handleTypeMismatch(exception: MethodArgumentTypeMismatchException): ResponseEntity> = - response(ErrorCode.INVALID_TYPE_VALUE) + response(CommonErrorCode.INVALID_TYPE_VALUE) @ExceptionHandler(HttpRequestMethodNotSupportedException::class) fun handleMethodNotSupported(exception: HttpRequestMethodNotSupportedException): ResponseEntity> = - response(ErrorCode.METHOD_NOT_ALLOWED) + response(CommonErrorCode.METHOD_NOT_ALLOWED) @ExceptionHandler(NoHandlerFoundException::class, NoResourceFoundException::class) - fun handleNotFound(exception: Exception): ResponseEntity> = response(ErrorCode.RESOURCE_NOT_FOUND) + fun handleNotFound(exception: Exception): ResponseEntity> { + return response(CommonErrorCode.RESOURCE_NOT_FOUND) + } @ExceptionHandler(Exception::class) fun handleUnexpectedException(exception: Exception): ResponseEntity> { log.error("Unhandled exception", exception) - return response(ErrorCode.INTERNAL_SERVER_ERROR) + return response(CommonErrorCode.INTERNAL_SERVER_ERROR) } private fun response( diff --git a/src/main/kotlin/team/cklob/arena/common/OpenApiConfig.kt b/src/main/kotlin/team/cklob/arena/common/OpenApiConfig.kt index 38501fe..ce94eaa 100644 --- a/src/main/kotlin/team/cklob/arena/common/OpenApiConfig.kt +++ b/src/main/kotlin/team/cklob/arena/common/OpenApiConfig.kt @@ -23,8 +23,8 @@ class OpenApiConfig { ).addSchemas( "CommonErrorResponse", ObjectSchema() - .addProperty("code", StringSchema().example(ErrorCode.INVALID_REQUEST.name)) - .addProperty("message", StringSchema().example(ErrorCode.INVALID_REQUEST.message)) + .addProperty("code", StringSchema().example(CommonErrorCode.INVALID_REQUEST.code)) + .addProperty("message", StringSchema().example(CommonErrorCode.INVALID_REQUEST.message)) .addProperty("data", ObjectSchema().nullable(true)), ), ) diff --git a/src/main/kotlin/team/cklob/arena/recommendation/RecommendationErrorCode.kt b/src/main/kotlin/team/cklob/arena/recommendation/RecommendationErrorCode.kt new file mode 100644 index 0000000..332baa0 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/recommendation/RecommendationErrorCode.kt @@ -0,0 +1,11 @@ +package team.cklob.arena.recommendation + +import org.springframework.http.HttpStatus +import team.cklob.arena.common.ErrorCode + +enum class RecommendationErrorCode( + override val status: HttpStatus, + override val message: String, +) : ErrorCode { + RECOMMENDATION_ALREADY_REACTED(HttpStatus.CONFLICT, "이미 반응한 추천입니다."), +} diff --git a/src/main/kotlin/team/cklob/arena/security/JwtAuthenticationFilter.kt b/src/main/kotlin/team/cklob/arena/security/JwtAuthenticationFilter.kt index 4bf5371..53d6114 100644 --- a/src/main/kotlin/team/cklob/arena/security/JwtAuthenticationFilter.kt +++ b/src/main/kotlin/team/cklob/arena/security/JwtAuthenticationFilter.kt @@ -9,7 +9,6 @@ import org.springframework.http.HttpHeaders import org.springframework.security.authentication.UsernamePasswordAuthenticationToken import org.springframework.security.core.context.SecurityContextHolder import org.springframework.web.filter.OncePerRequestFilter -import team.cklob.arena.common.ErrorCode class JwtAuthenticationFilter( private val jwtTokenProvider: JwtTokenProvider, @@ -27,7 +26,7 @@ class JwtAuthenticationFilter( } if (!authorization.startsWith(BEARER_PREFIX) || authorization.length == BEARER_PREFIX.length) { - securityErrorHandler.write(response, ErrorCode.INVALID_TOKEN) + securityErrorHandler.write(response, SecurityErrorCode.INVALID_TOKEN) return } @@ -37,11 +36,11 @@ class JwtAuthenticationFilter( SecurityContextHolder.getContext().authentication = authentication filterChain.doFilter(request, response) } catch (exception: ExpiredJwtException) { - securityErrorHandler.write(response, ErrorCode.EXPIRED_TOKEN) + securityErrorHandler.write(response, SecurityErrorCode.EXPIRED_TOKEN) } catch (exception: JwtException) { - securityErrorHandler.write(response, ErrorCode.INVALID_TOKEN) + securityErrorHandler.write(response, SecurityErrorCode.INVALID_TOKEN) } catch (exception: IllegalArgumentException) { - securityErrorHandler.write(response, ErrorCode.INVALID_TOKEN) + securityErrorHandler.write(response, SecurityErrorCode.INVALID_TOKEN) } } diff --git a/src/main/kotlin/team/cklob/arena/security/SecurityErrorCode.kt b/src/main/kotlin/team/cklob/arena/security/SecurityErrorCode.kt new file mode 100644 index 0000000..2703d7f --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/security/SecurityErrorCode.kt @@ -0,0 +1,14 @@ +package team.cklob.arena.security + +import org.springframework.http.HttpStatus +import team.cklob.arena.common.ErrorCode + +enum class SecurityErrorCode( + override val status: HttpStatus, + override val message: String, +) : ErrorCode { + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "인증이 필요합니다."), + INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 토큰입니다."), + EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "만료된 토큰입니다."), + FORBIDDEN(HttpStatus.FORBIDDEN, "접근 권한이 없습니다."), +} diff --git a/src/main/kotlin/team/cklob/arena/security/SecurityErrorHandler.kt b/src/main/kotlin/team/cklob/arena/security/SecurityErrorHandler.kt index bda5e43..932ff02 100644 --- a/src/main/kotlin/team/cklob/arena/security/SecurityErrorHandler.kt +++ b/src/main/kotlin/team/cklob/arena/security/SecurityErrorHandler.kt @@ -18,7 +18,7 @@ class SecurityErrorHandler( response: HttpServletResponse, authException: org.springframework.security.core.AuthenticationException, ) { - write(response, ErrorCode.UNAUTHORIZED) + write(response, SecurityErrorCode.UNAUTHORIZED) } override fun handle( @@ -26,7 +26,7 @@ class SecurityErrorHandler( response: HttpServletResponse, accessDeniedException: AccessDeniedException, ) { - write(response, ErrorCode.FORBIDDEN) + write(response, SecurityErrorCode.FORBIDDEN) } fun write( diff --git a/src/main/kotlin/team/cklob/arena/trading/TradingErrorCode.kt b/src/main/kotlin/team/cklob/arena/trading/TradingErrorCode.kt new file mode 100644 index 0000000..87d570a --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/trading/TradingErrorCode.kt @@ -0,0 +1,11 @@ +package team.cklob.arena.trading + +import org.springframework.http.HttpStatus +import team.cklob.arena.common.ErrorCode + +enum class TradingErrorCode( + override val status: HttpStatus, + override val message: String, +) : ErrorCode { + INSUFFICIENT_BALANCE(HttpStatus.CONFLICT, "잔액이 부족합니다."), +} From ad442ed1df978d5081dd944e9dfb418b5e64d9d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=95=98=EB=AF=BC?= Date: Mon, 13 Jul 2026 22:09:51 +0900 Subject: [PATCH 4/6] =?UTF-8?q?fix=20::=20=EB=A6=AC=EB=B7=B0=20=EC=9D=98?= =?UTF-8?q?=EA=B2=AC=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../team/cklob/arena/common/GlobalExceptionHandler.kt | 10 ++++------ .../cklob/arena/security/JwtAuthenticationFilter.kt | 7 ++++--- .../kotlin/team/cklob/arena/security/JwtProperties.kt | 1 + .../kotlin/team/cklob/arena/security/SecurityConfig.kt | 1 + src/main/resources/application.yaml | 1 + .../cklob/arena/security/SecurityIntegrationTest.kt | 10 ++++++++++ 6 files changed, 21 insertions(+), 9 deletions(-) diff --git a/src/main/kotlin/team/cklob/arena/common/GlobalExceptionHandler.kt b/src/main/kotlin/team/cklob/arena/common/GlobalExceptionHandler.kt index 58d508b..41de2be 100644 --- a/src/main/kotlin/team/cklob/arena/common/GlobalExceptionHandler.kt +++ b/src/main/kotlin/team/cklob/arena/common/GlobalExceptionHandler.kt @@ -23,13 +23,11 @@ class GlobalExceptionHandler { response(exception.errorCode, exception.message ?: exception.errorCode.message) @ExceptionHandler(MethodArgumentNotValidException::class, BindException::class) - fun handleValidationException(exception: Exception): ResponseEntity> { + fun handleValidationException(exception: BindException): ResponseEntity> { val fieldErrors = - when (exception) { - is MethodArgumentNotValidException -> exception.bindingResult.fieldErrors - is BindException -> exception.bindingResult.fieldErrors - else -> emptyList() - }.map { FieldErrorDetail(field = it.field, reason = it.defaultMessage ?: "유효하지 않은 값입니다.") } + exception.bindingResult.fieldErrors.map { + FieldErrorDetail(field = it.field, reason = it.defaultMessage ?: "유효하지 않은 값입니다.") + } return response( errorCode = CommonErrorCode.INVALID_REQUEST, diff --git a/src/main/kotlin/team/cklob/arena/security/JwtAuthenticationFilter.kt b/src/main/kotlin/team/cklob/arena/security/JwtAuthenticationFilter.kt index 53d6114..5ca11d6 100644 --- a/src/main/kotlin/team/cklob/arena/security/JwtAuthenticationFilter.kt +++ b/src/main/kotlin/team/cklob/arena/security/JwtAuthenticationFilter.kt @@ -20,18 +20,19 @@ class JwtAuthenticationFilter( filterChain: FilterChain, ) { val authorization = request.getHeader(HttpHeaders.AUTHORIZATION) - if (authorization == null) { + if (authorization == null || !authorization.startsWith(BEARER_PREFIX)) { filterChain.doFilter(request, response) return } - if (!authorization.startsWith(BEARER_PREFIX) || authorization.length == BEARER_PREFIX.length) { + val token = authorization.removePrefix(BEARER_PREFIX) + if (token.isBlank()) { securityErrorHandler.write(response, SecurityErrorCode.INVALID_TOKEN) return } try { - val userId = jwtTokenProvider.getUserId(authorization.removePrefix(BEARER_PREFIX)) + val userId = jwtTokenProvider.getUserId(token) val authentication = UsernamePasswordAuthenticationToken(userId, null, emptyList()) SecurityContextHolder.getContext().authentication = authentication filterChain.doFilter(request, response) diff --git a/src/main/kotlin/team/cklob/arena/security/JwtProperties.kt b/src/main/kotlin/team/cklob/arena/security/JwtProperties.kt index 61bbdc4..01fcc30 100644 --- a/src/main/kotlin/team/cklob/arena/security/JwtProperties.kt +++ b/src/main/kotlin/team/cklob/arena/security/JwtProperties.kt @@ -8,6 +8,7 @@ import java.time.Duration @Validated @ConfigurationProperties("security.jwt") data class JwtProperties( + /** Base64-encoded secret with at least 32 bytes before encoding. */ @field:NotBlank val secret: String, val accessTokenExpiration: Duration, diff --git a/src/main/kotlin/team/cklob/arena/security/SecurityConfig.kt b/src/main/kotlin/team/cklob/arena/security/SecurityConfig.kt index 94cafc6..626f9e7 100644 --- a/src/main/kotlin/team/cklob/arena/security/SecurityConfig.kt +++ b/src/main/kotlin/team/cklob/arena/security/SecurityConfig.kt @@ -34,6 +34,7 @@ class SecurityConfig { it.requestMatchers( "/auth/**", "/swagger-ui/**", + "/swagger-ui.html", "/v3/api-docs/**", "/actuator/health", "/error", diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 7c79fff..6d40c5e 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -4,5 +4,6 @@ spring: security: jwt: + # Base64-encoded secret with at least 32 bytes before encoding. secret: ${JWT_SECRET} access-token-expiration: PT15M diff --git a/src/test/kotlin/team/cklob/arena/security/SecurityIntegrationTest.kt b/src/test/kotlin/team/cklob/arena/security/SecurityIntegrationTest.kt index 3db812d..823cf6e 100644 --- a/src/test/kotlin/team/cklob/arena/security/SecurityIntegrationTest.kt +++ b/src/test/kotlin/team/cklob/arena/security/SecurityIntegrationTest.kt @@ -28,6 +28,16 @@ class SecurityIntegrationTest( extension(SpringExtension) describe("공통 응답 및 보안 설정") { + it("Bearer가 아닌 Authorization 헤더는 공개 API를 차단하지 않는다") { + mockMvc.get("/auth/test") { + header("Authorization", "Basic ignored") + accept = MediaType.APPLICATION_JSON + }.andExpect { + status { isOk() } + jsonPath("$.code") { value("SUCCESS") } + } + } + it("공개 API 응답을 공통 형식으로 감싼다") { mockMvc.get("/auth/test") { accept = MediaType.APPLICATION_JSON From 5151e099f54b946f0fe0f5f0aef4287a41979c95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=95=98=EB=AF=BC?= Date: Tue, 14 Jul 2026 08:04:30 +0900 Subject: [PATCH 5/6] =?UTF-8?q?refactor=20::=20domain=EA=B3=BC=20global=20?= =?UTF-8?q?=ED=8C=A8=ED=82=A4=EC=A7=80=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../team/cklob/arena/{ => domain}/challenge/Challenge.kt | 6 +++--- .../arena/{ => domain}/challenge/ChallengeErrorCode.kt | 4 ++-- .../kotlin/team/cklob/arena/{ => domain}/market/Market.kt | 2 +- .../arena/{ => domain}/recommendation/Recommendation.kt | 6 +++--- .../{ => domain}/recommendation/RecommendationErrorCode.kt | 4 ++-- .../kotlin/team/cklob/arena/{ => domain}/trading/Trading.kt | 4 ++-- .../cklob/arena/{ => domain}/trading/TradingErrorCode.kt | 4 ++-- src/main/kotlin/team/cklob/arena/{ => domain}/user/User.kt | 2 +- .../cklob/arena/{common => global/config}/OpenApiConfig.kt | 3 ++- .../arena/{common => global/exception}/CommonErrorCode.kt | 2 +- .../cklob/arena/{common => global/exception}/ErrorCode.kt | 2 +- .../arena/{common => global/exception}/ExpectedException.kt | 2 +- .../{common => global/exception}/GlobalExceptionHandler.kt | 5 ++++- .../cklob/arena/{common => global/response}/ApiResponse.kt | 4 +++- .../{common => global/response}/CommonResponseAdvice.kt | 2 +- .../arena/{ => global}/security/JwtAuthenticationFilter.kt | 2 +- .../team/cklob/arena/{ => global}/security/JwtProperties.kt | 2 +- .../cklob/arena/{ => global}/security/JwtTokenProvider.kt | 2 +- .../cklob/arena/{ => global}/security/SecurityConfig.kt | 2 +- .../cklob/arena/{ => global}/security/SecurityErrorCode.kt | 4 ++-- .../arena/{ => global}/security/SecurityErrorHandler.kt | 6 +++--- .../arena/{ => global}/security/SecurityIntegrationTest.kt | 2 +- 22 files changed, 39 insertions(+), 33 deletions(-) rename src/main/kotlin/team/cklob/arena/{ => domain}/challenge/Challenge.kt (96%) rename src/main/kotlin/team/cklob/arena/{ => domain}/challenge/ChallengeErrorCode.kt (73%) rename src/main/kotlin/team/cklob/arena/{ => domain}/market/Market.kt (98%) rename src/main/kotlin/team/cklob/arena/{ => domain}/recommendation/Recommendation.kt (93%) rename src/main/kotlin/team/cklob/arena/{ => domain}/recommendation/RecommendationErrorCode.kt (72%) rename src/main/kotlin/team/cklob/arena/{ => domain}/trading/Trading.kt (96%) rename src/main/kotlin/team/cklob/arena/{ => domain}/trading/TradingErrorCode.kt (72%) rename src/main/kotlin/team/cklob/arena/{ => domain}/user/User.kt (98%) rename src/main/kotlin/team/cklob/arena/{common => global/config}/OpenApiConfig.kt (92%) rename src/main/kotlin/team/cklob/arena/{common => global/exception}/CommonErrorCode.kt (94%) rename src/main/kotlin/team/cklob/arena/{common => global/exception}/ErrorCode.kt (81%) rename src/main/kotlin/team/cklob/arena/{common => global/exception}/ExpectedException.kt (78%) rename src/main/kotlin/team/cklob/arena/{common => global/exception}/GlobalExceptionHandler.kt (94%) rename src/main/kotlin/team/cklob/arena/{common => global/response}/ApiResponse.kt (89%) rename src/main/kotlin/team/cklob/arena/{common => global/response}/CommonResponseAdvice.kt (96%) rename src/main/kotlin/team/cklob/arena/{ => global}/security/JwtAuthenticationFilter.kt (98%) rename src/main/kotlin/team/cklob/arena/{ => global}/security/JwtProperties.kt (91%) rename src/main/kotlin/team/cklob/arena/{ => global}/security/JwtTokenProvider.kt (96%) rename src/main/kotlin/team/cklob/arena/{ => global}/security/SecurityConfig.kt (97%) rename src/main/kotlin/team/cklob/arena/{ => global}/security/SecurityErrorCode.kt (83%) rename src/main/kotlin/team/cklob/arena/{ => global}/security/SecurityErrorHandler.kt (89%) rename src/test/kotlin/team/cklob/arena/{ => global}/security/SecurityIntegrationTest.kt (99%) diff --git a/src/main/kotlin/team/cklob/arena/challenge/Challenge.kt b/src/main/kotlin/team/cklob/arena/domain/challenge/Challenge.kt similarity index 96% rename from src/main/kotlin/team/cklob/arena/challenge/Challenge.kt rename to src/main/kotlin/team/cklob/arena/domain/challenge/Challenge.kt index 8b7c9cf..d088257 100644 --- a/src/main/kotlin/team/cklob/arena/challenge/Challenge.kt +++ b/src/main/kotlin/team/cklob/arena/domain/challenge/Challenge.kt @@ -1,4 +1,4 @@ -package team.cklob.arena.challenge +package team.cklob.arena.domain.challenge import jakarta.persistence.Column import jakarta.persistence.Entity @@ -15,8 +15,8 @@ import jakarta.persistence.OneToOne import jakarta.persistence.Table import jakarta.persistence.UniqueConstraint import org.springframework.data.jpa.repository.JpaRepository -import team.cklob.arena.market.MarketType -import team.cklob.arena.user.User +import team.cklob.arena.domain.market.MarketType +import team.cklob.arena.domain.user.User import java.math.BigDecimal import java.time.LocalDateTime diff --git a/src/main/kotlin/team/cklob/arena/challenge/ChallengeErrorCode.kt b/src/main/kotlin/team/cklob/arena/domain/challenge/ChallengeErrorCode.kt similarity index 73% rename from src/main/kotlin/team/cklob/arena/challenge/ChallengeErrorCode.kt rename to src/main/kotlin/team/cklob/arena/domain/challenge/ChallengeErrorCode.kt index fafa35e..93a6949 100644 --- a/src/main/kotlin/team/cklob/arena/challenge/ChallengeErrorCode.kt +++ b/src/main/kotlin/team/cklob/arena/domain/challenge/ChallengeErrorCode.kt @@ -1,7 +1,7 @@ -package team.cklob.arena.challenge +package team.cklob.arena.domain.challenge import org.springframework.http.HttpStatus -import team.cklob.arena.common.ErrorCode +import team.cklob.arena.global.exception.ErrorCode enum class ChallengeErrorCode( override val status: HttpStatus, diff --git a/src/main/kotlin/team/cklob/arena/market/Market.kt b/src/main/kotlin/team/cklob/arena/domain/market/Market.kt similarity index 98% rename from src/main/kotlin/team/cklob/arena/market/Market.kt rename to src/main/kotlin/team/cklob/arena/domain/market/Market.kt index 2b3d8f6..b59a365 100644 --- a/src/main/kotlin/team/cklob/arena/market/Market.kt +++ b/src/main/kotlin/team/cklob/arena/domain/market/Market.kt @@ -1,4 +1,4 @@ -package team.cklob.arena.market +package team.cklob.arena.domain.market import jakarta.persistence.Column import jakarta.persistence.Entity diff --git a/src/main/kotlin/team/cklob/arena/recommendation/Recommendation.kt b/src/main/kotlin/team/cklob/arena/domain/recommendation/Recommendation.kt similarity index 93% rename from src/main/kotlin/team/cklob/arena/recommendation/Recommendation.kt rename to src/main/kotlin/team/cklob/arena/domain/recommendation/Recommendation.kt index d07e8d7..adba881 100644 --- a/src/main/kotlin/team/cklob/arena/recommendation/Recommendation.kt +++ b/src/main/kotlin/team/cklob/arena/domain/recommendation/Recommendation.kt @@ -1,4 +1,4 @@ -package team.cklob.arena.recommendation +package team.cklob.arena.domain.recommendation import jakarta.persistence.Column import jakarta.persistence.Entity @@ -14,8 +14,8 @@ import jakarta.persistence.ManyToOne import jakarta.persistence.Table import org.hibernate.annotations.CreationTimestamp import org.springframework.data.jpa.repository.JpaRepository -import team.cklob.arena.challenge.Challenge -import team.cklob.arena.market.Symbol +import team.cklob.arena.domain.challenge.Challenge +import team.cklob.arena.domain.market.Symbol import java.math.BigDecimal import java.time.LocalDateTime diff --git a/src/main/kotlin/team/cklob/arena/recommendation/RecommendationErrorCode.kt b/src/main/kotlin/team/cklob/arena/domain/recommendation/RecommendationErrorCode.kt similarity index 72% rename from src/main/kotlin/team/cklob/arena/recommendation/RecommendationErrorCode.kt rename to src/main/kotlin/team/cklob/arena/domain/recommendation/RecommendationErrorCode.kt index 332baa0..d019f65 100644 --- a/src/main/kotlin/team/cklob/arena/recommendation/RecommendationErrorCode.kt +++ b/src/main/kotlin/team/cklob/arena/domain/recommendation/RecommendationErrorCode.kt @@ -1,7 +1,7 @@ -package team.cklob.arena.recommendation +package team.cklob.arena.domain.recommendation import org.springframework.http.HttpStatus -import team.cklob.arena.common.ErrorCode +import team.cklob.arena.global.exception.ErrorCode enum class RecommendationErrorCode( override val status: HttpStatus, diff --git a/src/main/kotlin/team/cklob/arena/trading/Trading.kt b/src/main/kotlin/team/cklob/arena/domain/trading/Trading.kt similarity index 96% rename from src/main/kotlin/team/cklob/arena/trading/Trading.kt rename to src/main/kotlin/team/cklob/arena/domain/trading/Trading.kt index 960cbe5..a93db99 100644 --- a/src/main/kotlin/team/cklob/arena/trading/Trading.kt +++ b/src/main/kotlin/team/cklob/arena/domain/trading/Trading.kt @@ -1,4 +1,4 @@ -package team.cklob.arena.trading +package team.cklob.arena.domain.trading import jakarta.persistence.Column import jakarta.persistence.Entity @@ -16,7 +16,7 @@ import jakarta.persistence.Table import jakarta.persistence.UniqueConstraint import org.hibernate.annotations.CreationTimestamp import org.springframework.data.jpa.repository.JpaRepository -import team.cklob.arena.challenge.ChallengeParticipant +import team.cklob.arena.domain.challenge.ChallengeParticipant import java.math.BigDecimal import java.time.LocalDateTime diff --git a/src/main/kotlin/team/cklob/arena/trading/TradingErrorCode.kt b/src/main/kotlin/team/cklob/arena/domain/trading/TradingErrorCode.kt similarity index 72% rename from src/main/kotlin/team/cklob/arena/trading/TradingErrorCode.kt rename to src/main/kotlin/team/cklob/arena/domain/trading/TradingErrorCode.kt index 87d570a..4b451f4 100644 --- a/src/main/kotlin/team/cklob/arena/trading/TradingErrorCode.kt +++ b/src/main/kotlin/team/cklob/arena/domain/trading/TradingErrorCode.kt @@ -1,7 +1,7 @@ -package team.cklob.arena.trading +package team.cklob.arena.domain.trading import org.springframework.http.HttpStatus -import team.cklob.arena.common.ErrorCode +import team.cklob.arena.global.exception.ErrorCode enum class TradingErrorCode( override val status: HttpStatus, diff --git a/src/main/kotlin/team/cklob/arena/user/User.kt b/src/main/kotlin/team/cklob/arena/domain/user/User.kt similarity index 98% rename from src/main/kotlin/team/cklob/arena/user/User.kt rename to src/main/kotlin/team/cklob/arena/domain/user/User.kt index e2614e5..25223f8 100644 --- a/src/main/kotlin/team/cklob/arena/user/User.kt +++ b/src/main/kotlin/team/cklob/arena/domain/user/User.kt @@ -1,4 +1,4 @@ -package team.cklob.arena.user +package team.cklob.arena.domain.user import jakarta.persistence.Column import jakarta.persistence.Entity diff --git a/src/main/kotlin/team/cklob/arena/common/OpenApiConfig.kt b/src/main/kotlin/team/cklob/arena/global/config/OpenApiConfig.kt similarity index 92% rename from src/main/kotlin/team/cklob/arena/common/OpenApiConfig.kt rename to src/main/kotlin/team/cklob/arena/global/config/OpenApiConfig.kt index ce94eaa..a59181d 100644 --- a/src/main/kotlin/team/cklob/arena/common/OpenApiConfig.kt +++ b/src/main/kotlin/team/cklob/arena/global/config/OpenApiConfig.kt @@ -1,4 +1,4 @@ -package team.cklob.arena.common +package team.cklob.arena.global.config import io.swagger.v3.oas.models.Components import io.swagger.v3.oas.models.OpenAPI @@ -7,6 +7,7 @@ import io.swagger.v3.oas.models.media.StringSchema import io.swagger.v3.oas.models.security.SecurityScheme import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration +import team.cklob.arena.global.exception.CommonErrorCode @Configuration class OpenApiConfig { diff --git a/src/main/kotlin/team/cklob/arena/common/CommonErrorCode.kt b/src/main/kotlin/team/cklob/arena/global/exception/CommonErrorCode.kt similarity index 94% rename from src/main/kotlin/team/cklob/arena/common/CommonErrorCode.kt rename to src/main/kotlin/team/cklob/arena/global/exception/CommonErrorCode.kt index 16e7468..2e30f13 100644 --- a/src/main/kotlin/team/cklob/arena/common/CommonErrorCode.kt +++ b/src/main/kotlin/team/cklob/arena/global/exception/CommonErrorCode.kt @@ -1,4 +1,4 @@ -package team.cklob.arena.common +package team.cklob.arena.global.exception import org.springframework.http.HttpStatus diff --git a/src/main/kotlin/team/cklob/arena/common/ErrorCode.kt b/src/main/kotlin/team/cklob/arena/global/exception/ErrorCode.kt similarity index 81% rename from src/main/kotlin/team/cklob/arena/common/ErrorCode.kt rename to src/main/kotlin/team/cklob/arena/global/exception/ErrorCode.kt index 110ab5d..a8ca8dd 100644 --- a/src/main/kotlin/team/cklob/arena/common/ErrorCode.kt +++ b/src/main/kotlin/team/cklob/arena/global/exception/ErrorCode.kt @@ -1,4 +1,4 @@ -package team.cklob.arena.common +package team.cklob.arena.global.exception import org.springframework.http.HttpStatus diff --git a/src/main/kotlin/team/cklob/arena/common/ExpectedException.kt b/src/main/kotlin/team/cklob/arena/global/exception/ExpectedException.kt similarity index 78% rename from src/main/kotlin/team/cklob/arena/common/ExpectedException.kt rename to src/main/kotlin/team/cklob/arena/global/exception/ExpectedException.kt index 2c92830..0eedb16 100644 --- a/src/main/kotlin/team/cklob/arena/common/ExpectedException.kt +++ b/src/main/kotlin/team/cklob/arena/global/exception/ExpectedException.kt @@ -1,4 +1,4 @@ -package team.cklob.arena.common +package team.cklob.arena.global.exception class ExpectedException( val errorCode: ErrorCode, diff --git a/src/main/kotlin/team/cklob/arena/common/GlobalExceptionHandler.kt b/src/main/kotlin/team/cklob/arena/global/exception/GlobalExceptionHandler.kt similarity index 94% rename from src/main/kotlin/team/cklob/arena/common/GlobalExceptionHandler.kt rename to src/main/kotlin/team/cklob/arena/global/exception/GlobalExceptionHandler.kt index 41de2be..467efdb 100644 --- a/src/main/kotlin/team/cklob/arena/common/GlobalExceptionHandler.kt +++ b/src/main/kotlin/team/cklob/arena/global/exception/GlobalExceptionHandler.kt @@ -1,4 +1,4 @@ -package team.cklob.arena.common +package team.cklob.arena.global.exception import com.fasterxml.jackson.databind.exc.InvalidFormatException import jakarta.validation.ConstraintViolationException @@ -13,6 +13,9 @@ import org.springframework.web.bind.annotation.RestControllerAdvice import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException import org.springframework.web.servlet.NoHandlerFoundException import org.springframework.web.servlet.resource.NoResourceFoundException +import team.cklob.arena.global.response.CommonApiResponse +import team.cklob.arena.global.response.FieldErrorDetail +import team.cklob.arena.global.response.ValidationErrorData @RestControllerAdvice class GlobalExceptionHandler { diff --git a/src/main/kotlin/team/cklob/arena/common/ApiResponse.kt b/src/main/kotlin/team/cklob/arena/global/response/ApiResponse.kt similarity index 89% rename from src/main/kotlin/team/cklob/arena/common/ApiResponse.kt rename to src/main/kotlin/team/cklob/arena/global/response/ApiResponse.kt index 1ec3226..d0570d2 100644 --- a/src/main/kotlin/team/cklob/arena/common/ApiResponse.kt +++ b/src/main/kotlin/team/cklob/arena/global/response/ApiResponse.kt @@ -1,4 +1,6 @@ -package team.cklob.arena.common +package team.cklob.arena.global.response + +import team.cklob.arena.global.exception.ErrorCode data class CommonApiResponse( val code: String, diff --git a/src/main/kotlin/team/cklob/arena/common/CommonResponseAdvice.kt b/src/main/kotlin/team/cklob/arena/global/response/CommonResponseAdvice.kt similarity index 96% rename from src/main/kotlin/team/cklob/arena/common/CommonResponseAdvice.kt rename to src/main/kotlin/team/cklob/arena/global/response/CommonResponseAdvice.kt index b705d49..853ba5c 100644 --- a/src/main/kotlin/team/cklob/arena/common/CommonResponseAdvice.kt +++ b/src/main/kotlin/team/cklob/arena/global/response/CommonResponseAdvice.kt @@ -1,4 +1,4 @@ -package team.cklob.arena.common +package team.cklob.arena.global.response import org.springframework.core.MethodParameter import org.springframework.http.MediaType diff --git a/src/main/kotlin/team/cklob/arena/security/JwtAuthenticationFilter.kt b/src/main/kotlin/team/cklob/arena/global/security/JwtAuthenticationFilter.kt similarity index 98% rename from src/main/kotlin/team/cklob/arena/security/JwtAuthenticationFilter.kt rename to src/main/kotlin/team/cklob/arena/global/security/JwtAuthenticationFilter.kt index 5ca11d6..25cc033 100644 --- a/src/main/kotlin/team/cklob/arena/security/JwtAuthenticationFilter.kt +++ b/src/main/kotlin/team/cklob/arena/global/security/JwtAuthenticationFilter.kt @@ -1,4 +1,4 @@ -package team.cklob.arena.security +package team.cklob.arena.global.security import io.jsonwebtoken.ExpiredJwtException import io.jsonwebtoken.JwtException diff --git a/src/main/kotlin/team/cklob/arena/security/JwtProperties.kt b/src/main/kotlin/team/cklob/arena/global/security/JwtProperties.kt similarity index 91% rename from src/main/kotlin/team/cklob/arena/security/JwtProperties.kt rename to src/main/kotlin/team/cklob/arena/global/security/JwtProperties.kt index 01fcc30..3ee743d 100644 --- a/src/main/kotlin/team/cklob/arena/security/JwtProperties.kt +++ b/src/main/kotlin/team/cklob/arena/global/security/JwtProperties.kt @@ -1,4 +1,4 @@ -package team.cklob.arena.security +package team.cklob.arena.global.security import jakarta.validation.constraints.NotBlank import org.springframework.boot.context.properties.ConfigurationProperties diff --git a/src/main/kotlin/team/cklob/arena/security/JwtTokenProvider.kt b/src/main/kotlin/team/cklob/arena/global/security/JwtTokenProvider.kt similarity index 96% rename from src/main/kotlin/team/cklob/arena/security/JwtTokenProvider.kt rename to src/main/kotlin/team/cklob/arena/global/security/JwtTokenProvider.kt index bbeb13a..1573cb3 100644 --- a/src/main/kotlin/team/cklob/arena/security/JwtTokenProvider.kt +++ b/src/main/kotlin/team/cklob/arena/global/security/JwtTokenProvider.kt @@ -1,4 +1,4 @@ -package team.cklob.arena.security +package team.cklob.arena.global.security import io.jsonwebtoken.Claims import io.jsonwebtoken.Jwts diff --git a/src/main/kotlin/team/cklob/arena/security/SecurityConfig.kt b/src/main/kotlin/team/cklob/arena/global/security/SecurityConfig.kt similarity index 97% rename from src/main/kotlin/team/cklob/arena/security/SecurityConfig.kt rename to src/main/kotlin/team/cklob/arena/global/security/SecurityConfig.kt index 626f9e7..ee9ec7f 100644 --- a/src/main/kotlin/team/cklob/arena/security/SecurityConfig.kt +++ b/src/main/kotlin/team/cklob/arena/global/security/SecurityConfig.kt @@ -1,4 +1,4 @@ -package team.cklob.arena.security +package team.cklob.arena.global.security import com.fasterxml.jackson.databind.ObjectMapper import org.springframework.context.annotation.Bean diff --git a/src/main/kotlin/team/cklob/arena/security/SecurityErrorCode.kt b/src/main/kotlin/team/cklob/arena/global/security/SecurityErrorCode.kt similarity index 83% rename from src/main/kotlin/team/cklob/arena/security/SecurityErrorCode.kt rename to src/main/kotlin/team/cklob/arena/global/security/SecurityErrorCode.kt index 2703d7f..13aacb0 100644 --- a/src/main/kotlin/team/cklob/arena/security/SecurityErrorCode.kt +++ b/src/main/kotlin/team/cklob/arena/global/security/SecurityErrorCode.kt @@ -1,7 +1,7 @@ -package team.cklob.arena.security +package team.cklob.arena.global.security import org.springframework.http.HttpStatus -import team.cklob.arena.common.ErrorCode +import team.cklob.arena.global.exception.ErrorCode enum class SecurityErrorCode( override val status: HttpStatus, diff --git a/src/main/kotlin/team/cklob/arena/security/SecurityErrorHandler.kt b/src/main/kotlin/team/cklob/arena/global/security/SecurityErrorHandler.kt similarity index 89% rename from src/main/kotlin/team/cklob/arena/security/SecurityErrorHandler.kt rename to src/main/kotlin/team/cklob/arena/global/security/SecurityErrorHandler.kt index 932ff02..11b4afd 100644 --- a/src/main/kotlin/team/cklob/arena/security/SecurityErrorHandler.kt +++ b/src/main/kotlin/team/cklob/arena/global/security/SecurityErrorHandler.kt @@ -1,4 +1,4 @@ -package team.cklob.arena.security +package team.cklob.arena.global.security import com.fasterxml.jackson.databind.ObjectMapper import jakarta.servlet.http.HttpServletRequest @@ -7,8 +7,8 @@ import org.springframework.http.MediaType import org.springframework.security.access.AccessDeniedException import org.springframework.security.web.AuthenticationEntryPoint import org.springframework.security.web.access.AccessDeniedHandler -import team.cklob.arena.common.CommonApiResponse -import team.cklob.arena.common.ErrorCode +import team.cklob.arena.global.exception.ErrorCode +import team.cklob.arena.global.response.CommonApiResponse class SecurityErrorHandler( private val objectMapper: ObjectMapper, diff --git a/src/test/kotlin/team/cklob/arena/security/SecurityIntegrationTest.kt b/src/test/kotlin/team/cklob/arena/global/security/SecurityIntegrationTest.kt similarity index 99% rename from src/test/kotlin/team/cklob/arena/security/SecurityIntegrationTest.kt rename to src/test/kotlin/team/cklob/arena/global/security/SecurityIntegrationTest.kt index 823cf6e..5d048e0 100644 --- a/src/test/kotlin/team/cklob/arena/security/SecurityIntegrationTest.kt +++ b/src/test/kotlin/team/cklob/arena/global/security/SecurityIntegrationTest.kt @@ -1,4 +1,4 @@ -package team.cklob.arena.security +package team.cklob.arena.global.security import io.kotest.core.spec.style.DescribeSpec import io.kotest.extensions.spring.SpringExtension From 0ad0405d1d02038bca0145d6323588d18c15860f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=95=98=EB=AF=BC?= Date: Tue, 14 Jul 2026 08:09:13 +0900 Subject: [PATCH 6/6] =?UTF-8?q?docs=20::=20=ED=8C=A8=ED=82=A4=EC=A7=80=20?= =?UTF-8?q?=EC=95=84=ED=82=A4=ED=85=8D=EC=B2=98=20=EA=B7=9C=EC=B9=99=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- ARCHITECTURE.md | 55 +++++++++++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 3 +-- 3 files changed, 57 insertions(+), 3 deletions(-) create mode 100644 ARCHITECTURE.md diff --git a/AGENTS.md b/AGENTS.md index d6d3c8c..bb6af20 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,7 +16,7 @@ Kotlin, Spring Boot, Spring Data JPA, Spring Security, JWT, PostgreSQL, Redis, G `user`, `market`, `challenge`, `trading`, `portfolio`, `recommendation` (reaction logging), `learning` -Packages are organized by **domain, not by layer**. Before adding new code, decide which domain it belongs to and place it in that package. +Follow [ARCHITECTURE.md](ARCHITECTURE.md) for the required `domain` and `global` package layout. Before adding code, decide whether it belongs to a domain layer or a global concern and place it there. ## Boundary with the FastAPI AI Service (important) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..c0db7f4 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,55 @@ +# Architecture + +## Package Layout + +```text +team.cklob.arena +├── domain +│ └── {domainName} +│ ├── presentation +│ │ ├── request +│ │ ├── response +│ │ └── controller +│ ├── application +│ │ └── impl +│ ├── domain +│ │ ├── entity +│ │ ├── type +│ │ └── repository +│ └── infrastructure +└── global + ├── common + ├── config + ├── security + ├── exception + ├── response + ├── annotation + ├── util + └── property +``` + +`{domainName}` is one of `user`, `market`, `challenge`, `trading`, `portfolio`, `recommendation`, or `learning`. + +Do not create an empty package. Add a package only when it contains production code. + +## Domain Layers + +- `presentation`: HTTP controllers and request/response DTOs. Controllers validate input and delegate only to application services. +- `application`: use cases, orchestration, and transaction boundaries. Place concrete service implementations in `application.impl`. +- `domain`: entities, domain types, and repository interfaces. Keep domain business rules here when they do not require orchestration or external I/O. +- `infrastructure`: external API clients, persistence adapters, and other technology-specific implementations. + +Use the package form `team.cklob.arena.domain.{domainName}.{layer}`. A domain may depend on `global`, but one domain must not reach into another domain's `presentation` or `application` package. + +## Global Packages + +- `common`: cross-cutting types that do not belong to another global concern. +- `config`: Spring configuration and framework integration setup. +- `security`: JWT, authentication, authorization, and Spring Security components. +- `exception`: shared exception contracts, error-code contracts, and global exception handlers. +- `response`: shared API response envelopes and response advice. +- `annotation`: reusable annotations and their supporting code. +- `util`: stateless helpers with no domain ownership. +- `property`: `@ConfigurationProperties` classes. + +`global` must not contain domain business logic, domain entities, or domain repositories. diff --git a/CLAUDE.md b/CLAUDE.md index 21135c3..1349996 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,8 +18,7 @@ Main backend for an AI mock investment battle platform. Users compete against AI `user` · `market` · `challenge` · `trading` · `portfolio` · `recommendation` (reaction logging) · `learning` -- Follow **domain-based package structure**. Avoid layer-first structure (splitting into controller/service/repository at the top level). -- Package convention: `team.cklob.arena.{domain}.{layer}` +Follow [ARCHITECTURE.md](ARCHITECTURE.md) for the required `domain` and `global` package layout. ## Boundary with the FastAPI AI Service