PHpullh

LANGUAGE COMPARISON

List를 언어별로 비교하기

같은 개념을 실제 예제로 나란히 확인하고, 각 언어의 개별 설명으로 이어갈 수 있습니다.

같은 이름, 다른 자료구조

"리스트"라는 한 단어가 언어마다 전혀 다른 것을 가리킵니다. Python의 list와 Java의 ArrayList, Go의 슬라이스는 모두 연속된 메모리 위에 얹힌 가변 길이 배열이고, Java의 LinkedList와 Go의 container/list는 노드를 포인터로 엮은 연결 리스트입니다. 겉으로는 같은 인터페이스처럼 보이지만 인덱스 접근 비용과 중간 삽입 비용이 정반대입니다. Kotlin의 List는 또 다른 층위로, 자료구조가 아니라 읽기 전용 인터페이스입니다. PHP의 배열은 이름만 배열이고 실제로는 순서를 기억하는 해시 테이블이라 리스트와 맵의 역할을 겸합니다.

그래서 이 비교에서 먼저 확인해야 할 것은 문법이 아니라 세 가지 성질입니다. 첫째, 대입할 때 복사되는가 공유되는가. 둘째, 길이가 늘어날 때 무슨 일이 일어나는가. 셋째, 불변이라고 표시된 것이 정말 불변인가 아니면 못 바꾸게 막아 둔 뷰일 뿐인가. 이 세 가지가 다른 언어로 코드를 옮길 때 조용히 결과를 바꿉니다.

Kotlin

List, Set, Map 생성과 기초

불변/가변 컬렉션 생성 함수와 기본 조작 방법을 익힙니다.

// 불변 컬렉션
val list = listOf(1, 2, 3, 4, 5)
val set  = setOf("a", "b", "c", "b")    // 중복 제거
val map  = mapOf("one" to 1, "two" to 2)

// 가변 컬렉션
val mList = mutableListOf(1, 2, 3)
mList.add(4)
mList.removeAt(0)
mList[0] = 99

val mMap = mutableMapOf("a" to 1)
mMap["b"] = 2
mMap.remove("a")

// 읽기 안전
println(map["one"])          // 1 (Int?)
println(map.getOrDefault("three", 0))  // 0
println(map.getOrElse("three") { -1 }) // -1

// 컬렉션 + 연산
val combined = list + listOf(6, 7)     // 새 리스트 반환
val filtered = list - listOf(2, 4)     // [1, 3, 5]

// 배열
val arr = arrayOf(1, 2, 3)
val intArr = intArrayOf(1, 2, 3)       // primitive 배열
println(intArr.sum())                   // 6

Python

불변 컬렉션

Python에서 불변 컬렉션을 활용하여 안전한 데이터 구조를 만드는 방법입니다.

from types import MappingProxyType
from dataclasses import dataclass, field

# MappingProxyType: 읽기 전용 딕셔너리 뷰
original = {"a": 1, "b": 2, "c": 3}
readonly = MappingProxyType(original)
print(readonly["a"])      # 1
# readonly["a"] = 10      # TypeError!

# 원본 변경은 뷰에 반영됨
original["d"] = 4
print(readonly["d"])      # 4

# 불변 데이터클래스
@dataclass(frozen=True)
class Config:
    host: str
    port: int
    tags: tuple[str, ...] = ()  # 불변 시퀀스

    def with_port(self, new_port: int) -> "Config":
        """변경된 복사본 반환"""
        return Config(self.host, new_port, self.tags)

cfg = Config("localhost", 8080, ("web", "api"))
# cfg.port = 9090  # FrozenInstanceError

cfg2 = cfg.with_port(9090)
print(f"{cfg.port} → {cfg2.port}")  # 8080 → 9090

# 해시 가능 (딕셔너리 키로 사용 가능)
cache = {cfg: "기본 설정", cfg2: "새 설정"}
print(cache[cfg])  # 기본 설정

Go

슬라이스 — Go의 핵심 자료구조

슬라이스는 동적 배열입니다. 내부적으로 배열 포인터, 길이, 용량을 갖습니다.

package main

import "fmt"

func main() {
	// 슬라이스 생성
	s1 := []int{1, 2, 3, 4, 5}
	s2 := make([]int, 5)       // len=5, cap=5, 제로값
	s3 := make([]int, 3, 10)   // len=3, cap=10

	// append — 용량 초과 시 새 배열 할당 (약 2배)
	s := []int{1, 2, 3}
	s = append(s, 4)
	s = append(s, 5, 6, 7)       // 여러 값
	s = append(s, s1...)          // 슬라이스 전개

	// 슬라이싱 [low:high:max]
	fmt.Println(s[1:4])   // [2 3 4]
	fmt.Println(s[:3])    // [1 2 3]
	fmt.Println(s[3:])    // [4 5 6 7 1 2 3 4 5]

	// copy — 독립적인 복사
	dst := make([]int, 3)
	n := copy(dst, s1)
	fmt.Println(dst, n)   // [1 2 3] 3

	// 2D 슬라이스
	matrix := make([][]int, 3)
	for i := range matrix {
		matrix[i] = make([]int, 3)
		for j := range matrix[i] { matrix[i][j] = i*3 + j }
	}

	// 슬라이스 함수들 (Go 1.21+)
	// slices.Sort, slices.Contains, slices.Index
	// import "slices"

	// 삭제 — 순서 유지
	i := 2
	s1 = append(s1[:i], s1[i+1:]...)
	fmt.Println(s1) // [1 2 4 5]

	// 삭제 — 순서 무관 (빠름)
	s2[i] = s2[len(s2)-1]
	s2 = s2[:len(s2)-1]

	fmt.Println(len(s3), cap(s3)) // 3 10
	_ = s2
}

Java

불변 컬렉션 (List.of, Map.of)

Java 9+의 팩토리 메서드로 불변 컬렉션을 생성하고 활용합니다.

import java.util.*;

public class ImmutableCollections {
    public static void main(String[] args) {
        // List.of — 불변 리스트
        List<String> list = List.of("A", "B", "C");
        // list.add("D"); // UnsupportedOperationException

        // Set.of — 불변 집합
        Set<Integer> set = Set.of(1, 2, 3);
        // 중복 시 IllegalArgumentException

        // Map.of — 최대 10개 엔트리
        Map<String, Integer> map = Map.of("a", 1, "b", 2);

        // Map.ofEntries — 10개 초과
        Map<String, Integer> large = Map.ofEntries(
            Map.entry("key1", 1),
            Map.entry("key2", 2),
            Map.entry("key3", 3)
        );

        // 기존 컬렉션을 불변으로 복사
        List<String> mutable = new ArrayList<>(List.of("X", "Y"));
        List<String> immutable = List.copyOf(mutable);
        mutable.add("Z"); // 원본은 변경 가능
        System.out.println(mutable);    // [X, Y, Z]
        System.out.println(immutable);  // [X, Y] (영향 없음)

        // Collections.unmodifiable* vs List.of
        // unmodifiable: 래퍼 (원본 변경 반영)
        // List.of/copyOf: 독립 복사본
    }
}

JavaScript

배열 생성과 순회

배열 대신 정수 변수 하나와 조건식이 전부입니다. Rust의 배열 [i32; 4]는 길이가 타입에 박혀 있어 컴파일 시점에 크기가 정해지고, 길이가 변하는 자료는 Vec을 씁니다. 범위를 벗어난 인덱스 접근은 실행 중 패닉이므로, 실패를 값으로 받고 싶으면 get이 돌려주는 Option을 씁니다.

// 배열 생성과 순회
const values = [3, 1, 4, 15];
console.log([...values].sort((a, b) => a - b));

TypeScript

배열 생성과 순회

배열 대신 정수 변수 하나와 조건식이 전부입니다. Rust의 배열 [i32; 4]는 길이가 타입에 박혀 있어 컴파일 시점에 크기가 정해지고, 길이가 변하는 자료는 Vec을 씁니다. 범위를 벗어난 인덱스 접근은 실행 중 패닉이므로, 실패를 값으로 받고 싶으면 get이 돌려주는 Option을 씁니다.

// 배열 생성과 순회
const values: number[] = [3, 1, 4, 15];
console.log([...values].sort((a, b) => a - b));

C#

배열 생성과 순회

배열 대신 정수 변수 하나와 조건식이 전부입니다. Rust의 배열 [i32; 4]는 길이가 타입에 박혀 있어 컴파일 시점에 크기가 정해지고, 길이가 변하는 자료는 Vec을 씁니다. 범위를 벗어난 인덱스 접근은 실행 중 패닉이므로, 실패를 값으로 받고 싶으면 get이 돌려주는 Option을 씁니다.

// 배열 생성과 순회
using System;

var count = 15;
Console.WriteLine(count > 5 ? "big" : "small");

C++

배열 생성과 순회

배열 대신 정수 변수 하나와 조건식이 전부입니다. Rust의 배열 [i32; 4]는 길이가 타입에 박혀 있어 컴파일 시점에 크기가 정해지고, 길이가 변하는 자료는 Vec을 씁니다. 범위를 벗어난 인덱스 접근은 실행 중 패닉이므로, 실패를 값으로 받고 싶으면 get이 돌려주는 Option을 씁니다.

// 배열 생성과 순회
#include <iostream>

int main() {
    int count = 15;
    std::cout << (count > 5 ? "big" : "small") << "\n";
}

Rust

배열 생성과 순회

배열 대신 정수 변수 하나와 조건식이 전부입니다. Rust의 배열 [i32; 4]는 길이가 타입에 박혀 있어 컴파일 시점에 크기가 정해지고, 길이가 변하는 자료는 Vec을 씁니다. 범위를 벗어난 인덱스 접근은 실행 중 패닉이므로, 실패를 값으로 받고 싶으면 get이 돌려주는 Option을 씁니다.

// 배열 생성과 순회
fn main() {
    let count = 15;
    let label = if count > 5 { "big" } else { "small" };
    println!("{}", label);
}

대입은 복사인가 공유인가

가장 비싼 오해가 여기서 나옵니다. Python에서 b = a는 같은 리스트 객체에 이름을 하나 더 붙일 뿐이라, b.append(1)이 a에도 보입니다. Java도 마찬가지로 참조가 복사됩니다. 반면 PHP의 배열은 값 의미론을 따라, 대입하면 논리적으로 복사된 것처럼 동작합니다. 내부적으로는 쓰기 전까지 복사를 미루지만 관찰되는 결과는 독립된 두 배열입니다. Python 습관으로 PHP를 쓰면 "왜 함수 안에서 바꾼 게 반영이 안 되지"라고 묻게 되고, 반대 방향에서는 "왜 원본이 같이 바뀌지"라고 묻게 됩니다.

Go의 슬라이스는 이 둘 중 어느 쪽도 아닌 세 번째 모델입니다. 슬라이스 값 자체는 복사되지만 그 안의 포인터가 같은 배열을 가리키므로, 원소를 바꾸면 양쪽에 보입니다. 여기에 append가 붙으면 더 미묘해집니다. 남은 용량이 있으면 원래 배열에 그대로 쓰고, 부족하면 새 배열을 잡아 옮깁니다. 즉 같은 코드가 용량 상태에 따라 원본을 건드리기도 하고 안 건드리기도 합니다. 슬라이스를 잘라서 함수에 넘긴 뒤 그 함수가 append를 하면, 호출자의 데이터가 덮어써질 수 있습니다. 잘라 낸 조각을 오래 들고 있어야 한다면 명시적으로 copy로 떼어 내는 편이 안전합니다.

"불변"이라는 단어의 세 가지 뜻

  • 읽기 전용 뷰 — Kotlin의 List는 변경 메서드가 없을 뿐, 같은 데이터를 가리키는 MutableList가 어딘가에 남아 있으면 내용은 바뀝니다. Java의 Collections.unmodifiableList도 같은 성격입니다.
  • 변경 시 예외 — Java List.of(...)로 만든 리스트는 add를 호출하면 컴파일은 통과하고 실행 중에 UnsupportedOperationException이 납니다. 타입이 아니라 런타임이 막는 방식입니다.
  • 진짜 불변 — Python의 튜플처럼 애초에 변경 연산이 존재하지 않는 자료형입니다. 다만 튜플 안에 리스트가 들어 있으면 그 안쪽은 여전히 바뀝니다. 얕은 불변과 깊은 불변은 다릅니다.

순회 중 수정은 네 언어 모두에서 문제를 일으키지만 증상이 다릅니다. Java는 ConcurrentModificationException으로 즉시 실패해 그나마 친절합니다. Python은 예외 없이 원소를 건너뛰어 조용히 틀린 결과를 냅니다. Go는 range가 시작 시점의 길이를 기준으로 돌아 중간에 추가한 원소를 보지 못합니다. PHP는 참조 순회 여부에 따라 동작이 갈립니다. 지우면서 순회해야 한다면 새 리스트를 만들어 담는 쪽이 모든 언어에서 안전합니다.

성능 판단은 자료구조 이름이 아니라 접근 패턴에서 나옵니다. 중간 삽입이 많다고 무조건 연결 리스트가 유리하지는 않습니다. 삽입 지점을 찾는 데 이미 순회가 필요하고, 노드가 메모리에 흩어져 있어 캐시 지역성이 나쁘기 때문입니다. 실제로 대부분의 코드에서는 연속 배열 계열이 기본 선택이고, 연결 리스트는 이미 손에 쥔 노드를 O(1)로 떼어 내야 하는 큐·LRU 같은 경우에 값을 합니다. 언어별 컬렉션 API 전체는 Kotlin 컬렉션, Java 컬렉션, Go 컬렉션, Python 컬렉션 문서에서 이어 보실 수 있습니다.

용량과 재할당

가변 길이 배열은 원소를 추가하다가 공간이 모자라면 더 큰 메모리를 새로 잡고 기존 내용을 옮깁니다. 이 재할당은 한 번에 대략 두 배씩 늘리는 전략을 쓰기 때문에 평균적으로는 저렴하지만, 개별 호출 하나는 갑자기 비싸질 수 있습니다. 지연 시간에 민감한 코드에서 간헐적인 스파이크의 원인이 되기도 합니다.

최종 크기를 미리 알고 있다면 그만큼의 용량을 처음부터 잡아 두는 편이 낫습니다. Go의 make([]T, 0, n), Java의 new ArrayList<>(n), C++의 reserve가 그 자리에 있습니다. 여기서 길이와 용량을 혼동하면 조용한 버그가 생깁니다. Go에서 make([]int, n)은 길이가 n인, 즉 제로값이 n개 들어 있는 슬라이스를 만들며, 여기에 append를 하면 n개 뒤에 붙습니다. 앞의 n개가 0으로 채워진 결과가 나오는 이유입니다. Python과 PHP에는 용량을 직접 지정하는 API가 없고 런타임이 알아서 처리합니다.