목표
- 여러 소켓 URL을 갖는 서비스를 개발하기 위한 구조를 잡는다.
- 서비스별로 소켓 메시지를 처리하는 클래스를 작성하여 분리 할 수 있도록 한다.
- 클라이언트는 자신의 화면(또는 컴포넌트)에서 사용 할 소켓 커맨드에만 집중 할 수 있도록 한다.
용어 설명
개발 가이드
1. 개발 환경
- Java 17
- Spring Boot 3.1.2
- Gradle 8.2.1
- Spring Boot Dependenciesgroovy
// LocalDateTime support for Jackson implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-websocket' compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' developmentOnly 'org.springframework.boot:spring-boot-docker-compose' runtimeOnly 'org.mariadb.jdbc:mariadb-java-client' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test'org.springframework.boot:spring-boot-starter-websocket: WebSocket 지원
2. 프로토콜
통신은 기본적으로 JSON을 이용하여 데이터를 주고 받는다.
2.1. 클라이언트 -> 서버 프로토콜
커맨드와 데이터를
::로 구분하고, 커맨드는 일반 문자열(영문 대문자 스네이크 케이스)을 사용하고, 데이터는 JSON 형식으로 전달한다.
예시
CS_GET_ROOM::{"roomId": 1}2.2 서버 -> 클라이언트 프로토콜 and 브로드 캐스팅 프로토콜
서버에서 클라이언트로 전달하는 프로토콜은 JSON 형식으로 전달한다.
예시
{"command": "SC_GET_ROOM", "code": 200, "message": "OK", "data": {"roomId": 1}}command- 클라이언트로 전달 할 커맨드이다.
- 커맨드를 기준으로 작동 함수를 호출한다.
code- 커맨드 수행 결과 응답 코드이다.
message- 커맨드 수행 결과 응답 메시지이다.
data- 클라이언트 요청 커맨드에 대한 응답 데이터이다.
2.3. 커맨드 작성 규칙
- 커맨드는 영문 대문자 스네이크 케이스를 사용한다.
- 커맨드는 prefix로
CS_,SC_,BC_를 사용한다.CS_: 클라이언트 -> 서버SC_: 서버 -> 클라이언트BC_: 브로드 캐스팅
- Prefix 이후 커맨드는 동사로 시작한다. 주요 동사는 다음 예제를 참고하고, 예시 외에도 필요시 정의하여 사용 할 수 있다.
GET_: 조회SET_: 설정 또는 저장REG_: 신규 데이터 등록UPD_: 데이터 수정DEL_: 데이터 삭제RES_: 요청에 대한 처리 결과 응답
BC_커맨드의 경우 위 3번 규칙을 무시 할 수 있다.- 이 후 커맨드 이름을 명시적으로 작성한다. 몇가지 예를 들면 다음과 같다.
CS_GET_ROOM: 클라이언트가 룸 정보를 조회 한다.SC_RES_GET_ROOM: 클라이언트의GET_ROOM명령 요청에 대해 룸 정보를 응답 한다.BC_ROOM: 신규 작성 또는 수정된 룸 정보를 브로드 캐스팅 한다.
3. 개발 가이드
3.1. 서버
3.1.1. 주요 클래스
프로젝트 루트 패키지에
socket패키지를 생성하여 관련 클래스를 작성한다. 이후 각 서비스별로 패키지를 추가(아래 예시에서는chat)하여 관련 클래스를 상속/구현하여 사용한다.
❯ tree ./socket
./socket
├── SocketClient.java
├── SocketClientPool.java
├── SocketCommand.java
├── SocketListener.java
├── SocketService.java
└── chat
├── ChatSocketClient.java
├── ChatSocketClientPool.java
├── ChatSocketController.java
├── client
│ ├── ChatClientSocketService.java
│ └── dto
│ ├── BcOnlineDto.java
│ ├── CsGetClientDto.java
│ └── ScResClientDto.java
├── message
│ ├── ChatMessageSocketService.java
│ └── dto
│ ├── BcMessageDto.java
│ └── CsSendMessageDto.java
├── room
│ ├── ChatRoomSocketService.java
│ └── dto
│ ├── CsGetRoomDto.java
│ ├── CsJoinRoomDto.java
│ ├── CsRegRoomDto.java
│ ├── ScResRegRoomDto.java
│ ├── ScResRoomDto.java
│ └── ScResRoomUserDto.java
└── user
├── ChatUserSocketService.java
└── dto
├── CsGetFriendDto.java
└── ScResFriendDto.java
10 directories, 25 filesSocketClient.java- 클라이언트 정보를 담는 클래스이다.
SocketClientPool에 등록된다.
SocketClientPool.java- 클라이언트 정보를 관리하는 클래스이다.
SocketClient를 등록/삭제/조회 하는 메소드를 제공한다.
SocketCommand.java- 커맨드를 정의하는 클래스이다.
SocketListener.java@ServerEndpoint어노테이션을 사용하여 웹 소켓 서버를 구현하는 클래스에서 이벤트 리스트로 등록 할 수 있도록 서비스 클래스에서 구현 해야 하는interface이다.
SocketService.java- 각 소켓 서비스의 하위 서비스에서 소켓 메시지를 처리 하는 클래스에서 상속 받아 사용하는 클래스이다.
3.1.2 주요 공용 메서드
abstract SocketClientPool.java- 멤버 메서드
아래 설명에서
T는T extends SocketClient로 선언 됨.protected void addSocketClient(T socketClient)SocketClientPool에SocketClient를 등록한다.
public T getSocketClient(Session session)SocketClientPool에 등록된SocketClient를 조회한다.jakarta.websocket.Session을 이용하여 조회한다.
public T getSocketClient(String uuid)SocketClientPool에 등록된SocketClient를 조회한다.uuid를 이용하여 조회한다.
public Session getSession(String uuid)SocketClientPool에 등록된SocketClient의Session을 조회한다.uuid를 이용하여 조회한다.
- 추상 메서드
public abstract void addSocketClient(Session session, String uuid, Object... args);- 상속 받는 각 서비스에서 구현해야 하며, 각 서비스별 SocketClient를 생성하여 멤버 메서드
addSocketClient(T socketClient)를 호출하도록 구현한다.
- 상속 받는 각 서비스에서 구현해야 하며, 각 서비스별 SocketClient를 생성하여 멤버 메서드
- 멤버 메서드
abstract SocketService.java- 멤버 메서드
protected void send(SocketCommand<?> command, Session session)Session에SocketCommand를 전송한다.
protected void broadcastAll(SocketCommand<?> command)SocketClientPool에 등록된 모든SocketClient에SocketCommand를 전송한다.
protected void broadcast(SocketCommand<?> command, List<String> uuidList)SocketClientPool에 등록된SocketClient중uuidList에 포함된uuid를 갖는SocketClient에SocketCommand를 전송한다.
protected String getCommand(String message)- 클라이언트로 부터 수신한
message에서 커맨드를 추출하여 반환한다.
- 클라이언트로 부터 수신한
protected <T> T getData(String message, Class<T> clazz)- 클라이언트로 부터 수신한
message에서 데이터를 추출하여clazz타입으로 반환한다.
- 클라이언트로 부터 수신한
protected String getDataString(String message)- 클라이언트로 부터 수신한
message에서 데이터를 추출하여String타입(JSON String)으로 반환한다.
- 클라이언트로 부터 수신한
- 추상 메서드
없음
- 멤버 메서드
3.2 서버 서비스 구현 절차 - 채팅 서비스 예시
3.2.1. 서비스용 패키지 추가
socket패키지 하위에 서비스용 패키지를 추가한다.
socket패키지 하위에 서비스용chat패키지를 추가한다.
3.2.2. EndPoint 클래스 작성
SocketController를 suffix로 사용한다.MVC 패턴 개발 절차와 비슷하게 할 생각이었으나,
EndPointsuffix를 사용하는것이 좀 더 명확 할 것 같기도 하다. 어떤 방향이 좋을지에 대해 논의해 보자.
ChatSocketController.java
@Slf4j
@Service
@ServerEndpoint(value = "/ws/chat")
public class ChatSocketController {
private static final List<SocketListener> socketListenerList = new ArrayList<>();
@OnOpen
public void onOpen(Session session) {
socketListenerList.forEach(socketListener -> socketListener.onOpen(session));
}
@OnMessage
public void onMessage(String message, Session session) {
socketListenerList.forEach(socketListener -> socketListener.onMessage(message, session));
}
@OnClose
public void onClose(Session session) {
socketListenerList.forEach(socketListener -> socketListener.onClose(session));
}
public static void addSocketListener(SocketListener socketListener) {
socketListenerList.add(socketListener);
}
}@ServerEndpoint(value = "/ws/chat")- 웹 소켓 서버를 구현하는 클래스임을 선언한다.
value는 웹 소켓 서버의 URL을 지정한다.
EndPoint 개발 방법에 맞도록
@OnOpen,@OnMessage,@OnClose어노테이션 및 메소드를 추가한다.@OnOpen: 클라이언트가 서버에 연결되면 호출된다.@OnMessage: 클라이언트가 서버에 메시지를 전송하면 호출된다.@OnClose: 클라이언트가 서버와 연결을 종료하면 호출된다.
소켓 리스너 처리용
static멤버 변수 및 멤버 메소드를 추가한다.- 소켓 리스너를 리스트로 저장하기 위한 멤버변수를 추가한다.
javaprivate static final List<SocketListener> socketListenerList = new ArrayList<>();- 소켓 리스너를 리스트에 추가하기 위한 멤버 메소드를 추가한다.
javapublic static void addSocketListener(SocketListener socketListener) { socketListenerList.add(socketListener); }@OnOpen,@OnMessage,@OnClose메소드에서socketListenerList에 등록된SocketListener를 호출하도록 내용을 추가한다.SocketListener.java
javapublic interface SocketListener { void onOpen(Session session); void onMessage(String message, Session session); void onClose(Session session); }EndPoint의 각@OnOpen,@OnMessage,@OnClose메소드에서 각 메소드와 매치되는 Listener의 메소도를 호출하도록 구성한다.
3.2.3. SocketClient 클래스 작성
SocketClient클래스를 상속받아 각 서비스별로 필요한 Properties를 추가하여 구현한다. 채팅서비스에서는 클라이언트의사용자ID와 사용자가 현재 참여하고 있는 채팅방의roomId를 추가하여 구성하였다.
ChatSocketClient.java
@Getter
@Setter
public class ChatSocketClient extends SocketClient {
private final Long userId;
private Long roomId;
public ChatSocketClient(Session session, String uuid, Long userId) {
super(session, uuid);
this.userId = userId;
}
}userId는 불변이므로final로 구성하였다.roomId는 사용자가 채팅방에 참여하면서 변경될 수 있으므로setter를 추가하였다.
3.2.4. SocketClientPool 클래스 작성
SocketClientPool클래스를 상속받아 위 3.2.3. SocketClient 클래스 작성에서 작성한ChatSocketClient를 관리하도록 구현한다.주로
ChatSocketClient클래스에서 추가된 Properties를 관리하는 메소드를 추가하는 작업이 주를 이룬다.
ChatSocketClientPool.java
@Component
public class ChatSocketClientPool extends SocketClientPool<ChatSocketClient> {
@Override
public void addSocketClient(Session session, String uuid, Object... args) {
ChatSocketClient chatSocketClient = new ChatSocketClient(session, uuid, (Long) args[0]);
addSocketClient(chatSocketClient);
}
public boolean isExistUser(Long userId) {
return getSocketClientList().stream()
.anyMatch(chatSocketClient -> chatSocketClient.getUserId().equals(userId));
}
public void setJoinRoom(Long userId, Long roomId) {
getSocketClientList().stream()
.filter(chatSocketClient -> chatSocketClient.getUserId().equals(userId))
.forEach(chatSocketClient -> chatSocketClient.setRoomId(roomId));
}
public List<String> getRoomUserClientIdList(Long roomId) {
return getSocketClientList().stream()
.filter(chatSocketClient -> chatSocketClient.getRoomId() != null
&& chatSocketClient.getRoomId().equals(roomId))
.map(ChatSocketClient::getUuid)
.toList();
}
}- 부모 클래스의 추상 메소드인
addSocketClient(Session session, String uuid, Object... args)를 구현한다.ChatSocketClient를 생성하여addSocketClient(SocketClient socketClient)를 호출하도록 구현한다.args는ChatSocketClient에서 서비스용으로 추가된 Porperties를 받도록 구성한다.
- 서비스에 툭화된 Properties를 관리하는 용도의 공용 메소드를 추가로 정의 할 수 있다.
isExistUser: 입력 받은userId를 갖는 소켓 클라이언트가 존재하는지 여부를 확인하는 메소드setJoinRoom: 입력 받은userId를 갖는 소켓 클라이언트의roomId를 입력 받은roomId로 변경하는 메소드getRoomUserClientIdList: 입력 받은roomId를 갖는 소켓 클라이언트의uuid를 리스트로 반환하는 메소드
3.2.5. 소켓 프로토콜 정의서 확인 및 구현
프로토콜 정의서는 아래 이미지와 같이 작성 된 것으로 가정 하며, 이후 절차는
사용자 관리의친구 목록 조회를 예시로 설명한다.
대분류에 해당하는 하위 패키지를 추가한다.
- 이전 추가된
....socket.chat패키지 하위에user패키지를 추가한다.
- 이전 추가된
dto패키지를 추가한다.서비스 클래스(
ChatUserSocketService)를 추가한다.여기까지 처리하면 아래와 같은 구조가 된다. 소켓 서비스의 클래스명은 여러 소켓 서비스를 사용하는 경우 중복 되지 않도록
UserSocketService보다는 서비스 명을 포함하도록 구성한다.shell❯ tree ./user ./user ├── ChatUserSocketService.java └── dtoSocketService를 상속받고,SocketListener인터페이스를 구현하도록 구성한다.java@Slf4j @Service public class ChatUserSocketService extends SocketService implements SocketListener { public ChatUserSocketService(ObjectMapper objectMapper, ChatSocketClientPool socketClientPool) { super(objectMapper, socketClientPool); } @Override public void onOpen(Session session) { } @Override public void onMessage(String message, Session session) { } @Override public void onClose(Session session) { } }onOpen과onClose는 클라이언트 관리용 서비스에서만 사용하므로/* IGNORE */를 추가하여 빈 메소드임을 명시한다.java@Override public void onOpen(Session session) { /* IGNORE */ } // ... @Override public void onClose(Session session) { /* IGNORE */ }사용자 DB처리등 비즈니스 로직 처리를 위한
UserService를 주입받도록 구성한다.java@Slf4j @Service public class ChatUserSocketService extends SocketService implements SocketListener { private final UserService userService; public ChatUserSocketService(ObjectMapper objectMapper, ChatSocketClientPool socketClientPool, UserService userService) { super(objectMapper, socketClientPool); this.userService = userService; } // ... }ChatSocketController에서 이벤트 발생시 호출 할 수 있도록 생성자에서 리스너로 자신을 등록 처리 한다.java// ... public ChatUserSocketService(ObjectMapper objectMapper, ChatSocketClientPool socketClientPool, UserService userService) { super(objectMapper, socketClientPool); this.userService = userService; ChatSocketController.addSocketListener(this); } // ...onMessage메소드에서 커맨드를 수신하여 분기 할 수 있도록 기본형태(switch구문)를 구성한다.java// ... @Override public void onMessage(String message, Session session) { String command = getCommand(message); switch (command) { } } // ...이후 커맨드를 추가 할 때마다
case를 추가하여 분기 처리한다. 아래에 이어서 커맨드 추가 절차에 대해 알아 보겠다.
커맨드 추가 절차
이전에 만들어 둔
dto패키지에CS_GET_FRIEND커맨드의 파라미터를 수신 할 클래스를 추가한다. 클래스명은 커맨드명을 Camel Case로 변경 후Dtosuffix를 붙이는 형태로 한다.CsGetFriendDto.java
java@AllArgsConstructor @NoArgsConstructor @Builder @Data public class CsGetFriendDto { private String clientId; private Long userId; }마찬 가지로 Client에 응답 할
SC_RES_FRIEND커맨드의 응답 데이터를 담을 클래스를 추가한다.ScResFriendDto.java
java@AllArgsConstructor @NoArgsConstructor @Builder @Data public class ScResFriendDto { private Long userId; private String userName; private String onlineYn; }ChatUserSocketService클래스에CS_GET_FRIEND커맨드를 처리 할 메소드를 추가한다.java// ... @Override public void onMessage(String message, Session session) { String command = getCommand(message); switch (command) { case "CS_GET_FRIEND" -> handleCsGetFriend(getData(message, CsGetFriendDto.class), session); } } // ...- 커맨드를 처리하는 메소드는
handleprefix에 커맨드명을 Camel Case로 변경하여 사용한다. getData메소드는SocketService에 구현되어 있으며,message에서 데이터를 추출하여clazz타입으로 반환한다.
- 커맨드를 처리하는 메소드는
handleCsGetFriend메소드를 추가한다.javaprivate void handleCsGetFriend(CsGetFriendDto csGetFriendDto, Session session) { List<UserDto> userList = userService.getFriends(csGetFriendDto.getUserId()); List<ScResFriendDto> scResFriendList = userList.stream() .map(userDto -> objectMapper.convertValue(userDto, ScResFriendDto.class)) .toList(); responseScResFriend(scResFriendList, session); }- 비즈니스 로직은 불가피한 경우를 제외하고, 각 서비스 클래스(예시의 경우
UserService)에서 처리하도록 구성한다. - 응답에 사용 할 데이터(
ScResFriendDto)를 구성한다. - 클라이언트에 응답처리 하는 메소드는
responseprefix에 커맨드명을 Camel Case로 변경하여 사용한다.
- 비즈니스 로직은 불가피한 경우를 제외하고, 각 서비스 클래스(예시의 경우
responseScResFriend메소드를 추가한다.javaprivate void responseScResFriend(List<ScResFriendDto> scResFriendList, Session session) { send(SocketCommand.<List<ScResFriendDto>>builder() .command("SC_RES_FRIEND") .code(200) .message("OK") .data(scResFriendList) .build(), session); }SocketService에 구현되어 있는send메소드를 사용하여 응답을 전송한다.
필요한 경우
broadcast메소드를 추가한다.broadcaseprefix에BC_XXXX커맨드의BC_부분을 제외한 명령어 부분을 Camel Case로 변경하여 사용한다.SocketService에 구현되어 있는broadcastAll또는broadcast메소드를 호출 하도록 구현 한다.- 추가한
broadcastXxxx메소드는handleXxxx메소드에서 호출하도록 구성한다.
3.3. 클라이언트
클라이언트 구현은
NextJS에서 구현하는 것을 예시로 설명한다.NextJS프로젝트 생성은 완료 한 것으로 간주 한다.
3.3.1. 필요 라이브러리 설치
recoil을 사용하도록 구성 하였다.recoil라이브러리를 설치한다.
$ npm install recoilsrc/pages/경로에_app.tsx를 추가하고,recoil을 사용하도록 구성한다.src/pages/_app.tsx
typescriptimport {AppProps} from "next/app"; import {RecoilRoot} from "recoil"; function App({ Component, pageProps }: AppProps) { return ( <RecoilRoot> <Component {...pageProps} /> </RecoilRoot> ); } export default App;- 참고:
tsx확장자는typescript xml의 약자로,React에서XML(HTML) 구문이 포함되는 파일에 사용한다. 위_app.tsx처럼return구문에서html형태의 컴포넌트를 반환하도록 구현되는 경우tsx확장자를 사용하고, 로직으로 구성된 모듈의 경우ts확장자를 사용한다.
- 참고:
3.3.2. WebSocket 관리를 위한 recoil 상태 추가
src/atoms/web-socket.ts
import {atom} from "recoil";
/* 서버로 부터 수신 한 메시지 이력을 저장하기 위한 타입 */
export interface WebSocketMsgHistoryType {
lastMessage: string|null;
messageHistory: string[];
}
/* @deprecated 클라이언트 정보를 저장하기 위한 타입 */
export interface WebSocketClientInfoType {
clientId: string|null;
}
/* WebSocket 객체를 저장하기 위한 recoil state */
const WebSocketState = atom<WebSocket|null>({
key: 'webSocket',
default: null,
});
/* 서버로 부터 수신 한 메시지 이력을 저장하기 위한 recoil state */
const WebSocketMsgHistoryState = atom<WebSocketMsgHistoryType>({
key: 'webSocketMsgHistory',
default: {
lastMessage: null,
messageHistory: [],
}
});
/* @deprecated 클라이언트 정보를 저장하기 위한 recoil state */
const WebSocketClientInfoState = atom<WebSocketClientInfoType>({
key: 'webSocketClientInfo',
default: {
clientId: null,
}
});
export {WebSocketState, WebSocketMsgHistoryState, WebSocketClientInfoState};3.3.3. 서비스 진입점 작성 및 WebSocket 연결 처리
src/pages/chat/index.tsx
import {useRecoilState} from "recoil";
import {useEffect} from "react";
import Router from "next/router";
import {WebSocketClientInfoState, WebSocketMsgHistoryState, WebSocketState} from "@/atoms/web-socket";
const ChatHome = () => {
const [webSocket, setWebSocket] = useRecoilState(WebSocketState);
const [webSocketMsgHistory, setWebSocketMsgHistory] = useRecoilState(WebSocketMsgHistoryState);
const [, setWebSocketClientInfo] = useRecoilState(WebSocketClientInfoState);
useEffect(() => {
setWebSocket(new WebSocket("ws://localhost:8080/ws/chat"));
}, []);
useEffect(() => {
if (webSocket !== null) {
webSocket.onmessage = onReceiveSocketMessage;
}
}, [webSocket]);
const onReceiveSocketMessage = (evt: MessageEvent) => {
const resp = JSON.parse(evt.data);
switch (resp.command) {
case 'SC_HELO':
webSocket?.send("CS_GET_CLIENT::" + JSON.stringify({
clientId: sessionStorage.getItem("clientId"),
userId: user.userId,
}));
break;
case 'SC_RES_CLIENT':
setWebSocketClientInfo({
clientId: resp.data.clientId,
});
sessionStorage.setItem("clientId", resp.data.clientId);
Router.push('/chat/friends');
break;
}
setWebSocketMsgHistory({
lastMessage: evt.data,
messageHistory: [...webSocketMsgHistory.messageHistory, evt.data],
});
};
return (
<div>
채팅 홈
</div>
);
}
export default ChatHome;페이지 진입시
WebSocket연결을 위한WebSocket객체를 생성하고,WebSocketState에 저장한다.typescriptuseEffect(() => { setWebSocket(new WebSocket("ws://localhost:8080/ws/chat")); }, []);- 서버단 구현시
@ServerEndpoint(value = "/ws/chat")에서 지정한 URL을 사용한다. - TODO: host(
ws://localhost)와 port(8080)를 환경변수로 관리하도록 수정한다.
- 서버단 구현시
webSocket에 값이 할당 된 후onmessage이벤트 핸들러를 할당하도록 구현한다.typescriptuseEffect(() => { if (webSocket !== null) { webSocket.onmessage = onReceiveSocketMessage; } }, [webSocket]);onmessage(onReceiveSocketMessage)에는 초기 클라이언트 식별 로직 및 이후 소켓을 이용하는 화면 구현시 사용 할WebSocketMsgHistoryState에 메시지를 추가하는 로직으로 구성된다.typescriptconst onReceiveSocketMessage = (evt: MessageEvent) => { const resp = JSON.parse(evt.data); switch (resp.command) { case 'SC_HELO': webSocket?.send("CS_GET_CLIENT::" + JSON.stringify({ clientId: sessionStorage.getItem("clientId"), userId: user.userId, })); break; case 'SC_RES_CLIENT': setWebSocketClientInfo({ clientId: resp.data.clientId, }); sessionStorage.setItem("clientId", resp.data.clientId); Router.push('/chat/friends'); break; } setWebSocketMsgHistory({ lastMessage: evt.data, messageHistory: [...webSocketMsgHistory.messageHistory, evt.data], }); };- 초기 소켓 커맨드는 다음 순서로 진행된다.
- 서버에 연결되면(
@OnOpen) 서버에서 클라이언트로SC_HELO커맨드를 전송한다. - 클라이언트는
SC_HELO커맨드를 수신하면CS_GET_CLIENT커맨드를 전송한다.- 기존에 연결된 적이 있는 클라이언트일 경우 기존에 연결했던 정보를 조회하여 클라이언트에 안내한다.
- 서버는
CS_GET_CLIENT커맨드를 수신하면SC_RES_CLIENT커맨드를 전송한다.- 클라이언트에게
clientId를 전송한다. - 클라이언트는
clientId를sessionStorage에 저장한다.
- 클라이언트에게
- 클라이언트는
Router.push('/chat/friends')를 호출하여 친구 목록 화면으로 이동한다.- 예시에서 실제 채팅 서비스의 초기 화면은 친구 목록 화면(`/chat/friends')이 된다.
- 서버에 연결되면(
- 현재 수신한 메시지를
recoil의WebSocketMsgHistoryState에 추가하여, 다른 화면에서도 사용 할 수 있도록 조치 한다.
- 초기 소켓 커맨드는 다음 순서로 진행된다.
3.3.4. 서비스 화면 개발 예시
ChatHome에서Router.push('/chat/friends')를 호출하여 이동한 친구 목록 화면을 예시로 하여, 각 화면단 개발 방법에 대해 알아본다.
src/pages/chat/friends.tsx
import {UserState} from "@/atoms/user";
import {useRecoilState} from "recoil";
import {WebSocketClientInfoState, WebSocketMsgHistoryState, WebSocketState} from "@/atoms/web-socket";
import {useEffect, useState} from "react";
import Router from "next/router";
import Friend from "@/components/Friend";
import {Button} from "@mui/material";
interface ScResFriendProps {
userId: number;
userName: string;
onlineYn: string;
}
const Friends = () => {
const [user] = useRecoilState(UserState);
const [webSocket] = useRecoilState(WebSocketState);
const [webSocketMsgHistory] = useRecoilState(WebSocketMsgHistoryState);
const [friendList, setFriendList] = useState([] as ScResFriendProps[]);
const [checkedFriendIds, setCheckedFriendIds] = useState([] as number[]);
useEffect(() => {
if (user.userId === -1) {
alert("로그인이 필요합니다.");
Router.push('/login');
}
// 친구 목록 조회
webSocket?.send("CS_GET_FRIEND::" + JSON.stringify({
clientId: sessionStorage.getItem("clientId"),
userId: user.userId,
}));
}, []);
useEffect(() => {
if (webSocketMsgHistory.lastMessage === null) {
return;
}
const resp = JSON.parse(webSocketMsgHistory.lastMessage);
switch (resp.command) {
case 'SC_RES_FRIEND':
setFriendList(resp.data);
break;
case 'SC_RES_REG_ROOM':
Router.push('/chat/room/' + resp.data.roomId);
break;
case 'BC_ONLINE':
for (const element of friendList) {
if (element.userId === resp.data.userId) {
element.onlineYn = resp.data.onlineYn;
break;
}
}
setFriendList([...friendList]);
break;
}
}, [webSocketMsgHistory]);
const onFriendChecked = (id: number, checked: boolean) => {
if (checked) {
setCheckedFriendIds([...checkedFriendIds, id]);
} else {
setCheckedFriendIds(checkedFriendIds.filter((friendId) => friendId !== id));
}
};
const onChatClick = () => {
if (checkedFriendIds.length === 0) {
alert("친구를 선택해주세요.");
return;
}
webSocket?.send("CS_REG_ROOM::" + JSON.stringify({
clientId: sessionStorage.getItem("clientId"),
userId: user.userId,
friendIdList: checkedFriendIds,
}));
};
return (
<div>
<h1>친구목록</h1>
{friendList.length === 0 && <div>친구가 없습니다.</div>}
<div>
{friendList.map((friend) => {
return (
<Friend key={friend.userId}
id={friend.userId}
name={friend.userName}
onlineYn={friend.onlineYn}
onChecked={onFriendChecked}
/>
);
})}
</div>
<div>
<Button variant="contained" onClick={onChatClick}>대화하기</Button>
</div>
</div>
);
};
export default Friends;WebSocket사용을 위해 관련recoil상태를 추가한다.typescriptconst [webSocket] = useRecoilState(WebSocketState); const [webSocketMsgHistory] = useRecoilState(WebSocketMsgHistoryState);- 웹 소켓 관련 상태는 진입점에서 세팅한 값을 가져와 사용만(Read Only)하므로
setter는 사용하지 않는다.
- 웹 소켓 관련 상태는 진입점에서 세팅한 값을 가져와 사용만(Read Only)하므로
페이지 진입시 초기값이 필요한 경우
useEffect에서 소켓 명령을 전송한다.(REST API 사용시와 동일하다.)typescriptuseEffect(() => { // 친구 목록 조회 webSocket?.send("CS_GET_FRIEND::" + JSON.stringify({ clientId: sessionStorage.getItem("clientId"), userId: user.userId, })); }, []);- webSocket은
null일 수 있으므로,webSocket?.send형태로 호출한다.
- webSocket은
서버로부터 메시지를 수신하면,
WebSocketMsgHistoryState에 저장되므로, 해당 변수인webSocketMsgHistory값이 바뀌는 것을 감지하도록useEffect를 추가한다.typescriptuseEffect(() => { if (webSocketMsgHistory.lastMessage === null) { return; } const resp = JSON.parse(webSocketMsgHistory.lastMessage); switch (resp.command) { } }, [webSocketMsgHistory]);switch구문 내부에 현재 화면에서 사용하는 커맨드에 대해 수신 로직을 구현한다.SC_RES_FRIEND: 친구 목록 조회 요청에 대한 응답SC_RES_REG_ROOM: 채팅방 생성 요청에 대한 응답BC_ONLINE: 친구 온라인 상태 변경 알림
