728x90
반응형

HLS

  • HLS: HTTP Live Streaming
    • 콘텐츠를 작은 세그먼트로 나누어서 .m3u8 파일과 .ts 세그먼트 파일로 구성함
    • 즉 비디오를 2-10초 정도의 작은 .ts(Transport Stream)으로 나누게 됨
    • m3u8 파일이 모든 세그먼트와 재생 정보를 관리하는 익덱스 파일
    • 적응형 비트레이트: 네트워크 상태에 따라 다양한 화질 선택 가능
  • m3u8 형식 혹은 .ts 파일이 hls 스트리밍에 사용되는 파일 형식

예시

manifest.m3u8 (메인 인덱스 파일)
|- quality_high.m3u8 (고화질 인덱스)
|  |- segment0_high.ts
|  |- segment1_high.ts
|  |- ...
|- quality_medium.m3u8 (중간 화질 인덱스)
|  |- segment0_medium.ts
|  |- ...
|- quality_low.m3u8 (저화질 인덱스)
   |- segment0_low.ts
   |- ...

FFMPEG 이용한 m3u8 파일 변환

  • 여러개의 .ts 파일과 .m3u8 파일이 생김
    • m3u8 파일이 각 ts 파일에 대한 인덱스 역할을 함
ffmpeg -i <video_file>.mp4 -profile:v baseline -level 3.0 -start_number 0 -hls_time 10 -hls_list_size 0 -f hls <output_file>.m3u8

HTTP Streaming vs HLS

주요 차이점

  1. 적응성
    • HLS: 네트워크 상태에 따라 화질을 자동으로 조정
    • HTTP 스트리밍: 단일 화질만 제공, 적응형 기능 없음
  2. 지연 시간
    • HLS: 일반적으로 10-30초 지연 발생
    • HTTP 스트리밍: 버퍼링 시간만큼의 지연
  3. 대역폭 효율
    • HLS: 현재 네트워크 상태에 맞는 최적의 화질 제공
    • HTTP 스트리밍: 고정 화질로 네트워크 상태가 좋지 않으면 버퍼링 발생
  4. 서버 부하
    • HLS: 여러 화질의 세그먼트를 미리 생성해야 함
    • HTTP 스트리밍: 단일 파일만 제공하므로 서버 부하 낮음
  5. 구현 복잡성
    • HLS: 인코딩, 세그먼트화, 인덱스 파일 관리 등 복잡한 과정 필요
    • HTTP 스트리밍: 단순히 파일을 제공하는 것만으로 가능
  6. 확장성
    • HLS: CDN과 연계하여 우수한 확장성 제공
    • HTTP 스트리밍: CDN 활용 가능하나 적응형 기능 부재
  7. 호환성
    • HLS: iOS, Safari에서 네이티브 지원, 다른 브라우저는 추가 라이브러리 필요
    • HTTP 스트리밍: 대부분의 브라우저에서 네이티브 지원

간단한 비유

  • HLS는 도서관에서 책의 각 장을 필요할 때마다 가져오는 것과 같음
  • 일반 HTTP 스트리밍은 책 전체를 한 번에 빌려오는 것과 같음
728x90
반응형
728x90
반응형

계기

  • 이전에 만들었던 프로젝트들을 리팩토링 중, 검증 방법이 헤더의 Authorization 으로 토큰 검증을 하게 해 뒀던 걸 발견했다.
  • 뭐 잘못된 방법은 아니지만, 쿠키를 활용해서 검증하는 방법으로 변경하였다.

개요

  • 로그인 요청을 받았을 때, 이메일/패스워드 정보를 검증 후 유저 정보를 Redis 서버에서 체크한다.
    • Redis 서버에 해당 정보가 조회될 경우, 새로운 환경에서 로그인 요청을 한 것으로 판단한다.
      • 이전 로그인은 종료시키기 위해, 해당 정보를 삭제한다.
    • 조회된 정보가 없다면 이전 로그인은 만료된 것이므로 무시한다.
  • 이후 새로운 accessToken과 refreshToken을 생성한다.
    • refreshToken을 상위 키로, 하위 키로는 유저 id 로 삼아 로그인 정보를 세팅한다.
      • 키: refresh:{refreshToken}:{userId}
  • 해당 키들을 응답 쿠키에 각각 도메인, 만료시간, 토큰, secure, httpOnly 를 세팅한다.
    • accessToken의 만료 시간은 한시간, refreshToken의 만료 시간은 일주일로 세팅한다.
  • 이후 로그인 및 유저 정보가 필요한 요청은 미들웨어에서 토큰 검증 및 데이터 추출하여 사용한다.
  • 만료시간이 끝난 경우, 토큰 갱신 요청을 날려서 새로운 access token을 발급 받는다.

코드 구현

  • 백엔드는 golang, 프론트는 nextjs app router를 사용하였다.

백엔드

  • 주요 로직은 주석처리, 토큰 세팅 및 쿠키 세팅하는 로직만 유지
// 컨트롤러가 아닌, 비즈니스 로직을 담은 서비스
func LoginService(request LoginRequest, response http.ResponseWriter, request *http.Request) {    
    /* 
        DB 에서 유저 정보 조회 및 패스워드 매칭
    */

    // Access Token 생성
    token, tokenErr := auth.CreateAccessToken(userData.UserId, uuid.String(), userData.Email, fmt.Sprintf("%d", userData.UserStatus), fmt.Sprintf("%d", userData.IsAdmin), time.Hour)

    if tokenErr != nil {
        log.Printf("[LOGIN] Create Token Error: %v", tokenErr)

        return LoginResponse{
            Status:  http.StatusInternalServerError,
            Code:    "ULG09",
            Message: "Create JWT Error",
        }
    }

    // Refresh Token 생성
    refreshToken, refreshTokenErr := auth.CreateRefreshToken("",, time.Hour*24*7)

    if refreshTokenErr != nil {
        log.Printf("[LOGIN] Create Refresh Token Error: %v", refreshTokenErr)

        return LoginResponse{
            Status:  http.StatusInternalServerError,
            Code:    "ULG09",
            Message: "Create JWT Error",
        }
    }

    // Redis 서버에서 유저 정보 조회 - 이전 로그인 만료 여부 체크
    userDataFromRedis, getRedisErr := GetRefreshUserInfoFromRedis(refreshToken, userData.UserId)

    if getRedisErr != nil {
        return LoginResponse{
            Status:  http.StatusNotFound,
            Code:    "ULG08",
            Message: "Get User Data from redis Error",
        }
    }

    // 유저 정보가 조회되었을 시
    if (userDataFromRedis != LoginUserInfo{}) {
        // 조회된 Redis 정보 삭제
        delerr := DeleteAlreadySetToken(refreshToken, userData.UserId)

        if delerr != nil {
            return LoginResponse{
                Status:  http.StatusInternalServerError,
                Code:    "ULG08",
                Message: "Delete Already Set Data from redis Error",
            }
        }
    }

    // Redis 서버에 새로운 로그인 정보 세팅
    setErr := SetRefreshToken(refreshToken, userData.Email, fmt.Sprintf("%d", userData.UserStatus), userData.UserId, userData.UserName, fmt.Sprintf("%d", userData.IsAdmin))

    if setErr != nil {
        return LoginResponse{
            Status:  http.StatusInternalServerError,
            Code:    "ULG10",
            Message: "Set JWT Error",
        }
    }

    accessTokenCookie := http.Cookie{
        Name:     "accessToken",
        Value:    token,
        Path:     "/",
        Secure:   true,
        HttpOnly: true,
        SameSite: http.SameSiteNoneMode,
    }

    refreshTokenCookie := http.Cookie{
        Name:     "refreshToken",
        Value:    refreshToken,
        Path:     "/",
        Secure:   true,
        HttpOnly: true,
        SameSite: http.SameSiteNoneMode,
    }

    http.SetCookie(response, &accessTokenCookie)
    http.SetCookie(response, &refreshTokenCookie)

    return LoginResponse{
        Status:       http.StatusOK,
        Code:         "0000",
        AccessToken:  token,
        RefreshToken: refreshToken,
        Message:      "Login Success",
    }

프론트

  • App Router의 Server Side Component를 이용해서 쿠키 및 토큰을 관리한다.
    • 로그인 응답으로 토큰을 받고 서버 사이드 쿠키에 세팅한다.
export async function POST(request: NextRequest) {
  // const cookieStore = cookies();

  // 로그인 데이터 확인 - 요청 조건 검증
  const loginSchema = z.object({
    email: z.string().email(),
    password: z.string().min(6).max(16),
  });

  try {
    const _loginData = await request.json();
    const loginData = await loginSchema.parseAsync(_loginData);

    const loginResponse = await login({ ...loginData });

    if (!loginResponse.ok) {
      return NextResponse.json({ message: 'unauthorized' }, { status: 401 });
    }
});

    const response = <LoginResponse>(await loginResponse.json());

    if (response.status !== 200) {
      return NextResponse.json({ message: 'Login Failed' }, { status: 302 });
    }

    // 브라우저에 리턴할 쿠키 설정
    const ONE_HOUR = 60 * 60 * 1000;
    const ONE_WEEK = 60 * 60 * 1000 * 24 * 7;

    const res = NextResponse.json({ message: 'Login Success' }, { status: 200 });

    // 쿠키 설정
    res.cookies.set('accessToken', response.accessToken, {
      secure: true, // 프로덕션 환경에서는 true로 설정
      httpOnly: true,
      domain: process.env.NEXT_PUBLIC_COOKIE_DOMAIN,
      expires: new Date(Date.now() + ONE_HOUR),
    });

    res.cookies.set('refreshToken', response.refreshToken, {
      secure: true,
      httpOnly: true,
      domain: process.env.NEXT_PUBLIC_COOKIE_DOMAIN,
      expires: new Date(Date.now() + ONE_WEEK),
    });

    return res;
  } catch (error) {
    console.log(error);
    return NextResponse.json({ message: 'server error' }, { status: 500 });
  }
}
  • 서버 사이드 컴포넌트. 받은 쿠키들을 이용해 이후 요청을 한다.
    • 만료시 토큰 리프레시를 한다.

export async function POST(request: NextRequest) {
    try {
        let tokenCookie = request.cookies.get("accessToken");
        let refreshToken = request.cookies.get("refreshToken");

        let cookie: string;
        let refreshCookie: string;

        if (!tokenCookie) {
            /** 
                refresh token 갱신 요청 로직
            */

            const ONE_HOUR = 60 * 60 * 1000;
            const ONE_WEEK = 60 * 60 * 1000 * 24 * 7;

            cookieStore.set('accessToken', response.accessToken, {
                secure: true,
                httpOnly: true,
                path:"/",
              //   domain: process.env.NEXT_PUBLIC_COOKIE_DOMAIN,
                expires: new Date(Date.now() + ONE_HOUR),
              });

              cookieStore.set('refreshToken', response.refreshToken, {
                secure: true,
                httpOnly: true,
                path:"/",
              //   domain: process.env.NEXT_PUBLIC_COOKIE_DOMAIN,
                expires: new Date(Date.now() + ONE_WEEK),
              });


            cookie = response.accessToken;
            refreshCookie = response.refreshToken;
        } else {
            cookie = tokenCookie.value;
            refreshCookie = refreshToken.value;

            // 기술통계 요청 데이터 확인
            const exampleScheme = z.object({
                // 요청 검증 필드
            });

            const _exampleData = await request.json();
            const exampleData = await exampleScheme.parseAsync(_exampleData);

            const exampleResponse = await example({ ...exampleData }, cookie, refreshCookie);

            if (!exampleResponse.ok) {
                return NextResponse.json({ message: 'unauthorized' }, { status: 401 });
            }

            const dataResponse = <ExampleResponse>await exampleResponse.json();

            const response = NextResponse.json(dataResponse);

            return response;
        }
    } catch (error) {
        console.log(error);
        return NextResponse.json({ message: 'server error' }, { status: 500 });

    }
}
  • Middleware를 적용하여, 쿠키가 없을 시 로그인 페이지로 자동 리디렉션 시킨다.
import { NextFetchEvent, NextMiddleware, NextRequest, NextResponse } from "next/server";

// 인증 없이 방문 불가 페이지
const PROTECTED_ROUTE: string[] = [
  // 페이지 리스트들
];

export type MiddlewareFactory = (middleware: NextMiddleware) => NextMiddleware;

export const authMiddleware: MiddlewareFactory = (next: NextMiddleware) => {
  return async (request: NextRequest, event: NextFetchEvent) => {
    // URL 에서 언어 값 분리
    const [ , , ...segments ] = request.nextUrl.pathname.split('/');

    const cookies = request.cookies.get("accessToken");

    // 체크사항
    // 1. 로그인 유무
    // 2. 인증 없이 방문 불가 페이지
    const isProtecteRoute = PROTECTED_ROUTE.includes(request.nextUrl.pathname);

    if (!cookies && isProtecteRoute) {
      return NextResponse.redirect(new URL('/login', request.url));
    }

    if (cookies && isProtecteRoute) {
      if (cookies.value === "") {
        return NextResponse.redirect(new URL('/login', request.url));
      }
    }
    // 인증 성공, 통과
    return next(request, event);
  };
};
728x90
반응형

'기록 > 사이드 프로젝트' 카테고리의 다른 글

[NEXTJS] 사이드 컴포넌트 쿠키 처리  (0) 2025.01.22
[OAUTH] 구글 OAuth  (0) 2025.01.02
728x90
반응형

Nextjs App Router 서버 사이드 요청

  • Nextjs의 App Router를 통해 서버사이드에서 요청 날리게끔 구성되어 있다.
  • 요청에 쿠키를 담아 서버쪽에서 검증 받는 흐름이지만, 쿠키가 계속 안 들어왔다.
    • credentials: "include" 인데도 들어오지 않는 것에 의문을 가졌다.
  • 결론은 서버 사이드에서의 요청일 경우 직접 쿠키를 담아줘야 한다는 것이다.

쿠키 양식

"Cookie": "accessToken=tokendata; refreshToken=refreshToken"

변경된 코드

  • 헤더에 직접 입력
  • API 라우트에서 쿠키를 뽑아와 인자로 던짐

클라이언트 사이드 요청 함수

  • react query를 이용해 요청 및 관리
  • 이 때는 쿠키를 담을 수 없다.
    • 클라이언트 사이드에서는 쿠키를 가져올 수 없음. 브라우저 범위이기 때문
export interface ReferenceListResponse {
    status: number;
    code: string;
    message: string;
    result: ReferenceListItem[];
    totalCount: number;
}

export interface ReferenceListItem {
    referenceSeq: string;
    referenceName?: string;
    referenceUrl: string;
    referenceMemo?: string;
    referenceCategory: string;
    createdAt: string;
}

export const referenceListRequest = async (props: ReferenceListRequest) => {

    const response = await jsFetch<ReferenceListResponse>(`/api/reference/list`, {
        method: 'post',
        cache: 'no-store',
        body: JSON.stringify(props),
        headers: {
            'content-type': 'application/json',

        },        
    });

    return response;

};

서버 사이드 요청 함수

export interface ReferenceListRequest {
    category: string;
    page: number;
    pageSize: number;
}


export const referenceList = async (request: ReferenceListRequest, accessToken: string, refreshToken: string) => {

    const response = await fetch(
    `${ process.env.NEXT_PUBLIC_BASE_API }/reference/list`, {
        method: 'post',
        cache: 'no-store',
        body: JSON.stringify({ category: request.category, page: request.page, pageSize: request.pageSize }),
        headers: {
            'content-type': 'application/json',
            "Cookie": `accessToken=${ accessToken }; refreshToken=${ refreshToken }`
        },
        credentials: 'include', // 쿠키를 포함해서 요청 전송
        next: { tags: [ 'search-word' ] },
    });

    return response;
};
728x90
반응형

'기록 > 사이드 프로젝트' 카테고리의 다른 글

[AUTH] 검증 방법 변경 - Http Only Cookie  (0) 2025.01.31
[OAUTH] 구글 OAuth  (0) 2025.01.02

+ Recent posts