golang

Clean Go Code

탄생 2024. 4. 30. 00:08

❖ Clean Go Code

- 해당 링크의 go언어의 클린코드 내용을 요약한 것입니다.

- URL : https://github.com/Pungyeon/clean-go-article

❖ 왜 깨끗한 코드를 작성해야 할까?

  • 가독성 높은 코드는 개발자가 작업을 이해하는 데 필요한 시간을 절약해 줍니다.
  • 클린 코드 작성은 가독성뿐만 아니라 일관성과 재사용성을 강화해 유지보수에도 이점을 제공합니다.
  • 클린 코드를 생각하지 않고 편리하게 코드를 작성하게 되면 코드 리뷰라 테스트가 어려워질 수 있습니다.
  • 클린 코드를 작성하는 데 초기 비용이 들 수 있지만, 유지보수 비용을 감안하면 장기적으로 오히려 시간을 절약할 수 있습니다.

❖ 클린코드 소개

테스트 주도 개발

  • 짧은 주기로 테스트를 통과하는 코드를 작성하고 이를 반복해서 기능을 개발해 나가는 것입니다.
  • 단일 책임(Singlr Responsibility Principle)을 갖는 짧은 함수 단위로 작성하는 것이 좋습니다.
  • 예를 들어 40줄인 함수보다 4줄인 함수를 테스트하고 이해하는 것이 훨씬 쉽습니다.
  • 테스트 주도 개발의 주기
    1. 테스트를 작성한다.
    2. 테스트가 실패하면 통과하도록 만든다.
    3. 코드를 리팩토링 합니다.
    4. 이를 반복합니다.

네이밍 규칙

    ● Comments

    - 불필요한 주석은 달지 마자.

    - 코드가 잘 작성되었더라도 논리가 복잡할 경우, 이를 설명하는 주석이 유용합니다.

    - 반복문의 구조를 설명하는 주석은 초보자에게 유용할 수 있지만, 경험 많은 개발자들에게는 불필요할 수 있습니다.

// iterate over the range 0 to 9 
// and invoke the doSomething function
// for each iteration
for i := 0; i < 10; i++ {
  doSomething(i)
}

    - 코드 또한 깨끗한 코드로 간주하지 않습니다.
    - 반복문이 기술적으로 무엇을 하는지 설명하고, 함수가 수행하는 기능에 더 의미 있는 이름을 부여해야 합니다.

for workerID := 0; workerID < 10; workerID++ {
  instantiateThread(workerID)
}

    - 변수와 함수 이름을 변경하면 코드가 수행하는 작업을 자연스럽게 설명할 수 있습니다.

    -  주석을 코드에 직접 매핑할 필요 없이 수행중인 작업을 더 명확하게 이해할 수 있습니다

    - 주석 작성에 익숙해지고, 현재 수행 중인 작업을 설명하는 주석을 줄일수록 코드는 더욱 깔끔해집니다.

 

    ● 함수 네이밍

    - 함수가 수행하는 기능이 구체적일수록 함수 이름은 일반적이어야 합니다

    - 함수명은 높은 추상화 수준에서 간략하게 작성해야 합니다.

    - 예를 들어, Parse 함수를 만든다고 할 때, 그 최상위 수준의 이름은 다음과 같이 설정될 수 있습니다.

func Parse(filepath string) (Config, error) {
    switch fileExtension(filepath) {
    case "json":
        return parseJSON(filepath)
    case "yaml":
        return parseYAML(filepath)
    case "toml":
        return parseTOML(filepath)
    default:
        return Config{}, ErrUnknownFileExtension
    }
}

    - fileExtension은 좀 더 구체적입니다.

    - 최고 수준의 추상화에서 구체적인 이름을 작성한다면 읽기가 어렵습니다.
      DetermineFileExtensionAndParseConfigurationFile

func fileExtension(filepath string) string {
    segments := strings.Split(filepath, ".")
    return segments[len(segments)-1]
}

 

     변수 네이밍

    - 변수명의 경우 함수와 달리 구체적이어야 합니다.

    - 변수의 범위가 작아질수록 해당 변수가 무엇을 나타내는지 명확해지기 때문입니다.

    - 함수의 범위가 작을 경우 변수명을 더 작게 사용할 수 있습니다.

    - b라는 변수의 범위가 너무 작아서 무엇을 나타내는지 알 수 있습니다.

func PrintBrandsInList(brands []BeerBrand) {
    for _, b := range brands { 
        fmt.Println(b)
    }
}

    - 범위가 커진다면 구체적인 이름으로 선언하는 것이 좋습니다.

func BeerBrandListToBeerList(beerBrands []BeerBrand) []Beer {
    var beerList []Beer
    for _, brand := range beerBrands {
        for _, beer := range brand {
            beerList = append(beerList, beer)
        }
    }
    return beerList
}

    - 짧은 변수와 긴 변수를 섞어 사용하여 일관적이지도 않아 더 지저분한 코드가 됩니다.

func BeerBrandListToBeerList(b []BeerBrand) []Beer {
    var bl []Beer
    for _, beerBrand := range b {
        for _, beerBrandBeerName := range beerBrand {
            bl = append(bl, beerBrandBeerName)
        }
    }
    return bl
}

 

클린 함수

     함수 길이

    - 함수를 가능한 짧게 만드는 것이다.

    - 짧은 함수(go에서는 일반적으로 5~8줄)를 작성하여 자연스럽게 읽는 코드를 만들 수 있습니다.

var (
    NullItem = Item{}
    ErrInsufficientPrivileges = errors.New("user does not have sufficient privileges")
)

func GetItem(ctx context.Context, json []bytes) (Item, error) {
    order, err := NewItemFromJSON(json)
    if err != nil {
        return NullItem, err
    }
    if !GetUserFromContext(ctx).IsAdmin() {
	      return NullItem, ErrInsufficientPrivileges
    }
    return db.GetItem(order.ItemID)
}

    - 더 작은 함수를 사용하면 들여 쓰기 지옥도 제거됩니다.

    - 들여쓰기 지옥은 다른 개발자가 코드 흐름을 이해하기 어렵게 만듭니다.

    - if문이 확장되면 어떤 문이 무엇을 반환하는지 파악하기 어렵습니다.

func GetItem(extension string) (Item, error) {
    if refIface, ok := db.ReferenceCache.Get(extension); ok {
        if ref, ok := refIface.(string); ok {
            if itemIface, ok := db.ItemCache.Get(ref); ok {
                if item, ok := itemIface.(Item); ok {
                    if item.Active {
                        return Item, nil
                    } else {
                      return EmptyItem, errors.New("no active item found in cache")
                    }
                } else {
                  return EmptyItem, errors.New("could not cast cache interface to Item")
                }
            } else {
              return EmptyItem, errors.New("extension was not found in cache reference")
            }
        } else {
          return EmptyItem, errors.New("could not cast cache reference interface to Item")
        }
    }
    return EmptyItem, errors.New("reference not found in cache")
}

    - if문의 중첩대신 최대한 빨리 반환되는 구조로 작성을 합니다.

func GetItem(extension string) (Item, error) {
    refIface, ok := db.ReferenceCache.Get(extension)
    if !ok {
        return EmptyItem, errors.New("reference not found in cache")
    }

    ref, ok := refIface.(string)
    if !ok {
        // return cast error on reference 
    }

    itemIface, ok := db.ItemCache.Get(ref)
    if !ok {
        // return no item found in cache by reference
    }

    item, ok := itemIface.(Item)
    if !ok {
        // return cast error on item interface
    }

    if !item.Active {
        // return no item active
    }

    return Item, nil
}

    - 함수 추출을 통해 더 작은 함수로 분할할 수 있습니다.

    - 들여 쓰기 지옥은 테스트코드 작성하기도 어렵습니다.

    - 함수 추출을 하게 되면 테스트코드는 더 쉽게 작성이 가능합니다.

    - 전체적으로 더 많은 코드 라인이 발생하지만 코드 자체를 읽기는 훨씬 쉬워졌습니다.

    - 이렇게 코드를 작성하면 한 번에 3~5줄만 읽어도 되므로 낮은 수준의 기능을 더 쉽게 이해할 수 있습니다.

func GetItem(extension string) (Item, error) {
    ref, ok := getReference(extension)
    if !ok {
        return EmptyItem, ErrReferenceNotFound
    }
    return getItemByReference(ref)
}

func getReference(extension string) (string, bool) {
    refIface, ok := db.ReferenceCache.Get(extension)
    if !ok {
        return EmptyItem, false
    }
    return refIface.(string), true
}

func getItemByReference(reference string) (Item, error) {
    item, ok := getItemFromCache(reference)
    if !item.Active || !ok {
        return EmptyItem, ErrItemNotFound
    }
    return Item, nil
}

func getItemFromCache(reference string) (Item, bool) {
    if itemIface, ok := db.ItemCache.Get(ref); ok {
        return EmptyItem, false
    }
    return itemIface.(Item), true
}

 

     함수 파라미터

    - 함수의 파라미터는 하나 또는 두 개의 입력 매개변수만 포함되어야 합니다.

    - 어떤 예외적인 경우에는 세 개가 허용될 수 있지만 여기서부터는 리팩토링을 고려해야 합니다.

 

    - 이 함수는 6개의 입력 매개변수를 사용하는데, 이는 상당히 많은 수입니다.

    - 주석 덕분에 코드 기능을 이해할 수 있습니다. 그렇다면 매개변수를 추가할 때마다 주석을 달아줘야 합니다.

    - 주석이 없다면 네 번째, 다섯 번째 인수가 무엇을 나타내는지 확인하기 어렵습니다.

    - 많은 입력 매개변수로 인해 실수로 인자의 순서가 바뀌어 에러가 발생할 수도 있습니다.

q, err := ch.QueueDeclare(
  "hello", // name
  false,   // durable
  false,   // delete when unused
  false,   // exclusive
  false,   // no-wait
  nil,     // arguments
)

q, err := ch.QueueDeclare("hello", false, false, false, false, nil)

    - 이러한 입력 매개변수를 대신해 ‘옵션’(struct)으로 바꾸는 것이 좋습니다.

    - 속성의 순서도 더 이상 중요하지 않으므로 입력 값의 잘못된 순서는 더 이상 문제가 되지 않습니다.

type QueueOptions struct {
    Name string
    Durable bool
    DeleteOnExit bool
    Exclusive bool
    NoWait bool
    Arguments []interface{} 
}

q, err := ch.QueueDeclare(QueueOptions{
    Name: "hello",
    Durable: false,
    DeleteOnExit: false,
    Exclusive: false,
    NoWait: false,
    Arguments: nil,
})

 

     가변 범위

    - 변수가 전역적이고 변경 가능하다면 해당 값은 어느 부분에서나 변경될 수 있습니다.

    - 범위가 큰 비전역 변수가 어떻게 문제를 일으키는지 예를 들어 보겠습니다.

    - Golang scope issue(https://idiallo.com/blog/golang-scopes) 에서 가져온 코드이며 variable shadowing 문제가 발생합니다.

    - val, err := doComplex()에서 val은 새로운 변수를 선언하며 첫 번째 선언된 변수와 상관이 없습니다.

func doComplex() (string, error) {
    return "Success", nil
}

func main() {
    var val string
    num := 32

    switch num {
    case 16:
    // do nothing
    case 32:
        val, err := doComplex()
        if err != nil {
            panic(err)
        }
        if val == "" {
            // do something else
        }
    case 64:
        // do nothing
    }

    fmt.Println(val)
}

    - 이를 리팩토링 한다면 변수의 범위는 축소하여 큰 범위의 변수의 값을 이해하지 않도록 합니다.

    - val은 더 이상 수정되지 않으며 범위도 축소되었습니다.

func getStringResult(num int) (string, error) {
    switch num {
    case 16:
    // do nothing
    case 32:
       return doComplex()
    case 64:
        // do nothing
    }
    return "", nil
}

func main() {
    val, err := getStringResult(32)
    if err != nil {
        panic(err)
    }
    if val == "" {
        // do something else
    }
    fmt.Println(val)
}

 

     변수 선언

    - 변수 사용은 사용하는 곳에 최대한 가깝게 선언하여 가독성을 향상합니다.

    - C 프로그래밍에서는 변수 선언을 다음과 같이 정의하는 것을 흔히 볼 수 있습니다.

func main() {
  var err error
  var items []Item
  var sender, receiver chan Item
  
  items = store.GetItems()
  sender = make(chan Item)
  receiver = make(chan Item)
  
  for _, item := range items {
    ...
  }
}

    - 우리 뇌의 단기 기억도 용량이 제한되어 있습니다.

    - 어떤 변수가 어떻게 변경이 되어가는지 추적을 하다 보면 이해하기가 더 어려워집니다.

    - 따라서 변수를 가능한 사용하는 곳 가까운 곳에 선언하는 것이 좋습니다.

func main() {
	var sender chan Item
	sender = make(chan Item)

	go func() {
		for {
			select {
			case item := <-sender:
				// do something
			}
		}
	}()
}

    - 익명 함수를 호출하여 그 결과를 저장한다면 더 나은 결과를 얻을 수 있습니다.

func main() {
  sender := func() chan Item {
    channel := make(chan Item)
    go func() {
      for {
        select { ... }
      }
    }()
    return channel
  }
}

    - 익명 함수를 지정함수로 변경할 수도 있습니다.

func main() {
  sender := NewSenderChannel()
}

func NewSenderChannel() chan Item {
  channel := make(chan Item)
  go func() {
    for {
      select { ... }
    }
  }()
  return channel
}

    - 변수를 속성으로 사용하여 구조체를 만드는 것도 좋은 방법입니다.

    - 외부에서 sender 변수를 변경할 수 없습니다.

    - 사용자가 구현에 대해 걱정하지 않기 때문에, 우리는 패키지의 구현을 언제든지 변경할 수 있습니다.

type Sender struct {
  sender chan Item
}

func NewSender() *Sender {
  return &Sender{
    sender: NewSenderChannel(),
  }
}

func (s *Sender) Send(item Item) {
  s.sender <- item
}

func NewSenderChannel() chan Item {
  channel := make(chan Item)
  go func() {
    for {
      select { ... }
    }
  }()
  return channel
}
---
func main() {
  sender := NewSender()
  sender.Send(&Item{})
}