개발을 진행하다 보니 front 쪽 터미널 열고 Npm run dev, back 쪽 터미널 열고
npm start 하는게 귀찮아서 한번에 하는게 없을까? 하고 찾아보니
concurrently 라는 모듈을 활용해서 루트 폴더 위치에서 npm run dev로
한번에 개발환경을 실행하였다.
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "concurrently \"npm start -w backend\" \"npm run dev -w frontend\""
},

Comment 테이블을 자기참조 관계로 설정.
대댓글 기능을 위해 parent_comment_id 필드를 추가하였음.

{
"email": "user@example.com",
"password": "password123!",
"nickname": "새로운사용자"
}
{
"user_id": 5,
"email": "user@example.com",
"nickname": "새로운사용자",
"createdAt": "2025-06-16T15:00:00Z"
}
게시물 총 개수와 현재 페이지, 총 페이지 개수를 담아서 보내서
FE 쪽에서 편하게 Pagination을 구현할 수 있도록 하였다.
{
"posts": [
{
"post_id": 20,
"title": "수정, 삭제 기능 TEST 글 ",
"content": "<p>우리집 강아지 보고 싶다...</p><img src=\"http://localhost:3000/uploads/image-1750255864330-870955488.JPG\">",
"created_at": "2025-06-18T14:11:12.000Z",
"author": {
"nickname": "조완기"
}
},
{
"post_id": 16,
"title": "gif 도 올라가나요...?",
"content": "<p>공을 보고 찹시다</p><p></p><img src=\"http://localhost:3000/uploads/image-1750159274057-209008867.gif\">",
"created_at": "2025-06-17T11:21:19.000Z",
"author": {
"nickname": "관리자"
}
},
{
"post_id": 13,
"title": "Siuuuuuuuuuuuuuuuuuuu",
"content": "<p>No.7 Cristiano Ronaldo<br><br>수정 TEST</p><p></p><img src=\"http://localhost:3000/uploads/image-1750137298007-603170785.jpeg\">",
"created_at": "2025-06-17T05:14:59.000Z",
"author": {
"nickname": "새로운사용자"
}
},
{
"post_id": 11,
"title": "페이지 제발 넘어가게 해주세요",
"content": "여기에 글 내용을 작성합니다.",
"created_at": "2025-06-17T02:21:13.000Z",
"author": {
"nickname": "새로운사용자"
}
},
...
"pagination": {
"currentPage": 1,
"totalPages": 2,
"totalPosts": 13
}
}
{
"title": "새로운 글 제목입니다",
"content": "여기에 글 내용을 작성합니다."
}
{
"post_id": 2,
"title": "새로운 글 제목입니다",
"content": "여기에 글 내용을 작성합니다.",
"view_count": 0,
"created_at": "2025-06-16T15:20:00Z",
"updated_at": "2025-06-16T15:20:00Z",
"author_id": 5
}
{
"content": "??? 아님 호날두 월드컵 우승 함?",
"parent_comment_id" : 4
}
{
"comment_id": 19,
"post_id": 13,
"author_id": 2,
"parent_comment_id": 4,
"content": "??? 아님 호날두 월드컵 우승 함?",
"created_at": "2025-06-19T00:52:34.000Z"
}
📦backend
┣ 📂src
┃ ┣ 📂api
┃ ┃ ┣ 📂auth
┃ ┃ ┃ ┣ 📜auth.controller.js
┃ ┃ ┃ ┗ 📜auth.routes.js
┃ ┃ ┣ 📂posts
┃ ┃ ┃ ┣ 📜posts.controller.js
┃ ┃ ┃ ┗ 📜posts.routes.js
┃ ┃ ┗ 📂uploads
┃ ┃ ┃ ┣ 📜uploads.controller.js
┃ ┃ ┃ ┗ 📜uploads.routes.js
┃ ┣ 📂config
┃ ┃ ┗ 📜db.js
┃ ┗ 📂middlewares
┃ ┃ ┣ 📜auth.middleware.js
┃ ┃ ┗ 📜upload.middleware.js
┣ 📂uploads
┃ ┣ 📜image-1750136690808-481934296.jpg
┃ ┣ 📜image-1750136811296-768163654.jpg
┃ ┣ 📜image-1750136827772-633512392.png
...
┣ 📜.env
┣ 📜app.js
┣ 📜database.vuerd.json
┗ 📜package.json
env 파일에 민감정보인 구글 Oauth를 위한 client_id, Pw 등을 저장해두었다.
FE에서 요청만 보내고 BE단에서 최대한 처리해보려고 하였다.
const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID;
const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET;
const GOOGLE_REDIRECT_URI = 'http://localhost:3000/login/redirect';
const FRONTEND_REDIRECT_URI = 'http://localhost:5173/auth/google/callback'; // 프론트엔드 콜백 경로

그림의 flow대로 흘러가는 로직이라고 보면 된다.
단 Refresh Token 없이 이번에는 Access Token 만 활용하였다.
// OAuth 이후 Redirect
app.get('/login/redirect', async (req, res) => {
const { code } = req.query;
try {
// 1. 구글에 액세스 토큰 요청
const tokenResponse = await axios.post('https://oauth2.googleapis.com/token', null, {
params: {
code,
client_id: GOOGLE_CLIENT_ID,
client_secret: GOOGLE_CLIENT_SECRET,
redirect_uri: GOOGLE_REDIRECT_URI,
grant_type: 'authorization_code',
},
});
const { access_token, id_token } = tokenResponse.data;
// 2. 구글 사용자 정보 요청
let googleUserInfo;
if (id_token) {
// 여기서는 간단히 디코딩
const base64Url = id_token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const jsonPayload = Buffer.from(base64, 'base64').toString('utf8');
googleUserInfo = JSON.parse(jsonPayload);
} else {
const userInfoResponse = await axios.get('https://www.googleapis.com/oauth2/v2/userinfo', {
headers: {
Authorization: `Bearer ${access_token}`,
},
});
googleUserInfo = userInfoResponse.data;
}
// console.log('Google User Info:', googleUserInfo); // 디버깅용
const { email, name, id } = googleUserInfo; // 구글 계정의 고유 ID, 이메일, 이름
const [existingUsers] = await db.query('SELECT * FROM USER WHERE email = ? AND provider = ?', [email, 'google']);
let user;
if (existingUsers.length > 0) {
// 기존 사용자
user = existingUsers[0];
console.log('Existing Google user logged in:', user.email);
} else {
const userNickname = name || '구글사용자';
const tempPassword = `google_${id}_${Date.now()}`; // 임의의 값
const hashedPassword = await bcrypt.hash(tempPassword, 12);
const [insertResult] = await db.query('INSERT INTO USER (email, password, nickname, provider) VALUES (?, ?, ?, ?)', [
email,
hashedPassword, // 임시 비밀번호 또는 null
userNickname,
'google',
]);
const newUserId = insertResult.insertId;
user = { user_id: newUserId, email, nickname: userNickname, provider: 'google' };
console.log('New Google user signed up:', user.email);
}
// 4. 서비스 JWT 발급
const serviceToken = jwt.sign(
{ userId: user.user_id, nickname: user.nickname, email: user.email }, // 우리 서비스의 사용자 정보
process.env.JWT_SECRET,
{ expiresIn: '1h' }
);
// 5. 프론트엔드로 리다이렉트 (JWT 포함)
// 닉네임도 함께 넘겨주면 프론트엔드에서 편리하게 사용 가능
res.redirect(`${FRONTEND_REDIRECT_URI}?token=${serviceToken}`);
} catch (error) {
console.error('Google OAuth Error:', error.response ? error.response.data : error.message);
// 에러 발생 시 프론트엔드 에러 페이지로 리다이렉트
res.redirect(`${FRONTEND_REDIRECT_URI}?error=google_oauth_failed`);
}
});
// google Auth Logic
app.get('/login', (req, res) => {
let url = 'https://accounts.google.com/o/oauth2/v2/auth';
// client_id는 위 스크린샷을 보면 발급 받았음을 알 수 있음
// 단, 스크린샷에 있는 ID가 아닌 당신이 직접 발급 받은 ID를 사용해야 함.
url += `?client_id=${GOOGLE_CLIENT_ID}`
// 아까 등록한 redirect_uri
// 로그인 창에서 계정을 선택하면 구글 서버가 이 redirect_uri로 redirect 시켜줌
url += `&redirect_uri=${GOOGLE_REDIRECT_URI}`
// 필수 옵션.
url += '&response_type=code'
// 구글에 등록된 유저 정보 email, profile을 가져오겠다 명시
url += '&scope=email profile'
// 완성된 url로 이동
// 이 url이 위에서 본 구글 계정을 선택하는 화면임.
res.redirect(url);
});
수정기능 작성위해 기존에 게시물 목록을 쏴줄때
작성자의 id 필드가 추가로 필요해 생각없이 id: author_id 추가 했다가
FE에서 받는 형식과 달라서 에러가 발생했다.
그래서 FE에서 인터페이스 정의 다시 하고, 이를 바탕으로 가공해서 보내주었다.
export const getPosts = async (req, res) => {
const page = parseInt(req.query.page || '1', 10);
const limit = parseInt(req.query.limit || '10', 10);
if (isNaN(page) || isNaN(limit) || page < 1 || limit < 1) {
return res.status(400).json({ error: '유효하지 않은 페이지 또는 limit 값입니다.' });
}
try {
const [[{ totalPosts }]] = await db.query('SELECT COUNT(*) as totalPosts FROM Post');
const totalPages = Math.ceil(totalPosts / limit);
const offset = (page - 1) * limit;
// 1. DB에서 데이터를 가져옵니다 (SQL은 이전과 동일합니다).
const [postsFromDB] = await db.query(`
SELECT p.post_id, p.title, p.content, p.created_at, u.nickname as author_nickname
FROM POST p
JOIN USER u ON p.author_id = u.user_id
ORDER BY p.created_at DESC
LIMIT ? OFFSET ?
`, [limit, offset]);
// 2. 프론트엔드가 원하는 중첩 구조로 데이터를 가공
const formattedPosts = postsFromDB.map(post => ({
post_id: post.post_id,
title: post.title,
content: post.content,
created_at: post.created_at,
author: {
id: post.author_id,
nickname: post.author_nickname
}
}));
// 3. 가공된 데이터와 페이지네이션 정보를 함께 응답합니다.
res.status(200).json({
posts: formattedPosts,
pagination: {
currentPage: page,
totalPages: totalPages,
totalPosts: totalPosts
}
});
} catch (err) {
console.error('Error fetching posts:', err);
res.status(500).json({ error: '서버 오류' });
}
};
Local에서 서버를 돌렸고, 이미지나 데이터들을 로컬에 저장해놨는데,
실제로는 AWS EC2, S3 등 클라우드 서비스를 활용해서 배포를 진행하는데
이 과정을 진행하지 못한 것이 아쉽다
쿼리 문을 정처기 공부할때 이후로 처음 봐서
안그래도 타입스크립트 때문에 버거운데 쿼리문까지 살펴보다 보니
실수도 많고 에러도 많이 발생했다.
오늘 발표 잘들었습니다