글로벌 로그인 연동 Resource Server 에는 무엇이 있을까?

바로 Google, Facebook 이다. 글로벌하게 자주 쓰이는 로그인 연동 방식이다.

 

자, 우리의 개발자들은 생각한다.

뭐? 자주 쓰인다고 ???

 

자동화해야겠다.

 

그래서 탄생한 것이 ...

CommonOAuth2Provider 객체이다.

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

package org.springframework.security.config.oauth2.client;

import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;

public enum CommonOAuth2Provider {
    GOOGLE {
        public ClientRegistration.Builder getBuilder(String registrationId) {
            ClientRegistration.Builder builder = this.getBuilder(registrationId, ClientAuthenticationMethod.CLIENT_SECRET_BASIC, "{baseUrl}/{action}/oauth2/code/{registrationId}");
            builder.scope(new String[]{"openid", "profile", "email"});
            builder.authorizationUri("https://accounts.google.com/o/oauth2/v2/auth");
            builder.tokenUri("https://www.googleapis.com/oauth2/v4/token");
            builder.jwkSetUri("https://www.googleapis.com/oauth2/v3/certs");
            builder.issuerUri("https://accounts.google.com");
            builder.userInfoUri("https://www.googleapis.com/oauth2/v3/userinfo");
            builder.userNameAttributeName("sub");
            builder.clientName("Google");
            return builder;
        }
    },
    GITHUB {
        public ClientRegistration.Builder getBuilder(String registrationId) {
            ClientRegistration.Builder builder = this.getBuilder(registrationId, ClientAuthenticationMethod.CLIENT_SECRET_BASIC, "{baseUrl}/{action}/oauth2/code/{registrationId}");
            builder.scope(new String[]{"read:user"});
            builder.authorizationUri("https://github.com/login/oauth/authorize");
            builder.tokenUri("https://github.com/login/oauth/access_token");
            builder.userInfoUri("https://api.github.com/user");
            builder.userNameAttributeName("id");
            builder.clientName("GitHub");
            return builder;
        }
    },
    FACEBOOK {
        public ClientRegistration.Builder getBuilder(String registrationId) {
            ClientRegistration.Builder builder = this.getBuilder(registrationId, ClientAuthenticationMethod.CLIENT_SECRET_POST, "{baseUrl}/{action}/oauth2/code/{registrationId}");
            builder.scope(new String[]{"public_profile", "email"});
            builder.authorizationUri("https://www.facebook.com/v2.8/dialog/oauth");
            builder.tokenUri("https://graph.facebook.com/v2.8/oauth/access_token");
            builder.userInfoUri("https://graph.facebook.com/me?fields=id,name,email");
            builder.userNameAttributeName("id");
            builder.clientName("Facebook");
            return builder;
        }
    },
    
    ...

    public abstract ClientRegistration.Builder getBuilder(String registrationId);
}

 

 

 

따라서 우리 개발자는 다음 속성만 설정해두면 구글 로그인 연동이 알아서 구현된다!!

spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: your-google-client-id
            client-secret: your-google-client-secret
            redirect-uri: "{baseUrl}/login/oauth2/code/google"

OAuth2 에 대하여.

간단하게 말하면, 인증과 인가를 처리하기 위한 표준 규칙을 일컫는다.

 

다음 포스팅에서 더 자세하게 알 수 있다.

https://jinn-o.tistory.com/309

 

[OAuth2] accessToken에 이어서 왜 refreshToken 까지 필요할까?

OAuth2.0 이 뭐에요 ?Open Authorization의 약자로, 웹 애플리케이션이나 모바일 앱 등에서 인증(Authentication)과 인가(Authorization)를 처리하기 위한 표준 프로토콜이다. 표준이기 때문에, 사용자가 직접 자

jinn-o.tistory.com

 

 

스프링이 OAuth2 라는 RFC6749 규약을 지키는 방법

1. application.yml 을 작성한다.

spring:
  security:
    oauth2:
      client:
        registration: # Client 설정
          keycloak:
            authorizationGrantType: authorization_code      # OAuth 2.0 권한 부여 타입
            clientId: oauth2-client-app                      # 서비스 공급자에 등록된 클라이언트 아이디
            clientName: oauth2-client-app                    # 클라이언트 이름
            clientSecret: ********************************   # 서비스 공급자에 등록된 클라이언트 비밀번호
            redirectUrl: http://localhost:8080/login/oauth2/code/keycloak  # 인가서버에서 권한 코드 부여 후 클라이언트로 리다이렉트 하는 위치
            clientAuthenticationMethod: client_secret_post    # 클라이언트 자격증명 전송방식(basic, post, none)
            scope: openid, email, profile
        provider: # 공급자 설정
          keycloak:
            authorizationUri: http://localhost:8081/realms/oauth2/protocol/openid-connect/auth # Authorization code 부여 엔드 포인트
            issuerUri: http://localhost:8081/realms/oauth2 # 서비스 공급자 위치 (issuer)
            jwkSetUri: http://localhost:8081/realms/oauth2/protocol/openid-connect/certs # JwkSetUri 엔드 포인트 (JWT 를 암호화하기 위한 검증)
            tokenUri: http://localhost:8081/realms/oauth2/protocol/openid-connect/token # AccessToken 등 토큰 요청할때 엔드 포인트
            userInfoUri: http://localhost:8081/realms/oauth2/protocol/openid-connect/userinfo # 사용자 정보 가져오기
            userNameAttribute: preferred_username # 사용자명을 추출하는 클레임명

 

 

이제 이 application.yml 에 정의한 속성들을 다음 OAuth2ClientProperties 객체로 매핑시켜야 한다.

@ConfigurationProperties(
    prefix = "spring.security.oauth2.client"
)
public class OAuth2ClientProperties implements InitializingBean {
    private final Map<String, Provider> provider = new HashMap();
    private final Map<String, Registration> registration = new HashMap();

    ...

    public static class Registration {
        private String provider;
        private String clientId;
        private String clientSecret;
        private String clientAuthenticationMethod;
        private String authorizationGrantType;
        private String redirectUri;
        private Set<String> scope;
        private String clientName;

        ...
        
        (getters and setters)
    }

    public static class Provider {
        private String authorizationUri;
        private String tokenUri;
        private String userInfoUri;
        private String userInfoAuthenticationMethod;
        private String userNameAttribute;
        private String jwkSetUri;
        private String issuerUri;

        ...
        
        (getters and setters)
    }
}

 

 

객체를 매핑시키는 방법, OAuth2ClientPropertiesRegistrationAdapter

public final class OAuth2ClientPropertiesRegistrationAdapter {

    public static Map<String, ClientRegistration> getClientRegistrations(OAuth2ClientProperties properties) {
        Map<String, ClientRegistration> registrations = new HashMap<>();
        properties.getRegistration().forEach((registrationId, registration) -> {
            OAuth2ClientProperties.Provider provider = properties.getProvider().get(registrationId);
            registrations.put(registrationId, createClientRegistration(registrationId, registration, provider));
        });
        return registrations;
    }

    private static ClientRegistration createClientRegistration(String registrationId,
                                                               OAuth2ClientProperties.Registration registration,
                                                               OAuth2ClientProperties.Provider provider) {
        return ClientRegistration.withRegistrationId(registrationId)
                .clientId(registration.getClientId())
                .clientSecret(registration.getClientSecret())
                .authorizationUri(provider.getAuthorizationUri())
                .tokenUri(provider.getTokenUri())
                .userInfoUri(provider.getUserInfoUri())
                .redirectUri(registration.getRedirectUri())
                .scope(registration.getScope())
                .build();
    }
}

 

 

코드를 보면 Properties 객체를 ClientRegistration 객체로 변환하고 있는 것을 확인할 수 있다.

 

최종 목적은 ClientRegistration 객체를 반환하는 것.

public final class ClientRegistration {

    private final String registrationId;
    private final String clientId;
    private final String clientSecret;
    private final String authorizationUri;
    private final String tokenUri;
    private final String redirectUri;
    private final Set<String> scopes;

    // Builder 패턴을 통해 객체 생성
    public static Builder withRegistrationId(String registrationId) {
        return new Builder(registrationId);
    }

    public static class Builder {
        private String registrationId;
        private String clientId;
        private String clientSecret;
        private String authorizationUri;
        private String tokenUri;
        private String redirectUri;
        private Set<String> scopes;

        public Builder clientId(String clientId) {
            this.clientId = clientId;
            return this;
        }

        public ClientRegistration build() {
            return new ClientRegistration(this);
        }
    }
}

 

 

OAuth2ClientProperties 객체와 ClientRegistration 객체가 나뉘어 있는 이유.

  • 구성과 실행의 역할 분리하기 위해서
    • OAuth2ClientProperties: 구성 데이터를 관리하고
    • ClientRegistration: 실행 시 최적화된 정보 제공한다.
  • 불변 객체가 필요했기 때문에
    • 런타임 중 데이터 변경 없이 안정적인 동작을 보장해야 했기에, ClientRegistration 객체로 굳혀놓는 것이다.
  • Spring Boot와 Spring Security의 역할 분리:
    • Spring Boot: 설정 관리(OAuth2ClientProperties), 즉 application.yml 에서 원시 데이터 매칭한다.
    • Spring Security: 런타임에서 클라이언트 인증 처리(ClientRegistration), 즉 실제 인증에 맞게 데이터를 재구성한다.

OAuth2.0 이 뭐에요 ?

Open Authorization의 약자로, 웹 애플리케이션이나 모바일 앱 등에서 인증(Authentication)인가(Authorization)를 처리하기 위한 표준 프로토콜이다. 표준이기 때문에, 사용자가 직접 자신의 자격 증명을 애플리케이션에 직접 제공하지 않고도, 신뢰할 수 있는 제 3자 서비스(Goolge, Kakao 등)을 통해 자신의 자원을 안전하게 공유할 수 있도록 설계되었다. 뒤에 붙은 2.0 은 버전을 의미한다.

 

OAuth 2.0 의 특징

  • 주로 사용자의 권한을 관리한다는 것에 목적이 있다. 사용자가 누구인지 인증하는 것에도 쓰이지만, 결국 주된 목적은 권한 관리에 있다.
  • 사용자의 민감한 정보(비밀번호)를 노출하지 않고도, 제 3자가 사용자의 자원(이메일, 캘린더 등)에 접근할 수 있도록 설계할 수 있다.
  • Token 기반으로 서버 간의 인증을 처리한다. 이때 AccessToken 과 RefreshToken 의 개념이 들어간다.
  • 서명을 사용했던 1.0 버전대 대신 2.0 버전에서는 Bearer Token 기반 인증을 사용한다.
  • 권한을 부여하는 여러 Grant Type 이 있다. (Authorization Code, Client Credentials 등).

 

OAuth 2.0 Roles (역할) 에 대하여.

요소 역할 예시
Client 리소스 요청자 (애플리케이션) 쇼핑몰 앱, 모바일 앱
User 일반(실제) 사용자 Google 계정을 가진 사용자
Resource Owner 리소스 소유자 Gmail 사용자
Resource Server 보호된 리소스를 호스팅하는 서버 Google Drive API
Authorization Server 사용자 인증 및 Access Token 발급 서버 Google OAuth 서버, Keycloak



Client 를 Resource Server 에 등록하는 방법

필요한 요소들

  • Client ID : 애플리케이션 식별자
  • Client Secret : 애플리케이션에 따른 비밀번호
  • Authorized Redirect URIs : Authorized Code 를 전달해줄 url

중요한 것은, Client ID 와 Redirect URI 를 Resource Server 에 전달해주면, Client Secret 을 발급해준다는 것이다.

 

Resource Owner 에게 개인정보를 수집해도 되는지 물어봐야 한다.

위 이미지는, Resource Owner 인 나에게 Google 에게 나의 이름, 이메일 주소 등을 공유해도 되는지 물어보는 창이다. 이 창이 뜨고, 계속(Allow) 버튼을 누르는 과정에서 어떤 일이 일어나는 걸까?

 

< 이 예시의 역할정리 먼저 >

  • Resource Owner : 사용자
  • Client : Leetcode.com
  • Resource Server : Google
  • Authorization Server : Google

1. 일단 Client 와 Resource Server 는 (Client 를 이미 Resource Server 에 등록했으므로) 등록한 Client ID 와 Password, Redirect URL 를 알고있다.

 

2. 이 시점에서 Resource Owner(사용자)가 `구글로 로그인하기` 버튼을 클릭하면 다음과 같은 정보가 담긴 url 이 Resource Server 로 날아간다.

url 구조 GET/ (Resource Server의 지정된 주소)
?client_id=(Client_id)
&scope=B,C
&redirect_uri=(Redirect URL)
url 예시
(keycloak)
http://localhost:8081/realms/oauth2/protocol/openid-connect/auth?response_type=code&client_id=oauth2-client-app&scope=profile email&redirect_url=http://localhost:8080

 

이 때 Scope 란, 모든 자원에 대해서 인가를 허용할 수 없으니, 그 범위를 지정하는 것이다.

여기서는 이름, 이메일일것이다.

 

3. 이제 Resource Owner(사용자)가 로그인을 하고, Allow 버튼을 클릭했다. 그렇게되면 Resource Server 는 특정 UserA 가 scope B 와 C 에 대해서 허용했다. 라는 내용을 알게된다. 즉, 내(사용자)가 Google 에게 이렇게 말한것이다. "leetcode(Client) 에게 너가 이미 알고있는 내 이름과 이메일 등을 공유하도록 허용할게."

 

4. 그러면 이제 그 증거로 Resource Server 인 Google 인 Resource Owner 인 나(사용자)에게 Authorization code 가 담긴 url 를 전송한다. Redirect Url 에 담아서!

url 구조 GET/ (Redirect URL)?code=(Authorization code)
url 예시
(keycloak)
http://localhost:8080/?session_state=6b4ea5ee-cc4f-4b9c-8623-e9f2b2d0d25c&iss=http%3A%2F%2Flocalhost%3A8081%2Frealms%2Foauth2&code=c54dad36-a58d-4393-9759-d5d01113ed87.6b4ea5ee-cc4f-4b9c-8623-e9f2b2d0d25c.fa88663a-c0dd-4f7f-9502-b985b7f46632

 

... 와 동시에 Client 에게도 이 url 이 전달된다.

 

5. 이제 Client 에서 AccessToken 을 받아올 차례다. Client 는 토큰 값을 얻어오기 위해서 발급받은 Authorization code 가 담긴 url 요청을 Resource Server 로 보낸다.

url 구조 POST/ (Resource Server의 지정된 주소)
grant_type=authorization_code
client_id=(Client ID)
client_secret=(Client Password)
redirect_uri=(Redirect URL)
code=(Authorization code)
url 예시
(keycloak)
http://localhost:8081/realms/oauth2/protocol/openid-connect/token
(나머지 요소들은 POST 요청의 Body에 넣는다)

 

그러면 이제 Client 와 Resource Sever는 AccessToken을 나눠갖는다.

 

이제 AccessToken 이 발급되었다!

이 엑세스 토큰에 어떤 정보가 들어있을까? 앞서 말했다시피, OAuth 2.0 은 `인가`를 우선한다고 했다.

 

이제 이 Access Token은, 특정 사용자를, 특정 클라이언트에서, 특정 자원(Scope)에 대한 인가를 허용한다는 증명이 되는 셈이다. 이제 이 세가지의 요소 중에 하나라도 바뀐다면 자원에 접근되지 않을 것이다.

 

그런데 사실, AccessToken 과 함께 발급되는 토큰이 하나 더 있다.

바로, RefreshToken 이다.

https://datatracker.ietf.org/doc/html/rfc6749#section-1.5 (RFC6749 공식문서 참조)

 

(A) 토큰을 요청한다

(B) AccessToken 과 RefreshToken 을 발급받는다

(C) AccessToken 으로 지정된 Resource 에 접근한다

(D) Resource 를 받아온다

(E) 시간이 흐른 뒤, 또 AccessToken 으로 지정된 Resource 에 접근한다

(F) 이번에는 AccessToken 이 만료되어서, Resource에 접근할 수 없게 된다.

(G) AccessToken 을 재발급 받기 위해 RefreshToken 을 Authorization Server 에 요청한다.

(H) Authorization Server 는 AccessToken 을 발급해주고, 최초에 발급했던 것과 마찬가지로 만료 시간(expires_in)도 함께 발급한다.

 

결론.

RefreshToken 은 AccessToken 을 재발급 하기 위해서 존재한다!

 

 

1. AccessToken 에 만료 시간을 주는 이유

  • 탈취 위험 감소
    AccessToken이 노출되더라도 짧은 만료 시간 덕분에 공격자가 사용할 수 있는 시간을 제한하는 등

  • 최소 권한 보장
    사용자의 권한이 변경되었을 때도 단순히 AccessToken 을 만료시키는 방식으로 최소 권한을 보장

 

 

2. RefreshToken 이 필요한 이유

  • 끊김 없는 세션 유지
    사용자가 로그아웃하지 않는 한, 로그인을 다시 요구하지 않는다.

  • 서버 부하 감소
    매번 Authorization Server 와 통신하지 않고, Resource Server가 Authorization Server와 독립적으로 토큰을 검증

  • 보안성 증가
    RefreshToken 은 클라이언트 내부에서만 사용되므로 외부 노출 가능성이 낮기 때문에, AccessToken 이 탈취되더라도 Refresh Token 이 없으면 재발급이 불가능하다.

 

HttpBasic 인증 방식에 대해서 (동작 방식)

1. 클라이언트에서 서버로 Authorization 헤더에 인증 정보(username + password)를 Base64로 인코딩한 값을 포함하여 서버로 전송한다.

 

2. 이 때 서버가 이 값을 받아서 디코딩하여 인증정보를 확인한다.

인코딩 형식 Authorization: Basic Base64(username:password)
실제 인코딩된 값 예시 Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=

 

 

3-1. 인증에 성공했을 시, 인증 요청을 처리한다.

3-2. 인증에 실패했을 시, HTTP 401 Unauthorized 상태 코드를 응답한다. 이때 응답에는 WWW-Authenticate 헤더를 포함하여 클라이언트에게 인증방식을 명시한다.

응답 예시 HTTP/1.1 401 Unauthorized
WWW-Authenticate: Basic realm="Access to the protected resource"

(realm은 인증 영역을 나타내며, 사용자가 어떤 영역에 접근하려고 했는지 알려준다.)

 

 

HTTP Basic 인증이 어떻게 쓰였을까?

HTTP Basic은 초창기 HTTP/1.0 부터 포함된 표준 인증 메커니즘이다. 특히 정적 콘텐츠나 초기 웹 애플리케이션에서 1990년대 후반 ~ 2000년대 초반에 널리 쓰이던 인증 방식이다.

 

HTTP Basic 인증 방식이 널리 쓰인 이유, 즉 장점은 다음과 같다.

  • HTTP 표준만 지원하면, HTTP 프로토콜만으로 간단하게 인증이 가능하다.
  • 브라우저가 기본적으로 Authorization 헤더를 처리할 수 있는 기능을 제공하 때문에, 추가적인 UI가 필요없다.

 

그러나 HTTP Basic 인증방식은 Stateless 하기 때문에, 단독으로는 로그인 상태를 유지하지 못했다. 그래서 HTTP Basic 은 세션과 결합하여, 로그인 상태를 유지했다.

 

 

Stateful 한 세션 인증 방식과 결합한 HttpBasic 인증 방식

  • 로그인 인증에 성공하면, 세션을 생성한다. HttpSession(세션)에 사용자 정보를 저장하는데, 세션ID를 클라이언트의 브라우저 쿠키에 저장하는 방식으로 저장했다.
  • 이후 요청에 대해서는 Authorization 헤더를 포함하지 않아도, Session ID 를 통해서 인증 상태를 유지했다.

 

 

그러나.. 이제는 이 인증 방식을 거의 사용하지 않는다.

왜일까? 치명적인 단점들이 많이 발견되었기 때문이다.

  • Base64 인코딩은 암호화가 아니다. Base64는 단순한 데이터 변환 방식일 뿐 암호화가 아니기 때문에, 데이터를 쉽게 디코딩할 수 있다.
  • HTTP로 전송 시, 인증 정보가 평문으로 노출된다. 이에 따라서 Man-In-the-Middle 공격(MITM)에 취약하다. 따라서 HTTPS를 사용하지 않으면, 심각한 보안 문제를 초래할 수 있다.
  • 클라이언트는 매 요청마다 Authorization 헤더에 인증 정보를 포함하여 전송해야 한다. 그에 따라서 인증 정보가 여러 번 노출되며, 네트워크 트래픽이 불필요하게 증가하게 된다.

등등의 단점이 존재하는데, 확실이 구식 방식이라는 것이 명실히 드러난다.

 

 

 

결론.

HttpBasic 은 비활성화 해두자. (혹은 메소드 체인에서 아예 삭제하자.)

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeHttpRequests(auth -> auth.anyRequest().permitAll())
                .httpBasic(AbstractHttpConfigurer::disable)

        return http.build();
    }

}

 

 

그리고 httpBasic을 대체하여 JWT, OAuth2 와 같은 방식을 사용하자.

★★★ HttpSecurity 객체란?

HttpSecurity 객체는 Spring Security에서 인증(Authentication)인가(Authorization)를 설정하기 위한 핵심 클래스이기 때문에 무려 꽉 찬 별을 3개나!! 붙였다. 이 객체는 보안 설정의 중심 역할을 하며, 보안 필터 체인(Security Filter Chain)을 구성한다.

 

다음 문장은 일단 외워놓고 시작하자.

Spring Security 는 필터들을 여러 개 붙여놓는 행위, 즉 "필터 체이닝" 을 통해서 여러 인증 및 인가 로직들을 구현한다. 이때 필터들을 덕지덕지 붙여놓고 build 하는 객체가 HttpSecurity 이다.

 

Spring Security 는 HttpSecurity 객체로부터 시작된다.

  • Spring Security의 모든 설정은 이 객체를 통해 정의된다.
  • HTTP 요청을 어떻게 인증하고 인가할지, 어떤 보안 기능을 활성화하거나 비활성화할지 결정한다.
  • HttpSecurity는 설정을 도와주는 DSL (Domain-Specific Language) 역할을 하며, 가독성이 좋고 직관적으로 보안 규칙을 작성할 수 있게 한다.
  • 필터들을 다 붙일 때, Builder 패턴처럼 속성을 메소드 형식으로 붙이는 것처럼 붙인다.
  • 결론적으로 SecurityFilterChain 객체를 반환하도록 한다. 여러 필터들이 붙어서 커스텀된 체인 객체이다. (필터 무리(단체)이다.)

 

1. Spring Security 를 설정하는 방법.

 

Spring Boot 프로젝트를 실행하면 가장 먼저 포함하는 어노테이션이 있다.

바로 이것이다.

 

@SpringBootApplicaion

package promo.back.fromat;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;

@SpringBootApplication
public class FromatApplication {

	public static void main(String[] args) {
		SpringApplication.run(FromatApplication.class, args);
	}

}

 

@SpringBootApplicaion 어노테이션 내부로 들어가보면, 다음 어노테이션이 나온다.

 

@EnableAutoConfiguration

이 어노테이션은 Spring Boot 가 Spring Framework 와 다른 점을 보여주기도 한다. 이 어노테이션은 Spring Boot의 핵심 기능이라고 볼 수 있는 간결한 자동 설정을 활성화한다. 이 어노테이션은 Spring Boot 가 실행될 때 어플리케이션의 클래스 경로를 스캔하여, 필요한 Bean과 설정을 자동으로 등록하도록 만든다. Spring Boot 는 다양한 Starter 라이브러리와 연동하여 특정 라이브러리를 사용하기 위한 기본 걸정을 자동으로 구성한다. 실제로 Gradle 에서 라이브러리를 가져올 때, 왠만한 라이브러리의 명은 spring-starter 가 붙는 것을 많이 보았을 것이다.

 

여기서 중요한 점은, 만약 Spring-Security 에 관련된 라이브러리를 가져온다면, 자동 설정하는 라이브러리에서 Spring Security 와 관련된 라이브러리도 함께 가져온다는 것이다.

 

 

다음은 스프링 시큐리티에 관련한 Default 설정 클래스 파일이다.

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

package org.springframework.boot.autoconfigure.security.servlet;

import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication.Type;
import org.springframework.boot.autoconfigure.security.ConditionalOnDefaultWebSecurity;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.Customizer;
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.AuthorizeHttpRequestsConfigurer;
import org.springframework.security.web.SecurityFilterChain;

@Configuration(
    proxyBeanMethods = false
)
@ConditionalOnWebApplication(
    type = Type.SERVLET
)
class SpringBootWebSecurityConfiguration {
    SpringBootWebSecurityConfiguration() {
    }

    @Configuration(
        proxyBeanMethods = false
    )
    @ConditionalOnMissingBean(
        name = {"springSecurityFilterChain"}
    )
    @ConditionalOnClass({EnableWebSecurity.class})
    @EnableWebSecurity
    static class WebSecurityEnablerConfiguration {
        WebSecurityEnablerConfiguration() {
        }
    }

    @Configuration(
        proxyBeanMethods = false
    )
    @ConditionalOnDefaultWebSecurity
    static class SecurityFilterChainConfiguration {
        SecurityFilterChainConfiguration() {
        }

        @Bean
        @Order(2147483642)
        SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
            http.authorizeHttpRequests((requests) -> {
                ((AuthorizeHttpRequestsConfigurer.AuthorizedUrl)requests.anyRequest()).authenticated();
            });
            http.formLogin(Customizer.withDefaults());
            http.httpBasic(Customizer.withDefaults());
            return (SecurityFilterChain)http.build();
        }
    }
}

 

 

 

@ConditionalOnMissingBean

커스텀 Security 설정 파일이 존재하지 않는다면 현재 기본 시큐리티 설정 파일을 사용한다는 뜻이다.

현재 defaultSecurityFilterChain 메소드를 살펴보면, 모든 설정이 Defaults 로 설정되어 있는 것을 확인할 수 있다.

 

자, 우리는 이런 기본 설정 파일을 사용하고 싶은 것이 아니다.

직접 Security Config 클래스를 생성해보자.

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

    /*
        HttpSecurity 를 DI 주입받아서, 여러 기본 Filter 들을 가져와야 한다.

        * 인증 API : formLogin(), logout(), csrf(), httpBasic(), SessionManagement(), RememberMe(), ExceptionHandling(), addFilter()
        * 인가 API : authorizeHttpRequests(auth -> auth.requestMatchers(/admin).hasRole(USER).permitAll().authenticated().fullyAuthentication().access(hasRole(USER)).denyAll())
     */
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())

                // 보통 그냥 Default 로 사용한다.
                .httpBasic(httpSecurityHttpBasicConfigurer ->
                        httpSecurityHttpBasicConfigurer
                                /*
                                    인증되지 않은 사용자가 보호된 리소스에 접근했을 때 호출되는 Spring Security 의 컴포넌트.
                                    HTTP Basic 인증에서는 클라이언트에게 401 Unauthorized 응답과 WWW-Authenticate 헤더를 반환한다.
                                    현재 구상한 메소드는 기본 예외처리와 에러응답 그대로 작성해보았다.
                                 */
                                .authenticationEntryPoint(((request, response, authException) -> {
                                    response.setHeader("WWW-Authenticate", "Basic realm=security");
                                    response.sendError(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase());
                                }))
                );

        return http.build();
    }

}

 

@EnableWebSecurity

이 태그를 붙인 @Configuration 클래스 파일은, Spring Security 에서 Custom-Security 설정 파일로 인식하고, 기본 Security 설정 파일을 가져다가 사용하지 않는다. 

 

자 지금까지는 프롤로그였다.

가장 중요한 것은, 사용자 정의 SecurityConfig 클래스에서 지정해야하는, securityFilterChain 메소드이다.

 

 

★ 먼저, 필터 Filter 란?

다음 게시글을 참고해보자.

https://jinn-o.tistory.com/300

 

필터, 인터셉터 그리고 푸른 수염(?)의 Servlet

왜 푸른수염이냐고요? 제목 재미없게 쓰면 안들어와볼거잖아요 ㅠ 르세라핌의 콘서트가 열렸다. 콘서트에 입장하는 사람들과 권한을 어떻게 관리할까?보안 검색대와 VIP 팬클럽콘서트장 입구

jinn-o.tistory.com

 

 

2. SecurityConfig 에서 하는 일

우리의 최종 목표는 초반에도 언급했다시피, "필터 체이닝 객체"의 구현 이다.

 

먼저, 왜 Spring Security 는 필터 체이닝 방식을 채택했을까?

 

  • Spring Security의 설계 철학에는 모듈화, 유연성, 확장성이 있다. 이를 완벽하게 따르는 방식이 필터 체이닝 패턴이다.
  • Spring Security는 Java Servlet API 의 javax.servelt.Filter 를 기반으로 보안을 구현한다. (즉, Servlet Filter를 기반으로 동작한다는 뜻이다.)
  • Servlet 필터는 HTTP 요청과 응답을 가로채서 전처리하거나 후처리할 수 있는 도구이다.
  • Spring Security 는 HTTP 요청이 들어올 때 인증/인가 등의 보안 작업들을 순차적으로 수행하기 위해서 필터 체이닝 패턴을 사용한다.

 

3. 책임 연쇄 패턴 (Chain of Responsibility)

  • 각 필터는 요청을 처리하거나, 처리하지 않고 다음 필터로 전달
  • 이 패턴은 단일 책임 분리유연한 확장성을 제공
  • 각 필터는 요청을 검증하거나, 작업을 수행한 뒤 체인의 다음 필터로 요청을 전달.

동작 흐름 예시

Client Request → Filter1 → Filter2 → Filter3 → Controller

 

어떻게 이것이 가능할까?

각 메소드마다 자기 자신을 반환하면 체이닝이 가능하다.

public HttpSecurity httpBasic(Customizer<HttpBasicConfigurer<HttpSecurity>> httpBasicCustomizer) throws Exception {
        httpBasicCustomizer.customize((HttpBasicConfigurer)this.getOrApply(new HttpBasicConfigurer()));
        return this;
    }

 

위 코드는 HttpSecurity 객체에서 체이닝 연결을 위해서 this, 즉 자기자신을 반환하는 것을 확인할 수 있다.

 

 

또한 Customizer 라는 함수형 인터페이스를 사용하면, 단 하나의 추상메소드만을 가진 람다식으로 (인터페이스의 추상 메서드를) 익명 클래스로 간결하게 표현할 수 있다. ( => 이는 Java8 의 람다식에 관련된 개념이다.)

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

package org.springframework.security.config;

@FunctionalInterface
public interface Customizer<T> {
    void customize(T t);

    static <T> Customizer<T> withDefaults() {
        return (t) -> {
        };
    }
}

 

void customize() 의 추상메소드만이 존재하는 Customizer 함수형 인터페이스이다.

 

 

4. 메소드만으로 필터를 등록하는 방법

Spring Security 6 이상에서는 DSL 스타일의 메서드 체이닝을 통해 기본 필터가 자동으로 등록된다.

 

Configurer, 즉 설정 클래스와 필터를 연결하는 로직이 들어 있다.

예를들어, Spring Security의 메서드(httpBasic, formLogin, authorizeHttpRequests 등)는 각각 Configurer를 통해 필요한 필터를 필터 체인에 추가한다.

예시: authorizeHttpRequests

  • 호출: .authorizeHttpRequests()
  • Configurer: AuthorizeHttpRequestsConfigurer
  • 필터: FilterSecurityInterceptor

Spring Security는 내부적으로 이러한 구조를 제공하여, 설정 메서드만으로 필터를 등록하고 동작하게 한다.

 

주요 설정 메서드와 자동 등록 필터

메서드 자동 등록 필터 역할
authorizeHttpRequests() FilterSecurityInterceptor URL 기반 접근 권한(인가) 처리
httpBasic() BasicAuthenticationFilter HTTP Basic 인증 처리
formLogin() UsernamePasswordAuthenticationFilter 폼 로그인 요청 인증 처리
csrf() CsrfFilter CSRF 보호 처리
cors() CorsFilter CORS 정책 처리
logout() LogoutFilter 로그아웃 요청 처리

 

 

5. 직접 커스텀 필터를 등록하려면

addFilterBefore 또는 addFilterAfter 메서드를 사용

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
        .authorizeHttpRequests(auth -> auth
            .anyRequest().authenticated()
        )
        .addFilterBefore(new CustomFilter(), UsernamePasswordAuthenticationFilter.class) // 커스텀 필터 등록
        .csrf(AbstractHttpConfigurer::disable);

    return http.build();
}

 

 

 

결론 (핵심 정리).

  • SpringSecurity 의 SecurityFilterChain 은 HTTP 요청을 처리하는 필터들의 체인을 구성하는 역할을 한다.
  • 이때 HttpSecurity 는 이 체인을 구성하기 위한 DSL (Domain-Specific Language) 역할을 한다.
  • HttpSecurity 에서 각 메서드(authorizeHttpRequests, httpBasic, csrf 등등)들을 호출하면, 해당 설정에 맞는 "설정 클래스" 와 "인증 필터" 가 필터 체인에 등록된다.
  • 메서드에서 객체 자신을 반환하게하고, 빌더 패턴과 Customizer 인터페이스(함수형 인터페이스)를 이용해서 메서드 체이닝을 구현한다.

Spring MVC에 살고있는 HandlerExceptionResolver의 구현체들.

Spring MVC 에는 특정한 예외 처리 매커니즘이 존재한다.

예외가 발생했을 때, 탐지하고 처리하여 적절한 응답을 반환하는 매뉴얼이 존재하는 것이다.

 

그것에 대해서 알아보자.

 

크게 세가지가 존재한다.

예시 코드는 현재 실제 개발중인 From-At. 코드에서 가져왔다.

 

1. ExceptionHandlerExceptionResolver

  • RESTful API 시스템에서 가장 많이 쓰이는 방식이다. 왜냐하면 사용자 정의를 필요로 하기 때문이다.
  • @ExceptionHandler@ControllerAdvice 를 기반으로 사용자 정의 예외 처리 메서드를 호출한다.
  • 사용자가 ControllerAdvice 로 탐지되는 클래스를 생성하여, 전역적으로 사용자 정의 예외 처리 클래스를 생성한다.
  • 그러고 ExceptionHandler 로 여러 예외 지정하여 처리하는 메서드를 실행할 수 있도록 한다.
  • 각 특정 예외마다 해당 예외를 처리하는 특정한 응답 형식 등을 커스텀해서 지정한다.
package promo.back.fromat.common.exception;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import promo.back.fromat.common.dto.BaseResponse;
import promo.back.fromat.common.status.ResultCode;

import java.util.HashMap;
import java.util.Map;

@RestControllerAdvice
public class CommonExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    protected ResponseEntity<BaseResponse<Map<String, String>>> methodArgumentNotValidException(MethodArgumentNotValidException ex) {
        Map<String, String> errors = new HashMap<>();

        ex.getBindingResult().getAllErrors().forEach(error -> {
                String fieldName = (error.getObjectName());

                String errorMessage = error.getDefaultMessage();
                if (errorMessage == null || errorMessage.isBlank()) errorMessage = "Not Exception Message";

                errors.put(fieldName, errorMessage);
            }
        );

        return new ResponseEntity<>(
                new BaseResponse<>(
                        ResultCode.ERROR.name(),
                        errors,
                        ResultCode.ERROR.getDescription()
                ),
                HttpStatus.BAD_REQUEST
        );
    }

    @ExceptionHandler(BadCredentialsException.class)
    protected ResponseEntity<BaseResponse<Map<String, String>>> badCredentialsException(BadCredentialsException e) {
        return new ResponseEntity<>(
                new BaseResponse<>(
                        ResultCode.ERROR.name(),
                        Map.of("로그인 실패", "아이디 혹은 비밀번호를 다시 확인하세요."),
                        ResultCode.ERROR.getDescription()
                ),
                HttpStatus.BAD_REQUEST
        );
    }

    @ExceptionHandler(InvalidInputException.class)
    protected ResponseEntity<BaseResponse<Map<String, String>>> invalidInputException(InvalidInputException ex) {
        String ex_message = ex.message.isBlank() ? "Not Exception Message" : ex.message;

        Map<String, String> errors = Map.of(ex.fieldName, ex_message);

        return new ResponseEntity<>(
                new BaseResponse<>(
                        ResultCode.ERROR.name(),
                        errors,
                        ResultCode.ERROR.getDescription()
                ),
                HttpStatus.BAD_REQUEST
        );
    }

    @ExceptionHandler(Exception.class)
    protected ResponseEntity<BaseResponse<Map<String, String>>> defaultException(Exception ex) {
        String ex_message = ex.getMessage().isBlank() ? "Not Exception Message" : ex.getMessage();

        Map<String, String> errors = Map.of("미처리 에러", ex_message);

        return new ResponseEntity<>(
                new BaseResponse<>(
                        ResultCode.ERROR.name(),
                        errors,
                        ResultCode.ERROR.getDescription()
                ),
                HttpStatus.BAD_REQUEST
        );
    }
}

 

 

☆ @RestControllerAdvice

 실은 그냥 ControllerAdvice도 있는데, 앞에 Rest가 붙게되면, RESTful API, 즉 json 통신을 위한 ControllerAdvice 이다. 마치 @Controller 와 @RestController 의 차이와 같다고 보면 된다.

 

 이 어노테이션을 달아주면 Spring MVC 컨테이너가 사용자 정의 예외 처리 로직을 모아둔 클래스구나 - 하고 인식하여, 전역적으로 예외들을 낚아채서 처리해준다.

 

☆ @ExceptionHandler(특정 Exception.class)

 이 어노테이션을 달아주면 Spring MVC 컨테이너가 연결한 특정 Exception.class 에 해당하는 에러가 터질때마다 해당 메소드로 연결해준다. 만약 이 어노테이션(@ExceptionHandler)을 단 메서드가 특정 컨트롤러 내부에 있다면, 그 컨트롤러 내부에서 터지는 에러들만 낚아채겠지만, 만약 @RestControllerAdvice 어노테이션이 달려있는 클래스 내부에 이 어노테이션(@ExceptionHandler)을 단 메소드를 배치해놓는다면, 전역적으로 해당 에러가 터졌을 때, 어느 컨트롤러에서 터지든 모든 해당 에러를 낚아채서 처리하고 사용자가 정의한 방식대로 반환한다.

 

☆ ResponseEntity

Spring Framework 에서 제공하는 클래스 중 하나이다. HTTP 응답의 전체를 제어할 수 있는 클래스이다. RESTful API 개발에서 클라이언트에게 상태 코드, 헤더, 바디를 포함한 완전한 HTTP 응답을 반환하는 데 사용된다.

 

 

2. ResponseStatusExceptionResolver

  • 예외 클래스에 선언된 @ResponseStatus 어노테이션을 처리하여 HTTP 상태 코드를 반환한다.
  • 하지만 헤더나 바디 없이 그저 상태 코드만을 반환하기도 하고, Restful API 에서 자주 사용하는 방식인 RestControllerAdvice 에서 잘 안쓰이기 때문에, 세밀한 제어가 안되어 잘 안쓰인다.
@ResponseStatus(HttpStatus.NOT_FOUND)
public class PageNotFoundException extends RuntimeException {
    public PageNotFoundException(String message) {
        super(message);
    }
}

@Controller
public class HtmlController {

    @GetMapping("/page")
    public String getPage() {
        throw new PageNotFoundException("Page not found");
    }
}

 

 

☆ @ResponseStatus

 이 어노테이션은 단순한 상태 로직만 반환하는데다가, @RestControllerAdvice + ResponseEntity 조합에 밀려 잘 사용되지 않는 이유로 RESTful API  서비스에서는 잘 사용되지는 않는다.

 그러나 전통적인 Java 웹 애플리케이션(JSP, Thymeleaf 등) 에서는 HTML 뷰 페이지를 반환하는 것이 주된 목표가 되기 때문에, 예외 발생 시 상태 코드와 함께 특정 에러 페이지를 반환하는 방식이 일방적이다. 따라서 간단한 예외 처리나 상태 코드 반환이 필요할 때 예외 클래스에 @ResponseStatus를 붙여 처리한다.

 

 

3. DefaultHandlerExceptionResolver

  • Spring MVC가 정의한 표준 예외이다. 자주 발생하는 기본 예외에 대한 처리를 제공한다.
  • 발생한 예외를 Spring 의 표준 예외 목록과 비교하여 해당 예외를 처리한다.
  • HTTP 상태 코드와 기본 에러 페이지를 반환한다.
  • 보통 별도의 커스텀 처리가 필요하지 않은 표준 예외들에 해당한다.

예를 들어,

  • HttpRequestMethodNotSupportedException → 405 Method Not Allowed.
  • HttpMediaTypeNotSupportedException → 415 Unsupported Media Type.
  • HttpMessageNotReadableException → 400 Bad Request.
  • 기타 Spring 내부 표준 예외들.

 

실제 해당 구현체를 들어가보면 구현이 되어 있다.

더 자세한 코드는 직접 스프링 라이브러리를 통해서 들어가보면 된다.

 

기본 구현체 들의 호출 순서.

Spring MVC는 기본적으로 우선순위에 따라 HandlerExceptionResolver를 호출한다.

  1. ExceptionHandlerExceptionResolver
  2. ResponseStatusExceptionResolver
  3. DefaultHandlerExceptionResolver

HandlerExceptionResolverComposite가 이들 구현체를 관리하며, 첫 번째로 예외를 처리할 수 있는 Resolver가 동작하면 이후 Resolver는 호출되지 않는다.

 

Spring의 Bean Validation 표준(JSR 380)

스프링에는 검증에 관련한 표준이 존재한다. JSR 380 은 Java Bean Validation 2.0의 표준사양이다. 이름하여 Java Specification Request 의 약자로, Java 기술 표준에 대한 제안서를 의미한다. 뒤에 붙은 380은 버전의 고유 식별 번호이다. Java 객체의 속성 값이 특정 조건을 충족하는지 검증하는 표준을 제공하며, 객체의 속성 값을 검증하기 위한 어노테이션 기반의 유효성 검증 메커니즘을 제공한다.


Spring은 이 표준을 기반으로 Hibernate Validator와 같은 구현체를 활용하여 유효성 검증을 지원한다. 이때, Hibernate ValidatorJSR 380 (Bean Validation 2.0) 표준의 `참조 구현체(reference implementation)` 로, Java 객체의 유효성 검증을 지원하는 오픈 소스 라이브러리이다. 즉, 쉽게 말해서, 검증을 쉽게 도와주도록 Spring 이 미리 짜둔 구현체를 의미한다. 어느정도 구현해놓은 기본적인 검증 로직을 제공하기 위해서는 그 코드(구현체)를 어딘가에는 라이브러리의 형태로 저장해놓고 편리하게 불러와서 사용할 수 있어야 할 것이다. 그 구현체가 바로 Hibernate Validator이다.

 

커스텀 Validator를 만드는 방법.

요약(미리보기) :
사용자 정의 어노테이션과 ConstraintValidator 인터페이스를 조합하여 원하는 검증 로직을 작성하면 된다.

 

단계별로 살펴보자.

참고. 실제 현재 진행중인 사이드 프로젝트인 [From-At.] 의 코드에서 발췌했다.

1. 사용자 정의 애노테이션을 생성하자.

package promo.back.fromat.common.annotation;

import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import promo.back.fromat.common.validator.ValidEnumValidator;

import java.lang.annotation.*;

@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Constraint(validatedBy = {ValidEnumValidator.class})
public @interface ValidEnum {
    String message() default "Invalid enum value"; // 기본 오류 메시지
    Class<?>[] groups() default {}; // 검증(Validation) 그룹
    Class<? extends Payload>[] payload() default {}; // 메타 정보
    Class<? extends Enum<?>> enumClass();
}

 

참고로, 검증대상은 Gender를 구분하는 Enum 클래스이다.

 

애노테이션에서는 속성이 추상 메서드 형태로 정의되기 때문에, 메소드처럼 속성의 마지막에 () 을 붙인다. 애노테이션의 속성은 정적 데이터를 나타내지만, 이 데이터를 메서드 호출처럼 동적으로 접근할 수 있도록 설계한 것이다. 이렇게 설계한 이유는 애노테이션의 값도 타입 안정성과 일관성을 유지하기 위함이다.

 

또한 기본값을 제공하여, 값이 생략되더라도 안정적으로 사용할 수 있도록 하며, 속성을 메서드로 정의했기 때문에 런타임 시점에 리플렉션을 이용하여 값을 쉽게 조회할 수도 있다.

 

2. Validator 구현체를 작성하자.

package promo.back.fromat.common.validator;

import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import promo.back.fromat.common.annotation.ValidEnum;

import java.util.Arrays;

public class ValidEnumValidator implements ConstraintValidator<ValidEnum, Enum<?>> {
    private Enum<?>[] enumValue;
    @Override
    public void initialize(ValidEnum constraintAnnotation) {
        enumValue = constraintAnnotation.enumClass().getEnumConstants();
    }

    @Override
    public boolean isValid(Enum<?> value, ConstraintValidatorContext context) {
        if (value == null) return true;
        return Arrays.stream(enumValue)
                .anyMatch(it ->
                        it.name().equals(value.toString())
                );
    }
}

 

★ ConstraintValidator 에 대하여.

이 인터페이스는, JSR 380 표준에 정의된 인터페이스로, 특정 검증 어노테이션과 해당 검증 로직을 연결하기 위한 기반 인터페이스이다. 

 

★ Hibernate Validation 구현체에 대하여.

위 코드에서 작성한 ValidEnumValidator 는 커스텀 Validation 구현체이지만, 스프링에는 다양한 기본 HibernateValidation 구현체가 존재한다. 흔히 검증 로직으로 사용하는 @NotNull, @Size, @Email 등이 모두 위의 방식으로 구현되어 있다. 나는 그 방식을 그대로 채택하여 커스텀 검증 구현체를 만들었을 뿐이다.

 

 

3. 이제 이렇게 구현한 커스텀 어노테이션을 적용해보자.

package promo.back.fromat.common.status;

public enum Gender {
    MALE,
    FEMALE
    ;
}

 

검증 대상인 Gender Enum 클래스이다.

 

package promo.back.fromat.member.dto;

import jakarta.persistence.*;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Builder;
import lombok.Getter;
import lombok.Setter;
import promo.back.fromat.common.annotation.ValidEnum;
import promo.back.fromat.common.dto.BaseDto;
import promo.back.fromat.common.status.Gender;
import promo.back.fromat.member.entity.Member;

import java.time.LocalDate;
import java.util.Objects;

@Getter @Setter @Builder
public class MemberUpdateDto extends BaseDto {

    @Email
    @NotBlank
    @Column(nullable = false, length = 254, updatable = false)
    private String email;

    @Column(length = 10)
    private String name;

    @Column(length = 5)
    @Enumerated(EnumType.STRING)
    @ValidEnum(
            enumClass = Gender.class,
            message = "MAIL 이나 FEMALE 중 하나를 선택해주세요."
    )
    private Gender gender;

    public String getGender() {
        return gender.name();
    }

    public void setGender(String gender) {
        if (gender.equals("MALE")) this.gender = Gender.MALE;
        else this.gender = Gender.FEMALE;
    }

}

 

DTO 에 내가 만든 커스텀 검증 애노테이션을 검증하고 싶은 GenderEnum에 추가해준다.

 

 

 

package promo.back.fromat.member.controller;

import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;
import promo.back.fromat.common.dto.BaseResponse;
import promo.back.fromat.member.dto.MemberSignupDto;
import promo.back.fromat.member.dto.MemberUpdateDto;
import promo.back.fromat.member.entity.Member;
import promo.back.fromat.member.service.MemberService;

import java.util.List;

@Slf4j
@RequestMapping("/api/member")
@RestController
@RequiredArgsConstructor
public class MemberController {

    private final MemberService memberService;

    @GetMapping()
    public List<Member> getAllMembers() {
        return memberService.getAllMember();
    }

    /**
     * 회원가입
     */
    @PostMapping("/signup")
    public BaseResponse<Void> signup(@RequestBody @Valid MemberSignupDto requestDto) {
        String resultMessage = memberService.signup(requestDto);
        return new BaseResponse<>(resultMessage);
    }
}

 

컨트롤러에서 해당 DTO를 검증하기 위해서는 반드시 @Valid를 넣어주어야 한다. 그래야 검증 대상 DTO로 인식이 된다.

일반적으로 생각하는 단순 예외 처리 방법.

public String someServiceLogic() {
    if (someCondition) {
        throw new NullPointerException("예외 발생");
    }
    return "success";
}

그냥 서비스단에서 냅다 throw 를 던져버린다.

 

그러나 이런 방식으로 처리했을 때, 다음과 같은 특징이 있다.

 

  • 예외 처리를 서비스 로직, 컨트롤러 등에서 처리해야 한다. (try-catch)
  • 예외 처리 응답이 일관성이 지켜지지 않을 수 있다. 각 메서드마다 독립적으로 작성하기 때문이다.
  • 각 메서드에 지저분(?)하게 중복된 try-catch 로직이 포함될 가능성이 있다.
  • 유지보수성 측면에서도 좋지 않다. 예외 로직 변경시, 모든 메서드를 수정하게 되기 때문이다.
  • 공통되거나 혹은 일관적으로 규칙이 있는 예외 처리 로직에 대해서 관리하기 불편하다.

 

Spring MVC 는 예외 처리 매커니즘으로 HandlerExceptionResolver를 만들었다.

import org.springframework.web.servlet.HandlerExceptionResolver;
import org.springframework.web.servlet.ModelAndView;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

public class CustomExceptionResolver implements HandlerExceptionResolver {

    @Override
    public ModelAndView resolveException(
            HttpServletRequest request,
            HttpServletResponse response,
            Object handler,
            Exception ex) {

        try {
            // 특정 예외에 대한 처리
            if (ex instanceof NullPointerException) {
                response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
                response.getWriter().write("{\"error\":\"Null Pointer Exception 발생\"}");
            } else if (ex instanceof IllegalArgumentException) {
                response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
                response.getWriter().write("{\"error\":\"Invalid argument\"}");
            } else {
                // 기본 처리
                response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
                response.getWriter().write("{\"error\":\"Unexpected error occurred\"}");
            }
        } catch (IOException e) {
            e.printStackTrace();
        }

        // 뷰 반환하지 않음 (REST API라면 null)
        return new ModelAndView();
    }
}

 

이런식으로 HandlerExceptionResolver 를 상속받은 다음에, 해당 커스텀 Resolver 클래스에,

원하는 Exception 종류를 if문을 통해서 판별한 다음에, response 로 보낼 처리 로직을 작성해주면 되는 것이다.

 

이렇게하면 내가 서비스 로직에서 throw를 통해 던진 예외를 resolver 가 낚아채서, 예외를 처리해준다.

 

그렇다. 나는 throw로 예외만 던지면 되는것이다! !

 

하지만 Resolver 클래스를 작성만 해준다고, 스프링이 해당 Resolver를 인식할 수 있는 것은 아니다.

다음과 같이 Config 에 등록해주어야 한다.

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.HandlerExceptionResolver;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import java.util.List;

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
        resolvers.add(new CustomExceptionResolver());
    }
}

 

여러 Resolver 들을 등록할 수 있는 List<HandlerExceptionResolver> resolvers 라는 리스트에, 추가하고 싶은 resolver 들을 add 메소드를 통해서 추가하면, 리졸버가 등록된다.

 

 

이제 나는 다음과 같이 서비스 로직에서 NullPointerException만 날리면 Resolver 가 낚아채서 예외 처리 로직을 수행해준다!!

import org.springframework.stereotype.Service;

@Service
public class ExampleService {

    public String executeLogic(boolean throwError) {
        if (throwError) {
            throw new NullPointerException("Test NullPointerException");
        }
        return "Success";
    }
}

 

Spring MVC에서 예외 처리를 커스터마이징해서,

발생한 예외를 Spring MVC의 예외 처리 흐름에서 중앙 집중적으로 처리하려면,

 

1. HandlerExceptionResolver를 구현하여 resolveException 메서드에 예외 처리 로직을 작성

2. 이를 Spring 컨텍스트에 등록 (WebMvcConfigurer)

3. WebMvcConfigurer의 extendHandlerExceptionResolvers 메서드를 Override

4. 오버라이드한 메서드에 커스텀 Resolver를 추가(add 함수)

 

 

그러니까, ExceptionResolver를 사용하는 이유?

  • 서비스 로직의 외부에서 예외 처리를 할 수 있다. (지저분?한 try-catch 불필요)
  • 중앙화된 Resolver 에서 통일성 있게 예외 처리를 할 수 있다.
  • 일관된 응답(response)를 제공할 수 있으며, resolver 만 수정하면 전체 적용이 가능하다.
  • 특히 REST API 응답의 통일성이 필요할 때 유용하다.
  • 다만 Resolver 구현 및 Spring 설정(Config)이 필요하긴 하다.

서블릿 API를 음식점으로 비유하면?

"API 처리 도구는 음식점에서 종업원이나 키오스크 같은 존재"

종업원(API 처리 도구)이 없다면 고객(사용자)과 주방(서버) 사이의 소통이 원활하지 않을 것이다. 그도 그럴것이, 특정 음식점(웹 서비스)에 처음 들어온 고객(사용자)은 그 음식점(웹 서비스)의 메뉴(서비스 요청 규격)에 대해 잘 모를 수 밖에 없다. 메뉴판(API 규격)이나 안내해주는 종업원(API 처리 도구)이 없다면, 고객은 자신이 원하는 음식을 어떻게 알고 주문할 것인가.

 

API와 서블릿의 역할

이처럼 API는 사용자(고객)의 요청과 서버(주방)의 응답 사이를 연결해주고,
각 요청과 응답을 효율적으로 처리하는 중요한 역할을 맡고 있다.

 

즉, `음식점 API = 메뉴판`, `음식점의 주문을 처리해주는 도구 = 종업원` 인 셈이다.

이 논리를 고~ 대로 서블릿에 갖다 넣으면 된다.

서블릿 API란?

서블릿 API는 서블릿을 통해 요청과 응답을 처리하는 표준 규격이다.

서블릿 API는 다음과 같은 중요한 역할을 한다.

  1. 클라이언트(사용자)의 HTTP 요청을 받아들여 처리.
  2. 요청 데이터를 분석하고 필요한 작업을 실행.
  3. 서버에서 처리된 결과(응답)를 사용자에게 반환.

서블릿 API는 이를 효율적으로 처리하기 위한 표준 인터페이스와 규칙을 제공한다.
서블릿 컨테이너(Tomcat, Jetty 등)는 이 규격을 따라 동작하며, 개발자는 서블릿 API를 기반으로 서블릿을 작성해 클라이언트와 서버 간의 소통을 원활히 할 수 있다.

 

이때 스프링은 내장 톰캣에 서블릿 API가 있다! !

스프링 부트는 서블릿 API를 내장 톰캣을 통해 포함하고 있다. 이를 통해 개발자는 서블릿 컨테이너를 따로 설정하거나 설치하지 않고도 HTTP 요청과 응답을 효율적으로 처리할 수 있다. 특히 스프링 MVC의 핵심인 `DispatcherServlet`은 서블릿 API의 `HttpServlet`을 확장하여 만들어졌으며, 요청-응답 흐름의 중심 역할을 담당한다.

 

 

스프링이 서블릿 API를 사용하는 핵심 요소

 

DispatcherServlet: 스프링의 핵심 서블릿

  • DispatcherServlet은 스프링 MVC에서 요청과 응답을 처리하는 프론트 컨트롤러 역할을 한다.
  • 서블릿 API의 HttpServlet 클래스를 확장하여 만들어졌으며, 모든 HTTP 요청을 받아 처리한다.

  • 동작 과정:
    1. 클라이언트의 요청이 들어오면 서블릿 컨테이너가 DispatcherServlet에 요청을 전달한다.
    2. DispatcherServlet은 요청을 분석하고, 적절한 컨트롤러로 라우팅한다.
    3. 컨트롤러가 비즈니스 로직을 처리하고, 결과를 DispatcherServlet에 반환한다.
    4. DispatcherServlet은 결과를 기반으로 클라이언트에 응답을 반환한다.

근본적으로 Entity 와 DTO 가 왜 분리되어 있는지 생각해보자.

이름 자체에서 이미 그 답이 나온다.
 

Entity = Domain Model = DB에 직접 매핑되는 실제 객체

DTO = Data Transfer Object = 데이터를 전송하고 변환하는데 초점맞춘 객체

 
 
그렇다. 

비즈니스 로직과 데이터 표현을 분리하여 " 관심사를 분리 " 하는 것이다.

Entity는 데이터베이스와 직접적으로 매핑되어 비즈니스 로직을 처리하는 역할을 담당하며, 데이터베이스의 구조와 밀접하게 연결된다. 반면, DTO(Data Transfer Object)는 클라이언트와의 데이터 교환을 위한 객체로, 주로 요청과 응답에 필요한 데이터만을 담아 효율적인 데이터 전송에 초점을 둔다. 이렇게 구분하면 Entity는 비즈니스 로직에 집중할 수 있고, DTO는 필요한 데이터만 선택적으로 제공하여 데이터 전송을 최적화할 수 있다.
 
이렇게하면 각 객체가 자신의 책임에만 집중할 수 있어 유지보수성이 향상되고, 코드의 구조가 명확해진다.
 
 

그러니까 유효성 검사라는 것은 결국 "사용자"가 어떤 값을 입력할지 모르기 때문에 진행하는 것이다. 그와 반면에 엔티티의 경우는 이미 DB로 확정되어 만들어진 것이기 때문에 형식이 안맞을 일이 거의 없다.

 
DTO에서 유효성 검사를 하는 핵심 이유는 사용자가 입력할 데이터의 형식이나 내용이 예상과 다를 수 있기 때문이다. 클라이언트에서 서버로 전달되는 데이터는 신뢰할 수 없기 때문에, 서버는 안전하고 일관된 데이터만 비즈니스 로직으로 전달되도록 해야 한다. DTO는 이러한 데이터를 검증하고 필터링하는 역할을 담당한다. 이것이 DTO의 존재이유다.
 
반면에 Entity는 이미 데이터베이스와 구조가 확정된 상태로, 엔티티에 저장된 데이터는 기본적으로 데이터베이스 스키마와 일치해야 한다. 즉, 데이터베이스에 저장되는 데이터는 스키마에 의해 형식이 이미 보장되므로, 데이터의 형식이 맞지 않거나 유효하지 않을 가능성이 매우 낮다. 따라서 Entity는 데이터 형식 검증보다는 비즈니스 로직에만 집중할 수 있다.
 
 

결론.

 

  • DTO에서 유효성 검사를 수행하는 이유는 클라이언트로부터 받은 데이터가 형식이나 값의 조건을 만족하는지 검증하여, 서버로 유효한 데이터만 전달하기 위해서이다.
  • Entity는 데이터베이스와 매핑된 구조로 데이터 형식이 미리 결정되어 있으며, 데이터의 일관성을 보장하는 역할을 하기 때문에 추가적인 형식 검증이 필요하지 않다.

 

+ Recent posts