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')
=> 이부분은 사실 원격요청을 하지 않고 제공되는 공개키, 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에서 제공하고 있는데 다음글에서 좀 더 자세히 적용해보고 서비스를 어떻게 분리하는 것이 보다 효과적일지 살펴본다.
- 신규 프로젝트에서는 기술스택을 Spring WebFlux 로 선정하였다. 그 이유는 다음과 같다.
a. 기본적으로 Spring, Java에 대한 이해도가 높다. 하지만 Legacy 코드는 없다.
b. 데이터에 대한 읽기 연산이 대부분이고, 특별한 보안처리나 트랜잭션 처리가 필요없다. (참조해야할만한 Dependecny 가 적다.)
c. 저장공간으로 Redis Cache를 활용한다. 즉, Reactive를 적극 활용할 수 있다.
d. 다수의 API 호출을 통해서 새로운 결과를 만들어 낸다.
즉, IO / Network의 병목구간을 최소화 한다면 자원활용을 극대화 할 수 있을 것으로 보인다.
<진행내용>
- 기존의 For loop 방식과 Async-non blocking 차이,그리고 Mono / Flux 를 살펴본다. (Spring WebFlux)
@ReactiveRedisCacheable
public Mono<String> rawApiCall(...) throws .Exception {
Mono<String> response = webClient
.get()
.uri(url)
.retrieve()
.onStatus(HttpStatus::is4xxClientError, clientResponse -> Mono.error(new Exception(...)))
.onStatus(HttpStatus::is5xxServerError, clientResponse -> Mono.error(new Exception(... )))
.bodyToMono(String.class)
.timeout(Duration.ofMillis(apiTimeout))
.onErrorMap(ReadTimeoutException.class, e -> new Exception(...))
.onErrorMap(WriteTimeoutException.class, e -> new Exception(...))
.onErrorMap(TimeoutException.class, e -> new Exception(...));
return response;
}
webClient를 이용해서 타 API를 호출하는 부분이다. 응답값에는 다수의 건이 포함되어 있으나 해당 데이터를 보내는 쪽에서도 병렬처리를 진행하고 있기 때문에 Collection 이나 Array 형태로 처리하는 부분을 제외하고 그냥 Raw line 형태로 제공하고 있다.
Spring MVC기반에서는 이 값을 꺼내기 위해서 결국 block하고 값에 접근하는 로직이 필요하다. 굳이 코드로 구현하자면 아마도 이렇게 만들어 질 것이다.
List<ApiResponse> ret = new ArrayList<>();
for(String value : Collection ... ){
String contents = apiService.rawApiCall(value).block();
String[] lines = contents.split("\n");
for(String data : lines){
if(StringUtils.hasText(data)){
ApiResponse apiResponse = mapper.readValue(data, ApiResponse.class);
if(populationHourApiResponse .. ){
// biz logic
FinalResponse finalResponse = new FinalResponse();
// setter
...
..
ret.add(finalReponse);
}
}
}
}
이 코드에는 여러가지 문제점이 있는데
- block()을 수행하게 되면 비동기 넌블러킹 처리의 여러 장점이 사라진다.
- 오히려 더 적은 수의 쓰레드를 사용해야 하는 구조특성상 block이 생기면 더 병목이 발생하는 경우도 있다.
- return 에 얼만큼의 데이터가 담길지 모르게 된다.
- API Call 이후 biz logic의 수행시간이 길어질 수록 전체 응답시간은 더욱 길어진다.
해당 내용을 block없이 처리하도록 Flux를 최대한 활용하여 작성해보았다.
public Flux<FinalResponse> getDataByConditionLevel1{
List<Mono<String>> monoList = new ArrayList();
for(String value : Collections ...)){
monoList.add( apiService.rawApiCall(value) );
}
return
Flux.merge(monoList)
.flatMap(s -> Flux.fromIterable(Arrays.asList(s.split("\n"))))
.filter(s -> StringUtils.hasText(s))
.map(data -> {
try {
return mapper.readValue(data, PopulationApiResponse.class);
} catch (JsonProcessingException e) {
log.error(e.getLocalizedMessage());
}
return new ApiResponse();
})
.filter(aApiResponse -> ... biz logic)
.map(apiResponse ->
new FinalResponse(...)
);
}
주요하게 바뀐부분을 살펴보면 다음과 같다.
1. API응답의 결과를 block해서 기다리지 않고 Mono를 모아서 Flux 로 변환한다.
Mono는 0..1건의 데이터, Flux는 0..N건의 데이터를 처리하도록 되어있다.
즉 개별 Mono를 대기하여 처리하는 것이 아니라 하나의 Flux로 모아서 단일 Stream처럼 처리할 수 있다,.
2. 값이 아니라 행위를 넘겨준다.
Spring WebFlux에서는 기본적으로 Controller - Service - Dao 등의 Layer간 이동을 할때 Mono / Flux 를 넘겨준다.
즉, 어떠한 값을 보내는 것이 아니라 Mono / Flux로 구성된 Publisher를 전달해주면 subscribe를 통해서 실제 데이터가 발생될 때 우리가 정의한 Action을 수행하는 형태가 된다고 이해하면 될듯 하다. (Hot / Cold 방식의 차이가 있는데 일단 Skip하도록 한다.)
위의 로직은 각 개별 데이터 간의 연산이나 관계가 없기 때문에 비교적 쉽게 변경할 수 있었다.
하지만 해당 데이터를 다시 조합하거나 Grouping 하거나 하는 경우가 있다면 약간 더 복잡해질 수 있기 때문에 고민이 필요하며 각 비지니스 케이스에 적합한 단위와 연산으로 재설계를 해주는 것이 좋다. ( -> 필수다 !)
예를 들어서 rawApiCall에 필요한 인자값이 yyyyMMdd hh:mm:ss 형태의 timeStamp라면 특정기간 내 시간대별 결과를 얻기 위해서는 다음과 같이 Call을 하고 조합해야 한다.
즉 수행해야 하는 액션은 다음과 같다.
- Flux를 응답받는 메소드를 다시 감싸서
- 응답결과를 적절하게 Biz Logic에 따라서 처리한 뒤
- aggreation 을 통하여 새로운 응답을 만들어 낸다. (e.g 그룹별 개수, 합계, 평균 등등)