socketIO를 이용해 양방향 통신을 구현하는 것은 꽤 간단하다. (물론, 인증이나 채팅룸 등 다양한 기능을 추가하면 복잡해진다.)
하지만, 리액트를 이용해 개발을 하다보면 redux-saga를 자연스럽게 자주 쓰게되는데, 생각보다 까다로웠다. 리덕스 사가의 채널을 이용해 소켓통신을 구현했을 때 장점에 대해 짚어보고, 양방향 통신을 직접 구현하며 포스팅을 진행하려한다.
SocketIO
- socketIO는 실시간 웹 애플리케이션을 위한 Javascript라이브러리다.
- 웹 클라이언트와 서버간에 실시간 양방향 통신이 가능하다.
- 채팅 등의 기능을 구현하는데 적절하다.
- 클라이언트 측 라이브러리, 서버(Node) 측 라이브러리 두 부분으로 구성된다.
먼저 redux 설계는 ducks 패턴
을 이용했다.
포스팅 주제와는 다르니 제외하고 소켓과 리덕스사가 채널을 연결하는 부분만 살펴본다.
소스코드는 포스팅 하단에 적어놓겠다.
우선 서버측 코드부터 작성해본다.
전체 코드 양도 많지않지만, 서버측이 20% 클라이언트가 80% 정도 되는 것 같다.
서버 코드는
express 공식문서의 hello world 예제를 프로젝트 루트의 server.js에 복붙하고, socket 코드만 추가했다.
먼저 express
와 socket.io
를 설치한다.
yarn add socket.io express
간단하게 server.js
와 socket.js
로만 구성돼있다.
const express = require('express')
const app = express()
const port = 3000
// http서버
const server = require('http').createServer(app)
const socket = require("./socket")
// 소켓설정 부분
socket(server, app)
// GET /some으로 요청이 들어올 경우 소켓접속자에게 데이터를 전송
app.get("/some", (req,res) => {
console.log("------------")
const io = app.get("io")
io.emit("tasks", { id: 9999})
res.send("Hi")
});
server.listen(port, () => {
console.log(`Example app listening at http://localhost:${port}`)
})
const SocketIO = require("socket.io");
module.exports = (server, app) => {
const io = SocketIO(server, {
cors:{
origin: "*",
methods: ["*"]
},
path: "/socket"
});
app.set("io", io);
io.on("connect", (socket) => {
console.log("----- socket connected");
let id = 0;
setInterval(function() {
io.emit("tasks", { id: id, images: testImages});
id++;
if (id >= 20) {
clearInterval(this);
}
}, 1500);
socket.on("disconnect", () => {
console.log("----- socket disconnected");
})
})
}
socket.js
는 함수를 module.exports
하고 있는데, 내보내는 함수의 내부에서 socket객체를 생성한 뒤, app.set("io", io)
로 express 구성에 추가했다. 여기서 추가해줬기 때문에, GET /some
요청에 socket을 사용할 수 있다. 앱 내에서 언제든 소켓객체를 사용할 수 있다.
특정 미들웨어를 만들어, 특정 라우트에서만 소켓객체를 사용할 수도 있겠다.
io.on("connect", (socket) => {})
부분을 보면 소켓에 연결되었을 때 어떤 동작을 수행할 지 작성 되어있다. connect
와 connection
은 동일하다.
이 함수 내부의 코드를 보면 setInterval
함수로 1.5초 마다 emit
을 수행 하고 있다. 연결된 클라이언트에게 특정 데이터를 전달하는 역할을 한다. 보다시피 1.5초마다 데이터를 전송하고 있고 id값이 증가하며, id가 20이상이되면 clearInterval
로 해제한다.
그리고, connect
disconnect
일 때는 서버측에서 콘솔로그만 찍는다.
서버측 코드 작성이 끝났다.
앱 최초실행 즉시 소켓으로 서버에 연결한다.
연결 후 서버에서는 1.5초마다 소켓을 통해 데이터를 전달하고 20회 반복한다.
추가로 GET /some 라우트로 요청이 들어오면 소켓으로 응답한다.
create-react-app
를 이용해 간단하게 프로젝트를 생성하고 필요한 모듈들을 설치한다
create-react-app과 redux, redux-saga에 대한 설명과 코드는 포스팅내용에서 생략했다.
하단에 클라이언트측의 소스코드 링크가 있으니 참고하면 좋다.
create-react-app socket-test-client && cd socket-test-client
다음으로 리덕스, 사가, 소켓클라이언트를 포함해 필요한 모듈을 설치한다.
yarn add socket.io-client redux react-redux redux-saga immer
socket.js
라는 파일을 만들고 socket객체를 내보내도록 만들어보자
import io from 'socket.io-client';
// 소켓 연결 객체
export default io("http://localhost:3000", {path: "/socket"});
이제 이 소켓 객체를 import해서 사용하면 된다.
// 소켓데이터 수신하는 채널 "tasks" 생성
useEffect(() => {
dispatch(socketActions.waitTask());
}, []);
socketContainer.js
라는 이름의 컴포넌트를 만들고 최초 마운트 시 소켓연결 action을 dispatch하고 있다.
이 액션함수 waitTask
가 dispatch되면 사가함수가 실행되도록 코드를 작성한다.
리덕스는 ducks
패턴으로 작성했다.
먼저 redux의 reducer생성 함수를 만든다. immer 라이브러리를 이용했다
// createReducer.js
import produce, {enableES5} from 'immer';
// createReducer는 reducer 생성자 함수
// reducer를 리턴한다.
// reducer는 immer패키지의 produce메서드를 이용해서
// action에 따라 store의 상태를 불변객체로 변경한다.
export default function createReducer(initialState, handlerMap) {
enableES5();
return function (state = initialState, action) {
return produce(state, (draft) => {
const handler = handlerMap[action.type];
if (handler) {
handler(draft, action);
}
});
};
}
이어서 ducks
패턴으로 리덕스를 구축한다.
store의 상탯값을 직접 조작하는 부분은 아직 없기 때문에 types
actions
에만 waittask
를 추가했다.
// state.js
import createReducer from '@utils/createReducer';
export const types = {
WAIT_TASK: 'socket/WAIT_TASK',
};
export const actions = {
waitTask: () => ({
type: types.WAIT_TASK,
}),
};
export const INITIAL_STATE = {
tasks: [],
};
const reducer = createReducer(INITIAL_STATE, {
});
export default reducer;
// saga.js
import { call, put, take, takeEvery } from 'redux-saga/effects';
import socket from '~/config/socket';
import { closeChannel, createSocketChannel } from './createSocketChannel';
import { actions, types } from './state';
function* waitTask() {
let channel;
try {
channel = yield call(createSocketChannel, "tasks");
while(true) {
const task = yield take(channel);
yield onTask(task);
}
} catch(e) {
console.log(e, "error");
} finally {
socket.close();
closeChannel(channel);
}
}
function* onTask(task) {
yield put(actions.pushTask(task));
}
export default function* watcher() {
yield takeEvery(types.WAIT_TASK, waitTask);
}
가장 아랫부분에서 보면 takeEvery
로 모든 dispatch를 놓치지않고 구독하도록 했다. WAIT_TASK
가 dispatch하면 waitTask
사가함수가 실행된다.
사가함수 waitTask
가 실행되면 createSocketChannel
이라는 함수를 호출하는데 여기에 사가의 eventChannel
기능을 이용해 socket을 연결했다.
// createSocketChannel.js
import {eventChannel, buffers} from 'redux-saga';
import socket from '../../config/socket';
// 기본 matcher, buffer
const defaultMatchers = () => true;
const defalutBuffer = buffers.none();
// 소켓 이벤트채널 생성 팩토리함수
export function createSocketChannel(eventType, buffer = defalutBuffer, matchers = defaultMatchers) {
return eventChannel(
emit => {
const emitter = (message) => {
emit(message);
};
socket.on(eventType, emitter);
// 항상 unsubscribe 함수를 반환해야한다.소스코드가 종료되기전에 socket.off 시키고있다
// .
return () => {
socket.off(eventType, emitter);
}
},
buffer,
matchers,
)
}
export function closeChannel(channel) {
if (channel) {
channel.close();
}
}
아까 만들었던 소켓인스턴스를 사용하고 있으며, matcher
와 buffer
는 추가적인 기능이다. 2,3번째 인자로 그 값이 주어지지 않을경우 defaultMatchers
와 defaultBuffer
를 이용한다.
eventChannel
의 emit
을 이용해 채널을 take
했을 때 반환할 값을 넣어준다.
socket.on(eventType, emitter);
이벤트 타입에 맞는 소켓이벤트가 실행됐을 때 채널은 소켓을 통해 전달된 값(message)
을 리턴한다.
다시 saga.js로 돌아가서
const task = yield take(channel)
에 따라 전달된 task
로 onTask를 호출한다.
function* onTask(task) {
yield put(actions.pushTask(task));
}
pushTask는 리덕스 스토어에 배열형태로 데이터를 추가해주는 액션이다.
// state.js
import createReducer from '@utils/createReducer';
import moment from 'moment';
export const types = {
WAIT_TASK: 'socket/WAIT_TASK',
PUSH_TASK: 'socket/PUSH_TASK',
};
export const actions = {
waitTask: () => ({
type: types.WAIT_TASK,
}),
pushTask: (payload) => ({
type: types.PUSH_TASK,
payload,
}),
};
export const INITIAL_STATE = {
tasks: [],
};
const reducer = createReducer(INITIAL_STATE, {
[types.PUSH_TASK]: (state, action) => {
state.tasks.push({...action.payload, time: moment(new Date()).format("HH시 mm분 ss초") });
},
});
export default reducer;
앱 마운트 시 saga eventChannel을 이용한 소켓연결, 서버에서 데이터 전송 시 redux store에 데이터가 추가되는 로직이 완성됐다.
화면 측 코드는 생략했다.
redux store의 배열데이터를 가져와 화면에 보여줬다.
연결상태는 소켓인스턴스의 connect
, disconnect
이벤트를 이용해 리액트 상태값을 변경해주면 된다.
useEffect(() => {
socket.on("connect", () => {
setOn(true);
})
socket.on("disconnect", () => {
setOn(false);
socket.connect();
})
}, []);
GET /some
으로 요청을 보내서 서버에서 socket을 이용한 응답을 하는지 확인해본다.
app.get("/some", (req,res) => {
console.log("------------");
const io = app.get("io");
io.emit("tasks", { id: 9999, images: testImages});
res.send("aa");
});
http요청에도 연결된 socket을 활용할 수 있다.
간단하게 socket.io
와 사가의 eventChannel
을 이용해 소켓통신을 구현해봤다.
socket.io
는 단순한 양방향 통신외에도 인증이나 채널 귓속말 등 다양한 기능을 제공한다.
리덕스 사가의 channel
과 socket.io
의 궁합이 꽤 좋은 것 같다. 이 후에 양방향통신 구현이 필요할 때에 활용할지 고민해봐야겠다.
참고글: https://meetup.toast.com/posts/114
참고글: socket.io 공식문서
참고글: redux-saga 한글 (채널)
Github 리포지토리: 클라이언트