You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1238 lines
31 KiB

4 weeks ago
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)
}