본문 바로가기
LG CNS AM INSPIRE CAMP

<LG CNS 5기> 10일차 TIL : 토이프로젝트-Axios 비동기 CRUD 구현과 상태 동기화 기법 (feat. Preflight, .env, 토큰 인가 시스템)

by hihijh826 2026. 6. 1.
728x90
반응형
SMALL

학습일자 : 2026.06.01

 

🔒 1. 인증/인가

① 인증(Authentication) vs 인가(Authorization)

  • 인증 (Who?): "당신은 누구십니까?"를 검증하여 유저 고유의 신분증을 발급하는 단계이다. (예: ID/PW 검증 후 localStorage에 토큰 주입)
  • 인가 (What?): "이 유저가 이 자원에 접근할 권한이 있는가?"를 검증하는 단계이다. 유저가 API 요청을 보낼 때, 발급받은 토큰을 HTTP Request Header에 담아서 서버로 전달하는 것이 실무의 정석이다. 서버는 헤더의 토큰을 열어보고 수정/삭제 권한을 승인(인가)한다.

🔑 2. 토큰(Token)이란?

토큰은 로그인 성공 시 발급받는 디지털 회원증으로, 권한이 필요한 요청을 보낼 때마다 HTTP 헤더에 담아 서버에 제시함으로써 자신이 허가된 사용자(인가)임을 증명하는 열쇠


✉️  토큰을 주고받는법

  1. 로그인 완료: 프론트엔드가 ID/PW를 보내면, 서버는 검증 후 암호화된 토큰(예: JWT)을 응답으로 보내줌
  2. 금고에 보관: 프론트엔드는 이 토큰을 안전하게 보관 (현재 우리 프로젝트에서는 localStorage에 저장 중!)
  3. 요청마다 헤더에 탑승 (핵심 🌟): 이제 게시글을 지우거나 수정할 때, Axios 요청 설정의 HTTP Request Header에 아래와 같이 규칙을 정해 토큰을 심어 보냄
// 실무에서 통용되는 HTTP Authorization 헤더 형식
Authorization: Bearer 보관중인_토큰_문자열

🛠️ Axios 토큰 탑승 코드 

실무에서는 매번 요청할 때마다 토큰을 적기 귀찮으므로, Axios 인터셉터(Interceptor) 기능을 이용해 모든 요청의 헤더에 토큰이 자동으로 탑승하도록 설계

// api.js 인스턴스 설정 파일 내부
api.interceptors.request.use((config) => {
    // 1. 로컬 스토리지에서 보관 중인 토큰을 꺼낸다.
    const token = localStorage.getItem('token');

    // 2. 토큰이 존재하면, 모든 API 요청 헤더에 'Authorization' 이라는 이름으로 탑승시킨다!
    if (token) {
        config.headers.Authorization = `Bearer ${token}`;
    }

    return config;
});

 

 

3.  브라우저의 사전 점검: Preflight(프리플라이트)

CORS(다른 도메인 간 통신) 환경에서 브라우저가 본 요청을 보내기 전, 서버에 "이 요청 안전한가요?"라고 먼저 찔러보는 사전 예비 요청 단계이다.

  • 동작 과정:
  1. 노크 (OPTIONS 요청): 브라우저가 진짜 요청을 보내기 전, 서버에 OPTIONS 메서드로 먼저 안전성을 질의한다.
  2. 허락 (서버의 응답): 서버가 프론트엔드의 도메인을 확인하고 안전하다는 응답 헤더(Access-Control-Allow-Origin)를 반환한다.
  3. 본 요청 (진짜 데이터 전송): 허락을 받은 브라우저가 그제서야 우리가 작성한 진짜 Axios.post() 데이터를 서버로 날린다.
  • 실무 포인트: 우리가 흔히 쓰는 Content-Type: application/json 통신 방식은 브라우저가 보안상 검증이 필요한 요청으로 판단하기 때문에 무조건 Preflight(OPTIONS)를 거치게 된다. 만약 네트워크 창에 OPTIONS가 빨간색으로 뜨며 실패한다면 100% CORS 에러이므로 백엔드 서버에서 도메인 및 메서드를 허용해 주어야 한다.

 

4.비밀 보관함: .env (환경 변수)

소스 코드에 직접 적기엔 보안상 위험하거나 개발/배포 환경에 따라 주소가 바뀔 가능성이 있는 민감한 데이터(API 비밀키, 백엔드 서버 URL 등)를 소스 외부에서 관리하는 파일이다.

  • 실무 규칙 및 사용법 (Vite 기준):
  1. 위치: src 폴더 내부가 아닌, package.json과 같은 레벨인 프로젝트 최상위 루트(Root)에 생성한다.
  2. 작성 규칙: 변수명 앞에 반드시 VITE_ 접두사를 붙여야 리액트 엔진이 보안상 안전한 변수로 인식한다.
VITE_API_URL=http://localhost:4000
VITE_SECRET_KEY=mysecret1234

3.  코드 적용: import.meta.env.VITE_변수명으로 쏙 꺼내 쓴다. (CRA 환경은 process.env.REACT_APP_ 사용)

const api = axios.create({
    baseURL: import.meta.env.VITE_API_URL // 환경에 맞게 동적으로 주입됨
});

4. 주의사항: .env 파일은 절대 깃허브에 올리면 안 되므로, 반드시 .gitignore 파일에 등록하여 업로드 목록에서 배제해야 한다.


🏗️ 2. 댓글 삭제(Delete) 기능 구현과 상태 동기화

단순히 DB 데이터만 지우는 것을 넘어, 리액트의 상태(State)를 변경하여 새로고침 없이 화면을 즉시 동기화하는 데이터 단방향 제어(부모 ➡️ 자식 ➡️ 손자) 흐름이다.

 

Step 1. 최상단 부모에서 삭제 핸들러 만들기 (BlogReadPage.jsx)

api.delete로 서버의 데이터를 지운 후, filter 함수를 사용하여 기존 배열에서 삭제된 id를 제외한 새로운 배열을 생성해 상태를 최신화한다.

// BlogReadPage.jsx 내부
const deleteCommentHandler = async (commentId) => {
    try {
        // 1. json-server(db.json)에서 해당 id의 댓글 데이터 삭제 (서버 통신)
        await api.delete(`/comments/${commentId}`);

        // 2. 현재 화면의 상태(comments 배열) 업데이트 (UI 즉시 동기화)
        // [방법 (1)] 일반 상태 업데이트 방식
        // setComments(comments.filter(comment => comment.id !== commentId));

        // [방법 (2)] 함수형 업데이트 방식 🌟 (prevComments를 사용하여 항상 최신의 상태 유지를 보장)
        setComments(prevComments => prevComments.filter(comment => comment.id !== commentId));
    } catch (error) {
        console.error("댓글 삭제에 실패했습니다.", error);
    }
};

return (
    // 자식 컴포넌트인 List에 삭제 핸들러를 onDelete라는 이름의 Props로 전달
    <BlogCommentList comments={comments} onDelete={deleteCommentHandler} />
)

 

Step 2. 리스트 컴포넌트에서 Props 넘겨주기 (BlogCommentList.jsx)

부모에게서 받은 onDelete 함수를 map()을 통해 반복 생성되는 자식 컴포넌트들에게 토스한다.

// BlogCommentList.jsx
const BlogCommentList = ({ comments, onDelete }) => {
  return (
    <Wrapper>
      {comments.map((comment) => (
        <BlogCommentItem
            key={comment.id}
            comment={comment}
            onDelete={onDelete} // 💡 손자 컴포넌트로 징검다리 토스
        />
      ))}
    </Wrapper>
  )
}

 

Step 3. 아이템 컴포넌트에서 클릭 이벤트 연결하기 (BlogCommentItem.jsx)

가장 하위 컴포넌트의 삭제 버튼 onClick 이벤트에 함수를 연결하되, 지우고자 하는 본인의 comment.id를 인자로 담아 호출한다.

// BlogCommentItem.jsx
const BlogCommentItem = ({ comment, onDelete }) => {
  return (
    <Wrapper>
        <ContentText>{comment.content}</ContentText>
        {/* 💡 화살표 함수를 사용하여 클릭 시에만 인자를 담아 실행되도록 콜백 처리 */}
        <Button title="삭제" onClick={() => onDelete(comment.id)}>삭제</Button>
    </Wrapper>
  )
}

 

🛠️ 3. 댓글 수정(Update) 기능 구현과 역방향 데이터 흐름

수정 로직은 화면 제어와 데이터 입력을 말단 컴포넌트가 담당하고, 최종 가공된 데이터를 부모 컴포넌트의 핸들러로 끌어올리는(State Lifting Up) 역방향 흐름을 가진다.

 

Step 1. 아이템 컴포넌트에서 화면 제어 및 임시 저장 (BlogCommentItem.jsx)

isEditMode 상태에 따라 입력창과 일반 텍스트를 스위칭하는 조건부 렌더링을 수행한다.

// BlogCommentItem.jsx 내부
const [isEditMode, setIsEditMode] = useState(false); // 현재 수정 모드 상태 토글
const [editContent, setEditContent] = useState(comment.content); // 수정 중인 텍스트 임시 저장

const handleSave = () => {
  if (onEdit) {
    onEdit(comment.id, editContent); // 부모로부터 받은 함수에 id와 수정본 전달!
  }
  setIsEditMode(false); // 일반 모드로 전환
};

return (
  <Wrapper>
      {/* 💡 조건부 렌더링: 수정 모드일 때는 입력창, 아닐 때는 일반 텍스트 노출 */}
      {isEditMode ? (
          <TextInput value={editContent} changeHandler={(e) => setEditContent(e.target.value)} />
      ) : (
          <ContentText>{comment.content}</ContentText>
      )}

      {currentUserEmail === comment.email && (
          <ButtonContainer>
              {isEditMode ? (
                  <>
                      <Button title="저장" onClick={handleSave} />
                      <Button title="취소" onClick={() => { setIsEditMode(false); setEditContent(comment.content); }} />
                  </>
              ) : (
                  <>
                      <Button title="수정" onClick={() => setIsEditMode(true)} />
                      <Button title="삭제" onClick={() => onDelete(comment.id)} />
                  </>
              )}
          </ButtonContainer>
      )}
  </Wrapper>
)

 

Step 2. 리스트 컴포넌트에서 징검다리 통로 개설 (BlogCommentList.jsx)

상위 페이지에서 정의한 onEdit 핸들러를 꼬리 물기식 Props로 자식에게 전달한다.

// BlogCommentList.jsx
const BlogCommentList = ({ comments, onDelete, onEdit, currentUserEmail }) => {
  return (
  <Wrapper>
    {comments.map((comment) => (
      <BlogCommentItem
          key={comment.id}
          comment={comment}
          onDelete={onDelete}
          onEdit={onEdit} // 💡 수정 권한 핸들러 토스
          currentUserEmail={currentUserEmail}
      />
    ))}
  </Wrapper>
  )
}

 

Step 3. 최상단 부모에서 실제 서버 데이터 업데이트 (BlogReadPage.jsx)

api.patch를 이용하여 리소스의 일부 속성(content)만 효율적으로 수정하고, 배열의 map()을 돌려 타깃 데이터를 치환한다.

// BlogReadPage.jsx 내부
const editCommentHandler = async (commentId, updatedContent) => {
    try {
        // 1. 서버 통신: patch를 이용해 특정 id의 댓글 중 'content' 내용만 부분 업데이트
        await api.patch(`/comments/${commentId}`, {
            content: updatedContent
        });

        // 2. 화면 갱신: 기존 댓글 배열(prevComments)을 순회(map)하면서,
        // 수정한 댓글의 id와 일치하는 것만 내용을 교체해주고 나머지는 그대로 유지시킴 (불변성 준수)
        setComments(prevComments =>
            prevComments.map(comment =>
                comment.id === commentId ? { ...comment, content: updatedContent } : comment
            )
        );
    } catch (error) {
        console.error("댓글 수정에 실패했습니다.", error);
    }
};

return (
  <BlogCommentList
      comments={comments}
      onDelete={deleteCommentHandler}
      onEdit={editCommentHandler} // 핸들러 주입
      currentUserEmail={email}
  />
)

💡 4. 핵심 트러블 슈팅 및 배운 점

① onClick={onDelete(comment.id)} 라고 쓰면 안 되는 이유

  • 문제점: 괄호를 붙여 호출식으로 작성하면 컴포넌트가 렌더링되자마자 함수가 즉시 자동 실행되어 버린다. 그 결과 화면이 켜짐과 동시에 모든 댓글이 순삭(?)되는 렌더링 루프 에러가 터진다.
  • 해결책: 반드시 onClick={() => onDelete(comment.id)} 형태로 화살표 함수로 한 번 감싸서 넘겨야 한다. 그래야 리액트가 콜백 함수 형태로 메모리에 기억해 두고 있다가 사용자가 실제 버튼을 클릭했을 때만 올바르게 인자를 담아 실행한다.

② JavaScript filter()와 map()을 통한 리액트 불변성 법칙의 사수

  • 데이터를 삭제할 때는 원본 배열을 훼손하지 않고 걸러내는 filter()가 치트키이며, 데이터를 수정할 때는 특정 대상만 타깃팅하여 교체하는 map()과 스프레드 연산자(...)의 조합이 최적의 실무 패턴이다. 두 메서드 모두 기존 데이터를 오염시키지 않고 완전히 새로운 배열을 반환하므로 리액트의 대원칙인 '불변성 유지(Immutability)'를 완벽하게 지킬 수 있다.

🔄 5. 댓글 조작 시 리렌더링 발동 메커니즘

화면이 새로고침 없이 즉시 동동기화되는 이면에는 리액트만의 정교한 가상 DOM 리렌더링 사이클이 존재한다.

  1. 상태 감지 (Trigger): filter()나 map()이 반환한 완전히 새로운 주소값의 배열이 setComments를 통해 주입되는 순간, 리액트는 상태가 변경되었음을 감지하고 렌더링 신호를 켠다.
  2. 부모 컴포넌트 재실행 (Render): 해당 상태를 쥐고 있는 최상단 부모 컴포넌트인 BlogReadPage 함수 전체가 리프레시되며 위에서부터 아래로 다시 컴파일된다.
  3. 자식 컴포넌트 도미노 현상 (Prop Propagation): 부모가 새로 빌드되면서 자식인 BlogCommentList가 받는 comments 프로퍼티(Props)의 레퍼런스가 변했기 때문에, 하위의 자식들과 손자 컴포넌트들까지 줄줄이 연속으로 재호출된다.
  4. 가상 DOM 비교 및 부분 반영 (Commit): 리액트는 메모리상에서 새로 리렌더링된 결과물(가상 DOM)을 이전 화면의 스냅샷과 비교(Diffing)한다. 화면 전체를 새로 지우고 그리는 것이 아니라, "사라진 댓글 1개" 혹은 "텍스트가 변한 댓글 1개"의 바뀐 노드만 정확히 집어내어 브라우저 화면(진짜 DOM)에 패치한다.

 

 

오늘의 회고: 블로그 프로젝트를 통해 불변성을 지키는 filter/map 상태 제어와 가상 DOM 리렌더링 메커니즘을 마스터하며 새로고침 없는 실시간 UI 동기화를 구현하는 시간을 가졌다. 아울러 .env 보안 설정, Preflight(OPTIONS)의 원리, 그리고 HTTP 헤더에 토큰을 실어 보내는 인증·인가 시스템을 직접 구축해 봄으로써 프론트엔드 실무 아키텍처와 웹 네트워크 보안의 정수를 완벽히 체득한 뜻깊은 시간이었다.

 

728x90
반응형
LIST