매우매우 지겨운 과정이고 특별한 것이 없기 때문에 코드만 남겨둔다.

1. main 파일

# main.py 파일
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from routers import user, population, maritalstatus, bmi_status, religion, smoking, drinking, parent_asset, academic_background, income

app = FastAPI()

app.include_router(user.router)
app.include_router(population.router)
app.include_router(maritalstatus.router)
app.include_router(bmi_status.router)
app.include_router(religion.router)
app.include_router(smoking.router)
app.include_router(drinking.router)
app.include_router(parent_asset.router)
app.include_router(academic_background.router)
app.include_router(income.router)

origins = [
    "http://localhost:3000",
    "http://localhost:8000",
    "http://localhost:8000/maritalstatus",
    "http://localhost:8000/bmistatus",
    "http://localhost:8000/religion",
    "http://localhost:8000/smoking",
    "http://localhost:8000/drinking",
    "http://localhost:8000/parent_asset",
    "http://localhost:8000/academic_background",
    "http://localhost:8000/income",
]

app.add_middleware(
    CORSMiddleware,
    allow_origins=origins,
    allow_methods=["GET", "POST", "PUT", "DELETE"],  # 수정된 부분: 모든 HTTP 메서드를 허용합니다.
    allow_headers=["*"],
)

@app.get('/')
def root():
    return "hello world"

라우터 임포트하고 cors 문제를 해결했다.

 

2. 라우터 추가

# routers 폴더 - religion_status.py
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from routers.schemas import ReligionBase, ReligionDisplay
from db.database import get_db
from db import db_religion_crud

router = APIRouter(
    prefix='/religion',
    tags=['religion']
)


@router.post('', response_model=ReligionDisplay)
def religion_question(request: ReligionBase, db: Session = Depends(get_db)):
    # 유효성 검사: 성별이 제공되었는지 확인
    if request.gender == "남" or "남자":
        request.gender = "남자"
    elif request.gender == "여" or "여자":
        request.gender = "여자"
    else:
        raise HTTPException(status_code=400, detail="Gender is required")

    # 유효성 검사: 시작 나이가 종료 나이보다 큰지 확인
    if request.age_start and request.age_end and request.age_start > request.age_end:
        request.age_start, request.age_end = request.age_end, request.age_start



    if request.location:
        for index, location in enumerate(request.location):
            if location == '강원특별자치도':
                request.location[index] = '강원도'

    # 데이터베이스 조회
    return db_religion_crud.religion_ans(db, request)

 

입력 받은 데이터 중에 지역명의  조금 달라져서 예외를 추가했다.

# routers 폴더 - smoking.py
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from routers.schemas import SmokingBase, SmokingDisplay
from db.database import get_db
from db import db_smoking_crud

router = APIRouter(
    prefix='/smoking',
    tags=['smoking']
)

@router.post('', response_model=SmokingDisplay)
def smoking_question(request: SmokingBase, db: Session = Depends(get_db)):
    # 유효성 검사: 성별이 제공되었는지 확인
    if request.gender not in ["남", "남자", "여", "여자"]:
        raise HTTPException(status_code=400, detail="Gender is required and should be '남', '남자', '여', or '여자'")

    # 성별 값 표준화
    if request.gender in ["남", "남자"]:
        request.gender = "남자"
    elif request.gender in ["여", "여자"]:
        request.gender = "여자"

    # 유효성 검사: 시작 나이가 종료 나이보다 큰지 확인
    if request.age_start is not None and request.age_end is not None and request.age_start > request.age_end:
        request.age_start, request.age_end = request.age_end, request.age_start

    if request.smoking == "상관없음":
        return SmokingDisplay(smoking_rate=100)
    else:
        return db_smoking_crud.smoking_ans(db, request)

 

흡연 관련 데이터를 출입구. 어쩌다 보니 예외처리가 들어갔다. 나중에 한번에 처리할려고 했는데 포함되었다.

# routers 폴더 - drinking.py
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from routers.schemas import DrinkingBase, DrinkingDisplay
from db.database import get_db
from db import db_drinking_crud

router = APIRouter(
    prefix='/drinking',
    tags=['drinking']
)

@router.post('', response_model=DrinkingDisplay)
def drinking_question(request: DrinkingBase, db: Session = Depends(get_db)):
    # 유효성 검사: 성별이 제공되었는지 확인
    if request.gender not in ["남", "남자", "여", "여자"]:
        raise HTTPException(status_code=400, detail="Gender is required and should be '남', '남자', '여', or '여자'")

    # 성별 값 표준화
    if request.gender in ["남", "남자"]:
        request.gender = "남자"
    elif request.gender in ["여", "여자"]:
        request.gender = "여자"

    # 유효성 검사: 시작 나이가 종료 나이보다 큰지 확인
    if request.age_start is not None and request.age_end is not None and request.age_start > request.age_end:
        request.age_start, request.age_end = request.age_end, request.age_start

    if request.drinking:
        if len(request.drinking) == 6:
            return DrinkingDisplay(drinking_rate = 100)
        return db_drinking_crud.drinking_ans(db, request)

 

음주 관련 데이터 출입구. 흡연 라우터와 비슷하다.

# routers 폴더 - paent_asset.py
from fastapi import APIRouter, Depends, HTTPException
from routers.schemas import ParentAssetDisplay, ParentBase

router = APIRouter(
    prefix='/parent_asset',
    tags=['parent_asset']
)

@router.post('', response_model=ParentAssetDisplay)
def parent_asset(request: ParentBase):
    # 유효성 검사: 성별이 제공되었는지 확인
    if not request.parent_rate:
        raise HTTPException(status_code=400, detail=f"값 없음, {request.parent_rate}")

    asset_ans_dict = {
        '상관없음' : 1,
        '3.15억 이상': 0.5,
        '4.35억 이상': 0.4,
        '6.00억 이상': 0.3,
        '8.30억 이상': 0.2,
        '12.85억 이상': 0.1,
        '18.30억 이상': 0.05,
        '35.75억 이상': 0.01,
        '48.05억 이상': 0.005,
        '75.50억 이상': 0.001,
    }
    asset_ans = ParentAssetDisplay(
        parent_rate = asset_ans_dict[request.parent_rate]
        )

    return asset_ans

 

부모님 자산 관련 데이터 출입구.

데이터가 몇개 없고 파일을 구하기 어려워서 참고할만한 기사를 가지고 하드코딩했다. 사실 DB에 넣고 CRUD하는게 귀찮았다.

# routers 폴더 - academic_background.py
from fastapi import APIRouter, Depends, HTTPException
from routers.schemas import AcademicBackgroundBase, AcademicBackgroundDisplay

router = APIRouter(
    prefix='/academic_background',
    tags=['academic_background']
)

@router.post('', response_model=AcademicBackgroundDisplay)
def parent_asset(request: AcademicBackgroundBase):
    # 유효성 검사: 성별이 제공되었는지 확인
    if not request.academic_background:
        raise HTTPException(status_code=400, detail=f"값 없음, {request.academic_background}")

    academic_background_ans_dict = {
        '상관없음' : 0,
        '중졸 이상': 100 - (16.4 + 47.1 + 17.4 + 14.8),
        '고졸 이상' : 100 - (47.1 + 17.4 + 14.8),
        '2~3년제 대학교 이상' : 100 - (17.4 + 14.8),
        '4년제 대학교 이상': 100 - (14.8),
        '1등급 이상': 4,
        '2등급 이상': 11,
        '3등급 이상': 23,
        '4등급 이상': 40,
        '5등급 이상': 60,
        '6등급 이상': 77,
        '7등급 이상': 89,
        '8등급 이상': 96,
    }
    academic_background_ans = AcademicBackgroundDisplay(
        academic_background_rate = academic_background_ans_dict[request.academic_background]
        )

    return academic_background_ans

 

배우자 학력 수준. 역시 몇개 없어서 하드코딩이다.

프론트엔드에 100-확률 계산하기 때문에 수식이 약간 일관성이 없다.

from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from routers.schemas import IncomeBase, IncomeDisplay
from db.database import get_db
from db import db_income_crud

router = APIRouter(
    prefix='/income',
    tags=['income']
)

@router.post('', response_model=IncomeDisplay)
def smoking_question(request: IncomeBase, db: Session = Depends(get_db)):
    # 요청된 나이 범위의 유효성 검사
    if request.age_start is not None and request.age_end is not None and request.age_start > request.age_end:
        request.age_start, request.age_end = request.age_end, request.age_start

    # 소득 항목 리스트 설정
    income_list = set(["1000만원 이상",
                       "150~250만원",
                       "250~350만원",
                       "350~450만원",
                       "450~550만원",
                       "550~650만원",
                       "650~800만원",
                       "800~1000만원",
                       "85만원 미만",
                       "85~150만원",
                       "상관없음"])

    # 요청된 소득 항목 집합 설정
    request_set = set(request.income)

    # 소득 항목이 유효한지 확인
    if not request_set.issubset(income_list):
        raise HTTPException(status_code=400, detail="잘못된 소득 항목입니다.")

    # '상관없음'인 경우 소득 비율을 100으로 설정
    if "상관없음" in request.income:
        return IncomeDisplay(income_rate=100.0)
    else:
        return db_income_crud.income_ans(db, request)

배우자 소득 수준 라우터이다. 입력값 검증을 좀 확실하게 해두었다.

 

3. 스키마

# routers 폴더 - schemas.py
from pydantic import BaseModel
from datetime import datetime
from pydantic import Field
from typing import Union, Dict

class UserBase(BaseModel):
   username: str
   password: str

# todo 암호관련 작업 필요하다.

class UserDisplay(BaseModel):
   username: str
   class Config():
       from_attributes = True

class CommentBase(BaseModel):
   text: str
   username: str
   timestamp: datetime

class PopulationQuestion(BaseModel):
   gender: str | None = '남'
   age_start: int | None = None
   age_end: int | None = None
   location: list | None = None

class PopulationDisplay(BaseModel):
   gender: str | None = None
   man_gender_population: int | None = None
   woman_gender_population: int | None = None
   total_population: int | None = None
   man_age_range_population: int | None = None
   woman_age_range_population: int | None = None
   total_population_in_range: int | None = None
   man_population_by_region: int | None = None
   woman_population_by_region: int | None = None
   man_population_by_all_region: Dict[str, int] | None = None  # 수정된 부분
   woman_population_by_all_region: Dict[str, int] | None = None  # 수정된 부분

class MaritalStatusBase(BaseModel):
   maritalstatus: list
   gender: str | None = None
   age_start: int | None = None
   age_end: int | None = None
   location: list | None = None

class MaritalStatusDisplay(BaseModel):
   age_start : int | None = None
   age_end : int | None = None
   total_pop_marital : int | None = None
   target_pop_marital : int | None = None

class BmiStatusBase(BaseModel):
   bmistatus: list
   gender: str
   age_start: int
   age_end: int

class BmiStatusDisplay(BaseModel):
   total_pop_bmi : int | None = None
   target_pop_bmi : int | None = None

class ReligionBase(BaseModel):
   religion : list
   gender: str
   age_start: int
   age_end: int
   location: list

class ReligionDisplay(BaseModel):
   whole_country_pop_religion : int | None = None
   whole_country_pop_selected_religion : int | None = None
   selected_location_religion_sum : int | None = None
   selected_location_religion_target_sum : int | None = None

class SmokingBase(BaseModel):
   smoking: str
   gender: str
   age_start: int
   age_end: int

class SmokingDisplay(BaseModel):
   smoking_rate : float | None = None

class DrinkingBase(BaseModel):
   drinking: list
   gender: str
   age_start: int
   age_end: int

class DrinkingDisplay(BaseModel):
   drinking_rate : float | None = None

class ParentBase(BaseModel):
   parent_rate : str | None = None

class ParentAssetDisplay(BaseModel):
   parent_rate : float | None = None

class AcademicBackgroundBase(BaseModel):
    academic_background : str | None = None

class AcademicBackgroundDisplay(BaseModel):
   academic_background_rate : float | None = None


class IncomeBase(BaseModel):
   income: list
   age_start: int
   age_end: int

class IncomeDisplay(BaseModel):
   income_rate : float | None = None

# class PopulationDisplay(BaseModel):
#    gender: str
#    total_population : int
#    man_population: int
#    woman_gender_population: int
#    total_population_in_range : int
#    man_age_range_population: int
#    woman_age_range_population: int
#    man_population_by_rigion = Dict[str, int]
#    woman_population_by_rigion = Dict[str, int]
#    class Config():
#        from_attributes = True
#    # todo 그래프를 그릴 데이터를 같이 반환할 필요가 있겠다.

# class PostBase(PopulationQuestion):
#    height_upper: str | None = None
#    height_lower: str | None = None
#    marriage: str | None = None
#    bmi: str | None = None
#    personality: str | None = None
#    education: str | None = None
#    religion: str | None = None
#    smoking: str | None = None
#    drinking: str | None = None
#    occupation: str | None = None
#    parent_asset: str | None = None
#    timestamp: datetime

 

스키마도 입출력 형식에 맞게 짜두었다.

 

4. db모델

# models.py
from db.database import Base
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Float
from sqlalchemy.orm import relationship

class DbUser(Base):
   __tablename__= 'user'
   id = Column(Integer, primary_key=True, index=True)
   username = Column(String)
   password = Column(String)
   items = relationship('DbPost', back_populates='user')

# todo 암호 관련 작업이 필요하다.

class DbPost(Base):
   __tablename__ = 'post'
   id = Column(Integer, primary_key=True, index=True)
   age = Column(String)
   height = Column(String)
   education = Column(String)
   occupation = Column(String)
   residence_location = Column(String)
   religion = Column(String)
   timestamp = Column(DateTime)
   user_id = Column(Integer, ForeignKey('user.id'))
   user = relationship('DbUser', back_populates='items')
   comments = relationship('DbComment', back_populates='post')

class DbComment(Base):
   __tablename__ = 'comment'
   id = Column(Integer, primary_key=True, index=True)
   text = Column(String)
   username = Column(String)
   timestamp = Column(DateTime)
   post_id = Column(Integer, ForeignKey('post.id'))
   post = relationship('DbPost', back_populates='comments')

class DbPopulation(Base):
   __tablename__ = 'population_statistics'
   id = Column(Integer, primary_key=True, index=True)
   gender = Column(String)
   age = Column(Integer)
   total_population = Column(Integer)
   seoul = Column(Integer)
   busan = Column(Integer)
   daegu = Column(Integer)
   incheon = Column(Integer)
   gwangju = Column(Integer)
   daejeon =  Column(Integer)
   ulsan = Column(Integer)
   sejong =  Column(Integer)
   gyeonggi =  Column(Integer)
   gangwon = Column(Integer)
   chungcheongbuk = Column(Integer)
   chungcheongnam = Column(Integer)
   jeollabuk = Column(Integer)
   jeollanam = Column(Integer)
   gyeongsangbuk = Column(Integer)
   gyeongsangnam = Column(Integer)
   jeju = Column(Integer)

class DbMaritalStatus(Base):
   __tablename__ = 'marital_status_statistics'
   id = Column(Integer, primary_key=True, index=True)
   residential_area = Column(String)
   age = Column(String)
   man_age_total = Column(Integer)
   man_unmarried = Column(Integer)
   man_Married = Column(Integer)
   man_Separation_by_death = Column(Integer)
   man_divorce = Column(Integer)
   woman_age_total = Column(Integer)
   woman_unmarried = Column(Integer)
   woman_Married = Column(Integer)
   woman_Separation_by_death = Column(Integer)
   woman_divorce = Column(Integer)

class DbBmiStatus(Base):
   __tablename__ = 'bmi_status_statistics'
   id = Column(Integer, primary_key=True, index=True)
   age = Column(String)
   gender = Column(String)
   total = Column(Integer)
   BMI_18_5_under = Column(Integer)
   BMI_18_5to25_0 = Column(Integer)
   BMI_25_0to30_0 = Column(Integer)
   BMI_30_5to40_0 = Column(Integer)
   BMI_40_0_over = Column(Integer)

class DbReligion(Base):
   __tablename__ = 'Religon_statistics'
   id = Column(Integer, primary_key=True, index=True)
   residence_location = Column(String)
   gender = Column(String)
   age = Column(String)
   total = Column(Integer)
   christianity = Column(Integer)
   catholicism = Column(Integer)
   etc = Column(Integer)
   daesoonjinrihoe = Column(Integer)
   daejonggyo = Column(Integer)
   buddhism =  Column(Integer)
   one_buddhism = Column(Integer)
   confucianism = Column(Integer)
   atheist =  Column(Integer)
   religious =  Column(Integer)
   chondogye =  Column(Integer)


class DbSmoking(Base):
   __tablename__ = 'smoking_statistics'
   id = Column(Integer, primary_key=True, index=True)
   gender = Column(String)
   age = Column(String)
   smoking_rate = Column(Float)

class DbDrinkingPerMonth(Base):
   __tablename__ = 'drinking_per_month'
   id = Column(Integer, primary_key=True, index=True)
   gender = Column(String)
   age = Column(String)
   no_drinking = Column(Float)
   drinking = Column(Float)
   one_under_per_month = Column(Float)
   one_two_per_month = Column(Float)
   one_two_per_week = Column(Float)
   three_fout_per_week = Column(Float)
   everyday = Column(Float)

class DbIncome(Base):
   __tablename__ = 'income'
   id = Column(Integer, primary_key=True, index=True)
   age = Column(String)
   income_1000_over = Column(Float)
   income_150_250 = Column(Float)
   income_250_350 = Column(Float)
   income_350_450 = Column(Float)
   income_450_550 = Column(Float)
   income_550_650 = Column(Float)
   income_650_800 = Column(Float)
   income_800_1000 = Column(Float)
   income_85_under = Column(Float)
   income_85_150 = Column(Float)

# alembic revision --autogenerate
# alembic upgrade head

# 잘못된 db 테이블을 생성했을때 사용
# alembic revision --autogenerate -m "Drop table 테이블이름"
# alembic upgrade head

 

db 모델도 특별할것은 없다. 다만 늦게 알게 된 부분이 있는데

db의 칼럼명은 영어로 하고 입력 받는 칼럼명은 한글일때 어떻게 일치 시킬지가 고민이었다.

예를 들어 'engligh' 와 '영어' 처럼 말이다. 한두개면 그냥 하드코딩하지만 몇십개가 되면 상당히 골치 아픈 부분이다.

이때 모델링을 할때 english = Column(str, name='영어') 으로 name 옵션을 붙여두면 데이터 처리할때 활용할수 있다.

이미 하드코딩을 한 상태라서 그냥 놔둬버렸다.

뭔가 불편하고 귀찮은 코딩 방식인데 라는 생각이 들면 100% 쉬운 방법이 있다는 것을 유념해야 한다.
실제로 시도해봤지만 잘안되서 역시 하드코딩을 ㅠㅜ

 

5. DB CRUD 파일

from sqlalchemy.orm.session import Session
from sqlalchemy import func, Integer
from db.models import DbReligion
from routers.schemas import ReligionBase, ReligionDisplay

def religion_ans(db: Session, request: ReligionBase):
    # 필터링된 결과를 가리킬 변수
    filtered_db = db.query(DbReligion)

    # 성별로 필터링
    filtered_db = filtered_db.filter(DbReligion.gender == request.gender)

    # 나이로 필터링
    min_age = request.age_start
    max_age = request.age_end
    filtered_db = filtered_db.filter(DbReligion.age.cast(Integer).between(min_age, max_age))

    # 지역으로 필터링
    if "전국" not in request.location:
        request.location.append("전국")
    filtered_db = filtered_db.filter(DbReligion.residence_location.in_(request.location))

    _selected_religion = []

    religion_dict = {
        '기타': 'etc',
        '개신교': 'christianity',
        '천주교': 'catholicism',
        '대순진리회': 'daesoonjinrihoe',
        '대종교': 'daejonggyo',
        '불교': 'buddhism',
        '원불교': 'one_buddhism',
        '유교': 'confucianism',
        '종교없음': 'atheist',
        '종교있음': 'religious',
        '천도교': 'chondogye'
    }
    for key in request.religion:
        _selected_religion.append(religion_dict[key])

    # DB 지역 칼럼 이름 가져오기
    religion_columns = [column.key for column in DbReligion.__table__.columns \
                      if column.key in _selected_religion]


    # 전국 + 전체 종교인구
    _whole_country_pop_religion = (
        filtered_db.filter(DbReligion.residence_location == '전국')
        .with_entities(func.sum(DbReligion.total))
        .scalar()
    )

    _whole_country_pop_selected_religion = 0
    _selected_location_religion_target_sum = 0

    for region in religion_columns:
        # 전국 + 선택한 종교 인구
        _whole_country_pop_selected_religion += (
            filtered_db.filter(DbReligion.residence_location == '전국')
            .with_entities(func.sum(getattr(DbReligion, region)))
            .scalar()
        )

        # 선택한 지역 + 선택한 종교 인구
        _selected_location_religion_target_sum += (
            filtered_db.filter(DbReligion.residence_location != '전국')
            .with_entities(func.sum(getattr(DbReligion, region)))
            .scalar()
        )

    # 선택한 지역 + 전체 종교 인구
    _selected_location_religion_sum = (
        filtered_db.filter(DbReligion.residence_location != '전국')
        .with_entities(func.sum(DbReligion.total))
        .scalar()
    )


    religion_result = ReligionDisplay(
        whole_country_pop_religion = _whole_country_pop_religion,
        whole_country_pop_selected_religion = _whole_country_pop_selected_religion,
        selected_location_religion_sum = _selected_location_religion_sum,
        selected_location_religion_target_sum=_selected_location_religion_target_sum
    )
    return religion_result

 

위에서 말한 하드코딩의 예시. 종교명을 딕셔너리로 다 적었다.

# db 폴더 - db_smoking_crud.py
from sqlalchemy.orm.session import Session
from sqlalchemy import func, or_, and_
from db.models import DbSmoking
from routers.schemas import SmokingBase,SmokingDisplay

def smoking_ans(db: Session, request: SmokingBase):
    filtered_db = db.query(DbSmoking)

    # 성별 필터링
    filtered_db = filtered_db.filter(DbSmoking.gender == request.gender)

    age_ranges = []

    dividing_value = 10

    # 19세 이하 처리
    age_start = request.age_start - request.age_start % dividing_value

    # 84세 이상 처리
    if request.age_end >= 70:
        age_ranges.append("70세이상")
        request.age_end = 61

    # 10세 단위로 범위 생성
    age_ranges.extend(
        [f"{i}-{i + dividing_value - 1}세" if i > 29 else "19-29세" for i in
         range(age_start, request.age_end + 1, dividing_value)])


    # 필터링을 위한 조건 생성
    filter_condition = or_(*[DbSmoking.age.like(f"{age_range}") for age_range in age_ranges])

    # 데이터베이스에서 필터링된 결과 가져오기
    filtered_data = filtered_db.filter(filter_condition).all()

    # 필터링 된 데이터의 갯수 가져오기
    count = len(filtered_data)

    # 흡연 비율 계산
    _smoking_rate = round(sum(data.smoking_rate for data in filtered_data) / count, 2)

    # 결과 반환
    if request.smoking == "비흡연":
        _smoking_rate = 100 - _smoking_rate

    smoking_result = SmokingDisplay(
        smoking_rate=_smoking_rate,
    )
    return smoking_result

 

흡연 CRUD. 나이 처리가 특이하다.

정수로 들어오는 나이를 19~29세라는 통계의 칼럼으로 바꿨다.

 

# db폴더 db_drinking_crud.py
from sqlalchemy.orm.session import Session
from sqlalchemy import func, or_, and_
from db.models import DbDrinkingPerMonth
from routers.schemas import DrinkingBase,DrinkingDisplay

def drinking_ans(db: Session, request: DrinkingBase):
    filtered_db = db.query(DbDrinkingPerMonth)

    # 성별 필터링
    filtered_db = filtered_db.filter(DbDrinkingPerMonth.gender == request.gender)

    age_ranges = []

    dividing_value = 10

    # 19세 이하 처리
    if request.age_start <= 19:
        request.age_start = 20
    request.age_start = request.age_start - request.age_start % dividing_value

    # 84세 이상 처리
    if request.age_end >= 60:
        age_ranges.append("60세이상")
        request.age_end = 59

    # 10세 단위로 범위 생성
    age_ranges.extend(
        [f"{i}~{i + dividing_value - 1}세" for i in range(request.age_start, request.age_end + 1, dividing_value)])

    # 필터링을 위한 조건 생성
    filter_condition = or_(*[DbDrinkingPerMonth.age.like(f"{age_range}") for age_range in age_ranges])

    # 데이터베이스에서 필터링된 결과 가져오기
    filtered_data = filtered_db.filter(filter_condition).all()

    # 필터링 된 데이터의 갯수 가져오기
    count = len(filtered_data)

    # 흡연 비율 계산
    _no_drinking = round(sum(data.no_drinking for data in filtered_data) / count, 2)
    _drinking = round(sum(data.drinking for data in filtered_data) / count, 2)
    _one_under_per_month = round(sum(data.one_under_per_month for data in filtered_data) / count, 2)
    _one_two_per_month = round(sum(data.one_two_per_month for data in filtered_data) / count, 2)
    _one_two_per_week = round(sum(data.one_two_per_week for data in filtered_data) / count, 2)
    _three_fout_per_week = round(sum(data.three_fout_per_week for data in filtered_data) / count, 2)
    _everyday = round(sum(data.everyday for data in filtered_data) / count, 2)

    data_convert_dict = {
        "마시지않는다": _no_drinking,
        "마신다": _drinking,
        "월1회이하": _one_under_per_month,
        "월2~3회": _one_two_per_month,
        "주1~2회": _one_two_per_week,
        "주3~4회": _three_fout_per_week,
        "거의매일": _everyday
    }

    # 결과 반환
    _drinking_rate = 0
    _drinking_rate_temp = 0
    _temp_num = 0

    for Frequency in request.drinking:
        if Frequency == "마시지않는다":
            _drinking_rate += data_convert_dict[Frequency]
        else:
            _drinking_rate_temp += data_convert_dict[Frequency]
            _temp_num += 1

    if _temp_num >= 1:
        _drinking_rate = round(_drinking_rate + _drinking * _drinking_rate_temp / 100, 2)

    drinking_result = DrinkingDisplay(
        drinking_rate= _drinking_rate
    )
    return drinking_result

 

음주 통계 자료 처리를 위한 CRUD. 

마시지 않는다는 그대로 더하고 나머지는 평균값을 내는게 특이점이다.

from sqlalchemy.orm.session import Session
from sqlalchemy import func, or_
from db.models import DbIncome
from routers.schemas import IncomeBase, IncomeDisplay

def income_ans(db: Session, request: IncomeBase):
    age_ranges = []

    dividing_value = 5

    # 19세 이하 처리
    if request.age_start <= 19:
        age_ranges.append("19세 이하")
        request.age_start = 20
    request.age_start = request.age_start - request.age_start % dividing_value

    # 84세 이상 처리
    if request.age_end >= 65:
        age_ranges.append("65세 이상")
        request.age_end = 64

    # 5세 단위로 범위 생성
    age_ranges.extend(
        [f"{i}~{i + 4}세" for i in range(request.age_start, request.age_end + 1, dividing_value)])

    # 필터링을 위한 조건 생성
    filter_condition = or_(*[DbIncome.age.like(f"{age_range}") for age_range in age_ranges])

    # 나이로 필터링하여 데이터 가져오기
    filtered_data = db.query(DbIncome).filter(filter_condition).all()

    _income_rate = 0

    count = len(filtered_data)

    income_columns_dict = {
        "1000만원 이상": "income_1000_over",
        "150~250만원": "income_150_250",
        "250~350만원": "income_250_350",
        "350~450만원": "income_350_450",
        "450~550만원": "income_450_550",
        "550~650만원": "income_550_650",
        "650~800만원": "income_650_800",
        "800~1000만원": "income_800_1000",
        "85만원 미만": "income_85_under",
        "85~150만원": "income_85_150"
    }

    # 소득 항목에 대응하는 칼럼 합 구하기
    for data in filtered_data:
        for income_column in request.income:
            column_name = income_columns_dict[income_column]
            column_value = getattr(data, column_name)
            _income_rate += column_value

    # 데이터의 갯수로 나누어 평균 구하기
    if count > 0:
        _income_rate /= count

    income_rate = IncomeDisplay(
        income_rate=_income_rate
    )
    return income_rate

DB에서 쿼리를 하는 과정에서 문제가 있어서 답이 안나오나보다 하고 머리를 싸메고 있었는데,

나이를 5단위로 리스트를 만드는 과정에 문제가 있었다. "20 ~ 24세" 가 아니라 "20~24세" 띄어쓰기가 문제였다.

두시간 넘게 삽질했는데 허무한 원인이었다.

 

이로서 육각남 찾기 백엔드는 끝났다. 이제 프론트엔드를 본격적으로 꾸며볼 차례이다.

사실 로그인 기능, 덧글 등을 구현할까 했는데 좀 지쳐버렸다.

2. BMI 상태

import pandas as pd
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from db.models import DbBmiStatus
from db.database import Base

# 엑셀 파일 읽기
df = pd.read_excel("/Users/honjun/Desktop/PycharmProjects/hexagonman/datafile/연령별_체질량_분포_현황_20240329090731.xlsx")

df = df.fillna(method='ffill')

# 첫 번째 행을 열 이름으로 설정하고 삭제
df.columns = df.iloc[0]
df = df.drop(0)

# 인덱스 다시 설정
df.reset_index(drop=True, inplace=True)


# 데이터베이스에 연결
engine = create_engine('sqlite:///../haxagonMan.db')  # 데이터베이스 URL에 따라 변경해야 할 수 있습니다.

# 데이터베이스 테이블 생성
Base.metadata.create_all(engine)

# 데이터베이스 세션 생성
Session = sessionmaker(bind=engine)
session = Session()

print(df.head(10).to_string())


for index, row in df.iterrows():
    BMI_status_statistics = DbBmiStatus(
        age=str(row.iloc[0]),
        gender=str(row.iloc[1]),
        total=int(row.iloc[2]),
        BMI_18_5_under =int(row.iloc[3]),
        BMI_18_5to25_0 =int(row.iloc[4]),
        BMI_25_0to30_0 =int(row.iloc[5]),
        BMI_30_5to40_0 =int(row.iloc[6]),
        BMI_40_0_over =int(row.iloc[7]),
    )
    session.add(BMI_status_statistics)

# 변경사항 커밋
session.commit()

# 세션 종료

엑셀 파일 BMI 데이터를 db 에 넣기

# routers 폴더 - bmi_status.py
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from routers.schemas import BmiStatusBase, BmiStatusDisplay
from db.database import get_db
from db import db_bmi_status_crud

router = APIRouter(
    prefix='/bmistatus',
    tags=['bmistatus']
)


@router.post('', response_model=BmiStatusDisplay)
def bmi_status_question(request: BmiStatusBase, db: Session = Depends(get_db)):
    # 유효성 검사: 성별이 제공되었는지 확인
    if request.gender == "남" or "남자":
        request.gender = "남자"
    elif request.gender == "여" or "여자":
        request.gender = "여자"
    else:
        raise HTTPException(status_code=400, detail="Gender is required")

    # 유효성 검사: 시작 나이가 종료 나이보다 큰지 확인
    if request.age_start and request.age_end and request.age_start > request.age_end:
        request.age_start, request.age_end = request.age_end, request.age_start

    # 데이터베이스 조회
    return db_bmi_status_crud.bmi_status_ans(db, request)

라우터를 작성.

class BmiStatusBase(BaseModel):
   bmistatus: list
   gender: str | None = None
   age_start: int | None = None
   age_end: int | None = None

class MaritalStatusDisplay(BaseModel):
   total_pop_bmi : int | None = None
   target_pop_bmi : int | None = None

스키마도 작성하였다.

들어오는 데이터는 나이와 데이터와 성별, bmi 상태를 리스트 받는다.

출력 데이터는 성별, 나이에 해당하는 전체 인구에서 bmi 요청에 해당하는 부분인구 2개로 하였다.

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from routers import user, population, maritalstatus, bmi_status

app = FastAPI()

app.include_router(user.router)
app.include_router(population.router)
app.include_router(maritalstatus.router)
app.include_router(bmi_status.router)

origins = [
    "http://localhost:3000",
    "http://localhost:8000",
    "http://localhost:8000/maritalstatus",
    "http://localhost:8000/bmistatus",
]

app.add_middleware(
    CORSMiddleware,
    allow_origins=origins,
    allow_methods=["GET", "POST", "PUT", "DELETE"],  # 수정된 부분: 모든 HTTP 메서드를 허용합니다.
    allow_headers=["*"],
)

@app.get('/')
def root():
    return "hello world"

main.py파일에도 작성 한 라우터를 반영.

from sqlalchemy.orm.session import Session
from sqlalchemy import func, or_, and_
from db.models import DbBmiStatus
from routers.schemas import BmiStatusBase, BmiStatusDisplay

def bmi_status_ans(db: Session, request: BmiStatusBase):
    bmi_mapping = {'저체중 (BMI 18.5 미만)': 'BMI_18_5_under',
                   '정상체중 (BMI 18.5~25.0 미만)': 'BMI_18_5to25_0',
                   '비만1단계 (BMI 25.0~30.0 미만)': 'BMI_25_0to30_0',
                   '비만2단계 (BMI 30.0~40.0 미만)': 'BMI_30_5to40_0',
                   '비만3단계 (BMI 40.0 이상)': 'BMI_40_0_over'}

    filtered_db = db.query(DbBmiStatus)
    filtered_db = filtered_db.filter(DbBmiStatus.gender == request.gender)

    age_ranges = []

    # 19세 이하 처리
    if request.age_start <= 19:
        age_ranges.append("19세 이하")
        request.age_start = 20
    elif request.age_start % 5 != 0:
        request.age_start = request.age_start - request.age_start % 5

    # 84세 이상 처리
    if request.age_end >= 85:
        age_ranges.append("84세 이상")
        request.age_end = 84

    # 5세 단위로 범위 생성
    age_ranges.extend([f"{i} ~ {i + 4}세" for i in range(request.age_start, request.age_end + 1, 5)])

    # 필터링을 위한 조건 생성
    filter_condition = or_(*[DbBmiStatus.age.like(f"{age_range}") for age_range in age_ranges])

    # 데이터베이스에서 필터링된 결과 가져오기
    filtered_data = filtered_db.filter(filter_condition).all()

    # total_pop_bmi 값 계산
    total_pop_bmi = sum(data.total for data in filtered_data)

    # target_pop_bmi 값 계산
    target_pop_bmi = sum(getattr(data, bmi_mapping[i]) for data in filtered_data for i in request.bmistatus)

    # 결과 반환
    bmi_status_result = BmiStatusDisplay(
        total_pop_bmi = total_pop_bmi,
        target_pop_bmi = target_pop_bmi,
    )
    return bmi_status_result

db CRUD 파일도 생성하여 작성하였다. request 로 들어온 값과 db의 칼럼값을 일치시키는 문제 때문에 고민이 있었다.

import React, { useState } from 'react';
import Header from './components/Header/Header';
import AgeInput from './components/UserInput/AgeRange';
import LocationSelector from './components/UserInput/LocationSelector';
import MaritalStatusSelector from './components/UserInput/MaritalStatusSelector';
import WeightStatusSelector from './components/UserInput/WeightStatusSelector';
import AppearanceScore from './components/UserInput/AppearanceScore';
import ReligionSelector from './components/UserInput/ReligionSelector';
import SmokingSelector from './components/UserInput/SmokingSelector';
import DrinkingSelector from './components/UserInput/DrinkingSelector';
import GenderSelector from './components/UserInput/GenderSelector';
import GenderGraph from './components/Graph/GenderGraph';
import AgeRangeGraph from './components/Graph/AgeRangeGraph';
import LocationGraph from './components/Graph/LocationGraph';
import MaritalGraph from './components/Graph/MaritalGraph'; 
import BmiGraph from'./components/Graph/BmiGraph';
import './App.css';

const App = () => {
  const [selectedGender, setSelectedGender] = useState('');
  const [ageStart, setAgeStart] = useState(null);
  const [ageEnd, setAgeEnd] = useState(null);
  
  const [genderGraphData, setGenderGraphData] = useState(null);
  const [ageRangeGraphData, setAgeRangeGraphData] = useState(null);
  const [locationGraphData, setLocationGraphData] = useState(null);
  const [maritalGraphData, setMaritalGraphData] = useState(null);
  
  const [bmistatus, setBmistatus] = useState([]);
  
  const [BmiGraphData, setBmiGraphData] = useState(null);

  
  const handleStatusChange = (selectedStatus) => {
    setBmistatus(selectedStatus);
  };

  const handleAgeChange = (start, end) => {
    setAgeStart(start);
    setAgeEnd(end);
  };

  const handleGenderChange = (gender) => {
    setSelectedGender(gender);
  };

  const handleGenderGraphDataChange = (data) => {
    setGenderGraphData(data);
  };

  const handleAgeRangeGraphDataChange = (data) => {
    setAgeRangeGraphData(data);
  };

  const handleLocationGraphDataChange = (data, selectedLocations) => {
    setLocationGraphData({ data, selectedLocations });
  };

  const handleMaritalStatusGraphDataChange = (data) => {
    setMaritalGraphData(data);
  };

  const handleBmiGraphDataChange = (data) => {
    setBmiGraphData(data);
  };

  return (
    <div className="app-container">
      <Header />
      <div className="content-container">
        <div className="input-container">
          <GenderSelector
            selectedGender={selectedGender}
            onGenderChange={handleGenderChange}
            onGraphDataChange={handleGenderGraphDataChange}
          />
          <AgeInput
            gender={selectedGender}
            onAgeChange={handleAgeChange}
            onGraphDataChange={handleAgeRangeGraphDataChange}
          />
          <LocationSelector
            ageStart={ageStart}
            ageEnd={ageEnd}
            onGraphDataChange={handleLocationGraphDataChange}
          />
          <MaritalStatusSelector
            gender={selectedGender}
            ageStart={ageStart}
            ageEnd={ageEnd}
            selectedLocations={locationGraphData ? locationGraphData.selectedLocations : []} // 수정된 부분
            onGraphDataChange={handleMaritalStatusGraphDataChange}
          />
          <WeightStatusSelector
            gender={selectedGender}
            ageStart={ageStart}
            ageEnd={ageEnd}
            onStatusChange={handleStatusChange}
            onGraphDataChange={handleBmiGraphDataChange}
          />
          <AppearanceScore />
          <ReligionSelector />
          <SmokingSelector />
          <DrinkingSelector />
        </div>
        <div className="graph-container">
          {genderGraphData && (
            <GenderGraph responseData={genderGraphData} />
          )}
          {ageRangeGraphData && (
            <AgeRangeGraph responseData={ageRangeGraphData} />
          )}
          {locationGraphData && (
            <LocationGraph
              responseData={locationGraphData.data}
              selectedLocations={locationGraphData.selectedLocations}
            />
          )}
          {maritalGraphData !== null && (
            <MaritalGraph responseData={maritalGraphData} /> // MaritalGraph 컴포넌트 추가
          )}
          {BmiGraphData !== null && (
            <BmiGraph responseData={BmiGraphData} /> 
          )}
        </div>
      </div>
    </div>
  );
};

export default App;

app.js 파일

import React, { useState, useEffect } from 'react';
import withMouseHoverColorChange from './HOC/withMouseHoverColorChange';
import './WeightStatusSelector.css';

const WeightStatusSelector = ({ gender, ageStart, ageEnd, onStatusChange, onGraphDataChange }) => {
  const [selectedStatus, setSelectedStatus] = useState([]);

  const statusOptions = [
    '저체중 (BMI 18.5 미만)',
    '정상체중 (BMI 18.5~25.0 미만)',
    '비만1단계 (BMI 25.0~30.0 미만)',
    '비만2단계 (BMI 30.0~40.0 미만)',
    '비만3단계 (BMI 40.0 이상)'
  ];

  useEffect(() => {
    // 상태가 변경될 때만 onStatusChange 호출
    onStatusChange(selectedStatus);
  }, [selectedStatus, onStatusChange]);

  useEffect(() => {
    // 상태가 변경될 때만 postBMIStatus 호출
    if (selectedStatus.length > 0) {
      postBMIStatus();
    }
  }, [selectedStatus, gender, ageStart, ageEnd]); // selectedStatus 변경 시에만 호출

  const handleStatusClick = (status) => {
    if (selectedStatus.includes(status)) {
      setSelectedStatus(selectedStatus.filter(s => s !== status));
    } else {
      setSelectedStatus([...selectedStatus, status]);
    }
  };

  const postBMIStatus = async () => {
    try {
      const response = await fetch('http://localhost:8000/bmistatus', {
        method: 'POST',
        headers: {
          'Accept': 'application/json',
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({
          bmistatus: selectedStatus,
          gender: gender,
          age_start: parseInt(ageStart), // 정수로 변환
          age_end: parseInt(ageEnd) // 정수로 변환
        })
      });
      if (response.ok) {
        const data = await response.json(); // 응답 결과를 JSON 형식으로 파싱
        onGraphDataChange(data); // 응답 결과를 onGraphDataChange에 전달
        console.log('BMI 상태를 서버에 성공적으로 전송했습니다.');
      } else {
        console.error('BMI 상태를 서버에 전송하는 도중 오류가 발생했습니다.');
      }
    } catch (error) {
      console.error('네트워크 오류:', error);
    }
  };

  return (
    <div className="weight-status-selector">
      <div className="label">체중 상태</div>
      <div className="buttons">
        {statusOptions.map(status => (
          <button
            key={status}
            className={selectedStatus.includes(status) ? 'button selected' : 'button'}
            onClick={() => handleStatusClick(status)}
          >
            {status}
            {selectedStatus.includes(status) && <span className="checkmark">&#10003;</span>}
          </button>
        ))}
      </div>
    </div>
  );
};

export default withMouseHoverColorChange(WeightStatusSelector, '#f0f0f0', '#2196F3');

WeightStatusSelector.js 파일

import React, { useEffect, useRef } from 'react';
import Chart from 'chart.js/auto'; // Chart.js 최신 버전을 사용하기 위한 import

const BmiGraph = ({ responseData }) => {
  const chartRef = useRef(null);
  const chartInstance = useRef(null);

  useEffect(() => {
    if (responseData) {
      const { target_pop_bmi, total_pop_bmi } = responseData;

      const ctx = chartRef.current.getContext('2d');

      // 이전 차트 파괴
      if (chartInstance.current !== null) {
        chartInstance.current.destroy();
      }

      // 새로운 차트 생성
      chartInstance.current = new Chart(ctx, {
        type: 'doughnut', // 원형 그래프
        data: {
          labels: ['선택한 BMI 인구%', '나머지%'],
          datasets: [{
            label: 'BMI 비율',
            data: [target_pop_bmi / total_pop_bmi * 100, (1 - target_pop_bmi / total_pop_bmi) * 100], // 수정된 부분
            backgroundColor: [
              'rgba(255, 99, 132, 0.6)', // 부분값
              'rgba(54, 162, 235, 0.6)', // 전체값
            ],
            borderColor: [
              'rgba(255, 99, 132, 1)',
              'rgba(54, 162, 235, 1)',
            ],
            borderWidth: 1
          }]
        },
        options: {
          responsive: true,
          maintainAspectRatio: true, // 그래프 크기를 부모 요소에 맞게 조절
          plugins: {
            title: {
              display: true,
              text: 'BMI 비율'
            },
            tooltip: {
              callbacks: {
                label: function(context) {
                  let label = context.label || '';
                  if (label) {
                    label += ': ';
                  }
                  label += Math.round(context.raw) + '%';
                  return label;
                }
              }
            }
          }
        }
      });
    }
  }, [responseData]);

  return (
    <div className="BmiGraph">
      <canvas ref={chartRef}></canvas>
    </div>
  );
};

export default BmiGraph;

BmiGraph.js 파일. 파일명이 일관적이지 않다. 

1. 혼인 상태

import openpyxl
import pandas as pd

# 엑셀 파일 열기
workbook = openpyxl.load_workbook('/Users/username/Desktop/PycharmProjects/hexagonman/datafile/maritalstatus_data.csv')
# 시트 선택
sheet = workbook.active

# 판다스 데이터프레임으로 변환
df = pd.DataFrame(sheet.values)

# 멀티 인덱스가 존재하기 때문에 인덱스를 리셋
df = df.reset_index()

# 피벗 테이블 생성
df = df.pivot_table(index=[0, 1], columns=2, values=4, aggfunc='first')

# 마지막 행 삭제
df = df[:-1]

# 인덱스 초기화 및 열 이름 변경
df = df.reset_index()
df = df.rename(columns={0: '지역', 1: '나이'})

# '항목' 열을 삭제합니다.
df.drop(columns='항목', inplace=True)

df = df[~df['나이'].str.match(r'\d+~\d+세')]

# 결과 출력
workbook.close()

먼저 결혼 자료 관련를 가져와서 필요없는 부분을 제거하였다. 피벗 테이블, 행삭제, 멀티인덱스 리셋 등이 이뤄졌다.

class DbMaritalStatus(Base):
   __tablename__ = 'marital_status_statistics'
   id = Column(Integer, primary_key=True, index=True)
   residential_area = Column(String)
   age = Column(String)
   man_age_total = Column(Integer)
   man_unmarried = Column(Integer)
   man_Married = Column(Integer)
   man_Separation_by_death = Column(Integer)
   man_divorce = Column(Integer)
   woman_age_total = Column(Integer)
   woman_unmarried = Column(Integer)
   woman_Married = Column(Integer)
   woman_Separation_by_death = Column(Integer)
   woman_divorce = Column(Integer)

 db 모델 파일에 모델을 추가하고

alembic revision --autogenerate
alembic upgrade head

db 파일을 업데이트하여 모델링을 반영한다.

import pandas as pd
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from db.models import DbMaritalStatus
from db.database import Base
# your_module은 DbPopulation 클래스를 정의한 파일의 이름입니다.

# 엑셀 파일 읽기 (1행은 건너뛰고 읽음)
excel_file = '/Users/honjun/Desktop/PycharmProjects/hexagonman/datafile/maritalstatus_data_manipulated.xlsx'
df = pd.read_excel(excel_file)

# 데이터베이스에 연결
engine = create_engine('sqlite:///../haxagonMan.db')  # 데이터베이스 URL에 따라 변경해야 할 수 있습니다.

# 데이터베이스 테이블 생성
Base.metadata.create_all(engine)

# 데이터베이스 세션 생성
Session = sessionmaker(bind=engine)
session = Session()

# 데이터프레임 반복하며 데이터베이스에 행 추가
# 데이터프레임 반복하며 데이터베이스에 행 추가
for index, row in df.iterrows():
    # if index == 0:
    #     continue

    # NaN 값을 처리하고 해당 값이 NaN이 아닌 경우에만 정수로 변환하여 데이터베이스에 행 추가
    man_age_total = int(row.iloc[2]) if not pd.isna(row.iloc[2]) else None
    woman_age_total = int(row.iloc[7]) if not pd.isna(row.iloc[7]) else None
    man_unmarried = int(row.iloc[3]) if not pd.isna(row.iloc[3]) else None
    man_Married = int(row.iloc[4]) if not pd.isna(row.iloc[4]) else None
    man_Separation_by_death = int(row.iloc[5]) if not pd.isna(row.iloc[5]) else None
    man_divorce = int(row.iloc[6]) if not pd.isna(row.iloc[6]) else None
    woman_unmarried = int(row.iloc[8]) if not pd.isna(row.iloc[8]) else None
    woman_Married = int(row.iloc[9]) if not pd.isna(row.iloc[9]) else None
    woman_Separation_by_death = int(row.iloc[10]) if not pd.isna(row.iloc[10]) else None
    woman_divorce = int(row.iloc[11]) if not pd.isna(row.iloc[11]) else None

    marital_status_statistics = DbMaritalStatus(
        residential_area=str(row.iloc[0]),
        age=str(row.iloc[1]),
        man_age_total=man_age_total,
        man_unmarried=man_unmarried,
        man_Married=man_Married,
        man_Separation_by_death=man_Separation_by_death,
        man_divorce=man_divorce,
        woman_age_total=woman_age_total,
        woman_unmarried=woman_unmarried,
        woman_Married=woman_Married,
        woman_Separation_by_death=woman_Separation_by_death,
        woman_divorce=woman_divorce
    )
    session.add(marital_status_statistics)

# 변경사항 커밋
session.commit()

# 세션 종료
session.close()

데이터를 DB에 추가한다. None 값을 0으로 처리하였다.

#routers 폴더 - maritalsatus.py
from fastapi import APIRouter, Depends
from sqlalchemy.orm.session import Session
from routers.schemas import MaritalStatusBase, MaritalStatusDisplay
from db.database import get_db
from db import db_marital_status_crud

router = APIRouter(
   prefix='/maritalstatus',
   tags=['maritalstatus']
)

@router.post('', response_model=MaritalStatusDisplay)
def population_questinon(request:MaritalStatusBase, db:Session=Depends(get_db)):
   if request.age_start and request.age_end and request.age_start > request.age_end:
      request.age_start, request.age_end = request.age_end, request.age_start
   return db_marital_status_crud.marital_status_ans(db, request)

새로운 파일을 생성하여 라우터를 추가한다.

나이, 성별, 지역 데이터도 같이 request 받도록 설정하였다.

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from routers import user, population, maritalstatus

app = FastAPI()

app.include_router(user.router)
app.include_router(population.router)
app.include_router(maritalstatus.router)

origins = [
    "http://localhost:3000",
    "http://localhost:8000",
    "http://localhost:8000/maritalstatus",
]

app.add_middleware(
    CORSMiddleware,
    allow_origins=origins,
    allow_methods=["GET", "POST", "PUT", "DELETE"],  # 수정된 부분: 모든 HTTP 메서드를 허용합니다.
    allow_headers=["*"],
)

@app.get('/')
def root():
    return "hello world"

main 파일에서도 라우터를 추가하고 

cors 문제가 발생해서

origins = [
    "http://localhost:3000",
    "http://localhost:8000",
    "http://localhost:8000/maritalstatus",
]

app.add_middleware(
    CORSMiddleware,
    allow_origins=origins,
    allow_methods=["GET", "POST", "PUT", "DELETE"],  # 수정된 부분: 모든 HTTP 메서드를 허용합니다.
    allow_headers=["*"],
)

수정 및 추가하였다.

class MaritalStatusBase(BaseModel):
   maritalstatus: list
   gender: str | None = None
   age_start: int | None = None
   age_end: int | None = None
   location: list | None = None

class MaritalStatusDisplay(BaseModel):
   age_start : int | None = None
   age_end : int | None = None
   total_pop_marital : int | None = None
   target_pop_marital : int | None = None

스카마 파일에서 스키마를 추가하였다.

Base는 입력 받을 때 형식, Display는 사용자에게 전달될 형식이다.

# db폴더 db_marital_status_crud.py
from sqlalchemy.orm.session import Session
from sqlalchemy import func, Integer
from db.models import DbMaritalStatus
from routers.schemas import MaritalStatusBase, MaritalStatusDisplay

def marital_status_ans(db: Session, request: MaritalStatusBase):
    # 성별에 따라 접두사를 설정합니다.
    prefix = "man_" if request.gender == "남" else "woman_"

    # 결혼 상태에 따라 접미사를 설정합니다.
    marital_status_mapping = {
        "미혼": "unmarried",
        "기혼": "Married",
        "사별": "Separation_by_death",
        "이혼": "divorce"
    }

    # 필터링할 나이 범위
    min_age = request.age_start
    max_age = request.age_end

    # 필터링을 통한 쿼리
    filtered_db = db.query(DbMaritalStatus).filter(DbMaritalStatus.age.cast(Integer).between(min_age, max_age))

    # 'residential_area'가 '전국'인 행의 해당 성별의 총합 구하기
    total_pop_marital = filtered_db.filter(DbMaritalStatus.residential_area == '전국').with_entities(
        func.sum(getattr(DbMaritalStatus, prefix + "age_total"))).scalar()

    # 필터링할 지역 값
    target_regions = request.location

    # 지역 필터링을 통한 쿼리
    filtered_db = filtered_db.filter(DbMaritalStatus.residential_area.in_(target_regions))

    # 결혼 상태에 따른 열 선택
    filter_marital_status = [prefix + marital_status_mapping[status] for status in request.maritalstatus]
    columns_to_keep = [getattr(DbMaritalStatus, column_name) for column_name in filter_marital_status]

    # 결과 쿼리 수행
    filtered_results = filtered_db.with_entities(*columns_to_keep).all()

    # 결과 합계 계산
    target_pop_marital = sum(sum(row) for row in filtered_results if row is not None)

    # 결과 반환
    marital_status_result = MaritalStatusDisplay(
        age_start=request.age_start,
        age_end=request.age_end,
        total_pop_marital=total_pop_marital,
        target_pop_marital=target_pop_marital
    )
    return marital_status_result

db crud 파일도 새로 생성하였다.

import React, { useState } from 'react';
import Header from './components/Header/Header';
import AgeInput from './components/UserInput/AgeRange';
import LocationSelector from './components/UserInput/LocationSelector';
import MaritalStatusSelector from './components/UserInput/MaritalStatusSelector';
import WeightStatusSelector from './components/UserInput/WeightStatusSelector';
import AppearanceScore from './components/UserInput/AppearanceScore';
import ReligionSelector from './components/UserInput/ReligionSelector';
import SmokingSelector from './components/UserInput/SmokingSelector';
import DrinkingSelector from './components/UserInput/DrinkingSelector';
import GenderSelector from './components/UserInput/GenderSelector';
import GenderGraph from './components/Graph/GenderGraph';
import AgeRangeGraph from './components/Graph/AgeRangeGraph';
import LocationGraph from './components/Graph/LocationGraph';
import MaritalGraph from './components/Graph/MaritalGraph'; // MaritalGraph 컴포넌트 import 추가
import './App.css';

const App = () => {
  const [ageStart, setAgeStart] = useState(null);
  const [ageEnd, setAgeEnd] = useState(null);
  const [genderGraphData, setGenderGraphData] = useState(null);
  const [ageRangeGraphData, setAgeRangeGraphData] = useState(null);
  const [locationGraphData, setLocationGraphData] = useState(null);
  const [selectedGender, setSelectedGender] = useState('');
  const [maritalGraphData, setMaritalGraphData] = useState(null);

  const handleAgeChange = (start, end) => {
    setAgeStart(start);
    setAgeEnd(end);
  };

  const handleGenderChange = (gender) => {
    setSelectedGender(gender);
  };

  const handleGenderGraphDataChange = (data) => {
    setGenderGraphData(data);
  };

  const handleAgeRangeGraphDataChange = (data) => {
    setAgeRangeGraphData(data);
  };

  const handleLocationGraphDataChange = (data, selectedLocations) => {
    setLocationGraphData({ data, selectedLocations });
  };

  const handleMaritalStatusGraphDataChange = (data) => {
    setMaritalGraphData(data);
  };

  return (
    <div className="app-container">
      <Header />
      <div className="content-container">
        <div className="input-container">
          <GenderSelector
            selectedGender={selectedGender}
            onGenderChange={handleGenderChange}
            onGraphDataChange={handleGenderGraphDataChange}
          />
          <AgeInput
            gender={selectedGender}
            onAgeChange={handleAgeChange}
            onGraphDataChange={handleAgeRangeGraphDataChange}
          />
          <LocationSelector
            ageStart={ageStart}
            ageEnd={ageEnd}
            onGraphDataChange={handleLocationGraphDataChange}
          />
          <MaritalStatusSelector
            gender={selectedGender}
            ageStart={ageStart}
            ageEnd={ageEnd}
            selectedLocations={locationGraphData ? locationGraphData.selectedLocations : []} // 수정된 부분
            onGraphDataChange={handleMaritalStatusGraphDataChange}
          />
          <WeightStatusSelector />
          <AppearanceScore />
          <ReligionSelector />
          <SmokingSelector />
          <DrinkingSelector />
        </div>
        <div className="graph-container">
          {genderGraphData && (
            <GenderGraph responseData={genderGraphData} />
          )}
          {ageRangeGraphData && (
            <AgeRangeGraph responseData={ageRangeGraphData} />
          )}
          {locationGraphData && (
            <LocationGraph
              responseData={locationGraphData.data}
              selectedLocations={locationGraphData.selectedLocations}
            />
          )}
          {maritalGraphData !== null && (
            <MaritalGraph responseData={maritalGraphData} /> // MaritalGraph 컴포넌트 추가
          )}
        </div>
      </div>
    </div>
  );
};

export default App;

app.js 파일

import React, { useState, useEffect } from 'react';
import './MaritalStatusSelector.css';
import withMouseHoverColorChange from './HOC/withMouseHoverColorChange';

const MaritalStatusSelector = ({ gender, ageStart, ageEnd, selectedLocations, onGraphDataChange }) => {
  const [selectedStatus, setSelectedStatus] = useState([]);
  const [prevSelectedStatus, setPrevSelectedStatus] = useState([]);

  const statusOptions = ['미혼', '기혼', '사별', '이혼'];

  const handleStatusClick = (status) => {
    if (selectedStatus.includes(status)) {
      setSelectedStatus(selectedStatus.filter((s) => s !== status));
    } else {
      setSelectedStatus([...selectedStatus, status]);
    }
  };

  useEffect(() => {
    if (selectedStatus.length > 0 && selectedStatus !== prevSelectedStatus) {
      const fetchData = async () => {
        try {
          const data = {
            maritalstatus: selectedStatus,
            gender: gender,
            age_start: ageStart,
            age_end: ageEnd,
            location: selectedLocations,
          };

          const response = await fetch('http://localhost:8000/maritalstatus', {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json',
              accept: 'application/json',
            },
            body: JSON.stringify(data),
          });

          const responseData = await response.json();
          console.log('성공:', responseData);
          onGraphDataChange(responseData);
        } catch (error) {
          console.error('오류:', error);
        }
      };

      fetchData();
      setPrevSelectedStatus(selectedStatus);
    }
  }, [selectedStatus, prevSelectedStatus, gender, ageStart, ageEnd, selectedLocations, onGraphDataChange]);

  return (
    <div className="marital-status-selector">
      <div className="label">혼인 상태</div>
      <div className="buttons">
        {statusOptions.map((status) => (
          <button
            key={status}
            className={selectedStatus.includes(status) ? 'button selected' : 'button'}
            onClick={() => handleStatusClick(status)}
          >
            {status}
            {selectedStatus.includes(status) && <span className="checkmark">&#10003;</span>}
          </button>
        ))}
      </div>
    </div>
  );
};

export default withMouseHoverColorChange(MaritalStatusSelector, '#f0f0f0', '#2196F3');

MaritalStatusSelector.js 콘퍼넌트 파일.

버튼 렌더링과 백엔드와 통신, app.js로 결과를 prop 하는 내용을 담았다.

import React, { useEffect, useRef } from 'react';
import * as d3 from 'd3';

const MaritalGraph = ({ responseData }) => {
  const svgRef = useRef(null);

  useEffect(() => {
    if (responseData) {
      const width = 300;
      const height = 300;
      const radius = Math.min(width, height) / 2;

      const color = d3.scaleOrdinal()
        .range(["#66c2a5", "#fc8d62"]);

      const svg = d3.select(svgRef.current)
        .attr("width", width)
        .attr("height", height)
        .append("g")
        .attr("transform", `translate(${width / 2},${height / 2})`);

      const pie = d3.pie()
        .sort(null)
        .value((d) => d.value);

      const arc = d3.arc()
        .innerRadius(0)
        .outerRadius(radius);

      const data = [
        { label: "Target Population", value: responseData.target_pop_marital },
        { label: "Non-Target Population", value: responseData.total_pop_marital - responseData.target_pop_marital }
      ];

      const arcs = pie(data);

      // 아크 요소 추가
svg.selectAll("path")
.data(arcs)
.enter().append("path")
.attr("fill", (d, i) => color(i))
.attr("d", arc)
.append("title")
.text(d => `${d.data.label}: ${d.data.value}`);

// 그래프 제목 추가
svg.append("text")
.attr("dy", ".35em")
.style("text-anchor", "middle")
.text(`${responseData.age_start}-${responseData.age_end}세 결혼 비율`);

// 각 섹션의 백분율 표시
svg.selectAll("text.percent")
.data(arcs)
.enter().append("text")
.attr("class", "percent")
.attr("transform", d => `translate(${arc.centroid(d)})`)
.attr("dy", ".35em")
.style("text-anchor", "middle")
.text(d => `${((d.endAngle - d.startAngle) / (2 * Math.PI) * 100).toFixed(2)}%`);
    }
  }, [responseData]);

  return (
    <svg ref={svgRef}></svg>
  );
};

export default MaritalGraph;

받아온 데이터를 기반으로 그래프를 그리는 코드이다.

워낙 정신없이 코드를 고치는 바람에 많은 코드히스토리를 잃어버리고 말았다. 그래서 정리해본다.

현재 src 폴더에서 리액트로 작성한 파일들이다.

 

CSS파일들은 중요도가 낮기 때문에 생략한다.

0. 현재 진행상황

실제 구현 모습을 gif로 만들어봤다.

작은 문제들이 있지만 어느정도 모양이 잡혔다.

1. App.js 파일

// React와 useState 훅을 import합니다.
import React, { useState } from 'react';

// 컴포넌트들을 import합니다.
import Header from './components/Header/Header';
import AgeInput from './components/UserInput/AgeRange';
import LocationSelector from './components/UserInput/LocationSelector';
import MaritalStatusSelector from './components/UserInput/MaritalStatusSelector';
import WeightStatusSelector from './components/UserInput/WeightStatusSelector';
import AppearanceScore from './components/UserInput/AppearanceScore';
import ReligionSelector from './components/UserInput/ReligionSelector';
import SmokingSelector from './components/UserInput/SmokingSelector';
import DrinkingSelector from './components/UserInput/DrinkingSelector';
import GenderSelector from './components/UserInput/GenderSelector';
import GenderGraph from './components/Graph/GenderGraph';
import AgeRangeGraph from './components/Graph/AgeRangeGraph';
import LocationGraph from './components/Graph/LocationGraph';

// CSS 파일을 import합니다.
import './App.css';

// App 컴포넌트를 정의합니다.
const App = () => {
  // 각각의 상태를 useState 훅을 사용하여 정의합니다.
  const [ageStart, setAgeStart] = useState(null);
  const [ageEnd, setAgeEnd] = useState(null);
  const [genderGraphData, setGenderGraphData] = useState(null);
  const [ageRangeGraphData, setAgeRangeGraphData] = useState(null);
  const [locationGraphData, setLocationGraphData] = useState(null);
  const [selectedGender, setSelectedGender] = useState('');

  // 나이 변경 이벤트를 처리하는 함수입니다.
  const handleAgeChange = (start, end) => {
    setAgeStart(start);
    setAgeEnd(end);
  };

  // 성별 그래프 데이터 변경 이벤트를 처리하는 함수입니다.
  const handleGenderGraphDataChange = (data) => {
    setGenderGraphData(data);
  };

  // 나이 범위 그래프 데이터 변경 이벤트를 처리하는 함수입니다.
  const handleAgeRangeGraphDataChange = (data) => {
    setAgeRangeGraphData(data);
  };

  // 위치 그래프 데이터 변경 이벤트를 처리하는 함수입니다.
  const handleLocationGraphDataChange = (data, selectedLocations) => {
    setLocationGraphData({ data, selectedLocations });
  };

  // 성별 변경 이벤트를 처리하는 함수입니다.
  const handleGenderChange = (gender) => {
    setSelectedGender(gender);
  };

  // JSX를 반환합니다.
  return (
    <div className="app-container">
      <Header />
      <div className="content-container">
        <div className="input-container">
          {/* 성별 선택 컴포넌트 */}
          <GenderSelector
            selectedGender={selectedGender}
            onGenderChange={handleGenderChange}
            onGraphDataChange={handleGenderGraphDataChange}
          />
          {/* 나이 입력 컴포넌트 */}
          <AgeInput
            gender={selectedGender}
            onAgeChange={handleAgeChange}
            onGraphDataChange={handleAgeRangeGraphDataChange}
          />
          {/* 위치 선택 컴포넌트 */}
          <LocationSelector
            ageStart={ageStart}
            ageEnd={ageEnd}
            onGraphDataChange={handleLocationGraphDataChange} // LocationSelector에서 전달
          />
          {/* 기혼 여부 선택 컴포넌트 */}
          <MaritalStatusSelector />
          {/* 체중 상태 선택 컴포넌트 */}
          <WeightStatusSelector />
          {/* 외모 점수 입력 컴포넌트 */}
          <AppearanceScore />
          {/* 종교 선택 컴포넌트 */}
          <ReligionSelector />
          {/* 흡연 여부 선택 컴포넌트 */}
          <SmokingSelector />
          {/* 음주 여부 선택 컴포넌트 */}
          <DrinkingSelector />
        </div>
        <div className="graph-container">
          {/* 성별 그래프 */}
          {genderGraphData && (
            <GenderGraph responseData={genderGraphData} />
          )}
          {/* 나이 범위 그래프 */}
          {ageRangeGraphData && (
            <AgeRangeGraph responseData={ageRangeGraphData} />
          )}
          {/* 위치 그래프 */}
          {locationGraphData && (
            <LocationGraph
              responseData={locationGraphData.data}
              selectedLocations={locationGraphData.selectedLocations}
            />
          )}
        </div>
      </div>
    </div>
  );
};

// App 컴포넌트를 내보냅니다.
export default App;

GenderSelector, AgeInput, LocationSelector
3개의 컴퍼넌트에서 성별, 나이, 지역 데이터를 각각의 변수에 담아서 다른 컴퍼넌트와 함께 쓸수 있도록 했다.

Redux를 이용하는것이 코드가 깔끔해진다고 하는데 다음에 구현하기로 한다.

 

2. AgeRange.js

import React, { useState } from 'react';
import TextField from '@mui/material/TextField';
import withMouseHoverColorChange from './HOC/withMouseHoverColorChange'; // 마우스 호버 시 색상 변경 HOC
import './AgeRange.css';

// 나이 입력 컴포넌트 정의
const AgeInput = ({ gender, onAgeChange, onGraphDataChange }) => {
  // 상태 변수 선언
  const [ageStart, setageStart] = useState(''); // 시작 나이
  const [ageEnd, setageEnd] = useState(''); // 끝 나이
  const [populationData, setPopulationData] = useState(null); // 인구 데이터

  // 시작 나이 변경 핸들러
  const handleageStartChange = (e) => {
    const inputValue = e.target.value;
    // 입력값이 숫자 2자리 또는 빈 문자열인 경우에만 값을 변경
    if (/^\d{0,2}$/.test(inputValue)) {
      setageStart(inputValue);
      // 부모 컴포넌트로 변경된 나이 전달
      onAgeChange(inputValue, ageEnd);
    }
  };
  
  // 끝 나이 변경 핸들러
  const handleageEndChange = (e) => {
    const inputValue = e.target.value;
    // 입력값이 숫자 2자리 또는 빈 문자열인 경우에만 값을 변경
    if (/^\d{0,2}$/.test(inputValue)) {
      setageEnd(inputValue);
      // 부모 컴포넌트로 변경된 나이 전달
      onAgeChange(ageStart, inputValue);
    }
  };
  
  // 키 입력 이벤트 핸들러
  const handleKeyDown = async (e) => {
    // Tab 키 또는 Enter 키가 입력되면 데이터 업데이트
    if (e.key === 'Enter' || e.key === 'Tab') {
      await fetchData();
    }
  };

  // 데이터 가져오기 함수
  const fetchData = async () => {
    // 시작 나이와 끝 나이가 유효한 숫자일 경우에만 실행
    if ((ageStart !== '' && !isNaN(parseInt(ageStart))) && (ageEnd !== '' && !isNaN(parseInt(ageEnd)))) {
      try {
        // 서버로 데이터 요청
        const response = await fetch('http://localhost:8000/population', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            'accept': 'application/json'
          },
          body: JSON.stringify({
            gender: '남', // Gender 정보는 Redux store에 저장되어 있지 않으므로 하드코딩되어 있습니다. 필요에 따라 수정하세요.
            age_start: parseInt(ageStart),
            age_end: parseInt(ageEnd)
          })
        });
        // 응답이 성공적으로 받아지면
        if (!response.ok) {
          throw new Error('Failed to fetch data');
        }
        // JSON 데이터로 변환
        const data = await response.json();
        // 인구 데이터 상태 업데이트
        setPopulationData(data);
        // 그래프 데이터 변경 핸들러 호출
        onGraphDataChange(data); 
      } catch (error) {
        console.error('Error fetching data:', error);
      }
    }
  };

  // JSX 반환
  return (
    <div className="age-input">
      <div className="age-label">결혼 적정 나이 연령을 입력해주세요</div>
      <TextField
        id="ageStartInput"
        label="시작(1~99)"
        variant="outlined"
        value={ageStart}
        onChange={handleageStartChange}
        onKeyDown={handleKeyDown}
        inputProps={{ inputMode: 'numeric', pattern: '[0-9]*' }}
      />
      <TextField
        id="ageEndInput"
        label="끝(1~99)"
        variant="outlined"
        value={ageEnd}
        onChange={handleageEndChange}
        onKeyDown={handleKeyDown}
        inputProps={{ inputMode: 'numeric', pattern: '[0-9]*' }}
      />
      {populationData && (
        <div>
          {/* Display fetched data */}
          {/* Modify this section according to how you want to display the fetched data */}
          <pre>{JSON.stringify(populationData, null, 2)}</pre>
        </div>
      )}
    </div>
  );
};

// 마우스 호버 시 색상 변경 HOC 적용
export default withMouseHoverColorChange(AgeInput, '#f0f0f0', '#2196F3');

나이 입력 값이 tab이나 enter 키 입력이 들어오면 상태를 업데이트 되도록하였다.

입력값은 공백 또는 숫자 두자리 제한하였다.

3. GenderSelector.js

import React, { useState } from 'react';
import './GenderSelector.css';
import withMouseHoverColorChange from './HOC/withMouseHoverColorChange';

// 성별 선택 컴포넌트 정의
const GenderSelector = ({ onGraphDataChange }) => {
  // 상태 변수 선언
  const [selectedGender, setSelectedGender] = useState(''); // 선택된 성별
  const [responseData, setResponseData] = useState(null); // 서버 응답 데이터
  const [isFetching, setIsFetching] = useState(false); // 데이터 가져오는 중 여부

  // 성별 변경 핸들러
  const handleGenderChange = async (gender) => {
    // 데이터 가져오는 중인 경우 중복 요청 방지
    if (isFetching) return;

    // 데이터 요청 중임을 표시
    setIsFetching(true);

    try {
      // 서버로 데이터 요청
      const response = await fetch('http://localhost:8000/population', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'accept': 'application/json'
        },
        body: JSON.stringify({
          gender: gender
        })
      });

      // 응답이 성공적인 경우
      if (!response.ok) {
        throw new Error('Network response was not ok');
      }

      // JSON으로 파싱된 데이터를 상태에 저장
      const data = await response.json();
      setResponseData(data);
      // 그래프 데이터 변경 핸들러 호출하여 새로운 데이터 전달
      onGraphDataChange(data); 
    } catch (error) {
      // 오류 처리
      console.error('There was a problem with the fetch operation:', error);
    } finally {
      // 데이터 요청 완료 후 상태 업데이트
      setIsFetching(false);
    }

    // 선택된 성별 상태 업데이트
    setSelectedGender(gender);
  };

  // JSX 반환
  return (
    <div className="gender-selector">
      <div className="label">성별</div>
      <div className="checkboxes">
        {/* 남성 버튼 */}
        <button
          disabled={isFetching} // 데이터 요청 중에는 버튼 비활성화
          className={selectedGender === '남' ? 'selected' : ''} // 선택된 성별에 따라 클래스 적용
          onClick={() => handleGenderChange('남')} // 클릭 시 해당 성별로 변경 요청
        >
          남성
          {selectedGender === '남' && <span className="checkmark">&#10003;</span>} {/* 선택 표시 */}
        </button>
        {/* 여성 버튼 */}
        <button
          disabled={isFetching} // 데이터 요청 중에는 버튼 비활성화
          className={selectedGender === '여' ? 'selected' : ''} // 선택된 성별에 따라 클래스 적용
          onClick={() => handleGenderChange('여')} // 클릭 시 해당 성별로 변경 요청
        >
          여성
          {selectedGender === '여' && <span className="checkmark">&#10003;</span>} {/* 선택 표시 */}
        </button>
      </div>
      {/* Graph 컴포넌트를 직접 렌더링하지 않고 데이터만 전달 */}
    </div>
  );
};

// 마우스 호버 시 색상 변경 HOC 적용
export default withMouseHoverColorChange(GenderSelector, '#f0f0f0', '#E3D3E4');

남자, 여자 값을 선택하여 저장.

백엔드에서 데이터값을 받아온다.

3. LocationSelector

import React, { useState } from 'react';
import './LocationSelector.css';
import withMouseHoverColorChange from './HOC/withMouseHoverColorChange';

// 위치 선택 컴포넌트 정의
const LocationSelector = ({ ageStart, ageEnd, onGraphDataChange }) => {
  // 선택된 위치들을 저장하는 상태 변수
  const [selectedLocations, setSelectedLocations] = useState([]);

  // 전체 지역 목록
  const locations = [
    { name: '전국', value: 'total_population' },
    { name: '서울특별시', value: 'seoul' },
    { name: '부산광역시', value: 'busan' },
    { name: '대구광역시', value: 'daegu' },
    { name: '인천광역시', value: 'incheon' },
    { name: '대전광역시', value: 'daejeon' },
    { name: '울산광역시', value: 'ulsan' },
    { name: '세종특별자치시', value: 'sejong' },
    { name: '경기도', value: 'gyeonggi' },
    { name: '강원특별자치도', value: 'gangwon' },
    { name: '충청북도', value: 'chungcheongbuk' },
    { name: '충청남도', value: 'chungcheongnam' },
    { name: '전라북도', value: 'jeollabuk' },
    { name: '전라남도', value: 'jeollanam' },
    { name: '경상북도', value: 'gyeongsangbuk' },
    { name: '경상남도', value: 'gyeongsangnam' },
    { name: '제주특별자치도', value: 'jeju' }
  ];

  // 위치를 클릭할 때 실행되는 함수
  const handleLocationClick = (location) => {
    const updatedSelectedLocations = [...selectedLocations];

    if (location === '전국') {
      // 전국을 선택한 경우 다른 모든 위치를 선택 해제
      setSelectedLocations(['전국']);
    } else {
      // 전국을 선택하지 않은 경우에만 선택 상태 변경
      if (updatedSelectedLocations.includes(location)) {
        // 이미 선택된 위치일 경우 선택 해제
        updatedSelectedLocations.splice(updatedSelectedLocations.indexOf(location), 1);
      } else {
        // 선택되지 않은 경우 선택 추가
        const indexOfTotal = updatedSelectedLocations.indexOf('전국');
        if (indexOfTotal !== -1) {
          // 전국이 선택되어 있는 경우 전국 선택 해제 후 선택 추가
          updatedSelectedLocations.splice(indexOfTotal, 1);
        }
        updatedSelectedLocations.push(location);
      }
      setSelectedLocations(updatedSelectedLocations);
    }

    // 새로운 도시 선택이 있을 때마다 인구 데이터 가져오기
    fetchPopulationData(updatedSelectedLocations);
  };

  // 위치의 인구 데이터를 가져오는 함수
  const fetchPopulationData = (selectedLocations) => {
    const requestBody = {
      gender: '남', // 성별은 상수로 설정
      age_start: ageStart !== null ? ageStart : null, // ageStart 값이 null이 아닌 경우에만 사용
      age_end: ageEnd !== null ? ageEnd : null, // ageEnd 값이 null이 아닌 경우에만 사용
      location: selectedLocations.map(location => {
        const selectedLocation = locations.find(loc => loc.name === location);
        return selectedLocation ? selectedLocation.value : null;
      }).filter(Boolean) // 필터링하여 null 제거
    };
    
    fetch('http://localhost:8000/population', {
      method: 'POST',
      headers: {
        'Accept': 'application/json',
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(requestBody)
    })
    .then(response => response.json())
    .then(data => {
      // 선택된 위치와 함께 데이터 콜백 호출
      onGraphDataChange(data, selectedLocations);
    })
    .catch(error => {
      console.error('인구 데이터 가져오기 오류:', error);
      // 오류 처리, 사용자에게 메시지 표시 등
    });
  };

  // JSX 반환
  return (
    <div className="location-selector">
      <div className="label">지역</div>
      <div className="buttons">
        {/* 모든 위치에 대한 버튼 생성 */}
        {locations.map(location => (
          <button
            key={location.name}
            className={selectedLocations.includes(location.name) ? 'button selected' : 'button'}
            onClick={() => handleLocationClick(location.name)}
          >
            {location.name}
            {/* 선택된 위치에는 체크 마크 표시 */}
            {selectedLocations.includes(location.name) && <span className="checkmark">&#10003;</span>}
          </button>
        ))}
      </div>
      {/* 인구 데이터가 있으면 표시 */}
      {onGraphDataChange && (
        <div className="onGraphDataChange">
          {/* 여기에 인구 데이터를 렌더링 */}
          {JSON.stringify(onGraphDataChange)}
        </div>
      )}
    </div>
  );
};

// 마우스 호버 색상 변경 HOC 적용
export default withMouseHoverColorChange(LocationSelector, '#f0f0f0', '#2196F3');

지역을 복수 선택가능하게하고 전국을 선택하면 나머지는 선택해제된다.

한글 지역을 선택하지만 데이터를 전송할때는 영어로 변환해서 보낸다.

위의 코드들과 달리 인구데이터 결과가 컨퍼넌트 내에 출력하였다. (나중에 삭제할 예정이다)

4. GenderGraph.js

import React, { useEffect, useRef } from 'react';
import Chart from 'chart.js/auto'; // Chart.js 최신 버전을 사용하기 위한 import

// 연령대별 인구 비율을 표시하는 그래프 컴포넌트
const AgeRangeGraph = ({ responseData }) => {
  const chartRef = useRef(null); // 차트를 그릴 캔버스 요소에 대한 참조
  const chartInstance = useRef(null); // 차트 인스턴스에 대한 참조

  useEffect(() => {
    if (responseData) {
      const { man_age_range_population, woman_age_range_population, total_population_in_range } = responseData;

      const ctx = chartRef.current.getContext('2d'); // 캔버스 요소의 2D 렌더링 컨텍스트 가져오기

      // 이전 차트 파괴
      if (chartInstance.current !== null) {
        chartInstance.current.destroy();
      }

      // 새로운 차트 생성
      chartInstance.current = new Chart(ctx, {
        type: 'pie', // 파이 차트 유형 설정
        data: {
          labels: ['남성', '여성'], // 차트 라벨 설정
          datasets: [{
            label: '인구 비율',
            data: [man_age_range_population / total_population_in_range * 100, woman_age_range_population / total_population_in_range * 100], // 데이터 설정
            backgroundColor: [
              'rgba(255, 99, 132, 0.6)', // 남성 색상
              'rgba(54, 162, 235, 0.6)', // 여성 색상
            ],
            borderColor: [
              'rgba(255, 99, 132, 1)',
              'rgba(54, 162, 235, 1)',
            ],
            borderWidth: 1
          }]
        },
        options: {
          responsive: true, // 반응형 설정
          maintainAspectRatio: true, // 그래프 크기를 부모 요소에 맞게 조절
          plugins: {
            title: {
              display: true,
              text: '연령대별 인구 비율' // 그래프 제목
            },
            tooltip: {
              callbacks: {
                // 툴팁 라벨 설정
                label: function(context) {
                  let label = context.label || '';
                  if (label) {
                    label += ': ';
                  }
                  label += Math.round(context.raw) + '%';
                  return label;
                }
              }
            }
          }
        }
      });
    }
  }, [responseData]); // responseData가 변경될 때마다 useEffect 실행

  // JSX 반환
  return (
    <div className="graph">
      <canvas ref={chartRef}></canvas> {/* 차트를 그릴 캔버스 요소 */}
      {responseData && (
        <div style={{ textAlign: 'center', marginTop: '10px' }}>
          {/* 데이터 출력 */}
          <p>총 인구 {responseData.total_population_in_range.toLocaleString()}명 중에</p>
          <p>남성 인구는 {responseData.man_age_range_population.toLocaleString()}명, {parseFloat((responseData.man_age_range_population / responseData.total_population_in_range * 100).toFixed(2))}% 입니다</p>
          <p>여성 인구는 {responseData.woman_age_range_population.toLocaleString()}명, {parseFloat((responseData.woman_age_range_population / responseData.total_population_in_range * 100).toFixed(2))}% 입니다.</p>
        </div>
      )}
    </div>
  );
};

export default AgeRangeGraph;

성별 입력 데이터를 실시간으로 추적하여 데이터 값에 따라 그래프를 변경하여 렌더링한다.

실제로는 성별 선택에 무관하게 모든 데이터를 가져오기 때문에 변경되는 점은 없다.

 

5. AgeRangeGraph.js

import React, { useEffect, useRef } from 'react';
import Chart from 'chart.js/auto'; // Chart.js 최신 버전을 사용하기 위한 import

// 성별 인구 비율을 보여주는 그래프 컴포넌트
const GenderGraph = ({ responseData }) => {
  const chartRef = useRef(null); // 차트를 그릴 캔버스 요소에 대한 참조
  const chartInstance = useRef(null); // 차트 인스턴스에 대한 참조

  useEffect(() => {
    if (responseData) {
      const { man_gender_population, woman_gender_population, total_population } = responseData;

      const ctx = chartRef.current.getContext('2d'); // 캔버스 요소의 2D 렌더링 컨텍스트 가져오기

      // 이전 차트 파괴
      if (chartInstance.current !== null) {
        chartInstance.current.destroy();
      }

      // 새로운 차트 생성
      chartInstance.current = new Chart(ctx, {
        type: 'pie', // 파이 차트 유형 설정
        data: {
          labels: ['남성', '여성'], // 차트 라벨 설정
          datasets: [{
            label: '인구 비율',
            data: [man_gender_population / total_population * 100, woman_gender_population / total_population * 100], // 데이터 설정
            backgroundColor: [
              'rgba(255, 99, 132, 0.6)', // 남성 색상
              'rgba(54, 162, 235, 0.6)', // 여성 색상
            ],
            borderColor: [
              'rgba(255, 99, 132, 1)',
              'rgba(54, 162, 235, 1)',
            ],
            borderWidth: 1
          }]
        },
        options: {
          responsive: true, // 반응형 설정
          maintainAspectRatio: true, // 그래프 크기를 부모 요소에 맞게 조절
          plugins: {
            title: {
              display: true,
              text: '성별 인구 비율' // 그래프 제목
            },
            tooltip: {
              callbacks: {
                // 툴팁 라벨 설정
                label: function(context) {
                  let label = context.label || '';
                  if (label) {
                    label += ': ';
                  }
                  label += Math.round(context.raw) + '%';
                  return label;
                }
              }
            }
          }
        }
      });
    }
  }, [responseData]); // responseData가 변경될 때마다 useEffect 실행

  // JSX 반환
  return (
    <div className="GenderGraph">
      <canvas ref={chartRef}></canvas> {/* 차트를 그릴 캔버스 요소 */}
      {responseData && (
        <div style={{ textAlign: 'center', marginTop: '10px' }}>
          {/* 데이터 출력 */}
          <p>전체 인구 {responseData.total_population.toLocaleString()}명 중에</p>
          <p>남성 인구는 {responseData.man_gender_population.toLocaleString()}명, {parseFloat((responseData.man_gender_population / responseData.total_population * 100).toFixed(2))}% 입니다</p>
          <p>여성 인구는 {responseData.woman_gender_population.toLocaleString()}명, {parseFloat((responseData.woman_gender_population / responseData.total_population * 100).toFixed(2))}% 입니다.</p>
        </div>
      )}
    </div>
  );
};

export default GenderGraph;
// 코드

나이 입력 값을 계속 관찰하고 추적하여 업데이트한다. 값이 변경되면 그래프를 새로 렌더링한다. 기존 그래프는 제거한다.

 

6. LocationGraph.js

import React from 'react';
import { Bar } from 'react-chartjs-2'; // Bar 차트를 사용하기 위한 라이브러리 임포트

// 지역별 인구 그래프 컴포넌트 정의
const LocationGraph = ({ responseData, selectedLocations }) => {
  // responseData와 selectedLocations가 null인 경우를 처리합니다.
  if (!responseData || !selectedLocations) {
    return <div>No data available</div>; // 데이터가 없는 경우 메시지 출력
  }

  // 서버 응답 데이터에서 남성과 여성 인구 데이터 추출
  const { man_population_by_all_region, woman_population_by_all_region, man_population_by_region, woman_population_by_region } = responseData;

  // 지역 정보 배열
	const locations = [
        { value: '전국', name: 'total_population' },
        { value: '서울특별시', name: 'seoul' },
        { value: '부산광역시', name: 'busan' },
        { value: '대구광역시', name: 'daegu' },
        { value: '인천광역시', name: 'incheon' },
        { value: '광주광역시', name: "gwangju" },
        { value: '대전광역시', name: 'daejeon' },
        { value: '울산광역시', name: 'ulsan' },
        { value: '세종특별자치시', name: 'sejong' },
        { value: '경기도', name: 'gyeonggi' },
        { value: '강원특별자치도', name: 'gangwon' },
        { value: '충청북도', name: 'chungcheongbuk' },
        { value: '충청남도', name: 'chungcheongnam' },
        { value: '전라북도', name: 'jeollabuk' },
        { value: '전라남도', name: 'jeollanam' },
        { value: '경상북도', name: 'gyeongsangbuk' },
        { value: '경상남도', name: 'gyeongsangnam' },
        { value: '제주특별자치도', name: 'jeju' }
  	];

  // 그래프의 라벨을 설정하기 위해 지역 정보 배열을 사용합니다.
  const labels = locations.map(location => location.value);

  // 남성과 여성 데이터 배열 생성
  const manData = man_population_by_all_region ? Object.values(man_population_by_all_region) : [];
  const womanData = woman_population_by_all_region ? Object.values(woman_population_by_all_region) : [];

  // 선택한 지역이 2 이상인 경우, 그래프 두 번째 위치에 데이터를 추가합니다.
  if (selectedLocations.length >= 2) {
    labels.splice(1, 0, '선택한 도시');
    manData.splice(1, 0, man_population_by_region);
    womanData.splice(1, 0, woman_population_by_region);
  }

  // '전국' 데이터의 값을 0으로 설정하여 해당 데이터가 그래프에 표시되지 않도록 처리합니다.
  if (labels.length > 0) {
    manData[0] = 0;
    womanData[0] = 0;
  }

  // 막대 그래프에 사용될 데이터 설정
  const data = {
    labels: labels, // 그래프의 x축 라벨
    datasets: [
      {
        label: '남성 인구', // 데이터셋 레이블
        backgroundColor: 'rgba(54, 162, 235, 0.5)', // 막대 색상
        borderColor: 'rgba(54, 162, 235, 1)', // 막대 테두리 색상
        borderWidth: 1, // 테두리 두께
        hoverBackgroundColor: 'rgba(54, 162, 235, 0.7)', // 호버 시 막대 색상
        hoverBorderColor: 'rgba(54, 162, 235, 1)', // 호버 시 테두리 색상
        data: manData // 남성 데이터 배열
      },
      {
        label: '여성 인구', // 데이터셋 레이블
        backgroundColor: 'rgba(255, 99, 132, 0.5)', // 막대 색상
        borderColor: 'rgba(255, 99, 132, 1)', // 막대 테두리 색상
        borderWidth: 1, // 테두리 두께
        hoverBackgroundColor: 'rgba(255, 99, 132, 0.7)', // 호버 시 막대 색상
        hoverBorderColor: 'rgba(255, 99, 132, 1)', // 호버 시 테두리 색상
        data: womanData // 여성 데이터 배열
      }
    ]
  };

  // 선택한 도시 라벨의 인덱스 찾기
  const selectedCityIndex = labels.indexOf('선택한 도시');

  // 막대 그래프 옵션 설정
  const options = {
    maintainAspectRatio: true, // 그래프 비율 유지
    scales: {
      xAxes: [{
        ticks: {
          callback: (value, index, values) => {
            if (index === selectedCityIndex) {
              return {
                text: value,
                fontStyle: 'bold', // 선택한 도시 라벨에 강조 효과 적용
                fontSize: 14 // 폰트 크기 증가
              };
            }
            return value;
          }
        }
      }]
    }
  };

  // 막대 그래프 컴포넌트 반환
  return (
    <div style={{ width: '100%', height: '100%' }}>
      <h2>지역별 인구 그래프</h2> {/* 그래프 제목 */}
      <div style={{ width: '100%', height: '100%' }}>
        <Bar
          data={data} // 데이터 전달
          options={options} // 옵션 전달
        />
      </div>
    </div>
  );
};

export default LocationGraph; // LocationGraph 컴포넌트 내보내기
// 코드

bar 그래프를 사용한다.

이번에는 영어 name을 한글로 다시 변환하여 bar 그래프의 라벨로 사용할수 있게 한다.

아직 문제점이 두가지 있는데

전국을 선택할시 그래프 업데이트가 이뤄지지 않는다.

전국 열을 삭제하여 그래프를 렌더링하고 싶은데 삭제하면 예상치 못한 문제가 발생한다.

1. 프론트엔드의 UI에 맞게 백엔드 수정하기

프로젝트를 진행하면서 프론트엔드를 아주 계획적이면서 튼튼하고 확고하게 짰다면 백엔드 작업이 한층 수월해졌을거라는 경험이 생겼다.

 

그 이전에 기획 단계가 구체적이어야 프론트엔드 또는 백엔드가 믿음을 갖고 일을 진행할수 있다는 것이다.

 

나는 첫 웹앱 작성을 하는 아마추어이기 때문에 많은 시행착오가 필요했다.

 

이제 어느정도 프론트엔드 UI가 갖춰졌고 어떤 데이터를 사용자로 부터 받아올지 예측이 되기 때문에 다시 백엔드 작업을 이어서 하기로 했다.

 

2. 코드 작성

데이터를 어떻게 주고 받을지 잠시 고민했다. 함수를 쪼개서 해야할지 통합을 해야될지 시도를 하루 정도 해봤는데, 쪼갤수록 코드가 중복되고 내가 원하는 결과를 뽑아내는데 더 복잡한 코드를 요구했기 때문에 통계 자료 하나당 하나의 함수를 사용하기로 결정하였다.

전체코드

더보기
# db 폴더 - db_population_crud.py
from collections import defaultdict
from sqlalchemy.orm.session import Session
from sqlalchemy import func, and_
from db.models import DbPopulation
from routers.schemas import PopulationQuestion, PopulationDisplay


def population_ans(db: Session, request: PopulationQuestion):
    # 성별 및 지역별 인구 쿼리 준비
    filtered_population = db.query(DbPopulation)
    filtered_man = filtered_population.filter(DbPopulation.gender == '남')
    filtered_woman = filtered_population.filter(DbPopulation.gender == '여')

    # 남성과 여성의 총 인구 계산
    total_population = filtered_population.with_entities(func.sum(DbPopulation.total_population)).scalar()
    man_population = filtered_man.with_entities(func.sum(DbPopulation.total_population)).scalar()
    woman_population = filtered_woman.with_entities(func.sum(DbPopulation.total_population)).scalar()

    # 성별과 연령대에 따른 인구수 계산
    man_population_in_range = None
    woman_population_in_range = None

    if request.age_start is not None and request.age_end is not None:
        man_population_in_range = filtered_man.filter(
            and_(
                DbPopulation.age >= request.age_start,
                DbPopulation.age <= request.age_end
            )
        ).with_entities(func.sum(DbPopulation.total_population)).scalar()

        woman_population_in_range = filtered_woman.filter(
            and_(
                DbPopulation.age >= request.age_start,
                DbPopulation.age <= request.age_end
            )
        ).with_entities(func.sum(DbPopulation.total_population)).scalar()
    else:
        # todo 연령조건이 없으면 위에서 계산한 man_population, woman_population 값과 같기 때문에
        # If age_start or age_end is None, include all ages
        man_population_in_range = filtered_man.with_entities(func.sum(DbPopulation.total_population)).scalar()
        woman_population_in_range = filtered_woman.with_entities(func.sum(DbPopulation.total_population)).scalar()

    # 특정 지역 인구 총합 계산
    man_by_all_region = defaultdict(int)
    woman_by_all_region = defaultdict(int)

    man_by_region, woman_by_region = 0, 0
    if request.location:
        if request.age_start is not None and request.age_end is not None:
            filtered_man = filtered_man.filter(
                and_(
                    DbPopulation.age >= request.age_start,
                    DbPopulation.age <= request.age_end
                )
            )
            filtered_woman = filtered_woman.filter(
                and_(
                    DbPopulation.age >= request.age_start,
                    DbPopulation.age <= request.age_end
                )
            )
        # DB 지역 칼럼 이름 가져오기
        region_columns = [column.key for column in DbPopulation.__table__.columns \
                          if column.key not in ['id', 'gender', 'age']]

        # 남, 여 지역별 총합구하기
        for region in region_columns:
            total_man_region = filtered_man.with_entities(func.sum(getattr(DbPopulation, region.lower()))).scalar()
            man_by_all_region[region] = total_man_region

            total_woman_region = filtered_woman.with_entities(func.sum(getattr(DbPopulation, region.lower()))).scalar()
            woman_by_all_region[region] = total_woman_region

        # 유저가 선택한 지역의 총합.
        region_list = [location for location in request.location]
        if '전국' in region_list:
            man_by_region = man_by_all_region[total_population]
            woman_by_region = woman_by_all_region[total_population]
        else:
            for region in region_list:
                man_by_region += man_by_all_region[region]
                woman_by_region += woman_by_all_region[region]

    # PopulationDisplay 객체 생성
    population_res = PopulationDisplay(
        gender=request.gender,
        man_gender_population=man_population,
        woman_gender_population=woman_population,
        total_population=total_population,
        man_age_range_population=man_population_in_range,
        woman_age_range_population=woman_population_in_range,
        total_population_in_range=man_population_in_range + woman_population_in_range
        if man_population_in_range is not None and woman_population_in_range is not None
        else None,
        man_population_by_all_region=dict(man_by_all_region),
        woman_population_by_all_region=dict(woman_by_all_region),
        man_population_by_region=man_by_region,
        woman_population_by_region=woman_by_region
    )

    return population_res
# routers 폴더 - schemas.py
from pydantic import BaseModel
from datetime import datetime
from pydantic import Field
from typing import Union, Dict

class UserBase(BaseModel):
   username: str
   password: str

# todo 암호관련 작업 필요하다.

class UserDisplay(BaseModel):
   username: str
   class Config():
       from_attributes = True


class CommentBase(BaseModel):
   text: str
   username: str
   timestamp: datetime

class PopulationQuestion(BaseModel):
   gender: str
   age_start: int | None = None
   age_end: int | None = None
   location: list | None = None

class PopulationDisplay(BaseModel):
   gender: str | None = None
   man_gender_population: int | None = None
   woman_gender_population: int | None = None
   total_population: int | None = None
   man_age_range_population: int | None = None
   woman_age_range_population: int | None = None
   total_population_in_range: int | None = None
   man_population_by_region: int | None = None
   woman_population_by_region: int | None = None
   man_population_by_all_region: Dict[str, int] | None = None  # 수정된 부분
   woman_population_by_all_region: Dict[str, int] | None = None  # 수정된 부분
# main.py
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from routers import user, population

app = FastAPI()

app.include_router(user.router)
app.include_router(population.router)

origins = [
    "http://localhost:3000",
    "http://localhost:8000",
]

app.add_middleware(
    CORSMiddleware,
    allow_origins=origins,
    allow_methods=["*"],
    allow_headers=["*"],
)

@app.get('/')
def root():
    return "hello world"
# PopulationDisplay 객체 생성
population_res = PopulationDisplay(
    gender=request.gender,
    man_gender_population=man_population,
    woman_gender_population=woman_population,
    total_population=total_population,
    man_age_range_population=man_population_in_range,
    woman_age_range_population=woman_population_in_range,
    total_population_in_range=man_population_in_range + woman_population_in_range
    if man_population_in_range is not None and woman_population_in_range is not None
    else None,
    man_population_by_all_region=dict(man_by_all_region),
    woman_population_by_all_region=dict(woman_by_all_region),
    man_population_by_region=man_by_region,
    woman_population_by_region=woman_by_region
)

return population_res

먼저 결과를 반환할 객체이다.

프론트엔드에서 그래프를 그려야하기 때문에 관련 데이터를 고민해봤다.

남, 여 인구 비율 : 남자 인구, 여자 인구, 전체인구

연령으로 필터링한 남녀 인구 : (연령으로 필터링 된) 남자 인구, 여자 인구, 전체 인구

지역으로 필터링한 남녀 인구 : (지역으로 필터링 된) 남자 인구, 여자 인구, 전체 인구 + 각 지역의 남자, 여자인구

class PopulationDisplay(BaseModel):
   gender: str | None = None
   man_gender_population: int | None = None
   woman_gender_population: int | None = None
   total_population: int | None = None
   man_age_range_population: int | None = None
   woman_age_range_population: int | None = None
   total_population_in_range: int | None = None
   man_population_by_region: int | None = None
   woman_population_by_region: int | None = None
   man_population_by_all_region: Dict[str, int] | None = None  # 수정된 부분
   woman_population_by_all_region: Dict[str, int] | None = None  # 수정된 부분

스키마도 그에 맞게 조정하였다.

대부분의 데이터 타입은 int 이고 성별만 str이다.  or 조건으로 값이 없어도 통과되며 기본값은 None으로 설정하였다.

왜냐하면 남, 여 성별만 필수 데이터로 설정하고 나머지는 연령조건과 지역조건은 임의의(없어도 되는) 데이터로 설정했기 때문이다.

각 지역별 인구는 딕셔너리 자료형으로 반환한다.

def population_ans(db: Session, request: PopulationQuestion):
    # 성별 및 지역별 인구 쿼리 준비
    filtered_population = db.query(DbPopulation)
    filtered_man = filtered_population.filter(DbPopulation.gender == '남')
    filtered_woman = filtered_population.filter(DbPopulation.gender == '여')

    # 남성과 여성의 총 인구 계산
    total_population = filtered_population.with_entities(func.sum(DbPopulation.total_population)).scalar()
    man_population = filtered_man.with_entities(func.sum(DbPopulation.total_population)).scalar()
    woman_population = filtered_woman.with_entities(func.sum(DbPopulation.total_population)).scalar()

DB에서 데이터를 가져와서 담고

request.gender 값이 있든 없든 남, 여로 필터링 한다.

전체 인구, 남자 인구, 여자 인구를 각각 합산한다.

 

# 성별과 연령대에 따른 인구수 계산
man_population_in_range = None
woman_population_in_range = None

if request.age_start is not None and request.age_end is not None:
    man_population_in_range = filtered_man.filter(
        and_(
            DbPopulation.age >= request.age_start,
            DbPopulation.age <= request.age_end
        )
    ).with_entities(func.sum(DbPopulation.total_population)).scalar()

    woman_population_in_range = filtered_woman.filter(
        and_(
            DbPopulation.age >= request.age_start,
            DbPopulation.age <= request.age_end
        )
    ).with_entities(func.sum(DbPopulation.total_population)).scalar()
else:
    # If age_start or age_end is None, include all ages
    man_population_in_range = filtered_man.with_entities(func.sum(DbPopulation.total_population)).scalar()
    woman_population_in_range = filtered_woman.with_entities(func.sum(DbPopulation.total_population)).scalar()

연령 조건이 있을때 작동한다.

이상~이하 조건을 적용해서 남자, 여자 따로 계산하여 변수에 담았다.

else 조건은 모든 연령의 값을 계산하는 건데. 위에서 계산한 man_population, woman_population 값과 동일하기 때문에 따로 계산을 안해도 될 것같다. 나중에 수정한다.

# 특정 지역 인구 총합 계산
man_by_all_region = defaultdict(int)
woman_by_all_region = defaultdict(int)

man_by_region, woman_by_region = 0, 0
if request.location:
    if request.age_start is not None and request.age_end is not None:
        filtered_man = filtered_man.filter(
            and_(
                DbPopulation.age >= request.age_start,
                DbPopulation.age <= request.age_end
            )
        )
        filtered_woman = filtered_woman.filter(
            and_(
                DbPopulation.age >= request.age_start,
                DbPopulation.age <= request.age_end
            )
        )
    # DB 지역 칼럼 이름 가져오기
    region_columns = [column.key for column in DbPopulation.__table__.columns \
                      if column.key not in ['id', 'gender', 'age']]

    # 남, 여 지역별 총합구하기
    for region in region_columns:
        total_man_region = filtered_man.with_entities(func.sum(getattr(DbPopulation, region.lower()))).scalar()
        man_by_all_region[region] = total_man_region

        total_woman_region = filtered_woman.with_entities(func.sum(getattr(DbPopulation, region.lower()))).scalar()
        woman_by_all_region[region] = total_woman_region

    # 유저가 선택한 지역의 총합.
    region_list = [location for location in request.location]
    if '전국' in region_list:
        man_by_region = man_by_all_region[total_population]
        woman_by_region = woman_by_all_region[total_population]
    else:
        for region in region_list:
            man_by_region += man_by_all_region[region]
            woman_by_region += woman_by_all_region[region]

지역별 인구를 구하는 코드

나이 조건이 있으면 필터링하여 계산한다.

DB 지역 칼럼을 가져와 리스트에 담는다. id, gender, age 칼럼을 제외한다.

칼럼 리스트를 반복문으로 딕셔너리로 자료구조를 설정한 남자, 여자 분리하여 담는다.

유저가 입력한 지역을 가져와서 변수에 담는다.

'전국'이라는 데이터가 있으면 이미 계산된 total_population 칼럼의 값을 이용한다.

그렇지 않으면 각각 지역의 값을 더해서 합산한다.

테스트 입력값이다.

남성이면서 1~10세, 부산지역

결과에서 각 지역별 인구가 딕셔너리 자료형으로 반환되는 것을 확인할수 있다.

연령조건, 지역조건을 제외하더라도 작동하는지 테스트 해보았다.

지역 조건을 여러개 입력해도 잘 작동하였다.

이제 이 데이터를 가지고 리액트에서 실시간으로 반응하도록 작성해본다.

1.  나머지 입력 창을 컴포넌트로 추가하기

이제 똑같이 

  • 지역(체크 박스로 고른다)
  • 혼인여부
  • 체격
  • 외모
  • 성격
  • 학력
  • 종교
  • 흡연
  • 음주
  • 안정적인 직업
  • 부모재산(노후가 대비 되어 있거나 지원을 해줄수 있는 지 여부)

입력 박스를 추가할 생각이다.

2. 지역

지역 체크 박스를 추가하였다. 복수 선택이 가능하면서 선택됐을때 효과가 눈에 띄도록 요청하였다.

LocationSelector.js

import React, { useState } from 'react';
import './LocationSelector.css';

const LocationSelector = () => {
  const [selectedLocations, setSelectedLocations] = useState([]);

  const locations = [
    '전국', '서울특별시', '부산광역시', '대구광역시', '인천광역시', '대전광역시', '울산광역시',
    '세종특별자치시', '경기도', '강원특별자치도', '충청북도', '충청남도', '전라북도', '전라남도',
    '경상북도', '경상남도', '제주특별자치도'
  ];

  const handleLocationClick = (location) => {
    if (selectedLocations.includes(location)) {
      setSelectedLocations(selectedLocations.filter(loc => loc !== location));
    } else {
      setSelectedLocations([...selectedLocations, location]);
    }
  };

  return (
    <div className="location-selector">
      <div className="label">지역</div>
      <div className="buttons">
        {locations.map(location => (
          <button
            key={location}
            className={selectedLocations.includes(location) ? 'button selected' : 'button'}
            onClick={() => handleLocationClick(location)}
          >
            {location}
            {selectedLocations.includes(location) && <span className="checkmark">&#10003;</span>}
          </button>
        ))}
      </div>
    </div>
  );
};

export default LocationSelector;

LocationSelector.css

.location-selector {
    margin-top: 20px;
  }
  
  .label {
    font-size: 1.2rem;
    margin-bottom: 10px;
  }
  
  .buttons {
    display: flex;
    flex-wrap: wrap;
  }
  
  .button {
    margin-right: 10px;
    margin-bottom: 10px;
    padding: 8px 12px;
    border: 1px solid #ccc;
    border-radius: 5px;
    background-color: #fff;
    color: #333;
    cursor: pointer;
    position: relative;
  }
  
  .button.selected {
    background-color: #4caf50;
    color: #fff;
  }
  
  .checkmark {
    position: absolute;
    top: 50%;
    right: 5px;
    transform: translateY(-50%);
    font-size: 0.8rem;
    color: #fff;
  }

App.js

import React, { useState } from 'react';
import Header from './components/Header/Header';
import AgeInput from './components/UserInput/Age';
import LocationSelector from './components/UserInput/LocationSelector';
import './App.css';

const App = () => {
  const [ageGroup, setAgeGroup] = useState('');

  const handleInputChange = (e) => {
    setAgeGroup(e.target.value);
  };

  return (
    <div>
      <Header />
      <AgeInput ageGroup={ageGroup} handleInputChange={handleInputChange} />
      <LocationSelector />
      {/* 다른 컴포넌트들을 이곳에 추가할 수 있습니다. */}
    </div>
  );
};

export default App;

순서대로 App.js 에 추가하였다.

 

3. 혼인 여부

찾는 대상이 미혼, 기혼, 돌싱 등 선택할수 있는 체크박스를 하나더 생성했다.

코드와 위와 대동소이하지만 컴포넌트로 파일을 따로 빼서 만들었다.

 

4. 체격

BMI 수치로 구성할 예정이므로 BMI 통계에 의하면

저체중(BMI 18.5 미만) 정상체중(BMI 18.5~25.0 미만) 비만1단계(BMI 25.0~30.0 미만) 비만2단계(BMI 30.0~40.0 미만) 비만3단계(BMI 40.0 이상)

5단계로 나뉜다.

역시 똑같이 5단계로 버튼을 만들었다.

 

5. 외모

외모는 주관적인 영역 평가이기 때문에 박스로 숫자만 입력하도록 하게 만들생각이다.

1~100까지만 입력할수 있게 제한을 걸어둔 박스가 만들어졌다.

이런 방법으로 FastAPI에서도 데이터 입력 값을 제한하여 2중으로 데이터를 검증할 계획이다.

 

6.  학력

최종학력은 제대로 된 통계자료가 없어서 구성이 애매하다.

 

7.  종교

'불교', '개신교', '천주교', '원불교', '유교', '천도교', '대순진리회', '대종교', '기타', '종교없음'

조금 선택사항이 많지만 통계조사에 나와있는대로 하였다.

8. 흡연

흡연, 비흡연, 전체로 구성

9. 음주

'비음주', '1~2잔', '3~4잔', '5~6잔', '7~9잔', '10잔 이상' 으로 구분하였다.

10. 부모님 자산(노후 준비 및 지원 여부)

11. 남여 대상

가장 먼저 결정해야 될 문제인데 깜빡하고 있었다. 가장 위로 이동 시킨다.

 

12. 전체 화면

밑에 흡연과 음주 여부는 짤렸지만 전체 모양은 그럴듯하게 잡혔다. 그래프 컨테이너의 위치를 조금 고민해볼 필요가 있겠다.

1. 헤드 꾸미기

먼저 헤드를 꾸며달라고 요청했다.

좌측에는 로고를 넣고 오른쪽에는 로그인과 로그아웃 버튼이 나타나도록 했다.

컴포넌트로 관리 할수 있도록 파일을 분리해달라고 요청했다.

파일트리를 알려달라고 했고 똑같이 구성했다.

파일 트리에 맞게 코드를 수정하도록 하였다.

App.js

컴포넌트로 만드 Header.js를 App.js로 가져와서 적용시키는 코드이다.

헤더를 이쁘게 꾸며달라고 요청해보았다.

로고 색깔만 변경했다. 대충 이런 느낌으로 만들어주었다.

로그인, 로그아웃 버튼의 글자색이 좀 별로라는 생각이 들지만 나중에 수정하기로 한다. 

* {
  margin : 2px;
}

.app {
  background-color: #fafafa;
}

1. React 설치하기

비주얼 스튜디오를 이용한다. 파이참ce 버전은 자바스크립트 사용불가이다.

프로젝트를 진행한 폴더를 하나 생성(대문자가 포함되면 안된다.)

npx create-react-app .

 

터미널에서  입력. 마지막에 .은 현재폴더를 의미한다. 

npm start

시작하고

localhost:3000

웹브라주져에서 주소를 입력해서 아래 화면이 뜨면 설치 성공이다.

 

이제부터 CORs 문제가 발생할 수 있기 때문에 CORs 문제를 미리 해결해둬야 된다.

2024.03.11 - [육각남 찾기 proj(FastAPI+React)] - 육각남 찾기 프로젝트 4. CORs 문제 해결

 

육각남 찾기 프로젝트 4. CORs 문제 해결

1. CORs 문제 해결 코딩을 시작하기 앞서 CORs문제를 해결한다. 리액트를 사용해서 하나의 PC로 작업할 경우 CORs 문제가 생기기 때문에 미리 해결해두고 간다. 교차 출처 리소스 공유 - FastAPI (tiangolo.

portfolio-dev.tistory.com

 

2. 헬로 월드 출력

* {
  margin : 2px;
}

.app {
  background-color: #fafafa;
}

 

App.css 파일에 흰 화면으로 설정

import logo from './logo.svg';
import './App.css';

function App() {
 return (
   "hello world"
 );
}

export default App;

App.js 에 입력하고 위 주소에 접속해보면 흰바탕에 헬로월드가 출력된다.

 

이제 본격적으로 리액트로 화면을 개발할 차례이다.

1.  스키마 조정

# routers 폴더 - schemas.py
from pydantic import BaseModel
from datetime import datetime
from pydantic import Field
from typing import Union

class UserBase(BaseModel):
   username: str
   password: str

# todo 암호관련 작업 필요하다.

class UserDisplay(BaseModel):
   username: str
   class Config():
       from_attributes = True


class CommentBase(BaseModel):
   text: str
   username: str
   timestamp: datetime

class PopulationQuestion(BaseModel):
   age_upper: int | None = None
   age_lower: int | None = None
   gender: str | None = None
   residence_location: list | None = None

class PopulationDisplay(BaseModel):
   total_population : int
   gender_population: int
   total_population_in_range : int
   target_percent : float
   gender: str
   test: Union[str, int, float, list, dict]
   class Config():
       from_attributes = True
   # todo 그래프를 그릴 데이터를 같이 반환할 필요가 있겠다.

class PostBase(PopulationQuestion):
   residence_location: str | None = None
   height_upper: str | None = None
   height_lower: str | None = None
   marriage: str | None = None
   bmi: str | None = None
   personality: str | None = None
   education: str | None = None
   religion: str | None = None
   smoking: str | None = None
   drinking: str | None = None
   occupation: str | None = None
   parent_asset: str | None = None
   timestamp: datetime

 

스키마 전체 코드이다.

class PopulationQuestion(BaseModel):
   age_upper: int | None = None
   age_lower: int | None = None
   gender: str | None = None
   residence_location: list | None = None

부분 질문의 대답을 처리하기 위해서 클래스를 나누었다.

나이와 키는 상한선과 하한선을 입력 받도록 하였다.

class PostBase(PopulationQuestion):
   residence_location: str | None = None
   height_upper: str | None = None
   height_lower: str | None = None
   marriage: str | None = None
   bmi: str | None = None
   personality: str | None = None
   education: str | None = None
   religion: str | None = None
   smoking: str | None = None
   drinking: str | None = None
   occupation: str | None = None
   parent_asset: str | None = None
   timestamp: datetime

PopulationDisplay(BaseModel)를 상속받는 클래스로 만들어 보았다.

앞으로 더 진행되면 더 잘게 쪼개서 여러개 상속을 받아서 최종적으로 사용자가 보내기를 눌렀을때 받을 데이터가 모음이 될것이다.

사용자가 입력 부분을 검증할 스키마이다.

대부분 OR 로 빈칸이 되어도 검증 통과를 할수 있도록 하였다.

str로 일단 설정을 해뒀지만 int, list로 변경이 될수 있다.

class PopulationDisplay(BaseModel):
   total_population : int
   gender_population: int
   total_population_in_range : int
   target_percent : float
   gender: str
   test: Union[str, int, float, list, dict]
   class Config():
       from_attributes = True
   # todo 그래프를 그릴 데이터를 같이 반환할 필요가 있겠다.

질문을 받고 답변이 나갈때의 스키마이다. 

사용자가 나이를 예를 들어 25세~35세로 지정한다면 답변은 예를 들어

전체 인구(total_population)는 5천만이면 남자(gender_population)는 2천5백만 입니다. 25세에서 35세 인구(population_up_to_low)는 오백만이며 20%(target_percent)입니다.

 

생각을 해보니 단순한 수치 제시에는 4가지 값만 반환하면 되겠지만 그래프를 그릴 계획이 있기 때문에 더 많은 데이터를 반환할 필요가 있겠다. 나중에 수정하도록한다.

 

이를 토대로 API를 작성해본다.

 

2. API 작성

#routers 폴더 - population.py
from sqlalchemy.orm.session import Session
from routers.schemas import PopulationQuestion,PopulationDisplay
from fastapi import APIRouter, Depends
from db.database import get_db
from db import db_answer_crud

router = APIRouter(
   prefix='/population',
   tags=['population']
)

@router.post('', response_model=PopulationDisplay)
def population_questinon(request:PopulationQuestion, db:Session=Depends(get_db)):
   if request.age_lower != None and request.age_upper != None:
      return db_answer_crud.population_anw(db, request)

population.py 파일을 생성하였다.

나이 상한선과 하한선 값이 있다면 db_answer_crud 파일에 population_anw()함수의 결과를 반환하도록 하였다.

if request.age_lower != None and request.age_upper != None:

처음에는 조건을 True 로 했는데 테스트에서 0값(0세)를 넣으면 통과를 못하기 때문에 조건을 None으로 변경하였다.

 

이제 db_answer_crud 파일을 코딩한다.

# main.py
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from routers import user, population

app = FastAPI()

app.include_router(user.router)
app.include_router(population.router)

origins = [
    "http://localhost:3000",
    "http://localhost:8000",
]

app.add_middleware(
    CORSMiddleware,
    allow_origins=origins,
    allow_methods=["*"],
    allow_headers=["*"],
)

@app.get('/')
def root():
    return "hello world"

작성한 API를 main.py 에도 반영했다.

3. DB 관련 변경 사항

# models.py
from db.database import Base
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey
from sqlalchemy.orm import relationship

class DbUser(Base):
   __tablename__= 'user'
   id = Column(Integer, primary_key=True, index=True)
   username = Column(String)
   password = Column(String)
   items = relationship('DbPost', back_populates='user')

# todo 암호 관련 작업이 필요하다.

class DbPost(Base):
   __tablename__ = 'post'
   id = Column(Integer, primary_key=True, index=True)
   age = Column(String)
   height = Column(String)
   education = Column(String)
   occupation = Column(String)
   residence_location = Column(String)
   religion = Column(String)
   timestamp = Column(DateTime)
   user_id = Column(Integer, ForeignKey('user.id'))
   user = relationship('DbUser', back_populates='items')
   comments = relationship('DbComment', back_populates='post')

class DbComment(Base):
   __tablename__ = 'comment'
   id = Column(Integer, primary_key=True, index=True)
   text = Column(String)
   username = Column(String)
   timestamp = Column(DateTime)
   post_id = Column(Integer, ForeignKey('post.id'))
   post = relationship('DbPost', back_populates='comments')

class DbPopulation(Base):
   __tablename__ = 'population_statistics'
   id = Column(Integer, primary_key=True, index=True)
   gender = Column(String)
   age = Column(Integer)
   total_population = Column(Integer)
   seoul = Column(Integer)
   busan = Column(Integer)
   daegu = Column(Integer)
   incheon = Column(Integer)
   gwangju = Column(Integer)
   daejeon =  Column(Integer)
   ulsan = Column(Integer)
   sejong =  Column(Integer)
   gyeonggi =  Column(Integer)
   gangwon = Column(Integer)
   chungcheongbuk = Column(Integer)
   chungcheongnam = Column(Integer)
   jeollabuk = Column(Integer)
   jeollanam = Column(Integer)
   gyeongsangbuk = Column(Integer)
   gyeongsangnam = Column(Integer)
   jeju = Column(Integer)

db - models.py 파일에 변경이 있었다.

class DbPopulation(Base):
   __tablename__ = 'population_statistics'
   id = Column(Integer, primary_key=True, index=True)
   gender = Column(String)
   age = Column(Integer)
   total_population = Column(Integer)
   seoul = Column(Integer)
   busan = Column(Integer)
   daegu = Column(Integer)
   incheon = Column(Integer)
   gwangju = Column(Integer)
   daejeon =  Column(Integer)
   ulsan = Column(Integer)
   sejong =  Column(Integer)
   gyeonggi =  Column(Integer)
   gangwon = Column(Integer)
   chungcheongbuk = Column(Integer)
   chungcheongnam = Column(Integer)
   jeollabuk = Column(Integer)
   jeollanam = Column(Integer)
   gyeongsangbuk = Column(Integer)
   gyeongsangnam = Column(Integer)
   jeju = Column(Integer)

gender 로 남,여로 입력한 부분 이외에는 모두 Interger 가 되었다.

데이터 베이스에 실제 입력값을 다시 넣었다.

age 열은 0세 -> 0, str -> int로 변경되었다.

그리고 총합 관련 행을 모두 삭제하였다.

import pandas as pd
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from db.models import DbPopulation
from db.database import Base
# your_module은 DbPopulation 클래스를 정의한 파일의 이름입니다.

# 엑셀 파일 읽기 (1행은 건너뛰고 읽음)
excel_file = '/Users/honjun/Downloads/poppulation.xlsx'  # 엑셀 파일 경로
df = pd.read_excel(excel_file)

# 데이터베이스에 연결
engine = create_engine('sqlite:///haxagonMan.db')  # 데이터베이스 URL에 따라 변경해야 할 수 있습니다.

# 데이터베이스 테이블 생성
Base.metadata.create_all(engine)

# 데이터베이스 세션 생성
Session = sessionmaker(bind=engine)
session = Session()

# 데이터프레임 반복하며 데이터베이스에 행 추가
for index, row in df.iterrows():
    if row.iloc[1] in ["남 인구수", "여 인구수" , "연령구간인구수"]:
        continue
    population_statistics = DbPopulation(
        gender=row.iloc[0],
        # age= int(''.join(filter(str.isdigit, age_string)) for age_string in row.iloc[1]),
        age=int(''.join(filter(str.isdigit, row.iloc[1]))),
        total_population=int(row.iloc[2].replace(',', '')),
        seoul=int(row.iloc[3].replace(',', '')),
        busan=int(row.iloc[4].replace(',', '')),
        daegu=int(row.iloc[5].replace(',', '')),
        incheon=int(row.iloc[6].replace(',', '')),
        gwangju=int(row.iloc[7].replace(',', '')),
        daejeon=int(row.iloc[8].replace(',', '')),
        ulsan=int(row.iloc[9].replace(',', '')),
        sejong=int(row.iloc[10].replace(',', '')),
        gyeonggi=int(row.iloc[11].replace(',', '')),
        gangwon=int(row.iloc[12].replace(',', '')),
        chungcheongbuk=int(row.iloc[13].replace(',', '')),
        chungcheongnam=int(row.iloc[14].replace(',', '')),
        jeollabuk=int(row.iloc[15].replace(',', '')),
        jeollanam=int(row.iloc[16].replace(',', '')),
        gyeongsangbuk=int(row.iloc[17].replace(',', '')),
        gyeongsangnam=int(row.iloc[18].replace(',', '')),
        jeju=int(row.iloc[19].replace(',', '')),
    )
    session.add(population_statistics)

# 변경사항 커밋
session.commit()

# 세션 종료
session.close()

데이터를 넣을 때 사용한 코드이다. 몇번 안할 작업이라 수작업이라고 해도 상관은 없지만 효율적인 작업을 위해 고민을 해봐야 할 부분이다.

 

4. DB 작성

from sqlalchemy.orm.session import Session
from sqlalchemy import func, and_, or_
from db.models import DbPopulation
from routers.schemas import PopulationQuestion, PopulationDisplay

def population_anw(db: Session, request: PopulationQuestion):
    # 남성과 여성 데이터를 한 번에 쿼리하여 필터링
    filtered_population = db.query(DbPopulation)
    # 총 인구수 계산
    total_population = filtered_population.with_entities(func.sum(DbPopulation.total_population)).scalar()

    if request.gender:
        filtered_population = filtered_population.filter(DbPopulation.gender == request.gender)

    # 성별 인구수 계산
    gender_population = filtered_population.with_entities(func.sum(DbPopulation.total_population)).scalar()

    # 타겟 연령별 인구 계산
    if request.age_lower != None and request.age_upper != None:
        filtered_population = filtered_population.filter(and_(DbPopulation.age >= request.age_lower,
                                                              DbPopulation.age <= request.age_upper))
    # 특정 지역 인구
    total_population_in_range = 0
    if request.residence_location:
        # location_list에 포함된 열 index만 남기기
        location_list = [location for location in request.residence_location]
        # location_list에 있는 열만 필터링
        filters = [getattr(DbPopulation, location_column) for location_column in location_list]
        filtered_population = filtered_population.filter(or_(*filters))

        # 필터링된 열의 값들의 총합 계산
        for population in filtered_population:
            for location in location_list:
                total_population_in_range += getattr(population, location)

    # 지역 입력값이 없으면 타겟 연령별 인구의 총합으로 한다.
    else:
        total_population_in_range = filtered_population.with_entities(func.sum(DbPopulation.total_population)).scalar()

    # 타겟 퍼센트 계산
    if gender_population:
        target_percent = round(total_population_in_range / gender_population * 100, 2)
    else:
        target_percent = 0.0

    population_res = PopulationDisplay(
        total_population=total_population,
        gender_population=gender_population,
        total_population_in_range=total_population_in_range,
        target_percent=target_percent,
        gender=request.gender,
        test=1
    )

    return population_res

나름 작성을 하여 제대로된 경과를 뽑아 내는데 성공했다.

챗gpt에게 코드를 효율적으로 바꿔달라고 요청했더니 상당히(!) 다른 코드를 주었다.

하지만 이상하게 작동하지 않아서 약간 손을 볼 필요가 있었다.

# 남성과 여성 데이터를 한 번에 쿼리하여 필터링
filtered_population = db.query(DbPopulation)
# 총 인구수 계산
total_population = filtered_population.with_entities(func.sum(DbPopulation.total_population)).scalar()

데이터 베이스에서 그대로 가져온다. 현재 남,녀 인구 순수한 데이터만 남아 있기 때문에 그대로 가져왔다.

필터링한 인구에서 total_population 열을 모두 합해서 총인구 계산을 했다.

if request.gender:
    filtered_population = filtered_population.filter(DbPopulation.gender == request.gender)

# 성별 인구수 계산
gender_population = filtered_population.with_entities(func.sum(DbPopulation.total_population)).scalar()

입력한 성 값이 있으면. 남자면 남자로 필터링하여 다시 저장했다.

똑같이 필터링한 데이터의 총합으로 남자 인구의 총합을 구한다.

# 타겟 연령별 인구 계산
if request.age_lower != None and request.age_upper != None:
    filtered_population = filtered_population.filter(and_(DbPopulation.age >= request.age_lower,
                                                          DbPopulation.age <= request.age_upper))

입력한 연령 구간이 있다면 그 구간만 남기도록 필터링을 걸었다.

# 특정 지역 인구
total_population_in_range = 0
if request.residence_location:
    # location_list에 포함된 열 index만 남기기
    location_list = [location for location in request.residence_location]
    # location_list에 있는 열만 필터링
    filters = [getattr(DbPopulation, location_column) for location_column in location_list]
    filtered_population = filtered_population.filter(or_(*filters))

    # 필터링된 열의 값들의 총합 계산
    for population in filtered_population:
        for location in location_list:
            total_population_in_range += getattr(population, location)

# 지역 입력값이 없으면 타겟 연령별 인구의 총합으로 한다.
else:
    total_population_in_range = filtered_population.with_entities(func.sum(DbPopulation.total_population)).scalar()

특정 지역이라는 입력값이 있으면 특정지역의 인구만 필터링한 후 총합을 구한다.

 

지역 입력값이 없으면 위에서 연령으로 필터링한 값의 총합을 구한다.

# 타겟 퍼센트 계산
if gender_population:
    target_percent = round(total_population_in_range / gender_population * 100, 2)
else:
    target_percent = 0.0

population_res = PopulationDisplay(
    total_population=total_population,
    gender_population=gender_population,
    total_population_in_range=total_population_in_range,
    target_percent=target_percent,
    gender=request.gender,
    test=1
)

return population_res

 

마지막으로 구간 인구/성별 총인구를 나눠서 소수점 둘째자리까지 표현하였다.

 

결과는 API에서 반환 모델을 PopulationDisplay를 모델로 하기 때문에 그 형식에 맞춰서 작성한 것이다.

 

(test=1은 삭제)

5. 테스트 결과

스웨거 UI를 통해서 확인한 모습

총인구, 남자 총인구, 0~1세 총인구, 남자 총인구 대비 0~1세의 비율, 입력한 성별이 순서대로 출력되는 것을 확인할수 있었다.

나이 하한선을 0세로 입력해도 통과가 된다. 하지만 버그 방지를 위해서 API파라매터 입력의 값을 1세에서 100세로 제한해둘 필요가 있겠다.

서울과 부산 복수의 값을 리스트로 입력해도 잘 작동하는 것을 확인했다.

 

이제 테스트 해볼 백엔드의 형태를 갖췄으니 프론트 엔드 작업을 해봐야 겠다.

202312_202312_연령별인구현황_연간_1세.xlsx
0.03MB

1. 엑셀이냐 판다스냐

데이터를 엑셀 같은 프로그램에서 데이터를 가공해서 DB에 넣을지 아니면 판다스를 이용해서 파이썬으로 다룰지 잠시 고민했다.

 

엑셀로 하는것이 눈으로 직접 결과를 바로 볼수 있다는 점에서 편리하겠지만

 

이 프로젝트의 목적이 학습에 있는 만큼 판다스를 이용해 보려고 한다.

 

2. 지역별 나이 데이터 모습

2023년 지역별 인구

현재 데이터는 이미지와 같다.

 

데이터 가공할 부분을 좀 생각해봤는데

 

  1. 행정기관코드 열은 필요 없으므로 삭제
  2. 좌우로 넓은 데이터를 세로로 길게 만들기(행과 열을 피봇한다)
  3. 남자와 여자를 헤드에 추가한다.

3. 파이썬에 테스트 코딩하기

먼저 필요한 모듈인 

  • openpyxl
  • pandas

패키지를 설치한다.

import openpyxl
import pandas as pd

# 엑셀 파일 열기
workbook = openpyxl.load_workbook('/Users/honjun/Downloads/202312_202312_연령별인구현황_연간_1세.xlsx')
# 시트 선택
sheet = workbook.active

# 판다스 데이터프레임으로 변환
df = pd.DataFrame(sheet.values)

# 멀티 인덱스가 존재하기 때문에 인덱스를 리셋
df = df.reset_index()

# 행열을 변경
df = df.transpose()

# 필요없는 행과 열을 제거
df = df.drop(df.index[0:2])
df = df.drop(df.columns[0:2], axis=1)

# 인덱스를 다시 지정하고 지정한 인덱스를 삭제
df = df.rename(columns=df.iloc[0])
df = df.drop(df.index[0])

# 이름이 없는 인덱스의 이름을 지정
df = df.rename(columns={None: '성별'})

# 남, 여 값이 비어 있기 때문에 위의 셀 값을 참조해서 넣도록 함
df = df.fillna(method='ffill')

# 삭제한 행이 있기 때문에 다시 초기화 함
df = df.reset_index()
# 초기화 후 이전 인덱스 열을 삭제
df = df.drop(df.columns[0], axis=1)

# 인덱싱을 사용하여 테스트
print(df.iloc[0, 1])

# 엑셀 파일로 저장
df.to_excel("/Users/honjun/Downloads/output.xlsx", index=False)

 

쓸모 없는 코드가 제법 있어 보이지만 데이터를 어쨌든 정제하였다.

# 멀티 인덱스가 존재하기 때문에 인덱스를 리셋
df = df.reset_index()

멀티 인덱스를 어떻게 처리하는지 몰라서 한참을 검색했다. 그냥 인덱스 초기화가 답이다.

한개의 열을 검색하거나 삭제하는데 두개의 열이 묶여서 검색되거나 삭제되는 문제가 해결되었다.

(2023년 열과, 남 열이 분리되어야 하는데 같이 묶여 있는 문제가 있었다)

엑셀 파일을 처음 불러 온 상태이다.

좌우로 길게 출력된다.

# 행열을 변경한다
df = df.transpose()

상하로 길게 변경하였다.

# 필요없는 행과 열을 제거
df = df.drop(df.index[0:2])
df = df.drop(df.columns[0:2], axis=1)

필요 없는 행과 열을 삭제하였다.

# 인덱스를 다시 지정해주고 지정한 인덱스를 삭제한다.
df = df.rename(columns=df.iloc[0])
df = df.drop(df.index[0])

# 이름이 없는 인덱스의 이름을 지정했다.
df = df.rename(columns={None : '성별'})

1,2,3,4 로 되어있던 행 인덱스를 행정기관, 전국 등으로 변경하였다.

None으로 비어 있는 행인덱스를 성별로 변경하였다.

# 남, 여 값이 비어 있기 때문에 위의 셀 값을 참조해서 넣도록 한다.
df = df.fillna(method='ffill')

None으로 비어 있는 값을 위의 셀값으로 채웠다. 남,여 구분 할 수 있게 되었다.

# 삭제한 행이 있기 때문에 다시 초기화 하였다.
df = df.reset_index()
# 초기화 후 이전 인덱스 열을 삭제
df = df.drop(df.columns[0], axis=1)

몇개 행을 지우는 바람에 순서가 엉켜있다. 인덱스를 초기화하면서 추가된 인덱스 열을 삭제한다.

print(df.iloc[0,1])

인덱싱을 이용한 출력으로 정확한 데이터가 출력되는지 확인해보았다.

# 이름이 없는 인덱스의 이름을 지정했다.
df = df.rename(columns={None : '성별'})
df = df.rename(columns={'행정기관' : '나이'})

추가로 행정기관 열인덱스 네임을 나이로 변경하였다.

workbook.close()

df.to_excel('/Users/username/Downloads/poppulation.xlsx', index=False)

마지막에 워크북을 닫고 엑셀파일로 다시 저장하였다.

 

4. 데이터 베이스에 엑셀 파일 데이터넣기

# models.py
from db.database import Base
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey
from sqlalchemy.orm import relationship

class DbUser(Base):
   __tablename__= 'user'
   id = Column(Integer, primary_key=True, index=True)
   username = Column(String)
   password = Column(String)
   items = relationship('DbPost', back_populates='user')

# todo 암호 관련 작업이 필요하다.

class DbPost(Base):
   __tablename__ = 'post'
   id = Column(Integer, primary_key=True, index=True)
   age = Column(String)
   height = Column(String)
   education = Column(String)
   occupation = Column(String)
   residence_location = Column(String)
   religion = Column(String)
   timestamp = Column(DateTime)
   user_id = Column(Integer, ForeignKey('user.id'))
   user = relationship('DbUser', back_populates='items')
   comments = relationship('DbComment', back_populates='post')

class DbComment(Base):
   __tablename__ = 'comment'
   id = Column(Integer, primary_key=True, index=True)
   text = Column(String)
   username = Column(String)
   timestamp = Column(DateTime)
   post_id = Column(Integer, ForeignKey('post.id'))
   post = relationship('DbPost', back_populates='comments')

class DbPopulation(Base):
   __tablename__ = 'population_statistics'
   id = Column(Integer, primary_key=True, index=True)
   gender = Column(String)
   age = Column(String)
   total_population = Column(Integer)
   seoul = Column(Integer)
   busan = Column(Integer)
   daegu = Column(Integer)
   incheon = Column(Integer)
   gwangju = Column(Integer)
   daejeon =  Column(Integer)
   ulsan = Column(Integer)
   sejong =  Column(Integer)
   gyeonggi =  Column(Integer)
   gangwon = Column(Integer)
   chungcheongbuk = Column(Integer)
   chungcheongnam = Column(Integer)
   jeollabuk = Column(Integer)
   jeollanam = Column(Integer)
   gyeongsangbuk = Column(Integer)
   gyeongsangnam = Column(Integer)
   jeju = Column(Integer)

모델링을 먼저하고 데이터베이스에 테이블을 추가하였다.

import pandas as pd
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from db.models import DbPopulation
from db.database import Base
# your_module은 DbPopulation 클래스를 정의한 파일의 이름입니다.

# 엑셀 파일 읽기 (1행은 건너뛰고 읽음)
excel_file = '/Users/username/Downloads/poppulation.xlsx'  # 엑셀 파일 경로
df = pd.read_excel(excel_file)

# 데이터베이스에 연결
engine = create_engine('sqlite:///haxagonMan.db')  # 데이터베이스 URL에 따라 변경해야 할 수 있습니다.

# 데이터베이스 테이블 생성
Base.metadata.create_all(engine)

# 데이터베이스 세션 생성
Session = sessionmaker(bind=engine)
session = Session()

# 데이터프레임 반복하며 데이터베이스에 행 추가
for index, row in df.iterrows():
    population_data = DbPopulation(
        gender=row.iloc[0],
        age=row.iloc[1],
        total_population=row.iloc[2],
        seoul=row.iloc[3],
        busan=row.iloc[4],
        daegu=row.iloc[5],
        incheon=row.iloc[6],
        gwangju=row.iloc[7],
        daejeon=row.iloc[8],
        ulsan=row.iloc[9],
        sejong=row.iloc[10],
        gyeonggi=row.iloc[11],
        gangwon=row.iloc[12],
        chungcheongbuk=row.iloc[13],
        chungcheongnam=row.iloc[14],
        jeollabuk=row.iloc[15],
        jeollanam=row.iloc[16],
        gyeongsangbuk=row.iloc[17],
        gyeongsangnam=row.iloc[18],
        jeju=row.iloc[19]
    )
    session.add(population_data)

# 변경사항 커밋
session.commit()

# 세션 종료
session.close()

저장한 엑셀파일을 읽어서 모델의 열과 엑셀파일의 열을 일치시켜 population_data 변수에 담고 DB에 추가하였다.

너무 수작업스러운 면이 있어서 더 나은 방법을 강구할 필요성을 느꼈다.

문제는 엑셀파일을 먼저 읽어서 이를 토대로 테이블을 만들고 DB을 업데이트 하는 방법을 사용해보려고 했는데

DB에 columns에 id가 생성되지 않아서 모델링과 충돌하였다. id column를 따로 추가하려고 했으나 명령 오류로 잘 되지 않았다.

 

(추가)

유저가 입력한 데이터를 저장할것이 아니라 이미 엑셀로 정제된 데이터이라면 모델링이 필요하지 않을 수 있겠다는 생각이 들었다.

 

그래서 챗 GPT에게 물어봤더니 단순한 데이터 분석용으로는 반드시 필요한것은 아니라고 답변을 받았다.

 

다음에 데이터를 추가할때는 엑셀파일을 그대로 데이터베이스에 넣는 방법을 고려해볼만하다.

+ Recent posts