V 언어 문법: 동시성 프로그래밍과 채널
지금까지 배운 프로그램은 한 번에 하나의 작업만 순서대로 처리했다. 하지만 현실의 프로그램은 파일을 다운로드하면서 동시에 화면을 업데이트하고, 여러 API를 동시에 호출한 뒤 결과를 모아야 한다. 시리즈 마지막 편에서는 여러 작업을 동시에 처리하는 법을 배운다.
동시성이란?
순차 vs 동시
순차 처리(sequential) 는 한 번에 하나씩 차례대로 하는 것이다. 동시 처리(concurrent) 는 여러 작업을 동시에 진행하는 것이다.
비유하자면 카페다.
- 순차 처리: 점원이 1명. 주문을 받고 → 커피를 만들고 → 서빙하고 → 다음 손님 주문을 받는다. 한 번에 한 손님만 처리한다.
- 동시 처리: 점원이 2명. 한 명은 주문을 받고, 다른 한 명은 커피를 만든다. 동시에 여러 작업이 진행된다.
순차 처리:
작업A ████████ → 작업B ████████ → 작업C ████████
총 시간: ──────────────────────────────────────→
동시 처리:
작업A ████████
작업B ████████
작업C ████████
총 시간: ────────────────→ (훨씬 빠르다!)
동시성을 쓰면 여러 작업을 겹쳐서 실행하므로, 전체 소요 시간이 크게 줄어든다.
동시성 vs 병렬성
자주 혼동되는 두 개념을 짚고 넘어가자.
| 개념 | 의미 | 비유 |
|---|---|---|
| 동시성(Concurrency) | 여러 작업을 번갈아가며 처리 | 1명이 여러 일을 빠르게 왔다갔다 |
| 병렬성(Parallelism) | 여러 작업을 진짜 동시에 처리 | 여러 명이 각자 하나씩 처리 |
V의 spawn은 시스템의 CPU 코어가 여러 개면 진짜 병렬로 실행되고, 코어가 하나면 번갈아가며 실행된다. 프로그래머 입장에서는 둘 다 같은 코드를 쓰면 된다.
spawn — 별도 스레드에서 실행
spawn은 함수를 새로운 스레드에서 실행하는 키워드다. "일꾼을 한 명 더 고용하는 것"이라고 생각하면 된다.
기본 사용법
import time
fn do_work(name string) {
println('${name}: 작업 시작')
time.sleep(2 * time.second) // 2초 동안 작업하는 척
println('${name}: 작업 완료!')
}
fn main() {
// 별도 스레드에서 함수 실행
handle := spawn do_work('작업A')
println('main: 다른 일을 하는 중...')
time.sleep(1 * time.second)
println('main: 여전히 다른 일 중...')
// 스레드가 끝날 때까지 기다림
handle.wait()
println('main: 모든 작업 완료')
}
출력 (시간 순서):
작업A: 작업 시작
main: 다른 일을 하는 중...
main: 여전히 다른 일 중...
작업A: 작업 완료!
main: 모든 작업 완료
핵심을 짚어보자.
spawn do_work('작업A')—do_work를 새 스레드에서 실행한다. main은 멈추지 않고 다음 줄로 진행한다.handle.wait()— 스레드가 끝날 때까지 기다린다. 이 줄에서 main이 멈추고, 스레드가 끝나면 다시 진행한다.
여러 스레드 동시 실행
import time
fn download(url string) {
println('다운로드 시작: ${url}')
time.sleep(1 * time.second) // 다운로드 시뮬레이션
println('다운로드 완료: ${url}')
}
fn main() {
// 3개의 다운로드를 동시에 시작
h1 := spawn download('file1.zip')
h2 := spawn download('file2.zip')
h3 := spawn download('file3.zip')
// 모두 끝날 때까지 기다림
h1.wait()
h2.wait()
h3.wait()
println('모든 다운로드 완료!')
}
순차적으로 다운로드하면 3초가 걸리지만, 동시에 실행하면 약 1초면 끝난다. 이것이 동시성의 힘이다.
spawn에서 반환값 받기
스레드에서 실행한 함수의 반환값을 받을 수 있다.
import time
fn compute(n int) int {
time.sleep(1 * time.second) // 오래 걸리는 계산 시뮬레이션
return n * n
}
fn main() {
h1 := spawn compute(5)
h2 := spawn compute(10)
h3 := spawn compute(15)
// wait()로 반환값을 받는다
r1 := h1.wait()
r2 := h2.wait()
r3 := h3.wait()
println('결과: ${r1}, ${r2}, ${r3}') // 결과: 25, 100, 225
}
h1.wait()는 스레드가 끝날 때까지 기다리고, 함수의 반환값을 돌려준다.
🔍 다른 언어와 비교:
언어 동시 실행 방식 결과 받기 V spawn fn()handle.wait()Go go fn()채널로 전달 Python threading.Thread/asynciothread.join()/awaitRust std::thread::spawnhandle.join()Java new Thread(() -> ...)/ExecutorServicefuture.get()V의
spawn은 Go의go와 가장 유사하지만,wait()로 반환값을 직접 받을 수 있다는 점이 더 편리하다.
채널(Channel) — 스레드 간 안전한 데이터 전달
스레드끼리 데이터를 주고받아야 할 때 채널(channel) 을 사용한다.
채널이란?
비유하자면 컨베이어 벨트다. 한쪽(보내는 측)에서 물건을 올려놓으면, 다른 쪽(받는 측)이 물건을 집어 간다. 벨트가 가득 차면 보내는 쪽이 잠시 기다리고, 비어 있으면 받는 쪽이 기다린다.
보내는 스레드 채널 받는 스레드
┌──┐ ┌──┬──┬──┐ ┌──┐
│ │──물건──→ │ │ │ │ ──물건──→│ │
└──┘ └──┴──┴──┘ └──┘
컨베이어 벨트
채널 생성과 사용
fn producer(ch chan string) {
ch <- '첫 번째 메시지' // 채널에 보내기
ch <- '두 번째 메시지'
ch <- '세 번째 메시지'
}
fn main() {
ch := chan string{} // string 채널 생성
spawn producer(ch) // 별도 스레드에서 보내기
// 채널에서 받기
msg1 := <-ch
msg2 := <-ch
msg3 := <-ch
println(msg1) // 첫 번째 메시지
println(msg2) // 두 번째 메시지
println(msg3) // 세 번째 메시지
}
채널 문법을 정리하면 이렇다.
| 문법 | 의미 | 설명 |
|---|---|---|
chan string{} | 채널 생성 | string을 주고받는 채널 |
ch <- 값 | 보내기 (push) | 채널에 값을 넣는다 |
<-ch | 받기 (pop) | 채널에서 값을 꺼낸다 |
<-의 방향을 보면 이해하기 쉽다. 화살표가 채널을 가리키면 보내기, 채널에서 나오면 받기다.
ch <- 값 → 값이 ch로 들어간다 (보내기)
변수 := <-ch → 값이 ch에서 나온다 (받기)
채널의 동기화 효과
채널은 단순한 데이터 전달 수단이 아니라, 스레드 간 동기화 도구이기도 하다.
- 받는 쪽은 데이터가 올 때까지 자동으로 기다린다 (블로킹)
- 보내는 쪽도 상대가 받을 준비가 될 때까지 기다린다 (버퍼 없는 채널의 경우)
덕분에 "보내고 → 받고"의 순서가 자동으로 보장된다. 별도의 잠금(lock) 코드를 쓸 필요가 없다.
버퍼링 채널
기본 채널은 버퍼가 없다. 보내는 쪽은 상대가 받을 때까지 기다려야 한다. 버퍼링 채널을 쓰면 일정량의 데이터를 대기 없이 보낼 수 있다.
fn main() {
// 버퍼 크기 3 — 최대 3개까지 대기 없이 보낼 수 있다
ch := chan int{cap: 3}
ch <- 10 // 즉시 완료 (버퍼에 저장)
ch <- 20 // 즉시 완료
ch <- 30 // 즉시 완료
// ch <- 40 // 버퍼가 꽉 찼으므로 받을 때까지 대기
println(<-ch) // 10
println(<-ch) // 20
println(<-ch) // 30
}
chan int{cap: 3} — cap으로 버퍼 크기를 지정한다. 3개까지는 상대가 안 받아도 계속 보낼 수 있다.
언제 버퍼링 채널을 쓸까?
| 채널 종류 | 장점 | 사용 상황 |
|---|---|---|
버퍼 없음 (chan T{}) | 동기화 보장 | 보내고 받는 타이밍이 중요할 때 |
버퍼 있음 (chan T{cap: N}) | 성능 향상 | 속도 차이가 큰 생산자-소비자 패턴 |
채널 닫기와 순회
보내는 쪽이 더 이상 보낼 데이터가 없으면 채널을 닫을 수 있다.
fn producer(ch chan int) {
for i in 1 .. 6 {
ch <- i
}
ch.close() // 더 이상 보낼 데이터 없음
}
fn main() {
ch := chan int{}
spawn producer(ch)
// 채널이 닫힐 때까지 반복해서 받기
for {
val := <-ch or { break } // 채널이 닫히면 루프 탈출
println(val)
}
// 1, 2, 3, 4, 5
}
ch.close() — 채널을 닫는다. 닫힌 채널에서 받으려고 하면 or 블록으로 빠진다. 이 패턴으로 "언제 끝나는지 모르는 데이터 스트림"을 안전하게 처리할 수 있다.
select 문 — 여러 채널 동시 대기
여러 채널에서 데이터가 올 수 있을 때, 가장 먼저 도착한 데이터를 처리하고 싶다면 select를 사용한다.
import time
fn fast_worker(ch chan string) {
time.sleep(1 * time.second)
ch <- '빠른 작업 완료!'
}
fn slow_worker(ch chan string) {
time.sleep(3 * time.second)
ch <- '느린 작업 완료!'
}
fn main() {
fast_ch := chan string{}
slow_ch := chan string{}
spawn fast_worker(fast_ch)
spawn slow_worker(slow_ch)
// 두 채널 중 먼저 도착하는 것을 받는다
select {
msg := <-fast_ch {
println(msg) // 빠른 작업 완료! (1초 후)
}
msg := <-slow_ch {
println(msg)
}
}
}
select는 여러 채널 연산 중 준비된 것이 있으면 즉시 실행하고, 없으면 하나가 준비될 때까지 기다린다. 위 예제에서는 fast_ch가 1초 후에 먼저 데이터를 보내므로, 해당 블록이 실행된다.
select의 특징
| 특징 | 설명 |
|---|---|
| 여러 채널 동시 대기 | 가장 먼저 준비된 채널을 처리 |
| 비결정적 선택 | 동시에 여러 채널이 준비되면 무작위 선택 |
| 보내기/받기 모두 가능 | ch <- val(보내기)도 select에 넣을 수 있다 |
| 타임아웃 가능 | > 5 * time.second로 시간 제한 설정 |
타임아웃 패턴
import time
fn slow_api(ch chan string) {
time.sleep(10 * time.second) // 매우 느린 API
ch <- '응답 도착'
}
fn main() {
ch := chan string{}
spawn slow_api(ch)
select {
result := <-ch {
println('성공: ${result}')
}
> 3 * time.second {
println('타임아웃! 3초 초과') // 이것이 출력된다
}
}
}
> 3 * time.second는 "3초 안에 어떤 채널도 준비되지 않으면 이 블록을 실행하라"는 뜻이다. API 응답 대기, 네트워크 타임아웃 등 시간 제한이 필요한 상황에서 매우 유용하다.
공유 객체(Shared Objects)
채널은 데이터를 보내고 받는 방식이다. 그런데 때로는 여러 스레드가 같은 데이터를 직접 읽고 쓰고 싶을 때가 있다. 이때 shared 키워드를 사용한다.
문제: 동시 접근의 위험
여러 스레드가 같은 변수에 동시에 쓰면 데이터 경쟁(data race) 이 발생한다. 결과가 예측 불가능해지는 심각한 버그다.
스레드A: count를 읽음 → 10
스레드B: count를 읽음 → 10
스레드A: count에 11을 씀
스레드B: count에 11을 씀 ← 12가 되어야 하는데 11이 됐다!
해결: shared와 lock
import time
struct Counter {
mut:
count int
}
fn increment(shared counter Counter) {
for _ in 0 .. 1000 {
lock counter { // 잠금: 이 블록 안에서는 나만 접근
counter.count += 1
} // 잠금 해제: 다른 스레드도 접근 가능
}
}
fn main() {
shared counter := Counter{}
h1 := spawn increment(shared counter)
h2 := spawn increment(shared counter)
h3 := spawn increment(shared counter)
h1.wait()
h2.wait()
h3.wait()
// 읽을 때도 lock 필요
result := rlock counter {
counter.count
}
println('최종 카운트: ${result}') // 최종 카운트: 3000
}
핵심을 하나씩 짚어보자.
shared counter := Counter{} — shared로 선언하면 여러 스레드에서 안전하게 공유할 수 있는 변수가 된다.
lock counter { ... } — 이 블록 안에서 counter에 접근하는 동안 다른 스레드는 기다려야 한다. 한 번에 하나의 스레드만 접근할 수 있으므로 데이터 경쟁이 발생하지 않는다.
rlock counter { ... } — 읽기 전용 잠금이다. 여러 스레드가 동시에 읽을 수 있지만, 쓰는 스레드가 있으면 기다린다.
| 키워드 | 의미 | 동시 접근 |
|---|---|---|
lock | 읽기 + 쓰기 잠금 | 한 스레드만 가능 |
rlock | 읽기 전용 잠금 | 여러 스레드 동시 가능 |
채널 vs shared — 언제 뭘 쓸까?
| 기준 | 채널 (chan) | 공유 객체 (shared) |
|---|---|---|
| 데이터 흐름 | 한쪽에서 다른 쪽으로 전달 | 여러 곳에서 같은 데이터에 접근 |
| 동기화 | 자동 (보내기/받기로) | 수동 (lock/rlock 필요) |
| 권장 상황 | 생산자-소비자, 파이프라인 | 공유 카운터, 공유 캐시 |
| 안전성 | ★★★★★ (더 안전) | ★★★★☆ (lock 빠뜨리면 위험) |
💡 경험 법칙: "데이터를 보내야 하면 채널, 데이터를 공유해야 하면 shared." 가능하면 채널을 먼저 고려하자. 채널이 더 안전하고, lock을 관리하는 실수를 방지할 수 있다.
실전 패턴: 동시 작업 결과 모으기
여러 작업을 동시에 실행하고, 모든 결과를 모아서 처리하는 가장 흔한 패턴이다.
import time
fn fetch_data(source string, ch chan string) {
// 각 소스에서 데이터를 가져오는 시뮬레이션
time.sleep(1 * time.second)
ch <- '${source}: 데이터 수신 완료'
}
fn main() {
sources := ['서버A', '서버B', '서버C', '서버D']
ch := chan string{cap: sources.len}
// 모든 소스에 동시에 요청
for source in sources {
spawn fetch_data(source, ch)
}
// 모든 결과를 수집
mut results := []string{}
for _ in 0 .. sources.len {
results << <-ch
}
println('=== 모든 결과 수신 ===')
for r in results {
println(r)
}
}
출력 (약 1초 후 한꺼번에):
=== 모든 결과 수신 ===
서버A: 데이터 수신 완료
서버B: 데이터 수신 완료
서버C: 데이터 수신 완료
서버D: 데이터 수신 완료
4개의 서버에 순차적으로 요청하면 4초 걸리지만, 동시에 요청하면 약 1초면 모든 결과를 받을 수 있다.
📝 정리
이번 글에서 배운 핵심 포인트를 체크리스트로 정리한다.
- [x] 동시성 — 여러 작업을 동시에 진행, 순차 처리보다 빠름
- [x]
spawn— 함수를 별도 스레드에서 실행,handle.wait()로 반환값 받기 - [x] 채널 (
chan) —ch <- 값(보내기),<-ch(받기), 스레드 간 안전한 데이터 전달 - [x] 버퍼링 채널 —
chan T{cap: N}으로 N개까지 대기 없이 보내기 - [x] 채널 닫기 —
ch.close(), 닫힌 채널은or { break }로 감지 - [x]
select— 여러 채널 동시 대기, 타임아웃 수 패턴 (> 시간) - [x]
shared+lock/rlock— 여러 스레드의 공유 데이터 안전 접근 - [x] 채널 우선 — 가능하면 shared보다 채널을 먼저 고려 (더 안전)
🧪 직접 해보기
과제 1: 동시 제곱 계산
1부터 10까지의 제곱을 동시에 계산해서 채널로 결과를 모아보자.
fn square(n int, ch chan int) {
// n의 제곱을 계산해서 채널에 보내세요
}
fn main() {
ch := chan int{cap: 10}
for i in 1 .. 11 {
spawn square(i, ch)
}
// 모든 결과를 받아서 합계를 구하세요
// 기대 결과: 1 + 4 + 9 + ... + 100 = 385
}
과제 2: 타임아웃이 있는 작업
select와 타임아웃을 사용해서, 2초 안에 응답이 오지 않으면 "시간 초과"를 출력하는 프로그램을 만들어보자.
import time
fn slow_task(ch chan string) {
// 랜덤하게 1~4초 걸리는 작업을 시뮬레이션
// 힌트: time.sleep(n * time.second) 사용
ch <- '작업 완료!'
}
fn main() {
ch := chan string{}
spawn slow_task(ch)
// select로 2초 타임아웃 구현
// 성공 시: "결과: 작업 완료!" 출력
// 타임아웃 시: "시간 초과!" 출력
}
🎉 시리즈를 마치며
V Language Grammar 시리즈 전 10편을 모두 따라왔다면, 축하한다! V 언어의 핵심 문법을 모두 익힌 것이다.
시리즈 전체 흐름 돌아보기
| 편 | 내용 | 핵심 키워드 |
|---|---|---|
| 1편 | 소개, 설치, Hello World | fn main, println, v run |
| 2편 | 변수, 상수, 기본 타입 | :=, mut, const, int, string |
| 3편 | 함수와 클로저 | fn, return, 다중 반환, defer |
| 4편 | 조건문, 반복문, 패턴 매칭 | if, match, for, in |
| 5편 | 배열과 맵 | []T, map, filter, sort, map[K]V |
| 6편 | 구조체와 메서드 | struct, 메서드 리시버, pub, mut: |
| 7편 | 열거형, 합 타입, 인터페이스 | enum, type A = B \| C, interface |
| 8편 | 에러 처리 | ?T, !T, or {}, ! 전파 |
| 9편 | 모듈과 패키지 | module, import, pub, v.mod |
| 10편 | 동시성과 채널 | spawn, chan, select, shared |
다음 단계
V 문법을 익혔다면, 다음으로 도전해볼 만한 것들이 있다.
- V 공식 문서 — github.com/vlang/v/blob/master/doc/docs.md에서 이 시리즈에서 다루지 못한 고급 기능(제네릭, 컴파일 타임, C 상호운용 등)을 살펴보자
- 작은 프로젝트 만들기 — CLI 도구, 간단한 웹 서버(
vweb), 파일 처리 스크립트 등을 직접 만들어보자 - V 커뮤니티 — Discord와 GitHub에서 다른 V 개발자들과 교류하자
V 언어를 배우는 여정은 여기서 끝이 아니라, 이제 막 시작이다. 배운 문법을 가지고 직접 코드를 짜고, 에러를 만나고, 해결하는 과정에서 진짜 실력이 쌓인다. 즐거운 V 프로그래밍 되시길! 🎉