const-tommy.dev
기록을 불러오는 중입니다
muroom 프로젝트의 로그인 모달은 단순히 창이 뜨고 사라지는 상태(State)가 아니다. 우리는 이를 독립적인 주소를 가진 라우트로 설계했다. 유저가 어디서든 로그인 페이지 링크를 공유할 수 있어야 하고, 소셜 로그인이나 회원가입을 거쳐도 이전의 탐색 맥락이 완벽하게 유지되어야 했기 때문이다.
Next.js의 Parallel Routes와 Intercepting Routes를 결합하여, 유저가 기존에 보던 화면은 유지하면서 주소창과 상위 레이어만 로그인 폼으로 교체하는 구조를 채택했다.

Parallel & Intercepting Routes를 활용한 계층적 구조
@modal (Parallel Slot): 루트 레이아웃에서 메인 콘텐츠와 병렬로 렌더링되는 전용 슬롯이다.(.)welcome (Intercepting): 동일 레벨의 /welcome 경로를 가로채어, 페이지 전체 이동 없이 모달 슬롯 안에서 로그인 폼을 띄운다.default.tsx: Parallel Route 환경에서 의도치 않은 404 에러를 방지하고 상태 불일치를 해결하는 중요한 안전장치 역할을 한다.인터셉팅 라우트의 최대 약점은 새로고침(F5) 이다. 새로고침 시 클라이언트의 가로채기 맥락이 유실되어 배경이 증발하는 현상이 발생한다. 서버는 유저의 이전 클라이언트 사이드 맥락을 알지 못하기 때문이다.
나는 이를 해결하기 위해 @modal 레이아웃에서 HomePage를 기본 배경으로 제공하는 조건부 렌더링을 설계했다. 유저에게 텅 빈 흰 배경 대신 서비스의 정체성을 보여주는 화면을 하단에 깔아줌으로써, 새로고침 시에도 서비스 흐름이 끊기지 않는 '시각적 연속성(Visual Continuity)' 을 확보했다.
// @modal/(.)welcome/layout.tsx
export default function Layout({ children }: { children: React.ReactNode }) {
const { isMobile } = useResponsiveLayout();
return (
<div className='relative size-full'>
{/* 1. 새로고침 시 배경 유실을 막기 위해 데스크톱 한정으로 HomePage를 배경에 배치 */}
{!isMobile && (
<div className='absolute inset-0 z-0'>
<Suspense fallback={<Loading />}>
<HomePage isMobile={false} />
</Suspense>
</div>
)}
{/* 2. 실제 모달 콘텐츠는 Portal을 통해 최상위 DOM으로 탈출하여 스타일 간섭 방지 */}
{mounted && createPortal(
<div className={cn('z-99 fixed inset-0', ...)}>
<WelcomeLayout>{children}</WelcomeLayout>
</div>,
document.body
)}
</div>
);
}소셜 로그인은 외부 도메인을 거쳐 돌아오는 Stateless한 과정이다. 유저가 지도의 특정 좌표에서 로그인을 시도했다면, 인증 후에도 정확히 그 지점으로 복귀해야 한다.
useAuthRedirectStore (Zustand + Persistence)브라우저 메모리 대신 LocalStorage에 목적지를 박제하여 OAuth 리다이렉트 후에도 유저의 최종 목적지 데이터를 보존했다.
export const useAuthRedirectStore = create<AuthRedirectState>()(
persist(
(set, get) => ({
redirectUrl: null, // 로그인 시점의 window.location.href 저장
performRedirect: () => {
const target = get().redirectUrl || '/home';
window
SIGNUP_REQUIRED와 쿼리 트리거인증은 성공했지만 추가적인 정보 입력(회원가입)이 필요한 케이스를 위해 쿼리 파라미터를 활용한 상태 체이닝 기법을 적용했다.
복잡한 전역 상태 대신 URL 파라미터를 택한 이유는 직관성과 유지보수성 때문이다. 유저 입장에서는 본인이 어떤 프로세스(trigger_join)에 있는지 주소창을 통해 명확히 알 수 있고, 개발자 입장에서도 별도의 상태 관리 로직 없이 파라미터의 존재 유무만으로 회원가입 모달을 띄우는 '선언적 처리' 가 가능해지기 때문이다.
// callback/page.tsx
if (type === 'SIGNUP_REQUIRED' && signupToken) {
setRegisterDTO({ signupToken });
const returnTarget = currentRedirectUrl || '/home';
const separator = returnTarget.includes('?') ? '&' : '?';
// 원래 돌아가려던 주소 뒤에 회원가입 모달을 즉시 띄울 트리거(?trigger_join=true)를 주입
router
z-index나 스타일 상속 문제에 휘말리지 않도록 document.body로 렌더링 위치를 물리적으로 분리했다.pb-[env(safe-area-inset-bottom)])을 적용하여, 기기별 노치 디자인이나 시스템 버튼과 서비스 인터페이스가 겹치는 현상을 방지했다.