심심한 개발자의 취미생활

sveltekit API 요청

  • 외부 API 나 +server.ts 핸들러에서 데이터를 가져 오려면 sveltekit에서 제공되는 fetch함수를 사용한다. sveltekit에서 제공된 fetch함수는 몇 가지 추가 기능을 제외하면 기본 웹 fetch API 와 동일하게 작동한다.
  1. 서버에서 인증된 요청 가능

    • 사용자가 브라우저를 통해 페이지를 요청할 때, 브라우저는 Cookie 헤더(로그인 세션 정보 등)나 Authorization 헤더(API 토큰 등)를 함께 보냅니다. load 함수 내에서 fetch를 사용하여 앱 내부의 다른 API 엔드 포인트를 호출하면, sveltekit은 원래의 페이지 요청에 포함되었던 cookie와 Authorization 헤더를 자동으로 복사하여 새로운 요청에 활용한다.
  2. 서버에서 상대 경로 요청 가능

    • 일반적으로 API 호출시 https://api.example.com/data와 같이 전체 URL을 명시해야 하지만 sveltekit은 앱의 origin(http://localhost:5173)을 알고 있기에 fetch('/api/data')와 같이 상대 경로를 사용할 수 있다.
  3. 내부 요청 시 HTTP 오버 헤드 없음

    • 서버에서 실행되는 load 함수가 자신의 앱 내부에 있는 +server.ts API 라우트를 fetch 할 때, sveltekit은 네트워크 HTTP 요청을 거치지 않고 해당 핸들러 함수(GET, POST 등)을 직접 호출하고 그 결과를 받아 온다.
  4. 서버 사이드 렌더링시 응답이 HTML 에 포함됨

    • 서버 사이드 렌더링 과정에서 load 함수가 fetch를 실행하면 sveltekit은 응답을 생성되는 HTML 문서의 <script type="application/json">과 같은 태그 안에 텍스트 형태로 직접 삽입한다.
  5. 하이드레이션 시 데이터 재사용

    • 4번과 관련하여 브라우저가 서버로 부터 받은 HTML과 Javascript를 실행하여 하이드레이션 하는 과정에서 load함수가 실행될 시 4번 과정에서 삽입된 데이터가 있는지 먼저 확인 후 있을 경우 데이터를 활용하고 없을 경우 fetch를 실행한다.

Cookies

  • 서버의 load기능은 cookies를 가져오고 설정할 수 있지만 보안 정책으로 요청을 보내는 대상(host)이 현재 sveltekit 애플리케이션과 동일한 도메인이거나 하위 도매인 (subdomain)일 경우에만 사용자의 쿠키를 자동으로 요청에 포함시켜 전달한다.

  • sveltekit이 https://my.app.com에서 실행 된다면 아래와 같이 정책이 적용된다

    • 쿠키가 전달 되는 경우

      1. 동일한 호스트
        • fetch('/api/userinfo') / https://my.app.com/api/userinfo
        • 앱 내부의 API 호출하는 것은 신뢰할 수 있는 통신으로 간주되어 쿠키가 전달된다.
      2. 하위 도메인
        • fetch('https://api.my.app.com/data')
        • api.myapp.commyapp.com의 하위 도메인이므로, 같은 애플리메이션 인프라의 일부로 간주하여 쿠키가 전달된다.
    • 쿠키가 전달 되지 않는 경우

      1. 완전히 다른 외부 호스트
        • fetch('https://api.your.app.com/data')
        • 제 3자의 도메인에 쿠키 정보를 유출하지 않기 위한 핵심적인 보안 조치로 쿠키가 전달 되지 않는다.
      2. 상위 도메인
        • fetch('app.com')
        • 더 구체적인 하위 도메인이 아닌 상위 도메인으로의 요청은 다른 서비스로 간주되어 신뢰 경계를 벗어난 것으로 판단, 쿠키가 전달 되지 않는다.
  • 만약 외부 API에 인증 정보(API 키 등)를 보내야 한다면 수동으로 명시하여 헤더에 담아 보내야 합니다.

Headers

  • load함수가 서버에서 실행 될 때, 해당 페이지의 HTTP 응답 헤더를 설정하기 위한 setHeaders함수를 제공합니다.
  • setHeaders함수는 응답에 헤더를 제어하는 sveltekit의 공식적인 방법이지만 브라우저에서 실행시 아무런 효과가 없습니다.
  • 주요 사용 사례로는 페이지 캐싱(caching) 제어를 위한 Cache-Control 설정이다.
setHeaders({
    'Cache-Control': 'public, max-age=60'
    // 'Content-Language', 'ETag' 등
})
  • 동일한 헤더를 별도의 load함수에서 여러번 설정하는 경우 에러가 발생한다. setHeaders 함수를 사용하여 한번만 설정 할 수 있다.
  • set-cookie를 사용하여 헤더를 추가할 수 없다. 대신 cookies.set(name, value, options)를 사용한다.

Errors

  • load 함수 실행 중 오류가 발생하면 가장 가까운 +error.svelte가 렌더링 된다.
  • @sveltejs/kiterror 헬퍼를 사용하면 HTTP 상태 코드와 상태 메시지를 지정할 수 있다.
import { error } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';

export const load: LayoutServerLoad = ({ locals }) => {
	if (!locals.user) {
		error(401, 'not logged in');
	}

	if (!locals.user.isAdmin) {
		error(403, 'not an admin');
	}
};
  • 예상 하지 못한 오류가 발생한 경우 sveltekit은 서버 콘솔에 오슈 스택 트레이스를 기록하고 사용자에세는 일반적인 500 error 메시지를 보여준다. 그리고 오류를 최종적으로 처리 하기 전에 src/hooks.server.ts - handleError 함수를 호출한다.
    • handleError는 예상 하지 못한 모든 서버 오류에 대한 글로벌 오류 처리기로 이 훅을 사용하여 오류 처리 방식을 커스터 마이징 할 수 있다.

Redirects

  • 사용자를 리다이렉션 하기위해선 @sveltejs/kitredirect 헬퍼를 사용하여 3XX상태 코드와 함께 리다이렉션 위치를 지정한다.
  • error(...)와 마찬가지로 redirect(...)를 호출하면 예외가 발생하여 내부 헬퍼 함수 내부에서 실행을 쉽게 중지 할 수 있다.
import { redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';

export const load: LayoutServerLoad = ({ locals }) => {
	if (!locals.user) {
		redirect(307, '/login'); // redirect 함수는 예외 처리 블럭(try - catch) 외부에 작성해야 한다. 내부에서 사용시 catch문이 실행된다.
	}
};
  • 브라우저에서는 $app.navigationgoto 함수를 사용하여 load함수 외부에서 페이지 이동 및 리다이렉션이 가능하다.

Promise 기반 스트리밍

  • 일반적인 SSR에서는 페이지에서 요구하는 모든 데이터가 준비 될때까지 HTML 페이지 렌더링을 대기하다보니 데이터 수신 속도가 느린 데이터로 인해 페이지의 로딩이 지연된다는 문제점이 있었다. sveltekit에서는 이 문제를 해결하기 위해 server load함수에서 Promise를 직접 반환하는 것이 가능하다.
  • 동작 방식 - +layout.server.ts, +layout.svelte에서도 동일하게 작동한다.
    • 서버 (+page.server.ts)의 load 함수 내에서 여러 데이터를 요청할때 빠르고 필수 적인 데이터는 await를 사용하여 즉시 값을 얻고 느리고 부가적인 데이터는 await 없이 Promise 객체 자체를 반환한다. sveltekit은 await된 데이터만 기다린 후, 페이지의 기본 HTML을 브라우저로 전송하고 서버에서 Promise가 완료 되면 브라우저로 HTTP 스트림을 통해 데이터를 streaming 한다.
    • 클라이언트 (+page.svelte)의 페이지에서는 svelte의 내장 기능은 {#await} 블록을 사용하여 Promise를 처리한다.
// .server.ts
return {
    post: await postPromise,
    streamed: {
        comments: commentsPromise
    }
}
// .svelte
{#await data.streamed.comments}
    // 대기 중 UI
{:then comments}
    // 수신 완료 시 UI
{:catch error}
    // 수신 실패 시 UI
{/await}

병렬 처리

  • 클라이언트는 페이지 로딩 또는 이동 - 렌더링시 모든 .server.ts에 요청을 보내지 않고 브라우저에서 서버로 하나의 특별한 요청만 보낸다.
  • sveltekit은 페이지를 렌더링 하는데 필요한 모든 load 함수 들을 미리 파악하여 동시에 실행을 시작한다.
  • 실행이 완료 되면 서버는 모두 모아 하나의 큰 JSON 객체로 묶어 브라우저로 응답한다.

`load`함수 의존성 자동 추적

  • sveltekit은 각 load함수가 어떤 데이터에 의존하는지 자동으로 추적한다. 그리고 네비게인션이 발생했을 때, 의존하는 데이터가 변경된 load 함수만 다시 실행한다.
  • sveltekit은 load 함수의 인자로 전달되는 객체들의 사용을 지켜보며 의존성을 파악한다.
    • params 객체
      • load함수가 param.id를 사용했다면 이 load함수는 id 파라미터에 의존하게 되며 URL 의 id값이 변경될때 다시 실행된다.
      • 만약 param 객체 전체를 사용했다면 내부 데이터 하나라도 변경될시 다시 실행된다.
    • url 객체
      • load함수가 url.searchParams를 사용했다면 URL 의 쿼리 스트링이 변경될 때 다시 실행된다.
      • url.pathname을 사용했다면, 경로가 변경될 때 다시 실행된다.
    • parent 함수
      • load함수가 await parent()를 호출했다면, 부모 load함수의 결과 데이터에 의존하게 되어 부모 load함수가 다시 실행되면 결과 값이 바뀌면 다시 실행된다.

종속성 추적 해제

  • untrack()함수는 sveltekit의 자동 의존성 추적 시스템에서 의도적으로 특정 의존성을 제외 하고 싶을떄 사용한다.
  • untrack()함수는 콜백 함수를 인자로 받는데 untrack의 콜백 함수 내에서 접근하는 모든 의존성은 sveltekit의 의존성 추적기에서 무시 된다.

수동 의존성 관리 - `depends()`, `invalidate()`

  • URL에 나타나지 않는 쿠키에 저장된 테마 정보나, 폼 제출(action)을 통해 변경 되는 데이터에 의존하는 경우 sveltekit은 수동으로 의존성을 관리할 수 있다.
  • depends()invalidate()는 하나의 쌍(구독과 발행)으로 함께 사용되며 수동 의존성 관리가 올바르게 동작한다.
  • depends(식별자)
    • load함수 내에서 호출하며, load함수가 특정 데이터에 의존한다고 명시적으로 표시한다.
    • depends('app:theme')depends('data:posts')와 같이 자유롭게 정할수 있다.
  • invalidate(식별자)
    • 주로 +page.svelte컴포넌트 내부나 action의 결과로 호출되며 invalidate('app:theme')가 호출되면, depends('app:theme')를 사용했던 모든 load함수들은 다음 네비게이션 시 강제로 다시 실행된다.

`load`함수가 다시 실행되는 경우 정리

데이터 로딩시 인증 측면에서의 고려사항

  • sveltekit에서는 +layout.server.tsload 함수에 인증 로직을 넣는 것만으로는 충분하지 않다.
    1. layout의 load함수는 항상 실행되지 않는다.
      • load함수는 의존하는 데이터가 변경되지 않으면 성능을 위해 재실행을 하지 않는다. 만약 사용자의 세션이 만료되었더라도, load함수가 재실행되지 않으며 인증 체크 로직을 건너뛰게 되어 보호된 페이지나 데이터에 접근하는 보안 사고가 발생할 수 있다.
    2. load함수는 병렬로 실행된다.
      • +layout.server.tsload가 인증 체크를 실패했을 시 redirect를 throw 하지만 동시에 +page.server.tsload가 실행을 시작하며 redirect가 완전치 처리 되기 전에 데이터베이스에서 민감한 정보를 조회할 수 있다.

올바른 인층 처리 전략

  1. Hooks 사용 (권장)

    • 모든 서버 요청에 대해 라우팅 및 load함수가 실행되기 전에 실행되는 중앙 미들웨어 src/hooks.server.ts파일의 handle 함수를 사용한다.
      • handle 함수 내에서 사용자의 쿠키를 확인하여 인증 상태를 확인한 후, 보호된 경로에 접근하는데 인증되지 않았다면, load가 실행되기 전에 redirect를 throw하여 처리한다. 인증 되었다면 사용자 정보를 event.locals에 담아 이후 실행될 load함수나 API 엔드 포인트에서 쉽게 사용할 수 있도록 한다.
    • 장점
      • 중앙 집중 - 인증 로직이 한 곳에 모여 관리가 편하다.
      • 완벽한 보호 - 모든 요청에 대해 가장 먼저 실행 되므로, 기존 문제점을 모두 원칙적으로 차단한다.
      • 효율성 - event.locals를 통해 사용자 정보를 한번만 조회하고 재사용할 수 있다
  2. +page.server.ts에서 직접 확인

    • 보호가 필요한 모든 +page.server.tsload함수 시작 부분에서 직접 인증 체크 로직을 넣는다.
    • 장점 - 어떤 페이지가 보호되는지 매우 명시적이고, 특정 페이지에만 특별한 권한을 부여할 경우 유용하다.
    • 단점 - 코드 중복과 유지보수가 복잡해진다.
  3. +layout.server.ts + await parent()

    • +layout.server.ts에 인증 체크 로직을 넣고, 그 아래 모든 자식 +page.server.ts들이 load함수 시작 부분에서 await parent()를 반드시 호출 하도록 강제한다.
    • 장단점 - 설명 안한다

getRequestEvent 사용

  • getRequestEvent는 sveltekit에서 제공하는 고급 기능으로, 서버 측 코드의 어느 곳에서나 현재 요청에 대한 RequestEvent 객체에 접근할 수 있게 해주는 함수이다.

  • 일반적으로 RequestEvent 객체는 hooks.server.tshandle 함수나 +server.ts의 핸들러, +page.server.ts의 loadactions 함수의 인자로 직접 전달된다. 하지만 때로는 이렇게 전달 받은 event 객체를 여러 함수를 거쳐 깊숙한 곳에 있는 유틸리티 함수나 모듈까지 전달해야 하는 prop drilling 문제가 발생하는에 이러한 문제를 해결하기 위해 getRequestEvnet 가 사용된다.

  • 제약 사항

    • 서버 전용 : 서버측 코드에서만 사용할 수 있다.
    • 요청 생명 주기 내에서만 유효 : 요청 처리의 동기적인 실행 흐름 내에서만 호출되어야 하며 setTimeout이나 setInterval의 콜백 함수 처럼 요청 컨텍스트가 사라진 비동기적인 환경에서는 null이 반환된다.
// src/lib/server/auth.ts
import { redirect } from '@sveltejs/kit';
import { getRequestEvent } from '$app/server';

export function requireLogin() {
	const { locals, url } = getRequestEvent();

	// assume `locals.user` is populated in `handle`
	if (!locals.user) {
		const redirectTo = url.pathname + url.search;
		const params = new URLSearchParams({ redirectTo });

		redirect(307, `/login?${params}`);
	}

	return locals.user;
}
// +page.server.ts
import { requireLogin } from '$lib/server/auth';

export function load() {
    // 유저의 로그인 상태 확인
	const user = requireLogin();

	// `user` is guaranteed to be a user object here, because otherwise
	// `requireLogin` would throw a redirect and we wouldn't get here
	return {
		message: `hello ${user.name}!`
	};
}