패스트캠퍼스 프론트엔드/복습

야놀자X패스트캠퍼스 토이프로젝트2 복습

JellyApple 2023. 11. 17. 00:34

이번에 두 번째 토이프로젝트를 11/6~11/16일까지 진행했는데 짧지만 많은 걸 배웠던 시간 같았다. 바로 내일부터 백엔드와 같이 프로젝트를 하기 때문에 오늘 틈틈이 복습 할 것을 적어두려고 한다. 복습 내용은 남들이 보기엔 사소할수도 있지만 내가 이번에 프로젝트 하면서 어려움을 느꼈던 부분 위주로 적으려고 한다. 주제는 채팅 서비스였기 때문에 우리 조는 카카오톡을 비슷하게 따라하고자 했다.

 

1. 동적 라우팅(Dynamic Routing) 

이번 프로젝트에서 NextJs를 사용했는데 사실 아쉬운 점 중 하나였다. SSR과 SSG , CSR의 차이점을 충분히 이해하지 못해 이를 활용하지 못했던 점이 아쉬웠다. 스택 선정이 매우 중요함을 느꼈고 나중에 NextJs 관련해선 길게 포스팅 해볼 예정이다. 일단 이번에 내가 맡은 내 채팅/ 모든 채팅 조회였고 내 채팅을 보여주려면 userId 같은 동적 라우팅이 필요했다. NextJs에서 우리는 App routing을 사용했는데 [userId]/page.tsx 형식으로 동적 라우팅 해주었다. 리액트보다 조금 더 간편했던 것 같다. 리액트에서는 아래와 같이 Route에서 :id 이런 형식으로 path parameter를 보여준다.

<Router>
	<Routes>
		<Route path='/myproduct/:id' element={<MyProduct />} />
	</Routes>
</Router>

function MyProduct() {
	const params = useParams();
	const myproductId = params.id;
}

 

이번 프로젝트에서는 동적 라우팅으로 시작했다가 내 채팅만 보내주는 API가 있었기 때문에 채팅 페이지 컴포넌트에 userType을 주어 my 일 땐 getMyChats / all 일 땐 getAllChats로 다른 API 호출 함수를 받아 data를 뿌려줬다. 그래서 폴더 구조는 mychat/page.tsx였다.

 

2. React-query
이번 프로젝트 시작하기 앞서 사이드 프로젝트에서 간단하게 react-query를 사용했고 강의도 들었지만 실제로 써본 적은 처음이였다. 아직 완전히 알진 못하고 많이 부족하지만 사용해본 것을 몇 가지 적어보려고 한다
1) React-Query란? 

fetching, caching, 서버 데이터와의 동기화를 지원해주는 라이브러리

 

위 말처럼 비동기 상태 처리를 쉽게 도와주는 라이브러리라고 할 수 있다. 즉, 클라이언트에서는 리덕스 , 리코일 등을 사용해서 상태 관리를 하고 서버 데이터는 React-query로 관리해줘서 이 둘을 완전히 분리해주는 역할이다. 

 

2) 장점

  • 캐싱 : React-query는 캐싱을 통해 데이터를 저장해 반복되는 비동기 처리를 막아준다. 
    최신의 데이터를 가져오는 경우는 새 컴포넌트가 마운트 될 때, 브라우저에 포커스가 발생했을 때 등 다양하다. 이를 위해 React-query에서 제공하는 옵션이 몇 가지 있다. 참고로 내가 이번 프로젝트에 사용한 것은 서버에 지속적으로 내 채팅과 모든 채팅 정보를 요구해야하기 때문에 refetchInterval를 사용했다.
    refetchOnWindowFocus, //default: true
    refetchOnMount, //default: true
    refetchOnReconnect, //default: true
    staleTime, //default: 0
    cacheTime, //default: 5분 (60 * 5 * 1000)​
    refetchInterval: 1000
  • 클라이언트와 서버 데이터의 분리  : React-Query에서는 서버 데이터 즉, 비동기 호출을 통해 받아오는 데이터와 클라이언트 단에서 발생하는 데이터를 엄격히 분리해주는 역할을 지원한다. onSuccess , onError 등이 있었지만 tanstack-query로 업데이트 된 뒤 사라지고 select 로 변경되었다. 

3) 사용법 
이번 프로젝트에서는 GET만 썼지만 다른 것들도 가능하다. GET은 useQuery를 쓰고 나머지는 useMutation을 사용한다. 이번에 사용한 코드는 아래와 같이 사용했다. useMutation 부분은 따로 참고해서 가져왔다. 반환값은 useQuery와 동일하다.

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';


export default function App() {
  const queryClient = new QueryClient();
  return (
     <QueryClientProvider client={queryClient}>
      <RecoilRoot>
      <Body>
        <ReactQueryDevtools />
      </Body>
    </RecoilRoot>
    </QueryClientProvider>
  )
}
 const { data, isLoading } = useQuery<Chat[]>({
    queryKey: ['getChatsKey'],
    queryFn: userType === 'my' ? getMyChats : getAllChats,
    refetchOnWindowFocus: false,
    refetchInterval: 1000,
  });

 

function App() {
  const mutation = useMutation({
    mutationFn: (newTodo) => {
      return axios.post('/todos', newTodo)
    },
  })

 

또한 isLoading 옵션으로 로딩중 구현이 가능하며 , Error 등 다양한 반환 값들이 있다.

if(isloading){
  return <Loading />
}

 

3. props 및 query로 데이터 넘겨주는 방식

이번에 느낀 점이 typescript에서 props를 넘길 때 타입에 대해 많이 생각을 해야 함을 느꼈다. 그리고 props를 넘길 때 로직 같은 것도 아직 머릿속에서 정리가 잘 안되고 있던 점을 깨달았다. 또한 프로젝트 중간 채팅방을 구현하시는 팀원분께서 query로 데이터를 넘겨달라고 하셔서 이 부분을 useRouter()를 활용해 구현하였다. 
1) props : 컴포넌트 끼리 데이터를 전달하고 싶을 때 사용한다. 즉, 상위 컴포넌트에서 하위 컴포넌트로 데이터를 전달해주는 객체다. 이 props를 이용해서 하위 컴포넌트로 데이터를 보낸다. 

// 채팅방 아이템 관련 코드
const MyChatItem = ({ name, latestMessage, users, onClick, isPrivate }: Chat) => {
  const userId = typeof window !== 'undefined' ? localStorage.getItem('userId') : null;
  // 1대1 채팅일 때 상대방 이름과 이미지 가져오는 함수
  const getPictureName = () => {
    if (users) {
      const otherUser = users.find((user) => user.id !== userId);
      return otherUser;
    }
  };

  const otherUser = getPictureName();
  const otherUserName = otherUser ? otherUser.username : '';
  const otherPicture = otherUser ? otherUser.picture : '';

  const chatsPicture =
    isPrivate && users && users.length > 2 // private 한 그룹 채팅인 경우
      ? '/assets/groupPrivate.svg'
      : isPrivate && users && users.length === 2 // private이면서 1대1 채팅인 경우
      ? otherPicture
      : !isPrivate && users && users.length > 2 // private 아니면서 그룹채팅인 경우
      ? '/assets/groupUsers.svg'
      : !isPrivate && users && users.length === 2
      ? otherPicture // private 하지 않으면서 1대1 인 경우
      : '/assets/noUser.svg';
  const usersNumber = users && users.length > 0 ? users.length : '';
  const chatsName =
    users && users.length === 1 ? '상대방이 채팅방을 나갔습니다.' : users && users.length === 2 ? otherUserName : name;
  return (
    <ChatBox onClick={onClick}>
      <ChatImage src={chatsPicture} alt="chats picutre" />
      <ChatInfo>
        <ChatPart>
          <ChatName>
            {chatsName} <span>{usersNumber}</span>
          </ChatName>
        </ChatPart>
        <LateMessage>
          {latestMessage
            ? latestMessage.text.split(':')[0] == 'notice09'
              ? latestMessage.text.split(':')[1]
              : eclipsText(latestMessage.text, 20)
            : ''}{' '}
        </LateMessage>
      </ChatInfo>
      <MessageCount>
        <ReceiveTime>{latestMessage ? formatCreatedAt(latestMessage.createdAt) : ''}</ReceiveTime>
        <TypeCheckBox>{isPrivate ? <FaLock size="20" className="lockIcon" /> : ''}</TypeCheckBox>
      </MessageCount>
    </ChatBox>
  );
};
export default MyChatItem;


이번 프로젝트 때 작성한 코드인데 지금 보니 많이 부족해보이지만 지금은 props를 넘겨준 방식에 대해서만 보고자 한다.
나는 처음에 const MyChatItem = ({name, latesetMessage , users , onClick } : Chat) => {} 으로 저 props를 넘겨주었다 당연히 타입스크립트이기 때문에 Chat 이라는 타입을 주었다. 이 Chat은 API에서 응답해주는 데이터들의 타입을 정해둔 것이다.  그리고 users는 Chat 안에 User 타입으로 들어가 있고 이 User 타입에는 id , username , name 등 다양하게 들어가 있다. 

2) Query: 채팅방을 들어갈 때 쿼리로 보내주는 코드를 짰다. useRouter를 이용해서 해주었다. (결국 직접 API로 가져오고 상태로 관리하는게 좋다고 생각해서 뺀 코드긴 하다)

const enterChatRoom = (chat: Chat) => {
        if (chat.id && chat.users) {
            const users = chat.users
                .map((user) => `[name:${user.username}, id:${user.id}, picture:${user.picture}]`)
                .join(',');
            const latestMessageQuery = JSON.stringify(chat.latestMessage);

            router.push(
                `/chating/${chat.id}?name=${chat.name}&isPrivate=${
                    chat.isPrivate
                }&users=${users}&latestMessage=${encodeURIComponent(latestMessageQuery)}`,
            );
        }
    };
// Chat 페이지 관련된 interface 

export interface Chat {
  id?: string;
  name: string;
  isPrivate?: boolean;
  users?: User[];
  latestMessage?: Message | null;
  onClick?: () => void;
  updatedAt?: Date;
}

export interface Message {
  id: string;
  text: string;
  userId: string;

  createdAt: Date;
}

export interface User {
  id: string | null;
  password: string;
  name: string;
  picture: string;
  chats: string[]; // chat id만 속합니다.
  username: string;
}

이 안에 들어가 있는 것들을 이용해서 chatsPicture, chatsName , userName 등 다양한 변수를 만들어서 이를 또 전달해주었다. 그리고 MyChatItem이 리액트 쿼리를 통해 가져온 데이터들만큼 동적으로 들어가질 것이기 때문에 이를 props로 전달해주었다.

 // 채팅방 조회 페이지
 const MyChats = ({ userType }: { userType: string }) => {
    sortTime(filterChats).map((chat) => (
                <MyChatItem
                  key={chat.id}
                  name={chat.name}
                  latestMessage={chat.latestMessage}
                  users={chat.users}
                  onClick={() => enterChatRoom(chat)}
                  isPrivate={chat.isPrivate}
                />
              ))
 }

API를 통해 받아온 데이터의 타입이 res.chats이기 때문에 이를 map으로 풀어서 가져와준다. 그래서 아까 props로 설정해준 name , latesetMessage , users, onClick , isPrivate 등을 그대로 가져왔고 이를 현재 받아온 chat.name , chat.latesetMessage 등으로 가져와주었다. 또한 Key는 map 함수 등을 사용할 때 즉 , 리액트에서 컴포넌트 배열을 렌더링 할 때 가져오는 고유 값이라 chat.id로 넣어주었다. 이런 식으로 props를 통해 데이터를 전달할 수 있다. 또한 그냥 가져오는 것 뿐만 아닌 타입을 잘 지켜줘야 하며 또한 그 타입에서 값이 무조건 있어야 하는 지 유무를 판단해주는 것도 중요했다. undefined 일 수도 있다라는 에러를 가장 많이 본 것 같다..ㅠ

4. 검색 기능

이번에 검색 기능을 구현했는데 이젠 쉽게 느끼겠다 생각했는데 할수록 은근 어려웠던 점이 많았다. 그래서 이를 정리하고자 한다. 이 과정에서 debounce를 사용했는데 lodash에 대해 좀 더 알아보고자 한다.

1) 검색 기능 로직
이번 프로젝트에서 검색 기능을 사용한 로직 및 컴포넌트들은 다음과 같다.
- <SearchMyChat> : 이 컴포넌트에서 검색창 및 검색 기능 로직을 짰고 위 ChatPage에 import 해주었다. 
- <ChatPage> : 위 <SearchMyChat> 컴포넌트를 가져오는 채팅방 조회 페이지다. 
- 로직 : input 값을 받아서 저장 => 그 후 useQuery로 가져온 data(chats)와 비교해서 filter 처리 해주는 filterChats를 만들어주고 state에 저장해줌 => debounce적용

2) Debounce란?
lodash 라이브러리에서 지원하는 것 중 하나로 과도한 요청 시 성능 저하를 막기 위해 사용한다. 수 많은 함수 호출 중 마지막 함수만을 호출한다. 그래서 검색 기능 시 한글자 입력할때마다 함수가 호출이 되지 않고 주어진 시간 후에 함수가 호출되게 한다. 이와 비슷한 Throttle이라는 방식도 있지만 이것은 마지막 함수가 호출된 후 일정 시간이 지난 후에는 그 함수를 다시는 호출하지 않는다는 점에서 차이가 있다.

// SearchMyChat 로직
const SearchMyChat = ({ userType }: { userType: string }) => {
  const [input, setInput] = useRecoilState(searchInputState);
  const [filterChats, setFilteredChats] = useRecoilState(searchChatsState) 
  const onInputChange = debounce((e: React.ChangeEvent<HTMLInputElement>) => {
    setInput(e.target.value);
    if (chats) {
      const filteringChats = chats.filter((chat) => chat.name.includes(e.target.value));
      setFilteredChats(filteringChats);
    }
  }, 300);
  }

원래는 setInput을 리코일로 해줄 생각은 없었는데 검색어 입력 중에 오타가 있거나 검색 결과에 없을 땐 검색 결과가 없습니다를 표시해주고 싶어서 이를 기존 chats 값과 비교해줘야 하기 때문에 전역 상태로 만들어서 ChatPage에서 사용해주었다. e.target.value를 가져오고 이를 Input에 저장해주고  비교해주었다. 그 후 debounce를 적용해주었다.

// ChatPage에서 filterInputValue를 활용해서 검색 중간에 채팅방 없음 결과 보여주는 코드
<ChatList>
        {isLoading && <Loading />}
        {userId && data ? (
          filterInputValue ? (
            filterChats.length > 0 ? (
              sortTime(filterChats).map((chat) => (
                <MyChatItem
                  key={chat.id}
                  name={chat.name}
                  latestMessage={chat.latestMessage}
                  users={chat.users}
                  onClick={() => enterChatRoom(chat)}
                  isPrivate={chat.isPrivate}
                />
              ))
            ) : (
              <NoUserWrap>
                <NoUserText>해당 채팅방이 존재하지 않습니다.</NoUserText>
              </NoUserWrap>
            )
          ) : (
            sortTime(data).map((chat) => (
              <MyChatItem
                key={chat.id}
                name={chat.name}
                latestMessage={chat.latestMessage}
                users={chat.users}
                onClick={() => enterChatRoom(chat)}
                isPrivate={chat.isPrivate}
              />
            ))
          )
        ) : (
          <NoUserWrap>
            <NoUserText>내 채팅방이 존재하지 않습니다.</NoUserText>
          </NoUserWrap>
        )}
      </ChatList>


 

5. CSS 디자인 

이번에 가장 부족하다고 느꼈던 부분이다. 전체적으로 팀원들에 비해 디자인 능력이 부족함을 깨달았고 CSS로 보였다 특히 채팅창을 만들 때 세세한 디테일이 너무 아쉬웠다. 멘토님께 여쭤보니 웹 디자인 레이아웃 및 색채학을 공부하면 좋다 하셔서 공부할 것이다. 특히 모달창 부분에서 뒷 배경을 블러 처리하는 것에서 뒷 배경이 이상하게 잡히는 부분 등 CSS에 대해 더 공부를 해야 함을 느꼈다.
1) Styled-component 작성 방식
나는 Wrapper로 감싸주고 ChatContainer 처럼 한 번 더 감싸줬는데 이렇게 되니 이 안에 들어가는 컴포넌트들의 종속성이 모호해지고 괜히 쓸데 없이 한 번 더 감싸서 코드의 가독성도 어려운 것 같다. Wrapper로 감싼 뒤 그냥 바로 ChatList나 SearchMyChat 컴포넌트들을 넣는게 더 좋을 것 같다.

2) margin , padding 
1) 번 문제에서 파생된 문제같다. 저렇게 한 번 더 감싸주니 margin과 padding의 범위가 모호해져 반응형 작업이나 모달창 같이 전체 배경을 다루는 문제 때 삐뚤어지거나 원하는대로 작동을 안하는 것 같다. 이 부분을 좀 더 신경써야 할 것 같다.

3) 디자인 감각
디자인 감각이 많이 부족함을 느꼈다 특히 다른 조 디자이너 출신 분의 디자인 감각을 보고 정말 많은 생각을 하게 됐다.. 멘토님께 여쭤보니 색채학과 웹 디자인 레이아웃을 공부해보라고 추천해주셨다! 
웹 디자인 레이아웃 유형 종류 : https://uxdlab.tistory.com/6

  1. 고정 레이아웃 (Fixed Layout):
    • 요소들의 크기와 위치를 고정된 값으로 설정하여 브라우저 창 크기에 관계 없이 일정한 레이아웃을 유지하는 방식입니다. 일반적으로 고정 픽셀 값이나 백분율로 크기를 지정합니다.
    • https://www.apple.com/
  2. 유동 레이아웃 (Liquid or Fluid Layout):
    • 백분율로 너비를 지정하여 화면 크기에 따라 레이아웃이 조절되는 방식입니다. 화면 크기가 변할 때 요소들이 자동으로 크기를 조정하여 레이아웃이 유동적으로 변합니다.
    • https://edition.cnn.com/
  3. 그리드 레이아웃 (Grid Layout):
    • 그리드 시스템을 사용하여 웹 페이지를 나누고 배치하는 방식입니다. 각 요소들이 그리드 셀에 위치하며, 그리드 라인을 기준으로 정렬됩니다. Flexbox나 CSS Grid를 사용하여 구현할 수 있습니다.
    • https://developer.mozilla.org/ko/
  4. 플렉스 박스 레이아웃 (Flexbox Layout):
    • 요소들을 부모 컨테이너 내에서 유연하게 정렬하고 배치하는 데 사용되는 레이아웃입니다. 주로 한 축(행 또는 열)을 기준으로 요소들을 배치합니다.
  5. 멀티컬럼 레이아웃 (Multi-column Layout):
    • 여러 컬럼을 가진 레이아웃을 생성하는 방식으로, 텍스트를 여러 열로 나누어 표시할 때 유용합니다. CSS의 column-count와 column-gap 등의 속성을 사용하여 구현할 수 있습니다.
    • https://www.nytimes.com/
  6. 절대 위치 레이아웃 (Absolute Positioning):
    • 요소를 부모 요소나 뷰포트를 기준으로 정확한 좌표에 배치하는 방식입니다. 일반적으로 부모 요소에 대해 position: relative;를 설정하고, 자식 요소에 position: absolute;를 사용합니다.
    • https://www.w3schools.com/
  7. 스티키 레이아웃 (Sticky Layout):
    • 스크롤이 특정 지점에 도달하면 해당 요소가 화면에 고정되는 레이아웃입니다. 일반적으로 헤더나 사이드바 등에 적용됩니다.
    • https://medium.com/

6. 모달창
모달창 만드는 건 사실 그렇게 어렵진 않았는데 이 부분을 따로 적은 이유는 모달창 나왔을 때 배경을 투명하게 처리하는 부분에서 종속되는 부분이 이상한 부분으로 되어서 투명하게 처리되지 않았다. 5번과 관련되었던 것 같은데 Wrapper 다음으로 하나를 더 감싸주고 이를 이상하게 값을 주어서 그랬던 것 같다. 이와 같은 실수 범하지 않도록 적고자 했다. 또한 모달창 로직 중 새 유저면 모달창이 뜨고 입장하기를 누르면 채팅방 참여 API를 실행하고 그 해당 채팅방으로 들어가게 하는 로직의 연결이 조금 어려웠다.

// 채팅방 입장 코드
const enterChatRoom = (chat: Chat) => {
    if (chat.id && chat.users) {
      if (chat.users.every((user) => user.id !== userId)) {
        setSelectedChat(chat);
        setChatModalOpen(true);
        console.log('새로 입장 성공');
      } else {
        router.push(`/chatting/${chat.id}`);
        console.log('기존 유저 들어가기 성공');
      }
    }
  };

  // 입장하기 버튼 눌렀을 때 채팅에 참여시키는 함수
  const onEnterHandler = async () => {
    if (selectedChat && selectedChat.id) {
      partChats(selectedChat.id);
      setChatModalOpen(false);
}
}

이 chat 정보를 selectedChat으로 state를 통해 관리해주었고 채팅방을 눌렀을 때 모달을 열고 이 정보를 selectedChat에 넣는 로직을 구현했다. 또한 선택된 채팅방이 있으면 채팅방에 참여할 수 있는 API를 호출하는 partChats 함수를 구현하고 모달을 닫아주게 하였다. 

const EnterChatRoomModal = ({ isOpen, onEnterClick, onCancelClick, selectedChat }: EnterChatRoomModalProps) => {
  const handleEnterClick = () => {
    onEnterClick();
  };
  const handleCancelClick = () => {
    onCancelClick();
  };
  return (
    <Container style={{ display: isOpen ? 'flex' : 'none' }}>
      <Overlay onClick={handleCancelClick} />
      <ModalContainer>
        <ModalMainText>
          <span>{textModalData.enter}</span>
        </ModalMainText>
        <ModalBtnContainer>
          <EnterBtn onClick={handleEnterClick}>{textModalData.enterBtn}</EnterBtn>
          <CancelBtn onClick={handleCancelClick}>{textModalData.cancelBtn}</CancelBtn>
        </ModalBtnContainer>
      </ModalContainer>
    </Container>
  );
};


열고 닫는 isOpen 과 입장하기를 눌렀을 때 실행할 함수를 넘겨줄 onEnterClick , 취소 눌렀을 때 onCancleClick , 그리고 내가 선택한 채팅방의 정보를 넘겨줄 selectedChat을 props로 넘겨주었다. 여기서 헷갈렸던 부분은 handleEnterClick으로 onEnterClick()은 다른 페이지에서 enterChatRoom 혹은 이와 비슷한 함수가 될 것이니 함수를 만들어줘서 이 안의 함수를 실행시키는 형식으로 props를 넘겨주었다. 

// ChatPage.tsx  
<EnterChatRoomModal
        isOpen={chatModalOpen}
        onEnterClick={onEnterHandler}
        onCancelClick={onModalHandler}
        selectedChat={selectedChat}
      />

그 후 이 모달을 사용하는 ChatPage에서 props를 위의 만들었던 함수로 넘겨주었다.

 

7. 삼항연산자 
삼항연산자는 JSX의 형태에서 if..else문을 못쓰기 때문에 이를 대체하기 위해 사용하는데 다들 아시다시피 ? 와 :로 구분한다. 조건 ? 참 : 거짓 형태로 반환한다 if else문이 여러개 있는 경우가 있는데 이를 구현할 때도 삼항연산자를 사용하는데 조금 헷갈리는 것 같아 이 부분을 기억하기 위해 적어두었다.

function getFee(isMember) {
  return isMember ? '$2.00' : '$10.00';
}
// 삼항연산자 사용 시
function example(…) {
    return condition1 ? value1
         : condition2 ? value2
         : condition3 ? value3
         : value4;
}
// 삼항연산자 사용하지 않고 if _ else문 사용 시
function example(…) {
    if (condition1) { return value1; }
    else if (condition2) { return value2; }
    else if (condition3) { return value3; }
    else { return value4; }
}


8. 프로젝트 후기
이렇게 살펴보니 정말 많이 부족함을 느끼고 코드를 보면 부끄러움을 느끼기도 했다. 그럼에도 올해 초에는 html , css도 제대로 못했던 내가 이 정도로 발전했구나를 느꼈고 특히 앞선 프로젝트들보다 이 프로젝트에서 처음으로 스스로가 발전했구나를 느꼈다. 아직 남들이 보기엔 부족하고 솔직히 말하면 구글링과 GPT 없이는 이렇게 구현하지 못했을 것이다..그래서 이 프로젝트를 통해 느낀 점은 스스로 발전함을 느꼈지만 아직 부족함을 깨닫고 안주하지말고 달려야 함을 배웠다. 디자인 감각도 그렇고 코드의 가독성 및 상태 관리나 props를 넘겨주는 방식 등 꽤나 많은 공부를 해야함을 느꼈다. 다가오는 미니 프로젝트에서는 정말 모를 때 제외하고는 GPT를 사용하지 않고 코드를 작성하는게 내 목표다.. 퀄리티는 많이 떨어질 지 몰라도 스스로 뿌듯함과 큰 실력 향상을 가져올 것이라 생각한다..!