관리 메뉴

거니의 velog

(11) 프론트엔드 프로젝트 : 글쓰기 기능 구현하기 2 본문

React_프론트엔드 프로젝트

(11) 프론트엔드 프로젝트 : 글쓰기 기능 구현하기 2

Unlimited00 2024. 2. 23. 14:43

2. 에디터 하단 컴포넌트 UI 구현하기

* 에디터 하단에 태그를 추가하는 컴포넌트와 포스트 작성을 완료하거나 취소하는 버튼을 보여 주는 컴포넌트를 만들어 보자.

(1) TagBox 만들기

* 태그를 추가하는 컴포넌트 이름은 TagBox라고 하겠다. 이 컴포넌트를 다음과 같이 만들어 보자.

[components/write/TagBox.js]

import React from 'react';
import styled from 'styled-components';
import palette from '../../lib/styles/palette';

const TagBoxBlock = styled.div`
  width: 100%;
  border-top: 1px solid ${palette.gray[2]};
  padding-top: 2rem;

  h4 {
    color: ${palette.gray[8]};
    margin-top: 0;
    margin-bottom: 0.5rem;
  }
`;

const TagForm = styled.form`
  border-radius: 4px;
  overflow: hidden;
  display: flex;
  width: 256px;
  border: 1px solid ${palette.gray[9]}; /* 스타일 초기화 */
  input,
  button {
    outline: none;
    border: none;
    font-size: 1rem;
  }
  input {
    padding: 0.5rem;
    flex: 1;
    min-width: 0;
  }
  button {
    cursor: pointer;
    padding-right: 1rem;
    padding-left: 1rem;
    border: none;
    background: ${palette.gray[8]};
    color: white;
    font-weight: bold;
    &:hover {
      background: ${palette.gray[6]};
    }
  }
`;

const Tag = styled.div`
  margin-right: 0.5rem;
  color: ${palette.gray[6]};
  cursor: pointer;
  &:hover {
    opacity: 0.5;
  }
`;

const TagListBlock = styled.div`
  display: flex;
  margin-top: 0.5rem;
`;

// React.memo를 사용하여 tag 값이 바뀔 때만 리렌더링되도록 처리
const TagItem = React.memo(({ tag }) => <Tag>#{tag}</Tag>);

// React.memo를 사용하여 tags 값이 바뀔 때만 리렌더링되도록 처리
const TagList = React.memo(({ tags }) => (
  <TagListBlock>
    {tags.map((tag) => (
      <TagItem key={tag} tag={tag} />
    ))}
  </TagListBlock>
));

const TagBox = () => {
  return (
    <TagBoxBlock>
      <h4>태그</h4>
      <TagForm>
        <input placeholder="태그를 입력하세요" />
        <button type="submit">추가</button>
      </TagForm>
      <TagList tags={['태그1', '태그2', '태그3']} />
    </TagBoxBlock>
  );
};

export default TagBox;

* TagBox 컴포넌트에서 모든 작업을 하지는 않는다. 이 컴포넌트를 만들 때 TagItem, TagList 라는 두 컴포넌트를 추가로 만들었는데, 이렇게 컴포넌트를 분리시킨 이유는 렌더링을 최적화하기 위해서이다. 현재 TagBox 컴포넌트는 두 가지 상황에서 렌더링을 한다. 첫 번째는 input 이 바뀔 때이고, 두 번째는 태그 목록이 바뀔 때이다.

* 만약 컴포넌트를 분리하지 않고 한 컴포넌트 안에서 전부 직접 렌더링한다면, input 값이 바뀔 때마다 태그 목록도 리렌더링될 것이다. 태그 목록이 리렌더링되면 또 태그 하나하나가 모두 리렌더링된다.

* 하지만 위에서 작성한 코드처럼 TagList와 TagItem 컴포넌트를 분리시켜 주면 input 값이 바뀌어도 TagList 컴포넌트가 리렌더링되지 않는다. 그리고 태그 목록에 변화가 생겨도 이미 렌더링 중인 TagItem 들은 리렌더링 되지 않고, 실제로 추가되거나 삭제되는 태그에만 영향을 미치게 된다.

* 컴포넌트를 분리하기만 하면 최적화가 되는 것은 아니다. 추가로 React.memo를 사용하여 컴포넌트들을 감싸 주면, 해당 컴포넌트가 받아 오는 props가 실제로 바뀌었을 때만 리렌더링해 준다. shouldComponentUpdate를 구현하고 모든 props를 비교해 보는 것과 동일하다.

* 다 만들었으면 WritePage에서 Editor 하단에 렌더링하자.

[pages/WritePage.js]

import React from 'react';
import Editor from '../components/write/Editor';
import TagBox from '../components/write/TagBox';
import Responsive from '../components/common/Responsive';

const WritePage = () => {
  return (
    <Responsive>
      <Editor />
      <TagBox />
    </Responsive>
  );
};

export default WritePage;

* TagBox 컴포넌트가 다음과 같이 잘 나타났는가?

TagBox

* TagBox 컴포넌트에 Hooks 를 사용하여 태그를 추가하고 제거하는 기능을 구현하겠다. TagBox 를 다음과 같이 수정해 보자.

[components/write/TagBox.js]

import React, { useState, useCallback } from 'react';
import styled from 'styled-components';
import palette from '../../lib/styles/palette';

const TagBoxBlock = styled.div`
  width: 100%;
  border-top: 1px solid ${palette.gray[2]};
  padding-top: 2rem;

  h4 {
    color: ${palette.gray[8]};
    margin-top: 0;
    margin-bottom: 0.5rem;
  }
`;

const TagForm = styled.form`
  border-radius: 4px;
  overflow: hidden;
  display: flex;
  width: 256px;
  border: 1px solid ${palette.gray[9]}; /* 스타일 초기화 */
  input,
  button {
    outline: none;
    border: none;
    font-size: 1rem;
  }
  input {
    padding: 0.5rem;
    flex: 1;
    min-width: 0;
  }
  button {
    cursor: pointer;
    padding-right: 1rem;
    padding-left: 1rem;
    border: none;
    background: ${palette.gray[8]};
    color: white;
    font-weight: bold;
    &:hover {
      background: ${palette.gray[6]};
    }
  }
`;

const Tag = styled.div`
  margin-right: 0.5rem;
  color: ${palette.gray[6]};
  cursor: pointer;
  &:hover {
    opacity: 0.5;
  }
`;

const TagListBlock = styled.div`
  display: flex;
  margin-top: 0.5rem;
`;

// React.memo를 사용하여 tag 값이 바뀔 때만 리렌더링되도록 처리
const TagItem = React.memo(({ tag, onRemove }) => (
  <Tag onClick={() => onRemove(tag)}>#{tag}</Tag>
));

// React.memo를 사용하여 tags 값이 바뀔 때만 리렌더링되도록 처리
const TagList = React.memo(({ tags, onRemove }) => (
  <TagListBlock>
    {tags.map((tag) => (
      <TagItem key={tag} tag={tag} onRemove={onRemove} />
    ))}
  </TagListBlock>
));

const TagBox = () => {
  const [input, setInput] = useState('');
  const [localTags, setLocalTags] = useState([]);

  const insertTag = useCallback(
    (tag) => {
      if (!tag) return; // 공백이라면 추가하지 않음
      if (localTags.includes(tag)) return; // 이미 존재한다면 추가하지 않음
      setLocalTags([...localTags, tag]);
    },
    [localTags],
  );

  const onRemove = useCallback(
    (tag) => {
      setLocalTags(localTags.filter((t) => t !== tag));
    },
    [localTags],
  );

  const onChange = useCallback((e) => {
    setInput(e.target.value);
  }, []);

  const onSubmit = useCallback(
    (e) => {
      e.preventDefault();
      insertTag(input.trim()); // 앞뒤 공백 없앤 후 등록
      setInput(''); // input 초기화
    },
    [input, insertTag],
  );

  return (
    <TagBoxBlock>
      <h4>태그</h4>
      <TagForm onSubmit={onSubmit}>
        <input
          placeholder="태그를 입력하세요"
          value={input}
          onChange={onChange}
        />
        <button type="submit">추가</button>
      </TagForm>
      <TagList tags={localTags} onRemove={onRemove} />
    </TagBoxBlock>
  );
};

export default TagBox;

* 이제 태그 등록 및 삭제가 잘 되는지 확인해 보자. 삭제는 추가된 태그를 클릭하면 삭제할 수 있게 만들었다.

태그 직접 등록 및 삭제

* 이제 TagBox 컴포넌트 개발을 거의 마쳤다. 추후 이 컴포넌트에 있는 tags 배열을 리덕스에서 관리할 때 또 수정할 것이다.

[1] WriteActionButtons 만들기

* WriteActionButtons 컴포넌트는 포스트 작성 및 취소를 할 수 있는 컴포넌트이다. 이 컴포넌트는 두 개의 버튼을 만들고 onPublish, onCancel 이라는 props를 받아 와서 사용하도록 해 보겠다.

[components/write/WriteActionButtons.js]

import React from 'react';
import styled from 'styled-components';
import Button from '../common/Button';

const WriteActionButtonsBlock = styled.div`
  margin-top: 1rem;
  margin-bottom: 3rem;
  button + button {
    margin-left: 0.5rem;
  }
`;

/* TagBox에서 사용하는 버튼과 일치하는 높이로 설정 후 서로 간의 여백 지정 */
const StyledButton = styled(Button)`
  height: 2.125rem;
  & + & {
    margin-left: 0.5rem;
  }
`;

const WriteActionButtons = ({ onCancel, onPublish }) => {
  return (
    <WriteActionButtonsBlock>
      <StyledButton cyan onClick={onPublish}>
        포스트 등록
      </StyledButton>
      <StyledButton onClick={onCancel}>취소</StyledButton>
    </WriteActionButtonsBlock>
  );
};

export default WriteActionButtons;

* 컴포넌트를 다 만든 뒤에는 WritePage에서 렌더링하자.

[pages/WritePage.js]

import React from 'react';
import Editor from '../components/write/Editor';
import TagBox from '../components/write/TagBox';
import WriteActionButtons from '../components/write/WriteActionButtons';
import Responsive from '../components/common/Responsive';

const WritePage = () => {
  return (
    <Responsive>
      <Editor />
      <TagBox />
      <WriteActionButtons />
    </Responsive>
  );
};

export default WritePage;

* 글쓰기 페이지에 필요한 모든 컴포넌트와 UI를 완성했다! 이제 화면에 다음과 같은 결과가 나타날 것이다.

WritePage UI 구성 완료