저희는 메일 서비스를 만들기 위해 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
로 들어갈 때, 값이 들어가면 안 되는 경우가 있습니다.
이러한 경우 분기를 통해 해결합니다.