처음 써 본 Go로 댓글 서비스의 뼈대를 세우다
틀이 정해진 언어와 흔적을 남기지 않는 테스트
2026/09/22처음 써 본 Go로 댓글 서비스의 뼈대를 세우다
orbithall 개발기 두 번째 글이다. 이번에는 API 서버의 뼈대를 다룬다. 언어와 라이브러리를 고른 이유, 개발 환경과 DB 스키마를 적용하는 방식, 그리고 테스트를 어떻게 굴렸는지를 차례로 적고, 마지막에는 코딩 에이전트인 Claude Code와 이 단계를 어떻게 함께했는지 덧붙인다.
Go와 Chi
Go는 군더더기가 적은 언어다. 키워드가 25개뿐일 만큼 언어 자체가 작고, 에러도 예외로 던지는 대신 값으로 돌려받아 if err != nil로 하나하나 확인한다. 숨은 동작보다 눈에 보이는 코드를 선호하고, 웬만한 일은 표준 라이브러리로 해결하는 문화가 있다. 코드 스타일도 gofmt 하나로 정해져 있다. 1편에서 말한 것처럼, 이렇게 틀이 정해진 언어라면 에이전트와 함께 코딩할 때 더 빠르고 안정적이지 않을까 싶었다.
라우터는 Chi를 골랐다. Gin이나 Echo는 자체 컨텍스트 타입과 생태계를 갖춘 프레임워크지만, Chi는 Go 표준 net/http를 그대로 쓰면서 라우팅과 미들웨어만 더해 주는 가벼운 라우터다. 군더더기 없는 Go의 결과 잘 맞는다고 봤고, 규칙 파일에도 Chi만 쓰도록 처음부터 적어 두었다.
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Route("/api", func(r chi.Router) {
r.Use(handlers.AuthMiddleware(db))
r.Get("/posts/{slug}/comments", commentHandler.ListComments)
r.Put("/comments/{id}", commentHandler.UpdateComment)
r.Delete("/comments/{id}", commentHandler.DeleteComment)
})
/api 아래 경로에는 API 키를 확인하는 미들웨어를 걸고, 각 핸들러는 표준 http.HandlerFunc 모양 그대로다. Go의 net/http만 알면 읽을 수 있는 코드라서, Go를 처음 배우는 입장에서도 부담이 적었다.
SQL은 직접 쓴다
DB 접근에는 ORM을 쓰지 않고, Go 표준 database/sql로 SQL을 직접 썼다. 테이블이라고 해 봐야 사이트, 글, 댓글에 나중에 붙은 사용자 정도인 작은 스키마다. 여기에 ORM을 붙이는 건 배보다 배꼽이 큰 일이라고 봤다. SQL을 직접 쓰면 어떤 쿼리가 실행되는지 코드에 그대로 보이고, 코드를 생성하는 별도 단계도 필요 없다. 에이전트가 쓴 코드를 읽을 때도 실제로 어떤 쿼리가 나가는지 바로 확인할 수 있다.
DB를 다루는 함수들은 모두 아래 인터페이스를 받는다.
type DBTX interface {
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}
Go의 인터페이스는 따로 선언하지 않아도 메서드만 맞으면 만족된다. DB 연결(*sql.DB)과 트랜잭션(*sql.Tx) 모두 이 세 메서드를 가지고 있어서, 같은 함수를 운영에서는 DB 연결로, 테스트에서는 트랜잭션으로 부를 수 있다. 이 구조가 뒤에서 이야기할 테스트 방식의 바탕이 된다.
개발 환경과 마이그레이션
개발 환경은 Docker Compose로 PostgreSQL과 API 서버 컨테이너를 띄운다. API 컨테이너는 Air로 Go 파일이 바뀔 때마다 다시 빌드하고 재시작한다. 테스트는 로컬에 설치한 Go로 돌린다.
DB 스키마 변경(마이그레이션)은 SQL 파일로 관리한다. 컨테이너가 시작할 때마다 golang-migrate가 아직 적용되지 않은 파일만 적용한 뒤 서버를 띄운다. 개발과 운영이 같은 Dockerfile로 같은 순서를 밟으니, 로컬과 운영의 스키마가 어긋날 일이 없다. 마이그레이션을 별도 컨테이너나 배포 파이프라인의 한 단계로 빼는 방법도 있었지만, 서버 한 대짜리 서비스에는 이쪽이 가장 단순했다.
테스트: 흔적을 남기지 않게
TDD는 좋다고는 들었지만 실제로 해 볼 기회가 없던 방식이었다. 이번에는 에이전트의 도움을 받아 테스트를 먼저 쓰고 구현하는 흐름을 처음으로 제대로 해 봤다. 규칙 파일에 모든 구현에는 테스트를 붙이도록 적어 두었고, 지금은 테스트 코드가 약 7천 줄로 실제 코드(약 4천 줄)보다 많다.
테스트는 대부분 실제 PostgreSQL에 쿼리를 날리는 통합 테스트다. 처음에는 테스트에서 넣은 데이터를 cleanup 함수로 직접 지웠는데, 금방 번거로워졌다. 외래 키 때문에 지우는 순서를 신경 써야 했고, 테스트가 중간에 실패하면 cleanup이 돌지 않아 데이터가 그대로 남았다. 백엔드의 표준적인 테스트 방식을 잘 모르기도 했고, 무엇보다 테스트 때문에 DB에 데이터가 쌓이는 게 싫었다.
그래서 테스트마다 트랜잭션을 열고, 끝나면 무조건 롤백하는 방식으로 바꿨다.
func TestCreateComment(t *testing.T) {
db := testhelpers.SetupTestDB(t)
defer db.Close()
ctx, tx, cleanup := testhelpers.SetupTxTest(t, db)
defer cleanup() // 테스트가 끝나면 성공·실패와 상관없이 롤백
// tx로 사이트, 글, 댓글을 만들고 결과를 검증한다
}
DB 함수가 앞서 본 DBTX를 받기 때문에, 테스트에서는 트랜잭션을 그대로 넘기면 된다. 테스트가 성공하든 실패하든 defer로 롤백되니 DB에는 아무것도 남지 않는다.
같은 날 테스트 전용 DB도 따로 만들었는데, 여기서 초기 설정과 맞지 않는 부분이 드러났다. 마이그레이션은 API 컨테이너가 시작할 때 개발용 DB에만 적용되도록 짜여 있었다. 새로 만든 테스트 DB에는 테이블이 생기지 않았다. 결국 테스트를 시작할 때 테스트 헬퍼가 직접 마이그레이션을 적용하도록 바꿨다.

트랜잭션 안에서 멈춘 시계
롤백 방식에는 예상하지 못한 대가가 따라왔다. PostgreSQL의 NOW()는 함수를 부른 시각이 아니라 트랜잭션이 시작된 시각을 돌려준다. 한 테스트 안에서 댓글 세 개를 연달아 만들면 세 댓글의 작성 시각이 모두 같아진다. 작성 시각으로만 정렬하던 댓글 목록은 순서가 보장되지 않았고, 테스트가 실패했다.
그래서 두 가지를 정했다. 첫째, 정렬은 ORDER BY created_at ASC, id ASC처럼 작성 시각이 같으면 id로 한 번 더 정렬한다. 둘째, 시각을 기록하는 함수를 용도에 따라 나눴다.
| 함수 | 돌려주는 시각 | 쓰는 곳 |
|---|---|---|
NOW() |
트랜잭션이 시작된 시각 | 작성 시각 |
CLOCK_TIMESTAMP() |
함수를 부른 실제 시각 | 수정 시각, 삭제 시각 |
수정 시각까지 NOW()로 남기면, 한 트랜잭션 안에서 만들고 고친 댓글은 작성 시각과 수정 시각이 같아져 수정 여부를 가릴 수 없다. 실제로 수정한 순간을 남기려면 CLOCK_TIMESTAMP()가 필요했다.
테스트 방식 하나를 바꾸자, 정렬 기준과 시각을 기록하는 방식까지 다시 정해야 했다. 이 결정들은 하나씩 결정 기록(ADR)으로 남겨 두었다.
에이전트와 작업하며
Go를 고르며 기대했던 부분은 어느 정도 맞았다. 컴파일 언어라서, 에이전트가 코드를 쓰고 빌드한 뒤 컴파일 에러를 보고 스스로 고치는 과정을 꽤 알아서 해 나갔다. Go는 타입이 맞지 않는 코드는 물론 쓰지 않는 변수나 import가 남아도 컴파일되지 않는다. 사람이 보기 전에 컴파일러가 한 번 걸러 주는 셈이다. if err != nil이 끝없이 반복되는 Go 특유의 코드도, 내가 직접 쓰지 않아도 되니 거부감이 한결 덜했다.
공부가 목적이었으니 Go 코드에는 주석을 풍부하게 달아 달라고 했다. 규칙 파일에 "Go를 모르는 사람도 이해할 수 있게" 주석을 달라고 적어 두었는데, 이게 꽤 도움이 됐다. 처음 보는 언어 특유의 표현도 주석과 함께 읽으니 이해하기 쉬웠다.
다만 주석에도 원칙이 필요했다. 에이전트는 주석에 작업하던 맥락을 그대로 녹여 넣곤 한다. "(작업하면서 알게 된 건데 원래 ...를 적용했다가 잘 안 돼서) ...로 변경" 같은 주석은 에이전트의 혼잣말일 뿐, 나중에 코드를 읽는 사람에게는 도움이 되지 않는다. 주석에는 필요한 정보만 담고, 왜 달렸는지 처음 보는 사람도 이해할 수 있어야 한다.
마침 첫 설정부터 이 문제를 겪었다. 에이전트가 잡은 첫 Docker 설정은 에이전트가 알고 있던 옛 버전(Go 1.21)과 옛 도구 경로로 만들어져서 빌드가 되지 않았다. 이걸 고치면서 규칙 파일에 "변경 이력이나 과거 상태는 주석에 남기지 않는다, 현재 상태와 이유만 설명한다"는 원칙을 넣고, 나쁜 예시로 "기존 golang:1.21을 1.25로 업데이트" 같은 주석을 적어 두었다.
문서와 코드가 어긋난 일도 이 시기에 있었다. 댓글 API 작업 문서에는 "삭제 핸들러 구현 완료, 테스트 5개 통과"라고 적혀 있었는데, 정작 커밋된 코드의 삭제 핸들러는 TODO로 비어 있었다. 작업하던 코드가 커밋되기 전에 수정하는 과정이나 git을 다루다 지워졌던 것 같다. 같은 날 오후 테스트와 함께 다시 채웠다. 문서의 "완료"가 곧 코드의 완료는 아니었던 셈이다.
다음 편에서는 이 뼈대 위에 만든 댓글 API를 다룬다.
저장소
- API 서버: june20516/orbithall