TOP
본문 바로가기
📚 목차
[파이썬 Projects]/<파이썬 웹개발>

[파이썬] FastAPI - 메모 앱 프로젝트 5: 웹페이지 개선

by 기록자_Recordian 2025. 5. 9.
728x90
반응형
이전 내용
 

[파이썬] FastAPI - 메모 앱 프로젝트 4: 사용자별 메모 관리(feat. Almebic)

이전 내용 [파이썬] FastAPI - 메모 앱 프로젝트 3: 사용자 인증이전 내용 [파이썬] FastAPI - 메모 앱 프로젝트 2: CRUD 구현이전 내용 [파이썬] FastAPI - 메모 앱 프로젝트 1: 초기 설정, DB 연동이전 내용 [

puppy-foot-it.tistory.com


프론트엔드 페이지 개선하기
home.html

 

프로젝트에 프론트엔드 페이지를 추가하여 웹에서 프로젝트를 확인할 수 있도록 한다. 프론트엔드 페이지는 HTML, CSS, 자바스크립트로 작성되며 Jinja2 템플릿 엔진과 연동된다.

templates 폴더 내에 생성된 home.html 페이지는 로그인 및 회원가입 기능을 제공하며, 사용자가 입력한 정보는 /login 및 /signup 엔드포인트로 전송된다.

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <title>마이 메모 앱</title>
    <style>
        body { font-family: Arial, sans-serif; }
        .container {
            width: 300px;
            margin: auto;
            border: 1px solid #ddd;
            padding: 20px;
        }
        .form-group {
            margin-bottom: 10px;
        }
        .form-group label, .form-group input {
            display: block;
            width: 100%;
        }
        .form-group input {
            padding: 5px;
            margin-top: 5px;
        }
        .buttons {
            display: flex;
            justify-content: space-between;
            margin-top: 20px;
        }
    </style>
    <script>
        function submitLoginForm(event) {
            event.preventDefault();
            const formData = new FormData(event.target);
            const data = {
                username: formData.get('username'),
                password: formData.get('password')
            };
            fetch('/login', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json'
                },
                body: JSON.stringify(data)
            })
            .then(response => response.json())
            .then(data => {
                alert(data.message); // 로그인 응답 메시지를 팝업으로 표시
            })
            .catch((error) => {
                console.error('Error:', error);
                alert('로그인 에러: ' + error); // 에러 메시지를 팝업으로 표시
            });
        }

        function submitSignupForm(event) {
            event.preventDefault();
            const formData = new FormData(event.target);
            const data = {
                username: formData.get('username'),
                email: formData.get('email'),
                password: formData.get('password')
            };
            fetch('/signup', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json'
                },
                body: JSON.stringify(data)
            })
            .then(response => response.json())
            .then(data => {
                alert(data.message); // 회원가입 응답 메시지를 팝업으로 표시
            })
            .catch((error) => {
                console.error('Error:', error);
                alert('회원가입 에러: ' + error); // 에러 메시지를 팝업으로 표시
            });
        }
    </script>
</head>
<body>
    <div class="container">
        <h1>마이 메모 앱에 오신 것을 환영합니다!</h1>
        <p>간단한 메모를 작성하고 관리할 수 있는 앱입니다.</p>

        <form id="loginForm" onsubmit="submitLoginForm(event)">
            <div class="form-group">
                <label for="username">사용자 이름:</label>
                <input type="text" id="username" name="username" required>
            </div>
            <div class="form-group">
                <label for="password">비밀번호:</label>
                <input type="password" id="password" name="password" required>
            </div>
            <div class="buttons">
                <button type="submit">로그인</button>
            </div>
        </form>

        <form id="signupForm" onsubmit="submitSignupForm(event)">
            <div class="form-group">
                <label for="signup_username">사용자 이름:</label>
                <input type="text" id="signup_username" name="username" required>
            </div>
            <div class="form-group">
                <label for="signup_email">이메일:</label>
                <input type="email" id="signup_email" name="email" required>
            </div>
            <div class="form-group">
                <label for="signup_password">비밀번호:</label>
                <input type="password" id="signup_password" name="password" required>
            </div>
            <div class="buttons">
                <button type="submit">회원가입</button>
            </div>
        </form>
    </div>
</body>
</html>

 

[전체 구조 요약]

구역 역할
<head> CSS(스타일)과 JavaScript 코드
<body> 실제로 보이는 로그인/회원가입 폼
<form> 사용자의 입력값을 서버에 전송하는 양식
JavaScript fetch()로 FastAPI 서버에 데이터 전송 (AJAX 방식)

 

[로그인 폼 분석]

1. HTML 부분: 사용자가 이름과 비밀번호를 입력하면, submitLoginForm(event)라는 JavaScript 함수가 실행

<form id="loginForm" onsubmit="submitLoginForm(event)">
  ...
  <input type="text" id="username" name="username" required>
  <input type="text" id="password" name="password" required>
  ...
</form>
태그  설명
<form> 사용자 입력을 서버로 보내기 위한 태그
onsubmit 폼을 제출할 때 실행할 JavaScript 함수 지정
<input> 실제로 사용자에게 보여지는 입력창
name 서버에 데이터를 보낼 때의 key 이름
required 빈칸이면 제출 막기
<button type="submit"> 폼을 제출하게 하는 버튼

 

코드  설명
<form id="loginForm" onsubmit="submitLoginForm(event)"> 로그인 폼의 시작. onsubmit은 이 폼을 제출할 때 실행할 JavaScript 함수(submitLoginForm)를 지정함.
<div class="form-group"> 입력 요소들을 묶는 박스 (CSS로 스타일링에 사용).
<label for="username">사용자 이름:</label> 사용자에게 보여지는 "아이디"라는 라벨. for="username"은 아래 input과 연결되어 있음.
<input type="text" id="username" name="username" required> 사용자 이름 입력 칸. required는 필수 입력임을 뜻함.
</div> 사용자 이름 입력 그룹 끝.
<div class="form-group"> 비밀번호 입력 칸을 위한 그룹 시작.
<label for="password">비밀번호:</label> 사용자에게 보여지는 "비밀번호" 라벨.
<input type="text" id="password" name="password" required> 비밀번호 입력 칸. 실제 서비스에서는 type="password"로 바꾸는 게 일반적.
</div> 비밀번호 입력 그룹 끝.
<div class="buttons"> 버튼을 감싸는 박스 (버튼 간 간격 등 스타일 조정용).
<button type="submit">로그인</button> 이 버튼을 누르면 폼이 제출됨. 그리고 submitLoginForm() 함수가 실행됨.
</div> 버튼 그룹 끝.
</form> 로그인 폼 끝. 전체 입력 완료 후 제출 시 JavaScript 함수 실행됨.

 

2. JavaScript 함수: 서버에 로그인 정보를 AJAX 방식으로 보낸다는 뜻

function submitLoginForm(event) {
    event.preventDefault(); // 페이지 새로고침 막기
    const formData = new FormData(event.target);
    const data = {
        username: formData.get('username'),
        password: formData.get('password')
    };
    fetch('/login', {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify(data)
    })
    .then(response => response.json())
    .then(data => {
        alert(data.message); // 서버에서 온 응답 메시지
    })
    ...
}
코드 설명
event.preventDefault(); 기본 동작인 폼 전송 후 새로고침을 막음. AJAX 방식에서는 꼭 필요함.
new FormData(event.target) <form> 안에 있는 모든 입력값을 하나의 데이터로 모음. (자동으로 input name="" 기준으로 가져옴)
formData.get('username') 사용자가 입력한 값을 꺼냄. 예: input name="username"의 값
fetch('/login', {...}) /login 경로로 **비동기 요청(서버 호출)**을 보냄. 즉, FastAPI 서버에 로그인 정보 보내는 것
body: JSON.stringify(data) 데이터를 JSON 형태로 변환해서 서버에 보냄 (FastAPI는 JSON을 이해함)
.then(response => response.json()) 서버 응답을 받아서, 그걸 JSON으로 바꿈
alert(data.message) FastAPI 서버가 보내준 메시지를 팝업(alert)으로 띄움
.catch(...) 네트워크 오류 등 문제가 생겼을 때, 콘솔에 에러를 찍고 팝업 띄움

 

[예시 흐름]

  1. 사용자가 아이디, 비밀번호 입력 후 버튼 클릭
  2. 브라우저가 새로고침하지 않고 JavaScript가 입력값만 꺼냄
  3. fetch()를 통해 /login 주소로 POST 요청 전송
  4. FastAPI 서버에서 로그인 처리하고, 결과를 { "message": "로그인 성공!" } 이런 식으로 반환
  5. 그 메시지를 팝업으로 보여줌
개념 설명
FormData form에서 데이터 추출 도구
fetch() 서버와 데이터 주고받기
preventDefault() 폼의 기본 새로고침 막기
.then() / .catch() 서버 응답 처리 / 에러 처리
JSON.stringify() JS 객체 → JSON 문자열 변환

 

 


프론트엔드 페이지 개선하기
memos.html

 

로그인 후 자신의 메모 리스트를 확인하고, 수정, 삭제 및 새로운 메모까지 추가할 수 있는 memos.html 파일을 개선한다.

이 HTML 파일은 FastAPI와 연동되는 메모 관리 웹 앱의 프론트엔드 화면이다. 메모 추가, 수정, 삭제 기능은 JavaScript에서 API 요청(fetch)을 통해 FastAPI 백엔드와 통신한다.

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <title>나의 메모</title>
    <link href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" rel="stylesheet">
    <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.1/css/all.min.css" rel="stylesheet">
    <style>
        .container {
            margin-top: 20px;
            max-width: 800px;
        }

        .card {
            margin-bottom: 20px;
            border: none;
            box-shadow: 0 4px 8px rgba(0,0,0,.1);
            background-color: #fff;
        }

        .memo-title, .memo-content {
            width: 100%;
            margin-bottom: 10px;
            border: 1px solid #ddd;
            background-color: #fff;
            padding: 10px;
        }

        .memo-title {
            font-size: 1.1rem;
        }

        .memo-content {
            min-height: 100px;
        }

        .edit-buttons {
            margin: 10px;
            text-align: right;
            margin-right: 0px;
            margin-bottom: 0px;
        }

        .edit-buttons .btn {
            background-color: #f8f9fa;
            border: none;
            border-radius: 5px;
            margin-left: 5px;
            padding: 5px 10px;
            color: #495057;
            transition: all 0.3s ease;
        }

        .edit-buttons .btn:hover {
            background-color: #e2e6ea;
            transform: scale(1.1);
        }

        .edit-buttons .btn-edit {
            background-color: #E74C3C;
            color: #fff;
        }

        .edit-buttons .btn-edit:hover {
            background-color: #C0392B;
        }

        .edit-buttons .btn-delete {
            background-color: #3498DB;
            color: #fff;
        }

        .edit-buttons .btn-delete:hover {
            background-color: #2980B9;
        }

        .btn-primary {
            background-color: #3F464D;
            border-color: #007bff;
        }

        .btn-primary:hover {
            background-color: #0056b3;
            border-color: #0056b3;
        }

        .btn-block {
            display: block;
            width: 100%;
        }

        .header-bar {
            background-color: #FF8066; /* 변경된 헤더바 배경색 */
            padding: 10px 0; /* 상하 패딩 */
            text-align: center; /* 텍스트 가운데 정렬 */
            border-radius: 10px; /* 둥근 꼭짓점 */
            box-shadow: 0 4px 6px rgba(0,0,0,.1); /* 그림자 효과 */
            animation: slideDown 0.5s ease-out; /* 슬라이드 다운 애니메이션 */
            margin: 10px;
            position: relative;
            display: flex; /* 플렉스박스 레이아웃 적용 */
            justify-content: center; /* 가로 중앙 정렬 */
            align-items: center; /* 세로 중앙 정렬 */
        }

        .header-item {
            position: absolute;
            top: 50%;
            transform: translateY(-50%);
        }

        .header-item:first-child {
            left: 20px;
        }

        .header-item:last-child {
            right: 20px;
        }

        .username-button, .logout-button {
            display: flex;
            align-items: center;
        }

        .username-button i, .logout-button i {
            margin-right: 5px;
        }

        .header-bar h1 {
            color: white; /* 헤더바 텍스트 색상 */
            margin: 0; /* 여백 제거 */
            font-size: 1.3em; /* 폰트 크기 조정 */
            font-weight: bold;
            transition: all 0.3s ease-in-out; /* 부드러운 변화 효과 */
        }

        .header-content {
            text-align: center;
        }

        .user-info {
            position: absolute; /* 절대 위치 지정 */
            top: 10px;
            right: 20px;
            font-size: 0.9rem; /* 폰트 크기 조정 */
        }

        .logout-button {
            margin-left: 10px; /* 로그아웃 버튼과 사용자 ID 사이의 간격 */ 
        }

        .btn-sm {
            padding: 0.15rem 0.5rem;
            font-size: .8rem;
            line-height: 1.5;
            border-radius: 0.2rem;
        }

        /* 슬라이드 다운 애니메이션 효과 */
        @keyframes slideDown {
            from {
                transform: translateY(-100%);
                opacity: 0;
            }
            to {
                transform: translateY(0);
                opacity: 1;
            }
        }
    </style>
    <script>
        function createMemo() {
            var title = document.getElementById('new-title').value;
            var content = document.getElementById('new-content').value;

            fetch('/memos', {
                method:'POST',
                headers: {
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify({ title: title, content: content})
            })
            .then(response => response.json())
            .then(data => {
                console.log(data);
                window.location.reload(); // 페이지 새로고침
            })
            .catch((error) => {
                console.error('Error:', error);
            });
        }

        function toggleEdit(id) {
            var titleEl = document.getElementById('title-' +  id);
            var contentEl = document.getElementById('content-', id);
            var isReadOnly = titleEl.readOnly;

            titleEl.readOnly = !isReadOnly;
            contentEl.readOnly = !isReadOnly;

            if (!isReadOnly) {
                updateMemo(id);
            }
        }

        function updateMemo(id) {
            var title = document.getElementById('title-' + id).value;
            var content = document.getElementById('content-' + id).value;

            fetch('/memos/' + id, {
                method: 'PUT',
                headers: {
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify({ title: title, content: content })
            })
            .then(response => response.json())
            .then(data => {
                console.log(data);
                alert('메모가 업데이트 되었습니다.');
            })
            .catch((error) => {
                console.error('Error:', error);
            });
        }

        function deleteMemo(id) {
            if (!confirm('메모를 삭제하시겠습니까?')) return;

            fetch('/memos/' + id, {
                method: 'DELETE',
            })
            .then(response => response.json())
            .then(data => {
                console.log(data);
                window.location.reload(); // 페이지 새로고침
            })
            .catch((error) => {
                console.error('Error:', error);
            });
        }

        function logout() {
            fetch('/logout', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json'
                }
            })
            .then(response => response.json())
            .then(data => {
                console.log(data);
                window.location.href = '/'; // 로그아웃 후 홈페이지로 리다이렉트
            })
            .catch((error) => {
                console.error('Error:', error);
            });
        }
    </script>
</head>
<body>
    <div class="container">
        <!--헤더바 추가-->
        <div class="header-bar">
            <div class="header-item">
                <a href="#" class="btn btn-sm btn-danger username-button">
                    <i class="fas fa-user"></i> {{ username }}
                </a>
            </div>
            <h1>나의 메모</h1>
            <div class="header-item">
                <button onclick="logout()" class="btn btn-sm btn-danger logout-button">
                    <i class="fas fa-sign-out-alt"></i> 로그아웃</button>
            </div>
        </div>
        <div class="card">
            <div class="card-body">
                <input type="text" id="new-title" placeholder="새 메모 제목" class="form-control memo-title">
                <textarea id="new-content" placeholder="내용을 입력하세요" class="form-control memo-content"></textarea>
                <button onclick="createMemo()" class="btn btn-primary btn-block">메모 추가</button>
            </div>
        </div>

        {%for memo in memos %}
        <div class="card memo">
            <div class="card-body">
                <input type="text" id="title-{{ memo.id }}" value="{{ memo.title }}" class="form-control memo-title" readonly>
                <textarea id="content-{{ memo.id }}" class="form-control memo-content" readonly>{{ memo.content }}</textarea>
                <div class="edit-buttons">
                    <button onclick="toggleEdit({{ memo.id }})" class="btn btn-edit"><i class="fas fa-edit"></i></button>
                    <button onclick="deleteMemo({{ memo.id }})" class="btn btn-delete"><i class="fas fa-trash-alt"></i></button>
                </div> 
            </div>
        </div>
        {% endfor %}
    </div>
</body>
</html>

 

[주요 구조 설명]

코드 설명
<!DOCTYPE html> HTML5 문서임을 브라우저에 알린다.
<html lang="ko"> 문서의 언어를 한국어로 설정.
<meta charset="UTF-8"> 한글을 포함한 다양한 문자를 인코딩할 수 있게 UTF-8 설정.
<link href="...bootstrap.min.css" rel="stylesheet"> Bootstrap 프레임워크로 기본 디자인을 적용.
<link href="...font-awesome.min.css" rel="stylesheet"> 아이콘 폰트(Font Awesome)를 사용하기 위한 설정.
<div class="header-bar"> 상단 헤더 바로, 사용자 이름과 로그아웃 버튼이 포함.
{{ username }} Jinja2 문법으로 FastAPI에서 전달한 사용자 이름을 표시.
<textarea id="new-content"...> 새 메모의 내용을 입력하는 텍스트박스.
onclick="createMemo()" 버튼 클릭 시 createMemo() 함수 실행 → FastAPI로 POST 요청 보냄
{% for memo in memos %} FastAPI에서 넘겨준 메모 리스트를 반복 렌더링
{{ memo.title }} 메모의 제목을 HTML에 표시
toggleEdit({{ memo.id }}) 수정 버튼 클릭 시 해당 메모를 편집 가능하도록 변경
deleteMemo({{ memo.id }}) 삭제 버튼 클릭 시 FastAPI로 DELETE 요청을 보냄

 

[핵심 작동 방식]

항목 설명
FastAPI 역할 클라이언트가 보낸 요청(JSON)을 처리하고, HTML 렌더링(Jinja2) 또는 JSON 응답 반환
JavaScript 역할 사용자의 행동(메모 생성/수정/삭제)을 감지하고 FastAPI로 HTTP 요청 (fetch) 보냄
Jinja2 문법 HTML 안에서 서버 데이터를 {{ 변수 }} 또는 {% 반복문 %} 등으로 출력
스타일(CSS) Bootstrap + 직접 작성한 CSS로 레이아웃과 버튼 스타일 꾸밈

 

[주요 함수]

함수명 설명  주요 동작
createMemo() 새로운 메모를 생성 POST /memos 요청을 보내고, 성공 시 페이지를 새로고침.
toggleEdit(id) 특정 메모 수정 모드 토글 readonly을 해제하고, 수정 완료 시 updateMemo(id)를 호출
updateMemo(id) 메모 내용을 수정 PUT /memos{id}로 수정 내용을 전송하고, 성공 시 알림 표시
deleteMemo(id) 메모를 삭제 삭제 확인 후 DELETE /memos{id} 요청을 보내고, 성공 시 새로고침
logout() 로그아웃 수행 POST /logout 요청 후 / 페이지로 리디렉션

 


main.py 수정하기

 

memos.html 접속 시 username을 추가로 전달하기 위해 main.py 파일을 수정한다.

# 메모 조회
@app.get("/memos")
async def list_memos(request: Request, db: Session = Depends(get_db)):
    # 사용자별 메모 관리
    username = request.session.get("username")
    if username is None:
        raise HTTPException(status_code=401, detail="Not Authorized")
    user = db.query(User).filter(User.username == username).first()
    if user is None:
        raise HTTPException(status_code=404, detail="User를 찾을 수 없습니다.")
    memos = db.query(Memo).filter(Memo.user_id == user.id).all()
    # 리스트 내포 사용하여 전체 정보 반환
    return templates.TemplateResponse("memos.html", {
        "request": request, 
        "memos": memos,
        "username": username}) # 사용자 이름 추가

테스트

 

main.py 서버를 실행하고, 웹 브라우저를 열고 root('/) URL로 접속한다.

◆ 회원가입 테스트

계정이 성공적으로 생성되었다.

 

◆ 로그인 테스트

앞서 생성한 계정으로 로그인을 하여 '로그인 성공!' 메시지가 표시되면 로그인 기능이 잘 구현된 것이다.

만약, 계정 정보가 일치하지 않을 경우에는 에러 메시지(undefined) 가 뜬다.

 

◆ 메모 관리 테스트

(로그인 상태) http://127.0.0.1:8000/memos 주소로 이동하면 상단에 로그인한 사용자 이름이 표시되어있다.

1. 메모 생성 기능

 

2. 메모 수정 기능

하단의 버튼(빨간색 'V' 표시)을 눌러 수정이 가능하다.

 

3. 메모 삭제 기능

 

메모가 잘 삭제된 것을 확인할 수 있다.

 

◆ 로그아웃 테스트

상단의 로그아웃 버튼을 누르면 메인 페이지로 이동한다.

 

◆ 비로그인 상태에서 접근 테스트

로그아웃 후 로그인되지 않은 상태에서 /memos에 접근하려고 하면 승인되지 않았다는 메시지가 뜨며 접근이 불가하다.


[참고]

가장 빠른 풀스택을 위한 플라스크 & FastAPI


다음 내용

 

[파이썬] FastAPI - 메모 앱 프로젝트 6: 프론트엔드 페이지 개선

이전 내용 [파이썬] FastAPI - 메모 앱 프로젝트 5: 웹페이지 개선이전 내용 [파이썬] FastAPI - 메모 앱 프로젝트 4: 사용자별 메모 관리(feat. Almebic)이전 내용 [파이썬] FastAPI - 메모 앱 프로젝트 3: 사용

puppy-foot-it.tistory.com

728x90
반응형