[네이버클라우드 개발자 스토리] 좋은 코드란 무엇일까?🤔 #클린코드 이야기

NAVER CLOUD PLATFORM
NAVER CLOUD PLATFORM
21 min readSep 24, 2021

📍 “좋은 코드를 짜야 한다”

누구나 인정하는 진리죠. 하지만 “무엇이 좋은 코드인가?”에 대해선 늘 의견이 분분합니다.​

좋은 코드를 이야기하기 위해서는 먼저 직관적으로 느끼는 코드의 ‘나쁜 냄새’​를 명확한 언어로 표현할 줄 알아야 합니다.​

명확한 언어로 좋은 코드에 대해 합의하고, 이를 기반으로 리뷰하면 좋은 코드를 유지할 수 있습니다.

👀 Intro

지난 8월, 프로젝트 설계가 끝난 후 개발 시작 전 내부적으로 ‘클린코드 세미나’를 진행했습니다.

‘클린코드’는 개발을 시작하는 주니어에게 많이 추천되는 코딩의 교과서라고 할 수 있습니다.​

프로젝트 참여자들은 제각기 개발 경험이 많은 실무자들이었는데요, 왜 기초 중의 기초라 불리는 클린코드 세미나를 진행했을까요? 정답은 더욱 훌륭한 커뮤니케이션을 위해서입니다.​

이번 포스팅에서는 클린코드 세미나 내용을 공유하고, 그 의의를 살펴보고자 합니다.​

* 예시는 Golang 기반으로, 해당 언어에 특정한 내용이 다수 포함되어 있습니다.

Common Knowledge for Communication

개발 중 우리는 “이것은 좋은 코드인가?”라는 질문을 하게 됩니다. 어느 정도 경험이 있는 개발자들은 직관적으로 안 좋은 코드를 인지할 수 있습니다. 이러한 직관을 클린코드에서는 ‘코드에서 나오는 나쁜 냄새를 맡는다’고 표현합니다.​

직관은 설명하기 어렵습니다. 냄새를 말로 설명하기 어려운 것과 마찬가지죠.​

클린코드는 이러한 직관에 이름을 붙입니다. 직관에 이름을 붙이고, 우선순위를 정하고, 이에 대해 합의하면 하나의 기준이 됩니다. 이렇게 만들어진 기준은 향후 코드에 관한 커뮤니케이션의 기반이 됩니다.​

“어떤 코드가 좋은 코드인가?”에 대한 기준을 공유한다면 코딩 룰의 정립, 리뷰를 포함한 커뮤니케이션이 조금은 편해집니다.

클린코드 / Clean Code by Robert C. Martin

Clean Code 클린 코드 / 로버트 C. 마틴 저 — 링크

Robert C. Martin은 Agile과 TDD의 선구자, SOLID 원칙의 창시자입니다. 업계의 원로로 불리는 만큼, 그의 대표작인 클린코드(2008) 또한 꽤 오래된 책입니다.​

클린코드에서는 테스트, 리팩토링, 동시성 처리 등 개발 전반에서 ‘잘 동작하는 코드’와 ‘유지 보수가 용이한 코드’까지 논의합니다.​

본 포스팅에서는 클린코드의 범위를 가독성을 추구하여 직관적으로 이해할 가능성이 높은 코드로 두고, 가독성 영역을 중심으로 이야기해 보겠습니다.

▶ 왜 가독성인가?

클린코드는 코드를 처음 보는 사람도 동작을 직관적으로 파악할 수 있도록 하는 것을 목표로 합니다. 여기서 코드를 처음 보는 사람은 우리 팀원들, 유지 보수를 할 후임자들, 오픈소스나 API 사용자, 그리고 3달 뒤의 자기 자신​이 있습니다.

We don’t read code, we decode it.

Peter Seibel

우리는 코드를 읽는 것이 아니라, 해석합니다. 가독성 확보는 코드 해석에 드는 비용을 줄이는 작업입니다.​

심지어 우리는 코드를 몇 번이나 반복해서 읽습니다. 클린코드에서는 코드를 짜는 것과 읽는 것의 비중이 1:10 정도라고 이야기합니다. 조금 과장하자면 가독성 확보는 전체 코딩 업무의 90%에 대한 효율화 작업이라고 할 수 있겠습니다.

🔎 잠깐, 가독성이란?

코드의 가독성이란 ‘코드가 잘 읽히고, 해당 코드의 동작을 직관적으로 예측할 수 있는지’를 뜻합니다. 본 포스팅에서는 가독성을 아래 두 개념으로 나누어서 이야기하고자 합니다.​

✅ 표현적 가독성 (Legibility)

: 눈에 잘 들어오는 코드, 읽기 편한 코드​

✅ 기능적 가독성 (Readability)

: 변수, 함수, 클래스 등이 어떤 역할을 갖고, 어떤 동작을 하며, 서로 어떤 관계를 맺는지 직관적으로 파악할 수 있는 코드​

정확한 용어를 쓰자면 가독성, 판독성, 이독성 등으로 나눠서 이야기해야겠지만, 일반적으로 가독성이라는 용어가 자리 잡았다고 생각하여, 가독성으로 통일해서 적겠습니다.

🔎표현적 가독성(Legibility)

표현적 가독성이라고 이름 붙인 Legibility*는 “코드의 개별 요소를 파악하기가 얼마나 용이한가”를 의미합니다. ‘읽기 쉬운가?’라고 생각하시면 됩니다.​

* Legibility : What influences the ease of identifying elements of a program (출처)

-

언어별 코딩 룰

언어마다 정해진 네이밍이나 디자인 방식이 있습니다. Golang도 마찬가지입니다.​

물론 취향 차이와 그에 따른 논란은 늘 있기 마련입니다. 이에 대해 clean-go-article의 저자 Lasse Martin Jakobsen는 다음과 같이 이야기합니다.

I prefer snake case over camel case, and I quite like my constant variables to be uppercase.

gofmt의 정책 중 마음에 들지 않는 것이 있더라도 취향의 자유보다 통일성 있는 코드가 더 중요하다고 생각하고, gofmt의 규약을 따를 것을 커뮤니티에 요청합니다.

-

IDE 포맷터 세팅 및 형식 맞추기

IDE에서 읽기 편한 환경을 구성하기 위한 형식 맞추기 작업입니다. 대부분의 IDE는 포맷터를 설정할 수 있도록 지원하는데, 이 설정을 팀원들 간에 통일하면 서로 다른 포맷팅으로 인한 의미 없는 코드 변경을 줄일 수 있습니다.​

방법은 다음과 같습니다.​

✔ 최대 가로 길이를 화면 절반쯤에 맞춥니다.

✔ 파일당 행은 500줄 미만으로 끊어줍니다.

✔ 하나의 파일은 두괄식으로 작성합니다.

✔ 변수는 사용하는 곳과 가까운 곳에 선언합니다. 즉, 함수가 시작되는 부분에 몰아서 선언하지 않습니다.

-

함수

“The first rule of functions is that they should be small.

The second rule of functions is that they should be smaller than that”.

함수를 만드는 첫 번째 원칙, 작게. 두 번째 원칙은 더 작게!

아마 클린코드에서 가장 많이 인용되는 문장이 아닌가 싶습니다.​

클린코드에서는 20줄 미만의 함수를 권장합니다. 때로는 이 기준에 집착해서 필요 이상으로 함수를 쪼개기도 하는데요, 이는 다소 잘못된 적용입니다.​

20줄이라는 기준은 함수를 물리적으로 제한하여 자연스럽게 책임을 제한하려는 의도입니다. 즉 함수의 길이는 현상이고, 본질은 함수의 책임입니다.​

* ‘함수는 한 가지 책임을 갖는다’ — 이를 단일 책임 원칙(SRP, Single Responsibility Principle)이라 부릅니다.​

물론 짧은 함수 자체의 형식적인 가치도 있습니다. 전체 함수가 한 화면에 들어오지 않으면 화면을 위아래로 오가면서 코드를 읽어야 합니다. 저는 50줄 정도를 코드 길이의 마지노선으로 잡아두었습니다.​

그렇다면 어떻게 짧은 함수를 만들 수 있을까요? 간단한 원칙은 함수를 코드의 나열이 아닌 n-1 단계의 추상화 수준을 갖는 함수의 집합으로 만드는 것입니다. 이에 대해서는 아래 ‘함수의 내려가기 규칙’ 파트에서 추가적으로 다루겠습니다.​

깊이 또한 가독성에 미치는 영향이 큽니다. for / if / switch 등의 들여 쓰기는 가능한 1회만 하고, 들여쓰기 내부가 길어진다면 함수 호출로 대체합니다.​

* 들여 쓰기가 3회 이상인데 내부 코드가 길어진다면 정말 읽기 힘들어집니다!

🔻e.g. DB 접근 샘플

함수 자체는 약 40줄로, 이 정도는 딱 한 화면에 들어오는 사이즈입니다. 50줄이 넘어가면 함수의 전체를 보기 위해서 스크롤링을 해야 합니다. 가능한 함수 전체가 한 화면을 넘어가지 않도록 합니다. (좌우든 상하든!)​

326 라인부터 for과 switch가 중복해서 쓰이는데, 이 상황에서는 2번 들여 쓰기는 허용하되 case 안의 코드는 하위 함수로 떼어내는 편이 가독성이 더 좋겠습니다.​

파일의 전체 라인은 650줄로, 앞서 말한 500줄 기준을 넘습니다. DB 연결과 데이터 접근을 다른 파일로 분리하면 각 500줄 미만으로 관리할 수 있겠습니다.​

이처럼 ‘500줄이 넘으면 나눌 수 있을까?’ 를 고민하는 것이 합의된 기준이 갖는 의의입니다.​

-

주석

잘못된 코드는 빠르게 수정되지만, 잘못된 주석은 잘 수정되지 않습니다.

주석이 잘못되었다고 프로그램이 죽지는 않으니까요. 주석은 코드에 비해서 중요도와 영향력이 현저히 낮습니다. 우선순위가 낮은 작업은 밀리다가 잊히기 십상입니다.​

그래서 클린코드에서는 주석은 필요악이라고 이야기합니다. 관리가 잘 안되는 만큼 정말 필요한 부분에만 최소한으로 사용하라는 의미입니다.​

따라서 TODO / 외적인 맥락 / 제한사항과 같이 코드로 설명할 수 없는 부문만 주석으로 설명하고, 가능하면 코드 자체가 스스로를 설명할 수 있게끔(Self-Descriptive) 작성합니다.

🔻e.g. clean-go-article : Self-Descriptive naming을 통한 주석의 제거

// tutorial comment
// iterate over the range 0 to 9
// and invoke the doSomething function
// for each iteration
for i := 0; i < 10; i++ {
doSomething(i)
}
// document why, not how : 여전히 안 좋은 코드
// instantiate 10 threads to handle upcoming work load
for i := 0; i < 10; i++ {
doSomething(i)
}
// self-demonstrative names : Good!
for workerID := 0; workerID < 10; workerID++ {
instantiateThread(workerID)
}
// self-demonstrative names : Better!
for workerID := 0; workerID < maxWorkerThreads; workerID++ {
instantiateBotQueueConsumeThread(workerID)
}

단, 모든 부작용에도 불구하고, 이해할 수 없는 코드보단 장황한 주석이 달린 코드가 낫습니다.​

​Golang에서는 함수나 타입의 최상단에 주석을 작성하고, godoc을 통해 문서를 생성할 수 있습니다. 문서를 보기 위해 새 창을 띄울 필요가 없어 꽤나 편리하고, Golang 커뮤니티에서도 적극 권장하는 방법입니다.

​또한 특정 위치에 주석을 몰아서 쓰는 것이, 주석을 파편화하여 코드 이곳저곳에 흩뿌려진 것보다 훨씬 관리하기 좋습니다.

🔎 기능적 가독성 (Readability)

일반적으로 Readability는 가독성으로 번역되지만, 여기서는 ‘기능적 가독성’이라고 이름 붙이겠습니다. Readability는 ​코드가 이해하기 쉬운지 어려운지를 나타냅니다. 흐름에 따라 읽으면서 자연스럽게 이해할 수 있는가?로 생각하시면 됩니다.

  • Readability : What makes a program easier or harder to read and apprehend by developers (출처)

-

함수의 내려가기 규칙

함수의 (추상화 단계의) 내려가기 규칙(Stepdown rule)은 기능적 가독성을 구성하는 가장 중요한 요소입니다. 내려가기 규칙은 아래 두 가지 원칙으로 구성됩니다.​

✔ 함수는 한 가지 추상화 단계를 처리합니다.

✔ n 단계의 추상적인 함수는 n-1 단계의 추상적인 함수로 구성됩니다.​​

추상화 단계는 어려운 말이지만, 문제 혹은 과업(Task) 정도로 가볍게 풀이할 수도 있습니다.​

‘식사를 한다’는 것이 가장 추상적인 과업이라면, ‘점심에 팀원들과 제육쌈밥’을 먹는 건 그보다 한 단계 구체적인 과업의 집합입니다.​

같은 예시를 활용하자면, 내려가기 규칙은 함수를 작성할 때 점심 식사를 한다와 같은 식으로 추상화 단계가 섞이도록 작성하지 말 것을 강조합니다. 바로 아래에 저녁 식사를 한다 함수를 만들고 싶지 않다면 말입니다.​

내려가기 규칙은 낯설 수도 있지만, 기능 명세를 구성하다 보면 자연히 추상화 단계를 나타낼 수 있습니다.

// 식사의 기능 명세
식사를 합시다!
- 누가 먹나요?
- 어떤 메뉴를 먹지?
- 누가 먹는지, 끼니가 언제인지가 중요해
- 어디서 먹지?
- 어떤 메뉴인지, 몇명인지가 중요해
- 그럼 진짜로 식사를 합시다!
// 간단한 수도 코드
func (n ncloud) HaveMeal() err {
mealMembers := n.getMealMembers()menu := n.selectMenu(mealMembers, time.Now())restaurant := n.selectRestoraunt(menu, len(mealMembers))return n.haveMeal(mealMembers, restaurant)
}

-

의미 있는 이름

개발자가 가장 시간을 오래 쓰는 일이 네이밍이라는 농담이 있고, 때로는 농담이 아니기도 합니다.

개발을 하면서 깨달은 한 가지 사실은, 짧으면서 좋은 이름을 짓는 건 ‘문학적 재능의 영역’이라는 것입니다.​

좋은 이름을 짓기 위해서는 모든 것에 이름을 붙인다고 생각하고, 짧고 불명확한 이름보다는 길고 명확한 이름이 낫다는 걸 명심합니다.​

✔ 항상 이름 있는 상수(Named Constant)를 사용하고, 매직 넘버, 매직 스트링은 사용하지 않습니다.

✔ -Info, -Data, tmp- 와 같은 무의미한 접미사, 접두사는 제거합니다. (tmpInfoData?)

✔ 대명사, 축약, 생략은 알아보기 힘듭니다.

* disp보다는 display가 더 명확하고, Decode 하기 쉽습니다. 뇌의 Clock을 덜 사용한다고 할까요.

✔ 서로 무관한 함수에서 같은 이름을 사용해서는 안 됩니다(구분 가능성).

마찬가지로 서로 연관된 함수에서 같은 대상을 다른 이름으로 불러도 안 됩니다(일관성).

✔ 타입 인코딩(-string, -int 같이 타입을 적어두는 것)은 지양합니다.

* 단, 형 변환(Type Casting)을 하는 경우 이름 짓기 골치 아프니, 저는 개인적으로 형 변환의 경우 타입 인코딩을 사용합니다.

🔻e.g. 타입 인코딩

✔ newHead가 string으로 들어오는데, int로 변환하는 과정에서 이름 짓기가 매우 불편해서 타입 인코딩을 했습니다. 하지만 애초에 함수의 인자로 int 타입을 요구하는 편이 낫습니다. 기껏 받은 인자를 string으로 전혀 사용하지 않으니 말입니다.

✔ 더불어 bson 구성 시 매직 스트링을 사용했는데, 이를 모두 이름 있는 상수로 변경해야 합니다.

-

추상화 단계에 따른 이름 짓기

❎ 함수의 이름은 내려갈수록 구체적으로

이름은 충분히 설명적(Self-Descriptive)이어야 하지만, 상위 함수가 모든 동작을 설명할 수는 없습니다.

가령, 아래 Parse 함수를 DetermineFileExtensionAndParseConfigurationFile 로 바꾼다면 되려 읽기가 어렵습니다.​

함수의 이름은 자신의 추상화 단계를 따릅니다. 즉, 함수는 내려갈수록(깊어질수록) 구체적이고 설명적인 이름을 갖도록 합니다.

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
}
}

반대로, 변수의 이름은 내려갈수록 추상적으로

앞서 본 것처럼, 함수는 내려갈수록 구체적입니다.​ 함수가 구체적이라는 의미는 해당 함수의 범위(scope)가 더 명확해진다(좁아진다)는 의미입니다.

좁은 범위에서 사용되는 변수명은 덜 명확해도 충분히 구체적일 수 있습니다.

// 축약이 의미를 가지는 맥락. b라는 키워드조차 충분히 구체적이게 된다. 
func PrintBrandsInList(brands []BeerBrand) {
for _, b := range brands {
fmt.Println(b)
}
}

반대로, 변수가 사용되는 범위가 넓어질수록 변수명은 구체적이어야 합니다. 즉, 변수의 이름은 선언과 사용이 멀어질수록 구체적이어야 합니다. 예를 들어 글로벌 변수는 매우 구체적인 이름을 가져야 합니다.

-

부수효과 (Side Effect)

함수의 부수효과는 함수의 이름에 명시되지 않은, 혹은 직관적으로 예측할 수 없는 모든 행위를 가리킵니다.

부수효과에 대한 경고는 뻔한 이야기지만, 또 실무에서 가장 자주 하는 실수이기도 합니다. 특히 이전에 작성된 코드를 고치다 보면 어느새 부수효과를 일으키는 코드가 레포에 올라가는 경우를 심심찮게 목격할 수 있습니다.

func (a *Account) validatePassword() error {
if (!validate(a.password)){
return errors.new(INVALID_PASSWORD)
}
// side effect를 야기하는 부분.
// validatePassword에서 a.initialize를 호출할 것이라고 예상할 수 있을까?
a.initialize()
return nil
}​

-

YAGNI : You Aren’t Gonna Need It

YAGNI는 XP(Extream Programing)에서 나온 개념입니다. 직역하자면 “너 그거 안 쓸걸?” 정도인데, 풀이하자면 당장 사용되지 않는 코드를 ‘필요할 것 같으니까’ 작성해서는 안 된다는 원칙입니다. 확장 가능성을 염두에 두는 것과 미리 확장해두는 것은 명백하게 다르기 때문​입니다.​

우리는 처음부터 6차선 도로를 만들 수 없습니다. 우선 2차선 도로를 만들어야 합니다. 확장 가능성의 확보가 6차선 만큼의 너비를 미리 가늠해두는 것이라면, 확장해두는 것은 2차선 도로에 6차선 표지판을 세워두는 것이라고 할 수 있습니다.​

이는 당장의 리소스 낭비일 뿐만이 아니라, 코드를 읽는 사람에게 그 코드가 현재 유의미할 것이라는 오해를 불러일으킵니다.

-

인자​

인자 수는 1~2개로 유지합니다. 3개를 넘어가는 경우 구조체로 묶어서 주고받는 편이 낫습니다.​

구조체를 넘기면 구조체 자체에도, 각 인자에도 이름을 붙일 수 있습니다. 즉, 맥락을 주입하고 명확성을 보장합니다.​

단, 구조체로 묶을 때는 언어적 특성이 고려되어야 합니다. Golang의 Context나 Mutex 같은 요소는 구조체로 묶는 것이 제한되거나, 사용 시 유의할 필요가 있습니다.

🔻 e.g. clean-go-article : 인자 struct 만들기

// 안 좋은 예
q, err := ch.QueueDeclare(
"hello", // name
false, // durable
false, // delete when unused
false, // exclusive
false, // no-wait
nil, // arguments
)
// 좋은 예
// 인자를 받아서 검사하기도 용이함
// 단점을 꼽자면 default value에 대한 신중한 설계가 필요
// 예를 들어 bool의 경우 default false인데, 사용자의 결정이 필요하다면 *bool으로 구성
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,
})

-

Call by Pointer VS Call by Value

함수를 제공하는 쪽에서는 주소를 받을 것인가, 값을 받을 것인가를 정해야 합니다. 그런데 주소 전달과 값 전달은 꽤 논쟁적인 부분이고, 언어의 디자인에 따라서 규칙이 달라집니다. 여기서는 Golang을 기준으로 살펴보고자 합니다.​

먼저 함수의 인자로 전달할 때 기준은 두 가지입니다. 둘 중 하나에 부합한다면 주소를 전달합니다.​

✔ 값을 변경하는가?

✔ 값을 전달하기에 크기가 너무 큰가?

- 값 전달은 복사가 일어나기 때문인데요, 너무 큰 게 얼마나 큰 걸까요? 다소 모호한 문제입니다.

🔻 e.g. 잘못된 Call by Pointer

✔ header 값을 변경하지 않는데 포인터로 받는 경우입니다.

✔ 멀티 리턴이 되는 Golang에서는 가능한 주소를 전달받지 않고 값을 리턴합니다.

✔ 또한 Golang의 slice와 interface의 Internal Definition은 struct이며 map과 chan의 경우는 pointer입니다. 이러한 특성을 잘 이해하지 않으면 의도치 않은 동작을 발생시킬 수 있습니다.

🔻 e.g. Call by Pointer를 사용하는 경우​

✔ InitConfig와 같이 Method(클래스에 종속적인 함수)에서도 값을 변경하는 경우 Pointer Receiver를 사용합니다.

✔ yaml.Unmarshal의 경우 출력 인자(out interface{})를 사용합니다.

- 다양한 출력(struct, map, …)을 지원하는 함수가 출력 인자를 통해 다양한 출력을 추상화한 사례입니다.

  • 이런 경우를 제외하고는 출력 인자를 잘 사용하지 않습니다.

-

Method Receiver에 대한 세 번째 규칙 : Consistency

Golang에서는 Method Receiver에 웬만하면 값을 사용하라고 추천합니다. 동시에 단 하나라도 주소를 사용해야 한다면, 모두 다 주소로 사용하라고도 이야기합니다.​

인터페이스 충족 여부를 관리하기 힘들기 때문인데요, 상세한 내용이 궁금하다면 관련 코멘트를 참고하시길 바랍니다. (링크1, 링크2)​

결론만 말하자면 “반드시 일관적일 필요는 없다”이고, 저 또한 일관성을 위해서 코드에서 보낼 수 있는 신호를 포기하는 것이 바람직한지 의문이 듭니다.​

하지만 Golang의 잦은 코드 리뷰 코멘트를 모아둔 CodeReviewComments에서는 “Finally, when in doubt, use a pointer receiver.”라고 이야기합니다. 이건 팀원들끼리 이야기를 좀 해봐야겠네요.

👀 Outro

📌논의의 기반으로서의 클린코드

“무엇이 좋은 코드인가?”에 대한 대답은 정해져있지 않습니다. 클린코드가 좋은 교재이지만, 바이블은 될 수 없는 이유입니다.​

클린코드를 다시 읽고 공유하는 이유는 이를 논의의 기반으로 삼기 위해서입니다. 글로벌 변수와 로컬 변수의 구분을 어떻게 할 것인가? 에러코드와 메시지를 어떻게 구성할까? 클린코드는 이러한 논의의 출발점이 되기에 훌륭한 지침입니다.

-

📌처음부터 그럴듯한 코드를 짤 수는 없습니다.

저는 나름대로 세미나를 할 정도로 가독성에 대해서 이해했다고 생각하지만, 그럼에도 예시에 든 것처럼 제 코드에도 구멍이 상당히 많습니다. (세미나 목적으로 일부러 이상하게 짠 게 아닙니다)​

역사의 모든 대문호들은 다들 “초고는 쓰레기다”라는 이야기를 했습니다. 가독성이란 면에서 코딩은 글쓰기와 거의 같습니다. 개발자의 실력은 그의 첫 커밋에서보다는 프로젝트가 유지되면서 어떻게 발전하는가에서 더 드러납니다.​

그래서 오늘도 코드의 수정이 있을 때마다 조금 더 예쁜 코드를 남기고 떠나기로 결심합니다. 이것이 보이 스카우트 규칙입니다.

  • 본 포스팅은 네이버클라우드 Virtualization Tech Service 이동엽님이 작성해 주셨습니다.
  • 관련 궁금한 점은 네이버 클라우드 플랫폼 페이스북 유저 그룹에 남겨주시면 빠르게 도와드리겠습니다.

​​

--

--

NAVER CLOUD PLATFORM
NAVER CLOUD PLATFORM

We provide cloud-based information technology services for industry leaders from startups to enterprises.