[무비캐스터] 팀 프로젝트 회고록

Yunhye Park·2023년 11월 25일
1
post-thumbnail
post-custom-banner

팀 프로젝트 소개

✔️ 주제 : 영화 정보 열람 및 코멘트 작성 사이트
✔️ 기간/인원수 : 23.11.7 ~ 23.11.23 (2주) / 4명

4명이서 진행했는데 한 분은 백엔드를 전담, 나를 포함한 3명이 풀스택을 맡았다. 그래서 페이지별로 할 일을 나눴다.

나는 영화 API를 DB에 저장하고, 메인 페이지와 검색 페이지를 담당했다.

✔️ 기술 스택

구현한 내용

🌻 영화 API DB 저장

사용한 영화 API : TMDB

⁃ 필요한 정보 : 영화 제목, 포스터, 개봉일, 줄거리, 장르

⁃ 영화 API는 크게 한국/외국 기반으로 나눌 수 있다. 국내 API는 영화진흥위원회KMDB가 대표적이다.
⁃ 영화진흥위원회는 일별/주별 박스오피스 정보를 제공해서 좋았지만, 1) 포스터 경로와 줄거리가 없고 2) 받아올 수 있는 페이지가 한정적이다. KMDB는 API 키를 바로 발급해서 사용할 수 없고, 며칠 내에 발급이 된다.
그래서 선택한 TMDB. 필요한 정보를 모두 제공해주는 데다가 갈래도 꽤나 다양했다. discover 리스트에서는 특정 키워드별로 찾아볼 수도 있고 말이다.

코드 작성 포인트

🔔 엔드포인트에 접속하면 해당 리스트를 get 요청으로 받아올 수 있도록 설계.
🔔 param에는 특정 페이지 하나만 적어넣을 수 있어서, for문으로 1부터 299까지 돌렸다.
🔔 보안을 위해 API 키는 환경변수로 설정.
🔔 영화 장르는 id(number)로 담긴다. 그래서 장르 id와 이름이 담긴 API와 영화 API를 합쳐 하나의 테이블에 create 해야 한다.
🔔 여러 종류의 영화를 저장할 수 있도록 주석에 url 종류를 적어 팀원들이 특정 부분만 변경하면 다른 정보를 받아올 수 있게 했다.

// model/getMovies.js

async function apiMovies () {
    const movieResults = [];
    const genreResults = [];

  // 영화 장르 API
    const genreUrl = `https://api.themoviedb.org/3/genre/movie/list?api_key=${key}&language=ko-KR`
    const genreResponse = await axios.get(genreUrl);
    genreResults.push(...genreResponse.data.genres);

  // 영화 리스트 API
    for (let i=1; i<300; i++) {
        const urlHead = `https://api.themoviedb.org/3/movie`
        // url 종류 : top_rated / popular / now_playing
        const url = `${urlHead}/upcoming?api_key=${key}&language=ko-KR&page=${i}`;

        try {
            const response = await axios.get(url);
            movieResults.push(...response.data.results);
        } catch (err) {
            throw err;
        };
    }
    
    return {movieResults, genreResults}
};
// controller/CGetMovies.js

const {Movie_info} = require('../model');
const apiMovies = require('../model/getMovies');

exports.get_api_movies = async (req, res) => {
    try {
        // API 불러오는 함수 호출
        const allResults = await apiMovies();
        const movieResults = allResults.movieResults;
        const genreResults = allResults.genreResults;

        // 기존에 있는 영화 확인
        for (const movieData of movieResults) {
            const existingMovie = await Movie_info.findOne({
                where: {
                    title: movieData.title
                }
            });

            // 영화 API의 장르번호와 장르 API의 장르 번호가 같은 {id, name}을 각각 컬럼에 저장
            if (!existingMovie) {
                const genreIds = movieData.genre_ids || []; // 장르 num
                const genres = []; // 장르명
                
                for (const genreId of genreIds) {
                    const genre = genreResults.find((g) => g.id === genreId);
                  // ex. {id: 18, name: 액션}
                    genres.push(genre? genre.name : '정보 없음');
                }

                await Movie_info.create({
                    title: movieData.title,
                    overview: movieData.overview,
                    release_date: movieData.release_date,
                    poster_path: movieData.poster_path,
                    genre_ids: genreIds.join(', '),
                    genre: genres.join(', ')
                });
            }
        }
        res.send('movie get')
    } catch (error) {
        console.error("err:", error);
    }
}

🌻 메인 페이지

메인페이지는 크게 3가지 섹션으로 나눈다.
모두 10개로 통일했고, 최신 영화 / 평점 높은 순으로 / 평점 2.0~3.5 영화이다.

처음 렌더할 때 보여줄 내용이었기 때문에 main 컨트롤러명에 세 가지 내용을 한꺼번에 넣어 객체로 보냈다.

1. New Release

exports.main = async (req, res) => {
  try {
    // section 1: 최신 영화
    const latestMovies = await Movie_info.findAll({
      where: {
        poster_path: { [Op.not]: 'url 없음' },
      },
      order: [['release_date', 'DESC']],
      limit: 10,
    });
    
    // section 2: 평점 높은 영화
    const topRatedMovies = await getTopRatedMovies();
    
    // section 3: 평균 평점이 2.0~3.5 미만인 영화
    const lowerRatedMovies = await getLowerRatedMovies();

    res.render('main',
               { data: { sec1: latestMovies, sec2: topRatedMovies, sec3: lowerRatedMovies } });
  } catch (err) {
    console.error('section err: ', err);
  }
};

⁃ TMDB는 무수히 많은 영화 데이터가 존재하지만, 제목만 있고 내용이 부실한 경우가 많다. 최근에 개봉했다면 더욱이. 그런 건 메인에서 제외해야 한다고 생각해서 poster_path: { [Op.not]: 'url 없음' }를 조건으로 내걸었다. 'url 없음'은 필드 설계할 때 default값으로 설정한 내용이다.

1~10위를 5개씩 3초 주기로 번갈아 보여주는 기능이 있어서 BE보다는 FE 쪽이 중요한 영역이다.

<section class="box-office-section">
  <div class="box-office__film">
    <img src="../static/img/filmroll.jpg" alt="" width="1500px" height="280px" />
      <span class="box-office-text">New Release</span>
      <div class="box-office-swiper">
        <ul class="box-office-slider">
           <% for (let i = 0; i < (data.sec1.length)/2; i++) { %>
              <div class="box-office-rank"><%= i + 1 %></div>
              <li class="box-office-poster">
               <img src="<%= `https://image.tmdb.org/t/p/w154${data.sec1[i].poster_path}` %>" />
              </li>
           <% } %>
         </ul>
       </div>
   </div>
</section>

// <script> section 1 : 슬라이드 3초 주기 무한 반복
document.addEventListener('DOMContentLoaded', () => {
    const boxOfficePosters = document.querySelectorAll('.box-office-poster img');
    const ranking = document.querySelectorAll('.box-office-rank');
        
    const data = <%- JSON.stringify(data.sec1) %>;
    
    let currentIdx = 0;
  
    setInterval(() => {
        if (data && data.length > 0) {
          currentIdx = currentIdx % data.length;
    
          for (let i = 0; i < boxOfficePosters.length; i++) {
             const posterPath = data[currentIdx].poster_path;
             boxOfficePosters[i].src = `https://image.tmdb.org/t/p/w154/${posterPath}`;
             ranking[i].innerHTML = `${(currentIdx % data.length) + 1}`;
             currentIdx++;
           }
        }
      }, 3000);
   });

배운 것

항상 FE에서 요청해야만 데이터를 받을 수 있는 게 아니다. 서버에서 보낸 데이터를 html이 아닌 script 내부에서 사용하고 싶을 때 <%- JSON.stringfy() %>로 받아오면 된다.
나머지(%) 연산자는 현재 값을 원하는 범위 내의 값으로 변경할 때 유용하다. 1부터 10, 총 10개니까 인덱싱 넘버를 모두 10으로 나눈 값의 나머지로 계산했다.

보완 사항

⁃ 인덱싱 연산 부분을 data.length라고 적어뒀지만, limit을 10으로 걸었으니까 10이라고 대체해도 된다. 하지만 나중에 이 코드를 보면 '왜 갑자기 10이 나왔지?'가 의문스러울 것 같기도..

2. Top Rated

⁃ API에 평점이 존재하긴 하지만, 우리는 회원가입 기능이 있기 때문에 사이트 유저들이 작성한 평점의 평균에 따라 실시간으로 바뀌도록 만들었다.

⁃ 테이블 3개(User + Comment + Comment_like)를 조인하면서 영화는 중복 없이, 그런데 평점 높은 순으로 정렬된.. 극강의 조합이었다.

async function getTopRatedMovies() {
  const getMovieInfo = await Comment.findAll({
    attributes: [
      'movieidx',
      [Sequelize.fn('AVG', Sequelize.col('rate')), 'averageRate'],
      [Sequelize.fn('MAX', Sequelize.col('timestamp')), 'timestamp'],
    ],
    group: ['movieidx'],
    order: [[Sequelize.literal('averageRate'), 'DESC']],
    limit: 10,
    raw: true,
    include: [
      {
        model: Movie_info,
        as: 'CommentMovie',
        attributes: ['title', 'poster_path'],
      },
      {
        model: User,
        as: 'CommentUser',
        attributes: [
          [Sequelize.fn('MAX', Sequelize.col('nickname')), 'nickname']
        ],
      },
    ],
  });

  const movieIds = getMovieInfo.map((movie) => movie.movieidx);

  const additionalDetails = await Comment.findAll({
    attributes: [
      'movieidx',
      [Sequelize.fn('MAX', Sequelize.col('rate')), 'maxRate'],
      [Sequelize.fn('GROUP_CONCAT', Sequelize.col('commentid')), 'commentIds'],
      [
        Sequelize.literal(
          '(SELECT GROUP_CONCAT(DISTINCT nickname) FROM User WHERE User.userid = CommentUser.userid GROUP BY CommentUser.userid)'
        ),
        'nicknames',
      ],
    ],
    where: {
      movieidx: movieIds,
    },
    include: [
      {
        model: User,
        as: 'CommentUser',
        attributes: ['userid'],
      },
    ],
    group: ['movieidx', 'CommentUser.userid'],
    raw: true,
  });

  const commentIds = additionalDetails.map((detail) => detail.commentIds.split(','));

  const descriptionDetails = await Comment.findAll({
    attributes: ['commentid', 'description'],
    where: {
      commentid: { [Op.in]: commentIds },
    },
    raw: true,
  });

  const tRatedMovies = getMovieInfo.map(async (movie) => {
    const movieDetails = additionalDetails.filter((detail) => detail.movieidx === movie.movieidx);
    const topDetail = movieDetails.reduce(
      (prev, current) => (prev.maxRate > current.maxRate ? prev : current),
      {}
    );
    const descriptions = descriptionDetails.filter((desc) => topDetail.commentIds.includes(desc.commentid));
    const description = descriptions.length > 0 ? descriptions[0].description : '';
    
    const commentids = descriptionDetails.filter((desc) => topDetail.commentIds.includes(desc.commentid));
    const commentid = commentids.length > 0 ? commentids[0].commentid : '';
    
    return {
      ...movie,
      CommentUser: {
        nickname: topDetail?.nicknames,
      },
      description: description,
      commentid: commentid,
    };
  });

  const topRatedMovies = await Promise.all(tRatedMovies);

  return topRatedMovies;
}

mysql의 특성 때문에 1) 테이블 3개를 조인하면서 2) 중복된 영화 없이 3) 평점 높은 순으로 정렬을 한번에 할 수가 없었다. 이걸 처리하는 게 관건이었다.

계속 만났던 에러

Error: Expression #6 of SELECT list is not in GROUP BY clause
and contains nonaggregated column 'mc_test.CommentUser.nickname'
which is not functionally dependent on columns in GROUP BY clause;
this is incompatible with sql_mode=only_full_group_by

⁃ 원인 : mysql의 ONLY_FULL_GROUP_BY 모드

💡 ONLY_FULL_GROUP_BY 모드?
SELECT문에서 GROUP BY 구문을 사용할 때, GROUP BY에 존재하지 않는 열은 반드시 집계 함수로 작성. GROUP BY 절에 있는 모든 열들은 집계 함수 없이.

⁃ 시도 : GROUP BY에 해당 열(description) 추가 -> 해당 열은 고유한 필드라서 이렇게 설정하면 description만 다른 레코드는 다 가져올 수 있으므로 영화 중복 가능.
⁃ 해결 : MAX, AVG 등 적절한 집계함수 추가.
ex. [Sequelize.fn('MAX', Sequelize.col('description')), 'description']

⁃ 이러다보니 온갖 집계함수가 등장했는데 좀 더 쉽게 바꿀 방법이 분명 있을 듯하다.

보완 사항

⁃ 더 심플한 코드로 작성.
⁃ 유저가 작성한 코멘트(평점)가 없으면 메인에 아무 포스터도 보이지 않는다. 예외처리 필요.
⁃ 좋아요 상태 유지 기능 추가.
💡 좋아요 동작(레코드 생성/삭제 + 클래스 온/오프)은 하는데, 페이지를 리로드하면 유저가 좋아요 한 코멘트가 리셋되었다. 공식 프로젝트 끝나고나서 내 로직에 틈을 발견했다. 레코드가 존재하고,그 레코드의 useridx가 유저 세션과 동일한 케이스와 레코드가 존재하지만 유저 세션이 동일하지 않은 케이스로 나눠서 처리해야 할 듯하다.
⁃ 좋아요 클릭 시 애니메이션 (Ref. instagram)

3. 호불호 영화 (평점 2.0~3.5)

⁃ 이전의 것보다는 쉬운.. 테이블 2개(코멘트 + 영화 테이블) 조인. 하지만 평점 높은 순으로 정렬하는 건 동일!

async function getLowerRatedMovies() {
  const lowerRatedMovies = await Comment.findAll({
    attributes: [
      'movieidx',
      [Sequelize.fn('ROUND', Sequelize.fn('AVG', Sequelize.col('rate')), 1), 'averageRate'],
      // 소수점 1자리까지 반올림
    ],
    group: ['movieidx'],
    having: Sequelize.literal('averageRate < 3.5 AND averageRate >= 2.0'),
    order: [[Sequelize.literal('averageRate'), 'DESC']],
    limit: 10,
    raw: true,
    include: [
      {
        model: Movie_info,
        as: 'CommentMovie',
        attributes: ['title', 'poster_path'],
      },
    ],
  });

  const resultLowerRated = await Promise.all(
    lowerRatedMovies.map(async (movie) => {
      const additionalDetails = await Comment.findAll({
        attributes: [
          'movieidx',
          [Sequelize.fn('MAX', Sequelize.col('rate')), 'maxRate'],
          [Sequelize.fn('GROUP_CONCAT', Sequelize.col('commentid')), 'commentIds'],
          [
            Sequelize.literal(
              '(SELECT GROUP_CONCAT(DISTINCT nickname) FROM User
              WHERE User.userid = CommentUser.userid GROUP BY CommentUser.userid)'
            ),
            'nicknames',
          ],
        ],
        where: {
          movieidx: movie.movieidx,
        },
        include: [
          {
            model: User,
            as: 'CommentUser',
            attributes: ['userid'],
          },
        ],
        group: ['movieidx', 'CommentUser.userid'],
        raw: true,
      });

      const commentIds = additionalDetails.map((detail) => detail.commentIds.split(','));

      const descriptionDetails = await Comment.findAll({
        attributes: ['commentid', 'description'],
        where: {
          commentid: { [Op.in]: commentIds },
        },
        raw: true,
      });

      const movieDetails = additionalDetails
                           .filter((detail) => detail.movieidx === movie.movieidx);
      const topDetail = movieDetails
                           .reduce((prev, current) => 
                                     (prev.maxRate > current.maxRate ? prev : current), {});
      const descriptions = descriptionDetails
                           .filter((desc) => topDetail.commentIds.includes(desc.commentid));
      const description = descriptions.length > 0 ? descriptions[0].description : '';

      return {
        ...movie,
        CommentUser: {
          nickname: topDetail?.nicknames,
        },
        description: description,
      };
    })
  );

  return resultLowerRated;
}

⁃ 평점별로 날씨 아이콘을 다르게 처리하는 기능을 넣었다.

<section class="md-section">
  <div class="md-section__title section__title">
    <i class="bi bi-brightness-high sun-icon"></i>
<i class="fa-solid fa-cloud cloud-icon"></i>
<h4>호불호 확실한 영화</h4>
</div>
<div class="swiper md-section-swiper">
  <div class="swiper-wrapper md-movie__list section__list">
    <% for (let i=0; i<data.sec3.length; i++) { %>
      <div class="swiper-slide md-movie__body section__body">
        <div class="md-movie__poster-body section__poster-body">
          <ul>
           <li>
            <div class="md-movie__poster" onclick="move(<%= data.sec3[i].movieidx %>)">
              <img
                src="<%= `https://image.tmdb.org/t/p/w200${data.sec3[i]['CommentMovie.poster_path']}` %>"
                alt="" />
            </div>                 
           </li>
          </ul>
        </div>
        <div class="md-movie__info">
           <ul>
              <li class="md-movie__rating">
                 <span class=""><%= data.sec3[i].averageRate%></span>
              </li>
              <li>
                <h4 class="movie-title__large"><%= data.sec3[i]['CommentMovie.title'] %></h4>
              </li>
           </ul>
        </div>
     </div>
    <% } %>
   </div>
 </div>
 <div class="arrow-box swiper-button-prev__bt">
     <i class="fa-solid fa-chevron-left arrow-icon left-arrow"></i>
 </div>
 <div class="arrow-box swiper-button-next__bt">
     <i class="fa-solid fa-chevron-right arrow-icon right-arrow"></i>
 </div>
</section>

// <script> main.ejs

const rateIcons = document.querySelectorAll('.md-movie__rating');
const dataSec3 = <%- JSON.stringify(data.sec3) %>;

document.addEventListener('DOMContentLoaded', () => {
  for (let i = 0; i < 10; i++) {
    const movieData = dataSec3[i].averageRate;

    const iconElement = document.createElement('i');

    if (movieData >= 4.0) {
      iconElement.className = 'bi bi-brightness-high sun-icon weather-icon';
      iconElement.setAttribute('data-index', i);
    } else if (movieData >= 3.0) {
      iconElement.className = 'bi bi-cloud-sun weather-icon';
      iconElement.setAttribute('data-index', i);
    } else {
      iconElement.className = 'bi bi-cloud-rain rain-icon weather-icon';
      iconElement.setAttribute('data-index', i);
    }

    iconElement.setAttribute('data-index', i);

    // 아이콘 적용
    if (rateIcons[i]) {
      // 제대로 선택했나 확인
      if (rateIcons[i].hasChildNodes()) {
        rateIcons[i].insertBefore(iconElement, rateIcons[i].firstChild);
      } else {
        rateIcons[i].appendChild(iconElement);
      }

    } else {
      console.error(`rateIcons[${i}] is undefined.`);
    }
  }
});

배운 것

⁃ 예외 처리의 중요성. 아이콘을 적용할 때에도 확실히 데이터가 존재하는지부터 확인하고(.hasChildNodes()) 다음 단계로 넘어가야 어느 부분에서 에러가 발생했는지 역추적이 쉬워진다.
⁃ html에 모든 요소를 미리 만들지 않아도 된다. 상황에 따라 달라진다면 append 등을 사용해 각
data- 접두어로 사용자 정의 태그를 만들 수 있단 걸 처음 알았다. 이걸 setAttribute()와 묶어서 태그의 속성을 부여할 수 있다. 하지만 자율성이 높아진 만큼 남용은 자제해야 할 듯하다.

보완 사항

⁃ 4.0 이상 데이터가 넘어올 리가 없는데 추가했다. if 조건식을 논리곱 없이 간단하게 쓰려다보니 실수한 듯.

2, 3 공통 사항

⁃ swiper 라이브러리로 좌우 슬라이드 기능을 넣었다.
⁃ CDN 삽입이나 설치 후, 클래스명을 swiper > (swiper-wrapper) > swiper-slide 순으로 지정해야 한다.
⁃ slide는 말 그대로 슬라이드로 넘길 요소, wrapper는 슬라이드 묶음을, swiper는 슬라이드 묶음과 버튼을 한데 모아둔 거라고 보면 된다. 고로 button을 넣을 때에만 swiper-wrapper가 필요하다.

🌻 검색 페이지

⁃ 검색 기능과 태그별 추천 영화 기능이 함께 있었다. 그런데 두 기능을 유지하면서 영화 상세 페이지 이동이 불가해서 검색 기능만 남겨두고, 페이지 이동을 편하게 하려고 로직도 바꿨다.

exports.search_movie = (req, res) => {
    res.render('search', {data: null});
};

exports.search_movie_result = (req, res) => {
    if (!req.query.input) {
        res.json({data: null});
    } else {
        Movie_info.findAll({
            where: {
                title: {[Op.like]: `%${req.query.input}%`}
            }
        }).then((result) => {
            let movieInfo;

            if (result && result.length > 0) {
                movieInfo = result.map(movie => ({
                    movieidx: movie.movieidx,
                    title: movie.title,
                    poster: movie.poster_path,
                    count: result.length
                }));
            } else {
                movieInfo = [{ msg: '검색 결과가 없습니다.' }];
            }

            res.json({data: movieInfo, searchInput: req.query.input});
        
        })
    }
}

⁃ 검색 결과 없을 때의 메시지까지 BE에서 작성하여 FE에서는 어떤 메시지인지 구체적으로 보이지 않게 했다.

// search.ejs

<div class="search-container">
  <section class="search__section">
    <div class="search__body">
      <input type="text" name="search" class="search-body__input"
             id="search" placeholder="검색할 영화를 입력하세요." />
        <i class="fa-solid fa-magnifying-glass regular-icon search-icon"
           onclick="searchResult()"></i>
    </div>
    <a href="/"><i class="fa-solid fa-xmark regular-icon close-icon"></i></a>
  </section>

 <section class="search-result__section hidden">
   <h2 class="search-result__count">
     <span style="color: var(--blue-point-color);">검색 결과</span>
     <span id="result__count">0</span>
     <span style="color: var(--blue-point-color);"></span>
   </h2>
   <div class="search-result__list"></div>
   <div class="search-result"></div>
 </section>
</div>

<%- include('./footer') %>

 // <script>
const posterUrl = `https://image.tmdb.org/t/p/w184`;

const searchInput = document.querySelector('.search-body__input');
const searchCount = document.querySelector('.search-result__count');
const resultPosterBody = document.querySelectorAll('.search-result__poster');
const resultPoster = document.querySelectorAll('.search-result__poster img');
const failResult = document.querySelector('.search-result');

function searchResult() {
  const resultSection =  document.querySelector('.search-result__list')

  document.querySelector('.search-result__section').classList.remove("hidden")

  axios({
    method: 'get',
    url: '/search/result',
    params: {input: searchInput.value}
  }).then((res) => {
    const result = res.data;

    document.querySelector("#result__count").innerHTML
      = result.data[0].msg ? 0 : result.data.length;

    let html = "";
    for(let i=0; i<result.data.length; i++) {
      if (result.data[0].msg) {
        failResult.textContent = result.data[0].msg;
      } else {
        const movie = result.data[i]
        html += `<div class="search-result__box">
<div class="search-result__body"token interpolation">${movie.movieidx})">
<div class="search-result__info">
<div class="search-result__poster">
<img src="https://image.tmdb.org/t/p/w154${movie.poster}" alt="${movie.title} 포스터 이미지" />
</div>
<h3 class="search-result__title">${movie.title}</h3>
</div>
</div>
</div>`
      }
    }

    resultSection.innerHTML = html;
  })
}

배운 것

⁃ 리액트에서 동적 UI 만들 때와 비슷한 것 같다. 먼저 html와 css 구상을 끝낸 다음에 조건 충족하는 경우에 append(), innerHTML 등으로 추가한다.
⁃ 이때 최대 장점 : 사용자 검색 패턴별로 예외처리 할 필요가 없다. 원래는 html이 고정된 상태라 최대 10개로 개수를 제한해놔서 어떤 상황이건 10개가 나왔다. 그리고 현재 검색 결과가 10개인데 다시 검색한 결과로 5개만 나온다면 나머지 5개의 데이터를 덮어쓰지 못했기 때문에 상황이 엉켰다. 그래서 케이스 별로 초기화 설정을 넣었던 건데.. 모든 문제가 단번에 해결.

보완 사항

⁃ 시간이 없어서 검색 기능만 이렇게 해놨는데 태그별 영화도 같은 방식으로 가능할 것 같다. 공통 부분이 많으니 함수로 묶어야겠다.
⁃ 포스터 없는 영화는 alt 말고 css로 백그라운드 컬러를 입히거나 이미지를 넣어두는 게 UI적으로 좋겠다.
⁃ 현재는 영화 타이틀로만 검색이 가능한데 장르명으로도 가능하게 로직을 바꿔보는 것도 좋겠다.


프로젝트 발표 피드백

발표 PPT 구성

  • 프로젝트 소개 및 기획의도
  • 팀 소개 및 역할 분담
  • 개발 환경
  • 화면정의서 및 주요 기능 설명
  • 데이터모델링
  • API명세서
  • 화면 시연 영상
  • 협업 과정

받은 피드백

좋았던 점

  1. 커뮤니케이션 툴(노션) 소개
  2. 개발 환경 모두 언급하며 설명
  3. 화면 정의서 내용(사진 + 핵심 description)이 잘 이해됨
  4. ERD 보여준 것
  5. API 명세서 보여준 것

아쉬운 점

  1. OPEN API 사용 시 어떤 API를 썼는지 설명
  2. 기능 + 코드/로직 + 기술 설명
    ex. 스와이프 기능이 있다 -> 라이브러리 or 직접 구현 / 유효성 검사는 정규식 처리, ...
  3. 에러처럼 보이지 않게 화면 처리
    ex. 로그인 하지 않은 상태에서 좋아요 클릭 시 alert 기능
  4. 무엇이 어렵고 무엇을 배웠는지 짧게라도 팀원별 회고 넣기
  5. 반응형 필수
  6. 로그인이 필수인 기능은 살펴보기 번거로움 -> 기본 value 채운 상태로 진행

셀프 피드백

⁃ 추가 커뮤니케이션 툴 : 슬랙

⁃ 팀 슬랙을 만들어서 3개의 스레드 기준에 맞게 정보/상황을 공유했다.
✔️ 공지 : 언제나 손쉽게 확인할 수 있는 참고 사항
✔️ 자유 : PR을 올렸을 때/받았을 때 모두 DM을 남겼다. 그래서 git pull을 까먹어서 누락된 일이 전혀 없었고, 서로 무엇을 하고 있는지 업데이트 상황도 바로바로 알 수 있었다.
✔️ 코드 : 비슷한 기능(ex. 검색 페이지의 검색 - 마이 페이지의 검색)은 먼저 구현한 사람이 자신의 코드를 공유해 코드 리뷰를 하며 수정하는 식으로 진행했다.

⁃ github ReadMe에 정리할 생각을 못했단 게 아쉽다. 마지막까지 개발에 집중하느라 우리가 한 걸 갈무리 할 시간이 너무 없었다.

⁃ 폴더 구조에 fragment(공통 부분)를 만들어 공통으로 사용하던 header나 footer를 넣었으면 구조를 더 잘 보여줄 수 있을 것 같다.

⁃ API 명세서, ERD는 잘 작성해서 보여준 듯하다. 우리 팀은 페이지별로 할 일이 나눠져서 API 명세서가 큰 의미는 없었지만, 다음에 FE/BE가 명확한 팀플을 할 때엔 이번에 한 것처럼 상세히 적으면 되겠다.

⁃ 발표 자료에서 화면 정의서에 검색 페이지를 누락했다. 🤦‍♀️ 열심히 해놓고 못 보여주다니 억울.


공식적으로(?) 프로젝트는 끝났지만

피드백을 바탕으로 각자 우선 순위를 정하고, 일주일(~11/30) 안에 끝낼 분량을 스스로 설정했다.

아래는 내가 할 일!

  1. 반응형 (메인, 검색)
  2. 메인 페이지 코멘트 좋아요 유지
  3. 로그인 없이 코멘트 좋아요 클릭 시 alert 기능 추가
  4. 애니메이션 : card 호버, 코멘트 좋아요 클릭
  5. 프로젝트 ReadMe 작성

우리는 특히 기능 구현에 집중한 팀이어서 UI나 디자인을 확실히 신경 쓰지 못했다. 그래서 최소한의 디자인 공통 사항(ex. 폰트)을 정하고, 반응형도 각자 구현하기로 했다. 이후에는 각자 보완하고 싶은 사항은 개인적으로 진행하기로 했다.


짤막하게 KPT

KEEP

  • 팀원 모두의 성향에 맞는 소통 방식

  • 처음부터 냅다 개발에 들어가지 않고 ERD와 API 설계서, 깃 컨벤션 규칙, 브랜치 규칙 등 틀 짜기에 힘쓴 것

  • 슬랙 채널 새로 파서 소통한 것

  • 약간 애매하거나 잘 모르겠다 싶으면 바로바로 물어본 것

  • 깃이 난리가 나든 배포가 안 되든 침착하게 대처한 것

  • 열심히 함

  • 모각코 왜 하는지 알겠다.. 플젝 기간 거의 내내 강의실에서 하니까 좋았음

  • 로직을 내 머리로 직접 짜보려 노력하고, 설령 도움을 받는다 한들 그 코드를 읽고 해석하고자 한 것

  • PR 하면서 코드 리뷰

PROBLEM

  • 하지만 통으로 4일 동안 틀 잡기를 했던 것 치곤.. 초반에 너무 진도를 못 뺀 느낌이다.

    • 첫날 : 주제 선정 및 기획
    • 이튿날 : [FE] 와이어프레임 + 유저 플로우 / [BE] API 설계서 / [공통] 룰 만들기(깃 컨벤션, 협업 툴 선정, 클래스와 아이디명)
    • 삼일째 : 전체 일정 짜기 / 파트별로 공통 사항 정하기
  • 이렇게 했다면 스퍼트가 더 잘 나지 않았을까. 아무래도 처음 하는 프로젝트다 보니 무엇을 어느 정도로 해야 하는지 애매했던 것 같다. 잘 모르겠으니 일단 하고 보자!는 마인드가 좀 있었음. 근데 이게 problem에 들어올 정도는 아닌 것 같긴 하다.. 필요한 시행착오.

  • 좋아요 클릭하고 리로드 시 상태 유지... BE는 됐는데 FE로 처리가 안 된다.. 일주일 째 붙잡고 있는 중. 파다보면 답이 나오겠거니 싶다.

  • 스트레스 관리... 쉽지 않다. 이 기간에 운동을 전혀 하지 못한 것이 큰 몫을 한다고 생각.

TRY

  • 리액트에서는 useEffect로 간단히 처리할 수 있으니까, 역으로 리액트 수업하면서 좋아요 기능 해보고 바닐라로 옮겨 가서 해볼까 싶다.

  • 혹은 바닐라 자바스크립트로 좋아요 기능 유지에만 포커스 맞춰서 만들고 -> DB 연결해서 레코드 생성/삭제도 추가해 보기.

  • 이렇게 회고록으로 + 포폴 자료로 꼼꼼히 적었으니 다음엔 좀 더 효율적으로 진행할 수 있지 않을까 기대해본다. 그러나 무엇보다 중요한 건 팀원들과의 합이라고 뼈저리게 느껴서.. 일 처리가 잘 된다는 건 그만큼 소통이 잘 되어서 할 일에만 집중할 수 있다는 거니까.

  • 다음번엔 운동하면서 하기! 인간의 의지와 집중력은 하루에 총량이 정해져 있으니까, 잘 안 되면 어디 나가서 걷는 게 낫다.


그외 프로젝트 끝나고 할 일

  • 코드 주석 지우기
  • 코드 포맷팅 (프리티어)
  • 사용하지 않는 폴더 , 파일 삭제 (temp/ 같은 폴더에 몰아 넣던지)
  • package.json 수정 (name, desc, main, author, ....)
  • 버그 수정
  • 포트폴리오 작성
profile
일단 해보는 편
post-custom-banner

0개의 댓글