미니 프로젝트가 끝나고 클론코딩 프로젝트를 진행했다.
클론코딩 때 우리 조의 목적은 완벽한 결과물을 제출하는 것 보다 실전때도 활용할 수 있는 새로운 기술에 도전하고 숙지하는 것을 목표로 잡았다.
그렇게 채팅을 구현하기 위해 웹소켓에 도전했고... 역시 세상에 공부할 내용은 너무 많았다.
기존 브라우저와의 통신이 클라이언트가 서버로 request를 보내야만 이뤄졌다면, 웹소켓은 클라이언트의 request 없이도 서버가 클라이언트로 데이터를 전송할 수 있는 양방향 통신 구조를 가진 transport protocol이다.
웹소켓도 API이기 때문에 하나의 HTTP 접속으로 양방향 메세지를 자유롭게 주고받을 수 있다.
양방향 통신의 특성상 실시간으로 이뤄지기 때문에 채팅, 게임, 실시간 차트 등 실시간이 요구되는 응용 프로그램에 자주 사용된다. 기존 단방향 통신과 다르게 새로고침이나 페이지 이동 없이도 같은 브라우저 내에서 데이터가 업데이트 되는 것이다.
새로고침 없이 데이터가 업데이트되는 것은 Ajax와 비슷하다고 볼 수 있지만, Ajax도 클라이언트가 요청을 보내야 서버가 응답하기 때문에 실시간으로 적용되지 않는다.
이번 프로젝트에서 백엔드는 spring을 사용했기 때문에, 우리 조는 sockjs와 stomp를 사용했다.
function FriendList() {
const chatrooms = useSelector((state) => state.rooms.rooms);
// 1. 채팅방 리스트를 불러오기 위해 전역으로 상태 관리를 한다.
const userInfo = useSelector((state) => state.rooms.userInfo);
// 2. 채팅방 입장 시 사용자 정보 (닉네임)을 넘길 수 있도록 사용자 정보도 전역으로 상태관리를 한다.
const dispatch = useDispatch();
const navigate = useNavigate();
const [roomTitle, setRoomTitle] = useState("");
// 3. 채팅방 생성 시 방 제목을 기입하는 인풋필드의 value state를 감지하기 위해 useState로 선언.
function enterRoom(roomId) {
// 4. 입장하기 버튼 클릭 시 실행되는 함수.
// 4-1. 특정 방에 들어가기 위해서 해당 방의 path variable(roomId)를 매개변수로 받는다.
localStorage.setItem("wschat.nick", userInfo.nickname);
// 4-2. 입장 시 로컬스토리지에 저장되어있던 사용자의 닉네임을 받아온다.
localStorage.setItem("wschat.roomId", roomId);
// 4-3. 입장 시 로컬스토리지에 저장되어있던 채팅방 id를 받아온다.
navigate(`/chat/room/${roomId}`);
// 4-4. roomId로 받아온 path variable의 값으로 이동해야 하는 채팅방의 url을 설정한다.
}
async function createARoom(roomName) {
// 5. 방을 생성할 경우 실행되는 함수다.
// 5-1. 인풋필드에 작성한 방 이름을 roomName이라는 변수로 받아 함수의 매개변수로 사용한다.
if ("" === roomName) {
alert("방 제목을 입력해 주십시요.");
return;
} else {
await dispatch(createRoom(roomName));
// 5-2. 함수 실행 시 dispatch를 통해 slice 파일에 있는 createRoom 함수로 roomName을 보낸다.
dispatch(readAllRooms());
// 5-3. 방이 만들어질 때 방 리스트를 다시 한번 불러와서 만들어진 방이 새롭게 리스트에 업데이트되도록 한다.
setRoomTitle("");
// 5-4. 인풋필드에 입력한 값을 초기화한다.
}
}
useEffect(() => {
dispatch(readAllRooms());
}, [dispatch]);
// 6. dispatch가 실행될때마다 readAllRooms가 실행되어 채팅방 전체 리스트를 불러온다.
const initialState = {
rooms: [], // 1. 생성된 채팅방 리스트
userInfo: { // 2. 사용자의 정보
username: "",
nickname: "",
},
isLoading: false,
error: null,
};
export const readAllRooms = createAsyncThunk(
// 3. realAllRooms라는 상수에 비동기 함수를 할당한다.
"rooms/READ_ROOMS",
// 3-1. createAsyncThunk 함수의 첫번째 매개변수로 액션 밸류를 지정한다.
async (payload, thunkAPI) => {
// 3-2. 두번째 매개변수로는 서버로 보낼 payload와 데이터를 다시 extra reducer로 전달해줄 thunkAPI를 할당한다.
try {
const response = await authInstance.get("/chat/rooms");
// 3-3. response라는 상수에 서버로 request를 보낼 api 주소를 적는다.
// 3-4. get 방식으로 소통하는 경우, payload를 따로 보내지 않는다. (맞는 주소로 요청을 보내는 것이 중요)
return thunkAPI.fulfillWithValue({
rooms: response.data.chatRoomList,
userInfo: response.data.userInfo,
});
// 3-5. 서버에서 리퀘스트에 대한 응답으로 보내준 내용중, rooms엔 response.data.chatRoomList를 할당해서 채팅방에 대한 리스트를 받는다.
// 3-6. 사용자에 대한 정보는 userInfo에 response.data.userInfo를 지정해서 따로 받는다.
} catch (error) {
// 3-7. 서버로부터 모종의 이유로 에러를 전달 받으면 thunkAPI를 통해 extra reducer로 전달한다.
return thunkAPI.rejectWithValue(error);
}
}
);
export const createRoom = createAsyncThunk(
// 4. 채팅방 리스트에서 방 만들기 함수가 실행되면 dispatch를 통해 roomName을 매개변수로 전달받아서 실행되는 비동기 함수이다.
"room/CREATE_ROOM",
// 4-1. createAsyncThunk()를 활용하게되면 첫번째 매개변수로 액션의 이름 (액션밸류)를 정의한다.
// 4-2. createAsyncThunk()를 활용할 경우, 선언한 액션 이름에 pending, fulfilled, rejected에 대한 action을 자동으로 부여한다.
// 4-3. 리덕스에서 액션은 state에 어떤 변화가 필요할 때 발생되어야 하는 객체이다. 액션 객체엔 해당 객체의 type이 필수적으로 지정되어야하고, 객체에 들어갈 다른 값은 개발자의 필요에 따라서 지정된다.
// 4-4. action value가 객체의 type을 지정하는 것
async (payload, thunkAPI) => {
// 4-4. createAsyncThunk의 두번째 매개변수는 미들웨어가 처리할 비동기(async) 함수이고, 실행 결과를 promise 형식으로 반환한다. (try, catch, finally)
// 4-5. 처리할 비동기 함수에 넣는 매개변수는 해당 함수가 리턴할 값 (통칭 payload)과 promise 형태로 반환할 thunkAPI이다.
try {
const response = await authInstance.post(`/chat/room?name=${payload}`);
// 4-6. response라는 변수 안에 axios instance와 메소드를 지정해서 서버에게 방을 생성할 때 필요한 데이터를 전달한다.
// 4-7. 여기서 전댤한 데이터는 생성할 방의 roomName이었고, payload안에 roomName이 들어있기 때문에, api 주소 끝에 payload를 담아서 서버에 보내면, 서버는 프론트에게 방 생성에 필요한 데이터를 전달한다.
return thunkAPI.fulfillWithValue(response.data);
// 4-8. 서버가 프론트로부터 받은 payload를 기반으로 response를 request로 보내주면, 그 response에서 필요한 데이터를 뽑는다.
// 4-9. 그래서 thunkAPI를 통해서 extra reducer로 response에서 data에 들어있는 항목만 빼서 보낸다. (response.data)
} catch (error) {
// 4-10. 만약 모종의 이유로 에러가 발생하면 thunkAPI로 error를 보낸다.
return thunkAPI.rejectWithValue(error);
}
}
);
const chatRoomsSlice = createSlice({
// 5. createAsyncThunk() 를 통해서 thunkAPI로 보내진 데이터는 slice에 있는 extra reducer에게 보내진다.
name: "rooms",
// 5-1. slice의 이름을 지정해서 config store에 등록할 때 사용한다.
initialState,
// 5-2. 전역으로 관리할 state의 초기값을 지정한다.
reducers: {},
extraReducers: {
// 5-3. thunkAPI를 통해서 받아온 데이터는 extraRecuder로 보내진다.
[readAllRooms.pending]: (state) => {
// 5-4. createAsyncThunk()를 통해서 받아온 액션 중 pending 상태일때의 state를 지정한다.
state.isLoading = true;
// 5-5. pending 상태에서 state는 isLoading이고, 아직 로딩중이기 때문에 true라고 표시한다.
},
[readAllRooms.fulfilled]: (state, action) => {
// 5-6. 이제 요청이 끝나고 액션이 fulfilled 상태일때의 state가 어떻게 되어야 하는지 지정해야한다.
// 5-7. state는 action으로 인해 변경된다,
state.isLoading = false;
// 5-8. fulfilled일때 state는 더 이상 로딩중이 아니기 때문에 isLoading은 false가 된다.
state.rooms = action.payload.rooms;
// 5-9. state로 관리하는 rooms의 상태는 readAllRooms의 경우, 새로 변경된 값이 기존 state를 대체한다.
// 5-10. 때문에 action.payload.rooms를 기존 state.rooms에 재할당함으로서 매번 채팅방 리스트는 갱신될 수 있다.
state.userInfo = action.payload.userInfo;
// 5-11. state로 관리하는 userInfo도 readAllRooms가 실행될 때 변경된 값이 기존 state를 대체하여 유저 정보를 갱신한다.
},
[readAllRooms.rejected]: (state, action) => {
// 5-12. 요청이 끝나고 에러가 발생한 경우의 state의 변화도 지정해야한다.
state.isLoading = false;
// 5-13. 로딩은 끝났으니 false로 지정한다.
state.error = action.payload;
// 5-14. state의 에러 상태는 action의 결과값 (payload)기 때문에 state의 error도 action의 payload로 재할당한다.
},
[createRoom.pending]: (state) => {
// 5-15. createRoom이 대기 상태일 때 state는 변화가 없고
state.isLoading = true;
// 5-16. state의 속성 중 isLoading이 true로 되어있다.
},
[createRoom.fulfilled]: (state, action) => {
// 5-17. createRoom이 성공적으로 실행됐을 때의 state 변화를 지정한다.
state.isLoading = false;
// 5-18. 로딩이 끝났으니 false로 지정한다.
// 5-19. state로 관리를 하지 않기 때문에 굳이 변화를 따로 지정하지 않았다.
},
[createRoom.rejected]: (state, action) => {
state.isLoading = false;
state.error = action.payload;
// 5-19. state의 에러 상태는 action의 결과값 (payload)기 때문에 state의 error도 action의 payload로 재할당한다.
},
},
});
function Chat() {
let SockJs = new SockJS("http://웹소켓과 연결되는 서버 주소");
// 1. 스프링과 연결했기 때문에 SockJs를 사용했다.
// 1-1.해당 url과 연결되어있는 웹소켓 서버에 연결하기 위한 client 객체를 만들어서 sockjs라는 변수에 저장.
let ws = Stomp.over(SockJs);
// 2. stomp라는 별도의 솔루션을 사용하기 때문에 sockjs 클라이언트 객체를 stopm.over 메소드로 감싸서 stompClient를 생성한다.
let reconnect = 0;
const dispatch = useDispatch();
const navigation = useNavigate();
const param = useParams();
const roomId = param.id;
const sender = localStorage.getItem("wschat.nick");
// 3. localStorage에 저장된 닉네임을 출력해서 sender라는 변수에 담는다.
const [message, setMessage] = useState("");
// 4. 사용자가 작성하는 메세지 내용을 관리하기 위해 useState를 사용한다.
// 4-1. 9-7 참고
const messages = [];
// 5. 작성된 메세지들을 하나하나 저장하기 위해 빈 배열을 선언한다,
const [viewMessages, setViewMessages] = useState([]);
// 6. 작성된 메세지들이 화면에 뿌려질 수 있도록 또 다른 변수로 상태 관리를 한다.
// 6-1. map을 통해 보여지는 부분은 이 부분이다.
const beforechat = useSelector((state) => state.chat.messageList);
// 7. 채팅방 이전에 있던 내역을 다시 불러오기 위해서 전역 상태로 관리하고 있던 state 중 messageList를 불러와서 변수에 할당한다.
const scrollRef = useRef();
const scrollToBottom = () => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
};
function sendMessage() {
// 9. 사용자가 메세지를 보내게되면 해당 함수가 실행된다.
ws.send(
"/app/chat/message",
// 9-1. stomp client 객체를 이용해서 send 함수를 실행한다.
// 9-2. 첫번째 매개변수로 메세지가 도달해야할 서버 주소를 할당하고
{},
JSON.stringify({
// 9-3. 3번째 매개변수로 사용자가 보내게되는 메세지의 정보를 제이슨 형식으로 서버에 보낸다.
type: "TALK",
// 9-4. 입장 시 자동으로 전달되는 메세지가 아니라 사용자가 작성한 내용이 전달되기 때문에 해당 메세지의 type을 'talk'이라고 지정한다.
roomId: roomId,
// 9-5. 어떤 방에서 해당 채팅이 오갔는지 식별하기 위해 서버로 채팅방의 고유 번호를 넘기고
sender: sender,
// 9-6. 사용자를 식별하기 위해 사용자의 닉네임도 같이 보낸다.
message: message,
// 9-7. 그리고 마지막으로 사용자가 작성한 메세지의 내용도 같이 전달한다.
})
);
}
function recvMessage(recv) {
// 10. 누군가가 보낸 메세지를 받을 때 실행되는 함수이다.
// 10-1. roomSubscribe() 함수에서 정의된것 처럼 연결되었을 때 누군가가 메세지를 보내면 그 메세지를 자동으로 받게 된다.
messages.push({
// 10-2. 받게된 메세지는 messages라는 빈 배열에 추가해서 받은 메세지를 모두 저장한다.
type: recv.type,
// 10-3. 저장된 메세지의 타입은 내가 받은 메세지의 타입이다. (상대방의 enter 혹은 talk)
sender: recv.type === "ENTER" ? "" : recv.sender,
// 10-4. 만약 enter 타입의 메세지를 받은 경우 보낸이의 닉네임은 빈칸이고, 아닐 경우 메세지를 보낸이의 닉네임을 출력한다.
message: recv.type === "ENTER" ? `[알림] ${recv.message}` : recv.message,
// 10-5. enter 타입의 메세지를 받은 경우 메세지 내용 사전에 [알림]을 표시하고 아니라면 메세지의 내용만 표시한다.
});
setViewMessages([...messages]);
// 10-6. 그리고 map을 돌려 화면에 출력하기 위헤 setViewMessages에 messages 배열을 넣어 모든 메세지를 확인할 수 있게 한다.
// 10-7. 이때 깊은 복사를 통해서 messages 배열의 불변성을 지켜야한다.
}
function roomSubscribe() {
// 8. 채팅방 입장 시 자동으로 실행되는 함수이다.
ws.connect(
// 8-1. stomp client 객체가 연결을 시도하고,
{},
function (frame) {
ws.subscribe(`/topic/chat/room/${roomId}`, function (response) {
// 8-2. 적혀있는 주소의 채팅방 웹소켓을 '구독'한다.
// 8-3. 그리고 구독했을 때 response를 매개변수로 해서 또 두번째 매개변수에 있는 함수가 실행된다.
var recv = JSON.parse(response.body);
// 8-4. recv라는 변수에 response로 받아온 데이터 중 body에 해당하는 내용을 문자열로 변경한다.
recvMessage(recv);
// 8-5. 문자열로 만들어진 내용을 recvMessage라는 함수의 매개변수로 값을 넘긴다.
});
ws.send(
// 8-6. 그리고 채티방에 처음 연결됐을 때 사용자는 어떠한 메세지를 보내게 된다.
"/app/chat/message",
// 8-7. 메세지 내용만 관리하는 url로,
{},
JSON.stringify({
type: "ENTER",
roomId: roomId,
sender: sender,
})
// 8-8. 위의 내용을 제이슨 형식으로 서버에 보낸다.
// 8-9. 들어온 방의 고유번호, 사용자의 닉네임을 "enter"라는 타입으로 서버에 보낸다.
);
},
function (error) {
// 8-10. 만약 웹소켓 연결 시도에 에러가 났다면,
if (reconnect++ <= 5) {
// 8-11. 재시도 횟수가 5번 이하일때까지
setTimeout(function () {
// 8-12. 텀을 두는 함수를 이용해서
SockJs = new SockJS("/ws/chat");
// 8-13. sockjs 클라이언트를 생성하고
ws = Stomp.over(SockJs);
// 8-14. sockjs 클라이언트를 감싸서 사용할 stopm 클라이언트 객체를 생성한다.
roomSubscribe();
// 8-15. 객체 생성 후 다시 해당 방을 '구독'하게 만든다.
}, 10 * 1000);
// 8-16. 10초마다 재연결을 시도한다.
}
}
);
}
useEffect(() => {
scrollToBottom();
}, [viewMessages]);
useEffect(() => {
dispatch(readBeforeChat(param.id));
// 11. 이전에 나눈 채팅 내용도 확인할 수 있도록 이전 대화 내역을 불러오는 함수를 실행시킨다.
// 11-1. 이때 출력해야할 채팅 내용을 식별할 수 있도록 해당 채팅방의 고유 번호를 매개변수로 보낸다.
roomSubscribe();
}, []);
const initialState = {
id: 11,
roomName: "",
createUserName: null,
messageList: [], // 1. 메세지 내역을 전부 저장하는 곳
isLoading: false,
error: null,
};
export const readBeforeChat = createAsyncThunk(
// 2. readBeforeChat이라는 상수에 비동기 함수를 할당한다.
"chat/READ_BEFORE_CHAT",
// 2-1. createAsyncThunk 함수의 첫번째 매개변수로 액션 밸류를 지정한다.
async (payload, thunkAPI) => {
// 2-3. 두번째 매개변수로는 서버로 보낼 payload와 데이터를 다시 extra reducer로 전달해줄 thunkAPI를 할당한다.
try {
const response = await authInstance.get(`/chat/room/join/${payload}`);
// 2-3. response라는 상수에 서버로 request를 보낼 api 주소를 적는다.
// 2-4. 현재 서버로 전달하고자 하는 데이터는 지금까지 특정 채팅방의 id이다.
return thunkAPI.fulfillWithValue(response.data.messageList);
// 2-5. 서버로부터 다시 받아온 data 중에 messageList에 들어있던 내용만 extra reducer로 보낸다.
} catch (error) {
// 3-7. 서버로부터 모종의 이유로 에러를 전달 받으면 thunkAPI를 통해 extra reducer로 error를 전달한다.
return thunkAPI.rejectWithValue(error);
}
}
);
const chatSlice = createSlice({
name: "chat",
initialState,
reducers: {},
extraReducers: {
[readBeforeChat.pending]: (state) => {
state.isLoading = true;
},
[readBeforeChat.fulfilled]: (state, action) => {
// 4. 이제 요청이 끝나고 액션이 fulfilled 상태일때의 state가 어떻게 되어야 하는지 지정해야한다.
state.isLoading = false;
// 4-1. fulfilled일때 state는 더 이상 로딩중이 아니기 때문에 isLoading은 false가 된다.
state.messageList = action.payload;
// 4-2. state로 관리하는 messageList는 지금까지 messages에 쌓였던 내용을 모두 저장한다.
// 4-3. 모든 메세지 내역을 불러오기 위해 thunkAPI로 받아온 response.data.messageList의 내용을 payload로서 기존 state에 재할당한다.
},
[readBeforeChat.rejected]: (state, action) => {
state.isLoading = false;
state.error = action.payload;
},
},
});
css를 잘 만들기 위해선 < div > 태그를 생각보다 많이 사용해야 한다는 것을 깨달았다...
어떤 상황에서도 동일한 레이아웃을 위해 크기를 지정할 땐 px
보다 %
혹은 rem
을 사용하자.
styled.div
를 사용해서 특정 div를 생성하면, 해당 div 내부에 있는 특정 컴포넌트에게 스타일을 적용하고싶을 땐 {}를 사용하자.
예시:
const SecondLine = styled.div`
display: flex;
flex-direction: column;
margin-left: 100px;
width: 100%;
gap: 20px;
/* SecondLine에 해당하는 input과 button 요소는 {}를 이용해서 따로 css를 적용할 수 있다.*/
input {
width: 14rem;
height: 1.5rem;
}
button:hover {
background-color: #eeeeee;
border: none;
}
`;
:hover
, :focus
등 다양하게 이용해서 좀 더 다이내믹한 페이지를 만들 수 있다.
생각보다 css가 지원하는 디자인 요소는 무궁무진하다. 전부는 아니더라도 어느정도 개념을 숙지하자.
const {
register, // 1.어떤 인풋의 내용이 useForm을 사용할지 지정하고 매개 변수를 줘서 필요한 부분을 설정한다.
handleSubmit,
// 1-1. 그리고 useForm을 사용한 인풋의 값들을 handleSubmit을 실행할 때 가져온다.
formState: { errors },
} = useForm();
<StInputGroup>
<div>
<input
{...register("username", {
// 2. 해당 인풋은 username으로 정의되어 등록됐다.
required: "이메일은 필수 입력입니다.",
// 2-1. 필수값임을 표시한다.
})}
type="email"
// 2-2. useForm에 등록된 인풋의 type
name="username"
placeholder="이메일"
></input>
{errors.email && (
// 2-3. 만약 해당 type에 오류가 발생할 경우,
<p style={{ color: "red", fontSize: "12px" }}>
{errors.email.message}
// 2-4. required에 지정했던 에러 메세지를 출력한다.
</p>
)}
</div>
<div>
<input
{...register("password", {
// 3. 해당 인풋은 username으로 정의되어 등록됐다.
required: "비밀번호는 필수 입력입니다.",
// 3-1. 필수값임을 표시한다.
})}
type="password"
// 3-2. useForm에 등록된 인풋의 type
name="password"
placeholder="비밀번호"
></input>
{errors.password && (
// 3-3. 만약 해당 type에 오류가 발생할 경우,
<p style={{ color: "red", fontSize: "12px" }}>
{errors.password.message}
// 3-4. required에 지정했던 에러 메세지를 출력한다.
</p>
)}
</div>
</StInputGroup>
이번 프로젝트도 진짜 시작을 어떻게 할 지 너무 막막하고 어려운 시간이었다.
다른 팀원분들의 도움으로 프로젝트를 끝낼 수 있었는데, 정말... 내가 했다고 말 하기도 부담스러울 정도로 도움을 받았다. 😭😭😭 그래도 아주 조금은 웹소켓이 뭔지 감을 잡을 수 있는 시간이었기 때문에 값졌다고 할 수 있겠다.
다음번엔 프로젝트에 더 기여할 수 있도록 더 열심히 공부해야겠다!!!
출처: