OAuth 2.0 Resource Server에서 JWT 토큰을 검증하고 권한을 처리하는 방법에 대해 알아보겠습니다. Spring Security를 사용하여 Resource Server를 구성하고, JWT 토큰의 scope와 roles 클레임을 권한으로 변환하는 과정을 살펴볼 것입니다. 또한, 자주 발생하는 SSL 관련 오류와 그 해결 방법에 대해서도 다룰 예정입니다.
Authorization_code vs Client_credentials
OAuth 2.0의 Authorization Code 방식과 Client Credentials 방식은 서로 다른 사용 사례와 흐름을 가지고 있습니다. 두 방식의 주요 차이점은 다음과 같습니다:
Authorization Code 방식:
사용자 개입이 필요한 인증 흐름
웹 애플리케이션이나 모바일 앱에서 주로 사용
리소스 소유자(사용자)의 동의를 얻어 액세스 토큰을 발급
보안성이 높고 refresh token을 사용할 수 있음
Client Credentials 방식:
클라이언트 애플리케이션이 직접 자신의 자격 증명으로 인증
서버 간 통신이나 백그라운드 작업에 주로 사용
사용자 컨텍스트 없이 클라이언트 자체의 권한으로 액세스 토큰을 얻음
간단하지만 사용자 특정 데이터에 접근할 수 없음
OAuth2 Authorization Server Setup
Spring Boot와 Spring Security를 사용하여 OAuth 2.0 Authorization Server를 구현하기 위해서는 다음과 같은 핵심 설정이 필요합니다:
AWS RDS와 DynamoDB의 비용 및 성능 비교는 데이터베이스 선택 시 중요한 고려사항입니다. 이 분석에서는 두 서비스의 비용 구조, 성능 특성, 그리고 대규모 쓰기 작업 시나리오에서의 비용 효율성을 살펴보았습니다. 또한 고가용성을 위한 중복 구성 시의 비용과 Spring Boot와의 연동 방법에 대해서도 논의하였습니다.
AWS RDS vs DynamoDB 비용비교
AWS RDS와 DynamoDB의 비용 비교에서, 25백만 건의 500바이트 레코드(총 12.5GB)를 기준으로 분석한 결과, DynamoDB가 RDS보다 상당히 높은 비용을 보였습니다. DynamoDB의 월간 비용은 $937.5로 추정되며, 이는 주로 쓰기 작업에 따른 비용입니다. 반면 RDS의 월간 비용은 $198.24로, 인스턴스 비용과 쓰기 작업 비용을 포함합니다. 이러한 차이는 DynamoDB의 쓰기 중심 요금 체계와 RDS의 인스턴스 기반 요금 구조의 차이에서 비롯됩니다.
DynamoDB: 쓰기 작업당 $1.25/백만 건, 스토리지 비용 $0.25/GB/월
RDS: 인스턴스 비용(예: db.t3.medium) + 쓰기 작업 비용 $0.20/백만 건
고가용성 구성 시, DynamoDB의 비용은 $1,406.25로 증가하며, RDS Multi-AZ는 $297.36로 증가합니다
선택은 애플리케이션의 요구사항, 확장성 필요, 그리고 운영 팀의 역량을 고려하여 이루어져야 합니다. DynamoDB는 글로벌 확장성과 관리 용이성에서 우위를 보이며, RDS는 복잡한 쿼리와 트랜잭션 지원에 더 적합합니다.
추가적으로 비용에서 고려할 수 있는 부분
AWS 데이터베이스 서비스의 비용을 고려할 때, 다음과 같은 추가적인 요소들을 염두에 두어야 합니다:
데이터 전송 비용: AWS 리전 간 또는 인터넷으로의 데이터 전송에 따른 추가 비용이 발생할 수 있습니다.
백업 및 복구: RDS의 경우 자동 백업과 수동 스냅샷에 대한 추가 스토리지 비용이 발생할 수 있으며, DynamoDB는 온디맨드 백업과 특정 시점으로의 복구(PITR) 기능에 대한 비용이 추가될 수 있습니다.
성능 최적화: RDS의 경우 쿼리 최적화를 통해 성능을 향상시키고 비용을 절감할 수 있습니다. 예를 들어, AI 기반 최적화 도구를 사용하여 쿼리 성능을 23배까지 향상시킨 사례가 있습니다.
서버리스 옵션: Amazon Athena와 같은 서버리스 쿼리 서비스를 사용하면 데이터 스캔량에 따라 비용이 청구되며, S3 Express One Zone 스토리지 클래스를 활용하여 쿼리 성능을 최대 2.1배 향상시킬 수 있습니다.
이러한 요소들을 고려하여 총소유비용(TCO)을 산정하고, 애플리케이션의 요구사항에 맞는 최적의 데이터베이스 솔루션을 선택해야 합니다.
Spring Boot 환경에서 DynamoDB 를 사용할때 설정해야 하는 부분
Spring Boot 환경에서 DynamoDB를 사용할 때는 다음과 같은 주요 설정을 고려해야 합니다:
- JWT Token을 이용할 경우 일반적으로 Filter에서 해당 토큰을 검증한뒤 필요한 정보를 찾아서 Authentication 인터페이스 를 전달하여 세팅하게 됩니다. 이 Authentication 을 SpringSecurity 에서 일반적인 형태로 구현해놓은 것이 UserNamePasswordAuthenticationToken 입니다.
public Authentication getAuthentication(String token) throws TimeoutException, ExecutionException, InterruptedException {
LoginUserDetails loginUserDetails = this.makeUserDetailsFromToken(token);
return new UsernamePasswordAuthenticationToken(loginUserDetails, "", loginUserDetails.getAuthorities());
}
<Token정보로부터 사용자정보를 얻어낸 뒤 UsernamePasswordAuthenticationToken 생성>
public class UsernamePasswordAuthenticationToken extends AbstractAuthenticationToken {
private static final long serialVersionUID = 620L;
private final Object principal;
private Object credentials;
<Sring Security에 구현되어 있는 클래스>
2. Test Code 작성법
public @ResponseBody
Page<DataDto> findDataAll (@AuthenticationPrincipal LoginUserDetails loginUserDetails,
Pageable pageable) {
- Spring 전체 Context를 기동한다면 가능하겠지만 우리는 일반적으로 Controller Layer를 테스트할때 @WebMvcTest를 사용합니다. (테스트 레이어 분리, 범위 한정 등등의 이유로)
- 문제는 이렇게 진행하면 해당 코드를 테스트할때 그냥 호출하게 되면 loginUserDetails에서는 Null 이 발생하게 됩니다.
- 1번에서 설명했던 Authentication정보를 Context에 세팅하는 부분 (Filter) 이 호출되지 않기 때문입니다.
- 그래서 아래와 같이 수동으로 UserDatails를 세팅해주는 InMemoryUserDetailManager 사용을 권장합니다.
@Bean
@Primary
public UserDetailsService userDetailsService() {
List<SimpleGrantedAuthority> userAuth = new ArrayList<>();
userAuth.add(new SimpleGrantedAuthority("ROLE_USER"));
List<SimpleGrantedAuthority> adminAuth = new ArrayList<>();
adminAuth.add(new SimpleGrantedAuthority("ROLE_ADMIN"));
LoginUserDetails loginUserDetails = new LoginUserDetails(1, "", "user","", userAuth);
LoginUserDetails loginAdminDetails = new LoginUserDetails(2, "", "admin", "", adminAuth);
return new InMemoryUserDetailsManager(Arrays.asList(loginUserDetails, loginAdminDetails));
}
- 그리고 테스트 케이스에서는 @WithUserDetails 어노테이션을 사용해서 앞에서 생성했던 UserDetails정보를 통해서 호출할 수 있습니다. (@WithMockUser, @WithAnonymousUser, @WithSecurityContext 등을 적합한 것을 선택해서 사용하면 됩니다.)
- User 클래스를 상속받아서 별도로 구현한 경우에는 NullPointerException 가 발생하는 경우가 있습니다. (Spring Security 버전에 따라 차이가 있는지는 확인해야 할 것 같습니다.)
public class LoginUserDetails extends User {
private Integer userId;
private String userGroup;
public LoginUserDetails(Integer userId, String password, String userName, String userGroup, Collection<? extends GrantedAuthority> authorities) {
super(userName, password, authorities);
this.userId = userId;
this.userGroup = userGroup;
}
.. 기타 로직구현 메소드
- 이유는 다음과 같습니다. User 클래스를 살펴보면 이 역시 UsenamePasswordAuthenticationToken 처럼 Spring Security에서 UserDetails 인터페이스를 일반적으로 구현해놓은 클래스입니다.
public class User implements UserDetails, CredentialsContainer {
private static final long serialVersionUID = 620L;
private static final Log logger = LogFactory.getLog(User.class);
private String password;
private final String username;
private final Set<GrantedAuthority> authorities;
private final boolean accountNonExpired;
private final boolean accountNonLocked;
private final boolean credentialsNonExpired;
private final boolean enabled;
- 그리고 아까 테스트코드에서 우리가 사용한 InMemoryUserDetailsManager 역시 Spring Security에서 UserDetailsManager를 구현해 놓은 클래스입니다.
public class InMemoryUserDetailsManager implements UserDetailsManager, UserDetailsPasswordService {
protected final Log logger = LogFactory.getLog(this.getClass());
private final Map<String, MutableUserDetails> users = new HashMap();
private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder.getContextHolderStrategy();
private AuthenticationManager authenticationManager;
... 중략 ...
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
UserDetails user = (UserDetails)this.users.get(username.toLowerCase());
if (user == null) {
throw new UsernameNotFoundException(username);
} else {
return new User(user.getUsername(), user.getPassword(), user.isEnabled(), user.isAccountNonExpired(), user.isCredentialsNonExpired(), user.isAccountNonLocked(), user.getAuthorities());
}
}
- Spring Security는 바로 이 loadUserByUsername 을 호출해서 UserDetails를 세팅하게 되는데 User 객체를 받게 됩니다.
-바로 여기가 문제의 원인입니다.
- 비지니스 사항때문에 User를 상속받아서 별도로 구현한 LoginUserDetails는 User의 Sub type입니다.
LoginUserDetails는 User가 될 수 있지만, User는 LoginUserDetails가 될 수 없습니다.
- 이러한 내용을 Controller에서 좀 더 명확하게 확인하기 위해서 (errorOnInvalidType=true)를 사용해봅니다.
public @ResponseBody
Page<DataDto> findDataAll (@AuthenticationPrincipal(errorOnInvalidType=true) LoginUserDetails loginUserDetails,
Pageable pageable) {
- 아래와 같은 오류메시지를 확인할 수 있습니다.
Caused by: java.lang.ClassCastException: class org.springframework.security.core.userdetails.User cannot be cast to class ...LoginUserDetails (org.springframework.security.core.userdetails.User and ...LoginUserDetails are in unnamed module of loader 'app')