함께 성장하는 프로독학러

Memo_app 07. 메모(Memo) - Update, Delete 기능 구현 본문

Programming/tutorials

Memo_app 07. 메모(Memo) - Update, Delete 기능 구현

프로독학러 2018. 6. 27. 06:46

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


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

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

(이 중 셋, 네 번째인 Update, Delete 를 구현하도록 하겠습니다)


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

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

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

<React.js codelab 2016 - Velopert>


1) Update (수정) 기능 구현


이번 포스팅에서 먼저 구현할 내용은 이미 작성된 메모의 내용을 수정하는 기능입니다.

수정 기능이 동작하는 원리는 다음 그림과 같습니다.



가장 먼저 살펴볼 것은 BACK-END 의 API 입니다.

BACK-END 의 API 는 PUT: /api/memo/:id 로 접근한 요청에 대해서 처리하는 라우터로, 전달받은 데이터와 url 로 들어온 id 파라메터 등을 검사해 수정할 수 있는 상황인지 파악하고, 수정할 수 있는 상황이면 DB 를 수정하고 성공객체를 리턴합니다.

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

리덕스에서는 API 와 통신을 주고받고 그 결과에 따라 다른 액션객체를 리듀서에 전달하는 thunk 함수를 정의합니다. 리듀서는 전달받은 액션객체의 유형에 따라 리덕스 state 를 변경합니다.

세 번째로 살펴볼 것은 Home, MemoList, Memo 컴포넌트입니다.

Home 컴포넌트에 리덕스를 연결하여 thunk 함수를 사용할 수 있게한 뒤, 해당 thunk 함수를 실행시키고 이에 따라 사용자측에서 동작하는 코드를 포함한 메소드를 정의합니다. 그리고 해당 메소드를 MemoList 컴포넌트를 거쳐 Memo 컴포넌트에 전달합니다.

Memo 컴포넌트에서는 thunk 의 인자로 들어갈 (수정될 내용 등) 값을 결정하여 실행합니다.


데이터의 이동은 다음 과정을 거칩니다.

1. Redux 에서 정의한 thunk 함수와 리덕스 state 를 connect 를 통해 Home 컨테이너로 전달.

2. Home 컨테이너에서 thunk 함수를 실행하는 메소드를 정의해 MemoList 컴포넌트로 전달.

3. MemoList 컴포넌트에서 전달 받은 메소드를 그대로 전달

4, 5, 6. Memo 컴포넌트에서 전달받은 메소드의 인자들을 결정해서 실행하면 API 에 데이터가 전달됨.

7. API 가 상황에 따라 에러객체나 성공 객체를 리턴

8. 리턴받은 객체에 따라 리덕스 state 가 변경되고 해당 값이 Home 컨테이너로 전달됨.


1-1) BACK-END API 구현


그럼 가장 먼저 BACK-END 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
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
78
79
80
81
82
83
84
85
86
87
88
// MODIFY MEMO
/*
    MODIFY MEMO: PUT /api/memo/:id
    BODY SAMPLE: { contents: "sample "}
    ERROR CODES
        1: INVALID ID,
        2: CONTENTS IS NOT STRING
        3: EMPTY CONTENTS
        4: NOT LOGGED IN
        5: NO RESOURCE
        6: PERMISSION FAILURE
*/
router.put('/:id', (req, res) => {
 
    // CHECK MEMO ID VALIDITY
    // url 파라메터 값으로 전달받은 id값이 몽고db 형식인지 검사
    if(!mongoose.Types.ObjectId.isValid(req.params.id)) {
        return res.status(400).json({
            error: "INVALID ID",
            code: 1
        });
    }
 
    // CHECK CONTENTS VALID
    // 수정할 내용이 문자열이 아닌 경우
    if(typeof req.body.contents !== 'string') {
        return res.status(400).json({
            error: "CONTENTS IS NOT STRING",
            code: 2
        });
    }
 
    // 수정할 내용이 비어있는 경우
    if(req.body.contents === "") {
        return res.status(400).json({
            error: "EMPTY CONTENTS",
            code: 3
        });
    }
 
    // CHECK LOGIN STATUS
    // 세션을 통해 로그인 여부 확인
    if(typeof req.session.loginInfo === 'undefined') {
        return res.status(403).json({
            error: "NOT LOGGED IN",
            code: 4
        });
    }
 
    // FIND MEMO
    // id 로 도큐먼트 조회
    Memo.findById(req.params.id, (err, memo) => {
        if(err) throw err;
 
        // IF MEMO DOES NOT EXIST
        // id는 몽고db 형식이지만 메모가 없을 경우
        if(!memo) {
            return res.status(404).json({
                error: "NO RESOURCE",
                code: 5
            });
        }
 
        // IF EXISTS, CHECK WRITER
        // 검색된 메모의 작성자와 로그인된 데이터가 다른 경우 - 권한 없음
        if(memo.writer != req.session.loginInfo.username) {
            return res.status(403).json({
                error: "PERMISSION FAILURE",
                code: 6
            });
        }
 
        // MODIFY AND SAVE IN DATABASE
        // 결격사항이 없을 경우 메모를 수정하고 DB 에 저장
        memo.contents = req.body.contents;
        memo.date.edited = new Date();
        memo.is_edited = true;
 
        memo.save((err, memo) => {
            if(err) throw err;
            return res.json({
                success: true,
                memo
            });
        });
 
    });
});
cs


router.put('/:id', ...) 의 라우터를 위와 같이 수정합니다.

해당 API 는 PUT 방식으로 들어오는 '/api/memo/:id' 에 대한 요청을 처리하는 라우터로, req.body 로 { contents: "sample" } 과 같은 데이터를 전달받습니다.


코드의 17 번째 줄은 url 파라메터로 들어온 id 값이 MongoDB 형식인지 검사하는 코드입니다.

26 번째 줄과 34 번째 줄은 req.body.contents 의 내용이 유효한지 검사합니다.

위의 세 경우 문제가 있다면 HTTP status 400 과 함께 에러 객체를 리턴합니다.

* HTTP status 400 은 잘못된 요청을 의미합니다.


43 번째 줄은 세션을 통해 로그인 여부를 확인하는 코드입니다. 만약 로그인 상태가 아니라면 HTTP status 403 과 함께 에러 객체를 리턴합니다.

* HTTP status 403 은 금지됨을 의미합니다. (사용자가 권한이 없기 때문에 요청을 거부함)


위의 에러객체를 리턴하는 경우가 아니라면 52 번째 줄에서 url 파라메터로 들어온 id 값을 통해 memo 를 쿼리합니다.

57 번째 줄은 id 는 MongoDB 형식이지만 해당 _id 값을 가진 메모가 없을 경우이며 HTTP status 404 와 함께 에러객체를 리턴합니다.

* HTTP status 404 는 찾을 수 없음을 의미합니다.

66 번째 줄은 메모 작성자와 로그인 정보가 다른 상황이며, 이때는 HTTP status 403 과 함께 에러 객체를 리턴합니다.

* HTTP status 403 은 금지됨을 의미합니다. (사용자가 권한이 없기 때문에 요청을 거부함)

코드가 75 번째 줄까지 진행되면 (에러가 없으면) 쿼리된 memo 를 수정하고 수정된 값을 DB 에 저장합니다.

그리고 81 번째 줄과 같이 성공 객체를 리턴합니다. 이때 리턴되는 객체의 memo 값은 수정된 memo 데이터 입니다. 


1-2) Redux 구현


1-2-1) ActionTypes 추가


메모 수정에 관련된 ActionTypes 를 추가해줍니다.


(./src/actions/ActionTypes.js)

1
2
3
4
// Edit memo
export const MEMO_EDIT = "MEMO_EDIT";
export const MEMO_EDIT_SUCCESS = "MEMO_EDIT_SUCCESS";
export const MEMO_EDIT_FAILURE = "MEMO_EDIT_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
import {
    ...,
    MEMO_EDIT,
    MEMO_EDIT_SUCCESS,
    MEMO_EDIT_FAILURE
} from './ActionTypes';
...
/* MEMO EDIT */
export function memoEditRequest(id, index, contents) {
    return (dispatch) => {
        dispatch(memoEdit());
 
        return axios.put('/api/memo/' + id, { contents })
        .then((response) => {
            dispatch(memoEditSuccess(index, response.data.memo));
        }).catch((error) => {
            dispatch(memoEditFailure(error.response.data.code));
        });
    };
}
 
export function memoEdit() {
    return {
        type: MEMO_EDIT
    };
}
 
export function memoEditSuccess(index, memo) {
    return {
        type: MEMO_EDIT_SUCCESS,
        index,
        memo
    };
}
 
export function memoEditFailure(error) {
    return {
        type: MEMO_EDIT_FAILIURE,
        error
    };
}
 
cs


15, 21, 29 번째 줄은 액션 생성자 함수입니다. 각각 진행중, 성공, 실패를 알리는 액션객체를 리턴합니다. 성공 객체에는 index 값과 memo 값이 포함되어 있는데, 이는 각각 메모 리스트 배열의 index 값과 원소를 의미합니다.

두 번째 줄은 thunk 함수로, 상황에 따라 다른 액션 객체를 리듀서로 전달시키는 역할을 합니다.

thunk 가 실행되면 진행중이라는 객체를 전달하고(4 번째 줄),

API 와 통신 후 성공하면 성공객체를 전달(8 번째 줄),

메모를 수정하는데 실패하면 실패 객체를 리턴합니다. (10 번째 줄)


1-2-3) 리듀서 구현


(./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
...
    case types.MEMO_EDIT:
        return {
          ...state,
          edit: {
            ...state.edit,
            status: 'WAITING',
            error: -1,
            memo: undefined
          }
        };
    case types.MEMO_EDIT_SUCCESS:
        let editBefore = state.list.data.slice(0, action.index);
        let editAfter = state.list.data.slice(action.index+1);
        return {
          ...state,
          edit: {
            ...state.edit,
            status: 'SUCCESS'
          },
          list: {
            ...state.list,
            data: [...editBefore, action.memo, ...editAfter]
          }
        };
    case types.MEMO_EDIT_FAILURE:
        return {
          ...state,
          edit: {
            ...state.edit,
            status: 'FAILURE',
            error: action.error
          }
        };
...
cs


리듀서는 전달받은 action 객체의 type 값에 따라 state 를 업데이트 하는 역할을 수행합니다.

state.edit.status 를 통해 메모의 수정이 잘 이루어 졌는지 확인할 수 있고, 수정이 잘 되면 수정된 객체를 배열안에서 원래의 자리로 다시 집어 넣어 줘야 합니다.

이 때 배열의 slice 메소드와 ES6 의 spread operator 가 사용되었습니다.

* slice 메소드에 익숙하지 않으시거나 ES6 의 spread operator 에 익숙하지 않으신 분들은 아래의 링크들을 참조해주세요.

<배열의 slice 메소드>

<ES6 의 spread operator>


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


리덕스에 정의한 thunk 함수와 메모 수정 상태를 알 수 있는 state.mem.edit 을 Home 컨테이너에 연결합니다.


(./src/containers/Home.js)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
...
import { memoPostRequest, memoListRequest, memoEditRequest } from 'actions/memo';
...
const mapStateToProps = (state) => {
    return {
        ...,
        editStatus: state.memo.edit
    };
};
...
const mapDispatchToProps = (dispatch) => {
    return {
        ...,
        memoEditRequest: (id, index, contents) => {
            return dispatch(memoEditRequest(id, index, contents));
        }
    };
};
...
cs


코드의 두 번째 줄에서 memoEditRequest (thunk) 를 import 했습니다.

7 번째 줄과 14 번째 줄을 통하여 Home 컨테이너에서 메모 수정 상태와 thunk 함수를 사용할 수 있게 되었습니다.


1-4) Home 컨테이너에서 thunk 실행 메소드 정의


Home 컨테이너에서 thunk 실행 메소드 정의해서 MemoList 컴포넌트를 거쳐 Memo 컴포넌트로 전달해 주도록 하겠습니다.


(./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
...
    handleEdit = (id, index, contents) => {
        return this.props.memoEditRequest(id, index, contents).then(
            () => {
                if(this.props.editStatus.status==="SUCCESS") {
                    Materialize.toast('Success!'2000);
                } else {
                    /*
                      ERROR CODES
                          1: INVALID ID,
                          2: CONTENTS IS NOT STRING
                          3: EMPTY CONTENTS
                          4: NOT LOGGED IN
                          5: NO RESOURCE
                          6: PERMISSION FAILURE
                    */
                    let errorMessage = [
                        'Something broke',
                        'Contents should be string',
                        'Please write something',
                        'You are not logged in',
                        'That memo does not exist anymore',
                        'You do not have permission'
                    ];
 
                    let error = this.props.editStatus.error;
 
                    // NOTIFY ERROR
                    let $toastContent = $('<span style="color: #FFB4BA">' + errorMessage[error - 1+ '</span>');
                    Materialize.toast($toastContent, 2000);
 
                    // IF NOT LOGGED IN, REFRESH THE PAGE AFTER 2 SECONDS
                    if(error === 4) {
                        setTimeout(()=> {location.reload(false)}, 2000);
                    }
 
                }
            }
        );
    }
...
    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}
                      onEdit={this.handleEdit}/>
          </div>
      );
    }
...
cs


코드의 두 번째 줄에서 thunk 함수를 실행하는 메소드인 handleEdit 메소드를 정의했습니다.

메소드의 내용은 다음과 같습니다.

thunk 의 실행이 끝나서 state 가 업데이트 된 시점에서 this.props.editStatus.status==="SUCCESS" 라면 수정에 성공했다는 알림을 띄우고,

실패 했다면 error 코드에 따라 어떤 문제가 있는지 알려주는 알림창을 띄웁니다. 추가적으로 로그인이 안 된 상태라면 2초 뒤에 페이지를 새로고침 합니다. (34 번째 줄)

* location.reload(false) 는 페이지를 캐시로 부터 새로고침하는 메소드 (window 메소드)


49 번째 줄에서 MemoList 컴포넌트로 handleEdit 메소드를 onEdit 프롭스로 전달합니다.


1-5) MemoList 컴포넌트에서 Memo 컴포넌트로 메소드 전달


Home 으로 부터 전달받은 onEdit 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
38
39
40
41
42
43
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}
              onEdit={this.props.onEdit}
              />
          );
        })
      }
      return (
        <div>
          { mapToComponents(this.props.data) }
        </div>
      );
    }
}
 
MemoList.propTypes = {
    data: PropTypes.array,
    currentUser: PropTypes.string,
    onEdit: PropTypes.func
};
 
MemoList.defaultProps = {
    data: [],
    currentUser: '',
    onEdit: (id, index, contents) => {
        console.error('edit function not defined');
 
    }
};
 
export default MemoList;
cs


상위 컴포넌트로 부터 props 를 전달 받았으므로 propTypes 와 defaultProps 를 지정해줍니다. (31, 37 번째 줄)

컴포넌트 매핑을 할 때 Memo 컴포넌트에 onEdit 프롭스로 this.props.onEdit 을 그대로 전달해 줍니다.(15 번째 줄)


1-6) Memo 컴포넌트에서 thunk 실행


Memo 컴포넌트에서는 컴포넌트 state 를 통해 memo.contents 부분에 수정될 내용을 만듭니다. (thunk 함수의 세 번째 인자)


이를 구현하기에 앞서 Memo 컴포넌트의 뷰를 조금 수정해 보도록 하겠습니다.

수정할 내용은 Memo 컴포넌트 안에서 수정 버튼을 눌러 수정 모드로 들어가면 메모의 내용이 표시되는 부분이 Write 컴포넌트와 비슷하게 변하도록 하는 것입니다.


(./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
...
    state = {
      editMode: false
    };
 
    toggleEdit = () => {
        this.setState({
            editMode: !this.state.editMode
        });
    }
...
    render() {
        const dropDownMenu = (
            /* CODES */
                <ul id={`dropdown-${this.props.data._id}`} className='dropdown-content'>
                    <li><a onClick={this.toggleEdit}>Edit</a></li>
                     /* CODES */
        );
        
        const memoView = (
             /* CODES */
        );
        
        const editView = (            
            <div className="write">
                <div className="card">
                    <div className="card-content">
                        <textarea
                            className="materialize-textarea"></textarea>
                    </div>
                    <div className="card-action">
                        <a onClick={this.toggleEdit}>OK</a>
                    </div>
                </div>
            </div>
        );        
        return (
            <div className="container memo">
                { this.state.editMode ? editView : memoView }
            </div>
        );
    }
...
cs


코드의 두 번째 줄에서 컴포넌트 state.editMode 를 정의 했습니다. 

이 값이 true 면 editView 를 렌더링하고, false 면 memoView 를 렌더링 합니다. (39 번째 줄)

6 번째 줄에서는 실행 될 때 마다 this.state.editMode 의 값이 반대 값으로 변하는 메소드를 정의 했습니다.

this.state.editMode 의 기본 값은 false 이므로 16 번째 줄의 Eidt 버튼을 누르면 this.state.editMode 값이 true 가 되어 editView 를 렌더링합니다.

editView 에서 수정 확인 버튼을 누르면 this.state.editMode 값은 false 가 되므로 다시 memoView 가 렌더링 됩니다.


그럼 Memo 컴포넌트에서 thunk 의 인자를 결정해 실행하는 기능을 구현하도록 하겠습니다.


(./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
...
    state = {
      editMode: false,
      value: this.props.data.contents
    }
...
    toggleEdit = () => {
        if(this.state.editMode) {
            let id = this.props.data._id;
            let index = this.props.index;
            let contents = this.state.value;
            
            this.props.onEdit(id, index, contents).then(() => {
                this.setState({
                    editMode: !this.state.editMode
                });
            })
        } else {
            this.setState({
                editMode: !this.state.editMode
            });   
        }
    }
...
    handleChange = (e) => {
        this.setState({
            value: e.target.value
        });
    }
...
    render() {
        ...        
        const editView = (
            <div className="write">
                <div className="card">
                    <div className="card-content">
                        <textarea
                            className="materialize-textarea"
                            value={this.state.value}
                            onChange={this.handleChange}></textarea>
                    </div>
                    <div className="card-action">
                        <a onClick={this.toggleEdit}>OK</a>
                    </div>
                </div>
            </div>
        );
...
Memo.propTypes = {
    data: PropTypes.object,
    ownership: PropTypes.bool,
    onEdit: PropTypes.func,
    index: PropTypes.number
};
 
Memo.defaultProps = {
    data: {
        _id: 'id1234567890',
        writer: 'Writer',
        contents: 'Contents',
        is_edited: false,
        date: {
            edited: new Date(),
            created: new Date()
        },
        starred: []
    },
    ownership: true,
    onEdit: (id, index, contents) => {
        console.error('onEdit function not defined');
    },
    index: -1
}
...
cs


먼저 코드의 49~73 번째 줄과 같이 들어온 props 의 propTypes 와 defaultProps 를 지정해 주었습니다.


코드의 두 번째 줄에서 state.value key 를 설정하고 값으로 props 로 전달받은 data.contents 를 지정했습니다.

이 값은 수정 화면으로 변했을 때 textarea 안에 값이 들어와 있는 상태로 만들기 위한 값입니다.


코드의 25 번째 줄에서는 handleChange 메소드를 정의 했습니다. 이는 textarea 값에 입력된 값을 state.value 값으로 변경하는 메소드입니다.

코드의 39~40 번쨰 줄을 통해 textarea 에[ 입력된 값과 state 값을 실시간으로 연동시킵니다.


코드의 7 번째 줄은 edit 버튼을 눌렀을 때 실행되는 메소드로, this.state.editMode 값이 true 라면 (edit 버튼이 눌려 수정모드인 상태) thunk 에 인자를 결정해 메모를 수정하여 DB에 저장하는 코드입니다. DB 에 수정된 값을 저장하고 state 가 변경되면 editMode 를 다시 false 로 전환시킵니다. 

18 번째 줄은 this.state.editMode 가 false 일때 다시 true 로 변경시키는 코드입니다.

* 기본값 false 인 상태에서 수정 버튼을 누르면 true 가 되면서 editView 가 보여짐 -> editView 에서 수정 완료버튼을 누르면 true 상태이기 때문에 DB 에 수정된 값을 저장하고 다시 false 로 변경


여기까지 잘 따라오셨나요? 브라우저를 통해 수정기능이 잘 작동하는지 확인해보세요!


2) Delete (삭제) 기능 구현


이제 메모 삭제 기능을 구현해 보도록 하겠습니다.

메모 삭제가 동작하는 방식은 아래 그림과 같습니다.



첫 번째로 살펴볼 것은 BACK-END API 입니다.

DELETE 방식으로 '/api/memo/:id' 로 들어온 요청을 처리하며, id 값으로 들어온 파라메터를 _id 값으로 가진 memo 도큐먼트를 DB 에서 삭제합니다. 성공하면 성공객체를, 실패하면 에러객체를 리턴합니다.

두 번째는 FRONT-END 의 Redux 입니다.

리덕스에서는 API 와 통신을 주고받고 그 결과에 따라 다른 액션객체를 리듀서에 전달하는 thunk 함수를 정의합니다. 리듀서는 전달받은 액션객체의 유형에 따라 리덕스 state 를 변경합니다.

세 번째는 Home, MemoList, Memo 컴포넌트 입니다.

Home 컨테이너에 리덕스를 연결해 thunk 함수를 사용할 수 있게하고, 이를 실행하는 메소드를 만들어 MemoList 컴포넌트를 거쳐 Memo 컴포넌트로 전달합니다. Memo 컴포넌트에서는 thunk 의 인자를 결정하여 실행합니다.


데이터의 이동은 다음과정을 거칩니다.

1. Redux 에서 thunk 함수를 정의해서 Home 컨테이너로 전달. (connect)

2. Home 컨테이너에서 thunk 실행 메소드를 정의하여 MemoList 로 전달.

3. MemoList 에서 그대로 Memo 로 전달.

4, 5, 6. Memo 컴포넌트에서 실행된 thunk 를 통해 API 에 요청

7. DB 에서 요청된 작업을 수행하고 성공객체를 전달

8. 성공하면 state.memo.list.date 배열이 변경됨. Home 컨테이너로 해당 배열을 전달


2-1) BACK-END API


서버에서 요청을 받아 DB 에서 해당 _id 의 memo 도큐먼트를 삭제합니다.


(./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
48
49
50
51
52
53
54
55
56
// DELETE MEMO
/*
    DELETE MEMO: DELETE /api/memo/:id
    ERROR CODES
        1: INVALID ID
        2: NOT LOGGED IN
        3: NO RESOURCE
        4: PERMISSION FAILURE
*/
router.delete('/:id', (req, res) => {
 
    // CHECK MEMO ID VALIDITY
    // url 파라메터로 전달받은 id 가 mongodb id 형식에 맞는지 검사
    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 AND CHECK FOR WRITER
    // 몽구스 Model.findById 로 메모 조회 - 첫 번째 인자: 찾을 도큐먼트의 _id값
    Memo.findById(req.params.id, (err, memo) => {
        if(err) throw err;
        // _id 형식은 몽고db 형식이지만, 해당하는 메모가 없을 경우
        if(!memo) {
            return res.status(404).json({
                error: "NO RESOURCE",
                code: 3
            });
        }
        // 해당 메모의 작성자와 세션에 로그인된 유저가 다를 경우
        if(memo.writer != req.session.loginInfo.username) {
            return res.status(403).json({
                error: "PERMISSION FAILURE",
                code: 4
            });
        }
 
        // REMOVE THE MEMO
        // 위의 모든 결격사항이 없을 경우 메모를 삭제
        Memo.remove({ _id: req.params.id }, err => {
            if(err) throw err;
            res.json({ success: true });
        });
    });
});
cs


url 파라메터로 들어온 id 의 유효성을 검사 / 로그인 여부 확인 / 해당 메모 존재여부 확인 / 권한 있는지 확인

위의 네 가지 경우를 위의 코드에서 검사하였습니다.

만약 문제가 있을 경우 알맞는 HTTP status 와 함께 에러 객체를 리턴합니다.

* HTTP status 400: 잘못된 요청, 403: 금지됨, 404: 찾을 수 없음


에러 상황이 아니면 코드의 51 번째 줄에서 DB 상의 메모를 삭제합니다. 삭제에 성공하면 성공 객체를 리턴합니다.


2-2) Redux 구현


2-2-1) ActionTypes 추가


(./src/actions/ActionTypes.js)

1
2
3
4
// Remove memo
export const MEMO_REMOVE = "MEMO_REMOVE";
export const MEMO_REMOVE_SUCCESS = "MEMO_REMOVE_SUCCESS";
export const MEMO_REMOVE_FAILURE = "MEMO_REMOVE_FAILURE";
cs


2-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
import {
    ...,
    MEMO_REMOVE,
    MEMO_REMOVE_SUCCESS,
    MEMO_REMOVE_FAILURE
} from './ActionTypes';
 
...
/* MEMO REMOVE */
export function memoRemoveRequest(id, index) {
    return (dispatch) => {
        dispatch(memoRemove());
 
        return axios.delete('/api/memo/' + id)
        .then((response) => {
            dispatch(memoRemoveSuccess(index));
        }).catch((error) => {
            dispatch(memoRemoveFailure(error.response.data.code));
        });
    };
}
 
export function memoRemove() {
    return {
        type: MEMO_REMOVE
    };
}
 
export function memoRemoveSuccess(index) {
    return {
        type: MEMO_REMOVE_SUCCESS,
        index
    };
}
 
export function memoRemoveFailure(error) {
    return {
        type: MEMO_REMOVE_FAILURE,
        error
    };
}
cs


23, 29, 36 번째 줄은 액션 생성자 함수로, 각각 삭제 진행, 성공, 실패를 의미합니다.

성공 객체는 삭제한 메모의 메모리스트 데이터 배열안에서의 index 를 포함합니다.

실패 객체는 API 로 부터 전달받은 에러 코드를 포함합니다.


10 번째 줄은 thunk 함수로, 진행 액션 객체를 먼저 리듀서에 보내고 API 통신에 성공하면 성공 액션 객체를 리듀서로 보냅니다. 만약 통신 결과 메모 삭제에 실패하면 실패이유 코드를 포함한 액션 객체를 리듀서로 보냅니다.


2-2-3) reducer 구현


(./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
...
        /* MEMO REMOVE */
        case types.MEMO_REMOVE:
            return {
              ...state,
              remove: {
                ...state.remove,
                status: 'WAITING',
                error: -1
              }
            };
        case types.MEMO_REMOVE_SUCCESS:
            let removeBefore = state.list.data.slice(0, action.index);
            let removeAfter = state.list.data.slice(action.index+1);
            return {
              ...state,
              remove: {
                ...state.remove,
                status: 'SUCCESS'
              },
              list: {
                ...state.list,
                data: [...removeBefore, ...removeAfter]
              }
            };
        case types.MEMO_REMOVE_FAILURE:
            return {
              ...state,
              remove: {
                ...state.remove,
                status: 'FAILURE',
                error: action.error
              }
            };
...
cs


3 번째 줄은 진행 액션 객체를 받았을 때 state 를 어떻게 변경할지 결정하는 코드입니다.

state.remove.status 를 WAITING 으로 변경합니다.


12 번째 줄은 성공 액션 객체를 전달 받았을 때 state 를 어떻게 변경할지 결정하는 코드입니다.

state.remove.status 를 SUCCESS 로 변경하고, 렌더링에 필요한 state 인 state.list.data 값은 원래의 배열에서 action.index 에 해당하는 원소만을 삭제한 배열로 업데이트 합니다.


26 번째 줄은 실패 액션 객체를 전달 받았을 때 state 를 어떻게 변경할지 결정하는 코드입니다.

state.remove.status 를 FAILURE 로 변경하고, API 가 리턴한 에러코드를 state.remove.error 에 저장합니다.


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


리덕스에서 정의한 thunk 함수와 remove 상태를 알 수 있는 state 를 매핑합니다.


(./src/containers/Home.js)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
...
import { ..., memoRemoveRequest } from 'actions/memo';
...
const mapStateToProps = (state) => {
    return {
        ...,
        removeStatus: state.memo.remove
    };
};
 
const mapDispatchToProps = (dispatch) => {
    return {
        ...,
        memoRemoveRequest: (id, index) => {
            return dispatch(memoRemoveRequest(id, index));
        }
    };
};
...
cs


2 번째 줄에서 thunk 함수인 memeRemoveRequest 를 불러와 14 번째 줄에서 Home 컴포넌트에 매핑했습니다.

7 번째 줄에서는 삭제 상황을 알 수 있는 리덕스 state 를 매핑했습니다.


2-4) Home 컨테이너에서 thunk 실행 메소드 생성해 MemoLsit 컴포넌트로 전달


(./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
55
...
    handleRemove = (id, index) => {
        this.props.memoRemoveRequest(id, index).then(() => {
            if(this.props.removeStatus.status==="SUCCESS") {
                // LOAD MORE MEMO IF THERE IS NO SCROLLBAR
                // 1 SECOND LATER. (ANIMATION TAKES 1SEC)
                // 메모를 지우는 통신을 성공하고 1초 뒤에 스크롤이 있는지 확인 => 없으면 전 메모 불러와 스크롤 생성
                setTimeout(() => {
                    if($("body").height() < $(window).height()) {
                        this.loadOldMemo();
                    }
                }, 1000);
            } else {
                // ERROR
                /*
                    DELETE MEMO: DELETE /api/memo/:id
                    ERROR CODES
                        1: INVALID ID
                        2: NOT LOGGED IN
                        3: NO RESOURCE
                        4: PERMISSION FAILURE
                */
                let errorMessage = [
                    'Something broke',
                    'You are not logged in',
                    'That memo does not exist',
                    'You do not have permission'
                ];
 
                 // NOTIFY ERROR
                let $toastContent = $('<span style="color: #FFB4BA">' + errorMessage[this.props.removeStatus.error - 1+ '</span>');
                Materialize.toast($toastContent, 2000);
 
 
                // IF NOT LOGGED IN, REFRESH THE PAGE
                if(this.props.removeStatus.error === 2) {
                    setTimeout(()=> {location.reload(false)}, 2000);
                }
            }
        });
    }
...
    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}
                      onEdit={this.handleEdit}
                      onRemove={this.handleRemove}/>
          </div>
      );
    }
...
cs


코드의 2 번째 줄에서 thunk 메소드를 실행하는 handleRemove 라는 메소드를 정의 했습니다.

API 통신을 통해 state 가 변경되고 this.props.removeStatus.status==="SUCCESS" 이면, 1초 뒤에 스크롤이 있는지 확인하고 없다면 스크롤이 생기도록 이전 메모를 불러옵니다.

(원래 있던 컴포넌트가 없어지면서 스크롤바가 없어질 수 있음)

(1초 뒤에 하는 이유는 컴포넌트 삭제 애니메이션이 1초 걸리기 때문 - 애니메이션은 나중에 구현할 것)

만약 메모삭제에 실패하면 실패 이유를 사용자에게 알림으로 띄웁니다. (31 번째 줄)

로그인 상태가 아니라면 2초뒤에 새로고침합니다. (36 번째 줄)


정의한 handleRemove 메소드를 MemoList 컴포넌트에 onRemove props 로 전달합니다.


2-5) MemoList 컴포넌트에서 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
...
    const mapToComponents = data => {
        return data.map((memo, i) => {
          return (
            <Memo
              data={memo}
              ownership={ memo.writer === this.props.currentUser }
              key={memo._id}
              index={i}
              onEdit={this.props.onEdit}
              onRemove={this.props.onRemove}
              />
          );
        })
      }
...
MemoList.propTypes = {
    ...,
    onRemove: PropTypes.func
};
 
MemoList.defaultProps = {
    ...,
    onRemove: (id, index) => {
        console.error('remove function not defined');
    }
};
...
cs


17~27 번째 줄과 같이 props 를 전달 받았으므로 propTypes 와 defaultProps 값을 지정해 줍니다.

11 번째 줄에서 onRemove props 로 그대로 Memo 컴포넌트로 전달해 줍니다.


2-6) Memo 컴포넌트에서 삭제기능 구현


(./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
...
    handleRemove = () => {
        let id = this.props.data._id;
        let index = this.props.index;
        this.props.onRemove(id, index);
    }
...
    render() {
        const dropDownMenu = (
          ...
              <ul id={`dropdown-${this.props.data._id}`} className='dropdown-content'>
                  <li><a onClick={this.toggleEdit}>Edit</a></li>
                  <li><a onClick={this.handleRemove}>Remove</a></li>
              </ul>
          ...
        );
...
Memo.propTypes = {
    ...,
    onRemove: PropTypes.func
};
 
Memo.defaultProps = {
    ...,
    onRemove: (id, index) => {
        console.error('remove function not defined');
    }
}
...
cs


두 번째 줄에서 thunk 에 인자를 줘서 실행하는 handleRemove 메소드를 정의 했습니다.

정의한 handleRemove 메소드를 Remove 버튼의 onClick 이벤트에 등록합니다. (13 번째 줄)

전달받은 props 의 propTypes 와 defaultProps 를 지정해 주는것도 잊지 않으셨겠죠? (20, 25 번째 줄)


여기까지 완료 했으면 삭제기능이 제대로 작동할 것입니다.

어떤가요? 잘 삭제되나요?


3) 애니메이션 기능 추가


메모가 로딩 되거나 삭제될 때 애니메이션 효과를 줘 보겠습니다.

애니메이션이 있고 없고는 완성도의 측면에서 엄청난 차이를 보이니 구현하는 편이 더 멋지겠죠?


애니메이션 라이브러리인 ReactCSSTransitionGroup 을 사용하여 애니메이션을 구현하도록 하겠습니다.


우선 우리의 프로젝트에 ReactCSSTransitionGroup 모듈을 추가해 주도록 하겠습니다.


npm install --save react-addons-css-transition-group


ReactCSSTransitionGroup 를 사용할 때 주의할 점은 애니메이션을 적용할 컴폰넌트가 아니라 대상의 부모 컴포넌트에 적용해야 한다는 점입니다.

우리는 Memo 컴포넌트가 로딩되거나 삭제될 때 애니메이션을 사용할 것이기 때문에 Memo 컴포넌트가 아닌 MemoList 컴포넌트에 ReactCSSTransitionGroup 를 적용해야 되겠죠.


ReactCSSTransitionGroup 는 컴포넌트로서 사용하는데, ReactCSSTransitionGroup 컨포넌트의 자식 컴포넌트로 애니메이션을 적용할 컴포넌트가 들어옵니다.

ReactCSSTransitionGroup 컴포넌트의 속성으로는 trasitionName 속성과 transition...Timeout 속성이 있습니다.

trasitionName 속성의 값과 transition...Timeout 속성에서의 ... 를 합쳐 CSS 의 클래스명이 됩니다.

transition...Timeout 속성은 애니메이션의 지연시간을 지정하는 속성으로, 속성값의 단위는 ms 입니다.

* 1000ms = 1초


1
2
3
4
5
6
        <ReactCSSTransitionGroup
          transitionName="example"
          transitionEnterTimeout={5000}
          transitionLeaveTimeout={3000}>
          {items}
        </ReactCSSTransitionGroup>
cs


예를 들어 위와 같은 코드가 있다고 합시다. 

이 코드는 다섯 번 째 줄의 items 에 애니메이션 주는 코드입니다.

위 코드에서 적용되는 CSS 클래스명은 두 개 입니다.

하나는 example-enter 이고, 다른 하나는 example-leave 입니다.

example-enter 에 해당하는 애니메이션을 5초 있다 실행하고, example-leave 에 해당하는 애니메이션을 3초 있다가 실행합니다.


* 자세한 사용법은 아래의 링크를 참조해 주세요.

<ReactCSSTransitionGroup>


그럼 우리의 코드에 애니메이션을 적용시켜 보도록 하죠.


3-1) Memo 로딩 애니메이션 효과


3-1-1) memo-enter 스타일 클래스 추가


memo-enter 스타일 클래스를 style.css 에 추가시켜 줍니다.


(./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
/* ANIMATION */
@-webkit-keyframes memo-enter {
    0% {
      opacity: 0;
    }
    100% {
      opacity: 1;
    }
}
@keyframes memo-enter {
    0% {
      opacity: 0;
    }
    100% {
      opacity: 1;
    }
}
.memo-enter {
    -webkit-animation-duration: 2s;
    animation-duration: 2s;
    -webkit-animation-name: memo-enter;
    animation-name: memo-enter;
}
cs


2초 짜리 애니메이션 효과를 만들었습니다.

* CSS 의 @keyframes 와 애니메이션에 익숙하지 않으신 분들은 아래의 링크를 참조해 주세요.

<CSS 애니메이션 구현>


3-1-2) MemoList 컴포넌트에서 ReactCSSTransitionGroup 사용


Memo 컴포넌트가 생성되는 애니메이션을 구현하기 위해 상위 컴포넌트인 MemoList 컴포넌트에서 ReactCSSTransitionGroup 를 적용합니다.


(./src/components/MemoList.js)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import ReactCSSTransitionGroup from 'react-addons-css-transition-group';
...
    render(){
    ...
    return (
        <div>
          <ReactCSSTransitionGroup transitionName="memo"
                                   transitionEnterTimeout={2000}>
            { mapToComponents(this.props.data) }
          </ReactCSSTransitionGroup>
        </div>
      );
    }
...
cs


memo-enter(생성) 애니메이션이 2초 대기시간을 가집니다.


3-2) Memo 삭제 애니메이션 효과


삭제 될 때는 Fade out 되면서 오른쪽으로 슬라이드 되는 효과를 줍니다.

컴포넌트가 삭제된 이후에 삭제된 컴포넌트의 아래에 있던 컴포넌트가 서서히 올라오도록 삭제되는 컴포넌트의 max-height 값을 적용합니다.


3-2-1) memo-leave 스타일 클래스 추가


memo-leave 스타일 클래스를 style.css 에 추가시켜 줍니다.


(./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
body {
    background-color: #ECEFF1;
    overflow-x: hidden; 
}
...
@-webkit-keyframes memo-leave {
    0% {
      opacity: 1;
      max-height: 1080px;
    }
    50% {
      opacity: 0;
       -webkit-transform: translateX(100px);
    }
    100% {
        max-height: 0px;
    }
}
@keyframes memo-leave {
    0% {
      opacity: 1;
      max-height: 1080px;
    }
    50% {
      opacity: 0;
      transform: translateX(100px);
    }
    100% {
        max-height: 0px;
    }
}
.memo-leave {
    max-height: 0px;
    opacity: 0;
    -webkit-animation-duration: 1s;
    animation-duration: 1s;
    -webkit-animation-name: memo-leave;
    animation-name: memo-leave;
}
cs


메모가 삭제되면서 오른쪽으로 슬라이드 될 때 가로 스크롤 바가 생기지 않도록 4번째 줄과 같이 body 태그에 overflow-x 속성을 hidden 으로 주었습니다.


3-2-2) MemoList 컴포넌트의 ReactCSSTransitionGroup 수정


(./src/components/MemoList.js)

1
2
3
4
5
6
7
8
9
    return (
        <div>
          <ReactCSSTransitionGroup transitionName="memo"
                                   transitionEnterTimeout={2000}
                                   transitionLeaveTimeout={1000}>
            { mapToComponents(this.props.data) }
          </ReactCSSTransitionGroup>
        </div>
      );
cs


코드의 5 번째 줄에 memo-leave 클래스 스타일을 적용할 수 있도록 코드를 추가해 주었습니다.


브라우저를 통해 메모를 작성, 삭제해서 애니메이션이 제대로 동작하는지 확인해 보세요.

잘 동작하나요?


+ react-timeago


포스팅을 작성하던 중 빼먹은 부분이 있어 추가합니다.

react-timeago 컴포넌트를 이용하여 작성시간 / 수정시간이 얼마나 지났는지 표시하도록 하겠습니다.


(./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
...
import TimeAgo from 'react-timeago';
...
    render() {
        ...
        // EDITED info (수정된 시간 -TimeAgo)
        let editedInfo = (
            <span style={{color: '#AAB5BC'}}> · Edited <TimeAgo date={this.props.data.date.edited} live={true}/></span>
        );
 
        const memoView = (
          <div className="card">
              <div className="info">
                  <a className="username">{this.props.data.writer}</a> wrote a log · <TimeAgo date={this.props.data.date.created}/>
                  { this.props.data.is_edited ? editedInfo : undefined }
                  { 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>
        );
 
        ...
    }
...
cs


코드의 두 번째 줄에서 시간이 얼마나 지났는지 표시하는 컴포넌트인 TimeAgo 컴포넌트를 import 했습니다.

수정된 메모의 수정 시간을 TimeAgo 컴포넌트로 표시하는 eidtedInfo 를 7 번째 줄에서 정의하고, 15번째 줄을 통해 해당 메모가 수정된 메모일 때 eidtedInfo 를 렌더링하도록 했습니다.

14 번째 줄의 수정된 시간도 TimeAgo 컴포넌트를 이용하여 렌더링 했습니다.


여기까지...


memo 의 수정 및 삭제 그리고 Memo 컴포넌트가 로딩되거나 사라질때 애니메이션 까지 적용해 보았습니다.

이번 포스팅 역시 긴 포스팅이었네요.

여기까지 잘 따라 오셔 주셔서 너무나 감사드립니다.

항상 기능이 어떻게 동작하는지, 데이터가 어떻게 이동되는지 원리를 잘 파악하고 따라와 주시길 바라겠습니다.


다음 포스팅은 기능 구현의 마지막 포스팅이 될 것 같습니다.

별점 주기 및 user 검색 / 담벼락 기능을 구현해 보도록 하겠습니다.


감사합니다.


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

https://velopert.com/1921


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

Comments