background
aurora10분

Layout 관리

2025년 7월 26일

이제 프로젝트 생성도 완료했고 피그마도 얼추 정리가 되어 본격적인 UI 작업을 시작하게 되었는데 우리는 Next.js를 사용하는 프로젝트 이기에 라우팅 그룹을 이용하도록 구조를 작성하였다. 따라서 라우팅 그룹에 공유되는 페이지들의 공통 레이아웃을 먼저 잡고 페이지의 UI를 짜는 방식으로 진행하였다.


(Auth)

layout.tsx

typescript
"use client";

import React from "react";
import { AnimatePresence } from "framer-motion";
import Image from "next/image";

const AuthLayout = ({ children }: { children: React.ReactNode }) => {
  return (
    <div className="min-h-screen flex">
      {/* 왼쪽 패널 로고 컨테이너*/}
      <div className="flex-1 bg-aurora-dark flex flex-col items-center justify-center p-8">
        <div className="text-center">
          <div className="mb-6">
            <Image
              src="/background/logo.png"
              alt="Aurora Logo"
              className="w-auto h-24 mx-auto"
              width={100}
              height={100}
            />
            <h1 className="text-4xl font-bold text-white tracking-wide">
              Aurora
            </h1>
          </div>
        </div>
      </div>

      {/* 오른쪽 패널 form 컨테이너*/}
      <div className="flex-3 relative overflow-hidden">
        <div className="bg-aurora-blue-gradient-diagonal relative z-10 flex items-center justify-center min-h-full p-8">
          <div className="w-full max-w-md">
            <div className="bg-aurora-form/62 rounded-xl p-8 shadow-2xl border border-white/10">
              <AnimatePresence mode="wait">{children}</AnimatePresence>
            </div>
          </div>
        </div>
      </div>
    </div>
  );
};

export default AuthLayout;

다음과 같이 왼쪽에 동일하게 나올 부분과 오른쪽 UI 적인 부분에 대해서 동일한 부분을 작성한 뒤 페이지에 따라 UI가 나뉠 부분은 children 으로 표시를 해주었다.

login

typescript
'use client'

import React from 'react'
import Link from 'next/link'
import { motion } from 'framer-motion'
import { useAuthForm } from '../hooks/useAuthForm'
import { AuthFormData } from '../types/AuthFormData'
import { AuthInput } from '../components/AuthInput'
import { AuthCheckbox } from '../components/AuthCheckbox'
import { AuthButton } from '../components/AuthButton'

const pageVariants = {
  initial: {
    opacity: 0,
    y: 50,
  },
  in: {
    opacity: 1,
    y: 0,
  },
  exit: {
    opacity: 0,
    y: -50,
  },
}

const LoginPage = () => {
  const { formData, errors, isLoading, updateField, handleSubmit } = useAuthForm({
    onSubmit: async (data: AuthFormData) => {
      console.log('Login data:', data)
      await new Promise(resolve => setTimeout(resolve, 1000)) // 임시 delay
    }
  })

  const onSubmit = (e: React.FormEvent) => {
    e.preventDefault()
    handleSubmit('login')
  }

  return (
    <motion.div 
      key="login"
      variants={pageVariants}
      initial="initial"
      animate="in"
      exit="exit"
      transition={{
        duration: 0.4,
        ease: "easeInOut"
      }}
      className="w-full"
    >
      <div className="text-center mb-8">
        <h2 className="text-2xl font-bold text-white mb-2">로그인</h2>
        <p className="text-white/70">계정에 로그인하여 시작하세요</p>
      </div>

      <form onSubmit={onSubmit} className="space-y-6">
        <AuthInput
          label="이메일"
          type="email"
          id="email"
          name="email"
          value={formData.email}
          placeholder="이메일을 입력하세요"
          error={errors.email}
          onChange={(value) => updateField('email', value)}
          required
        />

        <AuthInput
          label="비밀번호"
          type="password"
          id="password"
          name="password"
          value={formData.password}
          placeholder="비밀번호를 입력하세요"
          error={errors.password}
          onChange={(value) => updateField('password', value)}
          required
        />

        <div className="flex items-center justify-between">
          <AuthCheckbox
            id="rememberMe"
            name="rememberMe"
            checked={formData.rememberMe || false}
            onChange={(checked) => updateField('rememberMe', checked)}
          >
            로그인 상태 유지
          </AuthCheckbox>
          
          <Link href="#" className="text-sm text-purple-300 hover:text-purple-200">
            비밀번호 찾기
          </Link>
        </div>

        {/* <AuthButton
          type="submit"
          variant="primary"
          loading={isLoading}
          disabled={isLoading}
        >
          로그인
        </AuthButton> */}
        <Link href="/server-connect" className="block w-full">
        <button
          type="submit"
          className="w-full bg-purple-500 hover:bg-purple-600 text-white font-semibold py-3 px-4 rounded-lg transition duration-200 focus:outline-none focus:ring-2 focus:ring-purple-400"
        >
          로그인
        </button>
        </Link>
      </form>

      <div className="mt-8 text-center">
        <p className="text-white/70">
          계정이 없으신가요?{' '}
          <Link href="/register" className="text-purple-300 hover:text-purple-200 font-medium">
            회원가입
          </Link>
        </p>
      </div>

      {/* Navigation for testing */}
      <div className="mt-8 pt-6 border-t border-white/10">
        <AuthButton variant="secondary">
          <Link href="/" className="block w-full">
            메인으로
          </Link>
        </AuthButton>
      </div>
    </motion.div>
  )
}

export default LoginPage

register

typescript
'use client'

import React from 'react'
import Link from 'next/link'
import { motion } from 'framer-motion'
import { useAuthForm } from '../hooks/useAuthForm'
import { AuthFormData } from '../types/AuthFormData'
import { AuthInput } from '../components/AuthInput'
import { AuthCheckbox } from '../components/AuthCheckbox'
import { AuthButton } from '../components/AuthButton'
import { AuthInputWithButton } from '../components/AuthInputWithButton'

const pageVariants = {
  initial: {
    opacity: 0,
    y: 50,
  },
  in: {
    opacity: 1,
    y: 0,
  },
  exit: {
    opacity: 0,
    y: -50,
  },
}

const RegisterPage = () => {
  const { formData, errors, isLoading, updateField, handleSubmit } = useAuthForm({
    initialData: {
      name: '',
      confirmPassword: '',
      agreeToTerms: false
    },
    onSubmit: async (data: AuthFormData) => {
      // 실제 회원가입 로직 구현
      console.log('Register data:', data)
      // 예: API 호출, 회원가입 처리
      await new Promise(resolve => setTimeout(resolve, 1000)) // 임시 delay
    }
  })

  const onSubmit = (e: React.FormEvent) => {
    e.preventDefault()
    handleSubmit('register')
  }

  return (
    <motion.div 
      key="register"
      variants={pageVariants}
      initial="initial"
      animate="in"
      exit="exit"
      transition={{
        duration: 0.4,
        ease: "easeInOut"
      }}
      className="w-full"
    >
      <div className="text-center mb-8">
        <h2 className="text-2xl font-bold text-white mb-2">회원가입</h2>
      </div>

      <form onSubmit={onSubmit} className="space-y-6">

      <AuthInputWithButton
          label="이메일"
          type="email"
          id="email"
          name="email"
          value={formData.email}
          placeholder="이메일을 입력하세요"
          error={errors.email}
          onChange={(value) => updateField('email', value)}
          buttonText="이메일 인증"
          onButtonClick={() => {
            alert('이메일 인증 버튼 클릭')
          }}
          required
        />
        <AuthInput
          label="이름"
          type="text"
          id="name"
          name="name"
          value={formData.name || ''}
          placeholder="이름을 입력하세요"
          error={errors.name}
          onChange={(value) => updateField('name', value)}
          required
        />

       

        <AuthInput
          label="비밀번호"
          type="password"
          id="password"
          name="password"
          value={formData.password}
          placeholder="비밀번호를 입력하세요"
          error={errors.password}
          onChange={(value) => updateField('password', value)}
          required
        />

        <AuthInput
          label="비밀번호 확인"
          type="password"
          id="confirmPassword"
          name="confirmPassword"
          value={formData.confirmPassword || ''}
          placeholder="비밀번호를 다시 입력하세요"
          error={errors.confirmPassword}
          onChange={(value) => updateField('confirmPassword', value)}
          required
        />

        {/* <AuthCheckbox
          id="agreeToTerms"
          name="agreeToTerms"
          checked={formData.agreeToTerms || false}
          onChange={(checked) => updateField('agreeToTerms', checked)}
          error={errors.agreeToTerms}
        >
          <Link href="#" className="text-purple-300 hover:text-purple-200">서비스 약관</Link> 및{' '}
          <Link href="#" className="text-purple-300 hover:text-purple-200">개인정보 처리방침</Link>에 동의합니다.
        </AuthCheckbox> */}
        <div className="mt-8 text-center">
        <p className="text-white/70">
          이미 계정이 있으신가요?{' '}
          <Link href="/login" className="text-purple-300 hover:text-purple-200 font-medium">
            로그인 하러가기
          </Link>
        </p>
      </div>
        <AuthButton
          type="submit"
          variant="primary"
          loading={isLoading}
          disabled={isLoading}
          className='mb-6 mt-4'
        >
          회원가입
        </AuthButton>
      </form>

    </motion.div>
  )
}

export default RegisterPage

결과

로그인 페이지

image.png

image.png

회원가입 페이지

image.png

image.png

다음과 같이 UI를 통일성 있고 쉽게 구현할 수 있었다.


Next.js의 라우팅 그룹은 하위 페이지가 많을 수록 더 진가를 드러내는 것 같았는데 우리 프로젝트의 (sever) 그룹이 이에 해당했다.

서버 그룹의 경우

서버 생성 → 프로젝트 생성 → 채널 생성, 개인 메시지 와 같은 플로우로 이루어져 있고 각각 동적 라우팅을 구현해야 했는데 디스코드나, 메타모스트의 경우를 확인 했을 때 서버 생성 부터 개인 메시지 페이지 까지 중간의 메인 영역을 제외한 채널 목록이나 개인 메시지 목록은 공통되게 공유를 하는 것을 확인하였고 우리의 목업도 그런 방식으로 진행하였다. 결국 하나의 레이아웃 안에 사이드바를 넣으면 내부 영역은 간단하게 작성할 수 있었다. 또한 탭의 변경과 같은 상태 관리도 같은 라우팅 그룹이기 때문에 props 으로 주고 받거나 전역 상태 관리를 할 필요가 없어 굉장히 편리했다.

(server)

layout.tsx

typescript
"use client";

import React from "react";
import { useServerLayout } from "./hooks/useServerLayout";
import { ServerHeader, ProjectSidebar, UserSidebar } from "./components";

export default function ServersLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const {
    serverId,
    projectId,
    channelId,
    activeTab,
    setActiveTab,
    directMessages,
    onlineUsers,
    isSidebarOpen,
    getServerName,
    getChannelName,
    isProjectActive,
    isProjectSelected,
    toggleSidebar,
  } = useServerLayout();

  return (
    <div className="h-screen flex flex-col bg-white">
      {/* 상단 헤더 */}
      <ServerHeader
        serverId={serverId}
        channelId={channelId}
        getServerName={getServerName}
        getChannelName={getChannelName}
        toggleSidebar={toggleSidebar}
        isSidebarOpen={isSidebarOpen}
      />

      <div className="flex flex-1 bg-aurora-main">
        {/* 왼쪽+중앙: 프로젝트 목록과 채널 목록을 포함하는 사이드바 영역 */}
        <ProjectSidebar
          serverId={serverId}
          projectId={projectId}
          channelId={channelId}
          isProjectActive={isProjectActive}
          isProjectSelected={isProjectSelected}
        />

        {/* 오른쪽: 메인 콘텐츠 영역 */}
        <div className="flex-1 bg-gray-800 relative">{children}</div>

        {/* 사이드바 with 슬라이드 애니메이션 */}
        <div
          className={`bg-gray-600 flex flex-col rounded-tl-lg transition-all duration-300 ease-in-out overflow-hidden ${
            isSidebarOpen ? "w-64 opacity-100" : "w-0 opacity-0"
          }`}
        >
          <UserSidebar
            activeTab={activeTab}
            setActiveTab={setActiveTab}
            onlineUsers={onlineUsers}
            directMessages={directMessages}
            serverId={serverId}
            projectId={projectId}
            isSidebarOpen={isSidebarOpen}
          />
        </div>
      </div>
    </div>
  );
}

channel

typescript
"use client";

import React from "react";
import { useChannelPage } from "../../../../../hooks/useChannelPage";
import { ChatHeader, MessageList, MessageInput } from "../components";

const ChannelPage = () => {
  const {
    channelId,
    newMessage,
    setNewMessage,
    messages,
    getChannelName,
    handleSendMessage,
  } = useChannelPage();

  const channelName = getChannelName(channelId);

  return (
    <div className="h-full flex">
      {/* 중앙: 채팅 영역 */}
      <div className="flex-1 flex flex-col rounded-tl-lg rounded-tr-lg overflow-hidden bg-chatting-background">
        {/* 채팅 헤더 */}
        <ChatHeader channelName={channelName} />

        {/* 채팅 메시지 영역 */}
        <MessageList messages={messages} channelName={channelName} />

        {/* 메시지 입력 영역 */}
        <MessageInput
          newMessage={newMessage}
          setNewMessage={setNewMessage}
          handleSendMessage={handleSendMessage}
          channelName={channelName}
        />
      </div>
    </div>
  );
};

export default ChannelPage;

message

typescript
"use client";

import React from "react";
import { useMessagePage } from "../../../../../hooks/useMessagePage";
import {
  PrivateMessageHeader,
  PrivateMessageList,
  PrivateMessageInput,
} from "../components";

const MessagePage = () => {
  const {
    userId,
    newMessage,
    setNewMessage,
    messages,
    getUserName,
    getUser,
    handleSendMessage,
  } = useMessagePage();

  const userName = getUserName(userId);
  const user = getUser(userId);

  return (
    <div className="h-full flex rounded-tr-lg rounded-tl-lg bg-chatting-background">
      {/* 중앙: 1:1 채팅 영역 */}
      <div className="flex-1 flex flex-col bg-white">
        {/* 채팅 헤더 */}
        <PrivateMessageHeader user={user} userName={userName} />

        {/* 채팅 메시지 영역 */}
        <PrivateMessageList messages={messages} userName={userName} />

        {/* 메시지 입력 영역 */}
        <PrivateMessageInput
          newMessage={newMessage}
          setNewMessage={setNewMessage}
          handleSendMessage={handleSendMessage}
          userName={userName}
        />
      </div>
    </div>
  );
};

export default MessagePage;

(컴포넌트의 경우 분량이 너무 많은 관계로 업로드가 불가능 하다…)

결과

서버 진입 시 메인화면

image.png

image.png

채널 진입 시 메인 화면

image.png

image.png

다이렉트 메시지 진입 시 메인 화면

image.png

image.png


Next.js의 라우팅 그룹 굉장히 강력한 기능 중 하나라고 생각된다.. Next.js 공부하면 할 수록 재밌는 것 같다. 이제 음성, 영상 채널 UI 작업하고 나머지 것들 빨리 해야지..

태그

#Next.js#aurora