React.js Codelab 2016 – Express 와 React.js 를 사용한 웹 어플리케이션 만들기 (6)


5편에서는 메모를 수정하고 삭제하는 기능을 구현해보았습니다.
이번 편에서는, 별 주기 기능을 구현해보겠습니다.
이 작업은 수정 기능과 매우 비슷하므로 쉽게 구현 하실 수 있을겁니다.

23. 별 주기 기능

서버 – 별주기 API 구현하기 (server/routes/memo.js)

/*
    TOGGLES STAR OF MEMO: POST /api/memo/star/:id
    ERROR CODES
        1: INVALID ID
        2: NOT LOGGED IN
        3: NO RESOURCE
*/
router.post('/star/:id', (req, res) => {
    // CHECK MEMO ID VALIDITY
    if(!mongoose.Types.ObjectId.isValid(req.params.id)) {
        return res.status(400).json({
            error: "INVALID ID",
            code: 1
        });
    }

    // CHECK LOGIN STATUS
    if(typeof req.session.loginInfo === 'undefined') {
        return res.status(403).json({
            error: "NOT LOGGED IN",
            code: 2
        });
    }

    // FIND MEMO
    Memo.findById(req.params.id, (err, memo) => {
        if(err) throw err;

        // MEMO DOES NOT EXIST
        if(!memo) {
            return res.status(404).json({
                error: "NO RESOURCE",
                code: 3
            });
        }

        // GET INDEX OF USERNAME IN THE ARRAY
        let index = memo.starred.indexOf(req.session.loginInfo.username);

        // CHECK WHETHER THE USER ALREADY HAS GIVEN A STAR
        let hasStarred = (index === -1) ? false : true;

        if(!hasStarred) {
            // IF IT DOES NOT EXIST
            memo.starred.push(req.session.loginInfo.username);
        } else {
            // ALREADY starred
            memo.starred.splice(index, 1);
        }

        // SAVE THE MEMO
        memo.save((err, memo) => {
            if(err) throw err;
            res.json({
                success: true,
                'has_starred': !hasStarred,
                memo,
            });
        });
    });
});

이 API 는, 주어진 :id 값을 가진 메모를 찾고, 해당 메모의 starred 데이터 배열에 자신의 아이디가 존재하지 않는다면, 자신의 아이디를 starred 데이터 배열에 추가하고, 이미 존재한다면, starred 데이터 배열에서 제거합니다.
API 실행이 완료되면, 별을 주었는지 가져갔는지에 대한 값 has_star 과 새로운 메모데이터인 memo 객체를 반환합니다 (업데이트를 할 수 있게끔)

 

Action Types 추가 (src/actions/ActionTypes.js)

export const MEMO_STAR = "MEMO_STAR";
export const MEMO_STAR_SUCCESS = "MEMO_STAR_SUCCESS";
export const MEMO_STAR_FAILURE = "MEMO_STAR_FAILURE";

 

memo 액션파일 수정 (src/actions/memo.js)

import {
    /* CODES */
    MEMO_STAR,
    MEMO_STAR_SUCCESS,
    MEMO_STAR_FAILURE
} from './ActionTypes';

/* CODES */

/* MEMO TOGGLE STAR */
export function memoStarRequest(id, index) {
    return (dispatch) => {
        // TO BE IMPLEMENTED
    };
}

export function memoStar() {
    return {
        type: MEMO_STAR
    };
}

export function memoStarSuccess(index, memo) {
    return {
        type: MEMO_STAR_SUCCESS,
        index,
        memo
    };
}

export function memoStarFailure(error) {
    return{
        type: MEMO_STAR_FAILURE,
        error
    };
}

 

memoStarRequest 구현 (src/actions/memo.js)

/* MEMO TOGGLE STAR */
export function memoStarRequest(id, index) {
    return (dispatch) => {
        // TO BE IMPLEMENTED
        return axios.post('/api/memo/star/' + id)
        .then((response) => {
            dispatch(memoStarSuccess(index, response.data.memo));
        }).catch((error) => {
            dispatch(memoStarFailure(error.response.data.code));
        });
    };
}

 

memo 리듀서 파일 수정 (src/reducers/memo.js)

const initialState = {
    /* CODES */
    star: {
        status: 'INIT',
        error: -1
    }
};

export default function memo(state, action) {
    /* CODES */

    switch(action.type) {
        /* CODES */
        case types.MEMO_STAR:
            return update(state, {
                star: {
                    status: { $set: 'WAITING' },
                    error: { $set: -1 }
                }
            });
        case types.MEMO_STAR_SUCCESS: 
            return update(state, {
                star: {
                    status: { $set: 'SUCCESS' }
                },
                list: {
                    data: {
                        [action.index]: { $set: action.memo }
                    }
                }
            });
        case types.MEMO_STAR_FAILURE:
            return update(state, {
                star: {
                    status: { $set: 'FAILURE' },
                    error: { $set: action.error }
                }
            });
        default:
            return state;
    }
}

자, 이렇게 리듀서 까지 완성을 하였습니다.

앞으로 뭘 해야 할지는, 대충 감이 잡히지 않나요?

 

Home 컨테이너 컴포넌트 memoStarRequest, starStatus 매핑하기 (src/containers/Home.js)

import { 
   /* CODES */
    memoStarRequest
} from 'actions/memo';

class Home extends React.Component {
    
    /* CODES */
    
}

const mapStateToProps = (state) => {
    return {
        /* CODES */
        starStatus: state.memo.star
    };
};

const mapDispatchToProps = (dispatch) => {
    return {
        /* CODES */
        memoStarRequest: (id, index) => {
            return dispatch(memoStarRequest(id, index));
        }
    };
};

export default connect(mapStateToProps, mapDispatchToProps)(Home);

 

handleStar 구현하기 (src/containers/Home.js)

class Home extends React.Component {
    constructor(props) {
        /*CODES*/
        this.handleStar = this.handleStar.bind(this);
        /*CODES*/
    }
    
    handleStar(id, index) {
        this.props.memoStarRequest(id, index).then(
            () => {
                if(this.props.starStatus.status !== 'SUCCESS') {
                    /*
                        TOGGLES STAR OF MEMO: POST /api/memo/star/:id
                        ERROR CODES
                            1: INVALID ID
                            2: NOT LOGGED IN
                            3: NO RESOURCE
                    */
                    let errorMessage= [
                        'Something broke',
                        'You are not logged in',
                        'That memo does not exist'
                    ];
                    
                    
                    // NOTIFY ERROR
                    let $toastContent = $('<span style="color: #FFB4BA">' + errorMessage[this.props.starStatus.error - 1] + '</span>');
                    Materialize.toast($toastContent, 2000);
    
    
                    // IF NOT LOGGED IN, REFRESH THE PAGE
                    if(this.props.starStatus.error === 2) {
                        setTimeout(()=> {location.reload(false)}, 2000);
                    }
                }
            }
        );
    }

    render() {
        /* CODES */
        
        return (
            <div className="wrapper">
                { this.props.isLoggedIn ? write : undefined }
                <MemoList data={this.props.memoData} 
                     /* CODES */
                     onStar={this.handleStar}
                 />
            </div>
        );
    }
    /* CODES */

handleStar를 구현하고 MemoList로 전달해줍니다

 

MemoList 컴포넌트 수정하기 (src/components/MemoList.js)

import React from 'react';
import { Memo } from 'components';

class MemoList extends React.Component {
    
    render() {
        const mapToComponents = data => {
            return data.map((memo, i) => {
                return (<Memo 
                            /* CODES */
                            onStar={this.props.onStar}
                            currentUser={this.props.currentUser}
                />);
            });
        };
        
        return (
            <div>
                {mapToComponents(this.props.data)}
            </div>
        );
    }
}

MemoList.propTypes = {
    /* CODES */
    onStar: React.PropTypes.func,
};

MemoList.defaultProps = {
    /* CODES */
    onStar: (id, index) => {
        console.error('star function not defined');
    }
};

export default MemoList;

onStar 과 starStatus 를 그대로 Memo 컴포넌트로 전달해주었습니다.

추가적으로, currentUser props 도 전달되었습니다 (유저가 각 메모를 star 한지 안한지 여부를 확인하는데 사용됩니다)

propTypes 와 defaultProps 도 수정되었습니다.

 

Memo 컴포넌트 handleStar 구현 (src/components/Memo.js)

class Memo extends React.Component {

    constructor(props) {
        /* CODES */
        this.handleStar = this.handleStar.bind(this);
    }
    
    /* CODES */
    
    handleStar() {
        let id = this.props.data._id;
        let index = this.props.index;
        this.props.onStar(id, index); 
    }
    
    /* CODES */
    
    render() {
        /* CODES */
        
        // IF IT IS STARRED ( CHECKS WHETHER THE NICKNAME EXISTS IN THE ARRAY )
        // RETURN STYLE THAT HAS A YELLOW COLOR
        let starStyle = (this.props.data.starred.indexOf(this.props.currentUser) > -1) ? { color: '#ff9980' } : {} ;

        
        const memoView = (
            <div className="card">
                /* CODES */
                <div className="footer">
                    <i className="material-icons log-footer-icon star icon-button" 
                        style={starStyle}
                        onClick={this.handleStar}>star</i>
                    <span className="star-count">{this.props.data.starred.length}</span>
                </div>
            </div>
        );
        
        /* CODES */
    }
    
    
    /* CODES */
}

Memo.propTypes = {
    /* CODES */
    onStar: React.PropTypes.func,
    starStatus: React.PropTypes.object,
    currentUser: React.PropTypes.string
};

Memo.defaultProps = {
    /* CODES */
    onStar: (id, index) => {
        console.error('star function not defined');
    },
    starStatus: {},
    currentUser: ''
}

export default Memo;

onStar, currentUser 을 propTypes 와 defaultProps 에 추가하였습니다.

handleStar 메소드를 만든 후, 생성자에서 this 와 binding 해주었습니다.

해당 메모가 유저가 star을 한지 안한지 확인하려면, 배열의 indexOf 메소드를 통하여 starred 데이터에 로그인유저의 username이 적혀있는지 확인하면됩니다.

만약에 해당 메모가 star 한 메모라면, inline style 을 통하여 노란색 스타일을 반환하고, 그렇지 않으면 비어있는 스타일을 반환합니다.

그리고, star 버튼에 스타일을 추가해주고, onClick에 handleStar 메소드를 실행하도록합니다.

 

이제 메모에 별을 줄 수 있게 되었습니다 !

 

이제 앞으로 유저검색기능이 남았는데요, 그걸 구현하기 전에, 먼저 성능을 최적화해보겠습니다.

 

24. 성능 최적화

지금 상태에서, 페이지를 더 이상 메모가 나타나지 않을 때 까지 계속 스크롤을 해보세요.

맨 끝에 도달 했다면, 조금 위로 갔다, 아래로갔다를 반복해보세요. 크게 눈에 띄이진 않지만 렉이 있는걸 발견 할 수 있습니다 (CPU 성능이 좋으면 거의 안느껴집니다. 그래도 스크롤이 살짝 끊기는 느낌이 있을겁니다)

도대체 무슨 일이 일어나고 있을까요? 한번 Memo 컴포넌트의 render() 메소드의 최상단에 console.log(this.props.data) 를 입력하고 개발자도구를 키고 웹어플리케이션을 사용해보세요.

 

렌더링이 엄청나게 많이 진행되고있다는 사실을 파악 할 수 있을겁니다.

스크롤링 해보세요. 변경되지 않은 이미 렌더링 한 컴포넌트에서 render() 메소드가 실행되죠?

한번 수정을 해보세요. 수정하지 않은 컴포넌트들에서도 render() 메소드가 실행되고 있습니다.

 

문제를 발견 한 이상, 우리는 이 문제를 해결해야합니다.

이 문제가 발생하는 이유는, redux store 에 API 관련 status 가 업데이트 될 때도 컴포넌트가 업데이트되기 때문입니다.

(컴포넌트가 다시 렌더링 되지는 않지만, render 메소드가 실행 되고, 변화가 있나 없나 계산을 하겠지요)

 

이 문제점을 해결하기 위해선 먼저 MemoList 컴포넌트를 수정해야합니다.

MemoList 컴포넌트의 렌더링 메소드에선 데이터를 컴포넌트로 매핑하는 나름 ‘복잡한’ 작업을 하는데, 렌더링 메소드가 필요 할 떄만 실행되도록 해야겠죠?

MemoList 컴포넌트에도 render() 메소드의 상단에 console.log('MemoList render method executed'); 라는 코드를 입력하세요

(정확한 테스팅을 위해서 아까 Memo 컴포넌트에 적은 console.log 는 잠시 주석처리해주세요)

 

문제점 1: 5초마다 새 게시물 불러오기 시도를 할 때, 새로운 게시물이 없더라도 렌더링 메소드가 실행 된다, 그것도 두번.

이유: Home 컴포넌트의 listStatus.status 가 ‘WAITING’ 로 변한다음, ‘SUCCESS’ 로 변하면서 두번

문제점 2: 스크롤 해서 이전 게시물을 불러오려고 시도 할 때, 렌더링 메소드가 4번이나 실행 된다.

이유: Home 컴포넌트의 무한스크롤링을 위한 state.loadingStatus 이 토글 되면서 한번, listStatus 때문에 두번, 마지막으로 Home 컴포넌트에서 새 데이터를 갖다줘서 한번

 

MemoList 컴포넌트가 전달 받은 props 가 변경 될 때 render 메소드가 트리거 됩니다.
그리고, 전달받은 props 에 변화가 없더라도, 패런트 컴포넌트 (Home) 에서 render 메소드가 실행 될 때도, render 메소드가 트리거 됩니다.

이를 막아주려면 LifeCycle API 인 shouldComponentUpdate 를 사용하면됩니다.

 

MemoList 컴포넌트에 shouldComponentUpdate 메소드 추가 (src/components/MemoList.js)

    shouldComponentUpdate(nextProps, nextState) {
        let update = JSON.stringify(this.props) !== JSON.stringify(nextProps);
        return update;
    }

 

위와 같이, 전달받은 props 값이 달라질때만 render() 메소드를 실행하도록 설정하면 위 문제들이 완화됩니다.

 

자, 다시 아까 Memo 컴포넌트에 주석처리했던 console.log 를 주석해제해보세요.

그 후, 다시 테스팅을 해보시면, 스크롤링을 할 때 아까처럼 콘솔에 미친듯이 출력되지는 않지만, 변동이 없는 Memo 들도 render() 메소드가 실행되고 있죠.

한번 이 컴포넌트도 shouldComponentUpdate 메소드를 추가해봅시다.

 

Memo 컴포넌트에 shouldComponentUpdate 메소드 추가 (src/components/Memo.js)

    shouldComponentUpdate(nextProps, nextState) {
        let current = {
            props: this.props,
            state: this.state
        };
        
        let next = {
            props: nextProps,
            state: nextState
        };
        
        let update = JSON.stringify(current) !== JSON.stringify(next);
        return update;
    }

이번엔 state 도 비교하게 하였습니다. 이렇게 하면, 새 메모가 추가 될 때, 새로운 메모들만 render 메소드가 실행됩니다.

 

이렇게 추가해주면, CPU 자원이 낭비되는 문제를 해결 할 있습니다.

작업이 끝났다면 render 메소드에서 입력한 console.log 를 지우세요.

한번 맨끝까지 스크롤을 한 다음, 스크롤을 위로 아래로 왔다갔다 해보세요. 어때요, 아까보다 스크롤이 부드럽지 않나요?

 

축하드립니다! 저희 프로젝트의 핵심 기능을 모두 완성하였고, 마무리도 어느정도 끝났습니다!

이제 남은 유저 검색기능은, 다음 편에서 제가 핵심 코드들만 제공해드릴테니 여러분이 직접 구현해보세요~

  • hanwong

    Home/handleStar에서 로그인 안된 상태에서 클릭했을 때 리프레쉬 되는 처리를 넣은 이유가 있나요??

    • 아! 제가 좀 빼 먹은 부분이 있는것 같은데,
      로그인 안 된상태에서 클릭하면 아예 요청조차 하지 않게했어야했었는데 그 부분을 놓친것 같네요.

      원래 새로고침을 하는 이유는 “로그인 된 상태에서 요청을 했는데 서버에선 로그인 상태가 아니라고 할 때” 를 고려해서 넣었었습니다.

      따라서 코드를, 요청을 하기전에 redux의 state를 확인하여 로그인 되어있지 않다면 실패를 띄우고 작업을 종료를 하게끔 수정을 해야 할 것 같습니다.

  • hogri

    render에 const를 적용해서 사용하신 이유가 있을까요? 내부에 onClick 이벤트 function이 여러번 생서될 것 같은데. 아닌가요? 성능상으로 더 빠른건가요 궁금하네요 ㅜㅜ

    const memoView = (

    /* CODES */

    star
    {this.props.data.starred.length}

    );

    • memoView에 넣은 이유는, 그 메모의 상태에 따라서 수정할때의 view인 editView를 보여줄지 읽을때의 memoView를 보여줄 지 정하기 위해서입니다.

      생각을 해보니, 이렇게 하면 edit이 아닐때도 edit에 필요한 함수들을 만들긴 하다보니 자원이 조금 낭비될수도 있겠네요, 그런데 실제로 렌더링 되지 않고 코드만 실행되니 문제가 되지 않습니다.

      결국엔 그 함수가 실행되는것도 아니고 그 값을 assign하는 부분에서만 비용이 들 테니까요

      더 최적화를 하려면, if else를 사용해서 아예 그 코드가 실행되지 않게 하거나

      아니면 렌더링 부분에서 ? : 를 사용하여 바로 코드를 넣어도 됩니다. (저같은경우엔 이렇게 하면 좀 더러워보여서 차라리 const에 담아서 보여주게 한거구요)

      만약에 제가 지금 코드를 다시 짰더라면, memoView와 editView를 사용할게아니라,

      각 뷰에 Stateless 컴포넌트를 만들어서 ? : 로 바로 입력할것같습니다.

      그렇게하면 div 이런게 없으니까 코드도 더 깔끔해지겠죠.

      나중에 시간이 나면 좀 더 깔끔한 코드 스타일로 새 프로젝트를 만들어봐야겠습니다 🙂 이 프로젝트를 만든 이후에 제가 코딩하는 스타일도 좀 달라져서요, 지금보면 좀 불편한감이 있긴 하네요 ㅠㅠ

  • 정성우

    memoStarRequest 구현 (src/actions/memo.js) 부분에
    dispatch(memoStar()); 가 빠져있습니다.