
dowith의 인증 방식에 사용한 기술과 기술을 선택한 이유를 설명하겠습니다.
대표적인 인증 방식 두 가지를 고르라면 쿠키를 동반한 세션방식과 JWT를 Access Token으로 사용하는 방식이 있습니다.
dowith 인증 방식은, 그 중 JWT를 Access Token으로 사용하는 방식을 채택하였습니다.
쿠키를 동반한 세션방식은 세션을 저장하기 위한 메모리가 필요합니다.
EC2의 t2 micro는 1G의 작은 메모리를 갖기 때문에 메모리 사용량을 최대한 줄이고 싶었습니다.
또한 서버에서 사용자를 실시간으로 추적해야 하는 요구사항이 없기에 세션 방식 대신 stateless한 JWT를 Access Token으로 사용하는 방식을 채택하였습니다.
클라이언트는 인증을 위해 서버에게 Access Token을 전달합니다.
서버는 로그인한 사용자에게 리소스에 대한 접근 권한을 주기 위해 클라이언트에게 Access Token을 전달합니다.
이러한 전달 과정에서 Access Token은 언제든지 악의적인 사용자에게 탈취될 수 있습니다
그렇기에 Access Token은 일반적으로 짧은 시간동안만 유효하도록 합니다. dowith에서는 이를 30분으로 정하였습니다.
하지만 사용자가 30분 이상 서비스를 사용하면, 사용자는 서비스 이용 도중에 다시 로그인을 해야하는 불편함을 겪습니다.
이를 해결하기 위해 유효 기간이 긴 Refresh Token을 함께 사용합니다.
Access Token이 만료된 클라이언트는 Refresh Token을 서버에 전송함으로써 유효한 Access Token을 재발급받습니다.
이를 통해 사용자는 Access Token이 만료되었다고 해서 다시 로그인을 하는 불편함을 겪지 않아도 됩니다.
Refresh Token은 서버와 주고 받는 빈도는 낮지만 이 또한 탈취될 수 있습니다.
Refresh Token의 유효 기간은 2주로 Access Token보다 상대적으로 유효 기간이 길어 탈취되었을 때 더욱 위험합니다.
또한 사용자가 사이트를 이용하는 도중에 Refresh Token의 2주라는 유효 기간이 만료될 가능성 또한 있습니다.
이 경우 사용자는 사이트 사용 도중 다시 로그인을 해야 하는 불편함을 겪습니다.
이러한 문제점들을 해결하기 위해 Refresh Token 교환 방식인 Refresh Token Rotation 방식을 사용합니다.
서버는 memberId와 Refresh Token을 하나로 묶은 튜플을 DB에 저장합니다.
클라이언트가 보낸 Refresh Token이 DB에 저장된 Refresh Token과 일치하는 경우에만 서버는 해당 요청을 정상 요청이라고 판단합니다.
이 경우에만 새로운 Refresh Token을 만들고, 이를 DB에 저장함으로써 이전의 Refresh Token을 무효화시킵니다.
이 방식은 Refresh Token을 DB에 저장해야 한다는 단점이 있지만, Access Token이 만료된 경우에만 DB 조회가 일어나므로 작은 오버헤드라 생각했습니다.
만약 쿠키를 사용한 세션 방식에서 세션을 메모리 대신 DB에 저장했다면 매 요청마다 DB 조회가 일어나므로 이에 비하면 낫다고 할 수 있습니다.
RTR 방식을 적용하면, 클라이언트가 보낸 Refresh Token이 악의적인 사용자에게 탈취되었더라도 서버가 응답하는 시점에 탈취된 Refresh Token이 무효화되므로 악의적으로 재사용할 수 없습니다.
서버가 클라이언트에게 보낸 Refresh Token이 탈취된 경우에는 유효한 토큰이므로 사용될 수 있지만 클라이언트가 Access Token이 만료되어 요청을 하는 시점엔 탈취된 Refresh Token이 무효화되므로 상대적으로 안전합니다.
사용자가 사이트를 사용하는 동안에 Refresh Token의 유효 기간이 계속해서 갱신되기에 사용 도중 로그인을 다시 해야하는 불편함이 없습니다.
Access Token의 decoded 헤더와 클레임
HEADER
{
"typ": "JWT",
"alg": "HS256"
}
PAYLOAD
{
"iss": "dowith",
"iat": 1720757987,
"exp": 1720759787,
"memberId": 4,
"tokenType": "ACCESS"
}
Refresh Token의 헤더와 클레임
HEADER
{
"typ": "JWT",
"alg": "HS256"
}
PAYLOAD
{
"iss": "dowith",
"iat": 1720757959,
"exp": 1721967559,
"memberId": 4,
"tokenType": "REFRESH"
}
테스트한 쿼리
SELECT * FROM member WHERE provider = 'GOOGLE' AND auth_id = '84009008787';
사용자 정보를 가져오기 위한 쿼리로, OAuth Proiver와 OAuth 결과로 알아낸 User ID를 조건으로 사용하여 검색합니다.
실행한 EXPLAIN
EXPLAIN SELECT * FROM member WHERE provider = 'GOOGLE' AND auth_id = '45001055063';
SIMPLE: 서브쿼리나 UNION 없이 단순 SELECT임을 의미
ALL: full table scan
rows: 옵티마이저가 예측한 읽어야 할 행의 수, member 테이블의 전체 행 수일 가능성이 높음
filtered: 쿼리 조건을 만족하는 행의 비율로, 읽은 행의 1%만이 최종 결과로 반환될 것으로 예측함
Extra: WHERE 조건을 사용하여 필터링했음을 의미함
실행 계획 결과 : Full Table Scan이 수행될 것으로 예상했습니다.
실행한 EXPLAIN ANALYZE
EXPLAIN ANALYZE SELECT * FROM member WHERE provider = 'GOOGLE' AND auth_id = '84009008787';
Filter: WHERE 절에 정의된 조건으로, 필터링 조건을 만족하는 결과를 찾기 위해 테이블을 스캔함
(cost=9829 rows=972): 쿼리 실행에 예상되는 상대적인 비용과 예상되는 결과 행 수
actual time=29..64.4: 실제로 쿼리를 실행하는 데 걸린 시간으로, 약 35ms 정도 소요됨
rows=1: 결과로 반환된 행 수는 1개임Table scan on member: 인덱스를 사용하지 않고 전체 테이블을 검색했음을 의미
(cost=9829 rows=97246): 테이블을 스캔하는 데 소요되는 예상 비용과 예상되는 행의 수
(actual time=0.484..50.2 rows=100000 loops=1): 테이블 스캔 시작부터 끝까지의 실제 실행 시간으로, 50ms 정도 소요됨
따라서 0.484..64.4로 쿼리 실행에 약 64ms 소요되었습니다.
실행한 EXPLAIN
EXPLAIN SELECT * FROM member WHERE provider = 'GOOGLE' AND auth_id = '45001055063';
SIMPLE: 서브쿼리나 UNION 없이 단순 SELECT임을 의미
const: 인덱스가 Primary Key 또는 Unique Index로 하나의 행만 검색된다는 것을 의미
key: 실제로 사용된 인덱스로, provider_auth_id 인덱스가 사용됨
ref: 인덱스된 컬럼을 비교하기 위해 사용된 값으로, 두 개의 상수가 인덱스와 비교되고 있음을 의미
rows: 실행 계획에서 검색될 것으로 예상하는 행 수
filtered: 쿼리 조건에 의해 필터링된 행의 백분율로, 100.00은 모든 행이 조건에 맞음을 의미
실행 계획 결과 : provider_auth_id 인덱스를 이용하여 하나의 행만 검색할 것으로 예상했습니다.
실행한 EXPLAIN ANALYZE
EXPLAIN ANALYZE SELECT * FROM member WHERE provider = 'GOOGLE' AND auth_id = '84009008787';
실행 시작부터 끝까지 걸린 총 시간은 약 0.000209 - 0.000167 = 0.000042 초로, 약 0.042ms 소요되었습니다.
결과: 인덱스 있을 때 0.042ms → 인덱스 없을 때 64ms
실행한 EXPLAIN
EXPLAIN SELECT * FROM member WHERE provider = 'GOOGLE' AND auth_id = '73101613000';
실행 계획 결과 : 마찬가지로 Full Table Scan이 수행될 것으로 예상했습니다.
실행한 EXPLAIN ANALYZE
EXPLAIN ANALYZE SELECT * FROM member WHERE provider = 'GOOGLE' AND auth_id = '7310161300';
0.439..302로 쿼리 실행에 약 302ms 소요되었습니다.
실행한 EXPLAIN
EXPLAIN SELECT * FROM member WHERE provider = 'GOOGLE' AND auth_id = '73101613000';
실행 계획 결과 : 마찬가지로 provider_auth_id 인덱스를 이용하여 하나의 행만 검색할 것으로 예상했습니다.
실행한 EXPLAIN ANALYZE
EXPLAIN ANALYZE SELECT * FROM member WHERE provider = 'GOOGLE' AND auth_id = '7310161300';
실행 시작부터 끝까지 걸린 총 시간은 약 0.000333 - 0.000208 = 0.000125초로, 약 0.125 ms 소요되었습니다.
결과: 인덱스 있을 때 0.125ms → 인덱스 없을 때 302ms
실행 시간에 큰 차이 없음
마찬가지로 실행 시작부터 끝까지 걸린 총 시간은 약 0.000417 - 0.000292 = 0.000125초로, 약 0.125 ms 소요되었습니다.
실행한 EXPLAIN
EXPLAIN SELECT * FROM member WHERE provider = 'GOOGLE' AND auth_id = '42002135651';
[provider, auth_id]와 [auth_id, provider] 중에서 [provider, auth_id]를 사용할 것이라고 계획하는 것을 확인할 수 있습니다.
실행한 EXPLAIN
provider = 'GOOGLE'
EXPLAIN SELECT * FROM member WHERE provider = 'GOOGLE' AND auth_id = '56198121994';
[provider, auth_id]와 [auth_id, provider] 중에서 [provider, auth_id]를 사용할 것이라고 계획하는 것을 확인할 수 있습니다.
실행한 EXPLAIN
provider = 'KAKAO'
EXPLAIN SELECT * FROM member WHERE provider = 'KAKAO' AND auth_id = '77511301629';
마찬가지로 [provider, auth_id]와 [auth_id, provider] 중에서 [provider, auth_id]를 사용할 것이라고 계획하는 것을 확인할 수 있습니다.
최종적으로 [provider, auth_id] UNIQUE 인덱스를 데이터베이스에 생성하기로 결정하였습니다.
출처