프론트 공용 문서 - To-Letter/To-Letter-front GitHub Wiki

폴더 구조 정리

public
├── images(이미지 파일 관리 폴더)
│   ├── scenery(풍경 구현에 필요한 이미지 관리 폴더)
├── models(glb파일 관리 폴더)
src
├── api(api 관련 로직들을 정리)
│   ├── controller(서버와의 통신에 대한 엔드포인트 및 파라미터 관리 폴더)
├── components(재사용 컴포넌트들을 담은 폴더)
│   ├── account(사용자 계정에 대한 컴포넌트 폴더)
│   ├── Room(방안에서 사용되는 사물 컴포넌트 폴더)
│   ├── Scenery(풍경 관련 컴포넌트 폴더)
├── contants(풍경 관련 상수값 관리 폴더)
├── context(context 코드 폴더)
├── pages(페이지 관리 폴더)
├── recoil(recoil관련 폴더)
├── utils(공통적으로 사용되는 헬퍼 함수나 유틸리티 기능)

font & color

1. font

2. color

  • main color : #cbb1a0
  • sub color : #7b5d54



공용 UI

1. Toast Message(/src/component/ToastMessage.tsx)

ToastMessage 타입 정의

interface ToastMessageProps {
  message: string;
  duration?: number; // 메시지가 표시되는 시간 (기본값: 3초)
  onClose: () => void;
}

useState를 이용해 관리

  const [toast, setToast] = useState<{ message: string; visible: boolean }>({
    message: "",
    visible: false,
  });

setter로 message와 visible의 유무를 업데이트

setToast({
          message: "회원가입 성공!",
          visible: true,
        });

toast message를 사용하고자 하는 구간에 추가하여 사용

{toast.visible && (
        <ToastMessage
          message={toast.message}
          onClose={() => setToast({ ...toast, visible: false })}
        />
      )}

2. Progressage Bar -> Loding Spinner(변경 예정 추후에 업데이트)

전역 상태 값

1. PopupContext, MenuContext(삭제예정)

PopupContext 타입 정의
createContext를 이용하여 PopupContext생성

interface PopupContextProps {
  activePopup: string | null;
  setActivePopup: (popupId: string | null) => void;
}
const PopupContext = createContext<PopupContextProps | undefined>(undefined);

Provider를 이용해 하위 컴포넌트에세 값 제공

export const PopupProvider = ({ children }: { children: ReactNode }) => {
  const [activePopup, setActivePopup] = useState<string | null>(null);

  return (
    <PopupContext.Provider value={{ activePopup, setActivePopup }}>
      {children}
    </PopupContext.Provider>
  );
};

usePopup을 이용해 context값을 전역적으로 관리

export const usePopup = () => {
  const context = useContext(PopupContext);
  if (!context) {
    throw new Error("usePopup must be used within a PopupProvider");
  }
  return context;
};

3. Loding Spinner(recoil을 통해 상태관리를 하고 있으나, 공용 UI이기 때문에 해당 목차로 기재)

상태관리(/src/recoil/loadingAtom.ts)

import { atom } from 'recoil';

// 로딩 상태를 관리하는 Recoil atom
export const loadingState = atom<boolean>({
  key: 'loadingState', // 고유 키
  default: false, // 초기값 false
});

loading Spinner code(/src/component/LoadingSpinner.tsx)

...
import { loadingState } from '../recoil/loadingAtom';
...
const LoadingSpinner: React.FC = () => {
  const isLoading = useRecoilValue(loadingState); // Recoil에서 로딩 상태 가져오기

  if (!isLoading) return null; // 로딩 중이 아닐 때는 컴포넌트를 렌더링하지 않음

  return (
    <SpinnerOverlay>
      <Spinner />
    </SpinnerOverlay>
  );
};

export default LoadingSpinner;

사용 예시(/src/component/account/MailVerify.tsx)

const MailVerify: React.FC = () => {
.
.
  const setLoding = useSetRecoilState(loadingState)
.
.
  // 이메일 인증 요청
  const submitSignup = async () => {
    if (!verifyMe) {
      setToast({ message: "인증 요청 버튼을 먼저 눌러주세요.", visible: true });
    } else if (mailKey === "" || mailKey.length !== 6) {
      setToast({ message: "인증 키가 제대로 입력되지 않았습니다.", visible: true });
    } else {
      try {
        setLoding(true);
        let res: any = await postEmailVerify({
          email: email,
          randomCode: mailKey,
        });

        if (res.data.responseCode === 200) {
          setLoding(false);
          setToast({ message: "회원가입 성공!", visible: true });
          setModalState({
            isOpen: true,
            type: "login",
          });
        } else if (res.data.responseCode === 401) {
          setLoding(false);
          setAuthReqMessage(true);
          setVerifyMe(true);
          setToast({ message: "시간 초과로 인증코드를 다시 보냈습니다.", visible: true });
        } else if (res.data.responseCode === 403) {
          setLoding(false);
          setToast({ message: "인증 코드가 불일치합니다.", visible: true });
        } else if (res.data.responseCode === 404) {
          setLoding(false);
          setToast({ message: "메일이 존재하지 않습니다. 다른 메일로 시도해주세요.", visible: true });
          setModalState({
            isOpen: true,
            type: "signup",
          });
        }
      } catch (err) {
        console.error(err);
      }
    }
  };
.
.
.
};

export default MailVerify;



전역 State 관리(recoil)

1. account(회원가입/로그인) 관련 recoil(/src/recoil/accountAtom.ts)

로그인/회원가입 모달 상태의 타입 정의

interface accountModalState {
  isOpen: boolean;
  type: 'login' | 'signup' | 'kakaoSignup' | 'MailVerify' | null;
}

로그인/회원가입 모달창 열림/닫힘 상태

export const accountModalState = atom<accountModalState>({
  key: 'accountModalState', // 고유 키
  default: {
    isOpen: false,
    type: null, // 초기값
  },
});

이메일 입력 값(이메일 인증 시 사용) 상태

export const emailState = atom<string>({
  key: 'emailState', // 고유 키
  default: '', // 유저가 입력한 이메일 값
});

useRecoilState로 전역적으로 관리

const [_email, setEmail] = useRecoilState(emailState);
const [_modalState, setModalState] = useRecoilState(accountModalState);



공용 함수 및 객체

1. axiosInterceptor(/src/apis/axiosInterceptor.ts)

로그인된 상황에서 sendApi를 이용한 모든 요청이 응답 받은 후 거치고 가는 공용 Interceptor함수

response =>

  1. 모든 response header에 authorization, refreshtoken token이 있는지 체크
    2-1. 없는 경우 엑세스 토큰 유지 중
    2-2. 있는 경우 엑세스 토큰이 만료 되어 리프레시 토큰을 바탕으로 새로운 토큰을 받아온 것, 토큰 교체

error =>

  1. 1003(리프레시 토큰 만료), 1002(빈 토큰)statusCode 인지를 확인
  2. alert 창 출력 후 로그아웃 처리
axiosInterceptor.interceptors.response.use(
  async (response: any) => {
    try {
      const accessToken = response.headers?.get("authorization");
      const refreshToken = response.headers?.get("refreshtoken");
      console.log("intercepter: ", accessToken, refreshToken);
    if(accessToken !== undefined && refreshToken !== undefined){
      sessionStorageService.set("accessToken", accessToken);
      sessionStorageService.set("refreshToken", refreshToken);
    }
      
    } catch (error) {
      console.error("axiosInterceptor error")
    }
    
    return response;
  },
  async (error) => {
    try {
      if (error.response.data.code === 1003 && error.response.data.code === 1002) {
        alert('로그인 유지 시간이 만료되었습니다. 재로그인 해주세요.')
        sessionStorageService.delete();
        window.location.href = '/'
      }
    } catch (error) {
      console.error("axiosInterceptor error")
    }
  }
);

2. sendApi(/src/apis/sendApi.ts)

서버에 요청을 보내는 공용 함수

get요청

  1. sessionStorage에 accessToken의 유무를 sessionStorageService로 체크
  2. accessToken이 있다면(로그인한 상태) axiosInterceptor로 토큰 만료 여부 체크
  3. accessToken이 null이라면 axios요청을 보냄(보통 비로그인시 필요한 요청(ex.닉네임 중복체크)

post, put, delete 요청

  1. 공통적으로 axiosInterceptor을 사용해 요청
  2. authorization을 요청 헤더에 보냄으로서 axiosInterceptor에서 토큰 만료 등의 여부 체크
export const sendApi = {
  
  get: (url: string) => {
    const accessToken = sessionStorageService.get("accessToken");
    // accessToken 존재시 즉, 로그인한 상태
    if (accessToken !== null) {
      return axiosInterceptor.get(
        AUTH_KEY.apiUrl + url,
        //기본 헤더 설정, 엑세스 토큰 및 리프레시 토큰 설정
        authorization()
      );
    } else {
      return axios.get(AUTH_KEY.apiUrl + url, { withCredentials: true });
    }
  },

  post: (url: string, req: object = {}) => {
    return axiosInterceptor.post(
      AUTH_KEY.apiUrl + url,
      req,
      authorization()
    );
  },
  put, delete는 post와 동일

3. authorization(/src/utils/httpService.ts)

요청을 보낼 때 필요한 인증 헤더를 설정

const authorization = () => {
  return {
    headers: {
      Authorization: `${sessionStorageService.get("accessToken")}`,
      refreshToken: `${sessionStorageService.get("refreshToken")}`,
    },
    
    withCredentials: true,
  };
};

4. sessionStorageService(/src/utils/sessionStorageService.ts)

세션 스토리지 관리 객체

set: 엑세스 토큰이나 리프레시 토큰을 세션 스토리지에 저장하는 메소드(로그인 시, 토큰 재발급 시 사용)

get: 세션 스토리지에서 엑세스 토큰 또는 리프레시 토큰을 가져오는 메소드(로그인 여부 판별 시 사용)

delete: 세션 스토리지에서 두 토큰을 삭제하는 메소드(로그아웃 처리 시 사용)

import { SESSION_ACCESSTOKEN_KEY, SESSION_REFRESHTOKEN_KEY } from "../constants/session";
// SESSION_ACCESSTOKEN_KEY : 엑세스 토큰 key 값 상수 문자열 
// SESSION_REFRESHTOKEN_KEY : 리프레시 토큰 key 값 상수 문자열

/**
 * 세션 스토리지 관리
 */
const sessionStorageService = {
  /**
   * 세션 스토리지에 값을 넣는 메소드
   * @param type "accessToken" | "refreshToken"
   * @param value 받아온 토큰 값
   * @returns type과 value를 통해 세션 스토리지에 각각의 토큰을 저장
   */
  set: (type: "accessToken" | "refreshToken", value: string = "") => {
    if(typeof window !== "undefined"){
      if(type === "accessToken"){
        return sessionStorage.setItem(SESSION_ACCESSTOKEN_KEY, value)
      }else if( type === "refreshToken"){
        return sessionStorage.setItem(SESSION_REFRESHTOKEN_KEY, value)
      }
    }
  },
  /**
   * 세션 스토리지에서 값을 가져오는 메소드
   * @param type "accessToken" | "refreshToken"
   * @returns type에 맞는 토큰을 반환, 로그인하지 않았을 경우 null
   */
  get: (type: "accessToken" | "refreshToken") => {
    if(typeof window !== "undefined"){
    const data = type ==="accessToken" 
    ? sessionStorage.getItem(SESSION_ACCESSTOKEN_KEY) 
    : sessionStorage.getItem(SESSION_REFRESHTOKEN_KEY);
    
      if (data) {
        return data;
      }
      return  null;
    }
  },

  /**
   * 세션 스토리지에서 각각의 토큰을 삭제하는 메소드
   * @returns accessToken, refreshToken 토큰 삭제
   */
  delete: () => {
    return typeof window !== "undefined"
      ? (sessionStorage.removeItem(SESSION_ACCESSTOKEN_KEY)
         ,sessionStorage.removeItem(SESSION_REFRESHTOKEN_KEY))
      : null;
  },
};

export default sessionStorageService;
⚠️ **GitHub.com Fallback** ⚠️