[SB 3기] 코드잇 고급 프로젝트 회고 : 옷장을 부탁해

JHLee·2025년 11월 20일
post-thumbnail

코드잇 고급 프로젝트 회고 : 옷장을 부탁해

고급 프로젝트 종료 후 약 한 달이 지났다. (시간이 너무 빨라요⏰)
이제 더는 미룰 수 없다는 생각으로 회고를 작성해보려 한다..!
기간이 제일 길었던 만큼 새로운 기술 적용도 많이 하고 열정적으로 임했던 프로젝트였다.


🗂️ 프로젝트 개요

옷장을 부탁해(Otboo)
실시간 날씨와 사용자의 옷장을 기반으로 오늘 입을 옷을 추천해주는 개인화 코디 추천 서비스입니다.
사용자가 보유한 의상을 체계적으로 관리하고, OOTD 피드·팔로우·DM 등 소셜 기능을 통해 코디를 공유할 수 있습니다.

✅ 주요 기능

  • 사용자 · 인증 시스템
    • 회원가입/로그인, OAuth2 기반 소셜 로그인(Google, Kakao)
    • 프로필 정보 관리
  • 날씨 · 위치 연동
    • 기상청 API를 활용한 실시간/예보 날씨 데이터 수집 및 저장
    • Kakao API 기반 위치 검색
  • 옷장 관리 시스템
    • 의상 등록/수정/삭제, 속성 관리
    • 외부 쇼핑몰 구매 링크 연동 기능
  • 의상 추천 시스템
    • 날씨, 사용자 프로필, 보유 의상을 조합한 코디 추천 로직 설계
    • LLM 기반 추천 이유 생성
    • 추천 점수 기반 필터링 + 랜덤 폴백 전략으로 최소 추천 세트 보장
    • Redis를 활용해 최근 추천 아이템을 TTL 동안 제외하여 중복 추천 방지
  • 피드 시스템
    • 날씨·착장 정보를 포함한 OOTD 피드 등록/수정/삭제
    • 댓글 등록/목록 조회, 좋아요/취소
    • 피드 검색 및 정렬
  • 실시간 기능
    • WebSocket 기반 실시간 DM 기능
    • 팔로우/댓글/좋아요 등 이벤트 발생 시 실시간 알림 발송 및 목록 조회
  • 파일 업로드 · 인프라
    • AWS S3를 통한 피드/프로필 이미지 업로드 및 스토리지 관리
    • Docker 기반 로컬 개발 환경 구성, AWS ECS를 통한 컨테이너 배포
  • 팔로우 시스템
    • 사용자 간 팔로우/언팔로우 및 팔로잉 피드 조회

📌 기술 요구 사항

  • 유효성 검사
  • 커스텀 예외 처리
  • 로그 관리
  • 테스트 주도 개발 (TDD)
  • 테스트 커버리지 80% 이상
  • CI/CD 파이프라인 구축 (GitHub Actions)
  • Spring Batch를 통한 배치 작업 관리
  • 분산 환경 구성
  • Elasticsearch를 활용하여 피드 검색 기능 개선

👩‍💻 나의 역할

1️⃣ 기능 개발

  • OOTD 피드 도메인
    • 피드 등록, 수정, 논리 삭제 기능 구현
    • 정렬 및 커서 기반 페이지네이션 피드 목록 조회 기능 구현 (RDB, Elasticsearch)
    • 댓글 등록, 목록 조회 기능 구현
    • 피드 좋아요/취소 기능 구현
  • 의상 추천
    • 날씨 데이터, 사용자 보유 의상, 프로필 정보를 활용한 자체 추천 알고리즘 개발
    • LLM 기반 후처리 레이어 설계/적용(프롬프트 설계 포함)로 추천 품질 고도화
    • 추천 임계값 미달 시 랜덤 폴백 로직 추가로 무추천 케이스 최소화
  • OAuth2 소셜 계정 연동
    • Google 계정 연동 및 인증 구현
    • Kakao 계정 연동 및 인증 구현
  • DM 대화방 목록 조회 API 설계 구현

2️⃣ 개발 외 역할

  • 프로젝트 UI 개선
    • 로그인 화면 일러스트/레이아웃 수정
    • 로고 리디자인
    • 전반적인 색상 통일
    • DM 탭 분리 및 DM 대화방 목록 화면 추가
  • PM 역할
    • 일정 관리 및 작업 재분배

🔗 깃허브 링크


📚 개발 환경 및 사용 스택

⚙️ Backend Stack
📦 Framework
├── Spring Boot 3.5.5        # 메인 애플리케이션 프레임워크
├── Spring Data JPA          # ORM 및 데이터 접근
├── QueryDSL          	     # 동적 쿼리 작성
├── Spring Batch             # 대용량 배치 처리 및 스케줄링
├── Spring Security          # 인증·인가 및 보안 설정
├── OAuth2 Client            # 소셜 로그인(OAuth2) 연동
├── Spring WebSocket         # 실시간 양방향 통신
├── Spring WebFlux           # 비동기 HTTP 클라이언트
├── Spring Mail              # 이메일 발송
└── Gradle                   # 빌드 및 의존성 관리 도구

🗄️ Database & Cache
├── PostgreSQL              # 운영 환경 RDBMS
├── H2 Database             # 개발/테스트용 In-memory DB
└── Redis                   # 캐싱 관리

🔎 Search & AI
├── Elasticsearch             # 검색 엔진
└── Spring AI 1.0.3           # OpenAI 연동

📧 Messaging
├── Apache Kafka            # 도메인 이벤트 스트리밍
└── Spring Kafka            # Kafka 퍼블리셔·컨슈머 구현

💿 Storage & External APIs
├── AWS S3 2.31.7           # 파일 스토리지
└── Jsoup 1.18.1            # HTML 파싱

📚 Documentation
├── Swagger/OpenAPI 3.0     # API 문서 자동화
└── Notion                  # 프로젝트 문서 및 협업 기록
└── Github Projects         # 일정 및 이슈 관리

🔧 Development Tools
├── IntelliJ IDEA           # 통합 개발 환경(IDE)
├── Git & GitHub            # 버전 관리 및 협업
├── Discord                 # 팀 커뮤니케이션
└── Postman                 # API 테스트 도구

🚀 Infrastructure & Deployment
├── AWS                     # 클라우드 인프라
└── Docker                  # 컨테이너 기반 실행·배포

🛠️ 트러블슈팅 사례

1) 피드–날씨 연관관계 설계 오류

  • 현상

    • 피드 목록 조회 시 weather_idnull인 레코드가 들어오면서, 프론트에서 렌더링이 실패했다.
    • weatherId가 null로 설정될 경우 서비스단에서 날씨 정보를 찾을 수 없다는 404에러가 발생했다.
  • 원인

    • 초기 스키마에서 weather_id에 NOT NULL 제약을 걸지 않았다.
    • 외래키 제약을 ON DELETE SET NULL로 설정해, 날씨 데이터가 삭제되면 피드의 weather_idnull로 바뀌도록 설계했다.
    • 서비스 단에서는 항상 유효한 날씨 정보가 있다고 가정하고 조회했다.
  • 해결

    • weather_id 컬럼에 NOT NULL 제약을 추가했다.
    • 외래키 제약 조건을 ON DELETE RESTRICT로 변경해, 참조 중인 날씨 데이터는 삭제되지 않도록 보호했다.
  • 배운 점
    조회에 필수적인 연관관계는 애플리케이션이 아니라 DB 스키마에서 강하게 보장하는 것이 안전하다.
    특히 ON DELETE SET NULL은 편리해 보이지만, 실제 비즈니스 요구사항과 맞지 않으면 오히려 장애의 원인이 될 수 있다.

2) 피드 목록 조회 - 카운트 갱신 시 updatedAt 미갱신

  • 현상

    • 좋아요/댓글 카운트만 증가시키는 업데이트 후에도 updated_at이 갱신되지 않았다.
  • 원인

    • 카운트 갱신에 JPQL 벌크 UPDATE를 사용했다.
    • 벌크 UPDATE는 영속성 컨텍스트를 거치지 않기 때문에, JPA Auditing(@LastModifiedDate)이 적용되지 않는다.
  • 해결

    • 쿼리를 네이티브 UPDATE로 전환했다.
    • updated_at = CURRENT_TIMESTAMP를 쿼리에서 명시적으로 업데이트하도록 변경했다.
  • 배운 점
    대량 업데이트가 필요해 벌크 UPDATE를 사용할 때는,
    Auditing/엔티티 리스너가 동작하지 않는다는 점을 항상 염두에 두고 쿼리에서 필드를 갱신해야 한다.

3) CommentRepositoryTest 간헐적 실패 - 시간 의존 테스트

  • 현상

    • 댓글 정렬/조회 관련 테스트가 로컬에서는 통과하지만, CI 환경이나 특정 시점에 간헐적으로 실패했다.
  • 원인

    • 테스트 데이터의 시간 값을 Instant.now()로 생성했다.
    • 실행 타이밍에 따라 경계 조건(예: 동일 시각, 정렬 순서)이 달라지는 비결정적 테스트가 되었다.
  • 해결

    • 테스트에서 사용하는 시간을 Instant.parse("2025-01-01T00:00:00Z")처럼 고정 값으로 치환하여 결정적 테스트를 보장했다.
  • 배운 점
    테스트는 항상 결정적(deterministic)이어야 한다.
    현재 시간, 랜덤 값에 직접 의존하는 코드는 설계 단계에서부터 테스트 가능성을 함께 고려해야 한다.

4) 배포 환경에서 피드 조회 실패 – ES LocalDateTime 컨버전 문제

  • 현상

    • 운영 환경에서만 피드 조회 API가 다음 에러와 함께 500을 반환했다.

      ConversionException: Unable to convert value '1760968800000' to java.time.LocalDateTime for property 'forecastedAt'
  • 원인

    • Elasticsearch 문서의 forecastedAt 필드 타입은 epoch_millis(Long)이었다.
    • Spring Data Elasticsearch가 이를 도메인 객체의 LocalDateTime로 역직렬화할 때, 숫자 → LocalDateTime 변환 컨버터가 등록되어 있지 않았다.
    • DTO에 @JsonFormat으로 문자열 포맷을 강제하면서, 타입 변환 경로가 더 복잡해졌다.
  • 해결

    • DTO에서 @JsonFormat을 제거해 문자열 포맷 강제를 해제했다.
    • Long ↔ LocalDateTime 변환용 Converter Bean을 정의하고,
      특정 프로파일에만 적용되던 @Profile을 제거해 모든 프로파일에서 공통으로 등록되도록 했다.
  • 배운 점
    검색 인프라(ES)의 타입과 애플리케이션 도메인 타입(LocalDateTime 등) 사이의 매핑은 명시적으로 설계해야 한다.
    특히 테스트/운영 프로파일에 따라 Bean 구성이 달라지지 않도록 관리하는 것이 중요하다.

5) 피드 삭제 후에도 검색 결과에 노출되던 문제

  • 현상

    • 피드를 삭제(soft delete)했는데도, ES 기반 피드 검색 결과에 계속 노출되었다.
  • 원인

    • 색인 시 ES 문서 _id를 DB feed_id가 아닌 자동 생성 ID로 사용했다.
      delete(id) 호출 시 다른 문서를 바라보게 됨.
    • soft delete를 도입했지만 ES 검색 쿼리에 is_deleted = false 필터를 까먹고 넣지 않았다.
  • 해결

    • 색인 시 ES 문서의 _id를 DB feed_id와 동일한 값으로 고정했다.
    • soft delete 시 DB뿐 아니라 ES 문서에도 is_deleted = true로 반영했다.
    • ES 검색 쿼리의 기본 조건에 is_deleted = false 필터를 강제했다.
  • 배운 점
    RDB와 검색 인덱스를 함께 사용할 때는 ID 전략(PK와 _id)과 삭제 전략(soft/hard delete, 조회 필터)을 처음부터 일관성 있게 설계해야 한다.

6) Nori 플러그인/부트스트랩 타이밍 문제

  • 현상

    • 초기 기동 시 한글 분석기 부재로 인덱스 생성/매핑이 실패하면서, 최초 기동 시 에러가 발생했다.
  • 원인

    • Elasticsearch 컨테이너 기동 이후에 analysis-nori 플러그인을 설치하려 했다.
    • 플러그인 설치 실패 시에도 컨테이너가 계속 떠 있어 문제를 조기에 인지하지 못했다.
  • 해결

    • 컨테이너 빌드 단계에서 Nori 플러그인을 선 설치하도록 Dockerfile/스크립트를 수정했다.
    • 플러그인 설치가 실패하면 빌드 자체를 실패시키도록 해, 문제를 빠르게 감지할 수 있게 했다.
  • 배운 점
    인프라 의존성이 있는 플러그인은 빌드 타임에 강제하는 것이 전체 시스템 안정성에 유리하다.

7) 의상 추천 엔진 품질 개선

7-1) Softmax 샘플링 품질 이슈

  • 현상

    • 점수가 비슷한 후보들이 많거나, 특정 후보만 극단적으로 높을 때 확률이 한 후보로 지나치게 쏠리거나 다양성이 저하되었다.
  • 해결

    • 동점 구간은 균등 샘플링으로 처리해 불필요한 연산을 줄이고 공정성을 확보했다.
    • 극단 분포가 감지될 때 로그를 남기도록 해, 품질 이슈를 조기에 파악할 수 있게 했다.

7-2) 최근 추천 아이템 반복 노출

  • 현상

    • 같은 사용자가 짧은 시간 안에 추천을 다시 받을 경우, 동일한 조합이 반복 노출됐다.
  • 해결

    • Redis에 카테고리별 최근 추천 아이템 목록을 TTL(30분)로 저장했다.
    • 추천 후보군 생성 시, 해당 목록에 포함된 아이템을 우선 제외해 중복 노출을 줄였다.

7-3) 추천 임계값 미달 시 빈 리스트 반환

  • 현상

    • 사용자의 옷은 존재하지만, 점수 임계값을 넘는 후보가 하나도 없으면 빈 리스트를 반환했다.
      → 사용자 입장에서는 “추천이 없는 서비스”처럼 보이는 UX 문제.
  • 해결

    • 점수 기반 추천 후보가 없을 경우, 랜덤 추천 fallback을 추가했다.
    • 각 부위별로 한 벌씩을 무작위로 선택해, 최소한의 추천 조합은 보장되도록 했다.

7-4) 사용자 의상이 등록되어 있지 않은 경우 404 반환

  • 현상

    • 사용자가 옷을 한 벌도 등록하지 않은 상태에서 추천 API를 호출하면 404를 반환했다.
  • 해결

    • HTTP 200 + 빈 리스트를 반환하도록 변경해, 클라이언트에서 더 자연스럽게 처리할 수 있도록 했다.

8) OAuth2 – Google 로그인 클라이언트 유형 설정 오류

  • 현상

    • Google OAuth2 로그인 시도 시 인증 실패가 발생했다.
  • 원인

    • Google Cloud Console에서 OAuth 클라이언트를 Desktop App 유형으로 생성했다.
    • Desktop App은 redirect URI를 등록할 수 없어, Spring Security OAuth2 클라이언트 설정과 맞지 않았다.
  • 해결

    • 클라이언트 유형을 Web application으로 새로 생성했다.
    • 로컬/운영 환경별로 승인된 redirect URI를 등록해, 환경에 따라 올바른 콜백 URL로 인증이 이뤄지도록 했다.

📌 개선 계획

  • 로컬 및 운영 환경에서 분산 환경 적용
  • 전체적인 UI 개선
  • 추가 API 개발
    • 회원 탈퇴
    • 인기 피드 랭킹
    • 어드민 전체 공지
  • 꼭 필요한 배포환경만 남기고 비용 최소화
  • 피드 목록 조회: Elasticsearch vs RDB 의사결정

👉 프로젝트 종료 후 피드 목록 조회 RDB 기반 변경, 피드 물리 삭제, 논리 삭제 복구 기능, DM 대화방 생성 등의 리팩토링을 완료했다!🤩


💡 프로젝트를 통해 배운 점

✅ 기술적 성과

  • 검색/탐색 품질 고도화

    • Elasticsearch를 활용하여 피드 검색 기능을 개선했다.
  • 추천 품질 향상

    • 의상 추천 결과에 대해 LLM 기반 추천 이유 생성을 도입하고, 프롬프트를 표준화했다.
    • 사용자가 “왜 이 조합을 추천받았는지” 이해할 수 있는 설명을 제공하면서, 자연스러운 표현과 일관된 톤을 유지했다.
  • 인증 신뢰성 확보 (OAuth2)

    • OAuth2 클라이언트 타입과 리디렉션 URI를 환경별로 정합성 있게 맞추어 설정했다.
    • 잘못된 설정으로 인한 로그인 실패 케이스를 제거하고 인증 플로우를 안정화했다.
  • UI 일관성

    • 색상·일러스트 재정비로 화면 간 시각적 일관성을 강화했다.
  • 테스트 품질 향상

    • 단위/통합 테스트를 꾸준히 추가해 테스트 커버리지 80% 이상을 달성했다.
    • 리팩토링이나 신규 기능 추가 시에도 테스트를 기반으로 안정적으로 코드를 수정할 수 있었다.
  • 프론트엔드까지 포함한 문제 해결 경험

    • 백엔드 API 수정에 그치지 않고, 알림 목록 무한스크롤, DM 탭분리 등 프론트엔드 코드도 함께 수정했다.
    • 화면/UI 문제를 “프론트 영역”으로만 넘기지 않고, 엔드투엔드 관점에서 이슈를 추적하고 해결하는 풀스택 시야를 갖추게 되었다.

🤝 비기술적 성과

  • 일정 가시화 & 리스크 관리

    • 스프린트 보드와 캘린더로 작업 현황을 가시화하고, 매일 팀 내에서 진행 상황을 공유했다.
    • 팀원의 일정 변경이나 지연 가능성이 보이면 즉시 공유하고, 데드라인 재협의·작업 재배정으로 리스크를 초기에 줄였다.
  • 커뮤니케이션 & 협업 방식

    • 요구사항 변경, 버그, 설계 고민 등을 혼자 오래 붙잡지 않고, 초기에 팀원과 공유해 함께 논의하는 습관을 들였다.
    • 코드 리뷰를 통해 서로의 코드 스타일과 설계 의도를 맞춰 가며, 피드백을 주고받는 문화를 유지했다.

📌 교훈

  • 새로운 기술은 필요할 때 도입하자
    피드 검색 기능에 Elasticsearch를 도입해 보면서, 현재 사용량 기준에서는 과설계에 가까울 수 있다는 점을 체감했다.
    새로운 기술을 도입할 때는 “왜 필요한지”, “우리 규모에 맞는지”를 먼저 검토하고 도입 범위를 정해야 한다는 교훈을 얻었다.

  • LLM은 만능이 아니라, 적절하게 써야 한다
    추천 로직 전체를 LLM으로 처리했을 때는 평균 응답 시간이 4초 이상으로 느려져 실서비스에는 적합하지 않았다.
    결국 추천 산출은 자체 알고리즘, LLM은 추천 이유 생성으로 역할을 분리해 성능과 사용자 경험을 모두 만족시킬 수 있었다.
    이 경험을 통해, 무작정 LLM을 도입하기보다 성능·비용·사용 시점을 함께 고려해 역할을 설계하는 것이 중요하다는 것을 배웠다.

  • 일정은 “가시화 + 공유”로 관리해야 한다
    스프린트 보드와 캘린더로 일정을 가시화하고, 팀 내에서 진척 상황을 꾸준히 공유하자 지연이 발생했을 때도 작업 재분배를 훨씬 빠르고 효율적으로 할 수 있었다.
    팀원의 일정 변경이나 지연 가능성이 보이면 즉시 공유하고, 데드라인을 재협의하거나 작업을 재배정하는 방식이 프로젝트 리스크를 줄이는 데 큰 도움이 된다는 점을 배웠다.

  • 백엔드에만 머무르지 않고 프론트까지 챙길 때 완성도가 올라간다
    백엔드 기능 개발뿐만 아니라, 프론트엔드(UI·화면 흐름)까지 함께 다루면서 버그 대응 속도와 전체 서비스 완성도가 확실히 올라간다는 걸 느꼈다.
    한쪽 영역에만 머무르기보다, 필요할 때는 프론트도 직접 보고 고칠 수 있는 역량이 협업과 운영에 큰 힘이 된다는 점을 깨달았다.

  • “내 도메인”을 넘어서 전체 코드베이스를 이해하려는 태도가 필요하다
    다른 팀원의 도메인과 코드를 평소에도 함께 파악해 두어야, 작업 재배정이 필요할 때 맥락을 빠르게 이해하고 투입될 수 있음을 느꼈다.
    협업 환경에서는 개인 담당 영역만 파는 것에서 끝나는 것이 아니라, 서비스 전체 구조와 주요 흐름을 함께 이해하려는 자세가 중요하다는 걸 느꼈다.


✍️ 마무리

이번 프로젝트에서는 Spring Security, Redis, Kafka, OAuth2, WebSocket, Elasticsearch 등 새로운 기술을 많이 도입해 볼 수 있었다.

무엇보다도 이번에도 너무 좋은 팀원들을 만나 재밌게 프로젝트를 진행할 수 있었다.🙌
각자 맡은 영역에서 열심히 하는 모습을 보며 나도 많이 자극을 받았고,
의사소통 방식이나 협업 태도에서도 배울 점이 정말 많았다.

이번 프로젝트를 시작하면서 “팀원들의 코드를 꼼꼼히 리뷰해 보자”는 나만의 목표를 세웠는데,
꽤 성실하게 지켜낸 것 같아 개인적으로는 만족스럽다.
(적어도 내 역량 내에서는 최선을 다했다고 자부한다..! 👀)

물론 아쉬운 부분들도 꽤나 있었다.
우선 PM 역할을 처음 맡아보면서 일정을 더 적극적으로 관리하지 못한 점이 아쉽다.
초반부터 팀원들의 일정까지 함께 고려해 전체 스케줄을 설계했어야 하는데,
“내 일만 제때 끝나면 되겠지” 하는 안일한 마음으로 시작했던 것 같다.😵‍💫

일정이 조금씩 밀리기 시작했을 때도,
“1~2일 정도 밀리는 건 괜찮지 않을까?”라는 생각에 일정 조정을 대부분 팀원들에게 맡겨 두었다.
그때 강사님께서 데드라인을 팀과 함께 분명히 정하고, 지키기 어려운 경우에는 작업을 재분배하는 방식을 추천해주셨고, 이를 반영하면서 일정을 조금 더 타이트하게 관리할 수 있었다.
결과적으로는 작업을 재분배한 덕분에 일정 안에 마무리할 수 있었고, PM의 중요성을 다시 한 번 느끼게 되었다.
앞으로는 의견을 피력하거나 일정 관련해서 조율·재촉해야 할 때, 좀 더 분명하게 어필하고 적극적인 자세로 임해야겠다고 느꼈다.

의사소통 측면에서도 아쉬움이 남는다.
회의 시간에 수동적으로 따르는 태도를 보였던 순간들이 많았는데,
다음에는 회의 전에 미리 생각을 정리해 두고, 내 의견을 명확히 정리해 조금 더 주도적으로 참여해 보고 싶다.

개인적으로 이번 프로젝트에서 가장 좋았던 점은,
“프로젝트 끝났으니까 여기까지!”가 아니라 이후 리팩토링까지 함께 이어갔다는 것이다.
기간이 끝난 뒤에도 팀원들끼리 코어 타임을 정해서 리팩토링과 기능 보완을 이어갔고,
실제로 서비스를 계속 다듬어 가는 경험을 할 수 있었다.

이번 프로젝트는 나에게 꽤 의미 있는 프로젝트로 남을 것 같다.👍

실은 정말 회고 쓰기 귀찮았는데 막상 쓰고 나니 정리도 되고, 추억도 새록새록 떠올라서 뿌듯하다. 이제 진짜 취준하러 가야지...💨

profile
개발자로 성장하기

1개의 댓글

comment-user-thumbnail
2025년 11월 28일

트러블 슈팅이 정말 꼼꼼하게 작성되어서 스스로 생각하시면서 개발하신 것이 눈에 보이네요 본받고 싶어요!! 고급 프로젝트 고생하셨습니다 글 잘 읽고 갑니당 ^_^

답글 달기