게임 속 캐릭터를 화면에 그리기까지 꼭짓점 하나하나는 여러 번 옮겨집니다. 캐릭터 크기에 맞게 늘이고, 바라보는 방향으로 돌리고, 카메라 시점에 맞게 다시 돌립니다. 꼭짓점이 백만 개라면 변환 세 번을 백만 번씩, 모두 삼백만 번 계산해야 할까요?
그럴 필요가 없습니다. 선형변환 두 개를 차례로 하면 그 결과도 하나의 선형변환이고, 따라서 행렬 하나로 적을 수 있습니다. 세 변환을 미리 행렬 하나로 합쳐 두면 꼭짓점마다 곱셈은 한 번이면 됩니다. 그래픽스 엔진이 실제로 이렇게 일합니다.
두 행렬을 합쳐 "차례로 한 변환"의 행렬을 만드는 계산이 행렬곱입니다. 이번 교시에는 행렬곱을 공식부터 외우지 않고, 5교시의 그림("열은 î와 ĵ가 도착한 곳")에서 끌어냅니다. 그러면 공식도, "순서를 바꾸면 안 된다"는 규칙도 저절로 이해됩니다.
변환 를 먼저 하고, 그 결과에 변환 를 하면 벡터 는 이렇게 옮겨 갑니다.
가 끝났을 때 격자선은 여전히 곧고 평행하고 고르게 놓여 있습니다. 그 격자에 를 해도 마찬가지입니다. 그러니 두 단계를 합친 것도 선형변환이고, 5교시에 따라 행렬 하나로 적을 수 있습니다. 이 행렬을 라고 씁니다.
여기서 꼭 익숙해져야 할 점이 있습니다. 먼저 한 변환 가 오른쪽에 옵니다. 는 벡터 에 가까운 쪽부터, 즉 오른쪽에서 왼쪽으로 읽습니다. 수학 시간에 본 함수 합성 가 "먼저 , 그다음 "인 것과 같습니다. 파이썬으로 쓰면 A(B(v))이고, 안쪽 괄호부터 계산하는 것과 같습니다.
아래 실습대의 기본값은 "먼저 할 변환 B"가 90° 회전, "그다음 할 변환 A"가 밀기입니다. 오른쪽에 B와 A가 나란히 있고, 그 아래 "먼저 B, 그다음 A = AB" 칸에 둘을 합친 행렬이 나옵니다.
순서를 바꾸면 결과가 달라집니다: AB ≠ BA
NumPy로 확인하기
실습대에서 값을 정할 때마다, 서버의 NumPy가 같은 숫자로 계산한 결과입니다.
>>> A = np.array([[1, 1], [0, 1]])
>>> B = np.array([[0, -1], [1, 0]])
>>> A @ B
array([[ 1, -1],
[ 1, 0]])
>>> B @ A
array([[ 0, -1],
[ 1, 1]])
>>> np.allclose(A @ B, B @ A)
False
>>> A * B
array([[ 0, -1],
[ 0, 0]])'차례로 재생'을 누르면 격자가 먼저 시계 반대 방향으로 90° 돌고(B), 잠깐 멈춘 뒤 비스듬히 밀립니다(A). 마지막 모습에서 청록 î는 (1, 1)에, 진홍 ĵ는 (−1, 0)에 있습니다. 5교시에서 배운 대로 이 둘을 세로로 세워 붙이면 AB가 됩니다.
정말 그런지 숫자로 따라가 봅시다.
그러니 AB의 1열은 (1, 1), 2열은 (−1, 0)이고, 실습대 오른쪽 칸 및 NumPy 칸의 A @ B와 같습니다.
방금 한 일을 일반적인 말로 정리하면 행렬곱 계산법이 됩니다.
의 1열이 "를 한 뒤 가 있는 곳"이니, 거기에 를 하면 최종 도착지입니다. 한 문장으로 줄이면 이렇습니다. 의 열은 곱하기 (의 열)이다.
의 1열을 , 2열을 라고 쓰면 이렇습니다.
6-1의 예에 적용해 봅시다. 은 5교시의 열 결합으로 계산합니다.
손으로 계산할 때는 5교시의 "행과의 내적"을 쓰면 빠릅니다. 의 행 열 성분은 의 행과 의 열의 내적입니다. 일반적인 로 쓰면 이렇습니다.
예를 들어 왼쪽 위 성분 는 의 1행 와 의 1열 의 내적입니다. 왼손 손가락으로 의 행을 가로로 훑고, 오른손 손가락으로 의 열을 세로로 훑으며 곱해 더한다고 기억하면 됩니다.
정사각형이 아닌 행렬도 곱할 수 있습니다. 조건은 하나, 의 열 개수와 의 행 개수가 같아야 합니다. 행과 열의 내적을 하려면 둘의 길이가 같아야 하기 때문입니다.
안쪽 두 숫자 이 같아야 하고, 결과의 모양은 바깥쪽 두 숫자 가 됩니다. 5교시에서 행렬에 꼭짓점 4개를 담은 행렬을 곱해 를 얻은 것도 이 규칙입니다.
실습대에서 '순서 바꾸기'를 켜면 "먼저 A, 그다음 B = BA" 칸이 나옵니다. 먼저 밀고 나중에 돌리면 î는 (0, 1)로, ĵ는 (−1, 1)로 가서 결과가 [[0, −1], [1, 1]]입니다. 아까의 [[1, −1], [1, 0]]과 다릅니다. 상태 문구도 "순서를 바꾸면 결과가 달라집니다: AB ≠ BA"로 바뀝니다.
숫자의 곱셈은 2 × 3 = 3 × 2이지만, 행렬곱은 대개 순서를 바꾸면 결과가 달라집니다. 일상에서도 흔한 일입니다. 양말을 신고 신발을 신는 것과, 신발을 신고 양말을 신는 것은 결과가 다릅니다. 사진 편집에서 "돌린 뒤 가로로 늘이기"와 "가로로 늘인 뒤 돌리기"도 다른 그림을 만듭니다.
실습대의 칩 다섯 개로 만들 수 있는 짝을 모두 확인해 보면 이렇습니다.
| B와 A의 짝 | 순서를 바꾸면 | 왜 |
|---|---|---|
| 그대로 + 아무거나 | 같다 | '그대로'는 아무 일도 하지 않음 |
| 가로로 늘이기 + 좌우 뒤집기 | 같다 | 둘 다 가로 방향만 건드림 |
| 90° 회전 + 90° 회전 | 같다 | 같은 변환끼리는 언제나 같음 |
| 90° 회전 + 가로로 늘이기 | 다르다 | 돌린 뒤 늘이면 원래의 세로 방향이 늘어남 |
| 90° 회전 + 밀기 | 다르다 | 기본값의 예 |
| 90° 회전 + 좌우 뒤집기 | 다르다 | 뒤집으면 도는 방향이 반대가 됨 |
| 가로로 늘이기 + 밀기 | 다르다 | 미는 양이 늘어나는지가 순서에 달림 |
| 밀기 + 좌우 뒤집기 | 다르다 | 미는 방향이 순서에 따라 반대가 됨 |
순서를 바꿔도 같은 짝도 있지만, 그것은 특별한 경우입니다. 행렬곱을 만나면 "기본은 순서를 바꾸면 안 된다"고 생각하세요.
항등행렬(identity matrix) 는 실습대의 '그대로', 아무것도 바꾸지 않는 변환입니다. 는 로, 는 로 가니 열이 , 입니다. 아무 일도 하지 않는 변환을 앞에 하든 뒤에 하든 결과는 같습니다.
숫자의 곱셈에서 1이 하는 역할을 행렬에서는 가 합니다. 7교시의 역행렬은 "곱하면 가 되는 행렬"로 정의됩니다.
변환이 세 개라면 어떨까요? 먼저 , 그다음 , 마지막에 를 하는 변환은 입니다. 이것을 로 계산하든 로 계산하든 결과가 같습니다. 이것이 결합법칙입니다.
괄호를 어디에 치든 "먼저 , 그다음 , 마지막에 "라는 순서는 그대로이기 때문입니다. 바뀌는 것은 "어느 두 개를 먼저 합쳐 두느냐"뿐입니다. 순서(와 )는 바꾸면 안 되지만 괄호는 마음대로 옮겨도 된다는 점을 구분해 두세요. 도입에서 말한 그래픽스 엔진의 요령이 바로 이것입니다. 꼭짓점마다 를 계산하는 대신 를 한 번 만들어 두고 만 계산합니다.
실습대의 '90° 회전'을 일반적인 각도 로 넓혀 봅시다. 3교시에서 본 와 그 짝인 를 씁니다. 원점에서 길이 1인 화살표를 가로축에서 시계 반대 방향으로 만큼 돌리면, 끝점의 좌표가 입니다.
도착한 곳을 세로로 세워 붙이면 회전 행렬입니다.
를 넣으면 , 이라 실습대의 이 나옵니다.
회전을 두 번 하면 각도가 더해집니다. 만큼 돌린 뒤 만큼 더 돌리면 만큼 돈 것입니다.
실습대에서 B와 A를 모두 '90° 회전'으로 고르면 결과가 , 즉 180° 회전이 됩니다. 회전끼리는 순서를 바꿔도 결과가 같다는 것도 이 식에서 바로 보입니다. 이니까요.
실습대의 NumPy 칸은 A와 B를 만든 뒤 A @ B, B @ A, np.allclose(A @ B, B @ A), A * B를 차례로 보여 줍니다. 한 줄씩 직접 해 봅시다.
@: 행렬곱5교시에서 행렬과 벡터를 곱할 때 쓴 @가 행렬끼리의 곱에도 그대로 쓰입니다.
>>> import numpy as np
>>> A = np.array([[1, 1], [0, 1]]) # 밀기
>>> B = np.array([[0, -1], [1, 0]]) # 90° 회전
>>> A @ B
array([[ 1, -1],
[ 1, 0]])
>>> B @ A
array([[ 0, -1],
[ 1, 1]])A @ B는 "먼저 B, 그다음 A"입니다. 파이썬 코드도 책의 기호와 같은 순서로 적으니, 읽을 때는 똑같이 오른쪽부터 읽습니다. # 뒤는 파이썬의 주석이라 실행에 영향을 주지 않습니다.
6-2의 "AB의 열은 A 곱하기 (B의 열)"도 확인해 봅시다. 5교시에서 배운 B[:, 0]으로 B의 열을 꺼내 A로 옮긴 뒤, np.column_stack으로 다시 열로 쌓으면 A @ B와 같습니다.
>>> np.column_stack([A @ B[:, 0], A @ B[:, 1]])
array([[ 1, -1],
[ 1, 0]])벡터 하나를 두 단계로 옮긴 것과, 합친 행렬로 한 번에 옮긴 것도 같습니다.
>>> v = np.array([2, 1])
>>> A @ (B @ v)
array([1, 2])
>>> (A @ B) @ v
array([1, 2])@는 np.matmul 함수와 같은 일을 합니다. 오래된 코드에서는 np.dot(A, B)도 자주 보입니다. 2차원 배열끼리라면 셋 다 같은 결과를 줍니다.
>>> np.matmul(A, B)
array([[ 1, -1],
[ 1, 0]])np.allclose: 두 행렬이 같은가A @ B == B @ A라고 쓰면 칸마다 비교한 True/False 행렬이 나옵니다. "전부 같은가"를 한 번에 알고 싶을 때는 np.allclose를 씁니다. 3교시의 np.isclose를 모든 칸에 해 보고, 모두 True일 때만 True를 돌려줍니다.
>>> A @ B == B @ A
array([[False, True],
[ True, False]])
>>> np.allclose(A @ B, B @ A)
False정수 행렬이라면 np.array_equal로 정확히 비교해도 되지만, 회전 행렬처럼 소수가 섞이면 아주 작은 오차가 생기므로 np.allclose가 안전합니다. 곧 그런 예를 봅니다.
*는 행렬곱이 아니다실습대 NumPy 칸의 마지막 줄 A * B는 일부러 넣어 둔 함정입니다. *는 2교시부터 써 온 원소별 곱, 같은 자리의 숫자끼리 곱하기입니다.
>>> A * B
array([[ 0, -1],
[ 0, 0]])왼쪽 위는 1 × 0 = 0, 오른쪽 위는 1 × (−1) = −1처럼 칸끼리 곱했을 뿐이라 변환을 이어 한 것과는 아무 관계가 없습니다. 실습대에서 B를 '좌우 뒤집기', A를 '가로로 늘이기'로 고르면 A * B와 A @ B가 우연히 같아집니다. 두 행렬 모두 대각선 밖이 0이라 그렇습니다. 이런 우연 때문에 *를 잘못 써도 테스트를 통과해 버리는 일이 생기니, 행렬곱은 언제나 @로 쓰세요.
np.eye: 항등행렬항등행렬은 np.eye(n)으로 만듭니다. 이름은 identity의 첫 소리 "아이(I)"에서 왔습니다.
>>> I = np.eye(2)
>>> I
array([[1., 0.],
[0., 1.]])
>>> A @ I
array([[1., 1.],
[0., 1.]])
>>> np.allclose(A @ I, A)
Truenp.eye는 기본으로 실수(float64) 배열을 만들기 때문에 A @ I도 1.처럼 실수로 나옵니다. 값은 A와 같습니다. 정수가 필요하면 np.eye(2, dtype=int)라고 씁니다.
6-5의 회전 행렬을 함수로 만들어 봅시다. NumPy의 np.cos, np.sin은 각도를 도(°)가 아니라 라디안으로 받습니다. 그래서 np.radians로 먼저 바꿉니다(3교시에서 쓴 np.degrees의 반대 방향입니다).
>>> def rot(deg):
... t = np.radians(deg)
... return np.array([[np.cos(t), -np.sin(t)],
... [np.sin(t), np.cos(t)]])
>>> rot(30)
array([[ 0.8660254, -0.5 ],
[ 0.5 , 0.8660254]])
>>> rot(90)
array([[ 6.123234e-17, -1.000000e+00],
[ 1.000000e+00, 6.123234e-17]])rot(90)에서 0이어야 할 자리에 6.123234e-17이 나왔습니다. e-17은 "10의 −17제곱을 곱한다"는 뜻이라 0.00000000000000006 정도, 사실상 0입니다. 컴퓨터가 π를 정확히 담지 못해 생기는 3교시의 부동소수점 오차입니다. 오차가 섞이면 숫자 크기가 제각각이라 NumPy가 모든 칸을 e 표기로 맞춰 보여 줍니다. 보기 좋게 보려면 np.round로 반올림합니다.
>>> np.round(rot(90), 10)
array([[ 0., -1.],
[ 1., 0.]])이제 "30° 돌리고 60° 더 돌리기 = 90° 돌리기"와 "90°를 두 번 = 180°"를 확인합니다. 오차 때문에 ==로는 확인할 수 없으니 np.allclose를 씁니다.
>>> np.allclose(rot(60) @ rot(30), rot(90))
True
>>> np.allclose(rot(90) @ rot(90), rot(180))
True
>>> np.allclose(rot(60) @ rot(30), rot(30) @ rot(60))
True마지막 줄은 회전끼리는 순서를 바꿔도 같다는 6-5의 결론입니다.
@를 여러 번 이어 쓰면 파이썬은 왼쪽부터 (A @ B) @ C로 계산합니다. 결합법칙 덕분에 A @ (B @ C)와 결과가 같습니다.
>>> C = np.array([[2, 0], [0, 1]]) # 가로로 늘이기
>>> A @ B @ C
array([[ 2, -1],
[ 2, 0]])
>>> np.array_equal((A @ B) @ C, A @ (B @ C))
TrueA @ B @ C는 "먼저 C(가로로 늘이기), 그다음 B(90° 회전), 마지막에 A(밀기)"입니다. 합친 행렬을 한 번 만들어 두면, 5교시의 정사각형 꼭짓점처럼 점을 여러 개 담은 행렬도 한 번에 옮길 수 있습니다.
>>> M = A @ B @ C
>>> square = np.array([[0, 1, 1, 0],
... [0, 0, 1, 1]])
>>> M @ square
array([[ 0, 2, 1, -1],
[ 0, 2, 2, 0]])순서를 거꾸로 적기. "먼저 돌리고 그다음 민다"를 말 순서대로 rotate @ shear라고 적으면 반대 순서가 됩니다. 먼저 하는 것이 오른쪽, shear @ rotate입니다. 헷갈리면 벡터 v를 오른쪽 끝에 붙여 shear @ (rotate @ v)로 읽어 보세요. v에 가까운 것부터 일어납니다.
@ 대신 *. 6-6에서 본 대로 오류 없이 엉뚱한 답을 줍니다. 대각선 밖이 0인 행렬끼리는 우연히 같은 답이 나와 더 알아채기 어렵습니다.
모양이 맞지 않는 곱. 앞 행렬의 열 개수와 뒤 행렬의 행 개수가 다르면 곱할 수 없습니다. np.ones((2, 3))은 1로 채운 2 × 3 행렬입니다.
>>> X = np.ones((2, 3))
>>> (X @ np.ones((3, 4))).shape
(2, 4)
>>> X @ X
Traceback (most recent call last):
...
ValueError: matmul: Input operand 1 has a mismatch in its core dimension 0, with gufunc signature (n?,k),(k,m?)->(n?,m?) (size 2 is different from 3)(2 × 3)(3 × 4)는 안쪽 숫자 3이 같아서 (2 × 4)가 되지만, (2 × 3)(2 × 3)은 안쪽 숫자가 3과 2로 달라 오류가 납니다. 메시지 끝의 (size 2 is different from 3)이 그 뜻입니다.
[[1, 1], [0, 1]], 가로로 늘이기 [[2, 0], [0, 1]])[[-1, 0], [0, 1]]를 두 번 하면 어떤 행렬이 될까요? 그림으로 먼저 예상해 보세요.rot(45)를 두 번 곱한 결과가 rot(90)과 같은지 np.allclose로 확인하세요.>>> shear = np.array([[1, 1], [0, 1]])
>>> stretch = np.array([[2, 0], [0, 1]])
>>> stretch @ shear
array([[2, 2],
[0, 1]])
>>> shear @ stretch
array([[2, 1],
[0, 1]])
>>> flip = np.array([[-1, 0], [0, 1]])
>>> flip @ flip
array([[1, 0],
[0, 1]])
>>> np.allclose(rot(45) @ rot(45), rot(90))
True
>>> (np.ones((3, 2)) @ np.ones((2, 5))).shape
(3, 5)1번은 먼저 하는 밀기가 오른쪽에 와서 stretch @ shear입니다. 6-2처럼 î와 ĵ를 따라가 봅시다. î = (1, 0)은 밀기로는 그대로이고 늘이기로 (2, 0)이 됩니다. ĵ = (0, 1)은 밀기로 (1, 1)이 되고, 늘이기로 가로 성분만 2배가 되어 (2, 1)이 됩니다. 출력 [[2, 2], [0, 1]]을 세로로 읽으면 1열 (2, 0), 2열 (2, 1)로 딱 맞습니다. 순서를 바꾼 shear @ stretch에서는 ĵ가 먼저 늘이기를 만나는데, ĵ는 가로 성분이 0이라 그대로이고, 그다음 밀기로 (1, 1)이 됩니다. 그래서 2열이 (1, 1)이고, 미는 양이 2배로 늘어나지 않습니다.
2번은 거울에 비친 것을 다시 비추면 원래대로 돌아오니 항등행렬 I입니다.
3번은 45° + 45° = 90°라 True입니다.
4번은 안쪽 숫자 2가 같아서 3 × 5입니다. 순서를 바꾸면 (2 × 5)(3 × 2)로 안쪽 숫자가 5와 3으로 달라 곱할 수 없습니다. 모양이 다른 행렬은 순서를 바꾸면 곱 자체가 안 될 수도 있습니다.
이번 교시 정리
변환 B를 먼저, 그다음 A를 하면 그것도 선형변환이고 행렬 AB로 쓴다. 먼저 한 것이 오른쪽: 오른쪽부터 읽는다.
AB의 열은 A 곱하기 (B의 열). 손으로는 'A의 i행 · B의 j열'의 내적이 AB의 i행 j열 성분.
대개 AB ≠ BA (양말과 신발). 하지만 (AB)C = A(BC): 괄호는 옮겨도 되고 순서는 바꾸면 안 된다.
항등행렬 I는 '그대로'(AI = IA = A), 회전 행렬은 R(α)R(β) = R(α + β).
NumPy: 행렬곱은 @(= np.matmul), *는 원소별 곱. 항등행렬은 np.eye, 오차가 있는 비교는 np.allclose.
다음 교시에는 변환이 넓이를 몇 배로 바꾸는지 알려 주는 행렬식과, 변환을 거꾸로 되돌리는 역행렬을 배웁니다. "곱하면 I가 되는 행렬"을 찾는 이야기입니다.
새 교시·새 강의 소식 받기
이 강의에 새 교시가 더해지거나 이어지는 강의가 열리면 이메일로 알려 드려요.
R
ReflexKR
눌러 보며 배우는 파이썬과 Reflex. 모든 예제는 Reflex 0.10.0에서 직접 실행해 확인했습니다.
ReflexKR은 배너 광고 없이 무료로 운영돼요. 도움이 되셨다면 커피 한 잔으로 응원해 주세요.
© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0
질문과 답변
이 교시에서 궁금한 점을 남겨 주세요. 모든 언어 페이지의 질문이 함께 모입니다.