프로그래밍(Web)/Golang

[바미] Go - SQL query(CRUD)

Bami 2020. 12. 17. 17:04
728x90
반응형

model/model.go

  package model

  import "time"

  type Todo struct {
    ID        int       `json:"id"`
    Name      string    `json:"name"`
    Completed bool      `json:"completed"`
    CreatedAt time.Time `json:"created_at"`
  }

  type DbHandler interface {
    GetTodos() []*Todo
    AddTodo(name string) *Todo
    RemoveTodo(id int) bool
    CompleteTodo(id int, complete bool) bool // 1
    close() 
  }

 // var handler DbHandler

  func NewDBHandler() DBHandler { // 2
    //handler = newMemoryHandler()
    handler = newSqliteHandler()
  }
/*
  func GetTodos() []*Todo {
    return handler.getTodos()
  }

  func AddTodo(name string) *Todo {
    return handler.addTodo(name)
  }

  func RemoveTodo(id int) bool {
    return handler.removeTodo(id)
  }

  func CompleteTodo(id int, complete bool) bool {
    return handler.completeTodo(id, complete)
  }
*/

이제 GetTodos(), AddTodo(), RemoveTodo(), CompleteTodo() 들만 채워주면 되는데 문제가 하나 생긴게, DB를 열어주면 close를 해주어야 해서 close를 추가했는데,

이 인스턴스에 대한 생명주기에 대해 관리해주어야 합니다.

그런데 이 패키지안에서 알 수 없기 때문에 이 close를 호출해주는 책임을 이 패키지를 사용하는 쪽에 넘겨주어야 합니다.

 

1 : 그래서 dbHandler 라는 interfaceclose함수를 추가시켜주고, interface와 메소드 모두 public하게 바꾸어 줍니다.

2 : 그리고 init()NewDBHandler()로 만들어서 DBHandler라는 인터페이스 자체를 바깥쪽으로 내보냅니다.

그래서 이 함수를 호출한 쪽에서 DBHandler의 인스턴스들을 가지고 사용하다가 필요가 없어질 때 close()를 할 수 있게 웹으로 책임을 넘겨줍니다.

 

그 후 인터페이스가 바뀌었기 때문에 model/sqliteHandler.go도 수정해 줍니다.

package model

import (
    "database/sql"
    "os"

    _ "github.com/mattn/go-sqlite3"
)

type sqliteHandler struct {
    db *sql.DB
}

func (s *sqliteHandler) GetTodos() []*Todo {
    return nil
}

func (s *sqliteHandler) AddTodo(name string) *Todo {
    return nil
}

func (s *sqliteHandler) RemoveTodo(id int) bool {
    return false
}

func (s *sqliteHandler) CompleteTodo(id int, complete bool) bool {
    return false
}

func (s *sqliteHandler) close() {
    s.db.Close()
}

func newSqliteHandler() DBHandler {
    os.Remove("./test.db")
    database, err := sql.Open("sqlite3", "./test.db")
    if err != nil {
        panic(err)
    }
    statement, _ := database.Prepare(
        `CREATE TABLE IF NOT EXISTS todos (
            id        INTEGER  PRIMARY KEY AUTOINCREMENT,
            name      TEXT,
            completed BOOLEAN,
            createdAt DATETIME
        )`)
    statement.Exec()
    return &sqliteHandler{db: database}
}

그 후 model/memoryHandler.go 쪽도 바꾸어 줍니다.

package model

import "time"

type memoryHandler struct {
    todoMap map[int]*Todo
}

func (m *memoryHandler) GetTodos() []*Todo {
    list := []*Todo{}
    for _, v := range m.todoMap {
        list = append(list, v)
    }
    return list
}

func (m *memoryHandler) AddTodo(name string) *Todo {
    id := len(m.todoMap) + 1
    todo := &Todo{id, name, false, time.Now()}
    m.todoMap[id] = todo
    return todo
}

func (m *memoryHandler) RemoveTodo(id int) bool {
    if _, ok := m.todoMap[id]; ok {
        delete(m.todoMap, id)
        return true
    }
    return false
}

func (m *memoryHandler) CompleteTodo(id int, complete bool) bool {
    if todo, ok := m.todoMap[id]; ok {
        todo.Completed = complete
        return true
    }
    return false
}

func(m *memoryHandler) close() { // 1

}

func newMemoryHandler() DBHandler {
    m := &memoryHandler{}
    m.todoMap = make(map[int]*Todo)
    return m
}

1 : 인터페이스에 맞춰주기 위해 close()함수를 새로 만들어 줍니다.

 

그리고 app/app.go도 수정을 해줍니다.

package app

import (
    "net/http"
    "strconv"

    "github.com/gorilla/mux"
    "github.com/unrolled/render"
)

var rd *render.Render

type AppHandler struct { // 1
    http.Handler // 1
    db model.DBHandler // 2
}

func indexHandler(w http.ResponseWriter, r *http.Request) {
    http.Redirect(w, r, "/todo.html", http.StatusTemporaryRedirect)
}

func getTodoListHandler(w http.ResponseWriter, r *http.Request) {
    list := model.GetTodos()
    rd.JSON(w, http.StatusOK, list)
}

func addTodoHandler(w http.ResponseWriter, r *http.Request) {
    name := r.FormValue("name")
    todo := model.AddTodo(name)
    rd.JSON(w, http.StatusCreated, todo)
}

type Success struct {
    Success bool `json:"success"`
}

func removeTodoHandler(w http.ResponseWriter, r *http.Request) {
    vars := mux.Vars(r)
    id, _ := strconv.Atoi(vars["id"])
    ok := model.RemoveTodo(id)
    if ok {
        rd.JSON(w, http.StatusOK, Success{true})
    } else {
        rd.JSON(w, http.StatusOK, Success{false})
    }
}

func completeTodoHandler(w http.ResponseWriter, r *http.Request) {
    vars := mux.Vars(r)
    id, _ := strconv.Atoi(vars["id"])
    complete := r.FormValue("complete") == "true"
    ok := model.CompleteTodo(id, complete)
    if ok {
        rd.JSON(w, http.StatusOK, Success{true})
    } else {
        rd.JSON(w, http.StatusOK, Success{false})
    }
}

func MakeHandler() http.Handler {
    //todoMap = make(map[int]*Todo)

    rd = render.New()
    r := mux.NewRouter()

    r.HandleFunc("/todos", getTodoListHandler).Methods("GET")
    r.HandleFunc("/todos", addTodoHandler).Methods("POST")
    r.HandleFunc("/todos/{id:[0-9]+}", removeTodoHandler).Methods("DELETE")
    r.HandleFunc("/complete-todo/{id:[0-9]+}", completeTodoHandler).Methods("GET")
    r.HandleFunc("/", indexHandler)

    return r
}

1 : 이 쪽도 마찬가지로 main.go에서 사용하기 때문에 이것도 하나의 패키지인데, 이제는 Model.goDBHandler를 만들어서 사용할 것이기 때문에, 이 것의 대한 close책임을 이 패키지를 사용한 쪽에 넘겨야 합니다.

그런데 app.go도 하나의 패키지이고, 이것을 main.go에서 사용하기 때문에 app.go에는 close책임이 없기 때문에 수정해 주어야 합니다.

 

1-1 : http.Handler포함 타입 이라고 하는데

  handler http.Handler

맴버 변수를 암시적으로 생략해서 이 인터페이스를 포함하고 있다. 라는 의미입니다.

상속하고 비슷한 개념으로 볼 수 있지만 상속관계가 아닙니다.
has-a관계이지 is-a관계가 아닙니다.

is-a관계 has-a관계에 대해

1-2 : model의 DB핸들러를 추가해 줍니다.

그 후 아래의 함수들을 AppHandler의 메소드로 바꾸어 줍니다.

func (a *AppHandler) indexHandler(w http.ResponseWriter, r *http.Request) {
    http.Redirect(w, r, "/todo.html", http.StatusTemporaryRedirect)
}

func (a *AppHandler) getTodoListHandler(w http.ResponseWriter, r *http.Request) {
    list := a.db.GetTodos() // 1
    rd.JSON(w, http.StatusOK, list)
}

func (a *AppHandler) addTodoHandler(w http.ResponseWriter, r *http.Request) {
    name := r.FormValue("name")
    todo := a.db.AddTodo(name)
    rd.JSON(w, http.StatusCreated, todo)
}

type Success struct {
    Success bool `json:"success"`
}

func (a *AppHandler) removeTodoHandler(w http.ResponseWriter, r *http.Request) {
    vars := mux.Vars(r)
    id, _ := strconv.Atoi(vars["id"])
    ok := a.db.RemoveTodo(id)
    if ok {
        rd.JSON(w, http.StatusOK, Success{true})
    } else {
        rd.JSON(w, http.StatusOK, Success{false})
    }
}

func (a *AppHandler) completeTodoHandler(w http.ResponseWriter, r *http.Request) {
    vars := mux.Vars(r)
    id, _ := strconv.Atoi(vars["id"])
    complete := r.FormValue("complete") == "true"
    ok := a.db.CompleteTodo(id, complete)
    if ok {
        rd.JSON(w, http.StatusOK, Success{true})
    } else {
        rd.JSON(w, http.StatusOK, Success{false})
    }
}

1 : AppHandler의 db 변수를 사용 할 수 있도록 바꾸어 줍니다.

 

그 후 AppHandler를 initialize해주어야 하는데, MakeHandler()할 때 반환값을 AppHandler의 포인터가 반환되도록 바꾸어 줍니다.

var rd *render.Render = render.New() // 1

func MakeHandler(filepath string) *AppHandler {
    r := mux.NewRouter()
    a := &AppHandler{
        Handler: r, // 1
        db:      model.NewDBHandler(filepath),
    }

    r.HandleFunc("/todos", a.getTodoListHandler).Methods("GET")
    r.HandleFunc("/todos", a.addTodoHandler).Methods("POST")
    r.HandleFunc("/todos/{id:[0-9]+}", a.removeTodoHandler).Methods("DELETE")
    r.HandleFunc("/complete-todo/{id:[0-9]+}", a.completeTodoHandler).Methods("GET")
    r.HandleFunc("/", a.indexHandler)

    return a
}

그 후 muxRouter를 지워주고, AppHandler를 만들어 줍니다.

1 : render.New() 부분은 전역이기 때문에 저 위치에 initialize시켜 줍니다.

2 : http 핸들러 부분은 muxRouter가 되고, dbHandler는 model의 NewDBHandler를 호출해서 그 결과를 db변수에 집어 넣습니다. 이렇게 하면 AppHandler라는 인스턴스가 만들어지게 되는데 muxRouter에 등록을 할 때 일반 함수가 아닌 메소드로 변경되었기 때문에 메소드 형태로 바꾸어 줍니다.

그리고 반환을 AppHandler를 반환하기 때문에 a값을 넣어 줍니다.

이렇게 한 이유는 DB핸들러가 프로그램이 종료되기 전에 Close()를 불러주어야 하는데 이걸 model패키지 입장에서는 자기가 만든 인스턴스에 대해서 얼만큼 사용될 지를 알 수 없기 때문입니다.

그러니까 자기 패키지안에선 Close()를 불러줄 수 가 없습니다.

결국에는 이 패키지를 사용한 쪽에서 불러주어야 하는데 그 패키지를 app/app.go에서 사용하는데

이 app 또한 전적으로 DBHandler를 사용권한을 가지고 있지 않습니다. 이것 또한 바깥에서 사용되는 패키지이기 때문 입니다.

 

그래서 새로운 인스턴스를 만들어서 바깥에서 AppHandler의 function을 호출할 수 있게 코드를 추가해 줍니다.

 

app/app.go

....

 func (a *AppHandler) Close() { // 1
    a.db.Close()
}

...

1 : 이렇게해서 app패키지를 사용하는 쪽에서 Close()를 호출 할 수 있도록 해줍니다.

 

그리고 main.go에서 appHandler를 만들었기 때문에 프로그램이 종료되기 전에 defer로 m의 Close를 호출 할 수 있게 됩니다.

main.go

func main() {
   m := app.MakeHandler("./test.db")
   defer m.Close()
   n := negroni.Classic()
   n.UseHandler(m)

   log.Println("Started App")
   err := http.ListenAndServe(":3000", n)
   if err != nil {
      panic(err)
   }
}

그 후 app에서 build가 잘 되는지 test를 해보면 만들어 놓은것이 없으므로 Fail이 났음을 확인할 수 있습니다.

그리고 test.db가 생성 된 것을 확인 할 수 있습니다.

저 test.db가 생성된 이유는 model/sqliteHandler.go에서

func newSqliteHandler() dbHandler {
  database, err := sql.Open("sqlite3", "./test.db")
    ...
}

"./test.db"를 여는데 저 파일이 없기 때문에 생성된 것입니다.

 

이제 본격적으로 기능들을 추가해봅시다!

먼저 getTodos()addTodo()를 수정해보죠.

model/sqliteHandler.go

package model

func (s *sqliteHandler) getTodos() []*Todo { // 1
  todos := []*Todo{} // 1
  rows, err := s.db.Query("SELECT id, name, completed, createdAt FROM todos") // 2
  if err != nil { // 3
    panic(err)
  }
  defer rows.Close() // 5
  for rows.Next() { // 4
     var todo Todo
     rows.Scan(&todo.ID, &todo.Name, &todo.Completed, &todo.CreatedAt)
     todos = append(todos, &todo)
  }
  return todos
}

1-1 : data를 저장할 쿼리문을 만들어야 하는데, data를 읽어서 그 data를 반환시켜주어야 하기 때문에 data를 반환시켜주는 list를 만들어 줍니다.
1-2 : 쿼리문 작성.
Query()를 보면 query문을 먼저 작성하고, 전달인자(argument)를 넣으면 성공시에 sql.Rows, 실패시에 error가 나오게 됩니다.
1-3 : 에러가 있을 시 에러 처리.
1-4 : 데이터가 잘 뽑아져 나왔을 시 각 행(row)을 돌면서 데이터를 가져와야 하기 때문에 for문으로 반복문을 돌려주는데, for.Next()는 다음행으로 가겠다는 의미이며, 다음 행이 없을 때 return false가 되어 return true가 될 때 까지 돌면서 레코드들을 읽어 줍니다.

rows.Scan()을 쓰게 되면 쿼리안에 있던 값들이 각각에 맞춰서 가져오게 되는데, 이 data를 담을 todo객체를 만들어서 Scan()안에 받아올 각 항목들을 넣어주면 됩니다.

그 후 todo에 들어간 값들을 todos에 저장해준 뒤, todos를 return 해줍니다.

1-5 : 그 다음 row를 close시켜 줍니다.

func (s *sqliteHandler) addTodo(name string) *Todo { // 2
   stmt, err := s.db.Prepare("INSERT INTO todos (name, completed, createdAt) VALUES (?, ?, datetime('now'))") // 1

  if err != nil { // 2
      panic(err)
  }

  rst, err := stmt.Exec(name, false) // 3

  if err != nil { // 4
    panic(err)
  }

  id, _ := rst.LastInsertId() // 5
  var todo Todo // 6
  todo.ID = int(id) // 7
  todo.Name = name // 8
  todo.Completed = false // 9
  todo.CreatedAt = time.Now() // 10

  return &todo // 1
}

2-1 : 여기서도 쿼리문을 작성해주어야 하는데 s.db.Prepare()로 Statement를 만들어 줍니다.
Prepare의 반환값으로 Statement값과 error값이 나옵니다.
2-2 : error 처리.
2-3 : Statement값이 나왔기 때문에 execute해주어야 하는데 Exec()의 대응되는 전달인자(argument)

아까 Insert 쿼리에 '?'로 썼던 부분입니다. 그래서 name에 name값, complete값에 false값을 넣어줍니다.

Exec()가 반환되는 값은 result와 error가 나옵니다.

2-4 : 이 과정에서 error가 있을 수 있기 때문에 에러 처리를 해줍니다.
2-5 : 그 후 result값이 나왔고, AddTodo를 한 다음에 추가한 Todo의 정보를 알려주어야 합니다.

name, completed, createdAt값은 알 수 있는데 insert 시 id값을 넣지 않았으므로 id값은 알 수가 없습니다.

(table을 만들었을 때 AUTO로 설정했기 때문입니다.)

그래서 자동으로 발급된 그 id값을 알아야하기 때문에 result의 메소드로 LastInsertId()가 있는데 마지막으로 추가된 레코드의 id값을 알려 줍니다.

이 것을 id변수에 넣어주고,

2-6 : return값이 todo이기 때문에 todo를 만들어 줍니다.
2-7 : 그래서 todo의 id는 새로만든 id가 될 것이고, 이 타입이 int64여서 int로 바꾸어 줍니다.
2-8 : name값은 요청한 name값이 되고,
2-9 : completed값은 false로 넣어주고,
2-10 : CreateAt은 현재시간으로 넣어 줍니다.
2-11 : 그 후 포인터 값을 반환시켜 줍니다.

 

이렇게 하면 add와 get이 끝이 납니다!

 

그 후 저장후에 테스트를 진행해 봅시다!

에러 문을 보면 65번째 줄과 79번째 줄이 통과가 안되었는데, 이 부분은 Completetododelete한 부분입니다.

그러므로 방금 수정했던 Add와 Get은 통과가 되었다는 의미인데, 여기서 한 번 더 테스트를 해보면

아까는 통과되었던 부분도 통과가 되지 않은 것을 확인 할 수 있습니다.

그 이유는 레코드가 2개가 와야하는데 4개가 왔기 때문입니다. 아까 생성된 test.db파일이 한번 만들어지면 지워지지 않는데

 

첫 번째 테스트 시 이미 테이블이 만들어진 상태에서 테스트 코드에서 add를 2번하여 레코드 2개가 추가되었고, GetTodos를 진행하여 2개의 레코드를 조회했고, 두 번째 테스트 시 추가 했었던 레코드 2개가 이미 추가 된 상태에서 2개를 또 추가하는 것이기 때문에

4개가 된 것입니다.

 

그래서 이것을 테스트를 돌리기 전에 db파일을 삭제하도록 수정해야 합니다.

app/app_test.go

 func TestTodos(t *testing.T) {
   os.Remove("./test.db")

   ah := MakeHandler()
   defer ah.Close()
   ts := httptest.NewServer(ah)
    ...
 }

os.Remove()를 추가 해주고, ts부분에서 AppHandler가 나와서 Close()를 시켜주어야 하기 때문에 다음과 같이 수정해 줍니다.
AppHandler http.Handler를 임베디드하고 있기 때문에 NewServer()의 인자로 바로 써 줄 수 있습니다.

ah 또한 function이 끝나기 전에 DB를 닫아주어야 하기 때문에 Close()시켜 줍니다.

 

이제 테스팅을 해보면

Get, Add를 제외한 부분만 에러가 나는 것을 확인할 수 있습니다.

 

이제 CompleteTodo를 수정해봅시다!

CompleteTodo는 완료상태를 변경하는 것인데, 기존 레코드는 그대로 두고, 기존 레코드의 complete값만 변경해주는 부분입니다.

func (s *sqliteHandler) CompleteTodo(id int, complete bool) bool {
   stmt, err := s.db.Prepare("UPDATE todos SET completed=? WHERE id=?") // 1

   if err != nil { // 2
     panic(err)
   }

   rst, err := stmt.Exec(complete, id) // 3

   if err != nil { // 4
     panic(err)
   }

  cnt, _ := rst.RowsAffected() // 5
  return cnt > 0 // 6
}

1 : Prepare()UPDATE문을 넣어줍니다. 마찬가지로 statement가 나오고, error가 나옵니다.
2 : 에러 처리.
3 : 첫번째 전달인자(argument)값이 Compelte, 두 번째 전달인자(argument)값이 id값으로 넣어 줍니다.
4 : 에러 처리.
5 : 위의 쿼리문을 영향받은 레코드 갯수가 몇개인지 알려줍니다. UPDATE 시 1이 될 것이고, UPDATE가 되지 않으면 0이 될 것입니다.
6 : 그래서 UPDATE된 항목이 있을 시 True가 될 것이고, 그렇지 않으면 False값이 될 것 입니다.

 

그 다음 RemoveTodo()를 수정해봅시다!

func (s *sqliteHandler) RemoveTodo(id int) bool {
  stmt, err := s.db.Prepare("DELETE FROM todos WHERE id=?") // 1

  if err != nil { // 2
    panic(err)
  }

  rst, err := stmt.Exec(id) // 3

  if err != nil { // 4
    panic(err)
  }

  cnt, _ := rst.RowsAffected() // 5
  return cnt > 0 // 6
}

1 : remove도 마찬가지로 Statement를 만들어 줍니다.
2 : 에러 처리.
3 : 첫번째 전달인자(argument)값을 id값으로 넣어 줍니다.
4 : 에러 처리.
5 : 위의 쿼리문을 영향받은 레코드 갯수가 몇개인지 알려 줍니다.
6 : 그래서 DELETE된 항목이 있을 시 True가 될 것이고, 그렇지 않으면 False값이 될 것 입니다.

 

그 후 테스트를 진행해보자!

4개 모두 통과가 되었습니다!

코딩이 완료 되었고, 서버로 실행하여 동작을 하는지 확인해 봅시다!

memoryDB가 아닌 FileDB로 변경해주었기 때문에 위와 같이 임의적으로 data를 추가 시킨 뒤 서버를 재시작 해주면 마지막 상태가 저장 되어 있음을 확인 할 수 있습니다.

프로그램이 종료되어도, test.db에 저장되어 있습니다.

 

그 후에 한가지 수정할 사항이 있는데, model/sqliteHandler.go에서

func newSqliteHandler() dbHandler {
  database, err := sql.Open("sqlite3", "./test.db")
    ...
  }

이렇게 configuration에 관련된 항목들을 코드안에 있는 박아 놓는 형태가 좋지 않아 최대한 바깥 쪽으로 빼주는게 좋습니다.

func newSqliteHandler(filepath string) DBHandler {
   database, err := sql.Open("sqlite3", filepath)
     if err != nil {
        panic(err)
     }

   statement, _ := database.Prepare(
    `CREATE TABLE IF NOT EXISTS todos (
       id        INTEGER  PRIMARY KEY AUTOINCREMENT,
       name      TEXT,
       completed BOOLEAN,
       createdAt DATETIME
    )`)

   statement.Exec()
   return &sqliteHandler{db: database}
}

이런식으로 filepath를 인자로 받아서 sql.Open()에 넣어 줍니다.

그래서 기존에 있던 "./test.db"는 model/model.goNewDBHandler()안에 넣어 줍니다.

func NewDBHandler() DBHandler {
  //handler = newMemoryHandler()
  return newSqliteHandler("./test.db")
}

그래서 이렇게 바뀌어야 하는데 여기에 넣어 놓으나, sqliteHandler.go에 넣어 놓으나 사실상 똑같기 때문에 filepath의 인자를 받아서 여기에도 filepath를 넘겨 줍니다.

func NewDBHandler(filepath string) DBHandler {
  //handler = newMemoryHandler()
  return newSqliteHandler(filepath)
}

또 filepath부분이 불리는 부분인 app/app.goMakeHandler()부분도 마찬가지로 수정해 줍니다.

func MakeHandler(filepath string) *AppHandler {
  r := mux.NewRouter()
  a := &AppHandler{
        Handler: r,
    db:      mo
    del.NewDBHandler(filepath),
  }

  r.HandleFunc("/todos", a.getTodoListHandler).Methods("GET")
  r.HandleFunc("/todos", a.addTodoHandler).Methods("POST")
  r.HandleFunc("/todos/{id:[0-9]+}", a.removeTodoHandler).Methods("DELETE")
  r.HandleFunc("/complete-todo/{id:[0-9]+}", a.completeTodoHandler).Methods("GET")
  r.HandleFunc("/", a.indexHandler)

  return a
}

그렇게 사용하는 부분들을 거슬러 올라가다보면 main.go까지 사용하는데 main에서 "./test.db"를 추가 시켜 줍니다.

func main() {
  m := app.MakeHandler("./test.db")
  defer m.Close()
  n := negroni.Classic()
  n.UseHandler(m)

  log.Println("Started App")
  err := http.ListenAndServe(":3000", n)

  if err != nil {
    panic(err)
  }
}

여기서 파일을 바꾸어 줄 수 있습니다. 그렇다면 여기에서도 마찬가지로

 

코드에 박힌 것이 아니야?

라고 생각할 수 있습니다.

그래서 여기서는 flag같은 실행인자를 가져오는 패키지가 있어서 사용할 수 있는데 지금은 그것을 사용하지 않고 그냥 넣어 줍니다.

main에 넣는 것은 금방 바꿀 수 있어서 좋고, 패키지 안쪽에 있는 것 보다 최대한 바깥쪽에 넣어주는 것이 좋습니다.

 

이렇게 해서 기존 todos를 바꾸어 주는 부분들이 끝이 났습니다.

 

풀소스 : github.com/ckdqja135/Typescript-restful-starter/tree/master/mdfile/Go/Go%20-%20Web%20Source/SQL%20query(CRUD)%20%ED%92%80%EC%86%8C%EC%8A%A4

 

728x90
반응형