지난 시간의 연속이다.

하.. npm 버전 관리도 안해서 분명 설치를 했는 데 모듈을 찾지도 못한다.

구글링을 해보니 웹팩에러라고 한다.

해결법을 작성한다.

* next.config.js 수정

 eslint: {
    dirs: ['comp', 'contexts', 'lib', 'pages', 'styles'],
    ignoreDuringBuilds: true,
  },
  webpack(config, options) {
    if (!options.isServer) {
      config.resolve.fallback.fs = false
             config.resolve.fallback.dns = false
             config.resolve.fallback.net = false
             config.resolve.fallback.tls = false
             config.resolve.fallback["pg-native"] = false
      // config.node = {
      //     dns: 'empty',
      //     net: 'empty',
      //     fs: 'empty',
      //     tls: 'empty',
      //     "pg-native": 'empty'
      // };
  }

    return config
  }

이건 환경에 따라 다르니 통상적으로

Next.js 에서는 next.config.js 에서 처리해준다고 생각하자 !



아르바이트로 외주 프로젝트를 하다가,

난감한 상황을 만났다.

이전 개발자들의 legacy 한 코드가 중복이 되어, 용량이 매우 많아 빌드가 안되는 것.

알아보니 프로젝트 규모가 큰 것도 아니고, 그냥 이전 개발자들이 프로젝트에 대한 파악이

부족해 여기저기 갖다쓰다보니 중복된 코드와 파일로 용량이 많아진 것이다.

그러나 하루안에 배포해야 하고 고치기엔 빡센 상황이니 응급처치 방법을 기재한다.

(빌드전)
export NODE_OPTIONS=--max_old_space_size=4096

빌드하기전 노드에 이렇게 옵션을

(용량은 작을수록 좋으며, 점진적으로 늘려나가는 것이 좋다. 저것은 4기가 기준)

npm run build

끝~~

지난번에 useRef 를 활용, 버튼을 클릭하여 특정 위치로 이동하는 이벤트를 만들어보았다.

이번 포스팅은 그와 함께 쓸 스크롤을 방향을 인식하여 위,아래로 한단계씩 이동하는

스크롤을 만들어볼 것이다.

일단 스크롤 이벤트를 정의할 때, 크게 세가지로 구분해보자.

1.scroll

2.mouse wheel

3.touch

이렇게 3가지가 있는 데, 필자의 대상은 핸드폰에서 터치로 스크롤 이동하기 때문에 3번 터치 이벤트를 세부적으로 알아보면,

1. touch start -> 터치를 시작할 때 이벤트 발생

2. touch move -> 터치 진행중일 때 이벤트 발생, 터치중일때는 다수의 이벤트가 발생한다.

3. touch end -> 터치가 끝났을 때 이벤트 발생

4. touch cancle -> 터치를 취소할 때 이벤트 발생, 가량 터치중일 때 다른 웹페이지로 이동한다거나 접속장애로 페이지가 다운될 때에도 발생될 수 있다.

통상적으로는 터치가 시작할때의 좌표값을 인지하고, 터치가 종료되었을 때의 좌표값을 인지하여

이벤트를 발생시키므로, 1 & 3번을 활용하여 이벤트를 작성해보자.

아래 포스팅을 확인하면, useRef 의 설정은 동일하다.

위 포스팅의 코드를 유지한 채 아래 코드를 추가한다.

* 필요한 부분만 넣었으므로, 상관없는 코드는 배제하고 작성하겠다.

1. JS 부분

 
 let start_y : any = null; // 시작 Y축 좌표
  let end_y : any = null; // 시작 Y축 좌표
  let index = 0; // 페이지 번호

useEffect(()=>{
    const script = document.createElement('script');
    document.getElementById('app')?.scrollTo(0, 0); // 첫 렌더시 스크롤이 최상단 고정된다

  window.addEventListener("touchstart", initTouch); // 터치 스타트 이벤트를 initTouch에 부여
  window.addEventListener("touchend", (e) => { // 터치 엔드 이벤트를 swipeEnd에 부여
    window.addEventListener("touchend", swipeEnd)
  }, { passive : true});
  }, [])

const initTouch = (e : any) => { 
    e.preventDefault();
    start_y = e.touches[0].pageY; //최초 스타트 시 Y축 좌표를 start_y 변수에 저장한다.
  };

 const swipeEnd = (e:any) => {
    e.preventDefault();
    end_y = e.changedTouches[0].pageY; // 터치가 끝났을 때 Y축 좌표를 end_y 변수에 저장한다.
    if(start_y > end_y){ // 위에서 아래로 이동
      if(index === 0){ 
        element.main.current?.scrollIntoView({ behavior: 'smooth',  block: 'start'});
      }else if(index === 1){
        element.intro.current?.scrollIntoView({ behavior: 'smooth',block: 'start' });
      }else if(index === 2){
        element.customer.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
      }else if(index === 3){
        element.worker.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
      }else if(index === 4){
        element.labor.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
      }else if(index === 5){
        element.schedule.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
      }else if(index === 6){
        element.wage.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
      }
      if(index <= 6){
        index += 1;
      }
    }else if(start_y < end_y){  // 아래에서 위로 이동
      if(index === 7){
        element.wage.current?.scrollIntoView({ behavior: 'auto', block: 'start' });
      }else if(index === 6){
        element.schedule.current?.scrollIntoView({ behavior: 'auto', block: 'start' });
      }else if(index === 5){
        element.labor.current?.scrollIntoView({ behavior: 'auto', block: 'start' });
      }else if(index === 4){
        element.worker.current?.scrollIntoView({ behavior: 'auto', block: 'start' });
      }else if(index === 3){
        element.customer.current?.scrollIntoView({ behavior: 'auto', block: 'start' });
      }else if(index === 2){
        element.intro.current?.scrollIntoView({ behavior: 'auto', block: 'start' });
      }else if(index === 1){
        element.main.current?.scrollIntoView({ behavior: 'auto', block: 'start' });
      }
      if(index >= 0){
        index -= 1;
      }
    }
}

2. HTML 부분

<div id='app' style={{overflowY : 'scroll' }} >
          <Section position={`relative`} ref={element.main}>. // 0페이지
            <SectionInner>
              <Main />
            </SectionInner>
          </Section>
         <Section position={`relative`} ref={element.intro}>  // 1페이지
            <SectionInner>
              <Intro />
            </SectionInner>
          </Section>
         <Section position={`relative`} ref={element.customer}>  // 2페이지
            <SectionInner>
              <CustomerStepper />
            </SectionInner>
          </Section>

          <Section position={`relative`} ref={element.worker}> // 3페이지
            <SectionInner>
              <WorkerStepper />
            </SectionInner>
          </Section>

          <Section position={`relative`} ref={element.labor}> // 4페이지
            <SectionInner>
              <LaborStepper />
            </SectionInner>
          </Section>

          <Section position={`relative`} ref={element.schedule}> // 5페이지
            <SectionInner>
              <ScheduleStepper />
            </SectionInner>
          </Section>
          <Section position={`relative`} ref={element.wage}> //6페이지
            <SectionInner>
              <WageStepper />
            </SectionInner>
          </Section>
</div>

 

브라우저 호환성 때문인지, 크롬 개발자모드에서 핸드폰모드로 할시,

터치 스크롤을 할때 scrollIntoView({ behavior: 'auto', block: 'start' });

auto모드는 잘 동작하지만,

scrollIntoView({ behavior: 'smooth', block: 'start' });

smooth 모드는 동작하지 않았다. 왜 이러지 하면서 실제 핸드폰으로 테스트해보니 잘 동작한다.

다양한 기기에서 테스트해보길 권장한다.

 

Next.js 를 모바일 웹용으로 사용하던 중 이미지와 css 를 늦게 불러오는 이슈를 발견했다.

아마도 SSR 에서 Document를 불러올 때 HTML을 우선 불러오고 나중에 css 를 불러오는 듯하다.

 

 

CSS가 늦게 불러와져서 화면이 깨진다.

구글링을 해보니 몇 가지 옵션을 추가하면 된다고 한다.

1.Next.config.js 옵션 추가

 
 compiler : {
    styledComponents : true,
  },

 

2._document.tsx 추가 (_app.tsx 와 별개로 페이지를 만들어야 함)

 

import Document, { DocumentContext } from "next/document";
import { ServerStyleSheet } from "styled-components";

export default class MyDocument extends Document {
  static async getInitialProps(ctx: DocumentContext) {
    const sheet = new ServerStyleSheet();
    const originalRenderPage = ctx.renderPage;

    try {
      ctx.renderPage = () =>
        originalRenderPage({
          enhanceApp: (App) => (props) =>
            sheet.collectStyles(<App {...props} />),
        });

      const initialProps = await Document.getInitialProps(ctx);
      return {
        ...initialProps,
        styles: [initialProps.styles, sheet.getStyleElement()],
      };
    } finally {
      sheet.seal();
    }
  }
}

적용 후 화면.. 매우 잘 된다.

 

 

 

 

생각보다 쉽다

리액트에서 특정 메뉴를 클릭할 경우

특정 위치로 이동하는 것을 해보자.

* 시나리오

1) 메뉴 버튼 클릭

2) 메뉴 리스트에서 특정 버튼 클릭

3) 이동

import { useState, useRef} from 'react';
import {
  MenuButton,
  Section,
  SectionInner,
  NavItem,
  Navigation,
  TitleWrapper,
} from './promotionComponents';

const Promotion: NextPage<any> = (props: any) => {

  const [menuOpen , setMenuOpen] = useState(false); // 메뉴 팝업
   
    const handleClick = () => {
    setMenuOpen(true);
    };
    const handleClose = () => {
    setMenuOpen(false);
    };
    const element = {
      customer : useRef<HTMLDivElement>(null), 
      worker : useRef<HTMLDivElement>(null),
      labor : useRef<HTMLDivElement>(null),
      schedule : useRef<HTMLDivElement>(null),
      wage : useRef<HTMLDivElement>(null),
      insurance : useRef<HTMLDivElement>(null),
    };
    const onMoveToElement = (e:any) => {
      const targetId = e.target.id;
      if(targetId === 'customer'){ // 타겟이 거래처면 
        element.customer.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); //거래처로 이동하라
        setMenuOpen(false); // 메뉴는 종료
    };

   return (
   <>
     <MenuButton
          onClick={handleClick}
      >
        <MenuIcon fontSize="large"></MenuIcon>
      </MenuButton>

       {menuOpen ?
          <Navigation>
            <NavItem id= 'customer' onClick={(e:any) => onMoveToElement(e)}>사업장 관리</NavItem>
            <NavItem id= 'worker' onClick={(e:any) => onMoveToElement(e)}>근로자 관리</NavItem>
            <NavItem id= 'labor' onClick={(e:any) => onMoveToElement(e)}>근로계약서</NavItem>
            <NavItem id= 'schedule' onClick={(e:any) => onMoveToElement(e)}>근무일정</NavItem>
            <NavItem id= 'wage' onClick={(e:any) => onMoveToElement(e)}>임금명세서</NavItem>
            <NavItem id= 'insurance' onClick={(e:any) => onMoveToElement(e)}>4대보험</NavItem>
            <div style={{ backgroundColor:'rgba(0,0,0,0.6)', width:'100%', height:'100%'}} onClick={handleClose}></div>
        </Navigation>
        :<></>
       }
        // 이동 대상 거래처 화면, ref 부분의 element.customer 를 확인하자
         <Section position={`relative`} ref={element.customer}>
            <SectionInner>
              <WrapperDiv ju={`center`}>
                <TitleWrapper>
                  <TitleP fontSize={`20px`}>
                    사업장 관리
                  </TitleP>
                </TitleWrapper>
              </WrapperDiv>
              <CustomerStepper />
            </SectionInner>
          </Section>
</>
  );



}

export default Promotion;
 

결과

 

 

끝~

오픈소스로 나와있는 calendar api 들이 많지만,

필자의 경우 커스텀할 일이 많아 직접 캘린더를 구현해볼 것이다.

1.전제조건

1) 종료일자기준 30일전부터 캘린더에 전부 표현할 것 , 1개의 캘린더에 모든 날짜가 들어간다.

2) 설명을 위한 글이므로 소스코드를 여러페이지에 분리하지 않고 한 페이지에 구현

3) state, dispatch 등 calendar 설명에 불 필요한 부분은 과감히 생략

4) 꼭 필요한 부분은 소스코드에 명시할 것이며 css 관련해서는 자세하게 작성하지 않는다.

5) 순수하게 캘린더에 필요한 부분만 작성할 것이며 상황에 따른 커스텀은 따로 하길 바란다.

6) 설명을 위해 일부 주석이 들어갈 수 있다.

2. 소스코드(JS)

 

import React, {useState } from 'react';
import moment from 'moment'; 
    const [dayArray, setDayArray] = useState<any>({ array: [] }); // 실제 캘린더 데이터

    const dayInit: any = {
        monthIndex: 0,
        weekIndex: 0,
        weekData: "",
        day: "--",
        status: {
            영업구분 : { check : true, 근로자수 : 0} ,
        },
        func: {
            onClick: false,
        }
    }
    var calcEndDate = moment(data.basicInfoState.customer_calc_basic_date);
    var calcStartDate = moment(data.basicInfoState.customer_calc_basic_date).subtract(30,'d');
    var tempCalcStartDate =  moment(data.basicInfoState.customer_calc_basic_date).subtract(30,'d');

  const dayArrayInit = () => { // 캘린더 작성 버튼 클릭 이후
       
        let monthIndex = 0; // 일자별 구분
        let totalResult: any[] = []; // 전체 배열
        let calc_day : any = calcStartDate; // 시작일자 기준값
        if(calc_day.day() === 0){  // 종료일자 기준 30일 전이 일요일이면 아무행위를 하지않음
          
        }else { // 종료일자 기준 30일 전이 일요일이 아닐 경우 해당 날짜만큼 차감하여 일요일로 만든다.
            calc_day.subtract(calc_day.day(),'days');
          }

            for(let j= calc_day.week(); j <= calcEndDate.week(); j++){ // 첫주차~마지막주차까지 반복
                let weekArray : any[] = []; // 일주일 단위 데이터
                for(let z = 0; z < 7; z++){ // 주차별 일요일~토요일까지 반복한다.

                    var dayData: any = {
                        monthIndex: 0,
                        weekIndex: 0,
                        weekData: "",
                        day: "--",
                        status: {
                            영업구분 : {check : true, 근로자수 : 0},
                        },
                        func: { onClick:  true }
                    }
                    let  dayType : any = moment(calc_day.format('YYYY-MM-DD').day()); // 무슨 요일인지 선언함
              
                   
                    if(calc_day.isAfter(calcEndDate) === false && calc_day.isAfter(tempCalcStartDate) === true){
                      
                     // 시작일자부터 종료일자에 속하면, 정확한 데이터를 넣음
                   
                    weekArray.push({
                        ...dayData,
                        monthIndex: monthIndex,
                        weekIndex: z,
                        weekData: moment(calc_day.format('YYYY-MM-DD').day()),
                        day: calc_day.format('YYYY-MM-DD');
                        func: { onClick:  true }
                    });

                    }else{

                     // 날짜 범위밖에서는 날짜를 입력하지 않고, 클릭할 수 있는 기능을 없앰
                        weekArray.push({
                            ...dayInit,
                            monthIndex: 0,
                            weekIndex: z,
                            weekData: moment(calc_day.format('YYYY-MM-DD')).day(),
                        }) ;

                    }
                    calc_day.add(1,'days'); // 데이터를 넣었으니 기준일자를 하루씩 증가시킨다.
                    monthIndex++; // monthIndex 값을 1씩 증가
                }
               
                var jsonData = {
                    'weekData' : weekArray,
                };
                totalResult.push(jsonData); // 실제 데이터를 넣는다.
        }
        data.setDayArray({ array: totalResult }); // 실제 캘린더 데이터 배열
  }

이것으로 1차적으로 캘린더를 생성하는 작업은 끝났다.

이제 이 배열을 가공하여 HTML, CSS 작업을 하면 되겠다.

** 원래 시작일자와 종료일자를 나눠, 월 단위로 캘린더를 그려지는 것을

설명하려 했는 데, 소스코드가 너무 복잡하여 이해시킬 자신이 없어

최대한 간단하게 캘린더를 구현했던 버전으로 작성한다.

월별로 캘린더를 나누고 싶은 분은 현재 소스에서 커스텀을 일부 진행하길 바란다.

위 캘린더는 moment.js를 다수 활용했으니, 메서드에 대해서 모르시는 분은 아래 글들을 참고하자.

 

2025.02.10 - [JS] - JS 날짜 라이브러리중 Moment.js에 대해 알아보자

 

리액트를 하다보면 부모 와 자식간에 props를 넘길 일이 많은데,

부모 밑에 직계자식만 있는 것이 아닌

Master -> slave1 -> slave2 -> slave3 -> ... 뭐 이런식으로

중간 단계가 많은 경우가 있는 데,

내가 사용할 곳은 slave3 단계에서만 필요한데 Master 에서 선언할 경우

단계별로 Props를 다 넘겨야 되니 여간 귀찮은 게 아니다.

그럴 때를 대비하여 React Hooks 에서 UseContext API 를 갖춰놓았으니,

활용해보도록 하자.

나는 타입스크립트 환경이므로 각자 환경에 맞는 문법을 찾아 사용해보자.

1. Master.tsx

import { useState, createContext } from "react";

export const calcDataContext : any = createContext('');  

const Master = (props: any) => {
   var machineData = 'Machine-01';
   var mainData = 'Factory-01';
    const calcData = {
      machineData,
      mainData,
    }

 return (
     <calcDataContext.Provider value={{calcData}} >
        <Master />
      </calcDataContext.Provider>

 )
}
export default Input;

 

코드를 보면 알겠지만 최상위단을 useContext.Provider 로 감싸준다.

이렇게 해줌으로 써 Master의 자식들은 Master의 값을 호출할 수 있다.

2. Slave1.tsx (코드를 작성하지 않으므로 생략 - 중간단계)

3. Slave2.tsx (코드를 작성하지 않으므로 생략 - 중간단계)

4. Slave3.tsx (목표지점)

import { calcDataContext } from "../..";
import { useContext } from "react";

const Slave3 = ({  
}: any) => {
 const data : any = useContext(calcDataContext);
    return (
     <>
        <h1>{data.calcData.machineData}</h1>
        <h2>{data.calcData.mainData}</h2>
     </>
    ) 
}
export default withResizeDetector(Slave3);

사용하고 싶은 자식 소스단에서 부모의 useContext 를 호출하여 값을 활용할 수 있다.

변수뿐만 아니라 함수같은 것들도 같이 호출할 수 있으므로, 잘 활용하면 매우 편하다는 것을 알 수 있다.

앞으로는 useContext 를 활용하여

Props를 꼭 필요한 곳에서만 호출하여 쓰자!

일반적인 웹 HTML 태그에서 이미지를 불러올 때는

<img src='https://www.naver.com/' />

이런 식으로 호출해야 하지만

Next.js 에서는 다른 방식으로 호출해야 한다.

 

import Image from 'next/image'
<Image  layout = 'responsive' width={100} height={100} alt='카카오공유'  src="https://developers.kakao.com/assets/img/about/logos/kakaolink/kakaolink_btn_medium.png"  />

 

다만 이런 식으로 했을 때 이미지의 경로중 도메인을 등록하지 않으면 해당 URL이 호출이 되지 않으면서 에러가 발생한다.

Next image 도메인 미 지정시 경고

그러므로 next.config.js 파일을 수정하여 도메인을 입력하자.

이렇게 하면 URL은 정상적으로 동작하지만, 또 사이즈를 맞추라고 에러메시지가 뜰 것이다.

Next.js 에서는 이미지를 자동으로 최적화하기 때문에 잦은 경고 메시지가 나오는데,

그럴 경우 옵션을 하나 추가한다.

unoptimized={true}

이러면 Next.js에서 최적화를 하지 않기 때문에 입맛대로 커스터마이징 하기 좋다.

최종 이미지 코드

<Image layout = 'responsive' unoptimized={true} width={100} height={100} alt='카카오공유' src="https://developers.kakao.com/assets/img/about/logos/kakaolink/kakaolink_btn_medium.png" />

 

 

 

 

Next.js 에서 node_module 을 사용하지 않고 스크립트를 직접 불러오는 방식을 적어보자.

Next.js의 _app.tsx 파일

Next.js 의 _app.tsx 파일에

import를 한다.

다만 <script> 속성안에

async 또는 defer 를 추가해야 하는데, 추가하지 않는다면 추후 빌드를 할 때

경고메시지가 뜨므로 꼭 추가해야 한다.

async 는 여러분도 많이 알다시피 비동기성으로 읽히며,

스크립트가 먼저 다운로드된 순서대로 실행하며,

defer 는 HTML 을 전부 읽은 뒤에 실행한다.

가급적 defer 를 사용하는 게 안정성면에서는 좋은 거 같다.


현재 내 사이트의 도메인을 효율적으로 다른사람에게 전달하는

기능이 없을 까하다가, 대한민국 사람이라면 카카오톡을 흔하게 쓰니

카카오버튼으로 공유 기능을 만들어보았다.

그러려면 일단 카카오 Developers 에 제품 등록을 해야한다.

https://developers.kakao.com/

카카오 Developers 사이트의 내 어플리케이션 버튼을 클릭한다.

애플리케이션 추가하기 버튼을 클릭한다.

애플리케이션 추가시 내용 넣는 곳

차후 카카오 공유를 할때 앱 이름으로 링크가 공유되니, 앱 이름은 신중하게 써넣자

차후 수정이 안되는 거 같다.

카카오 Developers, API Key

저장버튼을 클릭하면 이런식으로 나오는데 ,

자바스크립트 키를 실제로 활용하므로 해당 키를 복사해 놓는다.

또한 우리가 활용한 플랫폼의 정보를 설정해야 하는데,

플랫폼 설정하기 링크를 클릭한다.

카카오 Developers, 플랫폼 등록 화면

Web 플랫폼 등록 버튼을 클릭한다.

도메인 입력 화면

빈칸에 도메인을 입력한다.

저장버튼을 누르면 카카오에서 작업할 것은 끝난 것이다.

이제 소스 작업을 해보자.

 

// index.html
 <script src="https://developers.kakao.com/sdk/js/kakao.js">
 </script>

해당 SDK를 index.html 혹은 App.js 에 추가하고 ,

Next.js 의 경우에는 _app.tsx 에 추가한다.

1.js 부분

 
const url_text = 'https://naver.com';

useEffect(() => {
          createKakaoButton()
        }, [])

   const createKakaoButton = () => {
          // kakao sdk script이 정상적으로 불러와졌으면 window.Kakao로 접근이 가능합니다
          window.Kakao.init(process.env.NEXT_PUBLIC_KAKAO_SHARE_KEY);
          window.Kakao.Link.createDefaultButton({
            container: '#kakao-link-btn',
            objectType: 'feed',
            content: {
              title: '네이버 소개',
              description: '#네이버 #블로그 #맛집',
              imageUrl: '',
              link: {
                mobileWebUrl: url_text,
                webUrl: url_text
              }
            },
            social: {
              likeCount: 286,
              commentCount: 45,
              sharedCount: 845
            },
            buttons: [
              {
                title: '웹으로 보기',
                link: {
                  mobileWebUrl: url_text,
                  webUrl: url_text
                }
              },
              {
                title: '앱으로 보기',
                link: {
                  mobileWebUrl: url_text,
                  webUrl: url_text
                }
              }
            ]
          });
        }

 

2. html 부분

 

<a id="kakao-link-btn" href="javascript:;">
     <img src="//developers.kakao.com/assets/img/about/logos/kakaolink/kakaolink_btn_medium.png"/>
 </a>

일단 .env 파일에 자바스크립트 key 를 설정하고,

해당 소스를 부여하는 데 필자는 react-hooks 환경이었기에

useEffect 에 해당 이벤트를 실행한다.

정상적으로 됐으면 카카오톡 버튼이 생성되며, html 태그에 따로 이벤트를 추가하지 않아도 된다.

그 외 자세한 옵션은 공식문서에서 변경해서 확인해보길 바란다.

--- 추가

타입스크립트에서만 해당되는 부분이다.

위의 소스 코드중

window.Kakao.init(process.env.NEXT_PUBLIC_KAKAO_SHARE_KEY);

window.Kakao.Link.

이런식으로 선언하는 부분이 있는데 이 window 부분의

Kakao 를 따로 선언하지 않으면 빌드할 때 에러가 발생한다.

그러므로 소스코드를 추가한다.

window 객체 속성 추가

이제 build 를 실행하면 정상적으로 될 것이다.

https://developers.kakao.com/tool/demo/message/kakaolink?message_type=default

+ Recent posts