[250903수2078H] 영화 리뷰 AI 분석 웹앱 구현 (3)

윤승호·2025년 9월 3일

데이터베이스를 기반으로 한 웹앱을 만들어 보니 간단한 기능 하나에도 얼마나 많은 코드가 필요한지 알게 되었다. 백엔드에서 API를 만든다고 곧장 통신이 되는 게 아니라는 것도 알게 되었다. 연결이 잘 안 된다는 것이 바로 이런 뜻이었구나. 어렵지만 신기하고 재밌다.

학습시간 09:00~03:00(당일18H/누적2078H)


◆ 학습내용

영화 리뷰 AI 분석 웹앱 구현하기!

어제에 이어 5번 부터 시작!


5. 백엔드(리뷰화면)

어제 메인 화면에 영화 목록 만드는 것까지 했다.

오늘은 리뷰 화면을 완성시킬 차례다.

보기를 눌러서 리뷰 화면에 들어가 보자.

리뷰화면에 들어왔다.

앗 근데 가만 보니까 뭔가 허전한 것 같다.

메인 화면에 있었던 영화 포스터도 딱 나오고 DB에 넣었던 감독, 출시일, 장르 같은 것도 함께 나오면 좋지 않을까?

# 특정 영화 정보 조회
@app.get("/movies/{movie_id}", response_model=Movie)
def read_movie(movie_id: int, session: Session = Depends(get_session)):
    movie = session.get(Movie, movie_id)
    if not movie:
        raise HTTPException(status_code=404, detail="Movie not found")
    return movie

리뷰 보기 페이지에서 해당 영화 정보만 조회하는 API를 추가했다.

def get_movie_details(movie_id):
    try:
        res = requests.get(f"{BACKEND_URL}/movies/{movie_id}")
        if res.status_code == 200:
            return res.json()
        else:
            st.error("영화 상세 정보 로딩 실패")
            return None
    except requests.ConnectionError:
        st.error("서버에 연결할 수 없습니다.")
        return None

방금 백엔드에서 만든 API를 프론트엔드에서 호출할 수 있도록 함수를 추가했다.

    if movie_details:
        col1, col2 = st.columns([2, 4])
        
        with col1: # 왼쪽 컬럼
            st.image(movie_details['poster_url'])

        with col2: # 오른쪽 컬럼
            st.markdown(f"### {movie_details['title']}")
            rating = get_movie_rating(movie_id)
            st.markdown(f"AI 분석 평점: ⭐{rating:.2f}")
            st.divider()
            st.markdown(f"감독: {movie_details['director']}")
            st.markdown(f"개봉일: {movie_details['release_date']}")
            st.markdown(f"장르: {movie_details['genre']}")
            
    st.divider()

이렇게 가져온 DB 내용을 화면에 띄우기 위해 리뷰 화면을 다루는 코드 최상단에 코드를 일부 추가했다.

상단에 포스터와 영화 정보가 나오면 성공이다!

오!! 이정도면 머릿속으로 구상한 것과 아주 흡사하게 나왔다.

헉 그러고 보니 영화 정보를 수정하는 버튼이 없다. API만 만들어 두고 방치하고 있었군...

수정 기능을 만들어 보자!!

def update_movie(movie_id, title, release_date, director, genre, poster_url):
    update_data = {
        "title": title,
        "release_date": str(release_date),
        "director": director,
        "genre": genre,
        "poster_url": poster_url
    }
    try:
        # FastAPI의 update_movie API(/movies/{movie_id})에 PATCH 요청을 보냄
        res = requests.patch(f"{BACKEND_URL}/movies/{movie_id}", json=update_data)
        return res
    except requests.ConnectionError:
        st.error("서버에 연결할 수 없습니다.")
        return None

먼저 프론트엔드에 update_movie 라는 함수를 만들어서 백엔드의 update_movie API를 호출한다.

    col1_btn, col2_btn, _ = st.columns([1, 1, 8])
    with col1_btn:
        if st.button("뒤로가기"):
            st.session_state.view = 'main'
            st.session_state.editing = False
            st.rerun()

    with col2_btn:
        if st.button("수정하기"):
            st.session_state.editing = not st.session_state.editing
            st.rerun()

뒤로가기 버튼 옆에 수정하기 버튼을 만들어 주고,

    movie_details = get_movie_details(movie_id)

    if movie_details:
        if st.session_state.editing:
            st.subheader("영화 정보 수정")
            
            with st.form("edit_movie_form"):
                # 기존 영화 정보를 기본값으로 채워넣음
                title = st.text_input("제목", value=movie_details['title'])
                release_date = st.date_input("개봉일", value=datetime.strptime(movie_details['release_date'], '%Y-%m-%d').date())
                director = st.text_input("감독", value=movie_details['director'])
                genre = st.text_input("장르", value=movie_details['genre'])
                poster_url = st.text_input("포스터 URL", value=movie_details['poster_url'])
                    
                    if res and res.status_code == 200:
                        st.toast("수정 완료")
                        st.session_state.editing = False
                        st.rerun()
                    else:
                        st.error("수정 실패")

수정하기 버튼을 눌렀을 때 작동할 로직을 만들어 준다.

수정 버튼을 누르면 DB에 존재하는 기존 정보를 호출받아서 출력한다.

그리고 완료 버튼을 누르면 방금 추가한 update_movie 함수를 호출해서 백엔드 API를 통해 DB에 저장할 수 있도록 업데이트 내역을 보낸다.

잘 돌아가나 한번 보자!

일단 수정버튼은 있다.

버튼을 눌렀을 때 기존 정보도 잘 가져온다.

제목을 조금 수정해 볼까??

헉 실패가 떴다.

sqlalchemy.exc.ProgrammingError: (sqlite3.ProgrammingError) Error binding parameter 2: type 'HttpUrl' is not supported

백엔드 터미널을 보니까 이런 로그가 있다.

음,,, HttpUrl 타입을 지원하지 않는다고 한다.

이거 왠지 어제 봤던 ValueError랑 비슷해 보인다.

# 특정 영화 정보를 수정하는 API
@app.patch("/movies/{movie_id}", response_model=Movie)
def update_movie(movie_id: int, movie_update: MovieUpdate, session: Session = Depends(get_session)):
    # DB에서 수정할 영화 데이터를 가져옴
    db_movie = session.get(Movie, movie_id)
    if not db_movie:
        raise HTTPException(status_code=404, detail="Movie not found")

    update_data = movie_update.model_dump(mode='json', exclude_unset=True)

백엔드의 update_movie API 내부에 model_dump 라는 함수가 있는데, 어제 했던 것처럼 mode='json' 코드를 추가했다. 어제와 같은 에러라면 이걸로 해결되지 않을까??

헉 ㅠ 실패다. 이게 문제가 아니었나??

TypeError: SQLite Date type only accepts Python date objects as input.

근데 이번엔 다른 에러가 떴다. Date type에 관한 에러다.

SQLite은 파이썬 형식의 Date type을 아주 사랑한다고 한다. 깐깐하긴!

        if key == "poster_url" and value is not None:
            setattr(db_movie, key, str(value))
        else:
            setattr(db_movie, key, value)

아까 추가했던 mode='json' 코드를 삭제 후 poster_url만 str로 변경해서 넘기도록 코드를 추가했다.

이번엔 되려나????

fastapi.exceptions.ResponseValidationError

헉 ㅠ 이번엔 ValidationError가 떴다. 이건 또 첨 보네...

오늘도 멘붕이다.

...

몇 시간 찾아보다가 뭔가를 발견했다.

FastAPI에서 변경 성공 응답을 보냈으나 응답데이터를 제대로 수신하지 못할 때 저 에러가 뜬다고 한다.

엥?? 변경에 성공했다고????

헐 정말이다. 새로고침을 해보니 에러는 그대로 있지만 제목은 '(수정본)' 텍스트를 추가한 게 반영되었다.

도대체 어떻게 된 거냐고....

일단 수정은 되니까 에러만 안 뜨게 고치면 될 것 같은데!

# 특정 영화 정보를 수정하는 API
@app.patch("/movies/{movie_id}", response_model=Movie)
def update_movie(movie_id: int, movie_update: MovieUpdate, session: Session = Depends(get_session)):
    db_movie = session.get(Movie, movie_id)
    if not db_movie:
        raise HTTPException(status_code=404, detail="Movie not found")
    
    update_data = movie_update.model_dump(exclude_unset=True)
    
    for key, value in update_data.items():
        if key == "poster_url" and value is not None:
            setattr(db_movie, key, str(value))
        else:
            setattr(db_movie, key, value)
            
    session.add(db_movie)
    session.commit()

    return Movie.model_validate(db_movie)

에러가 뜨지 않도록 수정 API를 이것저것 만지다가 리턴값을 Movie.model_validate(db_movie)로 주면 된다는 것을 알았다.

model_validate는 DB에 연결된 실시간 변하는 객체(db_moive)를 딱 데이터만 담긴 파이썬 객체로 변환해 주는 기능이라고 한다. DB 세션이 끝난 데이터를 읽으려다 발생하는 에러를 막아준다고...!

다시 원래 제목으로 수정해 보자!!

기존에 '(수정본)' 텍스트를 삭제 후 저장한다.

오!! 에러없이도 잘 수정된다.

흑흑 ㅠㅠ 힘들었다...

산 넘어 산이다. 이번엔 리뷰 작성하는 부분을 손볼 차례다.

이것도 문제가 많을 듯한 느낌이 드는 건 왜일까.

개발의 호흡 제 1형!!!!!!!!!!!!

엇? 생각 외로 한방에 잘 들어갔다.

삭제도 잘 된다.

^^ 머쓱,,,

이번엔 리뷰를 수정하는 기능을 만들어야겠다.

class ReviewUpdate(SQLModel):
    author: Optional[str] = None
    review_text: Optional[str] = None

먼저 SQLModel을 하나 추가했다.

이 모델은 작성자와 리뷰내용을 옵션으로 받는다. 둘 중 하나만 변경도 가능하도록!

이번엔 에러 없이 한방에 되면 좋겠다...!

@app.patch("/reviews/{review_id}", response_model=Review)
def update_review(review_id: int, review_update: ReviewUpdate, session: Session = Depends(get_session)):
    db_review = session.get(Review, review_id)
    if not db_review:
        raise HTTPException(status_code=404, detail="Review not found")
        
    # 받은 데이터만 dict 형태로 가져옴
    update_data = review_update.model_dump(exclude_unset=True)
    
    # 받은 필드들의 값을 업데이트
    for key, value in update_data.items():
        setattr(db_review, key, value)

백엔드에 update_review 함수를 새로 만들었다. 이것도 일단 해보고 에러 뜨면 mode json으로 변경해야지.

    # 리뷰 수정 시 감성 점수 다시 계산
    if "review_text" in update_data:
        result = sentiment_pipeline(db_review.review_text)[0]
        score = result['score']
        sentiment_score = score if result['label'] == 'LABEL_1' else 1 - score
        db_review.sentiment_score = sentiment_score

    session.add(db_review)
    session.commit()
    session.refresh(db_review)
    
    return Review.model_validate(db_review)

리뷰 내용을 수정하면 감성 점수도 다시 계산하도록 했다.

def update_review(review_id, author, review_text):
    review_data = {"author": author, "review_text": review_text}
    try:
        res = requests.patch(f"{BACKEND_URL}/reviews/{review_id}", json=review_data)
        return res
    except requests.ConnectionError:
        st.error("서버에 연결할 수 없습니다.")
        return None

프론트엔드에 API를 전달받는 함수를 추가했다.

def render_review_page():
    movie_id = st.session_state.selected_movie_id

    # 영화 수정 세션
    if 'editing' not in st.session_state:
        st.session_state.editing = False

    # 리뷰 수정 세션
    if 'editing_review_id' not in st.session_state:
        st.session_state.editing_review_id = None

리뷰 수정 버튼을 만들기 전에 수정을 위한 세션을 추가하고,

    for review in reviews:
    (중략)
    ...
                form_col1, form_col2, _ = st.columns([1, 1, 8])
                with form_col1:
                    if st.form_submit_button("저장", use_container_width=True, type="primary"):
                        res = update_review(review['id'], new_author, new_review_text)
                        if res and res.status_code == 200:
                            st.toast("리뷰 수정 완료")
                        else:
                            st.toast("리뷰 수정 실패")
                        st.session_state.editing_review_id = None # 수정 모드 종료
                        st.rerun()
                
                with form_col2:
                    if st.form_submit_button("취소", use_container_width=True):
                        st.session_state.editing_review_id = None # 수정 모드 종료
                        st.rerun()

저장과 취소 기능을 하는 버튼을 만들었다.

계속 하다 보니까 streamlit 버튼 사이즈는 1, 1, 8 비율로 하는 게 무난히 깔끔한 것 같다.

다시 테스트 해보자!!

리뷰를 하나 달았다.

수정 버튼이 잘 보인다.

수정버튼도 잘 눌려지고,

수정도 잘 된다! 휴...!!

근데 평점 기능에 뭔가 문제가 있어 보인다. 최고로 재밌다는 리뷰를 달았는데 평점이 0.65라니 이상하다.

이건 내일 해야겠다... 오늘도 삽질하느라 고생한 나에게 칭찬을...ㅠㅠ

profile
나는 AI 엔지니어가 된다.

0개의 댓글