본문 바로가기

IT와 과학/AI

Bing Search API로 나만의 검색 사이트 만들기: Flask 웹앱 완성 프로젝트 [2025]

728x90
반응형

Bing Search API로 나만의 검색 사이트 만들기: Flask 웹앱 완성 프로젝트 [2025]

 

 

 

드디어 Bing Search API 마스터 여정의 마지막 편입니다!

지난 두 편에서 API 연동부터 고급 검색 기능까지 완벽하게 구현했다면, 이제 이 모든 것을 하나로 모아 실제 사용 가능한 검색 웹사이트를 만들어보겠습니다.

오늘 만들 검색 사이트는 단순한 예제가 아닙니다. Google처럼 깔끔한 UI, 탭 기반 멀티 검색, 실시간 결과 업데이트, 모바일 반응형 디자인까지 - 실제 포트폴리오나 사이드 프로젝트로 활용할 수 있는 완성도 높은 결과물을 만들어보겠습니다.

시리즈 총정리: 지금까지의 여정

완성된 Bing Search API 마스터 스킬

1편에서 배운 것들

  • API 키 발급 및 기본 설정
  • 웹 검색 클라이언트 구현
  • Google API와의 상세 비교 분석

2편에서 배운 것들

  • 이미지 검색 (고품질 필터링)
  • 뉴스 검색 (카테고리별 분류)
  • 비디오 검색 (YouTube 통합)
  • 엔티티 검색 (구조화 정보)
  • 성능 최적화 (병렬 처리 + 캐싱)

3편에서 완성할 것 🎯

  • Flask 웹 애플리케이션
  • 반응형 UI/UX 디자인
  • 검색 히스토리 및 북마크
  • 실제 서비스 배포

오늘 만들 검색 사이트 미리보기

🌐 MyBingSearch - 완전한 검색 포털
├── 🔍 통합 검색 인터페이스
│   ├── 웹 검색 (기본)
│   ├── 이미지 검색 (필터링)
│   ├── 뉴스 검색 (카테고리)
│   └── 비디오 검색 (메타데이터)
├── 📱 모바일 반응형 디자인
├── 📚 검색 히스토리 관리
├── ⭐ 즐겨찾기 시스템
├── 📊 검색 통계 대시보드
└── 🚀 Heroku 배포 완료

Flask 기초부터 차근차근: 웹 프레임워크 완전 이해

Flask가 처음이신가요? 걱정 마세요!

Flask는 Python으로 웹사이트를 만들 수 있게 해주는 도구입니다. 마치 레고 블록처럼 필요한 기능을 하나씩 조립해서 웹사이트를 만들 수 있어요.

왜 Flask를 선택했을까요?

  • 배우기 쉬움: 최소한의 코드로 웹사이트 제작 가능
  • 유연함: 필요한 기능만 골라서 사용
  • Python: 이미 Bing API에서 Python을 사용했으니 연결이 자연스러움
  • 빠른 개발: 프로토타입부터 실제 서비스까지 빠르게 구현

Flask 10분 완성 가이드

Step 1: 가장 간단한 Flask 앱

from flask import Flask

# Flask 앱 생성
app = Flask(__name__)

# 메인 페이지 (라우트)
@app.route('/')
def home():
    return '<h1>안녕하세요! 첫 번째 Flask 앱입니다!</h1>'

# 앱 실행
if __name__ == '__main__':
    app.run(debug=True)

이 코드를 app.py로 저장하고 python app.py로 실행하면 http://127.0.0.1:5000에서 웹사이트를 볼 수 있어요!

Step 2: HTML 템플릿 사용하기

from flask import Flask, render_template

app = Flask(__name__)

@app.route('/')
def home():
    return render_template('index.html', title='내 웹사이트')

@app.route('/search')
def search():
    return render_template('search.html')

Step 3: 사용자 입력 받기

from flask import Flask, request, render_template

app = Flask(__name__)

@app.route('/search', methods=['GET', 'POST'])
def search():
    if request.method == 'POST':
        query = request.form['query']  # 폼에서 검색어 받기
        # 여기서 Bing API 호출!
        return f'검색어: {query}'
    
    return render_template('search.html')

이게 Flask의 전부입니다! 라우트(@app.route)로 URL을 연결하고, 함수에서 HTML을 반환하면 웹사이트가 됩니다.

Flask가 부담스럽다면? 더 쉬운 대안들

🚀 대안 1: Streamlit (가장 쉬움)

Streamlit은 데이터 사이언티스트들이 사랑하는 도구로, HTML/CSS 없이도 웹앱을 만들 수 있어요.

import streamlit as st
from bing_search_client import BingSearchClient  # 2편에서 만든 클라이언트

# 페이지 설정
st.set_page_config(page_title="MyBingSearch", page_icon="🔍")

# 제목
st.title("🔍 MyBingSearch")
st.subheader("Bing API로 만든 검색 포털")

# 사이드바에 검색 타입 선택
search_type = st.sidebar.selectbox(
    "검색 타입을 선택하세요",
    ["웹", "이미지", "뉴스", "비디오"]
)

# 검색어 입력
query = st.text_input("검색어를 입력하세요")

# 검색 버튼
if st.button("검색"):
    if query:
        client = BingSearchClient()
        
        with st.spinner("검색 중..."):
            if search_type == "웹":
                results = client.search(query, count=10)
                parsed_results = client.parse_results(results)
                
                # 결과 표시
                st.success(f"{len(parsed_results)}개 결과 발견!")
                
                for result in parsed_results:
                    st.markdown(f"**[{result['title']}]({result['url']})**")
                    st.write(result['description'])
                    st.divider()
                    
            elif search_type == "이미지":
                results = client.search_images(query, count=12)
                parsed_results = client.parse_image_results(results)
                
                # 이미지 그리드로 표시
                cols = st.columns(3)
                for i, image in enumerate(parsed_results):
                    with cols[i % 3]:
                        st.image(image['thumbnail_url'], caption=image['name'])
                        st.markdown(f"[원본 보기]({image['content_url']})")
    else:
        st.warning("검색어를 입력해주세요!")

# 최근 검색 기록 (세션 상태 사용)
if 'search_history' not in st.session_state:
    st.session_state.search_history = []

if query and st.button("검색"):
    # 검색 기록에 추가
    if query not in st.session_state.search_history:
        st.session_state.search_history.append(query)

# 사이드바에 검색 기록 표시
if st.session_state.search_history:
    st.sidebar.subheader("최근 검색어")
    for hist_query in st.session_state.search_history[-5:]:  # 최근 5개만
        if st.sidebar.button(hist_query, key=f"hist_{hist_query}"):
            st.experimental_rerun()

실행 방법:

pip install streamlit
streamlit run streamlit_app.py

자동으로 브라우저가 열리고 웹앱이 실행됩니다! HTML/CSS/JavaScript 없이도 완전한 검색 웹앱이 완성돼요.

대안 2: FastHTML (초신속)

FastHTML은 최근 주목받는 새로운 Python 웹 프레임워크로, Flask보다도 더 간단합니다.

from fasthtml.common import *
from bing_search_client import BingSearchClient

# 앱 생성
app, rt = fast_app()

# CSS 스타일 추가
css = """
.search-container { max-width: 800px; margin: 0 auto; padding: 20px; }
.search-box { width: 100%; padding: 15px; font-size: 18px; border: 2px solid #ddd; border-radius: 10px; }
.search-btn { background: #007bff; color: white; padding: 15px 30px; border: none; border-radius: 10px; cursor: pointer; }
.result-item { border: 1px solid #eee; padding: 20px; margin: 10px 0; border-radius: 8px; }
"""

@rt("/")
def get():
    return Titled("MyBingSearch - 검색 포털",
        Style(css),
        Div(
            H1("🔍 MyBingSearch"),
            P("Bing API로 만든 똑똑한 검색 포털"),
            Form(
                Input(name="query", placeholder="검색어를 입력하세요...", cls="search-box"),
                Button("검색", type="submit", cls="search-btn"),
                method="post", action="/search"
            ),
            cls="search-container"
        )
    )

@rt("/search", methods=["POST"])
def post(query: str):
    if not query:
        return RedirectResponse("/")
    
    client = BingSearchClient()
    results = client.search(query, count=10)
    parsed_results = client.parse_results(results)
    
    result_elements = []
    for result in parsed_results:
        result_elements.append(
            Div(
                H3(A(result['title'], href=result['url'], target="_blank")),
                P(result['description']),
                Small(result['display_url']),
                cls="result-item"
            )
        )
    
    return Titled(f"'{query}' 검색 결과",
        Style(css),
        Div(
            H2(f"'{query}' 검색 결과 ({len(parsed_results)}개)"),
            A("← 돌아가기", href="/"),
            *result_elements,
            cls="search-container"
        )
    )

# 실행
serve()

🌐 대안 3: 순수 HTML + JavaScript (프론트엔드 전용)

웹 프레임워크 없이 HTML과 JavaScript만으로도 검색 사이트를 만들 수 있어요. 단, 이 경우 API 키 보안에 주의해야 합니다.

    
    
    

🔍 MyBingSearch

 

        // 주의: 실제 환경에서는 API 키를 클라이언트에 노출하면 안 됩니다!
        // 이 방법은 학습용/테스트용으로만 사용하세요.
        
        async function performSearch() {
            const query = document.getElementById('searchInput').value;
            if (!query) return;
            
            const resultsDiv = document.getElementById('results');
            resultsDiv.innerHTML = '<p>검색 중...</p>';
            
            try {
                // CORS 문제로 직접 호출이 어려울 수 있습니다.
                // 실제로는 백엔드 서버를 통해 호출해야 합니다.
                const response = await fetch(`/api/search`, {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({ query: query, type: 'web' })
                });
                
                const data = await response.json();
                displayResults(data.results);
                
            } catch (error) {
                resultsDiv.innerHTML = '<p class="text-danger">검색 중 오류가 발생했습니다.</p>';
            }
        }
        
        function displayResults(results) {
            const resultsDiv = document.getElementById('results');
            
            if (results.length === 0) {
                resultsDiv.innerHTML = '<p class="text-muted">검색 결과가 없습니다.</p>';
                return;
            }
            
            let html = '<div class="mt-4">';
            results.forEach(result => {
                html += `
                    <div class="card mb-3">
                        <div class="card-body">
                            <h5 class="card-title">
                                <a href="${result.url}" target="_blank">${result.title}</a>
                            </h5>
                            <p class="card-text">${result.description}</p>
                            <small class="text-muted">${result.display_url}</small>
                        </div>
                    </div>
                `;
            });
            html += '</div>';
            
            resultsDiv.innerHTML = html;
        }
        
        // 엔터키로 검색
        document.getElementById('searchInput').addEventListener('keypress', function(e) {
            if (e.key === 'Enter') {
                performSearch();
            }
        });
    

📱 대안 4: Gradio (AI/ML 개발자 친화적)

Gradio는 머신러닝 모델을 웹으로 배포할 때 자주 사용하는 도구입니다.

import gradio as gr
from bing_search_client import BingSearchClient

client = BingSearchClient()

def search_web(query, search_type="웹", count=10):
    """검색 함수"""
    if not query:
        return "검색어를 입력해주세요."
    
    try:
        if search_type == "웹":
            results = client.search(query, count)
            parsed_results = client.parse_results(results)
            
            output = f"'{query}' 검색 결과 ({len(parsed_results)}개):\n\n"
            for i, result in enumerate(parsed_results, 1):
                output += f"{i}. {result['title']}\n"
                output += f"   URL: {result['url']}\n"
                output += f"   설명: {result['description']}\n\n"
                
        elif search_type == "이미지":
            results = client.search_images(query, count)
            parsed_results = client.parse_image_results(results)
            
            # Gradio에서 이미지 표시
            images = []
            captions = []
            for result in parsed_results[:6]:  # 첫 6개만
                images.append(result['thumbnail_url'])
                captions.append(f"{result['name']} ({result['width']}x{result['height']})")
                
            return images, f"'{query}' 이미지 검색 결과 ({len(parsed_results)}개)"
            
        return output
        
    except Exception as e:
        return f"검색 중 오류가 발생했습니다: {str(e)}"

# Gradio 인터페이스 생성
with gr.Blocks(title="MyBingSearch") as demo:
    gr.Markdown("# 🔍 MyBingSearch")
    gr.Markdown("*Bing API로 만든 검색 포털*")
    
    with gr.Row():
        with gr.Column(scale=3):
            query_input = gr.Textbox(
                label="검색어",
                placeholder="검색하고 싶은 내용을 입력하세요...",
                lines=1
            )
        with gr.Column(scale=1):
            search_type = gr.Dropdown(
                choices=["웹", "이미지", "뉴스"],
                value="웹",
                label="검색 타입"
            )
    
    search_btn = gr.Button("검색", variant="primary", size="lg")
    
    with gr.Row():
        output_text = gr.Textbox(
            label="검색 결과",
            lines=20,
            max_lines=30
        )
        output_images = gr.Gallery(
            label="이미지 결과",
            visible=False
        )
    
    # 검색 타입에 따라 출력 변경
    def update_output_visibility(search_type):
        if search_type == "이미지":
            return gr.update(visible=False), gr.update(visible=True)
        else:
            return gr.update(visible=True), gr.update(visible=False)
    
    search_type.change(
        update_output_visibility,
        inputs=[search_type],
        outputs=[output_text, output_images]
    )
    
    search_btn.click(
        search_web,
        inputs=[query_input, search_type, gr.Number(value=10, visible=False)],
        outputs=[output_text]
    )

# 실행
demo.launch(share=True)  # share=True로 공개 링크 생성 가능

어떤 방법을 선택해야 할까요?

방법 난이도 개발 속도 커스터마이징 배포 추천 대상

Streamlit ⭐⭐⭐ ⭐⭐ ⭐⭐⭐ 데이터 분석가, 빠른 프로토타입
FastHTML ⭐⭐ ⭐⭐⭐ ⭐⭐⭐ ⭐⭐ 웹 개발 입문자
Flask ⭐⭐⭐ ⭐⭐ ⭐⭐⭐ ⭐⭐⭐ 웹 개발자, 실제 서비스
순수 HTML ⭐⭐ ⭐⭐ ⭐⭐⭐ 프론트엔드 개발자
Gradio ⭐⭐⭐ ⭐⭐ AI/ML 개발자

추천 학습 순서:

  1. Streamlit으로 시작 → 빠르게 결과물 확인
  2. FastHTML 체험 → 웹 개발 감각 익히기
  3. Flask 마스터 → 실제 서비스 개발 (원래 3편 내용)

Flask 학습이 부담스럽다면?

Streamlit 버전부터 시작해서 단계적으로 Flask로 발전시키는 방법을 추천합니다:

# 1단계: Streamlit으로 빠른 프로토타입
streamlit_app.py  # 위의 Streamlit 코드

# 2단계: FastHTML로 웹 개발 감각 익히기  
fasthtml_app.py   # 위의 FastHTML 코드

# 3단계: Flask로 완전한 서비스 개발
flask_app.py      # 원래 3편의 Flask 코드

이렇게 하면 어떤 수준이든 상관없이 2편에서 만든 Bing API 클라이언트를 활용해서 검색 사이트를 만들 수 있어요!

전문가 수준의 디렉토리 구조

bing_search_webapp/
├── app/
│   ├── __init__.py              # Flask 앱 팩토리
│   ├── models/
│   │   ├── __init__.py
│   │   ├── search_history.py    # 검색 기록 모델
│   │   └── favorites.py         # 즐겨찾기 모델
│   ├── api/
│   │   ├── __init__.py
│   │   ├── bing_client.py       # 2편에서 만든 클라이언트
│   │   └── search_routes.py     # API 라우트
│   ├── templates/
│   │   ├── base.html           # 기본 템플릿
│   │   ├── index.html          # 메인 검색 페이지
│   │   ├── results.html        # 검색 결과 페이지
│   │   └── components/         # 재사용 컴포넌트
│   ├── static/
│   │   ├── css/
│   │   │   ├── main.css        # 메인 스타일
│   │   │   └── responsive.css  # 반응형 CSS
│   │   ├── js/
│   │   │   ├── search.js       # 검색 로직
│   │   │   └── ui.js          # UI 인터랙션
│   │   └── images/
│   └── utils/
│       ├── __init__.py
│       ├── cache.py            # 캐싱 유틸리티
│       └── helpers.py          # 헬퍼 함수
├── migrations/                  # 데이터베이스 마이그레이션
├── tests/                      # 테스트 코드
├── .env                        # 환경변수
├── .gitignore
├── requirements.txt
├── config.py                   # 설정 관리
├── wsgi.py                     # WSGI 엔트리포인트
└── README.md

핵심 설정 파일들

requirements.txt:

Flask==2.3.3
Flask-SQLAlchemy==3.0.5
Flask-Migrate==4.0.5
Flask-WTF==1.1.1
WTForms==3.0.1
requests==2.31.0
python-dotenv==1.0.0
gunicorn==21.2.0
aiohttp==3.8.6
Werkzeug==2.3.7

config.py:

import os
from dotenv import load_dotenv

load_dotenv()

class Config:
    """기본 설정"""
    SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production'
    SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or 'sqlite:///search_app.db'
    SQLALCHEMY_TRACK_MODIFICATIONS = False
    
    # Bing Search API 설정
    BING_SEARCH_API_KEY = os.environ.get('BING_SEARCH_API_KEY')
    BING_SEARCH_ENDPOINT = 'https://api.bing.microsoft.com/v7.0'
    
    # 캐싱 설정
    CACHE_TTL = int(os.environ.get('CACHE_TTL', 3600))  # 1시간
    
    # 페이징 설정
    RESULTS_PER_PAGE = int(os.environ.get('RESULTS_PER_PAGE', 10))
    MAX_RESULTS_PER_REQUEST = int(os.environ.get('MAX_RESULTS_PER_REQUEST', 50))

class DevelopmentConfig(Config):
    """개발 환경 설정"""
    DEBUG = True

class ProductionConfig(Config):
    """운영 환경 설정"""
    DEBUG = False

config = {
    'development': DevelopmentConfig,
    'production': ProductionConfig,
    'default': DevelopmentConfig
}

Flask 백엔드 구현: RESTful API 설계

Flask 앱 팩토리 패턴

app/init.py:

from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from config import config

# 전역 확장 객체들
db = SQLAlchemy()
migrate = Migrate()

def create_app(config_name='default'):
    """Flask 앱 팩토리"""
    
    app = Flask(__name__)
    app.config.from_object(config[config_name])
    
    # 확장 초기화
    db.init_app(app)
    migrate.init_app(app, db)
    
    # 블루프린트 등록
    from app.api import bp as api_bp
    app.register_blueprint(api_bp, url_prefix='/api')
    
    from app.main import bp as main_bp
    app.register_blueprint(main_bp)
    
    return app

데이터 모델 설계

app/models/search_history.py:

from datetime import datetime
from app import db

class SearchHistory(db.Model):
    """검색 기록 모델"""
    
    id = db.Column(db.Integer, primary_key=True)
    query = db.Column(db.String(500), nullable=False)
    search_type = db.Column(db.String(20), nullable=False)  # web, images, news, videos
    results_count = db.Column(db.Integer, default=0)
    timestamp = db.Column(db.DateTime, default=datetime.utcnow)
    user_ip = db.Column(db.String(45))  # IPv6 지원
    user_agent = db.Column(db.Text)
    
    def to_dict(self):
        """딕셔너리로 변환"""
        return {
            'id': self.id,
            'query': self.query,
            'search_type': self.search_type,
            'results_count': self.results_count,
            'timestamp': self.timestamp.isoformat(),
            'user_ip': self.user_ip
        }
    
    @staticmethod
    def get_recent_searches(limit=10):
        """최근 검색어 조회"""
        return SearchHistory.query.order_by(
            SearchHistory.timestamp.desc()
        ).limit(limit).all()
    
    @staticmethod
    def get_popular_searches(days=7, limit=10):
        """인기 검색어 조회"""
        from sqlalchemy import func
        
        cutoff_date = datetime.utcnow() - timedelta(days=days)
        
        return db.session.query(
            SearchHistory.query,
            func.count(SearchHistory.query).label('search_count')
        ).filter(
            SearchHistory.timestamp >= cutoff_date
        ).group_by(
            SearchHistory.query
        ).order_by(
            func.count(SearchHistory.query).desc()
        ).limit(limit).all()

class Favorites(db.Model):
    """즐겨찾기 모델"""
    
    id = db.Column(db.Integer, primary_key=True)
    title = db.Column(db.String(500), nullable=False)
    url = db.Column(db.Text, nullable=False)
    description = db.Column(db.Text)
    search_type = db.Column(db.String(20), nullable=False)
    timestamp = db.Column(db.DateTime, default=datetime.utcnow)
    user_ip = db.Column(db.String(45))
    
    def to_dict(self):
        return {
            'id': self.id,
            'title': self.title,
            'url': self.url,
            'description': self.description,
            'search_type': self.search_type,
            'timestamp': self.timestamp.isoformat()
        }

API 라우트 구현

app/api/search_routes.py:

from flask import Blueprint, request, jsonify, current_app
from app.api.bing_client import BingSearchClient  # 2편에서 만든 클라이언트
from app.models.search_history import SearchHistory, Favorites
from app import db
import asyncio
from concurrent.futures import ThreadPoolExecutor

bp = Blueprint('api', __name__)

# 검색 클라이언트 초기화
search_client = BingSearchClient()

@bp.route('/search', methods=['POST'])
def search():
    """통합 검색 API"""
    
    data = request.get_json()
    query = data.get('query', '').strip()
    search_type = data.get('type', 'web')
    count = min(data.get('count', 10), current_app.config['MAX_RESULTS_PER_REQUEST'])
    page = data.get('page', 1)
    
    if not query:
        return jsonify({'error': '검색어를 입력해주세요'}), 400
    
    try:
        # 검색 실행
        if search_type == 'web':
            results = search_client.search(query, count)
            parsed_results = search_client.parse_results(results) if results else []
            
        elif search_type == 'images':
            image_type = data.get('imageType', 'All')
            size = data.get('size', 'All')
            color = data.get('color', 'All')
            
            results = search_client.search_images(
                query, count, image_type=image_type, size=size, color=color
            )
            parsed_results = search_client.parse_image_results(results) if results else []
            
        elif search_type == 'news':
            category = data.get('category', '')
            sort_by = data.get('sortBy', 'Date')
            
            results = search_client.search_news(
                query, category=category, count=count, sort_by=sort_by
            )
            parsed_results = search_client.parse_news_results(results) if results else []
            
        elif search_type == 'videos':
            pricing = data.get('pricing', 'All')
            length = data.get('length', 'All')
            
            results = search_client.search_videos(
                query, count, pricing=pricing, length=length
            )
            parsed_results = search_client.parse_video_results(results) if results else []
            
        else:
            return jsonify({'error': '지원하지 않는 검색 타입입니다'}), 400
        
        # 검색 기록 저장
        search_history = SearchHistory(
            query=query,
            search_type=search_type,
            results_count=len(parsed_results),
            user_ip=request.remote_addr,
            user_agent=request.headers.get('User-Agent', '')
        )
        db.session.add(search_history)
        db.session.commit()
        
        # 응답 데이터 구성
        response_data = {
            'query': query,
            'search_type': search_type,
            'results_count': len(parsed_results),
            'page': page,
            'results': parsed_results
        }
        
        # 검색 메타 정보 추가 (웹 검색의 경우)
        if search_type == 'web' and results:
            search_info = search_client.get_search_info(results)
            response_data['meta'] = search_info
        
        return jsonify(response_data)
        
    except Exception as e:
        current_app.logger.error(f"검색 오류: {e}")
        return jsonify({'error': '검색 중 오류가 발생했습니다'}), 500

@bp.route('/search/suggestions', methods=['GET'])
def search_suggestions():
    """검색어 자동완성"""
    
    query = request.args.get('q', '').strip()
    
    if len(query) < 2:
        return jsonify({'suggestions': []})
    
    try:
        # 최근 검색 기록에서 유사한 검색어 찾기
        similar_searches = SearchHistory.query.filter(
            SearchHistory.query.ilike(f'%{query}%')
        ).order_by(
            SearchHistory.timestamp.desc()
        ).limit(5).all()
        
        suggestions = [search.query for search in similar_searches]
        
        # 중복 제거 및 현재 입력과 정확히 일치하는 것 제외
        unique_suggestions = []
        for suggestion in suggestions:
            if suggestion.lower() != query.lower() and suggestion not in unique_suggestions:
                unique_suggestions.append(suggestion)
        
        return jsonify({'suggestions': unique_suggestions[:5]})
        
    except Exception as e:
        current_app.logger.error(f"자동완성 오류: {e}")
        return jsonify({'suggestions': []})

@bp.route('/favorites', methods=['GET', 'POST', 'DELETE'])
def manage_favorites():
    """즐겨찾기 관리"""
    
    if request.method == 'GET':
        # 즐겨찾기 목록 조회
        page = request.args.get('page', 1, type=int)
        per_page = 20
        
        favorites = Favorites.query.order_by(
            Favorites.timestamp.desc()
        ).paginate(
            page=page, per_page=per_page, error_out=False
        )
        
        return jsonify({
            'favorites': [fav.to_dict() for fav in favorites.items],
            'total': favorites.total,
            'pages': favorites.pages,
            'current_page': page
        })
    
    elif request.method == 'POST':
        # 즐겨찾기 추가
        data = request.get_json()
        
        required_fields = ['title', 'url', 'search_type']
        if not all(field in data for field in required_fields):
            return jsonify({'error': '필수 필드가 누락되었습니다'}), 400
        
        favorite = Favorites(
            title=data['title'],
            url=data['url'],
            description=data.get('description', ''),
            search_type=data['search_type'],
            user_ip=request.remote_addr
        )
        
        db.session.add(favorite)
        db.session.commit()
        
        return jsonify({'message': '즐겨찾기에 추가되었습니다', 'id': favorite.id}), 201
    
    elif request.method == 'DELETE':
        # 즐겨찾기 삭제
        favorite_id = request.args.get('id', type=int)
        
        if not favorite_id:
            return jsonify({'error': '즐겨찾기 ID가 필요합니다'}), 400
        
        favorite = Favorites.query.get_or_404(favorite_id)
        db.session.delete(favorite)
        db.session.commit()
        
        return jsonify({'message': '즐겨찾기에서 삭제되었습니다'})

@bp.route('/stats', methods=['GET'])
def search_stats():
    """검색 통계"""
    
    try:
        # 오늘의 검색 통계
        from datetime import datetime, timedelta
        
        today = datetime.utcnow().date()
        today_start = datetime.combine(today, datetime.min.time())
        
        today_searches = SearchHistory.query.filter(
            SearchHistory.timestamp >= today_start
        ).count()
        
        # 타입별 검색 통계 (최근 7일)
        week_ago = datetime.utcnow() - timedelta(days=7)
        
        type_stats = db.session.query(
            SearchHistory.search_type,
            db.func.count(SearchHistory.id).label('count')
        ).filter(
            SearchHistory.timestamp >= week_ago
        ).group_by(SearchHistory.search_type).all()
        
        # 인기 검색어 (최근 7일)
        popular_queries = SearchHistory.get_popular_searches(days=7, limit=10)
        
        return jsonify({
            'today_searches': today_searches,
            'type_stats': [{'type': stat[0], 'count': stat[1]} for stat in type_stats],
            'popular_queries': [{'query': query[0], 'count': query[1]} for query in popular_queries]
        })
        
    except Exception as e:
        current_app.logger.error(f"통계 조회 오류: {e}")
        return jsonify({'error': '통계 조회 중 오류가 발생했습니다'}), 500

프론트엔드 구현: 현대적인 반응형 UI

기본 HTML 템플릿

app/templates/base.html:

    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    {% block extra_head %}{% endblock %}


    

    
{% block content %}{% endblock %}

    

    {% block extra_js %}{% endblock %}

메인 검색 페이지

app/templates/index.html:

{% extends "base.html" %}

{% block title %}MyBingSearch - 통합 검색 포털{% endblock %}

{% block content %}
<div class="row justify-content-center">
    <div class="col-lg-8">
        <!-- 검색 박스 -->
        <div class="card shadow-sm mb-4">
            <div class="card-body p-4">
                <div class="text-center mb-4">
                    <h1 class="display-6 text-primary mb-3">
                        <i class="fas fa-search me-2"></i>MyBingSearch
                    </h1>
                    <p class="text-muted">웹, 이미지, 뉴스, 비디오를 한 번에 검색하세요</p>
                </div>
                
                <!-- 검색 폼 -->
                <form id="searchForm" class="mb-3">
                    <div class="input-group input-group-lg">
                        <input type="text" 
                               id="searchInput" 
                               class="form-control" 
                               placeholder="검색어를 입력하세요..." 
                               autocomplete="off"
                               required>
                        <button type="submit" class="btn btn-primary">
                            <i class="fas fa-search"></i>
                        </button>
                    </div>
                    
                    <!-- 자동완성 드롭다운 -->
                    <div id="suggestions" class="list-group position-absolute w-100 mt-1" style="z-index: 1000; display: none;"></div>
                </form>
                
                <!-- 검색 타입 선택 -->
                <div class="row g-2">
                    <div class="col-6 col-md-3">
                        <input type="radio" class="btn-check" name="searchType" id="typeWeb" value="web" checked>
                        <label class="btn btn-outline-primary w-100" for="typeWeb">
                            <i class="fas fa-globe me-1"></i>웹
                        </label>
                    </div>
                    <div class="col-6 col-md-3">
                        <input type="radio" class="btn-check" name="searchType" id="typeImages" value="images">
                        <label class="btn btn-outline-primary w-100" for="typeImages">
                            <i class="fas fa-image me-1"></i>이미지
                        </label>
                    </div>
                    <div class="col-6 col-md-3">
                        <input type="radio" class="btn-check" name="searchType" id="typeNews" value="news">
                        <label class="btn btn-outline-primary w-100" for="typeNews">
                            <i class="fas fa-newspaper me-1"></i>뉴스
                        </label>
                    </div>
                    <div class="col-6 col-md-3">
                        <input type="radio" class="btn-check" name="searchType" id="typeVideos" value="videos">
                        <label class="btn btn-outline-primary w-100" for="typeVideos">
                            <i class="fas fa-video me-1"></i>비디오
                        </label>
                    </div>
                </div>
            </div>
        </div>
        
        <!-- 고급 검색 옵션 -->
        <div class="card mb-4" id="advancedOptions" style="display: none;">
            <div class="card-header">
                <h6 class="mb-0">
                    <i class="fas fa-cog me-2"></i>고급 검색 옵션
                </h6>
            </div>
            <div class="card-body">
                <!-- 이미지 검색 옵션 -->
                <div id="imageOptions" class="search-options" style="display: none;">
                    <div class="row g-3">
                        <div class="col-md-4">
                            <label class="form-label">이미지 크기</label>
                            <select class="form-select" id="imageSize">
                                <option value="All">전체</option>
                                <option value="Small">작게</option>
                                <option value="Medium">보통</option>
                                <option value="Large">크게</option>
                                <option value="Wallpaper">배경화면</option>
                            </select>
                        </div>
                        <div class="col-md-4">
                            <label class="form-label">색상</label>
                            <select class="form-select" id="imageColor">
                                <option value="All">전체</option>
                                <option value="ColorOnly">컬러만</option>
                                <option value="Monochrome">흑백</option>
                                <option value="Red">빨강</option>
                                <option value="Blue">파랑</option>
                                <option value="Green">초록</option>
                            </select>
                        </div>
                        <div class="col-md-4">
                            <label class="form-label">타입</label>
                            <select class="form-select" id="imageType">
                                <option value="All">전체</option>
                                <option value="Photo">사진</option>
                                <option value="Clipart">클립아트</option>
                                <option value="Line">라인아트</option>
                            </select>
                        </div>
                    </div>
                </div>
                
                <!-- 뉴스 검색 옵션 -->
                <div id="newsOptions" class="search-options" style="display: none;">
                    <div class="row g-3">
                        <div class="col-md-6">
                            <label class="form-label">카테고리</label>
                            <select class="form-select" id="newsCategory">
                                <option value="">전체</option>
                                <option value="Business">비즈니스</option>
                                <option value="Entertainment">엔터테인먼트</option>
                                <option value="Health">건강</option>
                                <option value="Politics">정치</option>
                                <option value="ScienceAndTechnology">과학기술</option>
                                <option value="Sports">스포츠</option>
                                <option value="World">세계</option>
                            </select>
                        </div>
                        <div class="col-md-6">
                            <label class="form-label">정렬</label>
                            <select class="form-select" id="newsSortBy">
                                <option value="Date">최신순</option>
                                <option value="Relevance">관련도순</option>
                            </select>
                        </div>
                    </div>
                </div>
                
                <!-- 비디오 검색 옵션 -->
                <div id="videoOptions" class="search-options" style="display: none;">
                    <div class="row g-3">
                        <div class="col-md-4">
                            <label class="form-label">길이</label>
                            <select class="form-select" id="videoLength">
                                <option value="All">전체</option>
                                <option value="Short">짧음 (5분 이하)</option>
                                <option value="Medium">보통 (5-20분)</option>
                                <option value="Long">김 (20분 이상)</option>
                            </select>
                        </div>
                        <div class="col-md-4">
                            <label class="form-label">해상도</label>
                            <select class="form-select" id="videoResolution">
                                <option value="All">전체</option>
                                <option value="SD480p">SD (480p)</option>
                                <option value="HD720p">HD (720p)</option>
                                <option value="HD1080p">Full HD (1080p)</option>
                            </select>
                        </div>
                        <div class="col-md-4">
                            <label class="form-label">가격</label>
                            <select class="form-select" id="videoPricing">
                                <option value="All">전체</option>
                                <option value="Free">무료</option>
                                <option value="Paid">유료</option>
                            </select>
                        </div>
                    </div>
                </div>
            </div>
        </div>
        
        <!-- 로딩 스피너 -->
        <div id="loadingSpinner" class="text-center my-5" style="display: none;">
            <div class="spinner-border text-primary" role="status">
                <span class="visually-hidden">검색 중...</span>
            </div>
            <p class="mt-2 text-muted">검색 중입니다...</p>
        </div>
        
        <!-- 검색 결과 -->
        <div id="searchResults"></div>
        
        <!-- 최근 검색어 -->
        <div class="card">
            <div class="card-header">
                <h6 class="mb-0">
                    <i class="fas fa-history me-2"></i>최근 검색어
                </h6>
            </div>
            <div class="card-body">
                <div id="recentSearches">
                    <p class="text-muted">검색 기록이 없습니다.</p>
                </div>
            </div>
        </div>
    </div>
</div>
{% endblock %}

{% block extra_js %}
<script src="{{ url_for('static', filename='js/search.js') }}"></script>
{% endblock %}

고급 JavaScript 검색 로직

app/static/js/search.js:

class BingSearchApp {
    constructor() {
        this.searchForm = document.getElementById('searchForm');
        this.searchInput = document.getElementById('searchInput');
        this.searchResults = document.getElementById('searchResults');
        this.loadingSpinner = document.getElementById('loadingSpinner');
        this.suggestionsContainer = document.getElementById('suggestions');
        
        this.currentQuery = '';
        this.currentPage = 1;
        this.isLoading = false;
        
        this.initializeEventListeners();
        this.loadRecentSearches();
    }
    
    initializeEventListeners() {
        // 검색 폼 제출
        this.searchForm.addEventListener('submit', (e) => {
            e.preventDefault();
            this.performSearch();
        });
        
        // 검색 타입 변경
        document.querySelectorAll('input[name="searchType"]').forEach(radio => {
            radio.addEventListener('change', () => {
                this.toggleAdvancedOptions();
                if (this.currentQuery) {
                    this.performSearch();
                }
            });
        });
        
        // 자동완성
        this.searchInput.addEventListener('input', this.debounce(() => {
            this.getSuggestions();
        }, 300));
        
        // 자동완성 숨기기
        document.addEventListener('click', (e) => {
            if (!this.searchInput.contains(e.target) && !this.suggestionsContainer.contains(e.target)) {
                this.hideSuggestions();
            }
        });
        
        // 고급 옵션 토글
        document.addEventListener('click', (e) => {
            if (e.target.matches('.toggle-advanced')) {
                const advancedOptions = document.getElementById('advancedOptions');
                advancedOptions.style.display = advancedOptions.style.display === 'none' ? 'block' : 'none';
            }
        });
    }
    
    async performSearch(page = 1) {
        const query = this.searchInput.value.trim();
        if (!query) return;
        
        this.currentQuery = query;
        this.currentPage = page;
        
        const searchType = document.querySelector('input[name="searchType"]:checked').value;
        
        // 로딩 상태 표시
        this.showLoading();
        
        try {
            const searchData = {
                query: query,
                type: searchType,
                count: 20,
                page: page
            };
            
            // 고급 옵션 추가
            if (searchType === 'images') {
                searchData.size = document.getElementById('imageSize').value;
                searchData.color = document.getElementById('imageColor').value;
                searchData.imageType = document.getElementById('imageType').value;
            } else if (searchType === 'news') {
                searchData.category = document.getElementById('newsCategory').value;
                searchData.sortBy = document.getElementById('newsSortBy').value;
            } else if (searchType === 'videos') {
                searchData.length = document.getElementById('videoLength').value;
                searchData.resolution = document.getElementById('videoResolution').value;
                searchData.pricing = document.getElementById('videoPricing').value;
            }
            
            const response = await fetch('/api/search', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify(searchData)
            });
            
            const data = await response.json();
            
            if (response.ok) {
                this.displayResults(data);
            } else {
                this.showError(data.error || '검색 중 오류가 발생했습니다.');
            }
        } catch (error) {
            console.error('검색 오류:', error);
            this.showError('네트워크 오류가 발생했습니다.');
        } finally {
            this.hideLoading();
        }
    }
    
    displayResults(data) {
        const { query, search_type, results_count, results } = data;
        
        let html = `
            <div class="card mb-4">
                <div class="card-header d-flex justify-content-between align-items-center">
                    <h5 class="mb-0">"${query}" 검색 결과</h5>
                    <div class="d-flex align-items-center gap-3">
                        <span class="badge bg-primary">${results_count}개 결과</span>
                        <button class="btn btn-sm btn-outline-secondary toggle-advanced">
                            고급 옵션
                        </button>
                    </div>
                </div>
                <div class="card-body">
        `;
        
        if (results.length === 0) {
            html += '<p class="text-muted">검색 결과가 없습니다.</p>';
        } else {
            // 검색 타입별 결과 렌더링
            if (search_type === 'web') {
                html += this.renderWebResults(results);
            } else if (search_type === 'images') {
                html += this.renderImageResults(results);
            } else if (search_type === 'news') {
                html += this.renderNewsResults(results);
            } else if (search_type === 'videos') {
                html += this.renderVideoResults(results);
            }
        }
        
        html += '</div></div>';
        
        this.searchResults.innerHTML = html;
        
        // 즐겨찾기 버튼 이벤트 리스너 추가
        this.attachFavoriteListeners();
    }
    
    renderWebResults(results) {
        return results.map(result => `
            <div class="result-item mb-3 p-3 border rounded">
                <div class="d-flex justify-content-between align-items-start">
                    <div class="flex-grow-1">
                        <h6>
                            <a href="${result.url}" target="_blank" class="text-decoration-none">
                                ${result.title}
                            </a>
                        </h6>
                        <p class="text-muted small mb-1">${result.display_url}</p>
                        <p class="mb-2">${result.description}</p>
                    </div>
                    <button class="btn btn-sm btn-outline-warning add-favorite" 
                            data-title="${result.title}" 
                            data-url="${result.url}" 
                            data-description="${result.description}"
                            data-type="web">
                        <i class="fas fa-star"></i>
                    </button>
                </div>
            </div>
        `).join('');
    }
    
    renderImageResults(results) {
        return `
            <div class="row g-3">
                ${results.map(image => `
                    <div class="col-6 col-md-4 col-lg-3">
                        <div class="card h-100">
                            <div class="position-relative">
                                <img src="${image.thumbnail_url}" 
                                     class="card-img-top" 
                                     alt="${image.name}"
                                     style="height: 200px; object-fit: cover;"
                                     loading="lazy">
                                <button class="btn btn-sm btn-outline-warning position-absolute top-0 end-0 m-2 add-favorite"
                                        data-title="${image.name}" 
                                        data-url="${image.content_url}" 
                                        data-description="${image.width}x${image.height}"
                                        data-type="images">
                                    <i class="fas fa-star"></i>
                                </button>
                            </div>
                            <div class="card-body p-2">
                                <h6 class="card-title small">${image.name}</h6>
                                <p class="card-text small text-muted">
                                    ${image.width}×${image.height} • ${image.encoding_format}
                                </p>
                                <a href="${image.content_url}" target="_blank" class="btn btn-sm btn-primary">
                                    원본 보기
                                </a>
                            </div>
                        </div>
                    </div>
                `).join('')}
            </div>
        `;
    }
    
    renderNewsResults(results) {
        return results.map(article => `
            <div class="result-item mb-3 p-3 border rounded">
                <div class="d-flex justify-content-between align-items-start">
                    <div class="flex-grow-1">
                        <div class="d-flex align-items-center mb-2">
                            ${article.image_url ? `<img src="${article.image_url}" class="me-3" style="width: 60px; height: 60px; object-fit: cover;">` : ''}
                            <div>
                                <h6>
                                    <a href="${article.url}" target="_blank" class="text-decoration-none">
                                        ${article.title}
                                    </a>
                                    ${article.is_breaking_news ? '<span class="badge bg-danger ms-2">속보</span>' : ''}
                                </h6>
                                <p class="text-muted small mb-0">
                                    ${article.provider.name} • ${this.formatDate(article.published_time)}
                                </p>
                            </div>
                        </div>
                        <p class="mb-2">${article.description}</p>
                    </div>
                    <button class="btn btn-sm btn-outline-warning add-favorite"
                            data-title="${article.title}" 
                            data-url="${article.url}" 
                            data-description="${article.description}"
                            data-type="news">
                        <i class="fas fa-star"></i>
                    </button>
                </div>
            </div>
        `).join('');
    }
    
    renderVideoResults(results) {
        return `
            <div class="row g-3">
                ${results.map(video => `
                    <div class="col-md-6 col-lg-4">
                        <div class="card h-100">
                            <div class="position-relative">
                                <img src="${video.thumbnail_url}" 
                                     class="card-img-top" 
                                     alt="${video.title}"
                                     style="height: 200px; object-fit: cover;">
                                <div class="position-absolute bottom-0 end-0 bg-dark text-white px-2 py-1 small">
                                    ${video.duration}
                                </div>
                                <button class="btn btn-sm btn-outline-warning position-absolute top-0 end-0 m-2 add-favorite"
                                        data-title="${video.title}" 
                                        data-url="${video.content_url}" 
                                        data-description="${video.description}"
                                        data-type="videos">
                                    <i class="fas fa-star"></i>
                                </button>
                            </div>
                            <div class="card-body">
                                <h6 class="card-title">${video.title}</h6>
                                <p class="card-text small text-muted">
                                    ${video.creator} • 조회수 ${this.formatNumber(video.view_count)}회
                                </p>
                                <a href="${video.content_url}" target="_blank" class="btn btn-sm btn-primary">
                                    시청하기
                                </a>
                            </div>
                        </div>
                    </div>
                `).join('')}
            </div>
        `;
    }
    
    async getSuggestions() {
        const query = this.searchInput.value.trim();
        if (query.length < 2) {
            this.hideSuggestions();
            return;
        }
        
        try {
            const response = await fetch(`/api/search/suggestions?q=${encodeURIComponent(query)}`);
            const data = await response.json();
            
            if (data.suggestions && data.suggestions.length > 0) {
                this.showSuggestions(data.suggestions);
            } else {
                this.hideSuggestions();
            }
        } catch (error) {
            console.error('자동완성 오류:', error);
            this.hideSuggestions();
        }
    }
    
    showSuggestions(suggestions) {
        const html = suggestions.map(suggestion => `
            <button type="button" class="list-group-item list-group-item-action suggestion-item">
                <i class="fas fa-search me-2 text-muted"></i>${suggestion}
            </button>
        `).join('');
        
        this.suggestionsContainer.innerHTML = html;
        this.suggestionsContainer.style.display = 'block';
        
        // 제안어 클릭 이벤트
        this.suggestionsContainer.querySelectorAll('.suggestion-item').forEach(item => {
            item.addEventListener('click', () => {
                this.searchInput.value = item.textContent.trim();
                this.hideSuggestions();
                this.performSearch();
            });
        });
    }
    
    hideSuggestions() {
        this.suggestionsContainer.style.display = 'none';
    }
    
    toggleAdvancedOptions() {
        const searchType = document.querySelector('input[name="searchType"]:checked').value;
        
        // 모든 옵션 숨기기
        document.querySelectorAll('.search-options').forEach(option => {
            option.style.display = 'none';
        });
        
        // 해당 타입의 옵션 보이기
        const optionElement = document.getElementById(`${searchType}Options`);
        if (optionElement) {
            optionElement.style.display = 'block';
        }
    }
    
    attachFavoriteListeners() {
        document.querySelectorAll('.add-favorite').forEach(button => {
            button.addEventListener('click', async (e) => {
                e.preventDefault();
                
                const title = e.currentTarget.dataset.title;
                const url = e.currentTarget.dataset.url;
                const description = e.currentTarget.dataset.description;
                const type = e.currentTarget.dataset.type;
                
                await this.addToFavorites(title, url, description, type, e.currentTarget);
            });
        });
    }
    
    async addToFavorites(title, url, description, type, button) {
        try {
            const response = await fetch('/api/favorites', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify({ title, url, description, search_type: type })
            });
            
            const data = await response.json();
            
            if (response.ok) {
                button.innerHTML = '<i class="fas fa-check"></i>';
                button.classList.remove('btn-outline-warning');
                button.classList.add('btn-warning');
                button.disabled = true;
                
                this.showToast('즐겨찾기에 추가되었습니다!', 'success');
            } else {
                this.showToast(data.error || '오류가 발생했습니다.', 'error');
            }
        } catch (error) {
            console.error('즐겨찾기 추가 오류:', error);
            this.showToast('네트워크 오류가 발생했습니다.', 'error');
        }
    }
    
    async loadRecentSearches() {
        try {
            const response = await fetch('/api/stats');
            const data = await response.json();
            
            if (data.popular_queries && data.popular_queries.length > 0) {
                const html = data.popular_queries.slice(0, 5).map(item => `
                    <button class="btn btn-sm btn-outline-secondary me-2 mb-2 recent-search" 
                            data-query="${item.query}">
                        ${item.query} <span class="badge bg-secondary">${item.count}</span>
                    </button>
                `).join('');
                
                document.getElementById('recentSearches').innerHTML = html;
                
                // 최근 검색어 클릭 이벤트
                document.querySelectorAll('.recent-search').forEach(button => {
                    button.addEventListener('click', () => {
                        this.searchInput.value = button.dataset.query;
                        this.performSearch();
                    });
                });
            }
        } catch (error) {
            console.error('최근 검색어 로드 오류:', error);
        }
    }
    
    showLoading() {
        this.isLoading = true;
        this.loadingSpinner.style.display = 'block';
        this.searchResults.innerHTML = '';
    }
    
    hideLoading() {
        this.isLoading = false;
        this.loadingSpinner.style.display = 'none';
    }
    
    showError(message) {
        this.searchResults.innerHTML = `
            <div class="alert alert-danger" role="alert">
                <i class="fas fa-exclamation-triangle me-2"></i>${message}
            </div>
        `;
    }
    
    showToast(message, type = 'info') {
        const toastContainer = document.getElementById('toastContainer') || this.createToastContainer();
        
        const toastId = 'toast-' + Date.now();
        const bgClass = type === 'success' ? 'bg-success' : type === 'error' ? 'bg-danger' : 'bg-info';
        
        const toastHtml = `
            <div id="${toastId}" class="toast ${bgClass} text-white" role="alert">
                <div class="toast-body">
                    ${message}
                </div>
            </div>
        `;
        
        toastContainer.insertAdjacentHTML('beforeend', toastHtml);
        
        const toastElement = document.getElementById(toastId);
        const toast = new bootstrap.Toast(toastElement);
        toast.show();
        
        // 토스트가 숨겨진 후 DOM에서 제거
        toastElement.addEventListener('hidden.bs.toast', () => {
            toastElement.remove();
        });
    }
    
    createToastContainer() {
        const container = document.createElement('div');
        container.id = 'toastContainer';
        container.className = 'toast-container position-fixed top-0 end-0 p-3';
        container.style.zIndex = '9999';
        document.body.appendChild(container);
        return container;
    }
    
    formatDate(dateString) {
        if (!dateString) return '';
        
        const date = new Date(dateString);
        const now = new Date();
        const diffMs = now - date;
        const diffMins = Math.floor(diffMs / 60000);
        const diffHours = Math.floor(diffMins / 60);
        const diffDays = Math.floor(diffHours / 24);
        
        if (diffMins < 60) {
            return `${diffMins}분 전`;
        } else if (diffHours < 24) {
            return `${diffHours}시간 전`;
        } else if (diffDays < 7) {
            return `${diffDays}일 전`;
        } else {
            return date.toLocaleDateString('ko-KR');
        }
    }
    
    formatNumber(num) {
        if (num >= 1000000) {
            return (num / 1000000).toFixed(1) + 'M';
        } else if (num >= 1000) {
            return (num / 1000).toFixed(1) + 'K';
        }
        return num.toString();
    }
    
    debounce(func, wait) {
        let timeout;
        return function executedFunction(...args) {
            const later = () => {
                clearTimeout(timeout);
                func(...args);
            };
            clearTimeout(timeout);
            timeout = setTimeout(later, wait);
        };
    }
}

// 앱 초기화
document.addEventListener('DOMContentLoaded', () => {
    new BingSearchApp();
});

배포 및 운영: 실제 서비스 런칭

Heroku 배포 설정

Procfile:

web: gunicorn wsgi:app
release: flask db upgrade

runtime.txt:

python-3.11.6

wsgi.py:

import os
from app import create_app, db
from app.models.search_history import SearchHistory, Favorites

app = create_app(os.getenv('FLASK_CONFIG') or 'default')

@app.shell_context_processor
def make_shell_context():
    return {'db': db, 'SearchHistory': SearchHistory, 'Favorites': Favorites}

if __name__ == "__main__":
    app.run()

환경변수 설정 (.env for production)

# Heroku 환경변수 설정 명령어
heroku config:set FLASK_CONFIG=production
heroku config:set SECRET_KEY=your-super-secret-key-here
heroku config:set BING_SEARCH_API_KEY=your-bing-api-key
heroku config:set DATABASE_URL=postgresql://...

배포 명령어

# Git 초기화 및 Heroku 앱 생성
git init
heroku create your-app-name

# PostgreSQL 데이터베이스 추가
heroku addons:create heroku-postgresql:mini

# 환경변수 설정
heroku config:set FLASK_CONFIG=production
heroku config:set BING_SEARCH_API_KEY=your_api_key

# 배포
git add .
git commit -m "Initial deployment"
git push heroku main

# 데이터베이스 마이그레이션
heroku run flask db upgrade

완성품 및 확장 아이디어

✅ 완성된 기능들

🔍 핵심 검색 기능

  • 웹/이미지/뉴스/비디오 통합 검색
  • 고급 검색 옵션 (필터링, 정렬)
  • 실시간 자동완성
  • 검색 결과 페이징

📱 사용자 경험

  • 반응형 모바일 디자인
  • 직관적인 탭 기반 인터페이스
  • 로딩 스피너 및 에러 처리
  • 즐겨찾기 시스템

📊 데이터 관리

  • 검색 히스토리 저장
  • 인기 검색어 통계
  • 사용량 분석 대시보드
  • 데이터베이스 최적화

🚀 성능 및 배포

  • 캐싱 시스템으로 속도 향상
  • Heroku 원클릭 배포
  • 환경변수 보안 관리
  • 실제 도메인 연결 가능

🎯 추가 확장 아이디어

Level 1: 기본 확장

📧 이메일 알림 시스템
├── 검색 결과 이메일 발송
├── 주간 트렌드 리포트
└── 새로운 결과 알림

🤖 챗봇 연동
├── 자연어 검색 쿼리 해석
├── 검색 결과 요약
└── 추천 검색어 제안

Level 2: 고급 기능

🔍 AI 검색 최적화
├── 머신러닝 기반 결과 랭킹
├── 개인화된 검색 경험
└── 검색 패턴 분석

📱 모바일 앱
├── React Native 앱 개발
├── 오프라인 검색 기록
└── 푸시 알림

Level 3: 비즈니스 확장

💰 수익화 모델
├── 프리미엄 검색 기능
├── API 서비스 제공
└── 광고 시스템 연동

🌐 글로벌 서비스
├── 다국어 지원
├── 지역별 최적화
└── CDN 활용

📝 시리즈 완주 축하합니다!

🎉 우리가 함께 이뤄낸 것들

1편 - 탄탄한 기초

  • Bing Search API 마스터
  • Google과의 완벽한 비교 분석
  • 5분만에 API 키 발급하는 방법

2편 - 고급 기능 완전 정복

  • 이미지/뉴스/비디오/엔티티 검색
  • 성능 최적화 (병렬 처리 + 캐싱)
  • 실전 수준의 데이터 파싱

3편 - 완전한 웹서비스 구축

  • Flask 풀스택 웹 애플리케이션
  • 현대적인 반응형 UI/UX
  • 실제 배포 가능한 완성품

🚀 이제 여러분이 가진 능력들

기술적 역량

  • RESTful API 설계 및 구현
  • 현대적인 웹 개발 (Flask + Bootstrap + JavaScript)
  • 데이터베이스 설계 및 ORM 활용
  • 클라우드 배포 및 운영

🎯 실무 활용 능력

  • 외부 API 연동 및 최적화
  • 사용자 경험 중심의 인터페이스 설계
  • 성능 모니터링 및 개선
  • 확장 가능한 아키텍처 구성

💼 포트폴리오 가치

  • 실제 동작하는 완성된 서비스
  • 최신 기술 스택 활용 사례
  • 사용자 친화적인 디자인
  • 비즈니스 로직 구현 경험

📚 학습 자료 및 레퍼런스

공식 문서:

심화 학습 주제:

  • RESTful API 디자인 패턴
  • 마이크로서비스 아키텍처
  • Redis를 활용한 고급 캐싱
  • Docker 컨테이너화
  • AWS/GCP 클라우드 배포

📝 시리즈 최종 정리

🎯 달성한 목표들

  1. Bing Search API 완전 마스터 - 모든 검색 타입 구현
  2. 실전 웹 애플리케이션 구축 - 포트폴리오급 완성품
  3. 현대적 개발 스택 경험 - Flask + Bootstrap + JavaScript
  4. 클라우드 배포 경험 - Heroku 실제 서비스 런칭
  5. 성능 최적화 구현 - 캐싱, 비동기 처리 등

💡 핵심 학습 포인트

  • API 통합의 모든 것: 인증, 에러 처리, 최적화
  • 풀스택 웹 개발: 백엔드 API부터 프론트엔드 UI까지
  • 사용자 경험 설계: 직관적이고 반응성 좋은 인터페이스
  • 데이터 모델링: 검색 기록, 즐겨찾기 등 실용적 기능
  • 배포와 운영: 개발 환경에서 실제 서비스까지
  •  

🔗 Bing Search API 완전정복 시리즈 (완결):

  • 1편: Bing Search API 시작하기 - 구글보다 나은 무료 API 완전 정복
  • 2편: 이미지/뉴스/비디오 검색까지 완벽 구현
  • 3편: 나만의 검색 사이트 만들기 - Flask 웹앱 완성 프로젝트 ← 완결

GitHub 저장소: MyBingSearch Project
라이브 데모: mybingsearch.herokuapp.com

긴 여정 동안 함께해주셔서 감사합니다! 여러분의 성공적인 개발 여정을 응원하며, 앞으로도 더 깊이 있는 기술 콘텐츠로 찾아뵙겠습니다.

질문이나 개선사항이 있다면 언제든 댓글로 남겨주세요! 🚀


이 시리즈가 도움이 되셨다면 ❤️ 공감과 구독 부탁드립니다. 여러분의 피드백과 성공 사례를 댓글로 공유해주세요!

 

Flask검색사이트, 웹앱개발, 검색사이트제작, 풀스택개발, API웹앱, 검색인터페이스, 웹개발프로젝트, Python웹앱, 검색UI, 반응형검색, 웹앱배포, Heroku배포, 프론트엔드, 백엔드개발, BingAPI프로젝트, 검색포털개발, SQLAlchemy, Bootstrap5, JavaScript검색, AJAX검색, 데이터베이스설계, 사용자경험, 웹서비스개발, 포트폴리오프로젝트, RESTfulAPI, 검색엔진개발, 웹개발완성품, 실전프로젝트, 개발자포트폴리오

728x90
반응형