우리는 왜 0과 1을 알아야 할까?
현대의 프로그래밍 언어는 매우 친절합니다. 우리가 int a = 10이라고 적으면, 컴퓨터 내부에서 전기가 어떻게 흐르고 메모리에 0과 1이 어떻게 박히는지 몰라도 개발하는 데에는 지장이 없습니다. 하드웨어 성능은 비약적으로 발전했고, 컴파일러는 우리보다 똑똑하니까요.
그럼에도 불구하고, 여전히 많은 오픈소스 라이브러리나 코어 시스템, 그리고 알고리즘 문제에서는 &, |, << 같은 낯선 기호들이 등장합니다.
비트 연산(Bitwise Operation)은 데이터를 가장 낮은 단위인 비트(Bit) 수준에서 제어하는 기술입니다. 인간의 사고방식(10진수)이 아닌 컴퓨터의 사고방식(2진수)으로 데이터를 바라볼 때, 우리는 비로소 데이터를 극한으로 압축하거나, 불필요한 연산을 줄이는 통찰력을 얻을 수 있습니다.
오늘은 자바(Java)를 통해 이 0과 1의 세계를 들여다보고, 이것이 어떻게 사용될 수 있는지 알아보겠습니다.
기본기: 비트 연산자의 종류와 이진법
비트 연산의 세계로 들어가기 전에, 우리가 사용할 수 있는 도구(Operator)들을 먼저 살펴보겠습니다. 각 연산자는 비트를 조작하는 고유한 방식을 가지고 있습니다.
| 연산자 | 이름 (Name) | 동작 설명 |
|---|---|---|
& |
AND | 두 비트가 모두 1일 때만 1을 반환합니다. |
| **` | `** | OR |
^ |
XOR | 두 비트가 서로 다르면 1, 같으면 0을 반환합니다. |
~ |
NOT | 비트를 반전시킵니다 (1 → 0, 0 → 1). |
<< |
Left Shift | 비트를 왼쪽으로 n칸 밉니다. 빈자리는 0으로 채웁니다. |
>> |
Right Shift | 비트를 오른쪽으로 n칸 밉니다. 앞자리는 부호 비트(Sign Bit)로 채웁니다. |
>>> |
Unsigned Shift | 비트를 오른쪽으로 n칸 밉니다. 앞자리는 무조건 0으로 채웁니다. |
이 연산자들을 자유자재로 다루기 위해서는, 먼저 컴퓨터가 숫자를 이해하는 방식인 '이진법'에 익숙해져야 합니다.
선수 지식: 이진법(Binary)이란?
우리가 평소에 사용하는 숫자 체계는 10진법(Decimal)입니다. 각 자릿수가 10의 거듭제곱(100,101,102...)으로 이루어져 있죠.
| 자릿값 | 1000 (103) | 100 (102) | 10 (101) | 1 (100) |
|---|---|---|---|---|
| 수 | 1 | 2 | 3 | 4 |
반면, 컴퓨터는 2진법(Binary)을 사용합니다. 각 자릿수는 2의 거듭제곱(20,21,22...) 단위로 구성되며, 사용할 수 있는 숫자는 오직 0과 1뿐입니다.
| 자릿값 | 128 | 64 | 32 | 16 | 8 | 4 | 2 | 1 |
|---|---|---|---|---|---|---|---|---|
| 비트 | 0 | 0 | 0 | 0 | 1 | 0 | 1 | 0 |
예를 들어, 10진수 5를 이진수로 변환하면 00000101이 됩니다. 그 이유는 5가 4와 1의 합(2<sup>2</sup>+2<sup>0</sup>)으로 이루어져 있기 때문입니다.
이제 이 0과 1들이 각 연산자를 만났을 때 어떻게 변하는지 하나씩 뜯어보겠습니다.
비트의 논리
가장 먼저 살펴볼 것은 두 비트의 값을 비교하여 새로운 값을 만들어내는 논리 연산자입니다. 이들은 데이터의 특정 부분을 추출하거나(AND), 새로운 상태를 추가할 때(OR) 핵심적인 역할을 합니다.
1) `&` (AND): 교집합과 추출
& 연산자는 두 비트가 모두 1일 때만 1을 반환합니다. 이를 집합으로 비유하면 교집합과 같습니다.
[계산 원리: 13 & 9]
1101 (13)
& 1001 (9)
--------
1001 (9)각 자릿수를 비교했을 때, 둘 다 1인 자리만 살아남습니다.
int a = 13;
int b = 9;
System.out.println(a & b); // 9 (1001)즉, 13 & 9의 결과는 9가 됩니다.
코드로 확인해보자
public static void main(String[] args) {
int a = 13;
int b = 9;
int result = a & b;
System.out.println("a & b = " + result); // 결과는 9
System.out.println("이진수 확인:");
System.out.println("a = " + Integer.toBinaryString(a)); // 1101
System.out.println("b = " + Integer.toBinaryString(b)); // 1001
System.out.println("a & b = " + Integer.toBinaryString(result)); // 1001
}a & b = 9
이진수 확인:
a = 1101
b = 1001
a & b = 1001& 연산자는 어디에 쓰일까?
&는 주로 '특정 비트가 켜져 있는지 확인(Masking)'하거나 '나머지 연산 최적화'에 사용됩니다.
① 상태(Flag) 검사
게임 캐릭터가 여러 상태값(status)을 가지고 있을 때, 특정 상태(STUNNED)인지 확인하려면 어떻게 해야 할까요?
final int INVISIBLE = 0b0001; // 투명 상태
final int POISONED = 0b0010; // 중독 상태
final int STUNNED = 0b0100; // 기절 상태이제 어떤 캐릭터가 POISONED랑 STUNNED 상태라면 이렇게 표현할 수 있습니다:
int status = POISONED | STUNNED; // 0b0110그리고 캐릭터가 지금 기절했는지 확인하고 싶다면?
boolean isStunned = (status & STUNNED) != 0;
System.out.println("기절 상태? " + isStunned); // truestatus랑 STUNNED를 & 해봤을 때 0이 아니면, 그 비트가 켜져 있다는 뜻이 됩니다.
② 짝수/홀수 판별 (Performance)
어떤 숫자가 짝수인지 판별할 때 % 2를 쓸 수도 있지만, 비트 연산이 더 효율적입니다. 이진수의 가장 마지막 비트(LSB)가 1이면 홀수, 0이면 짝수라는 성질을 이용하는 것입니다.
int num = 42;
// 1(0001)과 AND 연산하여 마지막 비트만 추출
boolean isEven = (num & 1) == 0;2) `|` (OR): 합집합과 설정
| 연산자는 두 비트 중 하나라도 1이면 1을 반환합니다. 집합의 합집합 개념입니다.
[계산 원리: 13 | 9]
1101 (13)
| 1001 (9)
--------
1101 (13)0과 1 중 1이 하나라도 있으면 결과는 1이 됩니다.
int a = 13;
int b = 9;
System.out.println(a | b); // 13 (1101)코드로 확인해보자
public static void main(String[] args) {
int a = 13;
int b = 9;
int result = a | b;
System.out.println("a | b = " + result); // 결과는 13
System.out.println("이진수 확인:");
System.out.println("a = " + Integer.toBinaryString(a)); // 1101
System.out.println("b = " + Integer.toBinaryString(b)); // 1001
System.out.println("a | b = " + Integer.toBinaryString(result)); // 1101
}a | b = 13
이진수 확인:
a = 1101
b = 1001
a | b = 1101| 연산자는 어디에 쓰일까?
| 연산자는 흩어져 있는 여러 상태를 하나로 합치거나(Merge), 특정 옵션을 켜는(Set) 용도로 사용됩니다.
① 상태 병합 (Merge)
캐릭터에게 '투명' 상태와 '중독' 상태를 동시에 부여하고 싶다면 덧셈(+) 대신 |를 사용합니다.
final int INVISIBLE = 0b0001;
final int POISONED = 0b0010;
// 두 비트를 모두 켭니다. (0001 | 0010 = 0011)
int status = INVISIBLE | POISONED;② 상태 추가 (Add)
이미 상태를 가지고 있는 변수에 새로운 상태(STUNNED)를 추가할 때도 사용합니다.
// 기존 status에 STUNNED 비트만 덧입힙니다.
status |= STUNNED;이처럼 | 연산자는 기존 데이터는 건드리지 않고, 내가 원하는 비트만 확실하게 켜주는 역할을 합니다.
3) `^` (XOR): 다를 때만 반응한다
^ (Exclusive OR)는 두 비트가 서로 다를 때만 1, 같으면 0을 반환합니다. "너랑 나랑 다르면 참(True)"인 연산자입니다.
[계산 원리: 13 ^ 9]
1101 (13)
^ 1001 (9)
--------
0100 (4)같은 값(1, 1)이나 (0, 0)은 사라지고(0), 서로 다른 부분만 남습니다.
int a = 13;
int b = 9;
System.out.println(a ^ b); // 4코드로 확인해보자
public static void main(String[] args) {
int a = 13;
int b = 9;
int result = a ^ b;
System.out.println("a ^ b = " + result); // 결과는 4
System.out.println("이진수 확인:");
System.out.println("a = " + Integer.toBinaryString(a)); // 1101
System.out.println("b = " + Integer.toBinaryString(b)); // 1001
System.out.println("a ^ b = " + Integer.toBinaryString(result)); // 0100
}a ^ b = 4
이진수 확인:
a = 1101
b = 1001
a ^ b = 100^ 연산자는 어디에 쓰일까?
XOR에는 "어떤 값에 동일한 값을 두 번 XOR 하면 원래대로 돌아온다"는 강력한 마법 같은 성질이 있습니다.
A⊕B⊕B=A① 토글 (Toggle)
특정 비트를 켰다 껐다(On/Off) 반복하고 싶을 때 사용합니다.
int flag = 0b1010;
int mask = 0b0010;
flag ^= mask; // 1010 ^ 0010 = 1000 (꺼짐)
flag ^= mask; // 1000 ^ 0010 = 1010 (다시 켜짐)② 변수 스왑 (Swap without Temp)
임시 변수(temp) 없이 두 변수의 값을 바꿀 때 사용하는 고전적인 테크닉입니다.
int a = 5;
int b = 7;
a ^= b;
b ^= a;
a ^= b;
// 결과: a = 7, b = 54) `~` (NOT): 뒤집기와 음수의 세계
~ 연산자는 모든 비트를 반전시킵니다. 0은 1로, 1은 0으로 바꿉니다. 단순해 보이지만, 여기에는 컴퓨터가 음수를 다루는 비밀인 '2의 보수'가 숨어 있습니다.
[계산 원리: ~5]
0000 0101 (5)
---------
1111 1010 (결과) -> 10진수로 읽으면 -6왜 ~5가 -5가 아니라 -6이 될까요? 이를 이해하려면 컴퓨터가 음수를 만드는 과정을 들여다봐야 합니다.
💡 Deep Dive: 2의 보수 (음수를 만드는 법)
컴퓨터는 뺄셈 회로를 따로 만들지 않기 위해, 음수를 "더했을 때 0이 되는 수"로 정의합니다. 어떤 양수를 음수로 바꾸는(2의 보수를 구하는) 공식은 다음과 같습니다.
공식: 비트를 모두 반전(
~)시키고, +1을 더한다.
예시: 5를 -5로 바꿔보자
- 5의 이진수:
0000 0101 - 반전 (Step 1):
1111 1010(여기까지가~5의 결과입니다) - +1 더하기 (Step 2):
1111 1011
결과인 1111 1011이 바로 컴퓨터가 인식하는 -5입니다.
진짜 -5인지 역으로 증명해볼까요? 음수(1111 1011)를 다시 양수로 바꾸는 과정도 똑같습니다.
- 반전:
0000 0100 - +1 더하기:
0000 0101(5)
원래 값인 5가 나왔습니다. 즉, 1111 1011은 -5가 맞습니다.
결론: 우리가 자바에서 ~5를 했을 때 -6이 나오는 이유는, 2의 보수 과정 중 '반전'까지만 수행하고 '+1'을 하지 않았기 때문입니다. 그래서 ~x의 결과는 항상 **-(x + 1)**이 됩니다.
~ 연산자는 언제 쓰일까?
~는 단독으로 쓰이기보다, &와 결합하여 특정 비트만 끄고 싶을 때 주로 사용됩니다.
int flags = 0b1111; // 모든 옵션 켜짐
int mask = 0b0010; // 2번째 비트
// mask를 반전(~0010 -> 1101)시켜서 AND 연산
flags &= ~mask; // 1111 & 1101 = 1101 (2번째 비트만 꺼짐)이동기: 시프트 연산자 (Shift)
비트들을 좌우로 밀어버리는(Shift) 연산자입니다. 이는 컴퓨터 내부에서 가장 빠른 곱셈과 나눗셈 도구입니다.
1) `<<` (Left Shift): 2배씩 커진다
비트를 왼쪽으로 밉니다. 빈 오른쪽 자리는 0으로 채워집니다. 수학적으로는 2n을 곱하는 효과가 있습니다.
5 (0000 0101)
<< 1
----------
10 (0000 1010) -> 값 2배 증가int x = 5;
System.out.println(x << 2); // 5 * 2^2 = 202) `>>` (Right Shift): 2배씩 작아진다 (부호 유지)
비트를 오른쪽으로 밉니다. 이때 삭제되는 오른쪽 비트는 버려지고, 빈 왼쪽 자리는 '부호 비트(Sign Bit)'와 같은 값으로 채워집니다. (양수면 0, 음수면 1) 수학적으로는 2n으로 나누는 효과가 있습니다.
-8 (1111 1000)
>> 1
-----------
-4 (1111 1100) -> 앞자리가 1(음수)로 채워짐3) `>>>` (Unsigned Right Shift): 부호를 무시한다
특수한 연산자입니다. >>와 같지만, 빈 왼쪽 자리를 무조건 0으로 채웁니다. 즉, 음수를 밀어버리면 부호 비트가 사라지면서 거대한 양수로 변하게 됩니다.
int a = -8;
System.out.println(a >> 1); // -4 (부호 유지)
System.out.println(a >>> 1); // 2147483644 (부호 무시, 0으로 채움)실전 활용
<<,>>: 고성능이 필요한 알고리즘, 그래픽스 처리에서 곱셈/나눗셈 대체.>>>: 이미지 처리, 암호화(Hashing) 등 숫자의 부호(-, +)는 중요하지 않고, 비트의 패턴 자체가 중요한 바이너리 데이터를 다룰 때 사용합니다.
마치며: 기본기로 돌아가기
솔직히 말해서 오늘날 웹 애플리케이션을 개발하며 비트 연산을 직접 작성할 일은 드뭅니다. 하드웨어 성능은 차고 넘치며, 컴파일러는 이미 우리보다 똑똑하게 최적화를 수행하니까요.
하지만 반대로 생각해 볼까요? 우리가 사용하는 데이터베이스의 인덱싱, 네트워크 프로토콜, 이미지 압축 알고리즘의 기저에는 여전히 비트 연산이 치열하게 돌아가고 있습니다.
비트 연산을 공부하는 이유는 단순히 "코드를 짧게 줄이기 위해서"가 아닙니다. 컴퓨터가 데이터를 처리하는 가장 원초적인 방식을 이해하고, 더 깊이 있는 사고를 하기 위함입니다.
가끔은 10진수의 편리함을 내려놓고, 0과 1의 시선으로 코드를 바라보세요. 보이지 않던 최적화의 포인트들이 보일지도 모릅니다.
