외부 이미지 저장소 연동
Typora와 PicGo를 활용한 마크다운 이미지 관리
마크다운 문서 작성 시 이미지 관리는 번거로운 작업입니다. Typora 에디터와 PicGo 도구를 조합하면 이 문제를 해결할 수 있습니다. PicGo는 다양한 클라우드 저장소를 지원하는 이미지 업로드 클라이언트입니다.
국내 사용자에게 적합한 선택은 gitee 저장소입니다. GitHub 대비 접속 속도가 빠르고 무료로 이용 가능합니다. 설정 완료 후 Typora에서 이미지를 붙여넣기하면 자동으로 업로드되며, 문서를 다른 플랫폼으로 옮겨도 이미지가 정상 표시됩니다.
Qiniu 클라우드 스토리지 적용
본 프로젝트의 프로필 사진은 Qiniu 클라우드에 저장합니다. 서비스 이용 절차는 다음과 같습니다:
- 계정 생성 및 실명 인증
- 객체 스토리지 제품 선택 후 버킷 생성
- 접근 권한을 공개로 설정
- 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() 메서드를 사용하면 동적 속성 변경이 더 직관적입니다.