Spring Boot + FireBase Auth 기능을 활용한 간편 회원체계를 만들기
TL; DR
회원 체계는 초기 사이드 프로젝트에서 은근한 발목을 잡는다. 예상보다 회원 체계를 만드는 공이 크지만, 그렇다고 안 할 수도 없고 애매한 상황이다. 필요한 부분은 FireBase로 넘기고 간단한 부분들만 받아서 쓰면 되지 않을까?라는 스스로에 생각에 기반한 프로젝트
계기
사실 초창기 사이드 프로젝트에서 회원 체계를 현재 DB에서 관리하는 것은 은근히 귀찮은 작업이다. 예를 들면 패스워드 암복호화 같은 작업이나, 소셜 로그인을 붙이는 경우처럼 회원 인증 체계는 우리 서비스로 가져오는 순간부터 이상하게 잡무가 많아진다.
그리고 사이드 프로젝트의 성패를 가르는 지점은 결국 해당 도메인의 성격을 얼마나 잘 녹이면서도, 빠르게 프로젝트를 완료하는가에 달려있다 본다. 예상보다 사이드 프로젝트는 공수가 늘고, 투자할 수 있는 시간이 많아지면 많아질수록 탠션이 엄청 죽기때문에 절대적으로 실제 들어가는 시간이 중요하다고 생각한다.
즉, 이런 공수만 줄여도 비즈니스 도메인적인 로직에 상대적으로 집중할 수 있는데, 그런 공수를 줄여줄 수 있는 서드파티용 로그인 체계를 사용하는 것이 꽤나 도움이 될거라고 생각했다.
중요한 포인트 짚어보자
도식화해보면 다음과 같이 되는것이다.
- 엑세스토큰, 리프레쉬 토큰 생성, 세션 체크 → Firebase
- 이관 받은 세션의 체크, 비즈니스 로직 → Spring Boot
화면을 보면?
이런식으로 로그인 하려는 페이지가 뜨고 → 자체적으로 회원인지 여부 검증, 회원이면 로그인 / 회원이 아닌경우 회원가입 로직을 태워 해당하는 기능을 간단하게 빨리 구현할 수 있다.
물론, 현재는 FE보다는 BE 로직을 더 중시했으므로, 간단한 UI 툴로서 처리했다. 그래서 아래 로직은 단순하게 이 UI Framework에 올바르게 작성된 코드일뿐 FE가 새로 짜여하는 경우라면, 그런 형태로 구축하면된다.
코드로 알아보자
일단 해당 경우는 SPA 형태의 FE BE 분리 형태일때 좀 더 유리한 로직이였다. 일단 기본적으로 BE 로직은 세션 체크 이외에 값은 처리하지 않는 형태로 구현하는 식으로 처리했다.
IDP는 firebase가 되기때문에 해당하는 엑세스 로직은 다음처럼 처리한다. 간단한 테스트를 위한 코드이므로 html과 script로만 작성했다.
React면 React스럽게, Vue면 Vue스럽게 작성하면된다.
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>Sample FirebaseUI App</title>
<script src="https://www.gstatic.com/firebasejs/10.0.0/firebase-app-compat.js"></script>
<script src="https://www.gstatic.com/firebasejs/10.0.0/firebase-auth-compat.js"></script>
<script>
//관련 키들은 여기서 관리하겠지만? 실제로 PRD 환경에서는 이렇게 사용하진 않을테니...
const firebaseConfig = {
apiKey: "-",
authDomain: "-",
projectId: "-",
storageBucket: "-",
messagingSenderId: "-",
appId: "-"
};
// Initialize Firebase
firebase.initializeApp(firebaseConfig); //순서는 앱 초기화를 먼저하고 아래 로직을 돌아가야한다.
</script>
<script src="https://www.gstatic.com/firebasejs/ui/6.1.0/firebase-ui-auth.js"></script>
<link type="text/css" rel="stylesheet" href="https://www.gstatic.com/firebasejs/ui/6.1.0/firebase-ui-auth.css"/>
<script type="text/javascript">
var uiConfig = {
callbacks: {
signInSuccessWithAuthResult: function(authResult, redirectUrl) {
var user = authResult.user;
var credential = authResult.credential;
var isNewUser = authResult.additionalUserInfo.isNewUser;
var providerId = authResult.additionalUserInfo.providerId;
var operationType = authResult.operationType;
// 실제 토큰은 user._delegate에 존재하니, 꺼내쓰면 된다.
const token = user._delegate.accessToken
document.cookie = `token=${token}; path=/; secure; SameSite=Strict`;
// 리다이렉트 URL로 방지했다. 이경우는 FE 이동하는 경우 사용하면 된다
return false;
}
},
// 그리고
signInSuccessUrl: 'http://localhost:8080/test',
signInOptions: [
{
provider: firebase.auth.EmailAuthProvider.PROVIDER_ID,
forceSameDevice: false,
},
firebase.auth.PhoneAuthProvider.PROVIDER_ID,
firebaseui.auth.AnonymousAuthProvider.PROVIDER_ID
],
// Terms of service url/callback.
tosUrl: '<your-tos-url>',
// Privacy policy url/callback.
privacyPolicyUrl: function() {
window.location.assign('<your-privacy-policy-url>');
}
}
var ui = new firebaseui.auth.AuthUI(firebase.auth());
ui.start('#firebaseui-auth-container', uiConfig);
</script>
</head>
<body>
<h1>Welcome to My Awesome App</h1>
<div id="firebaseui-auth-container"></div>
</body>
</html>
firebase-ui 꿀팁
일단 firebase 등록 먼저하고 토큰 생성에 대한 로직을 작성해야한다.
단, 이 로직은 React, Vue의 경우 다른 로직으로 구성해야 할 것이다.
그리고 해당하는 로그인이나 회원가입이 완료되면, 해당하는 사이트에서 토큰을 쿠키로 브라우저에 등록해서 처리하고 백엔드 로직은 오로지 그 토큰이 유효한지만 판단한다.
물론, Refresh Token을 재처리하는 기능을 백에서 처리해도 되며, 401오류 발생시 FE에서 처리하는 방법도 존재한다.
@Configuration
public class FirebaseConfig {
@Value("classpath:data/firebase.json")
private Resource firebase;
@Bean
public FirebaseApp firebaseApp() throws IOException {
InputStream credentials = new ByteArrayInputStream(firebase.getContentAsByteArray());
FirebaseOptions firebaseOptions = FirebaseOptions.builder()
.setCredentials(GoogleCredentials.fromStream(credentials))
.build();
return FirebaseApp.initializeApp(firebaseOptions);
}
@Bean
public FirebaseAuth firebaseAuth(FirebaseApp firebaseApp) {
return FirebaseAuth.getInstance(firebaseApp);
}
}
일단 Spring Boot Code의 Security 로직은 기존에 많이 봐왔던 JWT filter와 거의 비슷하다. 왜냐하면 Firebase 토큰이 JWT 토큰형식이기 때문이다. 그래서 기존 로직에 JWT이 올바른지에 대한 여부는 Firebase에서 판단한다. 그렇기에 JWT filter보다는 좀 더 간략화 되어있다.
@Component
@RequiredArgsConstructor
public class TokenAuthenticationFilter extends OncePerRequestFilter {
private static final String BEARER_PREFIX = "Bearer ";
private static final String USER_ID_CLAIM = "user_id";
private static final String AUTHORIZATION_HEADER = "Authorization";
private final FirebaseAuth firebaseAuth;
private final ObjectMapper objectMapper;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws IOException, ServletException {
String authorizationHeader = request.getHeader(AUTHORIZATION_HEADER);
if (authorizationHeader != null && authorizationHeader.startsWith(BEARER_PREFIX)) {
String token = authorizationHeader.replace(BEARER_PREFIX, "");
Optional<String> userId = extractUserIdFromToken(token);
if (userId.isPresent()) {
var authentication = new UsernamePasswordAuthenticationToken(userId.get(), null, null);
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
} else {
setAuthErrorDetails(response);
return;
}
}
filterChain.doFilter(request, response);
}
private Optional<String> extractUserIdFromToken(String token) {
try {
FirebaseToken firebaseToken = firebaseAuth.verifyIdToken(token, true);
String userId = String.valueOf(firebaseToken.getClaims().get(USER_ID_CLAIM));
return Optional.of(userId);
} catch (FirebaseAuthException exception) {
return Optional.empty();
} }
private void setAuthErrorDetails(HttpServletResponse response) throws IOException {
HttpStatus unauthorized = HttpStatus.UNAUTHORIZED;
response.setStatus(unauthorized.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(unauthorized,
"Authentication failure: Token missing, invalid or expired");
response.getWriter().write(objectMapper.writeValueAsString(problemDetail));
}
}
해당 로직은 다음처럼 표현할 수 있다.
- Bearer Token이 유효한가?
- FireBase에 해당 토큰이 유효한 토큰인지 검사
- 세션 처리
또한 credentials 처럼 구체적인 값은 유효성 검증이 아닌 그 토큰을 통한 회원 정보를 구축하는 방법도 존재한다. 그건 구현하는 개발자 입맛에 맞춰 처리하면 된다.
회원 정보를 직접 세션에 담아서 처리할 수도 있다. 개인정보중 필요한 값들은 별도로 세션에 저장할 수도 있기 때문이다.
장단점 및 총평
구축하면서 Spring Boot 로직이 엄청나게 간단해진다는 점은 꽤나 좋았다. 왜냐하면 JWT를 사용하되, 모든 로직이 Firebase로 빨려들어가면서 실제로 세션 체크하는 부분은 꼬작
FirebaseToken firebaseToken = firebaseAuth.verifyIdToken(token, true);
String userId = String.valueOf(firebaseToken.getClaims().get(USER_ID_CLAIM));
return Optional.of(userId);
이정도기 때문이다. 인증과 관련한 부분을 FIrebase가 모든 로직을 가져가면서 내가 해야할 일이 많이 준다는 점이 몹시 편했다.
그래서 회원로직을 단순화할 수 있다는 점은 장점이라고 느껴졌다. 이메일 SMS와 같은 기능도 모두 Firebase에서 제공하기 때문에 굳이 우리가 들고 있을 필요도 없으며, 회원관련한 정보도 Firebase가 들고 있으므로 사실 문제가 될만한 요소들이 적긴하다.
단, 상세 회원 정보들을 가져가는것은 어려워보이긴했다. 즉, 상세한 회원의 정보들을 가져가려면 Join Table을 해야한다는점? (나이, 지역, 성별등..) 은 좀 귀찮을 수 있다.
또한 가격도 월간 5만명까지만 무료인데, 사이드 프로젝트가 월간 5만을 넘으면 사실 사업적 성공을 했다고 봐도 되므로, 회원체계의 다변화를 택해보자. 꼭 FireBase가 아니여야하지 않는가에 대한 고민을 좀 해보고 판단해보는 게 프로젝트 성장과 꽤나 밀접한 고민이 될거라고 본다.
총평을 내보자면, 개인적으로 2달~3달안에 프로젝트 완수하고 매주 6시간 정도의 분량의 공수를 들여야하는 상황이라면 난 이 방법이 맞다고 생각한다. 너무 편리하고 FE와의 공수도 대부분 FE & Client로 인증 로직이 들어가 BE 공수는 세션 확인 절차만 거치면 되는 점 역시 좋았다.
물론, 확장성 및 커스텀화라는 측면에서는 부족한 점이 Firebase에는 존재했다. 한국에서 사용할 소셜 로그인을 포함하기 위해서는 좀 더 다양한 옵션이 존재해야하지만, 그렇지 않다. 이것은 비롯 Firebase이기 때문이고, 이러한 인증 대행을 하는 SaaS서비스는 꽤 많아졌다.
즉, 그런 해당 서비스를 사용하여 가격 대비 실익을 따져서 개발하면 된다고 생각한다. 그렇기에 나는 사이드 프로젝트를 개발하게된다면 무조건 사용할거 같다.
'Spring' 카테고리의 다른 글
Custom Deserializer를 Json Module방식으로 등록해보자 (0) | 2024.11.24 |
---|---|
에러에 상태에 따라서 로깅 레벨을 조절해보자 (0) | 2024.11.10 |
@AuthenticationPrincipal과 getAuthentication()에서 가져온 principal의 다른점은 있을까? (1) | 2024.10.27 |
Transactional의 Self Injection이 올바른가 (0) | 2024.06.22 |
@JsonInclude란? (0) | 2024.02.04 |