오늘의 명언
[이전글] 기능구현 정리본 ①
포스트의 목적 및 참고사항
각 포스트의 순서는 '기능 개요 → 구현 과정 → 마무리(혹은 회고)' 형식으로 진행됩니다.
- 해당 포스트는 나만의 명언집 프로젝트를 만들면서 구현한 기능을 어떤 과정을 거쳐서 만들어졌는지를 기록하는 문서로서 역할을 합니다.
- 해당 포스트 이전에는 기능구현 정리본① 에서 작성되었지만, 크기가 커짐에 따라 기능구현 정리본② 로 이어 작성됩니다.
- 해당 포스트는 새로 추가되는 정보가 있는 경우 최근 날짜로 갱신 됩니다.
- 해당 포스트에서 구현된 기능의 코드 퀄리티는 보장할 수 없습니다.
- 매번 코드 리팩터링과 기능 확장이 이어짐에 따라 실제 코드와는 코드의 구조나 기능의 동작 방식이 차이가 날 수 있습니다.
- 해당 포스트의 언어체는 '~ 였다.' 형식으로 작성됩니다.
해당 프로젝트에 사용된 언어 및 프레임워크는 Typescript 5.3.3 , NextJS 14.1 을 사용하였다. 백엔드에서 동작하는 모든 코드도 타 백엔드 언어가 아닌 NextJS 를 사용하여 구현 되었습니다. 참고로 NextJS 의 백엔드 환경은 NodeJS 환경 기반으로 리액트 서버 컴포넌트(RSC)에 따라 리액트의 JSX 문법을 서버 측 환경에서도 사용할 수 있도록 되어 있습니다.
[기능 구현] 북마크 기능
(개요) 북마크 기능은 기본적으로 사용자가 웹 사이트의 링크를 저장하고, 필요할 때 해당 링크를 통해 원하는 콘텐츠에 쉽게 접근할 수 있도록 하는 접근성 향상을 위한 기능이다.
(기능 목적) 현 프로젝트에서는 북마크 기능을 활용하여 사용자가 자신이 저장한 명언 카드에 접근할 수 있도록 편의성을 제공하기 위해 추가 하기로 결정하였다.
북마크 기능 로직(알고리즘)
북마크 기능 중 추가 기능의 구현 단계를 구분하면 다음 처럼 5 가지로 나뉜다.
① 사용자가 명언 북마크 버튼을 클릭한다.
② 해당 명언의 id 와 사용자의 accessToken 이 서버로 전달된다.
③ 서버에서는 accessToken 을 검증하여 유효한 토큰인 경우 페이로드에 담긴 사용자의 id 를 반환한다.
④ 해당 사용자의 id와 명언 id 와 일치하는 데이터가 데이터베이스에 존재한다면 이미 추가된 목록임을 Response 객체에 담아서 응답한다.
⑤ 문제가 없다면, 북마크 리스트 테이블에 해당 명언 정보를 저장한다.
따라서 해당 순서에 따라서 북마크를 추가하는 기능을 구현해볼 것이다.
[참고] 사용된 주요 도구
참고로 북마크 기능을 구현하기 위해 사용된 프론트엔드의 라이브러리는 NextJS 에서 제공하는 SWR 이라는 상태관리 라이브러리을 적용하였고, 백엔드에서 데이터를 저장하고 가져오는데 사용도되는 데이터베이스는 Postres 를 활용하였다.
[프론트엔드] 로직 작성
북마크 기능 자체는 별게 없다. 사용자가 버튼을 클릭하면 클릭한 유저의 accessToken 과 해당 아이템의 식별자(id) 를 쿼리 스트링이나 경로 파라미터 형식으로 전달해주기만 하면 된다. 나머지는 서버에서 로직을 처리하므로 프론트엔드에서 할 일은 사용자가 사용하기 좋은 UI 를 만들어 주고, 해당 UI 를 이용하여 fetch 요청을 하는 로직을 작성해두기만 하면 기능 구현 자체만 본다면 끝이다.
이를 염두에 두고 각각 그 구현 과정을 정리해본다.
① 구현하기 | fetch 요청 함수 구성 정의하기
일단 디자인은 모두 구현되어 있다고 가정하고, 사용자가 버튼을 클릭 시 동작시킬 함수를 정의해야 한다.
본인의 경우에는 요청하는 fetch 함수를 다음과 같이 정의했다.
여기서 defaultFetch 는 fetch 요청 로직이 중복되고 재사용되는 경우가 많아서 모듈화한 것이고, defaultConfig 의 경우에도 같은 이유로 정의하였다. defaultFetchm의내부 로직은 서버에서 데이터를 받아오면 내부적으로 try ~ catch 등의 예외처리를 하고나서 문제가 없으면 meg, success, result 등의 데이터를 반환한다.
Method.POST 의 경우에는 Method 라는 enum 타입을 정의하여 내부적으로 POST 로 접근하면 'POST'를 반환하도록 되어 있다. 이 외에도 나머지 REST API 요청 메서드 정보가 Method 에 정의되어 있다
② 구현하기 | 버튼 UI 구현
현재 북마크 추가 등의 명언 카드에 대한 컨트롤 버튼들은 QuotesCardControlButtons 컴포넌트에 모두 모아 뒀는데, 해당 클릭 이벤트의 경우도 동일 컴포넌트 내에 정의하였다. 컴포넌트 이름이 너무 길어서 수정할 필요가 있어 보인다.
우선 북마크 추가 버튼의 경우 다른 버튼 요소들과 드롭다운이 되면 모달 형태로 나타나게 구현 하였다. (참고로 마우스가 해당 영역을 벗어나면 모달이 닫히도록 처리되어 있다.)
③ 구현하기 | onClick 이벤트 호출을 위한 함수(이벤트 핸들러) 정의
함수의 경우에는 결국 앞서 정의된 fetch 함수를 호출하기 위한 용도인데, 그 이전에 fetch 내부에서 처리하기에는 책임이 집중되는 것을 피하기 위해서 일부 로직을 서로 분산하여 처리하도록 로직을 구성하였다.
item 의 경우에는 현재 렌더링되고 있는 명언 카드의 정보를 가지고 있는 객체인데, 내부적으로 해당 명언의 식별자 정보를 담고 있다.
hasToken 의 경우 현재 사용자가 로그인한 상태인지 아닌지를 검증하기 위한 커스텀 훅이다. 내부적으로 JWT 인지 유효성을 검증하는 정규표현식이 있고, 만일 스토로지에 문자열이 존재하더라도 토큰 형식이 아니면 1차적으로 로그인 유지가 아님을 걸러내기 위한 역할을 한다.
addBookmarkItem 은 앞서 정의한 fetch 함수를 랩핑하고 있는 함수로서 명언 id 를 전달 받아서 해당 정보를 서버에 전달하는 역할을 한다.
그리고 내부적으로 return 으로 반환되는 success (→ 페치 성공여부에 대한 boolean 값) 를 통해 setIsUpdate 를 호출하도록 하고 있는데, 여기서 setIsUpdate 에 전달되는 true는 향후 명언 목록을 서버에서 조회시 swr 이 해당 변경사항을 즉시 반영하여 갱신하도록 하는 Zustand를 사용한 전역 상태로 활용된다.
여기 까지만 하면 프론트엔드에서 처리해야 할 작업은 모두 끝났다. 나머지는 백엔드에서 해당 정보를 넘겨 받아서 데이터의 유효성을 검증하고, 데이터베이스에 해당 정보를 저장하는 작업을 수행해야 한다.
[백엔드] 로직 작성
이제는 브라우저의 뒷단에서 사용자 모르게 작업을 처리하는 백엔드에서 기능 구현을 정리해본다.
참고로 프론트엔드에서 백엔드 api 서버에 접근하기 위한 url 은 'api/bookmark' 로 접근한다.
① 구현하기 | 데이터 받아오기
우선적으로 POST 요청 시 받은 데이터를 꺼내와야 한다. NextJS(14.1) 에서는 NextRequest 객체의 .json() 메서드를 사용하여 body 객체에 접근할 수 있으므로 해당 방식을 사용하여 데이터를 꺼내온다.
body 객체는 말그대로 POST 요청이 클라이언트 단의 fetch 함수의 body에 임시 저장한 데이터를 담고 있는 객체이다.
② 구현하기 | accessToken 유효성 검증
북마크를 추가한 사용자가 방문자인지 아니면 실제 회원인지를 검증하기 위해서는 로그인 시 사용자에게 발급한 accessToken 을 검증해야 한다. accessToken 내부에는 sub(사용자 식별자) 가 들어 있는데, 이 속성은 꺼내 쓰기 위해서는 우선 아래와 같이 검증을 통과해야 한다.
tokenVertify 는 내부적으로 해당 토큰이 accessToken 인지 refreshToken 인지 1차적으로 검증하고, 각 토큰에 따른 별도의 검증 절차를 실시한다. accessToken을 검증해서 토큰 형식이 맞는지, 만료된게 아닌지, 빈 값이 아닌지 등을 검사하고 통과하지 못하면 상태코드와 메시지 등을 반환하고, 통과하는 경우에는 해당 토큰을 해독하여 페이로드 객체를 반환한다(참고로 accessToken 생성에 사용된 라이브러리는 JsonWepToken 을 활용한다).
③ 구현하기 | user 객체로 부터 sub 반환 받고, 해당 아이템의 존재 유무 검사하기
앞서 토큰 검증 후 반환받은 user 객체에는 아래와 같이 sub 속성이 존재한다. sub 에는 사용자가 로그인을 요청할 때 해당 사용자를 식별하는 id 값이 저장되어 있고, 이 값을 활용해서 해당 사용자가 요청한 아이템이 기존 데이터베이스에 존재하는지 검사하는 것이 이번 세 번째 단계이다.
앞서 sub 는 userId 변수에 할당하여 사용하였다. 이제는 기존 데이터베이스에 해당 명언이 유저가 이미 가지고 있는지 체크하기 위해 쿼리를 작성하는데, user_id 와 quote_id 가 동일한 로우가 조회되는 경우에는 NextResponse 객체를 사용하여 이미 북마크에 추가된 카드이므로 수정할 내용이 없다는 의미로, 304 HTTP 코드와 같이 응답해주었다.
④ 구현하기 | 북마크 아이템을 데이터베이스에 저장하기
마지막으로 북마크 아이템을 저장하는 로직을 작성한다. 이 부분도 앞서 데이터베이스 조회한 방식과 별차이가 없다. 이 쯤되면 기본적인 CURD 를 활용한 작업은 특별할게 없다는 사실을 알 수 있다.
구현 결과(마무리)
전체적으로 아래와 같은 과정을 거쳐서 로직을 작성하였다. 아래 예시 이미지에 나온 코드는 코드 리팩터링을 통해서 변경될 수 있는 부분으로 참고용으로 보면 좋을 듯 하다.
사실 해당 기능 구현 이후에 로직 자체에 문제가 많다고 판단해서 대대적인 기능 확장 및 리팩터링이 진행되었다. 이에 대한 정리본은 이 링크 를 타고 들어가면 확인할 수 있다(해당 링크의 포스트 설명은 높임말을 사용하고 있습니다. 포스트 마다 통일성이 다름에 따라 혼란을 드려서 미리 죄송합니다.).
[마무리] 이번 구현에서 어려웠던 점
북마크 기능을 구현하면서 제일 어려웠던 점은 사용자가 추가한 북마크 목록이 즉시 갱신되도록 하는 부분이었다. 사실 위 구현에서는 이에 대한 이슈를 어떻게 해결하였는지 나오지는 않았지만, 해당 부분은 트러블슈팅1, 트러블 슈팅2 로 나눠서 두 차례에 거쳐 기능 개선과 확장, 리팩터링이 이루어졌다.
처음에는 로직 자체가 단순해 보여서 쉽겠지라고 생각했으나, 전역 상태관리, 재유효화(캐싱 문제) 등이 얽혀 있어서 까다로운 여정이었다. 그래도 해당 기능을 구현하고, 문제에 직면하고, 해결해 나가면서 새롭게 알게된 지식들이 있었기에 재밌고 값진 경험이었다.
[기능 구현] 프로필 이미지 업로드 기능 ① | 이미지 미리보기 기능
(기능 목적) 사용자가 프로필 이미지를 등록할 때 미리볼 수 없다면, 자신이 어떤 이미지를 올렸는지 식별하기 어려울 것이다. 따라서 해당 불편사항을 개선하기 위한 목적으로 해당 기능을 도입하기로 하였다.
(개요) 이번 구현 기능은 로그인 한 사용자가 프로필 이미지를 등록하면, 미리 보기를 보여주고, 수정하기를 클릭하면 해당 프로필이 업데이트되도록 하는 기능 전반에 대한 것이다.
(참고) 현재 본인의 프로젝트에서 사용자의 프로필은 회원가입 이후 마이페이지의 프로필 수정 탭에서 가능하도록 되어 있다.
(참고) 현재 구현되는 기능은 총 두 가지로 구분된다. 하나는 이미지 미리보기 기능이고, 다른 하나는 업로드 후 보여주는 기능이다. 우선은 프로필 이미지를 미리보기 하는 기능 부터 구현한다.
[기능1] 프로필 이미지 미리보기 로직
프로필 이미지 미리보기 로직은 다음과 같이 이루어지지 않을까 짐작해본다.
① 사용자가 프로필 이미지 업로드 버튼을 클릭한다.
② 사용자가 업로드 하는 파일이 이미지 형식인지 아닌지 확인하고, 아니라면 해당 형식이 아님을 알린다.
③ 사용자가 업로드한 이미지 파일의 크기가 500KB 를 넘어선다면 해당 파일을 업로드할 수 없음을 알린다.
④ 2와 3의 조건이 달성된 이미지 파일을 올리면, 해당 이미지를 미리보여준다.
① 구현하기 | 업로드 함수 구현을 위한 구조 설정
입력하는 창을 만드는 것은 기본적인 사항이므로 넘어가기로 하고, 바로 함수 구현으로 넘어간다.
우선 본인이 작성한 로직에서 래퍼가 되는 부분은 아래와 같다.
간략하게 보고 넘어가면, imagePreviewReader 함수에 이벤트 객체를 넘겨주면, 해당 함수 내부에서 image 의 유효성을 검사하고 모든 검사를 통과하는 경우 최종적으로 src 를 반환한다. 그 후 imageUploader 함수를 호출하여 파이어베이스에 이미지를 업로드하는 동작까지 같이 수행한다.
해당 함수는 구현된 이후에 자식 컴포넌트의 prop 으로 전달되고, 자식 컴포넌트 내부에서는 onChange 이벤트에 등록된다. 만일 아래와 같이 onChange = {onChange} 형식으로 지정하는 경우 해당 onChange 함수의 첫 번째 인자는 event 객체를 자동으로 전달받게 된다.
↓
input 태그의 type 속성에 "file" 을 지정하게 되면, 파일 업로드와 연관된 이벤트 정보가 event 객체에 남게 되고, 해당 객체에 접근하여 사용자가 업로드한 이미지 정보를 접근할 수 있다.
② 구현하기 | 미리 보기 함수 구현 (1)
일단 구현된 함수는 다음과 같다. 엄청 복잡한 코드도 아니고 미리보기에 필요한 기능은 브라우저의 이벤트 객체에서 모두 지원해주기 때문에 활용만 하면 될 뿐이다.
현재 위 코드의 경우에는 별도의 파일 검사를 하지 않고 있다. 이대로 사용한다면, 프로필 이미지에 필요한 파일 크기를 크게 벗어나게 되고, 이미지 형식이 아닌 파일이 업로드 될 수 있다.
따라서 해당 문제를 개선하기 위해서는 파일 크기, 이미지 형식, 이미지 크기에 대한 유효성을 검사하는 코드를 추가해야 한다.
③ 구현하기 | 이미지 타입 체크하기 함수 구현
앞서 초기 버전의 코드는 등록된 프로필 이미지의 유형에는 아무런 제한이 없는 상태이므로 mp4, txt. json, jpg 등등 어떠한 파일이든 모두 업로드가 가능하였다. 따라서 해당 유형(타입)을 제한하여 프로필 이미지에 쓰이는 일부 타입을 제외하고는 업로드 자체가 되지 않도록 처리해야 한다.
아래 코드는 imageTypeChecker 라는 함수명으로 이미지 타입을 체크 한다는 것을 알 수 있다. 해당 함수는 imageType 이라는 인자를 받는데, 해당 이미지의 타입은 File 객체의 프로퍼티인 .type 에 접근하면 확인할 수 있다.
const file = e.target.files[0] || ''
const { type: imageType, size } = file || { type: null }
const hasType = imageTypeChecker(imageType)
imageTypeChecker 함수내부에는 아래와 같이 types 배열을 생성하여 제한하고자 하는 타입의 유형을 요소로 가지고 있다. 또한 해당 types 배열은 includes 함수를 사용하여 해당 배열 내에 imageType 이 존재하는 경우 true 를 반환한다.
④ 구현하기 | 이미지의 파일 크기 유효성 검사 함수 정의하기
이번에는 이미지의 파일 크기를 측정하여 일정 크기를 벗어나는 경우 이후 로직을 처리하지 않도록 하는 함수를 정의한다.
해당 함수의 이름은 imageFileSizeChecker 로 정의하였고, 이미지 파일의 크기(size) 정보를 담고 있는 size 를 인자로 받아서 매개변수로 전달한다.
const isValidFileSize = imageFileSizeChecker(size)
size 또한 FIle 객체의 프로퍼티인 .size 으로 접근하여 얻을 수 있는 값으로, 업로드 된 파일의 크기를 바이트(byte) 단위로 담고 있다. 여기서 size 크기를 512,000 으로 잡았는데, 그 이유는 현재 프로젝트에서 프로필 이미지를 제한하고자 하는 크기는 500KB 이고, 1KB 는 바이트 단위로 1024Btye 가 되므로 500 * 1024 를 연산한 결과가 512,000 이기 때문이다. 즉, 해당 크기를 벗어나면 false 를 던지도록 로직을 구성하였다.
⑤ 구현하기 | 이미지 사이즈 유효성 검사 정의하기
이번에 구현하는 함수는 이미지의 크기 (즉, 이미지 넓이와 높이)가 특정 범위를 벗어 나는지 체크하는 함수이다.
우선 앞서 검사를 모두 통과한 경우에만 createObjectURL 를 호출하여 file 객체로 부터 blod 타입의 url 주소를 반환 받도록 한다.
그리고 해당 src 는 imageSizeChecker 라는 함수의 인자로 전달한다.
아래는 해당 함수를 구현한 결과인데, Promise 내부에 이미지의 인스턴스를 생성( new Image( ) ) 하고, 해당 이미지 인스턴스의 src 속성에 매개변수로 전달받은 src 를 할당하였다.
여기서 생성된 이미지의 인스턴스인 tempImg 내에 할당한 src 는 load 이벤트를 호출하지 않는 경우에는 조회가 불가능하다 .그 이유는 src 에 할당된 이미지를 업로드 하는 동작은 비동기적으로 이루어지기 때문이다.
따라서 load 이벤트를 호출하여 DOM 이 모두 렌더링 된 이후에 tempImg 의 속성을 확인해보면 생성된 img 요소를 볼 수 있다.
단, 여기서 주의할 점은 load 이벤트 자체도 비동기적으로 동작하기 때문에, load 이벤트 내부에서 특정 유효성 로직을 처리하고 그 결과를 이벤트 외부의 변수에 할당 후 반환하려고 하면, 반환된 값이 해당 변수에 들어오지 않는다.
즉, 다음과 같은 상황이 된다.
function imageSizeChecker(){
let isValidImg:boolean;
tempImg.addEventListener('load',()=>{
const {width, height} = tempImg
if(width<=230) {
return isValidImg = true
}
return false
})
console.log(isValidImg) // 값이 할당되기 전 사용되었다는 에러가 발생
}
이 문제를 해결하려면 해당 함수의 내부 로직 자체를 비동기로 처리하면 된다. 즉, Promise 를 활용하는 것이다. Promise 는 이행, 보류, 거절 이라는 세 가지 상태를 가지는데, Promise 내부에 정의되어 호출되는 비동기적 로직들이 처리되고 제대로 실행이 되었다면 resolve() 가 호출되어 보류에서 이행 상태로 변환되고, 그게 아니면 reject() 를 호출하여 에러를 발생시킨다.
즉, 다시 돌아와서 load 이벤트 내부에서 유효성 검사를 진행하고, 그 결과에 따라서 ,resolve 를 호출하면 Promise 는 보류 상태에서 이행 상태로 변경되고, 그 결과를 다시 Promise 형태로 반환한다. 그러면 해당 결과를 async ~ await 키워드를 사용하여 true/ false 로 반환받을 수 있게 된다.
즉, 기존의 래퍼(외부) 함수 내부에서 imageSizeChecker 함수를 호출하면 그 처리 결과가 isValidImageSize 변수에 할당된다.
[ 참고 ] 그 외 함수 구현에 사용된 각 옵션에 대한 설명이 필요하다면?
여기서 주요하게 바라봐야 할 부분은 e (= event 객체) 이다. 이를 출력해보면 다음과 같이 파일 업로드와 관련한 다양한 속성들을 확인할 수 있는데, 그 중에서 e.target.files 에 접근하면 사용자가 업로드한 파일에 대한 정보를 리스트 형태로 조회할 수 있다.
위 이미지에서는 files 내부에 아무런 파일 정보가 보이지 않는데, 이는 보안상 보이지 않도록 처리되어 있어서 이다. 하지만 e.target.files 로 직접 접근하면 아래와 같이 파일 정보를 확인할 수 있다.
그리고 e.target.files[0] 로 접근하면 FIle 객체에 접근할 수 있는데,
해당 File 객체를 URL 주소로 변환해주는 함수(정확히는 DOMString 으로 바꿔주는 것) 가 바로 URL.createObjectURL 이다. 정확히는 BLOB 타입의 파일이나 MediaResource 타입(e.target.files[0] 에 있는 File ) 의 파일을 BLOB 형태의 URL 로 변환해주는 기능을 수행한다.
즉, 이를 활용해서 e.target.files[0] 로 접근한 파일을 file 변수에 담고, URL.createObjectURL(file) 형태로 작성해서 출력해보면 출력해보면 어떤 형태로 반환해주는지 확인해볼 수 있다.
↓
createObjectURL 로 생성된 blod 타입의 URL 은 현재 로드된 DOM 이 존재하는 동안 유효하며, 이를 반환하여 img 태그나 NextJS 에서 제공하는 Image 컴포넌트의 src 프롭의 값으로 전달하면, 해당 컴포넌트가 살아 있는 동안(마운트 동안) 업로드한 이미지를 미리보기 할 수 있다.
사실상 여기까지만 하면 나머지 부분은 반환된 boolean 값으로 에러를 던지거나 하는 부분이므로 더 이상 언급하지 않을 것이다. 이것으로 미리보기 기능 구현을 완료 하였다.
[미리보기 기능] 구현결과
현재 프로필은 230 x 230 을 벗어나거나, 이미지 형식이 아닌 경우는 업로드하지 못하도록 설정하였는데, 그 시연 결과를 나타낸 것이다.
[참고] MDN 공식 문서에서는 URL 의 정적 메서드를 아래와 같이 설명하고 있다.
[마무리] 이번 구현에서 어려웠던 점 등
부족했던 부분을 다시 한 번 알게된 계기
이미지 미리보기 기능의 경우에는 예전에도 구현해본 경험이 있기에 그 과정 자체는 어렵다고 여기지는 않았으나, 예전에 아무것도 모르던 시기에는 유효성 검사라는 것을 별도로 추가하지 않아서 어떠한 형식의 파일이라도 업로드가 되었기에 문제가 많았다.
따라서, 이번 기능의 구현은 과거 부족했던 점을 분석해보고, 이를 반영하여 만든 기능이었기에 유의미한 시간이었다고 생각된다.
비동기적 처리에 대한 기초가 흔들리면 발생하는 일
이번 구현에서 어려웠던 점을 하나 뽑자면, Promise 을 활용하여 비동기적인 처리를 어떻게 직렬화할 것인지에 대한 부분이었다. 앞서 유효성 검사 기능 중에 이미지 사이즈의 유효성을 검사하는 부분이 있는데, 해당 로직에는 원래 Promise 를 사용하지 않았다. 그래서 해당 이미지의 사이즈를 측정하기 위해 load 이벤트가 호출되고, 그 동안에 상위 스코프에 있는 변수가 미리 사용되어 반환됨으로써 기대하는 결과를 얻지 못하는 문제가 발생했었다.
사실 이 부분은 비동기적 처리에 대한 지식이 부족했기에 생긴 문제였으므로 이에 대한 부분을 다시 복습하고 나서 해결할 수 있었다. 이런 점에서 본다면, 기본은 아무리 강조해도 지나치지 않는다는 사실을 많이 느꼈다.
[기능 구현] 프로필 업로드 기능 ② | 파이어베이스 스토로지 활용 이미지 업로드 기능
이번에 구현한 기능은 파이어베이스 스토로지를 활용한 이미지 업로드 기능이다. 해당 기능 구현에 사용된 파이어베이스 스토로지는 기본적으로 5GB 까지 무료로 공간을 사용할 수 있고, 하루 1GB 의 요청을 무료로 처리해주므로 간단한 앱을 구현하는 데에는 이것 만큼 가성비 좋은 옵션은 없다. 따라서 해당 도구를 활용해서 프로필 업로드 기능을 구현해본다.
환경 설정 부분은 [더보기] 를 하면 보이도록 설정해두고 넘어간다. 어떻게 파이어베이스와 현재 프로젝트를 연동할 수 있는지 알고자 한다면 참고하면 된다.
[환경설정] 파이어베이스와 현재 프로젝트 연결하려면?
[환경설정] ① 우선 파이어베이스 홈페이지에 접속 후 로그인 하고 [ 프로젝트 추가]를 클릭한다.
[환경설정] ② 그 후 좌측 대시보드 항목에서 Storage 를 찾는다.
Storage 항목을 클릭하면 Cloud Storage 설정 항목이 뜨는 것을 볼 수 있는데, 이 중에서 개발 모드에서 사용하기 좋게 테스트 모드에서 시작을 클릭한다. 실제 운영 시 해당 설정을 바꿀 수 있으므로 걱정 하지 않아도 된다.
생성하고 나면 아래 화면을 볼 수 있다.
[환경설정] ③ 이제 프로젝트 개요로 이동해서 앱추가를 클릭 해야 한다.
아마 아무런 앱 추가가 없었다면 다음 화면 일지도 모른다 (화면의 레이아웃이나 디자인은 달라질 수 있지만 기능은 똑같기 때문에 찾으면 다 보인다)
[환경설정] ④ 앱 이름을 설정하고, SDK 추가 에서 npm 선택 후 sdk 정보 확인
앱 닉네임은 해당 프로젝트로 식별가능한 닉네임으로 자유롭게 지정한다.
그 다음 아래로 내려서 SDK 추가 부분을 살펴보면 아래와 같은 항목을 확인할 수 있다. 여기서 firebase 설치하는 명령어와 파이어베이스와 서버를 연결하는 sdk 환경변수 들을 확인할 수 있다.
[참고] 참고로 sdk 환경변수들은 클라이언트 단에서 접근 가능하다. 따라서 해당 변수들을 알아도 접근할 수 없도록 향후 보안 정책을 추가해야 한다.
[환경설정] ⑤ npm i firebase 설치 후 환경변수에 sdk 보관하기
앞서 sdk 코드들을 확인했다면 해당 코드들을 환경변수에 저장해야 한다. 그 후 아래와 같이 firebase.ts 파일을 만들어서 재사용 가능하게 설정한다.
아래는 본인이 사용한 코드인데, 파이어베이스에서 이에 대한 설명을 문서화 하여 알려주고 있으므로 찾아보길 바란다.
import { initializeApp } from "firebase/app";
import { getStorage } from "firebase/storage";
const firebaseConfig = {
apiKey: process.env.NEXT_PUBLIC_FIRE_API_KEY,
authDomain: process.env.NEXT_PUBLIC_FIRE_AUTH_DOMAIN,
projectId: process.env.NEXT_PUBLIC_FIRE_PROJECT_ID,
storageBucket: process.env.NEXT_PUBLIC_FIRE_STORAGE_BUCKET,
messagingSenderId: process.env.NEXT_PUBLIC_FIRE_MESSAGING_SENDER_ID,
appId: process.env.NEXT_PUBLIC_FIRE_APP_ID,
measurementId: process.env.NEXT_PUBLIC_FIRE_MEASUREMENT_ID
};
console.log(process.env.NEXT_PUBLIC_FIRE_STORAGE_BUCKET)
// Initialize Firebase
export const app = initializeApp(firebaseConfig); // 파이어베이스 설정을 초기화
export const storage = getStorage(app); // 파이어베이스 저장소 접근을 위한 인스턴스를 반환한다.
[참고] 파이어베이스 환경설정 이미지
여기 까지 했으면 이제 파이어베이스를 프로젝트 환경에서 사용할 수 있다.
[프론트엔드] 로직 작성
이제 부터 파이어베이스 스토로지와 연계하여 이미지 업로드 기능을 어떻게 만드는 지 구현해볼 것이다. 앞서 환경설정이 모두 끝났다면, firebase.ts (혹은 본인의 구성파일) 에는 다음 형식으로 작성되어 있을 지도 모른다.
해당 파일은 이미지 업로드를 수행할 때 파이어베이스 스토로지에 접근하기 위한 구성파일인데, initializeApp 의 인자로 firebaseConfig 를 전달하면 파이어베이스에 접근할 수 있게 된다. 일반적인 데이터베이스로 따지면 데이터베이스와 백엔드 서버를 이어주는 매개체(인스턴스)를 생성해주는 것과 같다.
getStorage 는 우리가 앞서 생성한 파이어베이스 스토로지의 인스턴스를 생성해주는 메서드로 app 을 전달해주면, app 에 지정된 구성 옵션을 매개로 스토로지에 접근할 수 있는 권한이 생성된다.
구현에 앞서 전반적인 과정에 대한 이미지를 남겨보자면 다음 흐름으로 업로드 기능이 동작한다.
① 구현하기 | 업로드 함수 작성
앞서 구현한 프로필 미리보기 로직이 실행되고, 그 다음 순서는 파이어베이스에 이미지를 업로드 하는 함수를 작성하여 호출하는 로직을 작성하였다.
이렇게 구현한 이유는 사용자가 이미지를 업로드 한 순간 파이어베이스에 업로드 되고, 그 결과로 업로드한 파일이 저장된 위치(url)를 반환받도록 하기 위해서 이다.
일단, 본인의 경우에는 코드를 다음과 같이 작성했다.
여기서 ref 와 uploadBytes , getDownloadURL 함수는 모두 firebase/storage 모듈에서 import 해온 것이다.
import { ref, uploadBytes, getDownloadURL } from 'firebase/storage'
ref 는 파이어베이스의 스토로지를 참조하여 해당 정보를 담고 있는 인스턴스를 생성하는 함수로서, 다음과 같은 형태로 사용된다.
ref(storage, filePath)
현재 본인의 코드에서는 profile-images/ + uuidv4() + fileName 을 filePath에 지정했는데, 이렇게 되는 경우 스토로지에 업로드 될 때 'profile-images/[uuid][filename] 형태로 저장된다.
↓
다시 돌아와서, storage는 앞서 firebase.ts 파일에서 설정한 sdk 설정을 바탕으로 생성한 스토로지 인스턴스를 의미한다.
uploadBytes 함수는 실제 파이어베이스 스토로지에 프로필 이미지를 업로드하는 기능을 수행한다.
[첫 번째 인자]에는 앞서 생성한 ref 인스턴스를 넣고, [두 번째 인자]는 업로드 하고자 하는 파일(data) 를 넣는다. 참고로 파일(data) 의 경우 blod 를 포함한 2개의 버퍼 타입을 지원한다.
친절하게도 문서화가 잘되어 있어서 해당 함수를 마우스 호버하면 각 인자에 대한 설명을 자세히 확인할 수 있다.
uploadBytes 함수의 경우 Promise 를 반환한다. 어떤 값을 반환하는지는 아래 더보기를 클릭하면 영상이 뜨는데, 해당 영상의 내용과 같이 접근하면 어떤 데이터를 반환하는지 확인할 수 있다(만일 영상이 나오지 않는다면 이 부분의 주요한 점은 아래에서 다룬다.).
② 구현하기 | URL 조회 기능 추가
사실 위 영상은 크게 중요한게 아니고, 해당 함수 구현에 필요한 것은 then 체이닝을 통해 반환하는 shapshot 객체와 해당 변수를 이용해서 파일 다운로드를 수행하는 함수에 대한 부분이다.
여기서 다운로드를 실행하는 함수는 getDownloadsURL 로 명명되어 있는데, 실행 시 기능만 본다면, 업로드한 파일에 접근 가능한 URL 을 GET 요청으로 조회하는 역할을 수행한다.
(참고로, 해당 함수를 사용하는 이유는 서버 측에서 해당 URL 을 문자열 형태로 데이터베이스에 저장하기 위해서 미리 URL 을 조회해서 상태에 할당해야 하기 때문이다.)
함수를 잘보면, 아래와 같이 snapshot 을 then 체이닝의 매개변수로 입력된 것을 볼 수 있다, 해당 snapshot 변수는 객체이다. 이는 현재 참조하고 있는 storage 인스턴스에 대한 메타데이터 정보를 가지고 있다.
여기서 중요한 정보는 ref 로, 아래에도 잘나와 있지만, 앞서 storage 에 대한 참조 정보와 업로드하고자 하는 파일에 대한 정보를 담고 있는 그 ref 와 똑같은 정보를 가지고 있다.
즉, 아래 이미지와 같다.
getDownloadURL 함수는 해당 ref 에 저장된 스토로지에 파일에 대한 메타데이터를 참조하여 파이어베이스 Storage 에 저장되어 있는 파일을 찾아서 해당 파일에 접근할 수 있는 URL 을 반환한다.
그리고 반환된 URL은 useState 훅을 활용해 상태로 저장하고, 해당 상태를 서버로 PATCH 요청 보냄으로써 프로필 업데이트 작업이 이루어진다.
③ 구현하기 | 서버로 PATCH 요청 보내기
앞서 과정 까지 끝냈다면 imageUrl 에는 해당 프로필 이미지에 대한 url 정보가 담겨 있게 된다. 그리고 해당 정보는 유저가 프로필 수정 버튼을 클릭하는 순간 서버로 PATCH 요청을 보내야 한다. 따라서 이번에 구현하는 부분은 서버로 해당 데이터를 담아서 보내는 기능이다.
const [imageUrl, setImageUrl] = useState('')
본인의 경우 업로드 로직을 다음과 같이 작성했다. 참고로, 해당 사이트의 경우 사용자가 닉네임과 프로필 이미지를 회원가입 이후에 개인의 필요성에 따라 지정하도록 하였기 때문에, 마이페이지의 프로필 탭에서 프로필 이미지와 닉네임 변경에 대한 요청을 동시에 처리하도록 되어 있다.
해당 함수의 호출은 NextJS 의 action 으로 동작하기 때문에, 첫 번째 인자로는 formData 가 들어온다, 이 때 formData에는 사용자가 form 내부에서 input 이나 textarea 등에 입력한 정보들이 담겨 있다.
여기서 담겨진 정보는 .get(name) 으로 접근할 수 있는데, 여기서 name 은 해당 태그의 name 속성에 입력된 값을 의미한다.
마지막으로 언급할 함수는 updateUserInfo 함수로서 첫 번째 인자는 유저의 닉네임 , 두 번째 인자로 앞서 저장한 프로필 이미지의 url 에 해당한다.
해당 정보는 다음과 같이 매개변수로 전달받아 Body 객체 담아서 서버로 전송 처리하도록 로직을 작성했다.
[백엔드] 로직작성
현재 프로젝트의 백엔드 로직은 모두 NextJS 에서 이루어진다. 앞서 프론트엔드에서 마지막으로 PATCH 요청이 무사히 전달 된다면 '/api/users/mypage/upload ' 경로로 요청이 올 것이다.
① 구현하기 | body 데이터 가져오기
우선 앞서 api 서버 경로로 무사히 도착했다면, body 객체에 담긴 json 데이터는 NextRequest 의 .json() 메소드로 접근하여 반환받을 수 있다.
참고로, 여기서 '0' 이 구조분해할당한 객체의 키값으로 나온 이유는 프론트엔드 단에서 fetch 함수를 추상화하여 재사용 시 개인의 개발 편의성을 위해 0 번째 키의 값으로 body 데이터가 할당되도록 로직을 구성했기 때문이다.
req.json() 은 Promise 를 반환하므로 async ~ await 을 활용하여 body 데이터를 반환받을 수 있도록 해야 하며, 이를 구조분해할당 하면 앞서 body 에 담아서 온 유저의 요청 정보가 담겨 있다.
② 구현하기 | 사용자에 대한 권한 검증 및 DB 저장
프로필을 수정한다는 것은 회원가입을 하고 로그인한 사용자라는 의미인데, 만일 예상치 못한 상황에서 잘못된 요청이 통과하면 큰 문제가 되므로 이에 대한 사용자의 권한을 검증해야 한다.
이에 활용하는 것은 accessToken 을 기반으로 하며, 해당 accessToken 을 검증하는 로직을 추상화 시킨 tokenVerify 를 활용하여 검증을 수행했다.
검증 결과 유효한 사용자라면 해당 정보를 user 객체로 부터 얻어올 수 있다.
그리고 유저 정보를 바탕으로 데이터베이스에 저장하기만 하면 프로필 업로드 기능의 구현은 끝난다.
const query = `
UPDATE users
SET nickname = $1, profile_img_url = $2, updated_at = CURRENT_TIMESTAMP
WHERE email = $3 AND user_id = $4
`
await db.query(query, [nickname, profile_image, dbEmail, userId])
구현 결과
[마무리] 이번 기능 구현에서 어려웠던 점 등
누군가 만든 기능도 이해하고 사용하려면 어려운 법
매번 느끼는 것이지만, 누군가가 만들어둔 기능을 이해하고 사용하는 것은 많은 노력이 필요하다는 사실을 많이 느꼈다. 그저 기능을 사용하기만 하면 간단하게 끝날 수도 있는 일이기도 하지만, 해당 기능의 내부가 어떻게 돌아가는지 어떻게 문서화 되어 설명하고 있는지 이해하지 못하고 넘어가는 것은 성장의 장애물이 될 것이라 판단했고, 일일이 타입 정의에 대한 문서를 찾아보거나 공식 문서의 내용을 보면서 이해하고 적용하기 위해 노력했다.
그러다 보니 평소에는 알지 못했던 내용들을 알아가는 계기가 되기도 했다. 원래 누군가가 구현한 과정만 보고 따라하는 것은 그 사람이 올려둔 내용의 일부 지식만을 가져가는 것이지만, 실제 해당 기능을 구현한 개발자가 문서화해둔 파일을 살펴보고, 나름대로 기능을 뜯어서 분석해보는 것은 더 많은 지식을 가져가는 기회가 될 수 있다는 점을 많이 알게된 기회가 된 것 같다.
[기능 구현] 프로필 업로드 기능 ② | AWS S3 활용(구현중)
(목적) 향후 웹 사이트를 배포하는 경우 AWS EC2 를 활용할 예정인데, 이 때 이미지를 저장하는 객체를 S3 로 고민하고 있다. 향후 어떻게 될지는 모르지만, 기존 파이어베이스 로직을 대체하기 위해 미리 구현해 놓을 목적으로 정리한다.
우선적으로 버킷(파일 저장소)을 생성해야 한다. 아래는 그 절차를 정리하였다.
[환경 설정] S3 버킷 생성 및 프로젝트 환경 설정
환경 설정 부분은 모두 더보기 형태로 정리한다. 사실 제일 중요한 부분이긴 하지만, 실제 프로젝트 내에서 코드에 집중하기 위해서 숨겨둔다.
[환경설정] ① 버킷 생성 페이지 들어가기
우선 아마존 웹 서비스 사이트 아마존 웹 사이트 로 접속하여 루트 사용자 로그인 후 검색창에서 s3 를 검색한다. 그 후 우측 중단에 버킷 만들기 버튼을 클릭하여 버킷 생성 페이지로 이동한다.
[환경설정] ② 일반구성 지정하기
버킷 만들기로 들어가면 제일 먼저 보이는 화면이 위와 같은데, AWS 리전은 쉽게 말해 데이터를 저장하고 있는 기관을 의미한다. 해당 기관에서는 사용자의 데이터를 안전하게 보관하고 있고, 해당 정보를 필요로 하는 사용자가 가까이 있으면 빠르고 효율적으로 데이터를 서비스하는 역할을 수행한다.
버킷 이름은 전세계 범용적으로 사용되고 있는 모든 bucket 을 식별하는 이름을 지정해야 한다. 해당 이름의 명명 규칙은 [버킷 이름 지정 규칙 보기] 로 들어가면 확인할 수 있다.
[환경설정] ③ 객체 소유권
이 부분은 기본적으로 권장되는 옵션을 선택한다. ACL 을 활성화하면 해당 S3 에 저장된 객체(파일)를 다른 계정에서도 소유할 수 있으므로 주의해야 한다.
[환경설정] ④ 이 버킷의 퍼블릭 액세스 차단 설정 외 나머지
이 부분은 해당 버킷을 지정한 도메인이나 호스트가 아닌 곳에서 접근 가능하게 할지 말지 지정하는 부분이다. 실제 프로덕션에서 사용하는 경우에는 보안을 위해 차단하는 것이 맞으나, 개발 환경에서 테스트를 쉽게 하기 위해서는 임시적으로 퍼블릭 액세스가 가능 하도록 설정한다.
아래와 같이 기본적으로 체크가 되어 있는 부분을 해제하면 된다. (버킷 생성 이후에도 권한 페이지에서 수정이 가능하므로 걱정하지 않아도 된다.)
↓
이 때 현재 설정으로 인해 발생하는 문제에 대한 고지를 확인하고 체크✔ 한다.
그 후 나머지는 현재 중요한게 아니므로 기본 값으로 셋팅한다.
여기 까지 했으면 이번에는 최소한의 보안을 위해 버킷 정책을 수정하러 가보자
[환경설정] ⑤ 버킷 정책 수정하기
우선 [권한] - [버킷 정책] 섹션의 우측 상단 [ 편집] 을 클릭하여 들어간다.
들어가 보면 현재 버킷에는 아무런 권한 설정이 되어 있지 않은 것을 볼 수 있는데, 몇 가지 설정을 통해 사용자를 읽기만 가능하고, 삭제와 수정은 관리자만 가능하도록 기초적인 권한 설정 작업을 처리해야 한다.
본인의 경우에는 아래와 같은 형식으로설정했다. 이렇게 되면 일반 사용자는 GET 요청만 가능하고, 관리자(루트 사용자)만 수정과 삭제 요청이 가능하게 권한을 설정할 수 있다.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "1",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::버켓이름/*"
},
{
"Sid": "2",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::AWS계정ID:root"
},
"Action": [
"s3:PutObject",
"s3:DeleteObject"
],
"Resource": "arn:aws:s3:::버켓이름/*"
}
]
}
이대로 넘어가면 이해하기 어려우므로 각각 부연 설명을 첨부한다.
버켓이름: 앞서 우리가 버켓을 생성할 때 지정한 고유한 버켓의 ID 를 입력하면 된다.
AWS계정ID: 이는 AWS 홈페이지에서 우측 상단의 계정의 드롭다운 버튼을 클릭하면 아래와 같이 확인할 수 있다. 해당 계정 ID 를 복사해서 넣어주면 된다.
Statement (정책 선언): 정책을 생성할 때 각각의 정책을 의미한다. 각 Statement는 하나 이상의 권한 규칙을 정의할 수 있다.
Sid (Statement ID): 이는 해당 Statement에 대한 고유 식별자로서 여러 개의 Statement가 있을 때 특정 Statement를 참조할 때 사용된다. 즉, id 같은 것이다.
Effect (허용 효과): 이 부분은 정책의 효과를 정의한다. "Allow"는 해당 규칙을 통해 허용되는 작업을 나타낸다. 즉, Allow 를 적용하면 해당 정책을 적용하는 것을 허용한다는 의미이다.
Principal (주체): 규칙을 적용할 주체를 지정한다. 여기서 사용된 " * " 는 모든 주체를 나타낸다. 두 번째 Statement에서는 특정 IAM 사용자 또는 역할을 나타내는 ARN(Amazon Resource Name)이 지정되어 있다.
Action (동작): 이 부분은 해당 정책에서 허용되는 작업을 정의하는 부분이다. 위에서 살펴보면, 첫 번째 Statement에서는 "s3:GetObject" 작업(객체 가져오기)만 허용되고, 두 번째 Statement에서는 "s3:PutObject" 및 "s3:DeleteObject" 작업(객체 추가 및 삭제)이 허용되도록 지정되어 있다.
Resource (리소스): 이 규칙이 적용되는 대상 리소스를 지정한다. 이 예제에서는 "wise-sayings-bucket"이라는 이름의 특정 S3 버킷에 대한 작업을 제한한다. "arn:aws:s3:::wise-sayings-bucket/*"는 이 버킷 내의 모든 객체에 대한 리소스를 지정한다. 쉽게 말해 현재 정책을 어느 버킷에 지정하는지 정하는 것이다.
[환경설정] ⑥ CORS 설정하기
CORS 는 철자 그대로 교차(Cross) 출처(Origin) 리소스(Resource; 자원) 공유(Share) 에 대한 정책을 의미하는데, 해당 정책은 동일한 프로토콜(http:// or https://), 도메인(www.domain.com), 포트(:3000) 가 모두 동일한 출처 간에 자원 공유가 가능한 정책 (Same Origin Policy) 을 기본으로 한다.
즉, CORS 를 설정하는 부분은 서로 다른 출처를 가진 사이트 간에 요청을 어떻게 통제할 것인지를 지정하는 부분이다.
AWS S3에서 이에 대해 어떻게 지정하는 지는 다음 링크 aws 에서 제공하는 CORS 구성에서 자세한 내용에서 확인할 수 있다.
본인의 경우에는 아래와 같이 CORS 를 지정하여 모든 사이트에서 접근할 수 있도록 지정하였다. 특히 도메인 부분은 향후 프로덕션에서 사용하는 도메인 주소가 이미 존재한다면 해당 주소와 개발 서버 주소를 지정하는 것이 좋은데, 일단 본인은 " * " 으로 지정하여 개발 및 테스트하기 쉽게 설정하였다.
[
{
"AllowedHeaders": [
"*"
],
"AllowedMethods": [
"PUT",
"POST"
],
"AllowedOrigins": [
"*"
],
"ExposeHeaders": [
"ETag"
]
}
]
[환경설정] ⑦ Access Key 발급
이제 접근키를 발급 받아야 한다. 해당 키는 우리가 개발한 서버 (백엔드 환경)에서 S3 버킷을 열고 들어갈 문 열쇠이므로 매우 중요하다. 발급 받은 이후에는 해당 키를 유출되지 않게 안전하게 보관해야 한다.
키 만들기 를 클릭하면 아래 내용이 나올 수 있는데, 이는 모든 권한을 가진 루트 사용자 아이디로 액세스 키를 생성하면, 탈취 당하는 경우 해당 사이트에 대한 모든 권한이 넘어가는 것과 마찬가지 이기 때문에 보안상 위험하여 다른 대안을 권장하는 화면이다.
IAM 은 루트 사용자 외에 일반 사용자, 그룹 사용자 등을 별도로 지정하는 관리 시스템으로 해당 도구를 활용하면, 루트 권한이 없는 일반 사용자 계정을 생성할 수 있으므로, 고려해보는 것이 좋을 수 있다. ( 참고로 IAM 정책 시물레이션을 활용하면, 우리가 지정한 서비스 정책에 대한 사용자 시뮬레이션을 돌려 볼 수 있다)
[참고] MFA 란?
혹시 아래와 같이 MFA 없음 이라고 표시되고 있다면, 이를 고려하는 것이 좋다. MFA 는 멀티 팩터 인증의 줄임말로 말 그대로 로그인 시 이중으로 인증하는 보안 도구이다.
예를 들어, 우리가 Github 에 로그인 하려고 하면, 특정 번호를 깃허브 모바일 앱에서 확인하고 입력하게 하거나 하는 등의 2단계 인증을 거치는 모든 행위들이 MFA 이다.
여기 까지 한다면, 웹에서의 S3 버킷 설정(환경설정)은 끝이다. 나머지는 서버로 돌아가서 필요한 설정과 기능 구현을 이어가야 한다.
[마무리] 는 아니고, 잠깐 언급하고 갈 점
현재 S3 를 이용한 프로필 업로드 기능은 aws 배포가 끝난 뒤에 붙여나갈 계획이다. 현재는 파이어베이스의 스토로지를 활용하여 프로필 업로드 기능을 구현하였는데, S3 의 기능을 잘 활용한다면, 보다 효율적이고 짧은 코드를 사용하여 기능을 구현할 수 있겠다는 생각이 들어서 곧 마이그레이션을 이어나갈 예정이다.
[기능 구현] 사이트맵 생성하기
(기능 목적 및 TMI) 이번에 구현하는 것은 SEO 최적화를 위한 사이트맵을 생성하는 것이다. 사실 별 생각이 없었지만, 사이트를 실제 배포해서 운영하고자 다짐하니, 신경쓸게 정말 많아진 것같다.
NextJS 에서는 정적 사이트맵과 동적 사이트맵을 생성할 수 있는 도우미 함수가 존재하고, 이에 대한 튜토리얼도 공식문서에 나와 있으므로 이를 참고하여 기능을 구현한다.
[개념] 사이트맵이란?
우선 구현에 앞서 사이트맵이 무엇인지 간략하게 정리하고 넘어갈 생각이다. 사이트맵은 현재 방문자가 방문하여 돌아다닐 수 있는 사이트의 곳곳에 대한 정보를 담고 있는 지도라고 할 수 있다. 보통 크롤링 봇이 사이트를 돌아다니면서 해당 사이트의 정보를 수집하고, 그 정보를 바탕으로 검색순위를 조정하는 작업에 사이트맵이 큰 영향을 미친다.
만일 사이트맵이 존재하지 않는다면, 크롤링 봇은 해당 사이트가 어떠한 구조로 이루어져 있는지 명확하게 알지 못한다. 이 문제는 결국 포털 사이트에서 해당 사이트에 대한 검색순위를 낮게 하게 만드는 원인이 되므로 SEO 최적화를 생각한다면, 사이트 맵은 무조건 있어야 하는 필수요소라고 할 수 있다.
로직 작성
① 구현하기 | sitemap.ts 파일 생성 및 기초 셋팅
NextJS 에서는 사이트맵을 생성하는 방법으로 3가지를 제공한다.
첫 번째는 sitemap.xml 파일을 직접 작성하는 방법
두 번째는 sitemap.ts 파일을 정적으로 생성하는 방법
세 번째는 두 번째 파일을 동적으로 생성하는 방법
이라 할 수 있다. 이 중에서 본인이 활용하는 방법은 두 번째와 세 번째를 혼합하여 ts 파일을 작성하는 방법으로 NextJS 에서는 해당 ts 파일을 내부적으로 처리하여 url 로 접속시 xml 로 변환하고, 이를 출력한 sitemap 을 확인할 수 있도록 되어 있다.
우선, NextJS 14.1 버전 기준으로 src/app/ 경로에 sitemap.ts 파일을 생성하여 다음 구조를 갖춰 두면 된다.
// reference : https://nextjs.org/docs/app/api-reference/file-conventions/metadata/sitemap#generating-a-sitemap-using-code-js-ts
import { MetadataRoute } from 'next'
export default function sitemap(): MetadataRoute.Sitemap {
return [
]
}
② 구현하기 | 정적 사이트맵 작성하기
이번에는 정적 사이트맵을 작성해야 한다. 사용법은 공식 문서에 보면 잘 나와 있으므로 자세한 내용은 공식문서를 보는 것이 좋다. 여기서는 대략적으로 어떻게 본인의 웹 사이트의 정적 사이트 맵을 구성했는지 그 결과를 보여주고, 설명하는 식으로 넘어갈 것이다.
import { MetadataRoute } from 'next'
const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL
export default function sitemap(): MetadataRoute.Sitemap {
return [
{
url: `${BASE_URL}`,
lastModified: new Date(),
changeFrequency: 'daily',
priority: 1,
},
{
url:`${BASE_URL}/quotes/topics`,
lastModified: new Date(),
changeFrequency:'daily',
priority:0.9,
},
{
url:`${BASE_URL}/quotes/authors`,
lastModified: new Date(),
changeFrequency:'daily',
priority:0.9,
}
]
}
우선 BASE_URL 은 환경변수(.env or .env.local)에 NEXT_PUBLIC_환경변수명 형태로 작성해두고, 이를 해당 사이트 맵에서 import 하여 사용한다. 해당 주소는 향후 프로덕션 환경에서 해당 도메인 사이트의 URL 이 들어가는 부분이므로 환경변수로 관리하여 향후 수정가능성을 높이는 것이 좋다.
그 후 export default function sitemap() 함수를 생성하는데, 이 때 export default 로 내보낼 수 있도록 해야 한다. 이렇게 해야 빌드 시 NextJS 가 해당 파일을 인식하여 사이트맵을 생성할 수 있다.
그리고 함수 내의 값을 반환하는데, 이 때 배열 형태로 반환해야 한다.
그 다음 해당 [ ] 내부에 각 정적 페이지의 사이트맵 구성 정보를 다음과 같이 객체 리터럴 형식으로 입력해야 한다. 여기서 url 은 페이지의 주소를 , lastModfied 는 마지막으로 수정된 날짜, changeFrequency 는 사이트맵에서 해당 페이지의 갱신(변경) 빈도, priority 는 검색 노출 시 우선순위를 의미한다.
[참고] 각 속성에 대한 추가 설명이 필요하다면?
lastmod: 페이지가 마지막으로 수정된 날짜를 지정한다. 이 속성은 선택적으로 형식은 YYYY-MM-DD이거나 YYYY-MM-DDThh:mm:ssTZD (예: 2005-01-01 또는 2005-01-01T12:00:00+00:00)이 된다.
changefrequency: 페이지의 변경 빈도를 나타낸다. 가능한 값으로는 항상 (always), 자주 (hourly, daily, weekly, monthly, yearly), 드물게 (never)가 있다. 이 또한 선택적으로 지정 가능.
priority: 페이지의 상대적인 우선순위를 나타낸다. 값의 범위는 0.0에서 1.0까지이며, 높을수록 우선순위가 높다. 이것 또한 선택적으로 가능하다.
url : 색인이 이루어지는 페이지의 주소이다. 이는 필수로 들어가야 하며, 크롤링 봇은 해당 주소를 바탕으로 색인을 생성한다.
본인의 경우에는 임의로 각 옵션을 지정해두었다 참고로 url 을 제외하고는 모두 선택적인 옵션이다. 나중에 각 사이트의 필요에 따라서 조정하면 된다.
정적 사이트맵 구현 결과
이정도 까지만 작성하고, 저장 후 도메인주소/sitemap.xml 을 검색하면 다음과 같이 자동으로 생성된 sitemap.xml 파일을 확인할 수 있다.
③ 구현하기 | 동적 사이트맵 생성하기
이번에는 동적 사이트맵을 생성해볼 것이다. 동적 사이트맵을 생성하는 방법도 크게 특별한 건 없다. 그냥 동적으로 변동되는 사이트의 주소를 map 고차함수를 사용하여 객체로 맵핑된 배열을 반환하고, 해당 배열을 전개 연산자를 사용하여 기존 정적 사이트맵의 구성요소와 자연스럽게 이어주기만 하면 쉽게 만들 수 있다.
예를 들어, 주제별로 명언 목록을 서버로 부터 받아오는 함수가 있다고 가정할 때, 해당 명언의 카테고리에 따라서 동적인 페이지를 렌더링하는 경우 다음과 같이 데이터를 서버로 부터 가져오고 해당 데이터를 순회하여 맵핑 처리할 수 있다.
// /sitemap/sitemap.ts
const topics = await getQuoteCategoryFromDb('topics')
const topicEntries = topics.map((categoryInfo: { category: string }) => ({
url: `${BASE_URL}/quotes/topics/${categoryInfo.category}`,
lastModified: new Date(),
}))
// /sitemap/get.ts
export async function getQuoteCategoryFromDb(mainCategory: string) {
const url = config.apiPrefix + config.apiHost + `/api/sitemap/${mainCategory}`
try {
const res = await fetch(url)
const categories = await res.json();
return categories
} catch (error) {
console.error(error)
}
}
여기서 getQuoteCategoryFromDb 함수는 서버로 부터 주제별 명언의 카테고리 목록을 불러오는 fetcher 함수이다. 앞서 fetch 의 결과는 배열형태로 서버에서 반환되어 오는데, 이를 return 해두게 되면, sitemap.ts 파일에서 promise 로 반환받아 사용할 수 있다.
sitemap.ts 로 받아온 데이터는 map 함수를 이용해 앞서 정적 사이트맵을 생성했을 때와 같이 객체 형태로 요소를 맵핑한다.
그 후 ... 연산자를 활용하여 기존 정적 사이트맵 요소의 뒤에 자연스럽게 이어주기만 하면 된다.
import { MetadataRoute } from 'next'
const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL
export default function sitemap(): MetadataRoute.Sitemap {
const topics = await getQuoteCategoryFromDb('topics')
const topicEntries = topics.map((categoryInfo: { category: string }) => ({
url: `${BASE_URL}/quotes/topics/${categoryInfo.category}`,
lastModified: new Date(),
}))
return [
{
url: `${BASE_URL}`,
lastModified: new Date(),
changeFrequency: 'daily',
priority: 1,
},
{
url:`${BASE_URL}/quotes/topics`,
lastModified: new Date(),
changeFrequency:'daily',
priority:0.9,
},
{
url:`${BASE_URL}/quotes/authors`,
lastModified: new Date(),
changeFrequency:'daily',
priority:0.9,
},
// 이렇게 이어준다.
...topicEntries
]
}
동적 사이트맵 구현 결과
위와 같이 작성하고 다시 사이트맵을 조회한다면 다음과 같은 형식으로 조회된 것을 확인할 수 있다. 참고로 loc 는 ts 파일에서의 url 과 같다.
This XML file does not appear to have any style information associated with it. The document tree is shown below.
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>http://localhost:3000</loc>
<lastmod>2024-02-29T11:29:00.733Z</lastmod>
<changefreq>daily</changefreq>
<priority>1</priority>
</url>
<url>
<loc>http://localhost:3000/quotes/topics</loc>
<lastmod>2024-02-29T11:29:00.733Z</lastmod>
<changefreq>daily</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>http://localhost:3000/quotes/authors</loc>
<lastmod>2024-02-29T11:29:00.733Z</lastmod>
<changefreq>daily</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>http://localhost:3000/user-quotes</loc>
<lastmod>2024-02-29T11:29:00.733Z</lastmod>
<changefreq>daily</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>http://localhost:3000/quotes/topics/긍정</loc>
<lastmod>2024-02-29T11:29:00.732Z</lastmod>
</url>
<url>
<loc>http://localhost:3000/quotes/topics/기타</loc>
<lastmod>2024-02-29T11:29:00.732Z</lastmod>
</url>
<url>
<loc>http://localhost:3000/quotes/topics/꿈</loc>
<lastmod>2024-02-29T11:29:00.732Z</lastmod>
</url>
<url>
<loc>http://localhost:3000/quotes/topics/동기부여</loc>
<lastmod>2024-02-29T11:29:00.732Z</lastmod>
</url>
<url>
<loc>http://localhost:3000/quotes/topics/리더십</loc>
<lastmod>2024-02-29T11:29:00.732Z</lastmod>
</url>
<url>
<loc>http://localhost:3000/quotes/topics/봉사</loc>
<lastmod>2024-02-29T11:29:00.732Z</lastmod>
</url>
<url>
<loc>http://localhost:3000/quotes/topics/사랑</loc>
<lastmod>2024-02-29T11:29:00.732Z</lastmod>
</url>
<url>
<loc>http://localhost:3000/quotes/topics/용기</loc>
<lastmod>2024-02-29T11:29:00.732Z</lastmod>
</url>
<url>
<loc>http://localhost:3000/quotes/topics/인생</loc>
<lastmod>2024-02-29T11:29:00.732Z</lastmod>
</url>
[마무리] 이번 기능 구현에서 어려웠던 점
웹 개발을 공부하면서 사이트 맵을 처음으로 만들어봤다. 그래서 기초 지식이 전혀 없는 상태에서 사이트맵에 대해 이해하고, 구현하는 것은 매우 어려울 것으로 생각 되었으나, NextJS 에서는 사이트맵을 쉽게 만들 수 있는 기능을 내부적으로 제공하고 있었고, 이를 문서화도 잘 해두어서 크게 혼란을 경험하는 일은 없었다.
다만, 사이트맵의 파일을 작성할 때 사용되는 규칙이 있는데, 해당 규칙을 지키지 않아서 사이트맵이 정상적으로 그려지지 않는 문제를 경험했었다.
해결한 방법은 주소의 문자열 사이에 공백을 제거하는 것 이었는데, 이 글을 작성하고 있는 시점에 그냥 URL 을 인코딩하면 되는 일 아닌가 라는 생각이 불쑥 들었다. 이를 반영해서 수정해야 겠다.
[기능 구현] 소셜 로그인 | Next-Auth.js 기반 OAuth 구글 로그인 구현하기
이번에는 Auth.js 라는 인증 라이브러리를 활용하여 구글 로그인 기능을 구현해보려고 한다. 현재 Auth.js 는 실험적인 버전 v5에 대한 베타 버전을 배포하고 있는데, 기존 Next-auth.js 에서 마이그레이션을 진행중이므로 현 기능 구현에 사용된 버전 또한 최신 베타 버전인 v5 를 사용하였다.
참고로 구글 소셜 로그인을 위한 기본적인 클라이언트 id 나 시크릿를 발급 받는 과정은 생략한다. 이 부분은 너무나 많은 개발 블로그에서 다루고 있으므로 불필요한 정리라 판단하여 배제하였다. 여기서 정리되는 부분은 실제 본인의 프로젝트 내에서 적용된 방식에 대한 소개 및 정리에 초점을 두고 작성한다.
① 구현하기 | 패키지 설치 및 시작점 살펴볼 주요 변경점
npm 기준으로 다음과 같이 설치하면 최신 beta 버전(실험적 v5)이 설치된다.
npm install next-auth
NextAuth(이제는 Auth.js) 의 공식 문서에 따르면, 최신 v5 부터는 아래와 같이 기존 v4에서 사용되던 기능들이 추상화되거나 명칭이 변경되는 대대적인 변화가 생겨났다. 보면 알 수 있듯이 v4는 next12 버전 즉, pages 라우터를 기준으로 본다면, v5는 next13 버전 이후의 app 라우터와 호환되도록 변경된 것을 볼 수 있다.
주요 변경점 중 제일 눈에 띄는 변화는 기존의 여러 메서드로 분산되어 있던 것을 auth() 메소드 하나로 추상화 시킨 것이고, 원래 라면 서버에서 소셜 로그인 사용자의 정보를 읽을 때는 getServerSession 을 써야 했으나, auth() 을 호출하기만 하면 쉽게 읽어올 수 있도록 바뀐 점이라 본다. 이 부분을 이렇게 바꾼 이유를 찾아보니 auth.js 는 NextJS 에 제한적이지 않고 다양한 언어에서 범용적으로 사용할 수 있도록 하기 위해서라 한다.
② 구현하기 | auth.config.ts 파일 작성하기
따라서 여기 부터는 해당 v5 의 auth 를 참고하여, config 파일을 작성하고, 이를 재사용하고자 별도의 파일을 작성하고자 한다.
우선 구현 설명에 앞서 config.auth.ts 파일을 생성하고, 작성한 전체 코드는 다음과 같다. 참고로 해당 코드의 작성 흐름은 해당 공식문서를 참고 했다.
import Google from 'next-auth/providers/google'
import type { NextAuthConfig } from 'next-auth'
import NextAuth from 'next-auth'
const {
AUTH_GOOGLE_ID,
AUTH_GOOGLE_SECRET,
AUTH_SECRET,
} = process.env
export const authConfig = {
// 소셜로그인 서비스 제공자 옵션 구성 정보 입력
providers: [Google({
clientId: AUTH_GOOGLE_ID || '',
clientSecret: AUTH_GOOGLE_SECRET || '',
})]
} satisfies NextAuthConfig
export const { handlers: { GET, POST }, auth } = NextAuth(authConfig)
제일 먼저 해야 하는 작업은 당연하게도 필요한 모듈을 import 해오는 것이다. Oauth 서비스를 제공하는 자로서 여기서는 Google 이 대상이기 때문에 google 프로파이더를 import 한다.
NextAuthConfig 는 NextAuth 의 타입 설명이므로 가져오고, 마지막으로 제일 중요한 인증 모듈인 NextAuth 을 가져온다.
import Google from 'next-auth/providers/google'
import type { NextAuthConfig } from 'next-auth'
import NextAuth from 'next-auth'
그 다음에는 환경변수(.env 나 .env.local) 로 구글 클라이언트 ID, 구글 클라이언트 스크릿, 인증 시크릿을 별도로 가져온다. 여기서 AUTH_SECREP 은 향후 배포 이후 필요한 것이므로 개발 환경에서는 쓰이지 않는다.
const {
AUTH_GOOGLE_ID,
AUTH_GOOGLE_SECRET,
AUTH_SECRET,
} = process.env
그 다음에는 인증을 위한 구성 데이터를 작성해야 한다. 이 부분은 export 되어서 향후 [...nextauth] 부분에서 사용되므로 내보내는 것이다.
여기서 중요한 부분 중 하나로 satisfies NextAuthConfig 이 있는데 해당 타입을 명시해야 자동완성의 이점을 누릴 수 있으니 잊으면 안 된다.
providers 는 현재 이용하고자 하는 인증 제공자에 대한 정보를 입력 받으며, 앞서 import 한 Google 프로바이더를 [Google] 입력하고, 해당 프로파이더를 호출할 때 인자로 클라이언트 ID와 클라이언트 SECREP 을 객체 리터럴 형태로 넘겨주면 된다.
[참고] 현재 프로젝트에서는 별도의 데이터베이스 Adapter 를 사용하지 않았지만, 만일 프리즈마 등의 맵핑된 데이터베이스를 사용하는 경우 해당 공식문서의 여기를 살펴보는 것을 권장한다.
export const authConfig = {
// 소셜로그인 서비스 제공자 옵션 구성 정보 입력
providers: [Google({
clientId: AUTH_GOOGLE_ID || '',
clientSecret: AUTH_GOOGLE_SECRET || '',
})]
} satisfies NextAuthConfig
마지막으로 [...nextauth] 경로에서 사용해야 할 GET 과 POST 메소드 그리고 앞서 작성한 authConfig 를 추상화하여 사용할 수 있도록 만들어진 auth 메소드를 내보내도록 해야 한다.
export const { handlers: { GET, POST }, auth } = NextAuth(authConfig)
이렇게 하면 사용할 기초 준비가 모두 끝났다. 이 다음 구현하기에서는 다음 route.ts 파일에서 이를 어떻게 사용하는지 간략하게 정리해볼 것이다.
③ 구현하기 | route.ts 파일 작성하기
이 부분은 크게 건드릴 건 없다. 앞서 config.auth.ts 파일에서 작성하고 export 해둔 모듈을 가져오기만 하면 된다. 나머지는 해당 라이브러리가 내부적으로 GET, POST 요청을 처리하여 자동으로 프로바이더에서 제공하는 콜백 경로로 리디렉션 수행하고, 사용자의 정보를 인증 제공자로 부터 받아오는 처리를 대신해준다.
// src/app/api/auth[...nextauth]/route.ts
export {GET, POST} from '@/configs/config.auth'
③ 구현하기 | RootLayout 으로 가서 SessionProvider 랩핑하기
앞서 설정이 모두 끝났으면, 로그인 이후 사용자의 정보를 useSession 으로 읽어올 수 있게 해야 한다. 이 때 useSession 을 사용할 수 있도록 하기 위해서는 앱의 최상단 컴포넌트의 루트 레이아웃 파일로 가서 SessionProvider 모듈을 import 하고 이를 사용할 것임을 NextJS 에 알리도록 로직을 수정해야 한다.
그러므로 우선 /src/app/layout.tsx 로 이동 한다.
그 후 next-auth/react 모듈에서 SessionProvider 를 import 한다.
import { SessionProvider } from 'next-auth/react'
그리고 body 내부의 전체 요소를 SessionProvider 로 랩핑한다.
return (
<html lang="ko" className=" h-full bg-[#162557]">
<body className={`${gowunDodum.className}`}>
<SessionProvider>
<Header />
<Timer />
<main className="min-h-[100vh] w-full mx-auto max-w-[1700px] relative">
<Toaster />
{children}
<ScrollAndNavButtons />
</main>
</SessionProvider>
</body>
</html>
)
이렇게만 해둔다면, 이제 부터 useSession 훅을 모든 'use client' 가 입력된 페이지나 레이아웃 내부 에서 사용할 수 있게 된다.
④ 구현하기 | 로그인 버튼과 로그아웃 버튼
앞서 과정 까지가 기초적인 환경 셋팅이라면 이제는 실제 로그인과 로그아웃 요청을 위한 메소드를 등록 후 호출할 수 있도록 해야 한다.
현 프로젝트에서 해당 버튼은 사용자가 로그인하는 영역에 위치시켜 두었는데, 본인의 경우 해당 로직을 처리하기 위한 별도의 컴포넌트를 LoginSocial 로 분리하여 처리하고 있다.
그리고 해당 컴포넌트 내부에서는 상단에서 signIn 메소드를 next-auth/react 모듈에서 가져오고 있고, 이를 버튼 요소를 onClick 이벤트 핸들러로 호출 시 동작하도록 로직을 구성하고 있다.
import { signIn } from 'next-auth/react'
// --- 중략 ---
<button onClick={reqGoolgeRogin} className=' text-[1em] font-semibold flex items-center mx-auto bg-white justify-center p-[12px] my-[8px] hover:bg-[#dadada] hover:font-bold rounded-[5px] max-w-[200px] w-full'>
<FcGoogle className={'text-[1.5em] mr-[1em]'} /> 구글 로그인
</button>
그리고 해당 버튼을 클릭하는 순간 구글 로그인을 위한 콜백 url 로 자동으로 이동하여 해당 사용자의 개인정보 이용 등에 대한 제공 동의 화면이 나오게 되는 데 , 대략적으로 다음 과정이 별도의 설정없이 자연스레 이루어진다.
로그아웃 같은 경우도 마찬가지이다. 로그인된 상태에서 로그아웃 버튼을 클릭하면, 해당 로그아웃 요청을 수행하는 signOut 메서드를 앞서 signIn과 같은 모듈에서 import 하여 호출하면 소셜 로그인 시 등록된 세션 쿠키가 제거되면서 로그아웃이 이루어진다.
import { signOut } from 'next-auth/react'
사실상 호출만 하면 되는 부분이므로 별도 설명 없이 마무리 한다.
구현 결과(시연)
앞서 설정대로 되었다면, 아래와 같이 간단한 소셜 로그인 기능이 동작하는 것을 볼 수 있다.
[참고] 유저정보.. 클라이언트 는 useSession 서버 컴포넌트에서는? ........ auth() !
앞서 useSession() 훅은 'use client' 가 최상위에 명시된 경우에 즉 클라이언트 컴포넌트 내부에서만 사용이 가능하다. 그럼 서버 컴포넌트에서 사용자의 소셜 로그인 정보를 획득하려면 어떻게 해야할까?
이 때는 앞서 config.auth.ts 파일에서 정의했던 NextAuth 에서 객체구조분해로 할당받은 auth() 메소드를 호출하면된다.
예를 들어, 아래는 서버 컴포넌트인데, 어떻게 호출하는지 그 예시를 보여준다.
위 예시 처럼 auth 메소드를 호출하면 내부적으로 expires(토큰 만료일), user(유저 정보) 에 접근할 수 있는데, 여기서 user 프로퍼티에 접근하면 해당 유저의 닉네임, 이름, 이메일 등 제공받고자 했던 정보를 열람할 수 있다.
즉, 해당 정보를 바탕으로 사용자가 해당 사이트를 이용할 때 필요한 정보를 별도의 데이터베이스에 저장해두고, 이를 토대로 접근 권한을 제공하면 된다.
[마무리] 이번 구현에서 혼란스러웠던 점.
사실 이번 구현에 사용된 코드 자체는 라이브러리 내에서 추상화해서 제공하는 코드를 활용하면 되므로 큰 어려움은 없었으나, 현재 NextAuth 에서 Auth.js 로 마이그레이션 되면서 생겨나는 문서 상의 혼동? 같은 것이 많아서 현재 프로젝트에 맞는 예시를 찾아보기가 생각보다 까다로웠다. 다행히 코드 내부적으로 문서화가 잘되어 있어서, 해당 메소드의 타입 선언 파일로 이동하면 JSDOC 을 활용하여 친절하게 설명이 되어 있으므로, 이를 참고해서 필요한 정보를 찾아서 구현이 가능했다.
[다음 포스트] 기능구현 3
'프로젝트 > 나만의명언집' 카테고리의 다른 글
[나만의 명언집 배포] NextJS(^14.1) - ① AWS Amplify 배포 (0) | 2024.03.06 |
---|---|
[나만의 명언집 프로젝트] 테스트 코드 적용 정리본(일부) (4) | 2024.03.05 |
[나만의 명언집 만들기 프로젝트] 기능 구현 모음집 ① (0) | 2024.03.04 |
[나만의 명언집 프로젝트] 트러블 슈팅 모음집 ② | 7 ~ 13 (0) | 2024.03.02 |
[나만의명언집 프로젝트] 트리블 슈팅 모음집 ① | 1 ~ 6 (1) | 2024.02.24 |