feat: 성능 비교 테스트베드 데모 UI 구현 (src/, Bubble Tea + Lipgloss)

Phase 0 산출물 — 실제 측정 없이 데모 모드로 동작하는 Terminal UI.
시연 시 최종 목표 화면을 가시화하고, 파라미터 조정에 반응하는
그럴듯한 추세를 시뮬레이션한다.

비교 대상 시스템:
- REST + HTTP/2 (TCP+TLS) + JSON
- gRPC + HTTP/2 (TCP+TLS) + Protobuf
- gRPC + HTTP/3 (QUIC+TLS1.3) + Protobuf  ★ 본 연구 제안

조절 가능한 파라미터:
- 워크로드 시나리오 (Small-Many 1KB×10000 / Large-Few 1MB×50)
- 링크 지연 (0~500ms), 패킷 손실 (0~5%)
- 대역폭 (1~1000Mbps), 디바이스 수 (1~100)

화면 구성:
- 메인 메뉴 / 설정 / 실시간 진행 / 결과 비교 / 정보 5개 화면
- 진행률 막대, latency sparkline, 비교 차트(P50/P95/P99/RPS/연결시간)

구현:
- src/cmd/benchcli/main.go        진입점
- src/internal/ui/app.go          Bubble Tea Model + 화면 dispatcher
- src/internal/ui/types.go        Config / Result / RunState 정의
- src/internal/ui/styles.go       Lipgloss 스타일·색상
- src/internal/ui/components.go   progressBar / sparkline / slider
- src/internal/ui/simulator.go    mock 시뮬레이터 (Phase 5에서 실측으로 교체)
- src/internal/ui/screen_*.go     각 화면 (menu/config/running/results/about)

의존성: bubbletea v1.2.4, lipgloss v1.0.0
빌드: `cd src && go run ./cmd/benchcli`

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-07 01:32:15 +00:00
parent d2c63e7c19
commit 3db48c3bae
13 changed files with 1573 additions and 0 deletions
+257
View File
@@ -0,0 +1,257 @@
package ui
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
func (a App) updateResults(m tea.KeyMsg) (App, tea.Cmd) {
switch m.String() {
case "esc":
a.screen = screenMenu
case "r":
// 같은 조건으로 재실행
rs := startRun(a.config)
a.run = &rs
a.screen = screenRunning
return a, tickCmd()
case "n":
// 새 실험 (설정 화면으로)
a.screen = screenConfig
case "q":
return a, tea.Quit
}
return a, nil
}
func (a App) viewResults() string {
if a.run == nil {
return errorStyle.Render("결과가 없습니다.")
}
rows := []string{}
// 실험 헤더
header := fmt.Sprintf("📊 결과 — %s", a.config.ScenarioLabel())
if a.run.Aborted {
header += " " + errorStyle.Render("(중단됨)")
}
rows = append(rows, sectionHeader(header))
rows = append(rows, helpStyle.Render(fmt.Sprintf(
"지연=%dms · 손실=%.1f%% · 대역폭=%dMbps · 디바이스=%d · 메시지=%dKB×%d/디바이스",
a.config.LinkDelayMs,
a.config.PacketLossPct,
a.config.BandwidthMbps,
a.config.DeviceCount,
a.config.MessageSizeKB(),
a.config.MessageCount(),
)))
rows = append(rows, "")
// 비교 테이블
rows = append(rows, sectionHeader("성능 비교 표"))
rows = append(rows, helpStyle.Render(fmt.Sprintf(
" %-32s %9s %9s %9s %10s %10s %12s",
"시스템", "P50(ms)", "P95(ms)", "P99(ms)", "RPS", "성공률", "총 데이터",
)))
rows = append(rows, helpStyle.Render(strings.Repeat("─", 100)))
bestRPS, bestRPSID := 0.0, ""
bestP99, bestP99ID := 1e9, ""
for _, sys := range a.config.Systems {
if !sys.Selected {
continue
}
r := a.run.Results[sys.ID]
if r == nil {
continue
}
if r.ThroughputRPS > bestRPS {
bestRPS = r.ThroughputRPS
bestRPSID = sys.ID
}
if r.P99 > 0 && r.P99 < bestP99 {
bestP99 = r.P99
bestP99ID = sys.ID
}
}
for _, sys := range a.config.Systems {
if !sys.Selected {
continue
}
r := a.run.Results[sys.ID]
if r == nil {
continue
}
successRate := 0.0
if r.Total > 0 {
successRate = float64(r.Success) / float64(r.Total) * 100
}
clr := systemColor(sys.ID)
labelStyled := lipgloss.NewStyle().Foreground(clr).Bold(true).Render(sys.Label)
marker := ""
if sys.ID == bestRPSID {
marker += "★"
}
if sys.ID == bestP99ID {
marker += "⚡"
}
// 라벨이 색상 escape 코드로 인해 visible width가 다르므로 자체 padding
labelPad := padVisible(sys.Label, 32)
rows = append(rows, fmt.Sprintf(
" %s %9.1f %9.1f %9.1f %10.0f %9.1f%% %12s %s",
lipgloss.NewStyle().Foreground(clr).Bold(true).Render(labelPad),
r.P50, r.P95, r.P99,
r.ThroughputRPS,
successRate,
formatBytes(r.BytesSent),
marker,
))
_ = labelStyled
}
rows = append(rows, "")
rows = append(rows, helpStyle.Render(" ★ 최고 처리량 ⚡ 최저 P99 latency"))
rows = append(rows, "")
// Latency 비교 차트
rows = append(rows, sectionHeader("Latency 비교 (낮을수록 좋음)"))
maxLat := 0.0
for _, sys := range a.config.Systems {
if !sys.Selected {
continue
}
r := a.run.Results[sys.ID]
if r == nil {
continue
}
if r.P99 > maxLat {
maxLat = r.P99
}
}
if maxLat == 0 {
maxLat = 1
}
for _, sys := range a.config.Systems {
if !sys.Selected {
continue
}
r := a.run.Results[sys.ID]
if r == nil {
continue
}
clr := systemColor(sys.ID)
rows = append(rows, lipgloss.NewStyle().Foreground(clr).Bold(true).Render(sys.Label))
rows = append(rows, " P50 "+horizontalBar(r.P50, maxLat, 50, clr)+fmt.Sprintf(" %.1f ms", r.P50))
rows = append(rows, " P95 "+horizontalBar(r.P95, maxLat, 50, clr)+fmt.Sprintf(" %.1f ms", r.P95))
rows = append(rows, " P99 "+horizontalBar(r.P99, maxLat, 50, clr)+fmt.Sprintf(" %.1f ms", r.P99))
}
rows = append(rows, "")
// 처리량 비교
rows = append(rows, sectionHeader("처리량 비교 (높을수록 좋음)"))
maxRPS := 0.0
for _, sys := range a.config.Systems {
if !sys.Selected {
continue
}
r := a.run.Results[sys.ID]
if r == nil {
continue
}
if r.ThroughputRPS > maxRPS {
maxRPS = r.ThroughputRPS
}
}
if maxRPS == 0 {
maxRPS = 1
}
for _, sys := range a.config.Systems {
if !sys.Selected {
continue
}
r := a.run.Results[sys.ID]
if r == nil {
continue
}
clr := systemColor(sys.ID)
rows = append(rows, fmt.Sprintf(
" %-32s %s %.0f RPS",
lipgloss.NewStyle().Foreground(clr).Bold(true).Render(padVisible(sys.Label, 32)),
horizontalBar(r.ThroughputRPS, maxRPS, 50, clr),
r.ThroughputRPS,
))
}
rows = append(rows, "")
// 연결 수립 시간
rows = append(rows, sectionHeader("연결 수립 시간 (낮을수록 좋음 — QUIC 0/1-RTT 효과)"))
maxConn := 0.0
for _, sys := range a.config.Systems {
if !sys.Selected {
continue
}
r := a.run.Results[sys.ID]
if r == nil {
continue
}
if r.ConnectionTime > maxConn {
maxConn = r.ConnectionTime
}
}
if maxConn == 0 {
maxConn = 1
}
for _, sys := range a.config.Systems {
if !sys.Selected {
continue
}
r := a.run.Results[sys.ID]
if r == nil {
continue
}
clr := systemColor(sys.ID)
rows = append(rows, fmt.Sprintf(
" %-32s %s %.1f ms",
lipgloss.NewStyle().Foreground(clr).Bold(true).Render(padVisible(sys.Label, 32)),
horizontalBar(r.ConnectionTime, maxConn, 50, clr),
r.ConnectionTime,
))
}
rows = append(rows, "")
rows = append(rows, helpStyle.Render(strings.Repeat("─", 70)))
rows = append(rows, helpStyle.Render(
"💡 본 결과는 데모 시뮬레이션이며 실제 측정값이 아닙니다.",
))
rows = append(rows, helpStyle.Render(
" 실제 벤치마크는 향후 cmd/server / cmd/benchmark-runner 와 tc 연동 후 수행됩니다.",
))
box := boxStyle.Render(strings.Join(rows, "\n"))
help := helpKeys(
[2]string{"r", "같은 조건 재실행"},
[2]string{"n", "새 실험"},
[2]string{"Esc", "메뉴"},
[2]string{"q", "종료"},
)
return lipgloss.JoinVertical(lipgloss.Left, "", box, "", help)
}
// padVisible visible 길이 기준으로 우측 공백 패딩 (한글 등 wide character 무시 단순 구현)
func padVisible(s string, width int) string {
if len(s) >= width {
return s
}
return s + strings.Repeat(" ", width-len(s))
}