Python 강좌 : 제 8강 - List 기초 연산 (1)
목록(List)
Python에서는 목록(List)을 이용하여 다양한 연산이 가능합니다.
목록은 여러 개의 값을 순서대로 담아 두는 자료형입니다. 학생 점수 30개를 변수 30개에 따로 저장하는 대신, 목록 하나에 담아 두면 몇 번째 값이라는 위치로 꺼내 쓰거나 한꺼번에 반복 처리할 수 있습니다.
데이터 형식과는 무관하게 저장할 수 있으며 List 안에 또 다른 List를 포함시킬 수 있습니다.
List는 대괄호([])를 사용하며 순서를 중요시합니다. 또한 +, * 연산 시 원소에 대한 값이 아닌 List 자체에 대한 연산을 실시합니다.
즉, [1, 2] * 2는 각 원소에 2를 곱한 [2, 4]가 아닌, 목록 자체를 반복한 [1, 2, 1, 2]가 됩니다.
목록의 성질은 다음 네 가지로 정리할 수 있습니다. 앞으로 배울 튜플(Tuple), 사전(Dictionary), 집합(Set)도 이 네 가지 기준으로 비교하게 됩니다.
- 변경 가능(Mutable) : 만든 뒤에도 원소를 바꾸거나 추가·삭제할 수 있습니다.
- 순서가 있음 : 원소는 넣은 순서대로
0,1,2, … 번 위치(index)를 가집니다. - 중복 허용 : 같은 값이 여러 번 들어 있어도 됩니다.
- 해시를 사용하지 않음 : 값을 찾을 때 앞에서부터 차례대로 비교합니다. (집합·사전과의 차이점이며, 9강과 13강에서 다시 다룹니다.)
목록은 내부적으로 각 원소(객체)를 가리키는 참조를 연속된 칸에 나란히 저장합니다. 그래서 목록[5]처럼 위치를 알고 있으면 목록이 아무리 길어도 해당 칸으로 바로 이동하여 일정한 시간에 값을 꺼낼 수 있습니다.
반면 목록은 값 자체를 담는 것이 아니라 값을 가리키는 참조를 담기 때문에, 같은 목록을 여러 곳에서 가리키면 한쪽의 변경이 다른 쪽에도 보이는 현상이 생깁니다. 이번 강좌의 반복과 포함 절에서 이 부분을 자세히 확인합니다.
이번 강좌에서는 목록의 생성, 이어 붙이기, 반복, 참조(인덱싱·슬라이싱), 중첩 목록을 다루며, 원소를 추가·삭제·정렬하는 함수는 제 9강에서 다룹니다.
생성
목록을 만드는 가장 기본적인 방법은 값을 쉼표(,)로 구분하여 대괄호([])로 감싸는 것입니다. 이렇게 소스 코드에 값을 직접 적어 만드는 방식을 리터럴(Literal) 표기라 합니다.
a = [1, 2, 3]
b = [4, 5, 6]
print(a)
print(b)- 결과
- [1, 2, 3]
[4, 5, 6]
목록은 대괄호([])와 쉼표(,)를 이용하여 생성이 가능합니다.
print()로 목록을 출력하면 대괄호와 쉼표를 포함하여 목록을 만들 때와 같은 모양으로 표시됩니다.
e = []
f = list(range(5))
g = [1, "Python", 3.14, True, None]
print(e)
print(f)
print(g)- 결과
- []
[0, 1, 2, 3, 4]
[1, ‘Python’, 3.14, True, None]
[]는 빈 목록을 생성하며, list()를 이용하여 range() 등 다른 반복 가능한 객체를 목록으로 변환할 수 있습니다.
빈 목록은 처음에는 값이 없지만 나중에 값을 하나씩 채워 넣을 때 시작점으로 자주 사용합니다.
하나의 목록에 정수, 문자열, 실수, 논리값, None 등 서로 다른 자료형을 함께 저장할 수 있습니다. 목록은 값 자체가 아니라 값을 가리키는 참조를 저장하므로 원소마다 자료형이 달라도 문제가 없습니다.
출력 결과에서 문자열 "Python"이 'Python'처럼 작은따옴표로 표시되는 것은 목록을 출력할 때 원소를 코드에 쓰는 형태(repr)로 보여 주기 때문입니다.
print(list("Python"))
print(list((1, 2, 3)))
try:
list(5)
except TypeError as err:
print(err)- 결과
- [‘P’, ‘y’, ‘t’, ‘h’, ‘o’, ‘n’]
[1, 2, 3]
‘int’ object is not iterable
list(x)는 x에서 원소를 하나씩 꺼내어 새 목록을 만듭니다. 문자열은 문자를 하나씩 꺼낼 수 있으므로 list("Python")은 문자 6개짜리 목록이 됩니다.
튜플 (1, 2, 3)도 원소를 하나씩 꺼낼 수 있으므로 같은 원소를 가진 목록으로 변환됩니다.
반면 정수 5는 원소를 꺼낼 수 있는 객체가 아니므로 TypeError가 발생합니다. 숫자 하나를 담은 목록이 필요하다면 list(5)가 아니라 [5]로 작성해야 합니다.
- Tip :
list("Python")은["Python"]과 다릅니다. 전자는 문자 6개를 담은 목록이고, 후자는 문자열 하나를 담은 목록입니다.
이어 붙이기
두 목록을 하나로 합치거나 목록 뒤에 값을 덧붙여야 할 때는 + 연산자를 사용합니다. 숫자의 +가 덧셈인 것과 달리, 목록의 +는 두 목록의 원소를 순서대로 이어 붙인 목록을 만듭니다.
a = [1, 2, 3]
b = [4, 5, 6]
c = a + b
d = a + [9]
print(c)
print(d)- 결과
- [1, 2, 3, 4, 5, 6]
[1, 2, 3, 9]
목록은 +를 이용하여 목록끼리 합치거나 새로운 값을 추가할 수 있습니다.
a + b는 a의 원소 1, 2, 3 뒤에 b의 원소 4, 5, 6을 차례대로 이어 붙인 [1, 2, 3, 4, 5, 6]이 되며, a + [9]는 a 뒤에 9 하나를 붙인 [1, 2, 3, 9]가 됩니다.
+는 새로운 목록을 생성하므로 a와 b의 값은 변경되지 않습니다. 두 목록의 원소를 모두 새 목록으로 옮겨 담기 때문에, 목록이 길수록 + 연산에 걸리는 시간도 길어집니다.
a = [1, 2, 3]
a += [4]
print(a)
try:
[1, 2] + 3
except TypeError as err:
print(err)- 결과
- [1, 2, 3, 4]
can only concatenate list (not “int”) to list
+=를 사용하면 기존 목록의 뒤에 값을 이어 붙입니다.
+는 목록끼리만 연산할 수 있으므로, 값을 추가할 때는 [9]처럼 대괄호로 감싸야 합니다. 정수를 그대로 더하면 TypeError가 발생합니다.
오류 메시지 can only concatenate list (not "int") to list는 “목록에는 목록만 이어 붙일 수 있는데 정수(int)가 들어왔다”는 뜻입니다.
a = [1, 2, 3]
b = a
a += [4]
print(a, b, a is b)
a = a + [5]
print(a, b, a is b)- 결과
- [1, 2, 3, 4] [1, 2, 3, 4] True
[1, 2, 3, 4, 5] [1, 2, 3, 4] False
a += [4]와 a = a + [4]는 결과가 같아 보이지만 동작 방식이 다릅니다.
b = a는 목록을 복사하지 않고 b도 같은 목록 객체를 가리키게 합니다. is 연산자는 두 변수가 같은 객체를 가리키는지 확인하며, 첫 줄에서 True가 출력됩니다.
+=는 새 목록을 만들지 않고 기존 목록을 직접 늘립니다. 그래서 a에 4를 붙이자 같은 객체를 가리키는 b에도 4가 보입니다.
반면 a = a + [5]는 +로 새 목록을 만든 뒤 그 목록을 a에 다시 연결합니다. 이제 a와 b는 서로 다른 객체이므로, b는 [1, 2, 3, 4]로 남고 a is b는 False가 됩니다.
a = [1, 2]
a += "xy"
print(a)
a += (3, 4)
print(a)- 결과
- [1, 2, ‘x’, ‘y’]
[1, 2, ‘x’, ‘y’, 3, 4]
+는 목록끼리만 연산할 수 있지만, +=는 문자열이나 튜플처럼 원소를 하나씩 꺼낼 수 있는 객체라면 무엇이든 받아 그 원소들을 뒤에 붙입니다.
그래서 a += "xy"는 "xy"라는 문자열 하나가 아니라 'x', 'y' 두 문자가 각각 추가됩니다. 문자열 하나를 통째로 추가하려면 a += ["xy"]처럼 대괄호로 감싸야 합니다.
- Tip :
+=는 9강에서 배우는목록.extend()와 같은 방식으로 동작합니다.
반복
같은 값이 여러 번 들어간 목록이 필요할 때는 * 연산자로 목록을 반복합니다. 예를 들어 길이가 10이고 모든 값이 0인 목록은 [0] * 10으로 간단히 만들 수 있습니다.
a = [1, 2, 3]
b = [4, 5, 6]
print(a * 2)
print(b * 3)- 결과
- [1, 2, 3, 1, 2, 3]
[4, 5, 6, 4, 5, 6, 4, 5, 6]
목록은 *를 이용하여 목록을 반복시켜 목록 자체의 크기가 커집니다.
a * 2는 [1, 2, 3]을 두 번 이어 붙인 길이 6의 목록이고, b * 3은 세 번 이어 붙인 길이 9의 목록입니다. +와 마찬가지로 새로운 목록을 반환하며 원본 a, b는 변하지 않습니다.
print([1, 2] * 0)
print([1, 2] * -1)
print(3 * [0])- 결과
- []
[]
[0, 0, 0]
0이나 음수를 곱하면 오류 대신 빈 목록이 반환됩니다.
3 * [0]처럼 정수를 앞에 써도 같은 결과가 나옵니다. 단, 곱할 수 있는 값은 정수뿐이며 [0] * 2.5처럼 실수를 곱하면 TypeError가 발생합니다.
grid = [[0] * 3] * 3
grid[0][0] = 1
print(grid)
grid = [[0] * 3 for _ in range(3)]
grid[0][0] = 1
print(grid)- 결과
- [[1, 0, 0], [1, 0, 0], [1, 0, 0]]
[[1, 0, 0], [0, 0, 0], [0, 0, 0]]
*는 목록 안의 원소를 복사하지 않고 같은 객체를 반복해서 참조합니다.
그래서 [[0] * 3] * 3으로 만든 2차원 목록은 세 개의 행이 모두 같은 목록이므로, 하나의 값을 변경하면 모든 행이 함께 변경됩니다.
첫 번째 결과를 보면 grid[0][0]만 바꿨는데 세 행의 첫 칸이 모두 1이 되었습니다. 겉보기에는 행이 세 개지만 실제로는 하나의 행 목록을 세 번 가리키고 있기 때문입니다.
[0] * 3처럼 정수나 문자열 같은 변경할 수 없는 값을 반복할 때는 이 문제가 드러나지 않습니다. 값을 “바꾸는” 것은 그 칸이 다른 객체를 가리키게 하는 것이기 때문입니다. 문제는 목록처럼 변경 가능한 객체를 *로 반복할 때 생깁니다.
2차원 목록을 만들 때는 [[0] * 3 for _ in range(3)]처럼 목록 내포(List Comprehension)를 사용하여 행마다 새로운 목록을 생성합니다. 두 번째 결과처럼 첫 번째 행만 변경되는 것을 확인할 수 있습니다.
- Tip :
for를 사용한 목록 내포는 반복문 강좌에서 자세히 다룹니다.
참조
목록에 저장된 값을 꺼내는 방법은 두 가지입니다. 원소 하나를 꺼내는 인덱싱(Indexing)과, 원소 여러 개를 구간으로 잘라 내는 슬라이싱(Slicing)입니다.
a = [1, 2, 3]
b = [4, 5, 6]
c = [1, 2, 3, 4, 5, 6, 7, 8, 9]
print(a[0])
print(a[1])
print(a[2])
print(a[-1])
print(b[0:1])
print(b[0:-1])
print(c[0:-1:2])- 결과
- 1
2
3
3
[4]
[4, 5]
[1, 3, 5, 7]
목록은 대괄호([])와 index를 이용하여 목록을 참조할 수 있으며, :을 이용하여 목록의 일부를 잘라낼(Slicing) 수 있습니다.
목록[index]를 사용하면 index의 값을 출력합니다.
목록[start:end]를 사용하면 start부터 end 직전까지 출력합니다. end 위치의 값은 포함되지 않습니다.
목록[start:end:step]를 사용하면 start부터 end 직전까지 step 간격만큼 출력합니다.
좌측의 첫 번째 값은 0이며, 우측의 첫 번째 값은 -1을 사용하여 출력이 가능합니다.
그래서 b[0:-1]은 마지막 값 6을 제외한 [4, 5]가 되며, c[0:-1:2]는 마지막 값 9를 제외하고 두 칸씩 건너뛴 [1, 3, 5, 7]이 됩니다.
목록 b = [4, 5, 6]의 위치 번호를 표로 나타내면 다음과 같습니다.
| 원소 | 4 | 5 | 6 |
|---|---|---|---|
| 양수 index | 0 | 1 | 2 |
| 음수 index | -3 | -2 | -1 |
음수 index는 len(목록) + index로 바뀌어 계산됩니다. 길이가 3인 목록에서 -1은 3 + (-1) = 2번 위치, 즉 마지막 원소입니다.
인덱싱과 슬라이싱의 결과 형태가 다르다는 점도 중요합니다. b[0]은 원소 4 자체를 반환하지만, b[0:1]은 원소가 하나뿐이어도 [4]처럼 목록을 반환합니다.
- Tip : 슬라이싱의
start:end를 “칸과 칸 사이의 경계”로 생각하면 이해하기 쉽습니다.0:2는 0번 경계부터 2번 경계까지이므로0번과1번 원소, 즉end - start = 2개의 원소를 잘라 냅니다.
c = [1, 2, 3, 4, 5, 6, 7, 8, 9]
print(c[:3])
print(c[6:])
print(c[:])
print(c[::2])
print(c[::-1])
print(c[-3:])
print(c[5:100])- 결과
- [1, 2, 3]
[7, 8, 9]
[1, 2, 3, 4, 5, 6, 7, 8, 9]
[1, 3, 5, 7, 9]
[9, 8, 7, 6, 5, 4, 3, 2, 1]
[7, 8, 9]
[6, 7, 8, 9]
start를 생략하면 처음부터, end를 생략하면 끝까지 잘라냅니다. c[:]는 목록 전체를 복사한 새로운 목록을 반환합니다.
c[:3]은 앞에서 세 개, c[6:]는 6번 위치부터 끝까지, c[-3:]는 뒤에서 세 개를 잘라 냅니다. “앞에서 n개”는 [:n], “뒤에서 n개”는 [-n:]으로 기억하면 편리합니다.
step에 음수를 사용하면 역방향으로 잘라내므로, c[::-1]은 뒤집힌 목록을 반환합니다.
c[5:100]처럼 잘라내는 범위가 목록의 길이를 넘어가도 오류 없이 존재하는 값까지만 반환합니다.
c = [1, 2, 3, 4, 5, 6, 7, 8, 9]
try:
c[100]
except IndexError as err:
print(err)- 결과
- list index out of range
반면 목록[index]로 존재하지 않는 위치를 참조하면 IndexError가 발생합니다.
슬라이싱은 “해당 구간에 있는 원소들”을 돌려주므로 구간이 비어 있으면 빈 목록을 반환하면 되지만, 인덱싱은 반드시 원소 하나를 돌려주어야 하므로 그 위치에 원소가 없으면 오류를 낼 수밖에 없습니다.
길이가 n인 목록에서 사용할 수 있는 index는 0부터 n-1까지, 음수로는 -n부터 -1까지입니다. 특히 마지막 원소를 꺼내려고 목록[len(목록)]을 사용하는 실수가 잦으므로, 마지막 원소는 목록[-1]로 참조합니다.
a = [1, 2, 3]
b = a
c = a[:]
print(b is a, c is a, c == a)
a[0] = 100
print(b)
print(c)- 결과
- True False True
[100, 2, 3]
[1, 2, 3]
슬라이싱은 항상 새로운 목록을 만듭니다. 그래서 a[:]는 목록을 복사하는 가장 간단한 방법입니다.
b = a는 같은 객체를 가리키므로 b is a가 True이고, c = a[:]는 내용은 같지만 다른 객체이므로 c is a는 False, c == a는 True입니다.
==는 내용(값)이 같은지, is는 같은 객체인지를 비교합니다. 이후 a[0]을 변경하면 같은 객체인 b에는 변경이 보이고, 복사본인 c는 원래 값을 유지합니다.
포함
목록의 원소로 다시 목록을 넣으면 표(행과 열)처럼 2차원 구조의 데이터를 표현할 수 있습니다. 이를 중첩 목록(Nested List)이라 합니다.
a = [1, 2, 3]
b = [4, 5, 6]
c = [a, b]
print(c)- 결과
- [[1, 2, 3], [4, 5, 6]]
목록안에 목록을 포함시켜 목록을 포함한 목록 또한 생성이 가능합니다.
c는 원소가 a와 b 두 개인 목록입니다. 겉에 있는 목록의 길이는 안쪽 원소의 개수와 상관없이 len(c) == 2입니다.
a = [1, 2, 3]
b = [4, 5, 6]
c = [a, b]
print(c[1][0])
a[0] = 100
print(c)- 결과
- 4
[[100, 2, 3], [4, 5, 6]]
c[1][0]처럼 대괄호를 이어서 사용하면 안쪽 목록의 값을 참조할 수 있습니다. c[1]은 b이며, b[0]은 4입니다.
대괄호는 왼쪽부터 차례대로 계산됩니다. c[1]이 먼저 계산되어 목록 [4, 5, 6]이 되고, 그 결과에 [0]을 적용하여 4를 얻습니다. 2차원 목록에서는 목록[행][열] 순서로 읽으면 됩니다.
목록 c는 a와 b의 값을 복사하지 않고 같은 목록 객체를 참조합니다. 그래서 a의 값을 변경하면 c의 내용도 함께 변경됩니다.
import copy
a = [[1, 2], [3, 4]]
b = a[:]
c = copy.deepcopy(a)
a[0][0] = 100
print(b)
print(c)- 결과
- [[100, 2], [3, 4]]
[[1, 2], [3, 4]]
중첩 목록을 복사할 때는 주의가 필요합니다. a[:]는 바깥 목록만 새로 만들고, 안쪽 목록은 원본과 같은 객체를 그대로 가리킵니다. 이를 얕은 복사(Shallow Copy)라 합니다.
그래서 a[0][0]을 변경하면 얕은 복사본 b에서도 [[100, 2], [3, 4]]로 변경된 값이 보입니다.
안쪽 목록까지 모두 새로 복사하려면 copy 모듈의 copy.deepcopy()를 사용합니다. 이를 깊은 복사(Deep Copy)라 하며, c는 원본이 바뀌어도 [[1, 2], [3, 4]]를 유지합니다.
- Tip : 안쪽에 목록이 없는 1차원 목록이라면 얕은 복사만으로 충분합니다. 원소가 정수·문자열처럼 변경할 수 없는 값이기 때문입니다.
정리
| 이름 | 설명 | 반환값/특징 |
|---|---|---|
[a, b, c] |
리터럴로 목록 생성 | 새 목록 |
list(x) |
반복 가능한 객체의 원소로 목록 생성 | 새 목록, 정수 등은 TypeError |
목록1 + 목록2 |
두 목록을 이어 붙임 | 새 목록, 원본 유지 |
목록 += x |
원본 뒤에 x의 원소를 붙임 |
원본 변경, 반복 가능한 객체 허용 |
목록 * n |
목록을 n번 반복 |
새 목록, 원소는 복사하지 않고 참조 |
목록[i] |
i번 위치의 원소 |
원소 자체, 범위 밖이면 IndexError |
목록[start:end:step] |
구간을 잘라 냄 | 새 목록(얕은 복사), 범위 밖이어도 오류 없음 |
목록[i][j] |
중첩 목록의 안쪽 원소 | 왼쪽 대괄호부터 차례로 계산 |
copy.deepcopy(x) |
안쪽 객체까지 모두 복사 | 새 객체 |
목록은 변경 가능하고, 순서가 있으며, 중복을 허용하는 자료형입니다. index로 원소 하나를 꺼내는 것은 위치만 계산하면 되므로 목록의 길이와 상관없이 빠릅니다.
+, *, 슬라이싱은 새 목록을 만들고, +=는 원본을 변경합니다. 목록은 값이 아니라 참조를 담는다는 점을 기억하면 * 반복과 중첩 목록 복사에서 생기는 의외의 결과를 이해할 수 있습니다.
댓글 남기기