45강. 웹 관리창 만들기

0. 학습 목표
→ 브라우저에서 현재 접속자와 로그인 기록을 확인할 수 있는 관리 페이지를 서버에 추가합니다.
0.1 이번 글에서 다룰 내용
이번 글은 구현 중심 강의입니다.

38강에서 그린 최종 구조도에는 PySide6 앱, FastAPI 서버, 웹 관리창 세 가지가 있었습니다. 앞 두 가지는 이미 완성됐습니다. 이번 강의에서 마지막 조각인 웹 관리창을 만듭니다. 브라우저에서 http://127.0.0.1:8000/admin을 열면 현재 접속자 목록과 로그인 기록이 표시됩니다.
관리 페이지는 새로고침으로 갱신하는 정적 스냅샷입니다. 실시간 자동 갱신은 이번 강의의 범위를 벗어납니다. 새로고침만으로도 현재 상태를 충분히 파악할 수 있고, 구현이 단순해서 FastAPI가 HTTP와 WebSocket을 한 서버에서 함께 처리하는 구조를 명확하게 보여줍니다.
이번 강의에서 desktop/main.py는 전혀 건드리지 않습니다. server/app.py에만 세 가지가 추가됩니다.
| 구분 | 내용 |
| 이해할 것 | FastAPI가 WebSocket과 HTTP 페이지를 같은 서버에서 함께 제공하는 방식 |
| 만들 것 | server/app.py 수정 — login_log 리스트, /login에 기록 추가, GET /admin 엔드포인트 |
| 확인할 것 | 앱으로 로그인한 뒤 /admin을 열면 접속자 목록과 로그인 기록이 표시되는 것 |
0.2 이번 강의에서 수정하는 구조
fastapi-chat/
├── server/
│ ├── app.py ← ✏️ login_log, /login 기록 추가, GET /admin 추가
│ └── test_client.html ← 유지
└── desktop/
└── main.py ← 유지 (변경 없음)
(✏️ 수정 · 표시 없음은 변경 없음)
1. 서버 - 로그인 기록 남기기
→ login_log 리스트를 추가하고, 로그인 성공 시마다 기록을 쌓습니다.
먼저 import에 datetime과 HTMLResponse를 추가하고, users 딕셔너리 옆에 login_log 리스트를 선언합니다.
from fastapi import FastAPI, WebSocket
from fastapi.responses import JSONResponse, HTMLResponse # ✏️ HTMLResponse 추가
from pydantic import BaseModel
from typing import Dict, List # ✏️ List 추가
from datetime import datetime # ➕ 로그인 시각 기록용
app = FastAPI()
users: Dict[str, dict] = {}
login_log: List[dict] = [] # ➕ 로그인 기록 저장소
이제 login() 엔드포인트에서 성공 응답을 돌려주기 직전에 기록 한 줄을 추가합니다.
@app.post("/login")
async def login(req: UserRequest):
user = users.get(req.username)
if user is None:
return JSONResponse(
{"ok": False, "message": "아이디를 찾을 수 없습니다."},
status_code=401
)
if user["password"] != req.password:
return JSONResponse(
{"ok": False, "message": "비밀번호가 틀렸습니다."},
status_code=401
)
login_log.append({ # ➕ 로그인 성공 기록
"username": req.username,
"nickname": user["nickname"],
"time": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
})
return {"ok": True, "nickname": user["nickname"]}
login_log는 리스트이므로 서버가 실행되는 동안 계속 쌓입니다. 서버를 재시작하면 초기화됩니다. users와 마찬가지로 46강에서 MySQL로 옮깁니다.
2. 서버 - GET /admin 엔드포인트 만들기
→ 접속자 목록과 로그인 기록을 HTML로 만들어 브라우저에 돌려주는 관리 페이지를 추가합니다.
FastAPI에서 브라우저에 HTML 페이지를 돌려주는 방법은 간단합니다. HTMLResponse에 HTML 문자열을 담아 반환하면 됩니다. 별도 템플릿 파일이나 추가 패키지 없이 Python 문자열만으로 페이지를 만듭니다.
@app.get("/admin", response_class=HTMLResponse) # ➕ 관리 페이지 엔드포인트
async def admin_page():
# 현재 접속자 목록 행 생성
nicknames = manager.get_nicknames()
if nicknames:
user_rows = "".join(
f"{n}"
for n in nicknames
)
else:
user_rows = "현재 접속자 없음"
# 로그인 기록 행 생성 (최신순)
if login_log:
log_rows = "".join(
f""
f"{r['time']}"
f"{r['username']}"
f"{r['nickname']}"
f""
for r in reversed(login_log) # ➕ reversed()로 최신 기록이 위에 오게
)
else:
log_rows = "로그인 기록 없음"
html = f"""
관리자 페이지
body {{ font-family: sans-serif; padding: 32px; background: #f8f8f8; }}
h2 {{ color: #006dd7; border-left: 5px solid #006dd7; padding-left: 12px; }}
table {{ border-collapse: collapse; width: 100%; margin-bottom: 32px; background: white; }}
th {{ background: #ddeeff; padding: 10px; border: 1px solid #ddd; text-align: left; }}
.refresh {{ font-size: 13px; color: #888; margin-bottom: 24px; }}
채팅 서버 관리 페이지마지막 조회: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")} · 새로고침현재 접속자 ({len(nicknames)}명){user_rows}닉네임로그인 기록 (총 {len(login_log)}건){log_rows}시각아이디닉네임""" return HTMLResponse(content=html) # ➕ HTML 문자열을 응답으로 반환
HTML 문자열 안에서 중괄호를 쓸 때 주의합니다. f-string 안에서 CSS의 {{}}처럼 이중 중괄호를 써야 리터럴 중괄호로 처리됩니다. 단일 {}는 Python 변수 삽입으로 인식되어 오류가 납니다.
reversed(login_log)는 리스트를 직접 뒤집지 않고 역순 반복자만 만듭니다. 원본 login_log는 그대로 시간순으로 유지되고, 페이지에서만 최신 기록이 위에 표시됩니다.
페이지 맨 위에 "마지막 조회" 시각과 새로고침 링크를 두는 이유가 있습니다. 관리 페이지는 실시간으로 갱신되지 않습니다. 언제 찍힌 스냅샷인지 표시해두면 혼동을 막을 수 있습니다.
3. 실행 결과 확인하기
→ 앱으로 로그인하고 /admin 페이지에서 접속자와 기록이 표시되는지 확인합니다.
서버를 실행하고 앱에서 로그인합니다. 브라우저에서 http://127.0.0.1:8000/admin을 엽니다.
채팅 서버 관리 페이지
마지막 조회: 2025-06-24 14:32:10 · 새로고침
현재 접속자 (1명)
┌──────────┐
│ 닉네임 │
├──────────┤
│ 홍길동 │
└──────────┘
로그인 기록 (총 2건)
┌─────────────────────┬──────────┬──────────┐
│ 시각 │ 아이디 │ 닉네임 │
├─────────────────────┼──────────┼──────────┤
│ 2025-06-24 14:32:05 │ hong │ 홍길동 │
│ 2025-06-24 14:20:11 │ hong │ 홍길동 │
└─────────────────────┴──────────┴──────────┘
앱을 종료하면 접속자 목록에서 이름이 사라집니다. 관리 페이지를 새로고침하면 갱신된 상태가 표시됩니다. 로그인 기록은 로그인 성공 시에만 기록되므로 앱 종료와 무관하게 유지됩니다.
✔ 확인 기준: /admin 페이지에 현재 접속자 이름이 표시되고, 로그인 기록이 최신순으로 나열되면 완료. 페이지가 뜨지 않으면 @app.get("/admin")이 manager = ConnectionManager() 아래에 있는지 확인하세요. manager를 선언하기 전에 /admin이 정의되면 manager.get_nicknames()에서 오류가 납니다.
4. 최종 코드 정리하기
→ 이번 강의에서 완성한 server/app.py 전체를 정리합니다.
4.1 server/app.py
from fastapi import FastAPI, WebSocket
from fastapi.responses import JSONResponse, HTMLResponse
from pydantic import BaseModel
from typing import Dict, List
from datetime import datetime
app = FastAPI()
# ── 사용자 저장소 (메모리) ──────────────────────────
users: Dict[str, dict] = {}
login_log: List[dict] = []
class UserRequest(BaseModel):
username: str
password: str
nickname: str = ""
# ── 인증 엔드포인트 ─────────────────────────────────
@app.post("/register")
async def register(req: UserRequest):
if req.username in users:
return JSONResponse(
{"ok": False, "message": "이미 사용 중인 아이디입니다."},
status_code=400
)
users[req.username] = {
"password": req.password,
"nickname": req.nickname or req.username
}
return {"ok": True, "message": "회원가입 성공"}
@app.post("/login")
async def login(req: UserRequest):
user = users.get(req.username)
if user is None:
return JSONResponse(
{"ok": False, "message": "아이디를 찾을 수 없습니다."},
status_code=401
)
if user["password"] != req.password:
return JSONResponse(
{"ok": False, "message": "비밀번호가 틀렸습니다."},
status_code=401
)
login_log.append({
"username": req.username,
"nickname": user["nickname"],
"time": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
})
return {"ok": True, "nickname": user["nickname"]}
# ── WebSocket ───────────────────────────────────────
class ConnectionManager:
def __init__(self):
self.active_connections: Dict[WebSocket, str] = {}
async def connect(self, websocket: WebSocket):
await websocket.accept()
self.active_connections[websocket] = ""
def disconnect(self, websocket: WebSocket):
self.active_connections.pop(websocket, None)
def set_nickname(self, websocket: WebSocket, nickname: str):
self.active_connections[websocket] = nickname
def get_nickname(self, websocket: WebSocket) -> str:
return self.active_connections.get(websocket, "")
def get_nicknames(self) -> list:
return [n for n in self.active_connections.values() if n]
def get_socket_by_nickname(self, nickname: str):
for ws, name in self.active_connections.items():
if name == nickname:
return ws
return None
async def broadcast(self, message: dict):
for connection in self.active_connections:
await connection.send_json(message)
manager = ConnectionManager()
# ── 관리 페이지 ─────────────────────────────────────
@app.get("/admin", response_class=HTMLResponse)
async def admin_page():
nicknames = manager.get_nicknames()
if nicknames:
user_rows = "".join(
f"{n}"
for n in nicknames
)
else:
user_rows = "현재 접속자 없음"
if login_log:
log_rows = "".join(
f""
f"{r['time']}"
f"{r['username']}"
f"{r['nickname']}"
f""
for r in reversed(login_log)
)
else:
log_rows = "로그인 기록 없음"
html = f"""
관리자 페이지
body {{ font-family: sans-serif; padding: 32px; background: #f8f8f8; }}
h2 {{ color: #006dd7; border-left: 5px solid #006dd7; padding-left: 12px; }}
table {{ border-collapse: collapse; width: 100%; margin-bottom: 32px; background: white; }}
th {{ background: #ddeeff; padding: 10px; border: 1px solid #ddd; text-align: left; }}
.refresh {{ font-size: 13px; color: #888; margin-bottom: 24px; }}
채팅 서버 관리 페이지마지막 조회: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")} · 새로고침현재 접속자 ({len(nicknames)}명){user_rows}닉네임로그인 기록 (총 {len(login_log)}건){log_rows}시각아이디닉네임""" return HTMLResponse(content=html) # ── WebSocket 엔드포인트 ──────────────────────────── @app.websocket("/ws") async def websocket_endpoint(websocket: WebSocket): await manager.connect(websocket) try: while True: data = await websocket.receive_json() msg_type = data.get("type", "") if msg_type == "join": nickname = data.get("sender", "익명") manager.set_nickname(websocket, nickname) await manager.broadcast({ "type": "system", "text": f"{nickname} 님이 입장했습니다." }) await manager.broadcast({ "type": "user_list", "users": manager.get_nicknames() }) elif msg_type == "chat": await manager.broadcast({ "type": "chat", "sender": manager.get_nickname(websocket), "text": data.get("text", "") }) elif msg_type == "whisper": target_name = data.get("target", "") text = data.get("text", "") sender_name = manager.get_nickname(websocket) target_ws = manager.get_socket_by_nickname(target_name) if target_ws is None: await websocket.send_json({ "type": "system", "text": f"'{target_name}' 님은 현재 접속해 있지 않습니다." }) else: whisper_msg = { "type": "whisper", "sender": sender_name, "text": text } await target_ws.send_json(whisper_msg) await websocket.send_json(whisper_msg) except Exception: nickname = manager.get_nickname(websocket) manager.disconnect(websocket) if nickname: await manager.broadcast({ "type": "system", "text": f"{nickname} 님이 퇴장했습니다." }) await manager.broadcast({ "type": "user_list", "users": manager.get_nicknames() })
4.2 최종 확인 표
| 코드 | 역할 |
login_log: List[dict] = [] |
로그인 기록 저장소 — 서버 재시작 시 초기화 |
login_log.append({...}) |
로그인 성공 시 시각·아이디·닉네임 기록 |
datetime.now().strftime(...) |
로그인 시각을 문자열로 저장 |
@app.get("/admin", response_class=HTMLResponse) |
브라우저 요청에 HTML 페이지 반환 |
manager.get_nicknames() |
현재 접속자 목록 — 요청 시점의 스냅샷 |
reversed(login_log) |
원본 리스트 유지하면서 최신순으로 표시 |
style='{{ ... }}' (f-string 안 이중 중괄호) |
f-string에서 리터럴 중괄호 — 단일 {}는 변수 삽입으로 처리됨 |
HTMLResponse(content=html) |
문자열을 HTTP 응답으로 반환 — 별도 템플릿 파일 불필요 |
이제 38강에서 그린 구조도가 완성됐습니다. PySide6 앱은 WebSocket으로, 웹 관리창은 HTTP로 같은 FastAPI 서버에 연결됩니다. 남은 것은 메모리 저장소를 MySQL로 교체하는 것입니다.
→ 다음 강의 (46강): users와 login_log를 MySQL 테이블로 옮깁니다. 서버를 재시작해도 회원 정보와 로그인 기록이 사라지지 않습니다. FastAPI에서 MySQL에 연결하는 방법과, 기존 메모리 방식 코드를 DB 조회로 바꾸는 흐름을 비교하며 작업합니다.