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')
개인을 각각의 서비스로 상상해보면 일반적인 의사소통을 하면 전달하는방식 (언어, 글쓰기 등)은 유사하지만 그 안에 담기는 내용은 다르며, 같은 메시지도 해석하여 동작하는 방식이 다르다. 유사한 내용을 다루어 본 사람끼리 더 잘 통하고 이해하며, 내가 잘 모르는 내용에 대해서는 반만 듣고 반은 버린다. 때로는 상대의 공격적인 언행에 대해서는 그냥 내가 필터링을 하거나 한귀로 듣고 한귀로 흘려버리기도 한다.
또 어느날 어떤 사람이 컨디션이 평상시와 다르면 말을 더 많이 하거나, 혹은 말을 하지 않기도 하며 가끔은 누군가 괜찮은지 안부인사를 묻기도 한다.
2. Service 개인을 각 서비스로 또 상상해보자. 서비스가 동작하는 것은 누군가에게 어떠한 기능을 제공해주기 위해서 존재하기도 하지만 스스로의 목적을 추구하기도 한다. 누군가를 위해서 어떤 일을 해주기도 하고 혹은 누군가가 해주는 어떤 일을 받기도 해서 내가 원하는 바를 달성한다. 혼자서 모든일 을 처리할 수 없기 때문에 다양한 타인/조직, 물건,생물등 소통하는 것은 당연한 것이다.
3. Role 맡고 있는 역할과 책임이 존재한다. 누군가의 스승, 누군가의 동료, 누군가의 배우자. 각각의 역할에는 기대하는 바가 있고 그 기대하는 바가 적절히 충족되어야 상대방이 만족한다. 만약 그 기대하는 바가 완벽하게 충족되지 않는다면 스스로 방법을 찾아보거나 부족한 부분을 대신할 수 있는 다른 것을 찾게 된다.
4. Error / Fault / Failure Error는 언제나 발생한다. 늦잠을 잘 수도 있고, 과식을 할 수도 있고, 물건을 놓칠 수도 있다. Error가 가끔은 Fault로 연결된다. 지각을 하기도 하고, 소화가 안되고, 물건이 땅에 떨어진다. (놓쳐다가 잡으면 안떨어진거니까..) 그리고 이것들이 누적되면 Failure로 연결되며 우리는 타격을 입게 된다. 성적을 망친다던지, 앓아눕던지, 소중한 물건을 망가뜨리거나..
실수를 막기 위해서 시계알람을 울리거나, 미끄럼 방지스티커를 부착하는 것 같이 작은 안전장치가 큰 불이익을 막을 수 있다.
5. Waiting 전자제품이 고장났다. -> AS센터에 전화를 한다. -> 전화를 안받는다. 받을 때까지 전화를 건다. -> 전화를 받았다. -> 담당AS기사를 확인해보고 10분뒤에 전화를 준다고 한다. -> 10분동안 전화를 기다린다. -> 전화가 다시 온다. 글로만 읽어도 답답하고 에너지소모가 많다.
요즘은 전자제품이 고장났다 -> App을 통해서 접수한다. -> 내 할일 한다. 잠시 후 담당AS기사 방문일정이 알람으로 온다. -> 확인하고 다시 내 할일 한다.
물론 매우 중요한 일은 여전히 기다려야 한다. e.g) 인증, 결제 ARS
<정리>
우리의 이러한 생활을 관찰하다보면 Software Architecture에서의 해답점과 연결점을 많이 찾아낼 수 있다.
1. Protocol
- 주고 받는 메시지의 규약을 정하고, 그에 해당하는 메시지를 받았을 때 수행하는 동작을 서로 약속한다. (e.g HTTP, TCP/IP, Json 등)
- Health Check를 통해서 각 노드/서비스의 상태를 주기적으로 확인한다.
2. Component / Sequence
- 최종 얻기 위한 결과물을 위해서 필요한 구성요소를 정의하고 (Component)
- 어떠한 순서로 요청하여 받은 결과물을 활용할 것인지 그려본다. (Sequence)
3. Exception Catch / Retry / Side-car
- 명시적으로 일어날 수 있는 오류에 대해서는 사전에 처리를 하고 예방한다.
- 일시적인 오류에 대해서는 다시 시도해보고
- 보다 큰 오류로 전파되는 것을 미리 막아둔다. (나만 죽는게 낫다)
4. Async / Non-blocking / Timeout
- 반드시 모든 작업이 동시에 끝나야 할 필요는 없다. (Async)
- 오래 기다려야 하는 작업(내가 제어할 수 없는 일) 은 맡겨놓고 다른 작업을 한다. (Non-blocking)
- 완료되었다는 것을 인지하면 그 때 후속작업을 수행한다. (callback, future)
- 동시에 끝나야 하는 작업이 있을 수도 있고, 응답을 반드시 확인해야 하는 경우도 있다. (Sync)
- 언제까지 끝난다고 장담할 수 있는 일은 없다. 적절한 시점에는 포기해야 한다. (unbounded delay, timeout)
=> 이부분은 사실 원격요청을 하지 않고 제공되는 공개키, pem을 통해서 로컬에서 검증이 가능하지만 현재 java로는 직접 구현해야 하는 부분이 많고, 자주는 아니지만 공개키는 변경될 수 있기 때문에 키값을 확인하기 위해서는 결국 원격요청이 필요해서 쉽게 가기로 했다.
-- "cognito:groups" 정보를 이용해서 권한을 세팅한다.
B. 기존 토큰이라면
- secretKey를 이용해서 검증하고
- "roles" 정보를 이용해서 권한을 세팅한다.
<정리>
이렇게 만들면 기존의 JWT Token(HS256) 와 신규 Cognito Token (RSA256) 를 모두 소화할 수 있다.
그러나 만들어 놓고보니 소스가 지저분하다. ResourceServer에서 권한을 확인하여 API접근제어를 해야하는데 좋은 구조는 아니다.
- 로직이 복잡하고(구조 특성상 해당모듈을 그대로 copy해가는 방식이 될텐데 유지보수성이...영), secretKey 공유의 문제도 있다.
- 꼭 두 가지 토큰을 소화할 필요가 있을까?
- 기존 방식은 외부 사용자가 고려되지 않은 방식이고, OAuth 2.0은 출발자체가 3rd Party연계를 간편하게 하기 위한 방법이다.
다양한 확장을 위해서 인증은 각각의 방식을 유지하고 서비스를 사용자에 따라서 분리하는 것이 해결책이라는 판단을 내렸다.
- 내부 사용자가 이용하며 Client, Resource Server가 같은 Boundary에 있는 레거시의 경우 기존 토큰을 사용하고
- 외부 사용자가 이용해야 하는 API 서비스는 별도로 분리 구성하며, 이 서비스에서는 OAuth 2.0만을 활용하여 인가/인증을 하도록 한다.
(이부분은 AWS API Gateway내에서 자격증명으로 Cognito를 연계하는 방식과 시너지를 낼 수 있을 것 같다.)
- 이를 위해서 보다 쉽게 Resource Server를 세팅하는 법을 Spring Security에서 제공하고 있는데 다음글에서 좀 더 자세히 적용해보고 서비스를 어떻게 분리하는 것이 보다 효과적일지 살펴본다.