2교시에는 화살표를 더하고 늘였습니다. 이번 교시에는 화살표를 재어 봅니다. 화살표 하나는 얼마나 긴가, 두 화살표는 얼마나 같은 쪽을 보고 있는가.
이 질문은 생각보다 자주 나옵니다. 게임에서 적이 플레이어 앞에 있는지 뒤에 있는지, 조명이 표면을 얼마나 정면으로 비추는지, 추천 시스템에서 두 사람의 취향이 얼마나 비슷한지가 모두 "두 벡터가 얼마나 같은 쪽을 보는가"라는 같은 질문입니다. 이 질문에 답하는 계산이 내적(dot product) 입니다.
내적 자체는 곱하고 더하는 것이 전부라 계산은 쉽습니다. 이번 교시의 목표는 그 단순한 계산이 왜 각도와 연결되는지를 그림으로 납득하는 것입니다. 마지막에는 NumPy로 길이와 각도를 구하고, 컴퓨터의 소수 계산에 늘 따라붙는 작은 오차를 다루는 법도 배웁니다.
벡터 를 화살표로 그리면, 오른쪽으로 3, 위로 4 가는 직각삼각형의 빗변이 됩니다. 중학교에서 배운 피타고라스 정리로 빗변 길이를 구하면
입니다. 이것이 벡터의 길이이고, 책에서는 노름(norm) 이라고 부르며 양쪽에 세로줄 두 개를 그어 로 씁니다.
3차원이면 성분 하나가 늘어날 뿐 모양은 같습니다. 의 길이는 입니다. 몇 차원이든 성분마다 제곱해 모두 더하고 제곱근을 씌우면 길이가 나옵니다. 그림으로 그릴 수 없는 784차원 벡터에도 길이가 있다는 뜻입니다.
길이에는 두 가지 성질이 있습니다. 영벡터만 길이가 0이고, 나머지는 모두 양수입니다. 그리고 스칼라배를 하면 길이도 그만큼 배가 됩니다: . 음수를 곱해 방향이 뒤집혀도 길이는 음수가 되지 않으니 에 절댓값이 붙습니다.
두 점 사이의 거리도 길이로 구합니다. 2교시에서 가 w에서 u로 가는 화살표였으니, 두 점 사이의 거리는 입니다.
길이가 1인 벡터를 단위벡터라고 합니다. 단위벡터는 "방향"만 담은 벡터입니다. 아무 벡터나 자기 길이로 나누면 방향은 그대로 두고 길이만 1로 맞출 수 있는데, 이 작업을 정규화(normalize) 라고 합니다.
의 모자(^)는 "길이를 1로 맞춘 것"이라는 표시로 "v 햇"이라고 읽습니다. 게임에서 캐릭터를 목표 쪽으로 초당 5칸씩 움직이고 싶다면, 목표 방향 벡터를 정규화한 뒤 5를 곱합니다. 정규화를 빼먹으면 목표가 멀수록 캐릭터가 빨라지는 버그가 생깁니다.
영벡터는 길이가 0이라 나눌 수 없으니 정규화할 수 없습니다. 방향이 없는 벡터에서 방향을 뽑을 수는 없는 셈입니다.
두 벡터의 내적은 같은 자리의 성분끼리 곱한 다음, 그것들을 모두 더한 숫자 하나입니다. 결과가 벡터가 아니라 숫자라는 점이 덧셈·스칼라배와 다릅니다.
u = (3, 0), w = (2, 2)라면 3×2 + 0×2 = 6입니다. 아래 실습대의 기본값이 바로 이것입니다. 오른쪽에 길이, 내적 계산식, 사이각 θ가 함께 나옵니다.
길이: |u| = 3, |w| = 2.83
내적 u · w = 3×2 + 0×2 = 6
사이각 θ = 45° (cos θ = 0.707)
같은 쪽을 봅니다 (내적 > 0)
그림자 길이 = (u · w) / |u| = 2
NumPy로 확인하기
실습대에서 값을 정할 때마다, 서버의 NumPy가 같은 숫자로 계산한 결과입니다.
>>> u = np.array([3, 0])
>>> w = np.array([2, 2])
>>> u @ w
np.int64(6)
>>> np.dot(u, w)
np.int64(6)
>>> np.linalg.norm(u)
np.float64(3.0)
>>> np.linalg.norm(w)
np.float64(2.8284271247461903)
>>> cos = u @ w / (np.linalg.norm(u) * np.linalg.norm(w))
>>> cos
np.float64(0.7071067811865475)
>>> np.degrees(np.arccos(cos))
np.float64(45.00000000000001)w를 돌려 보면 규칙이 보입니다.
| 사이각 θ | 실습대 상태 | 내적 | 예 (u = (3, 0)) |
|---|---|---|---|
| 90°보다 작다 | 같은 쪽을 봅니다 | 양수 | w = (2, 2) → 6 |
| 정확히 90° | 서로 수직입니다 | 0 | w = (0, 2) → 0 |
| 90°보다 크다 | 반대쪽을 봅니다 | 음수 | w = (−2, 1) → −6 |
곱해서 더했을 뿐인데 왜 각도와 관계가 있을까요? 실습대의 기본값이 답을 보여 줍니다. u = (3, 0)은 x축 위에 누워 있으니 내적은 , 즉 u의 길이 × w의 x성분입니다. 그런데 w의 x성분은 w를 x축 위로 내린 그림자의 길이이고, 그 그림자 길이는 삼각형에서 입니다. 그래서
가 됩니다. u가 x축 위에 있지 않아도 이 식은 그대로 성립합니다. 두 화살표를 함께 돌려도 길이와 사이각은 변하지 않고, 내적도 변하지 않기 때문입니다. 실습대에서 u를 (0, 3), w를 (−2, 2)로 옮겨 기본 그림을 90° 돌려 보세요. 내적은 여전히 6, 사이각도 여전히 45°입니다.
이 식을 거꾸로 쓰면 각도를 구할 수 있습니다.
기본값에서는 이고, 코사인이 0.707인 각은 45°입니다. 코사인은 0°에서 1, 90°에서 0, 180°에서 −1이므로, 내적의 부호가 곧 "90°보다 작은가 큰가"를 알려 줍니다. 위 표가 바로 이 이야기입니다.
자기 자신과의 내적은 길이의 제곱입니다: . 길이와 내적은 한 식구입니다.
실습대의 '그림자 보기'가 켜져 있으면 노랑 굵은 화살표와 회색 점선이 보입니다. 위에서 u에 수직으로 빛을 비출 때 w가 u 위에 드리우는 그림자입니다. 회색 점선은 w의 끝에서 u를 지나는 직선에 수직으로 내린 선이고, 노랑 화살표가 그림자입니다. 이것을 w를 u 위로 사영(projection) 한다고 말합니다.
그림자 길이는 3-3의 삼각형에서 이미 나왔습니다. 인데, 내적 식을 이용하면 각도 없이 바로 구할 수 있습니다.
실습대의 "그림자 길이 = (u · w) / |u|"가 이 식입니다. 기본값이면 로, w = (2, 2)의 그림자가 x축 위로 2만큼 드리워집니다. w를 u의 반대쪽으로 끌면 그림자 길이가 음수가 되고, 노랑 화살표도 u와 반대 방향을 가리킵니다. 수직이면 그림자는 0, 점 하나로 줄어듭니다.
그림자 화살표 자체(노랑 벡터)는 u 방향의 단위벡터에 그림자 길이를 곱해 얻습니다. 정리하면 입니다. 이 식은 13교시에서 "점들에 가장 잘 맞는 직선"을 찾을 때 다시 등장하니, 지금은 "그림자 = 내적으로 구한다"만 기억해 두면 됩니다.
게임에서는 "적이 플레이어 앞쪽으로 얼마나 떨어져 있나"를 구할 때 이 그림자를 씁니다. 플레이어가 보는 방향이 u, 플레이어에서 적으로 가는 화살표가 w입니다.
추천 시스템은 사람의 취향을 벡터로 적습니다. 예를 들어 영화 장르 (액션, 로맨스, 코미디, 공포) 네 칸에 0점에서 5점까지 매긴 4차원 벡터입니다.
| 사람 | 액션 | 로맨스 | 코미디 | 공포 |
|---|---|---|---|---|
| 나 | 5 | 1 | 4 | 0 |
| 지민 | 4 | 0 | 5 | 1 |
| 서준 | 0 | 5 | 1 | 4 |
| 하린 | 10 | 2 | 8 | 0 |
4차원이라 그림은 못 그리지만 각도는 구할 수 있습니다. 두 취향 벡터 사이의 를 코사인 유사도라고 부릅니다. 1에 가까우면 같은 쪽을 보는 취향, 0에 가까우면 상관없는 취향입니다(점수가 모두 0 이상이라 음수는 잘 나오지 않습니다).
내적을 그대로 쓰지 않고 길이로 나누는 데는 이유가 있습니다. 하린은 나와 취향 비율이 똑같지만 모든 점수를 두 배로 후하게 줍니다. 내적은 길이에 비례해서 커지므로 점수를 후하게 주는 사람일수록 무조건 "비슷하다"고 나옵니다. 길이로 나누면 길이의 영향이 사라지고 방향, 즉 취향의 비율만 남습니다. 그래서 하린과 나의 코사인 유사도는 정확히 1입니다. 계산은 3-6에서 NumPy로 합니다.
검색 엔진이나 AI가 문장을 768차원 같은 벡터로 바꾼 뒤 "의미가 비슷한 문장"을 찾을 때도 같은 계산을 씁니다.
실습대의 "NumPy로 확인하기" 칸에 나오는 줄을 처음부터 따라가 봅시다.
>>> import numpy as np
>>> u = np.array([3, 0])
>>> w = np.array([2, 2])
>>> u @ w
np.int64(6)
>>> np.dot(u, w)
np.int64(6)@는 파이썬에서 행렬 곱셈을 위해 만든 연산자입니다. 1차원 배열 두 개 사이에 쓰면 내적을 계산합니다. np.dot(u, w)도 같은 일을 하는 함수입니다. 요즘 코드는 짧고 읽기 쉬운 @를 주로 씁니다. 결과는 배열이 아니라 숫자 하나(np.int64(6))입니다.
길이는 np.linalg.norm으로 구합니다. linalg는 linear algebra(선형대수)의 줄임말로, NumPy의 선형대수 함수들이 모여 있는 곳입니다.
>>> np.linalg.norm(u)
np.float64(3.0)
>>> np.linalg.norm(w)
np.float64(2.8284271247461903)정수 벡터라도 제곱근 때문에 길이는 실수(float64)로 나옵니다. 피타고라스 식을 그대로 적어도 같은 답이 나옵니다. v ** 2는 성분마다 제곱하는 원소별 연산이고, np.sum은 성분을 모두 더하고, np.sqrt는 제곱근입니다.
>>> v = np.array([3, 4])
>>> v ** 2
array([ 9, 16])
>>> np.sqrt(np.sum(v ** 2))
np.float64(5.0)
>>> np.linalg.norm(v)
np.float64(5.0)정규화는 2교시에서 본 브로드캐스팅 그대로, 배열을 숫자 하나로 나누면 됩니다.
>>> v / np.linalg.norm(v)
array([0.6, 0.8])
>>> 5 * v / np.linalg.norm(v)
array([3., 4.])이제 실습대 NumPy 칸의 나머지 세 줄입니다. 코사인을 구하고, np.arccos로 각을 되찾고, np.degrees로 도(°) 단위로 바꿉니다.
>>> cos = u @ w / (np.linalg.norm(u) * np.linalg.norm(w))
>>> cos
np.float64(0.7071067811865475)
>>> np.arccos(cos)
np.float64(0.7853981633974484)
>>> np.degrees(np.arccos(cos))
np.float64(45.00000000000001)np.arccos는 "코사인이 이 값인 각"을 돌려주는 함수(아크코사인)인데, 결과가 라디안이라는 단위로 나옵니다. 라디안은 180°를 π(약 3.14159)로 재는 단위이고, 0.785…는 π/4, 즉 45°입니다. 사람이 읽기 쉬운 도 단위로 바꾸는 함수가 np.degrees, 반대로 도를 라디안으로 바꾸는 함수가 np.radians입니다.
마지막 줄이 45가 아니라 45.00000000000001로 나왔습니다. 계산이 틀린 것이 아닙니다. 컴퓨터는 소수를 2진법으로 저장하는데, 0.1이나 √2 같은 수는 2진법으로 끝없이 이어지므로 정해진 자릿수(약 16자리)에서 잘라 저장합니다. 잘린 만큼의 아주 작은 오차가 계산을 거치며 끝자리에 드러난 것입니다. 이것을 부동소수점 오차라고 하며, 선형대수 계산에서는 늘 만나게 됩니다.
가장 유명한 예는 이것입니다.
>>> 0.1 + 0.2
0.30000000000000004
>>> 0.1 + 0.2 == 0.3
False그래서 소수끼리는 ==로 비교하지 않습니다. 대신 np.isclose로 "아주 가까우면 같다고 보자"고 비교합니다. 배열 전체를 한꺼번에 비교할 때는 np.allclose를 씁니다. 기본값으로는 차이가 0.00000001(1억분의 1)보다 작거나 값 크기의 10만분의 1보다 작으면 같다고 봅니다. 부동소수점 오차는 이보다 훨씬 작으니 걸러집니다.
>>> np.isclose(0.1 + 0.2, 0.3)
np.True_
>>> np.isclose(np.degrees(np.arccos(cos)), 45)
np.True_
>>> np.allclose(v / np.linalg.norm(v), [0.6, 0.8])
Truenp.True_는 NumPy의 참(True) 값입니다. if문 등에서는 파이썬의 True와 똑같이 쓸 수 있습니다. 화면에 보기 좋게 보여 주고 싶을 때는 round(float(x), 3)처럼 반올림하면 됩니다. float(x)는 NumPy 숫자를 보통 파이썬 숫자로 바꿉니다.
>>> round(float(np.degrees(np.arccos(cos))), 3)
45.0이 오차는 각도 계산에서 한 번 더 문제를 일으킵니다. 같은 방향의 두 벡터라면 코사인이 정확히 1이어야 하는데, 오차 때문에 1을 살짝 넘을 때가 있습니다.
>>> a = np.array([2, 3])
>>> b = np.array([4, 6])
>>> c = a @ b / (np.linalg.norm(a) * np.linalg.norm(b))
>>> c
np.float64(1.0000000000000002)코사인은 −1과 1 사이의 값이라, np.arccos에 1보다 큰 수를 넣으면 답 대신 nan(Not a Number, 숫자 아님)과 경고가 나옵니다. 이럴 때는 np.clip으로 값을 −1과 1 사이로 잘라 낸 뒤 넣습니다. 실습대도 속으로는 이렇게 계산합니다.
>>> np.clip(c, -1, 1)
np.float64(1.0)
>>> np.degrees(np.arccos(np.clip(c, -1, 1)))
np.float64(0.0)3-5의 취향 벡터로 코사인 유사도를 계산해 봅시다. 같은 계산을 여러 번 하니 함수로 만들어 둡니다.
>>> def cos_sim(x, y):
... return x @ y / (np.linalg.norm(x) * np.linalg.norm(y))
>>> me = np.array([5, 1, 4, 0])
>>> jimin = np.array([4, 0, 5, 1])
>>> seojun = np.array([0, 5, 1, 4])
>>> harin = np.array([10, 2, 8, 0])
>>> round(float(cos_sim(me, jimin)), 3)
0.952
>>> round(float(cos_sim(me, seojun)), 3)
0.214
>>> cos_sim(me, harin)
np.float64(1.0)
>>> me @ jimin, me @ harin
(np.int64(40), np.int64(84))지민은 나와 아주 비슷하고(0.952), 서준은 별로 비슷하지 않습니다(0.214). 하린은 점수를 두 배로 줬을 뿐 취향 비율이 같으니 1입니다. 마지막 줄을 보면 내적만으로는 하린(84)이 지민(40)보다 두 배 넘게 비슷해 보입니다. 길이로 나누는 이유가 여기에 있습니다. 4차원이든 768차원이든 코드는 한 글자도 바뀌지 않습니다.
u * w를 내적으로 착각하기. *는 2교시에서 본 원소별 곱셈이라 벡터가 나옵니다. 내적은 그것을 다 더한 숫자 하나입니다.
>>> u * w
array([6, 0])
>>> np.sum(u * w)
np.int64(6)
>>> u @ w
np.int64(6)len을 길이로 착각하기. 파이썬의 len은 성분이 몇 개인지(차원)를 알려 줄 뿐, 화살표의 길이가 아닙니다.
>>> len(v)
2
>>> np.linalg.norm(v)
np.float64(5.0)도와 라디안 섞기. np.cos(60)은 60°가 아니라 60라디안의 코사인입니다. 도 단위 각을 넣을 때는 np.radians로 먼저 바꾸세요. 결과가 0.5가 아니라 0.5000000000000001인 것은 앞에서 본 부동소수점 오차입니다.
>>> np.cos(np.radians(60))
np.float64(0.5000000000000001)np.linalg.norm으로 확인해 보세요. 이 벡터를 정규화하면 무엇이 될까요?>>> p = np.array([1, 2, 2])
>>> np.linalg.norm(p)
np.float64(3.0)
>>> p / np.linalg.norm(p)
array([0.33333333, 0.66666667, 0.66666667])
>>> u = np.array([3, 0])
>>> w = np.array([-2, 1])
>>> u @ w
np.int64(-6)
>>> cos = u @ w / (np.linalg.norm(u) * np.linalg.norm(w))
>>> round(float(np.degrees(np.arccos(cos))), 1)
153.4
>>> np.array([1, 2]) @ np.array([-2, 1])
np.int64(0)
>>> u @ w / np.linalg.norm(u)
np.float64(-2.0)1번의 길이는 $\sqrt{1 + 4 + 4} = 3$이라 정규화하면 (1/3, 2/3, 2/3)입니다. 2번은 내적이 음수이니 90°보다 큰 약 153.4°입니다. 3번은 성분의 자리를 바꾸고 한쪽 부호를 뒤집으면 수직인 벡터가 됩니다: (1, 2)와 (−2, 1). 4번의 그림자 길이 −2는 그림자가 u의 반대쪽으로 2만큼 드리워진다는 뜻입니다.
이번 교시 정리
길이(노름) ‖v‖ = 성분 제곱의 합의 제곱근 (피타고라스). 몇 차원이든 같은 식.
단위벡터 = 길이 1인 벡터. v / ‖v‖ 로 정규화하면 방향만 남는다.
내적 u · w = 성분끼리 곱해 더한 숫자 하나 = ‖u‖‖w‖cos θ. 양수면 같은 쪽, 0이면 수직, 음수면 반대쪽.
w를 u 위로 내린 그림자(사영)의 길이 = (u · w) / ‖u‖.
코사인 유사도 = 길이로 나눈 내적: 크기는 빼고 방향(비율)만 비교한다.
NumPy: u @ w, np.linalg.norm, np.degrees(np.arccos(...)). 소수는 == 대신 np.isclose / np.allclose로 비교.
다음 교시에는 두 벡터를 늘이고 더해서 어디까지 갈 수 있는지 알아봅니다. 처음으로 NumPy의 2차원 배열, 즉 행렬이 등장합니다.
새 교시·새 강의 소식 받기
이 강의에 새 교시가 더해지거나 이어지는 강의가 열리면 이메일로 알려 드려요.
R
ReflexKR
눌러 보며 배우는 파이썬과 Reflex. 모든 예제는 Reflex 0.10.0에서 직접 실행해 확인했습니다.
ReflexKR은 배너 광고 없이 무료로 운영돼요. 도움이 되셨다면 커피 한 잔으로 응원해 주세요.
© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0
질문과 답변
이 교시에서 궁금한 점을 남겨 주세요. 모든 언어 페이지의 질문이 함께 모입니다.