들어가며

4주차 개발진행 상황입니다. 타 도메인에서 다중전송 시 중복수신이 되는 문제를 해결하였으며 메일함 추가, 생성, 수정 그리고 예약 메일 등록을 할 수 있도록 만들었습니다. 또한 서버와의 에러 처리 구조를 만들어 클라이언트에서 적절하게 에러를 표시할 수 있도록 하였습니다! 😃

배형진

타 도메인에서 다중전송 시 중복수신 문제

본문

다중전송을 daitnu.com에서 수신 시 받는 주소의 수만큼 메일이 도착하는 것을 확인할 수 있었습니다. 문제를 해결하기 위해 log를 살펴본 결과 도메인에 따라 다중전송의 방법이 다르다는 것을 확인하였습니다.

현재 postfix에서 hook이 발생하면 shell script를 통해 nodeJS 파일을 실행하여 메일 데이터를 파싱합니다. 그리고 파싱한 데이터는 DB에 저장하고 있습니다.
이때, NAVER 경우는 받는 주소의 수만큼 postfix에서 hook이 발생하여 받는 주소의 수만큼 DB에 데이터를 저장하게 되는 반면, Google의 경우 메일 수신 시 받는 주소의 수와 상관없이 postfix에서 한 번의 hook이 발동하여 한 번만 DB에 데이터를 저장하게 됩니다.

더 자세한 차이를 살펴보기 위해 두 개의 주소로 메일을 전송하였을 때 nodeJS에서 수신되는 메일의 headers를 확인해보았습니다. 다음은 NAVER에서 전달받은 headers 내용의 일부입니다.

## 1번째 수신자 (rooot@daitnu.com)
Map {
  'received' => [ 'from cvsmtppost005.nm.naver.com (cvsmtppost005.nm.naver.com [125.209.224.203]) by daitnu.com (Postfix) with ESMTPS id 1F0EE2BFCC0 for <rooot@daitnu.com>; Sat, 30 Nov 2019 15:03:56 +0900 (KST)',
    'from cvsendbo38.nm ([10.112.235.167]) by cvsmtppost005.nm.naver.com with ESMTP id lPxbzy56TuOzkBxeaqMRnQ for <rooot@daitnu.com>; Sat, 30 Nov 2019 06:03:55 -0000' ],
...
## 2번째 수신자 (dkwk1254@daitnu.com)
Map {
  'received' => [ 'from cvsmtppost025.nm.naver.com (cvsmtppost025.nm.naver.com [125.209.224.213]) by daitnu.com (Postfix) with ESMTPS id 33D5C2C095B for <dkwk1254@daitnu.com>; Sat, 30 Nov 2019 15:03:56 +0900 (KST)',
    'from cvsendbo38.nm ([10.112.235.167]) by cvsmtppost025.nm.naver.com with ESMTP id 8HN+pQVlTc2CghbjBGJLoQ for <dkwk1254@daitnu.com>; Sat, 30 Nov 2019 06:03:55 -0000' ],
...

다음은 Google에서 전달받은 headers 내용의 일부입니다.

Map {
  'received' => [ 'from mail-il1-f181.google.com (mail-il1-f181.google.com [209.85.166.181]) by daitnu.com (Postfix) with ESMTPS id 8690A2BFCC0; Sat, 30 Nov 2019 15:41:44 +0900 (KST)',
    'by mail-il1-f181.google.com with SMTP id u17so28716215ilq.5; Fri, 29 Nov 2019 22:41:44 -0800 (PST)' ],
...

여기서 두 도메인의 차이점은 NAVER의 경우 received 배열에 실제 받는 주소가 포함되어 있고 Google의 경우는 실제 받는 주소가 포함되어 있지 않습니다. 이 차이점을 이용하면 문제를 해결할 수 있다고 생각하였습니다다.

해결방안

received 배열의 첫 번째 값을 정규식을 통해 실제 받는 주소 값을 추출합니다. 그리고 해당하는 하나의 주소만 owner로 설정하여 DB에 저장합니다. 결과적으로 보기에는 tbl_mail_template 테이블에는 중복돼서 데이터가 들어가 있지만, 실제 사용자가 확인하게 되는 tbl_mail은 owner가 서로 다르게 저장됩니다. 만약 정규식으로 주소를 가지고 오지 못했다면 기존의 방식을 그대로 사용합니다.

const addReceiver = (receviers, email) => {
  const [id, domain] = email.trim().split("@");
  if (domain === MY_DOMAIN) {
    receviers.push(id);
  }
}

const getReceivers = ({ headers, to }) => {
  const receivedOfHeader = headers.get(RECEIVED_KEY)[0];
  const realReceiver = EXP_EXTRACT_RECEIVER.exec(receivedOfHeader);
  const receivers = [];
  if (realReceiver) {
    addReceiver(receivers, realReceiver[0].slice(1, -1));
  } else {
    to.split(",").forEach(email => addReceiver(receivers, email));
  }
  return receivers;
};

추가로 할말

Google은 수신자가 여러 명이어도 한 번의 hook을 발생하고 NAVER는 수신자 수만큼 hook을 발생합니다. 일반적으로 봤을 때는 Google이 더 효율적인 방법으로 보내고 있어 보입니다. 하지만 여러 번의 hook을 발생하여 보내는 이유가 분명히 있을 것으로 생각하였습니다.

어디까지나 제 생각이지만 Google에서는 제공하고 있지 않지만 NAVER에서는 제공하고 있는 수신확인 기능 때문이 아닐까라는 생각이 들었습니다. 현재 네이버에서는 다음과 같은 코드를 메일에 포함하고 있습니다.

<table style="display:none">
  <tr>
    <td>
      <img
        src="https://mail.naver.com/readReceipt/notify/?img=FleX1z%2B5WrcYaqgwFqICMxk4M63SKxuqFxtmaA3SM6UXM6UZMqtwFAgqFqioax%2BgMX%2B0MogmFLl5WLl5pNiC740ThoRZWredtzGTW4%2Bc%2Bru5DH0P.gif"
        border="0"
      />
    </td>
  </tr>
</table>

메일을 읽는 순간 해당 URL로 접속이 되므로 외부 도메인일지라도 사용자가 읽은 것을 NAVER의 server로 알릴 수 있게 됩니다. 이를 위해 수신자 수만큼 hook을 발생시키는 것이 아닐까 조심스럽게 추측해봅니다.

성재호

Advanced Content Filter


Advanced Content Filter는 기존의 Simple Content Filter와는 다르게 로컬 도메인에서 로컬 도메인으로 보낼 때에도 필터링이 가능한 방법입니다.

해당 방법은 Simple Content Filter와는 다르게 조금 더 복잡하지만 더 좋은 퍼포먼스를 보여준다고 말하고 있고, 필터되지 않은 메일은 localhost:10025에서 받은 후에 필터링 후 localhost:10026으로 보내주는 방법입니다.

이 아이디어에 착안하여 필터링하는 과정에서 쉘 프로그램을 실행시키도록 할 예정이었습니다.

위에서 말한 메일을 10025포트에서 10026포트로 전달해주며 어떠한 로직을 실행시킬 프로그램이 필요하였던 찰나에 이미 구현되어있는 프로그램을 발견하였고 해당 프로그램을 적용하여 로컬에서 로컬로 메일을 보내는 것에도 훅스를 발동시킬 수 있었습니다

$ vim /etc/postfix/main.cf

# ...
# ...
content_filter = scan:127.0.0.1:10025
receive_override_options = no_address_mappings

$ vim /etc/postfix/master.cf

# ...
# ==========================================================================
# service type  private unpriv  chroot  wakeup  maxproc command + args
#               (yes)   (yes)   (no)    (never) (100)
# ==========================================================================
smtp      inet  n       -       y       -       -       smtpd
scan unix - - n - 10 smtp
  -o smtp_send_xforward_command=yes
  -o disable_mime_output_conversion=yes
127.0.0.1:10026 inet n - n - 10 smtpd
  -o content_filter=
  -o receive_override_options=no_unknown_recipient_checks,no_header_body_checks,no_milters
  -o smtpd_authorized_xforward_hosts=127.0.0.0/8
# ...

위의 두 파일은 해당 사이트에서 예시로 적어준 코드를 간단하게 적용해본 이해를 돕기 위해 예시로 작성해보았습니다.

메일함


메일 사용자 생성시 기본 메일함 지정

$ vim /etc/dovecot/conf.d/10-mail.conf

# ...
namespace inbox {
  # Namespace type: private, shared or public
  # private은 유저의 개인 메일을 위한 타입입니다
  # shared는 말 그대로 다른 유저들도 접근 가능한 메일함이라고 보시면 됩니다
  # public은 시스템 관리자가 관리하는 공유 메일함이라고 보시면 됩니다
  type = private

  # 보통 seperator는 '/'(slash)를 쓴다고 합니다
  # 또한 모든 유저에게 같은 separator를 주어야 한다고 합니다
  separator = /

  # 모든 namespace마다 다른 prefix를 가지고 있어야 합니다
  # 비워주세요
  # 다른 예시로는 "Public/"을 들고 있더군요
  # 하지만 위와 같이 설정하면 아웃룩 같은 경우는 보낸메일함이 Public이라는
  # 폴더 내부에 들어가므로 Blank로 둡니다
  prefix = ""

  location =

  inbox = yes

  hidden = no

  # imap 연결시 list라는 명령어를 치면 현재 namespace 휘하에 있는
  # 메일함들을 보여주는 것을 허용해줍니다
  list = yes

  # prefix가 비어있다면 yes로 적어주어야 한다고 합니다
  subscriptions = yes

  # See 15-mailboxes.conf for definitions of special mailboxes.
  # 리눅스의 localize를 한글로 설정하였다면 괜찮지만 다른 경우 영문을 권장합니다.
  mailbox 휴지통 {
    auto = subscribe
    special_use = \Trash
  }
  mailbox 내게쓴메일함 {
    auto = subscribe
  }
  mailbox 보낸메일함 {
    auto = subscribe # autocreate and autosubscribe the Sent mailbox
    special_use = \Sent
  }
  mailbox 임시보관함 {
    auto = subscribe
    special_use = \Drafts
  }
  mailbox 스팸메일함 {
    auto = subscribe
    special_use = \Junk
  }
  mailbox 중요메일함 {
    auto = subscribe
    special_use = \Flagged
  }
}

auto에 들어가는 값에는 no/create/subscribe 세가지가 있는데

create는 유저를 생성할 때 자동으로 만들어 준다는 이야기입니다

subscribe는 유저가 생성된 후에도 10-mail.conf파일 내에 autosubscribe로 메일함을 추가해 준다면 미리 생성된 유저도 추후에 만들어지는 유저도 모두 해당 메일함을 가질 수 있습니다

no는 둘 다 설정을 하지 않겠다라는 것이라고 합니다

special_use와 같은 경우는 RFC 6154에 따라 만들 수 있다고 합니다

자세한 설명은 맨 아래에 적혀질 Reference를 참고해 주시고 종류는 아래와 같습니다

\All \Archive \Drafts \Flagged \Junk \Sent \Trash

이렇게 설정한 후에 POP3가 아닌 IMAP을 통해 접속해보면 위에 적은 이름과 동일하게 메일함들이

존재하는 것을 확인할 수 있습니다.(한글의 경우 깨져서 보일 수 있습니다)

$ openssl s_client -connect imap.your-domain.com:993
# ...

a login user1 password1234
# 로그인이 되었다면 아래의 명령어를 입력하여 메일함들이 존재하는지 확인해 줍니다

a list "" "*"

메일함 추가/수정/삭제 in NodeJS

NodeJS에서 메일함을 다루는 데에 도움을 주는 모듈이 있습니다

[node-imap](https://github.com/mscdex/node-imap)이라는 모듈인데요 해당 모듈을 통해 저희 Daitnu 메일 서비스는 메일함을 추가/수정/삭제를 진행하고 있습니다.(추후에 데이터베이스 방식으로 변경할 수 있습니다)

Referenced


Postfix After-Queue Content Filter

MailboxSettings - Dovecot Wiki

RFC 6154 - IMAP LIST Extension for Special-Use Mailboxes

How to talk to IMAP server in Shell via OpenSSL

이정환

저는 이번에 예약 메일 보내기를 위한 예약 메일 등록을 만들어 보았습니다.

메일을 보낼 때 예약 정보를 기록해야합니다. 그러기 위해서 기존 메일 테이블에 reservation_time 필드를 새로 만들었습니다.

그래서 reservation_time이 null일 경우에는 이미 보낸 메일이며 reservation_time이 null이 아니라 Date 값이 저장되어 있을 경우 예약된 메일임을 구분할 수 있도록 합니다.

Backend

const Mail = sequelize.define(
  'Mail',
  {
    no: {
      type: DataTypes.BIGINT.UNSIGNED,
      primaryKey: true,
      autoIncrement: true,
    },
    owner: {
      type: DataTypes.BIGINT.UNSIGNED,
      allowNull: true,
    },
    category_no: {
      type: DataTypes.BIGINT.UNSIGNED,
      allowNull: true,
    },
    mail_template_id: {
      type: DataTypes.BIGINT.UNSIGNED,
      allowNull: false,
    },
    is_important: {
      type: DataTypes.BOOLEAN,
      allowNull: false,
      defaultValue: false,
    },
    is_read: {
      type: DataTypes.BOOLEAN,
      allowNull: false,
      defaultValue: false,
    },
    reservation_time: {
      type: DataTypes.DATE,
      allowNull: true,
    },
    message_id: {
      type: DataTypes.TEXT,
      allowNull: true,
    },
  }
);

메일 보내기 API에 요청시 reservationTime이 body에 포함되어 있을 경우 분기를 하여 처리하도록 하였습니다.

// server/src/v1/mail/controller.js


// ...
const write = async (req, res, next) => {

// ...

  try {
      if (!reservationTime) {
      await service.sendMail(mailContents, req.user);
      } else {
        dateValidator.validateDate(reservationTime);
        const date = strToDate(reservationTime);
        await service.saveReservationMail(mailContents, req.user, date);
      }
  } catch (error) {
      return next(error);
  }

  return res.status(STATUS.CREATED).json({ mail: mailContents });
};

// ...
  • dateValidator.validateDate를 통해 reservationTime이 유효한 날짜 형식인지 현재 시간의 이후 시간인지 확인합니다. 만약 유효한 날짜 또는 예약 시간이 현재 시간 이후가 아닐 경우 에러를 throw를 하도록 하였습니다.
  • reservationTime이 유효할 경우 Date 형식으로 만들어서 service 계층에 메일 정보, 유저 정보, 예약 날짜 정보를 함께 넘겨줍니다.

서비스 계층의 saveReservationMail 함수입니다. 간단하게 기존 메일 보내기에 사용되는 saveMail을 호출하도록 하였습니다.

// server/src/v1/mail/service.js
const saveReservationMail = async (mailContents, user, reservationTime) => {
  await DB.sequelize.transaction(
    async transaction => await saveMail(mailContents, transaction, user.no, reservationTime),
  );
};

saveMail은 메일을 보낼때 사용하고 있기 때문에 reservationTime의 default 값을 null로 설정해두었습니다.

// server/src/v1/mail/service.js
const saveMail = async (mailContents, transaction, userNo, reservationTime = null) => {
  const mailTemplateResult = await DB.MailTemplate.create(
    { ...mailContents, to: mailContents.to.join(','), createdAt: reservationTime },
    { transaction },
  );
  const mailTemplate = mailTemplateResult.get({ plain: true });

  await saveAttachments(mailContents.attachments, mailTemplate.no, transaction);
  const userCategory = await DB.Category.findOneByUserNoAndName(userNo, SENT_MAILBOX_NAME);
  await DB.Mail.create(
    {
      owner: userNo,
      mail_template_id: mailTemplate.no,
      category_no: userCategory.no,
      reservation_time: reservationTime,
    },
    { transaction },
  );
};
  • MailTemplate을 create 할때 createdAt에 reservationTime을 넣어주었습니다.

  • reservationTime이 null값일 경우 sequelize에서 자동으로 생성된 날짜를 만들어 줍니다.

  • createdAt에 reservationTime을 넣어준 이유는 메일 조회시 createdAt으로 손쉽게 예약 메일과 보낸 메일을 정렬하여 보여줄 수 있기 때문입니다.

  • Mail을 create 할때 reservation_time에 마찬가지로 reservationTime을 넣어주도록 하였습니다.

  • Mail에 reservation_time 정보가 있으면 예약 메일을 보낼 때 현재 시간 이전의 예약 메일을 조회할 때 sequelize를 사용하여 다음과 같은 간단한 쿼리를 만들 수 있습니다!

      Mail.findOldDateMail = date => {
        return Mail.findAll({
          where: {
            reservation_time: {
              [Op.lte]: date,
            },
          },
          include: [
            {
              model: sequelize.models.MailTemplate,
              include: [sequelize.models.Attachment],
            },
          ],
        });
      };

Frontend

기존 WriteMail 컴포넌트의 자식 컴포넌트인 SubmitButton 컴포넌트안에서 보내기 예약 버튼을 누를 경우 예약 날짜를 선택할 수 있도록 ReservationTimePicker을 보여주도록 하였습니다.

// web/components/WriteMail/SubmitButton

const SubmitButton = () => {
  const { receivers, files, subject, text, date } = useStateForWM();
  const dispatch = useDispatchForWM();

  // ...

  const [modalOpen, setModalOpen] = useState(false);

  return (
    <>
      <WM_S.RowWrapper>
        // ...
      </WM_S.RowWrapper>
      {sendMessage}
      <ReservationTimePicker open={modalOpen} handleModalClose={handleModalClose} />
    </>
  );
};

ReservationTimePicker 컴포넌트에서는 날짜를 선택하여 확인을 눌렀을 경우 Context API를 이용하여 date를 선택된 날짜로 수정하도록 합니다.

const ReservationTimePicker = ({ open, handleModalClose }) => {
  const classes = useStyles();
  const [date, setDate] = useState(moment());
  const [hour, setHour] = useState(0);
  const [minute, setMinute] = useState(0);
  const [error, setError] = useState('');

  const dispatch = useDispatchForWM();

  const handleSubmit = e => {
    e.preventDefault();

    const reservationDate = moment(date);
    reservationDate.set({
      hour,
      minute,
    });

    if (!validator.isAfterDate(reservationDate)) {
      setError(ERROR_CANNOT_RESERVATION);
      return;
    }

    handleModalClose();
    dispatch({
      type: UPDATE_DATE,
      payload: { date: reservationDate },
    });
  };

  return (
    <div className={classes.root}>
      <Modal
        aria-labelledby="transition-modal-title"
        aria-describedby="transition-modal-description"
        className={classes.modal}
        open={open}
        closeAfterTransition
        BackdropComponent={Backdrop}
        BackdropProps={{
          timeout: 500,
        }}>
        <Fade in={open}>
          <div className={classes.paper}>
            <S.InputForm>
              <S.Title>날짜 및 시간 선택</S.Title>
              <S.RowContainer>
                <MuiPickersUtilsProvider utils={MomentUtils} locale="ko">
                  <DatePicker
                    autoOk
                    orientation="portrait"
                    variant="static"
                    openTo="date"
                    disablePast={true}
                    value={date}
                    onChange={setDate}
                  />
                </MuiPickersUtilsProvider>
                <S.ColumnContainer>
                  <S.Text>{transformDateForReservationTimePicker(date)}</S.Text>
                  <S.RowContainer>
                    <Select
                      value={hour}
                      onChange={({ target: { value } }) => setHour(value)}
                      displayEmpty
                      className={classes.selectBox}>
                      {hours}
                    </Select>
                    <Select
                      value={minute}
                      onChange={({ target: { value } }) => setMinute(value)}
                      displayEmpty
                      className={classes.selectBox}>
                      {minutes}
                    </Select>
                  </S.RowContainer>
                  <S.ErrorText>{error}</S.ErrorText>
                </S.ColumnContainer>
              </S.RowContainer>
              <S.ButtonContainer>
                <S.WhiteButton className="submit-btn max-width" onClick={handleModalClose}>
                  취소
                </S.WhiteButton>
                <S.Button className="submit-btn max-width" onClick={handleSubmit}>
                  확인
                </S.Button>
              </S.ButtonContainer>
            </S.InputForm>
          </div>
        </Fade>
      </Modal>
    </div>
  );
};
  • handleSubmit 함수내에서 선택된 날짜가 현재 시간 보다 이후인가를 확인하고 Context에서 제공한 dispatch를 사용하여 선택된 날짜를 date에 넣어줍니다.

마지막으로 선택된 날짜를 Context에서 가져와 보내기 버튼을 누를 때 같이 보내주도록 하였습니다

const SubmitButton = () => {
  const { receivers, files, subject, text, date } = useStateForWM();
  const [sendMessage, setSendMessage] = useState(null);

  // ...

  const handleClick = () => {
    setSendMessage(<Message icon={LOADING} msg="메세지 보내는 중..." />);

    const formData = new FormData();
    receivers.forEach(r => {
      formData.append('to', r);
    });
    formData.append('subject', subject);
    formData.append('text', text);
    files.forEach(f => {
      formData.append('attachments', f);
    });

    if (date) {
      if (!validator.isAfterDate(date)) {
        setSendMessage(<Message icon={FAIL} msg={ERROR_CANNOT_RESERVATION} />);
        return;
      }
      formData.append('reservationTime', transformDateToReserve(date));
    }

    axios
      .post('/mail', formData, {
        headers: {
          'Content-Type': 'multipart/form-data',
        },
      })
      .then(() => {
        setSendMessage(<Message icon={SUCCESS} msg="메일 전송 완료" />);
        dispatch({ type: UPDATE_INIT });
      })
      .catch(err => {
        setSendMessage(<Message icon={FAIL} msg="메세지 전송 실패" />);
      });
  };


  return (
    <>
      <WM_S.RowWrapper>
        <div></div>
        <S.RowContainer>
          <ButtonGroup variant="outlined" color="default" ref={anchorRef}>
            <Button onClick={handleClick}>보내기</Button>
            <Button color="default" size="small" onClick={handleToggle}>
              <ArrowDropDownIcon />
            </Button>
          </ButtonGroup>

          // ...

        </S.RowContainer>
      </WM_S.RowWrapper>
      {sendMessage}
      <ReservationTimePicker open={modalOpen} handleModalClose={handleModalClose} />
    </>
  );
};

홍종화

이번 주에는 에러 처리를 구현했습니다.

서버에서의 에러 처리와 프론트에서의 에러 처리가 있습니다.

일단 서버에서는 express를 사용했기 때문에 next를 이용하여 error handler에 에러를 모으는 전략을 사용했습니다.

또한 API 서버이기 때문에 500에러를 제외한 400에러에서 사용자가 쉽게 에러를 알 수 있도록 하기 위해 많은 고민을 했습니다.

서버에서 500에러는 서버조차 해결할 방법을 모르는 것이기 때문에, 상세한 에러 내역을 사용자에게 그대로 보여주는 것은 많은 위험이 따릅니다. 때문에 500에러의 경우는 불친절할 수밖에 없습니다.

서버에서 에러 발생 시 일관된 에러를 반환하는 것은 중요합니다.
따라서 Error 발생 시 사용할 타입을 만드는데 집중했습니다.

일단 에러가 발생하면 status 코드와, message가 필수로 존재해야 합니다. 또한 서버에서 어떤 에러인지 식별하기 위한 code 프로퍼티를 추가적으로 필요했습니다.
같은 404가 발생하더라도 어떠한 곳에서 404가 발생했는지, private을 보장하기 위해 404를 사용한 것인지 식별하기 위해서 입니다.

따라서 ErrorCode의 구조는

class ErrorCode {
  constructor({ status, message, code }) {
    this.status = status;
    this.message = message;
    this.code = code;
  }

  /**
   * @param {Number} status
   * @param {String} message
   * @param {String} code
   */
  static createErrorCode(status, message, code) {
    return new ErrorCode({ status, message, code });
  }
}

다만 ErrorCode 타입을 반환하기보다 미리 만들어눈 에러들을 담은 객체를 반환합니다.

const $ = ErrorCode.createErrorCode;

const ERROR_CODE = {
  PAGE_NOT_FOUND: $(404, 'PAGE NOT FOUND', 'COMMON001'),
}

export default ERROR_CODE

또한 400에러에서 어떤 field가 어떤 value를 넣어서 어떤 reason에 의해 실패했는지 알리고 싶었습니다. 또한 하나의 에러가 걸리면 리턴하는 것이 아닌, 입력된 모든 값에 대해 검증을 하여, 잘못된 값을 한 번에 알리고 싶었습니다.

class ErrorField {
  /**
   * @param {String} field filed type
   * @param {*} value
   * @param {String} reason
   */
  constructor(field, value = '', reason) {
    this.field = field;
    this.value = field.substr(0, 2) === 'pw' ? 'secret' : value;
    this.reason = reason;
  }
}

export default ErrorField;

위와 같이 ErrorField를 만들어서 위의 조건을 충족 시킬 수 있었습니다.

그리고 ErrorCode와 ErrorField를 담을 수 있는 객체를 만듭니다.

class ErrorResponse {
  /**
   * @param {ErrorCode} errorCode instance
   * @param {ErrorField[]} fieldErrors
   */
  constructor(errorCode, fieldErrors = []) {
    this.errorCode = errorCode;
    this.fieldErrors = Array.isArray(fieldErrors) ? fieldErrors : [fieldErrors];
  }
}

export default ErrorResponse;

에러가 발생했을 때, 우리는 항상 ErrorResponse를 응답하게 됩니다.

이를 사용하는 방법은 아래와 같습니다.


const checkQuery = ({ category, page, sort }) => {
  const errorFields = [];

  if (category && !isInt(category, CATEGORY_NUMBER_RANGE)) {
    const errorField = new ErrorField('category', category, '유효하지 않은 값입니다.');
    errorFields.push(errorField);
  }

  if (page && !isInt(page, PAGE_NUMBER_RANGE)) {
    const errorField = new ErrorField('page', page, '유효하지 않은 값입니다.');
    errorFields.push(errorField);
  }

  if (sort && !SORTING_CRITERIA[sort]) {
    const errorField = new ErrorField('sort', sort, '유효하지 않은 정렬기준 입니다.');
    errorFields.push(errorField);
  }

  if (errorFields.length > 0) {
    throw new ErrorResponse(ERROR_CODE.INVALID_INPUT_VALUE, errorFields);
  }

  return true;
};

위에서 서술했듯이 하나의 필드에서 걸리면 반환하는 것이 아닌, 전체 필드를 검사하기 위해 에러 필드들을 배열에 담고, 배열에 담긴 에러가 있다면 에러로 인식하여 throw를 던지게 됩니다.

이러한 방법을 취하는 것은, 사용자의 편의와 서버 트래픽을 관리하기 위함입니다. 단순 연산의 경우 빠르게 처리할 수 있지만, 네트워크 지연은 상대적으로 길기 때문에, 최소의 요청으로 많은 것을 알리는 것이 좋다고 생각됐습니다.

const list = async (req, res, next) => {
  const userNo = req.user.no;
  const { query } = req;
  let mails;

  try {
    checkQuery(query);
    mails = await service.getMailsByOptions(userNo, query);
  } catch (error) {
    return next(error);
  }

  return res.json(mails);
};

검증 함수를 호출하는 곳에 try - catch로 감싸고, catch에 error를 받아 next로 넘겨줍니다. 이때 error는 이미 ResponseError이거나 500에러이거나 둘 중 하나 일 것입니다.

service 계층에서는 error를 통해 추가적인 작업을 할 필요가 없다면, try - catch를 하지 않습니다. 이때 db network 에러가 발생한다면 error 객체가 throw 될 것입니다.

즉 서버에서 발생할 수 있는 에러는 2가지입니다.

  • Error 객체
  • ErrorResponse 객체
  • ErrorResponse 객체는 400대 에러이며 Error 객체는 500대 에러입니다.

그렇게 때문에 error handler에서

app.use((err, req, res, next) => {
  if (err instanceof ErrorResponse) {
    const status = Number(err.errorCode.status);
    return res.status(status).json(err);
  }

  return res.status(500).json(INTERNAL_SERVER_ERROR_EXCEPTION);
});

깔끔하게 에러를 처리할 수 있게 됩니다.

여기에서 500에러를 단순히 반환하기만 한다면, 우리는 오류를 확인할 수 없겠죠.. 그래서 logger를 사용하게 됩니다.

app.use((err, req, res, next) => {
  if (err instanceof ErrorResponse) {
    const status = Number(err.errorCode.status);
    return res.status(status).json(err);
  }

  log.error(err.stack);

  return res.status(500).json(INTERNAL_SERVER_ERROR_EXCEPTION);
});

로거는 winston과 morgan을 사용했는데요

winston.js

import winston from 'winston';
import moment from 'moment';
import 'winston-daily-rotate-file';
import path from 'path';

moment.locale('ko', {
  weekdays: ['일요일', '월요일', '화요일', '수요일', '목요일', '금요일', '토요일'],
  weekdaysShort: ['일', '월', '화', '수', '목', '금', '토'],
  longDateFormat: {
    LLLL: 'YYYY-MM-DD HH:MM:SS(ddd)',
    LL: 'HH:MM:SS',
  },
});

const error = winston.createLogger({
  level: 'error',
  transports: [
    new winston.transports.DailyRotateFile({
      filename: path.join(__dirname, '../../../log/error/log'),
      zippedArchive: true,
      format: winston.format.printf(
        info => `${moment().format('LL')} [${info.level.toUpperCase()}] - ${info.message}`,
      ),
    }),

    new winston.transports.Console({
      format: winston.format.printf(
        info => `${moment().format('LL')} [${info.level.toUpperCase()}] - ${info.message}`,
      ),
    }),
  ],
});

const debug = winston.createLogger({
  level: 'debug',
  transports: [
    new winston.transports.DailyRotateFile({
      filename: path.join(__dirname, '../../../log/debug/log'),
      zippedArchive: true,
      format: winston.format.printf(info => `${moment().format('LL')}  ${info.message}`),
    }),

    new winston.transports.Console({
      format: winston.format.printf(
        info => `${moment().format('LL')} [${info.level.toUpperCase()}] - ${info.message}`,
      ),
    }),
  ],
});

export default {
  error(message) {
    error.error(message);
  },
  debug: {
    write(message) {
      debug.info(message);
    },
  },
};

app.js

morgan.format(
  'combined',
  ':method :url :status :res[content-length] - :response-time ms :remote-addr - :remote-user :referrer :user-agent',
);

if (NODE_ENV === 'production') {
  app.use(morgan('combined', { stream: log.debug }));
}

사용자가 들어와서 찍히는 로그와, error로 그를 나누기 위해 위와 같이 작성했습니다.

프론트 에러 처리

이제는 프론트 에러 처리를 생각해보겠습니다.
프론트 에러도 서버와 마찬가지로 2가지 종류를 생각해봤습니다.

  • 서버에서 ErrorResponse를 반환
  • Network 에러

저희는 비동기 요청을 axios를 사용해서 진행했는데요.
직접적으로 axios를 사용하지 않고 request.js라는 util을 만들어 사용했습니다.

request.js

import axios from 'axios';
import { errorParser } from './error-parser';
import HTTPResponse from './http-response';

const execute = async fn => {
  let response;

  try {
    const { data } = await fn();
    response = new HTTPResponse(false, data);
  } catch (err) {
    const error = errorParser(err);
    response = new HTTPResponse(true, error);
  }
  return response;
};

const BASE_URL = 'http://localhost/';
axios.defaults.baseURL = BASE_URL;
axios.defaults.withCredentials = true;
const defaultOptions = {
  headers: {
    'Content-Type': 'application/json',
    Accept: 'application/json; charset=utf-8',
  },
};

export default {
  async get(url, options = {}) {
    const fn = () => axios.get(url, { ...defaultOptions, ...options });
    const response = await execute(fn);
    return response;
  },

  async post(url, body, options) {
    const fn = () => axios.post(url, body, { ...defaultOptions, ...options });
    const response = await execute(fn);
    return response;
  },

  async put(url, body, options) {
    const fn = () => axios.put(url, body, { ...defaultOptions, ...options });
    const response = await execute(fn);
    return response;
  },

  async delete(url, options) {
    const fn = () => axios.delete(url, { ...defaultOptions, ...options });
    const response = await execute(fn);
    return response;
  },

  async patch(url, body, options) {
    const fn = () => axios.patch(url, body, { ...defaultOptions, ...options });
    const response = await execute(fn);
    return response;
  },
};

비동기 요청 시 발생할 수 있는 에러를 한곳에서 처리할 수 있도록 execute 함수를 사용하여 진행했습니다. axios instance를 사용하지 않은 이유는 options를 동적으로 주기 위해 설정해야 하는데, 생각보다 불편해서 axios 자체에 하나씩 할당했습니다. 시간이 되신다면 instance로 커스텀 하는 것도 추천드립니다.

프론트에서는 HTTPResponse 타입을 만들어 에러를 관리합니다.

class HTTPResponse {
  constructor(isError, data) {
    this.isError = isError;
    this.data = data;
  }
}
export default HTTPResponse;

굉장히 심플한 타입입니다.

또한 에러가 발생했을 대, 어떤 에러인지 parse 하는 역할을 수행하는 함수가 필요했습니다.

const isContainedErrorCode = error => {
  const { response } = error;
  return response && response.data && response.data.errorCode;
};

const errorParser = error => {
  if (!isContainedErrorCode(error)) {
    return { status: 500, message: error.message };
  }

  const { errorCode, fieldErrors } = error.response.data;

  const { status, message } = errorCode;
  if (status !== 400) {
    return { status, message };
  }

  const errorMessage = fieldErrors.reduce((prev, next) => {
    const line = `${next.field} : ${next.reason}\n`;
    return prev + line;
  }, '');

  return { status: 400, message: errorMessage };
};

export { errorParser };

기본적으로 network 에러는 ErrorResponse 타입이 아니기 때문에 객체가 포함되어 있는지 확인하여 구별할 수 있었습니다.

이 에러 처리를 사용한 사례를 하나 보여드리겠습니다.
데이터를 가져오는 fetch를 커스텀 훅을 이용해 만든 useFetch입니다.

import { useState, useEffect } from 'react';
import request from './request';

const useFetch = (callback, URL) => {
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    const fetchInitData = async () => {
      setIsLoading(true);
      const { isError, data } = await request.get(URL);
      if (isError) {
        callback(data);
        return;
      }
      callback(null, data);
      setIsLoading(false);
    };

    fetchInitData(URL);
  }, [callback, URL]);
  return isLoading;
};

export default useFetch;

에러가 발생했을 때 행동할 것은 callback을 통해 전될되게됩니다. 즉 에러에 대한 책임을 useFetch를 사용하는 컴포넌트에서 지게 됩니다.
callback 이외에도 return을 통해서 에러를 반환할 수 있습니다.
이에 대해서는 각자 어떤 선택을 하냐의 따라 달라질 것입니다.