토이 프로젝트 [File Drive] 회고
이번에는 파일 드라이브라는 클라우드 웹을 만들었다. 지난 토이 프로젝트 회고때 말한 것 처럼 이번 토이 프로젝트는 스프링 부트를 이용해 백엔드 서버를 만들고 리액트로 프론트를 만들었다.
"파일 드라이브" 라는 걸 만들기로 결심한 이유는, 여느때와 같이 유튜브를 보다가 Next.js로 FileDrive라는 웹 사이트를 만드는 영상을 보았다.
맨 앞에 완성된 프로젝트가 어떻게 돌아가는 지 조금 보다가 만들어보면 재밌겠다🤔 생각이 들었고, 바로 프로젝트 구상을 했다. 저분의 기능에 폴더 관리 기능을 추가하기로 했다. 그리고, 저 영상은, 다 보게 되면 왠지 저 분의 코드 구조를 따라하고 따라 칠 것만 같아서 영상은 더 이상 보지 않았다.
구성은 스프링 부트와 MySQL를 백엔드로 하고 업로드할 파일들은 파일 스토리지 서버에 저장하는 게 파일 URL이 반환되어 파일 관리 및 개발에 용이하기에 FireBase의 Storage를 사용하기로 했다.
이번 프로젝트는 개발 기간이 좀 오래걸렸다. 아니 다시 말하면, 그냥 완료까지 시간이 걸렸다. 검지 손가락을 다치는 바람에 2주간은 손가락을 접을 수가 없어 중간에 커밋을 안하는 기간이 많이 있었다. 게다가 마지막에는 주먹구구식으로 날코딩을 하면서 프로젝트를 마무리 지었다. 그래서 처음~마지막 커밋은 2주 정도지만 실 개발 일 수는 얼마 되지 않는다.
( 지금도 붕대 못풀었지만... 손 끝 마디는 구부릴수 있다. 🥲🤕 )
# 구상
위에 유튜브 영상 화면 캡쳐된 것을 보고 UI를 구상했다.
# 라이브러리
프론트엔드
- Typescript
- Shadcn/ui 🎨
- TailwindCSS 🎨
- Zustand 💾
- TanStack (React Query) 💾
- Blocknote ✍️ : 노션처럼 생긴 깔끔한 에디터이다. 아직 베타버전이지만 사용하는데 문제없다.
- Zod : 타입스크립트 스키마 정의. CUD 작업시 전달 타입 스키마 정의할 때 사용.
- Sonner : toast 메시지 표시.
- React-hook-form: form hook
- FireBase Store
백엔드
- Java17
- Spring Boot 3.2.3
- JPA + MySQL
- Spring Security
- JWT (jjwt): Java Json Web Token
- FireBase Store
- Swagger(openapi): API 명세서
# 고충
크게 기억나는 몇 가지만 적어보았다.
V Vite
1) Cors
수년의 경험?!으로 Proxy를 설정하면 된다는 건 알고 있었다. 그래서 React에서 Proxy를 설정하는 방법을 찾아보니 여러 가지가 나왔다.
- 방법 1 : package.json에 proxy 설정
- 방법 2 : http-proxy-middleware 라이브러리 설치 후 src/setupProxy.js 에서 proxy 설정
진짜 적어도 2시간 넘게 서버 API 포트도 바꿔보고, GET URL 바꿔보고 별의별 짓을 다해봤는데 안됬다. 테스트 컨트롤러 만들어서 그냥 검색창에서 URL 호출도 해보고 curl로도 해보고 다 잘 나오는데 프론트에서 하면 안됬다. 🥲 문제의 원인도 아는데 찾아도 답이 안나오는 이 답답함...
알고보니 Vite에서는 프록시 설정 방법이 달랐던 것이었다. (💩) Vite는 vite.config.ts 에서 설정을 해줘야 한다! [공식문서]
// vite.config.ts
export default defineConfig({
plugins: [react()],
server: {
proxy: {
"/api": "http://localhost:8080",
},
},
// ...
}
package.json 에서 하던 host, port 변경이나 proxy 설정 등은 다 vite.config.ts 에서 한다는 점 꼭 기억해야겠다. 💪
🌿 Spring boot
1) Firebase Storage 에 File 업로드는 잘 했지만 파일 다운로드 URL을 못 갖고 온다구요?
파일 객체를 서버에 넘겨주면 스프링에서는 아래 코드와 같이 해당 파일을 Storage에 올리고 생성된 파일 링크를 반환하여 디비에 넣어주도록 코드를 설계했다.
파일 링크를 반환하도록 한 이유는 전체 파일 리스트를 조회할 때 이미지의 경우 미리보기를 해줘야 하는데 그때마다 매번 FireBase 를 조회헤서 URL을 갖고 오는 건 리소스 낭비라고 생각했기 때문이었다. 그런데 getMediaLink는 내가 알고 있는 파일 다운로드 링크가 아니었다. 반환되는 URL로 접속해보면 접근 금지 메세지가 뜬다.
Anonymous caller does not have storage.objects.get access to the Google Cloud Storage object. Permission 'storage.objects.get' denied on resource (or it may not exist). |
getMediaLink에서 반환되는 URL과 실제로 내가 알아야하는 다운로드 링크의 경로는 다음과 같다.
- getMediaLink시 반환되는 경로: https://storage.googleapis.com/download/storage/v1/b/[스토리지 버킷 주소]/o/file?generation=[생성시간]&alt=media
- 다운로드 파일 경로: https://firebasestorage.googleapis.com/v0/b/[스토리지 버킷 주소]/o/file?alt=media&token=[액세스 토큰]
하지만 자바에서는 Storage에 있는 이미지(파일)의 URL을 갖고 올 수 없다! 다운로드 링크는 Client-side에서만 제공되고 Server-side에서는 제공이 안된다고 한다. signUrl로 갖고 올 수는 있으나 다운 받을 수 있는 기간을 정해놓고 URL을 생성하게 된다. 즉, 파일을 서버에 올린 후 URL을 디비에 저장해봤자 시간이 지나면 URL이 만료되었기 때문에 못쓴다.(🤦♀️) 생각지도 못한 문제였다.
결국 파일을 저장하는 디비 구조를 바꿨다. 스토리지 다운로드 링크를 저장하는 방식이 아닌 스토리지에 저장된 파일의 경로를 저장하도록 하고, 프론트(React)에서 해당 경로로 스토리지를 조회해 이미지를 불러오는 형식으로 바꿨다.
🤖 React
1) JWT 토큰 정보 저장하기
사실 토큰 정보 전체를 세션에 저장하는 건 위험한 짓일 수도 있다. 대부분 로컬 스토리지와 세션에 나눠서 저장하거나 쿠키에 저장하는 방식을 많이 구현한다.
- 쿠키에 저장
- 일반적인 쿠키 저장 방식은 XSS, CSRF 공격에 모두 취약하다.
- 하지만 httpOnly, secure, SameSite옵션을 이용하면 자바스크립트 코드 상에서 접근이 불가능하기 때문에 XSS공격에 안전하고(localStorage에 비해 안전해진다), HTTP 요청에만 포함되어 보내지므로 안전해진다.
- 자동으로 http request에 담아서 보내기 때문에 CSRF 공격에는 취약하다.
- LocalStorage + Session에 저장
- LocalStorage: 자바스크립트 코드상에서 접근이 가능하기 때문에 XSS 공격에 취약하지만, 자바스크립트 코드에 의해 헤더에 담기므로 CSRF 공격에는 안전하다.
- Session : 안전하지만 탭을 닫으면 정보가 사라진다. XSS 공격에 취약하지만 CSRF 공격에 대해서는 쿠키보다 안전하다.
하지만, 나중에 수정하기 쉽도록 토큰 갖고오는 부분을 모듈화했고 일단 세션에 다 저장해버렸다.
import { Token, TokenType } from "@/types";
const AuthToken = {
/**
* 토큰 저장
* @param data 토큰
*/
setToken: (data: Token) => {
AuthToken.removeTokenAll();
sessionStorage.setItem(TokenType.ACCESS, data.accessToken);
sessionStorage.setItem(TokenType.REFRESH, data.refreshToken);
},
/**
* 특정 토큰 조회
* @param key 토큰 키
* @returns {string} 토큰 값
*/
getToken: (key: TokenType): string => {
return sessionStorage.getItem(key) || "";
},
/**
* 토큰 전제 조회
* @returns {Token} 토큰 값
*/
getTokenAll: (): Token => {
return {
[TokenType.ACCESS]: sessionStorage.getItem(TokenType.ACCESS) || "",
[TokenType.REFRESH]: sessionStorage.getItem(TokenType.REFRESH) || "",
};
},
/**
* 토큰 전체 삭제
*/
removeTokenAll: () => {
sessionStorage.removeItem(TokenType.ACCESS);
sessionStorage.removeItem(TokenType.REFRESH);
},
};
export default AuthToken;
나중에 쿠키 저장방식으로 변경해야지 생각만하고 안바꿨다. (이 글 쓰다가 알아챘다.)
2) JWT 토큰이 만료시 Refresh 토큰으로 서버에서 정보 다시 갖고 오기
로그인시 서버에서는 Refresh, AccessToken을 프론트로 전달해 줬다. 그리고 AccessToken이 만료되면 Refresh 토큰으로 다시 서버로 부터 정보를 갱신해 오는 것으로 구성했다.
만약 A 라는 API를 호출할 때 인증이 필요한 경우 Header에 AccessToken 정보를 같이 보내고, 서버에서는 해당 Token을 확인한 후 인증 정보가 만료가 되면 인증이 만료되었고 프론트에 보내고, 프론트는 서버에 AccessToken과 Refresh Token을 같이 서버로 보내 갱신된 토큰을 세션에 저장 후 다시 A API를 호출하고 싶었다.
프론트에서 헤더에 Token을 넣고 토큰 만료시 신규 토큰 발급 후 재 조회하는 부분을 모듈로 빼는 것이 효과적일 것 같아서 "authFetch"라는 모듈을 만들었고, 인증정보가 필요한 API를 호출시 이 모듈을 호출하도록 했다.
export const authFetch = async (
url: string,
options: RequestInit,
retry?: boolean // 재귀호출인지 체크
): Promise<globalThis.Response | null> => {
// 헤더에 토큰 정보 추가
const authOptions = setAuthToken(options);
// API 호출
const response = await fetch(url, authOptions);
// 토큰 만료 및 토큰 에러 발생 → 리프레시 토큰으로 토큰 재 조회
if (!retry && response.status === 403) {
const result = await getNewToken(); // 토큰 재 조회
if (result) authFetch(url, options, true); // 다시 호출
}
// 인증 오류가 아닌 알수 없는 오류가 발생한 경우
if (!response.ok) {
toast.error(MESSAGE.E01, { id: "auth-fetch" });
return null;
}
return response;
};
인증 만료라 표시될 경우 같은 함수를 재호출(재귀호출)하도록 했다. 만일 서버쪽 문제로 계속 토큰이 만료되어 잘못하면 무한 루프에 빠질 수 있기 때문에 retry 옵션을 추가하여 한 번만 재귀호출 되도록 설정하였다.
이렇게 모듈화하면서 인증이 필요한 API 는 이 모듈을 호출하도록하였기 때문에 API 방식이 변경될 때 수정이 용이해졌기 때문에 유지보수가 편해졌다.
# 새로 알게된 점
🤖 React
1) useSearchParams 의 활용
파일명 검색을 하거나 파일 타입을 변경하는 경우 새로고침을 해도 검색어나 파일 타입을 그대로 표시하고 싶었기 때문에 상태 값으로 저장하기 보단 get parameter로 검색 정보를 표시하도록 구현하기로 했다. 현재 URL이 "/files?type=pdf" 인 경우 a가 포함된 파일명 검색을 하면 "/files?type=pdf&searchTerm=a" 이렇게 변경되길 원했다. get parameter에 타입 값과 검색어 키는 조건에 따라 있을 수도, 없을 수도 있기 때문에 동적으로 추가 해줄 필요가 있었다.
처음에는 아래와 같이 react-router-dom에 있는 useLocation과 useNavigate훅을 이용해서 구현했었다.
// 검색어 조회하는 경우
const location = useLocation();
const navigate = useNavigate();
const onSubmit = (values) => {
// 현재 URL 에서 get parameter 정보
const currentSearch = new URLSearchParams(location.search);
// 새로운 검색어 추가
currentSearch.set("searchTerm", values.searchTerm);
// 페이지 이동
navigate(`${location.pathname}?${currentSearch.toString()});
}
다른 곳에서 react-router-dom의 useSearchParams훅을 이용해서 get parameter 정보를 갖고 올 일이 있었는데, 그 때 사용하는 방식이 다음과 같았다.
const [searchParams] = useSearchParams();
이때, "이거 useState 같은 상태 관리 훅 같은데🤔?" 라는 느낌이 들어서 관련 문서를 찾아보았다. 아니나 다를까, 공식문서에도 useState와 같이 setSearchParams를 이용해서 업데이트 할 수 있다라고 명시되어 있었다.
그래서 바로 코드에 적용했다. 문서 하단에 setSearchParams 함수는 navigate와 같은 역할을 한다고 적혀 있었기 때문에 따로 navigate를 호출할 필요도 없어 코드도 더 간결해졌다.
const [searchParams, setSearchParams] = useSearchParams();
const onSubmit = (values) => {
searchParams.set("searchTerm", values.searchTerm);
setSearchParams(searchParams);
}
# 해결 못한 부분 😢
MySQL에서 Like 를 이용한 한글 검색
파일 명 검색 시 파일 명 일부만 입력하더라도 파일들이 조회도록 구현하고 싶었다. 따라서 Like를 이용해 구현했다.
// JPA Repository
Page<File> findAllByTypeAndNameContainingIgnoreCase(FileType type, String name, Pageable pageable);
SELECT * FROM 테이블명 WHERE name Like '%파일%';
영어 문자열은 잘 검색되었지만 한글은 검색이 안되었다.
방안 모색1. JPA 와 MySQL에서 찾아보기
처음엔 JPA 쪽 문제인것 같아서 인코딩 관련하여 이것저것 찾아봤지만, MYSQL에는 한글로 정상적으로 삽입은 되어 있었다. 따라서 JPA 문제는 아니라 보고, MySQL Workbench를 열어서 직접 쿼리를 날려보았다. 그 결과 MySQL 에서도 검색이 안된다.
MySQL 인코딩이 문제인가? 싶어서 character_set을 조회해봤지만 utf8mb4로 되어 있었다.
한 번, 동등(=)비교 연산자로 조회해보았는데 이건 또 검색이 잘 되었다.
SELECT * FROM file_drive.file WHERE name = "파일.pdf";
방안 모색2. GPT 에게 물어보기
혹시나 도움이 될까 싶어서 ChatGPT에 물어봤다. 그랬더니 Collcation을 utfmb4_unicode_ci 로 변경하라는 답변을 받았고, 현재 내 collection은 utf8mb4_0900_ai_ci이라서 알려준 쿼리로 변경을 실행해 보았다.
ALTER TABLE [테이블명] MODIFY COLUMN [컬럼명] VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
그 결과, Like 뿐만아니라 이젠 비교 연산자도 검색이 안되었다 ㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋ 그래서 다시 원상 복구 했다.
방안 모색3. Brew로 MySQL 서버 버전 다운그레이드해보기
최근에는 MySQL을 사용해 본적이 없었기에 혹시나 버전이 올라가면서 문제가 생겼나싶어 버전을 내려보았다. 기존에 brew를 이용해서 설치했었기 때문에 삭제한 후 brew에 존재하는 버전인 8.0을 설치해보았다. 역시나 안되었다. 😭
그래서 또 삭제하고 더 이전 버전인 5.7 버전도 설치해 보았고, 이것도 역시 안되었다. 😡 my.cnf에 인코딩 설정을 안해줘서 그런가 싶어서 5.7 버전일 때 character_set 을 조회해 보니 설정을 안해줘도 다 utf8로 되어 있었다.
지금까지 한 6시간은 고민한 것 같다.
방안 모색4. MySQL 홈페이지에서 다운받아서 설치해보기
마지막으로 brew 가 아닌 MySQL 홈페이지에 가서 직접 Community Server를 다운 받아 설치해보기로 했다. 8.0.32 버전을 다운 받아보았고 역시나 안되었다...
도무지 감을 잡지 못했고, 일단 포기했다 😥
이 글을 읽는 누군가가 방법을 알아내신다면 꼭 댓글이나 메일 부탁드립니다.🙏
# 느낀 점
🚧 Junit5와 Swagger
이전 회사에서 Junit을 사용하긴 했으나 대부분 Junit4였다. Junit5를 사용해 본 적이 거의 없었다. 게다가 문법이 많이 바뀌었다보니 정말 Service 테스트하는데 어떻게 Mock 데이터를 만들고 테스트를 해야할지 멘붕의 연속이었다. 테스트 코드 작성하다가 피똥싸는 줄 알았다. 결국엔 몇개 만들다가 포기하고 Swagger에서 API 테스트할 수 있도록 설정해버렸다. ( Swagger도 openapi로 바뀌면서 약간 문법이 바뀌었다. 이건 변수 명이 바뀐 정도라 어렵진 않아서 다행이었다. 휴 )
🪝 React-Hook-Form
이전 프로젝트때는 직접 비슷한 모듈을 만들어 사용했고, 처음으로 form hook을 사용해봤는데 편리했다. 다음 프로젝트에서는 직접 폼 훅을 만들어 보는 것도 재밌을 것 같다 : )
# 결론
손가락이 다쳐서 중간에 쉬는 기간이 길어지다보니 점점 이 프로젝트에 대한 흥미를 잃었다. 게다가 취업을 준비하면서 계속해서 서류탈락하다보니 자괴감과 점점 자신감을 잃고 있어, 더욱더 개발에 흥미를 잃었다. 그래서 끝으로 갈수록 완성에 목표를 두고 코드를 깨끗하지 못하게 작성했다. ( 비슷한 컴포넌트 반복해서 생성... ) 그래도 스프링부트를 이용해서 백엔드까지 개발했다는 점은 칭찬해주고 싶다.
그리고 일단 잠시 신규 프로젝트 개발에 집중하지 않고 언어 자체에 대해, CS에 대한 공부를 이어가려고 한다. 이번 프로젝트를 하면서 계속해서 구글링하는 내 자신을 발견했다. 정확한 매커니즘을 모른 채 개발하는 무의미한 개발은 더 이상 필요 없다고 생각한다.
다만, 이 프로젝트의 프론트엔드는 추후 짬짬이 소리소문없이 리팩토링해 나갈 것이다. ( 손가락이 근질근질할 때마다 수정해 나가야지ㅎ )
👍 잘한 점 3가지
- 스프링부트를 이용한 API 서버를 만들었다.
- JWT 를 이용한 인증을 적용했다. 🔐
- Zod와 React-hook-form을 이용해서 API 전송 전 데이터 검증🖊️을 했다.
👎 아쉬운 점 2가지
- Junit를 제대로 활용하지 못했다.
- 프론트엔드 코드가 아주 개판이다. 🐕 코드 리팩토링이 정말로 필요하다.
제가 작성한 코드에 코드 리뷰(평가)도 받고 싶은데 언제든지 제 깃헙에 남겨주세요! 환영입니다! 🫶