item-simulator

윤수빈·2024년 9월 11일
0

🎈 Intro

item-simulator는 Node.js 기반 express.js를 사용하여 REST API로 만들어진 아이템 시뮬레이션이다.
이번 포스트에서는 item-simulator 를 제작하면서 겪었던 스키마 작업과 API 비즈니스 로직의 관계에 대해 다루려고 한다.

이번에 진행했던 과제의 경우 Node.js 입문 / 숙련 강의를 토대로 전반적인 express.js를 이용한 REST API 구현과 DDL(Data Definition Language), DML(Data Manipulation Language) 을 통해 DB를 조작할 수 있는지 확인하는 과제였다고 생각한다.

강의 내용을 토대로 간단한 아이템 시뮬레이터를 만든다고 한다면 인증/인가가 어느 부분에서 이루어지는지, 쿠키와 세션, 안전하게 데이터를 제어하는 방법 등을 활용할 수 있는 시간이었다.

1. 스키마 작업

(최종 ERD)

1-1. tokenStorage

이 테이블은 'accessToken'과 'refreshToken' 2개를 생성하고, 'refreshToken'을 서버에 저장하여 탈취 문제를 보완하고 'refreshToken'의 유효함에 따라 'accessToken'을 재발급 해주는 것으로 했다.

1-2. accounts / accountInfos

계정과 계정의 정보가 담겨져있는 테이블이다.
사용자의 아이디와 비밀번호가 있고, 비밀번호의 경우 노출 시 위험성을 최소화하기 위해 bcrypt를 통해 Hash 단방향 암호화를 한 값으로 저장을 해주었다.

1-3. characters / characterInfos

계정의 캐릭터들과 캐릭터의 정보들이 담겨져있는 테이블이다.
Q. 1:1 대응이라면 굳이 계정과 계정정보를 나눈 이유가 있나?
A. 만약 인증된 캐릭터의 characterInfos를 조회하려고 한다면 이중 조회때문에 성능 문제가 있을 수 있다.. 고려하지 못한 부분이라서 다음부터는 합쳐서 해볼 예정이다.

1-4. inventories / inventoryInfos / inventoryItems

이 부분이 아이템 구매나 판매에 대한 로직을 처리할 때 고민을 많이 했던 부분이다.
inventories - inventoryInfos의 관계는 계정/캐릭터와 마찬가지이지만 inventoryItems를 따로 빼준 이유는 아이템이 한개가 아니라 여러개 있을 수 있다.
여기서 아이템을 구매하여 데이터가 추가되어야할 때 개별로 하나씩 추가될 필요는 없다고 생각했다.
그래서 item_quantity 라는 컬럼을 넣어 item_code만 하나씩 가지고 있고 해당 아이템의 개수가 여러개 있다는 방향으로 스키마를 구성하게 되었다.

1-5. items / itemHistories

아이템과 아이템의 변경기록이 담긴 테이블이다
아이템은 Infos를 별도로 주지 않고 한 곳에 몰아서 넣어주었다. (중간에 이중 조회 / 성능을 깨달음)

Q. items는 inventoryItems와 관계를 이으지 않고 별도로 해도 되지않을까?
A. 이 부분도 고민을 하게 되었는데 구현은 안했지만 inventoryItems의 아이템들의 상세정보를 모두 조회하는 API를 구현한다면 사용할 것 같다.

1-6. equipments

캐릭터의 장비 상태가 담긴 테이블이다.
장착 가능한 아이템은 enum equip_part(아이템 부위)에서 정의된 값으로 확인하여 장착하도록 로직을 구현하였다.
이 부분도 고민을 많이 했었다. 원래는 characters와 1:1 관계로 맺고 컬럼명을 아이템 부위로 맞춰서 했었는데 이렇게 하면 항상 null 값을 유지해야한다는 특성과 데이터 자체의 크기문제도 있었기에 성능적인 측면으로 최악이었다.
결국 enum으로 교체하면서 1:N으로 바꾸게 되었다.

2. API 로직

2-1. app.js

실제 express.js를 실행시키고 각종 미들웨어를 실행하는 메인장소이다.
PORT는 .env에 숨겨주었고, '/api'를 기본 라우팅으로 사용한다.

2-2. accounts.router.js

계정에 관련된 API를 수행한다.
계정 생성, 로그인, 로그아웃 API가 구현되어있다.
로그아웃 API는 클라이언트에서 쿠키를 전달하지 않는것으로 되어 있어서 사실상 의미가 없어진 API이긴 하다.

2-3. characters.router.js

캐릭터에 관련된 API를 수행한다.
캐릭터는 계정에 귀속되어 있으므로 계정 인증과 인가가 반드시 필요하다.
캐릭터 생성, 삭제, 조회 API가 구현되어있다.

2-4. items.router.js

아이템에 관련된 API를 수행한다.
아이템 생성, 수정, 전체 조회, 상세 조회 API가 구현되어 있다.
아이템도 게임 내 경제 시스템에 영향을 크게 주기때문에 인증/인가가 필요하지만 현재는 아무나 제어할 수 있도록 구현해 놓은 상태이다.

추후에는 사용자 권한 인증 / 인가 구현이 필수적이라고 생각한다.

2-5. inventories.router.js

인벤토리에 관련된 API를 수행한다.
아이템 구매, 판매, 인벤토리 아이템 목록 조회 API가 구현되어 있다.
인벤토리도 캐릭터 및 계정에 귀속되어 있기 때문에 인증/인가가 필요하다.

Q. 아이템 구매/판매는 items 라우터에 넣어야하지 않나?
A. 이 부분도 고민을 많이 했었는데 구매/판매 행위가 인벤토리에 영향을 주기 때문에 영향을 받는 쪽을 기준으로 맞춰서 구현했다.

2-6. equipments.router.js

장비에 관련된 API를 수행한다.
아이템 장착, 해제, 장비 조회 API가 구현되어 있다.
인증/인가가 마찬가지로 필요하고, 여기도 장비 테이블에 영향을 주기 때문에 equipments로 별도로 구현한 상태이다.
하지만 아이템 장착 / 해제할 때 스탯이 달라지는데 이부분은 characterInfos 테이블에도 영향을 주기때문에 사실... 어느 부분에 맞출지는 햇갈리는 상태이다.

2-7. roots.router.js

획득에 관련된 API를 수행한다.
명세서에 심플하게 요구된 게임 머니를 body로 요청하면 입력한 값만큼 캐릭터의 보유 머니를 늘려주는 API이다.
나중에는 더욱 복잡한 인증/인가 부분이 추가되어야 한다고 생각하고 있다.


🛠 Trouble-Shooting

REST API로 구현한다면 '저장할 스키마 작업부터 해야겠다' 라고 생각했지만 다시 돌아보면서 특정 API를 요청한다고 했을 때(아이템 조회, 장착, 인벤토리 조회 등) 어떤 비즈니스 로직을 거쳐야 서버에서 가장 안정적으로 빠르게 응답할 수 있을까? 고민을 하게되었다.

그래서 잠시 작업을 멈추고 스키마 작업을 하는 동시에 수행할 비즈니스 로직을 정리했던 것 같다.

하지만 역시 완벽한 것은 없다...
진행하면서도 수정되는 스키마가 있었고, 그 이유는 바로 저장되는 데이터의 크기, 복잡성 때문의 이유가 크다.

1. 배경

일단 주어진 과제에서 파라미터로 전달받을 것과 body로 전달받을 것을 생각하며 스키마 작업을 해야하는 점이 있었고, 어떻게 데이터를 저장해야 최대한 크기를 적게 사용하고 복잡하지 않을지 생각하게 되었다.


2. 발단

2-1. 캐릭터 - 인벤토리

캐릭터의 인벤토리에는 아이템이 쌓일 수 있다.
하지만 그 아이템이 DB에 저장된다면 어떻게 쌓이는게 좋을까?
처음에는 그냥 데이터가 하나씩 추가되도록 해야겠다~ 라는 마음으로 스키마 작업을 했다가 아이템이 수백개 수천개가 들어갈 수 있다면 동일한 아이템인데도 엄청 많은 데이터가 들어갈 수 있다는 점을 고려하지 못했다.

2-2. 캐릭터 - 장착

캐릭터에 장비창이 있는데 장착 부위가 존재하여 한개씩만 장착할 수 있도록 했다.
그러다 보니 내가 보기 편한 스타일로 컬럼이름을 'HEAD', 'WEAPON', 'LEGS', ... 처럼 장착 부위이름으로 컬럼을 주고 null 값으로 채워놓은 뒤 끼워놓고 빼고 하는 방향으로 하려 했었다.


3. 문제

3-1. 리소스의 크기로 인한 성능 문제

불필요하게 채워지는 null과 각 컬럼에 들어가는 JSON 방식이 겹쳐지면서 리소스가 커질수록 서버 성능에 문제가 있을 수 있다.

3-2. 중복 리소스로 인한 성능 문제

아이템의 경우 동일한 내용임에도 개수를 체크하려고 얻을때마다 데이터를 하나씩 추가한다면 서버 성능에 문제가 있을 수 있다.

3-3. 불필요한 테이블 구분으로 인한 성능 문제

characterInfos, accountInfos 처럼 굳이 구분지을 필요 없는 테이블로 인해서 한번 더 조회하는 경우가 생긴다. 이로 인해 로직 처리가 다소 느리다는 느낌을 자주 받았다.

3-4. 유지보수 문제

아이템의 부위를 컬럼으로 의미를 부여하여 사용하게 된다면 나중에 해당 부위가 변경되거나 삭제되었을 때 변경사항에 따른 유지보수가 매우 힘들어진다.


4. 위기

4-1. 명세서대로 구현하려면 복잡해지는 로직 발생

예를 들어, 장착한 아이템을 조회하는 API에서 아이템 파츠를 컬럼으로 주고 안에 JSON을 넣었을 때 JSON을 다시 읽어내서 각 아이템에 대한 정보를 별도로 추출하는 로직이 있어 복잡해졌다.

4-2. 저장 오류 발생

JSON 형태의 데이터를 prisma로 저장하거나 써야할 때, 복잡한 로직과 더불어서 [object][Object] 방식으로 저장이 되거나 불러와지는 경우가 있었다.
특히 itemHistories에 변경된 부분을 JSON 형식으로 저장하고 빼내서 그대로 출력하려고 했으나 저장할때 위 문제로 인해 진행이 어려웠다.


5. 해결

5-1. 캐릭터 - 인벤토리

캐릭터의 인벤토리에 아이템 코드가 하나씩만 있는 것으로 하고 개수를 체크하는 방향으로 하였다.
인벤토리에 있는 아이템들을 조회하는 API를 구현할 때 아이템의 코드, 이름, 아이템 수량을 불러내는데 이 부분을 저장할 때 같이 저장하고, 아이템을 얻었을 때 개수는 어떻게 변경하지? 했었는데 incrementdecrement 라는 것을 알게되면서 쉽게 구현할 수 있었다.

아이템 개수 증감소에 대한 부분 구현 코드

await prisma.$transaction(
            async (tx) => {
                await tx.inventoryItems.create({
                    data: {
                        item_code: +item_code,
                        item_name: item.item_name,
                        item_quantity: +item_quantity,
                        inventoriesId: inventory.inventoriesId,
                    },
                });

                await tx.characterInfos.update({
                    data: { money: { decrement: item.item_price } },
                    where: { charactersId: +charactersId },
                });
            },
            {
                isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted,
            },
        );

5-2. 캐릭터 - 장착

조호영 튜터님의 피드백 덕분에 이 부분은 파츠로 컬럼을 주지 않고 equip_part 라는 enum 타입을 선언하여 enum에 맞는 데이터가 삽입될 수 있도록 스키마에서 설정해주었다.

item_part가 중복된 장비가 장착되려고 하는 경우 이미 장착된 부위라는 응답을 보내주도록 구현했다.

스키마 작업 부분
model equipments {
  equipmentsId Int @id @default(autoincrement()) @map("equipmentsId")
  charactersId Int @map("charactersId")
  item_code Int? @map("item_code")
  item_name String? @map("item_name")
  item_part equip_part? @map("item_part")
  createdAt DateTime @default(now()) @map("createdAt")
  updatedAt DateTime @updatedAt @map("updatedAt")

  character characters @relation(fields: [charactersId], references: [charactersId], onDelete: Cascade)
}
캐릭터 아이템 장착 API 구현 로직

    const isEquipped = await prisma.equipments.findFirst({
        where: { charactersId: character.charactersId, item_part: item.item_part },
    });

    if (isEquipped) {
        return res.status(500).json({ errorMessage: '이미 장착된 아이템이 있습니다.' });
}
    
await prisma.$transaction(
        async (tx) => {
            // 캐릭터의 equipments 테이블에 아이템 부위에 해당 아이템을 장착하도록 한다.
            await tx.equipments.create({
                data: {
                    item_part: item.item_part,
                    item_code: item.item_code,
                    item_name: item.item_name,
                    charactersId: character.charactersId,
                },
            });

            // 캐릭터의 inventoryItems 테이블에서 해당 아이템의 item_quantity(개수)를 -1 줄여준다.
            await tx.inventoryItems.update({
                data: { item_quantity: { decrement: 1 } },
                where: { inventoryItemsId: inventoryItem.inventoryItemsId },
            });

            // 캐릭터의 characterInfos 테이블에서 item_stat에 맞게 health와 power를 증가시켜준다.
            await tx.characterInfos.update({
                data: {
                    health: { increment: item.item_health },
                    power: { increment: item.item_power },
                },
                where: { charactersId: +charactersId },
            });
        },
        {
            isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted,
        },
    );

6. 마무리

이번 트러블 슈팅을 요약하자면

  1. 명세서를 자세히보지 않고 스키마작업을 먼저했다.
  2. 비즈니스 로직 구현 과정 중 복잡함과 서버 성능 문제를 느꼈다.
  3. 명세서를 다시 읽고 비즈니스 로직을 생각하며 스키마를 다시 설계했다.
  4. 로직의 복잡함이 줄어서 코드의 가독성이 높아지고 조금 더 빠른 속도를 체감했다.

추가로 JSON 형식의 스키마는 쓰지말자가 아니라 가변성이 높은 데이터의 경우들을 한 번에 처리해야하는 경우에는 JSON 형식으로도 사용할 수 있다고 들었다.
현재 이 프로젝트는 캐릭터 스탯과 장비 부위에 JSON을 사용하려고 했지만 안에 있는 데이터들이 변할 수 있거나 많은 양이 아니어서 JSON을 사용하지 않아도 된다고 생각했다.

나중에는 프로젝트 특성에 맞는 방식의 설계도 고려해야된다고 생각이 들었다.

profile
정의로운 사회운동가

0개의 댓글