본문 바로가기
AI 오케스트레이션 캠프/미니프로젝트

1번째 미니프로젝트 1일차|공공임대 청약 통합 안내 서비스 개발과 오류 해결

by 랩보다 AI 더 잘해지기 2026. 8. 10.
728x90

 

팀 미니프로젝트로 여러 기관에 흩어져 있는 공공임대 청약 공고를 한곳에서 확인할 수 있는 서비스를 개발하고 있다.

우리 서비스는 사이트에서 직접 청약을 신청하는 것이 아니라, 사용자가 지역·보증금·월세 등의 조건으로 공고를 검색하고 원문 사이트로 이동할 수 있도록 돕는 청약 통합 안내 서비스다.

프로젝트 1일차에는 공고 등록·조회·수정·삭제부터 이미지 관리, 정렬, 페이지네이션까지 다양한 기능을 개발했다. 특히 기능 구현보다 실제 데이터를 안전하게 다루는 과정에서 많은 것을 배울 수 있었다.


프로젝트 기술 스택

구분사용 기술

백엔드 FastAPI, Pydantic v2
데이터베이스 Supabase PostgreSQL
이미지 저장소 Supabase Storage
관리자 화면 Streamlit
사용자 화면 Streamlit
협업 GitHub Issue → PR → develop 병합

프로젝트는 하나의 FastAPI 백엔드에 관리자용과 사용자용 Streamlit 앱이 각각 연결되는 구조다.

FastAPI 백엔드 (8000)
├── 관리자 화면 (8501) : 공고 등록·수정·삭제
└── 사용자 화면 (8502) : 공고 조회·즐겨찾기

관리자와 일반 사용자가 사용하는 기능이 다르기 때문에 화면을 별도의 앱으로 분리했다.


1일차에 구현한 기능

첫날에는 총 20개의 PR을 작성하고 develop 브랜치에 병합했다.

공고 데이터 구조 개선

  • listings 테이블 구조 개편
  • 서울 25개 자치구 선택 기능 적용
  • 공고 원문 링크인 source_url 연결
  • 상세주소를 저장하는 detail_address 컬럼 추가

다중 이미지 관리

  • Supabase Storage 이미지 업로드
  • 공고 삭제 시 Storage 파일 함께 정리
  • 수정 화면에서 이미지 교체 및 개별 삭제
  • 공고 하나당 사진을 최대 20장까지 등록
  • 목록 카드에는 대표 이미지 한 장만 표시
  • 상세 화면에는 등록된 전체 사진 표시

공고 조회 화면

  • 신청 마감일까지 남은 날짜를 디데이로 표시
  • 마감 임박순 정렬
  • 마감된 공고를 목록 뒤로 이동
  • 마감 공고의 글자를 흐린 회색으로 표시
  • 페이지네이션 적용
  • 제곱미터와 평수 함께 표시
  • 면적·모집인원·보증금·월세 기준 정렬
  • 카드 가독성 개선

면적은 다음과 같이 표시했다.

전용 13.98㎡ (약 4.2평)

관리자 화면

  • 공고 등록 후 기존 입력값 유지
  • 입력 초기화 버튼 추가
  • 금액 입력 시 쉼표와 만·억 단위 표시
  • 수정 화면의 뒤로가기 동작 개선
  • 새로고침 후에도 관리자 로그인 세션 유지

기능 목록만 보면 순조롭게 진행된 것 같지만, 실제로는 데이터가 삭제되거나 화면 상태가 예상대로 작동하지 않는 문제를 여러 번 만났다.


문제 1. 사진 한 장을 지웠는데 열 장이 모두 사라졌다

가장 위험했던 문제는 다중 이미지 삭제 기능에서 발생했다.

사진 10장이 등록된 공고에서 한 장만 삭제했는데, 나머지 사진과 Supabase Storage 파일까지 모두 사라졌다. Storage 파일은 한 번 삭제하면 되돌리기 어렵기 때문에 특히 큰 문제였다.

서버 로그에는 다음과 같이 정상 처리된 것으로 기록됐다.

PUT /admin/listings/245/images HTTP/1.1 200 OK

오류가 아니라 200 OK가 반환됐다는 점이 중요했다. 서버는 잘못 작동한 것이 아니라, 프론트엔드에서 받은 요청을 그대로 처리했기 때문이다.

원인

프론트엔드는 남길 사진 목록을 다음과 같은 튜플 목록으로 전송하고 있었다.

data = [
    ("kept_image_urls", url)
    for url in kept_image_urls
]

하지만 httpx에서 data에 튜플 목록을 직접 넣으면 의도한 폼 데이터로 처리되지 않았다. 그 결과 kept_image_urls가 서버에 전달되지 않았다.

서버는 기본값인 빈 목록을 받고 다음과 같이 판단했다.

남길 사진이 0장이다.
→ 기존 사진을 모두 삭제한다.

테스트가 문제를 발견하지 못한 이유

기존 백엔드 테스트에서는 올바른 사전 형태의 데이터를 직접 넣고 있었다.

client.put(
    "/admin/listings/1/images",
    data={
        "kept_image_urls": [PHOTO_A, PHOTO_C]
    },
)

백엔드만 확인하는 테스트였기 때문에 프론트엔드가 실제로 어떤 형식의 요청을 만드는지는 검사하지 못했다.

즉, 서버 코드는 정상이었지만 서버에 요청을 보내는 화면 코드가 잘못되어 있었다.

해결 방법

먼저 요청 데이터를 올바른 사전 형식으로 변경했다.

data = {
    "kept_image_urls": list(kept_image_urls),
    "keep_count": len(kept_image_urls),
}

여기에 keep_count도 함께 전송하도록 했다. 사용자가 몇 장을 남기려고 했는지 서버가 확인할 수 있게 만든 것이다.

if keep_count != len(kept_image_urls):
    raise HTTPException(
        status_code=400,
        detail="남길 사진 목록이 온전하지 않습니다. 아무것도 지우지 않았습니다.",
    )

keep_count는 기본값 없이 필수 값으로 지정했다. 요청 본문이 사라지면 삭제 작업을 실행하지 않고 422 오류로 중단한다.

잘못된 요청수정 전수정 후

요청 본문 없음 모든 사진 삭제 422 오류, 삭제하지 않음
3장을 남긴다고 했지만 목록이 0장 모든 사진 삭제 400 오류, 삭제하지 않음

프론트엔드가 실제 요청 본문을 정상적으로 만드는지 확인하는 테스트도 추가했다.

이번 경험을 통해 200 OK는 서버가 요청을 처리했다는 뜻이지, 사용자가 원한 결과가 나왔다는 뜻은 아니라는 것을 배웠다.


문제 2. 브라우저 뒤로가기가 작동하지 않았다

관리자 수정 화면에서 브라우저 뒤로가기를 누르면 주소는 변경되지만 화면은 그대로 남아 있었다.

원인은 두 가지였다.

같은 주소도 방문 기록에 계속 추가되는 문제

화면 상태를 st.query_params에 저장하고 있었는데, 화면을 다시 그릴 때마다 같은 값을 반복해서 기록했다.

st.query_params.from_dict(params)

값이 같아도 브라우저 방문 기록이 계속 추가되기 때문에 뒤로가기를 눌러도 동일한 화면으로 돌아오고 있었다.

이를 값이 변경됐을 때만 기록하도록 수정했다.

if st.query_params.to_dict() != params:
    st.query_params.from_dict(params)
    st.rerun()

이렇게 수정하자 같은 화면의 방문 기록이 여러 번 쌓이는 문제가 해결됐다.

Streamlit이 뒤로가기 이벤트에 반응하지 않는 문제

방문 기록을 정리해도 주소만 변경되고 Streamlit 화면은 다시 실행되지 않았다.

브라우저의 뒤로가기 이벤트인 popstate가 발생하면 페이지를 새로고침하도록 JavaScript 코드를 추가했다.

window.addEventListener("popstate", function () {
    window.location.reload();
});

주소와 화면 상태를 함께 사용하려면 브라우저의 동작과 Streamlit의 화면 실행 방식까지 모두 고려해야 한다는 것을 알게 됐다.


문제 3. 입력 초기화 버튼이 값을 지우지 않았다

공고 등록 후 비슷한 공고를 연속으로 입력할 수 있도록 기존 입력값을 유지했다. 별도로 입력 초기화 버튼도 만들었지만, 버튼을 눌러도 화면의 값은 그대로 남아 있었다.

st.session_state에서 키를 삭제하는 것만으로는 해결되지 않았다. st.form 내부의 입력값 일부가 브라우저 쪽에 남아 있었기 때문이다.

입력창 키에 번호를 붙여 초기화할 때마다 새로운 입력창을 만드는 방식으로 해결했다.

def field_key(name: str, nonce: int) -> str:
    return f"create-{name}-{nonce}"

초기화 버튼을 누르면 nonce 값을 1씩 증가시킨다.

create-title-1
→ 초기화
create-title-2

Streamlit은 키가 달라진 입력창을 새로운 위젯으로 판단하기 때문에 빈 입력칸을 다시 만들 수 있었다.


문제 4. 전체 정렬을 화면에서 하면 안 되는 이유

조회 화면에 면적·모집인원·보증금·월세 기준의 정렬 기능을 추가했다.

처음에는 프론트엔드에서 받아 온 공고 목록을 다시 정렬하려고 했다. 하지만 화면은 전체 공고가 아니라 한 페이지에 표시할 10건만 받아 온다.

따라서 화면에서 정렬하면 전체 112건을 정렬하는 것이 아니라 현재 페이지의 10건만 정렬하게 된다.

잘못된 처리 순서
페이지 나누기 → 현재 페이지 10건 정렬

올바른 처리 순서
전체 데이터 정렬 → 페이지 나누기

전체 기준으로 정렬하기 위해 정렬 기능을 백엔드로 옮겼다. 프론트엔드는 사용자가 선택한 정렬 기준만 서버에 전달한다.

SORT_COLUMNS = {
    "area_desc": ("area_sqm", True),
    "area_asc": ("area_sqm", False),
    "deposit_desc": ("deposit", True),
}

같은 값을 가진 공고의 순서가 계속 바뀌지 않도록 id를 보조 정렬 기준으로 추가했다.

return (
    query
    .order(column, desc=desc, nullsfirst=False)
    .order("id", desc=True)
)
  • nullsfirst=False: 값이 없는 공고는 뒤로 이동
  • id desc: 값이 같다면 최근에 등록한 공고를 먼저 표시

실제 공고 112건으로 확인한 결과 페이지가 바뀌어도 정렬 순서가 자연스럽게 이어졌다.

1페이지: 59.53㎡ → 56.96㎡
2페이지: 56.55㎡ → 52.29㎡
3페이지: 51.36㎡ → 47.02㎡

보조 정렬 기준이 없으면 같은 값을 가진 데이터의 순서가 요청할 때마다 달라져 페이지 사이에 중복이나 누락이 생길 수 있다는 점도 배웠다.


문제 5. 상세설명의 줄바꿈이 사라졌다

상세설명을 여러 줄로 저장했지만 화면에서는 한 줄로 이어져 표시됐다.

신청자격 : 서울 거주 1인 무주택세대구성원
소득기준 : 도시근로자 월평균 소득 70% 이하
자산기준 : 총자산 3억 4,500만 원 이하

마크다운에서는 일반적인 줄바꿈 하나를 공백처럼 처리할 수 있기 때문이다.

상세설명을 한 줄씩 나누어 표시하고, 항목 이름은 자동으로 굵게 변환했다.

def format_description_line(line: object) -> str:
    text = ("" if line is None else str(line)).strip()

    if not text:
        return ""

    for separator in (" : ", ": ", " :", ":"):
        if separator in text:
            label, _, value = text.partition(separator)
            label = label.strip()
            value = value.strip()

            if label and value:
                return f"**{label}** : {value}"

    return text

그 결과 상세설명이 다음과 같이 보기 쉽게 표시됐다.

신청자격 : 서울 거주 1인 무주택세대구성원
소득기준 : 도시근로자 월평균 소득 70% 이하
자산기준 : 총자산 3억 4,500만 원 이하

사진 여러 장을 별도 테이블로 관리한 이유

기존 listings.image_url에는 이미지 주소 하나만 저장할 수 있었다. 하지만 실제 청약 공고에는 약도, 건물 외관, 방, 부엌, 화장실 등 10장 이상의 사진이 포함돼 있었다.

두 가지 저장 방식을 비교했다.

구분별도 테이블배열 컬럼

저장 방식 사진 한 장을 한 행으로 저장 한 컬럼에 여러 URL 저장
사진 한 장 삭제 해당 행만 삭제 배열 전체를 다시 저장
사진 순서 변경 sort_order 수정 배열 전체를 다시 저장
기존 테이블 변경 최소화 가능 listings 스키마 변경 필요

최종적으로 사진 전용 listing_images 테이블을 만들었다.

CREATE TABLE IF NOT EXISTS listing_images (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    listing_id BIGINT NOT NULL
        REFERENCES listings(id) ON DELETE CASCADE,
    image_url TEXT NOT NULL,
    sort_order INTEGER NOT NULL DEFAULT 0,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    CONSTRAINT uq_listing_images_order
        UNIQUE (listing_id, sort_order)
);

기존 listings.image_url은 삭제하지 않고 목록 카드에서 사용하는 대표 이미지로 유지했다. 덕분에 기존 화면과 테스트를 크게 변경하지 않고 다중 이미지 기능을 추가할 수 있었다.

또한 팀원이 같은 시기에 listings 테이블에 좌표 컬럼을 추가하고 있었기 때문에, 별도 테이블을 사용함으로써 Git 충돌도 피할 수 있었다.


DB와 Storage는 별도로 관리해야 한다

ON DELETE CASCADE를 설정하면 공고가 삭제될 때 listing_images의 DB 행도 함께 삭제된다.

하지만 Supabase Storage에 저장된 실제 이미지 파일은 자동으로 삭제되지 않는다.

따라서 공고를 삭제하기 전에 이미지 URL 목록을 먼저 가져와야 한다.

image_urls = list(current_listing.images)

deleted_listing = listing_delete(listing_id)

for image_url in image_urls:
    delete_listing_image(image_url)

DB 행을 먼저 삭제하면 어떤 Storage 파일을 삭제해야 하는지 알 수 없기 때문이다.

반대로 Storage 파일을 삭제할 때는 다른 공고가 같은 이미지를 사용하고 있지 않은지도 확인해야 한다.

이미지 참조는 두 곳에 존재한다.

listings.image_url
└── 목록 카드의 대표 이미지

listing_images.image_url
└── 공고에 등록된 전체 이미지

이미지가 사용 중인지 확인하다가 오류가 발생하면 삭제하지 않는 방향으로 처리했다.

사용되지 않는 파일이 잠시 남는 것보다, 사용 중인 파일을 잘못 지워 복구하지 못하는 상황이 더 위험하기 때문이다.


1일차 개발 결과

항목결과

병합한 PR 20개
백엔드 테스트 140개 통과
관리자 화면 테스트 154개 통과
사용자 화면 테스트 154개 통과
등록된 실제 공고 112건
공고별 최대 이미지 20장

많은 기능을 구현했지만, 가장 크게 배운 부분은 기능의 개수보다 데이터를 안전하게 다루는 방법이었다.


프로젝트를 통해 배운 점

첫째, 200 OK가 반드시 사용자가 원한 결과를 의미하는 것은 아니다. 서버가 받은 요청을 정상적으로 처리했다는 뜻일 뿐이므로, 프론트엔드가 어떤 요청을 보냈는지도 확인해야 한다.

둘째, 테스트가 모두 통과해도 안전하다고 단정할 수 없다. 서버만 테스트하고 화면에서 만들어지는 실제 요청을 검사하지 않으면 중요한 오류가 남을 수 있다.

셋째, 이미지 삭제처럼 되돌리기 어려운 작업은 요청 데이터가 온전한지 먼저 검증해야 한다. 조금이라도 이상하면 삭제를 중단하는 편이 안전하다.

넷째, 데이터베이스와 파일 저장소는 별도로 관리된다. DB 행을 삭제해도 Storage 파일은 남을 수 있기 때문에 두 곳을 모두 정리해야 한다.

마지막으로 팀 프로젝트에서는 코드를 작성하기 전에 팀원이 어떤 파일과 테이블을 수정하고 있는지 먼저 확인해야 한다. 설계를 조금 바꾸는 것만으로도 불필요한 Git 충돌을 피할 수 있었다.


다음 작업 계획

다음 단계에서는 아래 기능을 구현할 예정이다.

  • Gemini API를 이용한 청약 공고 자동 입력
  • 공고 URL을 입력하면 주요 항목을 자동으로 추출하는 기능
  • 관리자 화면의 디데이 표시와 마감순 정렬
  • 등록된 사진의 순서를 변경하는 기능

첫날부터 예상하지 못한 오류를 많이 만났지만, 단순히 기능을 만드는 것을 넘어 실제 서비스에서 데이터를 안전하게 관리하는 방법을 경험할 수 있었다.

#FastAPI #Streamlit #Supabase #PostgreSQL #공공임대청약 #미니프로젝트 #다중이미지업로드 #페이지네이션 #GitHub협업 #AI오케스트레이션

댓글