티스토리 뷰
개요
Go 백엔드 2: 설정과 같은 브랜치에서 작업하였으나 데이터베이스 관련은 별도로 정리해두었다. 데이터베이스 마이그레이션과 데이터베이스 관련 라이브러리는 추후 별도로 다룰 예정이다.
(참고) Go 백엔드 1: 클린 아키텍처 기본 코드를 누적하고 싶었지만, 오류와 개선 사항이 있어 전면적으로 수정했다. 양해를 구한다.
링크
- GitHub 브랜치: https://github.com/nicewook/gocore/tree/2_config-and-db
- 블로그 링크
설정
프로젝트 구조
데이터베이스와 관련한 프로젝트의 주요 디렉토리와 파일 구조는 다음과 같다(일부 생략)
├── cmd
│ └── gocore
│ └── main.go
├── docker-compose.yaml
└── internal
├── db
│ └── db.go
├── domain
│ ├── errors.go
│ └── user.go
├── handler
│ └── userHandler.go
├── repository
│ └── postgres
│ └── userRepository.go
└── usecase
└── userUsecase.go
- docker-compose.yaml 은 로컬에서 도커로 PostgreSQL을 띄우기 위한 용도이다.
- internal/db/db.go 는 데이터베이스 연결 및 초기 테이블 생성 코드가 있다.
- internal/domain/user.go 는 usecase, repository 인터페이스에 대한 정의가 있다.
- internal/repository/postgres/userRepository.go 는 실제로 데이터베이스와 연동하는 부분이다.
- 마지막으로 cmd/gocore/main.go 를 보겠다.
docker-compose.yaml
로컬에서 PostgreSQL 서버를 띄우기 위한 파일이다.
- 현시점 최신버전인 postgres:17.2 이미지를 사용하였다. latest 태그는 예상치 못한 동작을 초래할 수 있으니 특히 프로덕션에서는 사용하지 않는다.
- user, password, db, 그리고 port 정보도 설정한다.
- 데이터베이스 연결시에 이 설정에 맞게 연결요청을 해야 한다.
- 도커 내부의 5432 포트를 호스트 PC의 5432 포트와 매칭한다.
- volumes 는 db_data 라는 볼륨을 사용하겠다고 선언하는 것이다.
- services.db.volumes는 이렇게 명시한 볼륨을 호스트의 /var/lib/postgresql/data 디렉토리에 연결한다는 것이다.
- 이로서 컨테이너를 종료해도 데이터는 그대로 남게 된다.
services:
db:
image: postgres:17.2
environment:
POSTGRES_USER: dev_user
POSTGRES_PASSWORD: dev_password
POSTGRES_DB: dev_db
ports:
- "5432:5432"
volumes:
- db_data:/var/lib/postgresql/data
volumes:
db_data:
docker compose를 사용하는 방법은 다음과 같다. 지주쓰게 된다면 Makefile에 타켓을 생성할 수 있겠다.
$ docker compose up -d # 컨테이너를 백그라운드에서 실행
$ docker compose down # 컨테이너 삭제. 데이터는 유지
$ docker compose down -v # 컨테이너 삭제. 데이터를 담은 볼륨까지 제거
internal/db/db.go
DB 연결을 생성하는 코드이다.
- 설정파일에서 가져온 정보를 이용하여 DSN을 생성한다.
- 데이터베이스와의 연결을 생성한다.
- 연결 풀과 관련한 설정을 해준다. 이 부분 만으로도 하나의 글을 장성할 수 있겠다.
- db.Ping()으로 실제 연결 상태를 확인하는 것은 좋은 습관이다. 이는 DB 설정 오류나 네트워크 문제를 초기 단계에서 빠르게 감지하는 데 도움이 된다.
- createUserTable 함수로 테이블을 생성해준 후 db 연결을 리턴해준다.
func NewDBConnection(cfg *config.Config) (*sql.DB, error) {
// DSN(Data Source Name) 생성
dsn := fmt.Sprintf(
"host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
cfg.DB.Host, cfg.DB.Port, cfg.DB.User, cfg.DB.Password, cfg.DB.DBName, cfg.DB.SSLMode,
)
// 데이터베이스 연결 생성: sql.Open은 실제 연결을 생성하는 것이 아니라 연결 가능한 객체를 반환한다. db.Ping()을 통해 실제 연결을 확인한다.
db, err := sql.Open("postgres", dsn)
if err != nil {
return nil, fmt.Errorf("failed to open database connection: %w", err)
}
// 연결 풀 설정 (성능 최적화)
db.SetMaxOpenConns(25) // 동시에 열 수 있는 최대 연결 수
db.SetMaxIdleConns(25) // 유휴 상태로 유지할 연결의 최대 수
db.SetConnMaxLifetime(5 * time.Minute) // 연결의 최대 수명 (5분 후 재연결)
// 실제 연결을 테스트하여 연결 가능 여부 확인
if err := db.Ping(); err != nil {
return nil, fmt.Errorf(
"failed to ping database (host: %s, db: %s): %w",
cfg.DB.Host, cfg.DB.DBName, err,
)
}
if err := createUserTable(db); err != nil {
return nil, fmt.Errorf("failed to create users table: %w", err)
}
return db, nil
}
createUserTable 함수는 users 테이블이 없는 경우에만 테이블을 생성해주는 함수이다.
- 예시를 위해 이렇게 추가해두었지만 goose 등의 db migration 라이브러리로 관리해야 할 부분이다.
func createUserTable(db *sql.DB) error {
const query = `
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE
)
`
if _, err := db.Exec(query); err != nil {
return fmt.Errorf("failed to create users table: %w", err)
}
return nil
}
internal/domain/user.go
인터페이스 부분을 리팩터링 하였다.
<aside> 💡
인터페이스는 마치 회사의 채용공고(Job Description, JD)와 같다.
예를 들어 UserRepository 인터페이스는 “이런 기능을 구현할 수 있는 사람(구현체)을 찾는다”는 의미다.
- Save: User를 저장해주는 기능
- GetByID: ID로 User를 조회하는 기능
- GetAll: 모든 User를 조회하는 기능
이렇게 정해진 조건만 만족하면, 어떤 사람이든(구현체든) 이 직무를 맡을 수 있는 것이다.
</aside>
리팩터링 내용은 다음과 같다.
- GetAll 을 추가하고, GetByID로 이름을 변경하였다.
- 파라미터와 리턴값은 포인터로 통일하였다. 포인터를 쓰는 것과 값을 쓰는 것은 장단점이 있다.
- 포인터: 메모리를 절약할 수 있고, 원본을 수정할 수 있지만 nil 체크
- User를 추가하는 UserUseCase의 CreateUser, UserRepository의 Save의 경우는 포인터를 파라미터로 넣으니 굳이 리턴값을 받지 않아도 되지만, 가독성을 위해 User 파라미터를 리턴 받도록 했다. 이는 리포지토리 구현부 코드에서 좀 더 설명하겠다.
- GetAll 을 추가해두었다.
type UserRepository interface {
Save(user *User) (*User, error)
GetByID(id int64) (*User, error)
GetAll() ([]User, error)
}
type UserUseCase interface {
CreateUser(user *User) (*User, error)
GetByID(id int64) (*User, error)
GetAll() ([]User, error)
}
internal/repository/postgres/userRepository.go
- (참고) GetAll 도 구현해두었으나 여기서 코드는 생략한다.
- Save를 주목해서 보자.
- PostgreSQL에 특화한 에러체크 부분을 유념하자. unique 필드인 email 에 중복이 있으면 포스트그레스만의 에러가 발생하고 이를 확인하여 에러가 발생하도록 해두었다.
- INSERT 시에는 name, email을 넣으면 데이터베이스에서는 자동으로 ID를 할당해준다. 이 부분이 중요하다. INSERT 직후에 이 ID가 필요한 경우가 많기 때문이다.
- 이미 파라미터를 포인터로 받았기 때문에 별도로 User 포인터를 리턴할 필요는 없지만 리턴한 값은 호출의 결과값이 들어있다는 인상을 주기에 가독성 측면에서 유리하다.
- ID만 필요한 경우도 있어서 ID만 리턴하도록 구현하는 경우도 많다. 각각의 상황에 따라 구현하는 것도 좋지만 프로젝트 전반에 일관성 있게 함수 시그니처를 통일해주는 것도 좋겠다.
type userRepository struct {
db *sql.DB
}
func NewUserRepository(db *sql.DB) domain.UserRepository {
return &userRepository{
db: db,
}
}
func (r *userRepository) Save(user *domain.User) (*domain.User, error) {
const query = `
INSERT INTO users (name, email)
VALUES ($1, $2)
RETURNING id
`
if err := r.db.QueryRow(query, user.Name, user.Email).Scan(&user.ID); err != nil {
// PostgreSQL의 unique_violation 에러 코드 (23505)
if pqErr, ok := err.(*pq.Error); ok && pqErr.Code == "23505" {
return nil, fmt.Errorf("email %s: %w", user.Email, domain.ErrAlreadyExists)
}
return nil, fmt.Errorf("failed to save user: %w", err)
}
return user, nil
}
func (r *userRepository) GetByID(id int64) (*domain.User, error) {
query := `
SELECT id, name, email
FROM users
WHERE id = $1
`
var user domain.User
err := r.db.QueryRow(query, id).Scan(&user.ID, &user.Name, &user.Email)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, domain.ErrNotFound // 데이터가 없으면 nil 반환
}
return nil, fmt.Errorf("failed to find user by ID: %w", err)
}
return &user, nil
}
에러를 래핑하면 문맥(Context) 을 유지하면서도 에러의 원인을 추적하기 쉬워진다. 이는 디버깅 시 중요한 단서가 되며, errors.Is()로 본래의 에러 타입을 안전하게 확인할 수 있다.
- fmt.Errorf를 사용하여 래핑하는데 래핑하는 코드는 가장 뒤에 %w에 대입해줘야 한다.
- 이렇게 래핑한 코드는 errors.Is 함수로 확인이 가능하게 된다.
if err := r.db.QueryRow(query, user.Name, user.Email).Scan(&user.ID); err != nil {
// PostgreSQL의 unique_violation 에러 코드 (23505)
if pqErr, ok := err.(*pq.Error); ok && pqErr.Code == "23505" {
return nil, fmt.Errorf("email %s: %w", user.Email, domain.ErrAlreadyExists)
}
return nil, fmt.Errorf("failed to save user: %w", err)
}
internal/handler/userHandler.go
Go 백엔드 1: 클린 아키텍처 기본 에서 잘못 작성한 부분을 수정하였다. 에러를 래핑하였을 때에 확인하는 부분이다.
CreateUser를 보자.
- 바로 위에서 언급한 대로 errors.Is(err, domain.ErrAlreadyExists) 로 래핑이 된 에러를 확인할 수 있다.
- 순서주의: 확인하고자하는 err를 앞에 두고, 래핑된 에러중에 있는지 확인하려는 에러를 뒤 파라미터로 넣어야 한다.
func (h *UserHandler) CreateUser(c echo.Context) error {
user := new(domain.User)
if err := c.Bind(&user); err != nil {
return c.JSON(http.StatusBadRequest, ErrResponse(domain.ErrInvalidInput))
}
if user.Name == "" || user.Email == "" {
return c.JSON(http.StatusBadRequest, ErrResponse(domain.ErrInvalidInput))
}
_, err := h.userUseCase.CreateUser(user)
if err == nil {
return c.JSON(http.StatusCreated, user)
}
switch {
case errors.Is(err, domain.ErrInvalidInput):
return c.JSON(http.StatusBadRequest, ErrResponse(err))
case errors.Is(err, domain.ErrAlreadyExists):
return c.JSON(http.StatusConflict, ErrResponse(err))
default:
return c.JSON(http.StatusInternalServerError, ErrResponse(domain.ErrInternal))
}
}
cmd/gocore/main.go
- config.LoadConfig 함수로 설정을 가져와서 db.NewDBConnection 생성자에 넣어주면 환경에 맞는 DB 연결이 이루어진다. repository.NewUserRepository에 DB 연결을 주입하면, 해당 리포지토리는 데이터베이스와의 모든 상호작용을 책임지게 된다. 이를 다시 usecase와 handler로 단계적으로 의존성을 전달하는 구조다.
func main() {
// 전략
cfg, err := config.LoadConfig(*env)
if err != nil {
log.Fatalf("Config load error: %v", err)
}
fmt.Printf("config: %+v\\n", cfg)
dbConn, err := db.NewDBConnection(cfg)
if err != nil {
log.Fatalf("DB connection error: %v", err)
}
// 의존성 주입
userRepo := repository.NewUserRepository(dbConn)
userUseCase := usecase.NewUserUseCase(userRepo)
userHandler := handler.NewUserHandler(userUseCase)
// 라우팅
e := echo.New()
e.POST("/users", userHandler.CreateUser)
e.GET("/users/:id", userHandler.GetByID)
e.GET("/users", userHandler.GetAll)
// 서버 실행
log.Println("Server started at :8080")
if err := e.Start(fmt.Sprintf(":%d", cfg.App.Port)); err != nil {
log.Fatal("Shutting down the server due to:", err)
}
}
마무리
설정에 이어 데이터베이스 연결까지 작업을 하였다. 테스트 코드를 작성하지 않으니 사소한 버그를 놓치기 쉽다는 것을 절실히 느꼈다. 다음 글에서는 mockery를 활용한 테스트 코드 작성으로 이러한 문제를 개선해보겠다.
'golang' 카테고리의 다른 글
Go 백엔드 2: 설정 (0) | 2025.02.09 |
---|---|
Go 백엔드 1: 클린 아키텍처 기본 (0) | 2025.02.06 |
Go, XORM, Soft delete, Unscoped (0) | 2024.02.14 |
Golang: http.Client의 Timeout (0) | 2023.12.20 |
Go: 함수가 리턴해도 함수 속 고루틴은 종료되지 않는다 (0) | 2023.10.10 |
![잡학툰 뱃지](https://tistory1.daumcdn.net/tistory/2908812/skin/images/badge.png)
- Total
- Today
- Yesterday
- solid
- bun
- strange
- API
- ChatGPT
- 오블완
- Bug
- 2024년
- 독서후기
- 독서
- clean agile
- websocket
- 잡학툰
- go
- notion
- intellij
- 인텔리제이
- agile
- golang
- 클린 애자일
- 클린 아키텍처
- 엉클 밥
- 영화
- Gin
- postgres
- 티스토리챌린지
- OpenAI
- 2023
- 노션
- folklore
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | ||||||
2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 | 25 | 26 | 27 | 28 |