Skip to content

Commit 1123b35

Browse files
authored
Merge pull request #107 from yeonsu00/develop
develop to main
2 parents ef6ad7c + 3daecd9 commit 1123b35

19 files changed

Lines changed: 4446 additions & 61 deletions

File tree

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ out/
3333
/nbdist/
3434
/.nb-gradle/
3535

36+
### Env ###
37+
.env
38+
.env.*
39+
!.env.example
40+
3641
### VS Code ###
3742
.vscode/
3843
/dev.env
@@ -44,3 +49,5 @@ out/
4449
node_modules/
4550
apps/taja-simulator/frontend/dist/
4651
/.omc/
52+
/apps/taja-api/src/main/java/com/taja/.omc/state/
53+
/k6/log/

apps/taja-api/src/main/java/com/taja/application/station/StationFacade.java

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,6 @@ public int uploadStationsFromFile(MultipartFile file, LocalDateTime requestedAt)
5151
return savedStations.size();
5252
}
5353

54-
@Transactional(readOnly = true)
5554
public NearbyStationsResponse findStationsInBounds(double centerLat, double centerLon,
5655
double latDelta, double lonDelta) {
5756
double height = (latDelta * 2) * 111.0;

apps/taja-api/src/main/java/com/taja/infrastructure/cache/StationHashRepository.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ public StationHashRepository(
3636

3737
public static final String STATION_KEY_PREFIX = "stations:";
3838
public static final String LOCK_PREFIX = "lock:station:";
39+
public static final String BULK_LOAD_LOCK_KEY = "lock:station:bulk-load";
3940

4041
@Value("${cache.station.ttl-sec:3600}")
4142
private long cacheTtlSec;
@@ -169,4 +170,13 @@ public void releaseLock(Integer number) {
169170
redisTemplateMaster.delete(LOCK_PREFIX + number);
170171
}
171172

173+
public boolean acquireBulkLoadLock() {
174+
return Boolean.TRUE.equals(
175+
redisTemplateMaster.opsForValue().setIfAbsent(BULK_LOAD_LOCK_KEY, "locked", Duration.ofSeconds(5)));
176+
}
177+
178+
public void releaseBulkLoadLock() {
179+
redisTemplateMaster.delete(BULK_LOAD_LOCK_KEY);
180+
}
181+
172182
}

apps/taja-api/src/main/java/com/taja/infrastructure/cache/StationRedisRepositoryImpl.java

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,23 @@ public List<StationInfo.NearbyAvailableStation> findNearbyAvailableStations(
7979

8080
@Override
8181
public List<StationInfo.StationFullInfo> findStationInfos(List<StationInfo.StationGeoInfo> geoInfos) {
82+
List<Integer> numbers = geoInfos.stream()
83+
.map(StationInfo.StationGeoInfo::number)
84+
.toList();
85+
86+
List<Integer> missingNumbers = stationHashRepository.findMissingNumbers(numbers);
87+
if (!missingNumbers.isEmpty()) {
88+
loadMissingWithSingleFlight(missingNumbers);
89+
}
90+
8291
return geoInfos.stream()
83-
.map(geo -> getOrRefresh(geo.number(), geo.latitude(), geo.longitude()))
92+
.map(geo -> {
93+
Optional<StationInfo.StationHashInfo> hashInfoOpt = stationHashRepository.fetchAllFields(geo.number());
94+
if (hashInfoOpt.isPresent() && stationHashRepository.isThresholdReached(geo.number())) {
95+
CompletableFuture.runAsync(() -> refreshCacheWithLock(geo.number()));
96+
}
97+
return StationInfo.StationFullInfo.from(hashInfoOpt.orElse(null), geo.latitude(), geo.longitude());
98+
})
8499
.flatMap(Optional::stream)
85100
.collect(Collectors.toList());
86101
}
@@ -108,6 +123,42 @@ public BikeCountInfo getStationStatusByNumber(Integer stationNumber) {
108123
.orElse(new BikeCountInfo(stationId, 0, LocalDateTime.now())));
109124
}
110125

126+
private static final long SINGLE_FLIGHT_WAIT_MS = 50;
127+
private static final int SINGLE_FLIGHT_MAX_ATTEMPTS = 20;
128+
129+
private void loadMissingWithSingleFlight(List<Integer> missingNumbers) {
130+
for (int attempt = 0; attempt < SINGLE_FLIGHT_MAX_ATTEMPTS; attempt++) {
131+
if (stationHashRepository.acquireBulkLoadLock()) {
132+
try {
133+
List<Integer> stillMissing = stationHashRepository.findMissingNumbers(missingNumbers);
134+
if (!stillMissing.isEmpty()) {
135+
List<Station> stations = stationJpaRepository.findAllByNumberIn(stillMissing);
136+
stationHashRepository.saveStationInfosWithPipeline(stations, LocalDateTime.now());
137+
}
138+
return;
139+
} finally {
140+
stationHashRepository.releaseBulkLoadLock();
141+
}
142+
}
143+
144+
try {
145+
Thread.sleep(SINGLE_FLIGHT_WAIT_MS);
146+
} catch (InterruptedException e) {
147+
Thread.currentThread().interrupt();
148+
break;
149+
}
150+
missingNumbers = stationHashRepository.findMissingNumbers(missingNumbers);
151+
if (missingNumbers.isEmpty()) {
152+
return;
153+
}
154+
}
155+
156+
if (!missingNumbers.isEmpty()) {
157+
List<Station> stations = stationJpaRepository.findAllByNumberIn(missingNumbers);
158+
stationHashRepository.saveStationInfosWithPipeline(stations, LocalDateTime.now());
159+
}
160+
}
161+
111162
private Optional<StationInfo.StationFullInfo> getOrRefresh(Integer number, double lat, double lon) {
112163
Optional<StationInfo.StationHashInfo> hashInfoOpt = stationHashRepository.fetchAllFields(number);
113164
Optional<StationInfo.StationFullInfo> stationFullInfo = StationInfo.StationFullInfo.from(hashInfoOpt.orElse(null), lat, lon);

apps/taja-api/src/main/java/com/taja/interfaces/scheduler/StationStatusScheduler.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ public class StationStatusScheduler {
1515
private final StationStatusService stationStatusService;
1616
private final StationInitializationHolder stationInitializationHolder;
1717

18-
@Scheduled(cron = "0 0/10 * * * *")
18+
@Scheduled(cron = "${taja.scheduler.status-collection.cron:0 0/10 * * * *}")
1919
public void scheduleStationStatusCollection() {
2020
if (!stationInitializationHolder.isInitialized()) {
2121
log.debug("대여소 정보 초기화 미완료로 대여소 실시간 상태 수집 스킵");
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# ============================================================
2+
# 부하 측정용 프로파일 (조건 A: 실시간 상태 수집기 off)
3+
# 실행: --spring.profiles.active=loadtest
4+
#
5+
# - "-" 는 Spring @Scheduled 의 cron 비활성화 특수값(Scheduled.CRON_DISABLED).
6+
# 10분 주기 실시간 상태 수집 cron만 끈다.
7+
# - 부팅 시 StationInitializationScheduler 가 직접 호출하는
8+
# status 1회 수집과 대여소 위치(geo) 적재는 그대로 유지된다.
9+
# ============================================================
10+
taja:
11+
scheduler:
12+
status-collection:
13+
cron: "-"

apps/taja-api/src/main/resources/application.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ management:
9393
endpoints:
9494
web:
9595
exposure:
96-
include: health, prometheus
96+
include: health, metrics, prometheus
9797
endpoint:
9898
prometheus:
9999
enabled: true

apps/taja-api/src/test/java/com/taja/infrastructure/cache/StationRedisRepositoryImplCacheTest.java

Lines changed: 41 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
import com.taja.domain.station.OperationMode;
99
import com.taja.domain.station.Station;
1010
import com.taja.domain.status.StationStatus;
11-
import com.taja.global.exception.StationNotFoundException;
1211
import com.taja.infrastructure.station.StationJpaRepository;
1312
import java.time.LocalDate;
1413
import java.time.LocalDateTime;
@@ -35,24 +34,27 @@ class StationRedisRepositoryImplCacheTest {
3534
@InjectMocks
3635
private StationRedisRepositoryImpl stationRedisRepository;
3736

38-
@DisplayName("캐시 미스 시 DB에서 조회 후 캐시에 저장하고 반환한다")
37+
@DisplayName("캐시 미스 시 DB에서 배치 조회 후 캐시에 저장하고 반환한다")
3938
@Test
40-
void getOrRefresh_whenCacheMiss_loadsFromDbAndSavesToCache() {
39+
void findStationInfos_whenCacheMiss_loadsFromDbInBatchAndSavesToCache() {
4140
// given
4241
Integer stationNumber = 101;
4342
double lat = 37.5665;
4443
double lon = 126.9780;
4544

45+
when(stationHashRepository.findMissingNumbers(List.of(stationNumber)))
46+
.thenReturn(List.of(stationNumber));
47+
when(stationHashRepository.acquireBulkLoadLock()).thenReturn(true);
48+
49+
Station station = createTestStation(stationNumber, lat, lon);
50+
when(stationJpaRepository.findAllByNumberIn(List.of(stationNumber)))
51+
.thenReturn(List.of(station));
52+
4653
StationInfo.StationHashInfo cachedHashInfo = new StationInfo.StationHashInfo(
4754
stationNumber, 1L, "테스트 대여소 101", 0, LocalDateTime.now()
4855
);
4956
when(stationHashRepository.fetchAllFields(stationNumber))
50-
.thenReturn(Optional.empty()) // 첫 번째: 캐시 미스
51-
.thenReturn(Optional.of(cachedHashInfo)); // 두 번째: 캐시 저장 후
52-
53-
Station station = createTestStation(stationNumber, lat, lon);
54-
when(stationJpaRepository.findByNumber(stationNumber))
55-
.thenReturn(Optional.of(station));
57+
.thenReturn(Optional.of(cachedHashInfo));
5658

5759
// when
5860
List<StationInfo.StationGeoInfo> geoInfos = List.of(
@@ -63,9 +65,8 @@ void getOrRefresh_whenCacheMiss_loadsFromDbAndSavesToCache() {
6365
// then
6466
assertThat(results).hasSize(1);
6567
assertThat(results.getFirst().number()).isEqualTo(stationNumber);
66-
verify(stationJpaRepository).findByNumber(stationNumber);
68+
verify(stationJpaRepository).findAllByNumberIn(List.of(stationNumber));
6769
verify(stationHashRepository).saveStationInfosWithPipeline(anyList(), any(LocalDateTime.class));
68-
verify(stationHashRepository, times(2)).fetchAllFields(stationNumber);
6970
}
7071

7172
@DisplayName("캐시 히트 시 즉시 반환하고 DB 조회하지 않는다")
@@ -198,58 +199,60 @@ void refreshCacheWithLock_whenLockAcquired_refreshesCache() throws InterruptedEx
198199
verify(stationHashRepository).releaseLock(stationNumber);
199200
}
200201

201-
@DisplayName("DB에 대여소가 없으면 예외가 발생한다")
202+
@DisplayName("DB에도 대여소가 없으면 해당 대여소를 결과에서 제외한다")
202203
@Test
203-
void getOrRefresh_whenStationNotFoundInDb_throwsException() {
204+
void findStationInfos_whenStationNotFoundInDb_excludesStation() {
204205
// given
205206
Integer stationNumber = 999;
206207
double lat = 37.5665;
207208
double lon = 126.9780;
208209

210+
when(stationHashRepository.findMissingNumbers(List.of(stationNumber)))
211+
.thenReturn(List.of(stationNumber));
212+
when(stationHashRepository.acquireBulkLoadLock()).thenReturn(true);
213+
when(stationJpaRepository.findAllByNumberIn(List.of(stationNumber)))
214+
.thenReturn(List.of()); // DB에도 없음
209215
when(stationHashRepository.fetchAllFields(stationNumber))
210216
.thenReturn(Optional.empty());
211-
when(stationJpaRepository.findByNumber(stationNumber))
212-
.thenReturn(Optional.empty());
213217

214218
List<StationInfo.StationGeoInfo> geoInfos = List.of(
215219
new StationInfo.StationGeoInfo(stationNumber, lat, lon)
216220
);
217221

218-
// when & then
219-
assertThatThrownBy(() -> stationRedisRepository.findStationInfos(geoInfos))
220-
.isInstanceOf(StationNotFoundException.class)
221-
.hasMessageContaining("999 번 대여소를 찾을 수 없습니다");
222-
verify(stationJpaRepository).findByNumber(stationNumber);
223-
verify(stationHashRepository, never()).saveStationInfosWithPipeline(anyList(), any());
222+
// when
223+
List<StationInfo.StationFullInfo> results = stationRedisRepository.findStationInfos(geoInfos);
224+
225+
// then
226+
assertThat(results).isEmpty();
227+
verify(stationJpaRepository).findAllByNumberIn(List.of(stationNumber));
224228
}
225229

226-
@DisplayName("여러 대여소 조회 시 각각 캐시 상태에 따라 처리한다")
230+
@DisplayName("여러 대여소 조회 시 캐시 미스된 것만 DB에서 배치 조회한다")
227231
@Test
228-
void findStationInfos_whenMultipleStations_processesEachIndependently() {
232+
void findStationInfos_whenMultipleStations_loadsOnlyMissingFromDb() {
229233
// given
230234
StationInfo.StationGeoInfo geo1 = new StationInfo.StationGeoInfo(201, 37.5665, 126.9780);
231235
StationInfo.StationGeoInfo geo2 = new StationInfo.StationGeoInfo(202, 37.5670, 126.9785);
232236

233-
// 첫 번째: 캐시 히트
237+
// 202번만 캐시 미스
238+
when(stationHashRepository.findMissingNumbers(List.of(201, 202)))
239+
.thenReturn(List.of(202));
240+
when(stationHashRepository.findMissingNumbers(List.of(202)))
241+
.thenReturn(List.of(202));
242+
when(stationHashRepository.acquireBulkLoadLock()).thenReturn(true);
243+
244+
Station station2 = createTestStation(202, 37.5670, 126.9785);
245+
when(stationJpaRepository.findAllByNumberIn(List.of(202)))
246+
.thenReturn(List.of(station2));
247+
234248
StationInfo.StationHashInfo hashInfo1 = new StationInfo.StationHashInfo(
235249
201, 1L, "테스트 대여소 201", 5, LocalDateTime.now()
236250
);
237-
when(stationHashRepository.fetchAllFields(201))
238-
.thenReturn(Optional.of(hashInfo1));
239-
when(stationHashRepository.isThresholdReached(201))
240-
.thenReturn(false);
241-
242-
// 두 번째: 캐시 미스
243251
StationInfo.StationHashInfo hashInfo2 = new StationInfo.StationHashInfo(
244252
202, 2L, "테스트 대여소 202", 0, null
245253
);
246-
when(stationHashRepository.fetchAllFields(202))
247-
.thenReturn(Optional.empty())
248-
.thenReturn(Optional.of(hashInfo2));
249-
250-
Station station2 = createTestStation(202, 37.5670, 126.9785);
251-
when(stationJpaRepository.findByNumber(202))
252-
.thenReturn(Optional.of(station2));
254+
when(stationHashRepository.fetchAllFields(201)).thenReturn(Optional.of(hashInfo1));
255+
when(stationHashRepository.fetchAllFields(202)).thenReturn(Optional.of(hashInfo2));
253256

254257
// when
255258
List<StationInfo.StationFullInfo> results = stationRedisRepository.findStationInfos(List.of(geo1, geo2));
@@ -258,7 +261,7 @@ void findStationInfos_whenMultipleStations_processesEachIndependently() {
258261
assertThat(results).hasSize(2);
259262
assertThat(results.get(0).number()).isEqualTo(201);
260263
assertThat(results.get(1).number()).isEqualTo(202);
261-
verify(stationJpaRepository, times(1)).findByNumber(202); // 두 번째만 DB 조회
264+
verify(stationJpaRepository).findAllByNumberIn(List.of(202)); // 미스된 것만 배치 조회
262265
}
263266

264267
@DisplayName("update 시 캐시 누락된 대여소가 있으면 DB에서 정적 정보를 조회해 재적재한 뒤 갱신한다")

apps/taja-api/src/test/java/com/taja/infrastructure/station/StationRedisRepositoryImplTest.java

Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -127,13 +127,11 @@ void findStationInfos_whenHashDataIsMissing() {
127127
new StationInfo.StationGeoInfo(201, 37.6, 127.1)
128128
);
129129

130-
// 캐시에 데이터가 없음 → 저장 후 재조회
131-
StationInfo.StationHashInfo cachedHashInfo = new StationInfo.StationHashInfo(201, 1L, "테스트 대여소", 0, null);
132-
when(stationHashRepository.fetchAllFields(201))
133-
.thenReturn(Optional.empty()) // 첫 번째 호출: 캐시 미스
134-
.thenReturn(Optional.of(cachedHashInfo)); // 두 번째 호출: 캐시 저장 후 조회
130+
// 캐시에 데이터가 없음 → 배치 조회 후 저장, 재조회
131+
when(stationHashRepository.findMissingNumbers(List.of(201)))
132+
.thenReturn(List.of(201));
133+
when(stationHashRepository.acquireBulkLoadLock()).thenReturn(true);
135134

136-
// DB에서 조회
137135
Station station = Station.builder()
138136
.stationId(1L)
139137
.number(201)
@@ -144,8 +142,12 @@ void findStationInfos_whenHashDataIsMissing() {
144142
.longitude(127.1)
145143
.operationMode(OperationMode.LCD_QR)
146144
.build();
147-
when(stationJpaRepository.findByNumber(201))
148-
.thenReturn(Optional.of(station));
145+
when(stationJpaRepository.findAllByNumberIn(List.of(201)))
146+
.thenReturn(List.of(station));
147+
148+
StationInfo.StationHashInfo cachedHashInfo = new StationInfo.StationHashInfo(201, 1L, "테스트 대여소", 0, null);
149+
when(stationHashRepository.fetchAllFields(201))
150+
.thenReturn(Optional.of(cachedHashInfo));
149151

150152
// when
151153
List<StationInfo.StationFullInfo> results = stationRedisRepository.findStationInfos(geoInfos);
@@ -155,6 +157,7 @@ void findStationInfos_whenHashDataIsMissing() {
155157
StationInfo.StationFullInfo result = results.getFirst();
156158
assertThat(result.number()).isEqualTo(201);
157159
assertThat(result.stationId()).isEqualTo(1L);
160+
verify(stationJpaRepository).findAllByNumberIn(List.of(201));
158161
verify(stationHashRepository).saveStationInfosWithPipeline(anyList(), any(LocalDateTime.class));
159162
}
160163

@@ -177,26 +180,28 @@ void findStations_WithinShape_excludeStation_whenMemberIsNotNumeric() {
177180
assertThat(results.getFirst().number()).isEqualTo(101);
178181
}
179182

180-
@DisplayName("날짜 형식이 잘못되었을 때, 예외가 발생한다.")
183+
@DisplayName("캐시와 DB 모두에 대여소가 없으면 해당 대여소를 결과에서 제외한다.")
181184
@Test
182-
void findStationInfos_whenDateFormatIsInvalid_throwsException() {
185+
void findStationInfos_whenStationMissingInCacheAndDb_excludesStation() {
183186
// given
184187
List<StationInfo.StationGeoInfo> geoInfos = List.of(
185188
new StationInfo.StationGeoInfo(102, 37.503, 127.003)
186189
);
187190

188-
// StationHashRepository에서 파싱 실패로 Optional.empty() 반환
191+
when(stationHashRepository.findMissingNumbers(List.of(102)))
192+
.thenReturn(List.of(102));
193+
when(stationHashRepository.acquireBulkLoadLock()).thenReturn(true);
194+
when(stationJpaRepository.findAllByNumberIn(List.of(102)))
195+
.thenReturn(List.of()); // DB에도 없음
189196
when(stationHashRepository.fetchAllFields(102))
190197
.thenReturn(Optional.empty());
191-
when(stationJpaRepository.findByNumber(102))
192-
.thenReturn(Optional.empty());
193198

194-
// when & then
195-
assertThatThrownBy(() -> stationRedisRepository.findStationInfos(geoInfos))
196-
.isInstanceOf(StationNotFoundException.class)
197-
.hasMessageContaining("102 번 대여소를 찾을 수 없습니다");
198-
verify(stationJpaRepository).findByNumber(102);
199-
verify(stationHashRepository, never()).saveStationInfosWithPipeline(anyList(), any());
199+
// when
200+
List<StationInfo.StationFullInfo> results = stationRedisRepository.findStationInfos(geoInfos);
201+
202+
// then
203+
assertThat(results).isEmpty();
204+
verify(stationJpaRepository).findAllByNumberIn(List.of(102));
200205
}
201206

202207
@DisplayName("getStationStatusByNumber는 Redis에 데이터가 있으면 Redis 값을 반환한다")

docker-compose.loadtest.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# ============================================================
2+
# 부하 측정(조건 A) 오버라이드 — 앱을 Docker로 실행
3+
# - SPRING_PROFILES_ACTIVE=loadtest : 10분 주기 status cron off (부팅 1회 수집은 유지)
4+
# - 8080 을 호스트로 공개 : k6(localhost:8080) + Prometheus(host.docker.internal:8080) 접근용
5+
#
6+
# 실행: docker compose -f docker-compose.yml -f docker-compose.loadtest.yml up -d --build app
7+
# ============================================================
8+
services:
9+
app:
10+
ports:
11+
- "8080:8080"
12+
environment:
13+
SPRING_PROFILES_ACTIVE: loadtest

0 commit comments

Comments
 (0)