diff --git a/README.md b/README.md index 23e63cf..2cfefe1 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,6 @@ -# ๐ŸŒ„ Retrip - ์—ฌํ–‰์„ ์š”์•ฝํ•ด์ฃผ๋Š” ์ƒˆ๋กœ์šด ๋ฐฉ์‹์˜ SNS -- Retrip์€ ์—ฌํ–‰์„ ์ข‹์•„ํ•˜๋Š” ์‚ฌ๋žŒ๋“ค์„ ์œ„ํ•œ ์ด๋ฏธ์ง€ ๊ธฐ๋ฐ˜ ์—ฌํ–‰ ์š”์•ฝ SNS์ž…๋‹ˆ๋‹ค. ์‚ฌ์šฉ์ž๊ฐ€ ์—…๋กœ๋“œํ•œ ์—ฌํ–‰ ์‚ฌ์ง„ ์† ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ์™€ ์ด๋ฏธ์ง€ ์ •๋ณด๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ, ์—ฌํ–‰์˜ ์ „์ฒด์ ์ธ ๋ถ„์œ„๊ธฐ์™€ ์ •๋ณด๋ฅผ ํ•˜๋‚˜์˜ ์ด๋ฏธ์ง€๋กœ ์š”์•ฝํ•ด์ฃผ๋Š” ํŠน๋ณ„ํ•œ ์„œ๋น„์Šค๋ฅผ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. +# ๐ŸŒ„ ReTrip - ์—ฌํ–‰์„ ์š”์•ฝํ•ด์ฃผ๋Š” ์ƒˆ๋กœ์šด ๋ฐฉ์‹์˜ SNS -![readme_mockup2]() - -- ๋ฐฐํฌ URL : -- Test ID : -- Test PW : - -
+Retrip์€ ์—ฌํ–‰์„ ์ข‹์•„ํ•˜๋Š” ์‚ฌ๋žŒ๋“ค์„ ์œ„ํ•œ ์ด๋ฏธ์ง€ ๊ธฐ๋ฐ˜ ์—ฌํ–‰ ์š”์•ฝ SNS์ž…๋‹ˆ๋‹ค. ์‚ฌ์šฉ์ž๊ฐ€ ์—…๋กœ๋“œํ•œ ์—ฌํ–‰ ์‚ฌ์ง„ ์† ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ์™€ ์ด๋ฏธ์ง€ ์ •๋ณด๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ, ์—ฌํ–‰์˜ ์ „์ฒด์ ์ธ ๋ถ„์œ„๊ธฐ์™€ ์ •๋ณด๋ฅผ ํ•˜๋‚˜์˜ ์ด๋ฏธ์ง€๋กœ ์š”์•ฝํ•ด์ฃผ๋Š” ํŠน๋ณ„ํ•œ ์„œ๋น„์Šค๋ฅผ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. ## ํ”„๋กœ์ ํŠธ ์†Œ๊ฐœ @@ -31,30 +24,105 @@ ## 1. ๊ฐœ๋ฐœ ํ™˜๊ฒฝ -- Front : HTML, Vue3 -- Back-end : ์ œ๊ณต๋œ API ํ™œ์šฉ -- ๋ฒ„์ „ ๋ฐ ์ด์Šˆ๊ด€๋ฆฌ : Github, Github Issues, Github Project -- ํ˜‘์—… ํˆด : Discord, Notion -- ์„œ๋น„์Šค ๋ฐฐํฌ ํ™˜๊ฒฝ : +- **Front**: HTML, Vue3, Vuetify3 +- **Back-end**: Spring Boot 3.4.5, Java 21, Spring Data JPA, Spring Security +- **Database**: MySQL, Redis +- **AI/ML**: OpenAI GPT-4 Vision API +- **์ธํ”„๋ผ**: Docker, Nginx, AWS S3 +- **๋ฒ„์ „ ๋ฐ ์ด์Šˆ๊ด€๋ฆฌ**: Github, Github Issues, Github Project +- **ํ˜‘์—… ํˆด**: Discord, Notion +- **์„œ๋น„์Šค ๋ฐฐํฌ ํ™˜๊ฒฝ**: AWS EC2, Docker Compose
## 2. ์ฑ„ํƒํ•œ ๊ฐœ๋ฐœ ๊ธฐ์ˆ ๊ณผ ๋ธŒ๋žœ์น˜ ์ „๋žต +### ๊ธฐ์ˆ  ์Šคํƒ ์ƒ์„ธ + +#### Backend Framework +- **Spring Boot 3.4.5** (Java 21) - ์ตœ์‹  LTS ๋ฒ„์ „ ์‚ฌ์šฉ์œผ๋กœ ์•ˆ์ •์„ฑ ํ™•๋ณด +- **Spring Web MVC** - RESTful API ๊ตฌํ˜„ +- **Spring Data JPA** - ORM์„ ํ†ตํ•œ ํšจ์œจ์ ์ธ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๊ด€๋ฆฌ +- **Spring Security** - OAuth2 ๊ธฐ๋ฐ˜ ์†Œ์…œ ๋กœ๊ทธ์ธ (Kakao) + +#### AI & ์ด๋ฏธ์ง€ ์ฒ˜๋ฆฌ +- **OpenAI GPT-4 Vision API** - ์ด๋ฏธ์ง€ ๋ถ„์„ ๋ฐ ์—ฌํ–‰ ํŒจํ„ด ์ธ์‹ +- **metadata-extractor 2.18.0** - EXIF ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์ถ”์ถœ (GPS, ์‹œ๊ฐ„ ์ •๋ณด) +- **TwelveMonkeys ImageIO** - HEIF, HEIC ๋“ฑ ๋‹ค์–‘ํ•œ ์ด๋ฏธ์ง€ ํฌ๋งท ์ง€์› +- **Thumbnailator** - ์ด๋ฏธ์ง€ ๋ฆฌ์‚ฌ์ด์ง• ๋ฐ ์ตœ์ ํ™” + +#### ๋ชจ๋‹ˆํ„ฐ๋ง +- **Spring Actuator** - ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์ƒํƒœ ๋ชจ๋‹ˆํ„ฐ๋ง +- **Prometheus & Micrometer** - ๋ฉ”ํŠธ๋ฆญ ์ˆ˜์ง‘ ๋ฐ ๋ชจ๋‹ˆํ„ฐ๋ง + +### ๋ธŒ๋žœ์น˜ ์ „๋žต +- **main**: ํ”„๋กœ๋•์…˜ ๋ฐฐํฌ ๋ธŒ๋žœ์น˜ +- **dev**: ๊ฐœ๋ฐœ ํ†ตํ•ฉ ๋ธŒ๋žœ์น˜ +- **feature/๊ธฐ๋Šฅ๋ช…**: ๊ธฐ๋Šฅ ๊ฐœ๋ฐœ ๋ธŒ๋žœ์น˜ +- **refactor/๋ฆฌํŒฉํ† ๋ง๋ช…**: ๋ฆฌํŒฉํ† ๋ง ๋ธŒ๋žœ์น˜
## 3. ํ”„๋กœ์ ํŠธ ๊ตฌ์กฐ +``` +retrip-api/ +โ”œโ”€โ”€ src/main/java/ssafy/retrip/ +โ”‚ โ”œโ”€โ”€ api/ +โ”‚ โ”‚ โ”œโ”€โ”€ controller/ # REST API ์—”๋“œํฌ์ธํŠธ +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ retrip/ +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ RetripController.java +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ response/ # API ์‘๋‹ต DTO +โ”‚ โ”‚ โ””โ”€โ”€ service/ +โ”‚ โ”‚ โ”œโ”€โ”€ openai/ # OpenAI GPT ์—ฐ๋™ +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ GptImageAnalysisService.java +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ OpenAiClient.java +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ response/ # GPT ์‘๋‹ต ๋ชจ๋ธ +โ”‚ โ”‚ โ””โ”€โ”€ retrip/ # ํ•ต์‹ฌ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง +โ”‚ โ”‚ โ”œโ”€โ”€ RetripService.java +โ”‚ โ”‚ โ”œโ”€โ”€ ImageConverter.java +โ”‚ โ”‚ โ””โ”€โ”€ info/ # ๋„๋ฉ”์ธ ์ •๋ณด ๊ฐ์ฒด +โ”‚ โ”œโ”€โ”€ config/ # ์„ค์ • ํด๋ž˜์Šค +โ”‚ โ”‚ โ”œโ”€โ”€ SecurityConfig.java # Spring Security ์„ค์ • +โ”‚ โ”‚ โ”œโ”€โ”€ OpenAiConfig.java # OpenAI ํด๋ผ์ด์–ธํŠธ ์„ค์ • +โ”‚ โ”‚ โ”œโ”€โ”€ RedisConfig.java # Redis ์„ค์ • +โ”‚ โ”‚ โ””โ”€โ”€ WebConfig.java # CORS ์„ค์ • +โ”‚ โ”œโ”€โ”€ domain/ # JPA ์—”ํ‹ฐํ‹ฐ +โ”‚ โ”‚ โ”œโ”€โ”€ retrip/ +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ Retrip.java # ์—ฌํ–‰ ์š”์•ฝ ์—”ํ‹ฐํ‹ฐ +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ TimeSlot.java # ์‹œ๊ฐ„๋Œ€ Enum +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ RetripRepository.java +โ”‚ โ”‚ โ””โ”€โ”€ place/ +โ”‚ โ”‚ โ””โ”€โ”€ RecommendationPlace.java # ์ถ”์ฒœ ์žฅ์†Œ ์—”ํ‹ฐํ‹ฐ +โ”‚ โ””โ”€โ”€ utils/ # ์œ ํ‹ธ๋ฆฌํ‹ฐ ํด๋ž˜์Šค +โ”‚ โ”œโ”€โ”€ ImageMetaDataUtil.java # ์ด๋ฏธ์ง€ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์ถ”์ถœ +โ”‚ โ”œโ”€โ”€ CoordinateUtil.java # GPS ์ขŒํ‘œ ์ฒ˜๋ฆฌ +โ”‚ โ””โ”€โ”€ DistanceUtil.java # ๊ฑฐ๋ฆฌ ๊ณ„์‚ฐ +โ”œโ”€โ”€ docker/ # Docker ์„ค์ • +โ”œโ”€โ”€ nginx/ # Nginx ์„ค์ • (SSL, ๋ฆฌ๋ฒ„์Šค ํ”„๋ก์‹œ) +โ”œโ”€โ”€ scripts/ # ๋ฐฐํฌ ์Šคํฌ๋ฆฝํŠธ +โ””โ”€โ”€ src/main/resources/ + โ”œโ”€โ”€ application.yml # ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์„ค์ • + โ””โ”€โ”€ analysis.prompt # GPT ๋ถ„์„ ํ”„๋กฌํ”„ํŠธ +``` +
## 4. ์—ญํ•  ๋ถ„๋‹ด ### ๐Ÿ’๐Ÿปโ€โ™‚๏ธ ๊น€์šฉ๋ฒ” +- AI ๋ชจ๋ธ ์—ฐ๋™ ๋ฐ ํ”„๋กฌํ”„ํŠธ ์—”์ง€๋‹ˆ์–ด๋ง +- ์ด๋ฏธ์ง€ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์ถ”์ถœ ๋ฐ ์ฒ˜๋ฆฌ +- GPS ๊ธฐ๋ฐ˜ ์œ„์น˜ ๋ถ„์„ ๋กœ์ง ๊ตฌํ˜„ +- Docker ๋ฐ ์ธํ”„๋ผ ๊ตฌ์„ฑ
### ๐Ÿ’๐Ÿปโ€โ™‚๏ธ ์˜ค์ผ์šฐ +- Spring Boot ๋ฐฑ์—”๋“œ ์•„ํ‚คํ…์ฒ˜ ์„ค๊ณ„ +- RESTful API ๊ฐœ๋ฐœ +- ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์„ค๊ณ„ ๋ฐ JPA ๊ตฌํ˜„ +- Spring Security ๋ฐ OAuth2 ์ธ์ฆ ๊ตฌํ˜„
@@ -63,37 +131,146 @@ ### ๊ฐœ๋ฐœ ๊ธฐ๊ฐ„ - ์ „์ฒด ๊ฐœ๋ฐœ ๊ธฐ๊ฐ„ : 2025-04-28 ~ ing -- UI ๊ตฌํ˜„ : 2025-05-15 ~ ing -- ๊ธฐ๋Šฅ ๊ตฌํ˜„ : 2022-05-12 ~ ing +- ๋ฐฑ์—”๋“œ API ๊ฐœ๋ฐœ : 2025-04-28 ~ 2025-05-10 +- AI ๋ชจ๋ธ ์—ฐ๋™ : 2025-05-11 ~ 2025-05-20 +- ์ธํ”„๋ผ ๊ตฌ์ถ• : 2025-05-21 ~ 2025-05-25 +- ํ…Œ์ŠคํŠธ ๋ฐ ์ตœ์ ํ™” : 2025-05-26 ~ ing
### ์ž‘์—… ๊ด€๋ฆฌ +- Github Projects๋ฅผ ํ™œ์šฉํ•œ ์นธ๋ฐ˜ ๋ณด๋“œ ์šด์˜ +- ์ฃผ 2ํšŒ ์ •๊ธฐ ๋ฏธํŒ…์„ ํ†ตํ•œ ์ง„ํ–‰์ƒํ™ฉ ๊ณต์œ  +- Discord๋ฅผ ํ†ตํ•œ ์‹ค์‹œ๊ฐ„ ์ปค๋ฎค๋‹ˆ์ผ€์ด์…˜
## 6. ์‹ ๊ฒฝ ์“ด ๋ถ€๋ถ„ +### ๐ŸŽฏ ์ด๋ฏธ์ง€ ์ฒ˜๋ฆฌ ์ตœ์ ํ™” +- ๋Œ€์šฉ๋Ÿ‰ ์ด๋ฏธ์ง€ ์ฒ˜๋ฆฌ๋ฅผ ์œ„ํ•œ ๋ฉ”๋ชจ๋ฆฌ ํšจ์œจ์ ์ธ ๋ฆฌ์‚ฌ์ด์ง• +- HEIF/HEIC ๋“ฑ ์ตœ์‹  ์ด๋ฏธ์ง€ ํฌ๋งท ์ง€์› +- ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ๋ณด์กดํ•˜๋ฉด์„œ ์ด๋ฏธ์ง€ ํฌ๊ธฐ ์ตœ์ ํ™” + +### ๐Ÿค– AI ๋ถ„์„ ์ •ํ™•๋„ +- GPT-4 Vision ํ”„๋กฌํ”„ํŠธ ์ตœ์ ํ™”๋กœ ๋ถ„์„ ์ •ํ™•๋„ ํ–ฅ์ƒ +- ์ด๋ฏธ์ง€ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ์™€ AI ๋ถ„์„ ๊ฒฐ๊ณผ์˜ ๊ต์ฐจ ๊ฒ€์ฆ +- ์œ„์น˜ ์ •๋ณด ๊ธฐ๋ฐ˜ ๋งž์ถคํ˜• ์ถ”์ฒœ ์•Œ๊ณ ๋ฆฌ์ฆ˜ + +### ๐Ÿ”’ ๋ณด์•ˆ ๋ฐ ์•ˆ์ •์„ฑ +- Spring Security๋ฅผ ํ†ตํ•œ ์ธ์ฆ/์ธ๊ฐ€ ์ฒ˜๋ฆฌ +- ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ ์‹œ ์•…์„ฑ ํŒŒ์ผ ๊ฒ€์ฆ +- Rate Limiting์œผ๋กœ API ๋‚จ์šฉ ๋ฐฉ์ง€ + +### ๐Ÿ“Š ๋ชจ๋‹ˆํ„ฐ๋ง +- Prometheus + Grafana๋ฅผ ํ†ตํ•œ ์‹ค์‹œ๊ฐ„ ๋ชจ๋‹ˆํ„ฐ๋ง +- ์ฃผ์š” ๋น„์ฆˆ๋‹ˆ์Šค ๋ฉ”ํŠธ๋ฆญ ์ถ”์  +- ์—๋Ÿฌ ๋กœ๊น… ๋ฐ ์•Œ๋ฆผ ์‹œ์Šคํ…œ +
## 7. ํŽ˜์ด์ง€๋ณ„ ๊ธฐ๋Šฅ +### ๐Ÿ“ธ ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ ๋ฐ ๋ถ„์„ +- ๋‹ค์ค‘ ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ (5~20์žฅ) +- ์‹ค์‹œ๊ฐ„ ์—…๋กœ๋“œ ์ง„ํ–‰๋ฅ  ํ‘œ์‹œ +- ์ด๋ฏธ์ง€ ๋ฏธ๋ฆฌ๋ณด๊ธฐ ๋ฐ ์ˆœ์„œ ์กฐ์ • + +### ๐Ÿ—บ๏ธ ์—ฌํ–‰ ์š”์•ฝ ๊ฒฐ๊ณผ +- AI ๊ธฐ๋ฐ˜ ์—ฌํ–‰ ์Šคํƒ€์ผ ๋ถ„์„ (MBTI, ์—ฌํ–‰ ์„ฑํ–ฅ) +- ์‹œ๊ฐ์  ์—ฌํ–‰ ์š”์•ฝ ์นด๋“œ ์ƒ์„ฑ +- ์œ„์น˜ ๊ธฐ๋ฐ˜ ์ถ”์ฒœ ์žฅ์†Œ ์ œ๊ณต +- ์—ฌํ–‰ ํ†ต๊ณ„ (์ด๋™ ๊ฑฐ๋ฆฌ, ์ฃผ์š” ํ™œ๋™ ์‹œ๊ฐ„๋Œ€ ๋“ฑ) + +### ๐Ÿ“ฑ SNS ๊ณต์œ  +- ์ƒ์„ฑ๋œ ์š”์•ฝ ์ด๋ฏธ์ง€ ๋‹ค์šด๋กœ๋“œ +- ์ฃผ์š” SNS ํ”Œ๋žซํผ ๊ณต์œ  ๊ธฐ๋Šฅ +- ๊ณ ์œ  URL์„ ํ†ตํ•œ ๊ฒฐ๊ณผ ๊ณต์œ  +
## 8. ํŠธ๋Ÿฌ๋ธ” ์ŠˆํŒ… +### ๐Ÿ”ง ๋Œ€์šฉ๋Ÿ‰ ์ด๋ฏธ์ง€ ์ฒ˜๋ฆฌ ๋ฉ”๋ชจ๋ฆฌ ์ด์Šˆ +- **๋ฌธ์ œ**: ๊ณ ํ•ด์ƒ๋„ ์ด๋ฏธ์ง€ ๋‹ค๋Ÿ‰ ์—…๋กœ๋“œ ์‹œ OutOfMemoryError ๋ฐœ์ƒ +- **ํ•ด๊ฒฐ**: + - ImageIO ๋Œ€์‹  TwelveMonkeys ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ์‚ฌ์šฉ + - ์ŠคํŠธ๋ฆฌ๋ฐ ๋ฐฉ์‹์œผ๋กœ ์ด๋ฏธ์ง€ ์ฒ˜๋ฆฌ + - ์ ์ ˆํ•œ JVM ํž™ ๋ฉ”๋ชจ๋ฆฌ ์„ค์ • + +### ๐ŸŒ CORS ์ด์Šˆ +- **๋ฌธ์ œ**: ํ”„๋ก ํŠธ์—”๋“œ-๋ฐฑ์—”๋“œ ๊ฐ„ CORS ์ •์ฑ… ์œ„๋ฐ˜ +- **ํ•ด๊ฒฐ**: + - WebConfig์—์„œ ๋ช…์‹œ์  CORS ์„ค์ • + - ํ™˜๊ฒฝ๋ณ„ ํ—ˆ์šฉ Origin ๋ถ„๋ฆฌ ๊ด€๋ฆฌ + +### โšก GPT API ์‘๋‹ต ์‹œ๊ฐ„ ์ตœ์ ํ™” +- **๋ฌธ์ œ**: GPT-4 Vision API ์‘๋‹ต ์‹œ๊ฐ„์ด ๊ธธ์–ด UX ์ €ํ•˜ +- **ํ•ด๊ฒฐ**: + - ์ด๋ฏธ์ง€ ์‚ฌ์ „ ์••์ถ•์œผ๋กœ ์š”์ฒญ ํฌ๊ธฐ ๊ฐ์†Œ + - ๋น„๋™๊ธฐ ์ฒ˜๋ฆฌ ๋ฐ ์ง„ํ–‰ ์ƒํƒœ ํ‘œ์‹œ + - ์‘๋‹ต ์บ์‹ฑ์œผ๋กœ ์žฌ์š”์ฒญ ์‹œ ์„ฑ๋Šฅ ํ–ฅ์ƒ +
## 9. ๊ฐœ์„  ๋ชฉํ‘œ +- ๐Ÿš€ ์‹ค์‹œ๊ฐ„ ๋ถ„์„ ์ง„ํ–‰๋ฅ  ํ‘œ์‹œ (WebSocket) +- ๐ŸŒ ๋‹ค๊ตญ์–ด ์ง€์› ํ™•๋Œ€ +- ๐Ÿ“Š ๋” ์ •๊ตํ•œ ์—ฌํ–‰ ํŒจํ„ด ๋ถ„์„ +- ๐ŸŽจ ๋‹ค์–‘ํ•œ ์š”์•ฝ ํ…œํ”Œ๋ฆฟ ์ œ๊ณต +- ๐Ÿ‘ฅ ์†Œ์…œ ๊ธฐ๋Šฅ ๊ฐ•ํ™” (ํŒ”๋กœ์šฐ, ์ข‹์•„์š”, ๋Œ“๊ธ€) +- ๐Ÿ“ฑ ๋ชจ๋ฐ”์ผ ์•ฑ ๊ฐœ๋ฐœ +
## 10. ํ”„๋กœ์ ํŠธ ํ›„๊ธฐ -### ๐Ÿ’๐Ÿปโ€โ™‚๏ธย ๊น€์šฉ๋ฒ” +### ๐Ÿ’๐Ÿปโ€โ™‚๏ธ ๊น€์šฉ๋ฒ” +AI ๊ธฐ์ˆ ์„ ์‹ค์ œ ์„œ๋น„์Šค์— ์ ์šฉํ•˜๋ฉด์„œ ํ”„๋กฌํ”„ํŠธ ์—”์ง€๋‹ˆ์–ด๋ง์˜ ์ค‘์š”์„ฑ์„ ๊นจ๋‹ฌ์•˜์Šต๋‹ˆ๋‹ค. ํŠนํžˆ ์ด๋ฏธ์ง€ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ์™€ AI ๋ถ„์„์„ ๊ฒฐํ•ฉํ•˜์—ฌ ๋” ์ •ํ™•ํ•œ ๊ฒฐ๊ณผ๋ฅผ ๋„์ถœํ•˜๋Š” ๊ณผ์ •์ด ํฅ๋ฏธ๋กœ์› ์Šต๋‹ˆ๋‹ค. Docker์™€ ์ธํ”„๋ผ ๊ตฌ์„ฑ์„ ํ†ตํ•ด DevOps ์—ญ๋Ÿ‰๋„ ํ–ฅ์ƒ์‹œํ‚ฌ ์ˆ˜ ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค. +
+ +### ๐Ÿ’๐Ÿปโ€โ™‚๏ธ ์˜ค์ผ์šฐ +Spring Boot์™€ JPA๋ฅผ ํ™œ์šฉํ•œ ๋ฐฑ์—”๋“œ ๊ฐœ๋ฐœ ๊ฒฝํ—˜์„ ์Œ“์„ ์ˆ˜ ์žˆ์—ˆ๊ณ , ํŠนํžˆ ๋Œ€์šฉ๋Ÿ‰ ์ด๋ฏธ์ง€ ์ฒ˜๋ฆฌ์™€ ์„ฑ๋Šฅ ์ตœ์ ํ™” ๊ณผ์ •์—์„œ ๋งŽ์€ ๊ฒƒ์„ ๋ฐฐ์› ์Šต๋‹ˆ๋‹ค. OAuth2 ์ธ์ฆ๊ณผ ๋ณด์•ˆ ์„ค์ •์„ ๊ตฌํ˜„ํ•˜๋ฉด์„œ ์‹ค๋ฌด์—์„œ ํ•„์š”ํ•œ ๋ณด์•ˆ ์ง€์‹๋„ ์Šต๋“ํ•  ์ˆ˜ ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค.
-### ๐Ÿ’๐Ÿปโ€โ™‚๏ธย ์˜ค์ผ์šฐ +--- + +## ๐Ÿš€ Quick Start + +### ์š”๊ตฌ์‚ฌํ•ญ +- Java 21+ +- MySQL 8.0+ +- Redis 7.0+ +- Docker & Docker Compose (์„ ํƒ์‚ฌํ•ญ) + +### ๋กœ์ปฌ ํ™˜๊ฒฝ ์‹คํ–‰ +```bash +# 1. ํ”„๋กœ์ ํŠธ ํด๋ก  +git clone https://github.com/ReTrip-Dev/ReTrip-api.git +cd ReTrip-api + +# 2. application-secret.yml ์„ค์ • +# src/main/resources/application-secret.yml ํŒŒ์ผ ์ƒ์„ฑ ํ›„ ํ•„์š”ํ•œ ์„ค์ • ์ถ”๊ฐ€ + +# 3. ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์‹คํ–‰ +./gradlew bootRun +``` + +### API ํ…Œ์ŠคํŠธ +```bash +# ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ ๋ฐ ๋ถ„์„ +curl -X POST http://localhost:8080/api/images/uploads \ + -F "images=@photo1.jpg" \ + -F "images=@photo2.jpg" \ + -F "images=@photo3.jpg" \ + -F "images=@photo4.jpg" \ + -F "images=@photo5.jpg" +``` + +## ๐Ÿ“ž ๋ฌธ์˜ + +ํ”„๋กœ์ ํŠธ์— ๋Œ€ํ•œ ๋ฌธ์˜์‚ฌํ•ญ์€ [Issues](https://github.com/ReTrip-Dev/ReTrip-api/issues)๋ฅผ ํ†ตํ•ด ๋‚จ๊ฒจ์ฃผ์„ธ์š”. \ No newline at end of file diff --git a/build.gradle b/build.gradle index 40937b5..86ec98c 100644 --- a/build.gradle +++ b/build.gradle @@ -63,23 +63,18 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' - // AWS - ์ค‘๋ณต๋œ ์˜์กด์„ฑ ์ œ๊ฑฐ ๋ฐ ๊ฐ„์†Œํ™” - implementation platform('software.amazon.awssdk:bom:2.20.56') - implementation 'software.amazon.awssdk:s3' - implementation 'software.amazon.awssdk:url-connection-client' - - // EXIF/๋ฉ”ํƒ€๋ฐ์ดํ„ฐ - ์•ˆ์ •์ ์ธ ๋ฒ„์ „ ์‚ฌ์šฉ + // EXIF/๋ฉ”ํƒ€๋ฐ์ดํ„ฐ implementation 'com.drewnoakes:metadata-extractor:2.18.0' - // TwelveMonkeys ImageIO - ์•ˆ์ •์ ์ธ ๋ฒ„์ „ ์‚ฌ์šฉ + // TwelveMonkeys ImageIO implementation 'com.twelvemonkeys.imageio:imageio-core:3.9.4' implementation 'com.twelvemonkeys.imageio:imageio-jpeg:3.9.4' implementation 'com.twelvemonkeys.imageio:imageio-tiff:3.9.4' - // Apache Commons Imaging (์‹คํ—˜์  ์˜์กด์„ฑ ์ œ๊ฑฐ) + // Apache Commons Imaging implementation 'org.apache.commons:commons-imaging:1.0-alpha3' - // ์ด๋ฏธ์ง€ ์ฒ˜๋ฆฌ ๊ด€๋ จ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ - ์•ˆ์ •์ ์ธ ๋ฒ„์ „์œผ๋กœ ๋ณ€๊ฒฝ + // ์ด๋ฏธ์ง€ ์ฒ˜๋ฆฌ ๊ด€๋ จ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ implementation 'net.coobird:thumbnailator:0.4.19' // Monitoring @@ -87,8 +82,8 @@ dependencies { implementation 'io.micrometer:micrometer-registry-prometheus' // OpenAI ์—ฐ๋™ - implementation("com.openai:openai-java:2.8.1") // ๊ณต์‹ SDK - implementation("org.springframework.boot:spring-boot-starter-webflux") // SSE ์ŠคํŠธ๋ฆผ ์ „์†ก + implementation 'com.openai:openai-java:2.8.1' + } diff --git a/src/main/java/ssafy/retrip/api/controller/retrip/RetripController.java b/src/main/java/ssafy/retrip/api/controller/retrip/RetripController.java index c6838da..8d02784 100644 --- a/src/main/java/ssafy/retrip/api/controller/retrip/RetripController.java +++ b/src/main/java/ssafy/retrip/api/controller/retrip/RetripController.java @@ -3,7 +3,9 @@ import jakarta.servlet.http.HttpServletRequest; import java.io.IOException; import java.util.List; +import java.util.concurrent.CompletableFuture; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; @@ -14,6 +16,7 @@ import ssafy.retrip.api.controller.retrip.response.TravelAnalysisResponseDto; import ssafy.retrip.api.service.retrip.RetripService; +@Slf4j @RestController @RequiredArgsConstructor @RequestMapping("/api/images") @@ -22,14 +25,18 @@ public class RetripController { private final RetripService retripService; @PostMapping("/uploads") - public ResponseEntity uploadMultipleImages(HttpServletRequest request, + public CompletableFuture> uploadMultipleImages(HttpServletRequest request, @RequestParam("images") List images) throws IOException { - try { - return ResponseEntity.ok(retripService.createRetripFromImages(images)); - } catch (Exception e) { - e.printStackTrace(); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); - } + return retripService.createRetripFromImages(images) + .thenApply(result -> { + log.info("์—ฌํ–‰ ๋ถ„์„ ์™„๋ฃŒ: retripId={}", result.getRetripId()); + return ResponseEntity.ok(result); + }) + .exceptionally(ex -> { + log.error("์—ฌํ–‰ ๋ถ„์„ ์ฒ˜๋ฆฌ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ", ex); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(null); + }); } } diff --git a/src/main/java/ssafy/retrip/api/service/openai/GptImageAnalysisService.java b/src/main/java/ssafy/retrip/api/service/openai/GptImageAnalysisService.java index 015ff56..7f757a1 100644 --- a/src/main/java/ssafy/retrip/api/service/openai/GptImageAnalysisService.java +++ b/src/main/java/ssafy/retrip/api/service/openai/GptImageAnalysisService.java @@ -1,9 +1,11 @@ package ssafy.retrip.api.service.openai; import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PostConstruct; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.List; +import java.util.concurrent.CompletableFuture; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.core.io.Resource; @@ -35,7 +37,7 @@ public class GptImageAnalysisService { * * @throws RuntimeException ํ”„๋กฌํ”„ํŠธ ํŒŒ์ผ์„ ์ฐพ์„ ์ˆ˜ ์—†๊ฑฐ๋‚˜ ์ฝ๋Š” ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ•  ๊ฒฝ์šฐ */ - @jakarta.annotation.PostConstruct + @PostConstruct private void init() { try { Resource resource = resourceLoader.getResource("classpath:analysis.prompt"); @@ -46,34 +48,20 @@ private void init() { } } - /** - * ์ฃผ์–ด์ง„ ์ด๋ฏธ์ง€ ๋ฐ์ดํ„ฐ์™€ ์œ„์น˜ ์ •๋ณด๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ GPT์— ๋ถ„์„์„ ์š”์ฒญํ•˜๊ณ , ๊ทธ ๊ฒฐ๊ณผ๋ฅผ ํŒŒ์‹ฑํ•˜์—ฌ DTO๋กœ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. - * - * @param imageDataUrls ๋ถ„์„ํ•  ์ด๋ฏธ์ง€์˜ ๋ฐ์ดํ„ฐ URL ๋ชฉ๋ก (Base64 ์ธ์ฝ”๋”ฉ) - * @param latitude ์ฃผ์š” ์œ„์น˜์˜ ์œ„๋„ - * @param longitude ์ฃผ์š” ์œ„์น˜์˜ ๊ฒฝ๋„ - * @return GPT๊ฐ€ ๋ฐ˜ํ™˜ํ•œ ๋ถ„์„ ๊ฒฐ๊ณผ๋ฅผ ๋‹ด์€ {@link AnalysisResponse} ๊ฐ์ฒด - * @throws IllegalStateException GPT ์‘๋‹ต JSON์„ ํŒŒ์‹ฑํ•˜๋Š” ๋ฐ ์‹คํŒจํ•  ๊ฒฝ์šฐ - */ - public AnalysisResponse analyze(List imageDataUrls, double latitude, double longitude) { - // ํ”„๋กฌํ”„ํŠธ ํ…œํ”Œ๋ฆฟ์— ์œ„๋„, ๊ฒฝ๋„ ๊ฐ’ ์‚ฝ์ž… + public CompletableFuture analyze(List imageDataUrls, double latitude, double longitude) { String prompt = promptTemplate .replace(LATITUDE_PLACEHOLDER, String.format("%.6f", latitude)) .replace(LONGITUDE_PLACEHOLDER, String.format("%.6f", longitude)); - // OpenAI ํด๋ผ์ด์–ธํŠธ๋ฅผ ํ†ตํ•ด ๋ถ„์„ ์š”์ฒญ - String gptAnalysisJson = openAiClient.analyzeImages(prompt, imageDataUrls); - - // GPT๋กœ๋ถ€ํ„ฐ ๋ฐ›์€ ์›๋ณธ JSON ์‘๋‹ต์„ ๋กœ๊ทธ๋กœ ๊ธฐ๋ก (๋””๋ฒ„๊น…์šฉ) - log.info("GPT-4o๋กœ๋ถ€ํ„ฐ ๋ฐ›์€ ๋ถ„์„ ๊ฒฐ๊ณผ (JSON): {}", gptAnalysisJson); - - try { - // ๋ฐ˜ํ™˜๋œ JSON ๋ฌธ์ž์—ด์„ AnalysisResponse ๊ฐ์ฒด๋กœ ๋ณ€ํ™˜ - return objectMapper.readValue(gptAnalysisJson, AnalysisResponse.class); - } catch (Exception e) { - // JSON ํŒŒ์‹ฑ ์‹คํŒจ ์‹œ, ์›๋ณธ ์‘๋‹ต๊ณผ ํ•จ๊ป˜ ์—๋Ÿฌ ๋กœ๊ทธ ๊ธฐ๋ก - log.error("GPT ์‘๋‹ต JSON ํŒŒ์‹ฑ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ. Response: {}", gptAnalysisJson, e); - throw new IllegalStateException("GPT ๋ถ„์„ ๊ฒฐ๊ณผ๋ฅผ ํŒŒ์‹ฑํ•˜๋Š” ๋ฐ ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.", e); - } + return openAiClient.analyzeImages(prompt, imageDataUrls) + .thenApply(gptAnalysisJson -> { + log.info("GPT-4o๋กœ๋ถ€ํ„ฐ ๋ฐ›์€ ๋ถ„์„ ๊ฒฐ๊ณผ (JSON): {}", gptAnalysisJson); + try { + return objectMapper.readValue(gptAnalysisJson, AnalysisResponse.class); + } catch (Exception e) { + log.error("GPT ์‘๋‹ต JSON ํŒŒ์‹ฑ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ. Response: {}", gptAnalysisJson, e); + throw new IllegalStateException("GPT ๋ถ„์„ ๊ฒฐ๊ณผ๋ฅผ ํŒŒ์‹ฑํ•˜๋Š” ๋ฐ ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.", e); + } + }); } } \ No newline at end of file diff --git a/src/main/java/ssafy/retrip/api/service/openai/OpenAiClient.java b/src/main/java/ssafy/retrip/api/service/openai/OpenAiClient.java index dd2e9eb..f948fb0 100644 --- a/src/main/java/ssafy/retrip/api/service/openai/OpenAiClient.java +++ b/src/main/java/ssafy/retrip/api/service/openai/OpenAiClient.java @@ -2,13 +2,16 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.LocalDateTime; import java.util.List; import java.util.Map; +import java.util.concurrent.CompletableFuture; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; +import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; @@ -29,18 +32,20 @@ public class OpenAiClient { @Value("${openai.model}") private String model; - /** - * GPT-4o์— ์ด๋ฏธ์ง€์™€ ํ”„๋กฌํ”„ํŠธ๋ฅผ ์ „์†กํ•˜์—ฌ ๋ถ„์„ ๊ฒฐ๊ณผ๋ฅผ JSON ํ˜•ํƒœ๋กœ ๋ฐ˜ํ™˜ - */ - public String analyzeImages(String prompt, List imageDataUrls) { + @Async("openAiExecutor") + public CompletableFuture analyzeImages(String prompt, List imageDataUrls) { + long startTime = System.currentTimeMillis(); + String threadName = Thread.currentThread().getName(); + + log.info("OpenAI API ์š”์ฒญ ์‹œ์ž‘ - Thread: {}, ์‹œ์ž‘์‹œ๊ฐ„: {}", + threadName, LocalDateTime.now()); + try { - // ๋ฉ”์‹œ์ง€ ๊ตฌ์„ฑ Map message = Map.of( "role", "user", "content", buildContent(prompt, imageDataUrls) ); - // API ์š”์ฒญ ๋ณธ๋ฌธ ๊ตฌ์„ฑ Map requestBody = Map.of( "model", model, "messages", List.of(message), @@ -48,7 +53,6 @@ public String analyzeImages(String prompt, List imageDataUrls) { "response_format", Map.of("type", "json_object") ); - // API ํ˜ธ์ถœ String response = restClient.post() .uri(apiUrl + "/chat/completions") .header(HttpHeaders.AUTHORIZATION, "Bearer " + apiKey) @@ -57,26 +61,32 @@ public String analyzeImages(String prompt, List imageDataUrls) { .retrieve() .body(String.class); - // ์‘๋‹ต์—์„œ ์ฝ˜ํ…์ธ  ์ถ”์ถœ JsonNode jsonResponse = objectMapper.readTree(response); - return jsonResponse.path("choices").path(0).path("message").path("content").asText(); + long endTime = System.currentTimeMillis(); + long executionTime = endTime - startTime; + + log.info("OpenAI API ์š”์ฒญ ์™„๋ฃŒ - Thread: {}, ์ข…๋ฃŒ์‹œ๊ฐ„: {}, ์‹คํ–‰์‹œ๊ฐ„: {}ms", + threadName, LocalDateTime.now(), executionTime); + + String result = jsonResponse.path("choices").path(0).path("message").path("content").asText(); + + return CompletableFuture.completedFuture(result); + } catch (Exception e) { - log.error("OpenAI API ํ˜ธ์ถœ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ", e); - throw new RuntimeException("์ด๋ฏธ์ง€ ๋ถ„์„ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: " + e.getMessage()); + log.error("[{}] GPT ํ˜ธ์ถœ ์‹คํŒจ", threadName, e); + return CompletableFuture.failedFuture( + new RuntimeException("GPT API ํ˜ธ์ถœ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: " + e.getMessage(), e) + ); } } - /** - * OpenAI API ์š”์ฒญ์— ํ•„์š”ํ•œ ์ฝ˜ํ…์ธ  ๊ตฌ์„ฑ - */ + private List> buildContent(String prompt, List imageDataUrls) { - // ํ…์ŠคํŠธ ํ”„๋กฌํ”„ํŠธ Map textContent = Map.of( "type", "text", "text", prompt ); - // ์ด๋ฏธ์ง€ ์ฝ˜ํ…์ธ  ๊ตฌ์„ฑ List> contents = imageDataUrls.stream() .map(url -> Map.of( "type", "image_url", @@ -84,7 +94,6 @@ private List> buildContent(String prompt, List image )) .collect(java.util.stream.Collectors.toList()); - // ํ…์ŠคํŠธ ํ”„๋กฌํ”„ํŠธ๋ฅผ ๋งจ ์•ž์— ์ถ”๊ฐ€ contents.add(0, textContent); return contents; } diff --git a/src/main/java/ssafy/retrip/api/service/retrip/RetripPersistenceService.java b/src/main/java/ssafy/retrip/api/service/retrip/RetripPersistenceService.java new file mode 100644 index 0000000..090867a --- /dev/null +++ b/src/main/java/ssafy/retrip/api/service/retrip/RetripPersistenceService.java @@ -0,0 +1,132 @@ +package ssafy.retrip.api.service.retrip; + +import static ssafy.retrip.domain.retrip.TimeSlot.AFTERNOON; +import static ssafy.retrip.utils.CoordinateUtil.analyzeMainLocation; +import static ssafy.retrip.utils.DateUtil.findEarliestTakenDate; +import static ssafy.retrip.utils.DateUtil.findLatestTakenDate; +import static ssafy.retrip.utils.DistanceUtil.calculateTotalDistance; + +import java.time.LocalDateTime; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import ssafy.retrip.api.controller.retrip.response.TravelAnalysisResponseDto; +import ssafy.retrip.api.service.openai.response.AnalysisResponse; +import ssafy.retrip.api.service.openai.response.AnalysisResponse.Recommendation; +import ssafy.retrip.api.service.retrip.info.ImageMetaData; +import ssafy.retrip.domain.place.RecommendationPlace; +import ssafy.retrip.domain.retrip.Retrip; +import ssafy.retrip.domain.retrip.RetripRepository; +import ssafy.retrip.domain.retrip.TimeSlot; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class RetripPersistenceService { + + public static final String DEFAULT_USERNAME = "์—ฌํ–‰์ž๋‹˜"; + private final RetripRepository retripRepository; + + @Transactional + public TravelAnalysisResponseDto saveRetrip(AnalysisResponse analysisResponse, List allMetadata) { + Retrip retrip = buildRetripFromAnalysis(analysisResponse); + updateRetripDetailsFromMetadata(retrip, allMetadata); + Retrip savedRetrip = retripRepository.save(retrip); + return buildTravelAnalysisResponseDto(savedRetrip, analysisResponse); + } + + private Retrip buildRetripFromAnalysis(AnalysisResponse analysis) { + AnalysisResponse.User user = analysis.getUser(); + AnalysisResponse.TripSummary summary = analysis.getTripSummary(); + AnalysisResponse.PhotoStats stats = analysis.getPhotoStats(); + + if (user == null || summary == null || stats == null) { + throw new IllegalStateException("GPT ๋ถ„์„ ๊ฒฐ๊ณผ์˜ ์„ธ๋ถ€ ์ •๋ณด(์‚ฌ์šฉ์ž, ์š”์•ฝ, ํ†ต๊ณ„)๊ฐ€ ๋ˆ„๋ฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."); + } + + AnalysisResponse.EgenTeto egenTeto = user.getEgenTeto(); + String egenTetoType = egenTeto != null ? egenTeto.getType() : null; + String egenTetoSubtype = egenTeto != null ? egenTeto.getSubtype() : null; + String egenTetoHashtag = egenTeto != null ? egenTeto.getHashtag() : null; + + Retrip retrip = Retrip.builder() + .countryCode(user.getCountryCode()) + .mbti(user.getMbti()) + .egenTetoType(egenTetoType) + .egenTetoSubtype(egenTetoSubtype) + .egenTetoHashtag(egenTetoHashtag) + .summaryLine(summary.getSummaryLine()) + .keywords(String.join(",", summary.getKeywords())) + .hashtag(summary.getHashtag()) + .favoriteSubjects(String.join(",", stats.getFavoriteSubjects())) + .favoritePhotoSpot(stats.getFavoritePhotoSpot()) + .build(); + + List recommendationDtos = analysis.getRecommendations(); + if (recommendationDtos != null) { + for (Recommendation dto : recommendationDtos) { + retrip.addRecommendation(RecommendationPlace.builder() + .emoji(dto.getEmoji()) + .place(dto.getPlace()) + .description(dto.getDescription()) + .build() + ); + } + } + + return retrip; + } + + public void updateRetripDetailsFromMetadata(Retrip retrip, List metadataList) { + if (metadataList == null || metadataList.isEmpty()) { + return; + } + metadataList.sort(Comparator.comparing(ImageMetaData::getTakenDate, + Comparator.nullsLast(Comparator.naturalOrder()))); + + LocalDateTime startDate = findEarliestTakenDate(metadataList); + LocalDateTime endDate = findLatestTakenDate(metadataList); + Map mainLocationInfo = analyzeMainLocation(metadataList); + + retrip.updateRetripDetailsData( + startDate, + endDate, + metadataList.size(), + calculateTotalDistance(metadataList), + analyzeMainTimeSlot(metadataList), + (Double) mainLocationInfo.get("latitude"), + (Double) mainLocationInfo.get("longitude") + ); + } + + private TravelAnalysisResponseDto buildTravelAnalysisResponseDto( + Retrip retrip, + AnalysisResponse analysis + ) { + return TravelAnalysisResponseDto.from( + retrip.getId(), + analysis, + retrip, + DEFAULT_USERNAME + ); + } + + private TimeSlot analyzeMainTimeSlot(List metadataList) { + if (metadataList.stream().allMatch(m -> m.getTakenDate() == null)) { + return AFTERNOON; + } + return metadataList.stream() + .map(ImageMetaData::getTakenDate) + .filter(Objects::nonNull) + .collect(Collectors.groupingBy(TimeSlot::from, Collectors.counting())) + .entrySet().stream() + .max(Map.Entry.comparingByValue()) + .map(Map.Entry::getKey) + .orElse(AFTERNOON); + } +} \ No newline at end of file diff --git a/src/main/java/ssafy/retrip/api/service/retrip/RetripService.java b/src/main/java/ssafy/retrip/api/service/retrip/RetripService.java index d9cf96b..e78927b 100644 --- a/src/main/java/ssafy/retrip/api/service/retrip/RetripService.java +++ b/src/main/java/ssafy/retrip/api/service/retrip/RetripService.java @@ -1,47 +1,29 @@ package ssafy.retrip.api.service.retrip; -import static ssafy.retrip.domain.retrip.TimeSlot.*; -import static ssafy.retrip.utils.CoordinateUtil.analyzeMainLocation; import static ssafy.retrip.utils.CoordinateUtil.calculateAverageCoordinates; -import static ssafy.retrip.utils.DateUtil.findEarliestTakenDate; -import static ssafy.retrip.utils.DateUtil.findLatestTakenDate; -import static ssafy.retrip.utils.DistanceUtil.calculateTotalDistance; import static ssafy.retrip.utils.ImageMetaDataUtil.extractMetadata; -import java.time.LocalDateTime; import java.util.ArrayList; -import java.util.Comparator; import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.stream.Collectors; +import java.util.concurrent.CompletableFuture; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; import ssafy.retrip.api.controller.retrip.response.TravelAnalysisResponseDto; import ssafy.retrip.api.service.openai.GptImageAnalysisService; -import ssafy.retrip.api.service.openai.response.AnalysisResponse; -import ssafy.retrip.api.service.openai.response.AnalysisResponse.Recommendation; import ssafy.retrip.api.service.retrip.info.GpsCoordinate; import ssafy.retrip.api.service.retrip.info.ImageMetaData; -import ssafy.retrip.domain.place.RecommendationPlace; -import ssafy.retrip.domain.retrip.Retrip; -import ssafy.retrip.domain.retrip.RetripRepository; -import ssafy.retrip.domain.retrip.TimeSlot; @Slf4j @Service @RequiredArgsConstructor public class RetripService { - public static final String DEFAULT_USERNAME = "์—ฌํ–‰์ž๋‹˜"; - - private final RetripRepository retripRepository; private final ImageConverter imageConverter; private final GptImageAnalysisService gptImageAnalysisService; + private final RetripPersistenceService retripPersistenceService; @Value("${retrip.image.min-count}") private int minImageCount; @@ -49,8 +31,7 @@ public class RetripService { @Value("${retrip.image.max-count}") private int maxImageCount; - @Transactional - public TravelAnalysisResponseDto createRetripFromImages(List images) { + public CompletableFuture createRetripFromImages(List images) { validateMinimumImageCount(images); @@ -61,19 +42,21 @@ public TravelAnalysisResponseDto createRetripFromImages(List imag prepareImageForProcessing(images, allMetadata, imageDataUrlsForGpt); GpsCoordinate averageCoords = calculateAverageCoordinates(allMetadata); - AnalysisResponse analysisResponse = gptImageAnalysisService - .analyze(imageDataUrlsForGpt, averageCoords.latitude(), averageCoords.longitude()); - - try { - Retrip retrip = buildRetripFromAnalysis(analysisResponse); - updateRetripDetailsFromMetadata(retrip, allMetadata); - Retrip savedRetrip = retripRepository.save(retrip); - - return buildTravelAnalysisResponseDto(savedRetrip, analysisResponse); - } catch (Exception e) { - log.error("GPT ๋ถ„์„ ๊ฒฐ๊ณผ ์ฒ˜๋ฆฌ ๋ฐ Retrip ์ƒ์„ฑ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ", e); - throw new IllegalStateException("์—ฌํ–‰ ๊ธฐ๋ก ์ƒ์„ฑ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.", e); - } + + return gptImageAnalysisService.analyze(imageDataUrlsForGpt, averageCoords.latitude(), averageCoords.longitude()) + .thenApply(analysisResponse -> { + try { + log.info("GPT ๋ถ„์„ ์™„๋ฃŒ, Retrip ์ €์žฅ ์‹œ์ž‘"); + return retripPersistenceService.saveRetrip(analysisResponse, allMetadata); + } catch (Exception e) { + log.error("GPT ๋ถ„์„ ๊ฒฐ๊ณผ ์ฒ˜๋ฆฌ ๋ฐ Retrip ์ƒ์„ฑ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ", e); + throw new RuntimeException("์—ฌํ–‰ ๊ธฐ๋ก ์ƒ์„ฑ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: " + e.getMessage(), e); + } + }) + .exceptionally(throwable -> { + log.error("์ „์ฒด ์ฒ˜๋ฆฌ ๊ณผ์ •์—์„œ ์˜ค๋ฅ˜ ๋ฐœ์ƒ", throwable); + throw new RuntimeException("์—ฌํ–‰ ๋ถ„์„ ์ฒ˜๋ฆฌ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: " + throwable.getMessage(), throwable); + }); } private void validateMinimumImageCount(List images) { @@ -108,94 +91,4 @@ private void prepareImageForProcessing( throw new IllegalStateException("์œ ํšจํ•œ ์ด๋ฏธ์ง€๊ฐ€ " + minImageCount + "์žฅ ๋ฏธ๋งŒ์ž…๋‹ˆ๋‹ค."); } } - - private Retrip buildRetripFromAnalysis(AnalysisResponse analysis) { - AnalysisResponse.User user = analysis.getUser(); - AnalysisResponse.TripSummary summary = analysis.getTripSummary(); - AnalysisResponse.PhotoStats stats = analysis.getPhotoStats(); - - if (user == null || summary == null || stats == null) { - throw new IllegalStateException("GPT ๋ถ„์„ ๊ฒฐ๊ณผ์˜ ์„ธ๋ถ€ ์ •๋ณด(์‚ฌ์šฉ์ž, ์š”์•ฝ, ํ†ต๊ณ„)๊ฐ€ ๋ˆ„๋ฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."); - } - - AnalysisResponse.EgenTeto egenTeto = user.getEgenTeto(); - String egenTetoType = egenTeto != null ? egenTeto.getType() : null; - String egenTetoSubtype = egenTeto != null ? egenTeto.getSubtype() : null; - String egenTetoHashtag = egenTeto != null ? egenTeto.getHashtag() : null; - - Retrip retrip = Retrip.builder() - .countryCode(user.getCountryCode()) - .mbti(user.getMbti()) - .egenTetoType(egenTetoType) - .egenTetoSubtype(egenTetoSubtype) - .egenTetoHashtag(egenTetoHashtag) - .summaryLine(summary.getSummaryLine()) - .keywords(String.join(",", summary.getKeywords())) - .hashtag(summary.getHashtag()) - .favoriteSubjects(String.join(",", stats.getFavoriteSubjects())) - .favoritePhotoSpot(stats.getFavoritePhotoSpot()) - .build(); - - List recommendationDtos = analysis.getRecommendations(); - if (recommendationDtos != null) { - for (Recommendation dto : recommendationDtos) { - retrip.addRecommendation(RecommendationPlace.builder() - .emoji(dto.getEmoji()) - .place(dto.getPlace()) - .description(dto.getDescription()) - .build() - ); - } - } - - return retrip; - } - - private TravelAnalysisResponseDto buildTravelAnalysisResponseDto( - Retrip retrip, - AnalysisResponse analysis - ) { - return TravelAnalysisResponseDto.from( - retrip.getId(), - analysis, - retrip, - DEFAULT_USERNAME - ); - } - - private TimeSlot analyzeMainTimeSlot(List metadataList) { - if (metadataList.stream().allMatch(m -> m.getTakenDate() == null)) { - return AFTERNOON; - } - return metadataList.stream() - .map(ImageMetaData::getTakenDate) - .filter(Objects::nonNull) - .collect(Collectors.groupingBy(TimeSlot::from, Collectors.counting())) - .entrySet().stream() - .max(Map.Entry.comparingByValue()) - .map(Map.Entry::getKey) - .orElse(AFTERNOON); - } - - public void updateRetripDetailsFromMetadata(Retrip retrip, List metadataList) { - if (metadataList == null || metadataList.isEmpty()) { - return; - } - metadataList.sort(Comparator.comparing(ImageMetaData::getTakenDate, - Comparator.nullsLast(Comparator.naturalOrder()))); - - LocalDateTime startDate = findEarliestTakenDate(metadataList); - LocalDateTime endDate = findLatestTakenDate(metadataList); - Map mainLocationInfo = analyzeMainLocation(metadataList); - - retrip.updateRetripDetailsData( - startDate, - endDate, - metadataList.size(), - calculateTotalDistance(metadataList), - analyzeMainTimeSlot(metadataList), - (Double) mainLocationInfo.get("latitude"), - (Double) mainLocationInfo.get("longitude") - ); - } } \ No newline at end of file diff --git a/src/main/java/ssafy/retrip/config/AsyncConfig.java b/src/main/java/ssafy/retrip/config/AsyncConfig.java new file mode 100644 index 0000000..1e1d242 --- /dev/null +++ b/src/main/java/ssafy/retrip/config/AsyncConfig.java @@ -0,0 +1,25 @@ +package ssafy.retrip.config; + +import java.util.concurrent.Executor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import ssafy.retrip.handler.CustomRejectedExecutionHandler; + +@EnableAsync +@Configuration +public class AsyncConfig { + + @Bean(name = "openAiExecutor") + public Executor openAiExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(20); + executor.setMaxPoolSize(50); + executor.setQueueCapacity(100); + executor.setThreadNamePrefix("openAiExecutor-"); + executor.setRejectedExecutionHandler(new CustomRejectedExecutionHandler()); + executor.initialize(); + return executor; + } +} diff --git a/src/main/java/ssafy/retrip/handler/CustomRejectedExecutionHandler.java b/src/main/java/ssafy/retrip/handler/CustomRejectedExecutionHandler.java new file mode 100644 index 0000000..9ef2d4b --- /dev/null +++ b/src/main/java/ssafy/retrip/handler/CustomRejectedExecutionHandler.java @@ -0,0 +1,24 @@ +package ssafy.retrip.handler; + +import java.util.concurrent.RejectedExecutionHandler; +import java.util.concurrent.ThreadPoolExecutor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class CustomRejectedExecutionHandler implements RejectedExecutionHandler { + + @Override + public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) { + log.warn("์Šค๋ ˆ๋“œ ํ’€ ํฌํ™”: ์ž‘์—… ๊ฑฐ๋ถ€๋จ. ActiveThreads={}, QueueSize={}", + executor.getActiveCount(), executor.getQueue().size()); + + if (!executor.isShutdown()) { + try { + log.info("ํ˜ธ์ถœ ์Šค๋ ˆ๋“œ์—์„œ ์ž‘์—… ์‹คํ–‰ ์žฌ์‹œ๋„"); + r.run(); + } catch (Exception e) { + log.error("๊ฑฐ๋ถ€๋œ ์ž‘์—… ์žฌ์‹คํ–‰ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ", e); + } + } + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 8d917f1..674d7b2 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -10,7 +10,7 @@ spring: servlet: multipart: max-file-size: 30MB - max-request-size: 50MB + max-request-size: 250MB # prometheus & actuator management: @@ -49,7 +49,7 @@ server: openai: api-key: ${openai.api-key} - model: gpt-4o-mini + model: gpt-4.1-mini --- spring: