CRUD, 몽고DB
카버차트는 위 사진처럼 스포티파이 API를 통해 가져온 음반 정보, 내가 임의로 추가한 텍스트를 합쳐 하나의 포스트로 표시한다. 그런데 데이터를 추가할수록 이를 지속적으로 관리할 수 있는 방식, 그러니까 특정 데이터베이스에 포스트를 업로드하고, 가져오고, 수정하고, 삭제하는 로직이 필요하다는 생각이 들었다. 이를 흔히 CRUD(Create, Read, Update, Delete)라고 한다. 몽고DB는 데이터를 JSON 구조로 저장해 사용자의 직관적인 이해를 돕고, 다른 데이터베이스에 비해 사용 방법이 쉽다. 무료 플랜의 경우 저장된 데이터를 불러오는 속도가 다소 느리긴 하지만, 사용하지 못할 정도는 아니다.
몽고DB 연결, 환경변수 설정
몽고DB 사용을 위해 먼저 가입을 한 뒤, 관리자 페이지에서 데이터베이스 항목을 클릭한다. 여기에서 Connect to your application을 누르면 다음과 같은 화면이 나온다. 그러면 3번 항목의 코드를 복사한다.
그리고 내 프로젝트에 환경변수 파일(.env)을 만든다. 몽고DB에 연결하려면 비밀번호를 저장해야 하는데, 깃허브에 비밀번호가 업로드되어 노출되는 일을 피하기 위해서이다. env 파일은 로컬 환경에만 남아 있고 기본적으로 깃허브에 업로드되지 않는다. 아까 복사해둔 코드를 env 파일의 MONGODB_URI 부분에 문자열 형태로 옮긴다. 아래 코드 블록에는 ...mongodb.net/와 ? 사이에 music-data라는 텍스트가 추가되어 있는데, 그건 컬렉션 이름이다. 임의로 컬렉션을 만들어 사용하는 경우라면 해당 부분에 컬렉션 이름을 별도로 추가하면 된다.
// 비밀번호에서 꺽쇠 기호(<>)는 제거해야 한다.
MONGODB_URI="mongodb+srv://carver1014:password@cluster0.wxopfcr.mongodb.net/music-data?retryWrites=true&w=majority"
DB_NAME="carver-mongodb"
CLOUDINARY_URL=cloudinary://xxxxxxxxxxx:yyyyyyyyyyyyyyyyyyy@carver
SESSION_SECRET=keyboard cat
원래는 위와 같이 세팅하면 몽고DB에 정상적으로 연결되어야 한다. 그런데 프로젝트를 netlify에 배포한 상태가 문제가 되어 오류가 발생했었다. 해결 방법은 그리 어렵지 않았다. netlify에서 해당 프로젝트로 이동한 뒤, 환경변수 설정란에 env 파일에 입력했던 일부 데이터를 그대로 옮기면 된다.
CRUD 작업을 위한 첫 번째 단계는 몽고DB 서버와의 연결이다. 별도의 모듈과 라이브러리, 데이터 관리 용도로 만든 lib 폴더에 몽고DB 데이터베이스에 연결하는 함수를 정의했다. 이 함수는 환경변수에서 몽고DB 관련 정보를 가져와 연결시킨다. try문에서 쓰인 dotenv라는 라이브러리는 node.js 프로젝트 내 환경변수를 파일에 저장해 관리할 수 있게 도와주는 역할을 한다.
// music/lib/mongodb.ts
import mongoose from "mongoose";
const connectMongoDB = async () => {
try {
require("dotenv").config();
await mongoose.connect(process.env.MONGODB_URI!);
console.log("Connected to MongoDB");
} catch (error) {
console.error(error);
}
};
export default connectMongoDB;
REST API 구축
몽고DB에 데이터를 저장하거나 조회하려면 스키마와 모델을 정의해야 한다. MusicData 인터페이스는 음악 데이터의 필드와 타입을 정의하고, musicSchema는 해당 필드와 타입을 바탕으로 실제 몽고DB 스키마를 생성한다. 그리고 Music 모델은 스키마를 기반으로 몽고DB 컬렉션에서 음악 데이터를 조작할 수 있는 기능을 제공한다.
// models/music.ts
import mongoose, { Schema, Document, Model } from "mongoose";
interface MusicData extends Document {
id: string;
imgUrl: string;
artist: string;
// ...
}
const musicSchema = new mongoose.Schema({
id: String,
imgUrl: String,
artist: String,
// ...
});
const Music: Model<MusicData> =
mongoose.models.Music || mongoose.model<MusicData>("Music", musicSchema);
export default Music;
route.ts 파일의 코드는 Next.js에서 REST API의 엔드포인트를 정의하고, 몽고DB를 통해 데이터를 생성, 조회, 업데이트 및 삭제하는 기능을 구현하는 코드이다. 모든 함수에서 비밀번호가 올바르지 않거나 데이터베이스 작업 중 오류가 발생하면 적절한 응답을 보내고, 성공적인 작업 시 결과 데이터를 JSON 형식으로 응답한다. 각 함수의 기능은 다음과 같다.
- POST 함수: 들어오는 요청 데이터에서 음악 데이터를 추출하고, 비밀번호를 검사한 후 데이터베이스에 새로운 음악 데이터를 저장한다.
- GET 함수: 모든 음악 데이터를 데이터베이스에서 조회한 후 JSON 형식으로 응답한다.
- DELETE 함수: 들어오는 요청 데이터에서 삭제할 음악 데이터의 ID와 비밀번호를 추출한 후 데이터베이스에서 해당 데이터를 삭제한다.
- PUT 함수: 들어오는 요청 데이터에서 업데이트할 음악 데이터의 정보를 추출하고, 해당 데이터가 존재하면 데이터를 업데이트한다.
// app/api/music/route.ts
export async function POST(request: Request) {
try {
require("dotenv").config();
await connectMongoDB();
const { data, password } = await request.json();
const {
id,
imgUrl,
artist,
// ...
} = data;
if (password !== process.env.UPROAD_PASSWORD)
return NextResponse.json({ message: "password is not correct" }, { status: 401 });
const existingData = await Music.findOne({ id });
if (existingData) {
return NextResponse.json({ message: "album already exists" }, { status: 409 });
}
const newData = new Music({
id,
imgUrl,
artist,
// ...
});
await newData.save();
return NextResponse.json(newData.toJSON());
} catch (error) {
console.error(error);
return NextResponse.json({ message: "Server Error" }, { status: 500 });
}
}
export async function GET(request: Request) {
try {
require("dotenv").config();
await connectMongoDB();
const dataArr = await Music.find();
return NextResponse.json(dataArr.map(data => data.toJSON()));
} catch (error) {
console.error(error);
return NextResponse.json({ message: "Server Error" }, { status: 500 });
}
}
export async function DELETE(request: Request) {
try {
require("dotenv").config();
await connectMongoDB();
const { id, password } = await request.json();
if (password !== process.env.UPROAD_PASSWORD)
return NextResponse.json({ message: "password is not correct" }, { status: 401 });
const existingData = await Music.findOne({ id });
if (!existingData) {
return NextResponse.json({ message: "Data not found" }, { status: 404 });
}
await existingData.deleteOne();
return NextResponse.json({ message: "Data deleted successfully" });
} catch (error) {
console.error(error);
return NextResponse.json({ message: "Server Error" }, { status: 500 });
}
}
export async function PUT(request: Request) {
try {
require("dotenv").config();
await connectMongoDB();
const { albumId, data, password } = await request.json();
const {
id,
imgUrl,
artist,
// ...
} = data;
if (password !== process.env.UPROAD_PASSWORD)
return NextResponse.json({ message: "password is not correct" }, { status: 401 });
const existingData = await Music.findOne({ id });
if (!existingData) {
return NextResponse.json({ message: "Data not found. Cannot update." }, { status: 404 });
}
existingData.id = id;
existingData.imgUrl = imgUrl;
existingData.artist = artist;
// ...
await existingData.save();
return NextResponse.json(existingData.toJSON());
} catch (error) {
console.error(error);
return NextResponse.json({ message: "Server Error" }, { status: 500 });
}
}
앞서 만든 몽고DB 관련 API는 서버 API이므로 클라이언트 사이드에서 이용하려면 해당 코드와의 상호작용하는 역할을 할 함수가 필요하다. 다음 코드들이 해당 작업을 수행한다. 함수는 각각의 기능을 수행하는 데 필요한 파라미터를 받아오고, 작업에 실패한 경우 콘솔에 오류 메시지를 출력한다.
// POST
export async function uploadData(albumData: AlbumInfo, password: string) {
if (albumData !== null) {
try {
const response = await fetch("/api/music", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
data: albumData,
password: password,
}),
});
if (response.status === 401) {
alert("관리자 비밀번호가 틀렸습니다.");
} // ...
const data = await response.json();
} catch (error) {
console.error("Error: ", error);
}
}
}
// GET
export async function fetchData(pathName: string) {
try {
const response = await fetch("/api/music", {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error("Failed to upload music data");
}
let data = await response.json();
// ...
return data;
} catch (error) {
console.error(error);
}
}
// DELETE
export const deleteData = async (id: string) => {
const userPassword = prompt("관리자 비밀번호를 입력해주세요.");
try {
const response = await fetch("/api/music", {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ id: id, password: userPassword }),
});
if (response.status === 401) {
alert("관리자 비밀번호가 틀렸습니다.");
} // ...
} catch (error) {
console.error(error);
}
};
// PUT
export const updateData = async (id: string, data: Partial<AlbumInfo>, password: string) => {
if (data !== null) {
try {
const response = await fetch("/api/music", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ albumId: id, data: data, password: password }),
});
if (response.status === 401) {
alert("관리자 비밀번호가 틀렸습니다.");
} // ...
} catch (error) {
console.error(error);
}
}
};
관리자 권한이 필요한 작업
한편 업로드, 삭제, 수정(POST, DELETE, PUT)의 경우, 관리자 권한이 필요한 작업이므로 몽고DB 서버 API와 상호작용 할 때 body 부분에 비밀번호 데이터를 함께 전달한다. 해당 비밀번호는 env 파일에 환경변수로 저장되어 있는데, 일치하는 경우에만 해당 작업이 수행된다. 다음 코드는 Upload 컴포넌트의 일부로, 포스트를 업로드할 때 클라이언트 사이드에서 서버 API로 비밀번호를 어떻게 전달하는지 보여준다.
export default function Upload({ variablePathName }: UploadProps) {
const [albumId, setAlbumId] = useState("");
const [password, setPassword] = useState<string>("");
const router = useRouter();
// ...
const handleUpload = async () => {
// ...
if (newAlbumData) {
await uploadData(newAlbumData, password);
router.push("/music/admin");
}
};
const handleEdit = () => {
updateData(albumId, data, password);
};
// ...
return (
<React.Fragment>
// ...
<div>관리자 비밀번호</div>
<input
className={styles["input"]}
value={password}
onChange={e => {
setPassword(e.target.value);
}}
onKeyDown={handlePasswordEnter}
/>
// ...
</React.Fragment>
);
}
사실 일반적인 사이트는 회원가입 기능을 만들어 관리자 아이디로 로그인되었을 때만 관련 기능을 사용할 수 있게 하는 편이지만, 이 사이트에는 유저가 회원가입을 해서 쓸 수 있는 별다른 기능이 없고, 소규모 프로젝트에 해당하므로 비밀번호 일치 여부만 확인하는 쪽으로 코드를 짰다.