세션 관리 UI
웹 서비스에 로그인하면 서버는 세션을 생성한다. 세션은 "이 사용자가 현재 로그인 상태"라는 것을 증명하는 단위다. 한 사용자가 여러 기기에서 로그인하면 기기마다 세션이 생긴다. 문제는 사용자가 자신의 세션을 볼 수 없다는 것이다. 어떤 기기에서 로그인되어 있는지, 의심스러운 접속이 있는지 확인할 방법이 없다.
세션 관리 UI는 이 문제를 해결한다. 사용자가 현재 활성화된 모든 세션을 한 눈에 확인하고, 필요 없는 세션을 직접 만료시킬 수 있게 한다. Google 계정의 "기기 활동 및 보안 이벤트", GitHub의 "Sessions" 페이지가 대표적인 예시다.
세션 데이터 구조
세션 관리 UI를 만들려면 서버가 세션마다 충분한 메타데이터를 저장해야 한다. 최소한 다음 정보가 필요하다.
interface Session {
id: string; // 세션 고유 식별자
current: boolean; // 현재 요청을 보낸 세션인지
createdAt: string; // 로그인 시점
expiredAt: string; // 만료 예정 시점
environment: {
ip: string; // 접속 IP
device: string; // 기기 정보 (User-Agent 파싱)
browser: string; // 브라우저 종류
};
}
current 필드가 핵심이다. 서버는 요청에 포함된 토큰이나 쿠키를 기준으로 "이 세션이 지금 요청을 보내고 있는 세션"인지 판별해서 current: true를 넣어준다. UI에서 이 값을 사용해서 현재 세션에 "Current Session" 뱃지를 표시하고, 삭제 시 로그아웃 경고를 보여줄 수 있다.
environment 정보는 로그인 시점에 서버가 요청 헤더에서 추출한다. User-Agent 문자열을 파싱하면 기기 종류와 브라우저를 알 수 있고, 요청의 출발 IP도 함께 저장한다. 이 정보가 있어야 사용자가 "이 세션이 내 것인지" 판단할 수 있다.
API 설계
세션 관리에 필요한 API는 단 두 개다.
전체 세션 조회
GET /auth/sessions
Authorization: Bearer <token>
현재 사용자의 모든 활성 세션 목록을 반환한다. 서버는 토큰에서 사용자 ID를 추출하고, 해당 사용자의 만료되지 않은 세션을 전부 조회한다.
const getAllSessions = async (): Promise<Session[]> => {
return authClient.get<Session[]>('/auth/sessions');
};
특정 세션 만료
DELETE /auth/sessions/:sessionId
Authorization: Bearer <token>
특정 세션을 강제로 만료시킨다. 서버는 해당 세션의 토큰을 무효화하고, 그 세션으로 접속 중인 클라이언트는 다음 요청부터 인증 실패를 받게 된다.
const expireSession = async (sessionId: string): Promise<void> => {
await authClient.delete(`/auth/sessions/${sessionId}`);
};
여기서 중요한 보안 로직이 있다. 서버는 "요청을 보낸 사용자"와 "삭제하려는 세션의 소유자"가 같은지 반드시 검증해야 한다. 그렇지 않으면 다른 사용자의 세션을 삭제할 수 있는 취약점이 생긴다.
React Query로 데이터 관리
세션 목록은 자주 바뀌지 않지만 최신 상태를 보여줘야 하므로, staleTime을 적당히 설정해서 불필요한 재요청을 줄인다.
const authKeys = {
all: ['auth'] as const,
sessions: () => [...authKeys.all, 'sessions'] as const,
};
export const useSessions = () => {
const { token, isAuthenticated } = useAuthStore();
return useQuery({
queryKey: authKeys.sessions(),
queryFn: authApi.getAllSessions,
enabled: !!token && isAuthenticated,
staleTime: 2 * 60 * 1000, // 2분
});
};
enabled 옵션으로 인증되지 않은 상태에서는 쿼리가 실행되지 않도록 막는다. 토큰이 없는데 세션 목록을 요청하면 401 에러만 발생하기 때문이다.
세션 삭제는 mutation으로 처리하고, 성공 시 세션 목록을 다시 가져온다.
export const useExpireSession = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: authApi.expireSession,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: authKeys.sessions() });
},
});
};
invalidateQueries를 호출하면 캐시된 세션 목록이 stale 상태가 되면서 자동으로 다시 가져온다. 사용자가 세션을 삭제하면 목록이 즉시 업데이트되는 것처럼 보인다.
현재 세션 삭제 처리
세션 관리 UI에서 가장 까다로운 부분은 "현재 세션"을 삭제하는 경우다. 다른 기기의 세션을 삭제하는 것은 단순하다. API를 호출하고 목록을 갱신하면 끝이다. 하지만 현재 세션을 삭제하면 자기 자신이 로그아웃되어야 한다.
const handleDeleteSession = async (sessionId: string, isCurrent: boolean) => {
if (isCurrent) {
const confirmed = confirm(
'현재 세션을 삭제하면 로그아웃됩니다. 계속하시겠습니까?'
);
if (!confirmed) return;
signOutMutation.mutate(undefined, {
onSuccess: () => {
router.push('/sign-in');
},
});
return;
}
const confirmed = confirm('이 세션을 삭제하시겠습니까?');
if (!confirmed) return;
expireSessionMutation.mutate(sessionId);
};
현재 세션을 삭제할 때는 expireSession 대신 signOut을 호출한다. signOut은 서버에서 세션을 만료시키는 것뿐 아니라, 클라이언트의 인증 상태(토큰, 스토어)까지 정리하고 로그인 페이지로 리다이렉트하는 전체 로그아웃 플로우를 수행한다. expireSession만 호출하면 서버에서는 세션이 만료되었지만 클라이언트는 여전히 로그인 상태라고 생각하는 불일치가 발생한다.
기기 아이콘 분류
세션 목록에서 기기 종류를 시각적으로 구분하면 사용자가 빠르게 인식할 수 있다. User-Agent에서 파싱한 device 문자열을 기반으로 아이콘을 분류한다.
const getDeviceIcon = (device: string) => {
const lower = device.toLowerCase();
if (
lower.includes('mobile') ||
lower.includes('android') ||
lower.includes('iphone')
) {
return <IconDeviceMobile className="h-5 w-5 text-gray-600" />;
}
return <IconDeviceDesktop className="h-5 w-5 text-gray-600" />;
};
단순한 문자열 매칭이지만 대부분의 경우 충분하다. User-Agent에는 운영체제와 기기 정보가 포함되어 있기 때문이다. 더 정교한 분류가 필요하면 ua-parser-js 같은 라이브러리를 사용해서 OS, 브라우저, 기기 타입을 정확하게 파싱할 수 있다.
날짜 포맷팅
세션의 생성 시간과 만료 시간을 사용자가 읽기 편한 형태로 변환한다. toLocaleString을 사용하면 브라우저의 로케일 설정에 맞는 날짜 형식을 자동으로 적용할 수 있다.
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString('ko-KR', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
로케일을 명시적으로 ko-KR로 지정하면 서버 환경이나 사용자의 브라우저 설정과 관계없이 일관된 형식을 보장한다. Intl.DateTimeFormat을 직접 사용하는 것도 같은 결과를 내지만, toLocaleString이 더 간결하다.
인증 가드
세션 관리 페이지는 인증된 사용자만 접근할 수 있어야 한다. 인증되지 않은 상태에서 이 페이지에 접근하면 로그인 페이지로 리다이렉트한다.
useEffect(() => {
if (!isAuthenticated && !isLoading) {
router.push('/sign-in');
}
}, [isAuthenticated, isLoading, router]);
isLoading을 함께 체크하는 이유가 있다. 페이지가 처음 로드될 때 인증 상태 확인은 비동기로 이루어진다. isLoading이 true인 동안은 아직 인증 상태가 확정되지 않은 것이므로, 이 시점에 리다이렉트하면 실제로는 로그인된 사용자가 불필요하게 로그인 페이지로 보내지는 문제가 생긴다.
Optimistic Update vs Invalidation
세션 삭제 후 목록을 업데이트하는 방법은 두 가지다.
Invalidation 방식 (현재 구현): mutation 성공 후 invalidateQueries로 서버에서 최신 데이터를 다시 가져온다. 구현이 간단하고 데이터 일관성이 보장된다. 단점은 삭제 후 목록이 업데이트될 때까지 약간의 딜레이가 있다는 것이다.
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: authKeys.sessions() });
}
Optimistic Update 방식: mutation을 보내기 전에 캐시에서 해당 세션을 미리 제거한다. UI가 즉시 반응하지만, 서버 요청이 실패하면 롤백해야 하는 복잡성이 추가된다.
onMutate: async (sessionId) => {
await queryClient.cancelQueries({ queryKey: authKeys.sessions() });
const previous = queryClient.getQueryData(authKeys.sessions());
queryClient.setQueryData(authKeys.sessions(), (old: Session[]) =>
old.filter(s => s.id !== sessionId)
);
return { previous };
},
onError: (err, sessionId, context) => {
queryClient.setQueryData(authKeys.sessions(), context?.previous);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: authKeys.sessions() });
}
세션 삭제는 자주 일어나는 동작이 아니고, 네트워크 지연도 크게 문제되지 않는다. 이 경우 invalidation 방식이 적절하다. 반면 채팅 메시지 전송처럼 즉각적인 피드백이 중요한 경우에는 optimistic update가 더 나은 선택이다.
보안 고려사항
세션 관리 기능은 보안과 직결되므로 몇 가지 추가 고려가 필요하다.
모든 세션 로그아웃: 비밀번호가 유출되었을 때 모든 기기에서 한 번에 로그아웃하는 기능이다. 서버에서 해당 사용자의 모든 세션을 일괄 만료시키면 된다.
비정상 접속 감지: 평소와 다른 IP나 지역에서 로그인이 감지되면 사용자에게 알림을 보낸다. 세션 생성 시 IP의 지리적 위치를 조회하고, 기존 세션들과 비교해서 이상 여부를 판단한다.
세션 자동 만료: 일정 기간 활동이 없는 세션은 자동으로 만료시킨다. expiredAt 필드가 이 역할을 한다. 서버에서 주기적으로 만료된 세션을 정리하거나, 요청 시점에 만료 여부를 확인해서 즉시 무효화한다.
토큰 회전(Token Rotation): 리프레시 토큰을 사용할 때마다 새 토큰을 발급하는 방식이다. 탈취된 토큰이 이미 사용되었다면 원래 사용자가 다음 갱신 시 충돌이 감지되어 세션이 강제 만료된다.
왜 세션 관리 UI인가
세션 관리 없이도 로그인/로그아웃은 동작한다. 하지만 다중 기기 환경에서는 보안 가시성이 부족하다.
| 항목 | 세션 관리 UI 없음 | 세션 관리 UI 있음 |
|---|---|---|
| 의심스러운 접속 확인 | 불가능 | 기기/IP/시간으로 즉시 확인 |
| 원격 로그아웃 | 비밀번호 변경으로 우회 | 특정 세션만 선택적 만료 |
| 전체 로그아웃 | 서버 측 일괄 처리 필요 | UI에서 원클릭 |
| 사용자 신뢰 | 낮음 (통제감 부재) | 높음 (직접 관리 가능) |
Google, GitHub, Slack 등 보안에 민감한 서비스는 모두 이 기능을 제공한다. 구현 비용 대비 보안 UX 향상이 크기 때문에, 인증 시스템을 직접 만든다면 초기부터 포함하는 것이 좋다.
정리
- 세션마다 IP, 기기, 브라우저 메타데이터를 저장하고 current 필드로 현재 세션을 식별한다. API는 목록 조회(GET)와 개별 만료(DELETE) 두 개면 충분하다.
- 현재 세션 삭제는 expireSession이 아니라 signOut 전체 플로우를 태워야 클라이언트 상태 불일치를 방지할 수 있다.
- Optimistic Update보다 Invalidation이 적합하다. 세션 삭제는 빈도가 낮고 데이터 일관성이 더 중요하기 때문이다.