[week14/개인과제]게시판 Full Stack 도전기 - BE Part

CHO WanGi·2025년 6월 19일

KRAFTON JUNGLE 8th

목록 보기
73/89

개발환경 Setting

npm - concurrently

https://velog.io/@bongjoki/npm-concurrently

개발을 진행하다 보니 front 쪽 터미널 열고 Npm run dev, back 쪽 터미널 열고
npm start 하는게 귀찮아서 한번에 하는게 없을까? 하고 찾아보니
concurrently 라는 모듈을 활용해서 루트 폴더 위치에서 npm run dev로
한번에 개발환경을 실행하였다.

  • package.json
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "dev": "concurrently \"npm start -w backend\" \"npm run dev -w frontend\""
  },

ERD 구성

대댓글 기능

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

API 명세 작성

회원가입

Requst Body

{
  "email": "user@example.com",
  "password": "password123!",
  "nickname": "새로운사용자"
}

Success Response : 201 Created

{
  "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
    }
}

게시글 작성

Requst Body

{
  "title": "새로운 글 제목입니다",
  "content": "여기에 글 내용을 작성합니다."
}

Success Response : 201 Created

{
  "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
}

댓글 작성

Request Body

{
  "content": "??? 아님 호날두 월드컵 우승 함?",
  "parent_comment_id" : 4
}

Response Body : 201 Created

{
    "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 등을 저장해두었다.

Google OAuth 로직

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'; // 프론트엔드 콜백 경로
  • OAuth Logic

그림의 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`);
  }
});
  • Resource Server = Google에 사용자 정보 요청 로직
// 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);
});

FE 받는 형식대로 Data 가공하기

수정기능 작성위해 기존에 게시물 목록을 쏴줄때
작성자의 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: '서버 오류' });
  }
};

아쉬운 점

1. 배포하지 못한 것

Local에서 서버를 돌렸고, 이미지나 데이터들을 로컬에 저장해놨는데,
실제로는 AWS EC2, S3 등 클라우드 서비스를 활용해서 배포를 진행하는데
이 과정을 진행하지 못한 것이 아쉽다

2. Query 문 이해도 부족

쿼리 문을 정처기 공부할때 이후로 처음 봐서
안그래도 타입스크립트 때문에 버거운데 쿼리문까지 살펴보다 보니
실수도 많고 에러도 많이 발생했다.

profile
제 Velog에 오신 모든 분들이 작더라도 인사이트를 얻어가셨으면 좋겠습니다 :)

2개의 댓글

comment-user-thumbnail
2025년 6월 19일

오늘 발표 잘들었습니다

1개의 답글