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` 로 복제 된다.
패키지를 설치할 때 가능한 경우 압축된 아카이브 파일(preferred distribution package) 형태로 다운로드하도록 지시합니다. 이는 일반적으로 깃 리포지토리에서 소스 코드 전체를 클론하는 대신, 아카이브된 릴리즈 파일을 다운로드하여 설치 시간을 줄이고 네트워크 대역폭을 절약할 수 있습니다.
2. laravel sail 을 설치하고 실행하기
도커환경에서의 실행을 위해 sail 을 설치해준다.
cd example-app
composer require laravel/sail --dev
php artisan sail:install
이때 주의할 것은 정확한 laravel 버전을 입력해 주어야 한다는 것이다. 대충 검색해서 8버전때 아무거나 사용했다!
+) 여러개 php 버전 사용하기 feat. brew
laravel은 버전에 따라 지원하는 php 버전이 다르다. 각 상황에 맞게 php 버전도 관리해 주어야 하는데 내가 사용하고자 하는 laravel 버전 8의 경우 php 7.3 ~ 8.1 까지 지원한다고 한다. 현재 내 컴퓨터에는 8.2와 8.3이 설치되어 있으므로 추가로 8.1버전을 설치해 준다
brew install php@8.1
이때 패키지들을 brew 설치하면 좋은점이 나타난다.. 이제 brew list 로 설치된 php 목록을 보면 다음과 같다
php # 8.3.7
php@8.1
php@8.2
참고로 첫번째꺼는 버전명시 없이 설치했더니 이름에서 버전이 안적혀있다~
php@8.2를 제일 처음에 설치하고 이후 최신버전인 8.3.7을 설치해서 사용중이었으나 8.1버전을 다시 설치하였고, 앞으로 사용할 php의 버전을 8.1버전으로 하기 위해 brew 설정을 바꿔주었다.
brew unlink php
brew link php@8.1
먼저 unlink하면 뭔가를 해제했다고 뜬다.
그러면 환경변수에서도 설정을 변경해 주라는 안내문구가 나오고, 그대로 입력하면 된다.
이후 아까와 같이 현재 설정된 php 버전을 확인하면 변경된 것을 볼 수 있다!
+) artisan ?
docker를 이용한 프로젝트 실습에서 명령어를 날릴때 아래와 같이 입력했었다
vendor/bin/sail artisan {명령어}
라라벨 공식문에서에서는 artisan (아티즌) 이 라라벨에 포함된 커맨드라인 인터페이스(CLI)의 이름 이라고 알려준다!
4.php 내장 웹 서버로 서버 연결하기 : php artisan
프로젝트의 홈 디렉토리에서 아래의 명령어를 통해 서버와 연결해 준다.
php artisan serve
그럼 localhost 환경에서 서버가 실행된다.
해당 주소로 접속하면 빈 페이지가 뜰줄 알았지만 생각외로 멀쩡한 페이지가 노출된다!
이는 laravel 프로젝트를 설치하면서 다양한 view 도 생성되었기 때문이다.
laravel 프로젝트에서 route가 기록되는 routes > web.php 파일을 보면 아래와 같은 코드가 작성되어 있다.
<?php
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
return view('welcome');
});
하위 경로 없이 호출하게되면 welcome view를 return 한다는 것! welcome view는 resources > views > welcome.blade.php 에 작성되어 있다.
이를 통해 view의 경로가 resources > views 로 자동 매핑되는것을 알아차릴 수 있었다.
5. docker-compose 로 프로젝트 실행하기 : sail
4번에서 처럼 php 명령어로도 서버를 실행할수 있음에도 왜 docker로 해야할까? 그 이유는 php/laravel 프로젝트를 전체적으로 실행시키기 위해선 db연결도 필요하고, 이 전체적인 서버 연결을 보다 편리하게 하기 위해서이다
docker를 통해 php서버와 mysql 서버를 동시에 띄우고, 내리고 하면 편리하겠다! docker-compose를 통해 웹서버와 db서버를 동시에 띄울것이므로 docker-compose를 사용한다고 한다.
1) docker-desktop
먼저 docker-compose를 사용하기 위해서는 docker-desktop 어플리케이션 다운로드가 필요하다. 인터넷에서 검색 후 다운로드 해 주고, 아이콘을 클릭하여 running 모드로 해준다!
이후 laravel 프로젝트 내에서 docker 실행을 진행해 준다.
2) sail 의존성 추가해주기
Laravel의 기본 Docker 개발 환경과 상호 작용하기 위한 간단한 명령줄 인터페이스이다. 이를 프로젝트에서 사용하려면 먼저 composer 로 의존성을 추가해 주어야 한다.
composer require laravel/sail --dev
그러면 몇가지 폴더가 생성되는걸 확인할 수 있다.
이후로 sail 을 설치해 주는데 이때 어떤 db를 사용할 것인지 선택지가 뜬다. mysql을 사용하도록 결정!
laravel 11버전의 화면
laravel 8버전 화면
영상과 캡쳐본에서도 보다싶이 적용되는데 몇초 걸린다.. 이건아마도 컴퓨터 사양탓인듯 하다 ㅎㅎ
설치가 완료되면 명령어를 통해 실행해보라고 안내가 나오면서, 선택한 db를 실행해보라고도 뜬다!
3) docker로 프로젝트 실행하기
먼저 docker로 프로젝트를 실행해보도록 한다. 그 전에 언제 `./vendor/bin/sail` 을 다 치랴.. 진작에 alias로 해당 단어들을 sail 로 저장해 두었다 ㅎㅎ 그리고 도커 실행!
# 단축키 저장
vi ~/.zshrc
alias sail="./vendor/bin/sail"
# 변경된 환경변수 바로 적용
source ~/.zshrc
# docker 로컬에서 실행
sail up
# docker 백그라운드에서 실행
sail up -d
그러면 80포트로 서버가 실행되었다고 확인된다. 하지만 주의할 것은 아직 db세팅을 안해주었다는것! 실제로 해당 서버페이지를 접속하면 서버 연결을 확인할 수 있다.
오른쪽 아래에 사용중인 php와 laravel 버전을 확인할 수 있다.
+) laravel 11 버전 docker compose 실행시 db 오류
laravel 11버전이었을 때 sail 설치해주고 바로 실행했을때 다음과 같은 에러 페이지가 노출된다.
일단 서버가 실행되는것은 확인했고, 정상적인 실행을 위해 db migration을 적용해 테이블이 생성되도록 해준다.
그럼 laravel 프로젝트 설치 중 기본으로 추가된 패키지들에서 지원하는 세가지 테이블이 생성된다.
그리고 나서 다시 `localhost:80` 으로 접속하면 아래와 같이 정상적인 페이지를 확인할 수 있다. 아래 페이지는 아까 `php artisan serve` 로 실행했을때 확인한 페이지와 동일하다.
즉, `php artisan serve`를 통해 서버를 실행하게 되면 db 연결 없이 프로젝트를 실행하게 되는것이고, sail을 통해 실행하게 되면 db연결이 추가된다는 것을 알 수 있다. laravel 8버전에서는 안이랬는데 버전이 달라지면서 지원하는 것이 달라졌나보다!
이제 여러가지 기능을 추가하면서 view를 만들어 혼자서 하는 웹페이지 개발을 시작해보자!