feat(dev): dev 프로파일 테스트 데이터 시더
CI / build (push) Failing after 12m15s
Deploy (dev) / deploy (push) Failing after 11m26s

스팟6·유저/강아지(견종·성향)·요일/시간대 분산 체크인 이력(자주오는견종용)·
현재 활성 체크인(메이트/AI, Redis)·한줄평 시드. idempotent(스팟 있으면 skip),
실패해도 기동 막지 않음(try/catch). prod 는 미실행이라 DB 깨끗.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
sb
2026-07-11 19:28:27 +09:00
parent ceba15df42
commit 96b415dd09
@@ -0,0 +1,225 @@
package com.dog.dognation.config;
import com.dog.dognation.domain.spot.SpotService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.core.annotation.Order;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.Set;
/**
* dev 프로파일 전용 테스트 데이터 시더 (PostgreSQL).
* 스팟/유저/강아지/성향, 요일·시간대에 분산된 체크인 이력(통계·자주오는견종용),
* 현재 활성 체크인(메이트/AI), 한줄평을 채운다.
* - idempotent: 스팟이 이미 있으면 건너뜀
* - 시더 실패가 앱 기동을 막지 않도록 전체 try/catch
* (prod 는 이 시더가 실행되지 않아 DB 가 깨끗하게 유지된다.)
*/
@Component
@Profile("dev")
@Order(100)
public class DevSeedData implements CommandLineRunner {
private static final Logger log = LoggerFactory.getLogger(DevSeedData.class);
private final JdbcTemplate jdbc;
private final SpotService spotService;
public DevSeedData(JdbcTemplate jdbc, SpotService spotService) {
this.jdbc = jdbc;
this.spotService = spotService;
}
private record SpotDef(String name, String type, double lat, double lng, String[] pref) {
}
@Override
public void run(String... args) {
try {
Long spotCount = jdbc.queryForObject("SELECT count(*) FROM spots", Long.class);
if (spotCount != null && spotCount > 0) {
log.info("[dev seed] 스팟 {}개 이미 존재 — 시드 건너뜀", spotCount);
return;
}
seed();
} catch (Exception e) {
log.warn("[dev seed] 실패(무시하고 기동): {}", e.toString());
}
}
private void seed() {
Random rnd = new Random(20260711L);
// 1) 스팟 (스팟별 단골 견종 pref → 자주오는견종 패턴 형성)
List<SpotDef> spotDefs = List.of(
new SpotDef("여의도 한강공원", "PARK", 37.5285, 126.9327,
new String[]{"말티즈", "푸들", "포메라니안", "비숑프리제"}),
new SpotDef("서울숲 반려견 놀이터", "PLAYGROUND", 37.5445, 127.0374,
new String[]{"리트리버", "웰시코기", "시바견", "보더콜리"}),
new SpotDef("올림픽공원", "PARK", 37.5202, 127.1214,
new String[]{"진돗개", "리트리버", "셰퍼드", "시바견"}),
new SpotDef("양재천 산책로", "TRAIL", 37.4844, 127.0430,
new String[]{"푸들", "시츄", "치와와", "말티즈"}),
new SpotDef("경의선숲길", "PARK", 37.5590, 126.9250,
new String[]{"비숑프리제", "포메라니안", "웰시코기", "푸들"}),
new SpotDef("보라매공원", "PARK", 37.4926, 126.9200,
new String[]{"진돗개", "리트리버", "시바견", "웰시코기"})
);
long[] spotIds = new long[spotDefs.size()];
for (int i = 0; i < spotDefs.size(); i++) {
SpotDef s = spotDefs.get(i);
spotIds[i] = insertSpot(s.name(), s.type(), s.lat(), s.lng());
}
// 2) 견종 → 크기
Map<String, String> sizeByBreed = new HashMap<>();
sizeByBreed.put("말티즈", "SMALL");
sizeByBreed.put("푸들", "SMALL");
sizeByBreed.put("포메라니안", "SMALL");
sizeByBreed.put("치와와", "SMALL");
sizeByBreed.put("시츄", "SMALL");
sizeByBreed.put("비숑프리제", "SMALL");
sizeByBreed.put("웰시코기", "MEDIUM");
sizeByBreed.put("시바견", "MEDIUM");
sizeByBreed.put("보더콜리", "MEDIUM");
sizeByBreed.put("진돗개", "MEDIUM");
sizeByBreed.put("리트리버", "LARGE");
sizeByBreed.put("셰퍼드", "LARGE");
String[] allBreeds = sizeByBreed.keySet().toArray(new String[0]);
String[] dogNames = {"뭉치", "초코", "콩이", "보리", "별이", "구름", "두부", "코코", "모카", "루비",
"탄이", "감자", "밤이", "호두", "장군", "복실", "나비", "단비", "봄이", "여름"};
List<Long> traitIds = jdbc.queryForList("SELECT id FROM trait_tags", Long.class);
// 3) 유저 + 강아지 (견종별 3~5마리) → 견종별 dogId 풀
Map<String, List<Long>> dogsByBreed = new HashMap<>();
Map<Long, Long> ownerByDog = new HashMap<>();
List<Long> allUserIds = new ArrayList<>();
int idx = 0;
for (String breed : allBreeds) {
int per = 3 + rnd.nextInt(3);
for (int k = 0; k < per; k++) {
String tier = idx % 6 == 0 ? "PREMIUM" : (idx % 6 == 1 ? "AD_FREE" : "FREE");
long uid = insertUser("dev" + idx + "@dog.com", "이웃" + idx, tier);
long dogId = insertDog(uid, dogNames[idx % dogNames.length], breed,
sizeByBreed.get(breed), rnd.nextBoolean() ? "MALE" : "FEMALE");
if (traitIds.size() >= 2) {
int a = rnd.nextInt(traitIds.size());
int b = (a + 1 + rnd.nextInt(traitIds.size() - 1)) % traitIds.size();
linkTrait(dogId, traitIds.get(a));
linkTrait(dogId, traitIds.get(b));
}
dogsByBreed.computeIfAbsent(breed, x -> new ArrayList<>()).add(dogId);
ownerByDog.put(dogId, uid);
allUserIds.add(uid);
idx++;
}
}
// 4) 체크인 이력 (통계용) — 최근 75일, 산책 시간대(6~21시) 분산 배치
OffsetDateTime now = OffsetDateTime.now();
List<Object[]> history = new ArrayList<>();
for (int si = 0; si < spotIds.length; si++) {
long spot = spotIds[si];
String[] pref = spotDefs.get(si).pref();
for (int d = 0; d < 75; d++) {
int perDay = 4 + rnd.nextInt(5); // 하루 4~8건
for (int k = 0; k < perDay; k++) {
int hour = 6 + rnd.nextInt(16);
OffsetDateTime t = now.minusDays(d)
.withHour(hour).withMinute(rnd.nextInt(60)).withSecond(0).withNano(0);
long dogId = pickDog(rnd, dogsByBreed, pref, allBreeds);
history.add(new Object[]{spot, ownerByDog.get(dogId), dogId, t, t.plusHours(2)});
}
}
}
jdbc.batchUpdate(
"INSERT INTO check_ins(spot_id, user_id, dog_id, checked_in_at, expires_at) VALUES (?, ?, ?, ?, ?)",
history);
// 5) 현재 활성 체크인 (Redis + Postgres) — 스팟별 2~4마리. spotService.checkIn 이 Redis 도 채움
for (int si = 0; si < spotIds.length; si++) {
long spot = spotIds[si];
String[] pref = spotDefs.get(si).pref();
int n = 2 + rnd.nextInt(3);
Set<Long> used = new HashSet<>();
for (int k = 0; k < n; k++) {
long dogId = pickDog(rnd, dogsByBreed, pref, allBreeds);
if (!used.add(dogId)) {
continue;
}
spotService.checkIn(spot, ownerByDog.get(dogId), dogId);
}
}
// 6) 한줄평
String[] tags = {"물그릇 있음", "그늘 시원해요", "진드기 많아요", "배변봉투함 있음",
"야간 조명 밝음", "잔디 상태 좋음", "주차 편함", "사람 많아요"};
for (long spot : spotIds) {
int rn = 3 + rnd.nextInt(3);
Set<String> usedTags = new HashSet<>();
for (int k = 0; k < rn; k++) {
String tag = tags[rnd.nextInt(tags.length)];
if (!usedTags.add(tag)) {
continue;
}
long uid = allUserIds.get(rnd.nextInt(allUserIds.size()));
spotService.addReview(spot, uid, tag, null);
}
}
log.info("[dev seed] 완료 — 스팟 {}, 강아지 {}, 체크인이력 {}건",
spotIds.length, ownerByDog.size(), history.size());
}
/** pref 견종에서 75% 확률로, 나머지는 전체에서 랜덤 선택. */
private long pickDog(Random rnd, Map<String, List<Long>> byBreed, String[] pref, String[] all) {
String breed = rnd.nextInt(4) < 3 ? pref[rnd.nextInt(pref.length)] : all[rnd.nextInt(all.length)];
List<Long> pool = byBreed.get(breed);
if (pool == null || pool.isEmpty()) {
for (List<Long> v : byBreed.values()) {
if (!v.isEmpty()) {
return v.get(rnd.nextInt(v.size()));
}
}
}
return pool.get(rnd.nextInt(pool.size()));
}
private long insertSpot(String name, String type, double lat, double lng) {
return jdbc.queryForObject("""
INSERT INTO spots(name, type, latitude, longitude, created_at)
VALUES (?, ?, ?, ?, now()) RETURNING id
""", Long.class, name, type, lat, lng);
}
private long insertUser(String email, String nickname, String tier) {
return jdbc.queryForObject("""
INSERT INTO users(email, nickname, subscription_tier, status, role, provider, created_at, updated_at)
VALUES (?, ?, ?, 'ACTIVE', 'USER', 'GOOGLE', now(), now()) RETURNING id
""", Long.class, email, nickname, tier);
}
private long insertDog(long userId, String name, String breed, String size, String gender) {
return jdbc.queryForObject("""
INSERT INTO dogs(user_id, name, breed, size, gender, neutered, created_at)
VALUES (?, ?, ?, ?, ?, false, now()) RETURNING id
""", Long.class, userId, name, breed, size, gender);
}
private void linkTrait(long dogId, long tagId) {
jdbc.update("INSERT INTO dog_trait_tags(dog_id, tag_id) VALUES (?, ?) ON CONFLICT DO NOTHING",
dogId, tagId);
}
}