![[OSSCA] Terraform Provider 개발(2) - Go API 제작하기](https://img1.daumcdn.net/thumb/R750x0/?scode=mtistory2&fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdna%2F3LkAt%2FbtsIPJaUAuF%2FAAAAAAAAAAAAAAAAAAAAANGaQAV8QZBpcgi0HmHniD_zrAzzpFjDDJd4hRiLL5-6%2Fimg.webp%3Fcredential%3DyqXZFxpELC7KVnFOS48ylbz2pIh7yKj8%26expires%3D1761922799%26allow_ip%3D%26allow_referer%3D%26signature%3DqPQDd%252Bk0aDWA5j1cLZ3Kuip4KyQ%253D)
기존에 분석했던 Go API를 기반으로 Cafe CRUD를 Go lang으로 개발해보고자 한다. 기존의 흐름과 비슷하게 차근차근 개발을 해보려고 한다.
main.go
cafeHandler := handlers.NewCafe(db, logger)
r.Handle("/cafes", cafeHandler).Methods("GET")
r.Handle("/cafes/{id:[0-9]+}", cafeHandler).Methods("GET")
r.HandleFunc("/cafes", cafeHandler.CreateCafe).Methods("POST")
r.HandleFunc("/cafes/{id:[0-9]+}", cafeHandler.UpdateCafe).Methods("PUT")
r.HandleFunc("/cafes/{id:[0-9]+}", cafeHandler.DeleteCafe).Methods("DELETE")
main.go를 보면 GET 메서드는 Handle 함수를 사용하고, 이외의 메서드는 HandleFunc이라는 함수를 사용하고 있다. 두 함수의 차이는 무엇이길래 이렇게 나뉘어서 함수를 작성하게 되는걸까?
Handle 메서드
func (r *Router) Handle(path string, handler http.Handler) *Route {
return r.NewRoute().Path(path).Handler(handler)
}
- Handle(path string, handler http.Handler) *Route
- 이 메서드는 http.Handler 인터페이스를 인자로 받아 경로(path)와 핸들러(handler)를 등록한다.
- http.Handler는 ServeHTTP(ResponseWriter, *Request) 메서드를 구현한 타입을 의미한다.
- 예시: r.Handle("/path", someHandler)
HandleFunc 메서드
func (r *Router) HandleFunc(path string, f func(http.ResponseWriter,
*http.Request)) *Route {
return r.NewRoute().Path(path).HandlerFunc(f)
}
- HandleFunc(path string, f func(http.ResponseWriter, *http.Request)) *Route
- 이 메서드는 함수 타입의 핸들러를 인자로 받아 경로와 함수를 등록한다.
- 함수는 http.ResponseWriter와 *http.Request를 인자로 받는다.
- 예시: r.HandleFunc("/path", func(w http.ResponseWriter, r *http.Request) {...})
이 두 메서드의 차이는 간단히 말해, Handle은 http.Handler 타입을, HandleFunc은 함수 타입의 핸들러를 인자로 받는다는 점이다. 따라서, Handler를 보게 되면 GET 요청은 cafeHandler 전체가 요청을 처리할 수 있지만, 다른 요청의 경우 각 요청을 처리하는 별도의 함수를 두었기 때문에 HandleFunc라는 함수를 직접 등록하는 구조를 사용하게 된다.
Handlers/cafe.go
main에서 구현한대로 handler에 대해 구현해보고자 한다. 이는 카페와 관련된 API 엔드포인트를 처리하는 핸들러를 정의하는 것으로, 각 함수는 특정 HTTP 메서드에 대한 요청을 처리하도록 구현하고자 한다.
구조체 및 생성자
// Cafe - 카페 핸들러 구조체
type Cafe struct {
con data.Connection // 데이터베이스 연결 객체
log hclog.Logger // 로깅 객체
}
// NewCafe - Cafe 구조체의 생성자 함수
func NewCafe(con data.Connection, l hclog.Logger) *Cafe {
return &Cafe{con, l}
}
- Cafe 구조체는 데이터베이스 연결(con)과 로깅(log)을 속성으로 가진다.
- NewCafe 함수는 Cafe 구조체의 인스턴스를 생성하여 반환한다.
ServeHTTP 메서드
http.Handler 인터페이스는 ServeHTTP(http.ResponseWriter, *http.Request) 메서드를 가지고 있기 때문에 Cafe 구조체가 해당 메서드를 구현하므로, 인터페이스를 만족하기 위해 ServeHTTP를 사용한다. 따라서, 해당 ServeHTTP에 GET요청을 통해 처리할 수 있는 Read 함수를 구현해주자.
func (c *Cafe) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
c.log.Info("Handle Cafe")
vars := mux.Vars(r)
var cafeID *int
if vars["id"] != "" {
cId, err := strconv.Atoi(vars["id"])
if err != nil {
c.log.Error("Cafe ID could not be converted to an integer", "error", err)
http.Error(rw, "Invalid cafe ID", http.StatusInternalServerError)
return
}
cafeID = &cId
}
request(요청)로 들어온 것을 mux 함수를 통해 vars라는 변수에 담아준다. 여기서 mux는 URL 경로에서 추출된 변수들을 맵 형태로 변환시켜주는 역할을 하고 있다.
- URL 경로 변수 추출: mux.Vars(r)는 요청 r에서 URL 경로 변수들을 추출하여 맵 형태로 반환한다. 예를 들어, URL 경로가 /cafes/{id} 형태로 정의된 경우, 실제 요청이 /cafes/123로 들어오면 mux.Vars(r)는 {"id": "123"} 형태의 맵을 반환한다.
- 경로 변수 접근: 반환된 맵을 통해 URL 경로 변수에 접근할 수 있다. 예를 들어, vars["id"]를 통해 id 변수에 접근할 수 있다.
따라서, vars를 통해 id에 접근하고, cafeID에 id값을 할당할 수 있다.
cofs, err := c.con.GetCafes(cafeID)
if err != nil {
c.log.Error("Unable to get cafes from database", "error", err)
http.Error(rw, "Unable to list cafes", http.StatusInternalServerError)
return
}
var d []byte
d, err = json.Marshal(cofs)
if err != nil {
c.log.Error("Unable to convert cafes to JSON", "error", err)
http.Error(rw, "Unable to list cafes", http.StatusInternalServerError)
return
}
rw.Write(d)
}
이후 cafeID를 이용하여 connection의 GetCafes 함수를 호출해 결과를 cof에 담는다. 또한, cof에 담긴 내용을 json으로 변환시킨 뒤 response(응답)값에 작성하는 함수의 구조로 코드를 작성하였다.
CreateCafe 메서드
func (c *Cafe) CreateCafe(rw http.ResponseWriter, r *http.Request) {
c.log.Info("Handle Cafe | CreateCafe")
var cafes []model.Cafe
reqBody, _ := io.ReadAll(r.Body)
c.log.Info("Request Body", "body", string(reqBody))
r.Body = io.NopCloser(bytes.NewBuffer(reqBody)) // 요청 본문을 리셋한다.
err := json.NewDecoder(r.Body).Decode(&cafes)
if err != nil {
c.log.Error("Unable to decode JSON", "error", err)
http.Error(rw, "Unable to parse request body", http.StatusInternalServerError)
return
}
CreateCafe의 경우 요청으로 들어온 본문을 파싱하여 DB에 저장하도록 설계해야 한다. 따라서, Cafe 엔티티를 설계한 뒤 mode.cafe 배열에 cafes라는 변수로 담아주도록 한다.
io.ReadAll을 통해 body를 읽어오고, 해당 body값을 &cafes를 통해 json값들을 cafes에 작성해주도록 한다.
c.log.Info("Decoded Body", "body", cafes)
cafe := cafes[0]
createdCafe, err := c.con.CreateCafe(cafe)
if err != nil {
c.log.Error("Unable to create new cafe", "error", err)
http.Error(rw, fmt.Sprintf("Unable to create new cafe: %s", err.Error()), http.StatusInternalServerError)
return
}
이후 파싱한 cafes중 create의 경우 한개만 사용하므로 cafes의 첫번째 값을 사용하여 카페를 만들어주도록 한다
Trouble Shooting
급하게 구현을 하는 과정에서 객체 배열과 단일 객체간의 패키지 호환성 문제를 계속 겪다보니, 단일 객체를 사용함에도 불구하고 배열로 구현된 부분이 많았다. 해당 부분에 대해 리팩토링을 진행해야 할 것이다.
d, err := createdCafe.ToJSON()
if err != nil {
c.log.Error("Unable to convert cafe to JSON", "error", err)
http.Error(rw, "Unable to create new cafe", http.StatusInternalServerError)
return
}
rw.Write(d)
}
이후 Create되어 반환된 내용을 JSON으로 파싱하여 리턴값에 마찬가지로 작성해준다.
UpdateCafe 메서드
func (c *Cafe) UpdateCafe(rw http.ResponseWriter, r *http.Request) {
c.log.Info("Handle Cafe | CreateCafe")
vars := mux.Vars(r)
body := model.Cafe{}
err := json.NewDecoder(r.Body).Decode(&body)
if err != nil {
c.log.Error("Unable to decode JSON", "error", err)
http.Error(rw, "Unable to parse request body", http.StatusInternalServerError)
return
}
cafeID, err := strconv.Atoi(vars["id"])
if err != nil {
c.log.Error("cafeID provided could not be converted to an integer", "error", err)
http.Error(rw, "Unable to update cafe", http.StatusInternalServerError)
return
}
cafe, err := c.con.UpdateCafe(cafeID, body)
if err != nil {
c.log.Error("Unable to update cafe", "error", err)
http.Error(rw, fmt.Sprintf("Unable to update cafe: %s", err.Error()), http.StatusInternalServerError)
return
}
d, err := cafe.ToJSON()
if err != nil {
c.log.Error("Unable to convert cafe to JSON", "error", err)
http.Error(rw, "Unable to update cafe", http.StatusInternalServerError)
return
}
rw.Write(d)
}
Update 또한 이전에 설명한 내용들과 마찬가지로 id값을 파싱해서 읽어온 뒤, 수정해야 하는 body값을 전달해 cafe의 정보를 업데이트할 수 있도록 구현하였다.
DeleteCafe 메서드
func (c *Cafe) DeleteCafe(rw http.ResponseWriter, r *http.Request) {
c.log.Info("Handle Cafes | DeleteCafe")
vars := mux.Vars(r)
cafeID, err := strconv.Atoi(vars["id"])
if err != nil {
c.log.Error("cafeID provided could not be converted to an integer", "error", err)
http.Error(rw, "Unable to delete cafe", http.StatusInternalServerError)
return
}
err = c.con.DeleteCafe(cafeID)
if err != nil {
c.log.Error("Unable to delete cafe from database", "error", err)
http.Error(rw, "Unable to delete cafe", http.StatusInternalServerError)
return
}
fmt.Fprintf(rw, "%s", "Deleted cafe")
}
delete는 조회와 유사하게 id값을 받아온 뒤 해당 id값을 전달해 삭제하는 방식으로 코드를 구현하였다.
models/cafe.go
models 폴더에 cafe라는 엔티티 또한 만들어 주었다.
// Coffee defines a coffee in the database
type Cafe struct {
ID int `db:"id" json:"id"`
Name string `db:"name" json:"name"`
Address string `db:"address" json:"address"`
Description string `db:"description" json:"description"`
Image string `db:"image" json:"image"`
CreatedAt string `db:"created_at" json:"-"`
UpdatedAt string `db:"updated_at" json:"-"`
DeletedAt sql.NullString `db:"deleted_at" json:"-"`
}
위와 같이 cafe 엔티티 항목을 작성해주었다.
type Cafes []Cafe
// FromJSON serializes data from json
func (c *Cafes) FromJSON(data io.Reader) error {
de := json.NewDecoder(data)
return de.Decode(c)
}
// ToJSON converts the collection to json
func (c *Cafes) ToJSON() ([]byte, error) {
return json.Marshal(c)
}
func (c *Cafe) FromJSON(data io.Reader) error {
de := json.NewDecoder(data)
return de.Decode(c)
}
// ToJSON converts the collection to json
func (c *Cafe) ToJSON() ([]byte, error) {
return json.Marshal(c)
}
이후 Cafes와 Cafe(단일객체)에 대해 파싱하고, JSON으로 반환하는 함수를 다음과 같이 작성해 주었다. 해당 내용에 중복되는 코드가 많아, 하나로 통일하는 방향으로 리팩토링 해야한다.
Connection.go
type Connection interface {
GetCafes(*int) (model.Cafes, error)
CreateCafe(model.Cafe) (model.Cafe, error)
UpdateCafe(int, model.Cafe) (model.Cafe, error)
DeleteCafe(int) error
}
우선적으로 Connection 인터페이스에 구현해야 하는 함수들을 적어주고 시작한다. Connection 인터페이스를 사용함으로써 유연성과 확장성을 높일 수 있다. 실제로 이전 handler에서 c.con.GetCafes와 같이 접근할 수 있었던 것이 인터페이스를 사용했기 때문에 가능하다.
PostgreSQL type
type PostgresSQL struct {
db *sqlx.DB
}
해당 connection에서 사용되는 함수들은 db에 접근해서 데이터를 저장하거나 수정하는 일을 해야한다. 이때 db에 대한 type을 만들어준뒤, 해당 타입을 활용하며 개발해야 한다.
GetCafes
func (c *PostgresSQL) GetCafes(cafeid *int) (model.Cafes, error) {
cos := model.Cafes{}
if cafeid != nil {
err := c.db.Select(&cos, "SELECT * FROM cafes WHERE id = $1", cafeid)
if err != nil {
return nil, err
}
} else {
err := c.db.Select(&cos, "SELECT * FROM cafes")
if err != nil {
return nil, err
}
}
return cos, nil
}
우선적으로 id를 받고, id가 없으면 전체 cafes를 반환하고, id가 있다면 단일 객체를 반환하는 식으로 코드를 작성하였다. 해당 부분에서 단일 객체도 Cafes로 반환하지 않아 JSON을 파싱하는 과정에서 다양한 오류를 마주하였다. 해당 부분에 유의하며 개발하자...
CreateCafe
func (c *PostgresSQL) CreateCafe(cafe model.Cafe) (model.Cafe, error) {
// 트랜잭션 시작
tx := c.db.MustBegin()
var cafeID int
// 카페 정보를 데이터베이스에 삽입
_, err := tx.NamedExec(
`INSERT INTO cafes (name, address, description, image, created_at, updated_at)
VALUES (:name, :address, :description, :image, now(), now())`, cafe)
if err != nil {
tx.Rollback() // 오류 발생 시 트랜잭션 롤백
return model.Cafe{}, err
}
// 마지막 삽입된 카페 ID 가져오기
err = tx.QueryRowx("SELECT LASTVAL()").Scan(&cafeID)
if err != nil {
tx.Rollback() // 오류 발생 시 트랜잭션 롤백
return model.Cafe{}, err
}
// 새로운 카페 정보 가져오기
var newCafe model.Cafe
err = tx.Get(&newCafe, "SELECT id, name, address, description, image FROM cafes WHERE id=$1", cafeID)
if err != nil {
tx.Rollback() // 오류 발생 시 트랜잭션 롤백
return model.Cafe{}, err
}
// 트랜잭션 커밋
err = tx.Commit()
if err != nil {
return model.Cafe{}, err
트랜잭션은 데이터베이스의 논리적인 작업 단위로, 여러 개의 데이터베이스 연산을 하나의 작업 단위로 묶어준다. 트랜잭션은 다음과 같은 ACID 특성을 가진다.
Atomicity (원자성): 트랜잭션 내의 모든 작업이 성공적으로 완료되거나, 실패하면 모든 작업이 취소된다.
Consistency (일관성): 트랜잭션이 완료되면 데이터베이스는 일관된 상태를 유지한다.
Isolation (격리성): 하나의 트랜잭션이 완료될 때까지 다른 트랜잭션은 해당 트랜잭션의 중간 상태를 볼 수 없다.
Durability (내구성): 트랜잭션이 성공적으로 완료되면, 그 결과는 영구적으로 반영된다.
해당 트랜잭션을 이용하여 cafes 정보를 데이터베이스에 삽입하고, 마지막 삽입된 카페 아이디를 반환할 수 없다면 삽입에 실패한 것이므로 실패를 반환한다. 이를 바탕으로 새로운 카페 정보를 가져온 뒤 트랜잭션을 커밋하며 Create 메서드 코드를 작성하였다.
UpdateCafe
func (c *PostgresSQL) UpdateCafe(cafeID int, cafe model.Cafe) (model.Cafe, error) {
m := model.Cafe{}
_, err := c.db.NamedExec(
`UPDATE cafes
SET name = :name, address = :address, description = :description, image = :image, updated_at = now()
WHERE id = :id;`, map[string]interface{}{
"id": cafeID,
"name": cafe.Name,
"address": cafe.Address,
"description": cafe.Description,
"image": cafe.Image,
})
if err != nil {
return m, err
}
m.ID = cafeID
m.Name = cafe.Name
m.Address = cafe.Address
m.Description = cafe.Description
m.Image = cafe.Image
return m, nil
}
이전까지의 메서드와 유사하게 model.Cafe를 받아 해당 내용을 기반으로 업데이트를 진행한다. 또한 업데이트된 내용을 기반으로 새로 객체를 만들어 반환하도록 개발하였다.
DeleteCafe
func (c *PostgresSQL) DeleteCafe(cafeID int) error {
tx := c.db.MustBegin()
// remove existing items from order
_, err := tx.NamedExec(
`DELETE FROM cafes WHERE id = :cafe_id `, map[string]interface{}{
"cafe_id": cafeID,
})
if err != nil {
tx.Rollback()
return err
}
err = tx.Commit()
if err != nil {
return err
}
return nil
}
앞서 Read와 유사하게, int로 된 ID를 받은 뒤 delete를 진행하였다. 해당 과정에서 에러가 발생하면 Rollback을 진행하도록 하였고, 마찬가지로 커밋을 한 뒤 nil을 반환하였다.
해당 구조와 같이 Go API 개발을 처음으로 진행해 보았다. 낯선 언어로 새롭게 개발해 보는 부분이라 고쳐야 할 부분이 많은 듯 하다. 우선은 Terraform으로 이렇게 개발한 API를 연결하는 과정까지 나아가 보고자 한다.
'Infra > Terraform' 카테고리의 다른 글
[OSSCA] Terraform Provider 개발(4) - Custom Provider 만들기 (0) | 2024.07.31 |
---|---|
[OSSCA] Terraform Provider 개발(3) - Go API 패키지 만들기 (0) | 2024.07.30 |
[OSSCA] Terraform Provider 개발(1) - Go API 분석하기 (0) | 2024.07.29 |
[OSSCA] Terraform Provider SDK 와 Framework 버전 차이를 알아보자 (1) | 2024.07.24 |
[OSSCA] Terraform Provider 살펴보기 (2) | 2024.07.23 |
보안 전공 개발자지만 대학로에서 살고 싶어요
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!