V 언어 문법: 배열과 맵 — 컬렉션 다루기

V 언어 문법: 배열과 맵 — 컬렉션 다루기

지금까지 변수 하나에 값 하나만 담아왔다. 하지만 현실에서는 "학생 30명의 점수", "장바구니에 담긴 상품 목록", "도시별 인구수" 같은 여러 데이터를 한꺼번에 다루는 일이 훨씬 많다. 이번 편에서는 V가 제공하는 두 가지 핵심 컬렉션 — 배열(Array)맵(Map) — 을 완전히 정복한다.

배열(Array)이란?

배열같은 타입의 데이터를 순서대로 나열한 묶음이다.

비유하자면 사물함이다. 0번 칸, 1번 칸, 2번 칸... 각 칸에 같은 종류의 물건(같은 타입의 값)을 하나씩 넣어둔다. 칸 번호(인덱스)로 원하는 물건을 꺼낼 수 있다.


배열 선언과 초기화

리터럴로 선언

가장 간단한 방법이다. 대괄호([]) 안에 값을 나열하면 된다.

numbers := [1, 2, 3, 4, 5]         // int 배열
names := ['Alice', 'Bob', 'Charlie']  // string 배열
scores := [95.5, 87.3, 100.0]      // f64 배열

V가 값들을 보고 타입을 자동 추론한다. [1, 2, 3]을 보면 "정수들의 배열이군 → []int"이라고 판단한다.

빈 배열 선언

처음에 비어있는 배열을 만들고 나중에 값을 추가하고 싶다면, 타입을 명시해야 한다. V가 추론할 값이 없기 때문이다.

mut empty := []int{}        // 빈 정수 배열
mut words := []string{}     // 빈 문자열 배열

💡 주의: 빈 배열은 나중에 값을 추가(append)할 것이므로 mut을 붙여야 한다.

크기와 초기값 지정

배열의 길이와 초기값을 미리 정할 수도 있다.

// 길이 5, 모든 원소가 0인 배열
zeros := []int{len: 5}
println(zeros)   // [0, 0, 0, 0, 0]

// 길이 3, 모든 원소가 -1인 배열
filled := []int{len: 3, init: -1}
println(filled)  // [-1, -1, -1]

// init에 인덱스(index)를 활용할 수도 있다
indexed := []int{len: 5, init: index * 2}
println(indexed)  // [0, 2, 4, 6, 8]

init: index * 2에서 index는 V가 제공하는 특별한 변수로, 각 원소의 위치(0, 1, 2, ...)를 나타낸다. 이를 활용하면 규칙적인 배열을 한 줄로 만들 수 있다.


배열 접근과 수정

인덱싱 — 특정 위치의 값 꺼내기

fruits := ['사과', '바나나', '딸기', '포도']

println(fruits[0])    // 사과 (첫 번째 — 인덱스는 0부터 시작!)
println(fruits[2])    // 딸기 (세 번째)
println(fruits.last()) // 포도 (마지막 원소)

⚠️ 인덱스는 0부터 시작한다. 첫 번째 원소가 [0], 두 번째가 [1]이다. 거의 모든 프로그래밍 언어가 이 규칙을 따른다.

존재하지 않는 인덱스에 접근하면 어떻게 될까?

fruits := ['사과', '바나나']
// println(fruits[10])   // ❌ 런타임 에러! 배열 범위 초과

V는 경계 검사(bounds checking) 를 자동으로 수행한다. 잘못된 인덱스 접근을 막아주므로, C처럼 엉뚱한 메모리를 읽는 사고가 일어나지 않는다.

값 수정

배열의 특정 위치 값을 바꾸려면, 배열이 mut이어야 한다.

mut scores := [80, 90, 70]
scores[2] = 95              // 세 번째 원소를 95로 변경
println(scores)             // [80, 90, 95]

배열에 원소 추가: << 연산자

<< 연산자로 배열 끝에 값을 추가(append)한다.

mut colors := ['빨강', '파랑']
colors << '초록'               // 하나 추가
colors << ['노랑', '보라']     // 여러 개를 한 번에 추가
println(colors)                // ['빨강', '파랑', '초록', '노랑', '보라']

<<는 V에서 배열에 원소를 추가하는 유일한 방법이다. Python의 append()나 JavaScript의 push()에 해당한다.


배열 슬라이싱

배열의 일부분을 잘라내서 새 배열로 만들 수 있다. 2편에서 문자열 슬라이싱을 배웠는데, 배열도 동일한 문법을 사용한다.

nums := [10, 20, 30, 40, 50]

println(nums[1..4])    // [20, 30, 40]  — 인덱스 1부터 3까지 (4 미포함)
println(nums[..3])     // [10, 20, 30]  — 처음부터 인덱스 2까지
println(nums[2..])     // [30, 40, 50]  — 인덱스 2부터 끝까지

슬라이싱 규칙을 정리하면 이렇다.

문법 의미 예시 (nums = [10,20,30,40,50])
arr[a..b] 인덱스 a부터 b-1까지 nums[1..4][20, 30, 40]
arr[..b] 처음부터 b-1까지 nums[..3][10, 20, 30]
arr[a..] a부터 끝까지 nums[2..][30, 40, 50]

💡 범위 표기(..)의 일관성: 4편에서 배운 for i in 0..5와 같은 규칙이다. 시작값은 포함하고, 끝값은 포함하지 않는다. V 전체에서 이 규칙이 일관되게 적용된다.


고정 크기 배열

지금까지 본 배열은 크기가 자유롭게 늘어나는 동적 배열이다. V에는 크기가 컴파일 시점에 고정되는 고정 크기 배열(fixed-size array) 도 있다.

mut fixed := [3]int{}             // 크기 3, 모든 원소 0
println(fixed)                    // [0, 0, 0]

fixed[0] = 10
fixed[1] = 20
fixed[2] = 30
println(fixed)                    // [10, 20, 30]

// fixed << 40                   // ❌ 에러! 고정 크기 배열에는 추가할 수 없다

[3]int{}에서 대괄호 안의 3이 배열의 크기를 고정시킨다. 이 배열은 항상 정확히 3개의 원소만 담을 수 있다.

동적 배열 vs 고정 크기 배열

구분 동적 배열 ([]int) 고정 크기 배열 ([3]int)
크기 자유롭게 늘어남 컴파일 시 확정, 변경 불가
<< 추가 ✅ 가능 ❌ 불가
메모리 힙(heap)에 할당 스택(stack)에 할당
성능 약간의 오버헤드 더 빠름
주 사용 용도 목록, 컬렉션 등 일반적 좌표, RGB 색상 등 크기 고정

대부분의 경우 동적 배열([]int)을 쓰면 충분하다. 고정 크기 배열은 "원소 수가 절대 변하지 않는 것이 확실할 때" (예: (x, y, z) 좌표, RGB 색상값 등) 성능을 위해 선택한다.


다차원 배열

배열 안에 배열을 넣으면 2차원 배열이 된다. 격자(grid), 표, 게임판 같은 2D 데이터를 표현할 때 사용한다.

// 3x3 격자 (구구단 표의 일부라고 상상해보자)
grid := [
    [1, 2, 3],
    [4, 5, 6],
    [7, 8, 9],
]

// 접근: grid[행][열]
println(grid[0][0])   // 1 (0행 0열)
println(grid[1][2])   // 6 (1행 2열)
println(grid[2][1])   // 8 (2행 1열)

grid[1][2]는 "1번째 행의 2번째 열"을 의미한다. 행과 열 모두 0부터 시작한다.

2차원 배열 순회

grid := [
    [1, 2, 3],
    [4, 5, 6],
    [7, 8, 9],
]

for row_idx, row in grid {
    for col_idx, val in row {
        println('[${row_idx}][${col_idx}] = ${val}')
    }
}
// [0][0] = 1
// [0][1] = 2
// ...
// [2][2] = 9

바깥 루프가 행(row)을 순회하고, 안쪽 루프가 각 행의 열(col)을 순회한다.


배열의 강력한 내장 메서드

V 배열의 진짜 힘은 내장 메서드에 있다. 3편에서 배운 고차 함수가 여기서 빛을 발한다. 각 메서드를 하나씩 살펴보자.

map — 모든 원소를 변환

각 원소에 같은 변환을 적용해서 새 배열을 만든다.

numbers := [1, 2, 3, 4, 5]

// 각 원소를 2배로
doubled := numbers.map(it * 2)
println(doubled)   // [2, 4, 6, 8, 10]

// 각 원소를 문자열로
strings := numbers.map('숫자: ${it}')
println(strings)   // ['숫자: 1', '숫자: 2', '숫자: 3', '숫자: 4', '숫자: 5']

it은 V가 제공하는 특별한 변수로, "현재 처리 중인 원소"를 가리킨다. map(it * 2)는 "각 원소(it)에 2를 곱해라"는 뜻이다.

🔍 다른 언어와 비교:

언어 배열 변환
V arr.map(it * 2)
Python [x * 2 for x in arr] (리스트 컴프리헨션)
JavaScript arr.map(x => x * 2)
Go 직접 for 루프를 작성해야 함

V의 it 키워드 덕분에 별도의 매개변수 선언 없이 매우 간결하게 쓸 수 있다.

filter — 조건에 맞는 원소만 골라내기

조건을 만족하는 원소만 남긴 새 배열을 만든다.

numbers := [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

// 짝수만 골라내기
evens := numbers.filter(it % 2 == 0)
println(evens)   // [2, 4, 6, 8, 10]

// 5 초과인 수만
big := numbers.filter(it > 5)
println(big)     // [6, 7, 8, 9, 10]

filter(it % 2 == 0)은 "각 원소(it)를 2로 나눈 나머지가 0인 것만 남겨라"는 뜻이다.

mapfilter 체이닝

mapfilter연속해서 쓸 수 있다. 이것을 체이닝(chaining) 이라고 한다.

numbers := [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

// 짝수만 골라서 → 각각 3배로
result := numbers.filter(it % 2 == 0).map(it * 3)
println(result)   // [6, 12, 18, 24, 30]

왼쪽에서 오른쪽으로 읽으면 된다: "numbers에서 짝수만 걸러내고(filter), 각각 3배로 만들어라(map)."

sort — 정렬

배열을 오름차순으로 정렬한다. 원본 배열을 직접 수정하므로 mut이 필요하다.

mut scores := [85, 42, 97, 63, 71]
scores.sort()
println(scores)   // [42, 63, 71, 85, 97]

내림차순으로 정렬하려면 sort에 비교 조건을 넣는다.

mut scores := [85, 42, 97, 63, 71]
scores.sort(a > b)       // a > b → 내림차순
println(scores)           // [97, 85, 71, 63, 42]

여기서 ab는 V가 제공하는 특별한 변수로, 비교할 두 원소를 나타낸다. a > b는 "큰 것이 앞으로 오게 정렬하라"는 뜻이다.

anyall — 존재/전체 확인

numbers := [2, 4, 6, 7, 8]

// any: 하나라도 조건을 만족하면 true
has_odd := numbers.any(it % 2 != 0)
println(has_odd)    // true (7이 홀수)

// all: 모든 원소가 조건을 만족해야 true
all_positive := numbers.all(it > 0)
println(all_positive)  // true (전부 양수)

all_even := numbers.all(it % 2 == 0)
println(all_even)      // false (7 때문에)
메서드 의미 비유
any "하나라도 있나?" 반에서 안경 쓴 학생이 한 명이라도 있나?
all "전부 그런가?" 반에서 모든 학생이 안경을 썼나?

기타 유용한 메서드

nums := [3, 1, 4, 1, 5, 9]

println(nums.len)          // 6 (배열 길이)
println(nums.first())     // 3 (첫 번째 원소)
println(nums.last())      // 9 (마지막 원소)
println(nums.contains(4)) // true (4가 포함되어 있는가?)
println(nums.index(5))    // 4 (5가 위치한 인덱스)
println(nums.reverse())   // [9, 5, 1, 4, 1, 3] (역순 — 새 배열 반환)

맵(Map)이란?

맵(Map)키(key)-값(value) 쌍으로 데이터를 저장하는 자료구조다.

비유하자면 사전(dictionary) 이다. "사과"라는 단어(키)를 찾으면 "빨간 과일"이라는 뜻(값)이 나온다. 배열처럼 번호(인덱스)가 아니라, 이름(키) 으로 값을 찾는 것이 핵심 차이다.


맵 선언과 사용

리터럴로 선언

ages := {
    '홍길동': 25
    '김영희': 30
    '이철수': 28
}

println(ages['홍길동'])   // 25
println(ages['김영희'])   // 30

중괄호({}) 안에 키: 값 형태로 나열한다. 키와 값의 타입은 V가 자동 추론한다.

빈 맵 선언

mut phone_book := map[string]int{}    // 키: string, 값: int인 빈 맵

map[키타입]값타입{}으로 키와 값의 타입을 명시한다.

값 추가/수정/삭제

mut scores := map[string]int{}

// 추가
scores['국어'] = 95
scores['영어'] = 88
scores['수학'] = 92
println(scores)           // {'국어': 95, '영어': 88, '수학': 92}

// 수정 (같은 키에 새 값 대입)
scores['영어'] = 90
println(scores['영어'])    // 90

// 삭제
scores.delete('수학')
println(scores)            // {'국어': 95, '영어': 90}

존재하지 않는 키에 접근하면?

scores := {
    '국어': 95
    '영어': 88
}

println(scores['체육'])   // 0 (int의 기본값)

없는 키에 접근하면 에러가 나지 않고, 해당 타입의 기본값(zero value) 이 반환된다. int의 기본값은 0, string의 기본값은 ''(빈 문자열), bool의 기본값은 false다.

in으로 키 존재 확인

값이 기본값인지 진짜로 들어있는 것인지 구별하려면 in 연산자를 쓴다. (4편에서 배운 바로 그 in이다.)

scores := {
    '국어': 95
    '영어': 88
}

if '국어' in scores {
    println('국어 점수: ${scores["국어"]}')   // 국어 점수: 95
}

if '체육' !in scores {
    println('체육 점수가 없습니다')            // 체육 점수가 없습니다
}

맵 순회

4편에서 이미 본 for ... in 문법으로 맵을 순회한다.

capitals := {
    '한국': '서울'
    '일본': '도쿄'
    '미국': '워싱턴 D.C.'
}

for country, capital in capitals {
    println('${country}의 수도는 ${capital}')
}
// 한국의 수도는 서울
// 일본의 수도는 도쿄
// 미국의 수도는 워싱턴 D.C.

맵의 유용한 메서드

info := {
    '이름': '홍길동'
    '도시': '서울'
    '직업': '개발자'
}

println(info.len)          // 3 (키-값 쌍의 개수)
println(info.keys())       // ['이름', '도시', '직업']
println(info.values())     // ['홍길동', '서울', '개발자']
메서드/속성 반환값 설명
.len int 키-값 쌍의 개수
.keys() []키타입 모든 키를 배열로 반환
.values() []값타입 모든 값을 배열로 반환
.delete(키) 해당 키-값 쌍 삭제

배열과 맵, 언제 어떤 것을 쓸까?

기준 배열 ([]T) 맵 (map[K]V)
데이터 접근 방식 순서(인덱스) 로 접근 이름(키) 으로 접근
순서 보장 ✅ 삽입 순서 유지 ⚠️ 순서 보장 안 됨
중복 값 중복 가능 키 중복 불가
주 사용 상황 목록, 순서가 중요한 데이터 이름↔값 매핑, 사전, 설정
예시 점수 목록, 할일 리스트 전화번호부, 설정값, 사전

판단 기준은 간단하다: "몇 번째 데이터?"라고 물으면 배열, "이 이름의 데이터?"라고 물으면 맵이다.


📝 정리

이번 글에서 배운 핵심 포인트를 체크리스트로 정리한다.

  • [x] 배열 선언[1, 2, 3] 리터럴 또는 []int{len: 5, init: 0} 초기화
  • [x] 배열 접근arr[0] 인덱싱 (0부터 시작), 자동 경계 검사
  • [x] 배열 수정mut 필수, <<로 원소 추가, arr[i] = 값으로 변경
  • [x] 슬라이싱arr[1..4], arr[..3], arr[2..] (끝값 미포함)
  • [x] 고정 크기 배열[3]int{}, 크기 변경 불가, 스택 할당으로 빠름
  • [x] 내장 메서드map(it*2), filter(it>0), sort(), any(), all() + it 키워드
  • [x] {'키': 값}, map[string]int{}, 키로 값 접근, in으로 존재 확인
  • [x] 맵 순회for key, value in map_var { ... }

🧪 직접 해보기

과제 1: 성적 분석기

학생 5명의 점수 배열이 있다. map, filter, sort를 활용해서 아래를 구현해보자.

fn main() {
    mut scores := [78, 92, 45, 88, 65]

    // 1) 점수를 오름차순으로 정렬하고 출력
    // 2) filter로 80점 이상인 점수만 골라서 출력
    // 3) map으로 각 점수를 10% 상향 조정한 새 배열을 만들고 출력
    // 4) any로 100점 이상이 있는지 확인
}

과제 2: 단어 빈도 카운터

문자열 배열에서 각 단어가 몇 번 나오는지 으로 세는 프로그램을 만들어보자.

fn main() {
    words := ['사과', '바나나', '사과', '딸기', '바나나', '사과']

    // 힌트: map[string]int{} 를 만들고
    // for ... in 으로 words를 순회하면서
    // 각 단어를 키로, 출현 횟수를 값으로 저장

    // 기대 결과:
    // 사과: 3
    // 바나나: 2
    // 딸기: 1
}

다음 편 예고

6편: V 언어 문법 — 구조체(Struct)와 메서드

나만의 데이터 타입을 만들어보자. 여러 종류의 데이터를 하나의 묶음으로 표현하는 구조체, 구조체에 기능을 붙이는 메서드, 접근 제한자, 임베디드 구조체까지 — 6편에서 타입 설계의 세계가 열린다.