MERN 스택으로 제작한 TODO 앱을 완성하고 배포를 했는데 모바일 환경에서 로그인이 되지 않는 문제를 발견했습니다.
확인해보니 데스크탑에서는 정상적으로 로그인되었지만, 모바일에서는 리프레시 토큰이 쿠키에 저장되지 않는 것이 원인이었습니다.
검색해보니 iOS 14부터는 개인정보 보호 정책으로 인해 Third-Party Cookie 사용이 제한되어, sameSite: None 설정을 유지하면 쿠키가 저장되지 않는다는 것을 확인했습니다.
안드로이드 환경에서는 테스트해보진 않았지만, 같은 문제가 발생할 가능성이 있어 수정이 필요하다고 판단했습니다.
해결책을 찾아봤지만, sameSite를 Lax 또는 Strict로 변경하는 것 외에는 방법이 없는 거 같습니다.
하지만 도메인이 없는 상태에서는 sameSite 옵션을 변경하는 것도 어렵다고 판단했습니다.
그래서 데스크탑에서는 기존 방식을 유지하되, 모바일 기기에서는 쿠키를 사용하지 않고, Session Storage에 리프레시 토큰을 저장하는 방식으로 변경했습니다.
1. 서버에서 모바일 기기 감지하기
Express에서 요청 헤더의 User-Agent 값을 기반으로 모바일 기기를 감지하는 함수를 추가했습니다.
function isMobile(req) {
const userAgent = req.headers?.['user-agent'] || "";
return /Mobi|Android|iPhone|iPad|iPod/i.test(userAgent);
}
2. 로그인 API 수정
모바일 기기일 경우 리프레시 토큰을 응답 데이터에 포함하도록 변경하고, 데스크탑에서는 기존 방식 그대로 쿠키를 저장하도록 변경했습니다.
googleLogin 함수도 동일한 코드를 추가했습니다.
export async function login(req, res, next) {
try {
const { email, password } = req.body;
if (!email) return next(createError(400, '이메일을 입력해주세요.'));
if (!password) return next(createError(400, '비밀번호를 입력해주세요.'));
await connectToDB();
const user = await User.findOne({ email }).select('+password');
if (!user) return next(createError(400, '이메일 또는 비밀번호가 올바르지 않습니다.'));
const isPasswordCorrect = await bcrypt.compare(password, user.password);
if (!isPasswordCorrect) return next(createError(400, '이메일 또는 비밀번호가 올바르지 않습니다.'));
const accessToken = generateAccessToken(user._id);
const refreshToken = generateRefreshToken(user._id);
if (!isMobile(req)) {
res.cookie('refresh_token', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'None',
maxAge: 24 * 60 * 60 * 1000,
});
}
return res.json({
_id: user.id,
email: user.email,
access_token: accessToken,
...(isMobile(req) && { refresh_token: refreshToken }),
});
} catch (error) {
next(error);
}
}
3. 리프레시 토큰을 이용한 액세스 토큰 재발급
모바일에서는 Authorization 헤더에 리프레시 토큰을 담고, 데스크탑에서는 쿠키를 이용하도록 변경했습니다.
export async function refreshAccessToken(req, res, next) {
try {
await connectToDB();
let refreshToken;
const authHeader = req.headers.authorization || "";
if (authHeader.startsWith('Bearer')) {
refreshToken = authHeader.split(' ')[1];
} else {
refreshToken = req.cookies.refresh_token;
}
if (!refreshToken) return next(createError(401, 'Refresh Token이 없습니다. 다시 로그인해주세요.'));
jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET, async (err, decoded) => {
if (err) return next(createError(403, '유효하지 않은 Refresh Token입니다. 다시 로그인해주세요.'));
const user = await User.findById(decoded.id);
if (!user) return next(createError(404, '해당 사용자를 찾을 수 없습니다. 다시 로그인해주세요.'));
const newAccessToken = generateAccessToken(user.id);
res.json({ access_token: newAccessToken });
});
} catch (error) {
next(error);
}
}
4. 로그아웃 기능 수정
모바일 기기에서는 sessionStorage에서 리프레시 토큰을 제거하고, 데스크탑에서는 clearCookie를 실행했습니다.
export async function logout(req, res, next) {
try {
if (!isMobile(req)) {
res.clearCookie('refresh_token', {
httpOnly: true,
secure: true,
sameSite: 'None',
});
}
return res.status(200).json({ message: '로그아웃 완료' });
} catch (error) {
next(error);
}
}
5. 클라이언트에서 모바일 기기 감지 함수 추가
export function isMobileDevice() {
return /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
}
6. 클라이언트 로그인 API 수정
모바일 환경에서는 리프레시 토큰을 sessionStorage에 저장하도록 변경했습니다.
googleLogin에도 동일한 코드를 추가했습니다.
export async function login(
previousState: RegisterState,
formData: FormData
): Promise<RegisterState> {
try {
const email = formData.get('email');
const password = formData.get('password');
const res = await fetch(`${API_BASE_URL}/api/user/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include', // 데스크탑 환경에서는 쿠키 사용
body: JSON.stringify({ email, password }),
});
const data = await res.json();
if (data?.error) {
return { ...previousState, error: data.error };
}
if (data.access_token) {
useAuthStore.getState().setAccessToken(data.access_token);
if (isMobileDevice()) {
sessionStorage.setItem('refresh_token', data.refresh_token);
}
}
return { error: null, success: data };
} catch (error) {
return { ...previousState, error: 'Something is wrong' };
}
}
7. 클라이언트에서 리프레시 토큰을 이용한 액세스 토큰 갱신
모바일 환경에서는 sessionStorage에 저장된 리프레시 토큰을 가져와 Authorization 헤더에 추가하고, 데스크탑 환경에서는 기존 방식대로 쿠키를 사용하여 액세스 토큰을 갱신하도록 변경했습니다.
import { API_BASE_URL } from '@/config';
import useAuthStore from '../store/authStore';
import { isMobileDevice } from './isMobileDevice';
export async function refreshAccessToken() {
try {
let headers: Record<string, string> = { "Content-Type": "application/json" };
let options: RequestInit = { method: "POST", headers };
if (isMobileDevice()) {
const refreshToken = sessionStorage.getItem("refresh_token");
if (!refreshToken) {
useAuthStore.getState().setAccessToken(null);
return null;
}
headers["Authorization"] = `Bearer ${refreshToken}`;
} else {
options.credentials = "include"; // 쿠키 사용
options.body = JSON.stringify({});
}
const res = await fetch(`${API_BASE_URL}/api/user/refresh-token`, options);
if (!res.ok) {
useAuthStore.getState().setAccessToken(null);
if (isMobileDevice()) {
sessionStorage.removeItem("refresh_token");
}
return null;
}
const data = await res.json();
useAuthStore.getState().setAccessToken(data.access_token);
return data.access_token;
} catch (error) {
console.error('토큰 갱신 중 오류 발생:', error);
return null;
}
}
'Node.js > Todo' 카테고리의 다른 글
Google 로그인 구현 (OAuth 2.0 + JWT + Zustand) (0) | 2025.02.27 |
---|---|
JWT 인증 방식 개선: 쿠키 기반에서 토큰 기반으로 전환 (0) | 2025.02.24 |
회원가입 및 로그인 기능 구현 (0) | 2025.02.20 |
Todo CRUD API 구현하기 (0) | 2025.02.19 |
쿠키 기반 JWT 인증 및 로그인/로그아웃 기능 (0) | 2025.02.18 |