본문 바로가기
프로젝트/AI명종원

next.js로 프론트엔드 개발하기

by HWK 2024. 6. 14.

우리의 프로젝트 인원 구성은 다음과 같았다.

백엔드 메인코더, AI 설계 및 제작, 데이터셋 확보
팀원1 프론트엔드 메인코더, 배포
팀원2 보고서 작성, 발표, 데이터셋 확보
팀원3 백엔드 보조코더, 데이터셋 확보

 

Spring을 주로 사용하던 팀원 1이 프론트 개발을 모두 하기에는 시간상, 기술상의 문제가 있었다.

또한 다른 팀원들은 프론트 개발 경험이 전무하고 새로운 업무 맡기를 부담스러워 했다.

결국 프론트 경험이 조금이라도 있고, 프론트-백엔드 분리 개발 및 배포에 관심이 있었던
내가 프론트 업무를 나눠 가지게 되었다. 내가 프론트 업무를 하게되면서 좋은 점이 있었는데,
나는 우리 프로젝트의 백엔드 플로우를 모두 꿰고있어서 각각의 기능을 프론트에 적용하기가 수월했다.

애초에 '어떻게 하면 프론트엔드에서 최대한 손을 대지 않고 코딩을 할 수 있을까?' 라는 생각을 가지고
백엔드 코드를 작성해 놓았기 때문에 백엔드-프론트 연결에는 더더욱 어려울게 없었다.

이렇게 어느정도 준비가 된 상태로 프론트 개발에 들어가게 되었다. 다음은 내가 수행한 프론트 페이지, 기능 이다.

 

1. next.config.mjs

const nextConfig = {
    async rewrites() {
        return [
            {
                source: "/api/:path*",
                destination: process.env.NEXT_PUBLIC_API_BASE_URL + "/api/:path*",
            },
        ];
    },
};

export default nextConfig;

 

위 코드는 프론트엔드에서 API 요청시 CORS 문제 없이 백엔드 API에게 요청을 할 수 있게 해준다.

또한 NEXT_PUBLIC_API_BASE_URL을 통해 개발과 배포 환경에서 다른 API 엔드포인트를 사용하도록 해준다.

 

 

2. 로그인 페이지

아래와 같이 페이지가 구성되어 있다.

아이디와 비밀번호를 기입하고, 로그인 버튼을 누르면 백엔드에 로그인 API가 요청된다.
비밀번호 또는 아이디에 문제가 있으면 경고 메세지를 보여주고, 맞다면 router.push('/') 가 작동해 홈페이지로 이동한다. 

회원가입을 누르면 회원가입 페이지로 이동한다.

 

3. 스크랩 페이지

내가 저장한 스크랩을 보여주는 페이지로, 마음에 드는 레시피만 따로 볼 수 있게 하기 위해서 설계된 페이지이다.

이렇게 스크랩 페이지에 들어가면 내가 저장한 레시피를 볼 수 있고,

레시피 보기를 누르면 해당 레시피가 나온다. 숨기기 버튼을 누르면 다시 숨길 수 있고, 리뷰작성, 삭제가 가능하다.

react-modal의 Modal을 사용하여 구현했고, 새로운 페이지를 생성하지 않아도 된다는 것이 큰 장점인 것 같다.

상세보기, 삭제, 리뷰작성 모두 recipe_id를 기반으로 작동하고, 백엔드에서 권한을 확인해준다.

 

4. User 페이지

아래는 지금 로그인한 User의 정보를 보여주는 페이지이다.

자신이 몇개의 글을 작성했는지 알 수 있고, 나의 게시글만 따로 모아볼 수도 있다.

나의 게시글 버튼을 누르면 백엔드의 검색 및 페이징 API에 요청을 하며, 게시글 페이지로 연결된다.

사용자 이름 변경, 비밀번호 변경, 회원 탈퇴시 사용자 인증을 위해 해당 user의 비밀번호를 입력해야 한다.

만약 비밀번호가 일치하다면, 탈퇴, 변경 API 요청이 되며 해당 기능이 처리된다.

변경, 탈퇴 기능또한 react-modal의 Modal을 사용해서 구현했다.

 

5. 채팅 페이지 - 채팅 저장

프론트에서 가장 많은 정보를 API 요청에 포함시켜야 했다.

채팅이 끝나고 나면 자동으로 저장하는 기능을 만들어야 했고, 상태 변수에 많은 정보를 저장해야 했다.

React의 useState 기능을 통해 많은 상태 변수들을 관리할 수 있었다.

아래는 채팅을 저장하는데 사용한 코드들이다.

type ChatMessageRequestDto = {
    content: string;
    isUser: number;
    chatType: string;
};

const [chatHistory, setChatHistory] = useState<ChatMessageRequestDto[]>([]);

const addChatMessage = (content: string, isUser: number, chatType: string) => {
    setChatHistory(prev => [...prev, {content, isUser, chatType}]);
};

const handleSaveAllChatMessage = useCallback((
    ingredients: string,
    yesOrNo: string,
    middleIngredients: string,
    menus: string,
    menu: string,
    recipeString: string
) => {
    addChatMessage("환영합니다! 재료를 인식할 사진을 업로드 해주세요!", 0, 'message');
    addChatMessage("이미지", 1, 'image');
    addChatMessage(`인식된 재료는 다음과 같습니다.\n${ingredients}`, 0, 'message');
    addChatMessage("재료를 추가하거나 수정하시겠습니까?", 0, 'message');
    addChatMessage(`${yesOrNo}`, 1, 'message');
    if (yesOrNo === "네") {
        addChatMessage(`수정된 재료: ${middleIngredients}`, 1, 'message');
    }
    addChatMessage(`해당 재료로 만들 수 있는 음식은 다음과 같습니다.\n${menus}\n어떤 재료의 음식의 레시피를 보시겠습니까?`, 0, 'message');
    addChatMessage(`${menu}`, 1, 'menu');
    addChatMessage(`${menu}의 레시피는 다음과 같습니다.`, 0, 'message');
    if (imageUrl !== null && imageUrl !== undefined) {
        addChatMessage(imageUrl, 0, 'imageUrl');
    }
    addChatMessage(`${recipeString}`, 0, 'recipe');
    addChatMessage(`${recipeLink}`, 0,'link');
}, [recipeLink]);

useEffect(() => {
    if (step === 4) {
        handleSaveAllChatMessage(
            firstIngredients,
            yesOrNo,
            middleIngredients,
            menus.join(', '),
            menu,
            recipeString
        );
    }
}, [firstIngredients, handleSaveAllChatMessage, menu, menus, middleIngredients, recipeString, step, yesOrNo]);
  • step이 4가되면 자동적으로 handleSaveAllChatMessage()가 호출되며 채팅 저장이 실행된다
  • handleSaveAllChatMessage()는 일련의 채팅 메시지를 한 번에 추가하는 함수이다. 이 함수는 여러 매개변수를 받아 각 단계별로 적절한 메시지를 chatHistory에 추가하며, useCallback을 사용하여 메모이제이션한다.
  • addChatMessage는 새로운 채팅 메시지를 추가하는 함수이며, chatHistory에 새로운 메시지를 추가한다.

chatType=image에 이미지를 저장하지 않은 이유는, 이미지는 chatting과 연관된 다른 테이블에 저장이 되기 때문이다.

아래와 같은 코드를 사용해서 이미지를 따로 저장했다.

useEffect(() => {
    if (chatHistory.length > 0 && step === 5) {
        if (!file) {
            alert('파일을 선택해주세요.');
            return;
        }

        const formData = new FormData();
        formData.append('file', file);
        formData.append('chatId', chatId);

        axios.post('/api/photo-save', formData, {
            headers: {
                'Content-Type': 'multipart/form-data'
            }
        })
            .then(response => {
                console.log('Image saved successfully:', response.data);
            })
            .catch(error => {
                console.error('Error saving image:', error);
            });
    }
}, [chatHistory, step, file, chatId]);

step 5에서 저장된 이미지 file 형식과 저장되는 채팅방 chatId가 같이 담겨서 API요청이 된다.

같은 step에서 이뤄지지 않은 이유는 두가지이다.

하나는 chat_room이 형성되어야 chatId가 생겨 chat_room을 먼저 생성해 둬야하기 때문이다.

다른 하나는 next.js 숙련도 부족 + 시간 부족이다. 같은 step에서 왜인지 비동기 설정이 잘 작동하지 않았다.

캡스톤 마감일까지 정말 쉬지않고 달려야 어느정도 완성된 프로젝트를 보여줄 수 있었고, 그렇기에 시간이 부족했다.

이 점이 정말 아쉬웠고, 각 분야에서 전문성을 가지는게 정말 중요하다는 것을 느꼈다.

 

6. 채팅 상세보기

위에서 만든 저장 기능으로 저장된 정보는 아래처럼 채팅 상세보기를 통해 보여지게 된다.

chat_id를 통해 채팅방을 불러오며, 이미지도 보여준다.
이후 이전의 채팅방과 동일한 형식으로 채팅 기록을 보여주는 형식이다.

저장된 채팅방에서도 당연히 게시판 연결, 타 사이트 연결, 레시피 저장(스크랩)기능이 작동된다.

 

7. 게시판 - 스크랩 연결

아래처럼 게시글을 작성할 때 스크랩한 레시피를 불러오는 기능이다.

어느정도 자유도를 가지고 자신이 사용한 레시피를 공유 할 수 있고,

스크랩 페이지에서도 리뷰 작성 버튼을 통해 해당 기능을 수행할 수 있다.

생각보다 쉬운 기능이였고, 아래와 같은 코드를 사용했다.

<Textarea className="w-full h-full resize-none" style={{minHeight: '200px', maxHeight: '80vh'}} value={content} onChange={(e)=>(setContent(e.target.value))}/>

 

프론트엔드 개발을 하며 느낀점

이렇게 프론트엔드 까지 모두 구현이 완료되었다.

다들 next.js를 처음 사용하다보니, 프론트 구현에 정말 많은 시간을 투자한 것 같다.

프론트에 전문적인 팀원이 있었으면 더 좋은 결과가 있었을 것이다.

실질적으로 2명이서 코딩을 했고, 학기중이라 시간이 적었다는 점, 즉 시간적 인원적 제약에 시달렸던 프로젝트였다.
대신 next.js에 대한 이해도와 경험을 쌓을 수 있었고, 새로운 도전을 했다는 점에서는 만족스러운 결과였다.

 

프로젝트 동영상: 24학년도 명지대학교 캡스톤 - AI 명종원 (youtube.com)

 

다음 글에서는 이미지 저장, 크롤링 코드, 오류 해결을 소개할 것이다.