From ebfa7a15bb4bef0dde78769bff42ca6df4d5148f Mon Sep 17 00:00:00 2001 From: hbyang Date: Mon, 15 Jun 2026 15:58:06 +0900 Subject: [PATCH] =?UTF-8?q?requirement.dev.txt=20=EC=82=AD=EC=A0=9C=20.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 103 ++++++++- agent/README.md | 1 + docker-compose.yml | 36 ++++ front/README.md | 1 + negodata/backend/.dockerignore | 7 + negodata/backend/Dockerfile | 16 ++ negodata/backend/README.md | 61 ++++++ .../conftest.cpython-314-pytest-9.0.3.pyc | Bin 0 -> 3882 bytes .../common/__pycache__/enums.cpython-314.pyc | Bin 0 -> 3345 bytes .../common/__pycache__/logger.cpython-314.pyc | Bin 0 -> 3244 bytes .../__pycache__/singleton.cpython-314.pyc | Bin 0 -> 1191 bytes .../db_session_manager.cpython-314.pyc | Bin 0 -> 16114 bytes .../common/database/db_session_manager.py | 198 ++++++++++++++++++ .../model/__pycache__/models.cpython-314.pyc | Bin 0 -> 1578 bytes .../model/__pycache__/mysql.cpython-314.pyc | Bin 0 -> 1566 bytes .../backend/common/database/model/models.py | 26 +++ negodata/backend/common/enums.py | 61 ++++++ negodata/backend/common/logger.py | 43 ++++ .../models/__pycache__/gmodel.cpython-314.pyc | Bin 0 -> 4521 bytes negodata/backend/common/models/gmodel.py | 64 ++++++ negodata/backend/common/singleton.py | 16 ++ .../utils/__pycache__/gtime.cpython-314.pyc | Bin 0 -> 2443 bytes negodata/backend/common/utils/gtime.py | 21 ++ .../__pycache__/config_loader.cpython-314.pyc | Bin 0 -> 2719 bytes .../__pycache__/config_models.cpython-314.pyc | Bin 0 -> 3061 bytes .../server_configs.cpython-314.pyc | Bin 0 -> 1806 bytes negodata/backend/config/config.docker.toml | 33 +++ negodata/backend/config/config.local.toml | 32 +++ negodata/backend/config/config.test.toml | 33 +++ negodata/backend/config/config_loader.py | 37 ++++ negodata/backend/config/config_models.py | 41 ++++ negodata/backend/config/server_configs.py | 20 ++ negodata/backend/conftest.py | 54 +++++ .../__pycache__/user_crud.cpython-314.pyc | Bin 0 -> 6861 bytes negodata/backend/crud/user_crud.py | 75 +++++++ negodata/backend/loadtest/locustfile.py | 80 +++++++ negodata/backend/pytest.ini | 9 + negodata/backend/requirements.txt | 8 + .../router/__pycache__/router.cpython-314.pyc | Bin 0 -> 2498 bytes negodata/backend/router/router.py | 44 ++++ .../auth/__pycache__/account.cpython-314.pyc | Bin 0 -> 4026 bytes .../auth/__pycache__/protocol.cpython-314.pyc | Bin 0 -> 3149 bytes negodata/backend/router/v1/auth/account.py | 42 ++++ negodata/backend/router/v1/auth/protocol.py | 34 +++ .../__pycache__/dependencies.cpython-314.pyc | Bin 0 -> 8206 bytes .../router/v1/validator/dependencies.py | 113 ++++++++++ .../__pycache__/auth_service.cpython-314.pyc | Bin 0 -> 7600 bytes negodata/backend/services/auth_service.py | 115 ++++++++++ .../test_auth.cpython-314-pytest-9.0.3.pyc | Bin 0 -> 12616 bytes negodata/backend/tests/test_auth.py | 63 ++++++ negodata/backend/web_main.py | 41 ++++ negodata/front/README.md | 1 + postgres-init/01-schema.sql | 32 +++ 53 files changed, 1560 insertions(+), 1 deletion(-) create mode 100644 agent/README.md create mode 100644 docker-compose.yml create mode 100644 front/README.md create mode 100644 negodata/backend/.dockerignore create mode 100644 negodata/backend/Dockerfile create mode 100644 negodata/backend/README.md create mode 100644 negodata/backend/__pycache__/conftest.cpython-314-pytest-9.0.3.pyc create mode 100644 negodata/backend/common/__pycache__/enums.cpython-314.pyc create mode 100644 negodata/backend/common/__pycache__/logger.cpython-314.pyc create mode 100644 negodata/backend/common/__pycache__/singleton.cpython-314.pyc create mode 100644 negodata/backend/common/database/__pycache__/db_session_manager.cpython-314.pyc create mode 100644 negodata/backend/common/database/db_session_manager.py create mode 100644 negodata/backend/common/database/model/__pycache__/models.cpython-314.pyc create mode 100644 negodata/backend/common/database/model/__pycache__/mysql.cpython-314.pyc create mode 100644 negodata/backend/common/database/model/models.py create mode 100644 negodata/backend/common/enums.py create mode 100644 negodata/backend/common/logger.py create mode 100644 negodata/backend/common/models/__pycache__/gmodel.cpython-314.pyc create mode 100644 negodata/backend/common/models/gmodel.py create mode 100644 negodata/backend/common/singleton.py create mode 100644 negodata/backend/common/utils/__pycache__/gtime.cpython-314.pyc create mode 100644 negodata/backend/common/utils/gtime.py create mode 100644 negodata/backend/config/__pycache__/config_loader.cpython-314.pyc create mode 100644 negodata/backend/config/__pycache__/config_models.cpython-314.pyc create mode 100644 negodata/backend/config/__pycache__/server_configs.cpython-314.pyc create mode 100644 negodata/backend/config/config.docker.toml create mode 100644 negodata/backend/config/config.local.toml create mode 100644 negodata/backend/config/config.test.toml create mode 100644 negodata/backend/config/config_loader.py create mode 100644 negodata/backend/config/config_models.py create mode 100644 negodata/backend/config/server_configs.py create mode 100644 negodata/backend/conftest.py create mode 100644 negodata/backend/crud/__pycache__/user_crud.cpython-314.pyc create mode 100644 negodata/backend/crud/user_crud.py create mode 100644 negodata/backend/loadtest/locustfile.py create mode 100644 negodata/backend/pytest.ini create mode 100644 negodata/backend/requirements.txt create mode 100644 negodata/backend/router/__pycache__/router.cpython-314.pyc create mode 100644 negodata/backend/router/router.py create mode 100644 negodata/backend/router/v1/auth/__pycache__/account.cpython-314.pyc create mode 100644 negodata/backend/router/v1/auth/__pycache__/protocol.cpython-314.pyc create mode 100644 negodata/backend/router/v1/auth/account.py create mode 100644 negodata/backend/router/v1/auth/protocol.py create mode 100644 negodata/backend/router/v1/validator/__pycache__/dependencies.cpython-314.pyc create mode 100644 negodata/backend/router/v1/validator/dependencies.py create mode 100644 negodata/backend/services/__pycache__/auth_service.cpython-314.pyc create mode 100644 negodata/backend/services/auth_service.py create mode 100644 negodata/backend/tests/__pycache__/test_auth.cpython-314-pytest-9.0.3.pyc create mode 100644 negodata/backend/tests/test_auth.py create mode 100644 negodata/backend/web_main.py create mode 100644 negodata/front/README.md create mode 100644 postgres-init/01-schema.sql diff --git a/README.md b/README.md index 53a5f28..2b2805d 100644 --- a/README.md +++ b/README.md @@ -1 +1,102 @@ -마지막 내 도리는 하자 . \ No newline at end of file +# O2O Negosium + +동일 구조의 두 서비스(**negosium**, **negodata**)가 **하나의 PostgreSQL 인스턴스**를 공유한다. + +## 구성 + +``` +o2o-negosium/ +├── docker-compose.yml # 두 backend (DB 는 외부) +├── postgres-init/ # DB·테이블 셋업 SQL (대상 DB 에 1회 적용) +├── backend/ # negosium 백엔드 (포트 9300) +├── negodata/backend/ # negodata 백엔드 (포트 9400) +├── agent/ front/ # (예정) +└── negodata/front/ # (예정) +``` + +두 백엔드는 같은 코드 골격(MVC · 람다 DB · Depends 주입 · JWT 로그인)을 쓴다. +아키텍처/패턴 상세는 각 서버 README 참고: [backend](backend/README.md) · [negodata/backend](negodata/backend/README.md) + +### DB 는 compose 밖 (config 로 연결) +DB 는 docker-compose 에서 관리하지 않는다. 각 backend 는 `config..toml` 의 접속 정보대로 +**외부 PostgreSQL**(호스트 로컬 postgres, 또는 따로 떠 있는 docker postgres)에 연결한다. +한 PostgreSQL 안에 서비스별 database 를 둔다. +``` +PostgreSQL (외부, 5432) +├── negosium_db ← negosium-backend +└── negodata_db ← negodata-backend +``` +- 컨테이너(docker env)에서 호스트 DB 접근: `host.docker.internal:5432` (`config.docker.toml`) +- 로컬 실행/테스트(local·test env): `127.0.0.1:5432` (`config.local/test.toml`) +- 계정/database 명은 config 에 맞춘다 (기본 `postgres` / `password`). + +| 서비스 | 서버 | docs | database | +|---|---|---|---| +| negosium-backend | http://localhost:9300 | /docs | negosium_db | +| negodata-backend | http://localhost:9400 | /docs | negodata_db | + +## 빠른 시작 + +```bash +# 1) DB 준비 (최초 1회) — 사용할 PostgreSQL 에 스키마 적용 +psql -h 127.0.0.1 -p 5432 -U postgres -f postgres-init/01-schema.sql +# (negosium_db / negodata_db + tbl_account 생성) + +# 2) 백엔드 기동 +docker compose up -d # 두 backend (DB 는 config 대로 외부 연결) +docker compose logs -f +docker compose down +``` + +## 테스트 + +```bash +# config.test.toml 의 PostgreSQL(기본 127.0.0.1:5432) 이 떠 있어야 한다 +cd backend # 또는 negodata/backend +pip install pytest pytest-asyncio httpx +python -m pytest +``` +- httpx `ASGITransport` 로 네트워크 없이 앱을 직접 호출하는 e2e (각 5개). +- `DB_SESSION_MNG` 싱글톤의 커넥션 풀이 첫 이벤트 루프에 묶이므로, 모든 테스트가 단일 session 루프를 공유한다(`pytest.ini`). + +## 성능 / 벤치마크 + +`/login` 은 **bcrypt(CPU 바운드)** 가 비용의 대부분이다. 초기에는 bcrypt 가 asyncio +이벤트 루프를 막아 **아무 일도 안 하는 `/healthz` 조차 p99 3.3s** 가 나왔다. + +**두 가지 최적화** +1. **bcrypt 를 `asyncio.to_thread` 로 오프로드** — 이벤트 루프 비차단. bcrypt 는 해싱 중 + GIL 을 해제하므로 스레드들이 여러 코어에서 실제 병렬 실행된다. +2. **워커 수 증가** (`process_count` 1 → 4) — login 처리량을 코어만큼 확장. + +### Before / After (동일 부하: 100 users) +| 지표 | Before | After | 변화 | +|---|---|---|---| +| `/healthz` median | 1700ms | **2ms** | 850배 개선| +| `/healthz` p99 | 3300ms | **14ms** | 235배 개선| +| `/me` p99 | 3100ms | **12ms** | 258배 개선| +| `/login` median | 9900ms | **220ms** | 45배 개선| +| `/login` p99 | 15000ms | **1400ms** | 11배 개선| +| `/login` RPS | 5.5 | **19.3** | 3.5배 개선| +| 전체 RPS | 17.6 | **75.2** | 4.3배 개선| + +### 최적화 후 Locust 차트 (100 users) +RPS 가 ~71 로 안정, p95 ~250ms(bcrypt), 실패 0%. median 은 초기 계정생성 버스트 후 바닥으로 떨어진다. + +![Locust Benchmark](backend/tests/Benchmark.png) + +> login 은 여전히 가장 느리다(bcrypt 의 의도된 비용). 핵심은 그게 **서버 전체를 막지 않는다**는 점. +> 더 높은 처리량은 워커/인스턴스 수평 확장이 정석이다(bcrypt cost 낮추기는 보안 트레이드오프). +> ⚠️ to_thread 가 이미 단일 워커에서 멀티코어 병렬화를 하므로, 워커를 코어 수만큼 늘리면서 +> to_thread 까지 쓰면 `워커 x 스레드` 가 코어를 넘어 오버서브스크립션이 된다(워커는 코어의 절반 안팎). + +부하 재현: +```bash +docker compose up -d +cd backend && pip install locust +python -m locust -f loadtest/locustfile.py --host http://localhost:9300 --headless -u 100 -r 10 -t 2m +# 부하 중 커넥션 모니터링: psql -h 127.0.0.1 -U postgres -c "SELECT count(*) FROM pg_stat_activity;" +``` + +## 기술 스택 +FastAPI · SQLAlchemy(async) · asyncpg · PostgreSQL 16 · python-jose(JWT) · bcrypt · uvicorn · Docker Compose diff --git a/agent/README.md b/agent/README.md new file mode 100644 index 0000000..53a5f28 --- /dev/null +++ b/agent/README.md @@ -0,0 +1 @@ +마지막 내 도리는 하자 . \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..62edcd1 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,36 @@ +# Negosium + Negodata 백엔드. +# DB 는 compose 에서 관리하지 않는다 — 각 backend 는 config.docker.toml 의 접속 정보대로 +# 외부 PostgreSQL 에 연결한다(호스트에 떠 있는 로컬 postgres, 또는 따로 실행 중인 docker postgres). +# 컨테이너에서 호스트의 DB 에 접속할 때는 host.docker.internal 을 쓴다. +# +# docker compose up -d +# negosium 서버: http://localhost:9300/docs +# negodata 서버: http://localhost:9400/docs +# +# DB 준비(최초 1회): postgres-init/01-schema.sql 을 대상 DB 에 적용한다. +# psql -h -p -U -f postgres-init/01-schema.sql +# (negosium_db / negodata_db 와 tbl_account 생성) + +services: + negosium-backend: + build: ./backend + container_name: negosium-backend + environment: + APP_ENV: docker + ports: + - "9300:9300" + # 컨테이너에서 호스트의 DB 로 접근 (config.docker.toml 의 host.docker.internal) + extra_hosts: + - "host.docker.internal:host-gateway" + restart: unless-stopped + + negodata-backend: + build: ./negodata/backend + container_name: negodata-backend + environment: + APP_ENV: docker + ports: + - "9400:9400" + extra_hosts: + - "host.docker.internal:host-gateway" + restart: unless-stopped diff --git a/front/README.md b/front/README.md new file mode 100644 index 0000000..53a5f28 --- /dev/null +++ b/front/README.md @@ -0,0 +1 @@ +마지막 내 도리는 하자 . \ No newline at end of file diff --git a/negodata/backend/.dockerignore b/negodata/backend/.dockerignore new file mode 100644 index 0000000..df70479 --- /dev/null +++ b/negodata/backend/.dockerignore @@ -0,0 +1,7 @@ +__pycache__/ +*.pyc +.pytest_cache/ +.git/ +tests/ +loadtest/ +*.md diff --git a/negodata/backend/Dockerfile b/negodata/backend/Dockerfile new file mode 100644 index 0000000..918eb81 --- /dev/null +++ b/negodata/backend/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.12-slim + +WORKDIR /app + +# 의존성 먼저 설치 (레이어 캐시 활용) +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +# docker-compose 에서 APP_ENV=docker 로 config.docker.toml 을 읽는다. +ENV APP_ENV=docker + +EXPOSE 9300 + +CMD ["python", "web_main.py"] diff --git a/negodata/backend/README.md b/negodata/backend/README.md new file mode 100644 index 0000000..87efd66 --- /dev/null +++ b/negodata/backend/README.md @@ -0,0 +1,61 @@ +# Negodata Backend + +DerbyMasters_Server 아키텍처를 이식한 FastAPI 골격. 기능은 **JWT id/pw 로그인**만 예시 구현. +[negosium-backend](../../backend/README.md) 와 동일 구조이며, 실행·테스트·벤치마크 종합은 [레포 최상위 README](../../README.md) 참고. + +## 디렉토리 구조 + +``` +negodata/backend/ +├── web_main.py # 엔트리포인트 (uvicorn) +├── config/ # 환경설정 (APP_ENV 별 toml 로드) +├── common/ +│ ├── enums.py # ErrorType / DBType / DBWRType / EXCEPTION_* +│ ├── models/gmodel.py # 프로토콜 베이스 (WebPacketProtocol 등) +│ └── database/ +│ ├── db_session_manager.py# ★ DB Read/Write + 람다 실행 핵심 +│ └── model/models.py # ORM 모델 (tbl_account) +├── crud/user_crud.py # DB 접근 (I*CRUD 인터페이스 + 구현) +├── services/auth_service.py # 비즈니스 로직 +└── router/ + ├── router.py # FastAPI app + └── v1/ + ├── auth/{account,protocol}.py # 엔드포인트 / Req_·Res_ + └── validator/dependencies.py # ★ JWT 발급·검증, 해시, RemoveNoneResponse +``` + +## 핵심 패턴 +- **MVC**: router(컨트롤러) → service(로직) → crud(쿼리). crud 는 인터페이스/구현 분리. +- **Depends 주입**: service 가 `IUserCRUD = Depends(UserCRUD)`, router 가 `Depends(AuthService)`, 인증은 `Depends(IsValidAccessToken)`. +- **DB Read/Write 분리**: `DBType` × `DBWRType` 조합마다 별도 async 엔진(조회=Read, 변경=Write). +- **람다 DB 실행**: service 는 세션을 직접 열지 않고 람다를 매니저에 넘긴다. 세션/트랜잭션은 매니저가 책임. + ```python + err, acc = await DB_SESSION_MNG.execute_lambda( # 단일 조회 + tbl_account.DBType(), DBWRType.DB_READ.value, lambda s: crud.get_account_by_id(s, id)) + err = await DB_SESSION_MNG.execute_lambda_run( # 여러 변경 = 1 트랜잭션 + [tbl_account.DBType()], [lambda s: crud.update_last_login(s, uid)]) + ``` +- **비동기**: 전 계층 async/await, SQLAlchemy async + asyncpg, task 단위 scoped session. +- **Protocol 규약**: 모든 패킷 `WebPacketProtocol` 상속, `Req_*`/`Res_*`(응답엔 `result: ErrorInfo`), 라우터별 `protocol.py`. +- **ResponseNone**: 응답의 `None` 필드 재귀 제거(`RemoveNoneResponse`). +- **bcrypt 비차단**: `GetHashedPW`/`VerifyPW` 를 `asyncio.to_thread` 로 오프로드(이벤트 루프 비차단). → [벤치마크](../../README.md#성능--벤치마크) + +## 엔드포인트 +| Method | Path | 설명 | +|---|---|---| +| POST | `/v1/auth/create` | 계정 생성 (pw bcrypt 해시) | +| POST | `/v1/auth/login` | 로그인, access/refresh 토큰 발급 | +| POST | `/v1/auth/refresh_token` | access 토큰 재발급 (refresh 필요) | +| GET | `/v1/auth/me` | 내 정보 (access 토큰 필요) | + +## 실행 / 테스트 +```bash +# 레포 최상위에서 docker compose up -d (backend만; DB 는 외부 PostgreSQL). 상세는 루트 README. +cd negodata/backend +pip install -r requirements.txt # 실행 +python web_main.py # APP_ENV 기본 local +pip install pytest pytest-asyncio httpx # 테스트 도구 +python -m pytest +``` +- 서버: http://localhost:9400/docs +- 환경: `config.{local,test,docker}.toml` (`APP_ENV` 로 선택, docker 는 DB 호스트=`host.docker.internal`, database=`negodata_db`) diff --git a/negodata/backend/__pycache__/conftest.cpython-314-pytest-9.0.3.pyc b/negodata/backend/__pycache__/conftest.cpython-314-pytest-9.0.3.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0ed4957ae2dc4bbce687cca034ee5d7b84e7b10d GIT binary patch literal 3882 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 literal 0 HcmV?d00001 diff --git a/negodata/backend/common/__pycache__/enums.cpython-314.pyc b/negodata/backend/common/__pycache__/enums.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bc32e2fb31b8ab1a6d241db2e2a2726c3dc1ebd6 GIT binary patch literal 3345 zcmbVO>rWe37QbW058k8!!XtqpMD4NMork% zO>~p1YLv!wH)=sWh^sn;IC`3TwaW0r=4rYIO`E-Sg@DD?+fdvWcF#dJ70!z*XMPQC zL>^q7&(Un?rSdsBDk1mJQJc@vRvO*dKPN{eO33|l9Mrqb0^MdU>9%IwY6EL^iM55R=|z3D|0dVzGbuD1jYkwZ5XZ4T zN~s^^c`y53P@Ko%STtCA<$QmkXOF;|K+_0Q9Kt5Q1y^wtru_(;{S>zNYp`|N)`MQn z`$9jKYBgnAZJAc*58?VUOGBB~$eC~xSA`F7G=7OQ*2Y!i zcH>to)vP4NB>u|082>BWP)jbW8%ajHm(=cl#pE{Da}T!}?fcv6!g4>uiLx*$&UgdC z%OdtlF&qoV=K3X3j?ZC6yL*dKZ*1l^zF>C#u&J(WYb%@DM_W7Bx77Q~B%pd@O}(|O z-uSe?h7^^NaRA3^G5DI@q#MeY@5b=} zq#OjBV~k@R&MCp?7-xmE?3B^~Mwk1%W6b5$b>hg5d-*BB>EInoW63c;U+9`PtG1#6*r?CUNcuk3+`#yrDkS*I}JV>zWo5J=aAgF*mfDLfO$(2;^< zgBS3OjF)oGJ|T;eY>xyac!^g6(MxuxC|}00dHcID>9Rc-3yJpFFJr%mikD(?I3BT+ zO(=i^_8HRE(GWl)kyz9&0t?9f^H(G*#MOlbHu6mnSJ6|G$!vRKLyb*|GmmTQ63!p6%#bdO1ZO%C@vGoKLv3&20-KU~TPKm`!*9)7F`yo3owWO9LsoE!%!%sWnBn zg1r}j5S}aJEEo#?dDaC>31=P8a~6foz$q543K)@AEY2aF7`_Ft`p0dUJH|Og1`{T- zpHaX3LcO`Au3m$Y20KCiq>s^-xAl4d^DXVhJ)i(ayLxly&N6XSKig*1^~If!7r~(O z0ExDqVASi2+DDt}`-|!Wz~5cVZLJeF(H`A~)15nuxrg_d(2O99vJ50FL;}&kB~fCu zl|@Ef{YEFAg|C4#)YT6dfRiW%rqO%D-Dj2H%LPf~Lq@74f_!jD^&k{_+~IOdJwzvc zWYn{l>|QB|XU+n)b;fx`eY~-c?Y0T>KuWRMc2+7+Iix5bP^o9{{M_G|LTY8%;{kI zbTD%|oIV{+Nxz32GNtDri>|#vGRo8&B;zX=A!F~Z#Z1*-<987(yG=B8;2Qo9f<^D^ F{{ituW}g56 literal 0 HcmV?d00001 diff --git a/negodata/backend/common/__pycache__/logger.cpython-314.pyc b/negodata/backend/common/__pycache__/logger.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0295f37d3254077de2b23f11c012564f0404e117 GIT binary patch literal 3244 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 literal 0 HcmV?d00001 diff --git a/negodata/backend/common/database/db_session_manager.py b/negodata/backend/common/database/db_session_manager.py new file mode 100644 index 0000000..c73a83e --- /dev/null +++ b/negodata/backend/common/database/db_session_manager.py @@ -0,0 +1,198 @@ +from asyncio import current_task + +from sqlalchemy.orm import sessionmaker +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_scoped_session +from sqlalchemy.util._collections import immutabledict + +from common.database.model.models import MAIN_BASE +from common.enums import DBType, DBWRType, ErrorType +from common.logger import LOG +from common.singleton import Singleton +from config.server_configs import main_db_config + + +class DBSessionManager(Singleton): + """DB 세션/엔진 관리자 (싱글톤). + + 핵심 패턴 + - DBType(논리 DB) x DBWRType(Read/Write) 조합마다 별도 async 엔진을 둔다. + => 조회는 Read 복제본, 변경은 Write 주 DB 로 자연스럽게 분리된다. + - 비즈니스 로직(service)은 직접 세션을 열지 않고 "람다"를 넘긴다. + execute_lambda : 단일 쿼리 (주로 조회) + execute_lambda_run : 동일 DB 의 여러 변경 쿼리를 한 트랜잭션으로 commit + 세션 open/close 와 commit/rollback 은 매니저가 책임진다. + """ + + def __init__(self): + if DBSessionManager.is_init(): + LOG.e_no_callstack("already init DBSessionManager") + return + DBSessionManager.set_init() + + self.__DB_URL_MAP = {"postgresql": "postgresql+asyncpg"} + # 종료 시 dispose 하기 위해 생성한 엔진을 모아둔다. + self.__engines = [] + # 논리 DB -> config. DB 가 늘어나면 여기에 추가만 하면 된다. + self.__db_type_map = { + DBType.MAIN.value: main_db_config, + } + + # Write 엔진 맵 + self.__write_session = { + DBType.MAIN.value: self.create_engine(DBType.MAIN.value, DBWRType.DB_WRITE.value), + } + # Read 엔진 맵 + self.__read_session = { + DBType.MAIN.value: self.create_engine(DBType.MAIN.value, DBWRType.DB_READ.value), + } + + def create_engine(self, db_type: int, db_wr_type: int): + db_config = self.__db_type_map.get(db_type) + if not db_config: + raise ValueError("Invalid database type") + + if db_wr_type == DBWRType.DB_READ.value: + pw = (":" + db_config.read_pw) if len(db_config.read_pw) > 0 else "" + db_url = f"{self.__DB_URL_MAP[db_config.db_type]}://{db_config.read_id}{pw}@{db_config.read_host}:{db_config.read_port}/{db_config.name}" + LOG.i(f"Read DB create engine url : {db_url}") + else: + pw = (":" + db_config.write_pw) if len(db_config.write_pw) > 0 else "" + db_url = f"{self.__DB_URL_MAP[db_config.db_type]}://{db_config.write_id}{pw}@{db_config.write_host}:{db_config.write_port}/{db_config.name}" + LOG.i(f"Write DB create engine url : {db_url}") + + engine = create_async_engine( + db_url, + echo=db_config.show_log, + pool_size=db_config.pool_size, + max_overflow=db_config.max_overflow, + pool_pre_ping=True, + pool_recycle=600, + ) + self.__engines.append(engine) + scoped_session = async_scoped_session( + sessionmaker(engine, class_=AsyncSession, expire_on_commit=False, autocommit=False, autoflush=False), + scopefunc=current_task, + ) + return scoped_session + + async def dispose_all(self): + """모든 엔진의 커넥션 풀을 정리한다. 앱 종료/테스트 종료 시 호출한다. + 호출하지 않으면 풀 커넥션이 이벤트 루프 종료 후 GC 되며 경고를 남긴다. + """ + for engine in self.__engines: + await engine.dispose() + + # ---- 세션 lifecycle ------------------------------------------------- + async def start_session(self, db_type: int, db_wr_type: int) -> AsyncSession: + if db_wr_type == DBWRType.DB_WRITE.value: + return self.__write_session[db_type]() + return self.__read_session[db_type]() + + async def end_session(self, db_type: int, db_wr_type: int): + if db_wr_type == DBWRType.DB_WRITE.value: + await self.__write_session[db_type].remove() + else: + await self.__read_session[db_type].remove() + + # ---- 저수준 DB 연산 (crud 에서 호출) -------------------------------- + async def run(self, db: AsyncSession, err_msg="DB Run Failed", raise_error=True) -> ErrorType: + try: + await db.commit() + return ErrorType.SUCCESS + except IntegrityError as ex: + await db.rollback() + LOG.e_no_callstack(f"duplicated. {ex}") + return ErrorType.DB_ALREADY_SAME_KEY + except Exception as ex: + await db.rollback() + err_type = ErrorType.DB_RUN_FAILED + LOG.e_no_callstack(f"[{err_type.name}] {err_msg=}, {ex=}") + if raise_error: + raise RuntimeError(err_type.name, err_msg) + return err_type + + async def insert(self, db: AsyncSession, obj, err_msg="DB Failed", raise_error=True) -> ErrorType: + try: + if isinstance(obj, MAIN_BASE): + db.add(obj) + elif isinstance(obj, list): + db.add_all(obj) + else: + raise RuntimeError("DO NOT USE QUERY IN DBJOB") + return ErrorType.SUCCESS + except Exception as ex: + await db.rollback() + err_type = ErrorType.DB_RUN_FAILED + LOG.e_no_callstack(f"[{err_type.name}] {err_msg=}, {ex=}") + if raise_error: + raise RuntimeError(err_type.name, err_msg) + return err_type + + async def add(self, db: AsyncSession, query, err_msg="DB Operation Failed", raise_error=True) -> ErrorType: + """update/delete 등 비-select 쿼리 실행.""" + try: + if hasattr(query, "column_descriptions"): + raise RuntimeError("DO NOT USE SELECT QUERY IN DBJOB") + await db.execute(query, execution_options=immutabledict({"synchronize_session": "fetch"})) + return ErrorType.SUCCESS + except IntegrityError as ex: + await db.rollback() + err_type = ErrorType.DB_ALREADY_SAME_KEY + LOG.e_no_callstack(f"[{err_type.name}] {err_msg=}, {ex=}") + return err_type + except Exception as ex: + await db.rollback() + err_type = ErrorType.DB_RUN_FAILED + LOG.e_no_callstack(f"[{err_type.name}] {err_msg=}, {ex=}") + if raise_error: + raise RuntimeError(err_type.name, err_msg) + return err_type + + async def execute(self, db: AsyncSession, query, err_msg="DB Query Execution Failed", raise_error=True) -> tuple[ErrorType, list]: + """select 쿼리 실행 후 결과 리스트 반환.""" + try: + if not hasattr(query, "column_descriptions"): + raise RuntimeError("DO NOT USE NON-SELECT QUERY IN DBJOB") + res = await db.execute(query, execution_options=immutabledict({"synchronize_session": "fetch"})) + return ErrorType.SUCCESS, res.scalars().fetchall() if 1 == len(query.column_descriptions) else res.all() + except Exception as ex: + err_type = ErrorType.DB_RUN_FAILED + LOG.e_no_callstack(f"[{err_type.name}] {err_msg=}, {ex=}") + if raise_error: + raise RuntimeError(err_type.name, err_msg) + return err_type, [] + + # ---- 람다 실행 진입점 (service 에서 호출) --------------------------- + async def execute_lambda(self, db_type: int, db_wr_type: int, func): + """단일 쿼리 호출. func(session) 한 개를 실행하고 결과를 그대로 반환.""" + s = await self.start_session(db_type, db_wr_type) + try: + return await func(s) + finally: + await self.end_session(db_type, db_wr_type) + + async def execute_lambda_run(self, db_type_list: list[int], func_list: list): + """동일 DB 의 변경 쿼리 여러 개를 한 트랜잭션으로 실행 후 commit. + 하나라도 SUCCESS 가 아니면 즉시 중단(rollback)된다. + """ + temp_list = list(set(db_type_list)) + if len(temp_list) != 1: + return ErrorType.DB_INVALID_TYPE + + db_type = temp_list[0] + s = await self.start_session(db_type, DBWRType.DB_WRITE.value) + try: + for func in func_list: + err_type = await func(s) + if err_type != ErrorType.SUCCESS: + return err_type + return await self.run(s) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED + finally: + await self.end_session(db_type, DBWRType.DB_WRITE.value) + + +DB_SESSION_MNG = DBSessionManager() diff --git a/negodata/backend/common/database/model/__pycache__/models.cpython-314.pyc b/negodata/backend/common/database/model/__pycache__/models.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ad3c9cd063670495e5dc6f4d409465a5542f38a1 GIT binary patch literal 1578 zcmZ8h&2Jk;6rc6Sdi@pKAx@-75T_KitI}+RQrZJhk|xMTN+=tb1Hu`t*AsV>{YYkK zlbT+90&yZ$Bov8Dj^S3sC0G7~pctWyR6zwCxTOk0+~CcwG25M%sjOd5KZWZS&Nu=h?(?xiV)kQnwre5fY3%Wbl>e zX6v1GV)m~OKdJuaRFM!epuh%Vl8#txIm_as$U}{UE{&b1oP;yEk(xveMUTNRe(Nml zbZE^?qUGz;5KAmQ0f^)PksToZyMU1O)BuqiATGwZh{%VCA&8u~;8zH(MLp3d0intu zdU_CfI6}O1p1Bo^GG(vmSvK+sVr4xC6S>zA$ zUO&{pAZ*Z0FE0o$Evj)UT8<|cW@?K(xoOw|`3{{5EH||KKB2zm81ST|W4LW?j`(ZL zTi3Qcx~7?4i)h|k-gTF>J>Lo(O(-pc8CugY*NEE!h~s#!CTyZ(GzVPlNcvOjo#^i2 z3nflPt&xZO{b3MY^hcrm^`oyI{Zgp>RHz&(61pcpeX|7L|n-vsyg1z6FI@=9<7!F*7`4 z`M7C&z;}x(!YwVVdE4+Aw!OCHVuSHC5Cd~4)H24=^{|0iZ3c|^kW&d`m~P*1Stc`y zZDS0B3Rw+|;3it`eJY-X=FUVG6#p9#|9%ON^;@#^JSHV4Pvb})J`vi9jLMg~3(qpe z?XkVq!R21&PWSF{y6{-uraPIv`%k8O>D%4e$l~v0_sA1HvJ}dXS9bNC)%}|XmwJVF zyVc{;*lue7je~Npbf^2_vyt)L+xz6lxx*Scnp^3Ow7Uz(#nJ7D-zN@letdL8?-iH6 zsD<4UX1k~k@gaZ1Hf-|&aXM2Tb*T8u;F)vdIqxCmA;BT3FL(kzA~_ee_Y`ph$B*7I zE#?T%&MeJG6p22GauBp}<(;TU-lI2QEBN%^1aT6VBmm0qP9v-ATI1ek8NA zX-zLafjE&W63Q{ha6l@zT=@@zVuUhM1r>1MmMREwgEza5<8~~~zIpHW-n{pl_k3lj zm_x9>-_zE2QwaSo4(BV~6VBE_=%5v(OLbI}m^3crL_JYUvScmAQnfTo*JLKwGAt9> zl69q)W!Xqh)pNBx%a5a`G>N8rAz7h{CXt>VL%Q57OebSCE9#lok*fS9j;h4V7BL-z z8qD4%xMc)HO>=q9cS6_WnYzcw8lhZXV$}B5cxKl39b$M~nKu||*e-zxDMlVL_{#IM zjrJxnd)G&wOz&`}NC+8FU=uM(M=Y_DXUS3Iqh?B%#x7D$gH3K`CQ(z-6YxvkI8Qqp zX>*fkBMN=QV2nXTF+vPMtvD*fo$ ze&pd8@sd4vBN1oHUeWVxX9`+utQf9f@Ls6YZ>MS@hpJ2uJ36=Cpt!R!Nt+scCh74ZMNg9 zhX*R0iaVnS*9XI}bNk(Re)LOe;-}KYkupImpl+xMp5LT4WY)$Tq|J*)$b8!~ zDRGI%?toeGLdP*$4&id>*;^suNKMdDQCG$yt48RsFG_DMH|B7ozHob~F|%-2JqHXy z+1rHT9!{0$CGewV*cR1Mu3+pLF2R`RFoudkN5} zGk20LJ(hRqZf<}1$#ge+vojlOg5CT+d7{UfQuXobp1!+&aQ*OdxAcCec2XJJ%N)FU zSnXDBbv}MJa%u180r_$MsQ&5k{Ab;fd!2=o^61XPA5urx?;KzIs9UamS&v#I%63s5 z;zQ7DhGX6*ZhOk7E){G3O<9kVLMGqlJpc^`4!FljAovq@jub{a~Tz1JubY~$^I>u Oq`I`D{)5CfiuOPID2lTH literal 0 HcmV?d00001 diff --git a/negodata/backend/common/database/model/models.py b/negodata/backend/common/database/model/models.py new file mode 100644 index 0000000..0da66c6 --- /dev/null +++ b/negodata/backend/common/database/model/models.py @@ -0,0 +1,26 @@ +from sqlalchemy.orm import declarative_base +from sqlalchemy import Column, Integer, String, Boolean, DateTime +from sqlalchemy.sql import text + +from common.enums import DBType + +# 모든 ORM 모델의 베이스. insert 시 isinstance 체크에도 사용된다. +MAIN_BASE = declarative_base() + + +class tbl_account(MAIN_BASE): + # 모델이 자신이 속한 논리 DB 를 알려준다 (람다 실행 시 DBType 으로 세션 선택). + @staticmethod + def DBType(): + return DBType.MAIN.value + + __tablename__ = "tbl_account" + + uid = Column(Integer, primary_key=True, autoincrement=True) + id = Column(String(45), nullable=False, unique=True) # 로그인 ID. 중복 가입 방지 위해 unique. + pw = Column(String(255), nullable=False, default="") # bcrypt 해시 저장 + nickname = Column(String(45), nullable=False, default="") + is_blocked = Column(Boolean, nullable=False, default=False) + # PostgreSQL UTC now: now() 는 timestamptz 이므로 utc 로 변환해 timestamp 로 저장. + last_login_at = Column(DateTime, nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) + create_at = Column(DateTime, server_default=text("(now() AT TIME ZONE 'utc')")) diff --git a/negodata/backend/common/enums.py b/negodata/backend/common/enums.py new file mode 100644 index 0000000..8793ea9 --- /dev/null +++ b/negodata/backend/common/enums.py @@ -0,0 +1,61 @@ +from enum import Enum, auto + +from fastapi import HTTPException + + +class ErrorType(Enum): + """서버 전역 결과 코드. Res_WebPacketProtocol.result 에 담겨 클라이언트로 전달된다. + HTTP status 와 겹치지 않도록 구간을 분리해서 관리한다. + """ + + SUCCESS = 0 + FAIL = 1 + + # DB / Redis 에러 + DB_RUN_FAILED = 10 + DB_ALREADY_SAME_KEY = auto() + DB_INVALID_KEY = auto() + DB_EMPTY_DATA = auto() + DB_INVALID_TYPE = auto() + + # 요청/직렬화 에러 + JSON_PARSE_ERROR = 100 + INVALID_REQUEST_DATA = auto() + INTERNAL_EXCEPTION = auto() + + # http 에러 코드와 겹치지 않게 설정 - router 전용 예외 발생 옵션 + HTTP_INVALID_CLIENT_REQUEST = 419 + HTTP_TO_MANY_REQUEST = 429 + HTTP_INVALID_CLIENT_ACCESS = 433 + HTTP_ACCESS_TOKEN_EXPIRED = 434 + HTTP_REFRESH_TOKEN_EXPIRED = 435 + HTTP_INVALID_TOKEN_ACCESS = 436 + + # 계정 관련 에러 + ACCOUNT_INVALID_INFO = 1200 + ACCOUNT_ALREADY_EXIST = auto() + ACCOUNT_BLOCKED_USER = auto() + + +# ErrorType 의 HTTP_* 값과 status_code 를 맞춰 router 단에서 raise 한다. +EXCEPTION_INVALID_CLIENT_REQUEST = HTTPException(status_code=ErrorType.HTTP_INVALID_CLIENT_REQUEST.value, detail=ErrorType.HTTP_INVALID_CLIENT_REQUEST.name) +EXCEPTION_TO_MANY_REQUEST = HTTPException(status_code=ErrorType.HTTP_TO_MANY_REQUEST.value, detail=ErrorType.HTTP_TO_MANY_REQUEST.name) +EXCEPTION_INVALID_CLIENT_ACCESS = HTTPException(status_code=ErrorType.HTTP_INVALID_CLIENT_ACCESS.value, detail=ErrorType.HTTP_INVALID_CLIENT_ACCESS.name) +EXCEPTION_ACCESS_TOKEN_EXPIRED = HTTPException(status_code=ErrorType.HTTP_ACCESS_TOKEN_EXPIRED.value, detail=ErrorType.HTTP_ACCESS_TOKEN_EXPIRED.name) +EXCEPTION_REFRESH_TOKEN_EXPIRED = HTTPException(status_code=ErrorType.HTTP_REFRESH_TOKEN_EXPIRED.value, detail=ErrorType.HTTP_REFRESH_TOKEN_EXPIRED.name) +EXCEPTION_HTTP_INVALID_TOKEN_ACCESS = HTTPException(status_code=ErrorType.HTTP_INVALID_TOKEN_ACCESS.value, detail=ErrorType.HTTP_INVALID_TOKEN_ACCESS.name) + + +class DBType(Enum): + """논리 DB 구분. 모델마다 DBType() 으로 자신이 속한 DB 를 반환한다. + DB 가 늘어나면 여기에 추가하고 db_session_manager 의 맵에 등록만 하면 된다. + """ + + MAIN = 1 + + +class DBWRType(Enum): + """Read / Write 접속 구분. 조회는 DB_READ, 변경은 DB_WRITE 엔진을 사용한다.""" + + DB_READ = 1 + DB_WRITE = 2 diff --git a/negodata/backend/common/logger.py b/negodata/backend/common/logger.py new file mode 100644 index 0000000..802f664 --- /dev/null +++ b/negodata/backend/common/logger.py @@ -0,0 +1,43 @@ +import sys +import traceback +from datetime import datetime, timezone + + +class _Logger: + """원본 DerbyServer LOG 인터페이스를 간소화한 버전. + LOG.i / LOG.d / LOG.w / LOG.e_no_callstack / LOG.SetPrefix 를 제공한다. + """ + + def __init__(self): + self._prefix = "" + + def SetPrefix(self, prefix: str): + self._prefix = prefix + + def _now(self) -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + + def _write(self, level: str, msg): + head = f"{self._now()} [{level}]" + if self._prefix: + head += f"[{self._prefix}]" + print(f"{head} {msg}", file=sys.stderr if level in ("WARN", "ERROR") else sys.stdout) + + def d(self, msg): + self._write("DEBUG", msg) + + def i(self, msg): + self._write("INFO", msg) + + def w(self, msg): + self._write("WARN", msg) + + def e(self, msg): + self._write("ERROR", msg) + traceback.print_stack() + + def e_no_callstack(self, msg): + self._write("ERROR", msg) + + +LOG = _Logger() diff --git a/negodata/backend/common/models/__pycache__/gmodel.cpython-314.pyc b/negodata/backend/common/models/__pycache__/gmodel.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..411a1d0352894dfcb720c857f35f7f147a28bb29 GIT binary patch literal 4521 zcma)9>u(g-6~D7HyF2@^*E|f)8t`HhD~lh?!&PWO0R@NHc{t9Rm`076jCaTG;`NMk z=WdXbFH1_P3#BzGRZRp6F3Uv#0l z(wsSS?wPsgp4a_NS7U>pz&QHL*pY903Hcl!O2gwY#p}Ri$zc)~1_{HvYfu<)F;^S% z;-EMnF=@cf-27P@^bB~Jw~aKD%_Qz#PvV|Fm;EIsbD0-p@|ZLx_eqZ2$K^hd`)lNW zE)Re_IOgvY9qj;@Hh{FTN*d(S5J;Qg%O%~0xX?kOtG>WjhoXWdzcj;AhOVZgF3b0{ zYHBYUNiA);52Unoa!Ig!&roX6gR?VQ!d}g$g7Z)e1Cu3Vgb8uNT!(#3j0;SPyO=vJ z!a|Z27-2M%VEnV09iV;p?D72j=kK0aEPQl6cIVm$h4(KPKD-(!oVt*|a=h@1rAYp_ zbA>-&jNCnTHUI8H;pD~MC9u?E$%>+@)0(1Kenpu!l9@E}K}C5Zqo$pikfKbasL9eP zUDFMi_9{x!NC4BUC?-=`Dxs*1(bRZ`X{Mr340aLsV!axB#nh-7n^q~jJge%Hv3|{* zV#ZADs6nS<3GhqI*kSbO+N5EoGSe|+k}6YU<7#3`)03bwJ#FYQuuV&wu}OaKotdRR z_&z7dm@<$P5wGYX63qxQ-Z*c(#wnJj0Ek^1v8wdO!e= z%=Zx8wRLm@qhzNL7uS=t9G8GLu^^W~@}_0h5cjagQNluTFGN^&Qe;q{Fftl^EdRSp z`JXOE3K!1gPhBhgd_HpL%C);Et`*K-hps(f z2-VC?n)RBQL_#yoNGyU5hg(uJ6A^qf|IXF?;>A0ckLOQa>^+#k3@l|K`U?}h7y+_J z$dXZV*tP38AxV&KATJBw72?8(@EsDvQ zrVblpo9?ZauLDgJUHVm)tPsiFzKrBtir;v&6Q$sS z5^)Tdi@%2lFk8@GMuT6&IX;G~wM#O|VXAJU!f+kwt^oqB(9tz~)Yk%y^1GnH`(Nn| z=RKmAdLN0C4t^~OLs2h<7D+7c$SeEzKQl67x!+XN8O`!KL9`@{p(SB)S`r2lJS~}8 zdV;Qp_qcaEJ8kd!Ml=@ZDPXOsxV9|he%%W%Wy#Gz=r4iR+pSw}w07rOyKl7a$hGdc zoc!aFKOD)m?wjlX(F-?QHoWIQ=LdEWS>HL|T>p;;m%Svo!45%n?%>vW?&!eO&z%^- z+<{Bz#&+k7-LAOM3A{7sdSK2~H77nWC*mA-M~eHR5hRUSaW9Q>7Jal zec6Wse_62Y%K!+tbE0C!6mHp2w220AjUbW+B-nE(E@zvLNkBItX+y$y(vBQ%hdvI( zT2)mf6B!*kGQ}o^@^ZZ;v~*fgmIQl?7^8hWHz?KVC4z1$x`CV^w@SwWPJS)3 zI%~{oj%#ly4>65y!v(RjtBlx*k4P{Z>Gtv@ifY!!62Vb?8c5A?wADV2#-z<;%pDh= zbR5V$006v|LxjOG!hCRK_~R~cq1ysm&HuF(;7BN`O{j1VC=+Ue8FY52rq7pa6%Bb* ztya}0_WioGiLvDMXL;yq*Tf%vfus035S6HKz?K2uHl6`+yfSH2 z0MOtA`EzXkvaPAd=H0xFiI(n-_uOdaKSKf)^f+n}?S zIHk>6KBxTy)bfCO=hp5>#D-fGOWayjS@6uav7~*1KLxr`;s5swiVy~`N@@u1=rRFvZVkUx3<51aFmvIOdH1bO zjC$B`GvK{?NU+b@UL|zW5!28ucQP|QV`2bp#0#6ixbY6+ATuvB6t3G;4u&J)8-eU2 z5Q@;6pS*SQt%WV`^_=VZYv_rO@XzR_LvU28+F>h=wM2}%jT*Fq;t%%qZ* z3ocntC8iK)La|2S^ItO-crKc#U&2iOhGPRIxa12Sd$xp>LaUFxSOH0PTd3cp5v1_! zr7s~@+n>un7&h1eV*#R9fx3MT vzJ=ZQ2{89N1;MxATZAWI?#n None: + super().__init__() + for dictionary in args: + for key in dictionary: + setattr(self, key, dictionary[key]) + for key in kwargs: + setattr(self, key, kwargs[key]) + + def to_json(self) -> str: + return json.dumps(self.__dict__) diff --git a/negodata/backend/common/singleton.py b/negodata/backend/common/singleton.py new file mode 100644 index 0000000..47d87e6 --- /dev/null +++ b/negodata/backend/common/singleton.py @@ -0,0 +1,16 @@ +class Singleton: + _init = False + + def __new__(cls, *args, **kwargs): + if not hasattr(cls, "instance"): + cls.instance = super(Singleton, cls).__new__(cls) + + return cls.instance + + @classmethod + def is_init(cls): + return cls._init + + @classmethod + def set_init(cls): + cls._init = True diff --git a/negodata/backend/common/utils/__pycache__/gtime.cpython-314.pyc b/negodata/backend/common/utils/__pycache__/gtime.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..88aa3959aa42e34a6ce5a80a57d7e219275460b3 GIT binary patch literal 2443 zcmcIl-D@0G6u&dGAColcBsKl;qq=F>no*Ns*Fu(3s?vZfv>{H~NNJm4vNK7y*_}!6 z+%;_M!y*f!71}<8wsyq|J~W_EKajlmKP053I4=tN(sl!p1=B~*xwE_3v|ttV!kjr@ zcg{V(bIzStw|BM^SaUbDbMqo0zoF1rp*m9=1g1nLh%UTC(u9$;z=X8O#FxlU(o1x4 zH_@d7up1YX^iUsB!}n28C118oX0vSG_GP5=u4DUc$XRxdnHltItY*AxpfMrh8Nvje zFmWQpq+P^?_$+iLJWgI0FlJ8HC7@jg>ib#bOjz&W))2Rb(V7#Q_Go=?gj=Iq_R4je z%x!I3Y;AfwYab_Uo4yS)>-58KABXc7rXg;%TzU9iIaa+{u3r77di65Mu@lFS#2zf& zu6|XDRWDW^+%8u?2agZGu70+-wr~wBv1+BfworlB&9#e5gR%bVjmrR z)Awx}Tl@6ZTKR`q^~Z(k!cS^OfLQq8LZBshYB3;(gc2Di6Jn}Nhy~gg$h*Q>K^Mk^ zQ$!QsxZ-{E_aoG11?s2*#jUF7N2XkwH<{mQ7@3^uc~&;V48ud`eI@@r?SyAjPs^J$ z>)1upnbwAFZ-%+E+MG*gw2W)nnwxY79DCaJvW2{c43yf`Ce6%@?O5QF&%2IRVA-6f zO=HCeXN!J^VVI8NGH43J@ON+|2U~SA9Kh=V@~A{Mh!B3`4{p z)tJ=L@M8uL$4R3)kc2P-c_4OlHxOTj1g5aVpuL|1g@<+89{?T!l*btLJDQk-gsFPZ z0zue&E)_p6Ui{#m(zl}Y-8{0Y^e@Z(o0&Am?%#neXf0qz^I50BY>xxJDEd;?Vf8tD z8tT|NUJL3e#5r}Hqn`RQ&85w=f-_%;A4mNtF#iPX7V^A`mNN63cReXFzv>8(e1d-SLwH@%%vCipA3hCO7Qvw^iTYgFzJC`#Im~4IlVK=-P+)RA_k>F%{r+_^A2>R*g(D`4( z-5VnOHEI%x>{v%`Jpvr+f?KO;P@9HSQyjV{zmVh#Np3{Cg=CEY;oJTKX datetime: + return datetime.now(timezone.utc).replace(tzinfo=None) + + @staticmethod + def UTCStr(fmt: str = "%Y-%m-%d %H:%M:%S") -> str: + return GTime.UTC().strftime(fmt) + + @staticmethod + def AddMinutes(minutes: int) -> datetime: + return GTime.UTC() + timedelta(minutes=minutes) + + @staticmethod + def AddDays(days: int) -> datetime: + return GTime.UTC() + timedelta(days=days) diff --git a/negodata/backend/config/__pycache__/config_loader.cpython-314.pyc b/negodata/backend/config/__pycache__/config_loader.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2cf5e4664209b5c42ec23c648cd93df2979ce789 GIT binary patch literal 2719 zcmai0%~KRd6z}=kFLqfF6+{Tjha?*fiwdZbiYc%xP=c_L6$P9cb_X0CW;Q*&F?g`{ zkV?!Yu~L;PD;}aZy~W%-n!kVzQs`(ZmCDH*1}b>SA@B9<;)023iq~&mzj^)o{rdf8 z_f|$i1jdzHiAz7pg#3XIrJ)p+)k#qD3v9 zq*aYEYE0*t?x-roO0Hra?zzO6qf;j_r%`xkUbAKs$93m2v$KgSHocrkL;eZ-fc>7O z&)SZWn@gaQ(U_K)*3y@CD+4Z=X>n3bTg&LQBRfxn5T6SlC=NpO1$in5XjRF+sVo~1 zUV>${|N?qaly;3`F$WstOfKQpWb5=0;RCZEB8_fvW>Zp?^!B zR^w{`tY%p@fQK+|FXq@$ZruTXPA!O+d9qFf<*M+4%R8;!%qyKP77N_GH&Db z^hZiZH}X z(cK`*;iC;`Z3Mv!uon~#Qb?(NK|tJZUBT%=+XMFS7wBfc?&njYKwk;M3Xe ze|xtaqEg1w;wcCiByW+d5BBu6k{H=b-Y$lmg_AcBLu?|Aq>29x8Aqi?VnB_WAk(a3 z0DP`Ur-$QySGKdd<@)g&o5r*&BaLzLax0-RbJM&{Id@h-e76z^SU1&AHPZkt)Z>2I z0%q^R2s3UNv^adh``w5brk5m;1*H z{o_kL=N9YF{}Mj`B({G=A;GE@0s&$o{`iASOWlLq{(9r$F7O{%Jq!wGT$s0x5%k(JUa@d1 zp-r#|xi-V(O0zlxo!>V+SvkAX9`q>wc5phtljo0Q2AmqL`R3YJ*OsHrg=q8beT&h= zLLk92jte~Wa6C-$cGEBlY)x8;0%^eenj_`WRunN5xEqavaI4EcWNFoGxh~^p4C{JTSoZ-#a++MPgF63(#pdbq z81WE&7b3VmHlKyh5Qo!4moqfx>;|&)8O?%UKNK6wI|5Gv$;k`FzqNs0@AuRP^e8M~ q#tyQ`iX;faQ&RaG>0Bh8zmm7se1Z_X5nm%Pt=H8EovQ={5A+Xk9Vb=* literal 0 HcmV?d00001 diff --git a/negodata/backend/config/__pycache__/config_models.cpython-314.pyc b/negodata/backend/config/__pycache__/config_models.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7f4c9b91e93d07871f5dc80ce49f2646cf8b51c5 GIT binary patch literal 3061 zcmbVOU2ofD6n>pJ-`~x5mbPOsq>8P9ZNQ|BG1#=NV=W>ZMF=g%P2AS28)vWIt}EPl zfCQJj=NI%3@H^;Tu*3!8YIjRpQ7(Co?F<*n21Lr^bB^Ed^PI=;YuQYa;kWsH>)s=o zv0up<|02GzcMpcmI;<_tu^W<*rdgT2#M+@MYlrVj=fYk)5ptt4qLDx}>_(%A#sX2< zjm8m81fr34@?|!YdO`!vNKWdC**=&(@Lg&9>tcPfmeI%_)Ep}V_j9mz|rB~4gwgfvAu*1F`LR=2m z5wd9&PiR+8%5vAC^wo9Ekq0IhPG-Q(u4Y+E*WBm}C#qYDWf@KkPoY_2JM4rl!JV*< zm-3Qn8Z#j$x@_`w6@3+@Yp9mh)4M`ZESlnk?R?t$7}Hp-b(QOVZAagCh=ZD3beQj8(+t&|hb6+!F9rruB3eMs6 zp(;rKSp{HzB*6SAm>&c4<395fx6Bof^LLy`Pg~kpIhy!qFvWi*K1dzradadGgPh=R z-!cu&Nf_peVrUOEFqIc*Z#e+$39{Wo^N8x5TA7BZJX`1?4pFMCV*rmsMUZ96<4_Ll zXzmh;yyL5((ZWoE*YV^H0_w(_1e6I*b;u_PrU<49jst=TJV8MMcisvNFM*9Iq^;bv z=MHGXamJ|O(HZ+3Zw_UwcsOIoTC^SZbPIL@|0Mhro@x9u$YQpW6uC}GTwN=) zqhbQtEw*Dg{J4{zSM~nY_kB@I4opj|aLu}J@F*6bpU27mgV6ne_y02ABzj|^BYP}# zB!FX~qqr+{zDP!W0i<}$x2b(R5Kjc`q;HdWrULEL0XsuBww?7j7wau4Vru{qb)_`9 z$#qE5DvrSMZC705UR>`vzBSl%5?oVzUQ^F>TgKcd8hQrW#jRCyQvsQs!~n9ZSo#;5 zlU`RJDkeT9%Z9nhvslyfiCquo=n%KvLfFOt9=9|39eMhhJ}!*)kz}(r)<<$0V|}Cw zsEkNLMrr0b8FI2LU)+(aqZ~PT*Qt*R(E1hzaXl2h`XGC38NL!ZW|N>0-sV3M3E zR;=L7?lhcWrIn7Xv{Tp)KTR-8&?0!9;0*#&8Ge?4B#pmG@D{<_1m_7}P@pr^hQK|% z4`Fx(TAa485AzdH*hewd-Cq*0e1r#kFN<#i~M1!GEDW9OztA*7d$KF@EpI zdg>O4PD0M}fajCGJl^6V!jlYJlyr~q#L}8ac<*lD-w0~Qs1W=cLA?oQmu>_>@}-U> z-#fIpO9bx{Tqbw{pDs`v0(WgB=pLV37P+78$>|^E@^A`RIiO{XDMFnVaJMXZH>Fwc z0ku$j8t1q-`qu&Xlj73Wor^wG-cIPo;KVO_cbH533S7EMSX2g2!;&QZ%uek`BU0n> o+CIZ$KOd4NAE)*i9{bsxbnNkkeTK*WE3$O_yDxuZc(^0~0m3|_!~g&Q literal 0 HcmV?d00001 diff --git a/negodata/backend/config/__pycache__/server_configs.cpython-314.pyc b/negodata/backend/config/__pycache__/server_configs.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3b116f2960e209e2ca465d74ce02d46c58880cd7 GIT binary patch literal 1806 zcmaJ>-%ne06hGhoy8T6=jyVvpWsD!4*qS;sHrW*1WE63x1q}rGE$?5>5N^b zkeQl87HSD`lnYs@)dLaI`$Ey>)4TWRQ@+TBO=0sXjKvOhpj}mgKTLaT4od|-;yOhN z94&u_RecGo@~|c1a6*_18V{nnpJCrv!rons+N?juDgbIBa-<%@)`%ND+w%VHL30r~ zN+QO3h#Xkb%1C9YPDBnY=+OvvcAQ`zOCv2s-}FU9lII|~1LQG88lHo+9w4g_X=KPS zX#k(|A*-~OsoSsIDQRG&#J|3n<>6P2r?QqXC(_vM(*IzJHN(uGc4vUaEF$MU!+ zXnYr_6pTCliw%43nF=ms`E-eLO#(TmC7x{DU|5)fc&2 zKR&ws<)hp8&U&Zp-WJ`2dATz-7!p4I~96E{WF!DHgaOs^e6i2&80XJTNRr#ef`?UzNm|ti~sk0meiH zDg=gvVaz3p5Vl)^{}iJ7C$4EuzL=1R*&M-mET;M)k~GZPrfpBWKsw1bbS%8J1s!|! zKda{|bjZD?C?Zb92#Bhxeid3_s#3oTm^{JR-7`{)vUFDdv% zgJzIbty15u!o??KAu1{g+noPDC9%MSTB=tXhl?3kWJxo|M5?esYNiQ+j%r*qu1G>s z)T}%|5>H@tnnRxnk58IuOpZ%fz8;-HG>iCIT&1e!zJyYbOfSikQuLyt$cm z5K0*%E=I~3VQuq~d}aBOy{)j{vu16_#3vKtd8Glxd|&@U=AT_7A>=;T(zZXLNj$jGGto4$^0d1u=BfV0h}rc<*wr*Ga#{K_?Ka}B@QYc^j#w~^Qw{Q2x=e`NFW z;4T19LI%9*x(qlIM>pfm-G<-o?mMkny9dKIwthG8?ZCPx+t{%Q?ksfdRC!h$OODm9 wY?VK4&w_u))3kDZ>H1oG*3*`DWua}S?!?N#(!g3(wl25{6~91Gsl;0U1r5TX^Z)<= literal 0 HcmV?d00001 diff --git a/negodata/backend/config/config.docker.toml b/negodata/backend/config/config.docker.toml new file mode 100644 index 0000000..b00384d --- /dev/null +++ b/negodata/backend/config/config.docker.toml @@ -0,0 +1,33 @@ +[WebServerConfig] +server_name = "NegodataServer" +port = 9400 +process_count = 4 +is_ssl = false +is_test = true + +[LogConfig] +print_console = true +log_level = "debug" + +# docker-compose 네트워크에서는 DB 가 compose 밖에 있으므로 호스트의 DB 에 host.docker.internal 로 접근한다. +[MainDBConfig] +db_type = "postgresql" +name = "negodata_db" +write_host = "host.docker.internal" +write_port = 5432 +write_id = "postgres" +write_pw = "password" +read_host = "host.docker.internal" +read_port = 5432 +read_id = "postgres" +read_pw = "password" +show_log = false +# 워커 4개 기준: (pool_size + max_overflow) x 2(R/W) x 4 = 320 < max_connections(500) +pool_size = 20 +max_overflow = 20 + +[JwtToken] +access_key = "docker_access_secret_key" +refresh_key = "docker_refresh_secret_key" +access_expire_min = 30 +refresh_expire_day = 7 diff --git a/negodata/backend/config/config.local.toml b/negodata/backend/config/config.local.toml new file mode 100644 index 0000000..bd5fb04 --- /dev/null +++ b/negodata/backend/config/config.local.toml @@ -0,0 +1,32 @@ +[WebServerConfig] +server_name = "NegodataServer" +port = 9400 +process_count = 1 +is_ssl = false +is_test = true + +[LogConfig] +print_console = true +log_level = "debug" + +# DB Read/Write 분리. 단일 DB 환경에서는 read/write 동일 호스트로 설정. +[MainDBConfig] +db_type = "postgresql" +name = "negodata_db" +write_host = "127.0.0.1" +write_port = 5432 +write_id = "postgres" +write_pw = "password" +read_host = "127.0.0.1" +read_port = 5432 +read_id = "postgres" +read_pw = "password" +show_log = false +pool_size = 100 +max_overflow = 200 + +[JwtToken] +access_key = "CHANGE_ME_ACCESS_SECRET_KEY" +refresh_key = "CHANGE_ME_REFRESH_SECRET_KEY" +access_expire_min = 30 +refresh_expire_day = 7 diff --git a/negodata/backend/config/config.test.toml b/negodata/backend/config/config.test.toml new file mode 100644 index 0000000..f0ff5a8 --- /dev/null +++ b/negodata/backend/config/config.test.toml @@ -0,0 +1,33 @@ +[WebServerConfig] +server_name = "NegodataServer-Test" +port = 9401 +process_count = 1 +is_ssl = false +is_test = true + +[LogConfig] +print_console = true +log_level = "debug" + +# 테스트는 단일 postgres 의 negodata_db 를 사용한다. +# pytest 가 tbl_account 를 TRUNCATE 로 비워 격리하므로, 운영 데이터가 있다면 주의. +[MainDBConfig] +db_type = "postgresql" +name = "negodata_db" +write_host = "127.0.0.1" +write_port = 5432 +write_id = "postgres" +write_pw = "password" +read_host = "127.0.0.1" +read_port = 5432 +read_id = "postgres" +read_pw = "password" +show_log = false +pool_size = 100 +max_overflow = 200 + +[JwtToken] +access_key = "test_access_secret_key" +refresh_key = "test_refresh_secret_key" +access_expire_min = 30 +refresh_expire_day = 7 diff --git a/negodata/backend/config/config_loader.py b/negodata/backend/config/config_loader.py new file mode 100644 index 0000000..8e1f7a0 --- /dev/null +++ b/negodata/backend/config/config_loader.py @@ -0,0 +1,37 @@ +import tomllib +from typing import Optional, Type, Dict, TypeVar +from pydantic import BaseModel + + +class ConfigModel(BaseModel): + pass + + +# APP_ENV +# local : 로컬 환경(개인 pc) +# dev : 개발환경 (사내 pc) +# prod : 서비스 환경 (클라우드 서버) +# +# 실행시 환경변수 설정 +# linux : export APP_ENV=dev +# window : set APP_ENV=dev +class Configs: + ConfigType = TypeVar("ConfigType", bound=ConfigModel) + + def __init__(self, file_path: str): + self._settings: Dict[Type["Configs.ConfigType"], "Configs.ConfigType"] = self._load_settings_from_toml(file_path) + + def _load_settings_from_toml(self, file_path: str) -> Dict[Type[ConfigType], ConfigType]: + with open(file_path, "rb") as f: + toml_content = tomllib.load(f) + + config_subclasses = ConfigModel.__subclasses__() + configs = { + config_class: config_class.model_validate(toml_content[config_class.__name__]) + for config_class in config_subclasses + if config_class.__name__ in toml_content + } + return configs + + def get(self, config_class: Type[ConfigType]) -> Optional[ConfigType]: + return self._settings.get(config_class) diff --git a/negodata/backend/config/config_models.py b/negodata/backend/config/config_models.py new file mode 100644 index 0000000..822d9d3 --- /dev/null +++ b/negodata/backend/config/config_models.py @@ -0,0 +1,41 @@ +from config.config_loader import ConfigModel + + +class WebServerConfig(ConfigModel): + server_name: str = "" + port: int = 0 + process_count: int = 1 + is_ssl: bool = False + is_test: bool = False + + +class LogConfig(ConfigModel): + print_console: bool = True + log_level: str = "debug" + + +# DB Read/Write 분리 설정. +# 하나의 논리 DB 에 대해 write(주) / read(복제) 접속 정보를 각각 가진다. +class MainDBConfig(ConfigModel): + db_type: str = "postgresql" + name: str = "" + write_host: str = "" + write_port: int = 5432 + write_id: str = "" + write_pw: str = "" + read_host: str = "" + read_port: int = 5432 + read_id: str = "" + read_pw: str = "" + show_log: bool = False + # 커넥션 풀 사이징. 실제 동시 커넥션 상한 = (pool_size + max_overflow) x 엔진수(R/W=2) x 워커수. + # PostgreSQL max_connections 를 넘지 않도록 설정해야 한다. (예: 10+20=30 x 2 x 5워커 = 300) + pool_size: int = 10 + max_overflow: int = 20 + + +class JwtToken(ConfigModel): + access_key: str = "" + refresh_key: str = "" + access_expire_min: int = 30 + refresh_expire_day: int = 7 diff --git a/negodata/backend/config/server_configs.py b/negodata/backend/config/server_configs.py new file mode 100644 index 0000000..6a6adbe --- /dev/null +++ b/negodata/backend/config/server_configs.py @@ -0,0 +1,20 @@ +import os + +from config.config_loader import Configs +from config.config_models import WebServerConfig, LogConfig, MainDBConfig, JwtToken + +# 실행 환경 결정 (기본 local). 환경변수 APP_ENV 로 변경. +APP_ENV = os.environ.get("APP_ENV", "local") + +_config_dir = os.path.dirname(__file__) +_config_file = os.path.join(_config_dir, f"config.{APP_ENV}.toml") + +if not os.path.exists(_config_file): + raise FileNotFoundError(f"설정 파일이 없습니다: {_config_file} (APP_ENV={APP_ENV}). config..toml 을 생성하세요.") + +configs = Configs(_config_file) + +web_server_config: WebServerConfig = configs.get(WebServerConfig) +log_config: LogConfig = configs.get(LogConfig) +main_db_config: MainDBConfig = configs.get(MainDBConfig) +jwt_token_config: JwtToken = configs.get(JwtToken) diff --git a/negodata/backend/conftest.py b/negodata/backend/conftest.py new file mode 100644 index 0000000..58d40a7 --- /dev/null +++ b/negodata/backend/conftest.py @@ -0,0 +1,54 @@ +# pytest 진입 시점에 가장 먼저 APP_ENV=test 를 설정해야 한다. +# (config.server_configs 가 import 되는 순간 config..toml 을 읽기 때문) +import os + +os.environ.setdefault("APP_ENV", "test") + +import pytest_asyncio +from httpx import ASGITransport, AsyncClient +from sqlalchemy import text +from sqlalchemy.ext.asyncio import create_async_engine + +from common.database.model.models import MAIN_BASE +from config.server_configs import main_db_config + + +def _write_url(cfg) -> str: + pw = f":{cfg.write_pw}" if cfg.write_pw else "" + return f"postgresql+asyncpg://{cfg.write_id}{pw}@{cfg.write_host}:{cfg.write_port}/{cfg.name}" + + +@pytest_asyncio.fixture +async def db_engine(): + """테스트용 스키마를 보장하고, 매 테스트 시작 시 테이블을 비워 격리한다. + + 앱(DB_SESSION_MNG)은 자체 엔진으로 같은 DB(config.test.toml)에 접속하므로, + 여기서 만든 스키마를 그대로 공유한다. + """ + engine = create_async_engine(_write_url(main_db_config)) + async with engine.begin() as conn: + await conn.run_sync(MAIN_BASE.metadata.create_all) # 이미 있으면 skip + await conn.execute(text("TRUNCATE TABLE tbl_account")) + yield engine + await engine.dispose() + + +@pytest_asyncio.fixture(scope="session", autouse=True) +async def _dispose_app_engines(): + """테스트 세션이 끝날 때 앱 싱글톤 엔진을 정리한다. + (이벤트 루프 종료 후 커넥션이 GC 되며 나오는 'Event loop is closed' 경고 제거) + """ + yield + from common.database.db_session_manager import DB_SESSION_MNG + + await DB_SESSION_MNG.dispose_all() + + +@pytest_asyncio.fixture +async def client(db_engine): + """앱을 실제 네트워크 없이 호출하는 httpx 클라이언트 (ASGITransport).""" + from router.router import app + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + yield ac diff --git a/negodata/backend/crud/__pycache__/user_crud.cpython-314.pyc b/negodata/backend/crud/__pycache__/user_crud.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..789451c10647765f1ce95ec66fbf276304d34f61 GIT binary patch literal 6861 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 H?9KlLJ8~fn literal 0 HcmV?d00001 diff --git a/negodata/backend/crud/user_crud.py b/negodata/backend/crud/user_crud.py new file mode 100644 index 0000000..b2a7ac6 --- /dev/null +++ b/negodata/backend/crud/user_crud.py @@ -0,0 +1,75 @@ +from abc import ABC, abstractmethod +from typing import Tuple + +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from common.database.db_session_manager import DB_SESSION_MNG +from common.database.model.models import tbl_account +from common.enums import ErrorType +from common.logger import LOG +from common.utils.gtime import GTime + + +# CRUD 는 인터페이스(I*) 와 구현(*) 으로 분리한다. +# - service 는 인터페이스 타입에 의존하고 Depends 로 구현을 주입받는다 (테스트/교체 용이). +# - 모든 메서드는 (session, ...) 을 받는다. session 은 람다 호출 시 매니저가 넘겨준다. +class IUserCRUD(ABC): + @abstractmethod + async def get_account_by_id(self, cdb: AsyncSession, user_id: str) -> Tuple[ErrorType, tbl_account]: + pass + + @abstractmethod + async def is_account(self, cdb: AsyncSession, user_id: str) -> ErrorType: + pass + + @abstractmethod + async def add_account(self, cdb: AsyncSession, account: tbl_account) -> ErrorType: + pass + + @abstractmethod + async def update_last_login(self, cdb: AsyncSession, user_uid: int) -> ErrorType: + pass + + +class UserCRUD(IUserCRUD): + async def get_account_by_id(self, cdb: AsyncSession, user_id: str) -> Tuple[ErrorType, tbl_account]: + try: + query = select(tbl_account).where(tbl_account.id == user_id).limit(1) + err_type, row_list = await DB_SESSION_MNG.execute(cdb, query, f"get_account_by_id(ID:{user_id}) failed.") + if err_type != ErrorType.SUCCESS: + return err_type, None + if len(row_list) != 1: + return ErrorType.DB_INVALID_KEY, None + return ErrorType.SUCCESS, row_list[0] + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, None + + async def is_account(self, cdb: AsyncSession, user_id: str) -> ErrorType: + try: + query = select(tbl_account).where(tbl_account.id == user_id).limit(1) + err_type, row_list = await DB_SESSION_MNG.execute(cdb, query) + if err_type != ErrorType.SUCCESS: + return err_type + if row_list: + return ErrorType.DB_ALREADY_SAME_KEY + return ErrorType.SUCCESS + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED + + async def add_account(self, cdb: AsyncSession, account: tbl_account) -> ErrorType: + try: + return await DB_SESSION_MNG.insert(cdb, account) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED + + async def update_last_login(self, cdb: AsyncSession, user_uid: int) -> ErrorType: + try: + query = update(tbl_account).where(tbl_account.uid == user_uid).values(last_login_at=GTime.UTC()) + return await DB_SESSION_MNG.add(cdb, query) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED diff --git a/negodata/backend/loadtest/locustfile.py b/negodata/backend/loadtest/locustfile.py new file mode 100644 index 0000000..5a6f803 --- /dev/null +++ b/negodata/backend/loadtest/locustfile.py @@ -0,0 +1,80 @@ +"""Negodata 인증 서버 부하 테스트. + +실행: + pip install locust + locust -f loadtest/locustfile.py --host http://localhost:9400 + # 웹 UI: http://localhost:8089 에서 사용자 수/spawn rate 입력 + + # 헤드리스(CI) 예시 - 100 VU, 10/s 증가, 2분: + locust -f loadtest/locustfile.py --host http://localhost:9400 \ + --headless -u 100 -r 10 -t 2m + +주의: +- /login 은 bcrypt 검증(CPU 바운드)이 들어가 가장 무겁다. RPS 가 낮으면 + 거의 확실히 bcrypt cost 가 병목이다 (DB 아님). +- 부하를 올리며 서버 측에서 PostgreSQL 커넥션 수를 함께 모니터링하라: + SELECT count(*) FROM pg_stat_activity; + (pool_size + max_overflow) x 2(R/W) x 워커수 가 max_connections 를 넘으면 + max_connections 초과 시 실패한다. +""" + +import random + +from locust import HttpUser, between, events, task + + +# 각 시뮬레이션 유저는 고유 계정을 만들어 로그인 흐름을 반복한다. +class AuthUser(HttpUser): + wait_time = between(0.5, 2.0) + + def on_start(self): + # 유저별 고유 계정 생성 후 1회 로그인하여 토큰 확보. + self.user_id = f"load_{random.randint(0, 1_000_000_000)}" + self.password = "pw1234" + self.token = None + + self.client.post( + "/v1/auth/create", + json={"id": self.user_id, "pw": self.password, "nickname": "load"}, + name="POST /v1/auth/create", + ) + self._login() + + def _login(self): + with self.client.post( + "/v1/auth/login", + json={"id": self.user_id, "pw": self.password}, + name="POST /v1/auth/login", + catch_response=True, + ) as resp: + if resp.status_code == 200 and resp.json().get("result", {}).get("success"): + self.token = resp.json().get("access_token") + resp.success() + else: + resp.failure(f"login failed: {resp.status_code} {resp.text[:120]}") + + @task(5) + def me(self): + # JWT 검증만 하는 경량 경로 (DB 無). bcrypt 경로와 처리량 비교용. + if not self.token: + return + self.client.get( + "/v1/auth/me", + headers={"Authorization": f"Bearer {self.token}"}, + name="GET /v1/auth/me", + ) + + @task(2) + def login(self): + # bcrypt + DB write 가 포함된 무거운 경로. + self._login() + + @task(1) + def healthz(self): + # 베이스라인 (앱 오버헤드 측정). + self.client.get("/healthz", name="GET /healthz") + + +@events.test_start.add_listener +def _on_start(environment, **kwargs): + print("부하 테스트 시작 - PostgreSQL 커넥션 수 모니터링 권장 (pg_stat_activity)") diff --git a/negodata/backend/pytest.ini b/negodata/backend/pytest.ini new file mode 100644 index 0000000..85c900a --- /dev/null +++ b/negodata/backend/pytest.ini @@ -0,0 +1,9 @@ +[pytest] +asyncio_mode = auto +# DB_SESSION_MNG(싱글톤)의 커넥션 풀이 첫 이벤트 루프에 묶이므로, +# 모든 테스트/픽스처가 단일 session 루프를 공유하게 한다. +asyncio_default_fixture_loop_scope = session +asyncio_default_test_loop_scope = session +testpaths = tests +filterwarnings = + ignore::pytest.PytestUnraisableExceptionWarning diff --git a/negodata/backend/requirements.txt b/negodata/backend/requirements.txt new file mode 100644 index 0000000..2f928b6 --- /dev/null +++ b/negodata/backend/requirements.txt @@ -0,0 +1,8 @@ +fastapi +uvicorn[standard] +sqlalchemy>=2.0 +asyncpg +python-jose[cryptography] +bcrypt +orjson +pydantic>=2.0 diff --git a/negodata/backend/router/__pycache__/router.cpython-314.pyc b/negodata/backend/router/__pycache__/router.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..788713d22d6d4172ad49cde14bef613ad9f53eea GIT binary patch literal 2498 zcmbtV%}*Og6rb5$d$9pSY`{bbU_JzK(%4CWkdFq5HUv9Lh`SIDRfx6N3v94=x3lAr z_~g(^iP}TyhZNLAYNA$ZbIYNJ{uLDzTG~yJDm7J`LnRPVtK9l#y~Zx>p;AZMncuv5 z^XAQazj=GeSL;R4=I=$X{VpK%Gn{||CUnmv zCshTJDYPTWJL#9I2e_gt4NL|S;j)bqniQoPskWrmhCu63X{|g;zCO4s zYf>GYR{yutnqW_~xLVmudnCUUkQ$;milxTV2~tzA?0qxr?5*5W#FKT2(p4%J6UQqi z4osZ*7g_5+WhL+1AXl9z9DL4HM#I<&$mUHgmC`k;+@M)mlV=nX<}C4?Y|^1b+!7__ zt(;;~Q1y+xsTvp6bULHV%S2(4v#%w`hR4R@mqwEpM@K-yU%12u*T}etrsP($h**Kc*knlo{PrbJ-{ZX_?B=DLFN# zXz3`?b5tSD5HW6AHOZu`X*!jul1!31IHw*yGXPxy{fPwJv&FjmqCISb7}*zPfHihW z9z5Tk0MLQjV3&mYg&U{<@1T6egu_d=rPRv29)UgAz|sv|;4iqmU`0ZmNJJOh{k)1F z;n6ThMA&9kr&ZI?O(iL3G7zO2klC^6CO)=Yl~Jb^(~vdN08@4ub3b&?!KoK+vN|@p zY+agPZR}aE>nX&Z`uy*!@2c<3EuH_)*IgKSDu}m-7l!YgTO3>|B&GJ{b7o^uyEZ zoxLlay~`ba%ijJq;rI{Lfx@LN5!D}fX|l{Ov=5!cpW&ea;q##efcdKEtRiRVY+n5s z3JN?X#Y_>^psKFrJ)=4eP3t)=4R4K+%JP(MW~N}e(^#@>3N-=PO`%T2LQMRy1n;8} zN-9)LX<}GPj*SmV-^|Eg~IO!ffm*i?s>h0sfrQDQ;#;BX7s z2hj35)jOk35uOReX(-;Zq1rXmelJ$g5k;~)^*b_?R}I40V|AqTY*yDItT0Z=rV>d{ zB~8UNAq+|9pHQqewT@y&pMn2GksE$TmZNIMjLg`i@i41R#nKwNel#NI=xjuW-%L)U zmb)mo+?U5+AESiyfU;dh?CNFt>IJfKa6wtPtREjNT8$JX3u3OuM_VIBEbkbiOT zv9OH%Yp9>?bKP#e)w<|j#laUGlq6=Ld6S{ddbSw66})rp!Pu8)zCN^k@Z56ONFlh2 uV_O`??MyX}HyPaYmaQMZd-8*mcLzThTn~g-0^#+*k(I!a@6l2FzJCF97zhCX literal 0 HcmV?d00001 diff --git a/negodata/backend/router/router.py b/negodata/backend/router/router.py new file mode 100644 index 0000000..e108613 --- /dev/null +++ b/negodata/backend/router/router.py @@ -0,0 +1,44 @@ +import time +from contextlib import asynccontextmanager + +from fastapi import FastAPI, Request +from fastapi.middleware.gzip import GZipMiddleware + +from common.database.db_session_manager import DB_SESSION_MNG +from common.logger import LOG +from common.utils.gtime import GTime +import router.v1.auth.account + +API_SERVER_START_TIME = GTime.UTCStr() + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # startup + yield + # shutdown: DB 엔진 커넥션 풀 정리 + await DB_SESSION_MNG.dispose_all() + + +app = FastAPI(title="Negodata Api Server", lifespan=lifespan) + +# Accept-Encoding: gzip 요청에 대해 1000 bytes 이상 응답을 압축. +app.add_middleware(GZipMiddleware, minimum_size=1000) + + +@app.middleware("http") +async def log_time(request: Request, call_next): + start_time = time.time() + response = await call_next(request) + elapsed = time.time() - start_time + LOG.d(f"took: {elapsed:.4f} - {request.url.path}") + return response + + +@app.get(path="/healthz", responses={404: {"description": "Not found"}}) +async def healthz(): + return API_SERVER_START_TIME + + +# 각 도메인 라우터를 등록한다. 새 기능 추가 시 router.v1.. 를 import 후 include. +app.include_router(router.v1.auth.account.router) diff --git a/negodata/backend/router/v1/auth/__pycache__/account.cpython-314.pyc b/negodata/backend/router/v1/auth/__pycache__/account.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1fbdd07d7e6481ded322e003621560e6c8ff1430 GIT binary patch literal 4026 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 K`p?3JtMXrR^17k` literal 0 HcmV?d00001 diff --git a/negodata/backend/router/v1/auth/__pycache__/protocol.cpython-314.pyc b/negodata/backend/router/v1/auth/__pycache__/protocol.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a842a2b0e65f17fd4c4479bab74d55d2230e9665 GIT binary patch literal 3149 zcmb7G&2Jk;6rb4-d;O6(Zj-o8`jxhPn9?kiQX;jgC`DCLg{oa>3W8+2U2l?Y?A>&B zOerTrE4cQE1W25aa^yeY*rOx$kS^kYIG~CiNMaF=yf6jscE%qO$lontlG5PwWftN1J-P8ttPBFu;ycHGs4;f*4~&kYZQ)< za^GWgU8bRW!F8&YvJ?&&PGDYhZd|tP1&3c|KKE_E8Wx{2+4yB=(6+KZ2ug!Y6HX1n zrI|FB4ayZm0$*~NKUe4Toh_HhrLr1ors-KV$27yNY1aHoy^4C?G;h_dYI~+&nzJqo zc-8eB&xh#&(+s%9UE8!cXYP$UhnP*Kf$wz;B>Y6b8aOP_YZilV?pWTO{;m@&aDP$1 z?Xv~l_A8F=pYV@+&YT~(^_q@K#p0HJ1OK;I(U}jsGX2xnbqj*l7u$&#UA)86;6eyg z0)+i5@{6Le-i|$y8VRg8)=z@4;>pQzd64)(6G#x{>J)i{8d8Z=HJ&nLo}MCHGZaXi z8fFaVmO1Iqxn4+Y%ylQ<3h2n6^%Ee@&<2?zGt$^SLMmY0N3PI$s+0HW$KJAz;6H}%kzA| z0TCPQMmQR`=U`>97sNkblP}35E%(Lbs!S;T4Yf5Dv?yx`-KBDdW#GxM#29|b@+byS zAg{t=Z0p&&X9MOvZF@IL+o@JfbBVGcmG!)F3OpeT{d7*uZe^ zk|-ctoq#a~1{~`Khy;v%5scHyh^To(GGv_^%3FvF46Yg~0IKpd{4@b_N`Ra((mV?w z=L`*y$RNl;1oG!v9q4vBQpE|iDg}I6o*vwtzuJ47#n}P3{fj#fkEYtan zGmGtl)!XyqBFsD|KPYfT@B_Gly4>as>Pnk8D(lC|xFN?L!L{Z)S05Ip8s}H~2OHxndxjd5D|?3dQ9lqfP z3BGmP#u&^b_*P1`4QL&ySnEhb>j0MVOuKayu`tgw2g>xEZToeA;J;aN7JNM?OR%GK zSc1A>3Gi4E%-Ag2D4QkN_}Qo*cq+~W`nW-W=-_tS=IpX zmOv8#BLD%G&}MmGU&Es!4(0X-#AfC0fZ_?*o}m!Vu;eyNwRv>x!H3OjGtEi!Vc~k? z{092&V2R%(u4i?s3hfaxT;s?^G-g zE`1E~4*PAtR`b14xHCG{V02DA84{V{jV!vPi8CJ^h}sp<3!mbu7!ga{WwbNV)$=Sn z4T~xKf{#G_B~wa&B}0FZiIzrbrn&oQ3s{k9X%a2o&$kFXT3LneYxcd+BJgbW%Cz(i dOu(a+r_`d&J;(7Ai{b{u&C;uX!`rZM{V&;pefR(X literal 0 HcmV?d00001 diff --git a/negodata/backend/router/v1/auth/account.py b/negodata/backend/router/v1/auth/account.py new file mode 100644 index 0000000..b5d4648 --- /dev/null +++ b/negodata/backend/router/v1/auth/account.py @@ -0,0 +1,42 @@ +from fastapi import APIRouter, Depends, Request +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +from common.models.gmodel import UserInfo +from router.v1.validator.dependencies import IsValidAccessToken, IsValidRefreshToken, RemoveNoneResponse +from services.auth_service import AuthService +from .protocol import Req_CreateAccount, Req_Login, Res_CreateAccount, Res_Login, Res_RefreshToken + +security = HTTPBearer() + +# 라우터(MVC 의 컨트롤러). 요청 검증 -> service 호출 -> RemoveNoneResponse 반환만 담당. +router = APIRouter(prefix="/v1/auth", tags=["Auth"], responses={404: {"description": "Not found"}}) + + +@router.post(path="/login", response_model=Res_Login, summary="로그인", description="id/pw 로 로그인하고 JWT 토큰을 발급한다.") +async def login(request: Request, req: Req_Login, service: AuthService = Depends()): + return RemoveNoneResponse(await service.attempt_login(req.id, req.pw, request.client.host)) + + +@router.post(path="/create", response_model=Res_CreateAccount, summary="계정 생성", description="새 계정을 생성한다.") +async def create_account(request: Request, req: Req_CreateAccount, service: AuthService = Depends()): + return RemoveNoneResponse(await service.create_account(req.id, req.pw, req.nickname, request.client.host)) + + +@router.post( + path="/refresh_token", + dependencies=[Depends(IsValidRefreshToken)], + response_model=Res_RefreshToken, + summary="액세스 토큰 갱신", + description="refresh 토큰으로 access 토큰을 재발급한다.", +) +async def refresh_token(service: AuthService = Depends(), credentials: HTTPAuthorizationCredentials = Depends(security)): + return RemoveNoneResponse(await service.refresh_token(credentials.credentials)) + + +@router.get( + path="/me", + summary="내 정보 (보호된 엔드포인트 예시)", + description="유효한 access 토큰이 있어야 호출 가능. 토큰 검증 결과 UserInfo 를 주입받는다.", +) +async def me(user_info: UserInfo = Depends(IsValidAccessToken)): + return {"uid": user_info.uid, "id": user_info.id, "nickname": user_info.nickname} diff --git a/negodata/backend/router/v1/auth/protocol.py b/negodata/backend/router/v1/auth/protocol.py new file mode 100644 index 0000000..2ef226e --- /dev/null +++ b/negodata/backend/router/v1/auth/protocol.py @@ -0,0 +1,34 @@ +from pydantic import Field + +from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol + + +# 라우터 폴더마다 protocol.py 를 두고 Req_/Res_ 를 정의한다 (protocol 규약). +class AuthProtocol(WebPacketProtocol): + pass + + +class Req_Login(AuthProtocol): + id: str = "" + pw: str = "" + + +class Res_Login(Res_WebPacketProtocol): + uid: int = Field(0, description="user uid", json_schema_extra={"format": "int64"}) + nickname: str = "" + access_token: str = "" + refresh_token: str = "" + + +class Req_CreateAccount(AuthProtocol): + id: str = "" + pw: str = "" + nickname: str = "" + + +class Res_CreateAccount(Res_WebPacketProtocol): + uid: int = Field(0, description="생성된 user uid", json_schema_extra={"format": "int64"}) + + +class Res_RefreshToken(Res_WebPacketProtocol): + access_token: str = "" diff --git a/negodata/backend/router/v1/validator/__pycache__/dependencies.cpython-314.pyc b/negodata/backend/router/v1/validator/__pycache__/dependencies.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0ee678c7e4eed57c55c96c9cb038f28f5f67a0e5 GIT binary patch literal 8206 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 h__?nCiH%%R>>*k{{oqP%(nml literal 0 HcmV?d00001 diff --git a/negodata/backend/router/v1/validator/dependencies.py b/negodata/backend/router/v1/validator/dependencies.py new file mode 100644 index 0000000..73ad74e --- /dev/null +++ b/negodata/backend/router/v1/validator/dependencies.py @@ -0,0 +1,113 @@ +import asyncio +import json +from typing import Any, Union + +from fastapi import Depends +from fastapi.responses import ORJSONResponse +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +import bcrypt +from jose import jwt, JWTError, ExpiredSignatureError + +from common.enums import ( + EXCEPTION_ACCESS_TOKEN_EXPIRED, + EXCEPTION_INVALID_CLIENT_ACCESS, + EXCEPTION_REFRESH_TOKEN_EXPIRED, +) +from common.logger import LOG +from common.models.gmodel import UserInfo +from common.utils.gtime import GTime +from config.server_configs import jwt_token_config + +security = HTTPBearer() + + +# ---- 비밀번호 해시 (bcrypt) ------------------------------------------------ +# bcrypt 는 CPU 바운드 동기 작업이라 그대로 호출하면 asyncio 이벤트 루프를 막아 +# 같은 워커의 다른 요청(healthz 등)까지 멈춘다. 스레드풀(asyncio.to_thread)로 보낸다. +# bcrypt 는 해싱 중 GIL 을 해제하므로 스레드들이 여러 코어에서 실제 병렬로 돈다. +# 입력은 최대 72 bytes 까지만 사용하므로 사전에 잘라준다. +def _hash_pw(pw: str) -> str: + return bcrypt.hashpw(pw.encode("utf-8")[:72], bcrypt.gensalt()).decode("utf-8") + + +def _verify_pw(pw: str, hashed_pw: str) -> bool: + try: + return bcrypt.checkpw(pw.encode("utf-8")[:72], hashed_pw.encode("utf-8")) + except (ValueError, TypeError): + return False + + +async def GetHashedPW(pw: str) -> str: + return await asyncio.to_thread(_hash_pw, pw) + + +async def VerifyPW(pw: str, hashed_pw: str) -> bool: + return await asyncio.to_thread(_verify_pw, pw, hashed_pw) + + +# ---- JWT 토큰 발급/검증 ---------------------------------------------------- +JWT_ALGORITHM = "HS256" +JWT_ACCESS_SECRET = jwt_token_config.access_key +JWT_REFRESH_SECRET = jwt_token_config.refresh_key +ACCESS_TOKEN_EXPIRE_MIN = jwt_token_config.access_expire_min +REFRESH_TOKEN_EXPIRE_MIN = 60 * 24 * jwt_token_config.refresh_expire_day + + +def __create_token(subject: Union[str, Any], secret_key: str, expire_min: int) -> str: + to_encode = { + "sub": str(subject), + "exp": GTime.AddMinutes(expire_min), # jose 가 exp 클레임을 자동 검증 + } + return jwt.encode(to_encode, secret_key, JWT_ALGORITHM) + + +def CreateAccessToken(subject: UserInfo) -> str: + return __create_token(subject.to_json(), JWT_ACCESS_SECRET, ACCESS_TOKEN_EXPIRE_MIN) + + +def CreateRefreshToken(subject: UserInfo) -> str: + return __create_token(subject.to_json(), JWT_REFRESH_SECRET, REFRESH_TOKEN_EXPIRE_MIN) + + +def __decode_token(jwt_token: str, secret_key: str, expired_exception) -> UserInfo: + try: + decoded = jwt.decode(jwt_token, secret_key, algorithms=[JWT_ALGORITHM]) + return UserInfo(**json.loads(decoded.get("sub"))) + except ExpiredSignatureError: + raise expired_exception + except JWTError as ex: + LOG.e_no_callstack(ex) + raise EXCEPTION_INVALID_CLIENT_ACCESS + + +def DecodeAccessToken(jwt_token: str) -> UserInfo: + return __decode_token(jwt_token, JWT_ACCESS_SECRET, EXCEPTION_ACCESS_TOKEN_EXPIRED) + + +def DecodeRefreshToken(jwt_token: str) -> UserInfo: + return __decode_token(jwt_token, JWT_REFRESH_SECRET, EXCEPTION_REFRESH_TOKEN_EXPIRED) + + +# ---- Depends 용 토큰 검증기 ------------------------------------------------ +# 보호된 엔드포인트에서 dependencies=[Depends(IsValidAccessToken)] 로 사용. +async def IsValidAccessToken(credentials: HTTPAuthorizationCredentials = Depends(security)) -> UserInfo: + return DecodeAccessToken(credentials.credentials) + + +async def IsValidRefreshToken(credentials: HTTPAuthorizationCredentials = Depends(security)) -> UserInfo: + return DecodeRefreshToken(credentials.credentials) + + +# ---- ResponseNone 처리 ----------------------------------------------------- +# 응답 객체에서 값이 None 인 필드를 재귀적으로 제거하여 페이로드를 줄인다. +# 모든 라우터는 return RemoveNoneResponse(await service....) 형태로 반환한다. +def RemoveNoneValues(obj: Any) -> Any: + if isinstance(obj, dict): + return {k: RemoveNoneValues(v) for k, v in obj.items() if v is not None} + if isinstance(obj, list): + return [RemoveNoneValues(v) for v in obj] + return obj + + +def RemoveNoneResponse(obj) -> ORJSONResponse: + return ORJSONResponse(content=RemoveNoneValues(obj.model_dump())) diff --git a/negodata/backend/services/__pycache__/auth_service.cpython-314.pyc b/negodata/backend/services/__pycache__/auth_service.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a30f2f14f6d27f60aae8ea779bb1d923b6d297f6 GIT binary patch literal 7600 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 literal 0 HcmV?d00001 diff --git a/negodata/backend/services/auth_service.py b/negodata/backend/services/auth_service.py new file mode 100644 index 0000000..1d41675 --- /dev/null +++ b/negodata/backend/services/auth_service.py @@ -0,0 +1,115 @@ +from fastapi import Depends + +from common.database.db_session_manager import DB_SESSION_MNG +from common.database.model.models import tbl_account +from common.enums import DBWRType, ErrorType +from common.logger import LOG +from common.models.gmodel import UserInfo +from crud.user_crud import IUserCRUD, UserCRUD +from router.v1.auth.protocol import Res_CreateAccount, Res_Login, Res_RefreshToken +from router.v1.validator.dependencies import ( + CreateAccessToken, + CreateRefreshToken, + DecodeRefreshToken, + GetHashedPW, + VerifyPW, +) + + +class AuthService: + """비즈니스 로직 계층 (MVC 의 컨트롤러-서비스 분리에서 서비스). + + - CRUD 는 Depends 로 인터페이스 타입으로 주입받는다. + - DB 접근은 DB_SESSION_MNG 의 람다 실행으로만 한다. + 조회 = execute_lambda(..., DB_READ, lambda s: crud.xxx(s, ...)) + 변경 = execute_lambda_run([DBType], [lambda s: crud.xxx(s, ...)]) + - 모든 메서드는 Res_* 를 만들어 result 에 ErrorType 을 세팅해 반환한다. + """ + + def __init__(self, user_crud: IUserCRUD = Depends(UserCRUD)): + self.user_crud = user_crud + + async def attempt_login(self, id: str, pw: str, connect_ip: str) -> Res_Login: + LOG.i(f"LOGIN : {id=}") + res = Res_Login() + + # 1) 계정 조회 (Read DB) + err_type, account = await DB_SESSION_MNG.execute_lambda( + tbl_account.DBType(), + DBWRType.DB_READ.value, + lambda s: self.user_crud.get_account_by_id(s, id), + ) + if err_type != ErrorType.SUCCESS: + # 계정 없음/조회 실패 모두 로그인 실패로 일반화 + res.result.SetResult(ErrorType.ACCOUNT_INVALID_INFO) + return res + account: tbl_account + + # 2) 비밀번호 검증 + if not await VerifyPW(pw, account.pw): + res.result.SetResult(ErrorType.ACCOUNT_INVALID_INFO) + return res + + # 3) 차단 여부 + if account.is_blocked: + res.result.SetResult(ErrorType.ACCOUNT_BLOCKED_USER) + return res + + # 4) 토큰 발급 + user_info = UserInfo(uid=account.uid, id=account.id, nickname=account.nickname) + res.access_token = CreateAccessToken(user_info) + res.refresh_token = CreateRefreshToken(user_info) + + # 5) 마지막 로그인 시간 갱신 (Write DB, 트랜잭션) + err_type = await DB_SESSION_MNG.execute_lambda_run( + [tbl_account.DBType()], + [lambda s: self.user_crud.update_last_login(s, account.uid)], + ) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + + res.uid = account.uid + res.nickname = account.nickname + return res + + async def create_account(self, id: str, pw: str, nickname: str, connect_ip: str) -> Res_CreateAccount: + LOG.i(f"CREATE : {id=}, {nickname=}") + res = Res_CreateAccount() + + # 1) 중복 ID 확인 (Read DB) + err_type = await DB_SESSION_MNG.execute_lambda( + tbl_account.DBType(), + DBWRType.DB_READ.value, + lambda s: self.user_crud.is_account(s, id), + ) + if err_type == ErrorType.DB_ALREADY_SAME_KEY: + res.result.SetResult(ErrorType.ACCOUNT_ALREADY_EXIST) + return res + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + + # 2) 계정 생성 (비밀번호는 bcrypt 해시로 저장) + account = tbl_account(id=id, pw=await GetHashedPW(pw), nickname=nickname or id) + err_type = await DB_SESSION_MNG.execute_lambda_run( + [tbl_account.DBType()], + [lambda s: self.user_crud.add_account(s, account)], + ) + if err_type != ErrorType.SUCCESS: + # 사전 검사와 INSERT 사이의 경쟁 조건에서 unique 위반이 나면 동일 코드로 매핑. + if err_type == ErrorType.DB_ALREADY_SAME_KEY: + res.result.SetResult(ErrorType.ACCOUNT_ALREADY_EXIST) + else: + res.result.SetResult(err_type) + return res + + res.uid = account.uid + return res + + async def refresh_token(self, refresh_token: str) -> Res_RefreshToken: + res = Res_RefreshToken() + # refresh 토큰 검증은 라우터 Depends(IsValidRefreshToken) 에서 1차 수행됨. + user_info = DecodeRefreshToken(refresh_token) + res.access_token = CreateAccessToken(user_info) + return res diff --git a/negodata/backend/tests/__pycache__/test_auth.cpython-314-pytest-9.0.3.pyc b/negodata/backend/tests/__pycache__/test_auth.cpython-314-pytest-9.0.3.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e3be67d1e2c39ed6034008f985fd04d3e51e7c50 GIT binary patch literal 12616 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 0 + + # 2) 로그인 -> 토큰 발급 + r = await client.post("/v1/auth/login", json={"id": "user1", "pw": "pw1234"}) + assert r.status_code == 200 + body = r.json() + assert body["result"]["success"] is True + assert body["access_token"] + assert body["refresh_token"] + assert body["nickname"] == "닉네임" + access_token = body["access_token"] + + # 3) 보호된 엔드포인트 호출 + r = await client.get("/v1/auth/me", headers={"Authorization": f"Bearer {access_token}"}) + assert r.status_code == 200 + assert r.json()["id"] == "user1" + + +async def test_login_with_wrong_password(client): + await client.post("/v1/auth/create", json={"id": "user2", "pw": "correct", "nickname": "n"}) + + r = await client.post("/v1/auth/login", json={"id": "user2", "pw": "wrong"}) + assert r.status_code == 200 + body = r.json() + assert body["result"]["success"] is False + # 자격증명 오류는 ACCOUNT_INVALID_INFO(1200) + assert body["result"]["code"] == 1200 + assert body.get("access_token", "") == "" # 실패 시 토큰은 빈 문자열 + + +async def test_login_nonexistent_account(client): + r = await client.post("/v1/auth/login", json={"id": "ghost", "pw": "whatever"}) + assert r.json()["result"]["success"] is False + + +async def test_duplicate_account_create(client): + r1 = await client.post("/v1/auth/create", json={"id": "dup", "pw": "pw1234", "nickname": "n"}) + assert r1.json()["result"]["success"] is True + + r2 = await client.post("/v1/auth/create", json={"id": "dup", "pw": "pw5678", "nickname": "n2"}) + body = r2.json() + assert body["result"]["success"] is False + # ACCOUNT_ALREADY_EXIST(1201) + assert body["result"]["code"] == 1201 + + +async def test_me_without_token_is_rejected(client): + r = await client.get("/v1/auth/me") + assert r.status_code in (401, 403) # HTTPBearer 가 자격증명 없음을 거부 diff --git a/negodata/backend/web_main.py b/negodata/backend/web_main.py new file mode 100644 index 0000000..e312a77 --- /dev/null +++ b/negodata/backend/web_main.py @@ -0,0 +1,41 @@ +# 실행 방법 +# pip install -r requirements.txt +# python web_main.py # 기본 local 환경 +# APP_ENV=dev python web_main.py # 환경 지정 +# +# 또는 uvicorn 직접 실행: +# uvicorn router.router:app --reload --host=0.0.0.0 --port=9400 + +import uvicorn + +from common.logger import LOG +from config.server_configs import web_server_config + +LOG.SetPrefix(web_server_config.server_name) + +# import 시점에 app 및 DB 세션 매니저(싱글톤)가 초기화된다. +import router.router + +if __name__ == "__main__": + LOG.i(f"Server Name : {web_server_config.server_name}") + LOG.i(f"Server Port : {web_server_config.port}") + LOG.i(f"API Server start time : {router.router.API_SERVER_START_TIME}") + + if web_server_config.is_ssl: + uvicorn.run( + "router.router:app", + host="0.0.0.0", + port=web_server_config.port, + access_log=False, + workers=web_server_config.process_count, + ssl_keyfile="./SSL/key.pem", + ssl_certfile="./SSL/cert.pem", + ) + else: + uvicorn.run( + "router.router:app", + host="0.0.0.0", + port=web_server_config.port, + access_log=False, + workers=web_server_config.process_count, + ) diff --git a/negodata/front/README.md b/negodata/front/README.md new file mode 100644 index 0000000..53a5f28 --- /dev/null +++ b/negodata/front/README.md @@ -0,0 +1 @@ +마지막 내 도리는 하자 . \ No newline at end of file diff --git a/postgres-init/01-schema.sql b/postgres-init/01-schema.sql new file mode 100644 index 0000000..4672d74 --- /dev/null +++ b/postgres-init/01-schema.sql @@ -0,0 +1,32 @@ +-- 단일 PostgreSQL 인스턴스에 여러 서비스의 database 를 함께 둔다. +-- 컨테이너 최초 기동 시 기본 DB(postgres)에 연결된 상태로 1회 실행된다. +-- +-- postgres (1개 서버, 5432) +-- ├── negosium_db (negosium 운영) +-- └── negodata_db (negodata 운영) + +CREATE DATABASE negosium_db; +CREATE DATABASE negodata_db; + +\connect negosium_db +CREATE TABLE IF NOT EXISTS tbl_account ( + uid SERIAL PRIMARY KEY, + id VARCHAR(45) NOT NULL UNIQUE, + pw VARCHAR(255) NOT NULL DEFAULT '', + nickname VARCHAR(45) NOT NULL DEFAULT '', + is_blocked BOOLEAN NOT NULL DEFAULT FALSE, + last_login_at TIMESTAMP NOT NULL DEFAULT (now() AT TIME ZONE 'utc'), + create_at TIMESTAMP DEFAULT (now() AT TIME ZONE 'utc') +); + + +\connect negodata_db +CREATE TABLE IF NOT EXISTS tbl_account ( + uid SERIAL PRIMARY KEY, + id VARCHAR(45) NOT NULL UNIQUE, + pw VARCHAR(255) NOT NULL DEFAULT '', + nickname VARCHAR(45) NOT NULL DEFAULT '', + is_blocked BOOLEAN NOT NULL DEFAULT FALSE, + last_login_at TIMESTAMP NOT NULL DEFAULT (now() AT TIME ZONE 'utc'), + create_at TIMESTAMP DEFAULT (now() AT TIME ZONE 'utc') +);