# edge TCP 서버 + node TCP 클라이언트 구현 (proto-socket / protobuf) ## 이 파일을 읽는 구현 에이전트에게 각 체크리스트 항목을 완료한 뒤 `[x]`로 표시하세요. 중간 검증 명령을 실제로 실행하고 출력을 `CODE_REVIEW.md` 검증 결과 섹션에 붙여 넣으세요. 계획과 다르게 구현한 부분은 반드시 `계획 대비 변경 사항`에 기록하세요. ## 배경 이전 작업에서 node의 TCP 서버 코드(`transport/server.go`, `session.go`, `frame.go`, `codec.go`, `tls.go`)와 JSON 기반 `packages/protocol/protocol.go`를 모두 삭제했다. `apps/node/internal/node/node.go`는 삭제된 `protocol.*` 타입을 참조해 현재 컴파일 불가 상태다. 이 플랜은 올바른 아키텍처를 처음부터 구현한다: **edge가 TCP 서버로 여러 노드를 수용하고, node가 시작 시 edge에 클라이언트로 연결**한다. wire format은 `../proto-socket` 패키지 기반 protobuf이며 JSON을 사용하지 않는다. ## 의존 관계 및 구현 순서 ``` REFACTOR-1 (proto gen + go.mod) └─> REFACTOR-2 (node transport: client.go, session.go, parser.go) └─> REFACTOR-3 (node.go — proto 타입으로 교체) └─> REFACTOR-4 (node bootstrap — DialEdge 연결) └─> REFACTOR-5 (edge server — 다중 노드 관리) ``` --- ### [REFACTOR-1] proto 컴파일 및 go.mod에 proto-socket 추가 **문제** `proto/gen/iop/` 디렉터리가 없어 proto 생성 타입을 사용할 수 없다. `go.mod`에 `git.toki-labs.com/toki/common-proto-socket/go` 의존성이 없다. **해결 방법** `go.mod`에 require + replace를 추가하고 `make proto`로 Go 소스를 생성한다. ``` # Before — go.mod require 블록 (go.mod:5-14) require ( github.com/prometheus/client_golang v1.20.5 github.com/spf13/cobra v1.8.1 github.com/spf13/viper v1.19.0 go.uber.org/fx v1.22.2 go.uber.org/zap v1.27.0 google.golang.org/protobuf v1.36.5 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.33.1 ) # After require ( git.toki-labs.com/toki/common-proto-socket/go v0.0.0-00010101000000-000000000000 github.com/prometheus/client_golang v1.20.5 github.com/spf13/cobra v1.8.1 github.com/spf13/viper v1.19.0 go.uber.org/fx v1.22.2 go.uber.org/zap v1.27.0 google.golang.org/protobuf v1.36.5 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.33.1 ) replace git.toki-labs.com/toki/common-proto-socket/go => ../proto-socket/go ``` protoc 실행: ```bash PATH=/config/go-sdk/bin:$PATH make proto PATH=/config/go-sdk/bin:$PATH go mod tidy ``` `Makefile:20-28`의 `proto` 타겟이 이미 올바르게 정의되어 있으므로 그대로 사용한다. **수정 파일 및 체크리스트** - [x] `go.mod` — require + replace 추가 - [x] `proto/gen/iop/runtime.pb.go` — protoc 생성 (신규) - [x] `proto/gen/iop/node.pb.go` — protoc 생성 (신규) - [x] `proto/gen/iop/control.pb.go` — protoc 생성 (신규) - [x] `proto/gen/iop/job.pb.go` — protoc 생성 (신규) **테스트 작성** 생성 파일이므로 별도 테스트 불필요. **중간 검증** ```bash PATH=/config/go-sdk/bin:$PATH make proto ls proto/gen/iop/ PATH=/config/go-sdk/bin:$PATH go mod tidy ``` 기대 결과: `runtime.pb.go node.pb.go control.pb.go job.pb.go` 4개 파일 생성 --- ### [REFACTOR-2] node transport 클라이언트 계층 구현 **문제** `apps/node/internal/transport/` 디렉터리가 비어 있다. proto-socket 기반의 edge 연결 클라이언트와 메시지 핸들러가 없다. **해결 방법** 세 파일을 신규 생성한다. **`apps/node/internal/transport/parser.go`** node가 edge로부터 수신하는 메시지 타입의 파서를 등록한다. ```go package transport import ( "google.golang.org/protobuf/proto" toki "git.toki-labs.com/toki/common-proto-socket/go" iop "iop/proto/gen/iop" ) func nodeParserMap() toki.ParserMap { return toki.ParserMap{ toki.TypeNameOf(&iop.RunRequest{}): func(b []byte) (proto.Message, error) { m := &iop.RunRequest{} return m, proto.Unmarshal(b, m) }, toki.TypeNameOf(&iop.CancelRequest{}): func(b []byte) (proto.Message, error) { m := &iop.CancelRequest{} return m, proto.Unmarshal(b, m) }, toki.TypeNameOf(&iop.CapabilityRequest{}): func(b []byte) (proto.Message, error) { m := &iop.CapabilityRequest{} return m, proto.Unmarshal(b, m) }, } } ``` **`apps/node/internal/transport/session.go`** proto-socket `TcpClient`를 래핑하고, edge로부터 오는 메시지 리스너를 등록한다. ```go package transport import ( "context" "sync" toki "git.toki-labs.com/toki/common-proto-socket/go" "go.uber.org/zap" "google.golang.org/protobuf/proto" iop "iop/proto/gen/iop" ) // Handler processes IOP messages received from edge. type Handler interface { OnRunRequest(ctx context.Context, sess *Session, req *iop.RunRequest) error OnCapabilityRequest(ctx context.Context, sess *Session) (*iop.CapabilityResponse, error) OnCancel(ctx context.Context, sess *Session, req *iop.CancelRequest) error } // Session represents the node's persistent connection to edge. type Session struct { client *toki.TcpClient nodeID string logger *zap.Logger cancelFns sync.Map // run_id → context.CancelFunc } func newSession(client *toki.TcpClient, handler Handler, nodeID string, logger *zap.Logger) *Session { s := &Session{client: client, nodeID: nodeID, logger: logger} toki.AddListenerTyped[*iop.RunRequest](&client.Communicator, func(req *iop.RunRequest) { go func() { if err := handler.OnRunRequest(context.Background(), s, req); err != nil { logger.Warn("run request error", zap.String("run_id", req.GetRunId()), zap.Error(err), ) } }() }) toki.AddRequestListenerTyped[*iop.CapabilityRequest, *iop.CapabilityResponse]( &client.Communicator, func(_ *iop.CapabilityRequest) (*iop.CapabilityResponse, error) { return handler.OnCapabilityRequest(context.Background(), s) }, ) toki.AddListenerTyped[*iop.CancelRequest](&client.Communicator, func(req *iop.CancelRequest) { if err := handler.OnCancel(context.Background(), s, req); err != nil { logger.Warn("cancel error", zap.String("run_id", req.GetRunId()), zap.Error(err), ) } }) client.AddDisconnectListener(func(_ *toki.TcpClient) { logger.Info("disconnected from edge", zap.String("node_id", nodeID)) }) return s } // Send transmits a proto message to edge. func (s *Session) Send(m proto.Message) error { return s.client.Send(m) } // IsAlive reports whether the connection is active. func (s *Session) IsAlive() bool { return s.client.IsAlive() } // Close terminates the connection to edge. func (s *Session) Close() error { return s.client.Close() } // RegisterCancel stores a cancel function for a running execution. func (s *Session) RegisterCancel(runID string, cancel context.CancelFunc) { s.cancelFns.Store(runID, cancel) } // DeregisterCancel removes a run's cancel function after completion. func (s *Session) DeregisterCancel(runID string) { s.cancelFns.Delete(runID) } // CancelRun invokes the cancel function for a run if one is registered. func (s *Session) CancelRun(runID string) { if v, ok := s.cancelFns.Load(runID); ok { v.(context.CancelFunc)() } } ``` **`apps/node/internal/transport/client.go`** edge 주소로 TCP 연결을 맺고 Session을 반환한다. ```go package transport import ( "context" "fmt" "net" toki "git.toki-labs.com/toki/common-proto-socket/go" "go.uber.org/zap" ) const ( heartbeatIntervalSec = 30 heartbeatWaitSec = 10 ) // DialEdge connects to the edge TCP server and returns a Session. func DialEdge(ctx context.Context, addr string, handler Handler, nodeID string, logger *zap.Logger) (*Session, error) { host, portStr, err := net.SplitHostPort(addr) if err != nil { return nil, fmt.Errorf("transport: invalid addr %q: %w", addr, err) } port := 0 if _, err := fmt.Sscanf(portStr, "%d", &port); err != nil { return nil, fmt.Errorf("transport: invalid port %q: %w", portStr, err) } client, err := toki.DialTcp(ctx, host, port, heartbeatIntervalSec, heartbeatWaitSec, nodeParserMap()) if err != nil { return nil, fmt.Errorf("transport: dial edge %s: %w", addr, err) } sess := newSession(client, handler, nodeID, logger) logger.Info("connected to edge", zap.String("addr", addr), zap.String("node_id", nodeID)) return sess, nil } ``` **수정 파일 및 체크리스트** - [x] `apps/node/internal/transport/parser.go` — 신규 생성 - [x] `apps/node/internal/transport/session.go` — 신규 생성 - [x] `apps/node/internal/transport/client.go` — 신규 생성 **테스트 작성** proto-socket의 TCP/heartbeat/disconnect 동작은 `../proto-socket/go/test/`에서 검증됨. Session 연결 흐름은 REFACTOR-5 최종 통합 검증으로 커버. 단위 테스트 생략. **중간 검증** ```bash PATH=/config/go-sdk/bin:$PATH go build ./apps/node/internal/transport/... ``` 기대 결과: 빌드 에러 없음 --- ### [REFACTOR-3] node.go — proto 타입으로 교체 **문제** `apps/node/internal/node/node.go`가 삭제된 `iop/packages/protocol` 패키지를 참조해 컴파일 불가 상태다. (node.go:18, :48, :108, :116, :126, :139-168) **해결 방법** `protocol.*` 참조를 `iop/proto/gen/iop` 생성 타입으로 교체한다. ```go // Before (node.go:5-19 imports) import ( "context" "encoding/json" "fmt" "time" "go.uber.org/zap" "iop/apps/node/internal/adapters" "iop/apps/node/internal/runtime" "iop/apps/node/internal/store" "iop/apps/node/internal/transport" "iop/packages/config" "iop/packages/protocol" ) // After import ( "context" "fmt" "time" "go.uber.org/zap" "iop/apps/node/internal/adapters" "iop/apps/node/internal/runtime" "iop/apps/node/internal/store" "iop/apps/node/internal/transport" "iop/packages/config" iop "iop/proto/gen/iop" ) ``` ```go // Before (node.go:48) func (n *Node) OnRunRequest(ctx context.Context, sess *transport.Session, req *protocol.RunRequest) error { n.logger.Info("run request received", zap.String("run_id", req.RunID), zap.String("adapter", req.Adapter), zap.String("model", req.Model), ) rr := runtime.RunRequest{ RunID: req.RunID, Adapter: req.Adapter, Model: req.Model, Workspace: req.Workspace, Policy: req.Policy, Input: req.Input, TimeoutSec: req.TimeoutSec, Metadata: req.Metadata, } // After // iop.RunRequest.Input / Policy 는 google.protobuf.Struct — AsMap()으로 변환. func (n *Node) OnRunRequest(ctx context.Context, sess *transport.Session, req *iop.RunRequest) error { n.logger.Info("run request received", zap.String("run_id", req.GetRunId()), zap.String("adapter", req.GetAdapter()), zap.String("model", req.GetModel()), ) rr := runtime.RunRequest{ RunID: req.GetRunId(), Adapter: req.GetAdapter(), Model: req.GetModel(), Workspace: req.GetWorkspace(), Policy: req.GetPolicy().AsMap(), Input: req.GetInput().AsMap(), TimeoutSec: int(req.GetTimeoutSec()), Metadata: req.GetMetadata(), } ``` ```go // Before (node.go:108-122) func (n *Node) OnCapabilityRequest(_ context.Context, _ *transport.Session) (*protocol.CapabilityResponse, error) { resp := &protocol.CapabilityResponse{NodeID: n.nodeID} for _, a := range n.registry.All() { caps, err := a.Capabilities(context.Background()) if err != nil { n.logger.Warn("capabilities query failed", zap.String("adapter", a.Name()), zap.Error(err)) continue } resp.Adapters = append(resp.Adapters, protocol.AdapterInfo{ Name: caps.AdapterName, Models: caps.Models, MaxConcurrency: caps.MaxConcurrency, }) } return resp, nil } // After func (n *Node) OnCapabilityRequest(_ context.Context, _ *transport.Session) (*iop.CapabilityResponse, error) { resp := &iop.CapabilityResponse{NodeId: n.nodeID} for _, a := range n.registry.All() { caps, err := a.Capabilities(context.Background()) if err != nil { n.logger.Warn("capabilities query failed", zap.String("adapter", a.Name()), zap.Error(err)) continue } resp.Adapters = append(resp.Adapters, &iop.AdapterInfo{ Name: caps.AdapterName, Models: caps.Models, MaxConcurrency: int32(caps.MaxConcurrency), }) } return resp, nil } ``` ```go // Before (node.go:126-129) func (n *Node) OnCancel(_ context.Context, sess *transport.Session, req *protocol.CancelRequest) error { n.logger.Info("cancel request", zap.String("run_id", req.RunID)) sess.CancelRun(req.RunID) return nil } // After func (n *Node) OnCancel(_ context.Context, sess *transport.Session, req *iop.CancelRequest) error { n.logger.Info("cancel request", zap.String("run_id", req.GetRunId())) sess.CancelRun(req.GetRunId()) return nil } ``` ```go // Before (node.go:138-169) sessionSink.Emit func (s *sessionSink) Emit(_ context.Context, event runtime.RuntimeEvent) error { var usageInfo *protocol.UsageInfo if event.Usage != nil { usageInfo = &protocol.UsageInfo{ InputTokens: event.Usage.InputTokens, OutputTokens: event.Usage.OutputTokens, } } re := protocol.RunEvent{...} payload, err := json.Marshal(re) if err != nil { return err } return s.sess.Send(&protocol.Envelope{...}) } // After — Envelope 없이 RunEvent 직접 전송 (proto-socket이 래핑) func (s *sessionSink) Emit(_ context.Context, event runtime.RuntimeEvent) error { re := &iop.RunEvent{ RunId: event.RunID, Type: string(event.Type), Delta: event.Delta, Message: event.Message, Error: event.Error, Timestamp: event.Timestamp.UnixNano(), } if event.Usage != nil { re.Usage = &iop.Usage{ InputTokens: int32(event.Usage.InputTokens), OutputTokens: int32(event.Usage.OutputTokens), } } return s.sess.Send(re) } ``` **수정 파일 및 체크리스트** - [x] `apps/node/internal/node/node.go` — imports 교체, 메서드 시그니처 및 본문 전체 교체 **테스트 작성** 타입 교체이며 로직은 동일하므로 기존 테스트 없음. 최종 통합으로 검증. **중간 검증** ```bash PATH=/config/go-sdk/bin:$PATH go build ./apps/node/... ``` 기대 결과: 빌드 에러 없음 --- ### [REFACTOR-4] node bootstrap — DialEdge 연결 추가 **문제** `apps/node/internal/bootstrap/module.go:70-84`의 fx.Invoke가 metrics 서버만 기동하고 edge 연결을 하지 않는다. **해결 방법** fx OnStart에서 `transport.DialEdge`를 호출해 edge에 연결하고, OnStop에서 닫는다. ```go // Before (bootstrap/module.go:70-84) fx.Invoke(func(lc fx.Lifecycle, cfg *config.NodeConfig, logger *zap.Logger) { lc.Append(fx.Hook{ OnStart: func(ctx context.Context) error { go func() { if err := observability.ServeMetrics(cfg.Metrics.Port); err != nil { logger.Warn("metrics server exited", zap.Error(err)) } }() return nil }, OnStop: func(_ context.Context) error { return nil }, }) }), // After fx.Invoke(func(lc fx.Lifecycle, n *node.Node, cfg *config.NodeConfig, logger *zap.Logger) { var sess *transport.Session lc.Append(fx.Hook{ OnStart: func(ctx context.Context) error { s, err := transport.DialEdge(ctx, cfg.Transport.EdgeAddr, n, cfg.Node.ID, logger) if err != nil { return err } sess = s go func() { if err := observability.ServeMetrics(cfg.Metrics.Port); err != nil { logger.Warn("metrics server exited", zap.Error(err)) } }() return nil }, OnStop: func(_ context.Context) error { if sess != nil { return sess.Close() } return nil }, }) }), ``` import에 `"iop/apps/node/internal/transport"` 추가. **수정 파일 및 체크리스트** - [x] `apps/node/internal/bootstrap/module.go` — import 추가, fx.Invoke 교체 **테스트 작성** bootstrap 와이어링이므로 별도 테스트 불필요. **중간 검증** ```bash PATH=/config/go-sdk/bin:$PATH go build ./apps/node/... ``` 기대 결과: 빌드 에러 없음 --- ### [REFACTOR-5] edge TCP 서버 — 다중 노드 관리 **문제** - `apps/edge/cmd/edge/main.go:27-29`의 serve 커맨드가 placeholder다. - edge config 구조체가 없다. - 여러 node가 연결될 수 있는 NodeRegistry가 없다. **해결 방법** proto-socket TcpServer로 여러 노드 연결을 수락하고, 각 노드의 nodeID·capabilities를 `NodeRegistry`에 등록·관리한다. **`packages/config/config.go` — EdgeConfig 추가 (파일 끝에 append)** ```go type EdgeConfig struct { Server EdgeServerConf `mapstructure:"server" yaml:"server"` TLS TLSConf `mapstructure:"tls" yaml:"tls"` Logging LoggingConf `mapstructure:"logging" yaml:"logging"` Metrics MetricsConf `mapstructure:"metrics" yaml:"metrics"` } type EdgeServerConf struct { Listen string `mapstructure:"listen" yaml:"listen"` } func LoadEdge(cfgFile string) (*EdgeConfig, error) { v := viper.New() v.SetConfigFile(cfgFile) v.SetDefault("server.listen", "0.0.0.0:9090") v.SetDefault("logging.level", "info") v.SetDefault("metrics.port", 9092) if err := v.ReadInConfig(); err != nil { return nil, err } var cfg EdgeConfig return &cfg, v.Unmarshal(&cfg) } ``` **`apps/edge/internal/node/registry.go` (신규)** 연결된 노드를 nodeID로 관리한다. ```go package node import ( "fmt" "sync" toki "git.toki-labs.com/toki/common-proto-socket/go" iop "iop/proto/gen/iop" ) // NodeEntry는 연결된 노드 하나를 나타낸다. type NodeEntry struct { NodeID string Client *toki.TcpClient Adapters []*iop.AdapterInfo } // Registry는 edge에 연결된 모든 node를 관리한다. type Registry struct { mu sync.RWMutex nodes map[string]*NodeEntry // nodeID → entry } func NewRegistry() *Registry { return &Registry{nodes: make(map[string]*NodeEntry)} } func (r *Registry) Register(entry *NodeEntry) { r.mu.Lock() defer r.mu.Unlock() r.nodes[entry.NodeID] = entry } func (r *Registry) Unregister(nodeID string) { r.mu.Lock() defer r.mu.Unlock() delete(r.nodes, nodeID) } func (r *Registry) Get(nodeID string) (*NodeEntry, bool) { r.mu.RLock() defer r.mu.RUnlock() e, ok := r.nodes[nodeID] return e, ok } func (r *Registry) All() []*NodeEntry { r.mu.RLock() defer r.mu.RUnlock() out := make([]*NodeEntry, 0, len(r.nodes)) for _, e := range r.nodes { out = append(out, e) } return out } func (r *Registry) Count() int { r.mu.RLock() defer r.mu.RUnlock() return len(r.nodes) } // Pick returns any one connected node (round-robin은 추후 구현). func (r *Registry) Pick() (*NodeEntry, error) { r.mu.RLock() defer r.mu.RUnlock() for _, e := range r.nodes { return e, nil } return nil, fmt.Errorf("no nodes connected") } ``` **`apps/edge/internal/transport/server.go` (신규)** proto-socket TcpServer로 노드 연결을 수락하고 Registry에 등록한다. 연결 시 CapabilityRequest를 전송해 노드 정보를 확인한다. ```go package transport import ( "net" "strconv" "time" toki "git.toki-labs.com/toki/common-proto-socket/go" "go.uber.org/zap" "google.golang.org/protobuf/proto" edgenode "iop/apps/edge/internal/node" iop "iop/proto/gen/iop" ) const ( heartbeatIntervalSec = 30 heartbeatWaitSec = 10 capabilityTimeout = 5 * time.Second ) func edgeParserMap() toki.ParserMap { return toki.ParserMap{ toki.TypeNameOf(&iop.RunEvent{}): func(b []byte) (proto.Message, error) { m := &iop.RunEvent{} return m, proto.Unmarshal(b, m) }, } } // Server wraps proto-socket TcpServer and manages node connections. type Server struct { tcp *toki.TcpServer registry *edgenode.Registry logger *zap.Logger } func NewServer(listen string, registry *edgenode.Registry, logger *zap.Logger) (*Server, error) { host, portStr, err := net.SplitHostPort(listen) if err != nil { return nil, err } port, err := strconv.Atoi(portStr) if err != nil { return nil, err } s := &Server{registry: registry, logger: logger} s.tcp = toki.NewTcpServer(host, port, func(conn net.Conn) *toki.TcpClient { return toki.NewTcpClient(conn, heartbeatIntervalSec, heartbeatWaitSec, edgeParserMap()) }) s.tcp.OnClientConnected = s.onNodeConnected return s, nil } func (s *Server) Start(ctx context.Context) error { if err := s.tcp.Start(ctx); err != nil { return err } s.logger.Info("edge listening for nodes", zap.String("addr", s.tcp.Addr())) return nil } func (s *Server) Stop() error { return s.tcp.Stop() } func (s *Server) onNodeConnected(client *toki.TcpClient) { s.logger.Info("node connection established") toki.AddListenerTyped[*iop.RunEvent](&client.Communicator, func(e *iop.RunEvent) { s.logger.Debug("run event received", zap.String("run_id", e.GetRunId()), zap.String("type", e.GetType()), ) }) go func() { resp, err := toki.SendRequestTyped[*iop.CapabilityRequest, *iop.CapabilityResponse]( &client.Communicator, &iop.CapabilityRequest{}, capabilityTimeout, ) if err != nil { s.logger.Warn("capability request failed", zap.Error(err)) _ = client.Close() return } entry := &edgenode.NodeEntry{ NodeID: resp.GetNodeId(), Client: client, Adapters: resp.GetAdapters(), } s.registry.Register(entry) s.logger.Info("node registered", zap.String("node_id", entry.NodeID), zap.Int("adapters", len(entry.Adapters)), ) client.AddDisconnectListener(func(_ *toki.TcpClient) { s.registry.Unregister(entry.NodeID) s.logger.Info("node unregistered", zap.String("node_id", entry.NodeID)) }) }() } ``` > **주의**: proto-socket `TcpServer`에 `Addr()` 메서드가 없으면 Start 로그에서 > 주소 출력을 생략하고 `logger.Info("edge listening for nodes")`만 사용한다. **`apps/edge/internal/bootstrap/module.go` (신규)** ```go package bootstrap import ( "context" "go.uber.org/fx" "go.uber.org/zap" edgenode "iop/apps/edge/internal/node" "iop/apps/edge/internal/transport" "iop/packages/config" "iop/packages/observability" ) func Module(cfg *config.EdgeConfig) fx.Option { return fx.Options( fx.Provide( func() *config.EdgeConfig { return cfg }, func(cfg *config.EdgeConfig) (*zap.Logger, error) { return observability.NewLogger(cfg.Logging.Level) }, func() *edgenode.Registry { return edgenode.NewRegistry() }, func(cfg *config.EdgeConfig, reg *edgenode.Registry, logger *zap.Logger) (*transport.Server, error) { return transport.NewServer(cfg.Server.Listen, reg, logger) }, ), fx.Invoke(func(lc fx.Lifecycle, srv *transport.Server, cfg *config.EdgeConfig, logger *zap.Logger) { lc.Append(fx.Hook{ OnStart: func(ctx context.Context) error { if err := srv.Start(ctx); err != nil { return err } go func() { if err := observability.ServeMetrics(cfg.Metrics.Port); err != nil { logger.Warn("metrics server exited", zap.Error(err)) } }() return nil }, OnStop: func(_ context.Context) error { return srv.Stop() }, }) }), ) } ``` **`apps/edge/cmd/edge/main.go` — serve 커맨드 구현** ```go // Before (main.go:18-34) 전체 package main import ( "fmt" "os" "github.com/spf13/cobra" ) func main() { ... } func rootCmd() *cobra.Command { root := &cobra.Command{...} root.AddCommand(&cobra.Command{ Use: "serve", Run: func(...) { fmt.Println("edge serve: not yet implemented") }, }) return root } // After package main import ( "fmt" "os" "github.com/spf13/cobra" "go.uber.org/fx" "iop/apps/edge/internal/bootstrap" "iop/packages/config" ) var cfgFile string func main() { if err := rootCmd().Execute(); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } } func rootCmd() *cobra.Command { root := &cobra.Command{ Use: "edge", Short: "IOP Edge — node gateway server", } root.PersistentFlags().StringVarP(&cfgFile, "config", "c", "configs/edge.yaml", "config file path") root.AddCommand(serveCmd()) return root } func serveCmd() *cobra.Command { return &cobra.Command{ Use: "serve", Short: "Start the IOP edge server", RunE: func(_ *cobra.Command, _ []string) error { cfg, err := config.LoadEdge(cfgFile) if err != nil { return fmt.Errorf("load config: %w", err) } fx.New(bootstrap.Module(cfg)).Run() return nil }, } } ``` **`configs/edge.yaml` 업데이트** ```yaml # Before server: listen: "0.0.0.0:8080" # After server: listen: "0.0.0.0:9090" tls: enabled: false logging: level: "info" metrics: port: 9092 ``` **수정 파일 및 체크리스트** - [x] `packages/config/config.go` — EdgeConfig, EdgeServerConf, LoadEdge 추가 - [x] `apps/edge/internal/node/registry.go` — 신규 생성 - [x] `apps/edge/internal/transport/server.go` — 신규 생성 - [x] `apps/edge/internal/bootstrap/module.go` — 신규 생성 - [x] `apps/edge/cmd/edge/main.go` — serve 커맨드 구현으로 교체 - [x] `configs/edge.yaml` — listen 포트 9090으로 수정 **테스트 작성** NodeRegistry의 Register/Unregister/Pick 로직은 단위 테스트로 검증한다. 파일: `apps/edge/internal/node/registry_test.go` ```go package node_test import ( "testing" toki "git.toki-labs.com/toki/common-proto-socket/go" edgenode "iop/apps/edge/internal/node" iop "iop/proto/gen/iop" ) func TestRegistry_RegisterAndCount(t *testing.T) { reg := edgenode.NewRegistry() entry := &edgenode.NodeEntry{ NodeID: "node-001", Client: &toki.TcpClient{}, Adapters: []*iop.AdapterInfo{{Name: "mock"}}, } reg.Register(entry) if reg.Count() != 1 { t.Fatalf("expected 1 node, got %d", reg.Count()) } } func TestRegistry_Pick_Empty(t *testing.T) { reg := edgenode.NewRegistry() if _, err := reg.Pick(); err == nil { t.Fatal("expected error on empty registry") } } func TestRegistry_Unregister(t *testing.T) { reg := edgenode.NewRegistry() reg.Register(&edgenode.NodeEntry{NodeID: "node-001"}) reg.Unregister("node-001") if reg.Count() != 0 { t.Fatalf("expected 0 nodes after unregister, got %d", reg.Count()) } } ``` **중간 검증** ```bash PATH=/config/go-sdk/bin:$PATH go build ./apps/edge/... PATH=/config/go-sdk/bin:$PATH go test ./apps/edge/internal/node/... ``` 기대 결과: 빌드 및 테스트 통과 --- ## 수정 파일 요약 | 파일 | 항목 | 변경 유형 | |------|------|-----------| | `go.mod` | REFACTOR-1 | 수정 | | `proto/gen/iop/*.pb.go` | REFACTOR-1 | 신규(생성) | | `apps/node/internal/transport/parser.go` | REFACTOR-2 | 신규 | | `apps/node/internal/transport/session.go` | REFACTOR-2 | 신규 | | `apps/node/internal/transport/client.go` | REFACTOR-2 | 신규 | | `apps/node/internal/node/node.go` | REFACTOR-3 | 수정 | | `apps/node/internal/bootstrap/module.go` | REFACTOR-4 | 수정 | | `packages/config/config.go` | REFACTOR-5 | 수정 | | `apps/edge/internal/node/registry.go` | REFACTOR-5 | 신규 | | `apps/edge/internal/transport/server.go` | REFACTOR-5 | 신규 | | `apps/edge/internal/bootstrap/module.go` | REFACTOR-5 | 신규 | | `apps/edge/cmd/edge/main.go` | REFACTOR-5 | 수정 | | `configs/edge.yaml` | REFACTOR-5 | 수정 | --- ## 최종 검증 터미널 두 개가 필요하다. **터미널 A — edge 서버 실행:** ```bash PATH=/config/go-sdk/bin:$PATH go build -o bin/edge ./apps/edge/cmd/edge ./bin/edge serve --config configs/edge.yaml ``` 기대 결과: ``` {"level":"info",...,"msg":"edge listening for nodes"} ``` **터미널 B — node 실행 (edge에 연결):** ```bash PATH=/config/go-sdk/bin:$PATH go build -o bin/node ./apps/node/cmd/node ./bin/node serve --config configs/node.yaml ``` 기대 결과 — node 로그: ``` {"level":"info",...,"msg":"connected to edge","addr":"localhost:9090","node_id":"node-001"} ``` 기대 결과 — edge 로그: ``` {"level":"info",...,"msg":"node connection established"} {"level":"info",...,"msg":"node registered","node_id":"node-001","adapters":1} ``` **전체 빌드 및 테스트:** ```bash PATH=/config/go-sdk/bin:$PATH go build ./... PATH=/config/go-sdk/bin:$PATH go test ./apps/edge/internal/node/... ```