Go로 서버를 개발하는 과정에서 서버 내부에 TCP 클라이언트 구조체(rq_client.go, rd_client.go)에서 두 가지 race condition을 발견해서 수정함. 나중에 비슷한 경험을 할 수 있어서 기억하려고 글로 남겨놓음.
문제 1: 재연결 후 새 커넥션을 닫는 race
handleDisconnect()는 연결이 끊기면 c.conn을 닫고 nil로 초기화하는 함수. 재연결이 완료되어 이미 새 커넥션이 할당된 상태에서, 이전 커넥션의 readLoop 고루틴이 뒤늦게 handleDisconnect()를 호출하면 새 커넥션까지 닫아버리는 문제가 있었음.
race가 터지는 타이밍:
1 2 3 4 5 6 7
[고루틴 A - 구 readLoop][고루틴 B - reconnect] ReadFrame() → 에러 감지 connect() 성공 → c.conn = newConn handleDisconnect() 호출 c.conn != nil 확인 → true ← 이미 newConn이 들어있는 상태 c.conn.Close() ← newConn을 닫아버림! c.conn = nil
재연결과 이전 readLoop 고루틴의 종료 타이밍이 겹칠 때 발생함. 네트워크가 불안정해서 재연결이 빠르게 이루어질수록 재현 확률이 높아짐.
1 2 3 4 5 6 7 8 9
// before: 현재 커넥션이 누구든 무조건 닫음 func(c *RQClient) handleDisconnect() { c.mu.Lock() if c.conn != nil { _ = c.conn.Close() c.conn = nil } c.mu.Unlock() }
수정: 고루틴이 읽던 커넥션(oldConn)을 파라미터로 받아서, 현재 c.conn과 동일할 때만 정리함.
func(c *RQClient) readLoop() { conn := c.conn // 이 고루틴이 담당하는 커넥션 for { _, err := c.reader.ReadFrame() if err != nil { c.handleDisconnect(conn) // 자기 커넥션만 정리 continue } // ... } }
문제 2: 동시 전송 시 프레임 interleaving
Send()에서 뮤텍스를 잡고 커넥션 포인터만 복사한 뒤 락을 풀고 Write()를 호출하고 있었음. 두 고루틴이 동시에 Send()를 호출하면 Write가 동시에 실행되어 TCP 스트림에 프레임이 섞일 수 있음.
race가 터지는 타이밍:
1 2 3 4 5 6 7 8
[고루틴 A][고루틴 B] mu.Lock() → conn 복사 mu.Unlock() mu.Lock() → conn 복사 mu.Unlock() conn.Write(frameA 앞부분) conn.Write(frameB 전체) ← 중간에 끼어듦 conn.Write(frameA 뒷부분)
TCP는 스트림 프로토콜이라 수신 측에서 프레임 경계를 직접 파싱함. 프레임이 섞이면 파싱이 깨져 이후 모든 요청이 오동작하게 됨. 동시 요청이 많을수록 재현 확률이 높아짐.
1 2 3 4 5 6 7 8
// before: 락 해제 후 Write → 동시 Write 가능 func(c *RQClient) Send(data []byte) error { c.mu.Lock() conn := c.conn c.mu.Unlock() // 여기서 락 해제 _, err := conn.Write(data) // 동시 실행 가능 // ... }