Go에서 DB 연동하기: database/sql과 Repository 패턴으로 구조 잡기
CRUD API까지 만들었다면,
다음으로 자연스럽게 이어지는 주제는 DB 연동이다.
이번 챕터에서는
ORM을 바로 쓰지 않고, 표준 라이브러리 database/sql 을 기준으로
- DB 연결 흐름
- SQL 실행 방식
- Repository 패턴으로 구조를 정리하는 방법
을 정리한다.
목표는
“편한 코드”가 아니라
실무에서 오래 유지되는 구조를 한 번 만들어보는 것이다.
왜 database/sql부터 다뤄야 할까
Go에는 GORM 같은 ORM도 있고,
sqlx 같은 헬퍼 라이브러리도 있다.
그럼에도 불구하고
database/sql을 먼저 다뤄보는 이유는 명확하다.
- 모든 DB 라이브러리의 기반
- 쿼리 실행 흐름이 명확함
- 성능 특성을 직접 제어 가능
- 문제가 생겼을 때 디버깅이 쉬움
실무에서도
“ORM을 쓰더라도 내부 동작은 database/sql”이라는 전제를 알고 있는지 여부가
코드 품질 차이를 만든다.
DB 연동의 전체 흐름
Go에서 DB를 쓰는 기본 흐름은 항상 같다.
- DB 연결(sql.Open)
- 커넥션 풀 관리
- 쿼리 실행
- 결과 스캔
- 리소스 정리
[이미지: Go database/sql 전체 흐름]
이 흐름을 구조로 녹여내는 게 이번 챕터의 핵심이다.
기본 구조 다시 잡기
이전 CRUD 구조에 DB 계층만 추가한다.
cmd/server/main.go
internal/handler/
internal/service/
internal/repository/
internal/model/
- model: DB와 매핑되는 구조체
- repository: SQL 처리 전담
- service: 비즈니스 로직
- handler: HTTP 처리
DB 코드는 repository에만 존재하도록 하는 게 목표다.
DB 연결 설정하기
import "database/sql"
import _ "github.com/mattn/go-sqlite3"
db, err := sql.Open("sqlite3", "app.db")
if err != nil {
log.Fatal(err)
}
⚠️ 중요한 포인트
- sql.Open은 실제 연결을 즉시 만들지 않는다
- 에러는 설정 문제만 검증
- 실제 연결 확인은 Ping()으로 한다
if err := db.Ping(); err != nil {
log.Fatal(err)
}
이 패턴은
MySQL, PostgreSQL에서도 동일하다.
model 정의하기 (DB 기준)
type User struct {
ID int
Name string
}
- JSON tag는 여기서 붙이지 않는다
- 이 구조체는 DB 표현에 가깝다
HTTP 응답용 구조체와 분리하는 것도
실무에서는 충분히 고려할 만한 선택이다.
Repository 패턴 도입하기
Repository의 역할은 명확하다.
“이 데이터가 어디에서 오는지 숨긴다”
인터페이스 정의
type UserRepository interface {
Create(name string) (User, error)
FindAll() ([]User, error)
FindByID(id int) (User, error)
}
- service는 이 인터페이스만 의존
- DB가 바뀌어도 service 코드는 그대로 유지
SQL 기반 Repository 구현
type userRepository struct {
db *sql.DB
}
func (r *userRepository) Create(name string) (User, error) {
res, err := r.db.Exec(
"INSERT INTO users(name) VALUES(?)",
name,
)
if err != nil {
return User{}, err
}
id, _ := res.LastInsertId()
return User{ID: int(id), Name: name}, nil
}
여기서 중요한 점은
- SQL은 repository에만 존재
- service는 SQL을 전혀 모른다
[이미지: Repository 패턴 의존성 방향]
조회 쿼리와 Scan 처리
func (r *userRepository) FindAll() ([]User, error) {
rows, err := r.db.Query("SELECT id, name FROM users")
if err != nil {
return nil, err
}
defer rows.Close()
users := []User{}
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Name); err != nil {
return nil, err
}
users = append(users, u)
}
return users, nil
}
⚠️ 주의할 점
- rows.Close()는 반드시 defer
- Scan 순서는 SELECT 컬럼 순서와 일치해야 함
- rows.Next() 이후 에러 처리 습관화
Service 계층은 이렇게 단순해진다
type UserService struct {
repo UserRepository
}
func (s *UserService) CreateUser(name string) (User, error) {
return s.repo.Create(name)
}
- 비즈니스 규칙이 여기로 모인다
- DB 세부 구현은 관심사 밖
이 구조 덕분에
테스트가 훨씬 쉬워진다.
Repository를 Mock으로 대체한 테스트
type fakeUserRepo struct {}
func (f *fakeUserRepo) Create(name string) (User, error) {
return User{ID: 1, Name: name}, nil
}
service := NewUserService(&fakeUserRepo{})
- 실제 DB 없이 service 테스트 가능
- 테스트 속도와 안정성 확보
이게 바로
interface + repository 패턴의 실질적인 가치다.
transaction은 어디서 관리할까
실무에서 자주 나오는 질문이다.
일반적인 기준은 다음이다.
- 단일 쿼리 → repository 내부
- 여러 repository 조합 → service에서 관리
tx, err := db.Begin()
이 경우 repository 메서드 시그니처를
조금 확장하게 된다.
이건 난이도가 올라가는 지점이니
처음에는 “그런 게 있다” 정도로만 인지해도 충분하다.
ORM을 쓰기 전에 이 단계를 거치는 이유
이 단계까지 경험하고 나면
- ORM이 편한 이유
- ORM이 숨기는 비용
- ORM으로 해결 안 되는 문제
가 동시에 보이기 시작한다.
그래서 실무에서는
- 단순 CRUD → ORM
- 복잡한 쿼리, 성능 민감 영역 → SQL 직접 작성
같은 혼합 전략을 쓰는 경우도 많다.
정리
Go에서 DB 연동의 핵심은
SQL보다 구조와 책임 분리다.
- database/sql은 모든 기반
- Repository 패턴으로 DB 의존성 차단
- service는 인터페이스에만 의존
- 테스트와 확장이 쉬워진다
이 구조를 한 번 익혀두면,
다음 단계인
- sqlx 적용
- GORM 도입
- 트랜잭션 설계
- 실제 PostgreSQL/MySQL 연동
으로 넘어가도
흐름이 전혀 흔들리지 않는다.
다음 주제로는
Gin으로 지금 구조 그대로 옮겨보기
혹은
실제 PostgreSQL 연동 + migration 설계
중에서 이어가면 좋다.