<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>DEVELOPE-sther</title>
    <link>https://ilove-ya.tistory.com/</link>
    <description></description>
    <language>ko</language>
    <pubDate>Sat, 25 Jul 2026 08:08:18 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>Ea스더</managingEditor>
    <image>
      <title>DEVELOPE-sther</title>
      <url>https://tistory1.daumcdn.net/tistory/6739538/attach/e8d76b0bd6a340fea0d67e303af1dda1</url>
      <link>https://ilove-ya.tistory.com</link>
    </image>
    <item>
      <title>[ORM] Prisma 개념, ERD -&amp;gt; Prisma schema로 변환해보자.</title>
      <link>https://ilove-ya.tistory.com/230</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;Prisma 란&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Prisma&lt;/b&gt;는 Node.js / TypeScript 환경에서 DB를 다루기 위한 &lt;b&gt;ORM(Object Relational Mapper)&lt;/b&gt; 이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;쉽게 말하: SQL을 직접 짜는 대신, JavaScript/TypeScript 코드로 DB를 다룰 수 있게 해주는 도구다.&lt;/p&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;// SQL 없이 이렇게 쓸 수 있다
const users = await prisma.user.findMany();
&lt;/code&gt;&lt;/pre&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;p data-ke-size=&quot;size16&quot;&gt;그러면 여기서 &lt;b&gt;ORM&lt;/b&gt;이 뭔지 알아보자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB는 SQL이라는 언어로 소통한다. 그런데 백엔드 코드는 JavaScript로 쓰고 있으니, 이 둘이 직접 대화하기가 어렵다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ORM은 이 둘 사이의 통역사 역할을 한다.&lt;/p&gt;
&lt;pre class=&quot;gcode&quot;&gt;&lt;code&gt;내 코드 (JS/TS)  &amp;rarr;  ORM (Prisma)  &amp;rarr;  DB (PostgreSQL 등)
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&amp;nbsp;&lt;/td&gt;
&lt;td&gt;SQL 직접&lt;/td&gt;
&lt;td&gt;Prisma(ORM)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;코드 스타일&lt;/td&gt;
&lt;td&gt;SELECT * FROM users WHERE id = 1&lt;/td&gt;
&lt;td&gt;prisma.user.findUnique({ where: { id: 1 } })&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;타입 안정성&lt;/td&gt;
&lt;td&gt;❌ 없음&lt;/td&gt;
&lt;td&gt;✅ TypeScript 자동완성 지원&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;실수 감지&lt;/td&gt;
&lt;td&gt;런타임에 발견&lt;/td&gt;
&lt;td&gt;컴파일 타임에 발견&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&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;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;Prisma의 구성 요소&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Prisma는 크게 3가지로 이루어진다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;1️⃣ Prisma Schema (schema.prisma)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;DB 구조를 정의하는 파일. 테이블, 컬럼, 관계를 여기에 적는다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;2️⃣ Prisma Client&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;schema를 바탕으로 자동 생성되는 쿼리 라이브러리.&lt;br /&gt;&lt;b&gt;npx prisma generate&lt;/b&gt;를 실행하면 만들어진다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;3️⃣&amp;nbsp;Prisma Migrate&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;schema 변경사항을 실제 DB에 반영해주는 도구.&lt;br /&gt;npx prisma migrate dev를 실행하면 마이그레이션 파일이 만들어지고 DB에 적용된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;schema.prisma  &amp;rarr;  (generate)  &amp;rarr;  Prisma Client  &amp;rarr;  DB 쿼리
             ↘  (migrate)   &amp;rarr;  실제 DB에 테이블 생성
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;schema.prisma 파일 뜯어보기&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;schema.prisma는 3개의 블록으로 구성된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;datasource : 어떤 DB를 쓸지!&lt;/p&gt;
&lt;pre class=&quot;dts&quot;&gt;&lt;code&gt;datasource db {
  provider = &quot;postgresql&quot;  // mysql, sqlite 등도 가능
  url      = env(&quot;DATABASE_URL&quot;)  // .env 파일에서 DB 주소를 읽어옴
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;generator : 뭘 생성할지!&lt;/p&gt;
&lt;pre class=&quot;axapta&quot;&gt;&lt;code&gt;generator client {
  provider = &quot;prisma-client-js&quot;  // Prisma Client를 JS로 생성
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;model : 테이블 정의!&lt;/p&gt;
&lt;pre class=&quot;autoit&quot;&gt;&lt;code&gt;model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  nickname  String?  // ?는 null 허용 (optional)
  createdAt DateTime @default(now())
}
&lt;/code&gt;&lt;/pre&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;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Prisma 타입&lt;/b&gt;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;String&lt;/td&gt;
&lt;td&gt;문자열 (varchar, text 모두)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Int&lt;/td&gt;
&lt;td&gt;정수&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Boolean&lt;/td&gt;
&lt;td&gt;true / false&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DateTime&lt;/td&gt;
&lt;td&gt;날짜+시각&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Json&lt;/td&gt;
&lt;td&gt;JSON 객체&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;String[]&lt;/td&gt;
&lt;td&gt;배열 (PostgreSQL만 지원)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&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;b&gt;어트리뷰트&lt;/b&gt;&amp;nbsp;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;@id&lt;/td&gt;
&lt;td&gt;Primary Key&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;@default(autoincrement())&lt;/td&gt;
&lt;td&gt;자동 증가 ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;@default(now())&lt;/td&gt;
&lt;td&gt;현재 시각 기본값&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;@unique&lt;/td&gt;
&lt;td&gt;유니크 제약&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;@map(&quot;컬럼명&quot;)&lt;/td&gt;
&lt;td&gt;실제 DB 컬럼명 지정&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;@@map(&quot;테이블명&quot;)&lt;/td&gt;
&lt;td&gt;실제 DB 테이블명 지정&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;@updatedAt&lt;/td&gt;
&lt;td&gt;업데이트 시 자동으로 현재 시각 저장&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;5-1. 기본 테이블 변환&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;DBML&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Table users {
  id         integer [primary key, increment]
  email      varchar [unique, not null]
  password   varchar
  nickname   varchar
  created_at timestamp
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Prisma&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  password  String?           // not null 없으면 ? 붙여서 optional
  nickname  String?
  createdAt DateTime @default(now()) @map(&quot;created_at&quot;)

  @@map(&quot;users&quot;)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;포인트:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;DBML의 [not null]이 없는 필드 &amp;rarr; Prisma에서 ? (nullable)&lt;/li&gt;
&lt;li&gt;snake_case 컬럼명 &amp;rarr; camelCase 필드명 + @map()으로 실제 DB 컬럼명 유지&lt;/li&gt;
&lt;li&gt;테이블명도 @@map()으로 소문자 복수형 유지&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;&lt;b&gt;ERD -&amp;gt; Prisma scheme 변환하기&amp;nbsp;&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이미 dbdiogram으로 작성한 ERD가 있어서, 이걸 Prisma로 옮겨 보면서 공부해보았다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1777107643967&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 1. Auth &amp;amp; User Management (김가영 담당)
// 유저 기본 정보 및 활동 점수 관리
Table users {
  id integer [primary key, increment]
  email varchar [unique, not null]
  password varchar [note: '소셜 로그인 유저는 비어있을 수 있음']
	name varchar
  profile_img text
  provider varchar [default: 'local', note: 'local, github']
  provider_id varchar [note: '깃허브 고유 ID']
  created_at timestamp
}

// 점수 변동 이력 (잔디 그래프 및 히스토리용)
Table score_logs {
  id integer [primary key, increment]
  team_member_id integer [ref: &amp;gt; team_members.id]
  amount integer // +50, +10 등
  reason varchar // ADOPTED_COMMENT, DAILY_CHECKIN 등
  created_at timestamp
}

// 2. Team Workspace (정애리 담당)
// 팀 생성 및 멤버십/권한 관리
Table teams {
  id integer [primary key, increment]
  name varchar [not null]
  description text
  slack_webhook_url text [note: '슬랙 알림 연동용']
  created_at timestamp
}

Table team_members {
  id integer [primary key]
  team_id integer [ref: &amp;gt; teams.id]
  user_id integer [ref: &amp;gt; users.id]
  role varchar // LEADER, MEMBER
  status varchar [default: 'PENDING', note: 'INVITED, JOINED, BANNED'] // 상태 추가
  score integer [default: 0]
  joined_at timestamp
}

// 3. Troubleshooting &amp;amp; Error Logs (김병성, 한재민 담당)
// 에러 포스팅 및 기술적인 로그 데이터 저장 -&amp;gt; 알림갈 사람 지정하게 해야할까요
Table error_issues {
  id integer [primary key, increment]
  user_id integer [ref: &amp;gt; users.id]
  team_id integer [ref: &amp;gt; teams.id]
  title varchar [not null]
  content text [note: '사용자가 작성한 상황 설명 (마크다운)']
  tag varchar[] [note: 'Frontend, Backend, DB 등']
  is_public boolean [default: false, note: '팀 외 전체 공개 여부']
  status varchar [default: 'unsolved', note: 'solved']
  created_at timestamp
  updated_at timestamp
}

Table error_logs {
  id integer [primary key, increment]
  issue_id integer [ref: &amp;gt; error_issues.id] // 게시글 하나에 2개 로그 연결가능
  log_type varchar [note: 'SENT(내 로그), RECEIVED(상대방 로그)']
  source varchar [note: 'Client, Server, DB, ExternalAPI 등']
  message text [note: '에러 메시지 요약']
  stack_trace text [note: '전체 스택 트레이스']
  request_data json [note: '요청 페이로드']
  response_data json [note: '응답 데이터']
  captured_at timestamp [note: '실제 에러 발생 시각']
}


// 4. Comments (정영호 담당)
Table comments {
  id integer [primary key, increment]
  issue_id integer [ref: &amp;gt; error_issues.id] 
  user_id integer [ref: &amp;gt; users.id]       
  parent_id integer [ref: &amp;gt; comments.id, note: 'null이면 원본 댓글, 값이 있으면 대댓글']
  content text [not null]
  is_adopted boolean [default: false]     
  reward_score integer [note: '채택 시 부여된 점수 기록']
  created_at timestamp
}

// 5. System Notifications 
Table notifications {
	id integer [primary key, increment]
  user_id integer [ref: &amp;gt; users.id]
  resource_id integer [note: '이동할 issue_id 등']
  type varchar [note: 'comment, adopt, team_invite']
  content text
  is_read boolean [default: false]
  created_at timestamp
}

// 그룹화 (시각적 구분용)
TableGroup Auth_User {
  users
  score_logs
  notifications
}

TableGroup Team_Management {
  teams
  team_members
}

TableGroup Core_Feature {
  error_logs
  error_issues
  comments
}&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;b&gt;1. 기본 테이블 변환&lt;/b&gt;&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;Table users {
  id         integer [primary key, increment]
  email      varchar [unique, not null]
  password   varchar
  nickname   varchar
  created_at timestamp
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;-&amp;gt;&lt;/b&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;kotlin&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  password  String?           // not null 없으면 ? 붙여서 optional
  nickname  String?
  createdAt DateTime @default(now()) @map(&quot;created_at&quot;)

  @@map(&quot;users&quot;)
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;DBML의 [not null]이 없는 필드 &amp;rarr; Prisma에서 ? (nullable)&lt;/li&gt;
&lt;li&gt;snake_case 컬럼명 &amp;rarr; camelCase 필드명 + @map()으로 실제 DB 컬럼명 유지&lt;/li&gt;
&lt;li&gt;테이블명도 @@map()으로 소문자 복수형 유지&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;2. 관계(Relation) 변환&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Prisma는 FK(외래 키)를 단순히 숫자 ID로 끝내지 않고, &lt;b&gt;관계 필드&lt;/b&gt;도 함께 선언해야 한다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;Table team_members {
  id      integer [primary key]
  team_id integer [ref: &amp;gt; teams.id]
  user_id integer [ref: &amp;gt; users.id]
  role    varchar
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;-&amp;gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;model TeamMember {
  id     Int    @id @default(autoincrement())
  teamId Int    @map(&quot;team_id&quot;)
  userId Int    @map(&quot;user_id&quot;)
  role   String

  // 관계 필드 &amp;mdash; 실제 DB 컬럼은 아니지만 Prisma가 JOIN을 처리해줌
  team Team @relation(fields: [teamId], references: [id])
  user User @relation(fields: [userId], references: [id])

  @@map(&quot;team_members&quot;)
}

// 반대편 모델에도 역방향 관계를 선언해야 함
model Team {
  // ...
  teamMembers TeamMember[]  // Team 하나에 여러 TeamMember
}

model User {
  // ...
  teamMembers TeamMember[]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;@relation(fields: [FK필드], references: [상대PK])&amp;nbsp;&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;반대편 모델에도 반드시 역방향 배열 필드 추가 (TeamMember[])&lt;/li&gt;
&lt;li&gt;관계 필드 자체는 실제 DB에 컬럼으로 생성되지 않음&lt;/li&gt;
&lt;/ul&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;b&gt;3. 자기참조 관계 (ex 댓글 &amp;rarr; 대댓글)&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대댓글 구조처럼 같은 테이블끼리 관계를 맺을 때&lt;/p&gt;
&lt;pre class=&quot;applescript&quot;&gt;&lt;code&gt;Table comments {
  id        integer [primary key, increment]
  parent_id integer [ref: &amp;gt; comments.id]
  content   text    [not null]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;-&amp;gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;model Comment {
  id       Int     @id @default(autoincrement())
  parentId Int?    @map(&quot;parent_id&quot;)  // null이면 원본 댓글
  content  String

  // 자기참조 &amp;mdash; 관계 이름을 따옴표로 명시해야 함
  parent  Comment?  @relation(&quot;CommentReplies&quot;, fields: [parentId], references: [id])
  replies Comment[] @relation(&quot;CommentReplies&quot;)

  @@map(&quot;comments&quot;)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;자기참조 관계는 반드시 &quot;관계이름&quot; 을 명시해야 한다&lt;/li&gt;
&lt;li&gt;parent는 optional(?), replies는 배열([])&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;배열 타입 (PostgreSQL)&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;gams&quot;&gt;&lt;code&gt;Table error_issues {
  category varchar[]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;-&amp;gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;model ErrorIssue {
  category String[]  // PostgreSQL에서만 사용 가능
}
&lt;/code&gt;&lt;/pre&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;b&gt;마이그레이션&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;sschema를 수정했다고 DB가 자동으로 바뀌지 않는다.&lt;br /&gt;&lt;b&gt;마이그레이션&lt;/b&gt;은 schema 변경사항을 실제 DB에 반영하는 과정이다.&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;# 개발 환경에서 마이그레이션 실행
npx prisma migrate dev --name init

# 마이그레이션 후 Prisma Client 재생성
npx prisma generate

# DB 현재 상태를 GUI로 확인
npx prisma studio
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마이그레이션을 실행하면 prisma/migrations/ 폴더에 SQL 파일이 자동으로 생성된다.&lt;br /&gt;이 파일들은 Git에 커밋해서 팀원과 공유해야 DB 구조를 동기화할 수 있다.&lt;/p&gt;</description>
      <author>Ea스더</author>
      <guid isPermaLink="true">https://ilove-ya.tistory.com/230</guid>
      <comments>https://ilove-ya.tistory.com/230#entry230comment</comments>
      <pubDate>Thu, 23 Apr 2026 23:52:17 +0900</pubDate>
    </item>
    <item>
      <title>[BookShop-FE] 비밀번호 초기화 페이지 구현, 로그인 상세 구현 - Zustand</title>
      <link>https://ilove-ya.tistory.com/224</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;비밀번호 초기화 페이지 구현&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  app.tsx&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;/reset 페이지 추가&lt;/p&gt;
&lt;pre id=&quot;code_1776707155171&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const router = createBrowserRouter([

  {
    path: &quot;/reset&quot;,
    element: &amp;lt;Layout&amp;gt;&amp;lt;ResetPassword/&amp;gt;&amp;lt;/Layout&amp;gt;
  }
])&lt;/code&gt;&lt;/pre&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;span style=&quot;color: #333333; text-align: start;&quot;&gt; &lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;ResetPassword.tsx&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UI 조건부 렌더링, 버튼 텍스트 변경&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;760&quot; data-origin-height=&quot;366&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/meym0/dJMcagrGoIe/ZUQYMToyOMNvCi5MSSZfA0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/meym0/dJMcagrGoIe/ZUQYMToyOMNvCi5MSSZfA0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/meym0/dJMcagrGoIe/ZUQYMToyOMNvCi5MSSZfA0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fmeym0%2FdJMcagrGoIe%2FZUQYMToyOMNvCi5MSSZfA0%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;760&quot; height=&quot;366&quot; data-origin-width=&quot;760&quot; data-origin-height=&quot;366&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre id=&quot;code_1776707222415&quot; class=&quot;xquery&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;typescript&quot;&gt;&lt;code&gt;import styled from &quot;styled-components&quot;;
import Title from &quot;../components/common/Title&quot;;
import InputText from &quot;../components/common/InputText&quot;;
import Button from &quot;../components/common/Button&quot;;
import { Link, useNavigate } from &quot;react-router-dom&quot;;
import { useState } from &quot;react&quot;;
import { useForm } from 'react-hook-form';
import { signup, resetRequest, resetPassword } from &quot;../api/auth.api&quot;;
import { useAlert } from &quot;../hooks/useAlert&quot;;
import { SignupStyle } from &quot;./Signup&quot;;

export interface SignupProps {
    email: string;
    password: string;
}
function ResetPassword() {
    const navigate = useNavigate();
    const showAlert = useAlert();
    const [resetRequested, setResetRequested] = useState(false);
    // const [email, setEmail] = useState(&quot;&quot;);
    // const [password, setPassword] = useState(&quot;&quot;);
    
    // const handleSubmit = (event: React.FormEvent&amp;lt;HTMLFormElement&amp;gt;) =&amp;gt; {
    //     event.preventDefault(); //페이지 이동을 막아줌

    // }

    const { register, handleSubmit, formState:{errors}} = useForm&amp;lt;SignupProps&amp;gt;();


    const OnSubmit = (data: SignupProps) =&amp;gt; {
        if (resetRequested){
            // 초기화
            resetPassword(data).then(() =&amp;gt; {
                showAlert(&quot;비밀번호가 초기화되었습니다.&quot;);
                navigate(&quot;/login&quot;);
            });

        } else {
            // 요청
            resetRequest(data).then(() =&amp;gt; {
                setResetRequested(true);
            });
        }
    }

    return (
        &amp;lt;&amp;gt;
            &amp;lt;Title size=&quot;large&quot;&amp;gt;비밀번호 초기화&amp;lt;/Title&amp;gt;
            &amp;lt;SignupStyle&amp;gt;
                &amp;lt;form noValidate onSubmit={handleSubmit(OnSubmit)}&amp;gt;
                    &amp;lt;fieldset&amp;gt;
                        &amp;lt;InputText placeholder=&quot;이메일&quot; inputType=&quot;email&quot; {...register(&quot;email&quot;, {required: true})} /&amp;gt;
                        {errors.email &amp;amp;&amp;amp; &amp;lt;p className=&quot;error-text&quot;&amp;gt;이메일을 입력해주세요.&amp;lt;/p&amp;gt;}
                    &amp;lt;/fieldset&amp;gt;
                    {resetRequested &amp;amp;&amp;amp; (
                        &amp;lt;fieldset&amp;gt;
                            &amp;lt;InputText placeholder=&quot;비밀번호&quot; inputType=&quot;password&quot; {...register(&quot;password&quot;, {required: true})}/&amp;gt;
                            {errors.password &amp;amp;&amp;amp; &amp;lt;p className=&quot;error-text&quot;&amp;gt;비밀번호을 입력해주세요.&amp;lt;/p&amp;gt;}
                        &amp;lt;/fieldset&amp;gt;
                    )}
                    &amp;lt;fieldset&amp;gt;
                        &amp;lt;Button type=&quot;submit&quot; size=&quot;medium&quot; scheme=&quot;primary&quot;&amp;gt;
                            {resetRequested ? &quot;비밀번호 초기화&quot; : &quot;초기화 요청&quot;}
                        &amp;lt;/Button&amp;gt;
                    &amp;lt;/fieldset&amp;gt;
                    &amp;lt;div className=&quot;info&quot;&amp;gt;
                        &amp;lt;Link to=&quot;/reset&quot;&amp;gt;비밀번호 초기화&amp;lt;/Link&amp;gt;
                    &amp;lt;/div&amp;gt;
                &amp;lt;/form&amp;gt;
            &amp;lt;/SignupStyle&amp;gt;
        &amp;lt;/&amp;gt;
    )
}


export default ResetPassword;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt; &lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;auth.api.ts&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1776707095235&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export const resetRequest = async(data: SignupProps) =&amp;gt; {
    const response = await httpClient.post(&quot;/users/reset&quot;, data);
    return response.data;
}

export const resetPassword = async(data: SignupProps) =&amp;gt; {
    const response = await httpClient.put(&quot;/users/reset&quot;, data);
    return response.data;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/MRNec/dJMcagFfTTM/P6EJu76UlYpk8lHL0Y8ZK1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/MRNec/dJMcagFfTTM/P6EJu76UlYpk8lHL0Y8ZK1/img.png&quot; style=&quot;width: 53.7135%; margin-right: 10px;&quot; data-widthpercent=&quot;54.35&quot; data-is-animation=&quot;false&quot; data-origin-height=&quot;286&quot; data-origin-width=&quot;746&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/MRNec/dJMcagFfTTM/P6EJu76UlYpk8lHL0Y8ZK1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FMRNec%2FdJMcagFfTTM%2FP6EJu76UlYpk8lHL0Y8ZK1%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;746&quot; height=&quot;286&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/FC9LO/dJMcaju9Wmk/kF8PLK5ZKPfUzkDG6eO65k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/FC9LO/dJMcaju9Wmk/kF8PLK5ZKPfUzkDG6eO65k/img.png&quot; style=&quot;width: 45.1237%;&quot; data-origin-width=&quot;802&quot; data-origin-height=&quot;366&quot; data-is-animation=&quot;false&quot; data-widthpercent=&quot;45.65&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/FC9LO/dJMcaju9Wmk/kF8PLK5ZKPfUzkDG6eO65k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FFC9LO%2FdJMcaju9Wmk%2FkF8PLK5ZKPfUzkDG6eO65k%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;366&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-end=&quot;303&quot; data-start=&quot;286&quot; data-section-id=&quot;d48q35&quot; data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b&gt;1단계: 초기화 요청&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;335&quot; data-start=&quot;304&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;312&quot; data-start=&quot;304&quot; data-section-id=&quot;1kc9ec4&quot;&gt;이메일 입력&lt;/li&gt;
&lt;li data-end=&quot;335&quot; data-start=&quot;313&quot; data-section-id=&quot;1ftth1s&quot;&gt;서버에 비밀번호 초기화 요청 보냄 -&amp;gt; &lt;b&gt;비밀번호 입력 단계로 전환&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;356&quot; data-start=&quot;337&quot; data-section-id=&quot;104oab2&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2단계: 비밀번호 재설정&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;394&quot; data-start=&quot;357&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;374&quot; data-start=&quot;357&quot; data-section-id=&quot;1y6o5xp&quot;&gt;비밀번호 입력창 추가로 노출&lt;/li&gt;
&lt;li data-end=&quot;394&quot; data-start=&quot;375&quot; data-section-id=&quot;bjf07w&quot;&gt;새 비밀번호 전송해서 실제 변경&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;로그인 구현&lt;/b&gt;&lt;/h2&gt;
&lt;pre id=&quot;code_1776707628961&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;로그인 성공 -&amp;gt; 토큰 획득 -&amp;gt; 전역 상태(isLoggedIn: true)
                    -&amp;gt; 토큰 저장(localstorage) &amp;lt;- http client(headers.Authrization)&lt;/code&gt;&lt;/pre&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;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1840&quot; data-origin-height=&quot;1398&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bEYn1r/dJMcag6iGTd/DTT2duysJI8Uu6LAoAqNr0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bEYn1r/dJMcag6iGTd/DTT2duysJI8Uu6LAoAqNr0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bEYn1r/dJMcag6iGTd/DTT2duysJI8Uu6LAoAqNr0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbEYn1r%2FdJMcag6iGTd%2FDTT2duysJI8Uu6LAoAqNr0%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;1840&quot; height=&quot;1398&quot; data-origin-width=&quot;1840&quot; data-origin-height=&quot;1398&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre id=&quot;code_1776708137761&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const login = (req, res)=&amp;gt;{
   const {email, password} = req.body;

   let sql = 'SELECT * FROM users WHERE email = ?';
   conn.query(sql, email,
    (err, results) =&amp;gt; {
        if(err){
           console.log(err);
           return res.status(StatusCodes.BAD_REQUEST).end(); 
        }

        const loginUser = results[0];

        // salt값 꺼내서 날 것으로 들어온 비밀번호 암호화 해보고
        const hashPassword = crypto.pbkdf2Sync(password, loginUser.salt, 1000, 10, 'sha512').toString('base64'); 

        // =&amp;gt; DB 비밀번호랑 비교 
        if(loginUser &amp;amp;&amp;amp; loginUser.password == hashPassword){
            const token = jwt.sign({
                id : loginUser.id,
                email : loginUser.email
            }, process.env.PRIVATE_KEY, {
                expiresIn : '60m',
                issuer : &quot;kayoung&quot;
            })
            
            //토큰 쿠키에 담기
            res.cookie(&quot;token&quot;, token, {
                httpOnly : true
            });
            console.log(token);

            return res.status(StatusCodes.OK).json({...results[0], token: token});
        } else {
            return res.status(StatusCodes.UNAUTHORIZED).end() //401 : Unauthorized
        }

    })
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;user 정보 배열 + 토큰값을 가져오고 있습니다.&amp;nbsp;&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;  app.tsx&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;라우트 등록&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1776708275409&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const router = createBrowserRouter([
    {
    path: &quot;/login&quot;,
    element: &amp;lt;Layout&amp;gt;&amp;lt;Login/&amp;gt;&amp;lt;/Layout&amp;gt;
  }
])&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  auth.api.ts&lt;/p&gt;
&lt;pre id=&quot;code_1776708935673&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;interface LoginResponse {
    token: string;
}

export const login = async(data: SignupProps) =&amp;gt; {
    const response = await httpClient.post&amp;lt;LoginResponse&amp;gt;(&quot;/users/login&quot;, data);
    return response.data;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LoginResponse에서 일단 필요한 토큰만 가져와서 login API 에서 받아와줍니다.&amp;nbsp;&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;  Login.tsx&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1776708988474&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import styled from &quot;styled-components&quot;;
import Title from &quot;../components/common/Title&quot;;
import InputText from &quot;../components/common/InputText&quot;;
import Button from &quot;../components/common/Button&quot;;
import { Link, useNavigate } from &quot;react-router-dom&quot;;
import { useState } from &quot;react&quot;;
import { useForm } from 'react-hook-form';
import { login, signup } from &quot;../api/auth.api&quot;;
import { useAlert } from &quot;../hooks/useAlert&quot;;
import { SignupStyle } from &quot;./Signup&quot;;

export interface SignupProps {
    email: string;
    password: string;
}
function Login() {
    const navigate = useNavigate();
    const showAlert = useAlert();

    const { register, handleSubmit, formState:{errors}} = useForm&amp;lt;SignupProps&amp;gt;();


    const OnSubmit = (data: SignupProps) =&amp;gt; {
        login(data).then((res)=&amp;gt;{
            console.log(res.token);
            showAlert(&quot;로그인 완료되었습니다.&quot;);
            navigate(&quot;/&quot;);
        })
    }

    return (
        &amp;lt;&amp;gt;
            &amp;lt;Title size=&quot;large&quot;&amp;gt;로그인&amp;lt;/Title&amp;gt;
            &amp;lt;SignupStyle&amp;gt;
                &amp;lt;form noValidate onSubmit={handleSubmit(OnSubmit)}&amp;gt;
                    &amp;lt;fieldset&amp;gt;
                        &amp;lt;InputText placeholder=&quot;이메일&quot; inputType=&quot;email&quot; {...register(&quot;email&quot;, {required: true})} /&amp;gt;
                        {errors.email &amp;amp;&amp;amp; &amp;lt;p className=&quot;error-text&quot;&amp;gt;이메일을 입력해주세요.&amp;lt;/p&amp;gt;}
                    &amp;lt;/fieldset&amp;gt;
                    &amp;lt;fieldset&amp;gt;
                        &amp;lt;InputText placeholder=&quot;비밀번호&quot; inputType=&quot;password&quot; {...register(&quot;password&quot;, {required: true})}/&amp;gt;
                        {errors.password &amp;amp;&amp;amp; &amp;lt;p className=&quot;error-text&quot;&amp;gt;비밀번호을 입력해주세요.&amp;lt;/p&amp;gt;}
                    &amp;lt;/fieldset&amp;gt;
                    &amp;lt;fieldset&amp;gt;
                        &amp;lt;Button type=&quot;submit&quot; size=&quot;medium&quot; scheme=&quot;primary&quot;&amp;gt;
                            로그인
                        &amp;lt;/Button&amp;gt;
                    &amp;lt;/fieldset&amp;gt;
                    &amp;lt;div className=&quot;info&quot;&amp;gt;
                        &amp;lt;Link to=&quot;/reset&quot;&amp;gt;비밀번호 초기화&amp;lt;/Link&amp;gt;
                    &amp;lt;/div&amp;gt;
                &amp;lt;/form&amp;gt;
            &amp;lt;/SignupStyle&amp;gt;
        &amp;lt;/&amp;gt;
    )
}


export default Login;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선은 API 연결 후 로그인 성공 시에 홈으로 이동밖에 구현되지 않은 상황입니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 zustand를 써보고자 합니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-end=&quot;67&quot; data-start=&quot;51&quot; data-section-id=&quot;1pqxvgi&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;  Zustand&lt;/b&gt;&lt;/h2&gt;
&lt;p data-end=&quot;126&quot; data-start=&quot;69&quot; data-ke-size=&quot;size16&quot;&gt;: 전역 상태를 간단하게 관리하는 라이브러리 (Redux보다 훨씬 가벼움)&lt;/p&gt;
&lt;p data-end=&quot;144&quot; data-start=&quot;133&quot; data-section-id=&quot;162m9c2&quot; data-ke-size=&quot;size16&quot;&gt;- 보통 상태 관리: useState &amp;rarr; 컴포넌트 안에서만 사용 가능 가능, props로 전달 &amp;rarr; 깊어지면 지옥&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;228&quot; data-start=&quot;215&quot; data-ke-size=&quot;size16&quot;&gt;-&amp;gt; Zustand 쓰면 어디서든 코드 짧고, 설정 거의 없이 어디서든 상태를 접근 가능하게 해줍니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/pmndrs/zustand&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/pmndrs/zustand&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1776709792708&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - pmndrs/zustand:   Bear necessities for state management in React&quot; data-og-description=&quot;  Bear necessities for state management in React. Contribute to pmndrs/zustand development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/pmndrs/zustand&quot; data-og-url=&quot;https://github.com/pmndrs/zustand&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bE4jhL/dJMb8T91APx/nZp9byvLeSeA4RGTSDa741/img.png?width=800&amp;amp;height=467&amp;amp;face=0_0_800_467,https://scrap.kakaocdn.net/dn/cAoCrA/dJMb8PGydy8/ljiR4fTzseV4qAdXwR1FkK/img.png?width=800&amp;amp;height=467&amp;amp;face=0_0_800_467,https://scrap.kakaocdn.net/dn/nSnOR/dJMb8XkhsKr/bQ9pTJkKuQddEjM6CH0EY1/img.jpg?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/pmndrs/zustand&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/pmndrs/zustand&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bE4jhL/dJMb8T91APx/nZp9byvLeSeA4RGTSDa741/img.png?width=800&amp;amp;height=467&amp;amp;face=0_0_800_467,https://scrap.kakaocdn.net/dn/cAoCrA/dJMb8PGydy8/ljiR4fTzseV4qAdXwR1FkK/img.png?width=800&amp;amp;height=467&amp;amp;face=0_0_800_467,https://scrap.kakaocdn.net/dn/nSnOR/dJMb8XkhsKr/bQ9pTJkKuQddEjM6CH0EY1/img.jpg?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;GitHub - pmndrs/zustand:   Bear necessities for state management in React&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;  Bear necessities for state management in React. Contribute to pmndrs/zustand development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;pre id=&quot;code_1776709096200&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;npm i zustand --save&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  store/authStore.ts 생성&lt;/p&gt;
&lt;pre id=&quot;code_1776709812755&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import {create} from &quot;zustand&quot;;

interface StoreState {
    isloggedIn: boolean;
    storeLogin: (token: string) =&amp;gt; void;
    storeLogout: () =&amp;gt; void;
}

const getToken = () =&amp;gt; {
    const token = localStorage.getItem('token');
    return token;
}

const setToken = (token: string) =&amp;gt; {
    localStorage.setItem('token', token);

};

const removeToken = () =&amp;gt; {
    localStorage.removeItem(&quot;token&quot;);
}

export const useAuthStore = create&amp;lt;StoreState&amp;gt;((set) =&amp;gt; ({
    isloggedIn: getToken() ? true : false, //초기값
    storeLogin: (token: string) =&amp;gt; {
        set({isloggedIn:true});
        setToken(token);
    },
    storeLogout: () =&amp;gt; {
        set({isloggedIn: false});
        removeToken();
    }
}))&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1776709853431&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;[로그인]
&amp;rarr; storeLogin(token)
   &amp;rarr; 상태 true
   &amp;rarr; localStorage 저장

[새로고침]
&amp;rarr; getToken() 실행
   &amp;rarr; 있으면 로그인 유지

[로그아웃]
&amp;rarr; storeLogout()
   &amp;rarr; 상태 false
   &amp;rarr; localStorage 삭제&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1776710034071&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function Login() {
    const navigate = useNavigate();
    const showAlert = useAlert();

    const { isloggedIn, storeLogin, storeLogout } = useAuthStore();
    const { register, handleSubmit, formState:{errors}} = useForm&amp;lt;SignupProps&amp;gt;();


    const OnSubmit = (data: SignupProps) =&amp;gt; {
        login(data).then((res)=&amp;gt;{
            // 상태 변화
            storeLogin(res.token);
            
            showAlert(&quot;로그인 완료되었습니다.&quot;);
            navigate(&quot;/&quot;);
        })
    }

    return (
        &amp;lt;&amp;gt;
            &amp;lt;Title size=&quot;large&quot;&amp;gt;로그인&amp;lt;/Title&amp;gt;
            &amp;lt;SignupStyle&amp;gt;
                &amp;lt;form noValidate onSubmit={handleSubmit(OnSubmit)}&amp;gt;
                    &amp;lt;fieldset&amp;gt;
                        &amp;lt;InputText placeholder=&quot;이메일&quot; inputType=&quot;email&quot; {...register(&quot;email&quot;, {required: true})} /&amp;gt;
                        {errors.email &amp;amp;&amp;amp; &amp;lt;p className=&quot;error-text&quot;&amp;gt;이메일을 입력해주세요.&amp;lt;/p&amp;gt;}
                    &amp;lt;/fieldset&amp;gt;
                    &amp;lt;fieldset&amp;gt;
                        &amp;lt;InputText placeholder=&quot;비밀번호&quot; inputType=&quot;password&quot; {...register(&quot;password&quot;, {required: true})}/&amp;gt;
                        {errors.password &amp;amp;&amp;amp; &amp;lt;p className=&quot;error-text&quot;&amp;gt;비밀번호을 입력해주세요.&amp;lt;/p&amp;gt;}
                    &amp;lt;/fieldset&amp;gt;
                    &amp;lt;fieldset&amp;gt;
                        &amp;lt;Button type=&quot;submit&quot; size=&quot;medium&quot; scheme=&quot;primary&quot;&amp;gt;
                            로그인
                        &amp;lt;/Button&amp;gt;
                    &amp;lt;/fieldset&amp;gt;
                    &amp;lt;div className=&quot;info&quot;&amp;gt;
                        &amp;lt;Link to=&quot;/reset&quot;&amp;gt;비밀번호 초기화&amp;lt;/Link&amp;gt;
                    &amp;lt;/div&amp;gt;
                &amp;lt;/form&amp;gt;
            &amp;lt;/SignupStyle&amp;gt;
        &amp;lt;/&amp;gt;
    )
}


export default Login;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그인할 때 이렇게 가져와 사용합니다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1390&quot; data-origin-height=&quot;522&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/1Sb4v/dJMcagkUqCe/zMbXWlYRCykN1kCM4KFM0K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/1Sb4v/dJMcagkUqCe/zMbXWlYRCykN1kCM4KFM0K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/1Sb4v/dJMcagkUqCe/zMbXWlYRCykN1kCM4KFM0K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F1Sb4v%2FdJMcagkUqCe%2FzMbXWlYRCykN1kCM4KFM0K%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;1390&quot; height=&quot;522&quot; data-origin-width=&quot;1390&quot; data-origin-height=&quot;522&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러면 개발자 도구의 local storage에서 token값이 잘 저장되는 것을 볼 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;  Header.tsx&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Header에서도 isloggedIn를 사용해 카트 / 로그인&amp;amp;회원가입 여부를 판단해주었습니다. 또, storeLogout를 로그인 button에 적용시켰습니다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2136&quot; data-origin-height=&quot;172&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/WxBHC/dJMcahREw3K/DAOPPuxQ1ccligOw2FeKz1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/WxBHC/dJMcahREw3K/DAOPPuxQ1ccligOw2FeKz1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/WxBHC/dJMcahREw3K/DAOPPuxQ1ccligOw2FeKz1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FWxBHC%2FdJMcahREw3K%2FDAOPPuxQ1ccligOw2FeKz1%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;2136&quot; height=&quot;172&quot; data-origin-width=&quot;2136&quot; data-origin-height=&quot;172&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2172&quot; data-origin-height=&quot;180&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/1HoUK/dJMcahREw3F/OZtlgXKaptmaFkWQw84QgK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/1HoUK/dJMcahREw3F/OZtlgXKaptmaFkWQw84QgK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/1HoUK/dJMcahREw3F/OZtlgXKaptmaFkWQw84QgK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F1HoUK%2FdJMcahREw3F%2FOZtlgXKaptmaFkWQw84QgK%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;2172&quot; height=&quot;180&quot; data-origin-width=&quot;2172&quot; data-origin-height=&quot;180&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위(로그인 전) / 아래(로그인 후)&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1776710214362&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;            &amp;lt;nav className=&quot;auth&quot;&amp;gt;
                {
                    isloggedIn &amp;amp;&amp;amp; (
                        &amp;lt;ul&amp;gt;
                            &amp;lt;li&amp;gt;&amp;lt;Link to=&quot;/cart&quot;&amp;gt;장바구니&amp;lt;/Link&amp;gt;&amp;lt;/li&amp;gt;
                            &amp;lt;li&amp;gt;&amp;lt;Link to=&quot;/orderlist&quot;&amp;gt;주문 내역&amp;lt;/Link&amp;gt;&amp;lt;/li&amp;gt;
                            &amp;lt;li&amp;gt;&amp;lt;button onClick={storeLogout}&amp;gt;로그아웃&amp;lt;/button&amp;gt;&amp;lt;/li&amp;gt;
                        &amp;lt;/ul&amp;gt;
                    )
                }
                {
                    !isloggedIn &amp;amp;&amp;amp; (
                        &amp;lt;ul&amp;gt;
                            &amp;lt;li&amp;gt;
                                &amp;lt;a href=&quot;/login&quot;&amp;gt;
                                    &amp;lt;FaSignInAlt /&amp;gt;로그인
                                &amp;lt;/a&amp;gt;
                            &amp;lt;/li&amp;gt;
                            &amp;lt;li&amp;gt;
                                &amp;lt;a href=&quot;/signup&quot;&amp;gt;
                                    &amp;lt;FaRegUser /&amp;gt;회원가입
                                &amp;lt;/a&amp;gt;
                            &amp;lt;/li&amp;gt;
                        &amp;lt;/ul&amp;gt;
                    )
                }
            &amp;lt;/nav&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;http client 에 headers.Authorization&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1396&quot; data-origin-height=&quot;1088&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bNCbIM/dJMcageayza/cv6JmAfksnN3crleyLHrF1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bNCbIM/dJMcageayza/cv6JmAfksnN3crleyLHrF1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bNCbIM/dJMcageayza/cv6JmAfksnN3crleyLHrF1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbNCbIM%2FdJMcageayza%2Fcv6JmAfksnN3crleyLHrF1%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;1396&quot; height=&quot;1088&quot; data-origin-width=&quot;1396&quot; data-origin-height=&quot;1088&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;헤더에 Authorization 추가된 것을 볼 수 있습니다.&amp;nbsp;&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;또한 로그인 실패 후 401 에러를 내게 되면, 권한이 없기 때문에 removeToken() 처리해주어야합니다.&amp;nbsp;&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;401에러가 나면 auth.api.ts에서 /login으로 가는건 auth.api.ts의 interceptors로 이미 구현을 해놓았기 때문에&lt;/p&gt;
&lt;pre id=&quot;code_1776879324665&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;
    axiosInstance.interceptors.response.use(
        (response) =&amp;gt; {
            return response;
        },
        (error) =&amp;gt; {
            //로그인 만료 처리
            if(error.response &amp;amp;&amp;amp; error.response.status === 401) {
                removeToken();
                window.location.href = &quot;/login&quot;;
                return;
            }
            return Promise.reject(error);
        }
    )&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1776879222508&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    const OnSubmit = (data: SignupProps) =&amp;gt; {
        login(data).then((res)=&amp;gt;{
            // 상태 변화
            storeLogin(res.token);

            showAlert(&quot;로그인 완료되었습니다.&quot;);
            navigate(&quot;/&quot;);
        }, (error) =&amp;gt; {
            showAlert(&quot;로그인이 실패했습니다.&quot;);
        })
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;login의 then에 error경우를 추가해주면 됩니다.&amp;nbsp;&lt;/p&gt;</description>
      <author>Ea스더</author>
      <guid isPermaLink="true">https://ilove-ya.tistory.com/224</guid>
      <comments>https://ilove-ya.tistory.com/224#entry224comment</comments>
      <pubDate>Tue, 21 Apr 2026 11:33:16 +0900</pubDate>
    </item>
    <item>
      <title>[BookShop-FE] 비밀번호 초기화 페이지 구현, 로그인 상세 구현 - Zustand</title>
      <link>https://ilove-ya.tistory.com/223</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;비밀번호 초기화 페이지 구현&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  app.tsx&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;/reset 페이지 추가&lt;/p&gt;
&lt;pre id=&quot;code_1776707155171&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const router = createBrowserRouter([

  {
    path: &quot;/reset&quot;,
    element: &amp;lt;Layout&amp;gt;&amp;lt;ResetPassword/&amp;gt;&amp;lt;/Layout&amp;gt;
  }
])&lt;/code&gt;&lt;/pre&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;span style=&quot;color: #333333; text-align: start;&quot;&gt; &lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;ResetPassword.tsx&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UI 조건부 렌더링, 버튼 텍스트 변경&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;760&quot; data-origin-height=&quot;366&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/meym0/dJMcagrGoIe/ZUQYMToyOMNvCi5MSSZfA0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/meym0/dJMcagrGoIe/ZUQYMToyOMNvCi5MSSZfA0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/meym0/dJMcagrGoIe/ZUQYMToyOMNvCi5MSSZfA0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fmeym0%2FdJMcagrGoIe%2FZUQYMToyOMNvCi5MSSZfA0%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;760&quot; height=&quot;366&quot; data-origin-width=&quot;760&quot; data-origin-height=&quot;366&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre id=&quot;code_1776707222415&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;typescript&quot;&gt;&lt;code&gt;import styled from &quot;styled-components&quot;;
import Title from &quot;../components/common/Title&quot;;
import InputText from &quot;../components/common/InputText&quot;;
import Button from &quot;../components/common/Button&quot;;
import { Link, useNavigate } from &quot;react-router-dom&quot;;
import { useState } from &quot;react&quot;;
import { useForm } from 'react-hook-form';
import { signup, resetRequest, resetPassword } from &quot;../api/auth.api&quot;;
import { useAlert } from &quot;../hooks/useAlert&quot;;
import { SignupStyle } from &quot;./Signup&quot;;

export interface SignupProps {
    email: string;
    password: string;
}
function ResetPassword() {
    const navigate = useNavigate();
    const showAlert = useAlert();
    const [resetRequested, setResetRequested] = useState(false);
    // const [email, setEmail] = useState(&quot;&quot;);
    // const [password, setPassword] = useState(&quot;&quot;);
    
    // const handleSubmit = (event: React.FormEvent&amp;lt;HTMLFormElement&amp;gt;) =&amp;gt; {
    //     event.preventDefault(); //페이지 이동을 막아줌

    // }

    const { register, handleSubmit, formState:{errors}} = useForm&amp;lt;SignupProps&amp;gt;();


    const OnSubmit = (data: SignupProps) =&amp;gt; {
        if (resetRequested){
            // 초기화
            resetPassword(data).then(() =&amp;gt; {
                showAlert(&quot;비밀번호가 초기화되었습니다.&quot;);
                navigate(&quot;/login&quot;);
            });

        } else {
            // 요청
            resetRequest(data).then(() =&amp;gt; {
                setResetRequested(true);
            });
        }
    }

    return (
        &amp;lt;&amp;gt;
            &amp;lt;Title size=&quot;large&quot;&amp;gt;비밀번호 초기화&amp;lt;/Title&amp;gt;
            &amp;lt;SignupStyle&amp;gt;
                &amp;lt;form noValidate onSubmit={handleSubmit(OnSubmit)}&amp;gt;
                    &amp;lt;fieldset&amp;gt;
                        &amp;lt;InputText placeholder=&quot;이메일&quot; inputType=&quot;email&quot; {...register(&quot;email&quot;, {required: true})} /&amp;gt;
                        {errors.email &amp;amp;&amp;amp; &amp;lt;p className=&quot;error-text&quot;&amp;gt;이메일을 입력해주세요.&amp;lt;/p&amp;gt;}
                    &amp;lt;/fieldset&amp;gt;
                    {resetRequested &amp;amp;&amp;amp; (
                        &amp;lt;fieldset&amp;gt;
                            &amp;lt;InputText placeholder=&quot;비밀번호&quot; inputType=&quot;password&quot; {...register(&quot;password&quot;, {required: true})}/&amp;gt;
                            {errors.password &amp;amp;&amp;amp; &amp;lt;p className=&quot;error-text&quot;&amp;gt;비밀번호을 입력해주세요.&amp;lt;/p&amp;gt;}
                        &amp;lt;/fieldset&amp;gt;
                    )}
                    &amp;lt;fieldset&amp;gt;
                        &amp;lt;Button type=&quot;submit&quot; size=&quot;medium&quot; scheme=&quot;primary&quot;&amp;gt;
                            {resetRequested ? &quot;비밀번호 초기화&quot; : &quot;초기화 요청&quot;}
                        &amp;lt;/Button&amp;gt;
                    &amp;lt;/fieldset&amp;gt;
                    &amp;lt;div className=&quot;info&quot;&amp;gt;
                        &amp;lt;Link to=&quot;/reset&quot;&amp;gt;비밀번호 초기화&amp;lt;/Link&amp;gt;
                    &amp;lt;/div&amp;gt;
                &amp;lt;/form&amp;gt;
            &amp;lt;/SignupStyle&amp;gt;
        &amp;lt;/&amp;gt;
    )
}


export default ResetPassword;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt; &lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;auth.api.ts&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1776707095235&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export const resetRequest = async(data: SignupProps) =&amp;gt; {
    const response = await httpClient.post(&quot;/users/reset&quot;, data);
    return response.data;
}

export const resetPassword = async(data: SignupProps) =&amp;gt; {
    const response = await httpClient.put(&quot;/users/reset&quot;, data);
    return response.data;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/MRNec/dJMcagFfTTM/P6EJu76UlYpk8lHL0Y8ZK1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/MRNec/dJMcagFfTTM/P6EJu76UlYpk8lHL0Y8ZK1/img.png&quot; style=&quot;width: 53.7135%; margin-right: 10px;&quot; data-widthpercent=&quot;54.35&quot; data-is-animation=&quot;false&quot; data-origin-height=&quot;286&quot; data-origin-width=&quot;746&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/MRNec/dJMcagFfTTM/P6EJu76UlYpk8lHL0Y8ZK1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FMRNec%2FdJMcagFfTTM%2FP6EJu76UlYpk8lHL0Y8ZK1%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;746&quot; height=&quot;286&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/FC9LO/dJMcaju9Wmk/kF8PLK5ZKPfUzkDG6eO65k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/FC9LO/dJMcaju9Wmk/kF8PLK5ZKPfUzkDG6eO65k/img.png&quot; style=&quot;width: 45.1237%;&quot; data-origin-width=&quot;802&quot; data-origin-height=&quot;366&quot; data-is-animation=&quot;false&quot; data-widthpercent=&quot;45.65&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/FC9LO/dJMcaju9Wmk/kF8PLK5ZKPfUzkDG6eO65k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FFC9LO%2FdJMcaju9Wmk%2FkF8PLK5ZKPfUzkDG6eO65k%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;366&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-end=&quot;303&quot; data-start=&quot;286&quot; data-section-id=&quot;d48q35&quot; data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b&gt;1단계: 초기화 요청&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;335&quot; data-start=&quot;304&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;312&quot; data-start=&quot;304&quot; data-section-id=&quot;1kc9ec4&quot;&gt;이메일 입력&lt;/li&gt;
&lt;li data-end=&quot;335&quot; data-start=&quot;313&quot; data-section-id=&quot;1ftth1s&quot;&gt;서버에 비밀번호 초기화 요청 보냄 -&amp;gt; &lt;b&gt;비밀번호 입력 단계로 전환&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;356&quot; data-start=&quot;337&quot; data-section-id=&quot;104oab2&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2단계: 비밀번호 재설정&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;394&quot; data-start=&quot;357&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;374&quot; data-start=&quot;357&quot; data-section-id=&quot;1y6o5xp&quot;&gt;비밀번호 입력창 추가로 노출&lt;/li&gt;
&lt;li data-end=&quot;394&quot; data-start=&quot;375&quot; data-section-id=&quot;bjf07w&quot;&gt;새 비밀번호 전송해서 실제 변경&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;로그인 구현&lt;/h2&gt;
&lt;pre id=&quot;code_1776707628961&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;로그인 성공 -&amp;gt; 토큰 획득 -&amp;gt; 전역 상태(isLoggedIn: true)
                    -&amp;gt; 토큰 저장(localstorage) &amp;lt;- http client(headers.Authrization)&lt;/code&gt;&lt;/pre&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;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1840&quot; data-origin-height=&quot;1398&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bEYn1r/dJMcag6iGTd/DTT2duysJI8Uu6LAoAqNr0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bEYn1r/dJMcag6iGTd/DTT2duysJI8Uu6LAoAqNr0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bEYn1r/dJMcag6iGTd/DTT2duysJI8Uu6LAoAqNr0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbEYn1r%2FdJMcag6iGTd%2FDTT2duysJI8Uu6LAoAqNr0%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;1840&quot; height=&quot;1398&quot; data-origin-width=&quot;1840&quot; data-origin-height=&quot;1398&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre id=&quot;code_1776708137761&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const login = (req, res)=&amp;gt;{
   const {email, password} = req.body;

   let sql = 'SELECT * FROM users WHERE email = ?';
   conn.query(sql, email,
    (err, results) =&amp;gt; {
        if(err){
           console.log(err);
           return res.status(StatusCodes.BAD_REQUEST).end(); 
        }

        const loginUser = results[0];

        // salt값 꺼내서 날 것으로 들어온 비밀번호 암호화 해보고
        const hashPassword = crypto.pbkdf2Sync(password, loginUser.salt, 1000, 10, 'sha512').toString('base64'); 

        // =&amp;gt; DB 비밀번호랑 비교 
        if(loginUser &amp;amp;&amp;amp; loginUser.password == hashPassword){
            const token = jwt.sign({
                id : loginUser.id,
                email : loginUser.email
            }, process.env.PRIVATE_KEY, {
                expiresIn : '60m',
                issuer : &quot;kayoung&quot;
            })
            
            //토큰 쿠키에 담기
            res.cookie(&quot;token&quot;, token, {
                httpOnly : true
            });
            console.log(token);

            return res.status(StatusCodes.OK).json({...results[0], token: token});
        } else {
            return res.status(StatusCodes.UNAUTHORIZED).end() //401 : Unauthorized
        }

    })
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;user 정보 배열 + 토큰값을 가져오고 있습니다.&amp;nbsp;&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;  app.tsx&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;라우트 등록&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1776708275409&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const router = createBrowserRouter([
    {
    path: &quot;/login&quot;,
    element: &amp;lt;Layout&amp;gt;&amp;lt;Login/&amp;gt;&amp;lt;/Layout&amp;gt;
  }
])&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  auth.api.ts&lt;/p&gt;
&lt;pre id=&quot;code_1776708935673&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;interface LoginResponse {
    token: string;
}

export const login = async(data: SignupProps) =&amp;gt; {
    const response = await httpClient.post&amp;lt;LoginResponse&amp;gt;(&quot;/users/login&quot;, data);
    return response.data;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LoginResponse에서 일단 필요한 토큰만 가져와서 login API 에서 받아와줍니다.&amp;nbsp;&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;  Login.tsx&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1776708988474&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import styled from &quot;styled-components&quot;;
import Title from &quot;../components/common/Title&quot;;
import InputText from &quot;../components/common/InputText&quot;;
import Button from &quot;../components/common/Button&quot;;
import { Link, useNavigate } from &quot;react-router-dom&quot;;
import { useState } from &quot;react&quot;;
import { useForm } from 'react-hook-form';
import { login, signup } from &quot;../api/auth.api&quot;;
import { useAlert } from &quot;../hooks/useAlert&quot;;
import { SignupStyle } from &quot;./Signup&quot;;

export interface SignupProps {
    email: string;
    password: string;
}
function Login() {
    const navigate = useNavigate();
    const showAlert = useAlert();

    const { register, handleSubmit, formState:{errors}} = useForm&amp;lt;SignupProps&amp;gt;();


    const OnSubmit = (data: SignupProps) =&amp;gt; {
        login(data).then((res)=&amp;gt;{
            console.log(res.token);
            showAlert(&quot;로그인 완료되었습니다.&quot;);
            navigate(&quot;/&quot;);
        })
    }

    return (
        &amp;lt;&amp;gt;
            &amp;lt;Title size=&quot;large&quot;&amp;gt;로그인&amp;lt;/Title&amp;gt;
            &amp;lt;SignupStyle&amp;gt;
                &amp;lt;form noValidate onSubmit={handleSubmit(OnSubmit)}&amp;gt;
                    &amp;lt;fieldset&amp;gt;
                        &amp;lt;InputText placeholder=&quot;이메일&quot; inputType=&quot;email&quot; {...register(&quot;email&quot;, {required: true})} /&amp;gt;
                        {errors.email &amp;amp;&amp;amp; &amp;lt;p className=&quot;error-text&quot;&amp;gt;이메일을 입력해주세요.&amp;lt;/p&amp;gt;}
                    &amp;lt;/fieldset&amp;gt;
                    &amp;lt;fieldset&amp;gt;
                        &amp;lt;InputText placeholder=&quot;비밀번호&quot; inputType=&quot;password&quot; {...register(&quot;password&quot;, {required: true})}/&amp;gt;
                        {errors.password &amp;amp;&amp;amp; &amp;lt;p className=&quot;error-text&quot;&amp;gt;비밀번호을 입력해주세요.&amp;lt;/p&amp;gt;}
                    &amp;lt;/fieldset&amp;gt;
                    &amp;lt;fieldset&amp;gt;
                        &amp;lt;Button type=&quot;submit&quot; size=&quot;medium&quot; scheme=&quot;primary&quot;&amp;gt;
                            로그인
                        &amp;lt;/Button&amp;gt;
                    &amp;lt;/fieldset&amp;gt;
                    &amp;lt;div className=&quot;info&quot;&amp;gt;
                        &amp;lt;Link to=&quot;/reset&quot;&amp;gt;비밀번호 초기화&amp;lt;/Link&amp;gt;
                    &amp;lt;/div&amp;gt;
                &amp;lt;/form&amp;gt;
            &amp;lt;/SignupStyle&amp;gt;
        &amp;lt;/&amp;gt;
    )
}


export default Login;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선은 API 연결 후 로그인 성공 시에 홈으로 이동밖에 구현되지 않은 상황입니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 zustand를 써보고자 합니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-end=&quot;67&quot; data-start=&quot;51&quot; data-section-id=&quot;1pqxvgi&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;  Zustand&lt;/b&gt;&lt;/h2&gt;
&lt;p data-end=&quot;126&quot; data-start=&quot;69&quot; data-ke-size=&quot;size16&quot;&gt;: 전역 상태를 간단하게 관리하는 라이브러리 (Redux보다 훨씬 가벼움)&lt;/p&gt;
&lt;p data-end=&quot;144&quot; data-start=&quot;133&quot; data-section-id=&quot;162m9c2&quot; data-ke-size=&quot;size16&quot;&gt;- 보통 상태 관리: useState &amp;rarr; 컴포넌트 안에서만 사용 가능 가능, props로 전달 &amp;rarr; 깊어지면 지옥&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;228&quot; data-start=&quot;215&quot; data-ke-size=&quot;size16&quot;&gt;-&amp;gt; Zustand 쓰면 어디서든 코드 짧고, 설정 거의 없이 어디서든 상태를 접근 가능하게 해줍니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/pmndrs/zustand&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/pmndrs/zustand&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1776709792708&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - pmndrs/zustand:   Bear necessities for state management in React&quot; data-og-description=&quot;  Bear necessities for state management in React. Contribute to pmndrs/zustand development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/pmndrs/zustand&quot; data-og-url=&quot;https://github.com/pmndrs/zustand&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bE4jhL/dJMb8T91APx/nZp9byvLeSeA4RGTSDa741/img.png?width=800&amp;amp;height=467&amp;amp;face=0_0_800_467,https://scrap.kakaocdn.net/dn/cAoCrA/dJMb8PGydy8/ljiR4fTzseV4qAdXwR1FkK/img.png?width=800&amp;amp;height=467&amp;amp;face=0_0_800_467,https://scrap.kakaocdn.net/dn/nSnOR/dJMb8XkhsKr/bQ9pTJkKuQddEjM6CH0EY1/img.jpg?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/pmndrs/zustand&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/pmndrs/zustand&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bE4jhL/dJMb8T91APx/nZp9byvLeSeA4RGTSDa741/img.png?width=800&amp;amp;height=467&amp;amp;face=0_0_800_467,https://scrap.kakaocdn.net/dn/cAoCrA/dJMb8PGydy8/ljiR4fTzseV4qAdXwR1FkK/img.png?width=800&amp;amp;height=467&amp;amp;face=0_0_800_467,https://scrap.kakaocdn.net/dn/nSnOR/dJMb8XkhsKr/bQ9pTJkKuQddEjM6CH0EY1/img.jpg?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;GitHub - pmndrs/zustand:   Bear necessities for state management in React&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;  Bear necessities for state management in React. Contribute to pmndrs/zustand development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;pre id=&quot;code_1776709096200&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;npm i zustand --save&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  store/authStore.ts 생성&lt;/p&gt;
&lt;pre id=&quot;code_1776709812755&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import {create} from &quot;zustand&quot;;

interface StoreState {
    isloggedIn: boolean;
    storeLogin: (token: string) =&amp;gt; void;
    storeLogout: () =&amp;gt; void;
}

const getToken = () =&amp;gt; {
    const token = localStorage.getItem('token');
    return token;
}

const setToken = (token: string) =&amp;gt; {
    localStorage.setItem('token', token);

};

const removeToken = () =&amp;gt; {
    localStorage.removeItem(&quot;token&quot;);
}

export const useAuthStore = create&amp;lt;StoreState&amp;gt;((set) =&amp;gt; ({
    isloggedIn: getToken() ? true : false, //초기값
    storeLogin: (token: string) =&amp;gt; {
        set({isloggedIn:true});
        setToken(token);
    },
    storeLogout: () =&amp;gt; {
        set({isloggedIn: false});
        removeToken();
    }
}))&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1776709853431&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;[로그인]
&amp;rarr; storeLogin(token)
   &amp;rarr; 상태 true
   &amp;rarr; localStorage 저장

[새로고침]
&amp;rarr; getToken() 실행
   &amp;rarr; 있으면 로그인 유지

[로그아웃]
&amp;rarr; storeLogout()
   &amp;rarr; 상태 false
   &amp;rarr; localStorage 삭제&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1776710034071&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function Login() {
    const navigate = useNavigate();
    const showAlert = useAlert();

    const { isloggedIn, storeLogin, storeLogout } = useAuthStore();
    const { register, handleSubmit, formState:{errors}} = useForm&amp;lt;SignupProps&amp;gt;();


    const OnSubmit = (data: SignupProps) =&amp;gt; {
        login(data).then((res)=&amp;gt;{
            // 상태 변화
            storeLogin(res.token);
            
            showAlert(&quot;로그인 완료되었습니다.&quot;);
            navigate(&quot;/&quot;);
        })
    }

    return (
        &amp;lt;&amp;gt;
            &amp;lt;Title size=&quot;large&quot;&amp;gt;로그인&amp;lt;/Title&amp;gt;
            &amp;lt;SignupStyle&amp;gt;
                &amp;lt;form noValidate onSubmit={handleSubmit(OnSubmit)}&amp;gt;
                    &amp;lt;fieldset&amp;gt;
                        &amp;lt;InputText placeholder=&quot;이메일&quot; inputType=&quot;email&quot; {...register(&quot;email&quot;, {required: true})} /&amp;gt;
                        {errors.email &amp;amp;&amp;amp; &amp;lt;p className=&quot;error-text&quot;&amp;gt;이메일을 입력해주세요.&amp;lt;/p&amp;gt;}
                    &amp;lt;/fieldset&amp;gt;
                    &amp;lt;fieldset&amp;gt;
                        &amp;lt;InputText placeholder=&quot;비밀번호&quot; inputType=&quot;password&quot; {...register(&quot;password&quot;, {required: true})}/&amp;gt;
                        {errors.password &amp;amp;&amp;amp; &amp;lt;p className=&quot;error-text&quot;&amp;gt;비밀번호을 입력해주세요.&amp;lt;/p&amp;gt;}
                    &amp;lt;/fieldset&amp;gt;
                    &amp;lt;fieldset&amp;gt;
                        &amp;lt;Button type=&quot;submit&quot; size=&quot;medium&quot; scheme=&quot;primary&quot;&amp;gt;
                            로그인
                        &amp;lt;/Button&amp;gt;
                    &amp;lt;/fieldset&amp;gt;
                    &amp;lt;div className=&quot;info&quot;&amp;gt;
                        &amp;lt;Link to=&quot;/reset&quot;&amp;gt;비밀번호 초기화&amp;lt;/Link&amp;gt;
                    &amp;lt;/div&amp;gt;
                &amp;lt;/form&amp;gt;
            &amp;lt;/SignupStyle&amp;gt;
        &amp;lt;/&amp;gt;
    )
}


export default Login;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그인할 때 이렇게 가져와 사용합니다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1390&quot; data-origin-height=&quot;522&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/1Sb4v/dJMcagkUqCe/zMbXWlYRCykN1kCM4KFM0K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/1Sb4v/dJMcagkUqCe/zMbXWlYRCykN1kCM4KFM0K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/1Sb4v/dJMcagkUqCe/zMbXWlYRCykN1kCM4KFM0K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F1Sb4v%2FdJMcagkUqCe%2FzMbXWlYRCykN1kCM4KFM0K%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;1390&quot; height=&quot;522&quot; data-origin-width=&quot;1390&quot; data-origin-height=&quot;522&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러면 개발자 도구의 local storage에서 token값이 잘 저장되는 것을 볼 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;  Header.tsx&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Header에서도 isloggedIn를 사용해 카트 / 로그인&amp;amp;회원가입 여부를 판단해주었습니다. 또, storeLogout를 로그인 button에 적용시켰습니다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2136&quot; data-origin-height=&quot;172&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/WxBHC/dJMcahREw3K/DAOPPuxQ1ccligOw2FeKz1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/WxBHC/dJMcahREw3K/DAOPPuxQ1ccligOw2FeKz1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/WxBHC/dJMcahREw3K/DAOPPuxQ1ccligOw2FeKz1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FWxBHC%2FdJMcahREw3K%2FDAOPPuxQ1ccligOw2FeKz1%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;2136&quot; height=&quot;172&quot; data-origin-width=&quot;2136&quot; data-origin-height=&quot;172&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2172&quot; data-origin-height=&quot;180&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/1HoUK/dJMcahREw3F/OZtlgXKaptmaFkWQw84QgK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/1HoUK/dJMcahREw3F/OZtlgXKaptmaFkWQw84QgK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/1HoUK/dJMcahREw3F/OZtlgXKaptmaFkWQw84QgK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F1HoUK%2FdJMcahREw3F%2FOZtlgXKaptmaFkWQw84QgK%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;2172&quot; height=&quot;180&quot; data-origin-width=&quot;2172&quot; data-origin-height=&quot;180&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위(로그인 전) / 아래(로그인 후)&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1776710214362&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;            &amp;lt;nav className=&quot;auth&quot;&amp;gt;
                {
                    isloggedIn &amp;amp;&amp;amp; (
                        &amp;lt;ul&amp;gt;
                            &amp;lt;li&amp;gt;&amp;lt;Link to=&quot;/cart&quot;&amp;gt;장바구니&amp;lt;/Link&amp;gt;&amp;lt;/li&amp;gt;
                            &amp;lt;li&amp;gt;&amp;lt;Link to=&quot;/orderlist&quot;&amp;gt;주문 내역&amp;lt;/Link&amp;gt;&amp;lt;/li&amp;gt;
                            &amp;lt;li&amp;gt;&amp;lt;button onClick={storeLogout}&amp;gt;로그아웃&amp;lt;/button&amp;gt;&amp;lt;/li&amp;gt;
                        &amp;lt;/ul&amp;gt;
                    )
                }
                {
                    !isloggedIn &amp;amp;&amp;amp; (
                        &amp;lt;ul&amp;gt;
                            &amp;lt;li&amp;gt;
                                &amp;lt;a href=&quot;/login&quot;&amp;gt;
                                    &amp;lt;FaSignInAlt /&amp;gt;로그인
                                &amp;lt;/a&amp;gt;
                            &amp;lt;/li&amp;gt;
                            &amp;lt;li&amp;gt;
                                &amp;lt;a href=&quot;/signup&quot;&amp;gt;
                                    &amp;lt;FaRegUser /&amp;gt;회원가입
                                &amp;lt;/a&amp;gt;
                            &amp;lt;/li&amp;gt;
                        &amp;lt;/ul&amp;gt;
                    )
                }
            &amp;lt;/nav&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;http client 에 headers.Authorization&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1396&quot; data-origin-height=&quot;1088&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bNCbIM/dJMcageayza/cv6JmAfksnN3crleyLHrF1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bNCbIM/dJMcageayza/cv6JmAfksnN3crleyLHrF1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bNCbIM/dJMcageayza/cv6JmAfksnN3crleyLHrF1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbNCbIM%2FdJMcageayza%2Fcv6JmAfksnN3crleyLHrF1%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;1396&quot; height=&quot;1088&quot; data-origin-width=&quot;1396&quot; data-origin-height=&quot;1088&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;헤더에 Authorization 추가된 것을 볼 수 있습니다.&amp;nbsp;&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;또한 로그인 실패 후 401 에러를 내게 되면, 권한이 없기 때문에 removeToken() 처리해주어야합니다.&amp;nbsp;&lt;/p&gt;</description>
      <author>Ea스더</author>
      <guid isPermaLink="true">https://ilove-ya.tistory.com/223</guid>
      <comments>https://ilove-ya.tistory.com/223#entry223comment</comments>
      <pubDate>Tue, 21 Apr 2026 04:09:57 +0900</pubDate>
    </item>
    <item>
      <title>[BookShop-FE] 회원가입 페이지 구현, React Hook Form</title>
      <link>https://ilove-ya.tistory.com/219</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;라우터 등록&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;라우터에 경로를 등록&lt;/b&gt;해야 합니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;bash&quot; style=&quot;color: #14181f;&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;import Signup from &quot;./pages/Signup&quot;;  

const router = createBrowserRouter([
  { path: &quot;/&quot;, element: &amp;lt;Layout&amp;gt;&amp;lt;Home /&amp;gt;&amp;lt;/Layout&amp;gt; },
  { path: &quot;/books&quot;, element: &amp;lt;Layout&amp;gt;&amp;lt;div&amp;gt;도서 목록&amp;lt;/div&amp;gt;&amp;lt;/Layout&amp;gt; },
  { path: &quot;/signup&quot;, element: &amp;lt;Layout&amp;gt;&amp;lt;Signup /&amp;gt;&amp;lt;/Layout&amp;gt; }, 
])&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;/signup 경로로 접근하면 Layout 안에 Signup 컴포넌트가 렌더링됩니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;컴포넌트 Props 확장 (Button, InputText)&lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Button &amp;mdash; React.ButtonHTMLAttributes 확장&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;groovy&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;// 변경 전
interface Props {
  children: React.ReactNode;
  size: ButtonSize;
  scheme: ButtonScheme;
  disabled?: boolean;
  isLoading?: boolean;
}

// 변경 후
interface Props extends React.ButtonHTMLAttributes&amp;lt;HTMLButtonElement&amp;gt; {
  // 기존 Props는 동일하되, HTML button의 모든 속성을 추가로 사용 가능
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;extends React.ButtonHTMLAttributes&amp;lt;HTMLButtonElement&amp;gt;를 추가하면 type=&quot;submit&quot;, onClick, onFocus 같은 &lt;b&gt;HTML 기본 속성을 별도 선언 없이&lt;/b&gt; 그냥 쓸 수 있습니다. Signup 폼에서 &amp;lt;Button type=&quot;submit&quot;&amp;gt; 처럼 쓸 수 있는 이유입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;InputText &amp;mdash; inputType과 ...props 전달&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;bash&quot; style=&quot;color: #14181f;&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;interface Props extends React.InputHTMLAttributes&amp;lt;HTMLInputElement&amp;gt; { 
	//기존 input이 가지고 있는 속성들 그대로 사용가능
    //추가로
  placeholder?: string;
  inputType?: &quot;text&quot; | &quot;email&quot; | &quot;password&quot; | &quot;number&quot;;  // 타입 제한
}

const InputText = React.forwardRef(
  ({ placeholder, inputType, onChange, ...props }: Props, ref) =&amp;gt; {
    return (
      &amp;lt;InputTextStyle
        placeholder={placeholder}
        ref={ref}
        type={inputType}       // &amp;larr; inputType을 type으로 연결
        onChange={onChange}
        {...props}             // &amp;larr; 나머지 HTML 속성 전부 전달
      /&amp;gt;
    );
  }
);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;forwardRef를 쓰는 이유는 부모 컴포넌트에서 ref로 input DOM에 직접 접근할 수 있게 하기 위해서입니다. (e.g. 자동 포커스, 유효성 검사)&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;회원가입 페이지 (Signup.tsx)&lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;상태 관리 &amp;mdash; useState&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;actionscript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;const [email, setEmail] = useState(&quot;&quot;);
const [password, setPassword] = useState(&quot;&quot;);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;email과 password를 각각 상태로 관리합니다. input이 바뀔 때마다 상태가 업데이트되는 &lt;b&gt;제어 컴포넌트(Controlled Component)&lt;/b&gt; 패턴입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;폼 제출 &amp;mdash; handleSubmit&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;const handleSubmit = (event: React.FormEvent&amp;lt;HTMLFormElement&amp;gt;) =&amp;gt; {
  event.preventDefault();  // 브라우저 기본 동작(페이지 새로고침) 차단
  // 여기서 API 호출 예정
};&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;event.preventDefault()가 없으면 폼 제출 시 페이지가 새로고침되어 상태가 초기화됩니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;bash&quot; style=&quot;color: #14181f;&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;import styled from &quot;styled-components&quot;;
import Title from &quot;../components/common/Title&quot;;
import InputText from &quot;../components/common/InputText&quot;;
import Button from &quot;../components/common/Button&quot;;
import { Link } from &quot;react-router-dom&quot;;
import { useState } from &quot;react&quot;;

function Signup() {
    const [email, setEmail] = useState(&quot;&quot;);
    const [password, setPassword] = useState(&quot;&quot;);
    
    const handleSubmit = (event: React.FormEvent&amp;lt;HTMLFormElement&amp;gt;) =&amp;gt; {
        event.preventDefault(); //페이지 이동을 막아줌

    }
    return (
        &amp;lt;&amp;gt;
            &amp;lt;Title size=&quot;large&quot;&amp;gt;회원가입&amp;lt;/Title&amp;gt;
            &amp;lt;SignupStyle&amp;gt;
                &amp;lt;form onSubmit={handleSubmit}&amp;gt;
                    &amp;lt;fieldset&amp;gt;
                        &amp;lt;InputText placeholder=&quot;이메일&quot; inputType=&quot;email&quot; value={email} onChange={(e) =&amp;gt; setEmail(e.target.value)}/&amp;gt;
                    &amp;lt;/fieldset&amp;gt;
                    &amp;lt;fieldset&amp;gt;
                        &amp;lt;InputText placeholder=&quot;비밀번호&quot; inputType=&quot;password&quot; value={password} onChange={(e) =&amp;gt; setPassword(e.target.value)}/&amp;gt;
                    &amp;lt;/fieldset&amp;gt;
                    &amp;lt;fieldset&amp;gt;
                        &amp;lt;Button type=&quot;submit&quot; size=&quot;medium&quot; scheme=&quot;primary&quot;&amp;gt;
                            회원가입
                        &amp;lt;/Button&amp;gt;
                    &amp;lt;/fieldset&amp;gt;
                    &amp;lt;div className=&quot;info&quot;&amp;gt;
                        &amp;lt;Link to=&quot;/reset&quot;&amp;gt;비밀번호 초기화&amp;lt;/Link&amp;gt;
                    &amp;lt;/div&amp;gt;
                &amp;lt;/form&amp;gt;
            &amp;lt;/SignupStyle&amp;gt;
        &amp;lt;/&amp;gt;
    )
}

const SignupStyle = styled.div`
    max-width: ${({theme}) =&amp;gt; theme.layout.width.small};
    margin: 80px auto;

    fieldset {
        border: 0;
        padding: 0 0 8px 0
        .error-text {
            color: red;
        }
    }
    
    input {
        width: 100%;
    }

    button {
        width: 100%;;
    }

    .info {
        text-align: center;
        padding: 16px 0 0 0;
    }
`;

export default Signup;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&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;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;React Hook Form 사용해 onChange 간단화하기&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재의 방식은 onChange를 하나하나 만들고 있습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 이렇게되면 onChange가 불필요하게 많아지고, 길어질 수 있습니다.&amp;nbsp;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;pf&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;사용자 입력
    &amp;darr;
onChange &amp;rarr; setEmail / setPassword (상태 업데이트)
    &amp;darr;
버튼 클릭 &amp;rarr; form의 onSubmit 발동
    &amp;darr;
handleSubmit &amp;rarr; event.preventDefault() &amp;rarr; API 호출 (예정)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;/div&gt;
&lt;/div&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;b&gt;React Hook Form&lt;/b&gt; 이라는 라이브러리를 사용해보고자합니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://react-hook-form.com/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://react-hook-form.com/&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1776602578490&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;React Hook Form - performant, flexible and extensible form library&quot; data-og-description=&quot;Performant, flexible and extensible forms with easy-to-use validation.&quot; data-og-host=&quot;react-hook-form.com&quot; data-og-source-url=&quot;https://react-hook-form.com/&quot; data-og-url=&quot;https://react-hook-form.com/&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://react-hook-form.com/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://react-hook-form.com/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url();&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;React Hook Form - performant, flexible and extensible form library&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Performant, flexible and extensible forms with easy-to-use validation.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;react-hook-form.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;pre id=&quot;code_1776603153795&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;npm install react-hook-form&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;npm 설치해준 후,&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1776603139010&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { useForm } from 'react-hook-form';&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;useForm을 사용해보겠습니다.&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;useForm 기본 사용&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;cpp&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;const { register, handleSubmit, formState: { errors } } = useForm&amp;lt;SignupProps&amp;gt;();&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;useForm에서 꺼내 쓰는 세 가지입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;register&lt;/b&gt; : input을 React Hook Form에 등록. {...register(&quot;email&quot;)} 형태로 spread하면 onChange, onBlur, ref 등이 자동으로 연결됩니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;handleSubmit&lt;/b&gt; : 유효성 검사를 통과했을 때만 콜백을 실행해줍니다. 통과 못 하면 콜백 자체가 호출되지 않습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;formState.errors&lt;/b&gt; : 유효성 검사 실패 시 어떤 필드가 왜 실패했는지 담겨 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이전에는 useState로 각 필드를 따로 관리하고, onChange도 직접 작성했는데, register를 spread하는 것만으로 그 과정이 전부 대체됩니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;// 변경 전
&amp;lt;InputText value={email} onChange={(e) =&amp;gt; setEmail(e.target.value)} /&amp;gt;

// 변경 후
&amp;lt;InputText {...register(&quot;email&quot;, { required: true })} /&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;noValidate&lt;/b&gt;&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;xml&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;&amp;lt;form noValidate onSubmit={handleSubmit(OnSubmit)}&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;noValidate를 추가한 이유는 브라우저 기본 유효성 검사(말풍선 팝업)를 끄기 위해서입니다. React Hook Form의 errors로 직접 에러 메시지를 렌더링하기 때문에, 브라우저 기본 UI와 충돌하지 않도록 비활성화합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;xml&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;{errors.email &amp;amp;&amp;amp; &amp;lt;p className=&quot;error-text&quot;&amp;gt;이메일을 입력해주세요.&amp;lt;/p&amp;gt;}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;errors.email은&lt;b&gt; 해당 필드가 유효성 검사를 통과하지 못했을 때만 존재&lt;/b&gt;합니다. 단락 평가(&amp;amp;&amp;amp;)로 간단하게 조건부 렌더링할 수 있습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;auth API 연동&amp;nbsp;&lt;/b&gt;&lt;/h2&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;  auth.api.ts&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;dart&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;export const signup = async (userData: SignupProps) =&amp;gt; {
    const response = await httpClient.post(&quot;/users/join&quot;, userData);
    return response.data;
};&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SignupProps 타입을 Signup.tsx에서 export해서 여기서도 그대로 재사용하고 있습니다. API 함수와 페이지 컴포넌트가 같은 타입을 공유하기 때문에 타입 불일치 실수를 방지할 수 있습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;회원가입 성공 처리 흐름&lt;/b&gt;&lt;/h2&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;coffeescript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;const OnSubmit = (data: SignupProps) =&amp;gt; {
    signup(data).then((res) =&amp;gt; {
        showAlert('회원가입이 완료되었습니다.');
        navigate('/login');
    });
};&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&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;Alert로 완료 메시지를 보여줍니다.&lt;/li&gt;
&lt;li&gt;useNavigate로 로그인 페이지로 이동합니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;useAlert 커스텀 훅&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;  hooks/useAlert.ts&lt;/b&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;typescript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;export const useAlert = () =&amp;gt; {
    const showAlert = useCallback((message: string) =&amp;gt; {
        window.alert(message);
    }, []);

    return showAlert;
};&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금 당장은 window.alert를 그냥 써도 동작은 동일합니다. 그런데 별도 훅으로 분리한 이유는, 나중에 브라우저 기본 alert 대신 커스텀 모달이나 토스트 UI로 교체할 때 &lt;b&gt;useAlert만 수정하면 전체 적용&lt;/b&gt;되기 때문입니다. 컴포넌트 코드는 건드리지 않아도 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;useCallback : &lt;/b&gt;함수를 메모이제이션해서 리렌더링 시 새로 생성되지 않게 하는 훅, 의존성 배열이 []이므로 처음 만든 함수 계속 재사용&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;전체 흐름 정리&lt;/b&gt;&lt;/h2&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;cpp&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;사용자 입력
    &amp;darr;
register로 연결된 input이 값을 추적 (useState 불필요)
    &amp;darr;
버튼 클릭 &amp;rarr; handleSubmit 발동
    &amp;darr;
유효성 검사 통과 실패 &amp;rarr; errors 업데이트 &amp;rarr; 에러 메시지 렌더링
유효성 검사 통과 성공 &amp;rarr; OnSubmit 호출
    &amp;darr;
signup API 호출 &amp;rarr; 성공 시 Alert + 로그인 페이지로 이동&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Devcourse/BookShop 사이드 프로젝트</category>
      <category>react</category>
      <category>React Hook Form</category>
      <category>typescript</category>
      <author>Ea스더</author>
      <guid isPermaLink="true">https://ilove-ya.tistory.com/219</guid>
      <comments>https://ilove-ya.tistory.com/219#entry219comment</comments>
      <pubDate>Sun, 19 Apr 2026 16:14:53 +0900</pubDate>
    </item>
    <item>
      <title>CORS의 개념, 주의점에 대해 알아봅쉬다</title>
      <link>https://ilove-ya.tistory.com/218</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://ilove-ya.tistory.com/215&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://ilove-ya.tistory.com/215&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1776579319228&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[Axios] Axios Network Error &amp;amp; CORS 오류 해결&quot; data-og-description=&quot;문제 상황프론트에서 Axios 요청을 보냈을 때 다음과 같은 에러가 발생했다. AxiosError: Network Error개발자 도구 콘솔에서는 추가로 아래 에러가 확인되었다.Access to XMLHttpRequest at 'http://localhost:9999/cate&quot; data-og-host=&quot;ilove-ya.tistory.com&quot; data-og-source-url=&quot;https://ilove-ya.tistory.com/215&quot; data-og-url=&quot;https://ilove-ya.tistory.com/215&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bwyt7p/dJMb9lk82TN/xlv4iKs4mvjFNoF9eBHgFk/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/bbLWUF/dJMb8XR7APx/TnbddOq9U4B95fmkk9xbi1/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800&quot;&gt;&lt;a href=&quot;https://ilove-ya.tistory.com/215&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://ilove-ya.tistory.com/215&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bwyt7p/dJMb9lk82TN/xlv4iKs4mvjFNoF9eBHgFk/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/bbLWUF/dJMb8XR7APx/TnbddOq9U4B95fmkk9xbi1/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[Axios] Axios Network Error &amp;amp; CORS 오류 해결&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;문제 상황프론트에서 Axios 요청을 보냈을 때 다음과 같은 에러가 발생했다. AxiosError: Network Error개발자 도구 콘솔에서는 추가로 아래 에러가 확인되었다.Access to XMLHttpRequest at 'http://localhost:9999/cate&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;ilove-ya.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프론트에서 Axios 요청을 보냈을 때 이런 에러가 발생했다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;pgsql&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;Access to XMLHttpRequest at 'http://localhost:9999/category' from origin 'http://localhost:3000' 
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음엔 서버 문제인 줄 알았다. 그런데 서버는 멀쩡히 돌아가고 있었고, Postman으로 요청하면 정상 응답이 왔다. 브라우저에서만 막히는 상황이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 오류를 해결하면서 &lt;b&gt;CORS&lt;/b&gt;라는 개념을 처음 알게 되었고, 정확히 무엇인지 이해하고자 이 글을 작성하게 되었다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;CORS(&lt;b&gt;Cross-Origin Resource Sharing)&lt;/b&gt;란?&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;: 다른 출처(Origin)에서 오는 요청을 브라우저가 허용할지 말지를 결정하는 보안 정책&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;CORS&lt;/b&gt;는 &lt;b&gt;Cross-Origin Resource Sharing&lt;/b&gt;의 약자로, 번역하면 &lt;b&gt;교차 출처 리소스 공유&lt;/b&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;b&gt;출처(Origin)&lt;/b&gt;란?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;출처는 &lt;b&gt;프로토콜 + 도메인 + 포트&lt;/b&gt; 세 가지가 모두 같아야 같은 출처다.&lt;/p&gt;
&lt;div&gt;URL같은 출처?이유
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;http://localhost:3000 &amp;rarr; http://localhost:3000/api&lt;/td&gt;
&lt;td&gt;✅ 같음&lt;/td&gt;
&lt;td&gt;셋 다 동일&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;http://localhost:3000 &amp;rarr; http://localhost:9999&lt;/td&gt;
&lt;td&gt;❌ 다름&lt;/td&gt;
&lt;td&gt;&lt;b&gt;포트&lt;/b&gt;가 다름&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;http://localhost:3000 &amp;rarr; https://localhost:3000&lt;/td&gt;
&lt;td&gt;❌ 다름&lt;/td&gt;
&lt;td&gt;&lt;b&gt;프로토콜&lt;/b&gt;이 다름&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;http://localhost:3000 &amp;rarr; &lt;a href=&quot;http://myserver.com&quot;&gt;http://myserver.com&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;❌ 다름&lt;/td&gt;
&lt;td&gt;&lt;b&gt;도메인&lt;/b&gt;이 다름&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내 상황이 딱 이 경우였다. 프론트는 localhost:3000, 백엔드는 localhost:9999로 &lt;b&gt;포트가 달라서 다른 출처&lt;/b&gt;였다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;왜 브라우저는 다른 출처 요청을 막는가?&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이건 브라우저의 기본 보안 정책인 &lt;b&gt;SOP(Same-Origin Policy, 동일 출처 정책)&lt;/b&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;브라우저는 &quot;&lt;b&gt;내가 열어준 사이트가 다른 서버에 몰래 요청 보내는 걸 기본으로 막겠&lt;/b&gt;다&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;예를 들어, 악의적인 사이트가 당신이 로그인된 상태를 이용해서 은행 서버에 몰래 요청을 보낼 수 있다. SOP는 이런 공격을 브라우저 레벨에서 원천 차단한다.&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;Postman은 브라우저가 아니기 때문에 SOP 정책 자체가 없기때문에, Postman에서는 잘 작동한다. CORS는 순전히 &lt;b&gt;브라우저의 보안 정책&lt;/b&gt;이다. 서버가 막은 게 아니라, 브라우저가 막은 것이다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;CORS는 어떻게 동작하는가?&lt;/b&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;단순 요청 (Simple Request)&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GET, POST 같은 단순한 요청은 바로 보내고, 서버 응답 헤더를 확인한다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;cs&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;[브라우저] &amp;rarr; 요청 전송
[서버]     &amp;rarr; 응답 + Access-Control-Allow-Origin 헤더
[브라우저] &amp;rarr; 헤더 확인 후 허용 / 차단 결정&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&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;b&gt;사전 요청 (Preflight Request)&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PUT, DELETE나 커스텀 헤더가 포함된 요청은 본 요청 전에 &lt;b&gt;OPTIONS 메서드로 먼저 물어본다.&lt;/b&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;less&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;[브라우저] &amp;rarr; OPTIONS 요청 (이 요청 보내도 돼?)
[서버]     &amp;rarr; 허용 여부 응답
[브라우저] &amp;rarr; 허용됐으면 본 요청 전송&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내 경우처럼&lt;b&gt; withCredentials: true&lt;/b&gt;를 쓰면 무조건 Preflight가 발생하고, &lt;b&gt;CORS 설정이 더 엄격하게 적용된다.&lt;/b&gt;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;서버에서 CORS 설정하는 법 (Express 기준)&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CORS를 해결하는 주체는 &lt;b&gt;서버&lt;/b&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;b&gt;1. cors 패키지 설치&lt;/b&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;cmake&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;npm install cors&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. 미들웨어 등록 (라우터보다 반드시 먼저!)&lt;/b&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;stata&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;const cors = require(&quot;cors&quot;);

app.use(cors({
  origin: &quot;http://localhost:3000&quot;,  // 허용할 출처
  credentials: true                 // 쿠키 허용 여부
}));

// 라우터는 항상 cors 아래에
app.use(&quot;/users&quot;, userRouter);
app.use(&quot;/books&quot;, bookRouter);
app.use(&quot;/category&quot;, categoryRouter);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;div&gt;옵션설명예시
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;origin&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;허용할 출처&lt;/td&gt;
&lt;td&gt;&quot;http://localhost:3000&quot;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;credentials&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;쿠키/인증 허용 여부&lt;/td&gt;
&lt;td&gt;true&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;methods&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;허용할 HTTP 메서드&lt;/td&gt;
&lt;td&gt;[&quot;GET&quot;, &quot;POST&quot;, &quot;PUT&quot;]&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;allowedHeaders&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;허용할 요청 헤더&lt;/td&gt;
&lt;td&gt;[&quot;Content-Type&quot;, &quot;Authorization&quot;]&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;origin을 여러 개 허용하고 싶을 때에는 이렇게 적용할 수 도 있다.&amp;nbsp;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;css&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;app.use(cors({
  origin: [&quot;http://localhost:3000&quot;, &quot;https://myapp.com&quot;],
  credentials: true
}));&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&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;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;php&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;app.use(cors()); // 운영 환경에서는 절대 사용 금지&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;withCredentials: true 쓸 때 주의할 점&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프론트에서 withCredentials: true를 설정하면 쿠키나 인증 정보를 요청에 포함시킨다. 이 경우 CORS 설정이 더 엄격해진다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;actionscript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;// 프론트 (Axios)
const axiosInstance = axios.create({
  baseURL: &quot;http://localhost:9999&quot;,
  withCredentials: true
});&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 때 서버에서 origin: &quot;*&quot; (전체 허용)으로 설정하면 브라우저가 &lt;b&gt;무조건 에러&lt;/b&gt;를 낸다. 반드시 출처를 구체적으로 명시해야 한다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;groovy&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;// ❌ 안 됨
app.use(cors({ origin: &quot;*&quot;, credentials: true }));

// ✅ 됨
app.use(cors({ origin: &quot;http://localhost:3000&quot;, credentials: true }));&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&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;</description>
      <author>Ea스더</author>
      <guid isPermaLink="true">https://ilove-ya.tistory.com/218</guid>
      <comments>https://ilove-ya.tistory.com/218#entry218comment</comments>
      <pubDate>Sun, 19 Apr 2026 16:07:34 +0900</pubDate>
    </item>
    <item>
      <title>[BookShop-FE] Route 틀 짜고, 백엔드와 연결해보자.</title>
      <link>https://ilove-ya.tistory.com/217</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Route&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. React Router 사용&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2.&lt;/p&gt;
&lt;pre id=&quot;code_1776577224597&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;npm install react-router-dom @ types/react-router-dom --save&lt;/code&gt;&lt;/pre&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;b&gt;Endpoint&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 로그인 /login&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 회원가입 / signup&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. 비밀번호 초기화 /reset&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4. 도서 목록 /books&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;5. 도서 상세 /books/{id}&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;6. 장바구니 /cart&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;7. 주문서 작성 /order&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;8. 주문 목록 /orderlist&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;Route 지정해주기&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;react-router-dom의 &lt;b&gt;createBroserRouter&lt;/b&gt;, &lt;b&gt;RouterProvider&lt;/b&gt; 을 사용해 route를 지정해줄 수 있습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;4,0,0&quot;&gt;createBrowserRouter&lt;/b&gt;: 라우트 설정을 자바스크립트 객체 배열 형태로 생성, 어떤 경로(path)에서 어떤 컴포넌트(element)를 보여줄지 정의하는 리스트&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;4,1,0&quot;&gt;RouterProvider&lt;/b&gt;: 위에서 생성한 라우터 객체를 실제 React 앱에 주입하는 역할, 앱의 &lt;b&gt;최상단에서 한 번만 사용&lt;/b&gt;합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;App.tsx&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1776357734866&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import Layout from &quot;./components/layout/Layout&quot;;
import Home from &quot;./pages/Home&quot;;
import ThemeSwitcher from &quot;./components/header/ThemeSwitcher&quot;;
import { useContext } from &quot;react&quot;;
import { BookStoreThemeProvider, ThemeContext } from &quot;./context/themeContext&quot;;
import { createBrowserRouter, RouterProvider } from &quot;react-router-dom&quot;;

const router = createBrowserRouter([
  {
    path: &quot;/&quot;,
    element: &amp;lt;Home /&amp;gt;,
  },
  {
    path: &quot;./books&quot;,
    element: &amp;lt;div&amp;gt;도서 목록&amp;lt;/div&amp;gt;
  }
])

function App() {
  const {themeName, toggleTheme} = useContext(ThemeContext);  
  return (
    &amp;lt;&amp;gt;
      &amp;lt;BookStoreThemeProvider&amp;gt;
        &amp;lt;Layout&amp;gt;
            &amp;lt;RouterProvider router={router} /&amp;gt;  
        &amp;lt;/Layout&amp;gt;
      &amp;lt;/BookStoreThemeProvider&amp;gt;
    &amp;lt;/&amp;gt;
  );
}

export default App;&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재에는 예상하지 못한 url 로 들어오면 에러페이지가 뜨는 상태. 이 공통적인 에러 페이지를 만들어보겠습니다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2192&quot; data-origin-height=&quot;838&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/NsCpm/dJMcadBBSHa/xk86SOA6flfBHAo4HzQWOk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/NsCpm/dJMcadBBSHa/xk86SOA6flfBHAo4HzQWOk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/NsCpm/dJMcadBBSHa/xk86SOA6flfBHAo4HzQWOk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FNsCpm%2FdJMcadBBSHa%2Fxk86SOA6flfBHAo4HzQWOk%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;2192&quot; height=&quot;838&quot; data-origin-width=&quot;2192&quot; data-origin-height=&quot;838&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  Error.tsx&lt;/p&gt;
&lt;pre id=&quot;code_1776358178126&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { useRouteError } from &quot;react-router-dom&quot;;

interface RouteError {
    statusText?: string;
    message?: String;
}
function Error() {
    const error = useRouteError() as RouteError;
    return (
        &amp;lt;div&amp;gt;
            &amp;lt;h1&amp;gt;오류가 발생했습니다.&amp;lt;/h1&amp;gt;
            &amp;lt;p&amp;gt;다음과 같은 오류가 발생했습니다. &amp;lt;/p&amp;gt;
            &amp;lt;p&amp;gt;{error.statusText || error.message}&amp;lt;/p&amp;gt;
        &amp;lt;/div&amp;gt;
    )
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt; &lt;span&gt; &lt;/span&gt;&lt;/span&gt;app.ts&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;path: &quot;/&quot;의 errorElement 추가합니다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1776358241951&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const router = createBrowserRouter([
  {
    path: &quot;/&quot;,
    element: &amp;lt;Home /&amp;gt;,
    errorElement: &amp;lt;Error /&amp;gt;
  },
  {
    path: &quot;/books&quot;,
    element: &amp;lt;div&amp;gt;도서 목록&amp;lt;/div&amp;gt;
  }
])&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2142&quot; data-origin-height=&quot;744&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Evlgr/dJMb99MNip3/PLAmkFhw3L84n3litsKBCK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Evlgr/dJMb99MNip3/PLAmkFhw3L84n3litsKBCK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Evlgr/dJMb99MNip3/PLAmkFhw3L84n3litsKBCK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FEvlgr%2FdJMb99MNip3%2FPLAmkFhw3L84n3litsKBCK%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;2142&quot; height=&quot;744&quot; data-origin-width=&quot;2142&quot; data-origin-height=&quot;744&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt; &lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;Header.tsx&lt;/p&gt;
&lt;pre id=&quot;code_1776358341449&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;                    {
                        CATEGORY.map((item) =&amp;gt; (
                            &amp;lt;li key={item.id}&amp;gt;
                                &amp;lt;a href={item.id === null ? `/books` : `/books?category_id=${item.id}`}&amp;gt;
                                {item.name}&amp;lt;/a&amp;gt;
                            &amp;lt;/li&amp;gt;
                        ))
                    }&lt;/code&gt;&lt;/pre&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;b data-index-in-node=&quot;12&quot; data-path-to-node=&quot;3&quot;&gt;&amp;lt;a&amp;gt; 태그는 '새로고침'을 발생시키고, &amp;lt;Link&amp;gt;는 '컴포넌트 교체'만 발생시키기&lt;/b&gt; 때문에, Link 로 수정해야지 화면깜빡임 없이 화면이동하는 걸로 보여집니다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 76px;&quot; border=&quot;1&quot; data-path-to-node=&quot;4&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;b&gt;구분&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;b&gt;&amp;lt;a&amp;gt; 태그 (Standard HTML)&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;b&gt;&amp;lt;Link&amp;gt; 컴포넌트 (React Router)&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;span data-path-to-node=&quot;4,1,0,0&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;4,1,0,0&quot;&gt;동작 방식&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;span data-path-to-node=&quot;4,1,1,0&quot;&gt;브라우저가 서버에 &lt;b data-index-in-node=&quot;10&quot; data-path-to-node=&quot;4,1,1,0&quot;&gt;새로운 HTML&lt;/b&gt;을 요청함&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;span data-path-to-node=&quot;4,1,2,0&quot;&gt;브라우저 주소창의 &lt;b data-index-in-node=&quot;10&quot; data-path-to-node=&quot;4,1,2,0&quot;&gt;URL만 변경&lt;/b&gt;하고 JS로 화면을 그림&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;span data-path-to-node=&quot;4,2,0,0&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;4,2,0,0&quot;&gt;상태 유지&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;span data-path-to-node=&quot;4,2,1,0&quot;&gt;앱 전체가 새로고침되어 &lt;b data-index-in-node=&quot;13&quot; data-path-to-node=&quot;4,2,1,0&quot;&gt;모든 상태(State)가 초기화&lt;/b&gt;됨&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;span data-path-to-node=&quot;4,2,2,0&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;4,2,2,0&quot;&gt;기존 상태(State)가 유지&lt;/b&gt;됨 (예: 입력 중인 텍스트 등)&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;span data-path-to-node=&quot;4,3,0,0&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;4,3,0,0&quot;&gt;속도/사용성&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;span data-path-to-node=&quot;4,3,1,0&quot;&gt;화면 깜빡임이 발생하고 속도가 상대적으로 느림&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;span data-path-to-node=&quot;4,3,2,0&quot;&gt;필요한 부분만 갈아끼우므로 부드럽고 빠름 (SPA 방식)&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;pre id=&quot;code_1776358416852&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;                    {
                        CATEGORY.map((item) =&amp;gt; (
                            &amp;lt;li key={item.id}&amp;gt;
                                &amp;lt;Link to={item.id === null ? `/books` : `/books?category_id=${item.id}`}&amp;gt;
                                {item.name}&amp;lt;/Link&amp;gt;
                            &amp;lt;/li&amp;gt;
                        ))
                    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;pre id=&quot;code_1776578115735&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;function App() {
  const {themeName, toggleTheme} = useContext(ThemeContext);  
  return (
    &amp;lt;&amp;gt;
      &amp;lt;BookStoreThemeProvider&amp;gt;
      	&amp;lt;Layout&amp;gt;
        &amp;lt;RouterProvider router={router} /&amp;gt;
        &amp;lt;/Layout&amp;gt;
      &amp;lt;/BookStoreThemeProvider&amp;gt;
    &amp;lt;/&amp;gt;
  );
}

export default App;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;그러나 여기서 &lt;b&gt;Router를 관리하는 주체(RouterProvider)가 Layout의 하위&lt;/b&gt;에 있기 때문에 오류가 발생합니다 .&lt;/p&gt;
&lt;pre id=&quot;code_1776358740221&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function App() {
  const {themeName, toggleTheme} = useContext(ThemeContext);  
  return (
    &amp;lt;&amp;gt;
      &amp;lt;BookStoreThemeProvider&amp;gt;
        &amp;lt;RouterProvider router={router} /&amp;gt;  
      &amp;lt;/BookStoreThemeProvider&amp;gt;
    &amp;lt;/&amp;gt;
  );
}

export default App;&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1776578178767&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { useRouteError } from &quot;react-router-dom&quot;;
import Layout from &quot;../layout/Layout&quot;;

interface RouteError {
    statusText?: string;
    message?: String;
}
function Error() {
    const error = useRouteError() as RouteError;
    return (
        &amp;lt;Layout&amp;gt;
            &amp;lt;h1&amp;gt;오류가 발생했습니다.&amp;lt;/h1&amp;gt;
            &amp;lt;p&amp;gt;다음과 같은 오류가 발생했습니다. &amp;lt;/p&amp;gt;
            &amp;lt;p&amp;gt;{error.statusText || error.message}&amp;lt;/p&amp;gt;
        &amp;lt;/Layout&amp;gt;
    )
}

export default Error;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 수정해주어야합니다.&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;프로젝트 모델 정의&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;book.model.ts&lt;/p&gt;
&lt;pre id=&quot;code_1776360368055&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export interface Book {
    id: number;
    title: string,
    img: 7,
    category_id: 0,
    form: string;
    isbn: string;
    summary: string;
    detail: string;
    author: string;
    pages: number;
    contents: string;
    price: number;
    likes: number;
    pubDate: string;
}

export interface BookDetail extends Book {
    categoryName: string;
    liked: boolean;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;cart.model.ts&lt;/p&gt;
&lt;pre id=&quot;code_1776360397156&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export interface Cart {
    id: number;
    bookId: number;
    title: string;
    sumamry: string;
    quantity: number;
    price: number;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;category.model.ts&lt;/p&gt;
&lt;pre id=&quot;code_1776360409005&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export interface Category {
    id: number | null;
    name: string;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;order.model.ts&lt;/p&gt;
&lt;pre id=&quot;code_1776360433888&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export interface Order {
    id: number;
    createdAt: string;
    address: string;
    receiver: string;
    contact: string;
    bookTitle: string;
    totalQuantity: number;
    totalPrice: number;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;pagination.model.ts&lt;/p&gt;
&lt;pre id=&quot;code_1776360448271&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export interface Pagination {
    currentPage: number;
    totalNumber: number;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;user.model.ts&lt;/p&gt;
&lt;pre id=&quot;code_1776360458961&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export interface User {
    id: number;
    email: string;
    password?: string;
}&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;&lt;b&gt;데이터 흐름&lt;/b&gt;&lt;/h2&gt;
&lt;pre id=&quot;code_1776578210693&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;View(Header.tsx) -&amp;gt; Hooks(useCategory) -&amp;gt; Query Library(optional) -&amp;gt; Fetcher (category.api.ts(http.ts)) -&amp;gt; API server&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음으로 백엔드 서버와 연결을 해보겠습니다. 제 인생 처음이에요 ㅎㅎ&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Docker, MySQL 포함 백엔드 서버를 실행을 시켜주고 실행을 해주어야하고,&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CORS 관련 문제도 해결해야 연결이 됐습니다 .&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://ilove-ya.tistory.com/215&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://ilove-ya.tistory.com/215&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1776579166849&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[Axios] Axios Network Error &amp;amp; CORS 오류 해결&quot; data-og-description=&quot;문제 상황프론트에서 Axios 요청을 보냈을 때 다음과 같은 에러가 발생했다. AxiosError: Network Error개발자 도구 콘솔에서는 추가로 아래 에러가 확인되었다.Access to XMLHttpRequest at 'http://localhost:9999/cate&quot; data-og-host=&quot;ilove-ya.tistory.com&quot; data-og-source-url=&quot;https://ilove-ya.tistory.com/215&quot; data-og-url=&quot;https://ilove-ya.tistory.com/215&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bwyt7p/dJMb9lk82TN/xlv4iKs4mvjFNoF9eBHgFk/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/bbLWUF/dJMb8XR7APx/TnbddOq9U4B95fmkk9xbi1/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800&quot;&gt;&lt;a href=&quot;https://ilove-ya.tistory.com/215&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://ilove-ya.tistory.com/215&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bwyt7p/dJMb9lk82TN/xlv4iKs4mvjFNoF9eBHgFk/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/bbLWUF/dJMb8XR7APx/TnbddOq9U4B95fmkk9xbi1/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[Axios] Axios Network Error &amp;amp; CORS 오류 해결&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;문제 상황프론트에서 Axios 요청을 보냈을 때 다음과 같은 에러가 발생했다. AxiosError: Network Error개발자 도구 콘솔에서는 추가로 아래 에러가 확인되었다.Access to XMLHttpRequest at 'http://localhost:9999/cate&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;ilove-ya.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&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;p data-ke-size=&quot;size16&quot;&gt;  http.ts&lt;/p&gt;
&lt;pre id=&quot;code_1776362970388&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import axios, {Axios, AxiosRequestConfig} from 'axios';

const BASE_URL = &quot;http://localhost:9999&quot;;
const DEFAULT_TIMEOUT = 30000;

export const createClient = (config?: AxiosRequestConfig) =&amp;gt; {
    const axiosInstance = axios.create({
        baseURL: BASE_URL,
        timeout: DEFAULT_TIMEOUT,
        headers: {
            &quot;content-type&quot;: &quot;application/json&quot;,
        },
        withCredentials: true,
        ...config
    })

    axiosInstance.interceptors.response.use(
        (response) =&amp;gt; {
            return response;
        },
        (error) =&amp;gt; {
            return Promise.reject(error);
        }
    )
    return axiosInstance;
}

export const httpClient = createClient();&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;&lt;b&gt;카테고리 연결해보자.&amp;nbsp;&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt; &lt;span&gt; &amp;nbsp;&lt;/span&gt;&lt;/span&gt;category.api.ts&lt;/p&gt;
&lt;pre id=&quot;code_1776362951931&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { Category } from &quot;../models/category.model&quot;;
import { httpClient } from &quot;./http&quot;;

export const fetchCategory = async () =&amp;gt; {
    const response = await httpClient.get&amp;lt;Category[]&amp;gt;(&quot;/category&quot;);
    return response.data;
}&lt;/code&gt;&lt;/pre&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;span style=&quot;color: #333333; text-align: start;&quot;&gt; &lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;Header.tsx&lt;/p&gt;
&lt;pre id=&quot;code_1776363003535&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function Header() {
    const [category, setCategory] = useState&amp;lt;Category[]&amp;gt;([]);

    useEffect(() =&amp;gt; {
        fetchCategory().then((category)=&amp;gt; {
            setCategory(category);
        });
    },[])

    return (
        &amp;lt;HeaderStyle&amp;gt;
            &amp;lt;h1 className=&quot;logo&quot;&amp;gt;
                &amp;lt;Link to=&quot;/&quot;&amp;gt;&amp;lt;img src={logo} alt=&quot;book store&quot;/&amp;gt;&amp;lt;/Link&amp;gt;
            &amp;lt;/h1&amp;gt; 
            &amp;lt;nav className=&quot;category&quot;&amp;gt;
                &amp;lt;ul&amp;gt;
                    {
                        category.map((item) =&amp;gt; (
                            &amp;lt;li key={item.category_id}&amp;gt;
                                &amp;lt;Link to={item.category_id === null ? `/books` : `/books?category_id=${item.category_id}`}&amp;gt;
                                {item.category_name}&amp;lt;/Link&amp;gt;
                            &amp;lt;/li&amp;gt;
                        ))
                    }
                &amp;lt;/ul&amp;gt;
                
                ....&lt;/code&gt;&lt;/pre&gt;</description>
      <category>axios</category>
      <category>react</category>
      <category>react-router-dom</category>
      <category>typescript</category>
      <category>카테고리 구현</category>
      <author>Ea스더</author>
      <guid isPermaLink="true">https://ilove-ya.tistory.com/217</guid>
      <comments>https://ilove-ya.tistory.com/217#entry217comment</comments>
      <pubDate>Fri, 17 Apr 2026 03:24:03 +0900</pubDate>
    </item>
    <item>
      <title>[Axios] Axios Network Error &amp;amp; CORS 오류 해결</title>
      <link>https://ilove-ya.tistory.com/215</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;문제 상황&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프론트에서 Axios 요청을 보냈을 때 다음과 같은 에러가 발생했다.&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;AxiosError: Network Error
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개발자 도구 콘솔에서는 추가로 아래 에러가 확인되었다.&lt;/p&gt;
&lt;pre class=&quot;oxygene&quot;&gt;&lt;code&gt;Access to XMLHttpRequest at 'http://localhost:9999/category' 
from origin 'http://localhost:3000' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;원인 추론 &amp;mdash; 서버 문제인가?&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아예 백엔드 서버가 실행되지 않았을 때와 같이 보이는 오류였고, Network Error라는 메시지 때문에 다음과 같은 가능성을 우선적으로 의심했다.&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;잘못된 URL 요청&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  그러나 서버는 정상 실행 중이고, http://localhost:9999 도 포스트맨이나, 브라우저로 잘 접속 되어 단순 네트워크 문제는 아니었다.&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;b&gt;원인 추론 &amp;mdash; Axios 설정 문제?&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개발자도구의 콘솔 에러에서 핵심 메시지를 발견했다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot;&gt;&lt;code&gt;No 'Access-Control-Allow-Origin' header is present
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;서버가 CORS 허용 헤더를 보내지 않음&lt;/li&gt;
&lt;li&gt;브라우저가 요청 자체를 차단&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Axios 설정을 점검했다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;const axiosInstance = axios.create({
  baseURL: &quot;http://localhost:9999&quot;,
  timeout: 30000,
  withCredentials: true
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;withCredentials: true&amp;nbsp;이 옵션은 &lt;b&gt;쿠키를 포함한 요청을 보내는 설정&lt;/b&gt;이며 CORS 정책이 더 엄격하게 적용된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;rarr; 이 시점에서 &amp;ldquo;CORS 문제 가능성&amp;rdquo;을 의심했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #666666; text-align: center;&quot;&gt;Axios 문제가 아니라 브라우저 보안 정책에 의해 차단된 상황&lt;/span&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;b&gt;최종 해결&lt;/b&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1776578815527&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;npm install CORS&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CORS 다운로드 후&amp;nbsp;&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;CORS 미들웨어 최상단에 추가하였다.&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;stata&quot;&gt;&lt;code&gt;const cors = require(&quot;cors&quot;);

app.use(cors({
  origin: &quot;http://localhost:3000&quot;,
  credentials: true
}));

app.use(express.json());

app.use(&quot;/users&quot;, userRouter);
app.use(&quot;/books&quot;, bookRouter);
app.use(&quot;/category&quot;, categoryRouter);
&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;&amp;nbsp;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Network Error 해결&lt;/li&gt;
&lt;li&gt;CORS 에러 사라짐&lt;/li&gt;
&lt;li&gt;/category API 정상 응답 확인&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;&lt;b&gt;알게 된 것&amp;nbsp;&lt;/b&gt;&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;withCredentials: true 사용 시 CORS 설정은 반드시 구체적으로 해야 한다&lt;/li&gt;
&lt;li&gt;Express에서는 CORS 미들웨어를 라우터보다 먼저 등록해야 한다&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>트러블슈팅</category>
      <author>Ea스더</author>
      <guid isPermaLink="true">https://ilove-ya.tistory.com/215</guid>
      <comments>https://ilove-ya.tistory.com/215#entry215comment</comments>
      <pubDate>Fri, 17 Apr 2026 03:11:01 +0900</pubDate>
    </item>
    <item>
      <title>[BookShop-FE] 본격적인 UI 작업 시작 - 헤더와 푸터</title>
      <link>https://ilove-ya.tistory.com/214</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;Footer.tsx&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2152&quot; data-origin-height=&quot;304&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bq6BFX/dJMcacbJWKV/c6WsetNnAF52KTz7Jl0pgK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bq6BFX/dJMcacbJWKV/c6WsetNnAF52KTz7Jl0pgK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bq6BFX/dJMcacbJWKV/c6WsetNnAF52KTz7Jl0pgK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbq6BFX%2FdJMcacbJWKV%2Fc6WsetNnAF52KTz7Jl0pgK%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;2152&quot; height=&quot;304&quot; data-origin-width=&quot;2152&quot; data-origin-height=&quot;304&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre id=&quot;code_1776576686367&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import styled from &quot;styled-components&quot;;
import logo from &quot;../../assets/images/logo.png&quot;

function Footer() {
    return (
        &amp;lt;FooterStyle&amp;gt;
            &amp;lt;h1 className=&quot;logo&quot;&amp;gt;
                &amp;lt;img src={logo} alt=&quot;book store&quot;/&amp;gt;
            &amp;lt;/h1&amp;gt; 
            &amp;lt;div className=&quot;copyright&quot;&amp;gt;
                &amp;lt;p&amp;gt;copyright(c), 2026, Book Store.&amp;lt;/p&amp;gt;
            &amp;lt;/div&amp;gt;
        &amp;lt;/FooterStyle&amp;gt;
    )
    
}

const FooterStyle = styled.footer`
    width: 100%;
    margin: 0 auto;
    max-width: ${({theme}) =&amp;gt; theme.layout.width.large};
    border-top: 1px solid ${({theme}) =&amp;gt; theme.color.background};
    padding: 20px 0;
    display: flex;
    justify-content: space-between;

    .logo{
        img {
            width: 140px;
        }
    }
    
    .copyright {
        p {
            font-size: 0.75ren;
            color: ${({theme}) =&amp;gt; theme.color.text};
        }
    }
`;

export default Footer;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Header.tsx&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;a href=&quot;https://ilove-ya.tistory.com/213&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://ilove-ya.tistory.com/213&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1776576935748&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[React] icons/fa import 불가 및 사용 문제  (CRA + React 19 문제)&quot; data-og-description=&quot;  문제 상황프로젝트 실행 중, 동일한 코드인데도 특정 라이브러리가 정상적으로 동작하지 않는 문제가 발생했다.import { FaSignInAlt, FaRegUser } from &amp;quot;react-icons/fa&amp;quot;;위 코드에서 아이콘이 정상적으로 &quot; data-og-host=&quot;ilove-ya.tistory.com&quot; data-og-source-url=&quot;https://ilove-ya.tistory.com/213&quot; data-og-url=&quot;https://ilove-ya.tistory.com/213&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/xz6MU/dJMb9fZxdSo/C3o1xPTEEV6YU4N1FNyxfK/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/h60kk/dJMb9gxnkJd/TKHhKghSu7wDnulcAmNYhk/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/bhaKSB/dJMb8XkhghR/5HQK6E84j2pC6kQLcTMCf1/img.png?width=2336&amp;amp;height=1458&amp;amp;face=0_0_2336_1458&quot;&gt;&lt;a href=&quot;https://ilove-ya.tistory.com/213&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://ilove-ya.tistory.com/213&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/xz6MU/dJMb9fZxdSo/C3o1xPTEEV6YU4N1FNyxfK/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/h60kk/dJMb9gxnkJd/TKHhKghSu7wDnulcAmNYhk/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/bhaKSB/dJMb8XkhghR/5HQK6E84j2pC6kQLcTMCf1/img.png?width=2336&amp;amp;height=1458&amp;amp;face=0_0_2336_1458');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[React] icons/fa import 불가 및 사용 문제 (CRA + React 19 문제)&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;  문제 상황프로젝트 실행 중, 동일한 코드인데도 특정 라이브러리가 정상적으로 동작하지 않는 문제가 발생했다.import { FaSignInAlt, FaRegUser } from &quot;react-icons/fa&quot;;위 코드에서 아이콘이 정상적으로&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;ilove-ya.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때, react-icons/fa 를 사용하는데에 오류가 발생하여 많이 해맸습니다. .&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2098&quot; data-origin-height=&quot;196&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dKD8f9/dJMcaipu57n/5w8ogvhk4KIgEJKRK5gQk0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dKD8f9/dJMcaipu57n/5w8ogvhk4KIgEJKRK5gQk0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dKD8f9/dJMcaipu57n/5w8ogvhk4KIgEJKRK5gQk0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdKD8f9%2FdJMcaipu57n%2F5w8ogvhk4KIgEJKRK5gQk0%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;2098&quot; height=&quot;196&quot; data-origin-width=&quot;2098&quot; data-origin-height=&quot;196&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre id=&quot;code_1776576734701&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { styled } from &quot;styled-components&quot;;
import logo from '../../assets/images/logo.png';
import { FaSignInAlt, FaRegUser } from &quot;react-icons/fa&quot;;

const CATEGORY = [
    {
        id: null,
        name: &quot;전체&quot;
    },
    {
        id: 0,
        name: &quot;동화&quot;
    },
    {
        id: 1,
        name: &quot;소설&quot;
    },
    {
        id: 2,
        name: &quot;사회&quot;
    }
]

function Header() {
    return (
        &amp;lt;HeaderStyle&amp;gt;
            &amp;lt;h1 className=&quot;logo&quot;&amp;gt;
                &amp;lt;img src={logo} alt=&quot;book store&quot;/&amp;gt;
            &amp;lt;/h1&amp;gt; 
            &amp;lt;nav className=&quot;category&quot;&amp;gt;
                &amp;lt;ul&amp;gt;
                    {
                        CATEGORY.map((item) =&amp;gt; (
                            &amp;lt;li key={item.id}&amp;gt;
                                &amp;lt;a href={item.id === null ? `/books` : `/books?category_id=${item.id}`}&amp;gt;
                                {item.name}&amp;lt;/a&amp;gt;
                            &amp;lt;/li&amp;gt;
                        ))
                    }
                &amp;lt;/ul&amp;gt;
            &amp;lt;/nav&amp;gt;
            &amp;lt;nav className=&quot;auth&quot;&amp;gt;
                &amp;lt;ul&amp;gt;
                    &amp;lt;li&amp;gt;
                        &amp;lt;a href=&quot;/login&quot;&amp;gt;
                            &amp;lt;FaSignInAlt /&amp;gt;로그인
                        &amp;lt;/a&amp;gt;
                    &amp;lt;/li&amp;gt;
                    &amp;lt;li&amp;gt;
                        &amp;lt;a href=&quot;/signup&quot;&amp;gt;
                            &amp;lt;FaRegUser /&amp;gt;회원가입
                        &amp;lt;/a&amp;gt;
                    &amp;lt;/li&amp;gt;
                &amp;lt;/ul&amp;gt;
            &amp;lt;/nav&amp;gt;           
        &amp;lt;/HeaderStyle&amp;gt;
    ) 
}

const HeaderStyle = styled.header`
    width: 100%;
    margin: 0 auto;
    max-width: ${({theme}) =&amp;gt; theme.layout.width.large};

    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 20px 0;
    border-bottom: 1px solid ${({theme}) =&amp;gt; theme.color.background};

    .logo {
        img {
            width: 200px;
        }
    }

    .category {
        ul {
            display: flex;
            gap: 32px; 
            li {
                a{
                    font-size: 1.5rem;
                    font-weight: 600;
                    text-decoration: none;
                    color: ${({theme}) =&amp;gt; theme.color.text};

                    &amp;amp;:hover {
                        color: ${({theme}) =&amp;gt; theme.color.primary}
                    }
                }
            }
        }
    }

    .auth {
        ul {
            display: flex;
            gap: 16px;
            li {
                a {
                    font-size: 1rem;
                    font-weight: 600;
                    text-decoration: none;
                    display: flex;
                    align-item: center;
                    line-height: 1;

                    svg {
                        margin-right: 6px;
                    }
                }
            }
        }   
    }
`;

export default Header;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Layout.tsx 수정&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1776576794859&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import styled from &quot;styled-components&quot;;
import Footer from &quot;../common/Footer&quot;;
import Header from &quot;../common/Header&quot;;


interface LayoutProps {
    children: React.ReactNode;
}

function Layout({ children }: LayoutProps) {
    return (
        &amp;lt;LayoutStyle&amp;gt;
            &amp;lt;Header /&amp;gt;
            &amp;lt;main&amp;gt;{children}&amp;lt;/main&amp;gt;
            &amp;lt;Footer /&amp;gt;
        &amp;lt;/LayoutStyle&amp;gt;
    )
}

const LayoutStyle = styled.main`
    width: 100%;
    margin: 0 auto;
    max-width: ${({theme}) =&amp;gt; theme.layout.width.large};
    padding: 20px 0;
`
export default Layout;&lt;/code&gt;&lt;/pre&gt;</description>
      <category>Devcourse/BookShop 사이드 프로젝트</category>
      <author>Ea스더</author>
      <guid isPermaLink="true">https://ilove-ya.tistory.com/214</guid>
      <comments>https://ilove-ya.tistory.com/214#entry214comment</comments>
      <pubDate>Fri, 17 Apr 2026 01:36:41 +0900</pubDate>
    </item>
    <item>
      <title>[React] icons/fa import 불가 및 사용 문제  (CRA + React 19 문제)</title>
      <link>https://ilove-ya.tistory.com/213</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2336&quot; data-origin-height=&quot;1458&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/rCEM5/dJMcafGg1Gj/2hpXU4Sh2k4FWXfbMbKqok/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/rCEM5/dJMcafGg1Gj/2hpXU4Sh2k4FWXfbMbKqok/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/rCEM5/dJMcafGg1Gj/2hpXU4Sh2k4FWXfbMbKqok/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FrCEM5%2FdJMcafGg1Gj%2F2hpXU4Sh2k4FWXfbMbKqok%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;2336&quot; height=&quot;1458&quot; data-origin-width=&quot;2336&quot; data-origin-height=&quot;1458&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-end=&quot;123&quot; data-start=&quot;111&quot; data-section-id=&quot;jz9qij&quot; data-ke-size=&quot;size26&quot;&gt;  문제 상황&lt;/h2&gt;
&lt;p data-end=&quot;179&quot; data-start=&quot;125&quot; data-ke-size=&quot;size16&quot;&gt;프로젝트 실행 중, 동일한 코드인데도 특정 라이브러리가 정상적으로 동작하지 않는 문제가 발생했다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1776309208298&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { FaSignInAlt, FaRegUser } from &quot;react-icons/fa&quot;;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;div&gt;&lt;br /&gt;&lt;br /&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;297&quot; data-start=&quot;257&quot; data-ke-size=&quot;size16&quot;&gt;위 코드에서 아이콘이 정상적으로 import되지 않거나, 이를 사용하는 부분에서 에러 발생&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;334&quot; data-start=&quot;299&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;318&quot; data-start=&quot;299&quot; data-section-id=&quot;15vb61k&quot;&gt;다른 사람 코드에서는 정상 동작&lt;/li&gt;
&lt;li data-end=&quot;334&quot; data-start=&quot;319&quot; data-section-id=&quot;taxuys&quot;&gt;내 환경에서만 오류 발생&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;p data-end=&quot;364&quot; data-start=&quot;355&quot; data-ke-size=&quot;size16&quot;&gt;처음에는 단순히:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;405&quot; data-start=&quot;366&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;387&quot; data-start=&quot;366&quot; data-section-id=&quot;11wip6s&quot;&gt;react-icons 설치 문제&lt;/li&gt;
&lt;li data-end=&quot;405&quot; data-start=&quot;388&quot; data-section-id=&quot;m2cti2&quot;&gt;node_modules 문제&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;415&quot; data-start=&quot;407&quot; data-ke-size=&quot;size16&quot;&gt;를 의심했지만, package.json을 동일하게 맞춰도 해결되지 않음&lt;/p&gt;
&lt;p data-end=&quot;415&quot; data-start=&quot;407&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;415&quot; data-start=&quot;407&quot; data-ke-size=&quot;size16&quot;&gt;➡️&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;415&quot; data-start=&quot;407&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;539&quot; data-start=&quot;472&quot; data-ke-size=&quot;size16&quot;&gt;문제는 단순 라이브러리가 아니라&lt;br /&gt;  &lt;b&gt;React / react-scripts / TypeScript 버전 충돌 이었음&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;553&quot; data-start=&quot;541&quot; data-ke-size=&quot;size16&quot;&gt;특히 CRA 환경에서:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;590&quot; data-start=&quot;555&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;570&quot; data-start=&quot;555&quot; data-section-id=&quot;1qy64yk&quot;&gt;최신 React (19)&lt;/li&gt;
&lt;li data-end=&quot;590&quot; data-start=&quot;571&quot; data-section-id=&quot;jgwofr&quot;&gt;최신 TypeScript (5)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;601&quot; data-start=&quot;592&quot; data-ke-size=&quot;size16&quot;&gt;조합이 들어가면서&lt;/p&gt;
&lt;p data-end=&quot;654&quot; data-start=&quot;603&quot; data-ke-size=&quot;size16&quot;&gt;일부 라이브러리 (react-icons 포함)가 정상적으로 동작하지 않는 상황 발생&lt;/p&gt;
&lt;p data-end=&quot;658&quot; data-start=&quot;656&quot; data-ke-size=&quot;size16&quot;&gt;즉, &lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;&lt;/span&gt;실제로는 빌드 환경 문제&lt;/p&gt;
&lt;p data-end=&quot;658&quot; data-start=&quot;656&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-end=&quot;731&quot; data-start=&quot;718&quot; data-section-id=&quot;typwcl&quot; data-ke-size=&quot;size26&quot;&gt;❌ 시도했던 것들&lt;/h2&gt;
&lt;h4 data-end=&quot;756&quot; data-start=&quot;733&quot; data-ke-size=&quot;size20&quot;&gt;1. package.json 통일&lt;/h4&gt;
&lt;p data-end=&quot;780&quot; data-start=&quot;758&quot; data-ke-size=&quot;size16&quot;&gt;같은 코드 작성자의 환경과 동일하게 맞춤&lt;/p&gt;
&lt;p data-end=&quot;787&quot; data-start=&quot;782&quot; data-ke-size=&quot;size16&quot;&gt;  실패&lt;/p&gt;
&lt;h4 data-end=&quot;818&quot; data-start=&quot;794&quot; data-ke-size=&quot;size20&quot;&gt;2. node_modules 재설치&lt;/h4&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1776309295361&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;rm -rf node_modules package-lock.json
npm install&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;888&quot; data-start=&quot;883&quot; data-ke-size=&quot;size16&quot;&gt;  실패&lt;/p&gt;
&lt;h4 data-end=&quot;913&quot; data-start=&quot;895&quot; data-ke-size=&quot;size20&quot;&gt;3. Vite 전환 고민&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;946&quot; data-start=&quot;915&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;928&quot; data-start=&quot;915&quot; data-section-id=&quot;1k5ph1t&quot;&gt;React 19 지원&lt;/li&gt;
&lt;li data-end=&quot;946&quot; data-start=&quot;929&quot; data-section-id=&quot;1v4ylbu&quot;&gt;TypeScript 5 지원&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;969&quot; data-start=&quot;948&quot; data-ke-size=&quot;size16&quot;&gt;  하지만 프로젝트 진행 중이라 보류&lt;/p&gt;
&lt;p data-end=&quot;969&quot; data-start=&quot;948&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-end=&quot;987&quot; data-start=&quot;976&quot; data-section-id=&quot;1qe4x97&quot; data-ke-size=&quot;size26&quot;&gt;✅ 해결 방법&lt;/h2&gt;
&lt;p data-end=&quot;1010&quot; data-start=&quot;989&quot; data-ke-size=&quot;size16&quot;&gt;CRA와 호환되는 안정 버전으로 정리:&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1776309318077&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&quot;react&quot;: &quot;^18.3.1&quot;,
&quot;react-dom&quot;: &quot;^18.3.1&quot;,
&quot;react-hook-form&quot;: &quot;^7.72.0&quot;,
&quot;react-icons&quot;: &quot;^4.11.0&quot;,
&quot;react-router-dom&quot;: &quot;^7.13.2&quot;,
&quot;react-scripts&quot;: &quot;5.0.1&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;442&quot; data-origin-height=&quot;164&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/5rLvh/dJMcabRoasy/K6BmYSITolMpN0TahR2OHk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/5rLvh/dJMcabRoasy/K6BmYSITolMpN0TahR2OHk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/5rLvh/dJMcabRoasy/K6BmYSITolMpN0TahR2OHk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F5rLvh%2FdJMcabRoasy%2FK6BmYSITolMpN0TahR2OHk%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;442&quot; height=&quot;164&quot; data-origin-width=&quot;442&quot; data-origin-height=&quot;164&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-end=&quot;1410&quot; data-start=&quot;1386&quot; data-ke-size=&quot;size16&quot;&gt;이 문제는 단순한 라이브러리 문제가 아니라: &lt;b&gt;&amp;ldquo;환경이 라이브러리를 못 받아주는 상태&amp;rdquo;였음.&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1565&quot; data-start=&quot;1463&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1495&quot; data-start=&quot;1463&quot; data-section-id=&quot;111kv6h&quot;&gt;import 에러가 나면 무조건 라이브러리 문제가 아니라 &lt;b&gt;환경(React / 빌드툴 / TS)부터 의심해야 봐야되고,&amp;nbsp;&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;1565&quot; data-start=&quot;1536&quot; data-section-id=&quot;sxuxpl&quot;&gt;CRA는: 최신 스택과 충돌이 쉽게 난다&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;</description>
      <author>Ea스더</author>
      <guid isPermaLink="true">https://ilove-ya.tistory.com/213</guid>
      <comments>https://ilove-ya.tistory.com/213#entry213comment</comments>
      <pubDate>Thu, 16 Apr 2026 03:24:07 +0900</pubDate>
    </item>
    <item>
      <title>[Trello 클론 해보기] Firebase Hosting으로 배포해보자.</title>
      <link>https://ilove-ya.tistory.com/203</link>
      <description>&lt;p data-end=&quot;113&quot; data-start=&quot;85&quot; data-section-id=&quot;1t7bf42&quot; data-ke-size=&quot;size16&quot;&gt;저번에는 Firebase를 통해 로그인/로그아웃 구현을 진행하였는데요,&lt;/p&gt;
&lt;p data-end=&quot;113&quot; data-start=&quot;85&quot; data-section-id=&quot;1t7bf42&quot; data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://ilove-ya.tistory.com/201&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://ilove-ya.tistory.com/201&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1776065747364&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[Trello 클론 해보기] Firebase로 로그인/로그아웃 구현&quot; data-og-description=&quot;  Firebase란?&amp;#96;Firebase&amp;#96; 는 Google 이 제공하는 백엔드 플랫폼(BaaS)!서버를 직접 만들지 않아도 인증, DB, 스토리지 등을 바로 쓸 수 있습니다. 즉, 백엔드 서버를 직접 구축하지 않아도 기본적인 서비&quot; data-og-host=&quot;ilove-ya.tistory.com&quot; data-og-source-url=&quot;https://ilove-ya.tistory.com/201&quot; data-og-url=&quot;https://ilove-ya.tistory.com/201&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bwmihW/dJMb87NXJtE/Zon4JpJCZiytWYB22ecmW1/img.png?width=800&amp;amp;height=900&amp;amp;face=0_0_800_900,https://scrap.kakaocdn.net/dn/l7J8j/dJMb88e1Wfn/9L6xeIKKzMFIrUkv9G1Is1/img.png?width=800&amp;amp;height=900&amp;amp;face=0_0_800_900,https://scrap.kakaocdn.net/dn/ceSlj4/dJMb8954TAm/TWvNImuLEE1O2BCcUgtO80/img.png?width=1886&amp;amp;height=590&amp;amp;face=0_0_1886_590&quot;&gt;&lt;a href=&quot;https://ilove-ya.tistory.com/201&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://ilove-ya.tistory.com/201&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bwmihW/dJMb87NXJtE/Zon4JpJCZiytWYB22ecmW1/img.png?width=800&amp;amp;height=900&amp;amp;face=0_0_800_900,https://scrap.kakaocdn.net/dn/l7J8j/dJMb88e1Wfn/9L6xeIKKzMFIrUkv9G1Is1/img.png?width=800&amp;amp;height=900&amp;amp;face=0_0_800_900,https://scrap.kakaocdn.net/dn/ceSlj4/dJMb8954TAm/TWvNImuLEE1O2BCcUgtO80/img.png?width=1886&amp;amp;height=590&amp;amp;face=0_0_1886_590');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[Trello 클론 해보기] Firebase로 로그인/로그아웃 구현&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;  Firebase란?`Firebase` 는 Google 이 제공하는 백엔드 플랫폼(BaaS)!서버를 직접 만들지 않아도 인증, DB, 스토리지 등을 바로 쓸 수 있습니다. 즉, 백엔드 서버를 직접 구축하지 않아도 기본적인 서비&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;ilove-ya.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-end=&quot;113&quot; data-start=&quot;85&quot; data-section-id=&quot;1t7bf42&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;113&quot; data-start=&quot;85&quot; data-section-id=&quot;1t7bf42&quot; data-ke-size=&quot;size16&quot;&gt;이번에는 배포를 진행해보려고 합니다.&amp;nbsp; Firebase Hosting을 사용하면 별도의 서버 없이도 React 앱을 간단하게 배포할 수 있습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-end=&quot;113&quot; data-start=&quot;85&quot; data-section-id=&quot;1t7bf42&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;  Firebase Hosting으로 배포하기&lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-end=&quot;273&quot; data-start=&quot;252&quot; data-section-id=&quot;19gld7x&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;1. Firebase CLI 설치&lt;/b&gt;&lt;/h3&gt;
&lt;p data-end=&quot;318&quot; data-start=&quot;275&quot; data-ke-size=&quot;size16&quot;&gt;Firebase를 통해 배포하기 위해서는 CLI 도구를 먼저 설치해야 합니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1776064116473&quot; class=&quot;bash&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;npm install -g firebase-tools&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;426&quot; data-start=&quot;363&quot; data-ke-size=&quot;size16&quot;&gt;Firebase CLI는 프로젝트를 Firebase와 연결하고 배포를 진행할 수 있도록 도와주는 도구입니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-end=&quot;451&quot; data-start=&quot;433&quot; data-section-id=&quot;569c20&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;2. Firebase 로그인&lt;/b&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1776064170990&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;firebase login&lt;/code&gt;&lt;/pre&gt;
&lt;div&gt;
&lt;div&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;&amp;nbsp;브라우저가 열리면서 Google 계정으로 로그인하게 됩니다. &lt;/span&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;&amp;nbsp;이 계정은 Firebase 콘솔에서 생성한 프로젝트와 연결됩니다.&lt;/span&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-end=&quot;580&quot; data-start=&quot;566&quot; data-section-id=&quot;4cr04p&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;3. 프로젝트 초기화&lt;/b&gt;&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1776064186216&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;firebase init&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;프로젝트 루트 경로에서 실행합니다.&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;설정과정&lt;/b&gt;&lt;/h4&gt;
&lt;/div&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;811&quot; data-start=&quot;655&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;667&quot; data-start=&quot;655&quot; data-section-id=&quot;1mohatx&quot;&gt;Hosting 선택
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;667&quot; data-start=&quot;655&quot; data-section-id=&quot;1mohatx&quot;&gt;&lt;b&gt;[Hosting: Configure files for Firebase Hosting and (optionally) set up Github Action deploys]&lt;/b&gt;&amp;nbsp;&lt;/li&gt;
&lt;li data-end=&quot;667&quot; data-start=&quot;655&quot; data-section-id=&quot;1mohatx&quot;&gt;GitHub와 Firebase가 연동되어 코드를 푸시할 때마다 자동으로 빌드 및 배포가 진행되도록 했습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li data-end=&quot;715&quot; data-start=&quot;668&quot; data-section-id=&quot;1ymqh&quot;&gt;Use an existing project &amp;rarr; 기존 Firebase 프로젝트 선택&lt;/li&gt;
&lt;li data-end=&quot;772&quot; data-start=&quot;716&quot; data-section-id=&quot;1fhm9jz&quot;&gt;Public directory &amp;rarr; dist (Vite 기준) / build (CRA 기준)&lt;/li&gt;
&lt;li data-end=&quot;811&quot; data-start=&quot;773&quot; data-section-id=&quot;1k3ukss&quot;&gt;Single-page app &amp;rarr; &lt;b&gt;Yes&lt;/b&gt; (React 필수)&lt;br /&gt;  SPA(Single Page Application) 설정을 해야 React Router 사용 시 새로고침해도 페이지가 깨지지 않습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-end=&quot;913&quot; data-start=&quot;905&quot; data-section-id=&quot;zhz2wf&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;4. 빌드&lt;/b&gt;&lt;/h3&gt;
&lt;p data-end=&quot;938&quot; data-start=&quot;915&quot; data-ke-size=&quot;size16&quot;&gt;배포 전에 반드시 빌드를 진행해야 합니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1776064246045&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;npm run build&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;1049&quot; data-start=&quot;967&quot; data-ke-size=&quot;size16&quot;&gt;React 프로젝트를 실제 배포 가능한 정적 파일로 변환하는 과정입니다. &amp;nbsp;결과물은 dist 또는 build 폴더에 생성됩니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-end=&quot;1064&quot; data-start=&quot;1056&quot; data-section-id=&quot;zew25e&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;5. 배포&lt;/b&gt;&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;pre id=&quot;code_1776064268554&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;firebase deploy&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;1118&quot; data-start=&quot;1095&quot; data-ke-size=&quot;size16&quot;&gt;배포가 완료되면 URL이 생성되어 보여줍니다.&amp;nbsp;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;&lt;a href=&quot;https://프로젝트이름.web.app&quot;&gt;https://프로젝트이름.web.app&lt;/a&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&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;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://trello-clone-b1b07.web.app/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://trello-clone-b1b07.web.app/&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1776064298906&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;trello-clone&quot; data-og-description=&quot;&quot; data-og-host=&quot;trello-clone-b1b07.web.app&quot; data-og-source-url=&quot;https://trello-clone-b1b07.web.app/&quot; data-og-url=&quot;https://trello-clone-b1b07.web.app/&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://trello-clone-b1b07.web.app/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://trello-clone-b1b07.web.app/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url();&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;trello-clone&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;trello-clone-b1b07.web.app&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;/div&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2718&quot; data-origin-height=&quot;1636&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bbUKav/dJMcadavSJg/r1bXIRKIFcyhXbcvLME4a1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bbUKav/dJMcadavSJg/r1bXIRKIFcyhXbcvLME4a1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bbUKav/dJMcadavSJg/r1bXIRKIFcyhXbcvLME4a1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbbUKav%2FdJMcadavSJg%2Fr1bXIRKIFcyhXbcvLME4a1%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;2718&quot; height=&quot;1636&quot; data-origin-width=&quot;2718&quot; data-origin-height=&quot;1636&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-end=&quot;1200&quot; data-start=&quot;1157&quot; data-ke-size=&quot;size16&quot;&gt;해당 URL로 접속하면 실제 서비스가 동작하는 것을 확인할 수 있습니다.&lt;/p&gt;
&lt;p data-end=&quot;1200&quot; data-start=&quot;1157&quot; data-ke-size=&quot;size16&quot;&gt;Firebase Hosting은 기본적으로 &lt;b&gt;HTTPS가 적용된 상태&lt;/b&gt;로 배포되기 때문에, 별도의 SSL 설정 없이도 안전하게 서비스를 제공할 수 있습니다.&lt;/p&gt;
&lt;p data-end=&quot;1200&quot; data-start=&quot;1157&quot; data-ke-size=&quot;size16&quot;&gt;또한&lt;b&gt; CDN(Content Delivery Network)&lt;/b&gt;을 기반으로 동작하기 때문에 전 세계 어디서 접속하더라도 빠른 속도로 서비스를 이용할 수 있습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-end=&quot;923&quot; data-start=&quot;903&quot; data-section-id=&quot;oqswy9&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;6. 배포 후 수정 사항 반영&lt;/b&gt;&lt;/h3&gt;
&lt;p data-end=&quot;949&quot; data-start=&quot;925&quot; data-ke-size=&quot;size16&quot;&gt;코드를 수정한 후에는 다시 배포해야 합니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;pre id=&quot;code_1776064429841&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;npm run build
firebase deploy&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;1061&quot; data-start=&quot;994&quot; data-ke-size=&quot;size16&quot;&gt;Firebase는 자동 배포가 아니기 때문에 수정 후에는 반드시 다시 빌드 + 배포 과정을 거쳐야 합니다.&lt;/p&gt;
&lt;p data-end=&quot;1061&quot; data-start=&quot;994&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;454&quot; data-start=&quot;430&quot; data-section-id=&quot;z8po7v&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;✅ GitHub Actions 자동 배포&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;539&quot; data-start=&quot;456&quot; data-ke-size=&quot;size16&quot;&gt;저는 이 전에 깃허브 액션 배포 설정을 해두었기 때문에,&lt;/p&gt;
&lt;p data-end=&quot;539&quot; data-start=&quot;456&quot; data-ke-size=&quot;size16&quot;&gt;일일이 빌드 + 배포과정을 거치지 않아도, 깃허브에 push만 하면 자동으로 Actions가 생성되며 빌드 -&amp;gt; 배포가 진행됩니다.&lt;/p&gt;
&lt;p data-end=&quot;539&quot; data-start=&quot;456&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;539&quot; data-start=&quot;456&quot; data-ke-size=&quot;size16&quot;&gt;이 과정에서 github/workflows 폴더가 생성되고, Firebase에서 자동으로 배포 스크립트를 만들어줍니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h2 data-end=&quot;1061&quot; data-start=&quot;994&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;왜 이 프로젝트에서 Firebase를 선택했는가?&lt;/b&gt;&lt;/h2&gt;
&lt;p data-end=&quot;1061&quot; data-start=&quot;994&quot; data-ke-size=&quot;size16&quot;&gt;이번 기회에 처음으로 Firebase에 대해 학습하면서, 복잡한 서버 설정 없이도 로그인 기능과 배포까지 빠르게 구현할 수 있다는 점이 편하고 흥미롭게 느껴졌습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1061&quot; data-start=&quot;994&quot; data-ke-size=&quot;size16&quot;&gt;그리고 무엇보다 프론트엔드 공부를 하면서, 백엔드없이 편리하게 서버와 연결하고, 관리할 수 있다는 점이 편리헀던 것같습니다.&lt;/p&gt;
&lt;p data-end=&quot;1061&quot; data-start=&quot;994&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1061&quot; data-start=&quot;994&quot; data-ke-size=&quot;size16&quot;&gt;다만, 제가 팀 프로젝트를 진행하면서는 한 번도 고려해본 적이 없었어서 왜 이렇게 편리한 것을 적용하지 않았던 것인지 궁금증이 생겼었습니다. 비용, 용량 등의 문제라면, 실제 서비스에서 Firebase를 쓰는 서비스가 있는지도 궁금했습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1061&quot; data-start=&quot;994&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1061&quot; data-start=&quot;994&quot; data-ke-size=&quot;size16&quot;&gt;  찾아보니 Firebase는 빠르게 개발할 수 있다는 장점이 있지만, &lt;b&gt;복잡한 비즈니스 로직 처리나 확장성 측면에서는 한계가 있어 상황에 따라 선택적으로 사용된다는 점&lt;/b&gt;을 알게 되었습니다.&lt;/p&gt;
&lt;p data-end=&quot;2916&quot; data-start=&quot;2788&quot; data-ke-size=&quot;size16&quot;&gt;또한 실제 서비스에서도 Firebase를 사용하는 경우는 존재하지만, &lt;b&gt;주로 초기 서비스(MVP)나 소규모 프로젝트에서 많이 활용&lt;/b&gt;되며,&lt;br /&gt;&lt;b&gt;서비스가 커질수록 별도의 백엔드 서버로 전환하는 경우가 많다는 점&lt;/b&gt;도 알게 되었습니다.&lt;/p&gt;
&lt;p data-end=&quot;2916&quot; data-start=&quot;2788&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;3012&quot; data-start=&quot;2923&quot; data-ke-size=&quot;size16&quot;&gt;  이번 프로젝트에서는 로그인과 배포 흐름을 빠르게 경험해보는 것이 목적이었기 때문에, Firebase를 사용하는 것이 적절한 선택이었다고 느꼈습니다.&lt;/p&gt;
&lt;p data-end=&quot;1061&quot; data-start=&quot;994&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1061&quot; data-start=&quot;994&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Devcourse</category>
      <author>Ea스더</author>
      <guid isPermaLink="true">https://ilove-ya.tistory.com/203</guid>
      <comments>https://ilove-ya.tistory.com/203#entry203comment</comments>
      <pubDate>Tue, 14 Apr 2026 17:41:44 +0900</pubDate>
    </item>
  </channel>
</rss>