-
Notifications
You must be signed in to change notification settings - Fork 0
feat(observability): 리포트 다운로드·SSE 스트림·JWT 인증 구현 #29 #72
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
The head ref may contain hidden characters: "feature/29-observability-\uB3C4\uBA54\uC778-\uAC1C\uBC1C"
Changes from all commits
2ab89bd
0bcd346
4ccbb43
19e0aea
deceb01
d54d514
bc55d78
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| package hs.kr.entrydsm.observability.adapterin.web.controller | ||
|
|
||
| import hs.kr.entrydsm.observability.adapterin.web.ClientIpResolver | ||
| import hs.kr.entrydsm.observability.adapterin.web.sse.SseBroadcaster | ||
| import hs.kr.entrydsm.observability.adapterin.web.sse.SseConnectionLimiter | ||
| import hs.kr.entrydsm.observability.domain.enum.ErrorCode | ||
| import hs.kr.entrydsm.observability.domain.exception.MonitorDomainException | ||
| import jakarta.servlet.http.HttpServletRequest | ||
| import java.util.concurrent.TimeUnit | ||
| import java.util.concurrent.atomic.AtomicBoolean | ||
| import org.springframework.http.MediaType | ||
| import org.springframework.web.bind.annotation.GetMapping | ||
| import org.springframework.web.bind.annotation.RestController | ||
| import org.springframework.web.servlet.mvc.method.annotation.SseEmitter | ||
|
|
||
| @RestController | ||
| class MonitorStreamController( | ||
| private val sseBroadcaster: SseBroadcaster, | ||
| private val connectionLimiter: SseConnectionLimiter, | ||
| private val clientIpResolver: ClientIpResolver, | ||
| ) { | ||
| @GetMapping("/api/monitor/v11/stream", produces = [MediaType.TEXT_EVENT_STREAM_VALUE]) | ||
| fun stream(httpRequest: HttpServletRequest): SseEmitter { | ||
| val clientKey = clientIpResolver.resolve(httpRequest) | ||
| if (!connectionLimiter.tryAcquire(clientKey)) { | ||
| throw MonitorDomainException(ErrorCode.TOO_MANY_CONNECTIONS) | ||
| } | ||
| val emitter = SseEmitter(TimeUnit.MINUTES.toMillis(EMITTER_TIMEOUT_MINUTES)) | ||
| // timeout/error 뒤에도 onCompletion이 불릴 수 있다. 중복 해제되면 카운터가 음수가 되어 제한이 무의미해진다. | ||
| val released = AtomicBoolean(false) | ||
| val releaseOnce = Runnable { if (released.compareAndSet(false, true)) connectionLimiter.release(clientKey) } | ||
| emitter.onCompletion(releaseOnce) | ||
| emitter.onTimeout(releaseOnce) | ||
| emitter.onError { releaseOnce.run() } | ||
| sseBroadcaster.register(emitter) | ||
| return emitter | ||
| } | ||
|
|
||
| companion object { | ||
| private const val EMITTER_TIMEOUT_MINUTES = 30L | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| package hs.kr.entrydsm.observability.adapterin.web.controller | ||
|
|
||
| import hs.kr.entrydsm.observability.adapterin.web.dto.common.ApiResponse | ||
| import hs.kr.entrydsm.observability.adapterin.web.dto.common.toResponse | ||
| import hs.kr.entrydsm.observability.adapterin.web.dto.response.ReportGeneratedResponse | ||
| import hs.kr.entrydsm.observability.application.port.`in`.GenerateReportUseCase | ||
| import hs.kr.entrydsm.observability.domain.enum.ErrorCode | ||
| import hs.kr.entrydsm.observability.domain.enum.ReportFormat | ||
| import hs.kr.entrydsm.observability.domain.exception.MonitorDomainException | ||
| import org.springframework.web.bind.annotation.PostMapping | ||
| import org.springframework.web.bind.annotation.RequestParam | ||
| import org.springframework.web.bind.annotation.RestController | ||
|
|
||
| @RestController | ||
| class ReportController( | ||
| private val generateReportUseCase: GenerateReportUseCase, | ||
| ) { | ||
| // 파일과 다운로드 토큰을 만드는 요청이라 GET이 아니다. 프리페치나 재시도가 리포트를 새로 만들지 않게 한다. | ||
| @PostMapping("/api/monitor/v11/reports") | ||
| fun generate( | ||
| @RequestParam(defaultValue = "xlsx") format: String, | ||
| ): ApiResponse<ReportGeneratedResponse> { | ||
| val parsedFormat = runCatching { ReportFormat.valueOf(format.trim().uppercase()) } | ||
| .getOrElse { throw MonitorDomainException(ErrorCode.INVALID_FORMAT) } | ||
| val result = generateReportUseCase.generate(parsedFormat) | ||
| return ApiResponse(data = result.toResponse()) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| package hs.kr.entrydsm.observability.adapterin.web.controller | ||
|
|
||
| import hs.kr.entrydsm.observability.application.port.out.ReportObjectStoragePort | ||
| import org.springframework.http.HttpHeaders | ||
| import org.springframework.http.MediaType | ||
| import org.springframework.http.ResponseEntity | ||
| import org.springframework.web.bind.annotation.GetMapping | ||
| import org.springframework.web.bind.annotation.RequestParam | ||
| import org.springframework.web.bind.annotation.RestController | ||
|
|
||
| /** 리포트 다운로드 URL이 가리키는 실제 파일 서빙. S3 presigned URL의 로컬 대체 구현. */ | ||
| @RestController | ||
| class ReportDownloadController( | ||
| private val reportObjectStoragePort: ReportObjectStoragePort, | ||
| ) { | ||
| @GetMapping("/api/monitor/v11/reports/download") | ||
| fun download(@RequestParam token: String): ResponseEntity<ByteArray> { | ||
| val downloaded = reportObjectStoragePort.resolve(token) ?: return ResponseEntity.notFound().build() | ||
| val safeFileName = downloaded.fileName.filterNot { it == '"' || it == '\n' || it == '\r' } | ||
| return ResponseEntity.ok() | ||
| .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"$safeFileName\"") | ||
| .contentType(MediaType.APPLICATION_OCTET_STREAM) | ||
| .body(downloaded.bytes) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| package hs.kr.entrydsm.observability.adapterin.web.dto.response | ||
|
|
||
| import java.time.Instant | ||
|
|
||
| data class LiveLogEventResponse( | ||
| val kind: String, | ||
| val level: String, | ||
| val source: String, | ||
| val message: String, | ||
| val pageUrl: String, | ||
| val occurredAt: Instant, | ||
| ) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| package hs.kr.entrydsm.observability.adapterin.web.dto.response | ||
|
|
||
| import java.time.Instant | ||
|
|
||
| data class ReportGeneratedResponse( | ||
| val status: String, | ||
| val downloadUrl: String, | ||
| val fileName: String, | ||
| val sizeBytes: Long, | ||
| val expiresAt: Instant, | ||
| ) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| package hs.kr.entrydsm.observability.adapterin.web.security | ||
|
|
||
| import hs.kr.entrydsm.observability.domain.enum.ErrorCode | ||
| import hs.kr.entrydsm.observability.domain.exception.MonitorDomainException | ||
| import io.jsonwebtoken.JwtException | ||
| import io.jsonwebtoken.Jwts | ||
| import io.jsonwebtoken.security.Keys | ||
| import jakarta.servlet.http.HttpServletRequest | ||
| import jakarta.servlet.http.HttpServletResponse | ||
| import java.nio.charset.StandardCharsets | ||
| import org.springframework.stereotype.Component | ||
| import org.springframework.web.servlet.HandlerInterceptor | ||
|
|
||
| /** | ||
| * ponytail: 이 저장소엔 아직 Spring Security/JWT 발급 체계가 병합되어 있지 않아, | ||
| * 전체 Security 스택 대신 Bearer 토큰 서명·발급자·role 클레임만 확인하는 경량 인터셉터로 둔다. | ||
| * identity의 정식 인증이 병합되면 같은 시크릿을 공유하도록 설정만 맞추면 된다. | ||
| */ | ||
| @Component | ||
| class JwtAuthInterceptor( | ||
| private val properties: JwtAuthProperties, | ||
| ) : HandlerInterceptor { | ||
| private val key by lazy { Keys.hmacShaKeyFor(properties.secret.toByteArray(StandardCharsets.UTF_8)) } | ||
|
|
||
| override fun preHandle(request: HttpServletRequest, response: HttpServletResponse, handler: Any): Boolean { | ||
| val token = bearerToken(request) ?: throw MonitorDomainException(ErrorCode.UNAUTHORIZED) | ||
| val claims = try { | ||
| Jwts.parser() | ||
| .verifyWith(key) | ||
| .requireIssuer(properties.issuer) | ||
| .build() | ||
| .parseSignedClaims(token) | ||
| .payload | ||
| } catch (exception: JwtException) { | ||
| throw MonitorDomainException(ErrorCode.UNAUTHORIZED) | ||
| } catch (exception: IllegalArgumentException) { | ||
| throw MonitorDomainException(ErrorCode.UNAUTHORIZED) | ||
| } | ||
| if (claims["role"] as? String != ADMIN_ROLE) { | ||
| throw MonitorDomainException(ErrorCode.FORBIDDEN) | ||
| } | ||
| return true | ||
| } | ||
|
|
||
| private fun bearerToken(request: HttpServletRequest): String? { | ||
| val header = request.getHeader("Authorization") ?: return null | ||
| if (!header.startsWith(BEARER_PREFIX)) return null | ||
| return header.removePrefix(BEARER_PREFIX).trim().takeIf { it.isNotEmpty() } | ||
| } | ||
|
|
||
| companion object { | ||
| private const val BEARER_PREFIX = "Bearer " | ||
| private const val ADMIN_ROLE = "ADMIN" | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| package hs.kr.entrydsm.observability.adapterin.web.security | ||
|
|
||
| import jakarta.validation.constraints.NotBlank | ||
| import org.springframework.boot.context.properties.ConfigurationProperties | ||
| import org.springframework.stereotype.Component | ||
| import org.springframework.validation.annotation.Validated | ||
|
|
||
| /** | ||
| * identity의 auth.jwt.secret/issuer와 설정 키 이름을 통일해, 정식 인증이 병합되면 | ||
| * 같은 시크릿을 가리키도록 맞추기만 하면 되게 한다. | ||
| */ | ||
| @Component | ||
| @ConfigurationProperties(prefix = "auth.jwt") | ||
| @Validated | ||
| class JwtAuthProperties { | ||
| /** 비어 있으면 기동에 실패한다. 알려진 기본 시크릿으로 토큰이 검증되는 상태를 만들지 않기 위한 것. */ | ||
| @field:NotBlank | ||
| lateinit var secret: String | ||
|
|
||
| @field:NotBlank | ||
| lateinit var issuer: String | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| package hs.kr.entrydsm.observability.adapterin.web.security | ||
|
|
||
| import org.springframework.context.annotation.Configuration | ||
| import org.springframework.web.servlet.config.annotation.InterceptorRegistry | ||
| import org.springframework.web.servlet.config.annotation.WebMvcConfigurer | ||
|
|
||
| @Configuration | ||
| class WebMvcConfig( | ||
| private val jwtAuthInterceptor: JwtAuthInterceptor, | ||
| ) : WebMvcConfigurer { | ||
| override fun addInterceptors(registry: InterceptorRegistry) { | ||
| registry.addInterceptor(jwtAuthInterceptor) | ||
| .addPathPatterns("/api/monitor/v11/**") | ||
| .excludePathPatterns( | ||
| "/api/monitor/v11/collect/session", | ||
| "/api/monitor/v11/collect/client-log", | ||
| ) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| package hs.kr.entrydsm.observability.adapterin.web.sse | ||
|
|
||
| import hs.kr.entrydsm.observability.adapterin.web.dto.common.toResponse | ||
| import hs.kr.entrydsm.observability.application.port.`in`.GetDashboardSnapshotUseCase | ||
| import java.util.concurrent.CopyOnWriteArrayList | ||
| import java.util.concurrent.atomic.AtomicLong | ||
| import org.springframework.scheduling.annotation.Scheduled | ||
| import org.springframework.stereotype.Component | ||
| import org.springframework.web.servlet.mvc.method.annotation.SseEmitter | ||
|
|
||
| /** | ||
| * ponytail: 커넥션 목록을 인메모리로만 들고 있어 다중 인스턴스에서는 인스턴스별로 브로드캐스트가 갈린다. | ||
| * 수평 확장이 필요해지면 Redis Pub/Sub로 교체한다. | ||
| * ponytail: 구독자에게 순차 전송한다. 느린 구독자 하나가 나머지 전송을 지연시킬 수 있다(관리자 대시보드라 동시 구독자가 적다). | ||
| * 구독자가 늘면 커넥션별 bounded 큐와 전용 executor로 분리한다. | ||
| */ | ||
| @Component | ||
| class SseBroadcaster( | ||
| private val getDashboardSnapshotUseCase: GetDashboardSnapshotUseCase, | ||
| ) { | ||
| private val emitters = CopyOnWriteArrayList<SseEmitter>() | ||
| private val eventIdSeq = AtomicLong(System.currentTimeMillis()) | ||
|
|
||
| fun register(emitter: SseEmitter) { | ||
| emitters.add(emitter) | ||
| emitter.onCompletion { emitters.remove(emitter) } | ||
| emitter.onTimeout { emitters.remove(emitter) } | ||
| emitter.onError { emitters.remove(emitter) } | ||
| sendInitialSnapshot(emitter) | ||
| } | ||
|
|
||
| fun publishLog(payload: Any) { | ||
| broadcast("log", payload) | ||
| } | ||
|
|
||
| @Scheduled(fixedRate = 5000) | ||
| fun broadcastFrequent() { | ||
| if (emitters.isEmpty()) return | ||
| val snapshot = getDashboardSnapshotUseCase.getSnapshot(null) | ||
| broadcast("traffic", snapshot.traffic.toResponse()) | ||
| broadcast("api", snapshot.api.toResponse()) | ||
| broadcast("business", snapshot.business.toResponse()) | ||
| broadcast("service", snapshot.services.toResponse()) | ||
| } | ||
|
|
||
| @Scheduled(fixedRate = 300000) | ||
| fun broadcastResource() { | ||
| if (emitters.isEmpty()) return | ||
| broadcast("resource", getDashboardSnapshotUseCase.getSnapshot(null).resource.toResponse()) | ||
| } | ||
|
|
||
| @Scheduled(fixedRate = 15000) | ||
| fun ping() { | ||
| emitters.forEach { emitter -> | ||
| runCatching { emitter.send(SseEmitter.event().comment("ping")) } | ||
| .onFailure { emitters.remove(emitter) } | ||
| } | ||
| } | ||
|
|
||
| private fun sendInitialSnapshot(emitter: SseEmitter) { | ||
| runCatching { | ||
| emitter.send(SseEmitter.event().reconnectTime(RECONNECT_TIME_MS)) | ||
| val snapshot = getDashboardSnapshotUseCase.getSnapshot(null) | ||
| emitter.send(SseEmitter.event().id(nextId()).name("snapshot").data(snapshot.toResponse())) | ||
| }.onFailure { emitters.remove(emitter) } | ||
| } | ||
|
|
||
| private fun broadcast(eventName: String, data: Any) { | ||
| emitters.forEach { emitter -> | ||
| runCatching { emitter.send(SseEmitter.event().id(nextId()).name(eventName).data(data)) } | ||
| .onFailure { emitters.remove(emitter) } | ||
|
Comment on lines
+68
to
+71
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- target outline ---'
ast-grep outline systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/sse/SseBroadcaster.kt || true
printf '%s\n' '--- target source ---'
cat -n systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/sse/SseBroadcaster.kt
printf '%s\n' '--- relevant symbols and scheduler configuration ---'
rg -n -S \
'SseBroadcaster|broadcastFrequent|broadcastResource|ping|Scheduled|TaskScheduler|ThreadPoolTaskScheduler|SseEmitter|\.send\(' \
systems gradle* build.gradle* settings.gradle* 2>/dev/null | head -n 300
printf '%s\n' '--- related tests ---'
rg -l -S 'SseBroadcaster|SseEmitter|broadcastFrequent|broadcastResource' . --glob '*Test.kt' --glob '*Tests.kt' --glob '*test*' | sortRepository: EntryDSM/entrydsm-platform Length of output: 12866 🌐 Web query:
💡 Result: SseEmitter writes to the HTTP response are blocking operations [1]. When using SseEmitter, these blocking writes are executed on a separate thread, which is managed by a configured AsyncTaskExecutor [1]. It is important to distinguish between the AsyncTaskExecutor used for SseEmitter and the TaskScheduler typically used for Citations: 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- scheduling and executor configuration ---'
rg -n -S \
'`@EnableScheduling`|`@EnableAsync`|SchedulingConfigurer|configureTasks|TaskScheduler|TaskExecutor|AsyncTaskExecutor|ThreadPoolTaskScheduler|ThreadPoolTaskExecutor|spring\.task\.scheduling|scheduling' \
. --glob '!**/build/**' --glob '!**/node_modules/**' | head -n 400
printf '%s\n' '--- broadcaster call sites ---'
rg -n -S 'sseBroadcaster\.(register|publishLog)|SseLiveLogPublisher|MonitorStreamController' \
systems --glob '*.kt' | head -n 300
printf '%s\n' '--- stream and publisher implementations ---'
for f in $(fd -t f -e kt . systems/observability/observability-adapter-in/src/main/kotlin | \
xargs rg -l -S 'class MonitorStreamController|class SseLiveLogPublisher'); do
echo "--- $f"
cat -n "$f"
done
printf '%s\n' '--- test files in the observability adapter-in subsystem ---'
git ls-files 'systems/observability/observability-adapter-in' | \
rg '(^|/)(test|tests)(/|$)|Test\.kt$|Tests\.kt$' || trueRepository: EntryDSM/entrydsm-platform Length of output: 5359 🏁 Script executed: #!/bin/bash
set -eu
python3 - <<'PY'
from pathlib import Path
import re
target = Path("systems/observability/observability-adapter-in/src/main/kotlin/hs/kr/entrydsm/observability/adapterin/web/sse/SseBroadcaster.kt")
text = target.read_text()
checks = {
"scheduled methods call broadcast": bool(re.search(r'`@Scheduled`[\s\S]*?fun broadcastFrequent[\s\S]*?broadcast\(', text)),
"scheduled methods call emitter.send": bool(re.search(r'`@Scheduled`[\s\S]*?fun ping[\s\S]*?emitter\.send\(', text)),
"broadcast iterates emitters": bool(re.search(r'emitters\.forEach\s*\{\s*emitter\s*->[\s\S]*?emitter\.send\(', text)),
"send failures remove emitter": bool(re.search(r'\.onFailure\s*\{\s*emitters\.remove\(emitter\)', text)),
}
for name, result in checks.items():
print(f"{name}: {result}")
for m in re.finditer(r'(`@Scheduled`[^\n]*\n\s*fun\s+\w+\(\)[\s\S]*?)(?=\n\s*`@Scheduled`|\n\s*private fun|\n\s*companion object)', text):
block = m.group(1)
method = re.search(r'fun\s+(\w+)', block).group(1)
sends = len(re.findall(r'\.send\(', block))
broadcasts = len(re.findall(r'\bbroadcast\(', block))
print(f"{method}: send_calls={sends}, broadcast_calls={broadcasts}")
PYRepository: EntryDSM/entrydsm-platform Length of output: 454 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- application bootstrap ---'
cat -n systems/observability/observability-bootstrap/src/main/kotlin/hs/kr/entrydsm/ExampleApplication.kt
printf '%s\n' '--- Spring dependency versions ---'
rg -n -S \
'spring-boot|spring-framework|springBoot|org\.springframework' \
gradle.properties settings.gradle* build.gradle* systems --glob '*.gradle' --glob '*.gradle.kts' --glob '*.toml' --glob '*.properties' | head -n 250
printf '%s\n' '--- all scheduled methods ---'
rg -n -B 3 -A 12 '`@Scheduled`' . --glob '*.kt' --glob '*.java' --glob '!**/build/**'Repository: EntryDSM/entrydsm-platform Length of output: 6422 🌐 Web query:
💡 Result: By default, Spring's Citations: 🌐 Web query:
💡 Result: In Spring MVC, while ResponseBodyEmitter and its subclass SseEmitter support asynchronous streaming, individual writes to the response remain inherently blocking [1]. When you return an SseEmitter from a controller method, Spring MVC executes the writing process on a separate thread provided by the configured AsyncTaskExecutor [1]. This architecture ensures that the main servlet container thread is not blocked, allowing it to return to the thread pool while the SSE stream continues to process data [1]. Key points regarding the interaction between these components include: 1. Blocking Nature of Writes: Although the overall programming model is asynchronous, the actual I/O operations for each write to the response are blocking [1]. This is a fundamental difference between Spring MVC and Spring WebFlux; WebFlux utilizes non-blocking I/O and does not require a dedicated thread per write [1]. 2. Role of AsyncTaskExecutor: The AsyncTaskExecutor is responsible for handling the blocking writes when streaming, as well as executing tasks associated with asynchronous request processing (such as Callable or DeferredResult) [1]. 3. Performance Considerations: Spring explicitly notes that the default AsyncTaskExecutor is often not suitable for production environments under heavy load [1]. It is recommended to configure a custom TaskExecutor (e.g., using a ThreadPoolTaskExecutor) with appropriately tuned parameters to prevent thread exhaustion or performance degradation during streaming operations [1]. By offloading these blocking writes to a separate thread pool managed by the AsyncTaskExecutor, Spring MVC maintains scalability even while using blocking I/O for streaming responses [1]. Citations: 느린 SSE 구독자의 전송을 분리하십시오.
전용 bounded executor와 연결별 bounded outbound queue를 사용하십시오. 큐가 가득 차면 해당 emitter를 종료하십시오. 스케줄러 및 API 요청 스레드에서 직접 🤖 Prompt for AI Agents |
||
| } | ||
| } | ||
|
|
||
| private fun nextId(): String = eventIdSeq.incrementAndGet().toString() | ||
|
|
||
| companion object { | ||
| private const val RECONNECT_TIME_MS = 5000L | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| package hs.kr.entrydsm.observability.adapterin.web.sse | ||
|
|
||
| import java.util.concurrent.ConcurrentHashMap | ||
| import org.springframework.stereotype.Component | ||
|
|
||
| /** | ||
| * ponytail: 단일 인스턴스 가정의 인메모리 카운터. 인증 붙기 전이라 계정 대신 IP 기준으로 제한한다. | ||
| * JWT 인증이 연결되면 계정 단위로 교체한다. | ||
| */ | ||
| @Component | ||
| class SseConnectionLimiter { | ||
| private val counts = ConcurrentHashMap<String, Int>() | ||
|
|
||
| fun tryAcquire(key: String): Boolean { | ||
| // compute는 키 단위로 원자적이다. 읽고 쓰는 사이에 다른 요청이 끼어들어 제한을 넘기지 않는다. | ||
| var acquired = false | ||
| counts.compute(key) { _, current -> | ||
| val count = current ?: 0 | ||
| if (count < MAX_CONNECTIONS_PER_KEY) { | ||
| acquired = true | ||
| count + 1 | ||
| } else { | ||
| count | ||
| } | ||
| } | ||
| return acquired | ||
| } | ||
|
|
||
| fun release(key: String) { | ||
| // 마지막 연결이 끊기면 키까지 지운다. 남겨두면 IP 수만큼 맵이 계속 커진다. | ||
| counts.computeIfPresent(key) { _, count -> (count - 1).takeIf { it > 0 } } | ||
| } | ||
|
|
||
| companion object { | ||
| private const val MAX_CONNECTIONS_PER_KEY = 3 | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: EntryDSM/entrydsm-platform
Length of output: 17569
🏁 Script executed:
Repository: EntryDSM/entrydsm-platform
Length of output: 34168
🏁 Script executed:
Repository: EntryDSM/entrydsm-platform
Length of output: 955
🏁 Script executed:
Repository: EntryDSM/entrydsm-platform
Length of output: 4652
🏁 Script executed:
Repository: EntryDSM/entrydsm-platform
Length of output: 8693
🌐 Web query:
Spring Boot 4 ConfigurationProperties JavaBean lateinit property missing binding startup validation@validateddocumentation💡 Result:
In Spring Boot, using Kotlin's lateinit properties with
@ConfigurationPropertiescan lead to runtime issues, such as UninitializedPropertyAccessException [1][2]. This occurs because the Spring Boot binder may attempt to access these properties to determine if they have already been initialized before applying values, which is incompatible with the semantics of lateinit [2]. For reliable configuration binding and validation: 1. Constructor Binding: Use constructor-based property binding instead of lateinit properties [3][4]. This is the recommended, idiomatic way to handle immutable configuration in Kotlin [3]. 2. Validation: To use@Validatedeffectively with constructor parameters, ensure you use the appropriate annotation use-site targets (e.g.,@field:NotBlank) [5][6]. Simply annotating the constructor parameters directly is often insufficient because Hibernate Validator looks for constraints on fields or getters, not constructor arguments [6]. By using use-site targets, you instruct the Kotlin compiler to place the validation annotations where the validator can discover them [5][6]. Example of recommended usage:@Validated@ConfigurationProperties("my.app") data class MyProperties(@field:NotBlank val username: String,@field:NotBlank val password: String) If you must use setter-based injection, standard mutable properties (or nullable types) are generally preferred over lateinit properties to avoid the uninitialized access exceptions during the binding process [5]. Spring Boot validation at startup requires the presence of a JSR-303 implementation, such as the spring-boot-starter-validation dependency [5][7].Citations:
기본 JWT 시크릿으로 애플리케이션이 시작되지 않게 하세요.
application.yaml은JWT_SECRET이 없을 때 알려진 개발용 문자열을 사용합니다. 운영 환경에서 환경 변수가 누락되면 애플리케이션이 정상 시작하고, 공격자가 해당 시크릿으로 JWT를 위조할 수 있습니다.JWT_SECRET의 기본값을 제거하고auth.jwt.secret을 시작 시 필수 값으로 검증하세요. 누락 또는 공백 값에 대한 설정 바인딩 테스트도 추가하세요.🧰 Tools
🪛 detekt (1.23.8)
[warning] 13-13: Usages of lateinit should be avoided.
(detekt.potential-bugs.LateinitUsage)
[warning] 14-14: Usages of lateinit should be avoided.
(detekt.potential-bugs.LateinitUsage)
🤖 Prompt for AI Agents
Source: Linters/SAST tools