<!-- task=edge_node_arch plan=1 tag=REFACTOR -->

# 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/...
```
