MSA로 프로젝트를 진행하면서 마주친 큰 골칫거리는 다름아닌 인증이었다. 검색을 통해 알아보니 MSA와 인증에 대한 정확한 해답은 존재하지 않는다. 다시 말해서, 사용자가 예약을 하려고 하거나 리뷰를 쓰려고 할 때 예약 서비스와 리뷰 서비스는 사용자가 인증되어 있는지 확인하는 방법이 필요하다. 어떻게 확인을 해야 할까? 대략적으로 3가지 방법이 있다. 앞서 말했듯이 완벽한 해답은 없다. 하나씩 간략하게 알아본다.
개별 서비스가 인증 서비스에 의존
1번 방법은 말 그대로 개별 서비스가 인증 서비스에 동기적으로 인증 요청을 전송하고 응답을 받는 것이다. MSA에서 동기적이란 의미는 이벤트나 이벤트 버스를 사용하지 않고 하나의 서비스에서 다른 서비스로 전송되는 직접적인 요청을 의미한다. 이 방법은 구현이 간단하지만 매우 큰 문제가 존재한다. 만약 인증 서비스가 다운되면 어떻게 될까? 인증이 필요한 모든 서비스가 인증 서비스에 의존하기 때문에 사용자가 인증되었는지 확인하는 방법이 사라진다.
개별 서비스가 게이트웨이로 작동하는 인증 서비스에 의존
2번 방법은 1번 방법과 매우 유사한데 차이점은 인증을 요구하는 요청이 목적 서비스로 바로 가는 것이 아니라 게이트웨이로 작동하는 인증 서비스를 미리 거친 후 목적 서비스에 도착한다는 것이다. 이 역시 1번과 같이 인증 서비스가 다운되면 인증을 할 수 없다는 똑같은 문제를 가지면 서비스 간 의존성을 유발한다.
개별 서비스가 인증
3번 방법은 개별 서비스가 JWT, 쿠키 등을 처리해서 사용자가 인증되었는지 결정한다. 이 방법은 서비스간 의존성을 없애고 1번/2번 방법에서 발생할 수 있는 가장 큰 문제를 해결한다. 하지만 단점도 역시 존재하는데 바로 개별 서비스에 인증 논리를 중복해야 한다는 것인데 이는 공유 라이브러리로 해결할 수 있다. 그러나 다른 큰 문제가 존재한다. 예를 들어, 악의적인 사용자를 인증 서비스에서 금지했다고 하자. 인증 서비스 데이터베이스는 요청에 따라 사용자의 상태를 금지 상태로 수정할 수 있다. 하지만 악의적인 사용자가 인증 시 발급되는 JWT 혹은 쿠키를 개발자가 어떻게 할 수 없다! 따라서 악의적인 사용자는 인증을 성공하고 리뷰를 작성하거나 예약을 할 수 있다. 서비스 간 직접적인 연걸이 없기 때문에 하나의 서비스의 수정된 상태를 다른 서비스가 알 수 없다는 것이 큰 단점이다.
선택
3가지 방법에서 알 수 있듯이 올바른 해답이 존재하지 않는다. 따라서 장단점을 잘 조율해서 최선의 방법을 선택해야 한다. 1번/2번 방법으로 가면 사용자의 상태가 즉시 다른 서비스에 반영되지만 인증 서비스가 다운되었을 때 해결할 수 있는 방법이 없다는 너무나 큰 단점이 존재한다. 3번 방법의 경우 전자의 가장 큰 문제가 사라지지만 사용자의 상태로 바로 반영되지 않는다는 단점이 존재한다. 따라서, 3번 방법이 가장 이상적이다. 인증 서비스가 다운될 경우 해결책이 없다는 것이 치명적이고 서비스 간 독립적인 상태를 유지하고 비동기적인 요청을 통해 서비스가 상호작용하는 것이 MSA의 철학에 부합한다.
서비스 간 수정에 대한 해결책
3번 방법의 악의적인 사용자를 예로 들면서 MSA에서는 서비스 간 상태를 어떻게 반영해야 하는지에 대한 문제가 발생한다. 이 문제를 어떻게 해결할 수 있을까? 다시 말해 사용자가 차단될 경우 다른 서비스에 이를 즉각적으로 반영하는 방법은 무엇일까? 인증 서비스에서 사용자를 차단한 즉시 이와 관련된 이벤트를 방출한다. 해당 이벤트를 듣는 서비스는 이벤트를 듣고 단기 데이터 저장소나 캐시에 이벤트에서 방출된 데이터를 저장한다. 캐시를 언급한 이유는 사용자는 언제든지 차단이 해제될 수 있기 때문이다. 따라서 인증 매커니즘의 수명과 같은 시간 동안만 저장하면 된다(e.g., 쿠키/JWT 15분 만료.). 이를 통해 사용자를 즉시 모든 서비스에서 차단할 수 있다. 하지만 각 서비스에서 이벤트를 듣고 사용자 목록을 저장하는 등 추가 구현이 필요하다는 단점이 있지만 이 역시 공유 라이브러리로 해결할 수 있다.
요구사항
인증 방법을 정하고 난 후 인증 매커니즘에 대한 요구사항을 알아봐야 한다. 대략적으로 5가지의 요구사항이 존재한다. 아이디나 이메일과 같은 사용자에 대한 정보를 저장해야 한다. 권한 정보를 저장할 수 있어야 하는데 관리자인지 일반 사용자인지 등의 정보를 내장된 방식으로 저장하고 변조할 수 없도록 해야한다. 다음으로, 사용자가 조작하거나 속여서 인증 메커니즘을 만료시키거나 무효화할 수 없도록 해야 한다. 또한, 사용자 브라우저에 접근해서 인증 메커니즘을 무효화할 수 없으므로 자동으로 만료되거나 무효화되어야 한다. 여러 프로그래밍 언어 간에 쉽게 이해되고 적용될 수 있어야 한다. 마지막으로 사용자 정보는 인증 메커니즘 내부에 있어야 하며 별도의 데이터 저장소를 요구해서는 안된다.
JWT vs. Cookie
요구사항을 알고나면 JWT를 사용하는 방향으로 흐른다. JWT는 사용자에 대한 정보를 저장할 수 있고 권한 처리를 할 수 있는 속성도 가지고 있다. 또한 만료 기간을 부호화할 수 있다. 쿠키도 만료 기간을 설정할 수 있지만 쿠키는 사실 브라우저가 처리하며 사용자가 쿠키 정보를 복사하여 만료 기간을 무시한 채 계속 사용할 수 있다. 이와 반대로 JWT는 만료 기간을 자체적으로 부호화하기 때문에 만료되면 더 이상 유효하지 않다. 또한 여러 언어가 JWT를 지원한다는 장점도 있다. 마지막으로 JWT는 사용자를 식별하기 위한 데이터 저장소를 필요로 하지 않는다. 오로지 쿠키 기반 인증 메커니즘으로 사용한다면 언어마다 맞춤 쿠키 구현에 차이가 상당할 수 있다. 또한 쿠키의 세션 아이디를 저장해야 하기 때문에 따로 데이터 저장소가 필요하다.
JWT 전달 방식
JWT는 인증 메커니즘일 뿐이기 때문에 브라우저와 서버 간에 어떻게 전달해야 하는지에 대한 문제가 남아있다. 헤더, 본문 그리고 쿠키를 통해 전달할 수 있는데 어떤 방법을 사용하는 것이 좋을까? 여러 장단점을 고려하면 쿠키를 전송 메커니즘으로 JWT를 인증 메커니즘으로 사용하는 것이 최선을 방법이라는 것을 알 수 있다. 쿠키를 암호화해야 할까? 쿠키의 내용이 서로 다른 언어 간에 쉽게 이해되어야 하는데 암호화를 적용하는데 그 암호화 알고리즘이 다른 언어에서 지원되지 않을 수 있다. 왜냐하면 JWT는 원래 쉽게 변조되지 않기 때문이다. 누군가 JWT의 정보를 수정하려고 하면 데이터를 변조하려고 시도했기 때문에 JWT이 유효하지 않게 된다.
예외
각 서비스마다 Redis를 사용해서 사용자를 저장하면 사용자 차단/차단해제와 같은 기능을 구현할 수 있다. 그런데 예외가 존재하는데 처음 로그인을 하거나 캐시가 만료될 경우 어쩔 수 없이 인증 서비스에 API 요청을 한다. 하지만 그 외에는 대부분 Redis에 저장된 캐시를 통해서 인증을 거치기 때문에 큰 문제가 될 것 같지는 않은 것 같으면서도 로그인/로그아웃을 전부 이벤트로 처리해야 하는가에 대한 고민이 있다. 왜냐하면 구현을 할 수 있지만 로그인/로그아웃 빈도는 상당히 높을 수 있기 때문이다. 이에 대해서는 더 알아봐야 한다. 또 다른 문제는 인증 미들웨어에 매개변수로 Redis 인스턴스를 요구한다. 인증 서비스에서 사실 이는 필요없지만 코드를 공유하다 보니 자기 자신에게 API 요청을 하는 경우가 발생한다. 매개변수를 추가해서 서비스 이름으로 구분하면 해결할 수 있을 것 같은데 더 좋은 방법을 찾아보자...
새로고침 토큰
새로고침 토큰 재발급은 큰 난관이다. 인증 미들웨어에서 접근토큰이 만료되면 새로고침 토큰의 유효성을 검사한다. 새로고침 토큰이 만료되지 않았고 유효하면 데이터베이스에 저장된 새로고침 토큰과 비교하고 일치하면 데이터베이스에 저장하는데 여기서 문제가 발생한다. MySQL, MongoDB와 같은 데이터베이스를 사용할 수 없기에 해결책은 역시 Redis이다. 그런데 로그인 시 접근 토큰과 새로고침 토큰을 쿠키로 발급하고 로그아웃 시 둘의 쿠키를 삭제한다. 당연히 데이터베이스에 저장된 새로고침 토큰도 제거해야 한다. 예외에서 로그인/로그아웃에 대해 말한 것처럼 이럴 경우 로그인/로그아웃 이벤트를 인증/인가가 필요한 서비스에 방출하고 각 서비스의 Redis에서 이를 매번 수정해야 한다. 코드는 다음과 같다.
인증 미들웨어 코드
위에 열거한 모든 점을 고려하여 작성한 코드는 다음과 같다. 새로고침 토큰뿐만 아니라 인증 후 전달되는 사용자 페이로드 타입을 환경에 따라 if 문으로 구분했는데 if 문을 없애고 테스트 환경에서 인증에 필요한 필요한 메서드는 모의(mock)하는 방향으로 가야한다. 또 다른 문제는 Redis에서 가져온 자료형을 Record<any, any>로 지정했는데 TypeScript에서 any는 가급적이면 사용을 자제해야 한다. 마지막으로 토큰 발행 후 비밀번호를 수정한 경우 로그인을 허용하지 않아야 하는데 현재 주석으로 처리되어 있다. 코드에서 알 수 있듯이 개선점이 많다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 | export const authenticationMiddleware = (redis: Redis) => { return catchAsync( async ( request: RequestWithUser, response: Response, next: NextFunction ) => { let jwtAccess, jwtRefresh; let result; /* Authorinzation 헤더의 Beaer 토큰을 사용하는 경우. */ // if ( // request.headers.authorization && // request.headers.authorization.startsWith('Bearer') // ) { // token = request.headers.authorization.split(' ')[1]; // } /* 1. 토큰을 추출한다. */ if (request.cookies && request.cookies[JwtType.AccessToken]) { jwtAccess = request.cookies[JwtType.AccessToken]; } if (request.cookies && request.cookies[JwtType.RefreshToken]) { jwtRefresh = request.cookies[JwtType.RefreshToken]; } if (!jwtAccess) { return next( new UnauthenticatedUserError( Code.UNAUTHORIZED, '로그인이 필요합니다.' ) ); } /* 2. 토큰을 검증한다. */ /* 콜백함수를 프로미스로 변형한다. */ try { result = await JwtUtil.verify(jwtAccess, JwtType.AccessToken); } catch (error: any) { /* * 접근토큰이 만료되면 새로고침 토큰 재발급 과정을 거친다. * 새로고침 토큰이 유효하면 접근토큰과 새로고침 토큰을 재발급을 한다. */ if (error.name === 'TokenExpiredError') { const jwtBundle = await JwtUtil.reissue(jwtRefresh); const cookies = CookieUtil.setJwtCookies( jwtBundle.accessToken, jwtBundle.refreshToken ); response.setHeader('Set-Cookie', cookies); } else { return next(error); } } const decoded = result as JwtDecoded; let user: Nullable<UserDocument>; let getCurrentUser: AxiosResponse<any, any>; let cachedUser: Record<any, any> = {}; /* 3. 사용자가 존재하는지 확인한다(테스트 환경에서 생략한다.) */ if ( process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'production' ) { const isCached = await RedisUtil.isCached( decoded.id, RedisType.User, redis ); if (!isCached) { try { getCurrentUser = await axios.get( `http://auth:3000/api/v1/users/current-user/${decoded.id}` ); } catch (error) { return next( new UserNotFoundError( Code.NOT_FOUND, '사용자가 존재하지 않습니다.' ) ); } user = getCurrentUser.data.data; cachedUser = { id: user!.id, banned: user!.banned, userRole: user!.userRole, }; await RedisUtil.cacheUser(cachedUser, 1 * 60 * 60, redis); } else { cachedUser = await RedisUtil.findUser(decoded.id, redis); } /* 4. 토큰 발행 후 비밀번호를 수정했는지 확인한다. */ // if (user.isPasswordUpdatedAfterJwtIssued(decoded.iat)) { // return next( // new InvalidJwtAfterPasswordUpdateError( // Code.JWT_AFTER_PASSWORD_UPDATE_ERROR, // '로그인이 필요합니다.' // ) // ); // } } /* 접근 제어되는 경로에 접근을 허락한다. */ const userPayload: UserPayload = { id: process.env.NODE_ENV === 'test' ? decoded.id : cachedUser!.id, banned: process.env.NODE_ENV === 'test' ? false : mapStringToBoolean(cachedUser!.banned), userRole: process.env.NODE_ENV === 'test' ? mapStringToUserRole(process.env.TEST_USER_ROLE) : mapStringToUserRole(cachedUser!.userRole), }; request.user = userPayload; next(); } ); }; | cs |
update: 2024.10.21
댓글 없음:
댓글 쓰기