티스토리 뷰

반응형

스프링 시큐리티 완전 처음부터 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(...)

이 메서드는:

  • 로그인 요청이 들어오면
  • 가장 먼저 호출되는 메서드

여기서 우리가 한 일:

  1. request body(JSON) 읽기
  2. username / password 추출
  3. UsernamePasswordAuthenticationToken 생성
  4. 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. 여기까지 오면, 시큐리티에서 이걸 이해한 상태다

이번 편을 끝까지 이해했다면, 사실상 중급 입구다.

정리하면:

  1. 시큐리티 로그인은 필터에서 끝난다
  2. UsernamePasswordAuthenticationFilter 는 커스터마이징 가능하다
  3. AuthenticationManager / Provider / UserDetailsService 구조는 그대로 재사용된다
  4. formLogin 은 “옵션”일 뿐, 필수가 아니다
  5. 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준비, 백엔드개발, 개발블로그

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/04   »
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
글 보관함
반응형