backend

Go에서 DB 연동하기: database/sql과 Repository 패턴으로 구조 잡기

mirabo01 2026. 2. 1. 22:27

CRUD API까지 만들었다면,
다음으로 자연스럽게 이어지는 주제는 DB 연동이다.

이번 챕터에서는
ORM을 바로 쓰지 않고, 표준 라이브러리 database/sql 을 기준으로

  • DB 연결 흐름
  • SQL 실행 방식
  • Repository 패턴으로 구조를 정리하는 방법

을 정리한다.

목표는
“편한 코드”가 아니라
실무에서 오래 유지되는 구조를 한 번 만들어보는 것이다.


왜 database/sql부터 다뤄야 할까

Go에는 GORM 같은 ORM도 있고,
sqlx 같은 헬퍼 라이브러리도 있다.

그럼에도 불구하고
database/sql을 먼저 다뤄보는 이유는 명확하다.

  • 모든 DB 라이브러리의 기반
  • 쿼리 실행 흐름이 명확함
  • 성능 특성을 직접 제어 가능
  • 문제가 생겼을 때 디버깅이 쉬움

실무에서도
“ORM을 쓰더라도 내부 동작은 database/sql”이라는 전제를 알고 있는지 여부가
코드 품질 차이를 만든다.


DB 연동의 전체 흐름

Go에서 DB를 쓰는 기본 흐름은 항상 같다.

  1. DB 연결(sql.Open)
  2. 커넥션 풀 관리
  3. 쿼리 실행
  4. 결과 스캔
  5. 리소스 정리

[이미지: 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 설계
중에서 이어가면 좋다.