고윤태의 개발 블로그

누구나 접근하기 쉽지만 얻어 가는 것이 있는 글을 쓰기 위해 노력 중입니다.

개발

나의 노력이 만든 CS팀의 만족

고윤태 2024. 5. 30. 11:57
안녕하세요 이번에 작성하게 될 내용은 제가 회사에서 간단한 개발을 통해서 CS팀에 아주 좋은 만족도를 주게 된 경험에 대해 공유하고자 글을 작성하게 되었습니다.

 

💻 개발 환경

React + javascript로 진행되었습니다.

🧐 구현하게 된 계기

저는 프로덕트를 직접 이용하는 고객들뿐만 아니라, CS 처리를 위해 어드민 기능을 사용하는 CS팀 역시 제가 기여한 서비스의 사용자로 생각합니다.

그렇기에 종종 대면하게 되었을 때 현재 업무 처리 방식에 불편함은 없는지 좀 더 능률적으로 할 수 있는 방향성이 있다면 언제든 회사 메신저를 통해 공유를 해달라고 요청드립니다.

물론 기존의 일정이 우선이기 때문에 모든 요청을 즉시 처리할 수는 없습니다. 그래서 '바로 처리는 어렵지만, 가능한 시기에 검토하여 반영하겠습니다'라고 안내하고, 이러한 사항들을 백로그에 기록해 두고 있습니다.

 

기존 일정이 마무리되고 어느 정도의 여유가 생겼을 때 백로그를 확인하던 도중 이거는 개발 기간이 길지도 않으면서 반영 시에 너무나 만족스러운 결과를 얻을 거 같은 것을 발견했습니다.

 

요청 사항 공유

작성되어 있던 요청 사항 중에 한 번에 묶어서 처리하면 좋겠다 싶은 2개를 가져왔습니다.

  1. 이미지 순서 변경 시 Drag and Drop으로 변경하기
  2. 이미지 멀티 등록

이미지 순서 변경 시 Drag and Drop으로 변경하기

 

해당 검정 박스로 표시한 < > 버튼을 통해 이미지의 순서를 앞 또는 뒤로 바꿀 수 있었습니다.

이미지의 순서를 변경하는 경우는 실제로 종종 발생하는 상황이었고 최소 10개에 사진에서 많게는 50개까지의 사진을 등록한 뒤 순서를 변경하는 경우도 존재했습니다.

현재 제가 보여드리는 예시에서는 순서를 변경하는 일을 수행해야 한다면 부담스럽지 않지만 이미지 개수가 많은 상황에서 운이 좋지 않아서 맨 아래에서 맨 위로 옮겨야 한다면 꽤나 귀찮은 작업으로 판단이 되는 상황입니다.

이미지 멀티 등록

이미지 등록 같은 경우는 검정 박스로 표시한 + 버튼을 눌러 이미지 선택기를 통하여 선택하게 됩니다.

 

한 두 개의 사진만 올려야 할 때는 상관없지만. 간혹 사용자 중에 PC 사용이 미숙하셔서 원활한 진행이 어려운 경우 CS팀에서 도움을 주는 경우가 꽤 존재하는데 최소 10개에서 많게는 50개의 사진을 올려야 하는 상황에서 사진을 단 1개 씩만 업로드하는 것은 시간 효율성이 매우 떨어집니다.


제가 사용자였어도 당연히 많은 불편함을 느꼈을만한 현재의 상황이었습니다.

이러한 문제점을 개선한다면 CS팀의 업무 능률은 당연히 올라갈 것입니다.

또한 제가 생각한 다른 장점은 최근에 새로 입사하신 CS팀 분들에게 더 나은 프로덕트를 위해 불편함을 말하는 것이 중요하다는 인식을 심어 드릴 수 있는 좋은 기회라고 생각합니다.

업무적으로 너무나도 불편하니깐 개발팀이 바빠 보이니깐 내가 불편함을 감수하고 참고해야지 라는 생각으로 계속 업무를 진행하다 보면 당연스럽게 업무에 대한 만족도가 떨어지기 때문이죠

 

이 기회를 통해서 아래의 스탭을 진행할 수 있는 CS팀 분들이 많아졌으면 좋겠다고 생각했습니다.

  1. 불편함 발견
  2. 개선사항 요청 및 방향성 제시
  3. 반영
  4. 만족도 상승

물론 방향성을 제시해 주시거나 개발자와 의견을 나눌 때 자기만의 편의성이 아닌 다른 팀원들의 편의성도 생각해서 방향을 잡는 과정이 어려울 수도 있지만 저는 좋은 경험 중 하나라고 생각합니다.

🤔 개발 과정 및 코드

이미지 순서 변경 시 Drag and Drop으로 변경하기

drag and drop 같은 경우는 Google에 검색해 보면 굉장히 많은 자료들이 있는데요

 

여러 글들을 참고하여 개발했습니다.

 

const ImageEditor = ({
  images,
  handleChange,
}) => {
  const dragItem = useRef();
  const dragOverItem = useRef();


  const dragStart = (idx) => {
    dragItem.current = idx;
  };

  const dragEnter = (idx) => {
    dragOverItem.current = idx;
  };

  const drop = () => {
    const copyListItems = [...images];
    const dragItemContent = copyListItems[dragItem.current];
    copyListItems.splice(dragItem.current, 1);
    copyListItems.splice(dragOverItem.current, 0, dragItemContent);
    dragItem.current = null;
    dragOverItem.current = null;
    handleChange(copyListItems);
  };

  const cards = images.map((item, idx) => (
    <Card
      onDragStart={() => dragStart(idx)}
      onDragEnter={() => dragEnter(idx)}
      onDragOver={(e) => e.preventDefault()}
      onDragEnd={drop}
    />
  ));

  return (
    <div>
        {cards}
    </div>
  );
};

export default ImageEditor;

 

해당 코드는 drag and drop을 구현하기 위한 핵심 코드만 모아놓았습니다.

이미지 멀티 등록

기존에는 이미지가 멀티 선택이 왜 안 됐나? 확인이 우선이었습니다.

확인을 해보니 파일 선택을 위해 input File을 사용했습니다만

<input type="file" id="file" name="file" />

 

이런 식으로 작성이 되어 있었습니다.

여러 이미지를 선택하는 방법을 찾아보니 StackOverFlow에서 

https://stackoverflow.com/questions/1593225/how-to-select-multiple-files-with-input-type-file

 

How to select multiple files with <input type="file">?

How to select multiple files with <input type="file">?

stackoverflow.com

해당 글을 통해 multiple 속성을 통해 이미지 멀티 선택이 가능하도록 변경했습니다.

 

근데 여기서 기존 로직 중에 변경이 필요했던 부분이 존재했습니다.

(기존 컴포넌트는 Class형 컴포넌트로 작성이 되어 있지만 리팩토링을 진행하면서 함수형으로 변경하였습니다.)

 

기존 코드

  handleChange = () => {
    const file = this.fileElement.files[0];
    const reader = new FileReader();
    reader.onload = (e) => {
      this.setState({url: e.target.result, hasFile: true});
    }
    reader.readAsDataURL(file);
  };

 

기존 코드에서 input file의 onChange 핸들러 함수였습니다. 이미지 파일이 load가 된다면 해당 URL을 url이라는 state에 set을 하고 있습니다.

 

변경한 코드

  const imageFileRenderPromise = (file) => {
    return new Promise((r) => {
      const reader = new FileReader();

      reader.onload = (e) => {
        r(e.target.result);
      };

      reader.readAsDataURL(file);
    });
  };

  const handleChange = async (event) => {
    const promise = [];

    Object.values(event.target.files).forEach((file) => {
      if (file) {
        fileRef.current = [...fileRef.current, file];
        promise.push(imageFileRenderPromise(file));
      }
    });

    const result = await Promise.all(promise);
    setUrls([...urls, ...result]);
  };

 

이미지가 onLoad 된 경우마다 setState를 실행하는 경우 리액트는 상태 업데이트를 비동기적으로 배치 처리하기 때문에, 여러 setState 호출이 서로 충돌하여 의도치 않은 결과를 초래할 수 있습니다.

그런 문제를 해결하기 위해 이미지를 load 하는 것을 Promise로 만들어 onLoad가 된 경우 resolve를 이용하여 완료가 됐음을 나타내고

Promise.all을 사용하여 모든 이미지가 로드된 경우 state에 set을 하는 방식으로 처리하도록 코드를 구현했습니다.

 

또한 또 하나의 리팩토링을 진행했어야 했는데요!

해당 ImageNewCard라는 컴포넌트는 하나의 이미지만 감당이 가능하도록 설계가 되어 있었습니다.

추가할 file을 1개만 관리를 하였고 그에 맞게 추가될 이미지의 Card도 1개만 존재하였습니다.

 

제가 코드만 수정해도 이미지가 여러 개가 선택이 가능하지만 반영되는 것은 1개의 이미지뿐일 수밖에 없는 구조였습니다.

 

해당 기능 구현을 위해 저는 기존 코드를 어느 정도 갈아엎는 선택을 해야만 했습니다.

 

기존 코드

class ImageNewCard extends PureComponent {
  state = {
    url: 'http://placeholder.com',
    hasFile: false,
  };
  render() {
    const { classes } = this.props;
    const { url, hasFile } = this.state;
    return (
      <div className='card-item'>
        <Card className={classes.card}>
          <CardActionArea>
            <CardMedia
              component='img'
              className={classes.media}
              height='100'
              image={url}
            />
            <input
              type='file'
              accept='image/*'
              style={{ display: 'none' }}
              ref={(comp) => (this.fileElement = comp)}
              onChange={this.handleChange}
            />
          </CardActionArea>
          <CardActions className={classes.actions}>
            {hasFile ? (
              <IconButton onClick={this.handleUpload}>
                <UploadIcon />
              </IconButton>
            ) : null}
            <IconButton onClick={this.handleOpen}>
              <AddIcon />
            </IconButton>
          </CardActions>
        </Card>
      </div>
    );
  }
  handleOpen = () => {
    this.fileElement.click();
  };
  handleChange = () => {
    const file = this.fileElement.files[0];

    const reader = new FileReader();

    reader.onload = (e) => {
      this.setState({ url: e.target.result, hasFile: true });
    };

    reader.readAsDataURL(file);
  };
  handleUpload = () => {
    // somethings...
  };
  reset = () => {
    this.setState({
      url: 'http://placeholder.com',
      hasFile: false,
    });
  };
}

 

기존 코드

const ImageNewCard = ({ classes, handleAdd }) => {
  const fileElement = useRef(null);
  const fileRef = useRef([]);
  const [urls, setUrls] = useState([]);

  const handleOpen = () => {
    fileElement.current.click();
  };

  const imageFileRenderPromise = (file) => {
    return new Promise((r) => {
      const reader = new FileReader();

      reader.onload = (e) => {
        r(e.target.result);
      };

      reader.readAsDataURL(file);
    });
  };

  const handleChange = async (event) => {
    const promise = [];

    Object.values(event.target.files).forEach((file) => {
      if (file) {
        fileRef.current = [...fileRef.current, file];
        promise.push(imageFileRenderPromise(file));
      }
    });

    const result = await Promise.all(promise);
    setUrls([...urls, ...result]);
  };

  const handleUpload = async (index) => {
    // somethings...
  };

  const newCard = (
    <div className='card-item'>
      <Card className={classes.card}>
        <CardActionArea>
          <CardMedia
            component='img'
            className={classes.media}
            height='100'
            image={'http://via.placeholder.com/350x150'}
          />
          <input
            multiple
            type='file'
            accept='image/*'
            style={{ display: 'none' }}
            ref={fileElement}
            onChange={handleChange}
          />
        </CardActionArea>
        <CardActions className={classes.actions}>
          {/* {hasFile && (
            <IconButton onClick={handleUpload}>
              <UploadIcon />
            </IconButton>
          )} */}
          <IconButton onClick={handleOpen}>
            <AddIcon />
          </IconButton>
        </CardActions>
      </Card>
    </div>
  );

  const imageCards = urls.map((url, index) => (
    <div className='card-item'>
      <Card className={classes.card}>
        <CardActionArea>
          <CardMedia
            component='img'
            className={classes.media}
            height='100'
            image={url}
          />
        </CardActionArea>
        <CardActions className={classes.actions}>
          <IconButton
            onClick={() => {
              handleUpload(index);
            }}
          >
            <UploadIcon />
          </IconButton>
        </CardActions>
      </Card>
    </div>
  ));

  return (
    <>
      {imageCards}
      {newCard}
    </>
  );
};

😁 글 작성 후기

사실 이 글을 작성한 주된 의도는 발생한 문제와 해결한 과정에 대한 기록보다는 같은 회사에서 근무하고 있는 "CS팀도 나의 프로덕트의 고객이다."라는 마인드를 다시 한번 상기하고자 하는 목적을 위주로 작성하게 된 글입니다.

또한 제가 개발자로서 느끼고 있는 가장 큰 보람에 대한 경험 공유이기도 합니다.

 

제 리소스가 남을 때 단 몇 시간의 투자로 CS팀의 업무의 시간에 대한 세이브를 시켜줄 수 있도록 하게 해 줍니다.
저는 CS팀과 개발팀은 악어와 악어새 관계라고 생각합니다.

 

 

완벽한 프로덕트를 시장에 내놓은 것이 너무나도 나이스한 상황이지만 현실적으로 작은 오류 몇 개는 의도치 않게 발생할 수 있습니다.
물론 그 상황을 피하고자 QA와 테스트 코드 작성을 강력하게 진행하는 게 바람직한 행동이라고 생각합니다.

 

그러나 앞서 말했듯이 작은 버그들은 당연 유저의 불만으로 인한 CS팀에게 문의가 들어갈 수밖에 없습니다.

실수를 한 것은 개발자지만 그들은 개발자를 대신하여 유저에게 사과를 해주곤 합니다.

솔직히 그럴 때마다 미안한 감정이 앞서고 그러한 문제가 발생하지 않도록 저도 노력하게 됩니다.

저렇게 노력해 주시는 분들에게 개발팀에서 투자가 없는 것은 말이 안 된다고 생각하는데요 간혹 App 유저가 사용하는 프로덕트만 고심하여 새로운 Feature를 개발하는 경우 해당 Feature를 대응하기 위한 CS팀의 백오피스에는 개발 시간을 투자하지 않고 "아 일단 저희가 수동으로..." 아니면 "일단 불편하더라도.."와 같은 말과 함께 신경 쓰지 못하고 넘어가는 케이스가 종종 있습니다.

이렇게 개발팀을 배려해 주시는 마음으로 일해 주시는 CS팀에게 리소스가 남을 때마다 먼저 "불편한 건 없는지" 또는 "업무에 효율을 늘리기 위해 필요한 건 없는지" 에 대해 조사한 후 개선해 주는 개발자가 되어보시는 건 어떨까요?