Devin.KR

Go · 기본

작고 명확한 Go 프로그램

고루틴과 채널 - 동시에 일하기

go 문, 버퍼 없는/있는 채널, close 와 range, select, 작업자 풀

개발자KR · 원고 갱신

이 장에서 배우는 것

지금까지 만든 프로그램은 한 번에 한 줄씩, 위에서 아래로 실행됐다. 이 장에서는 여러 일을 겹쳐서 진행하는 방법을 배운다. Go 는 이를 위해 고루틴(goroutine)이라는 가벼운 실행 단위와, 고루틴끼리 값을 주고받는 통로인 채널(channel)을 언어에 넣어 두었다. 대여소 프로그램에서는 쌓인 대여 기록의 요금을 여러 작업자에게 나눠 계산시키는 데 이 도구를 쓴다.

  • go 문으로 함수를 별도의 고루틴에서 실행하고, main 이 끝나면 고루틴도 함께 끝난다는 점을 설명할 수 있다.
  • 버퍼 없는 채널과 버퍼 있는 채널이 언제 멈추고 언제 넘어가는지 구분한다.
  • close 와 for range 로 "더 보낼 것이 없다"는 사실을 전달한다.
  • select 로 여러 채널 중 준비된 쪽을 고르고, default 와 시간 제한을 붙인다.
  • 작업자 풀(worker pool)을 만들고, 도착 순서와 상관없이 결과 순서를 고정한다.

문제 상황

동네 대여소 세 곳에서 하루 동안 쌓인 대여 기록이 있다. 기록마다 대여소 이름과 이용 시간(분)이 있고, 이를 요금으로 바꾸는 정산이 필요하다. 요금 규칙은 단순하다. 처음 30분은 무료이고, 그 뒤로는 10분 단위로 올림해서 500원씩 받는다.

계산 자체는 순식간이다. 그러나 실제 서비스에서는 기록 한 건을 정산할 때마다 회원 등급을 조회하거나 결제 서버에 물어보는 식의 기다림이 끼어든다. 기록이 수천 건이면 "한 건 끝나면 다음 건"으로 처리하는 방식은 기다림을 전부 더한 시간이 걸린다. 기다리는 동안 다른 기록을 처리할 수 있다면 전체 시간이 크게 줄어든다.

여기에 조건이 하나 붙는다. 정산서는 기록 번호 순서로 출력돼야 한다. 여러 작업자가 동시에 일하면 끝나는 순서는 실행할 때마다 달라진다. 그러므로 "동시에 처리하되 결과는 제자리에 놓는" 구조가 필요하다. 이 장의 완성 코드가 그 구조다.

고루틴과 채널의 기본

go 문: 함수를 옆 길로 보낸다

함수 호출 앞에 go 를 붙이면 그 호출은 새 고루틴에서 실행되고, 호출한 쪽은 기다리지 않고 바로 다음 줄로 넘어간다. 고루틴은 운영체제 스레드보다 훨씬 작게 시작하도록 Go 런타임이 관리하는 실행 단위여서, 수천 개를 만들어도 부담이 크지 않다.

주의할 점이 둘 있다. 첫째, main 함수가 끝나면 프로그램 전체가 끝나고, 아직 돌던 고루틴도 그 자리에서 사라진다. 둘째, 고루틴이 언제 실행되고 언제 끝나는지는 프로그램이 정하지 않는다. 그래서 "끝날 때까지 기다리기"와 "결과 받기"를 위한 수단이 따로 필요하고, 그 수단이 채널이다.

여기서 동시성(concurrency)과 병렬(parallelism)을 구분해 두자. 동시성은 여러 일이 겹쳐서 진행되도록 프로그램을 짜는 방식이고, 병렬은 실제로 여러 CPU 코어가 같은 순간에 일하는 상태다. Go 런타임은 고루틴을 사용 가능한 코어에 나눠 얹는다. 우리는 일을 나누는 구조만 짜고, 어느 코어에서 도는지는 신경 쓰지 않는다.

버퍼 없는 채널: 만나야 넘어간다

채널은 특정 타입의 값을 실어 나르는 통로다. make(chan int) 처럼 만들고, ch <- 값 으로 보내고, 값 := <-ch 로 받는다. 화살표 방향이 값이 흐르는 방향이다.

크기를 주지 않고 만든 채널은 버퍼가 없다. 이런 채널에서는 보내는 쪽과 받는 쪽이 동시에 준비돼야 값이 넘어간다. 보내는 쪽이 먼저 도착하면 받는 쪽이 올 때까지 멈추고, 받는 쪽이 먼저 도착해도 마찬가지다. 그래서 버퍼 없는 채널은 값 전달과 함께 "여기서 서로 시점을 맞춘다"는 약속 역할도 한다.

버퍼 없는 채널은 보내는 쪽과 받는 쪽이 만나야 넘어가고, 버퍼 있는 채널은 칸이 남아 있는 동안 보내는 쪽이 멈추지 않는다.

버퍼 있는 채널: 칸이 남아 있으면 계속 간다

make(chan int, 3) 처럼 두 번째 인자를 주면 그 수만큼 값을 담아 둘 수 있는 채널이 된다. 칸이 남아 있는 동안에는 받는 쪽이 없어도 보내기가 바로 끝나고, 칸이 모두 차면 보내는 쪽이 멈춘다. 반대로 채널이 비어 있으면 받는 쪽이 멈춘다. 내장 함수 len 은 지금 담긴 값의 수를, cap 은 칸의 수를 돌려준다.

버퍼는 "잠깐 쌓아 두는 여유"이지 속도를 자동으로 올려 주는 장치가 아니다. 크기는 처음 배우는 단계에서는 0(버퍼 없음)으로 시작하고, 필요성이 분명할 때 키우는 편이 낫다.

버퍼 없는 채널과 버퍼 있는 채널이 멈추는 때
구분만드는 법보내기가 멈추는 때받기가 멈추는 때
버퍼 없음make(chan T)받는 쪽이 준비되지 않았을 때보내는 쪽이 준비되지 않았을 때
버퍼 있음make(chan T, n)n 칸이 모두 찼을 때채널이 비었을 때

close 와 range: 더 보낼 것이 없다고 알리기

받는 쪽은 값이 몇 개 올지 모를 때가 많다. 이때 보내는 쪽이 close(ch) 로 채널을 닫으면 "더는 보내지 않는다"는 뜻이 전달된다. 받는 쪽에서는 for v := range ch 를 쓰면 값이 올 때마다 반복하다가, 채널이 닫히고 남은 값이 비면 반복이 저절로 끝난다.

닫힌 채널에서 받기를 하면 두 값을 받을 수 있다. v, ok := <-ch 에서 ok 가 false 이면 채널이 닫혔고 비었다는 뜻이며, 이때 v 는 그 타입의 제로값이다. 채널을 닫는 일은 보내는 쪽의 책임이다. 받는 쪽이 닫으면 보내는 쪽이 닫힌 채널에 보내다가 프로그램이 멈춘다.

채널 닫기와 관련된 동작
동작열린 채널닫힌 채널
보내기정상 동작패닉(panic)이 발생한다
받기값이 올 때까지 기다린다남은 값을 먼저 주고, 그 뒤에는 제로값과 ok == false
close정상 동작패닉이 발생한다

패닉은 프로그램이 더 진행할 수 없다고 판단해 스스로 중단하는 상황을 말한다. 오류 값으로 돌려받는 것이 아니므로, 애초에 일어나지 않게 짜야 한다.

select: 준비된 쪽을 고른다

select 는 switch 와 생김새가 비슷하지만 조건이 아니라 채널 동작을 기준으로 고른다. 각 case 는 보내기나 받기 하나이고, 그중 지금 바로 진행할 수 있는 것이 실행된다. 진행할 수 있는 것이 없으면 하나라도 준비될 때까지 기다린다. 둘 이상이 동시에 준비돼 있으면 그중 하나가 무작위로 골라지므로, 결과 순서에 기대는 코드를 쓰면 안 된다.

select 가 자주 쓰이는 모양은 두 가지다.

  • 기다리지 않기: default 를 넣으면 준비된 case 가 없을 때 바로 default 가 실행된다. 채널을 "들여다보기만 하고 멈추지 않는" 용도다.
  • 시간 제한: time.After(d) 는 d 시간이 지나면 값이 하나 도착하는 채널을 돌려준다. 이를 case 로 넣으면 일정 시간 안에 응답이 없을 때 포기하는 코드가 된다.

자세한 규칙은 Go 언어 명세의 select 문 항목에서 확인할 수 있다.

작업자 풀: 일을 나누고 결과를 제자리에 놓기

작업자 풀은 고정된 수의 고루틴(작업자)이 하나의 일감 채널에서 일을 꺼내 처리하는 구조다. 일감이 얼마나 많든 동시에 도는 고루틴 수는 작업자 수로 제한된다. 구성 요소는 다음과 같다.

  • 일감을 jobs 채널에 넣는 공급 고루틴.
  • for j := range jobs 로 일감을 꺼내 처리하고 results 채널에 결과를 보내는 작업자 여럿.
  • 결과를 정확히 일감 수만큼 받아서 모으는 main.
공급 고루틴이 jobs 채널에 일감을 넣으면 작업자 셋이 나눠 가져 처리하고, main 이 results 를 받아 번호 자리에 저장한다.

같은 채널을 여러 작업자가 동시에 받아도 안전하다. 한 값은 한 작업자에게만 전달된다. 어느 작업자가 어느 일감을 가져가는지는 실행마다 다를 수 있다. 그러므로 결과 순서를 고정하려면 일감에 번호(index)를 달고, 결과가 돌아왔을 때 그 번호 자리에 넣으면 된다. 슬라이스를 미리 make 로 만들어 두고 fees[번호] = 요금 으로 채우면, 도착 순서와 무관하게 결과가 입력 순서대로 놓인다. 이 슬라이스는 main 만 고치므로 여러 고루틴이 같은 변수를 동시에 건드리는 문제도 생기지 않는다. 그런 문제와 해법은 다음 장에서 다룬다.

함수 인자의 채널 타입에는 방향을 적을 수 있다. <-chan job 은 받기 전용, chan<- result 는 보내기 전용이다. 작업자가 jobs 에 실수로 보내거나 results 를 닫는 코드를 쓰면 컴파일 단계에서 걸러진다.

공급을 별도 고루틴으로 돌리는 데에는 이유가 있다. jobs 와 results 가 모두 버퍼 없는 채널일 때, main 이 일감을 전부 넣은 뒤에야 결과를 받으러 가는 구조라면 작업자가 결과를 보내다 멈추고, 그러면 작업자가 일감을 더 받지 못해 main 의 일감 보내기도 멈춘다. 모두가 서로를 기다리는 이 상태를 교착(deadlock)이라 한다. 공급을 옆 고루틴에 맡기면 main 이 곧바로 결과를 받으러 갈 수 있어 이 문제가 없다.

완성 코드

아래는 main.go 한 파일이다. 앞의 개념을 순서대로 짧게 보여 준 뒤, 마지막에 대여 기록을 작업자 풀로 정산한다. 모든 출력은 실행할 때마다 같다.

package main

import (
	"fmt"
	"time"
)

type Rental struct {
	ID      int
	Station string
	Minutes int
}

type job struct {
	index  int
	rental Rental
}

type result struct {
	index int
	fee   int
}

// calcFee 는 처음 30분을 무료로 하고, 그 뒤 10분 단위 올림으로 500원씩 받는다.
func calcFee(minutes int) int {
	if minutes <= 30 {
		return 0
	}
	blocks := (minutes - 30 + 9) / 10
	return blocks * 500
}

func worker(jobs <-chan job, results chan<- result) {
	for j := range jobs {
		results <- result{index: j.index, fee: calcFee(j.rental.Minutes)}
	}
}

func unbufferedDemo() {
	done := make(chan string)
	go func() {
		done <- "A 대여소 점검 끝"
	}()
	fmt.Println("받음:", <-done)
}

func bufferedDemo() {
	returns := make(chan int, 3)
	returns <- 101
	returns <- 102
	returns <- 103
	fmt.Println("대기 중인 반납:", len(returns), "/", cap(returns))
	fmt.Println("먼저 나온 것:", <-returns)
}

func closeDemo() {
	stations := make(chan string)
	go func() {
		defer close(stations)
		for _, s := range []string{"시청앞", "도서관", "공원"} {
			stations <- s
		}
	}()
	for s := range stations {
		fmt.Println("대여소:", s)
	}
	v, ok := <-stations
	fmt.Printf("닫힌 뒤: %q %v\n", v, ok)
}

func selectDemo() {
	ch := make(chan int)

	select {
	case n := <-ch:
		fmt.Println("받음:", n)
	default:
		fmt.Println("도착한 반납 없음")
	}

	select {
	case n := <-ch:
		fmt.Println("받음:", n)
	case <-time.After(20 * time.Millisecond):
		fmt.Println("20ms 안에 반납 없음")
	}
}

func settleAll(rentals []Rental) {
	jobs := make(chan job)
	results := make(chan result)

	for w := 0; w < 3; w++ {
		go worker(jobs, results)
	}

	go func() {
		for i, r := range rentals {
			jobs <- job{index: i, rental: r}
		}
		close(jobs)
	}()

	fees := make([]int, len(rentals))
	for range rentals {
		res := <-results
		fees[res.index] = res.fee
	}

	total := 0
	for i, r := range rentals {
		fmt.Printf("#%d %s %d분 %d원\n", r.ID, r.Station, r.Minutes, fees[i])
		total += fees[i]
	}
	fmt.Printf("합계: %d원\n", total)
}

func main() {
	unbufferedDemo()
	bufferedDemo()
	closeDemo()
	selectDemo()

	rentals := []Rental{
		{1, "시청앞", 25},
		{2, "도서관", 42},
		{3, "공원", 30},
		{4, "시청앞", 75},
		{5, "공원", 31},
		{6, "도서관", 120},
	}
	settleAll(rentals)
}

줄별 해설

타입 선언. Rental 은 대여 기록 한 건이다. job 은 기록에 자리 번호(index)를 붙인 일감이고, result 는 그 번호와 계산된 요금을 묶은 결과다. 번호를 일감과 결과에 함께 실어 보내는 것이 순서를 고정하는 핵심이다. job 과 result 는 이 파일 안에서만 쓰므로 소문자로 시작한다.

calcFee. 30분 이하는 0원이다. 그 초과분을 10분 단위로 올림하려고 (minutes - 30 + 9) / 10 을 썼다. 정수 나눗셈은 소수점을 버리므로, 나누기 전에 9를 더하면 올림과 같은 결과가 된다. 31분이면 (1 + 9) / 10 = 1 블록, 42분이면 (12 + 9) / 10 = 2 블록이다.

worker. 매개변수 jobs <-chan job 은 받기 전용이다. for j := range jobs 는 일감이 올 때마다 본문을 실행하고, jobs 가 닫히면 끝난다. 반복이 끝나면 함수가 반환되고 그 고루틴도 끝난다. results chan<- result 는 보내기 전용이며, 계산이 끝난 결과를 여기에 보낸다.

unbufferedDemo. done 은 버퍼 없는 채널이다. 익명 함수를 go 로 실행하면 그 안의 보내기는 main 쪽이 받으러 올 때까지 멈춘다. main 은 <-done 에서 받을 때까지 멈춘다. 이 만남 덕분에 고루틴이 일을 마치기 전에 함수가 끝나는 일이 없다.

bufferedDemo. 칸이 셋인 채널에 값 셋을 넣는 동안 받는 쪽이 없어도 멈추지 않는다. len 은 3, cap 도 3이다. 채널은 먼저 들어간 값이 먼저 나오므로 첫 수신 결과는 101이다.

closeDemo. 공급 고루틴은 defer close(stations) 로 함수가 끝날 때 채널을 닫도록 예약해 둔다. 앞 장에서 다룬 defer 를 그대로 쓴 것이다. main 의 for s := range stations 는 세 이름을 받고 채널이 닫히면 끝난다. 그 뒤의 v, ok := <-stations 는 닫힌 채널에서 받으므로 빈 문자열과 false 를 얻는다. %q 는 문자열을 따옴표로 감싸 출력하므로 빈 문자열도 눈에 보인다.

selectDemo. ch 에는 보내는 쪽이 아무도 없다. 첫 번째 select 는 받을 수 있는 case 가 없으므로 default 가 즉시 실행된다. 두 번째는 default 가 없어서 기다리다가, 20밀리초 뒤에 time.After 채널에 값이 도착하면 그 case 가 실행된다. 보내는 쪽이 없으니 결과가 매번 같다.

settleAll. for w := 0; w < 3; w++ 로 작업자 셋을 go 로 띄운다. 공급 고루틴은 기록마다 job 을 만들어 jobs 로 보내고, 다 보내면 close(jobs) 로 알린다. fees 는 기록 수만큼의 길이로 미리 만들어 둔 슬라이스다. for range rentals 는 값을 쓰지 않고 횟수만 세는 반복으로, 결과를 정확히 기록 수만큼 받는다. 결과가 하나 올 때마다 fees[res.index] 에 넣으므로 어떤 작업자가 먼저 끝났든 자리는 같다. 모두 받은 뒤에는 원래 순서대로 출력하고 합계를 낸다. 결과를 일감 수만큼 받았으므로 이 시점에는 모든 작업자가 마지막 결과를 보낸 상태이고, 나머지 작업은 jobs 가 닫힌 것을 보고 곧 끝난다.

main. 데모를 차례로 부르고, 여섯 건의 기록으로 정산한다. 요금은 25분 0원, 42분 1000원, 30분 0원, 75분 2500원, 31분 500원, 120분 4500원이고 합이 8500원이다.

실행 결과

main.go 가 있는 폴더에서 실행한다. 앞에서 만든 모듈이 없다면 go mod init bikeshop 을 먼저 실행하거나, 파일 하나만 go run main.go 로 실행해도 된다.

$ go run main.go
받음: A 대여소 점검 끝
대기 중인 반납: 3 / 3
먼저 나온 것: 101
대여소: 시청앞
대여소: 도서관
대여소: 공원
닫힌 뒤: "" false
도착한 반납 없음
20ms 안에 반납 없음
#1 시청앞 25분 0원
#2 도서관 42분 1000원
#3 공원 30분 0원
#4 시청앞 75분 2500원
#5 공원 31분 500원
#6 도서관 120분 4500원
합계: 8500원

실무에서 자주 틀리는 것

받는 쪽 없이 버퍼 없는 채널에 보내기

같은 고루틴 안에서 보내고 받으면 보내기 줄에서 영원히 끝나지 않는다. 다른 고루틴이 받아 줄 가능성이 없으므로 Go 런타임이 이를 알아채고 프로그램을 중단시킨다.

ch := make(chan int)
ch <- 1
fmt.Println(<-ch)

실행하면 fatal error: all goroutines are asleep - deadlock! 이 나온다. 보내는 일을 다른 고루틴으로 옮기거나, 값이 하나뿐이고 곧바로 받는다는 것이 분명하다면 버퍼를 1로 준다.

ch := make(chan int)
go func() {
	ch <- 1
}()
fmt.Println(<-ch)

받는 쪽이 채널을 닫거나 두 번 닫기

닫힌 채널에 보내면 패닉이 난다. 여러 고루틴이 보낼 수 있는 채널을 그중 하나가 닫아 버리면, 나머지가 보내는 순간 프로그램이 멈춘다.

ch := make(chan int, 1)
close(ch)
ch <- 1 // panic: send on closed channel

규칙은 "보내는 쪽이 하나일 때 그 보내는 쪽이 닫는다"이다. 이 장의 공급 고루틴처럼 보내는 주체가 하나이고, 그 주체가 모든 일을 마친 뒤 한 번만 닫도록 짠다. 받는 쪽은 닫을 필요가 없으며, 함수 인자를 받기 전용으로 선언해 두면 실수로 닫는 코드는 컴파일되지 않는다.

도착한 순서대로 결과를 쓰기

작업자 풀에서 결과를 받은 순서대로 append 하고 그대로 출력하면, 작업자의 속도에 따라 실행할 때마다 순서가 달라질 수 있다. 테스트와 비교가 어려워지는 전형적인 원인이다.

var fees []int
for range rentals {
	res := <-results
	fees = append(fees, res.fee) // 어느 기록의 요금인지 알 수 없다
}

일감에 번호를 달아 보내고, 결과가 돌아오면 그 번호 자리에 넣는다.

fees := make([]int, len(rentals))
for range rentals {
	res := <-results
	fees[res.index] = res.fee
}

close 를 빠뜨려서 range 가 끝나지 않기

보내는 쪽이 채널을 닫지 않으면 range 는 더 올 값을 계속 기다린다. 다른 고루틴이 모두 끝난 상황이라면 위와 같은 교착 오류로 중단되고, 다른 고루틴이 살아 있는 서버라면 멈춘 채로 조용히 자원만 차지한다.

ch := make(chan int)
go func() {
	for i := 0; i < 3; i++ {
		ch <- i
	}
}()
for v := range ch {
	fmt.Println(v)
}

0, 1, 2 를 출력한 뒤 main 이 더 받을 것을 기다리다 deadlock! 으로 중단된다. 공급이 끝나는 지점에서 닫는다.

go func() {
	defer close(ch)
	for i := 0; i < 3; i++ {
		ch <- i
	}
}()

한눈에 보기

이 장에서 쓴 문법과 주의할 점
문법뜻주의할 점
go f()f 를 새 고루틴에서 실행한다main 이 끝나면 함께 끝난다
make(chan T)버퍼 없는 채널보내는 쪽과 받는 쪽이 만나야 넘어간다
make(chan T, n)n 칸 버퍼 채널가득 차면 보내기가, 비면 받기가 멈춘다
ch <- v, v := <-ch보내기, 받기닫힌 채널에 보내면 패닉
close(ch)더 보내지 않는다고 알린다보내는 쪽이 한 번만 닫는다
for v := range ch닫히고 빌 때까지 받는다닫지 않으면 끝나지 않는다
v, ok := <-ch닫혔는지 확인하며 받는다닫히면 ok 는 false
select준비된 채널 동작을 고른다둘 이상 준비되면 무작위
<-chan T, chan<- T받기 전용, 보내기 전용잘못된 방향은 컴파일 오류

이 장의 공식 근거는 Go 언어 명세의 go 문 항목과 채널 타입 항목이다.

연습 문제

  1. main 에서 make(chan int, 3) 채널에 받는 쪽 없이 값을 네 번 보내면 어떻게 되는가. 그 이유를 설명하라.
  2. 완성 코드의 작업자 수를 3에서 1로 바꾸면 출력이 달라지는가. 다른 수로 바꿔도 같은지 이유와 함께 답하라.
  3. 50밀리초 뒤에 "반납 도착"을 보내는 고루틴을 만들고, select 로 200밀리초 안에 받으면 그 문자열을, 못 받으면 "시간 초과"를 출력하는 코드를 써라.
  4. 정산이 끝난 뒤 대여소별 요금 합계를 대여소 이름 순으로 출력하는 코드를 settleAll 끝에 덧붙인다면 어떻게 쓰겠는가. 여섯 건의 기록 기준으로 출력도 적어라.

정답과 해설

1. 셋까지는 칸이 남아 있어 바로 넘어가지만 넷째 보내기에서 멈춘다. 받아 줄 다른 고루틴이 없으므로 런타임이 fatal error: all goroutines are asleep - deadlock! 로 프로그램을 중단시킨다. 버퍼는 칸이 있는 동안만 기다림을 미룰 뿐, 받는 쪽이 없는 상황을 해결하지 못한다.

2. 출력은 같다. 작업자가 몇이든 결과에 index 가 실려 있고, main 이 그 번호 자리에 넣기 때문이다. 달라지는 것은 내부에서 어느 작업자가 어느 일감을 가져가는지와 끝나는 순서뿐이다. 이 프로그램에서는 그 부분이 출력에 나타나지 않게 설계했다.

3.

ch := make(chan string)
go func() {
	time.Sleep(50 * time.Millisecond)
	ch <- "반납 도착"
}()
select {
case msg := <-ch:
	fmt.Println(msg)
case <-time.After(200 * time.Millisecond):
	fmt.Println("시간 초과")
}

50밀리초 뒤에 값이 먼저 도착하므로 "반납 도착"이 출력된다. 대기 시간을 20밀리초로 줄이고 보내는 쪽을 200밀리초로 늦추면 "시간 초과"가 나온다. 두 시간의 차이가 충분히 크므로 순서가 뒤집힐 걱정은 없다. 두 시간이 비슷하면 결과가 실행마다 달라질 수 있다.

4. 맵 순회 순서는 정해져 있지 않으므로 이름을 슬라이스에 모아 정렬한 뒤 출력한다. 이 코드는 sort 패키지를 import 에 추가해야 한다.

sums := make(map[string]int)
for i, r := range rentals {
	sums[r.Station] += fees[i]
}
names := make([]string, 0, len(sums))
for name := range sums {
	names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
	fmt.Println(name, sums[name])
}

sort.Strings 는 바이트 순서로 정렬하므로 한글은 가나다 순이 된다. 출력은 다음과 같다.

공원 500
도서관 5500
시청앞 2500

이 합계는 fees 가 다 채워진 뒤 main 에서만 계산하므로 다른 고루틴과 변수를 나눠 쓰지 않는다.

오탈자·오류 제보 비공개로 접수되어 원고 수정에 반영됩니다

이메일 등 개인정보는 받지 않습니다. 답변이 필요한 질문은 아래 댓글을 이용해 주세요.

READER FEEDBACK

질문·의견

내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.