V 언어 문법: 동시성 프로그래밍과 채널

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: 모든 작업 완료

핵심을 짚어보자.

  1. spawn do_work('작업A')do_work새 스레드에서 실행한다. main은 멈추지 않고 다음 줄로 진행한다.
  2. 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 / asyncio thread.join() / await
Rust std::thread::spawn handle.join()
Java new Thread(() -> ...) / ExecutorService future.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이 됐다!

해결: sharedlock

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 커뮤니티DiscordGitHub에서 다른 V 개발자들과 교류하자

V 언어를 배우는 여정은 여기서 끝이 아니라, 이제 막 시작이다. 배운 문법을 가지고 직접 코드를 짜고, 에러를 만나고, 해결하는 과정에서 진짜 실력이 쌓인다. 즐거운 V 프로그래밍 되시길! 🎉