쿠폰 발급 이벤트 도메인을 개발을 하게되었다. SpringBoot + JPA +Mysql 환경이고, 유저에게 정해진 수량 내에서 쿠폰을 발급을 해주고, 쿠폰 재고가 다 떨어지면 발급을 막는 간단한 요구사항이었다. 개발 과정에서 큰 어려움은 없었고 테스트도 순조로웠는데, 여러명에 동시에 쿠폰을 발급받는 상황을 가정한 멀티쓰레드 테스트에서 문제가 발생하였다.
1. 테스트코드
우선 작성한 테스트코드는 다음과 같다.
package event.coupon.service;
@Profile("test")
@SpringBootTest
@Transactional
class CouponServiceTest {
@Autowired CouponRepository repository;
@Autowired CouponStockRepository stockRepository;
@Autowired private RedisTemplate<String, String> redisTemplate;
@Autowired private CouponService couponService;
long couponId = 2L;
@BeforeEach
void setup() {
// 테스트 쿠폰 생성
CouponRequest testCoupon = CouponRequest.builder()
.couponName("커밋 쿠폰222")
.planedCount(10L)
.discountPercent(20)
.limitDiscountAmount(BigDecimal.valueOf(20_000))
.build();
GeneratedCoupon generatedCoupon = couponService.generateCoupon(testCoupon);
System.out.println("settingDB : " + generatedCoupon);
System.out.println("redisStock : " + redisTemplate.opsForValue().get("coupon:stock:" + generatedCoupon.getCouponId()));
}
@Test
@DisplayName("다중 쓰레드를 이용하여 쿠폰발급 동시성 테스트.")
void multiThreadCouponTest() throws InterruptedException {
//given
ExecutorService executor = Executors.newFixedThreadPool(32);
CountDownLatch latch = new CountDownLatch(32);
//when
for (long userId = 1; userId <= 32; userId++) {
final long uid = userId;
executor.submit(() -> {
try {
couponService.issueCoupon(couponId, uid);
} finally {
latch.countDown();
}
});
}
latch.await(); // 모든 스레드 종료 대기
//then
// DB 상태도 확인
em.flush(); // 변경사항 DB에 반영
em.clear(); // 1차 캐시 비우고 진짜 DB 조회
// Redis 대기열에 들어간 유저 수 == 발급된 수
System.out.println(RedisKeyPrefix.STOCK_KEY.of(couponId));
String remain = redisTemplate.opsForValue().get(RedisKeyPrefix.STOCK_KEY.of(couponId));
assertThat(remain).isEqualTo("0");
CouponStock stock = stockRepository.findByCouponId(couponId).orElseThrow();
System.out.println(stock);
assertThat(stock.getIssuedCount()).isEqualTo(10);
}
}
테스트 시나리오는 다음과 같다.
@BeforEach로 미리 테스트 데이터로 만들어둔 10장의 쿠폰을 32명의 사용자가 동시에 발급을 했을때 레디스에 올려둔 재고가 정상적으로 감소하는지? 트래잭션이 정상적으로 동작하여 토탈 발급한 수량이 DB에 10장으로 나오는지를 테스트하고자 하였다.
2. 발생하는 문제.
처음엔 상단의 @Transactional이 없는 상태로 테스트를 하였는데, 데이터를 정리하는과정이 번거로워 자동 롤백기능을 사용하고자 @Transactional을 달았는데 그때부터 테스트가 깨지기 시작하였는데, DB에 기록된 쿠폰 발급수량은 10장이 아니라 0장으로 나오는 현상이 발생하였다.
디버깅을 해보았는데 여기서 에러가 발생하였다.
@Service
@Transactional
@RequiredArgsConstructor
@Slf4j
public class CouponService {
private final CouponRedisService couponRedisService;
private final CouponStockRepository couponStockRepository;
/* 사용자가 쿠폰을 발급받음*/
public CouponResponse issueCoupon(Long id, Long userId) {
TryAcquireStatus tryAcquireStatus = couponRedisService.tryAcquire(id, userId);
if (tryAcquireStatus.equals(TryAcquireStatus.REMAIN)) {
CouponStock couponStock = couponStockRepository.findByCouponIdForUpdate(id)
.orElseThrow(() -> new NotValidCouponException(id));
couponStock.issueCoupon();
couponStockRepository.save(couponStock);
return new CouponResponse(couponStock.getCoupon());
} else{
throw new ExceededCouponException();
}
}
}
CouponService의 issueCoupon()을 실행도중 couponStocRepository.findByCouponIdForupdate(id)에서 커스텀 에러인NotValidCouponException이 발생하였다. 해당 쿠폰 자체를 조회를 못한다는건데 BeforeEach()로 넣어준 데이터에 접근을 못한다는 소리다.
해당 Docs를 잘 읽어보면 Mysql의 기본엔진인 InnoDB의 default isolation에 대해서 설명이 자세하게 나와있다.
Mysql InnoDB의 기본 격리레벨은 Repeatable Read로 다음과 같이 설명하고 있다.
This is the default isolation level forInnoDB.Consistent readswithin the same transaction read thesnapshotestablished by the first read. This means that if you issue several plain (nonlocking)SELECTstatements within the same transaction, theseSELECTstatements are consistent also with respect to each other. SeeSection 17.7.2.3, “Consistent Nonlocking Reads”.
Aconsistent readmeans thatInnoDBuses multi-versioning to present to a query a snapshot of the database at a point in time. The query sees the changes made by transactions that committed before that point in time, and no changes made by later or uncommitted transactions. The exception to this rule is that the query sees the changes made by earlier statements within the same transaction. This exception causes the following anomaly: If you update some rows in a table, aSELECTsees the latest version of the updated rows, but it might also see older versions of any rows. If other sessions simultaneously update the same table, the anomaly means that you might see the table in a state that never existed in the database.
이 구절을 자세히 읽어보면, 다중 버전 관리를 사용하여 특정 시점의 데이터베이스 스냅샷을 쿼리에 제공하는것을 의미한다.
쿼리는 해당 시점 이전에 커밋된 트랜잭션에 의해 변경된 내용만 읽고 나중에 혹은 커밋되지않은 트랜잭션에 의해 변경된 내용은 보지않는다고 나와있다.
최초로 불러온 내용에 대해서만 제공하고 커밋되지 않은 테이블 내용에 대해서는 불러오지 않고 스냅샷을 보여준다는 의미이다.
그림으로 표현하면 다음과 같다.
Tx1이라는 트랜잭션이 시작되기 전 마지막으로 커밋된 내용이 스냅샷에 담기고, Tx1 내부에서 SELECT 요청이 들어오게 되면 Tx1 내부에서 변경이 일어나는 데이터들을 볼 수 있지만, Tx1 내부가 아닌 외부에서 DB에 자원에 대한 SELECT 요청한 경우엔 스냅샷에 담긴 내용을 반환하게 된다.
데이터베이스에서 발생하는 일을 알게되었으니 이제 테스트 코드에서 발생하는 현상을 알아보자.
@Transactional을 테스트코드 클래스 레벨에 선언하게 되면 다음 그림과 같이 테스트가 동작하게 된다.
일반적인 테스트 환경에선 Tx1 트랜잭션 내에서 setTestData()로 데이터를 넣어주면 해당 데이터는 동일 트랜잭션에서 넣어준 데이터이기에 test()에서 조회가 가능하고 Tx1이 종료가 되면 넣어준 데이터는 롤백이 된다. 해당 시퀀스가 Tx2, Tx3에서 반복이 된다.
Tx2에선 Tx1에서 데이터가 insert되고 update되는 그런 내용들을 일절 알 수가 없다.
이제 테스트 코드 구조를 그림으로 그려보면 다음과 같다.
그림 tx total
Spring의 트랜잭션은 ThreadLocal 기반으로 관리가 되기때문에 Tx main 트랜잭션에서 generateCoupon()으로 쿠폰데이터를 생성을 하였지만 ExecutorService로 새로운 쓰레드를 생성하게 되고 해당 쓰레드에서 새로운 트랜잭션들이 생겨나게 된다. Tx main과 Tx sub1, Tx sub2, Tx Sub3는 각각 다른 트랜잭션으로 갈라지게 된다. Tx sub 1 내에서 findByCouponIdForUpdate()를 진행하게 되면 Tx main과의 컨택스트가 끊어지게 되어서 generateCoupon()으로 만들어진 쿠폰 데이터를 조회하는게 불가능하다. 실제 DB에서 스냅샷을 찾게 되는데, DB에는 넣어준적 없는 데이터를 찾으라고 하니 예외가 발생하는것이다.
결국 멀티스레드 테스트 -> 트랜잭션의 분리 -> 스냅샷이 없는 데이터를 조회하게 되어 쿠폰 데이터 select에 실패 -> 쿠폰 발급량 업데이트에 실패를 하게 되는것이다.
4. 해결방법.
4.1.1
Service레이어에 @Transactional(propagation = Propagation.REQUIRES_NEW)를 붙여주는 방법이 있다. genereateCoupon()이 실행되고 나서 바로 커밋이 되어서 Tx sub 1에서도 findByCouponForUpdate()를 하여도 조회가 된다. 하지만 트랜잭션이 중첩되며 커낵션이 과잉으로 사용되어 성능저하의 원인이 되거나, 원래 트랜잭션과 커밋 타이밍이 달라져서 부모트랜잭션이 실패했는데 자식 트랜잭션은 커밋되어버리는 상황도 발생할 수 있게 되어서 운영에서 무지성으로 사용할 경우 장애의 원인이 될 수 있으니 사용에 주의해야 한다.
4.1.2
테스트코드에서 @Transactional을 삭제하는것도 방법일 수 있겠다만.. 자동 롤백이 편리성을 포기하긴 쉽지않다.
현재 프로젝트에서 사용자가 특정 어플리케이션에 접근을 하면 jwt 토큰을 검증과정을 수행한다,
SecurityFilterChain
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable()) // CSRF 비활성화
.addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class) // CORS 필터 추가
.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)// JWT 필터 추가
.authorizeHttpRequests(auth -> auth
.requestMatchers("/oauth2/**", "/login/**").permitAll() // ✅ 추가
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/user/**").permitAll()
.anyRequest().authenticated()
)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.exceptionHandling(exception -> exception.authenticationEntryPoint(customAuthenticationEntryPoint));
return http.build();
사용자가 어플리케이션에 접근을 하게되면 addFilterBefore로 등록된 jwtAuthentictionFilter가 동작을 하는 구조이다.
JwtAuthenticationFilter
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = resolveToken(request);
if (token != null && jwtTokenProvider.validateToken(token)) {
String username = jwtTokenProvider.getUsername(token);
String role = jwtTokenProvider.getRole(token);
// GrantedAuthority를 사용하여 권한 설정
List<GrantedAuthority> authorities = Arrays.asList(new SimpleGrantedAuthority(role));
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(username, null, authorities);
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
private String resolveToken(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if (cookies == null) {
return null;
}
return Arrays.stream(cookies).filter(s -> s.getName().equals("jwt"))
.map(Cookie::getValue)
.findFirst()
.orElse(null);
}
}
jwtAuthentictionFilter에서 doFilterinternal이 동작을 하게되는데 여기서 검증에 통과한 사용자의 경우엔 SecurityContextHolder에 UsernamePasswordAuthenticationmToken을 담아주는 역할을 수행한다. 그다음 authorizedHttpRequest에 설정해준 내용대로 requestMatchers에 등록된 경로의 경우 필터 통과 여부와 상관없이 항상 접근이 가능하도록 허용을 해준다.
2. 모두 접근이 가능한데 필터는 왜 동작하나
requestMatchers()에 등록된 경로는 모두 로그인과 관련한 경로이다. 토큰이 없는 사용자라도 접근하여서 로그인이 가능하게 열어둔 것이다. 근데 모두에게 열린 경로인데 필터가 동작해서 우선 토큰을 검증하는 상황이 그닥 효율적이지 않다고 생각을 했고, 특정 경로로 접근시엔 해당 필터가 동작하지 않고 바로 authorizedhttpRequests()동작하게 하고자 한다.
3. 필터 동작 멈춰!
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
@Override
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
String path = request.getRequestURI();
// JWT 토큰이 필요 없는 경로들에 대해 필터를 건너뜁니다
return path.startsWith("/api/auth/login") ||
path.startsWith("/api/user") ||
path.startsWith("/oauth2") ||
path.startsWith("/login");
}
... doFilterInternal()
해결방법은 정말 간단하다. 아까 토큰 검증을 진행했던 JwtAuthenticationFilter에서 shouldNotFilter()메서드만 오버라이드 해주면 된다. 해당 필터가 동작할 때 여기로 접근하면 그냥 필터링 하지않갰다는 기능과 역할에 충실한 이름이다. ServletReqeust에서 접근 경로를 받아오고 그 경로들 중 토큰검증이 필요없는 경로를 등록해주면 끝이다.
4. permitAll()이랑 뭐가 다른가요?
SecurityFilterChain에서의 permitAll()이랑 같아보이는데 뭐가 다른지 처음엔 궁금했다. 이걸 이해하려면 SecurifyFilterChain의 동작 순서를 먼저 알아야 한다.
상단 그림에서 permitAll()은 authorizedHttpRequest 영역에서 실행이 된다. 즉 addFilterBefore()에서 각 필터들이 실행이 되고 그 필터링을 모두 한 뒤 authorizedHttpReqeust에 내려오면 addFilterBefore()의 결과와 상관 없이 모두 허용을 해주겠다는 의미인데 여기서 사용하는 shouldNotFilter의 경우 JwtAuthenticationFilter자체가 동작하지 않게 막는 메서드이다. 해당 경로 접근시 필터링 자체를 막아서 성능적 이점을 가져가겠다는 목적을 가지고 사용을 한다.
5. addFilterAfter()쓰면 되지않나요?
그럼 다음과같은 의문이 든다. AddFilterAfter()로 걸어둬서 permitAll()로 걸린 경로는 다 허용해주면 되는거 아닌가 하는 의문이 든다.
이렇게 되면 물론 동작은 한다. 하지만 인증 로직의 순서 문제가 있다. 여기선 동작하는 필터들이 cors, jwtAuthenticationFilter 이렇게 두개뿐이지만 추후에 혹은 타 프로젝트에선 여러 필터들이 동작할 수 있다. 해당 필터들이 동작해야하는 순서라는게 있는데 jwtAuthenticationFilter만 addAfterFilter()로 등록하게 된다면 순서가 꼬이고 유지보수적인 문제에서 자유롭지 못하다.
인증과 관련된 부분들은 addFilterBefore()에다 담고, 그 후 로깅이나 세션관리, 권한확장이나 토큰 갱신과같은 사용자 확인및 인증과 관련된게 아닌 내용들에 대해서 담는것이 일반적인 관례다.
addFilterAfter() 예제
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// 기본 설정들...
// 인증 이후 로깅을 위한 필터
.addFilterAfter(new AuditLogFilter(), UsernamePasswordAuthenticationFilter.class)
// 접근 제어 결정 이후 응답 헤더를 수정하는 필터
.addFilterAfter(new SecurityHeadersFilter(), FilterSecurityInterceptor.class)
// JWT 인증 이후 토큰 갱신 처리를 위한 필터
.addFilterAfter(new TokenRefreshFilter(), JwtAuthenticationFilter.class);
return http.build();
}
}
소셜 로그인 경로로 접근시에 로그인은 누구나 허용해야 하므로 authorizeHttpRequests()에 해당경로를 추가해주고,
authorizeHttpRequests() 하단에 .oauth2Login()을 추가해주면 된다. oauth2Login()에는 로그인 정책을 지정하는 PrinciapOauth2UserService와, 로그인 성공후 진행할 정책에 대한 내용인 OAuth2LoginSuccessHanler를 등록해주고, 로그인 실패를 컨트롤 할 failureHanlder()를 채워주면 된다.
2-1 PrincipalOauth2UserService
@Service
@RequiredArgsConstructor
public class PrincipalOauth2UserService extends DefaultOAuth2UserService {
private final UserRepository userRepository;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) {
OAuth2User oAuth2User = super.loadUser(userRequest);
String email = oAuth2User.getAttribute("email");
OAuth2User oAuth2User2 = super.loadUser(userRequest);
Optional<Users> users = userRepository.findUsersByEmail(email);
if (users.isPresent()) {
// 토큰발급은 OAuth2LoginSuccessHandler에서 진행.
return new UserDetailsImpl(users.get(),oAuth2User2.getAttributes());
}
else{
throw new GoogleUserException("회원가입이 필요합니다. 관리자에게 문의하세요");
}
}
}
우선 UserRepository를 살펴본 뒤, 해당 메일로 가입한 이력이 있는지 확인한 뒤, 가입 이력이 있으면 로그인 성공을, 실패시 예외를 던지도록 구현하였다.
정책따라 다르긴 하지만, 소셜로그인 이력이 없으면 여기서 바로 회원가입을 진행하록 많이들 구현한다. 하지만 현재 요구사항에선 소셜가입은 관리자를 통해서만 가능하게 해달라는 요청이 있어 가입이 되어있지 않다면 예외를 뱉어내도록 구현하였다.
2-2 OAuth2LoginSuccessHandler
@Component
@RequiredArgsConstructor
public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final JwtTokenProvider jwtTokenProvider;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
String token = jwtTokenProvider.generateAuthToken(authentication);
ResponseCookie jwtCookie = ResponseCookie.from("jwt", token)
.httpOnly(true)// js에서 접근 불가능(xss 방어)
.secure(false) // true면 https에서만 사용 가능. 운영은 true로
.path("/") //모든 경로에서 사용가능
.maxAge(3600)
.sameSite("Strict") //Csrf 방어
.build();
HttpHeaders headers = new HttpHeaders();
headers.add(HttpHeaders.SET_COOKIE, jwtCookie.toString());
response.addHeader(jwtCookie.getName(), jwtCookie.getValue());
response.setStatus(HttpServletResponse.SC_OK);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"response\":\"SUCCESS\"}");
response.setHeader("jwt", jwtCookie.toString());
}
}
로그인 성공시엔 기존에 아이디/패스워드 로그인단에서 발급해주던 방식과 동일하게 토큰틀 발급하도록 해주었다.
3.
위 과정이 모두 완료되었다면 로그인 테스트를 진행한다. 우선 서버를 구동하고, 브라우저를 열어서 localhost:8080/oauth2/authorization/google 경로로 접근을 해주고 로그인을 진행해준다.
현재 Spring Cloud Gateway에 Spring Security를 도입을 하는 과정에서 jwt 토큰 인증중 예외가 발생하면 500에러가 그대로 노출이 되는 상황이 발생을 하였다. 현 상황을 그대로 두면 클라이언트에게 서버 상황을 그대로 노출을 하게되어 불편을 초래하고, 또 보안상 좋지않기 때문에 해당 예외를 400에러로 바꾸고 에러 메시지, 에러코드정도만 담은 간단한 json 형태로 바꾸어 출력을 해주고자 한다.
1. 웹플럭스 기반 Security에서의 필터체인
Spring Cloud Gateway는 기본적으로 WebFlux 기반으로 동작한다. WebFlux기반에서 돌아가는 SpringSecurity에서는 SecurityFilterChain에서 에러 핸들링 방법을 설정해줘야 한다.
@Bean
public SecurityWebFilterChain securityFilterChain(ServerHttpSecurity http) {
return http
.csrf(csrf -> csrf.disable()) // CSRF 비활성화
.authorizeExchange(exchanges -> exchanges
.pathMatchers(whiteListPath.getWhiteList()).permitAll() // 화이트리스트 경로는 허용
.anyExchange().authenticated() // 그 외는 인증 필요
)
.securityContextRepository(securityContextRepository)
.authenticationManager(authenticationManager)
.exceptionHandling(exceptionHandlingSpec ->
exceptionHandlingSpec.authenticationEntryPoint(authenticationEntryPoint)
)
.build();
}
.exceptionHandling() 으로 예외를 어떤걸 이용하여 핸들링을 할 지를 결정을 해주면 된다.
authenticationEntryPoint는 ServerAuthenticationEntryPoint 인터페이스를 구현한 클래스를 작성해주면 된다.
@Component
public class CustomAuthenticationEntryPoint implements ServerAuthenticationEntryPoint {
@Override
public Mono<Void> commence(ServerWebExchange exchange, AuthenticationException ex) {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON);
String body = "{\"error\": \"" + ex.getMessage() + "\"}";
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
DataBuffer buffer = exchange.getResponse()
.bufferFactory()
.wrap(bytes);
return exchange.getResponse().writeWith(Mono.just(buffer));
}
}
이런식으로 에러를 잡으면 에러코드와 형식, 그리고 예외 메시지를 함께 포함하여 클라이언트단에 전달하고자 한다.
2. 원하는 지점에서 예외발생시키기.
1.에서 작성한 SecurityFilterChain동작 중 jwt 토큰 추출과 검증방법을 지정해주는 AuthenticationManager 에서 토큰 검증 중 검증에 실패하거나, 로그아웃을 하여 BlackList에 등록이 된 jwt는 예외를 발생하도록 정해주었다.
@RequiredArgsConstructor
public class JwtAuthenticationManager implements ReactiveAuthenticationManager {
private final JwtTokenProvider tokenProvider;
@Qualifier("blacklistRedisTemplate")
private final RedisTemplate<String, Object> authRedisTemplate;
@Override
public Mono<Authentication> authenticate(Authentication authentication) {
String token = authentication.getCredentials().toString();
if (!tokenProvider.validateToken(token) || authRedisTemplate.hasKey(token)) {
return Mono.error(new CustomTokenException("토큰이 유효하지않습니다."));
}
String username = tokenProvider.getUsername(token);
String role = tokenProvider.getRole(token);
List<GrantedAuthority> authorities = List.of(new SimpleGrantedAuthority(role));
return Mono.just(new UsernamePasswordAuthenticationToken(username, null, authorities));
}
}
여기까지만 하면 이제 원하는대로 동작을 할 것 같다.
3. 의도치 않은 예외발생
이제 에러가 잡혔다고 생각하고 다시 테스트를 해보면
아까 봤던 에러가 그대로 올라온다. 무언가 이상하다. AuthenticationEntryPoint가 동작을 안한다는 의미이다.
사실 AuthenticationEntryPoint는 문제가 없다. 우선 예외가 터지는 구조는 다음과 같다.
SecurityFilterChain이 실행이 되고, JwtAuthenticationManager에서 Jwt 검증을 하다가 검증을 통과하지 못하면 Exception을 던지게 되는데 이 Exception을ExceptionTranslationWebFilter 가 감지한다.
이때 Exception이 AuthenticationException이나 AccessDeniedException을 감지하게 되면 ServerAuthenticationEntryPoint를 실행을 하게 되는데, 만약에 다른 예외이거나 SeucurityFilterChain 밖에서 예외가 터져버리면 ServerAuthenticationEntryPoint에서 예외를 잡을수가 없다.
JwtAuthenticationManager에서 작성한 코드를 다시 보자.
@Component
@RequiredArgsConstructor
public class JwtAuthenticationManager implements ReactiveAuthenticationManager {
private final JwtTokenProvider tokenProvider;
@Qualifier("blacklistRedisTemplate")
private final RedisTemplate<String, Object> authRedisTemplate;
@Override
public Mono<Authentication> authenticate(Authentication authentication) {
String token = authentication.getCredentials().toString();
if (!tokenProvider.validateToken(token) || authRedisTemplate.hasKey(token)) {
return Mono.error(new CustomTokenException("토큰이 유효하지않습니다."));
}
String username = tokenProvider.getUsername(token);
String role = tokenProvider.getRole(token);
List<GrantedAuthority> authorities = List.of(new SimpleGrantedAuthority(role));
return Mono.just(new UsernamePasswordAuthenticationToken(username, null, authorities));
}
}
Mono.error()로 주면 SecurityContextRepository의 load에서 예외가 발생 -> 정상 흐름이 아니라서 securityFilterChain에서 벗어나버려서 예외를 핸들링할 방법이 없다.
Mono.empty()로 주면 정상적인 SecurityFilterChain의 흐름이고 CustomAuthenticationEntryPoint까지 정상적으로 흘러는 가지만 empty()엔 파라미터를 받지않아서 우리가 주고싶었던 CustomException의 메시지를 client단에 넘겨줄 수 없고 AuthenticationException의 기본 메시지만 출력이 가능하다.
4. 의도한 메시지 보내주기 -> SecurityExceptionHandler 구현
return Mono.error()로 그대로 두고, ErrorWebExceptionhandler를 구현을 하게되면 SecurityFilterChain에서 벗어난 에러처리 + 지정한 메시지 클라이언트에게 전달 이 두가지를 다 성공할 수 있다. 코드는 다음과 같다.
@Component
public class SecurityExceptionHandler implements ErrorWebExceptionHandler {
@Override
public Mono<Void> handle(ServerWebExchange exchange, Throwable ex) {
// 여기서 필터 단계에서 발생한 예외를 캐치
HttpStatus status = HttpStatus.INTERNAL_SERVER_ERROR;
String message = "예상치 못한 에러가 발생했습니다.";
if (ex instanceof CustomTokenException) {
status = HttpStatus.UNAUTHORIZED;
message = ex.getMessage(); // "토큰이 유효하지 않습니다."
}
exchange.getResponse().setStatusCode(status);
exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON);
String body = "{\"error\": \"" + message + "\"}";
DataBuffer buffer = exchange.getResponse()
.bufferFactory()
.wrap(body.getBytes(StandardCharsets.UTF_8));
return exchange.getResponse().writeWith(Mono.just(buffer));
}
}
ErrorWebExceptionHandler는 Webflux 애플리케이션에서 최상위 예외처리자이다. 이 핸들러를 구현을 해주게 된다면 모든 예외처리가 가능하다.
이렇게 SecurityExceptionHandler를 구현을 하고 다시 테스트를 진행해보면
스프링 클라우드 게이트웨이를 도입하여 프로젝트를 진행 중 인가인증 서버와 member서버의 분리를 진행하였다. 다른 서비스를 진행하는 서버들이 계속 만들어지면서 각 서버들 인가인증에 이슈들이 생기기 시작했다.
0. 현재 현재는 인가/인증 서버에서 로그인을 담당한다. 로그인에 성공하면 클라이언트에게 jwt를 발급하고, 각 서비스 서버에 요청을 할때 jwt를 전달한다. 이때 jwt의 유효성검사, decoding은 각 서버에서 맡는다.
처음 설계당시엔 인가인증 서버에서 jwt 검증을 맡게되면 모든 서버에 요청이 들어올때마다 인가인증 서버에 검증 요청이 몰리게 되고 이렇게 되면 서버를 분리한 의도가 희석이 된다고 생각을 하였고, 각 서버에서 각자 공통 password로 검증을 진행하면 인가인증 서버 하나에 많은 트래픽이 모이지 않을거라 생각을 하였다. 그러나 점점 서버가 늘어날수록 이슈들이 하나씩 늘어나기 시작했다.
먼저 동일한 코드의 중복이었다. 동일한 jwt토큰관련한 config클래스, provider가 다른 서버들에서도 똑같이 작성을 해야했고 jwt 정책이 바뀔때마다 늘어나는 서버에도 동일하게 코드를 수정해야하고 유지보수에도 어려웠다, 변경점에 대해 적용이 동일하게 되었다고 해도 휴먼에러로 여러번 고생한적도 많아서 무언가 바꿔야한다는 생각을 했다.
1.공통모듈화
앞서 작성한 글처럼 공통모듈 만들기 로 문제를 해결하자는 생각이 먼저 들었다. jwt관련을 앞에 만들어둔 공통모듈로 빼버리고 필요한 부분에서 상속받아 사용하면 되기때문에 관리포인트도 줄고 여러 장점이 많다는 생각이 들었다.
하지만 해당객체가 선언된곳은 공통모듈이지만, 결국 불러서 로직을 실행을 하고, 해당 과정이 진행이 되는곳은 각 서버단이란 사실은 변함이 없었다. 결국은 실제로 빈을 등록을 해줘야 하고, 로직들이 실제로 돌아가는곳은 엔드포인트 서버이며 정책이 변경될 경우 각 서버들을 다 수정을 해줘야 한다.
2. gateway에 넘기기.
각 서비스 서버에 접근을 하려면 클라이언트는 gateway를 무조건 거쳐야 한다. 게이트웨이를 통해서 각 엔드포인트에 접근이 가능하기도 하고, 때로는 조건을 만족하지 못하면 블락을 당한다. 그렇다면 게이트웨이에서 jwt를 검증을 하게 한다면 어떨까. 그렇게 되면 검증은 모두 한곳에서 이뤄지게 되고, 각 서비스단에선 중복으로 jwt 검증관련 코드를 가지고 있지 않아도 된다. 토큰관련 정책이 변경이 되어도 게이트웨이와 인가인증만 신경쓰면 되므로 유지보수 관점에서도 나쁘지않은 선택으로 보인다.
우선은 시큐리티를 도입하지않고 jwt 토큰 유무와 토큰이 디코딩이 되는 정도만 검증을하고, 해당 검증이 통과가 된다면 엔드포인트를 열어주고, 엔드포인트에서 사용할 유저정보를 헤더로 내려주는 간단한 로직만 우선 적용하였다. 당장에 서비스가 복잡하진 않고 이정도만 해줘도 검증 로직은 충분하다고 생각했지만 예외 핸들링, CORS, CSRF 같은 보안 설정 일괄 관리 그리고 향후 OAuth2 도입 예정을 생각을 하니 시큐리티를 사용하는게 적합하다고 생각하게 되었다.
3. 어디까지, 어떻게 넘길까?
그렇다면 스프링 시큐리티와 글로벌 필터에 어떤 역할을 얼마나 주어야 하는지 고민이 생겼다.
우선 게이트웨이에서 수행할 역할은 다음과 같다.
토큰 검증과 접근제한이 가장 큰 키 포인트이다. 근데 이 두가지는 사실 시큐리티와 글로벌필터의 역할이 겹친다. 게이트웨이의 GlobalFilter단에서도 토큰검증과 접근자의 path에 접근을 시켜줄지 말지, 접근범위 정책을 작성을 하고, 스프링시큐리티도 토큰 검증과 접근 범위제한의 역할을 하게되기에 서로 역할이 겹치는 상황이다. . 해당 포인트에서 고민을 많이하게 되었는데 게이트웨이의 동작 순서에 따라서 역할을 나누게 되었다. 동작 순서는 다음과 같다.
위 그림과 같이 최초 클라이언트가 http 리퀘스트를 날리게 되면, 시큐리티 필터가 작동을 한다. 시큐리티에서 설정한 uri 접근 통제 및 인가 인증이 모두 끝나게 되면 글로벌 필터가 동작을 하게 되고. 엔드포인트 접근에 대해서 통제 정책을 실행하게 조건이 충족되게 되면 최종 엔드포인트에 도달하게 된다. 시큐리티에서 인증절차를 진행하다 조건이 만족하지 않게되면 글로벌필터에 도달하기 전에 시큐리티 익셉션이 터지게 되며 글로벌 필터는 작동하지 않는다. 이 흐름대로 시큐리티에서 jwt토큰 검증과 uri접근 통제를 처리하고, 엔드포인트에 대한 정책은 글로벌필터에서 처리를 하게 된다면 각자 역할 분리가 이뤄진다.
4. 시큐리티 설정.
SecurityConfig
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
private final WhiteListPath whiteListPath;
private final CustomAuthenticationEntryPoint authenticationEntryPoint; // 인증 실패
private final JwtAuthenticationManager authenticationManager;
private final JwtSecurityContextRepository securityContextRepository;
@Qualifier("blacklistRedisTemplate")
private final RedisTemplate<String, Object> blacklistRedisTemplate;// 추후 설정.
@Bean
public SecurityWebFilterChain securityFilterChain(ServerHttpSecurity http) {
return http
.csrf(csrf -> csrf.disable()) // CSRF 비활성화
.authorizeExchange(exchanges -> exchanges
.pathMatchers(whiteListPath.getWhiteList()).permitAll() // 화이트리스트 경로는 허용
.anyExchange().authenticated() // 그 외는 인증 필요
)
.securityContextRepository(securityContextRepository)
.authenticationManager(authenticationManager)
.exceptionHandling(exceptionHandlingSpec ->
exceptionHandlingSpec.authenticationEntryPoint(authenticationEntryPoint)
)
.build();
}
}
securityWebFilterChain에서 화이트 리스트로 지정된 경로에 대해서는 토큰이 유효하지 않아도 접근이 가능하게 허용하고, 그외 경로에 대해선 유효 토큰을 제시해야 접근이 가능하게 해준다.
SecurityWebFilterChain의 구성이 평소 설정과는 조금 다른데 지금 사용하는 건 Spring Cloud Gateway + WebFlux 기반이기 때문에, ServerHttpSecurity → SecurityWebFilterChain 을 사용하는 리액티브 환경에서는 addFilterBefore(...) 사용할 수 없다.
이 두개를 사용하여 JWT를 해석하고 인증 객체를 생성하는 식으로 커스터마이징해줘야 한다.
토큰을 꺼내오고 검증을 진행하는 AuthenticationManager와 SecurityContextManager의 코드는 다음과 같다.
AuthenticationManager
@Component
@RequiredArgsConstructor
public class JwtAuthenticationManager implements ReactiveAuthenticationManager {
private final JwtTokenProvider tokenProvider;
@Override
public Mono<Authentication> authenticate(Authentication authentication) {
String token = authentication.getCredentials().toString();
if (!tokenProvider.validateToken(token)) {
return Mono.error(new BadCredentialsException("토큰이 유효하지않습니다."));
}
String username = tokenProvider.getUsername(token);
String role = tokenProvider.getRole(token);
List<GrantedAuthority> authorities = List.of(new SimpleGrantedAuthority(role));
return Mono.just(new UsernamePasswordAuthenticationToken(username, null, authorities));
}
}
SecurityContextManager
@Component
@RequiredArgsConstructor
public class JwtSecurityContextRepository implements ServerSecurityContextRepository {
private final JwtAuthenticationManager authenticationManager;
@Override
public Mono<SecurityContext> load(ServerWebExchange exchange) {
String token = extractToken(exchange);
if (token == null) return Mono.empty();
Authentication auth = new UsernamePasswordAuthenticationToken(null, token);
return authenticationManager.authenticate(auth).map(SecurityContextImpl::new);
}
@Override
public Mono<Void> save(ServerWebExchange exchange, SecurityContext context) {
return Mono.empty(); // Stateless 처리
}
private String extractToken(ServerWebExchange exchange) {
return Optional.ofNullable(
exchange.getRequest().getCookies().getFirst("jwt")
).map(HttpCookie::getValue).orElse(null);
}
}
이렇게 하면 시큐리티에서 해야할 토큰검증과 권한 확인 절차에 대해선 완료가 되었다. 다음은 각 엔드포인트에 대한 정책 설정을 해주는 GlobalFilter에 대해서 설정이 필요하다.
GlobalFilter
@Component
@RequiredArgsConstructor
public class JwtGlobalFilter implements GlobalFilter {
private final JwtTokenProvider tokenProvider;
private final WhiteListPath whiteListPath;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String path = exchange.getRequest().getURI().getRawPath();
/*
* 게이트웨이서 이미 분기가 된 이후기때문에 Predicate가 제거된 whiteListPath를 사용.
* ex /users/api/auth/login -> /api/auth/login
* */
if (Arrays.stream(whiteListPath.getStrippedWhiteList()).anyMatch(path::matches)) {
return chain.filter(exchange);
}
HttpCookie httpCookie = extractToken(exchange);
if(httpCookie != null && tokenProvider.validateToken(httpCookie.getValue())){
String username = tokenProvider.getUsername(httpCookie.getValue());
String role = tokenProvider.getRole(httpCookie.getValue());
ServerWebExchange updatedExchange = exchange.mutate()
.request(
exchange.getRequest().mutate()
.header("X-Auth-ID", username)
.header("X-Auth-Role", role)
.build()
).build();
return chain.filter(updatedExchange);
} else{
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}
}
private HttpCookie extractToken(ServerWebExchange exchange) {
return exchange.getRequest()
.getCookies()
.getFirst("jwt"); // ← "jwt"는 쿠키 이름
}
}
(whiteListPath에 대한 내용은 밑에서 추가로 설명)
GlobalFilter에선 토큰에 대해서 검증을 진행을 따로 하지는 않고 엔드포인트에서 필요한 사용자 정보만 내려주는 정도만 진행해준다.
앞에서 SecurityFilterChain에서 토큰에 대한 검증은 이미 되었고, 검증에 문제가 있으면 이미 예외를 던지기 때문에 GlobalFilter까지온 토큰은 검증을 하지 않는다. 현재 화이트리스트로 지정된 경로와 그외 경로, 그리고 그 외 경로에 대한 권한별 접근은 아직 정책이 나오지 않아 자세하게 설정은 하지 않았다. 추후 관리자권한, 일반권한에 대해서 자세한 정책이 나온다면 추후 적용하는걸로 한다.
현재 화이트리스트(모든 사용자에 대해 접근허용)로 지정된 경로는 로그인 페이지 하나이고 해당 경로는 헤더에 유저정보를 내려줄 필요가 없기에 바로 엔드포인트에 접근을 허용한다. 그 외 경로에 대해서는 헤더에 username과 권한을 넘겨주어 엔드포인트 동작에 필요한 기본정보를 제공한다.(그외 정보는 엔드포인트에서 API 통신으로 요청)
WhiteListPath
@Component
@Getter
public class WhiteListPath {
public final String[] whiteList = {
"/users/api/auth/login",
};
// StripPrefix 이후 기준 (GlobalFilter용)
private final String[] strippedWhiteList = Arrays.stream(whiteList)
.map(path -> path.replaceFirst("^/[^/]+", ""))
.toArray(String[]::new);
}
SecurityFilterChain에서는 whiteList.getWhiteList()를 사용하는데 GlobalFilter에서는 whiteList.getStrripedWhiteList를 사용을 하는 이유는 다음과같다.
SecurityFilterChain에 도착한 최초 paht는 SpringCloudGateway의 application.yml에서 설정한 predicate를 달고있는 경로이다.
최초 클라이언트가 http://localhost:8060/users/api/auth/login으로 게이트웨이에 접근을 했다고 가정하면 securityFilterChain은
/users/api/auth/login을 경로로 인식한다. 반면 securityFilterChain을 통과한 이후엔 게이트웨이에서 이미 경로의 분기가 끝난 상태기 때문에 predicate가 빠진 /api/auth/login 을 경로로 인식한다. 현재 정책이 시큐리티와 globalFilter의 화이트리스트가 같기 때문에 경로를 다르게 인식하는 두 곳에서 모두 사용하기 위해서 "/users"를 제거한 strippedWhiteLsit를 사용한다.
5. 엔드포인트에서의 헤더 확인
테스트를 위하여 게시판 서버에 실제 헤더값이 어떻게 찍히는지 확인을 해보고자 한다.
먼저 findOne 메서드에 @RequestHeader Map<String, String> headers 를 추가하고, 게이트웨이에서 넣어주는 헤더를 실제로 받아오는지 println()으로 찍어보자
편의성을 이유로 엔티티가 순수하게 유지되지 않고, Security의 UserDetails를 상속받아서 사용을 하고있는 모습이다.
MeberServer와 authServer가 같은 UserEntity를 사용해야 하는데, 이 둘은 서버가 분리된 환경에서 심지어 한쪽은 security를 사용하고 반대쪽은 사용하지 않는 상황이다. MemberServer에서 UserDetails를 상속받지않는 그냥 UserEntity를 복붙해서 사용한다면, 유지 보수및 변경 대응이 너무 번거로워질것 같은 생각이 들었다.
그래서 이들을 공통 모듈을 상속받게 하여서 관리포인트를 하나로 만들어야하는 과제가 주어졌다.
2. 주요 포인트.
공통 모듈을 만드는 방법에도 두가지가 존재한다. 인가인증 서버와 MemberServer의 루트에 멀티모듈 방식으로 참조하게 하는 방법이 있고, 완전 별개의 프로젝트로 분리를 하여 Jar로 의존하거나 Gradle로 연동하는 방법이 있다.
멀티모듈 방식으로 한다면 개발속도도 빠르고, 따로 엔티티를 위해서 빌드를 해줄 필요는 없다. 하지만 서버간 완전 분리를 원하는 상황이라 UserEntity를 따로 git으로 관리를 해주는 수고가 들더라도 별개의 프로젝트로 분리를 하여 공통단으로 사용하기로 결정하였다.