From f7833ba466acc7498794eab5c3e4ad1503bcb99e Mon Sep 17 00:00:00 2001 From: hbyang Date: Mon, 15 Jun 2026 15:53:39 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B0=B1=EC=97=94=EB=93=9C=20=ED=94=84?= =?UTF-8?q?=EB=A0=88=EC=9E=84=20=EC=84=A4=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/.dockerignore | 7 + backend/Dockerfile | 16 ++ backend/README.md | 62 ++++++ .../conftest.cpython-314-pytest-9.0.3.pyc | Bin 0 -> 3873 bytes .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 174 bytes .../common/__pycache__/enums.cpython-314.pyc | Bin 0 -> 3336 bytes .../common/__pycache__/logger.cpython-314.pyc | Bin 0 -> 3235 bytes .../__pycache__/singleton.cpython-314.pyc | Bin 0 -> 1182 bytes .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 183 bytes .../db_session_manager.cpython-314.pyc | Bin 0 -> 16105 bytes backend/common/database/db_session_manager.py | 198 ++++++++++++++++++ .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 189 bytes .../model/__pycache__/models.cpython-314.pyc | Bin 0 -> 1569 bytes .../model/__pycache__/mysql.cpython-314.pyc | Bin 0 -> 1557 bytes backend/common/database/model/models.py | 26 +++ backend/common/enums.py | 61 ++++++ backend/common/logger.py | 43 ++++ .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 181 bytes .../models/__pycache__/gmodel.cpython-314.pyc | Bin 0 -> 4512 bytes backend/common/models/gmodel.py | 64 ++++++ backend/common/singleton.py | 16 ++ .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 180 bytes .../utils/__pycache__/gtime.cpython-314.pyc | Bin 0 -> 2434 bytes backend/common/utils/gtime.py | 21 ++ .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 174 bytes .../__pycache__/config_loader.cpython-314.pyc | Bin 0 -> 2710 bytes .../__pycache__/config_models.cpython-314.pyc | Bin 0 -> 3052 bytes .../server_configs.cpython-314.pyc | Bin 0 -> 1797 bytes backend/config/config.docker.toml | 33 +++ backend/config/config.local.toml | 32 +++ backend/config/config.test.toml | 33 +++ backend/config/config_loader.py | 37 ++++ backend/config/config_models.py | 41 ++++ backend/config/server_configs.py | 20 ++ backend/conftest.py | 54 +++++ .../crud/__pycache__/__init__.cpython-314.pyc | Bin 0 -> 172 bytes .../__pycache__/user_crud.cpython-314.pyc | Bin 0 -> 6852 bytes backend/crud/user_crud.py | 75 +++++++ .../__pycache__/locustfile.cpython-314.pyc | Bin 0 -> 4383 bytes backend/loadtest/locustfile.py | 80 +++++++ backend/pytest.ini | 9 + backend/requirements-dev.txt | 9 + backend/requirements.txt | 8 + .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 174 bytes .../router/__pycache__/router.cpython-314.pyc | Bin 0 -> 2489 bytes backend/router/router.py | 44 ++++ .../v1/__pycache__/__init__.cpython-314.pyc | Bin 0 -> 177 bytes .../auth/__pycache__/__init__.cpython-314.pyc | Bin 0 -> 182 bytes .../auth/__pycache__/account.cpython-314.pyc | Bin 0 -> 4017 bytes .../auth/__pycache__/protocol.cpython-314.pyc | Bin 0 -> 3140 bytes backend/router/v1/auth/account.py | 42 ++++ backend/router/v1/auth/protocol.py | 34 +++ .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 187 bytes .../__pycache__/dependencies.cpython-314.pyc | Bin 0 -> 8197 bytes backend/router/v1/validator/dependencies.py | 113 ++++++++++ .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 176 bytes .../__pycache__/auth_service.cpython-314.pyc | Bin 0 -> 7591 bytes backend/services/auth_service.py | 115 ++++++++++ backend/tests/Benchmark.png | Bin 0 -> 117596 bytes .../test_auth.cpython-314-pytest-9.0.3.pyc | Bin 0 -> 12607 bytes backend/tests/test_auth.py | 63 ++++++ backend/web_main.py | 41 ++++ 62 files changed, 1397 insertions(+) create mode 100644 backend/.dockerignore create mode 100644 backend/Dockerfile create mode 100644 backend/README.md create mode 100644 backend/__pycache__/conftest.cpython-314-pytest-9.0.3.pyc create mode 100644 backend/common/__pycache__/__init__.cpython-314.pyc create mode 100644 backend/common/__pycache__/enums.cpython-314.pyc create mode 100644 backend/common/__pycache__/logger.cpython-314.pyc create mode 100644 backend/common/__pycache__/singleton.cpython-314.pyc create mode 100644 backend/common/database/__pycache__/__init__.cpython-314.pyc create mode 100644 backend/common/database/__pycache__/db_session_manager.cpython-314.pyc create mode 100644 backend/common/database/db_session_manager.py create mode 100644 backend/common/database/model/__pycache__/__init__.cpython-314.pyc create mode 100644 backend/common/database/model/__pycache__/models.cpython-314.pyc create mode 100644 backend/common/database/model/__pycache__/mysql.cpython-314.pyc create mode 100644 backend/common/database/model/models.py create mode 100644 backend/common/enums.py create mode 100644 backend/common/logger.py create mode 100644 backend/common/models/__pycache__/__init__.cpython-314.pyc create mode 100644 backend/common/models/__pycache__/gmodel.cpython-314.pyc create mode 100644 backend/common/models/gmodel.py create mode 100644 backend/common/singleton.py create mode 100644 backend/common/utils/__pycache__/__init__.cpython-314.pyc create mode 100644 backend/common/utils/__pycache__/gtime.cpython-314.pyc create mode 100644 backend/common/utils/gtime.py create mode 100644 backend/config/__pycache__/__init__.cpython-314.pyc create mode 100644 backend/config/__pycache__/config_loader.cpython-314.pyc create mode 100644 backend/config/__pycache__/config_models.cpython-314.pyc create mode 100644 backend/config/__pycache__/server_configs.cpython-314.pyc create mode 100644 backend/config/config.docker.toml create mode 100644 backend/config/config.local.toml create mode 100644 backend/config/config.test.toml create mode 100644 backend/config/config_loader.py create mode 100644 backend/config/config_models.py create mode 100644 backend/config/server_configs.py create mode 100644 backend/conftest.py create mode 100644 backend/crud/__pycache__/__init__.cpython-314.pyc create mode 100644 backend/crud/__pycache__/user_crud.cpython-314.pyc create mode 100644 backend/crud/user_crud.py create mode 100644 backend/loadtest/__pycache__/locustfile.cpython-314.pyc create mode 100644 backend/loadtest/locustfile.py create mode 100644 backend/pytest.ini create mode 100644 backend/requirements-dev.txt create mode 100644 backend/requirements.txt create mode 100644 backend/router/__pycache__/__init__.cpython-314.pyc create mode 100644 backend/router/__pycache__/router.cpython-314.pyc create mode 100644 backend/router/router.py create mode 100644 backend/router/v1/__pycache__/__init__.cpython-314.pyc create mode 100644 backend/router/v1/auth/__pycache__/__init__.cpython-314.pyc create mode 100644 backend/router/v1/auth/__pycache__/account.cpython-314.pyc create mode 100644 backend/router/v1/auth/__pycache__/protocol.cpython-314.pyc create mode 100644 backend/router/v1/auth/account.py create mode 100644 backend/router/v1/auth/protocol.py create mode 100644 backend/router/v1/validator/__pycache__/__init__.cpython-314.pyc create mode 100644 backend/router/v1/validator/__pycache__/dependencies.cpython-314.pyc create mode 100644 backend/router/v1/validator/dependencies.py create mode 100644 backend/services/__pycache__/__init__.cpython-314.pyc create mode 100644 backend/services/__pycache__/auth_service.cpython-314.pyc create mode 100644 backend/services/auth_service.py create mode 100644 backend/tests/Benchmark.png create mode 100644 backend/tests/__pycache__/test_auth.cpython-314-pytest-9.0.3.pyc create mode 100644 backend/tests/test_auth.py create mode 100644 backend/web_main.py diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..df70479 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,7 @@ +__pycache__/ +*.pyc +.pytest_cache/ +.git/ +tests/ +loadtest/ +*.md diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..918eb81 --- /dev/null +++ b/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/backend/README.md b/backend/README.md new file mode 100644 index 0000000..f2c6b0c --- /dev/null +++ b/backend/README.md @@ -0,0 +1,62 @@ +# Negosium Backend + +DerbyMasters_Server 아키텍처를 이식한 FastAPI 골격. 기능은 **JWT id/pw 로그인**만 예시 구현. +실행·테스트·벤치마크 종합은 [레포 최상위 README](../README.md) 참고. + +## 디렉토리 구조 + +``` +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 (postgres + 서버). 상세는 루트 README. +cd backend +pip install -r requirements.txt # 실행 +python web_main.py # APP_ENV 기본 local +pip install -r requirements-dev.txt # 테스트 +python -m pytest +``` +- 서버: http://localhost:9300/docs +- 환경: `config.{local,test,docker}.toml` (`APP_ENV` 로 선택, docker 는 DB 호스트=`host.docker.internal`) + diff --git a/backend/__pycache__/conftest.cpython-314-pytest-9.0.3.pyc b/backend/__pycache__/conftest.cpython-314-pytest-9.0.3.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c84ed101cdc874a79dec7389be2d209bf1d49b81 GIT binary patch literal 3873 zcma)9eNYtV8Gqlsy{|nuP6aW5VI>Z7e%vKSqe1I*c974wi>r-JlEGOnWIfAM1?GysMJ^5-l;fYWID)HxfEAbhLU0Mt-;JH zx8gQvTQIB2qj(J39$ZqjR9R}!j^MJYY$dx8g`Bx4#N?uwN6C4|S+m}pZLSZwa?z&r zr;@w4&MFkj2xW%cThi+bWzDl$vA9jhGf(HG5Pk)Dmpmf#RIBztO-;C>x=y7PURJ!6 zni;6A4DOXUK~9L0qPhZds}L!VMR`H-VxrYT`ehM`=M+B75owqgnxg^_Z0DXpusU28 zsI7nxcbtm~;l_q=L=>8$%@MdJL@Fs{@fN60RzuT{>d?E?)((UkfmeXu#s_f-*MlTg z{A9t@BVwc4Bwk5M0t|1F6$v_9lPJYGMJ6K`sk{99WL}c}aZZ92wQ@qUe-|$wR>Xw= zs3;xwN5n?nFK!e!2z;|BN0V`X0~a~W3ymNVn#d9O60K@RILrxxsE{3oB@c{Ds=OSU zcJvHUID#J=?FBj2KLH6cHb@9koKTGo4g*0pSVOoA%9`|dBMe`FLjOf818t=Cz=0%i zh=Os__db893{;0RP{;yOdOklzgM^H1TU0`W#IcxUqZ1C@s^U@=9m`3GveGQ^^82xM z#*HVMOZ|Qo?^LlLbR0*eD4bN{s7XZ|RVKwd2!Q7gXIrE2>wwFz27WKb= zZTpq&{l1~4L-bH!C~`-*A&hRW`0DMiBa%c6R6pGK*lJ>^Ylso5vUNdryR-AoP6q}?1({T{4|3T6`&{}{z|wr^0#~EHp@yB#bCR3vn7hXmgG~Id-}sp z{p`T>*sjsE06VM8>gRg(%O8`@VD{bC zhA-%S9jx}74*gOmJ9TqV`}y_h?q2QOfRADH?i(e$%EGl3wY9+?REPIeS9W9 zUcY&p)h}MquXX6XceN|M?9|ZDh-ghi$e3U+UxYpT3Xg_N!rPX55>y&CCKxf!reoX*4?;>Pm4FfP47nScM-XfObR+kwfxPS0 z-d@DU2R=$oo)TulkTNm@iXz>;6#CMtiU%>@a9;-o8#`%2}kLu zqm)QvXJWmZM%nG-`QICN@0f7x7#!>pstYTN&*|vW@b|7p1ck(e<*~!i2tMMINw!<-O0qV;Qa`2`)#D5$`IX z?=iTXa@=##gwAq-%Dp^XPSf}D3kbahmlx6Zw%UQdk1*)mr;L`ygk!Un7dr1};PNc@ z{Y-|?D=5~w0`Yecy)aYn)Qur8Q#}Yd$bv|;s6`Pm z9v1~4@#O|i=6&Fg=~E8JIe}~DCCLZdR9$Ir4s)@Xv;jCqijnr>?C`8Kkx2WqEWHL= ztBGbIG=@Vn$jUsV1K%<$CYaS@%<6Hbc!Dl|Ko@@}6BIvLxE5c*CG=Sr&p4nTNI+A=}a&pthM6iH&uByrJaho1i^BrJ6n!(m|b5KaE( zLHl!>L~a0dK7gwu4b1eR{}Y&rZ3>zVm$D}J!3M!+2%hTa2H@^kt;0a*OP$jvu7d-e zA`Uj)Gpzr*mp~z0|3O7bw6N1BZfkvaL8$+9nD|l2bBU|hM^Z#CkvQfe>X5Fa^INAf zo7+0b{f8mhsP@uorX&&|^QAiJby$`JdqNU9YAl8yBC8Vq|CCkvhmDhN&pF343-VOq z6nD1$w;Y5)POcBE!@tA<#_@#(sGGKc+dAw5YQ%yA8T1HkCv*lTt=kqTagJo;K(2cv zhaogek+#SQQg5E>Xh=q5N>q^5jGc+r@a)%1;Y6$P`fxHN&Tn?m4!OC!;r}szeD;y$VqI;UC_)}Fvibp2;&a)f%98LeZ-t#CQ!~8 z$~hnHU;Y4<{1xT=1+9OG@*ko%{*E?2M6W)zk>3h445B;}ICl)^KBina|A_@rD`p5a pQ;?E;jdOdVL)j1T8;>bFUPBbN%n)iOd!DHcFELF2K>=kf_&+Bs`$+%* literal 0 HcmV?d00001 diff --git a/backend/common/__pycache__/__init__.cpython-314.pyc b/backend/common/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..833d40fca0092b275d93e5031df6885810f12f28 GIT binary patch literal 174 zcmdPqPtH$C)z3G|*Ud{!&o9m_&DBpzOwP_rO##Z} w=H}<=$H!;pWtPOp>lIYq;;_lhPbtkwwJTx;nhmnO7{vI*%*e=C#0+Es0M;!kxc~qF literal 0 HcmV?d00001 diff --git a/backend/common/__pycache__/enums.cpython-314.pyc b/backend/common/__pycache__/enums.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..dfde20d5580af298baccf34cf93460b488b9f378 GIT binary patch literal 3336 zcmbVO>u=lE6~C0Jhu=BzV2L7vZO*W zf)Vh!_niAX=Ug40^D}?g)L4h$xtO&7;z}(-|0GKBF&8$^w@nBoQ2=q23yo8l8aH86 zFVRh|nsFM_y{HZKA+F{S;^-ObjT*xbn`h`gG-LKQ6a$u4Z&PVs+`RzVQamrMocT?- z5qWTJAxEpBm&+IAsD|7>M|~kjM|pH(|AHLVko)IoEaW)+(mARj_s`K>$icjHj%vvL za~vq-IQG&xsv-B!aZv9z3v`>ctlL_3s|~ERW!5&Xt{)B5|C?NAz@*S@ED=@cU;@Vn zD5Y_n=e_I)A#njm;;~Tql?(lao<9a_63rk?aR{3N7F@$om<}Lp4p7($UuPN?S=W>f@C9`P$B% zO}HGB-%RR?5h(IU3ZclCM0`cW1`&^l*d$_|M(81O4|FLuL5KyTq97=Bf)I^|6Z2%> zAPDazg7bxwWE-=`)4@BG=CWhn?`4&ipZh#+ z!Rz3BtiW=dhf`XM?i~Bp6zk&+$5&i#p5@$*34wikl=bp1k6Y;`McvCbI^km7yio8` zTJ=NTBTPEn{yl3~H5Z5ROFQ*5LlJn-S=J4?ye^J)Dux@!j&ZDS{Cj7HTk+C{Um;>E ztez>jRPog19`h(|MQgEm?AtCMuk3+GCOo5OS*I}NV>zWw5J=ZVLP8K@DKeYD(2;^< zgBS3ejF&QQpOQsMwnu{!yu>TP*d@DDlrQ7>g8i3q>9Rc(4~zErPvSq0iI?JXBoVdG zLNmw2Fq}lA@t9qN;g<&&u1Ho0rwa>g6Ra7ld#_`7TQt#v{o+T+`Bx^rhK|L8swo)u(KmVtYPXfPJMBub37vc#yX zU+a9c_!Urvy800Va1y0RFnUY42dol&upo(izex2&knaqs5rjfdI$Un4kLaX*jC%Hy z-D^d095yof3Msw$hQ^fBfD%;o8k09uR^t<;8V5*?P1stfv&}g&&FPi)g9|4H!;GDo zmLgaLy8Jy9aIfatgY}(HKT@yXhI+#EI-EaZ)JMtu#&_CEl9;AB7tcZoZ)>YMV`?9+ zX`enUl)F$1+ly;qEksq|MnYBeXr5sH8HfllXL&VBaG( zsf(QVmd=Z8QwU>_kD z<6qLxq&FcLnO6B4$fq?FMg1Lh{|oi)T6(FYx9D92%d;^G9Xr0|&z_pioSMy^dN*_G z-Sm6E`Y%+Knl?Q%7G1kwXctVo2pyX-r4f@mJg^?g4)K{GK09^~2Vy}7=@ ztrOXkfy~K3_GCD7GMtXS56~F(7J-sQ*UlN*IpTP98XSB6|BSEx^slwq)1l1iQ1)~r zb2^fiehoQfO3y(SU3-UQl&N<}#@8-D#{ON4nQFYo?;==sTWIRQHT)k0i{97&1FH#Q A@&Et; literal 0 HcmV?d00001 diff --git a/backend/common/__pycache__/logger.cpython-314.pyc b/backend/common/__pycache__/logger.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d8d5b298d4ba005f01927d45c28615ee97b6d921 GIT binary patch literal 3235 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 Jb^%3H@E0OF1rGoK literal 0 HcmV?d00001 diff --git a/backend/common/database/__pycache__/__init__.cpython-314.pyc b/backend/common/database/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..dcbaf28d8fd296aacbf89b3f64e7df1ac09d36c5 GIT binary patch literal 183 zcmdPqPtH$C)z3G|*Ud{!&o9m_&DBpzOwP_rO##Z} z=H}<=rzDmnCM6c9>c_`t=4F<|$LkeT-r}&y%}*)KNwq6t1zG}fMlp!-iJ6g+v4|PS F0sy9$E+zl~ literal 0 HcmV?d00001 diff --git a/backend/common/database/__pycache__/db_session_manager.cpython-314.pyc b/backend/common/database/__pycache__/db_session_manager.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..36fecadf0a658d09335b39735e0954368b1a1778 GIT binary patch literal 16105 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 a=HQ+i@c0>kYw(QHT02@)w=bW5<^KTuiLRFb literal 0 HcmV?d00001 diff --git a/backend/common/database/db_session_manager.py b/backend/common/database/db_session_manager.py new file mode 100644 index 0000000..c73a83e --- /dev/null +++ b/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/backend/common/database/model/__pycache__/__init__.cpython-314.pyc b/backend/common/database/model/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..eceed956fb1fa614e7d03f09dce9521e7cf92789 GIT binary patch literal 189 zcmdPqPtH$C)z3G|*Ud{!&o9m_&DBpzOwP_rO##Z} z=H}<=rzDmnCM6c9>gNJg=IF=AXXa&=#K-FuRNmsS0g9F8q}mm+0<8i$rWnNd#LURZ JSi}ru0RZh2Fl+z- literal 0 HcmV?d00001 diff --git a/backend/common/database/model/__pycache__/models.cpython-314.pyc b/backend/common/database/model/__pycache__/models.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..722b7cb1972fca5466d3498f8245f33b8f3055cb GIT binary patch literal 1569 zcmZ8h&2Jk;6rc6Sdi@pKAx@-75T}6JRcTzIl=c9WqzST-5@6#Zgm6ad-HE%&ek8NA zNlh<4fjE&W5{fwG7;Z&ea^*h=iYt_nDyV=1w^Tuh8@$N* zws>8srCBJbrq)jN7ml(C&Hc!oZo=tR@D|0#{4a*@AA;HLf248t@ zw$WK9M*sToljin`ddK zLv3afEnl04SYl}jKqLo<>;UoK1%#}n28i4MaWTe4L_S0eLFB{*ze1=jYKdkE2vr8r z(}T#v5#j}V=4LF)l)a>7*~lk|m9-p@pXXWDvaxO$Uf{Cl2ot42$;#US;)3wfq8g{7<#=LYroPCNo4Os4@6oBi@=vRGeM0@Jqr;Puj_$UrbHra` z-geGfCBZ)4S%9w&z=cQ*G(S8gWgqIF9F5O`YkYQK}9g?P&L>);rOa!xt)> ziaH_>*ZRY-bIYba-A5m}j{LB@S`f z`(Rewz_#_4O}HGm)NE0+Ui zTvpGpLs0o9p}6m&N^~4T&?0P$>L^z*c6Enf%rh86b%8D99L5`gZucz(j8`n`GaJZr zJz@4443FUucF!{-4)PcSMK+0CsCXqR2A#(P%cQc9VwTBc>swHmWvmGt6{Es4mXBMu z2Yj1U5pHRr&D*-qu3;JaH&^#r&~KN zjqRrPUq2}KO1HZoJRKR|y|qt%nmeqMqq&vdNV~gmTpZoL|3l*N#>Yq3wO(=Q%X-)? zVYZ9v5Fhe4Y~42Q5vMcdQHP3u3Z6Mvp7S139ugdq`kW`=Ba(AbdruKJaQx^U(_(z^ z?99@9M3Lx|Cour%WM>0E` z*7V{Nh!d$Ip&WAzw{pvszkr|^p^Q{P1su4g3PRlA&93959ZR!s-ut~b@BQXIUnv!H z2-c6=+S*PEq2I;fY^D3c=^6-Kw2XAAf$9>I#)O<`Brejcp z*;@p+jew|WF3@2Z;Ot@!ti6tY-#@!T@nG#zjOiLX;qK>VjW6(pL0Ts|tiFgXq~o zCt6^$H5h`ei~cCrzIpWZqhHG7Kb6N1m2p}HbyH37{06llp$=Xr9bPm-=G&f0iAy|o z2h55WI*!qH2$w_8-V6yxYJ!f4axxZKF+zuZQGRQwIg6W(`P+-l>G`|r8K4B!ZV`(6 zI8~yTz>ij7TTn;1g0W|~1Y@4V7%B=KAr~;-3=OAmDPz21(||cZnCA;~z+hlZm$3W3 z6&GH_7-(@w>_WxMP%+#*8QK<=g_N)@p4fN*)!62`z)>+CJZA^E?fAg4MHS(e722X> z1PnX=s_kKe@hq?bBPYZf#_{!VfU#_cj0BKV6=N9WAh2wcnZ$80hM9z{21;-f-Sr_A zZ$S%ZTONeqTiE{n3SQ{9boF^cN>7|7kvw!Fq!Ss{E_dgj~XgI zSl*^PxxJ+)Q@!lX?o6x+cJh1Vi5_dpwZ|*F`p(+^^@Gd3^84NTadmVzv;XEntyjI( z{rK7NrQMtR 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/backend/common/models/__pycache__/__init__.cpython-314.pyc b/backend/common/models/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0c1f2d1b17d5d70a746b8d6801dc2953191dd7d2 GIT binary patch literal 181 zcmdPqPtH$C)z3G|*Ud{!&o9m_&DBpzOwP_rO##Z} z=H}<==K>Yv6zj*wXXa&=#K-FuRNmsS0ScDpq}mm+0<8c!q8P;Z#LURZSi}ru0RTVH BEpq?> literal 0 HcmV?d00001 diff --git a/backend/common/models/__pycache__/gmodel.cpython-314.pyc b/backend/common/models/__pycache__/gmodel.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..07b55435aadaab027f80edcddda43e9bc55ee604 GIT binary patch literal 4512 zcma)9>u(g-6~D7HyF2@^*E|f)8t`HhD+{)lhpW(n5)>R_=ixYGVp=t3vh0rC#p@a8 z&fP>#zAP!F4ulp|s+tNCr=l&cqKXDkE0z2Y*)|Gvpi-l#s=_W3&Z0W~)^qO6?1L^e zSDG_-&OI~t-1Gd+mgc4)f$`2<;_!F*`$w8tBLxkboGb9Xpn5P5h z;*dBfF=^1ty!=@j@(uc#zk{@rjYRRTC5o@#V}FUsoaYCgJT8sP{gT5EaDD*z!8(4B z^FzQ7j|cljM>@p0O~7rg;f6Uk0^An(vMbx92wfz;;!AvWI4)T7i!&@^=*eu{V+Ec{ zn%WCSTFYAAeHksAUKFgr)07%?|LlyGvUjto;5?M0Fv*i~!UTmd&%po_6@f{Lhj|qd zHj=E+D5JR)Em;W+gCpGt(Un-~#QEaoqorRi#)`k4 zEB*OG?9P!Z#rNh*$1n6Pf~G!8R#iPYt*NRNRMlxCoy+1ptg5f&l38aZqN)=aYO-ub z*K`Ax{i>QaQZQ*%Rg)!ICZ#4BqnSfFrkScr(b;7@i#3vX+0>|+m`+l7c{Zs}CI&Qf ziWxJBHw-$JNP%4v#&)Au*Cq`!lbcQ)N~WeXJq;_<(}tb^m9(sxnB@1qnOPcu&vJqj zQwDO3{7u^Zg(TAz?oypmov0w?_h30$Yt{%8*AN5Z%{J}0Z=@MS?i(X5_c7p+1s))~ zx`AwHjO-8;aV^QpiUhQUg*gXeHY>9x#mAb*2#Y9waIEY^#*jW?`3(y#MmnMmHwB_wAv{KmbVa-9o8#8F%> z{~jJdZ9#hp6@CTR_!y~`c6pLBskw~_BMrDa2ML%$N7L}pSPCR6?}7v`d+r;~dqj6j z9#KdazZQkzxSv90B$j{l<-L2K9v!v3uP3uP&GI`=v?TPQC1Fll5;_trEty(&g06-4 zxOKa`ZR-X`HKy-)W#)6m1sMV2(Ih|^7W6&+hoZ{n%A68pFCKQI^pPouq2bvs?$48_7zwXaq|>8=(d>Qbv|{kKsjcLp$L)xq!VX))Ja<37V(pvh zwAa)Js7ANqhM3tkO6~LM3ujDE!@~~X3$xW>L z4RaHH$@9<_Q3v_nLBh?OEHcE zO6UJn`uI%o%wqA>JmQGbxrNd~-hquSoTv*M4}1mBP{tb7IBXQqoP&dENU$8$hk4FC zJOo-@ZR@=LIBZn~0zPlg5&?3tpWnEnxr!jg2ZBPVZgTloFhLco5Mb0hMqUNa_uG=B z17Jc6*#~Et5ywQBwZcq#1!IGc1JZ&1+J56A{togSblm{iK%R@)_c2eyw>LffZ3F;x zJ!}P~&}-&qG>V8kqh|mxLF?B5AVIhC(M=#?Nl7|sTE3|__&tcQ3V$-_tR+rqvsS<< z{{XdoAl|*XCl<5e7R3~|R@B(m-htb7AFKhux+OQg<1ff=Y+U&N^@1XV!K)e?f;YZIfSg^$Z&igriVuujxMW^^YXhSZHrxn! zuMraLcaB$eopjhVbjzF0P0yI=XI1>PcMFR?0V;tFdWqs8w6OVeUdH0p|74$r5v!wB>mGs0 py>3AW%m)_W2`2YsAfK%3bsj`v1Al<~y>N>VpL^@y1a3UA{tLf9rB(m{ literal 0 HcmV?d00001 diff --git a/backend/common/models/gmodel.py b/backend/common/models/gmodel.py new file mode 100644 index 0000000..b09f002 --- /dev/null +++ b/backend/common/models/gmodel.py @@ -0,0 +1,64 @@ +import json +from typing import Optional + +from pydantic import BaseModel, Field + +from common.enums import ErrorType + + +class StructModel: + """프로토콜/구조체 식별용 마커 클래스.""" + + pass + + +class ErrorInfo(BaseModel, StructModel): + """모든 응답에 공통으로 실리는 결과 정보. result.success / code / desc 로 내려간다.""" + + success: Optional[bool] = True + code: Optional[int] = ErrorType.SUCCESS.value + desc: Optional[str] = ErrorType.SUCCESS.name + + def SetResult(self, enum: ErrorType): + if enum is not None: + self.success = ErrorType.SUCCESS.value == enum.value + self.code = enum.value + self.desc = enum.name + + +# ---- Protocol 규약 ------------------------------------------------------- +# 모든 통신 패킷은 WebPacketProtocol 을 상속한다. +# 요청 : Req_xxx (WebPacketProtocol) +# 응답 : Res_xxx (Res_WebPacketProtocol) - 항상 result 필드를 가진다. +# 각 라우터 폴더의 protocol.py 에 Req_/Res_ 를 정의한다. +class WebPacketProtocol(BaseModel, StructModel): + pass + + +class Req_WebPacketProtocol(WebPacketProtocol): + pass + + +class Res_WebPacketProtocol(WebPacketProtocol): + # default_factory 로 인스턴스마다 새 ErrorInfo 를 생성한다 (mutable default 공유 방지). + result: ErrorInfo = Field(default_factory=ErrorInfo) + msg: Optional[str] = None + + +class UserInfo(StructModel): + """JWT subject 로 인코딩되는 유저 식별 정보.""" + + uid: int + id: str + nickname: str + + def __init__(self, *args, **kwargs) -> 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/backend/common/singleton.py b/backend/common/singleton.py new file mode 100644 index 0000000..47d87e6 --- /dev/null +++ b/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/backend/common/utils/__pycache__/__init__.cpython-314.pyc b/backend/common/utils/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2887bea40846659021075cce2ab484ccd03d56ee GIT binary patch literal 180 zcmdPqPtH$C)z3G|*Ud{!&o9m_&DBpzOwP_rO##Z} z=H}<=mzHGa6zj*wXXa&=#K-FuRNmsS$<0qG%}KQ@Vg=d&azinQ@rjv{k+Fyw$N~Tk C6fGYB literal 0 HcmV?d00001 diff --git a/backend/common/utils/__pycache__/gtime.cpython-314.pyc b/backend/common/utils/__pycache__/gtime.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..927e8bb89a2fb5a095a9ae825ceb5da346c37412 GIT binary patch literal 2434 zcmcIl-D@0G6u&dGACs?6Qj?~BR5uM}TNjd9WjN|Oweok_aQ?o4{; zt|hS#i!6vnX!8)-YNQc-Xh5NUAbIhBNJvX@UKI4D?FJ$XrjMR;XTQ>dRnQA_=AQFA zcg{V(bMBp24>Z*i81Lp{7ZycAenX`^0;Nf@2PQc(MKs|Zk|2yE1STXzCcZ@4NC(lx zcA`mruo?%Gv_L0OgZEKUB~LaDX0cSp@??~Yj%|5$I5Vv@Gm_|49+i04!D2wfNx}q; zFmWotq(j7k_$)9P94D^}7&9$v63FJh(s~w}3~G%W4RADwXj-V?;nLa=N5lKp$|X$Z zSlvFXPOE42_0mTaSQp?hu`HRg|&R)+Bb!3S3!@QIXxJ8uyMQa zRW4FkT77UkU-$w%KK#1y+4|1PbwDD8)%?!ND!kTqmNt4KU4_rDKKysQq64RjR`b#*nIid<)h%Kh<8AlCrER5N zNm-Wk!ql2i8%b-O&}J~wCXPT;cy)f-Fb#vv?F5k{zboxmE`M@)HF0zJ#_+bH-cx$F zl-`ZjO{I5RIk_pH02m9aSUd*e?;1c8c^inZafxDbo-~tT6i+;)A-RmIB5eST zHi9VCo^O~QK&t|RBhSO6R2UU2qAm~a;_KT^u2r`Peth8eczqHu*;gM#9vkHYIvRWo z0Ck*{s{=_0BajDjN83SoG9)mA9R^+f92i`z(=ng$5U@PPsMlEG95hVTeHH}L-g;%> zNLGW41%G^6?qx;@G9 z(48Z_ca`2vxtC`^4}lw8W{WM+v)$25chrnT6UU>cjz`DTMo-pt+sNQmU9amI$IPa2 z4wv}FtdTAuimuP3sLRsugRmWd!@6!dN%V8!o=+t+7CY~l{HW3wF@cv+bc68P;7`MS zbb~RPn#NNC3tMXbMM*22)^#}9`~t#mc+ zlt}2{F3xsCFhiSvYgGWX0t{4a0~K4G+x~=Qai1%UHlhaj+2uFA-}-zf$fNJYFM9ce hPuEG>4*+i4Z3D3@34-toiEokkUZ_Qg7YPWy>@Q2n?+E|^ literal 0 HcmV?d00001 diff --git a/backend/common/utils/gtime.py b/backend/common/utils/gtime.py new file mode 100644 index 0000000..937513b --- /dev/null +++ b/backend/common/utils/gtime.py @@ -0,0 +1,21 @@ +from datetime import datetime, timezone, timedelta + + +class GTime: + """서버 전역에서 UTC 기준 시간을 사용하기 위한 유틸. (원본 DerbyServer 패턴 축약)""" + + @staticmethod + def UTC() -> 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/backend/config/__pycache__/__init__.cpython-314.pyc b/backend/config/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..df7d9670be434e68f5447d4e3c3c06ef5a5c9c43 GIT binary patch literal 174 zcmdPqPtH$C)z3G|*Ud{!&o9m_&DBpzOwP_rO##Z} wrDdk;$H!;pWtPOp>lIYq;;_lhPbtkwwJTx;nhmnO7{vI*%*e=C#0+Es0Mef-rT_o{ literal 0 HcmV?d00001 diff --git a/backend/config/__pycache__/config_loader.cpython-314.pyc b/backend/config/__pycache__/config_loader.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1896acf153cf3a4d5ee9c79339b4c43f55a26837 GIT binary patch literal 2710 zcmai0%~KRd6z}=kFZKgO1^JNWLz0b#MFrGI#S~Z;C_&iBN(7u5b_X0CW;Q*&N$_Cp zA(fa*Vx=loR&t2m^cHjTX#N5SQs`(ZmCDH*1}b>SA@B9<;)03kt$p3|`t|GA@7M1) zvp*UM5oq;=YeN0LTOiqcgi{w`H z+t!RR%a`AU{S-H#s+KmVtEwAP)j2zxH*p+R)sOO;S)7Tf>Wo1hW*U}m*)Y9JRUM`= zBcp1JQDZvKbVpSwW^xVVa7U73j!vEAoJL{Iyk^ZNPwLJUX6KTh*z`&=1Mw&AL-yO2 zK5IKhel9t!Wv*Cy7G^NOVyBw6mepxTZk`6gEf+o*IEc*WTse}~ni#KB}4x(2mkTkg{9{r4vENC0ZsBlS0 z2_Fa(B#Adfe1`UJaK-?DavHk?P9=xYgy{NaY&xegH=?Q;Q*)dw+!z1|?OO`83YP(% znq}F58KS$h7-L7db{E(=H6UIT$OaLVtHN_GZ?}3q&vd4kEHLwehfo7+hu{IjA1NJ; zPkSOQbQQH}Vr|Z+NGrK33`6j>9Ankd88fU|N1dVeoXYGu(+zGa!Ay2^bB5M}PkyfU zcJEwpdkxEg)4G)}&*W+Nc~BI{qtUUIQEPG3TChJ|8lAr`e--skgYlGN!h6Nx7=pp; z;7Ex83Ic<@bf6B*iw&O-i{+XmFFT59<-xl?s&9f4hyv@*gudh3#L=@=+xd99oQ(mOvk64`2jPv^V;ZEZP3 zrHH4+GvF{t_L7_r_Vl%qIN49$D7u`5lQ$4UY$A=MiT{R-<5D9rAV*CQX-+W!K3AmE z!wJ7D+d18G{dkQ{W7?IG#yEMoQ7Fv(G%r(5nHAvQjRNoLruwmF8d<2&grBy6+Pg5q zj2lKjYR0yhZZWqC2EdSIZrpHD#ofAo@5yoe;H14hj*V9fjpJtVe!kx6_^-m_ckCN5 zEs&>?svC)$=T;&u4G#Py*Ki9}o1eWdZtm4=?hhMuK{qYHsO z&wZr%(3&DwhklPAyb*pBt6zz=EymjJgcoC7>k5fX2-k;J>be)}x_>lR`o|ah$CrCP zT&lb9OZdX$_`y|$1S?kwEMEeOuEPQ67Y=`{MG0wtXOf zj#TtD2Dfl4))g=2Q!sd4F=Hv0-v(aXyuiz~uXmH+?jY-b*ylDL&nz_=Jl$u;RA-`G`Ud=dHxHN&BNtB5+V8) zcyN7eJ_oNM4yT7MXK0Ms4dmvtngt&{6bs8c0uKVo$#ccuwSivmGwNM>940Vg2U%oQ l5(ME1iT*}9mq_QYWbe995P~-n>jb)u+G?S5jey{e{sC&>BV+&o literal 0 HcmV?d00001 diff --git a/backend/config/__pycache__/config_models.cpython-314.pyc b/backend/config/__pycache__/config_models.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2468f3446221e0639e72fcf0d3a4125799cd2a24 GIT binary patch literal 3052 zcmbVOOLG)e6uv#v^L}UELmr4?RZ7Sxpil)ODg^{d)B)qdV%pA3Cu!1|9&XZh!|cYca$&h*$0RA^((iQlV2?&nr)s`?&bjY{oI|zld+_--BVZHfu@q?1m(y8CGU5u~w+cTH(9WxvsVj|R*MYzID ztW?NtCL(P~L|Y*d!#}>n&PuItl^Jo7XvreE#6+qU!F*9C_o=pgTjLKj?#<}1YaIzg z)1Q7g8AiL@Ujkf`Y_`POp$p$I)O?F%Qj9m=1q*u`fwgfvAu*1F`LR=2m z5wd9&PiR+8%5vAC^wbT_k^3eWPNvVzj%Ha($K31*C#qYDWf@KkrO+&~6L!Lu;7(Xa zrMzsK#%#!mu9$p7MPEhf7^-D;^^Q;!i>5eXJ0CSa!8BHLL*;r;+g5w4&8wQVF3f&& z%jE0L4oS;z7ie!e0PG2}-9z*E>781chNwJS=op9Ss;pxGk3&U}W!mFV4(yoRBNBPX zS3{%4*#xhnUR$zD+*qFw(l_%}_Lz-}O zW7P2I8~YqJV>ebDzcJiev=#Q`3U&hjB>WW0H2xXfVz!+Wxpqn9-P?`0a#m0fA0dP9Av znD~OM80HqwVolE{c0G7UW88KVVFv?v+|J~8<(X&txH!^BlFiykAIWKq^pP^4G9(EZ zrkUqt$jP#NaaXPmbL8Y*r#>u@Gr_VD*z&MQPRV!5~6CNRPWOf2sn{3ITZ^Vh;+!A+8d}ZA%Jz9@% zVw(SvpOj;N%TJor%bBFwao-o~+P0J8+6uH{O(Cb?S7;CWI@gp9z2{7f>it+(-3HM~ z$ax;{eA1UkTO1=i$*@J2?h&3?TJs3+y$$>qLG2hNg8w3@Pr})w7eSDGsUykvHZATl z!8-(32wuRai`0g|T^k9y$0wIX?q_>y=0~|am-%ne06hGhoy8T6=jyVvpWsD!4*qS;sHrW*1W{ZlfIDBB`=0Y!}(B4b#{jxf? zEF}^dj)Xn12qpxUERjb{G~vNNz(=4l;(ghSF=knpfIQ4c&$qp0Xu5qL&i9=2{hZ(D z-ZxxjcEEH0c3`yH2=E6JiigvgeUkw|fDA+&$ADJ91r!D#Lc)X+ybWPP$ViPIrZa|3 zAu~0HEYuR>C>OF)s|TW_=ar(%=XcN1=X{Zim?Gv=7>gb1K)b2}f0*{v9F_`v)OCs! zI9mP!tNIdF=?m5mPT5NzUixoBrid72gqZHG`s|9JwR3=(#Vhz z(f~f^V^(Rmk7(QnvNW8Ct3H!f{<%2VD=ODSrJ&PnAIou_b$%iwqzj$8WbI1R&*U*t z(zuSE9{yr?pJqptJD+#t%`H!Rb6+m!roVrBZ!tH$k^6SR zKg52d9H;0k7-22z5Oki_eP6c1bw)iElM2hy@K78sIaVn7bcZ%SfZRudDcz@RXM zDa7!FtyJJY`K$i%>zb1<<{@GdM=%TvrGAPe12eWM+cPhaPO=Rh3-4_~$6h@U`?KRf z0^8u93vYpcuYqY!06DV_Z41sVXxnRKnkFD`Z$ogQWeb9TH=|}BU4rj51wUla46>?K z>MK>a_@pevL`7i>^Z%z5)|FICwMgS|9}|i!X~wun6*fQ3G%nB)jf*7|Nl1yBmFI^O zNvuwD=%e8ANi&Vg2?@*BqcezR5x+>NRMp&tD$Hvuh-TJOo?D_9wJFgx)Ol?pnZ6 zHh*&VDgHsDMgU5qz(8Gg6B@3v;`9t_*q`d$D1{&i2bv11e5S?JiQ@~k+P9IN4Ml|N(8 rf`7-;v~pwV##(#U)0S~%p>3z`#7h5C|5{bHF1QI5zd%r_#9IFap3a_- literal 0 HcmV?d00001 diff --git a/backend/config/config.docker.toml b/backend/config/config.docker.toml new file mode 100644 index 0000000..2aa3f7a --- /dev/null +++ b/backend/config/config.docker.toml @@ -0,0 +1,33 @@ +[WebServerConfig] +server_name = "NegosiumServer" +port = 9300 +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 = "negosium_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/backend/config/config.local.toml b/backend/config/config.local.toml new file mode 100644 index 0000000..17a0384 --- /dev/null +++ b/backend/config/config.local.toml @@ -0,0 +1,32 @@ +[WebServerConfig] +server_name = "NegosiumServer" +port = 9300 +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 = "negosium_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/backend/config/config.test.toml b/backend/config/config.test.toml new file mode 100644 index 0000000..259c43b --- /dev/null +++ b/backend/config/config.test.toml @@ -0,0 +1,33 @@ +[WebServerConfig] +server_name = "NegosiumServer-Test" +port = 9301 +process_count = 1 +is_ssl = false +is_test = true + +[LogConfig] +print_console = true +log_level = "debug" + +# 테스트는 단일 postgres 의 negosium_db 를 사용한다. +# pytest 가 tbl_account 를 TRUNCATE 로 비워 격리하므로, 운영 데이터가 있다면 주의. +[MainDBConfig] +db_type = "postgresql" +name = "negosium_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/backend/config/config_loader.py b/backend/config/config_loader.py new file mode 100644 index 0000000..8e1f7a0 --- /dev/null +++ b/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/backend/config/config_models.py b/backend/config/config_models.py new file mode 100644 index 0000000..822d9d3 --- /dev/null +++ b/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/backend/config/server_configs.py b/backend/config/server_configs.py new file mode 100644 index 0000000..6a6adbe --- /dev/null +++ b/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/backend/conftest.py b/backend/conftest.py new file mode 100644 index 0000000..58d40a7 --- /dev/null +++ b/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/backend/crud/__pycache__/__init__.cpython-314.pyc b/backend/crud/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0a9b441051c0af992c34e021bcfffe261a85e34c GIT binary patch literal 172 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 D()t}e literal 0 HcmV?d00001 diff --git a/backend/crud/user_crud.py b/backend/crud/user_crud.py new file mode 100644 index 0000000..b2a7ac6 --- /dev/null +++ b/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/backend/loadtest/__pycache__/locustfile.cpython-314.pyc b/backend/loadtest/__pycache__/locustfile.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a555bd301a4087ba041b34c22aecec8a38590929 GIT binary patch literal 4383 zcmbssTWlQF_0H_J*-3nwBkd*5#(GvCE` zI+&wVU_z&pIXhj<6(TFhIwCq&5m7kcXmu3cXYK>eo+_JZdG4tx!!{&3SCQ7@g84*l zJqf$g4+EoaWkA*A=^<*Kzh=IFo|>cM`Hx3w{*#fpGg&(K>M8TYrMVN=nteX=1HxSytNb6Yz#-+m1wq~cP!`}S^vIige0`_|OR zCfbt!WVE2)|D^~T$bLX!MUEvDU8hZH>(nL3one>2Sm{o7V&Ri&wnrptbqmHxa$dO zo*B)b%!Zpmu0kN>FJA}ST>(qxC#UQfF|WN>bjO}!z0d5X=BJnPqnFKBv*;9jWA4nv ztxvNw|G`B5F%x{JxLq-4r^d9U5cv7 zq!MLHtYHgiurZ~o2}zG<6xv9K$MA7;a>TsAIr7au9$bJOOcTRx_aJHosk`=kc z)uc{Qp!{gIWR-?KRRvzjV{{Q!1I@n&(FJBYH8FQ)98K!$4hx2-6C!r6u4sn)kiv!) zC26>nBTAC#hQMTfFly%+&O&jq@CyKBNbm!MaggNv!H1Y*B~i%%0g{;$`BuI_$()$l z%q0pix(|5Pk%P`wuCTWxalO_aU)pmmPHJn9EA6=#?fFFy3+y8-D0(6BeTHX8njJ=U z1`3DV+*Sn0_Gs~3Yr_j8)CkYQJOEgzo-feD*i)3fga82K2EA;p0;GlCN0KEv5m&|1 zMWC3$%C;2QM|!MsxtJTA5nQ^)l_-c#pf0^xrVw3#ca_&RB)UaU#36b`-($toLwH`L zyU z=z~m6MS#^{B&x=gh}xnyC5weWa!8H>^B5GM|*9*pTR5-WmZS`e3KlrYp~)f765I6_gOcmIsnDfIs{8gX{H#l15_d zJOKV*L*jgh)RU0)Ly9eqdJ^Zt!AwOd6Ro__&<9!ohRg#?6V=-83p+F{5gJ~wjEbnt zqK73-(NoY%6i8Y{PbVN(^>j1}&D{vd)+||xnyDx>^L`nwJ+TgIaI~ZQUwGN;?hgC4 zmB@A#4rnqBtzh^e#aJ5qUQDshH5>y9(^eqC!${ypW@!y-3sa7=Ft4GR3;`w5vBYkm z+CxaZ8qt-dOfA%iMnLL+g<*u`g4L&=JO12kuzn_3p9`$HFaM^iJ95<(y`*C8 zx5QcDyNd(lU`0)?@`2gP&`f3M-ABKy+%VDlVf$qJueW@-ZF1YSw$H>Hjh(ZNyJs4A zfAQ>W;F0Xe}l zSLOnBxfS1=a8C)p^L^x-_{sE!$ERz5n5%vmEAr}Zup(<%`j;3Q&1D&SgT)*SviK$e zIDE<_!!iTc9phDoT=D>z%+t$4h}FyxkJ+mj&Hq$+F#|UEVq0)e=Ph zv7+mX_W&W|iGa})>~JO%upClcf$&%zx*;3C+X3h=eATEb=~BdW!n-==>mqH?{8`_` z+Of*7JZ+X@;9?B)f}gQqbcZVpk0jysL6QugBn_#tbOPakB)yoH6UCiMN$QVlI!nMk zAgM}{h9PKphvhhvSbRv))*xFvUqgFo5gb}@_&yBA3JKdji$^7yF)e;54WDyxMzthG z6L2qy#iJ~$BodMo=8WJXB)o=>-(Dz3*g`ZMusG>482Hgci$LU*e5La*>q1&}3Jnvjt!~=BOvk2dA;lJT|XS-!M z>)0hBiUz={zkr&LkmpMQDw0KAhJ*a%(@z1(y&u8wVK8o9cpW|;3m-hoe61|`*rB&R zIR)Qm5afn41=les+lF6B9*JveatJSSuEAkh8_){{d{ZG|y^?*q(lFe+;jg36Bjg`~ zciPwcmC#p+3%}u(q?j57&th{nT!oKT4TIP6wI(2B`4&B3`0#_gc$W*vv6z&I>r6>1 znsv}5DOe31Wq2Mh1l3lp3zldneHRS#4vyn)^EI5Xu#s>T|0FFpNXuQ%X0Cng=v@NS hd_PBc&n#Cn!_~}kp&2f8o9DQ_9Jq$3cP!94<-bvaK=J?p literal 0 HcmV?d00001 diff --git a/backend/loadtest/locustfile.py b/backend/loadtest/locustfile.py new file mode 100644 index 0000000..af9f02f --- /dev/null +++ b/backend/loadtest/locustfile.py @@ -0,0 +1,80 @@ +"""Negosium 인증 서버 부하 테스트. + +실행: + pip install locust + locust -f loadtest/locustfile.py --host http://localhost:9300 + # 웹 UI: http://localhost:8089 에서 사용자 수/spawn rate 입력 + + # 헤드리스(CI) 예시 - 100 VU, 10/s 증가, 2분: + locust -f loadtest/locustfile.py --host http://localhost:9300 \ + --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/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..85c900a --- /dev/null +++ b/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/backend/requirements-dev.txt b/backend/requirements-dev.txt new file mode 100644 index 0000000..4827277 --- /dev/null +++ b/backend/requirements-dev.txt @@ -0,0 +1,9 @@ +-r requirements.txt + +# 테스트 +pytest +pytest-asyncio +httpx + +# 부하 테스트 +locust diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..2f928b6 --- /dev/null +++ b/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/backend/router/__pycache__/__init__.cpython-314.pyc b/backend/router/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fce9beacf5a1b3bf0dcfa641fd04288c6f40b353 GIT binary patch literal 174 zcmdPqPtH$C)z3G|*Ud{!&o9m_&DBpzOwP_rP0=sP xFD*$e(vOeN%*!l^kJl@xyv1RYo1apelWJGQ3N#yJdohUdiJ6g+v4|PS0sz|lD$xJ{ literal 0 HcmV?d00001 diff --git a/backend/router/__pycache__/router.cpython-314.pyc b/backend/router/__pycache__/router.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ffea0b964ea34b99f90c480093a9ddd1430943b6 GIT binary patch literal 2489 zcmbtV%}*Og6rb5$d%=JqHejLzFds5;(%4CWkdFq5HUv9Lh`S~nst{|j7uaC$ZfD2& z@X4W-619h>ZKR+!QWLdOn_CV&^slI((9&*#RH>=j94di`TIJR^>os<150yI7&iv-h zn>TOX`_0<}-UbhXmb@Fi^1Fc0&umg|ZU=0Ag%K*E%Sgg;MA^v2ag3u}C&PT4j|o(W zxv0yY3vqW$q@s;o@tRmIt+lZ`?upgWIvb1e`d9;PurZE%Y2#EwB3M4QGIpT>XF_*P zaZ*hHNwoo#ZQ7Rlri3med8T|)-4It&rT!^@B3!XiMpL3xFEy04IuU3cD6dsU$vXgd zWld^?)0+NvS}W{{mR75KX_w@a{8DoiN3qmWK0#^?RJ?D4o&D8&N_eUyLKC*-OJg#*u-%6J%Ce%ZXCrPI1bmFqMoYx1l@!ki_Zkxe?1h+Cqh zyp>l>3aZ|*H&x@jn#p99Ycf%oboRVfCbHavL z8hH4&dZ8<#NpzVz_6|ZBknKfp;5jVeOR!REQRXD~ob9oA*)YPG2!O$|dqyWYnVQUO zw-t|GG!PPg0nlh`KYCad8mecc!mWdKQ zPZi<}5#xqcpGwJ^rc;?JsT64h^G)!Xe&~wmPbA>JP1fBP?O_YV$i5!~tg*ZC;PBQY zfNs^xFn)mhgok1n7yRQ+(1Ig|8cZtWs!a_SRcThS4XeL_uaK1b zoT{n0d@g0G1;rBPC^i0X?nxcEV~M29KH`Eo+_V9PQu6Ja+KI9|M*8h+xp4C)g6{s9 zJrI`4bamue340s9tv{eEWvePjAlun0IWo9O+y(qCKdGHLGx+~%X_9-zF0!Sw4B^tY zhY7~^9SsT^o)jdm0ZQv6N|cLml~$srYU03pbVMk7+KDE4n@vf^rm!$xa8q5MKM^eO zk%5^4A3PWgyDWji(ap)XE8Jh|4>zueQm z)IG4|8C(^P{!r&HUf2{-)4rD`%ltyu$Z`A`9vKooA87_ysEN)ga+b~&)Voke;4vv? zny3aOw(34rjO*s&n)$h6$2|cw#i}K p#Z=>XgTW0?#rn~E$3HlJZ}@}ZHGg>7A71kxTJ|6M9v!yt`xm)o1b+Yk literal 0 HcmV?d00001 diff --git a/backend/router/router.py b/backend/router/router.py new file mode 100644 index 0000000..15eec9c --- /dev/null +++ b/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="Negosium 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/backend/router/v1/__pycache__/__init__.cpython-314.pyc b/backend/router/v1/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6190ba3daec30a01b9efd8cbd95473aa8f34a10d GIT binary patch literal 177 zcmdPqPtH$C)z3G|*Ud{!&o9m_&DBpzOwP_rP0=sP zFD*$e(l0aAkB`sH%PfhH*DI*J#bJ}1pHiBWYFESxG#=!DVi4mKGb1Bo5i^hl0A$cB AssI20 literal 0 HcmV?d00001 diff --git a/backend/router/v1/auth/__pycache__/__init__.cpython-314.pyc b/backend/router/v1/auth/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6d10ee374f9cc4422b9dbf901e781d351331bcaa GIT binary patch literal 182 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 ZDYa;Q-$|TeQCwiKUU>a)_!<;j{|n 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/backend/router/v1/auth/protocol.py b/backend/router/v1/auth/protocol.py new file mode 100644 index 0000000..2ef226e --- /dev/null +++ b/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/backend/router/v1/validator/__pycache__/__init__.cpython-314.pyc b/backend/router/v1/validator/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a63b5f14892d54174da42f5cf9abc9ab013089b1 GIT binary patch literal 187 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 diH%%cF8N`{{miq$!`Dv literal 0 HcmV?d00001 diff --git a/backend/router/v1/validator/dependencies.py b/backend/router/v1/validator/dependencies.py new file mode 100644 index 0000000..73ad74e --- /dev/null +++ b/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/backend/services/__pycache__/__init__.cpython-314.pyc b/backend/services/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ec7545b54eae4eac945045952cd42eb08f1a35cc GIT binary patch literal 176 zcmdPqPtH$C)z3G|*Ud{!&o9m_&DBpzOwP_rP08U literal 0 HcmV?d00001 diff --git a/backend/services/__pycache__/auth_service.cpython-314.pyc b/backend/services/__pycache__/auth_service.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8dee9077780ea6b212c9144f221b97ab64664053 GIT binary patch literal 7591 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 literal 0 HcmV?d00001 diff --git a/backend/services/auth_service.py b/backend/services/auth_service.py new file mode 100644 index 0000000..1d41675 --- /dev/null +++ b/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/backend/tests/Benchmark.png b/backend/tests/Benchmark.png new file mode 100644 index 0000000000000000000000000000000000000000..17413093e917c82eb1c7d0e6f84824992dac1787 GIT binary patch literal 117596 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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/backend/web_main.py b/backend/web_main.py new file mode 100644 index 0000000..9134092 --- /dev/null +++ b/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=9300 + +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, + )