0. 들어가며: 왜 우리는 사용자 인증에 대해 고민해야 하는가?

여기서는, 로그인, 사용자 관리 그리고 OAuth 2.0에 관한 여정과 공부 기록을 정리합니다. 이전의 저는 항상 "로그인? 그거 그냥 DB에 아이디랑 비밀번호 넣고 맞는지 확인하면 되는 거 아니야?"가 궁금했어요. 몇 번의 프로젝트를 통해 로그인 메소드를 발전시키면서, 그리고 비밀번호 유출을 통한 해킹 피해를 실제로 당해 보면서, 그 단순한 생각이 얼마나 위험한지 깨닫게 됐어요.

왜 사용자 인증에 대해 고민해야 할까요?

개발을 시작하면 누구나 한 번쯤 이렇게 생각해봅니다. 실제로 초기에 간단한 로그인 기능을 구현할 땐, DB에 유저 정보를 저장하고, 입력값과 비교하는 방식만으로 충분해 보이기도 하죠.

  • 하지만 로그인은 단순한 기능 이상입니다. 인증은 서비스 보안의 시작점이자, 전체 시스템 설계에서 가장 민감한 부분 중 하나입니다. 인증이 허술하면 어떤 보안 대책도 무너질 수 있습니다. 비밀번호 유출, 세션 탈취, 무단 접근 등 대부분의 공격은 이 취약점을 노립니다.

  • 또한 인증 시스템은 신뢰성과도 직결됩니다. 사용자는 자신의 정보가 안전하다는 믿음이 있어야 서비스를 계속 사용할 수 있습니다. 인증이 불안하면 사용자는 금세 등을 돌리게 됩니다.

  • 그리고 서비스가 성장할수록 요구사항은 복잡해집니다. 소셜 로그인, 외부 API 연동, 마이크로서비스 환경 등은 단순한 ID/PW 검증만으로는 대응할 수 없습니다. 결국, 보다 유연하고 안전한 인증 체계가 필요해집니다.

이 글은 단순한 DB 로그인부터 시작해서, Spring Security, 그리고OAuth 2.0을 통한 소셜 로그인까지 점진적으로 다룹니다. 각 단계에서 어떤 기술적, 보안적 문제에 부딪혔는지, 그 해결 과정을 코드까지 풀어보고자 합니다.

읽기 전에:

단순한 로그인 기능이 이렇게 복잡하고 고민할 게 많은지 몰랐어요. 그저 기술을 가져다 쓸 뿐이었던 제가 하나하나 구현 과정을 기록하다 보니 엄청난 고민거리와 공부 주제들이 튀어나왔습니다. 이 글은 약 2~3주 간의 시간 동안 작성했고, 4섹션에 달하는 분량을 가지게 됐어요. 때문에 중간중간 제가 놓친 부분이 많을 수도 있을 것 같아요. 이 글을 읽으신 분들 중 그런 부족한 부분을 발견하신 분들이 있다면, 바쁘신 와중에 댓글로 남겨 주시면 정말 감사하겠습니다!

1. 첫걸음: 가장 기본적인 사용자 정보 저장 및 로그인 구현 (No Spring Security)

여기서는 최소한의 기능을 가진 로그인 시스템을 구현합니다.

1.1. 사용자 정보 설계: users 테이블 스키마

사용자 정보를 저장할 가장 기본적인 테이블입니다. 최소한의 정보만 담아볼게요.

SQL

CREATE TABLE users (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    username VARCHAR(50) NOT NULL UNIQUE,
    password VARCHAR(255) NOT NULL, -- 비밀번호는 나중에 해싱된 값이 들어갈 것이므로 넉넉하게
    email VARCHAR(100) UNIQUE,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
id: 각 사용자를 고유하게 식별하는 기본 키입니다. AUTO_INCREMENT로 자동 생성되게 합니다.

username: 사용자가 로그인할 때 사용할 아이디입니다. NOT NULL과 UNIQUE 제약 조건을 통해 중복을 방지합니다.

password: 사용자의 비밀번호가 저장될 공간입니다.

email: 사용자의 이메일 주소입니다. 이메일도 UNIQUE로 두어 중복 가입을 방지할 수 있습니다.

created_at: 사용자가 가입한 시간을 기록합니다.

고민할 부분: 비밀번호 저장 방식 (평문 vs. 해싱)

여기서 가장 중요한 보안 취약점이 드러납니다. 만약 password 컬럼에 사용자가 입력한 비밀번호를 그대로 저장 (평문 저장) 한다면 어떻게 될까요?

  1. DB 유출 시 대참사: 만약 해커가 DB에 접근하여 users 테이블을 통째로 털어간다면, 모든 사용자의 아이디와 비밀번호가 그대로 노출됩니다. 사용자들은 보통 여러 사이트에서 동일한 비밀번호를 사용하기 때문에, 여러 플랫폼에서 줄줄이 해킹 피해에 노출됩니다.

  2. 관리자의 위험: DB에 직접 접근 권한이 있는 개발자관리자도 사용자 비밀번호를 알 수 있게 됩니다.

정보통신망이용촉진및정보보호등에관한법률
제3조(정보통신서비스 제공자 및 이용자의 책무) ① 정보통신서비스 제공자는 이용자를 보호하고 건전하고 안전한 정보통신서비스를 제공하여 이용자의 권익보호와 정보이용능력의 향상에 이바지하여야 한다.

개인정보 보호법
제34조의2(노출된 개인정보의 삭제ㆍ차단) ① 개인정보처리자는 고유식별정보, 계좌정보, 신용카드정보 등 개인정보가 정보통신망을 통하여 공중(公衆)에 노출되지 아니하도록 하여야 한다. <개정 2023. 3. 14.>

비밀번호를 평문으로 저장하는 것은 절대 금지입니다. 비밀번호를 저장할 때는, 반드시 단방향 해시 함수(One-way Hash Function)를 사용해야 합니다.

  • 해싱(Hashing): 원본 문자열(비밀번호)을 고정된 길이의 다른 문자열(해시 값)로 변환하는 작업입니다. 중요한 것은 '단방향'이라는 점입니다. 즉, 해시 값을 가지고 원본 비밀번호를 역추적하는 것이 거의 불가능해야 합니다.

  • 왜 필요한가? DB가 유출되어도 해시 값만으로는 원본 비밀번호를 알 수 없으므로, 사용자 정보를 보호할 수 있습니다.

  • 어떤 함수를 쓸까? MD5, SHA-1 같은 초창기 해시 함수들은 이미 취약점이 발견되어 사용하지 않습니다. 현대에는 BCrypt, Scrypt, Argon2와 같이 무차별 대입 공격(Brute-force attack)에 강하도록 설계된 비밀번호 전용 해싱 함수를 사용해야 합니다. 이 글에서는 나중에 Spring Security와 함께 BCrypt를 사용할 예정입니다.

1.2. 회원가입 구현

간단한 웹 애플리케이션 (예: JSP/Servlet, 또는 최소한의 Spring MVC)을 가정하고, 회원가입 기능을 구현해보겠습니다.

API Endpoints: /signup (POST)

Java

// 예시 코드
public class SignupController {

    private UserRepository userRepository = new UserRepository();

    public void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException {
        String username = request.getParameter("username");
        String password = request.getParameter("password");
        String email = request.getParameter("email");

        if (username == null || username.isEmpty() || password == null || password.isEmpty()) {
            response.sendError(HttpServletResponse.SC_BAD_REQUEST, "아이디와 비밀번호를 입력해주세요.");
            return;
        }

        if (userRepository.findByUsername(username) != null) {
            response.sendError(HttpServletResponse.SC_CONFLICT, "이미 존재하는 아이디입니다.");
            return;
        }

        User newUser = new User(username, password, email); // 현재는 평문 저장 상태
        userRepository.save(newUser);

        response.setStatus(HttpServletResponse.SC_CREATED);
        response.getWriter().write("회원가입 성공");
    }
}

// User (간단한 Entity)
public class User {
    private Long id;
    private String username;
    private String password;
    private String email;

    public User(String username, String password, String email) {
        this.username = username;
        this.password = password;
        this.email = email;
    }
    // Getter, Setter 생략
}

// UserRepository
public class UserRepository {
    // 실제 DB 연결 및 JDBC 등 생략. 여기서는 간단히 메모리에 저장하는 방식 가정.
    private static Map<String, User> usersDb = new HashMap<>();
    private static Long nextId = 1L;

    public void save(User user) {
        user.setId(nextId++);
        usersDb.put(user.getUsername(), user);
        System.out.println("회원가입 완료: " + user.getUsername());
    }

    public User findByUsername(String username) {
        return usersDb.get(username);
    }

    // 실제로는 JDBC/JPA 등을 통해 DB와 연동
}

현재는 비밀번호가 평문으로 저장되고 있습니다. 다음 단계에서 더 보완할 예정이에요.

설명:

  • 사용자로부터 username, password, email을 입력받습니다.
  • 입력 값의 유효성 검사를 수행합니다 (예: null이거나 비어있는지). 실제로는 비밀번호 길이, 복잡성, 이메일 형식 등 더 복잡한 검사가 필요합니다.
  • 아이디 중복 확인을 통해 동일한 아이디로 여러 명이 가입하는 것을 막습니다.
  • User 객체를 생성하여 UserRepository를 통해 데이터베이스에 저장합니다.

1.3. 로그인 구현

Java

// 예시
public class LoginController {

    private UserRepository userRepository = new UserRepository();

    public void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException {
        String username = request.getParameter("username");
        String password = request.getParameter("password");

        // 입력값 유효성 검증
        if (username == null || username.isEmpty() || password == null || password.isEmpty()) {
            response.sendError(HttpServletResponse.SC_BAD_REQUEST, "아이디와 비밀번호를 입력해주세요.");
            return;
        }

        // 사용자 존재 여부 확인
        User user = userRepository.findByUsername(username);
        if (user == null) {
            response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "아이디 또는 비밀번호가 일치하지 않습니다.");
            return;
        }

        // 비밀번호 일치 여부 확인 (평문 비교)
        // 실제 운영에서는 절대 이렇게 평문 비교하면 안 됩니다!
        if (!user.getPassword().equals(password)) {
            response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "아이디 또는 비밀번호가 일치하지 않습니다.");
            return;
        }

        // 인증 성공
                response.setStatus(HttpServletResponse.SC_OK);
        response.getWriter().write("로그인 성공: " + user.getUsername());
    }
}

설명:

위 코드는 아래와 같은 과정을 거치고 있습니다.

  • 사용자의 username, password 입력값 검증
  • 사용자 존재 여부 확인
  • 평문 비밀번호 비교
  • 인증 성공 시 로그인 응답 반환

현재 구현은 HttpSession 사용 없이 단순 응답만 반환하고 있지만, 일반적인 웹 애플리케이션에서는 세션에 사용자 정보를 저장해 상태를 유지합니다:

HttpSession session = request.getSession();
session.setAttribute(\"loggedInUser\", user.getUsername());
session.setMaxInactiveInterval(30 * 60); // 30분

고민할 부분: 세션/JWT(토큰)이 없다면?

  • 사용자가 어떤 페이지에 접근할 때마다, 혹은 어떤 기능을 사용할 때마다 아이디와 비밀번호를 다시 입력하여 인증을 받아야 합니다.
  • 웹 서비스가 여러 대의 서버로 확장될 때, 세션을 특정 서버에만 저장하면 다른 서버에서는 해당 세션을 인식하지 못하는 문제가 발생합니다.
  • 이러한 문제들 때문에, 인증이 성공하면 그 상태를 일정 시간 유지할 수 있는 세션이나 토큰 등의 방식을 사용합니다.

1.4. 문제점 및 한계

지금까지의 기초적인 로그인 구현 방식의 문제점을 정리하자면 다음과 같습니다.

치명적인 보안 취약점

  • 평문 비밀번호 저장: 가장 큰 문제입니다. DB가 유출되면 사용자 비밀번호가 그대로 노출됩니다.

  • 취약한 비밀번호 비교: 해싱을 사용하지 않은 비밀번호 비교 방식은 무차별 대입 공격에 매우 취약합니다.

  • 세션 보안 취약점: HttpSession을 사용하는 경우, 세션 ID가 노출되면 세션 하이재킹 공격에 노출됩니다. (예: 네트워크 스니핑으로 세션 ID 가로채기, XSS 공격을 통해 세션 ID 탈취)

  • CSRF(Cross-Site Request Forgery)취약점: 사용자가 로그인된 상태에서 악성 웹사이트에 접속하면, 해당 웹사이트가 사용자의 권한으로 서비스에 원치 않는 요청을 보낼 수 있습니다.

위의 보안 취약점에 관한 레퍼런스:

낮은 확장성 및 유지보수성

  • 인증 로직 분산: 로그인 인증 로직이 컨트롤러에 직접 구현되어 있어, 모든 인증이 필요한 API마다 이 로직을 반복하거나 복사해야 합니다. 코드가 중복되고 유지보수가 어려워집니다.

  • 권한 관리 어려움: 특정 기능(예: 관리자 페이지)에 대한 접근 권한을 제한하려면, 해당 도메인의 모든 컨트롤러/메소드에서 직접 사용자의 권한을 확인하는 코드를 추가해야 합니다. 또한 새로운 권한이 추가될 때마다 모든 관련 코드를 수정해야 합니다.

  • 코드 중복: 비밀번호 해싱, 세션 관리 등 보안 관련 코드가 필요할 때마다 직접 구현하거나 여기저기 흩어져 존재하게 됩니다.

  • 성능 및 효율성 저하: 매번 수동으로 사용자 인증 및 권한을 확인하는 것은 불편합니다.

결론

위 방식은 소규모 개인 프로젝트나 학습용으로는 가능하지만, 실제 운영되는 서비스에서는 보안과 유지보수 측면에서 심각하게 부적합합니다.

다음 섹션에서는 Spring Framework의 도움을 받아 사용자 관리 로직을 좀 더 모듈화하고 구조적으로 개선하는 방법에 대해 알아보겠습니다. Spring의 의존성 주입, 제어 역전 컨테이너를 활용해 보겠습니다.

++ 보안 관련 추가 내용

세션 하이재킹:

HttpSession을 사용하는 구조에서는 브라우저의 쿠키에 저장된 JSESSIONID가 세션을 식별하는 키가 된다. 이 값이 노출되면 공격자가 사용자의 세션을 탈취해 인증된 사용자처럼 동작할 수 있다. 특히 HTTPS를 적용하지 않았거나, XSS를 통해 쿠키를 탈취당한 경우 이 위험이 커진다.

구체적인 내용: https://blog.naver.com/wnrjsxo/221114275533


2. Spring Framework와 함께하는 사용자 관리: Custom User 객체와 Service

앞선 섹션에서 우리는 가장 기본적인 로그인 구현 방식이 가진 문제점들을 명확하게 인지했습니다. 특히, 인증 로직이 컨트롤러에 직접적으로 섞여 있어 유지보수가 어렵고 확장성이 떨어지는 문제를 보았죠. 이제부터는 Spring Framework의 기능들을 활용하여 이 문제들을 해결해나갈 차례입니다.

Spring FrameworkDI(Dependency Injection)IoC(Inversion of Control) 컨테이너를 통해 코드의 모듈화와 재사용성을 극대화할 수 있도록 돕습니다. 이번 섹션에서는 Spring의 이러한 특징을 활용하여 사용자 관련 로직을 더 깔끔하고 효과적으로 관리하는 방법을 알아볼게요.

목표

  • SpringDI, IoC를 활용하여 사용자 관련 로직을 모듈화하고 기본적인 구조 개선

  • Custom User 객체와 Service 계층을 통해 비즈니스 로직을 캡슐화

2.1. 사용자 엔티티/도메인 모델 개선

Spring 기반 애플리케이션에서는 데이터베이스의 users 테이블과 매핑되는 Entity 또는 Domain모델을 정의합니다. JPA(Java Persistence API)를 사용한다면 @Entity 어노테이션을 활용할 수 있습니다.

Java

// src/main/java/com/example/domain/user/entity/User.java
package com.example.domain.user.entity;

@Entity // JPA 엔티티로 선언
@Table(name = "users") // 테이블 이름 users로 매핑
@Getter // 모든 필드에 대한 getter 생성
@NoArgsConstructor(access = AccessLevel.PROTECTED) // JPA를 위한 기본 생성자 자동 생성
@AllArgsConstructor // 전체 필드를 인자로 받는 생성자 자동 생성 (주의해서 사용할 것)
@ToString(exclude = "password") // 비밀번호는 toString에 포함되지 않도록 설정
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY) // ID 생성 로직은 DB에 위임
    private Long id;

    @Column(nullable = false, unique = true, length = 50)
    private String username;

    @Column(nullable = false)
    private String password;

    @Column(unique = true, length = 100)
    private String email;
}

보충: Lombok을 사용하는 이유

  • @Getter: 모든 필드의 getter 메서드를 자동 생성
  • @NoArgsConstructor(access = PROTECTED): JPA가 객체를 리플렉션으로 생성할 수 있도록 기본 생성자 제공
  • @AllArgsConstructor: 생성자 코드 반복 방지 (단, 서비스 계층에서 사용하는 생성자만 명시적으로 제공하는 편이 더 안전할 수 있음)
  • @ToString(exclude = \"password\"): 로그 출력 시 비밀번호 노출 방지

Lombok을 적용하면 반복되는 보일러플레이트 코드를 줄일 수 있지만, 팀 차원에서 도입 여부는 협의가 필요합니다. 또한 빌더 패턴이 필요한 경우 @Builder도 선택적으로 활용할 수 있습니다.

# 이 부분에 대해서는 아래의 블로그 글을 참고하면 좋습니다:

https://velog.io/@code-10/%EB%A1%AC%EB%B3%B5-AllNoArgsConstructor-%EC%A0%9C%EB%8C%80%EB%A1%9C-%EC%95%8C%EA%B3%A0-%EC%82%AC%EC%9A%A9%ED%95%B4%EB%B3%B4%EC%9E%90

설명:

  • @Entity: 이 클래스가 JPA 엔티티임을 나타냅니다.
  • @Table(name = "users"): 이 엔티티가 users 테이블과 매핑됨을 명시합니다.
  • @Id, @GeneratedValue: id 필드를 기본 키로 설정하고, 데이터베이스에 의해 자동으로 값이 생성되도록 합니다 (여기서는 MySQLAUTO_INCREMENT에 해당하는 IDENTITY 전략 사용).
  • @Column: 필드와 컬럼의 속성을 정의합니다. nullable = falseNOT NULL, unique = trueUNIQUE 제약 조건을 의미합니다.
  • 생성자: JPA는 기본 생성자를 필요로 합니다. 여기서는 lombok을 통해 별도의 생성자 메소드 없이 보일러플레이트 코드를 해결했습니다.

💡 고민할 부분: 비밀번호 필드를 String으로 두는 것의 문제점

현재 User 객체에서 password 필드는 단순 String 타입입니다. 이 자체로는 큰 문제가 없어 보이지만, 객체 지향적인 관점에서 보면 "비밀번호"라는 중요한 속성이 단순히 문자열로만 표현되는 것은 잠재적인 문제가 몇 가지 있습니다.

  • 불변성 보장 어려움: String은 불변 객체지만, User 객체 내에서 password를 직접 노출시키고 setter를 제공한다면, 예상치 못한 곳에서 비밀번호 값이 변경될 가능성이 생깁니다.

  • 타입 안정성 부족: 비밀번호는 해시된 값이어야 하는데, String 타입만으로는 이 필드가 "해시된 비밀번호"인지 "평문 비밀번호"인지 명확히 알 수 없습니다.

  • 비즈니스 로직 분리 어려움: 비밀번호와 관련된 유효성 검사, 해싱/비교 로직 등이 User 객체 자체에 포함되기 어렵습니다.

더 나아가면 Password라는 별도의 값 객체(Value Object)를 생성하여 비밀번호의 불변성을 보장하고, 해싱 로직을 해당 값 객체 내부에 포함하는 것도 고려해볼 수 있습니다. 하지만 이 글의 목적상 복잡성을 증가시키므로, 현재는 String으로 유지하되 이러한 고민이 필요하다는 점만 짚고 넘어가겠습니다.

Password 관리를 위해 참고하기 좋은 글:

https://velog.io/@ssh8560/Spring-Security-%EB%B9%84%EB%B0%80%EB%B2%88%ED%98%B8-%EA%B4%80%EB%A6%AC

2.2. 사용자 서비스 (UserService) 구현

Spring 애플리케이션에서는 비즈니스 로직을 서비스 계층에서 처리하는 것이 일반적입니다. 서비스 계층은 컨트롤러와 데이터 접근 계층(Repository) 사이에서 중간 다리 역할을 합니다.

UserRepository 정의

Java

package com.example.domain.user.repository;

import com.example.domain.user.entity.User;
import org.springframework.data.jpa.repository.JpaRepository; 
import java.util.Optional;

public interface UserRepository extends JpaRepository<User, Long> {
    // 사용자 이름으로 User 객체를 찾는 메서드 (Spring Data JPA가 자동으로 구현)
    Optional<User> findByUsername(String username);
    // Optional<User>은 User 객체가 있을 수도 있고 없을 수도 있음을 나타냅니다.
    // .orElse(null) 이나 .isPresent() 등으로 처리해야 합니다.
}
  • JpaRepository<User, Long>: Spring Data JPA의 인터페이스를 상속받으면, 기본적인 CRUD(Create, Read, Update, Delete) 메서드들을 자동으로 제공받습니다. 첫 번째 제네릭 타입은 엔티티 클래스, 두 번째는 엔티티의 ID 타입입니다.

  • findByUsername(String username): Spring Data JPA의 쿼리 메서드 기능입니다. 메서드 이름 규칙에 따라 username 필드를 기준으로 User 객체를 찾아주는 쿼리를 자동으로 생성해줍니다.

  • 반환 타입으로 Optional을 사용하면 null 처리를 명확하게 할 수 있습니다.

UserService 구현

이제 이 UserRepository를 사용하여 비즈니스 로직을 담을 UserService를 구현합니다.

Java

// import 생략

@Service // Spring Bean으로 등록
@RequiredArgsConstructor // lombok 생성자 어노테이션
public class UserService {

    private final UserRepository userRepository; // 불변성을 위해 final 선언

    @Transactional // 메서드 실행 중 예외 발생 시 롤백 (데이터 무결성 보장)
    public User signUp(String username, String password, String email) {
        // 1. 유효성 검사
        if (username == null || username.isEmpty() || password == null || password.isEmpty()) {
            throw new IllegalArgumentException("아이디와 비밀번호는 필수 입력 값입니다.");
        }

        // 2. 아이디 중복 확인
        if (userRepository.findByUsername(username).isPresent()) {
            throw new IllegalArgumentException("이미 존재하는 아이디입니다.");
        }

        // 3. 비밀번호 처리 (아직 평문 저장 가정)
        User newUser = new User(username, password, email);
        return userRepository.save(newUser);
    }

    @Transactional(readOnly = true) // 읽기 전용 트랜잭션 (성능 최적화)
    public User login(String username, String password) {
        // 1. 사용자 존재 여부 확인
        User user = userRepository.findByUsername(username)
                .orElseThrow(() -> new IllegalArgumentException("아이디 또는 비밀번호가 일치하지 않습니다."));

        // 2. 비밀번호 일치 여부 확인 (평문 비교)
        if (!user.getPassword().equals(password)) {
            throw new IllegalArgumentException("아이디 또는 비밀번호가 일치하지 않습니다.");
        }

        return user; // 로그인 성공
    }

    @Transactional(readOnly = true)
    public User getUserById(Long id) {
        return userRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다."));
    }
}

설명:

@Service: 이 클래스를 Spring의 서비스 계층 컴포넌트(Bean)로 등록하여, 다른 컴포넌트에서 주입받아 사용할 수 있게 합니다.

생성자 주입: private final UserRepository userRepository;와 생성자(lombok@RequiredArgsConstructor)를 통해 UserRepository 인스턴스를 주입받습니다. 이것이 바로 Spring의 의존성 주입(DI) 메커니즘입니다. UserService는 자신이 필요한 UserRepository를 직접 생성하지 않고, Spring 컨테이너로부터 주입받습니다.

@Transactional: 이 어노테이션을 통해 메서드 실행 중 데이터베이스 작업에 대한 트랜잭션(Transaction)을 관리합니다. signUp 메서드에서 예외가 발생하면 DB 변경 사항이 자동으로 롤백되어 데이터의 일관성을 유지할 수 있습니다. readOnly = true는 읽기 전용 작업에 대한 트랜잭션 최적화를 제공합니다.

비즈니스 로직 캡슐화: 회원가입(signUp)과 로그인(login)이라는 사용자 관련 비즈니스 로직이 UserService 내에 깔끔하게 캡슐화되었습니다. UserRepository를 통해 데이터베이스와 상호작용합니다.

💡 설계 상 고민할 부분: 여전히 인증/인가 로직이 UserService 내부에 섞여 들어가는 문제점

현재 UserService의 login 메서드에는 사용자의 아이디와 비밀번호를 검증하는 "인증(Authentication)" 로직이 포함되어 있습니다. 회원가입 시 아이디 중복 확인 역시 인증/가입 과정의 일부로 볼 수 있습니다.

이 방식은 이전 섹션의 컨트롤러에 직접 로직이 있던 것보다는 훨씬 낫지만, 여전히 다음과 같은 한계를 가지고 있습니다.

  • 단일 책임 원칙(Single Responsibility Principle): UserService는 "사용자 정보를 관리하는" 책임과 "사용자를 인증하는" 책임을 동시에 가지고 있습니다. 이상적으로는 각 책임은 분리되는 것이 좋습니다.

  • 보안 로직의 분산: 비밀번호 해싱, 세션 관리, 접근 제어(Authorization) 등 복잡하고 민감한 보안 관련 로직들이 UserService 또는 다른 서비스 계층에 분산되어 구현될 가능성이 높습니다. 일관적인 보안 정책 적용이 어려워집니다.

  • 확장성 제한: 새로운 인증 방식(예: 소셜 로그인)이 추가되면 login 메서드를 수정하거나 새로운 메서드를 추가해야 합니다.

  • 코드 중복: 로그인 외에 다른 API에서도 사용자의 권한을 확인해야 할 때, 그 로직이 서비스 계층에 또 다시 반복될 수 있습니다.

이러한 문제들은 나중에 Spring Security를 도입하여 인증(Authentication)인가(Authorization)를 전문적으로 처리하는 프레임워크에 위임함으로써 해결됩니다.

2.3. Controller 계층에서 활용

Java

// import 생략

// 요청 DTO (Data Transfer Object) 정의
public record SignupRequest (
    private String username;
    private String password;
    private String email;
 ){}

public record LoginRequest (
    private String username;
    private String password;
){}

@RestController // RESTful API 컨트롤러
@RequestMapping("/api/users") // 기본 경로 설정
@RequiredArgsConstructor
public class UserController {

    private final UserService userService;

    @PostMapping("/signup")
    public ResponseEntity<String> signUp(@RequestBody SignupRequest signupRequest) {
        try {            
        userService.signUp(signupRequest.getUsername(), signupRequest.getPassword(), signupRequest.getEmail());
            return ResponseEntity.status(HttpStatus.CREATED).body("회원가입 성공!");
        } catch (IllegalArgumentException e) {
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage());
        } catch (Exception e) {
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("서버 오류 발생: " + e.getMessage());
        }
    }

    @PostMapping("/login")
    public ResponseEntity<String> login(@RequestBody LoginRequest loginRequest, HttpSession session) {
        try {
            User user = userService.login(loginRequest.getUsername(), loginRequest.getPassword());

            // 로그인 성공 시 세션에 사용자 정보 저장
            // 실전에서는 이 로직도 Spring Security에 위임하거나 JWT 토큰을 발급합니다.
            session.setAttribute("loggedInUser", user.getUsername());
            session.setMaxInactiveInterval(60 * 30); // 30분 세션 유지

            return ResponseEntity.ok("로그인 성공! 환영합니다, " + user.getUsername() + "님!");
        } catch (IllegalArgumentException e) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(e.getMessage());
        } catch (Exception e) {
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("서버 오류 발생: " + e.getMessage());
        }
    }

    // 간단한 인증 테스트용 API (로그인된 사용자만 접근 가능하게 하고 싶을 때)
    @GetMapping("/profile")
    public ResponseEntity<String> getProfile(HttpSession session) {
        String username = (String) session.getAttribute("loggedInUser");
        if (username != null) {
            return ResponseEntity.ok("현재 로그인된 사용자: " + username);
        } else {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("로그인이 필요합니다.");
        }
    }
}

설명:

  • @RestController: 이 클래스가 RESTful 웹 서비스의 컨트롤러임을 나타냅니다. @ResponseBody가 모든 핸들러 메서드에 자동으로 적용되어, 반환 값이 HTTP 응답 본문으로 직접 전송됩니다.

  • @RequestMapping("/api/users"): 이 컨트롤러의 모든 핸들러 메서드에 /api/users 경로가 기본으로 붙습니다.

  • @PostMapping("/signup"), @PostMapping("/login"): HTTP POST 요청을 특정 경로에 매핑합니다.

  • @RequestBody: 클라이언트로부터 전송된 JSON/XML 형태의 요청 본문을 Java 객체(여기서는 SignupRequest, LoginRequest DTO)로 변환하여 매핑합니다.

  • ResponseEntity<String>: HTTP 응답의 상태 코드, 헤더, 본문을 직접 제어할 수 있게 해주는 Spring 프레임워크의 클래스입니다.

  • try-catch 블록: UserService에서 발생할 수 있는 IllegalArgumentException 등을 처리하여 적절한 HTTP 상태 코드(예: 400 Bad Request, 401 Unauthorized)와 메시지를 클라이언트에 반환합니다.

  • HttpSession: 여기서는 HttpSession을 직접 사용했지만, 이 역시 Spring Security가 도입되면 Spring Security에 의해 관리됩니다.

2.4. 문제점 및 한계

Spring Framework를 도입하고 서비스 계층을 분리함으로써 1번 섹션과 비교하여 다음과 같은 점들이 개선되었습니다.

  • 모듈화: 비즈니스 로직(회원가입, 로그인)이 UserService에 집중되어 관리되고, 컨트롤러는 이 서비스를 호출하는 역할만 수행하여 책임이 분리되었습니다.

  • 테스트 용이성: UserService는 컨트롤러와 독립적으로 테스트하기 용이해졌습니다.

  • 트랜잭션 관리: @Transactional 어노테이션을 통해 트랜잭션을 관리할 수 있게 되었습니다.

하지만 여전히 다음과 같은 문제점과 한계가 존재합니다.

1. 보안 측면:

  • 비밀번호 평문 저장/비교: 여전히 비밀번호를 해싱하지 않고 평문으로 저장하고 비교하는 치명적인 문제가 해결되지 않았습니다.

  • 세션 보호 미흡: 세션 탈취, 고정 등에 대한 방어책이 없으며, 세션 타임아웃 설정도 관리되지 않습니다.

  • CSRF/XSS 방어 부재: 웹 보안 공격에 대한 방어 로직이 없습니다. 브라우저 기반 공격에 취약합니다.

2. 인증/인가 로직의 수동 구현:

  • 로그인, 접근 제한 등의 로직이 UserService 내부에서 수작업으로 처리되고 있습니다.

  • 예를 들어 @GetMapping("/profile")과 같이 인증된 사용자만 접근할 수 있는 페이지나 API를 구현하려면, 매번 세션을 확인하는 코드를 직접 작성해야 합니다. 반복적이고 실수하기 쉽습니다.

  • 인가 로직도 마찬가지입니다. 예를 들어 관리자 페이지 접근 시 if (user.getRole().equals(\"ADMIN\")) 형태의 분기문을 직접 작성해야 합니다.

  • 이로 인해 보안 정책 적용이 코드마다 분산되고, 유지보수 시 일관성을 해치기 쉽습니다.

확장성 부족:

  • OAuth2.0 기반 소셜 로그인, SSO(Single Sign-On) 연동 등의 기능을 추가하기 어려운 구조입니다.

  • JWT 기반 인증 방식으로 전환하려면 인증 흐름 전반을 다시 설계해야 합니다.

  • 보안 정책(예: 접근 제어, 권한 분리)이 하드코딩되어 있어 동적으로 관리하기 힘듭니다.

결론:

지금까지의 개선을 통해 코드 구조는 명확해졌지만, 보안과 인증/인가 관련 로직은 여전히 개발자가 직접 관리하고 구현해야 하는 수준에 머물러 있습니다.

이러한 요구를 수동으로 처리하는 것은 위험하며, 확장성과 일관성도 떨어집니다.

다음 단계

이제부터는 단순히 로그인 기능을 구현하는 단계를 넘어, 신뢰할 수 있는 인증 시스템을 구축하는 단계로 진입합니다.

다음 섹션부터는 Spring 기반 백엔드 보안의 핵심 프레임워크인 Spring Security를 본격적으로 적용해보겠습니다.


3. 강력한 보안의 시작: Spring Security 도입

앞선 섹션에서 Spring Framework를 활용하여 사용자 관리 로직을 모듈화했지만, 여전히 보안 기능의 부재와 인증/인가 로직의 수동 구현이라는 큰 문제점을 안고 있었습니다. 'Spring Security'를 도입해서 더 보완해보겠습니다.

목표:

  • Spring Security의 핵심 개념 이해 (Filter Chain, Authentication, Authorization)

  • Spring Security를 활용하여 사용자 인증 및 권한 부여 로직을 프레임워크 수준에서 관리

  • 비밀번호 해싱 적용

  • 필요시 JWT(JSON Web Token) 기반 인증으로 확장

3.1. Spring Security의 핵심 개념

Spring Security는 웹 요청이 들어올 때 여러 개의 Filter들이 순차적으로 동작하며 보안 관련 로직을 처리하는 방식으로 동작합니다. 이 필터들은 필터 체인(Filter Chain)을 구성합니다.

3.1.1. Filter Chain

Spring SecurityDelegatingFilterProxy라는 서블릿 필터를 통해 내부적으로 여러 보안 필터들을 호출합니다. 이 필터들은 요청이 컨트롤러에 도달하기 전에 인증, 인가, 세션 관리, CSRF 방어 등 다양한 보안 작업을 수행합니다.

참고 글:
https://velog.io/@choidongkuen/Spring-Security-Spring-Security-Filter-Chain-%EC%97%90-%EB%8C%80%ED%95%B4

간단하게 글로 쓰면 아래와 같습니다.

클라이언트 요청 -> DelegatingFilterProxy -> [Security Filter Chain] -> DispatcherServlet (Controller)
|
+- UsernamePasswordAuthenticationFilter
+- BasicAuthenticationFilter
+- ExceptionTranslationFilter
+- FilterSecurityInterceptor
+- ... (다양한 필터들)
각 필터는 특정 보안 기능을 담당하며, 인증에 실패하거나 인가되지 않은 요청은 컨트롤러에 도달하기 전에 차단됩니다.

3.1.2. Authentication (인증)

인증(Authentication)은 "당신이 누구인지"를 확인하는 과정입니다. 사용자가 제공한 정보(예: 아이디/비밀번호)을 확인하여 사용자의 신원을 증명합니다.

  • Authentication 객체: 현재 인증된 사용자 정보(Principal), 자격 증명(Credentials), 권한(Authorities) 등을 담는 객체입니다. Spring에서의 인증 정보는 이 객체에 옮겨집니다.

  • AuthenticationManager: Authentication 요청을 받아 인증을 수행하는 인터페이스입니다. 실제 인증 로직은 AuthenticationProvider에게 위임합니다.

  • AuthenticationProvider: 특정 유형의 Authentication을 처리하는 실제 인증 로직을 구현합니다. 예를 들어, DaoAuthenticationProvider는 데이터베이스에 저장된 사용자 정보를 기반으로 인증을 수행합니다.

  • UserDetailsService: 사용자 이름(username)을 기반으로 사용자 정보를 로드하는 인터페이스입니다. AuthenticationProviderUserDetailsService를 통해 사용자 정보를 가져온 뒤 비밀번호를 검증합니다.

  • UserDetails: UserDetailsService가 반환하는 객체로, Spring Security가 사용자의 정보를 이해하고 처리할 수 있도록 사용자 아이디, 비밀번호, 권한 등을 캡슐화한 인터페이스입니다. 사용자가 구현한 User 엔티티를 UserDetails 구현체로 변환하여 사용해야 합니다.

3.1.3. Authorization (인가)

인가(Authorization)는 "당신이 무엇을 할 수 있는지"를 결정하는 과정입니다. 인증된 사용자가 특정 리소스나 기능에 접근할 권한이 있는지 확인합니다.

  • 접근 제어: 특정 URL 패턴, HTTP 메소드, 혹은 개별 메소드 호출에 대해 권한을 기반으로 접근을 제한할 수 있습니다.

  • 역할(Role)과 권한(Authority): Spring SecurityGrantedAuthority 인터페이스를 통해 사용자의 권한을 표현합니다. 일반적으로 "ROLE_ADMIN", "ROLE_USER"와 같은 방식으로 표현됩니다.

참고:

https://docs.spring.io/spring-security/reference/servlet/authorization/architecture.html

> 3.1.4. Principal

Principal은 현재 애플리케이션에 로그인한 사용자(주체)를 나타내는 객체입니다. Spring Security에서는 보통 Authentication 객체 내부에 UserDetails 타입으로 저장됩니다. 컨트롤러나 서비스 계층에서 @AuthenticationPrincipal 어노테이션 등을 통해 현재 로그인한 사용자 정보를 가져옵니다.

3.2. Spring Security 설정

Spring Security를 사용하려면 의존성을 추가하고 설정 클래스를 작성해야 합니다.

3.2.1. 의존성 추가 (build.gradle)

build.gradle 파일에 Spring Security 의존성을 추가합니다.

Gradle

dependencies {
    // Spring Web (Restful API)
    implementation 'org.springframework.boot:spring-boot-starter-web'
    // Spring Data JPA (DB 연동)
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    // H2 Database (개발용 인메모리 DB)
    runtimeOnly 'com.h2database:h2'
    // Spring Security 핵심 의존성
    implementation 'org.springframework.boot:spring-boot-starter-security'
    // Lombok (선택 사항: 보일러플레이트 코드 줄이기)
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    // JWT 라이브러리 (나중에 JWT 섹션에서 사용)
    // implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
    // runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3'
    // runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3'
    // Test
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'
}

3.2.2. Spring Security 설정 클래스

Spring Security는 설정 클래스를 통해 보안 정책을 정의합니다. Spring Boot 2.7.x 이전에는 WebSecurityConfigurerAdapter를 상속받았지만, Spring Boot 3.x (Spring Security 6.x)부터는 SecurityFilterChain 빈(Bean)을 직접 등록하는 방식으로 사용합니다.

Java

// src/main/java/com/example/config/SecurityConfig.java
package com.example.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;

@Configuration // Spring 설정 클래스
@EnableWebSecurity // Spring Security 활성화
public class SecurityConfig {

    // 비밀번호 암호화를 위한 PasswordEncoder 빈 등록
    @Bean
    public PasswordEncoder passwordEncoder() {
        // 비밀번호 해싱을 위한 BCryptPasswordEncoder 알고리즘
        return new BCryptPasswordEncoder();
    }

    // SecurityFilterChain 빈 등록 (Spring Security 6.x 이상)
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            // CSRF (Cross-Site Request Forgery) 방어 비활성화
            // REST API 서버의 경우 보통 Stateless하게 운영되므로 CSRF를 비활성화합니다.
            .csrf(AbstractHttpConfigurer::disable)
            // 요청에 대한 인가(Authorization) 설정
            .authorizeHttpRequests(authorize -> authorize
                // /api/users/signup 경로에 대한 모든 요청 허용
                .requestMatchers(new AntPathRequestMatcher("/api/users/signup")).permitAll()
                // /api/users/login 경로에 대한 모든 요청 허용
                .requestMatchers(new AntPathRequestMatcher("/api/users/login")).permitAll()
                // 그 외 모든 요청은 인증 필요
                .anyRequest().authenticated()
            )
            // 이외에 필요에 따라 추가(OAuth2.0 관련 설정, JWT 관련 설정 등)
        return http.build();
    }
}

CSRF 비활성화 관련 부분은 아래의 블로그 글 참고했어요:

https://velog.io/@wonizizi99/SpringSpring-security-CSRF%EB%9E%80-disable

설명:

  • @Configuration, @EnableWebSecurity: Spring Security 설정을 위한 필수 어노테이션입니다.

  • passwordEncoder() 빈: BCryptPasswordEncoder는 비밀번호를 안전하게 해싱하고 검증하는 데 사용됩니다. 이 빈을 등록하면 Spring Security가 내부적으로 이를 사용하여 비밀번호를 처리합니다. 이제부터 비밀번호는 평문으로 저장되지 않게 됩니다.

  • securityFilterChain(HttpSecurity http) 빈: Spring Security 6.x의 핵심 설정입니다. HttpSecurity 객체를 통해 웹 보안 규칙을 정의합니다.
    참고: https://docs.spring.io/spring-security/reference/servlet/architecture.html

  • csrf(AbstractHttpConfigurer::disable): CSRF 방어 기능을 비활성화합니다. REST API는 보통 세션을 사용하지 않고 JWT와 같은 토큰을 사용하므로 CSRF 공격에 비교적 안전하여 비활성화하는 경우가 많습니다. (세션 기반 웹 앱이라면 반드시 활성화해야 합니다)

  • authorizeHttpRequests(...): HTTP 요청에 대한 인가 규칙을 정의합니다.

  • requestMatchers(new AntPathRequestMatcher("/api/users/signup")).permitAll(): /signup 경로는 누구나 접근할 수 있도록 허용합니다. (회원가입은 인증되지 않은 사용자도 가능해야 하므로)

  • requestMatchers(new AntPathRequestMatcher("/api/users/login")).permitAll(): /login 경로도 누구나 접근할 수 있도록 허용합니다.

  • anyRequest().authenticated(): 그 외의 모든 요청은 인증된(authenticated) 사용자만 접근할 수 있도록 합니다.

3.3. UserDetailsService와 UserDetails 구현

Spring Security가 데이터베이스에 저장된 우리 서비스의 User 정보를 기반으로 인증을 수행하려면, UserDetailsService 인터페이스를 구현하여 사용자 정보를 로드하는 방법을 알려주어야 합니다. 또한, 로드된 User 엔티티를 Spring Security가 이해할 수 있는 UserDetails 타입으로 변환해야 합니다.

3.3.1. CustomUserDetails 구현

User 엔티티를 UserDetails 인터페이스에 맞춰 래핑하는 클래스를 만듭니다.

Java

// src/main/java/com/example/security/CustomUserDetails.java
package com.example.security;

import com.example.domain.entity.User;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;
import java.util.Collections;

// UserDetails 인터페이스를 구현하여 Spring Security가 사용자 정보를 이해하도록 함
@Getter // lombok으로 getter 메소드 대체
public class CustomUserDetails implements UserDetails {

    private final User user; // 우리의 User 엔티티를 포함
### 

    // 사용자의 권한을 반환 (여기서는 간단하게 "ROLE_USER"만 부여)
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER"));
        // 나중에는 User 엔티티에 Role 정보를 추가하여 동적으로 권한 부여 가능
        // 예: user.getRoles().stream().map(role -> new SimpleGrantedAuthority("ROLE_" + role.name())).collect(Collectors.toList());
    }

    // 사용자의 비밀번호 반환
    @Override
    public String getPassword() {
        return user.getPassword();
    }

    // 사용자의 아이디 반환
    @Override
    public String getUsername() {
        return user.getUsername();
    }

    // 계정 만료 여부 (true: 만료되지 않음)
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    // 계정 잠금 여부 (true: 잠금되지 않음)
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    // 비밀번호 만료 여부 (true: 만료되지 않음)
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    // 계정 활성화 여부 (true: 활성화됨)
    @Override
    public boolean isEnabled() {
        return true;
    }
}

설명:

  • UserDetails 인터페이스를 구현하여 Spring Security의 요구사항을 만족시킵니다.

  • User 엔티티를 CustomUserDetails 생성자를 통해 받아서 내부 필드에 저장합니다.

  • getAuthorities(): 사용자가 가진 권한 목록을 반환합니다. 여기서는 모든 사용자에게 기본적으로 "ROLE_USER" 권한을 부여하도록 간단히 구현했습니다. 제 프로젝트에서는User 엔티티에 Role 정보(예: Java 기준 enum타입)를 추가하고, 해당 정보를 기반으로 동적으로 권한을 할당하였습니다.

  • getPassword(), getUsername(): Spring Security가 인증 과정에서 사용할 비밀번호와 아이디를 반환합니다.

나머지 isAccountNonExpired(), isAccountNonLocked(), isCredentialsNonExpired(), isEnabled()는 계정의 상태(만료, 잠금, 활성화 등)를 나타내며, 기본적으로 true를 반환하도록 했습니다. 필요에 따라 사용자 계정 관리 로직을 추가할 수 있습니다.

3.3.2. CustomUserDetailsService 구현

UserDetailsService 인터페이스를 구현하여 Spring Securityusername을 통해 UserDetails 객체를 로드하도록 합니다.

Java

// src/main/java/com/example/security/CustomUserDetailsService.java
package com.example.security;

import com.example.domain.user.entity.User;
import com.example.domain.user.repository.UserRepository;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

// UserDetailsService 인터페이스를 구현하여 Spring Security가 사용자 정보를 로드하도록 함
@Service // Spring Bean으로 등록
@RequiredArgsConstructor // lombok 생성자 추가
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    // 사용자 이름(username)으로 UserDetails 객체를 로드
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // UserRepository를 통해 DB에서 사용자 정보를 조회
        User user = userRepository.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다: " + username));

        // 조회된 User 엔티티를 CustomUserDetails로 변환하여 반환
        return new CustomUserDetails(user);
    }
}

설명:

  • @Service 어노테이션으로 Spring Bean으로 등록됩니다.

  • @RequiredArgsConstructor: final 필드인 userRepository에 대한 생성자 주입을 Lombok이 자동으로 처리해줍니다.

  • loadUserByUsername(String username) 메서드를 오버라이드합니다. 이 메서드는 Spring Security의 인증 프로세스 중 사용자의 아이디(username)를 기반으로 데이터베이스에서 사용자 정보를 조회할 때 호출됩니다.

  • UserRepository를 사용하여 username에 해당하는 User 엔티티를 찾아 반환합니다.

  • 만약 사용자를 찾지 못하면 UsernameNotFoundException을 발생시켜 인증 실패를 알립니다.

  • 찾은 User 엔티티를 CustomUserDetails 객체로 감싸서 반환합니다. Spring Security는 이 UserDetails 객체를 기반으로 비밀번호 일치 여부를 확인합니다.

3.4. 로그인/회원가입 연동 및 비밀번호 암호화

이제 Spring Security를 설정하고 UserDetailsService를 구현했으니, 기존의 회원가입 및 로그인 로직을 Spring Security에 맞게 수정해야 합니다.

3.4.1. UserService 수정 (비밀번호 암호화 적용)

회원가입 시 비밀번호를 평문으로 저장하던 것을 PasswordEncoder를 사용하여 암호화하도록 수정합니다. 로그인 시에도 암호화된 비밀번호를 비교하도록 변경합니다.

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder; // PasswordEncoder 주입

    @Transactional
    public User signUp(String username, String password, String email) {
        if (username == null || username.isEmpty() || password == null || password.isEmpty()) {
            throw new IllegalArgumentException("아이디와 비밀번호는 필수 입력 값입니다.");
        }

        if (userRepository.findByUsername(username).isPresent()) {
            throw new IllegalArgumentException("이미 존재하는 아이디입니다.");
        }

        // 비밀번호를 BCrypt로 암호화하여 저장
        String encodedPassword = passwordEncoder.encode(password); // 핵심 변경 사항!
        User newUser = new User(username, encodedPassword, email); // 암호화된 비밀번호 저장
        return userRepository.save(newUser);
    }

    @Transactional(readOnly = true)
    // 이 login 메서드는 JWT 기반 로그인 시 UserController에서 직접 호출되지 않습니다.
    // 대신, Spring Security의 AuthenticationManager가 내부적으로 CustomUserDetailsService를 통해
    // 사용자를 로드한 후, PasswordEncoder.matches()를 사용하여 비밀번호를 검증할 때 활용됩니다.
    public User login(String username, String password) {
        // 1. 사용자 존재 여부 확인
        User user = userRepository.findByUsername(username)
                .orElseThrow(() -> new IllegalArgumentException("아이디 또는 비밀번호가 일치하지 않습니다."));

        // 2. 비밀번호 일치 여부를 PasswordEncoder를 사용하여 안전하게 검증합니다.
        // 평문 비밀번호(password)와 DB에 저장된 해싱된 비밀번호(user.getPassword())를 비교합니다.
        if (!passwordEncoder.matches(password, user.getPassword())) {
            throw new IllegalArgumentException("아이디 또는 비밀번호가 일치하지 않습니다.");
        }

        return user; // 인증 성공 (이 메서드 자체는 인증 로직의 일부를 보여주는 역할)
    }
}

설명:

  • PasswordEncoder 주입: SecurityConfig에서 빈으로 등록한 BCryptPasswordEncoderUserService에 자동으로 주입됩니다.

  • signUp() 메서드: passwordEncoder.encode(password)를 사용하여 사용자가 입력한 평문 비밀번호를 안전하게 해싱한 후 데이터베이스에 저장합니다. 이제 DB가 유출되어도 비밀번호는 안전하게 보호됩니다.

  • login() 메서드: passwordEncoder.matches(password, user.getPassword())를 사용하여 사용자가 입력한 평문 비밀번호와 DB에 저장된 해싱된 비밀번호가 일치하는지 확인합니다. matches() 메서드는 평문 비밀번호를 해싱한 후 저장된 해시 값과 비교해줍니다.

  • Spring SecurityUsernamePasswordAuthenticationFilter 등을 통해 로그인 요청을 가로채고, 앞서 구현한 CustomUserDetailsService를 사용하여 사용자 정보를 로드한 뒤, PasswordEncoder를 사용하여 비밀번호를 검증합니다.

3.5. 권한 기반 접근 제어2

Spring Security의 가장 강력한 기능 중 하나는 인가(Authorization)입니다. 사용자의 특정 역할(Role)이나 권한(Authority)에 따라 리소스 접근을 제어할 수 있습니다.

CustomUserDetails에 역할(Role) 추가

먼저 User 엔티티에 role 필드를 추가하고, CustomUserDetails가 해당 역할을 반환하도록 수정해보겠습니다.

Java

@Entity
@Table(name = "users")
@Getter 
@Setter 
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true, length = 50)
    private String username;

    @Column(nullable = false)
    private String password;

    @Column(unique = true, length = 100)
    private String email;

    @Enumerated(EnumType.STRING) // Enum 타입을 DB에 String으로 저장
    @Column(nullable = false)
    private Role role; // 역할 필드 추가

    // 역할 Enum 정의
    public enum Role {
        ROLE_USER, // 일반 사용자
        ROLE_ADMIN // 관리자
    }
}
public class CustomUserDetails implements UserDetails {

    private final User user;

    public CustomUserDetails(User user) {
        this.user = user;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        // 사용자의 역할(Role)을 기반으로 권한 부여
        return Collections.singleton(() -> user.getRole().name());
    }

    // ... (나머지 메서드는 동일)
}
// 
@Service
@RequiredArgsConstructor
public class UserService {
    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    @Transactional
    public User signUp(String username, String password, String email) {
        // ... 유효성 검사 ...
        if (userRepository.findByUsername(username).isPresent()) {
            throw new IllegalArgumentException("이미 존재하는 아이디입니다.");
        }

        String encodedPassword = passwordEncoder.encode(password);
        // 기본적으로 일반 사용자(ROLE_USER) 역할 부여
        User newUser = new User(username, encodedPassword, email, User.Role.ROLE_USER); // 역할 추가!
        return userRepository.save(newUser);
    }
    // ...
}

접근 제어 적용 방법

Spring Security는 다양한 방법으로 인가(Authorization)를 적용할 수 있습니다.

  • URL 패턴 기반 인가 (HttpSecurity 설정):
    SecurityConfig에서 특정 URL 패턴에 대한 접근 권한을 정의할 수 있습니다.
Java

// src/main/java/com/example/login/config/SecurityConfig.java (securityFilterChain 메서드 수정)
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
        .csrf(AbstractHttpConfigurer::disable)
        .authorizeHttpRequests(authorize -> authorize
            .requestMatchers(new AntPathRequestMatcher("/api/users/signup")).permitAll()
            .requestMatchers(new AntPathRequestMatcher("/api/users/login")).permitAll()
            // /api/admin/** 경로는 ROLE_ADMIN 역할을 가진 사용자만 접근 가능
            .requestMatchers(new AntPathRequestMatcher("/api/admin/**")).hasRole("ADMIN")
            .anyRequest().authenticated()
        )
        .formLogin(AbstractHttpConfigurer::disable)
        .httpBasic(AbstractHttpConfigurer::disable);

    return http.build();
}

이제 /api/admin으로 시작하는 모든 요청은 "ROLE_ADMIN" 역할을 가진 사용자만 접근할 수 있게 됩니다.

  • 메소드 기반 인가 (@PreAuthorize, @PostAuthorize 등):
    컨트롤러나 서비스 계층의 특정 메소드에 어노테이션을 붙여 접근 권한을 제어할 수 있습니다. 이를 사용하려면 @EnableMethodSecurity 어노테이션을 활성화해야 합니다.
Java

// Spring Security 설정 클래스에 추가
// src/main/java/com/example/login/config/SecurityConfig.java
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // 메소드 기반 보안 활성화
public class SecurityConfig {
    // ... (기존 코드)
}
// 새로운 AdminController.java를 생성합니다.
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/admin")
public class AdminController {

    // 이 메서드는 ROLE_ADMIN 역할을 가진 사용자만 접근 가능
    @PreAuthorize("hasRole('ADMIN')")
    @GetMapping("/dashboard")
    public String getAdminDashboard() {
        return "관리자 대시보드입니다. 환영합니다!";
    }

    // 이 메서드는 ROLE_ADMIN 또는 ROLE_USER 역할을 가진 사용자만 접근 가능
    @PreAuthorize("hasAnyRole('ADMIN', 'USER')")
    @GetMapping("/common-data")
    public String getCommonDataForAdminAndUser() {
        return "관리자 및 일반 사용자 모두 접근 가능한 공용 데이터입니다.";
    }
}

@EnableMethodSecurity: 메소드 수준의 보안을 활성화합니다.

@PreAuthorize("hasRole('ADMIN')"): 메소드 실행 전에 현재 사용자가 "ADMIN" 역할을 가지고 있는지 확인합니다. 역할 이름은 ROLE_ 접두사를 붙여야 합니다 (Spring Security의 기본 규칙).


3.6. JWT (JSON Web Token) 기반 인증으로 확장

기존의 세션 기반 인증은 서버에 사용자 상태(세션)를 저장하므로, 서버를 확장할 때 복잡해질 수 있습니다. JWT(JSON Web Token)는 이러한 상태 비저장(Stateless) 인증을 가능하게 하여, 마이크로서비스나 분산 환경에 더욱 적합합니다.

이 섹션은 아래의 포스팅을 참고하여 썼습니다:
https://blog.bizspring.co.kr/%ED%85%8C%ED%81%AC/jwt-json-web-token-%EA%B5%AC%EC%A1%B0-%EC%82%AC%EC%9A%A9/

3.6.1. Why JWT? 세션 기반의 한계점 극복

  • 확장성(Scalability): 서버가 여러 대로 늘어날 때 각 서버가 독립적으로 요청을 처리할 수 있어 수평 확장에 유리합니다.

  • CSRF 방어 용이: JWT는 클라이언트(브라우저)의 로컬 스토리지 등에 저장되므로, CSRF 공격으로부터 비교적 안전합니다.

CSRF 공격에 대해 다시 참고할만한 글 하나를 공유합니다:
https://devscb.tistory.com/123

  • CORS(Cross-Origin Resource Sharing) 문제 해결: 도메인이 다른 클라이언트에서도 JWT를 헤더에 포함하여 자유롭게 요청을 보낼 수 있습니다.

3.6.2. JWT 구성: Header, Payload, Signature

JWT는 점으로 구분된 세 부분으로 구성된 문자열입니다.

xxxxx(헤더) . yyyyy(페이로드) . zzzzz(시그니처)

  • Header (헤더): 토큰의 타입(typ: JWT)과 서명에 사용된 알고리즘(alg: HS256, RS256 등) 정보를 담습니다. (Base64Url 인코딩)

  • Payload (페이로드): 클레임(Claim)이라고 불리는 사용자 정보나 데이터가 담깁니다.

  1. 등록된 클레임 (Registered Claims): iss (발급자), exp (만료 시간), sub (주제), aud (대상) 등 미리 정의된 클레임.

  2. 공개 클레임 (Public Claims): 충돌 방지를 위해 정의된 클레임. (예: IANA JSON Web Token Claims Registry)

  3. 비공개 클레임 (Private Claims): 서비스 간에 협의된 커스텀 클레임. (예: userId, role)
    (Base64Url 인코딩)

  • Signature (서명): 인코딩된 헤더와 페이로드, 그리고 서버의 비밀 키(Secret Key)를 사용하여 암호화된 값입니다. 토큰이 변조되지 않았음을 확인하는 데 사용됩니다.

3.6.3. JWT 생성 및 유효성 검증 (jjwt 라이브러리 활용)

JWT를 사용하려면 jjwt와 같은 라이브러리를 사용합니다.

의존성 추가 (build.gradle):

Gradle

dependencies {
    // ... (기존 의존성)
    implementation 'io.jsonwebtoken:jjwt-api:0.12.3' // API 인터페이스
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3' // 구현체
    runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3' // JSON 파싱을 위한 Jackson
}

JWT 관련 유틸리티 클래스:

Java

// 
@Component
public class JwtTokenProvider {

    private static final Logger logger = LoggerFactory.getLogger(JwtTokenProvider.class);
    private final SecretKey key; // JWT 서명에 사용할 비밀 키
    private final long tokenValidityInMilliseconds; // 토큰 유효 기간 (밀리초)

    public JwtTokenProvider(@Value("${jwt.secret-key}") String secretKey,
                            @Value("${jwt.token-validity-in-seconds}") long tokenValidityInSeconds) {
        // 비밀 키를 Base64 디코딩하여 SecretKey 객체 생성
        this.key = Keys.hmacShaKeyFor(secretKey.getBytes());
        this.tokenValidityInMilliseconds = tokenValidityInSeconds * 1000;
    }

    // JWT 토큰 생성
    public String createToken(Authentication authentication) {
        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(",")); // 권한들을 쉼표로 구분하여 문자열로 저장

        long now = (new Date()).getTime();
        Date validity = new Date(now + this.tokenValidityInMilliseconds); // 토큰 만료 시간

        return Jwts.builder()
                .setSubject(authentication.getName()) // 토큰의 주체 (여기서는 사용자 이름)
                .claim("auth", authorities) // 권한 정보 (페이로드에 추가)
                .signWith(key, SignatureAlgorithm.HS256) // 서명 알고리즘과 비밀 키
                .setExpiration(validity) // 만료 시간 설정
                .compact(); // 토큰 생성
    }

    // JWT 토큰을 사용하여 Authentication 객체 생성
    public Authentication getAuthentication(String token) {
        Claims claims = Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token)
                .getBody(); // 토큰에서 클레임(Payload) 추출

        Collection<? extends GrantedAuthority> authorities =
                Arrays.stream(claims.get("auth").toString().split(",")) // 권한 문자열을 파싱하여 컬렉션으로 변환
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toList());

        // Spring Security의 User 객체 생성 (UserDetails 구현체)
        User principal = new User(claims.getSubject(), "", authorities);

        // UsernamePasswordAuthenticationToken 반환
        return new UsernamePasswordAuthenticationToken(principal, token, authorities);
    }

    // JWT 토큰 유효성 검증
    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            logger.info("잘못된 JWT 서명입니다.");
        } catch (ExpiredJwtException e) {
            logger.info("만료된 JWT 토큰입니다.");
        } catch (UnsupportedJwtException e) {
            logger.info("지원되지 않는 JWT 토큰입니다.");
        } catch (IllegalArgumentException e) {
            logger.info("JWT 토큰이 잘못되었습니다.");
        }
        return false;
    }
}

application.yml 설정:

YAML

# src/main/resources/application.yml
jwt:
  secret-key: yourSecretKeyMustBeAtLeast256BitsLongAndShouldBeSecurelyStored # 256비트 이상 (32자 이상)의 강력한 비밀 키
  token-validity-in-seconds: 86400 # 24시간 (24 * 60 * 60)

3.6.4. JWT Filter 구현

모든 요청에 대해 JWT 토큰의 유효성을 검증하고, 유효한 토큰일 경우 Spring Security Context에 인증 정보를 설정하는 필터입니다.

Java

public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider;

    public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {
        this.jwtTokenProvider = jwtTokenProvider;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        String jwt = resolveToken(request); // 요청 헤더에서 JWT 토큰 추출

        if (StringUtils.hasText(jwt) && jwtTokenProvider.validateToken(jwt)) {
            // 토큰이 유효하면 SecurityContext에 인증 정보 저장
            Authentication authentication = jwtTokenProvider.getAuthentication(jwt);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request, response); // 다음 필터로 요청 전달
    }

    // Request Header에서 토큰 정보 추출
    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization"); // "Bearer {token}" 형태
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7); // "Bearer " 제거 후 토큰 값만 반환
        }
        return null;
    }
}

3.6.5. Spring Security 설정 변경 (JWT 필터 추가)

JwtAuthenticationFilterSpring Security 필터 체인에 추가합니다. UsernamePasswordAuthenticationFilter 이전에 실행되도록 하여, 로그인 요청이 아닌 일반 API 요청에도JWT 인증이 먼저 적용되도록 합니다.

Java

@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

    private final JwtTokenProvider jwtTokenProvider;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    // AuthenticationManager 빈 등록 (로그인 시 인증 처리를 위해 필요)
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf(AbstractHttpConfigurer::disable)
            // 세션을 사용하지 않음 (JWT 기반이므로 Stateless)
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(authorize -> authorize
                .requestMatchers(new AntPathRequestMatcher("/api/users/signup")).permitAll()
                .requestMatchers(new AntPathRequestMatcher("/api/users/login")).permitAll() // 로그인 경로도 허용
                .requestMatchers(new AntPathRequestMatcher("/api/admin/**")).hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            .formLogin(AbstractHttpConfigurer::disable)
            .httpBasic(AbstractHttpConfigurer::disable)
            // JWT 필터를 UsernamePasswordAuthenticationFilter 이전에 추가
            .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}

설명:

  • JwtTokenProvider 주입: SecurityConfigJwtTokenProvider를 주입받습니다.(Lombok@RequiredArgsConstructor로 대체)

  • sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)): JWT를 사용하므로 서버에서 세션을 생성하거나 유지하지 않도록 설정합니다.

  • AuthenticationManager 빈: 실제 인증을 수행하는 컴포넌트입니다. AuthenticationConfiguration을 통해 주입받아 빈으로 등록합니다. 컨트롤러에서 로그인 요청 시 이 AuthenticationManager를 사용하여 인증을 시도할 수 있습니다.

  • addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class): 앞서 만든 JwtAuthenticationFilterSpring SecurityUsernamePasswordAuthenticationFilter보다 먼저 실행되도록 필터 체인에 추가합니다. 이렇게 하면 모든 요청에 대해 JWT 유효성 검사가 먼저 이루어지고, 유효하다면 인증 정보를 SecurityContextHolder에 설정합니다.

3.6.6. UserController 로그인 API 변경 (JWT 발급)

이제 클라이언트가 로그인에 성공하면 JWT 토큰을 발급하여 반환하도록 UserController의 로그인 로직을 변경합니다.

Java

// 로그인 요청 DTO
public record LoginRequest(
    @NotBlank String username,
    @NotBlank String password
) {}

// 로그인 성공 시 응답 DTO
public record LoginResponse(String token, String username) {}

// 로그인 실패 시 응답 DTO
public record ErrorResponse(String code, String message) {}


@RestController
@RequiredArgsConstructor
@RequestMapping("/api/users")
public class UserController {

    private final UserService userService;
    private final JwtTokenProvider jwtTokenProvider;
    private final AuthenticationManager authenticationManager;

    @PostMapping("/signup")
    public ResponseEntity<String> signUp(@RequestBody SignupRequest signupRequest) {
        try {
            userService.signUp(signupRequest.username(), signupRequest.getPassword(), signupRequest.email());
            return ResponseEntity.status(HttpStatus.CREATED).body("회원가입 성공!");
        } catch (IllegalArgumentException e) {
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage());
        } catch (Exception e) {
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("서버 오류 발생: " + e.getMessage());
        }
    }

    @PostMapping("/login") // 로그인 요청 처리
    public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest loginRequest) {
        try {
            // UsernamePasswordAuthenticationToken 생성 (사용자 입력 정보 기반)
            UsernamePasswordAuthenticationToken authenticationToken =
                    new UsernamePasswordAuthenticationToken(loginRequest.username(), loginRequest.password());

            // AuthenticationManager를 통해 인증 시도
            // CustomUserDetailsService의 loadUserByUsername과 PasswordEncoder.matches가 호출됨
            Authentication authentication = authenticationManager.authenticate(authenticationToken);

            // 인증 성공 시, SecurityContextHolder에 인증 정보 저장
            SecurityContextHolder.getContext().setAuthentication(authentication);

            // JWT 토큰 생성
            String jwt = jwtTokenProvider.createToken(authentication);

			// 인증 완료 후 getPrincipal()로 username 가져오는 것이 안전
            String username = (authentication.getPrincipal() instanceof UserDetails ud)
                    ? ud.getUsername()
                    : loginRequest.username();

            // 클라이언트에 JWT 토큰과 함께 응답 반환
            return ResponseEntity.ok(new LoginResponse(jwt, username));

        } catch (org.springframework.security.authentication.BadCredentialsException e) {
            // Spring Security의 인증 실패 예외 처리
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(new LoginResponse(null, "아이디 또는 비밀번호가 일치하지 않습니다."));
        } catch (Exception e) {
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new ErrorResponse("BAD_CREDENTIALS", "아이디 또는 비밀번호가 일치하지 않습니다."));
        }
    }

    // ... getProfile 메서드는 동일 (JWT 필터가 인증 처리 후 SecurityContext에 설정해줌)
}

설명:

  • AuthenticationManager 주입: SecurityConfig에서 빈으로 등록한 AuthenticationManager를 주입받습니다.

  • UsernamePasswordAuthenticationToken 생성: 사용자가 입력한 아이디와 비밀번호로 인증용 토큰을 만듭니다.

  • authenticationManager.authenticate(authenticationToken): 이 메서드를 호출하면 Spring Security의 인증 프로세스가 시작됩니다.

  • CustomUserDetailsServiceloadUserByUsername이 호출되어 사용자 정보를 로드합니다.

  • 로드된 UserDetails의 비밀번호와 입력된 비밀번호를 PasswordEncoder를 통해 비교합니다.

  • 인증에 성공하면, Authentication 객체가 반환됩니다.

  • SecurityContextHolder.getContext().setAuthentication(authentication): 인증이 성공하면, 생성된 Authentication 객체를 SecurityContextHolder에 저장합니다. 이렇게 하면 현재 스레드에서 해당 사용자 정보에 접근할 수 있게 됩니다. (주로 @AuthenticationPrincipal 어노테이션을 통해)

  • jwtTokenProvider.createToken(authentication): 인증된 Authentication 객체를 기반으로 JWT 토큰을 생성합니다.

  • 토큰 반환: 생성된 JWT 토큰을 클라이언트에게 응답 본문에 담아 반환합니다. 클라이언트는 이 토큰을 받아서 이후의 요청 헤더(Authorization: Bearer {token})에 포함하여 전송해야 합니다.

3.6.7. 로그아웃 (JWT의 경우)

JWT는 상태 비저장(Stateless) 토큰이므로, 서버 측에서 토큰을 '무효화'하는 표준적인 방법은 없습니다. 클라이언트가 단순히 토큰을 삭제함으로써 로그아웃이 이루어집니다.

하지만 보안을 강화하려면 다음과 같은 방법을 고려할 수 있습니다.

  • 토큰 만료 시간 단축: 짧은 유효 기간을 가진 Access Token을 사용하고, 더 긴 유효 기간을 가진 Refresh Token을 통해 Access Token을 재발급하는 방식을 사용합니다. Refresh Token은 서버 DBRedis에 저장하여 관리하고, 로그아웃 시 Refresh Token을 만료시킵니다.

  • 블랙리스트(Blacklist) 관리: 로그아웃된 JWT를 서버 측에서 블랙리스트로 관리하여, 해당 토큰이 재사용되는 것을 방지합니다. (Redis와 같은 인메모리 DB를 사용하면 효율적입니다.)

토큰 블랙리스트에 대해서는 아래 글을 참고하면 좋아요:
https://guswls28.tistory.com/148

3.7. 문제점 및 한계

Spring Security를 도입함으로써 로그인 시스템을 이전보다 훨씬 개선했습니다.

  • 보안 강화: 비밀번호 암호화(BCrypt), 세션/JWT 관리, CSRF/XSS 방어 (설정 가능), 계정 잠금/만료 등 다양한 보안 기능을 프레임워크가 제공합니다.

  • 인증/인가 로직 추상화: UserDetailsService, UserDetails, AuthenticationManager 등 표준적인 인터페이스를 통해 인증 로직을 분리하였고, 개발자가 직접 복잡한 로직을 구현할 필요가 없어졌습니다.

  • 확장성 강화: 세션 기반 또는 JWT 기반 등 다양한 인증 방식을 쉽게 선택하고 변경할 수 있습니다.

  • 유지보수성 강화: @PreAuthorizeURL 패턴을 통해 인가 로직을 코드가 아닌 설정 또는 어노테이션으로 관리할 수 있게 바꾸었습니다.

하지만 여전히 다음과 같은 한계가 남아있습니다.

  • 소셜 로그인(OAuth 2.0) 연동의 복잡성: 카카오, 네이버, 구글 등 외부 서비스의 소셜 로그인을 연동하려면 Spring Security 외에 추가적인 학습과 구현이 필요합니다. 각 서비스별로 인증 방식과 제공하는 사용자 정보가 다르기 때문입니다.

  • 다른 서비스의 리소스 접근: 우리 서비스의 API를 다른 애플리케이션(타사 서비스, 모바일 앱 등)이 사용하는 경우, 해당 애플리케이션이 사용자 대신 우리 서비스의 특정 리소스에 접근할 수 있도록 하는 표준화된 "권한 위임" 방식이 필요합니다. 현재의 JWT는 단순 인증을 위한 것이고, 권한 위임과는 거리가 있습니다.

결론:

Spring Security는 단일 서비스 내에서 사용자 인증 및 권한 부여를 처리하는 데 있어 매우 효과적입니다. 하지만 외부 서비스와의 연동, 특히 사용자 대신 다른 서비스에게 특정 리소스 접근 권한을 위임하는 시나리오에서는 아직 부족한 부분이 있습니다.

다음 섹션에서는 이러한 "권한 위임"의 문제를 해결하고, 소셜 로그인과 같은 시나리오를 가능하게 하는 표준 프레임워크인 OAuth 2.0에 대해 자세히 알아보고 Spring Security와 연동하여 구현해 보겠습니다.


4. 분산 환경과 외부 연동의 필수: OAuth 2.0 도입

드디어 이번 글의 하이라이트 중 하나인 OAuth 2.0에 대해 다룰 차례입니다! Spring Security를 통해 서비스 내부의 사용자 인증과 권한 부여는 튼튼해졌습니다. 하지만 만약 우리 서비스의 API를 다른 회사 서비스, 모바일 앱, 또는 IoT 기기 등이 사용해야 한다면 어떨까요? 혹은 사용자들이 카카오, 네이버, 구글 같은 소셜 계정으로 우리 서비스에 로그인하게 하고 싶다면요?

이때 필요한 것이 바로 OAuth 2.0입니다. OAuth 2.0은 인증(Authentication)이 아닌 권한 부여(Authorization)를 위한 표준 프레임워크입니다. 즉, "어떤 사용자가 누구에게 어떤 리소스에 대한 접근 권한을 줄 것인가"에 대한 문제를 해결하는 방법입니다.

목표

  • OAuth 2.0의 개념과 등장 배경 이해

  • OAuth 2.0의 핵심 역할(Role) 및 권한 부여 흐름(Grant Type) 파악

  • Spring Security OAuth2 Client를 활용하여 소셜 로그인 구현

4.1. OAuth 2.0이란 무엇인가?

OAuth 2.0에 대해 아래 글 참고하면 좋아요:
https://blog.naver.com/mds_datasecurity/222182943542

4.1.1. 등장 배경: 기존 방식의 문제점

OAuth 2.0이 왜 등장했는지 이해하는 것이 중요합니다. 과거에는 다음과 같은 문제점들이 있었습니다.

  • 비밀번호 공유 위험:

    이전에는 웹 서비스 연동을 위해 실제 비밀번호를 입력해야 하는 사례가 있었습니다. 예를 들어, 어떤 웹 서비스(A)가 사용자의 트위터 정보를 가져와야 할 때, 사용자에게 "트위터 아이디와 비밀번호를 입력해주세요"라고 요구하는 방식이 있었습니다. 이는 서비스 A가 사용자의 트위터 계정에 대한 모든 권한(비밀번호 변경, 게시물 삭제 등)을 가질 위험이 있는 방식이었습니다. 사용자는 비밀번호를 공유하는 것에 대한 불안감을 느끼고, 서비스 A에 대한 신뢰도도 낮아집니다.

이를 해결하기 위해 OAuth 1.0이 등장했습니다. 아래의 글을 참고하면 좋습니다:
https://velog.io/@kimunche/OAuth2.0%EC%9C%BC%EB%A1%9C%EC%9D%98-%EC%97%AC%EC%A0%95

그러나 OAuth 1.0도 한계가 있었습니다.

  • 구현이 복잡하고 관리가 어려움: OAuth 1.0의 역할은 인가 토큰 발급, 보호된 리소스 관리 등 여러 가지를 한 번에 담당했기 때문에 모호했습니다. 토큰 유효기간을 관리하는 명세도 없었고, 사용환경 또한 제한적이었습니다. 암호화, 서명 절차, 요청/응답 암호화 방식 등 개발자에게 개발 부담을 주는 부분들도 많았습니다.

  • 세분화된 권한 제어 불가능: 특정 서비스에 사용자 정보를 넘겨줄 때, "이 서비스는 사용자 이름만 볼 수 있고, 이 서비스는 사진도 업로드할 수 있다"와 같이 세분화된 권한을 제어할 수 없었습니다.

OAuth 2.0은 이러한 문제들을 해결하기 위해 등장했습니다.

4.1.2. 핵심 개념: 누가, 누구에게, 무엇을 허락하는가?

OAuth 2.0은 다음과 같은 4가지 핵심 역할(Role)을 정의합니다.

  • Resource Owner: 보호된 리소스(예: 사용자 정보, 사진)를 소유한 주체입니다. 일반적으로 최종 사용자(End-User)입니다.

ex) 카카오 계정의 사용자 A, 네이버 이메일 계정의 사용자 B

  • Client: Resource Owner를 대신하여 Resource Server에 접근하려는 애플리케이션입니다. 사용자로부터 권한을 위임받는 앱입니다.

ex) 현재 사용 중인 Velog, 소셜 로그인을 사용하려는 모바일 앱

  • Authorization Server: Resource Owner를 인증하고, Client에게 Access Token을 발급하는 서버입니다. 권한 부여 과정을 담당합니다.

ex) 카카오 인증 서버, 네이버 인증 서버, 구글 인증 서버

  • Resource Server: 보호된 리소스를 호스팅하는 서버입니다. Client의 Access Token을 검증하여 리소스 접근을 허용합니다.

ex) 카카오 사용자 정보 API, 네이버 프로필 API, 구글 Drive API

OAuth 2.0Resource Owner가 자신의 비밀번호를 Client에게 직접 넘겨주지 않고도, ClientResource Owner의 특정 리소스에 접근할 수 있도록 "권한을 위임"하는 방식으로 작동합니다.

4.1.3. Grant Type (권한 부여 방식)

OAuth 2.0은 다양한 시나리오에 맞는 여러 가지 권한 부여 방식(Grant Type)을 제공합니다. 이 중에서 가장 일반적이고 안전하며 웹 애플리케이션에서 많이 사용되는 방식이 Authorization Code Grant(권한 부여 승인 코드 방식)입니다.

  • Authorization Code Grant (권한 부여 승인 코드 방식):

가장 일반적이고 안전합니다. Client Secret(비밀 키)을 안전하게 보관할 수 있는 서버 측 웹 애플리케이션에 적합합니다.

ClientAuthorization Server로부터 인가 코드(Authorization Code)를 받은 후, 이 코드를 사용하여 Access Token을 교환합니다. 중간에 인가 코드가 탈취되어도 Access Token으로 바로 이어지지 않아 안전합니다.

  • Implicit Grant (암시적 승인 방식): (비교적 구식이며 보안 취약점으로 인해 권장되지 않음)

Access TokenAuthorization Server로부터 직접 Client(주로 브라우저 기반 JavaScript 앱)로 전달합니다.

Client Secret을 안전하게 보관할 수 없는 환경(프론트엔드 앱)에 사용되었으나, 보안상의 이유로 Authorization Code Grant with PKCE(Proof Key for Code Exchange) 방식으로 대체되고 있습니다.

  • Resource Owner Password Credentials Grant (자원 소유자 비밀번호 자격 증명 방식):

Resource Owner의 아이디/비밀번호를 Client가 직접 받아서 Authorization Server에 보내 Access Token을 얻습니다.

ClientResource Owner에 대한 강한 신뢰 관계가 있는 경우(예: 운영체제의 기본 클라이언트, ClientAuthorization Server와 같은 회사 소유인 경우)에만 제한적으로 사용해야 합니다. 일반적인 웹 애플리케이션에서는 절대 사용하면 안 됩니다.

  • Client Credentials Grant (클라이언트 자격 증명 방식):

Client 자체의 자격 증명(Client ID, Client Secret)만으로 Access Token을 얻습니다. Resource Owner의 개입이 필요 없습니다.

주로 서버 간의 API 호출과 같이, 사용자가 아닌 애플리케이션 자체가 리소스에 접근해야 할 때 사용됩니다.

이 글에서는 주로 소셜 로그인 시나리오에 적합한 Authorization Code Grant를 다룹니다.

4.2. OAuth 2.0 흐름 (Authorization Code Grant)

OAuth 2.0의 핵심은 ClientResource Owner의 비밀번호를 직접 알 필요 없이, Resource Owner의 동의를 얻어 Access Token을 발급받는 과정입니다. 다음은 Authorization Code Grant의 상세 흐름입니다.

출처: https://auth0.com/docs/get-started/authentication-and-authorization-flow/authorization-code-flow

출처: https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow

Client (우리 서비스) -> Resource Owner (사용자):

사용자가 우리 서비스에서 ex)"카카오 로그인" 버튼을 클릭합니다.

우리 서비스(Client)는 사용자(Resource Owner)를 Authorization Server (ex)카카오 인증 서버)로 리다이렉트합니다. 이때 다음과 같은 정보를 함께 보냅니다.

  • client_id: 우리 서비스의 고유 식별자

  • redirect_uri: 인증 후 Authorization Server가 인가 코드를 보낼 우리 서비스의 콜백 URL

  • scope: 우리 서비스가 접근하려는 리소스 범위 (예: 프로필 정보, 이메일 등)

  • response_type: code (인가 코드를 요청한다는 의미)

  • state: CSRF 공격 방지를 위한 임의의 문자열 (필수!)

  • Resource Owner (사용자) <-> Authorization Server (카카오 인증 서버):

사용자는 Authorization Server의 로그인 페이지에서 자신의 ex)카카오 계정으로 로그인합니다.

로그인 성공 후, Authorization Server는 사용자에게 "우리 서비스가 당신의 프로필 정보에 접근하는 것을 허용하시겠습니까?"와 같은 동의(Consent) 화면을 보여줍니다.

사용자가 동의하면, Authorization Server는 사용자에게 redirect_uri인가 코드(Authorization Code)state 값을 함께 리다이렉트합니다.

Authorization Server (카카오 인증 서버) -> Client (우리 서비스):

Authorization Server는 인가 코드와 state 값을 포함하여 우리 서비스의 redirect_uriHTTP 리다이렉트를 수행합니다. (ex) https://our-service.com/login/oauth2/code/kakao?code=ABCD123&state=xyz)

우리 서비스(Client)는 이 인가 코드와 state 값을 받습니다. 가장 먼저, 요청의 state 값과 1단계에서 보냈던 state 값이 일치하는지 검증하여 CSRF 공격을 방지합니다.

Client (우리 서비스) -> Authorization Server (카카오 인증 서버):

우리 서비스는 받은 인가 코드를 사용하여 Authorization ServerAccess Token 발급을 요청합니다. 이때 다음과 같은 정보를 함께 보냅니다.

  • grant_type: authorization_code

  • client_id

  • client_secret: 우리 서비스의 비밀 키 (Client만 알아야 함)

  • redirect_uri

  • code: 3단계에서 받은 인가 코드

Authorization Server (카카오 인증 서버) -> Client (우리 서비스):

Authorization ServerClient의 요청을 검증하고, 유효하면 Access Token과 (선택적으로) Refresh Token, 그리고 ID Token (OpenID Connect 사용 시) 등을 JSON 형태로 응답합니다.

  • Access Token: Resource Server에 접근할 때 사용되는 토큰 (유효 기간이 짧음)

  • Refresh Token: Access Token이 만료되었을 때 새로운 Access Token을 재발급받는 데 사용되는 토큰 (유효 기간이 김, 안전하게 보관)

  • ID Token: 사용자의 인증 정보가 담긴 JWT (OpenID Connect 표준에서 사용)

OpenId Connect에 대해서는 아래의 포스팅을 참조하면 좋아요:
https://hudi.blog/open-id/

Client (우리 서비스) -> Resource Server (카카오 사용자 정보 API):

우리 서비스는 발급받은 Access TokenHTTP 요청의 Authorization 헤더(예: Authorization: Bearer <Access Token>)에 담아 Resource Server (ex)카카오 사용자 정보 API)에 사용자 정보 조회를 요청합니다.

Resource Server (카카오 사용자 정보 API) -> Client (우리 서비스):

Resource ServerAccess Token의 유효성을 검증하고, 유효하면 요청된 리소스(예: 사용자 프로필 정보)를 우리 서비스에 JSON 형태로 반환합니다.

Client (우리 서비스) -> Resource Owner (사용자):

우리 서비스는 Resource Server로부터 받은 사용자 정보를 사용하여 우리 서비스의 DB에 사용자 정보를 저장하거나 업데이트합니다 (예: 회원가입/로그인 처리).

이후 우리 서비스는 자체적인 인증 토큰(예: JWT)을 생성하여 사용자에게 발급하고, 사용자를 우리 서비스의 메인 페이지로 리다이렉트합니다.

4.3. Spring Security와 OAuth 2.0 Client

Spring Security 5.x (특히 Spring Boot 2.x/3.x)부터는 OAuth 2.0 Client 기능을 내장하고 있어, 소셜 로그인 연동을 매우 쉽게 할 수 있습니다. 여기서는 별도의 외부 라이브러리 없이 Spring Security가 제공하는 기능을 활용해서 간단하게 예시코드를 짜보고자 합니다.

4.3.1. 의존성 추가 (build.gradle)

Spring Security OAuth2 Client 관련 의존성을 추가합니다.

Gradle

dependencies {
    // ... (기존 Spring Security 및 기타 의존성)
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' # OAuth 2.0 Client 기능
    // ...
}

4.3.2. application.yml 설정

연동하고자 하는 소셜 서비스(Provider)에 대한 설정 정보를 application.yml에 추가합니다. 여기서는 GoogleKakao를 예시로 들어보겠습니다.

사전 준비: 각 소셜 서비스 개발자 콘솔에서 애플리케이션 등록

각 소셜 로그인 제공자(Google, Kakao, Naver 등)의 개발자 콘솔에 접속하여 애플리케이션을 등록하고 Client IDClient Secret을 발급받아야 합니다.

예시)

Google: Google Cloud Console -> API 및 서비스 -> 사용자 인증 정보 -> OAuth 2.0 클라이언트 ID 만들기

Kakao: 카카오 개발자 센터 -> 내 애플리케이션 -> 앱 만들기 -> 요약정보 -> 카카오 로그인 활성화

Naver: 네이버 개발자 센터 -> 애플리케이션 등록

가장 중요한 설정은 Redirect URI입니다. Spring Security는 기본적으로 /{baseUrl}/login/oauth2/code/{registrationId} 패턴의 리다이렉트 URI를 사용합니다.

URI를 각 소셜 서비스의 개발자 콘솔에 반드시 등록해야 합니다.

YAML

# src/main/resources/application.yml

spring:
  security:
    oauth2:
      client:
        registration:
          # Google 로그인 설정
          google:
            client-id: YOUR_GOOGLE_CLIENT_ID # 발급받은 Google Client ID
            client-secret: YOUR_GOOGLE_CLIENT_SECRET # 발급받은 Google Client Secret
            scope:
              - profile # 사용자 프로필 정보
              - email   # 사용자 이메일 정보
          # Kakao 로그인 설정
          kakao:
            client-id: YOUR_KAKAO_CLIENT_ID # 발급받은 Kakao Client ID
            client-secret: YOUR_KAKAO_CLIENT_SECRET # 발급받은 Kakao Client Secret
            client-authentication-method: POST # Kakao는 Client Secret을 POST 방식으로 보냄
            authorization-grant-type: authorization_code # 권한 부여 방식
            redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" # 기본 리다이렉트 URI 패턴
            scope:
              - profile_nickname
              - profile_image
              - account_email
            client-name: Kakao # UI에 표시될 이름
        provider:
          # Kakao Provider 설정 (Google은 기본으로 내장되어 있어 별도 설정 필요 없음)
          kakao:
            authorization-uri: https://kauth.kakao.com/oauth/authorize
            token-uri: https://kauth.kakao.com/oauth/token
            user-info-uri: https://kapi.kakao.com/v2/user/me
            user-name-attribute: id # 사용자 정보를 가져올 때 사용할 속성명 (Kakao는 id)

설명:

  • spring.security.oauth2.client.registration: 클라이언트(우리 서비스)가 OAuth 2.0 Provider(Google, Kakao 등)에 등록한 정보들을 설정합니다.

  • client-id, client-secret: 각 서비스 개발자 콘솔에서 발급받은 값입니다.

  • scope: 우리 서비스가 사용자로부터 어떤 정보를 가져올 것인지 정의합니다. 각 소셜 서비스마다 제공하는 스코프 이름이 다릅니다.

  • redirect-uri: {baseUrl}/login/oauth2/code/{registrationId}Spring Security가 자동으로 리다이렉트 URI를 생성해주는 기본 패턴입니다. registrationIdgoogle, kakao와 같은 등록 이름입니다.

  • client-authentication-method, authorization-grant-type: Kakao의 경우 명시적으로 설정해주는 것이 좋습니다.

  • spring.security.oauth2.client.provider:OAuth 2.0 ProviderEndpoint 정보를 설정합니다. Google이나 NaverSpring Security에 기본적으로 설정되어 있어 별도로 명시하지 않아도 됩니다. (물론 명시해도 무방합니다.) Kakao와 같이 기본 제공되지 않는 Providerauthorization-uri, token-uri, user-info-uri 등을 직접 설정해야 합니다.

  • user-name-attribute: User Info URI에서 사용자 고유 식별자를 어떤 필드 이름으로 가져올지 지정합니다. (Googlesub, Naverresponse 객체 안의 id, Kakao최상위 idProvider마다 다릅니다.)

4.3.3. Spring Security 설정 변경

이제 SecurityConfig에서 OAuth 2.0 로그인을 활성화합니다.

Java

// 
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

    private final JwtTokenProvider jwtTokenProvider;
    private final CustomOAuth2UserService customOAuth2UserService; // CustomOAuth2UserService 주입

    // 생성자 주입
    public SecurityConfig(JwtTokenProvider jwtTokenProvider, CustomOAuth2UserService customOAuth2UserService) {
        this.jwtTokenProvider = jwtTokenProvider;
        this.customOAuth2UserService = customOAuth2UserService; // 주입
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf(AbstractHttpConfigurer::disable)
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(authorize -> authorize
                .requestMatchers(new AntPathRequestMatcher("/api/users/signup")).permitAll()
                .requestMatchers(new AntPathRequestMatcher("/api/users/login")).permitAll()
                // OAuth2 로그인 관련 경로도 모두 허용
                .requestMatchers(new AntPathRequestMatcher("/login/oauth2/**")).permitAll()
                .requestMatchers(new AntPathRequestMatcher("/oauth2/**")).permitAll()
                .requestMatchers(new AntPathRequestMatcher("/api/admin/**")).hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            // OAuth2 로그인 설정
            .oauth2Login(oauth2 -> oauth2
                // .loginPage("/oauth2/authorization/google") // 직접 OAuth2 로그인 페이지로 리다이렉트
                .userInfoEndpoint(userInfo -> userInfo
                    .userService(customOAuth2UserService) // OAuth2 인증 성공 후 사용자 정보 처리 로직
                )
                // OAuth2 로그인 성공/실패 핸들러 (다음 섹션에서 구현 예정)
                // .successHandler(oAuth2AuthenticationSuccessHandler)
                // .failureHandler(oAuth2AuthenticationFailureHandler)
            )
            .formLogin(AbstractHttpConfigurer::disable)
            .httpBasic(AbstractHttpConfigurer::disable)
            .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}

설명:

  • CustomOAuth2UserService 주입: OAuth 2.0 인증 성공 후 사용자 정보를 처리할 커스텀 서비스를 주입받습니다.

  • authorizeHttpRequests:
    /login/oauth2//oauth2/ 경로를 permitAll()로 설정합니다. 이 경로들은 Spring SecurityOAuth 2.0 로그인 흐름을 처리하는 데 사용됩니다.

  • oauth2Login(oauth2 -> ...):
    OAuth 2.0 로그인 기능을 활성화하고 세부 설정을 정의합니다.

  • userInfoEndpoint(userInfo -> userInfo.userService(customOAuth2UserService)):
    이 부분이 핵심입니다. OAuth 2.0 인증 과정에서 Access Token을 발급받은 후, Resource Server로부터 사용자 정보를 가져오는 로직을 CustomOAuth2UserService에 위임합니다. 우리가 구현할 CustomOAuth2UserService가 이 정보를 받아서 우리 서비스의 User 엔티티로 변환하고 저장하는 역할을 합니다.

  • 로그인 성공/실패 핸들러:
    OAuth2AuthenticationSuccessHandlerOAuth2AuthenticationFailureHandler를 사용하여 OAuth 2.0 로그인 성공/실패 시의 동작을 더욱 세밀하게 제어할 수 있습니다. (ex) 로그인 성공 후 JWT 발급, 클라이언트로 리다이렉트 등) 이 부분은 다음 섹션에서 더 자세히 다룰 예정입니다.

4.4. Custom OAuth2UserService 구현

OAuth 2.0 Provider로부터 사용자 정보를 가져온 후, 이 정보를 우리 서비스의 User 객체에 매핑하고, 필요하다면 DB에 저장하거나 업데이트하는 로직을 CustomOAuth2UserService에서 구현합니다.

Java

@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

    private final UserRepository userRepository;
    // PasswordEncoder는 OAuth2 로그인 시 비밀번호가 없으므로 필요 없습니다.
    // private final PasswordEncoder passwordEncoder;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        // DefaultOAuth2UserService를 통해 사용자 정보(OAuth2User)를 가져옴
        OAuth2User oAuth2User = super.loadUser(userRequest);

        // OAuth2 Provider (Google, Kakao 등)의 ID
        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        // 사용자 이름으로 사용될 속성명 (application.yml의 user-name-attribute)
        String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails()
                .getUserInfoEndpoint().getUserNameAttributeName();

        // OAuth2 Provider에서 가져온 사용자 정보 (Map 형태)
        Map<String, Object> attributes = oAuth2User.getAttributes();

        // 각 Provider별로 사용자 정보를 파싱하여 User 객체에 매핑
        // 여기서는 Google과 Kakao만 예시. Naver 등 추가 가능
        String socialId; // 소셜 서비스에서 제공하는 사용자 고유 ID
        String email = null;
        String name = null;

        if ("google".equals(registrationId)) {
            socialId = (String) attributes.get("sub"); // Google의 고유 ID
            email = (String) attributes.get("email");
            name = (String) attributes.get("name");
        } else if ("kakao".equals(registrationId)) {
            socialId = String.valueOf(attributes.get("id")); // Kakao의 고유 ID (long 형태를 String으로 변환)
            Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
            if (kakaoAccount != null) {
                email = (String) kakaoAccount.get("email");
                Map<String, Object> profile = (Map<String, Object>) kakaoAccount.get("profile");
                if (profile != null) {
                    name = (String) profile.get("nickname");
                }
            }
        } else {
            throw new OAuth2AuthenticationException("지원하지 않는 OAuth2 공급자: " + registrationId);
        }

        // 우리 서비스의 DB에 사용자 정보 저장 또는 업데이트
        String username = registrationId + "_" + socialId; // 예: google_1234567890
        Optional<User> existingUser = userRepository.findByUsername(username);
        User user;

        if (existingUser.isPresent()) {
            user = existingUser.get();
            // 이미 존재하는 사용자라면 정보 업데이트 (이메일, 이름 등)
            // user.setEmail(email); // 필요하다면 업데이트 로직 추가
            // user.setNickname(name);
            userRepository.save(user);
        } else {
            // 새로운 사용자라면 회원가입 처리
            // 비밀번호는 소셜 로그인에서는 사용되지 않으므로, 임의의 값을 생성하거나 null 처리
            // 실제 구현에서는 유니크한 임의의 문자열을 사용하는 것이 안전합니다.
            String tempPassword = UUID.randomUUID().toString(); // 임의의 비밀번호 생성
            user = new User(username, tempPassword, email, User.Role.ROLE_USER); // 기본 역할 부여
            userRepository.save(user);
        }

        // CustomUserDetails와 OAuth2User를 결합한 객체 반환
        // 이렇게 하면 Spring Security Context에 우리 서비스의 User 정보와 OAuth2User 정보 모두 저장 가능
        return new CustomOAuth2UserDetails(user, attributes, userNameAttributeName);
    }
}

설명:

  • DefaultOAuth2UserService 상속:
    Spring Security의 기본 OAuth2UserService를 상속받아 기본 동작을 재사용하면서 커스텀 로직을 추가합니다.

  • loadUser(OAuth2UserRequest userRequest):
    이 메서드는 OAuth 2.0 인증 성공 후, Access Token을 사용하여 Resource Server로부터 사용자 정보를 가져올 때 호출됩니다.

  • registrationId:
    현재 로그인하려는 소셜 서비스의 식별자입니다 (예: "google", "kakao").

  • userNameAttributeName:
    각 소셜 서비스의 사용자 정보 JSON에서 사용자의 고유 ID를 나타내는 필드 이름입니다. (Googlesub, Kakaoid 등)

  • oAuth2User.getAttributes():
    소셜 서비스에서 가져온 원본 사용자 정보를 Map 형태로 반환합니다.

  • Provider별 정보 파싱:
    Google, Kakao 등 각 소셜 서비스마다 제공하는 사용자 정보의 JSON 구조가 다르므로, registrationId에 따라 필요한 정보를 추출하는 로직을 구현해야 합니다.
    카카오의 경우 kakao_account 하위에 email, profile 하위에 nickname 등이 있습니다.

  • 사용자 저장/업데이트:

findByUsername(username)을 통해 이미 우리 서비스에 가입된 소셜 사용자인지 확인합니다. (usernameregistrationId_socialId와 같이 고유하게 구성하는 것이 좋습니다.)

이미 가입된 사용자라면 정보를 업데이트하고, 처음 로그인하는 사용자라면 새로운 User 객체를 생성하여 DB에 저장합니다.

  • 비밀번호 처리: 소셜 로그인의 경우 사용자로부터 직접 비밀번호를 받지 않으므로, User 엔티티의 password 필드에는 임의의 고유한 문자열(UUID 등)을 저장하거나, 비밀번호 없는 로그인을 지원하도록 User 엔티티를 설계할 수도 있습니다.

  • CustomOAuth2UserDetails 반환:
    최종적으로 CustomOAuth2UserDetails 객체를 반환합니다. 이 객체는 Spring Security Context에 저장되어 Authentication 객체를 구성하고, 이후 @AuthenticationPrincipal 등으로 접근할 수 있게 됩니다.

4.4.1. CustomOAuth2UserDetails 정의

OAuth2User 인터페이스를 구현하고, 우리 서비스의 User 객체와 OAuth2Userattributes를 모두 담을 수 있도록 래핑하는 클래스를 만듭니다.

Java

@RequiredArgsConstructor
// CustomUserDetails와 OAuth2User를 모두 구현하여 일반 로그인과 소셜 로그인 사용자를 통합 관리
public class CustomOAuth2UserDetails implements OAuth2User {

    private final User user; // 우리 서비스의 User 객체
    private final Map<String, Object> attributes; // OAuth2 Provider에서 받은 사용자 정보 원본
    private final String nameAttributeKey; // OAuth2User의 getName()이 반환할 속성 키

    @Override
    public Map<String, Object> getAttributes() {
        return attributes;
    }

    // OAuth2User의 고유 ID (sub, id 등)
    @Override
    public String getName() {
        // application.yml의 user-name-attribute에 따라 반환
        return (String) attributes.get(nameAttributeKey);
    }

    // 우리 서비스의 User 객체가 가진 권한 반환
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return Collections.singletonList(new SimpleGrantedAuthority(user.getRole().name()));
    }

    // 우리 서비스의 User 객체에 접근하기 위한 getter
    public User getUser() {
        return user;
    }

    // 편의상 우리 서비스의 username을 반환하는 메서드 추가
    public String getUsername() {
        return user.getUsername();
    }
}

설명:

OAuth2User 인터페이스를 구현하여 Spring SecurityOAuth2 인증 정보를 담습니다.

  • user 필드: 우리 서비스의 User 엔티티를 포함하여, 소셜 로그인 사용자도 내부적으로 우리 서비스의 User 객체로 관리할 수 있게 합니다.

  • attributes 필드: 소셜 Provider로부터 받은 원본 사용자 정보 Map을 그대로 유지합니다. 이를 통해 필요시 더 많은 정보에 접근할 수 있습니다.

  • nameAttributeKey: OAuth2UsergetName() 메서드가 반환할 속성의 키를 지정합니다. 이 키는 각 소셜 Provider의 고유 ID를 나타냅니다 (Googlesub, Kakaoid).

4.5. 로그인 성공 후 리다이렉트 및 추가 처리

소셜 로그인이 성공하면, Spring Security는 기본적으로 / 경로로 리다이렉트합니다. 하지만 REST API 기반 환경에서는 클라이언트(프론트엔드)에게 JWT 토큰을 발급해주고, 클라이언트가 이 토큰을 받아서 이후 요청에 사용할 수 있도록 해야 합니다. 이를 위해 AuthenticationSuccessHandler를 구현합니다.

Java

// 
@Component
@RequiredArgsConstructor
public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    private final JwtTokenProvider jwtTokenProvider;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
                                        Authentication authentication) throws IOException, ServletException {
        // CustomOAuth2UserDetails로부터 우리 서비스의 User 정보 가져오기
        CustomOAuth2UserDetails oAuth2User = (CustomOAuth2UserDetails) authentication.getPrincipal();
        String username = oAuth2User.getUsername(); // 우리 서비스의 username (예: google_12345)

        // JWT 토큰 생성
        String jwtToken = jwtTokenProvider.createToken(authentication);

        // 클라이언트로 리다이렉트할 URL 생성
        // JWT 토큰과 사용자 이름을 쿼리 파라미터로 포함하여 프론트엔드 리다이렉트
        String targetUrl = UriComponentsBuilder.fromUriString("http://localhost:3000/oauth2/redirect") // 프론트엔드 리다이렉트 URL
                .queryParam("token", jwtToken)
                .queryParam("username", username)
                .build().toUriString();

        getRedirectStrategy().sendRedirect(request, response, targetUrl); // 리다이렉트 수행
    }
}

설명:

  • SimpleUrlAuthenticationSuccessHandler 상속:
    Spring Security에서 제공하는 기본 성공 핸들러를 상속받아 커스터마이징합니다.

  • onAuthenticationSuccess(...):
    OAuth 2.0 인증 성공 시 호출되는 메서드입니다.

  • authentication.getPrincipal()을 통해 CustomOAuth2UserDetails 객체를 가져옵니다.

  • jwtTokenProvider.createToken(authentication)을 사용하여 로그인한 사용자에 대한 JWT 토큰을 생성합니다.

  • UriComponentsBuilder를 사용하여 프론트엔드 애플리케이션으로 리다이렉트할 URL을 만듭니다. 이때 생성된 JWT 토큰과 사용자 이름 등을 쿼리 파라미터로 포함하여 전달합니다. (보안상 민감한 정보는 쿼리 파라미터 대신 다른 안전한 방법을 고려할 수 있습니다. 예를 들어, 토큰은 해시에 담고, 클라이언트에서 토큰을 추출하여 서버 API를 호출하는 방식 등)

  • getRedirectStrategy().sendRedirect(...)를 통해 클라이언트 브라우저를 지정된 URL로 리다이렉트 시킵니다.

SecurityConfig에 SuccessHandler 추가

Java

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtTokenProvider jwtTokenProvider;
    private final CustomOAuth2UserService customOAuth2UserService;
    private final OAuth2AuthenticationSuccessHandler oAuth2AuthenticationSuccessHandler; // SuccessHandler 주입

    public SecurityConfig(JwtTokenProvider jwtTokenProvider,
                          CustomOAuth2UserService customOAuth2UserService,

    // ... (PasswordEncoder, AuthenticationManager 빈)

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf(AbstractHttpConfigurer::disable)
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(authorize -> authorize
                .requestMatchers(new AntPathRequestMatcher("/api/users/signup")).permitAll()
                .requestMatchers(new AntPathRequestMatcher("/api/users/login")).permitAll()
                .requestMatchers(new AntPathRequestMatcher("/login/oauth2/**")).permitAll()
                .requestMatchers(new AntPathRequestMatcher("/oauth2/**")).permitAll()
                .requestMatchers(new AntPathRequestMatcher("/api/admin/**")).hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            .oauth2Login(oauth2 -> oauth2
                .userInfoEndpoint(userInfo -> userInfo
                    .userService(customOAuth2UserService)
                )
                .successHandler(oAuth2AuthenticationSuccessHandler) // OAuth2 로그인 성공 핸들러 지정
            )
            .formLogin(AbstractHttpConfigurer::disable)
            .httpBasic(AbstractHttpConfigurer::disable)
            .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}

이제 소셜 로그인이 성공하면 OAuth2AuthenticationSuccessHandler가 호출되어 JWT 토큰을 발급하고 클라이언트로 리다이렉트하는 전체 흐름이 완성되었습니다.

4.6. 소셜 로그인 연동 (Google, Kakao 등 예시)

이제 클라이언트(프론트엔드)에서 소셜 로그인 버튼을 어떻게 구현하고, 그 뒤에 어떤 일이 일어나는지 간략하게 살펴봅시다.

클라이언트 측 로그인 버튼 예시 (HTML)

HTML

<a href="/oauth2/authorization/google">Google 로그인</a>

<a href="/oauth2/authorization/kakao">Kakao 로그인</a>

동작 방식:

  1. 사용자가 /oauth2/authorization/google (또는 /kakao) 링크를 클릭합니다.

  2. 이 요청은 Spring SecurityOAuth2AuthorizationRequestRedirectFilter에 의해 가로채집니다.

  3. Spring Securityapplication.yml에 설정된 정보를 바탕으로 Google/Kakao Authorization Server인증 URI를 생성하고, 사용자를 해당 URI로 리다이렉트합니다.
    (OAuth 2.0 흐름의 1단계)

  4. 사용자는 Google/Kakao 로그인 및 동의 과정을 거칩니다.
    (OAuth 2.0 흐름의 2단계)

  5. Google/Kakao Authorization Server는 인가 코드(code)를 포함하여 우리 서비스의 redirect_uri (ex) http://localhost:8080/login/oauth2/code/google)로 리다이렉트합니다.
    (OAuth 2.0 흐름의 3단계)

  6. 우리 서비스의 Spring Security 필터 체인이 이 요청을 가로채고, DefaultOAuth2UserServiceCustomOAuth2UserService를 통해 Access Token 발급 및 사용자 정보 조회를 수행합니다.
    (OAuth 2.0 흐름의 4~7단계)

  7. CustomOAuth2UserService에서 사용자 정보(우리 서비스의 User 객체)를 DB에 저장/업데이트합니다.

  8. OAuth2AuthenticationSuccessHandler가 호출되어, 우리 서비스의 JWT 토큰을 생성하고, 미리 지정한 프론트엔드 리다이렉트 URL(ex) http://localhost:3000/oauth2/redirect?token=...)로 사용자 브라우저를 리다이렉트합니다.
    (OAuth 2.0 흐름의 8단계)

프론트엔드 애플리케이션은 이 리다이렉트 URL의 쿼리 파라미터에서 JWT 토큰을 추출하여 로컬 스토리지 등에 저장하고, 로그인 상태를 업데이트합니다.

4.7. 문제점 및 고려사항

OAuth 2.0은 멋진 기술이지만, 몇 가지 오해와 고려사항이 있습니다.

  1. OAuth 2.0은 인증 프레임워크가 아니다 (중요!):
    OAuth 2.0 자체는 "권한 부여"를 위한 것이지, 사용자의 "인증"을 직접적으로 다루는 것이 아닙니다. OAuth 2.0은 사용자가 누구인지 알려주는 것이 아니라, 특정 클라이언트가 사용자의 특정 리소스에 접근할 수 있는 권한을 얻는 과정입니다.

  2. 실질적인 사용자 인증(로그인) 기능은 OpenID Connect(OIDC)라는 OAuth 2.0 기반의 추가 프로토콜을 통해 제공됩니다. 대부분의 소셜 로그인 서비스(Google, Kakao, Naver 등)는 OAuth 2.0OpenID Connect를 함께 구현하여 사용자 인증 및 정보 공유를 가능하게 합니다. Spring Security OAuth2 Client도 내부적으로 OpenID Connect를 지원합니다.

Access Token, Refresh Token 관리 전략:

Access Token은 유효 기간이 짧으므로, 만료 시 Refresh Token을 사용하여 새 Access Token을 발급받아야 합니다.

Refresh Token은 보안상 매우 중요하며, 서버 측에서 안전하게 저장(DB, Redis 등)하고 관리해야 합니다. Refresh Token이 탈취되면, 해커가 계속해서 새 Access Token을 발급받아 사용자 리소스에 접근할 수 있습니다.

Refresh Token Rotation (재활용 방지):

매번 새로운 Access Token과 함께 새로운 Refresh Token을 발급하여, 이전 Refresh Token을 무효화하는 방식으로 보안을 강화할 수 있습니다.

소셜 서비스별 제공하는 사용자 정보 필드 상이:

각 소셜 로그인 Provider마다 OAuth2User.getAttributes()로 반환되는 사용자 정보 Map의 구조와 필드 이름이 다릅니다. 따라서 CustomOAuth2UserService에서 이 부분을 주의 깊게 파싱해야 합니다.

계정 연동 (Account Linking):

사용자가 일반 로그인으로 이미 가입되어 있는데, 나중에 같은 이메일로 소셜 로그인을 시도할 경우, 기존 계정과 소셜 계정을 연동(링크)할 것인지, 아니면 별도의 계정으로 취급할 것인지 정책을 결정해야 합니다. (이메일 기반 연동, 전화번호 기반 연동 등)

프론트엔드에서의 토큰 관리:

프론트엔드에서 JWT 토큰을 localStorage에 저장하는 것은 XSS(Cross-Site Scripting) 공격에 취약할 수 있습니다. HttpOnly 옵션이 설정된 Secure Cookie에 저장하거나, 메모리에만 저장하는 방식 등을 고려해야 합니다. Refresh Token은 서버에서만 관리하는 것이 더 안전합니다.

결론:

OAuth 2.0을 도입함으로써 드디어 우리 서비스가 다른 외부 서비스와 연동하거나, 사용자가 소셜 계정으로 편리하게 로그인할 수 있도록 하는 "권한 위임"이라는 매우 중요한 기능을 구현했습니다. Spring SecurityOAuth 2.0 Client 기능을 통해 복잡한 OAuth 2.0 흐름을 비교적 쉽게 처리할 수 있게 되었고, JWT와 연동하여 상태 비저장 환경에서의 인증을 완성했습니다.

그러나 아직 조금 더 안정성을 챙겨야 합니다. 다음 섹션에서 최종적으로 JWT를 기반으로 한 인증 시스템을 완성하고 글을 마무리하겠습니다.


4.8. JWT 기반 인증 시스템 완성하기: Refresh Token, 필터, 로그아웃, ID 토큰

OAuth 2.0을 통해 소셜 로그인 기능까지 성공적으로 연동했습니다. 이제 사용자들은 우리 서비스의 아이디/비밀번호뿐만 아니라, 구글이나 카카오 계정으로도 손쉽게 로그인할 수 있게 되었습니다.

하지만 여기서 멈추지 않고, 사용자 경험과 보안을 더욱 강화하기 위한 로직들을 추가해야 비로소 보다 안정적인 JWT 기반의 인증 시스템이 완성됩니다.

이 섹션에서는 Refresh Token을 이용한 Access Token 재발급, 모든 API 요청에 대해 JWT를 검증하는 필터, 안전한 로그아웃 로직, 그리고 OAuth 2.0ID Token 활용 전략까지 모두 다뤄보겠습니다.

이 섹션 작성하면서 정말 괜찮은 블로그를 읽고 공유합니다:

  1. https://olrlobt.tistory.com/98
  2. https://inpa.tistory.com/entry/WEB-%F0%9F%93%9A-Access-Token-Refresh-Token-%EC%9B%90%EB%A6%AC-feat-JWT

4.8.1. Refresh Token을 이용한 토큰 재발급 로직

Access Token은 보안상의 이유로 유효 기간이 짧게 설정됩니다. 만약 Access Token이 만료될 때마다 사용자가 다시 로그인을 해야 한다면, UX가 크게 떨어지겠죠. 이 문제를 해결하기 위해 Access Token보다 긴 유효 기간을 갖는 Refresh Token을 사용합니다.

흐름:

  1. 클라이언트가 만료된 Access Token으로 API를 호출하면, 서버는 401 Unauthorized 에러를 반환합니다.

  2. 클라이언트는 저장해 둔 Refresh Token을 서버의 재발급 API로 보냅니다.

  3. 서버는 Refresh Token의 유효성을 검증하고, DB에 저장된 토큰과 일치하는지 확인합니다.

  4. 유효한 Refresh Token이라면, 서버는 새로운 Access Token과 새로운 Refresh Token을 생성하여 클라이언트에게 반환합니다.

이 로직을 UserService에 추가해보겠습니다.

UserService에 재발급 로직 추가

// UserService.java

@Service
@RequiredArgsConstructor
public class UserService {
    private final UserRepository userRepository;
    private final RefreshTokenRepository refreshTokenRepository; // RefreshToken 저장 Repository
    private final JwtTokenProvider jwtTokenProvider;
    private final PasswordEncoder passwordEncoder;

    // ... 기존 로그인 및 회원가입 로직 ...

    @Transactional
    public LoginResponseDto reissueToken(String refreshToken) {
        // 1. Refresh Token 유효성 검증
        if (!jwtTokenProvider.validateToken(refreshToken)) {
            throw new IllegalArgumentException("Refresh Token이 유효하지 않습니다.");
        }

        // 2. Refresh Token DB 조회
        RefreshToken storedToken = refreshTokenRepository.findByToken(refreshToken)
            .orElseThrow(() -> new IllegalArgumentException("Refresh Token을 찾을 수 없습니다."));

        // 3. User 정보 가져오기
        Authentication authentication = jwtTokenProvider.getAuthentication(refreshToken);
        CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal();
        User user = userDetails.getUser();

        // 4. 새로운 Access Token과 Refresh Token 생성
        String newAccessToken = jwtTokenProvider.createToken(authentication);
        String newRefreshToken = jwtTokenProvider.createRefreshToken(user.getUsername());

        // 5. DB에 새로운 Refresh Token 저장 (기존 토큰 대체)
        storedToken.updateToken(newRefreshToken);
        refreshTokenRepository.save(storedToken);

        // 6. 새로운 토큰 반환
        return new LoginResponseDto(newAccessToken, newRefreshToken);
    }
}

이제 이 로직을 호출하는 UserController의 엔드포인트를 추가해야 합니다.

UserController에 재발급 엔드포인트 추가

// UserController.java

@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {
    private final UserService userService;

    // ... 기존 회원가입 및 로그인 엔드포인트 ...

    @PostMapping("/refresh")
    public ResponseEntity<LoginResponseDto> reissueToken(@RequestBody ReissueRequestDto reissueRequest) {
        LoginResponseDto loginResponse = userService.reissueToken(reissueRequest.getRefreshToken());
        return ResponseEntity.ok(loginResponse);
    }
}

// ReissueRequestDto.java
@Getter @Setter
public class ReissueRequestDto {
    private String refreshToken;
}

4.8.2. JWT 기반 AuthenticationFilter 구현 및 SecurityConfig 연동

로그인 요청 외에 모든 API 요청에 대해 JWT 토큰의 유효성을 검증하고, 인증 정보를 Spring Security Context에 설정해주는 필터를 만들어야 합니다.

JwtAuthenticationFilter 구현

// JwtAuthenticationFilter.java

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        // 1. HTTP 헤더에서 JWT 토큰 추출
        String token = jwtTokenProvider.resolveToken(request);

        // 2. 토큰이 유효한지 검증
        if (token != null && jwtTokenProvider.validateToken(token)) {
            // 3. 토큰이 유효하면 Authentication 객체를 가져와서 Security Context에 저장
            Authentication authentication = jwtTokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        // 4. 다음 필터로 진행
        filterChain.doFilter(request, response);
    }
}

이제 이 필터를 Security 필터 체인에 등록해야 합니다. SecurityConfigUsernamePasswordAuthenticationFilter가 실행되기 전에 우리가 만든 JwtAuthenticationFilter가 먼저 실행되도록 설정하는 것이 중요합니다.

SecurityConfig 수정

// SecurityConfig.java (수정된 부분)

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtTokenProvider jwtTokenProvider;
    private final CustomOAuth2UserService customOAuth2UserService;
    private final OAuth2AuthenticationSuccessHandler oAuth2AuthenticationSuccessHandler;

    // ... 기존 빈(Bean) 설정 (passwordEncoder, authenticationManager) ...

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf(AbstractHttpConfigurer::disable)
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(authorize -> authorize
                // 회원가입, 로그인, 토큰 재발급, 로그아웃, OAuth2 관련 경로는 모두 허용
                .requestMatchers(new AntPathRequestMatcher("/api/users/signup")).permitAll()
                .requestMatchers(new AntPathRequestMatcher("/api/users/login")).permitAll()
                .requestMatchers(new AntPathRequestMatcher("/api/users/refresh")).permitAll()
                .requestMatchers(new AntPathRequestMatcher("/api/users/logout")).permitAll()
                .requestMatchers(new AntPathRequestMatcher("/login/oauth2/**")).permitAll()
                .requestMatchers(new AntPathRequestMatcher("/oauth2/**")).permitAll()
                .requestMatchers(new AntPathRequestMatcher("/api/admin/**")).hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            .oauth2Login(oauth2 -> oauth2
                .userInfoEndpoint(userInfo -> userInfo
                    .userService(customOAuth2UserService)
                )
                .successHandler(oAuth2AuthenticationSuccessHandler)
            )
            .formLogin(AbstractHttpConfigurer::disable)
            .httpBasic(AbstractHttpConfigurer::disable)
            // JWT 필터를 UsernamePasswordAuthenticationFilter 이전에 추가
            .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}

4.8.3. 안전한 로그아웃 로직 구현

JWT기반 인증 시스템에서 로그아웃은 단순히 클라이언트 측에서 토큰을 삭제하는 것을 넘어서, 서버 측에서도 토큰을 무효화하는 과정이 필요합니다. 특히 긴 유효 기간을 가진 Refresh Token은 반드시 서버 측에서 삭제되어야 합니다.

흐름:

  1. 클라이언트가 Refresh Token을 포함한 로그아웃 요청을 서버에 보냅니다.

  2. 서버는 해당 Refresh Token을 DB에서 찾아 삭제합니다.

  3. Refresh Token이 삭제되면, 해당 토큰으로는 더 이상 Access Token 재발급이 불가능해집니다.

  4. 클라이언트 측에서도 Access Token과 Refresh Token을 삭제합니다.

UserService에 로그아웃 로직 추가

// UserService.java (수정된 부분)

// ... 기존 로직 ...

@Transactional
public void logout(String refreshToken) {
    // 1. Refresh Token 유효성 검증
    if (!jwtTokenProvider.validateToken(refreshToken)) {
        throw new IllegalArgumentException("Refresh Token이 유효하지 않습니다.");
    }

    // 2. Refresh Token DB에서 삭제
    refreshTokenRepository.findByToken(refreshToken)
        .ifPresent(refreshTokenRepository::delete);
}

UserController에 로그아웃 엔드포인트 추가

// UserController.java (수정된 부분)

// ... 기존 로직 ...

@PostMapping("/logout")
public ResponseEntity<Void> logout(@RequestBody LogoutRequestDto logoutRequest) {
    userService.logout(logoutRequest.getRefreshToken());
    return ResponseEntity.ok().build();
}

// LogoutRequestDto.java
@Getter @Setter
public class LogoutRequestDto {
    private String refreshToken;
}

4.8.4. ID Token 활용 전략: 사용자 정보 검증

OAuth 2.0은 권한 부여를 위한 프레임워크이고, ID TokenOpenID Connect(OIDC)라는 OAuth 2.0 확장 프로토콜을 통해 발급되는 인증 정보입니다. ID TokenJWT의 한 형태로, Claim에 사용자의 고유 정보(sub, email, name 등)가 담겨 있습니다.

이 부분은 NAVER WORKS Developers에 잘 나와 있어요:
https://developers.worksmobile.com/kr/docs/auth-oidc

ID Token의 주요 용도:

사용자 인증:

ID TokenResource ServerAccess Token을 보내 사용자 정보를 가져오는 과정 없이, 토큰 자체에 담긴 정보를 통해 사용자를 식별하고 인증하는 데 사용할 수 있습니다.

정보 위변조 방지:

ID TokenJWS(JSON Web Signature)를 통해 서명되어 있으므로, 우리 서비스는 Provider의 공개 키를 사용하여 토큰의 유효성과 내용의 위변조 여부를 검증할 수 있습니다.

ID Token 활용 시나리오:

Spring Security OAuth2 Client를 사용하면 ID TokenAuthentication 객체 내부에 자동으로 포함됩니다. CustomOAuth2UserService에서 oAuth2User.getAttributes()를 통해 id_token 필드를 확인하여 추가적인 정보 검증이나 활용 로직을 구현할 수 있습니다.

CustomOAuth2UserService에서 ID Token 확인하기

// CustomOAuth2UserService.java (예시)

@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
    OAuth2User oAuth2User = super.loadUser(userRequest);
    String registrationId = userRequest.getClientRegistration().getRegistrationId();
    Map<String, Object> attributes = oAuth2User.getAttributes();
    
    // ID Token이 존재한다면 JWT로 파싱하여 활용 가능
    if (userRequest.getAdditionalParameters().containsKey("id_token")) {
        String idTokenString = (String) userRequest.getAdditionalParameters().get("id_token");
        // JWT 라이브러리를 사용하여 ID Token을 디코딩하고 검증하는 로직 추가 가능
        // Ex: Jwts.parserBuilder()...
    }
    
    // ... 기존 사용자 정보 파싱 및 저장 로직 ...

    return new CustomOAuth2UserDetails(user, attributes, userNameAttributeName);
}

참고: Spring Security는 OAuth 2.0의 ID Token을 내부적으로 검증하고 처리하므로, 위와 같은 추가적인 검증 로직 부분은 선택적으로 구현하면 됩니다.

이로써 Refresh Token을 활용한 재발급 로직, 안전한 로그아웃 처리, 그리고 ID Token의 활용 가능성까지 모두 포함한, 꽤 괜찮은(제 부족한 시각 기준으로) 인증 시스템이 완성되었습니다. 이제 사용자들은 우리 서비스의 아이디/비밀번호는 물론, 소셜 계정으로도 안전하게 로그인하고 API를 사용할 수 있게 되었어요.

profile
매일매일 조금씩 성장하는 개발자

0개의 댓글