Bootcamp/Fundamentals

Spring 숙련 - 1

개발자 오리 2026. 6. 24. 16:06

💡  오늘 학습 키워드

  • 인증/인가
  • 쿠키/세션
  • JWT
  • Filter
  • Spring Security
  • Validation

 

🎯  학습 내용 정리

1. 인증/인가

웹 서비스를 이용하다 보면 로그인은 너무나도 익숙한 기능이다. 하지만 로그인 과정에는 단순히 아이디와 비밀번호를 확인하는 것 이상의 개념이 포함되어 있다. 바로 인증과 인가이다.

특히 웹 애플리케이션은 HTTP의 특성상 사용자의 로그인 상태를 유지하기 어려운 환경에서 동작하기 때문에 이를 해결하기 위한 다양한 인증 방식이 등장했다.

 

인증과 인가는 비슷하게 들리지만 서로 다른 역할을 수행한다.

 

인증(Authentication)사용자가 실제로 본인이 맞는지를 확인하는 과정이다.

대표적인 예시는 다음과 같다.

  • 로그인 시 아이디와 비밀번호 확인
  • 스마트폰 지문 인식
  • 얼굴 인식
  • OTP 인증

즉, "당신이 누구인가?" 를 확인하는 과정이 인증이다.

 

인가(Authorization)인증된 사용자가 특정 자원에 접근할 수 있는 권한이 있는지 확인하는 과정이다.

예를 들어,

  • 관리자 페이지 접근 권한
  • 게시글 수정 권한
  • 회원 전용 기능 사용 권한

등이 인가에 해당한다.

즉, "당신이 이 기능을 사용할 수 있는가?" 를 확인하는 과정이다.

 

 

많은 사람들이 인증과 인가를 헷갈려한다.

로그인 자체를 인증과 인가가 모두 포함된 과정이라고 생각하기 때문인데, 실제로는 다음과 같이 구분된다.

  1. 아이디와 비밀번호를 입력하여 본인 여부를 확인한다.
    → 인증(Authentication)
  2. 로그인된 사용자의 역할(Role)이나 권한을 확인한다.
    → 인가(Authorization)

따라서 인증이 먼저 수행되고, 이후 인가가 이루어진다.

 

 

웹 애플리케이션은 일반 프로그램과 달리 서버와 클라이언트가 분리되어 있다.

또한 HTTP 프로토콜은 다음과 같은 특징을 가진다.

 

비연결성(Connectionless)

서버와 클라이언트는 요청과 응답이 끝나면 연결을 종료한다.

예를 들어 사용자가 웹 페이지를 요청하면 서버는 응답을 반환한 뒤 연결을 끊는다.

이는 서버 자원을 효율적으로 사용하기 위한 설계이다.

 

무상태(Stateless)

서버는 이전 요청의 상태를 기억하지 않는다.

예를 들어 사용자가 다음과 같은 요청을 보냈다고 가정해보자.

GET /login
POST /login
GET /mypage

서버는 /mypage 요청이 들어왔을 때 이전에 로그인했는지 알지 못한다.

모든 요청은 독립적으로 처리된다.

 

그렇다면 로그인 상태는 어떻게 유지될까?

우리는 실제로 인터넷을 사용할 때 로그인 상태가 계속 유지되는 것처럼 느낀다. 하지만 HTTP는 상태를 저장하지 않는다.

따라서 개발자는 별도의 방법을 사용하여 사용자가 인증된 상태라는 정보를 유지해야 한다.

 

대표적인 방법은 다음 두 가지이다.

  • 쿠키(Session) 기반 인증
  • JWT 기반 인증

 

 

쿠키-세션 방식서버가 로그인 상태를 직접 관리하는 방식이다.

즉, "이 사용자는 로그인한 사용자다"라는 정보를 서버가 저장한다.

 

 

쿠키-세션 방식의 동작 과정은 어떻게 될까?

 

1. 로그인 요청

사용자가 아이디와 비밀번호를 입력하여 로그인 요청을 보낸다.

 

2. 사용자 정보 검증

서버는 DB의 사용자 정보를 조회하여 아이디와 비밀번호를 검증한다.

 

3. 세션 생성

인증에 성공하면 서버는 세션 저장소에 로그인 정보를 저장한다.

 

4. Session ID 발급

서버는 사용자를 식별하기 위한 고유한 Session ID를 생성한다.

 

5. Session ID 전달

서버는 로그인 응답으로 Session ID를 클라이언트에게 전달한다.

 

6. 쿠키 저장

클라이언트는 전달받은 Session ID를 쿠키에 저장한다.

이후 모든 요청에 Session ID를 함께 전송한다.

 

7. 세션 검증

서버는 요청에 포함된 Session ID를 확인한다.

 

8. 로그인 여부 확인

세션 저장소에 해당 Session ID가 존재하면 로그인된 사용자로 판단한다.

 

9. 응답 반환

인증된 사용자에 맞는 데이터를 반환한다.

 

 

쿠키-세션 방식의 특징은 다음과 같다.

 

장점

  • 서버에서 인증 정보를 관리하므로 보안성이 상대적으로 높다.
  • 토큰 위변조 위험이 없다.

단점

  • 사용자가 많아질수록 세션 저장소의 부담이 증가한다.
  • 서버를 여러 대 운영할 경우 세션 공유가 필요하다.

 

JWTJSON Web Token의 약자사용자 정보를 암호화하여 토큰 형태로 저장하는 인증 방식이다.

쿠키-세션 방식과 가장 큰 차이점은 서버가 로그인 상태를 저장하지 않는다는 점이다.

 

JWT는 일반적으로 다음 세 부분으로 구성된다.

Header.Payload.Signature
  • Header : 토큰 정보
  • Payload : 사용자 정보
  • Signature : 위변조 검증 정보

 

JWT 기반 인증은 어떻게 동작하게 될까?

 

1. 로그인 요청

사용자가 로그인 요청을 보낸다.

 

2. 사용자 정보 검증

서버는 DB의 사용자 정보를 조회하여 인증을 수행한다.

 

3. JWT 생성

인증이 완료되면 사용자 정보를 기반으로 JWT를 생성한다.

 

4. JWT 반환

서버는 생성된 JWT를 클라이언트에게 전달한다.

 

5. 토큰 저장

클라이언트는 JWT를 저장한다.

보통 다음 위치 중 하나에 저장한다.

  • 쿠키
  • Local Storage
  • Session Storage

6. 요청 시 토큰 전달

이후 모든 요청에 JWT를 포함하여 서버에 전달한다.

일반적으로 HTTP Header에 담아 전송한다.

Authorization: Bearer {JWT}

 

7. 토큰 검증

서버는 JWT의 서명을 검증한다.

검증에 성공하면 사용자를 인증된 사용자로 판단한다.

 

8. 응답 반환

인증된 사용자에 맞는 데이터를 반환한다.

 

 

JWT 방식의 특징은 다음과 같다.

 

장점

  • 서버가 세션을 저장하지 않아도 된다.
  • 서버 확장에 유리하다.
  • 마이크로서비스 환경에서 활용하기 좋다.

단점

  • 토큰이 탈취되면 만료 전까지 사용할 수 있다.
  • 토큰 크기가 세션 ID보다 크다.
  • 로그아웃 처리와 토큰 관리가 상대적으로 복잡하다.

 

쿠키-세션과 JWT의 내용을 간단히 정리하면 다음과 같다.

구분 쿠키-세션 JWT
인증 정보 저장 위치 서버 클라이언트
서버 상태 관리 필요 불필요
확장성 상대적으로 낮음 높음
로그아웃 처리 쉬움 상대적으로 복잡
서버 부하 증가 가능 상대적으로 적음

 

각각의 내용은 아래에서 조금 더 자세하게 알아보자!

 

 

2. 쿠키 세션

웹 애플리케이션은 HTTP 프로토콜 위에서 동작한다. 하지만 HTTP는 기본적으로 무상태(Stateless) 프로토콜이기 때문에 사용자의 로그인 상태나 인증 정보를 기억하지 못한다.

 

이러한 문제를 해결하기 위해 등장한 개념이 바로 쿠키와 세션이다.

 

쿠키와 세션은 모두 HTTP 환경에서 사용자 상태를 유지(Stateful)하기 위해 사용되며, 서버가 클라이언트를 식별하고 인증 및 인가를 수행할 수 있도록 도와준다.

 

 

쿠키(Cookie)클라이언트에 저장되는 작은 데이터 파일이다.

서버가 클라이언트에게 전달하면 브라우저는 해당 정보를 저장하고, 이후 같은 서버에 요청할 때 쿠키 정보를 함께 전송한다.

 

즉, 쿠키는 클라이언트 측에 저장되는 상태 정보라고 볼 수 있다.

크롬 브라우저에서는 개발자 도구(F12)를 열고 Application → Storage → Cookies 메뉴를 통해 저장된 쿠키를 확인할 수 있다.

 

 

쿠키는 다음과 같은 정보로 구성된다.

  • Name : 쿠키를 구분하기 위한 고유 이름
  • Value : 쿠키에 저장되는 실제 값
  • Domain : 쿠키가 유효한 도메인
  • Path : 쿠키가 사용될 경로
  • Expires : 쿠키 만료 시간

브라우저는 만료 시간이 지나면 해당 쿠키를 자동으로 삭제한다.

 

 

세션(Session)서버가 클라이언트의 상태를 일정 시간 동안 유지하기 위해 사용하는 기술이다.

쿠키가 클라이언트에 데이터를 저장하는 방식이라면, 세션은 서버에 데이터를 저장하는 방식이다.

서버는 사용자를 구분하기 위해 고유한 세션 ID(Session ID)를 생성한다.

 

그리고 사용자 정보는 서버의 세션 저장소에 보관하고, 생성된 세션 ID만 클라이언트에게 전달한다.

클라이언트는 전달받은 세션 ID를 쿠키에 저장하고 이후 요청마다 함께 전송한다.

서버는 세션 ID를 통해 어떤 사용자인지 식별할 수 있다.

 

 

세션은 다음과 같은 순서로 동작한다.

 

1. 클라이언트 요청

사용자가 서버에 요청을 보낸다.

 

2. 세션 ID 생성

서버는 새로운 세션을 생성하고 고유한 세션 ID를 발급한다.

예시)

SESSIONID=12A345

 

3. 세션 ID 전달

서버는 응답 헤더에 세션 ID를 담아 클라이언트에게 전달한다.

 

4. 쿠키 저장

브라우저는 전달받은 세션 ID를 쿠키에 저장한다.

이를 세션 쿠키(Session Cookie)라고 부른다.

 

5. 이후 요청

클라이언트는 요청 시 세션 ID가 포함된 쿠키를 함께 전송한다.

 

6. 사용자 식별

서버는 전달받은 세션 ID를 확인하여 동일한 사용자임을 식별한다.

 

 

Spring에서는 Cookie 객체를 생성한 후 HttpServletResponse에 추가하여 쿠키를 생성할 수 있다.

public static void addCookie(String cookieValue, HttpServletResponse res) {
    try {
        cookieValue = URLEncoder.encode(cookieValue, "utf-8")
                .replaceAll("\\+", "%20");

        Cookie cookie = new Cookie("Authorization", cookieValue);
        cookie.setPath("/");
        cookie.setMaxAge(30 * 60);

        res.addCookie(cookie);
    } catch (UnsupportedEncodingException e) {
        throw new RuntimeException(e.getMessage());
    }
}

 

주요 코드를 살펴보자.

new Cookie("Authorization", cookieValue);

쿠키의 Name과 Value를 지정한다.

cookie.setPath("/");

모든 경로에서 사용할 수 있도록 설정한다.

cookie.setMaxAge(30 * 60);

30분 동안 쿠키를 유지한다.

res.addCookie(cookie);

생성한 쿠키를 응답에 포함시켜 브라우저에 전달한다.

 

 

Spring에서는 @CookieValue 을 사용하여 쿠키 값을 쉽게 읽을 수 있다.

@GetMapping("/get-cookie")
public String getCookie(
        @CookieValue("Authorization") String value) {

    return "getCookie : " + value;
}

쿠키 이름을 지정하면 해당 쿠키의 값을 자동으로 가져온다.

 

 

Servlet에서는 HttpSession을 제공하여 세션을 쉽게 생성하고 사용할 수 있다.

 

세션 생성 코드는 다음과 같다.

@GetMapping("/create-session")
public String createSession(HttpServletRequest req) {

    HttpSession session = req.getSession(true);

    session.setAttribute(
            "Authorization",
            "Robbie Auth"
    );

    return "createSession";
}

 

주요 코드를 살펴보자.

req.getSession(true)
  • 세션이 존재하면 기존 세션 반환
  • 세션이 없으면 새 세션 생성
session.setAttribute("Authorization", "Robbie Auth");

세션에 Name-Value 형태로 데이터를 저장한다.

생성된 세션의 ID는 브라우저 쿠키에 JSESSIONID라는 이름으로 저장된다.

 

 

세션 조회 코드는 다음과 같다.

@GetMapping("/get-session")
public String getSession(HttpServletRequest req) {

    HttpSession session = req.getSession(false);

    String value =
            (String) session.getAttribute("Authorization");

    return "getSession : " + value;
}

 

주요 코드를 살펴보자.

req.getSession(false)
  • 세션이 존재하면 반환
  • 세션이 없으면 null 반환
session.getAttribute("Authorization")

저장된 세션 데이터를 조회한다.

 

 

쿠키와 세션의 차이점을 정리해보면 다음과 같다.

구분 쿠키 세션
저장 위치 클라이언트 서버
저장 데이터 실제 데이터 세션 ID
보안성 상대적으로 낮음 상대적으로 높음
서버 부하 적음 증가 가능
데이터 크기 제한적 상대적으로 자유로움

쿠키는 클라이언트에 저장되기 때문에 관리가 쉽고 서버 부담이 적지만 보안에 취약할 수 있다.

반면 세션은 서버에 데이터를 저장하므로 보안성이 높지만 사용자가 많아질수록 서버 자원을 더 많이 사용하게 된다.

 

 

3. JWT

웹 애플리케이션에서 사용자를 인증하기 위해서는 로그인 상태를 유지할 방법이 필요하다. 전통적으로는 세션(Session)을 사용해 서버가 로그인 정보를 직접 저장했지만, 서버가 여러 대로 확장되면 세션 동기화 문제가 발생한다.

 

이러한 문제를 해결하기 위해 등장한 방식 중 하나가 JWT이다.

 

JWTJson Web Token의 약자사용자 정보를 JSON 형태로 담아 안전하게 전달하기 위한 토큰 기반 인증 방식이다. 서버는 로그인에 성공한 사용자 정보를 토큰으로 생성하여 클라이언트에게 전달하고, 이후 클라이언트는 요청마다 해당 토큰을 함께 전송한다. 서버는 토큰을 검증하여 사용자를 식별하기 때문에 별도의 세션 저장소 없이도 인증과 인가를 수행할 수 있다.

 

일반적으로 JWT는 쿠키나 브라우저 저장소(Local Storage, Session Storage)에 저장되며, API 요청 시 HTTP Header 또는 Cookie를 통해 서버에 전달된다.

 

그렇다면 왜 JWT를 사용할까?

 

서버가 한 대뿐인 환경에서는 세션 기반 인증 방식이 큰 문제가 되지 않는다. 사용자가 로그인하면 서버의 세션 저장소에 로그인 정보가 저장되고, 이후 요청 시 세션 ID를 통해 사용자를 식별할 수 있다. 하지만 서비스 규모가 커지면서 서버가 여러 대로 구성되면 상황이 달라진다.

 

각 서버는 서로 다른 세션 저장소를 가지고 있기 때문에 사용자가 로그인한 서버가 아닌 다른 서버로 요청이 전달될 경우 로그인 정보를 찾지 못하는 문제가 발생한다.

 

이를 해결하기 위해 다음과 같은 방법을 사용할 수 있다.

  • Sticky Session을 사용하여 특정 사용자의 요청을 항상 동일한 서버로 전달한다.
  • Redis와 같은 별도의 세션 저장소를 구축하여 모든 서버가 공유하도록 한다.

하지만 이러한 방식은 추가적인 인프라 관리 비용이 발생한다.

 

그래서 JWT가 등장하게 되었다. JWT는 로그인 정보를 서버가 아닌 클라이언트에 저장한다.

사용자가 로그인에 성공하면 서버는 사용자 정보를 JWT로 생성한 뒤 클라이언트에게 전달한다. 이후 클라이언트는 모든 요청에 JWT를 함께 전송하고, 서버는 토큰의 유효성을 검증하여 사용자를 식별한다.

즉, 로그인 상태를 서버가 저장하는 것이 아니라 클라이언트가 토큰 형태로 보관하는 방식이다.

 

이때 여러 서버가 존재하더라도 동일한 Secret Key만 공유하고 있다면 어느 서버에서든 JWT를 검증할 수 있다.

따라서 별도의 세션 저장소 없이도 인증 정보를 처리할 수 있으며, 서버 확장성이 크게 향상된다.

 

 

JWT의 특징은 다음과 같다.

장점

  • 서버가 로그인 상태를 저장하지 않아도 된다.
  • 동시 접속자가 많아져도 서버 부담이 상대적으로 적다.
  • 서버를 수평 확장하기 쉽다.
  • 프론트엔드와 백엔드가 서로 다른 도메인을 사용하는 환경에서 활용하기 좋다.
  • OAuth2 로그인과 같은 외부 인증 시스템에서 널리 사용된다.

단점

  • 구현이 세션 방식보다 복잡하다.
  • 토큰에 담는 정보가 많아질수록 네트워크 비용이 증가한다.
  • 발급된 토큰을 강제로 만료시키기 어렵다.
  • Secret Key가 유출되면 토큰 위조 위험이 발생한다.

 

 

JWT 인증 과정에 대해서 살펴보자.

1. 로그인

사용자가 아이디와 비밀번호를 입력하여 로그인 요청을 보낸다.

서버는 사용자 정보를 검증한 뒤 JWT를 생성한다.

JWT 내부에는 사용자 식별 정보와 권한 정보 등이 포함된다.

 

2. JWT 발급

생성된 JWT를 응답으로 반환한다.

실무에서는 JWT를 쿠키에 저장하는 경우가 많다.

Cookie cookie = new Cookie(AUTHORIZATION_HEADER, token);
cookie.setPath("/");

res.addCookie(cookie);

브라우저는 전달받은 JWT를 쿠키 저장소에 자동으로 저장한다.

 

3. API 요청

사용자는 이후 모든 요청에 JWT를 함께 전송한다.

서버는 요청에 포함된 쿠키에서 JWT를 추출한다.

public String getTokenFromRequest(HttpServletRequest req) {
    Cookie[] cookies = req.getCookies();

    if (cookies != null) {
        for (Cookie cookie : cookies) {
            if (cookie.getName().equals(AUTHORIZATION_HEADER)) {
                return cookie.getValue();
            }
        }
    }
    return null;
}

 

 

4. 토큰 검증

서버는 전달받은 JWT에 대해 다음 내용을 확인한다.

  • 토큰이 위조되지 않았는지 검증
  • 토큰 만료 여부 확인
  • 토큰 내부 사용자 정보 확인

검증에 성공하면 해당 사용자의 요청으로 인정하고 서비스를 제공한다.

 

 

JWT는 다음과 같은 세 부분으로 구성된다.

Header.Payload.Signature

예시는 다음과 같다.

xxxxx.yyyyy.zzzzz

각 영역은 점(.)으로 구분된다.

 

Header

토큰의 타입과 암호화 알고리즘 정보를 담는다.

{
  "alg": "HS256",
  "typ": "JWT"
}

 

Payload

실제 사용자 정보를 담는 영역이다.

{
  "sub": "1234567890",
  "username": "카즈하",
  "admin": true
}

사용자 ID, 권한, 이름 등의 정보를 저장한다.

이를 Claim이라고 부른다.

 

Signature

JWT가 위조되지 않았음을 검증하기 위한 영역이다.

HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret
)

 

Header와 Payload를 Secret Key를 사용해 서명한 값이다.

토큰 내용은 누구나 디코딩할 수 있지만, Secret Key가 없으면 Signature를 다시 생성할 수 없다.

따라서 토큰이 중간에 변조되었는지 검증할 수 있다.

 

 

그렇다면 JWT는 암호화된 데이터일까?

 

많은 사람들이 JWT를 암호화된 데이터라고 생각하지만, 일반적인 JWT는 암호화가 아니라 인코딩된 데이터이다. JWT의 Header와 Payload는 Base64 방식으로 인코딩되어 있기 때문에 누구나 내용을 확인할 수 있다.

따라서 비밀번호와 같은 민감한 정보는 JWT에 저장하면 안 된다.

 

JWT의 핵심은 정보를 숨기는 것이 아니라 Signature를 통해 위변조 여부를 검증하는 데 있다.

즉, JWT는 읽을 수는 있지만 수정할 수는 없는 Read Only 데이터라고 이해하면 된다.

 

 

4. Filter

웹 애플리케이션에서는 요청마다 반복적으로 수행해야 하는 작업들이 존재한다. 대표적으로 요청 URL 로깅, 인증(Authentication), 인가(Authorization), 보안 검사 등이 있다. 이러한 기능들을 모든 컨트롤러에 직접 작성한다면 중복 코드가 증가하고 유지보수가 어려워진다.

이러한 문제를 해결하기 위해 Servlet은 Filter 기능을 제공한다.

 

 

Filter클라이언트의 요청(Request)과 서버의 응답(Response)을 가로채어 공통 로직을 수행하는 컴포넌트이다.

Filter는 DispatcherServlet보다 앞단에서 동작하며, 요청이 컨트롤러에 도달하기 전 전처리 작업을 수행할 수 있고 응답이 클라이언트에게 전달되기 전 후처리 작업도 수행할 수 있다.

 

주로 다음과 같은 기능을 처리할 때 사용된다.

  • 요청 및 응답 로깅
  • 인증(Authentication)
  • 인가(Authorization)
  • CORS 처리
  • 인코딩 설정
  • 보안 관련 기능

특히 인증과 인가 로직을 Filter에 구현하면 비즈니스 로직과 보안 로직을 분리할 수 있다는 장점이 있다.

 

 

Filter는 하나만 존재하는 것이 아니라 여러 개가 연결된 체인(Chain) 형태로 동작한다. 클라이언트의 요청은 등록된 Filter들을 순서대로 통과한 뒤 Controller로 전달된다. 반대로 응답은 Controller에서 처리된 후 Filter들을 역순으로 통과하며 클라이언트에게 반환된다.

이를 Filter Chain이라고 한다.

Client
 ↓
Logging Filter
 ↓
Auth Filter
 ↓
Controller
 ↑
Response

각 Filter는 자신의 작업을 수행한 후 다음 Filter로 요청을 전달해야 한다.

chain.doFilter(request, response);

이 코드가 호출되지 않으면 다음 Filter 또는 Controller로 요청이 전달되지 않는다.

 

 

Filter의 가장 대표적인 활용 사례는 요청 로그를 남기는 것이다. 이를 위해 LoggingFilter를 구현할 수 있다.

 

LoggingFilter의 동작 과정은 다음과 같다.

  1. 클라이언트 요청 URL 조회
  2. URL 로그 출력
  3. 다음 Filter로 요청 전달
  4. Controller 처리 완료
  5. 응답 전 후처리 로그 출력

 

다음은 LoggingFilter의 코드이다.

@Slf4j(topic = "LoggingFilter")
@Component
@Order(1)
public class LoggingFilter implements Filter {

    @Override
    public void doFilter(
            ServletRequest request,
            ServletResponse response,
            FilterChain chain
    ) throws IOException, ServletException {

        HttpServletRequest httpServletRequest =
                (HttpServletRequest) request;

        String url = httpServletRequest.getRequestURI();

        log.info(url);

        chain.doFilter(request, response);

        log.info("비즈니스 로직 완료");
    }
}

 

주요 코드를 살펴보자.

log.info(url);

요청 URL을 기록하는 전처리 코드이다.

chain.doFilter(request, response);

다음 Filter 또는 Controller로 요청을 전달한다.

log.info("비즈니스 로직 완료");

비즈니스 로직 수행이 끝난 후 실행되는 후처리 코드이다.

 

 

실제 서비스에서는 특정 API에 접근하기 전에 인증 여부를 확인해야 한다. 이를 위해 AuthFilter를 구현할 수 있다.

@Component
@Order(2)
public class AuthFilter implements Filter {
}

Logging Filter 다음에 실행되도록 Order를 2로 지정한다.

 

 

URL 기반 인가 처리를 위해 먼저 요청 URL을 확인한다.

String url = httpServletRequest.getRequestURI();

회원가입이나 로그인 API는 인증 없이 접근 가능해야 한다.

if (url.startsWith("/api/user")
        || url.startsWith("/css")
        || url.startsWith("/js")) {

    chain.doFilter(request, response);
}

다음 URL은 인증을 생략한다.

  • 회원가입
  • 로그인
  • CSS 리소스
  • JavaScript 리소스

반면 나머지 API는 모두 인증 검사를 수행한다.

 

 

인증이 필요한 요청이라면 먼저 쿠키에서 JWT를 찾는다.

String tokenValue = jwtUtil.getTokenFromRequest(httpServletRequest);

JWT를 가져오는 메서드는 다음과 같다.

public String getTokenFromRequest(HttpServletRequest req) {

    Cookie[] cookies = req.getCookies();

    if (cookies != null) {
        for (Cookie cookie : cookies) {

            if (cookie.getName().equals(AUTHORIZATION_HEADER)) {

                return URLDecoder.decode(
                        cookie.getValue(),
                        "UTF-8"
                );
            }
        }
    }

    return null;
}

브라우저가 전달한 쿠키 목록을 조회한 후 JWT가 저장된 쿠키를 찾아 반환한다.

 

 

토큰이 존재하면 유효성을 검사한다.

if (!jwtUtil.validateToken(token)) {
    throw new IllegalArgumentException("Token Error");
}

검증 과정에서는 다음 내용을 확인한다.

  • 토큰 위변조 여부
  • 서명(Signature) 검증
  • 만료 시간 확인

검증에 실패하면 인증이 거부된다.

 

 

JWT 검증이 완료되면 토큰 내부 사용자 정보를 가져온다.

Claims info = jwtUtil.getUserInfoFromToken(token);

이후 사용자명을 기반으로 데이터베이스에서 사용자를 조회한다.

User user = userRepository.findByUsername(info.getSubject()).orElseThrow();

실제 사용자가 존재한다면 인증이 완료된다.

 

 

인증이 완료된 사용자 객체를 Request에 저장한다.

request.setAttribute("user", user);

그 후 다음 단계로 요청을 전달한다.

chain.doFilter(request, response);

Controller에서는 저장된 사용자 정보를 사용할 수 있다.

@GetMapping("/products")
public String getProducts(HttpServletRequest req) {

    User user = (User) req.getAttribute("user");

    System.out.println(user.getUsername());

    return "redirect:/";
}

이처럼 Filter에서 인증을 완료한 후 사용자 정보를 전달하면 Controller에서는 별도의 인증 처리 없이 현재 로그인한 사용자의 정보를 활용할 수 있다.

예를 들어 사용자가 등록한 상품 목록 조회, 주문 내역 조회, 마이페이지 조회 등의 기능을 구현할 수 있다.

 

 

Filter를 사용하면 공통 관심사를 비즈니스 로직과 분리할 수 있다. 만약 인증 로직을 모든 Controller마다 작성한다면 코드 중복이 심해지고 유지보수가 어려워진다. 반면 Filter를 사용하면 인증, 인가, 로깅 등의 기능을 한 곳에서 관리할 수 있다. 또한 모든 요청에 대해 일관된 정책을 적용할 수 있어 보안성과 유지보수성이 향상된다.

 

실무에서는 Spring Security가 내부적으로 수많은 Filter를 사용하여 인증과 인가를 처리한다. 따라서 Spring Security를 이해하기 위해서도 Filter의 동작 원리를 먼저 이해하는 것이 중요하다.

 

 

5. Spring Security

Spring Security인증과 인가를 포함한 다양한 보안 기능을 제공하는 Spring 기반 프레임워크이다. 웹 애플리케이션의 요청을 가로채어 사용자의 인증 여부와 권한을 검증하며, 보안 관련 로직을 체계적으로 관리할 수 있게 해준다.

 

Spring Boot 환경에서는 의존성만 추가하면 기본적인 보안 기능이 활성화된다.

implementation 'org.springframework.boot:spring-boot-starter-security'

Spring Security가 적용되면 모든 요청은 기본적으로 인증이 필요하며, 인증되지 않은 사용자는 로그인 페이지로 이동하게 된다.

 

 

Spring Security를 사용하기 위해서는 보안 설정 클래스를 작성해야 한다.

@Configuration
@EnableWebSecurity
public class WebSecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {

        http.csrf((csrf) -> csrf.disable());

        http.authorizeHttpRequests((authorizeHttpRequests) ->
                authorizeHttpRequests
                        .requestMatchers(
                                PathRequest.toStaticResources().atCommonLocations()
                        ).permitAll()
                        .anyRequest().authenticated()
        );

        http.formLogin(Customizer.withDefaults());

        return http.build();
    }
}

위 설정은 다음과 같은 역할을 수행한다.

  • 정적 리소스(css, js, image)는 누구나 접근 가능
  • 나머지 모든 요청은 인증 필요
  • 기본 Form Login 기능 사용
  • CSRF 보호 기능 비활성화

 

 

CSRFCross Site Request Forgery의 약자사이트 간 요청 위조 공격을 의미한다. 사용자가 로그인된 상태에서 악성 사이트에 접속하면 공격자가 의도한 요청이 서버로 전송될 수 있다. 쿠키 기반 인증 환경에서는 이러한 공격에 취약할 수 있기 때문에 Spring Security는 기본적으로 CSRF 방어 기능을 활성화한다.

하지만 REST API 기반 서버에서는 세션보다 JWT를 사용하는 경우가 많아 CSRF 위험이 상대적으로 낮기 때문에 아래와 같이 비활성화하는 경우가 많다.

http.csrf((csrf) -> csrf.disable());

 

 

 

Spring Security를 적용하면 별도의 로그인 페이지를 만들지 않아도 기본 로그인 화면이 제공된다.

기본 로그인 정보는 다음과 같다.

  • Username : user
  • Password : 애플리케이션 실행 시 로그에 출력

서버가 실행될 때마다 임시 비밀번호가 새롭게 생성된다.

 

 

Spring MVC에서는 모든 요청이 DispatcherServlet을 통해 Controller로 전달된다.

Client
   ↓
Filter
   ↓
DispatcherServlet
   ↓
Controller

Spring Security는 DispatcherServlet 이전에 위치한 Filter를 사용하여 인증과 인가를 처리한다.

사용자의 요청은 먼저 Security Filter를 통과해야 하며, 인증에 실패하면 Controller까지 도달하지 못한다.

 

 

Spring Security는 여러 개의 보안 필터를 체인 형태로 관리한다.

Client
   ↓
Security Filter Chain
   ↓
DispatcherServlet
   ↓
Controller

각 필터는 특정 역할을 담당한다.

  • 로그인 처리
  • JWT 검증
  • 권한 확인
  • 세션 관리
  • 예외 처리

이러한 필터들이 순서대로 실행되면서 애플리케이션의 보안을 책임진다.

 

 

Form Login 방식인증되지 않은 사용자가 보호된 URL에 접근했을 때 로그인 페이지를 제공하는 방식이다.

인증되지 않은 사용자
        ↓
보호된 URL 요청
        ↓
Spring Security
        ↓
로그인 페이지 반환

사용자가 로그인에 성공하면 세션에 인증 정보가 저장되고 이후 요청부터는 인증된 사용자로 인식된다.

 

 

Spring Security에서 로그인 요청을 처리하는 대표적인 필터UsernamePasswordAuthenticationFilter이다.

사용자가 아이디와 비밀번호를 제출하면 다음 과정을 거친다.

 

1. 로그인 요청 수신

username + password
        ↓
UsernamePasswordAuthenticationFilter

 

2. 인증 객체 생성

필터는 사용자가 입력한 정보를 기반으로 UsernamePasswordAuthenticationToken을 생성한다.

Authentication authentication =
        new UsernamePasswordAuthenticationToken(
                username,
                password
        );

 

3. AuthenticationManager에 인증 요청

생성된 인증 객체를 AuthenticationManager에게 전달한다.

UsernamePasswordAuthenticationToken
            ↓
AuthenticationManager

 

4. 인증 성공

인증이 완료되면 인증 객체(Authentication)가 생성되고 SecurityContext에 저장된다.

 

5. 인증 실패

인증에 실패하면 SecurityContext가 비워지고 예외가 발생한다.

 

 

Spring Security는 인증된 사용자 정보를 SecurityContext에 저장한다.

그리고 SecurityContextHolder를 통해 어디서든 현재 로그인한 사용자 정보를 조회할 수 있다.

SecurityContext context = SecurityContextHolder.createEmptyContext();

context.setAuthentication(authentication);

SecurityContextHolder.setContext(context);

인증이 완료되면 SecurityContextHolder 내부에 Authentication 객체가 저장된다.

 

 

Authentication현재 인증된 사용자를 나타내는 객체이다.

주요 구성 요소는 다음과 같다.

Authentication authentication

 

principal

사용자를 식별하는 정보

UserDetails

 

credentials

비밀번호와 같은 인증 정보

password

 

authorities

사용자가 가진 권한 정보

ROLE_USER
ROLE_ADMIN

권한 정보는 GrantedAuthority 형태로 관리된다.

@Override
public Collection<? extends GrantedAuthority> getAuthorities() {

    SimpleGrantedAuthority authority = new SimpleGrantedAuthority("ROLE_USER");

    return List.of(authority);
}

 

 

 

UserDetailsService사용자 조회를 담당하는 인터페이스이다.

Spring Security는 로그인 시 UserDetailsService를 호출하여 사용자 정보를 조회한다.

User user = userRepository.findByUsername(username)
        .orElseThrow(
            () -> new UsernameNotFoundException(username)
        );

조회된 사용자 정보는 UserDetails 객체로 변환된다.

return new UserDetailsImpl(user);

 

 

 

UserDetails인증이 완료된 사용자 정보를 담고 있는 객체이다.

Spring Security는 UserDetails를 기반으로 Authentication 객체를 생성한다.

일반적으로 프로젝트에서는 UserDetails를 직접 구현하여 사용한다.

public class UserDetailsImpl implements UserDetails {
    private final User user;
}

이를 통해 사용자 정보와 권한 정보를 자유롭게 확장할 수 있다.

 

 

Spring Security를 사용하지 않을 경우에는 Controller가 직접 로그인 로직을 수행해야 한다.

Client
   ↓
Controller
   ↓
Service
   ↓
DB

반면 Spring Security를 사용하면 인증 과정이 프레임워크 내부에서 처리된다.

Client
   ↓
Spring Security
   ↓
Controller
   ↓
Service
   ↓
DB

로그인 처리 과정은 다음과 같다.

 

1. 클라이언트 로그인 요청

POST /api/user/login
{
  "username": "user",
  "password": "1234"
}

 

2. AuthenticationManager 호출

UsernamePasswordAuthenticationFilter가 AuthenticationManager에게 인증을 요청한다.

 

3. UserDetailsService 호출

AuthenticationManager는 UserDetailsService를 호출하여 회원 정보를 조회한다.

 

4. 비밀번호 검증

사용자가 입력한 비밀번호와 DB에 저장된 암호화 비밀번호를 비교한다.

 

5. 인증 성공

Authentication 객체를 생성하여 SecurityContextHolder에 저장한다.

 

6. 세션 저장

인증 정보가 세션에 저장되어 로그인 상태가 유지된다.

 

7. 요청 처리

인증된 사용자만 Controller에 접근할 수 있다.

 

 

Spring Security는 수십 개의 필터를 체인 형태로 관리한다.

대표적으로 다음과 같은 필터들이 존재한다.

  • SecurityContextPersistenceFilter
  • UsernamePasswordAuthenticationFilter
  • BasicAuthenticationFilter
  • ExceptionTranslationFilter
  • FilterSecurityInterceptor

필터는 정해진 순서대로 실행되며, 각 단계에서 인증과 인가를 수행한다.

따라서 JWT 인증을 구현할 때도 원하는 위치에 커스텀 필터를 등록하여 인증 로직을 추가할 수 있다.

 

 

6. Validation

사용자 입력값은 항상 신뢰할 수 없기 때문에 서버에서는 데이터를 저장하거나 처리하기 전에 반드시 검증을 수행해야 한다. 특히 Java에서는 null 값에 접근할 경우 NullPointerException이 발생할 수 있으므로 데이터 검증은 안정적인 애플리케이션을 만들기 위한 필수 요소이다. Spring에서는 이러한 검증 과정을 보다 쉽게 처리할 수 있도록 Bean Validation 기능을 제공한다. 개발자는 다양한 애너테이션을 활용하여 검증 규칙을 선언적으로 정의할 수 있으며, 이를 통해 코드의 가독성과 유지보수성을 높일 수 있다.

 

 

Validation클라이언트로부터 전달받은 데이터가 애플리케이션에서 요구하는 조건을 만족하는지 확인하는 과정이다.

예를 들어 회원가입 기능을 구현할 때 다음과 같은 검증이 필요하다.

  • 아이디는 비어있으면 안 된다.
  • 이메일은 이메일 형식이어야 한다.
  • 가격은 양수여야 한다.
  • 문자열 길이는 특정 범위 내에 있어야 한다.

이러한 검증을 직접 코드로 작성할 수도 있지만, Spring에서는 Bean Validation을 통해 간단하게 처리할 수 있다.

 

 

Bean Validation객체의 필드에 검증 규칙을 선언하고 자동으로 검증할 수 있는 기능이다.

Spring Boot에서는 다음 의존성을 추가하여 사용할 수 있다.

implementation 'org.springframework.boot:spring-boot-starter-validation'

 

Bean Validation에서 자주 사용하는 Annotation은 다음과 같다.

@NotNull null 허용 불가
@NotEmpty null, 빈 문자열 허용 불가
@NotBlank null, 빈 문자열, 공백 문자열 허용 불가
@Size 문자열 길이 제한
@Max 최대값 제한
@Min 최소값 제한
@Positive 양수만 허용
@Negative 음수만 허용
@Email 이메일 형식 검증
@Pattern 정규표현식 검증

 

 

Bean Validation은 DTO 클래스에 Annotation을 추가하여 사용할 수 있다.

@Getter
public class ProductRequestDto {

    @NotBlank
    private String name;

    @Email
    private String email;

    @Positive(message = "양수만 가능합니다.")
    private int price;

    @Negative(message = "음수만 가능합니다.")
    private int discount;

    @Size(min = 2, max = 10)
    private String link;

    @Max(10)
    private int max;

    @Min(2)
    private int min;
}

각 필드에 필요한 검증 조건을 선언해 두면 Spring이 자동으로 검증을 수행한다.

예를 들어 다음과 같은 요청 데이터는 모든 조건을 만족한다.

{
  "name": "Student",
  "email": "Student@gmail.com",
  "price": 1234,
  "discount": -1234,
  "link": "54321",
  "max": 10,
  "min": 2
}

반대로 조건을 만족하지 않는 데이터가 들어오면 Validation 예외가 발생한다.

 

 

DTO에 선언한 Validation 규칙은 @Valid을 통해 실행할 수 있다.

@PostMapping("/validation")
@ResponseBody
public ProductRequestDto testValid(
        @RequestBody @Valid ProductRequestDto requestDto
) {
    return requestDto;
}

@Valid가 붙은 객체는 Controller에 진입하기 전에 Bean Validation 검증이 수행된다.

검증에 성공하면 정상적으로 메서드가 실행되고, 실패하면 Validation 예외가 발생한다.

 

 

실제 프로젝트에서는 회원가입 DTO에 Validation을 적용하는 경우가 많다.

@Getter
@Setter
public class SignupRequestDto {

    @NotBlank
    private String username;

    @NotBlank
    private String password;

    @Email
    @NotBlank
    private String email;

    private boolean admin = false;

    private String adminToken = "";
}

위 DTO는 다음 조건을 검증한다.

  • username은 비어있을 수 없다.
  • password는 비어있을 수 없다.
  • email은 비어있을 수 없으며 이메일 형식이어야 한다.

Controller에서는 @Valid를 추가하여 검증을 수행할 수 있다.

@PostMapping("/user/signup")
public String signup(@Valid SignupRequestDto requestDto) {

    userService.signup(requestDto);

    return "redirect:/api/user/login-page";
}

 

 

 

Validation 검증에 실패하면 예외가 발생한다. 회원가입 과정에서 검증에 실패했을 때 로그인 페이지가 아닌 회원가입 페이지로 이동시키고 싶다면 예외 처리가 필요하다.

이때 사용하는 객체가 BindingResult이다.

 

BindingResultValidation 과정에서 발생한 오류 정보를 저장하는 객체이다.

@Valid 바로 뒤에 파라미터로 선언하면 Validation 결과를 확인할 수 있다.

@PostMapping("/user/signup")
public String signup(
        @Valid SignupRequestDto requestDto,
        BindingResult bindingResult
) {

    List<FieldError> fieldErrors = bindingResult.getFieldErrors();

    if (fieldErrors.size() > 0) {

        for (FieldError fieldError : fieldErrors) {
            log.error(
                    fieldError.getField()
                    + " 필드 : "
                    + fieldError.getDefaultMessage()
            );
        }

        return "redirect:/api/user/signup";
    }

    userService.signup(requestDto);

    return "redirect:/api/user/login-page";
}

위 코드의 동작 과정은 다음과 같다.

  1. 회원가입 요청이 들어온다.
  2. @Valid가 DTO를 검증한다.
  3. 검증 실패 시 오류 정보가 BindingResult에 저장된다.
  4. 오류가 존재하면 회원가입 페이지로 이동한다.
  5. 오류가 없으면 회원가입을 진행한다.

 

 

BindingResult의 getFieldErrors() 메서드를 사용하면 발생한 모든 검증 오류를 확인할 수 있다.

List<FieldError> fieldErrors = bindingResult.getFieldErrors();

각 FieldError에는 다음 정보가 포함된다.

  • 어떤 필드에서 오류가 발생했는지
  • 어떤 검증 규칙을 위반했는지
  • 사용자 정의 메시지는 무엇인지

예를 들어 다음과 같은 로그를 출력할 수 있다.

username 필드 : 공백일 수 없습니다.
email 필드 : 올바른 이메일 형식이 아닙니다.
price 필드 : 양수만 가능합니다.

이를 활용하면 사용자에게 보다 친절한 오류 메시지를 제공할 수 있다.

 

 

Validation을 적용하면 다음과 같은 장점을 얻을 수 있다.

  • 잘못된 데이터의 저장을 방지할 수 있다.
  • NullPointerException과 같은 예외를 예방할 수 있다.
  • 비즈니스 로직 이전에 입력값을 검증할 수 있다.
  • 코드의 가독성과 유지보수성이 향상된다.
  • 중복된 검증 코드를 줄일 수 있다.

특히 회원가입, 로그인, 게시글 작성, 상품 등록과 같이 사용자 입력이 많은 기능에서는 Validation이 필수적이다.

 

 

📚  한줄 정리

  1. 인증은 사용자가 누구인지 확인하는 과정이고, 인가는 해당 사용자가 어떤 권한을 가지는지 확인하는 과정이다.
  2. 쿠키는 클라이언트에 상태 정보를 저장하는 기술이고, 세션은 서버에 사용자 정보를 저장한 뒤 세션 ID를 통해 사용자를 식별하는 기술이다.
  3. JWT는 사용자 정보를 토큰에 담아 클라이언트가 보관하고, 서버는 토큰의 유효성만 검증하여 인증과 인가를 수행하는 무상태(Stateless) 기반의 인증 방식이다.
  4. Filter는 클라이언트의 요청과 응답을 가로채 공통 로직을 처리하는 계층으로, 인증·인가·로깅과 같은 기능을 비즈니스 로직과 분리하여 효율적으로 관리할 수 있게 해주는 핵심 기술이다.
  5. Spring Security는 Filter Chain을 기반으로 사용자 인증과 권한 검증을 수행하며, 웹 애플리케이션의 보안을 표준화된 방식으로 구현할 수 있게 해주는 프레임워크이다.
  6. Validation은 잘못된 사용자 입력을 사전에 검증하는 기능이며, Spring에서는 Bean Validation과 @Valid를 통해 간편하게 구현할 수 있다.

'Bootcamp > Fundamentals' 카테고리의 다른 글

MSA 요약  (0) 2026.06.29
Spring 심화  (0) 2026.06.26
Spring 숙련 - 2  (0) 2026.06.25
Spring 입문 - 2  (0) 2026.06.23
Spring 입문 - 1  (0) 2026.06.22