실시간 채팅을 구현하면서 공부했던 웹소켓통신에 대한 내용은 여기에 정리했다.
- mount가 되었을 때 소켓 연결, unmount시 소켓 연결 해제
리액트의 useEffect 훅을 이용해서 구현할 수 있다고 생각했다.
- 멤버 데이터 업데이트 실시간 반영
멤버 데이터와 채팅 데이터를 따로 수신할 수 있는 방법이 필요했다.
- useEffect Hook 특성 이용
리액트의 useEffect로 컴포넌트가 마운트 혹은 언마운트 됐을 때 실행시키고 싶은 동작을 지정할 수 있다. 혹은 dependency 배열을 이용해 컴포넌트를 재렌더링 할 수 있다.
이러한 useEffect 훅의 특징을 이용해 유저가 채팅방에 들어왔을 때 자동으로 소켓을 연결하고 유저가 채팅방을 나갔을 때 소켓 연결을 해제하는 동작을 구현했다.
// mount시 통신 연결
useEffect(() => {
if (!userInfo || !accessToken) return
connection() // 웹소켓 연결 함수
return () => {
// unmount시 연결 해제
if (stomp) {
stomp.disconnect()
}
}
}, [userInfo, accessToken])
- 수신하는 메시지 타입 분리
웹소켓 통신을 구현하며 사용했던 stomp 프로토콜에는 수신하는 메세지의 타입을 설정해줄 수 있다.
채팅을 담당했던 백엔드 팀원과 협의를 거쳐 멤버 데이터와 채팅 데이터의 타입을 분리해 수신하기로 했다.
그리고 멤버 데이터가 날아온다면 기존 멤버 리스트 정보를 업데이트 하고, 채팅 아이템이 재렌더링 되도록했다.
stomp.connect(
{
token: accessToken,
},
() => {
// URI 구독
stomp.subscribe(`/sub/clubs/${userInfo?.clubId}`, (chatDTO) => {
// 구독후 데이터를 받을 때마다 실행
const data = JSON.parse(chatDTO.body)
// 채팅 데이터
if (data.type === 'MESSAGE') {
setChatList((chatList) => [data.chat, ...chatList])
return
}
// 멤버 데이터
if (data.type === 'MEMBERS') {
setMembers(data.members)
setTrigger((trigger) => trigger + 1)
return
}
})
}
)
- 채팅 내역의 재렌더링으로 인한 성능 저하..
멤버 프로필이나 구성이 업데이트 되면 변경된 프로필로 재렌더링을 진행하고 모임을 탈퇴한 멤버를 알 수 없음이라고 표시해줘야 한다.
이 때 기존에 렌더링되었던 채팅 아이템을 전부 재렌더링 시키는 아주 치명적인 문제가 발생했다. 렌더링 이슈를 해결하기 위해 다양한 방법을 찾아봤고 React 공식문서에서 react-window와 virtualized에 대한 내용을 새롭게 접할 수 있었다.
결국 사용자에게 보여지는 부분에 대해서만 렌더링을 진행하고 보여지지 않는 부분에 대해서는 렌더링을 하지 않는 기법이다. 시간이 촉박해 고도화하지 못했던 부분이라 아쉬움이 많이 남는다.
- 쿼리키 통일
React Query는 Query Key를 기준으로 데이터를 캐싱하고, 데이터 조작이 발생했을 때 새로운 데이터를 fetch해온다.
개발을 진행하다보면 동일한 API를 여러 사람이 가져다 쓰는 경우가 많은데, 같은 API지만 각자 설정해준 Query Key가 상이할 경우 같은 데이터를 불필요하게 계속해서 요청하는 문제가 발생할 것이다.
따라서 각 Query마다 Query Key를 통일시켜 주는 것이 필요했다.
- 비동기 요청 에러 관리 통일
팀원 중 한명이 구현한 toast message manager를 활용해 에러 관리 형식을 통일하고 싶었다.
API 가져다 쓰는 곳마다 매번 toast message manager를 import 해야하고, 에러 관리 로직을 작성해주어야 하는 비효율성을 지양하고 유지보수성을 높이는 것이 목표였다.
위 두 가지 문제를 해결하기 위해 다음과 같은 방식으로 접근했다.
- 서로 연관성 있는 API 요청을 하나의 파일로 관리

- Query Hook을 만들어 Query Key 통일
오늘의 산을 조회하는 Query Hook을 가져다가 쓰면 Query Key는 todayMountains로 고정되어 있기 때문에 통일성을 유지할 수 있다.
추가로 오늘의 산과 같이 하루동안 변하지 않는 데이터는 캐시타임을 자정으로 설정해 동일한 데이터의 반복적인 요청이 이뤄지지 않도록 했다.
// 오늘의 산 조회
export function useTodayMountainsQuery() {
return useQuery<AxiosDataResponse, AxiosDataError, MtInfo[]>(
['todayMountains'],
() => apiRequest.get(`/info/today/mountain`),
{
select: (res) => res.data.result,
cacheTime: untilMidnight(),
staleTime: untilMidnight(),
}
)
}
- 경우에 따라 Query Key 상세 설정
만약 특정 클럽의 특정 날짜의 일정 조회를 한다면 이미 조회됐던 해당 클럽의 해당 일정에 대한 정보는 다시 요청할 필요가 없다. 이런 경우 Query Key에 클럽 id와 일정 id를 포함시켜 새로운 일정에 대한 조회는 해당 데이터를 불러올 수 있게끔 했다.
// 일정 후기 조회
export function useMeetupReviewsQuery(clubId: number, meetupId: number) {
return useQuery<AxiosDataResponse, AxiosDataError, MeetupReview[]>(
['meetupReviews', clubId, meetupId],
() => apiRequest.get(`/clubs/${clubId}/meetups/${meetupId}/reviews`),
{ select: (res) => res?.data.result, enabled: !!clubId }
)
}
- 동일 Query에 대한 동일한 에러 관리
Query Hook을 모아둔 파일 안에서 toast message manager을 한 번만 import해 해당 요청에 대한 에러를 동일하게 관리했다. (요청이 성공했을 경우에 대한 관리도 통일시켰다.)
import apiRequest from 'apis/AxiosInterceptor'
import toast from 'components/common/Toast'
import { accessTokenState, refreshTokenState } from 'recoil/atoms'
// 이메일 인증코드 요청
export function useCheckEmail(email: string) {
return useMutation<AxiosDataResponse, AxiosDataError>(
() => apiRequest.post(`/members/auth/email-valid`, { email }),
{
onSuccess: (res) => {
toast.addMessage('success', res.data.message)
},
onError: (err) => {
toast.addMessage('error', err.data.message)
},
}
)
}
결과적으로 불필요한 API 요청을 줄이고, 에러 관리를 통일함으로써 코드 통일성을 높일 수 있었고 UX 개선에도 큰 도움이 되었다.
Query Hook만 가져다 쓰면 Query Key와 에러가 알아서 처리되기 때문에 개발 과정의 편의성도 증가했다.
++ 데이터 조작이 발생했을 경우 server state 동기화에 대한 관리도 간편하게 처리할 수 있었다.
// 일정 사진 등록
export function usePostMeetupPhoto(clubId: number, meetupId: number) {
const queryClient = useQueryClient()
return useMutation<AxiosDataResponse, AxiosDataError, { formData: FormData }>(
({ formData }) =>
apiRequest.post(`/clubs/${clubId}/meetups/${meetupId}/photos`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
}),
{
onSuccess: () => {
queryClient.invalidateQueries(['meetupAlbum', clubId, meetupId])
toast.addMessage('success', '사진이 추가되었습니다')
},
}
)
}
타입스크립트를 적용하면서 데이터 타입을 어떻게 관리해야할지 참고자료가 없어 막막했다. 다들 어떻게 하시나요..?
구현해야할 기능과 할 일이 너무 많았기 때문에 깊이 있게 고민할 시간이 사실 부족했다. 그래도 다른 현업 개발자들은 어떻게 타입을 관리하는지 이런 저런 레퍼런스를 많이 찾아봤다. 레퍼런스 자체도 많지 않았다..
보통 Component 기준 혹은 DB Model을 기준으로 타입을 나눠 관리한다.
우리 프로젝트의 특성상 데이터가 특정 컴포넌트에 국한되지 않고 여러 컴포넌트에서 사용되기 때문에 백에서 보내주는 데이터 형식을 기준으로 타입을 나눠 관리했다.
types 파일 구조

common.interface.ts
서버 데이터에 속하지 않는 것에 대한 타입, Axios나 Query Hook에 필요한 타입 같은 경우에는 common 파일에서 관리했다.
import { AxiosError } from 'axios'
export interface AxiosDataResponse extends AxiosError {
data: any
}
export interface AxiosDataError extends AxiosError {
data: any
}
export interface Message {
message: string
}
export interface InfinitePage {
hasNext: boolean
hasPrevious: boolean
numberOfElements: number
pageSize: number
}
모바일 앱의 특성상 화면이 작고 조작이 간편해야 했기 때문에 데이터를 조회할 때 무한스크롤을 적용하고 싶었다.
React Query를 도입했기 때문에 이 또한 Query Hook으로 만들고자 했고,React Query 공식문서에서 useInfiniteQuery를 접하게 되었다!
- useInfinteQuery 활용
백엔드 팀원이 작성한 API 명세서는 다음과 같다. 마지막 데이터의 photoId를 보내면 그 다음 데이터부터 다시 size만큼의 데이터를 보내주고 만약 photoId가 null값일 경우 처음 데이터부터 보내준다.

queryFn에는 데이터를 요청할 때 실행시킬 함수를 작성한다.

getNextPageParam 데이터 리스트의 마지막 페이지를 받아올 수 있다. 그리고 무조건 single variable을 반환해야 한다.

Return undefined to indicate there is no next page available.
- Scroll 인지하기
스크롤을 감지할 DOM을 선택해 화면의 오른쪽 끝(혹은 bottom)에 닿았을 경우 Query Hook을 실행시키고 싶었다. 다른 프론트 팀원 한 명과 함께 고민하고 구글링의 도움을 받아 완성할 수 있었다.
const infiniteRef = useRef<HTMLDivElement>(null)
const { isLoading, isError, data, fetchNextPage, hasNextPage } =
useInfiniteMeetupAlbumQuery(clubId, meetupId)
const photoInfo = useMemo(() => {
if (!data) return []
return data.pages.flatMap((page) => page.content)
}, [data])
useInfiniteVerticalScroll({
ref: infiniteRef,
loadMore: fetchNextPage,
isEnd: !hasNextPage,
})
이 역시 useInfiniteQuery에서 반환한 데이터를 기준으로 동작한다.
import { useState, useEffect, RefObject } from 'react'
/*
가로스크롤을 위한 무한스크롤 커스텀 훅
특정 DOM요소를 ref로 받은 뒤,
해당 요소의 마지막으로 스크롤이 닿았을 때, loadMore 함수(api 요청하는 비동기 함수) 실행
*/
type InfiniteScrollProps = {
ref: RefObject<HTMLElement> // 무한스크롤이 동작할 DOM 엘리먼트를 ref로 받음
loadMore: () => Promise<any> // 스크롤이 닿았을 시, 동작할 비동기 함수
isEnd?: boolean // true일 경우 비동기함수 동작하지 않음
threshold?: number // 스크롤 마진(해당 값 만큼, 미리 스크롤이 닿았다고 판단)
}
type InfiniteScrollReturns = {
isLoading: boolean // 비동기 요청에 대한 로딩 여부
}
function useInfiniteVerticalScroll({
ref,
loadMore,
isEnd = false,
threshold = 1,
}: InfiniteScrollProps): InfiniteScrollReturns {
const [isLoading, setIsLoading] = useState(false) // 비동기 요청에 대한 로딩 여부
// 스크롤 이벤트 감지 함수
const handleScroll = async () => {
const element = ref.current // 무한스크롤이 동작할 DOM 엘리먼트
if (isLoading || isEnd || !element) return // 로딩 중 | 마지막 정보 | element가 null일 경우, 함수 종료
const { scrollLeft, scrollWidth, clientWidth } = element // 엘리먼트의 스크롤 정보
if (scrollWidth - scrollLeft - clientWidth > threshold) return // 스크롤이 끝까지 닿지 않았을 경우, 함수 종료
setIsLoading(true) // 로딩여부 true
await loadMore() // 비동기 요청
setIsLoading(false) // 로딩여부 false
}
useEffect(() => {
const element = ref.current // 무한스크롤이 동작할 DOM 엘리먼트
if (!element) return //element가 null일 경우, 함수 종료
element.addEventListener('scroll', handleScroll) // element에 스크롤 이베트 감지함수 부착
// cleanup 함수
return () => {
element.removeEventListener('scroll', handleScroll)
}
}, [ref, handleScroll])
return { isLoading }
}
export default useInfiniteVerticalScroll