Springboot와 Next.js

서론

처음으로 풀스택 웹앱을 개발해보게 되었다.

기록용으로 남기면 나중에 봤을 때 재밌을 것 같아
시작부터 맨땅에 헤딩을 하며 만든 경험을 정리하고 기록으로 남겨보려고 한다.

기술 스택


Frontend

shadcn/ui

shadcn-avatar
shadcn-avatar

일단 눈에 뭔가 보여야 한다는 생각이 있었다.
하지만 css에 아예 잼병이라 뭔가 빠르게 만들 수 있는
라이브러리가 있을까 찾아보았고
이런 상황에서 도입을 했던 것이 shadcn/ui 였다.

shadcn/ui는 tailwindcssradix-ui 를 기반으로 만들어진 cli 툴이다.
drop-in한 방식으로 라이브러리 형태가 아니라
컴포넌트 자체를 프로젝트 자체에 로드해 커스텀해서 사용할 수 있다.

컴포넌트 자체가 내 소유인듯한 느낌이 드는게 색달랐고
tailwindcss가 클래스 네이밍에 대한 고민을 없애줌에 따라
정말 빠른 프로토타이핑이 가능했다.

Next.js외에도 tauri 데스크탑이나 크롬 익스텐션에 도입해서 써보고 있는데
특별한 경우를 제외하고는 매우 잘 작동한다 앞으로도 자주 쓸 듯 하다

Next.js

이 프로젝트를 시작할 즈음에는 이 짤이 유행하던 시점이었다.

nextjs-server-action
nextjs-server-action

React server component가 도입되고
(next.js 코어팀이 대거 참여했다 react코어팀을 족족 영입했기 때문)

그러다보니 next.js에도 변화의 바람이 불어
page => app router 기반의 구조로 상당한 변화가 있었다.

위 짤은 이 변화의 시점 이후 버전 14를
nextjs.conf에서 발표하면서 번지기 시작한 짤이다.
(use-php 같은 밈 프로젝트가 만들어지기도..)

지금와서야 생각하면 의미없는 고민이고 시간낭비라고 보이지만
아무것도 모르는 시점의 상태에서는 민감하게 다가왔다.
난 프로젝트를 완성시키는 게 중요했다.

만약 내 능력으로 해결할 수가 없고, 헬프콜을 칠 상대도 없고,
해당 이슈는 우선순위에서 밀리고, pr은 머지되지 않고
이러면 내가 마무리할 수 있을까?
그냥 page router를 선택하는게 맞지 않을까?

안그래도 실력이 없는데 이런 것까지 겹치면 프로젝트를 던질까봐 두려웠다.
하지만 그럼에도 불구하고 app-router 방식을 택했고
지금은 전혀 후회하지 않는다.
server, client 컴포넌트를 분리하며 구조가 짜내지는 쾌감은 이루 말할 수가 없다.

그렇다고 현재 내가 next.js의 강력한 피쳐들을 모두 사용하고 있는 건 아니다.
dub 과 같은 프로젝트를 보니까 내 코드는 초라하기 짝이 없다.
더 발전시킬 지점이 많이 남은 것 같다.

NextAuth

nextauth-logo

🚀 NextAuth.js is becoming Auth.js

Auth.js로 rebranding을 진행하고 있다.
오픈소스 중에서도 마이그레이션 하는 issue 가 보이곤 한다

일단 signin / signup이 구현돼야 그다음이 있다.
Authentication / Authorization에 대한 플로우가 정립이 안되어 있으면
이후에 피쳐를 개발할 때 혼선이 너무 생길 것 같았다.

이것 역시 가장 빠르게 완성시키고자 next-auth를 선택했다.
도입을 할 때 큰 걱정을 안했다. 처음에 언뜻봐도 자료가 너무 풍부했기 때문이다.

하지만 내 생각은 오산이었다. 현재 리팩토링 및 제거 1순위 대상이다.
정확히는 내 능력의 문제일 확률이 거의 99%인데
문제는 주제넘게 이걸 선택했다는 것이다.

해결하지 못한 Refresh Token 버그

BUG : Tokens rotation does not persist the new token

백엔드 사이드의 문제인 줄 알고 트러블 슈팅이 지연됐다.
요약하면 서버에는 새롭게 갱신된 refresh token이 제대로 담기는데
(실제로 DB 테이블에서 확인가능)
클라이언트 사이드에서는 제대로 토큰을 저장하지 못하고 있는 것.

그 결과 무조건 불일치가 발생하며 validation이 실패하고
계속 internal server error가 들어오는 상황이다.

현재 어쩔 수 없이 refresh를 포기하고 아래와 같이
update trigger와 setInterval을 통해 access token만 무효화하고 있다.

// [...nextauth]/route.ts
if (trigger == "update") {
    if (Date.now() < token.accessTokenExpires!) {
      return token;
    } else {
      token.name = "invalid-token";
    }
}
 
// Auth.tsx
export default function Auth({ children }: { children: React.ReactNode }) {
  const { data: session, update } = useSession();
  useEffect(() => {
    const interval = setInterval(() => {
      void update();
      if (session?.user!["name"] == "invalid-token") {
        void signOut();
      }
    }, 1000 * 3);
    return () => clearInterval(interval);
  }, [session?.user, update]);
 
  return <>{children}</>;
}

Turborepo

turborepo-icon

현재 개발중인 프로젝트는 chrome extension이 중요한 역할을 한다.
웹은 대시보드 기능이 전부인 상태이고 사실 익스텐션이 핵심적인 피쳐다.

둘다 shadcn을 사용하고 있는 상황이라 모노레포를 구성하기로 결심했고

pnpm dlx create-turbo@latest

위 템플릿을 최대한 베껴서 구성했다.
개발 경험이 정말 좋아졌다.
이전 같은 경우 웹, 익스텐션, 백엔드 3개를 개발하기 위해

에디터 3개를 열어두고 작업해야했는데
이제는 2개면 충분하다.

Vercel 배포

vercel-logo

눈물을 흘렸다. 배포를 그냥 거저로 하게 해준다.

이 모든 것을 제공해준다.
해야하는 거는 config 파일 작성이 끝.

cloudfront + s3에 배포해서 삽질하면서 이게 캐싱이 되는 건지
제대로 배포는 된 건지 전전긍긍했던 시간이 주마등처럼 스쳐지나가며
감사함을 느꼈다.


Backend

Spring security

spring-security-icon

Spring security버전이 6으로 마이그레이션되면서
대부분의 자료는 deprecated기준으로 설명하고 있던 것이 가장 큰 어려움이었다.

github 레포지터리에 키워드 검색하고 docs마이그레이션 가이드를 뚫으면서
하나하나 만들어 완료했다.

생소한 단어들과 개념들이 부담스러웠었는데
지금와서 다시보면 볼수록 정말 코드가 예쁘게 짜여지도록
잘 설계했다는 느낌을 받는다.

config는 물론이고 각 user별 도메인과 service가 완벽히 분리되고
인터페이스로 서로 상속받기도 쉬워서 개발속도가 말도 안되게 빨라지도록 해줬다.

현재 서비스는 OAuth2 provider들이 아닌
자체적으로 회원가입/로그인을 진행하고 있다.
(설계 미스 후회 중이다)

잠시 살펴본 거라 틀릴 수도 있겠지만 이렇게 한번 셋업해두면
그냥 provider에서 api secret key만 발급받았을 때
security config와 약간의 entity 수정만 진행하면 새로운 로그인 방식에 있어서 확장성이 한계가 없어보인다.

추후에 fastapi나 golang으로 백엔드를 구성할 때
이 구조를 잘 써먹을 수 있겠다는 느낌을 받았다.

😭 Docker 사용을 잠시 보류하다

docker-icon

최소한의 기능을 구현하고 배포를 준비하기 시작했다.
Dockerfile (nginx, springboot)과 docker-compose 파일을 작성했다.

그래서 사실상 ec2로 가서 docker setup만 하면 한방에 배포가 끝나는
모든 준비를 끝내놓은 셈이었다.

두근거리는 마음을 가지고 ssh로 접속해 docker-compose up을 실행했다.

그런데 진행률 98%에서 멈춰버리는 것이다. 그래서 ..

하지만 애석하게도 되지가 않았다.
이유는 뻔하지만 메모리 부족 문제였다.
docker를 위해서는 최소 2GiB의 메모리가 필요하다.
하지만 ec2 t2.micro는 1GiB밖에 안된다.

swap memory를 이용해서 우겨넣는 workaround가 있는 거 같았으나
주어진 머신환경에서 성능을 포기하고 싶지 않았다.

그래서 일단 바이너리로 올리고
추후에 유저가 어느정도 확보됐을 때 도입하는 플로우를 선택했다.
이제와서 생각해보면 어떻게 이런 사양에서 docker를 쓸 생각을 한 건지
어이가 없긴 하다.

아무튼 docker 세팅은 그래도 다해놓았으니
추후에 마이그레이션 하는 거는 전혀 문제 없어보인다.

wsl2의 ssh-server를 통해 홈서버를 돌리는 방법도 고려중이다.

이전에 x86-64 칩셋 환경 + CLion ssh를 사용하기 위해
충분한 사양을 가진 머신이 필요해서
집에 있는 윈도우 노트북에
공유기, WOL, DDNS, ssh-server, firewall, proxy 셋업을 해뒀다.

모든 셋업이 되어있는 상태라
DAU가 높지 않다면 셋업하는게 어렵지 않을 것으로 보인다.
다만 키 관리에 주의를 해야하니 vault 같은 셋업로 추가로 해야할 것 같다.

만약 홈서버를 돌리게 된다면 글을 남겨볼 생각이다.

Hibernate의 ddl-auto와 DB migration (Liquibase)


liquibase-icon

마법처럼 뭔가 이루어지던 것이 갑자기 무너지기 시작할 때 가장 무섭다.

# application-dev.yml
 
spring:
  jpa:
    defer-datasource-initialization: true
    hibernate:
      ddl-auto: create-drop
  sql:
    init:
      mode: always
문제를 인지하기 전의 application dev profile

초반에 initialization data가 필요해
resource에 init.sql을 넣어두고 진행했었다.

그리고 배포할 때도 처음에는 크게 걱정이없었다.

이게 전부일 거라고 생각했다.

이렇게 생각했던 이유는 당시에 entity가 2개밖에 없어서
사실상 ER Diagram이 내 머릿속에 들어와있는 상태이고
내가 만든 스키마는 완벽해서 변하지 않을 거라고 생각했기 때문이다.

하지만 1차로 배포를 하자마자 문제점과 개선사항이 여럿 발견되었는데
이게 entity를 건드려야 하는 문제라는 걸 알았을 때
문제를 비로소 심각하게 인지하기 시작했고 db migration을 도입하게 되었다.

검색결과 가장 많이 나오는 결과는 flyway 였다.
가장 도움이 된 글은 강남언니 기술블로그의

위 글 이었는데 서두에 이런 내용이 나온다.

... 1년이 지난 지금 선택하라면 Liquibase 로 할 것 같기는 합니다.

그냥 믿음으로 liquibase를 선택했다.
flyway를 선택하면 확실히 수월할 것 같긴 했으나
그래도 이런 퀄리티의 글을 쓴 개발자의 혜안이라면
적어도 나보다는 몇배는 통찰이 있을테니 믿기로 했다.

위와 더불어 liquibase의 github issue, pr 개수가 더 많이 열렸던 것도
선택의 이유로 작용했다.

개발자다운 기술스택 판단은 아니었던 것 같다.
스스로 반성하고 있다.
그래도 운이 좋았는지 liquibase를 선택한 거는 최고의 판단이었다.

작업 flow

위와 같은 작업을 통해 첫번째 배포이후로도 성공적으로
entity를 수정하고 배포할 수 있게 되었다.

Axios Network Error


axios-icon

클라이언트 단에서 Axios 를 사용해 아래와 같이
http request를 처리하고 있었다.

// apiClient.ts
import { publicRuntimeConfig } from "@/next.config";
import Axios from "axios";
 
export const apiClient = Axios.create({
  baseURL: `${publicRuntimeConfig?.API_URL}`,
});

백엔드를 nginx와 연결시켜 배포해둔 상태였고
cors 및 inbound rule도 모두 정상적으로 셋업해둔 상태였다.

vercel로 클라이언트를 배포하고 실제로 테스트를 해보는데
콘솔 탭에 아래와 같은 에러가 발생했다.
dev환경에서는 발생하지 않던 문제였고 프로덕션에 올라가니까 문제가 생겼다.

Mixed Content:
The page at 'https://<client_domain>/dashboard' was loaded over HTTPS,
but requested an insecure XMLHttpRequest endpoint
'http://<server_domain>/video/user'.
This request has been blocked; the content must be served over HTTPS.

요약하자면 현재 페이지가 https로 로드되었는데
fetch를 하는 엔드포인트는 http로 되어있어서
request를 block시키고 있는 상황이다.

ec2가 부여해준 ipv4 주소

http://<ec2_ipv4_address>/<api_endpoint>

이거를 계속 쓰고 싶었다.
도메인을 1개만 관리하고 싶었기 때문이다.

그래서 서브도메인을 사용하는 방안을 고려했으나
도메인을 구매한 곳에서 클라이언트 도메인의 네임서버를
vercel로 지정해둔 상태라 ec2로 요청을 보낼 수가 없었다.

어쩔 수 없이 싼마이 날먹 도메인 1,000원짜리를 구매해서 이슈를 해결했다.

Nginx 세팅 : sites-available, sites-enabled, conf.d


nginx-icon

첫번째 결론부터 얘기하자면 file system의 inode와 symlink라는 개념을 몰라서
약간 헤멨다. (알고보니까 운영체제 시간에 배우더라)

두번째 결론은 sites-available, sites-enabled가 표준이 아니라는 것이다.

Avoid sites-available & sites-enabled At All Costs

납득이 가는 것이 sites-available, sites-enabled로 설정을 하게 될 경우
다음과 같은 흐름을 거쳐야한다.

  1. sites-available에 설정파일을 작성한다
  2. sites-enabled에 1에서 작성한 파일에 대해 symlink를 생성한다
  3. default symbolic link가 있다면 삭제한다

위 과정을 docker를 쓴다면야 코드로 제어할 수 있으니 괜찮은데
지금 나처럼 docker를 사용하지 않는다면 휴먼에러가 발생하기 쉽다.

이런 지점들이 처음 했을 때 발생하는 시행착오들이라고 생각한다.
역시 직접해봐야만 알 수 있는 것들이 너무도 많다.



마무리

아직 해결해야하고 남아있는 문제들이 있다.
이를 테면 다음과 같은 것들이다.


개발 하면서 많은 걸 느꼈고 새롭게 도전할 수 있는게 많이 남아있는 사실이 즐겁다.
글을 자주 써야겠다고 생각했는데 학기 중에 도무지 시간이 안났다.
방학에 글 4개이상은 꼭 포스팅 해보고 싶다.