5. 개발노트 5주차

Daitnu·2019년 12월 10일
0

들어가며

5주 차 개발 진행 상황입니다. 저희는 MailTemplate event 처리를 개선하였으며 지난주 사용한 Advanced Content Filter에서 발생한 버그를 수정하였습니다. 공용으로 사용할 수 있는 스낵바를 만들었으며 예약 전송 API 작성하였습니다. 또한 에디터를 사용하여 메일을 작성할 수 있으며 첨부파일 업로드 시 object storage에 저장하였습니다. 🚀🚀🚀

배형진

기존 메일 리스트에서 각 메일의 정보를 보여주는 MailTemplate에 각각 event가 등록되어 있는데 이를 event delegation을 통해 개선하였습니다.

메일마다 등록되는 event가 늘어남에 따라 메일 리스트 전체에 등록되는 event 수가 상당히 늘어나고 있다고 느꼈습니다. 따라서 event delegation을 하면 메일리스트에 등록되는 event를 많이 줄일 수 있다고 판단하여 적용해 보았습니다.

MailTmeplate 컴포넌트에서 click event를 받는 element들의 id 값을 action-index 순으로 지정하여 MailArea에서 어떤 event를 처리할지 정할 수 있도록 하였습니다.

<S.DeleteButton id={`delete-${index}`}>

그런데 이때 문제가 되는 것은 material-ui의 icon을 클릭하였을 때 지정한 id 값을 찾을 수 없다는 것이었습니다. 디버깅해보니 icon이 아닌 그 부모 노드에 id가 지정되는 것을 확인할 수 있었습니다. 따라서 다음과 같은 조건문을 추가하였습니다.

if (typeof target.className === 'object') {
    target = target.parentNode;
}

그 후 target에서 id 값을 destructing하고 '-'으로 split 하여 action에 맞게 해당 index의 메일을 event 처리합니다. 여기서 handleAction은 action을 key 값으로 하여 event 처리 함수들을 매핑하는 객체입니다.

const [action, index] = id.split('-');
const mail = mails[index];
if (Object.values(ACTION).includes(action)) {
    handleAction[action]({ mail, dispatch, query, wastebasketNo, openSnackbar, index });
}

그런데, React에서 event delegation에 대해 찾아보다가 다음과 같은 글을 발견하였습니다.
https://blog.cloudboost.io/why-react-discourage-event-delegation-2b5fe3f52bea
윗글에서는 React에서 단반향 데이터 흐름이 핵심 아이디어인데 user define event delegation은 event 별로 데이터가 부모에게 전달되기 때문에 event bubbling은 React의 철학을 해친다는 내용이 적혀있었습니다.
또한, 응용 프로그램이 커지면 양방향 데이터 흐름을 유지하기 어렵고 이벤트 중인 데이터를 캡처할 수 없어 event bubbling에서 오류를 디버깅하기 어렵다는 내용을 보았습니다.

이 글을 읽고 나서 성능상 문제 되지 않던 것을 쓸데없이 개선한 것이 아니냐는 생각이 들었는데 담당 리뷰어님께 질문을 드려보니 자신도 React에서 event delegation을 잘 사용하고 있다고 말씀해주셨습니다. 그래서 조금 안심이 되었고 앞으로도 event delegation을 잘 활용해 나갈 생각입니다.

성재호

Advanced Content Filter (Bug fix)

저번 주에 적용한 Advanced Content Filter에는 치명적인 에러가 있었습니다.
약 100kb가 넘어가게 된다면 파일을 받아오지 못하는 에러였습니다.
해당 에러의 해결책을 찾아보다가 unlimit -s라는 명령어를 사용하는 방법이 있었지만 아무리 늘려봐도 무용지물이었습니다.
해당 에러의 문제는 shell script에서 string 문자열 max-length 문제였습니다.
쉘 스크립트는 string을 128kb까지만을 다룰 수 있었고 그에 따라 약 100kb가 넘는 파일과 함께 보내다 보니 쉘 스크립트가 Hooks 파일에 문자열을 넘겨주지 못하는 것이었습니다.
이에 따라 받아오는 메일을 파일로 저장하여 해당 파일의 경로를 인자로 넘겨주는 방법을 생각하게 되었고, 그 결과 높은 용량의 파일도 함께 받아올 수 있었습니다
해당 필터의 주요 예시 코드는 다음과 같습니다.
(Advanced Content Filter는 이전 블로그에서도 말씀드렸지만 해당 사이트를 참고하였습니다)

f = open("/var/spool/filter/mailFile", 'w')
f.write(data)
f.close()
os.system(SHELL_PATH + "/var/spool/filter/mailFile ")
# 위의 코드와 같이 리눅스상에서 명령어를 작성할 때에는 맨 뒤에 공백을 넣어주세요(os.system 부분)

팀원들과 함께 사용할(재사용 가능한) Snackbar


다음으로 팀원들과 함께 FE에서 사용하기 위한 Snackbar(알림창)를 구현하였는데요, material ui에서 가져와 사용하였습니다.
처음에는 각각의 컴포넌트 내에 Snackbar를 넣어 사용하였지만 이렇게 된다면 하나의 화면에서 여러 개의 Snackbar 컴포넌트를 넣게 되는 것이므로 비효율적이라 느꼈고,
또한 body에서 Snackbar를 open 시켜주고 body를 다른 컴포넌트로 바꾸어버리면 Snackbar는 빛을 보지 못하고 빠르게 사라져버리게 됩니다.
마치 동작하지 않은 것처럼 말이죠.
그리하여 Snackbar를 페이지에 하나만 두게 되었고, 해당 페이지의 전역 context에 Snackbar를 위한 state를 넣어주게 되었습니다.
코드는 다음과 같습니다

// MessageSnackbar.js

/**
 * top - center에 등장하는 snackbar
 * @param {Object} snackbar - snackbar를 위한 { state, setState } Object
 * @param {Boolean} snackbar.snackbarOpen
 * @param {String} snackbar.snackbarVariant - 'error' || 'success' || 'info'
 * @param {String} snackbar.snackbarContent - snackbar에 넣을 text
 * @param {Function} snackbar.snackbarClose - 스낵바를 받아줄 close 함수를 넣어주세요
 * @param {Number} snackbar.autoHideDuration - 자동 닫힘 시간 (단위 - ms) default: 5000
 */
const MessageSnackbar = ({
  snackbarOpen,
  snackbarVariant,
  snackbarContent,
  snackbarClose,
  autoHideDuration = 5000,
}) => {
  return (
    <Snackbar
      anchorOrigin={{
        vertical: 'top',
        horizontal: 'center',
      }}
      open={snackbarOpen}
      autoHideDuration={autoHideDuration}
      onClose={snackbarClose}>
      <MessageSnackbarContentWrapper
        onClose={snackbarClose}
        variant={snackbarVariant}
        message={snackbarContent}
      />
    </Snackbar>
  );
};

export const snackbarInitState = {
  snackbarOpen: false,
  snackbarVariant: 'error',
  snackbarContent: '',
  snackbarClose: null,
};

export const SNACKBAR_VARIANT = {
  ERROR: 'error',
  SUCCESS: 'success',
  INFO: 'info',
};

export const getSnackbarState = (variant, contentText) => {
  return {
    snackbarOpen: true,
    snackbarVariant: variant,
    snackbarContent: contentText,
  };
};

export default MessageSnackbar;
// .../context/reducer.js

const SET_SNACKBAR_STATE = 'SET_SNACKBAR_STATE';
// ...

export const initialState = {
  // ...
  snackbarOpen: false,
  snackbarVariant: 'error',
  snackbarContent: '',
  snackbarClose: null,
};

export const handleSnackbarState = payload => {
  return {
    type: SET_SNACKBAR_STATE,
    payload,
  };
};

export const reducer = (state = initialState, action) => {
  const { type, payload } = action;

  switch (type) {
    case SET_SNACKBAR_STATE:
      return { ...state, ...payload };
    // ...
    default:
      return { ...state };
  }
};
// .../context/index.js

// ...
const AppStateContext = createContext();
const AppDispatchContext = createContext();

const AppProvider = props => {
  const [state, dispatch] = useReducer(reducer, initialState);

  return (
    <AppStateContext.Provider value={{ state }}>
      <AppDispatchContext.Provider value={{ dispatch }}>
        {props.children}
      </AppDispatchContext.Provider>
    </AppStateContext.Provider>
  );
};

export { AppProvider, AppStateContext, AppDispatchContext };
// .../pages/index.js

// ...
const messageSnackbarProps = {
  ...snackbarState,
  snackbarClose: () => dispatch(handleSnackbarState({ snackbarOpen: false })),
};

const indexPage = (
  <GS.FlexWrap>
    <Header brand={'Daitnu'} />
    <GS.Content>
      <MessageSnackbar {...messageSnackbarProps} />
      <Aside />
      {state.view}
    </GS.Content>
    <Footer />
  </GS.FlexWrap>
);
return user ? indexPage : <Loading full={true} />;
// ...

여기에서 스스로에게는 아쉬운 점이 있는 데요.
Snackbar를 사용하기 위해서는 코드를 다음과 같은 방식으로 사용해야 합니다.

pageDispatch(
  handleSnackbarState(
    getSnackbarState(SNACKBAR_VARIANT.INFO, SNACKBAR_MSG.WAITING.SENDING)
  )
);

변수에 넣어 사용하면 되지만 조금 더 깔끔하게 사용하는 방법은 없었을까 하는 아쉬움이 남습니다.

이정환

이번에는 등록된 예약 메일을 보낼 수 있도록 예약 메일 API를 만들어 보겠습니다.

예약 매일 보내기 API를 만들어 15분마다 해당 API에 요청을 보내어 예약 메일을 전송할 것입니다.

15분마다 API를 요청하면 DB로부터 요청된 시간 전에 보내야 할 메일을 모두 불러와 전송하면 됩니다. 예를 들면 7시 15분에 예약 매일 보내기 API를 호출하면 DB에서 7시 15분 이전에 메일을 모두 불러와 전송하면 됩니다.

우선 sequelize 쿼리를 통해 date 날짜 이전의 reservation_time을 가진 데이터를 불러오는 함수를 만들었습니다.

// server/src/database/mail.js

// ...

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

// ...

다음은 예약된 메일을 받아와 전송 후 dovecot(imap) 프로토콜을 사용하여 메일 서버에 전송한 메일을 저장하는 코드를 작성했습니다.

// server/src/v1/admin/mail/service.js

// ...
const getFileNameAndBufferFromAttachment = async ({ url, name }) => {
  const stream = getStream(url);

  const buffer = await new Promise(resolve => {
    stream.on('data', data => {
      resolve(data);
    });
  });

  return { buffer, originalname: name };
};

const sendResrvationMail = async mail => {
  const { owner, MailTemplate, reservation_time } = mail;
  const { from, to, subject, text, Attachments } = MailTemplate;
  const mailboxName = SENT_MAILBOX_NAME;

  const user = await DB.User.findByPk(owner, { raw: true });
  user.password = aesDecrypt(user.imap_password);

  const transporter = nodemailer.createTransport(U.getTransport(user));
  const promises = Attachments.map(attachment => getFileNameAndBufferFromAttachment(attachment));
  const attachments = await Promise.all(promises);

  const mailContents = U.getSingleMailData({ from, to, subject, text, attachments });
  mailContents.date = reservation_time;

  const { messageId } = await transporter.sendMail(mailContents);
  const msg = makeMimeMessage({ messageId, mailContents, date: reservation_time });
  saveToMailbox({ user, msg, mailboxName });

  mail.reservation_time = null;
  await mail.save();

  return mail;
};

const handleReservationMails = async () => {
  const date = new Date();
  date.setMinutes(date.getMinutes() + ALLOWED_TIME);

  const mails = await DB.Mail.findAllPastReservationMailByDate(date);
  const promises = [];
  for (const mail of mails) {
    promises.push(sendResrvationMail(mail));
  }

  const successMails = await Promise.all(promises);

  return successMails;
};

// ...
  • 각 메일을 비동기적으로 처리하기 위한 sendResrvationMail 함수를 만들었습니다.
  • getFileNameAndBufferFromAttachment 함수를 통해 object storage에서 메일에 첨부된 파일을 불러와 attachments 배열에 저장합니다.
  • 또한 메일을 보낼 때 인증을 받기 위하여 owner를 통해 유저 정보를 가져와 plain password로 변환하는데 이는 dovecot SASL에서 plain password를 암호화하여 다시 인증 하기 때문입니다.
  • 만든 sendResrvationMail 함수를 수행 후 promises 배열에 넣어서 모든 async 함수의 결과를 받아오기 위하여 Promise.all을 사용하였습니다.

다음 코드는 해당 만든 서비스 계층을 사용하여 보낸 예약 메일 정보를 받아와 응답하는 controller입니다.


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

const sendReservationMails = async (req, res, next) => {
  let mails;

  try {
    mails = await service.handleReservationMails();
  } catch (error) {
    return next(error);
  }

  return res.json({ mails });
};

// ...

그리고 메일 예약 전송 API에서 미들웨어로 관리자인지 아닌지를 확인하도록 하였습니다.

// server/src/middlewares/auth.js
import ErrorResponse from '../libraries/exception/error-response';
import ErrorCode from '../libraries/exception/error-code';

const { ADMIN_KEY } = process.env;

// ...

const isAdmin = (req, res, next) => {
  if (!req.body.key || req.body.key !== ADMIN_KEY) {
    return next(new ErrorResponse(ErrorCode.PRIVATE_PATH));
  }

  return next();
};

export { isAuth, isAdmin };


// server/src/v1/index.js

const router = Router();

// ...
router.use('/admin', isAdmin, admin);
// ...

다음은 네이버 cloud function에서 매번 15분마다 실행하여 예약 메일 전송 API 요청을 하는 코드입니다.

require("dotenv").config();

const axios = require("axios");

const { API_SERVER_URL, ADMIN_KEY } = process.env;

axios.defaults.baseURL = API_SERVER_URL;

const main = () => {
  return new Promise((resolve, reject) => {
    axios
      .post("/admin/mail", {
        key: ADMIN_KEY
      })
      .then(res => {
        resolve(res.data);
      })
      .catch(error => {
        reject(error);
      });
  });
};

exports.main = main;
  • 요청을 할 때 인증을 위한 key 정보를 같이 보내는 것을 볼 수 있습니다.
  • 네이버 cloud function의 비동기 함수 처리 결과를 받아오기 위해서는 Promise를 통해 감싸주어야 합니다.

참고

async/await 를 사용하기 전에 promise를 이해하기

Cloud Functions 사용 가이드

홍종화

이번 주는 Editor 연동과 파일 업로드 / 미리 보기를 구현했습니다.
메일을 사용할 때 간편하게 사용할 수 있는 에디터가 무엇일까? 고민했고, 최종적으로 Toast Editor를 사용하게 됐습니다.
토스트 에디터는 작년에 한번 사용해본 경험도 있고, 레퍼런스도 잘되어 있는 편이라 쉽게 연동할 수 있었습니다. 이 에디터를 사용한 이유는 Markdown으로 쓸 수도 있고 위지위그로 사용할 수 있다는 장점이 있어 사용하게 됐습니다.

현재 우리 프로젝트는 Nextjs를 사용하기 때문에, Import를 그냥 하게 되면 에러가 발생하게 됩니다. 그래서 next/dynamic을 사용하여 ssr을 false로 지정해줘야 합니다.

const InputBody = dynamic(import('./InputBody'), { ssr: false });
const toolbarItems = [
  'heading',
  'bold',
  'italic',
  'strike',
  'divider',
  'hr',
  'quote',
  'divider',
  'ul',
  'ol',
  'task',
  'indent',
  'outdent',
  'divider',
  'table',
  'link',
  'divider',
  'code',
  'codeblock',
];
let editor;

const InputBody = () => {
  const dispatch = useDispatchForWM();

  useEffect(() => {
    editor = new Editor({
      initialValue: '',
      el: document.getElementById('editor-section'),
      previewStyle: 'vertical',
      height: '450px',
      initialEditType: 'markdown',
      useCommandShortcut: true,
      exts: ['scrollSync', 'colorSyntax', 'mark', 'table'],
      toolbarItems,
    });

    const handleEditorBlur = () => {
      dispatch({
        type: UPDATE_TEXT,
        payload: {
          html: editor.getHtml(),
          text: editor.getValue(),
        },
      });
    };

    editor.on('blur', handleEditorBlur);
  }, [dispatch]);

필요한 options들을 넣고 사용하는 것 외에는 특별한 사항이 없었습니다.

그다음으로는 파일 업로드를 담당했는데요. 파일은 Object storage(S3)에 저장하여 사용하고 있습니다. 스토리지의 경우 URL 접근이 PUBLIC 하거나 PRIVATE 한 상황 2가지밖에 없는데, 권한이 있는 유저에게만 접근이 가능하게 하려고, Clinent -> Server -> Storage 가 될 수밖에 없습니다. 이렇게 하게 되면 Storage의 URL도 노출되지 않고 Private을 유지할 수 있습니다. 사실 반환되는 URL 자체도 엄청 길어서 경우의 수가 많아 쉽게 추론하기 힘들 텐데요. 그래도 조금의 가능성조차 없애고자 이렇게 구현하게 됐습니다.

우리 팀은 ncloud를 사용하고 있습니다. ncloud의 스토리지는 S3과 호환이 되어 기존 S3 오픈소스들을 사용할 수 있다는 장점이 있습니다.

/* eslint-disable object-curly-newline */
import uuidv4 from 'uuid';
import AWS from 'aws-sdk';
import dotenv from 'dotenv';

dotenv.config();

const MB = 1000 ** 2;
const FILE_LIMIT_SIZE = 10 * MB;

const {
  STORAGE_END_POINT,
  STORAGE_REGION,
  STORAGE_ACCESS_KEY,
  STORAGE_SECRET_KEY,
  STORAGE_BUCKET,
} = process.env;

const endpoint = new AWS.Endpoint(STORAGE_END_POINT);
const region = STORAGE_REGION;

AWS.config.update({
  accessKeyId: STORAGE_ACCESS_KEY,
  secretAccessKey: STORAGE_SECRET_KEY,
});

const BASE_PATH = 'mail/';
const S3 = new AWS.S3({
  endpoint,
  region,
});

const options = {
  partSize: FILE_LIMIT_SIZE,
};

const rename = originalname => {
  const splitedFileName = originalname.split('.');
  const now = Date.now();
  const fileName = now + uuidv4().replace(/-/g, '');
  const extension = splitedFileName[splitedFileName.length - 1];
  const newFileName = `${fileName}.${extension}`;
  return newFileName;
};

const multipartUpload = ({ buffer, originalname }) => {
  const newName = rename(originalname);
  const promise = S3.upload(
    {
      Bucket: STORAGE_BUCKET,
      Key: BASE_PATH + newName,
      Body: buffer,
    },
    options,
  ).promise();
  return promise;
};

const download = path => {
  const file = S3.getObject({
    Bucket: STORAGE_BUCKET,
    Key: path,
  }).promise();

  return file;
};

const getStream = path => {
  const stream = S3.getObject({
    Bucket: STORAGE_BUCKET,
    Key: path,
  }).createReadStream();
  return stream;
};

export { download, multipartUpload, getStream };

server service


const getAttachment = async ({ attachmentNo, email }) => {
  const mailTemplateAndAttachment = await DB.Attachment.findAttachmentAndMailTemplateByPk(
    attachmentNo,
  );

  if (!mailTemplateAndAttachment) {
    throw new ErrorResponse(ERROR_CODE.PAGE_NOT_FOUND);
  }

  const to = mailTemplateAndAttachment['MailTemplate.to'];
  const from = mailTemplateAndAttachment['MailTemplate.from'];
  const tos = to.split(',').map(user => user.trim());
  const accessibleUsers = [from, ...tos];

  if (!accessibleUsers.some(user => user === email)) {
    throw new ErrorResponse(ERROR_CODE.PRIVATE_PATH);
  }

  const file = await download(mailTemplateAndAttachment.url);
  file.mimetype = mailTemplateAndAttachment.type;
  return file;
};

첨부 파일을 접근하기 위해서, request 한 유저가 권한이 있는지 검사를 해야 합니다. 메일을 보냈거나, 받는 사람이어야만 첨부 파일의 권한이 있으므로, to와 from을 이용하여 접근 가능한 유저 accessibleUsers를 만들고, 이에 해당하는 유저인지 확인하게 됩니다. 만약 some(하나라도 true이면 true를 반환)에 true를 반환한다면 접근 가능한 유저이기 때문에 file을 반환하게 됩니다.

profile
부스트캠프 4기 멤버십 6조 개발노트

0개의 댓글