PROJECT 01

JEJURO (개인 AI 맞춤 여행)

개발 기간
2026년 09월 21일 ~ 2026년 10월 09일 (약 3주)
개발 인원
팀프로젝트(2명)
기술
Java · Spring Boot · React · Python · FastAPI · CatBoost · MySQL · Docker · AWS EC2 · Spring Security · OAuth2 · JPA · Kakao Map API

🍊

여행 조건과 취향을 바탕으로 AI가 제주 관광지 10곳을 추천하고, 날짜별 일정과 이동 경로까지 함께 계획하는 웹 서비스


💡 핵심 요약: 내가 해결한 문제 3가지

기능을 늘리는 것보다, 실제 사용 중 깨지는 지점을 찾아 막는 데 집중함

문제 해결
① 자동 저장 충돌
두 탭에서 같은 일정을 고치면 나중 저장이 앞의 수정을 말없이 덮어씀
경로 버전 해시 비교 + 여행 행 잠금 → 409로 알리고, 저장 못 한 변경은 복구
② 지도 · 경로 계산
길찾기 API 실패 시 경로 · 시간 · 요금이 비고, 방문 순서는 사용자가 직접 맞춰야 함
직선거리 추정값으로 대체 · 8곳 이하 전수 비교, 9곳 이상 2-opt 근사
③ 배포 후 소셜 로그인 실패
EC2 배포 뒤 카카오 로그인에서 KOE006
도메인 연결 + HTTPS + Nginx로 화면과 API를 한 주소로 통합

👥 역할 분담

🙋

김현지 · Backend · DevOps

  • REST API: 여행 · 경로 · 추천 · 커뮤니티 · 회원 · 관리자 컨트롤러 21개
  • 소셜 로그인: 카카오 · 네이버 · 구글 OAuth2 로그인과 계정 연동 · 해제 (연동된 회원만 로그인, 자동 가입 없음)
  • 회원가입 · 로그인 문제 해결: 팀장이 만든 기본 기능의 문제 해결과 보완
  • AI 서버 연동: 입력 변환, 실패 대응, 추천 기록과 관리자 KPI
  • 지도 · 경로: 카카오모빌리티 길찾기, 방문 순서 계산, 택시 요금
  • 배포 · 운영: Docker Compose, AWS EC2, HTTPS, GitHub Actions 자동 배포

🤖

이예원 · 팀장 · AI

  • CatBoost 추천 모델 학습 · 재학습
  • FastAPI 추천 서버 (/recommend)
  • 동반자 18-slot 피처 설계
  • 회원가입 · 로그인 기본 기능 구현

🎬 시연

탭을 눌러 화면별 동작을 확인할 수 있음. 🔧 표시는 아래 트러블슈팅 · AI 연동과 연결되는 화면

🧳 여행 계획

1. 메인 화면

메인 화면
메인 화면

2. 여행 만들기

여행 이름 · 날짜 · 권역 · 동반자(최대 18명)를 단계별로 입력. 지난 날짜와 다른 여행과 겹치는 기간은 선택 불가

여행 만들기
여행 만들기

3. 취향 설문

여행 스타일 6문항 · 여행 동기 · 테마 설문 → AI 추천 입력으로 사용

취향 설문
취향 설문

4. AI 맞춤 추천 🔧

제주 관광지 275곳 중 10곳 추천, '관심 없음'은 다음 추천부터 제외. 🔧 Spring Boot → FastAPI 연동 · 실패 대응은 내 담당

AI 맞춤 추천
AI 맞춤 추천

5. 관광지 검색 · 담기

권역 · 분류 · 이름으로 검색, 상세 정보 · 지도 · 날씨 확인. 휴무일이 있는 장소는 담을 때 안내

관광지 검색 · 담기
관광지 검색 · 담기

6. 날짜별 일정 짜기 🔧

담은 장소를 일차별로 배치 · 자동 저장, 경로선 · 이동 시간 · 택시 요금, 방문 순서 추천. 🔧 트러블슈팅 ① 자동 저장 충돌 · ② 지도 · 경로

날짜별 일정 짜기
날짜별 일정 짜기

7. 일정 확정

모든 장소를 배치해야 확정 가능, 출발일부터는 확정한 일정을 바꿀 수 없도록 잠금

일정 확정
일정 확정

8. 여행 후기 작성

만족도 · 장소별 방문 여부 · 못 간 이유 기록 → 추천 모델 재학습에 활용

여행 후기 작성
여행 후기 작성
💬 커뮤니티

9. 커뮤니티 · 경로 가져오기

여행 후기 · 질문 게시판, 다른 사람의 경로를 내 여행으로 가져오기

커뮤니티 · 경로 가져오기
커뮤니티 · 경로 가져오기

10. 댓글 · 게시글 신고

부적절한 글 신고 → 관리자 검토 전까지 '신고된 게시글'로 가림

댓글 · 게시글 신고
댓글 · 게시글 신고
🛡️ 관리자

11. 신고 처리 · 추천 KPI 🔧

신고된 글 · 댓글 차단 또는 반려, 추천 담기율 · 확정률 · 만족도 대시보드. 🔧 KPI는 내가 만든 추천 기록을 집계

신고 처리 · 추천 KPI
신고 처리 · 추천 KPI

12. 관광지 정보 수정

관광지 정보 수정
관광지 정보 수정

13. 깨진 사진 찾기

깨진 사진 찾기
깨진 사진 찾기

14. 사진 주소 교체

사진 주소 교체
사진 주소 교체

🏗 시스템 구성

JEJURO 시스템 구성도
다이어그램 원본 코드
flowchart LR
	U["사용자 브라우저"] -->|HTTPS| W
	subgraph EC2["AWS EC2 · Docker Compose"]
		W["web<br>Nginx :443 + React"] -->|"/api, /oauth2"| A["api<br>Spring Boot :8080"]
		A --> D[("db<br>MySQL 8")]
		A --> AI["ai<br>FastAPI + CatBoost"]
	end
	A -.-> X["외부 연동<br>카카오모빌리티 · OAuth 3사<br>Open-Meteo · Gmail SMTP"]

🔥 트러블슈팅

① 자동 저장이 다른 탭의 수정을 덮어씀

🚨

문제: 일정 화면은 변경 후 0.7초 뒤 자동 저장함. 같은 일정을 두 탭에서 고치면 나중에 저장된 탭이 앞 탭의 수정을 말없이 지움

🔍

원인: 저장 API가 "일정 전체 덮어쓰기" 방식 → 서버는 요청이 어떤 상태를 보고 만들어진 것인지 알 수 없음

해결

  1. 버전 만들기: 경로 이름 + 일차별 방문 순서를 SHA-256으로 해시해 응답에 version으로 내려주고, 저장 요청에 baseVersion으로 다시 받음 (DB 칸 추가 없음)
  2. 서버에서 비교: 여행 행을 SELECT … FOR UPDATE로 잠근 뒤 현재 버전과 비교, 다르면 409
    • 격리 수준은 READ COMMITTED로 지정함. REPEATABLE READ에서는 잠금 전 스냅샷을 읽어 비교가 틀어질 수 있기 때문
  3. 화면에서 처리: 409를 받으면 자동 저장을 멈추고 알림. 저장 못 한 변경은 sessionStorage에 보관하고, 다시 열었을 때 버전이 같을 때만 복구

✅

결과: 말없는 덮어쓰기 → "다른 화면(탭)에서 이 경로가 먼저 바뀌었어요" 알림 + 최신 경로 다시 불러오기

코드 보기: RouteService 저장 메서드
@Transactional(isolation = Isolation.READ_COMMITTED)   // 여행 행 잠금 뒤 최신 데이터를 읽도록
public RouteDetailResponse save(Long routeId, Long userId, RouteSaveRequest req) {
    // 여행 행을 먼저 잠근다: 다른 탭의 저장·장소 빼기·일정 확정과 겹치지 않게 차례로 처리
    Travel travel = travelRepository.findOwnedByRouteForUpdate(routeId, userId)
            .orElseThrow(() -> ApiException.notFound("경로를 찾을 수 없습니다."));
    ...
    if (req.baseVersion() != null
            && !req.baseVersion().equals(version(route.getRouteName(), currentDays(routeId)))) {
        throw new ApiException(HttpStatus.CONFLICT,
                "다른 화면(탭)에서 이 경로가 먼저 바뀌었어요. 최신 경로를 불러온 뒤 다시 수정해 주세요.");
    }
    ...
}

② 길찾기 API가 실패해도 일정 화면은 멈추지 않게

🚨

문제: 카카오모빌리티는 섬 · 산처럼 도로 탐색이 안 되는 곳에서 실패함 → 경로선 · 이동 시간 · 택시 요금이 비어 화면이 멈춤. 방문 순서도 사용자가 직접 맞춰야 했음

해결 A: 실패 시 추정값으로 대체

해결 B: 방문 순서 추천 (외부 API 없이 서버에서 계산)

순서를 바꿀 장소 수 방법 결과
8곳 이하 모든 순서 비교 (8! = 40,320가지, 즉시 계산) 정확한 최단 (EXACT)
9곳 이상 최근접 이웃으로 잇고 → 2-opt로 꼬인 구간을 뒤집어 짧아지면 교체 근사 (HEURISTIC)

✅

결과: 외부 API가 실패해도 일정 편집은 계속 동작하고, 계산 결과가 정확값인지 근사값인지 함께 알려 줌

③ 배포 후 소셜 로그인 실패 (KOE006)

🚨

증상: EC2 배포 뒤 카카오 로그인에서 KOE006 오류

🔍

원인: KOE006 = 등록되지 않은 Redirect URI. 배포 주소가 카카오 콘솔에 등록한 주소와 달랐음

해결

  1. 도메인(jejuro.projectbyhj.site) 연결
  2. certbot으로 Let's Encrypt HTTPS 인증서 발급, Nginx에서 80 → 443 리다이렉트
  3. 카카오 · 네이버 · 구글 콘솔에 https 도메인 콜백 주소 등록
  4. 같이 정리: Nginx가 화면과 API를 한 주소로 묶도록 설정 → 세션 쿠키와 CSRF가 로컬과 똑같이 동작

✅

결과: 3사 소셜 로그인 정상 동작, http 요청은 https로 자동 이동

설정 보기: deploy/nginx.conf
# Spring으로 넘기는 경로: API, 소셜 로그인 시작·콜백
location ~ ^/(api|oauth2|login/oauth2)/ {
    proxy_pass http://api:8080;
}
# 나머지는 React 화면 (새로고침해도 index.html로)
location / { ... }

🤖 AI 서버 연동 (내 담당 범위)

ℹ️

모델 자체(CatBoost 회귀, 입력 피처 76개, 후보 관광지 275곳, Recall@10 약 27.1%)는 팀장 담당. 나는 그 모델을 서비스에서 안정적으로 쓰도록 Spring Boot → FastAPI 연동을 맡음

1. 입력 변환

  • DB 설문 문항 → 모델 피처 이름 매핑 (문항 순서와 피처 순서가 달라 따로 관리)
  • 권역 · 동반자 코드를 모델 입력 형식으로 전달
  • 관심 없음 · 숨김 장소는 제외 목록으로 전달 → 빼고도 10곳 유지

2. 실패 대응

  • 연결 5초 · 응답 30초 제한, 실패는 502로 통일
  • 형식이 다른 응답을 "추천 0개 성공"으로 넘기지 않고 오류로 처리
  • 같은 여행의 동시 요청(새로고침 · 이중 호출)은 잠금으로 한 줄로
  • AI 호출 중에는 트랜잭션을 열지 않아 DB 연결을 붙잡지 않음

3. 기록 · 재사용

  • 요청마다 모델 버전 · 응답 시간 · 결과 수 저장
  • 여행당 한 번: 성공한 추천은 다시 열어도 같은 결과
  • 기록이 관리자 KPI와 재학습 데이터로 이어짐

처리 흐름: 정상 흐름과 예외 분기

AI 추천 요청 처리 흐름도
다이어그램 원본 코드
sequenceDiagram
	participant U as 화면
	participant S as Spring Boot
	participant DB as MySQL
	participant AI as FastAPI
	U->>S: 추천 요청
	S->>S: 여행별 잠금 (동시 요청 한 줄로)
	alt 이미 성공한 추천 있음
		S-->>U: 저장된 결과 반환
	else 처음 요청
		S->>DB: 설문 · 동반자 조회 (VIEW)
		alt 설문 미완료
			S-->>U: 409
		else 설문 완료
			S->>AI: POST /recommend (제외 목록, top_n 10)
			alt 시간 초과 · 형식 오류
				S->>DB: 실패 기록
				S-->>U: 502
			else 정상 응답
				AI-->>S: place_name 10개
				S->>S: 우리 관광지 ID로 연결
				alt 연결된 곳 0개
					S-->>U: 502 (매핑 장애)
				else 연결 성공
					S->>DB: 요청 · 결과 기록
					S-->>U: 추천 10곳
				end
			end
		end
	end

🚀 배포 자동화 (CI/CD)

main에 푸시하면 응답 확인까지 자동으로 진행. 배포 완료 기준을 "서버가 실제로 응답할 때"로 잡음

  1. main 푸시: GitHub Actions 실행, 배포가 겹치면 앞 배포가 끝난 뒤 진행
  2. EC2 접속 (SSH): 호스트 · 키는 GitHub Secrets로 관리, 최신 코드 받기
  3. 사전 검사: 비밀 설정 파일이 없으면 배포 중단
  4. 다시 빌드 · 실행: docker compose로 web · api · ai · db 4개 컨테이너, db 헬스 체크 통과 후 api 시작
  5. 응답 확인: /api/auth/csrf 응답을 최대 3분 확인해야 완료

배포하며 겪은 문제

문제 원인 해결
설정 파일이 폴더로 바뀜 없는 파일을 컨테이너에 연결하면 Docker가 같은 이름의 빈 폴더를 만들어 이후 업로드가 꼬임 배포 첫 단계에서 파일 존재를 확인하고 없으면 중단
재배포 직후 502 api 컨테이너가 새로 만들어지면 Nginx가 예전 컨테이너 주소를 기억함 api를 다시 만든 뒤 web(Nginx)도 다시 시작

🛠 기술 선택 이유와 검증

기술 고른 이유 직접 구현한 범위 확인 방법
Spring Security
세션 + CSRF
화면과 API가 같은 주소 → 토큰을 브라우저에 저장하지 않는 쿠키 세션, 상태 변경 요청은 CSRF로 보호 3사 OAuth2 로그인 · 계정 연동 구현, 회원가입 · 로그인 문제 해결 (기본 기능은 팀장 구현) AuthControllerTest 8개
LoginAttemptLimiterTest 5개
JPA + 행 잠금 같은 여행에 저장 · 장소 빼기 · 확정 요청이 겹칠 수 있음 SELECT … FOR UPDATE, READ COMMITTED, 경로 버전 비교 동시성 단위 테스트는 아직 없음 → 다음 단계
FastAPI 분리 모델이 Python 생태계 · Spring과 장애 · 배포 단위를 나눔 HTTP 클라이언트, 타임아웃, 응답 검증, 실패 기록 RecommendServiceTest 3개
Docker Compose
GitHub Actions
서버 4개를 한 EC2에서 같은 설정으로 다시 만들 수 있게 Dockerfile 3개, compose, Nginx 설정, 배포 워크플로 배포마다 응답 확인 자동화
관리자 KPI 추천 담기율 · 확정률 · 만족도를 모델 버전별로 비교 추천 기록 기반 집계, CSV 내보내기 CsvNeutralizeTest 2개 (CSV 수식 주입 방지)

🔭 한계와 다음 단계

항목 지금 다음 단계
방문 순서 추천 직선거리 합 기준, 실제 도로 시간과 운영 시간은 반영하지 않음 길찾기 거리 행렬로 비교하고 운영 시간 조건 추가
추천 동시 요청 잠금 JVM 안의 잠금 64개 → 서버 한 대에서만 보장 서버를 늘리면 DB 잠금이나 Redis 분산 락으로 교체
일정 충돌 처리 경로 전체를 하나의 버전으로 비교 → 충돌하면 다시 불러와야 함 일차 · 장소 단위로 바뀐 부분만 합치기, 동시성 테스트 추가