메랜샵을 런칭하고 2주가 지나면서, 초기에 "일단 돌아가게" 만들어놨던 부분들이 하나둘 발목을 잡기 시작했습니다. 특히 데이터 초기화를 하드코딩으로 해놨던 게 가장 큰 문제였습니다.
아래 코드, 엔티티명, API 경로, 데이터 값은 모두 임의의 예시이며 실제 서비스와 무관합니다.
초기 개발 당시 맵 데이터가 거의 변하지 않을 거라 생각해서, 이런 식으로 만들었습니다:
@PostConstruct
private void init() {
// 하드코딩된 맵 데이터 예시
GameMap map1 = new GameMap("지역A", "세부지역1", ...);
GameMap map2 = new GameMap("지역B", "세부지역2", ...);
// 100개 이상 반복...
}
운영하면서 생긴 문제들:
결국 "이거 언제까지 이렇게 할 거야?"라는 생각이 들어서 대대적인 리팩토링을 시작했습니다.
하드코딩된 초기화 데이터를 없애고, 관리자가 웹 인터페이스로 관리할 수 있게 만들었습니다.
맵 업서트 API
@PostMapping("/.../...") // 예시 경로
public ResponseEntity<GameMap> upsertMap(@RequestBody @Valid MapUpsertRequest request) {
return ResponseEntity.ok(catalogService.upsertMap(request));
}
핵심 비즈니스 로직
@Transactional
public LocationData upsert(LocationUpsertRequest req) {
var found = locationRepository.findByExactName(req.name());
LocationData entity = LocationData.builder()
.name(req.name())
.zone(req.zone())
.subZone(req.subZone())
.imageUrl(req.imageUrl())
.miniMapUrl(req.miniMapUrl())
.build();
LocationData saved = found.isEmpty()
? locationRepository.save(entity)
: locationRepository.save(found.get(0).updateFrom(entity));
cacheService.refreshAll();
evictCache("search_recent");
evictCache("zone_recent");
return saved;
}
맵별 드롭 아이템도 배치로 관리할 수 있게 했습니다:
@Transactional
public List<ItemDrop> upsertItemDrops(List<ItemDropUpsertRequest> reqs) {
List<ItemDrop> result = new ArrayList<>();
for (var r : reqs) {
var existing = itemDropRepository.findByLocationNameAndItemName(r.locationName(), r.itemName());
if (existing.isPresent()) {
ItemDrop item = existing.get();
item.setImageUrl(r.imageUrl());
item.setDropRate(r.dropRate());
result.add(itemDropRepository.save(item));
} else {
result.add(itemDropRepository.save(
ItemDrop.builder()
.locationName(r.locationName())
.itemName(r.itemName())
.imageUrl(r.imageUrl())
.dropRate(r.dropRate())
.build()
));
}
}
cacheService.refreshAll();
return result;
}
디스코드와 프론트엔드에서 공용으로 사용할 수 있는 자동완성 API를 만들었습니다:
@GetMapping("/.../...") // 예시 경로
public List<String> autoComplete(
@RequestParam(required = false) String q,
@RequestParam(required = false) String zone
) {
return locationService.autoCompleteNames(q, zone, 20);
}
JPQL로 유연한 검색
@Query("""
SELECT l FROM LocationData l
WHERE (:zone IS NULL OR l.zone = :zone)
AND (:keyword IS NULL OR REPLACE(l.name, ' ', '')
LIKE CONCAT('%', REPLACE(:keyword, ' ', ''), '%'))
ORDER BY l.name ASC
""")
List<LocationData> findByKeywordAndZone(@Param("keyword") String keyword,
@Param("zone") String zone);
기존의 단순한 "등록 수" 기반에서 실제 거래 완료 데이터를 활용한 알고리즘으로 바꿨습니다.
시간 가중치를 적용한 인기도 계산
public void refreshPopularLocations() {
LocalDateTime since = LocalDateTime.now().minusDays(1);
var completedActions = actionRepository.findCompletedSince(since);
Map<String, Double> scoreMap = new HashMap<>();
for (var action : completedActions) {
long hours = Duration.between(action.getUpdateTime(), LocalDateTime.now()).toHours();
double weight = Math.max(0.1, 1.0 - (hours / 24.0));
scoreMap.merge(action.getLocationName(), weight, Double::sum);
}
this.cachedPopular = scoreMap.entrySet().stream()
.sorted(Map.Entry.<String, Double>comparingByValue().reversed())
.limit(10)
.map(e -> new PopularLocationResponse(
e.getKey(),
getCount(e.getKey()),
getZoneName(e.getKey()),
getImageUrl(e.getKey())
))
.toList();
}
}
하드코딩된 선택지를 없애고 서버 API를 활용하도록 변경했습니다:
@Override
public void onCommandAutoCompleteInteraction(CommandAutoCompleteInteractionEvent e) {
if (!e.getFocusedOption().getName().equals("location")) return;
String keyword = e.getFocusedOption().getValue();
String zone = mapCommandToZone(e.getName());
String url = "https://api.placeholder.com/api/data/locations/autocomplete?q=" +
URLEncoder.encode(keyword, UTF_8) +
(zone == null ? "" : "&zone=" + zone);
List<String> suggestions = apiClient.getSuggestions(url);
e.replyChoices(suggestions.stream()
.map(name -> new Command.Choice(name, name))
.collect(Collectors.toList()))
.queue();
}
데이터가 변경될 때마다 관련 캐시를 적절히 무효화하도록 했습니다:
그야말로 엄청난 대공사..🫠
처음에는 "일단 돌아가게" 만드는 것도 중요하지만, 운영에 들어가면서는 확장성과 유지보수성을 고려한 구조로 빠르게 전환한 점이 좋았습니다. 특히 응답 스키마는 유지하면서 내부 로직만 개선해서 프론트엔드 영향을 최소화도록 신경을 많이 썼습니다.
초기 설계 때부터 이런 부분들을 고려했다면 더 좋았을 텐데, 역시 경험이 부족했던 것 같습니다. 다음 프로젝트에서는 초기 구조 설계에 더 신경 써야겠습니다.