상세 컨텐츠

본문 제목

#2. 아이템 시뮬레이터 개발 - Node.Js(2)

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

by 남민우_ 2024. 12. 2. 19:34

본문

이번 게시글에서는 API를 개발한 내용과 Insomnia 를 이용해 테스트한 과정을 소개한다.

구현 API 목록

1. 회원가입 API : 아이디/비밀번호/비밀번호 확인/이름 을 넘겨받아 회원가입 요청

2. 로그인 API : 아이디/비밀번호 를 넘겨받아 로그인 - 성공 시 JWT 토큰 반환

3. 캐릭터 생성 API : 닉네임 을 넘겨받아 특정 계정 내에 캐릭터 생성

4. 캐릭터 삭제 API : 특정 계정 내의 캐릭터 삭제

5. 캐릭터 상세 조회 API : 캐릭터의 이름,수치 등 데이터 조회

6. 아이템 생성 API : 이름/수치/가격 을 넘겨받아 아이템 생성

7. 아이템 수정 API : 이름/수치 를 넘겨받아 기존 아이템의 데이터 수정

8. 아이템 목록 조회 API : 아이템 테이블의 전체 데이터 조회

9. 아이템 상세 조회 API : 특정 아이템의 수치 조회

10. 아이템 구입 API : 캐릭터 정보, 아이템 이름, 구매 수량을 넘겨받아 아이템 구매

11. 아이템 판매 API : 아이템 이름, 판매 수량을 넘겨받아 아이템 판매 - 가격의 60%로 정산

12. 인벤토리 조회 API : 특정 캐릭터의 인벤토리 전체 조회

13. 장비칸 조회 API : 특정 캐릭터의 장비칸 전체 조회

14. 아이템 장착 API : 인벤토리에서 아이템 장착 - 장비칸으로 이동, 캐릭터 수치 상승

15. 아이템 해제 API : 장비칸에서 아이템 해제 - 인벤토리로 이동, 캐릭터 수치 감소

16. 게임 재화 증가 API : 캐릭터의 게임 재화 일정량 상승

 

다시 봐도 내용이 많다. 얼핏 드는 생각으로 단순한 학습용 프로젝트가 이정도의 양인데, 실제 게임에는 얼마나 많은 내용들이 들어가있는건지 감이 안잡혀 살짝 소름이 돋았다.

 

본문으로 돌아와서, 이 API에도 자세한 요구사항들이 있지만 하나하나 살펴보기엔 내용이 너무 길어 주요 코드를 통해서 보도록 한다.

그리고 명세서도 같이 작성을 해보았는데, 기능들을 구현하면서 API 명세서의 필요성을 깨닫는 순간들이 많았다. 어떤 데이터를 Params 로 전달할지 또 어떤 데이터를 Request 의 Body 로 전달할지 정하지 않고 개발에 들어가니 어디서 값을 받아와야 할지 난감한 순간들이 다수 있었다. 이를 해결하기 위해 개발을 중단하고 API 명세서의 완성을 최우선으로 두고 작성했다.

https://www.notion.so/14ce5370239880149139c151ef905aa2?v=5cf7b2ac2e6e46739f983f9db02f7112

 

이 중에서 각 메서드 별로 1개씩의 API를 예시로 들면서 설명하고자 한다.

 

회원가입 API : POST

// 회원가입 API
router.post('/signUp', async (req, res, next) => {
  try {
    const { email, password, passwordCheck, accountName } = req.body;

    // 받은 데이터 유효성 검사
    const isExistAccount = await prisma.Accounts.findFirst({ where: { email } });
    if (isExistAccount) return res.status(409).json({ message: "이미 존재하는 이메일입니다." });
    const idRegex = /^[a-z0-9]+$/;
    if (!idRegex.test(email)) return res.status(400).json({ message: "올바르지 않은 형식의 아이디입니다." });
    if (password.length < 6) return res.status(400).json({ message: "비밀번호는 최소 6자리 이상이어야 합니다." });
    if (password !== passwordCheck) return res.status(400).json({ message: "비밀번호 확인이 올바르지 않습니다." });
    const isExistName = await prisma.Accounts.findFirst({ where: { accountName } });
    if (isExistName) return res.status(409).json({ message: "이미 존재하는 계정 이름입니다." });

    // password 암호화
    const hashedPassword = await bcrypt.hash(password, 10);
    // Accounts 테이블에 데이터 추가
    const newAccount = await prisma.Accounts.create({
      data: {
        email,
        password: hashedPassword,
        accountName
      }
    });

    // res 데이터
    const { password: _, ...responseData } = newAccount;
    return res.status(201).json({ message: "회원 가입이 완료되었습니다.", data: responseData });
  } catch (err) {
    console.log(err);
    return res.status(500).json({
      message: "서버 에러가 발생했습니다",
      errorCode: err.message
    });
  }
});

 

주석을 보면서도 대략적인 내용이 이해가 가능할 것인데, 크게 2가지의 포인트로 나눌 수 있다.

 

1. 데이터 유효성 검사

이 항목에서는 API 구현 요구사항에 있던 조건들, 혹은 데이터가 유효한지 등을 확인하는 코드를 작성하였다.

또한 여러 상태코드를 활용하여 서버의 Response 에 알맞은 코드를 넣어 클라이언트의 오류를 정확히 전달하고자 했다.

 

2. 테이블에 데이터 추가

이 항목에서는 1번 과정을 거친 적절한 데이터가 입력되었다고 판단하고 입력받은 값들을 Accounts 테이블에 새로 추가한다.

여기서 또 다른 요구사항으로 패스워드는 암호화 작업을 거쳐야 한다는 것이다. 사용자의 정보를 관리자만 접근할 수 있는 DB에 저장한다고 해도, 여러 보안의 취약점들이 있을 수 있기에 비밀번호의 경우는 bcrypt 라이브러리를 이용해 암호화 작업을 수행 후 저장한다.

 

이후 모든 로직이 수행되고 나면 클라이언트에게 201번의 상태코드와 함께 Response 를 반환한다.

 

캐릭터 삭제 API : DELETE

router.delete('/char/:charId/charDele', authMiddlewares, async (req, res, next) => {
    try {
        if (!req.account) return res.status(401).json({ message: "로그인이 필요합니다." });
        const charId = +req.params.charId;               // 사용자가 삭제하려는 캐릭터 ID
        const jwtID = req.account.accountId;             // 사용자의 JWT 로 받은 계정의 ID
        // 삭제하려는 캐릭터 데이터 전체
        const character = await prisma.characters.findFirst({ where: { characterId: charId } });

        // 데이터 유효성 검사
        if (!character) return res.status(404).json({ message: "해당하는 캐릭터가 존재하지 않습니다." });
        if (jwtID !== character.accountId) return res.status(403).json({ message: "타인의 캐릭터는 삭제할 수 없습니다." });

        await prisma.characters.delete({
            where: { characterId: charId }
        });

        return res.status(200).json({ message: "캐릭터가 정상적으로 삭제되었습니다." });
    } catch (err) {
        console.log(err);
        return res.status(500).json({
            message: "서버 에러가 발생했습니다",
            errorCode: err.message
        });
    }
});

 

여기서는 JWT(Json Web Token) 과 미들웨어를 이용해 사용자 인증 절차를 수행하는 로직이 포함되어 있다.

먼저 JWT 에 대해서 설명하자면, 서버와 클라이언트 사이에서 정보를 안전하게 전송하기 위해 도와주는 '웹 토큰' 이라고 간단하게 설명할 수 있는데 이를 이용해 해당 클라이언트가 정상적으로 로그인을 한 이용자인지, 알 수 없는 외부 경로로 침입한 이용자는 아닌지 등을 판명하는 것이다.

 

이러한 JWT는 로그인 API 에서 생성해 Response Header 의 Cookie 를 통해 전달하는데

// JWT 토큰 생성
const token = jwt.sign({ accountId: alreayAccount.accountId }, 'ISBC-secret-key');
res.cookie('auth', `Bearer ${token}`);

 

다음과 같은 코드를 통해 지급한다.

예시 사진을 보면 다음처럼 value 에 JWT 토큰이 들어간 것을 알 수 있다.

또한 미들웨어를 통해 클라이언트가 전달한 이 JWT 토큰에 대해 검사한다.

Middldware

미들웨어를 간단히 설명하면, 서버와 클라이언트 사이에서 특정한 로직을 수행하여 하나 이상의 통신을 가능하게 도와주는 도구 라고 말할 수 있다.

캐릭터 삭제 API에서는 'authMiddlewares' 라는 이름으로 미들웨어를 호출하는데, 이 함수는 클라이언트의 요청과 서버의 로직 처리 사이에 위치하여 무조건적인 응답이 아니라 사용자의 유효성을 먼저 검사 후 그에 맞는 결과를 반환하는 것으로 이해할 수 있다.

// 사용자 인증 미들웨어
export default async function (req, res, next) {
    try {
        const { auth } = req.cookies;
        // 토큰 존재 유무 확인
        if (!auth) throw new Error('해당 토큰이 존재하지 않습니다.');

        // 토큰 타입 확인
        const [tokentype, token] = auth.split(' ');
        if (tokentype !== 'Bearer') throw new Error('토큰 타입이 올바른 형식이 아닙니다.');

        // JWT 검증
        const decodedToken = jwt.verify(token, 'ISBC-secret-key');
        const accountId = decodedToken.accountId;
        // 유저 탐색
        const account = await prisma.accounts.findFirst({
            where: { accountId: +accountId }
        });
        if (!account) throw new Error('사용자가 존재하지 않습니다..');

        req.account = {
            accountId: account.accountId,
            accountName: account.accountName,
            email: account.email,
            createdAt: account.createdAt,
            updatedAt: account.updatedAt,
        };
        next();
    } catch (err) {
        res.clearCookie('auth');
        switch (err.name) {
            case 'TokenExpiredError':
                return res.status(401).json({ message: '토큰 기한이 만료되었습니다.' });
            case 'JsonWebTokenError':
                return res.status(401).json({ message: '위험 여지가 있는 토큰입니다.' });
            default:
                return res.status(401).json({ message: err.message ?? '비정상적인 요청입니다.' });
        }
    }
}

 

나는 이렇게 코드를 작성하였고, req.cookies 를 통해 사용자가 전달한 쿠키를 받아 토큰 기한이 지나지 않았는지, 올바른 형식의 데이터를 취하고 있는지 등의 절차를 통해 사용자 인증을 진행하도록 했다.

 

다시 API 로 돌아와, 이렇게 데이터 유효성 검사를 모두 마치고 나면 비교적 간단한 delete 의 로직을 수행한다.

await prisma.characters.delete({
	where: { characterId: charId }
});

 

다음 코드를 통해 characters 테이블에서 where 조건에 맞는 캐릭터, 즉 characterId 컬럼에서 charId 와 같은 값을 가진 데이터를 삭제한다.

이후에는 Response 를 통해 적절한 상태코드와 로직 수행이 완료되었음을 알려준다.

 

캐릭터 상세 조회 API : GET

// 캐릭터 상세 조회 API - JWT 사용
router.get('/char/:charId/charSear', authMiddlewares, async (req, res, next) => {
    try {
        if (!req.account) return res.status(401).json({ message: "로그인이 필요합니다." });
        const charId = +req.params.charId;
        const targetChar = await prisma.characters.findFirst({ where: { characterId: charId } });

        // 데이터 유효성 검사
        if (!targetChar) return res.status(404).json({ message: "해당하는 캐릭터가 존재하지 않습니다." });

        const jwtID = req.account.accountId;
        let data;
        // 자신의 캐릭터가 아닐 경우
        if (jwtID !== targetChar.accountId) {
            data = {
                name: targetChar.name,
                health: targetChar.health,
                power: targetChar.power
            }
            return res.status(200).json({ data });
        }
        // 자신의 캐릭터일 경우
        else {
            data = {
                name: targetChar.name,
                health: targetChar.health,
                power: targetChar.power,
                money: targetChar.money
            }
            return res.status(200).json({ data });
        }
    } catch (err) {
        console.log(err);
        return res.status(500).json({
            message: "서버 에러가 발생했습니다",
            errorCode: err.message
        });
    }
});

 

특정 1개의 캐릭터의 상세 정보를 받아오는 상세 조회 API이다.

캐릭터 삭제 API와 마찬가지로 Params 로 캐릭터 아이디(charId)를 받아오고 authMiddlewares 를 통해 Request 데이터 유효성 검사를 진행한다.

 

이 검사가 모두 성공적으로 마치고 나면 charId 에는 사용자가 조회하고자 하는 캐릭터 아이디, targetChar 에는 해당 아이디를 가진 캐릭터 데이터 객체가 할당된다.

 

다만 여기서 jwtID 와 targetChar.accountId 의 값을 비교하는 조건문이 들어가는데, 이는 "본인의 캐릭터일 경우 모든 데이터를, 타인의 캐릭터일 경우 이름과 스탯만 조회 가능" 이라는 요구사항을 해결하기 위해 작성하였다.

 

jwtID 는 req.account.accountId 를 할당해 클라이언트가 로그인 한 계정의 고유 번호를 받아온다. 해서 jwtID에는 본인 계정 아이디가 할당되는데 이를 조회하고자 했던 캐릭터의 계정 번호와 비교하여, 일치하지 않을 경우 타인의 캐릭터라고 판단해 간략화된 정보를, 일치할 경우 본인의 캐릭터라고 판단해 money 를 포함한 모든 데이터를 data 변수에 할당한다.

 

이후 Response 를 통해 적절한 상태코드와 이 data를 같이 반환하여 클라이언트에서 요청했던 정보를 넘겨주는 과정을 수행한다.

 

아이템 수정 API : PATCH

아이템의 이름, 정보 등의 데이터를 수정하는 API 이다.

이를 이해하기 위해서는 먼저 아이템의 테이블이 어떻게 구성되어 있는지를 봐야 할 것이다.

model Items {
  itemId          Int       @map("itemId")  @id  @default(autoincrement())

  name            String    @map("name")
  health          Int       @map("health")
  power           Int       @map("power")
  price           Int       @map("price")

  @@map("Items")

  char_Inven Char_Invens[]
  char_Item  Char_Items[]
}

아이템의 데이터를 저장하는 Items 테이블의 스키마 코드이다.

하단 부분은 다른 테이블과의 연관 관계를 설정하는 부분이니 생략하고, 우리가 볼 것은 컬럼을 지정하는 부분인 itemId, name, health, power, price 가 있다는 것이다.

 

아이템 생성 API 는 POST 메서드를 사용해 설명을 생략하였지만, body 를 통해 컬럼들의 데이터를 전달하고 이를 취합해 새로운 아이템 데이터를 생성하는 로직이다.

아이템 수정 API 에서는 price 컬럼을 제외한, 클라이언트가 정보 변경을 원하는 데이터들을 body 로 전달받아 이를 반영하는 로직을 수행하고 있다.

router.patch('/item/:itemId/itemFix', async (req, res, next) => {
    try {
        const itemId = +req.params.itemId;
        const targetItem = await prisma.items.findFirst({ where: { itemId } });
        if (!targetItem) return res.status(404).json({ message: "해당하는 아이템이 존재하지 않습니다." });

        const { name, stat, price = null } = req.body;
        if (price) return res.status(400).json({ message: "가격은 수정할 수 없습니다." });
        if (!name) return res.status(400).json({ message: "아이템의 이름은 필수 항목입니다." });

        await prisma.items.update({
            where: { itemId },
            data: {
                name: name,
                health: stat.health !== undefined ? stat.health : 0,
                power: stat.power !== undefined ? stat.power : 0
            }
        })

        return res.status(200).json({ message: "아이템의 정보가 수정되었습니다." });

    } catch (err) {
        console.log(err);
        return res.status(500).json({
            message: "서버 에러가 발생했습니다",
            errorCode: err.message
        });
    }
});

 

사실 API의 목적에 충실하게 로직을 구성한다면 이 API에도 미들웨어를 추가하여 아이템의 정보를 수정할 권한이 있는 요청인지 검사하는 과정이 필요할 것이다.

하지만 이번 프로젝트는 단순 학습을 위한 과정이므로, 이 과정을 생략하고 기존의 데이터를 수정하는 로직만 구성하도록 하였고 그에 따라 jwt 인증이나 미들웨어를 생략하였다.

 

다만 클라이언트가 body로 전달한 데이터가 올바른 형식인지에 대해서는 판단이 필요하다.

const itemId = +req.params.itemId;
const targetItem = await prisma.items.findFirst({ where: { itemId } });
if (!targetItem) return res.status(404).json({ message: "해당하는 아이템이 존재하지 않습니다." });

const { name, stat, price = null } = req.body;
if (price) return res.status(400).json({ message: "가격은 수정할 수 없습니다." });
if (!name) return res.status(400).json({ message: "아이템의 이름은 필수 항목입니다." });

이 부분이 데이터의 유효성 검사를 진행하는 코드이다.

 

Params 로 변경하고자 하는 아이템의 ID 값을 전달받고 이를 itemId 변수에 할당한다.

이후 해당하는 아이템을 targetItem 에 할당, 데이터가 정상적으로 존재하는지를 판단한다.

 

프로젝트를 완성하고 나서 다시 생각하게 되는 부분은 이 targetItem 을 할당하는 로직에서의 findFirst() 메서드이다.

이 findFirst() 는 매개변수로 들어온 값에 해당하는 첫번째 데이터를 반환하는데 이 코드에서는 사용자가 전달한 itemId 를 매개변수로 삼는다.

다만 아쉬운 부분은 이 itemId 는 Primary Key 와 auto_Increment 가 결합된 고유 ID 이다.

해서 자동적으로 Unique 속성이 추가되기 때문에, 이를 이용하여 데이터를 찾는 과정에서는 findFirst() 메서드보다 findUnique() 메서드를 활용하는 것이 더 낫지 않았을까 라는 생각이 든다.

 

이후에 body로 전달받은 데이터들을 각각 name, stat, price 에 할당한다. 다만 여기서 price = null 을 통해 사용자가 가격을 수정하려고 하는 잘못된 요청을 보냈을 때를 대비해 디폴트값을 설정하였고, 이후 조건문을 통해 price 에 값이 할당된 경우 return 하도록 하였다.

name 또한 마찬가지의 로직이다.

 

이렇게 데이터 유효성 검사가 끝나고 나면 사용자는 올바른 값을 요청했다고 판단한다.

await prisma.items.update({
	where: { itemId },
	data: {
		name: name,
		health: stat.health !== undefined ? stat.health : 0,
		power: stat.power !== undefined ? stat.power : 0
	}
})

이후 다음의 로직을 통해 items 테이블에서 itemId 에 맞는 데이터를 update 하는 과정이다.

 

사용자가 입력한 name, 그리고 stat 에는 health 와 power 라는 두가지 정보가 포함되어 있어 이를 분리하여 저장한다.

price 는 변동하지 않는다 라는 초기 요구사항이 있기에 변경하지 않도록 하였다.

 

이후 Response 를 통해 적절한 상태 코드와 완료 메세지를 반환한다.

 

마치며

이렇게 총 4가지 종류의 메서드를 활용하는 API 로직을 모두 소개하였다.

이번 프로젝트에서 구현한 대부분의 API 가 디테일한 내부 로직만 다를 뿐 전체적인 알고리즘 자체는 동일하여 별도로 소개하지 않았지만, 혹시 모를 경우를 대비해 프로젝트의 깃허브 링크를 첨부한다.

https://github.com/Namminu/ItemSimulator_BC

 

GitHub - Namminu/ItemSimulator_BC: 내일배움캠프 - 아이템 시뮬레이터 만들기

내일배움캠프 - 아이템 시뮬레이터 만들기. Contribute to Namminu/ItemSimulator_BC development by creating an account on GitHub.

github.com

 

관련글 더보기