minari0v0
소개프로젝트스토리

© 2026 minari0v0. All rights reserved.

모든 프로젝트GitHub 저장소

PickPl (픽플)

공간을 쇼핑하다 - AI 기반 무드 큐레이션 공간 룩북 플랫폼

Spring BootNext.jsTypeScriptJPAMySQLRedisPlaywrightGemini API
PickPl (픽플)
개발 기간
2026년 5월~2026년 7월
프로젝트 유형
개인 프로젝트
기술 스택 (Tech Stack)
Frontend
Next.jsTypeScript
Backend
Spring Boot
Database
MySQLRedis
Others
JPAPlaywrightGemini API

프로젝트 개요

"오늘 당신의 기분과 어울리는 공간을 쇼핑해 보세요."

스마트폰 클릭 몇 번으로 수많은 맛집과 카페 정보를 찾을 수 있는 시대예요. 하지만 정작 '집중해서 작업하기 좋은 잔잔한 북카페'나 '해질녘 노을이 잘 보이는 이색적인 야외 테라스'처럼 내 마음에 꼭 맞는 구체적인 무드의 공간을 찾기란 여전히 쉽지 않지요.

픽플(PickPl)은 이 번거로움을 해결하기 위해 탄생한 AI 기반 감성 무드 큐레이션 공간 룩북 플랫폼이에요. 단순한 포털 검색 방식을 넘어, 공간의 정보와 방문자 피드백 텍스트 데이터를 AI 에이전트가 정밀 분석하여 독자적인 감성 지표를 도출합니다. 인스타그램 피드를 넘기듯 직관적으로 공간을 발견하고, 실시간 날씨와 내 활동 통계에 기반한 맞춤형 공간 추천 서비스를 경험해 보세요.


프로젝트 구조

프론트엔드와 백엔드, 그리고 데이터 파이프라인이 유기적으로 연결된 모노레포(Monorepo) 형태로 구성되어 있어요. 각 레이어는 느슨하게 결합되어 있어 데이터 수집/가공과 서비스 제공이 완벽하게 분리되어 안정적으로 동작합니다.

전체 디렉토리 오버뷰

pickpl/ (Root Directory)
├── frontend/           # Next.js 프론트엔드 웹 앱
├── backend/            # Spring Boot 백엔드 API 서버
├── data-pipeline/      # Playwright & Gemini 데이터 수집 파이프라인
├── docker-compose.yml  # MySQL & Redis 개발 환경 컨테이너 스펙
└── Makefile            # 로컬 통합 개발 실행 단축 스크립트

💡 아래의 각 레이어 영역을 클릭하면 상세 디렉토리 구조를 볼 수 있어요!

🎨 Frontend (Next.js & Tailwind CSS) 구조 보기 상세 보기 ▾

frontend/
├── app/                  # App Router 기반 페이지 및 레이아웃
│   ├── admin/            # CMS 어드민 & 데이터 주입 패널
│   ├── login/            # 소셜 및 게스트 로그인
│   ├── oauth-signup/     # OAuth 취향 온보딩 회원가입
│   ├── auth-success/     # 소셜 로그인 성공 콜백 핸들러
│   ├── layout.tsx        # 글로벌 모바일 뷰 래퍼 레이아웃
│   └── page.tsx          # 메인 감성 룩북 피드 페이지
├── components/           # 프리미엄 반응형 UI 컴포넌트
│   ├── ui/               # 아코디언, 버튼, 스켈레톤 등 공통 하위 UI 칩
│   ├── modals/           # 공간 상세 모달 (PlaceDetailModal 등) 및 약관 모달
│   ├── views/            # 모바일 핏 스와이프 피드, 검색, 컬렉션 뷰
│   └── ResponsiveApp.tsx # 미디어 쿼리 기반 데스크탑-모바일 듀얼 레이아웃 래퍼
├── store/                # Zustand 기반 초경량 글로벌 상태 관리
└── api/                  # Axios 인터셉터 기반 REST API 클라이언트

⚙️ Backend (Spring Boot & Java) 구조 보기 상세 보기 ▾

backend/
├── src/main/java/com/pickpl/app/
│   ├── auth/         # 소셜 OAuth2 및 이메일 회원가입/인증 로직
│   ├── config/       # Spring Security, CORS, JPA 등 시스템 환경설정
│   ├── domain/       # JPA 핵심 엔티티 정의 (Place, Scrap, Member)
│   ├── init/         # 개발용 초기화 더미 데이터 주입
│   ├── place/        # 장소 정보 관리 API (Places, DB batch 주입, DTO)
│   ├── scrap/        # 개인 컬렉션/스크랩 보드 API
│   ├── security/     # JWT Token Provider 및 필터 보안 체인
│   └── vibe/         # 장소 상세 내의 실시간 혼잡도/분위기 투표 API
└── build.gradle      # 의존성 설정 (JPA, MySQL, Redis, JWT 등)

🐍 Data Pipeline (Python & Playwright & Gemini) 구조 보기 상세 보기 ▾

data-pipeline/
├── scraper/              # 1단계: 네이버 플레이스 모바일 페이지 기반 데이터 수집기 (Playwright)
├── analyzer/             # 2단계: Gemini AI 구조화 감성/카테고리 분석기
├── loader/               # 3단계: 가공 데이터 백엔드 DB 벌크 로더
├── raw_data/             # 크롤링 생데이터 및 AI 분석 JSON 결과 저장소
├── main.py               # 파이프라인 통합 CLI 엔진 (Scrape & Analyze 독립 수행 가능)
├── backfill.py           # 429 쿼터 초과 대비 임시 더미데이터 사후 복구용 백필 툴
├── migrate_from_log.py   # 수집 로그 기반 searchQuery 메타데이터 정밀 동기화 툴
├── migrate_queries.py    # 수집된 데이터의 쿼리 데이터 정밀 이관 툴
├── regions.json          # 전국 행정구역 및 수집 타겟 검색 질의 리스트
├── analyzed_places.json.example  # 로컬 개발 및 테스트를 위한 데이터셋 샘플
└── requirements.txt      # 파이썬 가상환경 의존성 정의 파일

기술 스택 및 아키텍처

분류기술
FrontendNext.js, React, TypeScript, SWR, Zustand, TailwindCSS
BackendSpring Boot, Java, Spring Security, Spring Data JPA, QueryDSL
DatabaseMySQL (RDB 데이터 영속), Redis (다중 세션 캐시)
AI / PipelineGemini 2.5 & 3 Flash, Playwright, BeautifulSoup, Tkinter
Geo APIW3C Geolocation API, Open-Meteo Weather API

📊 데이터베이스 ERD (논리적 FK 설계 기법)

성능 격리와 향후 마이크로서비스 아키텍처(MSA)로의 물리적 확장을 고려했어요. 물리적인 외래키(FK)를 맺는 대신, 비정형 성격의 Scrap이나 VibeVote 도메인은 애플리케이션 레벨에서 논리적인 관계로 제어하도록 설계했습니다. Place와 Tag는 N:M 관계의 유연성을 위해 PlaceTagMap 중간 매핑 테이블을 거칩니다.

erDiagram users ||--o{ social_connection : "has" users ||--o{ user_sessions : "has" users ||--o{ scrap : "logical_has" users ||--o{ vibe_vote : "logical_has" users ||--o{ tag_click_log : "logical_clicks" place ||--o{ place_tag_map : "tagged_with" tag ||--o{ place_tag_map : "maps_to" place ||--o{ scrap : "scrapped_in" place ||--o{ vibe_vote : "vibe_voted_in" users { Long id PK String email UK "unique" String password "nullable (social)" String nickname String profileImageUrl Role role "USER, GUEST, ADMIN" AuthProvider provider "LOCAL, GOOGLE, KAKAO, NAVER" String providerId boolean emailVerified boolean onboarded String equippedBadgeTitle "장착 중인 대표 칭호" } social_connection { Long social_connection_id PK Long user_id FK String provider String provider_id } user_sessions { Long user_session_id PK Long user_id FK String refresh_token_uuid UK String ip_address String location String device String browser LocalDateTime last_accessed_at } place { Long place_id PK String name String thumbnailUrl String imageUrls "TEXT (JSON)" String externalId UK "kakao/naver place id" String address Double latitude Double longitude String category String subCategory String aiMoodSummary "TEXT" int quietVoteCount int chattyVoteCount String editorsComment String curationTheme boolean isPublished } tag { Long tag_id PK String name TagType type "MOOD, FACILITY, WEATHER" } place_tag_map { Long place_tag_map_id PK Long place_id FK Long tag_id FK Double confidenceScore "AI 태깅 신뢰도" } scrap { Long scrap_id PK Long user_id FK "논리적 FK 매핑" Long place_id FK String folderName } vibe_vote { Long vibe_vote_id PK Long user_id FK "논리적 FK 매핑" Long place_id FK String vibeType }
graph TD Client([💻 Client Browser / Mobile]) -->|HTTPS / SPA| Frontend subgraph "Frontend Layer" Frontend[Next.js 16.2.6 & Tailwind 4] end Frontend -->|REST API + Secure JWT| Backend subgraph "Backend & Storage Layer" Backend[Spring Boot 4.0.6] subgraph "Docker Compose Infrastructure" MySQL[🗄️ MySQL 8.0.35] Redis[⚡ Redis 7.x] end end subgraph "Data Pipeline Layer" PythonCrawler[🐍 Python Crawler & Analyzer] Web["Naver Places (Naver Maps)"] Gemini[🤖 Gemini API] end Backend <-->|Read/Write / Connection Pool| MySQL Backend <-->|Fast Tag Cache & Token Store| Redis PythonCrawler -->|Web Scraper Engine| Web PythonCrawler -->|Image & Text Multimodal Analysis| Gemini PythonCrawler -->|Batch API Push /api/v1/places/batch| Backend

UI/UX 특징

모바일 화면에 최적화된 앱 스타일의 레이아웃으로 기획되었으며, 픽플 특유의 오렌지 브랜드 톤앤매너와 미니멀하고 직관적인 조작감을 제공합니다.

[사용자 애플리케이션 화면]

로그인 페이지
소셜 계정 전용 회원가입
취향 온보딩
메인 페이지
검색 페이지
큐레이션 페이지
장소 모달 1
장소 모달 2
스크랩 바텀시트
내 스크랩 화면
0 / 0

[마이페이지 화면]

마이페이지 대시보드
SNS 로그인 마이페이지
마이페이지 속 업적
마이페이지의 픽플 설정
계정 설정 화면
계정 로그인 기록
이메일 인증 요청
이메일 코드 입력창
이메일 SMTP 커스텀 본문
프로필 수정 페이지
0 / 0

[데이터 수집 화면]

수집 파이프라인 GUI 모니터

[관리자(CMS) 화면]

어드민 로그인 페이지
JSON 데이터 검토 페이지
수집 데이터 벌크 적재
공간 데이터 매니지먼트
장소 상세 편집 모달
룩북 기획전 테마 관리
리뷰 및 방문기록 관리
CMS 대시보드
관리자 환경 설정
0 / 0

주요 기능

1. 감성 무드 룩북 피드 및 다중 필터 검색

  • 인스타 피드 방식의 직관적인 무한 스크롤 탐색으로 사용자 관심 유도
  • 조용한(Cozy), 힙한(Hip) 등 주요 무드 분류와 실시간 GPS 거리를 연계한 취향 필터링 기능 제공

2. 개인화 큐레이션 보관함

  • 폴더 단위(예: '작업하기 좋은 카페')로 마음에 드는 분위기를 모아 분류하는 룩북형 보관 지원
  • 수집된 상점 사진들을 활용해 콜라주 형태의 폴더 커버 아트를 프론트단에서 자동 생성

3. 3D 캐릭터 뱃지 도감 및 대표 칭호 시스템

  • 유저 활동 지표를 분석해 무드 피규어 컨셉의 뱃지를 해금하는 게이미피케이션 탑재
  • 원하는 획득 뱃지를 대표 프로필 칭호로 장착해 기기 다중 접속 시에도 실시간 동기화

4. OAuth2 기반 소셜 로그인 및 다중 계정 연동

  • Spring Security OAuth2 Client 라이브러리를 활용해 Google, Kakao, Naver 소셜 간편 로그인 및 회원가입을 API로 구현
  • 기존 로컬(이메일) 계정 소유 유저가 동일한 이메일의 소셜 계정으로 로그인 시 자동으로 계정이 연동되도록 지원하며, 설정 탭을 통해 수동 계정 연동 및 해제 기능 제공

5. 로그인 기기 다중 세션 관리

  • 현재 접속 중인 디바이스 정보(OS, 브라우저명, IP 주소)를 수집하여 마이페이지 세팅 화면에 노출
  • 로그인한 유저 본인이 타 기기의 좀비 세션을 원격 강제 로그아웃시키는 보안 보강 기법 적용

6. 지능형 데이터 수집 및 AI 분석 파이프라인

  • Python 가상환경 내 Playwright 비동기 수집 엔진을 활용해 외부 네이버 플레이스의 공개된 공간 상세 및 방문자 반응 텍스트 데이터 수집
  • Gemini API 연동을 통해 수집한 텍스트 데이터의 감성을 구조화된 룩북 요약 JSON 포맷으로 실시간 파싱

7. 관리자 백오피스 (CMS)

  • CMS 대시보드를 활용해 수집된 전체 장소, 퍼블리시 대기 데이터 건수, 전체 태그 및 누적 로그 분석 지표를 일괄 관리
  • 어드민 공간 관리/승인 폼 제어 및 테마별 룩북 기획전 설정, 분석 완료 JSON 파일의 드래그앤드롭 벌크 적재

핵심 기술

1. 실시간 날씨 및 계절별 큐레이션

  • 채택 사유: 비가 오거나 특정 계절, 혹은 요일별 시간대에 알맞은 감성 룩북 배너를 자동으로 판단하여 노출시켜야 합니다
  • 설계 포인트: 클라이언트 측 위경도를 기반으로 외부 Open-Meteo API 기상 데이터를 실시간 파싱해요. 날씨가 비(RAINY)일 때는 rainy_indoor(비 오는 날 실내 공간) 기획전을 최우선 노출하고, 맑을 때는 주말 낮 pet_friendly(반려견 나들이) 또는 평일 낮 wellness(웰니스 다도) 기획전을 무작위 게릴라 매칭하거나, 기본값으로 계절 필터(spring, summer, autumn, winter) 큐레이션 배너를 노출하는 제어 판정 트리를 구현했습니다
// backend/src/main/java/com/pickpl/app/curation/service/CurationService.java
@Transactional(readOnly = true)
public CurationResponse getCuration(Long userId, Double latitude, Double longitude) {
    // 1. 기상 Open API 호출 및 현재 시간 수립
    String weatherState = weatherClient.getWeatherState(latitude, longitude);
    java.time.LocalDateTime now = java.time.LocalDateTime.now();
    java.time.DayOfWeek day = now.getDayOfWeek();
    int hour = now.getHour();
    int currentMonth = now.getMonthValue();
    String curationThemeKey;
 
    // 2. 날씨/시간대/계절 판정 매트릭스 트리 작동
    if ("RAINY".equals(weatherState)) {
        curationThemeKey = "rainy_indoor"; // 비 오는 날 큐레이션 배너 고정
    } else if ("SNOWY".equals(weatherState)) {
        curationThemeKey = "winter";
    } else {
        if ((day == java.time.DayOfWeek.FRIDAY || day == java.time.DayOfWeek.SATURDAY) && (hour >= 17 || hour <= 2)) {
            curationThemeKey = "night_market"; // 주말 저녁 야장 노포 테마
        } else if ((day == java.time.DayOfWeek.SATURDAY || day == java.time.DayOfWeek.SUNDAY) && (hour >= 9 && hour < 17)) {
            curationThemeKey = "pet_friendly"; // 주말 낮 애견동반 게릴라 테마
        } else if ((day == java.time.DayOfWeek.MONDAY || day == java.time.DayOfWeek.TUESDAY || day == java.time.DayOfWeek.WEDNESDAY || day == java.time.DayOfWeek.THURSDAY) && (hour >= 13 && hour < 17)) {
            curationThemeKey = "wellness"; // 평일 낮 다도/웰니스 차분한 테마
        } else {
            // 그 외 기본 달(Month)에 기반한 계절 배너 노출
            if (currentMonth >= 3 && currentMonth <= 5) curationThemeKey = "spring";
            else if (currentMonth >= 6 && currentMonth <= 8) curationThemeKey = "summer";
            else if (currentMonth >= 9 && currentMonth <= 11) curationThemeKey = "autumn";
            else curationThemeKey = "winter";
        }
    }
    
    List<PlaceSummaryResponse> places = java.util.Collections.emptyList();
    if (curationThemeKey != null) {
        places = placeService.findPlacesByCurationTheme(curationThemeKey, userId, latitude, longitude);
    }
    return CurationResponse.of(activeThemeTitle, activeThemeName, places);
}

2. 개인화 하이브리드 추천 엔진 (JPA & Redis & Time Decay)

  • 채택 사유: 사용자가 플랫폼 내에서 행동하는 패턴 자체를 취향 가중치 벡터로 가공하여, 기호에 밀접하게 맞닿은 장소들을 우선 노출시킵니다
  • 설계 포인트: Redis 캐시 LookUp 연동을 통해 사용자의 선호 가중치 맵을 로드해요. 온보딩 가중치(+5.0), 상세 조회 로그(최근 50개, 개당 +1.0), 분위기 투표(+2.0) 점수를 합산해 취향 벡터를 생성하고, 최신 핫플 가점을 위해 Math.exp(-0.05 * 경과일수) 지수 시간 감쇠 공식을 곱해 최종 점수를 산출합니다
// backend/src/main/java/com/pickpl/app/place/service/RecommendationService.java
@Transactional(readOnly = true)
public RecommendationResponse getPersonalizedRecommendations(Long userId) {
    if (userId == null) return getPopularFallback();
    
    // 1. Redis 캐시 또는 DB를 조회하여 사용자의 취향 가중치 벡터 맵을 획득
    Map<String, Double> userPreferences = getUserPreferenceVector(userId);
    if (userPreferences.isEmpty()) return getPopularFallback();
 
    // 2. 가장 선호도가 높은 주요 무드 및 상위 태그 추출
    String primaryMood = userPreferences.entrySet().stream()
            .max(Map.Entry.comparingByValue())
            .map(Map.Entry::getKey).orElse(null);
 
    List<String> topTags = userPreferences.entrySet().stream()
            .sorted(Map.Entry.<String, Double>comparingByValue().reversed())
            .limit(3).map(Map.Entry::getKey).toList();
 
    // 3. 후보군 수집 (선호 태그 매칭 장소 100건 + 최근 등록 장소 50건)
    Set<Place> candidates = new HashSet<>();
    if (!topTags.isEmpty()) {
        candidates.addAll(placeRepository.findTop100ByTagNames(topTags, PageRequest.of(0, 100)));
    }
    candidates.addAll(placeRepository.findAllByIsPublishedTrue(PageRequest.of(0, 50, Sort.by(Sort.Direction.DESC, "id"))).getContent());
 
    List<ScoredPlace> scoredPlaces = new ArrayList<>();
    LocalDateTime now = LocalDateTime.now();
 
    // 4. 각 후보지별 개인화 매칭 스코어링 및 시간 감쇠(Time Decay) 연산
    for (Place place : candidates) {
        double matchScore = place.getPlaceTagMaps().stream()
                .map(m -> m.getTag().getName())
                .mapToDouble(tag -> userPreferences.getOrDefault(tag, 0.0)).sum();
 
        if (matchScore <= 0) matchScore = 0.1;
 
        // 지수 시간 감쇠 공식: exp(-0.05 * 경과일수) 적용 (최신 등록 핫플레이스 가산점)
        long days = place.getCreatedAt() != null ? Duration.between(place.getCreatedAt(), now).toDays() : 0;
        double timeDecay = Math.exp(-0.05 * Math.max(0, days));
        double finalScore = matchScore * timeDecay;
 
        scoredPlaces.add(new ScoredPlace(place, finalScore, matchScore));
    }
 
    // 5. 최종 고득점 순으로 12개 장소를 추려 개인화 하이브리드 결과 반환
    List<PlaceSummaryResponse> recommended = scoredPlaces.stream()
            .sorted(Comparator.comparingDouble(ScoredPlace::finalScore).reversed())
            .limit(12)
            .map(sp -> PlaceSummaryResponse.from(sp.place(), false, null))
            .toList();
 
    return new RecommendationResponse("PERSONALIZED_HYBRID", primaryMood, recommended);
}

3. GPS 기반 실시간 직선거리 연산 (Haversine 구면 삼각법)

  • 채택 사유: 모바일 중심 서비스에서 내 주위의 근거리 공간을 정밀하고 신속하게 정렬해 보여주어야 했습니다
  • 설계 포인트: W3C Geolocation API 좌표 정보를 백엔드 DTO 매핑 핸들러에 태워 넘겨받은 후, 삼각함수와 라디안 각도를 이용한 하버사인(Haversine) 공식을 활용해 지구 표면상 두 위경도 좌표의 구면 직선거리를 계산해요. 1km 미만은 미터 정수(350m), 1km 이상은 소수점 킬로미터(1.2km) 단위로 포맷팅하여 응답 편의성을 높였습니다
// backend/src/main/java/com/pickpl/app/place/service/RecommendationService.java
private String calculateDistanceStr(Place place, Double userLat, Double userLon) {
    if (userLat == null || userLon == null || place.getLatitude() == null || place.getLongitude() == null) {
        return null;
    }
    double theta = userLon - place.getLongitude();
    double dist = Math.sin(Math.toRadians(userLat)) * Math.sin(Math.toRadians(place.getLatitude()))
            + Math.cos(Math.toRadians(userLat)) * Math.cos(Math.toRadians(place.getLatitude())) * Math.cos(Math.toRadians(theta));
    dist = Math.acos(dist);
    dist = Math.toDegrees(dist);
    dist = dist * 60 * 1.1515 * 1.609344; // 킬로미터 단위 정교화
    
    if (dist < 1.0) {
        return String.format("내 위치에서 %d" + "m", (int)(dist * 1000));
    } else {
        return String.format("내 위치에서 %.1f" + "km", dist);
    }
}

4. 다중 소셜 계정 연동 및 자동 매핑 (OAuth2 & SocialConnection)

  • 채택 사유: 가입 경로가 다른 복수의 계정을 하나로 통합하고, 이메일이 동일할 때 자동으로 매핑을 지원하여 사용자 UX 피로도를 획기적으로 낮춰야 했습니다
  • 설계 포인트: User 엔티티와 SocialConnection 엔티티를 1:N 관계로 구성했어요. 소셜 로그인 흐름(CustomOAuth2UserService)에서 이메일을 검증해 로컬 회원 정보가 일치하면 SocialConnection을 즉시 자동 등록(Auto-Link)하고, 활성 세션 상태인 경우에는 세션 어트리뷰트 핸들러를 경유해 로그인 상태에서 안전하게 소셜 연동(Link)을 가능하게 처리했습니다
  [users (마스터 계정)] 1 ------- * [social_connection (소셜 정보 맵핑)]
    - id (PK)                         - social_connection_id (PK)
    - email (동일 이메일 확인)        - user_id (FK)
    - provider (최초 가입 정보)       - provider (GOOGLE, KAKAO, NAVER)
                                      - provider_id
// backend/src/main/java/com/pickpl/app/security/oauth2/CustomOAuth2UserService.java
// ...
if (linkUserId != null) {
    // 1. 세션 기반 수동 계정 연동 프로세스
    Long userId = Long.valueOf(linkUserId);
    user = userRepository.findById(userId).orElseThrow(() -> new OAuth2AuthenticationException("존재하지 않는 유저입니다."));
    
    if (socialConnectionRepository.existsByProviderAndProviderId(providerStr, providerId)) {
        throw new OAuth2AuthenticationException("이미 다른 계정에 연결되어 있는 소셜 계정입니다.");
    }
    
    SocialConnection newConn = SocialConnection.builder().user(user).provider(providerStr).providerId(providerId).build();
    socialConnectionRepository.save(newConn);
} else {
    // 2. 로그인 시점 소셜 연동 여부 조회
    Optional<SocialConnection> socialConnOpt = socialConnectionRepository.findByProviderAndProviderId(providerStr, providerId);
    if (socialConnOpt.isPresent()) {
        user = socialConnOpt.get().getUser(); // 연동된 계정으로 로그인 허용
    } else {
        // 3. 동일 이메일의 로컬(LOCAL) 회원이 존재하는 경우 자동 연동(Auto-Link) 처리
        String email = oAuth2UserInfo.getEmail();
        Optional<User> localUserOpt = userRepository.findByEmail(email);
        if (localUserOpt.isPresent() && localUserOpt.get().getProvider() == AuthProvider.LOCAL) {
            User localUser = localUserOpt.get();
            SocialConnection newConn = SocialConnection.builder().user(localUser).provider(providerStr).providerId(providerId).build();
            socialConnectionRepository.save(newConn);
            user = localUser;
        } else {
            user = registerNewUser(provider, oAuth2UserInfo); // 신규 가입
        }
    }
}

5. CMS 백오피스 시스템 (데이터 주입 & 어드민 벌크 로더)

  • 채택 사유: 파이프라인에서 수집 및 분석을 마친 수백 건의 신규 장소를 수동 INSERT 대신, 어드민 웹 CMS를 통해 트랜잭셔널하게 대량 벌크 주입(Bulk Ingest)하고 관리해야 했습니다
  • 설계 포인트: 크롤링한 JSON 결과 파일을 드래그앤드롭으로 받아 자바 객체 리스트로 바인딩하는 PlaceBatchRequest 벌크 DTO를 매핑했어요. Spring Security 어드민 시크릿 헤더(X-Admin-Secret-Key) 인증 가드를 설계하여, 외부에 엔드포인트를 노출하지 않으면서도 대용량 데이터를 안전하게 일괄 적재할 수 있도록 내부 연동 API를 설계했습니다
// backend/src/main/java/com/pickpl/app/place/controller/InternalPlaceController.java
@RestController
@RequestMapping("/api/v1/internal/places")
@RequiredArgsConstructor
public class InternalPlaceController {
 
    private final PlaceService placeService;
    private final com.pickpl.app.security.admin.AdminKeyService adminKeyService;
 
    @Operation(summary = "장소 대량 등록", description = "Python 크롤러가 수집한 데이터를 대량 등록합니다. (Admin Secret Key 필요)")
    @PostMapping("/batch")
    public ResponseEntity<String> batchInsertPlaces(
            @RequestHeader("X-Admin-Secret-Key") String requestSecretKey,
            @RequestBody PlaceBatchRequest request) {
 
        if (!adminKeyService.getAdminKey().equals(requestSecretKey)) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("유효하지 않은 어드민 키입니다.");
        }
 
        int savedCount = placeService.saveBatch(request);
        return ResponseEntity.ok("성공적으로 " + savedCount + "개의 장소가 저장되었습니다.");
    }
}

6. Playwright & Gemini AI 기반 데이터 수집/분석 파이프라인

  • 채택 사유: 수많은 장소 정보 중 광고 노이즈를 걸러내고 신뢰도 높은 데이터를 지능적으로 요약하는 감성 수집 엔진이 핵심 자산이었습니다
  • 설계 포인트: Python 환경에서 Pydantic을 활용해 Gemini Structured Output 스키마를 수립하고, 다수 장소를 묶어 일괄 분석하는 배치 기법을 설계했어요. 안정적인 브라우저 동작을 위한 Playwright Chromium 컨텍스트 최적화 기술과 Tkinter 멀티스레드 기반 상태 모니터를 기동해 백그라운드 스레드 데몬과 메시지 큐 방식으로 데이터 정합성을 확보하며 안전하게 수집합니다
# data-pipeline/analyzer/gemini_client.py
from pydantic import BaseModel, Field
from google import genai
from google.genai import types
 
# Pydantic을 활용한 Gemini Structured Output 스키마 정의
class PlaceAnalysis(BaseModel):
    externalId: str = Field(description="장소의 고유 식별자(externalId)")
    category: str = Field(description="1차 카테고리 대분류. '음식점', '카페/디저트', '술집', '자연명소', '문화/체험' 중 하나")
    subCategory: str = Field(description="2차 카테고리 소분류")
    aiMoodSummary: str = Field(description="장소의 분위기를 반영한 큐레이터 한 줄 추천 문구")
    moodTags: list[str] = Field(description="감성 분위기 태그")
    facilityTags: list[str] = Field(description="시설/편의 태그")
    weatherTags: list[str] = Field(description="날씨/상황 태그")
 
class BatchAnalysisResponse(BaseModel):
    places: list[PlaceAnalysis] = Field(description="분석된 장소 결과 목록")
 
# Gemini 일괄 분석 호출부
def analyze_places_batch(self, places: list[dict], batch_size: int = 3):
    # Pydantic schema를 response_schema에 주입하여 구조화 JSON 수신 강제화
    response = self.client.models.generate_content(
        model=self.model_name,
        contents=contents,
        config=types.GenerateContentConfig(
            system_instruction=SYSTEM_PROMPT,
            response_mime_type="application/json",
            response_schema=BatchAnalysisResponse,
            temperature=0.2,
        ),
    )
    return response.text

프로젝트 성과

  • 안정적인 수집 파이프라인 구축: Playwright 모바일 헤더 및 브라우저 프로파일 최적화와 2단계 분리 설계를 통해 요청 제한 발생률을 최소화하고, 예외 발생 시 실시간으로 이어서 수집이 가능한 Resume 체계를 구축하여 수집 흐름 안정성을 극대화했습니다
  • RDB & NoSQL 하이브리드 캐싱 완성: 로그인 다중 세션 관리와 실시간 취향 탐색 연산에 Redis 인메모리 스토리지를 연동하여 캐싱 LookUp 속도를 최적화했습니다
  • TOC/목차 가독성 증대 및 UX 개선: Next.js & SWR Infinite 구조의 실시간 무한 스크롤과 CSS Stacking Context React Portal 격리를 통해 모바일 환경에 기민한 부드러운 앱 스타일 UX를 완성했습니다

트러블 슈팅

1. 네이버 플레이스 리뷰 수집 누락 개선

  • 현상 및 원인: 특정 업종(글램핑, 수목원 등) 수집 시 네이버 예약 SPA 리다이렉트 Glitch와 dynamic scrollHeight 기반 뷰포트 레이지 로딩 미반응으로 인한 리뷰 수집 누락
  • 해결 방법: absolute 고정 픽셀 기반 점진적 뷰포트 스크롤 강제 기동 및 수집 데이터 개수가 0개일 때 가시적 '리뷰' 탭 로케이터를 클릭해 사후 검증하는 2차 재시도 로직을 탑재했습니다

2. Gemini API 429 무료 쿼터 초과 중단 개선

  • 현상 및 원인: 일 한도/RPM 초과 시 파이프라인 무한 로딩 대기 유발 및 메모리 가공 데이터 유실
  • 해결 방법: 수집(--scrape)과 AI분석(--analyze) 단계를 독립 실행 스레드로 분리하고, Quota 예외 포착 시 그 시점까지의 배치 가공 데이터만 디스크 파일로 실시간 머지(save_and_merge_results) 후 종료하는 Resume 체계로 개편했습니다

3. Geolocation 상태 엇박자로 인한 데이터 증발 개선

  • 현상 및 원인: F5 새로고침 시 GPS 위경도 정보 갱신에 의한 SWR refetch와 Spring Boot JPQL 비표준 LIKE 구문 파싱 에러가 맞물려 화면의 초기 렌더링 리스트가 싹 증발하는 현상
  • 해결 방법: JPQL 내 비표준 LIKE 절을 RDB 표준 LIKE CONCAT('%', :keyword, '%') 로 교정하고, React Context단에 위치 권한 승인 상태 가드(Guard)를 쳐서 좌표 갱신이 완료되기 전의 불완전한 SWR Fetching을 락(Lock) 차단했습니다

4. CSS Stacking Context 모달 레이아웃 왜곡 개선

  • 현상 및 원인: 이메일 인증 모달창의 부모 relative 포지션 z-index가 개별 쌓임 맥락을 생성해, 우측 본문 내에서만 딤드 오버레이가 작동하고 좌측 네비게이션바는 차단하지 못하는 현상
  • 해결 방법: React Portal(createPortal)을 도입하여 모달 오버레이 DOM을 document.body 직속 자식으로 최상단 격리하고, SSR document undefined 방지를 위해 클라이언트 마운트 가드를 설계해 적용했습니다

프로젝트 회고

픽플 프로젝트는 대용량 비정형 데이터를 유기적으로 수집하고, 가공하여 DB에 적재하는 풀스택 데이터 파이프라인의 설계부터 구현까지 깊이 있게 경험해 볼 수 있는 소중한 시간이었어요.

특히, 데이터 분석 효율성을 위해 백그라운드 멀티스레드 기반 Tkinter 데스크톱 앱을 구성하고, Gemini API 429 쿼터 한계와 네트워크 지연 및 동적 웹 라우팅 예외 상황을 극복하기 위한 예외 복구(Resume) 로직을 짜며 "실패 지점을 예측하여 스스로 복구 가능한 견고한 소프트웨어 아키텍처"를 지향하는 법을 배웠습니다.

또한 브라우저 캐시 및 토큰 상태를 로컬 저장소에서 지우고, Spring Security 엔터티에 직접 컬럼을 맵핑해 서버 DB화하고 1:N 세션 테이블로 전파시키는 과정을 통해 멀티 디바이스 동기화의 표준적인 구현 방식과 Next.js에서의 상태 엇박자를 제어하는 프론트 가드의 중요성을 깨달은 뜻깊은 이정표였습니다.

다른 프로젝트 둘러보기

이전 프로젝트바이브 코딩 공모전 - 아이노트