함께 성장하는 프로독학러

Memo_app 06. 메모(Memo) - Retrieve 기능 구현 (메모읽기) / 무한스크롤링 구현 본문

Programming/tutorials

Memo_app 06. 메모(Memo) - Retrieve 기능 구현 (메모읽기) / 무한스크롤링 구현

프로독학러 2018. 6. 27. 01:20

안녕하세요, 프로독학러 입니다.


이번 포스팅에서는 메모에 관련된 기능들 중 Retrieve 를 구현해 보도록 하겠습니다.

메모의 기능들을 CRUD 라고도 하는데요, 이는 Create, Retrieve, Update, Delete 의 줄임말으로, 쓰기, 읽기, 수정하기, 삭제하기입니다.

(이 중 두 번째인 Retrieve 를 구현하도록 하겠습니다)


*본 튜토리얼은 Velopert 님의 'React.js Codelab 2016' 을 기반으로 되었습니다.

(여러 모듈들의 버전업에 따라 작성방법이 조금씩 달라진 코드를 버전에 맞게 수정하고, 제가 튜토리얼을 따라함에 있어 이해가 쉽지 않았던 부분에 설명을 추가하는 방식으로 진행합니다.)

* Velopert 님의 원본 튜토리얼을 보고싶으신 분들은 아래의 링크를 참고해 주세요.

<React.js codelab 2016 - Velopert>


1. Retrieve (읽기) 기능 구현


읽기 기능은 작성한 메모를 보여주는 기능입니다.

기능의 구현은 다음 그림과 같은 방식으로 구현됩니다.



위의 그림에서 가장 먼저 살펴볼 것은 BACK-END 의 API 입니다. 

GET 방식으로 '/api/memo' 로 들어온 요청을 처리하는 서버사이드 라우터인데, 요청이 들어오면 memos 콜렉션에 있는 모든 데이터를 조회합니다. 이때, option 을 통하여 최신순으로 조회하고, 6개만 조회합니다. 조회에 성공하면 조회된 데이터(배열 형식)를 리스폰스합니다.

두 번째로 살펴볼 것은 FRONT-END 의 Redux 입니다.

Redux 에서는 thunk 함수와 thunk 의 과정에서 action 객체를 받아 리덕스 state 를 수정하는 리듀서를 정의합니다. thunk 안에서 API 와 통신에 성공하면 리덕스 state.list.data 에 조회된 데이터(6개, 배열형식)를 저장합니다.

세 번째로 살펴볼 것을 Home 컨테이너입니다. 

Home 컨테이너는 저번 포스팅에서 구현한대로 정확한 '/' 경로에서 렌더링되는 컴포넌트입니다. Home 컨테이너에서는 MemoList 컴포넌트를 렌더링합니다.

네 번째로 살펴볼 것은 MemoList 컴포넌트입니다.

MemoList 컴포넌트는 컴포넌트 매핑을 통해 Memo 컴포넌트를 렌더링 합니다.

다섯 번째로 살펴볼 것은 Memo 컴포넌트입니다.

Memo 컴포넌트는 Home->MemoList->Memo 순으로 전달받은 도큐먼트 하나의 데이터를 통해 렌더링에 사용합니다.


데이터의 전달은 다음과 같은 순서로 이루어집니다.

1. Redux 에서 thunk 와 state 를 정의해 Home 컨테이너로 전달합니다. (connect 를 통해)

2. Home 컨테이너가 처음 렌더링 되거나 새로고침될 때, 혹은 스크롤링을 통해 이전 메모가 더 필요할 때 thunk 함수가 실행됩니다.

3. thunk 함수의 실행을 통해 API 와 통신이 이루어지고, API 는 DB 와 소통을 통해 메모 리스트를 리턴합니다.

4. 리덕스 state 는 API 로부터 전달받은 메모리스트를 state.memo.list.data 에 저장하고 다시 Home 컨테이너로 넘겨줍니다.

5. Home 컨테이너에서 MemoList 컴포넌트로 메모 리스트 배열을 전달합니다.

6. MemoList 에서 컴포넌트 매핑을 통해 Memo 컴포넌트를 하나씩 렌더링하는데, 이 때 메모 리스트 배열의 각각의 원소에 해당하는 객체 데이터를 Memo 컴포넌트에 전달하고, Memo 컴포넌트는 전달받은 데이터를 통해 Memo 를 렌더링하는데 사용합니다.


1-1) BACK-END API 구현


그럼 BACK-END API 부터 구현해 보도록 하겠습니다.

server - routes 디렉토리의 memo.js 의 router.get('/', (req, res) => { ... }) 부분을 다음과 같이 수정합니다.


(./server/routes/memo.js)

1
2
3
4
5
6
7
8
9
10
11
12
13
// GET MEMO LIST
/*
    READ MEMO: GET /api/memo
*/
router.get('/', (req, res) => {
    Memo.find() // 인자가 들어오지 않으면 -> 모든 도큐먼트를 조회
    .sort({"_id"-1}) // 1: 오름차순, -1: 내림차순 (최근것 부터 오래된 순으로 조회)
    .limit(6// 무한 스크롤링을 구현하는데, 그 단위는 6개의 도큐먼트씩
    .exec((err, memos) => { // find().exec(): 쿼리를 프로미스를 만들기 위해 붙이는 메소드
        if(err) throw err;
        res.json(memos);
    });
});
cs


GET 방식으로 '/api/memo' 로 접근하는 요청에 대해서 처리하는 API 입니다.

코드의 6 번째 줄에서 'memos' 콜렉션에 있는 데이터를 조회하는데, 인자가 들어오지 않았으므로 어떤 Query 도 하지 않고 모든 데이터를 조회합니다. 7 번째 줄에서 .sort 메소드를 통해 내림차순으로 데이터를 정렬하겠다고 지정했습니다. 그리고 8 번째 줄에서 조회하는 데이터의 갯수를 6개로 제한했습니다.


9 번째 줄에서는 데이터 조회 작업이 끝나면 실행하는 메소드로, 에러가 생기면 에러를 throw 하고, 에러가 없다면 조회된 데이터(배열형식)를 리스폰스(리턴)합니다. 


1-2) Redux 구현


리덕스를 구현해 보도록 하겠습니다.


1-2-1) ActionTypes 추가


메모 읽기에 관련된 actiontype 을 추가해 줍니다.


(./src/actions/ActionTypes.js)

1
2
3
4
// Get memo list from DB
export const MEMO_LIST = "MEMO_LIST";
export const MEMO_LIST_SUCCESS = "MEMO_LIST_SUCCESS";
export const MEMO_LIST_FAILURE = "MEMO_LIST_FAILURE";
cs


1-2-2) thunk 함수 및 액션 생성자 함수 구현


(./src/actions/memo.js)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import {
    ...
    MEMO_LIST,
    MEMO_LIST_SUCCESS,
    MEMO_LIST_FAILURE
} from './ActionTypes';
...
/* MEMO LIST */
/*
    Parameter:
        - isInitial: whether it is for initial loading
        - listType:  OPTIONAL; loading 'old' memo or 'new' memo
        - id:        OPTIONAL; memo id (one at the bottom or one at the top) (listType 파라메터의 기준)
        - username:  OPTIONAL; find memos of following user
*/
export function memoListRequest(isInitial, listType, id, username) {
    return (dispatch) => {
        // inform memo list API is starting
        dispatch(memoList());
 
        let url = '/api/memo';
 
        return axios.get(url)
        .then((response) => {
            dispatch(memoListSuccess(response.data, isInitial, listType));
        }).catch((error) => {
            dispatch(memoListFailure());
        });
    };
}
 
export function memoList() {
    return {
        type: MEMO_LIST
    };
}
 
export function memoListSuccess(data, isInitial, listType) {
    return {
        type: MEMO_LIST_SUCCESS,
        data,
        isInitial,
        listType
    };
}
 
export function memoListFailure() {
    return {
        type: MEMO_LIST_FAILURE
    };
}
cs


코드의 3~5 번째 줄에서 위에서 추가한 ActionTypes 를 import 했습니다.


코드의 32, 38, 47 번째 줄은 모두 액션 생성자 함수입니다. 각각 순서대로 메모 요청 객체, 메모 요청 성공 객체, 메모 요청 실패 객체를 리턴합니다.

성공객체를 만드는 액션 생성자 함수에는 data, isInitial, listType 값이 인자로 들어와서 액션 객체의 해달 필드의 값이 됩니다.

* ES6 의 property shorthand 가 사용되었습니다. 이에 익숙하지 않으신 분들은 아래의 링크를 참조해주세요.

<ES6 의 property shorthand>


코드의 16 번째 줄은 thunk 함수입니다. thunk 함수의 인자로 isInitail, listType, id, username 가 들어오는데, 이는 메모를 처음 불러올 때, 새 메모, 이전 메모를 불러오거나 특정한 유저의 메모만을 불러올 때 사용되는 인자로, 진행하면서 자세히 다루도록 하겠습니다.

일단은 axios 를 통해 GET 방식으로 '/api/memo' 에 접근해 6개의 메모를 받아오고, 받아오는데 성공하면 성공 액션 객체를, 실패하면 실패 액션 객체를 리듀서에게 전달한다는 것을 알아두면 되겠습니다.


1-2-3) 리듀서 구현


리듀서는 thunk 함수의 실행으로부터 각각 상황에 따라 다른 객체를 전달받아 리덕스 state 를 변경(업데이트) 하는 함수입니다.


(./src/reducers/memo.js)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
...
    case types.MEMO_LIST:
        return {
          ...state,
          list: {
            ...state.list,
            status: 'WAITING'
          }
        };
    case types.MEMO_LIST_SUCCESS:
        if(action.isInitial) {
            return {
              ...state,
              list: {
                ...state.list,
                status: 'SUCCESS',
                data: action.data,
                isLast: action.data.length < 6
              }
            }
        } else {
            if(action.listType === 'new') { // 배열의 앞부분에 추가
                return {
                  ...state,
                  list: {
                    ...state.list,
                    status: 'SUCCESS',
                    data: [...action.data, ...state.list.data]
                  }
                }
            } else { //action.listType === 'older' //배열의 뒷부분에 추가
                return {
                  ...state,
                  list: {
                    ...state.list,
                    status: 'SUCCESS',
                    data: [...state.list.data, ...action.data],
                    isLast: action.data.length < 6
                  }
                }
            }
        }
        return state;
    case types.MEMO_LIST_FAILURE:
        return {
          ...state,
          list: {
            ...state.list,
            status: 'FAILURE'
          }
        };
...
cs


리듀서가 전달 받은 action 객체의 type 속성에 따라 리덕스 state 를 다르게 변경하는 코드입니다.

코드의 2, 44 번째 줄은 성공 / 실패 했을 때 리덕스 state 를 어떻게 변경할지 정하는 코드이며, 두 경우 모두 state.list.status 값을 변경합니다. (WAITING / FAILURE)

코드의 10 번째 줄 부터 43 번째 줄 까지는 성공 했을 때 (성공 했다는 action 객체를 전달 받았을 때 = API 로 부터 메모리스트를 전달받음) 의 코드입니다.

11 번째 줄에서 action.isInitial 값이 true 일때 state 를 어떻게 변경할지 정의했습니다. 이는 처음 메모리스트를 요청한 것으로, API 가 전달해준 리스트들을 그대로 state.list.data 에 저장하는 것입니다. 

* isLast 값은 API 가 전달해 준 데이터의 길이가 6 이하일 때 true 가 되는 값으로 마지막 메모리스트인지 확인하는 용도로 사용됩니다.

21 번째 줄은 처음 메모리스트를 요청한 것이 아니라 id 를 기준으로 (thunk 의 인자) 새 메모나 이전 메모를 불러오는 요청에 따른 state 변경코드입니다. id 를 기준으로 새 메모들을 불러왔다면 data 배열의 앞에 새로 로딩된 메모가 위치해야하며, 이전 메모를 불러왔다면 data 배열의 뒤에 위치해야합니다.

* 이를 ES6 의 spread operator 를 통하여 구현했습니다. 이에 익숙하지 않으신 분들은 아래의 링크를 참조해주세요.

<ES6 의 spread operator>


1-3) Home 컨테이너에 Redux 연결


Home 컨테이너에서 Redux 에서 정의한 state 및 thunk 함수를 사용하기 위해서 Redux 와 connect 시켜줍니다.


(./src/containers/Home.js)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
...
import { memoPostRequest, memoListRequest } from 'actions/memo';
...
const mapStateToProps = (state) => {
    return {
        isLoggedIn: state.authentication.status.isLoggedIn,
        postStatus: state.memo.post,
        currentUser: state.authentication.status.currentUser,
        memoData: state.memo.list.data,
        listStatus: state.memo.list.status,
        isLast: state.memo.list.isLast
    };
};
 
const mapDispatchToProps = (dispatch) => {
    return {
        memoPostRequest: (contents) => {
            return dispatch(memoPostRequest(contents));
        },
        memoListRequest: (isInitial, listType, id, username) => {
            return dispatch(memoListRequest(isInitial, listType, id, username));
        }
    };
};
 
export default connect(mapStateToProps, mapDispatchToProps)(Home);
cs


코드의 두 번째 줄과 같이 memoListRequest (thunk) 를 import 했습니다.

그리고 하위 컴포넌트에 필요한 state 들과 memoListRequest 를 props 로 쓸 수 있도록 connect 했습니다.


1-4) MemoList, Memo 컴포넌트 생성


Home 컨테이너에서 렌더링할 Memo 들의 집합체인 MemoList 컴포넌트를 만들어 보겠습니다.

components 디렉토리에 MemoList.js 파일을 생성합니다.


(./src/components/MemoList.js)

1
2
3
4
5
6
7
8
9
10
11
12
13
import React, { Component } from 'react';
 
class MemoList extends Component {
    render() {
      return (
        <div>
          MemoList
        </div>
      );
    }
}
 
export default MemoList;
cs


MemoList 컴포넌트로부터 컴포넌트 매핑을 통해 렌더링 될 Memo 컴포넌트를 생성해 보겠습니다.

components 디렉토리에 Memo.js 파일을 생성합니다


(./src/components/Memo.js)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import React, { Component } from 'react';
 
 
class Memo extends Component {
    render() {
        return (
            <div className="container memo">
                <div className="card">
                    <div className="info">
                        <a className="username">Writer</a> wrote a log · 1 seconds ago
                        <div className="option-button">
                            <a className='dropdown-button' id='dropdown-button-id' data-activates='dropdown-id'>
                                <i className="material-icons icon-button">more_vert</i>
                            </a>
                            <ul id='dropdown-id' className='dropdown-content'>
                                <li><a>Edit</a></li>
                                <li><a>Remove</a></li>
                            </ul>
                        </div>
                    </div>
                    <div className="card-content">
                        Contents
                    </div>
                    <div className="footer">
                        <i className="material-icons log-footer-icon star icon-button">star</i>
                        <span className="star-count">0</span>
                    </div>
                </div>
            </div>
        );
    }
}
 
export default Memo;
cs


Memo 컴포넌트에 렌더링 될 내용은 Materializecss 를 통해 만들어졌습니다.


Memo 를 위한 style 을 추가해 주도록 하겠습니다.


(./src/style.css)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
/* Memo */
.memo .info {
    font-size: 18px;
    padding: 20px 40px 0px 20px;
    color: #90A4AE;
}
.memo .info .username {
    color: #263238;
    font-weight: bold;
    cursor: pointer;
}
.memo .card-content {
    word-wrap: break-word;
    white-space: pre-wrap;
}
.icon-button {
    color: #9e9e9e;
    cursor: pointer;
}
.icon-button:hover {
    color: #C5C5C5;
}
.icon-button:hover {
    color: #C5C5C5;
}
.icon-button:active {
    color: #ff9800;
}
.memo .option-button {
    position: absolute;
    right: 20px;
    top: 20px;
}
.memo .card-content {
    font-size: 18px;
}
.memo .footer {
    border-top: 1px solid #ECECEC;
    height: 45px;
}
.star {
    position: relative;
    left: 15px;
    top: 11px;
}
.star-count {
    position: relative;
    left: 20px;
    top: 4px;
    font-size: 13px;
    font-weight: bold;
    color: #777;
}
cs


MemoList 컴포넌트와 Memo 컴포넌트를 디렉토리에 생성했으니 components 디렉토리의 index 에 두 파일을 추가해 주도록 합시다.


(./src/components/index.js)

1
2
3
4
5
6
7
import Authentication from './Authentication';
import Header from './Header';
import Write from './Write';
import Memo from './Memo';
import MemoList from './MemoList';
 
export { Authentication, Header, Write, Memo, MemoList };
cs


1-5) Home 컨테이너에서 MemoList 컴포넌트 렌더링 + 메모로딩


MemoList 컴포넌트와 Memo 컴포넌트가 갖춰 졌으니 Home 컨테이너에서 렌더링 하는 코드를 작성해 봅시다.

추가적으로 Home 컨테이너가 렌더링 완료되고 난 뒤에 첫 메모 로딩을 실시합니다.


(./src/containers/Home.js)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
...
import { Write, MemoList } from 'components';
...
componentDidMount() {
      // DO THE INITIAL LOADING
        this.props.memoListRequest(true, undefined, undefined, undefined);
    }
...
render() {
      const write = ( <Write onPost={this.handlePost}/> );
      return (
          <div className="wrapper">
            { this.props.isLoggedIn ? write : undefined }
            <MemoList data={this.props.memoData}
                      currentUser={this.props.currentUser}/>
          </div>
      );
    }
...
 
cs


위 코드의 두 번째 줄과 같이 components 디렉토리에서 MemoList 컴포넌트를 불러온 뒤, 14 번째 줄에서 MemoList 컴포넌트를 렌더링 했습니다. 이 때, this.props.memoData 값과 this.props.currentUser 값을 props 로 전달했습니다.

(this.props.memoData 는 state.memo.list.data 로 API 로부터 전달받은 메모리스트 데이터입니다)

Home 컨테이너가 렌더링 완료되고 난 뒤에 코드의 6 번째 줄과 같이 thunk 함수를 호출합니다.

인자는 첫 번째 값에만 true 를 주고 나머지 값은 undefined 를 줍니다. (첫 번째 인자는 isInitial 로 첫 로딩인지 알려주는 파라메터)


Home 컨테이너가 렌더링완료 된 이후에 thunk 를 실행해 state 를 업데이트받아, MemoList 컴포넌트에 전달될 data 값이 생기게 됩니다.


1-6) MemoList 컴포넌트에서 Memo 컴포넌트 렌더링 (컴포넌트 매핑)


MemoList 에 data props 로 전달된 값은 배열형식 입니다.

배열 안에는 객체형식의 각각 메모에 해당하는 도큐먼트들이 존재합니다.

이 각각의 데이터를 Memo 컴포넌트로 전달하도록 하겠습니다.


(./src/components/MemoList.js)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import React, { Component } from 'react';
import { Memo } from 'components'
import PropTypes from 'prop-types';
 
class MemoList extends Component {
    render() {
      const mapToComponents = data => {
        return data.map((memo, i) => {
          return (
            <Memo
              data={memo}
              ownership={ memo.writer === this.props.currentUser }
              key={memo._id}
              index={i}
              />
          );
        })
      }
      return (
        <div>
          { mapToComponents(this.props.data) }
        </div>
      );
    }
}
 
MemoList.propTypes = {
    data: PropTypes.array,
    currentUser: PropTypes.string
};
 
MemoList.defaultProps = {
    data: [],
    currentUser: ''
};
 
export default MemoList;
cs


코드의 7 번째 줄에서 정의한 mapToComponents 를 살펴 보겠습니다.

mapToComponenet 는 함수로, 인자로 들어오는 값은 data 입니다. 이 data 는 배열 형식으로, 객체 형태로 이루어진 각각의 원소를 가지고 있습니다. 8 번째 줄에서 배열 data 에 map 메소드를 적용한 값을 리턴하고 있습니다. 

map 메소드의 첫 번째 인자는 콜백함수이며 콜백함수에 들어오는 인자는 배열의 원소, 인덱스, (배열 전체) 입니다. 즉, memo 는 각각 배열의 원소인 객체를 의미하고, i 는 index 를 의미합니다. map 메소드는 원래 배열과 같은길이의 배열을 리턴하며 각 원소는 콜백함수의 리턴값이 됩니다.

따라서 7번째 줄의 컴포넌트 매핑의 결과물은 <Memo ... /> 컴포넌트의 배열입니다. (길이는 매핑의 인자로 들어간 data 와 같음)

* 배열의 map 메소드에 익숙하지 않으신 분들은 아래의 링크를 참조해 주세요.

<배열의 map 매소드>


코드의 12 번째 줄에서 Memo 컴포넌트로 ownerShip props 를 전달 했습니다. 이 값은 현재 로그인 된 데이터와 메모의 작성자가 일치하면 true 가 되는 값으로, Memo 컴포넌트 안에서 드롭다운 메뉴를 보이도록 하는 역할을 수행합니다.


코드의 27~35 번째 줄에서 Home 컨테이너로부터 전달 받은 props 들의 propTypes 와 defaultProps 를 설정 했습니다.


1-7) Memo 컴포넌트에서 data (prpos) 이용해서 내용 렌더링


MemoList 로 부터 전달받은 data props (객체형식/도큐먼트) 를 이용하여 내용과 작성자를 렌더링합니다.


(./src/components/Memo.js)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
import React, { Component } from 'react';
import PropTypes from 'prop-types';
 
class Memo extends Component {
    render() {
        return (
            <div className="container memo">
                <div className="card">
                    <div className="info">
                        <a className="username">{this.props.data.writer}</a> wrote a log · 1 seconds ago
                        <div className="option-button">
                            <a className='dropdown-button' id='dropdown-button-id' data-activates='dropdown-id'>
                                <i className="material-icons icon-button">more_vert</i>
                            </a>
                            <ul id='dropdown-id' className='dropdown-content'>
                                <li><a>Edit</a></li>
                                <li><a>Remove</a></li>
                            </ul>
                        </div>
                    </div>
                    <div className="card-content">
                        {this.props.data.contents}
                    </div>
                    <div className="footer">
                        <i className="material-icons log-footer-icon star icon-button">star</i>
                        <span className="star-count">0</span>
                    </div>
                </div>
            </div>
        );
    }
}
 
Memo.propTypes = {
    data: PropTypes.object,
    ownership: PropTypes.bool
};
 
Memo.defaultProps = {
    data: {
        _id: 'id1234567890',
        writer: 'Writer',
        contents: 'Contents',
        is_edited: false,
        date: {
            edited: new Date(),
            created: new Date()
        },
        starred: []
    },
    ownership: true
}
 
export default Memo;
cs


MemoList 컴포넌트로 부터 전달받은 data 의 propTypes 와 defaultProps 를 지정해 줍니다. (34~50 번째 줄)


10 번째 줄에서 작성자의 이름을 전달 받은 data.writer 로, 22 번째 줄에서 내용을 전달받은 data.contents 로 렌더링 했습니다.


추가적으로 dropdown 메뉴를 작성자와 로그인데이터가 일치할 때만 보이도록 코드를 수정해 주도록 하겠습니다.


(./src/components/Memo.js)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
import React, { Component } from 'react';
import PropTypes from 'prop-types';
 
class Memo extends Component {
    componentDidUpdate() {
        // WHEN COMPONENT UPDATES, INITIALIZE DROPDOWN
        // (TRIGGERED WHEN LOGGED IN)
        $('#dropdown-button-'+this.props.data._id).dropdown({
            belowOrigin: true // Displays dropdown below the button
        });
    }
 
    componentDidMount() {
        // WHEN COMPONENT MOUNTS, INITIALIZE DROPDOWN
        // (TRIGGERED WHEN REFRESHED)
        $('#dropdown-button-'+this.props.data._id).dropdown({
            belowOrigin: true // Displays dropdown below the button
        });
    }
    render() {
        const dropDownMenu = (
          <div className="option-button">
              <a className='dropdown-button'
                   id={`dropdown-button-${this.props.data._id}`}
                   data-activates={`dropdown-${this.props.data._id}`}>
                  <i className="material-icons icon-button">more_vert</i>
              </a>
              <ul id={`dropdown-${this.props.data._id}`} className='dropdown-content'>
                  <li><a>Edit</a></li>
                  <li><a>Remove</a></li>
              </ul>
          </div>
        );
        const memoView = (
          <div className="card">
              <div className="info">
                  <a className="username">{this.props.data.writer}</a> wrote a log · 1 seconds ago
                    { this.props.ownership ? dropDownMenu : undefined }
              </div>
              <div className="card-content">
                  {this.props.data.contents}
              </div>
              <div className="footer">
                  <i className="material-icons log-footer-icon star icon-button">star</i>
                  <span className="star-count">0</span>
              </div>
          </div>
        );
        return (
            <div className="container memo">
                { memoView }
            </div>
        );
    }
}
 
Memo.propTypes = {
    data: PropTypes.object,
    ownership: PropTypes.bool
};
 
Memo.defaultProps = {
    data: {
        _id: 'id1234567890',
        writer: 'Writer',
        contents: 'Contents',
        is_edited: false,
        date: {
            edited: new Date(),
            created: new Date()
        },
        starred: []
    },
    ownership: true
}
 
export default Memo;
cs


하나의 덩어리로 렌더링 되던 Materializecss 태그들을 memoView, dropDownMenu 로 나워서 렌더링 했습니다. (각각 34, 21 번째 줄)

memoView 안에서 dropDownMenu 를 렌더링할 때 프롭스로 전달받은 ownership 의 값이 true 일때만 렌더링 하도록 설정했습니다. (38 번째 줄)

dropDownMenu 안에서 a 태그의 id 에 dropdown-button-(data._id) 값을 주었습니다. (24 번째 줄)

그리고 5, 13 번째 줄을 통하여 로그인을 통해 컴포넌트가 업데이되거나 새로고침될 때 드롭다운 메뉴를 보이도록 설정했습니다.

(사실 지금 단계에서는 해당 코드가 있던 없건 드롭다운 메뉴가 동작하지만, 해당 컴포넌트가 유동적으로 생성되고 업데이트 되는 경우를 대비해 따로 활성화 작업을 했습니다.)

* 이는 Materializecss 에 기반한 내용으로 아래의 링크를 참조하시면 도움이 되실 것입니다.

<Materializecss - dropdown>


여기까지 완료됐으면 서버를 재시작해 한번 확인 해 보도록하죠.



정확한 '/' 경로에서 작성했던 메모들이 제대로 렌더링 되는 것을 확인할 수 있습니다.

작성자로 로그인하여 드롭다운 메뉴가 잘 뜨는지도 확인해 보세요!


2. 추가 로딩 기능 구현


메모를 추가 로딩하는 기능을 구현해 보도록 하겠습니다.

추가 로딩은 현재 페이지의 로딩되어 있는 데이터 중에서 가장 위에 있는 메모의 _id 보다 높은 _id 를 가진 도큐먼트를 쿼리하면 새로운 메모를 읽어 올 수 있고, 가장 아래의 있는 메모의 _id 값보다 낮은 _id 를 갖는 메모를 쿼리하면 이전 메모를 읽어 올 수 있습니다.

(로딩된 메모를 배열 데이터로 state 에 저장했을 때, 새로운 메모 로딩은 배열의 첫 번째 원소보다 _id 값이 높은 메모를 쿼리, 이전 메모는 배열의 마지막 원소보다 낮은_id 값을 가진 메모를 쿼리하면 됩니다)

즉, 새로운 메모와 이전 메모를 로딩하기 위해선 기준이 되는 _id 값이 필요합니다.


작동 원리는 그림을 통해 살펴보도록 하겠습니다.



전체적인 내용은 메모읽기와 비슷합니다.

다른 점은 BACK-END API 에서의 라우트 경로에 파라메터가 두 개 있다는 점입니다.

API 는 url 로 들어온 listType 파라메터와 id 파라메터를 통해 DB 에 쿼리를 합니다. 

DB 에 memo 도큐먼트를 쿼리할 때 listType 이 new 면 id 파라메터를 기준으로 더 높은 _id 값을 가진 메모를 쿼리하고, old 면 id 파라메터를 기준으로 더 낮은 _id 값을 가진 메모를 쿼리합니다. 

Redux reuder 는 전달받은 action 객체의 listType 속성을 통해 원래의 state.list.data 배열에 앞부분에 새로운 데이터를 추가할 지 뒷부분에 새로운 데이터를 추가할지 결정합니다.

그리고 Home 컴포넌트에서 5초마다 새 메모를 로딩하고, 메모작성이 완료 됐을 때 다시 새 메모를 로딩하는 thunk 를 실행합니다.


데이터의 이동은 메모읽기와 같기 때문에 생략하겠습니다. (그림에 나타나 있습니다!)


2-1) BACK-END API 구현


url 파라메터를 통해 DB에 다르게 쿼리를 요청하는 API 를 작성해 보겠습니다.


(./server/routes/memo.js)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
/*
    READ ADDITIONAL (OLD/NEW) MEMO: GET /api/memo/:listType/:id
*/
router.get('/:listType/:id', (req, res) => {
    let listType = req.params.listType;
    let id = req.params.id;
 
    // CHECK LIST TYPE VALIDITY
    // url 을 통해 들어온 listType 파라메터가 old/new 둘 다 아닐경우
    if(listType !== 'old' && listType !== 'new') {
        return res.status(400).json({
            error: "INVALID LISTTYPE",
            code: 1
        });
    }
 
    // CHECK MEMO ID VALIDITY
    // 드러온 id 값이 mongodb 형식인지 조회
    if(!mongoose.Types.ObjectId.isValid(id)) {
        return res.status(400).json({
            error: "INVALID ID",
            code: 2
        });
    }
 
    let objId = new mongoose.Types.ObjectId(req.params.id);
 
    if(listType === 'new') {
        // GET NEWER MEMO
        Memo.find({ _id: { $gt: objId }})
        .sort({_id: -1}) //내림차순
        .limit(6)
        .exec((err, memos) => {
            if(err) throw err;
            return res.json(memos);
        });
    } else {
        // GET OLDER MEMO
        Memo.find({ _id: { $lt: objId }})
        .sort({_id: -1}) //오름차순이 아닌 내림차순이어야함. 정렬하는 순서는 같다 (home에서 보여질 때)
        .limit(6)
        .exec((err, memos) => {
            if(err) throw err;
            return res.json(memos);
        });
    }
});
cs


위의 코드는 '/api/memo/:listType/:id' 의 경로로 GET 방식으로 들어오는 요청에 대한 처리를 하는 API 입니다.

제일 먼저 코드의 5, 6 번째 줄에서 url 파라메터로 들어온 req.params.listType 과 req.params.id 값을 각각 listType, id 변수에 담습니다. 


그리고 10 번째 줄에서 listType 이 'old' 이거나 'new' 둘 중 하나인지 확인합니다. 만약 아니라면 HTTP status 400 과 함꼐 에러 객체를 리턴합니다.

* HTTP status 400 은 잘못된 요청이라는 의미입니다. (서버가 요청의 구문을 인식하지 못했다)


19 번째 줄에서는 id 값이 MongoDB 에 id 값인지 검사하는 mongoose.Types.ObjectId.isValid 메소드를 통해 url 로 들어온 id 값을 검사합니다. 만약 요청으로 들어온 id 값이 MongoDB 형식이 아니라면 HTTP 400 status 와 함께 에러 객체를 리턴합니다.


26 번째 줄에서는 new mongoose.Types.ObjectId 메소드를 통해 요청으로 들어온 id 값을 MongoDB id(_id) 형식으로 만들어 objId 에 할당했습니다. (DB 에 쿼리를 요청할 때 사용되는 _id)


28 번째 줄에서 listType 이 new 일 때 id 값을 기준으로 더 큰 id 의 메모 (최신의) 를 쿼리했습니다. 이 때 처음 메모를 로딩하는 것과 마찬가지로 내림차순, 6개의 데이터를 불러옵니다. (기존 데이터 배열에 새롭게 쿼리된 메모가 추가 될 때 new 는 배열의 앞쪽에 추가, 배열 안에서 똑같이 내림차순 데이터여야 순서가 꼬이지 않음)

37 번째 쭐은 listType 이 old 일때 쿼리이며 기준 id 보다 _id 값이 작은 (오래된) 메모를 불러옵니다.

두 경우 모두 실패하면 에러를 throw 하고, 성공하면 메모리스트 (배열) 을 리턴합니다.


2-2) Redux 구현


2-2-1) thunk 수정


리덕스에서 ActionTypes 는 첫 메모를 로딩하는 것과 같기 때문에 수정하지 않고, thunk 부분을 수정해 보도록 하겠습니다.


(./src/actions/memo.js)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
export function memoListRequest(isInitial, listType, id, username) {
    return (dispatch) => {
        // inform memo list API is starting
        dispatch(memoList());
 
        let url = '/api/memo';
 
        if(typeof username==="undefined") {
            // username not given, load public memo
            url = isInitial ? url : `${url}/${listType}/${id}`; // 처음로딩이면 '/api/memo', 아니면 /api/memo/listType/id
            // or url + '/' + listType + '/' +  id
        } else {
          // load memos of a user
          url = isInitial ? `${url}/${username}` : `${url}/${username}/${listType}/${id}`;
        }
 
        return axios.get(url)
        .then((response) => {
            dispatch(memoListSuccess(response.data, isInitial, listType));
        }).catch((error) => {
            dispatch(memoListFailure());
        });
    };
}
cs


기존의 thunk 코드에서 8~15 번째 줄이 추가 되었습니다.

기존의 thunk 는 axois 를 통해 BACK-END 와 통신할 때의 url 경로가 '/api/memo' 로 하나 였습니다. (코드의 6 번째 줄)

하지만 추가 로딩은 url 에 listType 과 id 파라메터가 들어가 주어야 하므로 추가했습니다.

만약 thunk 의 인자에 listType 값이 new 로, id 값이 기준 memo._id 값이 들어오고 username 이 undefined 로 들어오게 되면 코드의 8 번째 줄의 조건문에 의하여 url 값이 '/api/memo/new/memo._id' 가 되고, 해당 url 로 axios 통신을 시도합니다.

* username 이 thunk 의 인자로 들어오는 12 번째 코드는 추후에 담벼락 기능에서 사용할 코드입니다. 이해하지 않고 넘어가셔도 좋습니다.


2-2-2) Reducer 함수


Reducer 함수에서 메모 데이터를 가져오는데 성공했을 때 state 를 변경하는 부분을 살펴봅시다.


(./src/reducers/memo.js)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
...
case types.MEMO_LIST_SUCCESS:
        if(action.isInitial) {
            return {
              ...state,
              list: {
                ...state.list,
                status: 'SUCCESS',
                data: action.data,
                isLast: action.data.length < 6
              }
            }
        } else {
            if(action.listType === 'new') { // 배열의 앞부분에 추가
                return {
                  ...state,
                  list: {
                    ...state.list,
                    status: 'SUCCESS',
                    data: [...action.data, ...state.list.data]
                  }
                }
            } else { //action.listType === 'older' //배열의 뒷부분에 추가
                return {
                  ...state,
                  list: {
                    ...state.list,
                    status: 'SUCCESS',
                    data: [...state.list.data, ...action.data],
                    isLast: action.data.length < 6
                  }
                }
            }
        }
        return state;
...
cs


코드가 변한 것은 없지만 첫 메모로딩에서 설명하지 않았기에 설명을 추가하도록 하겠습니다.

reduer 가 전달 받은 action.type 값이 MEMO_LIST_SUCCESS 일 때의 코드로, 3 번째 줄은 첫 메모 로딩의 경우이기 때문에 설명하지 않고 넘어가겠습니다.

13 번째 줄은 전달받은 action 객체의 isInitial 값이 false 인 경우(첫 로딩이 아니라 추가로딩)며, 그 안에서 listType 이 new 거나 old 일 때 state 를 다르게 변경한다는 코드입니다. new 일 때는 list.data 배열의 앞 쪽에 새로 로딩된 데이터를 추가하며, old 일 때는 list.data 배열의 뒷쪽에 새로 생성된 데이터를 추가합니다. (코드의 20, 29 번째 줄)

새로 로딩한 데이터가 이전 메모일 경우에, 더 로딩 할 것이 있는지 체크하기 위해 list.isLast 값을 주었습니다. (30 번째 줄)

* 새로 로딩된 데이터 배열의 길이가 6 이하면 더 이상 로딩할 메모가 없다. (마지막 데이터다)


2-3) Home 컨테이너에서 5초마다 새 메모 로딩 구현


Home 컨테이너 컴포넌트에서 새롭게 작성되는 메모를 바로 받아 보기 위해서 5초마다 새로운 메모를 로딩하는 메소드를 실행합니다.


(./src/containers/Home.js)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
...
    loadNewMemo() {
        // CANCEL IF THERE IS A PENDING REQUEST
        if(this.props.listStatus === 'WAITING'
            return new Promise((resolve, reject)=> {
                resolve();
            });
        
        // IF PAGE IS EMPTY, DO THE INITIAL LOADING
        if(this.props.memoData.length === 0 )
            return this.props.memoListRequest(true);
            
        return this.props.memoListRequest(false'new', this.props.memoData[0]._id);
    }
...
    componentDidMount() {
        // LOAD NEW MEMO EVERY 5 SECONDS
        const loadMemoLoop = () => {
            this.loadNewMemo().then(
                () => {
                    this.memoLoaderTimeoutId = setTimeout(loadMemoLoop, 5000);
                }
            );
        };
        
        this.props.memoListRequest(true).then(
            () => {
                // BEGIN NEW MEMO LOADING LOOP
                loadMemoLoop();
            }
        );
    }
...
    componentWillUnmount() {
        // STOPS THE loadMemoLoop
        clearTimeout(this.memoLoaderTimeoutId);
    }
...
cs


코드의 두 번째 줄에서 새로운 메모를 로딩하는 loadNewMemo 메소드를 정의 했습니다.

코드의 4 번째 줄과 같이 메모 요청상태가 'WAITING' 일때는 메모를 로딩하지 않도록 했습니다.

* 여기서 취소할 때 그냥 return 을 한것이 아니라 비어있는 Promise 객체를 리턴했습니다. 그 이유는 Write 에서 사용할 handlePost 메소드 안에서 loadNewMemo 메소드를 사용하고 .then 을 사용할 수 있도록 하기 위함입니다. 

(Wirte 컴포넌트에서 handlePost 컴포넌트를 사용하고 메모 작성에 성공하면 성공 메시지를 띄우고 Write 의 입력 내용을 초기화 하는데, 이때 그냥 return 을 하게되면 요청이 중첩 됐을 때 내용을 초기화 하는 코드가 작동하기 이전에 return 되어 초기화가 되지 않을 수도 있습니다.)

코드의 10 번째 줄에서는 API 로 부터 전달받은 memo data 값이 없을 경우 새 메모를 로딩하는 것이 아니라 초기 로딩을 수행하도록 하는 코드입니다.

위의 두 경우가 아니라면 새 메모를 요청하는 thunk 를 실행합니다. (인자 값을 다르게 줌으로 인해 다른 API 에 접근)


코드의 18 번째 줄에서 컴포넌트가 렌더링 된 이후에 5초마다 새로운 메모를 로딩하는 코드를 정의 했습니다.

26 번째 줄은 컴포넌트가 렌더링 된 이후에 첫 메모 로딩을 실시하고, 이후에 5초마다 새 메모를 로딩하는 loadMemoLoop 함수를 실행하도록 했습니다.

34 번째 줄은 컴포넌트가 unmount 되야할 상황에 메모 루프 동작을 멈추는 코드입니다.


2-4) 메모 작성시, 새 메모를 로딩하도록 구현하기


Home 컨테이너에서 Write 컴포넌트로 전달해주는 props 인 handlePost 메소드를 수정합니다.


(./src/containers/Home.js)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
...
    handlePost = (contents) => {
        return this.props.memoPostRequest(contents).then(
            () => {
                if(this.props.postStatus.status === "SUCCESS") {
                    // TRIGGER LOAD NEW MEMO
                    this.loadNewMemo().then(
                        () => {
                            Materialize.toast('Success!'2000);
                        }
                    );
                } else {
                    /*
                      ERROR CODES
                          1: NOT LOGGED IN
                          2: CONTENTS IS NOT STRING
                          3: EMPTY CONTENTS
                    */
                    let $toastContent;
                    switch(this.props.postStatus.error) {
                        case 1:
                            // IF NOT LOGGED IN, NOTIFY AND REFRESH AFTER
                            $toastContent = $('<span style="color: #FFB4BA">You are not logged in</span>');
                            Materialize.toast($toastContent, 2000);
                            setTimeout(()=> {location.reload(false);}, 2000);
                            break;
                        case 2:
                            $toastContent = $('<span style="color: #FFB4BA">Contents should be string</span>');
                            Materialize.toast($toastContent, 2000);
                            break;
                        case 3:
                            $toastContent = $('<span style="color: #FFB4BA">Please write Something</span>');
                            Materialize.toast($toastContent, 2000);
                            break;
                        default:
                            $toastContent = $('<span style="color: #FFB4BA">Something Broke</span>');
                            Materialize.toast($toastContent, 2000);
                            break;
                    }
                }
            }
        );
    }
...
cs


코드의 7~11 번째 줄이 수정된 부분입니다. 

이는 메모를 작성하고 state 가 변경된 시점에서 (this.props.postStatus.status === "SUCCESS") 새 메모를 로딩하는 코드를 실행합니다. 새 메모를 로딩한 이후에는 'Sucess!' 알림을 띄우도록 했습니다.


서버를 재시작해 메모를 작성한 뒤에 작성한 메모가 바로 렌더링 되는지 확인해 보세요!


3. 무한스크롤링 구현하기


다음으로 구현할 기능은 무한스크롤링 입니다.

무한스크롤링은 페이스북이나 인스타그램과 같은 어플리케이션에서 스크롤을 내릴때 마다 이전의 컨텐츠를 새로 로딩하여 보여주는 것을 의미합니다.


이를 구현하는 것은 scroll 이벤트를 통해 진행하는데요, 이를 jQuery 로 구현하는 방법에 대해 제가 포스팅 한 글이 있습니다.

아래의 링크를 통해 무한 스크롤링을 구현하는 원리와 방법에 대해서 읽어보고 우리의 Memo 어플리케이션에 적용해 보도록 합시다!

<jQuery 의 scroll 이벤트를 통해 무한스크롤링 구현하는 방법>


3-1) Home 컨테이너에 스크롤 리스너 작성


(./src/containers/Home.js)

1
2
3
4
5
6
7
8
9
10
11
...
componentDidMount() {
    ...
    $(window).scroll(() => {
        // WHEN HEIGHT UNDER SCROLLBOTTOM IS LESS THEN 250
        if ($(document).height() - $(window).height() - $(window).scrollTop() < 250) {
            console.log("LOAD NOW");
        }
    });                
}
...
cs


Home 컨테이너 컴포넌트의 componentDidMount 부분에 위의 코드를 추가해 줍시다.

6 번째 줄에서 window 의 scroll 이벤트에 도큐먼트의 높이 - 창의 높이 - 스크롤의 위치 의 값이 250 보다 작아졌을 경우에 콘솔창에 "LOAD NOW" 를 출력하도록 하는 코드를 작성했습니다.


브라우저 창에서 스크롤을 해서 콘솔창에 제대로 뜨는지 확인해 봅시다.



위의 스크린샷과 같이 스크롤이 아래쪽에 다다랐을 때 브라우저 콘솔창에 "LOAD NOW" 가 뜨는 것을 알 수 있습니다.

하지만 문제가 있습니다. 저희가 원하는 동작은 스크롤이 해당 구간에 들어갔을 때 한 번만 LOAD NOW 를 출력하는 것이지만 위의 상태는 해당 구간안에서 움직임이 있을 때 마다 출력되서 너무 많이 출력되고 있는 것이죠.

이는 컴포넌트 state 를 통해 간단히 해결 가능합니다. (Home 컨테이너의 컴포넌트 state)


(./src/containers/Home.js)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
...
    state = {
      loadingState: false
    };
    ...
    componentDidMount() {
        ...
        $(window).scroll(() => {
            // WHEN HEIGHT UNDER SCROLLBOTTOM IS LESS THEN 250
            if ($(document).height() - $(window).height() - $(window).scrollTop() < 250) {
                if(!this.state.loadingState){
                    console.log("LOAD NOW");
                    this.setState({
                        loadingState: true
                    });
                }
            } else {
                if(this.state.loadingState){
                    this.setState({
                        loadingState: false
                    });
                }
            }
        });
...
cs


코드의 2 번째 줄과 같이 컴포넌트 class 안에서 state 를 정의합니다.

그리고 스크롤 이벤트안에서 스크롤이 아랫쪽에 다다랐을 때, this.state.loadingState 의 값이 false 일 때 콘솔창에 "LOADING NOW" 를 찍고 바로 this.state.loading state 를 ture 로 변경합니다. 이 코드 덕분에 스크롤이 아랫쪽 범위 안에서 움직여도 this.state.loadingState 값이 true 이기 때문에 콘솔창에는 아무 글자도 찍히지 않습니다.

만약 스크롤이 아랫쪽 범위 밖에 있다면 this.state.loadingState 를 다시 false 로 변경하여 스크롤이 다시 아랫쪽 범위에 다다랐을때 콘솔창에 글자를 찍도록 설정했습니다.


브라우저 창을 통해 확인해 보세요. 어때요? LOADING NOW 가 스크롤이 위에서 아래 범위에 다다랐을때 한번만 잘 찍히나요?


3-2) 실제 기능 구현


스크롤 이벤트에 콘솔에 글자를 찍는 대신에 DB 로 부터 이전 메모를 불러오는 코드를 작성해 줍시다.


(./src/containers/Home.js)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
...
    loadOldMemo = () => {
      // CANCEL IF USER IS READING THE LAST PAGE
      if(this.props.isLast) {
          return new Promise(
              (resolve, reject)=> {
                  resolve();
              }
          );
      }
 
      // GET ID OF THE MEMO AT THE BOTTOM
      let lastId = this.props.memoData[this.props.memoData.length - 1]._id;
 
      // START REQUEST
      return this.props.memoListRequest(false'old', lastId, this.props.username).then(() => {
          // IF IT IS LAST PAGE, NOTIFY
          if(this.props.isLast) {
              Materialize.toast('You are reading the last page'2000);
          }
      });
  }
...
    componentDidMount() {
        /* CODES */
        
        $(window).scroll(() => {
            // WHEN HEIGHT UNDER SCROLLBOTTOM IS LESS THEN 250
            if ($(document).height() - $(window).height() - $(window).scrollTop() < 250) {
                if(!this.state.loadingState){
                    this.loadOldMemo();
                    this.setState({
                        loadingState: true
                    });
                }
            } else {
                if(this.state.loadingState){
                    this.setState({
                        loadingState: false
                    });
                }
            }
        });
                           
    }
...
    componentWillUnMount() {
        // STOPS THE loadMemoLoop
        clearTimeout(this.memoLoaderTimeoutId);
        
        // REMOVE WINDOWS SCROLL LISTENER
        $(window).unbind();
    }
...
cs


코드의 2 번째 줄에서 이전 메모를 로딩하는 메소드를 정의했습니다.

4 번째 줄을 메소드가 실행 됐을 때 이미 마지막까지 로딩 되었다면 요청을 취소하는 코드입니다.

* loadNewMemo 와 마찬가지로 메소드를 사용하고 .then() 을 사용할 수 있도록 빈 Promise 를 리턴합니다.

13 번째 줄은 state 에 저장된 메모리스트의 마지막 원소의 _id 값을 lastId 변수에 담는 코드입니다.

16 번째 줄은 이전 메모를 로딩하는 thunk (인자로 이전 메모임을 결정) 를 실행하는 코드입니다. (마지막 인자는 상위 컴포넌트로부터 전달 받는 username props 이며 현재 전달받는 값이 없으므로 자동으로 undefined 가 됩니다.) 이전 메모 로딩을 실행하고 API 로 부터 전달받은 메모리스트 배열의 길이가 6 이하라면 마지막 페이지라는 알림을 띄웁니다.


코드의 31 번째 줄에서 이전 메모를 로딩하는 메소드 loadOldMemo (2번째 줄에서 정의한) 를 실행합니다.


코드의 52 번째 줄에서 컴포넌트가 언마운트 되야할 상황에서 스크롤 리스너를 제거하는 unbind 코드를 추가했습니다.


메모를 여러개 작성한 이후에 제대로 작동하는지 확인해 봅시다.

어때요 잘 동작하나요?


3-3) 스크롤바가 없을 경우...


위의 코드를 통해 무한스크롤링을 통해 DB 에서 이전 메모를 계속해서 불러올 수 있게 되었습니다.


만약 해상도가 매우 높거나 하는 상황때문에 처음 6개의 메모를 로딩했는데 스크롤바가 생기지 않는다면 어떡할까요?

위에서 구현한 코드는 윈도우에 scroll 이벤트를 통해 구현했기 때문에 스크롤바가 없다면 아예 작동하지 않습니다.

어떻게 하면 이 문제를 해결할 수 있을까요?


해결방법은 다음과 같습니다. 초기 로딩을 진행한 다음에 스크롤바가 생겼는지를 체크하고, 생기지 않았다면 스크롤바가 생길 때 까지 이전 메모를 불러오면 됩니다.

스크롤바의 존재여부를 체크하려면 바디태그의 높이와 윈도우(창)의 높이를 비교하면 됩니다.


( $("body").height() < $(window).height() )


(./src/containers/Home.js)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
...
    componentDidMount() {
        ...
        
        const loadUntilScrollable = () => {
            // IF THE SCROLLBAR DOES NOT EXIST,
            if($("body").height() < $(window).height()) {
                this.loadOldMemo().then(
                    () => {
                        // DO THIS RECURSIVELY UNLESS IT'S LAST PAGE
                        if(!this.props.isLast) {
                            loadUntilScrollable();
                        }
                    }
                );
            }
        };
        
        this.props.memoListRequest(true).then(
            () => {
                // BEGIN NEW MEMO LOADING LOOP
                loadUntilScrollable();
                loadMemoLoop();
            }
        );
        ...                   
    }
...
cs


위의 코드의 5 번째 줄에서 componentDidMount 안에서 loadUntilScrollable 함수를 정의했습니다.

함수의 내용은 바디태그와 창의 높이를 비교해 창이 더 크다면(스크롤이 없다면) 이전 메모를 실행하고, 마지막 페이지가 되기 전까지 (마지막페이지가 아닐경우 실행) loadUntilScrollable 를 실행하는 것입니다.

코드의 19 번째 줄은 첫 로딩을 실시한 후 위에서 정의한 loadUntilScrollable 를 실행하는 코드입니다.

(만약 창이 있는 경우 아무것도 실행되지 않습니다)


여기까지...


메모를 읽어 뷰에 렌더링하는 기능을 구현해 보았습니다.

상황에 따라 메모를 다양하게 읽어오는 메소드를 사용하였습니다.

* 무한 스크롤링을 할 때는 이전 메모를 불러오고, 메모를 작성하면 새 메모를 불러오고... 등등

역시 가장 중요한 내용은 기능이 어떤 방식으로 동작하는지입니다.

이해가 되지 않는 부분은 천천히 원리를 이해하면서 따라오시면 될 것 같습니다.

제 설명이 뛰어나지 못한 점 죄송스럽게 생각하고 있습니다... ㅜㅜ


부족한 설명에도 여기까지 따라와 주셔서 감사드리며, 다음 포스팅에서는 메모의 수정 및 삭제 기능을 구현해 보도록 하겠습니다.


감사합니다.


**참고 자료 (항상 감사드립니다)

<React.js codelab 2016 - Velopert>


*이 포스팅이 도움이 되셨다면 다녀가셨다는 표시로 공감 부탁드릴게요! (로그인 하지 않으셔도 공감은 가능합니다 ㅎㅎ)

Comments