텔링미 개발팀장(이름만)을 맡고있는 '키태'라고 한다. 처음으로 쓰는 팀 블로그에 무슨 글을 쓰면 좋을 지 생각하다 개발팀 모두가 힘들게 고생한 로그인 스프린트에 관해 써보려 한다. 더불어 로그인 이후에 가장 중요한 토큰 로직까지 아직 팀원들과 공유하진 않았지만 이 기회에 써보면 좋을 듯 하다.
스프링에서 로그인 방식은 크게 2가지가 있다.
- 세션 로그인
- JWT(Json Web Token) 로그인
각 방법마다 장단점이 있겠지만 우리는 10000명 이상의 사용자를 목표로 하고 있었기에 서버의 자원 소모가 큰 세션 로그인 보단 JWT 로그인이 적합하다고 판단했다. 또 서버팀이 이때까지 해온 대부분의 프로젝트에서 JWT 방식으로 로그인을 구현했기에 상대적으로 익숙한 기술을 도입하는게 맞다고 생각했다.
위 사진처럼 JWT는 Header, Payload, Signature 구조로 구성되어있는 토큰이다. 우리는 사용자 고유값을 Payload의 sub에 담아 클라이언트 측과 통신하기로 결정했다.
많은 사용자를 모으기 위해선 회원가입과 로그인 절차가 간편한 소셜로그인이 필수라 생각했다. 사용자들이 클릭 한번으로 로그인이 되는 것에 큰 편리함을 느끼기에 이는 피할 수 없는 부분이었다. 하지만 개발팀 (프론트엔드 2명, 백엔드 2명, iOS 1명)에서 나를 빼놓고는 모두가 소셜로그인 구현이 처음이었기에 사실 나한테는 큰 부담이었다. 기획팀과 개발팀의 회의(공수, 사용성 등) 끝에 소셜로그인은 애플, 카카오만 가져가기로 했다. 나는 이에 따른 로직을 먼저 세웠다.
위 그림은 내가 전 프로젝트에서 구현했던 카카오톡 소셜로그인 흐름도이다. 당시에는 클라이언트가 인가코드를 받아서 서버측에 넘기면 서버에선 이를 이용해 카카오측으로부터 사용자 고유값을 얻어 DB의 값과 비교하는 방식으로 진행했다. 하지만 우리는 클라이언트 측에서 카카오로부터 받은 토큰에서 사용자 고유값의 파싱이 가능했고 이를 바로 서버에 넘기기로 했다. 즉 위 그림의 4,5번 로직이 생략된 것이다.
위 그림은 전형적인 애플 소셜로그인 흐름도이다. 나도 이번에 애플 소셜로그인을 처음 구현해보며 공부를 많이 했는데 우리 텔링미 서비스는 자체 JWT를 사용하기에 클라이언트 측에서 받은 id_token을 파싱해 사용자 고유값인 sub를 가져와 DB의 값과 비교해주는 방식으로 진행했다.
즉 정리하자면,
<카카오>
클라이언트 -> 서버 (socialId) -> DB 비교
<애플>
클라이언트 -> 서버 (idToken) -> socialId 파싱 -> DB 비교
이렇게 소셜로그인 로직을 정리하고 개발팀에게 발표했다. 다행히 팀원들도 잘 이해를 해줘서 바로 구현 단계로 넘어갈 수 있었다.
위 로직대로 서버팀에서 먼저 구현을 시작했고 초반에는 애플(idToken), 카카오(socialId)으로 넘겨주는 값이 각각 달랐기에 2개의 API를 만들었다.
@ApiOperation(value = "카카오 로그인 API")
@RequestMapping(value = "/api/oauth/kakao", method = RequestMethod.POST)
public ResponseEntity<?> oauthKakao(@RequestBody("socialId") String socialId) {생략}
@ApiOperation(value = "애플 로그인 API")
@RequestMapping(value = "/api/oauth/apple", method = RequestMethod.POST)
public ResponseEntity<?> oauthApple(@RequestHeader("idToken") String idToken) throws ParseException {생략}
이런 느낌으로 구현했었다. 하지만 멘토 형이 말하길 위 코드는 확장성이 떨어지는 구조라 했다. 만약 여기서 네이버 소셜로그인을 도입한다 가정했을 때, 그럼 API를 하나 더 만들어야하는 공수가 생기는 것이다. 한번 두 API를 확장성있게 합쳐보라는 피드백을 받았고 이때 단지 기능 구현만 하는게 아닌 확장성 있는 구조로 만드는 것에 대해 크게 깨달았다.
@ApiOperation(value = "소셜로그인 API")
@PostMapping(value = "{loginType}")
public ResponseEntity<?> oauthLogin(@PathVariable("loginType") String socialLoginType, @RequestHeader(value = "idToken", required = false) String idToken, @RequestBody(required = false) OauthRequestDto oauthRequestDto) throws ParseException {생략}
위의 구조로 수정했다. 다만 idToken과 socialId는 필수값이 아니며 각 로그인 타입별로만 넣어주면 된다. 당연히 둘 다 안들어오거나 타입별로 다른 값이 들어오는 케이스들은 예외처리를 해주었다.
기존의 소셜로그인은 이 단계에서 끝이 난다. 하지만 우리 서비스는 추가 정보를 필요로 했기에 '추가정보 기입' 이라는 프로세스를 진행해줘야 했다. 우선 소셜로그인 버튼을 클릭했을 때 만약 가입이 안된 유저에게는
위 사진처럼 404 에러 코드와 socialId, socialLoginType을 반환해줬다. 여기서 서버팀에서 고민이 생겼는데 '회원가입이 안된 유저이면, 회원가입을 시키고 404를 반환하는게 맞는지' 였다. 즉 서비스를 처음 이용하는 유저가 로그인을 시도하면 우선 회원가입을 시키고(DB에 저장하고) 추가 정보를 기입하는게 맞는지, 아니면 모든 정보 기입이 끝났을 때(추가정보 포함) 회원가입을 시키는게 맞는지 의견이 분분했다. 이는 사용자가 버튼만 클릭하고 화면을 꺼버릴 수도 있기에 매번 추가정보까지 기입된 유저인지 체크하는게 비효율적이라 판단해 모든 정보 기입이 끝났을 때 회원가입을 시키도록 결정했다. 그래서 클라이언트에게 socialId와 socialLoginType을 넘겨주고 추가정보가 끝나면 User 객체의 모든 정보를 넘겨줌으로써 DB에 저장시켜주는 로직을 적용시켜줬다.
그럼 만약 DB에도 잘 저장이 되었고 그 다음 유저가 로그인 시도를 하면 어떻게 될까?
위 사진은 실제 테스트 계정으로 로그인했을 때 반환값이다. 우리는 accessToken, refreshToken 2개를 넘겨준다.
위 사진은 우리의 accessToken을 jwt.io에서 디코딩한 결과이다. 토큰 값은 보안 이유로 가렸다. 토큰을 2개나 반환하는 이유는 다음과 같다.
- 개인정보를 포함한 JWT는 탈취당할 위험이 높기에 만료기간을 짧게 해야한다.
- 이에 따라 개인정보를 포함한 accessToken은 만료 기간을 짧게 (30분) 설정한다.
- accessToken 만료 기간이 짧아짐에 따라 재로그인 주기가 짧아진다.
- 사용성을 위해 accessToken이 만료되었을 때, 재발급을 도와주는 토큰을 같이 반환한다.
- 그 토큰이 refreshToken이다. 이 토큰은 사용자 정보를 포함하지 않고 있기에 탈취당해도 된다.
- 사용자 정보를 포함하지 않기에 만료 기간을 길게 하여 (90일) accessToken의 재발급을 도와준다.
즉 위의 흐름도를 따르는 것이다. Refresh Token으로 AccessToken의 재발급을 요청했을 때 AccessToken 안에 있는 사용자 정보가 올바른 정보인지 비교 확인하기 위해 Refresh Token Entity를 만들어 서버 DB에서 관리 중이다.
그럼 여기서 궁금증이 생긴다.
- iOS 어플을 보면 로그아웃 되는 경우가 없이 항상 자동으로 로그인되던데??
여기서 상당히 고민이 많아졌다. '자동 로그인 기능을 구현해야 하나?'부터 생각해서 엄청나게 레퍼런스를 찾아봤다. 결론부터 말하자면 자동로그인 기능은 필요가 없고 위에서 설명한 토큰 로직으로 처리가 가능하다.
- iOS에서는 SDK, Keychain 같은 곳에 토큰을 저장한다.
- 사용자가 어플에 처음 '진입'할 때 13번 로직을 실행한다. (스플래시 화면)
- 이때, AccessToken만 재발급하는게 아닌 RefreshToken 또한 재발급해준다.
위에서 말했듯이 refreshToken 기한이 90일이기 때문에 사용자가 어플에 진입만해도 90일이라는 시간을 벌 수 있다. 많은 어플이 이 방식을 이용하는 것 같고 우리가 자동로그인처럼 느끼는 이유는 토큰 로직을 '스플래시 화면'에서 실행하기 때문이다. 가만 생각해보면 한 어플을 90일 동안 안들어간 적은 잘 없기에 사용자 입장에서는 항상 자동로그인처럼 느끼는 것이다.
이렇게 로그인 스프린트 도입기부터 구현까지 모든 점을 적어봤다. 처음 적는거라 어색하기도 하고 잘 못적었을까 걱정도 되지만 다들 잘봐줬으면 좋겠다. 처음에 개발팀장으로서 모든 기술의 로직을 정립하는게 부담스럽고 힘든 일이었지만 이제는 당연한 일인듯 잘 진행하고 있다. 개발 팀원들이 잘 도와줘서 너무 고맙고 8월까지 부디 잘 마무리했으면 좋겠다😄
사진 출처
- https://veiz.me/38 (JWT 구성도)
- https://data-jj.tistory.com/53 (카카오 소셜로그인 흐름도)
- https://d0lim.com/blog/2022/06/login-with-apple-workaround/ (애플 소셜로그인 흐름도)
- https://velog.io/@ehdrms2034/Spring-Security-JWT-Redis%EB%A5%BC-%ED%86%B5%ED%95%9C-%ED%9A%8C%EC%9B%90%EC%9D%B8%EC%A6%9D%ED%97%88%EA%B0%80-%EA%B5%AC%ED%98%84 (토큰 흐름도)