본문 바로가기
dev/프로젝트

[프로젝트] Spring Security

by dev-everyday 2025. 1. 28.
반응형

1. 코드 리뷰

feedback > 현재 testGetUser_Cacheable에서는 캐시와 관련 없는 테스트를 하고 있어서 가져온 캐시에 대한 값과 기존 넣은 값 비교로 바꾸자.

result > 먼저 beforeCache를 추가해서 이전에는 beforeCache가 없었고 조회 후 afterCache에 null이 아닌 값이 있다는 것을 확인하였고 이전 데이터와 캐싱된 데이터가 같은 것을 테스트하도록 코드를 변경하였다.

 

feedback > Principal을 이용한 인증 정보 조회를 적용해보자

info > 일반적으로 Spring Security에서 로그인한 사용자의 정보를 가져올 때 Principal 객체를 사용한다. 예를 들어 아래와 같은 코드를 통해 @Controller에서 현재 로그인한 사용자의 이름을 가져올 수 있다.

@GetMapping("/user")
public String getUserInfo(Principal principal) {
    return "현재 로그인한 사용자: " + principal.getName();
}

Spring Security의 기본 인증 방식에서는 자동으로 principal을 제공한다.

근데 OAuth2 로그인을 사용할 경우에는 Principal에서 직접 사용자 정보를 가져오지 않고 @AuthenticationPrincipal을 사용하여 OAuth2User 객체에서 정보를 추출해야 한다.

@GetMapping("/oauth-user")
public String getOAuthUserInfo(@AuthenticationPrincipal OAuth2User oauthUser) {
    return "OAuth 사용자 정보: " + oauthUser.getAttributes();
}

oauthUser.getAttributes() 시 OAuth 제공자(Google, Naver, Kakao)에서 제공하는 사용자 정보를 가져온다.

 

이 부분은 아래와 같이 구현되어 있다.

1) 사용자가 OAuth2 로그인 버튼 클릭(/oauth2/authorization/google)

2) Spring Security가 OAuth2 제공자로 로그인 요청 전송

3) OAuth2 제공자(Google, Kakao 등)가 사용자 인증 후 콜백 요청을 보냄

4) Spring Security가 OAuth2 로그인 인증 완료 후 CustomOAuth2UserService.loadUser() 실행

public OAuth2User loadUser(OAuth2UserRequest userRequest) {
        OAuth2User oAuth2User = super.loadUser(userRequest);

        String provider = userRequest.getClientRegistration().getRegistrationId();
        String providerUid = oAuth2User.getAttribute("sub");
        String email = oAuth2User.getAttribute("email");
        String accessToken = userRequest.getAccessToken().getTokenValue();
        String name = oAuth2User.getAttribute("name");
        ...
}

DefaultOAuth2UserService를 extends하고 있어서 가능하고 OAuth2UserRequest를 통해서 유저 정보를 return 받아온다.

기존 사용자가 있으면 accessToken을 업데이트하고 새 사용자면 isRegistered = false로 저장한다.

로그인 후 Spring Security가 /auth/signup으로 리다이렉트하는데 여기서 남은 닉네임과 생년월일을 작성하도록 하였다.

기존 회원이면 /dashboard, 신규 회원이면 /signup으로 가게 하였다.

 

feedback > 토큰에 Role 추가도 가능하다.

todo > JWT를 사용할 때 토큰 내부에 Role을 포함할 수 있다. JWT 내부에 Role 정보를 추가해서 토큰만 보고 사용자 권한을 확인할 수 있는데 JWT로 변경하면서 반영하도록 하자.

 

feedback > requestMatchers 통해서 검증이 가능하다.

result > Spring Security에서 특정 URL에 대해 권한을 검증할 수 있다. 예를 들어 관리자 권한이 있어야 접근이 가능할 경우 아래와 같이 작성하면 된다. 아직 따로 권한 설정은 하지 않았는데 이 부분도 이후에 추가해야 한다. hasAnyRole로는 여러 개의 Role을 허용할 수도 있다.

http
    .authorizeHttpRequests(auth -> auth
        .requestMatchers("/admin/**").hasRole("ADMIN") 
    );

권한 없는데 요청을 보내면 controller까지도 안 가고 검증을 할 수 있다.

소규모 프로젝트에서는 DB 기반 권한 관리도 가능한데 지금 이 방법을 고려 중이다.

 

feedback > GlobalExceptionHandler에 기본 Exception e 처리도 추가해두자. 그리고 excepiton 발생마다 customException을 추가해서 관리할 수 있다.

result > handleBadRequest, handleNotFound, handleException 추가 완료.

 

feedback > 캐싱할 때 Enum으로 관리해서 추가하는 방법도 있다. 따로 CacheKey를 Enum으로 관리해도 된다.

result > 우선 CacheNames와 CacheUtil을 추가하였다.

package com.fortune.app.common.util;

public class CacheNames {
    public static final String USER = "user";
    public static final String USER_LIST = "userList";
}
package com.fortune.app.common.util;

public class CacheUtil {

    public static String getUserCacheKey(Long userId) {
        return userId.toString();
    }

    public static String getUserListCacheKey() {
        return "all";
    }
}

그리고 캐싱하는 방법을 아래처럼 변경하였다.

@Cacheable(value = CacheNames.USER, key = "T(com.fortune.app.common.util.CacheUtil).getUserCacheKey(#userId)")

2. VPA, HPA

Kubernetes에서 리소스 관리 및 오토스케일링을 할 때 VPA와 HPA를 사용하여 애플리케이션의 부하 변화에 대응한다.

VPA는 Vertical Pod Autoscaler의 약자이고 HPA는 Horizontal Pod Autoscaler이다.

A) VPA(Vertical Pod Autoscaler)

VPA는 Pod의 리소스(cpu, memory 등)를 자동으로 조정하여 성능을 최적화하는 방식이다.

스케일업 또는 스케일다운 방식으로 CPU나 메모리가 지속적으로 부족하거나 과도하게 할당된 경우, 리소스를 더 효율적으로 사용해야 하는 워크로드, 고정된 개수의 pod에서 개별 pod의 성능을 조정해야 하는 경우 사용한다.

다만 pod를 재시작해야 하므로 무중단 배포가 어렵고 특정 노드의 리소스 제한을 초과하면 배포가 불가능하다.

B) HPA(Horizontal Pod Autoscaler)

HPA는 Pod의 개수를 조정하여 트래픽 변화에 대응하는 방식이다.

CPU 사용량 또는 Custom Metric을 기반으로 Pod 개수를 동적으로 조정한다.

부하가 증가하면 Scale-out을 하고 부하가 줄면 Scale-in을 하는데 일반적으로 Stateless 애플리케이션에서 많이 사용된다.

웹 서버, API 서버 등 트래픽이 급격하게 변하거나 특정 시점에 요청이 몰리는 서비스에 적합하다.

보통은 HPA를 많이 쓴다고 한다.

3. MyBatis

MyBatis를 사용하는 이유는 여러가지인데 SQL을 직접 작성할 수 있고 복잡한 쿼리 작성이 가능하다. 또한 기존 SQL 기반의 시스템과 통합하여 레거시 시스템과의 호환성과 동적 쿼리 때문에 사용하는 경우가 많다.

동적 쿼리의 경우 if, choose, foreach 같은 태그를 활용하여 유동적인 SQL을 작성할 수 있다.

그렇지만 XML 파일에서 직접 SQL을 관리해야하기 때문에 유지보수가 복잡할 수 있고 JPA처럼 객체와 DB 테이블 간 매핑을 자동화하는 기능이 부족하여 동적 매핑이 어렵고 CRUD도 전부 SQL 작성을 해야해서 생산성이 낮을 수 있다.

따라서, MyBatis는 SQL을 세밀하게 컨트롤해야 할 때 유용하지만 동적 쿼리가 필요 없고 단순한 CRUD 중심이라면 JPA나 다른 방법을 고려해보자.

4. Spring Security

Spring Security는 HttpServletRequest가 DispatcherServlet에 도달하기 전 필터 기반으로 인증과 인가를 적용하는 프레임워크다.

주로 인증(Authentication)과 인가(Authorization) 기능을 제공하고 세션 기반 인증, JWT, OAuth2 등 다양한 보안 기법을 적용할 수 있다.

Spring Security는 로그인/로그아웃 기능, API 요청 시 JWT(토큰) 기반 인증을 적용할 때, 역할 및 권한을 부여하여 사용자별 기능을 제한할 때, OAuth2 기반의 소셜 로그인을 적용할 때, CSRF, CORS, 세션 관리 등 보안 기능이 필요할 때 사용한다.

더보기

1) CSRF(Cross-Site Request Forgery)

사용자의 인증 정보를 도용하여 악의적인 요청을 보내는 공격이다.

피해자는 이미 로그인 된 상태에서 악의적인 요청을 보내게 되는데 공격자가 악성 링크나 스크립트를 포함한 사이트에 방문하도록 유도하고 B 사이트 방문 시 기존 로그인한 A 사이트로 자동 요청을 보내는 식으로 작동한다.

CSRF 토큰, SameSite 쿠키, Refer 검증을 통해 해결한다.

 

2) CORS(Cross-Origin Resource Sharing)

다른 도메인에서 API 요청을 막는 보안 정책으로 교차 출처 요청을 제어한다.

같은 도메인에서 온 요청만 허용하고 다른 출처에서 온 요청을 CORS 정책을 통해 제어해야 한다.

CORS 헤더 설정을 통해 해결하거나 Proxy 서버를 사용하면 된다.

서버에서 Access-Control-Allow-Origin 헤더를 추가하여 특정 출처를 허용해야 브라우저에서 요청을 보낼 수 있다.

프록시 서버의 경우 프론트엔드에서 백엔드와 같은 출처로 요청을 보내도록 프록시 서버를 설정한다.

CSRF는 악의적인 공격이지만 CORS는 브라우저의 기본 보안 정책이다.

1) 인증(Authentication)

사용자가 로그인 요청을 보냄 > Spring Security의 UsernamePasswordAuthenticationFilter가 요청을 가로챔 > 사용자의 아이디/비밀번호를 AuthenticationManager가 검증 > 인증 성공 시 SecurityContextHolder에 사용자 정보 저장 > 요청을 컨트롤러로 넘겨서 응답을 처리

인증 방식에는 세션 기반, JWT 기반, OAuth2 기반 인증이 존재한다.

2) 인가(Authorization)

사용자가 특정 API 요청 > Spring Security가 FilterSecurityInterceptor를 통해 접근 권한 체크 > 사용자의 역할 확인(@PreAuthorize, @Secured 등) > 접근 허용 또는 거부

권한 부여 방식에는 Role-Based Authorization(role 설정), Method-Level Authorization(@PreAuthorize 사용), URL-Based Authorization(특정 URL에 대해 antMatchers로 접근 제한) 등이 있다.

Spring Security의 동작 흐름

1) 클라이언트가 서버로 HTTP 요청을 보낸다.

2) DispatcherServlet이 요청을 처리하기 전에 Filter를 거치게 된다.

3) Spring Security는 SecurityFilterChain을 통해 요청을 필터링하여 인증/인가를 수행한다.

4) 인증 및 권한 검사를 통과하면 요청이 Servlet에 전달된다.

5) 최종적으로 컨트롤러에서 요청을 처리하고 응답을 반환한다.

Spring Security의 주요 컴포넌트

1) Filter 기반 동작

클라이언트 요청이 Servlet에 도달하기 전에 필터에서 사전 검사를 수행한다.

필터를 통해 인가되지 않은 요청을 차단하거나 인증된 사용자만 요청할 수 있도록 처리한다.

 

2) SecurityFilterChain

여러 개의 SecurityFilter가 모여 FilterChain을 형성한다.

요청을 필터링하여 인증/인가를 수행하고 적절한 처리를 결정한다.

 

3) DelegatingFilterProxy

Servlet 컨테이너와 Spring 컨테이너를 연결하는 다리 역할의 필터이다.

Spring Security의 보안 필터와 Servlet 필터를 연결한다.

Spring의 Bean을 활용할 수 있도록 돕는 역할을 한다.

Servlet 컨테이너는 Spring의 빈을 직접 인식할 수 없어서 DelegatingFilterProxy를 사용해서 Spring Context와 Servlet 컨테이너를 연결하여 보안 필터를 적용할 수 있게 도와준다.

 

4) FilterChainProxy

DelegatingFilterProxy 내부에 존재하는 Spring Security의 핵심 필터이다.

요청을 SecurityFilterChain으로 전달하고 적절한 필터를 실행한다.

더보기

1. 서블릿(Servlet)

서블릿은 Java 기반 웹 애플리케이션에서 클라이언트의 요청을 처리하고 응답을 반환하는 자바 프로그램이다.

웹 서버(Tomcat 등)에서 실행되며 사용자의 HTTP 요청을 받아 동적인 웹 페이지를 생성하거나 데이터를 처리하는 역할을 한다.

주로 웹 애플리케이션의 백엔드 로직을 처리하는 역할이다.

 

2. HttpServlet

HttpServlet은 java.servlet.http.HttpServlet 클래스를 상속받아 HTTP 기반 요청을 처리하는 서블릿이다.

웹 어플리케이션에서 HTTP 요청을 처리하는 기본 클래스로 HTTP 요청을 처리한다.

클라이언트가 특정 URL로 요청을 보내면 HttpServlet이 해당 요청을 받아 적절한 메서드를 실행하고 Servlet API로 웹 서버와 직접 통신할 수 있다.

 

3. DispatcherServlet

DispatcherServlet은 Spring MVC의 핵심 서블릿으로 클라이언트의 요청을 컨트롤러로 전달하고 응답을 반환하는 역할을 한다.

Spring Security의 주요 흐름

클라이언트가 요청을 보내면 DelegatingFilterProxy가 가로챈다.

FilterChainProxy가 요청을 SecurityFilterChain으로 전달하고 SecurityFilterChain 내부의 SecurityFilter들이 인증/인가 검사를 수행한다.

요청이 인증/인가를 통과하면 DispatcherServlet이 컨트롤러로 전달하고 컨트롤러가 요청을 처리하고 응답을 반환한다.

정리

정리하자면 Spring Security는 Http Request가 DispatcherServlet을 거치기 전 Servlet의 Filter를 기반으로 인증과 인가를 적용시켜주는 프레임워크다.

하나 이상의 filter 포함 시 클라이언트에서 보낸 요청이 Servlet으로 전달되기 전에 Filter를 거친다.

Servlet Container에서 DelegatingFilterProxy를 실행하고 FilterChainProxy는 DelegatingFilterProxy를 통해 받은 요청과 응답을 SecurityFilterChain에 전달하고 작업을 위임한다.

그러면 SecurityFilterChain의 여러 보안 필터를 실행하고 모든 필터를 통과하면 DispatcherServlet으로 요청을 전달한다.

더보기

1️⃣ 원래 HTTP 요청이 처리되는 과정 (Spring Security 없이)

클라이언트가 서버에 HTTP 요청을 보내면 Servlet 컨테이너가 이를 처리합니다.

기본적인 요청 처리 흐름

  1. 클라이언트 요청 → 브라우저/모바일 앱에서 서버로 HTTP 요청 전송.
  2. 웹 서버(Nginx, Apache, Tomcat 등)가 요청을 수락.
  3. Servlet 컨테이너가 DispatcherServlet으로 요청을 전달.
  4. DispatcherServlet이 컨트롤러(Controller)를 찾아 실행.
  5. 컨트롤러가 서비스(Service) 계층을 호출하고 비즈니스 로직을 실행.
  6. 서비스가 리포지토리(Repository) 계층과 데이터베이스(DB)에서 데이터를 가져옴.
  7. 컨트롤러가 최종 응답을 생성하고 DispatcherServlet이 클라이언트로 반환.

[요청 흐름]
Client → Web Server → Servlet Container → DispatcherServlet → Controller → Service → Repository → DB

2️⃣ Spring Security가 적용된 경우 (DelegatingFilterProxy 사용)

Spring Security는 요청이 DispatcherServlet으로 전달되기 전에 인증 및 권한 검사를 수행합니다.
이를 위해 DelegatingFilterProxy를 사용하여 보안 관련 필터를 동작시킵니다.

Spring Security 요청 처리 흐름

  1. 클라이언트 요청 → 브라우저/모바일 앱에서 HTTP 요청을 보냄.
  2. 웹 서버(Tomcat 등)가 요청을 수락.
  3. Servlet 컨테이너가 DelegatingFilterProxy를 실행 (Spring Security의 핵심 필터).
  4. DelegatingFilterProxy가 FilterChainProxy에 요청을 위임.
  5. FilterChainProxy가 SecurityFilterChain의 여러 보안 필터를 실행:
    • CorsFilter → CORS 정책 확인
    • JwtAuthenticationFilter → JWT 인증 수행
    • UsernamePasswordAuthenticationFilter → 로그인 요청 처리
    • SecurityContextPersistenceFilter → 인증 정보 저장
    • ExceptionTranslationFilter → 인증/인가 실패 시 예외 처리
    • FilterSecurityInterceptor → 접근 권한 체크
  6. 모든 필터를 통과하면 DispatcherServlet으로 요청 전달.

[Spring Security 요청 흐름]
Client → Web Server → Servlet Container → DelegatingFilterProxy → FilterChainProxy → SecurityFilterChain → DispatcherServlet → Controller → Service → Repository → DB

3️⃣ DelegatingFilterProxy의 역할

  • Spring Security의 필터(SecurityFilterChain)는 Servlet 컨테이너에서 직접 인식할 수 없음.
  • DelegatingFilterProxy는 Servlet 필터의 역할을 하면서 Spring의 SecurityFilterChain을 실행하는 다리 역할을 함.
  • 이를 통해 Spring 컨텍스트(ApplicationContext)에 등록된 보안 필터들을 활성화할 수 있음.

5. TODO

- 테스트코드 작성(JUnit5, Mockito), 비즈니스 로직 테스트 작성해보기

- README 추가하기

- queryDSL 리팩토링 적용해보기

- 자기소개서 부분 정리하기

- 자잘한 오류 고치기

반응형