참조
고친 사람 github-actions[bot]
참조는 값을 직접 담는 대신 그 값이 어디에 있는지를 가리키는 값입니다. 가리키는 쪽을 따라가면 실물 하나에 닿습니다. 실물은 한 벌만 두고 그것을 가리키는 쪽만 여럿 두려고 씁니다. 메모리에 놓인 객체도, 버전 관리 저장소의 커밋도, 데이터베이스의 다른 행도 참조로 가리킵니다.
쉽고 빠른 이해
참조는 데이터가 아니라 데이터가 있는 곳을 가리키는 값입니다. 주문 기록에 회원 정보를 전부 적지 않고 회원 번호만 적어 두는 것이 참조입니다.
같은 데이터를 여러 벌 복사해 두면 한 벌을 고쳤을 때 나머지가 낡습니다. 실물을 한 벌만 두고 나머지는 그것을 가리키게 하면 그런 어긋남이 생기지 않습니다.
어떻게 도나:
- 데이터는 한 곳에만 둡니다
- 쓰는 쪽은 데이터 대신 그것이 어디 있는지를 담은 값을 들고 다닙니다
- 데이터가 필요해지면 그 값을 따라가 꺼냅니다
대가는 가리키던 것이 없어질 수 있다는 것입니다. 사라진 데이터를 가리키는 참조를 따라가면 프로그램이 멈추거나 엉뚱한 값을 읽습니다.
상세
동아리 명부에 회원의 집을 그려 넣지는 않습니다. 대신 주소 한 줄을 적습니다. 주소를 적은 명부가 열 권이어도 집은 하나입니다. 집주인이 현관을 고치면 명부를 든 열 사람이 모두 고쳐진 현관을 보게 됩니다.
참조는 데이터를 담는 대신 그 데이터가 놓인 곳을 담은 값입니다. 변수에 객체를 넣는 일, 커밋 하나에 브랜치 이름을 붙이는 일, 주문 기록에 회원 번호만 적어 두는 일이 모두 참조입니다. 세 경우 모두 가리켜지는 실물은 한 벌입니다. 그것을 가리키는 값만 여러 벌일 수 있습니다.
맥락마다 가리키는 대상이 다르고 참조를 담아 두는 그릇도 다릅니다. 아래 표가 맥락마다 참조가 무엇을 가리키고 그 참조를 어디에 담는지 보여 줍니다.
| 맥락 | 참조가 가리키는 것 | 참조를 담는 그릇 |
|---|---|---|
| 프로그램 메모리 | 메모리에 놓인 객체 | 변수 · 객체의 필드 |
| 버전 관리 저장소 | 커밋 하나 | 브랜치 이름 · 태그 · HEAD |
| 관계형 데이터베이스 | 다른 테이블의 행 | 외래 키 열 |
세 맥락은 가리키는 방법이 서로 다릅니다. 메모리 안에서는 위치를 담은 값으로, 저장소에서는 이름으로, 데이터베이스에서는 키 값으로 가리킵니다. 그래도 하는 일은 같습니다. 실물을 옮겨 담지 않고 실물이 있는 곳만 들고 다닙니다.
참조를 이루는 두 쪽
참조에는 언제나 두 쪽이 있습니다. 가리키는 값과 가리켜지는 실물입니다. 앞을 참조라 부르고, 가리켜지는 실물을 대상이라 부릅니다.
참조를 따라가 대상을 꺼내는 일을 역참조라고 부릅니다. 변수에 담긴 참조를 써서 객체의 필드를 읽는 순간이 역참조입니다. 참조를 들고만 있을 때는 대상이 어떤 상태인지 알 수 없고, 따라가서 읽어야 그때의 값을 봅니다.
참조 자체도 하나의 값입니다. 변수에 담고 함수에 넘기고 다른 참조로 덮어쓸 수 있습니다. 참조를 덮어쓰면 가리키는 대상이 바뀝니다. 이때 앞서 가리키던 대상은 손대지 않은 채 남아 있습니다.
아래 그림에서 위 칸은 덮어쓰기 전입니다. 아래 칸은 덮어쓴 뒤입니다. 참조가 가리키는 곳만 바뀌고 대상 A 는 남아 있습니다.
flowchart TD
subgraph 전["덮어쓰기 전"]
R1["참조"] -- 역참조 --> D1["대상 A"]
end
subgraph 후["덮어쓴 뒤"]
R2["참조"] -- 역참조 --> D2["대상 B"]
D3["대상 A"]
end
값을 복사하는 쪽과 견주면
데이터를 넘기는 방법은 크게 둘입니다. 데이터를 복사해서 주는 방법과, 데이터가 있는 곳을 가리키는 참조를 주는 방법입니다. 아래 표가 둘의 차이를 넷으로 갈라 보여 줍니다.
| 값을 복사한다 | 참조로 가리킨다 | |
|---|---|---|
| 넘길 때 드는 비용 | 데이터 크기만큼 | 가리키는 값 하나 크기만큼 |
| 한쪽에서 고치면 | 고친 쪽만 바뀐다 | 가리키는 모두가 바뀐 것을 본다 |
| 대상이 없어지면 | 내 복사본은 남는다 | 따라갈 곳이 없어진다 |
| 같은 것인지 볼 때 | 내용을 견준다 | 가리키는 곳이 같은지 본다 |
둘째 줄이 참조를 쓰는 주된 이유이면서 주된 위험입니다. 회원 정보를 한 곳에 두고 여러 화면이 참조로 보면 정보를 한 번만 고쳐도 모든 화면이 새 값을 봅니다. 반대로 내 몫이라 여기고 고친 데이터를 남이 같이 보고 있었다면, 남의 화면이 말없이 함께 바뀝니다.
메모리 안의 객체를 가리키는 참조
프로그램이 실행 중에 만든 객체는 힙이라는 메모리 구역에 놓입니다. 변수는 그 객체를 담지 않습니다. 객체가 힙의 어디에 있는지를 담습니다. 「변수에 객체를 넣는다」는 말은 그 변수가 객체를 가리키게 한다는 뜻입니다.
한 객체를 여러 변수가 같이 가리킬 수 있습니다. 아래 코드에서 변수 둘이 같은 객체를 가리킵니다.
User a = new User("유미");
User b = a; // 같은 객체를 가리킴
b.name = "루비";
String n = a.name; // "루비"
둘째 줄은 객체를 복사하지 않습니다. a 가 들고 있던 참조를 b 에 복사할 뿐입니다. 그래서 b 를 거쳐 이름을 바꾸면 a 로 읽어도 바뀐 이름이 나옵니다. 객체가 하나뿐이기 때문입니다.
아래 그림은 변수 둘이 한 객체를 가리키는 모습과, 아무도 가리키지 않는 객체를 같이 보여 줍니다.
flowchart TD
subgraph 변수
A["a"]
B["b"]
end
subgraph 힙["힙에 놓인 객체"]
O1["가리켜지는 객체"]
O2["아무도 안 가리키는 객체"]
end
A --> O1
B --> O1
그림의 「아무도 안 가리키는 객체」처럼 어떤 변수로도 닿을 수 없게 된 객체는 프로그램이 다시 꺼낼 방법이 없습니다. 이런 객체를 찾아 메모리를 되돌려 주는 일이 가비지 컬렉션입니다. 어떤 객체를 치워도 되는지를 참조가 알려 주는 셈입니다.
참조와 포인터는 하는 일이 겹칩니다. 둘 다 데이터가 있는 곳을 값으로 들고 다닙니다. 다른 것은 얼마나 드러내느냐입니다. C 처럼 포인터를 내놓는 언어는 메모리 주소를 숫자처럼 더하고 뺄 수 있게 해 줍니다. 앞의 자바처럼 참조라고 부르는 언어는 주소를 감추고 그런 셈을 막습니다.
커밋을 가리키는 참조
버전 관리 저장소에서 커밋은 한 번 만들면 내용이 바뀌지 않는 기록입니다. 커밋마다 붙는 이름은 사람이 외우기 어려운 긴 문자열입니다. 그래서 사람이 쓰는 이름을 따로 둡니다. 그 이름이 커밋 하나를 가리킵니다. 이 이름이 저장소에서 말하는 참조입니다.
브랜치 이름과 태그와 HEAD 가 그런 참조입니다. main 같은 브랜치 이름은 그 갈래의 마지막 커밋을 가리킵니다. 태그는 특정 커밋에 붙여 두는 이름표입니다. HEAD 는 지금 작업 중인 브랜치를 가리킵니다.
아래 그림에서 이름 셋이 각각 어느 커밋을 가리키는지, 그리고 커밋끼리 앞선 커밋을 가리켜 사슬을 이루는 모습을 함께 볼 수 있습니다. 화살표는 모두 「가리킨다」는 뜻입니다.
flowchart TD
subgraph 참조["커밋을 가리키는 이름"]
H["HEAD"]
M["main"]
T["태그"]
end
subgraph 커밋사슬["커밋"]
C4["커밋 4"]
C3["커밋 3"]
C2["커밋 2"]
C1["커밋 1"]
end
H --> M
M --> C3
M -. 새 커밋을 만들면 여기로 .-> C4
T --> C1
C4 --> C3
C3 --> C2
C2 --> C1
커밋끼리 이어진 화살표도 참조입니다. 커밋 하나가 앞선 커밋을 가리켜 사슬을 이룹니다. 커밋을 새로 만들면 브랜치 이름이 새 커밋을 가리키도록 옮겨 갑니다. 커밋은 손대지 않고 그것을 가리키는 이름만 움직이는 것입니다.
같은 이력을 담은 저장소가 두 벌 있을 수 있습니다. 내 저장소와 원격 저장소입니다. 저장소는 저마다 자기 참조를 따로 가집니다.
푸시는 저쪽 저장소의 참조가 내 커밋을 가리키도록 갱신합니다. 그때 필요한 커밋 데이터도 함께 보냅니다.
페치는 저쪽 참조가 무엇을 가리키는지 받아옵니다. 받은 것은 내 참조를 건드리지 않고 내 저장소 안의 별도 이름 아래에 둡니다.
아래 그림에서 두 저장소가 참조를 어느 방향으로 주고받는지 볼 수 있습니다.
sequenceDiagram
participant L as 내 저장소
participant R as 원격 저장소
L->>R: 푸시 · 커밋을 보낼 테니 저쪽 참조를 옮겨라
L->>R: 페치 · 저쪽 참조가 무엇을 가리키나
R-->>L: 저쪽 참조와 그것이 가리키는 커밋
Note over L: 받은 것은 별도 이름 아래에 둔다
다른 행을 가리키는 참조
데이터베이스에서도 같은 일이 벌어집니다. 주문 기록마다 회원 이름과 연락처를 전부 적어 두면 회원이 연락처를 바꿀 때 그 회원의 주문을 모두 찾아 고쳐야 합니다. 그래서 회원 정보는 회원 테이블에 한 줄로 둡니다. 주문 쪽에는 그 줄을 가리키는 값만 적습니다.
가리키는 값으로는 대개 회원을 유일하게 알아보게 해 주는 값을 씁니다. 그 값을 기본 키라 부릅니다. 다른 테이블에서 그것을 가리키려고 둔 열은 외래 키라 부릅니다. 주문의 외래 키를 따라가 회원 한 줄을 붙여 읽는 일이 조인입니다.
아래 그림에서 주문 두 줄이 회원 한 줄을 함께 가리킵니다. 가리키던 회원 줄이 지워지면 화살표가 갈 곳을 잃습니다.
flowchart TD
subgraph 주문["주문 테이블"]
O1["주문 1 · 회원 번호 7"]
O2["주문 2 · 회원 번호 7"]
O3["주문 3 · 회원 번호 9"]
end
subgraph 회원["회원 테이블"]
M7["회원 7"]
M9["회원 9 · 지워짐"]
end
O1 --> M7
O2 --> M7
O3 --> M9
여기서도 가리키던 것이 사라지는 문제가 생깁니다. 주문이 남아 있는데 회원 줄을 지우면 어디도 가리키지 못하는 외래 키가 남습니다. 이런 일이 없도록 데이터베이스가 지우기와 넣기를 검사하는 규칙이 참조 무결성입니다.
참조가 가리키던 것이 없어지면
참조의 약점은 대상과 따로 움직인다는 것입니다. 참조는 멀쩡한데 대상만 사라질 수 있습니다. 이 어긋남이 맥락마다 다른 이름의 사고로 나타납니다.
아래 그림은 끊어진 참조 세 가지를 나란히 놓은 것입니다. 칸마다 참조가 따라가 닿는 곳이 다릅니다.
flowchart TD
subgraph 널["널 포인터"]
N1["참조"] --> N2["가리킬 대상 없음"]
end
subgraph 댕글링["댕글링 포인터"]
G1["참조"] --> G2["치워진 메모리 · 다른 데이터"]
end
subgraph 순환["순환 참조"]
S1["객체 A"] --> S2["객체 B"]
S2 --> S1
end
자바처럼 아무것도 가리키지 않는 참조를 두는 언어가 많습니다. 값이 아직 없다는 것을 나타내려고 쓰는 널 포인터가 그것입니다. 이 참조를 따라가면 읽을 대상이 없어 프로그램이 멈춥니다. 그래서 참조를 받은 쪽은 따라가기 전에 비었는지 보는 검사를 넣습니다.
이미 치워진 대상을 가리키는 참조도 있습니다. 이런 참조를 댕글링 포인터라고 부릅니다. 그 메모리에 다른 데이터가 들어와 있으면 엉뚱한 값을 읽습니다. 쓰기까지 하면 남의 데이터를 망가뜨립니다. C 나 C++ 처럼 대상을 언제 치울지 사람이 정하는 언어에서 나는 사고입니다.
가리키는 수를 세어 0이 되면 치우는 방식을 참조 카운팅이라고 부릅니다. 대상을 가리키는 참조가 하나도 안 남는 때가 치울 때라고 보는 것입니다.
참조끼리 고리를 이루면 이 방식이 통하지 않습니다. 두 객체가 서로를 가리키면 양쪽 다 가리켜지는 수가 0이 되지 않습니다. 이것이 순환 참조입니다. 치우지 못한 메모리가 쌓이면 메모리 누수가 됩니다.
언제 참조를 쓰고 언제 값을 쓰나
기준은 둘입니다. 하나는 크기이고 하나는 공유 여부입니다. 데이터가 크면 복사 비용이 커지므로 참조가 유리합니다. 여럿이 같은 데이터를 보아야 한다면 그때도 참조입니다. 한 번의 수정이 모두에게 보여야 할 때도 같습니다.
반대로 작은 값이거나, 받은 쪽이 마음대로 고쳐도 남이 영향받으면 안 되는 데이터라면 복사가 맞습니다. 숫자 하나를 참조로 넘기면 따라가는 품만 늘고 얻는 것이 없습니다. 설정 한 벌을 여러 작업에 나눠 주고 각자 고치게 하려면 깊은 복사로 대상까지 새로 만들어 줘야 합니다.
아래 그림이 그 판단을 따라갑니다.
flowchart TD
Q1{"데이터가 큰가"}
Q1 -- 예 --> A["참조로 넘긴다"]
Q1 -- 아니오 --> Q2{"여럿이 같은 것을 보아야 하나"}
Q2 -- 예 --> A
Q2 -- 아니오 --> Q3{"받은 쪽이 고친 것이 남에게 보이면 안 되나"}
Q3 -- 아니오 --> B["값을 복사해 넘긴다"]
Q3 -- 예 --> C["깊은 복사로 넘긴다"]
참조를 쓰기로 했다면 값을 치를 것이 셋 있습니다. 대상을 읽을 때마다 한 번 더 따라가야 합니다. 누가 같이 보고 있는지 모르는 채 고치면 남의 화면이 함께 바뀝니다. 그리고 대상을 언제 치울지 정해야 합니다. 마지막 몫을 언어에 맡기는 방식이 가비지 컬렉션입니다. 사람이 지는 방식은 직접 반납입니다.
관련 항목
참조와 같은 일을 하는 다른 이름
포인터 · 핸들 · 디스크립터 · 식별자 · 심볼릭 링크 · URL · 별칭
참조를 따라가거나 풀어내는 연산
역참조 · 널 검사 · 얕은 복사 · 깊은 복사 · 복사 · 조인
참조가 가리키는 대상과 그것이 담기는 구조
힙 · 스택 · 메모리 · 객체 · 레코드 · 노드 · 테이블
참조를 세어 대상을 치우는 방식
가비지 컬렉션 · 참조 카운팅 · 루트 집합 · 도달 가능성 · 약한 참조 · 강한 참조 · 소유권
참조가 끊어질 때 나는 오류
널 포인터 · 댕글링 포인터 · 순환 참조 · 이중 해제 · 메모리 누수 · 세그멘테이션 폴트
저장소에서 커밋을 가리키는 이름
브랜치 · 태그 · HEAD · 커밋 · 원격 추적 브랜치 · origin
참조를 주고받는 저장소 명령
푸시 · 페치 · 풀 · 원격 저장소 · 로컬 저장소 · Git
데이터베이스에서 다른 행을 가리키는 값
외래 키 · 기본 키 · 참조 무결성 · 정규화 · 관계형 데이터베이스 · 문서 데이터베이스
참조와 값을 가르는 언어 기능
다른 이름: reference · 레퍼런스