프로젝트 진행하면서 학습한 내용 - Kim-Gyuri/SanrioShop GitHub Wiki
로그인 및 주문 처리 시 트랜잭션 처리와 예외 처리 방안을 신중히 고려하여 설계하고자 했다.
또한, 로그인과 고객 정보 보호를 위한 보안 처리 방법을 고려하여 개발에 반영하려고 했다.
먼저 로그인과 고객 정보를 위한 보안 처리 방법에 대해 정리해보자.
목차는 다음과 같다.
-
프로젝트 기본 설정
-
로그인, 고객정보를 위한 보안처리
- 단일 토큰으로 했을 때의 문제점
- 다중 토큰: Refresh 토큰과 생명주기
- 토큰 2개 구현 포인트
- Refresh 토큰도 보안적으로 위험할까?
- Refresh 토큰 Rotate
-
Spring Security와 JWT 기반 인증 로직: 요청 흐름 도식화 및 변경한 코드
- 요청흐름
- Security Config 파일
- JWT 로그인을 위한 UserDetails, UserDetailsService 구현
- 로그인요청을 처리하는 JwtLoginService 구현
- JWT 검증 필터
- JWT Util 클래스
- Refresh 토큰 갱신
-
로그아웃 커스텀 필터
- 백엔드에서 로그아웃 수행작업
- 스프링 시큐리티에서의 로그아웃 구현의 위치
- 로그아웃 필터 구현
위와 같이, Jwt 사용을 위해 jsonwebtoken 관련 라이브러리와, Spring Security 라이브러리를 추가했습니다.
이전 프로젝트에서 스프링 시큐리티를 단일 토큰으로 구현했었다.
단일 토큰의 사용은 다음과 같았다.
- 로그인 성공 JWT 발급: 서버측에서 클라이언트로 JWT를 발급한다.
- 권한이 필요한 모든 요청: 클라이언트는 서버측에 JWT를 전송한다.
위의 방식은 권한이 필요한 모든 요청에 JWT를 서버로 전달하는 방식
이였다.
JWT는 매시간 수많은 요청을 위해 클라이언트의 Javascript 코드로 HTTP 통신을 통해 서버로 전달되어야 했다.
그리고 JWT 유효시간을 30분 이상 유지했어야 했기에 보안적으로 위험
할거라고 생각했다.
위와 같은 문제가 발생하지 않도록 Access, Refresh 토큰으로 관리하기로 했다.
권한이 필요한 요청에 쓰이는 토큰은 생명주기를 짧게
설정하고,
이 토큰이 만료되었을 때 함께 받은 Refresh 토큰으로 토큰을 재발급
할 수 있도록 개발하고자 했다.
짧은 생명주기는 약 10분, 재발급을 위한 토큰의 생명주기는 약 24시간으로 설정하기로 했다.
생명주기를 짧게 했기 때문에 JWT 보안 취약점을 개선할 수 있다.
재발급을 위한 토큰은, 짧은 생명주기 토큰만 있다면 매번 로그인을 진행해야 하는 번거로움이 있다.
그렇기에 생명주기가 긴 토큰도 두기로 했다.
-
로그인 성공시 생명주기와 활용도가 다른 토큰 2개 발급: Access/Refresh
- Access 토큰: 권한이 필요한 모든 요청 헤더에 사용될 JWT로 탈취 위험을 낮추기 위해 약 10분 정도의 짧은 생명주기를 가진다.
- Refresh 토큰: Access 토큰이 만료되었을 때 재발급 받기 위한 용도로만 사용되며 약 24시간 이상의 긴 생명주기를 가진다.
-
권한이 필요한 모든 요청: Access 토큰을 통해 요청
- 권한이 필요한 요청일 경우, 요청 헤더에 Access 토큰만 넣어주면 된다.
- Access 토큰만 사용하여 요청하기 때문에 Refresh 토큰은 호출 및 전송을 빈도가 낮다.
-
토큰이 만료된 경우, Refresh 토큰으로 Access 토큰 발급
- Access 토큰이 만료되었다는 요청이 돌아왔을 경우,
프론트엔드 로직을 통해서 Access 토큰을 재발급 받도록 구현하려고 한다. - 로그인을 성공했을 때, 발급 받은 Refresh 토큰을 가지고 Acccess 토큰을 새로 발급받아 요청을 처리하려고 한다.
- Access 토큰이 만료되었다는 요청이 돌아왔을 경우,
- 로그인이 완료되면 handler에서 Access/Refresh 토큰 2개를 발급해 응답한다.
토큰은 각기 다른 생명주기, payload 정보로 설정한다. - Access 토큰 요청을 검증하는 JWTFilter에서 Access 토큰이 만료된 경우는, 요청에 맞는 상태코드와 메시지를 응답하도록 구현하려고 한다.
- 프론트측 Javascript 로직에서, Access 토큰만료인 경우에는 Refresh 토큰을 서버측으로 전송하고 Access 토큰을 발급 받는 로직을 처리하도록 한다.
재발급 받을 때, 기존 Access는 DB에서 제거한다. - 서버측에서 컨트롤러 단에서 Refresh 토큰을 검증하고 Access를 응답하도록 구현한다.
1개 -> 2개 토큰으로 변경했을 때, 자주 사용되는 Access 토큰이 탈취되더라도 생명주기가 짧아 위험 확률이 줄었다.
하지만 Refresh 토큰 또한 사용되는 빈도만 적을뿐 탈취될 수 있다.
그렇기에 Refresh 토큰에 대한 보안도 필요하지 않을까 의문이 들었다.
-
Access/Refresh 토큰의 저장 위치 고려
- 로컬/세션 스토리지 및 쿠키에 어떻게 저장해야 할지 알맞는 저장소 위치에 대해 고민을 했다.
- 결론적으로는 참고자료와 똑같이 저장소를 설정하기로 했다.
-
Access/Refresh 토큰 저장 위치
- 클라이언트에서 발급 받은 JWT는 다음과 같이 저장하기로 했다.
- 로컬 스토리지: Access 토큰을 저장한다.
- 쿠키: Refresh 토큰을 저장한다.
-
로컬 스토리지
- Access 토큰은 짧은 생명 주기를 가지므로, 클라이언트 측에서 빠르게 관리하고 갱신할 수 있다.
- 로컬 스토리지는 XSS 공격에 취약하지만, 이를 인식하고 XSS 방어 로직을 구현하여 보안을 강화할 수 있다고 한다.
-
쿠키
- 쿠키는 CSRF 공격에 취약하지만, httpOnly 및 secure 속성을 설정함으로써 XSS 공격으로부터 보호할 수 있다.
- Refresh 토큰은 긴 생명 주기를 가지고 있어, 서버와의 인증 세션을 유지하는 데 적합하다고 한다.
- 쿠키는 해당 도메인으로 요청을 보낼 때 자동으로 전송되므로, 사용자가 수동으로 토큰을 관리할 필요가 없다.
참고자료를 참고하여, Refresh 토큰의 보안을 강화하기 위한 Refresh Rotate 방식을 채택했다.
Access 토큰이 만료되면, 클라이언트는 Refresh 토큰을 사용해 서버에 새로운 Access 토큰을 요청한다.
이때 서버는 Refresh 토큰의 유효성을 확인하고, 새로운 Access 토큰과 함께 새로운 Refresh 토큰을 발급한다.
클라이언트는 새로운 Refresh 토큰을 DB에 저장하고, 이전 Refresh 토큰은 만료시키고 DB에서 삭제
한다.
프론트의 API Client에서 Server측에 요청을 보내고 처리하는 요청흐름을 도식화하여 정리했다.
사용자의 웹에서 로그인 요청을 하면, 프론트의 API Client는 Server측에 요청을 보내고
Server는 이를 처리하여 JWT 데이터를 발급하는 방식으로 구현했다.
이때 권한이 필요한 API 요청
일 경우 Access 토큰을 요청헤더에
첨부하여 Server로 전송한다.
Server측에서는 JwtFilter가 Access 토큰을 검증하는 역할을 맡는다.
만약 Access 토큰이 만료되었다면, Server는 401 상태코드와 함께 JWT 만료에 대한 메시지를 응답하도록 처리했다.
-
예외처리
- 인증이 실패 시, jwtAuthEntryPoint가 호출되도록 작성했다.
- jwtAuthEntryPoint는 인증이 필요한 요청에 대해 적절한 오류 메시지와 상태 코드를 반환하는 역할을 한다.
- 사용자가 인증되지 않은 상태에서 보호된 리소스에 접근하려고 할 때 이 jwtAuthEntryPoint가 호출된다.
-
로그아웃 필터
- 커스텀 로그아웃 필터를 추가하여 로그아웃 요청을 처리했다.
- customLogoutFilter에 JWT 로그아웃 처리 로직을 작성했다. JWT를 초기화하고 삭제하는 기능을 포함한다.
UserDetails 인터페이스는 User 엔티티 클래스에 정의해두었다.
사용자이름 username은 User의 email 필드로 정의하여 사용했다.
CustomUserDetailsService는 JWT 인증 과정에서 UserDetailService를 사용하여 사용자의 자격증명을 검증하고 UserDetail를 통해 정보를 조회한다.
UserDetailService를 호출하여 사용자 정보를 조회하고 User 엔티티를 CustomUserDetails로 변환하여 사용한다.
로그인 요청을 처리할 때, 먼저 사용자가 입력한 이메일과 비밀번호에 대한 사용자 인증을 검사한다.
인증된 사용자일 경우, 입력된 비밀번호가 데이터베이스에 저장된 인코딩된 비밀번호와 일치하는지 검증합니다.
검증이 완료되면, JWT Utils를 통해 JWT 토큰을 생성합니다. 생성된 리프레시 토큰은 데이터베이스에 저장하고, 쿠키에 리프레시 토큰을 추가한다.
이 과정을 통해 인증된 사용자에게 필요한 토큰을 발급하여 이후 요청에 대한 인증을 가능하도록 구현했다.
JwtAuthenticationFilter는 JWT 토큰의 유효성을 검사하고 인증 과정을 처리하는 클래스다.
이 필터는 요청에 포함된 JWT를 검증하여 사용자의 인증 상태를 설정하는 중요한 역할을 수행한다.
doFilterInternal() 메서드 동작 과정은 다음과 같다.
- 요청 헤더에서 토큰 추출
- 요청 헤더에서 JWT 토큰을 꺼낸다.
- 토큰 존재 여부 확인
- 토큰이 없을 경우, 다음 필터로 요청을 넘긴다.
- 토큰 만료 확인
- 토큰이 만료된 경우 401 상태 코드와 함께 응답한다.
- 토큰 타입 확인
- 토큰의 타입이 access token이 맞는지 확인한다.
- 유효하지 않은 경우 401 상태 코드와 함께 응답한다.
- 사용자 정보 조회 및 인증
- 토큰에서 사용자 이름(여기선 email 정보를 의미)을 가져와 사용자 정보를 가져온다.
- 인증 토큰을 생성하여 SecurityContextHolder에 설정함으로써 이후 요청에서 인증된 사용자로 처리된다.
JwtProperties 클래스는 JWT를 생성하고 검증하는 기능을 추가한 클래스다.
Access, Refresh 토큰을 포함한 다중 토큰 관리를 구현했다.
토큰의 시간을 설정하였고, Access 토큰의 유효시간을 짧게 설정하여 보안을 높이고자 했다.
주요 메서드는 다음과 같다.
- createJwt()
- type 값으로 Access/Refresh 토큰 타입을 구분한다.
- 회원 요청을 처리하기 위한 JWT이므로, role 값은 ROLE_USER으로 가입된 회원으로 설정했다.
- 만료시간은 Access 토큰은 10분, Refresh 토큰은 24시간으로 설정하여 관리했다.
JWT를 탈취하여 서버측으로 접근할 경우 JWT가 만료되기 까지 서버측에서는 그것을 막을 수 없다.
프론트측에서 토큰을 삭제하는 로그아웃을 구현해도 이미 복제가 되었다면 피해를 입을 수 있다.
이런 문제를 해결하기 위해 생명주기가 긴 Refresh 토큰은 발급시 DB에 저장했다.
그리고 토큰을 재발급할 때, 기존 Refresh 토큰은 초기화하고 DB에서도 삭제하도록 구현했다.
ReissueService 클래스의 reissue 메서드는 Refresh 토큰을 사용하여 새로운 Access 토큰과 Refresh 토큰을 발급하는 로직을 처리한다.
ressiue() 메서드는 다음과 같다.
- Refresh 토큰 확인
- 요청에서 쿠키를 통해 Refresh 토큰을 검사한다.
- Refresh 토큰이 없다면 RefreshTokenNotFoundException 예외 발생한다.
- 토큰 만료 체크
- Refresh 토큰이 만료되었다면 InvalidRefreshTokenException 예외 발생한다.
- 토큰 타입 검증
- Refresh 토큰의 타입 올바르지 않는 경우, InvalidRefreshTokenException 예외 발생한다.
- DB에서 Refresh 토큰 조회
- DB에 저장되어 있지 않는 토큰인 경우, InvalidRefreshTokenException 예외 발생한다.
- 사용자 정보 조회 +Refresh 토큰에서 사용자 정보를 조회한다.
- 새로운 JWT 생성
- 사용자 정보로 새로운 Access, Refresh 토큰을 발급한다.
- Refresh 토큰 업데이트
- DB에 기존 Refresh 토큰을 삭제하고, 새로운 Refresh 토큰을 저장한다.
- 응답
- 새로운 Access 토큰을 응답 헤더에 추가하고, 새로운 Refresh 토큰을 쿠키에 설정한다.
로그아웃 버튼 클릭시 아래와 같이 동작하도록 구현했다.
- 프론트엔드측: 로컬 스토리지에 존재하는 Access 토큰 삭제 및 서버측 로그아웃 경로로 Refresh 토큰을 전송한다.
- 백엔드측: 로그아웃 로직을 추가하여 Refresh 토큰을 받아 쿠키 초기화 후 DB에서 해당 Refresh 토큰을 삭제한다.
username -> email 기반으로 Refresh 토큰을 삭제하는 방식이다.
- DB에 저장하고 있는 Refresh 토큰을 삭제한다.
- Refresh 토큰 쿠키를 null로 변경한다.
일반적으로 스프링 시큐리티 의존성을 프로젝트에 추가했을 경우, 기본 로그아웃 기능이 활성화 된다.
나는, 커스텀 필터를 구현하여 해당 로그아웃을 수행하도록 할 것이다.
SecurityConfig에도 커스텀 로그아웃 필터를 등록해준다.
주문시 트랜잭션처리와 예외처리를 어떻게 설계했는지 정리해보자.
목차는 다음과 같다.
- 주문시 트랜잭션처리와 예외처리에 대한 생각
- 주문 처리 흐름에서의 트랜잭션과 예외처리
- 리포지토리 인터페이스
- 서비스 클래스
- 테스트 코드
주문 처리 시 상품은 단일 상품으로 정의되었기 때문에, 동시에 주문 요청이 들어오는 상황에서 동시성 제어가 필요했다.
특히, 다수의 사용자가 동시에 주문할 경우 데이터 무결성을 보장하고,
시스템 안정성을 유지하기 위해 트랜잭션과 예외 처리를 신중하게 고려하여 설계하고자 했다.
주문 처리를 위해서는, 주문 생성, 판매상품에서 제외 등 단계에서 트랜잭션을 어떻게 사용해야 할지 고민했다.
비관적 락을 통해 동시에 여러 사용자가 동일한 상품에 주문을 요청하는 상황에서 발생할 수 있는 동시성 문제를 방지하고자 했다.
주문요청을 받기 전에, 해당 상품에 대한 주문요청이 들어왔는지 체크하는 로직이 필요했다.
이러한 방식으로 비관적 락
을 구현하여, 다수의 사용자가 동시에 상품을 주문할 때 DB의 동시성 문제를 해결하고자 했다.
주문요청 서비스 로직을 구현할 때, 비관적 락이 적용된 메서드를 호출하여 동시에 주문요청이 들어오는 경우를 처리하려고 했다.
해당 서비스 메서드는 @Transactional을 적용하여 트랜잭션을 관리했다.
주문 처리 중 발생할 수 있는 다양한 예외 상황을 안전하게 관리하기 위해 try-catch 블록을 활용했다.
이 과정에서 특정 예외 상황에 대해 로그를 남기고, 필요 시 적절한 예외를 발생시키도록 구현했다.
2명의 구매자가 동시에 같은 상품에 대해 거래를 요청을 가정해서 테스트 코드를 작성했다.
한 명만 성공하고 나머지 한 명은 실패하게 되는지 확인했다.
동시성 문제 해결을 위해 ExecutorService와 CountDownLatch를 사용해 여러 스레드를 동기화하고, 비관적 락을 통해 안전하게 처리되는지 확인하는 방법을 사용했다.
failCount.get()가 1인지를 확인하여, 한 명의 구매자만 성공적으로 거래에 참여했는지 검증했다.