본문 바로가기

WINK-(Web & App)/WINK 공홈 부수기

[WINK 공식 홈페이지] Next.js 백엔드 정리 💫 [backend / 손대현]

반응형

서론

우리 팀은 기존 Front-End만 존재하는 프로젝트를 엎는 것이 아닌, 기능 추가 및 업데이트 방식으로 프로젝트를 이어나가기로 했다.

 

일단, 기존 프로젝트의 스펙은 Next.js 13.1.6 버전을 사용하였는데, 2023년 10월 26일에 출시한 Next.js 14 버전으로 업데이트하고자 하였다.

 

Next.js의 전반적인 지식은 백엔드 팀원인 유건 선배님이 작성하신 Next.js 블로그 읽어보자.

나는 많은 Next.js의 기능 중 백엔드에 집중하여 소개하고자 한다.

React의 백엔드

Full-Stack Framework 인 Next.js와 다르게 React는 Front-End Framework 이기 때문에 백엔드 코드를 React에서 작성하면 안 된다.

물론 억지로 작성할 순 있지만, 보안적인 정보(DB Credential, etc...)가 클라이언트에 노출된다.

 

그래서 백엔드가 필요하다면 외부에서 다른 프레임워크(Spring Boot, Express.js, etc...)를 사용하여 프로젝트를 따로 관리한다.

이때 외부의 백엔드 서버의 Endpoint를 React의 axios나 fetch 등 HTTP Request를 통해 백엔드 서버와 통신할 수 있다.

// Back-end Project
@Controller
@RequestMapping("/api/user")
public class UserController {
    
    @PostMapping
    public String login(@RequestBody LoginRequestDto request) {
    	String id = request.getId();
        String pw = request.getPw();
        
        return service.login(id, pw);
    }
}

 

// Front-end Project
const LoginPage() => {
  const [id, setId] = useState<string>('');
  const [pw, setPw] = useState<string>('');

  const onLogin = async (e) => {
    e.preventDefault();

    const { username } = await fetch('/api/user', { id, pw });

    alert(`Hi ${username}`);
  };

  return (
    <div>
      <form action={onLogin} >
        <input value={id} onChange={(e) => setId(e.target.value)} />
        <input value={pw} onChange={(e) => setPw(e.target.value)} />

        <button type="submit">로그인</button>
      </form>
    </div>
  );
}

 

하지만 이 과정이 조금 번거로운데, 일단 백엔드 개발자 입장에서는 API의 Endpoint들과 사용법을 기술해 놓은 API 명세서를 작성해야 하고, 여러 보안적인 문제(CORS, CSRF, etc...)도 고려하여야 한다.

PHP의 백엔드 (고전)

혹시 예전에 웹개발을 해본 경험이 있다면 PHP를 사용해 본 적이 있을 것이다.

PHP는 웹 엔진에서 HTML과 통합되어 바로 해석할 수 있는 서버 사이드 스크립트 언어이다.

 

위 React처럼 Front-End, Back-End를 따로 관리할 필요가 없이, 하나의 php 스크립트에서 모든 작업을 할 수 있다.

<?php
    $id = $_POST['id'];
    $pw = $_POST['pw'];

    $conn = new mysqli();
    
    // 아이디 비밀번호 검증 로직
    
    if ($result->num_rows > 0) {
        $_SESSION['id'] = $id;
    }
?>

<body>
    <h1>로그인</h1>
    <form method="POST">
        <div>
            <label for="id">ID:</label>
            <input type="text" id="id" name="id" />
        </div>

        <div>
            <label for="pw">PW:</label>
            <input type="password" id="pw" name="pw" />
        </div>

        <input type="submit" value="로그인">
    </form>
</body>

제목과 내용을 작성한 후 버튼을 누르면, 동일 페이지에 POST요청이 가고, 이 POST 요청은 php가 받아서 DB에 저장하는 로직을 가진다.

물론, 위 <? php?> 안에 내용은 클라이언트에게 노출되지 않고, 서버에서만 해석된다.

과거 Next.js(<14)의 백엔드

위 "React의 백엔드"와 비슷하다.

Next.js는 하위 폴더 /api/* 에 있는 파일들을 자동으로 라우팅 해준다.

만약 Endpoint /api/user를 만들고 싶다면, 아래와 같이 라우팅을 해줄 수 있다.

// /api/user.ts

import type { NextApiRequest, NextApiResponse } from 'next'

interface ResponseData {
  username: string
}

export default function handler(
  req: NextApiRequest,
  res: NextApiResponse<ResponseData>
) {
  if (req.method === 'POST') {
    const { id, pw } = req.body;

    // 아이디 비밀번호 검증 로직

    res.status(200).json({ username: 'Daehyeon' });
  }
}

이를 실제 페이지 혹은 컴포넌트에서 사용하기 위해서는 HTTP Reqeust를 날려줘야 한다.

const LoginPage() => {
  const [id, setId] = useState<string>('');
  const [pw, setPw] = useState<string>('');

  const onLogin = async (e) => {
    e.preventDefault();

    const { username } = await fetch('/api/user', { id, pw });

    alert(`Hi ${username}`);
  };

  return (
    <div>
      <form action={onLogin} >
        <input value={id} onChange={(e) => setId(e.target.value)} />
        <input value={pw} onChange={(e) => setPw(e.target.value)} />

        <button type="submit">로그인</button>
      </form>
    </div>
  );
}

위 코드를 보면 알겠지만, 이렇게 함으로써 얻는 이득이 거의 없다.

handler 하나로 모든 요청을 제어해야 하기도 하고, 다른 백엔드 프레임워크에 있는 다양한 기능들을 사용할 수 없거나 구현해줘야 한다.

컴포넌트에서 fetch를 통해 HTTP Request를 날려야 한다면, 그냥 더 강력하고 유연한 백엔드 프레임워크(I love Spring...)를 쓰는 것이 좋아 보인다.

최신 Next.js( ≥14)의 백엔드

위 "PHP의 백엔드"와 비슷하다.

위 단점은 Next.js가 14 버전으로 업그레이드된 후, Server Action이 정식 기능으로 도입되면서 조금이나마 완화되었다.

(물론, Server Action이 Next.js 13.4 버전 때 알파 버전으로 공개가 되었다.)

 

기존 HTTP Request의 과정을 없애고 일반 프로젝트처럼 다른 파일을 import 하여 바로 사용할 수 있게 됐다.

이때, 이 안에 코드는 클라이언트로 노출되면 안 되기 때문에, 파일 최상단에 'use server'를 작성하여 서버에서만 동작하도록 해줘야 한다.

 

따라서 위 로그인 예제는 아래처럼 바꿀 수 있는 것이다.

interface IFormData {
  id: string,
  pw: string,
}

const Home = () {
  const onLogin = async (data: IFormData) => {
    'use server';

    // 로그인 로직 구현
    const { username } = ~~~;

    return username;
  };

  return (
    <div>
      <form action={onLogin} >
        <input name="id" />
        <input name="pw" />

        <button type="submit">로그인</button>
      </form>
    </div>
  );
}

그럼, onLogin 부분은 서버에서만 실행이 되므로 DB작업 등 서버 액션을 취할 수 있게 된다.

 

이제 더 이상 Next.js에서 API를 위해 라우팅 하는 게 아닌, 서버 사이드 액션들을 진짜 그 '액션'만 모아놓고 사용할 수 있다.

SSR 환경에서만 Server Action을 사용할 수 있나?

사실 이 문제 때문에 많은 고민을 해보았다. 이 부분은 필자의 주관적인 견해이다.

(정확한 답을 아는 사람이 있다면 알려주면 압도적 감사하겠습니다...)

 

일단, 위 코드를 SSR에서 실행해 봤다. 이는 당연하게도 정상적으로 작동하였다.

하지만 나는 React에 아직 익숙했기에, 위 코드에서 useState를 사용하려 했는데, 이는 서버 컴포넌트에서 사용할 수 없었다.

 

따라서 컴포넌트 최상단에 'use client'를 붙여 CSR임을 알려주었는데, 이 결과도 신기하게 정상적으로 작동하였다.

(물론 액션 최상단에는 'use server'를 붙였고, 서버에서 실행됨을 확인했다.)

// /action/LoginAction.ts
'use server';

export interface IFormData {
  id: string,
  pw: string,
}

export const LoginAction = async (data: IFormData) => {
  // 로그인 로직 구현
  const { username } = ~~~;

  return username;
};

 

// /app/components/LoginComponent.tsx
'use client';

import { LoginAction } from "@action/LoginAction";

export default function LoginComponent() {
  return (
    <form action={LoginAction}>
      <input name="id" />
      <input name="pw" />

      <button type="submit">로그인</button>
    </form>
  );
}

 

클라이언트에서 렌더링 되는데 서버 액션이 포함된다..? 머릿속이 복잡했다.

조금 더 탐구하기 위해 네트워크 패킷을 확인하였는데, 동일 호스트로 POST 요청을 주고받았다.

그 payload는 우리가 액션 함수 인자로 받아야 할 key와 일치하였다.

 

따라서 나는 아래와 같은 결론을 내 혼자서 내렸다...

"그냥 액션들 생긴 것이 저래 보여도! Next.js 내부에서 알아서 라우팅 하여 처리해 주구나!" (아닌가...?)

반응형