들어가며
작업하면서 훅으로 분리한 부분도 있지만, 여전히 굉장히 길고 난잡한 코드입니다. 프로젝트 진행중에 얼만큼의 기능 추가가 될지도 모르고, 로직의 변경이 있을지도 모른다는 핑계로 리팩토링을 미뤄오다가, 해당 작업을 진행하게 되었습니다.
시바는 현재 굉장히 많은 상호작용에 관여하고 있습니다.
vscode기준 아래는 180줄에 달하는 코드들입니다.
난잡한 코드를 읽기전에 각각의 기능에 대해 요약하자면,
1. glb파일에서 시바를 로드해서 랜더링함
2. 충돌체를 설정해서 시바가 충돌할 수 있도록 함
3. 시바가 키보드 입력에 따라 이동을 함
4. 시바가 이동시 카메라가 따라 다님
5. 시바가 이동시 이벤트가 발생하도록 함
6. 이벤트 발동조건에 따라 발생 여부 결정 ( 시바 위치, 시바가 이동중인지, 이벤트 가능한지 == 쿨타임 , 시바가 착지했는지)
7. 점프 시에는 착지하지 않았다고 설정함
8. 비컨위에 올라섰을 때 비컨을 보이지 않게 설정함
9. 로그인 유무에 따라 이벤트 결과 서버(firestore)에 전송하기
10. 이벤트 발생시 모달을 열어 이벤트 결과 보여주기
이 중에서 프레임 단위로 실행되어야 할 기능은 useFrame 훅 내부에서 실행하고 있습니다.
위는 제가 우선 리팩토링 전에 생각한 기능들이고 해당 기능들을 좀 더 잘게 관심사를 분리하여 보려고 합니다.
리팩토링 전의 코드
'use client';
import { Group, Mesh, MeshBasicMaterial, Quaternion, Vector3 } from 'three';
import React, { useMemo, useRef } from 'react';
import { OrbitControls, useGLTF } from '@react-three/drei';
import { GLTF, OrbitControls as OrbitControlsRef } from 'three-stdlib';
import { useCompoundBody } from '@react-three/cannon';
import { useFrame } from '@react-three/fiber';
import { useMovePosition } from '../../hooks/useMovePosition';
import { useInput } from '../../hooks/useInput';
import { useShibaStore } from '@/store/shiba';
import { ShibaLocation } from '@/shared/constants/model';
import {
EventResultProps,
SHIBA_EVENT,
ShibaEvent,
} from '@/shared/constants/shibaEvent';
import { useModalContext } from '@/shared/components/portal/ModalContext';
import { checkNewEvent } from '@/remote/shiba';
import { useShibaEventStore } from '@/store/shibaEvent';
import { useShowingProcessStore } from '@/store/showingProcess';
import { useSession } from 'next-auth/react';
type GLTFResult = GLTF & {
nodes: {
Group18985_default_0: Mesh;
Box002_default_0: Mesh;
Object001_default_0: Mesh;
};
materials: {
['default']: MeshBasicMaterial;
};
};
useGLTF.preload('/models/shiba.glb');
export function Shiba() {
const { nodes, materials } = useGLTF('/models/shiba.glb') as GLTFResult;
const worldPosition = useMemo(() => new Vector3(), []);
const worldDirection = useMemo(() => new Vector3(), []);
const { eventable, blockEvent, isLanded, setIsLanded, getEventableState } =
useShibaStore();
const { setIsVisible } = useShowingProcessStore();
const position: [x: number, y: number, z: number] = [0, 1, 0];
const { left, right, forward, backward, jump } = useInput();
const isMoving = forward || backward || left || right;
const width = 0.65;
const height = 1.2;
const front = 0.6;
const mass = 100;
const { open } = useModalContext();
const { data } = useSession();
const chassisBodyArgs = [width, height, front * 2];
const { eventList, setEventStatus } = useShibaEventStore();
const [chassisBody, chassisApi] = useCompoundBody(
() => ({
position,
mass: mass,
rotation: [0, 0, 0],
collisionFilterGroup: 5,
angularDamping: 0.95,
onCollide: () => {
!isLanded && setIsLanded(true);
},
shapes: [
{
args: chassisBodyArgs,
position: [0, 0, 0],
type: 'Box',
},
],
}),
useRef<Group>(null)
);
const controlMovement = useMovePosition({
worldDirection,
worldPosition,
chassisApi,
chassisBody,
});
const orbitControlsRef = useRef<OrbitControlsRef>(null);
const makeFollowCam = () => {
chassisBody?.current!.getWorldPosition(worldPosition);
chassisBody?.current!.getWorldDirection(worldDirection);
if (orbitControlsRef.current) {
orbitControlsRef.current.target.copy(worldPosition);
}
};
const checkMapType = () => {
const { x, y, z } = new Vector3().setFromMatrixPosition(
chassisBody.current!.matrixWorld
);
let newLocation: ShibaLocation;
if (y < 1.1) {
newLocation = x > 10.5 && z > 4 ? '언덕' : '강';
} else {
newLocation = x >= 2.5 ? '언덕' : '집';
}
if (eventable) {
blockEvent();
eventByLocation(newLocation);
}
if (Math.abs(5.5 - x) < 1 && Math.abs(-2.8 - z) < 1 && y < 2) {
setIsVisible(true);
} else {
setIsVisible(false);
}
};
const eventByLocation = (location: ShibaLocation) => {
const occurableEvents = SHIBA_EVENT[location];
const selectedEvent = getRandomEvent(occurableEvents);
open({ type: 'shiba', event: selectedEvent }, getEventableState);
const userId = data?.user?.email;
if (!eventList[selectedEvent.type]) {
setEventStatus(selectedEvent.type);
userId &&
checkNewEvent({
id: userId,
type: selectedEvent.type,
});
}
};
const getRandomEvent = (eventList: ShibaEvent[]): EventResultProps => {
const totalWeight = eventList.reduce((sum, event) => sum + event.weight, 0);
let random = Math.random() * totalWeight;
for (const event of eventList) {
const { weight } = event;
if (random < weight) {
return { ...event, percent: Math.floor((weight / totalWeight) * 100) };
}
random -= event.weight;
}
return {
...eventList[eventList.length - 1],
percent:
Math.floor(eventList[eventList.length - 1].weight / totalWeight) * 100,
};
};
useFrame((_, delta) => {
makeFollowCam();
controlMovement(delta);
!jump && isLanded && isMoving && checkMapType();
});
return (
<>
<group>
<group ref={chassisBody} position={[0, 0.5, 20]} castShadow>
<group position={[0, 0.35, 0.5]} rotation={[-Math.PI / 2, 0, 0]}>
<mesh
geometry={nodes.Group18985_default_0.geometry}
material={materials['default']}
/>
<mesh
geometry={nodes.Box002_default_0.geometry}
material={materials['default']}
/>
<mesh
geometry={nodes.Object001_default_0.geometry}
material={materials['default']}
/>
</group>
</group>
</group>
<OrbitControls ref={orbitControlsRef} minDistance={2} maxDistance={10} />
</>
);
}
리팩토링을 진행하기
우선 값을 먼저 분리하려고 합니다. 상수값들 조차 관심사별로 뭉쳐있지 않으니, 한눈에 파악하기 힘들더군요..(이런건 다음부터 리팩토링 전에도 신경써야 겠습니다..)
진행된 내용
1. glb파일에서 시바를 로드해서 랜더링함
이미 라이브러리 훅으로 사용하고 있기에 분리가 필요하지 않습니다.
3. 시바가 키보드 입력에 따라 이동을 함
훅으로 불리했습니다. 다만 훅 내부에서 useInput 훅을 사용하고 있기에 props로 받도록 하여 결합도를 낮췄습니다.
4. 시바가 이동시 카메라가 따라 다님
함수로 분리했기 때문에 추가 작업을 하지 않고, 별도의 파일로 관리할 필요성을 느끼지 못했습니다.
7. 점프 시에는 착지하지 않았다고 설정함
useMovePosition 훅 내부에서 설정하였기에 추가 작업을 하진 않았습니다.
위에 언급하지 않은 내용중 2번은 충돌체에 관한 내용이고 나머지는 이벤트 발생에 관련된 내용(5,6,8,9,10)들입니다.
충돌체 관련 분리하기 (2번)
const { eventable, blockEvent, isLanded, setIsLanded, getEventableState } =
useShibaStore();
const width = 0.65;
const height = 1.2;
const front = 0.6;
const mass = 100;
const position: [x: number, y: number, z: number] = [0, 1, 0];
const chassisBodyArgs = [width, height, front * 2];
const [chassisBody, chassisApi] = useCompoundBody(
() => ({
position,
mass: mass,
rotation: [0, 0, 0],
collisionFilterGroup: 5,
angularDamping: 0.95,
onCollide: () => {
!isLanded && setIsLanded(true);
},
shapes: [
{
args: chassisBodyArgs,
position: [0, 0, 0],
type: 'Box',
},
],
}),
useRef<Group>(null)
);
충돌체와 관련된 기능만 모으면 아래와 같습니다. useShibaStore를 제외한 넓이,높이,길이(앞뒤)와 위치등은 CompoundBody 훅 내부의 관심사 입니다. 딱히 다른 컴퍼넌트들이 알 필요가 없어 보이네요.
따라서 충돌체를 커스텀훅으로 분리하고자 합니다. 이때 isLanded, setIsLanded는 매개변수로 받아주려고 합니다.
import { useCompoundBody } from '@react-three/cannon';
import { useRef } from 'react';
import { Group } from 'three';
type props = {
collideFn: () => void;
};
export const useShibaBody = ({ collideFn }: props) => {
const width = 0.65;
const height = 1.2;
const front = 0.6;
const chassisBodyArgs = [width, height, front * 2];
const [shibaBody, shibaBodyApi] = useCompoundBody(
() => ({
position: [0, 1, 0],
mass: 100,
rotation: [0, 0, 0],
collisionFilterGroup: 5,
angularDamping: 0.95,
onCollide: collideFn,
shapes: [
{
args: chassisBodyArgs,
position: [0, 0, 0],
type: 'Box',
},
],
}),
useRef<Group>(null)
);
return [shibaBody, shibaBodyApi] as const;
};
외부 결합도를 가지던 useCompoundBody 내용을 스탬프 결합도로 바꿔서 결합도를 낮췄고, Shiba.tsx 코드 길이또한 184줄에서 162줄로 줄였습니다.
'use client';
import { Group, Mesh, MeshBasicMaterial, Vector3 } from 'three';
import React, { useMemo, useRef } from 'react';
import { OrbitControls, useGLTF } from '@react-three/drei';
import { GLTF, OrbitControls as OrbitControlsRef } from 'three-stdlib';
import { useCompoundBody } from '@react-three/cannon';
import { useFrame } from '@react-three/fiber';
import { useMovePosition } from '../../hooks/useMovePosition';
import { useInput } from '../../hooks/useInput';
import { useShibaStore } from '@/store/shiba';
import { ShibaLocation } from '@/shared/constants/model';
import {
EventResultProps,
SHIBA_EVENT,
ShibaEvent,
} from '@/shared/constants/shibaEvent';
import { useModalContext } from '@/shared/components/portal/ModalContext';
import { checkNewEvent } from '@/remote/shiba';
import { useShibaEventStore } from '@/store/shibaEvent';
import { useShowingProcessStore } from '@/store/showingProcess';
import { useSession } from 'next-auth/react';
import { useShibaBody } from '../../hooks/useShibaBody';
type GLTFResult = GLTF & {
nodes: {
Group18985_default_0: Mesh;
Box002_default_0: Mesh;
Object001_default_0: Mesh;
};
materials: {
['default']: MeshBasicMaterial;
};
};
useGLTF.preload('/models/shiba.glb');
export function Shiba() {
const { nodes, materials } = useGLTF('/models/shiba.glb') as GLTFResult;
const worldPosition = useMemo(() => new Vector3(), []);
const worldDirection = useMemo(() => new Vector3(), []);
const { eventable, blockEvent, isLanded, setIsLanded, getEventableState } =
useShibaStore();
const { setIsVisible } = useShowingProcessStore();
const { left, right, forward, backward, jump } = useInput();
const isMoving = forward || backward || left || right;
const { open } = useModalContext();
const { data } = useSession();
const { eventList, setEventStatus } = useShibaEventStore();
const [chassisBody, chassisApi] = useShibaBody({
collideFn: () => {
!isLanded && setIsLanded(true);
},
});
const controlMovement = useMovePosition({
worldDirection,
worldPosition,
chassisApi,
chassisBody,
});
const orbitControlsRef = useRef<OrbitControlsRef>(null);
const makeFollowCam = () => {
chassisBody?.current!.getWorldPosition(worldPosition);
chassisBody?.current!.getWorldDirection(worldDirection);
if (orbitControlsRef.current) {
orbitControlsRef.current.target.copy(worldPosition);
}
};
const checkMapType = () => {
const { x, y, z } = new Vector3().setFromMatrixPosition(
chassisBody.current!.matrixWorld
);
let newLocation: ShibaLocation;
if (y < 1.1) {
newLocation = x > 10.5 && z > 4 ? '언덕' : '강';
} else {
newLocation = x >= 2.5 ? '언덕' : '집';
}
if (eventable) {
blockEvent();
eventByLocation(newLocation);
}
if (Math.abs(5.5 - x) < 1 && Math.abs(-2.8 - z) < 1 && y < 2) {
setIsVisible(true);
} else {
setIsVisible(false);
}
};
const eventByLocation = (location: ShibaLocation) => {
const occurableEvents = SHIBA_EVENT[location];
const selectedEvent = getRandomEvent(occurableEvents);
open({ type: 'shiba', event: selectedEvent }, getEventableState);
const userId = data?.user?.email;
if (!eventList[selectedEvent.type]) {
setEventStatus(selectedEvent.type);
userId &&
checkNewEvent({
id: userId,
type: selectedEvent.type,
});
}
};
const getRandomEvent = (eventList: ShibaEvent[]): EventResultProps => {
const totalWeight = eventList.reduce((sum, event) => sum + event.weight, 0);
let random = Math.random() * totalWeight;
for (const event of eventList) {
const { weight } = event;
if (random < weight) {
return { ...event, percent: Math.floor((weight / totalWeight) * 100) };
}
random -= event.weight;
}
return {
...eventList[eventList.length - 1],
percent:
Math.floor(eventList[eventList.length - 1].weight / totalWeight) * 100,
};
};
useFrame((_, delta) => {
makeFollowCam();
controlMovement(delta);
!jump && isLanded && isMoving && checkMapType();
});
return (
<>
<group>
<group ref={chassisBody} position={[0, 0.5, 20]} castShadow>
<group position={[0, 0.35, 0.5]} rotation={[-Math.PI / 2, 0, 0]}>
<mesh
geometry={nodes.Group18985_default_0.geometry}
material={materials['default']}
/>
<mesh
geometry={nodes.Box002_default_0.geometry}
material={materials['default']}
/>
<mesh
geometry={nodes.Object001_default_0.geometry}
material={materials['default']}
/>
</group>
</group>
</group>
<OrbitControls ref={orbitControlsRef} minDistance={2} maxDistance={10} />
</>
);
}
위치 기반 이벤트 리팩토링 하기
1. checkMapType 함수 개선 및 이름 변경하기 -> interactByPosition
useFrame((_, delta) => {
makeFollowCam();
controlMovement(delta);
!jump && isLanded && isMoving && checkMapType(); // 불필요한 호출 방지
});
useFrame훅 내부에서 카메라, 이동, 이벤트 발생을 매 프레임으로 실행중인데, 카메라와 이동의 경우에는 이미 분리되어 있습니다. 따라서 checkMapType이라는 함수를 확인하고 리팩토링을 진행하려고 합니다.
해당 변수명 부터 사실 무슨 내용이 있을지 알기 힘들다는 생각이 되네요.
현재 제 프로젝트에서는 획득 목록을 확인하는 비컨이 있습니다. 시바가 비컨 위에 올라가게 되면 비컨의 상하 이동은 멈추게 되고 팝업창이 열리며 획득 목록을 보여줍니다.
또한 시바가 이동하는 위치에 따라서 각각 산, 언덕, 집으로 맵을 분류하고 있습니다.
그러기 위해선 시바의 위치를 측정하여 현재 어떤 곳에 있는지를 측정하고 있습니다. 이때 점프중이거나, 착지하지 않았을 때, 그리고 정지 했을 때는 불필요한 위치 측정을 하지 않도록 하고 있습니다.
const checkMapType = () => {
const { x, y, z } = new Vector3().setFromMatrixPosition(
chassisBody.current!.matrixWorld
);
let newLocation: ShibaLocation;
if (y < 1.1) {
newLocation = x > 10.5 && z > 4 ? '언덕' : '강';
} else {
newLocation = x >= 2.5 ? '언덕' : '집';
}
if (eventable) {
blockEvent();
eventByLocation(newLocation);
}
if (Math.abs(5.5 - x) < 1 && Math.abs(-2.8 - z) < 1 && y < 2) {
setIsVisible(true);
} else {
setIsVisible(false);
}
};
문제점
1. 우선 기능을 설명하다보니 위치에 따른 상호작용을 하는 함수입니다. 이를 기반으로 이름을 수정할 필요성이 보입니다.
2. 함수를 다시 보니, 팝업창이 열렸는데도 이벤트가 발생하겠군요.. 이를 얼리리턴 기법으로 수정할 필요성이 보입니다.
3. 획득목록 확인, 현재 위치 정의, event 실행 이렇게 3가지로 구분지을 수 있어 보입니다.
개선과정
Step 1 : 1차원적 분리
const interactByPosition = () => {
const { x, y, z } = new Vector3().setFromMatrixPosition(
chassisBody.current!.matrixWorld
);
if (Math.abs(5.5 - x) < 1 && Math.abs(-2.8 - z) < 1 && y < 2) {
return setIsVisible(true);
} else {
setIsVisible(false);
}
if (eventable) {
let newLocation: ShibaLocation;
if (y < 1.1) {
newLocation = x > 10.5 && z > 4 ? '언덕' : '강';
} else {
newLocation = x >= 2.5 ? '언덕' : '집';
}
blockEvent();
eventByLocation(newLocation);
}
};
우선 변수명을 위치에 따라 상호작용하기에 interactByPosition으로 바꿨습니다.
그리고 return을 통해서 관측중이지 않을떄만 위치에 따른 이벤트를 실행하도록 분리하였습니다.
Step 2 : 획득목록 확인 기능 분리 및 개선
하지만 획득목록 확인하는 부분에 대한 추가적인 분리가 필요해보였습니다. 굳이 얼리리턴을 써야하나? setIsVisible은 useShibaStore에서 가져오는데 불필요하게 결합도를 높인 것 같습니다. 팝업창 확인 여부를 Shiba만 관리하는데 차라리 isWatchingProcess로 기능을 변경하는게 어떤가라는 생각이 들었습니다.
아래와 같았던 코드를
import { create } from 'zustand';
interface ShowingProcess {
isVisible: boolean;
setIsVisible: (isVisible: boolean) => void;
}
export const useShowingProcessStore = create<ShowingProcess>()((set) => ({
isVisible: false,
// 수정할 대상
setIsVisible: (isVisible: boolean) =>
set((state) => ({
...state,
isVisible,
})),
}));
보고 있는지 검사하는 함수로 수정하여 선언적으로 사용할 수 있도록 하였습니다.
import { create } from 'zustand';
interface ShowingProcess {
isVisible: boolean;
isWatchingProcess: (x: number, y: number, z: number) => boolean;
}
export const useShowingProcessStore = create<ShowingProcess>()((set) => ({
isVisible: false,
isWatchingProcess: (x: number, y: number, z: number) => {
const isWatching = Math.abs(5.5 - x) < 1 && Math.abs(-2.8 - z) < 1 && y < 2;
set((state) => ({
...state,
isVisible: isWatching,
}));
return isWatching;
},
}));
그 결과 interactByPosition 함수는 아래와 같이 개선되었습니다.
위치를 검사해서 획득목록을 보고 있는지 검사, 보고 있지 않고 이벤트 가능하다면 실행하기
const interactByPosition = () => {
const { x, y, z } = new Vector3().setFromMatrixPosition(
chassisBody.current!.matrixWorld
);
const isWatching = isWatchingProcess(x, y, z);
if (!isWatching && eventable) {
let newLocation: ShibaLocation;
if (y < 1.1) {
newLocation = x > 10.5 && z > 4 ? '언덕' : '강';
} else {
newLocation = x >= 2.5 ? '언덕' : '집';
}
blockEvent();
eventByLocation(newLocation);
}
};
Step 3 : 현재 지역 정의하는 부분 분리하기
폴더의 depth를 늘이지 않기 위해 dotnaming으로 util함수를 관리하는 shiba.util.ts 파일을 만들어줍니다.이후 지역을 정의하는 함수를 생성합니다.
import { ShibaLocation } from '@/shared/constants/model';
const defineLocation = (x: number, y: number, z: number): ShibaLocation => {
if (y < 1.1) {
return x > 10.5 && z > 4 ? '언덕' : '강';
} else {
return x >= 2.5 ? '언덕' : '집';
}
};
const interactByPosition = () => {
const { x, y, z } = new Vector3().setFromMatrixPosition(
chassisBody.current!.matrixWorld
);
const isWatching = isWatchingProcess(x, y, z);
if (!isWatching && eventable) {
const currentLoation = defineLocation(x, y, z);
blockEvent();
eventByLocation(currentLoation);
}
};
이벤트 이름이 blockEvent보다는 쿨타임을 적용하는 기능이기에 applyEventCooldown로 수정하는게 좋을 거 같네요.
그리고 eventByLocation보다는 이벤트를 발생시킨다는 이름인 triggerEvent 가 더 적합해보이네요.
const interactByPosition = () => {
const { x, y, z } = new Vector3().setFromMatrixPosition(
chassisBody.current!.matrixWorld
);
const isWatching = isWatchingProcess(x, y, z);
if (!isWatching && eventable) {
const currentLoation = defineLocation(x, y, z);
applyEventCooldown();
triggerEvent(currentLoation);
}
};
2. triggerEvent 함수 개선하기 (구 eventByLocation 함수)
const triggerEvent = (location: ShibaLocation) => {
const occurableEvents = SHIBA_EVENT[location];
const selectedEvent = getRandomEvent(occurableEvents);
open({ type: 'shiba', event: selectedEvent }, getEventableState);
const userId = data?.user?.email;
if (!eventList[selectedEvent.type]) {
setEventStatus(selectedEvent.type);
userId &&
checkNewEvent({
id: userId,
type: selectedEvent.type,
});
}
};
const getRandomEvent = (eventList: ShibaEvent[]): EventResultProps => {
const totalWeight = eventList.reduce((sum, event) => sum + event.weight, 0);
let random = Math.random() * totalWeight;
for (const event of eventList) {
const { weight } = event;
if (random < weight) {
return { ...event, percent: Math.floor((weight / totalWeight) * 100) };
}
random -= event.weight;
}
return {
...eventList[eventList.length - 1],
percent:
Math.floor(eventList[eventList.length - 1].weight / totalWeight) * 100,
};
};
관련 기능 설명
1. getRandomEvent를 통해 랜덤 이벤트를 얻는다.
2. 획득한 이벤트 내용을 모달을 열며 보여준다.
3. 이미 겪었던 이벤트인지 확인하고, 처음 겪는 이벤트면 이벤트를 체크한다. 이때 로그인되었다면 db에도 반영해준다.
Step1. triggerEvent에 사용되는 getRandomEvent 함수 shiba.util.ts로 분리하기
발생 가능한 이벤트 목록을 받아오고, 확률에 기반해서 랜덤한 이벤트를 얻습니다(getRandomEvent 함수)
이후 해당 내용을 바탕으로 모달창을 열고, 만약 로그인되어있다면 해당 내용을 fireBase에 반영해줍니다.
우선 getRandomEvent의 경우에 제 기준으로 충분히 잘 분리되었다고 생각합니다. 발생 가능한 이벤트 중에서 비중을 따지고, Math.random을 통해 얻은 값을 기반으로 이벤트를 선택해줍니다. 이후 데이터를 db 및 모달 open함수에서 사용하기 좋게 가공하여 리턴해줍니다. 추가적으로 발생가능한 이벤트 내용들도 해당 함수내에서 확인하도록 수정하고 util파일로 이동시키겠습니다.
export const getRandomEvent = (location: ShibaLocation): EventResultProps => {
const eventList = SHIBA_EVENT[location];
const totalWeight = eventList.reduce((sum, event) => sum + event.weight, 0);
let random = Math.random() * totalWeight;
for (const event of eventList) {
const { weight } = event;
if (random < weight) {
return { ...event, percent: Math.floor((weight / totalWeight) * 100) };
}
random -= event.weight;
}
return {
...eventList[eventList.length - 1],
percent:
Math.floor(eventList[eventList.length - 1].weight / totalWeight) * 100,
};
};
개선된 triggerEvent 함수
const triggerEvent = (location: ShibaLocation) => {
const selectedEvent = getRandomEvent(location);
open({ type: 'shiba', event: selectedEvent }, getEventableState);
const userId = data?.user?.email;
if (!eventList[selectedEvent.type]) {
setEventStatus(selectedEvent.type);
userId &&
checkNewEvent({
id: userId,
type: selectedEvent.type,
});
}
};
Step2. 획득 내역 갱신 부분 분리하기
const { data } = useSession();
const { eventList, setEventStatus } = useShibaEventStore();
const triggerEvent = (location: ShibaLocation) => {
const selectedEvent = getRandomEvent(location);
open({ type: 'shiba', event: selectedEvent }, getEventableState);
const userId = data?.user?.email;
if (!eventList[selectedEvent.type]) {
setEventStatus(selectedEvent.type);
userId &&
checkNewEvent({
id: userId,
type: selectedEvent.type,
});
}
};
불필요한 api호출을 줄이기 위해서 그리고 비로그인시에도 획득목록을 확인할 수 있도록 획득 목록을 zustand를 통해서 전역상태 관리를 하고 있습니다.
캔버스가 처음 그려질 때, 로그인되었다면 서버에서 해당 내역을 받아오고, 그렇지 않다면 획득한 내용이 없는 상태로 렌더링이 됩니다.
따라서 커스텀 훅을 통해서 해당 기능을 분리하려고 합니다. 훅 내부에서는 획득 내용을 갱신하는 함수를 반환하여 선언적으로 사용할 수 있게 하려고 합니다.
갱신 관련 내용이 분리된 useEventProcess 훅
import { checkNewEvent } from '@/remote/shiba';
import { EventResultProps } from '@/shared/constants/shibaEvent';
import { useShibaEventStore } from '@/store/shibaEvent';
import { useSession } from 'next-auth/react';
export const useEventProcess = () => {
const { data } = useSession();
const { eventList, setEventStatus } = useShibaEventStore();
const userId = data?.user?.email;
const renewProcess = (selectedEvent: EventResultProps) => {
if (!eventList[selectedEvent.type]) {
setEventStatus(selectedEvent.type);
userId &&
checkNewEvent({
id: userId,
type: selectedEvent.type,
});
}
};
return renewProcess;
};
개선된 triggerEvent 함수
const triggerEvent = (location: ShibaLocation) => {
const selectedEvent = getRandomEvent(location);
open({ type: 'shiba', event: selectedEvent }, getEventableState);
renewProcess(selectedEvent);
};
최종 수정 코드 및 느낀점
'use client';
import { Mesh, MeshBasicMaterial, Vector3 } from 'three';
import React, { useMemo, useRef } from 'react';
import { OrbitControls, useGLTF } from '@react-three/drei';
import { GLTF, OrbitControls as OrbitControlsRef } from 'three-stdlib';
import { useFrame } from '@react-three/fiber';
import { useShibaStore } from '@/store/shiba';
import { useShowingProcessStore } from '@/store/showingProcess';
import { ShibaLocation } from '@/shared/constants/model';
import { useModalContext } from '@/shared/components/portal/ModalContext';
import { useMovePosition } from '../../hooks/useMovePosition';
import { useInput } from '../../hooks/useInput';
import { useShibaBody } from '../../hooks/useShibaBody';
import { defineLocation, getRandomEvent } from './shiba.util';
import { useEventProcess } from '../../hooks/useEventProcess';
type GLTFResult = GLTF & {
nodes: {
Group18985_default_0: Mesh;
Box002_default_0: Mesh;
Object001_default_0: Mesh;
};
materials: {
['default']: MeshBasicMaterial;
};
};
useGLTF.preload('/models/shiba.glb');
export function Shiba() {
const { nodes, materials } = useGLTF('/models/shiba.glb') as GLTFResult;
const worldPosition = useMemo(() => new Vector3(), []);
const worldDirection = useMemo(() => new Vector3(), []);
const {
eventable,
applyEventCooldown,
isLanded,
setIsLanded,
getEventableState,
} = useShibaStore();
const { isWatchingProcess } = useShowingProcessStore();
const renewProcess = useEventProcess();
const { open } = useModalContext();
const { left, right, forward, backward, jump } = useInput();
const isMoving = forward || backward || left || right;
const [chassisBody, chassisApi] = useShibaBody({
collideFn: () => {
!isLanded && setIsLanded(true);
},
});
const controlMovement = useMovePosition({
worldDirection,
worldPosition,
chassisApi,
chassisBody,
inputState: { left, right, forward, backward, jump },
});
const orbitControlsRef = useRef<OrbitControlsRef>(null);
const makeFollowCam = () => {
chassisBody?.current!.getWorldPosition(worldPosition);
chassisBody?.current!.getWorldDirection(worldDirection);
if (orbitControlsRef.current) {
orbitControlsRef.current.target.copy(worldPosition);
}
};
const interactByPosition = () => {
const { x, y, z } = new Vector3().setFromMatrixPosition(
chassisBody.current!.matrixWorld
);
const isWatching = isWatchingProcess(x, y, z);
if (!isWatching && eventable) {
const currentLoation = defineLocation(x, y, z);
applyEventCooldown();
triggerEvent(currentLoation);
}
};
const triggerEvent = (location: ShibaLocation) => {
const selectedEvent = getRandomEvent(location);
open({ type: 'shiba', event: selectedEvent }, getEventableState);
renewProcess(selectedEvent);
};
useFrame((_, delta) => {
makeFollowCam();
controlMovement(delta);
!jump && isLanded && isMoving && interactByPosition();
});
return (
<>
<group>
<group ref={chassisBody} position={[0, 0.5, 20]} castShadow>
<group position={[0, 0.35, 0.5]} rotation={[-Math.PI / 2, 0, 0]}>
<mesh
geometry={nodes.Group18985_default_0.geometry}
material={materials['default']}
/>
<mesh
geometry={nodes.Box002_default_0.geometry}
material={materials['default']}
/>
<mesh
geometry={nodes.Object001_default_0.geometry}
material={materials['default']}
/>
</group>
</group>
</group>
<OrbitControls ref={orbitControlsRef} minDistance={2} maxDistance={10} />
</>
);
}
180줄이었던 코드를 관심사 분리를 통해서 120줄로 줄였습니다. 선언적으로 사용하게 하고 결합도를 낮추는 방법에 대해 고민하다 보니 생각보다 작업량이 많았습니다.
최근 정처기를 공부하다보니 실질적으로 코드 짜는 양이 평소보다 줄었는데, 관련 내용을 상기하면서 리팩토링을 하니 공부한 보람이 느껴지내요. (결합도)
그리고 완벽하진 않더라도 나름 신경써서 코드를 작성했는데 이만큼이나 개선해야해? 라는 생각도 들었습니다.
그래도 좋은 설계에 대해 고민했기 때문에 개선할 수 있었고, 추후에 보면 허접한 코드로 보이더라도 내가 성장했다고 느낄 수 있지 않을까요?
'프로젝트' 카테고리의 다른 글
goodluck 프로젝트 폴더 구조와 좋은 구조에 대한 생각과 피드백 (0) | 2024.07.05 |
---|---|
MyPage를 개방폐쇄원칙(OCP) 고려해서 마이페이지 리팩토링 하기 (0) | 2024.07.02 |
Draco를 활용하여 glb파일 압축하여 성능 향상시키기 (0) | 2024.07.01 |
ManulPopup창을 관심사 분리하기 (값,계산,액션, SOLID) (0) | 2024.06.29 |
R3F 및 useCannon에서 시바 조종하기 (최종 이동 로직 구현 및 채택 과정) (0) | 2024.06.28 |