Skip to content

Commit 79cf6f3

Browse files
committed
[Feature] 지역별 맛집 수집 한도와 비율 조정
1 parent 96dc346 commit 79cf6f3

3 files changed

Lines changed: 198 additions & 58 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package com.yogieat.restaurant.service;
2+
3+
import com.yogieat.region.domain.RegionSummary;
4+
import java.util.List;
5+
6+
final class RestaurantCollectionPlan {
7+
8+
static final int REGION_RESTAURANT_LIMIT = 100;
9+
private static final int MIN_RESTAURANTS_PER_CATEGORY_REQUEST = 1;
10+
private static final int MAX_RESTAURANTS_PER_CATEGORY_REQUEST = 20;
11+
12+
private RestaurantCollectionPlan() {
13+
}
14+
15+
static List<Request> create(List<RegionSummary> activeRegions, int categoryCount) {
16+
int safeCategoryCount = Math.max(categoryCount, 1);
17+
18+
return activeRegions.stream()
19+
.map(summary -> toRequest(summary, safeCategoryCount))
20+
.filter(request -> request.remainingSlots() > 0)
21+
.toList();
22+
}
23+
24+
private static Request toRequest(RegionSummary summary, int categoryCount) {
25+
int currentCount = (int) Math.min(summary.restaurantCount(), REGION_RESTAURANT_LIMIT);
26+
int remainingSlots = REGION_RESTAURANT_LIMIT - currentCount;
27+
int countPerCategory = clamp(ceilDiv(remainingSlots, categoryCount));
28+
return new Request(summary, countPerCategory, remainingSlots);
29+
}
30+
31+
private static int ceilDiv(int dividend, int divisor) {
32+
return (dividend + divisor - 1) / divisor;
33+
}
34+
35+
private static int clamp(int count) {
36+
return Math.clamp(count,
37+
MIN_RESTAURANTS_PER_CATEGORY_REQUEST, MAX_RESTAURANTS_PER_CATEGORY_REQUEST);
38+
}
39+
40+
record Request(
41+
RegionSummary regionSummary,
42+
int countPerCategory,
43+
int remainingSlots
44+
) {
45+
}
46+
}

apps/domain/src/main/java/com/yogieat/restaurant/service/RestaurantCollectionProcessor.java

Lines changed: 98 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,11 @@
1414
import com.yogieat.region.domain.RegionMaster;
1515
import com.yogieat.region.domain.RegionSummary;
1616
import com.yogieat.region.service.RegionService;
17+
import com.yogieat.restaurant.domain.Restaurant;
1718
import com.yogieat.restaurant.domain.SuggestionRestaurant;
1819
import java.util.ArrayList;
1920
import java.util.Arrays;
21+
import java.util.HashMap;
2022
import java.util.List;
2123
import java.util.Map;
2224
import java.util.Optional;
@@ -62,8 +64,6 @@ public class RestaurantCollectionProcessor {
6264
private static final int RESTAURANTS_PER_REQUEST = 10;
6365
private static final int KAKAO_COLLECTION_CONCURRENT_PERMITS = 3;
6466
private static final int MIN_REVIEW_COUNT = 30;
65-
private static final List<String> HIGH_VOLUME_REGION_CODES = List.of("GANGNAM", "HONGDAE");
66-
private static final int DEFAULT_REGION_LIMIT = 50;
6767

6868
private final Semaphore kakaoApiSemaphore = new Semaphore(KAKAO_COLLECTION_CONCURRENT_PERMITS);
6969

@@ -74,29 +74,32 @@ public void collectAllRegions() {
7474
return;
7575
}
7676

77-
ConcurrentHashMap<Long, Long> regionCounts = new ConcurrentHashMap<>(loadRegionCounts(activeRegions));
78-
List<RegionSummary> regionsToCollect = filterRegionsNeedingCollection(activeRegions, regionCounts);
77+
List<RestaurantCollectionPlan.Request> collectionRequests =
78+
RestaurantCollectionPlan.create(activeRegions, FOOD_CATEGORIES.size());
7979

80-
if (regionsToCollect.isEmpty()) {
80+
if (collectionRequests.isEmpty()) {
81+
log.info("All active regions reached restaurant collection limit: {} restaurants",
82+
RestaurantCollectionPlan.REGION_RESTAURANT_LIMIT);
8183
return;
8284
}
8385

84-
List<String> locationsToCollect = regionsToCollect.stream()
85-
.map(summary -> summary.region().displayName())
86-
.toList();
87-
Map<String, RegionSummary> regionByLocation = regionsToCollect.stream()
86+
logCollectionPlan(collectionRequests);
87+
88+
Map<String, RestaurantCollectionPlan.Request> requestByLocation = collectionRequests.stream()
8889
.collect(Collectors.toMap(
89-
summary -> summary.region().displayName(),
90+
request -> request.regionSummary().region().displayName(),
9091
Function.identity(),
91-
keepExistingRegion()
92+
keepExistingRequest()
9293
));
94+
ConcurrentHashMap<Long, AtomicInteger> remainingSlotsByRegion =
95+
loadRemainingSlots(collectionRequests);
9396

9497
String restaurantNames = restaurantRepository.findAll().stream()
95-
.map(com.yogieat.restaurant.domain.Restaurant::name)
98+
.map(Restaurant::name)
9699
.collect(Collectors.joining(", "));
97100

98101
Map<LocationCategoryKey, List<SuggestionRestaurant>> allSuggestions =
99-
geminiClient.generateRestaurantsBatch(locationsToCollect, FOOD_CATEGORIES, restaurantNames, RESTAURANTS_PER_REQUEST);
102+
generateBatchSuggestions(collectionRequests, restaurantNames);
100103

101104
AtomicInteger totalProcessed = new AtomicInteger(0);
102105
AtomicInteger totalSuccess = new AtomicInteger(0);
@@ -112,21 +115,21 @@ public void collectAllRegions() {
112115
LocationCategoryKey key = entry.getKey();
113116
List<SuggestionRestaurant> suggestions = entry.getValue();
114117

115-
RegionSummary regionSummary = regionByLocation.get(key.location());
116-
if (regionSummary == null || isRegionLimitReached(regionSummary.region(), regionCounts)) {
118+
RestaurantCollectionPlan.Request request = requestByLocation.get(key.location());
119+
if (request == null) {
120+
continue;
121+
}
122+
123+
RegionMaster region = request.regionSummary().region();
124+
AtomicInteger remainingSlots = remainingSlotsByRegion.get(region.id());
125+
if (remainingSlots == null || remainingSlots.get() <= 0) {
117126
continue;
118127
}
119128

120129
futures.add(executor.submit(() -> {
121130
try {
122-
int processed = processRestaurantsForRegion(regionSummary.region(), key.category(), suggestions);
131+
int processed = processRestaurantsForRegion(region, key.category(), suggestions, remainingSlots);
123132
totalProcessed.addAndGet(processed);
124-
if (processed > 0) {
125-
regionCounts.compute(
126-
regionSummary.region().id(),
127-
(regionId, count) -> count == null ? (long) processed : count + processed
128-
);
129-
}
130133
totalSuccess.incrementAndGet();
131134
} catch (Exception e) {
132135
log.error("Failed: {} - {}", key.location(), key.category(), e);
@@ -158,42 +161,50 @@ public void collectAllRegions() {
158161
}
159162
}
160163

161-
private Map<Long, Long> loadRegionCounts(List<RegionSummary> activeRegions) {
162-
return activeRegions.stream()
164+
private void logCollectionPlan(List<RestaurantCollectionPlan.Request> collectionRequests) {
165+
collectionRequests.forEach(request -> log.info(
166+
"Region {} needs collection: {}/{} restaurants, requesting {} per category",
167+
request.regionSummary().region().displayName(),
168+
request.regionSummary().restaurantCount(),
169+
RestaurantCollectionPlan.REGION_RESTAURANT_LIMIT,
170+
request.countPerCategory()
171+
));
172+
}
173+
174+
private ConcurrentHashMap<Long, AtomicInteger> loadRemainingSlots(
175+
List<RestaurantCollectionPlan.Request> collectionRequests
176+
) {
177+
return collectionRequests.stream()
163178
.collect(Collectors.toMap(
164-
summary -> summary.region().id(),
165-
RegionSummary::restaurantCount
179+
request -> request.regionSummary().region().id(),
180+
request -> new AtomicInteger(request.remainingSlots()),
181+
(existing, ignored) -> existing,
182+
ConcurrentHashMap::new
166183
));
167184
}
168185

169-
private List<RegionSummary> filterRegionsNeedingCollection(
170-
List<RegionSummary> activeRegions,
171-
Map<Long, Long> regionCounts
186+
private Map<LocationCategoryKey, List<SuggestionRestaurant>> generateBatchSuggestions(
187+
List<RestaurantCollectionPlan.Request> collectionRequests,
188+
String restaurantNames
172189
) {
173-
List<RegionSummary> regionsToCollect = new ArrayList<>();
174-
175-
for (RegionSummary summary : activeRegions) {
176-
RegionMaster region = summary.region();
177-
long currentCount = regionCounts.getOrDefault(region.id(), 0L);
178-
int limit = getRegionLimit(region);
179-
180-
if (currentCount < limit) {
181-
regionsToCollect.add(summary);
182-
log.info("Region {} needs collection: {}/{} restaurants",
183-
region.displayName(), currentCount, limit);
184-
} else {
185-
log.info("Region {} reached limit: {}/{} restaurants (skipping)",
186-
region.displayName(), currentCount, limit);
187-
}
188-
}
190+
Map<LocationCategoryKey, List<SuggestionRestaurant>> suggestions = new HashMap<>();
191+
Map<Integer, List<RestaurantCollectionPlan.Request>> requestsByCount = collectionRequests.stream()
192+
.collect(Collectors.groupingBy(RestaurantCollectionPlan.Request::countPerCategory));
189193

190-
return regionsToCollect;
191-
}
194+
requestsByCount.forEach((countPerCategory, requests) -> {
195+
List<String> locationsToCollect = requests.stream()
196+
.map(request -> request.regionSummary().region().displayName())
197+
.toList();
198+
199+
suggestions.putAll(geminiClient.generateRestaurantsBatch(
200+
locationsToCollect,
201+
FOOD_CATEGORIES,
202+
restaurantNames,
203+
countPerCategory
204+
));
205+
});
192206

193-
private boolean isRegionLimitReached(RegionMaster region, Map<Long, Long> regionCounts) {
194-
long currentCount = regionCounts.getOrDefault(region.id(), 0L);
195-
int limit = getRegionLimit(region);
196-
return currentCount >= limit;
207+
return suggestions;
197208
}
198209

199210
/**
@@ -220,6 +231,15 @@ public int processRestaurantsForRegion(
220231
RegionMaster region,
221232
String category,
222233
List<SuggestionRestaurant> suggestions
234+
) {
235+
return processRestaurantsForRegion(region, category, suggestions, new AtomicInteger(suggestions.size()));
236+
}
237+
238+
private int processRestaurantsForRegion(
239+
RegionMaster region,
240+
String category,
241+
List<SuggestionRestaurant> suggestions,
242+
AtomicInteger remainingSlots
223243
) {
224244
RestaurantValidator.ValidationContext validationContext =
225245
restaurantCollectionWriteService.prepareValidationContext(region);
@@ -232,7 +252,7 @@ public int processRestaurantsForRegion(
232252
for (SuggestionRestaurant suggestion : suggestions) {
233253
futures.add(executor.submit(() -> {
234254
processSingleSuggestion(
235-
suggestion, region, category, validationContext, savedCount
255+
suggestion, region, category, validationContext, savedCount, remainingSlots
236256
);
237257
return null;
238258
}));
@@ -259,8 +279,13 @@ private void processSingleSuggestion(
259279
RegionMaster region,
260280
String category,
261281
RestaurantValidator.ValidationContext validationContext,
262-
AtomicInteger savedCount
282+
AtomicInteger savedCount,
283+
AtomicInteger remainingSlots
263284
) {
285+
if (remainingSlots.get() <= 0) {
286+
return;
287+
}
288+
264289
RestaurantEnrichedData enrichedData;
265290
try {
266291
kakaoApiSemaphore.acquire();
@@ -298,6 +323,10 @@ private void processSingleSuggestion(
298323
return;
299324
}
300325

326+
if (!tryAcquireSlot(remainingSlots)) {
327+
return;
328+
}
329+
301330
boolean saved = restaurantCollectionWriteService.persistRestaurant(
302331
suggestion,
303332
region,
@@ -308,17 +337,28 @@ private void processSingleSuggestion(
308337
);
309338
if (saved) {
310339
savedCount.incrementAndGet();
340+
} else {
341+
releaseSlot(remainingSlots);
311342
}
312343
}
313344

314-
private int getRegionLimit(RegionMaster region) {
315-
if (region != null && HIGH_VOLUME_REGION_CODES.contains(region.code())) {
316-
return 100;
345+
private boolean tryAcquireSlot(AtomicInteger remainingSlots) {
346+
while (true) {
347+
int current = remainingSlots.get();
348+
if (current <= 0) {
349+
return false;
350+
}
351+
if (remainingSlots.compareAndSet(current, current - 1)) {
352+
return true;
353+
}
317354
}
318-
return DEFAULT_REGION_LIMIT;
319355
}
320356

321-
private BinaryOperator<RegionSummary> keepExistingRegion() {
357+
private void releaseSlot(AtomicInteger remainingSlots) {
358+
remainingSlots.incrementAndGet();
359+
}
360+
361+
private BinaryOperator<RestaurantCollectionPlan.Request> keepExistingRequest() {
322362
return (existing, ignored) -> existing;
323363
}
324364

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package com.yogieat.restaurant.service;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import com.yogieat.common.GeoJson;
6+
import com.yogieat.region.domain.RegionMaster;
7+
import com.yogieat.region.domain.RegionSummary;
8+
import java.util.List;
9+
import org.junit.jupiter.api.Test;
10+
11+
class RestaurantCollectionPlanTest {
12+
13+
@Test
14+
void create_excludes_regions_when_restaurant_count_reaches_limit() {
15+
List<RestaurantCollectionPlan.Request> requests = RestaurantCollectionPlan.create(List.of(
16+
summary("FULL", "만석", RestaurantCollectionPlan.REGION_RESTAURANT_LIMIT),
17+
summary("ALMOST", "거의만석", RestaurantCollectionPlan.REGION_RESTAURANT_LIMIT - 1),
18+
summary("EMPTY", "부족", 10)
19+
), 5);
20+
21+
assertThat(requests)
22+
.extracting(request -> request.regionSummary().region().code())
23+
.containsExactly("ALMOST", "EMPTY");
24+
}
25+
26+
@Test
27+
void create_allocates_more_requests_to_regions_with_more_remaining_slots() {
28+
List<RestaurantCollectionPlan.Request> requests = RestaurantCollectionPlan.create(List.of(
29+
summary("ALMOST", "거의만석", RestaurantCollectionPlan.REGION_RESTAURANT_LIMIT - 1),
30+
summary("EMPTY", "부족", 10)
31+
), 5);
32+
33+
assertThat(requests)
34+
.extracting(RestaurantCollectionPlan.Request::countPerCategory)
35+
.containsExactly(1, 18);
36+
}
37+
38+
private RegionSummary summary(String code, String displayName, long restaurantCount) {
39+
return new RegionSummary(
40+
new RegionMaster(
41+
(long) code.hashCode(),
42+
code,
43+
"서울",
44+
displayName,
45+
new GeoJson.Point(List.of(127.0, 37.0)),
46+
true,
47+
0,
48+
null,
49+
null
50+
),
51+
restaurantCount
52+
);
53+
}
54+
}

0 commit comments

Comments
 (0)