데이터베이스를 기반으로 한 웹앱을 만들어 보니 간단한 기능 하나에도 얼마나 많은 코드가 필요한지 알게 되었다. 백엔드에서 API를 만든다고 곧장 통신이 되는 게 아니라는 것도 알게 되었다. 연결이 잘 안 된다는 것이 바로 이런 뜻이었구나. 어렵지만 신기하고 재밌다.
학습시간 09:00~03:00(당일18H/누적2078H)
영화 리뷰 AI 분석 웹앱 구현하기!
어제에 이어 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라니 이상하다.
이건 내일 해야겠다... 오늘도 삽질하느라 고생한 나에게 칭찬을...ㅠㅠ