[ 기본 정보 ]
[ 이번 프로젝트의 개인적인 목표 ]
[ 나의 역할 ]
📌 개발자의 개발 경험을 개선하기 위한 노력을 시도하였다.우리맵
서비스에 맞는 지도 컴포넌트를, Next.js 환경에서 사용할 수 있게 커스터마이징useSSE
라는 hook으로 구현useSSE
hook을 이용하는 useNotification
hook을 만들고, 도메인에 연결된 로직들을 해당 hook에 구성[ 진행 방식 ]
스프린트
→ 스프린트 기간은 잘 지켜지지 않았는데, 자세한 내용은 아래에 서술한다.
[ 기술 측면 ]
Next.js 환경에서 axios interceptor를 전역(모듈 스코프에서) 선언하게 되면, 클라이언트 사이드에서 비동기 작업을 할 때 인터셉터를 통과하지 못하는 문제가 있었다.
처음 리코일을 구성하기 전, 리덕스를 사용했었다. (리코일은 next.js 에서 key duplication warning 문제가 있었다. 해당 문제에 대해서 멘토님한테 질문드렸었는데, 뜸에도 불구하고 편해서 현업에서도 그냥 쓴다고 하더라… 그래서 그냥 쓰기로 했다)
리덕스를 사용한 이유는, 리덕스의 스토어는 리액트 외부에서(물론 사용하려면 Provider로 감싸주어야 하긴 하지만)선언하여, 스토어와 인터셉터를 연결할 때 인터셉터가 리액트 스코프 내부에 존재하지 않아도 스토어에 접근할 수 있는 장점이 있기 때문이다. 따라서 인터셉터를 외부에 선언하고, 리덕스 스토어와 묶어서 처리하면, 상태와 인터셉터를 연결할 수 있을 뿐더러 코드 분리차원에서도 이득이 있다고 생각을 했으나, Next.js는 서버에서 돌아가는 프레임워크이므로, redux와 연결된다는 장점을 취할 수 없었으며, 인터셉터 또한 클라이언트 사이드에서 동작하지 않았다.
이를 해결하기 위하여, 모듈 스코프에 선언하지 않고, 함수 스코프로 선언하여 instnace 역시 함수로 묶어 호출 시 인터셉터가 호출되도록 처리를 했다.
이 과정에서 발생한 딜레마 - 코드 분리 우선 vs 개발 경험 우선?
instance와 interceptor를 함수로 선언하고, 비동기 작업 시 해당 함수를 호출하는 방식은 동작도 잘 될 뿐더러 코드 분리 차원에서 굉장한 이득이었지만 (특히, api 함수들을 컴포넌트 외부로 뺄 수 있으므로 불필요한 hook의 사용을 자제할 수 있으므로 컴포넌트 내부에 불필요한 로직들이 필요가 없다.) 로그아웃 시 전역 유저 상태에 접근하여 해당 데이터를 null로 만들어야 하는데, 이러한 방식이 불가능했다. 이미 이 시점에서는 recoil을 사용하고 있기 때문에, 다른 방식으로 접근을 하게 되었다. 사실 hook으로 만들어서 instance hook을 사용하면 해결될 것을 알고는 있었지만, hook을 만들어서 사용하면 코드 분리 차원에서 좋지 않을 것이라, 다른 방식을 우선 생각해보기로 했다.
사실 전역 데이터에 접근할 필요가 없다면 굳이 hook으로 인스턴스를 만들어, 인터셉터를 선언할 필요가 없었을 것이다. 다만, 이 경우에는 여러 문제가 있다.
이를 해결하기 위해 window.addEventListener
를 통해 로그아웃 이벤트가 연결되면 로그아웃 시킬 수 있지 않을까? 하여, 이벤트리스너 컨텍스트를 만들어 감싸보았는데, 이 역시 다른 문제가 발생했었다.
window.addEventListner
가 이벤트가 dispatch되는 시점에 완벽히 실행되지 않는다.useEffect
실행 순서 리액트의 useEffect는, 가장 children 컴포넌트부터 실행된다. 이는 실제 브라우저가 렌더링 하는 순서와 흡사하다. child컴포넌트가 렌더링되고, 해당 크기를 바탕으로 부모의 크기를 계산하기 때문이다.
그래서 initial load 시 자식 컴포넌트에서 비동기 요청이 일어났을 때, Context의 useEffect는 아직 실행 전 상태이기 때문에 이벤트가 dispatch 되더라도 Context에 정의된 event listener는 동작하지 않는다.
여러 복합적인 이슈들을 따져보았을 때, 현재로서는 axios instance를 hook으로 선언하여 사용하는 것이 최선이라고 생각을 했고, 각 페이지 로직 또는 필요한 부분에서 instance hook을 호출하는 방식으로 합의를 했다.
Recoil effects, selector가 Next.js에서 동작하지 않는 문제
이 문제 역시 Next.js가 풀스택 프레임워크라는 것에 기인한다
Next.js는 서버로부터 프리렌더링을 한 뒤 클라이언트 사이드로 작업을 넘겨준다. 만약 이런 recoilState가 있다고 생각하자.
// recoil code
const LocalStorageEffect: AtomEffect<UserResponseType | null> = ({
setSelf,
onSet,
}) => {
const savedValue = LocalStorage.getItem('user', null);
setSelf(validateUser(savedValue) ? savedValue : null);
onSet((newValue, _, isReset) =>
isReset
? LocalStorage.removeItem('user')
: LocalStorage.setItem('user', newValue),
);
};
const userState = atom<UserResponseType | null>({
key: 'user',
default: null,
effects: [LocalStorageEffect],
});
export default userState;
리코일은 initialState가 null로 초기화됨과 동시에, localstorage로부터 값을 불러와서 초기화한다. 클라이언트 컴포넌트에서 recoilState를 불러와서 사용하려고 할 경우, 다음과 같이 사용할 수 있다.
function Component() {
const [user, setUser] = useRecoilState(userState)
}
근데, 이렇게 사용하면 Next.js 환경에서 에러가 난다. 왜냐하면 로컬스토리지는 클라이언트 사이드에서만 접근이 가능한데, 프리렌더링 단계에서 로컬스토리지에 접근할 수 없기 때문이다. 따라서 Next.js는 서버 렌더링과 클라이언트 사이드 렌더링이 일치하지 않는다고 에러를 보낸다.
생각한 해결 방법은 다음과 같았다.
getServerSideProps
라는 서버에서 데이터 fetch를 실행하고, 데이터 fetch를 실행 한 뒤 해당 데이터를 prop으로 넘겨준다.function useComponentDidMount() {
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
return mounted
}
여기서 useEffect는 항상 클라이언트에서 동작하기 때문에, 클라이언트 환경에서 mounted는 true이다. 다만, 여기서 조금 더 최적화를 하기 위해 값을 useMemo로 감싸주었다.
function useComponentDidMount() {
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
return useMemo(() => mounted, [mounted])
}
그리고, mount 됬을 때 recoilValue를 불러올 수 있도록 다음의 hook을 불러왔다.
function useRecoilValueAfterMount<T>(recoilState: RecoilState, defaultValue: T) {
const mounted = useComponentDidMount()
const value = useRecoilValue(recoilState)
// defaultValue는 recoil의 default와 동일해야 한다.
return mounted ? value : defaultValue
}
recoilValue를 클라이언트 사이드에서 값을 불러올 때 해당 hook을 사용할 경우, mount 된 후 클라이언트 사이드에서 값을 변경하므로 렌더링 문제가 없게 된다.(단, react보다는 null → value로 변하는 시간이 길 것이라고 예상한다.)
Map 컴포넌트 스크립트 로딩 문제
맵 컴포넌트는 응집도를 위하여 다음과 같이, 맵 스크립트를 불러오는 부분 + 맵을 표시하는 부분으로 이루어져 있었다.
function Map({ latitude, longitude }) {
return (
<>
<Script src="src" onLoad={loadMap} />
<KakaoMap />
</>
)
}
스크립트 로딩이 완전히 끝나기 전에 kakao api method에 접근하게 되면 에러가 발생하기 때문에, onLoad 리스너를 통해 로딩이 완전히 끝나면 loaded = true로 설정한 뒤, loaded = true일 경우에만 맵 컴포넌트가 보여지게끔 설정하였다.
그런데 다른 페이지들을 구현하고 보니, 문제가 발생했다. router를 이용하여 페이지를 이동했다가 다시 되돌아오면 맵 컴포넌트가 보여지지 않는 문제였다.
이를 확인해보니, 문제점은 다음과 같았다.
이를 좀 더 리액트스럽게 해결하였다.
리액트에서 해결하는 방법은, 리액트에서 사이드 이펙트를 관리하는 useEffect hook에서 처리하는 것이다.
function Map() {
useEffect(() => {
const $script = document.createElement('script');
$script.src = URL;
$script.addEventListener('load', onLoad);
document.head.appendChild($script);
return () => {
$script.remove();
};
return (
isLoaded ? <Map /> : null
)
}
해당 방식으로 테스트를 해 보았더니, 페이지 이동 후 돌아온 뒤에도 script tag가 다시 생기고, load event가 실행됨을 확인할 수 있었다.
그 외 다소 효율적이지 않은 메인 페이지 로직을 리팩토링하면서, 부수적인 버그 역시 해결하였다.
[ 팀 및 커뮤티케이션 측면 ]
[ 프로젝트 측면 ]
그 동안 조금 생각 없이 컴포넌트를 짰구나
라는 생각도 했다. 이전에 작성한 컴포넌트들은 하나의 컴포넌트가 가지고 있는 책임이 너무나도 많았고, 재사용성에 대한 고려가 되어 있지 않아 일정 시간이 지나고 난 뒤에는 이 컴포넌트가 작동하기 위해서 어떠한 props들이 필요한지 알 수 있는 방법이 없었다.function Dropdown({ trigger, children, ...props }: IDropdown) {
const [isOpen, setIsOpen] = useState(false);
const [boundary, setBoundary] = useState(0);
const dropdownMenuRef = useRef<HTMLUListElement | null>(null);
const calculateWidth = useCallback(() => {
if (!dropdownMenuRef.current) return;
setBoundary(
dropdownMenuRef.current.getBoundingClientRect().width +
dropdownMenuRef.current.getBoundingClientRect().left,
);
}, []);
const toggle = useCallback(() => {
setIsOpen((prev) => !prev);
}, []);
const close = useCallback(() => {
setIsOpen(false);
}, []);
const dropdownRef = useClickAway<HTMLDivElement>(close);
const triggerWithProps = trigger
? React.cloneElement(trigger, {
onClick: (e: React.MouseEvent<HTMLElement>) => {
calculateWidth();
toggle();
trigger.props.onClick?.(e);
},
})
: null;
const childrenWithProps = React.Children.map(children, (child) => {
if (!React.isValidElement(child)) return null;
return React.cloneElement(child, {
onClick: (e: React.MouseEvent<HTMLElement>) => {
close();
child.props.onClick?.(e);
},
});
});
return (
<S.DropdownWrapper ref={dropdownRef}>
<S.DropdownTrigger>{triggerWithProps}</S.DropdownTrigger>
<S.DropdownMenu
isOpen={isOpen}
widthBoundary={boundary}
ref={dropdownMenuRef}
{...props}
>
{childrenWithProps}
</S.DropdownMenu>
</S.DropdownWrapper>
);
}
[ 팀 및 커뮤티케이션 측면 ]
원래 예상하던 개발 일정보다 많이 늦춰졌다. 그래서 원래 4주간 진행인 프로젝트에서, 기획으로 거의 일주일을 쓰고, 베이직 기능을 빠르게 구현해서 디테일 고도화 및 추가 기능 구현까지가 목표였는데, 추가 기능으로 기획했던 것 중 오직 실시간 알림 기능만 구현할 수 있었다. 원래 예상보다 프로젝트가 늦어진 이유는 다음과 같다.
[ 프로젝트 측면 ]
[ 팀 및 커뮤티케이션 측면 ]
[ 프로젝트 측면 ]
프로덕트 측면에서 봤을 때, 많이 아쉬운 프로젝트였다. 인원수가 많았음에도 불구하고(많아서 사공이 많아 산으로 간건지는 모르겠지만) 이전 프로젝트에 비해 잡음이 꽤 존재했었다.
기획 단계에서 좀 더 확실히 사용자에게 와 닿을 수 있는 서비스를 기획했어야 하는데, 역량 부족으로 공감하기 어려운 서비스가 만들어진 것 같아 많이 아쉽다.
그러나 개인적 챌린징으로 봤을 때는 그래도 만족스러운 프로젝트였다. 새로운 환경에서 트러블 슈팅을 통해 앞으로 비슷한 문제를 마주했을 때 팁들과, 앞으로는 지양해야할 것들에 대해 확실히 알게 되었다.
또한 이전 프로젝트에 비해 코드 퀄리티에 신경을 쓰면서 코드를 작성하였고, 작성하는 과정 중에 했던 고민들이, 미래에 좋은 양분이 될 것 같아 기쁘다. 또 같은 팀원들도 확실히 코드에 신경을 쓴 티가 난다고 해주었을 때 내 노력이 그래도 헛되지 않았구나…라는 생각이 들었다.
해당 프로젝트를 끝으로 공식적인 프로그래머스 데브코스 일정을 모두 마쳤다. 한 달 가까이 함께 동고동락한 팀원들에게 너무 감사하며, 다음에 좋은 인연으로 만날 수 있으면 좋겠다.
준혁님 글 잘봤습니다! 정말 한 달동안 고생많으셨고 정말 감사했습니다! 🤗