상세 컨텐츠

본문 제목

#3. 풋살 온라인 프로젝트 - Node.Js

내일배움캠프 학습/진행 프로젝트

by 남민우_ 2024. 12. 6. 21:06

본문

이번에 진행한 프로젝트는 '풋살 온라인 프로젝트'이다.

물론 클라이언트까지 개발하는 것은 아니고, 지난 '아이템 시뮬레이터'와 비슷하게 API 구현 작업과 추가로 DB 설계 및 데이터 연동까지 작업을 진행했다.

팀 프로젝트로 이루어져 업무를 나누어서 진행했는데 내가 맡은 파트는

1. 보유 선수 조회 API

2. 스쿼드 조회 API

3. 스쿼드 등록/삭제 API

이렇게 4가지가 있다.

https://github.com/Namminu/BootCamp_FootballOnline

 

GitHub - Namminu/BootCamp_FootballOnline: 내일배움캠프 - 3주차 풋살 온라인 프로젝트

내일배움캠프 - 3주차 풋살 온라인 프로젝트. Contribute to Namminu/BootCamp_FootballOnline development by creating an account on GitHub.

github.com

이번 프로젝트의 깃허브 주소이다.

 

먼저 DB구조와 그 기능들에 대해 살펴보자.

DB 및 테이블 구조

먼저 DB 연결은 지난 프로젝트와 동일하게 AWS의 RDS를 이용하였다. 

DB 안의 테이블은 총 4개로 이루어져 있는데 이를 설명하는 ERD를 작성해보았다.

게임의 흐름 상 회원가입 후 로그인, 이후에 게임 기능들을 이용할 것이다.

 

해서 회원가입하는 계정을 저장하는 Accounts 테이블과 게임 플레이에 이용할 선수들의 정보를 미리 업로드 해둔 Players 테이블이 준비되어 있다.

 

또한 Accounts 테이블과 1:1로 연결하는 MyPlayers 테이블에서는 '선수 뽑기 API' 를 이용해 획득한 선수들의 정보를 저장한다. 이 안에서 'enhanced' 컬럼을 추가하여 해당 선수의 능력치를 강화하고 그 강화 단계 수치를 나타내도록 하였다.

 

마지막으로 Squad 테이블은 기능적으로 설명하면 다른 사람과 경쟁하는 게임 플레이에서 사용할 선수 스쿼드를 맡고 있다. 마찬가지로 Accounts 테이블과 1:1 로 연결되어 계정 당 한 개의 스쿼드만을 가질 수 있도록 하였고, MyPlayers 에서 id값으로 지정받는 myPlayer_id 컬럼을 squad_player1/2/3 컬럼에 저장하도록 해 스쿼드를 등록하는 역할을 만들어주었다.

 

그럼 이제 코드를 하나씩 살펴본다. 대략적은 흐름은 지난번 프로젝트와 유사하긴 하다.

 

1. 보유 선수 조회 API

router.get('/players/getPlayer', authMiddleware, async (req, res, next) => {
    try {
        const accountId = +req.account.account_id;
        if (!accountId) res.status(404).json({ message: "해당 계정이 존재하지 않습니다." });
        // MyPlayers 테이블과 Players 테이블 Join 데이터 할당
        const players = await prisma.myPlayers.findMany({
            where: { account_id: accountId },
            include: { players: true },
            orderBy: { player_id: 'asc' }
        });
        // 보유한 선수가 없을 경우
        if (!players || players.length === 0) {
            return res.status(404).json({ message: "보유한 선수가 존재하지 않습니다." });
        }
        // 보유 선수 데이터 매핑
        const playerList = players.map(player => ({
            "선수 이름": player.players.player_name,
            "강화 단계": player.enhanced,
            myPlayer_id: player.myPlayer_id
        }));

        return res.status(200).json({ "myPlayers": playerList });
    } catch (err) {
        console.log(err);
        next(err);
        return res.status(500).json({
            message: "서버 에러 발생",
            errCode: err.message,
        });
    }
});

 

보유 선수는 선수 뽑기 API를 통해 데이터 추출, account_id 를 단위로 계정에 저장된다.

해서 미들웨어 'authMiddleware' 를 통해 받은 req.account 에서 account_id 값을 추출해 이를 활용한다.

 

myPlayers 테이블에서 findMany 메서드를 통해 account_id 를 특정 후 Players 테이블과 inner join, player_id 를 기준으로 오름차순 정렬하여 players 에 저장한다.

보유한 선수가 없을 경우에 대한 예외 처리 또한 추가해주었으며 이후에 playerList 변수에 원하는 정보들을 매핑하는 과정을 진행 후 이를 반환한다.

 

테스트를 진행하면 다음과 같이 나타난다.

같은 이름의 선수라고 해도 강화 수치가 다를 수 있다. 해서 선수 이름과 그 데이터의 강화 수치, 이후 스쿼드 추가/삭제를 할 때 필요한 myPlayer_id 를 같이 출력한다.

 

2. 스쿼드 조회 API

router.get('/squad/:targetId', authMiddleware, async (req, res, next) => {
  try {
    // JWT 를 통해 받은 로그인 한 계정의 ID값
    const myAccountId = +req.account.account_id;
    // 로그인이 되어 있지 않을 경우 처리
    if (!myAccountId) return res.status(401).json({ message: "로그인이 필요합니다." });

    // 찾으려는 계정의 account_id값
    const targetId = +req.params.targetId;
    const target = await prisma.accounts.findUnique({ where: { account_id: targetId } });
    // 찾는 계정이 존재하지 않을 경우 처리
    if (!target) return res.status(404).json({ message: "존재하지 않는 계정입니다." });

    // 내 스쿼드가 아닌 경우
    if (myAccountId !== target.account_id) {
      const squad = await prisma.squad.findFirst({
        where: { account_id: targetId },
        include: {
          players1: { include: { players: true } },
          players2: { include: { players: true } },
          players3: { include: { players: true } },
        }
      });

      // 스쿼드가 존재 하지 않을 경우 처리
      if (!squad) return res.status(404).json({ message: "해당 계정은 스쿼드를 보유하고 있지 않습니다." });

      // 스쿼드 데이터 매핑
      const playerSlots = [squad.players1, squad.players2, squad.players3];
      const squadList = playerSlots.map((slot, idx) => {
        if (!slot) return null;
        const myPlayer_id = slot.myPlayer_id;
        return {
          [`Player${idx + 1}`]: `+${slot.enhanced} ${slot.players.player_name}`,
          myPlayer_id
        };
      }).filter(Boolean);

      return res.status(200).json({ [`${target.account_name} 님의 스쿼드 `]: squadList });
    }
    // 내 스쿼드인 경우
    else {
      const squad = await prisma.squad.findFirst({
        where: { account_id: targetId },
        include: {
          players1: { include: { players: true } },
          players2: { include: { players: true } },
          players3: { include: { players: true } },
        }
      });

      // 스쿼드가 존재 하지 않을 경우 처리
      if (!squad) return res.status(404).json({ message: "현재 스쿼드를 보유하고 있지 않습니다." });

      // 스쿼드 데이터 매핑
      const playerSlots = [squad.players1, squad.players2, squad.players3];
      const squadList = playerSlots.map((slot, idx) => {
        if (!slot) return null;
        const myPlayer_id = slot.myPlayer_id;
        return {
          [`Player${idx + 1}`]: `+${slot.enhanced} ${slot.players.player_name}`,
          myPlayer_id
        };
      }).filter(Boolean);

      return res.status(200).json({ [`나의 스쿼드 `]: squadList });
    }

  } catch (err) {
    console.log(err);
    next(err);
    return res.status(500).json({
      message: "서버 에러 발생",
      errCode: err.message
    });
  }
});

 

똑같은 '조회'의 기능을 맡은 GET 메서드이기 때문에 1번 API와 대부분 유사하고, 다른 점이라고 한다면 더 많아진 예외 처리, 그리고 JWT 로 전달받은 아이디(게임적으로 설명하면 본인 계정)를 조회할 때와 타인의 스쿼드를 조회할 때의 처리를 나누었다는 점이 있겠다.

 

특히 이 API는 params 로 targetId 를 전달받는다. 조회하고자 하는 account_id 를 뜻하는데 이를 targetId, target 변수에 사용하여 찾는 계정이 존재하는지에 대한 예외처리를 진행한다.

 

이후

 if (myAccountId !== target.account_id)

해당 조건문을 통해 JWT 로 전달받은 계정인 myAccountId 가 찾는 대상이 아닐 경우, 즉 타인의 스쿼드를 조회하고자 하는 경우를 분기점으로 나누어주었다.

내 스쿼드를 조회하는 경우에는 추가적인 정보를 더 보여줄 수도 있겠지만, 마땅히 보여줄 만한 데이터가 없다고 판단되어 내부 로직 자체는 동일하다.

 

이후에는 마찬가지로 데이터를 매핑하여 변수에 저장하는데, 좀 더 게임적으로 보이기 위해 문자열을 가공해주었고 그나마 이 부분에서 타인 스쿼드 조회와 내 스쿼드 조회의 경우를 다르게 나타내었다.

 

실행 결과는 다음과 같이 나타난다.

 

 

3. 스쿼드 추가 API

router.post("/squad/:myPlayerId/setup", authMiddleware, async (req, res, next) => {
  try {
    // 데이터 유효성 검사
    if (!req.account) return res.status(401).json({ message: "로그인이 필요합니다." });
    const accountId = +req.account.account_id;
    const players = await prisma.myPlayers.findMany({ where: { account_id: accountId } });
    if (players.length === 0) return res.status(404).json({ message: "현재 보유한 선수가 존재하지 않습니다." });

    // params 데이터 유효성 검사
    const myPlayerId = +req.params.myPlayerId;
    const player = await prisma.myPlayers.findUnique({
      where: {
        myPlayer_id: myPlayerId,
        account_id: accountId
      }
    });
    if (!player) return res.status(404).json({ message: "현재 해당 선수를 보유하고 있지 않습니다." });

    // 스쿼드에 남는 자리가 있는지 검사
    const squad = await prisma.squad.findUnique({
      where: { account_id: accountId },
      include: {
        players1: { include: { players: true } },
        players2: { include: { players: true } },
        players3: { include: { players: true } },
      }
    });
    if (squad) {
      const alreadySquad = [squad.squad_player1, squad.squad_player2, squad.squad_player3].filter(Boolean);
      if (alreadySquad.length >= 3) return res.status(400).json({ message: "더 이상 스쿼드를 추가할 수 없습니다." });
    }

    // 스쿼드에 해당 선수가 이미 등록되어 있는지 검사
    const alreadyPlayer = await prisma.squad.findFirst({
      where: {
        account_id: accountId,
        OR: [
          { squad_player1: myPlayerId },
          { squad_player2: myPlayerId },
          { squad_player3: myPlayerId },
        ]
      }
    });
    if (alreadyPlayer) return res.status(400).json({ message: "해당 선수는 이미 등록되어 있습니다." });

    // Squad 테이블에 본인 계정 데이터가 없을 경우 새로 추가
    if (!squad) {
      await prisma.squad.create({
        data: {
          account_id: accountId,
          squad_player1: myPlayerId
        }
      });
      const rolPlayer = await prisma.players.findUnique({ where: { player_id: player.player_id } });
      const message = `${rolPlayer.player_name} 선수를 스쿼드에 등록했습니다`;
      return res.status(200).json({ message, myPlayer_id: myPlayerId });
    }

    // Squad 테이블의 빈 컬럼에 데이터 등록
    let updateData = null;
    if (!squad.squad_player1) updateData = { squad_player1: myPlayerId };
    else if (!squad.squad_player2) updateData = { squad_player2: myPlayerId };
    else if (!squad.squad_player3) updateData = { squad_player3: myPlayerId };
    else return res.status(400).json({ message: "더 이상 스쿼드를 추가할 수 없습니다." });

    await prisma.squad.update({
      where: { squad_id: squad.squad_id },
      data: updateData,
    });

    // 로직 종료
    const rolPlayer = await prisma.players.findUnique({ where: { player_id: player.player_id } });
    const message = `${rolPlayer.player_name} 선수를 스쿼드에 등록했습니다`;
    return res.status(200).json({ message, myPlayer_id: myPlayerId });
  } catch (err) {
    console.log(err);
    next(err);
    return res.status(500).json({
      message: "서버 에러 발생",
      errCode: err.message,
    });
  }
});

 

 

코드가 제법 긴데, 천천히 흐름을 따라가다보면 금방 이해할 수 있다.

 

먼저 처음에는 마찬가지로 전달받은 데이터의 유효성을 검사한다.

JWT의 account_id 로 MyPlayers 테이블에서 탐색한 정보가 존재하는지, params 로 전달받은 myPlayer_id (이 코드에서는 추가할 선수의 아이디를 나타내기 위해 사용하였다.) 가 MyPlayers 테이블에 존재하는지에 대해 검사한다.

 

여기까지 통과가 되었다면 내 선수 목록에 추가하려는 선수가 존재하다는 것까지 확인히 된다.

그렇다면 다음에 검사할 항목은 내 스쿼드에 빈 자리가 있는지 이다.

 

account_id 를 기준으로 Squad 테이블을 탐색해 players1, players2, players3 의 정보를 찾고 이 선수들 데이터가 요구사항 기준인 3명을 넘어간다면 return 처리 하여 스쿼드를 더이상 추가할 수 없도록 하였다.

 

또한 스쿼드에 빈 자리가 있어도, 같은 선수가 중복으로 추가되어선 안된다. 손흥민 11명이 경기를 할 수는 없지 않을 것이니 말이다.

해서 위에서 탐색한 players1, players2, players3 의 정보가 들어간 alreadySquad 에서 각각의 myPlayer_id 를 찾고 이미 들어가 있는지를 확인한다.

 

여기까지 유효성 검사가 모두 진행되고 전부 통과하였다면 이제 스쿼드에 선수를 추가할 준비가 끝났다.

다만 추가하는 경우에도 한가지 분기를 나눠야 하는데, 바로 해당 계정에 이미 스쿼드가 추가된 적이 있는지이다.

스쿼드가 추가된 적이 없다면 새로운 데이터를 만들어야 할 것이고, 이미 존재하는 스쿼드가 있고 빈자리가 남아있는 것이라면 해당 데이터의 빈 컬럼을 찾아서 그에 할당해야 한다.

 

따라서

if (!squad)

 

이 단순한 조건문을 통해 이미 squad 가 존재하는지를 확인, 존재하지 않는다면 create 메서드를 통해 새롭게 데이터를 생성하고 그에 선수를 등록한다.

 

선수가 존재하지 않을 경우는 등록하기 전에 먼저 비어있는 컬럼을 찾아야 한다. squad_player1/2/3 중에서 빈 값을 updateData 로 받아오고 이에 새로운 선수 정보를 할당한다.

코드를 보면 squad_player2, 3 뿐만 아니라 1에 대해서도 로직을 수행하는 것을 볼 수 있는데 이는 이후에 설명할 스쿼드 삭제 API를 통해 squad_player1을 삭제하고 player2, 3 은 남아있는 경우를 대비해 추가한 코드이다.

 

결과를 실행하면 다음과 같이 나타난다.

 

4. 스쿼드 삭제 API

router.delete("/squad/:myPlayer_id/setdown", authMiddleware, async (req, res, next) => {
  try {
    // 데이터 유효성 검사
    if (!req.account) return res.status(401).json({ message: "로그인이 필요합니다." });
    const accountId = +req.account.account_id;

    const squad = await prisma.squad.findUnique({
      where: { account_id: accountId },
      select: {
        squad_id: true,
        squad_player1: true,
        squad_player2: true,
        squad_player3: true,
      },
    });
    if (!squad) return res.status(404).json({ message: "스쿼드가 등록되어 있지 않습니다." });

    // Squad 테이블에서 params 데이터 조회
    const myPlayer_id = +req.params.myPlayer_id;
    let updateData = {};
    if (squad.squad_player1 === myPlayer_id) updateData = { squad_player1: null };
    else if (squad.squad_player2 === myPlayer_id) updateData = { squad_player2: null };
    else if (squad.squad_player3 === myPlayer_id) updateData = { squad_player3: null };
    else return res.status(404).json({ message: "해당 선수가 스쿼드에 등록되어 있지 않습니다." });

    const targetPlayer = await prisma.myPlayers.findUnique({
      where: { myPlayer_id },
      select: {
        player_id: true,
        players: { select: { player_name: true } }
      }
    });
    const targetPlayerName = targetPlayer.players.player_name;
    // 조회 완료 후 데이터 삭제
    await prisma.squad.update({
      where: { account_id: accountId },
      data: updateData
    });

    // 데이터 삭제 후 현재 Squad 확인
    const curSquad = await prisma.squad.findUnique({
      where: { account_id: accountId },
      select: {
        squad_player1: true,
        squad_player2: true,
        squad_player3: true
      }
    });
    // 스쿼드에 남아있는 선수가 없다면 데이터 삭제
    if (!curSquad.squad_player1 && !curSquad.squad_player2 && !curSquad.squad_player3) {
      await prisma.squad.delete({ where: { account_id: accountId } });
    }

    // 로직 종료
    const message = `${targetPlayerName} 선수를 스쿼드에서 제외했습니다`;
    return res.status(200).json(message);
  } catch (err) {
    console.log(err);
    next(err);
    return res.status(500).json({
      message: "서버 에러 발생",
      errCode: err.message,
    });
  }
});

 

여기도 전체적인 로직은 간단하다.

그동안 진행했던 API 코드들은 모두 데이터 유효성 검사 - 분기 처리 - 로직 수행 의 구조인데, 이 삭제 API 에서는 분기 처리가 로직 수행 다음에 진행된다 라고만 이해하면 알맞을 듯 하다.

 

데이터 유효성 검사는 마찬가지로 JWT 의 account_id를 검사하고, 그 계정의 squad 가 존재하는지 확인하고, params 로 전달한 myPlayer_id 가 squad 안에 존재하는지 확인한다.

이 세 동작을 진행하고 나면 선수가 정상적으로 스쿼드에 추가되어 있다고 판단, 삭제하는 로직을 수행한다.

 

삭제할 때 말로는 삭제라고 해서 delete 메서드를 사용하나 싶을 수 있는데, 여기서는 먼저 update 메서드를 사용한다.

그 이유로 delete 메서드는 해당하는 데이터를 전부 삭제하지만, 우리가 수행해야 하는 것은 해당 데이터의 특정 컬럼만을 삭제하는 것이다. 이 과정은 '삭제' 라기보단 '수정' 이 알맞는 동작이라 update 메서드를 사용한다.

 

해서 updateData 에는 미리 params 로 전달한 myPlayer_id 값이 null 로 바뀌어 들어가있다. 이를 이용해 squad 테이블에서 해당 컬럼을 null 로 바꾸어 스쿼드 삭제의 기능을 수행한다.

 

여기까지만 해도 스쿼드 삭제 의 기능은 완성이지만, 한가지 생각해야 할 점으로 스쿼드 추가 API 에서의 연계 과정을 봐야 한다.

스쿼드 추가 API의 로직 중 '기존 데이터가 없으면 새롭게 데이터를 생성한다' 의 기능이 있었다. 이 기능이 account_id 를 통해 만들어지는데, 데이터를 생성하기 위해서는 기존 스쿼드가 모두 비워졌을 때 '데이터를 삭제' 하는 과정이 필요하다.

더보기

그냥 데이터를 만들어두고 계속 그걸 사용하면 되는거 아니야? 라고 생각할 수 있지만, 그렇지 않다.

그 이유로 가장 처음 데이터가 만들어질 때를 생각해보면 쉽게 이해할 수 있는데 게임의 동작을 떠올려보자.

 

1. 계정 생성 - 선수 가챠 - 스쿼드 추가

2. 스쿼드 구성 (총 3명의 선수로 이루어져 있다)

3. 스쿼드 삭제 (스쿼드 변경 등의 이유로 선수를 삭제할 수 있다)

 

이렇게 동작이 이루어질 텐데, 데이터를 미리 만들어두고 그것을 사용하겠다는 것은 2번 3번의 동작만 고려한 것이다.

계정을 아예 새로 만들고 그 계정에서 스쿼드를 처음 구성하고자 할 때는 본인 계정의 스쿼드 데이터가 존재하지 않는다. 따라서 데이터를 생성해줘야 하는데, 기존 데이터를 활용하겠다고 한다면? 해당 데이터를 찾을 수 없는 오류가 발생할 것이다.

 

따라서 번거롭더라도 스쿼드가 비어있을 때는 데이터를 삭제하는 로직을 추가해야 한다.

해서 한 선수를 스쿼드에서 삭제 후, 스쿼드에 남아있는 선수가 있는지를 판단해야 한다.

업데이트 된 squad 데이터를 다시 탐색 후, 그 안에서 squad_player1, 2, 3을 하나하 검사해 데이터가 남아있는지 판단한다. 이후 만약 남아있는 데이터가 없다면 delete 메서드를 통해 해당 account_id 의 데이터를 완전히 삭제한다.

이렇게 이후 스쿼드 추가 API 에서 정상적으로 데이터를 생성할 수 있게 된다.

 

중간에 targetPlayer 변수를 선언하는 과정이 있는데, 이는 return 메세지 출력에서 선수의 이름을 보여주기 위해 MyPlayers 테이블을 거쳐 Players 테이블에서 받아온 데이터 라고 이해할 수 있다.

 

결과는 다음과 같이 나타난다.

관련글 더보기