티스토리 뷰
스프링 시큐리티 완전 처음부터 3편 (Kotlin)
formLogin 버리고, JSON 로그인 API 직접 만들기 (실무에서 쓰는 방식의 시작)
2편까지 따라왔다면, 여기까지는 이런 상태다.
- ✔ DB 기반 로그인 가능
- ✔ UserDetailsService / PasswordEncoder 구조 이해
- ✔ formLogin 으로 로그인 성공하면 세션에 인증 정보 저장됨
근데…
솔직히 말해서 실무에서는 거의 이렇게 안 쓴다.
요즘 대부분의 백엔드 구조는:
- 프론트엔드: React / Vue / Flutter / 앱
- 백엔드: REST API
- 로그인 요청: JSON
- 응답: JSON
- 화면 이동? → 프론트가 한다
즉,
“HTML 로그인 페이지 + formLogin”
이건 학습용이지, 실전용은 아니다.
그래서 이번 글에서는:
- ❌ formLogin 완전히 제거
- ✅ /api/login JSON 로그인 API 직접 구현
- ✅ 로그인 성공 / 실패 응답을 우리가 정의
- ✅ 시큐리티 필터를 하나 직접 만들어서 끼워 넣기
여기까지 오면
“아… 이제 진짜 실무 영역 들어왔구나”
라는 느낌이 온다.
✔ 이번 글에서 최종적으로 만들 구조
POST /api/login
Content-Type: application/json
{
"username": "test",
"password": "1234"
}
⬇
{
"message": "login success",
"username": "test"
}
그리고 내부 흐름은 이렇게 바뀐다.
JSON 로그인 요청
↓
CustomAuthenticationFilter (우리가 직접 만듦)
↓
AuthenticationManager
↓
DaoAuthenticationProvider
↓
UserDetailsService + PasswordEncoder
↓
인증 성공
↓
SecurityContextHolder 저장
↓
JSON 응답 반환
중요 포인트
👉 시큐리티는 여전히 그대로 쓰지만
👉 입구(filter)와 출구(response)만 우리가 제어한다.
0. formLogin 제거부터 하자 (진짜 중요)
일단 기존 SecurityConfig에서
이 줄부터 제거해야 한다.
.formLogin(Customizer.withDefaults())
이걸 남겨두면:
- /login 엔드포인트가 살아 있고
- HTML 로그인 페이지가 계속 뜬다
우리는 완전히 API 서버처럼 만들 거라서
formLogin 은 이제 필요 없다.
1. 로그인 요청 DTO 만들기 (JSON 매핑용)
JSON 요청을 받으려면,
당연히 DTO부터 있어야 한다.
📄 LoginRequest.kt
package com.example.demo.security.dto
data class LoginRequest(
val username: String,
val password: String
)
아주 단순하다.
@RequestBody 로 들어오는 JSON을 이걸로 받을 거다.
2. Custom Authentication Filter 만들기
이제 진짜 핵심.
스프링 시큐리티의 기본 로그인 필터는
UsernamePasswordAuthenticationFilter 다.
👉 우리는 이걸 상속해서
👉 JSON 로그인 전용 필터를 하나 만들 거다.
2-1. 왜 Filter를 직접 만드나?
시큐리티의 철학은 이거다.
“인증은 컨트롤러가 아니라 필터에서 끝내라.”
그래서:
- ❌ @PostMapping("/login") 컨트롤러에서 인증 처리 → 비추
- ✅ 필터에서 인증 → 정석
2-2. CustomAuthenticationFilter 코드
📄 CustomAuthenticationFilter.kt
package com.example.demo.security.filter
import com.example.demo.security.dto.LoginRequest
import com.fasterxml.jackson.databind.ObjectMapper
import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.security.authentication.AuthenticationManager
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.Authentication
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
class CustomAuthenticationFilter(
private val authenticationManager: AuthenticationManager,
private val objectMapper: ObjectMapper = ObjectMapper()
) : UsernamePasswordAuthenticationFilter() {
init {
// 기본 /login 말고 우리가 원하는 엔드포인트로 변경
setFilterProcessesUrl("/api/login")
}
/**
* 로그인 시도 시 호출되는 메서드
*/
override fun attemptAuthentication(
request: HttpServletRequest,
response: HttpServletResponse
): Authentication {
val loginRequest = objectMapper.readValue(
request.inputStream,
LoginRequest::class.java
)
val authToken = UsernamePasswordAuthenticationToken(
loginRequest.username,
loginRequest.password
)
return authenticationManager.authenticate(authToken)
}
/**
* 로그인 성공 시
*/
override fun successfulAuthentication(
request: HttpServletRequest,
response: HttpServletResponse,
chain: FilterChain,
authResult: Authentication
) {
response.contentType = "application/json"
response.characterEncoding = "UTF-8"
response.writer.write(
"""
{
"message": "login success",
"username": "${authResult.name}"
}
""".trimIndent()
)
}
/**
* 로그인 실패 시
*/
override fun unsuccessfulAuthentication(
request: HttpServletRequest,
response: HttpServletResponse,
failed: org.springframework.security.core.AuthenticationException
) {
response.status = HttpServletResponse.SC_UNAUTHORIZED
response.contentType = "application/json"
response.characterEncoding = "UTF-8"
response.writer.write(
"""
{
"message": "login failed",
"reason": "${failed.message}"
}
""".trimIndent()
)
}
}
2-3. 이 필터가 하는 일, 단계별 해석
① setFilterProcessesUrl("/api/login")
init {
setFilterProcessesUrl("/api/login")
}
- 기본 로그인 URL /login 대신
- POST /api/login 요청만 이 필터가 처리하게 만든다
② attemptAuthentication()
override fun attemptAuthentication(...)
이 메서드는:
- 로그인 요청이 들어오면
- 가장 먼저 호출되는 메서드
여기서 우리가 한 일:
- request body(JSON) 읽기
- username / password 추출
- UsernamePasswordAuthenticationToken 생성
- AuthenticationManager.authenticate() 호출
👉 이후 흐름은 2편에서 만든 구조 그대로다.
③ successfulAuthentication()
로그인 성공 시:
- 기본 시큐리티는 redirect 를 한다
- 우리는 JSON 응답을 내려준다
👉 이 부분이 “실무용 API” 느낌 나는 지점이다.
④ unsuccessfulAuthentication()
로그인 실패 시:
- 상태 코드 401
- 실패 이유 포함 JSON 응답
프론트엔드 입장에서는
이제 에러 분기 처리하기 딱 좋은 형태다.
3. SecurityConfig 에 Custom Filter 등록하기
이제 만든 필터를
시큐리티 필터 체인에 직접 끼워 넣어야 한다.
3-1. SecurityConfig 전체 코드 (JSON 로그인 버전)
📄 SecurityConfig.kt
package com.example.demo.config
import com.example.demo.security.CustomUserDetailsService
import com.example.demo.security.filter.CustomAuthenticationFilter
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.authentication.AuthenticationManager
import org.springframework.security.authentication.dao.DaoAuthenticationProvider
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.security.web.SecurityFilterChain
@Configuration
class SecurityConfig(
private val customUserDetailsService: CustomUserDetailsService
) {
@Bean
fun passwordEncoder() = BCryptPasswordEncoder()
@Bean
fun authenticationProvider(): DaoAuthenticationProvider {
val provider = DaoAuthenticationProvider()
provider.setUserDetailsService(customUserDetailsService)
provider.setPasswordEncoder(passwordEncoder())
return provider
}
@Bean
fun authenticationManager(
config: AuthenticationConfiguration
): AuthenticationManager = config.authenticationManager
@Bean
fun securityFilterChain(
http: HttpSecurity,
authenticationManager: AuthenticationManager
): SecurityFilterChain {
val customFilter = CustomAuthenticationFilter(authenticationManager)
http
.csrf { it.disable() }
.authorizeHttpRequests {
it.requestMatchers("/public/**", "/api/login").permitAll()
it.anyRequest().authenticated()
}
// formLogin 제거
.authenticationProvider(authenticationProvider())
.addFilter(customFilter)
return http.build()
}
}
3-2. 핵심 포인트 정리
✔ /api/login 은 permitAll
.requestMatchers("/public/**", "/api/login").permitAll()
로그인 API 자체는
당연히 인증 없이 접근 가능해야 한다.
✔ addFilter(customFilter)
.addFilter(customFilter)
이 한 줄이 의미하는 건 이거다.
“기본 UsernamePasswordAuthenticationFilter 대신
내가 만든 필터를 체인에 넣겠다”
이제 로그인은:
- HTML 폼 ❌
- /login ❌
- POST /api/login (JSON) ⭕
4. 실제 테스트 해보기
① 로그인 요청
POST /api/login
Content-Type: application/json
{
"username": "test",
"password": "1234"
}
② 성공 응답
{
"message": "login success",
"username": "test"
}
③ 실패 응답
{
"message": "login failed",
"reason": "Bad credentials"
}
5. 여기까지 오면, 시큐리티에서 이걸 이해한 상태다
이번 편을 끝까지 이해했다면, 사실상 중급 입구다.
정리하면:
- 시큐리티 로그인은 필터에서 끝난다
- UsernamePasswordAuthenticationFilter 는 커스터마이징 가능하다
- AuthenticationManager / Provider / UserDetailsService 구조는 그대로 재사용된다
- formLogin 은 “옵션”일 뿐, 필수가 아니다
- API 서버에서는 JSON 로그인 방식이 훨씬 자연스럽다
다음 글 예고 (4편)
JWT 토큰 발급 + 인증 필터 만들기 (진짜 실무 시큐리티 구조)
다음 편에서는:
- 로그인 성공 시 JWT 발급
- Access Token / Refresh Token 개념
- Authorization: Bearer xxx 구조
- JWT 검증 필터 만들기
- 세션 없는(stateless) 시큐리티 구성
- 모바일 / SPA 에서 쓰는 구조 완성
👉 여기까지 오면
“시큐리티 모른다”는 말, 더 이상 못 한다.
출처
- Spring Security 공식 문서
- Spring Boot 3.x Reference
- 실무 Kotlin + JWT 인증 서버 구현 경험 기반
스프링시큐리티, springsecurity, 코틀린, kotlin, json로그인, restapi, authenticationfilter, jwt준비, 백엔드개발, 개발블로그
'study > kotlin' 카테고리의 다른 글
| 스프링 시큐리티 완전 처음부터 5편 (Kotlin) (0) | 2025.12.19 |
|---|---|
| 스프링 시큐리티 완전 처음부터 4편 (Kotlin) (0) | 2025.12.17 |
| 스프링 시큐리티 완전 처음부터 2편 (Kotlin) (0) | 2025.12.12 |
| 📘 Kotlin 기본 문법 완전 정리 (0) | 2025.12.11 |
| 스프링 시큐리티 완전 처음부터 1편 (Kotlin 버전) (0) | 2025.12.11 |
- Total
- Today
- Yesterday
- REACT
- kotlin
- JAX
- rag
- CI/CD
- node.js
- LangChain
- 개발블로그
- 웹개발
- ai철학
- 백엔드개발
- Prisma
- PostgreSQL
- DevOps
- Docker
- SEO최적화
- NestJS
- 딥러닝
- Express
- llm
- 생성형AI
- JWT
- fastapi
- 쿠버네티스
- Next.js
- seo 최적화 10개
- flax
- Python
- nextJS
- nodejs
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 |
