Skip to content

Commit 7cde44b

Browse files
committed
feat: 티켓팅 사이트 서버 시간
1 parent 76136fa commit 7cde44b

6 files changed

Lines changed: 344 additions & 86 deletions

File tree

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package com.back.web7_9_codecrete_be.domain.serverTime.controller;
2+
3+
import org.springframework.web.bind.annotation.GetMapping;
4+
import org.springframework.web.bind.annotation.PathVariable;
5+
import org.springframework.web.bind.annotation.RequestMapping;
6+
import org.springframework.web.bind.annotation.RestController;
7+
8+
import com.back.web7_9_codecrete_be.domain.serverTime.dto.ServerTimeResponse;
9+
import com.back.web7_9_codecrete_be.domain.serverTime.entity.TicketProvider;
10+
import com.back.web7_9_codecrete_be.domain.serverTime.service.TicketServerTimeService;
11+
import com.back.web7_9_codecrete_be.global.rsData.RsData;
12+
13+
import io.swagger.v3.oas.annotations.Operation;
14+
import io.swagger.v3.oas.annotations.Parameter;
15+
import io.swagger.v3.oas.annotations.tags.Tag;
16+
import lombok.RequiredArgsConstructor;
17+
18+
@Tag(
19+
name = "Server Time",
20+
description = """
21+
외부 티켓 사이트의 서버 시간과
22+
우리 서버 시간 간의 **차이(offset)** 를 제공하는 API
23+
24+
지원 티켓사(provider):
25+
- NOL
26+
- YES24
27+
- MELON
28+
- TICKETLINK
29+
"""
30+
)
31+
@RestController
32+
@RequiredArgsConstructor
33+
@RequestMapping("/api/v1/server-time")
34+
public class TicketServerTimeController {
35+
36+
private final TicketServerTimeService ticketServerTimeService;
37+
38+
@Operation(
39+
summary = "티켓 서버 시간 offset 조회",
40+
description = """
41+
외부 티켓 사이트의 HTTP Date 헤더를 기반으로
42+
우리 서버와의 **시간 차이(offsetMillis)** 를 **밀리초** 단위로 계산해 반환합니다.
43+
44+
- offsetMillis는 60초간 캐싱됩니다.
45+
"""
46+
)
47+
@GetMapping("/{provider}")
48+
public RsData<ServerTimeResponse> getServerTime(
49+
@Parameter(
50+
description = "티켓 사이트 제공자",
51+
example = "NOL",
52+
required = true
53+
)
54+
@PathVariable String provider
55+
) {
56+
TicketProvider ticketProvider = TicketProvider.valueOf(provider.toUpperCase());
57+
58+
ServerTimeResponse response = ticketServerTimeService.fetchServerTime(ticketProvider);
59+
60+
return RsData.success(response);
61+
}
62+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package com.back.web7_9_codecrete_be.domain.serverTime.dto;
2+
3+
public record ServerTimeMeasureResult(
4+
long offsetMillis,
5+
long rttMillis
6+
) {}
7+
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package com.back.web7_9_codecrete_be.domain.serverTime.dto;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
5+
public record ServerTimeResponse(
6+
7+
@Schema(
8+
description = "티켓 사이트 제공자 이름",
9+
example = "NOL"
10+
)
11+
String provider,
12+
13+
@Schema(
14+
description = """
15+
외부 티켓 서버 시간 - 우리 서버 시간 (밀리초 단위)
16+
17+
예)
18+
- offsetMillis = -500
19+
→ 티켓 서버 시간이 우리 서버보다 0.5초 느림
20+
- offsetMillis = 1200
21+
→ 티켓 서버 시간이 우리 서버보다 1.2초 빠름
22+
""",
23+
example = "-691"
24+
)
25+
long offsetMillis
26+
) {}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package com.back.web7_9_codecrete_be.domain.serverTime.entity;
2+
3+
public enum TicketProvider {
4+
5+
NOL("https://nol.interpark.com/ticket"),
6+
YES24("https://ticket.yes24.com"),
7+
MELON("https://ticket.melon.com"),
8+
TICKETLINK("https://www.ticketlink.co.kr");
9+
10+
private final String url;
11+
12+
TicketProvider(String url) {
13+
this.url = url;
14+
}
15+
16+
public String getUrl() {
17+
return url;
18+
}
19+
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
package com.back.web7_9_codecrete_be.domain.serverTime.service;
2+
3+
import java.time.Duration;
4+
import java.time.Instant;
5+
import java.time.ZoneId;
6+
import java.time.format.DateTimeFormatter;
7+
import java.util.ArrayList;
8+
import java.util.Comparator;
9+
import java.util.List;
10+
11+
import org.springframework.data.redis.core.RedisTemplate;
12+
import org.springframework.http.HttpHeaders;
13+
import org.springframework.http.HttpMethod;
14+
import org.springframework.http.ResponseEntity;
15+
import org.springframework.stereotype.Service;
16+
import org.springframework.web.client.RestTemplate;
17+
18+
import com.back.web7_9_codecrete_be.domain.serverTime.dto.ServerTimeMeasureResult;
19+
import com.back.web7_9_codecrete_be.domain.serverTime.dto.ServerTimeResponse;
20+
import com.back.web7_9_codecrete_be.domain.serverTime.entity.TicketProvider;
21+
22+
import lombok.RequiredArgsConstructor;
23+
import lombok.extern.slf4j.Slf4j;
24+
25+
@Service
26+
@RequiredArgsConstructor
27+
@Slf4j
28+
public class TicketServerTimeService {
29+
30+
private final RestTemplate restTemplate;
31+
private final RedisTemplate<String, Object> redisTemplate;
32+
33+
private static final int MEASURE_COUNT = 5;
34+
private static final int MIN_VALID_SAMPLE = 3;
35+
private static final ZoneId KST = ZoneId.of("Asia/Seoul");
36+
37+
private static final Duration OFFSET_TTL = Duration.ofSeconds(60);
38+
private static final String REDIS_KEY_PREFIX = "serverTime:offset:";
39+
40+
/**
41+
* 외부 티켓 서버 시간 조회
42+
*/
43+
public ServerTimeResponse fetchServerTime(TicketProvider provider) {
44+
45+
String redisKey = REDIS_KEY_PREFIX + provider.name();
46+
47+
Number cached = (Number) redisTemplate.opsForValue().get(redisKey);
48+
Long offsetMillis = cached != null ? cached.longValue() : null;
49+
50+
// 캐시 HIT
51+
if (offsetMillis != null) {
52+
53+
log.info("[SERVER TIME][CACHE HIT] provider={}, offset={}ms",
54+
provider.name(), offsetMillis);
55+
56+
return new ServerTimeResponse(
57+
provider.name(),
58+
offsetMillis
59+
);
60+
}
61+
62+
63+
// 캐시 MISS
64+
long medianOffset = measureMedianOffset(provider);
65+
66+
redisTemplate.opsForValue()
67+
.set(redisKey, medianOffset, OFFSET_TTL);
68+
69+
log.info("[SERVER TIME][CACHE MISS] provider={}, offset={}ms",
70+
provider.name(), medianOffset);
71+
72+
return new ServerTimeResponse(
73+
provider.name(),
74+
medianOffset
75+
);
76+
}
77+
78+
/**
79+
* 여러 번 측정 후 RTT 기준 필터링 + 중앙값(offset)
80+
*/
81+
private long measureMedianOffset(TicketProvider provider) {
82+
83+
List<ServerTimeMeasureResult> results = new ArrayList<>();
84+
85+
for (int i = 0; i < MEASURE_COUNT; i++) {
86+
try {
87+
results.add(measureOnce(provider));
88+
} catch (Exception e) {
89+
log.warn("[SERVER TIME] measure failed: {}", e.getMessage());
90+
}
91+
}
92+
93+
if (results.size() < MIN_VALID_SAMPLE) {
94+
throw new IllegalStateException("Not enough valid server time samples");
95+
}
96+
97+
results.sort(Comparator.comparingLong(ServerTimeMeasureResult::rttMillis));
98+
99+
List<Long> offsets = results.stream()
100+
.limit(results.size() - 1)
101+
.map(ServerTimeMeasureResult::offsetMillis)
102+
.sorted()
103+
.toList();
104+
105+
return offsets.get(offsets.size() / 2);
106+
}
107+
108+
/**
109+
* 서버 시간 측정
110+
*/
111+
private ServerTimeMeasureResult measureOnce(TicketProvider provider) {
112+
113+
long t1Nano = System.nanoTime();
114+
long t1Millis = System.currentTimeMillis();
115+
116+
ResponseEntity<Void> response = restTemplate.exchange(
117+
provider.getUrl(),
118+
HttpMethod.HEAD,
119+
null,
120+
Void.class
121+
);
122+
123+
long t2Nano = System.nanoTime();
124+
long rttMillis = (t2Nano - t1Nano) / 1_000_000;
125+
126+
long tMidMillis = t1Millis + (rttMillis / 2);
127+
128+
String dateHeader = response.getHeaders().getFirst(HttpHeaders.DATE);
129+
if (dateHeader == null) {
130+
throw new IllegalStateException("Date header missing");
131+
}
132+
133+
Instant serverInstant = DateTimeFormatter.RFC_1123_DATE_TIME
134+
.parse(dateHeader, Instant::from);
135+
136+
long offset = serverInstant.toEpochMilli() - tMidMillis;
137+
138+
return new ServerTimeMeasureResult(offset, rttMillis);
139+
}
140+
}

0 commit comments

Comments
 (0)