안녕하세요 브로콜리입니다.
안드로이드 프로젝트 오디에서는 지난 레벨3 4차 스프린트에서 카카오 SDK api를 활용한 카카오 로그인을 지원하기로 결정하였습니다. 이에 따라 안드로이드 팀과 함께 인증과정에 대한 합의가 필요했는데요. 이번 글에서는 어떠한 방식으로 인증 로직을 구현하고 그 과정에서 어떠한 고민들을 했는지 소개해보고자 합니다.
1. 무엇이 고민이었는가 : 로그인 상황별 FCM 디바이스 토큰의 싱크 맞추기
1) 카카오 인증정보 받기
- 안드로이드 측에서 SDK를 활용해 회원 관련 정보를 받습니다
2) 오디 서버에 인증하기
- POST /auth/kakao api에 회원 정보를 넘겨 인증을 시도합니다
- 서버에서는 액세스 토큰과 리프레시 토큰을 발급해줍니다
3) 토큰을 통한 인증
- 안드로이드는 api 호출 시 액세스 토큰을 담아 함께 요청합니다
- 만약 액세스 토큰이 만료되었다면 리프레시 토큰을 통해 액세스 토큰 갱신을 요청합니다. 서버는 리프레시 토큰 만료여부를 보고 액세스 토큰을 발급하여 줍니다.
전체적인 로직을 보시면, 사실상 카카오 인증 과정은 안드로이드 측에서 담당했기에 redirect URL과 api key 관리와 같은 부수적인 부분을 서버에서 고려하지 않아도 되었습니다. 즉 서버의 역할은 회원 정보를 DB에 넣고 관리하는 역할과 토큰을 통한 인증만을 담당하게 되었습니다.
그러나 이런 간단한 인증만 담당하게 되었다면 얼마나 행복했을까요? 아쉽게도 쉽게 상상가능한 예외 상황이 존재했습니다.
예를 들어 A라는 기기를 사용하는 콜리라는 회원이 있다고 가정해봅시다. 오디 서비스에서는 각 안드로이드 기기에 회원이 등록한 기기 정보를 기반으로 알림을 보내줍니다.
그런데 만약 새로운 회원이 A 기기를 통해 로그인을 하면 어떻게 될까요?
콜리를 위한 알림이 전혀 상관없는 사용자에게 가지는 않을까요?
반대로 콜리가 휴대폰에 더불어서 태블릿이라는 새로운 기기로 로그인을 시도하면 어떻게 될까요?
알림은 태블릿과 휴대폰 모두 오게 해야할까요?
두 기기 모두 로그인을 유지해야할까요, 혹은 한 기기는 자동 로그아웃을 해주어야 할까요?
2. Fcm 기기토큰과 ProviderId
이처럼 안드로이드 기기에서 카카오 계정을 활용해 로그인을 하는 상황은 여러가지 경우의 수가 있을 수 있습니다.
각 로그인 상황을 설명하기 위해 FCM 기기 토큰과 ProviderId가 무엇인지 간단히 짚고 넘어가겠습니다.
2-1) FCM 기기 토큰은 기기 식별자이다.
FCM(FireBase Cloud Messaging)이란 메시지를 안정적으로 무료 전송할 수 있는 크로스 플랫폼 메시징 솔루션으로 프로젝트 내에서는 각 안드로이드 기기에 알림을 보내기 위해 활용하였습니다. 여기서 FCM 기기 토큰이란 각 기기에 붙이는 식별자를 의미합니다.
예를 들어 기기A에 FCM token인 tokenA가 발급된 상황을 살펴보겠습니다. 앱 서버는 메시지와 함께 보내는 목적지에 기기토큰 tokenA를 넣어 알림 발송을 FCM 서버에 위임합니다. FCM 서버는 기기 토큰을 기반으로 기기를 식별하고 대상 기기에 알림을 보내주게 됩니다.
2-2) ProviderId는 카카오 계정 식별자이다.
ProviderId의 경우 하나의 카카오 계정 식별자를 의미합니다. 즉 카카오 계정 A와 B가 있다면 각각의 계정은 고유한 식별자를 지니게 됩니다. 따라서 우리의 서버에서는 카카오에서 발급해주는 고유한 계정 식별자인 ProvderId를 통해 계정을 식별하게 됩니다.
3. 5가지 로그인 상황에 따른 기기 토큰 싱크 맞추기
안드로이드 기기 특성상 다음과 같은 로그인 상황이 발생할 수 있습니다.
1) 특정 하나의 기기에 여러 계정이 로그인할 수 있다
2) 하나의 계정이 여러 기기에서 로그인을 시도할 수 있다.
이러한 특징은 인증 과정을 구현해야 하는 입장에서 골치아픈 로그인 상황이었는데요. 이를 위해 먼저 팀원들과 하나의 계정은 하나의 기기로만 앱을 사용할 수 있도록 한다는 서비스 정책을 결정했습니다. 즉, 하나의 계정이 여러 기기에서 로그인을 하더라도 최근에 로그인한 최신 기기 하나만을 대상으로 알림 서비스를 제공하기로 하였습니다.
그럼 5가지 로그인 상황과 상황에 따른 인증 전략에 대해 예시를 통해 설명해보겠습니다.
A라는 기기를 사용하는 콜리계정과 B라는 기기를 사용하는 브로계정, 두 회원 계정이 있는 상황을 가정해보겠습니다.
현재 DB 상황을 살펴보면 다음과 같은 모습일 것입니다.
FCM Token | providerId | |
회원1 | 기기 A | 콜리 |
회원2 | 기기 B | 브로 |
인증상황1) 새로운 유저가 새로운 기기로 로그인
C라는 기기를 사용하는 피망 회원이 신규 가입하는 상황입니다.
이 경우에는 신규 회원인 피망의 FCM 기기 토큰과 그대로 DB에 저장해주기만 하면 됩니다.
FCM Token | providerId | |
회원1 | 기기 A | 콜리 |
회원2 | 기기 B | 브로 |
회원3 | 기기 C | 피망 |
인증상황2) 새로운 유저가 기존 유저 기기로 로그인
신규 유저인 피망 유저가 브로 회원이 사용하던 기기 B를 가지고 로그인을 하는 경우입니다.
이 경우 기기의 이전이 일어났기에 2가지 작업이 필요합니다.
- 브로 회원이 더 이상 피망 회원을 대상으로 하는 알림을 받지 않도록 기기토큰을 null로 처리합니다.
- 신규 유저인 피망 회원을 기기B FCM token과 함께 저장합니다.
FCM Token | providerId | |
회원1 | 기기 A | 콜리 |
회원2 | null | 브로 |
회원3 | 기기 B | 피망 |
인증상황3) 기존 유저가 기존 기기로 로그인
가장 일반적인 상황으로 이전에 로그인했던 본인의 기기로 로그인하는 상황입니다.
즉, 콜리가 기기 A로, 브로가 기기 B로 로그인하는 상황입니다.
이 경우에는 각 회원들의 엑세스 토큰만 검증하면 되며 인증 과정에서 처리해주어야 할 별다른 로직이 없습니다.
인증상황4) 기존 유저가 타 유저 기기로 로그인
콜리가 브로의 기기B를 빼앗아 로그인하는 상황입니다.
이 경우도 인증상황2와 비슷하게 기기B를 브로 > 콜리 로 이전하는 과정이 필요합니다.
FCM Token | providerId | |
회원1 | 기기 B | 콜리 |
회원2 | null | 브로 |
인증상황5) 기존 유저가 새로운 기기로 로그인
콜리가 휴대폰을 바꾸어 기기C로 로그인하는 상황입니다.
콜리의 FCM token을 기기C로 업데이트해주어야 합니다.
FCM Token | providerId | |
회원1 | 기기 C | 콜리 |
회원2 | 기기 B | 브로 |
5가지 로그인 상황을 다시한번 정리해보면 다음과 같습니다.
1) 신규 유저가 새로운 기기로 로그인
2) 신규 유저가 타 유저 기기로 로그인
3) 기존 유저가 기존 기기로 로그인
4) 기존 유저가 타 유저 기기로 로그인
5) 기존 유저가 새로운 기기로 로그인
이를 추상화해보면 인증에 따라 FCM token과 providerId를 고려하여 기기 토큰의 싱크를 맞추어 주는 과정은 2가지 질문에 따라 나뉘어질 수 있음을 알 수 있습니다.
- 유저가 신규유저인가?
- 로그인을 시도한 기기는 새로운 기기인가?
이를 기반으로 페어였던 제리와 순서도를 만들어 최대한 축약된 인증 전략을 구현하기 위해 노력했습니다.
4. 1차 구현 : 순차적 분기
MemberService의 save 메서드에 각 인증 상황에 따라 순차적으로 분기하여 로직을 구현해주었습니다.
먼저 코드를 보겠습니다.
@Transactional
public Member save(Member requestMember) {
// 1. 같은 기기 사용자가 있는가?
Optional<Member> findMember = memberRepository.findByDeviceToken(requestMember.getDeviceToken());
if (findMember.isPresent()) {
Member sameDeviceTokenMember = findMember.get();
// 기존 유저 + 기존 로그인 기기 > 그대로 반환
if (sameDeviceTokenMember.isSame(requestMember.getAuthProvider())) {
return sameDeviceTokenMember;
}
// 기존 기기 사용자 > 알림을 받지 않도록 null로 업데이트
sameDeviceTokenMember.updateDeviceTokenNull();
}
// 2. 신규 회원인가?
Optional<Member> findMember = memberRepository.findByAuthProvider(requestMember.getAuthProvider());
if (findMember.isPresent()) {
Member sameAuthProviderMember = findMember.get();
//기존 유저 + 새로운 기기 > FCM token 업데이트
sameAuthProviderMember.updateDeviceToken(requestMember.getDeviceToken());
return sameAuthProviderMember;
}
// 신규유저 + 신규 기기 > 저장 후 반환
return memberRepository.save(requestMember);
}
너무 나열되어 있는 느낌이지만 코드를 찬찬히 뜯어봅시다
1) 신규 기기인가?
먼저 FCM deviceToken을 기반으로 회원을 조회하여 현재 로그인을 시도하는 기기를 사용했던 타 회원이 있는지 확인합니다. 만약 있다면 현재 로그인을 시도하는 회원과 기기 사용 회원이 동일한지 검증합니다.
Optional<Member> findMember = memberRepository.findByDeviceToken(requestMember.getDeviceToken());
if (findMember.isPresent()) {
Member sameDeviceTokenMember = findMember.get();
// 기존 유저 + 기존 로그인 기기 > 그대로 반환
if (sameDeviceTokenMember.isSame(requestMember.getAuthProvider())) {
return sameDeviceTokenMember;
}
// 기존 기기 사용자 > 알림을 받지 않도록 null로 업데이트
sameDeviceTokenMember.updateDeviceTokenNull();
}
- 로그인 시도 회원 == 기기 사용 회원 : 기존 회원이 기존 기기로 로그인을 재시도하는 것으로 그대로 회원을 반환합니다.
- 로그인 시도 회원 != 기기 사용 회원 : 다른 회원이 로그인 이력이 있는 기기로 로그인을 시도하는 것으로 기존 기기를 사용하던 회원의 FCM 디바이스 토큰은 null로 업데이트하여 알림을 받지 않도록 해줍니다.
2) 신규 회원인가?
ProviderId로 회원을 조회하여 회원 계정이 기존 가입 계정인지 확인합니다.
// 2. 신규 회원인가?
Optional<Member> findMember = memberRepository.findByAuthProvider(requestMember.getAuthProvider());
if (findMember.isPresent()) {
Member sameAuthProviderMember = findMember.get();
//기존 유저 + 새로운 기기 > FCM token 업데이트
sameAuthProviderMember.updateDeviceToken(requestMember.getDeviceToken());
return sameAuthProviderMember;
}
// 신규유저 + 신규 기기 > 저장 후 반환
return memberRepository.save(requestMember);
- 기 가입자라면 : 기기토큰만 갱신하고 반환합니다.
- 신규 가입자라면 : DB에 신규 가입자의 정보를 저장하고 반환합니다.
그렇게 해서 완성된 코드의 모습을 다시 바라보면 다음과 같습니다.
@Transactional
public Member save(Member requestMember) {
// 1. 같은 기기 사용자가 있는가?
Optional<Member> findMember = memberRepository.findByDeviceToken(requestMember.getDeviceToken());
if (findMember.isPresent()) {
Member sameDeviceTokenMember = findMember.get();
// 기존 유저 + 기존 로그인 기기 > 그대로 반환
if (sameDeviceTokenMember.isSame(requestMember.getAuthProvider())) {
return sameDeviceTokenMember;
}
// 기존 기기 사용자 > 알림을 받지 않도록 null로 업데이트
sameDeviceTokenMember.updateDeviceTokenNull();
}
// 2. 신규 회원인가?
Optional<Member> findMember = memberRepository.findByAuthProvider(requestMember.getAuthProvider());
if (findMember.isPresent()) {
Member sameAuthProviderMember = findMember.get();
//기존 유저 + 새로운 기기 > FCM token 업데이트
sameAuthProviderMember.updateDeviceToken(requestMember.getDeviceToken());
return sameAuthProviderMember;
}
// 신규유저 + 신규 기기 > 저장 후 반환
return memberRepository.save(requestMember);
}
그러나, 위의 코드에서 몇가지 불편함을 느끼는 부분들이 생겼고 팀원들에게 리팩터링 의사를 밝혔습니다.
5. 인증 전략 리팩터링
5-1) 기존 디바이스 토큰 싱크 인증 전략의 문제점
1. 상황 파악이 잘 안됨
- 각 인증 상황이 어떤 상황인지 분기문을 계속 따라가야 해서 트래킹이 쉽지가 않았습니다.
- 예를 들어서 이제 부터 1인당 기기를 두개 이상 로그인할 수 있도록 변경해주세요! 라는 요구사항이 오면 기존 유저가 새로운 기기/ 타 유저 기기로 로그인 하는 상황을 특정하는 어려움이 있습니다.
- 또한 최종 데모데이 때 4기 선배님께서 인증 관련 코드가 가독적이지 않다는 피드백을 받았습니다.
2. 순서에 의존한 로직 처리
- private method로 분리해놓아서 그렇지 사실상 순차적인 분기처리를 4번 해준 구조입니다.
- 순서에 의존하기에 두 분기의 순서를 바꾼다면 처리가 다르게 됩니다.
3. MemberService에서 인증 정책을 분리할 필요성
- MemberService의 save메서드 안에 인증 정책 관련 로직이 있는 것이 어색합니다.
- 즉, save에 Member를 넣으면 진짜 내가 넘긴 Member가 저장될지 안될지를 외부에서 모르는 상황입니다.
- 따라서, AuthService에게 로그인 상황별 처리 로직에 대한 책임을 더 부여하고 싶었습니다.
5-2) 전략 패턴을 활용해 리팩터링 하기
리팩터링 방향을 생각하던 중 각 로그인 상황을 하나의 객체로 생각해보면 어떨까 하는 생각이 들었습니다. 즉 각 로그인 상황에 대한 판단 + 디바이스 토큰 관련 인증 전략을 다향성으로 처리할 수 있다면 각 상황에 대한 트래킹이 쉬워지고 유지보수에 용이할 수 있을 것 같다는 생각이었습니다.
1) 로그인 상황 추상화하기
먼저 5가지로 구분된 각 로그인 상황을 LoginContext라는 인터페이스로 만들어 추상화하였습니다.
public interface LoginContext {
boolean match(
Optional<Member> sameDeviceMember,
Optional<Member> sameProviderIdMember,
Member requestMember
);
Member syncDevice(
Optional<Member> sameDeviceMember,
Optional<Member> sameProviderIdMember,
Member requestMember
);
}
- match : 동일 계정 사용자 유무 / 동일 기기 사용자 유무 / 로그인 시도 계정으로 각 로그인 상황 판단
- syncDevice : 각 로그인 상황 별로 디바이스 토큰 후속 처리 후, 인증 대상 반환
2) 로그인 상황별 구현체 만들기
각 로그인 상황 별로 LoginContext 구현체를 만들어주었습니다.
예를 들어 기존 유저가 기존 로그인 기기로 로그인을 시도한 상황에 대해서는 ExistingUserForExistingDevice 라는 구현체를 만들어주었습니다.
@Component
public class ExistingUserForExistingDevice implements LoginContext {
@Override
public boolean match(
Optional<Member> sameDeviceMember,
Optional<Member> sameProviderIdMember,
Member requestMember
) {
return sameDeviceMember.isPresent() //기기 사용자 있음
&& sameProviderIdMember.isPresent() // 같은 계정 있음
&& requestMember.isSame(sameDeviceMember.get()); // 로그인 시도 계정 == 동일 기기 사용자 계정
}
@Override
public Member syncDevice(
Optional<Member> sameDeviceMember,
Optional<Member> sameProviderIdMember,
Member requestMember
) {
return sameProviderIdMember.get(); //기존 유저가 기존 기기로 로그인하므로 그대로 반환
}
}
또다른 예로 기존 유저가 타 유저의 기기로 로그인 하는 OtherUserForExistingDevice의 경우는 다음과 같습니다.
@Component
public class OtherUserForExistingDevice implements LoginContext {
@Override
public boolean match(
Optional<Member> sameDeviceMember,
Optional<Member> sameProviderIdMember,
Member requestMember
) {
return sameDeviceMember.isPresent() //동일 기기 사용자 있음
&& sameProviderIdMember.isPresent() // 동일 계정 있음
&& !requestMember.isSame(sameDeviceMember.get()); // 로그인 시도 계정 != 기기 사용자 계정
}
@Override
public Member syncDevice(
Optional<Member> sameDeviceMember,
Optional<Member> sameProviderIdMember,
Member requestMember
) {
sameDeviceMember.get().updateDeviceTokenNull(); // 기존 기기 사용자 기기 토큰을 null
sameProviderIdMember.get().updateDeviceToken(requestMember.getDeviceToken()); //로그인 시도 계정의 기기 토큰 update
return sameProviderIdMember.get(); //로그인 시도 계정 인증
}
}
3) Authorizer 객체를 통해 다형성을 활용한 인증 처리 로직 구현
이후, Authorizer라는 객체를 만들어 각 LoginContext를 List 형태로 빈으로 주입받게 해주었습니다. Spring Context는 List 형태의 의존성 주입에 대하여 해당 인터페이스를 구현한 구현체 Component들을 모두 추적하여 리스트 형태로 의존성을 주입해줍니다. 즉 우리가 정의한 각 로그인 상황과 그에 따른 디바이스 토큰 싱크 정책을 의미하는 LoginContext 구현체들이 자동으로 Authroizer의 필드로 주입되게 되는 것입니다.
@Component
@RequiredArgsConstructor
public class Authorizer {
private final List<LoginContext> authPolicies;
@Transactional
public Member authorize(
Optional<Member> sameDeviceMember,
Optional<Member> sameProviderIdMember,
Member requestMember
) {
return authPolicies.stream()
.filter(type -> type.match(sameDeviceMember, sameProviderIdMember, requestMember))
.findAny()
.orElseThrow(() -> new OdyUnauthorizedException("잘못된 인증 요청입니다."))
.syncDevice(sameDeviceMember, sameProviderIdMember, requestMember);
}
}
이후 Authorizer는 각 로그인 상황을 판단하고 상황에 따른 디바이스 토큰 싱크 전략을 맞추도록 해줍니다. 다형성을 이용해 각 로그인 상황에서의 인증 로직을 캡슐화함과 동시에 AuthService는 조금 더 본질적인 인증 로직에 집중할 수 있도록 하였습니다.
실제로 AuthService는 Authorizer를 통해 각 인증상황에 대한 분기 없이 디바이스 토큰 싱크 맞추기와 같은 부수적인 인증 로직을 위임하고 토큰을 발급한다는 본질적인 코드에 더 집중할 수 있게 되었습니다.
public class AuthService {
private final JwtTokenProvider jwtTokenProvider;
private final MemberService memberService;
private final Authorizer authorizer;
@Transactional
public AuthResponse issueTokens(AuthRequest authRequest) {
Member requestMember = authRequest.toMember();
Member authorizedMember = findAuthroizedMember(requestMember);
Member savedAuthorizedMember = memberService.save(authorizedMember) ;
return issueNewTokens(savedAuthorizedMember.getId());
}
private Member findAuthroizedMember(Member requestMember) {
Optional<Member> sameDeviceMember = memberService.findByDeviceToken(requestMember.getDeviceToken());
Optional<Member> samePidMember = memberService.findByAuthProvider(requestMember.getAuthProvider());
return authorizer.authorize(sameDeviceMember, samePidMember, requestMember);
}
}
그리고 그 결과 MemberService의 save 로직은 다음과 같이 정리되었습니다.
@Transactional
public Member save(Member member) {
return memberRepository.save(member);
}
이제 save 그 이상의 어떠한 인증 로직도 MemberService에 존재하지 않습니다. 동료 개발자의 입장에서는 예상치 못한 save method의 사이드 이펙트에 대해 이제는 걱정하지 않아도 괜찮아졌습니다.
5-3) 리팩터링을 통해 얻은 점과 우려되는 점
얻은 점
- 관리의 응집성 : 각 상황별 관리가 가능합니다. 예를 들어 이제부터 1인당 2개 기기 접속 가능 이라고 한다면 해당되는 AuthorizationType에 들어가 기존 기기 사용자의 디바이스 토큰을 널처리하는 로직만 빼면 됩니다.
- 확장성 : 새로 추가되는 인증 상황은 스펙에 맞추어 인터페이스를 구현해주기만 하면됩니다. 반대로 막고자 하는 인증 상황을 뺄 수도 있습니다.
- 예를 들어 현재는 타 유저의 기기로 이전 시 알람관련 후속 처리를 안해놓아서 이전 사용자의 알람을 그대로 받아요. 이 상황의 경우 authorize 메서드에서 에러를 터트리면 됩니다.
- 예를 들어 현재는 타 유저의 기기로 이전 시 알람관련 후속 처리를 안해놓아서 이전 사용자의 알람을 그대로 받아요. 이 상황의 경우 authorize 메서드에서 에러를 터트리면 됩니다.
우려되는 점
- 각 인증 상황 처리 로직이 파편화되어 있다고 느끼기도 하였습니다. 객체들을 각 상황별로 처리하다 보니 상황별 인증 후속 처리를 한눈에 파악하기 힘들다고 느낄 수 있을 것 같았습니다.
- 확장성이 없다고 느낄 수 있다 : 5가지 상황 이상으로 인증 정책의 변화가 없다고 생각할 수 있습니다.
- Optional로 인해 로직의 가정이 많다. : syncDevice를 통해 디바이스 토큰의 싱크를 맞추는 과정에서 Optional 객체의 경우 get()쓰는 등 가정에 의한 로직이 많아졌습니다.
6. 느낀 점
OOP는 강하다
: 다형성을 활용하면서 각 인증상황이 응집도 있게 뭉쳐지는 느낌이 좋았습니다.
: 더불어 memberService와 AuthService에서 디바이스 토큰 싱크 맞추기와 같은 인증 정책을 분리할 수 있어 각 서비스가 조금 더 본질적으로 집중해야 할 부분들에 집중할 수 있도록 코드를 구성할 수 있게 되었습니다.
: 코드의 가독성과 트래킹이 쉬워졌다고 판단되며 객체지향적인 코드가 왜 유지보수에 유리한지에 대해 느낄 수 있는 계기가 되었습니다.
인증 시나리오를 상상하며 구축하는 힘
auth 패키지의 구현을 맡으면서 일반적으로 구현하게 되는 액세스/리프레시 토큰을 통한 인증 과정에만 머물지 않고 조금 더 다양한 시나리오를 고민하여 인증 전략을 세웠다는 점이 뿌듯했습니다. 더 많은 상상력을 기반으로 유저 경험에 대응하며 상세에 집착할 수록 더 나은 유저 경험을 제공할 수 있다는 점을 느꼈습니다.
프로젝트 오디에서 인증 구현부터 리팩터링까지 맡은 경험은 여기까지입니다.
오개념이나 질문은 언제나 댓글을 통해 이야기해주세요!
그럼 오늘도 행복하세요 :)
'프로젝트 > 오디' 카테고리의 다른 글
FCM 알림 비동기 + 이벤트 리스닝으로 리팩터링 하기 - 1편 (0) | 2024.12.03 |
---|---|
인수 테스트로 사용자 유즈 케이스 파악하기 (0) | 2024.11.17 |
💭프로젝트 '오디' 핵심 기능 리팩터링 상상일지 : 웹 소켓 전환 시나리오 (5) | 2024.11.03 |
프로젝트 `오디` 핵심 기능 구현 일지 : 실시간 친구 도착 예정정보 공유 기능 (1) | 2024.11.01 |
🌐NAT gateway로 private 서브넷에서 외부 API 호출하기 (0) | 2024.10.29 |