language : kotlin "2.1.20" framework : spring boot "3.4.4" Build tool : Gradle "8.13"
[Spring Security]
Spring Security는 Spring Framework 기반 애플리케이션에 보안 기능을 추가하기 위한 프레임워크로 주로 인증(Authentication)과 인가(Authorization) 기능을 제공하며, 세션 관리, CSRF 보호, 암호화, HTTP 요청 보안 설정 등 다양한 보안 관련 기능을 제공한다.
- 인증 (Authentication)
> 접근하는 자가 "인증" 된 사용자인가?
사용자가 누구인지 확인하는 과정이다. 인증되지 않는 사용자의 접근을 제한한다.
- 인가 (Authorization)
> 접근한 자가 "인가" 된 사용자인가?
접근한 사용자가 해당 기능에 권한이 있는지 확인하는 과정이다. 인증된 사용자이나 인가되지않은 사용자일 경우 접근이 제한된다.
[2025-05-02 16:27:18.174] [] 23605 [main] [WARN ] UserDetailsServiceAutoConfiguration [getOrDeducePassword:90]
Using generated security password: 97a0****-****-****-****-************
This generated password is for development use only. Your security configuration must be updated before running your application in production.
[2025-05-02 16:27:18.391] [] 23605 [main] [INFO ] MyProjectApplicationKt [logStarted:59] Started MyProjectApplicationKt in 6.119 seconds (process running for 6.422)
security password 를 따로 설정하지 않아 임의로 생성이 되었고, 이것은 개발용으로만 사용해야한다는 경고성 문구가 확인되며 운영환경에서는 UserDetailsService 또는 SecurityFilterChain으로 명시적 설정이 필요함을 알려준다.
2. Basic Auth 확인해보기
spring security 가 적용된 상태에서 아무런 인증값 없이 호출을 하면 401 에러가 발생한다.
이때, 콘솔에 찍힌 security password 를 가지고 Basic Auth 설정 후 통신을 하면 정상적으로 호출이 된다.
위 과정을 통해 spring security 에서는 기본적으로 Basic Auth를 제공하는것을 알 수 있다.
하지만, 프로젝트에서는 Bearer token을 사용할것이기 때문에 커스텀이 필요하다.
다음 장에서 Filter 를 이용해 Custom 인증 필터를 만드는 것을 진행해 보도록 하겠다.
application/json 타입일때에는 요청 파라미터를 정의할때 requestFields 메소드를 사용하였다. 하지만 x-www-form-urlencoded일때에는 params를 사용해야 한다.
// given
val requestDto =
HealthcheckController.TmpRequestDto(
userId = 1,
name = "tester2",
nickname = "banana",
age = 25,
productName = "잘팔리는노트",
hobby = mutableListOf("클라이밍", "뜨개질"),
address =
HealthcheckController.TmpAddress(
city = "세종특별시 아름동",
street = "654-321",
zipcode = 897,
),
)
val xxxRequest =
LinkedMultiValueMap(
mapOf(
"userId" to listOf(requestDto.userId.toString()),
"name" to listOf(requestDto.name),
"nickname" to listOf(requestDto.nickname),
"age" to listOf(requestDto.age.toString()),
"productName" to listOf(requestDto.productName),
"hobby" to listOf(requestDto.hobby.joinToString(",")),
"address.city" to listOf(requestDto.address.city),
"address.street" to listOf(requestDto.address.street),
"address.zipcode" to listOf(requestDto.address.zipcode.toString()),
),
)
+) Map<String!, String!>
params의 value 타입에 맞춰 작성해 주어야 한다. 이때 문제가 될만한 것으로는 list 타입의 hobby이다.
콤마 (,) 로 구분하여 string으로 변환하는 작업을 해준 뒤 params의 값으로 작성해야 한다.
// when
val result =
this.mockMvc.perform(
RestDocumentationRequestBuilders
.post("$domain/tmp2")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.params(xxxRequest)
.accept(MediaType.APPLICATION_JSON),
)
plugins {
id("org.asciidoctor.jvm.convert") version "3.3.2" // 1번
}
dependencies {
asciidoctorExt("org.springframework.restdocs:spring-restdocs-asciidoctor:2.0.5.RELEASE") // 2번
testImplementation("org.springframework.restdocs:spring-restdocs-mockmvc") // 3번
testImplementation("org.springframework.restdocs:spring-restdocs-asciidoctor") // 4번
}
tasks {
val snippetsDir by extra { file("build/generated-snippets") } // 5번
test { // 6번 : test Task 의 output 디렉토리 설정
outputs.dir(snippetsDir)
useJUnitPlatform()
}
asciidoctor { // asciidoctor Task 생성
configurations(asciidoctorExt.name)
inputs.dir(snippetsDir) // 7번 : asciidoctor Task 의 input 디렉토리 설정
dependsOn(test)
doLast { // 8번
copy {
from("build/docs/asciidoc")
into("src/main/resources/static/docs")
}
}
}
- 1번 : 플러그인 추가
- 2번 : asciidoctor 의존성 추가
- 3번 : spring rest docs 의존성 추가 (mockMvc)
- 4번 : spring rest docs 의존성 추가 (asciidoctor)
- 5번 : snippets 경로 설정
- 6번 : test Task의 output 디렉토리 설정
- 7번 : asciidoctor Task의 input 디렉토리 설정
- 8번 : 테스트 코드 실행 종료 후 파일 복붙하는 설정 (from 하위 파일을 into 폴더 하위로 복제한다)
[3] 테스트코드 작성하기
1. abstract class 작성
우아한 형제들 블로그를 참고하여 작성하였다.
package io.my.project
import com.fasterxml.jackson.databind.ObjectMapper
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.context.annotation.ComponentScan
import org.springframework.test.web.servlet.MockMvc
@SpringBootTest
@AutoConfigureRestDocs // REST Docs 설정
@AutoConfigureMockMvc
@ComponentScan(basePackages = ["io.gbike.hwikgo"]) // 컴포넌트 스캔 범위 지정
abstract class ApiDocumentationTest : BaseMockData() {
@Autowired
protected lateinit var mockMvc: MockMvc
@Autowired
protected lateinit var objectMapper: ObjectMapper
}
? SpringBootTest
여기서 `SpringBootTest` 대신 `WebMvcTest` 를 사용하여 빌드속도를 좀더 빠르게 할 수 있지만 테스트하려는 Controller 에 연결되어있는 많은 Service들과 Service에서 또 참고하는 유틸성 컴포넌트 등 일일이 주입해주어야 할 의존성이 너무 많은듯 하여 일단 SpringBootTest 어노테이션을 사용하였다.
### : MockMvc
spring REST Docs 문서화를 위해선 MockMvc 의 perform을 수행해야 한다. 동일하게 사용되는 객체이므로 abstract에서 생성했다.
2. 테스트 코드 작성
먼저 테스트 코드 작성을 위해 controller에 테스트 대상 코드를 작성해 주었다.
@RestController
class HealthcheckController() {
@GetMapping("/tmp")
fun tmp(requestDto: TmpRequestDto): TmpResponseDto {
val recommendEmail = requestDto.name + "@gmail.com"
val responseDto =
TmpResponseDto(
code = 200,
email = recommendEmail,
message = "hello, ${requestDto.nickname ?: "guest"}",
)
return responseDto
}
data class TmpRequestDto(
val userId: Int = 1,
val name: String = "",
val nickname: String = "",
val age: Int = 1,
val productName: String = "",
val hobby: MutableList<String> = mutableListOf(),
val address: TmpAddress = TmpAddress(city = "", street = "", zipcode = 0),
)
data class TmpAddress(
val city: String,
val street: String,
val zipcode: Int,
)
data class TmpResponseDto(
val code: Int,
val email: String,
val message: String = "",
)
}
그리고 나서 해당 controller 를 호출할 테스트 코드를 작성해 주었다. 이때 호출당시의 content-type 에 따라 사용하는 메소드가 달라진다.
- content-type : x-www-form-urlencoded
요청을 보내는 쪽에서 body가 `x-www-form-urlencoded` 타입일 경우 formParameters 를 사용한다.
해당 방식은 다음 글에서 다루도록 하겠다.
- content-type : application/json
요청을 보내는 쪽에서 body가 `application/json` 타입일 경우 requestFields 를 사용한다.
package io.myproject.controller
import io.myproject.ApiDocumentUtils.Companion.documentRequest
import io.myproject.ApiDocumentUtils.Companion.documentResponse
import io.myproject.ApiDocumentationTest
import org.junit.jupiter.api.Test
import org.springframework.http.MediaType
import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document
import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders
import org.springframework.restdocs.payload.PayloadDocumentation.*
import org.springframework.test.web.servlet.result.MockMvcResultMatchers
class HealthcheckControllerTest : ApiDocumentationTest() {
@Test
fun tmpTest() {
// given
val re =
HealthcheckController.TmpRequestDto(
userId = 1,
name = "tester1",
nickname = "apple",
age = 30,
productName = "잘팔리는필통",
hobby = mutableListOf("수영", "독서"),
address =
HealthcheckController.TmpAddress(
city = "서울시 강남구 역삼동",
street = "123-456",
zipcode = 123,
),
)
// when
val res =
this.mockMvc.perform(
RestDocumentationRequestBuilders
.get("$domain/tmp")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(re))
.accept(MediaType.APPLICATION_JSON),
)
res
.andExpect(MockMvcResultMatchers.status().isOk)
.andDo(
document(
"docs_json",
documentRequest,
documentResponse,
requestFields(
fieldWithPath("userId").description("유저Id").optional(),
fieldWithPath("name").description("이름").optional(),
fieldWithPath("nickname").description("닉네임").optional(),
fieldWithPath("age").description("나이").optional(),
fieldWithPath("productName").description("상품명").optional(),
fieldWithPath("hobby").description("취미").optional(),
fieldWithPath("address").description("주소").optional(),
fieldWithPath("address.city").description("도시").optional(),
fieldWithPath("address.street").description("도로명").optional(),
fieldWithPath("address.zipcode").description("우편번호").optional(),
),
responseFields(
fieldWithPath("code").description("code").optional(),
fieldWithPath("email").description("이메일").optional(),
fieldWithPath("message").description("메시지").optional(),
),
),
)
}
}
[4] 문서 생성하기
1. 테스트 실행 : adoc 파일 생성
먼저 작성한 테스트코드가 올바르게 실행되는지 확인한다. 테스트가 성공했다면 `build/generated-snippets` 에 테스트코드에서 지정한 documentdml identifier 이름의 폴더가 있고 그 아래에 사진과 같은 adoc 파일이 생성되어 있을 것이다.
- curl-request.adoc : curl 요청 형식으로 작성된 파일
- form-parameters : 본인의 테스트 코드 request body가 form-data 형식이어서 `formParameters`를 사용했기에 생성된 파일
- http-request : http 요청 파일
- http-response : http 응답 파일
- httpie-request : http client 요청 파일
- response-fields : 본인 테스트 코드 `responseFields`에 의해 생성된 파일
2. rest docs 포맷 설정 : index.adoc
테스트코드 실행으로 생성된 여러가지 adoc 파일을 활용해 rest docs를 구성해야 한다.
이제 프로젝트를 빌드하면 위의 adoc 파일의 내용이 index.adoc의 형식으로 index.html 파일로 변환된다. 또한 build.gradle.kts 에 작성했던 아래 설정으로 인해 `build/docs/asciidoc` 에 있는 파일이 `src/main/resources/static/docs` 로 복제 된다.