함께 성장하는 프로독학러

Memo_app 08. 추가 기능 구현 - 별점, 담벼락, 검색 기능 구현 본문

Programming/tutorials

Memo_app 08. 추가 기능 구현 - 별점, 담벼락, 검색 기능 구현

프로독학러 2018. 6. 28. 23:20

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


이번 포스팅에서는 추가적인 기능인 별점주기, 담벼락 기능, 유저 검색 기능을 구현해 보도록 하겠습니다.

(담벼락은 특정유저가 작성한 글만을 보여주는 페이지를 의미합니다)


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

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

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

<React.js codelab 2016 - Velopert>


1. 별점 기능


별점 기능은 마음에 드는 메모에 별점을 주거나 빼는 기능입니다. 별점은 최대 한 번 줄 수 있으며 준 별점을 다시 회수 할 수도 있습니다.

별점은 memo 도큐먼트에서 starred 필드(배열) 안에 자신의 이름을 (원소로) 넣거나 빼는 것입니다.

기능이 어떻게 구현되는지 동작원리를 그림을 통해 알아보도록 하겠습니다.



별점을 주거나 빼는 기능은 결국 DB 의 memo 를 수정하는 작업이므로 전체적인 내용을 메모를 수정하는 것과 비슷합니다.


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

BACK-END 의 API 는 POST: /api/memo/star/:id 로 접근한 요청에 대해서 처리하는 라우터로, 전달받은 url 파라메터(id)를 검사해 별점을 (메모를 수정) 줄 수 있는 상황인지 파악하고, 줄 수 있는 상황이면 DB 를 수정하고 성공객체를 리턴합니다.

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

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

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

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

Memo 컴포넌트에서는 thunk 의 인자로 들어갈 (memo._id, index) 값을 결정하여 실행합니다.


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

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
/*
    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
        // 해당 id의 메모의 statted 필드(배열)에 별점을 주려는(로그인유저) 유저가 있는지 확인
        let index = memo.starred.indexOf(req.session.loginInfo.username);
 
        // CHECK WHETHER THE USER ALREADY HAS GIVEN A STAR
        // indexOf 메소드의 결과가 없을 경우 -1 이 리턴된다.
        let hasStarred = (index === -1) ? false : true;
        // 결과가 없을 경우 false, 있을 경우 true
 
        if(!hasStarred) { //결과가 없을 경우
            // IF IT DOES NOT EXIST
            // starre 필드에 유저이름 푸쉬(배열의 맨 뒤에 원소추가)
            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, // 별을 주었는지, 가져갔는지 정보 (줬으면 true, 가져갔으면 false)
                memo
            });
        });
    });
});
cs


위와 같은 라우터를 memo 라우터에 추가해 줍니다. 

해당 API 는 POST 방식으로 '/api/memo/star/:id' 에 접근했을때 요청을 수행하는 라우터입니다.


위 API 에서는 아래의 경우를 검사합니다.

url 파라메터로 들어온 id 값이 MongoDB 의 id 형식인지 / 로그인 되어 있는지 / id 파라메터로 들어온 값을 _id 로가진 메모가 있는지

세 경우 중 오류가 있다면 HTTP status 와 함께 오류 객체를 리턴합니다.

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


만약 오류사항이 없다면 26 번째 줄과 같이 id 값을 쿼리하여 memo 를 찾습니다.

indexOf 메소드를 이용하여 해당 메모의 starred 값인 배열안에 로그인된 유저의 이름이 있는지 검색하여 index 에 할당합니다. (39번째 줄)

* indexOf 메소드의 리턴값은 인자로 들어온 값의 index 입니다. 만약 검색 결과가 없을 경우 -1 을 리턴합니다.

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

<배열/문자열의 indexOf 메소드>


만약 index 의 값이 -1 이라면 hasStarred 값을 false 로, 아니라면 true 로 설정합니다. (43 번째 줄)

hasStarred 의 값이 true 라면 해당 메모의 sttared 배열안에 로그인된 유저의 이름이 있다. 즉, 별점을 줬었다의 의미이고,

false 라면 배열에 이름이 없다. 즉, 별점을 준 적이 없었다 라는 의미입니다.


만약 별점을 준 적이 없으면 starred 배열에 로그인 유저의 이름을 원소로 추가하고 (46~49 번째 줄), 준 적있으면 해당 유저의 이름을 배열에서 뺍니다.

* 여기서 배열의 원소를 삭제하는 slice 메소드를 사용했습니다. 이에 익숙하지 않으신 분들은 아래의 링크를 참조해 주세요.

<배열의 원소 제거, splice 메소드>


57 번째 줄에서 수정한 memo 를 DB 에 저장하고, 성공객체를 내보내는데 이때 수정된 메모와 함께 has_starred 값을 지정합니다.

has_starred 의 값은 hasStarred 의 값의 반대로, 별점을 줬으면 true, 뺐으면 false 값이 됩니다.


1-2) Redux 구현


1-2-1) ActionTypes 추가


별점에 관련된 ActionTypes 를 추가해줍니다.


(./src/actions/ActionTypes.js)

1
2
3
4
// Give a star to memo
export const MEMO_STAR = "MEMO_STAR";
export const MEMO_STAR_SUCCESS = "MEMO_STAR_SUCCESS";
export const MEMO_STAR_FAILURE = "MEMO_STAR_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
import {
    ...,
    MEMO_STAR,
    MEMO_STAR_SUCCESS,
    MEMO_STAR_FAILURE
} from './ActionTypes';
...
/* MEMO TOGGLE STAR */
export function memoStarRequest(id, index) {
    return (dispatch) => {
        dispatch(memoStar());
 
        return axios.post('/api/memo/star/' + id)
        .then((response) => {
            dispatch(memoStarSuccess(index, response.data.memo));
        }).catch((error) => {
            dispatch(memoStarFailure(error.response.data.code));
        });
    };
}
 
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
    };
}
cs


먼저 ActionTypes 를 불러오고, 액션생성자 함수를 정의 합니다.

액션생성자 함수는 22, 28, 36 번째 줄에서 정의 했으며 각각 별점기능 구현중, 구현 성공, 구현 실패를 의미하는 action 객체를 리턴합니다.

성공 객체에는 프론트에서 렌더링을 위해 사용하는 메모리스트의 배열 중 별점기능을 구현한 메모의 index 값과 별점 작업을 완료한(starred 배열이 수정된 메모) 메모 객체가 포함되어 있습니다.

실패 객체에는 에러코드가 포함되어 있습니다.


9 번째 줄을 thunk 함수로, 진행 과정에 따라 다른 액션 객체를 리듀서에 전달하는 역할을 합니다.

진행중 액션객체를 먼저 보내고 (11 번째 줄), 

서버와 통신후 서버에서 리턴하는 값에 따라 성공객체 / 실패 객체를 리듀서로 보냅니다.


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
...
    /* Star toggle */
        case types.MEMO_STAR:
            return {
              ...state,
              star: {
                ...state.star,
                status: 'WAITING',
                error: -1
              }
            }
        case types.MEMO_STAR_SUCCESS:
            let starBefore = state.list.data.slice(0, action.index);
            let starAfter = state.list.data.slice(action.index+1);
            return {
                ...state,
              star: {
                ...state.star,
                status: 'SUCCESS'
              },
              list: {
                ...state.list,
                data: [...starBefore, action.memo ,...starAfter]
              }
            };
        case types.MEMO_STAR_FAILURE:
            return {
              ...state,
              star: {
                ...state.star,
                status: 'FAILURE',
                error: action.error
              }
            }
...
cs


별점 기능이 진행중 / 성공 / 실패했다는 상태는 state.star.status 를 통해 알 수 있습니다.

만약 실패 했다면 state.star.error 를 통해 에러 코드를 알 수 있습니다.

만약 성공 했다면 state.list.data 배열에 수정된 메모를 원래 위치에 갈아낍니다.


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


Home 컨테이너와 thunk 함수인 memoStarRequest 와 별점기능 수행의 상태를 알 수 있는 starStatus 를 매핑합니다.


(./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, memoRemoveRequest, memoStarRequest  } from 'actions/memo';
...
const mapStateToProps = (state) => {
    return {
        ...,
        starStatus: state.memo.star
    };
};
 
const mapDispatchToProps = (dispatch) => {
    return {
        ...,
        memoStarRequest: (id, index) => {
            return dispatch(memoStarRequest(id, index));
        }
    };
};
...
cs


2 번째 줄에서 memoStarRequest 를 불러와 14 번째 줄에서 memoStarRequest 로 매핑했습니다.

7 번째 줄에서 starStatus 로 state.memo.star (status 와 error 코드를 포함한 객체) 를 매핑했습니다.


1-4) Home 컨테이너에서 thunk 실행 메소드 정의 / MemoList 로 전달


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
...
    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() {
      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}
                      onStar={this.handleStar}/>
          </div>
      );
    }
...
cs


2 번째 줄에서 memoStarRequest (thunk) 를 실행하는 메소드를 정의 했습니다. thunk 의 실행을 통해 state 가 업데이트 된 시점에서 실패했다면 (state 를 통해 알 수 있음) 왜 실패 했는지 알림을 띄웁니다. (state 의 error 코드를 이용하여)

만약 로그인되 있지 않다면 2초 뒤에 새로고침하는 코드도 추가합니다. (26 번째 줄)


그리고 위에서 정의한 handleStar 메소드를 MemoList 컴포넌트에 onStar 프롭스로 전달합니다. (43 번째 줄)


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


thunk 를 실제로 사용하는 컴포넌트는 Memo 컴포넌트이기 때문에 Memo 컴포넌트로 onStar 를 그대로 전달합니다.


(./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
...
    const mapToComponents = data => {
            return data.map((memo, i) => {
                return (<Memo
                            ...
                            onStar={this.props.onStar}
                            currentUser={this.props.currentUser}
                            />);
            });
        };
...
MemoList.propTypes = {
    ...,
    onStar: PropTypes.func
};
 
MemoList.defaultProps = {
    ...,
    onStar: (id, index) => {
        console.error('star function not defined');
    }
};
...
cs


onStar 프롭스를 전달받았으므로 propTypes 와 defaultProps 를 지정해 주어야겠죠? (14, 19 번째 줄)

그리고 컴포넌트 매핑을 통해 렌더링하는 Memo 컴포넌트에 onStar props 로 그대로 전달합니다. (6 번째 줄)

추가적으로 currnetUser 값도 전달해줍니다. (7 번째 줄)

* currentuser 값은 Memo 컴포넌트 안에서 memo 데이터의 starred 배열 안에 사용자가 있는지 확인하는 용도로 쓰입니다. 이를 통해 속이 찬 별 / 빈 별을 렌더링 할 수 있습니다.


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


Memo 컴포넌트안에서 전달받은 onStart 메소드를 실행하는 handleStar 를 구현하고, 별모양 아이콘을 누를 때 적용되도록 하겠습니다.

추가적으로 현재유저의 별점여부에 따라 별 모양이 속이 찬 별 / 빈 별을 렌더링하겠습니다.


(./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
...
    handleStar = () => {
        let id = this.props.data._id;
        let index = this.props.index;
        this.props.onStar(id, index);
    }
 
...
    render(){
        // 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">
              ...
              <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>
        ...
    }
...
Memo.propTypes = {
    ...,
    onStar: PropTypes.func,
    currentUser: PropTypes.string
};
 
Memo.defaultProps = {
    ...,
    onStar: (id, index) => {
        console.error('star function not defined');
    },
    currentUser: ''
}
...
cs


전달받은 props 의 propTypes 와 defaulProps 를 지정하는것은 잊지 않으셨죠? (27~39 번째 줄)


코드의 두 번째 줄에서는 인자를 결정해 thunk 를 실행하는 handleStar 메소드를 정의 했습니다. (첫 번째 인자는 DB 상의 _id 이고, 두 번째 인자는 사용자측의 메모리스트 배열의 index 를 의미합니다)


코드의 12 번째 줄에서 별점을 주면 별 색깔을 채우는 style 을 지정하고, 19 번째 줄에서 i 태그에 sytle 로 지정했습니다.

또한 i 태그의 onClick 이벤트에 handleStar 메소드를 등록하여 thunk 를 실행하도록 하였습니다.

추가적으로 별점을 몇명한테서 받았는지를 알 수 있도록 21 번째 줄에 코드를 추가했습니다.


서버를 재시작/시작하여 브라우저에서 별점을 줘 보도로 할까요?

어때요 제대로 작동하나요?


2. 담벼락 기능


이제 담벼락 기능을 구현해 보도록하겠습니다.

담벼락 기능이란, 특정 유저가 작성한 메모만을 보는것을 의미합니다.


전체적인 메커니즘은 메모 읽기와 동일합니다.

동작원리 또한 읽기와 같은데, 다른 점이 있다면 그냥 메모읽기에서는 memos 콜렉션의 모든 데이터를 조회했지만 담벼락에서는 Query 를 통해 특정 user 가 작성한 메모만을 조회한다는 점입니다.


* 메모 읽기 기능을 구현 할 때는 첫 메모 로딩과 추가 메모 로딩을 따로 구현했지만 이번에는 같이 구현하겠습니다. 생각보다 간단하니 걱정말고 따라와 주세요!


2-1) BACK-END API 구현


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

새로운 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
57
58
59
60
61
62
63
64
    /*
        READ MEMO OF A USER: GET /api/memo/:username
    */
    //  맨 처음 유저의 메모 가져올 때 (6개만)
    router.get('/:username', (req, res) => {
        Memo.find({writer: req.params.username})
        .sort({"_id"-1})
        .limit(6)
        .exec((err, memos) => {
            if(err) throw err;
            res.json(memos);
        });
    });
 
 
    /*
        READ ADDITIONAL (OLD/NEW) MEMO OF A USER: GET /api/memo/:username/:listType/:id
    */
    // 특정 유저 메모 추가 로딩
    router.get('/:username/:listType/:id', (req, res) => {
        let listType = req.params.listType;
        let id = req.params.id;
 
        // CHECK LIST TYPE VALIDITY
        // url 로 들어온 listType 이 new/old 가 아니라면 에러 리스폰스
        if(listType !== 'old' && listType !== 'new') {
            return res.status(400).json({
                error: "INVALID LISTTYPE",
                code: 1
            });
        }
 
        // CHECK MEMO ID VALIDITY
        // url 로 들어온 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({ writer: req.params.username, _id: { $gt: objId }}) //_id 값이 기준 메모id 보다 큰값
            .sort({_id: -1})
            .limit(6)
            .exec((err, memos) => {
                if(err) throw err;
                return res.json(memos);
            });
        } else {
            // GET OLDER MEMO
            Memo.find({ writer: req.params.username, _id: { $lt: objId }}) //_id 값이 기준 메모id 보다 작은값
            .sort({_id: -1})
            .limit(6)
            .exec((err, memos) => {
                if(err) throw err;
                return res.json(memos);
            });
        }
    });
cs


코드의 5 번째 줄은 GET 방식으로 '/api/memo/:username' 경로로 접근하는 요청에 대한 처리를 하는 API 입니다. (첫 메모)

url 파라메터로 들어온 username 값으로 writer 를 쿼리해서 메모를 얻습니다. 성공하면 길이가 6인 배열을 리턴합니다.


코드의 20 번째 줄은 GET 방식으로 '/api/memo/:username/:listType/:id' 경로로 접근하는 요청에 대한 처리를 하는 API 입니다. (특정 유저의 메모 추가 로딩)

url 파라메터로 들어온 listType 값과 id 값을 검사해 오류가 있으면 HTTP status 와 함께 에러 객체를 리턴합니다.

* HTTP status 400 은 잘못된 요청입니다.

만약 오류가 없다면 기준이 되는 id 로 부터 새로운 메모 혹은 이전 메모를 로딩하려 로딩된 메모들(배열)을 리턴합니다.


2-2) Redux 구현


담벼락 기능은 메모읽기 기능의 리덕스들을 모두 공유합니다. (ActionTypes / thunk 및 액션 생성자 / 리듀서 )

따로 Redux 를 구현할 필요가 없습니다. 메모를 읽는데 특정 유저의 메모를 읽는 DB 상의 쿼리만 달라지기 때문입니다. (이 역시도 BACK-END API 에서 처리했음)


여기서는 메모 읽기 부분에서 구현한 thunk 의 인자로 username 값이 들어왔을 경우를 설명만하고 넘어가겠습니다.


(./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


memoListRequest thunk 는 이전에 구현한 그대로지만, 읽기 때 설명을 제대로하지 않고 넘어간 12 번째 줄의 코드에 대해서 살펴봅시다.

12 번째 줄은 thunk 의 username 인자가 들어왔을 떄의 코드로, axios 를 통해 HTTP 통신을 할 url 경로에 username 값을 추가하는 코드입니다. 만약 첫 메모 로딩이면 특정 유저의 메모를 처음 로딩하고 첫 로딩이 아니면 추가 로딩을 합니다.

thunk 의 인자들을 통해 url 의 경로가 결정되어 DB 에서 어떤 memo 를 읽어올지 결정하는 것입니다.


2-3) 담벼락 라우트 / 컨테이너 컴포넌트 생성


특정 유저의 메모만을 보여주는 담벼락 컴포넌트를 만들고, 담벼락 컴포넌트를 '/wall/:username' 라우트에 렌더링하도록 설정하겠습니다.


2-3-1) Wall 컨테이너 컴포넌트 생성


containers 디렉토리에 Wall.js 파일을 생성해 줍시다.


(./src/containers/Wall.js)

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


7 번째 줄은 url 파라메터로 들어온 username 값을 렌더링하도록 하는 코드입니다.

* react-router-dom 의 Route 에 의해 렌더링되는 컴포넌트는 기본적으로 세 개의 객체를 전달 받습니다. 여기서는 그 중 하나인 match 객체의 params 값을 사용했습니다. 이에 익숙하지 않으신 분들은 아래의 링크를 참조해 주세요.

<Route 와 파라메터, 쿼리>


새로운 컨테이너를 생성했으므로 containers 디렉토리의 index 에 Wall 컨테이너를 추가해주겠습니다.


(./src/containers/index.js)

1
2
3
4
5
6
7
import Register from './Register';
import Login from './Login';
import App from './App';
import Home from './Home';
import Wall from './Wall';
 
export { Register, Login, App, Home, Wall };
cs


2-3-2) 클라이언트 사이드 라우터 추가


담벼락 컴포넌트를 '/wall/:username' 라우트에 렌더링하도록 설정하겠습니다.


(./src/index.js)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
...
import { Register, Login, App, Home, Wall } from 'containers'
 
...
 
ReactDOM.render(
  <Provider store={store}>
    <Router>
      <div>
        ...
        <Route path="/wall/:username" component={Wall}/>
      </div>
    </Router>
  </Provider>
  ,
  document.getElementById('root')
);
...
cs


Wall 컨테이너를 2 번째 줄에서 import 하여 11 번째 줄에서 '/wall/:username' 라우트에 렌더링되도록 했습니다.


여기까지 진행하시고 브라우저를 통해 'localhost:4000/wall/1234' 와 같은 경로로 접근해 보세요.

1234 가 화면에 잘 렌더링 되나요? 1234 가 아닌 다른값을 넣어서 확인해보세요.


2-4) Wall 컨테이너에서 Home 컴포넌트 렌더링하기


Wall 컴포넌트 안에서 렌더링하는 내용은 메모를 읽어오는 것이기 때문에 Home 컴포넌트와 같습니다.

Home 컴포넌트를 재사용하도록 하죠.


(./src/containers/Wall.js)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import React, { Component } from 'react';
import { Home } from 'containers';
 
class Wall extends Component {
    render() {
        return (
            <div>
              <Home username={this.props.match.params.username}/>
            </div>
        );
    }
}
 
export default Wall;
cs


이 때 username props 로 url 로 들어온 username 파라메터를 전달해 줍니다.

Home 컴포넌트에서 메모를 로딩하는 thunk 를 실행할 때, username 인자로 사용할 값입니다.


2-5) Home 컴포넌트에서 특정유저 메모 읽기


Home 컴포넌트 안에서 메모읽기가 실행되는 thunk 가 실행되는 부분에 username 인자를 추가해 줍니다.

* 만약 Home 으로 들어오느 username 프롭스가 존재하지 않으면 자동으로 undefined 가 됩니다.


(./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
import PropTypes from 'prop-types';
...
    loadNewMemo() {
        ...
        // IF PAGE IS EMPTY, DO THE INITIAL LOADING
        if(this.props.memoData.length === 0 )
            return this.props.memoListRequest(true, undefined, undefined, this.props.username);
 
        return this.props.memoListRequest(false'new', this.props.memoData[0]._id, this.props.username);
    }
 
    loadOldMemo = () => {
        ...
        // 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() {
        ...
 
        this.props.memoListRequest(true, undefined, undefined, this.props.username).then(
            () => {
                // BEGIN NEW MEMO LOADING LOOP
                loadUntilScrollable();
                loadMemoLoop();
            }
        );
        ...
    }
...
Home.propTypes = {
    username: PropTypes.string
};
 
Home.defaultProps = {
    username: undefined
};
...
cs


우선 props 를 전달 받았으므로 propTypes 와 defaultProps 를 지정해 줍니다. (39, 43 번째 줄)

그리고 thunk 함수가 실행되는 부분 (7, 9, 18, 29 번째 줄) 에 props 로 들어온 username 값을 전달해 줍니다.


여기까지 잘 따라오셨나요?

브라우저에 localhost:4000/wall/(DB에 존재하는 유저네임) 으로 접속해 해당 유저의 메모만 잘 읽어오는지 확인해 보세요.

잘 렌더링되나요?

하지만 한 가지 문제점이 있습니다. 이 상태에서 주소창에 다른 유저네임을 입력해서 다른 유저의 담벼락에 들어갔을 때 컴포넌트가 우리가 원하는대로 작동하지 않습니다. (메모가 사라지는 애니메이션, 메모가 추가되는 애니메이션 등이 적용되지 않음)

이는 컴포넌트가 unmount / mount 의 과정을 거쳐 보여지는 것이 아니라 update 되기 때문입니다. 


이를 해결하기 위해 componentDidUpdate LifeCycle API 를 이용하겠습니다.

componentDidUpdate 에서 username 이 변경된 것을 감지하면 componentWillUnmount 와 componentDidMonut 메소드를 임의로 실행해줍니다. 


(./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
...
    componentDidMount() {
        ...
        this.props.memoListRequest(true, undefined, undefined, this.props.username).then(
            () => {
              // LOAD MEMO UNTIL SCROLLABLE
              setTimeout(loadUntilScrollable, 1000);
              // BEGIN NEW MEMO LOADING LOOP
              loadMemoLoop();
            }
        );
       ...
    }
...
    omponentDidUpdate(prevProps, prevState) {
    // 컴포넌트가 업데이트 된 이후에 현재 프롭스로 전달된 usernam과 이전 프롭스로 전달된 username 이 다르면
    // unmount, didmount 실행
        if(this.props.username !== prevProps.username) {
            this.componentWillUnmount();
            this.componentDidMount();
        }
    }
...
cs


15 번째 줄에서 컴포넌트가 업데이트 된 이후의 시점에 props 로 전달받은 username 값이 이전 props 의 그것과 다를 때 (다른 유저의 담벼락일 때) 수동으로 unmount / mount 시키도록 하였습니다.


7 번째 줄에서는 setTimeout 메소드를 이용하여 스크롤 바가 생긱때 까지 메모를 로딩하는 코드를 1초 있다가 실행하도록 했습니다.

이는 메모 애니메이션의 길이가 1초이기 때문에 해당 시간만큼 기다렸다가 로딩하도록 하는 것입니다.

(스크롤 바가 없어도 있는 것으로 인식하는 것을 방지하기 위해)


2-6) 담벼락 헤더 생성


Home 뷰에서 username props 를 전달 받은 상태라면 보이는 담벼락 헤더를 생성하겠습니다.


(./src/conatainers/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
...
    render() {
      const write = ( <Write onPost={this.handlePost}/> );
 
      const emptyView = (
          <div className="container">
              <div className="empty-page">
                  <b>{this.props.username}</b> isn't registered or hasn't written any memo
              </div>
          </div>
      );
 
      const wallHeader = (
          <div>
              <div className="container wall-info">
                  <div className="card wall-info blue lighten-2 white-text">
                      <div className="card-content">
                          {this.props.username}
                      </div>
                  </div>
              </div>
              { this.props.memoData.length === 0 ? emptyView : undefined }
          </div>
      );
 
      return (
          <div className="wrapper">
            { typeof this.props.username !== "undefined" ? wallHeader : undefined }
            { this.props.isLoggedIn && typeof this.props.username === "undefined" ? write : undefined }
            ...
          </div>
      );
    }
...
cs


13 번째 줄에서 wallHeader 를 정의 했습니다. 

그리고 5 번째 줄에서 담벼락일 때 데이터가 없으면 표시될 emptyView 를 정의하고 22 번째 줄에서 메모리스트가 비어있을 때 렌더링하도록 했습니다.

28 번째 줄에서는 담벼락 모드일때만 wallHeader 가 렌더링 되도록 설정했습니다.

29 번째 줄은 로그인 돼 있으며 담벼락 모드가 아닐때 write 를 렌더링하도록 했습니다.


담벼락 헤더 뷰를 만들었으니 그에 맞는 스타일을 추가해 주도록 하겠습니다.


(./src/style.css)

1
2
3
4
5
6
7
8
9
10
11
/* wallHeader */
.empty-page {
    font-size: 30px;
    text-align: center;
    color: #4D4D4D;
}
.wall-info {
    font-size: 30px;
    text-align: center;
}
cs


위의 코드를 실행시켜보면, 로딩 과정에서 메모 리스트의 배열의 길이가 0 이기 때문에 순간적으로 메모가 없다는 메시지를 띄웁니다.

이를 컴포넌트 state 를 통해 해결해 보도록 하겠습니다.


(./src/conatainers/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
...
    state = {
      loadingState: false,
      initiallyLoaded: false
    };
...
    componentDidMount() {
        ...
        this.props.memoListRequest(true, undefined, undefined, this.props.username).then(
            () => {
              ...
              this.setState({
                    initiallyLoaded: true
                });
            }
        );
    }
...
    componentWillUnmount() {
        ...
        this.setState({
            initiallyLoaded: false
        });
    }
...
    render() {
      ...
      const wallHeader = (          
          <div>
              <div className="container wall-info">
                  <div className="card wall-info blue lighten-2 white-text">
                      <div className="card-content">
                          {this.props.username}
                      </div>
                  </div>
              </div>
              { this.props.memoData.length === 0 && this.state.initiallyLoaded ? emptyView : undefined }
          </div>
      );    ...
    }
...
cs


코드의 37 번째 줄에서 state.initiallyLoaded 가 true 일때만 emptyView 를 렌더링하도록 설정했습니다.

* state.initiallyLoaded 값이 true 라는 것은 초기로딩이 끝난 상태를 의미합니다. (12 번째 줄)


2-7) Memo 컴포넌트에서 유저네임 클릭시 담벼락으로 이동


메모 뷰에서 작성자를 클릭하면 해당 유저의 담벼락으로 이동하도록 설정해 줍니다.


(./src/components/Memo.js)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
...
import { Link } from 'react-router-dom';
...
render(){
    ...
    const memoView = (
          <div className="card">
              <div className="info">
                  <Link to={`/wall/${this.props.data.writer}`} className="username">{this.props.data.writer}</Link> wrote a log · <TimeAgo date={this.props.data.date.created}/>
                  { this.props.data.is_edited ? editedInfo : undefined }
                  { this.props.ownership ? dropDownMenu : undefined }
              </div>
              ...
          </div>
        );
}
...
cs


2 번째 줄에서 Link 컴포넌트를 불러와 9 번째줄에서 사용했습니다.

Link 컴포넌트의 to 값은 담벼락으로 이동하는 url 입니다.


어때요 담벼락이 잘 구현되나요?


3. 검색 기능


이제 기능 구현의 마지막입니다!

여기까지 따라와 주셔서 너무 감사드립니다. 이제 얼마 남지 않았으니 힘내서 가 봅시다!

검색 기능은 Header 에서 검색 버튼을 눌러 사용자를 검색하는 기능입니다.

검색결과를 통해 해당 사용자에 담벼락으로도 이동할 수 있도록 구현할 예정입니다.


먼저 그림을 통해 검색 기능이 어떻게 동작하는지 살펴보도록 하겠습니다.



검색 기능은 Header 를 통해 어플리케이션의 전역에서 사용되는 기능입니다.


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

BACK-END 의 API 는 GET: /api/account/search/:username, 혹은 GET: /api/account/search 로 접근한 요청에 대해서 처리하는 라우터로, 전달받은 url 파라메터(username)를 이용하여 DB (accounts 콜렉션) 에서 데이터를 조회하고, 조회된 결과를 리턴합니다. (배열)

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

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

세 번째로 살펴볼 것은 App, Header, Search 컴포넌트입니다.

App 컴포넌트에 리덕스를 연결하여 DB 로부터 유저 검색 결과를 받아올 수 있는 thunk 를 props 로 사용할 수 있도록 한 뒤, 해당 thunk 를 사용하는 메소드를 만들어 Header 컴포넌트로 전달해 줍니다. Header 컴포넌트는 전달받은 thunk 실행 메소드를 Search 컴포넌트로 다시 전달해 줍니다. Search 컴포넌트는 HEader 컴포넌트 state 에 따라 보여지거나 안보여지는 컴포넌트입니다. 이 컴포넌트의 input 창이 변경될 때마다 Header 로 부터 onSearch props 로 전달받는 thunk 함수를 input 창에 입력된 keyword 로 하여 실시간으로 실행합니다.



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

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

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

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

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

7. API 가 검색 결과를 리턴

8. 리턴받은 검색결과(배열)를 리덕스 state 에 저장하고 해당 값을 App, Header, Search 로 전달. (4~8의 과정은 실시간으로 진행됨)


3-1) BACK-END API 구현


DB 와 통신을 통하여 DB 로 부터 검색결과를 받아 리턴하는 API 입니다.


(./server/routes/account.js)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/*
    SEARCH USER: GET /api/account/search/:username
*/
router.get('/search/:username', (req, res) => {
    // SEARCH USERNAMES THAT STARTS WITH GIVEN KEYWORD USING REGEX
    var re = new RegExp('^' + req.params.username);
    Account.find({username: {$regex: re}}, {_id: false, username: true})
    .limit(5)
    .sort({username: 1})
    .exec((err, accounts) => {
        if(err) throw err;
        res.json(accounts);
    });
});
 
// EMPTY SEARCH REQUEST: GET /api/account/search
router.get('/search', (req, res) => {
    res.json([]);
});
cs


검색 기능과 관련된 라우터는 두 개입니다. 하나는 url 로 검색할 요청이 들어온 경우고, 다른하나는 검색할 username 요청이 없는 경우입니다.

GET 방식으로 /api/account/search/:username 으로 접근한 요청에 대한 처리이며, account 라우터에 추가한 이유는 accounts 콜렉션에서 username 을 쿼리하기 때문입니다. (Account 모델을 통해 accounts 콜렉션의 도큐먼트들에 접근가능)

코드릐 4 번째 줄은 검색할 유저명을 입력한 경우이며, url 로 들어오는 username 값으로 정규표현식을 만들어 해당 표현식을 이용하여 DB 에서 검색합니다. 이 때, 프로젝션을 통해서 username 필드만 표시되도록 설정하였습니다. (코드의 7번째 줄)

검색결과는 [{“username”:”test1”}, {“username”:”test1”}] 과 같은 배열 형식입니다. 그리고 검색 결과를 리턴(response) 합니다.

17 번째 줄은 검색 요청값이 없는 경우로 빈 배열을 리턴합니다.


3-2) Redux 구현


3-2-1) ActionTypes


액션객체에서 type 속성값으로 사용될 actionTypes 를 추가해줍니다.


(./src/actions/Actiontypes.js)

1
2
3
4
// Search user
export const SEARCH = "SEARCH";
export const SEARCH_SUCCESS = "SEARCH_SUCCESS";
export const SEARCH_FAILURE = "SEARCH_FAILURE";
cs


3-2-2) thunk 및 액션 생성자 함수


검색 기능을 위한 thunk, 액션 생성자 함수를 위해 src/actions 디렉토리에 search.js 파일을 생성하겠습니다.


(./src/actions/search.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
import {
    SEARCH,
    SEARCH_SUCCESS,
    SEARCH_FAILURE
} from './ActionTypes';
import axios from 'axios';
 
export function searchRequest(keyword) {
    return (dispatch) => {
        dispatch(search());
 
        return axios.get('/api/account/search/' + keyword)
        .then((response) => {
            dispatch(searchSuccess(response.data));
        }).catch((error) => {
            dispatch(searchFailure());
        });
    };
}
 
export function search() {
    return {
        type: SEARCH
    };
}
 
export function searchSuccess(usernames) {
    return {
        type: SEARCH_SUCCESS,
        usernames
    };
}
 
export function searchFailure() {
    return {
        type: SEARCH_FAILURE
    };
}
cs


코드의 1~5 번째 줄에서 위에서 생성한 actiontypes 를 import 했습니다.


코드의 12, 27, 34 번째 줄은 액션 생성자 함수로, 각각 검색중, 검색 성공, 검색 실패를 의미하는 action 객체를 리턴합니다.

검색 성공을 의미하는 액션 객체에는 usernames 필드의 값으로 DB 상의 검색결과가 배열의 형태로 포함되어 있습니다.


8번째 줄에서는 API 와 통신을 주고 받고, 상태에 따라서 다른 액셩 객체를 리듀서로 전달하는 thunk 함수를 정의 했습니다.

함수의 인자로 keyword 를 받으며, 함수가 실행되면 먼저 실행중이라는 의미의 액션객체를 리듀서로 보냅니다. (10 번째 줄)

그리고 axios 를 통해 API 와 통신을 하는데, 이 때, 통신경로를 함수의 인자로 들어온 keyword 값에 따라 다르게 하여 통신합니다.

통신이 끝나고 DB 로 부터 제대로 결과를 받아오면 그 결과를 usernames 필드값으로 취하는 성공 액션 객체를 리듀서로 전달합니다. (14번째 줄)

만약 API 와 통신에 실패하면 실패를 의미하는 액션객체를 리듀서로 전달합니다. (16번째 줄)


3-2-3) 리듀서


전달받은 액션 객체의 type 값에 따라 리덕스 state 를 변경하는 역할을 하는 리듀서를 구현해 보도록 하겠습니다.

src/reducers 디렉토리에 search.js 파일을 생성합니다.


(./src/reducers/search.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
import * as types from 'actions/ActionTypes';
 
const initialState = {
    status: 'INIT',
    usernames: []
};
 
export default function search(state = initialState, action) {
 
    switch(action.type) {
        case types.SEARCH:
            return {
              ...state,
              status: 'WAITING'
            }
        case types.SEARCH_SUCCESS:
            return {
              ...state,
              status: 'SUCCESS',
              usernames: action.usernames
            }
        case types.SEARCH_FAILURE:
            return {
              ...state,
              status: 'FAILURE',
              usernames: []
            }
        default:
            return state;
    }
}
cs


코드의 3 번째 줄에서 기본 state 값을 설정했습니다.

status 는 검색 요청에 대한 상태를 알리는 필드이고, usernames 필드는 검색결과가 들어오는 필드입니다. (배열의 형식으로 저장)


11 번째 줄은 검색 요청일때 state 를 어떻게 변경할지 정하는 코드이며, state.search.status 값을 WAITING 으로 변경합니다.

16 번째 줄은 검색에 성공했을 때 변경되는 state 를 정의한 코드이며, state.search.status 값은 SUCCESS 로, usernames 에는 API 가 리턴한 배열을 값으로 넣어 리턴합니다.

22 번째 줄은 실패 했을 떄의 state 변경코드이며, state.search.status 를 FAILURE 로, state.search.usernames 는 빈 배열을 값으로 지정합니다.


새로운 파일이 생성되었으므로 reducers 디렉토이의 index 에 search 를 추가해줍니다.


(./src/reducers/index.js)

1
2
3
4
5
6
7
8
9
10
11
import authentication from './authentication';
import memo from './memo';
import search from './search';
 
import { combineReducers } from 'redux';
 
export default combineReducers({
    authentication,
    memo,
    search
});
cs


3-3) App 컨테이너에 Redux 연결


App 컨테이너에서 thunk 함수와 리덕스 state 를 사용할 수 있도록 매핑해줍니다.


(./src/containers/App.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
...
import { searchRequest } from 'actions/search';
...
class App extends Component {
...
    handleSearch = (keyword) => {
        this.props.searchRequest(keyword);
    }
...
    render(){
      ...
      return (
        <div>
          {isAuth ? undefined : <Header isLoggedIn={this.props.status.isLoggedIn}
                                        onLogout={this.handleLogout}
                                        onSearch={this.handleSearch}
                                        usernames={this.props.searchResults}
                                        history={this.props.history}/> }
        </div>
      );
    }
}
...
const mapStateToProps = (state) => {
    return {
        ...,
        searchResults: state.search.usernames
    };
};
 
const mapDispatchToProps = (dispatch) => {
    return {
        ...},
        searchRequest: (keyword) => {
            return dispatch(searchRequest(keyword));
        }
    };
};
...
 
cs


26, 33 번째 줄을 통해 리덕스 state 와 thunk 함수를 매핑했습니다. 

이를 통해 App 컴포넌트에서 this.props.searchResults 와 같이 프롭스로 리덕스 상태와 thunk 함수를 사용할 수 있습니다.


6 번째 줄에서 thunk 함수를 실행하는 handleSearch 메소드를 만들고 16 번째 줄에서 Header 컴포넌트에 onSearch 프롭스로 전달하였습니다. 17 번째줄에서 검색결과도 전달하였습니다.

* 18 번째 줄은 Search 컴포넌트에서 페이지 이동을 위해 전달해 줍니다. (라우트 컴포넌트에 기본적으로 전달되는 프롭스)


3-4) Header 컴포넌트에서 Search 컴포넌트로 검색결과와 thunk 메소드 전달


먼저 Header 컴포넌트에서 검색버튼을 누르면 보여질 Search 컴포넌트를 생성하도록 하겠습니다.

src/components 디렉토리에서 Search.js 파일을 생성해주세요.


(./src/components/Search.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
import React, { Component } from 'react';
 
class Search extends Component {
    render() {
      const mapDataToLinks = (data) => {
          // IMPLEMENT: map data array to array of Link components
          // create Links to '/wall/:username'
      };
 
      return (
          <div className="search-screen white-text">
              <div className="right">
                  <a className="waves-effect waves-light btn red lighten-1">CLOSE</a>
              </div>
              <div className="container">
                  <input placeholder="Search a user"></input>
                  <ul className="search-results">
                      { mapDataToLinks(this.props.usernames) }
                  </ul>
 
              </div>
          </div>
      );
    }
}
 
export default Search;
cs


Search 컴포넌트에 맞는 스타일을 지정해 주도록 하겠습니다.


(./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
/* SEARCH */
.search-screen {
    position: fixed;
    overflow-y: none;
    left: 0px;
    top: 0px;
    height:100%;
    width:100%;
    background-color: rgba(0, 0, 0, 0.70);
    z-index: 99;
}
.search-screen input {
    text-align: center;
    font-size: 50px;
    line-height: 80px;
    margin-top: 10vw;
    height: 80px;
    font-weight: 200;
}
.search-screen .btn {
    margin-top: 14px;
    margin-right: 20px;
}
ul.search-results {
    text-align: center;
    font-size: 30px;
    margin-top: 0px;
}
.search-results a {
    padding: 10px;
    display: block;
    color: white;
}
.search-results a + a {
    border-top: 1px solid #5F5F5F;
}
.search-results a:hover {
    background-color: rgba(255, 255, 255, 0.10);
}
cs


새로운 컴포넌트를 만들었으니 components index 에 Search 컴포넌트를 추가해 줍시다.


(./src/components/imdex.js)

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


그럼 Header 컴포넌트에서 Search 컴포넌트로 thunk 실행 메소드와 검색 결과를 전달해주도록 합시다.


(./src/components/Header.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 { Search } from 'components';
...
class Header extends Component {
    state = {
      search: false // 이 state 값에 따라 검색창을 보이거나 안보이거나
    }
 
    toggleSearch = () => {
        this.setState({
            search: !this.state.search
        });
    }
    render() {
      ...
      return (        
        <div>
          <nav>
              <div className="nav-wrapper blue darken-1">
                  <Link to ="/" className="brand-logo center">MEMOPAD</Link>
 
                  <ul>
                      <li><a onClick={this.toggleSearch}><i className="material-icons">search</i></a></li>
                  </ul>
 
                  <div className="right">
                      <ul>
                        { this.props.isLoggedIn ? logoutButton : loginButton }
                      </ul>
                  </div>
              </div>
          </nav>
          {this.state.search ? <Search onClose={this.toggleSearch}
                      onSearch={this.props.onSearch}
                      usernames={this.props.usernames}
                      history={this.props.history}/> : undefined }
        </div>
      );
    }
}
 
Header.propTypes = {
    ...,
    usernames: PropTypes.array,
    onSearch: PropTypes.func,
    history: PropTypes.object
};
 
Header.defaultProps = {
    ...,
    usernames: [],
    onSearch: () => { console.error("search function not defined");},
    history: {}
};
...
cs


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

1 번째 줄에서 Search 컴포넌트를 불러왔습니다.

Search 컴포넌트는 Header의 검색 버튼을 누르면 나타나고, 나타난 Search 컴포넌트 뷰 안에서 닫기 버튼을 누르거나 ESC 버튼을 누르면 사라지도록 할 것입니다. 

이를 위해 Header 컴폰넌트의 state 를 활용할 것입니다. 

Header 컴포넌트의 state.search 값을 설정하고(4 번째 줄), 그 값이 true 이면 Search 컴포넌트를 렌더링하고, false 이면 렌더링 하지 않습니다. (32 번째 줄)

8 번째 줄에서 state.search 를 토글하는 메소드를 만들고, 검색버튼의 onClick 이벤트에 등록해줍니다. (22 번째 줄)

그리고 32 번째 줄을 통해 토글 메소드를 전달 (Search 컴포넌트 안에서 닫기위해)하고, App 으로 부터 전달받은 onSearch 메소드(thunk 실행 메소드)와 검색결과인 usernames 값을 Search 컴포넌트로 전달해 줍니다. (33, 34 번째 줄)

35 번째 줄의 history 는 Search 컴포넌트 안에서 이동을 위해 전달해 줍니다.


추가적으로 Search 컴포넌트가 렌더링 될 때 애니메이션 효과를 추가해 주도록 합시다.

Search 컴포넌트가 마운트 될때 적용될 것이므로 Header 컴포넌트에서 ReactCSSTransitionGroup 를 이용해 Search 컴포넌트를 감싸주어야 합니다.


(./src/components/Header.js)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
...
import ReactCSSTransitionGroup from 'react-addons-css-transition-group';
...
    render() {
      ...
      return (
        <div>
          ...
          <ReactCSSTransitionGroup transitionName="search" transitionEnterTimeout={300} transitionLeaveTimeout={300}>
            {this.state.search ? <Search onClose={this.toggleSearch}
                        onSearch={this.props.onSearch}
                        usernames={this.props.usernames}
                        history={this.props.history}/> : undefined }
          </ReactCSSTransitionGroup>
        </div>
      );
    }
}
...
cs


이에 맞는 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
/* Search Animation */
@-webkit-keyframes search-enter {
    0% {
      opacity: 0;
      height: 0%;
    }
    100% {
      opacity: 1;
      height: 100%;
    }
}
@keyframes search-enter {
    0% {
      opacity: 0;
      height: 0%;
    }
    100% {
      opacity: 1;
      height: 100%;
    }
}
.search-enter {
    -webkit-animation-duration: 0.3s;
    animation-duration: 0.3s;
    -webkit-animation-name: search-enter;
    animation-name: search-enter;
}
@-webkit-keyframes search-leave {
    0% {
      opacity: 1;
      height: 100%;
    }
    100% {
      opacity: 0;
      height: 0%;
    }
}
@keyframes search-leave {
    0% {
      opacity: 1;
      height: 100%;
    }
    100% {
      opacity: 0;
      height: 0%;
    }
}
.search-leave {
    -webkit-animation-duration: 0.3s;
    animation-duration: 0.3s;
    -webkit-animation-name: search-leave;
    animation-name: search-leave;
}
cs


3-5) Search 컴포넌트에서 thunk 함수 실행


Search 컴포넌트에서 thunk 의 인자(DB 에 검색할 username)를 결정해서 실행하는것을 구현해 보겠습니다.


(./src/components/Search.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
89
90
91
92
93
94
95
96
97
98
99
100
101
102
import React, { Component } from 'react';
import { Link } from 'react-router-dom';
import PropTypes from 'prop-types';
 
class Search extends Component {
    constructor(props) {
        super(props);
 
        this.state = {
            keyword: ''
        };
 
        // LISTEN ESC KEY, CLOSE IF PRESSED
        const listenEscKey = (evt) => {
            evt = evt || window.event;
            if (evt.keyCode == 27) {
                this.handleClose();
            }
        };
 
        document.onkeydown = listenEscKey;
 
    }
 
    handleClose = () => {
        this.handleSearch(''); // 종료될 때 공백을 검색함으로 검색목록(배열)을 비운다
        document.onkeydown = null// 컨스트럭터에서 리스닝한 onkeydown 이벤트를 해제
        this.props.onClose();
    }
 
    handleSearch = (keyword) => {
        this.props.onSearch(keyword);
    }
 
    handleChange = (e) => {
        this.setState({
            keyword: e.target.value
        });
        this.handleSearch(e.target.value);
    }
 
    handleKeyDown = (e) => {
        // IF PRESSED ENTER, TRIGGER TO NAVIGATE TO THE FIRST USER SHOWN
        if(e.keyCode === 13) {
            if(this.props.usernames.length > 0) {
                this.props.history.push('/wall/' + this.props.usernames[0].username);
                this.handleClose();
            }
        }
    }
 
    render() {
      const mapDataToLinks = (data) => {
          return data.map((user, i) => {
              return (
                  <Link onClick={this.handleClose} to={`/wall/${user.username}`} key={i}>
                      {user.username}
                  </Link>
              );
          });
      };
 
      return (
          <div className="search-screen white-text">
              <div className="right">
                  <a className="waves-effect waves-light btn red lighten-1"
                      onClick={this.handleClose}>CLOSE</a>
              </div>
              <div className="container">
                  <input placeholder="Search a user"
                          value={this.state.keyword}
                          onChange={this.handleChange}
                          onKeyDown={this.handleKeyDown}></input>
                  <ul className="search-results">
                      { mapDataToLinks(this.props.usernames) }
                  </ul>
              </div>
          </div>
      );
    }
}
 
Search.propTypes = {
    onClose: PropTypes.func,
    onSearch: PropTypes.func,
    usernames: PropTypes.array,
    history: PropTypes.object
};
 
Search.defaultProps = {
    onClose: () => {
        console.error('onClose not defined');
    },
    onSearch: () => {
        console.error('onSearch not defined');
    },
    usernames: [],
    history: {}
};
 
export default Search;
 
cs


코드가 좀 많긴하지만 천천히 살펴보겠습니다.


먼저, 상위 컴포넌트로부터 프롭스를 전달 받았으니 propTypes 와 defaultProps 를 지정해 주어야겠죠? (83, 90 번째 줄)


코드의 14 번째 줄에서 컨스트럭터 안에서 listenEscKey 를 지정합니다. 이 메소드의 인자는 이벤트객체로, evt.keyCode (입력한 키보드의 키) 값이 27 (ESC 키) 일때 Search 컴포넌트를 종료하는 메소드를 실행시키는 것 입니다.

그리고 이 listenEscKey 를 도큐먼트의 onKeydown 이벤트에 등록합니다. (21번째 줄)


Search 컴포넌트를 종료하는 handleClose 메소드는 25 번째 줄에서 정의 하였습니다.

thunk 함수를 실행하는 메소드에 빈 인자를 주어 실행하고, 도큐먼트의 onKeydown 이벤트를 해제 시킵니다.

그리고 props 로 들어온 onClose 함수를 실행합니다. (Header 컴포넌트의 스테이트 변경으로 Search 컴포넌트 언마운트)


31 번째 줄의 handleSearch 메소드는 thunk 를 실행하는 메소드입니다. 인자로 들어오는 값으로 thunk 함수를 실행합니다.


우리는 input 창에 값을 입력할 때마다 실시간으로 onSearch 메소드를 실행할 것입니다.

이를 위해 컴포넌트 state 를 사용할 것입니다. 코드의 9 번째 줄에서 state 를 정의해 줍니다.


35 번째 줄에서 input 태그와 state 를 연동시키기 위해 handleChange 메소드를 정의습니다. 

입력된 값으로 state 를 변경하고, 변경된 state (state.keyword) 값을 인자로하여 handleSearch 메소드를 실행합니다.


input 태그에 연동하기 위해 onChange 이벤트에 handleChange 를 등록, value 값은 this.state.keyword 로 지정하여 실시간으로 state 값과 입력된 값을 연동시킵니다. (71~72 번째 줄)

 

코드의 42 번째 줄은 input 창에서 엔터키를 눌렀을 때 첫 번째 검색 결과로 이동시키는 코드입니다. 

이때 App 에서부터 전달받아온 history 를 사용합니다. history.push 를 이용하면 원하는 url 로 사용자를 이동시킬 수 있습니다. 

이동시킨 뒤에는 Search 컴포넌트를 종료시킵니다.

해당 메소드를 input 태그의 onKeyDown 이벤트에 등록해 엔터키가 눌리는것을 리스닝하고 눌리면 해당 메소드를 실행하도록 합니다. (73 번째 줄)


검색결과는 컴포넌트 매핑을 통하여 input 창 아래에 뜨는데, 이는 53 번째 줄을 통해 가능합니다.

* 이때 JS 의 map 메소드를 이용하였습니다. 이에 익숙하지 않으신 분들은 아래의 링크를 참조해 주세요.

<배열의 map 메소드>


Search 컴포넌트에서 작동하는 코드들은 모두 실시간으로 동작하기 때문에 test 를 검색 하기 위해 t 만 입력해도 아래에 t 로시작하는 유저이름들이 뜰 것입니다. 엔터를 치면 첫 번째 결과의 담벼락으로 이동합니다.



서버를 구동하여 한 번 제대로 작동하는지 확인해 봅시다.

어떤가요? 제대로 작동하나요? 검색을 통해 담벼락으로 이동까지 한 번 해 보세요!


여기까지...


Memo 어플리케이션의 주요기능들을 모두 구현했습니다.

항상 어떤 방식으로 동작하는지 아는 부분이 제일 중요합니다.

여기까지 따라오셨다면 동작방식에도 익숙해 지셨으리라 생각합니다.


다음 포스팅에서는 어플리케이션의 성능을 개선해 보도록 하겠습니다.


감사합니다.


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

https://velopert.com/1921


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

Comments