Flask 기반 숙박 공유 플랫폼: 사용자 프로필 기능 구현

외부 이미지 저장소 연동

Typora와 PicGo를 활용한 마크다운 이미지 관리

마크다운 문서 작성 시 이미지 관리는 번거로운 작업입니다. Typora 에디터와 PicGo 도구를 조합하면 이 문제를 해결할 수 있습니다. PicGo는 다양한 클라우드 저장소를 지원하는 이미지 업로드 클라이언트입니다.

국내 사용자에게 적합한 선택은 gitee 저장소입니다. GitHub 대비 접속 속도가 빠르고 무료로 이용 가능합니다. 설정 완료 후 Typora에서 이미지를 붙여넣기하면 자동으로 업로드되며, 문서를 다른 플랫폼으로 옮겨도 이미지가 정상 표시됩니다.

Qiniu 클라우드 스토리지 적용

본 프로젝트의 프로필 사진은 Qiniu 클라우드에 저장합니다. 서비스 이용 절차는 다음과 같습니다:

  1. 계정 생성 및 실명 인증
  2. 객체 스토리지 제품 선택 후 버킷 생성
  3. 접근 권한을 공개로 설정
  4. CDN 테스트 도메인 확인 (30일 제한, 운영 환경에서는 커스텀 도메인 권장)

Python SDK 설치 및 업로드 구현

pip install qiniu

공식 문서의 기본 예시를 참고하여, 바이너리 데이터 직접 업로드 방식으로 개선합니다:

# -*- coding: utf-8 -*-
from qiniu import Auth, put_data, etag
import qiniu.config

def upload_binary(file_content, filename):
    """바이너리 데이터를 Qiniu에 업로드"""
    # 인증 정보
    ak = 'Your_Access_Key'
    sk = 'Your_Secret_Key'
    
    # 권한 객체 생성
    auth_obj = Auth(ak, sk)
    
    # 업로드 토큰 발급 (1시간 유효)
    upload_token = auth_obj.upload_token('your-bucket', None, 3600)
    
    # 바이너리 데이터 업로드
    result, response = put_data(upload_token, None, file_content)
    
    if response.status_code == 200:
        file_key = result.get('key')
        return {
            'success': True,
            'key': file_key,
            'hash': result.get('hash')
        }
    return {'success': False, 'error': response.exception}

프로젝트 통합 모듈

설정값을 중앙 관리하고 재사용 가능한 모듈을 구성합니다:

# ihome/utils/qiniu_config.py
QN_ACCESS_KEY = 'kF97Axxxxxxxxxxxx'
QN_SECRET_KEY = 'sSSxlDxxxxxxxxxxxx'
QN_BUCKET = 'alex-ihome'
QN_DOMAIN = 'https://cdn.example.com/'
# ihome/utils/picture_uploader.py
from qiniu import Auth, put_data
from ihome.utils.qiniu_config import (
    QN_ACCESS_KEY, QN_SECRET_KEY, QN_BUCKET, QN_DOMAIN
)

class ImageUploader:
    """이미지 업로드 처리 클래스"""
    
    def __init__(self):
        self.auth_client = Auth(QN_ACCESS_KEY, QN_SECRET_KEY)
    
    def save(self, binary_data):
        """이미지 바이너리를 저장하고 URL 반환"""
        token = self.auth_client.upload_token(QN_BUCKET, None, 3600)
        ret, info = put_data(token, None, binary_data)
        
        if info.status_code != 200:
            raise UploadException(f"업로드 실패: {info.exception}")
        
        return QN_DOMAIN + ret.get("key")

class UploadException(Exception):
    pass

프로필 이미지 변경 기능

서버측 엔드포인트

파일 업로드가 필요한 경우 PATCH 메서드를 사용하여 부분 업데이트를 처리합니다:

# ihome/api_1_0/users.py
from flask import request, jsonify, current_app, session, g
from . import api
from ihome import models, db
from ihome.utils.response_codes import RET
from ihome.utils.commons import login_required, parameter_error
from ihome.utils.picture_uploader import ImageUploader

@api.route('/user/profile-image', methods=['PATCH'])
@login_required
def modify_avatar():
    """사용자 프로필 이미지 수정"""
    # multipart/form-data에서 파일 추출
    avatar_file = request.files.get('avatar')
    
    if not avatar_file:
        return parameter_error()
    
    # 업로더 인스턴스 생성 및 실행
    uploader = ImageUploader()
    
    try:
        image_binary = avatar_file.read()
        upload_result = uploader.save(image_binary)
    except Exception as err:
        current_app.logger.error(f"이미지 업로드 오류: {err}")
        return jsonify(errno=RET.THIRDERR, errmsg='외부 저장소 업로드 실패')
    
    # 데이터베이스 갱신
    g.user.avatar_url = upload_result
    try:
        db.session.add(g.user)
        db.session.commit()
    except Exception as err:
        current_app.logger.error(err)
        db.session.rollback()
        return jsonify(errno=RET.DBERR, errmsg='프로필 저장 중 오류')
    
    return jsonify(errno=RET.OK, data={'avatar_url': upload_result})

클라이언트측 구현

폼 데이터 전송과 JSON 응답 처리를 동시에 수행하려면 jquery.form.min.js 플러그인이 필요합니다:

<!-- profile.html -->
<div class="profile-section">
    <img id="avatar-display" src="" alt="프로필 사진">
    <form id="avatar-form" enctype="multipart/form-data">
        <label>이미지 선택: </label>
        <input type="file" name="avatar" accept="image/*">
        <button type="submit" class="btn btn-success">업로드</button>
    </form>
</div>

<script src="/static/js/jquery.min.js"></script>
<script src="/static/js/jquery.form.min.js"></script>
// profile.js
$(document).ready(function () {
    $('#avatar-form').submit(function (event) {
        event.preventDefault();  // 기본 제출 차단
        
        $(this).ajaxSubmit({
            url: '/api/v1.0/user/profile-image',
            type: 'PATCH',
            dataType: 'json',
            headers: {'X-CSRFToken': retrieveCookie('csrf_token')},
            success: function (response) {
                if (response.errno === '0') {
                    $('#avatar-display').attr('src', response.data.avatar_url);
                } else {
                    alert('업로드 실패: ' + response.errmsg);
                }
            }
        });
    });
});

닉네임 변경 기능

서버측 구현

@api.route('/user/display-name', methods=['PATCH'])
@login_required
def modify_nickname():
    """사용자 표시 이름 변경"""
    payload = request.get_json()
    
    if not payload:
        return parameter_error()
    
    new_name = payload.get('display_name')
    if not new_name or len(new_name.strip()) == 0:
        return parameter_error()
    
    # 중복 검사 및 저장
    g.user.display_name = new_name.strip()
    
    try:
        db.session.add(g.user)
        db.session.commit()
        session['display_name'] = new_name.strip()
    except IntegrityError:
        db.session.rollback()
        return jsonify(errno=RET.PARAMERR, errmsg='이미 사용 중인 닉네임')
    except Exception as err:
        current_app.logger.error(err)
        db.session.rollback()
        return jsonify(errno=RET.DBERR, errmsg='닉네임 저장 실패')
    
    return jsonify(errno=RET.OK)

클라이언트측 구현

$(document).ready(function () {
    $('#nickname-form').submit(function (event) {
        event.preventDefault();
        
        var nickname = $('#nickname-input').val().trim();
        if (!nickname) {
            alert('닉네임을 입력하세요');
            return;
        }
        
        $.ajax({
            url: '/api/v1.0/user/display-name',
            type: 'PATCH',
            contentType: 'application/json',
            data: JSON.stringify({display_name: nickname}),
            dataType: 'json',
            headers: {'X-CSRFToken': retrieveCookie('csrf_token')},
            success: function (resp) {
                if (resp.errno === '0') {
                    window.location.href = '/user/dashboard';
                } else {
                    alert(resp.errmsg);
                }
            }
        });
    });
});

사용자 정보 조회 기능

통합 정보 제공 엔드포인트

@api.route('/user/summary')
@login_required
def fetch_user_summary():
    """로그인 사용자의 기본 정보 조회"""
    current = g.user
    
    return jsonify(errno=RET.OK, data={
        'avatar': current.avatar_url,
        'nickname': current.display_name,
        'phone': current.mobile_number,
        'verified': bool(current.real_name)
    })

대시보드 페이지 초기화

// dashboard.js
$(document).ready(function(){
    $.get('/api/v1.0/user/summary', function (resp) {
        if (resp.errno === '0') {
            $('#avatar-display').attr('src', resp.data.avatar);
            $('#nickname-display').text(resp.data.nickname);
            $('#phone-display').text(resp.data.phone);
        } else if (resp.errno === '4101') {
            window.location.replace('/auth/login');
        }
    });
});

실명 인증 시스템

서버측: 조회와 저장 통합 처리

from sqlalchemy import or_

@api.route('/user/identity-verification', methods=['GET', 'PATCH'])
@login_required
def identity_verification():
    """실명 인증 정보 관리"""
    if request.method == 'GET':
        return retrieve_verification()
    return process_verification()


def retrieve_verification():
    """저장된 인증 정보 반환"""
    return jsonify(errno=RET.OK, data={
        'full_name': g.user.real_name,
        'id_number': g.user.identity_card
    })


def process_verification():
    """새로운 인증 요청 처리"""
    data = request.get_json()
    if not data:
        return parameter_error()
    
    full_name = data.get('full_name')
    id_number = data.get('id_number')
    
    if not all([full_name, id_number]):
        return parameter_error()
    
    # 기존 인증 여부 확인
    existing = Users.query.filter(
        or_(
            Users.real_name == full_name,
            Users.identity_card == id_number
        )
    ).first()
    
    if existing:
        return jsonify(errno=RET.DATAEXIST, errmsg='이미 인증된 정보')
    
    # 인증 정보 저장
    g.user.real_name = full_name
    g.user.identity_card = id_number
    
    try:
        db.session.add(g.user)
        db.session.commit()
    except Exception as err:
        current_app.logger.error(err)
        db.session.rollback()
        return jsonify(errno=RET.DBERR, errmsg='인증 정보 저장 실패')
    
    return jsonify(errno=RET.OK)

클라이언트측: 조건부 UI 제어

// verification.js
$(document).ready(function () {
    // 기존 인증 정보 조회
    $.get('/api/v1.0/user/identity-verification', function (resp) {
        if (resp.errno === '0' && resp.data.full_name) {
            // 인증 완료 상태: 읽기 전용으로 전환
            $('#full-name').val(resp.data.full_name).prop('readonly', true);
            $('#id-number').val(resp.data.id_number).prop('readonly', true);
            $('#submit-verification').hide();
        }
    });
    
    // 인증 폼 제출
    $('#verification-form').submit(function (event) {
        event.preventDefault();
        
        var nameValue = $('#full-name').val().trim();
        var idValue = $('#id-number').val().trim();
        
        if (!nameValue) {
            alert('실명을 입력하세요');
            return;
        }
        if (!idValue) {
            alert('신분증 번호를 입력하세요');
            return;
        }
        
        $.ajax({
            url: '/api/v1.0/user/identity-verification',
            type: 'PATCH',
            contentType: 'application/json',
            data: JSON.stringify({
                full_name: nameValue,
                id_number: idValue
            }),
            dataType: 'json',
            headers: {'X-CSRFToken': retrieveCookie('csrf_token')},
            success: function (resp) {
                if (resp.errno === '0') {
                    showCompletionNotice();
                    window.location.reload();
                } else {
                    alert(resp.errmsg);
                }
            }
        });
    });
});

인증 완료 후 페이지를 새로고침하면 readonly 속성이 적용되어 수정이 불가능해집니다. .attr() 대신 .prop() 메서드를 사용하면 동적 속성 변경이 더 직관적입니다.

태그: flask Qiniu ajaxSubmit sqlalchemy WTForms

7월 29일 18:10에 게시됨