기본 콘텐츠로 건너뛰기

When Exceptions Change: struct.Struct Boundaries

thumbnail

struct.Struct가 던지는 예외가 바뀌면, 내 장애 대응도 바뀐다: PR #145851 읽는 법

meta_description: CPython PR #145851은 struct.Struct의 내부 구현을 손보면서, 비ASCII 포맷 처리와 초기화되지 않은 객체의 속성 접근에서 던지는 예외 타입을 더 일관되게 정리했다. “예외 타입 하나쯤”이 테스트/에러 핸들링/호환성에서 어떤 차이를 만들 수 있는지, diff에서 어디를 보고 내 코드에 무엇을 점검할지 정리한다. meta_keywords: struct, Struct, format, ValueError, UnicodeEncodeError, UnicodeDecodeError, AttributeError, RuntimeError, CPython, PR145851, non-ASCII, surrogateescape, bytes, str, exception, 테스트, 호환성, 에러처리, 파이썬 meta_robots: index,follow

운영에서 제일 성가신 버그는 “같은 입력인데 어떤 환경에서만 터지는 것”이다. 그중에서도 예외 타입이 바뀌는 종류는 더 귀찮다.

  • 코드는 except UnicodeEncodeError:로 잡고 있었는데, 어느 날부터 ValueError가 날아오고
  • 테스트는 “정확한 예외 클래스”를 기대하고 있어서 갑자기 줄줄이 깨지고
  • 로그/알람은 예외 이름을 키로 집계해서, 그래프가 한순간에 찢어진다

CPython PR #145851은 딱 그 종류의 변화다. 겉으로는 “구현 디테일 변경”인데, 실제로는 예외의 계약(contract)을 조금 더 선명하게 만든다.



PR이 말하는 변화 3가지(그리고 왜 실무에선 체감이 큰지)

PR 페이지 요약과 diff를 보면 변화는 세 묶음으로 정리된다.

1) non-ASCII 문자열 포맷을 넣을 때: UnicodeEncodeError 대신 ValueError 2) non-ASCII bytes 포맷을 넣을 때: struct.error 대신 “더 명확한” 예외(테스트가 바뀜) 3) 초기화되지 않은 Struct 인스턴스에서 .format 접근: RuntimeError 대신 AttributeError

이게 왜 중요하냐면, 예외 타입은 단순히 “에러의 이름표”가 아니다.

  • 어떤 예외는 “입력이 잘못됐다”를 의미하고
  • 어떤 예외는 “내부 상태가 잘못됐다/준비되지 않았다”를 의미하고
  • 어떤 예외는 “인코딩/디코딩 경계에서 실패했다”를 의미한다

운영 코드에선 이 의미가 바로 대응으로 바뀐다.

  • 입력 검증으로 막을 문제인지
  • 재시도/폴백으로 처리할 문제인지
  • 버그로 보고 이슈를 열어야 하는지

diff에서 딱 여기만 보면 된다: _struct.c의 포맷 처리 경계

이 PR의 핵심은 포맷을 준비하는 경계(set_format/prepare_s)를 어떻게 잡느냐에 있다.

diff를 보면, 예전엔 유니코드 포맷을 ASCII bytes로 바꾸는 흐름이 강했다. 그런데 이번 PR은 “애초에 포맷은 유니코드로 들고 가고, ASCII인지 먼저 확인”하는 쪽으로 바뀐다.

예를 들어 이런 식의 조건이 들어간다(요지는 ‘non-ASCII면 ValueError’다).

if (!PyUnicode_IS_ASCII(format)) {
    PyErr_SetString(PyExc_ValueError, "non-ASCII character in struct format");
    return -1;
}

실무에서 이건 꽤 좋은 변화다. 예전엔 non-ASCII가 들어왔을 때 “인코딩 중 실패”처럼 보이는 예외가 나왔는데, 이제는 입력 자체가 잘못된 포맷 문자열이라는 쪽으로 의미가 정리된다.

이 변화가 특히 도움이 되는 곳은 “사용자가 포맷을 구성하는” 코드다.

  • 템플릿에서 포맷을 합성한다
  • 외부 설정 파일에서 포맷을 읽는다
  • 로그/프로토콜 정의를 문자열로 받아서 Struct(format)을 만든다

이때 ValueError는 바로 “네 포맷 문자열이 잘못됐어”로 처리할 수 있다.


초기화 반쪽짜리 객체에서 .formatAttributeError가 되는 이유

diff를 따라가다 보면 s_format이 예전처럼 Py_None로 채워지는 게 아니라, NULL로 시작하는 흐름이 보인다. 그리고 멤버로 format을 노출하는 방식도 바뀐다.

이 결과가 테스트에서 이렇게 드러난다.

  • 예전: 반쯤 초기화된 객체에서 getattr(S, 'format')RuntimeError
  • 이제: AttributeError

이건 언뜻 사소해 보이지만, 파이썬스러운 관점에선 더 자연스럽다.

  • “아직 없는 속성”은 AttributeError
  • “실행할 수 없는 상태”는 RuntimeError

반쪽짜리 객체에서 .format은 “실행 불가”라기보다 “아직 없는 값”에 가깝다. 그리고 AttributeError는 많은 코드가 이미 자연스럽게 다루는 예외다(예: hasattr, getattr(obj, 'x', default) 같은 패턴).

이 변화는 라이브러리 작성자에게 특히 중요하다. 내부에서 Struct를 생성하다가 실패했을 때, 중간 객체가 노출되는 경우가 있다면, 그 객체의 속성 접근이 어떤 예외를 던지는지가 사용자 경험을 바꾼다.


bytes 포맷을 받는 코드가 특히 위험한 이유: 디코딩 경계가 숨어 있다

PR #145851의 diff를 보면, bytes 포맷을 처리하는 쪽도 정리된다. 이전에는 bytes를 그대로 잡고 가거나, 에러가 struct.error 같은 형태로 퉁쳐지는 지점이 있었는데, 이번 변경은 “어떤 타입을 포맷으로 인정할지”와 “어디서 ASCII를 강제할지”를 더 분명하게 만든다.

실무에서 bytes 포맷이 위험한 이유는 간단하다.

  • bytes는 이미 인코딩된 결과물이라서, 그 안에 뭐가 들어 있는지 호출자가 모를 수 있고
  • 그 bytes를 문자열로 해석하는 순간(디코딩) 예외가 날 수 있고
  • 그 예외가 UnicodeDecodeError인지, ValueError인지, 혹은 라이브러리 고유 예외인지가 버전/구현에 따라 달라질 수 있다

그래서 포맷을 외부 입력으로 받는 API를 만든다면, 나는 두 가지 중 하나로만 정한다.

1) 입력은 str만 받는다. bytes는 받지 않는다. (호환성은 낮아도 운영이 편하다) 2) bytes를 받되, 함수 시작에서 즉시 decode('ascii')를 시도해서 실패를 초기에 터뜨린다.

두 번째를 택할 때는 이런 식으로 “경계를 앞쪽으로 당기는” 게 좋다.

def parse_format(fmt: str | bytes) -> str:
    if isinstance(fmt, bytes):
        # 여기서 실패하면 '포맷 입력이 잘못됨'으로 정리할 수 있다
        return fmt.decode('ascii')
    return fmt

이렇게 하면, struct.Struct() 내부가 어떤 예외로 바뀌더라도 내 서비스의 예외 계약은 흔들리지 않는다.


내가 내 코드에서 바로 점검할 4가지(테스트/운영 기준)

PR #145851을 읽고 나서, 내 코드에서 바로 확인할 포인트는 네 가지다.

1) struct.Struct(...)를 감싸는 예외 처리 - UnicodeEncodeError를 잡고 있다면, “포맷 문자열이 잘못된 경우”까지 잡고 있는지 다시 생각해야 한다. - 이번 변화 흐름을 보면 ValueError로 정리되는 방향이므로, 입력 검증/에러 메시지를 ValueError 중심으로 만드는 게 더 낫다.

2) 테스트가 “정확한 예외 클래스”를 박아놓고 있는지 - 테스트가 assertRaises(UnicodeEncodeError) 같은 식이라면, 버전 차이에서 깨질 수 있다. - 진짜로 중요한 건 예외 이름이 아니라 “어떤 입력이 거부되는지”인 경우가 많다.

3) bytes 포맷을 쓰는 코드가 있는지 - bytes 포맷을 받는 API라면, 디코딩 경계가 어디인지(그리고 어떤 예외가 사용자에게 노출되는지)를 명확히 해야 한다.

4) 로그/메트릭에서 예외 이름을 키로 쓰는지 - 예외 이름이 바뀌면 대시보드가 찢어진다. - “원인 분류”를 예외 이름 하나로만 하지 말고, 입력/상태/경계(인코딩, 초기화 등)까지 같이 기록하는 편이 안전하다.

이 네 가지를 점검하면, “업그레이드 후에만 터지는” 종류의 장애가 꽤 줄어든다.

하나를 더 보태면, 운영에서 가장 실용적인 방식은 “예외 이름을 버전별로 매핑해두는 것”이다. 예를 들어 예전엔 UnicodeEncodeError로 잡히던 케이스가 새 버전에서는 ValueError로 들어온다면, 대시보드에서 둘을 같은 원인군으로 묶어보는 식이다. 실제 장애 대응에선 ‘이름’보다 ‘원인군’이 더 중요하니까.

그리고 이건 struct에만 해당하는 얘기가 아니다. argparse, json, pathlib, asyncio처럼 운영 코드에 자주 박혀 있는 표준 라이브러리들도, 예외 타입 하나 바뀌면 테스트/알람이 동시에 흔들린다. 그래서 표준 라이브러리 업데이트를 할 때는 “바뀐 기능”보다 “바뀐 예외”를 먼저 훑는 편이 체감 효율이 높다.


마무리: 예외 타입은 사소한 구현이 아니라, 운영 계약이다

PR #145851은 struct.Struct라는 작은 영역을 다루지만, 메시지는 보편적이다.

예외 타입은 구현 디테일이 아니라 운영 계약이다. 특히 입력(포맷 문자열)이 외부에서 들어오는 코드라면, 예외가 “인코딩 실패”로 보이느냐 “입력 자체가 잘못됐다(ValueError)”로 보이느냐가 대응을 바꾼다.

업그레이드 계획을 잡을 때, diff에서 예외 타입이 바뀌는 줄을 한 번만 체크해도 다음 주의 야근 확률이 내려간다.


References

  • CPython PR #145851 — Change some implementation details in struct.Struct
    • https://github.com/python/cpython/pull/145851
  • Diff
    • https://github.com/python/cpython/pull/145851.diff

이미지 크레딧/라이선스

  • Blue Screen of Death on Internet Terminal.JPG — Ente75 / CC0
    • https://commons.wikimedia.org/wiki/File:Blue_Screen_of_Death_on_Internet_Terminal.JPG
    • http://creativecommons.org/publicdomain/zero/1.0/deed.en

댓글

이 블로그의 인기 게시물

Django에서 트랜잭션 관리하기

Django에서 트랜잭션 관리하기 안녕하세요! 오늘은 Django에서 데이터베이스 트랜잭션을 효과적으로 관리하는 방법에 대해 알아보겠습니다. 1. 트랜잭션의 중요성 트랜잭션은 데이터베이스의 일관성과 무결성을 보장하는 중요한 개념입니다. Django에서는 여러 가지 방법으로 트랜잭션을 관리할 수 있습니다. 1.1 기본 개념 원자성(Atomicity) : 트랜잭션은 모두 실행되거나 모두 실행되지 않아야 합니다. 일관성(Consistency) : 트랜잭션 전후로 데이터베이스의 일관성이 유지되어야 합니다. 격리성(Isolation) : 동시에 실행되는 트랜잭션들이 서로 영향을 주지 않아야 합니다. 지속성(Durability) : 완료된 트랜잭션의 결과는 영구적으로 저장되어야 합니다. 2. Django의 트랜잭션 관리 2.1 기본 설정 # settings.py DATABASES = { 'default': { 'ENGINE': 'django.db.backends.postgresql', 'NAME': 'mydatabase', 'USER': 'myuser', 'PASSWORD': 'mypassword', 'HOST': 'localhost', 'PORT': '5432', 'ATOMIC_REQUESTS': True, # 모든 뷰를 트랜잭션으로 래핑 } } 2.2 데코레이터 사용 from django.db import transaction @transaction.atomic def create_order(user, items): order = Order.objects.create(user=...

AWS S3 + CloudFront로 정적 파일 서빙 완전 가이드

AWS S3 + CloudFront로 정적 파일 서빙 완전 가이드 안녕하세요! 오늘은 AWS S3와 CloudFront를 사용하여 정적 파일을 효율적으로 서빙하는 방법에 대해 알아보겠습니다. 왜 S3와 CloudFront를 사용할까요? 높은 가용성 : AWS의 글로벌 인프라를 활용 빠른 전송 속도 : CloudFront의 CDN 기능으로 전 세계 사용자에게 빠른 전송 비용 효율성 : 사용한 만큼만 지불 보안 : AWS의 보안 기능 활용 확장성 : 트래픽 증가에 자동 대응 1. S3 버킷 설정 1.1 버킷 생성 및 설정 import boto3 def create_s3_bucket(): s3 = boto3.client('s3') # 버킷 생성 bucket_name = 'your-static-files-bucket' s3.create_bucket( Bucket=bucket_name, CreateBucketConfiguration={ 'LocationConstraint': 'ap-northeast-2' } ) # 버킷 정책 설정 bucket_policy = { "Version": "2012-10-17", "Statement": [ { "Sid": "PublicReadGetObject", "Effect": "Allow", "Principal": "*", "Action": "s3:GetObje...

RDS에서 Django 앱 성능을 높이는 데이터베이스 설정 팁

RDS에서 Django 앱 성능을 높이는 데이터베이스 설정 팁 안녕하세요! 오늘은 AWS RDS를 사용하는 Django 애플리케이션의 성능을 최적화하는 방법에 대해 알아보겠습니다. 1. RDS 인스턴스 최적화 1.1 인스턴스 타입 선택 # RDS 인스턴스 크기 조정 import boto3 def resize_rds_instance(): rds = boto3.client('rds') response = rds.modify_db_instance( DBInstanceIdentifier='your-db', DBInstanceClass='db.t3.large', # 워크로드에 맞는 인스턴스 타입 선택 ApplyImmediately=True ) return response['DBInstance'] 1.2 파라미터 그룹 설정 def create_parameter_group(): rds = boto3.client('rds') # PostgreSQL 파라미터 그룹 생성 response = rds.create_db_parameter_group( DBParameterGroupName='django-optimized', DBParameterGroupFamily='postgres13', Description='Optimized parameters for Django applications' ) # 성능 관련 파라미터 설정 parameters = [ { 'ParameterName': 'shared_buffers', 'ParameterValue': '2GB...