저희는 메일 서비스를 만들기 위해 RDBMS를 사용합니다.
현재 4차 수정까지 진행됐으며, 아래와 같습니다.
왜 이런 ERD가 나왔는지는 각자 구현 내용을 바탕으로 설명될 것입니다.






const insertQuery = 'INSERT INTO ?? SET ?';
const tableName = 'tbl_mail_template';
const now = mysql.raw('now()');
const mailTemplate = { from, to, subject, text, created_at: now, updated_at: now };
const queryFormat = mysql.format(insertQuery, [tableName, mailTemplate]);
이전까지는 value만 escaping 처리를 수행하였는데 indentifier까지 escaping 처리가 가능하다는 것을 깨달았습니다. 이번 기회에 SQL 문을 직접 문자열로 만들어서 실행시켰을 때 SQL Injection에 취약하다는 것을 인지하게 되었고 덕분에 SQL Injection에 대해 조사하게 되었습니다. 개발자라면 최소한의 보안 지식도 필요하다는 것을 다시 한번 느끼게 되었습니다.
context api에 관련한 이슈였습니다.context api를 사용하는 방향으로 개발을 진행하였고 하나의 context 안에 모든 데이터를 넣고 모든 컴포넌트들에게 데이터를 주게끔 코드를 작성하였었습니다.context내의 데이터 하나만 바뀌더라도 모든 컴포넌트들이 재렌더링되는 현상이 나타나 성능에 있어서 매우 나쁜 코드를 작성한 셈이었습니다.useMemo, React.memo와 같이 memoization을 사용하는 방법과 useReducer를 사용하여 context의 구조를 바꾸는 방법이 있었지만 현업 개발자분께서 context의 구조를 바꾸어보는 방향을 권해주셨기 때문에 context의 구조를 바꾸는 방법을 선택하여 context하나는 state만 내려주고 하나는 dispatch만 내려주어 재렌더링을 방지하도록 리팩토링을 진행하였습니다.import React, { useReducer, useContext } from 'react';
import { writeMailReducer } from './reducer';
import { initialState } from './reducer/initial-state';
const WMstateContext = React.createContext();
const WMdispatchContext = React.createContext();
export const WriteMailContextProvider = ({ children }) => {
const [state, dispatch] = useReducer(writeMailReducer, initialState);
return (
<WMstateContext.Provider value={state}>
<WMdispatchContext.Provider value={dispatch}>{children}</WMdispatchContext.Provider>
</WMstateContext.Provider>
);
};
export const useWriteMailState = () => {
const ctx = useContext(WMstateContext);
if (ctx === undefined) {
throw new Error('useWriteMailState은 WriteMailContextProvider 내에서 사용할 수 있습니다');
}
return ctx;
};
export const useWriteMailDispatch = () => {
const ctx = useContext(WMdispatchContext);
if (ctx === undefined) {
throw new Error('useWriteMailDispatch은 WriteMailContextProvider 내에서 사용할 수 있습니다');
}
return ctx;
};
value속성에 state값을 넣어주고 onChange때마다 setState를 해주다 보니 제목이나 내용을 입력할 때마다 재렌더링이 되는 이슈가 있었습니다.state값들은 모두 context내에서 관리하고 있었습니다.submit버튼을 누르면 제목과 내용 그리고 첨부파일까지 모두 초기화하는 방향으로 구현해나가고 있었기 때문에 제목과 내용에는 value속성을 state로 줄 수밖에 없는 상황이었습니다.value 속성을 state로 주려면 onChange속성 또한 줘야 하는 불가피한 상황이었습니다.flag라는 state를 두어 focus했을 때는 value속성을 주지 않고 제목이나 내용 state를 defaultValue를 주고 focusout(blur)시에는 value속성을 주는 방법으로 개선하여 submit button을 누르면 focusout상태이기 때문에 제목이나 내용의 value값이 context로부터 받아온 state이므로 변경이 가능했고, onChange를 사용하지 않고 onBlur 이벤트가 발생했을 때에만 context의 state값을 바꾸는 dispatch함수를 실행하기 때문에 제목이나 내용을 입력할 때마다 재렌더링 되는 것을 방지할 수 있었습니다.import React, { useState } from 'react';
import * as S from './styled';
import * as WM_S from '../styled';
import { useWriteMailState, useWriteMailDispatch } from '../ContextProvider';
import { UPDATE_SUBJECT } from '../ContextProvider/reducer/action-type';
const InputSubject = () => {
const { subject } = useWriteMailState();
const dispatch = useWriteMailDispatch();
const [flag, setFlag] = useState(true);
const blurHandler = e => {
dispatch({ type: UPDATE_SUBJECT, payload: { subject: e.target.value } });
setFlag(!flag);
};
const focusHandler = _ => {
setFlag(!flag);
};
return (
<WM_S.RowWrapper>
<WM_S.Label>제목</WM_S.Label>
{flag ? (
<S.InputSubject maxLength={50} onFocus={focusHandler} value={subject} />
) : (
<S.InputSubject maxLength={50} onBlur={blurHandler} defaultValue={subject} />
)}
</WM_S.RowWrapper>
);
};
export default InputSubject;
웹 페이지에서 회원가입을 하여 웹 메일 서비스를 이용할 수 있도록 해보았습니다.
먼저 회원 테이블 구조입니다.
// server/src/database/models/user.js
...
const User = sequelize.define(
'User',
{
no: {
type: DataTypes.BIGINT.UNSIGNED,
primaryKey: true,
autoIncrement: true,
},
id: {
type: DataTypes.STRING(255),
allowNull: true,
unique: true,
},
domain_no: {
type: DataTypes.BIGINT.UNSIGNED,
allowNull: false,
defaultValue: 1,
},
name: {
type: DataTypes.STRING(255),
allowNull: false,
},
password: {
type: DataTypes.STRING(255),
allowNull: false,
},
email: {
type: DataTypes.STRING(255),
allowNull: true,
unique: true,
},
sub_email: {
type: DataTypes.STRING(255),
allowNull: false,
unique: true,
},
salt: {
type: DataTypes.STRING,
allowNull: true,
},
},
);
...
지난주 dovecot-sql.conf.ext 설정에서 암호화 방식을 SHA512-CRYPT으로 설정했습니다.
또한 MySQL을 통해 다음과 방식으로 유저를 추가한 적이 있었습니다.
INSERT INTO `mailserver`.`virtual_users`
(`id`, `domain_id`, `password` , `email`)
VALUES
('1', '1', ENCRYPT('password', CONCAT('$6$', SUBSTRING(SHA(RAND()), -16))), 'email1@example.com'),
('2', '1', ENCRYPT('password', CONCAT('$6$', SUBSTRING(SHA(RAND()), -16))), 'email2@example.com');
ENCRYPT("password", CONCAT('$6$',sha(RAND()))) 해당 부분이 MySQL에서 SHA512-CRYPT scheme 방식을 사용하여 비밀번호를 암호화하는 방법입니다.RAND()는 0에서 1 사이의 랜덤 값을 생성하며 sha(str)는 str로부터 160비트의 해시값을 만들어 냅니다.salt 필드를 두어 랜덤으로 생성한 값을 따로 저장하여 로그인 시 salt 값을 사용하여 password를 인증할 수 있습니다!// server/src/libraries/crypto.js
import DB from '../database';
// MySQL의 SUBSTRING, SHA, RAND 함수를 사용하여 salt를 생성합니다.
export const createSalt = async () => {
const [saltQueryresult] = await DB.sequelize.query('SELECT SUBSTRING(SHA(RAND()), -16)', {
raw: true,
type: DB.sequelize.QueryTypes.SELECT,
});
const [saltKey] = Object.keys(saltQueryresult);
const salt = saltQueryresult[saltKey].toString();
return salt;
};
// MySQL의 ENCRYPT 함수를 통해 password, salt를 받아 암호화합니다.
export const encrypt = async (password, salt) => {
const [passQueryResult] = await DB.sequelize.query(
`SELECT ENCRYPT('${password}', CONCAT('$6$', '${salt}'))`,
{
raw: true,
type: DB.sequelize.QueryTypes.SELECT,
},
);
const [passKey] = Object.keys(passQueryResult);
const hashedPassword = passQueryResult[passKey].toString();
return hashedPassword;
};
// server/src/database/models/user.js
...
const convertToUserModel = async instance => {
const { id, password } = instance.dataValues;
const salt = await createSalt();
const hashedPassword = await encrypt(password, salt);
instance.email = `${id}@${DEFAULT_DOMAIN_NAME}`;
instance.password = hashedPassword;
instance.salt = salt;
};
...
// instance의 값을 수정하여 DB에 저장할 수 있습니다.
User.beforeCreate(async instance => {
await convertToUserModel(instance);
});
...
// server/src/v1/users/service.js
...
const register = async ({ id, password, name, sub_email }) => {
const userData = { id, password, name, sub_email };
let newUser;
try {
const [response, created] = await DB.User.findOrCreateById(userData, { transaction });
if (!created) {
throw new ErrorResponse(ERROR_CODE.ID_DUPLICATION);
}
newUser = response.get({ plain: true });
} catch (error) {
throw error;
}
return newUser;
};
...
const registerUser = async (req, res, next) => {
let newUser;
try {
await validation.join(req.body);
newUser = await service.register(req.body);
delete newUser.password;
delete newUser.salt;
} catch (error) {
return next(error);
}
return res.status(status.CREATED).json({ newUser });
};
...
const localLogin = async ({ id, password }) => {
const user = await DB.User.findOneById(id);
if (!user) {
throw new ErrorResponse(ERROR_CODE.INVALID_LOGIN_ID_OR_PASSWORD);
}
const hashedPassword = await encrypt(password, user.salt);
const match = user.password === hashedPassword;
if (!match) {
throw new ErrorResponse(ERROR_CODE.INVALID_LOGIN_ID_OR_PASSWORD);
}
return user;
};
...
salt와 함께 암호화하여 user의 password와 비교합니다.passport.use(
new LocalStrategy(
{
usernameField: 'id',
passwordField: 'password',
},
async (id, password, done) => {
let user;
try {
user = await authService.localLogin({ id, password });
delete user.password;
delete user.salt;
} catch (err) {
return done(err);
}
return done(null, user);
},
),
);
router.post('/login', validateLogin, passport.authenticate('local'), ctrl.login);
이번 주에 페이징을 담당해서 구현했습니다.
메일 리스트 하단부에

이러한 버튼이 있는데요. 버튼을 눌렀을 때 해당 mail data를 가져와 화면에 뿌려줍니다.
또한 이런 버튼들을 만들어주기 위해서 paging 정보가 있어야 했기 때문에 이와 관련된 BE 작업을 진행하였습니다.
페이징 처리를 하기 위해서는 한 페이지에 보여주는 perPageNum이라는 변수가 필요합니다.
또 한 번에 페이지 버튼을 몇 개 보여줄지 정해야 했기 때문에 pageListNum이라는 변수가 필요했습니다.
페이징 처리를 할 때 생각해봐야 할 것이 몇 가지 있었습니다.
1. 첫 번째 index라면 <-(왼쪽 화살표)가 없어야 합니다.
2. 마지막 index라면 ->(오른쪽 화살표)가 없어야 합니다.
3. 첫 번째 마지막 둘 다 아니라면 < > 가 모두 존재해야 합니다.
4. perPageNum에 따라 totalPage가 변합니다.
const DEFAULT_PAGE_LIST_NUM = 10;
const DEFAULT_PER_PAGE_NUM = 100;
const DEFAULT_PAGE_NUM = 1;
/**
* @param {Number} totalCount 메일함에있는 메일
* @param {Object} options
* @param {Number} options.page 현재 페이지
* @param {Number} options.perPageNum 한페이지당 출력할 갯수
* @returns {Object} paging result
*/
const paging = (totalCount, options = {}) => {
const perPageNum = options.perPageNum || DEFAULT_PER_PAGE_NUM;
let page = options.page || DEFAULT_PAGE_NUM;
const totalPage = Math.ceil(totalCount / perPageNum) || 1;
if (totalPage < page) {
page = totalPage;
}
const startPage = Math.floor((page - 1) / DEFAULT_PAGE_LIST_NUM) * DEFAULT_PAGE_LIST_NUM + 1;
let endPage = startPage + DEFAULT_PAGE_LIST_NUM - 1;
endPage = endPage > totalPage ? totalPage : endPage;
return { startPage, endPage, page, perPageNum, totalPage };
}
export default paging;
위 코드는 위 조건들을 만족하는 코드인데요.
현재의 page가 totalPage보단 클 수 없기 때문에 if를 통하여 값을 재설정해줍니다.
또한 endPage도 totalPage보다 클 수 없기 때문에 if를 통하여 값을 재설정 해줍니다.
해당 정보를 client로 보내고 이를 client에서 처리해야 합니다.
const getPageNumberRange = index => {
const start = index * PAGE_LIST_NUM;
return [start + 1, start + PAGE_LIST_NUM];
};
const Paging = ({ paging }) => {
const { page, startPage, totalPage } = paging;
const currentIndex = Math.floor(startPage / PAGE_LIST_NUM);
const lastIndex = Math.floor(totalPage / PAGE_LIST_NUM);
const [index, setIndex] = useState(currentIndex);
const { dispatch } = useContext(AppContext);
const classes = useStyles();
const pagingNumber = [];
const [startNumber, endNumber] = getPageNumberRange(index);
for (let i = startNumber; i <= endNumber && i <= totalPage; i += 1) {
const number = <PageNumber key={i} id={i} color="secondary" onActive={page === i} />;
pagingNumber.push(number);
}
const handleMoveBtnClick = value => {
const newIndex = index + value;
const [newPageNumber] = getPageNumberRange(newIndex);
setIndex(newIndex);
dispatch(handlePageNumberClick(newPageNumber));
};
const handleNumberClick = (e) => {
e.preventDefault();
const { id } = e.target;
if (!id || id === '') {
return;
}
if (Number.isInteger(id)) {
return;
}
dispatch(handlePageNumberClick(Number(id)));
};
return (
<GS.FlexRowWrap onClick={handleNumberClick}>
<Fab
size="small"
color="secondary"
aria-label="add"
className={classes.margin}
onClick={() => handleMoveBtnClick(-1)}
style={{ width: '35px', height: '10px ', display: index === 0 ? 'none' : '' }}>
<ArrowBackIcon />
</Fab>
{[...pagingNumber]}
<Fab
size="small"
color="secondary"
aria-label="add"
className={classes.margin}
onClick={() => handleMoveBtnClick(1)}
style={{ width: '35px', height: '10px ', display: index === lastIndex ? 'none' : '' }}>
<ArrowForwardIcon />
</Fab>
</GS.FlexRowWrap>
);
};
startPage와 totalPage를 PAGE_LIST_NUM로 나눠 current index와 lastIndex를 얻어냅니다.
이후 index를 아래 함수에 넣어 number의 값을 구합니다.
const getPageNumberRange = index => {
const start = index * 10;
return [start + 1, start + 10];
};
이 값을 가지고 for 문을 통해 페이징 숫자들을 만들어 냅니다. for 문을 사용해도 되지만, 가독성 면에서 떨어진다는 지적을 받았습니다. 블로깅 하는 시점에는 아직 반영이 안됐습니다.
버튼을 눌렀을 때, dispatch를 통해서 state를 바꿔야 하는데요. 숫자 하나하나에 이벤트를 달기보다 버블링을 이용한 델리게이션을 통해 구현했습니다.
const handleNumberClick = (e) => {
e.preventDefault();
const { id } = e.target;
if (!id || id === '') {
return;
}
if (Number.isInteger(id)) {
return;
}
dispatch(handlePageNumberClick(Number(id)));
};
때문에 target.id를 통해서 id를 얻어와야 합니다. 이때 버튼이 아닌 부분이 클릭된 것이 아닌지 확인하는 작업이 필요하기 때문에 if 문을 거친 후 통해한 경우에만 dispatch가 발동됩니다.
handlePageNumberClick 함수의 경우 아래와 같습니다. page의 상태를 변경시킵니다.
export const handlePageNumberClick = page => {
return {
type: PAGE_NUMBER_CLICK,
payload: {
page,
},
};
};
page가 변경될 경우 상위 컴포넌트인 MailArea에서 데이터를 가져오도록 했습니다.
( 아직 구현에 집중하고 있기 때문에 useFetch를 추상화하지는 못했습니다. 또한 에러 처리도 다음 주에 진행할 예정입니다. )
const useFetch = ({ category, page }, callback) => {
const [isLoading, setIsLoading] = useState(true);
const fetchInitData = async URL => {
setIsLoading(true);
const { data } = await axios.get(URL);
callback(data);
setIsLoading(false);
};
useEffect(() => {
const URL = `/mail?category=${category}&page=${page}`;
fetchInitData(URL);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, category]);
return isLoading;
};
const MailArea = () => {
const { state, dispatch } = useContext(AppContext);
const { category, page } = state;
const callback = data => dispatch(handleMailsChange({ category, ...data.mails, page }));
const isLoading = useFetch(state, callback);
if (isLoading) {
return <Loading />;
}
...
}
다음으론 server에서 api 요청을 받았을 때 처리를 알아보겠습니다.
GET /mail 엔드 포인트는 페이징뿐만 아니라 메일함(category)이 변경될 때도 동작해야 했습니다.
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 });
};
server는 controller, service, ORM(DAO) 영역으로 레이어 계층을 형성하고 있습니다.
컨트롤러 계층에서는 입력값을 validation 하는 역할을 담당하도록 했습니다.
category나 page 같은 경우 default 값이 존재하기 때문에 입력으로 들어오지 않아도 되기 때문에, 입력이 들어왔을 경우에만 validation을 하게 됩니다.
import { isInt } from 'validator';
import ERROR_CODE from '../exception/error-code';
import ErrorField from '../exception/error-field';
import ErrorResponse from '../exception/error-response';
const { MAX_SAFE_INTEGER } = Number;
const PAGE_NUMBER_RANGE = { min: 1, max: MAX_SAFE_INTEGER };
const CATEGORY_NUMBER_RANGE = { min: 0, max: MAX_SAFE_INTEGER };
const checkQuery = ({ category, page }) => {
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 (errorFields.length > 0) {
throw new ErrorResponse(ERROR_CODE.INVALID_INPUT_VALUE, errorFields);
}
return true;
};
export default checkQuery;
const DEFAULT_MAIL_QUERY_OPTIONS = {
category: 0,
page: 1,
perPageNum: 100,
};
const getMailsByOptions = async (userNo, options = {}) => {
const queryOptions = { ...DEFAULT_MAIL_QUERY_OPTIONS, ...options };
let { category, page, perPageNum } = queryOptions;
category = Number(category);
page = Number(page);
perPageNum = Number(perPageNum);
const query = getQueryByOptions({ userNo, category, perPageNum, page });
const { count: totalCount, rows: mails } = await DB.Mail.findAndCountAllFilteredMail(query);
const pagingOptions = {
page,
perPageNum,
};
const pagingResult = getPaging(totalCount, pagingOptions);
return {
paging: pagingResult,
mails,
};
};
service 계층에서는 유효한 값이 들어왔다는 가정하에 로직이 작성됩니다. (컨트롤러에서 벨리데이션을 했기 때문)
하나의 엔드 포인트에서 category, page를 담당해야 하므로 ORM 쿼리 조차 복잡해지는데요 (추후에 sort search까지 처리해야 합니다)
이를 위해 조금 추상적인 쿼리를 만들게 됐습니다.
Mail.findAndCountAllFilteredMail = ({
userNo,
mailFilter = {},
mailTemplateFilter = {},
options = {},
paging = DEFALT_PAGING,
}) => {
return Mail.findAndCountAll({
distinct: true,
...paging,
where: {
owner: userNo,
...mailFilter,
},
include: [
{
model: sequelize.models.MailTemplate,
where: {
...mailTemplateFilter,
},
include: [
{
model: sequelize.models.Attachment,
},
],
},
],
raw: true,
...options,
});
};
요청에 따른 유동적인 쿼리를 위해 where 절을 외부에서 입력받습니다.
때문에 service 계층에서는 쿼리를 만들 책임이 부여됩니다.
이를 위해서 service 계층에서 아래의 함수를 사용합니다.
const getQueryByOptions = ({ userNo, category, perPageNum, page }) => {
const query = {
userNo,
options: {
raw: false,
},
paging: {
limit: perPageNum,
offset: (page - 1) * perPageNum,
},
mailFilter: {},
};
if (category > 0) {
query.mailFilter.category_no = category;
}
return query;
};
where 절에 default로 들어갈 때, 값이 들어가면 안 되는 경우가 있습니다.
이러한 경우 분기를 통해 해결합니다.