Programming/Project Log

[가계부 만들기] Category Table 구조 변경

minarae7 2023. 5. 5. 23:14
728x90
반응형

카테고리 관련 기능을 구현하려고 보니 카테고리 관련 테이블 구조가 잘못된 것을 알게 되었다.

최초에는 시스템에서 정의한 카테고리만 보이도록 하고 시스템에서 정의한 카테고리는 변경을 할 수 없도록 하려고 했다.

그러나 출력되는 순서를 변경하고 싶거나 시스템 카테고리에 서브로 카테고리를 더 추가하고 싶을 수도 있을 것이라고 판단된다.

그럼 회원이 가입할 때 시스템 정의 카테고리를 회원별로 정의해 주고 회원별로 정의된 카테고리는 각 회원이 자신의 필요에 맞춰 수정할 수 있도록 해야 할 것이다.

그리고 최초 정의한 테이블에는 지출에 대한 카테고리만 생각하고 정의했는데 수입에 대해서도 카테고리를 정의하고 싶을 수 있으므로 카테고리 테이블에 수입/지출에 대한 구분 값을 넣을 것이다.

마지막으로 순서를 변경할 수 있도록 하기 위해서 출력하는 순서를 정의하도록 할 것이다.

728x90

테이블 구조 변경

현재 정의되어 있는 테이블 스키마는 다음과 같다.

tb_category 테이블 Schema

이 테이블에 수입/지출 구분 컬럼과 정렬 순서 칼럼을 추가할 것이다. 그리고 is_system 칼럼을 사용하지 않을 것이기 때문에 삭제할 것이다.

alter table tb_category
	add inout_type char(1) not null default 'O' comment '수입/지출 구분(I: incomes, O: outgoings)' after has_children,
	add sort_order tinyint unsigned not null default 1 comment '정렬순서' after class_name,
	drop is_system;

수입/지출 구분 컬럼은 enum 타입으로 지정해도 되지만 우선 여기서는 char로 지정하도록 하였다.

sort_order는 정렬 순서를 나타내며 각 사용자가 카테고리를 무지막지하게 생성할 일은 없으며 정렬 순서는 1부터 시작하며 음수로 내려가는 일이 없을 것이라고 판단하여 unsigned tinyint로 선언하였다.

반응형

아래는 추가로 카테고리 테이블을 수정한 내용이다.

alter table tb_log_detail drop foreign key fk_log_detail__category_no;
alter table tb_log_detail change category_no category_no bigint unsigned not null comment '카테고리 번호';
alter table tb_category 
	change category_no category_no bigint unsigned not null auto_increment comment '카테고리 번호',
	change parent_no parent_no bigint unsigned null comment '부모 카테고리 번호';
alter table tb_log_detail add constraint fk_log_detail__category_no foreign key  (category_no) references tb_category (category_no);

이 내용은 category_no를 int에서 bigint로 변경하기 위해서 수정하였다. 회원이 한 명 가입할 때마다 총 146개의 카테고리가 생성되므로 회원 테이블 member_no가 int이면 category_no는 bigint로 생성하는 것이 낫다고 판단된다.

근데 tb_log_detail 테이블에서 category_no 테이블을 외부키로 잡고 있기 때문에 형 변경을 하려면 먼저 tb_log_detail 외부키를 삭제하고 모두 형 변경을 마친 이후에 다시 외부키를 잡아주도록 하였다.

반응형

Model 변경

테이블 구조가 변경되었으니 models.py 파일 구조도 이에 맞춰서 변경하여야 한다.

...
from sqlalchemy.dialects.mysql import (
    TINYINT,
    SMALLINT,
    INTEGER,
    TINYINT,
    BIGINT
)

...
class Category(Base):
    __tablename__ = "tb_category"

    category_no = Column(BIGINT(unsigned=True), nullable=False, autoincrement=True, comment="카테고리 번호")
    member_no = Column(SMALLINT(unsigned=True), nullable=True, comment="카테고리 생성자 번호(기본 카테고리일 경우 null)")
    category_name = Column(VARCHAR(length=50), nullable=False, comment="카테고리 이름")
    has_children = Column(CHAR(length=1), nullable=False, default="F", comment="자식을 가지고 있는지 여부(T|F)")
    inout_type = Column(CHAR(length=1), nullable=False, default="O", comment="수입/지출 구분(I: incomes, O: outgoings)")
    parent_no = Column(BIGINT(unsigned=True), nullable=True, comment="부모 카테고리 번호")
    class_name = Column(VARCHAR(length=30), nullable=True, comment="아이콘 클래스")
    sort_order = Column(TINYINT(unsigned=True), default=1, comment="정렬순서")
    reg_dt = Column(DATETIME(timezone=False), nullable=False, server_default=func.now(), comment="생성일시")
    upd_dt = Column(DATETIME(timezone=False), nullable=False, server_default=func.now(), onupdate=func.now(), comment="수정일시")
    is_deleted = Column(CHAR(length=1), default="F", server_default="F", nullable=False, comment="삭제여부(T|F)")
    del_dt = Column(DATETIME(timezone=False), nullable=True, comment="삭제일시")

    __table_args__ = (
        PrimaryKeyConstraint(category_no, name="pk_category"),
        ForeignKeyConstraint(
            ["member_no"],
            ["tb_members.member_no"],
            name="fk_category__member_no",
            onupdate="NO ACTION",
            ondelete="NO ACTION",
        ),
        {
            "comment": "카테고리 정보"
        }
    )
...

sqlalchemy에서는 mysql 전용 자료형을 따로 지원한다. sort_order는 tinyint unsigned로 선언하였으므로 mysql 자료형에서 TINYINT로 지정하였다.

category_no는 mysql 전용 자료형으로 BIGINT로 선언하였고, 마찬가지로 parent_no도 BIGINT로 수정하였다.

위 코드에서 명시하지 않았지만 tb_log_detail을 ORM으로 표현한 LogDetail에서도 category_no를 BIGINT로 변경한다.

반응형

회원 가입 코드 재지정

그럼 마지막으로 회원이 가입할 때 각 회원별로 카테고리를 추가해주는 코드를 생성하여야 한다.

우선 생성할 카테고리 정보를 정의하여야 한다. 여기서는 다음과 같이 json 형태로 정의한다.

개발하는 가계부에는 지출/수입을 기준으로 하고 이체 관련 내용은 정리하지 않는다.

가계부의 카테고리 내역은 다음과 같다.

[
    {
        "category_name": "급여",
        "class_name": "",
        "children": [],
        "inout_type": "I",
        "sort_order": 1
    },
    {
        "category_name": "용돈",
        "class_name": "",
        "children": [],
        "inout_type": "I",
        "sort_order": 2
    },
    {
        "category_name": "금융수입",
        "class_name": "",
        "children": [],
        "inout_type": "I",
        "sort_order": 3
    },
    {
        "category_name": "사업수입",
        "class_name": "",
        "children": [],
        "inout_type": "I",
        "sort_order": 4
    },
    {
        "category_name": "기타수입",
        "class_name": "",
        "children": [],
        "inout_type": "I",
        "sort_order": 5
    },
    {
        "category_name": "식비",
        "class_name": "fa-solid fa-utensils",
        "children": ["한식", "중식", "일식", "양식", "아시안음식", "뷔페", "고기", "치킨", "피자", "패스트푸드", "배달", "식재료"],
        "inout_type": "O",
        "sort_order": 1
    },
    {
        "category_name": "카페/간식",
        "class_name": "fa-solid fa-mug-hot",
        "children": ["커피/음료", "베이커리", "디저트/떡", "도넛/핫도그", "아이스크림/빙수", "기타간식"],
        "inout_type": "O",
        "sort_order": 2
    },
    {
        "category_name": "술/유흥",
        "class_name": "fa-solid fa-champagne-glasses",
        "children": ["맥주/호프", "이자카야", "와인", "바(BAR)", "요리주점", "민속주점", "유흥시설"],
        "inout_type": "O",
        "sort_order": 3
    },
    {
        "category_name": "생활",
        "class_name": "fa-solid fa-basket-shopping",
        "children": ["생필품", "편의점", "마트", "생활서비스", "세탁", "목욕", "가구/가전"],
        "inout_type": "O",
        "sort_order": 4
    },
    {
        "category_name": "온라인쇼핑",
        "class_name": "fa-solid fa-truck-fast",
        "children": ["인터넷쇼핑", "홈쇼핑", "결제/충전", "앱스토어", "서비스구독"],
        "inout_type": "O",
        "sort_order": 5
    },
    {
        "category_name": "패션/쇼핑",
        "class_name": "fa-solid fa-bag-shopping",
        "children": ["패션", "신발", "아울렛/몰", "스포츠의류", "백화점"],
        "inout_type": "O",
        "sort_order": 6
    },
    {
        "category_name": "뷰티/미용",
        "class_name": "fa-solid fa-pump-soap",
        "children": ["화장품", "헤어샵", "미용관리", "미용용품", "네일", "성형외과", "피부과"],
        "inout_type": "O",
        "sort_order": 7
    },
    {
        "category_name": "교통",
        "class_name": "fa-solid fa-bus",
        "children": ["택시", "대중교통", "철도", "시외버스"],
        "inout_type": "O",
        "sort_order": 8
    },
    {
        "category_name": "자동차",
        "class_name": "fa-solid fa-car",
        "children": ["주유", "주차", "세차", "통행료", "할부/리스", "정비/수리", "차량보험", "대리운전"],
        "inout_type": "O",
        "sort_order": 9
    },
    {
        "category_name": "주거/통신",
        "class_name": "fa-solid fa-house-chimney",
        "children": ["휴대폰", "인터넷", "월세", "관리비", "가스비", "전기세"],
        "inout_type": "O",
        "sort_order": 10
    },
    {
        "category_name": "의료/건강",
        "class_name": "fa-solid fa-stethoscope",
        "children": ["약국", "종합병원", "피부과", "소아과", "산부인과", "안과", "이비인후과", "비노기과", "성형외과", "내과/가정의학", "정형외과", "치과", "한의원", "기타병원", "보조식품", "건강용품", "운동"],
        "inout_type": "O",
        "sort_order": 11
    },
    {
        "category_name": "금융",
        "class_name": "fa-solid fa-won-sign",
        "children": ["보험", "은행", "증권/투자", "카드", "이자/대출", "세금/과태료"],
        "inout_type": "O",
        "sort_order": 12
    },
    {
        "category_name": "문화/여가",
        "class_name": "fa-solid fa-ticket-sample",
        "children": ["영화", "도서", "게임", "음악", "공연", "전시/관람", "취미/체험", "테마파크", "스포츠", "마사지/스파"],
        "inout_type": "O",
        "sort_order": 13
    },
    {
        "category_name": "여행/숙박",
        "class_name": "fa-solid fa-plane",
        "children": ["숙박비", "항공권", "여행", "관광", "여행용품", "해외결제"],
        "inout_type": "O",
        "sort_order": 14
    },
    {
        "category_name": "교육/학습",
        "class_name": "fa-solid fa-graduation-cap",
        "children": ["학원/강의", "학습교재", "학교", "시험료"],
        "inout_type": "O",
        "sort_order": 15
    },
    {
        "category_name": "자녀/육아",
        "class_name": "fa-solid fa-children",
        "children": ["육아용품", "돌봄비", "자녀용돈", "자녀교육", "놀이/체험"],
        "inout_type": "O",
        "sort_order": 16
    },
    {
        "category_name": "반려동물",
        "class_name": "fa-solid fa-paw",
        "children": ["동물병원", "펫용품", "사료/간식"],
        "inout_type": "O",
        "sort_order": 17
    },
    {
        "category_name": "경조/선물",
        "class_name": "fa-solid fa-envelope",
        "children": ["축의금", "부의금", "기부/헌금", "선물", "회비"],
        "inout_type": "O",
        "sort_order": 18
    }
]

class_name 컬럼은 frontend 개발 시에 아이콘을 보여주기 위해서 정의하였으며, 각 카테고리는 하위 카테고리를 가질 수 있도록 구성하였다.

해당 카테고리 구성은 다른 프로그램을 참조하였다.

이제 회원 가입시 이 내용을 프로그램에 적용하는 작업을 진행하도록 하겠다.

우선 services/category_service.py 파일을 생성하고 아래 내용을 추가한다.

반응형
from sqlalchemy.ext.asyncio import AsyncSession
from ..database import models
import json
import asyncio

# 회원 가입시 기본 카테고리 구성 추가
async def insert_default_categories(
    db: AsyncSession,
    member_no: int,
):
    # 우선 카테고리 내용을 정의한 파일을 읽어들인다.
    with open("./categories.json", "r") as f:
        categories = json.load(f)

    # 메인 카테고리 리스트를 처리한다.
    for category in categories:
        new_category = {k: v for k, v in category.items() if k != 'children'}
        if len(category['children']) == 0:
            new_category['has_children'] = 'F'
        else:
            new_category['has_children'] = 'T'
        
        # 카테고리 정보를 DB에 저장한다.
        db_category = models.Category(**new_category, member_no=member_no)
        db.add(db_category)
        await db.commit()
        await db.refresh(db_category)
        insert_coros = []

        # 서브카테고리도 DB에 입력한다. 이 때 처리는 비동기로 한다.
        insert_coros = [db.execute(models.Category.__table__.insert().values(**{
                "member_no": member_no,
                "category_name": child,
                "has_children": "F",
                "inout_type": category["inout_type"],
                "parent_no": db_category.category_no,
                "sort_order": index + 1
            })) for index, child in enumerate(category['children'])]

        # 모든 데이터 추가 코루틴을 동시에 실행
        await asyncio.gather(*insert_coros)
        await db.commit()

 

반응형

이 함수의 파라미터는 member_no가 포함된다. 모든 카테고리는 사용자에게 포함되기 때문에 카테고리 정보에 member_no를 포함시켜주어야 한다.

처음으로 기본 카테고리로 정리한 내용을 프로그램에서 읽어오도록 하였다. 그러면 categories에 카테고리 정보가 List 형태로 담긴다.

이 categories를 하나씩 꺼내서 처리하는데 메인 카테고리 정보를 저장한 이후에 메인 카테고리에 포함된 서브 카테고리 내용을 비동기로 저장하도록 한다.

이렇게 하는 이유는 서브 카테고리에는 부모가 되는 메인 카테고리 category_no가 포함되어야 하는데 메인 카테고리의 저장이 끝나야 메인 category_no를 알 수 있다. 반대로 같은 메인 카테고리에 포함된 서브 카테고리를 서로 영향을 주지 않기 때문에 각자 저장이 끝날 때까지 기다릴 필요가 없다.

그래서 서브 카테고리를 저장할 때는 비동기로 처리하도록 하고 메인 카테고리 루프가 끝날 때는 서브 카테고리의 비동기 처리가 모두 끝날 때까지 다음 루프가 대기하도록 하였다.

이제 routers/members.py 파일에서 회원 가입 기능이 create 함수에 다음 코드를 추가한다.

반응형
...
from ..services import members_service, category_service

...
async def create(
    member: schemas.MemberCreate = Body(
        title="회원정보",
        example={
            "member_id": "foo",
            "member_pw": "1234567890",
            "member_name": "홍길동",
            "member_email": "test@example.com",
        }
    ),
    db: AsyncSession = Depends(get_db)
):
	try:
        result = await members_service.create_member(db, member)
        await category_service.insert_default_categories(db, result.member_no)

        return Response(status_code=HTTP_201_CREATED)
    except Exception as e:
        return JSONResponse(content={"detail": e.args[0]}, status_code=HTTP_400_BAD_REQUEST)

category_service를 추가로 로딩하도록 하였으며, members_service.create_member에서 회원 정보 저장에 대한 프로세스를 마무리 한 이후에 결과로 전달받은 result에서 member_no를 꺼내서 category_service.insert_default_categories 함수로 전달하도록 하였다.

members_service.create_member에는 DB에 저장한 이후에 primary key인 member_no가 포함된 결괏값을 전달하도록 한다.

이렇게 가입할 때 각 회원에게 기본 카테고리를 생성해주도록 코드를 작성하였다.

이 코드를 테스트하면 다음 그림과 같이 카테고리 정보가 회원 가입 시에 동시에 생성된다.

반응형

기본 카테고리 생성 후 테이블 결과

카테고리에 기본적인 구성이 마무리가 되었다.

다음 포스팅에서는 카테고리에 대한 정보를 생성/수정/삭제/열람하는 방법에 대한 코드를 기록하도록 하겠다.

728x90
반응형