<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>Record Repository</title>
    <link>https://seungjunn100.tistory.com/</link>
    <description>Record Repository</description>
    <language>ko</language>
    <pubDate>Fri, 10 Apr 2026 16:39:58 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>seungjunn100</managingEditor>
    <image>
      <title>Record Repository</title>
      <url>https://tistory1.daumcdn.net/tistory/8239506/attach/e69b19dee49f4c478f96192108dc4cb8</url>
      <link>https://seungjunn100.tistory.com</link>
    </image>
    <item>
      <title>[Next.js] Claude Code CLI를 활용한 리팩토링</title>
      <link>https://seungjunn100.tistory.com/entry/Nextjs-Claude-Code-CLI%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-%EB%A6%AC%ED%8C%A9%ED%86%A0%EB%A7%81</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;VSCode&lt;/code&gt; 터미널에서 &lt;code&gt;Claude Code CLI&lt;/code&gt;를 사용하여 로그인과 회원가입에 대한 리팩토링을 진행해보았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 &lt;code&gt;UX&lt;/code&gt;, 접근성, 기능 구현, 에러 처리, 보안쪽으로 개선 사항을 부탁했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내가 생각했던 부분과 생각하지 못한 부분을 다방면으로 생각하여 수정을 해주었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만, 팀프로젝트가 끝난 상황에서 진행한 상태여서 정확한 정보를 제공해주지 않은 측면에서는 &lt;code&gt;Claude&lt;/code&gt;가 가정해서 수정을 진행한 부분도 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;로그인 기능 개선 사항 정리&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;하드코딩된 테스트 자격증명 제거&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;파일 : &lt;code&gt;components/auth/LoginForm.tsx&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;이유 : 프로덕션 코드에 개발용 계정이 그대로 노출되어 있어, 누구나 소스코드를 통해 테스트 계정을 알 수 있는 보안 위험이 있었음.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;actionscript&quot;&gt;&lt;code&gt;// ❌ 이전 코드
const [email, setEmail] = useState('wx010622@naver.com');
const [password, setPassword] = useState('1111');

// ✅ 개선 코드
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 부분은 팀프로젝트 발표 당시 빠른 진행을 위해 초기값을 세팅해 둔 상태였는데, 이 부분을 빈 문자열로 다시 세팅해 주었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;로그인 성공 시 alert() 제거&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;파일 : &lt;code&gt;components/auth/LoginForm.tsx&lt;/code&gt;, &lt;code&gt;components/auth/KakaoLoginCallback.tsx&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;이유 : &lt;code&gt;alert()&lt;/code&gt;은 브라우저 기본 다이얼로그로 &lt;code&gt;UI&lt;/code&gt;를 차단하고, 스타일링이 불가능하며 구식 &lt;code&gt;UX&lt;/code&gt;. 어차피 &lt;code&gt;router.replace('/')&lt;/code&gt;로 페이지가 전환되므로 &lt;code&gt;alert&lt;/code&gt; 자체가 불필요함.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;// ❌ 이전 코드
alert(`안녕하세요, ${userState.item.name}님!\n로그인이 완료되었습니다!`);
router.replace('/');

// ✅ 개선 코드
router.replace('/');  // alert 없이 바로 이동&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나중에 &lt;code&gt;alert()&lt;/code&gt;를 토스트 &lt;code&gt;UI&lt;/code&gt;를 활용해서 추가하려고 했지만, 로그인이 된 상태라면 &lt;code&gt;router.replace('/')&lt;/code&gt;에 의해 &lt;code&gt;Home&lt;/code&gt;으로 이동하고 로그인이 된 상태면 헤더 메뉴까지 바뀌게 되어 굳이 필요하지 않아도 될 것 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;비밀번호 표시/숨김 토글 추가&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;파일 : &lt;code&gt;components/auth/LoginForm.tsx&lt;/code&gt;, &lt;code&gt;components/auth/AuthInput.tsx&lt;/code&gt;, &lt;code&gt;types/auth.ts&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;이유 : 비밀번호 입력창에 표시/숨김 기능이 없어 오타 확인이 불가했고, 접근성도 떨어졌음. &lt;code&gt;suffix prop&lt;/code&gt;을 &lt;code&gt;AuthInput&lt;/code&gt;에 추가해 입력창 우측에 버튼을 삽입할 수 있도록 확장함.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;sqf&quot;&gt;&lt;code&gt;// ❌ 이전 코드 (AuthInput &amp;mdash; suffix 미지원)
&amp;lt;input id={name} name={name} {...props} className={...} /&amp;gt;

// ✅ 개선 코드 (AuthInput &amp;mdash; suffix prop으로 절대위치 요소 렌더링)
&amp;lt;div className={suffix ? 'relative' : undefined}&amp;gt;
  &amp;lt;input ... /&amp;gt;
  {suffix}
&amp;lt;/div&amp;gt;

// ✅ LoginForm에서 눈 아이콘 버튼 주입
const [showPassword, setShowPassword] = useState(false);

&amp;lt;AuthInput
  type={showPassword ? 'text' : 'password'}
  suffix={
    &amp;lt;button
      type=&quot;button&quot;
      aria-label={showPassword ? '비밀번호 숨기기' : '비밀번호 표시'}
      onClick={() =&amp;gt; setShowPassword((prev) =&amp;gt; !prev)}
      className=&quot;absolute right-4 top-1/2 -translate-y-1/2 ...&quot;
    &amp;gt;
      {/* SVG 눈 아이콘 */}
    &amp;lt;/button&amp;gt;
  }
/&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;작성한 비밀번호를 확인하기 위해 표시/숨김 기능이 굳이 필요할까 라는 생각이 들었지만,&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;저시력 사용자들이 화면을 확대해서 비밀번호를 확인할 수도 있고,&lt;/li&gt;
&lt;li&gt;손떨림이 있거나 미세한 조작이 어려운 사용자들도 눈으로 확인하며 오타가 발생하지 않게 작성할 수 있어 신체적 피로를 덜어주고,&lt;/li&gt;
&lt;li&gt;방금 입력한 내용을 시각적으로 즉시 확인시켜 줄 수도 있고,&lt;/li&gt;
&lt;li&gt;모바일의 좁은 자판이나 확인 칸이 없는 환경에서 입력의 정확성과 확신을 높여줄 수도 있어&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시각적으로 확신을 주어 서비스를 보다 수월하게 사용할 수 있어 사용자들의 접근성을 높여줄 수도 있다는 생각을 하지 못했다.&amp;nbsp;그리고 버튼의 대체 텍스트까지 추가하여 접근성을 높일 수 있도록 개선해주었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;비밀번호 찾기 링크 오류 수정&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;파일 : &lt;code&gt;app/(auth)/login/page.tsx&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;이유 : 비밀번호 찾기가 / &lt;code&gt;signup&lt;/code&gt;(회원가입)으로 연결되어 있어 명백한 버그였음. 해당 기능이 아직 미구현이므로 클릭 불가한 비활성 상태로 표시해 사용자 혼란을 방지함.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;xml&quot;&gt;&lt;code&gt;// ❌ 이전 코드 (회원가입 페이지로 잘못 연결)
&amp;lt;Link href=&quot;/signup&quot; className=&quot;font-medium text-[14px] text-yg-primary md:text-base&quot;&amp;gt;
  비밀번호 찾기
&amp;lt;/Link&amp;gt;

// ✅ 개선 코드 (비활성화 + 툴팁)
&amp;lt;span
  className=&quot;font-medium text-[14px] text-yg-gray cursor-not-allowed md:text-base&quot;
  title=&quot;서비스 준비 중입니다.&quot;
&amp;gt;
  비밀번호 찾기
&amp;lt;/span&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 기능은 아직 구현하지 못하여 회원가입 링크로 적용한 상태였는데, 서비스 준비 중이라는 타이틀과 함께 클릭 불가한 비활성 상태로 표시해주어 사용자 입장을 생각해서 개선 해준게 좋았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;카카오 로그인 실패 에러 처리&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;파일 : &lt;code&gt;components/auth/KakaoLoginCallback.tsx&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;이유 : 카카오 로그인 실패 시 아무 처리 없이 로딩 스피너가 무한히 표시되었음. 실패하면 &lt;code&gt;/login?error=kakao&lt;/code&gt;로 리다이렉트해 &lt;code&gt;LoginForm&lt;/code&gt;에서 인라인 에러 메시지를 보여주도록 함.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// ❌ 이전 코드 (실패 처리 없음 &amp;rarr; 무한 로딩)
if (response?.ok) {
  setUser({ ... });
  alert('로그인이 완료되었습니다!');
  router.replace('/');
}
// 실패 시: 아무것도 안 함

// ✅ 개선 코드
if (response?.ok) {
  setUser({ ... });
  router.replace('/');
} else {
  router.replace('/login?error=kakao');  // 실패 시 에러 메시지와 함께 리다이렉트
}

// ✅ LoginForm에서 에러 파라미터 감지
const kakaoError = searchParams.get('error') === 'kakao';

{kakaoError &amp;amp;&amp;amp; (
  &amp;lt;p className=&quot;... text-yg-warning&quot;&amp;gt;카카오 로그인에 실패했습니다. 다시 시도해 주세요.&amp;lt;/p&amp;gt;
)}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순하게 카카오 서버에서만 로그인이 완료되면, 에러가 발생할 일이 없을 것 같았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만,&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;사용자가 카카오 동의 화면에서 취소하여 &lt;code&gt;code&lt;/code&gt;값 없이 리다이렉트되거나,&lt;/li&gt;
&lt;li&gt;네트워크 오류로 인해 &lt;code&gt;code&lt;/code&gt;값이 만료되거나,&lt;/li&gt;
&lt;li&gt;카카오 서버 장애로 인해 백엔드에서 &lt;code&gt;API&lt;/code&gt; 호출 실패하거나,&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;등등 생각하지 못했던 부분이 있었는데 위와 같이 에러 처리를 해주어 개선해주었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;카카오 콜백 &amp;mdash; code 없을 때 무한 로딩 방지&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;파일 : &lt;code&gt;components/auth/KakaoLoginCallback.tsx&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;이유 : &lt;code&gt;URL&lt;/code&gt;에 &lt;code&gt;code&lt;/code&gt; 파라미터가 없는 경우(직접 접근, &lt;code&gt;OAuth&lt;/code&gt; 오류 등) &lt;code&gt;if (code)&lt;/code&gt; 블록을 건너뛰어 로딩 화면이 영원히 유지되는 문제가 있었음.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;gauss&quot;&gt;&lt;code&gt;// ❌ 이전 코드 (code 없으면 조용히 무한 로딩)
if (code) {
  const response = await loginKakao(code);
  // ...
}

// ✅ 개선 코드 (code 없으면 즉시 에러 페이지로)
if (!code) {
  router.replace('/login?error=kakao');
  return;
}
const response = await loginKakao(code);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위와 같은 문제로 카카오 서버에서 카카오 로그인만 성공하면 &lt;code&gt;code&lt;/code&gt;는 무조건 넘어올 줄 알았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 &lt;code&gt;code&lt;/code&gt;가 없을 때도 있으므로 코드가 없으면 로그인 페이지로 이동하여 다시 로그인할 수 있도록 개선해주었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;카카오 로그인 사용자 정보 누락 수정&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;파일 : &lt;code&gt;components/auth/KakaoLoginCallback.tsx&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;이유 : 일반 로그인은 &lt;code&gt;email&lt;/code&gt;, &lt;code&gt;phone&lt;/code&gt;까지 저장하는데 카카오 로그인은 &lt;code&gt;_id&lt;/code&gt;, &lt;code&gt;name&lt;/code&gt;만 저장해 마이페이지에서 이메일/전화번호가 표시되지 않는 정보 불일치 문제가 있었음.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;roboconf&quot;&gt;&lt;code&gt;// ❌ 이전 코드 (name, _id만 저장)
setUser({
  _id: response.item._id,
  name: response.item.name,
});

// ✅ 개선 코드 (일반 로그인과 동일하게 email, phone 포함)
setUser({
  _id: response.item._id,
  name: response.item.name,
  email: response.item.email,
  phone: response.item.phone,
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원래는 동일하게 사용자 정보를 저장하는게 맞다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 카카오 콘솔에서 앱을 생성할 때 테스트용으로 만든 앱이여서 이름만 데이터를 가져올 수 있어서 이름만 저장한 상태였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 이 부분은 정확한 정보를 제공하지 않아 다시 정보를 제공한 후에 이름만 저장하도록 복구하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;로그아웃 &amp;mdash; 백엔드 API 호출 추가&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;파일 : &lt;code&gt;actions/auth.ts&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;이유 : 프론트에서 쿠키만 삭제하고 백엔드에 로그아웃을 알리지 않아 서버 측 토큰이 만료 전까지 유효한 상태로 남아있는 보안 문제가 있었음. 백엔드 로그아웃 &lt;code&gt;API&lt;/code&gt;를 호출해 서버에서도 토큰을 무효화하도록 함. &lt;code&gt;API&lt;/code&gt; 호출 실패 시에도 쿠키 삭제는 반드시 실행되도록 처리.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// ❌ 이전 코드 (쿠키만 삭제, 백엔드 미호출)
export async function logout() {
  const cookieStore = await cookies();
  cookieStore.set('accessToken', '', { path: '/', maxAge: 0 });
  cookieStore.set('refreshToken', '', { path: '/', maxAge: 0 });
}

// ✅ 개선 코드 (백엔드 토큰 무효화 후 쿠키 삭제)
export async function logout() {
  const cookieStore = await cookies();
  const accessToken = cookieStore.get('accessToken')?.value;

  if (accessToken) {
    try {
      await fetch(`${API_URL}/users/logout`, {
        method: 'GET',
        headers: {
          'Client-Id': CLIENT_ID,
          Authorization: `Bearer ${accessToken}`,
        },
      });
    } catch (err) {
      console.error('백엔드 로그아웃 실패:', err);
    }
  }

  cookieStore.set('accessToken', '', { path: '/', maxAge: 0 });
  cookieStore.set('refreshToken', '', { path: '/', maxAge: 0 });
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트에서 사용하는 &lt;code&gt;API&lt;/code&gt; 서버에는 로그아웃 엔드포인트가 따로 존재하지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그인하면 &lt;code&gt;JWT&lt;/code&gt; 기반의 토큰을 발급해주는데, 이 방식은 서버가 토큰을 기억하지 않으므로 백엔드에서 로그아웃을 하지 않고 삭제만 해주어도 로그아웃된 상태가 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단, 쿠키를 삭제하기 전에 토큰을 탈취했다면 만료 시간까지는 유효할 수 있다는 단점이 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 토큰을 쿠키에 &lt;code&gt;httpOnly&lt;/code&gt;로 저장하여 자바스크립트로 접근하지 못하며 &lt;code&gt;XSS&lt;/code&gt;로 탈취하지 못하게 설정을 하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 위의 개선코드는 해당 사항이 없어 원복하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;로그인 폼 클라이언트 유효성 검사&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;파일 : &lt;code&gt;components/auth/LoginForm.tsx&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;이유 : 이메일/비밀번호가 비어있어도 서버 요청이 전송되어 불필요한 &lt;code&gt;API&lt;/code&gt; 호출이 발생했음. &lt;code&gt;HTML required&lt;/code&gt; 속성을 추가해 브라우저 레벨에서 빈 값 제출을 차단함.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;fsharp&quot;&gt;&lt;code&gt;// ❌ 이전 코드 (빈 값 제출 가능)
&amp;lt;AuthInput name=&quot;email&quot; type=&quot;email&quot; ... /&amp;gt;
&amp;lt;AuthInput name=&quot;password&quot; type=&quot;password&quot; ... /&amp;gt;

// ✅ 개선 코드 (브라우저 기본 유효성 검사)
&amp;lt;AuthInput name=&quot;email&quot; type=&quot;email&quot; ... required /&amp;gt;
&amp;lt;AuthInput name=&quot;password&quot; type=&quot;password&quot; ... required /&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비어있으면 로그인을 시도할 생각이 없을 것 같았지만 혹시나 하는 상황을 대비해서 &lt;code&gt;API&lt;/code&gt; 호출을 예방하도록 개선한 부분이 좋았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;회원가입 기능 개선 사항 정리&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;'use client' + metadata 충돌 버그 수정&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;파일 : &lt;code&gt;app/(auth)/signup/page.tsx&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;이유 : &lt;code&gt;Next.js App Router&lt;/code&gt;에서 &lt;code&gt;metadata export&lt;/code&gt;는 &lt;code&gt;Server Component&lt;/code&gt;에서만 동작합니다. &lt;code&gt;'use client'&lt;/code&gt;가 붙어 있으면 metadata가 완전히 무시되어 브라우저 탭 타이틀, &lt;code&gt;SEO&lt;/code&gt;, &lt;code&gt;OG&lt;/code&gt; 태그가 모두 적용되지 않습니다. &lt;code&gt;SignupForm&lt;/code&gt; 자체가 이미 &lt;code&gt;'use client'&lt;/code&gt;이므로 &lt;code&gt;page.tsx&lt;/code&gt;에서는 제거해도 됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;dart&quot;&gt;&lt;code&gt;// ❌ 이전 코드 (metadata 무시됨)
'use client';
export const metadata: Metadata = { title: '회원가입', ... };  // 동작 안 함

// ✅ 개선 코드 ('use client' 제거 &amp;rarr; Server Component)
export const metadata: Metadata = { title: '회원가입', ... };  // 정상 동작&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제는 잘못된 분석으로 자기가 &lt;code&gt;'use client'&lt;/code&gt; 선언 문구를 제거했다고 착각한 부분이 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;애초에 해당 문구는 존재하지 않았으며, 정상적으로 동작하는 &lt;code&gt;Server Component&lt;/code&gt;였다..&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;회원가입 성공 시 alert() 제거&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;파일 : &lt;code&gt;components/auth/SignupForm.tsx&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;이유 : &lt;code&gt;router.replace('/login')&lt;/code&gt;으로 바로 이동하므로 &lt;code&gt;alert&lt;/code&gt;가 불필요하고, 브라우저 기본 다이얼로그는 스타일링 불가 + &lt;code&gt;UI&lt;/code&gt; 차단 문제가 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;// ❌ 이전 코드
if (userState?.ok) {
  alert('회원가입이 완료되었습니다!');
  router.replace('/login');
}

// ✅ 개선 코드
if (userState?.ok) {
  router.replace('/login');
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그인과 동일하게 회원가입 성공 시 &lt;code&gt;router&lt;/code&gt;에 의해 로그인 페이지로 이동하므로 &lt;code&gt;alert&lt;/code&gt;를 제거해주었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;본인인증 버튼 disabled 로직 버그 수정&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;파일 : &lt;code&gt;components/auth/SignupForm.tsx&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;이유 : &lt;code&gt;isCertified === null &amp;amp;&amp;amp; isCertified === false&lt;/code&gt;는 하나의 변수가 동시에 두 다른 값을 가질 수 없으므로 항상 &lt;code&gt;false&lt;/code&gt;입니다. 결과적으로 인증 완료 후에도 버튼이 비활성화되지 않아 중복 인증 요청이 가능한 버그였습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;// ❌ 이전 코드 (항상 false &amp;rarr; 항상 클릭 가능)
disabled={isCertified === null &amp;amp;&amp;amp; isCertified === false}

// ✅ 개선 코드 (인증 완료 시 실제로 비활성화)
disabled={isCertified === true}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 기존 코드는 수정 과정에서 미쳐 발견하지 못했던 부분 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;버튼의 컬러나 문구는 &lt;code&gt;isCertified === true&lt;/code&gt;일 때 동작하지 못하도록 &lt;code&gt;UI&lt;/code&gt;를 구현하였는데, &lt;code&gt;UI&lt;/code&gt;가 마치 안눌리는 것처럼 보이게 되어 클릭이 안되는줄 알았는데 다시 인증을 할 수 있는 상태였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;미처 발견하지 못한 부분을 발견하여 비활성화 상태로 개선해주었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;비밀번호 표시/숨김 토글 추가&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;파일 : &lt;code&gt;components/auth/SignupForm.tsx&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;이유 : 비밀번호, 비밀번호 확인 두 필드 모두 적용. 이미 구현된 &lt;code&gt;AuthInput&lt;/code&gt;의 &lt;code&gt;suffix prop&lt;/code&gt;을 재사용했습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;scala&quot;&gt;&lt;code&gt;// ❌ 이전 코드
&amp;lt;AuthInput name=&quot;password&quot; type=&quot;password&quot; ... /&amp;gt;
&amp;lt;AuthInput name=&quot;passwordCheck&quot; type=&quot;password&quot; ... /&amp;gt;

// ✅ 개선 코드
const [showPwd, setShowPwd] = useState(false);
const [showPwdCheck, setShowPwdCheck] = useState(false);

&amp;lt;AuthInput
  name=&quot;password&quot;
  type={showPwd ? 'text' : 'password'}
  suffix={&amp;lt;button aria-label=&quot;비밀번호 표시/숨기기&quot; ...&amp;gt; &amp;lt;/button&amp;gt;}
/&amp;gt;
&amp;lt;AuthInput
  name=&quot;passwordCheck&quot;
  type={showPwdCheck ? 'text' : 'password'}
  suffix={&amp;lt;button aria-label=&quot;비밀번호 확인 표시/숨기기&quot; ...&amp;gt; &amp;lt;/button&amp;gt;}
/&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그인 폼에서 추가했던 것과 동일하게 &lt;code&gt;UX&lt;/code&gt; 일관성을 지키기 위해 비밀번호와 비밀번호 확인 필드까지 적용해주었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;본인인증 catch 블록 사용자 에러 메시지 추가&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;파일 : &lt;code&gt;components/auth/SignupForm.tsx&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;이유 : 본인인증 중 예외(네트워크 오류, &lt;code&gt;PortOne SDK&lt;/code&gt; 오류 등)가 발생하면 &lt;code&gt;console.error&lt;/code&gt;만 호출하고 사용자에게 아무 피드백이 없었습니다. 사용자 입장에서는 버튼을 눌렀는데 아무 반응이 없는 것처럼 보입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;// ❌ 이전 코드 (사용자에게 피드백 없음)
} catch (err) {
  console.error('본인인증 오류:', err);
}

// ✅ 개선 코드 (인라인 에러 메시지 표시)
} catch (err) {
  console.error('본인인증 오류:', err);
  setCertifyMsg('본인인증 중 오류가 발생했습니다. 다시 시도해 주세요.');
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;try&lt;/code&gt; 블록에서 &lt;code&gt;SDK&lt;/code&gt;의 응답에 따른 에러 처리, 우리가 사용하는 서버에서 검증 실패에 대한 에러 처리만 진행하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 &lt;code&gt;try&lt;/code&gt; 내부 처리만으로는 네트워크 단절, &lt;code&gt;SDK&lt;/code&gt; 오류, &lt;code&gt;JSON&lt;/code&gt; 파싱 실패 같은 예외 상황에서 &lt;code&gt;catch&lt;/code&gt;가 아무 메시지도 안 보여주어 사용자는 아무 반응 없는 화면을 보게 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;catch&lt;/code&gt;의 &lt;code&gt;setCertifyMsg&lt;/code&gt; 추가하여 개선해주었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;PortOne 하드코딩 값 환경변수로 분리&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;파일 : &lt;code&gt;components/auth/SignupForm.tsx&lt;/code&gt;, &lt;code&gt;.env&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;이유 : &lt;code&gt;storeId&lt;/code&gt;, &lt;code&gt;channelKey&lt;/code&gt;가 소스코드에 직접 노출되어 있어 &lt;code&gt;GitHub&lt;/code&gt; 등에 올라갈 경우 외부에 공개됩니다. 환경변수로 분리하면 값 변경 시 코드 수정 없이 &lt;code&gt;.env&lt;/code&gt;만 바꿔도 되고, 환경별(개발/프로덕션) 설정도 용이합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;subunit&quot;&gt;&lt;code&gt;// ❌ 이전 코드 (값 하드코딩)
await PortOne.requestIdentityVerification({
  storeId: 'store-d1ae51ff-3845-45e1-8c36-533945dd9929',
  channelKey: 'channel-key-10ef6a4b-a90d-4bec-88cd-8591a7903ff4',
});

// ✅ 개선 코드 (환경변수 참조)
await PortOne.requestIdentityVerification({
  storeId: process.env.NEXT_PUBLIC_PORTONE_STORE_ID!,
  channelKey: process.env.NEXT_PUBLIC_PORTONE_CHANNEL_KEY!,
});

# .env에 추가
NEXT_PUBLIC_PORTONE_STORE_ID=store-d1ae51ff-3845-45e1-8c36-533945dd9929
NEXT_PUBLIC_PORTONE_CHANNEL_KEY=channel-key-10ef6a4b-a90d-4bec-88cd-8591a7903ff4&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 부분은 해당 페이지에서만 &lt;code&gt;storeId&lt;/code&gt;, &lt;code&gt;channelKey&lt;/code&gt;의 값들을 고정으로 사용하고 환경별로 달라질 일이 없을 것 같아서 하드코딩으로 작업해두었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 &lt;code&gt;.env&lt;/code&gt; 파일에서 관리하면 값이 바뀔 때 코드를 수정하지 않고 &lt;code&gt;.env&lt;/code&gt;만 수정하면 되므로 재배포 범위를 줄일 수 있고, 지금은 테스트용이라 환경별 차이가 없지만 테스트용 채널키와 운영용 채널키를 분리해야 하는 상황이 생길 수도 있어 &lt;code&gt;.env&lt;/code&gt; 파일에서 관리하면 좋다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;각 input 요소마다 여러 개의 상태 변수 사용&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 &lt;code&gt;input&lt;/code&gt; 요소마다 여러 개의 상태 변수를 사용하고 있어 코드가 길어지는 문제를 고민하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 부분을 리팩토링 하면 코드의 가독성은 개선되지만 성능에는 거의 영향이 없다고 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만, &lt;code&gt;React 18&lt;/code&gt;부터 이벤트 핸들러 내의 여러 &lt;code&gt;setState&lt;/code&gt; 호출은 자동으로 하나의 리렌더로 묶인다고 한다.&lt;/p&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;// 아래 3개의 setState가 실행돼도 리렌더는 1번
setNameIsValid(true);
setNameError('');
setNameTouched(true);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;진짜 성능 문제는 &lt;code&gt;controlled input&lt;/code&gt; 자체에 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;타이핑할 때마다 컴포넌트 전체가 리렌더되는 것이 실제 성능 이슈다. 이는 상태 변수 개수와 무관하며, &lt;code&gt;useState&lt;/code&gt;를 몇 개로 합쳐도 해결되지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실질적 성능 개선 방법은 &lt;code&gt;react-hook-form&lt;/code&gt;을 사용하는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;react-hook-form&lt;/code&gt;은 &lt;code&gt;ref&lt;/code&gt; 기반의 비제어 입력(&lt;code&gt;uncontrolled input&lt;/code&gt;)을 사용해 타이핑 시 리렌더를 최소화한다. 유효성 검사 상태가 바뀔 때만 리렌더가 발생한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단, 현재 &lt;code&gt;SignupForm&lt;/code&gt;의 커스텀 검증 로직(이메일 중복 확인, 본인인증 연동 등)을 전부 마이그레이션해야 하므로 리팩토링 비용이 크다. &lt;code&gt;SignupForm&lt;/code&gt; 규모에서 사용자가 체감할 성능 저하도 없으므로 지금 당장 도입할 필요는 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 다음 프로젝트때는 &lt;code&gt;react-hook-form&lt;/code&gt;을 이용해보려고 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;느낀점&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;Claude Code CLI&lt;/code&gt;를 통해 리팩토링을 진행해 보았는데, 확실히 코드의 품질이 올라가는 걸 느꼈다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 사용할 때 원하는 목표가 있을 때 정확한 정보를 제공해야 하며, &lt;code&gt;AI&lt;/code&gt;가 짜준 코드를 그대로 쓴느게 아니라 직접 읽어보고 프로젝트에 적합한지 확인하는 과정도 필요하다는걸 느꼈다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 내가 생각하지 못했던 부분을 좀 더 보안하여 코드의 품질을 높여주며 확인하는 과정에서 자연스럽게 배워가는 부분도 많았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;AI&lt;/code&gt;를 무턱대고 사용하는건 지양하는게 좋지만, 잘 활용만 한다면 개발 실력을 향상시키는데도 도움이 되는 것 같다.&lt;/p&gt;</description>
      <category>NextJs</category>
      <author>seungjunn100</author>
      <guid isPermaLink="true">https://seungjunn100.tistory.com/30</guid>
      <comments>https://seungjunn100.tistory.com/entry/Nextjs-Claude-Code-CLI%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-%EB%A6%AC%ED%8C%A9%ED%86%A0%EB%A7%81#entry30comment</comments>
      <pubDate>Fri, 6 Mar 2026 16:25:14 +0900</pubDate>
    </item>
    <item>
      <title>[Next.js] 회원가입 시 PortOne API로 휴대폰 본인인증 구현하기</title>
      <link>https://seungjunn100.tistory.com/entry/Nextjs-%ED%9A%8C%EC%9B%90%EA%B0%80%EC%9E%85-%EC%8B%9C-PortOne-API%EB%A1%9C-%ED%9C%B4%EB%8C%80%ED%8F%B0-%EB%B3%B8%EC%9D%B8%EC%9D%B8%EC%A6%9D-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://developers.portone.io/opi/ko/extra/identity-verification/readme-v2?v=v2&quot;&gt;PortOne 본인인증 연동&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;PortOne API&lt;/code&gt;를 활용하여 회원가입 시 휴대폰 본인인증 기능을 구현하기 위해 위의 공식 문서를 참고하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;PortOne SDK 설치&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 &lt;code&gt;PortOne&lt;/code&gt; 본인인증을 사용하려면 브라우저 SDK를 먼저 설치해야 한다.&lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;npm i @portone/browser-sdk&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;설치 후에는 컴포넌트에서 &lt;code&gt;import&lt;/code&gt;해서 사용할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;clean&quot;&gt;&lt;code&gt;import * as PortOne from '@portone/browser-sdk/v2';&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;PortOne 관리자 콘솔 연동&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;PortOne&lt;/code&gt; 관리자 콘솔에서 회원가입을 진행한 후에 테스트 채널을 생성하면 &lt;code&gt;Store ID&lt;/code&gt;와 &lt;code&gt;Channel Key&lt;/code&gt;를 발급받을 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후 서버에서 &lt;code&gt;PortOne REST API&lt;/code&gt;를 사용하기 위해 &lt;code&gt;API Secret Key&lt;/code&gt;를 발급받으면 사전 준비가 완료된다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;portone-1.png&quot; data-origin-width=&quot;1693&quot; data-origin-height=&quot;773&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/1gfam/dJMcafFJV4A/kLTZBZ326nZmapnwQeKJyK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/1gfam/dJMcafFJV4A/kLTZBZ326nZmapnwQeKJyK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/1gfam/dJMcafFJV4A/kLTZBZ326nZmapnwQeKJyK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F1gfam%2FdJMcafFJV4A%2FkLTZBZ326nZmapnwQeKJyK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1693&quot; height=&quot;773&quot; data-filename=&quot;portone-1.png&quot; data-origin-width=&quot;1693&quot; data-origin-height=&quot;773&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;portone-2.png&quot; data-origin-width=&quot;1382&quot; data-origin-height=&quot;743&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bF7Yb1/dJMcahctlvu/sTpuES3km7pJnKsUp1B9X1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bF7Yb1/dJMcahctlvu/sTpuES3km7pJnKsUp1B9X1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bF7Yb1/dJMcahctlvu/sTpuES3km7pJnKsUp1B9X1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbF7Yb1%2FdJMcahctlvu%2FsTpuES3km7pJnKsUp1B9X1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1382&quot; height=&quot;743&quot; data-filename=&quot;portone-2.png&quot; data-origin-width=&quot;1382&quot; data-origin-height=&quot;743&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;Store ID&lt;/code&gt;는 어떤 서비스에서 요청한 인증인지 구분하기 위한 식별자이며, &lt;code&gt;Channel Key&lt;/code&gt;는 어떤 인증 채널(KG이니시스, NICE 등)을 사용할지 구분하기 위한 키로서 노출되어도 상관없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;API Secret Key&lt;/code&gt;는 민감 정보라 브라우저에 절대 노출되면 안 되고 서버에서만 사용해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;브라우저 구현 (본인인증 요청)&lt;/h2&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// components/auth/SignupForm.tsx
const handleCertify = async () =&amp;gt; {
  try {
    const res: CertifyActionState = await PortOne.requestIdentityVerification({
      storeId: 'store-...',
      channelKey: 'channel-key-...',
      identityVerificationId: `identity-verification-${crypto.randomUUID()}`,
    });

    // 1) 응답 자체가 없는 경우
    if (!res) {
      setCertifyMsg('응답 없음');
      return;
    }

    // 2) code가 존재하면 인증 과정 중 오류
    if (res?.code) {
      setCertifyMsg(`본인인증 실패: ${res.message}`);
      return;
    }

    // 3) 성공하면 identityVerificationId를 서버로 전달
    const certifyRes = await fetch('/api/certify', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ identityVerificationId: res.identityVerificationId }),
    });

    const certifyResData = await certifyRes.json();

    if (!certifyResData.success) {
      setCertifyMsg(certifyResData.message);
      return;
    }

    // 4) 최종 성공 UI 처리
    setIsCertified(true);
    setPhoneNumber(certifyResData.phoneNumber);
    setCertifyMsg('본인인증 완료되었습니다.');
  } catch (err) {
    console.error('본인인증 오류:', err);
  }
};&lt;/code&gt;&lt;/pre&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;code&gt;PortOne.requestIdentityVerification()&lt;/code&gt;로 본인인증 창을 띄운다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이 때, &lt;code&gt;identityVerificationId&lt;/code&gt;는 인증 건을 구분하는 &lt;code&gt;ID&lt;/code&gt;라서 요청/조회에 계속 쓰인다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;crypto.randomUUID()&lt;/code&gt;는 브라우저 &lt;code&gt;Web Crypto API&lt;/code&gt;가 제공하는 함수로서 충돌 가능성이 거의 없는 고유 &lt;code&gt;ID&lt;/code&gt;를 생성한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;응답에 &lt;code&gt;code&lt;/code&gt;가 포함되어 있으면 인증 에러가 발생하여 메세지와 함께 에러 처리를 해주었다.&lt;/li&gt;
&lt;li&gt;성공하면 &lt;code&gt;identityVerificationId&lt;/code&gt;를 서버로 보내 서버에서 완료 처리를 한다.&lt;/li&gt;
&lt;li&gt;서버에서 성공 시 응답받은 데이터로 &lt;code&gt;UI&lt;/code&gt;를 처리한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;브라우저에서는 본인인증 절차를 수행하고 &lt;code&gt;identityVerificationId&lt;/code&gt;를 전달받는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트는 사용자가 조작할 수 있는 환경이기 때문에 인증 결과 검증은 서버에서 &lt;code&gt;PortOne REST API&lt;/code&gt;를 호출하여 최종 확인한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;서버 구현 (REST API로 인증 검증/정보 획득)&lt;/h2&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// app/api/certify/route.ts
import { NextRequest, NextResponse } from 'next/server';

const PORTONE_API_SECRET = process.env.PORTONE_API_SECRET;

export async function POST(request: NextRequest) {
  try {
    const { identityVerificationId } = await request.json();

    const res = await fetch(`https://api.portone.io/identity-verifications/${identityVerificationId}`, {
      headers: {
        Authorization: `PortOne ${PORTONE_API_SECRET}`,
      },
    });

    if (!res.ok) throw new Error('본인인증 정보 조회 실패');

    const resData = await res.json();

    if (resData.status === 'VERIFIED') {
      return NextResponse.json({
        success: true,
        phoneNumber: resData.verifiedCustomer.phoneNumber,
      });
    }

    return NextResponse.json({
      success: false,
      message: '본인인증이 완료되지 않았습니다. 다시 시도해주세요.',
    });
  } catch (err) {
    console.error(err);
    return NextResponse.json({
      success: false,
      message: '본인인증 검증 중 오류가 발생했습니다. 다시 시도해주세요.',
    });
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;브라우저에서 받은 &lt;code&gt;identityVerificationId&lt;/code&gt;를 포함하여 &lt;code&gt;/identity-verifications/{identityVerificationId}&lt;/code&gt; 엔드포인트에 헤더의 인증 키에 &lt;code&gt;API Secret Key&lt;/code&gt;를 포함하여 요청을 보낸다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;응답 데이터의 &lt;code&gt;status&lt;/code&gt; 값은 &lt;code&gt;&quot;READY&quot; | &quot;VERIFIED&quot; | &quot;FAILED&quot;&lt;/code&gt;중 하나이며, 이 중 &lt;code&gt;&quot;VERIFIED&quot;&lt;/code&gt; 상태일 경우 본인인증이 정상적으로 완료된 상태로서 사용자의 데이터를 받아서 브라우저로 전달하여 &lt;code&gt;UI&lt;/code&gt;로 보여줄 수 있게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;code&gt;Route Handler&lt;/code&gt;에서 처리한 이유&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;API Secret Key&lt;/code&gt;는 &lt;code&gt;API&lt;/code&gt; 서버에 요청을 보낼 때 헤더에 인증 키로 포함하여 보내는 민감 정보이기 때문에 브라우저에 절대 노출하면 안 되며 인증 결과 조회는 반드시 서버에서 처리하는게 좋다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;code&gt;verifiedCustomer&lt;/code&gt;에서 얻을 수 있는 정보&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;verifiedCustomer&lt;/code&gt; 객체에는 본인인증을 통해 확인된 사용자 정보가 포함되어 있으며, 서비스 로직에서 필요에 따라 활용할 수 있다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;ci&lt;/code&gt; : 연계 정보(Connecting Information)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;di&lt;/code&gt; : 중복 가입 확인 정보(Duplication Information)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;name&lt;/code&gt; : 이름&lt;/li&gt;
&lt;li&gt;&lt;code&gt;gender&lt;/code&gt; : 성별&lt;/li&gt;
&lt;li&gt;&lt;code&gt;birthDate&lt;/code&gt; : 생년월일 (YYYY-MM-DD)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;operator&lt;/code&gt; : 통신사&lt;/li&gt;
&lt;li&gt;&lt;code&gt;phoneNumber&lt;/code&gt; : 숫자로만 구성된 전화번호 (&lt;code&gt;UI&lt;/code&gt;에서 포맷팅이 필요)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;isForeigner&lt;/code&gt; : 외국인 여부&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>NextJs</category>
      <author>seungjunn100</author>
      <guid isPermaLink="true">https://seungjunn100.tistory.com/29</guid>
      <comments>https://seungjunn100.tistory.com/entry/Nextjs-%ED%9A%8C%EC%9B%90%EA%B0%80%EC%9E%85-%EC%8B%9C-PortOne-API%EB%A1%9C-%ED%9C%B4%EB%8C%80%ED%8F%B0-%EB%B3%B8%EC%9D%B8%EC%9D%B8%EC%A6%9D-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0#entry29comment</comments>
      <pubDate>Wed, 4 Mar 2026 23:48:14 +0900</pubDate>
    </item>
    <item>
      <title>[Next.js] 회원가입 기능 구현</title>
      <link>https://seungjunn100.tistory.com/entry/Nextjs-%ED%9A%8C%EC%9B%90%EA%B0%80%EC%9E%85-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트의 회원가입 기능을 구현하며 왜 그런지 이해하며 전반적인 흐름을 정리해 보았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;회원가입 서버 / 클라이언트 컴포넌트 분리&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;signup.png&quot; data-origin-width=&quot;1476&quot; data-origin-height=&quot;707&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/uncS3/dJMcai3tgzH/EWyGdNTlPKBFXt4tqpVKpK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/uncS3/dJMcai3tgzH/EWyGdNTlPKBFXt4tqpVKpK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/uncS3/dJMcai3tgzH/EWyGdNTlPKBFXt4tqpVKpK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FuncS3%2FdJMcai3tgzH%2FEWyGdNTlPKBFXt4tqpVKpK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1476&quot; height=&quot;707&quot; data-filename=&quot;signup.png&quot; data-origin-width=&quot;1476&quot; data-origin-height=&quot;707&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;회원가입 페이지도 로그인 페이지와 같이 페이지의 레이아웃과 &lt;code&gt;metadata&lt;/code&gt;는 서버에서 렌더링하고, 입력 상태 관리(&lt;code&gt;useState&lt;/code&gt;), 폼 제출(&lt;code&gt;useActionState&lt;/code&gt;), 페이지 이동(&lt;code&gt;useRouter&lt;/code&gt;)이 필요한 폼 영역만 &lt;code&gt;Client Component&lt;/code&gt;로 분리하여 작업하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;회원가입 인증 흐름 설계&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;클라이언트 입력 검증 UX 설계&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;브라우저에서 사용자가 인풋에 데이터를 입력 시 정해놓은 형식에 맞게 작성할 수 있도록 &lt;code&gt;UI&lt;/code&gt;를 설계하였다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;export default function SignupForm() {
  const [name, setName] = useState('');
  const [nameError, setNameError] = useState('');
  const [nameTouched, setNameTouched] = useState(false);
  const [nameIsValid, setNameIsValid] = useState&amp;lt;boolean | null&amp;gt;(null);

  ...

  // 이름 입력시 형식 검증 (onChange)
  const handleNameChange = (event: React.ChangeEvent&amp;lt;HTMLInputElement&amp;gt;) =&amp;gt; {
    const newName = event.target.value;
    setName(newName);

    if (nameTouched) {
      if (newName.trim() === '' || newName.length &amp;lt;= 1) {
        setNameIsValid(false);
        setNameError('이름은 2글자 이상 입력해주세요.');
      } else if (newName.length &amp;gt; 1) {
        setNameIsValid(true);
        setNameError('');
      }
    }
  };

  // 이름 입력시 형식 검증 (onBlur)
  const handleNameBlur = () =&amp;gt; {
    setNameTouched(true);

    if (name.trim() === '' || name.length &amp;lt;= 1) {
      setNameIsValid(false);
      setNameError('이름은 2글자 이상 입력해주세요.');
    } else if (name.length &amp;gt; 1) {
      setNameIsValid(true);
    }
  };

  ...

  return (
    &amp;lt;AuthInput label=&quot;이름&quot; name=&quot;name&quot; type=&quot;text&quot; placeholder=&quot;이름을 입력하세요.&quot; value={name} onChange={handleNameChange} onBlur={handleNameBlur} message={nameError} isValid={nameIsValid} /&amp;gt;

    ...
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하나의 인풋에 총 4개의 상태를 관리하도록 하였다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;데이터를 입력하기 위해 인풋을 클릭했을 때, &lt;code&gt;nameTouched&lt;/code&gt;의 상태를 &lt;code&gt;true&lt;/code&gt;로 설정
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;처음 입력 시에는 &lt;code&gt;onBlure&lt;/code&gt;로 포커스가 이동했을 때 형식 검증&lt;/li&gt;
&lt;li&gt;다음 입력 시에는 &lt;code&gt;nameTouched&lt;/code&gt;의 상태가 &lt;code&gt;true&lt;/code&gt;라면 &lt;code&gt;onChange&lt;/code&gt;로 실시간으로 형식 검증&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;이름이 입력될 때 인풋에 입력되고 있는 값을 새로운 이름으로 지정하기 위해 &lt;code&gt;setName(newName)&lt;/code&gt;로 이름 상태를 설정&lt;/li&gt;
&lt;li&gt;형식 검증 시 일치하지 않는다면, &lt;code&gt;nameIsValid&lt;/code&gt;의 상태를 &lt;code&gt;false&lt;/code&gt;로 설정&lt;/li&gt;
&lt;li&gt;&lt;code&gt;nameIsValid&lt;/code&gt;의 상태가 &lt;code&gt;false&lt;/code&gt;일 때, 에러 메세지를 띄우기 위해 &lt;code&gt;setNameError('이름은 2글자 이상 입력해주세요.')&lt;/code&gt;로 메세지 상태를 설정&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 4개의 상태를 관리하여 처음 입력시에 &lt;code&gt;onBlure&lt;/code&gt;로 포커스가 이동했을 때 검증을 하고 형식이 일치한다면 바로 다음으로 넘어가고, 일치하지 않는다면 다음 입력 시에는 &lt;code&gt;onChange&lt;/code&gt;로 실시간으로 검증을 하여 사용자가 입력을 빠르고 정확하게 완료할 수 있도록 하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;서버 액션 구현&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;회원가입은 단순 조회가 아니라 계정을 생성하여 서버 데이터의 상태를 변경하는 요청이므로, 서버에서 처리하는 편이 구조적으로 안정적이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 서버 액션을 사용하면 브라우저가 직접 외부 &lt;code&gt;API&lt;/code&gt;를 호출하는 구조를 피할 수 있어 운영/보안 측면에서 유리하다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;'use server';

import { ErrorRes, LoginActionState, UserActionState } from '@/types/auth';
import { cookies } from 'next/headers';

const API_URL = process.env.NEXT_PUBLIC_API_URL;
const CLIENT_ID = process.env.NEXT_PUBLIC_CLIENT_ID || '';

export async function signup(state: UserActionState, formData: FormData): Promise&amp;lt;UserActionState&amp;gt; {
  ...&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;이메일 정규 표현식 및 중복 확인&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;정규 표현식&lt;/h4&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;// 영어/숫자/._%+- 로 이루어진 문자 1개 이상
// + @
// + 영어/숫자/.- 로 이루어진 문자 1개 이상
// + .
// + 영어 2글자 이상
const emailExp = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;

// 이메일 형식 검증
if (!emailExp.test(email)) {
  setEmailIsValid(false);
  setEmailError('올바른 이메일 형식이 아닙니다.');
} else {
  setEmailIsValid(true);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이메일은 해당 정규 표현식을 통과해야만 중복 확인 버튼이 활성화 되어 중복 확인을 할 수 있다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;중복 확인&lt;/h4&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// components/auth/SignupForm.tsx

...

// 이메일 중복확인
const handleEmailCheck = async () =&amp;gt; {
  const res = await emailCheck(email);

  if (!res.ok) {
    setEmailChecked(false);
    setEmailCheckedMsg(res.message);
  } else {
    setEmailChecked(true);
    setEmailCheckedMsg('사용 가능한 이메일입니다.');
  }
};

...&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// lib/api/auth.ts
import { EmailCheckRes } from '@/types/auth';

const API_URL = process.env.NEXT_PUBLIC_API_URL || '';
const CLIENT_ID = process.env.NEXT_PUBLIC_CLIENT_ID || '';

export async function emailCheck(email: string): Promise&amp;lt;EmailCheckRes&amp;gt; {
  try {
    const res = await fetch(`${API_URL}/users/email?email=${encodeURIComponent(email)}`, {
      headers: {
        'Client-Id': CLIENT_ID,
      },
    });

    const resJson = await res.json();

    return resJson;
  } catch (err) {
    console.error(err);
    return { ok: 0, message: '네트워크 오류로 이메일 중복 확인에 실패했습니다.' };
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이메일은 &lt;code&gt;URL&lt;/code&gt; 쿼리로 전달되므로, 특수문자를 안전하게 인코딩하기 위해 &lt;code&gt;encodeURIComponent&lt;/code&gt;를 적용했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;응답 성공 시 &lt;code&gt;emailChecked&lt;/code&gt; 상태를 &lt;code&gt;true&lt;/code&gt;로 만들어, 중복 확인이 완료된 이메일이라는 상태를 보장했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;회원가입 요청&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트에서 폼 입력 데이터가 모두 &lt;code&gt;true&lt;/code&gt;가 되었을 때 회원가입 요청 버튼이 활성화될 수 있도록 설정하였다.&lt;/p&gt;
&lt;pre class=&quot;processing&quot;&gt;&lt;code&gt;// 폼 입력 데이터 모두 true 시
const isFormValid = nameIsValid &amp;amp;&amp;amp; emailChecked &amp;amp;&amp;amp; pwdCheckIsValid &amp;amp;&amp;amp; pwdIsValid &amp;amp;&amp;amp; isCertified;

...

&amp;lt;BaseButton type=&quot;submit&quot; size=&quot;xl&quot; variant={!isFormValid || isPending ? 'disabled' : 'primary'} disabled={!isFormValid || isPending} className=&quot;mt-4 md:mt-6&quot;&amp;gt;
  {isPending ? &amp;lt;BeatLoader size={10} color=&quot;#fff&quot; /&amp;gt; : '회원가입'}
&amp;lt;/BaseButton&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그인 요청에서 &lt;code&gt;body&lt;/code&gt;를 구성하는 방법과 회원가입 요청에서 &lt;code&gt;body&lt;/code&gt;를 구성하는 방법이 다르다.&lt;/p&gt;
&lt;pre class=&quot;dart&quot;&gt;&lt;code&gt;// 로그인 요청
const body = Object.fromEntries(formdata.entries());

// 회원가입 요청
const body = {
  type: formData.get('type') || 'user',
  name: formData.get('name'),
  email: formData.get('email'),
  password: formData.get('password'),
  phone: formData.get('phone'),
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 다른 이유는 회원가입에서 비밀번호 확인같은 필드는 클라이언트에서만 확인하기 위해 설정했던 부분이고, 서버에서 필요로하는 필드만 선택해서 보내기 위해 명시적으로 값을 꺼내는 방식을 사용하였다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;const body = {
  type: formData.get('type') || 'user',
  name: formData.get('name'),
  email: formData.get('email'),
  password: formData.get('password'),
  phone: formData.get('phone'),
};

...

res = await fetch(`${API_URL}/users`, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Client-Id': CLIENT_ID,
  },
  body: JSON.stringify(body),
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그인 요청과 마찬가지로 데이터를 서버에서 읽을 수 있게 객체로 만들어서 &lt;code&gt;JSON&lt;/code&gt; 형태로 바꾸어 전달해주어야 한다.&lt;/p&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;  useEffect(() =&amp;gt; {
    if (userState?.ok) {
      alert(`회원가입이 완료되었습니다!`);
      router.replace('/login');
    }
  }, [userState, router]);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;응답에 성공하여 회원가입이 완료되면 로그인 페이지로 이동하도록 하였다.&lt;/p&gt;</description>
      <category>NextJs</category>
      <author>seungjunn100</author>
      <guid isPermaLink="true">https://seungjunn100.tistory.com/28</guid>
      <comments>https://seungjunn100.tistory.com/entry/Nextjs-%ED%9A%8C%EC%9B%90%EA%B0%80%EC%9E%85-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84#entry28comment</comments>
      <pubDate>Wed, 4 Mar 2026 23:36:22 +0900</pubDate>
    </item>
    <item>
      <title>[Next.js] 카카오 소셜 로그인 기능 구현</title>
      <link>https://seungjunn100.tistory.com/entry/Nextjs-%EC%B9%B4%EC%B9%B4%EC%98%A4-%EC%86%8C%EC%85%9C-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://developers.kakao.com/docs/latest/ko/index&quot;&gt;Kakao Developers 문서&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;카카오 로그인을 사용하기 위해 위의 공식 문서를 참고하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 카카오 로그인 과정에 대해서 쉽게 이해할 수 있는 이미지를 확인할 수 있었다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;kakao-login-process.png&quot; data-origin-width=&quot;1600&quot; data-origin-height=&quot;1552&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/oSJfI/dJMcaaqS60W/NlYkVUHVvOtX5kKmc4LiuK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/oSJfI/dJMcaaqS60W/NlYkVUHVvOtX5kKmc4LiuK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/oSJfI/dJMcaaqS60W/NlYkVUHVvOtX5kKmc4LiuK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FoSJfI%2FdJMcaaqS60W%2FNlYkVUHVvOtX5kKmc4LiuK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1600&quot; height=&quot;1552&quot; data-filename=&quot;kakao-login-process.png&quot; data-origin-width=&quot;1600&quot; data-origin-height=&quot;1552&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;간단하게 설명하자면,&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;사용자가 카카오 로그인 버튼을 누르면, 카카오 인증 서버로 이동한다.&lt;/li&gt;
&lt;li&gt;로그인 및 동의를 완료하면, 카카오는 인가 코드를 포함한 &lt;code&gt;Redirect URI&lt;/code&gt;로 다시 돌려보낸다.&lt;/li&gt;
&lt;li&gt;서비스 서버는 이 인가 코드로 카카오에 토큰 발급을 요청한다.&lt;/li&gt;
&lt;li&gt;발급받은 토큰으로 사용자 정보를 조회한 뒤, 기존 회원 여부를 확인한다.&lt;/li&gt;
&lt;li&gt;회원 가입 또는 로그인 처리를 완료하고, 서비스 세션을 생성한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 클라이언트는 인가 코드만 받고 실제 로그인 완료 처리는 서버에서 진행된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;카카오 개발자 콘솔에서 앱 설정&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 카카오 로그인을 사용하기 위해서는 카카오 개발자 콘솔에서 앱 설정부터 진행해야 한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;app-settings-4.png&quot; data-origin-width=&quot;802&quot; data-origin-height=&quot;754&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bzrpys/dJMcahXQc7P/xczj3GOJD3gULuD6Jmc2f1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bzrpys/dJMcahXQc7P/xczj3GOJD3gULuD6Jmc2f1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bzrpys/dJMcahXQc7P/xczj3GOJD3gULuD6Jmc2f1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbzrpys%2FdJMcahXQc7P%2Fxczj3GOJD3gULuD6Jmc2f1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;802&quot; height=&quot;754&quot; data-filename=&quot;app-settings-4.png&quot; data-origin-width=&quot;802&quot; data-origin-height=&quot;754&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 앱의 기본 정보들을 입력하고 앱을 생성해야 아래와 같이 기본 정보들이 사용자에게 노출되어 제공된다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;app-settings-1.png&quot; data-origin-width=&quot;1600&quot; data-origin-height=&quot;1026&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bNpTjg/dJMcacI1mFR/TMX2EMa7KKwD9k1lusocHK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bNpTjg/dJMcacI1mFR/TMX2EMa7KKwD9k1lusocHK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bNpTjg/dJMcacI1mFR/TMX2EMa7KKwD9k1lusocHK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbNpTjg%2FdJMcacI1mFR%2FTMX2EMa7KKwD9k1lusocHK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1600&quot; height=&quot;1026&quot; data-filename=&quot;app-settings-1.png&quot; data-origin-width=&quot;1600&quot; data-origin-height=&quot;1026&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 카카오 로그인 시 사이트에서 제공받을 수 있는 정보들을 아래와 같이 동의 항목을 통해 설정할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러면 카카오 로그인 동의 화면에서 동의 항목을 체크하여 사이트에서 정보들을 활용할 수 있게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;권한은 앱의 용도에 따라서 권한이 달라진다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;app-settings-2.png&quot; data-origin-width=&quot;1059&quot; data-origin-height=&quot;729&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Ek1Qb/dJMcacI1mFZ/4pPNaPc6zzhAr3wI7FiyC1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Ek1Qb/dJMcacI1mFZ/4pPNaPc6zzhAr3wI7FiyC1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Ek1Qb/dJMcacI1mFZ/4pPNaPc6zzhAr3wI7FiyC1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FEk1Qb%2FdJMcacI1mFZ%2F4pPNaPc6zzhAr3wI7FiyC1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1059&quot; height=&quot;729&quot; data-filename=&quot;app-settings-2.png&quot; data-origin-width=&quot;1059&quot; data-origin-height=&quot;729&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 운영중인 사이트의 도메인 주소를 등록하고 카카오 로그인 시 리다이렉트될 &lt;code&gt;URI&lt;/code&gt;까지 설정 해준다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;app-settings-3.png&quot; data-origin-width=&quot;570&quot; data-origin-height=&quot;696&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cjq6ei/dJMb996z8IC/3AShRF1Zdyeh9CpQMT53F0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cjq6ei/dJMb996z8IC/3AShRF1Zdyeh9CpQMT53F0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cjq6ei/dJMb996z8IC/3AShRF1Zdyeh9CpQMT53F0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcjq6ei%2FdJMb996z8IC%2F3AShRF1Zdyeh9CpQMT53F0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;570&quot; height=&quot;696&quot; data-filename=&quot;app-settings-3.png&quot; data-origin-width=&quot;570&quot; data-origin-height=&quot;696&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막으로 프로젝트에서 자바스크립트 키와 리다이렉트 &lt;code&gt;URI&lt;/code&gt;를 &lt;code&gt;.env&lt;/code&gt; 파일에 선언해주면 설정은 마무리 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;참고로, 프론트엔드에서는 &lt;code&gt;JavaScript 키&lt;/code&gt;를 사용해 &lt;code&gt;SDK&lt;/code&gt;를 초기화하고, 인가 코드로 토큰을 요청하는 작업은 서버에서 &lt;code&gt;REST API 키&lt;/code&gt;를 사용하여 수행해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;REST API 키&lt;/code&gt;는 서버 전용 키이므로 브라우저에 노출되면 안 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;기능 구현 흐름&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://developers.kakao.com/tool/demo/login/login&quot;&gt;Kakao Developers 문서 - JavaScript SDK에서 제공하는 기능&lt;/a&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;KakaoLogin 버튼&lt;/h3&gt;
&lt;pre class=&quot;xml&quot;&gt;&lt;code&gt;export default function KakaoLogin() {
  function loginWithKakao() {
    window.Kakao.Auth.authorize({
      redirectUri: process.env.NEXT_PUBLIC_KAKAO_REDIRECT_URI!, // 앱에 등록된 카카오 로그인에서 사용할 Redirect URI 설정
    });
  }

  return (
    &amp;lt;&amp;gt;
      &amp;lt;Script
        src=&quot;https://t1.kakaocdn.net/kakao_js_sdk/2.7.7/kakao.min.js&quot;
        strategy=&quot;afterInteractive&quot;
        onLoad={() =&amp;gt; {
          if (!window.Kakao.isInitialized()) {
            window.Kakao.init(process.env.NEXT_PUBLIC_KAKAO_JS_KEY!); // 앱에 등록된 카카오 로그인에서 사용할 JavaScript 키 설정
          }
        }}
      /&amp;gt;

      &amp;lt;button type=&quot;button&quot; onClick={loginWithKakao} className=&quot;flex justify-center items-center gap-2 w-full h-11.5 mt-6 font-medium text-[16px] shadow-[0_0_15px_rgba(0,0,0,0.2)] rounded-full cursor-pointer md:h-13 md:text-[18px] text-[#3C1E1E] bg-[#FFEB00]&quot;&amp;gt;
        &amp;lt;Image width={25} height={25} src=&quot;/images/kakao_symbol.png&quot; alt=&quot;카카오 로고&quot; /&amp;gt;
        카카오 로그인
      &amp;lt;/button&amp;gt;
    &amp;lt;/&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 컴포넌트가 렌더링 되면서 카카오가 제공하는 &lt;code&gt;JavaScript SDK&lt;/code&gt; 파일을 브라우저에 로드하게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 &lt;code&gt;&amp;lt;Script&amp;gt;&lt;/code&gt;가 로드되면 브라우저 전역 객체에 &lt;code&gt;Kakao&lt;/code&gt; 네임스페이스가 추가되어 &lt;code&gt;window.Kakao&lt;/code&gt;를 통해 &lt;code&gt;SDK&lt;/code&gt; 기능을 사용할 수 있게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 &lt;code&gt;Window&lt;/code&gt; 타입에는 &lt;code&gt;Kakao&lt;/code&gt;가 없어서 아래와 같은 에러가 발생한다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Property 'Kakao' does not exist on type 'Window &amp;amp; typeof globalThis'&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제를 해결하기 위해 &lt;code&gt;Window&lt;/code&gt;에 &lt;code&gt;Kakao&lt;/code&gt;가 있다는걸 인식 시켜주도록 타입을 설정하면 해결할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;// 전역 스코프에 타입을 확장
declare global {
  // 기존 Window 인터페이스에 Kakao 속성 추가
  interface Window {
    // 실제로 쓰는 함수들만 최소한으로 타입 정의
    Kakao: {
      init: (apiKey: string) =&amp;gt; void;
      isInitialized: () =&amp;gt; boolean;
      Auth: {
        authorize: (options: { redirectUri: string }) =&amp;gt; void;
      };
    };
  }
}

export {};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 &lt;code&gt;&amp;lt;Script&amp;gt;&lt;/code&gt;가 로드될 때 자바스크립트 키로 초기화되지 않았다면, 자바스크립트 키로 초기화된 상태로 로드될 수 있도록 설정해주어야 해당 앱으로 요청을 할 수 있게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 다음 카카오 로그인 버튼을 클릭하게되면 &lt;code&gt;loginWithKakao&lt;/code&gt; 함수가 실행되면서 카카오 인증 서버로 이동해서 로그인 + 동의 받고, 성공하면 설정한 &lt;code&gt;Redirect URI&lt;/code&gt;로 인가 코드가 포함된 상태로 리다이렉트된다.&lt;/p&gt;
&lt;pre class=&quot;awk&quot;&gt;&lt;code&gt;http://localhost:3000/login/kakao?code=VAp6kZ7cRXr6wJ9r2U0xkwiKoRa8v-exXJlUHDkbf-7nhj9uMjTBvwAAAAQKFxAvAAABnLOsBnQq17LwdM8QAg&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Kakao Login 페이지&lt;/h3&gt;
&lt;pre class=&quot;xquery&quot;&gt;&lt;code&gt;export default function KakaoLoginCallback() {
  const setUser = useUserStore((state) =&amp;gt; state.setUser);
  const searchParams = useSearchParams();
  const code = searchParams.get('code');
  const router = useRouter();

  useEffect(() =&amp;gt; {
    async function login() {
      if (code) {
        const response = await loginKakao(code);

        if (response?.ok) {
          setUser({
            _id: response.item._id,
            name: response.item.name,
          });
          alert(`안녕하세요, ${response.item.name}님!\n로그인이 완료되었습니다!`);
          router.replace('/');
        }
      }
    }
    login();
  }, [code, setUser, router]);

  return (
    &amp;lt;&amp;gt;
      &amp;lt;div className=&quot;flex items-center justify-center h-screen&quot;&amp;gt;
        &amp;lt;div className=&quot;flex flex-col justify-center items-center w-xs h-50 px-4 bg-[#FFEB00] rounded-2xl&quot;&amp;gt;
          &amp;lt;Image width={40} height={40} src=&quot;/images/kakao_symbol.png&quot; alt=&quot;카카오 로고&quot; className=&quot;mb-4&quot; /&amp;gt;
          &amp;lt;p className=&quot;text-[#3C1E1E] font-medium text-[18px] mb-3&quot;&amp;gt;카카오 로그인 중&amp;lt;/p&amp;gt;
          &amp;lt;BeatLoader size={12} color=&quot;#3C1E1E&quot; /&amp;gt;
        &amp;lt;/div&amp;gt;
      &amp;lt;/div&amp;gt;
    &amp;lt;/&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러면 &lt;code&gt;/login/kakao&lt;/code&gt; 페이지에서는 &lt;code&gt;Redirect URI&lt;/code&gt;로 받은 인가 코드를 추출하고 컴포넌트가 마운트되면, &lt;code&gt;loginKakao&lt;/code&gt; 함수에 인가 코드를 매개변수로 담아 &lt;code&gt;API&lt;/code&gt; 서버에 요청하게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;응답에 성공하면, &lt;code&gt;API&lt;/code&gt; 서버가 카카오에 토큰 발급 요청을 수행하고 &lt;code&gt;accessToken&lt;/code&gt;과 &lt;code&gt;refreshToken&lt;/code&gt;을 발급받는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후 &lt;code&gt;API&lt;/code&gt; 서버에서는 해당 토큰으로 사용자 정보 조회 &lt;code&gt;API&lt;/code&gt;를 호출하여, 앱 설정에서 지정한 동의 항목에 해당하는 사용자 정보를 전달받아 세팅할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;loginKakao 서버 액션&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;export async function loginKakao(code: string): Promise&amp;lt;LoginActionState&amp;gt; {
  let res: Response;
  let data: LoginActionState;

  const body = {
    code,
    redirect_uri: process.env.NEXT_PUBLIC_KAKAO_REDIRECT_URI,
  };

  try {
    res = await fetch(`${API_URL}/users/login/kakao`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Client-Id': CLIENT_ID,
      },
      body: JSON.stringify(body),
    });

    data = await res.json();

  if (data?.ok) {
    // accessToken/refreshToken을 httpOnly cookie로 저장
    ...
  }

  ...
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트에서 토큰을 직접 요청할 경우 &lt;code&gt;REST API&lt;/code&gt; 키가 노출될 수 있으므로, 인가 코드까지만 클라이언트에서 받고 토큰 교환은 서버에서 수행하도록 설계하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인가 코드를 받은 &lt;code&gt;loginKakao&lt;/code&gt; 함수는 인가 코드와 &lt;code&gt;Redirect URI&lt;/code&gt;를 포함하여 &lt;code&gt;API&lt;/code&gt; 요청을 보내 응답에 성공하여 토큰을 받으면 쿠키로 저장하도록 카카오 로그인 기능을 구현하였다.&lt;/p&gt;</description>
      <category>NextJs</category>
      <author>seungjunn100</author>
      <guid isPermaLink="true">https://seungjunn100.tistory.com/27</guid>
      <comments>https://seungjunn100.tistory.com/entry/Nextjs-%EC%B9%B4%EC%B9%B4%EC%98%A4-%EC%86%8C%EC%85%9C-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84#entry27comment</comments>
      <pubDate>Wed, 4 Mar 2026 00:07:56 +0900</pubDate>
    </item>
    <item>
      <title>[Next.js] 로그인 상태에 따른 헤더 메뉴 깜빡임 문제 해결 (feat. Hydration)</title>
      <link>https://seungjunn100.tistory.com/entry/Nextjs-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EC%83%81%ED%83%9C%EC%97%90-%EB%94%B0%EB%A5%B8-%ED%97%A4%EB%8D%94-%EB%A9%94%EB%89%B4-%EA%B9%9C%EB%B9%A1%EC%9E%84-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0-feat-Hydration</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트의 헤더에 네비게이션을 로그인 상태에 따라 다르게 설정하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그인 되지 않은 상태라면 : &quot;로그인 / 회원가입&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그인 된 상태라면 : &quot;마이페이지 / 구독하기&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 로그인이 된 상태일 때, 새로고침을 하면 아주 짧은 순간 &quot;로그인 / 회원가입&quot; &lt;code&gt;UI&lt;/code&gt;가 먼저 렌더링 되고 잠시 후에 &quot;마이페이지 / 구독하기&quot; &lt;code&gt;UI&lt;/code&gt;로 바뀌는 현상이 발생했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 현상은 &lt;code&gt;Hydration&lt;/code&gt; 과정에서 발생한 문제이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Hydration이란&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버에서 미리 생성된 &lt;code&gt;HTML&lt;/code&gt;에, 브라우저에서 내려받은 &lt;code&gt;JS&lt;/code&gt;가 실행되며 이벤트 리스너와 상태를 연결해 인터랙션 가능한 리액트 애플리케이션으로 완성하는 과정이다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;동작 과정&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;빌드 시&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;작성한 &lt;code&gt;JSX(TSX)&lt;/code&gt; 코드는 &lt;code&gt;SWC&lt;/code&gt;에 의해 &lt;code&gt;JS&lt;/code&gt;로 변환된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후 서버에서 실행될 코드와 브라우저에서 실행될 코드가 각각 번들링된다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;서버&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리액트 컴포넌트가 실행되어 &lt;code&gt;HTML&lt;/code&gt;을 생성하고 완성된 &lt;code&gt;HTML&lt;/code&gt;을 브라우저로 전송한다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;브라우저&lt;/h4&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;브라우저가 서버로 부터 받은 &lt;code&gt;HTML&lt;/code&gt;을 파싱하면서 사용자가 화면을 볼 수 있게 된다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;HTML&lt;/code&gt; 파싱과 병렬로 &lt;code&gt;JS&lt;/code&gt; 번들 다운로드가 진행된다.&lt;/li&gt;
&lt;li&gt;다운로드된 &lt;code&gt;JS&lt;/code&gt; 번들이 실행된다.&lt;/li&gt;
&lt;li&gt;리액트 런타임이 실행되고, 컴포넌트 함수들이 실행되어 가상 &lt;code&gt;DOM&lt;/code&gt; 트리를 생성한다.&lt;/li&gt;
&lt;li&gt;생성된 가상 &lt;code&gt;DOM&lt;/code&gt;을 기존 서버 &lt;code&gt;DOM&lt;/code&gt;과 매칭하여 새로 그리지 않고 재사용하며, 각 노드에 리액트 내부 구조를 연결한다.&lt;/li&gt;
&lt;li&gt;각 &lt;code&gt;DOM&lt;/code&gt; 요소에 이벤트 리스너를 등록하여 완전한 리액트 앱이 동작하게 된다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 과정에서 클라이언트 상태(&lt;code&gt;persist&lt;/code&gt;)가 아직 준비되지 않은 경우, 서버에서 렌더링된 &lt;code&gt;HTML&lt;/code&gt;과 클라이언트 상태가 일시적으로 불일치하여 &lt;code&gt;UI&lt;/code&gt; 깜빡임이 발생할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;새로고침 시 UI가 깜빡인 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;헤더 컴포넌트에서 &lt;code&gt;user&lt;/code&gt;의 로그인 상태 여부에 따라 메뉴를 다르게 설정하기 위해 전역 상태 관리인 &lt;code&gt;Zustand&lt;/code&gt;의 스토어를 생성해 사용하였다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// 유저 스토어
// 사용자 정보 스토어의 초기값과 액션(함수) 정의
const UserStore: StateCreator&amp;lt;UserStoreState&amp;gt; = (set) =&amp;gt; ({
  user: null,
  setUser: (user: User | null) =&amp;gt; set({ user }),
  resetUser: () =&amp;gt; set({ user: null }),
});

// 사용자 정보를 세션스토리지에 저장
const useUserStore = create&amp;lt;UserStoreState&amp;gt;()(
  persist(UserStore, {
    name: 'user',
    storage: createJSONStorage(() =&amp;gt; sessionStorage),
  })
);&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// 헤더 컴포넌트
export default function HeaderInner() {
  const user = useUserStore((state) =&amp;gt; state.user);
  ...

  return (
    &amp;lt;ul&amp;gt;
      &amp;lt;li&amp;gt;&amp;lt;Link href=&quot;/survey&quot;&amp;gt;AI 추천받기&amp;lt;/Link&amp;gt;&amp;lt;/li&amp;gt;
      &amp;lt;li&amp;gt;&amp;lt;Link href=&quot;/products&quot;&amp;gt;영양제 정보&amp;lt;/Link&amp;gt;&amp;lt;/li&amp;gt;
      {user ? (
        &amp;lt;&amp;gt;
          &amp;lt;li&amp;gt;&amp;lt;Link href=&quot;/mypage&quot;&amp;gt;마이페이지&amp;lt;/Link&amp;gt;&amp;lt;/li&amp;gt;
          &amp;lt;li&amp;gt;&amp;lt;Link href=&quot;/subscription&quot;&amp;gt;구독하기&amp;lt;/Link&amp;gt;&amp;lt;/li&amp;gt;
        &amp;lt;/&amp;gt;
      ) : (
        &amp;lt;&amp;gt;
          &amp;lt;li&amp;gt;&amp;lt;Link href=&quot;/login&quot;&amp;gt;로그인&amp;lt;/Link&amp;gt;&amp;lt;/li&amp;gt;
          &amp;lt;li&amp;gt;&amp;lt;Link href=&quot;/signup&quot;&amp;gt;회원가입&amp;lt;/Link&amp;gt;&amp;lt;/li&amp;gt;
        &amp;lt;/&amp;gt;
      )}
    &amp;lt;/ul&amp;gt;
  );
  ...&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 여기서 문제가 발생했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;새로고침 직후에는 &lt;code&gt;persist&lt;/code&gt;가 스토리지에 저장된 &lt;code&gt;user&lt;/code&gt; 값을 아직 가져오기 전이라, 초기값(&lt;code&gt;null&lt;/code&gt;)으로 먼저 렌더링된다. 이후 스토리지에서 저장된 값이 적용되면서 상태가 변경되고, 그로 인해 &lt;code&gt;UI&lt;/code&gt;가 한 번 더 렌더링되어 깜빡임이 발생했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;상태 적용 완료 이후에만 렌더링하도록 개선&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 해결하기 위해, 스토리지에 저장된 &lt;code&gt;user&lt;/code&gt; 값이 아직 적용되기 전에는 헤더 메뉴를 렌더링하지 않고, 저장된 값이 적용된 이후에만 렌더링하도록 &lt;code&gt;hydrated&lt;/code&gt; 상태를 추가하였다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;const UserStore = (set) =&amp;gt; ({
  user: null,
  hydrated: false,
  setUser: (user) =&amp;gt; set({ user }),
  resetUser: () =&amp;gt; set({ user: null }),
  setHydrated: () =&amp;gt; set({ hydrated: true }),
});

const useUserStore = create&amp;lt;UserStoreState&amp;gt;()(
  persist(UserStore, {
    name: 'user',
    storage: createJSONStorage(() =&amp;gt; sessionStorage),
    onRehydrateStorage: () =&amp;gt; (state) =&amp;gt; {
      state?.setHydrated();
    },
  })
);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;persist&lt;/code&gt;는 페이지가 새로고침되면 다음 순서로 동작한다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;컴포넌트가 먼저 마운트되고 초기 상태(&lt;code&gt;user: null&lt;/code&gt;)로 렌더링된다.&lt;/li&gt;
&lt;li&gt;이후 &lt;code&gt;sessionStorage&lt;/code&gt;에 저장된 값을 읽어온다.&lt;/li&gt;
&lt;li&gt;읽어온 값을 스토어에 적용한다.&lt;/li&gt;
&lt;li&gt;적용이 완료되면 &lt;code&gt;onRehydrateStorage&lt;/code&gt;의 내부 함수가 실행된다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 시점에 &lt;code&gt;setHydrated()&lt;/code&gt;를 호출하여 &lt;code&gt;hydrated&lt;/code&gt;를 &lt;code&gt;true&lt;/code&gt;로 변경한다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// 헤더 컴포넌트
export default function HeaderInner() {
  const user = useUserStore((state) =&amp;gt; state.user);
  const hydrated = useUserStore((state) =&amp;gt; state.hydrated);
  ...

  return (
    &amp;lt;&amp;gt;
      {hydrated ? (
        &amp;lt;ul&amp;gt;
          &amp;lt;li&amp;gt;&amp;lt;Link href=&quot;/survey&quot;&amp;gt;AI 추천받기&amp;lt;/Link&amp;gt;&amp;lt;/li&amp;gt;
          &amp;lt;li&amp;gt;&amp;lt;Link href=&quot;/products&quot;&amp;gt;영양제 정보&amp;lt;/Link&amp;gt;&amp;lt;/li&amp;gt;
          {user ? (
            &amp;lt;&amp;gt;
              &amp;lt;li&amp;gt;&amp;lt;Link href=&quot;/mypage&quot;&amp;gt;마이페이지&amp;lt;/Link&amp;gt;&amp;lt;/li&amp;gt;
              &amp;lt;li&amp;gt;&amp;lt;Link href=&quot;/subscription&quot;&amp;gt;구독하기&amp;lt;/Link&amp;gt;&amp;lt;/li&amp;gt;
            &amp;lt;/&amp;gt;
          ) : (
            &amp;lt;&amp;gt;
              &amp;lt;li&amp;gt;&amp;lt;Link href=&quot;/login&quot;&amp;gt;로그인&amp;lt;/Link&amp;gt;&amp;lt;/li&amp;gt;
              &amp;lt;li&amp;gt;&amp;lt;Link href=&quot;/signup&quot;&amp;gt;회원가입&amp;lt;/Link&amp;gt;&amp;lt;/li&amp;gt;
            &amp;lt;/&amp;gt;
          )}
        &amp;lt;/ul&amp;gt;
      ) : null}
    &amp;lt;/&amp;gt;
  );
  ...&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 헤더의 메뉴도 &lt;code&gt;onRehydrateStorage&lt;/code&gt;가 실행되어 &lt;code&gt;hydrated&lt;/code&gt;를 &lt;code&gt;true&lt;/code&gt;로 변경되기 전까지 메뉴를 보여주지 않고, &lt;code&gt;true&lt;/code&gt;로 변경되면 전체 메뉴를 보여주는 식으로 수정하였다.&lt;/p&gt;</description>
      <category>NextJs</category>
      <author>seungjunn100</author>
      <guid isPermaLink="true">https://seungjunn100.tistory.com/26</guid>
      <comments>https://seungjunn100.tistory.com/entry/Nextjs-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EC%83%81%ED%83%9C%EC%97%90-%EB%94%B0%EB%A5%B8-%ED%97%A4%EB%8D%94-%EB%A9%94%EB%89%B4-%EA%B9%9C%EB%B9%A1%EC%9E%84-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0-feat-Hydration#entry26comment</comments>
      <pubDate>Tue, 3 Mar 2026 23:17:11 +0900</pubDate>
    </item>
    <item>
      <title>[Next.js] 로그인 기능 구현</title>
      <link>https://seungjunn100.tistory.com/entry/Nextjs-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트의 로그인 기능을 구현하며 왜 그런지 이해하며 전반적인 흐름을 정리해 보았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;로그인 서버 / 클라이언트 컴포넌트 분리&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;login-01.png&quot; data-origin-width=&quot;1660&quot; data-origin-height=&quot;946&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/783qK/dJMcabceEto/kUQxW7l4Fw7gASZf68GScK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/783qK/dJMcabceEto/kUQxW7l4Fw7gASZf68GScK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/783qK/dJMcabceEto/kUQxW7l4Fw7gASZf68GScK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F783qK%2FdJMcabceEto%2FkUQxW7l4Fw7gASZf68GScK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1660&quot; height=&quot;946&quot; data-filename=&quot;login-01.png&quot; data-origin-width=&quot;1660&quot; data-origin-height=&quot;946&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;App Router&lt;/code&gt; 환경에서 기본은 &lt;code&gt;Server Component&lt;/code&gt;이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그인 페이지의 레이아웃과 &lt;code&gt;metadata&lt;/code&gt;는 서버에서 렌더링하고, 입력 상태 관리(&lt;code&gt;useState&lt;/code&gt;), 폼 제출(&lt;code&gt;useActionState&lt;/code&gt;), 페이지 이동(&lt;code&gt;useRouter&lt;/code&gt;)이 필요한 로그인 폼 영역만 &lt;code&gt;Client Component&lt;/code&gt;로 분리하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 &lt;code&gt;Server Component&lt;/code&gt;는 &lt;code&gt;Client Component&lt;/code&gt;를 &lt;code&gt;import&lt;/code&gt;해서 사용할 수 있다. 하지만 반대의 상황은 허용되지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;로그인 인증 흐름 설계&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;서버 액션 구현&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그인 기능은 단순히 &lt;code&gt;API&lt;/code&gt; 요청을 보내는 문제가 아니라, 인증 정보(토큰)를 안전하게 저장하는 과정까지 포함한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;Client Component&lt;/code&gt;에서 직접 로그인 요청을 처리하면 토큰 저장/관리 로직이 브라우저로 내려가게 되어 공격 표면이 커질 수 있다. 특히 &lt;code&gt;cookies()&lt;/code&gt; 같은 서버 전용 &lt;code&gt;API&lt;/code&gt;로 &lt;code&gt;httpOnly&lt;/code&gt; 쿠키를 설정할 수 없기 때문에, 인증 처리는 서버에서 수행하는 구조가 더 안전하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 &lt;code&gt;App Router&lt;/code&gt;에서는 &lt;code&gt;'use server'&lt;/code&gt;를 선언해 함수가 서버에서만 실행되도록 만들 수 있다(&lt;code&gt;Server Action&lt;/code&gt;). 이를 통해 로그인 처리와 &lt;code&gt;httpOnly&lt;/code&gt; 쿠키 설정을 서버에서 안전하게 수행할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;'use server';

import { ErrorRes, LoginActionState, UserActionState } from '@/types/auth';
import { cookies } from 'next/headers';

const API_URL = process.env.NEXT_PUBLIC_API_URL;
const CLIENT_ID = process.env.NEXT_PUBLIC_CLIENT_ID || '';

export async function login(state: LoginActionState, formdata: FormData): Promise&amp;lt;LoginActionState&amp;gt; {
  ...&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 구현하면 폼 제출은 클라이언트에서 발생하지만, 실제 로그인 처리 및 쿠키 저장은 서버에서 실행하여 보안이 좋아진다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;로그인 요청&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;폼에서 제출된 데이터는 &lt;code&gt;FormData&lt;/code&gt; 형태로 서버 액션에 전달된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 데이터를 서버에서 읽을 수 있게 객체로 만들어서 &lt;code&gt;JSON&lt;/code&gt; 형태로 바꾸어 전달해주어야 한다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;const body = Object.fromEntries(formdata.entries());

...

res = await fetch(`${API_URL}/users/login`, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Client-Id': CLIENT_ID,
  },
  body: JSON.stringify(body),
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그인 성공 시 서버는 &lt;code&gt;accessToken&lt;/code&gt;과 &lt;code&gt;refreshToken&lt;/code&gt;을 응답하며, 인증이 필요한 요청에 사용된다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;토큰 저장 방법&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;토큰은 &lt;code&gt;localStorage&lt;/code&gt;, &lt;code&gt;sessionStorage&lt;/code&gt;, &lt;code&gt;cookie&lt;/code&gt;에 저장하는 방법들이 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 &lt;code&gt;localStorage&lt;/code&gt;와 &lt;code&gt;sessionStorage&lt;/code&gt;에 저장하게되면, 브라우저에서 실행되는 자바스크립트는 토큰에 접근할 수 있게 되어 &lt;code&gt;XSS&lt;/code&gt; 공격이 발생하면 탈취될 가능성이 생긴다.&lt;/p&gt;
&lt;pre class=&quot;gams&quot;&gt;&lt;code&gt;cookieStore.set('accessToken', accessToken, {
  httpOnly: true, // 클라이언트 JS에서 접근 불가, XSS 공격으로부터 토큰 보호
  secure: process.env.NODE_ENV === 'production',
  sameSite: 'lax', // CSRF 방어, 일반적인 링크 이동은 허용
  path: '/',
  maxAge: 60 * 30,
});&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;XSS (Cross-Site Scripting)&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;악성 스크립트를 웹 페이지에 삽입해 사용자의 브라우저에서 실행시키고, 토큰이나 민감 정보를 탈취하는 공격 방식&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/blockquote&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CSRF(Cross-Site Request Forgery)&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자가 의도하지 않은 요청을 다른 사이트를 통해 강제로 보내게 만들어, 인증된 상태를 악용하는 공격 방식&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 인증 토큰은 스토리지 대신 서버가 설정하는 &lt;code&gt;httpOnly&lt;/code&gt; 쿠키로 관리하는 것이 &lt;code&gt;XSS&lt;/code&gt; 관점에서 더 안전하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;로그인 이후, &lt;code&gt;user&lt;/code&gt; 상태 관리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그인 성공 시 토큰이 &lt;code&gt;httpOnly&lt;/code&gt; 쿠키에 저장이 된다고 해서 리액트 컴포넌트가 자동으로 로그인 여부를 알 수 있는 것은 아니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어, 헤더 메뉴를 &quot;로그인 / 회원가입&quot; &amp;rarr; &quot;마이페이지 / 로그아웃&quot;으로 변경하거나 로그인 여부에 따른 조건부 렌더링은 인증이 아니라 &lt;code&gt;UI&lt;/code&gt; 상태의 문제이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 전역 상태로 &lt;code&gt;user&lt;/code&gt;를 관리하도록 설계했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그인 상태가 여러 컴포넌트에서 사용되어 &lt;code&gt;Prop drilling&lt;/code&gt;없이 어디서든 접근하여 사용하기 위해 전역 상태 관리가 필요했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;Zustand&lt;/code&gt;는 하나의 &lt;code&gt;store&lt;/code&gt;를 생성해서 사용하는 구조이며, 사용이 직관적이고 설정이 간단하여 선택하게 되었다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;code&gt;store&lt;/code&gt; 설계&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// 사용자 정보 스토어의 초기값과 액션(함수)을 정의
const UserStore: StateCreator&amp;lt;UserStoreState&amp;gt; = (set) =&amp;gt; ({
  user: null,
  setUser: (user: User | null) =&amp;gt; set({ user }),
  resetUser: () =&amp;gt; set({ user: null }),
});

// 사용자 정보를 세션스토리지에 저장
const useUserStore = create&amp;lt;UserStoreState&amp;gt;()(
  persist(UserStore, {
    name: 'user',
    storage: createJSONStorage(() =&amp;gt; sessionStorage),
  })
);&lt;/code&gt;&lt;/pre&gt;</description>
      <category>NextJs</category>
      <author>seungjunn100</author>
      <guid isPermaLink="true">https://seungjunn100.tistory.com/25</guid>
      <comments>https://seungjunn100.tistory.com/entry/Nextjs-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84#entry25comment</comments>
      <pubDate>Tue, 3 Mar 2026 23:12:37 +0900</pubDate>
    </item>
    <item>
      <title>[Web] 웹접근성을 위한 WAI-ARIA</title>
      <link>https://seungjunn100.tistory.com/entry/%EC%9B%B9%EC%A0%91%EA%B7%BC%EC%84%B1%EC%9D%84-%EC%9C%84%ED%95%9C-WAI-ARIA</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;WAI-ARIA&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;WAI-ARIA(Web Accessibility Initiative - Accessible Rich Internet Applications)&lt;/code&gt;는 웹 콘텐츠의 접근성을 높이기 위해 &lt;code&gt;W3C WAI&lt;/code&gt;에서 개발한 기술이다. 웹이 단순한 문서 연결을 넘어 복잡한 사용자 경험(&lt;code&gt;UX&lt;/code&gt;)을 제공하는 리치 인터넷 애플리케이션(&lt;code&gt;RIA&lt;/code&gt;) 시대로 발전하면서 등장했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초기 웹은 정적이었으나, &lt;code&gt;JavaScript&lt;/code&gt;와 &lt;code&gt;Ajax&lt;/code&gt; 같은 기술로 웹 페이지가 동적으로 변하고 다양한 UI 컴포넌트가 등장하면서 기존 HTML만으로는 그 의미를 보조 기술(스크린 리더 등)에 정확히 전달하기 어려워졌다. 예를 들어, &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt;나 &lt;code&gt;&amp;lt;span&amp;gt;&lt;/code&gt; 태그로 만든 컴포넌트의 기능과 상태를 보조 기술이 파악할 수 없는 문제가 발생했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;WAI-ARIA&lt;/code&gt;는 이러한 문제를 해결하기 위해 역할(&lt;code&gt;Role&lt;/code&gt;), 속성(&lt;code&gt;Property&lt;/code&gt;), 상태(&lt;code&gt;State&lt;/code&gt;) 정보를 마크업에 추가하여 웹의 동적인 콘텐츠와 복잡한 &lt;code&gt;UI&lt;/code&gt;를 보조 기술이 인식하고 올바르게 전달할 수 있도록 돕는 역할을 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;유저 에이전트와 접근성 API 및 보조 기술 간의 관계&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;WAI-ARIA&lt;/code&gt;는 유저 에이전트(웹 브라우저)와 보조 기술 사이의 커뮤니케이션을 중재하는 핵심적인 역할을 수행한다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;code&gt;DOM&lt;/code&gt;에 &lt;code&gt;ARIA&lt;/code&gt; 속성 추가
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;개발자는 &lt;code&gt;HTML&lt;/code&gt; 요소에 &lt;code&gt;role&lt;/code&gt;, &lt;code&gt;property&lt;/code&gt;, &lt;code&gt;state&lt;/code&gt;와 같은 ARIA 속성을 추가한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;유저 에이전트의 역할
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;웹 브라우저는 &lt;code&gt;HTML&lt;/code&gt; 문서와 &lt;code&gt;ARIA&lt;/code&gt; 속성을 해석하여 접근성 트리(&lt;code&gt;Accessibility Tree&lt;/code&gt;)를 생성한다. 이 접근성 트리는 웹 콘텐츠의 구조, 역할, 상태에 대한 정보를 담고 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;접근성 &lt;code&gt;API&lt;/code&gt;의 역할
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;웹 브라우저는 운영체제(&lt;code&gt;OS&lt;/code&gt;)에서 제공하는 접근성 &lt;code&gt;API&lt;/code&gt;를 통해 이 접근성 트리를 노출한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;보조 기술의 역할
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;스크린 리더와 같은 보조 기술은 접근성 &lt;code&gt;API&lt;/code&gt;를 통해 접근성 트리의 정보를 읽어 들인다. 이 정보를 바탕으로 사용자에게 웹 콘텐츠를 전달하며 웹 페이지를 탐색하고 상호작용할 수 있도록 돕는다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;WAI-ARIA의 세 가지 구성 요소&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;역할(Role)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요소의 역할을 정의한다. 이는 해당 요소가 어떤 종류의 UI 컴포넌트인지 또는 페이지의 어느 영역인지 나타낸다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;role=&quot;button&quot;&lt;/code&gt;은 해당 요소가 버튼 기능을 수행&lt;/li&gt;
&lt;li&gt;&lt;code&gt;role=&quot;navigation&quot;&lt;/code&gt;은 내비게이션 영역&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;속성(Property)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요소의 특성을 정의한다. 이는 콘텐츠의 상태나 특성을 보조 기술에 전달하는데 사용된다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;aria-label=&quot;메뉴 열기&quot;&lt;/code&gt;는 해당 요소에 대한 설명 텍스트 제공&lt;/li&gt;
&lt;li&gt;&lt;code&gt;aria-required=&quot;true&quot;&lt;/code&gt;는 해당 요소가 필수 항목임을 알수 있도록 제공&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;상태(State)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요소의 현재 상태를 나타낸다. 속성과 유사하지만 상태는 사용자의 상호작용에 따라 동적으로 변하는 값이다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;aria-expanded=&quot;true&quot;&lt;/code&gt;는 확장 가능한 영역이 현재 펼쳐져 있음&lt;/li&gt;
&lt;li&gt;&lt;code&gt;aria-checked=&quot;true&quot;&lt;/code&gt;는 체크박스가 선택되어 있음&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;WAI-ARIA 작성 규칙&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;HTML의 시맨틱 요소 사용&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시맨틱 요소들은 기본적으로 의미와 기능을 제공할 수 있어 접근성을 가지고 있다. 굳이 &lt;code&gt;ARIA&lt;/code&gt;를 사용할 필요가 없다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;&amp;lt;header&amp;gt;&lt;/code&gt; = &lt;code&gt;role=&quot;banner&quot;&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;&amp;lt;nav&amp;gt;&lt;/code&gt; = &lt;code&gt;role=&quot;navigation&quot;&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;&amp;lt;button&amp;gt;&lt;/code&gt; = &lt;code&gt;role=&quot;button&quot;&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;&amp;lt;span role=&quot;button&quot; tabindex=&quot;0&quot;&amp;gt;버튼&amp;lt;/span&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;// role=&quot;button&quot;
var roleButton = document.querySelector('[role=&quot;button&quot;]');

// keyup 이벤트 핸들러 함수
function keyUpHandler(event) {
  if (event.key === 'Enter' || event.key == 'Space' || event.key == 'Spacebar' ||event.keyCode === 13 || event.keyCode === 32) {
  // Enter 키 또는 Spacebar 키가 눌렸을 때 실행할 작업
  }
}

// role=&quot;button&quot;으로 지정된 요소에 keyup 이벤트 리스너 추가
roleButton.addEventListener('keyup', keyUpHandler);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;&amp;lt;button&amp;gt;&lt;/code&gt; 요소를 사용했다면, 위와 같은 불필요한 스크립트를 작성할 필요가 없게된다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;HTML 요소의 기능 변경 제한&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 &lt;code&gt;HTML&lt;/code&gt; 요소에 &lt;code&gt;ARIA&lt;/code&gt; 속성을 추가하거나 기존의 의미를 변강하는건 좋지 않다.&lt;/p&gt;
&lt;pre class=&quot;xml&quot;&gt;&lt;code&gt;&amp;lt;!-- 이미지 이면서 버튼 역할을 할 수 없다. --&amp;gt;
&amp;lt;img src=&quot;./src/images/photo.jpg&quot; alt=&quot;photo&quot; role=&quot;button&quot; /&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요소의 의미론적 충돌로 인해 보조 기술 사용자들은 혼란스러울 수 있다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;키보드 사용 보장&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자와 상호 작용이 필요한 대화형 &lt;code&gt;UI&lt;/code&gt;의 경우 키보드로도 접근 및 사용이 가능하도록 제공해야 한다. 기본적으로 키보드로 접근할 수 없는 &lt;code&gt;HTML&lt;/code&gt; 요소의 경우 &lt;code&gt;tabindex=&quot;&quot;&lt;/code&gt; 속성을 사용하여 키보드로 접근이 가능하도록 할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;xml&quot;&gt;&lt;code&gt;&amp;lt;!-- 키보드로 접근 가능 --&amp;gt;
&amp;lt;span role=&quot;button&quot; tabindex=&quot;0&quot;&amp;gt;버튼&amp;lt;/span&amp;gt;

&amp;lt;!-- 키보드로 접근 불가능 --&amp;gt;
&amp;lt;span role=&quot;button&quot; tabindex=&quot;-1&quot;&amp;gt;버튼&amp;lt;/span&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;숨김 콘텐츠&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;ARIA&lt;/code&gt;를 사용하거나 &lt;code&gt;CSS&lt;/code&gt;를 사용해서 숨김 처리를 하면 접근성이 차단될 수 있다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;display: none&lt;/code&gt; 또는 &lt;code&gt;visibility: hidden&lt;/code&gt; 스타일 속성을 사용하면, 보조 기술뿐만 아니라 모든 사용자에게서 숨겨진다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;aria-hidden=&quot;true&quot;&lt;/code&gt;, &lt;code&gt;role=&quot;presentation&quot;&lt;/code&gt;, &lt;code&gt;role=&quot;none&quot;&lt;/code&gt;는 사용자에게 보여지지만 보조 기술에 노출되지 않도록 한다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;aria-hidden=&quot;true&quot;&lt;/code&gt; : 보조 기술에서 해당 요소를 완전히 숨김&lt;/li&gt;
&lt;li&gt;&lt;code&gt;role=&quot;presentation&quot;/&quot;none&quot;&lt;/code&gt; : 요소의 시맨틱 의미만 제거하고 콘텐츠는 노출&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;모든 상호작용 요소에 접근 가능한 이름을 부여&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상호작용 가능한 모든 요소는 접근 가능한 이름을 가져야 한다.&lt;/p&gt;
&lt;pre class=&quot;xml&quot;&gt;&lt;code&gt;&amp;lt;!-- 레이블 요소를 사용한 경우 --&amp;gt;
&amp;lt;div class=&quot;container&quot;&amp;gt;
  &amp;lt;label for=&quot;user-name&quot;&amp;gt;이름&amp;lt;/label&amp;gt;
  &amp;lt;input type=&quot;text&quot; id=&quot;user-name&quot; /&amp;gt;
&amp;lt;/div&amp;gt;

&amp;lt;!-- aria-label, aria-labelledby 속성을 사용한 경우 --&amp;gt;
&amp;lt;div&amp;gt;
  &amp;lt;div id=&quot;user-name&quot;&amp;gt;이름&amp;lt;/div&amp;gt;
  &amp;lt;input type=&quot;text&quot; aria-labelledby=&quot;user-name&quot; /&amp;gt;
&amp;lt;/div&amp;gt;

&amp;lt;button type=&quot;button&quot; aria-label=&quot;닫기&quot;&amp;gt; X &amp;lt;/button&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;더 다양한 WAI-ARIA 작성 방법&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://mulder21c.github.io/aria-practices/&quot;&gt;https://mulder21c.github.io/aria-practices/&lt;/a&gt;&lt;/p&gt;</description>
      <category>WEB</category>
      <author>seungjunn100</author>
      <guid isPermaLink="true">https://seungjunn100.tistory.com/23</guid>
      <comments>https://seungjunn100.tistory.com/entry/%EC%9B%B9%EC%A0%91%EA%B7%BC%EC%84%B1%EC%9D%84-%EC%9C%84%ED%95%9C-WAI-ARIA#entry23comment</comments>
      <pubDate>Mon, 23 Feb 2026 16:13:39 +0900</pubDate>
    </item>
    <item>
      <title>[Web] 웹표준과 웹접근성</title>
      <link>https://seungjunn100.tistory.com/entry/%EC%9B%B9%ED%91%9C%EC%A4%80%EA%B3%BC-%EC%9B%B9%EC%A0%91%EA%B7%BC%EC%84%B1</link>
      <description>&lt;h2&gt;웹표준(Web Standards)&lt;/h2&gt;
&lt;p&gt;웹사이트를 제작할 때 지켜야 하는 국제적인 기술 규약이다. &lt;code&gt;W3C(World Wide Web Consortium)&lt;/code&gt; 라는 국제 기구가 주도하며, 웹 개발의 기본 언어인 &lt;code&gt;HTML&lt;/code&gt;, &lt;code&gt;CSS&lt;/code&gt;, &lt;code&gt;JavaScript&lt;/code&gt;의 기술과 규칙을 정의한다.&lt;/p&gt;
&lt;br /&gt;

&lt;h3&gt;웹 표준이 중요한 이유&lt;/h3&gt;
&lt;h4&gt;호환성&lt;/h4&gt;
&lt;p&gt;사용자가 어떤 운영체제, 브라우저를 이용하든지 웹페이지를 동일하게 보이도록 만들 수 있다.&lt;/p&gt;
&lt;h4&gt;효율적인 유지보수&lt;/h4&gt;
&lt;p&gt;표준화된 코드는 다른 개발자들이 쉽게 이해할 수 있으며 협업이 수월해진다.&lt;/p&gt;
&lt;h4&gt;검색 엔진 최적화(SEO)&lt;/h4&gt;
&lt;p&gt;구글, 네이버 같은 검색 엔진은 웹 표준을 지킨 웹사이트의 콘텐츠를 더 잘 이해하고 분석할 수 있다. 이는 검색 결과에서 웹사이트가 더 높은 순위로 노출될 가능성을 높여준다.&lt;/p&gt;
&lt;br /&gt;

&lt;h3&gt;HTML, CSS, JavaScript와 웹 표준의 관계&lt;/h3&gt;
&lt;h4&gt;HTML&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;HTML&lt;/code&gt;은 웹페이지의 구조와 의미를 담당한다. &lt;code&gt;&amp;lt;h1&amp;gt;&lt;/code&gt; 태그는 제목을, &lt;code&gt;&amp;lt;p&amp;gt;&lt;/code&gt; 태그는 문단을 나타내는 것처럼 각 태그는 고유한 의미를 가진다. 웹 표준에서는 이처럼 의미에 맞게 태그를 올바르게 사용하는 것을 권장한다.&lt;/p&gt;
&lt;h4&gt;CSS&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;CSS&lt;/code&gt;는 웹페이지의 시각적인 표현(디자인)을 담당한다. 웹 표준은 &lt;code&gt;HTML&lt;/code&gt;이 구조만을 담당하고 &lt;code&gt;CSS&lt;/code&gt;가 디자인을 담당하도록 역할을 분리하는 것을 권장한다. 이 덕분에 구조와 디자인을 독립적으로 관리할 수 있어 유지보수가 용이해진다.&lt;/p&gt;
&lt;h4&gt;JavaScript&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;JavaScript&lt;/code&gt;는 웹페이지의 동적인 기능을 담당한다. 사용자의 행동에 반응하여 콘텐츠를 변경하거나 애니메이션을 추가하는 등 상호작용을 구현한다. 웹 표준은 &lt;code&gt;JavaScript&lt;/code&gt;가 웹페이지의 구조(&lt;code&gt;HTML&lt;/code&gt;)나 디자인(&lt;code&gt;CSS&lt;/code&gt;)을 비표준적으로 조작하는 것을 지양하고 오직 동적인 기능에만 집중하도록 권장한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-JavaScript&quot;&gt;// 구조의 비표준적인 방식 - 의미를 가지지 않는 태그를 사용
const newElement = document.createElement(&amp;#39;div&amp;#39;);
newElement.innerHTML = &amp;#39;&amp;lt;b&amp;gt;Hello World&amp;lt;/b&amp;gt;&amp;#39;;
document.body.appendChild(newElement);

// 구조의 표준적인 방식 - 내용의 의미에 맞는 태그를 사용
const newElement = document.createElement(&amp;#39;strong&amp;#39;);
newElement.textContent = &amp;#39;Hello World&amp;#39;;
document.body.appendChild(newElement);

// 디자인의 비표준적인 방식 - HTML 요소에 직접 스타일 정보를 삽입
const myButton = document.getElementById(&amp;#39;my-button&amp;#39;);
myButton.style.color = &amp;#39;red&amp;#39;;
myButton.style.fontSize = &amp;#39;20px&amp;#39;;

// 디자인의 표준적인 방식 - CSS 클래스 추가하고 실제 스타일 정보는 CSS 파일에 따로 저장
const myButton = document.getElementById(&amp;#39;my-button&amp;#39;);
myButton.classList.add(&amp;#39;active-button&amp;#39;);&lt;/code&gt;&lt;/pre&gt;
&lt;br /&gt;

&lt;h2&gt;웹접근성(Web Accessibility)&lt;/h2&gt;
&lt;p&gt;웹 접근성은 장애인이나 고령자 등 신체적, 환경적 조건에 관계없이 모든 사용자가 웹에 접근하고 이용할 수 있도록 보장하는 것을 의미한다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;신체적 조건이란 장애인, 고령자 등을 포함한 모든 사용자를 의미&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;환경적 조건이란 다양한 기기, 운영체제, 웹 브라우저, 네트워크 환경 등을 포함하는 것을 의미&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;br /&gt;

&lt;h3&gt;웹 접근성이 중요한 이유&lt;/h3&gt;
&lt;h4&gt;모두가 사용할 수 있는 웹&lt;/h4&gt;
&lt;p&gt;웹이 가진 본질적인 가치는 누구나 어디서나 접근할 수 있는 공간을 설계하는 것이다. 장애인이나 고령자 등 신체적, 환경적 조건에 관계없이 모든 사용자가 웹에 접근하고 이용할 수 있도록 보장하는 것이다. 마치 건물에 엘리베이터나 경사로를 설치해 누구나 쉽게 드나들 수 있게 하는 것과 같다.&lt;/p&gt;
&lt;h4&gt;법률적 준수&lt;/h4&gt;
&lt;p&gt;많은 국가에서 웹 접근성 준수를 법적으로 의무화하고 있다. 한국도 &lt;code&gt;지능정보화기본법&lt;/code&gt;, &lt;code&gt;장애인차별금지법&lt;/code&gt; 등 법적 의무사항이 있다. 미국에서는 시각 장애인 연맹(NFB)이 대형 유통업체인 타겟(Target)을 상대로 웹 접근성 미달로 고소한 사례도 있다. &lt;a href=&quot;https://www.w3.org/WAI/business-case/archive/target-case-study&quot;&gt;관련링크&lt;/a&gt;&lt;/p&gt;
&lt;h4&gt;사용자층 확대&lt;/h4&gt;
&lt;p&gt;웹 접근성을 개선하면 장애인이나 고령자 등 신체적·환경적 제약이 있는 사람은 물론, 모바일 사용자까지 더 폭넓은 이용자가 편리하게 웹사이트를 사용할 수 있다. 이는 곧 잠재 고객 확대와 비즈니스 기회 창출로 이어진다.&lt;/p&gt;
&lt;br /&gt;

&lt;h3&gt;웹 콘텐츠 접근성 가이드라인&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;웹 콘텐츠 접근성 가이드라인(WCAG - Web Content Accessibility Guidelines)&lt;/code&gt;은 웹 콘텐츠의 접근성을 확보하기 위해 &lt;code&gt;W3C&lt;/code&gt;에서 제공한 권고안이다. 이 지침의 목적은 장애인과 고령자를 포함한 모든 사용자가 웹 콘텐츠를 인식하고 이해하며, 원활히 상호작용할 수 있도록 보장하는 것이다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;WCAG&lt;/code&gt;는 4가지 주요 원칙을 바탕으로 구성되어 있다. 이 4가지 원칙은 접근 가능한 웹사이트를 만들기 위한 필수적인 조건이다.&lt;/p&gt;
&lt;h4&gt;인지 가능(Perceivable)&lt;/h4&gt;
&lt;p&gt;정보와 &lt;code&gt;UI&lt;/code&gt; 컴포넌트는 사용자가 인지할 수 있는 방식으로 제공되어야 한다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;이미지 대체 텍스트 제공&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;비디오 자막 지원&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;색상(명도) 대비&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;운용 가능(Operable)&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;UI&lt;/code&gt; 컴포넌트와 내비게이션은 사용자가 운용할 수 있는 방식으로 제공되어야 한다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;마우스 없이 키보드만으로 조작 가능&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;콘텐츠를 읽거나 기능을 사용하기 위한 충분한 시간&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;탐색을 쉽게 하기 위한 명확한 내비게이션&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;이해 가능(Understandable)&lt;/h4&gt;
&lt;p&gt;정보와 &lt;code&gt;UI&lt;/code&gt; 조작 방식은 사용자가 이해할 수 있는 방식으로 제공되어야 한다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;읽고 이해하기 쉬운 텍스트 콘텐츠&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;일관되고 예측 가능한 웹페이지의 반응&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;오류 발생시 오류를 명확하게 알려주고 수정 방법 제공&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;견고성(Robust)&lt;/h4&gt;
&lt;p&gt;콘텐츠는 다양한 기술 환경에서도 안정적으로 작동해야 한다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;다양한 브라우저, 보조 기술, 장치에서도 안정적으로 작동&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;HTML&lt;/code&gt;, &lt;code&gt;CSS&lt;/code&gt; 등 웹 기술의 문법적 규칙을 준수&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>WEB</category>
      <author>seungjunn100</author>
      <guid isPermaLink="true">https://seungjunn100.tistory.com/22</guid>
      <comments>https://seungjunn100.tistory.com/entry/%EC%9B%B9%ED%91%9C%EC%A4%80%EA%B3%BC-%EC%9B%B9%EC%A0%91%EA%B7%BC%EC%84%B1#entry22comment</comments>
      <pubDate>Mon, 23 Feb 2026 16:13:19 +0900</pubDate>
    </item>
    <item>
      <title>[Final Project] 브루노, 데이터 (feat. Schema, MongoDB)</title>
      <link>https://seungjunn100.tistory.com/entry/Final-Team-Project-%EB%B8%8C%EB%A3%A8%EB%85%B8%EC%99%80-MongoDB</link>
      <description>&lt;h2&gt;브루노&lt;/h2&gt;
&lt;p&gt;프로젝트를 시작하기전에 &lt;code&gt;API&lt;/code&gt; 요청을 재현할 수 있도록 테스트하기 위해 브루노를 사용하였다.&lt;/p&gt;
&lt;p&gt;팀에게 필요한 대략적인 &lt;code&gt;API&lt;/code&gt; 요청을 페이지별로 분리하여 &lt;code&gt;Collection&lt;/code&gt;을 설계하였다.&lt;/p&gt;
&lt;p&gt;설계한 폴더와 참고사항을 팀원에게 공유하고 각자의 로컬에서 테스트해 볼 수 있도록 하였다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1102&quot; data-origin-height=&quot;265&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cgIp5h/dJMcaiPBvs2/2lnGABXL97A3bAkaOSc7f0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cgIp5h/dJMcaiPBvs2/2lnGABXL97A3bAkaOSc7f0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cgIp5h/dJMcaiPBvs2/2lnGABXL97A3bAkaOSc7f0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcgIp5h%2FdJMcaiPBvs2%2F2lnGABXL97A3bAkaOSc7f0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1102&quot; height=&quot;265&quot; data-origin-width=&quot;1102&quot; data-origin-height=&quot;265&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;먼저 환경변수로 &lt;code&gt;API&lt;/code&gt; 서버 &lt;code&gt;URL&lt;/code&gt;과 &lt;code&gt;API&lt;/code&gt;를 사용할 수 있게 허가된 &lt;code&gt;ID&lt;/code&gt;를 설정하였다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1230&quot; data-origin-height=&quot;254&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dsvZhx/dJMcabCY4Yv/ILkP6EkgsOZC6uhJqTEFd1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dsvZhx/dJMcabCY4Yv/ILkP6EkgsOZC6uhJqTEFd1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dsvZhx/dJMcabCY4Yv/ILkP6EkgsOZC6uhJqTEFd1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdsvZhx%2FdJMcabCY4Yv%2FILkP6EkgsOZC6uhJqTEFd1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1230&quot; height=&quot;254&quot; data-origin-width=&quot;1230&quot; data-origin-height=&quot;254&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;&lt;code&gt;API&lt;/code&gt; 요청을 보낼 때 어디서 보냈는지 확인할 수 있게 헤더에 &lt;code&gt;Client-Id&lt;/code&gt;를 설정하였다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;911&quot; data-origin-height=&quot;407&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bbrNaH/dJMcai28rk3/r5iGSYk6ZFVmej6TNWqMkK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bbrNaH/dJMcai28rk3/r5iGSYk6ZFVmej6TNWqMkK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bbrNaH/dJMcai28rk3/r5iGSYk6ZFVmej6TNWqMkK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbbrNaH%2FdJMcai28rk3%2Fr5iGSYk6ZFVmej6TNWqMkK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;911&quot; height=&quot;407&quot; data-origin-width=&quot;911&quot; data-origin-height=&quot;407&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;로그인 &lt;code&gt;API&lt;/code&gt; 요청 시 성공하면 응답으로 토큰을 발급받아 환경 변수에 토큰을 저장하도록 설정하였다.&lt;/p&gt;
&lt;p&gt;이렇게 스크립트를 작성해두면 다른 사용자들이 로그인을해도 각자의 토큰이 환경 변수에 저장된다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1144&quot; data-origin-height=&quot;264&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bkzGgq/dJMcadndQFY/FqmX2AnkkhIKuHp0qHcNSk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bkzGgq/dJMcadndQFY/FqmX2AnkkhIKuHp0qHcNSk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bkzGgq/dJMcadndQFY/FqmX2AnkkhIKuHp0qHcNSk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbkzGgq%2FdJMcadndQFY%2FFqmX2AnkkhIKuHp0qHcNSk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1144&quot; height=&quot;264&quot; data-origin-width=&quot;1144&quot; data-origin-height=&quot;264&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;발급받은 토큰은 로그인한 사용자의 고유의 값으로 &lt;code&gt;Baerer Token&lt;/code&gt;의 값으로 설정해준다.&lt;/p&gt;
&lt;p&gt;그러면 아래와 같이 마이페이지나 결제같은 개인적인 작업을 할 때 &lt;code&gt;Baerer Token&lt;/code&gt;에 의해 권한을 확인한 후 작업을 수행할 수 있다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1403&quot; data-origin-height=&quot;295&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bAp4vm/dJMcaiWnX28/kryr1Db1mtoTVX23xxlfNk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bAp4vm/dJMcaiWnX28/kryr1Db1mtoTVX23xxlfNk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bAp4vm/dJMcaiWnX28/kryr1Db1mtoTVX23xxlfNk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbAp4vm%2FdJMcaiWnX28%2Fkryr1Db1mtoTVX23xxlfNk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1403&quot; height=&quot;295&quot; data-origin-width=&quot;1403&quot; data-origin-height=&quot;295&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;br /&gt;

&lt;h2&gt;데이터 (feat. Schema, MongoDB)&lt;/h2&gt;
&lt;h3&gt;스키마&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;Schema&lt;/code&gt;(스키마)란 데이터가 어떤 모양과 규칙을 가져야 하는지에 대한 데이터 설계도이다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-md&quot;&gt;- 스키마는 어떤 필드가 존재해야 하는지
- 필수인지, 선택인지
- 각 필드명은 무엇인지
- 각 필드의 타입이 무엇인지
- ... 등등&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;스키마가 있는 경우&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;Product {
  _id: number (필수)
  name: string (필수)
  price: number (필수)
  quantity: number (필수)
  extra?: object
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;타입스크립트와 같이 값을 엄격하게 받아 데이터의 품질을 유지할 수 있다.&lt;/p&gt;
&lt;h4&gt;스키마가 없는 경우&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;prcie&amp;quot;: 3000,
  &amp;quot;categoryId&amp;quot;: &amp;quot;diet&amp;quot;,
  &amp;quot;name&amp;quot;: &amp;quot;가르시니아 캄보지아&amp;quot;,
  &amp;quot;content&amp;quot;: &amp;quot;영양제를 드셔보세요!&amp;quot;,
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;자유롭게 작성이 가능하여 전부 데이터로 저장되어 관리가 복잡해지고 관리 비용이 커진다.&lt;/p&gt;
&lt;br /&gt;

&lt;h3&gt;DB 유형&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;DB&lt;/code&gt;마다 스키마 성격이 다르다.&lt;/p&gt;
&lt;h4&gt;관계형 DB (MySQL)&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-md&quot;&gt;- 스키마 강제
- 테이블 구조 먼저 정의
- 안정적, 정형 데이터에 강함&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;NoSQL (MongoDB)&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-md&quot;&gt;- 기본은 스키마 없음
- 문서마다 구조 달라도 저장됨
- 대신 애플리케이션 단에서 스키마를 걸어주는 게 일반적&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;현재 프로젝트에서는 &lt;code&gt;MongoDB&lt;/code&gt;를 활용하고 있는데 테스트 해본 결과 필수인 스키마만 지키면 자유자제로 데이터 필드를 설계하여 사용할 수 있었다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;[
  {
    &amp;quot;mainId&amp;quot;: &amp;quot;diet_garcinia&amp;quot;,
    &amp;quot;categoryId&amp;quot;: &amp;quot;diet&amp;quot;,
    &amp;quot;name&amp;quot;: &amp;quot;가르시니아 캄보지아&amp;quot;,
    &amp;quot;content&amp;quot;: &amp;quot;영양제를 드셔보세요!&amp;quot;,
    &amp;quot;mainNutrients&amp;quot;: [&amp;quot;HCA&amp;quot;],
    &amp;quot;mainFunctions&amp;quot;: [&amp;quot;탄수화물의 지방 전환 억제&amp;quot;, &amp;quot;체지방 감소&amp;quot;],
    &amp;quot;intakeGuide&amp;quot;: &amp;quot;식사 30분 전 섭취&amp;quot;,
    &amp;quot;precautions&amp;quot;: [&amp;quot;위장 장애 주의&amp;quot;, &amp;quot;임산부 섭취 금지&amp;quot;],
    &amp;quot;storage&amp;quot;: &amp;quot;서늘하고 건조한 곳&amp;quot;,
    &amp;quot;nutritionInfoExample&amp;quot;: {
      &amp;quot;servingSize&amp;quot;: &amp;quot;750mg&amp;quot;,
      &amp;quot;nutrients&amp;quot;: [{ &amp;quot;name&amp;quot;: &amp;quot;HCA&amp;quot;, &amp;quot;amount&amp;quot;: &amp;quot;750mg&amp;quot;, &amp;quot;dailyValue&amp;quot;: &amp;quot;100%&amp;quot; }]
    },
    &amp;quot;quantity&amp;quot;: 600,    // 필수
    &amp;quot;show&amp;quot;: true,       // 필수
    &amp;quot;active&amp;quot;: true      // 필수
  }, 
  .
  .
]&lt;/code&gt;&lt;/pre&gt;
&lt;br /&gt;

&lt;p&gt;그래서 예제로 위와 같은 &lt;code&gt;JSON&lt;/code&gt; 형태의 데이터를 설계하여 브루노를 통해 &lt;code&gt;DB&lt;/code&gt; 초기화를 진행했다.&lt;/p&gt;
&lt;p&gt;아래와 같이 &lt;code&gt;MongoDB&lt;/code&gt;에 임시로 설계한 데이터로 초기화 된 것을 볼 수 있었다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1400&quot; data-origin-height=&quot;654&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/kJHAe/dJMcad1Pjbm/OoUVBkcgtUnF8Oqk0N5XdK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/kJHAe/dJMcad1Pjbm/OoUVBkcgtUnF8Oqk0N5XdK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/kJHAe/dJMcad1Pjbm/OoUVBkcgtUnF8Oqk0N5XdK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FkJHAe%2FdJMcad1Pjbm%2FOoUVBkcgtUnF8Oqk0N5XdK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1400&quot; height=&quot;654&quot; data-origin-width=&quot;1400&quot; data-origin-height=&quot;654&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;브루노에서도 조회가 되는 것을 확인하였다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1234&quot; data-origin-height=&quot;594&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bKPEws/dJMcacomNzu/fqAebGnM2XqsjT7I2zXIk0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bKPEws/dJMcacomNzu/fqAebGnM2XqsjT7I2zXIk0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bKPEws/dJMcacomNzu/fqAebGnM2XqsjT7I2zXIk0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbKPEws%2FdJMcacomNzu%2FfqAebGnM2XqsjT7I2zXIk0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1234&quot; height=&quot;594&quot; data-origin-width=&quot;1234&quot; data-origin-height=&quot;594&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;</description>
      <category>Project</category>
      <author>seungjunn100</author>
      <guid isPermaLink="true">https://seungjunn100.tistory.com/19</guid>
      <comments>https://seungjunn100.tistory.com/entry/Final-Team-Project-%EB%B8%8C%EB%A3%A8%EB%85%B8%EC%99%80-MongoDB#entry19comment</comments>
      <pubDate>Thu, 22 Jan 2026 16:33:02 +0900</pubDate>
    </item>
    <item>
      <title>[Final Project] 프로젝트 구조와 기능 흐름 정리</title>
      <link>https://seungjunn100.tistory.com/entry/Final-Team-Project-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EA%B5%AC%EC%A1%B0%EC%99%80-%EA%B8%B0%EB%8A%A5-%ED%9D%90%EB%A6%84-%EC%A0%95%EB%A6%AC</link>
      <description>&lt;h2&gt;프로젝트 구조&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-md&quot;&gt;  final-team-project/
│
├──   app/                               # App 라우트 그룹
│   ├──   (auth)/                        ## 인증 관련 라우트 그룹
│   │   ├──   login/
│   │   │   └──   page.tsx               ### 로그인 페이지
│   │   ├──   signup/
│   │   │   └──   page.tsx               ### 회원가입 페이지
│   │   └──   layout.tsx                 ### 로그인/회원가입 전용 레이아웃 (헤터/푸터 X) (로고 홈 링크)
│   │
│   ├──   mypage/                        ## 마이페이지 라우트 그룹
│   │   └──   page.tsx                   ### 마이페이지
│   │
│   ├──   survey/                        ## AI 설문조사 라우트 그룹
│   │   ├──   result/
│   │   │   └──   page.tsx               ### AI 설문조사 결과 페이지
│   │   └──   layout.tsx                 ### AI 설문조사 전용 레이아웃 (헤터/푸터 X) (뒤로가기/설문종료)
│   │
│   ├──   products/                      ## 상품 목록 라우트 그룹
│   │   ├──   [id]/
│   │   │   └──   page.tsx               ### 상품 상세 페이지
│   │   └──   page.tsx                   ### 상품 목록 페이지
│   │
│   └──   subscription/                  ## 결제(구독) 라우트 그룹
│       └──   page.tsx                   ### 결제(구독) 페이지
│
├──   components/                        # 컴포넌트
│   └──   common/                        ## 공통 컴포넌트
│       ├──   Header.tsx                 ### 헤더 (예제)
│       ├──   Footer.tsx                 ### 푸터 (예제)
│       └──   ErrorBoundary.tsx          ### 에러 처리 로직 (예제)
│
├──   lib/                               # API 요청 로직
│   └──   auth.ts                        ### 인증 관련 API 요청 함수 정의 (예제)
│
├──   actions/                           # Server Actions
│   └──   auth.ts                        ### 인증 관련 서버 액션 (예제)
│
├──   types/                             # 타입 정의
│   ├──   index.ts                       ### 공통으로 사용하는 응답 타입 export
│   └──   auth.ts                        ### 인증 관련 API 요청/응답 타입 정의 (예제)
│
├──   store/                             # 상태 관리 (Zustand)
│   └──   authStore.ts                   ### 인증 상태 전역 관리 (예제)
│
├──   hooks/                             # 커스텀 훅
│   └──   useAuth.ts                     ### 인증 관련 로직을 추상화한 커스텀 훅 (예제)
│
├──   public/                            # 정적 파일
│       ├──   images/                    ## 이미지 파일
│       └──   icons/                     ## 아이콘 파일
│
├──   page.tsx
├──   layout.tsx
├──   error.tsx
├──   not-found.tsx
│
.
.&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;프로젝트를 들어가기에 앞서 전체 구조와 각자의 담당 범위를 먼저 정하고, 이를 기준으로 폴더 구조만 설계했다.&lt;/p&gt;
&lt;br /&gt;

&lt;p&gt;공통으로 사용하는 타입이나 &lt;code&gt;API&lt;/code&gt; 요청 로직까지 미리 구현하여 제공하면 좋았을 것 같았지만,&lt;br&gt;한정된 개발 기간과 현재 숙련도를 고려했을 때 공통 로직을 완성도 있게 작성하기는 어렵다고 판단했다.&lt;/p&gt;
&lt;br /&gt;

&lt;p&gt;그리고 이번 프로젝트의 목적이 단순히 완성된 결과물이 아니라 과정에 따른 학습이었기에,&lt;br&gt;최소한의 공통 규칙만 정한 뒤, 각자가 필요한 타입과 로직들은 직접 구현하며 경험해보는 방식을 선택했다.&lt;/p&gt;
&lt;br /&gt;

&lt;h2&gt;기능 정의서&lt;/h2&gt;
&lt;p&gt;각자 담당하는 페이지별로 구체적으로 어떤 기능을 개발할 것인지 기능 정의서를 작성하였다.&lt;/p&gt;
&lt;p&gt;아래에는 내가 담당한 부분의 한 부분을 작성한 것이다. 한정된 기간 내에 작업을 하기 위해 필수적인 기능을 &lt;code&gt;[1순위]&lt;/code&gt;로 두고 &lt;code&gt;[2순위]&lt;/code&gt;부터는 선택 사항으로 작업 하도록 방향을 잡았다.&lt;/p&gt;
&lt;h3&gt;로그인 페이지&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;- [1순위] 이메일, 비밀번호 입력시 형식 검증
- [1순위] 로그인 성공 시 이전 페이지로 이동
- [1순위] 로그인 실패 시 에러 사유 렌더링
- [2순위] 소셜 계정을 통한 로그인
- [2순위] 비밀번호 재설정
- [3순위] 로그인 상태 유지 기능&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;한정된 기간이라도 최대한 할 수 있는 부분은 해보려고 한다.&lt;/p&gt;
&lt;br /&gt;

&lt;h2&gt;와이어 프레임&lt;/h2&gt;
&lt;p&gt;와이어프레임이라 레이아웃을 간단하게 작업해서 보여줄 수도 있었지만, 로그인 페이지의 대략적인 시안이 잡혀있어 시안을 통해 정리했다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1843&quot; data-origin-height=&quot;1271&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bAFQuG/dJMcacBUcp4/GQ9eZpXWTtxy2hnHKnwWpk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bAFQuG/dJMcacBUcp4/GQ9eZpXWTtxy2hnHKnwWpk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bAFQuG/dJMcacBUcp4/GQ9eZpXWTtxy2hnHKnwWpk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbAFQuG%2FdJMcacBUcp4%2FGQ9eZpXWTtxy2hnHKnwWpk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1843&quot; height=&quot;1271&quot; data-origin-width=&quot;1843&quot; data-origin-height=&quot;1271&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;기능정의서에 정의한 것을 토대로 화면 구조에 따라서 사용자의 흐름은 어떻게 흘러가는지 자세히 적어, 팀원 간 기획 의도를 명확하게 알 수 있도록 작업하였다.&lt;/p&gt;</description>
      <category>Project</category>
      <author>seungjunn100</author>
      <guid isPermaLink="true">https://seungjunn100.tistory.com/18</guid>
      <comments>https://seungjunn100.tistory.com/entry/Final-Team-Project-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EA%B5%AC%EC%A1%B0%EC%99%80-%EA%B8%B0%EB%8A%A5-%ED%9D%90%EB%A6%84-%EC%A0%95%EB%A6%AC#entry18comment</comments>
      <pubDate>Thu, 22 Jan 2026 16:32:33 +0900</pubDate>
    </item>
  </channel>
</rss>