[좌충우돌 개발기] API 함수 이름은 어떻게 짓는게 좋을까?
개발자들은 API를 작성할 때, 함수 이름 짓기에 관하여 얼마나 공을 들일까요? 구글링을 하다 발견한 브런치 페이지에서는 절반정도의 개발자가 이름 짓기를 힘겨워 한다고 합니다.
좋은 API 함수 이름 짓기에 대한 명확한 기준도 없고.., 주어진 일정 기간내에 코드작성을 완료 해야 하는데 좋은 이름은 떠오르질 않고 시간만 흐르고 진도는 안나가고… 개발자라면 이런 문제에 한번쯤 부딪혀 봤을 것 같습니다. 필자에게도 최근 API 함수 이름 짓기에 대해서 생각을 글로 정리해봐야 겠다고 느끼게된 사건이 있었는데요. 이번 글에서는 최근 사례를 바탕으로 API 함수 이름 짓기에 관한 필자의 고민의 과정과 관점을 얘기해 볼까 합니다.
먼저 API 함수 이름 짓기에는 어떤 어떤 패턴들이 있을까요? 제가 실제로 목격했던 패턴은 크게 아래와 같습니다.
- 고민않고 넘버링 한다. 😂 (함수1, 함수2, 함수3…….)
- CRUD 함수이름을 활용하는 방법
- 사용자 행위를 이름으로 사용한다. (ReceiveProduct, ShipProduct, CancelOrder 등등)
그 중 빈도로 따지면 2 > 3 > 1 의 순인 것 같습니다. 1이야 안티 패턴이라고 누구나 이해할 수 있을 것 같아서 따로 더 이야기를 하진 않겠습니다. 하고픈 이야기는 2, 3번의 규칙을 따르는 API 함수 이름 짓기인데 최근 사례를 바탕으로 이야기를 풀어가볼까 합니다.
수불 시스템
- 근례 필자가 진행했던 시스템으로써 본문을 풀어갈 때 사례를 제시하기 위해 활용할 시스템입니다.
- 이 글에서 다루는 수불 시스템의 역할은 비즈니스상 상품 저장의 단위로 정의된 매장간에 상품의 이동(출고, 입고)을 관리하는 시스템 입니다.
요구사항
1. 사용자는 상품을 출고 할 수 있다.
2. 또한 사용자는 상품을 입고 할 수 있다.
3. 출고지의 출고상품 수량(음수)과 입고지의 입고상품 수량(양수)을 더하면 항상 0 이어야 한다. (실제 비즈니스 현장에서는 오차처리에 대한 요구사항도 있지만 복잡하므로 이번 글에서는 다루지 않겠습니다.)
이야기를 풀어가기 위해 위와 같은 요구사항이 있다고 가정하겠습니다.
상태설계
여러가지 구현법이 있을 수 있겠지만 아래와 같이 상태를 부여해서 관리하는 방법을 선택했습니다.
- 출고: S, 입고: R,
위와 같이 단순한 상태 설계를 바탕으로 아래와 같은 비즈니스 행위를 생각해 보았습니다.
- 출고: 매장에서 상품 출고(S) 즉, 수불 데이터가 생성
- 입고: 매장에서 상품 출고(S) -> 매장에서 상품 입고(R) 상태로 변하는 흐름
위 상태 변화의 흐름을 보면 첫 단계에 출고(S)가 존재하고 마지막 단계에는 입고(R)가 존재함을 알 수 있습니다. 한번 출고된 데이터는 반대편에서 입고가 되어야 요구사항 3번에서 표현된 제약조건을 만족할 수 있으므로 이러한 내용을 개념적으로 데이터의 완결성이 존재한다고 이해했습니다. 마치 데이터베이스에서 중요한 update나 delete 작업을 수행할 때 start transaction 후 결과에 따라 commit이나 rollback을 하는 것처럼, 한번 출고가 된 데이터는 항상 대응되는 입고가 있어야지 비지니스 의미적인 완결이 된다고 보는 것이죠.
CRUD 함수 이름을 활용해 API 함수 이름을 지을 때 구현 예시
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 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77
type Status string const ( Shipment = Status("S") Receipt = Status("R") ) type CreateDto struct { WaybillNo string `json:waybillNo` Items ItemDto `json:items` } type UpdateDto struct { WaybillNo string `json:waybillNo` Status string `json:type` Items ItemDto `json:items` } type ItemDto struct { SkuId int64 `json:skuId` Qty int64 `json:qty` } type DeliveryController struct {} func (d DeliveryController) Init(g *echo.Group) { d.POST("/", d.Create) d.PATCH("/status", d.UpdateStatus) } func (DeliveryController) Create(c echo.Context) error { var createDto CreateDto if err := c.Bind(&createDto); err != nil { return ReturnError(c, http.StatusBadRequest, api.Error{ Message: err.Error(), }) } delivery, err := createDto.translateToModel(); if err != nil { return ReturnError(c, http.StatusBadRequest, api.Error{ Message: err.Error(), } } created, err := delivery.Create(c.Request().Context()) if err != nil { return ReturnError(c, http.StatusInternalServerError, api.Error{ Message: err.Error(), }) } } } func (DeliveryController) UpdateStatus(c echo.Context) error { var updateDto UpdateDto // dto => delivery 생략, Create 참조 updated, err := delivery.UpdateStatus(c.Request().Context()) if err != nil { return ReturnError(c, http.StatusInternalServerError, api.Error{ Message: err.Error(), }) } } return ReturnSuccess(c, http.StatusCreated, updated) } type Delivery struct { Id int64 `json:id` WaybillNo string `json:waybillNo` Status status `json:status` Committed `json:committed` } func (d *Delivery) getByWaybillNo(ctx context.Context) error { // CRUD 코드 생략 } func (d *Delivery) Create(ctx) error { if (d.Status != Shipment) { return errors.new("The create status value is invalid.") } // CRUD 코드 생략 } func (d *Delivery) UpdateStatus(ctx context.Context) error { before, _ := d.getByWaybillNo(ctx) if (d.Status == Shipment) { return errors.New("Shipment status not allow update.") } // CRUD 코드 생략 }
요구사항에 대한 코드를 위와 같이 구현했습니다. Dto를 입력 받아서 Model로 변환한 후, 상태를 점검하여 데이터베이스에 저장 또는 업데이트 하고 있습니다. 여기까지 살펴보고 아래와 같은 비즈니스 요구사항이 추가로 들어왔다고 가정해 보겠습니다.
- 출고를 할 때, 출고지와 입고지 사이에 거리가 멀면 먼저 승인을 거쳐야 한다.
추가 요구사항을 바탕으로 상태를 추가 해보았습니다.
- 기존 => 출고: S, 입고: R
- 추가한 상태 => 승인대기: W, 심사통과: P, 심사거절: X
승인이라는 비즈니스 의미가 추가 됨으로써 표현되어야 하는 상태는 경우의 수를 고려해 보았을 때, 위와 같이 3가지 상태 값이 추가 되었습니다.
- 출고: 매장에서 상품 출고(S) 수불 데이터가 생성
- 입고: 출고(S) -> 입고(R) 상태로 변하는 흐름
- 심사 신청: 출고(S) -> 승인대기(W) 순서로 상태 변화
- 심사 거절: 승인대기(W) -> 심사거절(X) 순서로 상태 변화
- 심사 통과: 승인대기(W) -> 심사통과(P) -> 입고(R) 순서로 상태 변화
또한 추가된 상태 값에 따라 위와 같이 3가지 새로운 비즈니스 행위도 생겨났습니다. 이렇게 되었을 때 구현부에서는 어떤 변화가 있을까요?
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 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65
type Status string const ( Shipment = Status("S") Receipt = Status("R") WaitApproval = Status("W") RejectApproval = Status("X") PassApproval = Status("P") ) // Dto 구현부 동일하므로 생략 type DeliveryController struct {} func (d DeliveryController) Init(g *echo.Group) { d.POST("/", d.Create) d.PATCH("/status", d.UpdateStatus) } func (DeliveryController) Create(c echo.Context) error { // 첫번째 구현부와 동일하므로 생략 } func (DeliveryController) UpdateStatus(c echo.Context) error { var updateDto UpdateDto // dto => delivery 모델로 변환하는 로직 동일하므로 생략 updated, err := delivery.UpdateStatus(c.Request().Context()) if err != nil { return ReturnError(c, http.StatusInternalServerError, api.Error{ Message: err.Error(), }) } } return ReturnSuccess(c, http.StatusCreated, updated) } type Delivery struct { // 첫번째 구현부와 동일하므로 생략 } func (d *Delivery) getByWaybillNo(ctx context.Context) error { // CRUD 코드 생략 } func (d *Delivery) Create(ctx) error { // 첫번째 구현부와 동일하므로 생략 } func (d *Delivery) UpdateStatus(ctx context.Context) error { before, _ := d.getByWaybillNo(ctx) if (d.Status == Shipment) { return errors.New("Shipment status not allow update.") } if (d.Status == Wait) { if (before.Status != Shipment) { return errors.New("The update status value is invalid. before status is must be Shipment") } } if (d.Status == RejectApproval) { if (before.Status != WaitApproval) { return errors.New("The update status value is invalid. before status is must be WaitAppproval") } } if (d.Status == PassApproval) { if (before.Status != WaitApproval) { return errors.New("The update status value is invalid. before status is must be WaitApproval") } } if (d.Status == Receipt) { if (before.Status != Shipment || before.Status != Pass) { return errors.New("The update status value is invalid. before status is must be Shipment or Pass") } } // CRUD 코드 생략 }
추가 요구사항으로 인한 구현부의 가장 큰 변화는 UpdateStatus 함수의 Status 파라미터 유효성 검사를 위해 추가 된 중첩 if 문들 입니다. 원인은 UpdateStatus 함수의 이름은 데이터베이스의 상태값을 변경하는 행위를 나타내고 있습니다. 하지만 API를 통해 구현해야 하는 사용자 행위는 출고, 입고, 심사대기, 심사거절, 심사통과 와 같습니다. 이런 경우에 입장차에 따른 행위표현에 대한 불일치가 발생합니다. 출고, 입고, 심사대기, 심사거절, 심사통과 와 같은 행위들은 데이터베이스 입장에서 해석해보면 Status 값을 변경하는 행위는 동일하고, 파라미터(값) 이 다릅니다. 하지만 사용자 입장에서 해석해보면 비즈니스 적으로 각각 구분되는 의미를 갖는 별개의 행위 입니다. Controller에 바인딩된 함수는 사용자 - 어플리케이션 간의 소통을 표현하는 구간인데 데이터베이스의 입장이 노출됨으로써 행위의 불일치가 발생된 것입니다.
위와 같은 구현에서 필자가 생각하는 문제점은 크게 아래와 같은 2가지 입니다.
- 중첩 if문
승인 이라는 비지니스가 하나 추가 되었을 뿐인데도 유효성 검사를 위해 if문이 많이 증가했습니다. 이 부분은 누적될 수록 어플리케이션을 변경에 취약하게 만듭니다.
- 구성원간의 소통의 어려움
만약 소통을 "상태값이 W가 되기 전에는 항상 S 상태여야 하는데 ...." 와 같이 한다면, 개발자 사이에서도 밀접하게 붙어서 일하지 않는 이상 모두 기억하기 어려운 암호 같은 상태코드와 그 흐름을 업무 전문가나 새로운 개발자 혹은 다른팀과 소통하려면 매우 어려웠던 기억이 있습니다.
그러면 다음으로같은 요구사항을 가지고 아래와 같이 리팩토링을 진행해보겠습니다.
사용자 행위를 API 함수 이름 짓기에 사용할 때 구현 예시
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 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56
type Status string const ( Shipment = Status("S") Receipt = Status("R") Wait = Status("W") RejectApproval = Status("X") PassApproval = Status("P") ) type UpdateDto struct { WaybillNo string `json:waybillNo` Items ItemDto `json:items` } // 나머지 Dto 구현부 동일하므로 생략 type DeliveryController struct {} func (d DeliveryController) Init(g *echo.Group) { d.POST("/ship", d.Ship) d.PATCH("/receive", d.Receive) d.PATCH("/wait-approval", d.WaitApproval) d.PATCH("/reject-approval", d.RejectApproval) d.PATCH("/pass-approval", d.PassApproval) } func (DeliveryController) Ship(c echo.Context) error { // dto => delivery 모델로 변환하는 로직 동일하므로 생략 updated, err := delivery.Ship(c.Request().Context()) if err != nil { return ReturnError(c, http.StatusInternalServerError, api.Error{ Message: err.Error(), }) } } return ReturnSuccess(c, http.StatusCreated, updated) } func (DeliveryController) Receive(c echo.Context) { // dto => delivery 모델로 변환하는 로직 동일하므로 생략 updated, err := delivery.Receive(c.Request().Context()) // error return 로직 동일하므로 생략 return ReturnSuccess(c, http.StatusCreated, updated) } func (DeliveryController) WaitApproval(c echo.Context) { ... updated, err := delivery.WaitApproval(c.Request().Context()) ... return ReturnSuccess(c, http.StatusCreated, updated) } func (DeliveryController) RejectApproval(c echo.Context) { ... updated, err := delivery.RejectApproval(c.Request().Context()) ... return ReturnSuccess(c, http.StatusCreated, updated) } func (DeliveryController) PassApproval(c echo.Context) { ... updated, err := delivery.PassApproval(c.Request().Context()) ... return ReturnSuccess(c, http.StatusCreated, updated) }
혹시 어떤 차이점이 있는지 눈치 채셨나요? 가장 큰 차이는 API 계층의 표현이 풍부해지면서 모델 함수를 호출 할 때 Status 파라미터가 사라지고 모델 내부로 감춰졌습니다. 이 부분이 복잡한 if 로직을 피할 수 있게 해줍니다. 또한, 구성원 간에 비즈니스 행위를 기반으로 소통할 기반을 마련해 주기 때문에 훨씬 수월하게 소통할 수 있도록 도와 줍니다.
여기까지의 내용을 정리해보면 아래와 같습니다.
CRUD 함수 이름을 활용해 API 함수 이름 짓기할 때
- UpdateStatus 라는 이름은 데이터베이스에 저장된 상태값을 변경하는 행위를 나타내는데 사용자 행위를 나타내기엔 표현법이 부족하다.
- 표현법이 부족함으로 인해 행위를 결정짓기 위해 구체적인 상태값을 파라미터로 전달해줘야 한다.
- 전달된 파라미터의 유효성을 검사하기 위한 중첩된 if문이 발생할 여지가 생긴다.
- 변경을 어렵게 하는 요소가 된다
- 외부에서 전달받은 파라미터로 행위가 결정되기 때문에 소통시 S, R 등등과 같은 개발코드에 기반한 소통을 할 여지가 생긴다.
- 구성원간의 소통을 어렵게 하는 요소가 된다.
사용자 행위를 API 함수 이름 짓기에 사용할 때
- 개발자와 업무전문가가 함께 이해할 수 있는 비즈니스 용어로 소통할 수 있다.
- 외부에 노출되는 행위만 유지한 다면 모델 내부의 함수로직을 변경하는데 제약이 없다.
- 새로운 비지니스 요구사항이 추가 되었을 때 변경에 용이하다.
마무리하며
예전에 커뮤니티 활동을 하며 SOLID 에 대한 개념과 DDD 에 대한 이론들을 학습했던 기억이 있습니다. 당시에는 너무 추상적이고 모호해서 와닿지 않는 부분이 많았습니다. 간혹, 이해가 되는 부분이 있어도 "그래서 어떻게 구현해야하지?" 하곤 했죠. 글을 작성하며 생각을 정리하다 보니 사용자 행위를 API 함수 이름 짓기에 사용하는 것은 DDD 에서 강조하는 유비쿼터스 언어 와 SOLID 의 단일 책임의 원칙에 부합한다는 생각도 들게 합니다. 사용자 행위를 API 함수 이름 짓기에 사용하는 방법의 장점을 설명하기 위해 필자가 근례 경험했던 프로젝트를 간소화한 사례를 들어 설명했지만 구현방법에 이게 정답이야 하는 방법은 없을 것입니다. 제 사례를 소개함으로써 비슷한 고민을 한 개발자가 있다면 도움이 되었으면 좋겠고 혹은 필자가 모르는 방식의 API 함수 이름 짓기 또는 본문에 잘못된 점을 피드백 받을 수 있다면 더더욱 기쁠것 같습니다. 긴 글 읽어주셔서 감사합니다.