# 기존방법

- Slack Webhooks를 통해 Slack채널로 HTTP 요청

- Icoming Webhooks: 가장 간단한 방법중 하나로 Slack에서 발급된 URL로 HTTP POST 요청을 보냄

- Slack 웹훅을 사용하기 위해선 앱을 생성해야함

 

https://velog.io/@sssssssssy/nextjs-slack-webhooks

 

Next.js와 Slack Webhooks로 실시간 에러 알림 시스템 만들기

[FE] Next.js + Slack Webhooks 실시간 에러 알림 설정

velog.io

위의 링크를 통해 개발에 필요한 bot 생성 및 토큰 발행에 큰 도움을 받았다 (+git을 통해 기초코드까지 있음)

 

# 문자 발송은 바로 되지만 파일은 실패!

- 메시지 발송하는 방법은 위 링크에서 git을 통해 받은 코드와 설명을 읽은 후 바로 진행됨

- 그러나 여러가지 문제로 계속 실패 (특히, 권한 문제)

 

# 다양한 에러코드

1. not_int_channal

- 해당 에러코드는 내가 전달하고 싶은 Slack 채널에서 /invite @봇이름 을 하면 해결

 

2. method_deprecated

- 무슨 뜻이야..? 검색해보니 "사용되지 않는 메서드"

- 검색해보면 files.upload 를 사용하여 다 구현했다고 하는데 왜 이런 에러가?

- 공식 API문서를 보면 "files.upload는 2025년 3월에 종료되며 순차적인 웹 API 메서드로 대체됩니다."

- 코드 작성할때가 25년 3월 14일인데, 3월 11일에 종료되었다고 한다ㅠㅠ

이게 외 않대?

- 그럼 뭐 써요???

-  files.getUploadURLExternal 와 files.completeUploadExternal를 쓰라고 함

- 왜 파일업로드가 2개로 나누어졌지?

- files.getUploadURLExternal : 외부 파일 업로드에 대한 URL을 가져옵니다.

- files.completeUploadExternal:  files.getUploadURLExternal로 시작된 업로드를 완료합니다.

- 그럼 이걸로 시작해볼까?

 

 

3. missing_scope

- 메소드를 변경하니 새로운 문제 발생

- 권한을 추가해야한다는 뜻. slack api 웹페이지에서 OAuth & Permissions 메뉴를 들어간다.

- Scopes에서 Bot과 User의 Scopes를 설정할 수 있음

- 아마 필요한건 channels:read, chat:write, files:read, files:write로 생각됨

뭐가 필요한지 몰라서 다넣어봄

 

4. invalid_arguments

- '유효하지 않은 인자'

- 무엇을 잘못넣은거지....? 답은 공식 문서에 있다

 

5. 그래도 안됨ㅠㅠ

더보기
  • 파일 정보 읽기: fs.promises.stat과 fs.promises.readFile을 사용하여 파일 정보와 내용을 읽습니다.
  • 업로드 URL 얻기: files.getUploadURLExternal API를 호출하여 파일을 업로드할 URL을 얻습니다.
  • 파일 업로드: 얻은 URL에 PUT 요청으로 파일을 업로드합니다. 여기서 Buffer를 Blob으로 변환하여 업로드합니다.
  • 업로드 완료 처리: files.completeUploadExternal API를 호출하여 업로드를 완료합니다. 여기서 channels 속성을 제거했습니다.
  • 채널에 파일 공유: 업로드된 파일을 chat.postMessage API를 사용하여 채널에 공유합니다. 이 부분이 이전에 빠져있던 중요한 단계입니다. file_ids 배열에 업로드된 파일의 ID를 포함시켜 채널에 해당 파일을 첨부합니다.

각 메소드가 무엇을 하는건지 열심히 분석했지만 파일은 업로드가 안된다.

링크를 버튼으로 다운받게 하면 302 Found 에러가 발생한다.

보통 302에러는 사용자가 인증되지 않은 페이지에 엑세스하려고 할때 발생한다고 함.

다운로드 링크와 공개링크 둘다 안됨...

공개 링크의 경우 "files.sharedPublicURL"(파일을 공개/외부 공유가 가능하도록 함)를 통해 만들었는데도 안됨!

 

# 그래서 어떻게 해결했을까?

- API로 GET하고 POST를 해야한다는게 이해가 안됨

- 예를 들어 온도가 40도를 넘었을때, 변수로 40이라는 숫자가져오고

- "온도 40도가 넘었습니다" 라는 문장을 txt파일로 만들어서 Slack으로 전달하고 싶었음

- 계속 검색하다보니 npm에 slack/web-api 라이브러리 존재

 https://www.npmjs.com/package/@slack/web-api

 

@slack/web-api

Official library for using the Slack Platform's Web API. Latest version: 7.8.0, last published: 3 months ago. Start using @slack/web-api in your project by running `npm i @slack/web-api`. There are 644 other projects in the npm registry using @slack/web-ap

www.npmjs.com

 

- 이걸로 한번 시도해보자!

import { getErrorResponse } from '@/app/utils/helper';
import { config } from '../../../../config';
import { NextRequest } from 'next/server';
import { WebClient } from '@slack/web-api';

/**
 * Slack API를 통한 파일 전송 프로세스
 * 1. WebClient 초기화 (Bot Token 사용)
 * 2. filesUploadV2 메서드로 파일 업로드
 * 3. 업로드된 파일 ID를 사용하여 메시지 전송
 */

type BodyType = {
  location: string;
  message: string;
  fileType?: 'text';
};

export async function POST(req: NextRequest) {
  try {
    // 클라이언트로부터 요청 데이터 파싱
    const { message, location, fileType } = await req.json() as BodyType;
    console.log('[REQUEST] 받은 요청:', { message, location, fileType });

    // Slack WebClient 초기화 - Bot Token 사용
    // Bot Token에는 files:write, chat:write 등의 권한이 필요
    const client = new WebClient(config.slack.botToken);

    if (fileType === 'text') {
      const content = "계약현황";  // 파일에 포함될 텍스트 내용
      const fileName = "state.txt"; // 생성될 파일명

      try {
        // Step 1: 파일 업로드 준비
        console.log('[SLACK] 파일 업로드 시작');
        
        // Step 2: filesUploadV2 메서드 사용하여 파일 업로드
        // - Buffer.from(content): 문자열을 버퍼로 변환 (바이트 단위로 변환)
        // - filename: Slack에 표시될 파일 이름
        // - channels: 파일이 공유될 채널 ID
        // - initial_comment: 파일과 함께 표시될 초기 메시지
        const uploadResponse = await client.filesUploadV2({
          file: Buffer.from(content),          // 텍스트 내용을 버퍼로 변환(중요!)
          filename: fileName,                   // 파일 이름 설정
          title: fileName,                     // Slack에서 표시될 제목
          channels: config.slack.channelId,     // 파일을 공유할 채널
          initial_comment: "새로운 계약현황 파일이 업로드되었습니다."  // 초기 메시지
        });

        console.log('[SLACK] 파일 업로드 응답:', uploadResponse); // 성공했으면 끝!

        // Step 3: 업로드 성공 여부 확인
        if (!uploadResponse.ok) {
          throw new Error('파일 업로드 실패');
        }

        // Step 4: 업로드된 파일 정보로 메시지 전송
        // files 배열의 첫 번째 항목에서 파일 ID를 추출
        const fileId = uploadResponse.files?.[0]?.files;
        if (fileId) {
          // Step 5: chat.postMessage로 파일 관련 메시지 전송
          // - channel: 메시지를 보낼 채널
          // - blocks: 메시지의 구조화된 레이아웃
          // - text: 기본 텍스트 (블록이 표시되지 않을 때 사용)
          const messageResponse = await client.chat.postMessage({
            channel: config.slack.channelId || '',
            text: `텍스트 파일이 업로드되었습니다: ${fileName}`,
            blocks: [
              {
                type: "section",
                text: {
                  type: "mrkdwn",
                  text: `*텍스트 파일이 업로드되었습니다*\n\n• 파일명: ${fileName}\n• 내용: ${content}`
                }
              }
            ],
          });

          console.log('[SLACK] 메시지 전송 결과:', messageResponse);
        }

        // Step 6: 클라이언트에 성공 응답 반환
        return new Response(JSON.stringify({
          ok: true,
          message: '파일이 성공적으로 전송되었습니다.',
          filename: fileName
        }), {
          status: 200,
          headers: { 'Content-Type': 'application/json' }
        });

      } catch (error) {
        // 파일 업로드 과정에서 발생한 에러 처리
        console.error('[ERROR] Slack API 오류:', error);
        throw error;
      }
    }

    // 일반 에러 메시지 전송 (파일 업로드가 아닌 경우)
    const messageResponse = await client.chat.postMessage({
      channel: config.slack.channelId || '',
      text: `Error Report\nLocation: ${location}\nMessage: ${message}`
    });

    return new Response(JSON.stringify({ ok: true }), {
      status: 200,
      headers: { 'Content-Type': 'application/json' }
    });

  } catch (error) {
    console.error('[ERROR]', error);
    return getErrorResponse(
      500, 
      error instanceof Error ? error.message : '오류가 발생했습니다.'
    );
  }
}

 

드디어 다운로드 가능!

 

엑셀 스타일 다운받기

import { getErrorResponse } from '@/app/utils/helper';
import { config } from '../../../../config';
import { NextRequest } from 'next/server';
import { WebClient } from '@slack/web-api';
import XLSX from 'xlsx-js-style';  // xlsx-js-style로 변경

type BodyType = {
  location: string;
  message: string;
  fileType?: 'excel';  // 파일 타입을 엑셀로 변경
};

export async function POST(req: NextRequest) {
  try {
    const { message, location, fileType } = await req.json() as BodyType;
    console.log('[REQUEST] 받은 요청:', { message, location, fileType });

    const client = new WebClient(config.slack.botToken);

    if (fileType === 'excel') {
      // 헤더 데이터 정의 (스타일 포함)
      const headers = [
        {
          v: '계약 ID',
          t: 's',
          s: {
            font: { bold: true, color: { rgb: 'FFFFFF' }, sz: 12 },
            fill: { fgColor: { rgb: '1F497D' } },
            alignment: { horizontal: 'center', vertical: 'center' },
            border: {
              top: { style: 'medium', color: { rgb: '000000' } },
              bottom: { style: 'medium', color: { rgb: '000000' } },
              left: { style: 'thin', color: { rgb: '000000' } },
              right: { style: 'thin', color: { rgb: '000000' } }
            }
          }
        },
        // 나머지 헤더들도 동일한 스타일로 정의
        {v: '데이터센터', t: 's', s: { /* 동일한 헤더 스타일 */ }},
        {v: '서버 타입', t: 's', s: { /* 동일한 헤더 스타일 */ }},
        {v: 'CPU', t: 's', s: { /* 동일한 헤더 스타일 */ }},
        {v: 'RAM', t: 's', s: { /* 동일한 헤더 스타일 */ }},
        {v: '스토리지', t: 's', s: { /* 동일한 헤더 스타일 */ }},
        {v: '계약기간', t: 's', s: { /* 동일한 헤더 스타일 */ }},
        {v: '월 비용', t: 's', s: { /* 동일한 헤더 스타일 */ }}
      ];

      // 데이터 행 생성 (스타일 포함)
      const rows = [
        ['DC001', '강남 IDC', '웹 서버', 'Intel Xeon 8코어', '32GB', 'SSD 1TB', 12, 450000],
        ['DC002', '분당 센터', 'DB 서버', 'AMD EPYC 16코어', '64GB', 'NVMe 2TB', 24, 750000],
        ['DC003', '판교 IDC', '백업 서버', 'Intel Xeon 4코어', '16GB', 'HDD 4TB', 6, 300000],
        ['DC004', '광주 센터', '로드밸런서', 'AMD EPYC 8코어', '32GB', 'SSD 500GB', 12, 400000],
        ['DC005', '부산 IDC', '캐시 서버', 'Intel Xeon 12코어', '128GB', 'NVMe 1TB', 24, 600000],
      ].map(row => row.map((cell, index) => {
        const baseStyle = {
          font: { name: 'Arial', sz: 11 },
          alignment: { horizontal: 'center', vertical: 'center' },
          border: {
            top: { style: 'thin', color: { rgb: 'D9D9D9' } },
            bottom: { style: 'thin', color: { rgb: 'D9D9D9' } },
            left: { style: 'thin', color: { rgb: 'D9D9D9' } },
            right: { style: 'thin', color: { rgb: 'D9D9D9' } }
          }
        };

        // 각 컬럼별 특수 스타일 적용
        if (index === 0) {  // 계약 ID
          return {
            v: cell,
            t: 's',
            s: {
              ...baseStyle,
              font: { ...baseStyle.font, color: { rgb: '0066CC' } }
            }
          };
        } else if (index === 6) {  // 계약기간
          return {
            v: cell,
            t: 'n',
            s: {
              ...baseStyle,
              alignment: { horizontal: 'right' },
              numFmt: '0"개월"'
            }
          };
        } else if (index === 7) {  // 월 비용
          return {
            v: cell,
            t: 'n',
            s: {
              ...baseStyle,
              font: { ...baseStyle.font, color: { rgb: 'FF0000' } },
              alignment: { horizontal: 'right' },
              numFmt: '#,##0"원"'
            }
          };
        }
        return { v: cell, t: 's', s: baseStyle };
      }));

      // 워크북 생성
      const wb = XLSX.utils.book_new();
      const ws = XLSX.utils.aoa_to_sheet([headers, ...rows]);

      // 열 너비 설정
      ws['!cols'] = [
        { wch: 12 },  // 계약 ID
        { wch: 15 },  // 데이터센터
        { wch: 12 },  // 서버 타입
        { wch: 20 },  // CPU
        { wch: 10 },  // RAM
        { wch: 12 },  // 스토리지
        { wch: 10 },  // 계약기간
        { wch: 15 }   // 월 비용
      ];

      // 행 높이 설정
      ws['!rows'] = [{ hpt: 30 }];  // 헤더 행 높이

      // 워크시트를 워크북에 추가
      XLSX.utils.book_append_sheet(wb, ws, "계약현황");

      // 엑셀 파일 생성
      const excelBuffer = XLSX.write(wb, {
        type: 'buffer',
        bookType: 'xlsx'
      });

      const fileName = `datacenter_contracts_${new Date().toISOString().split('T')[0]}.xlsx`;

      try {
        console.log('[SLACK] 파일 업로드 시작');
        
        const uploadResponse = await client.filesUploadV2({
          file: excelBuffer,
          filename: fileName,
          title: '데이터센터 계약현황',
          channels: config.slack.channelId,
          initial_comment: "📊 최신 데이터센터 계약현황 보고서가 업로드되었습니다."
        });

        console.log('[SLACK] 파일 업로드 응답:', uploadResponse);

        if (!uploadResponse.ok) {
          throw new Error('파일 업로드 실패');
        }

        // 메시지 전송
        const fileId = uploadResponse.files?.[0]?.files;
        if (fileId) {
          await client.chat.postMessage({
            channel: config.slack.channelId || '',
            blocks: [
              {
                type: "section",
                text: {
                  type: "mrkdwn",
                  text: `*데이터센터 계약현황 보고서*\n\n• 파일명: ${fileName}\n• 업데이트: ${new Date().toLocaleString()}`
                }
              }
            ],
          });
        }

        return new Response(JSON.stringify({
          ok: true,
          message: '엑셀 파일이 성공적으로 전송되었습니다.',
          filename: fileName
        }), {
          status: 200,
          headers: { 'Content-Type': 'application/json' }
        });

      } catch (error) {
        console.error('[ERROR] Slack API 오류:', error);
        throw error;
      }
    }

    return new Response(JSON.stringify({ ok: true }), {
      status: 200,
      headers: { 'Content-Type': 'application/json' }
    });

  } catch (error) {
    console.error('[ERROR]', error);
    return getErrorResponse(
      500, 
      error instanceof Error ? error.message : '오류가 발생했습니다.'
    );
  }
}
728x90

쿼리파라미터와 동적라우팅 URL 방식의 차이

  1. 쿼리 파라미터 방식
    • URL에 ? 이후로 키-값 쌍으로 데이터를 전달합니다.
    • API 라우트에서는 request.nextUrl.searchParams로 값을 추출합니다.
    • 라우트 파일 위치: /app/api/sensor/route.ts
    • URL: `/api/sensor?sensorId=${sensorCodeId}`
  2. 동적 라우팅 방식
    • URL 경로의 일부로 값을 전달합니다.
    • API 라우트에서는 params 객체로 값을 추출합니다.
    • 라우트 파일 위치: /app/api/sensor/[sensorCodeId]/route.ts
    • URL: `/api/sensor/${sensorCodeId}`

쿼리 파라미터가 적합한 경우

  • 선택적(optional) 파라미터가 여러 개일 때
  • 필터링, 정렬, 검색 등 부가적인 옵션을 전달할 때
  • 페이지네이션(예: ?page=2&limit=10)
  • URL을 깔끔하게 유지하고 싶을 때
  • 같은 엔드포인트에 여러 파라미터가 조합될 때

동적 라우팅(/path/[param])이 적합한 경우:

  • 리소스의 고유 식별자를 표현할 때(ID, 슬러그 등)
  • RESTful API 설계를 따를 때
  • 경로가 리소스의 계층 구조를 표현할 때
  • URL이 의미적으로 중요할 때 (SEO에 유리)
  • 필수적인(required) 파라미터일 때

 

현재 상황(ID를 받아서 검색하는 경우)에 대한 추천

동적 라우팅 방식이 더 적합합니다.

이유

  1. 특정 센서 코드라는 리소스에 직접 접근하는 패턴입니다.
  2. RESTful API 설계 원칙에 더 부합합니다.
  3. sensorCodeId는 필수 파라미터이며 리소스의 식별자입니다.
  4. URL이 더 의미적이고 명확해집니다.
728x90

서버 컴포넌트와 클라이언트 컴포넌트는 React의 새로운 기능이라, 많은 타사 패키지들이 아직 "use client" 지시어를 추가하지 않은 상태예요. "useState", "useEffect", "createContext"와 같은 클라이언트 전용 기능을 사용하는 컴포넌트들은 클라이언트 컴포넌트 안에서만 작동하고, 서버 컴포넌트에서는 작동하지 않아요.

예시

acme-carousel이라는 패키지를 설치했다고 가정해봐요. 이 패키지에는 <Carousel /> 컴포넌트가 있어요. 이 컴포넌트는 useState를 사용하지만 아직 "use client" 지시어가 없어요.

  • 클라이언트 컴포넌트 내에서 사용
'use client';
import { useState } from 'react';
import { Carousel } from 'acme-carousel';

export default function Gallery() {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div>
      <button onClick={() => setIsOpen(true)}>View pictures</button>
      {/* Carousel을 클라이언트 컴포넌트 내에서 사용하기 때문에 작동해요 */}
      {isOpen && <Carousel />}
    </div>
  );
}

 

  • 서버 컴포넌트 내에서 사용
  • Next.js는 <Carousel />이 클라이언트 전용 기능을 사용하고 있다는 걸 알 수 없기 때문에 에러가 발생해요.
import { Carousel } from 'acme-carousel';

export default function Page() {
  return (
    <div>
      <p>View pictures</p>
      {/* 에러 발생: `useState`는 서버 컴포넌트 내에서 사용할 수 없어요 */}
      <Carousel />
    </div>
  );
}

해결 방법

서버 컴포넌트 안에서 사용할 수 있도록 클라이언트 컴포넌트를 감싸는 방법이 있어요:

1. 클라이언트 컴포넌트를 감싸기

'use client';
import { Carousel } from 'acme-carousel';
export default Carousel;`

2. 서버 컴포넌트 내에서 사용

 

import Carousel from './carousel';

export default function Page() {
	return ( 
    <div> <p>View pictures</p>     {/* 이제 Carousel을 서버 컴포넌트 내에서 사용할 수 있어요 */}
    <Carousel />
    </div> );
    }

 

일반적으로 대부분의 타사 컴포넌트는 클라이언트 컴포넌트 내에서 사용될 것이기 때문에, 특별한 처리가 필요 없을 거예요. 그러나 React 상태와 컨텍스트를 사용하는 제공자(provider)는 예외로, 애플리케이션의 루트에서 필요할 수 있어요.

728x90

1. 예상 오류 처리 (Error Handling)

// 기본적인 try-catch를 사용한 오류 처리
async function getData() {
  try {
    const res = await fetch('https://api.example.com/data')
    if (!res.ok) {
      throw new Error('Failed to fetch data')
    }
    return res.json()
  } catch (error) {
    console.error('Error:', error)
    return null // 또는 기본값 반환
  }
}

2. 서버 작업에서의 오류 처리 (Server Actions Error Handling)

// app/actions.ts
'use server'

async function serverAction(data: FormData) {
  try {
    // 서버 작업 수행
    const result = await database.save(data)
    return { success: true, data: result }
  } catch (e) {
    // 구체적인 오류 타입 처리
    if (e instanceof DatabaseError) {
      return { success: false, error: '데이터베이스 오류' }
    }
    return { success: false, error: '알 수 없는 오류' }
  }
}


3. 서버 구성 요소의 오류 처리 (Server Component Error Handling)

// app/ServerComponent.tsx
async function ServerComponent() {
  const data = await getData()
  
  if (!data) {
    // 오류 상태를 명시적으로 처리
    return <div>데이터를 불러오는데 실패했습니다</div>
  }

  return <div>{data.map(item => <Item key={item.id} {...item} />)}</div>
}


4. 오류 경계 사용 (Error Boundaries)
이 부분이 특히 중요한데, Next.js 13 이상에서는 error.tsx 파일을 사용하여 오류 경계를 구현합니다:


// app/error.tsx
'use client' // 클라이언트 컴포넌트여야 함

interface ErrorBoundaryProps {
  error: Error
  reset: () => void
}

export default function ErrorBoundary({
  error,
  reset,
}: ErrorBoundaryProps) {
  return (
    <div className="error-container">
      <h2>문제가 발생했습니다!</h2>
      <p>{error.message}</p>
      <button onClick={reset}>다시 시도</button>
    </div>
  )
}


5. 중첩된 경로에서 오류 처리 (Nested Routes Error Handling)

app/
├── error.tsx      // 전역 오류 처리
├── layout.tsx
├── page.tsx
└── blog/
    ├── error.tsx  // 블로그 섹션 오류 처리
    └── page.tsx


6. 전역 오류 처리 (Global Error Handling)

// app/global-error.tsx
'use client'

export default function GlobalError({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  return (
    <html>
      <body>
        <h1>심각한 오류가 발생했습니다</h1>
        <p>{error.message}</p>
        <button onClick={reset}>다시 시도</button>
      </body>
    </html>
  )
}


주요 포인트
1. 계층적 오류 처리
- error.tsx는 가장 가까운 상위 경로에서 오류를 포착
- 더 구체적인 오류 처리가 필요한 경우 하위 경로에 error.tsx 배치

2. 오류 복구 전략

// app/posts/error.tsx
'use client'

export default function ErrorBoundary({
  error,
  reset,
}: {
  error: Error
  reset: () => void
}) {
  useEffect(() => {
    // 오류 로깅
    console.error(error)
  }, [error])

  return (
    <div>
      <h2>포스트를 불러오는데 실패했습니다</h2>
      <button 
        onClick={() => reset()}
        className="retry-button"
      >
        다시 시도
      </button>
    </div>
  )
}


3. 오류 UI 구성
- 사용자 친화적인 메시지 제공
- 복구 옵션 제시 (reset 함수 활용)
- 필요한 경우 대체 콘텐츠 표시

4. 오류 타입별 처리

try {
  await someOperation()
} catch (error) {
  if (error instanceof NotFoundError) {
    // 404 처리
    notFound()
  }
  if (error instanceof AuthError) {
    // 인증 오류 처리
    redirect('/login')
  }
  // 기타 오류는 error.tsx에서 처리되도록 전파
  throw error
}

 

오류 핸들링을 통해
- 예측 가능한 오류 상황을 우아하게 처리
- 사용자 경험 개선
- 디버깅 및 유지보수 용이성 향상
- 애플리케이션의 안정성 확보

728x90

1. 레이아웃(Layout)
- 상태와 상호작용이 유지됩니다
- 여러 페이지에서 공유되는 UI를 위해 사용됩니다
- 렌더링 시 레이아웃은 자식 컴포넌트 간에 상태를 보존합니다
- 주로 사용되는 경우

// app/layout.tsx
export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html>
      <body>
        <nav>공통 네비게이션</nav>
        {children}
        <footer>공통 푸터</footer>
      </body>
    </html>
  )
}



2. 템플릿(Template)
- 각 자식 페이지나 레이아웃이 새로 마운트될 때마다 새 인스턴스가 생성됩니다
- 상태가 유지되지 않고 DOM 요소가 재생성됩니다
- 페이지 전환 시마다 다시 마운트되어야 하는 기능에 적합합니다
- 주로 사용되는 경우

// app/template.tsx
export default function Template({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <div>
      {/* 애니메이션 효과나 로깅 등이 필요한 경우 */}
      {children}
    </div>
  )
}


사용 사례 비교:

레이아웃 사용이 적합한 경우:
- 헤더, 푸터, 사이드바와 같은 지속적인 UI 요소
- 전역 상태나 컨텍스트를 공유해야 하는 경우
- 페이지 간 이동시에도 상태를 유지해야 하는 경우

템플릿 사용이 적합한 경우:
- 페이지 전환 애니메이션
- 페이지 접속시마다 초기화되어야 하는 카운터나 타이머
- useEffect 훅이 페이지마다 다시 실행되어야 하는 경우
- 페이지별 피드백이나 분석 기능

실제 사용 예시

// app/layout.tsx (레이아웃)
export default function Layout({ children }) {
  const [user] = useState(getUser()) // 상태 유지됨
  
  return (
    <UserContext.Provider value={user}>
      <nav>
        <Logo />
        <UserProfile />
      </nav>
      {children}
    </UserContext.Provider>
  )
}

// app/template.tsx (템플릿)
export default function Template({ children }) {
  useEffect(() => {
    // 페이지 방문마다 실행됨
    logPageView()
  }, [])

  return (
    <div className="page-transition">
      {children}
    </div>
  )
}



핵심 차이점을 정리하면
1. 상태 유지: 레이아웃은 유지, 템플릿은 초기화
2. 마운팅: 레이아웃은 한 번, 템플릿은 매 페이지 전환마다
3. 용도: 레이아웃은 공통 UI, 템플릿은 페이지별 기능
4. 성능: 레이아웃이 더 효율적 (재렌더링이 적음)

728x90

redirect 옵션

  • redirect: true: 인증이 성공하면 자동으로 지정된 URL로 리다이렉트합니다. 이 경우 **callbackUrl**을 지정해야 합니다. 인증이 성공하면 사용자가 callbackUrl로 이동합니다.
  • redirect: false: 인증이 성공해도 자동으로 리다이렉트하지 않습니다. 대신, 응답 객체를 통해 인증 결과를 처리할 수 있습니다. 이 경우 callbackUrl을 지정할 필요가 없습니다.

callbackUrl 옵션

  • callbackUrl: redirect: true일 때 사용됩니다. 인증이 성공하면 사용자가 이동할 URL을 지정합니다. 예를 들어, /dashboard로 설정하면 인증 성공 시 사용자가 대시보드 페이지로 이동합니다.
const response = await signIn('AuthCheck', {
    id,
    password,
    redirect: true,
    callbackUrl: '/dashboard',
});
// 인증 성공 시 자동으로 '/dashboard'로 리다이렉트
// 중요한건 뒤에 뭐가 있든 실행을 안한다!!!!
const response = await signIn('AuthCheck', {
    id,
    password,
    redirect: false,
});

if (response.ok) {
    alert('로그인 성공');
    router.push('/dashboard'); // 수동으로 '/dashboard'로 이동
} else {
    alert('로그인 실패');
    console.error(response.error);
}

redirect: false를 사용하면 인증 성공 후 원하는 로직을 추가로 실행할 수 있습니다. 예를 들어, 성공 메시지를 표시한 후 특정 페이지로 이동하는 등의 작업을 할 수 있습니다.

 

결론

redirect: true를 하면 callbackUrl로 이동하고 뒤에 뭐가 있든 실행을 안해버린다는 것!

나는 그것도 모르고 alert을 하고 있었다....

728x90

'일기 > 개발일기' 카테고리의 다른 글

[241015] 모드버스 RTU와 TCP  (0) 2024.10.15
[241014] RS-485 Modbus RTU 통신  (1) 2024.10.14
[241008] Next.js와 nextAuth 자동화 기능  (1) 2024.10.08
[241002] Next.js 미들웨어  (0) 2024.10.02
[240930] Next.js 인증  (2) 2024.09.30
  1. public 폴더에 동영상 파일을 넣는다. (mp4)
  2. types폴더에 video.d.ts 파일 생성
clare module '*.mp4' {
    const src: string;
    export default src;
}

    3. components 폴더에 VideoComponent.tsx 파일 생성

'use client';

import { Box } from '@mui/material';
import BkVideo from '../public/LoginPage_video.mp4';

const VideoComponent = () => {
    return (
        <Box
            component={'div'}
            position={'fixed'}
            zIndex={-1}
            width={'100vw'}
            height={'100vh'}
            top={0}
            left={0}
        >
            <video
                autoPlay
                playsInline
                loop
                muted
                width={'100%'}
                height={'100%'}
                preload="none"
                src={BkVideo}
                style={{
                    objectFit: 'cover',
                }}
            />
        </Box>
    );
};

export default VideoComponent;

    4. 로그인 페이지에 적용

'use client';
import React from 'react';
import { Container, Box, Typography, Paper } from '@mui/material';
import VideoComponent from '@/components/VideoComponent';
import LoginForm from '@/components/LoginForm';

export default function LoginPage() {
    return (
        <Container
            component="main"
            maxWidth="xs"
            sx={{
                height: '100vh',
                display: 'flex',
                alignItems: 'center',
            }}
        >
            <Paper elevation={3} sx={{ padding: 4, width: '100%' }}>
                <Box
                    sx={{
                        display: 'flex',
                        flexDirection: 'column',
                        alignItems: 'center',
                    }}
                >
                    <Typography component="h1" variant="h5" sx={{ mb: 2 }}>
                        FMS 5.6
                    </Typography>
                    <LoginForm />
                </Box>
            </Paper>
            <VideoComponent />
        </Container>
    );
}

 


public폴더에 이미지를 넣고 다음과 같이 작성

body {
    background-image: url('../public/bkground.jpg');
    /* background-size: cover;
    background-position: center; */
}

 

728x90

Next.js에서 middleware란?

 

Next.js에서 middleware.ts라는 파일을 프로젝트 루트 디렉토리에 생성하면 자동으로 미들웨어로 인식됩니다. 이 파일은 요청이 완료되기 전에 실행되며, 요청에 따라 응답을 수정하거나 리디렉션, 헤더 수정 등을 할 수 있습니다

 

문제발생

현재 그냥 api/auth를 사용하면서 middleware를 적용하면 에러발생

    if (!token) {
        console.log('No token found, redirecting to login');
        // if (
        //     request.nextUrl.pathname !== '/login' &&
        //     request.nextUrl.pathname !== '/'
        // ) {
        //     return NextResponse.redirect(new URL('/login', request.url));
        // }
    } else {
        console.log('Token found:', token);
    }

 

에러발생이유

미들웨어가 전역으로 진행중인데, 로그인화면에서 아이디와 비밀번호를 제출할땐, token이 생성되기 전이라서 if문의 기능을 하지 못함

 

문제해결

config에서 로그인과 관련된 /login /api 폴더를 제외함

export const config = {
    matcher: ['/((?!api|_next/static|_next/image|favicon.ico|.*\\\\.png$|login).*)'],
};

토큰이 없을때 리다이렉션하는 로직을 좀 더 세밀하게 조정

if (!token) {
    if (
        !request.nextUrl.pathname.startsWith('/api/auth') &&
        !request.nextUrl.pathname.startsWith('/login') &&
        request.nextUrl.pathname !== '/'
    ) {
        return NextResponse.redirect(new URL('/login', request.url));
    }
    console.log('No token found, but on an allowed path');
} else {
    console.log('Token found:', token);
}

 

전체코드

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { getToken } from 'next-auth/jwt';

export async function middleware(request: NextRequest) {
    console.log('Middleware is running');
    const token = await getToken({
        req: request,
        secret: process.env.NEXTAUTH_SECRET,
    });

    if (!token) {
        if (
            !request.nextUrl.pathname.startsWith('/api/auth') &&
            !request.nextUrl.pathname.startsWith('/login') &&
            request.nextUrl.pathname !== '/'
        ) {
            return NextResponse.redirect(new URL('/login', request.url));
        }
        console.log('No token found, but on an allowed path');
    } else {
        console.log('Token found:', token);
    }

    return NextResponse.next();
}

export const config = {
    matcher: [
        '/((?!api|_next/static|_next/image|favicon.ico|.*\\.png$|login).*)',
    ],
};
728x90

'일기 > 개발일기' 카테고리의 다른 글

[241015] 모드버스 RTU와 TCP  (0) 2024.10.15
[241014] RS-485 Modbus RTU 통신  (1) 2024.10.14
[241008] Next.js와 nextAuth 자동화 기능  (1) 2024.10.08
[241002-3] Next.js signIn의 redirect  (0) 2024.10.02
[240930] Next.js 인증  (2) 2024.09.30

+ Recent posts