package main import ( "bufio" "context" "crypto/tls" "encoding/json" "flag" "fmt" "log" "math" "math/rand" "net" "net/http" "net/url" "os" "os/exec" "regexp" "runtime" "sort" "strconv" "strings" "sync" "sync/atomic" "time" "github.com/gorilla/websocket" ) const ( modeLoadTest = "loadtest" modeFindMaxUsers = "find-max-users" labelRoot = "GET / 메인" labelHealth = "GET /health 상태확인" labelWebSocket = "WS /ws 터미널" ) var ( netstatPIDPattern = regexp.MustCompile(`LISTENING\s+(\d+)\s*$`) ssPIDPattern = regexp.MustCompile(`pid=(\d+)`) globalAggregator atomic.Pointer[aggregator] ) type config struct { mode string targetURL string users int duration time.Duration rampUp time.Duration timeout time.Duration wsTimeout time.Duration pauseMin time.Duration pauseMax time.Duration wsRatio float64 wsMaxConn int wsCommand string insecure bool monitorInterval time.Duration searchStartUsers int searchStepUsers int searchMaxUsers int maxFailureRate float64 maxP95 time.Duration } type scenario int const ( scenarioRoot scenario = iota scenarioHealth scenarioWebSocket ) type result struct { name string statusCode int duration time.Duration err error } type aggregator struct { mu sync.Mutex startedAt time.Time finishedAt time.Time total int64 success int64 failed int64 statusCounts map[int]int64 scenarioCounts map[string]int64 errorCounts map[string]int64 latencies []time.Duration scenarioLatencies map[string][]time.Duration } type aggregateSnapshot struct { total int64 success int64 failed int64 } type testReport struct { Config config StartedUsers int64 Elapsed time.Duration Total int64 Success int64 Failed int64 SuccessRate float64 RequestsPerSecond float64 AverageLatency time.Duration P50 time.Duration P95 time.Duration P99 time.Duration MaxLatency time.Duration StatusCounts map[int]int64 ScenarioCounts map[string]int64 ErrorCounts map[string]int64 ScenarioStats map[string]scenarioStat Memory serverMemoryStatsSnapshot } type scenarioStat struct { Count int64 Average time.Duration P95 time.Duration } type socketResponse struct { Type string `json:"type"` Data string `json:"data"` } type socketRequest struct { Type string `json:"type"` Data string `json:"data"` } type serverMemorySample struct { PID int WorkingSetBytes uint64 PrivateBytes uint64 } type serverMemoryStats struct { Enabled bool LocalTarget bool TargetPort string ProcessName string PID int LastWorkingSet uint64 LastPrivate uint64 MaxWorkingSet uint64 MaxPrivate uint64 SampleCount int64 LastError string Verbose bool mu sync.Mutex } type serverMemoryStatsSnapshot struct { Enabled bool LocalTarget bool ProcessName string PID int LastWorkingSet uint64 LastPrivate uint64 MaxWorkingSet uint64 MaxPrivate uint64 SampleCount int64 LastError string } func main() { cfg := parseFlags() if err := validateConfig(cfg); err != nil { log.Fatalf("잘못된 설정입니다: %v", err) } switch cfg.mode { case modeLoadTest: report := runLoadTest(cfg, true, true) printSummary(report) case modeFindMaxUsers: runMaxUserSearch(cfg) default: log.Fatalf("지원하지 않는 실행 모드입니다: %s", cfg.mode) } } func parseFlags() config { cfg := config{} flag.StringVar(&cfg.mode, "mode", modeLoadTest, "실행 모드(loadtest, find-max-users)") flag.StringVar(&cfg.targetURL, "target", "http://localhost:8080", "테스트 대상 기본 URL") flag.IntVar(&cfg.users, "users", 200, "동시 가상 사용자 수") flag.DurationVar(&cfg.duration, "duration", 30*time.Second, "전체 테스트 지속 시간") flag.DurationVar(&cfg.rampUp, "ramp-up", 10*time.Second, "전체 사용자를 점진적으로 올리는 시간") flag.DurationVar(&cfg.timeout, "timeout", 5*time.Second, "HTTP 요청 타임아웃") flag.DurationVar(&cfg.wsTimeout, "ws-timeout", 15*time.Second, "웹소켓 연결/읽기/쓰기 타임아웃") flag.DurationVar(&cfg.pauseMin, "pause-min", 150*time.Millisecond, "사용자 액션 사이 최소 대기 시간") flag.DurationVar(&cfg.pauseMax, "pause-max", 600*time.Millisecond, "사용자 액션 사이 최대 대기 시간") flag.Float64Var(&cfg.wsRatio, "ws-ratio", 0.05, "전체 시나리오 중 웹소켓 비율(0~1)") flag.IntVar(&cfg.wsMaxConn, "ws-max-conns", 20, "동시에 열 수 있는 최대 웹소켓 세션 수") flag.StringVar(&cfg.wsCommand, "ws-command", "Get-Location", "웹소켓 연결 후 전송할 PowerShell 명령어") flag.BoolVar(&cfg.insecure, "insecure", false, "wss 대상의 TLS 인증서 검증 건너뛰기") flag.DurationVar(&cfg.monitorInterval, "monitor-interval", 1*time.Second, "로컬 서버 메모리 실시간 출력 주기") flag.IntVar(&cfg.searchStartUsers, "search-start-users", 100, "최대 동접 탐색 시작 사용자 수") flag.IntVar(&cfg.searchStepUsers, "search-step-users", 100, "최대 동접 탐색 증가 폭") flag.IntVar(&cfg.searchMaxUsers, "search-max-users", 2000, "최대 동접 탐색 상한 사용자 수") flag.Float64Var(&cfg.maxFailureRate, "max-failure-rate", 1.0, "허용 실패율 퍼센트") flag.DurationVar(&cfg.maxP95, "max-p95", 1*time.Second, "허용 P95 지연 시간") flag.Parse() return cfg } func validateConfig(cfg config) error { if cfg.users <= 0 { return fmt.Errorf("users must be greater than 0") } if cfg.duration <= 0 { return fmt.Errorf("duration must be greater than 0") } if cfg.rampUp < 0 { return fmt.Errorf("ramp-up cannot be negative") } if cfg.timeout <= 0 { return fmt.Errorf("timeout must be greater than 0") } if cfg.wsTimeout <= 0 { return fmt.Errorf("ws-timeout must be greater than 0") } if cfg.pauseMin < 0 || cfg.pauseMax < 0 { return fmt.Errorf("pauses cannot be negative") } if cfg.pauseMax < cfg.pauseMin { return fmt.Errorf("pause-max must be greater than or equal to pause-min") } if cfg.wsRatio < 0 || cfg.wsRatio > 1 { return fmt.Errorf("ws-ratio must be between 0 and 1") } if cfg.wsMaxConn <= 0 { return fmt.Errorf("ws-max-conns must be greater than 0") } if cfg.monitorInterval < 0 { return fmt.Errorf("monitor-interval cannot be negative") } if cfg.searchStartUsers <= 0 { return fmt.Errorf("search-start-users must be greater than 0") } if cfg.searchStepUsers <= 0 { return fmt.Errorf("search-step-users must be greater than 0") } if cfg.searchMaxUsers < cfg.searchStartUsers { return fmt.Errorf("search-max-users must be greater than or equal to search-start-users") } if cfg.maxFailureRate < 0 || cfg.maxFailureRate > 100 { return fmt.Errorf("max-failure-rate must be between 0 and 100") } if cfg.maxP95 <= 0 { return fmt.Errorf("max-p95 must be greater than 0") } return nil } func runMaxUserSearch(cfg config) { log.Printf( "최대 동접 탐색 시작: 시작=%d 증가폭=%d 상한=%d 허용실패율=%.2f%% 허용P95=%s", cfg.searchStartUsers, cfg.searchStepUsers, cfg.searchMaxUsers, cfg.maxFailureRate, cfg.maxP95, ) var best *testReport for users := cfg.searchStartUsers; users <= cfg.searchMaxUsers; users += cfg.searchStepUsers { stageCfg := cfg stageCfg.users = users fmt.Println() fmt.Printf("=== 동접 %d명 탐색 단계 ===\n", users) report := runLoadTest(stageCfg, true, false) printCompactStageResult(report, cfg.maxFailureRate, cfg.maxP95) if isStable(report, cfg.maxFailureRate, cfg.maxP95) { reportCopy := report best = &reportCopy continue } fmt.Println() fmt.Println("최대 동접 탐색 결과") if best == nil { fmt.Printf("안정 구간을 찾지 못했습니다. 시작 사용자 수 %d명부터 기준을 넘었습니다.\n", cfg.searchStartUsers) } else { fmt.Printf("최대 안정 동시 사용자 수: %d명\n", best.Config.users) fmt.Printf("기준: 실패율 %.2f%% 이하, P95 %s 이하\n", cfg.maxFailureRate, cfg.maxP95) fmt.Printf("해당 단계 결과: 성공률 %.2f%%, P95 %s, 초당 요청 수 %.2f\n", best.SuccessRate, best.P95, best.RequestsPerSecond) if best.Memory.SampleCount > 0 { fmt.Printf("해당 단계 최대 서버 사용 메모리: %s\n", formatBytes(best.Memory.MaxPrivate)) } } return } fmt.Println() fmt.Println("최대 동접 탐색 결과") if best == nil { fmt.Println("안정 구간을 찾지 못했습니다.") return } fmt.Printf("탐색 상한까지 모두 통과했습니다. 현재 최대 안정 동시 사용자 수는 최소 %d명입니다.\n", best.Config.users) fmt.Printf("기준: 실패율 %.2f%% 이하, P95 %s 이하\n", cfg.maxFailureRate, cfg.maxP95) fmt.Printf("마지막 단계 결과: 성공률 %.2f%%, P95 %s, 초당 요청 수 %.2f\n", best.SuccessRate, best.P95, best.RequestsPerSecond) if best.Memory.SampleCount > 0 { fmt.Printf("마지막 단계 최대 서버 사용 메모리: %s\n", formatBytes(best.Memory.MaxPrivate)) } } func printCompactStageResult(report testReport, maxFailureRate float64, maxP95 time.Duration) { result := "실패" if isStable(report, maxFailureRate, maxP95) { result = "통과" } fmt.Printf( "결과=%s 사용자=%d 성공률=%.2f%% 실패=%d P95=%s RPS=%.2f", result, report.Config.users, report.SuccessRate, report.Failed, report.P95, report.RequestsPerSecond, ) if report.Memory.SampleCount > 0 { fmt.Printf(" 최대서버메모리=%s", formatBytes(report.Memory.MaxPrivate)) } fmt.Println() } func isStable(report testReport, maxFailureRate float64, maxP95 time.Duration) bool { if report.Total == 0 { return false } failureRate := 100 - report.SuccessRate return failureRate <= maxFailureRate && report.P95 <= maxP95 } func runLoadTest(cfg config, printStartLog bool, verboseMemory bool) testReport { if printStartLog { log.Printf( "부하 테스트 시작: 대상=%s 사용자=%d 지속시간=%s 램프업=%s 웹소켓비율=%.2f", cfg.targetURL, cfg.users, cfg.duration, cfg.rampUp, cfg.wsRatio, ) } agg := newAggregator() globalAggregator.Store(agg) agg.startedAt = time.Now() ctx, cancel := context.WithTimeout(context.Background(), cfg.duration) defer cancel() httpClient := newHTTPClient(cfg.timeout) wsURL, err := deriveWebSocketURL(cfg.targetURL) if err != nil { log.Fatalf("잘못된 대상 URL입니다: %v", err) } memoryStats := startServerMemoryMonitor(ctx, cfg, verboseMemory) wsSlots := make(chan struct{}, cfg.wsMaxConn) var startedUsers atomic.Int64 var wg sync.WaitGroup for i := 0; i < cfg.users; i++ { wg.Add(1) go func(userID int) { defer wg.Done() delay := rampDelay(cfg.rampUp, cfg.users, userID) if !sleepWithContext(ctx, delay) { return } startedUsers.Add(1) runVirtualUser(ctx, userID, cfg, httpClient, wsURL, wsSlots, agg) }(i) } wg.Wait() agg.finishedAt = time.Now() return buildReport(cfg, agg, startedUsers.Load(), memoryStats.snapshot()) } func newHTTPClient(timeout time.Duration) *http.Client { transport := &http.Transport{ MaxIdleConns: 512, MaxIdleConnsPerHost: 512, MaxConnsPerHost: 0, IdleConnTimeout: 90 * time.Second, } return &http.Client{ Timeout: timeout, Transport: transport, } } func deriveWebSocketURL(target string) (string, error) { parsed, err := url.Parse(target) if err != nil { return "", err } switch parsed.Scheme { case "http": parsed.Scheme = "ws" case "https": parsed.Scheme = "wss" case "ws", "wss": default: return "", fmt.Errorf("지원하지 않는 스킴 %q", parsed.Scheme) } parsed.Path = "/ws" parsed.RawQuery = "" parsed.Fragment = "" return parsed.String(), nil } func rampDelay(rampUp time.Duration, users int, userID int) time.Duration { if rampUp <= 0 || users <= 1 { return 0 } step := float64(rampUp) / float64(users) return time.Duration(step * float64(userID)) } func sleepWithContext(ctx context.Context, d time.Duration) bool { if d <= 0 { return true } timer := time.NewTimer(d) defer timer.Stop() select { case <-ctx.Done(): return false case <-timer.C: return true } } func runVirtualUser(ctx context.Context, userID int, cfg config, httpClient *http.Client, wsURL string, wsSlots chan struct{}, agg *aggregator) { rng := rand.New(rand.NewSource(time.Now().UnixNano() + int64(userID)*7919)) for { if ctx.Err() != nil { return } currentScenario := chooseScenario(rng, cfg.wsRatio) var res result switch currentScenario { case scenarioRoot: res = hitEndpoint(ctx, httpClient, cfg.targetURL, labelRoot) case scenarioHealth: res = hitEndpoint(ctx, httpClient, strings.TrimRight(cfg.targetURL, "/")+"/health", labelHealth) default: res = hitWebSocketWithLimit(ctx, wsURL, cfg.wsCommand, cfg.wsTimeout, cfg.insecure, wsSlots) } agg.add(res) pause := cfg.pauseMin if cfg.pauseMax > cfg.pauseMin { pause += time.Duration(rng.Int63n(int64(cfg.pauseMax - cfg.pauseMin))) } if !sleepWithContext(ctx, pause) { return } } } func chooseScenario(rng *rand.Rand, wsRatio float64) scenario { roll := rng.Float64() if roll < wsRatio { return scenarioWebSocket } if roll < wsRatio+0.30 { return scenarioHealth } return scenarioRoot } func hitEndpoint(ctx context.Context, client *http.Client, endpoint string, name string) result { startedAt := time.Now() req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return result{name: name, duration: time.Since(startedAt), err: err} } resp, err := client.Do(req) if err != nil { return result{name: name, duration: time.Since(startedAt), err: err} } defer resp.Body.Close() return result{ name: name, statusCode: resp.StatusCode, duration: time.Since(startedAt), } } func hitWebSocketWithLimit(ctx context.Context, wsURL string, command string, timeout time.Duration, insecure bool, wsSlots chan struct{}) result { select { case wsSlots <- struct{}{}: defer func() { <-wsSlots }() return hitWebSocket(ctx, wsURL, command, timeout, insecure) case <-ctx.Done(): return result{name: labelWebSocket, err: ctx.Err()} } } func hitWebSocket(ctx context.Context, wsURL string, command string, timeout time.Duration, insecure bool) result { startedAt := time.Now() dialer := websocket.Dialer{ HandshakeTimeout: timeout, TLSClientConfig: &tls.Config{ InsecureSkipVerify: insecure, }, } conn, _, err := dialer.DialContext(ctx, wsURL, nil) if err != nil { return result{name: labelWebSocket, duration: time.Since(startedAt), err: err} } defer conn.Close() _ = conn.SetReadDeadline(time.Now().Add(timeout)) _ = conn.SetWriteDeadline(time.Now().Add(timeout)) var welcome socketResponse if err := conn.ReadJSON(&welcome); err != nil { return result{name: labelWebSocket, duration: time.Since(startedAt), err: err} } req := socketRequest{ Type: "input", Data: command, } if err := conn.WriteJSON(req); err != nil { return result{name: labelWebSocket, duration: time.Since(startedAt), err: err} } var output socketResponse if err := conn.ReadJSON(&output); err != nil { return result{name: labelWebSocket, duration: time.Since(startedAt), err: err} } if welcome.Type == "error" || output.Type == "error" { return result{ name: labelWebSocket, duration: time.Since(startedAt), err: fmt.Errorf("서버가 웹소켓 오류를 반환했습니다"), } } return result{ name: labelWebSocket, statusCode: http.StatusSwitchingProtocols, duration: time.Since(startedAt), } } func newAggregator() *aggregator { return &aggregator{ statusCounts: make(map[int]int64), scenarioCounts: make(map[string]int64), errorCounts: make(map[string]int64), scenarioLatencies: make(map[string][]time.Duration), } } func (a *aggregator) add(res result) { a.mu.Lock() defer a.mu.Unlock() a.total++ a.scenarioCounts[res.name]++ a.latencies = append(a.latencies, res.duration) a.scenarioLatencies[res.name] = append(a.scenarioLatencies[res.name], res.duration) if res.err != nil { a.failed++ a.errorCounts[shortError(res.err)]++ return } a.success++ a.statusCounts[res.statusCode]++ } func (a *aggregator) snapshot() aggregateSnapshot { a.mu.Lock() defer a.mu.Unlock() return aggregateSnapshot{ total: a.total, success: a.success, failed: a.failed, } } func buildReport(cfg config, agg *aggregator, startedUsers int64, memory serverMemoryStatsSnapshot) testReport { agg.mu.Lock() defer agg.mu.Unlock() elapsed := agg.finishedAt.Sub(agg.startedAt) if elapsed <= 0 { elapsed = time.Millisecond } successRate := 0.0 if agg.total > 0 { successRate = float64(agg.success) / float64(agg.total) * 100 } report := testReport{ Config: cfg, StartedUsers: startedUsers, Elapsed: elapsed, Total: agg.total, Success: agg.success, Failed: agg.failed, SuccessRate: successRate, RequestsPerSecond: float64(agg.total) / elapsed.Seconds(), StatusCounts: copyIntMap(agg.statusCounts), ScenarioCounts: copyStringMap(agg.scenarioCounts), ErrorCounts: copyStringMap(agg.errorCounts), ScenarioStats: make(map[string]scenarioStat), Memory: memory, } if len(agg.latencies) > 0 { report.AverageLatency = averageDuration(agg.latencies) report.P50 = percentile(agg.latencies, 50) report.P95 = percentile(agg.latencies, 95) report.P99 = percentile(agg.latencies, 99) report.MaxLatency = maxDuration(agg.latencies) } for name, latencies := range agg.scenarioLatencies { report.ScenarioStats[name] = scenarioStat{ Count: agg.scenarioCounts[name], Average: averageDuration(latencies), P95: percentile(latencies, 95), } } return report } func shortError(err error) string { if err == nil { return "" } msg := normalizeError(err.Error()) if len(msg) > 120 { return msg[:120] } return msg } func normalizeError(msg string) string { switch { case strings.Contains(msg, "i/o timeout"): return "입출력 시간 초과" case strings.Contains(msg, "context deadline exceeded"): return "컨텍스트 제한 시간 초과" case strings.Contains(msg, "server returned websocket error"): return "서버가 웹소켓 오류를 반환했습니다" default: return msg } } func printSummary(report testReport) { fmt.Println() fmt.Println("=== 부하 테스트 요약 ===") fmt.Printf("대상: %s\n", report.Config.targetURL) fmt.Printf("시작한 사용자: %d/%d\n", report.StartedUsers, report.Config.users) fmt.Printf("지속 시간: %s\n", report.Elapsed.Truncate(time.Millisecond)) fmt.Printf("총 요청 수: %d\n", report.Total) fmt.Printf("성공: %d\n", report.Success) fmt.Printf("실패: %d\n", report.Failed) fmt.Printf("성공률: %.2f%%\n", report.SuccessRate) fmt.Printf("초당 요청 수: %.2f\n", report.RequestsPerSecond) if report.Total > 0 { fmt.Println() fmt.Println("지연 시간") fmt.Printf("평균: %s\n", report.AverageLatency) fmt.Printf("P50: %s\n", report.P50) fmt.Printf("P95: %s\n", report.P95) fmt.Printf("P99: %s\n", report.P99) fmt.Printf("최대: %s\n", report.MaxLatency) } if len(report.ScenarioCounts) > 0 { fmt.Println() fmt.Println("시나리오별 통계") for _, name := range sortedStringKeys(report.ScenarioCounts) { stat := report.ScenarioStats[name] fmt.Printf("%s: %d", name, stat.Count) fmt.Printf(" (평균 %s, p95 %s)\n", stat.Average, stat.P95) } } if len(report.StatusCounts) > 0 { fmt.Println() fmt.Println("HTTP/웹소켓 상태 코드") for _, code := range sortedIntKeys(report.StatusCounts) { fmt.Printf("%d%s: %d\n", code, describeStatusCode(code), report.StatusCounts[code]) } } printMemorySummary(report.Memory) if len(report.ErrorCounts) > 0 { fmt.Println() fmt.Println("오류") for _, msg := range sortedStringKeys(report.ErrorCounts) { fmt.Printf("%s: %d\n", msg, report.ErrorCounts[msg]) } } } func describeStatusCode(code int) string { switch code { case http.StatusOK: return " (HTTP 정상 응답)" case http.StatusSwitchingProtocols: return " (웹소켓 연결 성공)" default: return "" } } func printMemorySummary(memory serverMemoryStatsSnapshot) { if !memory.Enabled { return } fmt.Println() fmt.Println("서버 메모리") if !memory.LocalTarget { fmt.Println("로컬 서버가 아닌 대상이라 메모리 모니터링을 건너뛰었습니다.") return } if memory.SampleCount == 0 { fmt.Printf("메모리 수집 실패") if memory.LastError != "" { fmt.Printf(": %s", memory.LastError) } fmt.Println() return } fmt.Printf("PID: %d\n", memory.PID) if memory.ProcessName != "" { fmt.Printf("프로세스: %s\n", memory.ProcessName) } fmt.Printf("현재 서버 사용 메모리: %s\n", formatBytes(memory.LastPrivate)) fmt.Printf("테스트 중 최대 서버 사용 메모리: %s\n", formatBytes(memory.MaxPrivate)) fmt.Printf("현재 작업 집합 메모리: %s\n", formatBytes(memory.LastWorkingSet)) fmt.Printf("테스트 중 최대 작업 집합 메모리: %s\n", formatBytes(memory.MaxWorkingSet)) fmt.Printf("수집 샘플 수: %d\n", memory.SampleCount) } func averageDuration(values []time.Duration) time.Duration { if len(values) == 0 { return 0 } var total time.Duration for _, v := range values { total += v } return total / time.Duration(len(values)) } func percentile(values []time.Duration, p float64) time.Duration { if len(values) == 0 { return 0 } sorted := append([]time.Duration(nil), values...) sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] }) rank := int(math.Ceil((p / 100) * float64(len(sorted)))) if rank < 1 { rank = 1 } if rank > len(sorted) { rank = len(sorted) } return sorted[rank-1] } func maxDuration(values []time.Duration) time.Duration { if len(values) == 0 { return 0 } max := values[0] for _, v := range values[1:] { if v > max { max = v } } return max } func copyStringMap(src map[string]int64) map[string]int64 { dst := make(map[string]int64, len(src)) for k, v := range src { dst[k] = v } return dst } func copyIntMap(src map[int]int64) map[int]int64 { dst := make(map[int]int64, len(src)) for k, v := range src { dst[k] = v } return dst } func sortedStringKeys[T any](m map[string]T) []string { keys := make([]string, 0, len(m)) for key := range m { keys = append(keys, key) } sort.Strings(keys) return keys } func sortedIntKeys[T any](m map[int]T) []int { keys := make([]int, 0, len(m)) for key := range m { keys = append(keys, key) } sort.Ints(keys) return keys } func startServerMemoryMonitor(ctx context.Context, cfg config, verbose bool) *serverMemoryStats { stats := &serverMemoryStats{ Enabled: cfg.monitorInterval > 0, Verbose: verbose, } if !stats.Enabled { return stats } port, local := extractTargetPortIfLocal(cfg.targetURL) stats.LocalTarget = local stats.TargetPort = port if !local { if verbose { log.Printf("실시간 메모리 모니터링 생략: 로컬 대상이 아닙니다 (%s)", cfg.targetURL) } return stats } if verbose { log.Printf("실시간 메모리 모니터링 활성화: 포트=%s 주기=%s", port, cfg.monitorInterval) } go func() { ticker := time.NewTicker(cfg.monitorInterval) defer ticker.Stop() startedAt := time.Now() for { select { case <-ctx.Done(): return case <-ticker.C: sample, processName, err := captureServerMemorySample(port) stats.mu.Lock() if err != nil { stats.LastError = err.Error() stats.mu.Unlock() continue } stats.PID = sample.PID stats.ProcessName = processName stats.LastWorkingSet = sample.WorkingSetBytes stats.LastPrivate = sample.PrivateBytes if sample.WorkingSetBytes > stats.MaxWorkingSet { stats.MaxWorkingSet = sample.WorkingSetBytes } if sample.PrivateBytes > stats.MaxPrivate { stats.MaxPrivate = sample.PrivateBytes } stats.SampleCount++ stats.LastError = "" stats.mu.Unlock() if stats.Verbose { snapshot := aggregateSnapshotIfAvailable() log.Printf( "[실시간] 경과=%s 요청=%d 성공=%d 실패=%d 서버사용메모리=%s 작업집합=%s PID=%d", time.Since(startedAt).Truncate(time.Second), snapshot.total, snapshot.success, snapshot.failed, formatBytes(sample.PrivateBytes), formatBytes(sample.WorkingSetBytes), sample.PID, ) } } } }() return stats } func (s *serverMemoryStats) snapshot() serverMemoryStatsSnapshot { if s == nil { return serverMemoryStatsSnapshot{} } s.mu.Lock() defer s.mu.Unlock() return serverMemoryStatsSnapshot{ Enabled: s.Enabled, LocalTarget: s.LocalTarget, ProcessName: s.ProcessName, PID: s.PID, LastWorkingSet: s.LastWorkingSet, LastPrivate: s.LastPrivate, MaxWorkingSet: s.MaxWorkingSet, MaxPrivate: s.MaxPrivate, SampleCount: s.SampleCount, LastError: s.LastError, } } func aggregateSnapshotIfAvailable() aggregateSnapshot { agg := globalAggregator.Load() if agg == nil { return aggregateSnapshot{} } return agg.snapshot() } func captureServerMemorySample(port string) (serverMemorySample, string, error) { pid, err := findPIDByPort(port) if err != nil { return serverMemorySample{}, "", err } workingSet, privateBytes, processName, err := readProcessMemory(pid) if err != nil { return serverMemorySample{}, "", err } return serverMemorySample{ PID: pid, WorkingSetBytes: workingSet, PrivateBytes: privateBytes, }, processName, nil } func extractTargetPortIfLocal(raw string) (string, bool) { parsed, err := url.Parse(raw) if err != nil { return "", false } host := parsed.Hostname() if host == "" { return "", false } if !isLocalHost(host) { return "", false } port := parsed.Port() if port != "" { return port, true } switch parsed.Scheme { case "http", "ws": return "80", true case "https", "wss": return "443", true default: return "", false } } func isLocalHost(host string) bool { switch strings.ToLower(host) { case "localhost", "127.0.0.1", "::1": return true default: ip := net.ParseIP(host) return ip != nil && ip.IsLoopback() } } func findPIDByPort(port string) (int, error) { switch runtime.GOOS { case "windows": return findPIDByPortWindows(port) case "linux": return findPIDByPortLinux(port) default: return 0, fmt.Errorf("현재 환경에서는 서버 메모리 모니터링을 지원하지 않습니다") } } func findPIDByPortWindows(port string) (int, error) { out, err := exec.Command("netstat", "-ano", "-p", "TCP").CombinedOutput() if err != nil { return 0, fmt.Errorf("netstat 실행 실패: %w", err) } lines := strings.Split(string(out), "\n") for _, line := range lines { trimmed := strings.TrimSpace(line) if !strings.Contains(trimmed, "LISTENING") { continue } if !strings.Contains(trimmed, ":"+port) { continue } matches := netstatPIDPattern.FindStringSubmatch(trimmed) if len(matches) != 2 { continue } pid, convErr := strconv.Atoi(matches[1]) if convErr != nil { continue } return pid, nil } return 0, fmt.Errorf("포트 %s에서 LISTENING 중인 프로세스를 찾지 못했습니다", port) } func findPIDByPortLinux(port string) (int, error) { out, err := exec.Command("ss", "-ltnp").CombinedOutput() if err == nil { lines := strings.Split(string(out), "\n") for _, line := range lines { if !strings.Contains(line, ":"+port) { continue } matches := ssPIDPattern.FindStringSubmatch(line) if len(matches) != 2 { continue } pid, convErr := strconv.Atoi(matches[1]) if convErr == nil { return pid, nil } } } out, lsofErr := exec.Command("lsof", "-nP", "-iTCP:"+port, "-sTCP:LISTEN", "-t").CombinedOutput() if lsofErr != nil { if err != nil { return 0, fmt.Errorf("ss/lsof 실행 실패: %v / %w", err, lsofErr) } return 0, fmt.Errorf("lsof 실행 실패: %w", lsofErr) } text := strings.TrimSpace(string(out)) if text == "" { return 0, fmt.Errorf("포트 %s에서 LISTEN 중인 프로세스를 찾지 못했습니다", port) } pid, convErr := strconv.Atoi(strings.Split(text, "\n")[0]) if convErr != nil { return 0, fmt.Errorf("lsof PID 파싱 실패: %w", convErr) } return pid, nil } func readProcessMemory(pid int) (uint64, uint64, string, error) { switch runtime.GOOS { case "windows": return readProcessMemoryWindows(pid) case "linux": return readProcessMemoryLinux(pid) default: return 0, 0, "", fmt.Errorf("현재 환경에서는 프로세스 메모리 조회를 지원하지 않습니다") } } func readProcessMemoryWindows(pid int) (uint64, uint64, string, error) { script := fmt.Sprintf(`$p = Get-Process -Id %d -ErrorAction Stop; Write-Output ($p.WorkingSet64.ToString() + "," + $p.PrivateMemorySize64.ToString() + "," + $p.ProcessName)`, pid) out, err := exec.Command("powershell", "-NoLogo", "-NoProfile", "-Command", script).CombinedOutput() if err != nil { return 0, 0, "", fmt.Errorf("Get-Process 실행 실패: %w", err) } text := strings.TrimSpace(string(out)) parts := strings.SplitN(text, ",", 3) if len(parts) != 3 { return 0, 0, "", fmt.Errorf("메모리 정보 파싱 실패: %s", text) } workingSet, err := strconv.ParseUint(strings.TrimSpace(parts[0]), 10, 64) if err != nil { return 0, 0, "", fmt.Errorf("작업 집합 메모리 파싱 실패: %w", err) } privateBytes, err := strconv.ParseUint(strings.TrimSpace(parts[1]), 10, 64) if err != nil { return 0, 0, "", fmt.Errorf("서버 사용 메모리 파싱 실패: %w", err) } return workingSet, privateBytes, strings.TrimSpace(parts[2]), nil } func readProcessMemoryLinux(pid int) (uint64, uint64, string, error) { statusPath := fmt.Sprintf("/proc/%d/status", pid) file, err := os.Open(statusPath) if err != nil { return 0, 0, "", fmt.Errorf("/proc 상태 파일 열기 실패: %w", err) } defer file.Close() var ( processName string vmRSSKB uint64 vmSizeKB uint64 ) scanner := bufio.NewScanner(file) for scanner.Scan() { line := scanner.Text() switch { case strings.HasPrefix(line, "Name:"): processName = strings.TrimSpace(strings.TrimPrefix(line, "Name:")) case strings.HasPrefix(line, "VmRSS:"): value, parseErr := parseProcKBValue(line) if parseErr == nil { vmRSSKB = value } case strings.HasPrefix(line, "VmSize:"): value, parseErr := parseProcKBValue(line) if parseErr == nil { vmSizeKB = value } } } if err := scanner.Err(); err != nil { return 0, 0, "", fmt.Errorf("/proc 상태 파일 읽기 실패: %w", err) } if vmRSSKB == 0 && vmSizeKB == 0 { return 0, 0, "", fmt.Errorf("리눅스 프로세스 메모리 정보를 찾지 못했습니다") } return vmRSSKB * 1024, vmSizeKB * 1024, processName, nil } func parseProcKBValue(line string) (uint64, error) { fields := strings.Fields(line) if len(fields) < 2 { return 0, fmt.Errorf("메모리 값 형식이 올바르지 않습니다: %s", line) } return strconv.ParseUint(fields[1], 10, 64) } func formatBytes(v uint64) string { const unit = 1024 if v < unit { return fmt.Sprintf("%d B", v) } div, exp := uint64(unit), 0 for n := v / unit; n >= unit; n /= unit { div *= unit exp++ } suffixes := []string{"KB", "MB", "GB", "TB"} return fmt.Sprintf("%.2f %s", float64(v)/float64(div), suffixes[exp]) } func init() { log.SetOutput(os.Stdout) log.SetFlags(log.LstdFlags | log.Lmicroseconds) } func (r socketResponse) String() string { payload, _ := json.Marshal(r) return string(payload) }