프로젝트(Github) : https://github.com/dygreen/nocap-news
사용 기술 : React + Redux(RTK) + Styled-components
주소 : https://nocap-news.vercel.app/
거의 1년 정도 실무 경험을 해보니, 그 전에 진행했던 프로젝트들의 부족함이 많이 보이기 시작했다.
완벽한 코드는 없으니 (더군다나 내가 작성한 코드이기 때문에..!) 지속적으로 리팩토링이 필요하다고 생각한다.
다양한 프로젝트들 중에서, 취준 기간에 가장 공들였던 프로젝트이기도 하고
일단 보여지는 화면부터가 수정이 간절히 필요했던 뉴스앱 서비스 No Cap News부터 손보기로 했다.
이번 글에서는 어떤 부분을 리팩토링하였고, 리팩토링 하는 과정에서 배운점들을 기록해보고자 한다.
이전에 구현된 프로젝트 화면
목차
- 폴더 구조 변경하기
- body width 100%로 변경하기
- Route pathname 에 따라 TopBar 다르게 보여주기
- 메뉴 모달창으로 변경 및 Route 정리
- Redux-persist 사용
- 이미지 비율 16:9 로 세팅하기
- styled-component 의 다양한 기능 활용하기
- 처음 화면 진입 시, 기사 데이터가 화면에 보이지 않는 문제 해결 (feat. Redux Toolkit)
폴더 구조 변경하기
- 기존 폴더 구조
: 프로젝트 페이지와 페이지에 들어가는 컴포넌트만을 기준으로 분리하였다.
좀 더 세분화된 분류가 필요할 것 같아서 폴더 구조부터 변경하기로 했다. - 수정한 폴더 구조
: assets 폴더에는 공통으로 사용하는 함수(ex. localStorage, scroll to top..)를 넣어놓은 js 파일을 넣었다.
layout 폴더에는 Header 같은 공통된 레이아웃 컴포넌트를 넣었다.
pages 폴더에서는 메인/상세/마이 페이지를 구분하는 폴더를 만들어 각각에 해당되는 컴포넌트를 집어넣었다.
이전보다 세분화하여 분류하였기 때문에, 폴더 구조를 쉽게 파악할 수 있는 것 같다.
body width 100%로 변경하기
기존 프로젝트에 들어가보면 아래 이미지처럼 보인다.
body 안에 컨텐츠들의 width를 360px 고정값으로 설정했기 때문인데,
이렇게 설정한 이유는 단순히 사람들이 가장 많이 사용하는 모바일 화면 해상도를 따랐기 때문이다..ㅎ
하지만 실무에서 모바일 화면을 제작할 때 보통 width를 100%로 설정하고 작업하기 때문에 수정이 너무나도 필요했다.
현재는 프로젝트 내 모든 컨텐츠들의 width를 100%로 잡고 작업을 진행하고 있다..!
Route pathname에 따라 TopBar 다르게 보여주기
기존에는 <TopBarMain>
컴포넌트와 <TopBarDetail>
컴포넌트를 분리하여
메인 페이지와 상세 페이지의 TopBar UI를 다르게 보여주는 방식을 사용하였는데
하나의 컴포넌트에서 위 두개의 컴포넌트를 관리하는 것이 더 편리할 것 같았다.
따라서 <Header>
컴포넌트를 만들어, 컴포넌트 내부에서 pathname으로 메인/상세 페이지를 구분하여 TopBar를 보여주도록 수정했다.
import {useLocation} from "react-router-dom";
import styled from "styled-components";
import StateBar from "./TopBar/StateBar";
import TopBarMain from "./TopBar/TopBarMain";
import TopBarDetail from "./TopBar/TopBarDetail";
const HeaderWrap = styled.div`
width: 100%;
background: #fff;
`;
const Header = () => {
const location = useLocation();
return (
<HeaderWrap>
<StateBar/>
{/* pathname으로 메인/상세 탑 바 구분 */}
{
location.pathname === '/'
? <TopBarMain/>
: location.pathname.startsWith('/detail')
? <TopBarDetail/>
: null
}
</HeaderWrap>
);
}
export default Header
메뉴 모달창으로 변경 및 Route 정리
메뉴는 메인 페이지 상단 hamburger 아이콘을 클릭하면 등장한다.
기존에는 메뉴를 Route 페이지로 관리하였는데, 굳이 페이지로 관리할 필요 없이 모달창으로 관리하는 것이 좋을 것 같아 수정하기로 했다.
해당 부분을 수정하면서 <App> 내부 Route들도 정리하였다.
기존 코드
const App = () => {
return (
<div className="App">
<StateBar/>
<Suspense fallback={ <p className='loading'>Loading.. please wait for a moment!</p> }>
<Routes>
<Route path="/" element={<MainNews/>} />
<Route path="/m" element={<TopBarMenu/>}>
<Route path="menu" element={<Menu/>} />
<Route path="my-news" element={<MyNews/>} />
<Route path="my-comment" element={<MyComment/>} />
</Route>
<Route path="/detail" element={<TopBarDetail/>}>
<Route path=":id" element={<DetailNews/>} />
<Route path=":id/comment" element={<CommentAll/>} />
</Route>
<Route path="*" element={ <p className='loading'>Page Not Found.</p> } />
</Routes>
</Suspense>
</div>
);
}
export default App;
수정한 코드
const App = () => {
return (
<div className="App">
<Suspense fallback={ <p className='loading'>Loading.. please wait for a moment!</p> }>
<Routes>
<Route path="/" element={<Home/>} />
{/* 즐겨찾기 */}
<Route path="my-news" element={<MyNews/>} />
{/* 내가 남긴 댓글 */}
<Route path="my-comment" element={<MyComment/>} />
<Route path="/detail">
<Route path=":id" element={<DetailNews/>} />
<Route path=":id/comment" element={<CommentAll/>} />
</Route>
<Route path="*" element={ <p className='loading'>Page Not Found.</p> } />
</Routes>
</Suspense>
</div>
);
}
export default App;
- <StateBar>는 <Header> 컴포넌트에서 관리하는 것으로 수정
- /m/menu Route를 없애고, /my-news 와 /my-comment로 수정
메뉴 모달창으로 관리
const TopBarMain = () => {
const dispatch = useDispatch();
const menuFlag = useSelector(state => state.menu.menuFlag);
return (
<>
<TopBar/>
{/* 메뉴 아이콘 클릭시 메뉴 페이지로 이동 */}
{
!menuFlag
? <MenuIcon
src={process.env.PUBLIC_URL + '/image/menu_icon.png'}
onClick={() => { dispatch(toggleMenu(menuFlag)) }}
/>
: <MenuIcon
src={process.env.PUBLIC_URL + '/image/arrow_back.png'}
onClick={() => { dispatch(toggleMenu(menuFlag)) }}
/>
}
{/* Start : 메뉴 모달 */}
{
menuFlag
? <Menu/>
: null
}
{/* End : 메뉴 모달 */}
</>
);
}
export default TopBarMain;
Redux-persist 사용
최근 회사에서 프로젝트에 적용해본 Redux-persist를 사용하여 리팩토링하면 좋을 것 같았다.
필요했던 부분
- 메인 페이지 '카테고리 탭'
: 처음에는 사용자가 선택한 카테고리 탭을 localStorage에 저장해서,
메인 페이지에 다시 돌아왔을 때 기존 카테고리를 유지해 놓는 기능에 사용하면 어떨까 생각했다.
그러나 프로젝트 내부에서 RTK(Redux-toolkit)을 사용하고 있었기 때문에 RTK+Redux-persist를 이용하기로 했다. - 상세 페이지 '뉴스 데이터'
: 상세 페이지는 메인 페이지에서 뉴스를 선택하면, 선택한 뉴스 데이터를 상세 페이지에서 보여주는 식으로 동작했다.
선택한 뉴스 데이터는 RTK store에서 관리되고 있었는데, 새로고침을 하면 데이터들이 날아가 빈 화면이 표시되는 문제가 발생했다.
이 문제를 해결하기 위해서 Redux-persist를 사용하면 좋을 것 같았다.
Redux-persist 세팅하기
/* redux toolkit : state & 변경 함수 보관
- 메뉴, 카테고리 탭
- news
- 댓글
- 즐겨찾기
*/
import {combineReducers, configureStore, createSlice, getDefaultMiddleware} from '@reduxjs/toolkit';
import storage from 'redux-persist/lib/storage';
import {persistReducer} from 'redux-persist';
// menu: 메인 페이지 메뉴 모달창 컨트롤 + 카테고리 탭
const menu = createSlice({
name : 'menu',
initialState : {
menuFlag: false,
category: ''
},
reducers : {
toggleMenu(state, action) {
state.menuFlag = !action.payload
},
settingCategory(state, action) {
state.category = action.payload
}
}
})
// news: 뉴스 데이터 (ajax요청)
const news = createSlice({
name : 'news',
initialState : {
loading : 'first',
data : [],
},
reducers : {
newsData(state, action){ // news 데이터 셋팅
if (state.loading === 'first'){ /* 두번째 if문 실행하기 전 데이터 값이 있어야 하므로 첫번째 if문 실행 */
state.data = action.payload;
state.loading = 'second'
}
// detail page를 위한 id값 셋팅
if (state.loading === 'second'){
action.payload.map((a,val) => action.payload[val].source.id = val);
state.data = action.payload;
}
}
},
});
// comment: 댓글 데이터 추가
const comment = createSlice({
name : 'comment',
initialState : [
{ id: 0, user: 'Grace', date: '2022-03-19', content: 'consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco' },
{ id: 1, user: 'Liam', date: '2022-05-22', content: 'ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident' },
{ id: 2, user: 'Alicia', date: '2022-06-17', content: 'architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos' }
],
reducers : {
addContent(state, action){ // 댓글: 공백/중복 확인, 추가
let copy = [...state];
let same = copy.findIndex(a => a.content === action.payload.content); // content가 같으면 해당 index을 남김
if ( action.payload.content.trim() === '' ){ // 공백 검사
alert('Please enter your details.');
} else if ( same >= 0 ){ // 중복인 경우 : 중복 알림
alert('Your comment has already been registered.');
} else { // 중복이 아닌 경우 : 댓글 추가
copy.unshift(action.payload);
return copy
}
},
blockContent(state, action){ // 댓글: 신고/차단
let copy = [...state];
let block = copy.filter(a => a.content !== action.payload.content); // 신고/차단 외의 댓글만 남음
return block
},
},
});
// bookmark: 즐겨찾기 데이터
const bookmark = createSlice({
name : 'bookmark',
initialState : [
{
date : 'Mon May 23 2022',
list : [
{ title : 'Kansas Vote Galvanizes Democrats to Campaign on Abortion Rights', published : '2022-05-23T09:12:54Z' },
{ title : 'In 4 Swing States, G.O.P. Election Deniers Could Oversee Voting', published : '2022-05-23T08:54:33Z' },
],
},
{
date : 'Sun April 10 2022',
list : [
{ title : 'When Home Is a Ferry Ship: An Influx From Ukraine Strains', published : '2022-04-23T09:12:54Z' },
],
},
{
date : 'Fri Jan 07 2022',
list : [
{ title : 'A Cynical Ploy by the Democratic Party', published : '2022-01-02T08:26:06Z' },
{ title : 'Maybe What Happened in Kansas', published : '2022-01-07T09:12:54Z' },
{ title : 'The Republican Party Is the Anti-Democracy Party', published : '2022-01-08T08:26:06Z' },
],
},
],
reducers : {
bookmarking(state, action){ // 즐겨찾기한 아이템 추가
let found = state.findIndex(a => a.date === action.payload.date);
if( found >= 0 ){ // 추가한 날짜가 겹치면 해당 날짜에 데이터 추가
state[found].list.unshift(action.payload.list[found]);
} else { // 겹치지 않으면 날짜+데이터 모두 추가
state.unshift(action.payload);
return state
}
},
removeContent(state, action){ // 아이템 삭제
let remove = state[action.payload.i].list.filter(a => a.published !== action.payload.published); // list 데이터 삭제
state[action.payload.i].list = remove; // 삭제한 list 업데이트
// list 데이터가 없으면 해당 object 삭제
if(state[action.payload.i].list.length == 0){
state.splice(action.payload.i, 1);
}
return state
},
},
});
const reducers = combineReducers({
menu : menu.reducer,
news : news.reducer,
comment : comment.reducer,
bookmark : bookmark.reducer,
})
const persistConfig = {
key: 'root',
storage,
whitelist: ['menu', 'news', 'comment', 'bookmark']
}
export const store = configureStore({
reducer: persistReducer(persistConfig, reducers),
// A non-serializable value was detected in an action, in the path: `type` 오류 해결
middleware: getDefaultMiddleware({
serializableCheck: false,
}),
})
export let { toggleMenu , settingCategory} = menu.actions;
export let { newsData } = news.actions;
export let { addContent, blockContent } = comment.actions;
export let { bookmarking, removeContent } = bookmark.actions;
→ RTK에 미들웨어를 추가한 이유는...
Redux는 값을 주고 받을 때 object 형태로 값을 전달해야 직렬화(=string 형태로 변환)할 수 있는데, 변환이 불가능한 값을 전달했다는 에러가 발생했기 때문이다.
( A non-serializable value was detected in an action, in the path: `type` )
이 에러는 미들웨어 설정을 통해서도 해결을 할 수 있다하여 미들웨어를 추가하게 되었다.
이미지 비율 16:9로 세팅하기
기존에 이미지를 보여줄 때는 특정 px 값으로 height를 주어 고정시켰는데
화면의 비율에 따라 이미지를 16:9를 유지하는 것이 반응형으로 동작하기 때문에 코드를 수정하기로 했다.
export const ImgWrapper = styled.div`
position: relative;
width: 100%;
padding-bottom: 56.25%;
> img {
position: absolute;
width: 100%;
height: 100%;
}
`;
<ImgWrapper>
<img src={
news[i].image === null
? process.env.PUBLIC_URL + '/image/default_img.png'
: news[i].image
} alt={news[i].title}/>
</ImgWrapper>
→ width는 100%, height는 0으로 설정하여 화면에서 보여지지 않게 한 후에 padding 값을 조절하여
어느 기기에서도 width, height 둘 다 100%로 보여지게 하는 방식이다.
( padding-bottom:%를 사용하면 웹 성능 최적화에 도움이 된다고 한다. )
화면 비율에 따라 padding 값을 다르게 주면 된다.
- 21:9 일경우 = 0.42857140.4285714285714286%
- 16:9 일경우 = 0.5625%
- 4:3 일경우 = 0.75%
styled-components의 다양한 기능 활용하기
기존에는 styled-components의 기능들을 속속들이 알지 못하여 기본적인 기능들만 사용했었는데
최근에 다시 해당 패키지를 공부하면서 배운 핵심 개념들을 적용해보고 있다.
props를 통해 스타일 조정
<MyContentsTitle mynews={false}>{comment[i].content}</MyContentsTitle>
<MyContentsTitle mynews={true}>{bookmark[i].list[num].title}</MyContentsTitle>
export const MyContentsTitle = styled.div`
width: 100%;
font-weight: ${props => props.mynews ? 700 : 400};
font-size: 14px;
line-height: 17px;
margin-bottom: 8px;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
`;
→ MyContentsTitle 이라는 styled-component에 props로 mynews를 전달하여
mynews의 true/false 여부에 따라 스타일을 다르게 주는 방식을 사용해보았다.
const CategoryTab = () => {
const dispatch = useDispatch();
const category = useSelector(state => state.menu.category);
return (
<TabContainer>
<TabBox>
{
categories.map(tabs =>
<TabItem
key={tabs.name}
selectedTab={category === tabs.name}
onClick={() => { dispatch(settingCategory(tabs.name)) }}
>
{tabs.text}
</TabItem>
)
}
</TabBox>
</TabContainer>
);
}
const TabItem = styled.div`
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 5px 10px 6px;
font-weight: 400;
font-size: 14px;
color: #8C8C8C;
cursor: pointer;
${props =>
props.selectedTab &&
css`
background-color: #D7352A;
border-radius: 10px 10px 0px 0px;
font-weight: 700;
color: #fff;
`}
`;
→ TabItem이라는 styled-component에 selectedTab를 props로 전달하여
selectedTab props 조건에 맞는 경우에는 스타일을 다르게 주는 방식을 구현했다.
처음 화면 진입 시, 기사 데이터가 화면에 보이지 않는 문제 해결
프로젝트를 배포하고 처음으로 페이지에 진입했을 때,
기사 데이터가 화면에 보이지 않고 로딩중 메시지만 보이는 문제가 있었다.
dev mode에서는 제대로 동작하였는데 배포한 화면에서는 그렇지 않아서 원인을 찾기 위해 며칠을 헤맸다.
- 배포된 페이지 내에서 코드 한 줄 한 줄 디버깅도 해보고 (feat. chrome source)
- api가 제대로 요청되는지 확인하고
- 기사 데이터 뿌리는 JSX에 렌더링 조건을 추가하기도 해보았는데 해결되지 않았다..
결국 해결한 방법은 기사 데이터를 받아서 Redux에 넘기는 로직을 수정한 것이었다.
기존 코드
// 뉴스 데이터 불러오기
useEffect(() => {
const fetchData = async () => {
setLoading(true); // 기사를 받아오는 중
const option = {
method: "GET",
url: "https://gnews.io/api/v4/top-headlines",
params: {
topic: category,
country: 'us',
token: ''
}
}
try {
const res = await axios(option)
// redux로 결과 전달
const JsonData = res.data.articles;
dispatch(newsData(JsonData));
dispatch(newsIdSet(JsonData));
setLoading(false); // 받아오기 완료
}
catch (err){
console.warn(err);
}
}
fetchData();
},[category]);
// news: 뉴스 데이터 (ajax요청)
const news = createSlice({
name : 'news',
initialState : {
loading : 'first',
data : [],
},
reducers : {
newsData(state, action){ // news 데이터 셋팅
if (state.loading === 'first'){ /* 두번째 reducer를 실행하기 전 데이터 값이 있어야 하므로 if문 실행 */
state.data = action.payload;
state.loading = 'second'
}
},
newsIdSet(state, action){ // detail page를 위한 id값 셋팅
if (state.loading === 'second'){
action.payload.map((a,val) => action.payload[val].source.id = val);
state.data = action.payload;
}
},
},
});
→ 기존에는 api 요청으로 받아온 데이터를 newData, newIdSet 이라는 reducer 두 군데에 보내었다.
이렇게 짠 이유는 내가 사용한 api 사이트는 기사마다 고유의 id 값을 보내주지 않아서 직접 세팅하기 위해
newData reducer에서 데이터를 세팅한 뒤에
newIdSet reducer에서 id 값을 수동으로 세팅하기 위해서 두 개의 reducer를 만들게 되었다.
하지만 굳이 두개의 reducer를 통해야 할 필요가 없다고 생각이 되어
하나의 reducer로 합쳐서 id 값을 세팅하는 로직으로 수정하였다.
수정한 코드
// news: 뉴스 데이터 (ajax요청)
const news = createSlice({
name : 'news',
initialState : {
loading : 'first',
data : [],
},
reducers : {
newsData(state, action){ // news 데이터 셋팅
if (state.loading === 'first'){ /* 두번째 if문 실행하기 전 데이터 값이 있어야 하므로 첫번째 if문 실행 */
state.data = action.payload;
state.loading = 'second'
}
// detail page를 위한 id값 셋팅
if (state.loading === 'second'){
action.payload.map((a,val) => action.payload[val].source.id = val);
state.data = action.payload;
}
}
},
});
<ContentsWrapper>
{
loading ? <LoadingMsg>Loading.. please wait for a moment!</LoadingMsg> : null
}
{
news.length > 0
? news.map((data) => <NewsCont i={data.source.id} key={data.source.id}/>)
: null
}
</ContentsWrapper>
→ 이렇게 수정하니 처음 페이지에 진입했을 때, 원하던 대로 데이터가 세팅이 되었고 화면에도 표시되었다!
리팩토링 작업을 6월 말에 시작해서 7월 말까지, 약 한 달동안 틈날 때마다 진행해 거의 마무리 되었다.
거진 1년 전에 내가 작성한 코드를 봤을 때 수정할 부분이 너무 많이 보여서 엄두가 안났는데
천천히 하나하나 고쳐나가다 보니 이전보다는 깔끔해진 코드들에 너무 뿌듯했다..ㅎ
물론 현재 리팩토링을 마친 코드들도 문제가 많기 때문에 계속 리팩토링해볼 계획이다.
한번 완료된 프로젝트는 그대로 끝이 아니라, 그동안 쌓은 지식들을 활용해 계속 발전시켜 나가는 것이 바람직하다고 생각한다.
댓글