Computer >> 컴퓨터 >  >> 프로그래밍 >> Ruby

루비 비트 연산 완벽 가이드: 연산자, 실전 활용 사례와 숨겨진 마법

Rails 앱을 평생 개발하면서도 비트 연산자를 한 번도 사용할 일이 없을 수 있습니다. 프로그래밍을 막 시작한 초보자라면 '비트 연산(bitwise)'이라는 단어 자체가 낯설게 느껴질 수도 있죠.

하지만 효율성이 중요하고 리소스가 제한적인 시스템에 관심을 갖는 순간, 비트 연산은 필수적인 지식이 됩니다. 네트워크 프로토콜, 암호화, 유닉스 파일 권한, 임베디드 시스템 등에서 비트 연산이 광범위하게 활용되기 때문입니다.

뿐만 아니라 컴퓨터가 두 숫자를 더하는 과정 같은 저수준 동작을 진정으로 이해하려면 비트 연산에 대한 이해가 반드시 필요합니다.

루비를 비롯한 많은 언어가 비트 연산을 기본적으로 지원하기 때문에, 나중에 코드에서 비트 연산자를 만났을 때 무슨 일이 일어나고 있는지 이해할 수 있도록 지금부터라도 익혀두면 큰 도움이 됩니다.

이 글에서는 비트 연산이 무엇인지, 어떤 상황에서 활용할 수 있는지, 그리고 루비에서 어떻게 효과적으로 사용할 수 있는지 살펴보겠습니다. 시작해 볼까요?

비트 연산이란 무엇인가?

컴퓨터의 가장 낮은 수준에는 1과 0, 즉 비트(bit)만 존재합니다. 루비나 다른 프로그래밍 언어에서 수행하는 모든 연산은 결국 1과 0의 형태로 저장되며, 컴퓨터의 소프트웨어가 우리가 보는 값과 실제 저장된 값 사이를 효율적으로 변환해 줍니다. 예를 들어 "hello"라는 문자열도 1과 0의 연속으로 저장됩니다.

비트 연산은 이러한 비트들, 주로 숫자로 표현된 비트들에 직접 접근하여 특정 작업을 수행할 수 있게 해줍니다. 일부 연산은 소프트웨어 기능을 더 우아하고 효율적으로 구현하는 데 도움을 줍니다.

비트 연산의 두 가지 중요한 특징을 짚어보겠습니다. 첫째, 비트를 직접 사용하기 때문에 정보 저장에 매우 효율적입니다. 둘째, 실행 속도가 매우 빠릅니다.

기본 동작 방식은 하나의 비트 집합에 연산자를 적용하는 것이며, 두 개의 비트 집합에 연산자를 사용할 수도 있습니다. 주요 연산들을 살펴보겠습니다.

NOT 비트 연산

NOT은 단항 연산(unary operation)으로, 하나의 비트 집합에만 적용됩니다. 동작 방식은 아주 간단합니다. 1을 0으로, 0을 1로 바꾸면 됩니다. 기호는 ~로 표현합니다.

~101000 = 010111

AND 비트 연산

AND는 두 개의 비트 집합에 적용되는 연산으로, 기호는 &이며 다음과 같은 논리를 따릅니다:

1 & 1 = 1
1 & 0 = 0
0 & 1 = 0
0 & 0 = 0

즉, 길이가 같은 두 비트 집합이 있을 때 결과는 각 자리별로 위 논리를 적용한 것입니다:

0110 AND
0111
-----
0110

루비에서 확인해 보면:

25.to_s(2)           # 11001
30.to_s(2)           # 11110
(25 & 30).to_s(2)    # 11000

OR 비트 연산

AND와 마찬가지로 두 개의 비트 집합에 적용되는 연산이 OR입니다. 기호는 |이며 다음 논리를 따릅니다:

1 | 1 = 1
1 | 0 = 1
0 | 1 = 1
0 | 0 = 0

양쪽 중 하나라도 1이면 결과는 1이고, 그렇지 않으면 0입니다. 아주 간단하죠! 더 많은 비트로 OR 연산을 살펴보겠습니다:

0110 OR
0111
-----
0111

루비에서는 다음과 같습니다:

25.to_s(2)           # 11001
30.to_s(2)           # 11110
(25 | 30).to_s(2)    # 11111

실전 예제 1: 권한 시스템

여기까지 읽으면서 이런 의문이 들었을 수 있습니다. "꽤 저수준적인 연산 같은데, 대체 어디에 쓸모가 있지?" 네트워크 프로토콜, 그래픽, 암호화를 직접 다룰 계획이 없다면 더욱 그렇겠죠.

하지만 여러분은 이미 권한(permission) 시스템을 사용해 본 경험이 있을 겁니다. 권한 관리야말로 비트 연산이 진가를 발휘하는 분야입니다. 사용자가 문서에서 수행할 수 있는 다양한 작업이 있는 권한 시스템을 상상해 보세요:

  • 보기(View)
  • 편집(Edit)
  • 삭제(Delete)
  • 다른 사용자 초대(Invite)

이제 이 작업들을 역할(role)로 모델링해 보겠습니다:

  • 어시스턴트(Assistant): 문서를 보고 편집할 수 있습니다.
  • 옵저버(Observer): 문서를 볼 수만 있습니다.
  • 저자(Author): 모든 작업을 수행할 수 있습니다.

이것을 어떻게 모델링할 수 있을까요? 특정 시점에 사용자가 이 역할 중 하나를 가지고 있는지 어떻게 알 수 있을까요? 한 가지 답은 바로 비트 연산입니다.

각 사용자에게 자신이 가진 권한을 나타내는 하나의 비트 집합만 저장하면 됩니다:

(오른쪽부터 시작)
1번째 비트: 보기
2번째 비트: 편집
3번째 비트: 삭제
4번째 비트: 다른 사용자 초대

예를 들면 다음과 같습니다:

0001 = 문서를 볼 수만 있음.
0011 = 문서를 보고 편집할 수 있음.
1001 = 볼 수 있고, 편집·삭제는 불가능하며, 다른 사람을 초대할 수 있음.

사용자들의 값이 설정되어 있다면, 원하는 권한과 매우 빠른 비교를 수행할 수 있습니다. 사용자가 문서를 편집할 수 있는지 확인한다고 가정해 봅시다. 비트 AND 연산을 활용할 수 있습니다:

# 이것을 비트 마스크(bit mask)라고 합니다.
# 확인하려는 값만 담고 있습니다. 여기서는 편집을 나타내는 두 번째 비트입니다.
EDIT_PERMISSION_MASK = 0b0010

# 빠르게 확인할 수 있는 메서드를 정의할 수 있습니다:
def can_edit_document?(user_permisions)
  (EDIT_PERMISSION_MASK & user_permisions) != 0
end

즉, 비트 AND 연산 결과가 0이 아니라면 해당 비트가 설정되어 있다는 뜻입니다:

0010 AND
1101
----
0000 == 0 이므로 권한 없음

0010 AND
1110
----
0010 != 0 이므로 권한 있음

같은 논리를 다른 권한에도 적용할 수 있습니다. 확인하려는 비트의 위치만 바꾸면 되고, 결과적으로 다음과 같은 상수와 메서드들이 만들어집니다:

VIEW_PERMISSION_MASK   = 0b0001
EDIT_PERMISSION_MASK   = 0b0010
DELETE_PERMISSION_MASK = 0b0100
INVITE_PERMISSION_MASK = 0b1000

게다가 새로운 권한을 동적으로 정의하고 미래에 추가할 때도 간단한 비트 검사만으로 처리할 수 있습니다.

예를 들어, 앞서 어시스턴트는 문서를 보고 편집할 수만 있다고 했으니 해당 사용자의 권한은 0011입니다. 이 값을 데이터베이스에 저장한 후, 앞서 정의한 메서드들로 어시스턴트가 특정 작업을 수행할 수 있는지 손쉽게 확인할 수 있습니다.

ASSISTANT_MASK = VIEW_PERMISSION_MASK | EDIT_PERMISSION_MASK
# 결과: 0011

# 선택적으로, 이 사용자가 어시스턴트인지 확인하는 메서드를 만들 수도 있습니다.
# 이 메서드는 User 클래스 안에 정의할 수 있습니다.
def is_assistant?(user)
  (user.permissions == ASSISTANT_MASK)
end

이 방식이 낯익게 느껴진다면 그 이유는, 유닉스 계열 시스템의 파일 권한에서 흔히 사용되는 것과 정확히 같은 접근 방식이기 때문입니다.

실전 예제 2: 팀 내 포지션

비트 연산을 조금 더 활용해 보겠습니다 😉.

또 하나 비교적 흔한 사례는 스포츠 팀의 포지션이나 회사의 직무 구분입니다. 설명을 단순화하기 위해 농구 팀으로 가정해 보겠습니다.

농구 경기에는 5개의 포지션이 있습니다: - 포인트 가드(Point guard) - 슈팅 가드(Shooting guard) - 스몰 포워드(Small forward) - 파워 포워드(Power forward) - 센터(Center)

각 포지션에 비트 집합을 할당할 수 있습니다: 00001 포인트 가드 00010 슈팅 가드 00100 스몰 포워드 01000 파워 포워드 10000 센터

루비로 표현하면 다음과 같습니다:

POINT_GUARD_POSITION    = 0b00001
SHOOTING_GUARD_POSITION = 0b00010
SMALL_FORWARD_POSITION  = 0b00100
POWER_FORWARD_POSITION  = 0b01000
CENTER_POSITION         = 0b10000

POINT_GUARD_POSITION | SHOOTING_GUARD_POSITION | SMALL_FORWARD_POSITION | POWER_FORWARD_POSITION | CENTER_POSITION # = 31

이제 흥미로운 작업을 할 수 있습니다. 예를 들어 전체 팀이 모두 출석했는지 다음과 같이 확인할 수 있습니다:

# p1...p5는 각 선수의 포지션입니다
is_full_team_present = (p1 | p2 | p3 | p4 | p5 == 31)

왜 그럴까요? 비트 OR 연산을 수행하면 모든 포지션이 갖춰졌을 때 결과가 11111이 되기 때문입니다.

# OR 비트 연산
00001 |
00010 |
00100 |
01000 |
10000
-----
11111

그리고 11111은 31입니다. 2^0 + 2^1 + 2^2 + 2^3 + 2^4 = 31이기 때문입니다.

엄밀히 말하면 비트 연산과 직접 관련은 없지만, 이런 데이터 모델링을 활용하면 두 선수가 서로 교체 가능한지도 간단히 확인할 수 있습니다:

def can_be_exchanged?(player1, player2)
  player1.position == player2.position
end

XOR

두 비트 집합에 적용할 수 있는 또 다른 연산은 XOR이며, 기호는 ^입니다.

XOR은 배타적 OR(exclusive OR)를 의미하며 다음 논리를 따릅니다:

1 ^ 1 = 0
1 ^ 0 = 1
0 ^ 1 = 1
0 ^ 0 = 0

즉, 두 비트 중 하나만 1일 때 결과가 1이고, 둘이 같으면 0입니다.

이 연산은 x ^ x = 0이라는 성질 때문에, 어떤 알고리즘에서 숫자를 자기 자신과 비교할 때 활용됩니다.

시프트(Shift)

흥미로운 연산 그룹으로, 비트 집합 안에서 비트를 한쪽으로 "이동"시키는 시프트 연산이 있습니다.

비트 시프트를 하면 비트들이 왼쪽 또는 오른쪽으로 밀려납니다:

00010111 left-shift
<-------
00101110
10010111 right-shift
------->
11001011

비트를 n번 이동시킬 수도 있습니다. 루비에서 숫자 5에 왼쪽 시프트를 두 번 적용한 예입니다:

5.to_s(2) # 101
(5 << 2).to_s(2) # 10100

왼쪽 시프트는 <<로 표현하고, 오른쪽 시프트는 >>를 사용합니다:

5.to_s(2) # 101
(5 >> 2).to_s(2) # 1

이 경우 결과가 1뿐인 이유는, 101에서 밀려나간 비트 "0"과 "1"이 버려졌기 때문입니다.

오른쪽 시프트로 2 나누기

비트 시프트의 흥미로운 점 중 하나는 수학 연산을 대신 수행할 수 있다는 것입니다. 과거에는 이 방식이 더 빨랐지만, 요즘은 게임 개발처럼 리소스 제약이 심한 환경에서 일하는 프로그래머들이 주로 사용합니다.

숫자에 오른쪽 시프트를 적용하면 2로 나눈 결과를 얻습니다:

10.to_s(2)        # 1010
(10 >> 1).to_s(2) # 101
10 >> 1           # 5

왼쪽 시프트로 2 곱하기

같은 원리로 왼쪽 시프트를 사용하면 2를 곱할 수 있습니다:

10.to_s(2)        # 1010
(10 << 1).to_s(2) # 10100
10 << 1           # 20

홀수·짝수 빠르게 판별하기

비트 연산의 아주 간단하면서도 빠르고 이해하기 쉬운 예제가 있습니다.

숫자와 1, 딱 1과의 AND 연산을 해본다고 상상해 보세요. 즉, 컴퓨터 환경에 따라 여러 개의 0과 마지막에 1이 있는 값입니다. 2로 먼저 해보겠습니다:

2 = 00000010 &
    00000001
-------------
    00000000

이번엔 4입니다:

4 = 00000100 &
    00000001
-------------
    00000000

그렇다면 5는 어떨까요?

5 = 00000101 &
    00000001
-------------
    00000001

이번에는 1이 나왔습니다. 무슨 의미인지 짐작이 가나요?

1과의 AND 연산에서 짝수면 결과가 0, 홀수면 1이 나옵니다. 이 성질을 활용하면 루비에서 간단한 메서드를 만들 수 있습니다:

def is_odd?(number)
  number & 1
end
def is_even?(number)
  is_odd?(number) == 0
end
# 또는:
def is_even?(number)
  (number & 1) == 0
end

더 깊이 들어가고 싶거나 비트로 인생이 바뀔 듯한 순간을 경험하고 싶다면, 유명한 비트 해킹(bitwise hacks) 컬렉션을 찾아보는 것도 추천합니다. 다양한 트릭을 만날 수 있습니다.

마치며

비트 연산은 처음 접하면 따라가기 어렵게 느껴지지만, 한 번 익숙해지면 이를 활용하는 기존 프로젝트나 미래의 프로젝트를 다룰 준비가 훨씬 잘 되어 있을 것입니다. 게다가 코드 문제의 해결책을 설계할 때 활용할 수 있는 강력한 새 도구가 생기는 셈입니다.