비트 연산자 완전 정복 — `AND`, `OR`, `XOR`, `NOT`, 시프트를 이진수로 이해하기
비트 연산자, 왜 따로 알아야 하나요?
개발을 하다 보면 비트 연산자는 평소에는 잘 안 쓰다가도, 특정 순간에 갑자기 아주 중요해집니다.
- 권한 플래그를 한 정수에 모아 저장할 때
- 네트워크 마스크나 상태 비트를 해석할 때
- 압축, 암호화, 해시, 직렬화 같은 저수준 로직을 볼 때
- 성능보다도 정확하게 특정 비트만 켜고 끄는 작업이 필요할 때
그런데 막상 비트 연산자를 보면 많은 분이 여기서 막힙니다.
&와&&는 뭐가 다를까요?^는 제곱이 아니라는데, 그럼 정확히 무슨 뜻일까요?- 왜
~13은-14가 될까요? >>와>>>는 둘 다 오른쪽 시프트 같은데 왜 따로 있을까요?
핵심은 이것입니다.
비트 연산자는 숫자를 "십진수 값"이 아니라 "비트 패턴"으로 다루는 연산자입니다
즉, 13이라는 숫자를 그냥 13으로 보는 것이 아니라:
00001101
같은 비트 묶음으로 보고 직접 조작하는 것입니다.
이 글에서는 비트 연산자를 외워서 끝내지 않고, 2진수 감각, 각 연산자의 의미, 음수와 2의 보수, 시프트 연산, 비트 마스크 실전 패턴까지 한 번에 정리하겠습니다.
기준: 이 글은 비트 연산의 핵심 규칙을
Java Language Specification SE 23과ECMAScript사양 기준으로 설명합니다. 비트 그림은 이해를 위해 8비트로 단순화해서 보여 주고, 실제 음수와 시프트 예시는 주로 Java의 32비트int를 기준으로 보며, JavaScript 차이는 별도 구간에서 짚겠습니다.
먼저 가장 짧은 답부터 보면
- 비트 연산자는 정수의 각 비트를 직접 다루는 연산자입니다
&는 둘 다 1일 때만 1,|는 하나라도 1이면 1,^는 서로 다를 때만 1입니다~는 비트를 뒤집고,<<,>>,>>>는 비트를 왼쪽 또는 오른쪽으로 밀어 냅니다- 비트 연산을 이해하려면 2진수와 2의 보수(two's complement) 를 같이 알아야 합니다
- 실무에서는 플래그 저장, 마스크 추출, 권한 체크, 상태값 조작에 특히 많이 쓰입니다
Phase 1. 비트 연산 전 꼭 알아야 하는 것 — 비트와 2진수
비트 연산자는 결국 0과 1의 자리수를 다루는 연산입니다. 그래서 먼저 2진수 감각이 있어야 합니다.
비트는 자리마다 값이 다릅니다
10진수에서 345가:
3 * 100 + 4 * 10 + 5 * 1
인 것처럼, 2진수도 자리값이 있습니다.
자리값: 128 64 32 16 8 4 2 1
비트값: 0 0 0 0 1 1 0 1
예를 들어 00001101은:
8 + 4 + 1 = 13
이므로 10진수 13입니다.
예시 두 개만 놓고 계속 보겠습니다
이 글에서는 아래 두 숫자를 계속 예시로 쓰겠습니다.
13 = 00001101
10 = 00001010
이 두 값을 놓고 보면 각 비트가 어디서 다른지 눈에 잘 들어옵니다.
Phase 2. AND, OR, XOR는 비트를 어떻게 계산할까?
비트 연산은 전체 숫자를 한 번에 계산하는 것이 아니라, 각 자리끼리 독립적으로 계산합니다.
1비트 기준 규칙은 이렇습니다
먼저 이름과 기호를 같이 붙여 두면 표를 읽기 쉽습니다.
AND=&OR=|XOR=^
| A | B | A AND B | A OR B | A XOR B |
|---|---|---|---|---|
| 0 | 0 | 0 | 0 | 0 |
| 0 | 1 | 0 | 1 | 1 |
| 1 | 0 | 0 | 1 | 1 |
| 1 | 1 | 1 | 1 | 0 |
즉:
AND(&)는 교집합OR(|)는 합집합XOR(^)는 서로 다를 때만 1
처럼 이해하면 직관이 좋습니다.
13과 10으로 직접 보면
13 = 00001101
10 = 00001010
AND (&)
00001101
00001010
--------
00001000 -> 8
OR (|)
00001101
00001010
--------
00001111 -> 15
XOR (^)
00001101
00001010
--------
00000111 -> 7
왜 그런지 한 줄씩 보면:
&는 둘 다 1인 자리만 남기므로00001000|는 하나라도 1이면 1이므로00001111^는 서로 다른 자리만 1이므로00000111
이 세 연산자의 실무 감각
AND(&)는 "특정 비트만 남기기"
가장 많이 쓰는 형태는 마스크와 함께 쓰는 것입니다.
value = 11010110
mask = 00001111
value & mask = 00000110
즉, 필요한 자리만 추출할 때 좋습니다.
OR(|)는 "특정 비트 켜기"
value = 00000101
mask = 00001000
value | mask = 00001101
이미 1인 자리는 그대로 두고, 원하는 자리만 안전하게 켤 수 있습니다.
XOR(^)는 "토글하기"
value = 00001101
mask = 00000100
value ^ mask = 00001001
1이면 0으로, 0이면 1로 뒤집습니다. 그래서 toggle 용도로 자주 씁니다.
다만 여기서 중요한 주의점이 있습니다.
어떤 비트를 "항상 켜고 싶다"면
^가 아니라|를 써야 합니다
^는 두 번 적용하면 다시 원래 값으로 돌아오기 때문입니다. 즉, XOR는 "설정"이 아니라 반전입니다.
Phase 3. NOT(~)가 헷갈리는 이유 — 음수와 2의 보수
많은 분이 비트 연산에서 가장 당황하는 지점이 여기입니다.
System.out.println(~13); // -14
"비트를 뒤집었는데 왜 갑자기 음수지?"라는 질문이 자연스럽습니다.
~는 정말로 모든 비트를 뒤집습니다
8비트 그림으로 단순화하면:
13 = 00001101
~13 = 11110010
여기까지는 단순합니다. 문제는 11110010을 컴퓨터가 부호 있는 정수로 해석할 때입니다.
컴퓨터는 보통 음수를 2의 보수로 저장합니다
대부분의 현대 언어와 CPU는 signed integer를 2의 보수(two's complement) 방식으로 표현합니다.
핵심만 보면:
- 맨 앞 비트가 0이면 양수
- 맨 앞 비트가 1이면 음수일 가능성이 큽니다
- 음수는 단순히 "절댓값 앞에 마이너스"가 아니라, 정해진 비트 폭 안에서 2의 보수 형태로 저장됩니다
예를 들어 8비트에서 -14는:
14 = 00001110
비트 반전 = 11110001
1 더하기 = 11110010
가 됩니다. 즉:
11110010 = -14
이므로 ~13의 결과가 -14처럼 보이는 것입니다.
그래서 자주 쓰는 공식이 하나 있습니다
2의 보수 signed integer에서는 다음 관계가 성립합니다.
~x = -(x + 1)
예를 들어:
~13 = -(13 + 1) = -14
~0 = -(0 + 1) = -1
이 공식은 NOT 결과를 빨리 검산할 때 꽤 유용합니다.
Phase 4. 시프트 연산 — <<, >>, >>>
시프트는 비트를 좌우로 민다고 생각하면 됩니다.
왼쪽 시프트 <<
왼쪽으로 한 칸 밀면 빈 자리는 0으로 채웁니다.
5 = 00000101
5 << 1 = 00001010 -> 10
5 << 2 = 00010100 -> 20
Java의 2의 보수 정수 기준에서는 n << k를 2^k를 곱한 결과와 같은 방향으로 이해해도 좋습니다.
다만 실제로는 "곱셈"이 아니라 비트 이동입니다. 정해진 비트 폭 안에서 계산되므로 오버플로가 나면 우리가 기대한 수학적 곱셈 결과와는 달라질 수 있습니다.
오른쪽 시프트 >>
오른쪽으로 밀면 낮은 자리는 버려집니다. 보통 signed integer에서는 부호 비트를 유지하면서 밀어 줍니다.
20 = 00010100
20 >> 1 = 00001010 -> 10
20 >> 2 = 00000101 -> 5
Java의 signed integer 기준에서 n >> k는 부호를 유지한 채 오른쪽으로 미는 연산입니다. 양수에서는 직관적으로 2^k로 나누는 느낌에 가깝고, 값으로 보면 floor(n / 2^k)와 같은 결과가 됩니다.
하지만 음수에서는 이야기가 달라집니다. 예를 들어 Java에서는:
System.out.println(-8 >> 1); // -4
처럼 부호를 유지한 채 이동합니다.
논리 오른쪽 시프트 >>>
>>>는 빈 자리를 무조건 0으로 채우는 오른쪽 시프트입니다. 그래서 signed integer를 다룰 때 >>와 결과가 달라질 수 있습니다.
Java의 32비트 int 기준으로 보면:
System.out.println(-8 >> 1); // -4
System.out.println(-8 >>> 1); // 2147483644
왜 이렇게 되냐면, -8의 비트 패턴을 오른쪽으로 밀 때:
>>는 왼쪽을 1로 채워서 음수 성질을 유지하고>>>는 왼쪽을 0으로 채워서 큰 양수처럼 바뀌기 때문입니다
즉:
>>는 산술 시프트(arithmetic shift)>>>는 논리 시프트(logical shift)
라고 생각하면 됩니다.
Phase 5. 비트 마스크는 왜 이렇게 자주 나올까?
비트 연산은 단독으로 보기보다, 보통 비트 마스크(mask) 와 같이 볼 때 실무 감각이 생깁니다.
마스크는 쉽게 말해:
"어느 비트를 건드릴지 표시해 둔 패턴"
입니다.
특정 비트가 켜져 있는지 확인하기
int READ = 1 << 0; // 0001
int WRITE = 1 << 1; // 0010
int EXEC = 1 << 2; // 0100
int permission = READ | WRITE; // 0011
boolean canWrite = (permission & WRITE) != 0;
여기서 (permission & WRITE)는 WRITE 자리가 켜져 있으면 0이 아닌 값이 됩니다.
특정 비트를 켜기
permission |= EXEC;
이 연산은 EXEC 비트를 켭니다. 이미 켜져 있어도 그대로이므로 idempotent 합니다.
특정 비트를 끄기
permission &= ~WRITE;
이 패턴은 실무에서 정말 자주 봅니다.
WRITE마스크를 준비하고~WRITE로 해당 자리만 0, 나머지는 1로 뒤집고AND해서 그 자리만 지웁니다
특정 비트를 토글하기
permission ^= EXEC;
켜져 있으면 끄고, 꺼져 있으면 켭니다. 다만 앞서 말했듯이 "항상 켜기"에는 적합하지 않습니다.
Phase 6. 실무에서 자주 만나는 패턴
비트 연산을 배우는 이유는 결국 패턴을 빠르게 읽기 위해서입니다.
1. 플래그 여러 개를 한 값에 저장하기
권한, 옵션, 상태값처럼 예/아니오가 여러 개 있는 값은 비트 플래그로 표현하기 좋습니다.
bit 0 = 읽기 가능
bit 1 = 쓰기 가능
bit 2 = 삭제 가능
bit 3 = 관리자 여부
이런 식으로 정의하면 하나의 int 안에 여러 상태를 압축해서 담을 수 있습니다.
2. 특정 구간만 추출하기
프로토콜 헤더나 색상값처럼 한 정수 안에 여러 필드가 들어 있을 때는 마스크와 시프트를 같이 씁니다.
예를 들어 하위 4비트만 꺼내고 싶다면:
int value = 0b11010110;
int low4 = value & 0b00001111; // 0110
중간 비트를 꺼낼 때는:
int middle = (value >> 2) & 0b00000111;
처럼 먼저 시프트한 뒤 필요한 구간만 남깁니다.
3. 홀수/짝수 판별
가장 마지막 비트는 1의 자리이므로:
- 마지막 비트가 0이면 짝수
- 마지막 비트가 1이면 홀수
입니다.
boolean isOdd = (n & 1) == 1;
이 패턴은 단순하지만 비트 연산의 핵심 감각을 잘 보여 줍니다.
Phase 7. 자주 하는 실수들
비트 연산 자체보다도, 아래 함정을 모르면 디버깅이 오래 걸립니다.
&와 &&를 같은 것으로 보기
이 둘은 전혀 다릅니다.
&는 비트 연산자&&는 논리 연산자
언어에 따라 &를 boolean에도 쓸 수 있지만, 의미는 short-circuit 논리 연산과 다를 수 있습니다. 비트 연산 문맥에서는 &를 "비트별 AND"로 읽는 습관을 들이는 편이 안전합니다.
XOR를 "설정" 연산으로 쓰기
permission ^= WRITE;
이 코드는 WRITE를 "켜는" 코드가 아닙니다. 이미 켜져 있으면 꺼집니다. 그래서 토글이 목적이 아닐 때는 | 또는 & ~를 써야 합니다.
~ 결과를 unsigned처럼 상상하기
~00001101 = 11110010만 보고 242라고 생각할 수도 있지만, 언어가 signed integer로 해석하면 -14가 됩니다. 비트 패턴과 정수 해석 방식을 같이 봐야 합니다.
왼쪽 시프트를 무조건 곱셈으로 생각하기
n << 1은 많은 경우 n * 2처럼 보이지만:
- 오버플로가 날 수 있고
- 부호 비트가 바뀔 수 있고
- 언어별 정수 폭에 따라 결과가 달라질 수 있습니다
그래서 "곱셈 대체 최적화" 관점으로 보기보다, 비트 이동 자체가 의미인 상황에서 쓰는 편이 더 정확합니다.
JavaScript의 비트 연산을 일반 정수 연산처럼 보기
JavaScript는 이 지점이 특히 헷갈립니다.
Number에 대한 비트 연산은 피연산자를 32비트 정수로 변환해서 처리합니다BigInt에 대한&,|,^,~,<<,>>는 별도 규칙으로 동작하며 32비트로 잘리지 않습니다>>>는BigInt에 사용할 수 없습니다Number와BigInt를 비트 연산에서 섞어 쓰면TypeError가 납니다
즉, 언어 문법이 같아 보여도:
- 정수 폭
- signed/unsigned 해석
- 시프트 동작
은 언어마다 다를 수 있습니다.
마무리하면
비트 연산자를 가장 짧게 비교하면 이렇습니다.
| 연산 이름 | 핵심 역할 | 가장 자주 하는 일 | 주의할 점 |
|---|---|---|---|
| AND | 필요한 비트만 남기기 | 마스크 추출, 권한 체크 | 기호는 앰퍼샌드이고, 논리 AND와 다릅니다 |
| OR | 비트 켜기 | 플래그 설정 | 기호는 세로 막대이고, 이미 켜진 비트는 그대로 유지됩니다 |
| XOR | 서로 다른 비트만 1 | 비트 토글 | 기호는 캐럿이고, "항상 켜기" 용도는 아닙니다 |
| NOT | 전체 비트 반전 | 마스크 보조 연산 | 기호는 틸드이고, signed integer에서는 음수 해석이 따라옵니다 |
| LEFT SHIFT | 왼쪽 이동 | 자리 확장, 비트 배치 | 기호는 왼쪽 시프트이고, 비트 폭을 넘으면 오버플로를 봐야 합니다 |
| SIGNED RIGHT SHIFT | 부호 유지 오른쪽 이동 | signed 값 축소 | 기호는 부호 유지 오른쪽 시프트이고, 음수에서는 단순 나눗셈 감각만으로 보면 틀릴 수 있습니다 |
| ZERO-FILL RIGHT SHIFT | 0 채우기 오른쪽 이동 | zero-fill shift | 기호는 0 채우기 오른쪽 시프트이고, JavaScript BigInt에는 없습니다 |
그리고 핵심 포인트는 아래 네 가지로 정리할 수 있습니다.
- 비트 연산은 숫자를 값으로 보기보다, 고정 폭의 비트 패턴으로 보는 순간부터 이해가 쉬워집니다.
AND,OR,XOR,NOT를 외우기보다, 각 비트 자리에서 어떤 규칙이 적용되는지 직접 펼쳐 보는 습관이 더 중요합니다.- 음수와 시프트를 다룰 때는 반드시 기준 언어를 정해야 합니다. 특히
signed integer, 2의 보수,>>>지원 여부는 언어마다 다를 수 있습니다. - 실무에서는 연산 자체보다 마스크 패턴을 읽는 능력이 더 중요합니다. 권한, 상태값, 플래그 코드를 볼 때 특히 그렇습니다.
2단계 로킹 규약 완전 정복 — 2PL, Strict 2PL, 직렬 가능성까지
다음 글SOLID 원칙 완전 정복 — `SRP`, `OCP`, `LSP`, `ISP`, `DIP`를 변경 비용으로 이해하기