2.6. 텍스트 대 바이트

Python에는 원시 문자 데이터를 위한 두 가지 시퀀스 타입이 있습니다:

  • str – 유니코드 코드포인트의 시퀀스. 파일 경로, 로그 메시지, JSON 페이로드 등 사람이 읽을 수 있는 모든 텍스트에 사용됩니다.

  • bytes – 0부터 255 범위의 정수 시퀀스. UART 프레임, 이미지 버퍼, 네트워크 패킷, 레지스터 값 등 원시 이진 데이터에 사용됩니다.

이 둘은 명시적 변환 없이 혼합할 수 없습니다. str을 하드웨어 write 메서드에 전달하면 TypeError가 발생하며, 그 반대도 거부됩니다.

왼쪽에는 유니코드 코드포인트의 str, 오른쪽에는 원시 옥텟의 bytes 시퀀스가 있고, 그 사이에 encode와 decode 화살표가 있습니다.

str 은 유니코드 문자를 저장하고, bytes 는 원시 옥텟(octet)을 저장합니다. 이 둘 사이를 오가는 것이 인코딩(str → bytes)과 디코딩(bytes → str)입니다.

2.6.1. bytes 리터럴

bytes 리터럴은 b가 접두사로 붙은 문자열 형태의 리터럴입니다:

header  = b"OMV"
crlf    = b"\r\n"
payload = b"\x01\x02\x03"

bytes 리터럴 안에는 ASCII 문자만 직접 사용할 수 있으며, ASCII가 아닌 값은 \xHH 16진수 이스케이프로 작성해야 합니다.

2.6.2. 인코딩과 디코딩

  • str.encode()는 명명된 인코딩(기본값 "utf-8")을 사용하여 문자열을 바이트로 변환합니다.

  • bytes.decode()는 그 반대를 수행합니다.

>>> "hello".encode()
b'hello'
>>> "héllo".encode()
b'h\xc3\xa9llo'              # é is two bytes in UTF-8
>>> b"hello".decode()
'hello'

UTF-8은 기본값이며, ASCII가 아닌 문자가 포함될 수 있는 모든 경우에 적합한 선택입니다. 데이터가 순수 ASCII임이 보장될 때만 "ascii"를 사용하세요. 그래야 ASCII가 아닌 바이트가 끼어들었을 때 조용히 통과되지 않고 UnicodeError가 발생합니다.

2.6.3. 인덱싱과 슬라이싱

bytes 값은 인덱싱할 때 한 바이트짜리 문자열의 시퀀스가 아니라 정수의 시퀀스처럼 동작합니다:

>>> data = b"abc"
>>> data[0]
97                           # the int 97, not 'a'
>>> data[0:1]
b'a'                         # slicing returns bytes

흔한 실수는 data[0] == "a"로 비교한 뒤 결과가 False인 것에 놀라는 것입니다 – data[0]은 한 문자짜리 문자열이 아니라 정수이므로 두 값은 절대 일치할 수 없습니다.

2.6.4. ord와 chr – 문자와 정수 잇기

bytes를 인덱싱하면 정수가 반환되지만 프로그램의 나머지 부분은 대개 문자 단위로 생각하기 때문에, Python은 둘 사이를 오가기 위한 두 가지 내장 함수를 제공합니다:

  • ord() – 한 문자짜리 문자열을 받아 그 정수 코드포인트를 반환합니다.

  • chr() – 그 반대로, 정수가 주어지면 해당 코드포인트의 한 문자짜리 문자열을 반환합니다.

>>> ord("a")
97
>>> chr(97)
'a'
>>> ord("A"), chr(0x41)
(65, 'A')

ASCII 문자의 경우 코드포인트는 바이트 값과 같으므로, ord("a")b"a"[0]은 모두 97을 반환합니다. 덕분에 바이트 비교를 실제로 관심 있는 문자 관점에서 읽을 수 있습니다:

>>> data = b"abc"
>>> data[0] == ord("a")          # instead of the magic number 97
True

그리고 chr()는 바이트의 인쇄 가능한 형태를 보고 싶을 때 로깅이나 디버깅에 유용합니다:

>>> chr(data[0])
'a'

ASCII가 아닌 문자의 경우 ord()는 유니코드 코드포인트를 반환하는데, 이는 인코딩된 형태의 어떤 단일 바이트와도 같지 않습니다. 바이트 표현은 인코딩에 따라 달라집니다.

2.6.5. 가변 버퍼를 위한 bytearray

bytes는 불변입니다 – 모든 “수정”은 새 객체를 반환하고 원본은 그대로 둡니다. 수정하거나, 추가하거나, 조금씩 채워 넣을 데이터에는 bytearray를 사용하세요. 이는 bytes와 동일한 내용을 담지만 제자리 변경을 지원합니다:

>>> s = b"hello"
>>> s[0] = ord("H")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'bytes' object does not support item assignment

>>> s = bytearray(b"hello")
>>> s[0] = ord("H")
>>> s
bytearray(b'Hello')

2.6.5.1. bytearray 생성

bytearray 생성자는 여러 입력을 받습니다:

  • bytearray(8) – 0 바이트 8개로 이루어진 버퍼.

  • bytearray(b"hello") – bytes 값의 가변 복사본.

  • bytearray("hello", "utf-8") – 주어진 인코딩을 사용하여 문자열로부터 만든 bytearray.

  • bytearray([72, 73, 74]) – 0부터 255 범위의 정수 시퀀스로부터 만든 bytearray(여기서는 b"HIJ").

>>> bytearray(4)
bytearray(b'\x00\x00\x00\x00')
>>> bytearray(b"abc")
bytearray(b'abc')
>>> bytearray("café", "utf-8")
bytearray(b'caf\xc3\xa9')

2.6.5.2. bytearray 수정

인덱스 및 슬라이스 할당은 list와 똑같이 동작합니다:

>>> buf = bytearray(8)        # 8 zero bytes
>>> buf[0] = 0xFF             # one byte at a time
>>> buf[1:4] = b"ABC"         # replace a slice
>>> buf
bytearray(b'\xffABC\x00\x00\x00\x00')

개별 바이트는 0부터 255 범위의 정수여야 합니다. 다른 타입을 할당하면 TypeError 또는 ValueError가 발생합니다.

슬라이스 할당은 버퍼의 길이를 변경할 수 있습니다. 슬라이스를 더 긴 값으로 교체하면 bytearray가 커지고, 더 짧은 값으로 교체하면 줄어듭니다. b""로 교체하면 슬라이스가 완전히 삭제됩니다:

>>> buf = bytearray(b"abcdef")
>>> buf[1:3] = b"XYZ"         # 2 bytes replaced with 3
>>> buf
bytearray(b'aXYZdef')
>>> buf[1:4] = b""            # delete the inserted run
>>> buf
bytearray(b'adef')

bytearray.append()bytearray.extend() 메서드는 매번 전체 버퍼를 재할당하지 않고 끝에 바이트를 추가합니다:

>>> buf = bytearray()
>>> buf.append(0x01)
>>> buf.extend(b"abc")
>>> buf
bytearray(b'\x01abc')

2.6.5.3. bytearray에서 읽기

인덱싱, 슬라이싱, 반복, 그리고 bytes 검사 메서드(bytes.startswith(), bytes.find(), bytes.strip() 등)는 모두 bytes 값에서와 동일하게 동작합니다. 인덱싱은 정수를 반환하고, 슬라이싱은 또 다른 bytearray를 반환합니다:

>>> buf = bytearray(b"OpenMV")
>>> buf[0]
79
>>> buf[0:4]
bytearray(b'Open')
>>> buf.startswith(b"Open")
True

2.6.5.4. bytes와 bytearray 간 변환

bytesbytearray는 각자의 생성자로 서로 변환됩니다. API가 특정 형태를 요구할 때 이를 사용하세요:

>>> ba = bytearray(b"hello")
>>> snapshot = bytes(ba)      # immutable copy
>>> ba[0] = ord("H")
>>> ba, snapshot
(bytearray(b'Hello'), b'hello')

2.6.5.5. 제로 복사 슬라이싱을 위한 memoryview

bytesbytearray를 슬라이싱하면 보통 바이트를 새 버퍼로 복사합니다. memoryview는 복사 없이 동일한 바이트를 노출합니다:

>>> buf = bytearray(b"OpenMV Cam")
>>> view = memoryview(buf)
>>> view[0:6]                 # shares storage with buf
<memoryview ...>
>>> bytes(view[0:6])          # materialise as bytes when needed
b'OpenMV'

bytearray에 대한 뷰는 쓰기도 가능합니다 – 뷰를 변경하면 기저 버퍼가 변경됩니다:

>>> view[0] = ord("o")
>>> buf
bytearray(b'openMV Cam')

슬라이스를 복사하는 것이 낭비일 때 – 일반적으로 같은 대형 버퍼를 이리저리 전달하거나 조각 단위로 처리할 때 – memoryview를 사용하세요. 작은 bytes에 대한 일상적인 문자열 스타일 작업에는 일반 슬라이싱으로 충분합니다.