[TIL] 7. 토이프로젝트(2): 이벤트 핸들러부터 댓글 CRUD까지

MinseoKim·2026년 8월 7일
post-thumbnail

😅 절대 절대 잊으면 안되는 기본기..

  • onClick에는 실행 결과가 아니라 함수를 넘긴다.
  • API를 받으면 res.data가 배열인지 객체인지 먼저 본다.
  • 새 state가 이전 state에 의존하면 setState(prev => ...)를 쓴다.

1. 오늘의 한 줄 요약

오늘은 블로그 글 작성과 상세 조회, 댓글 등록·삭제 기능을 구현했다. 평소보다 에러가 많이 떠 식은땀 나는 하루였다.

TIL 작성을 위해 에러들을 회고하다보니, 결국 정확한 함수 이해에 부족한 점이 반복되고 있었다.

지금 내가 넘기는 값이 무엇인지, 함수가 어떤 인자를 받는지, 서버가 어떤 형태의 데이터를 반환하는지를 정확히 알아야 한다.

React에서는 코드 한 줄만 보는 것보다 데이터가 어디에서 시작해서 어디로 전달되는지 흐름을 따라가는 것이 훨씬 중요하다는 걸 오늘 제대로 느꼈다.


2. 오늘 배운 내용

1) 이벤트 핸들러에는 "함수"를 넘겨야 한다

오늘 가장 많이 헷갈렸던 부분 중 하나였다.

처음에는 버튼에 이렇게 작성했다.

<Button
    title="등록하기"
    onClick={writeHandler}
/>

writeHandler는 다음과 같이 여러 값을 받도록 만들어둔 상태였다.

const writeHandler = async (
    e,
    category,
    title,
    contents,
    email
) => {
    // ...
};

나는 버튼을 클릭하면 category, title, contents, email까지 알아서 들어오는 줄 알았다.

하지만 실제로 onClick={writeHandler}라고 작성하면 React가 넘겨주는 것은 클릭 이벤트 객체 하나뿐이다.

즉 실제 호출은 거의 이런 상태였다.

writeHandler(event);

그러니 나머지는 전부

category  // undefined
title     // undefined
contents  // undefined
email     // undefined

가 되어버렸다.

실제로 콘솔에서도 이런 요청이 찍혔다.

category=undefined
title=undefined
contents=undefined
email=undefined

값을 직접 넘기고 싶다면 클릭 시 실행할 새로운 함수를 만들어줘야 한다.

<Button
    title="등록하기"
    onClick={(e) =>
        writeHandler(
            e,
            category,
            title,
            content,
            email
        )
    }
/>

여기서 중요한 건 onClick에는 실행 결과가 아니라 실행할 함수 자체가 들어가야 한다는 것이다.


onClick={moveUrl(...)}이 안 됐던 이유도 같았다

이 코드도 처음에는 왜 안 되는지 이해가 안 됐다.

<Wrapper
    onClick={moveUrl(`/blogs/read/${props.blog.id}`)}
>

이렇게 작성하면 클릭했을 때 moveUrl()이 실행되는 것이 아니다.

컴포넌트가 렌더링되는 순간

moveUrl(...)

이 먼저 실행된다.

그리고 그 함수의 반환값이 onClick에 들어가게 된다.

그래서 클릭 시 실행되도록 하려면 다음처럼 작성해야 한다.

<Wrapper
    onClick={() =>
        moveUrl(`/blogs/read/${props.blog.id}`)
    }
>

오늘 이벤트 관련해서 배운 규칙을 한 줄로 정리하면:

onClick에는 "지금 실행할 코드"가 아니라 "나중에 클릭됐을 때 실행할 함수"를 넘긴다.


2) 이벤트 객체와 내가 가진 데이터는 다르다

댓글 삭제 버튼에서도 비슷한 실수를 했다.

처음 코드:

<Button
    title="삭제"
    onClick={(e) => handler(e.comment.id)}
/>

나는 e 안에서 댓글 정보를 꺼내려고 했다.

그런데 여기서 e는 댓글 데이터가 아니다.

e는 React가 넘겨준 클릭 이벤트 객체다.

그래서

e.comment

이라는 값 자체가 존재하지 않는다.

결국

e.comment.id

를 읽으려고 하면서

Cannot read properties of undefined
(reading 'id')

에러가 발생했다.

하지만 이미 이 컴포넌트는 comment를 props로 받고 있었다.

const BlogCommentItem = ({ comment, handler }) => {
    // ...
};

그러니까 이벤트 객체를 거칠 이유가 없었다.

<Button
    title="삭제"
    onClick={() => handler(comment.id)}
/>

이렇게 바로 사용할 수 있었다.

이걸 보면서 다시 느낀 점:

함수를 호출하기 전에 "지금 이 변수에 실제로 뭐가 들어있는지"부터 확인해야 한다.

e라고 해서 내가 원하는 데이터가 들어있는 게 아니다.


3) URL의 라우트 파라미터와 서버 데이터의 필드명은 다르다

글 상세 페이지를 불러올 때도 여기서 많이 막혔다.

const { blogId } = useParams();

URL이

/blogs/read/1

이라면

blogId === "1"

이 된다.

처음에는 다음처럼 요청했다.

api.get(`/blogs?blogId=${blogId}`)

그런데 데이터가 나오지 않았다.

처음에는 blogId가 잘못 들어온 줄 알았다.

하지만 문제는 blogId라우터에서 내가 정한 변수 이름이라는 점이었다.

실제 db.json의 블로그 데이터는 이런 구조였다.

{
    id: 1,
    title: '...',
    content: '...',
    category: '...',
    email: '...'
}

서버 데이터에는 blogId라는 필드가 없다.

그러니 다음 요청은

/blogs?blogId=1

서버 입장에서는

"blogId가 1인 데이터 찾아줘"

인데, 애초에 그런 key가 없기 때문에 결과가 없는 것이었다.

단건 조회라면 그냥

api.get(`/blogs/${blogId}`)

처럼 요청하는 게 더 자연스러웠다.


4) API가 배열을 주는지 객체를 주는지 확인해야 한다

오늘 가장 중요한 부분 중 하나.

같은 /blogs 데이터를 가져와도 요청 방식에 따라 반환되는 데이터 모양이 달랐다.

쿼리 필터 방식

api.get(`/blogs?id=${id}`)

결과:

[
    {
        id: 1,
        title: '...',
        ...
    }
]

배열이다.

그래서 데이터를 하나 꺼내려면

res.data[0]

이 필요했다.

반대로 단건 경로로 요청하면

api.get(`/blogs/${id}`)

응답은

{
    id: 1,
    title: '...',
    ...
}

처럼 객체 하나가 온다.

그런데 여기에서도 이전 습관대로

setBlog(res.data[0]);

를 써버렸다.

객체에는 [0]이 없으니 결국

undefined

가 들어간다.

그리고 다음 렌더링에서

blog.id

를 읽으려고 하니 에러가 났다.

수정:

setBlog(res.data);

오늘 여러 번 느낀 부분인데, API를 호출한 뒤에는 바로 데이터를 쓰기 전에

console.log(res.data);

데이터의 모양부터 확인하는 습관이 필요할 것 같다.


5) useState()의 초기값도 데이터 타입에 맞춰야 한다

댓글 입력창에 갑자기

[object Object]

가 뜨는 문제도 있었다.

원인은 이 코드였다.

const [comment, setComment] = useState({});

댓글은 입력창에서 사용할 문자열인데 초기값을 객체로 만들어둔 것이다.

그리고 이 값을 그대로 textareavalue에 전달했다.

<TextInput
    value={comment}
    handler={(e) => {
        setComment(e.target.value);
    }}
/>

객체 {}를 문자열로 화면에 표현하려고 하면 JavaScript에서는 기본적으로

[object Object]

가 된다.

그래서 입력창에 저 문자가 그대로 보였던 것이다.

수정은 간단했다.

const [comment, setComment] = useState('');

오늘은 state를 만들 때 초기값을 그냥 습관적으로 {}[]로 쓰면 안 된다는 것도 배웠다.

기준은 생각보다 명확했다.

// 입력창처럼 문자열을 저장
const [comment, setComment] = useState('');

// 여러 개의 데이터를 저장
const [comments, setComments] = useState([]);

// 하나의 객체 데이터를 저장
const [blog, setBlog] = useState({});

결국 먼저 생각해야 하는 건:

"나중에 이 state 안에는 어떤 형태의 데이터가 들어올까?"

그 데이터의 빈 형태를 초기값으로 사용하면 된다.


6) useParams()의 값은 문자열이다

댓글을 등록하고 나면 화면에는 잘 나왔는데, 새로고침하면 댓글이 사라지는 문제가 있었다.

처음에는 POST가 제대로 안 된 줄 알았다.

하지만 db.json을 확인해보니 댓글은 실제로 저장되어 있었다.

문제는 타입이었다.

const { blogId } = useParams();

React Router의 useParams()로 받아온 값은 항상 문자열이다.

blogId === "1"

그런데 블로그의 실제 id는 숫자였다.

blog.id === 1

댓글을 등록할 때 이렇게 저장하고 있었다.

api.post('/comments', {
    comment,
    blogId,
    email
});

그러면 서버에는

blogId: "1"

이 저장된다.

하지만 블로그의 id는

id: 1

이다.

결국

"1" !== 1

이기 때문에 _embed=comments로 관계를 조회했을 때 댓글을 찾지 못했다.

그래서 저장할 때 이미 숫자로 로드되어 있는 blog.id를 사용했다.

api.post('/comments', {
    comment,
    blogId: blog.id,
    email
});

이렇게 바꾸니 새로고침해도 댓글이 정상적으로 연결됐다.

오늘 이것 때문에:

값이 같아 보여도 타입이 다르면 전혀 다른 값일 수 있다.

라는 걸 다시 확인했다.


7) POST 성공 후 화면은 자동으로 바뀌지 않는다

댓글 POST 요청 자체는 정상적으로 성공하고 있었다.

api.post('/comments', {
    comment,
    blogId: blog.id,
    email
});

상태 코드도 201.

그런데 댓글 목록에는 바로 안 보였다.

처음에는

"서버에서 다시 새로운 데이터를 받아와야 하는 건가?"

라고 생각했다.

방법은 실제로 두 가지였다.

방법 1. 서버에서 다시 조회

if (res.status === 201) {
    loadData();
}

댓글을 등록한 뒤 블로그 데이터를 다시 가져오는 방식.

서버 최신 상태를 다시 가져오기 때문에 확실하지만 API 요청이 하나 더 필요하다.

방법 2. 방금 받은 댓글을 state에 직접 추가

POST가 성공하면 서버는 방금 생성된 댓글을 res.data로 돌려준다.

그러면 이 데이터를 기존 댓글 목록에 바로 추가할 수 있다.

if (res.status === 201) {
    setComments((ary) => [
        ...ary,
        res.data
    ]);
}

굳이 서버에 한 번 더 요청하지 않아도 화면에 즉시 반영된다.

이번에는 두 번째 방법을 사용했다.

추가로 댓글 등록이 끝난 뒤에는

setComment('');

로 입력창도 비워줬다.


8) 이전 state를 이용해서 갱신할 때는 함수형 업데이트

처음에는 이렇게 작성했다.

setComments([
    ...comments,
    res.data
]);

지금처럼 단순한 상황에서는 잘 동작한다.

하지만 더 안전한 방식은 다음과 같다.

setComments((ary) => [
    ...ary,
    res.data
]);

첫 번째 방식의 comments는 현재 함수가 만들어질 당시의 state 값을 참조한다.

비동기 요청이 겹치거나 상태 업데이트가 연속으로 발생하면 최신 state가 아닐 가능성이 있다.

반면 함수형 업데이트에서는 React가 최신 state를 인자로 넘겨준다.

ary => [...ary, res.data]

그래서 새로운 state가 이전 state를 기반으로 만들어지는 경우에는 이 방식이 더 안전하다.

내가 기억할 규칙:

이전 state 값이 필요하다면 setState(prev => ...)를 사용한다.

댓글 삭제도 같은 방식으로 정리할 수 있다.

setComments((ary) =>
    ary.filter(
        (comment) => comment.id !== id
    )
);

9) state 변경으로 발생하는 리렌더링은 무한 렌더링이 아니다

댓글 입력창에 글자를 입력할 때마다 콘솔이 계속 찍혔다.

처음에는

"이거 무한 렌더링 아닌가?"

라고 생각했다.

그런데 원인은 이 로그였다.

const BlogReadPage = () => {

    console.log(
        `debug >>> response, ${blogId}, ${email}`
    );

    // ...
};

로그가 useEffect() 안이 아니라 컴포넌트 함수 몸통에 바로 들어가 있었다.

댓글을 입력하면

setComment(e.target.value);

가 실행된다.

state가 바뀌니까 React는 컴포넌트를 다시 렌더링한다.

그러면 BlogReadPage()가 다시 실행되고, 그 안에 있는 console.log()도 다시 실행된다.

즉,

입력
→ setComment()
→ state 변경
→ 리렌더링
→ console.log() 다시 실행

이었다.

무한 렌더링이 아니라 정상적인 React 리렌더링 과정이었다.

오늘은 리렌더링이 단순히 "화면만 다시 그려지는 것"이 아니라, 함수 컴포넌트의 코드가 다시 실행된다는 것도 좀 더 확실하게 이해했다.


3. 실습 결과

오늘은 블로그 상세 페이지와 댓글 기능을 중심으로 작업했다.

구현하거나 수정한 기능은 다음과 같다.

  • 블로그 글 작성 POST 요청
  • 블로그 목록에서 상세 페이지 이동
  • useParams()를 이용한 글 id 조회
  • 블로그 단건 데이터 조회
  • 댓글 목록 _embed 조회
  • 댓글 등록
  • 등록한 댓글 즉시 화면 반영
  • 댓글 입력창 초기화
  • 댓글 삭제
  • 삭제 후 댓글 목록 state 갱신
  • 댓글과 블로그 id 타입 맞추기

특히 댓글 등록 후 다시 서버를 조회하지 않고,

setComments((ary) => [
    ...ary,
    res.data
]);

를 사용해서 서버가 돌려준 데이터를 바로 화면에 추가하는 방식까지 구현했다.

댓글 삭제 역시 DELETE 요청이 성공한 뒤

setComments((ary) =>
    ary.filter(
        (comment) => comment.id !== id
    )
);

형태로 서버에서는 삭제하고, 클라이언트 state에서도 해당 댓글을 제거하는 흐름으로 이해했다.


4. 문제와 해결

문제 1. POST 요청 값이 전부 undefined

처음에는 작성 버튼에

onClick={writeHandler}

만 연결했다.

하지만 writeHandler는 여러 개의 인자를 필요로 하고 있었고, React가 자동으로 넘겨주는 것은 이벤트 객체 하나뿐이었다.

그래서 실제 API 요청에서는 입력값들이 전부 undefined가 됐다.

해결

onClick={(e) =>
    writeHandler(
        e,
        category,
        title,
        content,
        email
    )
}

처럼 실제 값들을 직접 넘겨줬다.

여기에 입력 컴포넌트에도 value와 handler를 연결해 state가 실제 입력값을 가지고 있도록 수정했다.


문제 2. 데이터를 받아왔는데 스피너가 계속 돌았다

API 응답은 정상적으로 콘솔에 찍히는데 화면에서는 계속 스피너만 보였다.

원인은 여러 개가 겹쳐 있었다.

!blogId.blogId

처음에는 위 코드처럼 문자열인 blogId를 객체처럼 사용하고 있었다.

또 API 요청 결과가 배열인데 객체처럼 사용하거나, 반대로 객체인데 res.data[0] 으로 접근하기도 했다.

해결

먼저 API 응답 형태를 확인했다.

/blogs?id=1 → 배열

/blogs/1 → 객체

단건 조회에서는

setBlog(res.data);

를 사용하도록 수정했다.

결국 로딩 조건 역시 blogId 자체가 아니라 실제 데이터가 로드됐는지를 기준으로 판단해야 한다는 것도 알게 됐다.


문제 3. 댓글 등록은 되는데 새로고침하면 사라짐

POST 이후 화면에는 댓글이 보였지만 새로고침하면 사라졌다.

서버를 확인해보니 데이터는 존재했다.

원인은 blogId의 타입이었다.

useParams(); // "1"

라우터에서 가져온 id는 문자열이었고,

blog.id  // 1

실제 데이터 id는 숫자였다.

해결

댓글 POST 시 라우터의 blogId 대신 이미 서버에서 받아온 숫자 id를 사용했다.

api.post('/comments', {
    comment,
    blogId: blog.id,
    email
});

이후 _embed=comments에서도 정상적으로 관계가 연결됐다.


문제 4. 댓글 삭제 버튼에서 undefined.id 에러

처음에는

onClick={(e) =>
    handler(e.comment.id)
}

라고 작성했다.

하지만 e는 댓글이 아니라 클릭 이벤트 객체였다.

해결

이미 props로 가지고 있는 comment를 직접 사용했다.

onClick={() =>
    handler(comment.id)
}

삭제 핸들러도 실제로 필요한 값이 id 하나뿐이므로

const commentDeleteHandler = async (id) => {
    // ...
};

로 맞췄다.

DELETE 성공 후에는 화면에서도 해당 댓글을 제거했다.

setComments((ary) =>
    ary.filter(
        (comment) => comment.id !== id
    )
);

오늘 가장 크게 이해한 부분

오늘 계속 발생했던 에러들은 겉으로 보면 전부 달랐다.

undefined
[object Object]
Cannot read properties of undefined
스피너가 계속 도는 문제
댓글이 새로고침 후 사라지는 문제

그런데 원인을 따라가 보면 대부분 데이터의 형태와 흐름을 정확하게 모르고 사용한 것에서 시작했다.

이제 에러가 발생하면 바로 코드를 고치기보다 먼저 다음을 확인해보려고 한다.

  1. 지금 이 변수에는 실제로 어떤 값이 들어있는가?
  2. 문자열 / 숫자 / 배열 / 객체 중 어떤 타입인가?
  3. 이 함수는 어떤 인자를 받고 있는가?
  4. 내가 넘긴 인자의 순서와 개수가 맞는가?
  5. API의 res.data는 배열인가 객체인가?
  6. state 초기값은 나중에 들어올 데이터 형태와 맞는가?
  7. 서버 데이터와 클라이언트 데이터의 key와 타입이 같은가?

오늘은 단순히 댓글 기능을 만든 것보다 이 확인 순서를 알게 된 게 더 큰 수확인 것 같다.


5. 다음 할 일

오늘은 API 요청과 state 갱신 흐름을 많이 다뤘지만 아직 자연스럽게 설명하려면 조금 더 연습이 필요하다.

특히 다음 내용을 다시 정리해보고 싶다.

  • useEffect()의 의존성 배열
  • blogId가 바뀔 때 왜 [blogId]가 필요한지
  • API 요청에서 쿼리 파라미터와 path parameter의 차이
  • GET / POST / DELETE 요청에서 데이터를 전달하는 방식
  • API 응답 상태 코드 200, 201, 204 차이
  • 배열 응답과 객체 응답 구분하기
  • 함수형 state 업데이트가 필요한 상황
  • 컴포넌트 리렌더링과 함수 재실행 과정

그리고 지금 코드는 아직 async/await.then()을 같이 사용하고 있다.

const loadData = async () => {
    await api.get('/blogs')
        .then((res) => {
            // ...
        })
        .catch((err) => {
            // ...
        });
};

다음에는 이걸

try {
    const res = await api.get('/blogs');
} catch (err) {
    // ...
}

형태로 작성하는 방식까지 비교해보면서 비동기 처리 흐름을 제대로 이해해보고 싶다.

오늘은 코드가 계속 터져서 꽤 오래 헤맸지만, 그 과정에서 React에서 중요한 건 단순히 문법을 외우는 게 아니라 이벤트 → state → API → 응답 → state 변경 → 리렌더링으로 이어지는 데이터 흐름을 따라가는 것이라는 걸 조금씩 이해하기 시작했다.

0개의 댓글