배경
현대적인 웹 서비스에서 사용자의 첫인상은 로그인과 회원가입 과정에서 결정되는 경우가 많습니다. 다양한 사용자를 포용하기 위해 네이버, 구글과 같은 소셜 로그인과 전통적인 이메일 로그인을 모두 제공하는 것은 이제 선택이 아닌 필수가 되었습니다.
저희 프론트엔드 팀은 이 세 가지 로그인/회원가입 경로를 어떻게 하나의 일관된 사용자 경험(UX)으로 통합했는지, 그 과정에서 마주한 기술적 결정과 구현 방식을 정리합니다.
1. 통합 플로우의 목표: 분기(Fork)와 병합(Merge)
저희가 구상한 통합 플로우의 핵심은 간단합니다.
- 분기 (Fork): 사용자에게 로그인 방식을 선택할 명확한 UI를 제공합니다. (네이버 / 구글 / 이메일)
- 각자의 인증 처리: 각 방식에 맞는 인증 절차를 진행합니다.
- 병합 (Merge): 인증 성공 후에는 어떤 방식으로 로그인했는지와 관계없이 공통된 후처리 로직을 수행하여 사용자 정보를 갱신하고 최종 목적지로 안내합니다.
이 '병합' 단계가 바로 통합 플로우의 핵심이며, 코드의 중복을 막고 유지보수성을 높이는 열쇠입니다.
┌─────────────────────────────────────────────────────────────────────────────┐
│ 통합 로그인 및 회원가입 전체 플로우 │
└─────────────────────────────────────────────────────────────────────────────┘
1. [프론트엔드] 사용자가 로그인 방식 선택
└─> SocialLoginView
│
├─ (소셜 로그인: 구글/네이버) → SocialProviderButton 클릭
│ └─> 2A. [외부 브라우저] 소셜 OAuth 인증 진행
│
├─ (이메일 로그인) → /login 라우팅
│ └─> 2B. [프론트엔드] EmailLoginView
│
└─ (이메일 회원가입) → /signup 라우팅
└─> 2C. [프론트엔드] SignupView (3단계)
2A. [외부 브라우저] 소셜 OAuth 인증 진행
└─> SocialProviderButton 컴포넌트
└─> SocialAuthService.Provider.init() 호출
└─> 각 소셜 플랫폼의 OAuth 인증 페이지
└─> 사용자 동의
└─> 콜백 URL로 리다이렉트 (access_token 포함)
└─> 3A. [프론트엔드] 소셜 로그인 콜백 처리
2B. [프론트엔드] 이메일 로그인
└─> EmailLoginView
└─> 이메일/비밀번호 입력 → submitForm()
└─> authStore.login(email, password, rememberMe)
└─> POST /api/v1/auth/login
└─> 4. [백엔드] 인증 처리 및 세션 생성
2C. [프론트엔드] 이메일 회원가입
└─> SignupView (3단계)
├─ Step 1: EmailVerificationStep
│ └─> 이메일 인증, 비밀번호, 닉네임, 약관 동의
├─ Step 2: AgeVerificationStep
│ └─> 생년월일, 미성년자 본인인증
└─ Step 3: CompletionStep
└─> registerStore.submitSignup() 또는 submitSocialSignup()
└─> POST /api/v1/auth/signup
└─> 4. [백엔드] 회원가입 처리 및 자동 로그인
3A. [프론트엔드] 소셜 로그인 콜백 처리
└─> SocialCallbackView (provider별 분기)
└─> access_token 추출
└─> socialAuth.authenticate(provider, token)
└─> authStore.loginWithSocial(provider, token)
└─> POST /api/v1/auth/social/login
├─ [성공] → 4. 로그인 처리
└─ [실패] → handleSocialAuthFailure()
└─> fetchSocialAccountInfo() 호출
├─ [신규] → 회원가입 페이지로 이동 (정보 프리필)
└─ [오류] → 에러 안내 (이메일 중복, 미인증 등)
4. [백엔드] 인증 처리 및 세션 생성
├─ 이메일: POST /api/v1/auth/login
├─ 소셜: POST /api/v1/auth/social/login
└─ 회원가입: POST /api/v1/auth/signup 또는 /api/v1/auth/social/signup
│
└─> 인증 성공 시, 쿠키에 세션 정보 저장
└─> 5. [프론트엔드] 공통 후처리
5. [프론트엔드] 공통 후처리 (로그인 성공 후)
└─> composables/usePostAuth.js의 handlePostAuth() 함수 호출
│
├─ authStore.setUserData(userData)
│ └─> Pinia 스토어 및 sessionStorage에 사용자 정보 저장
│
├─ OAuth 요청 확인 (clientId, responseType 존재?)
│ ├─ [있음] → hasOAuthConsent() 확인
│ │ ├─ true → approveOAuthRequest() 즉시 실행
│ │ └─ false → /consent로 이동 (데이터 수집 동의)
│ │
│ └─ [없음] → 서비스 리다이렉트 확인
│ ├─ [있음] → redirectToService()
│ │ └─> /api/v1/auth/complete?serviceId=...&callbackUrl=...
│ │ └─> 최종 서비스로 리다이렉트
│ │
│ └─ [없음] → /dashboard (메인 페이지)로 이동
6. [프론트엔드] OAuth 승인 처리 (필요 시)
└─> DataConsentView
└─> 데이터 수집 동의 완료
└─> approveOAuthRequest()
└─> POST /api/v1/oauth/approve
└─> redirectUri?code=... 또는 redirectUri#accessToken=...
2. 팀 협업 과정
통합 로그인 플로우는 프론트엔드 단독으로 완결되는 작업이 아니었습니다. 백엔드, 기획, QA 각 직군과의 협업 없이는 안정적인 구현이 불가능했습니다.
설계 회의
프로젝트 초기, 백엔드 개발자 및 기획자와 총 3~4차례 설계 회의를 진행했습니다. 첫 번째 의제는 프론트엔드와 백엔드 간의 책임 경계 설정이었습니다. "인증 성공 이후의 분기 판단은 누가 하는가", "세션 생성 시점은 어디인가" 같은 질문을 화이트보드에 플로우로 그려가며 합의했습니다. 위에 정리한 전체 플로우 다이어그램의 초안도 이 과정에서 도출되었습니다.
API 스펙 협의
백엔드 팀과의 API 스펙 조율도 핵심이었습니다. 소셜 로그인 실패 시 응답 코드 구분(미가입 vs 이메일 중복 vs 미인증 계정), usePostAuth가 요구하는 사용자 데이터 필드, OAuth 승인 API(/api/v1/oauth/approve)의 요청/응답 형식 등을 문서로 정리했습니다. 프론트엔드 측에서 에러 코드 체계를 먼저 제안하고, 백엔드에서 구현 가능 여부를 검토하는 방식으로 2회의 스펙 리뷰를 거쳤습니다.
코드 리뷰
usePostAuth는 모든 로그인 방식의 후처리가 수렴하는 지점이었기에, PR 리뷰에서 가장 집중적으로 검토된 모듈이었습니다. OAuth 파라미터 우선순위 로직, 예외 처리 분기, sessionStorage 상태 유지 범위에 대해 팀원들의 피드백을 반영했고, 쿼리 파라미터가 부분적으로만 존재하는 경우 같은 엣지 케이스를 리뷰 단계에서 사전에 식별할 수 있었습니다.
3. 구현 들여다보기
단계 1: 사용자의 첫 관문, 로그인 화면

사용자는 가장 먼저 모든 로그인 옵션이 모여있는 화면을 마주하게 됩니다.
// views/SocialLoginView.vue
<template>
<!-- ... (레이아웃 마크업 생략) -->
<SocialProviderButton provider="naver" />
<SocialProviderButton provider="google" />
<RouterLink :to="{ path: '/login', query: { ...route.query } }">
이메일 계정으로 시작하기
</RouterLink>
</template>
<script setup>
// ... (imports 생략)
const route = useRoute();
const registerStore = useRegisterStore();
onMounted(() => {
// 쿼리 파라미터(callbackUrl, serviceId, clientId 등)를 스토어에 저장
registerStore.saveReturnUrl(callbackUrl || redirectUri || "");
registerStore.saveServiceId(serviceId ?? "");
// ...
});
</script>핵심 구현 사항:
-
쿼리 파라미터 보존- 외부 서비스에서 로그인 요청 시 전달된 쿼리 파라미터를 스토어에 저장 - OAuth 요청(
client_id,response_type,scope)이나 서비스 요청(serviceId,callbackUrl) 정보를 유지 - 외부에서 유입되는 OAuth 파라미터는 snake_case로 들어오기 때문에, 내부에서는 camelCase로 정규화하여 전 구간에서 동일한 키로 처리 - 로그인/회원가입 완료 후 원래 서비스로 복귀하기 위한 정보 - 라우팅 시 쿼리 전달
:to="{ path: '/login', query: { ...route.query } }"로 현재 쿼리를 그대로 전달- 이메일 로그인 페이지로 이동할 때도 OAuth/서비스 정보 유지
- 소셜 로그인 버튼-
SocialProviderButton컴포넌트는 provider prop으로 제공자 구분 - 각 버튼 내부에서 소셜 제공자별 인증 방식 처리
여기서 중요한 점은, 어떤 버튼을 클릭하든 결국 다음 단계인 '인증 처리'를 거쳐 마지막 '공통 후처리' 로직으로 모이게 된다는 것입니다.
단계 2A: 소셜 로그인 (네이버 / 구글)
소셜 로그인은 OAuth 2.0 프로토콜을 따릅니다. 프론트엔드에서의 흐름은 다음과 같습니다.
Google 소셜 로그인 버튼
// components/auth/SocialProviderButton.vue (Google 관련 로직)
<script setup>
const socialAuth = useSocialAuth();
const onAuthSuccess = async (tokenResponse) => {
await socialAuth.authenticate(props.provider, tokenResponse.access_token);
};
const onClickHandler = () => {
if (isWebView) {
// WebView 환경: 앱 브리지를 통해 소셜 로그인 시작
window?.AppBridge?.startSocialLogin(url);
return;
}
// 일반 웹 환경: 구글 SDK 동적 로드 → OAuth 팝업 표시
loadGoogleScript(() => {
const tokenClient = SocialAuthService.Google.init(
import.meta.env.VITE_GOOGLE_CLIENT_ID,
onAuthSuccess,
);
tokenClient.requestAccessToken();
});
};
</script>핵심 구현 사항:
- 환경 자동 감지: WebView에서는 앱 브리지, 일반 웹에서는 SDK 동적 로드 → 팝업 인증
- 동적 스크립트 로드: 클릭 시점에 SDK 로드 후 즉시
requestAccessToken()호출 - 토큰 처리: Access Token 획득 후
socialAuth.authenticate(provider, token)호출
Naver 소셜 로그인 버튼
// components/auth/SocialProviderButton.vue (Naver 관련 로직)
<script setup>
function handleNaverLogin() {
// 현재 쿼리 파라미터를 콜백 URL에 포함하여 Naver OAuth 초기화
const naverAuth = SocialAuthService.Naver.init(
"LOGIN",
buildQueryString(route.query),
);
// 전체 페이지 리다이렉트 (팝업 아닌 플랫폼 권장 방식)
window.location.href = naverAuth.generateAuthorizeUrl();
}
</script>구글과 달리 네이버는 팝업이 아닌 전체 페이지 리다이렉트 방식으로, 현재 쿼리 파라미터를 콜백 URL에 포함하여 OAuth 완료 후 동일한 파라미터로 복귀합니다.
소셜 인증 서비스 초기화
// services/socialAuth.js
export const SocialAuthService = {};
SocialAuthService.Google = {};
SocialAuthService.Google.init = (clientId, callback, errorCallback) => {
// Google OAuth 2.0 Token Client 초기화 (email, profile 스코프)
return google.accounts.oauth2.initTokenClient({ client_id: clientId, ... });
};
SocialAuthService.Naver = {};
SocialAuthService.Naver.init = (mode, queryParams) => {
// Naver LoginWithNaverId 초기화 (콜백 URL에 mode, 쿼리 파라미터 포함)
const naverAuth = new naver.LoginWithNaverId({ clientId: ..., callbackUrl: ..., isPopup: false });
naverAuth.init();
return naverAuth;
};단계 2B: 이메일 로그인
이메일 로그인은 더 직접적입니다. 사용자가 입력한 이메일과 비밀번호를 백엔드 API로 전송합니다.
// views/EmailLoginView.vue
<script setup>
const authStore = useAuthStore();
const postAuth = usePostAuth();
async function onSubmit() {
// 1. 로그인 요청
await authStore.login(email, password, rememberMe);
// 2. 이메일 인증 완료 여부에 따라 분기
if (authStore.userData.isEmailConfirmed) {
await postAuth.handlePostAuth(); // 공통 후처리
} else {
await router.push({ path: "/verify-email", query: route.query });
}
}
</script>핵심 구현 사항:
- 폼 유효성 검증:
useFormValidation컴포저블로 이메일 형식, 비밀번호 길이 등 검증 - 로그인 상태 유지:
rememberMe파라미터를 백엔드에 전달하여 장기 쿠키 생성 - 이메일 미인증 처리:
isEmailConfirmed가false이면/verify-email로 리다이렉트,true이면postAuth.handlePostAuth()호출
단계 2C: 이메일 회원가입 (3단계)
회원가입은 복잡한 요구사항을 만족하기 위해 3단계로 나뉩니다.
// views/SignupView.vue
<template>
<EmailVerificationStep v-if="activeStep === 1" />
<AgeVerificationStep v-else-if="activeStep === 2" />
<CompletionStep v-else-if="activeStep === 3" />
</template>
<script setup>
const registerStore = useRegisterStore();
const { activeStep } = storeToRefs(registerStore);
// 본인인증 후 복귀 시 Step 2로 자동 이동
// 가입 완료(Step 3) 후 페이지 이탈 시 스토어 초기화
</script>- 이메일 입력 및 중복 확인 (
checkEmailAvailability()) - 이메일 인증 코드 발송 (
sendVerificationEmail()) - 인증 코드 검증 (
verifyCode(), 타이머 기능) - 비밀번호 입력 (소셜 가입 시 제외)
- 닉네임 입력 및 중복 확인 (
checkNicknameAvailability()) - 약관 동의 (필수 항목)
- 생년월일 선택 (Dropdown)
- 미성년자 여부 확인
- 미성년자: 법정대리인 동의 + 본인인증
- 성인: 바로 가입
submitSignup()또는submitSocialSignup()호출
- 가입 완료 안내
registerStore.navigateToDestination()자동 실행- OAuth 또는 서비스로 리다이렉트
단계 3: 소셜 로그인 콜백 및 비즈니스 로직
소셜 로그인 완료 후 콜백 페이지에서 토큰을 추출하고 백엔드 인증을 시도합니다.
소셜 콜백 (Google / Naver 공통 구조)
Google과 Naver의 콜백 뷰는 토큰 추출 방식만 다를 뿐, 이후 흐름은 동일합니다.
// views/auth/SocialCallbackView.vue (Google/Naver 공통 구조)
<script setup>
onMounted(async () => {
// 1. 각 provider별 방식으로 accessToken 추출
// - Google: route.query.token
// - Naver: SocialAuthService.Naver.initCallback() → accessToken
const accessToken = extractToken(provider);
// 2. 서비스 리다이렉트 정보가 있으면 스토어에 저장
if (callbackUrl && serviceId) {
registerStore.saveReturnUrl(callbackUrl);
registerStore.saveServiceId(serviceId);
}
// 3. 모드에 따라 로그인 또는 계정 연동
if (isLoginMode) {
await socialAuth.authenticate(provider, accessToken);
} else {
await socialAuth.linkAccount(provider, accessToken, false);
}
});
</script>소셜 로그인 비즈니스 로직
// composables/useSocialAuth.js
export function useSocialAuth() {
// ... (setup 생략)
async function authenticate(provider, token) {
try {
// 1. 소셜 로그인 시도
await authStore.loginWithSocial(provider, token);
// 2. 성공 시 공통 후처리
await postAuth.handlePostAuth();
} catch (err) {
// 3. 실패(미가입) 시 회원가입 플로우 진입
await handleAuthFailure(provider, token);
}
}
async function handleAuthFailure(provider, token) {
// 소셜 계정 정보(이메일, 닉네임 등) 미리 조회
const { data } = await authApi.fetchSocialAccountInfo(provider, token);
// ... (중복 가입 등 예외 처리 로직 생략)
// 스토어에 정보 프리필(Prefill) 후 회원가입 페이지 이동
registerStore.saveProvider(provider);
registerStore.saveToken(token);
registerStore.saveEmail(data.email);
router.push("/signup");
}
return { authenticate /* ... */ };
}단계 4: 하나로 합쳐지는 길 (Merge) - usePostAuth
usePostAuth 컴포저블의 역할
인증 방식에 상관없이, 로그인 성공 후에는 동일한 후처리 로직이 실행됩니다.
// composables/usePostAuth.js
export function usePostAuth() {
// ... (쿼리 파라미터 추출 로직 생략)
// 메인 실행 함수: 상황에 따라 목적지 결정
async function handlePostAuth() {
if (isOAuthRequest()) {
return await handleOAuthCallback();
} else if (isServiceRedirect()) {
return await redirectToService();
} else {
return router.replace("/dashboard");
}
}
// OAuth 인증 요청 처리
async function handleOAuthCallback() {
// 이미 동의한 클라이언트라면 즉시 승인 및 리다이렉트
if (authStore.hasOAuthConsent(clientId)) {
return approveOAuthRequest();
}
// 동의 이력이 없다면 동의 페이지로 이동
return await router.push({ path: "/consent", query: route.query });
}
// ... (approveOAuthRequest, redirectToService 등 세부 구현 생략)
return { handlePostAuth, approveOAuthRequest };
}핵심 구현 사항:
- 쿼리 파라미터 기반 분기
- OAuth:
response_type,client_id,redirect_uri,scope필수 - 서비스:
serviceId,callbackUrl필수 - 우선순위: OAuth > 서비스 > 메인 페이지
-
OAuth 2.0 플로우-
hasOAuthConsent(): 승인 이력 확인 - 승인 이력 있음: 즉시 인증 코드 발급 - 승인 이력 없음:/consent페이지로 이동 -
서비스 리다이렉트- 서비스 정보 조회 (캐시 활용) - 접근 권한 확인 - 백엔드 엔드포인트로 리다이렉트
이제 각 로그인 컴포넌트에서는 usePostAuth를 호출하기만 하면 됩니다.
// 이메일 로그인
const postAuth = usePostAuth();
await authStore.login(email, password, rememberMe);
await postAuth.handlePostAuth();
// 소셜 로그인 (useSocialAuth 내부)
async function authenticate(provider, token) {
await authStore.loginWithSocial(provider, token);
await postAuth.handlePostAuth();
}
// 회원가입 완료
registerStore.navigateToDestination();
// 내부적으로 postAuth.handlePostAuth()와 동일한 로직4. 상태 관리: Pinia Store의 역할
통합 로그인 플로우에서 Pinia Store는 세 가지 핵심 역할을 담당합니다.
Auth Store (사용자 인증 상태)
// stores/auth.js
export const useAuthStore = defineStore("auth", () => {
const isAuthenticated = ref(false);
const userData = ref({
email: "", nickname: "", linkedProviders: [],
allowedServices: [], oauthConsents: [], isEmailConfirmed: false,
});
// 이메일 / 소셜 로그인 모두 동일한 setUserData()로 상태 저장
async function login(email, password, rememberMe) { ... }
async function loginWithSocial(provider, token) { ... }
function setUserData(data) {
isAuthenticated.value = true;
userData.value = data;
// sessionStorage에도 동기화
}
function hasOAuthConsent(clientId) {
return userData.value.oauthConsents?.includes(clientId);
}
return { isAuthenticated, userData, login, loginWithSocial, setUserData, hasOAuthConsent };
});Signup Store (회원가입 진행 상태)
// stores/register.js
export const useRegisterStore = defineStore("register", () => {
// 회원가입 입력 정보 + OAuth/서비스 리다이렉트 파라미터
const email = ref("");
const providerToken = ref("");
const callbackUrl = ref("");
// ... (기타 상태)
async function submitSignup() { ... }
// 가입 완료 후 저장된 파라미터를 복원하여 원래 목적지로 이동
function navigateToDestination() {
// callbackUrl + serviceId → 서비스 리다이렉트
// clientId → OAuth 파라미터 복원
// 없으면 → /dashboard
router.push({ path: "/dashboard", query });
}
return { ... };
}, {
// sessionStorage에 저장하여 새로고침 시에도 회원가입 단계 유지
persist: { storage: sessionStorage, paths: ["email", "activeStep", ...] },
});핵심 역할:
- 쿼리 파라미터 보존: OAuth/서비스 리다이렉트 정보 저장
- 회원가입 진행 상태: 3단계 플로우의 입력 데이터 저장
- 소셜 정보 프리필: 이메일/닉네임/생년월일 자동 입력
- sessionStorage 상태 유지: 페이지 새로고침 시에도 데이터 보존
5. 이렇게 통합하여 얻은 것들
usePostAuth 하나로 이메일/네이버/구글 로그인과 회원가입까지 로그인 이후의 후처리 로직을 공통화해 코드 재사용성이 크게 좋아졌습니다.
덕분에 새로운 소셜 제공자를 추가할 때도 "인증 처리"만 붙이면 되고, 이후 단계는 그대로 재사용할 수 있습니다.
리다이렉트나 OAuth 분기 규칙이 바뀌어도 한 파일만 수정하면 전 로그인 방식에 즉시 반영되어 유지보수성이 높아졌습니다. 사용자는 어떤 방식으로 로그인하든 동일한 흐름으로 최종 목적지로 이동해 UX가 일관됩니다.
또한 OAuth 2.0 표준 기반 구조를 유지해 외부 서비스 연동과 SSO 확장에도 유리해졌습니다.
마무리
처음에는 로그인 버튼만 세 개 붙이면 금방 끝날 일이라고 생각했습니다. 하지만 막상 구현을 시작해 보니 OAuth 콜백 처리, 신규/기존 회원 분기, 외부 서비스 리다이렉트, 예외 상황과 실패 처리까지 고려해야 할 케이스가 예상보다 훨씬 많았습니다.
그래서 전체 흐름을 다이어그램으로 먼저 정리해 "어디서 갈라지고 어디서 다시 합쳐지는지"를 명확히 했고, 그 덕분에 로그인 성공 이후를 책임지는 공통 후처리 레이어(usePostAuth)를 안정적으로 설계·구현할 수 있었습니다. 결과적으로 로그인 방식이 늘어나도 흐름은 흔들리지 않고, 디버깅이나 유지보수에서도 원인을 빠르게 추적할 수 있어 작업 효율이 확실히 좋아졌습니다.
Related Posts

이벤트 페이지 제작 공수를 줄이기 위한 드래그&드롭 빌더 개발기
이벤트 페이지를 만들 때마다 개발자가 직접 이미지를 S3에 올리고 HTML을 작성해야 했던 반복 작업을 없애기 위해, 드래그&드롭 기반 이벤트 페이지 빌더를 개발한 과정을 다룹니다.

웹뷰 환경에서의 구글 소셜 로그인 구현
웹뷰의 팝업 차단과 리다이렉트 제약을 극복하고, JavaScript-Native Bridge와 Android Intent Deep Link를 활용하여 안정적인 소셜 로그인 플로우를 구현한 과정을 다룹니다.

모바일 환경에서의 본인 인증 플로우 개선하기
window.open과 window.opener의 한계를 극복하고, sessionStorage를 활용하여 모바일 환경에서도 안정적으로 동작하는 본인 인증 플로우를 구현한 과정을 다룹니다.