Ada 프로그래밍

크리에이티브 커먼즈 라이선스 이 저작물은 크리에이티브 커먼즈 저작자표시-비영리-변경금지 4.0 국제 라이선스에 따라 이용할 수 있습니다.

목차


1. Ada 소개

1.1 Ada의 역사1

Ada 언어의 개발은 단순한 기술적 과제를 넘어, 미 국방부(DoD)의 소프트웨어 위기에 대한 전략적 대응이었습니다. 이 절에서는 Ada가 탄생하게 된 배경과 개발 과정을 단계별로 살펴봅니다.

1.1.1 배경: 국방 소프트웨어의 위기

1970년대 초, 미 국방부는 급증하는 소프트웨어 비용과 낮은 신뢰성 문제에 직면했습니다. 당시 국방부의 연간 소프트웨어 지출은 30억 달러를 초과했으며, 이는 계속 증가하는 추세였습니다. 비용 문제보다 더 심각한 것은 소프트웨어의 품질이었습니다. 항공기, 미사일, 통신 시스템 등 국방 시스템에 내장되는 임베디드 컴퓨터 시스템(Embedded Computer Systems, ECS)의 소프트웨어는 종종 예산을 초과하고, 개발이 지연되며, 신뢰할 수 없는 결과물을 내놓았습니다.

이 문제의 핵심 원인 중 하나는 프로그래밍 언어의 난립이었습니다. 당시 국방부 내에서는 450개가 넘는 프로그래밍 언어와 방언(dialect)이 사용되고 있었습니다. 각 프로젝트는 저마다 다른 언어나 기존 언어의 비호환 버전을 사용했으며, 이는 다음과 같은 문제를 야기했습니다.

  • 재사용성 부재: 한 프로젝트에서 개발된 코드를 다른 프로젝트에서 재사용하는 것이 거의 불가능했습니다.
  • 유지보수의 어려움: 각기 다른 언어로 작성된 시스템은 유지보수가 복잡하고 비용이 많이 들었습니다.
  • 도구의 분절: 컴파일러, 디버거와 같은 개발 도구들이 특정 언어와 하드웨어에 종속되어 공유되지 못했습니다.
  • 훈련 비용 증가: 개발자들은 프로젝트를 옮길 때마다 새로운 언어와 개발 환경을 학습해야 했습니다.

이러한 상황 속에서 소프트웨어는 하드웨어보다 수정하기 어렵고 비용이 많이 드는 존재가 되었습니다. 하드웨어가 노후화되어도 소프트웨어가 특정 하드웨어에 종속되어 교체하지 못하는 상황이 빈번하게 발생했습니다.

1.1.2 HOLWG의 출범과 요구사항 기반 접근

이러한 위기를 해결하기 위해 미 국방부는 1975년 1월, ‘고급 언어 워킹 그룹(High Order Language Working Group, HOLWG)’을 공식적으로 출범시켰습니다. HOLWG의 목표는 국방부의 임베디드 시스템에 적합한 단일 고급 프로그래밍 언어를 제정하는 것이었습니다.

HOLWG는 특정 언어를 즉시 선택하는 대신, 철저한 요구사항 기반 접근 방식을 채택했습니다. 이는 언어의 기능이 실제 사용자의 필요를 충족해야 한다는 원칙에 기반한 것입니다. 이 과정은 다음과 같은 일련의 요구사항 문서들을 통해 체계적으로 진행되었습니다.

  1. STRAWMAN (1975년 4월): 토론을 자극하기 위한 초기 요구사항 초안. 전 세계 군사 및 민간 커뮤니티에 배포되어 의견을 수렴했습니다.
  2. WOODENMAN (1975년 8월): 수렴된 의견을 바탕으로 다듬어진 두 번째 요구사항 문서.
  3. TINMAN (1976년 1월): 각 군의 공식적인 요구사항과 전 세계 기술 자문을 통합하여 작성된 문서. 이 시점에서 항공, 통신, 지휘통제 등 다양한 분야의 요구사항이 본질적으로 동일하다는 중요한 결론에 도달했으며, 이는 단일 언어의 실현 가능성을 뒷받침했습니다.
  4. IRONMAN (1977년 1월): 기존 언어 평가를 통해 일관성과 기술적 실현 가능성을 검증한 후, 언어 설계의 기반이 될 수 있도록 정제된 요구사항 명세.
  5. STEELMAN (1978년 6월): 네 개의 프로토타입 설계와 분석가들의 검토를 거쳐 완성된 최종 요구사항 문서. 이는 이후 진행될 언어 설계의 명확한 지침이 되었습니다.

이러한 개방적이고 반복적인 과정을 통해, 새로운 언어는 특정 설계자의 철학이 아닌, 광범위한 사용자 커뮤니티의 검증된 필요에 의해 정의되었습니다.

1.1.3 국제적 언어 설계 경쟁

HOLWG는 TINMAN 요구사항을 기준으로 수십 개의 기존 언어(FORTRAN, COBOL, Pascal, PL/I, ALGOL 68 등)를 평가했습니다. 평가 결과, 어떤 기존 언어도 요구사항을 완벽하게 만족시키지 못했지만, 요구사항을 모두 충족하는 새로운 언어를 개발하는 것은 기술적으로 가능하다는 결론에 도달했습니다.

이에 따라 국방부는 1977년, 새로운 언어 설계를 위한 국제적인 경쟁 입찰을 시작했습니다. 이 경쟁은 다음과 같이 진행되었습니다.

  • 1단계 (1977년 8월 ~ 1978년 2월): 14개의 제안서 중 4개의 계약자(Cii-Honeywell Bull, Intermetrics, SofTech, SRI-International)가 선정되어 프로토타입 언어 설계를 진행했습니다. 흥미롭게도 네 팀 모두 Pascal을 기반 언어로 선택했습니다. 각 설계안은 익명성을 보장하기 위해 색상(Green, Red, Blue, Yellow)으로 지칭되었습니다.
  • 2단계 (1978년 4월 ~ 1979년 3월): 전 세계 80여 개의 분석팀이 참여한 광범위한 평가를 거쳐, Cii-Honeywell Bull (Green)Intermetrics (Red) 두 팀이 최종 후보로 선정되었습니다. 두 팀은 1년간 언어 명세를 완성하는 작업을 진행했습니다.

1979년 5월 2일, HOLWG는 최종 심사를 통해 Cii-Honeywell Bull 팀이 설계한 ‘Green’ 언어를 최종 승자로 선정했습니다. 이 팀은 프랑스의 저명한 컴퓨터 과학자 장 이시비아(Jean Ichbiah)가 이끌고 있었습니다.

1.1.4 언어의 명명과 표준화

최종 설계안으로 선정된 ‘Green’ 언어에는, 시인 바이런(Lord Byron)의 딸이자 세계 최초의 프로그래머로 알려진 에이다 러브레이스(Ada Lovelace, 1815-1852)를 기리기 위해 ‘Ada’ 라는 이름이 공식적으로 부여되었습니다. 고등 언어 워킹 그룹(HOLWG)은 그녀의 후손인 리튼 백작(Earl of Lytton)에게 정식으로 서신을 보내 이름 사용에 대한 허가를 받을 만큼 이 명명에 경의를 표했습니다.

Ada의 최종 버전은 즉시 확정되지 않았습니다. 1979년 6월, Preliminary Ada 참조 매뉴얼이 ACM SIGPLAN 회보를 통해 출판되어 전 세계 기술 커뮤니티의 광범위한 검토를 받았습니다. ‘테스트 및 평가(Test and Evaluation)’ 단계로 명명된 이 기간 동안 수많은 조직이 실제 자신들의 애플리케이션을 Ada로 재작성하며 언어의 완성도를 높이는 데 기여했습니다.

이러한 과정을 거쳐 언어 설계팀은 최종 수정을 마쳤고, 마침내 1980년 12월 10일, 에이다 러브레이스의 생일에 맞추어 Ada는 미 국방부 표준인 MIL-STD-1815로 공식 제정되었습니다. 표준 번호 1815는 그녀의 출생 연도를 기념하는 것이었습니다. 이로써 5년이 넘는 기간 동안 진행된 대규모 언어 개발 프로젝트는 성공적으로 마무리되었습니다.

표준화 과정은 여기서 멈추지 않았습니다. 국방부 표준을 넘어 산업계 전반에서 사용되는 국제 표준을 목표로, 새로 설립된 ‘Ada 공동 프로그램 오피스(Ada Joint Program Office, AJPO)’가 후속 작업을 이끌었습니다. 그 결과, 1983년 2월 Ada 언어의 첫 번째 공식 국제 표준인 ANSI/MIL-STD-1815A가 승인되었습니다. 일반적으로 Ada 83으로 불리는 이 버전은 1987년 국제 표준화 기구(ISO)에 의해 ISO 8652로도 채택되어 명실상부한 국제 표준 언어의 지위를 획득했습니다.

Ada 83은 패키지, 제네릭, 예외 처리, 태스킹 등 당시로서는 매우 진보적인 개념들을 포함하고 있었으며, 이후 10년 이상 대규모 고신뢰성 시스템을 구축하는 산업 표준으로서 확고한 기반을 제공했습니다.

1.1.5 진화하는 언어: Ada 95, Ada 2005, Ada 2012, Ada 2022

소프트웨어 공학 기술이 발전하고 프로그래밍 패러다임이 변화함에 따라 Ada 언어 역시 지속적으로 개정되었습니다.

  • Ada 95: 첫 번째 주요 개정판인 Ada 95는 ISO에 의해 표준화된 최초의 객체 지향 프로그래밍 언어였습니다. 이 버전에서는 상속, 다형성 등 완전한 객체 지향 기능을 지원하기 위한 태그드 타입(tagged type) 개념이 도입되었습니다. 또한, 보호 객체(protected object)를 도입하여 병행성 처리 모델을 개선하고 데이터 경쟁(data race)을 방지하는 더 안전하고 효율적인 방법을 제공했습니다. 계층적 라이브러리(hierarchical libraries)와 표준 라이브러리의 확장도 이루어졌습니다.

  • Ada 2005: 이 버전은 “Interfaces”라는 개념을 도입하여 Java나 C#과 유사한 다중 상속의 유연성을 제공했습니다. 또한, 표준 라이브러리가 더욱 확장되었고, 실시간 시스템 지원 기능이 향상되었습니다. pragma와 같은 언어의 세부적인 부분들도 개선되어 프로그래머의 편의성과 코드의 명확성을 높였습니다.

  • Ada 2012: 계약 기반 프로그래밍(design by contract)을 언어 차원에서 본격적으로 지원하기 시작한 버전입니다. 서브프로그램의 사전 조건(precondition), 사후 조건(postcondition), 타입 불변식(type invariant) 등을 명시할 수 있는 구문이 추가되었습니다. 이를 통해 개발자는 프로그램의 정확성을 더욱 엄격하게 명시하고 검증할 수 있게 되었습니다. 또한 멀티코어 프로세서를 더 잘 활용할 수 있도록 병행성 관련 기능들이 개선되었습니다.

  • Ada 2022: 가장 최신 표준인 Ada 2022는 이전 버전의 개념들을 더욱 발전시키고 새로운 기능을 추가했습니다. delta 애그리게이트, 더 정교해진 계약 기반 프로그래밍 지원, 병렬 프로그래밍을 위한 새로운 속성 및 parallel 키워드 등이 포함되었습니다. 이러한 개선 사항들은 Ada를 현대적인 하드웨어 아키텍처와 개발 방법론에 더욱 적합한 언어로 만들었습니다.

이처럼 Ada는 단순한 프로그래밍 언어를 넘어, 소프트웨어 공학의 원칙을 체계적으로 구현한 도구로서 설계되었습니다. 그 역사는 신뢰성과 안전성이 최우선으로 요구되는 시스템을 구축하기 위한 끊임없는 노력의 과정이며, 오늘날에도 항공우주, 국방, 철도, 의료 등 실패가 허용되지 않는 핵심 시스템에서 그 가치를 증명하고 있습니다.

1.1.6 Ada의 동반자: SPARK와 정형 검증

Ada의 발전사와 함께, ‘신뢰성’이라는 설계 철학을 극한까지 추구하는 특수한 언어 집합이 함께 발전해 왔습니다. 바로 SPARK입니다.

SPARK는 Ada 언어의 정형적으로 정의된 부분집합(formally-defined subset)으로, 소프트웨어의 동작이 명세와 정확히 일치함을 수학적으로 증명하기 위해 설계되었습니다. 모든 유효한 SPARK 코드는 그 자체로 유효한 Ada 코드이지만, 그 역은 성립하지 않습니다. 즉, SPARK는 정적 분석을 어렵게 만드는 Ada의 일부 기능(예: 예외 처리, 일반적인 접근 타입)을 의도적으로 배제하거나 제한합니다.

SPARK의 목표는 단순히 버그를 줄이는 것을 넘어, 계약 기반 설계(Design by Contract)를 통해 코드의 속성을 명시하고 GNATprove와 같은 정적 분석 도구를 사용하여 다음과 같은 사실을 증명하는 것입니다.

  • 실행 시점 오류의 부재 (Absence of Run-Time Errors, AoRTE): 버퍼 오버플로우, 0으로 나누기 등 모든 런타임 오류가 발생하지 않음을 보장합니다.
  • 기능적 정확성: 코드가 명시된 전제조건과 후제조건을 완벽하게 만족함을 보장합니다.

SPARK는 Ada의 신뢰성 철학을 계승하고 이를 수학적 증명의 영역으로 확장한 것으로, 항공전자, 보안 펌웨어 등 단 하나의 오류도 허용되지 않는 최고 수준의 고신뢰성 시스템을 구축하는 데 사용됩니다.

1.2 Ada의 설계 철학

프로그래밍 언어의 설계 철학은 해당 언어의 문법 구조, 기능 집합, 그리고 전반적인 개발 패러다임을 결정하는 핵심적인 원칙입니다. Ada는 본래 세 가지 최우선 고려사항(overriding concerns)을 바탕으로 설계되었습니다: 프로그램의 신뢰성 및 유지보수성, 인간 활동으로서의 프로그래밍, 그리고 효율성입니다.

이 세 가지 원칙은 서로 동등한 비중을 가지며, 대규모의 장기 수명 고신뢰성 시스템 개발이라는 공동의 목표를 위해 상호 보완적으로 작용합니다. 이어지는 절에서는 이 원칙들이 Ada 언어의 구체적인 기능에 어떻게 반영되었으며, 시대의 요구에 맞춰 어떻게 진화해 왔는지 상세히 분석할 것입니다.

1.2.1 신뢰성 및 유지보수성 (Reliability and Maintainability)

Ada 설계 철학의 첫 번째 원칙은 소프트웨어가 명세된 대로 정확하고 예측 가능하게 동작하는 신뢰성과 수십 년에 걸쳐 시스템을 수정하고 개선할 수 있는 유지보수성을 동시에 확보하는 것입니다. 이 두 가치는 “오류는 가능한 한 빨리 발견되어야 한다”와 “코드는 작성되는 횟수보다 훨씬 더 많이 읽힌다”는 두 가지 사실을 언어 차원에서 해결함으로써 달성됩니다.

컴파일 시점의 오류 방지 및 신뢰성 확보

Ada는 런타임이 아닌 컴파일 시점에 오류를 발견하는 것을 최우선으로 합니다. 이를 위해 강타입 시스템(strong typing system)을 채택하여, 서로 다른 의미를 가진 데이터의 혼용을 원천적으로 차단합니다. 예를 들어, Distance 타입과 Time 타입을 별도로 정의하면, 프로그래머가 실수로 두 변수를 직접 연산하려 할 때 컴파일러가 이를 즉시 오류로 처리하여 데이터 오용으로 인한 논리적 결함을 사전에 방지합니다.

type Distance_In_Meters is new Float;
type Time_In_Seconds is new Float;

procedure calculate_speed is
  distance : Distance_In_Meters := 100.0;
  time     : Time_In_Seconds    := 9.58;
  -- speed : Float := distance / time; -- 컴파일 오류 발생
begin
  null;
end calculate_speed;

또한, 모든 오류를 컴파일 시점에 잡을 수 없는 경우를 대비해 런타임 검사(run-time checks)와 구조화된 예외 처리(structured exception handling) 메커니즘을 제공합니다. 배열의 인덱스 범위를 벗어나거나 subtype으로 정의된 값의 범위를 위반하는 경우(subtype Day is Integer range 1 .. 31;에 40을 할당), 런타임 예외 Constraint_Error가 발생하여 시스템이 정의되지 않은 위험 상태로 빠지는 것을 막고, 예외 처리 구문을 통해 안전한 복구 절차를 수행할 수 있도록 합니다.

모듈화 및 가독성을 통한 유지보수성 증대

장기적인 유지보수성은 코드의 가독성(readability)과 모듈화(modularity)에 달려있습니다. Ada는 이를 위해 패키지(package)라는 강력한 모듈화 단위를 제공하며, 패키지의 인터페이스를 정의하는 명세부(.ads)와 실제 구현을 담는 구현부(.adb)를 물리적으로 분리합니다.

이러한 분리 구조는 다음과 같은 장점을 가집니다.

  1. 정보 은닉 (Information Hiding): 명세부의 private 영역을 통해 타입의 내부 구조를 완벽히 감출 수 있습니다. 이를 통해 사용자는 push, pop과 같은 공개된 연산에만 의존하게 되며, 내부 구현(예: 배열 또는 연결 리스트)이 변경되어도 사용자 코드에 전혀 영향을 주지 않습니다. 이는 수정의 파급 효과를 최소화하여 유지보수 비용을 극적으로 낮춥니다.

  2. 컴포넌트 기반 개발: 명확한 인터페이스를 가진 패키지는 독립적으로 개발하고 테스트할 수 있는 소프트웨어 컴포넌트 역할을 합니다. 이는 대규모 시스템을 여러 팀이 분산하여 개발하고, 검증된 부품을 조립하는 방식으로 소프트웨어를 구축할 수 있게 하여 개발과 유지보수의 효율성을 높입니다.

결론적으로, Ada는 강타입 시스템과 런타임 검사로 신뢰성을 확보하고, 패키지를 통한 강력한 모듈화와 정보 은닉으로 장기적인 유지보수성을 보장합니다. 이 두 원칙은 서로 분리된 것이 아니라, 잘 구조화된 코드가 곧 신뢰할 수 있는 코드라는 철학 아래 유기적으로 결합되어 있습니다.

1.2.2 인간 활동으로서의 프로그래밍 (Programming as a Human Activity)

Ada 설계의 두 번째 핵심 원칙은 프로그래밍을 ‘인간의 활동’으로 보는 관점입니다. 이는 언어가 기계의 실행 방식에만 초점을 맞추는 것이 아니라, 코드를 작성하고 읽고 수정하는 사람이 겪는 인지적 과정을 깊이 고려해야 한다는 철학입니다. 따라서 Ada는 작성의 편의성이나 간결함보다는, 프로그래머의 실수를 줄이고 협업을 용이하게 하는 가독성(readability)과 명확성(explicitness)을 최우선 가치로 둡니다.

이러한 철학은 언어 전반에 다음과 같이 구현되어 있습니다.

  • 직관적 구문: if ... end if; 와 같이 의미가 명확한 영어 기반의 키워드를 사용하여 암호 같은 기호로 인한 오독 가능성을 줄입니다.
  • 개념의 일관성: 상대적으로 적은 수의 기본 개념들을 일관되고 체계적인 방식으로 통합하여, 프로그래머가 언어의 복잡성에 압도되지 않고 문제 해결에 집중하도록 돕습니다.
  • 오류 방지: 프로그래머의 부주의로 발생하기 쉬운 오류들을 언어 규칙으로 원천 차단하여, 디버깅에 소요되는 인간의 노력을 최소화합니다.

이처럼 인간 중심의 철학은 Ada가 정적인 언어로 머무르지 않고 지속적으로 진화하는 원동력이 되었습니다. Ada 95에서 도입된 객체 지향 개념이나 타입 확장, Ada 2012의 계약 기반 프로그래밍(pre, post 조건) 등은 모두 프로그래머가 자신의 의도를 코드에 더 명확하게 표현하고, 시스템을 더 쉽게 이해하고 수정할 수 있도록 돕는 방향으로 발전해 온 결과입니다. 이러한 진화는 언제나 신뢰성, 유지보수성, 효율성이라는 다른 핵심 가치를 훼손하지 않는 틀 안에서 이루어졌습니다.

1.2.3 효율성 (Efficiency)

신뢰성과 유지보수성을 강조하는 언어는 실행 속도나 메모리 사용량 측면에서 비효율적일 것이라는 오해를 받곤 합니다. 런타임 검사나 타입 시스템의 엄격함이 성능 저하를 유발할 것이라는 생각 때문입니다. 그러나 Ada의 설계 철학에서 효율성은 신뢰성, 유지보수성과 동등한 위치를 차지하는 핵심 요소입니다. Ada는 안전을 위해 성능을 희생하는 것이 아니라, 안전성을 보장하는 장치들을 통해 오히려 예측 가능하고 최적화된 코드를 생성하도록 설계되었습니다.

Ada의 효율성은 다음 세 가지 측면에서 이해할 수 있습니다.

첫째, 컴파일러의 정적 분석(static analysis)을 통한 최적화입니다. Ada의 강력한 타입 시스템과 서브타입, 제약 조건 등은 단순히 오류를 잡는 데 그치지 않고, 컴파일러에게 풍부한 정보를 제공하여 공격적인 최적화를 가능하게 합니다. 예를 들어, 1..10 범위로 선언된 변수가 있다면, 컴파일러는 해당 변수가 사용되는 코드에서 이 범위가 절대 위반되지 않음을 증명하고 불필요한 런타임 범위 검사를 제거할 수 있습니다. 즉, 많은 안전장치가 실제 실행 코드로 변환될 때는 ‘0의 비용(zero-cost)’을 갖게 됩니다. 이는 “신뢰성을 위해 런타임 오버헤드를 감수한다”는 통념을 정면으로 반박하는 Ada의 핵심적인 효율성 전략입니다.

둘째, 프로그래머의 제어 하에 있는 명시적인 성능 튜닝입니다. Ada는 성능이 극도로 중요한 코드 영역에 대해 프로그래머가 직접 제어할 수 있는 표준화된 방법을 제공합니다.

  • pragma suppress: 충분한 테스트를 거쳐 안전성이 검증된 특정 코드 블록에 한해, 런타임 검사를 선택적으로 비활성화할 수 있습니다. 이는 프로그램 전체에 영향을 미치는 비표준 컴파일러 옵션과 달리, 성능 최적화가 필요한 부분을 명시적으로 지정하므로 코드의 의도를 명확히 하고 오용을 방지합니다.
  • pragma inline: 서브프로그램 호출에 따른 오버헤드를 제거하기 위해 인라이닝을 지시할 수 있습니다. 이러한 pragma들은 안전과 성능 사이의 트레이드오프를 프로그래머가 책임지고 명시적으로 관리하도록 유도하며, 이는 “마법처럼” 동작하는 자동 최적화보다 훨씬 예측 가능하고 제어하기 쉽습니다.

셋째, 예측 가능한 실시간 성능과 메모리 제어입니다. Ada는 가비지 컬렉션(Garbage Collection)과 같이 실행 시간을 예측하기 어려운 메커니즘을 실시간 프로파일에서 배제합니다. 메모리 할당과 해제는 명시적으로 이루어지므로, 시스템의 시간적 동작을 정확하게 분석하고 보장할 수 있습니다. 또한, 내장된 동시성 모델(taskprotected object)은 운영체제의 스레드보다 가볍고, 스케줄링 오버헤드가 적으며, 실시간 요구사항을 만족하도록 설계되었습니다. 표현 명세(representation clauses)를 통해 데이터 구조의 메모리 레이아웃을 비트 단위까지 직접 제어함으로써 불필요한 패딩(padding)을 제거하고 메모리 사용량을 최소화하는 것 역시 효율성을 높이는 중요한 기능입니다.

결론적으로 Ada의 효율성은 단순히 빠른 실행 속도를 의미하는 것을 넘어, 예측 가능성(predictability)제어 가능성(controllability)을 포함하는 포괄적인 개념입니다. Ada는 컴파일러가 최대한의 정보를 활용해 최적화하도록 돕고, 프로그래머에게는 성능에 영향을 미치는 요소를 표준화된 방법으로 제어할 수 있는 권한을 부여합니다. 이러한 설계는 Ada가 엄격한 시간제약과 한정된 자원을 가진 임베디드 및 실시간 시스템 분야에서 최고의 선택지로 자리매김하게 한 원동력입니다.

1.3 Ada의 주요 특징

1.3.1 강타입 시스템과 서브타입 (strong typing system and subtypes)

Ada가 제공하는 신뢰성의 가장 첫 번째 방어선이자 핵심적인 토대는 바로 강타입 시스템(strong typing system) 입니다. 이는 단순히 정수와 문자열을 구분하는 것을 넘어, 프로그래머가 정의하는 문제 영역의 논리적 개념들을 코드에 직접 반영하고 컴파일러가 이를 강제하도록 만드는 메커니즘입니다. Ada의 타입 시스템은 타입(type)을 통한 논리적 분리와 서브타입(subtype)을 통한 값의 제약이라는 이중 방어 체계로 구성됩니다.

타입을 이용한 논리적 분리: 컴파일 시간의 안전장치

Ada에서 타입(type)은 특정 값들의 집합과 그 값들에 적용할 수 있는 연산들의 집합을 정의합니다. 강타입 시스템의 핵심은, 서로 다른 타입의 값들은 설령 컴퓨터 내부에서 동일한 방식으로 표현(예: 둘 다 32비트 정수)되더라도, 명시적인 형변환 없이는 혼합하여 사용할 수 없다는 원칙입니다.

Ada는 new 키워드를 사용하여 기존 타입으로부터 암묵적으로 호환되지 않는 타입을 생성할 수 있게 합니다. 이는 단순한 별칭(alias)을 만드는 C의 typedef와 근본적으로 다릅니다.

코드 예시: 물리 단위를 타입으로 구분

--  두 타입 모두 기저 타입은 Float이지만, 논리적으로는 완전히 다른 개념입니다.
type Distance is new Float;
type Mass     is new Float;

procedure calculate is
  d1 : Distance := 10.0;
  m1 : Mass     := 5.0;
  --  v : Float := d1 + m1; -- 컴파일 오류! Distance와 Mass는 더할 수 없음
begin
  null;
end calculate;

위 예제에서 DistanceMass는 모두 부동소수점 수이지만, 논리적으로는 전혀 다른 개념입니다. 프로그래머가 실수로 거리와 질량을 더하려고 시도하면, Ada 컴파일러는 이를 논리적 오류로 간주하고 컴파일 시간(compile time)에 즉시 오류를 발생시킵니다. 이처럼 Ada는 프로그래밍 언어의 타입 시스템을 문제 영역의 의미론적 규칙을 강제하는 도구로 사용하여, 설계 단계의 논리적 오류가 실행 파일에 포함되는 것을 원천적으로 차단합니다.

서브타입을 이용한 값의 제약: 런타임의 안전장치

서브타입(subtype)은 새로운 타입을 만드는 것이 아니라, 기존 타입(기반 타입)에 특정 제약(constraint)을 추가하여 값의 유효 범위를 한정할 수 있습니다. 서브타입의 변수는 기반 타입의 모든 연산을 그대로 사용할 수 있지만, 허용된 값의 범위를 벗어나는 즉시 런타임(runtime) 오류를 발생시킵니다.

코드 예시: 유효한 값의 범위 지정

procedure check_score is
  --  Integer의 부분집합으로, 0부터 100까지의 값만 허용하는 서브타입을 선언
  subtype Percentage is Integer range 0 .. 100;

  score : Percentage;
begin
  score := 85;  --  정상: 85는 0 .. 100 범위 내에 있음
  score := 101; --  오류: 이 문장이 실행되는 순간 'Constraint_Error' 예외가 발생
end check_score;

PercentageInteger의 부분집합이므로 Integer와 호환됩니다. 하지만 score 변수에 0..100 범위를 벗어나는 값을 할당하려는 시도는 프로그램 실행 중에 감지되어 Constraint_Error 예외를 일으킵니다. 이는 “성적은 100점을 넘을 수 없다”는 현실 세계의 규칙을 코드가 스스로 지키도록 강제하는 것입니다. 이러한 런타임 검사는 유효하지 않은 데이터가 시스템 전체로 퍼져나가 더 큰 문제를 야기하는 것을 막는 중요한 안전망 역할을 합니다.


결론적으로, Ada의 타입 시스템은 두 단계의 방어 전략을 제공합니다. 타입은 컴파일 시점에 서로 다른 논리적 개념 사이에 견고한 벽을 세워 설계 오류를 차단하고, 서브타입은 런타임 시점에 유효한 값의 범위 주위에 울타리를 쳐서 데이터 오류를 방지할 수 있습니다. 이러한 시스템은 코드를 더 명확하고, 자기 방어적이며, 스스로를 문서화하도록 만들어 소프트웨어 신뢰성을 근본적으로 향상시킵니다.

1.3.2 패키지를 통한 모듈화 및 정보 은닉 (modularity and information hiding via packages)

단순한 프로그램을 넘어 복잡한 대규모 소프트웨어 시스템을 구축할 때, 가장 중요한 공학적 원칙은 모듈화(modularity)입니다. 모듈화는 거대한 문제를 관리 가능한 작은 단위로 분해하고, 각 단위 간의 상호 의존성을 최소화하는 기술입니다. Ada는 패키지(package)라는 언어적 구조를 통해 이러한 모듈화를 체계적으로 지원하며, 이는 곧바로 소프트웨어의 유지보수성재사용성 향상으로 이어집니다.

명세와 구현의 분리: 공적인 계약과 사적인 구현

Ada 패키지의 가장 핵심적인 특징은 명세부(specification)와 구현부(body)의 물리적, 논리적 분리입니다. 이 둘은 보통 별개의 파일(.ads.adb)로 관리됩니다.

  • 명세부 (.ads 파일): 패키지가 외부 세계에 제공하는 ‘공개 인터페이스’ 또는 ‘공식 계약서’입니다. 여기에는 외부에 공개할 타입, 상수, 변수, 그리고 서브프로그램의 선언이 포함됩니다. 다른 개발자는 이 명세 파일만 보고도 해당 패키지가 어떤 기능을 제공하며 어떻게 사용해야 하는지 알 수 있습니다.

  • 구현부 (.adb 파일): 명세부에 선언된 서브프로그램의 실제 동작 로직과, 패키지 내부에서만 사용할 비공개 데이터 및 헬퍼(helper) 함수들이 위치하는 ‘사적인 작업 공간’입니다. 구현부의 내용은 외부에 완전히 숨겨집니다.

이러한 엄격한 분리는 거대한 이점을 제공합니다. 패키지의 내부 구현 로직을 더 효율적인 알고리즘으로 변경하거나 버그를 수정하더라도, 명세부라는 ‘공식 계약’만 변경되지 않는다면 해당 패키지를 사용하는 다른 어떤 코드에도 영향을 주지 않습니다. 이는 수정에 따른 파급효과를 차단하여, 대규모 팀 환경에서 안정적인 병렬 개발과 손쉬운 유지보수를 가능하게 합니다.

private 타입을 통한 진정한 정보 은닉

패키지는 단순히 코드의 구현을 숨기는 것을 넘어, 데이터 구조의 구체적인 형태까지 숨기는 정보 은닉(information hiding)을 언어 차원에서 강제합니다. 이는 private 키워드를 통해 이루어집니다.

패키지 명세부의 공개 영역에 타입을 private으로 선언하면, 해당 타입의 이름은 외부에 알려지지만 그 내부 구조는 명세부의 private 영역이나 구현부에서만 접근할 수 있습니다.

코드 예시: 스택(stack) 패키지

--  stack_package.ads (명세부)
package Stack_Package is
  type Stack is private; --  내부 구조를 숨긴 Stack 타입 선언

  --  Stack을 조작하기 위한 공개 인터페이스
  procedure push     (s : in out Stack;  item : Integer);
  procedure pop      (s : in out Stack;  item : out Integer);
  function  is_empty (s : Stack) return Boolean;

private
  --  Stack 타입의 실제 구현 (패키지 외부에서는 접근 불가)
  MAX_SIZE : constant := 100;
  type Integer_Array is array (1 .. MAX_SIZE) of Integer;
  type Stack is record
    elements  : Integer_Array;
    top_index : Natural := 0;
  end record;

end Stack_Package;

위 예시에서 Stack_Package를 사용하는 외부 코드는 Stack 타입의 변수를 선언하고 push, pop 등의 공개된 연산을 사용할 수 있습니다. 하지만 My_Stack.top_index와 같이 Stack의 내부 멤버에 직접 접근하려고 시도하면 컴파일 오류가 발생합니다.

이러한 정보 은닉 덕분에, 패키지 개발자는 나중에 Stack의 내부 구현을 고정 크기 배열에서 동적 크기의 연결 리스트(linked list)로 변경하더라도, Stack_Package를 사용하는 클라이언트 코드는 전혀 수정할 필요가 없게 됩니다. 이는 모듈 간의 결합도(coupling)를 낮추고, 시스템의 유연성과 적응성을 극대화하는 핵심적인 설계 원칙입니다.


결론적으로 Ada의 패키지는 단순한 네임스페이스(namespace)나 코드 그룹핑 도구가 아닙니다. 이는 명세와 구현의 분리, 그리고 private 타입을 통한 정보 은닉을 강제함으로써 소프트웨어를 논리적으로 명확하고, 구조적으로 견고하며, 장기적으로 유지보수하기 쉬운 형태로 만들도록 유도하는 공학적 장치입니다.

1.3.3 내장된 동시성 및 병렬 프로그래밍 지원 (Built-in Concurrency and Parallel Programming Support)

소프트웨어 시스템의 복잡도가 증가하고 멀티코어 프로세서가 보편화됨에 따라, 동시성(concurrency)과 병렬성(parallelism)은 더 이상 특수 분야의 전유물이 아닌 현대 프로그래밍의 핵심 요소가 되었습니다. 동시성이 여러 작업을 논리적으로 동시에 실행하는 개념이라면, 병렬성은 여러 작업을 물리적으로 동시에 실행하여 성능을 극대화하는 것을 목표로 합니다.

Ada는 언어 설계 초기부터 동시성 지원을 핵심 철학으로 삼았으며, 이는 다른 많은 언어들이 라이브러리 형태로 동시성을 지원하는 것과 근본적인 차이를 보입니다. 언어 자체에 내장된 동시성 모델은 문법적으로 명확하고, 컴파일러 수준의 검사를 통해 잠재적 오류를 방지하며, 이식성 높은 코드를 작성할 수 있도록 돕습니다. Ada 2022는 여기서 한 걸음 더 나아가, 전통적인 태스크 기반 동시성 모델을 보완하는 강력한 데이터 병렬 처리 기능을 도입하여 개발자가 멀티코어 하드웨어를 더욱 쉽고 안전하게 활용할 수 있는 길을 열었습니다.

태스크(Task)와 보호 객체(Protected Object)를 이용한 전통적 동시성 모델

Ada의 전통적인 동시성 모델은 ‘태스크(Task)’와 ‘보호 객체(Protected Object)’라는 두 가지 핵심 구성요소를 기반으로 합니다. 이 모델은 운영체제의 저수준 동기화 프리미티브(세마포어, 뮤텍스 등)를 직접 사용하는 것보다 훨씬 추상화되고 안전한 방식으로 동시 실행 흐름을 구성하고 자원을 공유하게 합니다.

태스크(Task)는 Ada에서 독립적인 실행 단위를 나타내는 기본 구성 요소입니다. 각 태스크는 자신만의 실행 흐름을 가지며, 다른 태스크와 동시에 실행될 수 있습니다. 태스크는 명세(specification)와 본체(body)로 구성되어 선언과 구현을 분리하며, 이를 통해 명확한 인터페이스를 정의할 수 있습니다.

보호 객체(Protected Object)는 공유 자원에 대한 접근을 안전하게 통제하기 위해 설계된 메커니즘입니다. 공유 데이터를 캡슐화하고, 해당 데이터에 접근하는 연산들(프로시저, 함수, 엔트리)을 정의합니다. 보호 객체의 가장 중요한 특징은 내부적으로 상호 배제(mutual exclusion)를 보장한다는 점입니다. 즉, 특정 시점에는 오직 하나의 태스크만이 보호 객체의 프로시저나 엔트리를 실행할 수 있으므로, 복잡한 락(lock) 메커니즘 없이도 데이터 경쟁(race condition)과 같은 동시성 문제를 원천적으로 방지합니다.

  • 보호 프로시저 (Protected Procedure): 객체 내부의 데이터를 수정할 수 있는 연산으로, 실행 동안 배타적인 접근 권한을 가집니다.
  • 보호 함수 (Protected Function): 객체 내부 데이터를 수정하지 않고 읽기만 하는 연산으로, 여러 태스크가 동시에 실행할 수 있어 읽기 성능을 높입니다.
  • 엔트리 (Entry): 특정 조건이 만족될 때까지 호출한 태스크의 실행을 중단시키는 조건부 동기화 메커니즘입니다. 예를 들어, 버퍼가 비어있을 때 소비자가 아이템을 가져가려고 하면, 버퍼에 아이템이 채워질 때까지 해당 태스크는 자동으로 대기 상태에 들어갑니다.

다음은 간단한 생산자-소비자 문제를 구현한 예시입니다. Buffer 보호 객체는 공유 자원인 버퍼를 안전하게 관리하며, ProducerConsumer 태스크는 이 버퍼를 통해 데이터를 교환합니다.

-- 버퍼 보호 객체 명세
protected type Buffer (size : Positive) is
   entry put_item (item : in Integer);
   entry get_item (item : out Integer);
private
   data : array (1 .. size) of Integer;
   count : Natural := 0;
   in_index, out_index : Positive := 1;
end Buffer;

-- 버퍼 보호 객체 본체
protected body Buffer is
   entry put_item (item : in Integer) when count < size is
   begin
      data (in_index) := item;
      in_index := (in_index mod size) + 1;
      count := count + 1;
   end put_item;

   entry get_item (item : out Integer) when count > 0 is
   begin
      item := data (out_index);
      out_index := (out_index mod size) + 1;
      count := count - 1;
   end get_item;
end Buffer;

이처럼 Ada의 태스크와 보호 객체는 복잡한 동시성 문제를 언어의 구조 안에서 명확하고 안전하게 해결할 수 있는 강력한 추상화를 제공합니다.

병렬 루프, 블록, 이터레이터를 통한 병렬 처리 강화 (Enhanced Parallelism with Parallel Loops, Blocks, and Iterators)

Ada 2022는 전통적인 태스크 기반 모델을 넘어, 데이터 병렬 처리에 초점을 맞춘 새로운 구문들을 도입했습니다. 이는 대규모 데이터셋에 동일한 연산을 적용하는 과학 및 공학 계산, 이미지 처리 등의 분야에서 멀티코어 프로세서의 성능을 직접적으로 활용하기 위함입니다. 이러한 기능들은 개발자가 저수준의 태스크 관리나 동기화에 신경 쓰지 않고도 병렬 처리를 손쉽게 구현할 수 있도록 설계되었습니다.

병렬 루프 (Parallel Loops)는 가장 대표적인 Ada 2022의 병렬 기능입니다. for ... loop 구문에 with parallel; 애스펙트를 추가하는 것만으로 루프의 각 반복(iteration)이 서로 다른 프로세서 코어에서 병렬로 실행될 수 있음을 명시합니다. 런타임 시스템은 가용한 코어 수에 맞춰 반복을 자동으로 분배하고 실행합니다. 이때 루프의 각 반복은 서로 독립적이어야 하며, 외부 상태를 변경하지 않아야 안전한 병렬 실행이 보장됩니다.

procedure process_data (data : in out Data_Array) is
  -- 각 데이터 요소를 독립적으로 처리
  procedure process_element (element : in out Data_Element) is
  begin
    -- 복잡한 계산 수행
    ...
  end process_element;
begin
  for element of data loop
    pragma parallel (independent); -- Ada 2022 이전 스타일의 프라그마
    process_element (element);
  end loop;

  -- Ada 2022의 병렬 루프 구문
  for element of data loop with parallel;
    process_element (element);
  end loop;
end process_data;

병렬 블록 (Parallel Blocks)은 서로 독립적인 여러 서브프로그램 호출을 병렬로 실행하고자 할 때 사용됩니다. parallel block ... end block 구문 안에 do ... end do; 블록을 여러 개 두면, 각 do 블록 안의 코드들이 병렬로 실행됩니다. 이는 기능적 분해(functional decomposition)에 기반한 병렬화에 유용합니다.

procedure execute_independent_tasks is
begin
  parallel block
  do
    task_a;
  end do;
  do
    task_b;
  end do;
  do
    task_c;
  end do;
  end block;
end execute_independent_tasks;

병렬 이터레이터 (Parallel Iterators)는 Ada의 강력한 컨테이너 라이브러리와 병렬 처리 기능을 결합한 것입니다. 컨테이너의 각 요소에 대해 특정 작업을 병렬로 수행할 수 있도록 하여, 데이터 중심의 병렬 프로그래밍을 더욱 간결하고 표현력 있게 만듭니다.

이러한 Ada 2022의 병렬 처리 기능들은 개발자가 “무엇을” 병렬로 처리할 것인지(의도)에만 집중하고, “어떻게” 병렬로 실행할 것인지(구현)는 컴파일러와 런타임 시스템에 위임하도록 합니다. 이는 코드의 가독성을 높이고, 수동 병렬화 시 발생할 수 있는 미묘한 버그를 줄이며, 하드웨어의 발전에 따라 프로그램의 성능이 자연스럽게 확장될 수 있는 기반을 제공합니다.

1.3.4 제네릭을 이용한 재사용성 (reusability with generics)

소프트웨어 공학의 핵심 목표 중 하나는 코드 재사용성(reusability)을 극대화하는 것입니다. 한 번 작성하여 검증된 코드를 여러 곳에서 재사용할 수 있다면, 개발 시간과 비용이 절감되고 잠재적인 오류의 수도 줄어들게 됩니다. Ada는 제네릭(generics)이라는 기능을 통해, 특정 타입에 종속되지 않는 일반화된 코드 템플릿(template)을 작성하고 이를 필요에 맞게 재사용하는 것을 체계적으로 지원합니다.

제네릭: 타입에 독립적인 코드 템플릿

많은 경우, 자료 구조나 알고리즘의 본질적인 로직은 그것이 다루는 데이터의 타입과 무관합니다. 예를 들어, 스택(Stack) 자료 구조의 pushpop 연산 로직은 스택에 정수(Integer)를 저장하든, 문자열(String)을 저장하든 동일합니다. 제네릭이 없다면, 개발자는 Integer_Stack 패키지와 String_Stack 패키지를 별도로 복사하여 작성해야 하며, 이는 코드 중복과 유지보수의 어려움을 초래합니다.

Ada의 제네릭 유닛은 이러한 문제를 해결하기 위해, 패키지나 서브프로그램을 하나의 템플릿으로 정의합니다. 이 템플릿은 제네릭 매개변수(generic formal parameter)를 통해 특정 타입이나 서브프로그램, 값 등을 외부에서 주입받을 수 있도록 설계됩니다.

코드 예시: 제네릭 스택 패키지의 정의

-- 어떤 타입('Element_Type')이든 저장할 수 있는 제네릭 스택 패키지
generic
  --  'private'은 어떤 타입이든 올 수 있음을 의미 (제한된 타입 제외)
  type Element_Type is private;
package Generic_Stack is
  type Stack is private;
  procedure push (s : in out Stack;  item : Element_Type);
  procedure pop  (s : in out Stack;  item : out Element_Type);
  --  ...
private
  --  ...
end Generic_Stack;

위의 Generic_Stack은 그 자체로 완전한 코드가 아니라, Element_Type이라는 가상의 타입을 사용하는 설계도 또는 청사진입니다.

인스턴스화: 템플릿으로부터 실제 코드 생성

제네릭 템플릿을 실제 코드에서 사용하기 위해서는 인스턴스화(instantiation)라는 과정을 거쳐야 합니다. 인스턴스화는 제네릭 템플릿에 구체적인 실제 매개변수(actual parameter)를 제공하여, 컴파일러가 그에 맞는 실체화된(concrete) 패키지나 서브프로그램을 생성하도록 지시하는 것입니다.

코드 예시: 제네릭 스택의 인스턴스화 및 사용

with Generic_Stack; --  제네릭 템플릿을 가져옴
with Ada.Text_IO; use Ada.Text_IO;

procedure use_stacks is
  --  'Generic_Stack' 템플릿으로부터 'Integer' 타입을 위한 스택 패키지를 생성
  package Integer_Stack is new Generic_Stack (Element_Type => Integer);

  --  'Generic_Stack' 템플릿으로부터 'String' 타입을 위한 스택 패키지를 생성
  package String_Stack is new Generic_Stack (Element_Type => String);

  int_stack  : Integer_Stack.Stack;
  str_stack  : String_Stack.Stack;
begin
  --  이제 각 인스턴스는 완벽하게 타입이 분리된 일반 패키지처럼 동작
  Integer_Stack.push (int_stack, 10);
  String_Stack.push (str_stack, "Hello, Ada");

  --  Integer_Stack.push (int_stack, "Not an integer"); -- 컴파일 오류!
end use_stacks;

Integer_StackString_StackGeneric_Stack이라는 단 하나의 템플릿으로부터 생성되었지만, 완전히 독립적이고 타입 안전성을 갖춘 별개의 패키지입니다. Integer_StackStringpush하려는 시도는 컴파일 시간에 즉시 발견되어 차단됩니다.


결론적으로, Ada의 제네릭 기능은 단순한 코드 복사를 넘어, 높은 수준의 추상화와 타입 안전성을 동시에 달성하는 핵심적인 재사용 메커니즘입니다. 로직을 한 번만 작성하고 검증한 뒤, 필요한 모든 타입에 대해 안전하게 재사용할 수 있게 함으로써 소프트웨어의 생산성과 유지보수성을 극적으로 향상시킵니다. 이는 잘 설계된 컴포넌트 라이브러리를 구축하는 기반 기술이며, Ada가 대규모의 복잡한 시스템을 구축하기 위한 공학적 언어임을 보여주는 대표적인 특징입니다.

1.3.5 구조화된 예외 처리 (structured exception handling)

완벽하게 설계된 프로그램이라 할지라도, 실행 중에는 예측하지 못한 문제들이 발생할 수 있습니다. 예를 들어, 사용자가 존재하지 않는 파일의 이름을 입력하거나, 네트워크 연결이 끊어지거나, 할당할 메모리가 부족해지는 등의 상황이 이에 해당합니다. 이러한 예외적인 상황에 대처하는 전통적인 방식은 오류 코드(error code)를 반환하여 호출자가 일일이 확인하도록 하는 것이지만, 이 방식은 정상적인 로직과 오류 처리 로직이 뒤섞여 코드를 복잡하게 만들고, 프로그래머가 오류 확인을 누락하여 프로그램이 위험한 상태로 계속 실행되게 만드는 원인이 되곤 합니다.

Ada는 이러한 문제들을 해결하기 위해 구조화된 예외 처리(structured exception handling) 메커니즘을 언어 차원에서 제공합니다. 이는 예상치 못한 오류, 즉 예외(exception)가 발생했을 때, 프로그램의 정상적인 실행 흐름을 중단하고 이를 처리하기 위해 미리 정의된 코드로 제어를 이전하는 강력하고 신뢰성 있는 방법입니다.

오류 처리 코드와 정상 로직의 분리

예외 처리의 가장 큰 장점은 정상적인 경우의 실행 로직예외적인 경우의 오류 처리 로직을 명확하게 분리하여 코드의 가독성과 유지보수성을 크게 향상시킨다는 점입니다. 핵심 로직은 beginexception 키워드 사이의 코드 블록에 집중적으로 기술하고, 모든 종류의 오류 상황에 대한 대처는 exception 키워드 뒤에 모아서 작성합니다.

begin
   --  정상적인 경우에 실행될 '행복한 경로(happy path)' 코드
   --  (파일 열기, 데이터 처리, 네트워크 통신 등)
   ...
exception
   --  오류가 발생했을 경우에만 실행될 코드
   when File_Not_Found_Error =>
      --  파일이 없을 때의 처리
   when Network_Error =>
      --  네트워크 오류 처리
   when others =>
      --  그 외 예상치 못한 모든 오류 처리
end;

이러한 구조 덕분에 개발자는 주된 알고리즘의 흐름을 한눈에 파악할 수 있으며, 발생 가능한 모든 오류에 대한 처리 방식을 한 곳에서 체계적으로 관리할 수 있습니다.

예외의 발생, 전파, 그리고 처리

Ada 예외 처리 메커니즘은 발생(raise), 전파(propagation), 처리(handling)라는 세 단계의 생명주기를 가집니다.

  1. 발생 (raising): 예외는 두 가지 방식으로 발생합니다. 첫째는 raise 문을 사용하여 코드에서 명시적으로 발생시키는 것입니다. 둘째는 런타임 시스템이 오류 조건을 감지하여 암시적으로 발생시키는 경우입니다. 예를 들어, 허용된 범위를 벗어나는 값을 변수에 할당하면 Constraint_Error 예외가 자동으로 발생합니다.
  2. 전파 (propagation): 일단 예외가 발생하면, 해당 코드 블록의 실행은 즉시 중단됩니다. 런타임 시스템은 현재 블록의 exception 부분에서 해당 예외를 처리할 수 있는 핸들러(when 절)를 찾습니다. 만약 적절한 핸들러가 없다면, 예외는 처리되지 않은 채로 현재 서브프로그램을 호출한 지점으로 되돌아가며 전파됩니다. 이 과정은 적절한 핸들러를 찾거나, 프로그램의 최상위 레벨(태스크 레벨)에 도달할 때까지 호출 스택을 따라 계속됩니다.
  3. 처리 (handling): when 절에 명시된 예외와 발생한 예외가 일치하면, 해당 when 절의 코드가 실행됩니다. 이 코드는 오류를 기록하거나, 자원을 해제(예: 파일 닫기)하거나, 시스템을 안전한 상태로 전환하는 등의 복구 작업을 수행할 수 있습니다. 핸들러의 실행이 끝나면 예외는 소멸되고, 프로그램의 실행은 end 키워드 다음 문장부터 정상적으로 계속됩니다.

Ada의 중요한 원칙 중 하나는, 예외가 절대 무시될 수 없다는 것입니다. 만약 예외가 최상위 레벨까지 전파되었음에도 처리되지 않으면, 해당 태스크는 즉시 종료됩니다. 이는 오류를 모른 척하고 넘어가서 시스템이 예측 불가능한 상태에 빠지는 것을 막는 ‘안전 우선(fail-safe)’ 설계 철학을 반영합니다.


결론적으로, Ada의 구조화된 예외 처리는 단순히 오류를 처리하는 편리한 방법을 넘어, 견고하고(robust) 고장 감내성(fault-tolerant)이 있는 소프트웨어를 구축하기 위한 필수적인 아키텍처입니다. 오류 처리 로직을 분리하여 코드의 명료성을 높이고, 예외의 체계적인 전파와 처리를 강제함으로써, 시스템이 예기치 않은 상황에서도 안정적으로 동작하거나 최소한 안전하게 종료되도록 보장합니다.

1.3.6 강화된 계약 기반 설계 (Enhanced Design by Contract)

소프트웨어의 신뢰성은 프로그램이 명세된 대로 정확하게 동작하는지에 달려 있습니다. 계약 기반 설계(Design by Contract, DbC)는 소프트웨어 컴포넌트 간의 상호작용을 비즈니스 계약에 비유하여, 각 컴포넌트의 책임과 권리를 명확히 규정하는 설계 방법론입니다. 이 접근법은 소프트웨어의 정확성, 견고성, 그리고 문서화를 크게 향상시킵니다.

Ada는 이러한 계약 기반 설계 원칙을 언어의 일부로 직접 통합하여, 주석이나 외부 도구에 의존하는 다른 언어들과 차별화됩니다. Ada 2012에서 처음 도입된 계약 관련 기능들은 Ada 2022에서 더욱 정교하고 강력하게 발전하여, 단순한 값의 제약을 넘어 서브프로그램의 부수 효과(side effect)와 동시성 동작까지 계약의 일부로 명시하고 검증할 수 있게 되었습니다.

사전조건, 사후조건, 타입 불변식의 안정화

Ada의 계약 기반 설계는 세 가지 핵심적인 계약 명세, 즉 사전조건, 사후조건, 그리고 타입 불변식을 기반으로 합니다.

  • 사전조건 (Precondition): 서브프로그램이 호출되기 에 호출자(caller)가 반드시 만족시켜야 하는 조건입니다. with pre => ... 애스펙트를 사용하여 명시합니다. 만약 호출자가 사전조건을 위반하면, 이는 호출자의 버그입니다. 사전조건을 통해 서브프로그램은 불필요한 입력값 검증 로직을 줄이고 자신의 핵심 기능에 집중할 수 있습니다.

  • 사후조건 (Postcondition): 서브프로그램이 실행을 성공적으로 마친 에 보장해야 하는 조건입니다. with post => ... 애스펙트를 사용하여 명시합니다. 사후조건을 만족시키는 것은 서브프로그램 구현부(callee)의 책임입니다. 사후조건 내에서는 'Old 애트리뷰트를 사용하여 서브프로그램 실행 전의 파라미터 값을 참조할 수 있어, 값의 변화를 명세하는 데 유용합니다.

  • 타입 불변식 (Type Invariant): 특정 타입의 모든 객체가 항상 만족해야 하는 조건입니다. 비공개 타입(private type)에 대해 with type_invariant => ... 애스펙트로 선언되며, 해당 타입의 객체가 생성된 후 그리고 해당 객체를 파라미터로 받는 모든 공용(public) 서브프로그램 호출이 끝난 후에 이 조건이 참임이 보장되어야 합니다. 이는 객체의 내부 상태가 항상 일관성을 유지하도록 강제하는 강력한 메커니즘입니다.

다음은 스택(Stack)을 구현하면서 계약을 적용한 예시입니다.

package Stacks is
   type Stack (capacity : Positive) is private;

   -- 사전조건: 스택이 가득 차 있지 않아야 함
   procedure push (s : in out Stack; item : in Integer)
      with pre => not is_full (s),
           post => not is_empty (s);

   -- 사전조건: 스택이 비어 있지 않아야 함
   procedure pop (s : in out Stack; item : out Integer)
      with pre => not is_empty (s),
           post => not is_full (s);

   function is_empty (s : Stack) return Boolean;
   function is_full (s : Stack) return Boolean;

private
   type Integer_Array is array (Positive range <>) of Integer;
   type Stack (capacity : Positive) is record
      items : Integer_Array (1 .. capacity);
      top   : Natural := 0;
   end record
      with type_invariant => (s.top <= s.capacity);
end Stacks;

이 예제에서 push 프로시저는 스택이 가득 차지 않았을 때만 호출될 수 있으며(pre), 실행 후에는 스택이 비어있지 않음을 보장합니다(post). type_invarianttop 인덱스가 스택의 용량을 절대 초과하지 않도록 보장하여 데이터 구조의 무결성을 지킵니다.

Global, Global'Class, Nonblocking 애스펙트를 통한 정교한 계약 명세

Ada 2022는 전통적인 계약 개념을 확장하여, 서브프로그램이 전역 변수와 상호작용하는 방식이나 동시성 환경에서의 동작 특성까지 명세할 수 있는 새로운 애스펙트들을 도입했습니다.

  • Global 애스펙트: 사전조건과 사후조건은 주로 파라미터의 상태를 다룹니다. 하지만 많은 서브프로그램은 파라미터 외에 전역 변수(global variable)를 읽거나 수정하는 부수 효과를 가집니다. with global => ... 애스펙트는 이러한 전역 상태 의존성을 계약의 일부로 명시적으로 선언하도록 합니다. 이를 통해 특정 서브프로그램이 어떤 전역 변수에 접근하는지(in), 어떤 변수를 수정하는지(out), 또는 둘 다 수행하는지(in out)를 명확히 할 수 있습니다. 이는 코드의 이해도를 높이고, 예상치 못한 부수 효과를 방지하며, 정적 분석 도구가 잠재적 버그를 찾는 데 결정적인 정보를 제공합니다.

    -- 'Counter' 전역 변수에 대한 의존성을 명시
    procedure increment_counter with
       global => (in out => Counter);
    
  • Global'Class 애스펙트: Global 애스펙트의 클래스-전체(class-wide) 버전으로, 객체 지향 프로그래밍에서 디스패칭(dispatching) 연산의 계약을 정의할 때 필수적입니다.

  • Nonblocking 애스펙트: 실시간 시스템이나 고도의 동시성 환경에서는 특정 연산이 절대 블록(block)되지 않아야 하는 경우가 많습니다. with nonblocking; 애스펙트는 해당 서브프로그램이 실행 중에 태스크 중단을 유발하는 어떠한 잠재적 블로킹 연산(예: 엔트리 호출, 지연문)도 포함하지 않음을 보장하는 계약입니다. 컴파일러는 이 계약을 정적으로 검증할 수 있으므로, 우선순위 역전과 같은 문제를 예방하고 시스템의 시간 결정성(determinism)을 보장하는 데 매우 중요합니다.

이러한 Ada 2022의 발전된 계약 기능들은 단순한 인자 값 검증을 넘어, 소프트웨어의 부수 효과, 자원 사용, 실시간 동작 등 복잡하고 미묘한 속성까지 정밀하게 제어하고 검증할 수 있는 강력한 프레임워크를 제공합니다. 이는 코드의 신뢰성과 유지보수성을 전례 없는 수준으로 끌어올립니다.

1.3.7 표현식의 유연성 증대 (Increased Flexibility in Expressions)

프로그래밍 언어의 표현력은 개발자가 자신의 의도를 얼마나 간결하고 명확하게 코드로 옮길 수 있는지를 결정합니다. 전통적으로 Ada는 문장(statement) 중심의 명령형 프로그래밍 스타일에 강점을 보여왔지만, 최신 프로그래밍 패러다임은 불변성(immutability)을 강조하고 복잡한 연산을 단일 표현식(expression)으로 처리하는 함수형 스타일을 선호하는 경향이 있습니다.

Ada 2022는 이러한 흐름에 발맞춰, 표현식의 기능을 대폭 강화하는 여러 새로운 구문을 도입했습니다. 이 기능들은 코드를 더 간결하게 만들고, 중간 상태를 저장하기 위한 불필요한 변수 선언을 줄이며, 코드의 가독성과 유지보수성을 향상시키는 것을 목표로 합니다.

선언식(Declare Expression)을 통한 인라인 변수 선언

때로는 복잡한 계산을 수행하는 표현식 내에서 중간 결과를 저장할 임시 변수가 필요할 때가 있습니다. 기존에는 이러한 변수를 표현식 바깥의 블록에 선언해야 했기 때문에, 변수의 사용 범위가 실제 필요한 것보다 넓어지고 코드의 흐름을 파악하기 어려워지는 단점이 있었습니다.

선언식(Declare Expression)은 declare ... begin ... end 블록을 표현식 내부에 직접 삽입하여, 표현식이 평가되는 동안에만 존재하는 지역 변수나 상수를 선언할 수 있도록 허용합니다. 이는 변수의 스코프(scope)를 최소화하여 코드의 명확성을 높이고, 표현식 자체의 독립성을 강화합니다.

-- 선언식을 사용하여 복잡한 로그 메시지를 생성하는 예
procedure log_event (event_code : Integer) is
   use Ada.Text_IO;
begin
   put_line
     -- 표현식 내부에 declare 블록을 사용하여 임시 변수 선언
     (declare
         now : constant Ada.Calendar.Time := Ada.Calendar.clock;
         timestamp : constant String := Ada.Calendar.Formatting.image (now);
      begin
         timestamp & ": Event " & Integer'image (event_code) & " occurred.");
end;

이 예제에서 nowtimestamp 상수는 put_line 프로시저 호출이라는 단일 표현식 내에서만 의미를 가집니다. 선언식을 사용함으로써, 이 상수들이 log_event 프로시저의 다른 부분에 영향을 미치지 않음을 보장할 수 있습니다.

** 축약 표현식(Reduction Expression)을 이용한 집합 연산 단순화**

배열이나 컨테이너의 모든 요소에 대해 특정 연산(예: 덧셈, 곱셈)을 적용하여 단일 결과값을 계산하는 작업은 매우 흔합니다. 이를 ‘축약(reduction)’ 연산이라고 합니다. 기존에는 이러한 작업을 위해 명시적인 루프를 작성해야 했습니다.

축약 표현식(Reduction Expression)은 이러한 집합 연산을 매우 간결한 구문으로 표현할 수 있게 합니다. 배열 애그리게이트와 유사한 형태를 사용하여 초기값과 각 요소에 적용할 연산을 지정하면, 런타임이 루프를 자동으로 처리하여 최종 결과값을 반환합니다.

-- 축약 표현식을 사용하여 배열의 모든 요소의 합을 계산
function sum (data : Integer_Array) return Integer is
begin
   -- [for element of data => element]는 data 배열의 모든 요소를 나타냄
   -- '+' 연산을 사용하여 모든 요소를 더하고, 초기값은 0
   return [for element of data => element with + default => 0];
end sum;

-- 벡터의 내적(dot product) 계산
function dot_product (a, b : Vector) return Float is
   -- a와 b의 각 요소를 곱한 값들의 합을 계산
   (for i in a'range => a (i) * b (i) with +);

축약 표현식은 코드의 의도를 “어떻게(how)”가 아닌 “무엇을(what)” 중심으로 기술하게 하여 가독성을 크게 향상시킵니다. 또한, with parallel 애스펙트와 결합하여 멀티코어 프로세서에서 병렬로 실행될 수 있는 잠재력을 가집니다.

델타 애그리게이트(Delta Aggregate)를 이용한 기존 값 기반의 레코드 생성

기존 레코드(record) 객체의 값 대부분을 그대로 유지하면서 일부 필드만 변경하여 새로운 레코드 객체를 생성해야 하는 경우가 많습니다. 기존 방식으로는 새로운 변수를 선언하고, 기존 객체의 모든 필드를 복사한 뒤, 원하는 필드를 다시 수정해야 해서 번거롭고 장황했습니다.

델타 애그리게이트(Delta Aggregate)는 이 과정을 단 하나의 표현식으로 단순화합니다. 기본이 될 레코드 객체를 지정하고, delta 키워드 뒤에 변경할 필드와 새로운 값만을 명시하면 됩니다.

type Config is record
   mode    : Mode_Type := Normal;
   retries : Natural   := 3;
   timeout : Duration  := 10.0;
   logging : Boolean   := False;
end record;

default_config : constant Config; -- 기본 설정값

-- 기본 설정에서 timeout만 변경한 새로운 설정 생성
fast_config : constant Config := (default_config with delta timeout => 1.5);

-- fast_config에서 logging을 활성화한 디버그용 설정 생성
debug_config : constant Config := (fast_config with delta logging => True);

델타 애그리게이트는 특히 불변 객체를 주로 사용하는 프로그래밍 스타일에서 매우 유용하며, 설정 객체나 상태 객체를 다룰 때 코드의 중복을 줄이고 의도를 명확하게 표현하는 데 큰 도움이 됩니다.

이처럼 Ada 2022에 도입된 표현식 강화 기능들은 개발자가 더 적은 코드로 더 많은 작업을 수행하고, 함수형 프로그래밍 기법을 자연스럽게 활용하며, 궁극적으로 더 안전하고 읽기 쉬운 소프트웨어를 작성할 수 있도록 지원합니다.

1.3.8 시스템 프로그래밍 및 저수준 제어 (systems programming and low-level control)

Ada는 고수준의 추상화와 강력한 타입 시스템을 제공하는 언어이지만, 동시에 하드웨어와 직접 상호작용해야 하는 임베디드 및 시스템 프로그래밍 영역에서 핵심적인 역할을 수행합니다. 이를 위해 Ada는 이식성을 해치지 않으면서도 메모리 레이아웃, 주소 연산, 하드웨어 제어를 가능하게 하는 표준화된 저수준 기능들을 언어 차원에서 제공합니다. 이러한 기능들은 비표준 pragma나 어셈블리 코드에 대한 의존성을 최소화하여, 예측 가능하고 신뢰성 높은 시스템 소프트웨어를 작성하는 기반이 됩니다.

표현 명세를 이용한 메모리 레이아웃 제어 (low-level control using representation clauses)

시스템 프로그래밍에서 가장 중요한 요구사항 중 하나는 데이터 구조의 메모리 내 표현 방식을 정확하게 제어하는 능력입니다. 이는 특정 하드웨어 레지스터, 네트워크 프로토콜 패킷, 또는 운영체제가 정의한 데이터 구조와 정확히 일치하는 메모리 레이아웃을 생성하기 위해 필수적입니다. Ada는 표현 명세(representation clauses) 라는 표준 기능을 통해 이를 지원합니다.

레코드 타입의 경우, 각 필드의 크기, 정렬(alignment), 그리고 레코드 시작점으로부터의 정확한 비트 위치(bit offset)까지 지정할 수 있습니다. 이는 컴파일러에게 단순히 필드를 순서대로 배치하는 것이 아니라, 개발자가 명시한 규격에 따라 메모리 구조를 강제하도록 지시합니다.

코드 예시: 하드웨어 장치 레지스터 정의

다음은 통신 칩의 제어 레지스터를 모델링하는 예시입니다. 레지스터는 16비트 크기이며, 각 비트 필드는 특정 기능을 제어합니다.

--  Device_Control_Register: 16비트 제어 레지스터
type Device_Control_Register is record
  enable   : Boolean; --  장치 활성화 비트
  Mode     : Register_Mode; --  동작 모드 (2비트)
  status   : Unsigned_4; --  상태 플래그 (4비트)
  reserved : Unsigned_9; --  예약 영역 (9비트)
end record;

--  이 레코드 타입의 전체 크기를 16비트로 강제합니다.
for Device_Control_Register'size use 16;

--  각 필드의 정확한 비트 위치와 크기를 지정합니다.
for Device_Control_Register use record
  enable   at 0 range 0 .. 0;  --  0번 비트
  Mode     at 0 range 1 .. 2;  --  1-2번 비트
  status   at 0 range 4 .. 7;  --  4-7번 비트
  reserved at 0 range 8 .. 15; --  8-15번 비트
end record;

이와 같이 표현 명세를 사용하면, Device_Control_Register 타입의 변수는 어떤 타겟 아키텍처에서 컴파일되더라도 항상 동일한 16비트 메모리 레이아웃을 갖게 됩니다. 이는 코드의 의도를 명확히 하고, 하드웨어 명세서와 소스 코드를 직접적으로 대응시켜 유지보수성을 극대화하며, 컴파일러나 플랫폼에 따른 비결정적 요소를 제거하여 코드의 이식성과 신뢰성을 보장합니다.

주소 연산 및 비트 단위 조작 (address arithmetic and bitwise operations)

Ada는 System 패키지와 Interfaces 패키지를 통해 주소 및 비트 단위의 저수준 조작을 타입 안전성을 유지하는 방식으로 지원합니다. C언어의 포인터 연산과 같이 잠재적으로 위험하고 타입 시스템을 우회할 수 있는 기능 대신, Ada는 명시적이고 제어된 메커니즘을 제공합니다.

  • 주소 처리: System 패키지에 정의된 System.Address 타입은 모든 메모리 주소를 표현하는 표준 타입입니다. 특정 변수나 객체의 주소는 'address 속성을 통해 얻을 수 있습니다. 주소에 대한 연산은 System.Storage_Elements 패키지에 정의된 함수들을 통해 수행되며, 이는 무분별한 포인터 연산을 방지하고 연산의 의도를 명확하게 만듭니다.

  • 비트 단위 조작: 비트 마스킹, 시프트 등 비트 단위 논리 연산은 모듈러 타입(modular types) 과 내장된 논리 연산자(and, or, xor, not)를 통해 수행됩니다. 모듈러 타입은 지정된 범위 내에서 순환(wrap-around)하는 정수 타입으로, 비트 연산의 기반이 되는 2의 보수 연산 체계와 완벽하게 부합합니다. 이를 통해 타입 안전성을 유지하면서 효율적인 비트 조작이 가능합니다.

코드 예시: 비트 플래그 설정

package body Bit_Manipulation is
   --  8비트 모듈러 타입을 정의하여 비트 연산의 기반으로 삼습니다.
   type Flags is mod 2 ** 8;

   procedure set_flag (value : in out Flags; position : Natural) is
      mask : constant Flags := 2 ** position;
   begin
      value := value or mask; -- 'or' 연산을 사용하여 특정 위치의 비트를 1로 설정
   end set_flag;

end Bit_Manipulation;

이러한 접근 방식은 저수준 조작이 필요할 때조차 타입 시스템의 보호를 받게 함으로써, C언어 등에서 흔히 발생하는 버퍼 오버플로우나 포인터 관련 오류를 원천적으로 방지합니다.

Interfaces 패키지를 통한 이종 언어 연동 (interfacing with other languages)

현실 세계의 시스템은 단일 언어로만 구성되는 경우가 드뭅니다. 기존에 C로 작성된 방대한 라이브러리, 운영체제 API, 또는 Fortran으로 작성된 과학 계산 코드와 연동하는 능력은 실용적인 언어의 필수 조건입니다. Ada는 표준 Interfaces 패키지와 그 자식 패키지들(Interfaces.C, Interfaces.C.Strings, Interfaces.Fortran 등)을 통해 이종 언어와의 상호운용성을 강력하게 지원합니다.

pragma conventionpragma import는 이러한 연동의 핵심입니다.

  • pragma convention (c, Ada_Type): Ada의 데이터 타입(주로 레코드)이 C 언어의 struct와 동일한 메모리 레이아웃 및 호출 규약을 따르도록 지정합니다.
  • pragma import (c, ada_subprogram, "c_function_name"): 외부 C 함수를 Ada 서브프로그램으로 가져옵니다. 이를 통해 Ada 코드 내에서 해당 C 함수를 마치 일반적인 Ada 서브프로그램처럼 타입 검사를 받으며 안전하게 호출할 수 있습니다.

코드 예시: C 표준 라이브러리 getenv 함수 호출

with Interfaces.C;
with Interfaces.C.Strings;

package body Environment is
  -- C의 getenv 함수를 Ada로 가져옵니다.
  -- char *getenv(const char *name);
  function getenv (name : Interfaces.C.Strings.chars_ptr)
    return Interfaces.C.Strings.chars_ptr;
  pragma import (c, getenv, "getenv");

  function get_variable (name : String) return String is
    -- Ada String을 C 스타일 문자열로 변환
    c_name : constant Interfaces.C.Strings.chars_ptr :=
      Interfaces.C.Strings.new_string (name);
    retval : constant Interfaces.C.Strings.chars_ptr := getenv (c_name);
    result : String := "";
  begin
    if retval /= Interfaces.C.Strings.NULL_PTR then
      result := Interfaces.C.Strings.value (retval);
    end if;

    -- 할당된 C 문자열 메모리 해제
    Interfaces.C.Strings.free (c_name);
    return result;
 end get_variable;

end Environment;

이처럼 Ada는 표준화된 인터페이스를 통해 외부 세계와의 연결을 명확하고 안전하게 관리합니다. 이는 개발자가 Ada의 신뢰성 보장 기능을 활용하면서도, 기존의 방대한 소프트웨어 자산을 재사용할 수 있게 하는 매우 실용적인 특징입니다.

1.3.9 최신 프로그래밍 언어 환경에서의 Ada의 위상

프로그래밍 언어의 생태계는 끊임없이 변화하며, 각 언어는 특정한 설계 철학과 목표를 가지고 발전합니다. C++, Java, Python, Rust와 같은 언어들이 각자의 영역에서 광범위하게 사용되는 오늘날, Ada는 ‘신뢰성’과 ‘예측 가능성’이라는 독보적인 가치를 중심으로 고유의 입지를 확고히 하고 있습니다. Ada를 다른 최신 언어와 비교하는 것은 어느 한쪽의 우월성을 논하기 위함이 아니라, 문제의 성격에 따라 가장 적합한 도구를 선택하는 공학적 관점에서 그 특성을 이해하기 위함입니다.

안전성과 신뢰성: 내장된 원칙 vs. 라이브러리/패턴

소프트웨어의 안전성은 현대 시스템의 가장 중요한 요구사항 중 하나입니다. 각 언어는 이 목표를 각기 다른 방식으로 추구합니다.

  • vs. C/C++: C와 C++은 하드웨어에 대한 직접적인 제어와 최고의 성능을 제공하지만, 그 대가로 개발자에게 메모리 관리의 모든 책임을 부여합니다. 버퍼 오버플로우, 널 포인터 역참조와 같은 고질적인 문제는 C/C++의 본질적인 취약점으로 남아있습니다. 이에 반해, Ada는 강타입 시스템, 경계 검사, 계약 기반 설계(DbC)와 같은 기능을 언어 자체에 내장하여 컴파일 시점과 런타임에 잠재적 오류를 원천적으로 방지합니다. C++이 스마트 포인터, RAII(Resource Acquisition Is Initialization) 패턴, 정적 분석 도구를 통해 안전성을 보강하고 있지만, 이는 언어의 기본 철학이라기보다는 부가적인 장치에 가깝습니다.

  • vs. Rust: Rust는 ‘소유권(Ownership)’과 ‘대여(Borrowing)’, ‘생명주기(Lifetime)’라는 독창적인 메모리 관리 모델을 통해 C++과 비슷한 수준의 성능을 유지하면서도 메모리 안전성을 보장하는 것을 목표로 하는 최신 시스템 프로그래밍 언어입니다. Rust의 접근법은 매우 강력하며 컴파일 시간에 많은 오류를 잡아낼 수 있습니다. 하지만 Rust의 모델은 가파른 학습 곡선을 요구하며, 때로는 복잡한 데이터 구조(예: 이중 연결 리스트)를 표현하기 어렵게 만들기도 합니다. Ada는 런타임 검사를 더 많이 활용하는 대신, 프로그래머에게 더 유연하고 직관적인 동시성 및 데이터 소유 모델을 제공합니다. 특히 SPARK와 결합될 경우, Ada는 런타임 오버헤드 없이 Rust와 동등하거나 그 이상의 정적 증명(formal proof) 수준에 도달할 수 있습니다.

  • vs. Java/C#: Java와 C#은 가상 머신(VM)과 가비지 컬렉터(Garbage Collector, GC)를 통해 메모리 안전성을 확보합니다. 이는 개발자를 메모리 관리의 부담에서 해방시켜 생산성을 높이지만, GC의 동작 시점을 예측하기 어려워 실시간 시스템에서 요구되는 시간 결정성(determinism)을 보장하기 어렵다는 단점이 있습니다. Ada는 가비지 컬렉터에 의존하지 않으면서도 명시적인 메모리 관리와 범위 기반 자원 관리(RAII와 유사)를 통해 예측 가능한 성능과 메모리 안전성을 동시에 달성하므로, 엄격한 실시간 제약 조건이 있는 임베디드 시스템에 더 적합합니다.

동시성: 언어 내장 모델 vs. 라이브러리 기반 접근

Ada는 설계 초기부터 동시성을 언어의 핵심 기능으로 포함했습니다. 태스크(Task)와 보호 객체(Protected Object), 그리고 Ada 2022의 병렬 처리 구문은 운영체제의 저수준 프리미티브를 안전하고 구조화된 방식으로 추상화합니다. 이는 Java의 synchronized 키워드나 C++의 <thread>, <mutex> 라이브러리를 사용하는 것보다 데이터 경쟁(race condition)이나 데드락(deadlock)과 같은 동시성 문제를 예방하는 데 더 효과적입니다. 다른 언어들이 동시성을 라이브러리로 ‘추가’한 반면, Ada는 동시성을 언어의 ‘일부’로 설계하여 컴파일러가 더 많은 오류를 검출하고 일관된 모델을 제공할 수 있도록 합니다.

생태계와 적용 분야

Python, JavaScript, Java와 같은 언어는 방대한 오픈 소스 라이브러리, 거대한 커뮤니티, 그리고 풍부한 개발 도구를 자랑하며 웹 개발, 데이터 과학, 모바일 애플리케이션 등 다양한 분야에서 압도적인 생산성을 보여줍니다. 이러한 측면에서 Ada의 생태계는 상대적으로 작고 특정 분야에 집중되어 있는 것이 사실입니다.

그러나 Ada의 가치는 생태계의 크기가 아닌, 그 적용 분야의 중요성에서 나옵니다. 항공우주, 국방, 철도, 원자력, 의료 기기와 같이 사소한 소프트웨어 결함이 치명적인 인명 또는 재산 피해로 이어질 수 있는 고신뢰성(High-Integrity) 시스템 영역에서 Ada는 타의 추종을 불허하는 신뢰를 받고 있습니다. 이는 단순히 언어의 기능을 넘어, 수십 년간 축적된 개발 경험, 검증 도구(SPARK 등), 그리고 엄격한 표준화 프로세스가 함께 만들어낸 결과입니다.

결론적으로, Ada는 범용 애플리케이션 개발의 속도나 편의성을 경쟁하는 언어가 아닙니다. Ada의 위상은 ‘절대 실패해서는 안 되는(must not fail)’ 소프트웨어를 구축하기 위한 가장 신뢰할 수 있는 공학적 도구라는 데 있습니다. 소프트웨어의 역할이 사회 기반 시설과 인간의 생명에 점점 더 깊숙이 관여하는 오늘날, Ada의 설계 철학과 기능들은 그 어느 때보다 높은 현대적 가치를 지닙니다.

1.4 Ada의 주요 응용 분야

Ada는 언어의 설계 철학인 신뢰성, 유지보수성, 효율성에 기반하여, 소프트웨어 결함이 치명적인 결과를 초래할 수 있는 고신뢰성(high-integrity) 시스템 개발에 집중적으로 사용됩니다. Ada의 강력한 정적 검사, 명확한 구문, 그리고 내장된 동시성 지원은 복잡하고 안전이 최우선인 시스템을 구축하는 데 최적화되어 있습니다.

1.4.1 고신뢰성 시스템 (high-integrity systems)

항공우주 및 방위 산업

Ada의 활용이 가장 두드러지는 분야는 항공우주 및 방위 산업입니다. 전투기, 상업용 여객기, 인공위성, 미사일 시스템 등의 항공전자(avionics) 소프트웨어는 극도의 신뢰성을 요구하며, Ada는 이러한 시스템의 핵심적인 역할을 담당합니다.

  • 항공전자 시스템: 보잉(Boeing) 777, 787에어버스(Airbus) A320, A330, A340, A350과 같은 현대 상업용 항공기의 비행 제어 및 조종석 디스플레이 시스템에 Ada가 사용됩니다.
  • 군용기: F-16, F-22, F-35와 같은 전투기의 임무 컴퓨터 및 무기 시스템은 Ada로 개발되었습니다.
  • 우주 항공: 국제 우주 정거장(ISS)의 생명 유지 및 제어 시스템, 아리안(Ariane) 로켓을 비롯한 다수의 발사체 시스템에서 Ada는 핵심적인 역할을 수행합니다.

교통 및 철도 시스템

항공 분야와 마찬가지로 높은 수준의 안전 무결성(Safety Integrity Level, SIL)이 요구되는 철도 및 교통 관제 시스템에서도 Ada가 널리 채택되고 있습니다.

  • 항공 교통 관제: 영국, 프랑스, 독일 등 다수 국가의 항공 교통 관제(ATC) 시스템은 수많은 항공기의 실시간 추적 및 관리를 위해 Ada를 기반으로 구축되었습니다. 록히드 마틴(Lockheed Martin)의 ERAM(En Route Automation Modernization) 시스템이 대표적인 예입니다.
  • 철도 신호 시스템: 프랑스의 TGV 고속철도와 파리, 런던, 홍콩 등 주요 도시의 지하철 무인 운행 및 신호 시스템은 Ada를 사용하여 최고 수준의 안전성을 보장합니다.

보안 최우선 시스템

소프트웨어 취약점이 심각한 보안 위협으로 이어지는 분야에서 Ada와 그 정형 검증 서브셋인 SPARK가 채택되고 있습니다.

  • 엔비디아 (NVIDIA): 자사 GPU의 핵심 보안 펌웨어를 SPARK로 개발합니다. 이는 버퍼 오버플로우와 같은 특정 종류의 런타임 오류가 없음을 수학적으로 증명하여, 하드웨어 수준에서부터 해킹 위협에 대응하기 위함입니다.

의료 시스템

환자의 안전이 최우선인 의료 기기 소프트웨어는 FDA와 같은 규제 기관의 엄격한 기준을 충족해야 합니다.

  • 힐롬/백스터 (Hillrom/Baxter): 환자 감시 장치의 심전도(ECG) 분석 알고리즘 개발에 SPARK를 적용하여, 치명적인 오류 없이 안정적으로 작동함을 보장했습니다.
  • 스칸디나비안 리얼 하트 (Scandinavian Real Heart): 인공 심장 제어 소프트웨어의 안정성과 정밀성을 확보하기 위해 Ada를 핵심 개발 언어로 사용합니다.

1.4.2 일반 응용 분야 (general-purpose applications)

Ada는 일반 소프트웨어 영역에서도 응용 사례를 찾을 수 있으나, 그 사용 빈도는 고신뢰성 시스템 분야에 비해 현저히 낮습니다. 언어의 설계 자체가 안정성과 예측 가능성에 맞춰져 있어, 빠른 개발 속도와 방대한 생태계를 우선시하는 일반 상용 소프트웨어 시장의 요구와는 다소 거리가 있기 때문입니다.

그럼에도 불구하고, 일부 개발자들은 Ada의 기술적 장점을 활용하여 다음과 같은 일반 소프트웨어 개발에 사용하고 있습니다.

  • 시스템 유틸리티 및 도구: Ada의 시스템 프로그래밍 능력은 운영체제 수준의 유틸리티나 개발 도구를 만드는 데 활용될 수 있습니다. 컴파일러, 정적 분석 도구, 혹은 고성능 파일 처리 유틸리티와 같은 소프트웨어 개발에 Ada가 사용될 수 있습니다. Ada로 작성된 GNAT 컴파일러 자체가 대표적인 예시입니다.
  • 웹 애플리케이션 백엔드: Ada로 웹 애플리케이션을 개발하는 것은 일반적이지 않지만 불가능하지는 않습니다. AWS (Ada Web Server)와 같은 라이브러리를 사용하면 Ada로 웹 서버나 백엔드 API를 구축할 수 있습니다. 특히 보안과 안정성이 중요한 웹 서비스의 경우, Ada는 데이터 처리의 정확성을 보장하고 런타임 오류를 줄이는 데 이점을 가질 수 있습니다.
  • 데스크톱 애플리케이션: GtkAda와 같은 라이브러리는 GTK+ 툴킷에 대한 바인딩을 제공하여, Ada로 그래픽 사용자 인터페이스(GUI)를 가진 데스크톱 애플리케이션을 개발할 수 있도록 지원합니다. 이를 통해 리눅스, 윈도우, macOS 등에서 동작하는 크로스플랫폼 애플리케이션 제작이 가능합니다.

이처럼 Ada는 일반적인 상용 애플리케이션 개발보다는, 사소한 오류조차 허용되지 않는 안전 최우선 시스템(safety-critical system)임무 최우선 시스템(mission-critical system)을 위한 전문 언어로서 확고한 입지를 다지고 있습니다.

2. 개발 환경 구축

1장에서 Ada 언어의 설계와 특징을 살펴보았습니다. 이 장은 이론을 넘어, Ada 소스 코드를 실제 실행 가능한 프로그램으로 만들기 위한 실용적인 첫 단계입니다. 코드를 작성하고 실행하기 위해서는 소스 코드를 기계어로 변환하는 컴파일러(compiler)를 포함한 여러 개발 도구가 필요합니다.

이 장에서는 Ada 소스 코드를 변환하는 컴파일러인 GNAT(GNU Ada Translator)와, 프로젝트의 생성 및 빌드 과정을 자동화하는 Alire를 설치하고 설정하는 과정을 다룹니다. Alire는 Ada의 라이브러리 및 프로젝트 관리 도구로, 현대적인 Ada 개발 환경과 오픈소스 커뮤니티에서 사실상의 표준(de facto standard)으로 사용되고 있습니다.

본문을 통해 우리는 Alire를 사용하여 GNAT 툴체인을 설치하고, VS Code 편집기와 연동하여 첫 프로젝트를 생성할 것입니다. 또한 대안으로 사용할 수 있는 통합 개발 환경(IDE)인 GNAT Studio도 간략히 소개합니다. 이 장을 마치면 독자는 Ada 코드를 작성하고 컴파일하며 실행할 수 있는 개발 환경을 갖추게 될 것입니다.

2.1 GNAT 컴파일러란?

GNATGNU Ada Translator의 약자입니다. 이것은 Ada 프로그래밍 언어를 위한 컴파일러로, 프로그래머가 작성한 소스 코드를 기계가 실행할 수 있는 코드로 변환하는 역할을 수행합니다.

GNAT는 GCC(GNU Compiler Collection)의 정식 구성 요소입니다. GCC는 GNU 프로젝트에 의해 개발된 오픈소스 컴파일러 시스템이며, C, C++, Ada를 포함한 다수의 프로그래밍 언어를 지원합니다.

일반적으로 ‘GNAT’이라는 용어는 컴파일러뿐만 아니라, 여러 코드 파일을 묶는 바인더(binder)와 최종 실행 파일을 만드는 링커(linker) 등을 포함하는 전체 툴체인(toolchain)을 지칭하는 데 사용되기도 합니다. 이 툴체인은 소스 파일로부터 실행 파일을 생성하는 전 과정을 처리하며, Alire와 같은 빌드 도구는 이 과정을 자동화하여 실행합니다.

2.2 Alire를 통한 GNAT 설치 (권장)

Ada 개발 환경을 구축하는 가장 효율적이고 안정적인 방법은 Alire (Ada LIbrary REpository)를 사용하는 것입니다. Alire는 Ada와 SPARK를 위한 패키지 매니저(package manager)이자 빌드 도구로서, 프로젝트 의존성 관리와 개발 환경 설정을 자동화합니다.

이 방법을 권장하는 주된 이유는 Alire가 GNAT 컴파일러 툴체인(toolchain)의 설치 및 관리를 내장하고 있기 때문입니다. 사용자는 운영체제에 맞는 컴파일러를 직접 찾아 다운로드하고 환경 변수를 설정하는 복잡한 과정 없이, 간단한 명령어로 필요한 버전의 GNAT를 설치하고 사용할 수 있습니다. 이는 개발 환경의 일관성을 보장하고 프로젝트 설정 시간을 단축시킵니다.

2.2.1 Alire 설치 절차

1단계: Alire 다운로드

Alire는 공식 GitHub 저장소의 ‘Releases’ 페이지를 통해 배포됩니다.

  1. 웹 브라우저를 통해 다음 주소로 이동합니다:

    • https://github.com/alire-project/alire/releases
  2. 가장 최신 버전의 릴리스에서 자신의 운영체제에 맞는 압축 파일을 다운로드합니다.

    • Windows: alire-*-x86_64-windows.zip
    • macOS (Intel): alire-*-x86_64-macos.tar.gz
    • macOS (Apple Silicon): alire-*-aarch64-macos.tar.gz
    • Linux: alire-*-x86_64-linux.tar.gz

2단계: 실행 파일 경로 설정

다운로드한 압축 파일의 압축을 해제하면 alr (또는 Windows의 경우 alr.exe) 실행 파일이 포함된 디렉터리가 생성됩니다. 터미널(Terminal) 또는 명령 프롬프트(Command Prompt) 어디에서든 alr 명령을 실행할 수 있도록 이 디렉터리를 시스템의 PATH 환경 변수에 추가해야 합니다.

  • Windows: ‘시스템 속성’ -> ‘고급’ -> ‘환경 변수’에서 ‘Path’ 시스템 변수 또는 사용자 변수에 alr.exe 파일이 위치한 디렉터리의 전체 경로를 추가합니다.

  • macOS/Linux: 사용 중인 셸(shell)의 설정 파일 (예: ~/.zshrc, ~/.bash_profile 등)에 다음 라인을 추가합니다. /path/to/alire/bin은 압축 해제 후 alr 실행 파일이 위치한 실제 경로로 대체해야 합니다.

    export PATH="/path/to/alire/bin:$PATH"
    

    설정 파일을 저장한 후, 새 터미널을 열거나 source ~/.zshrc 와 같은 명령을 실행하여 변경 사항을 적용합니다.

경로 설정이 올바르게 완료되었는지 확인하려면 터미널에서 다음 명령을 실행합니다.

$ alr --version

버전 정보가 정상적으로 출력된다면 Alire 설치가 성공적으로 완료된 것입니다.


2.2.2 Alire를 이용한 GNAT 컴파일러 설치

Alire가 설치되면, 이를 통해 GNAT 툴체인을 설치할 수 있습니다.

터미널에서 다음 명령을 실행합니다.

$ alr toolchain --select

이 명령은 Alire가 다운로드할 수 있는 GNAT 컴파일러 툴체인의 목록을 보여줍니다. 목록에는 다양한 버전의 GNAT Community, FSF (Free Software Foundation)에서 제공하는 GNAT 등이 포함될 수 있습니다.

실행 예시:

$ alr toolchain --select
Available toolchains:
 1. gnat_native=12.2.0           (not installed)
 2. gnat_native=2021             (not installed)
 3. gnat_arm_elf=12.2.1-1         (not installed)
Select a toolchain to install.
Enter a number or a full name [1]:

일반적인 네이티브(native) 개발에는 gnat_native 계열의 최신 버전을 선택하는 것이 좋습니다. 원하는 툴체인의 번호를 입력하고 Enter 키를 누르면 Alire가 자동으로 해당 GNAT 버전을 다운로드하고 ~/.alire/toolchains 디렉터리 내에 설치합니다. 이 과정은 인터넷 속도에 따라 수 분이 소요될 수 있습니다.

설치가 완료되면 Alire는 해당 툴체인을 기본값으로 사용하도록 설정합니다. 이로써 GNAT 컴파일러 설치 과정이 모두 완료됩니다. 다음 절에서 설명할 프로젝트 생성 및 빌드 과정에서 Alire는 자동으로 이 툴체인을 사용하게 됩니다.

2.3 운영체제별 설치 가이드 (Windows, macOS, Linux)

Alire 사용을 원하지 않거나 사용할 수 없는 환경의 사용자를 위해, 이 절에서는 각 운영체제에 GNAT 툴체인을 직접 설치하는 방법을 설명합니다. 이 방법은 AdaCore 웹사이트에서 직접 설치 프로그램을 다운로드하여 GNAT 컴파일러와 관련 도구들을 설치하고, 시스템의 PATH 환경 변수를 수동으로 설정하는 과정을 포함합니다.

AdaCore는 GNAT Community Edition의 공식 배포를 중단하고 Alire를 통한 생태계로의 전환을 발표했습니다. (Source: AdaCore Community Page). 그러나 Linux 배포판의 패키지 매니저를 통하거나 FSF (Free Software Foundation)에서 관리하는 GCC의 일부로서 GNAT를 설치하는 것은 여전히 유효한 방법입니다.


2.3.1 Windows

Windows 환경에서는 MSYS2 프로젝트를 통해 제공되는 GNAT 컴파일러를 설치하는 것이 가장 일반적인 수동 설치 방법입니다.

  1. MSYS2 설치: msys2.org에서 MSYS2 설치 프로그램을 다운로드하여 설치합니다.

  2. 패키지 데이터베이스 업데이트: MSYS2 터미널을 실행하고 다음 명령으로 패키지 데이터베이스와 기본 패키지를 최신 상태로 업데이트합니다.

    $ pacman -Syu
    

    업데이트 과정에서 터미널을 닫았다가 다시 열어야 할 수 있습니다.

  3. GNAT 설치: 모든 업데이트가 완료되면, pacman을 사용하여 GNAT 툴체인(mingw-w64-x86_64-gcc-ada)과 빌드 도구(mingw-w64-x86_64-gprbuild)를 설치합니다.

    $ pacman -S mingw-w64-x86_64-gcc-ada mingw-w64-x86_64-gprbuild
    
  4. PATH 환경 변수 설정: 컴파일러를 일반 명령 프롬프트(cmd)나 PowerShell에서 사용하려면 MSYS2의 MinGW64 bin 디렉터리를 Windows 시스템 PATH에 추가해야 합니다.

    • 기본 설치 경로: C:\msys64\mingw64\bin
    • ‘시스템 속성’ -> ‘환경 변수’에서 ‘Path’ 변수에 위 경로를 추가합니다.
  5. 설치 확인: 새로 연 명령 프롬프트에서 다음 명령을 실행하여 설치를 확인합니다.

    $ gnat --version
    

2.3.2 macOS

macOS에서는 Homebrew 패키지 매니저를 사용하는 것이 가장 간편한 방법입니다.

  1. Homebrew 설치: Homebrew가 설치되어 있지 않다면, brew.sh의 지침에 따라 설치합니다.

  2. GNAT 설치: 터미널을 열고 다음 명령을 실행하여 GCC를 설치합니다. Homebrew의 GCC는 Ada 컴파일러(GNAT)를 포함하고 있습니다.

    $ brew install gcc
    
  3. PATH 환경 변수 설정: Homebrew는 일반적으로 gnat과 같은 컴파일러에 gcc-14, gnat-14처럼 버전 번호를 붙여 설치하며, 심볼릭 링크를 /opt/homebrew/bin 등에 생성합니다. 대부분의 경우 brew install 과정에서 경로 설정이 자동으로 처리되지만, 그렇지 않은 경우 셸 설정 파일(~/.zshrc 등)에 경로를 직접 추가해야 할 수 있습니다.

  4. 설치 확인: 터미널에서 다음 명령을 실행하여 GNAT의 버전을 확인합니다. Homebrew가 설치한 버전에 따라 gnat 또는 gnat-14와 같이 명령이 다를 수 있습니다.

    $ gnat --version
    

2.3.3 Linux

대부분의 Linux 배포판은 자체 패키지 저장소를 통해 GNAT를 제공하므로, 이를 사용하는 것이 가장 안정적이고 편리합니다. 이 방식은 시스템의 다른 개발 도구와의 호환성을 보장하고 PATH 설정을 자동으로 처리합니다.

Debian / Ubuntu 계열

apt 패키지 매니저를 사용하여 GNAT와 GPRbuild를 설치합니다.

$ sudo apt-get update
$ sudo apt-get install gnat gprbuild

gnat 패키지는 해당 배포판 버전의 기본 GCC 버전에 맞는 GNAT 컴파일러를 의존성으로 설치합니다 (예: gnat-12).

Fedora / CentOS / RHEL 계열

dnf 패키지 매니저를 사용하여 gcc-gnat 패키지와 gprbuild를 설치합니다.

$ sudo dnf install gcc-gnat gprbuild

Arch Linux 계열

pacman 패키지 매니저를 사용하여 gcc-adagprbuild를 설치합니다.

$ sudo pacman -Syu gcc-ada gprbuild

설치 확인

어떤 배포판을 사용하든, 설치 후 터미널에서 다음 명령을 실행하여 GNAT가 올바르게 설치되었는지 확인할 수 있습니다.

$ gnat --version

2.4 설치 확인 및 경로 설정

GNAT 컴파일러의 설치가 완료되면, 명령 줄 인터페이스(Command Line Interface, CLI)에서 컴파일러를 정상적으로 호출할 수 있는지 확인하는 과정이 필수적입니다. 이 확인 작업은 GNAT 실행 파일이 위치한 디렉터리가 시스템의 PATH 환경 변수에 올바르게 등록되었는지를 검증하는 것입니다.

PATH는 운영체제가 특정 명령어에 해당하는 실행 파일을 찾기 위해 탐색하는 디렉터리들의 목록입니다. 이 경로가 올바르게 설정되어 있지 않으면, 터미널은 gnat이나 gprbuild와 같은 명령어를 인식하지 못합니다.

2.4.1 설치 확인 명령어

터미널(Windows의 경우 명령 프롬프트 또는 PowerShell)을 열고 다음 명령어를 실행하여 GNAT 컴파일러의 설치 여부와 버전을 확인합니다.

$ gnat --version

설치가 성공적으로 완료되고 PATH 설정이 올바르다면, 다음과 유사한 결과가 출력됩니다. 출력되는 버전 번호는 설치한 GNAT의 버전에 따라 다릅니다.

GNAT 13.2.0
Copyright (C) 2023 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

마찬가지로, Ada 프로젝트를 빌드하는 데 사용되는 핵심 도구인 gprbuild도 확인하는 것이 좋습니다.

$ gprbuild --version

성공 시 다음과 같은 버전 정보가 출력됩니다.

GPRBUILD Pro 23.0.0 (20230428) (x86_64-pc-linux-gnu)
Copyright (C) 2004-2023, AdaCore
This is free software; see the source for copying conditions.

만약 Alire를 통해 GNAT를 설치했다면, alr exec 명령을 통해 Alire가 관리하는 환경 내에서 명령을 실행하여 확인할 수도 있습니다.

$ alr exec gnat --version

2.4.2 경로 설정 문제 해결

만약 위 명령어 실행 시 다음과 같은 오류 메시지가 나타난다면, 이는 PATH 환경 변수 설정에 문제가 있다는 의미입니다.

  • Windows:
    'gnat'은(는) 내부 또는 외부 명령, 실행할 수 있는 프로그램, 또는
    배치 파일이 아닙니다.
    
  • macOS / Linux:
    bash: gnat: command not found
    

이 문제를 해결하려면 GNAT 실행 파일이 포함된 bin 디렉터리를 찾아 PATH에 직접 추가해야 합니다.

  1. bin 디렉터리 위치 확인:

    • Alire 사용자: GNAT 툴체인은 홈 디렉터리 내의 .alire 폴더에 설치됩니다. 경로는 일반적으로 다음과 같은 형태입니다.
      • ~/.alire/toolchains/gnat_native_x.x.x_.../bin
    • 수동 설치 사용자: 2.1.3절에서 설명한 설치 경로를 따릅니다.
      • (Windows/MSYS2) C:\msys64\mingw64\bin
      • (macOS/Homebrew) /opt/homebrew/bin
      • (Linux/패키지 매니저) /usr/bin (대부분 자동으로 설정됨)
  2. PATH 환경 변수 편집:

    • Windows: [제어판] → [시스템 및 보안] → [시스템] → [고급 시스템 설정] → [환경 변수]로 이동하여 사용자 또는 시스템 변수 Path에 위에서 찾은 bin 디렉터리 전체 경로를 추가합니다.
    • macOS/Linux: 셸 설정 파일(~/.zshrc, ~/.bashrc, ~/.profile 등)을 열고 파일의 끝에 다음 라인을 추가합니다.
      export PATH="/path/to/your/gnat/bin:$PATH"
      

      /path/to/your/gnat/bin 부분은 실제 bin 디렉터리 경로로 수정해야 합니다.

  3. 변경 사항 적용:

    • PATH를 변경한 후에는 반드시 새로운 터미널 창을 열어야 변경 사항이 적용됩니다. 또는, Linux/macOS에서는 source ~/.zshrc와 같이 설정 파일을 다시 로드하여 현재 세션에 적용할 수 있습니다.

gnat --version 명령이 어느 위치에서든 성공적으로 실행된다면, GNAT 개발 환경의 기본 설정이 완료된 것입니다.

2.5 GNAT Studio IDE 소개

지금까지는 Alire와 명령 줄 도구를 사용하여 GNAT 컴파일러를 설치하고 환경을 설정하는 방법을 다루었습니다. 이러한 접근 방식은 유연성이 높고 자동화에 용이하지만, 많은 개발자는 소스 코드 편집, 빌드, 디버깅, 버전 관리 등 개발에 필요한 모든 도구가 통합된 환경을 선호합니다. 통합 개발 환경(Integrated Development Environment, IDE) 은 이러한 요구를 충족시키는 그래픽 기반의 애플리케이션입니다.

이 절에서는 AdaCore에서 GNAT 툴체인과 함께 공식적으로 제공하는 IDE인 GNAT Studio를 소개합니다. GNAT Studio는 Ada 및 SPARK 언어에 특화되어 설계되었으며, GNAT 컴파일러와 GDB 디버거, GPRbuild 프로젝트 도구 등 핵심 개발 도구들과 긴밀하게 통합되어 있습니다.

이를 통해 사용자는 별도의 터미널 창이나 텍스트 편집기 없이도 단일 환경 내에서 프로젝트 생성부터 코드 작성, 컴파일, 실행, 그리고 복잡한 디버깅 세션 관리에 이르는 전체 개발 수명 주기를 관리할 수 있습니다. 특히 대규모 프로젝트나 고신뢰성(high-integrity) 시스템 개발에 유용한 고급 기능들을 다수 포함하고 있습니다.

다음 소절들에서는 GNAT Studio의 구체적인 정의와 함께 프로젝트를 생성하고 빌드, 실행, 디버깅하는 기본적인 사용법을 학습하게 됩니다.

2.5.1 GNAT Studio란?

GNAT Studio는 AdaCore에서 개발하고 배포하는 오픈소스 통합 개발 환경(IDE)으로, Ada와 SPARK 언어를 사용한 소프트웨어 개발에 특화되어 있습니다. Windows, macOS, Linux 등 다양한 플랫폼을 지원합니다.

GNAT Studio의 핵심 설계는 GNAT 컴파일러, GPRbuild 프로젝트 관리자, GDB 디버거와 같은 명령 줄 도구들을 하나의 일관된 그래픽 사용자 인터페이스(GUI)로 통합하여 제공하는 것입니다. 이러한 구조는 IDE가 낮은 자원 사용량과 높은 반응성을 유지하면서, 기본 도구들의 모든 기능을 활용할 수 있도록 합니다.

주요 특징은 다음과 같습니다.

  • 언어 구문 분석 기반 기능: GNAT Studio는 Ada의 구문과 의미론을 분석하도록 설계되었습니다. 이를 통해 단순한 구문 강조를 넘어, 패키지, 타입, 서브프로그램 간의 관계를 파악하여 다음 기능을 제공합니다.
    • 코드 탐색 (정의로 이동, 모든 참조 찾기)
    • 실시간 오류 검출 및 컴파일러 진단 표시
    • 코드 자동 완성
  • 프로젝트 관리: GNAT 프로젝트 파일(.gpr)을 통해 소스 파일, 디렉터리 구조, 컴파일러 스위치, 외부 라이브러리 의존성 등 프로젝트의 모든 측면을 시각적으로 관리하고 편집할 수 있습니다.

  • 통합 빌드 및 디버그 환경: 메뉴와 단축키를 통해 소스 코드의 컴파일, 빌드, 실행 과정을 지원합니다. GDB 기반의 그래픽 디버거를 내장하여 중단점 설정, 변수 값 확인, 호출 스택 추적, 스텝 단위 실행 등의 디버깅 기능을 제공합니다.

  • 도구 통합 및 확장성: SPARK 정형 분석, GNATtest 단위 테스트 프레임워크 생성, 코드 커버리지 분석 등 AdaCore의 정적 및 동적 분석 도구들과 연동됩니다. 또한 Python 스크립팅을 통해 기능을 확장하거나 새로운 도구를 통합할 수 있습니다.

2.5.2 프로젝트 생성 및 관리

GNAT Studio에서의 모든 작업은 프로젝트(Project) 를 중심으로 이루어집니다. 프로젝트는 소스 코드 파일, 디렉터리 구조, 컴파일러 옵션, 의존성 등 애플리케이션 빌드에 필요한 모든 정보를 기술하는 GNAT 프로젝트 파일(.gpr)에 의해 정의됩니다. GNAT Studio는 이 .gpr 파일을 읽어 프로젝트의 구조를 이해하고, 사용자에게 시각적인 관리 인터페이스를 제공합니다.

새 프로젝트 생성하기

GNAT Studio는 프로젝트 템플릿 마법사(wizard)를 통해 간단하게 새로운 프로젝트를 생성하는 기능을 제공합니다.

  1. GNAT Studio를 실행한 후, 상단 메뉴에서 File → New Project… 를 선택하거나 시작 화면의 Create project 버튼을 클릭합니다.

  2. ‘New Project’ 대화 상자가 나타나면, 생성할 프로젝트의 유형을 선택합니다. 가장 기본적인 프로젝트를 위해 Simple project with main 템플릿을 선택합니다. 이 템플릿은 하나의 주(main) 서브프로그램을 포함하는 실행 가능한(executable) 프로젝트의 기본 구조를 생성합니다.

  3. 프로젝트의 세부 정보를 설정하는 화면에서 다음 항목들을 입력합니다.

    • Name: 생성될 프로젝트 파일의 이름입니다 (예: my_app.gpr).
    • Location: 프로젝트가 생성될 디렉터리 경로를 지정합니다. GNAT Studio는 이 경로에 프로젝트 파일과 하위 디렉터리(예: src, obj)를 생성합니다.
    • Main: 프로젝트의 진입점(entry point)이 될 Ada 소스 파일의 이름을 지정합니다. 기본값은 main.adb 입니다.
  4. Apply 버튼을 클릭하면 지정된 위치에 .gpr 파일과 src 디렉터리, 그리고 그 안에 main.adb 파일이 생성됩니다. 동시에 GNAT Studio는 새로 생성된 프로젝트를 로드하여 작업할 준비를 마칩니다.


프로젝트 구조와 탐색

프로젝트가 로드되면, 인터페이스 좌측의 Project 뷰(view)에 해당 프로젝트의 계층 구조가 표시됩니다.

  • 최상위 노드: 프로젝트 파일(my_app.gpr)을 나타냅니다.
  • Source Dirs: 소스 코드가 위치한 디렉터리 목록입니다 (예: src).
  • 소스 파일: 각 소스 디렉터리 아래에 포함된 .adb (body) 및 .ads (specification) 파일들이 나열됩니다.
  • Object Dirs / Exec Dirs: 프로젝트가 빌드된 후 컴파일된 오브젝트 파일(obj)과 최종 실행 파일(bin 또는 exec)이 위치하는 디렉터리가 표시됩니다.

Project 뷰에서 특정 소스 파일을 클릭하면 중앙의 편집기 창에서 해당 파일의 내용을 확인하고 수정할 수 있습니다.

프로젝트 속성 관리

생성된 프로젝트의 설정을 변경하려면 프로젝트 속성 편집기를 사용합니다.

  1. 메뉴에서 Project → Properties… 를 선택합니다.

  2. ‘Project Properties’ 대화 상자에서 프로젝트의 다양한 측면을 수정할 수 있습니다. 주요 설정 탭은 다음과 같습니다.

    • Sources: 프로젝트에 포함할 소스 디렉터리를 추가하거나 제거합니다.
    • Switches: 컴파일러, 바인더(binder), 링커(linker)에 전달할 옵션(스위치)을 설정합니다. 예를 들어, ‘Ada’ 스위치 페이지에서 Style checksWarnings를 활성화하여 코드 품질을 관리하는 스위치(-gnatwa)를 추가할 수 있습니다.
    • Main: 프로젝트의 주 실행 파일 목록을 관리하고 변경할 수 있습니다.

여기서 변경된 모든 사항은 .gpr 파일에 자동으로 저장됩니다. 예를 들어, ‘Ada’ 컴파일러 스위치에 -gnatwa를 추가하면 .gpr 파일에 다음과 같은 코드가 반영됩니다.

project My_App is
   -- ...
   package Compiler is
      for Switches ("Ada") use ("-gnatwa");
   end Compiler;
   -- ...
end My_App;

기존 프로젝트 열기

이미 존재하는 프로젝트(예: 다른 곳에서 작성했거나, Alire로 생성한 프로젝트)를 열려면 메뉴에서 File → Open Project… 를 선택한 후, 해당 프로젝트의 .gpr 파일을 지정하면 됩니다.

  • Alire 프로젝트 연동: Alire로 생성한 프로젝트 디렉터리에서 alr edit 명령을 실행하면, Alire가 해당 프로젝트에 맞는 GNAT Studio를 자동으로 실행하고 프로젝트를 로드해줍니다. 이는 Alire가 관리하는 특정 버전의 GNAT 툴체인을 GNAT Studio가 사용하도록 보장하는 가장 확실한 방법입니다.

2.5.3 빌드, 실행, 디버깅 기초

GNAT Studio는 코드 작성부터 최종 실행 파일 생성 및 테스트에 이르는 전 과정을 단일 인터페이스 내에서 처리할 수 있도록 빌드, 실행, 디버깅 도구를 통합하여 제공합니다. 이 기능들은 메뉴, 도구 모음(toolbar), 단축키를 통해 쉽게 접근할 수 있습니다.

프로젝트 빌드 (Build)

빌드는 작성된 소스 코드(.ads, .adb)를 컴파일하고 링크하여 운영체제에서 직접 실행할 수 있는 하나의 실행 파일(executable)로 변환하는 과정입니다.

  1. 빌드 실행: 프로젝트를 빌드하려면 다음 방법 중 하나를 사용합니다.
    • 메뉴: BuildProjectBuild All
    • 단축키: F4
    • 도구 모음: 톱니바퀴 모양의 ‘Build All’ 아이콘 클릭
  2. 결과 확인: 빌드 과정은 GNAT Studio 하단의 Messages 창에 실시간으로 표시됩니다.
    • 성공: 컴파일과 링크가 오류 없이 완료되면 Build successful 이라는 메시지가 나타납니다. 생성된 실행 파일은 프로젝트의 bin 또는 exec 디렉터리에 위치하며, 오브젝트 파일은 obj 디렉터리에 저장됩니다.
    • 실패: 소스 코드에 문법 오류나 기타 문제가 있으면, Messages 창에 오류의 종류, 발생 위치(파일 및 줄 번호), 그리고 설명이 출력됩니다. 특정 오류 메시지를 클릭하면 편집기 창이 자동으로 해당 오류가 발생한 코드로 이동하므로 신속한 수정이 가능합니다.

프로그램 실행 (Run)

프로젝트가 성공적으로 빌드되면, 생성된 실행 파일을 GNAT Studio 내에서 직접 실행하여 동작을 테스트할 수 있습니다.

  1. 실행 명령: 프로그램을 실행하려면 다음 방법을 사용합니다.
    • 메뉴: BuildRunmain (또는 프로젝트의 주 실행 파일 이름)
    • 단축키: Shift+F2
  2. 실행 결과: 콘솔 기반 애플리케이션의 경우, GNAT Studio는 별도의 터미널 창을 열어 프로그램을 실행합니다. Ada.Text_IO.put_Line으로 출력하는 모든 내용은 이 창에 표시되며, Get_Line과 같은 입력 요구도 이 창을 통해 처리됩니다.

디버깅 기초 (Debugging)

디버깅은 프로그램 실행을 단계별로 추적하고 특정 지점에서의 변수 상태를 검사하여 논리적 오류(버그)의 원인을 찾는 과정입니다. GNAT Studio는 GDB(GNU Debugger)를 위한 그래픽 인터페이스를 제공합니다.

기본 디버깅 절차

  1. 중단점(Breakpoint) 설정: 프로그램 실행을 잠시 멈추고 싶은 코드 라인의 편집기 여백(줄 번호 왼쪽)을 클릭합니다. 빨간색 점이 나타나며, 이는 해당 라인이 실행되기 직전에 디버거가 실행을 일시 중지할 위치임을 의미합니다.

  2. 디버거 시작: 다음 방법 중 하나로 디버깅 세션을 시작합니다.
    • 메뉴: DebugInitializemain
    • 단축키: F5
  3. 실행 제어: 프로그램이 중단점에서 멈추면, 디버그 도구 모음이나 Debug 메뉴를 통해 실행 흐름을 제어할 수 있습니다.
    • Continue (F6 또는 F7): 다음 중단점까지 또는 프로그램이 종료될 때까지 실행을 계속합니다.
    • Step Over (F8): 현재 줄을 실행하고 같은 서브프로그램 내의 다음 줄로 이동합니다. 현재 줄이 다른 서브프로그램 호출문이어도 그 내부로 들어가지 않고 실행만 완료합니다.
    • Step Into (F9): 현재 줄이 서브프로그램 호출문일 경우, 해당 서브프로그램의 첫 번째 줄로 진입합니다.
    • Step Out (F10): 현재 서브프로그램의 실행을 완료하고, 이 서브프로그램을 호출했던 코드로 복귀합니다.
  4. 데이터 검사: 실행이 중단된 상태에서 변수의 값을 확인할 수 있습니다.
    • Data 창: 디버거 뷰의 ‘Data’ 창에 현재 유효 범위(scope)에 있는 지역 변수들의 이름과 값이 실시간으로 표시됩니다.
    • 마우스 오버: 편집기에서 변수 이름 위로 마우스 커서를 가져가면 해당 변수의 현재 값이 툴팁(tooltip)으로 나타납니다.
  5. 디버거 종료: 디버그 도구 모음의 ‘stop’ 버튼을 클릭하거나 실행 중인 프로그램 창을 닫아 디버깅 세션을 종료할 수 있습니다.

2.6 엄격한 코드 품질 관리: 컴파일러 경고 옵션

단순히 코드가 컴파일되는 것을 넘어, 잠재적인 버그나 이식성 문제를 일으킬 수 있는 의심스러운 코드를 찾아내는 것은 견고한 소프트웨어를 개발하는 데 매우 중요합니다. 컴파일러 경고(warning)는 이러한 잠재적 문제점을 알려주는 유용한 신호입니다.

이 절에서는 GNAT 컴파일러가 제공하는 경고 관련 옵션2을 사용하여 코드 품질을 엄격하게 관리하는 방법을 알아봅니다. 특히, 모든 유용한 경고를 활성화하고, 이를 단순한 경고가 아닌 컴파일 오류로 처리하여 수정 없이는 빌드가 불가능하도록 강제하는 방법을 배웁니다. 이 방식은 개발 초기 단계부터 코드의 품질을 높은 수준으로 유지하는 데 큰 도움이 됩니다.

2.6.1 주요 경고 및 오류 처리 옵션

-gnatwa

GNAT 프런트엔드(front-end)에서 제공하는 대부분의 Ada 관련 선택적 경고를 활성화합니다.

-Wall

GNAT의 기반이 되는 GCC 백엔드(back-end)에서 제공하는 대부분의 경고를 활성화합니다. 또한 이 옵션은 프런트엔드의 기본 경고 모드를 -gnatwa로 설정하여, 대부분의 프런트엔드 경고도 함께 활성화됩니다.

-gnatwe

GNAT 프런트엔드의 경고와 스타일 검사 메시지를 오류로 처리하여, 오류가 있을 경우 오브젝트 파일 생성을 막습니다.

-Werror

GCC 백엔드의 경고를 오류로 처리합니다. 또한 이 옵션은 프런트엔드의 기본 경고 모드를 -gnatwe로 설정하여, 프런트엔드 경고 역시 오류로 처리되도록 합니다.

2.6.2 경고 발생 예제 코드

다음 코드는 값을 할당하지 않은 변수를 사용하려고 시도하여 컴파일러 경고를 유발하는 예제입니다.

코드: uninitialized_variable_warning.adb

with Ada.Text_IO;

procedure uninitialized_variable_warning is
  -- 값을 할당하지(초기화하지) 않은 변수를 선언합니다.
  uninitialized_value : Integer;
  another_value       : Integer := 10;
begin
  if uninitialized_value > another_value then
    Ada.Text_IO.put_line ("This will not happen.");
  end if;

  Ada.Text_IO.put_line ("Program finished.");

end uninitialized_variable_warning;

2.6.3 컴파일 및 결과 비교

1. 일반 컴파일 (경고만 발생)

먼저, 특별한 옵션 없이 코드를 컴파일해 보겠습니다. 기본적으로 GNAT는 초기화되지 않은 변수 접근과 같이 심각한 잠재적 오류에 대해서만 경고합니다.

$ gnatmake uninitialized_variable_warning.adb
uninitialized_variable_warning.adb:5:03: warning: variable "uninitialized_value" is read but never assigned [-gnatwv]

컴파일러는 5번째 줄의 uninitialized_value 변수가 값이 할당된 적 없이 읽혔다는 내용의 경고를 출력합니다. 괄호 안의 [-gnatwv]는 이 경고가 -gnatwv 스위치에 의해 제어됨을 나타냅니다.

이 단계에서는 경고만 발생할 뿐이므로 빌드는 성공하여 실행 파일(uninitialized_variable_warning.exe 또는 uninitialized_variable_warning)이 생성됩니다.

2. 엄격한 컴파일 (오류로 처리)

이제 -Wall -Werror 옵션을 추가하여 컴파일합니다. 이 조합은 프런트엔드와 백엔드의 모든 주요 경고를 활성화하고 오류로 처리하는 설정입니다.

$ gnatmake -Wall -Werror uninitialized_variable_warning.adb
gcc -c -Wall -Werror uninitialized_variable_warning.adb
uninitialized_variable_warning.adb:5:03: warning: variable "uninitialized_value" is read but never assigned [-gnatwv]
uninitialized_variable_warning.adb:6:03: warning: "another_value" is not modified, could be declared constant [-gnatwk]
gnatmake: "uninitialized_variable_warning.adb" compilation error

이번에는 두 가지 중요한 변화가 있습니다.

  • 추가 경고 발견: -Wall 옵션은 더 광범위한 검사를 수행하므로, 기본 컴파일에서는 나타나지 않았던 “변수 another_value가 상수가 될 수 있다”는 새로운 경고(-gnatwk)를 추가로 발견했습니다.

  • 오류 처리: -Werror 옵션은 발견된 두 가지 경고 모두를 오류로 간주하여 빌드를 실패시켰습니다. 따라서 실행 파일이 생성되지 않습니다.

이처럼 엄격한 컴파일 옵션을 사용하면, 초기화되지 않은 변수 사용과 같은 잠재적 버그뿐만 아니라, 상수를 변수로 잘못 선언하는 등의 코드 스타일 및 명확성 문제까지도 강제적으로 수정하도록 유도할 수 있습니다.

2.6.4 수정된 코드와 최종 확인

컴파일러가 지적한 두 가지 문제를 모두 수정한 코드는 다음과 같습니다.

코드: uninitialized_variable_fixed.adb

with Ada.Text_IO;

procedure uninitialized_variable_fixed is
  -- 1. 변수를 선언과 동시에 0으로 초기화
  initialized_value : Integer := 0;
  -- 2. 값이 변하지 않으므로 상수로 변경
  ANOTHER_VALUE : constant Integer := 10;
begin
  if initialized_value > ANOTHER_VALUE then
    Ada.Text_IO.put_line ("This will not happen.");
  end if;

  Ada.Text_IO.put_line ("Program finished.");

end uninitialized_variable_fixed;

이제 수정된 코드를 다시 엄격한 옵션으로 컴파일해 보겠습니다.

$ gnatmake -Wall -Werror uninitialized_variable_fixed.adb

빌드는 여전히 실패하며 여러 경고들이 나타났습니다.

$ gnatmake -Wall -Werror uninitialized_variable_fixed.adb
gcc -c -Wall -Werror uninitialized_variable_fixed.adb
uninitialized_variable_fixed.adb:5:03: warning: "initialized_value" is not modified, could be declared constant [-gnatwk]
uninitialized_variable_fixed.adb:9:24: warning: condition can only be True if invalid values present [-gnatwc]
uninitialized_variable_fixed.adb:9:24: warning: condition is always False [-gnatwc]
uninitialized_variable_fixed.adb:9:24: warning: (see assignment at line 5) [-gnatwc]
gnatmake: "uninitialized_variable_fixed.adb" compilation error

컴파일러는 두 가지 중요한 문제를 추가로 지적했습니다.

  • initialized_value 역시 상수여야 함 (-gnatwk): uninitialized_value 변수를 0으로 초기화했지만, 이후 값이 전혀 변경되지 않았습니다. 따라서 이 변수 역시 ANOTHER_VALUE와 마찬가지로 상수로 선언하는 것이 올바릅니다.
  • 조건문이 항상 거짓임 (-gnatwc): 두 변수가 각각 010이라는 상수 값을 가지므로, if 0 > 10 이라는 조건은 실행 시점에 관계없이 항상 거짓입니다. 컴파일러는 이처럼 절대 실행될 수 없는 코드(dead code)가 존재함을 경고합니다.

컴파일러의 모든 지침을 따라 최종적으로 수정한 코드는 다음과 같습니다. 불필요한 변수와 실행될 수 없는 조건문을 모두 제거하여 코드의 의도를 명확히 했습니다.

코드: warning_free_example.adb

with Ada.Text_IO;

procedure warning_free_example is
  -- 이 프로그램은 단순히 메시지를 출력하는 것이 목적이므로
  -- 불필요한 변수 선언을 모두 제거했습니다.
begin
  Ada.Text_IO.put_line ("Program finished successfully.");
end warning_free_example;

이제 최종 코드를 다시 엄격한 옵션으로 컴파일합니다.

$ gnatmake -Wall -Werror warning_free_example.adb

아무런 경고 메시지 없이 깔끔하게 컴파일이 성공합니다.

이 과정은 엄격한 컴파일 옵션을 사용하는 것이 단순히 오류를 찾는 것을 넘어, 불필요하거나 논리적으로 결함이 있는 코드를 제거하고 설계를 개선하도록 유도하는 피드백 루프를 만든다는 것을 명확하게 보여줍니다.

3. Ada 프로그램의 기본 구조와 구성 요소

(도입부)

3.1 프로그램의 기본 골격

모든 Ada 프로그램은 정해진 양식과 구조를 따릅니다. 이 기본 골격은 프로그램이 어디서 시작하고 끝나며, 어떤 데이터를 사용하고 어떤 작업을 수행할지를 담는 틀의 역할을 합니다. 이는 마치 건물의 뼈대나 글의 서론-본론-결론 구조와 같습니다.

이번 절에서는 모든 실행 가능한 Ada 프로그램을 구성하는 가장 기본적인 예약어들, 즉 procedure, is, begin, end가 어떻게 프로그램의 뼈대를 형성하는지 알아볼 것입니다. 또한, “Hello, World!” 예제를 통해 이 구조가 실제 코드에서 어떻게 구현되는지 직접 살펴보겠습니다.

3.1.1 Ada의 시작과 끝: procedure, begin, end

실행 가능한 모든 Ada 프로그램은 procedure 라는 기본 단위로 만들어집니다. procedure는 특정 작업을 수행하는 코드 블록에 이름을 붙인 것으로, 프로그램의 진입점이자 가장 큰 틀이 됩니다.

이 골격은 세 가지 핵심 예약어로 구성됩니다.

  1. procedure: 프로그램의 시작을 알립니다. procedure 키워드 뒤에는 프로그래머가 정한 프로그램의 이름(식별자)이 따라옵니다.
  2. begin: 실제 프로그램의 실행 코드가 시작되는 지점을 표시합니다.
  3. end: begin으로 시작된 실행부와 procedure 전체의 끝을 표시합니다.

중요한 점은, end 키워드 뒤에는 반드시 해당 procedure의 이름을 다시 한번 적어주고 세미콜론(;)으로 문장을 마무리해야 한다는 것입니다. 이는 프로그램의 시작과 끝이 명확하게 짝을 이루도록 하여 코드의 가독성과 안정성을 높이는 Ada의 설계 특징입니다.

기본 골격 예시:

procedure my_first_program is
  -- (선언부는 이 'is'와 'begin' 사이에 위치합니다)
begin
  -- 실행 코드는 이 'begin'과 'end' 사이에 위치합니다.
  null;
end my_first_program;

3.1.2 선언부(is ~ begin)와 실행부(begin ~ end)

Ada의 procedure 블록은 크게 두 부분으로 나뉩니다. 이는 마치 요리 레시피에서 ‘재료 목록’ 부분과 ‘조리 과정’ 부분이 나뉘는 것과 같습니다.

선언부 (Declarative Part)

  • 위치: is 키워드와 begin 키워드 사이의 영역입니다.
  • 역할: 프로그램 실행에 필요한 모든 ‘재료’를 미리 선언하고 준비하는 공간입니다. 이곳에는 다음과 같은 요소들을 선언할 수 있습니다.
    • 변수(Variable): 데이터를 저장할 공간
    • 상수(Constant): 변하지 않는 값
    • 타입(Type): 새로운 데이터의 종류
    • 내부 서브프로그램 등

프로그램에 특별히 선언할 변수나 상수가 없다면 선언부는 비어 있을 수 있습니다.

실행부 (Executable Part)

  • 위치: begin 키워드와 end 키워드 사이의 영역입니다.
  • 역할: 프로그램이 시작되었을 때 실제로 수행될 ‘작업’들을 순서대로 나열하는 공간입니다. 이곳에 작성된 명령문(statement)들이 위에서 아래로 차례대로 실행됩니다.
  • 실행부는 비어 있을 수 없으며, 아무런 동작을 하지 않더라도 최소한 하나의 문장(예: null;)이 반드시 있어야 합니다.

이 두 부분을 구분함으로써 Ada는 프로그램에 필요한 자원과 실제 동작을 명확하게 분리하여 코드의 구조를 더 체계적으로 만듭니다.

구조 요약:

procedure procedure_name is
  -- ===================================
  --        선언부 (declarative part)
  --       (is 와 begin 사이)
  -- 필요한 모든 도구와 재료를 준비하는 곳
  -- ===================================
begin
  -- ===================================
  --        실행부 (executable part)
  --       (begin 과 end 사이)
  -- 준비된 재료로 실제 요리를 하는 곳
  -- ===================================
end procedure_name;

3.1.3 예제로 보는 전체 구조: “Hello, World!”

지금까지 배운 기본 골격과 구성 요소들이 실제 프로그램에서 어떻게 조합되는지, 프로그래밍의 가장 고전적인 예제인 “Hello, World!”를 통해 살펴보겠습니다.

이 간단한 코드는 Ada 프로그램의 완전한 구조를 명확하게 보여줍니다.

코드 예시 3-1: hello_world.adb

-- 표준 텍스트 입출력 라이브러리를 사용하겠다고 선언
with Ada.Text_IO;

-- 'Hello'라는 이름의 프로시저 시작
procedure Hello is
begin
  -- 실행부 시작

  -- Ada.Text_IO 패키지 안의 put_line 프로시저를 호출하여 문자열 출력
  Ada.Text_IO.put_line ("Hello, World!");

end Hello; -- 'Hello' 프로시저 끝

코드 분석

  1. with Ada.Text_IO; (컨텍스트 절)

    • 화면에 글자를 출력하는 기능(put_line)은 Ada.Text_IO라는 표준 라이브러리 패키지 안에 들어있습니다. 이 코드는 “우리 프로그램에서 Ada.Text_IO 패키지의 기능을 사용하겠습니다”라고 컴파일러에 미리 알려주는 역할을 합니다.
  2. procedure Hello is (프로시저 선언)

    • 우리 프로그램의 이름이 Hello임을 선언하고, 프로그램의 본체가 시작됨을 알립니다.
    • 이 예제는 별도의 변수나 상수를 선언할 필요가 없으므로 isbegin 사이의 선언부가 비어 있습니다.
  3. begin ~ end Hello; (실행부)

    • beginend Hello; 사이에 있는 코드가 프로그램이 실행될 때 실제로 수행되는 명령입니다.
    • Ada.Text_IO.put_Line (...)Ada.Text_IO 패키지 안에 있는 put_line이라는 프로시저를 호출하는 단 하나의 실행 문장(executable statement)입니다. 이 문장은 주어진 문자열을 화면에 출력하고 다음 줄로 커서를 옮깁니다.

이처럼 “Hello, World!” 예제는 외부 라이브러리를 가져오는 with 절, 프로그램의 뼈대를 이루는 procedure-begin-end 구조, 그리고 실제 동작을 정의하는 실행문으로 구성된, 작지만 완벽한 Ada 프로그램입니다.

3.1.4 프로그램 종료 상태(exit status) 명시

프로그램이 실행을 완료하면, 호출 환경(일반적으로 운영체제 셸)으로 정수 값 형태의 종료 상태(exit status)를 반환합니다. 이 값은 프로그램 실행 결과가 성공적인지 혹은 오류가 발생했는지를 나타내는 표준화된 약속입니다. 관례적으로 종료 상태 0은 성공적인 완료를 의미하며, 0이 아닌 값은 특정 유형의 오류를 나타냅니다.

종료 상태를 명시적으로 설정하는 것은 견고하고 예측 가능한 소프트웨어를 구축하는 데 필수적입니다. 특히 셸 스크립트를 통한 자동화, 프로세스 간 상호작용, 지속적 통합(CI) 파이프라인 환경에서 후속 작업의 분기 처리를 위한 핵심적인 근거로 사용됩니다.

Ada.Command_Line 패키지 명세

Ada 표준 라이브러리의 Ada.Command_Line 패키지는 명령줄 인수 처리와 더불어 프로그램의 종료 상태를 제어하는 표준 인터페이스를 제공합니다.

해당 패키지 명세의 주요 부분은 다음과 같습니다.

package Ada.Command_Line is

  -- 타입 및 상수 선언
  type Exit_Status is an implementation-defined integer type;

  -- 함수 및 프로시저 선언
  -- ...
  procedure set_exit_status (code : in Exit_Status);


  SUCCESS : constant Exit_Status;
  FAILURE : constant Exit_Status;

private
  -- ... Language-defined private part
end Ada.Command_Line;
  • Exit_Status: 종료 상태를 표현하기 위한 정수 타입입니다. 이 타입은 ‘구현 정의(implementation-defined)’ 이므로, 컴파일러 및 대상 시스템에 따라 실제 정수 타입(예: Integer, Interfaces.C.int)이 달라질 수 있습니다.
  • set_exit_status: 프로그램이 종료될 때 운영체제에 반환될 Exit_Status 값을 설정하는 프로시저입니다. 이 프로시저가 여러 번 호출되면, 가장 마지막에 설정된 값이 최종적으로 적용됩니다. 만약 프로그램 실행 중 한 번도 호출되지 않으면, 성공을 의미하는 SUCCESS가 기본값으로 사용됩니다.
  • SUCCESS, FAILURE: 이식성과 가독성을 위해 미리 정의된 상수입니다. SUCCESS는 일반적으로 0에 해당하며, FAILURE1 또는 다른 음이 아닌 값에 해당합니다.

사용 예제

다음은 특정 조건에 따라 프로그램의 성공 또는 실패 종료 상태를 설정하는 예제입니다. 가상 연산의 결과에 따라 set_exit_status 프로시저를 호출합니다.

with Ada.Text_IO;
with Ada.Command_Line;

procedure main is
  -- 명령줄 인수를 기반으로 성공 여부를 결정
  operation_successful : Boolean;
begin
  -- 프로그램에 인수가 정확히 하나 전달되었는지 확인합니다.
  if Ada.Command_Line.Argument_Count /= 1 then
    Ada.Text_IO.put_line ("Usage: main <argument>");
    Ada.Text_IO.put_line ("Example: main success");
    Ada.Command_Line.set_exit_status (Ada.Command_Line.FAILURE);
    return; -- 추가 인수가 있거나 없는 경우, 여기서 실행 종료
  end if;

  -- 첫 번째 인수가 "success"일 경우에만 성공으로 처리합니다.
  declare
    arg : constant String := Ada.Command_Line.Argument (1);
  begin
    operation_successful := (arg = "success");
  end;

  -- 결과에 따라 메시지를 출력하고 종료 상태를 설정합니다.
  if operation_successful then
    Ada.Text_IO.put_line ("Operation completed successfully.");
    Ada.Command_Line.set_exit_status (Ada.Command_Line.SUCCESS);
  else
    Ada.Text_IO.put_line ("Error: Operation failed based on argument.");
    Ada.Command_Line.set_exit_status (Ada.Command_Line.FAILURE);
  end if;

exception
  when others =>
    Ada.Text_IO.put_line ("An unexpected error occurred.");
    Ada.Command_Line.set_exit_status (Ada.Command_Line.FAILURE);

end main;

컴파일 및 실행 예시

코드는 gnatmake와 같은 표준 Ada 컴파일러로 빌드할 수 있습니다. 실행 후에는 echo $? 명령을 통해 프로그램의 종료 상태(exit status)를 확인할 수 있습니다.

성공 시나리오

프로그램에 인수로 success를 전달하면, 프로그램은 성공적으로 작업을 마치고 종료 상태 0을 반환합니다.

$ ./main success
Operation completed successfully.
$ echo $?
0

실패 시나리오 (잘못된 인수)

success가 아닌 다른 인수를 전달하면, 프로그램은 작업을 실패로 처리하고 종료 상태 1을 반환합니다.

$ ./main fail
Error: Operation failed based on argument.
$ echo $?
1

실패 시나리오 (인수 누락)

프로그램에 인수를 전달하지 않으면, 사용법 안내를 출력하고 종료 상태 1을 반환합니다.

$ ./main
Usage: main <argument>
Example: main success
$ echo $?
1

이식성 및 셸 관례3

Ada.Command_Line.SUCCESS(0)와 FAILURE(1)는 가장 기본적인 상태를 나타내지만, 정교한 셸 스크립트 환경에서는 더 구체적인 종료 코드가 필요할 수 있습니다. 그러나 임의의 값을 사용하기 전에, 셸이 특정 범위의 값을 예약하여 특별한 의미로 사용한다는 점을 반드시 인지해야 합니다.

Bash와 같은 POSIX 호환 셸은 다음과 같은 종료 상태 값을 특별하게 해석합니다.

  • 126: 명령은 발견되었으나 실행 권한이 없음.
  • 127: 명령을 찾을 수 없음 (경로 문제 등).
  • 128 + N: 치명적인 신호(fatal signal) N에 의해 프로세스가 종료됨. 예를 들어, SIGSEGV(신호 번호 11)로 종료된 프로세스의 종료 상태는 128 + 11 = 139가 됩니다.

Ada 프로그래머는 이러한 셸의 관례를 인지해야 합니다. 만약 Ada 프로그램이 내부 오류의 한 종류로 127을 반환하도록 설계되었다면, 셸 스크립트 사용자는 프로그램 내부의 논리적 오류가 아닌 “프로그램 실행 파일 자체를 찾지 못했다”는 의미로 해석하게 됩니다. 이는 디버깅 과정에서 오류의 원인을 오판하게 만드는 원인이 될 수 있습니다.

따라서, 프로그램의 고유한 오류 상태를 정의할 때는 셸이 예약한 값들과 충돌하지 않는 범위를 사용하는 것이 안전합니다. 일반적으로 1부터 125 사이의 값이 사용자 정의 오류 코드를 위해 상대적으로 안전한 범위로 간주됩니다.

다음은 파일 처리 실패를 나타내기 위해 사용자 정의 종료 코드 10을 사용하는 예입니다.

-- 예: 파일 열기 실패를 나타내는 사용자 정의 종료 코드 10 설정
declare
   FILE_OPEN_ERROR : constant Ada.Command_Line.Exit_Status := 10;
begin
  -- ... 파일 열기 시도 ...
  if file_is_not_open then
    Ada.Command_Line.set_exit_status (code => FILE_OPEN_ERROR);
  end if;
end;

이처럼 셸 환경의 규칙을 이해하고 종료 코드를 신중하게 선택하면, 다른 도구 및 스크립트와 안정적으로 연동되는 예측 가능한 프로그램을 작성할 수 있습니다.

3.2 컨텍스트 절 (with, use): 외부 기능 가져오기

우리가 만드는 모든 프로그램을 처음부터 전부 작성하는 경우는 드뭅니다. 화면에 글자를 출력하거나, 수학 계산을 하거나, 파일을 읽는 등 많은 기능은 이미 Ada가 표준 라이브러리(Standard Library) 형태로 미리 만들어 제공합니다.

이렇게 이미 만들어진 외부의 기능(패키지)을 내 프로그램 안으로 가져와 사용하겠다고 명시하는 것을 컨텍스트 절(Context Clause)이라고 합니다. 컨텍스트 절은 withuse라는 두 가지 핵심 키워드를 통해 작성됩니다.

이번 절에서는 with를 사용하여 라이브러리에 대한 의존성을 어떻게 명시하는지, 그리고 use를 사용하여 라이브러리의 기능을 어떻게 더 편리하게 사용할 수 있는지 알아보겠습니다.

3.2.1 with 절: 라이브러리 의존성 명시

with은 현재 작성 중인 프로그램이 어떤 외부 라이브러리 패키지를 필요로 하는지, 즉 어떤 패키지에 의존하는지를 컴파일러에 명시적으로 알리는 역할을 합니다. 이는 “이 프로그램을 컴파일하려면, 저 패키지에 대한 정보가 필요합니다”라고 선언하는 것과 같습니다.

with 절을 사용하면, 지정된 패키지의 명세(specification)에 공개된 모든 자원(타입, 변수, 서브프로그램 등)을 현재 프로그램 안에서 사용할 수 있게 됩니다.

구문 (Syntax)

with 절은 파일의 가장 앞부분, procedure 선언보다 먼저 위치해야 합니다. 하나 이상의 패키지를 가져올 경우 쉼표(,)로 구분하거나 여러 개의 with 절을 사용할 수 있습니다.

with Ada.Text_IO;
with Ada.Strings.Unbounded;

-- 또는 쉼표로 한 번에 선언
-- with Ada.Text_IO, Ada.Strings.Unbounded;

procedure My_Program is
  -- ...

점 표기법 (Dot Notation)

with 절로 패키지를 가져온 후에는, 해당 패키지 내부의 자원을 사용하기 위해 점 표기법(dot notation)을 사용해야 합니다. 이는 어떤 기능이 어느 패키지 소속인지 명확하게 구분하여 이름 충돌을 방지하고 코드의 가독성을 높여줍니다.

예시:

with Ada.Text_IO; -- Ada.Text_IO 패키지를 가져옴

procedure Greeter is
begin
  -- 'Ada.Text_IO' 패키지 안에 있는 'put_line' 프로시저를 호출
  Ada.Text_IO.put_line ("Hello from a package!");
end Greeter;

3.2.2 이름공간(Namespace) 관리: use, use type

Ada에서 이름공간(namespace)은 패키지(package)를 통해 체계적으로 관리됩니다. with 절이 특정 패키지에 대한 의존성을 선언하여 해당 패키지의 자원을 사용할 수 있도록 허락한다면, 이름공간 관리 절은 그 자원의 이름을 어떻게 사용할 것인지를 결정합니다. 즉, 매번 패키지 이름을 포함한 전체 경로(fully qualified name)를 사용할 것인지, 아니면 더 간결한 형태로 이름을 사용할 것인지를 정합니다.

use 절의 기본 사용법과 이름 충돌 문제

use 절은 특정 패키지에 선언된 이름들을 직접적으로 보이게(directly visible) 만듭니다. use 절을 사용하면, 패키지 이름을 접두어로 붙이지 않고도 해당 패키지의 서브프로그램이나 타입을 직접 호출할 수 있습니다.

with Ada.Text_IO;
with Ada.Integer_Text_IO;

-- use 절을 사용하지 않은 경우
procedure print_without_use is
begin
  -- 매번 패키지 이름을 포함한 전체 경로를 명시해야 함
  Ada.Text_IO.put_line ("Hello, world!");
  Ada.Integer_Text_IO.put (item => 42, width => 0);
end print_without_use;

-- use 절을 사용한 경우
procedure print_with_use is
  -- Ada.Text_IO와 Ada.Integer_Text_IO의 이름을 직접 사용할 수 있게 됨
  use Ada.Text_IO;
  use Ada.Integer_Text_IO;
begin
  -- 패키지 이름 없이 직접 호출 가능
  put_line ("Hello, world!");
  put (item => 42, width => 0);
end print_with_use;

use 절은 코드를 간결하게 만들지만, 남용할 경우 심각한 가독성 문제를 유발할 수 있습니다. 만약 서로 다른 두 패키지에 동일한 이름의 서브프로그램이 존재하고, 두 패키지 모두에 use 절을 적용하면 이름 충돌(name clash)이 발생합니다. 어느 패키지의 put이 호출되는지 모호해지며, 이는 컴파일 오류를 유발하거나, 더 나쁘게는 개발자의 의도와 다른 서브프로그램이 호출되는 결과를 낳을 수 있습니다.

이러한 이유로, use 절의 사용은 특히 선언부의 넓은 범위에 적용할 때 신중해야 합니다. 코드의 명확성과 유지보수성을 위해 use 절을 사용하지 않고 전체 경로를 명시하는 것이 권장되는 경우가 많습니다.

use type을 통한 연산자 직접 사용 (현대적 스타일)

use 절의 전면적인 이름 해제 방식의 단점을 보완하기 위해 Ada는 use type 절을 제공합니다. 이는 use 절의 보다 제한적이고 안전한 버전입니다.

use type 절은 지정된 타입과 관련된 연산자(operator)들만을 직접 보이게 만듭니다. 여기서 연산자란 +, -, =, <와 같이 중위 표기법(infix notation)으로 사용되는 서브프로그램을 의미합니다. 일반적인 서브프로그램이나 다른 타입의 이름은 직접적으로 보이게 되지 않으므로, use 절에서 발생할 수 있는 이름 충돌 문제를 대부분 피할 수 있습니다.

use type은 특히 사용자 정의 타입에 대한 산술 연산이나 비교 연산을 자연스럽게 표현하고자 할 때 매우 유용합니다.

package Vectors is
  type Vector is array (1 .. 3) of Float;

  -- '+' 연산자 오버로딩
  function "+" (left, right : Vector) return Vector;
end Vectors;

with Vectors;
procedure vector_test is
  use type Vectors.Vector; -- Vector 타입의 연산자만 직접 사용 가능하게 함

  v1 : constant Vectors.Vector := (1.0, 2.0, 3.0);
  v2 : constant Vectors.Vector := (4.0, 5.0, 6.0);
  v3 : Vectors.Vector;
begin
  -- 'use type' 덕분에 'Vectors."+"(v1, v2)' 대신 자연스러운 중위 표기법 사용 가능
  v3 := v1 + v2;

  -- 일반 서브프로그램은 여전히 전체 경로를 사용해야 함 (만약 존재했다면)
  -- Vectors.some_procedure (v3);
end vector_test;

위 예제에서 use type Vectors.Vector;Vectors 패키지에 정의된 Vector 타입을 위한 + 연산자를 직접 사용할 수 있도록 허용합니다. 덕분에 v1 + v2라는 직관적인 표현이 가능해집니다. 만약 use type이 없었다면 v3 := Vectors."+" (v1, v2); 와 같이 다소 부자연스러운 형태로 호출해야 합니다.

결론적으로, 현대적인 Ada 프로그래밍에서는 무분별한 use 절 사용을 지양하고, 코드의 명확성을 해치지 않으면서 표현력을 높일 수 있는 use type 절의 사용이 적극적으로 권장됩니다. 이는 이름 충돌의 위험을 최소화하면서 연산자 사용의 편의성을 얻는 가장 균형 잡힌 접근 방식입니다.

3.3 식별자(identifier)와 예약어(reserved words)

프로그램을 작성하는 것은 변수, 상수, 서브프로그램, 타입 등 수많은 요소를 만들고 이들을 조합하는 과정입니다. 이렇게 프로그래머가 직접 생성하는 프로그램의 각 요소에 고유한 이름을 붙여 구분할 수 있도록 하는 것을 식별자(identifier)라고 합니다. 식별자는 코드의 의미를 부여하는 가장 기본적인 도구입니다.

반면, 언어의 문법 구조를 형성하기 위해 특별한 의미를 갖고 미리 예약된 단어들이 있습니다. 이를 예약어(reserved word) 또는 키워드라고 부릅니다. 예약어는 if, procedure, type처럼 언어의 특정 기능을 나타내므로, 프로그래머가 식별자의 이름으로 사용할 수 없습니다.

올바른 식별자를 사용하는 것은 단순히 컴파일 오류를 피하는 것을 넘어, 코드의 가독성과 유지보수성을 결정하는 중요한 요소입니다. 이번 절에서는 식별자를 구성하는 규칙과 Ada의 대소문자 정책, 그리고 좋은 코드를 작성하기 위한 명명 규칙에 대해 알아보고, 식별자로 사용할 수 없는 예약어에는 어떤 것들이 있는지 살펴보겠습니다.

3.3.1 식별자의 정의와 명명 규칙

식별자(identifier)란 변수, 상수, 타입, 서브프로그램 등 프로그래머가 코드 내에서 정의하는 다양한 요소에 부여하는 고유한 이름을 의미합니다. 식별자는 해당 요소가 무엇을 하는지, 어떤 데이터를 담고 있는지 명확하게 설명하는 역할을 하므로 코드의 가독성에 직접적인 영향을 미칩니다.

Ada에서 식별자를 만들 때는 반드시 다음의 규칙을 따라야 합니다. 이 규칙을 위반하면 컴파일러가 구문 오류(syntax error)를 발생시킵니다.

  • 시작 문자: 식별자는 반드시 알파벳 대소문자(A-Z, a-z)로 시작해야 합니다.
  • 후속 문자: 첫 문자 다음에는 알파벳, 숫자(0-9), 그리고 밑줄(_) 문자가 올 수 있습니다.
  • 밑줄 사용 규칙:
    • 밑줄(_)이 연속으로 두 번 이상 나올 수 없습니다. (예: My__Variable 불가)
    • 밑줄(_)이 식별자의 맨 마지막에 올 수 없습니다. (예: My_Variable_ 불가)

Ada는 식별자의 길이에 특별한 제한을 두지 않으므로, 이름이 길어지더라도 그 의미를 충분히 설명할 수 있도록 작성하는 것이 가능합니다.

올바른 식별자 예시:

  • Temperature
  • Current_Sensor_Value (단어 사이를 밑줄로 구분하여 가독성을 높임)
  • X1
  • Is_Empty

잘못된 식별자 예시:

  • 1st_Value (규칙 1 위반: 숫자로 시작할 수 없음)
  • Sensor__Value (규칙 3 위반: 밑줄이 연속으로 사용됨)
  • Last_Item_ (규칙 3 위반: 밑줄로 끝날 수 없음)
  • Max-Value (허용되지 않는 하이픈(-) 문자 포함)

3.3.2 대소문자 무관성(case insensitivity) 정책

Ada 언어의 중요한 특징 중 하나는 식별자와 예약어에 대해 대소문자를 구분하지 않는다(case-insensitive)는 것입니다. 이는 프로그래머가 실수로 대소문자를 다르게 입력하여 발생하는 오류를 원천적으로 방지하고, 코드의 견고성을 높이기 위한 설계 철학의 일부입니다.

컴파일러는 식별자를 처리할 때 대소문자를 동일한 것으로 간주합니다. 따라서 아래의 식별자들은 모두 같은 변수를 가리킵니다.

  • Sensor_Value
  • sensor_value
  • SENSOR_VALUE
  • SeNsOr_VaLuE

예약어 역시 마찬가지입니다. procedure, PROCEDURE, Procedure는 모두 동일한 키워드로 인식됩니다.

예시 코드:

procedure case_insensitivity_example is
  -- 변수를 선언합니다.
  Message : String := "This is a test.";
begin
  -- 컴파일러는 Message, message, MESSAGE를 모두 동일한 변수로 인식합니다.
  message := "This will be changed.";
  MESSAGE := "This also works.";
end case_insensitivity_example;

예외: 문자 및 문자열 리터럴

대소문자 무관성 정책은 프로그래머가 정의하는 이름과 언어의 키워드에만 적용됩니다. 문자 리터럴(' ')과 문자열 리터럴(" ")의 내용은 대소문자를 정확하게 구분(case-sensitive)합니다. 데이터 자체의 값은 보존되어야 하기 때문입니다.

예를 들어, 문자 'A''a'와 다른 값이며, 문자열 "Apple""apple"과 다른 값으로 처리됩니다.

if 'A' = 'a' then -- 이 조건은 항상 거짓(False)입니다.
  null;
end if;

if "Ada" = "ada" then -- 이 조건 역시 항상 거짓(False)입니다.
  null;
end if;

이처럼 Ada는 이름의 일관성은 컴파일러가 보장하되, 데이터의 정확성은 프로그래머가 관리하도록 합니다. 비록 컴파일러는 대소문자를 구분하지 않지만, 다음 절에서 배우게 될 코딩 규칙(convention)을 통해 일관된 스타일을 유지하는 것은 가독성과 유지보수성을 위해 매우 중요합니다.

3.3.3 가독성을 위한 명명 규칙 (coding convention)

앞서 Ada 컴파일러는 식별자의 대소문자를 구분하지 않는다고 배웠습니다. 기술적으로 my_variableMy_Variable은 동일하지만, 사람에게는 다르게 보일 수 있습니다. 좋은 코드는 컴퓨터뿐만 아니라 사람이 읽고 유지보수하기 쉬워야 합니다. 따라서 프로젝트나 팀 전체에서 일관된 명명 규칙(coding convention)을 따르는 것은 매우 중요합니다.

이 책에서는 명확성과 일관성을 위해 Clair 코딩 스타일 가이드를 기준으로 모든 예제 코드를 작성합니다. 이 규칙을 익히면 이 책의 코드를 더 쉽게 이해할 수 있으며, 여러분의 프로젝트에 적용하여 전문적이고 가독성 높은 코드를 작성할 수 있습니다.

주요 명명 규칙은 다음과 같습니다.

  1. 타입, 서브타입, 예외, 보호 객체 (Types, Subtypes, exceptions, Protected Objects): Pascal_Case

    • 여러 단어일 경우 밑줄(_)로 연결합니다 (Pascal_Case_With_Underscores).
    • 이유: 변수나 서브프로그램 같은 다른 요소들과 시각적으로 명확하게 구분하기 위함입니다.
    • 예시:
      type Sensor_Reading is record ... end record;
      subtype Positive is Integer range 1 .. Integer'last;
      exception Data_Error is new Exception;
      protected type Buffer is ... end Buffer;
      
  2. 변수, 서브프로그램, 엔트리 (Variables, subprograms, Entries): snake_case (소문자)

    • 모든 실행 가능하거나 데이터를 담는 식별자를 일관된 스타일로 유지하여 가독성을 높입니다.
    • 예시:
      current_temperature : Float;
      procedure print_report (report_data : in String);
      -- 보호 객체 내 엔트리 예시
      entry get_item (item : out Data);
      
  3. 상수 (Constants): UPPER_CASE

    • 프로그램 실행 중에 변하지 않는 고정된 값임을 시각적으로 강조합니다.
    • 예시:
      PI : constant := 3.14159;
      MAX_BUFFER_SIZE : constant Natural := 1024;
      
  4. 패키지 (Packages): Pascal_Case

    • 관련된 기능들의 집합임을 명확히 나타냅니다.
    • 예시:
      package Sensor_Utilities is ... end Sensor_Utilities;
      with Ada.Text_IO;
      

명명 규칙 요약표

구분 표기법 예시
타입, 예외, 보호 객체 Pascal_Case_With_Underscores Sensor_Data, Invalid_Input
변수, 서브프로그램 snake_case user_name, calculate_sum
상수 UPPER_CASE_WITH_UNDERSCORES SECONDS_PER_HOUR, DEFAULT_PORT
패키지 Pascal_Case Image_Processing, Net_Sockets

이러한 규칙은 강제 사항은 아니지만, 좋은 코드를 작성하기 위한 약속입니다. 일관된 명명 규칙을 사용하면 코드를 처음 보는 사람도 각 식별자의 역할과 종류를 쉽게 추측할 수 있어 협업과 유지보수 효율이 크게 향상됩니다.

3.3.4 예약어(Reserved Word)와 표준 지시어

Ada 언어의 문법을 구성하는 가장 기본적인 요소는 예약어(reserved words)입니다. 이 단어들은 컴파일러에게 특별한 의미를 전달하는 약속이므로, 프로그래머가 변수, 타입, 서브프로그램 등의 이름(식별자)으로 사용할 수 없도록 예약되어 있습니다.

예약어는 Ada 코드의 구조와 의미를 형성하는 뼈대와 같습니다. 예를 들어, procedure, if, for, loop, begin, end 와 같은 단어들은 각각 프로시저의 시작, 조건문, 반복문 등을 정의하는 데 사용됩니다. Clair 코딩 스타일 가이드에 따라, 모든 예약어는 코드의 가독성을 위해 소문자 snake_case로 작성하는 것을 원칙으로 합니다.

Ada 2022 표준에 정의된 예약어의 전체 목록은 다음과 같습니다.

         
abort abs abstract accept access
aliased all and array at
begin body case constant declare
delay delta digits do else
elsif end entry exception exit
for function generic goto if
in interface is limited loop
mod new not null of
or others out overriding package
parallel pragma private procedure protected
raise range record rem renames
requeue return reverse select separate
some subtype synchronized tagged task
terminate then type until use
when while with xor  

예약어 외에도 Ada에는 컴파일러의 동작에 영향을 주거나 특정 의미를 갖는 다양한 표준 지시어가 있습니다. 이들은 예약어처럼 식별자로 사용할 수는 없지만, 고유한 문법적 위치와 역할을 가집니다.

  • 애트리뷰트(attributes): 특정 개체(entity)나 타입의 속성을 나타내며, 항상 작은따옴표(')로 시작합니다. (예: 'address, 'image, 'valid)
  • 프라그마(pragmas): pragma 예약어로 시작하며 컴파일러에 대한 특정 지시나 제약을 명시합니다. (예: pragma import, pragma suppress)
  • 애스펙트(aspects): with 키워드와 함께 사용되어 선언에 부가적인 속성이나 계약을 명시하는 현대적인 방법입니다. (예: with pre, with volatile)

이러한 지시어들은 예약어와 함께 언어의 기능을 풍부하게 만들며, 이들을 명확히 구분하는 것은 Ada 코드를 정확하게 이해하고 작성하는 데 필수적입니다.

3.4 리터럴 (literals): 정수, 실수, 문자, 문자열

프로그램 코드에서 숫자 100, 문자 'A', 문자열 "Hello, world!" 와 같이 값 자체를 직접 표현하는 것을 리터럴(literal)이라고 합니다. 리터럴은 변수나 상수를 초기화하거나, 계산에 사용되는 고정된 값을 나타내는 가장 기본적인 방법입니다.

Ada는 다양한 데이터 타입을 지원하는 만큼, 각각의 타입에 맞는 여러 형태의 리터럴 표기법을 제공합니다. 이번 절에서는 가장 기본이 되는 네 가지 종류의 리터럴, 즉 정수, 실수, 문자, 그리고 문자열 리터럴을 작성하는 방법과 각각의 규칙에 대해 자세히 알아보겠습니다.

3.4.1 리터럴의 개념

리터럴(literal)은 소스 코드에 고정된 값 그 자체를 직접 기록한 것을 의미합니다.

예를 들어, 숫자 123, 문자 'A', 문자열 "Hello"는 모두 리터럴입니다. 이들은 변수처럼 다른 값을 담는 공간이 아니라, 값 자체를 나타내는 프로그래밍의 가장 기본적인 요소입니다.

-- 100은 정수 리터럴, "User"는 문자열 리터럴입니다.
Max_Users : constant := 100;
User_Name : String := "User";

Ada의 모든 리터럴은 그 표기법에 따라 내재된 타입(type)을 가집니다. 예를 들어, 소수점 없는 숫자 25는 정수 타입으로, 소수점이 있는 숫자 9.8은 실수 타입으로, 작은따옴표로 감싼 'C'는 문자 타입으로 컴파일러에 의해 인식됩니다.

이처럼 리터럴은 프로그램에 필요한 구체적인 데이터를 제공하는 출발점이며, 변수 초기화, 상수 정의, 연산 등 코드 전반에서 사용됩니다. 이어지는 절에서는 각 데이터 타입에 맞는 리터럴을 어떻게 정확하게 표현하는지 알아보겠습니다.

3.4.2 정수 리터럴 (밑줄 표기법 및 기수 표기법 포함)

정수 리터럴은 Ada에서 가장 흔하게 사용되는 리터럴 중 하나입니다. Ada는 정수를 표현하는 여러 가지 방법을 제공하여, 가독성을 높이고 특정 목적에 맞게 값을 명확하게 표현할 수 있도록 지원합니다.

10진수 표기법 (Decimal Notation)

가장 기본적인 방법으로, 우리가 일상적으로 사용하는 10진수 숫자를 그대로 사용합니다.

123      0      98765

가독성을 위한 밑줄 표기법 (Underscore Notation)

숫자가 길어지면 자릿수를 파악하기 어렵습니다. Ada에서는 숫자 사이에 밑줄(_)을 삽입하여 자릿수를 시각적으로 구분할 수 있도록 허용합니다. 밑줄은 값에 영향을 주지 않으며, 컴파일러는 이를 무시합니다.

-- 밑줄을 사용하여 가독성을 높인 예시
declare
  One_Million   : constant := 1_000_000;  -- 1000000과 동일
  Memory_Size_A : constant := 65536;
  Memory_Size_B : constant := 65_536;   -- 두 상수는 같은 값
begin
  null;
end;

특정 진법 표기법 (Based Literals)

때로는 값을 10진수보다 2진수, 8진수, 또는 16진수로 표현하는 것이 더 자연스럽고 유용할 때가 있습니다. 특히 하드웨어 제어나 데이터 통신과 같이 비트 단위의 조작이 필요할 때 그렇습니다. Ada는 기수#숫자# 형식을 사용하여 다양한 진법을 명시적으로 표현할 수 있습니다.

기본 형식: 진법기수 # 숫자 #

  • 진법 기수: 2부터 16 사이의 10진수 정수입니다.
  • 숫자: 해당 진법에 맞는 숫자입니다. 16진수의 경우 A부터 F까지의 문자를 사용하며, 대소문자를 구분하지 않습니다. 숫자 부분에도 가독성을 위해 밑줄(_)을 사용할 수 있습니다.

주요 진법 예시:

  • 2진수 (Binary): 비트 패턴을 직접 표현할 때 유용합니다.
    -- 8비트 값 11110000 (10진수로 240)
    Bit_Mask : constant := 2#1111_0000#;
    
  • 8진수 (Octal):
    -- 10진수로 63
    Permission : constant := 8#77#;
    
  • 16진수 (Hexadecimal): 메모리 주소나 색상 값을 표현할 때 널리 사용됩니다.
    -- 10진수로 255
    Max_Value : constant := 16#FF#;
    
    -- 10진수로 4095
    Color_Code : constant := 16#0FFF#; -- 16#fff# 와 동일
    

정수 리터럴 표기법 요약

구분 형식 예시 10진수 값
10진수 숫자 12345 12345
밑줄 표기법 숫자_숫자 12_345 12345
2진수 2#숫자# 2#11_0000# 48
8진수 8#숫자# 8#60# 48
16진수 16#숫자# 16#30# 48

3.4.3 실수 리터럴 (지수 표기법 포함)

실수(real number)는 소수점을 포함하는 숫자를 나타내며, 과학 및 공학 계산에서 필수적입니다. Ada에서 실수 리터럴은 반드시 소수점(.)을 포함해야 하며, 소수점 양쪽에 최소 한 개 이상의 숫자가 있어야 합니다.

십진 표기법 (decimal notation)

가장 일반적인 형태이며, 정수 부분과 소수 부분 사이에 소수점을 찍어 표현합니다. 정수 리터럴과 마찬가지로 가독성을 위해 숫자 사이에 밑줄(_)을 사용할 수 있습니다.

예시:

  • 1.0
  • 0.5
  • 3.14159
  • 1_234.567_8 (밑줄 사용)

잘못된 표기법:

  • 1. (소수점 뒤에 숫자가 없음)
  • .5 (소수점 앞에 숫자가 없음)
  • 123 (소수점이 없어 정수 리터럴로 간주됨)

지수 표기법 (exponent notation)

매우 큰 수나 매우 작은 수를 간결하게 표현하기 위해 과학적 표기법(scientific notation)과 유사한 지수 표기법을 사용합니다.

기본 형식: 가수 E 지수

  • 가수 (Mantissa): 기본 표기법의 실수 리터럴 (예: 6.022).
  • E: 지수를 나타내는 기호. 대소문자를 구분하지 않습니다 (e 또는 E).
  • 지수 (Exponent): 10의 거듭제곱을 나타내는 정수. 부호(+ 또는 -)를 붙일 수 있습니다.

예시:

  • 3.0E2 ➡️ $3.0 \times 10^2$, 즉 300.0
  • 1.23E-4 ➡️ $1.23 \times 10^{-4}$, 즉 0.000123
  • 6.022_140_76E+23 ➡️ 아보가드로 수 (Avogadro’s number)

이 표기법들은 다양한 과학 기술 계산에서 정확하고 간결하게 값을 표현하는 데 매우 유용합니다.

3.4.4 문자 리터럴

문자(character) 리터럴은 단 하나의 문자를 값으로 나타냅니다. Ada에서 문자 리터럴은 작은따옴표(')로 문자를 감싸서 표현합니다.

작은따옴표 안에는 출력 가능한 어떤 문자든 올 수 있습니다.

예시:

  • 'A' (알파벳 대문자 A)
  • 'a' (알파벳 소문자 a)
  • '7' (숫자 7)
  • '$' (특수 기호 달러)
  • ' ' (공백 문자)

문자 리터럴은 Character 타입의 값을 나타내며, 작은따옴표 안에 반드시 하나의 문자만 있어야 합니다. 'AB'와 같이 두 개 이상의 문자를 넣으면 컴파일 오류가 발생합니다.

3.4.5 문자열 리터럴

문자열(string) 리터럴은 0개 이상의 문자들로 이루어진 연속된 시퀀스를 나타냅니다. Ada에서 문자열 리터럴은 큰따옴표(")로 문자 시퀀스를 감싸서 표현합니다.

예시:

  • "Hello, world!"
  • "Ada Programming"
  • "12345"
  • "" (아무 문자도 포함하지 않는 빈 문자열(empty string))

문자열 리터럴은 String 타입의 값을 나타내며, StringCharacter의 배열로 정의됩니다.

여러 줄 문자열

문자열이 너무 길어서 한 줄에 모두 표시하기 어려운 경우, 앰퍼샌드(&) 연산자를 사용하여 여러 줄에 걸쳐 문자열을 이어 붙일 수 있습니다.

declare
  -- & 연산자를 사용하여 두 문자열 리터럴을 연결
  Long_String : constant String := "This is a very long string that " &
                                  "spans multiple lines for readability.";
begin
  null;
end;

문자열 내에 큰따옴표 포함하기

문자열 리터럴 안에 큰따옴표(") 문자 자체를 포함해야 할 경우, 큰따옴표를 두 번 연속으로 사용하면 됩니다.

declare
  -- "Hello" 라는 단어에 따옴표를 포함시키기
  Quoted_String : constant String := "She said, ""Hello"".";
begin
  -- 출력 결과: She said, "Hello".
  null;
end;

3.5 주석 (comment)

소스 코드에는 컴파일러가 처리하는 명령어뿐만 아니라, 코드를 읽는 사람을 위해 작성하는 설명글을 포함할 수 있습니다. 이를 주석(comment)이라고 부르며, 컴파일러는 주석을 완전히 무시합니다.

주석의 주된 목적은 코드의 가독성과 유지보수성을 높이는 것입니다. 잘 작성된 코드는 그 자체로 많은 것을 설명하지만, 코드만으로는 표현하기 어려운 내용들이 존재합니다.

  • “무엇(What)”이 아닌 “왜(Why)”: 코드가 무엇을 하는지는 구문을 통해 알 수 있지만, 왜 그런 방식으로 작성되었는지에 대한 설계 의도나 배경을 설명합니다.
  • 복잡한 로직 설명: 특정 알고리즘이나 비즈니스 규칙의 복잡한 부분을 명확히 합니다.
  • 임시 코드 비활성화: 디버깅 과정에서 특정 코드 라인을 삭제하지 않고 잠시 실행에서 제외할 때 사용됩니다.

효과적인 주석 작성은 단순히 코드를 설명하는 것을 넘어, 미래의 자신과 동료 개발자를 위한 중요한 소통 수단입니다. 따라서 주석은 명확하고, 간결하며, 코드와 항상 동기화되어야 합니다.

3.5.1 주석의 역할과 중요성

주석은 단순한 코드의 부연 설명을 넘어, 소프트웨어의 품질과 수명을 결정하는 핵심적인 요소입니다. 잘 작성된 코드는 그 자체로 훌륭한 문서이지만, 코드만으로는 전달할 수 없는 중요한 정보들이 있으며, 주석은 바로 그 간극을 메우는 역할을 합니다.

설계 의도와 맥락의 전달

코드는 “무엇(what)”을 하는지 보여주지만, “왜(why)” 그렇게 하는지는 설명하지 못합니다. 주석의 가장 중요한 역할은 바로 이 “왜”에 해당하는 설계 의도와 배경, 그리고 맥락을 전달하는 것입니다.

  • 복잡한 로직: 특정 알고리즘을 선택한 이유, 최적화를 위해 사용된 비직관적인 코드의 배경 등을 설명합니다.
  • 비즈니스 규칙: 코드에 반영된 특정 비즈니스 정책이나 규약을 명시합니다.
  • 결정의 근거: Timeout_Duration : constant := 500; 이라는 코드에서, 숫자 500이 어떤 실험적 결과나 요구사항에 의해 도출되었는지 주석으로 남겨야만 그 의미가 명확해집니다.

유지보수성과 협업 효율 증진

소프트웨어는 한번 작성되고 끝나는 것이 아니라, 오랜 기간에 걸쳐 수정되고 확장됩니다. 코드를 읽는 시간은 코드를 작성하는 시간보다 훨씬 깁니다.

  • 미래를 위한 투자: 명확한 주석은 미래의 자신이나 다른 팀원이 코드를 이해하는 데 필요한 시간과 인지 부하(cognitive load)를 극적으로 줄여줍니다. 이는 버그 수정 및 기능 추가의 생산성을 높이는 직접적인 요인이 됩니다.
  • 지식 공유: 주석은 팀원 간의 중요한 지식 공유 수단입니다. 담당자가 바뀌거나 새로운 팀원이 합류했을 때, 주석은 코드의 암묵적인 지식을 전달하여 원활한 인수인계를 돕습니다.

재사용을 위한 API 문서화

패키지나 서브프로그램의 주석은 그 자체로 중요한 API(Application Programming Interface) 문서가 됩니다. 다른 개발자가 해당 코드를 재사용하고자 할 때, 주석은 다음과 같은 필수 정보를 제공해야 합니다.

  • 서브프로그램의 목적과 기능
  • 각 매개변수(parameter)의 의미와 제약 조건
  • 반환 값(return value)의 의미
  • 발생할 수 있는 예외(exception)의 종류와 원인

이러한 정보가 없다면 해당 코드는 사실상 재사용이 불가능한 ‘블랙박스’로 남게 됩니다.

결론적으로, 주석은 선택 사항이 아닌 전문 개발자의 의무입니다. 잘 관리된 주석은 소프트웨어 프로젝트의 장기적인 가치를 높이는 핵심적인 투자이며, 코드의 신뢰성과 생명력을 보장하는 중요한 안전장치입니다.

3.5.2 주석의 사용법 (--)

Ada에서 주석을 작성하는 방법은 매우 간단하고 일관됩니다. 두 개의 하이픈(--)을 사용하면, 그 지점부터 해당 라인의 끝까지 모든 텍스트가 주석으로 처리됩니다.

주석의 위치

주석은 코드의 두 가지 주요 위치에 사용될 수 있습니다.

  1. 한 라인 전체 사용: 주석이 한 라인 전체를 차지하는 경우입니다. 보통 코드 블록 전체의 목적을 설명하거나, 여러 줄에 걸쳐 상세한 설명을 제공할 때 사용됩니다.

  2. 코드 끝에 추가: 코드 문장이 끝난 뒤, 같은 라인에 주석을 추가하는 경우입니다. 특정 변수 선언이나 한 줄의 코드에 대한 간결한 설명을 덧붙일 때 유용합니다.

다음은 주석 사용법을 보여주는 예제입니다.

-- 이것은 전체 라인을 차지하는 주석입니다.
-- 프로시저의 목적이나 전반적인 동작을 설명할 수 있습니다.

procedure comment_example is
  MAX_RETRIES : constant Integer := 3; -- 최대 시도 횟수를 정의 (라인 끝 주석)
  retries     : Integer := 0;          -- 현재 시도 횟수를 저장할 변수
begin
  -- 루프의 목적: 최대 시도 횟수까지 작업을 반복합니다.
  loop
    retries := retries + 1; -- 시도 횟수를 1 증가시킵니다.

    -- 디버깅을 위해 임시로 비활성화된 코드 (주석 처리)
    -- Ada.Text_IO.put_line ("Debug: Current retries = " & Integer'image (retries));

    exit when retries >= MAX_RETRIES;
  end loop;
end comment_example;

여러 줄 주석

Ada는 C나 Java에서 사용되는 /* ... */ 형태의 블록 주석(block comment) 구문을 지원하지 않습니다. 여러 줄에 걸쳐 주석을 작성하려면, 아래와 같이 각각의 라인을 모두 --로 시작해야 합니다.

-- 이것은 여러 줄에 걸친 주석의 첫 번째 라인입니다.
-- 이것은 두 번째 라인입니다.
-- 이 방식은 의도적으로 채택된 것으로, 코드의 명확성을 높입니다.

이처럼 Ada의 주석 구문은 단순함을 통해 명확성을 확보합니다. 하나의 일관된 규칙만으로 필요한 모든 종류의 주석을 표현할 수 있습니다.

3.6 문장(statement)

Ada에서 문장(statement)은 프로그램이 수행해야 할 개별적인 동작이나 명령을 나타냅니다. 모든 문장은 세미콜론(;)으로 끝나며, 이는 하나의 명령이 끝났음을 컴파일러에 알리는 역할을 합니다.

문장은 종종 특정 ‘값’을 필요로 합니다. 예를 들어 변수에 어떤 값을 저장하거나, 프로시저를 호출할 때 인자로 값을 전달해야 합니다. 이처럼 값을 계산하거나 나타내는 코드 조각을 표현식(Expression)이라고 부릅니다. “Hello, World!”와 같은 리터럴이 가장 단순한 표현식의 예입니다. 표현식에 대한 자세한 내용은 6장에서 깊이 있게 다룰 것입니다.

이제 Ada의 주요 문장들에 대해 살펴보겠습니다.

3.6.1 문장(statement)과 세미콜론(;)의 역할

문장(statement)은 프로그램을 구성하는 가장 작은 실행 단위입니다. 프로그램의 실행부에 있는 각 문장은 컴퓨터가 수행해야 할 하나의 완전한 명령을 나타냅니다. 예를 들어, 변수에 값을 할당하는 것, 화면에 글자를 출력하는 것 모두 하나의 문장으로 표현됩니다.

Ada에서 세미콜론(;)은 문장의 종결자(terminator) 역할을 합니다. 이는 마치 영어 문장의 끝에 찍는 마침표(.)와 같습니다. 컴파일러는 세미콜론을 만나면 비로소 하나의 문장이 끝났다고 인식합니다.

세미콜론이 문장의 끝을 명확히 알려주기 때문에, 가독성을 위해 하나의 긴 문장을 여러 줄에 걸쳐 작성하는 것이 자유롭습니다.

코드 예시:

procedure statement_example is
  x : Integer;
  y : Integer;
begin
  -- X := 10 은 하나의 완전한 문장입니다.
  x := 10;

  -- 아래의 긴 코드 역시 세미콜론이 나오기 전까지는
  -- 모두 하나의 문장으로 취급됩니다.
  y := (x + 20) *
       (x - 5);
end statement_example;

이처럼 모든 문장은 반드시 세미콜론으로 끝나야 하며, 이는 Ada 문법의 가장 기본적이고 중요한 규칙 중 하나입니다.

3.6.2 대입문 (assignment statement)

대입문은 특정 표현식(expression)의 결과값을 변수에 저장(할당)하는 가장 기본적인 명령입니다. 프로그램의 상태를 변경하는 핵심적인 역할을 합니다.

구문 (Syntax)

대입문은 변수 이름, 대입 연산자 :=, 그리고 값으로 평가될 표현식으로 구성되며 세미콜론으로 끝납니다.

변수이름 := 표현식;
  • := (대입 연산자): 오른쪽의 값을 왼쪽의 변수에 “대입한다”는 의미입니다. 등호(=) 하나만 사용하는 비교 연산자와 명확히 구분되므로, 실수를 방지하는 데 도움이 됩니다.
  • 표현식 (Expression): 리터럴, 다른 변수, 상수, 또는 연산의 결과가 될 수 있습니다. 단, 표현식의 결과값은 반드시 변수의 데이터 타입과 호환되어야 합니다.

코드 예시

procedure Assignment_Example is
  Count : Integer;
  Is_Done : Boolean;
  Message : String (1 .. 12);
begin
  -- 정수 리터럴 10을 변수 Count에 대입
  Count := 10;

  -- 논리식의 결과(True)를 변수 Is_Done에 대입
  Is_Done := Count > 5;

  -- 문자열 리터럴을 변수 Message에 대입
  Message := "Hello, Ada!";
end Assignment_Example;

3.6.3 프로시저 호출문

프로시저 호출문은 미리 정의된 procedure의 실행부에 있는 코드 블록을 실행하도록 지시하는 명령입니다. 이는 특정 작업을 수행하는 코드 묶음을 “호출”하여 재사용하는 프로그래밍의 핵심적인 기능입니다.

구문 (Syntax)

프로시저 호출은 프로시저의 이름과 세미콜론으로 구성됩니다. 만약 프로시저가 매개변수(parameter)를 필요로 한다면, 괄호 () 안에 필요한 값들을 전달합니다.

-- 매개변수가 없는 경우
프로시저_이름;

-- 매개변수가 있는 경우
프로시저_이름 (매개변수1, 매개변수2, ...);

코드 예시

우리가 “Hello, World!” 예제에서 사용했던 put_line이 바로 프로시저 호출문의 가장 대표적인 예입니다.

with Ada.Text_IO; use Ada.Text_IO;

procedure Call_Example is
begin
  -- 'put_line'이라는 이름의 프로시저를 호출
  -- "This is a parameter." 라는 문자열을 매개변수로 전달
  put_line ("This is a parameter.");
end Call_Example;

위 코드에서 put_line ("This is a parameter."); 한 줄이 바로 Ada.Text_IO 패키지에 정의된 put_line 프로시저를 호출하는 문장입니다. 이 호출로 인해 해당 프로시저의 내부 코드가 실행되어 화면에 문자열이 출력됩니다.

3.6.4 null

null 문은 아무런 동작도 수행하지 않는, 실행 가능한 문장입니다. 이것의 주된 목적은 구문적으로는 문장이 반드시 위치해야 하지만, 논리적으로는 아무런 작업도 필요하지 않은 경우에 코드의 의도를 명확히 하는 것입니다.

null 문을 사용함으로써, 프로그래머는 해당 위치의 코드 누락이 실수가 아니라 의도된 부재(intentional absence)임을 명시적으로 표현할 수 있습니다.

구문 (syntax)

null 문의 구문은 단순히 키워드 null과 세미콜론으로 이루어집니다.

null;

주요 활용 사례

null 문은 조건문이나 예외 처리기 등에서 특정 경우를 의도적으로 무시하고 싶을 때 매우 유용합니다.

  1. case 문에서 특정 선택지 무시: case 문에서 일부 선택지에 대해서는 아무런 동작도 수행할 필요가 없을 때 사용됩니다.

    코드 예시: 일부 case 선택지 무시

    type Event_Type is (Status_Update, Warning, Critical_Error, User_Login);
    
    procedure handle_event (event : in Event_Type) is
    begin
      case event is
        when Status_Update | User_Login =>
          -- 상태 업데이트나 사용자 로그인은 기록할 필요가 없음
          null;
        when Warning =>
          log_warning_message ("A minor issue occurred.");
        when Critical_Error =>
          raise_alarm ("Critical system error detected!");
      end case;
    end handle_event;
    

    위 예제에서 Status_UpdateUser_Login 이벤트는 의도적으로 아무런 처리를 하지 않음을 null;을 통해 명확히 보여줍니다.

  2. 예외 처리기에서 예외 무시: 특정 예외가 발생했을 때 이를 인지하고는 있지만, 복구 조치 없이 정상적으로 실행을 계속하고자 할 때 사용됩니다.

    코드 예시: 특정 예외를 의도적으로 무시

    procedure perform_optional_action is
      Action_Not_Available : exception;
      -- ...
    begin
      -- ... 잠재적으로 Action_Not_Available 예외 발생 가능
      null;
    exception
      when Action_Not_Available =>
        -- 선택적 동작을 수행할 수 없는 것은 정상적인 상황이므로
        -- 아무것도 하지 않고 넘어갑니다.
        null;
      when others =>
        -- 다른 예외는 처리
        Log_Unexpected_Error;
    end perform_optional_action;
    
  3. 개발 중 스텁(Stub) 역할: 아직 구현되지 않은 서브프로그램(프로시저, 함수)의 몸체를 임시로 채우는 용도로 사용할 수 있습니다.

    procedure initialize_hardware is
    begin
      -- TODO: 하드웨어 초기화 로직 구현 예정
      null;
    end initialize_hardware;
    

결론적으로 null 문은 코드를 실행하지 않는다는 소극적인 역할을 하지만, 코드의 명확성과 가독성을 높여 “비어 있는” 것이 아니라 “의도적으로 비워둔” 것임을 전달하는 중요한 소통 도구입니다.

3.6.6 블록문: 지역 유효 범위와 실행 그룹화

블록문(block statement)은 프로그램의 특정 실행부 내에서 다른 코드에 영향을 주지 않는 지역적인 유효 범위(local scope)를 만드는 강력하고 유연한 기능입니다. 이를 통해 단순히 여러 실행문을 논리적으로 묶거나, 특정 작업에만 필요한 변수를 임시로 선언하거나, 특정 부분의 예외를 지역적으로 처리할 수 있습니다.

블록문의 전체 구조

블록문은 최대 네 부분으로 구성되며, 그 전체 구조는 다음과 같습니다. 이 전체 구조는 하나의 완전한 문장으로 취급되므로, 마지막 end 뒤에는 반드시 세미콜론(;)이 붙습니다.

[Block_Name:] -- 선택 사항: 블록에 이름을 붙일 수 있습니다.
declare          -- 선택 사항: 지역 변수, 상수 등을 선언합니다.
   -- 선언부 (Declarative Part)
begin            -- 필수 사항: 실제 실행할 코드를 포함합니다.
   -- 실행부 (Sequence of Statements)
exception        -- 선택 사항: 이 블록 내의 예외를 처리합니다.
   -- 예외 처리부 (Exception Handlers)
end [Block_Name];

핵심은 beginend;는 필수이며, declare 부와 exception 부는 필요에 따라 선택적으로 사용할 수 있다는 점입니다. 이러한 유연성 덕분에 블록문은 다양한 상황에 맞춰 사용될 수 있습니다.

블록 이름의 목적

블록에 이름을 붙이는 주된 이유는 다음과 같습니다.

  1. 가독성 향상: 코드가 길어지거나 블록이 여러 겹으로 중첩(nested)될 때, 이름은 코드의 구조를 파악하는 데 결정적인 도움을 줍니다. Swap_AreaCritical_Section처럼 의미 있는 이름을 붙이면 해당 블록의 역할을 명확히 전달할 수 있으며, 어떤 end가 어떤 블록의 끝인지 즉시 알 수 있습니다.

  2. 구조적 안정성 검증: 컴파일러는 블록의 시작과 끝 이름이 일치하는지 검사합니다. 만약 이름이 서로 다르면 컴파일 오류가 발생하여, 코드를 수정하는 과정에서 발생할 수 있는 구조적인 실수를 컴파일 단계에서 잡아주는 유용한 안전장치가 됩니다.

중요한 점은, 블록 이름은 goto 문처럼 프로그램의 흐름을 제어하기 위해 참조하는 용도가 아니라는 것입니다. 그 역할은 오직 가독성과 구조적 안정성을 높이는 데 있습니다.

사용 목적에 따른 형태

1. 지역 변수 선언 (declare 블록)

가장 일반적인 형태로, 특정 작업에만 필요한 임시 변수나 상수를 선언할 때 사용됩니다. 변수의 생명주기를 필요한 만큼으로 최소화하여 코드의 가독성과 안정성을 높입니다.

  • 예시: 변수 값 맞바꾸기
    Swap_Area:
    declare
       Temp : Integer; -- Temp 변수는 이 블록 안에서만 유효합니다.
    begin
       Temp := X;
       X    := Y;
       Y    := Temp;
    end Swap_Area;
    

2. 지역 예외 처리

지역 변수 선언은 필요 없지만, 코드의 특정 부분에서 발생하는 예외를 정밀하게 제어하고 싶을 때 사용합니다. if문과 달리 블록문은 자신만의 예외 처리부를 가질 수 있어, 견고한 코드 작성에 필수적입니다.

  • 예시: 파일 처리 중 발생할 수 있는 오류 처리
    begin
       Process_File (File_Object);
    exception
       when Ada.IO_Exceptions.Status_Error =>
          Put_Line ("오류: 파일이 열려있지 않습니다.");
    end;
    

3. 단순 그룹화

declareexception이 모두 필요 없을 때, 여러 실행문을 단순히 하나의 논리적 단위로 묶기 위해 사용합니다. 코드의 구조를 명확히 하거나 if문 같은 곳에서 여러 동작을 하나의 단위로 취급할 때 유용합니다.

  • 예시: if문 안에서 여러 동작 수행하기
    if Is_Ready then
       begin
          Initialize_Device;
          Run_Diagnostics;
       end;
    end if;
    

4. 전체 형태 사용

복잡한 작업을 수행할 때, 지역 변수 선언과 지역 예외 처리가 모두 필요한 경우 블록문의 전체 형태를 사용합니다.

  • 예시: 안전한 나눗셈 연산
    Safe_Division:
    declare
       Result : Float;
    begin
       Result := Float(Numerator) / Float(Denominator);
       Put_Line ("결과: " & Result'Image);
    exception
       when Constraint_Error => -- 0으로 나누는 경우 발생
          Put_Line ("오류: 0으로 나눌 수 없습니다.");
    end Safe_Division;
    

3.7 컴파일러 지시어: 프라그마와 애스펙트 (Compiler Directives: Pragmas and Aspects)

프로그램의 주된 로직을 구성하는 문장과 표현식 외에도, 개발자는 컴파일러의 동작을 제어하거나 특정 선언에 부가적인 정보를 제공해야 할 때가 있습니다. 이러한 역할을 하는 것이 바로 컴파일러 지시어(compiler directives)입니다.

Ada는 두 가지 형태의 컴파일러 지시어를 제공합니다: 전통적인 방식인 프라그마(pragma)와 현대적인 방식인 애스펙트(aspect)입니다. 두 가지 모두 코드의 실행 로직 자체를 바꾸기보다는, 컴파일 방식, 최적화, 다른 언어와의 인터페이스, 계약 기반 설계 명세 등 프로그램의 부가적인 특성을 명시하는 데 사용됩니다.

이번 절에서는 이 두 가지 지시어가 각각 어떻게 사용되며, 코드의 특성을 명시하고 프로그램의 신뢰성을 높이는 데 어떻게 기여하는지 알아보겠습니다.

3.7.1 프라그마(pragma): 컴파일러와의 전통적인 소통

프라그마(Pragma)는 Ada의 초기 버전부터 존재해 온 컴파일러 지시어의 전통적인 형태입니다. 이는 ‘실용적인 정보’라는 어원에서 알 수 있듯, 프로그램의 실행 로직에 직접 관여하기보다는 컴파일러에게 코드의 특정 부분을 어떻게 처리해야 할지에 대한 지침이나 제안을 전달하는 역할을 합니다.

프라그마는 코드의 특정 위치에 배치되어 컴파일러의 최적화 수준을 조절하거나, 런타임 검사를 비활성화하거나, 다른 프로그래밍 언어로 작성된 서브프로그램을 연결하는 등의 작업을 수행합니다.

구문 (Syntax)

프라그마는 pragma 예약어로 시작하고, 지시어의 이름(Identifier)과 선택적인 인수(Argument) 목록으로 구성되며 세미콜론으로 끝납니다.

pragma 지시어_이름;
pragma 지시어_이름 (인수1, 인수2, ...);

주요 특징

  • 지시어 역할: 프라그마는 명령이라기보다는 ‘지시’에 가깝습니다. 만약 컴파일러가 특정 프라그마를 이해하지 못하더라도, 대부분의 경우 오류를 발생시키지 않고 무시합니다. 이는 코드의 이식성을 높여줍니다.
  • 배치 유연성: 프라그마는 선언부나 문장이 위치할 수 있는 대부분의 위치에 비교적 자유롭게 배치될 수 있습니다.

코드 예시

가장 흔한 사용 사례 중 하나는 다른 언어의 함수를 Ada 코드에서 사용하기 위한 import 프라그마입니다.

with Interfaces.C.Strings;

package C_Interface is
   -- C 표준 라이브러리의 'getenv' 함수를 임포트하여
   -- Ada에서 호출할 수 있도록 함
   function getenv (name : Interfaces.C.Strings.chars_ptr)
      return Interfaces.C.Strings.chars_ptr;

   -- getenv 함수에 대한 임포트 프라그마
   -- 1. 컨벤션: C 언어의 호출 규약을 따름
   -- 2. Ada 이름: Ada 코드에서 사용할 이름은 getenv
   -- 3. 외부 링크 이름: 실제 C 라이브러리에서의 이름은 "getenv"
   pragma import (c, getenv, "getenv");

end C_Interface;

위 예제에서 pragma importgetenv라는 함수가 Ada 코드로 구현된 것이 아니라, 외부 C 라이브러리에 정의되어 있으며 링크 시 “getenv”라는 이름으로 찾아야 함을 컴파일러에게 알려줍니다. 이처럼 프라그마는 Ada 프로그램과 외부 환경 간의 소통을 가능하게 하는 중요한 통로 역할을 합니다.

사용 시 주의사항: 이름 숨김 (name hiding)

블록문은 외부와 격리된 자신만의 유효 범위를 가지므로, 바깥쪽에 이미 선언된 변수와 동일한 이름의 변수declare 부에 선언하는 것이 문법적으로 가능합니다.

이 경우, 블록 내부에서는 새로 선언된 지역 변수가 외부 변수를 일시적으로 가리게(hides) 됩니다. 이를 ‘이름 숨김’ 또는 ‘섀도잉(shadowing)’이라고 부릅니다.

  • 예시:

    procedure Hiding_Example is
       X : Integer := 10; -- 외부 변수 X
    begin
       Ada.Text_IO.Put_Line ("외부 블록 (시작): X = " & X'Image); -- " 10" 출력
    
       declare
          X : Integer := 99; -- 새로운 지역 변수 X (외부 X를 가림)
       begin
          Ada.Text_IO.Put_Line ("내부 블록: X = " & X'Image); -- " 99" 출력
       end;
    
       Ada.Text_IO.Put_Line ("외부 블록 (끝): X = " & X'Image); -- 다시 " 10" 출력
    end Hiding_Example;
    

이 기능은 유용할 때도 있지만, 의도치 않은 논리적 오류의 원인이 될 수 있으므로 변수명을 정할 때 주의가 필요합니다.

3.7.2 애스펙트(aspect): 선언에 정보를 부여하는 현대적 접근

애스펙트(Aspect)는 Ada 2012에서 도입된, 컴파일러 지시어를 표현하는 현대적이고 구조화된 방식입니다. 프라그마가 독립적인 문장으로 존재하여 코드의 흐름 중간에 위치하는 반면, 애스펙트는 with 키워드를 사용하여 선언(declaration) 자체에 직접 부착됩니다.

이러한 방식은 지시어와 그 지시어가 적용되는 대상을 문법적으로 강하게 연결하여, 코드의 가독성과 명확성을 획기적으로 향상시킵니다. 즉, 특정 변수나 서브프로그램의 부가적인 속성이 무엇인지 그 선언부만 보고도 즉시 파악할 수 있게 해줍니다.

구문 (Syntax)

애스펙트는 with 키워드와 함께, 애스펙트의 이름과 선택적인 값으로 구성됩니다. 하나의 선언에 여러 애스펙트를 쉼표(,)로 구분하여 함께 명시할 수도 있습니다.

-- 변수 선언에 애스펙트 적용
변수_이름 : 타입 with 애스펙트_이름;
변수_이름 : 타입 with 애스펙트_이름 => ;

-- 서브프로그램 선언에 애스펙트 적용
procedure 프로시저_이름 with 애스펙트1, 애스펙트2 => ;

주요 특징

  • 가독성과 지역성(Locality): 지시어가 선언의 일부이므로, 코드의 다른 부분을 찾아볼 필요 없이 해당 선언의 특성을 바로 이해할 수 있습니다.
  • 구조적 명확성: 프라그마와 달리 애스펙트는 적용 대상이 문법적으로 명확하여, 의도치 않은 곳에 지시어가 적용될 가능성이 없습니다.
  • 통합된 문법: 계약 기반 설계(Design by Contract)의 사전조건(pre), 사후조건(post) 등 언어의 고급 기능들이 모두 애스펙트의 형태로 자연스럽게 통합되어 일관성 있는 문법을 제공합니다.

코드 예시

3.7.1절에서 프라그마를 사용하여 구현했던 C 함수 임포트를 애스펙트를 사용하면 다음과 같이 더 명확하게 표현할 수 있습니다.

with Interfaces.C.Strings;

package C_Interface is
   -- getenv 함수 선언에 import 애스펙트를 직접 부착
   function getenv (name : Interfaces.C.Strings.chars_ptr)
      return Interfaces.C.Strings.chars_ptr
   with
      import      => True,
      convention  => C,
      link_name   => "getenv";

end C_Interface;

pragma import (...) 라는 별도의 문장을 사용하는 대신, import, convention, link_name 이라는 세 가지 애스펙트가 getenv 함수 선언의 일부가 되었습니다. 이 코드는 getenv 함수가 C 언어의 호출 규약을 따르는 외부 함수임을 훨씬 더 직관적으로 보여줍니다.

이처럼 애스펙트는 컴파일러 지시어를 단순한 부가 정보에서 선언의 본질적인 속성으로 끌어올려, 코드를 더 안전하고 읽기 쉽게 만드는 현대적인 Ada의 핵심 기능입니다.

3.7.3 일반적인 프라그마와 애스펙트 예시 (suppress, import, volatile)

프라그마와 애스펙트는 다양한 목적으로 사용됩니다. 여기서는 가장 흔하게 사용되는 몇 가지 예시를 통해 이들이 실제 코드에서 어떻게 적용되는지 살펴보겠습니다.

suppressUnsuppress: 런타임 검사 제어

Ada는 신뢰성을 높이기 위해 오버플로우, 접근 범위 초과 등 다양한 런타임 검사를 기본적으로 수행합니다. 하지만 성능이 극도로 중요한 특정 코드 구간에서는 이러한 검사가 오버헤드가 될 수 있습니다. suppress 지시어는 컴파일러에게 특정 종류의 검사를 일시적으로 비활성화하도록 지시합니다.

suppress를 사용하는 것은 코드의 안전성을 개발자가 직접 책임지겠다는 의미이므로, 해당 구간이 절대로 오류를 일으키지 않음을 완전히 확신할 수 있을 때만 제한적으로 사용해야 합니다.

  • 프라그마 형태:
    procedure critical_calculation (value : Integer) is
    begin
       -- 이 블록 안에서 발생하는 모든 오버플로우 검사를 비활성화
       pragma suppress (overflow_check);
       -- ... 고성능 연산 수행 ...
    
       -- 다시 검사를 활성화
       pragma unsuppress (overflow_check);
    end critical_calculation;
    
  • 애스펙트 형태 (Ada 2022):
    procedure critical_calculation (value : Integer)
       with suppress => overflow_check;
    

    애스펙트 형태는 해당 서브프로그램 전체에 대해 검사를 비활성화할 때 더 명확한 구문을 제공합니다.


import: 외부 언어와의 인터페이스

import 지시어는 C나 포트란과 같은 다른 언어로 작성된 서브프로그램을 Ada 코드에서 호출할 수 있도록 연결하는 역할을 합니다. 앞선 절에서 살펴본 바와 같이, 이는 시스템의 저수준 기능에 접근하거나 기존 라이브러리를 재사용할 때 필수적입니다.

  • 프라그마 형태:

    function c_sin (x : Float) return Float;
    pragma import (c, c_sin, "sin");
    
  • 애스펙트 형태:

    function c_sin (x : Float) return Float
       with import      => True,
            convention  => C,
            link_name   => "sin";
    

volatile: 최적화 방지

volatile 지시어는 특정 변수가 하드웨어 레지스터, 인터럽트 서비스 루틴, 또는 다른 스레드와 같이 프로그램의 통제 밖의 요인에 의해 언제든지 변경될 수 있음을 컴파일러에게 알립니다.

컴파일러는 일반적으로 코드를 최적화하면서 변수에 대한 불필요해 보이는 읽기/쓰기 동작을 제거하거나 순서를 바꿀 수 있습니다. 하지만 해당 변수가 volatile로 선언되면, 컴파일러는 이 변수에 대한 모든 접근을 코드에 명시된 순서대로 정확하게 유지하며 어떠한 최적화도 수행하지 않습니다. 이는 하드웨어를 직접 제어하는 임베디드 프로그래밍에서 매우 중요합니다.

  • 프라그마 형태:
    -- 특정 하드웨어의 상태 레지스터
    status_register : Status_Register_Type;
    pragma volatile (status_register);
    
  • 애스펙트 형태:
    -- 선언에 직접 volatile 속성을 부여
    status_register : Status_Register_Type with volatile;
    

이 예시들은 프라그마와 애스펙트가 단순히 문법적 차이를 넘어, 코드의 특성을 명시하고 컴파일러와의 정교한 소통을 가능하게 하는 강력한 도구임을 보여줍니다.

4. 스칼라 타입 (scalar types)

스칼라 타입은 분해할 수 없는 단일 값을 나타내는 데이터 타입입니다. Ada의 스칼라 타입은 크게 정수 타입, 실수 타입, 열거형 타입으로 나뉘며, 이는 프로그램이 다루는 데이터의 특성을 명확히 하고 컴파일러가 엄격한 검사를 수행할 수 있는 기반을 제공합니다.

4.1 정수 타입 (integer types)

정수 타입은 소수부가 없는 완전한 수(whole number)를 표현하기 위해 사용됩니다. Ada는 단일 int 타입만을 제공하는 여러 언어와 달리, 표현 범위와 부호 유무에 따라 다양한 정수 타입을 제공하여 프로그래머가 데이터의 특성을 더 정확하게 모델링할 수 있도록 지원합니다.

4.1.1 기본 정수 타입: Integer

정수 타입은 수학의 정수(…, -2, -1, 0, 1, 2, …)와 같이 소수부가 없는 완전한 수를 표현하기 위해 사용됩니다. Ada에서 가장 기본적이고 보편적으로 사용되는 정수 타입은 표준 라이브러리에 미리 정의된 Integer입니다.

Integer는 부호 있는(signed) 타입으로, 양수, 음수, 그리고 0을 모두 나타낼 수 있습니다. 이 타입의 가장 큰 특징은 그 크기와 표현 범위가 특정 하드웨어나 컴파일러에 고정되어 있지 않고, 대상 시스템에서 가장 자연스럽고 효율적인 크기로 결정된다는 점입니다. 이를 구현 정의(implementation-defined)라고 합니다.

Integer 타입의 정확한 범위는 Integer'firstInteger'last 속성을 통해 확인할 수 있습니다. Ada 언어 표준은 이 범위가 최소 –32,767부터 +32,767까지(16비트)를 포함하도록 보장하므로, 기본적인 이식성을 가집니다. 현대의 32비트 또는 64비트 시스템에서는 이보다 훨씬 넓은 범위를 갖는 것이 일반적입니다.

Ada는 Integer 외에도 Short_Integer, Long_Integer 등 다양한 크기의 정수 타입을 제공할 수 있으며, 프로그래머는 type 선언을 통해 특정 범위를 갖는 자신만의 정수 타입을 만들 수도 있습니다. 하지만 특별한 이유가 없다면, 일반적인 정수 연산에는 Integer를 사용하는 것이 표준적인 접근 방식입니다.

코드 예시 4-1: Integer 타입의 사용

다음은 Integer 타입의 변수를 선언하고, 기본적인 산술 연산을 수행한 뒤 결과를 출력하는 예제입니다.

with Ada.Text_IO;
with Ada.Integer_Text_IO;

procedure integer_example is
  -- Integer 타입의 변수와 상수를 선언합니다.
  initial_value : constant Integer := 100;
  increment     : Integer := 50;
  result        : Integer;
begin
  -- 기본적인 산술 연산을 수행합니다.
  result := initial_value + increment;

  Ada.Text_IO.put ("초기값: ");
  Ada.Integer_Text_IO.put (initial_value);
  Ada.Text_IO.new_line;

  Ada.Text_IO.put ("결과 (초기값 + 증가분): ");
  Ada.Integer_Text_IO.put (result);
  Ada.Text_IO.new_line;

end integer_example;

실행 결과:

초기값:         100
결과 (초기값 + 증가분):         150

4.1.2 서브타입과 범위 속성 ('first, 'last, 'range, 'valid)

서브타입(Subtype)은 기존에 존재하는 타입(기저 타입, Base Type)에 특정 제약(constraint)을 추가하여 만드는 새로운 이름의 타입입니다. 서브타입은 새로운 타입을 만드는 것이 아니라, 기존 타입의 값 중 일부만을 허용하는 ‘뷰(view)’ 또는 ‘창문’을 만드는 것과 같습니다. Ada에서 가장 흔한 제약은 range를 이용한 범위 제약입니다.

서브타입을 사용하면 변수가 가질 수 있는 값의 범위를 명확하게 제한하여 프로그램의 안정성과 가독성을 크게 향상시킬 수 있습니다. 예를 들어, ‘학생의 성적’은 음수일 수 없으므로, 이를 0에서 100 사이의 범위를 갖는 서브타입으로 정의할 수 있습니다.

서브타입 선언과 표준 서브타입

서브타입은 subtype 키워드를 사용하여 선언합니다.

subtype Grade is Integer range 0 .. 100;
subtype Uppercase is Character range 'A' .. 'Z';

Ada는 자주 사용되는 정수 서브타입을 표준 라이브러리에 미리 정의해두었습니다.

  • Natural: 0 이상의 정수. subtype Natural is Integer range 0 .. Integer'Last;
  • Positive: 1 이상의 양의 정수. subtype Positive is Integer range 1 .. Integer'Last;

범위 및 유효성 검사 속성

Ada는 서브타입의 범위 제약을 확인하고 활용할 수 있는 강력한 속성들을 제공합니다.

  • 'First'Last: 서브타입이 가질 수 있는 최솟값과 최댓값을 반환합니다.

    • Grade'First0을 반환합니다.
    • Grade'Last100을 반환합니다.
  • 'Range: 'First .. 'Last'와 동일한 의미의 범위를 나타냅니다. for 루프나 배열 선언에 매우 유용합니다.

    -- 0부터 100까지 반복하는 루프
    for i in Grade'Range loop
      -- ...
    end loop;
    
  • 'Valid: 특정 값이 해당 서브타입의 제약 조건을 만족하는지 검사하는 Boolean 속성입니다. Constraint_Error 예외를 발생시키지 않고 값의 유효성을 안전하게 확인할 수 있습니다.

    user_input : Integer := -10; -- 사용자로부터 유효하지 않은 값을 입력받았다고 가정
    score : Grade;
    
    if Grade'Valid (user_input) then
      score := user_input;
    else
      -- 오류 처리 로직
    end if;
    

이 속성들은 코드의 유연성을 극대화합니다. 만약 Grade의 범위가 0 .. 150으로 변경되더라도, 'Range'Valid를 사용한 코드는 수정할 필요 없이 새로운 범위를 자동으로 따르게 됩니다.

4.1.3 모듈러 타입: 부호 없는 순환 정수

Integer나 그 서브타입들은 지정된 범위를 벗어나는 연산이 발생하면 Constraint_Error 예외를 발생시켜 프로그램의 안전성을 보장합니다. 하지만 하드웨어 레지스터를 모델링하거나 특정 암호화 알고리즘을 구현할 때처럼, 정해진 범위를 넘어서면 값이 자동으로 순환(wrap-around)하는 동작이 필요한 경우가 있습니다.

이러한 요구사항을 위해 Ada는 모듈러 타입(modular type)을 제공합니다. 모듈러 타입은 mod 키워드로 정의되는 부호 없는(unsigned) 정수 타입으로, 연산 결과가 타입을 정의한 모듈러스(modulus) 값을 기준으로 순환하는 특징을 가집니다.

선언 구문:

type <타입_이름> is mod <모듈러스_값>;

type My_Type is mod N;으로 선언된 타입은 0부터 N-1까지의 범위를 갖습니다. 이 타입의 변수에 대한 모든 산술 연산은 모듈러 연산(modulo arithmetic)으로 수행됩니다. 예를 들어, N-11을 더하면 결과는 0이 되고, 0에서 1을 빼면 N-1이 됩니다.

이러한 특성은 비트 연산의 기반이 되는 2의 보수 연산 체계와 완벽하게 부합하여, 저수준 프로그래밍에 매우 유용합니다.

코드 예시 4-3: 모듈러 타입의 순환 동작

가장 대표적인 모듈러 타입인 8비트 부호 없는 정수(0..255)를 예로 들어, 순환 동작을 확인해 보겠습니다.

with Ada.Text_IO;

procedure modular_example is
  -- 256을 모듈러스로 하는 8비트 부호 없는 정수 타입을 정의합니다.
  -- 이 타입은 0부터 255까지의 값을 가집니다.
  type Unsigned_Byte is mod 256;

  counter : Unsigned_Byte := 255;
begin
  Ada.Text_IO.put_line ("초기 카운터 값: " & Unsigned_Byte'image (counter));

  -- 최댓값(255)에 1을 더하면 예외가 발생하는 대신, 0으로 순환합니다.
  counter := counter + 1;
  Ada.Text_IO.put_line ("카운터 + 1      : " & Unsigned_Byte'image (counter));

  -- 최솟값(0)에서 1을 빼면 255로 순환합니다.
  counter := counter - 1;
  Ada.Text_IO.put_line ("카운터 - 1      : " & Unsigned_Byte'image (counter));

  -- subtype Byte_Range is Integer range 0..255;
  -- 만약 위와 같은 일반 정수 서브타입을 사용했다면,
  -- 255 + 1 연산은 Constraint_Error 예외를 발생시켰을 것입니다.

end modular_example;

실행 결과:

초기 카운터 값:  255
카운터 + 1      :  0
카운터 - 1      :  255

비트 단위 논리 연산

모듈러 타입의 또 다른 중요한 특징은 비트 단위(bitwise) 논리 연산을 지원한다는 점입니다. 모듈러 타입의 값은 내부적으로 이진수 비트 패턴으로 표현되므로, and, or, xor, not 연산자를 사용하여 각 비트를 직접 조작할 수 있습니다.

이러한 특성은 하드웨어 제어를 위한 특정 비트 플래그를 설정하거나, 데이터의 특정 부분만을 추출하는 비트 마스킹(bit masking) 작업을 수행할 때 매우 유용합니다.

코드 예시: 비트 마스킹

-- 8비트 값에서 하위 4비트(니블, nibble)만 추출하는 예제
declare
  original_value : constant Unsigned_Byte := 16#A5#; -- 10100101
  mask           : constant Unsigned_Byte := 16#0F#; -- 00001111
  masked_value   : Unsigned_Byte;
begin
  masked_value := original_value and mask; -- 결과: 16#05# (00000101)
end;

이처럼 모듈러 타입은 예외 발생 없이 예측 가능한 순환 동작과 비트 단위 제어 기능을 모두 제공하므로, 고정 크기 데이터 처리, 해시 함수 등 저수준 시스템 프로그래밍에서 필수적인 도구입니다.

4.1.4 [심화] 정수 타입의 물리적 표현

지금까지 우리는 정수를 추상적인 수학적 값으로 다루어 왔습니다. 하지만 저수준 프로그래밍이나 다른 시스템과의 데이터 교환을 위해서는, 이 값들이 컴퓨터 메모리에 물리적으로 어떻게 저장되는지 이해하는 것이 중요합니다.

'size 속성과 메모리 크기

Ada의 모든 정수 타입은 메모리 상에서 고정된 크기의 비트(bit) 수를 차지합니다. 'size 속성을 사용하면 해당 타입이 몇 비트로 표현되는지 확인할 수 있습니다.

with Ada.Text_IO;

procedure show_size is
begin
  Ada.Text_IO.put_line ("Integer'size: " & Integer'size'image & " bits");
  Ada.Text_IO.put_line ("Long_Integer'size: " & Long_Integer'size'image & " bits");
end show_size;

GNAT 컴파일러를 64비트 시스템에서 실행하면, 일반적으로 Integer는 32비트, Long_Integer는 64비트로 결과가 나타납니다.

부호 있는 정수: 2의 보수 (Two’s Complement)

Integer와 같은 부호 있는 정수는 현대 컴퓨터 아키텍처에서 거의 예외 없이 2의 보수(Two’s Complement) 표현법을 사용하여 저장됩니다. 이 방식에서는 최상위 비트(Most Significant Bit, MSB)가 부호 비트 역할을 합니다 (0은 양수, 1은 음수). 음수는 모든 비트를 반전시킨 후 1을 더하는 방식으로 표현됩니다. 이 기법은 덧셈과 뺄셈을 동일한 하드웨어 회로로 처리할 수 있게 하여 매우 효율적입니다.

바이트 순서: 엔디안 (endianness)

정수와 같이 여러 바이트(byte)로 구성된 데이터를 메모리에 저장할 때, 바이트를 배열하는 순서를 엔디안(endianness)이라고 합니다. 이는 특히 서로 다른 아키텍처 시스템 간에 바이너리 데이터를 교환할 때 매우 중요합니다.

  1. 빅 엔디안 (Big-Endian): 가장 중요한 바이트(Most Significant Byte, MSB)가 가장 낮은 메모리 주소에 저장됩니다. 이는 우리가 숫자를 읽고 쓰는 방식과 같아 사람이 이해하기 직관적입니다. (예: 0x12345678 -> 12 34 56 78)
  2. 리틀 엔디안 (Little-Endian): 가장 덜 중요한 바이트(Least Significant Byte, LSB)가 가장 낮은 메모리 주소에 저장됩니다. (예: 0x12345678 -> 78 56 34 12). 대부분의 현대 데스크톱 CPU(x86, x64 계열)가 이 방식을 사용합니다.

일반적인 Ada 프로그래밍에서는 엔디안을 직접 신경 쓸 필요가 없습니다. 하지만 바이너리 파일 포맷을 다루거나 네트워크 프로토콜을 구현하는 등 저수준 프로그래밍에서는 데이터의 바이트 순서를 명확히 인지하고 올바르게 처리해야 합니다. 이러한 저수준 세부사항은 20장(저수준 프로그래밍)과 21장(인터페이싱)에서 데이터를 정확하게 해석하고 변환할 때 매우 중요해집니다.

4.2 실수 타입 (real types)

실수 타입은 소수부를 포함할 수 있는 숫자를 표현하는 데 사용됩니다. Ada는 실수를 표현하기 위한 두 가지 명확히 구분되는 메커니즘을 제공합니다: 과학 및 공학 계산에 적합한 부동소수점 타입과, 재무 계산과 같이 절대적인 정밀도가 요구되는 응용 분야에 적합한 고정소수점 타입입니다.

4.2.1 부동소수점 타입 (floating-point types)

부동소수점 타입은 숫자를 가수(mantissa)와 지수(exponent)의 조합으로 저장하여, 매우 넓은 범위의 값을 효율적으로 표현하는 데 사용됩니다. 이 방식은 값의 절대적인 정밀도보다는 유효 숫자의 개수에 기반한 상대적인 정밀도를 제공하므로, 과학 계산이나 그래픽 처리처럼 값의 크기가 다양하게 변하는 분야에 적합합니다.

Ada의 표준 부동소수점 타입으로는 Float가 있으며, 더 높은 정밀도가 필요할 경우 Long_Float 등을 사용할 수 있습니다. 그러나 Ada는 프로그래머가 응용 프로그램의 요구사항을 직접 명시하도록 권장합니다. 이를 위해 digits 키워드를 사용하여 새로운 부동소수점 타입을 정의할 수 있습니다.

digits를 이용한 이식성 높은 선언

사용자는 digits 키워드를 통해 해당 타입이 보장해야 할 최소 유효 십진수 자릿수를 명시합니다.

type Acceleration is digits 8 range -1.0E9 .. 1.0E9;

위 선언은 최소 8자리의 십진수 정밀도를 가지는 Acceleration 타입을 정의합니다. 컴파일러는 이 요구사항을 만족하는 대상 하드웨어의 가장 효율적인 부동소수점 표현(예: IEEE 754 표준의 단정도 또는 배정도 형식)을 자동으로 선택합니다.

이러한 접근 방식은 single이나 double과 같이 하드웨어에 종속적인 키워드를 직접 사용하는 것보다 이식성이 월등히 높습니다. 프로그램의 의도는 “최소 8자리의 정밀도”이지, “32비트 IEEE 754 형식”이 아니기 때문입니다. Ada의 이러한 설계 철학은 코드가 다른 하드웨어 아키텍처에서도 의도대로 동작하도록 보장합니다.

부동소수점 연산의 내재적 한계

부동소수점 연산은 이진수 체계에서 대부분의 십진 소수를 정확하게 표현할 수 없기 때문에, 미세한 반올림 오차(rounding error)를 본질적으로 내포하고 있습니다. 예를 들어, 0.1은 이진수로 표현하면 무한 소수가 되므로 가장 가까운 값으로 근사됩니다.

이러한 특성 때문에, 부동소수점 타입은 정확한 금액 계산이 필요한 재무 응용 프로그램이나 오차 누적이 치명적인 시스템에서는 사용에 매우 신중해야 합니다. 이러한 경우에는 이어지는 절에서 다룰 고정소수점 타입을 사용하는 것이 훨씬 더 안전하고 적합한 해결책입니다.

4.2.2 고정소수점 타입 (fixed-point types)

부동소수점 타입이 과학 및 공학 계산처럼 매우 넓은 범위의 수를 상대적인 정밀도로 다루는 데 최적화되어 있다면, 고정소수점 타입(fixed-point type)은 정밀도가 절대적인 오차 한계 내에서 보장되어야 하는 재무 계산이나 센서 데이터 처리와 같은 분야에 사용됩니다.

이름에서 알 수 있듯이, 고정소수점 타입은 소수점의 위치가 ‘고정’되어 있는 것처럼 동작합니다. 이는 모든 값이 특정 델타(delta)라고 불리는 최소 증분값의 정수배로 표현됨을 의미합니다. 이로 인해 반올림 오류를 예측하고 제어하기가 매우 용이하다는 큰 장점을 가집니다.

일반 고정소수점 타입 (Ordinary Fixed-Point Types)

일반 고정소수점 타입은 delta (절대 정밀도)와 range (값의 범위)를 지정하여 선언합니다.

선언 구문: type 타입이름 is delta 델타값 range 최소값 .. 최대값;

  • delta: 해당 타입이 표현할 수 있는 값 사이의 최대 간격, 즉 절대 오차의 한계를 지정합니다.
  • range: 해당 타입이 반드시 표현해야 하는 값의 범위를 지정합니다.

예제: 달러와 센트를 다루는 통화 타입

type Dollars is delta 0.01 range 0.0 .. 1_000_000.0;

위 선언은 Dollars라는 새로운 타입을 정의하며, 이 타입의 객체들은 최소 0.0부터 백만(1,000,000.0)까지의 값을 가질 수 있고, 그 값의 정밀도는 최소한 0.01(1센트)임을 보장합니다.

내부 표현과 실제 정밀도 ('Small) 컴파일러는 delta 값보다 작거나 같은 2의 거듭제곱 수 중에서 가장 큰 값을 실제 최소 증분값, 즉 'Small 속성값으로 선택합니다. 예를 들어 delta가 0.01일 때, 컴파일러는 1/128 (약 0.0078)을 'Small로 선택할 수 있습니다. 이는 프로그래머가 요구한 정밀도(0.01)보다 더 정밀한 값을 내부적으로 사용함을 의미합니다.

이러한 특성 때문에 일반 고정소수점 타입의 곱셈과 나눗셈 결과는 universal_fixed라는 익명의 타입이 되며, 이를 다시 특정 고정소수점 타입의 변수에 저장하려면 명시적인 타입 변환이 필요합니다.

Price   : Dollars := 19.99;
Tax_Rate: constant := 0.05;
Tax     : Dollars;

-- Price * Tax_Rate의 결과는 universal_fixed 이므로, Dollars 타입으로 변환해야 함
Tax := Dollars (Float (Price) * Tax_Rate); -- 다른 방법도 있음

십진 고정소수점 타입 (Decimal Fixed-Point Types)

일반 고정소수점 타입의 'Small이 2의 거듭제곱이라는 점은, 0.1과 같은 일부 십진 소수를 정확히 표현할 수 없다는 문제를 야기합니다. 이는 특히 단 1센트의 오차도 허용되지 않는 금융 계산에서 치명적일 수 있습니다.

이 문제를 해결하기 위해 Ada 2005부터 십진 고정소수점 타입(decimal fixed-point type)이 도입되었습니다. 이 타입은 delta가 반드시 10의 거듭제곱으로 표현됨을 보장합니다.

선언 구문: type 타입이름 is delta 십진델타값 digits 총자릿수;

예제: 정확한 금융 계산을 위한 타입

type Precise_Dollars is delta 0.01 digits 15;

위 선언은 소수점 이하 두 자리까지 정확히 표현하며, 총 15자리의 십진수 유효숫자를 가질 수 있는 Precise_Dollars 타입을 정의합니다. 이 타입의 'Small은 정확히 0.01이므로, 십진 소수와 관련된 어떠한 표현 오차도 발생하지 않습니다. 표준 라이브러리의 Ada.Decimal 패키지는 이러한 십진 타입을 활용한 다양한 연산을 제공합니다.

결론적으로, 고정소수점 타입은 다음과 같은 경우에 부동소수점 타입보다 훨씬 나은 선택입니다.

  • 값의 범위가 예측 가능하고 오차 한계가 절대적이어야 할 때 (예: 아날로그-디지털 변환 센서 값)
  • 십진 소수를 오차 없이 정확하게 표현해야 하는 금융 또는 회계 응용 프로그램.

이는 프로그래머에게 데이터 표현에 대한 더 정밀한 제어권을 부여하여, 소프트웨어의 신뢰성을 높이는 Ada의 중요한 기능입니다.

4.2.3 [심화] 실수 타입의 물리적 표현

Ada의 Float이나 Long_Float과 같은 실수 타입은 컴퓨터 내부에서 어떻게 표현될까요? 이들은 거의 모든 현대 컴퓨터 아키텍처에서 널리 채택된 IEEE 754 표준에 따라 메모리에 저장됩니다. 이 표준은 매우 큰 수와 매우 작은 수를 효율적으로 표현하기 위해 실수를 세 가지 구성 요소로 나누어 저장합니다.

IEEE 754의 구성 요소

IEEE 754 표준은 실수를 다음과 같은 세 부분의 이진수 비트(bit) 집합으로 표현합니다.

  • 부호 (Sign): 단 1비트로, 숫자가 양수(0)인지 음수(1)인지를 나타냅니다.
  • 지수 (Exponent): 숫자의 크기, 즉 소수점의 위치를 나타냅니다. 이 값을 통해 매우 큰 수(예: $10^{38}$)와 매우 작은 수(예: $10^{-38}$)를 모두 표현할 수 있습니다.
  • 가수 (Mantissa 또는 Fraction): 숫자의 유효숫자를 나타내며, 값의 정밀도를 결정합니다.

주요 형식과 정밀도

Ada의 실수 타입은 일반적으로 다음과 같은 IEEE 754 형식에 대응됩니다.

  1. 단정도 (Single Precision): Float 타입이 주로 해당됩니다.

    • 크기: 32비트 (부호 1비트, 지수 8비트, 가수 23비트).
    • 정밀도: 약 6~9 자릿수의 십진수 유효숫자를 정확하게 표현할 수 있습니다.
  2. 배정도 (Double Precision): Long_Float 타입이 주로 해당됩니다.

    • 크기: 64비트 (부호 1비트, 지수 11비트, 가수 52비트).
    • 정밀도: 약 15~17 자릿수의 십진수 유효숫자를 표현할 수 있어 훨씬 더 높은 정밀도를 제공합니다.

코드 예시 4-4: 실수 타입의 크기 확인

정수 타입과 마찬가지로 'Size 속성을 사용하여 실수 타입이 메모리에서 차지하는 비트 수를 확인할 수 있습니다.

with Ada.Text_IO;

procedure show_float_size is
begin
  Ada.Text_IO.put_line ("Float'Size:      " & Float'Size'image & " bits");
  Ada.Text_IO.put_line ("Long_Float'Size: " & Long_Float'Size'image & " bits");
end show_float_size;

실행 결과 (일반적인 64비트 시스템):

Float'Size:       32 bits
Long_Float'Size:  64 bits

고려사항: 반올림 오차와 특수 값

IEEE 754 표현 방식의 중요한 특징은 대부분의 소수를 이진수로 정확하게 표현할 수 없다는 점입니다. 예를 들어 십진수 0.1은 이진수로 무한 소수가 되어 가장 가까운 값으로 근사됩니다. 이러한 근사치 때문에 부동소수점 연산은 항상 미세한 반올림 오차(rounding error)를 내포할 수 있습니다. 따라서 정확한 값의 비교(=) 대신, 아주 작은 허용 오차 내에 있는지 검사하는 것이 더 안전한 프로그래밍 습관입니다.

또한 IEEE 754 표준은 1.0 / 0.0 연산의 결과인 무한대(Infinity)나 sqrt(-1.0)의 결과인 NaN(Not a Number)과 같은 특수한 값들도 표현할 수 있습니다.

이러한 물리적 표현의 특성을 이해하는 것은 과학 및 공학 계산에서 정확하고 신뢰성 있는 결과를 얻기 위해 필수적입니다.

4.3 열거형 타입 (Enumeration Types)

열거형 타입은 서로 연관된 순서 있는 값들의 집합을 명명된 상수로 정의하는 데이터 타입입니다. 이는 정수와 같은 “매직 넘버(magic number)”를 사용하는 것에 비해 코드의 가독성과 타입 안전성을 획기적으로 향상시킵니다.

4.3.1 열거형 타입의 정의와 장점

프로그램에서 특정 상태나 옵션을 표현할 때, 0부모 프로세스, 1자식 프로세스와 같이 정수 상수로 약속하여 사용하는 경우가 많습니다. 이러한 접근 방식은 다음과 같은 문제점을 가집니다.

  • 가독성 저하: if status = 1과 같은 코드는 1이 무엇을 의미하는지 즉시 파악하기 어렵습니다.
  • 타입 불안정성: status 변수에 약속되지 않은 값(예: 2 또는 -1)이 할당되는 것을 컴파일러가 막을 수 없어, 런타임에 예기치 않은 버그를 유발할 수 있습니다.

열거형 타입은 이러한 문제를 해결하기 위해, 가능한 모든 값을 의미 있는 이름으로 나열하여 새로운 타입을 정의합니다. 컴파일러는 해당 타입의 변수에 열거된 값들만 대입될 수 있도록 강제하여 타입 안전성을 보장합니다.

4.3.2 선언 및 사용

열거형 타입은 type 키워드를 사용하여 선언하며, 괄호 안에 식별자(identifier) 또는 문자 리터럴(character literal)로 구성된 열거형 리터럴(enumeration literal) 목록을 나열하여 정의합니다.

선언 구문

열거형 타입의 선언은 다음과 같은 구문 구조를 가집니다:

type 식별자 is (열거형_리터럴_명세 {, 열거형_리터럴_명세});

열거형_리터럴_명세식별자 또는 '문자_리터럴' 중 하나가 될 수 있습니다.

열거형 타입을 선언하고 사용하는 기본적인 방법은 다음과 같습니다.

기본 선언

가장 일반적인 형태의 열거형 선언은 식별자 목록을 사용하는 것입니다.

package Days_Of_Week is
   type Day is (Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday);
end Days_Of_Week;

위 예제에서 Day는 열거형 타입의 이름이며, Monday부터 Sunday까지는 해당 타입이 가질 수 있는 값을 나타내는 열거형 리터럴입니다.

문자 리터럴을 이용한 선언

열거형 리터럴로 문자 리터럴을 사용할 수도 있습니다. 이는 주로 문자 집합을 표현할 때 유용합니다.

type Roman_Digit is ('I', 'V', 'X', 'L', 'C', 'D', 'M');

변수 선언 및 값 할당

선언된 열거형 타입의 변수를 생성하고 값을 할당할 수 있습니다.

with Days_Of_Week;
procedure Schedule_Meeting is
   Today    : Days_Of_Week.Day := Days_Of_Week.Wednesday;
   Next_Day : Days_Of_Week.Day;
begin
   if Today = Days_Of_Week.Friday then
      Next_Day := Days_Of_Week.Monday;
   else
      Next_Day := Days_Of_Week.Day'Succ (Today); -- 'Succ 속성을 사용하여 다음 날짜를 구함
   end if;
end Schedule_Meeting;

서브타입 선언

정수 타입과 마찬가지로, 열거형 타입에 대해서도 range를 사용하여 서브타입을 선언할 수 있습니다. 이는 특정 범위의 값만을 유효하게 다루고자 할 때 유용합니다.

package Days_Of_Week is
   type Day is (Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday);
   subtype Weekday is Day range Monday .. Friday;
end Days_Of_Week;

중복된 열거형 리터럴 (Overloaded Enumeration Literals)

서로 다른 열거형 타입에서 동일한 이름의 리터럴을 사용할 수 있습니다. 이를 ‘중복(overloading)’이라 합니다.

type Light_Color is (Red, Amber, Green);
type Traffic_Light is (Red, Yellow, Green);

이 경우, RedGreen과 같은 리터럴을 사용할 때 컴파일러가 타입을 명확히 구분할 수 있도록 문맥을 제공해야 합니다. 모호성이 발생할 경우, 타입 한정(qualification)을 사용하여 명시적으로 타입을 지정해야 합니다.

My_Light : Light_Color := Light_Color'(Red);

4.3.3 열거형 타입의 속성

Ada는 열거형 타입에 대한 유용한 정보를 얻을 수 있는 여러 속성(Attribute)을 제공합니다. 주요 속성은 다음과 같습니다.

  • 'first'last: 타입 선언에서 첫 번째와 마지막 값을 반환합니다.

    • Fork_Status'firstParent를 반환합니다.
    • Fork_Status'lastChild를 반환합니다.
  • 'Succ'Pred: 주어진 값의 다음(Successor) 또는 이전(Predecessor) 값을 반환합니다.

    • Fork_Status'Succ (Parent)Child를 반환합니다.
    • Fork_Status'Pred (Child)Parent를 반환합니다.
  • 'Pos'Val: 열거형 값과 그 내부적인 정수 위치(0부터 시작) 사이의 변환을 수행합니다.

    • Fork_Status'Pos (Parent)0을 반환합니다.
    • Fork_Status'Pos (Child)1을 반환합니다.
    • Fork_Status'Val (0)Parent를 반환합니다.

이러한 속성들은 열거형 타입의 모든 값을 순회하며 작업을 수행하거나, 정수 기반의 외부 라이브러리와 데이터를 주고받을 때 유용하게 사용됩니다.

4.3.4 [심화] 열거형 타입의 물리적 표현

열거형 타입의 리터럴(literal)은 Idle, Running과 같은 이름으로 코드에 표현되지만, 컴퓨터 메모리에는 정수 값으로 저장됩니다. Ada 컴파일러는 각 열거형 리터럴에 고유한 숫자 코드를 할당하여 이를 구현합니다.

기본 숫자 매핑

프로그래머가 별도로 지정하지 않으면, 컴파일러는 각 리터럴에 0부터 시작하여 1씩 증가하는 정수 값을 순차적으로 할당합니다. 'Pos 속성은 바로 이 내부적인 정수 값을 반환합니다.

  • type Status is (Idle, Running, Halted);
    • Status'Pos(Idle) -> 0
    • Status'Pos(Running) -> 1
    • Status'Pos(Halted) -> 2

열거형 타입을 저장하는 데 필요한 메모리 크기('Size 속성)는 모든 리터럴을 표현할 수 있는 가장 작은 비트 수에 의해 결정됩니다. 예를 들어, 256개의 리터럴을 가진 열거형은 8비트(1바이트)로 충분히 표현할 수 있습니다.

표현 명세를 이용한 직접 매핑

저수준 프로그래밍에서는 하드웨어 레지스터의 특정 값이나 외부 데이터 형식과 열거형 리터럴을 정확히 일치시켜야 할 필요가 있습니다. 이때 표현 명세(representation clause)를 사용하여 각 리터럴에 대응하는 정수 값을 직접 지정할 수 있습니다.

이는 20.2절에서 더 자세히 다룰 내용이지만, 기본적인 구문은 다음과 같습니다.

코드 예시 4-5: 열거형 표현 명세

with Ada.Text_IO;

procedure enum_representation_example is
  -- 장치 제어 레지스터의 상태를 나타내는 열거형
  type Device_Status is (Off, Standby, Active, Error);

  -- 각 상태가 하드웨어 명세서에 정의된 특정 값에 대응하도록 강제
  for Device_Status use (Off     => 0,
                         Standby => 1,
                         Active  => 4,
                         Error   => 255);
begin
  Ada.Text_IO.put_line ("'Active' 상태의 내부 값: " & Device_Status'Pos (Active)'image);
  Ada.Text_IO.put_line ("'Error' 상태의 내부 값: " & Device_Status'Pos (Error)'image);
  Ada.Text_IO.put_line ("Device_Status 타입의 크기: " & Device_Status'Size'image & " bits");
end enum_representation_example;

실행 결과:

'Active' 상태의 내부 값:  4
'Error' 상태의 내부 값:  255
Device_Status 타입의 크기:  8 bits

분석:

  • Active 리터럴의 내부 값은 기본값인 2 대신, 우리가 지정한 4가 되었습니다.
  • Error 상태는 255에 매핑되었습니다.
  • 가장 큰 내부 값이 255이므로, 컴파일러는 이 타입을 저장하기 위해 최소 8비트가 필요하다고 결정했습니다.

이처럼 열거형의 물리적 표현을 직접 제어하는 기능은 Ada가 고수준의 추상화와 저수준의 하드웨어 제어 능력을 모두 갖추고 있음을 보여주는 강력한 예시입니다.

4.4 Boolean 타입

컴퓨터 과학의 논리적 기초를 이루는 Boolean 타입은 조건문, 반복문 등 프로그램의 실행 흐름을 제어하는 데 있어 가장 핵심적인 스칼라 타입입니다. Ada에서 Boolean은 단순히 참/거짓을 나타내는 키워드가 아니라, 언어에 미리 정의된 열거형 타입(predefined enumeration type)입니다.

Boolean 타입의 내부적인 정의는 다음과 같습니다.

type Boolean is (False, True);

이 정의에 따라, Boolean은 오직 FalseTrue라는 두 가지 값만 가질 수 있으며, 열거형 타입의 규칙에 따라 값 사이에는 명확한 순서(False < True)가 존재합니다.

선언 및 활용

Boolean 변수는 주로 관계 연산이나 함수 호출의 결과를 저장하여 프로그램의 상태를 나타내는 플래그(flag)로 사용됩니다.

with Ada.Text_IO; use Ada.Text_IO;

procedure boolean_logic_example is
   score           : constant Integer := 85;
   pass_mark       : constant Integer := 60;
   is_data_valid   : Boolean := True;
   has_passed      : Boolean;
begin
   -- 표현식의 결과(True 또는 False)가 Boolean 변수에 저장됨
   has_passed := (score >= pass_mark);

   -- if 문의 조건으로 사용
   if is_data_valid and has_passed then
      put_line ("유효한 데이터, 시험 통과!");
   end if;
end boolean_logic_example;

Boolean 타입은 모든 관계 연산자(=, /=, <, >, <=, >=)와 멤버십 연산자(in, not in)의 결과 타입입니다. 또한, 논리 연산자 and, or, not과 단축 평가(short-circuit)를 수행하는 and then, or else의 피연산자이자 결과 타입이기도 합니다.

이처럼 Boolean은 프로그램의 논리적 흐름을 구성하는 모든 곳에 사용되므로, 그 역할을 명확히 이해하는 것이 중요합니다. Boolean 값을 다루는 다양한 연산자에 대해서는 6장(표현식과 연산자)에서 더 자세히 학습합니다.

4.5 Character 타입

컴퓨터 프로그램에서 개별 문자를 표현하고 처리하는 기능은 매우 근본적입니다. Ada에서는 이를 위해 Character 라는 미리 정의된 열거형 타입을 제공합니다.

Character 타입은 단순히 8비트의 메모리 공간이 아니라, ISO-8859-1 (Latin-1) 표준에 정의된 256개의 문자에 각각 의미 있는 이름이 부여된 순서 있는 집합입니다. 이 표준은 널리 알려진 ASCII 문자 집합을 완전히 포함하면서, 서유럽 언어에서 사용되는 추가적인 특수 문자들을 포함하도록 확장한 것입니다.

'A', 'z', '?' 와 같은 문자 리터럴(literal)은 바로 이 Character 타입에 속하는 값입니다. Character 타입이 열거형 타입의 일종이므로, 이전 절에서 배운 'Pos, 'Val, 'Succ와 같은 속성들을 모두 동일하게 활용하여 문자의 순서를 확인하거나 내부 코드 값을 다룰 수 있습니다.

또한, 현대 소프트웨어는 전 세계의 다양한 언어를 지원해야 할 필요성이 증가함에 따라, Ada는 Latin-1 문자 집합을 넘어서는 국제 표준을 지원하기 위한 확장된 문자 타입들도 함께 제공합니다. 이어지는 절에서는 이러한 문자 타입들의 종류와 활용법에 대해 자세히 알아보겠습니다.

4.5.1 문자 집합의 종류

Ada는 현대 소프트웨어가 요구하는 다양한 문자 표현의 필요성을 충족시키기 위해, 여러 크기와 표준을 지원하는 문자 타입들을 제공합니다. 프로그래머는 응용 프로그램의 요구사항에 가장 적합한 타입을 선택하여 사용할 수 있습니다.

Character

  • 크기: 8비트
  • 표준: ISO/IEC 8859-1 (Latin-1)

Character는 Ada의 가장 기본적인 문자 타입입니다. 이는 널리 사용되는 7비트 ASCII 문자 집합을 완전히 포함하며, 여기에 서유럽 언어에서 사용되는 악센트 부호나 특수 기호들을 추가하여 8비트로 확장한 것입니다. 일반적인 영문 텍스트나 간단한 기호를 다루는 데에는 Character 타입만으로도 충분합니다.

Wide_Character

  • 크기: 16비트
  • 표준: ISO/IEC 10646 BMP (Basic Multilingual Plane)

Wide_Character는 전 세계의 주요 현대 언어 대부분을 표현할 수 있는 유니코드의 기본 다국어 평면(BMP)을 지원하기 위해 설계되었습니다. 한글, 한자, 일본어 등 비-라틴 문자를 포함하는 다국어 응용 프로그램을 개발할 때 필수적입니다. 이 타입은 65,536개의 문자를 표현할 수 있습니다.

Wide_Wide_Character

  • 크기: 32비트
  • 표준: ISO/IEC 10646 (전체 유니코드)

Wide_Wide_Character는 현존하는 거의 모든 문자와 기호, 심지어 고대 문자나 이모티콘(emoji)까지 포함하는 전체 유니코드 문자 집합을 표현하기 위한 타입입니다. 32비트의 넓은 공간을 사용하여 유니코드의 모든 영역에 접근할 수 있으므로, 최고 수준의 국제화 지원이 필요한 응용 프로그램에 사용됩니다.

이러한 확장된 문자 타입들도 Character와 마찬가지로 열거형 타입의 특성을 가지며, 관련된 속성과 연산을 동일하게 사용할 수 있습니다.

4.5.2 문자의 표현과 속성

Character 타입과 그 확장 타입들은 본질적으로 열거형 타입이므로, 각 문자 리터럴은 내부적으로 위치 번호(position number)라고 불리는 고유한 정수 코드 값에 대응됩니다. 이 코드 값은 ISO/IEC 표준(예: Character의 경우 Latin-1)에 의해 정의됩니다.

Ada는 문자와 이 내부 코드 값 사이를 안전하고 명확하게 변환할 수 있도록 'pos'val'이라는 두 가지 핵심 속성을 제공합니다.

  • 'Pos: 문자 값을 받아 그에 해당하는 정수 코드 값을 반환합니다.
  • 'val: 정수 코드 값을 받아 그에 해당하는 문자 값을 반환합니다.

이 두 속성은 서로 완벽한 역함수 관계에 있습니다.

코드 예제: 'pos'val 활용

아래 예제는 대문자 'A'와 그에 해당하는 Latin-1 코드 값 65 사이를 변환하는 과정을 보여줍니다.

with Ada.Text_IO; use Ada.Text_IO;

procedure Main is
  char_a   : constant Character := 'A';
  code_val : constant Integer   := Character'Pos (char_a); -- 'A'의 코드 값(65)을 얻음

  new_char : constant Character := Character'Val (65);     -- 코드 값 65로부터 문자('A')를 얻음
begin
  Put_Line ("Character'Pos ('A') is: " & Integer'Image (code_val));
  Put_Line ("Character'Val (65) is: " & new_char);

  -- 'Val에 유효하지 않은 코드 값을 넣으면 Constraint_Error 예외가 발생합니다.
  -- 예를 들어, Latin-1에는 256 이상의 코드 값이 없습니다.
end Main;

실행 결과:

Character'Pos ('A') is:  65
Character'Val (65) is: A

이처럼 문자의 내부 표현은 정수이지만, 코드의 명확성과 안정성을 위해 My_Char + 1과 같이 직접적인 정수 연산을 수행하기보다는 'Succ, 'Pred 또는 'Pos, 'Val과 같은 속성을 활용하는 것이 바람직합니다. 'Val 속성에 해당 문자 집합의 범위를 벗어나는 정수 값을 전달하면 Constraint_Error 예외가 발생하므로, 사용 전 값의 유효성을 검증하는 것이 안전합니다.

4.5.3 문자의 연산과 활용

Character 타입은 순서가 정의된 이산 타입이므로, 코드의 논리를 구성하는 데 유용한 다양한 연산을 지원합니다. 특히 관계 연산자를 이용한 비교나, 표준 라이브러리 패키지를 활용한 문자 처리는 실용적인 프로그래밍에서 매우 중요합니다.

관계 연산과 멤버십 테스트

내부 코드 값을 기준으로 문자의 순서가 정해져 있기 때문에, 관계 연산자(<, >, <=, >=)를 사용하여 두 문자를 비교할 수 있습니다. 또한, in 연산자를 사용하여 특정 문자가 주어진 범위에 속하는지 쉽게 확인할 수 있습니다.

with Ada.Text_IO; use Ada.Text_IO;

procedure Main is
  my_char : constant Character := 'g';
begin
  if my_char >= 'a' and my_char <= 'z' then
    Put_Line ("소문자입니다.");
  end if;

  -- 'in' 연산자를 사용하면 더 명확하고 간결합니다.
  if my_char in 'a' .. 'z' then
    Put_Line ("소문자입니다. ('in' 연산자 확인)");
  end if;
end Main;

표준 라이브러리 활용: Ada.Characters.Handling

단순 비교를 넘어서 대소문자 변환이나 문자의 종류(숫자, 글자, 특수기호 등)를 판별하는 기능은 직접 구현하기 번거롭습니다. Ada는 이러한 표준적인 문자 처리 기능들을 Ada.Characters.Handling 이라는 내장 패키지에 모아 제공합니다. 이 패키지를 활용하면 코드의 안정성과 이식성을 크게 높일 수 있습니다.

  • to_upper (c): 소문자를 대문자로 변환합니다.
  • to_lower (c): 대문자를 소문자로 변환합니다.
  • is_letter (c): 해당 문자가 알파벳 글자인지 확인합니다.
  • is_digit (c): 해당 문자가 0-9 사이의 숫자인지 확인합니다.
  • is_alphanumeric (c): 글자 또는 숫자인지 확인합니다.

아래 코드는 이 패키지의 활용법을 보여줍니다.

with Ada.Text_IO;         use Ada.Text_IO;
with Ada.Characters.Handling; use Ada.Characters.Handling;

procedure Main is
  char1 : constant Character := 'f';
  char2 : constant Character := '7';
begin
  Put_Line ("'f'를 대문자로: " & To_Upper (char1));

  if Is_Letter (char1) then
    Put_Line ("'f'는 글자입니다.");
  end if;

  if Is_Digit (char2) then
    Put_Line ("'7'은 숫자입니다.");
  end if;
end Main;

실행 결과:

'f'를 대문자로: F
'f'는 글자입니다.
'7'은 숫자입니다.

이처럼 실용적인 프로그램을 작성할 때는 문자를 직접 비교하기보다는 Ada.Characters.Handling 패키지에서 제공하는 함수들을 우선적으로 사용하는 것이 바람직합니다.

4.6 [정리] 스칼라 타입의 공통 속성 (12개)

앞선 절들에서 Ada가 제공하는 다양한 스칼라 타입(정수, 열거형, 실수 등)의 개별적인 특성과 선언 방법에 대해 학습했습니다. 이제부터는 이 모든 타입들을 더 효과적이고 안전하게 다룰 수 있게 해주는 공통된 도구속성(Attribute) 들을 종합적으로 정리하고 살펴보겠습니다.

속성은 타입이나 객체에 대한 추가 정보를 얻거나 특정 연산을 수행하기 위해 Ada가 언어 차원에서 기본으로 제공하는 내장 기능입니다. 이 기능은 아포스트로피(') 기호를 사용하여 호출하며, 코드의 유연성과 안정성을 극대화하는 데 핵심적인 역할을 합니다.

이전 예제에서 'first'range와 같은 일부 속성들이 잠시 소개되었지만, 이번 절에서는 스칼라 타입을 다룰 때 알아야 할 12개의 주요 공통 속성 전체를 한곳에 모아 체계적으로 설명합니다. 이어지는 하위 절들에서는 각 속성의 정확한 의미와 사용법, 그리고 적용될 수 있는 타입의 범위를 명확히 구분하여 다룰 것입니다.

4.6.1 범위 및 유효성 속성 ('first, 'last, 'range, 'valid)

4.1.2절에서 서브타입의 개념과 함께 처음 소개된 범위 및 유효성 속성은, 스칼라 타입을 다루는 데 있어 가장 기본이 되는 도구입니다. 이 속성들은 타입이 가질 수 있는 값의 경계를 명확히 정의하고, 그 경계를 넘어서는 값을 안전하게 검사하는 역할을 합니다. 이번 절에서는 이 네 가지 핵심 속성을 종합적으로 다시 정리하고 그 활용법을 구체화합니다.

'first'last

  • S'first: 스칼라 서브타입 S가 가질 수 있는 최솟값(lower bound)을 반환합니다.
  • S'last: 스칼라 서브타입 S가 가질 수 있는 최댓값(upper bound)을 반환합니다.

이 두 속성은 특정 숫자 리터럴을 직접 사용하는 ‘하드 코딩’을 피하게 해줌으로써, 타입의 범위 정의가 변경되더라도 코드가 영향을 받지 않도록 합니다.

-- 0점에서 100점 사이의 점수를 나타내는 서브타입
subtype Score is Integer range 0 .. 100;

min_score : constant Integer := Score'first; -- 0을 반환
max_score : constant Integer := Score'last;  -- 100을 반환

'range

  • S'range: 'first .. 'last'와 완전히 동일한 의미를 갖는 범위(range)를 나타냅니다.

이 속성은 for 루프 구문이나 배열의 인덱스를 선언할 때 코드를 매우 간결하고 명확하게 만들어 줍니다.

-- Score 서브타입의 모든 범위를 순회하는 루프
for i in Score'range loop
  -- i는 0부터 100까지 순서대로 반복됩니다.
  null;
end loop;

'Valid

  • S'Valid(X): 값 X가 서브타입 S의 제약 조건을 만족하는지 여부를 검사하여 Boolean 값(True 또는 False)을 반환합니다.

'Valid 속성은 Constraint_Error 예외를 발생시키지 않고 값의 유효성을 안전하게 사전 검사할 수 있는 핵심적인 도구입니다. 외부 입력이나 파일로부터 데이터를 읽어와 특정 서브타입의 변수에 할당하기 전에 사용하는 것이 일반적입니다.

procedure Check_Input (user_input : in Integer) is
  final_score : Score;
begin
  if Score'Valid (user_input) then
    final_score := user_input;
    -- 유효한 값이므로 할당 및 처리
  else
    -- 유효하지 않은 값이므로 오류 처리
    null;
  end if;
end Check_Input;

이처럼 범위 및 유효성 속성은 Ada가 타입의 경계를 엄격하게 검사하는 정적 언어로서의 장점을 최대한 활용할 수 있도록 돕는 필수적인 기능입니다.

4.6.2 이산 타입의 순서 및 위치 속성 ('succ, 'pred, 'pos, 'val)

이산 타입(정수, 열거형 등)은 값이 명확한 순서를 가진다는 본질적인 특성을 가집니다. Ada는 이러한 순서 관계를 활용하고, 값의 내부적인 위치를 다룰 수 있는 네 가지 핵심 속성을 제공합니다.

'Succ'Pred

  • S'Succ(X): 이산 타입 S의 값 X 바로 다음 순서의 값(successor)을 반환합니다.
  • S'Pred(X): 이산 타입 S의 값 X 바로 이전 순서의 값(predecessor)을 반환합니다.

이 속성들은 S'Last에 대해 'Succ를 호출하거나 S'First에 대해 'Pred를 호출하면 Constraint_Error 예외를 발생시키므로 주의해야 합니다.

type Day is (Mon, Tue, Wed, Thu, Fri, Sat, Sun);

today    : constant Day := Tue;
tomorrow : constant Day := Day'Succ (today); -- Wed 를 반환
yesterday: constant Day := Day'Pred (today); -- Mon 을 반환

'Pos'Val

  • S'Pos(X): 이산 타입 S의 값 X에 해당하는 위치 번호(position number)를 Integer 타입으로 반환합니다. 열거형의 경우, 첫 번째 값의 위치 번호는 0입니다.
  • S'Val(N): Integer 타입의 위치 번호 N을 받아, 그 위치에 해당하는 이산 타입 S의 값을 반환합니다.

이 두 속성은 서로 완벽한 역함수 관계에 있으며, 이산 타입의 값을 정수와 상호 변환해야 할 때 매우 유용합니다.

type Status is (Idle, Running, Halted);

-- 'Pos: 값 -> 위치 번호
pos_of_running : constant Integer := Status'Pos (Running); -- 1 을 반환

-- 'Val: 위치 번호 -> 값
val_of_zero : constant Status := Status'Val (0);         -- Idle 을 반환

'Val 속성에 해당 타입의 유효한 위치 번호 범위를 벗어나는 정수 값을 전달하면 Constraint_Error 예외가 발생합니다. 예를 들어, Status'Val(3)은 예외를 발생시킵니다. 이러한 속성들은 이산 타입의 값을 단순히 순회하는 것을 넘어, 값의 순서와 위치를 이용한 정교한 로직을 구현할 수 있게 해주는 강력한 도구입니다.

4.6.3 문자열 변환 속성 ('Image, 'Value, 'Width)

프로그램의 실행 결과를 확인하거나, 파일 및 사용자로부터 입력을 받아 처리하기 위해서는 스칼라 타입의 값을 사람이 읽을 수 있는 문자열(String)로 변환하거나 그 반대로 변환하는 기능이 필수적입니다. Ada는 모든 스칼라 타입에 대해 이러한 문자열 변환을 지원하는 세 가지 유용한 속성을 제공합니다.

'image'value

  • S'Image(X): 스칼라 타입 S의 값 X를 사람이 읽을 수 있는 형식의 String으로 변환하여 반환합니다. 이 속성은 디버깅이나 실행 상태를 로그로 남길 때 매우 유용합니다.
  • S'Value(Str): 문자열 Str을 해석하여 그에 해당하는 스칼라 타입 S의 값을 반환합니다. 만약 주어진 문자열이 해당 타입으로 변환될 수 없는 유효하지 않은 형식이라면 Constraint_Error 예외를 발생시킵니다.

'Image'Value는 서로 역함수 관계처럼 동작하며, 프로그램과 외부 세계 간의 데이터 교환에 핵심적인 역할을 합니다.

'Width

  • S'Width: 서브타입 S의 모든 값에 대해 'Image 속성을 적용했을 때 생성될 수 있는 문자열의 최대 길이Integer 타입으로 반환합니다.

이 속성은 여러 값을 일정한 형식에 맞춰 깔끔하게 출력해야 할 때, 필요한 출력 공간을 계산하는 데 유용하게 사용될 수 있습니다.

코드 예제: 속성 활용

아래 예제는 정수 서브타입에 세 가지 속성을 적용하는 과정을 보여줍니다.

with Ada.Text_IO; use Ada.Text_IO;

procedure Main is
  subtype Small_Int is Integer range -99 .. 99;

  my_val      : constant Small_Int := 42;
  str_image   : constant String    := Small_Int'Image (my_val); -- " 42" 와 같은 문자열을 반환

  new_val     : constant Small_Int := Small_Int'Value ("-10"); -- -10 값을 반환

  max_width   : constant Integer   := Small_Int'Width; -- 정수 부호와 자릿수를 고려한 최대 길이(예: 3)
begin
  Put_Line ("'Image of 42: " & str_image);
  Put_Line ("'Value of ""-10"": " & Integer'Image (new_val));
  Put_Line ("'Width of Small_Int: " & Integer'Image (max_width));
end Main;

실행 결과:

'Image of 42:  42
'Value of "-10": -10
'Width of Small_Int:  3

'Image는 가독성을 위해 값의 앞에 공백을 추가할 수 있으며, 'Width는 이러한 공백이나 부호(-)까지 모두 포함한 최대 길이를 계산합니다. 이 속성들은 모든 스칼라 타입에 적용되므로, 열거형이나 실수 타입의 값을 문자열로 다룰 때도 동일한 방식으로 활용할 수 있습니다.

4.6.4 [심화] 기저 타입 속성: 'base

'Base 속성은 서브타입(subtype)이 파생된 기저 타입(Base Type) 그 자체를 지칭하는 매우 강력하고 특별한 속성입니다.

일반적인 속성인 'First, 'Last, 'Range가 서브타입에 명시적으로 지정된 제약(constraint) 내에서 동작하는 반면, 'Base는 이러한 제약을 일시적으로 무시하고 서브타입의 근원이 되는 원본 타입의 특성에 접근할 수 있게 해줍니다.

S'BaseS라는 서브타입 그 자체를 반환하는 것이 아니라, S의 기저 타입에 대한 “익명의(anonymous)” 서브타입을 나타냅니다. 이 익명의 서브타입은 제약이 없으므로, 기저 타입의 모든 값을 포함합니다.

아래 코드를 통해 그 차이를 명확히 알 수 있습니다.

package body Main is
  -- -100 부터 100 까지의 범위를 갖는 정수 서브타입을 선언합니다.
  subtype Hundred is Integer range -100 .. 100;
begin
  -- 일반 속성은 'Hundred' 서브타입의 제약 내에서 동작합니다.
  -- Hundred'First  는 -100 을 반환합니다.
  -- Hundred'Last   는  100 을 반환합니다.

  -- 'Base 속성은 기저 타입인 'Integer'의 특성에 접근합니다.
  -- Hundred'Base'First 는 Integer'First 를 반환합니다. (컴파일러에 따라 매우 작은 음수)
  -- Hundred'Base'Last  는 Integer'Last  를 반환합니다. (컴파일러에 따라 매우 큰 양수)
  null;
end Main;

이처럼 'Base는 서브타입의 제약된 범위를 넘어서는 연산이 필요하거나, 타입의 근본적인 표현을 다뤄야 하는 저수준 프로그래밍 또는 고도의 제네릭(Generic) 코드를 작성할 때 필수적으로 사용되는 심화 기능입니다. 일반적인 응용 프로그램에서는 자주 사용되지 않지만, Ada 타입 시스템의 유연성과 깊이를 보여주는 중요한 예시입니다.

4.7 [심화] 범용 타입과 이름 있는 숫자

Ada의 정적 타입 시스템은 타입 간의 호환성을 컴파일 시점에 엄격하게 검사합니다. 이러한 환경에서 10이나 3.14와 같은 숫자 리터럴(literal)의 타입 소속은 중요한 의미를 가집니다. 특정 타입에 귀속되지 않은 이 리터럴들이 어떻게 다양한 숫자 타입과 오류 없이 연산될 수 있는지 이해하는 것은 Ada의 타입 이론을 파악하는 데 있어 필수적입니다.

Ada는 이 문제를 해결하기 위해 범용 타입(universal type)이라는 추상적 개념을 사용합니다. 범용 타입은 변수나 객체가 가질 수 있는 구체적인(concrete) 타입이 아니라, 컴파일러가 표현식의 문맥에 따라 최종 타입을 결정하기 전까지 숫자 리터럴을 표현하는, 이론상 무한한 정밀도를 가진 엔티티(entity)입니다.

이러한 범용 타입의 개념은 타입을 명시하지 않고 선언된 이름 있는 숫자(named number)에도 동일하게 적용되어, 코드의 유연성과 수치적 정확성을 보장하는 핵심적인 역할을 합니다.

본 절에서는 universal_integeruniversal_real의 원리를 탐구하고, 이 개념이 어떻게 이름 있는 숫자로 확장되어 사용되는지 학습합니다.

4.7.1 universal_integeruniversal_real

Ada의 숫자 리터럴(literal)은 선언되는 즉시 IntegerFloat과 같은 특정 타입에 귀속되지 않습니다. 대신, 이들은 컴파일러가 최종 타입을 결정하기 전까지 이론상 무한한 정밀도를 가지는 범용 타입(universal type)에 속하게 됩니다.

universal_integer

모든 정수 리터럴, 예를 들어 10, 0, -127 등은 universal_integer 타입입니다. 이 타입은 특정 기계의 워드 크기(Integer는 32비트, Long_Integer는 64비트 등)에 제약받지 않는, 수학적으로 완벽한 정수를 나타냅니다.

컴파일러는 해당 리터럴이 사용되는 문맥을 분석하여 가장 적합한 실제 정수 타입으로 변환합니다. 이를 통해 하나의 리터럴이 다양한 정수 타입과 유연하게 연산될 수 있습니다.

procedure Main is
   type Day_Count is range 0 .. 366;

   days_in_year : constant Day_Count := 365; -- 365(universal_integer)가 Day_Count 타입으로 결정됨
   items_per_box: constant Integer   := 12;  -- 12(universal_integer)가 Integer 타입으로 결정됨
begin
   null;
end Main;

36512라는 리터럴은 처음에는 universal_integer 타입이지만, 각각 Day_CountInteger 타입의 상수에 할당되는 시점에서 해당 타입으로 변환됩니다.

universal_real

모든 실수 리터럴, 예를 들어 3.14, 1.0E-5 등은 universal_real 타입입니다. universal_integer와 마찬가지로, 이 타입은 특정 부동소수점 형식의 정밀도 한계에 얽매이지 않는, 이론상 무한한 정밀도를 가진 실수를 표현합니다.

이러한 특성은 특히 높은 정밀도가 요구되는 과학 및 공학용 상수를 정의할 때 매우 유용합니다. 상수를 universal_real로 정의하면, 그 값이 실제 변수에 할당되거나 연산에 사용되는 시점까지 정밀도 손실이 전혀 발생하지 않습니다.

procedure Main is
   pi_approx_float : constant Float := 3.14159; -- Float의 정밀도로 저장됨
   pi_universal    : constant      := 3.14159_26535_89793; -- universal_real, 최대 정밀도 유지

   circle_area_float     : Float;
   circle_area_long_float: Long_Float;

   radius : constant Float := 10.0;
begin
   -- pi_universal이 사용되는 문맥에 맞게 각각 Float과 Long_Float으로 변환됨
   circle_area_float      := pi_universal * (radius ** 2);
   circle_area_long_float := pi_universal * (Long_Float (radius) ** 2);
   null;
end Main;

pi_universal 상수는 타입을 명시하지 않았으므로 universal_real로 남아있습니다. 이 상수가 Float 타입과의 연산에 사용될 때는 Float의 정밀도로 변환되고, Long_Float과의 연산에서는 더 높은 Long_Float의 정밀도로 변환되어, 정밀도 손실을 최소화합니다. 이처럼 범용 타입은 Ada의 엄격한 타입 시스템 내에서 수치 계산의 유연성과 정확성을 보장하는 핵심적인 메커니즘입니다.

4.7.2 타입이 없는 상수: 이름 있는 숫자

범용 타입의 개념은 이름 있는 숫자(named number) 라는 Ada의 강력한 기능으로 확장됩니다. 이름 있는 숫자는 constant로 선언되지만, 타입을 명시적으로 지정하지 않은 상수를 의미합니다.

이렇게 선언된 상수는 특정 타입에 묶이지 않고, 그 값이 나타내는 숫자 리터럴과 동일하게 universal_integer 또는 universal_real 타입으로 취급됩니다.


선언 및 특징

이름 있는 숫자는 타입 선언 없이 constant := 구문을 사용하여 정의합니다.

PI       : constant := 3.14159_26535; -- universal_real 타입의 이름 있는 숫자
MAX_SIZE : constant := 1024;          -- universal_integer 타입의 이름 있는 숫자

이러한 이름 있는 숫자의 가장 큰 특징은 유연성정밀도입니다.

  1. 유연성: 특정 타입에 고정되어 있지 않으므로, 컴파일러는 이름 있는 숫자가 사용되는 문맥을 파악하여 가장 적합한 실제 타입으로 자동 변환합니다. 이 덕분에 불필요한 타입 변환 코드를 줄일 수 있습니다.

  2. 정밀도: 값이 실제 변수에 할당되거나 연산에 사용되는 마지막 순간까지 이론상 최대 정밀도를 유지합니다. 이는 특히 정밀한 과학 기술용 상수를 정의할 때 그 값을 손실 없이 보존하는 데 매우 중요합니다.

코드 예제: 이름 있는 숫자의 활용

아래 코드는 universal_real 타입의 이름 있는 숫자인 pi가 어떻게 서로 다른 실수 타입(Float, Long_Float)과 타입 변환 없이 자연스럽게 연산되는지를 보여줍니다.

with Ada.Text_IO; use Ada.Text_IO;

procedure Main is
   pi : constant := 3.14159_26535; -- 이름 있는 숫자 (universal_real)

   -- 타입이 명시된 일반적인 상수
   gravity : constant Float := 9.8;

   radius_f : Float      := 1.0;
   radius_lf: Long_Float := 1.0;

   area_f : Float;
   area_lf: Long_Float;
begin
   -- 'pi'는 문맥에 맞게 각각 Float과 Long_Float으로 자동 변환됨
   area_f  := pi * (radius_f ** 2);
   area_lf := pi * (radius_lf ** 2);

   -- 'gravity'는 Float 타입이므로 다른 타입과 연산 시 명시적 변환이 필요함
   -- area_lf := gravity * (radius_lf ** 2); -- 컴파일 오류 발생!
   area_lf := Float (gravity) * (radius_lf ** 2); -- 올바른 사용법

   Put_Line ("Float Area: " & Float'Image (area_f));
end Main;

반면, Float 타입으로 명시된 상수 gravityLong_Float 타입의 변수와 직접 연산할 수 없어 컴파일 오류를 발생시킵니다. 이처럼 이름 있는 숫자는 Ada의 엄격한 타입 검사 규칙을 위반하지 않으면서도, 코드의 유연성과 수치적 정확성을 크게 향상시키는 정교한 메커니즘을 제공합니다.

4.8 스칼라 타입 간의 형변환

Ada의 엄격한 타입 시스템은 서로 다른 타입 간의 데이터 할당을 원칙적으로 허용하지 않습니다. 예를 들어, Integer 타입의 값을 Float 타입의 변수에 직접 할당할 수 없으며, 서로 다른 범위로 정의된 정수 타입끼리도 호환되지 않습니다.

하지만 실제 프로그래밍에서는 정수형으로 계산된 결과를 실수형 변수에 저장하거나, 서로 다른 종류의 타입을 연산해야 하는 경우가 필연적으로 발생합니다.

이러한 경우를 위해 Ada는 프로그래머의 의도를 명확히 드러내는 명시적 형변환(explicit type conversion)을 허용합니다. 형변환은 Target_Type_Name (Expression)과 같은 함수 호출 형태를 사용하며, 이는 ‘Expression의 값을 Target_Type_Name으로 변환하라’는 명시적인 명령입니다.

본 절에서는 이러한 형변환의 정확한 사용법과, 변환 과정에서 발생할 수 있는 잠재적인 런타임 오류 및 주의사항에 대해 학습합니다.

4.8.1 명시적 형변환

Ada에서 서로 다른 두 타입 간에 값을 변환하기 위해서는, 프로그래머가 변환의 의도를 컴파일러에게 명확히 알려주어야 합니다. 이러한 행위를 명시적 형변환(explicit type conversion)이라고 하며, 변환하고자 하는 목표 타입의 이름을 함수처럼 사용하는 구문을 통해 이루어집니다.

기본 문법

Target_Type_Name (Expression)

이 구문은 Expression의 값을 Target_Type_Name으로 변환하라는 명시적인 명령입니다. 변환은 단순히 비트 패턴을 재해석하는 것이 아니라, Expression논리적인 값Target_Type_Name의 값으로 변환하려고 시도합니다.

숫자 타입 간의 변환

가장 흔한 형변환은 서로 다른 숫자 타입 간의 변환입니다. 예를 들어, 정수 타입의 값을 실수 타입으로 변환하여 나눗셈 연산을 수행할 수 있습니다.

with Ada.Text_IO; use Ada.Text_IO;

procedure Main is
   item_count : constant Integer := 45;
   total_count: constant Integer := 100;
   percentage : Float;
begin
   -- Integer 끼리의 나눗셈은 정수 결과를 반환하므로 (45 / 100 = 0),
   -- 정확한 백분율을 얻기 위해 Float으로 형변환이 필요합니다.
   percentage := Float (item_count) / Float (total_count) * 100.0;

   Put_Line ("Percentage: " & Float'Image (percentage));
end Main;

실행 결과:

Percentage: 4.50000E+01

위 예제에서 Float(item_count)는 정수 값 45를 실수 값 45.0으로 변환합니다.

제약 조건과 형변환

형변환 시, 변환하려는 값이 목표 타입의 제약 조건(예: 범위)을 만족하지 못하면 Constraint_Error 예외가 런타임에 발생합니다.

procedure Main is
   subtype Small_Int is Integer range -128 .. 127;
   large_value : constant Integer := 1000;
   small_value : Small_Int;
begin
   -- small_value := large_value; -- 컴파일 오류: 타입 불일치

   -- 명시적 형변환 시도
   small_value := Small_Int (large_value);
   -- 실행 시점: large_value(1000)는 Small_Int의 범위(-128..127)를
   -- 벗어나므로 이 지점에서 Constraint_Error가 발생합니다.
end Main;

이처럼 명시적 형변환은 타입을 넘나들 수 있는 유연성을 제공하는 동시에, Ada의 타입 시스템이 제공하는 안정성을 해치지 않도록 런타임 검사를 수행하는 중요한 메커니즘입니다.

4.8.2 변환 시 주의사항

명시적 형변환은 강력한 기능이지만, 데이터의 손실이나 런타임 오류를 유발할 수 있는 잠재적 위험을 내포하고 있습니다. 따라서 변환을 수행할 때에는 다음과 같은 중요한 사항들을 반드시 고려해야 합니다.

정밀도 손실 (Loss of Precision)

정밀도가 높은 타입에서 낮은 타입으로 값을 변환할 경우, 표현할 수 있는 한계를 넘어서는 데이터는 소실될 수 있습니다. 이는 주로 더 넓은 범위나 정밀도를 가진 실수 타입을 더 좁은 실수 타입으로 변환할 때 발생합니다.

procedure Main is
   high_precision_pi : constant Long_Float := 3.14159_26535;
   low_precision_pi  : Float;
begin
   -- Long_Float의 정밀한 값이 Float의 표현 범위를 초과하므로,
   -- 정밀도 손실이 발생합니다.
   low_precision_pi := Float (high_precision_pi);
end Main;

low_precision_pi에는 high_precision_pi의 전체 값이 아닌, Float 타입이 표현할 수 있는 만큼의 근사값만 저장됩니다.

실수에서 정수로의 변환: 반올림 규칙

다른 많은 프로그래밍 언어와 달리, Ada에서 실수를 정수로 변환할 때 소수점 이하를 단순히 버리는 절삭(truncation)이 일어나지 않습니다. 대신, 가장 가까운 정수로의 반올림(rounding)이 수행됩니다.

특히, 소수부가 정확히 0.5인 경우에는 가장 가까운 짝수 정수로 반올림하는 ‘round-half-to-even’ 규칙을 따릅니다. 이 규칙은 통계적 편향을 최소화하기 위해 사용됩니다.

-- Integer (2.6)  -- 3 으로 반올림
-- Integer (2.4)  -- 2 로 반올림

-- round-half-to-even 규칙
-- Integer (2.5)  -- 가장 가까운 짝수인 2 로 반올림
-- Integer (3.5)  -- 가장 가까운 짝수인 4 로 반올림

이러한 반올림 정책은 예상치 못한 계산 결과를 초래할 수 있으므로, 실수-정수 간 변환 시 반드시 인지하고 있어야 합니다.


범위 제약 위반 (Constraint_Error)

형변환의 가장 흔한 런타임 오류는 변환하려는 값이 목표 서브타입의 범위를 벗어날 때 발생하는 Constraint_Error입니다. 안전한 변환을 위해서는, 변환을 시도하기 전에 'Valid 속성을 사용하여 값이 목표 서브타입에 유효한지 먼저 확인하는 것이 바람직합니다.

procedure Safe_Conversion (value_to_convert : in Integer) is
   subtype Limited_Range is Integer range 0 .. 100;
   target_variable : Limited_Range;
begin
   if Limited_Range'Valid (value_to_convert) then
      target_variable := Limited_Range (value_to_convert);
   else
      -- 예외를 발생시키지 않고 오류를 처리합니다.
      null;
   end if;
end Safe_Conversion;

이처럼 'Valid 속성을 활용하면, 잠재적인 런타임 오류를 사전에 방지하여 프로그램의 안정성을 크게 향상시킬 수 있습니다.

5. 복합 타입 (composite types)

복합 타입은 여러 개의 구성 요소(component)를 묶어서 하나의 단위로 다루는 데이터 타입입니다. Ada의 대표적인 복합 타입으로는 동일한 타입의 요소들을 집합으로 다루는 배열(Array)과, 서로 다른 타입의 요소들을 묶는 레코드(Record)가 있습니다.

5.1 배열 (Arrays)

앞선 4장에서 우리는 단일 값을 표현하는 스칼라 타입에 대해 학습했습니다. 이제부터는 여러 개의 데이터를 하나의 단위로 묶어서 관리하는 합성 타입(Composite Types)의 세계로 나아갑니다. 그 첫 번째 주자가 바로 배열(array)입니다.

배열은 동일한 타입의 요소(element)들이 연속적인 순서를 가지고 모여있는 집합입니다. 프로그래밍에서 가장 기본적이면서도 강력한 자료구조 중 하나로, 성적 목록, 센서 데이터의 집합, 혹은 문자들의 나열(문자열)과 같이 동일한 성격의 데이터를 효율적으로 저장하고 관리할 때 사용됩니다.

각 요소는 인덱스(index)라고 불리는 고유한 위치 지정자를 통해 접근할 수 있어, 특정 위치의 데이터를 빠르고 쉽게 읽거나 수정할 수 있습니다.

이번 절에서는 Ada가 제공하는 배열의 강력하고 유연한 기능들을 다음과 같은 순서로 탐구합니다.

  • 크기가 고정된 제약된 배열과 크기가 유연한 비제약 배열의 차이
  • 배열의 길이, 범위 등의 정보를 알려주는 배열 속성
  • 배열을 간결하게 초기화하는 배열 애그리게이트
  • 배열의 일부를 잘라내는 배열 슬라이싱
  • 행렬과 같은 다차원 데이터를 다루는 다차원 배열

배열을 능숙하게 다루는 능력은 효율적인 데이터 관리의 첫걸음이며, 복잡한 문제를 해결하는 데 필수적인 기술입니다.

5.1.1 제약된 배열과 비제약 배열

Ada의 배열 타입은 인덱스 범위가 언제 결정되는지에 따라 제약된(constrained) 배열과 비제약(unconstrained) 배열의 두 가지 종류로 나뉩니다.

제약된 배열 타입 (Constrained Array Type)

제약된 배열타입을 선언하는 시점에 인덱스의 범위가 명확하게 고정된 배열입니다. 이 타입으로 선언된 모든 배열 변수(객체)는 항상 동일한 크기와 인덱스 범위를 가집니다.

  • 특징: 모든 인스턴스의 크기가 동일하고 컴파일 시점에 알려집니다.
  • 용도: 크기가 절대 변하지 않는 데이터 구조에 적합합니다. (예: 3x3 행렬, 52장의 카드 덱)

선언 예시:

-- 항상 5개의 정수만 담을 수 있는 제약된 배열 타입
type Fixed_Scores is array (1 .. 5) of Integer;

-- Fixed_Scores 타입의 객체들은 선언 시 크기를 지정할 필요가 없습니다.
-- 항상 크기가 5로 고정됩니다.
player_scores : Fixed_Scores;

비제약 배열 타입 (Unconstrained Array Type)

비제약 배열타입을 선언할 때는 인덱스의 범위를 지정하지 않고, 해당 타입의 변수(객체)를 선언할 때 범위를 지정하는 배열입니다. 타입 선언 시에는 인덱스 범위 자리에 <> (박스) 표기를 사용합니다.

  • 특징: 타입 자체는 크기를 정의하지 않으며, 객체마다 다른 크기를 가질 수 있습니다.
  • 용도: 크기가 다양할 수 있는 데이터 처리에 유연성을 제공합니다. (예: 문자열, 가변 크기 벡터) Ada의 표준 String 타입이 대표적인 비제약 배열입니다.

선언 예시:

-- 인덱스 타입은 Positive이지만, 범위는 미정인 비제약 배열 타입
type Vector is array (Positive range <>) of Float;

-- 객체 선언 시에는 반드시 범위를 지정하여 크기를 확정해야 합니다.
velocity    : Vector (1 .. 3);   -- 크기가 3인 벡터
positions   : Vector (1 .. 100); -- 크기가 100인 벡터
구분 제약된 배열 비제약 배열
범위 결정 시점 타입 선언 시 객체 선언 시
객체 크기 모두 동일 객체마다 다를 수 있음
타입 선언 구문 array (1 .. 10) of ... array (Positive range <>) of ...

5.1.2 배열 속성 (Array Attributes)

Ada의 배열은 자신의 특성을 질의할 수 있는 다양한 속성(attribute) 을 제공합니다. 이 속성들은 배열의 인덱스 범위, 길이 등과 같은 정보를 제공하여, 하드코딩된 상수 값에 의존하지 않고도 유연하고 강건한 코드를 작성할 수 있도록 돕습니다. 속성을 사용하면 배열의 정의가 변경되더라도 코드를 수정할 필요가 없으므로 유지보수성이 크게 향상됩니다.

속성은 배열 객체나 배열 타입 이름 뒤에 작은따옴표(')를 붙이고 속성 이름을 기술하는 방식으로 사용합니다.

주요 배열 속성

'First, 'Last, 'Length, 'Range

이 네 가지 속성은 배열의 첫 번째 차원(또는 유일한 차원)에 대한 정보를 반환하며, 가장 빈번하게 사용됩니다.

  • A'First: 배열 A의 첫 번째 인덱스 값을 반환합니다.
  • A'Last: 배열 A의 마지막 인덱스 값을 반환합니다.
  • A'Length: 배열 A의 요소 개수 (A'Last - A'First + 1)를 반환합니다.
  • A'Range: 배열 A의 인덱스 범위 (A'First .. A'Last)를 반환합니다.

이 속성들은 특히 for 루프와 함께 사용될 때 강력한 시너지를 발휘합니다.

예제 1: 1차원 배열 속성 활용

procedure Test_Array_Attributes is
   -- 점수를 저장하는 배열 (인덱스: 1..10)
   Scores : array (1 .. 10) of Integer;

   -- 요일을 저장하는 배열 (인덱스: Day'Range)
   type Day is (Mon, Tue, Wed, Thu, Fri, Sat, Sun);
   Work_Hours : array (Day range Mon .. Fri) of Float;
begin
   -- Scores 배열 순회
   -- for i in 1 .. 10 loop 와 동일하지만, 훨씬 유연함
   for i in Scores'Range loop
      Scores (i) := i * 10;
   end loop;

   -- Work_Hours 배열의 정보 출력
   -- Ada.Text_IO.Put_Line ("First: " & Integer'Image (Work_Hours'First)); -- 컴파일 오류: Day 타입을 출력할 수 없음
   -- Ada.Text_IO.Put_Line ("Last: " & Integer'Image (Work_Hours'Last));  -- 'Image 속성을 사용해야 함
   -- Ada.Text_IO.Put_Line ("Length: " & Integer'Image (Work_Hours'Length));

   -- 'Range 속성을 활용한 루프
   for D in Work_Hours'Range loop
      if D = Fri then
         Work_Hours (D) := 4.0;
      else
         Work_Hours (D) := 8.0;
      end if;
   end loop;
end Test_Array_Attributes;

위 예제에서 Scores'Range1 .. 10과 동일하며, Work_Hours'RangeMon .. Fri와 동일합니다. 만약 Scores 배열의 범위가 (0 .. 9)로 변경되더라도 for i in Scores'Range loop 코드는 수정 없이 정상적으로 동작합니다.

다차원 배열을 위한 속성

다차원 배열의 경우, 각 차원별로 속성을 조회할 수 있도록 정수 인자를 받는 형태의 속성을 제공합니다.

  • A'First(N): 배열 AN번째 차원의 첫 번째 인덱스 값을 반환합니다.
  • A'Last(N): 배열 AN번째 차원의 마지막 인덱스 값을 반환합니다.
  • A'Length(N): 배열 AN번째 차원의 요소 개수를 반환합니다.
  • A'Range(N): 배열 AN번째 차원의 인덱스 범위를 반환합니다.

NStatic 표현식이어야 하며, 1부터 시작합니다. A'FirstA'First(1)과 동일하며, A'RangeA'Range(1)과 동일합니다.

예제 2: 2차원 배열 속성 활용

procedure Test_Matrix_Attributes is
   type Matrix is array (1 .. 3, 1 .. 4) of Float;
   M : Matrix;
begin
   -- 2차원 배열을 순회할 때 속성을 사용하면,
   -- 배열의 차원 크기가 변경되어도 코드를 수정할 필요가 없음
   for Row in M'Range (1) loop       -- 또는 M'First(1) .. M'Last(1)
      for Col in M'Range (2) loop    -- 또는 M'First(2) .. M'Last(2)
         M (Row, Col) := Float (Row + Col);
      end loop;
   end loop;

   -- Ada.Text_IO.Put_Line ("Rows: " & Integer'Image (M'Length (1)));     -- 결과: 3
   -- Ada.Text_IO.Put_Line ("Columns: " & Integer'Image (M'Length (2)));  -- 결과: 4
end Test_Matrix_Attributes;

비제약 배열과 속성

서브프로그램의 매개변수와 같이 비제약 배열 타입을 사용하는 경우, 실제 전달되는 배열 객체의 경계를 알 수 없으므로 속성의 사용은 선택이 아닌 필수입니다.

예제 3: 비제약 배열을 처리하는 서브프로그램

package Vector_Utils is
   type Vector is array (Integer range <>) of Float;

   function Sum (V : Vector) return Float;
end Vector_Utils;

package body Vector_Utils is
   function Sum (V : Vector) return Float is
      Total : Float := 0.0;
   begin
      -- 전달받은 Vector 'V'의 실제 범위는 호출 시점에 결정됨
      -- 따라서 'Range 속성을 사용하여 안전하게 순회해야 함
      if V'Length = 0 then
         return 0.0;
      end if;

      for i in V'Range loop
         Total := Total + V (i);
      end loop;
      return Total;
   end Sum;
end Vector_Utils;

-- 활용 예시
procedure Main is
   V1 : Vector_Utils.Vector := (1.0, 2.0, 3.0);         -- 인덱스: 1..3
   V2 : Vector_Utils.Vector (-5 .. 5);                  -- 인덱스: -5..5
   Result : Float;
begin
   Result := Vector_Utils.Sum (V1);
   Result := Vector_Utils.Sum (V2);
   Result := Vector_Utils.Sum ((10.0, 20.0)); -- 익명 배열 전달
end Main;

Sum 함수는 어떤 인덱스 범위를 가진 Vector가 전달되더라도 V'Range를 통해 실제 객체의 범위를 정확히 파악하여 안전하게 동작합니다. 이처럼 속성은 일반화되고 재사용 가능한 코드를 작성하는 데 핵심적인 역할을 합니다.

속성 요약

속성 이름 인자 설명 반환 타입
S'First 없음 S의 첫 번째 차원의 하한(lower bound) S의 인덱스 타입
S'First(N) N SN번째 차원의 하한 SN차원 인덱스 타입
S'Last 없음 S의 첫 번째 차원의 상한(upper bound) S의 인덱스 타입
S'Last(N) N SN번째 차원의 상한 SN차원 인덱스 타입
S'Length 없음 S의 첫 번째 차원의 길이 universal_integer
S'Length(N) N SN번째 차원의 길이 universal_integer
S'Range 없음 S의 첫 번째 차원의 인덱스 범위 (S'First .. S'Last) S의 인덱스 타입의 범위
S'Range(N) N SN번째 차원의 인덱스 범위 (S'First(N) .. S'Last(N)) SN차원 인덱스 타입의 범위

5.1.3 배열 애그리게이트 (Array Aggregates)

애그리게이트(Aggregate)는 배열과 같은 복합 타입의 리터럴 값을 한 번에 표현하는 매우 유용한 구문입니다. 이를 통해 배열 변수를 선언과 동시에 초기화하거나, 배열 전체에 새로운 값을 간결하게 대입할 수 있습니다.

애그리게이트에는 위치 지정(positional) 방식과 이름 지정(named) 방식이 있으며, 이름 지정 방식이 일반적으로 더 선호됩니다.

위치 지정 애그리게이트 (Positional Aggregate)

가장 기본적인 형태로, 배열의 인덱스 순서에 따라 괄호 () 안에 값의 목록을 나열합니다.

type Day_Of_Week is (Mon, Tue, Wed, Thu, Fri, Sat, Sun);
type Work_Hours is array (Day_Of_Week range Mon..Fri) of Natural;

-- 위치 지정 애그리게이트로 근무 시간을 초기화
Daily_Hours : Work_Hours := (8, 8, 9, 8, 7); -- Mon=8, Tue=8, ...

이 방식은 배열의 크기가 작고 순서가 명확할 때는 간편합니다. 하지만 배열의 인덱스 범위가 변경되면 코드를 수정해야 하므로 유지보수성이 떨어질 수 있습니다.

이름 지정 애그리게게이트 (Named Aggregate)

인덱스 => 값 형태로 각 요소의 위치와 값을 명시적으로 지정합니다. 이 방식은 순서에 상관없이 값을 지정할 수 있고, 코드의 가독성안전성을 크게 향상시킵니다.

  • 기본 사용법:

    -- 이름 지정 방식 (순서는 무관)
    Hours_A : Work_Hours := (Mon => 8, Wed => 9, Tue => 8, Fri => 7, Thu => 8);
    
  • 범위(..) 와 others 활용: 이름 지정 애그리게이트의 진정한 강점은 범위와 others 키워드에서 나옵니다.

    • 범위: ..를 사용하여 여러 인덱스에 동일한 값을 한 번에 할당할 수 있습니다.
    • others: 명시적으로 지정되지 않은 나머지 모든 인덱스에 특정 기본값을 할당합니다.
    -- 월요일부터 목요일까지는 8시간, 금요일은 7시간 근무
    Hours_B : Work_Hours := (Mon..Thu => 8, Fri => 7);
    
    -- 금요일은 4시간, 나머지 요일은 모두 8시간 근무
    Hours_C : Work_Hours := (Fri => 4, others => 8);
    
    -- 모든 근무 시간을 0으로 초기화
    Hours_D : Work_Hours := (others => 0);
    

others를 사용한 초기화는 매우 강력한 기법입니다. 만약 Work_Hours 타입의 범위가 나중에 변경되더라도, others를 사용한 코드는 수정할 필요 없이 새로운 범위에 맞춰 올바르게 동작합니다. 이처럼 이름 지정 애그리게이트는 변경에 유연하고 오류가 적은 코드를 작성하는 데 핵심적인 역할을 합니다.

5.1.4 배열 연산 (Array Operations)

Ada는 배열의 개별 요소에 접근하는 것 외에도, 배열 전체를 하나의 단위로 다룰 수 있는 다양한 내장 연산자들을 제공합니다. 이러한 연산자들은 코드를 간결하게 만들고, 복잡한 배열 조작을 직관적으로 표현할 수 있게 해줍니다. 주요 연산은 비교, 결합, 그리고 논리 연산으로 나뉩니다.

배열 비교 (Array Comparison)

배열은 두 가지 종류의 비교 연산을 지원합니다: 동등 비교와 관계(순서) 비교.

동등 비교 (=/=)

두 배열이 같은지 혹은 다른지를 비교합니다. 이 연산자는 대부분의 배열 타입에 대해 사전 정의되어 있습니다.

  • = (같음): 두 배열의 길이가 같고, 각 인덱스에 대응하는 모든 요소가 서로 같을 때 True를 반환합니다.
  • /= (같지 않음): 두 배열의 길이가 다르거나, 하나 이상의 대응 요소가 다를 때 True를 반환합니다.
procedure Test_Equality is
   V1 : constant array (1 .. 4) of Integer := (10, 20, 30, 40);
   V2 : constant array (1 .. 4) of Integer := (10, 20, 30, 40);
   V3 : constant array (1 .. 4) of Integer := (10, 99, 30, 40); -- 2번째 요소가 다름
   V4 : constant array (0 .. 3) of Integer := (10, 20, 30, 40); -- 인덱스 범위는 다르지만, 내용과 길이는 같음

   Are_V1_V2_Equal : Boolean := (V1 = V2); -- True
   Are_V1_V3_Equal : Boolean := (V1 = V3); -- False
   Are_V1_V4_Equal : Boolean := (V1 = V4); -- True. 인덱스 범위가 달라도 비교 가능
begin
   null;
end Test_Equality;

중요한 점은, 비교는 배열의 길이와 요소의 값을 기준으로 하며 인덱스 범위 자체는 직접적인 영향을 주지 않는다는 것입니다. V1V4는 인덱스 범위가 각각 1..40..3으로 다르지만, 길이가 4로 동일하고 모든 요소가 같으므로 V1 = V4True입니다.

관계 비교 (<, <=, >, >=)

이 연산자들은 요소의 순서가 정의된 1차원 배열(예: Character, Integer, 열거형 타입을 요소로 갖는 배열)에 대해 사전 정의되어 있습니다. 비교는 사전 편찬 순서(lexicographical order), 즉 사전에서 단어를 정렬하는 방식과 유사하게 동작합니다.

  1. 두 배열의 첫 요소부터 차례대로 비교합니다.
  2. 처음으로 다른 요소가 발견되면, 그 요소들의 대소 관계가 전체 배열의 대소 관계를 결정합니다.
  3. 한 배열이 다른 배열의 시작 부분과 완전히 일치하고 길이가 더 짧다면(즉, 접두사라면), 더 짧은 배열이 “작다”고 간주됩니다.

이러한 특성 때문에 String 타입(문자들의 1차원 배열)을 비교할 때 매우 직관적으로 동작합니다.

procedure Test_Relational is
   Result1 : Boolean := ("Apple" < "Banana");   -- True ('A' < 'B')
   Result2 : Boolean := ("Ada" < "Adam");      -- True ("Ada"가 "Adam"의 접두사)
   Result3 : Boolean := ("Test" >= "Test");     -- True
   Result4 : Boolean := ("Zebra" < "Apple");    -- False ('Z' > 'A')
begin
   null;
end Test_Relational;

배열 결합 (Array Concatenation)

& 연산자는 두 개의 1차원 배열을 연결하거나 배열에 요소를 추가하여 새로운 배열을 생성합니다. 이는 특히 문자열을 조립할 때 매우 유용하게 사용됩니다.

결합 연산에는 네 가지 형태가 있습니다.

  1. 배열 & 배열: 두 배열을 순서대로 연결합니다.
  2. 배열 & 요소: 배열의 끝에 한 요소를 추가합니다.
  3. 요소 & 배열: 배열의 시작 부분에 한 요소를 추가합니다.
  4. 요소 & 요소: 두 요소를 포함하는 새로운 배열을 생성합니다.
procedure Test_Concatenation is
   -- 문자열 결합
   Hello      : constant String := "Hello";
   World      : constant String := "World";
   Greeting   : constant String := Hello & ", " & World & '!'; -- "Hello, World!"

   -- 정수 배열 결합
   Part_1     : constant array (1 .. 3) of Integer := (1, 2, 3);
   Part_2     : constant array (1 .. 2) of Integer := (4, 5);
   Combined   : constant array (1 .. 7) of Integer := Part_1 & Part_2 & (6, 7); -- (1,2,3,4,5,6,7)

   -- 요소 결합
   Single     : constant Character := 'A';
   As_Array   : constant String := Single & 'B'; -- "AB"
begin
   null;
end Test_Concatenation;

결합 결과로 생성되는 배열의 인덱스 하한(lower bound)은 일반적으로 왼쪽 피연산자의 하한을 따릅니다. 만약 왼쪽 피연산자가 단일 요소라면, 결과 배열의 하한은 보통 1(문자열의 경우 Positive'First)이 됩니다.

논리 연산 (Logical Operations)

Boolean 타입을 요소로 갖는 1차원 배열에 대해서는 and, or, xor, not 논리 연산자를 사용할 수 있습니다.

  • and, or, xor: 두 개의 Boolean 배열을 피연산자로 사용하며, 두 배열의 길이가 반드시 같아야 합니다. 연산은 각 인덱스에 대응하는 요소별(component-wise)로 수행됩니다.
  • not: 한 개의 Boolean 배열을 피연산자로 사용하며, 각 요소의 논리값을 반전시킨 새로운 배열을 반환합니다.
procedure Test_Logical_Ops is
   type Bit_Mask is array (Integer range <>) of Boolean;

   Mask_A : constant Bit_Mask := (True,  True,  False, False);
   Mask_B : constant Bit_Mask := (True,  False, True,  False);

   -- 요소별 논리 연산
   Mask_And : constant Bit_Mask := Mask_A and Mask_B; -- (True,  False, False, False)
   Mask_Or  : constant Bit_Mask := Mask_A or  Mask_B; -- (True,  True,  True,  False)
   Mask_Xor : constant Bit_Mask := Mask_A xor Mask_B; -- (False, True,  True,  False)

   -- 요소별 부정
   Not_A    : constant Bit_Mask := not Mask_A;         -- (False, False, True,  True)
begin
   null;
end Test_Logical_Ops;

이러한 배열 연산 기능은 데이터를 집합적으로 처리하는 로직을 간결하고 명확하게 표현할 수 있도록 하여 코드의 가독성과 생산성을 높여줍니다.

5.1.5 배열 슬라이싱 (array Slicing)

슬라이싱(Slicing)은 1차원 배열의 연속된 일부분을 선택하여, 그 자체로 독립된 경계를 가진 새로운 배열처럼 다룰 수 있게 하는 강력한 기능입니다. 슬라이싱을 사용하면 반복문을 사용하지 않고도 배열의 특정 부분을 간결하게 읽거나 수정할 수 있습니다.

슬라이스의 구문은 배열이름 (시작_인덱스 .. 종료_인덱스) 입니다.

슬라이스 읽기 및 쓰기

슬라이스는 배열의 값을 읽어 다른 변수에 할당하거나, 슬라이스된 부분에 직접 새로운 값을 대입하는 데 사용될 수 있습니다.

다음 예제는 전체 성적 배열에서 특정 부분을 슬라이싱하여 읽고, 다른 부분에는 새로운 값을 대입하는 과정을 보여줍니다.

with Ada.Text_IO;
with Ada.Integer_Text_IO;
procedure Slice_Example is
  use Ada.Text_IO;
  use Ada.Integer_Text_IO;

  -- 10명의 학생 성적을 저장하는 배열
  Scores : array (1..10) of Integer := (88, 92, 75, 98, 84, 77, 68, 95, 89, 91);

  -- 성적 일부를 저장할 배열 (인덱스 범위가 다름에 유의)
  Mid_Group : array (1..3) of Integer;

begin
  -- Scores 배열의 4번부터 6번까지 요소를 슬라이싱하여 Mid_Group에 대입 (읽기)
  Mid_Group := Scores (4..6); -- Mid_Group의 값: (98, 84, 77)

  -- Scores 배열의 1번부터 3번까지 요소에 애그리게이트 값을 대입 (쓰기)
  Scores (1..3) := (50, 55, 60);

  Put ("Middle Group (from index 4-6):");
  for I in Mid_Group'range loop
    Put (Item => Mid_Group (I), Width => 4);
  end loop;
  New_Line;

  Put ("All Scores after update:");
  for I in Scores'range loop
    Put (Item => Scores (I), Width => 4);
  end loop;
  New_Line;
end Slice_Example;

실행 결과:

Middle Group (from index 4-6):  98  84  77
All Scores after update:        50  55  60  98  84  77  68  95  89  91

슬라이싱의 핵심 규칙: 길이 일치

슬라이스를 포함한 배열 대입문에서 가장 중요한 규칙은, 대입 기호(:=) 양쪽의 배열 길이가 같아야 한다는 것입니다. 각 배열의 인덱스 범위 자체는 달라도 무방합니다.

LHS : array (1..4)   of Character;
RHS : array (11..14) of Character := ('A', 'B', 'C', 'D');

-- 양쪽 모두 길이가 4이므로 유효한 대입문입니다.
LHS := RHS; -- LHS는 이제 ('A', 'B', 'C', 'D') 값을 가집니다.

이처럼 슬라이싱은 서로 다른 인덱스 범위를 가진 배열 간에도 데이터를 유연하게 복사하고 조작할 수 있는 안전하고 효율적인 방법을 제공하여 코드의 가독성과 유지보수성을 크게 향상시킵니다.

5.1.6 배열 반복 (Iteration over Arrays)

배열의 모든 요소에 순차적으로 접근하여 작업을 수행하는 것은 프로그래밍에서 가장 기본적인 작업 중 하나입니다. Ada는 이러한 배열 반복을 위해 간결하고 안전하며 가독성 높은 여러 구문을 제공합니다. 어떤 구문을 선택하는지는 단순히 요소를 읽기만 할 것인지, 아니면 인덱스가 필요한지 등 구체적인 요구사항에 따라 달라집니다.

1. 인덱스 기반 for...in 루프

가장 전통적이고 기본적인 배열 반복 방법은 for...in 루프와 'Range 속성을 함께 사용하는 것입니다. 이 방식은 배열의 각 요소에 인덱스를 통해 직접 접근해야 할 때, 또는 루프 내에서 배열 요소를 수정해야 할 때 유용합니다.

'Range 속성을 사용하면 배열의 인덱스 범위가 변경되더라도 코드를 수정할 필요가 없으므로 강건한 코드를 작성할 수 있습니다.

procedure Classic_Iteration is
   Grades : array (1 .. 5) of Integer := (87, 92, 78, 95, 88);
begin
   -- Grades 배열의 모든 요소를 출력
   for i in Grades'Range loop
      -- i는 1, 2, 3, 4, 5 순서로 변함
      -- Ada.Text_IO.Put (Integer'Image (Grades (i)) & " ");
   end loop;
   -- 출력:  87  92  78  95  88

   -- 점수에 5점씩 가산 (배열 요소 수정)
   for i in Grades'Range loop
      Grades (i) := Grades (i) + 5;
   end loop;
end Classic_Iteration;

2. 요소 기반 for...of 루프 (Ada 2012 이상)

Ada 2012부터 도입된 for...of 루프는 배열의 각 요소(element) 를 직접 순회합니다. 이 방식은 루프 내에서 인덱스가 필요 없고, 요소 값을 읽기만 하면 될 때 코드를 훨씬 더 간결하고 명확하게 만들어 줍니다.

루프 변수는 기본적으로 해당 요소의 상수 복사본(constant copy)이므로, 루프 내에서 실수로 요소 값을 변경하는 것을 방지하여 안정성을 높입니다.

procedure Element_Iteration is
   Names : constant array (1 .. 3) of String := ("Ada", "Charles", "Grace");
   Total_Length : Natural := 0;
begin
   -- 배열의 각 요소를 직접 순회
   -- 루프 변수 'Name'은 'Names' 배열의 요소를 차례대로 가리킴
   for Name of Names loop
      -- Ada.Text_IO.Put_Line ("Hello, " & Name);
      Total_Length := Total_Length + Name'Length;
   end loop;
end Element_Iteration;

for...of 구문은 불필요한 인덱스 변수를 제거하여 “무엇을” 할 것인지에 더 집중하게 하므로, 현대적인 Ada 프로그래밍에서 적극적으로 권장되는 방식입니다.

3. 역순 반복 (reverse 키워드)

배열을 마지막 요소부터 첫 요소까지 역순으로 순회해야 할 경우, 루프 구문에 reverse 키워드를 추가하기만 하면 됩니다. 이는 for...infor...of 구문 모두에 적용할 수 있습니다.

procedure Reverse_Iteration is
   Countdown : constant array (1 .. 5) of Positive := (5, 4, 3, 2, 1);
begin
   -- 인덱스 기반 역순 반복
   for i in reverse Countdown'Range loop
      -- i는 5, 4, 3, 2, 1 순서로 변함
      -- Ada.Text_IO.Put (Positive'Image (Countdown (i)));
   end loop;

   -- 요소 기반 역순 반복
   for Element of reverse Countdown loop
      -- Element는 1, 2, 3, 4, 5 순서로 변함
      -- Ada.Text_IO.Put (Positive'Image (Element));
   end loop;
end Reverse_Iteration;

4. [심화] indiceselements 애스펙트 (Ada 2022)

Ada 2022는 더욱 유연한 반복을 위해 'indices'elements라는 새로운 애스펙트(aspect)를 도입했습니다.

  • 'indices: 배열의 유효한 인덱스들을 순회하는 이터레이터(iterator)를 반환합니다. 1차원 배열의 경우 'Range와 유사하게 동작하지만, 다차원 배열이나 비연속적인 인덱스를 갖는 컨테이너 등에서 더 일반적인 반복 방법을 제공합니다.
    for I in My_Array'indices loop
       -- ...
    end loop;
    
  • 'elements: 배열의 요소들을 순회하는 이터레이터를 반환합니다. 특히 이 애스펙트는 인덱스와 요소를 한 번에 튜플(tuple) 형태로 얻는 강력한 기능을 제공합니다.
    for (Index, Element) of My_Array'elements loop
       -- Ada.Text_IO.Put_Line ("Index: " & Index'Image & ", Value: " & Element'Image);
    end loop;
    

    이 구문은 for...of 루프의 내부 동작 방식과 관련이 있으며, 인덱스와 요소가 동시에 필요할 때 가장 명확하고 효율적인 코드를 작성하게 해줍니다.

어떤 반복문을 선택할 것인가?

사용 목적 추천 구문 이유
단순히 요소를 읽기만 할 때 for Element of My_Array loop 가장 간결하고 안전함. 불필요한 인덱스 변수가 없어 가독성이 높음.
요소를 수정하거나 인덱스가 필요할 때 for I in My_Array'Range loop 전통적이고 명확한 방식. 인덱스를 통한 직접적인 제어가 가능함.
인덱스와 요소를 함께 사용하며 읽을 때 for (I, E) of My_Array'elements 가장 현대적이고 표현력이 뛰어남. 두 정보를 한 번에 얻어 효율적임.

5.1.7 문자열: 배열의 활용 (Strings: An Application of Arrays)

지금까지 다룬 배열의 개념들은 Ada의 가장 기본적이고 중요한 데이터 타입 중 하나인 String을 이해하는 데 핵심적인 역할을 합니다. String은 특별한 타입이 아니라, 표준 라이브러리에 미리 정의된 문자의 1차원 배열입니다.

Standard 패키지 내에서 String 타입은 다음과 같이 선언되어 있습니다.

type String is array (Positive range <>) of Character;

이 선언은 String 타입의 본질에 대해 다음 세 가지 중요한 정보를 알려줍니다.

  1. 배열 (array): String은 배열 타입입니다.
  2. 비제약 (range <>): 길이가 고정되지 않은 비제약 배열이므로, 객체를 선언하거나 초기화할 때 크기를 지정해야 합니다.
  3. Positive 인덱스, Character 요소: 인덱스로는 Positive 타입(1 이상의 정수)을 사용하며, 각 요소는 Character 타입입니다.

이처럼 String이 본질적으로 배열이기 때문에, 이전에 학습한 모든 배열의 기능들—속성, 슬라이싱, 연산—이 String에 그대로 적용됩니다. 이는 Ada가 문자열을 다루는 방식이 매우 일관되고 강력하다는 것을 의미합니다.

배열의 모든 기능을 사용하는 문자열

String이 배열이므로, 별도의 문자열 전용 함수 라이브러리에 의존하지 않고도 대부분의 문자열 조작을 기본 언어 기능만으로 수행할 수 있습니다.

  • 속성 ('Length, 'Range): 문자열의 길이나 인덱스 범위를 얻는 것은 다른 배열과 완전히 동일합니다.
  • 인덱싱 (Indexing): My_String(1)과 같이 특정 위치의 문자에 접근할 수 있습니다.
  • 슬라이싱 (Slicing): My_String(1 .. 5)와 같이 부분 문자열을 매우 쉽게 추출할 수 있습니다.
  • 결합 (&): & 연산자로 문자열들을 이어 붙여 새로운 문자열을 만듭니다.
  • 비교 (=, <, >): 사전 편찬 순서에 따라 문자열을 비교합니다.

실용 예제: 파일 경로 다루기

다음 예제는 문자열이 배열의 특성을 어떻게 활용하여 파일 경로를 분석하는지 보여줍니다.

procedure Handle_File_Path is
   File_Path : constant String := "/home/user/document.txt";

   -- 파일 경로에서 마지막 '/' 문자의 위치를 찾는 함수
   function Find_Last_Slash (Path : String) return Natural is
   begin
      for i in reverse Path'Range loop
         if Path (i) = '/' then
            return i;
         end if;
      end loop;
      return 0; -- 찾지 못한 경우
   end Find_Last_Slash;

   Slash_Pos : constant Natural := Find_Last_Slash (File_Path);
   Directory : String := "";
   File_Name : String := "";

begin
   if Slash_Pos > 0 and Slash_Pos < File_Path'Length then
      -- 슬라이싱을 사용하여 디렉토리와 파일 이름 분리
      Directory := File_Path (File_Path'First .. Slash_Pos); -- "/home/user/"
      File_Name := File_Path (Slash_Pos + 1 .. File_Path'Last); -- "document.txt"
   end if;

   -- 결합 연산자를 사용하여 새로운 경로 생성
   -- new_path : constant String := directory & "archive.zip";
   -- Ada.Text_IO.Put_Line (new_path); -- "/home/user/archive.zip"
end Handle_File_Path;

이처럼 String을 배열로 이해하면, 복잡해 보이는 문자열 처리 작업을 배열 슬라이싱, 인덱싱, 반복문과 같은 친숙한 도구를 사용하여 논리적으로 해결할 수 있습니다. 이는 Ada의 타입 시스템이 제공하는 일관성의 큰 장점 중 하나입니다.

5.1.8 다차원 배열 (Multi-dimensional arrays)

1차원 배열이 데이터의 선형적인 나열을 표현한다면, 다차원 배열(multi-dimensional array)은 행렬, 그리드, 테이블과 같이 2개 이상의 차원을 갖는 데이터를 표현하기 위해 사용됩니다. 다차원 배열의 요소에 접근하기 위해서는 각 차원에 해당하는 여러 개의 인덱스가 필요합니다.

선언 및 요소 접근

다차원 배열은 array 선언 시 괄호 안에 각 차원의 인덱스 범위를 쉼표(,)로 구분하여 정의합니다.

-- 2차원 배열 (행렬)
type Matrix is array (1 .. 3, 1 .. 4) of Float;

-- 3차원 배열 (정육면체 공간)
type Cube is array (0 .. 9, 0 .. 9, 0 .. 9) of Integer;

요소에 접근할 때도 선언과 마찬가지로 괄호 안에 각 차원의 인덱스를 쉼표로 구분하여 지정합니다.

procedure Access_Matrix is
   M : Matrix;
   X : Float;
begin
   -- 2행 3열의 요소에 값 할당
   M (2, 3) := 10.5;

   -- 1행 1열의 요소 값을 변수 X에 저장
   X := M (1, 1);
end Access_Matrix;

Ada의 강점 중 하나는 각 차원의 인덱스 타입이 서로 달라도 된다는 것입니다. 예를 들어, 요일별, 상점별 판매량을 저장하는 배열을 다음과 같이 선언할 수 있습니다.

type Day is (Mon, Tue, Wed, Thu, Fri, Sat, Sun);
type Weekly_Sales is array (Day range Mon .. Fri, 1 .. 10) of Natural; -- 5x10 배열

다차원 배열의 반복

다차원 배열의 모든 요소를 순회하기 위해서는 차원의 수만큼 중첩된 for 루프를 사용합니다. 이때, 배열의 크기 변경에 유연하게 대처하기 위해 각 차원의 범위를 지정하는 속성('Range(N), 'First(N), 'Last(N))을 사용하는 것이 매우 중요합니다.

procedure Iterate_Matrix is
   M : Matrix;
begin
   -- M'Range(1)은 첫 번째 차원의 범위(1..3)를 반환
   -- M'Range(2)는 두 번째 차원의 범위(1..4)를 반환
   for Row in M'Range (1) loop
      for Col in M'Range (2) loop
         M (Row, Col) := Float (Row * Col);
      end loop;
   end loop;
end Iterate_Matrix;

위 코드는 Matrix 타입의 행과 열의 크기가 변경되더라도 전혀 수정할 필요 없이 안전하게 동작합니다.

다차원 배열 애그리게이트

다차원 배열도 애그리게이트(aggregate)를 사용하여 초기화할 수 있습니다. 각 차원에 대해 괄호를 중첩하여 값을 지정합니다.

-- 2x3 크기의 정수 행렬 초기화
Matrix_Const : constant array (1 .. 2, 1 .. 3) of Integer :=
   ( (11, 12, 13),  -- 첫 번째 행
     (21, 22, 23) ); -- 두 번째 행

-- 'others'를 사용한 초기화
Zero_Matrix : Matrix := (others => (others => 0.0));

others를 중첩하여 사용하면 배열의 모든 요소를 특정 값으로 간단하게 초기화할 수 있습니다.

배열의 배열 (Arrays of Arrays)과의 관계

다차원 배열은 배열의 배열(array of arrays)과 개념적으로 다릅니다.

  • 다차원 배열: 모든 행의 길이가 동일한 직사각형(rectangular) 구조를 갖는 단일 객체입니다. 요소 접근은 M(Row, Col) 형식으로 이루어집니다.
  • 배열의 배열 (Jagged Array): 각 요소가 또 다른 배열인 배열입니다. 내부 배열들의 길이가 서로 다를 수 있어 “들쭉날쭉한(jagged)” 구조를 가질 수 있습니다. 요소 접근은 JA(Row)(Col)와 같이 두 번의 인덱싱으로 이루어집니다.
-- 1. 3x4 크기의 다차원 배열
type Rectangular_Matrix is array (1 .. 3, 1 .. 4) of Integer;

-- 2. 각 요소가 'Vector' 타입인 배열의 배열
type Vector is array (Integer range <>) of Integer;
type Jagged_Array is array (1 .. 3) of Vector;

-- Jagged_Array는 다음과 같이 길이가 다른 배열들을 가질 수 있음
-- JA(1) := (1, 2);
-- JA(2) := (1, 2, 3, 4, 5);
-- JA(3) := (10, 20, 30);

대부분의 경우, 데이터가 고정된 그리드 형태라면 다차원 배열을 사용하는 것이 더 직관적이고 효율적입니다.

5.1.9 익명 배열 (Anonymous Arrays)

익명 배열(Anonymous Array)은 명시적인 타입 이름이나 변수 선언 없이, 코드 내에서 직접 생성하여 사용하는 배열을 의미합니다. 이는 주로 서브프로그램에 배열 값을 한 번만 전달하는 용도로 사용될 때 코드를 간결하게 만들어 줍니다. 익명 배열은 본질적으로 배열 애그리게이트(aggregate)를 타입의 문맥이 명확한 곳에서 직접 사용하는 것입니다.

개념 및 필요성

때로는 특정 배열 데이터가 단 한 번의 서브프로그램 호출을 위해서만 필요한 경우가 있습니다. 이런 상황에서 데이터를 저장하기 위해 별도의 상수나 변수를 선언하는 것은 번거롭고, 코드의 가독성을 저해할 수 있습니다.

기존 방식:

procedure Print_Report is
   -- 서브프로그램에 전달하기 위해 상수 배열 선언
   SCORES : constant array (1 .. 3) of Integer := (100, 95, 88);

   procedure Display_Scores (Data : array (Integer range <>) of Integer) is
      -- ...
   begin
      -- ...
   end Display_Scores;
begin
   Display_Scores (SCORES);
end Print_Report;

익명 배열 사용: 익명 배열을 사용하면 SCORES라는 상수를 선언하는 과정을 생략하고, 배열 애그리게이트를 호출 지점에 직접 전달할 수 있습니다.

procedure Print_Report_Anonymous is
   procedure Display_Scores (Data : array (Integer range <>) of Integer) is
   -- ...
   begin
      -- ...
   end Display_Scores;
begin
   -- 별도의 변수 선언 없이 배열 애그리게이트를 직접 전달
   Display_Scores ((100, 95, 88));
end Print_Report_Anonymous;

이처럼 익명 배열은 일회성 데이터를 간결하게 표현하여 코드의 명확성을 높여줍니다.

타입 모호성과 한정(Qualification)

익명 배열을 사용할 때, 컴파일러가 전달되는 값의 타입을 명확히 추론할 수 있어야 합니다. 만약 동일한 이름의 서브프로그램이 서로 다른 배열 타입을 매개변수로 받도록 중복정의(overloading)되어 있다면, 타입 모호성이 발생할 수 있습니다.

type Integer_Vector is array (Positive range <>) of Integer;
type Float_Vector is array (Positive range <>) of Float;

procedure Process (V : Integer_Vector) is begin end;
procedure Process (V : Float_Vector) is begin end;

-- Process ((10, 20, 30)); -- 🚨 모호성 오류!
-- 컴파일러는 (10, 20, 30)이 Integer_Vector인지 Float_Vector인지 판단할 수 없음

이러한 모호성을 해결하기 위해 타입 한정(type qualification)을 사용합니다. 이는 애그리게이트 앞에 타입이름'을 붙여 타입을 명시적으로 지정하는 구문입니다.

-- 타입 한정을 통해 모호성 해결
Process (Integer_Vector'(10, 20, 30)); -- Integer_Vector 버전을 호출
Process (Float_Vector'(10.0, 20.0, 30.0)); -- Float_Vector 버전을 호출

권장 사용 지침

  • 일회성 인자: 익명 배열은 서브프로그램에 값을 한 번만 전달하는 경우에 가장 이상적입니다.
  • 재사용 데이터: 만약 동일한 배열 데이터가 코드의 여러 부분에서 반복적으로 사용된다면, 익명 배열 대신 이름 있는 상수(named constant)로 선언하는 것이 유지보수와 가독성 측면에서 훨씬 바람직합니다.

결론적으로, 익명 배열은 코드를 간결하게 만드는 유용한 도구이지만, 타입 문맥이 명확한 상황에서 일회성 데이터에 한해 신중하게 사용해야 합니다.

5.1.10 [심화] 배열의 메모리 레이아웃

Ada의 배열은 추상적인 데이터 구조일 뿐만 아니라, 물리적으로 연속된(contiguous) 메모리 블록에 저장되는 것이 언어 차원에서 보장됩니다. 이는 배열의 모든 요소가 메모리상에서 빈틈없이 연달아 배치됨을 의미하며, 인덱스를 통한 빠른 임의 접근(random access)을 가능하게 하는 기반이 됩니다.

배열이 차지하는 총 메모리 크기는 'Size 속성을 통해 비트(bit) 단위로 알 수 있으며, 이는 일반적으로 배열의 길이 * 요소 하나의 크기(즉, My_Array'Length * My_Array'Component_Size)와 같습니다.

행 우선 순서 (Row-Major Order)

다차원 배열의 요소를 메모리에 배치하는 순서에는 두 가지 주요 방식이 있습니다. Ada는 C, C++, Python과 마찬가지로 행 우선 순서(Row-Major Order)를 사용합니다. 이는 배열의 가장 마지막 인덱스가 가장 빠르게 변하는 순서로 요소들이 저장됨을 의미합니다.

예를 들어, 2x3 행렬이 다음과 같이 선언되었다고 가정해 봅시다.

type Matrix is array (1 .. 2, 1 .. 3) of Integer;
My_Matrix : Matrix;

My_Matrix의 6개 요소는 메모리에 다음과 같은 순서로 연속적으로 배치됩니다.

  1. My_Matrix(1, 1)
  2. My_Matrix(1, 2)
  3. My_Matrix(1, 3)
  4. My_Matrix(2, 1)
  5. My_Matrix(2, 2)
  6. My_Matrix(2, 3)

첫 번째 행의 모든 요소가 먼저 배치된 후, 두 번째 행의 모든 요소가 그 뒤를 잇습니다.

이식성과 인터페이싱

이러한 저장 방식은 포트란(Fortran)과 같은 언어와의 상호운용성에서 매우 중요합니다. 포트란은 열 우선 순서(Column-Major Order)를 사용하므로, 첫 번째 인덱스가 가장 빠르게 변합니다. 따라서 Ada와 포트란 간에 다차원 배열을 주고받을 때는 이러한 메모리 레이아웃의 차이를 반드시 인지해야 합니다. 이 주제는 21.4절(포트란 및 다른 언어와의 연동)에서 더 자세히 다룹니다.

배열의 메모리 레이아웃에 대한 이해는 성능 최적화, 특히 CPU 캐시의 효율성을 극대화하거나 다른 언어로 작성된 라이브러리와 데이터를 교환할 때 필수적인 저수준 지식입니다.

5.2 레코드 (records)

배열이 동일한 타입의 요소들을 모아놓은 집합이라면, 지금부터 다룰 레코드(record)는 서로 다른 타입의 요소들을 하나의 논리적 단위로 묶은 합성 타입입니다. 이는 프로그래밍에서 배열만큼이나 중요하고 빈번하게 사용되는 자료구조입니다.

현실 세계의 데이터는 대부분 여러 종류의 정보가 결합된 형태를 띱니다. 예를 들어, 한 명의 ‘직원’을 표현하기 위해서는 정수 타입의 ‘사원 번호’, 문자열 타입의 ‘이름’, 그리고 실수 타입의 ‘연봉’ 등 각기 다른 데이터 타입이 필요합니다. 레코드는 이처럼 연관된 데이터들을 필드(field)라는 이름으로 묶어, Employee와 같은 새로운 사용자 정의 타입을 생성할 수 있도록 해줍니다.

이번 절에서는 데이터를 구조화하는 데 핵심적인 역할을 하는 레코드에 대해 다음과 같은 내용을 학습합니다.

  • 레코드를 정의하고 사용하는 기본 구조
  • 레코드 전체를 복사하고 비교하는 레코드 연산
  • 레코드를 생성하고 초기화하는 애그리게이트
  • 레코드 안에 다른 레코드를 포함하는 중첩된 레코드
  • 특정 값에 따라 구조가 변하는 가변 레코드
  • 객체 지향 프로그래밍의 기초가 되는 tagged 레코드

레코드를 통해 데이터를 의미 있는 단위로 구조화함으로써, 프로그램의 가독성과 유지보수성을 크게 향상시킬 수 있습니다.

5.2.1 기본 레코드 구조

레코드(record)는 서로 다른 타입의 데이터들을 논리적인 하나의 단위로 묶어주는 합성 타입입니다. 이는 마치 여러 정보를 담고 있는 ‘양식(form)’이나 ‘카드(card)’와 같습니다. 예를 들어, ‘학생’이라는 정보 단위를 표현하기 위해서는 ‘학번’(정수), ‘이름’(문자열), ‘평균 학점’(실수)과 같이 각기 다른 종류의 데이터가 필요합니다. 레코드는 바로 이러한 연관된 데이터들을 필드(field)라는 이름으로 묶어 새로운 타입을 정의할 수 있게 해줍니다.

레코드 선언하기

레코드는 type 키워드를 사용하여 선언하며, recordend record 사이에 필드들을 정의합니다. 각 필드는 필드명 : 타입; 형식으로 선언합니다.

type Student is record
   Student_ID : Integer;
   Name       : String (1 .. 30);
   GPA        : Float;
end record;

위 예제는 Student라는 새로운 레코드 타입을 정의합니다. 이 타입은 Student_ID, Name, GPA라는 세 개의 필드를 가지며, 이 필드들이 모여 하나의 Student 객체를 구성합니다.

레코드 객체 선언 및 필드 접근

정의된 레코드 타입을 사용하여 변수나 상수를 선언할 수 있습니다. 레코드의 개별 필드에 접근할 때는 점 표기법(dot notation), 즉 객체명.필드명 형식을 사용합니다.

procedure Manage_Students is
   -- 'Student' 타입의 변수 선언
   S1 : Student;
begin
   -- 점 표기법을 사용하여 각 필드에 값 할당
   S1.Student_ID := 20250001;
   S1.Name       := "Hong, Gil-dong                "; -- 30자에 맞춤
   S1.GPA        := 3.85;

   -- 필드 값 읽기
   -- Ada.Text_IO.Put_Line ("Name: " & S1.Name);
   -- Ada.Text_IO.Put_Line ("ID: " & Integer'Image (S1.Student_ID));
end Manage_Students;

이처럼 레코드는 연관된 데이터를 구조화하여 프로그램의 가독성을 높이고 데이터 조작을 용이하게 만듭니다. 각 필드는 독립적인 변수처럼 사용될 수 있으며, 이들이 모여 하나의 의미 있는 데이터 단위를 형성합니다. 이는 복잡한 정보를 체계적으로 관리하는 데 필수적인 기능입니다.

5.2.2 레코드 연산 (할당 및 비교)

Ada에서 레코드는 단순히 필드들의 집합이 아니라, 하나의 온전한 값으로 취급됩니다. 따라서 레코드 전체를 대상으로 하는 기본적인 연산, 즉 할당(assignment)과 비교(comparison)를 직접 수행할 수 있습니다.

1. 레코드 할당 (Assignment) :=

하나의 레코드 객체의 값을 다른 레코드 객체로 한 번에 복사할 수 있습니다. := 연산자를 사용하면, 원본 레코드의 모든 필드 값이 대상 레코드의 해당 필드로 각각 멤버별 복사(member-wise copy)됩니다. 이 연산을 수행하려면 두 레코드 객체가 동일한 타입이어야 합니다.

procedure Record_Assignment is
   type Point is record
      X : Float := 0.0;
      Y : Float := 0.0;
   end record;

   Origin : constant Point := (0.0, 0.0);
   P1     : Point;
   P2     : Point;
begin
   -- P1의 필드에 값을 개별적으로 할당
   P1.X := 10.0;
   P1.Y := 20.0;

   -- P1의 모든 필드 값을 P2로 한 번에 복사
   P2 := P1;

   -- 이제 P2는 (X => 10.0, Y => 20.0) 상태가 됨
   -- if P2.X = 10.0 and P2.Y = 20.0 then ...

   -- 레코드 전체를 상수로 초기화하는 것도 가능
   P1 := Origin;
   -- 이제 P1은 (X => 0.0, Y => 0.0) 상태가 됨
end Record_Assignment;

이 기능은 개별 필드를 일일이 복사하는 번거로움을 없애주고, 코드를 훨씬 간결하고 명확하게 만들어 줍니다.


2. 레코드 비교 (Comparison) =/=

두 레코드 객체가 같은지 혹은 다른지를 비교하기 위해 = (같음) 와 /= (같지 않음) 연산자가 사전 정의되어 있습니다. 비교 연산 역시 멤버별(member-wise)로 수행됩니다.

두 레코드가 같다(=)고 판단되는 조건은 다음과 같습니다.

모든 해당 필드들이 각각 동일한 값을 가질 경우

하나라도 다른 값을 가진 필드가 있다면 두 레코드는 같지 않습니다.

procedure Record_Comparison is
   type Vector is record
      X, Y, Z : Integer;
   end record;

   V1 : constant Vector := (1, 2, 3);
   V2 : constant Vector := (1, 2, 3);
   V3 : constant Vector := (9, 2, 3);

   Is_V1_Eq_V2 : Boolean;
   Is_V1_Eq_V3 : Boolean;
begin
   Is_V1_Eq_V2 := (V1 = V2); -- 모든 필드가 같으므로 True
   Is_V1_Eq_V3 := (V1 = V3); -- X 필드가 다르므로 False

   -- if V1 /= V3 then ... -- True
end Record_Comparison;

주의사항: 가변 레코드(variant record)의 경우, 두 객체의 판별자(discriminant) 값이 다르면 두 레코드는 즉시 다른 것으로 간주되며, 같은 경우에만 나머지 필드들을 비교합니다.

이러한 내장 연산 덕분에 레코드를 마치 정수나 문자처럼 하나의 단위로 다룰 수 있어, 데이터 구조를 더욱 직관적으로 조작할 수 있습니다.

5.2.3 레코드 애그리게이트와 초기화

레코드 타입의 객체를 생성하고 필드에 초기값을 부여하는 가장 일반적이고 구조적인 방법은 레코드 애그리게이트(Record Aggregate)를 사용하는 것입니다. 애그리게이트는 레코드의 모든 필드 값을 괄호 () 안에 묶어 표현하는 구문으로, 변수 선언 시 초기화 또는 상수 선언에 주로 사용됩니다.

Ada는 두 가지 종류의 레코드 애그리게이트를 제공합니다: 위치별 애그리게이트이름 지정 애그리게이트.

1. 위치별 애그리게이트 (Positional Aggregate)

위치별 애그리게이트는 레코드 타입 선언에 나열된 필드의 순서에 맞춰 값들을 쉼표로 구분하여 지정하는 방식입니다.

type Book is record
   Title  : String (1 .. 50);
   Author : String (1 .. 20);
   Year   : Natural;
end record;

-- 위치별 애그리게이트를 사용한 상수 선언
Ada_RM : constant Book :=
   ("Ada Reference Manual                    ", -- Title
    "ISO/IEC/JTC1/SC22/WG9",                   -- Author
    2022);                                     -- Year

이 방식은 레코드의 구조가 단순하고 필드 수가 적을 때 간결하게 사용할 수 있습니다. 하지만 필드의 순서가 바뀌거나 새로운 필드가 중간에 추가되면 코드를 수정해야 하므로, 유지보수에 취약할 수 있습니다.


2. 이름 지정 애그리게이트 (Named Aggregate)

이름 지정 애그리게이트는 필드_이름 => 값 형식을 사용하여 각 필드에 값을 명시적으로 지정합니다. 값의 순서는 레코드 선언의 순서와 상관없이 자유롭게 지정할 수 있습니다.

Clean_Code : constant Book :=
   (Author => "Robert C. Martin    ",
    Year   => 2008,
    Title  => "Clean Code: A Handbook of Agile Software Craftsmanship");

이름 지정 애그리게이트의 장점:

  • 가독성: 어떤 값이 어느 필드에 해당하는지 명확하게 알 수 있습니다.
  • 유지보수성: 레코드 정의에서 필드 순서가 변경되어도 애그리게이트 코드는 수정할 필요가 없습니다.
  • 유연성: 위치별 방식과 혼합하여 사용할 수도 있습니다 (단, 이름 지정 부분 뒤에 위치 지정 부분을 사용할 수는 없습니다).

가독성과 유지보수 측면의 월등한 이점으로 인해, 대부분의 Ada 코딩 스타일 가이드에서는 이름 지정 애그리게이트의 사용을 강력히 권장합니다.

3. 필드 기본값 (Default Value)

레코드 필드를 선언할 때, := 연산자를 사용하여 기본 초기값을 지정할 수 있습니다. 이렇게 하면 애그리게이트에서 해당 필드를 생략했을 때 자동으로 기본값이 사용됩니다.

type Configuration is record
   Mode     : Mode_Type := Normal; -- 기본값 지정
   Timeout  : Natural   := 1_000;  -- 기본값 지정
   Log_File : Unbounded_String;     -- 기본값 없음
end record;

-- Log_File만 값을 지정하고 나머지는 기본값 사용
My_Config : Configuration := (Log_File => To_Unbounded_String ("/var/log/app.log"));

My_Config 객체는 ModeNormal로, Timeout1_000으로 자동 초기화됩니다. 이 기능은 선택적으로 설정하는 옵션 필드가 많은 레코드에서 매우 유용합니다.

5.2.4 중첩된 레코드 (Nested Records)

현실 세계의 데이터는 종종 계층적인 구조를 가집니다. 예를 들어, ‘사람’이라는 데이터는 ‘이름’과 ‘주소’라는 하위 데이터 묶음을 포함하고, ‘주소’는 다시 ‘도시’, ‘상세 주소’, ‘우편번호’ 등으로 구성됩니다. Ada는 이러한 계층 구조를 중첩된 레코드(nested record)를 통해 매우 자연스럽게 표현할 수 있습니다.

중첩된 레코드는 한 레코드의 필드(field)가 또 다른 레코드 타입인 구조를 의미합니다. 이는 복잡한 데이터를 논리적인 단위로 분해하고 재사용 가능한 컴포넌트로 만들어, 코드의 모듈성과 가독성을 크게 향상시킵니다.

선언 및 필드 접근

중첩된 레코드를 만들기 위해서는 먼저 내부(inner) 레코드 타입을 정의한 후, 외부(outer) 레코드에서 해당 타입을 필드로 사용하면 됩니다.

-- 1. 내부 레코드 타입 정의
type Date is record
   Year  : Integer range 1900 .. 2100;
   Month : Integer range 1 .. 12;
   Day   : Integer range 1 .. 31;
end record;

-- 2. 외부 레코드에서 'Date' 타입을 필드로 사용
type Employee is record
   ID         : Positive;
   Name       : String (1 .. 20);
   Start_Date : Date; -- 'Date' 레코드가 'Employee' 레코드에 중첩됨
end record;

중첩된 레코드의 하위 필드에 접근할 때는 점 표기법(dot notation)을 연쇄적으로 사용합니다.

procedure Access_Nested_Field is
   New_Employee : Employee;
begin
   -- 중첩된 레코드의 필드에 값 할당
   New_Employee.ID := 101;
   New_Employee.Name := "John Doe            "; -- 20자에 맞춤
   New_Employee.Start_Date.Year := 2025;
   New_Employee.Start_Date.Month := 8;
   New_Employee.Start_Date.Day := 4;

   -- 중첩된 필드의 값 읽기
   -- if new_employee.start_date.year = 2025 then
   --    ...
   -- end if;
end Access_Nested_Field;

애그리게이트를 이용한 초기화

중첩된 레코드는 중첩된 애그리게이트(nested aggregate)를 사용하여 초기화할 수 있습니다. 애그리게이트의 구조는 레코드의 구조를 그대로 반영해야 합니다. 특히 이름 지정 애그리게이트를 사용하면 구조가 복잡해도 가독성을 높일 수 있습니다.

procedure Initialize_Nested is
   -- 이름 지정 애그리게이트를 사용한 명확한 초기화
   Some_Employee : constant Employee :=
      (ID         => 102,
       Name       => "Jane Smith          ",
       Start_Date => (Year => 2024, Month => 10, Day => 1)
      );
begin
   null;
end Initialize_Nested;

이처럼 중첩된 레코드는 복잡한 데이터를 논리적 단위로 묶어 관리하게 함으로써, 추상화 수준을 높이고 코드의 구조를 실제 세계의 데이터 모델과 유사하게 만들어 직관성을 향상시키는 중요한 설계 도구입니다.

5.2.5 판별자를 이용한 가변 레코드

일반적인 레코드는 모든 객체가 동일한 필드 구조를 갖지만, 때로는 특정 값에 따라 레코드의 일부 구조가 달라져야 하는 경우가 있습니다. 예를 들어, 그래픽 객체를 표현할 때 도형의 종류(원, 사각형 등)에 따라 필요한 데이터(반지름, 너비/높이 등)가 달라집니다.

이러한 요구사항을 안전하고 효율적으로 만족시키기 위해 Ada는 판별자(discriminant)를 이용한 가변 레코드(variant record)를 제공합니다. 이는 하나의 레코드 타입 정의 안에 여러 가지 필드 구조(variant)를 포함하고, 판별자의 값에 따라 그중 하나가 선택되도록 하는 강력한 기능입니다.

판별자와 가변부의 구조

판별자는 레코드의 “종류”를 결정하는 특별한 필드입니다. 레코드 이름 뒤에 괄호를 사용하여 선언하며, 이산 타입(정수, 열거형 등)이어야 합니다. 레코드 내부에서는 case 문을 사용하여 판별자의 값에 따라 달라지는 가변부(variant part)를 정의합니다.

-- 판별자로 사용할 열거형 타입
type Message_Kind is (Text, Key_Press, Mouse_Click);

-- 가변 레코드 선언
type Event (Kind : Message_Kind) is record
   Timestamp : Time; -- 모든 Event 객체가 공통으로 갖는 고정부 (fixed part)

   case Kind is      -- Kind 판별자의 값에 따라 구조가 달라지는 가변부
      when Text =>
         Content : Unbounded_String;
      when Key_Press =>
         Code    : Key_Code;
         Shift   : Boolean;
      when Mouse_Click =>
         X_Pos   : Natural;
         Y_Pos   : Natural;
   end case;
end record;

Event 레코드에서 Kind가 판별자입니다. 모든 Event 객체는 Timestamp 필드를 공통으로 가지지만, Kind의 값에 따라 Content, (Code, Shift), 또는 (X_Pos, Y_Pos) 필드 중 하나를 갖게 됩니다.

가변 레코드의 선언과 사용

가변 레코드 타입의 객체를 선언할 때는 반드시 판별자의 값을 지정해야 합니다. 이 값은 해당 객체의 수명 동안 절대 변경할 수 없으며, 이로 인해 객체의 구조가 고정됩니다.

-- Kind가 Text인 Event 객체 선언
Chat_Message : Event (Kind => Text);

-- Kind가 Key_Press인 Event 객체 선언
Escape_Key   : Event (Key_Press);

이렇게 선언된 객체는 자신의 판별자에 해당하는 필드에만 접근할 수 있습니다.

-- 올바른 접근
Chat_Message.Content := To_Unbounded_String ("Hello, Ada!");
Escape_Key.Code    := KEY_ESCAPE;

-- 잘못된 접근 (컴파일 오류!)
-- Chat_Message.Code := KEY_ENTER;
-- Escape_Key.X_Pos   := 100;

안전성 및 장점

C 언어의 union과 유사해 보일 수 있지만, Ada의 가변 레코드는 비교할 수 없을 정도로 안전합니다.

  1. 데이터 일관성 보장: 판별자의 값과 실제 레코드의 구조는 항상 일치합니다. KindText인 객체가 Key_Code 필드를 갖는 것은 원천적으로 불가능합니다.
  2. 타입 안전성: 컴파일러는 객체의 판별자 값을 알고 있으므로, 해당 판별자에 유효하지 않은 필드에 접근하려는 시도를 컴파일 시점에 막아줍니다. 이로 인해 메모리를 잘못 해석하여 발생하는 치명적인 버그를 예방할 수 있습니다.
  3. 명확성: 레코드 정의 자체에 모든 가능한 구조가 명시되므로, 코드를 이해하기 쉽고 유지보수가 용이합니다.

가변 레코드는 이처럼 데이터의 유연성과 언어의 강력한 타입 안전성을 결합하여, 복잡하면서도 신뢰성 높은 자료구조를 만들 수 있게 해주는 Ada의 뛰어난 기능 중 하나입니다.

5.2.6 [심화] tagged 레코드와 OOP 기초

지금까지 우리가 살펴본 레코드는 구조가 한번 정의되면 고정되는, 정적인 데이터 구조였습니다. 하지만 소프트웨어 공학의 많은 문제들은 확장(extension)과 다형성(polymorphism)이라는 개념을 필요로 합니다. 즉, 기존의 데이터 타입을 기반으로 새로운 타입을 만들고, 프로그램이 실행되는 동안 실제 객체의 타입에 따라 적절한 동작이 선택되도록 하는 기능입니다.

Ada는 바로 이러한 객체 지향 프로그래밍(Object-Oriented Programming, OOP)을 지원하기 위해 tagged 라는 특별한 종류의 레코드를 제공합니다. tagged 레코드는 Ada의 상속과 동적 디스패치(dynamic dispatch)의 근간을 이루는 핵심 요소입니다.

tagged 키워드의 의미

레코드 선언 시 tagged 키워드를 추가하면, 해당 레코드는 “태그가 붙었다”는 의미를 갖게 됩니다.

type Object is tagged record
   ID : Integer;
end record;

tagged 키워드는 컴파일러에게 다음과 같은 두 가지 중요한 사실을 알립니다.

  1. 확장 가능성: 이 레코드 타입은 앞으로 파생 타입(derived type)의 부모가 될 수 있습니다. 즉, 다른 레코드가 Object를 상속받아 새로운 필드를 추가하며 확장될 수 있습니다.
  2. 런타임 타입 정보: 이 타입의 객체에는 실행 시점에 자신의 정확한 타입을 식별할 수 있는 숨겨진 태그(tag)가 포함됩니다. 이 태그 덕분에 변수가 부모 타입으로 선언되었더라도, 그 변수가 실제로 담고 있는 객체가 자식 타입인지 아닌지를 런타임에 구분할 수 있습니다.

타입 확장 (상속)

tagged 레코드는 new ... with record 구문을 사용하여 새로운 타입을 파생(상속)시킬 수 있습니다. 새로운 타입은 부모의 모든 필드를 물려받고, 자신만의 필드를 추가로 가질 수 있습니다.

-- 'Object'를 상속받는 'Circle' 타입 정의
type Circle is new Object with record
   Radius : Float;
end record;

-- 'Circle'을 상속받는 'Filled_Circle' 타입 정의
type Filled_Circle is new Circle with record
   Color : Color_Type; -- Color_Type은 열거형 등으로 정의되어 있다고 가정
end record;

위 예제에서 CircleObject로부터 ID 필드를 물려받고 Radius 필드를 추가로 가집니다. Filled_CircleCircle로부터 IDRadius를 모두 물려받고 Color 필드를 추가합니다. 이것이 바로 다른 OOP 언어의 상속(inheritance)에 해당합니다.

다형성과 동적 디스패치

tagged 타입의 진정한 힘은 다형성(polymorphism)에서 나옵니다. 'Class 속성을 사용하면, 부모 타입의 변수가 자기 자신은 물론 자신을 상속받은 모든 자식 타입의 객체를 담을 수 있게 됩니다. 이러한 타입을 클래스-전체 타입(class-wide type)이라고 부릅니다.

-- 'Object' 클래스-전체 타입을 가리키는 포인터 변수 선언
The_Object : Object'Class_Access := new Circle'(ID => 1, Radius => 10.0);

위 코드에서 The_ObjectObject'Class 타입의 객체를 가리키도록 선언되었지만, 실제로는 Circle 타입의 객체를 담고 있습니다.

이제 각 타입에 대해 동일한 이름의 서브프로그램(메서드)을 정의했다고 가정해 봅시다.

procedure Draw (O : in Object);
procedure Draw (C : in Circle);

클래스-전체 타입의 변수를 통해 Draw 프로시저를 호출하면, Ada 런타임 시스템은 객체에 숨겨진 태그를 확인하여 그 객체의 실제 타입에 맞는 Draw 프로시저를 자동으로 호출해 줍니다. 이를 동적 디스패치(dynamic dispatch)라고 합니다.

Draw (The_Object.all); -- 변수의 선언 타입이 아닌, 실제 객체 타입인 Circle의 Draw가 호출됨

The_Object := new Object'(ID => 2);
Draw (The_Object.all); -- 이번에는 Object의 Draw가 호출됨

이처럼 tagged 레코드는 정적인 레코드의 한계를 넘어, 코드를 더 유연하고, 재사용 가능하며, 확장하기 쉽게 만드는 객체 지향 프로그래밍의 문을 열어주는 매우 중요한 개념입니다. 이 기능들은 추후 OOP를 다루는 장에서 더욱 상세하게 탐구하게 될 것입니다.

5.2.7 [심화] 레코드의 메모리 레이아웃과 패딩

레코드의 필드(field)들은 기본적으로 소스 코드에 선언된 순서대로 메모리에 배치됩니다. 하지만 컴파일러는 하드웨어의 성능을 최적화하기 위해 필드 사이에 보이지 않는 여분의 바이트를 삽입할 수 있습니다. 이 과정을 패딩(padding), 그리고 필드가 특정 메모리 주소 배수(예: 4바이트 경계)에 위치하도록 하는 규칙을 정렬(alignment)이라고 합니다.

정렬과 패딩의 필요성

대부분의 현대 CPU는 데이터의 크기에 맞는 주소 경계에서 데이터를 읽어올 때 가장 빠릅니다. 예를 들어, 4바이트 크기의 Integer는 4의 배수가 되는 메모리 주소(0, 4, 8, …)에서 접근할 때 한 번의 메모리 접근으로 읽을 수 있습니다. 만약 정렬되지 않은 주소에 있다면, CPU는 여러 번의 메모리 접근을 수행해야 하므로 성능이 저하됩니다.

이러한 하드웨어의 특성 때문에, Ada 컴파일러는 성능을 위해 자동적으로 패딩을 추가하여 필드를 정렬합니다.

코드 예시 6-1: 패딩으로 인한 크기 변화

다음 레코드는 1바이트 Boolean과 4바이트 Integer를 포함합니다. 필드 크기의 합은 5바이트이지만, 실제 메모리 크기는 다를 수 있습니다.

with Ada.Text_IO;
with Ada.Unchecked_Conversion; --'Size 속성을 위해 필요할 수 있음

procedure record_layout_example is
  type Control_Data is record
    is_enabled : Boolean;
    -- 컴파일러는 'value' 필드를 4바이트 경계에 맞추기 위해
    -- 이 사이에 3바이트의 패딩을 삽입할 수 있습니다.
    value      : Integer;
  end record;
begin
  Ada.Text_IO.put_line ("Control_Data'Size: " & Control_Data'Size'image & " bits");
end record_layout_example;

실행 결과 (일반적인 32/64비트 시스템):

Control_Data'Size:  64 bits

분석:

is_enabled (1바이트)와 value (4바이트)의 합은 5바이트(40비트)이지만, 컴파일러는 value 필드를 4바이트 경계에 정렬하기 위해 is_enabled 뒤에 3바이트의 패딩을 추가했습니다. 이로 인해 레코드의 전체 크기는 8바이트(64비트)가 되었습니다.

메모리 레이아웃:

바이트 0 바이트 1-3 바이트 4-7
is_enabled (1) 패딩 (3) value (4)

이식성과 제어

이러한 컴파일러의 자동 최적화는 다른 언어로 작성된 코드와 데이터를 주고받거나, 하드웨어 레지스터를 직접 제어해야 할 때 문제를 일으킬 수 있습니다. 외부 시스템은 Ada 컴파일러가 추가한 패딩을 알지 못하기 때문입니다.

이러한 문제를 해결하기 위해, Ada는 프로그래머가 레코드의 메모리 레이아웃을 비트 단위까지 직접 제어할 수 있는 방법을 제공합니다. pragma Pack을 사용하거나 20.2절에서 자세히 다룰 레코드 표현 명세(record representation clause)를 통해 패딩을 제거하고 각 필드의 정확한 위치를 지정할 수 있습니다.

5.3 문자열 처리

문자열은 텍스트 데이터를 다루는 모든 프로그램에서 필수적인 요소입니다. Ada는 두 가지 주요 문자열 처리 방식을 제공합니다: 선언 시점에 길이가 고정되는 내장된 String 타입과, 런타임에 길이를 동적으로 변경할 수 있는 Ada.Strings.Unbounded 패키지입니다.

5.3.1 기본 String 타입: 고정 길이 문자열

Ada에서 문자열 처리의 가장 기본이 되는 타입은 Standard 패키지에 사전 정의된 String입니다. 이 타입의 본질은 이전 ‘배열’ 절에서 살펴보았듯이, Character의 1차원 비제약 배열입니다.

type String is array (Positive range <>) of Character;

‘비제약 배열’이라는 정의와 ‘고정 길이 문자열’이라는 이 절의 제목은 언뜻 보기에 모순처럼 보일 수 있습니다. 이 둘의 관계를 이해하는 것이 String 타입을 정확히 사용하는 첫걸음입니다.

  • 타입(Type)은 비제약: String이라는 타입 자체는 길이가 정해져 있지 않습니다. 즉, 10개의 문자를 담을 수도 있고 100개의 문자를 담을 수도 있습니다.
  • 객체(Object)는 고정 길이: 하지만 String 타입의 변수나 상수를 선언할 때, 해당 객체의 크기(길이)는 반드시 정해져야 하며, 한번 정해진 길이는 그 객체의 수명 동안 절대 변하지 않습니다.
procedure Fixed_String_Example is
   -- 길이가 10으로 고정된 String 변수 선언
   Fixed_Name : String (1 .. 10);

   -- "Hello" 리터럴의 길이에 맞춰 길이가 5로 고정된 상수 선언
   Greeting   : constant String := "Hello";
begin
   Fixed_Name := "Ada Lovela"; -- 정확히 10개의 문자를 할당

   -- Fixed_Name := "Ada"; -- 🚨 오류: 길이가 맞지 않음 (Constraint_Error 발생)
   -- Fixed_Name := Fixed_Name & "ce"; -- 🚨 오류: 기존 변수의 길이를 늘릴 수 없음
                                    -- '&' 연산은 새로운 String 객체를 생성할 뿐임
end Fixed_String_Example;

기본 String 타입은 다음과 같은 명확한 장단점을 가집니다.

장점 (Strengths)

  1. 단순성과 효율성: 언어의 핵심 기능이므로 사용법이 간단하고, 슬라이싱이나 인덱싱 같은 기본 조작이 매우 빠릅니다.
  2. 예측 가능한 메모리 사용: 객체의 크기가 컴파일 시점에 결정되므로, 대부분 스택(stack)에 할당됩니다. 이는 힙(heap) 메모리를 사용하는 동적 할당에 비해 빠르고, 메모리 단편화(fragmentation) 문제가 없으며, 메모리 누수(leak)의 위험이 없습니다. 이러한 특성은 실시간 시스템이나 높은 신뢰성이 요구되는 환경에서 매우 중요합니다.

단점 (Weaknesses)

  1. 비유연성: 길이가 고정되어 있어 문자열을 추가(append), 삽입(insert), 삭제(delete)하는 작업이 매우 번거롭습니다. 문자열 결합(&)과 같은 연산은 매번 새로운 메모리 공간에 새 문자열 객체를 생성하므로, 반복적인 문자열 조작에는 비효율적일 수 있습니다.
  2. 수동 크기 관리: 파일이나 네트워크에서 가변적인 길이의 문자열을 읽어올 때, 버퍼의 크기를 프로그래머가 직접 관리해야 하며, 버퍼 크기를 초과하면 Constraint_Error가 발생할 수 있습니다.

결론적으로, 기본 String 타입은 다루는 문자열의 최대 길이가 명확하게 알려져 있고, 런타임에 길이가 변하지 않는 상황에 가장 적합합니다. 컴파일러 경고 메시지, 고정된 파일 형식의 필드, 사용자 인터페이스의 레이블 등이 좋은 활용 예입니다. 문자열의 길이가 동적으로 변하는 경우에는 이어지는 절에서 다룰 다른 도구들을 사용하는 것이 바람직합니다.

5.3.2 Ada.Strings.Fixed: 기본 문자열 조작 및 검색

기본 String 타입은 길이가 고정되어 있어 직접적인 수정이 어렵다는 한계가 있습니다. 이러한 불편함을 해소하고, 고정 길이 문자열을 대상으로 하는 필수적인 조작 및 검색 기능을 제공하기 위해 Ada 표준 라이브러리는 Ada.Strings.Fixed 패키지를 제공합니다.

이 패키지의 가장 큰 특징은 동적인 메모리 할당(heap) 없이, 즉 Unbounded_String처럼 복잡한 메모리 관리 없이, 순수하게 배열의 특성을 활용하여 문자열을 처리한다는 점입니다. 이는 예측 가능하고 효율적인 성능이 중요한 환경에서 매우 유용합니다.

문자열 검색

Fixed 패키지는 문자열 안에서 특정 패턴이나 문자를 찾는 강력한 함수들을 제공합니다.

  • Index(Source, Pattern): Source 문자열에서 Pattern 문자열이 처음으로 나타나는 시작 인덱스를 반환합니다. 찾지 못하면 0을 반환합니다.
  • Count(Source, Pattern): Source 문자열에서 Pattern이 몇 번 나타나는지를 반환합니다.
with Ada.Strings.Fixed; use Ada.Strings.Fixed;
procedure Test_Search is
   Haystack : constant String := "apple-banana-apple-orange";
   Position : Natural;
   Num      : Natural;
begin
   -- "apple" 패턴 찾기
   Position := Index (Source => Haystack, Pattern => "apple"); -- 1
   -- "banana" 패턴 찾기
   Position := Index (Haystack, "banana"); -- 7

   -- "apple" 개수 세기
   Num := Count (Haystack, "apple"); -- 2
end Test_Search;

문자열 변환 및 수정

Fixed 패키지는 문자열의 일부를 바꾸거나, 대소문자를 변경하고, 공백을 제거하는 등의 다양한 기능을 제공합니다. 대부분의 수정 함수는 새로운 문자열을 반환합니다.

  • To_Lower(Source) / To_Upper(Source): 문자열 전체를 소문자 또는 대문자로 변환한 새 문자열을 반환합니다.
  • Replace_Slice(Source, Low, High, By): SourceLow부터 High까지의 부분을 By 문자열로 교체한 새 문자열을 반환합니다. 원본과 결과 문자열의 길이가 다를 수 있습니다.
  • Trim(Source, Side): Source의 양쪽(Both), 앞쪽(Left), 또는 뒤쪽(Right)의 공백(space)을 제거한 새 문자열을 반환합니다.
  • Delete(Source, From, Through): Source에서 From부터 Through 인덱스까지의 문자들을 제거하고 뒤따르는 문자들을 앞으로 이동시킨 프로시저입니다. 원본 변수를 직접 수정하며, 남는 공간은 공백으로 채워집니다.
with Ada.Strings.Fixed; use Ada.Strings.Fixed;
procedure Test_Manipulation is
   Original   : constant String := "  Ada Programming   ";
   Trimmed    : String (1 .. 15);
   Replaced   : String (1 .. 19);

   Statement  : String (1 .. 25) := "I love Ada Programming...  ";
begin
   -- 공백 제거
   Trimmed := Trim (Original, Side => Both); -- "Ada Programming"

   -- 부분 교체
   Replaced := Replace_Slice (Trimmed, 1, 3, "ADA"); -- "ADA Programming"

   -- 부분 삭제 (프로시저)
   Delete (Statement, 1, 7); -- "Programming...       "
end Test_Manipulation;

Ada.Strings.Fixed는 기본 String 타입의 한계를 보완하는 필수적인 도구입니다. 이 패키지의 함수들을 잘 활용하면, 동적 메모리 할당의 부담 없이도 대부분의 문자열 처리 요구사항을 효율적이고 안전하게 해결할 수 있습니다.

5.3.3 국제화 지원: Wide_String과 UTF 인코딩

소프트웨어의 사용자가 전 세계로 확장됨에 따라, 영어를 넘어 한글, 한자, 아랍어 등 다양한 언어를 처리하는 능력, 즉 국제화(Internationalization, i18n)는 필수적인 요건이 되었습니다. Ada의 기본 Character 타입은 보통 8비트(256자)로 표현되는 ISO-8859-1(Latin-1) 문자 집합을 사용하므로, 수많은 문자를 가진 언어들을 표현하기에는 역부족입니다.

이 문제를 해결하기 위해, Ada는 유니코드(Unicode)를 완벽하게 지원하는 확장된 문자 및 문자열 타입을 표준으로 제공합니다.

Wide_CharacterWide_Wide_Character

Ada는 더 넓은 범위의 문자를 다루기 위해 두 가지 추가적인 문자 타입을 Standard 패키지에 정의하고 있습니다.

  • Wide_Character: 16비트 크기의 문자 타입입니다. 유니코드의 기본 다국어 평면(BMP, Basic Multilingual Plane)에 포함된 대부분의 현대 문자를 표현할 수 있습니다.
  • Wide_Wide_Character: 32비트 크기의 문자 타입입니다. 현재까지 정의된 모든 유니코드 문자를 표현할 수 있는 가장 포괄적인 문자 타입입니다.

이러한 확장 문자 타입을 기반으로, String에 대응하는 확장 문자열 타입이 존재합니다.

  • Wide_String: array (Positive range <>) of Wide_Character
  • Wide_Wide_String: array (Positive range <>) of Wide_Wide_Character

Wide_StringWide_Wide_String은 기본 String과 마찬가지로 슬라이싱, 결합(&), 속성('Length, 'Range) 등 모든 배열 연산을 동일하게 지원합니다. 국제화된 텍스트를 메모리 상에서 처리할 때는 일반적으로 Wide_Wide_String을 사용하는 것이 가장 안전하고 확실한 방법입니다.

Ada.Strings.UTF_Encoding: UTF-8 처리하기

현대의 파일 시스템이나 네트워크 통신에서는 텍스트를 UTF-8 형식으로 인코딩하는 것이 일반적입니다. UTF-8은 가변 길이 인코딩으로, ASCII 문자는 1바이트로, 한글과 같은 다른 문자들은 2~4바이트로 표현합니다.

프로그램 내부에서는 각 문자를 고정 크기로 다룰 수 있는 Wide_Wide_String을 사용하고, 외부(파일, 네트워크 등)와 데이터를 주고받을 때는 UTF-8 형식의 String을 사용하는 것이 일반적인 패턴입니다. 이 둘 사이의 변환을 위해 Ada는 Ada.Strings.UTF_Encoding 패키지를 제공합니다.

  • Encode 함수: Wide_Wide_String을 UTF-8 바이트 시퀀스를 담은 String으로 변환합니다.
  • Decode 함수: UTF-8 바이트 시퀀스를 담은 StringWide_Wide_String으로 변환합니다.
with Ada.Strings.UTF_Encoding;
with Ada.Wide_Wide_Text_IO;

procedure Test_UTF8_Encoding is
   -- 내부 처리를 위한 Wide_Wide_String
   WWS : constant Wide_Wide_String := "Hello, 세상!";

   -- 외부 저장을 위한 UTF-8 String
   UTF8_S : String;
begin
   -- Encode: Wide_Wide_String -> UTF-8 String
   UTF8_S := Ada.Strings.UTF_Encoding.Encode (WWS);
   -- UTF8_S는 "Hello, 세상!"을 UTF-8 바이트 스트림으로 담고 있음

   -- Decode: UTF-8 String -> Wide_Wide_String
   declare
      Decoded_WWS : constant Wide_Wide_String := Ada.Strings.UTF_Encoding.Decode (UTF8_S);
   begin
      if WWS = Decoded_WWS then
         Ada.Wide_Wide_Text_IO.Put_Line ("Encode/Decode Successful.");
      end if;
   end;
end Test_UTF8_Encoding;

이처럼 Ada는 Wide_Wide_String을 통해 모든 문자를 일관되게 처리하고, UTF_Encoding 패키지를 통해 외부 시스템과의 데이터 교환을 안전하게 수행할 수 있는 완벽한 국제화 지원 체계를 갖추고 있습니다.

5.3.4 Ada.Strings.Unbounded 패키지: 가변 길이 문자열

기본 String 타입의 고정 길이 특성은 안정적이지만, 사용자 입력 처리, 파일 파싱, 동적 메시지 생성 등 문자열의 길이가 수시로 변하는 상황에서는 매우 다루기 어렵습니다. 이러한 문제를 해결하기 위해 Ada 표준 라이브러리는 Ada.Strings.Unbounded 패키지를 제공합니다.

이 패키지는 이름 그대로 길이에 제한이 없는(unbounded), 진정한 의미의 가변 길이 문자열 타입인 Unbounded_String을 제공합니다.

Unbounded_String 타입의 본질

Unbounded_String은 내부적으로 힙(heap) 메모리를 사용하여 문자열 데이터를 관리하는 제어되는 타입(controlled type)입니다. 사용자는 메모리 할당 및 해제에 대해 신경 쓸 필요가 없으며, 패키지가 제공하는 프로시저와 함수를 통해 문자열의 길이를 동적으로 늘리거나 줄일 수 있습니다.

  • 메모리 관리: Unbounded_String 객체는 필요에 따라 자동으로 힙 메모리를 할당받아 크기를 조절합니다.
  • 타입 변환: String과는 별개의 타입이므로, 두 타입 간의 데이터 교환을 위해서는 명시적인 변환 함수가 필요합니다.

주요 연산 및 사용법

Unbounded_String의 진정한 가치는 다양한 수정 및 조작 연산을 통해 드러납니다.

1. 변환 (Conversion)

  • To_Unbounded_String(Source : String) return Unbounded_String: 기본 StringUnbounded_String으로 변환합니다.
  • To_String(Source : Unbounded_String) return String: Unbounded_String을 다시 기본 String으로 변환합니다.

2. 수정 (Modification)

  • Append(Source : in out Unbounded_String, New_Item : String): Source의 끝에 New_Item 문자열을 추가합니다. 원본 Unbounded_String이 직접 수정됩니다.
  • & 연산자: Unbounded_StringString, 또는 두 Unbounded_String을 결합하여 새로운 Unbounded_String을 반환합니다.
  • Delete, Replace_Slice: Ada.Strings.Fixed와 유사한 조작 프로시저들을 제공하여, 원본 객체를 직접 수정합니다.

3. 정보 조회 (Query)

  • Length(Source : Unbounded_String) return Natural: 현재 문자열의 길이를 반환합니다.

실용 예제: 동적으로 문장 만들기

with Ada.Strings.Unbounded; use Ada.Strings.Unbounded;
with Ada.Text_IO;

procedure Build_Sentence is
   -- 비어있는 Unbounded_String 생성
   Sentence : Unbounded_String := To_Unbounded_String ("");
begin
   Append (Sentence, "Ada is a");
   Append (Sentence, " powerful");
   Append (Sentence, " and reliable language.");

   -- Ada.Text_IO.Put_Line ("Length: " & Integer'Image (Length (Sentence)));
   -- Ada.Text_IO.Put_Line (To_String (Sentence));
   -- 출력: Ada is a powerful and reliable language.
end Build_Sentence;

장점 (Strengths)

  • 최고의 유연성: 문자열의 길이가 런타임에 어떻게 변할지 예측할 수 없는 모든 상황에 이상적입니다.
  • 사용 편의성: 복잡한 메모리 관리를 패키지가 알아서 처리해주므로 사용이 간편합니다.

고려사항 (Considerations)

  • 성능: 힙 메모리 할당 및 재할당은 스택을 사용하는 기본 String에 비해 성능 오버헤드가 발생할 수 있습니다. 문자열 수정이 매우 빈번할 경우 성능에 영향을 줄 수 있습니다.
  • 실시간 시스템 부적합: 동적 메모리 할당은 실행 시간을 예측하기 어렵게 만드는 요인(non-deterministic)이 될 수 있으므로, 일반적으로 하드 리얼타임(hard real-time) 시스템에서는 사용을 피합니다.

결론적으로 Unbounded_String은 일반적인 응용 프로그램에서 가변적인 문자열을 다루는 가장 편리하고 강력한 방법입니다. 하지만 성능과 결정성(determinism)이 극도로 중요한 환경이라면, 이어지는 절에서 다룰 Bounded_String을 고려해야 합니다.

5.3.5 Ada.Strings.Bounded: 제한된 가변 길이 문자열

Ada.Strings.Bounded 패키지는 Unbounded와 마찬가지로 가변 길이 문자열을 지원하지만, 문자열이 가질 수 있는 최대 길이에 제한이 있다는 점이 다릅니다. 이 패키지는 힙(heap) 메모리를 사용하지 않고 스택(stack)에 데이터를 할당하므로, 동적 할당이 금지되거나 예측 가능한 메모리 사용량이 중요한 실시간 및 임베디드 시스템에 매우 적합합니다.

Ada.Strings.Bounded의 특징 및 사용 시점

  • 예측 가능한 메모리 사용: 문자열의 최대 크기가 컴파일 시점에 정해지므로, 프로그램이 사용할 메모리 양을 정확히 예측할 수 있습니다.
  • 힙 단편화 방지: 동적 할당 및 해제를 반복하지 않으므로 힙 단편화 문제가 발생하지 않습니다.
  • 경계 검사: 문자열 연산 결과가 지정된 최대 길이를 초과하면 Ada.Strings.Length_Error 예외가 발생하여, 버퍼 오버플로우와 같은 문제를 사전에 방지합니다.

최대 길이가 얼마인지 예측 가능한 상황(예: 사용자 이름, 파일 경로 등)이라면 Unbounded보다 Bounded를 사용하는 것이 더 안전하고 효율적인 선택입니다.

사용법: 제네릭 패키지 인스턴스화

Ada.Strings.Bounded는 제네릭 패키지이므로, 사용하려는 최대 길이를 지정하여 새로운 패키지를 인스턴스화(instantiation)해야 합니다.

with Ada.Strings.Bounded.Generic_Bounded_Length;
with Ada.Text_IO;

procedure Bounded_String_Example is
  use Ada.Text_IO;

  -- 최대 길이를 20으로 하는 새로운 bounded string 패키지를 생성
  package Bounded_20 is
    new Ada.Strings.Bounded.Generic_Bounded_Length (Max => 20);

  -- 생성한 패키지 내의 타입과 함수를 사용
  use Bounded_20;

  Name    : Bounded_String;
  Surname : constant Bounded_String := To_Bounded_String ("Kim");

begin
  -- To_Bounded_String, To_String, Append, "&" 등 Unbounded와 유사한 연산 제공
  Name := To_Bounded_String ("Yuna");
  Append (Source => Name, New_Item => " ");
  Append (Source => Name, New_Item => Surname); -- 이제 Name은 "Yuna Kim"

  put_line (To_String (Name));

  -- 최대 길이를 초과하는 연산 시도
  begin
    Append (Name, " | Republic of Korea"); -- 이 연산 결과는 20자를 초과함
    put_line ("This will not be printed.");
  exception
    when Ada.Strings.Length_Error =>
      put_line ("Error: String length exceeded the maximum capacity of 20.");
  end;

end Bounded_String_Example;

실행 결과:

Yuna Kim
Error: String length exceeded the maximum capacity of 20.

위 예제처럼, Bounded_String은 정해진 경계 안에서 안전하게 문자열을 다룰 수 있는 강력한 도구입니다. 다음 절에서는 지금까지 배운 세 가지 문자열 타입을 언제 사용해야 하는지 종합적으로 비교하고 선택 기준을 제시합니다.

5.3.6 [정리] 문자열 처리 패키지 선택 가이드 (String vs. Unbounded vs. Bounded)

지금까지 Ada에서 문자열을 다루는 세 가지 주요 방법인 String, Ada.Strings.Unbounded, Ada.Strings.Bounded를 학습했습니다. 어떤 상황에 어떤 타입을 사용해야 할까요? 올바른 선택은 프로그램의 성능, 안전성, 유지보수성에 직접적인 영향을 미칩니다.

한눈에 보는 비교

특징 String (기본 타입) Ada.Strings.Bounded Ada.Strings.Unbounded
길이 고정 길이 제한된 가변 길이 (최대치 지정) 완전한 가변 길이
메모리 할당 스택 또는 정적 영역 스택 힙 (동적 할당)
주요 사용처 컴파일 시점에 확정된 텍스트, 하드웨어 I/O 실시간/임베디드 시스템, 최대 길이 예측 가능 일반 응용 프로그램, 파일 처리
장점 최고 성능, 가장 간결 예측 가능한 메모리, 힙 단편화 없음 최고의 유연성, 길이 걱정 없음
단점 길이 변경 불가, 다루기 까다로움 최대 길이 초과 시 Length_Error, 번거로움 동적 할당 오버헤드, 힙 단편화 가능성

선택을 위한 질문 (Decision Guide)

다음 질문을 순서대로 따라가면 가장 적합한 타입을 선택할 수 있습니다.

  1. 문자열의 길이가 컴파일 시점에 알려져 있고, 절대 변하지 않는가?
    • 예 (Yes): String을 사용하십시오.
    • 상수 문자열(constant String := "...";), 고정된 형식의 메시지, 성능이 극도로 중요한 저수준 프로그래밍에 가장 적합합니다. 가장 빠르고 메모리 오버헤드가 없습니다.
  2. 길이가 변해야 하지만, 동적 힙 메모리 사용을 피해야 하는가? (예: 실시간/임베디드 시스템)
    • 예 (Yes): Ada.Strings.Bounded를 사용하십시오.
    • 사용자 이름(최대 30자)이나 파일 경로(최대 255자)처럼, 길이가 변하더라도 합리적인 최대치를 예측할 수 있는 모든 경우에 이상적입니다. 예측 가능성과 안전성이 중요한 시스템의 표준적인 선택입니다.
  3. 문자열 길이를 전혀 예측할 수 없고, 유연성이 가장 중요한가?
    • 예 (Yes): Ada.Strings.Unbounded를 사용하십시오.
    • 크기를 알 수 없는 파일을 읽어 처리하거나, 복잡한 사용자 입력에 따라 동적으로 문자열을 조립하는 일반적인 데스크톱 또는 서버 애플리케이션에 적합합니다. 사용하기 가장 편리하지만, 실시간 시스템에서는 사용을 피해야 합니다.

이처럼 Ada는 문제의 특성과 요구사항에 맞춰 프로그래머가 직접 트레이드오프를 결정하고 가장 적절한 도구를 선택할 수 있도록 지원합니다. 이는 소프트웨어의 신뢰성과 효율성을 중시하는 Ada의 핵심 철학을 보여줍니다.

5.3.7 [심화] 성능 및 메모리 모델 비교 (String vs. Bounded vs. Unbounded)

Ada가 세 가지 주요 문자열 타입을 제공하는 이유는 각각의 타입이 성능, 메모리 사용, 그리고 예측 가능성 측면에서 뚜렷한 트레이드오프(trade-off)를 가지기 때문입니다. 어떤 문자열 타입을 선택하는지는 애플리케이션의 요구사항, 특히 실시간 제약이나 메모리 제약 여부에 따라 신중하게 결정해야 하는 중요한 설계 사안입니다.

메모리 모델: 스택(Stack) vs. 힙(Heap)

각 타입이 메모리를 사용하는 방식은 성능 특성을 결정하는 가장 근본적인 차이점입니다.

  • String (기본 타입)
    • 메모리 모델: 순수한 스택(Stack) 기반 배열입니다. 지역 변수로 선언될 경우, 해당 객체를 위한 메모리 공간 전체가 컴파일 시점에 크기가 결정되어 스택에 할당됩니다.
    • 특징: 동적 할당이 전혀 없어 메모리 단편화나 누수의 위험이 없습니다. 메모리 사용량이 컴파일 시점에 예측 가능합니다.
  • Ada.Strings.Bounded.Bounded_String
    • 메모리 모델: 내부적으로 고정된 크기(Capacity)의 String과 현재 길이를 나타내는 Length 필드를 가진 레코드입니다. 이 레코드 객체 전체가 스택(Stack)에 할당됩니다.
    • 특징: String과 마찬가지로 힙을 사용하지 않으므로, 메모리 할당으로 인한 비결정성이 없습니다.
  • Ada.Strings.Unbounded.Unbounded_String
    • 메모리 모델: 실제 문자열 데이터를 가리키는 포인터를 내부에 가진 제어되는 레코드(controlled record)입니다. 문자열 데이터 자체는 힙(Heap) 메모리에 동적으로 할당됩니다.
    • 특징: 문자열 길이가 늘어 내부 버퍼가 부족해지면, 더 큰 공간을 힙에 새로 할당하고 기존 데이터를 복사한 후 이전 공간을 해제하는 과정이 필요합니다. 이 과정은 실행 시간을 예측하기 어렵게 만듭니다.

성능 특성 비교

특성 String (기본) Bounded_String Unbounded_String
생성 속도 매우 빠름 (스택 포인터 조정) 매우 빠름 (스택 레코드 생성) 상대적으로 느림 (힙 할당 오버헤드 발생 가능)
접근 속도 가장 빠름 (직접적인 메모리 주소 계산) 빠름 (레코드 필드 접근 후 주소 계산) 약간 느림 (포인터 역참조(indirection) 오버헤드)
길이 변경 불가능 (변경 시 새 객체 생성 및 전체 복사) 용량 내에서 빠름 (내부 길이 필드만 수정) 유연하지만 비용이 높음 (용량 초과 시 힙 재할당 및 전체 복사)
메모리 결정성 결정적 (Deterministic) 결정적 (Deterministic) 비결정적 (Non-deterministic)
주요 사용처 고정된 상수, 실시간 시스템의 메시지, 하드웨어 레지스터 소프트 실시간 시스템, 최대 길이가 예측 가능한 동적 문자열 일반 응용 프로그램 (파일 처리, UI, 네트워크 통신)

선택 가이드 요약

  • String을 선택해야 할 때:
    • 문자열의 내용과 길이가 컴파일 시점에 알려져 있거나 절대 변하지 않는 경우.
    • 하드 리얼타임(Hard Real-Time) 시스템과 같이 힙 메모리 사용이 원천적으로 금지되는 환경.
  • Bounded_String을 선택해야 할 때:
    • 문자열의 길이는 변해야 하지만, 예측 불가능한 힙 할당은 피해야 하는 실시간 또는 임베디드 환경.
    • 문자열의 최대 길이를 합리적으로 예측할 수 있는 경우. Unbounded_String의 성능 오버헤드 없이 가변 길이의 유연성을 얻을 수 있는 최적의 절충안입니다.
  • Unbounded_String을 선택해야 할 때:
    • 문자열의 길이를 전혀 예측할 수 없는 일반적인 데스크톱 또는 서버 애플리케이션.
    • 최고 수준의 유연성이 성능 및 결정성보다 더 중요한 경우.

이 세 가지 도구의 특성을 정확히 이해하고 상황에 맞게 사용하는 것은 안정적이고 효율적인 Ada 프로그램을 작성하는 핵심 기술 중 하나입니다.

6. 표현식과 연산자 (Expressions and Operators)

이전 장들에서 우리는 프로그램을 구성하는 데이터의 기본 재료가 되는 타입(Type)에 대해 배웠습니다. 타입이 프로그래밍 세계의 ‘명사’라면, 지금부터 배울 표현식(Expression)은 이 명사들을 조합하여 의미 있는 ‘구’나 ‘절’을 만드는 동사와 같습니다. 표현식은 단순히 값을 나타내는 것을 넘어, 값을 계산하고 새로운 값을 만들어내는 모든 종류의 공식을 의미합니다.

프로그램 내에서 수행되는 거의 모든 계산은 표현식을 통해 이루어집니다. 변수에 새로운 값을 할당하거나(X := Y + 1;), if 문의 조건을 검사하거나(if Count > 0 then ...), 서브프로그램에 인자를 전달하는(Put_Line ("Hello");) 등, 프로그램의 동적인 행위는 모두 표현식을 기반으로 합니다.

이 장에서는 표현식을 구성하는 핵심 요소인 연산자(operator)의 종류와 우선순위 규칙부터 시작하겠습니다. 이어서, 서로 다른 타입을 안전하게 변환하는 방법, ifcase를 문장이 아닌 표현식으로 사용하여 간결하게 값을 도출하는 현대적인 기법들을 학습할 것입니다.

이 장을 마치고 나면, 독자 여러분은 Ada가 제공하는 다양한 도구를 활용하여, 단순한 값의 표현부터 복잡한 논리적, 산술적 계산에 이르기까지 정확하고 강력한 표현식을 자유자재로 구성할 수 있는 능력을 갖추게 될 것입니다.

6.1 표현식의 개요

표현식은 값을 만들어내는 모든 형태의 코드를 의미합니다. 가장 단순한 표현식은 숫자 10이나 문자열 "Hello"와 같은 리터럴(literal)이며, 변수나 상수 그 자체도 자신의 값을 나타내는 표현식입니다. Ada의 진정한 힘은 이러한 단순한 표현식들을 연산자(operator)로 연결하여 더 복잡하고 의미 있는 새로운 값을 계산하는 데 있습니다.

6.1.1 이름(Name), 값(Value), 그리고 표현식(Expression)

Ada에서 프로그램을 구성하는 가장 기본적인 요소들의 관계를 이해하는 것은 매우 중요합니다. 이 세 가지 용어는 서로 밀접하게 연관되어 있습니다.

  • 값 (Value) 값은 10, 3.14, True와 같이 더 이상 분해되지 않는 데이터 그 자체를 의미합니다. 이는 프로그래밍 세계에서 다루는 정보의 가장 근본적인 단위입니다.

  • 이름 (Name) 이름은 변수, 상수, 타입 등 선언된 개체(entity)를 가리키는 식별자입니다. 예를 들어, My_Variable, PI, Is_Ready 같은 것들이 이름입니다. 이름은 그 자체로 값이 아니지만, 프로그램 실행 중에 평가(evaluate)될 때 자신이 가리키는 개체의 값으로 변환됩니다. 즉, 이름은 값을 담는 그릇 또는 값에 붙여진 라벨이라고 생각할 수 있습니다.

  • 표현식 (Expression) 표현식은 하나 이상의 이름, 리터럴(값 자체), 연산자, 함수 호출 등이 문법에 맞게 조합되어 새로운 값을 계산하는 공식입니다. 표현식의 가장 중요한 특징은 평가되었을 때 반드시 단 하나의 값을 산출한다는 것입니다.

이들의 관계를 간단한 예제로 살펴보겠습니다.

Score       : Integer := 90;
Bonus       : constant Integer := 10;
Final_Score : Integer;

위 코드에서,

  • 9010입니다.
  • Score, Bonus, Final_Score이름입니다.
  • Score + BonusScore라는 이름과 Bonus라는 이름이 + 연산자로 연결된 표현식입니다.

다음 대입문을 실행할 때,

Final_Score := Score + Bonus;

컴퓨터는 다음과 같은 과정을 거칩니다.

  1. 대입 연산자(:=)의 오른쪽에 있는 표현식 Score + Bonus를 평가합니다.
  2. 이름 Score를 평가하여 그 값인 90을 얻습니다.
  3. 이름 Bonus를 평가하여 그 값인 10을 얻습니다.
  4. 90 + 10이라는 연산을 수행하여 새로운 값 100을 산출합니다.
  5. 이 최종 값 100Final_Score라는 이름이 가리키는 메모리 공간에 저장합니다.

결론적으로, 리터럴은 가장 단순한 형태의 표현식이며, 모든 이름은 평가될 때 자신의 값을 나타내는 표현식으로 간주될 수 있습니다. Ada의 모든 계산은 이처럼 표현식을 평가하여 새로운 값을 만들어내는 과정의 연속입니다.

6.1.2 표현식의 평가와 타입

Ada는 강력한 정적 타입(static-typed) 언어입니다. 이는 모든 표현식이 프로그램 실행 전, 즉 컴파일 시점에 두 가지 중요한 특성을 가진다는 의미입니다. 바로 평가(Evaluation) 규칙과 타입(Type)입니다.

1. 평가 (Evaluation)

평가는 프로그램이 실행될 때, 표현식이 정해진 규칙에 따라 계산되어 최종적인 단일 값으로 대체되는 과정을 의미합니다. 예를 들어, (A + B) * 2 라는 표현식은 AB의 값을 먼저 더하고, 그 결과에 2를 곱하는 순서로 평가됩니다. 이처럼 어떤 연산을 먼저 수행할지에 대한 규칙을 연산자 우선순위(Operator Precedence)라고 하며, 이는 6.2절에서 자세히 다룹니다.

평가의 결과는 항상 표현식과 동일한 타입의 값이 됩니다.

2. 타입 (Type)

Ada에서 모든 표현식은 자신만의 타입을 가집니다. 이 타입은 표현식을 구성하는 이름, 리터럴, 연산자들의 타입에 따라 컴파일 시점에 결정됩니다.

  • Integer 타입의 변수 X가 있을 때, X + 5 라는 표현식의 타입 역시 Integer 입니다.
  • Boolean 타입의 변수 Is_Ready가 있을 때, not Is_Ready 라는 표현식의 타입 역시 Boolean 입니다.
  • 문자열 리터럴 "Total: "Integer 값을 문자열로 변환한 결과를 & 연산자로 연결한 표현식의 타입은 String 이 됩니다.

컴파일러는 이 타입 정보를 활용하여 표현식이 문맥에 맞게 올바르게 사용되었는지를 매우 엄격하게 검사합니다. 예를 들어, if 문의 조건으로는 반드시 Boolean 타입의 표현식이 와야 하며, Integer 타입의 변수에는 Integer 타입의 표현식 결과만이 대입될 수 있습니다.

Count : Integer;
Is_Done : Boolean;

-- (O) 올바른 사용: Integer 타입 표현식을 Integer 변수에 대입
Count := 10 + 5;

-- (O) 올바른 사용: Boolean 타입 표현식을 if 문의 조건으로 사용
if Count > 0 then
   ...
end if;

-- (X) 잘못된 사용: 컴파일 오류 발생!
-- if 문에는 Boolean 타입이 와야 하는데, Integer 타입 표현식을 사용함
if Count then
   ...
end if;

이처럼 컴파일 시점에 타입을 엄격하게 검사하는 것은 런타임에 발생할 수 있는 수많은 잠재적 오류를 사전에 방지하는 Ada 언어의 핵심적인 안전 철학입니다. 이를 통해 프로그래머의 실수를 줄이고 소프트웨어의 신뢰성을 크게 향상시킬 수 있습니다.

6.2 표현식의 평가 순서: 우선순위와 결합성

하나의 표현식에 여러 연산자가 함께 사용될 때, 어떤 순서로 계산이 이루어지는지는 결과에 결정적인 영향을 미칩니다. 3 + 4 * 2 라는 표현식은 왼쪽부터 계산하면 14가 되지만, 수학적 규칙을 따르면 11이 됩니다.

이러한 모호성을 없애고 항상 일관된 결과를 보장하기 위해, Ada는 모든 연산자에 대해 엄격한 우선순위(precedence)결합성(associativity) 규칙을 정의하고 있습니다. 이 규칙들은 표현식이 평가되는 순서를 명확히 규정하는 언어의 문법입니다.

이 절에서는 먼저 서로 다른 연산자 간의 실행 서열을 결정하는 우선순위 규칙에 대해 알아보고, 동일한 서열의 연산자들이 연달아 나타날 때 계산 방향을 정하는 결합성 규칙에 대해 학습합니다. 이 규칙들을 이해하는 것은 복잡한 표현식의 결과를 정확히 예측하고, 의도한 대로 동작하는 코드를 작성하기 위한 필수적인 과정입니다.

6.2.1 연산자 서열과 우선순위 규칙

우선순위(Precedence)는 서로 다른 종류의 연산자가 하나의 표현식에 함께 있을 때, 어떤 연산자가 먼저 실행될지를 결정하는 규칙입니다. 우선순위가 높은 연산자가 낮은 연산자보다 먼저 평가됩니다. 이는 우리가 수학에서 곱셈을 덧셈보다 먼저 계산하는 것과 같은 원리입니다.

Ada는 모든 연산자를 6개의 우선순위 서열(level)로 명확하게 정의하고 있습니다.

서열 (Precedence) 연산자 종류
6 (가장 높음) **, abs, not 지수, 절댓값, 논리 부정
5 *, /, mod, rem 승제, 모듈로
4 +, - 단항 덧셈 및 뺄셈
3 +, -, & 이항 덧셈, 뺄셈, 문자열 결합
2 =, /=, <, <=, >, >= 관계 연산
1 (가장 낮음) and, or, xor 논리 연산

Note: 단축 평가 연산자인 and thenor else는 우선순위가 가장 낮은 논리 연산자와 동일하게 취급되지만, 엄밀히는 ‘제어 구문’으로 분류됩니다.

이 규칙에 따라, 3 + 4 * 2 라는 표현식에서 곱셈(*)은 서열 5이고 덧셈(+)은 서열 3이므로, 곱셈이 먼저 수행됩니다. 따라서 이 표현식은 3 + (4 * 2) 와 동일하게 평가되어 최종 결과는 11이 됩니다.

마찬가지로, 다음 표현식을 살펴보겠습니다.

Is_Valid : Boolean := A > B and C < D;

관계 연산자(>, <)는 서열 2이고 논리 연산자(and)는 서열 1이므로, A > BC < D 가 각각 먼저 평가되어 Boolean 값으로 변환됩니다. 그 후, 두 Boolean 값을 대상으로 and 연산이 수행됩니다. 이 표현식은 다음과 같이 괄호를 사용한 것과 같습니다.

Is_Valid : Boolean := (A > B) and (C < D);

이처럼 우선순위 규칙은 코드의 동작을 예측 가능하게 만드는 핵심적인 문법입니다. 하지만 코드의 명확성을 위해, 조금이라도 혼동의 여지가 있다면 규칙에 의존하기보다 괄호를 사용하여 계산 순서를 명시적으로 지정하는 것이 바람직합니다.

6.2.2 괄호의 역할: 계산 순서의 명시적 제어

프로그래머는 괄호()를 사용하여 언어에 내장된 연산자 우선순위 규칙을 무시하고, 표현식의 계산 순서를 원하는 대로 명시적으로 제어할 수 있습니다.

괄호 안에 있는 표현식은 다른 어떤 연산자보다도 항상 가장 먼저 평가됩니다.

앞서 살펴본 3 + 4 * 2 예제를 다시 보겠습니다. 우선순위 규칙에 따라 이 표현식의 결과는 11이었습니다. 만약 덧셈을 먼저 수행하고 싶다면, 다음과 같이 괄호를 사용하면 됩니다.

-- 괄호 안의 (3 + 4)가 먼저 계산됩니다.
Result_1 : constant Integer := (3 + 4) * 2; -- 7 * 2 = 14

-- 괄호가 없는 경우, 우선순위 규칙에 따릅니다.
Result_2 : constant Integer := 3 + 4 * 2;   -- 3 + 8 = 11

이처럼 괄호는 계산 순서를 바꾸는 강력하고 명확한 도구입니다.

가독성을 위한 괄호 사용

때로는 연산자 우선순위 규칙에 따라 코드가 의도한 대로 동작하더라도, 그 의도가 다른 프로그래머에게 즉시 명확하게 전달되지 않을 수 있습니다.

Is_Ready := not Is_Empty and Has_Permission;

위 코드는 not 연산자의 우선순위가 가장 높아 (not Is_Empty) and Has_Permission으로 올바르게 동작합니다. 하지만 이 규칙에 익숙하지 않은 사람에게는 not (Is_Empty and Has_Permission)으로 오해될 소지가 있습니다.

이러한 모호성을 없애고 코드의 가독성을 높이기 위해, 다음과 같이 괄호를 명시적으로 추가하는 것이 바람직합니다.

Is_Ready := (not Is_Empty) and Has_Permission;

코드는 컴퓨터뿐만 아니라 사람도 읽는다는 점을 기억하십시오. 연산자 우선순위 규칙에 의존하기보다, 계산 순서가 조금이라도 복잡하거나 오해의 소지가 있다면 주저 없이 괄호를 사용하여 의도를 명확히 밝히는 것이 좋은 프로그래밍 습관입니다.

6.2.3 결합성: 동일 서열 연산자의 계산 방향

결합성(Associativity)은 동일한 우선순위 서열에 있는 연산자들이 하나의 표현식에 연달아 나타날 때, 계산이 진행되는 방향을 결정하는 규칙입니다.

Ada에서 대부분의 이항(binary) 연산자는 왼쪽 결합성(left-associativity)을 가집니다. 이는 매우 직관적인 규칙으로, 단순히 왼쪽에서 오른쪽 순서로 연산이 수행됨을 의미합니다.

예를 들어, 덧셈과 뺄셈은 모두 우선순위 서열 3으로 동일합니다. 다음 표현식을 살펴보겠습니다.

Result := 100 - 10 + 5;

이 표현식은 왼쪽 결합성 규칙에 따라 다음과 같이 왼쪽부터 순서대로 평가됩니다.

  1. 100 - 10 이 먼저 계산되어 90 이 됩니다.
  2. 그 결과인 905 를 더하여 최종 결과는 95 가 됩니다.

즉, 위 코드는 컴파일러에게 다음과 같이 해석됩니다.

Result := (100 - 10) + 5;

만약 오른쪽부터 계산된다면 100 - (10 + 5) 가 되어 결과는 85로 달라질 것입니다. 이처럼 결합성 규칙은 동일 서열 연산자의 계산 순서를 명확히 하여 항상 일관된 결과를 보장합니다.

대부분의 경우 이 왼쪽 결합성 규칙은 우리가 일상적으로 수식을 계산하는 방식과 동일하여 자연스럽게 받아들일 수 있습니다. 하지만 모든 연산자가 이 규칙을 따르는 것은 아니며, 이에 대한 특별한 규칙은 다음 절에서 다루겠습니다.

6.2.4 특별 규칙: 결합성 없는 ** 연산자

대부분의 연산자가 왼쪽 결합성 규칙을 따르는 것과 달리, 지수(거듭제곱)를 계산하는 ** 연산자에는 Ada의 특별한 안전 규칙이 적용됩니다.

Ada에서 ** 연산자는 결합성이 없습니다(non-associative).

이는 A ** B ** C 와 같이 ** 연산자를 괄호 없이 연속으로 사용하는 것이 문법적으로 금지됨을 의미합니다. 이 코드는 컴파일러에 의해 모호한 표현식으로 간주되어 오류로 처리됩니다.

규칙의 이유: 수학적 모호성 제거

이러한 규칙이 존재하는 이유는 수학적으로 (A ** B) ** CA ** (B ** C) 의 결과가 완전히 다르기 때문입니다. 다른 프로그래밍 언어에서는 이 둘 중 하나의 규칙(예: 오른쪽 결합성)을 따르지만, 이로 인해 프로그래머가 의도치 않은 버그를 만들 수 있습니다.

Ada는 이러한 잠재적 오류를 원천적으로 차단하고, 프로그래머가 계산 순서에 대한 의도를 코드에 명시적으로 밝히도록 강제합니다.

  • 예시: 2 ** 3 ** 2

    -- Value := 2 ** 3 ** 2;  -- (X) 컴파일 오류! 모호한 표현식입니다.
    

    이 계산을 수행하려면, 프로그래머는 자신의 의도에 따라 다음 두 가지 중 하나를 반드시 선택해야 합니다.

    -- (2 ** 3)을 먼저 계산: 8 ** 2 = 64
    Value_1 : constant Integer := (2 ** 3) ** 2;
    
    -- (3 ** 2)를 먼저 계산: 2 ** 9 = 512
    Value_2 : constant Integer := 2 ** (3 ** 2);
    

이처럼 Ada의 ** 연산자에 대한 규칙은 언어의 일관된 안전 철학을 보여주는 좋은 예입니다. 언어가 편의를 위해 모호함을 허용하는 대신, 프로그래머에게 명확성을 요구하여 소프트웨어의 신뢰성을 높이는 것입니다.

6.3 주요 연산자

표현식의 평가 순서를 결정하는 규칙을 이해했으므로, 이제 표현식을 구성하는 실질적인 도구인 연산자(operator) 자체에 대해 자세히 살펴보겠습니다.

Ada는 수학적 계산, 논리적 비교, 문자열 결합 등 다양한 목적을 위한 풍부한 연산자들을 제공합니다. 이어지는 절들에서는 이 연산자들을 기능과 우선순위 서열에 따라 분류하여, 가장 우선순위가 높은 연산자부터 순서대로 그 역할과 사용법을 학습합니다.

6.3.1 최고 우선순위 연산자 (**, abs, not)

가장 높은 우선순위 서열(level 6)에는 지수, 절댓값, 논리 부정 연산자가 속합니다. 이 연산자들은 다른 어떤 연산보다도 먼저 평가됩니다.

** (지수 연산자)

** 연산자는 거듭제곱을 계산합니다. 왼쪽 피연산자를 오른쪽 피연산자만큼 거듭제곱한 결과를 반환합니다.

  • 피연산자: 왼쪽은 Integer 또는 부동소수점 타입, 오른쪽(지수)은 반드시 Integer 타입이어야 합니다.

  • 결과 타입: 왼쪽 피연산자의 타입과 동일합니다.

  • 예시:

    Four_Cubed    : constant Integer := 4 ** 3;    -- 4의 3제곱, 결과는 64
    Area_Of_Circle: Float;
    Radius        : Float := 5.0;
    PI            : constant Float := 3.14159;
    begin
       Area_Of_Circle := PI * (Radius ** 2); -- PI * (5.0 ** 2) = 78.53975
    

    지수가 음수일 경우, 역수에 대한 거듭제곱으로 계산됩니다. 예를 들어, X ** -21.0 / (X ** 2)와 같습니다.

abs (절댓값 연산자)

abs 연산자는 숫자 피연산자의 절댓값을 구합니다.

  • 피연산자: 모든 숫자 타입 (Integer, 부동소수점, 고정소수점 등)

  • 결과 타입: 피연산자의 타입과 동일합니다.

  • 예시:

    Absolute_Value : constant Integer := abs -10; -- 결과는 10
    Tolerance      : constant Float   := abs (Expected - Actual);
    

not (논리 및 비트 부정 연산자)

not 연산자는 피연산자의 종류에 따라 두 가지 방식으로 동작합니다.

  1. Boolean 타입: TrueFalse로, FalseTrue로 논리적 값을 반전시킵니다.
    Is_Not_Ready : constant Boolean := not True; -- 결과는 False
    
  2. 모듈러(Modular) 타입: 부호 없는 정수인 모듈러 타입에 사용될 경우, 모든 비트(bit)를 반전시키는 비트 부정(bitwise NOT) 연산을 수행합니다.
    subtype Byte is Interfaces.Unsigned_8; -- 8비트 부호 없는 정수
    X : constant Byte := 2#0000_1111#;      -- 10진수로 15
    Y : constant Byte := not X;             -- 결과는 2#1111_0000#, 10진수로 240
    

6.3.2 승제 연산자 (*, /, mod, rem)

우선순위 서열 5에는 곱셈, 나눗셈, 그리고 나머지 연산을 수행하는 연산자들이 속합니다.

* (곱셈)

* 연산자는 두 피연산자를 곱한 결과를 반환합니다. 모든 숫자 타입에 사용될 수 있습니다.

  • 피연산자: 모든 숫자 타입
  • 결과 타입: 피연산자와 동일한 타입
  • 예시:
    Area : constant Integer := 10 * 5;      -- 결과는 50
    PI   : constant Float   := 3.14159;
    Circumference : constant Float := 2.0 * PI * 5.0; -- 결과는 31.4159
    

/ (나눗셈)

/ 연산자는 왼쪽 피연산자를 오른쪽 피연산자로 나눈 결과를 반환합니다. 이 연산자는 피연산자의 타입에 따라 동작 방식이 달라지는 점에 주의해야 합니다.

  • 정수 나눗셈: 두 피연산자가 모두 정수 타입일 경우, 나눗셈의 결과에서 소수점 이하는 버려집니다(truncated). 즉, 0에 가까운 방향으로 내림 처리됩니다.
    Result_1 : constant Integer := 7 / 3;  -- 결과는 2 (2.333...에서 .333... 버림)
    Result_2 : constant Integer := -7 / 3; -- 결과는 -2 (-2.333...에서 -.333... 버림)
    
  • 실수 나눗셈: 피연산자 중 하나라도 부동소수점 타입이면, 표준적인 실수 나눗셈이 수행됩니다.
    Result_3 : constant Float := 7.0 / 3.0; -- 결과는 2.333...
    

remmod (나머지 연산)

Ada는 나머지 계산을 위해 remmod라는 두 가지 연산자를 제공합니다. 두 연산자는 양수를 다룰 때는 동일하게 동작하지만, 음수를 다룰 때 결과의 부호가 달라지므로 목적에 맞게 구별하여 사용해야 합니다.

1. rem (Remainder 연산자)

rem은 일반적인 ‘나머지’ 연산에 사용됩니다. A rem BA = (A / B) * B + (A rem B) 공식을 만족하도록 정의됩니다. 이 정의에 따라, 결과의 부호는 항상 왼쪽 피연산자(A)의 부호를 따릅니다.

  • 예시:
    R1 : constant Integer :=  11 rem  4; -- 결과:  3
    R2 : constant Integer :=  11 rem -4; -- 결과:  3 (왼쪽이 양수)
    R3 : constant Integer := -11 rem  4; -- 결과: -3 (왼쪽이 음수)
    R4 : constant Integer := -11 rem -4; -- 결과: -3
    

2. mod (Modulo 연산자)

mod는 숫자가 특정 범위를 순환하는 모듈로 산술(Modular Arithmetic)을 위해 설계되었습니다. 시계의 시간 계산(11시에서 3시간 뒤는 2시)이나 배열의 인덱스를 순환시키는 등의 작업에 유용합니다. mod 연산의 결과 부호는 항상 오른쪽 피연산자(B)의 부호를 따릅니다.

  • 예시:
    M1 : constant Integer :=  11 mod  4; -- 결과:  3
    M2 : constant Integer :=  11 mod -4; -- 결과: -1 (오른쪽이 음수, 11 = (-4*-3) - 1)
    M3 : constant Integer := -11 mod  4; -- 결과:  1 (오른쪽이 양수, -11 = (4*-3) + 1)
    M4 : constant Integer := -11 mod -4; -- 결과: -3
    

일반적으로 단순한 나눗셈의 나머지가 필요할 때는 rem을, 주기적인 순환을 구현할 때는 mod를 사용하는 것이 올바른 선택입니다.

6.3.3 단항 덧셈 연산자 (+, -)

우선순위 서열 4에는 숫자 바로 앞에 붙어 그 값의 부호를 나타내는 단항(unary) +- 연산자가 속합니다. 이들은 두 개의 피연산자를 필요로 하는 이항(binary) 덧셈/뺄셈 연산자(서열 3)와는 구별되며, 그들보다 우선순위가 높습니다.

단항 + (항등 연산자)

단항 + 연산자는 피연산자의 값을 그대로 반환하는 항등(identity) 연산자입니다. 즉, 아무런 변화도 주지 않습니다.

  • 피연산자: 모든 숫자 타입

  • 결과 타입: 피연산자의 타입과 동일합니다.

  • 사용 목적: 단항 +는 계산 자체에는 영향을 미치지 않지만, 코드의 가독성을 위해 양수임을 명시적으로 강조하고 싶을 때 사용될 수 있습니다.

    X_Velocity : constant Float := +5.0; -- 양수임을 명시
    Y_Offset   : constant Integer := +10;
    

단항 - (부호 반전 연산자)

단항 - 연산자는 피연산자의 부호를 반전시키는 부호 반전(negation) 연산자입니다. 양수는 음수로, 음수는 양수로 바꿉니다.

  • 피연산자: 모든 숫자 타입
  • 결과 타입: 피연산자의 타입과 동일합니다.
  • 예시:
    Debt      : constant Integer := -1000; -- 음수임을 나타냄
    Opposite  : Integer;
    Value     : Integer := 50;
    begin
       Opposite := -Value; -- Opposite은 -50이 됨
    

우선순위의 중요성

단항 연산자의 우선순위가 이항 연산자보다 높다는 점은 다음 예제에서 중요하게 작용합니다.

Result := 10 + -4;

이 표현식에서 컴파일러는 먼저 단항 - 연산자를 -4에 적용하여 음수 -4를 만듭니다. 그 후, 10 + (-4)의 이항 덧셈을 수행하여 최종 결과는 6이 됩니다. 이는 10 - 4와 동일한 결과를 낳습니다.

6.3.4 이항 덧셈 및 연결 연산자 (+, -, &)

우선순위 서열 3에는 두 개의 피연산자를 필요로 하는 이항(binary) +-, 그리고 배열이나 문자열을 결합하는 & 연산자가 속합니다.

이항 +- (덧셈 및 뺄셈)

이항 +- 연산자는 모든 숫자 타입에 대해 우리가 일반적으로 알고 있는 덧셈과 뺄셈을 수행합니다.

  • 피연산자: 모든 숫자 타입
  • 결과 타입: 피연산자의 타입과 동일합니다.
  • 예시:
    Total_Cost   : constant Integer := Price + Tax;
    Remaining    : constant Float   := 100.0 - 25.5;
    

& (연결 연산자)

& 연산자는 두 피연산자를 연결하여 새로운 배열을 만드는 연결(concatenation) 연산자입니다. 주로 문자열을 결합하는 데 매우 유용하게 사용됩니다.

  • 피연산자: 배열 타입 또는 배열의 원소(component) 타입

  • 결과 타입: 피연산자와 동일한 배열 타입

  • 주요 사용 형태:

    1. 배열 & 배열: 두 배열을 연결하여 더 긴 배열을 만듭니다.
      Greeting : constant String := "Hello, " & "World!";
      -- 결과: "Hello, World!"
      
    2. 배열 & 원소: 배열의 끝에 원소 하나를 추가합니다.
      Path : constant String := "/home/user" & '/';
      -- 결과: "/home/user/"
      
    3. 원소 & 배열: 배열의 시작에 원소 하나를 추가합니다.
      Unit : constant String := '$' & "100.00";
      -- 결과: "$100.00"
      
    4. 원소 & 원소: 두 원소로 구성된 길이 2의 배열을 만듭니다.
      Pair : constant String := 'A' & 'B';
      -- 결과: "AB"
      

& 연산자는 불변(immutable)하는 Ada의 String 타입을 다룰 때, 새로운 문자열을 동적으로 생성하는 가장 기본적인 도구입니다.

6.3.5 관계, 멤버십, 논리 연산자

프로그램의 흐름을 제어하는 if문이나 while문과 같은 조건문에는 반드시 Boolean 타입의 결과가 필요합니다. 지금부터 배울 연산자들은 바로 이 Boolean 값을 만들어내는 데 특화된 연산자들입니다.

관계 연산자 (=, /=, <, <=, >, >=)

우선순위 서열 2에 속하는 관계(relational) 연산자는 두 피연산자의 값을 비교하여 그 관계가 참인지 거짓인지를 반환합니다.

  • 피연산자: 같은 스칼라 타입 (정수, 실수, 열거형 등)
  • 결과 타입: Boolean
  • 종류:
    • =: 같음
    • /=: 같지 않음
    • <: 작음
    • <=: 작거나 같음
    • >: 큼
    • >=: 크거나 같음
  • 예시:
    Is_Equal : constant Boolean := (A = B);
    Is_Greater : constant Boolean := (Count > 100);
    

멤버십 연산자 (in, not in)

관계 연산자와 같은 서열 2에 속하는 멤버십(membership) 연산자는 특정 값이 주어진 범위나 서브타입에 속하는지를 검사합니다.

  • 피연산자: 왼쪽은 스칼라 값, 오른쪽은 범위 또는 서브타입 이름
  • 결과 타입: Boolean
  • 종류:
    • in: 범위에 속하면 True
    • not in: 범위에 속하지 않으면 True
  • 예시:
    subtype Weekend is Day range Saturday .. Sunday;
    Today : Day := Sunday;
    
    Is_Weekend : constant Boolean := Today in Weekend; -- 결과: True
    Is_Alphabetic : constant Boolean := My_Char in 'a' .. 'z';
    Is_Out_Of_Range : constant Boolean := Value not in 0 .. 99;
    

논리 연산자 (and, or, xor)

가장 낮은 우선순위 서열(level 1)에 속하는 논리(logical) 연산자는 두 Boolean 피연산자를 조합하여 새로운 Boolean 값을 만듭니다.

  • 피연산자: Boolean 타입
  • 결과 타입: Boolean
  • 종류:
    • and: 두 피연산자가 모두 True일 때만 True
    • or: 두 피연산자 중 하나라도 True이면 True
    • xor (Exclusive OR): 두 피연산자가 서로 다를 때(True/False)만 True
  • 예시:
    Has_Permission : Boolean;
    Is_Logged_In : Boolean;
    ...
    Can_Access : constant Boolean := Has_Permission and Is_Logged_In;
    

이 논리 연산자들은 Boolean 배열이나 모듈러 타입에 사용될 경우, 비트 단위(bitwise) 연산을 수행하는 데도 사용됩니다.

6.4 단축 평가: 불필요한 연산의 생략

일반적인 논리 연산자 andor는 항상 양쪽의 피연산자를 모두 평가하여 결과를 결정합니다. 하지만 많은 경우, 첫 번째 조건의 결과만으로 전체 논리식의 결과가 이미 확정될 수 있습니다. 예를 들어 A and B에서 AFalse라면 B의 값과 상관없이 전체는 False가 됩니다.

이러한 상황에서 불필요한 평가를 생략하여 프로그램의 안전성과 효율성을 높이는 기법을 단축 평가(short-circuit evaluation)라고 합니다.

Ada는 이 단축 평가를 위해 and thenor else라는 특별한 제어 구문(control form)을 제공합니다. 이들은 연산자라기보다는 프로그램의 실행 흐름을 제어하는 역할을 하며, 조건부 논리를 구성할 때 and, or보다 우선적으로 사용해야 하는 매우 중요한 도구입니다.

이어지는 절에서는 이 두 제어 구문이 각각 어떤 상황에서 사용되어 런타임 오류를 방지하고 성능을 최적화하는지 자세히 살펴보겠습니다.

6.4.1 and then: 단축 평가 AND

and then 제어 구문은 논리적인 AND 연산을 수행하지만, 왼쪽 피연산자의 결과에 따라 오른쪽 피연산자의 평가 여부를 결정하는 조건부 로직을 가지고 있습니다. 이 단축 평가 기능은 프로그램의 안전성과 효율성을 모두 향상시키는 데 사용됩니다.

A and then B 표현식의 평가 과정은 다음과 같습니다.

  1. 컴파일러는 먼저 왼쪽 피연산자인 A를 평가합니다.
  2. 만약 A의 결과가 False이면, 전체 표현식의 결과는 이미 False로 확정됩니다. 따라서 오른쪽 피연산자인 B는 아예 평가(실행)하지 않고 즉시 False를 반환합니다.
  3. 오직 A의 결과가 True일 경우에만 B를 평가하고, 그 B의 결과를 전체 표현식의 최종 결과로 사용합니다.

이와 달리, 일반 and 연산자는 항상 AB를 모두 평가합니다.

주요 활용 사례

and then의 단축 평가 기능은 두 가지 중요한 목적으로 활용됩니다.

1. 런타임 오류 방지 (안전성)

and then의 가장 중요하고 강력한 용도는 잠재적인 런타임 오류를 원천적으로 방지하는 것입니다. 특정 연산이 수행되기 전에, 그 연산이 안전한지 먼저 검사하는 로직을 하나의 문장에 간결하고 안전하게 담을 수 있습니다.

  • 예시: Null 포인터 접근 방지
    -- Ptr이 null이면, and then 뒤의 Ptr.all은 평가되지 않으므로 안전함
    if Ptr /= null and then Ptr.all.Data > 0 then
       -- ...
    end if;
    
  • 예시: 0으로 나누기 방지
    if Divisor /= 0 and then (Value / Divisor) > 1 then
       -- ...
    end if;
    

2. 성능 최적화 (효율성)

비용이 적게 드는(cheap) 빠른 검사를 왼쪽에 배치하고, 비용이 많이 드는(expensive) 복잡한 검사를 오른쪽에 배치하여 불필요한 연산을 생략할 수 있습니다.

-- Is_Quick_Check가 False이면, 비용이 큰 Long_Running_Function은 호출되지 않음
if Is_Quick_Check and then Long_Running_Function (Data) then
   -- ...
end if;

이처럼 and then은 코드를 간결하게 유지하면서도 프로그램의 안정성과 효율성을 크게 높여주는 필수적인 도구입니다. 두 조건 사이에 선후 관계나 비용 차이가 있는 논리를 구성할 때는, 일반 and 대신 and then을 사용하는 것이 표준적인 관례(idiom)입니다.

6.4.2 or else: 단축 평가 OR

or else 제어 구문은 and then의 반대 논리를 제공하는 단축 평가 OR 연산입니다. 이 구문 역시 왼쪽 피연산자의 결과에 따라 오른쪽 피연산자의 평가 여부를 결정하여, 프로그램의 효율성과 논리적 흐름 제어에 기여합니다.

A or else B 표현식의 평가 과정은 다음과 같습니다.

  1. 컴파일러는 먼저 왼쪽 피연산자인 A를 평가합니다.
  2. 만약 A의 결과가 True이면, 전체 표현식의 결과는 이미 True로 확정됩니다. 따라서 오른쪽 피연산자인 B는 아예 평가(실행)하지 않고 즉시 True를 반환합니다.
  3. 오직 A의 결과가 False일 경우에만 B를 평가하고, 그 B의 결과를 전체 표현식의 최종 결과로 사용합니다.

이와 달리, 일반 or 연산자는 항상 AB를 모두 평가합니다.

주요 활용 사례

or else의 단축 평가 기능은 주로 다음과 같은 목적으로 활용됩니다.

1. 성능 최적화 (효율성)

or else의 가장 대표적인 용도는 비용이 많이 드는(expensive) 연산을 불필요하게 실행하는 것을 방지하여 성능을 최적화하는 것입니다. 간단하고 빠른 검사를 왼쪽에 배치하여, 조건이 만족되면 굳이 복잡한 함수를 호출하거나 어려운 계산을 수행할 필요가 없습니다.

  • 예시: 복잡한 유효성 검사 생략 사용자가 관리자(Is_Admin)이거나, 또는 복잡한 권한 검사 함수(Has_Sufficient_Permissions)를 통과했는지 확인하는 경우를 생각해 보겠습니다.

    -- Is_Admin이 True이면, 비용이 큰 Has_Sufficient_Permissions 함수는 호출되지 않음.
    if Is_Admin or else Has_Sufficient_Permissions (User_ID) then
       Grant_Access;
    end if;
    

2. 논리적 흐름 제어

어떤 조건이 이미 충족되었을 때, 대안적인 다른 조건을 굳이 검사할 필요가 없는 논리적 흐름을 구성하는 데 사용됩니다.

  • 예시: 기본값 또는 캐시된 값 사용 어떤 설정값이 이미 유효한지(Is_Config_Valid) 먼저 확인하고, 그렇지 않을 경우에만 기본 설정으로 복원하는 함수(Restore_Defaults)를 호출하여 성공 여부를 확인할 수 있습니다.

    -- 설정이 이미 유효하면, Restore_Defaults 함수는 호출되지 않음.
    if Is_Config_Valid or else Restore_Defaults then
       Put_Line ("설정 확인 완료.");
    end if;
    

결론적으로 and then이 주로 프로그램의 안전성을 보장하는 데 중점을 둔다면, or else는 주로 효율성을 높이고 명확한 논리적 흐름을 구성하는 데 더 많이 사용됩니다. 조건부 논리를 구성할 때는 항상 일반 논리 연산자 대신 단축 평가 제어 구문을 우선적으로 사용하는 것이 바람직합니다.

6.5 타입 변환과 한정 표현식

Ada의 정적 타입 시스템은 서로 다른 타입 간의 직접적인 혼용을 허용하지 않는다. 하지만 프로그래밍에서는 명시적으로 타입을 변환하거나, 표현식의 타입을 컴파일러에게 명확히 지정해야 하는 경우가 발생한다.

이 절에서는 이러한 목적을 위해 사용되는 두 가지 구문인 타입 변환(Type Conversion)한정 표현식(Qualified Expression)을 다룬다. 타입 변환은 한 타입의 값을 기반으로 다른 타입의 새로운 값을 생성하는 기능이며, 한정 표현식은 값의 변경 없이 표현식의 타입을 명확히 규정하는 기능이다.

이어지는 절들에서 각 구문의 정확한 구문과 사용 사례를 학습한다.

6.5.1 명시적 타입 변환

Ada의 강력한 타입 시스템은 서로 다른 타입의 값이 혼용되는 것을 원칙적으로 금지하여 안정성을 높인다. 그러나 실제 프로그래밍에서는 한 타입의 값을 다른 타입으로 변환해야 할 필요가 발생한다. 예를 들어, 정수(Integer) 값을 실수(Float) 계산에 사용하거나, 사용자 정의 숫자 타입을 표준 숫자 타입으로 변환하는 경우이다.

이러한 경우를 위해 Ada는 명시적 타입 변환(Explicit Type Conversion) 구문을 제공한다. 이는 프로그래머의 의도를 코드에 명확히 드러내어, 한 타입의 값을 기반으로 다른 타입의 새로운 값을 생성하는 안전한 메커니즘이다.

구문

타입 변환은 변환하고자 하는 목표 타입을 함수처럼 사용하여 값을 감싸는 형태로 이루어진다.

Target_Type_Name (Expression)
  • Target_Type_Name: 변환하고자 하는 목표 타입의 이름
  • Expression: 변환할 값을 산출하는 표현식

동작 원리

타입 변환은 기존 값의 비트(bit) 표현을 그대로 유지하며 타입만 변경하는 것이 아니다. 대신, 표현식의 값을 평가한 후 그 값을 기반으로 목표 타입에 맞는 새로운 값을 생성한다. 이 과정에서 값의 내부적인 표현 방식이 완전히 달라질 수 있다.

  • 예시 1: 숫자 타입 간의 변환 Integer 값을 Float 값으로 변환하는 것은 가장 흔한 예이다.

    Count : Integer := 10;
    Total : Float   := 100.0;
    Average : Float;
    begin
       -- Total := Total + Count; -- (X) 컴파일 오류! Float와 Integer는 직접 더할 수 없음
    
       -- Count를 Float 타입으로 명시적으로 변환하여 계산
       Average := Total / Float(Count); -- 100.0 / 10.0 으로 평가됨
    end;
    

    위 예에서 Float(Count)Integer10을 기반으로 새로운 Float10.0을 생성하며, 이 값은 Total 과의 나눗셈에 사용될 수 있다.

  • 예시 2: 파생 타입 간의 변환 하나의 타입으로부터 파생된 다른 타입들 사이에서도 타입 변환이 가능하다.

    type Gram is new Integer;
    type Kilogram is new Integer;
    
    Weight_In_Grams : Gram := 1000;
    Weight_In_Kilograms : Kilogram;
    begin
       -- 두 타입은 부모가 같지만 서로 다른 타입이므로 직접 대입 불가
       -- Weight_In_Kilograms := Weight_In_Grams; -- (X) 컴파일 오류!
    
       -- Gram 타입의 값을 Integer로 변환 후, 다시 Kilogram 타입으로 변환
       Weight_In_Kilograms := Kilogram(Integer(Weight_In_Grams) / 1000);
    end;
    

타입 변환은 이처럼 밀접하게 관련된 타입(예: 숫자 타입, 동일한 부모에서 파생된 타입) 사이에서 주로 허용된다. 완전히 관련 없는 타입 간의 변환(예: Integer를 레코드 타입으로 변환)은 일반적으로 허용되지 않으며, 이는 언어의 타입 안정성을 유지하는 중요한 규칙이다.

6.5.2 한정 표현식 (Qualified Expressions)

때로는 표현식의 값이 무엇인지는 명확하지만, 그 표현식의 타입이 여러 가능성 중 하나로 해석될 수 있어 모호한 경우가 발생한다. 이는 특히 오버로딩된(overloaded) 서브프로그램이나 열거형 리터럴을 사용할 때 흔히 발생한다.

이러한 모호성을 해결하고 표현식의 타입을 컴파일러에게 명확하게 지정하기 위해 사용하는 구문이 바로 한정 표현식(Qualified Expression)이다.

중요한 점은, 한정 표현식은 타입 변환과 달리 값이나 그 내부 표현을 전혀 변경하지 않는다는 것이다. 이는 단지 “이 표현식의 타입은 이것이다”라고 컴파일러에 단언(assertion)하는 역할을 한다.

구문

한정 표현식은 목표 타입 이름 뒤에 아포스트로피(apostrophe, )를 붙이고, 괄호로 표현식을 감싸는 형태로 이루어진다.

Type_Name'(Expression)
  • Type_Name: 명확히 지정하고자 하는 타입의 이름
  • Expression: 타입을 한정할 대상 표현식

주요 사용 사례

1. 오버로딩된 열거형 리터럴의 모호성 해결

서로 다른 열거형 타입이 동일한 이름의 리터럴을 가질 수 있다. 이 경우, 해당 리터럴만 단독으로 사용하면 컴파일러는 어떤 타입의 리터럴을 의미하는지 알 수 없어 오류를 발생시킨다.

  • 예시:
    type Color is (Red, Green, Blue);
    type Traffic_Light is (Red, Amber, Green);
    
    -- Favorite_Color : Color := Red; -- (X) 컴파일 오류! 'Red'가 Color의 Red인지
                                    --     Traffic_Light의 Red인지 모호함
    
    -- 한정 표현식을 사용하여 타입을 명확히 지정
    Favorite_Color : Color := Color'(Red);
    Stop_Signal    : Traffic_Light := Traffic_Light'(Red);
    

    Color'(Red)는 “이 RedColor 타입의 Red이다”라고 컴파일러에게 알려준다.

2. 애그리게이트(Aggregate) 타입의 모호성 해결

배열이나 레코드의 값을 생성하는 애그리게이트는 그 형태만으로는 타입을 특정할 수 없을 때가 있다.

  • 예시:
    type Vector is array (1 .. 2) of Integer;
    type Point  is array (1 .. 2) of Integer;
    
    V : Vector;
    P : Point;
    begin
       -- (1, 2)라는 애그리게이트는 Vector 타입일 수도 있고, Point 타입일 수도 있음
       -- 이 모호성을 해결해야 함
       V := Vector'(1, 2);
       P := Point'(1, 2);
    end;
    

3. 오버로딩된 함수 반환 타입의 명확화

동일한 이름과 매개변수를 가지지만 반환 타입만 다른 함수들이 오버로딩된 경우, 함수의 호출 결과를 어떤 타입의 변수에 담을지 명확히 해야 할 때 사용될 수 있다.

이처럼 한정 표현식은 컴파일러가 타입 추론에 실패하는 모호한 상황을 해결하여, 프로그래머의 의도를 정확하게 전달하고 코드의 명확성을 높이는 필수적인 도구이다.

6.6 조건 표현식 (Conditional Expressions)

프로그램의 흐름을 제어하는 조건문(if, case) 외에, Ada는 조건에 따라 값을 결정하는 조건 표현식(Conditional Expression) 구문을 제공한다.

조건문이 특정 조건에 따라 다른 동작(action)을 수행하는 반면, 조건 표현식은 특정 조건에 따라 다른 값(value)을 산출한다는 근본적인 차이가 있다. 표현식은 평가되었을 때 반드시 하나의 값을 반환해야 하기 때문이다.

이 기능은 변수 선언 시 초기값을 결정하거나, 대입문에서 조건에 따라 다른 값을 할당할 때 코드를 매우 간결하고 명확하게 작성할 수 있게 한다.

이어지는 절에서는 if 표현식과 case 표현식의 구문과 사용법을 알아본다.

6.6.1 if 표현식

if 표현식은 Boolean 조건에 따라 여러 값 중 하나를 선택하여 반환하는 가장 기본적인 조건 표현식입니다.

구문

기본적인 if 표현식은 if-then-else 구조를 가지며, 전체가 하나의 표현식이므로 괄호()로 묶여야 합니다. if 문(statement)과 달리 end if는 사용되지 않으며, else 부분은 반드시 있어야 합니다. 모든 경로에서 반드시 하나의 값이 반환되어야 하기 때문입니다.

(if Condition then Expression1 else Expression2)

if 문과의 비교

if 표현식의 가치는 기존의 if 문과 비교했을 때 명확히 드러납니다.

  • if 문을 사용한 경우: 조건에 따라 변수에 다른 값을 할당하기 위해 여러 줄의 코드가 필요합니다.

    Max_Value : Integer;
    -- ...
    if A > B then
       Max_Value := A;
    else
       Max_Value := B;
    end if;
    
  • if 표현식을 사용한 경우: 동일한 논리를 단 한 줄의 대입문으로 간결하게 표현할 수 있습니다.

    Max_Value : Integer;
    -- ...
    Max_Value := (if A > B then A else B);
    

    이 방식은 특히 상수를 선언과 동시에 조건에 따라 초기화할 때 유용합니다.

    MAX_VALUE : constant Integer := (if A > B then A else B);
    

elsif 와 다중 조건 처리

if 표현식 자체에는 elsif 키워드가 없지만, else 부분에 또 다른 if 표현식을 연달아 사용하는 방식으로 다중 조건을 처리할 수 있습니다.

Grade : constant Character :=
  (if Score >= 90 then 'A'
   else if Score >= 80 then 'B'
   else if Score >= 70 then 'C'
   else 'F');

중요 규칙

if 표현식에서 thenelse 부분에 오는 모든 표현식들의 결과 타입은 서로 호환 가능해야 합니다. 즉, 모두 같은 타입이거나 공통의 상위 타입으로 변환이 가능해야 합니다. 이는 if 표현식 전체가 단 하나의 정적인 타입을 가져야 한다는 규칙 때문입니다.

6.6.2 case 표현식

case 표현식은 하나의 값을 기준으로 여러 선택지 중 하나를 골라 해당하는 값을 반환하는 조건 표현식입니다. 이는 if 표현식이 Boolean 조건에 기반하는 것과 달리, 이산적인(discrete) 값(예: 정수, 열거형)의 일치 여부를 기준으로 동작합니다.

구문

case 표현식은 case-is 구조를 가지며, 전체가 하나의 표현식이므로 괄호()로 묶여야 합니다. case 문(statement)과 달리 end case는 사용되지 않습니다.

(case Selector is
   when Choice_1 => Expression_1,
   when Choice_2 => Expression_2,
   ...
   when others   => Expression_Others)

case 문과의 비교

case 표현식은 case 문을 사용하여 변수에 값을 할당하는 코드를 단 하나의 표현식으로 간결하게 만들어 줍니다.

  • case 문을 사용한 경우:

    Symbol : Character;
    -- ...
    case Today is
       when Monday .. Friday =>
          Symbol := '-';
       when Saturday | Sunday =>
          Symbol := '*';
    end case;
    
  • case 표현식을 사용한 경우: 동일한 논리를 상수를 선언하면서 동시에 초기화하는 간결한 코드로 작성할 수 있습니다.

    SYMBOL : constant Character :=
      (case Today is
         when Monday .. Friday => '-',
         when Saturday | Sunday => '*');
    

when others의 필수성

표현식은 어떤 경우에도 반드시 하나의 값을 반환해야 합니다. 따라서 case 표현식에서는 선택자(selector)가 가질 수 있는 모든 가능한 값을 when 절에서 반드시 포함해야 합니다.

실질적으로 이 규칙을 만족시키기 위해, when others 절은 거의 항상 필수적입니다. when others는 앞선 when 절에서 다루지 않은 모든 나머지 경우를 포괄하는 역할을 합니다. 이를 생략하면 컴파일러는 모든 가능한 값을 다루었는지 증명할 수 없어 대부분의 경우 컴파일 오류를 발생시킵니다.

  • 예시: 열거형을 문자열로 변환
    type Status is (Idle, Working, Paused, Done);
    Current_Status : Status := Working;
    
    Status_String : constant String :=
      (case Current_Status is
         when Idle    => "대기 중",
         when Working => "작업 중",
         when Paused  => "일시 중지",
         when Done    => "완료됨");
    

    위 예제는 Status 타입의 모든 값(Idle, Working, Paused, Done)을 when 절에서 명시적으로 다루었기 때문에 when others 없이도 컴파일이 가능합니다. 하지만 만약 when Done 절이 빠졌다면, 컴파일러는 Done의 경우에 반환할 값이 없다고 판단하여 오류를 발생시킬 것입니다. 따라서 안전하게 when others를 사용하는 것이 일반적입니다.

case 표현식 역시 if 표현식과 마찬가지로, 모든 when 절의 표현식 결과 타입이 서로 호환 가능해야 한다는 규칙을 따릅니다.

6.7 정량화 표현식 (Quantified Expressions)

정량화 표현식은 배열이나 특정 범위의 모든 또는 일부 원소가 주어진 조건을 만족하는지를 검사하여 Boolean 값을 반환하는 기능입니다.

이 표현식은 전통적인 반복문을 사용하여 여러 줄에 걸쳐 작성해야 했던 논리 검증 코드를 단 하나의 간결한 표현식으로 대체할 수 있게 합니다. 또한, “배열의 모든 원소가 양수인가?” 또는 “범위 내에 짝수가 하나라도 있는가?”와 같은 프로그래머의 의도를 코드에 직접적으로 표현하여 가독성을 크게 향상시킵니다.

정량화 표현식에는 for allfor some의 두 가지 형태가 있습니다. 이어지는 절에서 각각의 구문과 사용법을 알아봅니다.

6.7.1 for all

for all 표현식은 지정된 배열이나 이산적인 범위(discrete range)의 모든 원소가 주어진 Boolean 조건을 만족할 때만 True를 반환합니다.

이 표현식은 단축 평가(short-circuit) 방식으로 동작합니다. 즉, 조건을 만족하지 않는 원소를 하나라도 발견하는 즉시 평가를 멈추고 최종 결과로 False를 반환합니다. 모든 원소를 끝까지 검사하고 나서야 모든 원소가 조건을 만족한다고 판단하여 True를 반환합니다.

구문

for all 표현식은 배열을 대상으로 하거나, 특정 범위를 대상으로 할 수 있습니다.

  • 배열 대상:

    (for all Element of Array_Name => Predicate)
    
  • 범위 대상:

    (for all Value in Discrete_Range => Predicate)
    
    • Predicate: 검사할 Boolean 조건식입니다. ElementValue는 해당 반복의 현재 원소를 가리키는 임시 상수의 이름입니다.

사용 예시

for all 표현식의 가치는 전통적인 반복문과 비교했을 때 명확히 드러납니다.

  • 예시: 배열의 모든 원소가 0보다 큰지 검사

    1. 반복문을 사용한 경우 배열의 모든 원소가 양수인지 확인하려면, 플래그 변수를 하나 두고 반복문을 돌면서 조건에 맞지 않는 경우를 찾아야 합니다.

    A : constant array(1 .. 5) of Integer := (10, 20, 30, 40, 50);
    All_Positive_Flag : Boolean := True;
    begin
       for I in A'Range loop
          if A(I) <= 0 then
             All_Positive_Flag := False;
             exit; -- 더 검사할 필요 없이 반복 종료
          end if;
       end loop;
       -- All_Positive_Flag는 True가 됨
    

    2. for all 표현식을 사용한 경우 동일한 논리를 단 하나의 간결하고 명확한 표현식으로 대체할 수 있습니다.

    A : constant array(1 .. 5) of Integer := (10, 20, 30, 40, 50);
    
    All_Positive : constant Boolean := (for all X of A => X > 0);
    

    이 코드는 “A의 모든 원소 X에 대하여 X는 0보다 크다”라는 논리를 직접적으로 표현하므로, 코드의 의도를 파악하기가 훨씬 용이합니다.

6.7.2 for some

for some 표현식은 for all과 반대되는 논리를 제공합니다. 이 표현식은 지정된 배열이나 이산적인 범위(discrete range)의 원소 중 단 하나라도 주어진 Boolean 조건을 만족하면 True를 반환합니다.

이 표현식 역시 단축 평가(short-circuit) 방식으로 동작합니다. 즉, 조건을 만족하는 원소를 하나라도 발견하는 즉시 평가를 멈추고 최종 결과로 True를 반환합니다. 모든 원소를 끝까지 검사했음에도 불구하고 조건을 만족하는 원소를 찾지 못했을 때만 False를 반환합니다.

구문

for some 표현식의 구문은 for all과 매우 유사합니다.

  • 배열 대상:

    (for some Element of Array_Name => Predicate)
    
  • 범위 대상:

    (for some Value in Discrete_Range => Predicate)
    

사용 예시

  • 예시: 배열에 짝수가 하나라도 포함되어 있는지 검사

    1. 반복문을 사용한 경우 배열에 짝수가 존재하는지 확인하려면, 플래그 변수를 하나 두고 반복문을 돌면서 짝수를 찾는 즉시 반복을 중단해야 합니다.

    A : constant array(1 .. 5) of Integer := (1, 3, 5, 8, 9);
    Has_Even_Flag : Boolean := False;
    begin
       for I in A'Range loop
          if A(I) mod 2 = 0 then
             Has_Even_Flag := True;
             exit; -- 짝수를 찾았으므로 더 검사할 필요 없음
          end if;
       end loop;
       -- Has_Even_Flag는 True가 됨
    

    2. for some 표현식을 사용한 경우 동일한 논리를 단 하나의 간결하고 의도가 명확한 표현식으로 대체할 수 있습니다.

    A : constant array(1 .. 5) of Integer := (1, 3, 5, 8, 9);
    
    Has_Even : constant Boolean := (for some X of A => X mod 2 = 0);
    

    이 코드는 “A의 어떤 원소 X에 대하여 X는 짝수이다”라는 논리를 직접적으로 표현하여, 코드의 의도를 파악하기가 훨씬 용이합니다.

6.8 [심화] 표현식 함수 (Expression Functions)

Ada 2012부터 도입된 표현식 함수(Expression Function)는 함수의 본문을 begin-end 블록 없이 단 하나의 표현식으로 정의할 수 있게 하는 간결한 구문입니다. 함수의 로직이 간단한 계산이나 값의 반환으로 이루어진 경우, 이 기능을 사용하면 불필요한 코드를 줄이고 가독성을 크게 향상시킬 수 있습니다.

구문

기존의 함수 정의 방식과 표현식 함수를 비교하면 그 차이가 명확합니다.

  • 전통적인 함수 구문 begin, return, end 키워드를 사용하여 완전한 블록 구조를 가집니다.
    function Square (X : Integer) return Integer is
    begin
       return X * X;
    end Square;
    
  • 표현식 함수 구문 함수의 is 키워드 뒤에 괄호()로 감싼 표현식을 바로 기술합니다. begin이나 return 키워드가 사용되지 않습니다.
    function Square (X : Integer) return Integer is (X * X);
    

장점 및 활용

  1. 코드의 간결성: 본문이 한 줄짜리 return 문으로 구성된 간단한 함수를 매우 간결하게 표현할 수 있습니다. 이는 코드의 양을 줄여 핵심 로직에 더 집중할 수 있게 합니다.

  2. 가독성 향상: 함수가 수행하는 작업이 선언부에서 바로 확인되므로, 코드의 의도를 파악하기가 더 용이합니다.

  3. 다양한 표현식과의 결합: 표현식 함수는 이 장에서 배운 다른 표현식들(조건 표현식, 정량화 표현식 등)과 결합하여 강력한 기능을 간결하게 구현할 수 있습니다.

    • 예시 1: 두 값 중 최댓값 반환 if 표현식을 사용하여 두 값 중 더 큰 값을 반환하는 함수를 한 줄로 정의할 수 있습니다.
      function Max (A, B : Integer) return Integer is (if A > B then A else B);
      
    • 예시 2: 짝수 여부 확인 mod 연산의 결과를 직접 반환하는 Boolean 함수를 정의합니다.
      function Is_Even (Value : Integer) return Boolean is (Value mod 2 = 0);
      

표현식 함수는 전제조건(Pre)이나 후제조건(Post)과 같은 애스펙트(aspect)와도 함께 사용될 수 있어, 간결함과 계약 기반 설계의 장점을 모두 취할 수 있습니다. 이 기능은 복잡한 로직보다는, 상태 조회나 간단한 계산을 수행하는 작은 함수들을 정의할 때 특히 유용합니다.

7. 제어 구조

(도입부)

7.1 조건문 (conditional statements)

프로그램이 실행되는 과정에서 특정 조건의 참(true) 또는 거짓(false) 여부에 따라 실행 경로를 동적으로 결정해야 하는 경우가 빈번하게 발생합니다. 예를 들어, 사용자의 입력 값이 특정 범위 내에 있는지 확인하거나, 시스템의 현재 상태에 따라 다른 동작을 수행해야 할 수 있습니다. 이처럼 조건에 기반한 결정을 내릴 때 사용하는 구문을 조건문 (conditional statements)이라고 합니다.

Ada는 두 가지 주요 조건문을 제공합니다.

  1. if: 가장 보편적인 조건문으로, 하나 이상의 논리적 조건을 평가하여 실행할 코드 블록을 선택합니다. 복잡한 논리 관계를 표현하는 데 유용합니다.
  2. case: 단일 변수나 표현식의 값이 여러 가능한 값 중 하나와 일치하는 경우를 검사할 때 사용됩니다. if 문으로 복잡하게 표현될 수 있는 다중 분기 조건을 간결하고 명확하게 만들어 줍니다.

이번 절에서는 이 두 가지 조건문의 정확한 구문, 의미, 그리고 각각의 문법이 어떤 상황에서 가장 효과적으로 사용될 수 있는지에 대해 상세히 탐구할 것입니다. 올바른 조건문을 선택하는 것은 코드의 가독성과 유지보수성을 향상시키는 중요한 요소입니다.

7.1.1 if

if 문은 프로그램의 실행 흐름을 조건에 따라 분기시키는 가장 핵심적인 제어 구조입니다. 주어진 불리언 표현식(Boolean Expression)을 평가하여 그 결과에 따라 특정 코드 블록의 실행 여부를 결정합니다. Ada의 if 문은 end if;로 블록의 끝을 명시적으로 닫아주어야 하므로, 코드의 범위가 명확하고 구조적 모호성이 없습니다.

기본 구조와 다중 조건 처리

if 문은 elseelsif 절을 사용하여 단순한 조건 분기부터 복잡한 다중 조건 처리까지 구성할 수 있습니다.

  • 기본 if-then 구조: 조건이 True일 경우에만 코드를 실행합니다.

    if <condition> then
      <sequence_of_statements>
    end if;
    
  • else: if 조건이 False일 때 실행될 대체 경로를 제공합니다.

    if user_age >= 19 then
      put_line ("✅ Access granted.");
    else
      put_line ("❌ Access denied.");
    end if;
    
  • elsif: 여러 조건을 순차적으로 검사할 때 사용합니다. if 또는 이전 elsif 조건이 False일 경우, 다음 elsif 조건을 평가합니다. 이는 불필요한 if 중첩을 피하게 하여 코드를 평탄하고 읽기 쉽게 만듭니다.

    procedure evaluate_score (score : in Integer) is
    begin
      if score >= 90 then
        put_line ("Grade: A");
      elsif score >= 80 then
        put_line ("Grade: B");
      elsif score >= 70 then
        put_line ("Grade: C");
      else
        put_line ("Grade: F");
      end if;
    end evaluate_score;
    

논리 연산자와 단락 평가 (short-circuit evaluation)

복잡한 조건은 and, or 같은 논리 연산자로 구성할 수 있습니다. 이때, Ada는 안정성과 효율성을 위해 단락 평가(short-circuit evaluation)를 수행하는 and thenor else를 제공합니다.

연산자 동작 주요 용도
A and then B AFalse이면 B평가하지 않음. 🚨 오류 방지 (e.g., 널 포인터 검사)
A or else B ATrue이면 B평가하지 않음. ⚡️ 성능 최적화 (비용이 큰 연산 방지)

단락 평가는 불필요한 연산을 방지할 뿐만 아니라, 특정 조건 하에서만 유효한 연산을 안전하게 수행하도록 보장하는 데 매우 중요합니다.

코드 예시: and then을 사용한 안전한 포인터 접근

널 포인터(null pointer)를 역참조(dereference)하는 런타임 오류를 방지하는 것은 매우 중요합니다.

with Interfaces.C.Strings;

procedure safe_string_check (c_string : in Interfaces.C.Strings.chars_ptr) is
begin
  -- 1. 포인터가 널이 아닌지 먼저 확인
  -- 2. 널이 아닐 경우에만, 문자열이 끝났는지 확인
  if c_string /= Interfaces.C.Strings.NULL_PTR and then
     Interfaces.C.Strings.Is_Terminated (c_string)
  then
    put_line ("✅ The C string is valid and null-terminated.");
  else
    put_line ("❌ The string pointer is null or the string is not terminated.");
  end if;
end safe_string_check;

만약 위 코드에서 and를 사용했다면, c_stringNULL_PTR일 경우에도 Is_Terminated 함수를 호출하여 런타임 오류가 발생할 것입니다. and then을 사용하면 첫 번째 조건이 False일 때 두 번째 조건은 평가되지 않으므로 프로그램의 안정성이 보장됩니다.

7.1.2 case

case 문은 단일 표현식의 값에 따라 실행 경로를 선택하는 다중 분기 제어 구조입니다. 이는 여러 elsif 절을 사용하는 긴 if 문을 대체할 수 있는 더 명확하고 구조적인 대안을 제공합니다. case 문은 특히 이산 타입 (Discrete Type)—정수, 문자, 또는 열거 타입—과 함께 사용될 때 코드의 가독성과 안정성을 크게 향상시킵니다.

기본 구조 및 구문

case 문의 핵심은 이산 타입 표현식을 평가하고, 그 값이 when 절에 명시된 선택지 중 하나와 일치하는 경우 해당 코드 블록을 실행하는 것입니다.

  • 구문 (syntax):
    case <expression> is
      when <choice_1> =>
        <sequence_of_statements_1>
      when <choice_2> =>
        <sequence_of_statements_2>
      ...
      when others =>
        <sequence_of_statements_others>
    end case;
    
  • 구성 요소:
    • <expression>: 이산 타입으로 평가되는 표현식입니다.
    • when <choice> =>: <expression>의 값과 비교할 선택지를 지정합니다. 각 when 절은 하나 이상의 실행문을 가질 수 있습니다.
    • end case;: case 문의 끝을 알리는 필수 종료자입니다.

선택지 지정 방법

when 절의 선택지(<choice>)는 여러 형태로 지정하여 코드를 단순화할 수 있습니다.

  1. 단일 값: 하나의 특정 값으로 분기합니다. (when 200 => ...)
  2. 범위 (..): 연속적인 값의 범위를 지정합니다. (when 90 .. 100 => ...)
  3. 대안 (|): 여러 개의 비연속적인 값을 | (수직선) 문자로 묶어 지정합니다. (when 'a' | 'e' | 'i' | 'o' | 'u' => ...)

이러한 형태들은 서로 자유롭게 조합하여 사용할 수도 있습니다. (when 1 | 3 .. 5 => ...)

when others 절의 역할

when others 절은 case 문의 안정성을 보장하는 핵심적인 안전장치입니다. 이 절은 다른 when 절에서 명시적으로 다루지 않은 모든 가능한 값을 처리하는 기본 경로(default path) 역할을 합니다.

Ada 컴파일러는 case 문이 표현식 타입의 모든 가능한 값을 처리하는지 정적 검사(Static Checking)를 수행합니다. 만약 모든 값을 명시적으로 나열하지 않았다면, when others 절은 반드시 포함되어야 합니다. 이를 위반하면 컴파일 오류가 발생합니다. 이 규칙은 처리되지 않은 값으로 인해 발생할 수 있는 런타임 오류(Constraint_Error)를 원천적으로 방지합니다.

case 문 활용 예시

예시 1: 열거 타입을 사용한 case

열거 타입을 사용하면 모든 경우의 수를 명시적으로 나열할 수 있으며, 이 경우 when others가 필요 없어집니다.

package Application_Modes is
  type Mode is (Normal, Maintenance, Standby, Error);
end Application_Modes;

with Application_Modes; use Application_Modes;
procedure set_application_mode (new_mode : in Mode) is
begin
  case new_mode is
    when Normal =>
      put_line ("Switching to Normal operation. ⚙️");
    when Maintenance =>
      put_line ("Entering Maintenance mode. 🛠️");
    when Standby =>
      put_line ("Entering Standby mode. 😴");
    when Error =>
      put_line ("Error state detected! 🚨");
  end case;
  -- Mode 타입의 모든 값이 처리되었으므로 'when others'는 불필요합니다.
end set_application_mode;

예시 2: 정수 타입을 사용한 case

Integer와 같이 넓은 범위를 갖는 타입을 사용할 경우, when others 절이 필수적입니다.

procedure handle_http_status (status_code : in Positive) is
begin
  case status_code is
    when 200 =>
      put_line ("✅ OK: Request succeeded.");
    when 201 .. 299 =>
      put_line ("✅ Success: Acknowledged or created.");
    when 400 | 401 | 403 =>
      put_line ("❌ Client Error: Check your request.");
    when 404 =>
      put_line ("❌ Client Error: Not Found.");
    when 500 .. 599 =>
      put_line ("🔥 Server Error: Something went wrong on our end.");
    when others =>
      put_line ("❓ Received an unhandled status code: " & status_code'image);
  end case;
end handle_http_status;

7.1.3 if 문과 case 문의 선택 기준

특징 case if
분기 기준 단일 이산 타입 표현식 복잡한 논리 조건
주요 장점 가독성, 컴파일러의 완전성 검사, 최적화 가능성 유연성, 범용성, 단락 평가(and then/or else)
사용 시나리오 상태 코드 처리, 메뉴 옵션 선택 등 여러 변수 비교, 비-이산 타입(Float, String) 비교

결론적으로, “분기 조건이 단일 이산 값에 기반한다면 case 문을 우선적으로 고려하고, 그 외 모든 복잡한 조건에는 if 문을 사용한다” 는 것이 가장 명확한 선택 기준입니다.

7.2 반복문 (loop statements)

프로그래밍에서 특정 작업을 여러 번 반복해야 하는 경우는 매우 흔합니다. 예를 들어, 배열의 모든 요소를 처리하거나, 특정 조건이 만족될 때까지 사용자 입력을 기다리거나, 파일의 끝에 도달할 때까지 데이터를 읽는 작업 등이 있습니다. 반복문 (loop statements)은 이처럼 코드의 특정 블록을 반복적으로 실행하기 위한 제어 구조입니다.

Ada는 명확성과 제어력을 중시하는 언어의 설계 철학에 따라, 각기 다른 상황에 최적화된 세 가지 종류의 반복문을 제공합니다. 올바른 반복문을 선택하는 것은 코드의 의도를 명확히 하고, 잠재적인 오류(예: 무한 루프)를 방지하며, 프로그램의 논리를 간결하게 표현하는 데 도움이 됩니다.

이번 절에서는 다음과 같은 Ada의 주요 반복문에 대해 학습합니다.

  1. 기본 loop: 가장 유연한 형태의 반복문으로, exit 조건을 통해 반복을 제어합니다. 무한 루프나 복잡한 종료 조건을 가진 루프를 구성하는 데 사용됩니다.
  2. while 루프: 반복을 시작하기 전에 조건을 검사하여, 조건이 참인 동안에만 반복을 계속합니다. 반복 횟수를 미리 알 수 없는 상황에 적합합니다.
  3. for 루프: 지정된 이산 범위(discrete range)나 컨테이너의 요소들을 순회하며 정해진 횟수만큼 반복합니다. 가장 구조적이고 예측 가능한 형태의 반복문입니다.

각 반복문의 구문, 동작 방식, 그리고 어떤 상황에서 가장 효과적으로 사용될 수 있는지를 상세히 탐구하여, 효율적이고 신뢰성 높은 코드를 작성하는 능력을 기를 것입니다.

7.2.1 loop 문 (기본 루프)

가장 기본적인 loop 문은 Ada에서 가장 유연한 반복 구조입니다. 이 구문 자체는 명시적인 종료 조건 없이 무한히 반복하는 루프를 생성하며, 루프의 제어는 전적으로 개발자가 명시하는 탈출 구문에 의해 이루어집니다.

기본 구조와 무한 루프

기본 loop 문의 구문은 loop 키워드로 시작하여 end loop;로 끝나는 단순한 형태입니다. 내부에 탈출 조건이 없다면 이 블록은 영원히 반복됩니다.

  • 구문 (syntax):

    loop
      <sequence_of_statements>
    end loop;
    

의도적인 무한 루프는 임베디드 시스템의 메인 스케줄러, 항상 요청을 대기해야 하는 서버의 리스너(listener) 등과 같이 프로그램의 수명 동안 지속적으로 실행되어야 하는 작업에 사용될 수 있습니다.

exit를 이용한 조건부 탈출

루프를 정상적으로 종료시키기 위해서는 반드시 탈출 구문이 필요합니다. Ada는 이를 위해 exit 문을 제공하며, 일반적으로 exit when 형태가 가장 널리 사용됩니다.

  • exit when: 가장 보편적이고 구조적인 탈출 방식입니다. when 뒤의 조건이 True로 평가될 때 즉시 루프를 종료합니다.

    코드 예시 4-18: exit when을 사용한 반복

    procedure count_to_five is
      counter : Integer := 1;
    begin
      loop
        put_line ("Current count: " & counter'image);
    
        -- counter가 5에 도달하면 루프를 탈출합니다.
        exit when counter = 5;
    
        counter := counter + 1;
      end loop;
    
      put_line ("Loop finished.");
    end count_to_five;
    
  • ifexit: if 문 내에서 조건 없이 exit를 사용하여 탈출할 수도 있습니다. 이는 exit when과 기능적으로 동일하지만, 코드가 더 길어집니다.

    -- 위의 'exit when'과 동일한 로직
    if counter = 5 then
      exit;
    end if;
    

루프 이름(loop naming)을 이용한 중첩 루프 제어

여러 루프가 중첩된 상황에서 안쪽 루프의 exit 문은 기본적으로 가장 안쪽의 루프 하나만 탈출합니다. 이때 바깥쪽 루프까지 한 번에 탈출해야 한다면 루프 이름을 사용해야 합니다.

루프 이름은 <<loop_name>> 또는 loop_name : 형태로 선언할 수 있으며, exit loop_name when ...; 구문을 통해 어떤 루프를 탈출할지 명확하게 지정할 수 있습니다.

  • 코드 예시 4-19: 중첩 루프에서 특정 값 찾기

    아래 코드는 2차원 배열에서 특정 값을 찾으면 모든 루프를 즉시 종료하는 예시입니다.

    procedure find_value_in_matrix (matrix : in Matrix_Type; value_to_find : in Integer) is
    begin
      Search_loop: -- 외부 루프에 이름 부여
      for row in matrix'Range (1) loop
        for col in matrix'Range (2) loop
          if matrix (row, col) = value_to_find then
            put_line ("Found " & value_to_find'image & " at (" & row'image & "," & col'image & ")");
            -- 'Search_loop'라는 이름의 외부 루프를 직접 탈출합니다.
            exit Search_loop;
          end if;
        end loop;
      end loop Search_loop; -- 루프 이름으로 종료를 명시할 수 있음
    
      -- 'exit'가 실행되지 않았다면 아래 메시지가 출력됩니다.
      if not (found) then -- 'found'는 별도의 boolean 변수로 가정
        put_line (value_to_find'image & " not found in matrix.");
      end if;
    
    end find_value_in_matrix;
    

이처럼 루프 이름은 복잡한 중첩 구조에서 제어 흐름을 명확하고 안전하게 만들어, 코드의 가독성과 신뢰성을 크게 향상시킵니다.

7.2.2 while 루프

while 루프는 반복을 시작하기 에 특정 조건을 검사하여, 그 조건이 참(True)인 동안에만 코드 블록을 반복 실행하는 선-검사(pre-condition) 반복문입니다. 반복 횟수가 사전에 정해져 있지 않고, 오직 특정 상태가 유지되는 동안에만 작업을 계속해야 할 때 유용합니다.

구문 및 동작 원리

while 루프의 구조는 while 키워드와 반복을 계속할 조건, 그리고 loopend loop;로 구성됩니다.

  • 구문 (syntax):

    while <condition> loop
      <sequence_of_statements>
    end loop;
    
  • 동작 원리:

    1. while 키워드 뒤의 <condition>을 평가합니다.
    2. 평가 결과가 True이면, loopend loop; 사이의 문장들을 실행합니다. 실행이 끝나면 다시 1번 단계로 돌아가 조건을 재평가합니다.
    3. 평가 결과가 False이면, 루프 본체를 실행하지 않고 즉시 end loop; 다음 문장으로 실행 흐름을 넘깁니다.

이러한 선-검사 방식 때문에, 만약 처음부터 조건이 False라면 루프의 본체는 단 한 번도 실행되지 않을 수 있습니다.

사용 예시

while 루프는 파일의 끝(End-Of-File)에 도달할 때까지 모든 줄을 읽거나, 스택(Stack)이 비어있지 않은 동안 모든 요소를 꺼내는 등의 작업에 이상적입니다.

코드 예시 4-20: 파일의 모든 내용 읽기

with Ada.Text_IO;
use Ada.Text_IO;

procedure read_all_lines (file : in File_Type) is
  line_buffer : String (1 .. 255);
  line_length : Natural;
begin
  -- 파일의 끝에 도달하지 않은 동안 반복합니다.
  while not End_Of_File (file) loop
    Get_Line (file, line_buffer, line_length);
    put_line (line_buffer (1 .. line_length));
  end loop;

  put_line ("--- End of file reached. ---");
end read_all_lines;

7.2.3 while 루프와 기본 loop 문의 비교

while 루프는 기본 loopexit when을 사용해 동일한 로직으로 구현할 수 있지만, 구조와 의도에서 차이가 있습니다.

  • 조건 검사 위치: while은 루프의 시작에서 조건을 검사합니다. 반면, 기본 loopexit when을 통해 루프의 중간이나 에서 조건을 검사하는 것이 일반적입니다.

  • 가독성: “이 조건이 만족되는 동안에만 실행한다”는 의도를 명확하게 표현하고 싶을 때는 while 루프가 더 가독성이 높습니다. 반면, 루프 내에서 여러 지점에서 복잡한 조건으로 탈출해야 할 때는 기본 loop 문이 더 유연합니다.

아래는 위의 while 루프를 기본 loop로 재작성한 코드입니다.

-- 기본 loop를 사용한 동일 로직
loop
  exit when End_Of_File (file); -- 루프 시작 부분에서 탈출 조건 검사

  Get_Line (file, line_buffer, line_length);
  put_line (line_buffer (1 .. line_length));
end loop;

두 코드는 기능적으로 동일하지만, while 버전이 루프의 진입 조건 자체를 명확히 드러낸다는 점에서 해당 시나리오에 더 적합하다고 볼 수 있습니다.

7.2.4 for 루프

for 루프는 지정된 이산 범위(discrete range)를 순회하며, 정해진 횟수만큼 반복을 수행하는 가장 구조적인 반복문입니다. 반복 횟수가 루프 진입 전에 결정되므로, while이나 기본 loop 문에 비해 프로그램의 동작을 예측하기 쉽고 잠재적인 오류(특히 무한 루프)를 방지할 수 있어 안전합니다.

기본 구조

for 루프는 루프 변수(loop variable)와 순회할 이산 범위를 지정하여 구성됩니다.

  • 구문 (syntax):

    for <loop_variable> in <discrete_range> loop
      <sequence_of_statements>
    end loop;
    

코드 예시 4-21: 기본적인 for 루프

procedure print_numbers is
begin
  -- 1부터 5까지의 정수 범위를 순회합니다.
  for i in 1 .. 5 loop
    put_line ("Current number: " & i'image);
  end loop;
end print_numbers;

루프 변수의 불변성 (immutability)

Ada의 for 루프가 갖는 매우 중요한 특징은 루프 변수의 불변성입니다.

  1. 암묵적 선언: 루프 변수(i 등)는 for 루프에 의해 암묵적으로 선언됩니다. 따라서 별도로 declare 블록에 선언할 필요가 없으며, 선언해서도 안 됩니다. 변수의 타입은 범위로부터 자동으로 유추됩니다.
  2. 읽기 전용: 루프 변수는 루프 본체 내에서 상수(constant)로 취급됩니다. 즉, 그 값을 읽을 수는 있지만, 새로운 값을 할당하여 변경할 수는 없습니다.
for i in 1 .. 5 loop
  -- i := i + 1; -- 컴파일 오류! 루프 변수는 변경할 수 없습니다.
end loop;

이러한 설계는 프로그래머가 실수로 루프 카운터를 변경하여 반복 흐름을 깨뜨리는 흔한 오류를 원천적으로 방지합니다. 이는 코드의 예측 가능성과 안정성을 크게 높이는 Ada의 핵심적인 안전장치입니다.

reverse 키워드를 이용한 역방향 반복

범위를 역순으로 순회하려면 reverse 키워드를 사용합니다. 이때 범위 자체는 여전히 하한 .. 상한의 오름차순으로 지정해야 합니다.

  • 구문 (syntax):

    for <loop_variable> in reverse <discrete_range> loop
      ...
    end loop;
    

코드 예시 4-22: 역방향 for 루프

procedure countdown is
begin
  put_line ("Countdown starts!");
  for i in reverse 1 .. 10 loop
    put_line (i'image & "...");
  end loop;
  put_line ("Liftoff!");
end countdown;

배열 및 컨테이너 순회 (for .. of)

Ada 2012부터 도입된 for .. of 구문을 사용하면 인덱스를 직접 다루지 않고 배열(array)이나 다른 컨테이너의 요소들을 직접 순회할 수 있습니다. 이는 “off-by-one”과 같은 인덱스 관련 오류를 방지하고, “각 요소에 대해 작업을 수행한다”는 의도를 더 명확하게 표현합니다.

  • 구문 (syntax):

    for <element> of <container> loop
      ...
    end loop;
    

코드 예시 4-23: 배열 요소 순회

procedure process_scores is
  type Score_Array is array (1 .. 4) of Integer;
  scores : constant Score_Array := (88, 95, 72, 100);
begin
  for score of scores loop
    put_line ("Processing score: " & score'image);
  end loop;
end process_scores;

7.3 goto

goto 문은 프로그램의 제어 흐름을 지정된 레이블(label)로 즉시 이동시키는 분기문입니다. 이러한 직접적인 제어 흐름 변경은 코드의 논리 구조를 순차적으로 파악하기 어렵게 만들어 유지보수성을 저해하는 요인이 됩니다. 이러한 이유로, 비록 Ada를 포함한 많은 언어에 이 기능이 존재하지만 현대적인 구조적 프로그래밍에서는 사용을 지양합니다.

7.3.1 구문

goto 문은 레이블 선언과 goto 호출, 두 부분으로 구성됩니다.

  1. 레이블 선언: <<레이블_이름>>과 같이 이중 꺾쇠괄호로 선언합니다.
  2. goto 호출: goto 레이블_이름; 형태로 사용합니다.
-- 구문 예시 (좋은 사용 사례는 아님)
<<my_label>>
-- ... 코드 ...
goto my_label;

7.3.2 제한적인 사용 사례: 깊은 중첩 루프 탈출

goto가 논의되는 매우 드문 상황 중 하나는, 다중으로 중첩된 루프를 한 번에 탈출해야 할 때입니다. exit 문은 하나의 루프만 탈출할 수 있기 때문에, 여러 루프를 탈출하려면 복잡한 플래그 변수를 사용해야 할 수 있습니다. 이 경우 goto가 코드를 더 명확하게 만들 수도 있습니다.

다음은 2차원 배열에서 특정 값을 찾으면 모든 루프를 즉시 중단하는 예제입니다.

with Ada.Text_IO;

procedure goto_example is
  matrix : array (1..10, 1..10) of Integer;
  value_to_find : constant Integer := 7;
begin
  -- 예제 행렬 초기화 (여기서는 생략)
  matrix(3, 8) := value_to_find;

  for i in matrix'range (1) loop
    for j in matrix'range (2) loop
      if matrix (i, j) = value_to_find then
        Ada.Text_IO.put_line ("Found " & Integer'image (value_to_find) &
                              " at (" & Integer'image (i) & "," &
                              Integer'image (j) & ")");
        goto found_it; -- 중첩된 루프를 한 번에 탈출
      end if;
    end loop;
  end loop;

  Ada.Text_IO.put_line ("Value not found."); -- 이 줄은 값을 찾으면 실행되지 않음

  <<found_it>>

  Ada.Text_IO.put_line ("Search complete.");

end goto_example;

실행 결과:

Found 7 at ( 3, 8)
Search complete.

7.3.3 goto의 구조적 대안

goto 사용이 필요해 보이는 대부분의 상황에는 더 나은 구조적 대안이 존재합니다.

  • 이름 있는 루프와 exit 문 (가장 권장되는 대안)

    중첩 루프를 한 번에 탈출하기 위해 goto를 사용하는 것은 Ada의 이름 있는 루프 기능으로 완벽하게 대체할 수 있습니다. 각 루프에 이름을 부여하고 exit 문에 탈출하려는 루프의 이름을 명시하면 됩니다. 이는 goto 없이 명확하고 구조적인 방식으로 동일한 목적을 달성합니다.

    with Ada.Text_IO;
    
    procedure named_loop_example is
      matrix : array (1 .. 10, 1 .. 10) of Integer;
      value_to_find : constant Integer := 7;
    begin
      -- ... 행렬 초기화 ...
      matrix(3, 8) := value_to_find;
    
    outer_loop: -- (1) 루프에 이름 부여
      for i in matrix'range (1) loop
        for j in matrix'range (2) loop
          if matrix (i, j) = value_to_find then
            Ada.Text_IO.put_line ("Found value at (" & i'image & ", " & j'image & ")");
            exit outer_loop; -- (2) 지정된 이름의 루프를 즉시 탈출
          end if;
        end loop;
      end loop outer_loop;
    
      Ada.Text_IO.put_line ("Search complete.");
    end named_loop_example;
    

    이 방식은 goto와 레이블이 코드의 다른 부분에 흩어져 있는 것과 달리, 루프의 시작과 끝이 명확하게 한 쌍으로 존재하므로 가독성과 유지보수성이 훨씬 뛰어납니다.

  • 프로시저/함수로 분리

    중첩 루프 로직을 별도의 서브프로그램으로 추출하고, 값을 찾았을 때 return 문을 사용해 즉시 종료할 수 있습니다.

  • 상태 플래그 사용

    Boolean 플래그 변수를 두어 바깥쪽 루프의 탈출 조건을 제어합니다.

7.3.4 goto의 적법성 규칙과 제약 (legality rules and constraints)

goto 문은 아무 곳으로나 제어를 이전할 수 없으며, 프로그램의 구조적 무결성을 해치지 않기 위한 엄격한 규칙을 따릅니다. Ada 2022 레퍼런스 매뉴얼에 명시된 핵심 규칙은 다음과 같습니다.

핵심 규칙: 목표 문장(레이블)을 감싸는 가장 안쪽의 실행 코드 블록(sequence_of_statements)은, goto 문 또한 반드시 감싸야 합니다.(The innermost sequence_of_statements that encloses the target statement shall also enclose the goto_statement.)

이 규칙은 제어 이전이 항상 같은 레벨 또는 안쪽에서 바깥쪽으로만 가능함을 의미합니다. 이 규칙에 따라 다음과 같은 주요 제약 사항이 파생됩니다.

허용되지 않는 주요 점프 유형

1. 바깥쪽에서 안쪽 스코프로의 점프

루프나 블록문 외부에서 내부로 제어를 이전하는 것은 금지됩니다.

-- 잘못된 예: 루프 외부에서 내부로 점프
procedure invalid_jump_in is
begin
  goto inner_label; -- 컴파일 오류!

  for i in 1 .. 10 loop
    <<inner_label>>
    Ada.Text_IO.put_line ("This is unreachable.");
  end loop;
end invalid_jump_in;

2. 복합문의 다른 대안(alternative)으로의 점프

if-elsif-else, case, select 문의 한 갈래(alternative)에서 다른 갈래로 직접 점프하는 것은 금지됩니다.

-- 잘못된 예: if-elsif 대안 간의 점프
procedure invalid_jump_between_alts (a : Integer) is
begin
  if a = 1 then
    goto label_2; -- 컴파일 오류!
  elsif a = 2 then
    <<label_2>>
    null;
  end if;
end invalid_jump_between_alts;

3. 예외 핸들러에서 다시 원래 코드로의 점프

예외가 발생하여 예외 핸들러로 제어가 이전된 후, 다시 원래의 실행 코드로 돌아가는 것은 금지됩니다.

-- 잘못된 예: 예외 핸들러에서 정상 흐름으로 복귀
procedure invalid_jump_from_handler is
begin
  <<retry_point>>
  -- ... 위험한 연산 수행 ...
  raise Program_Error;

exception
  when Program_Error =>
    goto retry_point; -- 컴파일 오류!
end invalid_jump_from_handler;

이러한 규칙들은 goto로 인해 발생할 수 있는 잠재적인 혼란과 논리적 오류를 언어 차원에서 체계적으로 방지합니다.

8. 서브프로그램

(도입부)

8.1 서브프로그램의 개념과 종류

지금까지 우리는 순차, 선택, 반복 구조를 이용해 프로그램의 흐름을 제어하는 방법을 배웠습니다. 하지만 프로그램의 규모가 커지고 복잡해지면, 모든 코드를 하나의 거대한 흐름 안에 작성하는 것은 비효율적이며 유지보수를 어렵게 만듭니다.

이러한 문제를 해결하기 위해 프로그래밍 언어는 특정 작업을 수행하는 코드 블록을 하나의 단위로 묶어 이름을 붙이고, 필요할 때마다 호출하여 재사용할 수 있는 기능을 제공합니다. Ada에서는 이러한 독립적인 코드 단위를 서브프로그램(subprogram)이라고 부릅니다.

서브프로그램의 역할과 장점

서브프로그램은 현대 프로그래밍의 핵심적인 구성 요소로, 다음과 같은 중요한 장점을 제공합니다.

  1. 재사용성 (Reusability): 한번 작성된 서브프로그램은 프로그램의 여러 곳에서 반복적으로 호출될 수 있습니다. 이는 코드의 중복을 제거하고 개발 시간을 단축시킵니다.
  2. 모듈화 (Modularity): 거대하고 복잡한 문제를 여러 개의 작고 관리 가능한 서브프로그램 단위로 분해할 수 있습니다. 각 서브프로그램은 특정 기능에만 집중하므로, 전체 시스템의 구조가 명확해집니다.
  3. 추상화 (Abstraction): 서브프로그램의 사용자는 그 내부가 어떻게 복잡하게 구현되었는지 알 필요 없이, 단순히 서브프로그램의 이름과 매개변수만으로 원하는 기능을 사용할 수 있습니다. 이는 “무엇을 하는가(What)”와 “어떻게 하는가(How)”를 분리하여 코드의 가독성과 사용 편의성을 높입니다.
  4. 유지보수 용이성: 특정 기능에 대한 수정이 필요할 때, 해당 서브프로그램 내부만 수정하면 되므로 변경의 영향 범위가 제한됩니다. 이는 버그 수정과 기능 개선을 훨씬 쉽게 만듭니다.

서브프로그램의 두 종류: 프로시저와 함수

Ada의 서브프로그램은 그 역할에 따라 크게 두 종류로 나뉩니다.

  • 프로시저 (Procedure): 특정 동작(action)이나 절차(procedure)를 수행하는 것을 목적으로 합니다. 화면에 메시지를 출력하거나, 파일을 저장하거나, 로봇 팔을 움직이는 등의 작업을 수행합니다. 프로시저는 값을 반환하지 않습니다.

  • 함수 (Function): 특정 값을 계산(compute)하여 결과 값을 반환(return)하는 것을 목적으로 합니다. 두 숫자의 합을 구하거나, 특정 데이터의 유효성을 검사하여 참/거짓을 반환하는 등의 작업을 수행합니다. 함수 호출은 그 자체가 하나의 ‘값’으로 취급됩니다.

구분 프로시저 (Procedure) 함수 (Function)
목적 동작(Action) 수행 값(Value) 계산 및 반환
반환값 없음 (No return value) 반드시 하나의 값을 반환
호출 단독 문장으로 호출 (e.g., Do_It;) 표현식(Expression)의 일부로 사용 (e.g., X := F(Y);)

이어지는 절들에서는 이 두 종류의 서브프로그램을 선언하고 사용하는 구체적인 방법과, 이들을 더욱 강력하게 만들어주는 매개변수 전달 기법, 오버로딩 등에 대해 자세히 알아볼 것입니다.

8.2 프로시저 (procedures)

프로시저(Procedure)는 특정 동작(action)이나 절차(procedure)를 수행하기 위해 설계된 서브프로그램입니다. 함수의 주된 목적이 값을 계산하여 반환하는 것이라면, 프로시저의 주된 목적은 프로그램의 상태를 변경하거나, 외부 장치와 상호작용하거나, 일련의 명령을 순서대로 실행하는 것입니다.

가장 중요한 특징은 프로시저는 값을 반환하지 않는다는 점입니다.

프로시저 선언 및 호출

프로시저는 procedure 키워드를 사용하여 선언하며, 수행할 작업들을 실행부(begin ... end)에 기술합니다.

선언 구문:

procedure 프로시저_이름 (매개변수_목록) is
   -- 프로시저 내에서 사용할 지역 변수나 상수 선언
begin
   -- 실제 동작을 수행하는 일련의 문장들
end 프로시저_이름;

프로시저를 호출하는 것은 그 자체가 하나의 완전한 문장(statement)입니다. 이는 함수 호출이 표현식(expression)의 일부로 사용되는 것과 대조됩니다.

호출 구문:

프로시저_이름 (매개변수);

활용 예제: 두 변수의 값 교환하기

프로시저의 역할을 가장 명확하게 보여주는 예제 중 하나는 두 변수의 값을 서로 바꾸는 Swap 작업입니다. 이 작업은 값을 계산하는 것이 아니라, 변수의 상태를 직접 변경하는 ‘동작’이기 때문에 프로시저로 구현하는 것이 가장 적합합니다. 이 예제는 in out 모드 매개변수의 활용법도 잘 보여줍니다.

procedure Swap (A, B : in out Integer) is
   -- A와 B의 값을 교환하기 위한 임시 저장 공간
   Temp : constant Integer := A;
begin
   A := B;
   B := Temp;
end Swap;

-- Swap 프로시저 활용 예시
procedure Test_Swap is
   Value_1 : Integer := 10;
   Value_2 : Integer := 20;
begin
   -- Ada.Text_IO.Put_Line ("Before: " & Integer'Image (Value_1) & "," & Integer'Image (Value_2));
   -- 출력: Before:  10, 20

   -- Swap 프로시저 호출
   Swap (Value_1, Value_2);

   -- Ada.Text_IO.Put_Line ("After: " & Integer'Image (Value_1) & "," & Integer'Image (Value_2));
   -- 출력: After:  20, 10
end Test_Swap;

Swap 프로시저는 Value_1Value_2 변수의 상태를 직접 변경하는 부수 효과(side effect)를 가집니다. 이처럼 프로그램의 상태를 바꾸는 것이 주된 목적인 경우 프로시저를 사용합니다.

프로시저의 역할

프로그램 설계에서 프로시저는 다음과 같은 핵심적인 역할을 담당합니다.

  • 상태 변경: out 또는 in out 매개변수를 통해 변수의 값을 수정하거나, 객체의 내부 상태를 변경하는 작업을 캡슐화합니다.
  • 동작 그룹화: ‘시스템 초기화’, ‘보고서 출력’, ‘데이터베이스 연결’과 같이 여러 단계로 이루어진 하나의 논리적인 작업을 단일 이름 아래에 그룹화하여 추상화 수준을 높입니다.
  • 입출력(I/O) 처리: 화면에 무언가를 출력(Ada.Text_IO.Put_Line)하거나 파일에 데이터를 쓰는 작업 등 외부 세계와의 상호작용은 모두 프로시저를 통해 이루어집니다.

결론적으로, 프로그램에서 무언가 ‘일어나게’ 하거나 ‘변화하게’ 만드는 모든 행위는 프로시저로 구현된다고 생각할 수 있습니다.

8.3 함수 (Functions)

앞서 프로시저가 특정 ‘동작’을 수행하는 서브프로그램이라고 배웠다면, 지금부터 다룰 함수(function)는 특정 ‘계산’을 수행하여 그 결과 값을 반환(return)하는 데 특화된 서브프로그램입니다.

프로그래밍에서 우리는 수많은 계산을 필요로 합니다. 두 점 사이의 거리를 구하거나, 사용자가 입력한 문자열이 유효한 이메일 형식인지 확인하거나, 주어진 값들 중 최댓값을 찾는 등의 작업이 모두 계산에 해당합니다. 함수는 바로 이러한 계산 로직을 하나의 독립적인 단위로 묶어 재사용 가능하게 만들어 줍니다.

함수 호출은 그 자체가 하나의 ‘값’으로 취급된다는 것이 프로시저와의 가장 핵심적인 차이점입니다. 따라서 함수는 변수에 값을 할당하거나, 다른 서브프로그램의 인자로 전달되거나, 조건문의 일부가 되는 등 프로그램 내에서 값이 위치할 수 있는 모든 곳에서 자유롭게 사용될 수 있습니다.

이번 절에서는 값을 만들어내는 강력한 도구인 함수의 선언 방법과 호출 방식, 그리고 좋은 함수를 설계하기 위한 지침들을 학습하여 코드의 표현력과 재사용성을 한 단계 더 끌어올릴 것입니다.

8.3.1 함수의 정의와 특징

함수(function)는 Ada 서브프로그램의 두 가지 형태 중 하나로, 특정 연산을 수행한 후 그 결과를 단 하나의 값(single value)으로 반환하는 역할을 합니다. 이는 “질문에 답하는” 코드 블록이라고 생각할 수 있습니다. 예를 들어 “두 숫자 5와 10 중 더 큰 값은 무엇인가?”라는 질문에 “10”이라고 답을 돌려주는 것이 바로 함수의 역할입니다.

값을 반환하는 서브프로그램

함수의 가장 핵심적인 정체성은 ‘값을 반환한다’는 것입니다. 이로 인해 다음과 같은 특징이 파생됩니다.

  • 결과 타입(Return Type): 함수를 선언할 때는 반드시 어떤 타입의 값을 반환할 것인지를 명시해야 합니다. Integer, Float, Boolean과 같은 스칼라 타입은 물론, String, 레코드, 배열과 같은 합성 타입도 반환할 수 있습니다.
  • 표현식(Expression)으로서의 역할: 함수 호출은 그 자체가 하나의 값으로 취급되므로, 변수에 값을 할당하거나 다른 연산의 일부가 되는 등 ‘표현식’이 올 수 있는 모든 곳에 사용할 수 있습니다.
-- X, Y는 변수, Max는 두 Integer 중 큰 값을 반환하는 함수라고 가정
X := 10;
Y := 20;

-- 함수 호출의 결과(20)가 Z에 할당됨
Z : Integer := Max (X, Y);

-- 함수 호출의 결과(20)가 조건문에서 10과 비교됨
if Max (X, Y) > 10 then ...

-- 함수 호출의 결과가 다른 함수의 인자로 사용됨
Print_Value (Max (X, Y));

프로시저와의 핵심적인 차이점

함수와 프로시저는 코드를 재사용하는 단위라는 점에서는 공통점을 가지지만, 그 목적과 사용법에서 명확한 차이를 보입니다.

구분 함수 (Function) 프로시저 (Procedure)
핵심 목적 값의 계산 및 반환 (Computation & Return) 동작 또는 절차의 수행 (Action & Procedure)
반환값 있음 (필수) 없음
호출 방식 표현식의 일부로 사용<br/>(e.g., X := F(A);) 단독 문장으로 호출<br/>(e.g., Do_Something(B);)
주요 용도 수학적 계산, 데이터 변환, 유효성 검사 등 화면 출력, 파일 쓰기, 상태 변경 등
매개변수 모드 주로 in 모드 사용 (부수 효과를 최소화하기 위함) in, out, in out 등 모든 모드를 자유롭게 사용

이러한 차이점을 명확히 이해하고, ‘값을 얻는 것’이 목적인지 ‘어떤 일을 시키는 것’이 목적인지에 따라 함수와 프로시저를 올바르게 선택하여 사용하는 것이 구조적으로 좋은 프로그램을 설계하는 첫걸음입니다.

8.3.2 함수 선언 및 호출

함수를 사용하기 위해서는 먼저 함수의 명세(specification)와 구현부(body)를 포함하는 선언을 작성해야 합니다. 함수의 명세는 함수의 이름, 매개변수, 그리고 가장 중요하게는 반환할 값의 타입을 정의합니다.

선언 구문

함수의 기본 구조는 function 키워드로 시작하여, 매개변수 목록과 return 뒤에 오는 반환 타입을 명시하고, is 이후에 실제 동작을 구현합니다.

function 함수이름 (매개변수_목록) return 반환_타입 is
   -- 함수 내에서 사용할 지역 변수나 상수 선언
begin
   -- 실제 연산을 수행하는 코드
   ...
   return 결과_값; -- 반드시 return 문을 통해 값을 반환해야 함
end 함수이름;

예제: 두 정수 중 더 큰 값을 반환하는 Max 함수

function Max (Left, Right : Integer) return Integer is
begin
   if Left > Right then
      return Left;
   else
      return Right;
   end if;
end Max;

Max 함수는 두 개의 Integer 타입 값을 in 모드 매개변수로 받아, 비교 후 더 큰 Integer 값을 반환합니다.

표현식으로서의 함수 호출

함수 호출은 그 자체가 하나의 ‘값’을 가지는 표현식(expression)으로 취급됩니다. 이는 프로시저 호출이 하나의 ‘문장(statement)’으로 취급되는 것과 근본적으로 다른 점입니다. 따라서 함수 호출은 값이 올 수 있는 거의 모든 문맥에서 사용될 수 있습니다.

1. 할당문의 일부로 호출 함수가 반환한 값을 변수에 저장하는 가장 일반적인 사용 방식입니다.

procedure Test_Assignment is
   A : Integer := 10;
   B : Integer := 20;
   Largest : Integer;
begin
   Largest := Max (A, B); -- Max(10, 20) 호출 결과인 20이 Largest에 저장됨
end Test_Assignment;

2. 조건문에서 호출 특히 Boolean을 반환하는 함수(Predicate)는 조건문과 함께 사용될 때 코드의 가독성을 크게 향상시킵니다.

function Is_Even (Num : Integer) return Boolean is
begin
   return (Num mod 2 = 0);
end Is_Even;

procedure Test_Condition is
   Val : Integer := 1024;
begin
   if Is_Even (Val) then
      -- Ada.Text_IO.Put_Line ("It's an even number.");
   end if;
end Test_Condition;

3. 다른 서브프로그램의 매개변수로 호출 한 함수의 반환 값을 다른 프로시저나 함수의 입력으로 직접 전달할 수 있습니다.

-- Ada.Text_IO.Put_Line(Integer'Image(Max(30, 40)));
-- Max(30, 40)이 먼저 호출되어 40을 반환하고,
-- 이 40이 Integer'Image 함수의 인자로 전달되어 문자열 " 40"으로 변환된 후,
-- 최종적으로 Put_Line 프로시저가 이 문자열을 출력합니다.

4. 애그리게이트 또는 연산 표현식 내에서 호출 레코드나 배열 애그리게이트를 초기화하거나, 복잡한 수식의 일부로 함수를 호출할 수도 있습니다.

Total_Cost : Float := Price_Of (Item_A) + Price_Of (Item_B);

이처럼 함수는 프로그램 내에서 값으로 취급되므로, 다양한 문맥에서 다른 코드 요소들과 자연스럽게 조합되어 간결하고 표현력 높은 코드를 작성하게 해줍니다.

8.3.3 return 문과 결과 값

return 문은 함수 본문 내에서 가장 중요한 역할을 수행하는 문장입니다. 이 문장은 두 가지 핵심적인 기능을 동시에 수행합니다.

  1. 값 반환: 함수가 계산한 최종 결과 값을 지정합니다.
  2. 실행 종료: return 문이 실행되는 즉시 함수의 실행은 종료되고, 제어 흐름은 함수를 호출했던 코드로 되돌아갑니다.

return 문의 구문과 타입 호환성

return 문은 return 키워드 뒤에 반환할 값을 나타내는 표현식(expression)으로 구성됩니다.

return 표현식;

여기서 가장 중요한 규칙은 표현식의 타입이 반드시 함수 선언부에 명시된 반환 타입과 일치하거나 호환되어야 한다는 것입니다. Ada 컴파일러는 이 규칙을 엄격하게 검사하여, 의도치 않은 타입의 값이 반환되는 것을 원천적으로 차단합니다.

function Calculate_Bonus (Salary : Float) return Float is
   BONUS_RATE : constant Float := 0.1;
begin
   -- Salary * BONUS_RATE는 Float 타입이므로, 함수의 반환 타입과 일치
   return Salary * BONUS_RATE;

   -- return 100; -- 🚨 컴파일 오류! Integer를 Float 타입 함수에서 반환할 수 없음
end Calculate_Bonus;

모든 실행 경로의 끝에는 return이 있어야 한다

Ada 컴파일러는 함수 내의 모든 가능한 실행 경로(execution path)가 return 문으로 끝나도록 보장합니다. 만약 조건문 등으로 인해 return 문에 도달하지 않고 함수가 끝날 가능성이 있는 코드가 있다면, 컴파일러는 이를 오류로 처리합니다. 이 기능은 함수가 값을 반환하지 않는 상황을 방지하는 매우 강력한 안전장치입니다.

잘못된 예 (컴파일 오류 발생):

function Is_Positive (N : Integer) return Boolean is
begin
   if N > 0 then
      return True;
   end if;
   -- 만약 N이 0이나 음수일 경우, 함수는 아무것도 반환하지 않고 끝나게 됨
   -- 컴파일러가 이 경로를 발견하고 "missing return statement" 오류를 발생시킴
end Is_Positive;

올바른 예:

function Is_Positive (N : Integer) return Boolean is
begin
   if N > 0 then
      return True;
   else
      return False; -- 모든 경로에 return 문이 있도록 보장
   end if;
end Is_Positive;

[설계] 단일 반환점 vs. 다중 반환점

함수 내 return 문의 배치 스타일에 대해서는 두 가지 접근 방식이 있습니다.

  1. 단일 반환점 (Single Return Point): 함수 내에 결과 값을 저장할 지역 변수를 두고, 함수의 맨 마지막에 단 한 번의 return 문을 사용하는 방식입니다.

    • 장점: 함수의 종료 지점이 하나이므로 코드의 흐름을 추적하고 디버깅하기 용이합니다. 일부 코딩 표준에서는 이 방식을 권장합니다.
    • 예시:
    function Max (Left, Right : Integer) return Integer is
       Result : Integer;
    begin
       if Left > Right then
          Result := Left;
       else
          Result := Right;
       end if;
       return Result;
    end Max;
    
  2. 다중 반환점 (Multiple Return Points): 논리적으로 가장 자연스러운 위치에 여러 개의 return 문을 사용하는 방식입니다. 특히 함수의 시작 부분에서 예외적인 조건을 미리 처리하는 ‘가드 절(guard clause)’ 패턴에 유용합니다.

    • 장점: 불필요한 else나 중첩된 if 문을 줄여 코드를 더 간결하게 만들 수 있습니다.
    • 예시:
    function Day_Name (D : Day_Of_Week) return String is
    begin
       if D not in Day_Of_Week'Range then
          return "Invalid Day"; -- 가드 절
       end if;
       -- 이후 정상적인 변환 로직 ...
    end Day_Name;
    

어떤 스타일을 선택할지는 프로젝트의 코딩 표준이나 개인의 선호에 따라 달라질 수 있습니다. 두 방식 모두 장단점이 있으므로, 코드의 명확성을 최우선 기준으로 삼는 것이 바람직합니다.

8.3.4 [설계] 순수 함수와 부수 효과(Side Effects)

함수는 특정 값을 계산하여 반환하는 것이 주된 목적이지만, 그 과정에서 의도치 않게 프로그램의 다른 부분에 영향을 미칠 수 있습니다. 이처럼 함수의 주된 목적인 ‘값 반환’ 외에 발생하는 모든 부가적인 효과를 부수 효과(side effect)라고 합니다.

좋은 소프트웨어 설계, 특히 높은 신뢰성이 요구되는 시스템에서는 이러한 부수 효과를 최소화하는 것이 매우 중요합니다.

부수 효과란 무엇인가?

함수에서의 부수 효과는 다음과 같은 경우에 발생합니다.

  • 전역(global) 변수의 값을 변경하는 경우
  • in out 또는 out 모드의 매개변수 값을 변경하는 경우
  • 화면, 파일, 네트워크 등 외부 장치에 입출력(I/O) 작업을 수행하는 경우
  • 예외(exception)를 발생시키는 경우
  • 데이터베이스의 상태를 변경하는 경우

이러한 부수 효과가 있는 함수는 예측을 어렵게 만듭니다. 예를 들어, Total := Get_Value (X) + Get_Value (X); 라는 코드가 있을 때, 만약 Get_Value 함수가 내부적으로 전역 변수를 변경한다면, 두 Get_Value (X) 호출의 결과가 다를 수 있습니다. 이는 코드의 이해를 방해하고 잠재적인 버그의 원인이 됩니다.

순수 함수 (Pure Functions)

순수 함수(pure function)란 부수 효과가 전혀 없는 함수를 의미합니다. 순수 함수는 다음과 같은 두 가지 중요한 특징을 가집니다.

  1. 참조 투명성 (Referential Transparency): 함수의 반환 값은 오직 입력된 매개변수 값에 의해서만 결정됩니다. 동일한 입력에 대해서는 언제나 동일한 출력을 보장합니다.
  2. 부수 효과 없음: 함수는 자신의 외부에 있는 어떤 상태도 변경하지 않습니다.

앞서 작성했던 Max 함수나 Is_Even 함수는 대표적인 순수 함수입니다.

function Max (Left, Right : Integer) return Integer is
begin
   -- 오직 Left와 Right 값에만 의존하며, 외부 상태를 변경하지 않음
   if Left > Right then
      return Left;
   else
      return Right;
   end if;
end Max;

순수 함수의 장점

계산 로직을 순수 함수로 작성하면 다음과 같은 엄청난 이점을 얻을 수 있습니다.

  • 예측 가능성과 가독성: 함수 호출이 프로그램의 다른 부분에 어떤 영향을 미칠지 걱정할 필요가 없으므로 코드를 이해하고 예측하기가 매우 쉬워집니다.
  • 테스트 용이성: 함수의 동작이 오직 입력값에만 의존하므로 테스트가 매우 간단합니다. 복잡한 외부 상태를 설정할 필요 없이, 다양한 입력값을 넣고 기대하는 반환 값이 나오는지만 확인하면 됩니다.
  • 재사용성: 특정 문맥에 의존하지 않으므로, 프로그램의 어느 곳에서든 안심하고 재사용할 수 있습니다.
  • 병렬 처리 용이성: 여러 순수 함수들은 서로에게 영향을 주지 않으므로, 순서에 상관없이 병렬적으로 안전하게 실행할 수 있습니다. 이는 멀티코어 프로세서의 성능을 활용하는 데 유리합니다.

좋은 함수를 위한 설계 지침

  1. 계산과 동작을 분리하라: 함수의 주된 역할은 ‘계산’에 집중해야 합니다. 계산된 결과를 화면에 출력하는 ‘동작’은 프로시저에 위임하는 것이 좋습니다.
  2. in 매개변수를 사용하라: 함수는 외부 상태를 변경하지 않는 것이 원칙이므로, 매개변수는 in 모드로 선언하여 값을 읽기만 하는 용도로 사용하는 것이 바람직합니다.
  3. 전역 변수 사용을 피하라: 함수가 필요한 모든 정보는 매개변수를 통해 전달받아야 합니다.

물론 현재 시간을 반환하는 함수처럼 모든 함수가 순수 함수가 될 수는 없습니다. 하지만 가능한 한 계산 로직을 순수 함수로 분리하고, 부수 효과가 필수적인 부분(I/O 등)은 프로시저로 명확하게 격리하는 것이 견고하고 신뢰성 높은 소프트웨어를 만드는 핵심 설계 원칙입니다. Ada는 pragma Pure를 통해 특정 서브프로그램이 순수하다는 것을 컴파일러에게 명시하고 강제할 수도 있습니다.

8.3.5 활용 예제

이론적인 개념을 실제 코드로 확인하는 것은 학습에 매우 중요합니다. 이번 절에서는 앞서 배운 함수의 개념을 활용하여, 다양한 타입의 값을 계산하고 반환하는 몇 가지 실용적인 예제들을 작성해 보겠습니다.

예제 1: 두 값 중 더 큰 값을 반환하는 Max 함수

가장 기본적인 형태의 함수입니다. 제네릭(Generic)을 사용하지 않고, 특정 타입에 대해 동작하는 함수를 만듭니다. 이 예제는 함수의 기본 구조와 조건부 return을 잘 보여줍니다.

-- Float 타입을 위한 Max 함수
function Max (Left, Right : Float) return Float is
begin
   if Left > Right then
      return Left;
   else
      return Right;
   end if;
end Max;

-- Date 타입을 위한 Max 함수 (Date 타입이 <, > 연산자를 가지고 있다고 가정)
function Max (Left, Right : Date) return Date is
begin
   if Left > Right then
      return Left;
   else
      return Right;
   end if;
end Max;

핵심: 이처럼 함수는 Integer, Float 같은 숫자 타입뿐만 아니라, 비교 연산자(>, <)가 정의된 어떠한 타입에 대해서도 동일한 논리 구조로 작성될 수 있습니다. (추후 ‘제네릭’ 장에서 이를 일반화하는 방법을 배우게 됩니다.)

예제 2: 특정 문자가 숫자인지 판별하는 Is_Digit 함수

Boolean 값을 반환하는 함수는 프로그램의 논리 흐름을 제어하는 데 매우 유용합니다. 이러한 함수는 종종 판별 함수(Predicate function)라고 불립니다.

function Is_Digit (C : Character) return Boolean is
begin
   -- Character 타입의 '0'과 '9' 사이의 범위에 포함되는지 확인
   return (C >= '0' and C <= '9');
end Is_Digit;

-- 활용 예시
procedure Check_Input is
   Input_Char : Character := '7';
begin
   if Is_Digit (Input_Char) then
      -- Ada.Text_IO.Put_Line ("The input is a digit.");
   else
      -- Ada.Text_IO.Put_Line ("The input is not a digit.");
   end if;
end Check_Input;

핵심: 복잡한 if (C >= '0' and C <= '9') then ... 과 같은 조건을 if Is_Digit(C) then ... 으로 추상화하여 코드의 가독성을 크게 향상시킬 수 있습니다.

예제 3: 레코드 타입을 반환하는 함수

함수는 스칼라 타입뿐만 아니라, 레코드나 배열과 같은 합성 타입도 반환할 수 있습니다. 이는 관련 데이터들을 하나의 단위로 묶어 생성하고 반환하는 팩토리(Factory) 패턴과 유사한 역할을 수행하게 합니다.

type Point is record
   X, Y : Float;
end record;

-- 원점(0.0, 0.0)을 생성하여 반환하는 함수
function Origin return Point is
begin
   return (X => 0.0, Y => 0.0);
end Origin;

-- 두 Point의 중간 지점을 계산하여 반환하는 함수
function Midpoint (P1, P2 : Point) return Point is
   Mid_X : constant Float := (P1.X + P2.X) / 2.0;
   Mid_Y : constant Float := (P1.Y + P2.Y) / 2.0;
begin
   return (X => Mid_X, Y => Mid_Y);
end Midpoint;

-- 활용 예시
procedure Test_Point_Functions is
   Start_Point : Point := (X => 10.0, Y => 20.0);
   End_Point   : Point := Origin; -- 함수를 통해 값 초기화
   Center_Point : Point;
begin
   Center_Point := Midpoint (Start_Point, End_Point);
end Test_Point_Functions;

핵심: 복잡한 객체의 생성 과정을 함수 내부에 캡슐화할 수 있습니다. Origin 함수는 (0.0, 0.0)이라는 ‘마법의 숫자’를 코드 여러 곳에 흩어놓는 대신, 의미 있는 이름을 가진 단일 소스로부터 값을 제공하여 유지보수성을 높입니다.

이러한 예제들을 통해 함수가 단순히 수학 계산을 넘어, 코드의 구조를 개선하고, 가독성을 높이며, 데이터 생성 과정을 추상화하는 다재다능한 도구임을 확인할 수 있습니다.

8.4 중첩된 서브프로그램과 전방 선언

지금까지 우리는 패키지나 메인 서브프로그램의 선언부에 서브프로그램들을 나란히 선언하는 방식을 사용해 왔습니다. 하지만 프로그램의 규모가 커지고 로직이 복잡해지면, 서브프로그램의 선언 위치와 가시성(visibility)을 더 세밀하게 제어해야 할 필요가 생깁니다.

이번 절에서는 서브프로그램의 구조를 조직하는 두 가지 중요한 기법을 배웁니다.

첫째, 중첩된 서브프로그램(nested subprogram)은 특정 서브프로그램 내에서만 사용되는 ‘지역(local)’ 또는 ‘헬퍼(helper)’ 서브프로그램을 그 안에 직접 선언하는 방법입니다. 이는 외부에는 불필요한 세부 구현을 숨기고, 관련된 코드들을 논리적으로 함께 묶어 캡슐화와 가독성을 높여줍니다.

둘째, 전방 선언(forward declaration)은 서브프로그램의 전체 구현부(body)를 나중에 정의하기로 약속하고, 그 명세(specification)만을 미리 선언하는 기법입니다. 이는 두 서브프로그램이 서로를 호출하는 ‘상호 재귀’와 같이 순환적인 의존 관계를 해결하거나, 코드의 전체적인 구조를 더 깔끔하게 정리하고자 할 때 반드시 필요합니다.

이 두 기법은 코드의 구조를 설계하고, 각 서브프로그램이 어디서 보이고 호출될 수 있는지를 제어하는 필수적인 도구입니다. 이를 통해 우리는 더 모듈화되고 유지보수하기 쉬운 프로그램을 작성할 수 있습니다.

8.4.1 중첩된 서브프로그램

중첩된 서브프로그램(nested subprogram)은 하나의 서브프로그램(외부 서브프로그램)의 선언부(is ... begin 사이)에 다른 서브프로그램(내부 서브프로그램)을 선언하는 구조를 말합니다. 이는 특정 작업을 수행하는 데 필요한 보조적인(helper) 로직을, 그 작업이 필요한 곳에만 지역적으로 정의하여 캡슐화하는 강력한 기법입니다.

개념과 목적

복잡한 작업을 수행하는 서브프로그램을 작성하다 보면, 그 내부에서만 반복적으로 사용되는 작은 기능이 필요할 때가 많습니다. 이 보조 기능을 별도의 독립된 서브프로그램으로 외부에 선언하면, 다른 곳에서는 전혀 사용되지 않음에도 불구하고 패키지 전체에 노출되어 네임스페이스(namespace)를 불필요하게 어지럽힐 수 있습니다.

중첩된 서브프로그램은 바로 이 문제를 해결합니다. 보조 기능이 필요한 서브프로그램 내부에 ‘지역 서브프로그램(local subprogram)’으로 선언함으로써, 그 기능의 가시성(visibility)과 유효범위(scope)를 오직 외부 서브프로그램 내부로만 한정시키는 것입니다.

구문과 유효범위

중첩된 서브프로그램의 선언은 일반적인 서브프로그램 선언과 동일하며, 그 위치만 외부 서브프로그램의 선언부에 위치합니다.

procedure Outer_Procedure is
   Outer_Variable : Integer := 10;

   -- 'Outer_Procedure' 내부에 중첩된 'Helper_Function' 선언
   function Helper_Function (Param : Integer) return Integer is
   begin
      -- 외부 서브프로그램의 지역 변수(Outer_Variable)에 접근 가능
      return Param * Outer_Variable;
   end Helper_Function;

begin -- Outer_Procedure의 실행부 시작
   declare
      Result : Integer;
   begin
      -- 중첩된 Helper_Function은 오직 이 안에서만 호출 가능
      Result := Helper_Function (5); -- Result는 50이 됨
   end;
end Outer_Procedure;

-- procedure Main is
-- begin
--    Outer_Procedure;
--    Result := Helper_Function(5); -- 🚨 컴파일 오류! Helper_Function은 외부에서 보이지 않음
-- end Main;

핵심 규칙:

  1. 가시성: 중첩된 서브프로그램은 자신을 감싸고 있는 외부 서브프로그램의 실행부(begin ... end 사이)에서만 호출할 수 있습니다.
  2. 문맥 공유: 중첩된 서브프로그램은 외부 서브프로그램의 모든 지역 변수, 상수, 매개변수에 직접 접근할 수 있습니다. 위 예제에서 Helper_FunctionOuter_Variable을 직접 사용하는 것을 볼 수 있습니다.

활용 예제: 보고서 출력

보고서를 출력하는 프로시저에서 구분선을 그리는 작업이 여러 번 필요하다고 가정해 봅시다.

procedure Print_Report (Title : String) is
   SEPARATOR_CHAR : constant Character := '=';
   REPORT_WIDTH   : constant Positive := 40;

   -- 구분선을 그리는 로직을 헬퍼 프로시저로 캡슐화
   procedure Print_Separator is
   begin
      for i in 1 .. REPORT_WIDTH loop
         -- Ada.Text_IO.Put (SEPARATOR_CHAR);
      end loop;
      -- Ada.Text_IO.New_Line;
   end Print_Separator;

begin -- Print_Report의 실행부
   Print_Separator;
   -- Ada.Text_IO.Put_Line (Title);
   Print_Separator;
   -- ... 보고서 내용 출력 ...
   Print_Separator;
end Print_Report;

Print_Separator 프로시저는 보고서 출력에만 필요한 보조 기능입니다. 이를 Print_Report 내부에 중첩시킴으로써, 이 기능이 외부로 불필요하게 노출되는 것을 막고 코드의 구조를 더 명확하게 만들 수 있습니다. Print_SeparatorSEPARATOR_CHARREPORT_WIDTH 같은 외부 상수를 직접 사용할 수 있어 매개변수 전달도 필요 없습니다.

이처럼 중첩된 서브프로그램은 캡슐화, 네임스페이스 오염 방지, 코드 지역성 향상이라는 장점을 통해 더욱 구조적이고 유지보수하기 쉬운 코드를 작성하게 해주는 중요한 도구입니다.

8.4.2 전방 선언과 상호 재귀

Ada 컴파일러는 기본적으로 “선언하기 전에 사용할 수 없다(declaration before use)”는 규칙을 따릅니다. 즉, 어떤 서브프로그램을 호출하려면, 그 호출문이 나오기 전에 해당 서브프로그램이 이미 선언되어 있어야 합니다. 하지만 두 서브프로그램이 서로를 호출해야 하는 상호 재귀(mutual recursion) 상황에서는 이 규칙을 지킬 방법이 없습니다.

AB를 호출하고, B가 다시 A를 호출하는 경우, AB 중 어느 것을 먼저 선언하더라도 다른 하나는 아직 선언되지 않은 상태이므로 컴파일 오류가 발생합니다. 이 문제를 해결하기 위해 Ada는 전방 선언(forward declaration)이라는 기법을 제공합니다.

전방 선언이란?

전방 선언은 서브프로그램의 전체 구현부(body)를 나중에 제공할 것을 컴파일러에게 약속하고, 서브프로그램의 명세(specification)만을 미리 선언하는 것입니다.

  • 명세(Specification): 서브프로그램의 ‘얼굴’에 해당합니다. 이름, 매개변수 목록, 그리고 함수일 경우 반환 타입을 포함합니다. is 키워드 없이 세미콜론(;)으로 끝납니다.
  • 구현부(Body): is ... begin ... end를 포함하는 실제 코드 블록입니다.

전방 선언을 통해 컴파일러는 “이러한 명세를 가진 서브프로그램이 이 선언부 어딘가에 존재하니, 아직 구현부가 없더라도 호출을 허용한다”라고 인지하게 됩니다.

구문:

-- 프로시저의 전방 선언
procedure Procedure_Name (Parameter_List);

-- 함수의 전방 선언
function Function_Name (Parameter_List) return Return_Type;

상호 재귀 문제 해결 예제

양의 정수가 짝수인지 홀수인지를 판별하는 두 함수 Is_EvenIs_Odd를 상호 재귀적으로 정의해 보겠습니다.

  • n이 짝수인지의 여부는 n-1이 홀수인지의 여부와 같다.
  • n이 홀수인지의 여부는 n-1이 짝수인지의 여부와 같다.
  • 기저 조건(base case): 0은 짝수이다.
procedure Test_Mutual_Recursion is

   -- 1. Is_Odd 함수의 명세를 미리 선언 (전방 선언)
   function Is_Odd (N : Positive) return Boolean;

   -- 2. Is_Even 함수의 전체 구현부 정의
   --    이제 Is_Even은 Is_Odd를 호출할 수 있음 (전방 선언 덕분에)
   function Is_Even (N : Positive) return Boolean is
   begin
      if N = 1 then
         return False;
      else
         return Is_Odd (N - 1);
      end if;
   end Is_Even;

   -- 3. 전방 선언했던 Is_Odd 함수의 실제 구현부 정의
   --    이 구현부의 명세는 전방 선언과 정확히 일치해야 함
   function Is_Odd (N : Positive) return Boolean is
   begin
      if N = 1 then
         return True;
      else
         return Is_Even (N - 1);
      end if;
   end Is_Odd;

   Result : Boolean;
begin
   Result := Is_Even (10); -- True
   Result := Is_Odd (7);   -- True
end Test_Mutual_Recursion;

핵심 규칙:

  1. 전방 선언된 서브프로그램의 구현부(body)는 반드시 동일한 선언 영역 내에 나중에라도 제공되어야 합니다.
  2. 구현부의 명세는 전방 선언된 명세와 파라미터, 반환 타입 등이 완전히 일치해야 합니다.

코드 구조화

전방 선언은 상호 재귀 해결 외에, 코드의 가독성을 위한 구조화 목적으로도 사용됩니다. 패키지 바디(package body)의 앞부분에 모든 서브프로그램의 전방 선언을 모아두면, 마치 목차처럼 패키지가 제공하는 기능 전체를 한눈에 파악할 수 있고, 실제 구현부는 그 뒤에 어떤 순서로든 자유롭게 배치할 수 있습니다.

이처럼 전방 선언은 순환적인 의존 관계를 해결하고 코드의 논리적 구조를 유연하게 만드는 데 필수적인 기법입니다.

8.5 매개변수 전달 기법

지금까지 우리는 서브프로그램을 선언하고 호출하는 방법을 배웠습니다. 하지만 서브프로그램의 진정한 힘은 호출하는 쪽(caller)과 데이터를 주고받으며 상호작용할 때 발휘됩니다. 이 데이터 통신의 통로 역할을 하는 것이 바로 매개변수(parameter) 또는 인자(argument)입니다.

매개변수는 서브프로그램에 작업을 수행하는 데 필요한 정보를 전달하고, 서브프로그램이 수행한 작업의 결과를 다시 호출자에게 돌려주는 역할을 합니다. Ada는 데이터의 흐름을 명확히 하고, 서브프로그램 호출을 더 유연하고 안전하게 만들기 위한 정교하고 강력한 매개변수 전달 기법들을 제공합니다.

이번 절에서는 다음과 같은 두 가지 핵심적인 주제를 다룹니다.

  1. 데이터 흐름의 방향 제어: in, out, in out 이라는 세 가지 매개변수 모드(parameter mode)를 통해, 데이터가 서브프로그램으로 ‘입력’만 되는지, 서브프로그램에서 ‘출력’만 되는지, 아니면 ‘입력되어 수정된 후 출력’되는지를 명시적으로 제어하는 방법을 배웁니다. 이는 서브프로그램의 동작을 명확히 하고 잠재적인 버그를 예방하는 중요한 안전장치입니다.

  2. 호출의 유연성과 가독성 향상: 명명된 매개변수(named parameter)를 사용하여 매개변수의 순서와 상관없이 그 의미를 명확히 하며 값을 전달하는 방법과, 기본 매개변수(default parameter)를 통해 특정 매개변수를 선택적으로 생략하여 서브프로그램 호출을 간소화하는 방법을 알아봅니다.

이러한 매개변수 전달 기법들을 능숙하게 사용하는 것은 재사용 가능할 뿐만 아니라, 그 의도가 명확하고 안전하며 사용하기 편리한 고품질의 서브프로그램을 설계하는 데 필수적입니다.

8.5.1 매개변수 모드: in, out, in out

서브프로그램과 데이터를 주고받을 때, 우리는 종종 “이 데이터는 서브프로그램에 정보를 주기 위한 것인가?”, “서브프로그램이 이 데이터를 수정해야 하는가?”, “서브프로그램이 이 변수에 결과를 담아 돌려주기만 해야 하는가?”와 같은 데이터의 흐름 방향을 고민하게 됩니다.

다른 많은 프로그래밍 언어에서는 이러한 구분이 모호하여 의도치 않게 원본 데이터가 변경되는 등의 버그가 발생하곤 합니다. Ada는 이러한 문제를 원천적으로 방지하고 데이터의 흐름을 명확히 하기 위해, 각 매개변수의 역할을 지정하는 세 가지 매개변수 모드(parameter mode)를 제공합니다.

매개변수 모드는 서브프로그램의 명세(specification)에 명시되며, 해당 매개변수가 어떻게 사용될 것인지에 대한 의도를 컴파일러와 다른 프로그래머에게 명확하게 전달합니다.

  1. in 모드:
    • 역할: 서브프로그램에 입력(input) 데이터를 전달하는 용도입니다.
    • 특징: 서브프로그램 내부에서 이 매개변수는 상수(constant)처럼 취급되어 값을 읽을 수만 있고, 절대 수정할 수 없습니다. 이는 기본(default) 모드입니다.
    • 데이터 흐름: 호출자(Caller) -> 서브프로그램
  2. out 모드:
    • 역할: 서브프로그램이 계산한 출력(output) 결과를 호출자에게 전달하는 용도입니다. (함수의 return 외에 결과를 전달하는 또 다른 방법입니다.)
    • 특징: 서브프로그램 내부에서 이 매개변수는 초기화되지 않은 변수처럼 취급됩니다. 서브프로그램은 반드시 이 매개변수에 값을 할당해야 하며, 할당하기 전에는 값을 읽을 수 없습니다.
    • 데이터 흐름: 서브프로그램 -> 호출자(Caller)
  3. in out 모드:
    • 역할: 호출자가 전달한 값을 서브프로그램이 수정(modification)하여 그 결과를 다시 호출자에게 반영하는 용도입니다.
    • 특징: 서브프로그램 내부에서 읽고 쓰는 것이 모두 가능한 변수로 취급됩니다.
    • 데이터 흐름: 호출자(Caller) <-> 서브프로그램

이러한 매개변수 모드는 단순한 주석이나 약속이 아닙니다. Ada 컴파일러는 각 모드의 규칙(예: in 모드 변수에 값을 할당하려는 시도)을 엄격하게 검사하여 위반 시 컴파일 오류를 발생시킵니다. 이는 서브프로그램의 동작을 예측 가능하게 하고, 코드의 안정성과 가독성을 극적으로 향상시키는 Ada의 핵심적인 안전장치입니다.

8.5.2 명명된 매개변수와 기본 매개변수

Ada는 서브프로그램 호출 시, 단순히 값의 순서에만 의존하는 방식의 한계를 넘어 코드의 가독성유연성을 극적으로 향상시키는 두 가지 강력한 매개변수 전달 기법을 제공합니다.

1. 명명된 매개변수 연관 (Named Parameter Association)

일반적으로 서브프로그램을 호출할 때 우리는 매개변수 선언 순서에 맞춰 값을 전달합니다. 이를 위치별 연관(positional association)이라고 합니다. 하지만 매개변수가 많거나 타입이 비슷할 경우, 각 값의 의미를 파악하기 어렵고 순서를 헷갈려 버그를 유발하기 쉽습니다.

-- 무엇을 하는지 한눈에 파악하기 어려운 위치별 호출
Create_Task ("Primary_Process", 10, 1024, True);

명명된 매개변수 연관(Named parameter association)은 => 기호를 사용하여 어떤 인자(argument)가 어떤 매개변수(parameter)에 전달되는지를 명시적으로 지정하는 방식입니다.

-- 명명된 매개변수를 사용하여 의미가 명확해진 호출
Create_Task (Name => "Primary_Process", Priority => 10, Stack_Size => 1024, Is_Daemon => True);

명명된 매개변수의 장점:

  • 최상의 가독성: 호출문 자체가 문서 역할을 합니다. 각 값이 무엇을 의미하는지 즉시 알 수 있습니다.
  • 유연한 순서: 매개변수의 순서를 신경 쓸 필요 없이, 원하는 순서대로 인자를 전달할 수 있습니다.
  • 안전성: 동일한 타입의 매개변수 순서를 실수로 바꾸는 오류를 방지할 수 있습니다.

규칙: 위치별 방식과 명명된 방식을 혼용할 수 있지만, 한번 명명된 방식을 사용하기 시작하면 그 뒤의 모든 인자도 반드시 명명된 방식을 사용해야 합니다.

2. 기본 매개변수 (Default Parameters)

서브프로그램을 설계할 때, 어떤 매개변수들은 대부분의 경우 특정 값으로 고정되지만 가끔 다른 값이 필요할 수 있습니다. 이러한 ‘선택적(optional)’ 매개변수를 위해 Ada는 기본 매개변수 기능을 제공합니다.

in 모드 매개변수를 선언할 때 := 연산자를 사용하여 기본값을 지정할 수 있습니다.

procedure Create_Window (
   Width      : Positive;
   Height     : Positive;
   Title      : String := "New Window"; -- 기본값 지정
   Resizable  : Boolean := True;        -- 기본값 지정
   Visible    : Boolean := True         -- 기본값 지정
) is
-- ...
end Create_Window;

기본 매개변수의 장점: 기본값이 지정된 매개변수는 호출 시 생략 가능합니다. 인자가 전달되지 않으면 선언부에 명시된 기본값이 자동으로 사용됩니다. 이는 서브프로그램 호출을 매우 간결하게 만들어 줍니다.

-- 1. 필수 매개변수만 전달 (나머지는 기본값 사용)
Create_Window (Width => 800, Height => 600);
-- Title은 "New Window", Resizable과 Visible은 True로 자동 설정됨

-- 2. 일부 기본값만 변경 (명명된 매개변수와 함께 사용할 때 가장 강력함)
Create_Window (Width => 1024, Height => 768, Resizable => False);
-- Resizable만 False로 변경하고, Title과 Visible은 여전히 기본값을 사용

이처럼 명명된 매개변수와 기본 매개변수는 함께 사용될 때 최고의 시너지를 발휘합니다. 개발자는 복잡한 서브프로그램을 위한 유연하고 상세한 인터페이스를 제공하면서도, 사용자는 가장 일반적인 경우에 대해 매우 단순하고 간결한 호출문을 사용할 수 있게 됩니다. 이는 잘 설계된 라이브러리와 애플리케이션의 핵심적인 특징입니다.

8.6 서브프로그램 오버로딩 (Overloading)

서브프로그램 오버로딩(overloading)은 동일한 유효 범위(scope) 내에서 여러 개의 서브프로그램이 같은 이름을 공유할 수 있도록 하는 기능입니다. 이는 이름은 같지만 서로 다른 매개변수나 반환 타입을 갖는 여러 버전의 서브프로그램을 정의할 수 있게 하여, 코드의 가독성과 API의 직관성을 높여줍니다.

8.6.1 오버로딩의 개념과 규칙

컴파일러는 서브프로그램 호출이 발생했을 때, 어떤 버전의 서브프로그램을 호출해야 할지 결정하기 위해 각 서브프로그램의 프로파일(profile)을 분석합니다. 서브프로그램의 프로파일은 다음 요소들로 구성됩니다.

  • 매개변수의 개수
  • 각 매개변수의 순서와 기반 타입(base type)
  • (함수의 경우) 반환 타입

컴파일러는 호출 시 제공된 인자들의 타입과 개수, 그리고 (함수의 경우) 반환값이 사용되는 문맥을 종합하여 가장 적합한 프로파일을 가진 서브프로그램을 선택합니다. 만약 호출이 모호하여 두 개 이상의 프로파일과 일치할 수 있는 경우, 컴파일러는 오류를 보고하여 프로그래머가 명확한 호출을 하도록 유도합니다.

8.6.2 오버로딩의 활용 사례

서브프로그램 오버로딩은 단순히 문법적인 기능에 그치지 않고, 소프트웨어의 API(Application Programming Interface)를 더 직관적이고 사용하기 쉽게 만드는 데 매우 유용하게 사용됩니다. 이번 절에서는 오버로딩이 실제로 어떻게 활용되는지 대표적인 사례들을 통해 살펴보겠습니다.

사례 1: 동일한 연산을 다른 타입에 적용

가장 일반적이고 강력한 활용 사례는 개념적으로는 동일한 연산을 서로 다른 데이터 타입에 적용하는 경우입니다. 대표적인 예가 ‘출력’ 기능입니다. 우리는 정수, 실수, 문자열 등 다양한 타입의 값을 화면에 출력하고 싶지만, 내부적인 처리 방식은 각기 다릅니다.

오버로딩을 사용하면, 사용자는 Print라는 단 하나의 이름만 기억하면 됩니다.

-- 정수 값을 출력하는 프로시저
procedure Print (Value : Integer) is
begin
   -- Ada.Text_IO.Put (Integer'Image (Value));
end Print;

-- 문자열을 출력하는 프로시저
procedure Print (Value : String) is
begin
   -- Ada.Text_IO.Put (Value);
end Print;

-- Boolean 값을 출력하는 프로시저
procedure Print (Value : Boolean) is
begin
   if Value then
      Print ("True");
   else
      Print ("False");
   end if;
end Print;

-- 활용
-- Print (101);
-- Print ("Hello, World!");
-- Print (True);

사용자는 Print_Integer, Print_String 처럼 구별된 이름을 외울 필요 없이, 그저 Print 프로시저를 호출하기만 하면 됩니다. 컴파일러가 전달되는 인자의 타입을 보고 가장 적절한 Print 프로시저를 알아서 선택해 주기 때문입니다. 이는 다형성(polymorphism)의 한 형태(ad-hoc polymorphism)로, 코드의 사용 편의성을 크게 높여줍니다.

사례 2: 다양한 방법으로 객체 생성

복잡한 객체를 생성할 때, 여러 가지 다른 정보 소스를 기반으로 객체를 초기화하는 방법을 제공하고 싶을 수 있습니다. 이때 오버로딩은 매우 유용한 ‘생성자(constructor)’ 또는 ‘팩토리 함수(factory function)’ 패턴을 구현하게 해줍니다.

예를 들어, ‘시간’을 나타내는 Time 레코드 객체를 생성하는 여러 방법을 Create라는 단일 함수 이름으로 제공할 수 있습니다.

type Time is record
   Hour, Minute, Second : Natural;
end record;

-- 시, 분, 초로 Time 객체 생성
function Create (H, M, S : Natural) return Time;

-- "HH:MM:SS" 형식의 문자열로부터 Time 객체 생성
function Create (From_String : String) return Time;

-- 자정부터 경과한 총 초로부터 Time 객체 생성
function Create (Total_Seconds : Natural) return Time;

사용자는 자신이 가지고 있는 데이터의 형태(개별 정수, 문자열, 총 초 등)에 가장 편리한 Create 함수를 골라서 사용할 수 있습니다. 이는 API 사용자에게 높은 수준의 유연성을 제공합니다.

사례 3: 타입에 따른 다른 동작 제공

때로는 동일한 이름의 연산이라도 타입의 특성에 따라 전혀 다른 방식으로 동작해야 할 수 있습니다. 예를 들어, Process라는 프로시저가 User 타입에 대해서는 권한을 검사하고, Data_Packet 타입에 대해서는 체크섬(checksum)을 검증하도록 만들 수 있습니다.

procedure Process (U : in User); -- 유저 관련 처리
procedure Process (P : in Data_Packet); -- 패킷 관련 처리

이처럼 오버로딩은 서브프로그램의 이름을 연산의 ‘의미’ 중심으로 통합하고, 구체적인 구현의 ‘방법’은 타입에 따라 분리할 수 있게 해주는 강력한 추상화 도구입니다.

8.7 연산자 오버로딩 (Operator Overloading)

서브프로그램 오버로딩의 가장 강력하고 표현력 있는 활용 사례 중 하나는 연산자 오버로딩(operator overloading)입니다. 이는 +, *, = 와 같은 표준 연산자들의 동작을 우리가 직접 정의한 새로운 타입에 맞게 재정의하는 기능입니다.

우리는 IntegerFloat 같은 내장 숫자 타입에 대해 A + B 와 같은 연산이 당연하다고 생각합니다. 연산자 오버로딩을 통해, 2차원 벡터 Vector_A + Vector_B 나 복소수 Complex_1 * Complex_2 와 같은 연산도 이처럼 자연스럽고 직관적인 형태로 표현할 수 있습니다.

C := Add_Vectors(A, B); 와 같은 함수 호출 방식보다 C := A + B; 방식이 훨씬 더 읽기 쉽고 수학적 표기법에 가깝습니다. 이처럼 연산자 오버로딩은 코드를 간결하게 만들고 가독성을 극대화하는 중요한 도구입니다.

연산자 오버로딩 구문

연산자 오버로딩은 함수를 선언하되, 함수의 이름을 연산자 기호를 포함하는 문자열 리터럴(" ")로 지정하는 방식으로 이루어집니다.

-- 이항(binary) 연산자 '+' 오버로딩
function "+" (Left, Right : Vector) return Vector;

-- 단항(unary) 연산자 '-' 오버로딩
function "-" (Right : Vector) return Vector;

-- 동등 비교 '=' 연산자 오버로딩
function "=" (Left, Right : Vector) return Boolean;

오버로딩된 연산자는 그 본질이 함수이므로, 모든 함수의 규칙을 동일하게 따릅니다.

활용 예제: 2차원 벡터 연산 정의

2차원 벡터 타입을 위한 덧셈(+)과 스칼라 곱셈(*) 연산을 오버로딩해 보겠습니다.

package Vector_Arithmetic is
   type Vector is record
      X, Y : Float;
   end record;

   -- 벡터 덧셈 (+) 오버로딩
   function "+" (Left, Right : Vector) return Vector;

   -- 스칼라 곱셈 (*) 오버로딩
   function "*" (Left : Vector; Right : Float) return Vector;

end Vector_Arithmetic;

package body Vector_Arithmetic is
   function "+" (Left, Right : Vector) return Vector is
   begin
      return (X => Left.X + Right.X, Y => Left.Y + Right.Y);
   end "+";

   function "*" (Left : Vector; Right : Float) return Vector is
   begin
      return (X => Left.X * Right, Y => Left.Y * Right);
   end "*";
end Vector_Arithmetic;

-- 활용 예시
with Vector_Arithmetic; use Vector_Arithmetic;
procedure Test_Vector_Ops is
   V1 : constant Vector := (X => 1.0, Y => 2.0);
   V2 : constant Vector := (X => 3.0, Y => 4.0);
   V3, V4 : Vector;
begin
   -- 오버로딩된 연산자를 사용하여 자연스러운 수식 표현
   V3 := V1 + V2;    -- V3 becomes (X => 4.0, Y => 6.0)
   V4 := V3 * 10.0;  -- V4 becomes (X => 40.0, Y => 60.0)
end Test_Vector_Ops;

위 예제처럼, 연산자 오버로딩을 통해 Vector 타입을 마치 내장 숫자 타입처럼 자연스럽게 다룰 수 있게 되었습니다.

규칙 및 제한사항

Ada의 연산자 오버로딩은 강력하지만, 언어의 일관성을 해치지 않도록 몇 가지 중요한 규칙이 있습니다.

  1. 새로운 연산자 생성 불가: Ada에 이미 존재하는 연산자(+, -, and 등)만 오버로딩할 수 있으며, **$$ 와 같은 새로운 연산자를 만들 수는 없습니다.
  2. 피연산자 개수 변경 불가: 이항 연산자를 단항으로, 또는 단항 연산자를 이항으로 바꿀 수 없습니다.
  3. 우선순위 및 결합법칙 변경 불가: 연산자의 우선순위(precedence)와 결합법칙(associativity)은 바꿀 수 없습니다. *는 언제나 +보다 먼저 계산됩니다.
  4. 사용자 정의 타입 필수: 매개변수 중 적어도 하나는 반드시 사용자 정의 타입(레코드, 비공개 타입 등)이어야 합니다. 즉, IntegerInteger의 덧셈처럼 내장 타입 간의 연산을 재정의할 수는 없습니다.

이러한 규칙들은 연산자 오버로딩이 남용되어 코드의 예측 가능성을 해치는 것을 방지하고, 그 장점을 안전하게 활용할 수 있도록 보장합니다.

8.8 재귀 (Recursion)

지금까지 우리는 서브프로그램이 다른 서브프로그램을 호출하는 일반적인 경우를 살펴보았습니다. 하지만 서브프로그램이 자기 자신을 직접 또는 간접적으로 다시 호출하는 특별한 경우가 있는데, 이를 재귀(recursion)라고 부릅니다.

재귀는 ‘큰 문제를 해결하기 위해, 그보다 작은 똑같은 유형의 문제를 해결하는’ 방식으로 접근하는 강력한 문제 해결 기법입니다. 이는 마치 러시아의 전통 인형 ‘마트료시카’처럼, 큰 인형 안에 그보다 작은 똑같은 모양의 인형이 반복되어 들어있는 구조와 유사합니다.

모든 올바른 재귀적 해결책은 반드시 다음 두 가지 요소를 포함해야 합니다.

  1. 기저 조건 (Base Case): 더 이상 재귀 호출을 하지 않고 직접 답을 반환하는 가장 단순한 경우입니다. 이는 무한한 호출의 고리를 끊는 ‘탈출 조건’ 역할을 하며, 재귀 함수에서 가장 중요한 부분입니다.

  2. 재귀 단계 (Recursive Step): 주어진 문제를 더 작은 단위로 쪼개고, 그 작은 문제의 답을 얻기 위해 자기 자신을 다시 호출하는 부분입니다.

재귀는 특정 종류의 문제, 특히 트리(tree) 구조 순회나 분할 정복(divide-and-conquer) 알고리즘, 수학적 정의(예: 팩토리얼)를 매우 우아하고 직관적으로 표현할 수 있게 해줍니다. 하지만 잘못 사용하면 프로그램을 무한 루프에 빠뜨리거나 스택 메모리를 과도하게 사용하여 문제를 일으킬 수도 있습니다.

이번 절에서는 재귀의 기본 개념과 대표적인 예시를 통해 그 작동 원리를 이해하고, 재귀적 접근법이 가지는 장점과 단점을 자세히 살펴보겠습니다.

8.8.1 재귀의 개념과 활용

재귀(Recursion)란 하나의 서브프로그램이 자기 자신의 코드를 다시 호출하는 프로그래밍 기법을 의미합니다. 이는 순환(iteration)을 사용하는 forwhile 루프와 함께, 반복적인 작업을 수행하는 또 다른 강력한 방법입니다. 루프가 정해진 횟수나 조건에 따라 작업을 ‘수평적으로’ 반복한다면, 재귀는 문제를 더 작은 동일한 문제로 ‘수직적으로’ 파고들며 해결해 나갑니다.

재귀의 두 가지 핵심 요소

모든 올바른 재귀적 서브프로그램은 반드시 다음 두 가지 구성 요소를 가져야만 합니다. 이 중 하나라도 빠지면 재귀는 제대로 동작하지 않고, 무한 호출에 빠져 스택 오버플로우(Stack Overflow) 오류를 일으키게 됩니다.

  1. 기저 조건 (Base Case) 재귀 호출의 고리를 끊는 ‘탈출 조건’입니다. 이는 가장 단순하여 더 이상 문제를 쪼갤 필요 없이 즉시 답을 알 수 있는 경우를 말합니다. 모든 재귀 호출의 흐름은 언젠가 반드시 이 기저 조건에 도달해야만 합니다.

  2. 재귀 단계 (Recursive Step) 현재의 문제를 해결하기 위해, 문제의 규모를 줄여 자기 자신을 다시 호출하는 부분입니다. 여기서 가장 중요한 것은 재귀 호출의 인자가 점차 기저 조건을 향해 수렴해야 한다는 점입니다. 예를 들어, F(N)F(N-1)을 호출한다면, N값이 1씩 줄어들어 언젠가는 기저 조건(예: N=0)에 도달하게 됩니다.

개념적 흐름:

function Recursive (Problem) is
begin
   if Problem이_충분히_단순하다면 then -- 1. 기저 조건 (Base Case)
      return 직접_계산한_답;
   else                               -- 2. 재귀 단계 (Recursive Step)
      Smaller_Problem := Problem을_더_작게_만들기;
      Result_Of_Smaller := Recursive (Smaller_Problem); -- 자기 자신 호출
      return Result_Of_Smaller를_이용해_최종_답_계산;
   end if;
end Recursive;

재귀의 주요 활용 분야

재귀는 특정 유형의 문제들을 매우 자연스럽고 우아하게 표현할 수 있게 해주어, 다음과 같은 분야에서 널리 활용됩니다.

  • 수학적 정의 구현: 팩토리얼(Factorial)이나 피보나치 수열(Fibonacci sequence)과 같이 그 정의 자체가 재귀적인 수학 함수를 코드로 옮길 때 매우 직관적입니다.

  • 자료 구조 순회: 폴더와 하위 폴더 구조처럼 계층적인 구조를 가진 트리(Tree)나 그래프(Graph)와 같은 복잡한 자료구조의 모든 노드를 방문(순회)할 때, 재귀는 가장 자연스러운 표현 방법입니다. “현재 노드를 처리하고, 모든 자식 노드에 대해 동일한 작업을 재귀적으로 수행한다”는 간단한 논리로 전체 구조를 탐색할 수 있습니다.

  • 분할 정복 (Divide and Conquer) 알고리즘: 복잡한 문제를 더 이상 나눌 수 없을 때까지 작은 하위 문제들로 분할한 뒤, 각 하위 문제의 답을 구하고 이를 다시 조합하여 원래 문제의 답을 찾는 알고리즘입니다. 병합 정렬(Merge Sort)이나 퀵 정렬(Quick Sort)과 같은 효율적인 정렬 알고리즘들이 분할 정복 기법의 대표적인 예시이며, 이는 본질적으로 재귀적인 접근입니다.

재귀적 사고방식에 익숙해지는 것은 문제 해결 능력을 한 차원 높여주는 중요한 과정입니다. 이어지는 절에서는 구체적인 예시를 통해 재귀가 어떻게 코드로 구현되는지 자세히 살펴보겠습니다.

8.8.2 재귀의 장점과 단점

재귀는 특정 문제를 해결하는 매우 우아한 도구이지만, 모든 상황에 적합한 만병통치약은 아닙니다. 반복적인 작업을 위해 재귀를 사용할지, 아니면 forwhile 같은 반복문(iteration)을 사용할지는 코드의 명확성, 성능, 그리고 메모리 사용량 사이의 트레이드오프(trade-off)를 고려하여 신중하게 결정해야 합니다.

재귀의 장점 (Advantages)

  1. 코드의 우아함과 가독성: 재귀의 가장 큰 장점은 문제의 정의 자체가 재귀적일 때, 코드가 매우 간결하고 직관적으로 표현된다는 점입니다. 트리(tree) 자료구조 순회나 분할 정복(divide-and-conquer) 알고리즘과 같은 문제는 반복문으로 구현하면 로직이 매우 복잡해지지만, 재귀를 사용하면 문제의 논리적 구조를 코드에 그대로 반영할 수 있어 이해하기 쉽습니다.

  2. 복잡한 문제의 단순화: 재귀는 복잡한 문제를 더 작은 단위의 동일한 문제로 나누어 생각하게 함으로써, 문제 해결 과정을 단순화합니다. 이는 특히 상태를 추적하기 복잡한 문제에서, 명시적인 스택(stack) 자료구조를 사용하는 등의 번거로움을 줄여줍니다.

재귀의 단점 (Disadvantages)

  1. 성능 오버헤드 (Performance Overhead): 재귀 호출은 본질적으로 서브프로그램 호출입니다. 따라서 호출이 일어날 때마다 매개변수와 지역 변수 등을 스택에 저장하고, 호출이 끝나면 스택에서 제거하는 과정(스택 프레임 관리)이 필요합니다. 단순한 배열 순회와 같이 반복문으로 쉽게 해결되는 문제의 경우, 재귀를 사용하면 이러한 함수 호출 오버헤드 때문에 성능이 저하될 수 있습니다.

  2. 스택 메모리 사용과 스택 오버플로우 (Stack Overflow): 재귀의 가장 치명적인 단점입니다. 재귀 호출이 깊어질수록 스택 메모리는 계속해서 누적됩니다. 만약 기저 조건이 잘못되었거나 재귀의 깊이가 너무 깊어지면, 프로그램에 할당된 스택 공간을 모두 소진하여 스택 오버플로우(Stack Overflow) 오류를 발생시키며 프로그램이 비정상적으로 종료될 수 있습니다. 반면, 반복문은 일반적으로 정해진 양의 스택 메모리만 사용하므로 이러한 위험에서 상대적으로 자유롭습니다.

  3. 디버깅의 어려움: 재귀 함수의 실행 흐름은 여러 함수 호출이 중첩된 형태이므로, 특정 단계에서 값이 어떻게 변하는지 추적하고 디버깅하기가 반복문에 비해 더 까다로울 수 있습니다.

[심화] 꼬리 재귀 최적화 (Tail Call Optimization)

단점에도 불구하고 재귀의 우아함을 포기할 수 없는 경우를 위해, 일부 컴파일러는 꼬리 재귀 최적화(Tail Call Optimization, TCO)라는 기술을 제공합니다. 꼬리 호출(tail call)이란, 함수가 값을 반환하기 직전의 마지막 작업으로 자기 자신을 호출하는 것을 말합니다.

function Factorial_Tail_Recursive (N, Accumulator : Natural) return Natural is
begin
   if N = 0 then
      return Accumulator;
   else
      -- 이것이 꼬리 호출: 재귀 호출 외에 다른 연산이 없음
      return Factorial_Tail_Recursive (N - 1, N * Accumulator);
   end if;
end Factorial_Tail_Recursive;

GNAT과 같은 현대적인 Ada 컴파일러는 이러한 꼬리 호출을 감지하면, 새로운 스택 프레임을 쌓는 대신 기존의 스택 프레임을 재활용하는 방식으로 코드를 최적화합니다. 결과적으로 꼬리 재귀 함수는 반복문과 동일한 수준의 성능과 메모리 효율성을 갖게 됩니다.

결론적으로, 문제의 본질이 재귀적이고 코드의 명확성을 높일 수 있다면 재귀는 훌륭한 선택입니다. 하지만 성능이 매우 중요하거나 재귀의 깊이를 예측할 수 없는 경우, 더 안전하고 효율적인 반복문을 사용하는 것이 바람직합니다. 항상 문제에 가장 적합한 도구를 선택하는 지혜가 필요합니다.

8.9 [심화] 서브프로그램 계약 (Pre- and Post-conditions)

지금까지 서브프로그램이 어떤 일을 하는지 설명하기 위해 우리는 주석을 사용해 왔습니다. 하지만 주석은 컴파일러가 이해하지 못하며, 코드가 변경될 때 함께 수정된다는 보장이 없어 오래된 정보가 될 수 있습니다. 높은 신뢰성이 요구되는 시스템에서는, 서브프로그램의 동작을 보다 형식적이고 강제적으로 명시할 방법이 필요합니다.

이를 위해 Ada 2012부터 도입된 계약 기반 설계(Design by Contract) 기능은 서브프로그램의 명세(specification)에 그것의 요구사항보장사항을 직접 명시할 수 있게 해줍니다. 이 계약은 단순한 주석이 아니라, 컴파일러가 확인하고 런타임에 검사할 수 있는 실행 가능한 코드의 일부입니다.

계약의 핵심 요소는 사전 조건(Precondition)과 사후 조건(Postcondition)입니다.

1. 사전 조건 (with Pre => ...) : 호출자의 의무

사전 조건(Precondition)은 서브프로그램이 올바르게 실행되기 위해 호출되기 전에 반드시 만족되어야 하는 조건입니다. 이 조건을 만족시키는 것은 전적으로 호출자(caller)의 책임입니다.

with Pre => 조건식 구문을 사용하여 서브프로그램 명세에 사전 조건을 명시합니다.

function Square_Root (X : Float) return Float
  with Pre => X >= 0.0;

위 코드에서 Pre => X >= 0.0Square_Root 함수를 호출하는 코드는 반드시 X에 0 이상의 값을 전달해야 한다는 계약입니다. 만약 음수 값을 전달하면, 함수의 본문이 실행되기도 전에 Assertion_Error 예외가 발생하여 잘못된 호출임을 즉시 알려줍니다. 이는 잘못된 데이터로 인해 함수 내부에서 발생할 수 있는 더 복잡한 오류를 사전에 차단합니다.

2. 사후 조건 (with Post => ...) : 서브프로그램의 보장

사후 조건(Postcondition)은 서브프로그램이 실행을 성공적으로 마친 후, 반드시 만족시켜야 하는 조건입니다. 이 조건을 만족시키는 것은 서브프로그램의 구현부(body)의 책임입니다.

사후 조건에서는 종종 서브프로그램 실행 전의 값과 후의 값을 비교해야 합니다. 이때 'Old 속성을 사용하여 매개변수의 초기 값을 참조할 수 있습니다.

procedure Increment (Value : in out Integer)
  with Post => Value = Value'Old + 1;

위 계약은 Increment 프로시저가 실행되고 나면, Value의 최종 값은 반드시 실행 전의 Value 값(Value'Old)에 1을 더한 것과 같아야 함을 보장합니다. 만약 구현부의 실수로 Value가 1만큼 증가하지 않았다면, 프로시저가 종료된 직후 Assertion_Error가 발생합니다.

종합 예제: 은행 계좌 출금

계좌에서 돈을 출금하는 프로시저에 계약을 적용해 보겠습니다.

procedure Withdraw (Balance : in out Money_Type; Amount : in Money_Type)
  with
    Pre  => Amount > 0 and then Balance >= Amount,
    Post => Balance = Balance'Old - Amount;
  • 사전 조건: 호출자는 반드시 0보다 큰 금액(Amount > 0)을 출금해야 하며, 출금할 금액이 현재 잔고(Balance)를 초과해서는 안 된다.
  • 사후 조건: Withdraw 프로시저는 실행 후, 반드시 BalanceAmount만큼 정확히 감소시켜야 한다.

계약 기반 설계의 장점

  • 신뢰성 향상: 계약은 실행 가능한 단정문(assertion)으로 동작하여, 개발 및 테스트 단계에서 버그를 조기에 발견하도록 돕습니다.
  • 명확한 문서화: 계약 자체가 가장 명확하고 신뢰할 수 있는 문서 역할을 합니다. 호출자와 구현부의 책임 소재가 코드 상에 명확히 드러납니다.
  • 디버깅 용이성: 계약 위반으로 발생하는 Assertion_Error는 오류의 원인이 호출자에게 있는지(사전 조건 위반), 아니면 구현부 자체에 있는지(사후 조건 위반)를 명확하게 알려주어 디버깅을 쉽게 만듭니다.
  • 정적 분석 지원: 정적 분석 도구들은 이 계약 정보를 활용하여, 코드를 실행하지 않고도 잠재적인 계약 위반을 찾아낼 수 있습니다.

이처럼 서브프로그램 계약은 코드의 신뢰성과 명확성을 극적으로 향상시키는 고급 기능으로, 특히 안전이 최우선인(safety-critical) 시스템 개발에서 Ada가 가진 강력함을 보여주는 대표적인 예입니다.

8.10 [심화] 서브프로그램 호출과 스택 프레임

우리가 서브프로그램을 호출할 때, 이는 단순히 코드의 특정 위치로 제어가 이동하는 것 이상의 의미를 가집니다. Ada 런타임 시스템은 이 과정을 체계적으로 관리하기 위해 호출 스택(call stack)이라는 메모리 영역을 사용합니다. 호출 스택은 서브프로그램의 실행과 복귀를 추적하는 핵심적인 자료구조입니다.

8.10.1 스택 프레임 (stack frame)

서브프로그램이 호출될 때마다, 해당 호출을 위한 정보 묶음인 스택 프레임(stack frame)이 호출 스택의 맨 위에 쌓입니다(pushed). 스택 프레임은 해당 서브프로그램의 실행이 끝날 때까지 유지되며, 다음과 같은 정보를 포함합니다.

  • 매개변수 (parameters): 호출자가 전달한 인자 값들이 저장되는 공간입니다.
  • 지역 변수 (local variables): 서브프로그램 내부에서 선언된 변수들이 저장됩니다.
  • 반환 주소 (return address): 서브프로그램 실행이 끝난 후, 제어권을 되돌려줄 호출자 코드의 위치입니다.
  • 기타 관리 정보: 이전 스택 프레임의 위치 등 실행 상태를 유지하기 위한 추가 정보가 포함됩니다.

서브프로그램이 return하거나 마지막 end에 도달하면, 해당하는 스택 프레임은 스택에서 제거되고(popped), 저장된 반환 주소로 실행 흐름이 돌아갑니다. 이처럼 스택은 후입선출(LIFO, Last-In-First-Out) 방식으로 동작합니다.

8.10.2 재귀 호출과 스택 사용량

7.6절에서 배운 재귀(Recursion)는 이 스택 프레임 개념으로 명확하게 이해할 수 있습니다. 서브프로그램이 자기 자신을 호출할 때마다, 이전 호출과는 완전히 별개의 새로운 스택 프레임이 스택에 계속해서 쌓입니다.

예를 들어 Factorial(3)을 호출하면, Factorial(3), Factorial(2), Factorial(1)을 위한 스택 프레임이 차례대로 스택에 쌓이게 됩니다. 종료 조건(N=0)에 도달해야 비로소 스택 프레임들이 역순으로 하나씩 제거되면서 최종 결과가 계산됩니다.

8.10.3 스택 오버플로우: 원인과 방지책

호출 스택에 할당된 메모리 공간은 유한합니다. 만약 재귀 호출의 깊이가 너무 깊어지거나, 종료 조건이 없어 무한히 호출이 이어진다면, 스택 프레임이 계속 쌓이다가 결국 할당된 공간을 모두 소진하게 됩니다. 이 상태를 스택 오버플로우(Stack Overflow)라고 하며, 프로그램은 Storage_Error 예외와 함께 비정상적으로 종료됩니다.

스택 오버플로우를 방지하기 위한 주요 전략은 다음과 같습니다.

  1. 신뢰할 수 있는 종료 조건: 모든 재귀 알고리즘은 반드시 언젠가 참이 되는 명확한 종료 조건(base case)을 가져야 합니다.
  2. 꼬리 재귀 최적화 (Tail-Call Optimization): 만약 재귀 호출이 서브프로그램의 가장 마지막에 수행되는 유일한 연산이라면(꼬리 호출), GNAT과 같은 현대적인 컴파일러는 이를 최적화하여 새로운 스택 프레임을 쌓는 대신 현재 스택 프레임을 재사용할 수 있습니다. 이는 사실상 재귀를 효율적인 반복문처럼 변환하여 스택 오버플로우를 원천적으로 방지합니다.
  3. 반복문으로의 전환: 모든 재귀 알고리즘은 반복문(iteration)으로 변환할 수 있습니다. 재귀가 코드의 가독성을 높여주는 경우도 있지만, 스택 사용량이 우려되는 깊은 재귀의 경우 반복문으로 작성하는 것이 더 안전한 대안입니다.

서브프로그램 호출의 이면에서 동작하는 스택 프레임의 원리를 이해하는 것은 재귀를 올바르게 사용하고, Storage_Error와 같은 런타임 오류를 예방하며, 궁극적으로 안정적이고 효율적인 프로그램을 작성하는 데 필수적인 지식입니다.

9. 패키지 (Packages): 모듈화와 정보 은닉

소프트웨어 시스템의 규모와 복잡성이 증가함에 따라, 전체 시스템을 이해하고 관리하기 용이한 논리적 단위로 분할하는 능력은 프로젝트의 성공에 결정적인 요소가 됩니다. 소프트웨어 공학에서는 이러한 접근법을 모듈화(modularity)라 칭하며, 이는 유지보수성을 향상시키고, 재사용성을 증대시키며, 개발자 간의 협업을 촉진하는 핵심 원칙으로 기능합니다.

Ada 언어는 패키지(package)라는 강력한 구문을 통해 모듈화와 정보 은닉(information hiding)을 언어 차원에서 체계적으로 지원합니다. 패키지는 관련된 타입, 데이터 객체, 서브프로그램, 그리고 다른 패키지들을 하나의 이름공간(namespace) 아래에 묶는 논리적 컨테이너 역할을 수행합니다.

그러나 패키지의 본질적 가치는 단순한 그룹화에 그치지 않습니다. Ada 패키지는 명세(specification)와 본체(body)의 분리를 통해, 외부에는 반드시 필요한 인터페이스만을 노출하고 내부의 복잡한 구현 세부사항은 은닉하는 ‘블랙박스’ 모델을 구현합니다. 패키지 명세는 클라이언트(client) 코드가 해당 모듈을 사용하기 위해 알아야 하는 모든 정보를 담은 공개 계약(public contract)으로 작용하며, 패키지 본체는 이 계약을 이행하는 실제 구현을 포함합니다.

이러한 분리 구조는 소프트웨어의 결합도(coupling)를 낮추고 응집도(cohesion)를 높이는 결과를 가져옵니다. 클라이언트는 패키지의 내부 구현 방식이 변경되더라도, 공개 인터페이스가 동일하게 유지되는 한 아무런 영향을 받지 않습니다. 이는 시스템의 특정 부분을 수정했을 때 발생할 수 있는 예기치 않은 파급 효과를 최소화하여, 프로그램의 안정성과 유지보수성을 극대화합니다.

본 장에서는 Ada 패키지의 기본 구조와 문법을 시작으로, 사유 타입(private type)과 제한 타입(limited type)을 이용한 데이터 추상화 기법을 탐구할 것입니다. 나아가 자식 패키지(child package)를 통한 계층적 설계, 패키지의 생명주기 관리, 그리고 순환 의존성과 같은 대규모 시스템 설계 시 발생하는 현실적인 문제들을 해결하는 고급 기법에 대해 학습할 것입니다. 이를 통해 독자께서는 패키지를 단순한 코드 묶음이 아닌, 견고하고 재사용 가능한 소프트웨어 컴포넌트를 구축하는 핵심 도구로 활용하는 능력을 갖추게 될 것입니다.

9.1 패키지의 기본 구조 (Basic Structure of a Package)

앞서 설명드렸듯이, 패키지는 Ada에서 모듈화와 정보 은닉을 구현하는 핵심 구성 요소입니다. 본 절에서는 이러한 패키지를 구성하는 가장 기본적인 문법적 구조와 그 역할에 대해 학습하겠습니다.

Ada의 모든 패키지는 원칙적으로 명세(specification)본체(body)라는 두 개의 개별적인 단위로 구성됩니다. 이 두 부분은 보통 별도의 파일에 저장되며, 각각 명확히 구분되는 목적을 가집니다.

  1. 패키지 명세 (Package Specification) 패키지 명세는 해당 패키지가 외부(클라이언트 코드)에 제공하는 공개 인터페이스(public interface)를 정의합니다. 여기에는 외부에서 호출하거나 사용할 수 있는 타입, 상수, 변수, 서브프로그램 등의 선언이 포함됩니다. 패키지 명세는 .ads 확장자를 가진 소스 파일에 작성되며, 외부 모듈과의 ‘계약(contract)’ 역할을 수행합니다. 클라이언트는 이 명세 파일만을 보고도 패키지의 모든 공개 기능을 사용하는 방법을 알 수 있어야 합니다.

  2. 패키지 본체 (Package Body) 패키지 본체는 명세에 선언된 기능들의 실제 구현(implementation)을 포함합니다. 명세에 선언된 서브프로그램의 완전한 코드는 반드시 본체 내에 작성되어야 합니다. 또한, 패키지 내부에서만 사용되고 외부에는 노출될 필요가 없는 비공개(private) 타입, 변수, 보조 서브프로그램 등도 패키지 본체에 위치합니다. 패키지 본체는 .adb 확장자를 가진 파일에 작성되며, 그 내용은 클라이언트에게 완전히 숨겨집니다.

이처럼 인터페이스와 구현을 물리적으로 분리하는 것은 Ada 패키지 설계의 가장 중요한 특징입니다. 이 구조를 통해 패키지의 내부 구현 로직이 변경되더라도, 공개 명세에 변경이 없다면 해당 패키지를 사용하는 클라이언트 코드는 아무런 영향을 받지 않고 재컴파일할 필요가 없습니다.

이어지는 내용에서는 패키지 명세와 본체의 구체적인 작성법을 살펴보고, withuse 절을 통해 다른 패키지의 기능을 프로그램으로 가져오는 방법을 알아보겠습니다.

9.1.1 소프트웨어 공학과 추상화의 필요성

초기의 컴퓨터 프로그램은 그 규모가 작고 구조가 단순하여 개발자 한 명이 전체 시스템의 모든 세부 사항을 파악하는 것이 가능했습니다. 그러나 현대의 소프트웨어 시스템, 예를 들어 항공기 제어 시스템, 금융 거래망, 또는 운영체제 등은 수백만, 수천만 줄의 코드로 구성되며 그 복잡성은 과거와 비교할 수 없습니다.

이러한 대규모 시스템을 아무런 구조적 원칙 없이 개발하는 것은 현실적으로 불가능합니다. 시스템의 크기가 커질수록 구성 요소 간의 상호 의존성은 기하급수적으로 증가하며, 이는 다음과 같은 문제들을 야기합니다.

  • 이해의 어려움 (Difficulty of Comprehension): 시스템의 한 부분을 이해하기 위해 다른 많은 부분의 동작 방식을 알아야만 합니다.
  • 수정의 파급 효과 (Ripple Effect of Modification): 코드의 작은 변경이 의도치 않게 시스템의 다른 부분에 오류를 유발할 수 있습니다.
  • 재사용의 한계 (Limitation of Reuse): 특정 기능이 다른 부분과 너무 강하게 얽혀 있어, 해당 기능만 분리하여 다른 곳에서 재사용하기가 어렵습니다.
  • 협업의 비효율성 (Inefficiency of Collaboration): 여러 개발자가 동시에 시스템을 수정할 때, 서로의 작업이 충돌할 가능성이 높아집니다.

소프트웨어 공학은 이러한 복잡성의 문제를 해결하고, 예측 가능하며 신뢰할 수 있는 방식으로 소프트웨어를 개발하기 위해 정립된 학문 분야입니다. 그리고 이 복잡성을 다루는 가장 근본적인 지적 도구가 바로 추상화(abstraction)입니다.

추상화는 특정 시스템이나 객체의 필수적인 특징은 강조하되, 불필요하고 비본질적인 세부 사항은 숨기는 과정입니다. 예를 들어, 우리는 자동차를 운전할 때 가속 페달, 브레이크, 핸들 등 잘 정의된 인터페이스를 통해 자동차와 상호작용합니다. 엔진 내부의 연소 과정이나 변속기의 기계적 동작 원리를 알지 못해도 운전이 가능한 것은, 자동차라는 복잡한 시스템이 운전자에게 높은 수준으로 추상화되어 있기 때문입니다.

소프트웨어 개발에서도 마찬가지입니다. 잘 설계된 소프트웨어 모듈은, 자동차의 인터페이스처럼, 자신이 수행하는 기능(무엇을 하는가, What)만을 외부에 명확히 공개하고, 그 기능을 어떻게 수행하는지(How)에 대한 복잡한 내부 과정은 감춥니다.

이러한 추상화의 원칙을 언어 차원에서 구현하는 도구가 바로 Ada의 패키지입니다. 이어지는 절들에서는 Ada 패키지가 어떻게 명세와 본체의 분리를 통해 추상화를 실현하고, 대규모 소프트웨어 개발에 필수적인 구조적 기틀을 제공하는지 구체적으로 학습하게 될 것입니다.

9.1.2 패키지 명세(specification): 공개 인터페이스 (.ads)

패키지 명세는 패키지의 공개된 얼굴(public face)이며, 해당 패키지와 상호작용하는 외부 클라이언트에게 일종의 계약서(contract) 역할을 합니다. 명세는 패키지가 “무엇을(what)” 제공하는지에 대한 모든 정보를 담고 있으며, “어떻게(how)” 그 기능을 수행하는지에 대한 내부 구현은 완전히 감춥니다. Ada 컴파일러는 이 명세를 통해 클라이언트 코드가 패키지의 기능을 올바르게 사용하고 있는지를 검증합니다.

패키지 명세는 관례적으로 .ads라는 파일 확장자를 사용합니다. 명세 파일에는 외부에서 접근 가능해야 하는 다음과 같은 요소들이 선언됩니다.

  • 타입 (types): 클라이언트가 사용해야 할 데이터의 종류를 정의합니다.
  • 객체 (objects): 상수(constants)나 변수(variables)를 선언하여 외부에 값을 제공하거나 상태를 공유할 수 있습니다. 다만, 패키지 명세에 변수를 직접 노출하는 것은 정보 은닉의 원칙을 해칠 수 있어 신중하게 사용해야 합니다.
  • 서브프로그램 (subprograms): 클라이언트가 호출할 수 있는 프로시저(procedure)와 함수(function)의 선언부를 포함합니다. 서브프로그램의 실제 구현 코드는 명세가 아닌 본체에 위치합니다.
  • 자식 패키지 (child packages): 계층적 구조를 이루는 다른 패키지의 명세를 선언할 수 있습니다.
  • 제네릭 유닛 (generic units): 타입이나 값에 독립적인 재사용 가능한 컴포넌트를 정의합니다.
  • 예외 (exceptions): 패키지 사용 중 발생할 수 있는 예외 상황을 선언합니다.

다음은 간단한 카운터(counter) 기능을 제공하는 패키지의 명세 예시입니다.

-- File: simple_counter.ads

package Simple_Counter is

  procedure increment;
  -- 정수 카운터의 값을 1 증가시킵니다.

  procedure reset;
  -- 정수 카운터의 값을 0으로 초기화합니다.

  function get_value return Integer;
  -- 현재 정수 카운터의 값을 반환합니다.

end Simple_Counter;

simple_counter.ads 파일은 클라이언트에게 세 가지 기능만을 약속합니다.

  1. increment 프로시저를 호출할 수 있다.
  2. reset 프로시저를 호출할 수 있다.
  3. get_value 함수를 호출하여 정수(Integer) 값을 얻을 수 있다.

클라이언트는 카운터 값이 실제로 어디에, 어떻게 저장되는지에 대해서는 전혀 알 필요가 없으며 알 수도 없습니다. 오직 이 명세에 정의된 인터페이스만을 통해 카운터와 상호작용할 뿐입니다. 이처럼 패키지 명세는 클라이언트에게 필요한 최소한의 정보만을 제공하는, 잘 정의된 창구 역할을 수행합니다.

9.1.3 패키지 본체(body): 비공개 구현 (.adb)

패키지 명세가 외부 세계와의 계약을 정의한다면, 패키지 본체는 그 계약을 이행하는 실제 작업 공간입니다. 본체는 명세에서 약속한 기능들이 어떻게 동작하는지에 대한 모든 구현 세부사항을 포함하며, 이러한 내용은 패키지를 사용하는 클라이언트에게 철저히 숨겨집니다. 이 정보 은닉(information hiding)이야말로 패키지가 제공하는 강력한 추상화의 핵심입니다.

패키지 본체는 관례적으로 .adb라는 파일 확장자를 사용합니다 (b는 ‘body’를 의미합니다).

패키지 본체의 주요 역할은 다음과 같습니다.

  1. 서브프로그램 구현: 명세에 선언된 모든 서브프로그램(프로시저, 함수)의 완전한 실행 코드를 제공합니다.
  2. 비공개 요소 정의: 패키지 기능을 구현하는 데 필요하지만 외부에 노출할 필요가 없는 내부 변수, 상수, 타입, 보조 서브프로그램 등을 선언하고 사용합니다. 이러한 요소들은 오직 패키지 본체 내부에서만 접근할 수 있습니다.
  3. 초기화 코드 실행: 패키지가 메모리에 로드될 때(정교화 시) 단 한 번 실행되어야 할 초기화 코드를 포함할 수 있습니다.

앞선 9.1.2절에서 정의한 Simple_Counter 패키지의 본체는 다음과 같이 작성할 수 있습니다.

-- File: simple_counter.adb

package body Simple_Counter is

  the_value : Integer := 0;
  -- 이 변수는 패키지 본체 내부에 선언되어 외부에 완전히 숨겨집니다.
  -- Simple_Counter를 사용하는 클라이언트는 ahe_value의 존재 자체를 알 수 없습니다.

  procedure increment is
  begin
    the_value := the_value + 1;
  end increment;

  procedure reset is
  begin
    the_value := 0;
  end reset;

  function get_value return Integer is
  begin
    return the_value;
  end get_value;

end Simple_Counter;

위의 simple_counter.adb 파일을 통해 몇 가지 중요한 사실을 알 수 있습니다.

  • package body Simple_Counter is ... end Simple_Counter; 구문으로 본체를 정의합니다.
  • 카운터의 현재 값을 저장하는 the_value 변수는 명세가 아닌 본체에 선언되었습니다. 따라서 이 변수는 Simple_Counter 패키지의 비공개 자산이며, 외부에서는 get_value, increment, reset 프로시저를 통하지 않고는 절대 접근하거나 수정할 수 없습니다.
  • 명세에 선언되었던 세 서브프로그램의 실제 동작이 begin ... end; 블록 안에 구체적으로 구현되어 있습니다.

이러한 명세와 본체의 분리는 컴파일 과정에서도 중요한 이점을 제공합니다. Simple_Counter 패키지의 본체(simple_counter.adb) 내부 로직을 아무리 변경하더라도, 명세(Simple_Counter.ads)가 변경되지 않는 한, 이 패키지를 사용하는 클라이언트 코드는 다시 컴파일할 필요가 없습니다. 이는 대규모 시스템에서 빌드 시간을 크게 단축시키고, 시스템의 안정성을 높이는 데 기여합니다.

9.1.4 with 절을 이용한 의존성 명시

지금까지 우리는 하나의 패키지를 독립적으로 설계하고 구현하는 방법을 살펴보았습니다. 그러나 실제 프로그램은 여러 개의 패키지가 서로의 기능을 호출하며 유기적으로 상호작용하는 복잡한 구조를 가집니다. 어떤 패키지가 다른 패키지의 기능을 사용하기 위해서는, 먼저 컴파일러에게 해당 패키지에 대한 의존성이 존재함을 명시적으로 알려야 합니다. Ada에서는 이 역할을 with 절이 수행합니다.

with 절은 특정 컴파일 단위(패키지 또는 서브프로그램)가 다른 패키지의 공개 명세에 접근해야 함을 선언하는 지시어입니다. with 절에 명시된 패키지의 모든 공개 선언(타입, 객체, 서브프로그램 등)은 해당 컴파일 단위 내에서 사용 가능하게 됩니다.

with 절의 구문은 매우 간단하며, 항상 컴파일 단위의 가장 앞부분에 위치해야 합니다.

with Ada.Text_IO;

procedure Hello_World is
begin
  Ada.Text_IO.put_line ("Hello, World!");
end Hello_World;

위 예제는 Ada.Text_IO라는 표준 라이브러리 패키지를 사용하기 위해 with 절을 선언하고 있습니다.

  • with Ada.Text_IO;: 이 구문을 통해 Hello_World 프로시저는 Ada.Text_IO 패키지의 명세에 선언된 모든 자원에 접근할 수 있는 권한을 얻습니다.
  • Ada.Text_IO.put_line (...): with 절로 가져온 패키지의 자원을 사용할 때는, 패키지_이름.자원_이름과 같은 점 표기법(dot notation)을 사용해야 합니다. 이는 put_line이라는 프로시저가 Ada.Text_IO 패키지에 속해 있음을 명확히 나타냅니다.

여러 개의 패키지에 대한 의존성은 하나의 with 절에 쉼표로 구분하여 나열하거나, 여러 개의 with 절을 연달아 작성하여 명시할 수 있습니다.

-- 쉼표로 구분하여 나열
with Ada.Text_IO, Ada.Integer_Text_IO;

-- 별도의 with 절로 작성
with Ada.Text_IO;
with Ada.Strings.Unbounded;

두 방식 모두 동일하게 동작하며, 프로젝트의 코딩 스타일에 따라 선택할 수 있습니다.

중요한 점은 with 절이 단지 다른 패키지로의 ‘연결 통로’를 열어주는 역할만 한다는 것입니다. 즉, Ada.Text_IOwith 했다고 해서 put_line이라는 이름을 직접 사용할 수는 없으며, 반드시 Ada.Text_IO.put_line처럼 전체 이름을 명시해야 합니다. 이는 이름 충돌을 방지하고 코드의 출처를 명확하게 하여 가독성을 높이는 Ada의 중요한 설계 철학입니다.

이어지는 9.1.5절에서는 use 절을 통해 이러한 전체 이름 표기를 간소화하는 방법을 학습하되, 그에 따르는 장점과 위험성에 대해서도 함께 고찰할 것입니다.

9.1.5 use 절을 이용한 이름공간 관리

앞선 절에서 with 절을 통해 다른 패키지의 자원을 가져온 후에는, 패키지_이름.자원_이름 형태의 점 표기법(dot notation)을 사용하여 해당 자원에 접근해야 함을 학습했습니다. 이는 코드의 출처를 명확히 하여 가독성을 높이는 안전한 방식입니다.

하지만 패키지 이름을 반복적으로 명시하는 것은 코드를 길고 번잡하게 만들 수 있습니다. use 절은 이러한 점 표기법의 사용을 생략할 수 있도록, 특정 패키지의 이름들을 현재 이름공간(namespace)으로 직접 가져오는 메커니즘을 제공합니다.

use 절의 기본 구문과 동작

use 절은 with 절 다음에 위치하며, 다음과 같은 형식으로 사용됩니다.

with Ada.Text_IO;
use Ada.Text_IO;

procedure Hello_World_With_Use is
begin
  -- 'use' 절을 통해 Ada.Text_IO.put_line을 'put_line'으로 직접 호출 가능
  put_line ("Hello, World!");
end Hello_World_With_Use;

use Ada.Text_IO; 구문을 통해, Ada.Text_IO 패키지 명세에 선언된 put_line과 같은 이름들이 현재 유효범위(scope) 안으로 들어왔습니다. 결과적으로 Ada.Text_IO.put_line 대신 put_line이라는 이름만으로 프로시저를 직접 호출할 수 있게 됩니다.

use 절의 위험성과 설계 지침

use 절은 코드 작성을 편리하게 하지만, 남용할 경우 심각한 부작용을 초래할 수 있어 신중하게 사용해야 합니다.

  1. 이름 충돌 (Name Collision): 서로 다른 두 패키지에서 동일한 이름의 자원을 use 절을 통해 가져올 경우, 컴파일러는 어떤 자원을 사용해야 할지 결정할 수 없어 모호성 오류(ambiguity error)를 발생시킵니다.
  2. 가독성 저하 (Reduced Readability): 코드에 some_procedure (x);와 같은 호출문이 있을 때, use 절이 여러 개 있다면 some_procedure가 어느 패키지에서 온 것인지 즉시 파악하기 어렵습니다. 이는 코드의 유지보수를 어렵게 만드는 주요 원인이 됩니다.

이러한 이유로, 다음과 같은 설계 지침을 따르는 것이 권장됩니다.

  • 패키지 명세(.ads)에서는 use 절을 사용하지 마십시오. 패키지 명세는 외부와의 명확한 계약이므로, 모든 이름의 출처가 명확히 드러나는 점 표기법을 사용하는 것이 원칙입니다.
  • 패키지 본체(.adb)나 프로시저 내부에서는 제한적으로 사용하십시오. use 절의 영향 범위가 좁은 곳에서는 가독성을 해치지 않는 선에서 편의를 위해 사용할 수 있습니다.

use type 절: 안전한 대안

Ada는 use 절의 위험성을 완화하면서 편의성을 제공하는 더 안전한 대안으로 use type 절을 제공합니다. use type 절은 특정 타입과 연관된 연산자(operator)들만을 현재 이름공간으로 가져옵니다.

package Vectors is
   type Vector is record
      x, y, z : Float;
   end record;

   -- Vector 타입을 위한 "+" 연산자 오버로딩
   function "+" (left, right : Vector) return Vector;
end Vectors;

-- 클라이언트 코드
with Vectors;
procedure Vector_Test is
   use type Vectors.Vector; -- Vectors.Vector 타입의 연산자(+)만 직접 사용 가능하게 함

   v1 : Vectors.Vector := (x => 1.0, y => 2.0, z => 0.0);
   v2 : Vectors.Vector := (x => 3.0, y => 4.0, z => 0.0);
   v3 : Vectors.Vector;
begin
   -- use type 덕분에 Vectors."+"(v1, v2) 대신 v1 + v2 사용 가능
   v3 := v1 + v2;
end Vector_Test;

위 예제에서 use type Vectors.Vector;Vectors 패키지에 있는 다른 서브프로그램이나 타입은 그대로 두고, 오직 Vector 타입의 피연산자를 갖는 "+" 연산자만을 v1 + v2 형태로 쓸 수 있게 허용합니다. 이는 이름공간 오염을 최소화하면서 코드의 가독성을 높이는 매우 효과적인 방법입니다.

결론적으로 use 절은 그 편리함 이면에 위험성이 존재하므로 항상 신중하게 접근해야 하며, 연산자와 관련된 편의성을 위해서는 use type 절을 우선적으로 고려해야 합니다.

9.1.6 예제: 간단한 정수 스택(integer stack) 패키지 구현

지금까지 학습한 패키지의 기본 구조 개념들을 종합하여, 고전적인 자료구조인 스택(stack)을 정수를 저장하는 패키지로 구현하는 예제를 살펴보겠습니다. 이 예제는 패키지 명세와 본체의 역할, 그리고 정보 은닉의 원칙이 실제 코드에서 어떻게 적용되는지를 명확하게 보여줍니다.

1. 패키지 명세 (integer_stacks.ads)

먼저, 스택 패키지의 공개 인터페이스를 정의하는 명세 파일입니다. 이 명세는 스택을 사용하는 클라이언트에게 필요한 모든 기능(push, pop, is_empty)과 발생 가능한 예외(Stack_Error)를 약속합니다.

-- File: integer_stacks.ads

package Integer_Stacks is

   Stack_Error : exception;

   procedure push (item : in Integer);
   -- 스택의 최상단에 'item'을 추가합니다.
   -- 스택이 가득 차 있으면 Stack_Error 예외를 발생시킵니다.

   procedure pop (item : out Integer);
   -- 스택의 최상단에서 항목을 제거하여 'item'에 반환합니다.
   -- 스택이 비어 있으면 Stack_Error 예외를 발생시킵니다.

   function is_empty return Boolean;
   -- 스택이 비어 있으면 True를, 그렇지 않으면 False를 반환합니다.

end Integer_Stacks;

이 명세 파일만 보고 클라이언트는 다음 사실을 알 수 있습니다.

  • pushpop 프로시저를 통해 정수를 스택에 넣거나 뺄 수 있습니다.
  • is_empty 함수를 통해 스택이 비었는지 확인할 수 있습니다.
  • 잘못된 조작(가득 찬 스택에 push, 빈 스택에서 pop) 시 Stack_Error 예외가 발생할 수 있습니다.
  • 스택이 내부적으로 어떻게 데이터를 저장하는지, 용량은 얼마인지 등은 전혀 알 수 없습니다.

2. 패키지 본체 (integer_stacks.adb)

다음은 명세에서 약속한 기능들의 실제 구현을 담고 있는 본체 파일입니다.

-- File: integer_stacks.adb

package body Integer_Stacks is

   MAX_SIZE : constant Positive := 100;
   stack    : array (1 .. MAX_SIZE) of Integer;
   top      : Integer range 0 .. MAX_SIZE := 0;

   procedure push (item : in Integer) is
   begin
      if top = MAX_SIZE then
         raise Stack_Error;
      end if;
      top := top + 1;
      stack (top) := item;
   end push;

   procedure pop (item : out Integer) is
   begin
      if top = 0 then
         raise Stack_Error;
      end if;
      item := stack (top);
      top  := top - 1;
   end pop;

   function is_empty return Boolean is
   begin
      return top = 0;
   end is_empty;

end Integer_Stacks;

본체는 다음과 같은 구현 세부사항을 포함하며, 이는 클라이언트에게 완전히 감춰집니다.

  • MAX_SIZE: 스택의 최대 용량을 100으로 정의하는 내부 상수입니다.
  • stack: 실제 정수 값들을 저장하는 배열입니다.
  • top: 스택의 가장 마지막 요소 위치를 가리키는 인덱스 변수입니다.

push, pop 프로시저는 top 인덱스를 조작하여 stack 배열에 데이터를 추가하거나 제거합니다. MAX_SIZEtop = 0 같은 경계 조건을 검사하여 Stack_Error를 발생시키는 로직 또한 본체 내부에 구현되어 있습니다.

3. 클라이언트 코드 예시

Integer_Stacks 패키지를 사용하는 클라이언트는 다음과 같이 with 절로 패키지를 가져와 명세에 정의된 기능만을 사용하여 프로그램을 작성합니다.

with Ada.Text_IO;
with Ada.Integer_Text_IO;
with Integer_Stacks;

procedure main is
  use Ada.Text_IO;
  use Ada.Integer_Text_IO;
  item : Integer;
begin
  put_line ("Pushing 10 and 20 onto the stack.");
  Integer_Stacks.push (10);
  Integer_Stacks.push (20);

  put_line ("Popping from stack...");
  Integer_Stacks.pop (item);
  put ("Popped: ");
  put (item, width => 1);
  new_line;
exception
  when Integer_Stacks.Stack_Error =>
    put_line ("Error: Stack operation failed.");
end main;

이 예제는 Integer_Stacks 패키지를 통해 스택의 내부 구현에 전혀 접근하지 않고도 pushpop이라는 잘 정의된 인터페이스만으로 모든 작업을 수행하고 있습니다. 만약 나중에 Integer_Stacks의 내부 구현을 배열이 아닌 연결 리스트(linked list)로 변경하더라도, 명세가 동일하게 유지되는 한 이 Main 프로시저 코드는 단 한 줄도 수정할 필요가 없습니다. 이것이 바로 Ada 패키지가 제공하는 강력한 모듈화와 정보 은닉의 힘입니다.

9.2 정보 은닉과 데이터 추상화 (Information Hiding and Data Abstraction)

앞선 9.1절에서는 패키지가 명세(specification)와 본체(body)로 분리되는 구조적 특징을 학습했습니다. 이러한 구조는 소프트웨어 공학에서 가장 중요한 두 가지 원칙, 즉 정보 은닉(Information Hiding)데이터 추상화(Data Abstraction)를 실현하기 위한 언어적 기반이 됩니다. 본 절에서는 이 두 가지 원칙의 개념을 이해하고, 이를 Ada 언어에서 어떻게 구현하는지 구체적으로 살펴보겠습니다.

정보 은닉은 시스템의 각 모듈이 자신의 내부 설계 결정(자료 구조, 알고리즘 등)을 외부에 노출하지 않도록 숨기는 설계 원칙입니다. 9.1.6절의 스택 예제에서, 스택이 내부적으로 배열로 구현되었다는 사실과 그 크기(MAX_SIZE), 그리고 최상단 위치를 가리키는 인덱스(top)는 패키지 본체 안에 숨겨져 있었습니다. 클라이언트는 이 정보에 직접 접근할 수 없으며, 오직 push, pop 등 공개된 인터페이스를 통해서만 스택을 조작할 수 있었습니다. 만약 내부 구현을 배열에서 연결 리스트로 변경하더라도, 공개 인터페이스만 동일하다면 클라이언트 코드는 아무런 영향을 받지 않습니다. 이처럼 정보 은닉은 모듈의 독립성을 높이고, 변경의 파급 효과를 최소화하여 시스템의 유지보수성을 크게 향상시킵니다.

데이터 추상화는 데이터의 구체적인 표현 방식은 감추고, 해당 데이터에 적용할 수 있는 연산(operations)만을 정의하여 새로운 데이터 타입(Abstract Data Type, ADT)을 만드는 과정입니다. 예를 들어, ‘스택’이라는 추상적 개념은 ‘데이터를 쌓고(push), 빼내는(pop) 것’이라는 행위로 정의됩니다. 그 데이터가 실제로 메모리 상의 배열에 저장되는지, 연결 리스트에 저장되는지는 추상화의 관점에서는 중요하지 않습니다. 데이터 추상화를 통해 개발자는 복잡한 내부 구현을 신경 쓰지 않고, 문제의 본질에 더 집중하여 프로그램을 설계할 수 있습니다.

정보 은닉은 데이터 추상화를 구현하는 핵심적인 기법입니다. 데이터의 내부 표현을 숨김으로써(정보 은닉), 해당 데이터에 대한 추상적인 관점을 사용자에게 제공할 수 있습니다(데이터 추상화).

Ada는 이러한 원칙들을 언어 차원에서 강력하게 지원하기 위해 사유 타입(private type)제한 타입(limited type)이라는 특별한 메커니즘을 제공합니다. 이어지는 절들에서는 이 기능들을 사용하여 클라이언트의 부적절한 데이터 접근을 원천적으로 차단하고, 완전한 데이터 추상화를 이루는 방법을 학습하게 될 것입니다.

9.2.1 데이터 추상화의 원칙

데이터 추상화는 복잡한 자료 구조의 내부적인 표현과 구현 세부사항은 숨기고, 해당 자료 구조에 적용할 수 있는 연산들의 집합을 통해 그 자료 구조의 본질적인 동작만을 정의하는 것을 원칙으로 합니다. 이 원칙의 핵심 목표는 ‘무엇을 하는가(what it does)’와 ‘어떻게 하는가(how it does it)’를 명확히 분리하는 것입니다.

이 원칙을 통해 우리는 추상 데이터 타입(Abstract Data Type, ADT) 이라는 개념을 정립할 수 있습니다. ADT는 다음과 같은 두 가지 요소로 정의됩니다.

  1. 데이터의 명세(Specification): 데이터가 무엇인지, 그리고 그 데이터가 어떤 상태를 가질 수 있는지에 대한 논리적 설명입니다.
  2. 연산의 집합(Set of Operations): 해당 데이터를 조작하고 접근할 수 있는 유일한 수단인 서브프로그램(프로시저, 함수)들의 목록입니다.

예를 들어 ‘스택(Stack)’이라는 ADT를 생각해 보겠습니다.

  • 데이터 명세: 스택은 순서가 있는 항목들의 컬렉션으로, 한쪽 끝에서만 데이터의 추가(push)와 제거(pop)가 일어나는 후입선출(Last-In, First-Out) 방식의 자료 구조입니다.
  • 연산의 집합:
    • create(): 새로운 빈 스택을 생성합니다.
    • push(item): 스택의 최상단에 항목을 추가합니다.
    • pop(): 스택의 최상단 항목을 제거하고 반환합니다.
    • is_empty(): 스택이 비어있는지 확인합니다.
    • peek(): 스택의 최상단 항목을 제거하지 않고 반환합니다.

데이터 추상화의 원칙에 따르면, 스택을 사용하는 프로그래머는 위 연산들의 사용법만 알면 충분합니다. 스택이 내부적으로 고정 크기 배열로 구현되었는지, 동적 배열로 구현되었는지, 혹은 연결 리스트로 구현되었는지는 알 필요가 없으며, 알아서도 안 됩니다.

이처럼 ADT는 클라이언트와 구현부 사이에 추상화의 벽(wall of abstraction) 을 세웁니다. 클라이언트는 이 벽의 바깥쪽에서 공개된 연산만을 통해 데이터에 접근하며, 벽의 안쪽에 있는 구체적인 데이터 표현 방식과 알고리즘은 볼 수 없습니다.

이러한 원칙은 다음과 같은 중대한 공학적 이점을 제공합니다.

  • 구현의 독립성 (Implementation Independence): ADT의 내부 구현을 변경하더라도 (예: 성능 개선을 위해 배열 기반 스택을 연결 리스트 기반으로 교체), 공개된 연산의 명세만 그대로 유지된다면 클라이언트 코드는 전혀 수정할 필요가 없습니다. 이는 유지보수 비용을 획기적으로 줄여줍니다.
  • 정보의 지역화 (Localization of Information): 특정 데이터 타입에 대한 모든 정보(표현, 알고리즘)는 하나의 모듈(패키지) 내에 집중되어 있습니다. 이로 인해 시스템의 다른 부분에 미치는 영향을 최소화하면서 해당 데이터 타입을 이해하고 수정하기가 용이해집니다.
  • 무결성 보장 (Integrity Assurance): 데이터는 오직 정의된 연산을 통해서만 조작될 수 있으므로, 클라이언트가 데이터를 임의로 수정하여 유효하지 않은 상태(예: 스택의 top 인덱스를 배열 범위를 벗어나게 조작)로 만드는 것을 원천적으로 방지할 수 있습니다.

Ada는 사유 타입(private type)과 같은 강력한 언어 기능을 통해 이러한 데이터 추상화의 원칙을 엄격하게 강제할 수 있도록 지원합니다. 이어지는 절에서는 이 기능들을 사용하여 완전한 ADT를 구현하는 방법을 학습하겠습니다.

9.2.2 사유 타입 (Private Types)

앞선 9.1.6절의 스택 예제는 패키지 본체에 데이터를 숨겨 정보 은닉을 달성했지만, 한계점 또한 명확합니다. 해당 패키지는 단 하나의 전역 스택만을 제공하므로, 클라이언트가 여러 개의 독립적인 스택을 생성하여 사용할 수 없습니다.

이 문제를 해결하기 위해 클라이언트가 직접 스택 타입의 변수를 선언할 수 있도록, 패키지 명세에 Stack 타입을 정의하는 방법을 생각해 볼 수 있습니다. 가장 단순한 접근법은 레코드(record) 타입을 명세에 직접 노출하는 것입니다.

-- 잘못된 설계의 예시
package Exposed_Stack is
   MAX_SIZE : constant Positive := 100;
   type Stack is record
      elements : array (1 .. MAX_SIZE) of Integer;
      top      : Integer range 0 .. MAX_SIZE := 0;
   end record;
   -- ... push, pop 등의 서브프로그램 선언 ...
end Exposed_Stack;

그러나 이 설계는 데이터 추상화의 원칙을 심각하게 위반합니다. Stack 타입의 내부 구조(즉, elements 필드와 top 필드)가 클라이언트에게 완전히 노출되기 때문입니다. 클라이언트는 pushpop과 같은 공식적인 인터페이스를 사용하지 않고도, My_Stack.top := -1; 과 같이 레코드의 필드에 직접 접근하여 데이터를 조작할 수 있습니다. 이는 데이터의 무결성을 깨뜨리고 스택을 유효하지 않은 상태로 만들 수 있는 매우 위험한 설계입니다.

Ada는 이러한 문제를 해결하고 완전한 데이터 추상화를 지원하기 위해 사유 타입(private type) 메커니즘을 제공합니다.

사유 타입은 패키지 명세를 두 부분으로 나누어 정의합니다.

  1. 공개 선언 (Public Declaration): 패키지 명세의 공개적인 부분에 type 이름 is private; 구문을 사용하여 타입을 선언합니다. 이는 클라이언트에게 타입의 이름만 알려줄 뿐, 그 내부 구조는 숨깁니다. 클라이언트의 관점에서 이 타입은 불투명(opaque)합니다.
  2. 전체 선언 (Full Declaration): 패키지 명세의 맨 끝, private 키워드 뒤에 오는 비공개(private) 부분에 해당 타입의 완전한 정의(예: 레코드 구조)를 명시합니다. 이 부분은 컴파일러가 해당 타입의 크기와 구조를 파악하는 데 사용되지만, 클라이언트 코드에서는 접근할 수 없습니다.

다음은 사유 타입을 사용하여 재설계한 스택 ADT(Abstract Data Type)의 명세입니다.

-- File: stack_adt.ads

package Stack_ADT is

   type Stack is private;

   Stack_Error : exception;

   procedure push (s : in out Stack; item : in Integer);
   -- 스택 s에 item을 추가합니다.

   procedure pop (s : in out Stack; item : out Integer);
   -- 스택 s에서 항목을 꺼내 item에 반환합니다.

   function is_empty (s : in Stack) return Boolean;
   -- 스택 s가 비어있는지 확인합니다.

private

   MAX_SIZE : constant Positive := 100;

   type Stack is record
      elements : array (1 .. MAX_SIZE) of Integer;
      top      : Integer range 0 .. MAX_SIZE := 0;
   end record;

end Stack_ADT;

이 새로운 설계에서는 클라이언트가 Stack_ADT.Stack 타입의 변수를 여러 개 생성할 수 있으면서도, 그 변수의 내부 필드인 elementstop에는 절대 직접 접근할 수 없습니다. My_Stack.top과 같은 코드는 컴파일 시점에 오류로 처리됩니다. 클라이언트는 오직 Stack_ADT 패키지가 제공하는 push, pop, is_empty 연산을 통해서만 Stack 객체를 조작할 수 있습니다.

이처럼 사유 타입은 데이터의 표현을 완벽하게 숨김으로써, 클라이언트의 부적절한 접근을 언어 차원에서 원천적으로 차단하고 데이터의 무결성을 보장하는 강력한 데이터 추상화 도구입니다.

다만, 사유 타입으로 선언된 객체는 기본적으로 대입(:=) 연산과 동등 비교(=, /=) 연산이 허용됩니다. 이러한 연산마저 통제해야 할 필요가 있을 때는 이어지는 절에서 학습할 ‘제한 타입(limited type)’을 사용해야 합니다.

9.2.3 제한 타입 (Limited Types)

앞선 절에서 사유 타입(private type)을 사용하여 데이터의 내부 표현을 성공적으로 숨겼습니다. 이로써 클라이언트는 타입의 내부 필드에 직접 접근할 수 없게 되었습니다. 하지만, Ada의 사유 타입은 기본적으로 두 가지 연산을 허용합니다. 바로 대입(assignment, :=) 연산과 동등 비교(equality comparison, =/=) 연산입니다.

대부분의 경우 이 기본 연산들은 유용하지만, 특정 데이터 타입을 설계할 때는 이러한 연산마저도 금지해야 할 필요가 있습니다. 예를 들어, 파일 핸들(file handle)이나 네트워크 소켓(network socket)과 같은 시스템 자원을 관리하는 타입을 생각해 보십시오.

  • 대입의 문제: 파일 핸들 객체 File1을 다른 객체 File2에 대입(File2 := File1;)하는 경우, 이는 파일 자체를 복사하는 것이 아니라 단순히 자원을 가리키는 핸들 값만 복사합니다. 이로 인해 두 객체가 동일한 외부 자원을 가리키게 되어, 한 객체에서 파일을 닫으면 다른 객체는 유효하지 않은 핸들을 갖게 되는 등의 심각한 문제를 야기할 수 있습니다.
  • 비교의 문제: 두 파일 핸들 객체가 ‘같다’는 것은 무엇을 의미할까요? 단순히 핸들 값이 같은 것인지, 아니면 두 객체가 가리키는 파일의 내용이 같은 것인지 모호합니다. 이처럼 기본 동등 비교가 타입의 논리적 의미와 맞지 않는 경우가 많습니다.

이러한 상황에서 데이터 타입에 대한 통제 수준을 최대로 높이기 위해 Ada는 제한 타입(limited type)을 제공합니다.

제한 타입은 사유 타입의 특성을 모두 가지면서, 추가적으로 대입과 기본 동등 비교 연산을 금지하는 타입입니다. 타입이 limited로 선언되면, 해당 타입의 객체에 대해 :=, =, /= 연산을 사용하려는 모든 시도는 컴파일 시점에 오류로 처리됩니다.

제한 타입은 패키지 명세에서 limited private 키워드를 사용하여 선언합니다.

-- File: limited_stack_adt.ads

package Limited_Stack_ADT is

   type Stack is limited private; -- 'private' 대신 'limited private' 사용

   Stack_Error : exception;

   procedure push (s : in out Stack; item : in Integer);
   procedure pop (s : in out Stack; item : out Integer);
   function is_empty (s : in Stack) return Boolean;

private

   MAX_SIZE : constant Positive := 100;

   type Stack is record
      elements : array (1 .. MAX_SIZE) of Integer;
      top      : Integer range 0 .. MAX_SIZE := 0;
   end record;

end Limited_Stack_ADT;

Stack 타입이 limited private으로 변경됨에 따라, 이 타입을 사용하는 클라이언트는 이제 다음과 같은 제약을 받습니다.

with Limited_Stack_ADT;

procedure Client_Test is
   stack1 : Limited_Stack_ADT.Stack;
   stack2 : Limited_Stack_ADT.Stack;
begin
   Limited_Stack_ADT.push (stack1, 10);

   -- 아래의 대입문은 컴파일 오류를 발생시킵니다.
   -- 'Stack'이 제한 타입이므로 대입 연산이 금지되기 때문입니다.
   stack2 := stack1;  -- !! 컴파일 오류(Compile-Time Error) !!
end Client_Test;

이처럼 제한 타입은 타입 설계자에게 해당 타입의 객체가 어떻게 생성되고, 소멸하며, 복사될 수 있는지에 대한 완전한 통제권을 부여합니다. 만약 복사나 비교 기능이 필요하다면, 설계자는 Copy 프로시저나 Are_Equal 함수와 같이 명시적인 이름의 서브프로그램을 직접 구현하여 제공해야 합니다.

결론적으로, 제한 타입은 자원의 유일성이 보장되어야 하거나 객체의 복사 및 비교를 엄격하게 제어해야 하는 모든 종류의 ADT를 구현할 때 사용하는 가장 강력하고 안전한 데이터 추상화 메커니즘입니다.

9.2.4 지연된 상수 (Deferred Constants)

지금까지 패키지 명세의 비공개(private) 부분은 타입의 전체 선언을 숨기기 위한 용도로 사용되었습니다. 그러나 이 비공개 부분은 타입 선언뿐만 아니라, 클라이언트에게는 그 존재를 알리되 실제 값은 숨기고 싶은 상수를 정의하는 데에도 활용될 수 있습니다. 이러한 상수를 지연된 상수(deferred constant)라고 합니다.

일반적인 상수는 이름 : constant 타입 := 값;의 형태로 선언과 동시에 값이 할당됩니다. 이 상수가 패키지 명세의 공개 부분에 선언된다면, 그 값 또한 클라이언트에게 그대로 노출됩니다.

하지만 어떤 경우에는 상수의 존재와 타입은 공개하되, 실제 값은 패키지의 구현 세부사항으로 감추고 싶을 수 있습니다. 예를 들어, 특정 에러 메시지나, 외부 하드웨어와 통신하는 데 사용되는 특정한 제어 문자 같은 값들이 여기에 해당될 수 있습니다. 이 값을 패키지 명세에 직접 노출하면, 클라이언트 코드가 이 값에 의존하게 될 수 있으며, 나중에 이 값을 변경할 때 클라이언트 코드까지 수정해야 하는 문제가 발생합니다.

지연된 상수는 이러한 문제를 해결하기 위해 사유 타입(private type)과 유사한 선언 방식을 사용합니다.

  1. 불완전 선언 (Incomplete Declaration): 패키지 명세의 공개 부분에 이름 : constant 타입; 과 같이 값을 생략한 형태로 상수를 선언합니다.
  2. 전체 선언 (Full Declaration): 패키지 명세의 비공개(private) 부분에 이름 : constant 타입 := 실제_값; 형태로 상수의 완전한 정의를 제공합니다.

다음 예제는 메시지 패키지에서 최대 메시지 길이를 지연된 상수로 정의하는 방법을 보여줍니다.

package Message_Handler is

   MAX_MESSAGE_LENGTH : constant Positive;
   -- 상수의 이름과 타입은 공개하지만, 실제 값은 숨깁니다.

   -- ... 다른 서브프로그램 및 타입 선언 ...

private

   MAX_MESSAGE_LENGTH : constant Positive := 256;
   -- 비공개 부분에서 실제 값을 할당합니다.

end Message_Handler;

이 설계를 통해 Message_Handler 패키지를 사용하는 클라이언트는 Message_Handler.MAX_MESSAGE_LENGTH라는 상수를 코드에 사용할 수 있습니다. 예를 들어, 메시지를 담을 버퍼를 선언할 때 이 상수를 참조할 수 있습니다.

with Message_Handler;

procedure Process_Messages is
   message_buffer : String (1 .. Message_Handler.MAX_MESSAGE_LENGTH);
begin
   -- ...
end Process_Messages;

여기서 중요한 점은 클라이언트 코드가 MAX_MESSAGE_LENGTH의 실제 값이 ‘256’이라는 사실에는 전혀 의존하지 않는다는 것입니다. 나중에 시스템 요구사항이 변경되어 최대 길이를 512로 늘려야 할 경우, Message_Handler 패키지의 비공개 부분에 있는 값만 수정하면 됩니다.

-- Message_Handler.ads 파일의 비공개 부분 수정
private
   MAX_MESSAGE_LENGTH : constant Positive := 512; -- 256에서 512로 변경

이처럼 패키지 명세의 공개 부분은 변경되지 않았으므로, Process_Messages와 같은 클라이언트 코드는 다시 컴파일할 필요가 없습니다.

결론적으로, 지연된 상수는 패키지의 구현 세부사항에 속하는 특정 값을 정보 은닉의 원칙에 따라 안전하게 캡슐화하는 효과적인 기법입니다. 이는 패키지의 유연성과 유지보수성을 높이는 데 기여합니다.

9.3 패키지 계층과 이름공간 관리 (Package Hierarchy and Namespace Management)

앞선 절들에서는 단일 패키지를 통해 데이터와 연산을 캡슐화하고, 사유(private) 및 제한(limited) 타입을 이용하여 외부로부터 구현을 숨기는 방법을 학습했습니다. 이 기법들은 개별 모듈의 품질을 높이는 데 필수적입니다. 그러나 실제 대규모 소프트웨어 시스템은 수십, 수백 개의 패키지로 구성되며, 이들을 단순히 평면적인(flat) 구조로 나열하는 방식은 곧 한계에 부딪힙니다.

시스템의 규모가 커지면 다음과 같은 조직적인 문제가 발생합니다.

  • 이름공간 오염 (Namespace Pollution): 서로 다른 목적을 가진 패키지들이 이름이 충돌할 가능성이 커집니다.
  • 구조적 모호성 (Structural Ambiguity): 패키지들 간의 논리적 관계나 의존성을 파악하기 어려워 전체 시스템 아키텍처를 이해하기 힘듭니다.
  • 재사용의 어려움 (Difficulty of Reuse): 특정 기능과 관련된 여러 패키지들을 하나의 단위로 묶어 관리하기가 번거롭습니다.

이러한 문제들을 해결하기 위해 Ada는 파일 시스템의 디렉터리 구조와 유사한 계층적 패키지(hierarchical package) 체계를 제공합니다. 이를 통해 관련 있는 패키지들을 논리적인 부모-자식 관계로 묶어, 거대한 시스템을 체계적으로 구조화하고 관리할 수 있습니다.

이 계층 구조의 핵심은 자식 패키지(child package) 입니다. 자식 패키지는 기존 패키지(부모)의 이름공간 내에 새로운 이름공간을 생성합니다. 예를 들어, Ada 표준 라이브러리의 Ada.Strings.Unbounded 패키지는, Strings 패키지의 자식이며 Strings 패키지는 다시 Ada 패키지의 자식입니다. 이 구조는 Unbounded가 문자열(Strings) 처리 기능의 일부라는 것을 명확하게 보여줍니다.

이러한 계층적 접근법은 다음과 같은 이점을 제공합니다.

  1. 논리적 그룹화: 관련된 기능들을 하나의 상위 패키지 아래에 모아 시스템의 논리적 구조를 명확하게 표현할 수 있습니다.
  2. 이름공간 분할: 각 패키지 계층이 고유한 이름공간을 형성하므로, 이름 충돌의 위험 없이 다른 계층에 동일한 이름의 패키지를 생성할 수 있습니다.
  3. 세분화된 정보 은닉: 자식 패키지는 부모 패키지의 비공개(private) 부분에 접근할 수 있는 특별한 가시성 규칙을 가집니다. 이를 통해 서로 밀접하게 관련된 서브시스템을 구현하면서도, 시스템의 다른 부분에 대해서는 캡슐화를 유지하는 정교한 설계가 가능해집니다.

본 절에서는 자식 패키지를 정의하고 사용하는 방법, 공개 자식과 사유 자식의 차이점, 그리고 계층 구조 내에서의 가시성 규칙에 대해 상세히 학습합니다. 또한, 긴 패키지 이름을 간결하게 사용하기 위한 renames 절의 활용법도 함께 살펴보겠습니다.

9.3.1 복잡성 관리를 위한 계층 구조

소프트웨어를 구성하는 모듈, 즉 패키지의 수가 증가함에 따라, 이들을 단순히 나열하는 평면적(flat) 구조는 필연적으로 관리의 한계에 도달합니다. 수십, 수백 개의 패키지가 동일한 수준에 존재한다고 가정해 보십시오. 이는 마치 컴퓨터의 모든 파일을 하나의 최상위 폴더에 저장하는 것과 같습니다. 이러한 환경에서는 다음과 같은 문제들이 발생하며, 이는 시스템의 복잡성을 기하급수적으로 증가시킵니다.

  1. 전역 이름공간의 오염 (Pollution of the Global Namespace): 모든 패키지 이름은 유일해야 하므로, 새로운 패키지를 추가할 때마다 기존의 모든 패키지 이름과 충돌하지 않는지 확인해야 합니다. 이는 개발자의 창의성을 제약하고, Utilities_for_Network_Module_Version_2와 같이 길고 부자연스러운 이름을 양산하게 됩니다.
  2. 논리적 관계의 부재 (Absence of Logical Relationship): 파일 목록만으로는 어떤 패키지가 다른 패키지와 관련이 있는지, 어떤 패키지가 더 큰 기능의 일부인지 파악할 수 없습니다. 시스템의 아키텍처는 오직 외부 문서나 개발자의 기억에만 의존하게 되어, 시간이 지남에 따라 이해하고 유지보수하기가 매우 어려워집니다.
  3. 캡슐화의 한계 (Limitation of Encapsulation): 서로 밀접하게 연관되어 내부 데이터를 공유해야 하는 패키지 그룹이 있다고 가정해 봅시다. 평면 구조에서는 이들 간의 긴밀한 관계를 표현할 방법이 없습니다. 데이터를 공유하기 위해서는 모든 정보를 공개(public) 인터페이스에 노출해야 하며, 이는 시스템의 다른 모든 부분에 불필요한 정보를 노출시켜 정보 은닉의 원칙을 훼손합니다.

이러한 복잡성의 문제를 해결하기 위한 구조적 해법이 바로 계층 구조(hierarchy) 입니다.

계층 구조는 관련된 패키지들을 부모-자식 관계의 트리(tree) 형태로 조직하는 방식입니다. 부모 패키지는 더 큰 개념적 단위를 나타내는 이름공간을 형성하며, 자식 패키지들은 그 부모의 이름공간 내에 위치하여 더 구체적이고 세분화된 기능을 제공합니다.

예를 들어, 그래픽 처리 시스템을 설계한다고 상상해 봅시다. 평면 구조에서는 Shapes, Colors, Vectors, Rasterizer 등의 패키지가 모두 동일 선상에 놓입니다. 반면, 계층 구조에서는 Graphics라는 최상위 패키지를 두고, 그 아래에 Graphics.Shapes, Graphics.Colors, Graphics.Math.Vectors 와 같이 논리적 관계를 명확히 표현할 수 있습니다.

이러한 계층 구조는 다음과 같은 명확한 이점을 제공합니다.

  • 구조적 명료성: 패키지 이름 자체가 시스템의 아키텍처를 반영하는 문서 역할을 합니다. Graphics.Shapes라는 이름만 보아도 이 패키지가 그래픽 시스템의 일부이며, 도형과 관련된 기능을 담고 있음을 즉시 유추할 수 있습니다.
  • 이름 충돌 방지: 각 패키지는 자신의 부모가 형성하는 이름공간 내에 존재하므로, 다른 계층에 있는 동일한 이름의 패키지와 충돌하지 않습니다. Graphics.Math.VectorsPhysics.Motion.Vectors는 서로 다른 컨텍스트에서 공존할 수 있습니다.
  • 정교한 접근 제어: Ada의 계층 구조는 자식 패키지에게 부모 패키지의 비공개(private) 부분에 접근할 수 있는 특별한 권한을 부여합니다. 이를 통해 밀접하게 관련된 서브시스템 간에는 효율적인 데이터 공유를 허용하면서도, 서브시스템 외부에는 엄격한 캡슐화를 유지하는 이상적인 설계가 가능해집니다.

다음 절에서는 이러한 계층 구조를 실제로 구현하는 Ada의 핵심 메커니즘인 ‘자식 패키지’에 대해 구체적으로 학습하겠습니다.

9.3.2 자식 패키지 (Child Packages)

Ada에서 계층 구조를 구현하는 구체적인 메커니즘은 자식 패키지(child package) 입니다. 자식 패키지는 기존에 존재하는 부모 패키지(parent package)의 기능과 이름공간을 확장하는 새로운 패키지입니다.

자식 패키지는 점 표기법(dot notation)을 사용하여 부모_패키지.자식_패키지와 같은 형태로 선언됩니다. 이 이름 자체가 두 패키지 간의 부모-자식 관계를 명시합니다.

관례적으로, GNAT 컴파일러 환경에서는 소스 파일의 이름이 패키지 이름과 일치해야 합니다. 이때 패키지 이름의 점(.)은 하이픈(-)으로 변환됩니다. 예를 들어, Graphics.Shapes라는 패키지의 명세와 본체는 각각 graphics-shapes.adsgraphics-shapes.adb라는 파일에 저장됩니다.

자식 패키지는 그 가시성(visibility)에 따라 공개(public) 자식과 사유(private) 자식, 두 종류로 나뉩니다.

1. 공개 자식 패키지 (Public Child Packages)

공개 자식 패키지는 부모의 기능을 공개적으로 확장하기 위해 사용됩니다. 부모 패키지를 with 할 수 있는 모든 클라이언트는, 이 공개 자식 패키지 역시 with 하여 사용할 수 있습니다. 이는 표준 라이브러리에서 흔히 볼 수 있는 구조입니다.

예시: 그래픽 시스템의 공개 자식 패키지

-- File: graphics.ads
package Graphics is
   type Color is (RED, GREEN, BLUE);
end Graphics;
-- File: graphics-shapes.ads
-- Graphics 패키지의 공개 자식으로 Shapes를 선언합니다.
package Graphics.Shapes is
   procedure draw_circle (c : in Color; radius : in Float);
end Graphics.Shapes;

이 구조에서, Graphics.ShapesGraphics의 기능을 확장합니다. 외부 클라이언트는 with Graphics.Shapes;를 통해 원을 그리는 기능을 사용할 수 있습니다. 이 관계는 ShapesGraphics 서브시스템의 일부라는 것을 명확히 보여줍니다.

2. 사유 자식 패키지 (Private Child Packages)

사유 자식 패키지는 패키지 선언 앞에 private 키워드를 붙여 정의합니다. 사유 자식 패키지는 부모 패키지와 그 자손들(다른 자식 패키지)에게만 보이며, 서브시스템 외부의 클라이언트에게는 완전히 숨겨집니다. 이는 복잡한 서브시스템을 여러 개의 내부 패키지로 구현하면서도, 외부에는 오직 단일하고 간결한 인터페이스만을 제공하고자 할 때 매우 유용합니다.

예시: 복잡한 서브시스템의 사유 자식 패키지

-- File: complex_subsystem.ads
package Complex_Subsystem is
   procedure perform_main_task;
end Complex_Subsystem;
-- File: complex_subsystem-helpers.ads
-- Complex_Subsystem의 사유 자식으로 Helpers를 선언합니다.
private package Complex_Subsystem.Helpers is
   -- 이 패키지는 Complex_Subsystem의 구현을 돕는 내부용 패키지입니다.
   function internal_calculation return Integer;
end Complex_Subsystem.Helpers;
-- File: complex_subsystem.adb
with Complex_Subsystem.Helpers; -- 부모의 본체는 사유 자식을 with 할 수 있습니다.

package body Complex_Subsystem is
   procedure perform_main_task is
      result : Integer;
   begin
      -- 사유 자식의 기능을 호출
      result := Complex_Subsystem.Helpers.internal_calculation;
      -- ...
   end perform_main_task;
end Complex_Subsystem;

이 설계에서 외부 클라이언트는 Complex_Subsystemwith 할 수 있으며, Complex_Subsystem.Helpers의 존재 자체를 알 수 없습니다. with Complex_Subsystem.Helpers;를 시도하면 컴파일 오류가 발생합니다.

자식 패키지의 특별한 가시성 규칙

자식 패키지가 계층 구조에서 갖는 가장 중요한 특징은 부모 패키지의 비공개(private) 부분에 접근할 수 있다는 점입니다. 일반적인 클라이언트는 패키지의 공개된 명세만 볼 수 있지만, 자식 패키지의 본체는 부모 명세의 private 키워드 뒤에 선언된 타입이나 상수까지 볼 수 있습니다.

이 규칙을 통해 부모 패키지는 외부에는 엄격한 캡슐화를 유지하면서도, 자신의 자식들로 구성된 서브시스템 내에서는 구현에 필요한 세부 정보를 효율적으로 공유할 수 있습니다. 이는 정보 은닉의 원칙을 해치지 않으면서도 응집도 높은 모듈 그룹을 설계할 수 있게 하는 Ada의 정교한 메커니즘입니다.

9.3.3 renames를 이용한 가독성 향상

패키지 계층이 깊어질수록, 특정 기능에 접근하기 위한 전체 경로 이름(fully qualified name)은 매우 길어질 수 있습니다. 예를 들어, My_Company.ERP_System.Human_Resources.Payroll.Get_Tax_Rate 와 같은 이름은 그 출처가 명확하다는 장점이 있지만, 코드에서 반복적으로 사용하기에는 지나치게 길고 번잡합니다. 이는 코드의 가독성을 저해하고, 오타의 가능성을 높이는 원인이 됩니다.

renames 절은 이처럼 긴 이름의 객체, 예외, 패키지, 또는 서브프로그램에 대해 현재 유효범위(scope)에서만 유효한 새롭고 간결한 이름을 부여하는 선언입니다. renames는 새로운 개체(entity)를 만드는 것이 아니라, 기존에 존재하는 개체에 대한 별칭(alias)을 만드는 것과 같습니다. 이는 코드의 의미를 바꾸지 않으면서 표현을 간결하게 만들어 가독성을 향상시키는 데 목적이 있습니다.

renames 선언은 선언부(declarative part)에 위치하며, 대상의 종류에 따라 약간씩 다른 구문을 가집니다.

1. 객체 및 예외 재명명 (Renaming Objects and Exceptions)

상수, 변수와 같은 객체나 예외는 다음과 같은 구문으로 재명명할 수 있습니다.

-- 원본 선언
package Config is
   DEFAULT_TIMEOUT : constant Positive := 1000;
   Network_Error   : exception;
end Config;

-- 클라이언트 코드
with Config;
procedure Main is
   -- 객체(상수) 재명명
   timeout : constant Positive renames Config.DEFAULT_TIMEOUT;

   -- 예외 재명명
   net_err : exception renames Config.Network_Error;
begin
   -- ...
   if some_condition then
      raise net_err; -- Config.Network_Error 대신 net_err 사용
   end if;
end Main;

2. 서브프로그램 재명명 (Renaming Subprograms)

프로시저나 함수를 재명명할 때는, 원본 서브프로그램의 파라미터와 반환 타입 명세(signature)를 동일하게 명시해야 합니다.

-- 원본 선언
package My_System.IO.Low_Level is
   function read_sensor_value (id : Integer) return Float;
end My_System.IO.Low_Level;

-- 클라이언트 코드
with My_System.IO.Low_Level;
procedure Control_Loop is
   -- 함수 재명명
   function get_sensor (id : Integer) return Float
     renames My_System.IO.Low_Level.read_sensor_value;

   sensor_val : Float;
begin
   sensor_val := get_sensor (1); -- 긴 이름 대신 간결한 이름 사용
end Control_Loop;

3. 패키지 재명명 (Renaming Packages)

패키지 전체를 재명명하여 해당 패키지 내의 모든 자원에 간결한 접두사로 접근할 수 있습니다.

with Ada.Strings.Unbounded;
procedure Text_Processing is
   -- 패키지 재명명
   package U_Str renames Ada.Strings.Unbounded;

   my_string : U_Str.Unbounded_String;
begin
   my_string := U_Str.to_unbounded_string ("Example");
   -- Ada.Strings.Unbounded 대신 U_Str 사용
end Text_Processing;

renamesuse 절과 비교하여 명확성 측면에서 이점을 가집니다. use 절은 패키지의 모든 이름을 현재 이름공간으로 가져와 이름 충돌이나 출처의 모호성을 야기할 수 있는 반면, renames는 오직 지정된 개체에 대해서만 명시적으로 별칭을 만들기 때문에 코드의 가독성과 안전성을 유지하면서 장황함을 줄일 수 있는 효과적인 기법입니다.

9.4 패키지 생명주기와 정교화 (Package Lifecycle and Elaboration)

지금까지 우리는 패키지의 정적인 구조, 즉 어떻게 선언하고 정보를 숨기며 계층을 구성하는지에 대해 학습했습니다. 그러나 패키지는 단순히 코드를 담는 수동적인 컨테이너가 아닙니다. 프로그램이 실행될 때, 패키지 역시 자신만의 생명주기(lifecycle)를 가지며, 이 과정에서 초기 상태를 설정하고 실행 준비를 마칩니다.

Ada에서 선언된 개체(타입, 객체, 서브프로그램 등)가 런타임에 그 선언의 효력을 발휘하게 되는 과정을 정교화(elaboration)라고 부릅니다. 변수 선언의 정교화는 해당 변수를 위한 메모리를 할당하고 초기값을 부여하는 것을 의미하며, 서브프로그램 선언의 정교화는 해당 서브프로그램을 호출 가능한 상태로 만드는 것을 의미합니다.

패키지의 정교화는 이보다 조금 더 복잡한 과정을 포함합니다.

  1. 패키지 명세의 정교화: 명세에 선언된 모든 타입, 객체, 서브프로그램 등이 차례대로 정교화됩니다.
  2. 패키지 본체의 정교화: 본체의 선언부에 있는 내부 객체들이 정교화된 후, 본체의 맨 마지막에 위치할 수 있는 선택적인 실행부, 즉 초기화 블록(begin ... end;)이 실행됩니다.

이 초기화 블록은 해당 패키지가 사용되기 전에 단 한 번만 실행되는 코드를 담습니다. 이 블록은 복잡한 자료구조의 초기 상태 설정, 파일 열기, 장치 초기화 등 패키지가 자신의 임무를 수행하기 위해 필요한 사전 준비 작업을 하는 데 사용됩니다.

여기서 중요한 질문이 발생합니다. 만약 A 패키지가 B 패키지를 with 하고, B 패키지가 C 패키지를 with 한다면, 이들의 정교화는 어떤 순서로 이루어질까요? Ada의 규칙은 명확합니다. 어떤 단위가 정교화되기 위해서는, 그 단위가 의존하는 모든 단위가 먼저 정교화되어야 합니다. 즉, C가 가장 먼저 정교화되고, 그 다음 B, 마지막으로 A가 정교화됩니다.

그러나 이 의존성 순서만으로는 충분하지 않은 경우가 있습니다. 예를 들어, A 패키지의 초기화 코드가 B 패키지의 서브프로그램을 호출해야 한다면, B 패키지의 본체까지 반드시 먼저 정교화되어 있어야 합니다.

이처럼 프로그램의 정확하고 예측 가능한 동작을 위해서는 정교화 순서를 명시적으로 이해하고 제어하는 것이 매우 중요합니다. 특히 고신뢰성이 요구되는 실시간 및 임베디드 시스템에서는 초기화 순서의 오류가 치명적인 결과를 초래할 수 있습니다.

본 절에서는 패키지의 초기화 블록을 작성하는 방법과 정교화의 기본 규칙을 살펴보고, 나아가 pragma Elaborate_Body, pragma Preelaborate, pragma Pure와 같은 컴파일러 지시어를 사용하여 정교화 순서를 명시적으로 제어함으로써 프로그램의 안정성과 예측 가능성을 보장하는 고급 기법에 대해 학습하겠습니다.

9.4.1 패키지 정교화 (Elaboration) 과정

프로그램의 소스 코드는 컴파일 과정을 거쳐 실행 가능한 기계어로 번역됩니다. 그러나 프로그램이 실행되는 순간, 코드에 선언된 모든 것들이 즉시 사용 가능한 상태가 되는 것은 아닙니다. Ada에서는 프로그램 실행 중 선언(declaration)이 효력을 갖게 되는 동적인 과정을 정교화(elaboration)라고 정의합니다.

정교화는 선언된 개체의 종류에 따라 다른 의미를 가집니다.

  • 객체(Object) 선언의 정교화: 해당 객체를 위한 메모리 공간을 할당하고, 명시된 초기값이 있다면 그 값으로 초기화합니다.
  • 타입(Type) 선언의 정교화: 해당 타입을 식별하고 사용할 수 있도록 타입에 대한 내부 정보를 생성합니다.
  • 서브프로그램(Subprogram) 선언의 정교화: 해당 서브프로그램을 호출 가능한 상태로 만듭니다.

패키지의 정교화는 이러한 개별적인 정교화 과정들이 정해진 순서에 따라 일어나는 복합적인 절차입니다. 한 패키지의 정교화는 크게 두 단계로 나뉩니다.

  1. 패키지 명세(Specification)의 정교화: 패키지 명세의 선언부에 있는 모든 선언들이 위에서 아래로 순서대로 정교화됩니다.
  2. 패키지 본체(Body)의 정교화: 패키지 본체의 선언부에 있는 내부 선언들이 먼저 순서대로 정교화됩니다. 그 후, 본체의 beginend 사이에 있는 초기화 코드가 실행됩니다. 만약 초기화 코드가 없다면, 본체의 정교화는 선언부의 정교화가 끝나는 시점에 완료됩니다.

이 과정에서 가장 중요한 규칙은 의존성 규칙(Dependency Rule)입니다. 만약 패키지 Awith B;를 통해 패키지 B에 의존한다면, A가 정교화되기 전에 B의 명세는 반드시 먼저 정교화되어야 합니다. 이는 프로그램의 주 진입점(main subprogram)부터 시작하여 모든 의존성 관계를 거슬러 올라가며 전체적인 정교화 순서(elaboration order)를 결정합니다.

다음 예제는 정교화 순서를 명확히 보여줍니다.

-- File: logger.ads
package Logger is
   procedure log (message : in String);
end Logger;

-- File: logger.adb
with Ada.Text_IO;
package body Logger is
   procedure log (message : in String) is
   begin
      Ada.Text_IO.put_line ("[LOG] " & message);
   end log;
begin
   -- Logger 패키지의 초기화 코드
   Ada.Text_IO.put_line ("Elaborating Logger Body");
end Logger;
-- File: component.ads
with Logger;
package Component is
   procedure initialize;
end Component;

-- File: component.adb
package body Component is
   procedure initialize is
   begin
      Logger.log ("Component Initialized.");
   end initialize;
begin
   -- Component 패키지의 초기화 코드
   Logger.log ("Elaborating Component Body");
end Component;
-- File: main.adb
with Ada.Text_IO;
with Component;

procedure Main is
begin
   Ada.Text_IO.put_line ("Main program starts.");
   Component.initialize;
   Ada.Text_IO.put_line ("Main program ends.");
end Main;

Main 프로시저는 Component에 의존하고, ComponentLogger에 의존합니다. 따라서 프로그램이 시작될 때 정교화 순서는 LoggerComponentMain이 됩니다. 위 코드를 실행하면 다음과 같은 출력을 통해 정교화 순서를 직접 확인할 수 있습니다.

Elaborating Logger Body
Elaborating Component Body
Main program starts.
[LOG] Component Initialized.
Main program ends.

이처럼 프로그램의 주 실행(Main program starts.)이 시작되기 전에, 의존성에 따라 각 패키지 본체의 초기화 코드가 먼저 실행되는 것을 볼 수 있습니다. 이 정교화 과정은 프로그램의 초기 상태를 일관되고 예측 가능하게 설정하는 데 필수적인 메커니즘입니다.

9.4.2 패키지 본체의 초기화 블록

패키지 본체는 선언부 외에, begin 키워드로 시작하여 end로 끝나는 선택적인 실행부를 가질 수 있습니다. 이를 초기화 블록(initialization block) 이라고 부르며, 이 블록에 포함된 코드들은 해당 패키지 본체가 정교화될 때 단 한 번 실행됩니다.

초기화 블록의 주된 목적은 패키지가 올바르게 동작하는 데 필요한 사전 준비 작업을 수행하는 것입니다. 이는 단순한 기본값 할당을 넘어, 알고리즘이 필요한 복잡한 초기 상태를 설정하는 역할을 담당합니다.

package body Package_With_Init is
   -- 1. 본체의 선언부
   --    내부 변수, 타입, 상수 등이 여기에 선언됩니다.
   internal_data : Some_Complex_Type;
   is_ready      : Boolean := False;

begin
   -- 2. 초기화 블록의 실행부
   --    이 부분은 패키지 정교화 시점에 단 한 번 실행됩니다.
   --    복잡한 초기화 로직을 여기에 작성합니다.
   initialize_complex_data (internal_data);
   is_ready := True;

exception
   -- 3. 초기화 블록의 예외 처리부
   --    초기화 과정에서 발생할 수 있는 예외를 처리합니다.
   when Some_Error =>
      handle_initialization_failure;

end Package_With_Init;

초기화 블록의 주요 활용 사례

초기화 블록은 다음과 같은 다양한 상황에서 유용하게 사용됩니다.

  • 복잡한 자료구조 초기화: 배열이나 레코드에 미리 계산된 값을 채워 넣어 조회 테이블(lookup table)을 생성하는 경우.
  • 자원 할당: 프로그램 실행 동안 계속 사용될 로그 파일을 열거나, 데이터베이스 연결을 설정하는 경우.
  • 하드웨어 초기화: 임베디드 시스템에서 특정 하드웨어 장치의 레지스터를 설정하거나, 통신 포트를 활성화하는 경우.
  • 콜백 등록: 프레임워크 기반의 프로그램에서, 자신의 패키지가 제공하는 처리 함수(handler)를 중앙 관리자에 등록하는 경우.
  • 상태 점검 및 설정: 시스템의 환경 변수를 읽어와 패키지 내부의 동작 모드를 결정하는 경우.

예제: 조회 테이블 생성

다음은 초기화 블록을 사용하여 프로그램 시작 시 삼각함수 sin 값을 미리 계산하여 조회 테이블에 저장하는 예제입니다. 이를 통해 런타임 중에는 복잡한 계산 없이 배열에서 값을 바로 찾아와 성능을 높일 수 있습니다.

-- File: trig_lookup.ads
package Trig_Lookup is
   -- 각도(degree)를 입력받아 미리 계산된 sin 값을 반환합니다.
   function sin (angle_in_degrees : Integer range 0 .. 360) return Float;
   Initialization_Error : exception;
end Trig_Lookup;
-- File: trig_lookup.adb
with Ada.Numerics.Generic_Elementary_Functions;

package body Trig_Lookup is

   package Math is new Ada.Numerics.Generic_Elementary_Functions (Float);

   type Sine_Table_Type is array (0 .. 360) of Float;
   sine_table : Sine_Table_Type; -- 실제 sin 값을 저장할 내부 배열

   -- 공개된 함수는 내부 테이블을 조회하여 값을 반환하기만 합니다.
   function sin (angle_in_degrees : Integer range 0 .. 360) return Float is
   begin
      return sine_table (angle_in_degrees);
   end sin;

begin -- 초기화 블록 시작
   -- 프로그램 시작 시 0도부터 360도까지의 sin 값을 미리 계산하여 테이블에 저장
   for i in sine_table'range loop
      declare
         angle_in_radians : constant Float := Float (i) * Math.pi / 180.0;
      begin
         sine_table (i) := Math.sin (angle_in_radians);
      end;
   end loop;

exception
   -- 초기화 중 수치 연산 오류 등이 발생할 경우를 대비
   when others =>
      raise Initialization_Error;

end Trig_Lookup;

초기화 과정의 예외 처리

초기화 블록 내에서도 예외 처리가 가능하며, 이는 매우 중요합니다. 만약 초기화 과정에서 오류가 발생하고 이를 처리하지 못하면(예: 필요한 파일을 찾지 못함), 해당 예외는 상위로 전파되어 궁극적으로는 프로그램 전체의 실행을 중단시킬 수 있습니다. 따라서 초기화 블록에서는 발생 가능한 오류를 예측하고 exception 절을 통해 안정적으로 처리하여, 프로그램이 비정상적으로 종료되는 것을 방지해야 합니다.

9.4.3 정교화 순서 제어와 안전성 보장

Ada의 기본적인 정교화 규칙, 즉 with 절에 따른 의존성 순서는 대부분의 경우에 충분합니다. 그러나 이 규칙만으로는 해결되지 않는 미묘하고 잠재적으로 위험한 상황이 존재합니다. 바로 정교화 순서 문제(elaboration order problem)입니다.

이 문제는 한 패키지의 초기화 코드(begin ... end; 블록)가 다른 패키지의 서브프로그램을 호출할 때 발생할 수 있습니다. 예를 들어, Client 패키지가 Server 패키지를 with한다고 가정해 봅시다.

-- Client.adb
with Server;
package body Client is
begin
   Server.some_procedure; -- 문제 발생 가능성
end Client;

기본 의존성 규칙에 따르면, Client의 정교화가 시작되기 전에 Server명세가 정교화되는 것만 보장됩니다. Server본체가 언제 정교화될지에 대해서는 아무런 보장이 없습니다. 만약 Client의 초기화 코드가 실행되는 시점에 아직 Server의 본체가 정교화되지 않았다면, Server.some_procedure는 아직 호출 가능한 상태가 아닙니다. 이 경우 프로그램은 Program_Error 예외를 발생시키며 비정상적으로 종료될 것입니다.

이러한 비결정적(non-deterministic) 동작은 신뢰할 수 있는 시스템을 구축하는 데 큰 장애물이 됩니다. Ada는 이 문제를 해결하고 정교화 순서를 프로그래머가 명시적으로 제어할 수 있도록 다음과 같은 컴파일러 지시어(pragma)들을 제공합니다.

1. pragma Elaborate_Body

이 프라그마는 앞서 설명한 정교화 순서 문제를 직접적으로 해결하기 위해 사용됩니다. 특정 단위의 정교화가 시작되기 전에, 의존하는 패키지의 본체까지 반드시 먼저 정교화되어야 함을 컴파일러에게 지시합니다.

-- Client.adb
with Server;
pragma Elaborate_Body (Server); -- 컴파일러에게 Server의 본체를 먼저 정교화하도록 지시

package body Client is
begin
   -- 이 시점에는 Server의 본체가 반드시 정교화되었음이 보장됨
   Server.some_procedure; -- 이제 이 호출은 안전합니다.
end Client;

이 프라그마를 추가함으로써, Client의 초기화 코드가 실행되기 전에 Server의 초기화가 완료되고 모든 서브프로그램이 호출 가능해짐을 보장받을 수 있습니다.

2. pragma Preelaborate

Preelaborate는 더 강력한 제약을 가하는 프라그마입니다. 패키지에 이 프라그마가 적용되면, 해당 패키지는 런타임에 실행될 정교화 코드를 전혀 포함할 수 없게 됩니다. 즉, 패키지 본체에 begin ... end; 형식의 초기화 블록을 가질 수 없으며, 패키지 내에 선언된 모든 객체의 초기값은 반드시 컴파일 시점에 계산 가능한 정적 표현식(static expression)이어야 합니다.

-- preelaborable_package.ads
pragma Preelaborate;
package Preelaborable_Package is
   MAX_USERS : constant := 10; -- 정적 값
   -- ...
end Preelaborable_Package;

Preelaborate로 지정된 패키지는 런타임 의존성이 없으므로 정교화 순서 문제에서 자유롭습니다. 컴파일러는 이 패키지가 모든 규칙을 준수하는지 검사하며, 위반 시 컴파일 오류를 발생시킵니다. 이는 고신뢰성 시스템에서 프로그램 시작 시의 동작을 단순화하고 예측 가능성을 높이는 데 필수적인 기능입니다.

3. pragma Pure

Pure는 가장 강력한 제약을 가하는 프라그마입니다. Pure로 지정된 패키지는 Preelaborate의 모든 규칙을 따르는 동시에, 전역적인 상태(state), 즉 패키지 수준의 변수를 가질 수 없습니다. 오직 타입과 서브프로그램, 그리고 정적인 상수만을 포함할 수 있습니다.

-- pure_package.ads
pragma Pure;
package Pure_Package is
   type My_Integer is new Integer;
   function is_positive (x : My_Integer) return Boolean;
end Pure_Package;

Pure 패키지는 상태를 갖지 않으므로 부작용(side effect)이 없고, 정교화 순서에 전혀 영향을 받지 않습니다. 따라서 가장 안전하고 재사용성이 높은 컴포넌트를 작성하는 데 사용됩니다. 수학 상수나 기본적인 타입 변환 함수를 제공하는 패키지 등이 Pure로 선언되기에 적합한 예입니다.

지시어 제약 조건 보장 내용 주요 용도
pragma Elaborate_Body 없음 특정 패키지의 본체가 먼저 정교화됨 런타임 초기화 순서 문제 해결
pragma Preelaborate 동적 초기화 코드 금지 런타임 정교화 코드가 없음 예측 가능한 시작 동작이 필요한 고신뢰성 컴포넌트
pragma Pure Preelaborate 규칙 + 전역 변수 금지 상태가 없고 부작용이 없음 근본적인 타입, 상수, 순수 함수 정의

이러한 정교화 제어 도구들을 적절히 사용함으로써, 프로그래머는 프로그램의 초기화 과정에서 발생할 수 있는 잠재적인 위험을 제거하고 시스템의 전반적인 안정성과 신뢰성을 보장할 수 있습니다.

9.5 [심화] 대규모 패키지 관리 기법

지금까지 우리는 패키지의 계층 구조를 통해 여러 패키지를 논리적으로 조직하는 방법을 학습했습니다. 이 기법은 시스템 전체의 아키텍처를 명료하게 만들고 이름공간을 효과적으로 관리하는 데 필수적입니다. 그러나 복잡성은 다른 차원에서도 발생합니다. 바로 단일 패키지, 특히 그 본체(.adb 파일)의 크기가 시스템의 성장과 함께 비정상적으로 커지는 경우입니다.

하나의 서브시스템을 담당하는 패키지는 그 역할의 중요성만큼 많은 수의 서브프로그램과 내부 데이터 구조를 포함할 수 있습니다. 결과적으로 해당 패키지의 본체 파일 하나에 수천, 혹은 수만 줄의 코드가 집중될 수 있습니다. 이러한 ‘거대 패키지(mega-package)’는 다음과 같은 심각한 관리상의 문제를 야기합니다.

  • 가독성 및 이해도 저하: 개발자는 특정 기능을 이해하거나 수정하기 위해 거대한 단일 파일을 탐색해야만 합니다. 이는 코드의 전체적인 흐름을 파악하는 것을 어렵게 하고, 인지적 부담을 가중시킵니다.
  • 유지보수성의 악화: 방대한 코드 속에서 버그의 원인을 찾거나, 새로운 기능을 추가할 때 기존 코드에 미칠 영향을 분석하는 작업이 매우 복잡하고 오류를 유발하기 쉬워집니다.
  • 팀 협업의 비효율성: 여러 명의 개발자가 논리적으로는 다른 기능을 작업하더라도, 물리적으로는 동일한 파일을 수정해야 하므로 버전 관리 시스템에서 잦은 충돌(conflict)이 발생합니다. 이는 개발 생산성을 심각하게 저해하는 요인이 됩니다.

이러한 문제는 패키지를 더 작게 분할하는 것만으로는 해결하기 어렵습니다. 왜냐하면 해당 코드들은 응집도 높게 묶여 하나의 논리적 단위를 형성해야 하기 때문입니다.

Ada는 이러한 상황을 해결하기 위해, 논리적 구조는 유지하면서 물리적 구현만 분할할 수 있는 정교한 기법을 제공합니다. 즉, 하나의 패키지 본체에 속해야 할 서브프로그램들의 구현을 각각 별도의 파일로 분리하여 관리할 수 있는 분리 컴파일(separate compilation) 모델입니다.

본 심화 절에서는 separate 키워드를 사용하여 거대한 패키지 본체를 여러 개의 관리 가능한 단위(subunit)로 분할하는 방법을 학습합니다. 이 기법을 통해 패키지 본체는 구현의 ‘목차’와 같은 역할을 하게 되며, 실제 세부 코드는 개별 파일에 위임됩니다. 이를 통해 코드의 가독성, 유지보수성, 그리고 팀의 협업 효율성을 극대화하는 실용적인 방법을 익히게 될 것입니다.

9.5.1 separate를 이용한 구현부 분리

거대한 패키지 본체를 물리적으로 여러 파일로 분할하기 위해 Ada가 제공하는 핵심 키워드는 separate입니다. 이 메커니즘을 통해, 패키지 본체 내에 선언된 서브프로그램이나 다른 단위(태스크, 보호 객체 등)의 구현부를 서브유닛(subunit) 이라는 별도의 파일로 분리하여 컴파일할 수 있습니다.

이 과정은 두 단계로 이루어집니다.

  1. 본체에서의 스텁(Stub) 선언: 원래 서브프로그램의 전체 구현이 위치해야 할 부모 패키지의 본체(.adb 파일)에는, 해당 구현이 분리되었음을 알리는 ‘스텁(stub)’을 대신 작성합니다. 스텁의 구문은 is separate;로 끝납니다.
  2. 별도 파일에서의 서브유닛 구현: 실제 구현 코드는 새로운 파일에 작성합니다. 이 서브유닛 파일은 자신이 어느 부모 단위에 속하는지를 명시하는 separate (부모_단위_이름) 절로 시작해야 합니다.

GNAT 컴파일러 환경에서는 서브유닛 파일의 이름에 대한 명확한 규칙이 있습니다. 파일 이름은 부모단위_파일명-서브유닛_이름.adb 형식으로 구성됩니다. 예를 들어, data_manager.adb 파일에 속한 process_records 프로시저를 분리한다면, 서브유닛 파일의 이름은 data_manager-process_records.adb가 됩니다.

예제: separate를 이용한 패키지 구현 분할

여러 개의 복잡한 연산을 수행하는 서비스 패키지를 separate를 이용해 구현해 보겠습니다.

1. 패키지 명세 (large_service.ads)

패키지의 공개 인터페이스는 이전과 동일하게 정의됩니다.

package Large_Service is
   procedure initialize;
   procedure process_transaction (id : in Integer);
   procedure generate_report;
end Large_Service;

2. 패키지 본체 (large_service.adb) - 스텁 사용

패키지 본체는 이제 각 서브프로그램의 스텁만을 포함하여 매우 간결해집니다. 이 파일은 전체 구현의 ‘목차’와 같은 역할을 합니다.

-- File: large_service.adb
-- 이 파일은 더 이상 수천 줄의 코드를 포함하지 않습니다.

package body Large_Service is

   -- 공유될 수 있는 내부 데이터나 타입 선언
   MAX_TRANSACTIONS : constant := 10_000;
   type Internal_State is (IDLE, BUSY, ERROR);
   current_state : Internal_State := IDLE;

   -- 각 서브프로그램의 구현은 별도의 파일에 있음을 명시합니다.
   procedure initialize is separate;
   procedure process_transaction (id : in Integer) is separate;
   procedure generate_report is separate;

end Large_Service;

3. 서브유닛 파일들 - 실제 구현

각각의 스텁에 대한 실제 구현은 별도의 파일에 작성됩니다.

-- File: large_service-initialize.adb

separate (Large_Service)
procedure initialize is
begin
   -- 초기화 관련 복잡한 로직 ...
   current_state := IDLE; -- 부모 본체의 변수에 접근 가능
end initialize;
-- File: large_service-process_transaction.adb

separate (Large_Service)
procedure process_transaction (id : in Integer) is
begin
   -- 트랜잭션 처리 관련 복잡한 로직 ...
   if current_state = IDLE then
      current_state := BUSY;
      -- ...
   end if;
end process_transaction;

( generate_report 프로시저 역시 large_service-generate_report.adb 파일에 유사하게 구현됩니다. )

separate의 핵심 이점: 논리적 문맥 유지

separate를 사용한 분리의 가장 중요한 특징은 서브유닛이 논리적으로 여전히 부모의 본체 내에 위치한다는 점입니다. 따라서 initializeprocess_transaction과 같은 서브유닛은 Large_Service 패키지 본체에 선언된 MAX_TRANSACTIONScurrent_state와 같은 내부 자원에 마치 자신이 그 안에 직접 작성된 것처럼 자유롭게 접근할 수 있습니다.

결론적으로, separate 메커니즘은 패키지의 논리적인 응집도와 캡슐화를 유지하면서도, 물리적인 소스 코드의 복잡성을 관리하고 팀 기반 개발의 효율성을 높일 수 있는 강력하고 실용적인 솔루션입니다.

9.6 [설계] 객체 지향 설계를 위한 패키지 활용

지금까지 우리는 패키지를 절차적(procedural)이고 모듈적인(modular) 프로그래밍의 관점에서 학습했습니다. 패키지는 관련된 데이터와 서브프로그램을 하나의 단위로 묶고, 명세와 본체의 분리를 통해 구현을 숨기는 강력한 캡슐화(encapsulation) 도구입니다.

캡슐화, 정보 은닉, 그리고 명확한 인터페이스 정의라는 패키지의 핵심 원칙들은 객체 지향 프로그래밍(Object-Oriented Programming, OOP)의 근간을 이루는 사상이기도 합니다. Ada는 이러한 원칙들을 기반으로 하여, 상속(inheritance), 다형성(polymorphism)과 같은 객체 지향의 고유한 특성들을 지원하기 위한 확장된 기능을 제공합니다.

다른 많은 언어들이 class라는 단일 키워드를 사용하여 객체 지향의 모든 개념을 표현하는 것과 달리, Ada에서 ‘클래스’는 특정 키워드가 아닌, 언어의 여러 기능이 조합된 설계 패턴의 결과물입니다. 가장 보편적인 형태는 패키지와 태그드 타입(tagged type)의 결합입니다.

이 설계 패턴에서 각 구성 요소는 다음과 같은 명확한 역할을 담당합니다.

  1. 태그드 타입 (Tagged Type): 객체가 가질 데이터 속성(attribute)들을 정의합니다. C++이나 Java에서의 멤버 변수(member variable)에 해당합니다. 타입 선언에 tagged 키워드를 붙임으로써, 해당 타입이 향후 다른 타입에 의해 확장(상속)될 수 있음을 명시합니다.
  2. 패키지 (Package): 태그드 타입과 그 타입을 조작하는 서브프로그램들(메서드)을 하나의 논리적 단위로 묶어 캡슐화하는 역할을 합니다. 패키지의 명세는 클래스의 공개 인터페이스를, 본체는 그 구현을 담게 됩니다. private 선언을 통해 데이터 속성을 숨기고, 오직 공개된 서브프로그램을 통해서만 데이터에 접근하도록 강제합니다.

이는 우리가 9.2절에서 학습한 추상 데이터 타입(ADT)의 개념을 객체 지향 패러다임으로 확장한 것입니다. 즉, Ada의 클래스는 상속과 다형성을 지원하는 특별한 형태의 ADT로 볼 수 있습니다.

본 설계 절에서는 객체 지향 프로그래밍의 모든 것을 다루기보다는, 이 패러다임에서 패키지가 수행하는 핵심적인 역할에 집중할 것입니다. 패키지를 이용하여 클래스와 같은 구조를 어떻게 형성하는지, 타입에 소속된 연산인 ‘프리미티브 연산(primitive operation)’이란 무엇인지, 그리고 가독성 높고 유지보수하기 쉬운 객체 지향 설계를 위한 명명 전략과 구조화 기법에 대해 고찰할 것입니다. 이는 향후 더 깊이 있는 객체 지향 프로그래밍 학습을 위한 견고한 기반이 될 것입니다.

9.6.1 Ada에서의 클래스: 패키지와 태그드 타입의 결합

Ada에서 클래스(class)는 단일 키워드가 아닌, 잘 정의된 설계 패턴을 통해 구현됩니다. 이 패턴의 가장 핵심적인 두 요소는 패키지(package)와 태그드 타입(tagged type)입니다. 이 둘의 조합은 객체 지향 프로그래밍의 근간인 캡슐화, 그리고 상속과 다형성의 기반을 마련합니다.

  1. 태그드 타입 (Tagged Type): 데이터와 확장성 객체의 상태, 즉 데이터 멤버(data member)는 레코드(record)를 통해 정의됩니다. 이 레코드 선언에 tagged라는 키워드를 추가하면, 해당 타입은 일반 레코드를 넘어 객체 지향의 특성을 가질 수 있게 됩니다. tagged 키워드는 컴파일러에게 해당 타입의 객체에 런타임에 타입을 식별할 수 있는 숨겨진 ‘태그(tag)’를 추가하도록 지시합니다. 이 태그는 추후 다형성(polymorphism)을 구현하는 데 결정적인 역할을 합니다.

  2. 패키지 (Package): 캡슐화와 메서드 패키지는 태그드 타입과, 그 타입을 조작하는 연산(operation)들을 하나의 단위로 묶는 캡슐 역할을 합니다. 다른 객체 지향 언어의 ‘메서드(method)’에 해당하는 개념은 Ada에서 프리미티브 연산(primitive operation) 이라고 부릅니다. 어떤 타입의 프리미티브 연산은 해당 타입과 동일한 스코프(즉, 동일한 패키지 명세)에 선언되면서, 파라미터나 반환 값으로 해당 타입을 갖는 서브프로그램을 의미합니다.

이 두 요소를 결합하여 ‘사람’을 나타내는 간단한 클래스를 설계하는 예제는 다음과 같습니다.

예제: Person 클래스 구현

1. 명세 (person_adt.ads): 공개 인터페이스 정의

Person_ADT 패키지는 Person이라는 클래스의 공개 인터페이스를 정의합니다. Person 타입은 tagged private으로 선언되어 내부 구조는 숨기고, 오직 공개된 프리미티브 연산(create, get_name, display 등)을 통해서만 객체에 접근할 수 있습니다.

-- File: person_adt.ads
with Ada.Strings.Unbounded;

package Person_ADT is

   type Person is tagged private;

   -- 생성자(Constructor) 역할을 하는 프리미티브 연산
   function create (name : in String; age : in Natural) return Person;

   -- 접근자(Accessor/Getter) 역할을 하는 프리미티브 연산
   function get_name (p : in Person) return String;
   function get_age (p : in Person) return Natural;

   -- 기타 동작(Method)을 정의하는 프리미티브 연산
   procedure display (p : in Person);

private

   type Person is tagged record
      name : Ada.Strings.Unbounded.Unbounded_String;
      age  : Natural := 0;
   end record;

end Person_ADT;

2. 본체 (person_adt.adb): 비공개 구현

본체에서는 명세에서 약속한 프리미티브 연산들의 실제 구현을 제공합니다.

-- File: person_adt.adb
with Ada.Text_IO;
with Ada.Strings.Unbounded;

package body Person_ADT is

   function create (name : in String; age : in Natural) return Person is
   begin
      return p : Person do
         p.name := Ada.Strings.Unbounded.to_unbounded_string (name);
         p.age  := age;
      end return;
   end create;

   function get_name (p : in Person) return String is
   begin
      return Ada.Strings.Unbounded.to_string (p.name);
   end get_name;

   function get_age (p : in Person) return Natural is
   begin
      return p.age;
   end get_age;

   procedure display (p : in Person) is
   begin
      Ada.Text_IO.put_line ("Name: " & get_name (p) & ", Age: " & Natural'image (get_age (p)));
   end display;

end Person_ADT;

3. 클라이언트 코드

클라이언트는 Person_ADT 패키지를 with 하여 Person 객체를 생성하고, 공개된 프리미티브 연산만을 사용하여 객체를 조작합니다.

with Person_ADT;
with Ada.Text_IO;

procedure Main is
   p1 : Person_ADT.Person := Person_ADT.create (name => "Alice", age => 30);
begin
   Ada.Text_IO.put_line ("Displaying Person 1:");
   Person_ADT.display (p1);

   -- p1.age := 31;  -- !! 컴파일 오류(Compile-Time Error) !!
   -- 'Person' 타입이 private이므로 내부 필드에 직접 접근할 수 없습니다.
end Main;

이 예제는 Ada의 클래스가 어떻게 구현되는지를 명확히 보여줍니다. 패키지(Person_ADT)는 캡슐화의 경계를 형성하고, 태그드 타입(Person)은 데이터 구조를 정의하며, 프리미티브 연산은 해당 데이터에 접근하고 조작하는 유일한 통로(메서드) 역할을 합니다. 이 구조는 정보 은닉을 강제하고, 향후 상속을 통한 기능 확장의 기반을 마련합니다.

9.6.2 패키지를 이용한 프리미티브 연산(Primitive Operations) 캡슐화

Ada의 객체 지향 모델을 이해하기 위해서는 프리미티브 연산(primitive operation)의 개념을 정확히 파악하는 것이 중요합니다. 프리미티브 연산은 특정 타입에 근본적으로 소속된 ‘메서드(method)’를 의미하며, 이는 상속(inheritance)과 다형성(polymorphism)의 대상이 됩니다.

Ada 레퍼런스 매뉴얼에 따르면, 타입 T의 프리미티브 연산은 다음과 같은 규칙을 만족하는 서브프로그램(프로시저 또는 함수)입니다.

  1. 타입 T의 선언과 동일한 즉각적인 유효범위(same immediate scope)에 선언되어야 합니다.
  2. 해당 서브프로그램은 파라미터나 함수 반환 값으로 타입 T를 가져야 합니다.

이 규칙에서 ‘동일한 즉각적인 유효범위’라는 조건이 바로 패키지의 역할을 결정짓는 핵심 요소입니다. 어떤 타입을 패키지 명세 내에 선언했다면, 오직 그 패키지 명세 안에 함께 선언된 서브프로그램만이 해당 타입의 프리미티브 연산이 될 자격을 가집니다.

따라서 패키지는 단순히 관련 요소를 그룹화하는 것을 넘어, 특정 타입에 소속될 프리미티브 연산의 집합, 즉 클래스의 메서드 목록을 정의하는 경계(boundary) 역할을 수행합니다.

9.6.1절의 Person_ADT 예제를 다시 살펴보겠습니다.

package Person_ADT is
   type Person is tagged private; -- 타입 T의 선언

   -- 아래 서브프로그램들은 Person 타입과 동일한 스코프(Person_ADT 명세)에 선언되었고,
   -- Person 타입을 파라미터 또는 반환 값으로 가지므로 모두 Person의 프리미티브 연산입니다.

   function create (name : in String; age : in Natural) return Person;
   function get_name (p : in Person) return String;
   procedure display (p : in Person);

private
   -- ...
end Person_ADT;

create, get_name, display는 모두 Person_ADT 패키지 명세 안에 Person 타입과 함께 선언되었으므로 Person 타입의 프리미티브 연산입니다.

반면, Person_ADT 패키지 외부에 다음과 같은 프로시저를 작성했다고 가정해 보겠습니다.

with Person_ADT;
with Ada.Text_IO;

-- 이 프로시저는 Person 타입의 객체를 사용하지만, Person 타입과 다른 스코프에 선언되었습니다.
procedure print_person_as_json (p : in Person_ADT.Person) is
begin
   Ada.Text_IO.put_line ("{ ""name"": """ & Person_ADT.get_name (p) & """ }");
end print_person_as_json;

print_person_as_json 프로시저는 Person 타입의 객체를 다루지만, Person_ADT 패키지 외부에서 선언되었기 때문에 Person 타입의 프리미티브 연산이 아닙니다. 이것은 Person 객체를 사용하는 일반적인 유틸리티 서브프로그램일 뿐입니다. 따라서 이 프로시저는 상속되거나 다형적으로 동작할 수 없습니다.

결론적으로, 패키지는 특정 타입의 프리미티브 연산이 될 수 있는 서브프로그램의 유효범위를 한정함으로써, 해당 타입의 공개 인터페이스를 명시적으로 정의하는 역할을 수행합니다. private 선언과 함께 사용될 때, 패키지는 데이터(태그드 타입)의 내부 표현을 숨기고 오직 정의된 프리미티브 연산들을 통해서만 데이터에 접근하도록 강제합니다. 이 메커니즘은 데이터의 직접적인 조작이나 패키지 외부에서 정의된 임의의 연산이 객체의 내부 상태를 손상시키는 것을 원천적으로 차단합니다. 이것이 바로 패키지를 이용한 프리미티브 연산의 캡슐화이며, Ada 객체 지향 설계의 기본 원칙입니다.

이러한 프리미티브 연산의 개념은 추후 상속을 학습할 때 더욱 중요해집니다. 자식 타입은 부모 타입의 프리미티브 연산들을 상속받으며, 필요에 따라 이들을 재정의(override)하여 자신만의 특화된 동작을 구현할 수 있게 됩니다.

9.6.3 설계 고찰: 패키지 이름과 타입 이름의 분리

Java, C++, C#과 같은 객체 지향 언어에 익숙한 개발자들은 Ada의 패키지와 타입 명명 규칙에서 처음에는 어색함을 느낄 수 있습니다. 다른 언어에서는 클래스 이름이 곧 타입 이름이자, 종종 파일을 대표하는 이름이기도 합니다.

// Java/C#의 일반적인 사용 방식
import com.gui.Widget;
// 'Widget'이라는 이름이 곧 타입.
Widget my_widget = new Widget();

하지만 Ada에서는 Widget이라는 패키지를 가져온다고 해서 Widget이라는 타입이 바로 생기는 것이 아니며, Widget.ObjectWidget.Widget_Type과 같은 이름을 사용해야 합니다. 이는 많은 이들에게 장황하고 비직관적으로 느껴질 수 있습니다. 이러한 차이는 근본적인 설계 철학의 차이에서 비롯됩니다.

Ada의 철학: 패키지는 타입이 아닌 ‘모듈’이다

다른 많은 언어에서 클래스(class)가 프로그래밍의 중심 단위인 반면, Ada에서는 패키지(package)가 중심이 되는 모듈(module)입니다.

  • Java/C#: 클래스는 곧 타입입니다. 하나의 파일에는 보통 하나의 public 클래스만 존재합니다.
  • Ada: 패키지는 타입을 담는 ‘그릇’이지, 그 자체가 타입은 아닙니다. 하나의 패키지는 관련된 여러 타입, 서브프로그램, 상수, 예외 등을 포함할 수 있는 범용적인 모듈입니다.

예를 들어, Widget 패키지는 위젯의 기본 타입을 나타내는 Object 타입, 위젯에서 발생할 수 있는 이벤트를 정의하는 Event 타입, 그리고 위젯의 상태를 나타내는 State 열거 타입 등을 모두 함께 제공할 수 있습니다. 이처럼 패키지를 하나의 타입과 동일시하지 않음으로써, Ada는 더 유연하고 포괄적인 모듈 설계를 가능하게 합니다.

일반적인 Ada 명명 관례 (Idioms)

이러한 설계 철학으로 인해 Ada에서는 다음과 같은 명명 관례가 흔히 사용됩니다.

  • .Object 접미사 사용: 패키지의 가장 핵심이 되는 타입을 Object라고 명명합니다. 이는 해당 패키지가 대표하는 ‘객체’임을 명확히 합니다.

  • Widget.Object, Button.Object, File.Handle (또는 File.Object) _Type 접미사 사용: 패키지 이름에 _Type을 붙여 타입 이름을 만듭니다.

Widget.Widget_Type, Button.Button_Type 이러한 방식이 장황하게 느껴질 수 있지만, “Widget은 모듈의 이름이고, 그 안의 Object가 실제 타입이다”라는 것을 코드 상에 명백하게 드러내어 명확성을 높인다는 장점이 있습니다.

결론: 트레이드오프의 이해

Ada의 방식은 ‘하나의 패키지가 하나의 클래스’인 단순한 경우에는 다소 장황해 보이는 비용을 치릅니다. 하지만 그 대가로, 관련된 모든 요소를 하나의 논리적 단위로 강력하게 캡슐화할 수 있는 더 유연하고 강력한 모듈화 능력을 얻습니다.

이러한 설계적 트레이드오프를 이해하는 것은 Ada의 고유한 강점을 제대로 활용하고, 이 언어의 방식으로 자연스럽게 사고하는 데 매우 중요한 과정입니다.

9.7 [고급 설계] 의존성 관리와 문제 해결

지금까지 우리는 with 절을 사용하여 패키지 간의 의존 관계를 설정하는 방법을 학습했습니다. 이 모델은 상위 모듈이 하위 모듈에 의존하는 계층적이고 비순환적(acyclic)인 아키텍처를 구축하는 데 매우 효과적입니다. 이러한 단방향 의존성은 시스템의 복잡성을 관리하고 이해하기 쉽게 만드는 이상적인 구조입니다.

그러나 실제 복잡한 시스템을 모델링하다 보면, 두 개 이상의 패키지가 서로의 타입을 참조해야 하는 순환 의존성(circular dependency) 또는 상호 의존성(mutual dependency) 문제가 불가피하게 발생하기도 합니다.

예를 들어, Customer(고객) 패키지와 Order(주문) 패키지를 설계한다고 가정해 보겠습니다.

  • Order 객체는 어떤 고객의 주문인지를 알아야 하므로, Customer 타입에 대한 참조가 필요합니다 (OrderCustomer에 의존).
  • Customer 객체는 해당 고객의 모든 주문 목록을 유지해야 하므로, Order 타입에 대한 참조가 필요합니다 (CustomerOrder에 의존).

이 상황을 일반적인 with 절로 표현하려고 하면 다음과 같은 교착 상태에 빠지게 됩니다.

-- customer.ads
with Orders; -- 'Order' 타입을 사용하기 위해
package Customers is
   type Customer is tagged private;
   -- ...
end Customers;

-- orders.ads
with Customers; -- 'Customer' 타입을 사용하기 위해
package Orders is
   type Order is tagged private;
   -- ...
end Orders;

customer.ads를 컴파일하기 위해서는 orders.ads가 먼저 컴파일되어야 하고, orders.ads를 컴파일하기 위해서는 customer.ads가 먼저 컴파일되어야 합니다. 컴파일러는 이 순환 참조를 해결할 수 없어 컴파일을 진행하지 못합니다.

이러한 논리적인 순환 의존성 문제를 해결하는 것은 대규모 객체 지향 시스템을 설계할 때 마주치는 중요한 과제입니다. 단순히 설계를 변경하여 순환 관계를 제거할 수도 있지만, 이는 종종 문제의 본질을 왜곡하거나 부자연스러운 구조를 만들 수 있습니다.

Ada는 이러한 교착 상태를 우아하게 해결하기 위해 with 절의 특별한 형태인 limited with을 제공합니다. limited with 절은 완전한 의존성을 형성하는 대신, 다른 패키지에 대한 매우 제한적이고 불완전한 뷰(incomplete view)만을 제공합니다. 이 제한된 뷰는 타입의 이름 정도만 알려주어, 완전한 명세가 처리되기 전에도 해당 타입을 포인터(접근 타입)의 대상으로 지정하는 등의 작업을 가능하게 합니다. 이것으로 컴파일러는 순환 의존성의 고리를 끊고 각 단위를 처리할 수 있게 됩니다.

본 고급 설계 절에서는 순환 의존성 문제의 본질을 더 깊이 탐구하고, limited with 절의 정확한 구문과 의미, 그리고 사용 제약 조건에 대해 학습합니다. 이를 통해 상호 참조가 필수적인 정교한 데이터 모델을 타입 안전성을 유지하면서 구축하는 방법을 익히게 될 것입니다.

9.7.1 순환 의존성 문제의 이해

순환 의존성(circular dependency)은 둘 이상의 컴파일 단위(일반적으로 패키지)가 서로를 직접 또는 간접적으로 with 하여 닫힌 사슬(closed chain)을 형성하는 상황을 말합니다. 가장 간단한 형태는 패키지 A가 패키지 Bwith하고, 동시에 패키지 B가 패키지 Awith하는 경우입니다.

이러한 구조는 Ada 컴파일러의 동작 원리상 일반 with 절로는 해결할 수 없는 근본적인 문제를 야기합니다.

컴파일러의 관점: 정교화 교착 상태

Ada 컴파일러가 소스 파일을 처리하는 과정을 생각해 보겠습니다. 컴파일러가 어떤 단위 A를 컴파일하기 위해서는, Awith하는 모든 단위 B의 명세 정보를 먼저 완전히 파악하고 있어야 합니다. B에 선언된 타입의 크기, 상수의 값, 서브프로그램의 전체 파라미터 목록 등을 알아야 A에서 이들을 올바르게 사용하고 있는지 검증할 수 있기 때문입니다.

순환 의존성 상황에서 컴파일러의 작업 흐름은 다음과 같습니다.

  1. 컴파일러가 A의 명세(a.ads)를 처리하기 시작합니다.
  2. with B; 구문을 만납니다. A를 처리하기 위해 B의 정보가 필요하므로, A의 처리를 일시 중단하고 B의 명세(b.ads)를 먼저 처리하려고 시도합니다.
  3. 컴파일러가 B의 명세(b.ads)를 처리하기 시작합니다.
  4. with A; 구문을 만납니다. B를 처리하기 위해 A의 정보가 필요하므로, B의 처리를 일시 중단하고 A의 명세(a.ads)를 먼저 처리하려고 시도합니다.
  5. 교착 상태 (Deadlock): 컴파일러는 다시 A를 처리해야 하는 상황에 놓입니다. A를 처리하려면 B가 필요하고, B를 처리하려면 A가 필요한 무한 루프에 빠지게 되어 더 이상 컴파일을 진행할 수 없습니다.

이것이 일반 with 절이 순환 의존성을 허용하지 않는 기술적인 이유입니다.

소프트웨어 공학적 관점에서의 분석

컴파일러의 기술적 제약을 넘어, 순환 의존성은 소프트웨어 설계의 품질 지표 관점에서도 분석될 필요가 있습니다. 모듈화 설계 원칙에 따르면, 순환 의อน성은 잠재적인 구조적 문제를 시사하는 지표로 간주될 수 있습니다.

  • 결합도 (Coupling) 증가: 패키지 A와 B가 상호 의존하는 경우, 두 모듈 간의 결합도는 극대화됩니다. 이는 A의 내부 구현 변경이 B에 영향을 미칠 수 있으며, 그 반대 역시 성립함을 의미합니다. 이러한 강한 결합은 개별 모듈의 독립적인 수정, 테스트, 그리고 검증 과정을 복잡하게 만들어 시스템의 유지보수 비용을 증가시키는 요인이 됩니다.
  • 재사용성 (Reusability) 저하: 모듈 A를 다른 시스템에서 재사용하고자 할 때, A는 B에 대한 의존성을 가지므로 B 모듈 또한 반드시 함께 이식되어야 합니다. 그러나 B 역시 A에 대한 의존성을 가지므로, 두 모듈은 사실상 분리가 불가능한 단일 서브시스템을 형성하게 됩니다. 이는 각 모듈의 독립적인 재사용 가치를 저해합니다.

도메인 모델의 양방향 관계 표현

그러나 모든 순환 의존성이 설계상의 결함을 의미하는 것은 아닙니다. 모델링하려는 문제 영역(problem domain) 자체가 본질적으로 양방향의 상호 참조 관계(bidirectional relationship)를 내포하는 경우가 존재합니다. 예를 들면 다음과 같습니다.

  • Customer(고객)와 Order(주문): Order는 자신의 Customer를 참조하며, Customer는 자신의 Order 목록을 참조합니다.
  • Employee(직원)와 Department(부서): Employee는 자신의 소속 Department를, Department는 소속 Employee 목록을 참조합니다.
  • 그래프 자료구조의 NodeEdge: Node는 자신에 연결된 Edge 목록을, Edge는 자신이 연결하는 Node들을 참조합니다.

이러한 본질적인 양방향 관계를 표현하기 위해 인위적으로 단방향 의존성만을 갖도록 설계를 변경하는 것은, 도메인 모델의 의미론적 명료성을 해치고 불필요한 복잡성을 야기할 수 있습니다. Ada는 이러한 경우를 위해, 타입 안전성을 유지하면서 상호 참조 관계를 직접적으로 모델링할 수 있는 언어적 메커니즘을 제공합니다. 다음 절에서는 그 해법인 limited with 절에 대해 학습하겠습니다.

9.7.2 limited with 절을 이용한 순환 의존성 해결

앞 절에서 설명한 순환 의존성이라는 컴파일 교착 상태를 해결하기 위해, Ada는 with 절의 특별한 형태인 limited with을 제공합니다. 이 구문은 일반 with 절과 달리, 참조하려는 패키지에 대한 불완전한 뷰(incomplete view)만을 제공함으로써 컴파일러가 순환의 고리를 끊을 수 있게 해줍니다.

limited with 절의 규칙과 동작은 Ada 레퍼런스 매뉴얼 10.1.2절에 상세히 기술되어 있으며, 그 핵심 원리는 다음과 같습니다.

limited with Other_Package; 구문을 사용하면, 현재 명세 파일에서는 Other_Package에 대해 매우 제한된 정보만 알게 됩니다. 구체적으로, Other_Package의 명세에 선언된 private 타입이나 불완전 타입(incomplete type)의 이름만 볼 수 있게 됩니다. 이 제한된 정보만으로는 해당 타입의 객체를 직접 선언하거나(My_Obj : Other_Package.Some_Type;은 불가), 그 타입의 상수를 사용하거나, 서브프로그램을 호출할 수 없습니다.

하지만 이 불완전한 뷰만으로도 접근 타입(access type), 즉 포인터를 선언하는 것은 가능합니다. 컴파일러는 어떤 타입의 실제 크기나 구조를 몰라도 그 타입을 가리키는 포인터를 정의할 수 있기 때문입니다. 이것이 순환 의존성을 해결하는 열쇠입니다.

전체적인 해결 패턴은 다음과 같습니다.

  1. 명세(.ads)에서는 limited with 사용: 서로를 참조해야 하는 패키지 명세에서는 limited with를 사용하여 상대방 타입의 이름만 가져와 접근 타입을 선언합니다. 이를 통해 컴파일 교착 상태를 회피합니다.
  2. 본체(.adb)에서는 일반 with 사용: 실제 구현이 필요한 본체에서는 일반 with를 사용하여 상대방 패키지의 모든 공개 자원에 접근합니다. 본체를 컴파일하는 시점에는 모든 패키지 명세가 이미 처리되었으므로, 이 시점의 일반 with는 더 이상 문제를 일으키지 않습니다.

예제: CustomerOrder 문제 해결

CustomerOrder가 서로를 참조하는 문제를 limited with와 불완전 타입 선언을 통해 해결하는 과정은 다음과 같습니다.

1. customers.ads 명세 파일

Orders 패키지에 대해 limited with를 사용하여, Order 타입의 전체 정의를 알기 전에 Order를 가리키는 접근 타입을 선언할 수 있습니다.

-- File: customers.ads
limited with Orders; -- Orders 패키지에 대한 불완전한 뷰를 요청합니다.

package Customers is
   type Customer is tagged private;
   type Customer_Access is access all Customer'Class;

   -- Order 타입을 직접 사용할 수는 없지만, Order를 가리키는 접근 타입은 사용 가능합니다.
   procedure add_order (c : in out Customer; o : in Orders.Order_Access);

private
   -- ...
   type Customer is tagged record
      name   : String (1 .. 30);
      -- orders : Orders.Order_List_Type; -- 주문 목록을 저장할 구조
   end record;
end Customers;

2. orders.ads 명세 파일

마찬가지로 Orders 패키지는 Customers에 대해 limited with를 사용합니다.

-- File: orders.ads
limited with Customers; -- Customers 패키지에 대한 불완전한 뷰를 요청합니다.

package Orders is
   type Order is tagged private;
   type Order_Access is access all Order'Class;

private
   -- Customer 타입을 직접 사용할 수는 없지만, Customer를 가리키는 접근 타입은 선언 가능합니다.
   type Order is tagged record
      order_id : Positive;
      owner    : Customers.Customer_Access; -- 주문한 고객 정보
   end record;
end Orders;

이제 두 명세 파일은 서로에 대해 완전한 정보를 요구하지 않으므로, 컴파일러는 순환 문제없이 각각을 처리할 수 있습니다.

3. customers.adb 본체 파일

본체에서는 Orders 패키지의 모든 기능이 필요하므로, 일반 with 절을 사용합니다.

-- File: customers.adb
with Orders; -- 본체에서는 일반 with를 사용하여 전체 뷰를 가져옵니다.

package body Customers is
   procedure add_order (c : in out Customer; o : in Orders.Order_Access) is
   begin
      -- Orders.Order 타입의 전체 정의를 알기 때문에,
      -- o.all과 같은 연산이 가능합니다.
      -- ... 주문을 고객의 주문 목록에 추가하는 로직 ...
   end add_order;
   -- ...
end Customers;

(orders.adb 역시 with Customers;를 사용하여 동일한 방식으로 구현됩니다.)

이처럼 limited with 절은 순환 의”존성이 필수적인 복잡한 데이터 모델을 Ada의 강력한 타입 시스템 안에서 안전하게 구현할 수 있도록 지원하는 고급 설계 도구입니다. 이는 시스템의 논리적 구조를 해치지 않으면서 컴파일러의 기술적 한계를 우회하는 정교한 해결책을 제공합니다.

9.8 [사례 연구] 표준 라이브러리 패키지 설계 분석

지금까지 9장에서는 Ada 패키지의 문법적 구조부터 시작하여 정보 은닉, 계층 구조, 정교화 제어, 그리고 고급 의존성 관리에 이르기까지 폭넓은 개념을 학습했습니다. 이러한 이론적 지식을 실제 전문가 수준의 코드에 적용된 사례를 통해 확인하는 것은, 학습한 내용을 공고히 하고 설계 원칙에 대한 깊은 통찰을 얻는 매우 효과적인 방법입니다.

이를 위해, Ada 언어 설계자들이 직접 구현한 Ada 표준 라이브러리(Ada Standard Library) 패키지들의 설계를 분석해 보겠습니다. 표준 라이브러리는 단순히 유용한 기능을 제공하는 것을 넘어, Ada 언어의 철학과 기능을 가장 잘 활용하는 모범적인 설계의 집합체입니다. 우리는 이 라이브러리 패키지들의 명세(.ads 파일)를 ‘읽는’ 방법을 통해, 앞에서 배운 추상화와 캡슐화, 계층화의 원칙들이 어떻게 실제 문제 해결에 적용되는지 살펴볼 것입니다.

본 사례 연구에서는 특히 두 가지 핵심 라이브러리 패키지를 중심으로 분석을 진행합니다.

  1. Ada.Strings.Unbounded: 동적으로 길이가 변하는 문자열을 안전하게 처리하기 위한 추상 데이터 타입(ADT)입니다. 이 패키지 분석을 통해 다음을 학습합니다.
    • limited private 타입을 사용하여 어떻게 데이터의 내부 표현을 완벽하게 숨기고, 대입과 같은 위험한 연산을 금지하여 데이터 무결성을 보장하는가.
    • 사용자가 문자열을 안전하고 편리하게 조작할 수 있도록 어떤 프리미티브 연산들이 제공되는가.
  2. Ada.Containers: 벡터(동적 배열), 맵(키-값 저장소), 세트 등 범용적인 자료구조의 컬렉션을 제공하는 라이브러리입니다. 이 패키지의 계층 구조 분석을 통해 다음을 학습합니다.
    • Ada.Containers.Vectors, Ada.Containers.Ordered_Maps 와 같이 자식 패키지를 활용하여, 어떻게 관련 있는 자료구조들을 하나의 논리적이고 체계적인 계층으로 구성하는가.
    • 제네릭(generic)을 패키지와 결합하여, 어떻게 타입에 독립적이면서도 강력한 타입 안전성을 보장하는 재사용 가능한 컨테이너를 설계하는가.

이 분석 과정을 통해 독자께서는 이론으로 배운 설계 원칙들이 실제 코드에서 어떤 모습으로 나타나는지 확인하고, 나아가 자신의 프로그램을 설계할 때 이러한 패턴들을 적용할 수 있는 능력을 갖추게 될 것입니다. 이는 단순히 라이브러리를 사용하는 것을 넘어, 라이브러리처럼 견고하고 재사용성 높은 코드를 작성하는 개발자로 성장하는 중요한 단계가 될 것입니다.

9.8.1 Ada.Strings.Unbounded의 구조 분석: 자식 패키지와 정보 은닉

Ada의 기본 String 타입은 그 길이가 컴파일 시점에 고정된다는 한계를 가집니다. 파일에서 읽어오거나 사용자로부터 입력받는 등, 길이를 미리 예측할 수 없는 텍스트를 다루기 위해 표준 라이브러리는 Ada.Strings.Unbounded 패키지를 제공합니다.

본 절에서는 Ada.Strings.Unbounded 패키지의 명세(specification)를 분석하여, 9장에서 학습한 정보 은닉과 패키지 계층화의 원칙이 어떻게 적용되어 안전하고 유연한 문자열 타입을 구현했는지 살펴보겠습니다.

limited private를 통한 완벽한 정보 은닉

Ada.Strings.Unbounded의 설계를 이해하기 위한 가장 핵심적인 부분은 그 중심 타입인 Unbounded_String의 선언입니다.

package Ada.Strings.Unbounded is
   ...
   type Unbounded_String is limited private;
   ...

이 타입이 limited private으로 선언된 것은 두 가지 중요한 설계 결정을 의미합니다.

  1. private의 역할: 내부 표현의 은닉 Unbounded_String의 실제 내부 구현은 동적으로 할당된 메모리 버퍼를 가리키는 포인터, 현재 문자열의 길이, 그리고 할당된 버퍼의 전체 용량을 관리하는 복잡한 구조일 가능성이 높습니다. private 선언은 이러한 모든 구현 세부사항을 클라이언트로부터 완벽하게 숨깁니다. 이로 인해 클라이언트는 내부 구조를 직접 조작하여 데이터의 일관성을 깨뜨리는 행위(예: 길이 정보만 바꾸고 실제 데이터는 그대로 두는 것)가 원천적으로 불가능해집니다. 모든 조작은 반드시 패키지가 제공하는 공식적인 연산(operation)을 통해서만 이루어져야 합니다.

  2. limited의 역할: 위험한 연산의 금지 limited 키워드는 private의 제약에 더해, 대입(:=)과 기본 동등 비교(=) 연산을 금지합니다. 만약 대입이 허용된다면, US1 := US2; 와 같은 코드는 내부의 포인터 값만 복사하는 ‘얕은 복사(shallow copy)’를 수행할 것입니다. 이는 두 Unbounded_String 객체가 동일한 메모리 버퍼를 공유하게 만들어, 하나의 객체를 수정하면 다른 객체도 예기치 않게 변경되고, 각 객체가 소멸될 때 동일한 메모리를 두 번 해제하려는 심각한 오류를 유발할 수 있습니다. limited 선언은 이러한 위험을 컴파일 시점에 차단합니다. 문자열을 복사하고 싶다면, To_Unbounded_String 이나 Append 와 같은 명시적이고 안전한 연산을 사용해야만 합니다.

이처럼 limited private 타입으로 Unbounded_String을 정의함으로써, 설계자는 데이터의 무결성을 보장하고 모든 연산이 예측 가능하게 동작하도록 강제하는 완벽한 추상 데이터 타입(ADT)을 구현하였습니다.

자식 패키지를 이용한 기능의 모듈화

Ada.Strings.Unbounded는 문자열의 생성, 수정, 검색과 관련된 핵심 기능에 집중합니다. 만약 입출력과 같은 부가적인 기능까지 이 패키지에 모두 포함했다면 패키지는 불필요하게 비대해졌을 것입니다. 대신, 라이브러리 설계자는 자식 패키지를 이용하여 관련 기능들을 논리적으로 분리하고 모듈화했습니다.

대표적인 예가 Ada.Strings.Unbounded.Unbounded_IO 입니다.

-- Ada.Strings.Unbounded의 자식 패키지로 선언된 Unbounded_IO
package Ada.Strings.Unbounded.Unbounded_IO is
   procedure Put_Line (File : in Ada.Text_IO.File_Type;
                       Item : in Unbounded_String);
   ...
end Ada.Strings.Unbounded.Unbounded_IO;

이 자식 패키지는 Unbounded_String 타입의 객체를 파일에 쓰거나 읽어오는 입출력 기능만을 전문적으로 담당합니다. 이러한 계층적 설계는 다음과 같은 이점을 제공합니다.

  • 관심사의 분리 (Separation of Concerns): 핵심 문자열 처리 로직과 입출력 로직이 명확하게 분리됩니다.
  • 의존성 최소화: 문자열 입출력이 필요 없는 클라이언트는 Ada.Strings.Unboundedwith하면 되며, 불필요하게 Ada.Text_IO에 대한 의존성을 가질 필요가 없습니다.

Ada.Strings.Unbounded 패키지는 limited private 타입을 통해 데이터의 캡슐화와 무결성을 보장하고, 자식 패키지를 통해 기능의 모듈성과 확장성을 확보하는 Ada다운(Ada-esque) 설계의 전형적인 모범 사례입니다. 이 구조는 9장에서 다룬 설계 원칙들이 어떻게 조합되어 견고하고 재사용성 높은 소프트웨어 컴포넌트를 만들어내는지를 명확히 보여줍니다.

9.8.2 Ada.Containers의 설계: 제네릭과 계층 구조

Ada.Containers는 벡터(동적 배열), 리스트, 맵(key-value 연관 배열), 세트 등 프로그래밍에서 필수적으로 사용되는 범용 자료구조들을 모아놓은 표준 라이브러리입니다. 이 라이브러리는 단순히 유용한 기능을 제공하는 것을 넘어, 재사용 가능하고, 타입 안전하며, 확장성 있는 대규모 라이브러리를 어떻게 설계해야 하는지에 대한 청사진을 제시합니다.

Ada.Containers의 설계는 크게 두 가지 핵심 원칙에 기반합니다: 계층 구조를 통한 체계적인 분류, 그리고 제네릭(generics)을 통한 타입 안전성 및 재사용성 확보입니다.

계층 구조를 통한 체계적인 분류

Ada.Containers는 단일 패키지가 아닌, 여러 패키지들이 유기적으로 연결된 거대한 계층 구조를 이룹니다.

  1. 최상위 부모 패키지 (Ada.Containers): Ada.Containers 자체는 추상적인 이름공간 역할을 합니다. 이 패키지를 직접 인스턴스화하여 컨테이너를 생성할 수는 없습니다. 대신, 모든 컨테이너들이 공통적으로 사용하는 Count_Type(요소의 개수를 나타내는 타입)이나 Cursor(컨테이너의 특정 위치를 가리키는 반복자)와 같은 기본 타입과 개념을 정의합니다.

  2. 자식 패키지 (Child Packages): 실제 자료구조의 구현은 모두 Ada.Containers의 자식 패키지 형태로 제공됩니다. 이는 각 자료구조의 특성에 따라 기능을 명확하게 분류하고 조직화합니다.

    • Ada.Containers.Vectors: C++의 std::vector와 유사한 동적 배열을 제공합니다.
    • Ada.Containers.Doubly_Linked_Lists: 양방향 연결 리스트를 제공합니다.
    • Ada.Containers.Ordered_Maps: 키(key)를 기준으로 정렬되는 맵을 제공합니다.
    • Ada.Containers.Hashed_Maps: 해시 테이블 기반의 맵을 제공하여 빠른 조회를 지원합니다.
    • Ada.Containers.Ordered_Sets / Hashed_Sets: 정렬되거나 해싱된 집합을 제공합니다.

이러한 계층적 설계는 Containers.Vectors라는 이름만으로도 “컨테이너의 일종인 벡터”라는 의미를 명확하게 전달하며, 사용자가 필요한 자료구조를 쉽게 찾고 그 관계를 이해할 수 있도록 돕습니다.

제네릭을 통한 타입 안전성과 재사용성 확보

컨테이너 라이브러리가 풀어야 할 가장 큰 숙제는 ‘어떻게 모든 종류의 데이터를 담으면서도 타입 안전성을 잃지 않을 것인가’입니다. Ada.Containers는 이 문제를 제네릭(generic)을 통해 해결합니다.

Ada.Containers.Vectors와 같은 패키지들은 일반적인 패키지가 아닌 제네릭 패키지입니다. 즉, 실제 패키지로 사용되기 전에 어떤 타입의 요소를 저장할 것인지 등 구체적인 정보를 제공하여 인스턴스화(instantiation) 과정을 거쳐야 하는 ‘설계도’ 또는 ‘틀(template)’과 같습니다.

Ada.Containers.Vectors의 제네릭 명세 일부는 다음과 같은 형태입니다.

generic
   type Element_Type is private; -- 벡터에 저장될 요소의 타입을 매개변수로 받습니다.
   with function "=" (Left, Right : Element_Type) return Boolean is <>;
package Ada.Containers.Vectors is
   type Vector is tagged private;
   procedure Append (Container : in out Vector; New_Item : in Element_Type);
   ...
end Ada.Containers.Vectors;

사용자는 이 제네릭 패키지를 다음과 같이 인스턴스화하여 자신만의 구체적인 벡터 패키지를 생성합니다.

-- 정수(Integer)를 저장하는 벡터 패키지를 새로 생성
package Integer_Vectors is
  new Ada.Containers.Vectors (Element_Type => Integer);

-- 문자열(String)을 저장하는 벡터 패키지를 새로 생성
package String_Vectors is
  new Ada.Containers.Vectors (Element_Type => String);

-- 위에서 정의한 Person_ADT.Person 타입을 저장하는 벡터 패키지 생성
package Person_Vectors is
  new Ada.Containers.Vectors (Element_Type => Person_ADT.Person);

제네릭의 핵심 이점:

  • 타입 안전성 (Type Safety): Integer_Vectors로 생성된 벡터에는 오직 Integer 타입의 값만 추가할 수 있습니다. 만약 문자열을 추가하려고 시도하면, 이는 런타임 오류가 아닌 컴파일 시점 오류로 잡힙니다. 이는 프로그램의 신뢰성을 극적으로 향상시킵니다.
  • 재사용성 (Reusability): 벡터 자료구조의 복잡한 내부 로직(메모리 할당, 재할당, 요소 접근 등)은 단 한 번만 작성되었습니다. 개발자는 이 검증된 로직을 Integer, String, 혹은 사용자가 정의한 어떠한 타입에 대해서도 인스턴스화 과정을 통해 즉시 재사용할 수 있습니다.

결론: 설계 원칙의 시너지

Ada.Containers 라이브러리는 두 설계 원칙의 강력한 시너지를 보여줍니다. 계층 구조는 다양한 종류의 컨테이너들을 체계적으로 분류하고 조직하며, 제네릭은 각각의 컨테이너가 특정 데이터 타입에 대해 안전하고 효율적으로 동작하도록 보장합니다. 이 두 가지를 결합함으로써 Ada.Containers는 확장 가능하고, 이해하기 쉬우며, 매우 높은 수준의 신뢰성을 제공하는 현대적인 라이브러리 설계의 정수를 보여주고 있습니다.

10. 예외 처리

아무리 완벽하게 프로그램을 설계하고 작성하더라도, 예상치 못한 오류는 언제나 발생할 수 있습니다. 사용자가 존재하지 않는 파일을 열려고 시도하거나, 네트워크 연결이 갑자기 끊기거나, 계산 결과가 허용된 숫자 범위를 초과하는 등 프로그램의 정상적인 실행을 방해하는 상황은 무수히 많습니다.

이러한 예외적인(exceptional) 상황을 어떻게 처리하느냐가 소프트웨어의 견고함(robustness)신뢰성(reliability)을 결정합니다. 전통적인 방식에서는 함수가 반환하는 상태 코드를 모든 호출 지점에서 일일이 확인해야 했습니다. 이러한 방식은 주된 로직을 파악하기 어렵게 만들고, 프로그래머가 오류 확인을 누락할 경우 시스템 전체를 위험에 빠뜨릴 수 있습니다.

Ada는 이러한 문제를 해결하기 위해 예외 처리(Exception Handling)라는 체계적이고 강력한 메커니즘을 제공합니다. 예외 처리의 핵심 철학은 정상적인 실행 흐름과 오류 처리 로직을 명확하게 분리하는 것입니다. 이를 통해 우리는 프로그램의 주된 로직을 깔끔하게 유지하면서, 예외적인 상황이 발생했을 때 어떻게 대응할지를 구조적으로 관리할 수 있습니다.

이번 9장에서는 Ada의 예외 처리 메커니즘을 기초부터 심도 있게 학습합니다. 예외를 선언하고 발생시키는 방법부터 시작하여, 예외가 프로그램 내에서 어떻게 전파되고 처리되는지, 그리고 동시성 환경에서는 어떻게 동작하는지를 살펴볼 것입니다. 나아가 고급 예외 관리 기법과 실제 사례 연구를 통해 다양한 상황에 대처하는 모범 사례를 익힙니다.

이 장을 마치고 나면, 여러분은 단순히 동작하는 프로그램을 넘어, 어떠한 예외 상황에서도 안정적으로 대처할 수 있는 견고하고 신뢰성 높은 소프트웨어를 구축할 수 있는 능력을 갖추게 될 것입니다.

10.1 견고한 프로그래밍의 필요성

소프트웨어의 품질을 평가할 때 ‘견고함(Robustness)’은 핵심적인 척도 중 하나입니다. 견고한 프로그램이란, 단순히 주어진 명세를 완벽하게 수행하는 것을 넘어, 예상치 못한 입력이나 비정상적인 실행 환경에 직면했을 때도 치명적인 오류로 중단되지 않고 안정적으로 동작하거나 예측 가능한 방식으로 실패(fail gracefully)하는 프로그램을 의미합니다.

프로그램의 오류는 단순히 프로그래머의 실수(bug)에 국한되지 않습니다. 사용자의 잘못된 데이터 입력, 디스크 공간 부족, 네트워크 단절, 하드웨어 센서의 비정상적인 값 반환 등 프로그램 외부 환경에서 비롯되는 경우가 훨씬 많습니다.

견고하지 못한 소프트웨어는 단순히 멈추는 것에서 그치지 않고, 데이터를 손상시키거나, 항공기, 원자력 발전소, 의료 기기와 같은 고신뢰성 시스템에서는 인명과 재산에 직접적인 위협이 될 수 있습니다.

따라서 견고한 소프트웨어를 구축하기 위해서는 오류를 다루는 체계적인 접근법이 필수적입니다. 이번 절에서는 먼저 프로그램에서 발생할 수 있는 오류의 종류를 이해하고, 전통적인 오류 처리 방식이 왜 현대적인 고신뢰성 시스템에 부적합한지 살펴볼 것입니다. 그리고 이를 통해 Ada가 제공하는 구조적인 예외 처리 방식의 필요성을 자연스럽게 이해하게 될 것입니다.

10.1.1 프로그램 오류의 이해

견고한 프로그램을 작성하기 위한 첫걸음은 프로그램에서 발생할 수 있는 ‘오류(Error)’의 종류를 이해하는 것입니다. 오류는 그것이 발견되는 시점에 따라 크게 컴파일-시간 오류, 링크-시간 오류, 그리고 런타임 오류로 나눌 수 있습니다.

컴파일-시간 오류 (Compile-Time Errors)

컴파일-시간 오류는 소스 코드를 실행 파일로 변환하는 컴파일 과정에서 발견되는 오류입니다. 이는 대부분 프로그래밍 언어의 문법이나 정적 규칙을 위반했을 때 발생합니다.

  • 예시:
    • 키워드 오타 (procedureprocedur로 작성)
    • 문장의 끝에 세미콜론(;) 누락
    • 타입 불일치 (정수 타입 변수에 문자열을 대입)

Ada의 컴파일러는 강력한 정적 분석 기능을 통해 이러한 오류를 매우 엄격하게 검사합니다. 컴파일-시간 오류는 프로그램이 실행되기 전에 발견되므로 가장 안전하고 수정하기 쉬운 “좋은” 오류입니다. Ada의 설계 철학은 가능한 한 많은 오류를 이 단계에서 잡아내는 것입니다.

링크-시간 오류 (Link-Time Errors)

링크-시간 오류는 각 소스 파일이 성공적으로 컴파일된 후, 이를 하나의 실행 파일로 묶는 링크(link) 과정에서 발생하는 오류입니다. 이는 주로 프로그램의 여러 구성 요소 간의 연결이 맞지 않을 때 발생합니다.

  • 예시:
    • 패키지 명세(ads)에 선언만 하고, 본체(adb)에 구현하지 않은 서브프로그램을 호출
    • 존재하지 않는 라이브러리와 연결을 시도

이 역시 프로그램 실행 전에 발견되므로 비교적 안전하지만, 프로그램의 구조나 빌드 설정에 문제가 있음을 나타냅니다.

런타임 오류 (Run-Time Errors)

런타임 오류는 프로그램이 실행되는 도중에 발생하는, 가장 다루기 까다롭고 위험한 오류입니다. 코드의 문법은 완벽하고 성공적으로 실행 파일이 만들어졌지만, 특정 실행 조건 하에서 문제가 발생하는 경우입니다. 런타임 오류는 다시 두 가지로 나눌 수 있습니다.

  1. 논리적 오류 (Logical Errors / Bugs): 프로그램이 중단되지는 않지만, 알고리즘의 결함으로 인해 의도와 다른 잘못된 결과를 출력하는 경우입니다. 예외 처리가 이 문제를 직접 해결하지는 않지만, 계약 기반 설계(Design by Contract)의 단정(Assert) 등을 통해 비정상적인 상태를 감지하는 데 도움을 줄 수 있습니다.

  2. 예외적인 상황 (Exceptional Situations): 정상적인 상황에서는 문제가 없으나, 외부 환경이나 계산 과정에서 발생하는 예기치 못한 상황입니다. 이번 장에서 다루는 ‘예외 처리’는 바로 이러한 상황을 관리하기 위한 것입니다.

    • 외부 요인: 존재하지 않는 파일을 열려고 시도, 디스크 공간 부족, 네트워크 연결 끊김.
    • 내부 요인: 0으로 나누기, 숫자 타입의 표현 범위를 초과하는 연산(Constraint_Error), null 접근 타입 참조.

이러한 예외적인 상황을 적절히 처리하지 않으면 프로그램은 비정상적으로 중단되거나 데이터를 손상시킬 수 있습니다. 다음 절에서는 이러한 런타임 오류를 처리하던 전통적인 방식의 한계를 살펴보고, 왜 Ada의 예외 처리 메커니즘이 필요한지 알아보겠습니다.

10.1.2 전통적인 오류 처리 방식의 한계

런타임 오류를 처리하기 위해 예외 처리(exception handling)가 등장하기 전에는 주로 상태 반환 코드(status return code)나 전역 오류 변수(global error variable)와 같은 방식이 사용되었습니다. 이러한 전통적인 방식들은 프로그램을 동작하게는 할 수 있지만, 코드의 가독성과 견고함에 심각한 한계를 드러냅니다.

상태 반환 코드 (Status Return Codes)

가장 널리 사용되던 방식으로, 서브프로그램이 자신의 실행 결과를 나타내는 특별한 값(정수 또는 열거형)을 반환하는 기법입니다. 호출자는 이 반환값을 확인하여 성공 여부나 오류의 종류를 판단합니다.

-- 예시: 상태 코드를 사용한 파일 열기 (권장하지 않는 방식)
type Status_Code is (Success, File_Not_Found, Permission_Denied);

procedure open_file (Path : String; Code : out Status_Code) is
  -- ... 파일 열기 시도 ...
  -- 성공 시 Code := Success;
  -- 실패 시 Code := File_Not_Found; 또는 Permission_Denied;
begin
  null;
end open_file;

-- 호출자의 코드
procedure process_data is
  Status : Status_Code;
begin
  open_file ("data.txt", Status);
  if Status /= Success then
    if Status = File_Not_Found then
      -- 파일 없음 오류 처리
    elsif Status = Permission_Denied then
      -- 권한 없음 오류 처리
    end if;
    return; -- 작업 중단
  end if;

  -- open_file이 성공했을 때만 실행되는 주 로직
  -- ...
end process_data;

이 방식의 한계는 명확합니다.

  1. 주 로직과 오류 처리 로직의 혼합: 프로그램의 정상적인 실행 흐름(happy path) 중간중간에 오류를 확인하는 if-then-else 문이 계속해서 삽입됩니다. 이로 인해 정작 중요한 알고리즘의 흐름을 파악하기가 매우 어려워지고 코드가 복잡해집니다.

  2. 오류 처리의 누락 가능성: 프로그래머가 반환된 상태 코드를 확인하는 것을 잊거나 의도적으로 무시하기 쉽습니다. 언어가 오류 확인을 강제하지 않기 때문입니다. 무시된 오류는 사라지지 않고 잠복해 있다가 프로그램의 다른 부분에서 훨씬 더 심각한 데이터 손상이나 비정상 종료를 유발하여 디버깅을 어렵게 만듭니다.

  3. 오류 정보 전파의 어려움: 여러 단계의 서브프로그램 호출(A → B → C)에서 가장 안쪽(C)에서 발생한 오류를 최상위(A)까지 전달하려면, 중간의 모든 서브프로그램(B)이 오류 코드를 받아서 그대로 다시 반환하는 코드를 추가로 작성해야 합니다. 이는 오류와 직접 관련 없는 중간 단계의 코드를 불필요하게 오염시킵니다.

전역 오류 변수 (Global Error Variables)

오류가 발생했을 때 약속된 전역 변수(C 언어의 errno처럼)에 오류 코드를 설정하는 방식입니다. 호출자는 서브프로그램 호출 직후 이 전역 변수를 확인하여 오류 상태를 파악합니다.

이 방식은 상태 반환 코드의 일부 문제를 해결하는 것처럼 보이지만, 더 심각한 문제를 야기합니다. 특히 동시성 프로그래밍(concurrency) 환경에서는 여러 태스크가 동시에 전역 변수를 수정하려 할 때 경쟁 상태(race condition)가 발생하여 신뢰할 수 없는 오류 값을 읽게 될 위험이 있습니다.

이처럼 전통적인 오류 처리 방식은 코드의 가독성을 해치거나, 잠재적인 오류를 무시할 위험을 감수하게 만듭니다. 결국 깔끔하면서도 견고한 코드를 작성하기 어렵게 만드는 근본적인 한계를 가집니다. 다음 절에서는 Ada의 예외 처리 메커니즘이 이러한 문제들을 어떻게 구조적으로 해결하는지 알아보겠습니다.

10.1.3 구조적 접근법: 예외(exception) 소개

전통적인 오류 처리 방식의 한계를 극복하기 위해, Ada는 예외 처리(Exception Handling)라는 구조적이고 강력한 접근법을 제공합니다. 이 메커니즘의 핵심 철학은 관심사의 분리(Separation of Concerns), 즉 정상적인 실행 흐름오류 처리 흐름을 코드 상에서 명확하게 분리하는 것입니다.

핵심 철학: 정상 흐름과 오류 흐름의 분리

예외 처리를 사용하면, 프로그램의 주 로직은 모든 연산이 성공할 것이라는 가정하에 “성공 경로(happy path)”에만 집중하여 작성할 수 있습니다. 오류를 확인하는 if문이 코드 곳곳에 흩어져 있을 필요가 없습니다. 이로써 주된 알고리즘은 간결하고 명확해져 가독성과 유지보수성이 크게 향상됩니다.

그리고 예상치 못한 문제가 발생했을 때의 처리 코드는 exception이라는 별도의 블록에 모아서 작성합니다. 이 블록은 오직 예외가 발생했을 때만 실행됩니다.

이는 마치 건물의 주 출입구와 비상구를 분리하는 것과 같습니다. 평상시에는 주 출입구를 이용하며, 비상 상황(예외)이 발생했을 때만 비상구(예외 핸들러)라는 정해진 경로를 통해 대처하는 것과 동일한 원리입니다.

동작 원리: 발생(Raise)과 처리(Handle)

Ada의 예외 처리 메커니즘은 두 가지 주요 동작으로 이루어집니다.

  1. 예외 발생 (Raise): 런타임 오류가 발생하면, 해당 지점에서 예외가 발생(raise)합니다. 이 순간 프로그램의 정상적인 실행은 즉시 중단됩니다.
  2. 예외 처리 (Handle): 제어권은 런타임 시스템으로 넘어가고, 시스템은 현재 실행 중인 코드 블록의 exception 부분에서 해당 예외를 처리할 수 있는 핸들러(handler)를 찾습니다. 적절한 핸들러를 찾으면 그 코드를 실행하고, 만약 찾지 못하면 예외는 호출 스택을 따라 상위 서브프로그램으로 전파(propagate)됩니다.

구조적 예외 처리의 장점

이러한 접근법은 전통적인 방식의 한계를 명확하게 해결합니다.

  • 가독성 향상: 주 로직과 오류 처리 로직이 분리되어 코드를 이해하기 쉽습니다.
  • 오류 처리 강제: 발생한 예외는 무시되지 않습니다. 만약 프로그램의 어떤 수준에서도 처리되지 않으면, 최종적으로 프로그램은 중단됩니다. 이는 오류를 실수로라도 누락하는 것을 방지하여 프로그램의 신뢰성을 높입니다.
  • 오류 정보의 자동 전파: 중간 단계의 서브프로그램들이 오류를 전달하기 위한 별도의 코드를 작성할 필요 없이, 예외가 자동으로 상위로 전파되어 필요한 곳에서 처리될 수 있습니다.

다음은 예외 처리의 기본 구조를 보여주는 개념적인 코드입니다.

begin
  -- 정상적인 실행 로직
  -- 이 코드는 모든 연산이 성공할 것이라고 가정하고 작성됩니다.
  Step_1;
  Step_2;
  Step_3;

exception
  when Error_In_Step_2 =>
    -- Step_2에서 특정 오류가 발생했을 때 실행될 복구 코드
  when others =>
    -- 그 외 예측하지 못한 다른 오류가 발생했을 때 실행될 코드
end;

이제 우리는 왜 예외 처리가 필요한지 이해했습니다. 다음 섹션부터는 Ada에서 예외를 실제로 어떻게 선언하고, 발생시키며, 처리하는지에 대한 구체적인 구문과 기법을 학습할 것입니다.

10.2 Ada 예외의 기초

앞선 9.1절에서 우리는 왜 전통적인 오류 처리 방식에 한계가 있으며, 왜 구조적인 예외 처리가 견고한 소프트웨어에 필수적인지를 살펴보았습니다. 이제 이론을 넘어, Ada에서 예외를 다루는 구체적인 방법과 문법을 배울 차례입니다.

이번 9.2절에서는 예외 처리의 가장 기본적인 구성 요소들을 하나씩 학습합니다. 예외가 무엇인지 정확히 정의하고, 우리만의 예외를 선언(declare)하며, raise 문을 통해 예외를 발생(raise)시키고, exception 블록으로 이를 처리(handle)하는 전체 과정을 다룰 것입니다.

또한, 우리가 직접 선언하는 예외 외에도 0으로 나누거나 타입의 범위를 벗어나는 등의 특정 규칙 위반 시 언어 런타임 시스템이 자동으로 발생시키는 사전 정의된 예외들에 대해서도 알아봅니다.

이 절을 마치면 여러분은 Ada 프로그램에서 예외를 정의하고, 기본적인 예외 상황을 제어할 수 있는 핵심적인 문법 지식을 갖추게 될 것입니다.

10.2.1 예외란 무엇인가?

프로그램 실행 중에 발생하는 오류나 예상치 못한 상황을 예외(exception)라고 합니다. 예외는 프로그램의 정상적인 명령어 흐름을 방해하는 이벤트입니다.

Ada에서 예외는 오류를 처리하기 위한 체계적이고 구조적인 메커니즘을 제공합니다. 오류가 발생할 수 있는 지점에서 상태 코드를 반환하고 모든 호출자가 이를 확인하도록 요구하는 전통적인 방식과 달리, 예외는 오류 처리 로직을 주된 프로그램 로직과 분리합니다. 이러한 분리는 다음과 같은 장점을 가집니다.

  • 가독성 향상: 주된 알고리즘이 오류 검사 코드로 인해 복잡해지는 것을 방지하여 코드의 가독성과 유지보수성을 높입니다.
  • 오류 처리 강제: 발생한 예외는 반드시 처리되어야 합니다. 만약 현재 유효 범위 내에 적절한 예외 핸들러가 없다면 예외는 호출 스택을 따라 상위로 전파됩니다. 최상위 수준까지 전파된 예외가 처리되지 않으면 프로그램은 종료됩니다. 이 특성은 프로그래머가 오류를 무시하고 넘어가는 것을 방지합니다.

Ada에서 예외 처리의 기본 흐름은 다음과 같습니다.

  1. 예외 발생 (Raise): 프로그램 실행 중 예외적인 상황이 발생하면, 해당 지점에서 예외를 raise 합니다.
  2. 실행 중단 및 핸들러 탐색: 예외가 발생하면, 현재 코드 블록의 정상적인 실행은 즉시 중단됩니다. 이후 런타임 시스템은 해당 예외를 처리할 수 있는 예외 핸들러(exception handler)를 찾기 시작합니다.
  3. 예외 처리 (Handle): 적절한 핸들러를 찾으면, 해당 핸들러의 코드가 실행됩니다. 핸들러의 실행이 완료되면, 프로그램 제어는 예외가 발생했던 코드 블록의 다음으로 이동합니다.

개념적으로, 이는 공장의 비상 정지 시스템과 유사합니다. 정상적인 생산 라인(프로그램의 정상 흐름)에서 치명적인 문제가 발생(예외 발생)하면, 비상 벨이 울리면서 라인이 즉시 멈춥니다. 그 후, 작업자들은 사전에 정의된 비상 대응 매뉴얼(예외 핸들러)에 따라 문제를 해결합니다.

다음은 예외 처리 구조를 보여주는 개념적인 코드 형식입니다.

begin
  -- 프로그램의 정상적인 실행 흐름
  -- 이 블록 안에서 예외가 발생할 수 있습니다.

exception
  when 특정_예외 =>
    -- '특정_예외'가 발생했을 때 실행될 코드
  when others =>
    -- 그 외 다른 모든 예외가 발생했을 때 실행될 코드
end;

이후 섹션에서는 Ada에서 이러한 예외를 직접 선언하고, 발생시키며, 처리하는 구체적인 구문과 기법에 대해 학습할 것입니다.

10.2.2 예외 타입 선언

Ada는 Constraint_Error와 같이 사전 정의된 예외들을 제공하지만, 대부분의 실제 응용 프로그램에서는 해당 도메인에 맞는 특정한 오류 상황을 표현하기 위해 우리만의 예외를 직접 만들어 사용해야 합니다. 예를 들어, ‘네트워크 연결 실패’나 ‘잔액 부족’과 같은 오류는 언어에 내장된 예외만으로는 표현하기 어렵습니다.

사용자 정의 예외를 만드는 것은 코드의 가독성을 높이고 오류의 원인을 명확하게 전달하는 첫걸음입니다.

예외 선언 구문

Ada에서 예외는 다음과 같이 간단하게 선언할 수 있습니다. 식별자 뒤에 exception 키워드를 붙이면 됩니다.

식별자 : exception;

예외의 이름은 해당 오류 상황을 명확히 설명하도록 짓는 것이 중요합니다. 일반적으로 _Error 접미사를 붙여 다른 식별자와 구분하는 명명 규칙을 많이 사용합니다.

-- 예외 선언 예시
Invalid_Input_Error  : exception;
Network_Timeout      : exception;
Insufficient_Funds   : exception;

예외의 선언 위치와 유효 범위(Scope)

예외는 변수나 타입과 마찬가지로 선언부에 선언되며, 선언된 위치에 따라 유효 범위가 결정됩니다.

  1. 패키지 명세 (Package Specification): 가장 일반적인 선언 위치입니다. 패키지 명세에 선언된 예외는 해당 패키지를 with하는 모든 클라이언트 코드에서 접근할 수 있습니다. 이는 패키지가 제공하는 기능에서 발생할 수 있는 오류를 외부에 알리는 공식적인 방법입니다.

  2. 서브프로그램 또는 패키지 본체 (Subprogram or Package Body): 서브프로그램이나 패키지 본체 내부에 선언된 예외는 해당 유효 범위 내에서만 사용할 수 있습니다. 외부에는 공개되지 않는 내부적인 오류를 처리할 때 유용합니다.

다음은 자판기 프로그램을 예시로 한 선언 위치입니다.

-- vending_machine.ads
package Vending_Machine is

  -- 클라이언트에게 공개되는 예외
  Out_Of_Stock       : exception; -- 재고 없음 오류
  Insufficient_Funds : exception; -- 잔액 부족 오류

  procedure Purchase (Item_ID : Integer);

private
  -- 패키지 내부에서만 사용되는 비공개 예외
  Internal_Jam_Error : exception; -- 내부 걸림 오류

end Vending_Machine;

이처럼 적절한 이름으로 예외를 선언하고, 용도에 맞게 유효 범위를 지정함으로써 우리는 추상적인 런타임 오류를 구체적이고 의미 있는 프로그램의 이벤트로 만들 수 있습니다.

10.2.3 raise 문을 이용한 예외 발생

Ada 런타임 시스템은 타입의 범위를 벗어나는 연산이 발생했을 때 Constraint_Error를 자동으로 발생시키는 것처럼, 특정 상황에서 예외를 스스로 발생시킵니다. 하지만 많은 경우, 우리는 프로그램의 비즈니스 규칙이나 특정 논리적 조건에 따라 의도적으로 예외를 발생시켜야 합니다.

raise 문은 이처럼 프로그래머가 직접 예외를 발생시키는 데 사용하는 명시적인 명령어입니다.

raise 문의 구문과 사용법

raise 문의 구문은 매우 간단합니다. raise 키워드 뒤에 발생시키고자 하는 예외의 이름을 적으면 됩니다.

raise 예외_이름;

raise 문은 주로 if 문이나 case 문과 함께 사용되어, 특정 조건이 만족되었을 때(즉, 오류 상황이 감지되었을 때) 예외적인 처리 흐름을 시작시키는 역할을 합니다.

다음은 은행 계좌에서 출금하는 프로시저의 예시입니다. 잔액보다 많은 금액을 출금하려는 시도는 언어 규칙 위반은 아니지만, 명백한 비즈니스 규칙 위반입니다.

package Bank_Account is
  Insufficient_Funds : exception; -- '잔액 부족' 예외

  procedure Withdraw (Amount  : in Positive;
                      Balance : in out Natural);
end Bank_Account;

package body Bank_Account is
  procedure Withdraw (Amount  : in Positive;
                      Balance : in out Natural) is
  begin
    if Amount > Balance then
      -- 출금액이 잔액보다 많다는 오류 조건을 감지
      -- -> 예외를 명시적으로 발생시켜 오류를 알린다.
      raise Insufficient_Funds;
    end if;

    -- 정상 로직: 위의 if 조건이 거짓일 때(예외가 발생하지 않았을 때)만 실행된다.
    Balance := Balance - Amount;
  end Withdraw;
end Bank_Account;

raise 문의 효과

raise 문이 실행되면, 해당 블록의 정상적인 실행은 즉시 중단됩니다. raise 문 이후에 있는 코드는 전혀 실행되지 않습니다. 프로그램의 제어권은 즉시 런타임 시스템으로 넘어가며, 시스템은 이 예외를 처리할 수 있는 예외 핸들러를 찾기 시작합니다.

예외 다시 발생시키기 (Re-raising)

exception 핸들러 블록 내에서는, 방금 처리한 예외를 상위로 다시 전파시켜야 할 때가 있습니다. 이때는 예외 이름을 명시하지 않은 raise; 문을 사용합니다.

이 기법은 특정 레벨에서 오류 로깅(logging)과 같은 부분적인 처리를 수행한 뒤, 전체적인 복구는 상위 호출자에게 위임하고 싶을 때 유용합니다.

begin
  -- ... 작업을 수행 ...
exception
  when Some_Error =>
    Log_Error ("Something went wrong here."); -- 1. 현재 레벨에서 로그만 남긴다.
    raise; -- 2. 동일한 예외를 호출자에게 다시 던져서 처리를 위임한다.
end;

이처럼 raise 문은 단순한 조건 검사를, 무시할 수 없는 강력한 오류 신호로 바꾸어주는 핵심적인 도구입니다.

10.2.4 exception 블록을 이용한 예외 처리

raise 문으로 예외를 발생시키거나 시스템이 예외를 자동으로 발생시켰을 때, 이 예외를 붙잡아 처리하는 “안전망” 역할을 하는 것이 바로 exception 블록입니다. exception 블록은 발생한 예외의 전파를 멈추고, 정의된 복구 코드를 실행하여 프로그램의 제어권을 되찾아오는 역할을 합니다.

예외 처리 구문

예외 처리는 일반적으로 begin으로 시작하는 블록의 끝에 exception 키워드를 추가하여 구성합니다.

begin
   -- 예외가 발생할 수 있는 보호된 코드 블록
   -- ...

exception
   when 예외_이름_1 =>
      -- 예외_이름_1을 처리하는 코드

   when 예외_이름_2 | 예외_이름_3 =>
      -- 예외_이름_2 또는 예외_이름_3을 처리하는 코드

   when others =>
      -- 위에서 명시되지 않은 다른 모든 예외를 처리하는 코드
end;
  • begin ... exception: beginexception 사이의 코드는 보호된 영역입니다. 이 영역에서 예외가 발생하지 않으면, exception 이하의 모든 코드는 실행되지 않고 건너뜁니다.
  • when 예외_이름 =>: 특정 예외를 처리하는 핸들러(handler)를 정의합니다.
  • | (수직 막대): 여러 종류의 예외를 동일한 핸들러로 처리하고 싶을 때 사용합니다.
  • when others =>: 앞에서 명시되지 않은 다른 모든 예외를 처리하는 ‘만능’ 핸들러입니다. 예상치 못한 오류에 대한 최종적인 안전장치 역할을 하므로 매우 중요합니다.

실행 흐름

begin 블록 내에서 예외가 발생하면 다음과 같은 순서로 실행됩니다.

  1. 보호된 영역의 정상적인 실행이 즉시 중단됩니다.
  2. 런타임 시스템은 exception 블록의 when 절을 위에서부터 순서대로 확인하여 발생한 예외와 일치하는 첫 번째 핸들러를 찾습니다.
  3. 일치하는 핸들러의 코드를 실행합니다.
  4. 핸들러의 실행이 끝나면, 프로그램 제어는 exception 블록을 포함하는 end; 문의 다음 문장으로 이동합니다. 예외가 발생했던 위치로 되돌아가지 않습니다.

예제: 출금 시도 및 예외 처리

앞서 Withdraw 프로시저에서 발생시켰던 Insufficient_Funds 예외를 exception 블록으로 처리하는 완전한 예제입니다.

with Ada.Text_IO;
use  Ada.Text_IO;

procedure Test_Withdrawal is
  Insufficient_Funds : exception;
  Current_Balance    : Natural := 100;

  procedure Withdraw (Amount : in Positive) is
  begin
    if Amount > Current_Balance then
      raise Insufficient_Funds;
    end if;
    Current_Balance := Current_Balance - Amount;
  end Withdraw;

begin
  put_line ("Current balance: " & Natural'image (Current_Balance));
  New_Line;

  -- 1. 성공적인 출금 시도
  put_line ("Attempting to withdraw 50...");
  Withdraw (Amount => 50);
  put_line ("Withdrawal successful. New balance: " & Natural'image (Current_Balance));
  New_Line;

  -- 2. 실패가 예상되는 출금 시도
  put_line ("Attempting to withdraw 120...");
  begin
    Withdraw (Amount => 120);
    put_line ("This line will not be printed."); -- 예외 발생으로 실행되지 않음
  exception
    when Insufficient_Funds =>
      put_line ("Error: Withdrawal failed as expected due to insufficient funds.");
      put_line ("Balance remains unchanged: " & Natural'image (Current_Balance));
  end;
end Test_Withdrawal;

실행 결과:

Current balance:  100

Attempting to withdraw 50...
Withdrawal successful. New balance:  50

Attempting to withdraw 120...
Error: Withdrawal failed as expected due to insufficient funds.
Balance remains unchanged:  50

두 번째 출금 시도에서 Withdraw 프로시저 내의 raise 문이 실행되자마자, exception 블록의 when Insufficient_Funds 핸들러로 제어가 즉시 이동했음을 확인할 수 있습니다.

이처럼 begin-exception-end 구조는 오류가 발생할 수 있는 코드를 안전하게 실행하고, 문제 발생 시 정해진 복구 절차를 수행하도록 보장하는 Ada 예외 처리의 근간입니다.

10.2.5 사전 정의된 언어 예외 (Predefined Language Exceptions)

우리가 직접 예외를 선언하지 않더라도, Ada 언어 자체에는 미리 정의된 표준 예외들이 있습니다. 이 예외들은 언어의 핵심 규칙이 런타임에 위반되었을 때 런타임 시스템에 의해 자동으로 발생합니다. 이들은 모두 표준 라이브러리 패키지 Standard에 선언되어 있어 어디서든 사용할 수 있습니다.

이러한 사전 정의된 예외를 이해하는 것은 디버깅과 견고한 프로그램 작성에 필수적입니다. 가장 흔하게 마주치는 예외들은 다음과 같습니다.

Constraint_Error

가장 빈번하게 발생하는 예외입니다. 어떤 값이 해당 타입이나 서브타입에 대해 정의된 제약 조건(constraint)을 위반할 때 발생합니다.

  • 주요 발생 원인:

    • 배열의 인덱스 범위를 벗어난 접근
    • range로 지정된 범위를 벗어나는 값을 변수에 대입
    • 0으로 나누기
    • 숫자 오버플로우 또는 언더플로우
  • 예시:

    procedure Constraint_Error_Example is
      subtype Day_Of_Month is Integer range 1..31;
      Today : Day_Of_Month;
    begin
      Today := 32; -- 제약 조건 위반! Constraint_Error 발생
    exception
      when Constraint_Error =>
        Ada.Text_IO.put_line ("Error: Invalid day of the month.");
    end Constraint_Error_Example;
    

Program_Error

보통 프로그램의 구조나 실행 순서에 더 심각한 논리적 결함이 있을 때 발생합니다. “올바르게 작성되었다면 일어나지 말았어야 할” 상황을 나타냅니다.

  • 주요 발생 원인:
    • 본체(body)가 아직 실행(elaborate)되지 않은 서브프로그램을 호출
    • 함수(function)가 return 문 없이 종료
    • 이미 종료된 태스크의 엔트리를 호출하려는 시도
    • 접근성 검사(accessibility check) 위반

Program_Error가 발생했다면, 단순한 데이터 문제를 넘어 프로그램의 설계 자체를 재검토해야 할 가능성이 높습니다.

Storage_Error

프로그램이 메모리를 더 이상 할당할 수 없을 때 발생합니다.

  • 주요 발생 원인:
    • new 키워드를 사용한 동적 할당 시 힙(heap) 공간 부족
    • 서브프로그램의 재귀 호출이 너무 깊어져 스택(stack) 공간 소진

특히 메모리가 제한적인 임베디드 시스템에서 이 예외는 시스템의 치명적인 오류를 의미할 수 있습니다.

Tasking_Error

동시성(concurrency) 프로그래밍에서 태스크 간의 통신이나 상호작용 중에 문제가 발생했을 때 발생합니다.

  • 주요 발생 원인:
    • 태스크 활성화(activation) 중 오류 발생
    • 종료된 태스크와 통신(랑데부) 시도

이 예외는 동시성 프로그래밍을 다루는 후반부에서 더 자세히 학습할 것입니다.

그 외 주요 예외

위 예외들 외에도 Ada.IO_Exceptions 패키지에 정의된 Name_Error, Use_Error, Data_Error 등 특정 라이브러리에서 정의한 예외들도 표준적으로 사용됩니다.

이처럼 사전 정의된 예외들은 언어의 안전장치 역할을 합니다. 이 예외들이 왜 발생하는지 이해하고 적절히 처리함으로써, 우리는 프로그램의 실행 흐름에 대한 제어권을 잃지 않고 안정적으로 오류에 대처할 수 있습니다.

10.3 구조적 예외 처리

앞선 9.2절에서 우리는 예외를 선언하고, raise 문으로 발생시키며, exception 블록으로 처리하는 기본적인 ‘문법’을 익혔습니다. 이제 한 걸음 더 나아가, 예외가 프로그램의 ‘구조’ 속에서 어떻게 동작하는지 살펴보겠습니다.

Ada의 예외 처리가 ‘구조적’이라고 불리는 이유는, 예외가 단일 블록에 갇혀있지 않고 프로그램의 호출 스택을 따라 체계적으로 전파(propagate)되는 규칙을 가지고 있기 때문입니다. 이번 절의 핵심 질문은 이것입니다: “만약 특정 예외에 대한 핸들러가 없는 곳에서 예외가 발생하면, 그 예외는 어떻게 되는가?”

이 질문에 답하기 위해, 우리는 예외가 중첩된 블록과 서브프로그램 호출을 거슬러 올라가는 예외 전파의 원리를 배우고, 특정 예외를 선택적으로 처리하는 방법, 그리고 처리한 예외를 의도적으로 다시 상위로 전달하는 재발생(re-raising) 기법 등을 학습할 것입니다.

이 절을 통해 여러분은 단일 블록에서의 예외 처리를 넘어, 프로그램 전체에 걸친 다층적인 오류 처리 전략을 설계할 수 있는 시야를 갖게 될 것입니다.

10.3.1 begin-end 블록과 예외 핸들러

Ada에서 예외 처리의 가장 기본적인 단위는 beginend로 둘러싸인 블록입니다. declare를 포함하는 선언 블록, 서브프로그램 본체, 패키지 본체 등 begin 키워드를 사용하는 모든 곳에는 예외를 처리하기 위한 exception 핸들러 섹션을 둘 수 있습니다.

블록의 구조

예외 핸들러를 포함하는 블록의 전체적인 구조는 다음과 같습니다.

[declare
   -- 선언부 (Declarative Part)]
begin
   -- 실행부 (Sequence of Statements)
   -- 이 영역이 예외로부터 '보호되는 영역'입니다.

exception
   -- 예외 핸들러 (Exception Handlers)
   -- begin 블록에서 발생한 예외를 처리하는 부분입니다.
end;
  • 보호 영역 (Protected Area): beginexception 키워드 사이에 있는 코드 영역을 의미합니다. exception 블록에 있는 핸들러는 오직 이 보호 영역 내에서 발생한 예외만을 처리할 수 있습니다.
  • 예외 핸들러 (Exception Handlers): exception 키워드 아래에 when ... => 구문으로 정의된 코드 조각들입니다. 예외가 발생했을 때 실행되는 일종의 작은 비상 대응 절차라고 할 수 있습니다.

실행 흐름과 제어권 이동

예외가 발생했을 때, 프로그램의 제어권이 어떻게 이동하는지 이해하는 것이 매우 중요합니다.

  1. 보호 영역에서 예외가 발생하면, 해당 영역의 실행은 즉시 중단됩니다.
  2. 런타임 시스템은 같은 블록exception 섹션에서 일치하는 핸들러를 찾습니다.
  3. 핸들러를 찾아 실행하면, 예외는 처리된 것(handled)으로 간주됩니다.
  4. 핸들러 실행이 끝나면, 제어권은 해당 begin-end 블록 전체가 끝난 바로 다음 문장으로 이동합니다.

다음은 중첩된 블록을 통해 이러한 제어 흐름을 명확히 보여주는 예제입니다.

with Ada.Text_IO;
use  Ada.Text_IO;

procedure Block_Control_Flow is
   Test_Error : exception;
begin
   put_line ("(1) 바깥 블록 진입");

   -- 안쪽 블록 시작
   begin
      put_line ("(2) 안쪽 블록 진입");
      raise Test_Error;
      put_line ("(3) 이 문장은 절대 실행되지 않음");
   exception
      when Test_Error =>
         put_line ("(4) '안쪽 블록'의 핸들러가 예외를 처리함");
   end;
   -- 안쪽 블록 끝

   put_line ("(5) 안쪽 블록 처리 후 실행 재개");

exception
   when Test_Error =>
      put_line ("(6) '바깥 블록'의 핸들러는 실행되지 않음");
end Block_Control_Flow;

실행 결과:

(1) 바깥 블록 진입
(2) 안쪽 블록 진입
(4) '안쪽 블록'의 핸들러가 예외를 처리함
(5) 안쪽 블록 처리 후 실행 재개

분석:

raise Test_Error가 실행되자마자 안쪽 블록의 정상 흐름은 중단되고(3번 문장 건너뜀), 즉시 같은 블록 내의 예외 핸들러를 찾습니다. 안쪽 블록에 when Test_Error 핸들러가 있으므로 4번 문장이 실행됩니다.

중요한 점은, 안쪽 블록이 예외를 성공적으로 처리했기 때문에 예외는 “소멸”됩니다. 따라서 바깥 블록의 예외 핸들러(6번 문장)는 호출될 일이 없습니다. 안쪽 블록의 실행이 모두 끝나자, 프로그램은 정상적으로 다음 문장인 5번 문장을 실행합니다.

이처럼 begin-exception-end 구조는 예외 처리를 위한 독립적인 유효 범위를 형성합니다. 그렇다면 만약 안쪽 블록에 일치하는 핸들러가 없다면 어떻게 될까요? 이 경우 예외는 전파(propagation)되기 시작하며, 다음 절에서 이 주제를 자세히 다루겠습니다.

10.3.2 특정 예외 처리

하나의 exception 블록 안에는 여러 개의 when 절을 두어, 다양한 종류의 예외에 대해 각기 다른 대응을 하도록 만들 수 있습니다. 이는 “파일을 찾을 수 없는 오류”와 “파일의 데이터 형식이 잘못된 오류”를 구분하여, 상황에 맞는 최적의 복구 로직을 수행하게 해주는 핵심적인 기능입니다.

when 절과 실행 순서

exception 블록에 여러 핸들러가 있을 경우, 런타임 시스템은 when 절을 코드에 명시된 순서대로 확인하여 발생한 예외와 일치하는 첫 번째 핸들러를 실행합니다. 일단 하나의 핸들러가 실행되면, 그 뒤에 있는 다른 핸들러들은 더 이상 확인하지 않고 exception 블록 전체의 실행을 마칩니다.

다음은 설정 파일에서 값을 읽어오는 간단한 예제입니다. 이 과정에서 발생할 수 있는 여러 종류의 예외를 각각 다르게 처리합니다.

with Ada.Text_IO;
with Ada.Integer_Text_IO;

procedure Read_Configuration is
   subtype Config_Value_Range is Integer range 0..100;

   File  : Ada.Text_IO.File_Type;
   Value : Config_Value_Range;
begin
   -- 1. 파일을 연다 (Name_Error 발생 가능)
   Ada.Text_IO.Open (File => File, Mode => Ada.Text_IO.In_File, Name => "config.dat");

   declare
      Input : Integer;
   begin
      -- 2. 파일에서 정수를 읽는다 (Data_Error 발생 가능)
      Ada.Integer_Text_IO.Get (File => File, Item => Input);

      -- 3. 읽은 값이 유효 범위에 있는지 확인한다 (Constraint_Error 발생 가능)
      Value := Input;
   end;

   Ada.Text_IO.Close (File);
   Ada.Text_IO.put_line ("Success: Value read is " & Value'image);

exception
   when Ada.Text_IO.Name_Error =>
      -- 파일이 없을 때의 복구 전략
      Value := 0; -- 기본값 사용
      Ada.Text_IO.put_line ("Info: config.dat not found. Using default value:" & Value'image);

   when Ada.Text_IO.Data_Error =>
      -- 파일 내용은 있으나, 정수가 아닐 때의 복구 전략
      Value := 10; -- 기본값 사용
      Ada.Text_IO.put_line ("Warning: Invalid data in config.dat. Using default value:" & Value'image);

   when Constraint_Error =>
      -- 파일의 숫자가 허용 범위를 벗어났을 때의 복구 전략
      Value := 100; -- 최대값 사용
      Ada.Text_IO.put_line ("Warning: Value out of range. Using max value:" & Value'image);

end Read_Configuration;

분석

위 코드는 발생할 수 있는 세 가지 다른 예외(Name_Error, Data_Error, Constraint_Error)에 대해 각각 다른 기본값을 설정하는 맞춤형 복구 전략을 수행합니다. 이처럼 오류의 원인에 따라 다르게 대응하는 능력은 프로그램의 지능과 견고함을 크게 향상시킵니다.

여러 예외를 한 번에 처리하기

만약 여러 다른 종류의 예외에 대해 동일한 처리를 하고 싶다면, | (수직 막대) 기호를 사용하여 하나의 when 절에 묶을 수 있습니다. 이는 코드의 중복을 줄여줍니다.

exception
   when Ada.Text_IO.Name_Error | Ada.Text_IO.Use_Error =>
      -- 파일이 없거나, 접근 권한이 없는 두 경우 모두 동일하게 처리
      Ada.Text_IO.put_line ("Fatal: Cannot access config file.");
      -- ... 프로그램 종료 절차 수행 ...

특정 예외를 명시적으로 처리하는 것은 오류의 성격을 명확히 구분하고, 각 상황에 가장 적절한 조치를 취하게 함으로써 소프트웨어의 신뢰도를 높이는 가장 기본적인 단계입니다.

10.3.3 when others 선택지

지금까지 특정 예외를 개별적으로 처리하는 방법을 배웠습니다. 하지만 만약 우리가 예상하지 못한, 혹은 일일이 처리하기 번거로운 다른 모든 예외를 한 번에 처리하고 싶다면 어떻게 해야 할까요? 이때 사용되는 것이 바로 when others 선택지입니다.

when others는 이름 그대로, 해당 exception 블록 내의 다른 when 절에서 처리되지 않은 다른 모든 종류의 예외를 처리하는 ‘만능’ 핸들러입니다.

구문과 규칙

when othersexception 블록의 가장 마지막에 위치해야 합니다. 그 뒤에는 다른 when 절이 올 수 없습니다. 만약 when others 뒤에 다른 핸들러가 온다면, 그 코드는 절대 도달할 수 없으므로 컴파일러가 오류로 처리합니다.

exception
   when Specific_Error_1 =>
      -- ...
   when Specific_Error_2 =>
      -- ...
   when others => -- 반드시 가장 마지막에 위치해야 함
      -- 위에서 명시된 두 예외를 제외한 모든 예외를 처리
end;

when others의 올바른 사용과 오용

when others는 프로그램의 견고함을 위한 최종 방어선이 될 수 있지만, 잘못 사용하면 오히려 문제의 원인을 숨겨버리는 독이 될 수 있습니다.

올바른 사용 사례

  1. 최상위 레벨에서의 최종 처리: 프로그램의 메인 프로시저나 태스크의 주 루프와 같이, 더 이상 예외를 전파할 곳이 없는 최상위 레벨에서 사용됩니다. 여기서의 역할은 예상치 못한 오류로 인해 프로그램이 아무런 흔적도 없이 비정상 종료되는 것을 막고, 오류를 로그로 기록한 뒤 프로그램을 안전하게 종료시키는 것입니다.

  2. 자원 해제 보장 및 재발생: 특정 자원(예: 파일, 잠금(lock), 메모리)을 사용한 뒤 반드시 해제해야 하는 경우에 유용합니다. 어떤 예외가 발생하더라도 when others 핸들러에서 자원을 우선 해제한 뒤, raise; 문을 사용해 예외를 다시 상위로 전파시켜 오류가 발생했음을 알립니다.

    procedure Safe_Operation is
       -- ...
    begin
       Acquire_Resource; -- 자원 획득
       -- ... 위험한 작업 수행 ...
       Release_Resource; -- 정상적인 자원 해제
    exception
       when others =>
          Release_Resource; -- 1. 어떤 오류가 발생해도 자원은 반드시 해제한다.
          raise;            -- 2. 오류 자체는 숨기지 않고 상위로 다시 알린다.
    end Safe_Operation;
    

잘못된 사용 사례: 예외 숨기기

가장 위험한 오용은 when others를 사용하여 모든 예외를 조용히 “삼켜버리는” 것입니다.

-- !!! 절대로 이렇게 사용하지 마십시오 !!!
exception
   when others =>
      null; -- 아무것도 하지 않고 예외를 무시함

이는 마치 시끄럽다고 화재경보기를 꺼버리는 것과 같습니다. 당장의 예외는 사라진 것처럼 보이지만, 프로그램은 이미 데이터가 깨지거나 논리적으로 모순된 상태에 빠졌을 가능성이 높습니다. 이 상태로 계속 실행되면, 나중에 훨씬 더 심각하고 찾기 어려운 문제를 야기하게 됩니다.


when others오류를 무시하기 위한 도구가 아니라, 예상치 못한 상황에 대한 제어권을 잃지 않기 위한 도구입니다. 특정 예외들을 처리한 뒤, 마지막 방어선으로서 when others를 사용하되, 그 안에서는 반드시 오류를 기록하고, 자원을 정리하며, 대부분의 경우 예외를 다시 발생시켜 문제 상황을 상위 로직에 알려야 합니다.

10.3.4 예외 전파: 처리되지 않은 예외의 이동 경로

begin-end 블록에서 예외가 발생했지만, 그 블록의 exception 부분에 일치하는 핸들러가 없다면 어떻게 될까요? 예외는 사라지지 않습니다. 대신, 더 적절한 핸들러를 찾아 프로그램의 구조를 따라 바깥쪽으로 이동하기 시작하는데, 이 과정을 예외 전파(Exception Propagation)라고 합니다. ⬆️

예외 전파는 혼란스러운 과정이 아니라, 매우 체계적이고 예측 가능한 규칙을 따릅니다.

전파 메커니즘

처리되지 않은 예외는 현재 블록을 즉시 탈출하여, 자신을 감싸고 있는 바로 바깥쪽의 상위 블록이나 자신을 호출한 호출자(caller)에게로 제어권을 넘깁니다.

이는 마치 여러 개의 상자가 중첩된 것과 같습니다. 가장 안쪽 상자에서 문제가 발생했는데 해결할 수 없다면, 문제를 바로 바깥 상자로 넘깁니다. 바깥 상자도 해결할 수 없다면, 다시 그 바깥 상자로 넘기는 과정이 반복됩니다.

  1. 현재 블록의 실행 종료: 처리되지 않은 예외가 발생한 begin-end 블록은 즉시 실행을 종료합니다.
  2. 상위로 예외 재발생: 예외는 자신을 감싸고 있는 블록이나 자신을 호출한 서브프로그램의 위치에서 다시 발생한 것처럼 취급됩니다.

예제: 서브프로그램 간의 예외 전파

다음 예제는 Inner_Proc에서 발생한 예외가 자신을 호출한 Outer_Proc로 전파되어 처리되는 과정을 보여줍니다.

with Ada.Text_IO;
use  Ada.Text_IO;

procedure Propagation_Example is
   Test_Error : exception;

   procedure Inner_Proc is
   begin
      put_line ("(2) Inner_Proc 실행, 예외 발생!");
      raise Test_Error;
      -- Inner_Proc에는 예외 핸들러가 없으므로, 예외는 호출자로 전파된다.
   end Inner_Proc;

   procedure Outer_Proc is
   begin
      put_line ("(1) Outer_Proc 실행, Inner_Proc 호출.");
      Inner_Proc; -- 이 지점에서 예외가 전파되어 온 것처럼 취급된다.
      put_line ("(X) 이 문장은 절대 실행되지 않음");
   exception
      when Test_Error =>
         put_line ("(3) Outer_Proc의 핸들러가 전파된 예외를 처리함.");
   end Outer_Proc;

begin -- 메인 프로시저
   Outer_Proc;
   put_line ("(4) 메인 프로시저로 복귀, 실행 계속.");
end Propagation_Example;

실행 결과:

(1) Outer_Proc 실행, Inner_Proc 호출.
(2) Inner_Proc 실행, 예외 발생!
(3) Outer_Proc의 핸들러가 전파된 예외를 처리함.
(4) 메인 프로시저로 복귀, 실행 계속.

분석:

Inner_Proc에서 Test_Error가 발생했지만 내부에 핸들러가 없으므로, Inner_Proc는 즉시 종료되고 예외는 Inner_Proc를 호출했던 Outer_ProcInner_Proc; 라인으로 전파됩니다. Outer_Proc에는 Test_Error를 처리할 수 있는 핸들러가 있으므로, 3번 문장이 실행됩니다. Outer_Proc가 예외를 성공적으로 처리했으므로, 프로그램은 정상적으로 메인 프로시저로 복귀하여 4번 문장을 실행합니다.

최종 전파와 프로그램 종료

만약 예외가 호출 스택을 따라 계속 전파되어 프로그램의 가장 최상위인 메인 프로시저에 도달했는데도 처리되지 못하면, 프로그램은 최종적으로 비정상 종료됩니다. 이 경우 Ada 런타임 환경은 일반적으로 처리되지 않은 예외의 이름과 발생 위치 등의 정보를 출력하여 디버깅을 돕습니다.


예외 전파는 오류를 그것이 발생한 저수준의 위치에서만 처리하도록 강제하는 대신, 프로그램의 구조 내에서 가장 적절한 수준(level)에서 오류를 처리할 수 있도록 유연성을 제공합니다. 이를 통해 우리는 세부적인 복구는 안쪽 블록에서, 포괄적인 정책 결정은 바깥쪽 블록에서 처리하는 등 다층적인 오류 처리 전략을 설계할 수 있습니다.

10.3.5 예외 다시 발생시키기 (Re-raising)

예외를 처리하다 보면, 현재 exception 핸들러에서 일부 정리 작업을 수행하되, 오류 상황 자체는 상위 호출자에게도 알려서 추가적인 조치를 취하게 하고 싶을 때가 있습니다. 예를 들어, 오류를 파일에 기록(log)은 하되, 전체 트랜잭션을 롤백하는 책임은 상위 모듈에 위임하는 경우입니다.

이때 사용하는 것이 예외를 다시 발생시키는(re-raising) 기법입니다.

raise;

exception 핸들러 내에서, 예외 이름을 명시하지 않은 raise; 문을 사용하면 현재 핸들러가 처리 중인 바로 그 예외를 다시 발생시킬 수 있습니다.

exception
   when Some_Error =>
      -- 1. 현재 레벨에서 수행할 작업을 처리한다.
      Log_Error ("An error occurred.");
      Clean_Up_Local_Resources;

      -- 2. 동일한 예외를 상위로 전파하여 알린다.
      raise;
end;

이는 raise Some_Error; 와는 다릅니다. 후자는 새로운 예외를 만드는 반면, raise;는 기존 예외의 정보(예: 발생 위치)를 그대로 보존한 채 전파시킵니다.

주요 사용 사례: 자원 정리와 책임 위임

예외 재발생은 관심사를 분리하는 매우 중요한 설계 패턴입니다.

  • 저수준 핸들러: 자신이 획득한 자원(잠금(lock), 파일 핸들, 메모리 등)을 해제하는 책임만 집니다. 이 레벨에서는 전체 비즈니스 로직을 복구하는 방법을 알지 못합니다.
  • 고수준 핸들러: 저수준에서 전파된 예외를 받아, 사용자에게 오류를 알리거나, 전체 작업을 취소하거나, 다른 복구 전략을 실행하는 등 포괄적인 정책을 결정합니다.

다음 예제는 Do_Critical_Work 프로시저가 어떤 종류의 예외가 발생하든 상관없이 자신이 획득한 Lock을 반드시 해제하고, 예외 자체는 호출자에게 넘겨 처리하도록 위임하는 과정을 보여줍니다.

with Ada.Text_IO; use Ada.Text_IO;

procedure Reraise_Example is
   -- 가상적인 잠금(Lock) 타입과 관련 연산
   type Lock is limited private;
   procedure Acquire (L : in out Lock) is begin put_line ("Lock Acquired."); end;
   procedure Release (L : in out Lock) is begin put_line ("Lock Released."); end;

   Database_Error : exception;

   procedure Do_Critical_Work (L : in out Lock) is
   begin
      Acquire (L);
      put_line ("(1) 중요한 작업 수행 중...");

      raise Database_Error; -- 작업 중 데이터베이스 오류 발생 시뮬레이션

      Release (L); -- 이 문장은 실행되지 않음
   exception
      when others => -- 어떤 예외가 발생하든
         put_line ("(2) Do_Critical_Work 핸들러: 로컬 자원 정리");
         Release (L); -- 1. 잠금을 반드시 해제한다.
         put_line ("(3) 예외를 다시 발생시켜 호출자에게 알림");
         raise; -- 2. 예외를 상위로 전파한다.
   end Do_Critical_Work;

begin -- 메인 프로시저
   declare
      My_Lock : Lock;
   begin
      put_line ("작업 시작...");
      Do_Critical_Work (My_Lock);
   exception
      when Database_Error =>
         put_line ("(4) 메인 핸들러: Database_Error를 최종 처리함");
         put_line ("   -> 사용자에게 오류 메시지를 보여주는 등 정책 결정");
   end;
end Reraise_Example;

실행 결과:

작업 시작...
Lock Acquired.
(1) 중요한 작업 수행 중...
(2) Do_Critical_Work 핸들러: 로컬 자원 정리
Lock Released.
(3) 예외를 다시 발생시켜 호출자에게 알림
(4) 메인 핸들러: Database_Error를 최종 처리함
   -> 사용자에게 오류 메시지를 보여주는 등 정책 결정

분석:

Do_Critical_Workwhen others 핸들러는 자신이 획득한 Lock을 해제하는 책임만 다했습니다. raise;를 통해 예외를 다시 전파함으로써, 메인 프로시저의 핸들러는 Database_Error가 발생했다는 사실을 인지하고 그에 맞는 상위 수준의 대응을 할 수 있었습니다.

이처럼 예외를 다시 발생시키는 기법은, 각 모듈이 자신의 책임만 다하도록 하여 코드의 역할을 명확히 분리하고, 예외를 무시하지 않으면서도 안전하게 자원을 관리할 수 있게 해주는 핵심적인 패턴입니다.

10.4 서브프로그램 및 태스크에서의 예외

앞선 9.3절에서는 예외가 처리되지 않았을 때 중첩된 블록을 따라 어떻게 전파되는지 학습했습니다. 이제 우리는 이 개념을 실제 프로그램의 기본 구성 단위인 서브프로그램(subprogram)과 Ada의 강력한 기능인 태스크(task)로 확장하고자 합니다.

서브프로그램 호출 경계를 넘어갈 때 예외는 어떻게 동작할까요? 더 나아가, 독립적으로 실행되는 태스크에서 발생한 예외는 일반적인 서브프로그램처럼 호출자에게 전파될 수 있을까요? 만약 아니라면, 시스템의 다른 부분은 태스크의 실패를 어떻게 감지할 수 있을까요?

이번 절에서는 이러한 질문에 답하기 위해, 서브프로그램 호출 시의 예외 전파 규칙을 명확히 하고, 태스크라는 동시성 환경에서의 예외 처리, 태스크 종료와의 관계, 그리고 태스크 간의 오류 통신 방법 등을 자세히 살펴볼 것입니다.

이 절을 통해 여러분은 프로그램의 구조적, 동시적 경계를 넘나드는 예외를 안정적으로 관리하는 방법을 익혀, 모듈화되고 신뢰성 높은 대규모 애플리케이션을 구축하는 데 필요한 핵심 역량을 갖추게 될 것입니다.

10.4.1 서브프로그램 호출 중 발생하는 예외

서브프로그램(프로시저 또는 함수) 내부에서 발생한 예외가 처리되지 않을 때, 예외는 중첩된 begin-end 블록에서와 동일한 전파(propagation) 규칙을 따릅니다. 즉, 예외는 서브프로그램의 실행을 즉시 중단시키고, 해당 서브프로그램을 호출한 호출자(caller)에게로 전파됩니다.

전파 메커니즘

  1. 피호출자(callee, 호출된 서브프로그램) 내부에서 예외가 발생합니다.
  2. 피호출자 내부에 해당 예외를 처리할 핸들러가 없다면, 피호출자의 실행은 그 즉시 완전히 종료됩니다.
  3. 예외는 피호출자를 호출했던 바로 그 문장으로 되돌아와 다시 발생한 것처럼 취급됩니다.
  4. 이제 호출자의 begin-end 블록이 해당 예외를 처리할 책임이 있습니다. 만약 호출자에도 핸들러가 없다면, 예외는 다시 호출자의 호출자로 전파를 계속합니다.

예제: 함수 호출과 예외 처리

다음 예제는 0으로 나누기를 시도하는 Safe_Divide 함수에서 발생한 예외를 호출자가 처리하는 과정을 보여줍니다.

with Ada.Text_IO; use Ada.Text_IO;

procedure Subprogram_Exception_Example is
   -- 이 시나리오를 위한 사용자 정의 예외
   Division_By_Zero : exception;

   function Safe_Divide (Numerator   : Integer;
                         Denominator : Integer) return Float is
   begin
      if Denominator = 0 then
         raise Division_By_Zero; -- 입력값 검증 실패 시 예외 발생
      end if;
      return Float (Numerator) / Float (Denominator);
      -- Safe_Divide 함수 내부에는 예외 핸들러가 없다.
   end Safe_Divide;

begin -- 메인 프로시저 (호출자)
   declare
      Result : Float;
   begin
      put_line ("10.0 / 2.0 계산 시도...");
      Result := Safe_Divide (10, 2);
      put_line ("-> 결과: " & Float'image (Result));
      New_Line;

      put_line ("5.0 / 0.0 계산 시도...");
      -- 예외 발생 가능성이 있는 호출을 begin-end 블록으로 보호
      begin
         Result := Safe_Divide (5, 0); -- 예외가 이 지점으로 전파된다.
         put_line ("이 문장은 실행되지 않습니다.");
      exception
         when Division_By_Zero =>
            put_line ("-> Safe_Divide 함수로부터 Division_By_Zero 예외를 처리했습니다.");
            put_line ("   0으로 나눌 수 없습니다.");
      end;
   end;
end Subprogram_Exception_Example;

실행 결과:

10.0 / 2.0 계산 시도...
-> 결과:  5.00000E+00

5.0 / 0.0 계산 시도...
-> Safe_Divide 함수로부터 Division_By_Zero 예외를 처리했습니다.
   0으로 나눌 수 없습니다.

분석

두 번째 Safe_Divide (5, 0) 호출에서, 함수 내의 if 조건이 참이 되어 Division_By_Zero 예외가 발생합니다. Safe_Divide 함수는 핸들러가 없으므로 즉시 종료되고, 예외는 이 함수를 호출했던 Result := Safe_Divide (5, 0); 라인으로 전파됩니다. 호출자의 begin-end 블록이 이 예외를 성공적으로 처리하여 프로그램이 비정상적으로 종료되는 것을 막았습니다.

중요한 점은 함수가 예외를 전파하며 종료될 때는 어떠한 값도 반환하지 않는다는 것입니다. 위의 예에서 예외가 발생했을 때 Result 변수에 어떤 값도 대입되지 않았습니다.


예외는 서브프로그램의 인터페이스(API)를 구성하는 중요한 일부입니다. 서브프로그램을 작성할 때는 어떤 상황에 어떤 예외를 발생시킬 수 있는지 명확히 해야 하며, 다른 서브프로그램을 호출할 때는 해당 서브프로그램이 전파할 수 있는 예외를 처리할 준비를 해야 합니다.

이러한 구조적 전파 메커니즘을 통해, 오류를 감지하는 책임(피호출자)과 오류를 처리하는 정책을 결정하는 책임(호출자)을 명확하게 분리할 수 있습니다.

10.4.2 태스크 내에서의 예외 처리

태스크(Task)는 독립적인 실행 흐름을 가지는 동시성(concurrent) 단위이기 때문에, 예외 처리 방식에 있어 서브프로그램과는 다른 매우 중요한 특징을 가집니다.

가장 중요한 규칙: 예외는 전파되지 않는다

서브프로그램과 달리, 태스크 내에서 처리되지 않은 예외는 그 태스크를 생성하거나 선언한 부모 단위(parent unit)로 전파되지 않습니다.

그 이유는 간단합니다. 태스크는 호출-응답 관계가 아닌, 병렬적으로 독립적으로 실행되는 관계이기 때문입니다. 부모 단위는 태스크가 종료되기를 기다리며 멈춰있지 않으므로, 예외가 전파되어 돌아갈 “호출 지점”이 개념적으로 존재하지 않습니다.

태스크의 운명: 조용한 종료

태스크 내에서 예외가 발생했지만, 태스크 자신의 exception 블록에서 이를 처리하지 못했다면 어떤 일이 벌어질까요?

해당 태스크는 그 즉시 실행을 멈추고 아무런 외부 알림 없이 조용히 종료(terminate)됩니다. 이 “조용한 죽음”은 동시성 프로그램에서 찾아내기 매우 어려운 버그의 원인이 될 수 있습니다. 시스템의 다른 부분은 중요한 작업을 수행하던 태스크가 사라졌다는 사실조차 모른 채 계속 동작할 수 있기 때문입니다. 👻

다음 예제는 핸들러가 없는 태스크가 예외 발생 후 어떻게 조용히 종료되는지를 보여줍니다.

with Ada.Text_IO;     use Ada.Text_IO;
with Ada.Real_Time; use Ada.Real_Time;

procedure Task_Silent_Termination is

   task Unreliable_Agent is
      -- 이 태스크는 예외 핸들러를 가지고 있지 않다.
   end Unreliable_Agent;

   task body Unreliable_Agent is
      Count : Integer := 0;
   begin
      loop
         delay until Clock + Milliseconds (500);
         Count := Count + 1;
         put_line ("Agent: Working, cycle " & Integer'image (Count));

         if Count = 3 then
            put_line ("Agent: Raising unhandled exception!");
            raise Program_Error; -- 처리되지 않는 예외 발생
         end if;
      end loop;
   end Unreliable_Agent;

begin -- 메인 프로시저
   put_line ("Main: Program started, agent is active.");
   delay until Clock + Seconds (2.0);

   -- 2초 후, 에이전트 태스크는 Count=3에서 예외가 발생하여 이미 종료되었다.
   -- 메인 프로시저는 이 사실을 직접 통지받지 못한다.
   put_line ("Main: Two seconds passed. Agent has stopped responding.");
   put_line ("Main: Program continues, but the agent task is gone.");

end Task_Silent_Termination;

실행 결과:

Main: Program started, agent is active.
Agent: Working, cycle  1
Agent: Working, cycle  2
Agent: Working, cycle  3
Agent: Raising unhandled exception!
Main: Two seconds passed. Agent has stopped responding.
Main: Program continues, but the agent task is gone.

Unreliable_Agent 태스크는 예외 발생 후 아무런 추가 출력 없이 사라졌고, 메인 프로시-저는 계속 실행됨을 볼 수 있습니다.

결론: 태스크의 자기 책임

이러한 특성 때문에, 견고한 동시성 프로그램을 작성하기 위해서는 모든 태스크가 자기 자신의 예외를 책임져야 합니다. 오랜 시간 동작하는 태스크라면, 반드시 loop 전체를 감싸는 최상위 begin-end 블록과 when others를 포함한 예외 핸들러를 두어, 어떤 예외가 발생하더라도 스스로 복구하거나, 최소한 자신의 상태를 외부에 알리고 안전하게 종료되도록 설계해야 합니다.

10.4.3 예외와 태스크 종료

앞 절에서 우리는 처리되지 않은 예외가 태스크를 조용히 종료시킨다는 것을 확인했습니다. 이번 절에서는 이 종료(termination)의 의미를 좀 더 깊이 살펴보고, 예외가 태스크의 생명주기(lifecycle)에 어떤 영향을 미치는지 알아보겠습니다.

태스크 종료의 두 가지 경로

태스크의 실행이 끝나는 것, 즉 ‘종료’에는 두 가지 경로가 있습니다.

  1. 정상 종료 (Normal Termination): 태스크의 begin-end 블록에 있는 모든 문장이 성공적으로 실행을 마치고, 태스크의 가장 마지막 end에 도달했을 때 정상적으로 종료됩니다. 무한 loop를 가진 태스크는 일반적으로 외부의 종료 요청이 없는 한 정상적으로 종료되지 않습니다.

  2. 비정상 종료 (Abnormal Termination): 처리되지 않은 예외가 태스크의 가장 바깥쪽 유효 범위를 벗어나 전파될 때 발생합니다. 이는 태스크가 자신의 임무를 완수하지 못하고 실패했음을 의미합니다.

예외로 인한 종료는 태스크가 실패하는 가장 일반적인 시나리오입니다. 일단 예외로 인해 종료 절차가 시작된 태스크는 외부에서 복구하거나 재시작시킬 수 없습니다.

태스크 상태 확인: 'Terminated 속성

다른 태스크가 특정 태스크의 종료 여부를 확인해야 할 때가 있습니다. 예를 들어, ‘관리자’ 태스크가 자신이 생성한 ‘작업자’ 태스크들이 여전히 살아있는지 감시해야 할 수 있습니다. 이때 'Terminated(어포스트로피-Terminated) 속성을 사용할 수 있습니다.

'Terminated 속성은 Boolean 값을 반환하며, 해당 태스크가 종료되었으면 True를, 아직 실행 중이면 False를 반환합니다.

다음 예제는 예외로 인해 종료된 태스크의 'Terminated 속성 값이 어떻게 변하는지 보여줍니다.

with Ada.Text_IO;     use Ada.Text_IO;
with Ada.Real_Time; use Ada.Real_Time;

procedure Task_Termination_Attribute is

   task Doomed_Worker; -- 예외로 인해 종료될 태스크

   task body Doomed_Worker is
   begin
      put_line ("Worker: 실행 시작... 500ms 후 예외 발생 예정.");
      delay until Clock + Milliseconds (500);
      raise Program_Error; -- 처리되지 않는 예외 발생
   end Doomed_Worker;

begin -- 메인 프로시저
   put_line ("Main: 프로그램 시작.");
   put_line ("Main: Worker 태스크가 종료되었는가? " & Boolean'image(Doomed_Worker'Terminated));

   delay until Clock + Seconds (1.0); -- Worker가 실패할 시간을 준다.

   put_line ("Main: 1초 경과.");
   put_line ("Main: Worker 태스크가 종료되었는가? " & Boolean'image(Doomed_Worker'Terminated));

end Task_Termination_Attribute;

실행 결과:

Main: 프로그램 시작.
Worker: 실행 시작... 500ms 후 예외 발생 예정.
Main: Worker 태스크가 종료되었는가? FALSE
Main: 1초 경과.
Main: Worker 태스크가 종료되었는가? TRUE

분석: 프로그램 시작 직후에는 Doomed_Worker 태스크가 아직 실행 중이므로 'Terminated 속성은 False입니다. 하지만 1초가 지난 후에는 태스크 내부에서 발생한 예외가 처리되지 않아 태스크가 종료되었고, 따라서 'Terminated 속성은 True를 반환합니다.


처리되지 않은 예외는 태스크를 종료시키는 직접적인 원인입니다. 'Terminated 속성은 이렇게 “죽은” 태스크를 외부에서 감지할 수 있는 수동적인 방법을 제공합니다.

이러한 특성은 견고한 동시성 시스템을 설계할 때 매우 중요합니다. 오랫동안 실행되어야 하는 태스크는 자신의 생존을 위해 내부적으로 예외를 처리해야만 합니다. 만약 태스크가 실패할 수 있다면, 시스템의 다른 부분은 해당 태스크의 종료를 감지하고 적절한 복구 절차(예: 새로운 작업자 태스크 생성)를 수행할 수 있어야 합니다.

10.4.4 태스크 간 예외 정보 통신

태스크 내의 처리되지 않은 예외는 전파되지 않고 해당 태스크를 조용히 종료시킬 뿐입니다. 'Terminated 속성은 태스크가 실패했다는 사실은 알려주지만, 왜(why) 실패했는지에 대한 정보는 주지 않습니다.

하지만 내결함성 시스템을 구축하려면, 관리자(supervisor) 태스크는 작업자(worker) 태스크가 왜 실패했는지 알아야만 올바른 복구 절차를 수행할 수 있습니다. 예를 들어, ‘네트워크 일시 단절’ 오류는 재시도를, ‘설정 파일 오류’는 안전 모드 진입이라는 다른 대응이 필요하기 때문입니다. 이를 위해서는 태스크 간에 예외 정보를 전달할 명시적인 통신 채널이 필요합니다. 📡

통신 패턴: 보호 객체를 이용한 실패 보고

Ada에서 태스크 간의 안전한 데이터 공유는 보호 객체(Protected Object)를 통해 이루어집니다. 이 보호 객체를 일종의 “실패 보고 우체통”으로 사용하여, 실패한 태스크가 자신의 예외 정보를 남기고 종료되도록 설계할 수 있습니다.

설계:

  1. 실패 보고자 (Failure_Reporter): 보호 객체를 정의합니다. 이 객체는 예외 정보를 저장할 변수와, 예외를 기록하는 프로시저(Set_Failure), 그리고 기록된 예외 정보를 가져가는 엔트리(Get_Failure)를 가집니다.
  2. 작업자 태스크 (Worker Task): 자신의 모든 로직을 begin-exception 블록으로 감쌉니다. when others 핸들러 안에서, 종료되기 직전에 Failure_Reporter.Set_Failure를 호출하여 자신의 예외 정보를 “우체통”에 넣습니다.
  3. 관리자 태스크 (Supervisor Task): Failure_Reporter.Get_Failure 엔트리를 호출하여 “우체통”에 예외 정보가 들어올 때까지 대기합니다. 정보가 들어오면, 예외 내용을 분석하여 적절한 복구 로직을 수행합니다.

예제: 실패 보고 및 처리

with Ada.Text_IO, Ada.Exceptions, Ada.Real_Time;
use Ada.Text_IO, Ada.Exceptions, Ada.Real_Time;

procedure Task_Error_Communication is
   -- 1. 실패 보고를 위한 보호 객체
   protected Failure_Reporter is
      procedure Report_Failure (E : in Exception_Occurrence);
      entry Wait_For_Failure (E : out Exception_Occurrence);
   private
      Failure : Exception_Occurrence := Null_Occurrence;
      Has_Failed : Boolean := False;
   end Failure_Reporter;

   protected body Failure_Reporter is
      procedure Report_Failure (E : in Exception_Occurrence) is
      begin
         if not Has_Failed then
            Failure := E;
            Has_Failed := True;
         end if;
      end Report_Failure;

      entry Wait_For_Failure (E : out Exception_Occurrence) when Has_Failed is
      begin
         E := Failure;
         Has_Failed := False; -- 다음 실패를 위해 리셋
      end Wait_For_Failure;
   end Failure_Reporter;

   -- 2. 실패할 수 있는 작업자 태스크
   task Worker;
   task body Worker is
   begin
      delay until Clock + Milliseconds(500);
      raise Program_Error with "Sensor hardware fault"; -- 예외 발생
   exception
      when E : others =>
         -- 3. 종료 직전, 자신의 예외 정보를 보고한다.
         put_line ("Worker: 예외 발생! 관리자에게 보고합니다.");
         Failure_Reporter.Report_Failure (E);
         -- 이 핸들러가 끝나면 태스크는 종료된다.
   end Worker;

   -- 4. 작업자를 감시하는 관리자 태스크
   task Supervisor;
   task body Supervisor is
      Error_Info : Exception_Occurrence;
   begin
      put_line ("Supervisor: 작업자의 실패 보고를 대기합니다...");
      Failure_Reporter.Wait_For_Failure (Error_Info);
      put_line ("Supervisor: 실패 보고를 수신했습니다!");
      put_line ("  -> 원인: " & Exception_Message (Error_Info));
      -- ... 원인에 따른 복구 절차 수행 ...
   end Supervisor;

begin
   null; -- 메인 프로시저는 태스크들이 실행되도록 둔다.
end Task_Error_Communication;

실행 결과:

Supervisor: 작업자의 실패 보고를 대기합니다...
Worker: 예외 발생! 관리자에게 보고합니다.
Supervisor: 실패 보고를 수신했습니다!
  -> 원인: Sensor hardware fault

분석 및 결론

위 예제에서 Worker 태스크는 실패하자마자 Failure_Reporter에 자신의 예외 정보를 기록합니다. Wait_For_Failure 엔트리에서 대기하고 있던 Supervisor 태스크는 이 정보가 기록되자마자 즉시 깨어나서 예외의 상세 내용을 확인할 수 있었습니다.

이는 단순히 'Terminated 속성을 주기적으로 확인하는 수동적인 감시(passive polling)보다 훨씬 효율적이고 즉각적인 능동적인 통지(proactive notification) 방식입니다.

결론적으로, 예외가 태스크 경계를 넘어 자동으로 전파되지 않는 Ada의 특성상, 견고한 동시성 시스템을 구축하려면 보호 객체를 이용한 명시적인 오류 보고 채널을 만드는 것이 필수적입니다. 이 패턴을 통해 시스템은 단순한 오류 감지(fault detection)를 넘어, 지능적인 복구의 기반이 되는 오류 진단(fault diagnosis) 능력을 갖추게 됩니다.

10.5 고급 예외 관리

지금까지 우리는 Ada의 예외 처리 기본 메커니즘을 학습했습니다. 예외를 선언하고, 발생시키고, 처리하며, 그것이 프로그램 구조를 따라 어떻게 전파되는지 이해했습니다. 이 지식만으로도 많은 오류 상황에 대처할 수 있습니다.

하지만 만약 발생한 예외에 대한 더 상세한 정보, 예를 들어 어떤 예외가 발생했는지, 어떤 메시지를 담고 있는지, 혹은 어디서 발생했는지와 같은 정보를 얻고 싶다면 어떻게 해야 할까요?

이러한 고급 요구사항을 위해 Ada는 Ada.Exceptions라는 표준 라이브러리 패키지를 제공합니다. 이 패키지는 예외 ‘발생 정보(occurrence)’ 자체를 데이터 객체처럼 다룰 수 있는 타입과 서브프로그램들을 제공하여, 예외를 훨씬 더 정교하게 제어할 수 있게 해줍니다.

이번 9.5절에서는 Ada.Exceptions 패키지를 중심으로 다음의 고급 기법들을 탐구할 것입니다.

  • Exception_Occurrence 타입을 이용해 예외 발생 정보 얻기
  • Raise_Exception 프로시저를 이용해 예외에 사용자 정의 메시지 첨부하기
  • 제네릭 유닛(Generic Units)에서의 예외 처리 시 고려사항

이 절을 학습하고 나면, 여러분은 예외를 단순한 제어 흐름의 신호로만 보는 것을 넘어, 디버깅, 로깅, 오류 보고 시스템에 활용할 수 있는 풍부한 정보를 담은 데이터 객체로 다룰 수 있게 될 것입니다. 이는 대규모 시스템의 유지보수성을 한 차원 높여주는 핵심 기술입니다.

10.5.1 Ada.Exceptions 패키지 활용

지금까지 우리는 when 절에 예외의 이름을 명시하여 특정 예외를 처리했습니다. 하지만 때로는 어떤 종류의 예외가 발생했는지 동적으로 확인하거나, 발생한 예외에 대한 상세 정보를 얻어 로그로 남기고 싶을 때가 있습니다.

이러한 고급 기능을 위해 Ada 표준 라이브러리는 Ada.Exceptions 패키지를 제공합니다. 이 패키지의 핵심은 예외의 ‘발생’ 자체를 하나의 데이터 덩어리로 다룰 수 있게 해주는 Exception_Occurrence 타입입니다.

Exception_Occurrence 타입: 예외 정보의 스냅샷

Exception_Occurrence는 특정 예외가 발생한 순간의 모든 정보를 담고 있는 일종의 스냅샷입니다. 이 타입의 변수에는 어떤 예외가, 어떤 메시지를 가지고, 어느 코드 위치에서 발생했는지가 기록됩니다.

예외 발생 정보 캡처하기

when 절의 구문을 확장하면, 발생한 예외의 Exception_Occurrence를 상수(constant)에 담을 수 있습니다. when others와 함께 사용하는 것이 일반적입니다.

exception
   when E : others =>
      -- E는 방금 발생한 예외의 모든 정보를 담고 있는
      -- Exception_Occurrence 타입의 상수가 된다.
      -- 이제 E를 가지고 여러 가지 작업을 할 수 있다.
end;

Exception_Occurrence 정보 활용하기

일단 E에 예외 발생 정보를 담았다면, Ada.Exceptions 패키지가 제공하는 함수들을 통해 상세 내용을 추출할 수 있습니다.

  • Exception_Name(E): 예외의 전체 이름을 String으로 반환합니다. (예: My_Package.Invalid_Input_Error)
  • Exception_Message(E): 예외 발생 시 함께 전달된 메시지를 String으로 반환합니다. (메시지 첨부는 다음 절에서 배웁니다.)
  • Exception_Information(E): 예외 이름, 메시지, 그리고 예외가 발생한 소스 코드 위치를 알려주는 스택 트레이스(stack trace)를 포함한 상세 정보를 String으로 반환합니다. 디버깅 로그를 작성할 때 가장 유용합니다.

예제: 범용 예외 로거(Logger) 작성

다음은 when E : othersAda.Exceptions의 함수들을 활용하여, 어떤 예외가 발생하든 상세한 정보를 로그로 남기는 범용 핸들러를 작성하는 예제입니다.

with Ada.Text_IO;      use Ada.Text_IO;
with Ada.Exceptions; use Ada.Exceptions;

procedure Universal_Logger_Example is

   procedure Log_Exception (E : in Exception_Occurrence) is
   begin
      put_line ("--- 예외 발생: 상세 정보 ---");
      put_line ("- 이름   : " & Exception_Name (E));
      put_line ("- 메시지 : " & Exception_Message (E));
      New_Line;
      put_line ("- 스택 트레이스:");
      put_line (Exception_Information (E));
      put_line ("------------------------------");
   end Log_Exception;

   procedure Risky_Code is
      X, Y : Integer := 1, 0;
   begin
      Y := X / Y; -- Constraint_Error 발생
   end Risky_Code;

begin
   Risky_Code;
exception
   when E : others =>
      -- 어떤 예외가 발생하든 E에 담아서 로거에 전달한다.
      Log_Exception (E);
      put_line ("프로그램이 예외를 기록하고 안전하게 계속됩니다.");
end Universal_Logger_Example;

실행 결과 (컴파일러 및 환경에 따라 약간 다를 수 있음):

--- 예외 발생: 상세 정보 ---
- 이름   : CONSTRAINT_ERROR
- 메시지 :

- 스택 트레이스:
Exception name: CONSTRAINT_ERROR
Message: universal_logger_example.adb:26
Call stack:
... (스택 트레이스 정보) ...

------------------------------
프로그램이 예외를 기록하고 안전하게 계속됩니다.

Ada.Exceptions 패키지는 예외를 단순한 제어 흐름 변경의 신호에서, 풍부한 진단 정보를 담고 있는 데이터 객체로 격상시킵니다. when E : others 구문을 통해 어떤 예외든 가로채서 그 내용을 분석하고 기록할 수 있는 능력은, 디버깅이 용이하고 유지보수성이 높은 대규모의 견고한 시스템을 구축하는 데 필수적인 기술입니다.

10.5.2 예외 발생 정보 획득

앞 절에서 when E : others 구문을 통해 예외 발생 정보(Exception_Occurrence)를 캡처하는 방법을 배웠습니다. 이제 이 E라는 객체로부터 구체적인 정보를 “질의(interrogate)”하고 추출하는 표준 함수들을 자세히 살펴보겠습니다. 이 함수들은 모두 Ada.Exceptions 패키지에 정의되어 있습니다.

Ada.Exceptions의 주요 정보 획득 함수

1. Exception_Name

  • 명세: function Exception_Name (X : Exception_Occurrence) return String;
  • 설명: 발생한 예외의 전체 이름을 문자열로 반환합니다. Constraint_Error와 같은 사전 정의된 예외는 대문자 이름(“CONSTRAINT_ERROR”)을, 사용자 정의 예외는 패키지 경로를 포함한 이름(예: “Bank_Account.Insufficient_Funds”)을 반환합니다. 이를 통해 어떤 종류의 오류가 발생했는지 동적으로 확인할 수 있습니다.

2. Exception_Message

  • 명세: function Exception_Message (X : Exception_Occurrence) return String;
  • 설명: 예외가 발생할 때 함께 첨부된 사용자 정의 메시지를 반환합니다. 만약 첨부된 메시지가 없다면 빈 문자열을 반환합니다. (메시지를 첨부하는 방법은 다음 절에서 배웁니다.)

3. Exception_Information

  • 명세: function Exception_Information (X : Exception_Occurrence) return String;
  • 설명: 디버깅에 가장 유용한 함수입니다. 예외 이름, 첨부된 메시지, 그리고 예외가 어디서 발생했는지를 보여주는 호출 스택 트레이스(call stack trace)를 포함한 상세하고 사람이 읽기 좋은 형식의 보고서를 문자열로 반환합니다.

예제: 예외 정보 추출 및 출력

다음은 중첩된 프로시저 호출 중에 예외가 발생했을 때, when E : others로 예외를 잡아 각 정보 추출 함수가 어떤 결과를 반환하는지 보여주는 예제입니다.

with Ada.Text_IO;      use Ada.Text_IO;
with Ada.Exceptions; use Ada.Exceptions;

procedure Interrogate_Exception_Example is
   -- 시나리오를 위한 사용자 정의 예외
   Critical_System_Fault : exception;

   procedure Action_C is
   begin
      put_line ("      -> In Action_C. Raising exception.");
      raise Critical_System_Fault;
   end Action_C;

   procedure Action_B is
   begin
      put_line ("   -> In Action_B. Calling Action_C.");
      Action_C;
   end Action_B;

   procedure Action_A is
   begin
      put_line ("-> In Action_A. Calling Action_B.");
      Action_B;
   end Action_A;

begin
   Action_A;
exception
   when E : others =>
      New_Line;
      put_line ("--- Exception Details ---");
      put_line ("- Exception_Name:");
      put_line ("    " & Exception_Name (E));
      New_Line;
      put_line ("- Exception_Message:");
      put_line ("    '" & Exception_Message (E) & "'");
      New_Line;
      put_line ("- Exception_Information:");
      put_line (Exception_Information (E));
      put_line ("-------------------------");
end Interrogate_Exception_Example;

실행 결과 (컴파일러 및 환경에 따라 형식은 약간 다를 수 있음):

-> In Action_A. Calling Action_B.
   -> In Action_B. Calling Action_C.
      -> In Action_C. Raising exception.

--- Exception Details ---
- Exception_Name:
    INTERROGATE_EXCEPTION_EXAMPLE.CRITICAL_SYSTEM_FAULT

- Exception_Message:
    ''

- Exception_Information:
Exception name: INTERROGATE_EXCEPTION_EXAMPLE.CRITICAL_SYSTEM_FAULT
Message:
Call stack traceback:
...
   at interrogate_exception_example.action_c(interrogate_exception_example.adb:11)
   at interrogate_exception_example.action_b(interrogate_exception_example.adb:16)
   at interrogate_exception_example.action_a(interrogate_exception_example.adb:21)
   at interrogate_exception_example(interrogate_exception_example.adb:25)

-------------------------

분석:

  • Exception_Name은 우리가 선언한 예외의 완전한 이름을 정확히 보여줍니다.
  • Exception_Message는 첨부된 메시지가 없으므로 빈 문자열('')을 반환했습니다.
  • Exception_Information은 예외 이름과 함께, 예외가 Action_C에서 발생하여 Action_BAction_A를 거쳐 메인 프로시저까지 전파된 전체 경로를 보여주어, 오류의 근원을 추적하는 데 결정적인 단서를 제공합니다.

Ada.Exceptions 패키지의 정보 획득 함수들은 추상적인 오류 이벤트를 분석 가능한 구체적인 데이터로 바꾸어주는 강력한 도구입니다. 특히 Exception_Information을 활용하여 상세한 로그를 남기는 것은 복잡한 시스템의 오류를 진단하고 수정하는 데 있어 필수적인 기법입니다.

10.5.3 예외에 메시지 연관시키기

앞 절에서 Exception_Message 함수가 빈 문자열을 반환하는 것을 보았습니다. 이는 우리가 raise My_Error; 와 같은 기본 raise 문을 사용했기 때문입니다. 이 구문은 오류가 발생했다는 ‘사실’만 전달할 뿐, 오류에 대한 구체적인 ‘상황 정보’를 담지는 못합니다.

하지만 Ada에서는 예외를 발생시키는 바로 그 순간에, 동적인 문자열 메시지를 함께 첨부하여 예외를 훨씬 더 유용한 정보로 만들 수 있습니다.

Raise_Exception 프로시저와 'Identity 속성

예외에 메시지를 첨부하는 전통적인 방법은 Ada.Exceptions 패키지의 Raise_Exception 프로시저를 사용하는 것입니다.

  • 명세: procedure Raise_Exception (E : in Exception_Id; Message : in String := "");

이 프로시저는 두 개의 매개변수를 받습니다.

  1. E: 발생시킬 예외의 고유 식별자(Exception_Id)입니다. 특정 예외의 Exception_Id'Identity (어포스트로피-Identity) 속성을 통해 얻을 수 있습니다. (예: My_Error'Identity)
  2. Message: 예외에 첨부할 사용자 정의 문자열입니다.

'Identity 속성으로 예외를 지정하고, 전달할 메시지와 함께 Raise_Exception을 호출하면 됩니다.

raise ... with 구문 (Ada 2005 이상)

최신 Ada에서는 Raise_Exception을 직접 호출하는 것보다 훨씬 간결하고 가독성 높은 구문을 제공합니다. raise 문 뒤에 with 키워드와 함께 문자열을 붙이는 방식입니다. 이 구문이 현재 권장되는 방식입니다.

raise 예외_이름 with "첨부할 메시지 문자열";

예제: 동적 오류 메시지를 담은 예외 발생

다음은 사용자 이름의 유효성을 검사하고, 실패 시 구체적인 이유를 메시지에 담아 예외를 발생시키는 예제입니다.

with Ada.Text_IO;      use Ada.Text_IO;
with Ada.Exceptions; use Ada.Exceptions;

procedure Exception_With_Message is
   Invalid_User_Data : exception;

   procedure Validate_User_Name (Name : in String) is
      Max_Len : constant := 10;
   begin
      if Name'Length = 0 then
         -- 유효성 검사 실패 시, 구체적인 이유를 메시지로 전달
         raise Invalid_User_Data with "사용자 이름은 비어 있을 수 없습니다.";

      elsif Name'Length > Max_Len then
         raise Invalid_User_Data with "이름이 너무 깁니다 (최대 " &
           Max_Len'image & "자, 입력된 이름: " & Name & ").";
      end if;
      put_line ("'" & Name & "' 은(는) 유효한 이름입니다.");
   end Validate_User_Name;

begin
   Validate_User_Name ("John"); -- 성공
   Validate_User_Name ("");       -- 첫 번째 예외 발생

exception
   when E : others =>
      New_Line;
      put_line ("--- 작업 실패 ---");
      put_line ("오류 타입 : " & Exception_Name (E));
      put_line ("상세 내용 : " & Exception_Message (E)); -- 이제 메시지가 출력된다!
      put_line ("-----------------");
end Exception_With_Message;

Validate_User_Name (""); 호출 시의 실행 결과:

'John' 은(는) 유효한 이름입니다.

--- 작업 실패 ---
오류 타입 : EXCEPTION_WITH_MESSAGE.INVALID_USER_DATA
상세 내용 : 사용자 이름은 비어 있을 수 없습니다.
-----------------

만약 Validate_User_Name ("Supercalifragilistic");를 호출했다면, Exception_Message는 “이름이 너무 깁니다 (최대 10자, 입력된 이름: Supercalifragilistic).” 라는 동적인 메시지를 포함하게 될 것입니다.


예외에 메시지를 연관시키는 것은 오류 처리의 수준을 한 차원 높여주는 기법입니다. 이는 단순히 “무엇이” 잘못되었는지(예외 이름)를 넘어, “어떻게” 또는 “왜” 잘못되었는지에 대한 구체적인 상황 정보를 제공합니다.

raise ... with 구문을 사용하여 동적인 컨텍스트를 제공하면, 훨씬 더 유용한 로그를 생성하고, 사용자에게 친절한 오류 메시지를 보여주며, 디버깅 과정을 크게 단축할 수 있습니다.

10.5.4 제네릭 서브프로그램과 예외 처리

제네릭(Generic)은 코드 재사용성을 극대화하는 강력한 기능이지만, 예외 처리와 만났을 때 특별한 고려가 필요합니다. 제네릭 유닛(패키지 또는 서브프로그램)은 인스턴스화될 때 클라이언트로부터 서브프로그램을 매개변수로 전달받는 경우가 많습니다.

핵심적인 질문: 제네릭 유닛을 작성하는 “프레임워크” 개발자는 클라이언트가 제공할 서브프로그램이 어떤 종류의 예외를 발생시킬지 미리 알 수 없습니다. 이 상황에서 어떻게 견고한 에러 핸들링을 구현할 수 있을까요?

문제 상황: 제네릭 For_Each 프로시저

배열의 모든 요소에 대해 클라이언트가 제공한 작업을 수행하는 제네릭 프로시저 For_Each를 생각해 봅시다.

generic
   type Item_Type is private;
   type Index_Type is (<>);
   type Array_Type is array (Index_Type range <>) of Item_Type;
   with procedure Process (Item : in Item_Type);
procedure For_Each (On_Array : in Array_Type);

procedure For_Each (On_Array : in Array_Type) is
begin
   for I in On_Array'range loop
      Process (On_Array (I)); -- 만약 이 Process 프로시저가 예외를 발생시킨다면?
   end loop;
end For_Each;

위 코드에서 클라이언트가 제공한 Process 프로시저가 예외를 발생시킨다면, For_Each 루프는 그 즉시 중단되고 예외는 For_Each를 호출한 쪽으로 전파될 것입니다. For_Each 자체는 아무런 방어 장치가 없습니다.

해결책 1: when others를 이용한 기본 방어

가장 간단한 방법은 제네릭 매개변수 호출부를 begin-exception-end 블록으로 감싸고, when others로 알 수 없는 모든 예외를 잡는 것입니다.

procedure For_Each (On_Array : in Array_Type) is
begin
   for I in On_Array'range loop
      begin
         Process (On_Array (I));
      exception
         when others =>
            Ada.Text_IO.put_line ("Error processing item at index " & Index_Type'image(I));
            -- 여기서 루프를 계속할 것인가, 중단할 것인가?
      end;
   end loop;
end For_Each;

이 방식은 루프 전체가 중단되는 것은 막아주지만, 모든 오류를 동일하게 취급하며 구체적인 복구 전략을 세울 수 없다는 한계가 있습니다.

해결책 2: 예외 전파 패턴 (권장되는 설계)

더 나은 설계는 제네릭 유닛이 예외를 “삼키지” 않고, 자신의 지역적인 정리 작업만 수행한 뒤 예외를 다시 상위로 전파시키는 것입니다. 이는 관심사 분리 원칙에 부합합니다.

  • 제네릭 유닛의 책임: 자신의 상태 일관성을 유지하고, 예외가 발생했다는 사실을 호출자에게 알린다.
  • 클라이언트(호출자)의 책임: 자신이 제공한 Process 프로시저가 발생시킬 수 있는 특정 예외들을 알고 있으므로, 그에 맞는 최종 처리를 한다.
procedure For_Each (On_Array : in Array_Type) is
begin
   for I in On_Array'range loop
      begin
         Process (On_Array (I));
      exception
         when others =>
            -- 1. 제네릭 유닛 수준에서 필요한 정리 작업 수행 (예: 로그 남기기)
            Log_Framework_Error ("Exception occurred in client procedure.");
            -- 2. 원래의 예외를 호출자에게 다시 전파하여 최종 처리를 위임
            raise;
      end;
   end loop;
end For_Each;

이 패턴을 사용하면, For_Each를 인스턴스화하고 호출한 클라이언트는 다음과 같이 자신이 발생시킨 예외를 직접 처리할 수 있습니다.

-- 클라이언트 코드
begin
   My_For_Each (On_Array => ...);
exception
   when My_Specific_Error => -- 클라이언트는 이 예외를 알고 있다!
      -- ...
end;

해결책 3: 예외 핸들러를 매개변수로 받기 (고급 설계)

가장 유연한 방식은, 처리할 작업(Process)뿐만 아니라 오류가 발생했을 때 호출될 오류 핸들러 자체를 제네릭 매개변수로 받는 것입니다.

generic
   -- ...
   with procedure Process (Item : in Item_Type);
   with procedure Handle_Error (E : in Exception_Occurrence);
procedure For_Each (On_Array : in Array_Type);

procedure For_Each (On_Array : in Array_Type) is
begin
   for I in On_Array'range loop
      begin
         Process (On_Array (I));
      exception
         when E : others =>
            Handle_Error (E); -- 클라이언트가 제공한 핸들러 호출
      end;
   end loop;
end For_Each;

이 설계는 클라이언트에게 예외 처리 정책(계속 진행, 중단, 재시도 등)에 대한 완전한 제어권을 부여하지만, 제네릭의 인터페이스가 더 복잡해집니다.


제네릭 코드를 작성할 때는 제네릭 매개변수로 들어온 서브프로그램이 어떤 예외든 발생시킬 수 있다고 가정해야 합니다.

가장 일반적이고 권장되는 패턴은 “정리 후 재발생(cleanup and re-raise)”입니다. 즉, when others를 사용해 알 수 없는 예외를 잡되, 제네릭 유닛의 내부 상태만 안전하게 정리하고 raise;를 통해 예외를 숨기지 않고 호출자에게 전파하는 것입니다. 이를 통해 재사용 가능하면서도 견고한 제네릭 컴포넌트를 만들 수 있습니다.

10.6 모범 사례 및 설계 패턴

지금까지 우리는 Ada 예외 처리의 ‘어떻게(how)’에 해당하는 문법과 메커니즘을 모두 학습했습니다. 이제는 ‘어떻게 잘(how well)’ 사용할 것인가에 대한 지혜, 즉 설계의 영역으로 넘어갈 차례입니다.

예외 처리는 양날의 검과 같습니다. 올바르게 사용하면 프로그램의 견고함을 극적으로 향상시키지만, 남용하거나 잘못된 방식으로 사용하면 오히려 제어 흐름을 이해하기 어렵게 만들고 유지보수를 악몽으로 만들 수 있습니다.

이번 9.6절에서는 단순히 예외를 사용하는 것을 넘어, 예외를 ‘잘’ 사용하기 위한 핵심적인 모범 사례와 검증된 설계 패턴들을 탐구합니다. 다음의 주제들을 다룰 것입니다.

  • 예외를 일반적인 제어 흐름 대신 오류 처리에만 사용하는 기준
  • 관련 오류들을 묶어주는 체계적인 예외 계층 구조 설계
  • 예외 발생 시에도 자원 누수를 막는 Ada.Finalization 활용법
  • 예외 안전성(Exception Safety)의 개념과 이를 보장하는 코드 작성 기법

이 절의 목표는 새로운 문법을 배우는 것이 아니라, 예외 처리에 대한 올바른 ‘설계적 관점’과 ‘판단력’을 기르는 것입니다. 이 원칙들을 이해하고 나면, 여러분은 예외 처리 기능을 효과적으로 활용하여 전문가 수준의 신뢰성과 유지보수성을 갖춘 소프트웨어를 만들 수 있게 될 것입니다.

10.6.1 예외 사용 시점과 지양 시점

예외는 강력한 도구이지만, 그 힘은 올바른 목적으로 사용될 때 발휘됩니다. 가장 중요한 원칙은 이것입니다: “예외는 이름 그대로, 진정으로 예외적인(exceptional) 상황에만 사용해야 한다.”

‘예외적인 상황’이란, 해당 서브프로그램이 자신의 임무나 계약(contract)을 더 이상 완수할 수 없게 만드는 심각한 오류 조건을 의미합니다. 즉, 정상적인 결과 중 하나가 아니라 명백한 실패(failure)를 나타냅니다.

예외를 사용해야 하는 경우

다음과 같은 상황은 예외를 사용하기에 적합합니다.

  1. 계약 위반 (contract violations): 서브프로그램이 명세된 작업을 수행할 수 없을 때.

    • 비어 있는 스택에서 pop 연산을 시도할 때 (Stack_Empty_Error).
    • 음수에 대해 제곱근(sqrt) 계산을 요청받았을 때 (Argument_Error).
    • 서브프로그램의 전제조건(precondition)이 만족되지 않았을 때.
  2. 외부 환경의 실패 (External Failures): 프로그램이 제어할 수 없는 외부 요소가 실패했을 때.

    • 필요한 파일을 찾을 수 없을 때 (Name_Error).
    • 네트워크 연결이 유실되었을 때 (Connection_Lost).
    • 하드웨어 장치가 응답하지 않을 때 (Device_Error).
  3. 자원 고갈 (Resource Exhaustion): 필수적인 자원을 획득할 수 없을 때.

    • 메모리 할당에 실패했을 때 (Storage_Error).
    • 사용 가능한 데이터베이스 커넥션이 없을 때 (Connection_Pool_Empty).

예외 사용을 지양해야 하는 경우

예외를 절대 일반적인 제어 흐름(normal control flow)을 위해 사용해서는 안 됩니다. 이는 대표적인 안티-패턴(anti-pattern)입니다.

  • 상황: 배열에서 특정 원소를 찾는 기능을 구현한다고 가정해 봅시다.

  • 잘못된 설계: 원소를 찾지 못했을 때, Not_Found_Error 예외를 발생시킨다.

    -- 나쁜 예: 예외를 일반 제어 흐름에 사용
    function find (Item : Element; In_Array : My_Array) return Index is
    begin
       for I in In_Array'range loop
          if In_Array (I) = Item then
             return I;
          end if;
       end loop;
       raise Not_Found_Error; -- !!! 잘못된 사용 !!!
    end find;
    
  • 문제점: 원소를 ‘찾지 못하는 것’은 탐색 알고리즘의 실패가 아니라, 예상 가능한 정상적인 결과 중 하나입니다. 이러한 로직을 예외 처리 구문으로 만들면 코드를 읽는 사람은 불필요하게 exception 블록까지 확인해야만 정상적인 로직을 이해할 수 있습니다. 또한, 예외를 발생시키고 처리하는 과정은 단순한 if문보다 훨씬 큰 성능 부하를 유발합니다.

  • 올바른 설계: null을 반환하거나, 성공 여부를 Boolean으로 반환하는 등 다른 방식을 사용합니다.

    -- 좋은 예: 예상 가능한 결과는 반환값으로 처리
    type Index_Or_Null is access constant Index;
    function Find (Item : Element; In_Array : My_Array) return Index_Or_Null is
    begin
       for I in In_Array'range loop
          if In_Array (I) = Item then
             return new Index'(I);
          end if;
       end loop;
       return null; -- '찾지 못함'이라는 정상 결과를 null로 표현
    end Find;
    

결론: 간단한 경험 법칙

언제 예외를 써야 할지 고민된다면 이 질문을 던져보십시오: “이 이벤트가 이 서브프로그램의 명백한 실패를 의미하는가?”

  • 그렇다면 예외를 사용하는 것이 좋습니다.
  • 아니라거나, 그저 예상 가능한 결과 중 하나일 뿐이라면, if문이나 반환값, out 매개변수 등 일반적인 제어 구조를 사용해야 합니다.

이 원칙을 지키는 것은 진짜 ‘예외적인’ 상황과 ‘정상적인’ 로직을 분리하여, 코드를 더 깨끗하고, 효율적이며, 유지보수하기 쉽게 만드는 핵심입니다.

10.6.2 예외 계층 구조 설계

애플리케이션이 복잡해지면 수십 개의 서로 다른 예외가 생겨날 수 있습니다. 이때 모든 예외를 평평한(flat) 구조로 관리하면, 예외 핸들러가 매우 길어지고 유지보수가 어려워집니다. 예를 들어, 모든 종류의 ‘입출력 오류’에 대해 동일한 복구 절차를 수행하고 싶을 때, 모든 I/O 관련 예외를 when 절에 |로 묶어 나열하는 것은 비효율적입니다.

이러한 문제를 해결하기 위해, 관련된 예외들을 계층 구조(hierarchy)로 설계하는 패턴을 사용합니다. 🏛️

Ada의 예외 계층 구현: renames

다른 객체 지향 언어와 달리 Ada의 예외는 타입 상속을 지원하지 않습니다. 대신, renames 절을 사용하여 매우 효과적인 예외 계층을 구성할 수 있습니다.

  1. 특정 서브시스템이나 오류의 범주를 대표하는 루트(root) 예외를 하나 선언합니다.
  2. 더 구체적인 하위 예외들을 선언할 때, 이 루트 예외를 renames 하도록 정의합니다.

이렇게 하면, 구체적인 하위 예외들은 사실상 루트 예외의 “별칭(alias)”이 됩니다. 따라서 루트 예외를 처리하는 핸들러는 모든 하위 예외들을 한 번에 처리할 수 있습니다.

예제: 입출력(I/O) 예외 계층

다음은 다양한 입출력 오류를 IO_Error라는 하나의 계층으로 묶는 예제입니다.

package My_IO is
   -- 이 서브시스템의 모든 I/O 관련 오류를 대표하는 루트 예외
   IO_Error : exception;

   -- IO_Error 계층에 속하는 구체적인 예외들
   File_Not_Found    : exception renames IO_Error;
   Permission_Denied : exception renames IO_Error;
   Device_Error      : exception renames IO_Error;

   procedure Read_From_File (Path : String);
end My_IO;

package body My_IO is
   procedure Read_From_File (Path : String) is
      -- ...
   begin
      if not File_Exists (Path) then
         raise File_Not_Found;
      elsif not Has_Permission (Path) then
         raise Permission_Denied;
      end if;
      -- ...
   end Read_From_File;
end My_IO;

계층 구조의 활용

이제 My_IO 패키지를 사용하는 클라이언트는 오류 처리의 상세 수준을 직접 선택할 수 있습니다.

-- 클라이언트 코드
begin
   My_IO.Read_From_File ("secret.txt");
exception
   -- 1. 가장 구체적인 예외를 먼저 처리할 수 있다.
   when My_IO.File_Not_Found =>
      put_line ("처리: 기본 설정 파일을 대신 사용합니다.");

   -- 2. 그 외 모든 I/O 관련 오류는 포괄적으로 처리한다.
   --    Permission_Denied, Device_Error 등이 여기에 해당된다.
   when My_IO.IO_Error =>
      put_line ("처리: 심각한 입출력 오류 발생. 관리자에게 문의하십시오.");

   when others =>
      put_line ("처리: 입출력과 관련 없는 다른 오류 발생.");
end;

예외 계층의 장점

  1. 계층적 처리: 호출자는 자신이 처리하고 싶은 예외는 구체적으로, 나머지는 상위 계층에서 포괄적으로 처리할 수 있는 유연성을 갖게 됩니다.
  2. 유지보수성 및 확장성: 나중에 My_IO 패키지에 Disk_Full_Error : exception renames IO_Error; 와 같은 새로운 예외를 추가하더라도, 기존에 when My_IO.IO_Error => 핸들러를 가지고 있던 클라이언트 코드는 아무런 수정 없이도 새로운 Disk_Full_Error를 처리할 수 있습니다.
  3. 가독성: 오류들 간의 관계를 명확히 표현하여, 시스템의 오류 모델을 이해하기 쉽게 만듭니다.

잘 설계된 예외 계층은 대규모 시스템의 복잡성을 관리하는 핵심적인 설계 패턴입니다. 이는 오류 처리 로직을 유연하고 확장 가능하게 만들어, 시간이 지나도 소프트웨어를 건강하게 유지하는 데 크게 기여합니다. 여러분의 모듈을 설계할 때, 발생 가능한 오류들을 “가족” 단위로 묶어 계층화하는 습관을 들이는 것이 좋습니다.

10.6.3 자원 관리 및 종료 처리 (Ada.Finalization)

예외 처리 시 가장 흔하게 발생하는 문제 중 하나는 자원 누수(resource leak)입니다. 자원(resource)이란 파일 핸들, 데이터베이스 커넥션, 동적 할당된 메모리, 잠금(lock) 등 사용 후에 반드시 ‘해제’ 또는 ‘정리’되어야 하는 모든 것을 의미합니다.

만약 자원을 획득한 후, 그것을 해제하기 전에 예외가 발생하면 어떻게 될까요?

-- 자원 누수가 발생하는 나쁜 예
procedure Leaky_Operation is
begin
   Acquire_Lock; -- 1. 잠금 획득
   Do_Something_Risky; -- 2. 여기서 예외 발생!
   Release_Lock; -- 3. 이 코드는 절대 실행되지 않음!
exception
   when others =>
      put_line ("An error occurred.");
      -- Release_Lock을 여기서도 호출해야 하지만, 잊어버리기 쉽다.
end Leaky_Operation;

위 코드에서 Do_Something_Risky가 예외를 발생시키면, Release_Lock 호출은 건너뛰게 되어 잠금이 영원히 해제되지 않는 교착 상태(deadlock)의 원인이 될 수 있습니다. 모든 예외 핸들러마다 자원 해제 코드를 일일이 넣어주는 것은 번거롭고 실수하기 쉬운 방법입니다.

해결책: RAII와 제어되는 타입 (Controlled Types)

이 문제를 해결하는 가장 강력하고 현대적인 설계 패턴은 RAII(Resource Acquisition Is Initialization)입니다. RAII는 자원의 생명주기를 스택에 선언된 객체의 생명주기와 일치시키는 기법입니다.

  • 객체가 생성(선언)될 때 자원을 획득합니다.
  • 객체의 유효 범위(scope)가 끝날 때, 그 이유가 정상적인 종료이든 예외 전파이든 상관없이, 객체의 소멸자가 자동으로 호출되어 자원을 해제합니다.

Ada에서는 Ada.Finalization 패키지의 Controlled 타입을 상속받아 RAII 패턴을 구현할 수 있습니다.

Controlled 타입 구현하기

Ada.Finalization.Controlled를 상속받는 타입을 선언하면, 두 개의 핵심 프로시저를 재정의(override)할 수 있습니다.

  1. Initialize (Object : in out My_Type): 해당 타입의 객체가 생성된 직후 자동으로 호출됩니다.
  2. Finalize (Object : in out My_Type): 해당 타입의 객체가 유효 범위를 벗어날 때 어떤 경우에든 반드시 자동으로 호출됩니다. 바로 이 Finalize 프로시저에 자원 해제 코드를 넣는 것이 핵심입니다.

예제: 자동 잠금 해제 객체

다음은 Controlled 타입을 이용해, 자신이 관리하는 잠금을 유효 범위가 끝날 때 자동으로 해제해 주는 Auto_Lock 객체를 구현한 예입니다.

with Ada.Finalization;

-- 가상적인 저수준 잠금 패키지가 있다고 가정
package Locks is
   type Lock_Type is limited private;
   procedure Acquire (L : in out Lock_Type);
   procedure Release (L : in out Lock_Type);
private ... end Locks;

package Auto_Locks is
   -- 1. Controlled 타입을 상속받아 자원 관리 타입 정의
   type Auto_Lock is new Ada.Finalization.Controlled with private;
private
   type Auto_Lock is new Ada.Finalization.Controlled with record
      Internal_Lock : Locks.Lock_Type;
   end record;
   -- 2. Initialize와 Finalize 재정의
   overriding procedure Initialize (Object : in out Auto_Lock);
   overriding procedure Finalize (Object : in out Auto_Lock);
end Auto_Locks;

package body Auto_Locks is
   overriding procedure Initialize (Object : in out Auto_Lock) is
   begin
      Locks.Acquire (Object.Internal_Lock); -- 생성 시 잠금 획득
   end Initialize;
   overriding procedure Finalize (Object : in out Auto_Lock) is
   begin
      Locks.Release (Object.Internal_Lock); -- 소멸 시 잠금 해제
   end Finalize;
end Auto_Locks;

Auto_Lock 사용하기

이제 이 Auto_Lock 객체를 사용하면, 예외 처리가 극도로 단순하고 안전해집니다.

procedure Use_Auto_Lock is
   Some_Error : exception;
begin
   put_line ("임계 영역에 진입합니다...");
   declare
      -- 1. Guard 객체가 선언되면서 Initialize가 호출되어 잠금이 획득됩니다.
      Guard : Auto_Locks.Auto_Lock;
   begin
      put_line ("잠금을 획득했습니다. 중요한 작업을 수행합니다.");
      raise Some_Error; -- 2. 작업 중 예외 발생!
   end; -- 3. 예외가 전파되면서 Guard가 유효 범위를 벗어납니다.
        --    이때 Finalize가 자동으로 호출되어 잠금이 해제됩니다.
exception
   when Some_Error =>
      put_line ("예외를 처리했습니다. 잠금은 이미 안전하게 해제되었습니다!");
end Use_Auto_Lock;

분석: Guard 객체가 선언된 declare-begin-end 블록에서 예외가 발생하자마자, 제어권은 exception 핸들러로 넘어가기 전에 Ada 런타임이 GuardFinalize 프로시저를 먼저 호출해 줍니다. 따라서 잠금은 100% 해제가 보장됩니다. 더 이상 핸들러마다 Release_Lock을 써줄 필요가 없습니다.


Ada.Finalization.Controlled를 활용한 RAII 패턴은 예외 안전성을 보장하는 가장 중요한 기법입니다. 이는 자원 관리를 자동화하고 인간의 실수를 원천적으로 방지하여, 복잡한 시스템에서도 자원 누수 없이 견고한 코드를 작성할 수 있도록 돕습니다.

10.6.4 예외 안전성(exception-safe)을 고려한 코드 작성

예외 처리는 단순히 exception 블록을 추가하여 프로그램의 비정상 종료를 막는 것에서 끝나지 않습니다. 진정으로 견고한 코드는 예외가 발생했을 때 프로그램이 일관되고 예측 가능한 상태를 유지하도록 보장해야 합니다. 이를 예외 안전성(Exception Safety)이라고 하며, 이는 고신뢰성 소프트웨어 설계의 핵심 원칙입니다.

예외 안전성이란, 어떤 연산 중간에 예외가 발생하여 중단되더라도 리소스가 누수되지 않고(no leaks), 객체의 상태가 깨지지 않는(no broken invariants) 것을 의미합니다.

예외 안전성의 세 가지 수준

예외 안전성은 보장의 강도에 따라 일반적으로 세 가지 수준으로 나뉩니다.

1. 기본 보증 (Basic Guarantee)

  • 약속: 연산 중 예외가 발생하더라도, 프로그램은 유효한 상태를 유지하며 리소스 누수도 없다.
  • 설명: 예외 발생 후 객체의 상태가 연산 이전과 같다고 보장할 수는 없지만, 객체는 여전히 일관된(consistent) 상태여서 안전하게 소멸되거나 다른 값을 대입할 수 있습니다. 이것이 예외 안전성의 최소 요구 조건입니다.
  • 구현: Ada.Finalization.Controlled 타입을 활용한 RAII(Resource Acquisition Is Initialization) 패턴이 핵심입니다. 동적으로 할당된 자원(메모리, 파일 핸들er, 락 등)을 제어되는 타입의 객체가 관리하도록 하면, 예외 발생 시 객체의 유효 범위를 벗어날 때 Finalize 프로시저가 자동으로 호출되어 자원을 안전하게 해제해 줍니다.

2. 강력 보증 (Strong Guarantee)

  • 약속: 연산이 예외로 중단되면, 프로그램의 상태는 연산이 시작되기 전의 상태와 완전히 동일하게 복구된다.

  • 설명: ‘전부 아니면 전무(all-or-nothing)’의 트랜잭션(transaction)과 같은 보장입니다. 연산이 성공하면 모든 변경사항이 적용되고, 실패하면 아무 일도 없었던 것처럼 됩니다.

  • 구현: “복사 후 맞바꾸기(copy-and-swap)” 기법이 대표적입니다.

    1. 수정하려는 객체의 임시 복사본을 만듭니다.
    2. 모든 연산을 임시 복사본에 수행합니다. 이 과정에서 예외가 발생할 수 있습니다.
    3. 모든 연산이 성공적으로 끝나면, 그때서야 원본 객체와 임시 복사본을 맞바꿉니다(swap). 이 맞바꾸기 연산은 예외를 발생시키지 않아야 합니다.
    procedure Update_Settings (Config : in out Settings_Type) is
       -- 1. 원본의 복사본을 만든다.
       Temp_Settings : Settings_Type := Config;
    begin
       -- 2. 예외 발생 가능성이 있는 모든 작업을 복사본에 수행한다.
       Load_Port (From => ..., Into => Temp_Settings);   -- 예외 발생 가능
       Load_Address (From => ..., Into => Temp_Settings); -- 예외 발생 가능
    
       -- 3. 모든 작업이 성공했으므로, 예외가 발생하지 않는 연산으로 원본을 갱신한다.
       Config := Temp_Settings;
    exception
       when others =>
          -- 예외가 발생하면 Temp_Settings는 버려지고, 원본 Config는 전혀 변경되지 않았다.
          -- 호출자에게 오류를 알리기 위해 예외를 다시 발생시킨다.
          raise;
    end Update_Settings;
    

3. 예외 불발 보증 (Nofail/Nothrow Guarantee)

  • 약속: 이 연산은 절대로 예외를 발생시키지 않음을 보장한다.
  • 설명: 객체의 소멸자(Finalize), 자원 해제, 두 객체의 상태를 맞바꾸는(swap) 기본 연산 등은 반드시 이 보증을 따라야 합니다. 만약 객체의 소멸 과정에서 예외가 발생한다면, 안전한 자원 해제가 불가능해지기 때문입니다. Ada의 기본 타입 대입, 접근 값(access value) 대입 등은 예외 불발 보증을 제공합니다.

결론: 올바른 설계를 통한 안전성 확보

예외 안전성은 단순히 코드를 begin-exception으로 감싸는 행위가 아니라, 실패 상황을 염두에 둔 설계 원칙입니다. 어떤 서브프로그램을 작성하든 “이 연산 중간에 예외가 발생하면 객체는 어떤 상태에 놓이게 될까?”를 항상 고려해야 합니다.

  • 자원 관리는 RAII에 맡기고, (기본 보증)
  • 상태 변경은 복사 후 맞바꾸기와 같은 기법으로 원자적으로 만들고, (강력 보증)
  • 소멸자와 같은 핵심 정리 코드는 절대로 실패하지 않도록 (예외 불발 보증)

설계하는 습관은 전문가 수준의 견고한 Ada 코드를 작성하기 위한 필수적인 역량입니다.

10.7 실제 적용 시나리오 및 사례 연구

지금까지 우리는 예외의 기본 문법부터 시작하여 예외 전파 규칙, 고급 관리 기법, 그리고 모범 설계 패턴에 이르기까지 Ada의 예외 처리 메커니즘을 다각도로 학습했습니다. 이론적 지식을 실제 문제 해결 능력으로 전환하는 가장 좋은 방법은 구체적인 사례를 통해 그 쓰임새를 직접 확인하는 것입니다.

이번 9.7절에서는 서로 다른 특성을 가진 세 가지 시나리오를 통해, 지금까지 배운 예외 처리 기법이 어떻게 적용되어 시스템의 견고함(robustness)신뢰성(reliability)을 높이는지 살펴볼 것입니다.

  1. 파일 입출력: 모든 프로그래머가 마주하는 가장 흔한 시나리오로, 외부 환경의 실패에 우아하게 대처하는 방법을 배웁니다.
  2. 임베디드 시스템: 자원이 제한되고 안전이 최우선인 환경에서, 오류 복구와 시스템 상태를 제어하는 기법을 다룹니다.
  3. 내결함성 네트워크 서비스: 여러 클라이언트가 동시에 접속하는 환경에서, 일부의 장애가 전체 시스템에 영향을 주지 않도록 오류를 격리하는 방법을 연구합니다.

이러한 사례 연구들을 통해, 여러분은 추상적인 예외 처리 원칙이 어떻게 다양한 응용 분야에서 신뢰성 높은 소프트웨어를 구축하는 실질적인 설계 패턴으로 구현되는지 명확하게 이해하게 될 것입니다.

10.7.1 파일 입출력 오류 처리

프로그램이 외부 파일과 상호작용하는 파일 입출력(I/O)은 런타임 오류의 가장 흔한 원천 중 하나입니다. 파일이 존재하지 않거나(Name_Error), 접근 권한이 없거나(Use_Error), 디스크가 꽉 차거나(Device_Error), 파일 내용이 예상과 다른 형식일 때(Data_Error) 등 수많은 문제가 발생할 수 있습니다.

견고한 애플리케이션은 이러한 문제들을 예상하고, 프로그램이 비정상적으로 종료되는 대신 우아하게 대처할 수 있어야 합니다.

시나리오: 설정 파일 읽기

config.txt라는 파일에서 Key=Value 형식으로 저장된 설정값을 읽어오는 프로그램을 작성한다고 가정해 봅시다. 이 프로그램은 파일이 없거나, 권한이 없거나, 파일 내용 일부에 문법 오류가 있더라도 최대한 안정적으로 동작해야 합니다.

예외 처리를 통한 견고한 구현

Ada.Text_IO 패키지의 파일 관련 연산들은 오류 발생 시 다양한 예외를 발생시킵니다. 우리는 exception 블록을 사용하여 이러한 예외들을 체계적으로 처리할 수 있습니다.

with Ada.Text_IO;
with Ada.Strings.Unbounded; use Ada.Strings.Unbounded;

procedure Load_Configuration is
  Config_File : Ada.Text_IO.File_Type;
  Config_Path : constant String := "config.txt";
begin
  -- 1. 파일을 연다. Name_Error, Use_Error 등이 발생할 수 있다.
  Ada.Text_IO.Open (File => Config_File,
                    Mode => Ada.Text_IO.In_File,
                    Name => Config_Path);

  -- 2. 파일의 끝까지 한 줄씩 읽어 처리한다.
  while not Ada.Text_IO.End_Of_File (Config_File) loop
    declare
      Line : String := Ada.Text_IO.Get_Line (Config_File);
    begin
      -- 한 줄의 형식이 잘못된 경우 Data_Error가 발생할 수 있다.
      -- 이 오류는 해당 줄만 건너뛰고 계속 진행하도록 처리한다.
      Process_Config_Line (Line);
    exception
      when Ada.Text_IO.Data_Error =>
        Ada.Text_IO.put_line ("Warning: Skipping malformed line: " & Line);
    end;
  end loop;

  -- 3. 모든 작업 완료 후 파일을 닫는다.
  Ada.Text_IO.Close (Config_File);
  Ada.Text_IO.put_line ("Configuration loaded successfully from " & Config_Path);

exception
  -- 4. 파일 열기 등 최상위 수준에서 발생한 예외를 처리한다.
  when Ada.Text_IO.Name_Error =>
    Ada.Text_IO.put_line ("Info: Configuration file not found at " & Config_Path);
    Ada.Text_IO.put_line ("       -> Proceeding with default settings.");
    -- Load_Default_Configuration; -- 기본값으로 프로그램을 계속 진행

  when Ada.Text_IO.Use_Error =>
    Ada.Text_IO.put_line ("Fatal: Permission denied for file " & Config_Path);
    -- Raise_Fatal_Error; -- 더 이상 진행이 불가능하므로 프로그램을 종료

  when Ada.Text_IO.Device_Error =>
    Ada.Text_IO.put_line ("Fatal: Device error (e.g., disk full) occurred.");
    -- Raise_Fatal_Error;

  when others =>
    Ada.Text_IO.put_line ("Fatal: An unexpected error occurred.");
    -- Raise_Fatal_Error;

end Load_Configuration;

분석 및 결론

위 코드에는 두 가지 수준의 예외 처리가 적용되었습니다.

  1. 세밀한 제어 (Fine-grained Control): while 루프 안의 declare-begin-exception 블록은 파일의 내용 한 줄에 대한 오류(Data_Error)를 처리합니다. 문제가 있는 줄은 경고 메시지를 출력하고 건너뛸 뿐, 전체 파일 읽기 과정을 중단시키지 않습니다.

  2. 전역적 제어 (Global Control): 프로시저의 메인 exception 블록은 파일 열기(Open)와 같이 작업 전체에 영향을 미치는 치명적인 오류를 처리합니다. Name_Error의 경우 기본값으로 프로그램을 계속 실행하는 복구 전략을 선택했고, Use_Error와 같이 더 심각한 문제는 프로그램을 안전하게 중단하도록 처리했습니다.

이처럼 파일 I/O 로직을 begin-end 블록으로 감싸고 발생 가능한 예외들을 종류별로 처리함으로써, 우리는 다양한 실패 시나리오에 유연하게 대응하는 신뢰성 높은 소프트웨어를 만들 수 있습니다.

10.7.2 임베디드 시스템에서의 오류 처리

임베디드 시스템은 데스크톱이나 서버 환경과는 근본적으로 다른 제약 조건과 요구사항을 가집니다. 메모리와 처리 능력이 제한적이고, 사람의 개입 없이 장시간 독립적으로 동작해야 하며, 때로는 시스템의 작은 오작동이 물리적인 손상이나 안전 문제로 직결될 수 있습니다. ☢️

따라서 임베디드 시스템에서의 오류 처리는 단순히 프로그램을 종료하는 것이 아니라, 어떻게든 시스템을 안전한 상태(safe state)로 전환하고, 가능하다면 스스로 복구(recover)하여 임무를 계속 수행하는 데 초점을 맞춥니다.

핵심 전략: 예측 가능성과 상태 관리

임베디드 시스템의 예외 처리는 예측 불가능한 상황을 예측 가능한 상태로 바꾸는 과정입니다.

패턴 1: 로그, 안전 상태 진입, 그리고 리셋

가장 보편적이고 강력한 패턴 중 하나입니다. 시스템의 최상위 레벨에서 예외를 처리하여, 예상치 못한 모든 오류에 대한 최종 방어선을 구축합니다.

  1. 로그(Log): 오류가 발생하면, 원인 분석을 위해 예외 정보(발생 위치, 종류 등)를 플래시 메모리 같은 비휘발성 저장소에 기록합니다.
  2. 안전 상태 진입(Enter Safe Mode): 모터를 끄거나, 밸브를 잠그는 등 시스템이 물리적으로 위험하지 않은 상태로 즉시 전환합니다.
  3. 리셋(Reset): 하드웨어 워치독 타이머(watchdog timer)를 이용하거나 리셋 명령을 직접 호출하여 시스템을 깨끗한 초기 상태에서 다시 시작하도록 합니다.
with Ada.Exceptions;

procedure Robot_Controller_Main is
  -- ...
begin
  -- 시스템 초기화
  Initialize_Hardware;

  loop
    -- 주기적으로 수행되는 메인 제어 로직
    Perform_Sensing;
    Perform_Calculation;
    Perform_Actuation;
  end loop;

exception
  when others =>
    -- 예상치 못한 모든 오류를 여기서 처리한다.
    Log_Fatal_Error (Ada.Exceptions.Exception_Information (others)); -- 1. 로그
    Enter_Safe_Mode; -- 2. 안전 상태 진입
    Trigger_System_Reset; -- 3. 리셋
end Robot_Controller_Main;

이 패턴은 복잡한 복구 로직을 구현하는 대신, 시스템을 가장 확실하게 안정시킬 수 있는 방법입니다.

패턴 2: 상태 기반 복구 (State-Based Recovery)

인공위성이나 원격 탐사 장비처럼 물리적으로 리셋이 불가능하거나 임무 연속성이 매우 중요한 시스템에서 사용되는 고급 패턴입니다. 이 설계에서 시스템은 여러 동작 상태(state)를 가지며, 예외는 상태를 전환하는 트리거(trigger) 역할을 합니다.

  • Normal_Mode: 모든 기능이 정상 동작하는 상태
  • Degraded_Mode: 일부 센서나 액추에이터에 문제가 생겨, 기능이 축소된 상태로 동작
  • Safe_Hold_Mode: 최소한의 기능만 유지하며 지상국의 명령을 기다리는 상태
procedure Satellite_Control is
  type System_State is (Normal, Degraded, Safe_Hold);
  Current_State : System_State := Normal;
begin
  loop
    case Current_State is
      when Normal =>
        begin
          Do_Normal_Operations;
        exception
          when Primary_Sensor_Failure =>
            -- 주 센서 고장 예외 발생!
            Current_State := Degraded; -- 1. 상태를 '기능 저하 모드'로 변경
            Activate_Backup_Sensor;   -- 2. 백업 센서 활성화
        end;

      when Degraded =>
        Do_Degraded_Operations;
        -- ...

      when Safe_Hold =>
        Wait_For_Ground_Command;
        -- ...
    end case;
  end loop;
end Satellite_Control;

이처럼 예외가 발생했을 때 프로그램을 중단하는 대신, 시스템의 동작 모드를 변경하여 임무를 어떻게든 이어나가도록 설계할 수 있습니다.

결론: 제어권의 유지

임베디드 시스템에서 예외 처리의 궁극적인 목표는 ‘제어권’을 잃지 않는 것입니다. 예상치 못한 오류가 발생하더라도, 프로그램은 정해진 복구 절차에 따라 움직여야 합니다. Ada의 구조적 예외 처리는 이러한 예측 가능하고 신뢰성 높은 복구 로직을 구현하는 데 매우 적합한, 강력하고 체계적인 방법을 제공합니다.

10.7.3 내결함성(fault-tolerant) 네트워크 서비스 구축

지금까지 배운 예외 처리 기법들이 실제로 어떻게 강력한 시스템을 만드는 데 사용되는지, 내결함성(Fault-Tolerance)을 갖춘 네트워크 서버를 구축하는 사례를 통해 살펴보겠습니다. 내결함성이란 시스템의 일부 구성 요소에 장애(fault)가 발생하더라도 전체 서비스는 중단 없이 계속해서 동작하는 능력을 의미합니다.

시나리오: 끊임없이 동작해야 하는 서버

우리가 만들 서버는 여러 클라이언트의 요청을 받아 처리하는 간단한 서비스입니다. 서버는 다음의 위험에 노출되어 있습니다.

  • 클라이언트가 요청을 보내는 도중 갑자기 연결을 끊는 경우
  • 클라이언트가 프로토콜에 맞지 않는 비정상적인 데이터를 보내는 경우
  • 요청을 처리하는 내부 로직에서 예상치 못한 오류가 발생하는 경우

내결함성이 없는 서버라면, 단 하나의 클라이언트에서 발생한 문제로 인해 서버 전체가 다운되어 다른 모든 클라이언트에게 서비스를 제공할 수 없게 될 것입니다. 우리의 목표는 한 클라이언트와의 통신에서 문제가 발생하더라도, 해당 클라이언트와의 연결만 정리하고 다음 클라이언트의 요청을 계속해서 받을 수 있는 견고한 서버를 만드는 것입니다.

핵심 설계: 오류 격리 방화벽

이러한 내결함성은 오류 격리(fault isolation)를 통해 구현할 수 있습니다. 즉, 각 클라이언트를 처리하는 로직을 하나의 독립된 작업 단위로 보고, 이 단위 안에서 발생하는 모든 예외가 외부(메인 서버 루프)로 전파되지 않도록 막는 것입니다. Ada의 begin-end 블록과 exception 핸들러는 이러한 “방화벽”을 만드는 데 완벽한 도구입니다.

먼저, 발생할 수 있는 주요 오류를 표현하기 위한 예외를 선언합니다.

-- protocol.ads
package Protocol is
  Invalid_Request_Format : exception; -- 클라이언트가 보낸 데이터 형식 오류
  Operation_Failed       : exception; -- 서버 내부 처리 로직 오류
  -- ...
end Protocol;

이제 이 예외들을 활용하여 내결함성 서버 루프를 설계합니다.

with Ada.Text_IO;
with Ada.IO_Exceptions;
with Protocol;
-- ...
procedure Server is
  -- ... 서버 소켓 초기화 ...
begin
  loop -- 1. 메인 서버 루프는 절대로 중단되지 않아야 한다.
    declare
      -- 각 클라이언트 연결에 대한 자원을 선언
      Client : Client_Socket;
    begin
      -- 2. 클라이언트 연결을 수락한다.
      Client := Accept_Connection (Server_Socket);
      Ada.Text_IO.put_line ("New client connected.");

      -- 3. [오류 격리 블록 시작]
      --    한 클라이언트의 오류가 다른 클라이언트에게 영향을 주지 않도록 한다.
      begin
        declare
          Request  : Message := Read_From (Client); -- End_Error, Data_Error 가능
          Response : Message;
        begin
          if not Is_Valid (Request) then
            raise Protocol.Invalid_Request_Format; -- 형식 오류 발생
          end if;

          Response := Process (Request); -- Operation_Failed 가능
          Write_To (Client, Response);
        end;

      exception
        when Protocol.Invalid_Request_Format =>
          Ada.Text_IO.put_line ("Error: Invalid request from client.");
          -- 클라이언트에게 오류 응답을 보낼 수 있음

        when Protocol.Operation_Failed =>
          Ada.Text_IO.put_line ("Error: Internal processing failed.");
          -- 클라이언트에게 서버 내부 오류 응답을 보낼 수 있음

        when Ada.IO_Exceptions.End_Error =>
          -- 클라이언트가 갑자기 연결을 끊은 경우, 이는 "오류"가 아닌 정상적인 종료 상황이다.
          Ada.Text_IO.put_line ("Info: Client disconnected gracefully.");

        when others =>
          -- 예상치 못한 다른 모든 예외를 처리한다.
          Ada.Text_IO.put_line ("Error: An unexpected error occurred.");
      end;
      -- 4. [오류 격리 블록 끝]

      -- 5. 예외 발생 여부와 상관없이 항상 클라이언트 연결을 정리한다.
      Close_Connection (Client);
      Ada.Text_IO.put_line ("Client connection closed.");

    exception
      -- Accept_Connection 자체에서 오류가 나는 등, 서버 루프가 더는
      -- 동작할 수 없는 치명적인 오류만 이곳에서 처리한다.
      when others =>
        Ada.Text_IO.put_line ("FATAL: Server loop is shutting down.");
        return; -- 루프 종료
    end;
  end loop;
end Server;

분석 및 결론

위 코드의 핵심은 이중 begin-end 구조입니다. 바깥쪽 loop 안의 declare-begin-exception-end 블록이 각 클라이언트를 처리하는 오류 방화벽 역할을 합니다.

클라이언트로부터 잘못된 데이터를 받거나(Invalid_Request_Format), 내부 처리 중 문제가 생기거나(Operation_Failed), 클라이언트 연결이 끊겨도(End_Error), 예외는 모두 안쪽 exception 핸들러에서 처리됩니다. 핸들러 실행이 끝나면 프로그램 제어는 Close_Connection으로 넘어가고, 이어서 바깥쪽 loop의 다음 반복으로 진입하여 새로운 클라이언트를 기다립니다.

이처럼 Ada의 구조적 예외 처리를 활용하면, 각 작업 단위의 실패를 국지적으로 격리하고 처리하여 시스템 전체의 안정성을 보장하는 내결함성 설계를 명확하고 안정적으로 구현할 수 있습니다.

11. 접근 타입과 메모리 관리

지금까지 우리는 크기와 생명주기가 컴파일 시점이나 스코프 진입 시점에 정적으로 결정되는 데이터 타입들을 다루었습니다. 이러한 정적 데이터 구조는 예측 가능하고 안전하지만, 프로그램 실행 중에 크기가 변하거나 복잡한 관계를 맺어야 하는 데이터를 다루기에는 유연성이 부족합니다. 연결 리스트, 트리, 그래프와 같이 동적으로 성장하고 축소하는 자료구조를 구현하려면 새로운 접근 방식이 필요합니다.

이번 10장에서는 바로 이러한 동적 데이터 구조와 메모리 관리를 위한 Ada의 핵심 기능인 접근 타입(access types)을 탐구합니다. 접근 타입은 다른 언어의 포인터와 유사하게 다른 객체에 대한 참조를 저장하지만, Ada는 여기에 강력한 안전장치를 더하여 포인터가 야기하는 고질적인 문제들을 언어 차원에서 방지합니다.

본 장에서는 먼저 접근 타입의 기본 개념과 선언, 사용법을 익히고, new 연산자를 이용한 동적 메모리 할당과 Unchecked_Deallocation을 통한 수동 해제 방법을 배웁니다. 이어서, 허상 포인터를 컴파일 시점에 방지하는 Ada의 독창적인 기능인 접근성 검사(accessibility checks)를 심도 있게 분석합니다.

나아가, 현대적인 Ada 프로그래밍에서 수동 메모리 관리를 대체하는 안전하고 효율적인 대안인 Ada.Containers 표준 라이브러리의 활용법을 살펴보고, 마지막으로 콜백(callback)과 같은 동적 프로그래밍 패턴의 기반이 되는 접근-서브프로그램 타입까지 학습할 것입니다. 이 장을 마치면 여러분은 Ada가 제공하는 도구들을 사용하여 동적 메모리를 안전하고 신뢰성 있게 관리하는 능력을 갖추게 될 것입니다.

11.1 접근 타입의 기본 개념 (Fundamentals of Access Types)

지금까지 우리가 다룬 데이터 타입, 즉 정수, 실수, 배열, 레코드 등은 모두 그 크기와 생명주기가 정적으로 결정되는 특징을 가집니다. 변수가 선언될 때 필요한 메모리 공간이 스택(stack)에 할당되고, 해당 변수가 선언된 유효 범위(scope)를 벗어나면 메모리는 자동으로 해제됩니다. 이러한 방식은 예측 가능하고 안전하지만, 프로그램 실행 중에 데이터의 크기나 구조가 동적으로 변해야 하는 복잡한 요구사항을 충족시키기에는 유연성이 부족합니다.

예를 들어, 개수를 예측할 수 없는 사용자 입력을 저장해야 하거나, 노드(node)가 수시로 추가되고 삭제되는 트리(tree)나 연결 리스트(linked list) 같은 자료구조를 구현해야 한다고 가정해 봅시다. 정적 배열로는 이러한 요구를 효율적으로 처리하기 어렵습니다.

이러한 동적 자료구조를 구현하고 메모리를 유연하게 관리하기 위해 Ada는 접근 타입(access type)을 제공합니다. 접근 타입의 변수는 데이터 값을 직접 담는 대신, 다른 객체가 위치한 메모리 주소를 가리키는 ‘지정자(designator)’를 값으로 가집니다. C나 C++과 같은 언어에 익숙한 프로그래머에게 이는 포인터(pointer)와 매우 유사한 개념으로 다가올 것입니다.

하지만 Ada의 접근 타입은 전통적인 포인터가 야기하는 다양한 문제를 해결하기 위해 설계된 강력한 안전 장치를 내장하고 있다는 점에서 근본적인 차이를 보입니다. Ada는 메모리 누수(memory leak), 허상 포인터(dangling pointer)와 같은 치명적인 오류를 컴파일 시점 또는 최소한 런타임에서 체계적으로 방지하는 것을 목표로 합니다.

이번 절에서는 동적 메모리 관리의 첫걸음인 접근 타입의 기초를 다집니다. 먼저 C/C++의 포인터와 비교하여 Ada 접근 타입의 설계 철학을 이해하고, 정확한 선언 방법과 사용법을 익힌 후, 아무것도 가리키지 않는 상태를 나타내는 null 값에 대해 배울 것입니다. 이를 통해 여러분은 정적인 데이터를 넘어 동적으로 살아 움직이는 프로그램을 만들 수 있는 강력한 도구를 얻게 될 것입니다.

11.1.1 C/C++의 포인터와 Ada 접근 타입의 비교

Ada의 접근 타입과 C/C++의 포인터는 메모리에 존재하는 다른 객체에 대한 참조를 저장한다는 점에서 기능적 유사성을 가집니다. 그러나 두 언어의 설계 철학은 상이하며, 이는 타입 시스템, 연산, 그리고 안전성 보장 메커니즘에서 구체적인 차이로 나타납니다.

C/C++의 포인터는 저수준 메모리 조작을 위한 높은 수준의 유연성을 제공합니다. 이는 임의의 주소 연산과 타입 간의 명시적, 암묵적 변환을 허용함으로써 달성됩니다. 반면, Ada의 접근 타입은 신뢰성이 중요한 시스템을 위해 설계되었으며, 언어 차원에서 잠재적인 오류를 방지하는 데 중점을 둡니다.

주요 기술적 차이점은 다음 표와 같이 요약할 수 있습니다.

비교 항목 C/C++ 포인터 Ada 접근 타입
타입 안전성 void*를 통해 타입 시스템을 우회할 수 있으며, 포인터 간의 타입 변환이 비교적 자유롭습니다. 지정된 타입의 객체만 가리키도록 하는 강타입 원칙을 적용합니다. 타입 변환은 Unchecked_Conversion을 통해 명시적으로 이루어져야 합니다.
주소 연산 포인터에 정수를 더하거나 빼는 주소 연산(ptr + 1)이 기본적으로 허용됩니다. 주소 연산이 기본적으로 금지됩니다. 필요한 경우, System.Storage_Elements 패키지를 통해 명시적으로 수행해야 합니다.
허상 포인터 방지 언어 차원의 방지 기능이 부재합니다. 해제된 메모리에 대한 접근을 막는 것은 프로그래머의 책임입니다. 접근성 검사(accessibility checks) 규칙을 통해, 접근 타입 변수의 생명주기가 가리키는 객체의 생명주기를 초과할 수 없도록 컴파일 시점에 검사합니다.
null 처리 NULL 또는 nullptr를 역참조할 경우의 결과는 미정의 동작(undefined behavior)입니다. null 값을 역참조할 경우, Constraint_Error 예외가 발생하는 것으로 명확히 정의됩니다.

이러한 차이점들을 종합하면, C/C++ 포인터는 하드웨어에 대한 직접적이고 유연한 제어를 우선시하는 설계를 반영합니다. 반면, Ada 접근 타입의 설계는 잠재적인 프로그래밍 오류를 컴파일러와 런타임 시스템이 체계적으로 감지하고 방지하도록 하여, 소프트웨어의 견고성과 예측 가능성을 높이는 것을 목표로 합니다.

11.1.2 접근 타입 선언 및 사용 (access all, 역참조, null)

접근 타입 선언

Ada에서 접근 타입은 지정할 수 있는 객체의 종류에 따라 두 가지 형태로 선언할 수 있으나, 현대 Ada 프로그래밍에서는 access all 키워드를 사용하는 범용 접근 타입(general access type)이 표준적인 방식으로 간주됩니다. 이 타입은 힙(heap)에 동적으로 할당된 객체뿐만 아니라, 스택(stack)에 선언된 지역 변수 등 모든 종류의 메모리 영역에 있는 객체를 가리킬 수 있습니다.

선언 구문:

type <Type_Name> is access all <Designated_Type>;

객체의 주소를 얻기 위해서는, 주소를 얻고자 하는 객체를 aliased 키워드로 선언한 뒤 'access 속성을 사용해야 합니다.

역참조 및 null

선언된 접근 타입 변수는 값을 직접 담는 대신, 다른 객체를 가리키거나 아무것도 가리키지 않는 상태인 null 값을 가집니다.

  • null: 어떠한 객체도 지정하고 있지 않음을 나타내는 명시적인 값입니다. 안전한 프로그래밍을 위해 접근 변수는 사용 전에 유효한 주소를 갖거나 null인지 반드시 확인해야 합니다. null 포인터를 역참조하려 하면 Constraint_Error 예외가 발생합니다.

  • 역참조 (.all): 접근 변수가 가리키는 실제 객체 자체를 얻는 연산입니다. My_Ptr.allMy_Ptr이 가리키는 객체 전체를 의미합니다. 만약 가리키는 대상이 레코드라면, My_Ptr.Field와 같이 점(.) 표기법을 사용하여 필드에 직접 접근할 수 있으며, 이 경우 역참조는 암시적으로 일어납니다.

코드 예시 10-2: 접근 타입의 선언과 사용

with Ada.Text_IO;

procedure basic_access_example is
   type Data_Record is record
      id    : Integer;
      value : Float;
   end record;

   -- Data_Record를 가리키는 범용 접근 타입 선언
   type Data_Access is access all Data_Record;

   -- 스택에 'aliased' 키워드로 객체 선언
   my_data : aliased Data_Record := (id => 101, value => 3.14);

   -- 접근 변수를 null로 안전하게 초기화
   ptr : Data_Access := null;
begin
   if ptr = null then
      Ada.Text_IO.put_line ("1. ptr이 초기화되었습니다 (null).");
   end if;

   -- 'access 속성을 사용하여 my_data의 주소를 ptr에 할당
   ptr := my_data'access;
   Ada.Text_IO.put_line ("2. ptr이 my_data를 가리킵니다.");

   -- 점 표기법(암시적 역참조)을 사용하여 필드 수정
   ptr.value := 1.618;

   -- my_data의 값이 변경되었는지 확인
   Ada.Text_IO.put_line ("3. my_data.value가 변경되었습니다: " & Float'image (my_data.value));

   -- .all (명시적 역참조)을 사용하여 전체 객체 복사
   declare
      another_data : Data_Record;
   begin
      another_data := ptr.all;
      Ada.Text_IO.put_line ("4. another_data.id: " & Integer'image (another_data.id));
   end;

end basic_access_example;

실행 결과:

1. ptr이 초기화되었습니다 (null).
2. ptr이 my_data를 가리킵니다.
3. my_data.value가 변경되었습니다:  1.61800E+00
4. another_data.id:  101

11.2 동적 메모리의 수동 관리

10.1절에서 접근 타입이 기존에 선언된 객체를 가리키는 방법을 살펴보았다면, 이번 절에서는 접근 타입의 주된 용도인 동적 메모리 할당에 대해 다룹니다. 동적 할당은 프로그램 실행 중에 필요한 만큼의 메모리를 ‘힙(heap)’이라는 특별한 메모리 공간에서 할당받아 객체를 생성하는 것을 의미합니다.

11.2.1 new를 이용한 동적 할당

Ada에서 동적 할당은 new 연산자를 통해 이루어집니다. new 연산자는 지정된 타입의 객체를 저장할 만큼의 메모리를 저장소 풀(storage pool)에서 할당하고, 새로 생성된 객체를 가리키는 접근 값을 반환합니다.

new 연산자는 두 가지 주요 형태로 사용됩니다.

  • 기본 할당: 객체를 생성하고 해당 타입의 기본값으로 초기화합니다.

    My_Ptr := new <타입_이름>;
    
  • 초기값 지정 할당: 객체를 생성함과 동시에 지정된 초기값으로 초기화합니다.

    My_Ptr := new <타입_이름>'(<초기값>);
    

11.2.2 Unchecked_Deallocation을 이용한 명시적 해제

new로 할당된 메모리는 더 이상 필요 없을 때 시스템에 반환하여 다른 용도로 사용될 수 있도록 해야 합니다. 이를 메모리 해제(deallocation)라고 하며, Ada에서는 Ada.Unchecked_Deallocation이라는 제네릭 프로시저를 통해 명시적으로 수행할 수 있습니다.

이름에 ‘비검사(Unchecked)’가 붙은 이유는 C의 free 함수처럼, 이 프로시저의 안전성을 프로그래머가 전적으로 책임져야 하기 때문입니다.

  • 댕글링 포인터 (Dangling Pointer): 메모리가 해제된 후에도 여전히 그 주소를 가리키고 있는 접근 값이 남아있을 수 있습니다.
  • 이중 해제 (Double Free): 이미 해제된 메모리를 다시 해제하려고 시도하면 런타임 시스템이 오염될 수 있습니다.

Unchecked_Deallocation은 제네릭이므로, 사용하기 전에 해제할 객체의 타입(Object)과 해당 객체를 가리키는 접근 타입(Name)을 지정하여 인스턴스화해야 합니다.

  1. 인스턴스화:

    procedure Free is new Ada.Unchecked_Deallocation
      (Object => Node, Name => Node_Access);
    
  2. 호출:

    Old_Node : Node_Access := List_Head;
    List_Head := List_Head.all.next;
    
    Free (Old_Node); -- Old_Node가 가리키던 메모리 해제
    

안전 수칙: 메모리를 해제한 후에는 해당 접근 타입 변수에 null을 할당하여, 의도치 않게 댕글링 포인터를 사용하는 것을 방지하는 것이 매우 중요합니다.

Free (Old_Node);
Old_Node := null; -- 이제 이 포인터는 안전하게 '아무것도 가리키지 않음'

11.2.3 [참고] 가비지 컬렉션과의 관계

일부 Ada 구현(컴파일러 및 런타임 시스템)은 더 이상 어떤 접근 변수도 가리키지 않는 동적 객체를 자동으로 탐지하여 메모리를 회수하는 가비지 컬렉터(Garbage Collector)를 제공할 수 있습니다.

만약 가비지 컬렉터가 활성화된 환경이라면, Unchecked_Deallocation을 사용해서는 안 됩니다. 수동 해제와 자동 수집이 혼용될 경우 시스템에 심각한 오류를 유발할 수 있습니다.

Unchecked_Deallocation은 메모리 해제 시점을 프로그래머가 직접 제어해야 하는 실시간 시스템이나, 가비지 컬렉터의 비결정적인 지연(pause)을 허용할 수 없는 고신뢰성 시스템에서 주로 사용됩니다.

11.2.4 재귀적 자료구조와 불완전 타입 선언

new 연산자의 가장 대표적인 사용 사례는 연결 리스트(linked list)나 트리(tree)와 같이 자기 자신을 참조하는 재귀적 자료구조를 구현하는 것입니다.

연결 리스트의 각 노드(Node)는 데이터와 함께 다음 노드를 가리키는 접근 값을 포함해야 합니다. 이를 코드로 표현하면 다음과 같은 순환적인 정의 문제가 발생합니다.

  • Node 레코드를 정의하려면, 그 안에 Node_Access 타입의 필드가 필요합니다.
  • Node_Access 접근 타입을 정의하려면, 가리킬 대상인 Node 타입이 먼저 정의되어 있어야 합니다.

Ada는 이러한 순환 의존성 문제를 해결하기 위해 불완전 타입 선언(incomplete type declaration)을 제공합니다. 이는 컴파일러에게 “이러한 이름의 타입이 나중에 완전하게 정의될 것이니, 지금은 이름만 알고 있어달라”고 알려주는 일종의 예고입니다.

재귀적 자료구조는 항상 다음의 3단계 패턴으로 선언됩니다.

코드 예시 10-3: 연결 리스트 노드 선언 패턴

procedure linked_list_declaration is
  -- 1단계: 불완전 타입 선언
  -- 'Node'라는 타입이 존재할 것임을 예고합니다.
  type Node;

  -- 2단계: 접근 타입 선언
  -- 이제 컴파일러는 'Node'라는 이름을 알고 있으므로,
  -- Node를 가리키는 접근 타입을 선언할 수 있습니다.
  type Node_Access is access all Node;

  -- 3단계: 완전 타입 선언
  -- 이제 'Node_Access'가 정의되었으므로, 이를 필드로 사용하여
  -- 'Node' 타입을 완전하게 정의할 수 있습니다.
  type Node is record
     data : Integer;
     next : Node_Access := null;
  end record;

  List_Head : Node_Access;
begin
  List_Head := new Node'(data => 10, next => new Node'(data => 20, next => null));
end linked_list_declaration;

이 3단계 패턴은 Ada에서 동적인 자료구조를 구현하는 근본적이고 필수적인 기법입니다.

11.3 Ada의 안전 철학: 위험 요소와 내장된 방어 체계

newUnchecked_Deallocation을 이용한 수동 메모리 관리는 프로그래머에게 완전한 제어권을 부여하지만, 그에 상응하는 큰 책임을 요구합니다. C/C++과 같은 언어에서 가장 잡기 어렵고 치명적인 버그 중 다수는 바로 이 수동 메모리 관리 과정에서 발생합니다.

이번 절에서는 수동 관리 시 마주치는 가장 대표적인 두 가지 위험 요소인 댕글링 포인터(Dangling Pointer)메모리 누수(Memory Leak)를 살펴봅니다. 그리고 이러한 위험을 방지하기 위해 Ada가 언어 차원에서 제공하는 독창적이고 강력한 내장 방어 체계, 즉 접근성 검사(Accessibility Checks)에 대해 심도 있게 알아볼 것입니다.

11.3.1 위험 요소: 댕글링 포인터와 메모리 누수

1. 댕글링 포인터 (Dangling Pointers)

댕글링 포인터(Ada 용어로는 허상 포인터)는 이미 해제되어 유효하지 않은 메모리 영역을 가리키는 접근 값입니다. 이러한 포인터를 역참조하면 예측 불가능한 값이나 메모리 오염을 유발하여 프로그램 전체를 불안정하게 만듭니다.

주요 발생 원인은 두 가지입니다.

  • 스코프 종료: 접근 타입 변수가 자신이 가리키는 객체보다 더 오래 살아남을 때 발생합니다.
  • 명시적 해제: Unchecked_Deallocation으로 메모리를 해제한 후, 해당 메모리를 가리키던 다른 접근 변수가 남아있을 때 발생합니다.

2. 메모리 누수 (Memory Leaks)

메모리 누수new 연산자로 힙(heap)에 동적으로 할당된 메모리가 더 이상 어떤 접근 값으로도 참조되지 않아 사용할 수 없게 되었음에도 불구하고, 해제되지 않아 시스템 자원을 계속 점유하는 현상입니다.

메모리 누수는 힙에 할당된 객체를 가리키는 마지막 접근 값을 잃어버릴 때 발생합니다.

코드 예시 10-4: 메모리 누수 발생

procedure Memory_Leak_Example is
   type Integer_Access is access Integer;
   Ptr : Integer_Access;
begin
   for I in 1 .. 1_000 loop
      -- 루프가 돌 때마다 새로운 메모리를 할당하여 Ptr에 대입합니다.
      Ptr := new Integer'(I);
      -- 이전 반복에서 할당했던 메모리를 가리키던 접근 값은 덮어써지면서
      -- 사라지고, 해당 메모리를 해제할 방법이 없어 누수가 발생합니다.
   end loop;
end Memory_Leak_Example;

이러한 누수가 장시간 실행되는 프로그램에서 반복되면, 가용 메모리가 점차 고갈되어 결국 Storage_Error 예외와 함께 프로그램이 중단될 수 있습니다.

11.3.2 컴파일 시점 방어: 접근성 검사 (Accessibility Checks)

Ada는 스코프 종료로 인해 발생하는 댕글링 포인터 문제를 방지하기 위해, 접근성 검사(Accessibility Checks)라는 독창적이고 강력한 안전장치를 갖추고 있습니다. 이 검사는 프로그램 실행 중이 아닌 컴파일 시점에 수행되므로, 잠재적인 오류를 사전에 원천적으로 차단합니다.

핵심 규칙은 객체와 접근 타입의 생명주기(lifetime)를 비교하는 것입니다.

접근성 규칙: 수명이 긴 포인터가 수명이 짧은 객체를 가리키도록 허용하지 않습니다.

코드 예시 10-5: 접근성 검사 위반 (컴파일 오류)

다음은 함수 내의 지역 변수 주소를 함수 밖으로 반환하려는, 전형적인 댕글링 포인터 생성 시도입니다.

function Get_Invalid_Address return Integer_Access is
   Local_Var : aliased Integer := 10;
begin
   -- Local_Var는 이 함수가 끝나면 소멸됩니다.
   -- 함수 밖에서도 살아남는 포인터가 이 주소를 가리키는 것은 위험합니다.
   return Local_Var'access; -- 컴파일 오류 발생!
end Get_Invalid_Address;

Ada 컴파일러는 Local_Var의 생명주기가 반환되는 접근 값의 생명주기보다 짧다는 것을 정적으로 분석하여 이 코드를 거부합니다.

코드 예시 10-6: 유효한 접근

반대로, 수명이 짧은 포인터가 수명이 긴 객체를 가리키는 것은 안전하며 허용됩니다.

Global_Var : aliased Integer := 100;

procedure Safe_Access is
   Local_Ptr : Integer_Access;
begin
   -- 수명이 짧은 Local_Ptr가 수명이 긴 Global_Var를 가리키는 것은 안전합니다.
   Local_Ptr := Global_Var'access;
   -- Local_Ptr는 이 프로시저가 끝나면 먼저 소멸되므로 문제가 없습니다.
end Safe_Access;

이처럼 접근성 검사는 디버깅하기 가장 어려운 종류의 메모리 오류를 프로그램 실행 전에 근절함으로써, Ada의 안전 철학을 보여주는 핵심적인 기능입니다.

11.3.3 예외적 허용: 'Unchecked_Access와 프로그래머의 책임

Ada의 접근성 검사는 강력한 안전장치이지만, 극히 예외적인 저수준 프로그래밍 상황에서는 프로그래머가 컴파일러보다 객체의 생명주기를 더 잘 알고 있음을 확신하고 이 안전장치를 의도적으로 우회해야 할 필요가 있습니다.

이를 위해 Ada는 'Unchecked_Access 속성을 제공합니다. 이 속성은 이름 그대로, 접근성 규칙 검사를 수행하지 않는(unchecked) 버전의 'access입니다.

'Unchecked_Access를 사용하는 순간, 댕글링 포인터를 방지할 책임은 전적으로 컴파일러에서 프로그래머에게로 이전됩니다. 만약 프로그래머의 판단이 잘못되었다면, 그 결과 생성된 댕글링 포인터는 예측 불가능한 런타임 오류나 메모리 오염을 유발할 것입니다.

function get_dangerous_address return Integer_Access is
   local_var : aliased Integer := 10;
begin
   -- 접근성 검사를 우회하므로 컴파일러는 이 코드를 허용합니다.
   -- 하지만 이는 명백한 버그로, 댕글링 포인터를 반환하게 됩니다.
   return local_var'Unchecked_Access;
end get_dangerous_address;

이처럼 위험함에도 불구하고 'Unchecked_Access는 인터럽트 핸들러나 사용자 정의 메모리 관리자를 구현하는 등, 언어의 정적 안전 모델에서 벗어나야 하는 불가피한 상황을 위해 존재합니다.

결론적으로, 이 속성은 Ada의 핵심 안전장치를 의도적으로 비활성화하는 기능이므로 일반적인 애플리케이션 프로그래밍에서의 사용은 엄격히 제한되어야 합니다. 코드 내에 이 속성이 나타나는 것은 코드 리뷰 시 특별한 검토가 필요함을 의미하며, 그 사용의 정당성은 명확한 기술적 근거를 통해 입증되어야 합니다.

11.4 현대적 접근법: Ada.Containers를 이용한 안전한 동적 데이터

이전 절들에서 접근 타입을 사용하여 동적 메모리를 직접 할당(new)하고 해제(Unchecked_Deallocation)하는 저수준 기법을 살펴보았습니다. 이러한 수동 메모리 관리는 유연성을 제공하지만, 프로그래머에게 댕글링 포인터나 메모리 누수와 같은 심각한 오류에 대한 모든 책임을 부여합니다.

현대적인 Ada 프로그래밍에서는 이러한 위험을 피하고 생산성을 높이기 위해, 잘 검증되고 사용하기 쉬운 표준 라이브러리인 Ada.Containers의 사용이 적극적으로 권장됩니다. 이 라이브러리는 동적 데이터 관리를 위한 포괄적인 자료구조(Data Structures) 집합을 제공합니다.

11.4.1 Ada.Containers 개요 및 수동 구현과의 비교

Ada.Containers는 가장 흔하게 사용되는 자료구조들을 구현한 제네릭 패키지들의 모음입니다. 이 컨테이너들은 내부적으로 메모리 관리를 자동으로 수행하므로, 프로그래머는 저수준의 포인터 조작이나 명시적 메모리 해제에 대해 신경 쓸 필요가 없습니다.

주요 컨테이너 패키지는 다음과 같습니다.

  • Ada.Containers.Vectors: 크기가 동적으로 변하는 배열 (동적 배열)
  • Ada.Containers.Doubly_Linked_Lists: 양방향 연결 리스트
  • Ada.Containers.Hashed_Maps: 키-값 쌍을 저장하는 해시 맵
  • Ada.Containers.Ordered_Sets: 정렬된 상태로 요소를 저장하는 집합

수동 구현과의 비교

10.2.4절에서 연결 리스트를 수동으로 구현하기 위해 불완전 타입 선언, new를 통한 노드 생성, 포인터 연결, 그리고 Unchecked_Deallocation을 이용한 복잡한 해제 절차가 필요했던 것을 상기해 봅시다.

Ada.Containers.Doubly_Linked_Lists를 사용하면 이 모든 과정이 몇 줄의 코드로 대체됩니다.

with Ada.Containers.Doubly_Linked_Lists;
...
package Integer_Lists is new Ada.Containers.Doubly_Linked_Lists
  (Element_Type => Integer);
use Integer_Lists;

My_List : List;
My_List.append (10);
My_List.append (20);

이 접근 방식은 단순히 코드가 간결해지는 것을 넘어, 메모리 누수나 댕글링 포인터와 같은 치명적인 버그가 발생할 가능성을 원천적으로 차단하므로 훨씬 더 안전하고 신뢰성이 높습니다.

특별한 이유가 없는 한, 동적 데이터 관리는 Ada.Containers를 활용하는 것이 안전성과 생산성 측면에서 월등히 효율적인 접근 방식입니다.

11.4.2 사용 패턴: 동적 배열(Vector) 예제

Ada.Containers의 사용법은 일반적으로 다음 세 단계의 패턴을 따릅니다.

  1. 패키지 인스턴스화: 사용하고자 하는 컨테이너 제네릭 패키지를 저장할 요소의 타입에 맞게 인스턴스화합니다.
  2. 컨테이너 객체 선언: 인스턴스화된 패키지 내부의 컨테이너 타입(예: Vector)으로 변수를 선언합니다.
  3. API를 통한 조작: 컨테이너가 제공하는 API(프로시저 및 함수)를 사용하여 데이터를 조작합니다.

코드 예시 10-7: Ada.Containers.Vectors 사용

with Ada.Text_IO;
with Ada.Containers.Vectors;

procedure container_vector_example is
  -- 1. 패키지 인스턴스화
  -- 정수(Integer)를 요소로 갖는 벡터 패키지를 생성합니다.
  package Integer_Vectors is new Ada.Containers.Vectors
    (Index_Type   => Positive,
     Element_Type => Integer);
  use Integer_Vectors;

  -- 2. 컨테이너 객체 선언
  my_vector : Vector; -- 비어있는 벡터 객체가 생성됩니다.

begin
  -- 3. API를 통한 조작
  my_vector.append (10); -- 벡터의 끝에 10 추가
  my_vector.append (20);
  my_vector.append (30);

  Ada.Text_IO.put_line ("Vector length: " & my_vector.length'image);
  Ada.Text_IO.put_line ("First element: " & my_vector.element (1)'image);

  Ada.Text_IO.put ("All elements:");
  for item of my_vector loop
     Ada.Text_IO.put (item'image);
  end loop;
  Ada.Text_IO.new_line;

end container_vector_example;

실행 결과:

Vector length:  3
First element:  10
All elements: 10 20 30

위 코드에서 my_vector의 메모리는 append 호출 시 자동으로 늘어나며, my_vector가 스코프를 벗어날 때 자동으로 해제됩니다. 프로그래머는 newAda.Unchecked_Deallocation을 전혀 호출할 필요가 없습니다.

11.5 [심화] 동적 디스패칭과 콜백: 접근-서브프로그램 타입

지금까지 10장에서 다룬 접근 타입은 모두 데이터 객체(object)를 가리키는 것이었습니다. 하지만 Ada의 접근 타입은 여기서 멈추지 않고, 서브프로그램(프로시저 또는 함수) 자체를 가리키는 능력까지 제공합니다. 이를 접근-서브프로그램 타입(access-to-subprogram type)이라고 하며, 이는 C나 C++의 함수 포인터(function pointer)에 해당하는 Ada의 타입-안전(type-safe)한 기능입니다.

이 기능을 통해 서브프로그램을 변수에 저장하거나, 다른 서브프로그램에 매개변수로 전달하는 등 동적인 방식으로 호출할 대상을 결정할 수 있습니다. 이는 콜백(callback) 메커니즘, 디스패치 테이블(dispatch table), 전략(strategy) 디자인 패턴 등 유연하고 확장 가능한 소프트웨어 아키텍처를 구축하는 데 필수적인 도구입니다.

11.5.1 선언, 사용법 및 C 함수 포인터와의 비교

선언 및 사용법

접근-서브프로그램 타입은 access 키워드 뒤에 대상이 되는 서브프로그램의 완전한 프로파일(profile)을 명시하여 선언합니다.

선언 구문:

-- 프로시저를 가리키는 접근 타입
type <Type_Name> is access procedure (parameter_profile);

-- 함수를 가리키는 접근 타입
type <Type_Name> is access function (parameter_profile) return <Return_Type>;

사용법은 다음 두 단계로 이루어집니다.

  1. 주소 얻기 ('access 속성): 서브프로그램의 이름에 'access 속성을 붙여 해당 서브프로그램의 주소를 얻습니다.
  2. 호출: 접근 타입 변수를 일반적인 서브프로그램 호출과 동일한 구문으로 호출합니다.

코드 예시 10-8: 접근-서브프로그램 타입의 동적 호출

procedure Subprogram_Pointer_Demo is
   -- 문자열을 받아 처리하는 프로시저를 가리킬 타입 선언
   type Event_Handler is access procedure (Message : in String);

   procedure Log_To_Console (Message : in String) is
   begin
      Ada.Text_IO.put_line ("CONSOLE: " & Message);
   end Log_To_Console;

   -- 현재 사용할 이벤트 핸들러를 저장할 변수
   Current_Handler : Event_Handler;
begin
   -- Log_To_Console의 주소를 할당
   Current_Handler := Log_To_Console'access;
   -- 할당된 프로시저를 동적으로 호출
   Current_Handler ("Application started.");
end Subprogram_Pointer_Demo;

C 함수 포인터와의 비교: 타입 안전성

C 언어의 함수 포인터도 유사한 기능을 제공하지만, Ada의 접근-서브프로그램 타입은 타입 안전성 측면에서 근본적인 우위를 가집니다.

  • C 함수 포인터: C에서는 서로 다른 시그니처를 가진 함수 포인터들 사이에 캐스팅(casting)이 가능하며, 이는 프로그래머의 실수로 인해 스택을 오염시키거나 정의되지 않은 동작을 유발하는 심각한 런타임 오류의 원인이 될 수 있습니다.
  • Ada 접근-서브프로그램 타입: Ada 컴파일러는 서브프로그램의 프로파일 전체(모든 파라미터의 타입, 모드, 순서 및 반환 타입)가 정확히 일치하는지 컴파일 시점에 엄격하게 검사합니다. 프로파일이 일치하지 않는 서브프로그램의 'access를 할당하려는 시도는 즉시 컴파일 오류로 처리됩니다.

이러한 정적 검증은 Ada가 동적인 기능을 제공할 때조차 신뢰성을 최우선으로 고려하는 설계 철학을 명확하게 보여줍니다.

11.5.2 활용 패턴: 콜백 메커니즘

접근-서브프로그램 타입의 가장 대표적인 활용 사례는 콜백(callback) 메커니즘입니다. 콜백은 범용적인 기능을 수행하는 서브프로그램이, 특정 이벤트가 발생했을 때 호출해야 할 구체적인 동작을 외부로부터 전달받는 설계 패턴입니다.

다음은 정수 배열에서 특정 조건을 만족하는 모든 원소를 찾아, 발견할 때마다 외부에서 제공한 ‘액션’ 프로시저를 호출해주는 범용 Find_And_Act 프로시저의 예입니다.

코드 예시 10-9: 콜백 메커니즘

with Ada.Text_IO;

procedure callback_example is
   type Integer_Array is array (Positive range <>) of Integer;

   -- 콜백으로 전달될 프로시저의 프로파일을 정의하는 접근 타입
   type Action_Callback is access procedure (value : in Integer);

   -- 범용 검색 및 처리 프로시저
   procedure find_and_act
     (data     : in Integer_Array;
      is_match : access function (value : Integer) return Boolean;
      action   : in Action_Callback)
   is
   begin
      for i in data'range loop
         if is_match (data (i)) then
            -- 조건 만족 시, 전달받은 Action 프로시저를 '콜백'
            action (data (i));
         end if;
      end loop;
   end find_and_act;

   -- --- 콜백으로 사용할 구체적인 서브프로그램들 ---
   function is_even (value : Integer) return Boolean is (value mod 2 = 0);
   procedure print_value (value : in Integer) is
   begin
      Ada.Text_IO.put (value'image & " ");
   end print_value;

   my_data : constant Integer_Array := (1, 2, 3, 4, 5, 6, 7, 8);

begin
   Ada.Text_IO.put_line ("짝수 찾기:");
   -- 'is_even' 함수와 'print_value' 프로시저를 콜백으로 전달
   find_and_act
     (data     => my_data,
      is_match => is_even'access,
      action   => print_value'access);
   Ada.Text_IO.new_line;

end callback_example;

실행 결과:

짝수 찾기:
 2  4  6  8

find_and_act 프로시저는 “어떻게 찾을지(is_match)”와 “찾았을 때 무엇을 할지(action)”에 대한 구체적인 내용을 전혀 모릅니다. 오직 약속된 프로파일을 가진 서브프로그램을 호출할 뿐입니다. 이처럼 접근-서브프로그램 타입을 사용하면 알고리즘과 정책을 완벽하게 분리하여 코드의 재사용성과 유연성을 극대화할 수 있습니다.

12. 제네릭 (Generics): 타입 독립적인 코드 설계

(도입부)

12.1 제네릭의 개념과 필요성

지금까지 우리는 서브프로그램과 패키지를 통해 코드의 재사용성을 높이는 방법을 배웠습니다. 하지만 이러한 재사용은 ‘특정 타입’에 종속되는 한계를 가집니다. 예를 들어, 두 정수(Integer)의 값을 교환하는 Swap 프로시저를 작성했다면, 두 실수(Float)나 두 Date 타입의 값을 교환하기 위해서는 거의 동일한 로직의 코드를 타입만 바꾸어 새로 작성해야 합니다.

procedure Swap_Integers (A, B : in out Integer);
procedure Swap_Floats   (A, B : in out Float);
procedure Swap_Dates    (A, B : in out Date);
-- ... 타입마다 별도의 Swap 프로시저가 필요하다.

이러한 코드의 중복은 유지보수를 어렵게 만들고, 잠재적인 버그의 원인이 됩니다. 만약 교환 로직에 개선이 필요하다면, 모든 복사본을 찾아 일일이 수정해야 하기 때문입니다.

타입 독립적인 코드: 제네릭의 필요성

제네릭(Generic)은 바로 이 문제를 해결하기 위한 Ada의 강력한 추상화 기능입니다. 제네릭을 사용하면, 특정 타입에 얽매이지 않는 타입 독립적인(type-independent) 코드 단위를 작성할 수 있습니다.

제네릭 유닛(generic unit)은 그 자체로 직접 사용할 수 있는 서브프로그램이나 패키지가 아니라, 일종의 ‘틀(template)’ 또는 ‘설계도(blueprint)’입니다. 이 ‘틀’에는 타입이나 값, 심지어 다른 서브프로그램까지 들어갈 수 있는 비어있는 ‘구멍(placeholder)’들이 있으며, 이 구멍을 제네릭 매개변수(generic formal parameter)라고 부릅니다.

‘틀’과 ‘인스턴스’

제네릭 ‘틀’을 실제로 사용하기 위해서는, 이 비어있는 구멍들을 우리가 원하는 구체적인 타입이나 값으로 채워 넣어 ‘인스턴스(instance)’를 만들어야 합니다. 이 과정을 인스턴스화(instantiation)라고 부릅니다.

  • 제네릭 유닛 (Generic Unit): 타입 독립적인 코드의 ‘틀’. 예를 들어, “어떤 타입(T)이든 두 개의 값을 교환하는 Swap 프로시저”가 여기에 해당합니다.
  • 인스턴스화 (Instantiation): 제네릭 유닛의 ‘틀’에 구체적인 타입(예: Integer)을 지정하여, 실제로 호출 가능한 코드 단위를 생성하는 과정입니다. new 키워드를 사용합니다.
  • 인스턴스 (Instance): 인스턴스화를 통해 생성된 구체적인 서브프로그램이나 패키지. Integer_Swap이나 Float_Swap과 같이 특정 타입에 대해 동작하는 실제 코드입니다.
-- 1. 'T' 라는 타입을 매개변수로 받는 제네릭 '틀' 정의
generic
   type T is private; -- T는 어떤 타입이든 될 수 있음 (제네릭 타입 매개변수)
procedure Generic_Swap (A, B : in out T);

-- 2. 제네릭 프로시저의 구현부
procedure Generic_Swap (A, B : in out T) is
   Temp : constant T := A;
begin
   A := B;
   B := Temp;
end Generic_Swap;

-- 3. '틀'로부터 'Integer' 타입용 인스턴스 생성
procedure Integer_Swap is new Generic_Swap (T => Integer);

-- 4. '틀'로부터 'Date' 타입용 인스턴스 생성
procedure Date_Swap is new Generic_Swap (T => Date);

이처럼 제네릭은 알고리즘의 로직을 타입으로부터 완전히 분리하여, 코드의 재사용성을 극대화하고 강력한 타입 안전성을 유지하면서 높은 수준의 추상화를 가능하게 하는 핵심적인 기능입니다. 이번 장에서는 이러한 제네릭 유닛을 정의하고, 다양한 종류의 제네릭 매개변수를 활용하며, 실제 인스턴스를 만들어 사용하는 모든 과정을 깊이 있게 학습합니다.

12.1.1 코드 중복 문제와 제네릭 해법

소프트웨어 공학의 가장 기본이 되는 원칙 중 하나는 “반복하지 마라(Don’t Repeat Yourself, DRY)”입니다. 코드의 중복은 프로그램의 크기를 불필요하게 늘릴 뿐만 아니라, 유지보수 과정에서 심각한 문제를 야기하는 주된 원인이기 때문입니다.

타입에 종속된 코드의 한계

예를 들어, 두 변수의 값을 맞바꾸는 간단한 Swap 프로시저를 생각해 봅시다. Integer 타입을 위한 프로시저는 다음과 같이 작성할 수 있습니다.

procedure Swap_Integers (A, B : in out Integer) is
   Temp : constant Integer := A;
begin
   A := B;
   B := Temp;
end Swap_Integers;

이제 Float 타입의 값을 교환할 필요가 생겼다면, 우리는 어쩔 수 없이 위와 거의 동일한 코드를 복사-붙여넣기 하여 타입만 변경한 새로운 프로시저를 만들어야 합니다.

procedure Swap_Floats (A, B : in out Float) is
   Temp : constant Float := A;
begin
   A := B;
   B := Temp;
end Swap_Floats;

만약 Date 타입, Vector 타입 등 교환이 필요한 새로운 타입이 생길 때마다 이 작업은 계속해서 반복될 것입니다. 이러한 접근 방식은 다음과 같은 심각한 문제점을 가집니다.

  • 유지보수의 어려움: 만약 최초의 교환 로직에 버그가 있었거나, 더 효율적인 방식으로 개선해야 할 경우, 프로그래머는 프로젝트 전체에 흩어져 있는 모든 Swap_... 프로시저를 찾아 일일이 수정해야 합니다. 하나라도 놓치면 프로그램 전체의 동작 일관성이 깨지게 됩니다.
  • 오류 발생 가능성 증가: 수동으로 코드를 복사하고 수정하는 과정은 실수를 유발하기 쉽습니다.
  • 코드의 비대화: 동일한 논리를 가진 코드가 여러 벌 존재하게 되어 소스 코드가 불필요하게 커지고 복잡해집니다.

제네릭 해법: 알고리즘과 타입의 분리

제네릭(Generic)은 이러한 문제를 근본적으로 해결합니다. 제네릭의 핵심 아이디어는 알고리즘(수행되는 논리)을 그것이 처리하는 데이터의 타입으로부터 분리하는 것입니다.

Swap의 경우, ‘임시 변수를 이용해 두 값을 맞바꾼다’는 알고리즘은 교환되는 값의 타입이 무엇인지와는 무관합니다. 제네릭을 사용하면 이 공통적인 알고리즘을 단 한 번만 정의하는 ‘틀(template)’을 만들 수 있습니다.

-- "어떤 타입이든 교환할 수 있는" 제네릭 Swap 프로시저 '틀'
generic
   type Element_Type is private; -- 교환할 요소의 타입을 매개변수로 받음
procedure Generic_Swap (A, B : in out Element_Type);

procedure Generic_Swap (A, B : in out Element_Type) is
   Temp : constant Element_Type := A;
begin
   A := B;
   B := Temp;
end Generic_Swap;

Generic_Swap은 특정 타입(IntegerFloat)에 얽매이지 않습니다. 대신, Element_Type이라는 ‘타입 매개변수’를 통해 “어떤 타입이든 들어올 수 있다”고 정의합니다.

이제 우리는 이 하나의 ‘틀’로부터 필요한 타입의 Swap 프로시저 인스턴스(instance)를 마치 공장에서 제품을 찍어내듯 만들어낼 수 있습니다.

procedure Swap_Integers is new Generic_Swap (Element_Type => Integer);
procedure Swap_Floats   is new Generic_Swap (Element_Type => Float);

이제 Swap의 로직은 Generic_Swap이라는 단 하나의 소스에만 존재합니다. 로직 수정이 필요하면 이 한 곳만 고치면 되고, Swap_IntegersSwap_Floats를 포함하여 이 ‘틀’로부터 생성된 모든 인스턴스에 변경 사항이 자동으로 반영됩니다.

이처럼 제네릭은 코드 중복을 제거하고, 타입 안전성은 그대로 유지하면서 재사용성을 극대화하여 견고하고 유지보수하기 쉬운 소프트웨어를 만드는 핵심적인 해법을 제공합니다.

12.1.2 제네릭 유닛의 ‘틀(template)’과 ‘인스턴스(instance)’

Ada 제네릭의 강력함은 추상적인 ‘틀’(template)과 구체적인 ‘인스턴스’(instance)라는 두 가지 개념을 명확히 분리하는 데서 나옵니다. 이 두 개념의 관계를 이해하는 것이 제네릭을 올바르게 사용하는 데 있어 가장 중요합니다.

제네릭 유닛: 재사용 가능한 코드의 ‘틀’

제네릭 유닛(Generic Unit)은 그 자체로 완성된 코드 단위가 아닙니다. 이는 실제 서브프로그램이나 패키지를 만들어내기 위한 ‘설계도’ 또는 ‘틀’입니다. 이 ‘틀’에는 알고리즘의 핵심 로직이 담겨 있지만, 특정 데이터 타입에 대한 부분은 비워져 있습니다.

제네릭 유닛은 두 부분으로 구성됩니다.

  1. 제네릭 선언부 (generic ...): ‘틀’에 필요한 ‘재료’를 정의하는 부분입니다. 어떤 종류의 타입, 어떤 값, 또는 어떤 서브프로그램이 필요한지를 명시하는 제네릭 매개변수(generic formal parameter) 목록이 위치합니다.
  2. 서브프로그램/패키지 명세 및 구현부: 제네릭 매개변수를 사용하여 작성된, 타입에 독립적인 실제 로직입니다.

쿠키 커터에 비유하자면, 제네릭 유닛은 ‘별 모양 쿠키 커터’와 같습니다. 이것은 별 모양을 정의하지만, 그 자체는 먹을 수 있는 쿠키가 아닙니다.

인스턴스화와 인스턴스: ‘틀’로부터 만들어진 실제 결과물

인스턴스화(Instantiation)는 제네릭이라는 추상적인 ‘틀’에 구체적인 ‘재료’(실제 타입, 값 등)를 넣어, 우리가 직접 호출하고 사용할 수 있는 실제 코드 단위를 만들어내는 과정입니다. 이는 ‘쿠키 커터’로 ‘반죽’을 찍어 ‘쿠키’를 만드는 행위에 해당합니다.

Ada에서는 new 키워드를 사용하여 인스턴스화를 수행합니다.

인스턴스(Instance)는 인스턴스화의 결과로 생성된, 완전히 구체화된 서브프로그램 또는 패키지입니다. 이 인스턴스는 더 이상 제네릭이 아니며, 일반적인 서브프로그램이나 패키지와 똑같이 사용할 수 있습니다.

-- 제네릭 '틀' 정의 (앞선 예제)
generic
   type Element_Type is private;
procedure Generic_Swap (A, B : in out Element_Type);

-- ... 구현부 생략 ...

-- '틀'로부터 실제 '결과물'을 만드는 과정 (인스턴스화)
-- 이제 Integer_Swap은 실제 호출 가능한 프로시저(인스턴스)가 된다.
procedure Integer_Swap is new Generic_Swap (Element_Type => Integer);

-- 변수 선언
My_Int_1 : Integer := 10;
My_Int_2 : Integer := 20;

-- 인스턴스 호출
Integer_Swap (My_Int_1, My_Int_2);

-- Generic_Swap (My_Int_1, My_Int_2); -- 🚨 컴파일 오류! '틀'은 직접 호출할 수 없다.

new 키워드에 대한 주의사항: 제네릭 인스턴스화에 사용되는 new는 접근 타입(access type)에서 동적 메모리를 할당하는 new와는 역할이 다릅니다. 여기서 new는 컴파일러에게 “이 제네릭 틀을 사용하여 새로운 코드 단위를 컴파일 시점에 생성하라”고 지시하는 키워드입니다. 이 과정은 런타임이 아닌 컴파일 시점에 일어나므로, 생성된 인스턴스의 호출 성능은 일반 서브프로그램과 동일합니다.

구분 제네릭 유닛 (The ‘Template’) 인스턴스 (The ‘Instance’)
정의 코드 생성을 위한 ‘틀’, ‘설계도’ ‘틀’로부터 생성된 실제 코드 단위, ‘제품’
사용 직접 호출하거나 사용할 수 없음 일반 서브프로그램/패키지처럼 호출하고 사용함
생성 generic ... 구문으로 정의 is new ... 구문으로 생성 (인스턴스화)
존재 시점 소스 코드 상에만 존재하는 추상적 단위 컴파일 후 실행 코드에 존재하는 구체적 실체
예시 generic procedure Generic_Swap(...) procedure Integer_Swap is new Generic_Swap(...)

12.2 제네릭 매개변수: 값, 타입, 서브프로그램

Ada 제네릭의 유연성과 강력함은 인스턴스화 시에 전달할 수 있는 제네릭 형식 매개변수(Generic Formal Parameter) 의 다양성에서 나옵니다. 다른 언어의 제네릭이 주로 타입(type)에 국한되는 것과 달리, Ada는 값(value), 타입(type), 서브프로그램(subprogram) 세 가지 종류의 매개변수를 모두 허용합니다. 이를 통해 컴포넌트의 동작을 매우 정밀하게 조정하고 확장할 수 있습니다.

12.2.1 값 매개변수 (Generic Formal Objects)

값 매개변수는 제네릭 유닛의 각 인스턴스에 상수 값을 전달하기 위해 사용됩니다. 이 값은 인스턴스가 생성될 때 고정되며, 인스턴스 내부에서 설정 값이나 상수처럼 활용됩니다. 값 매개변수는 항상 in 모드입니다.

11.1절의 Generic_Stack 예제에서 Initial_Capacity가 바로 값 매개변수입니다.

generic
   type Item is private;
   -- Initial_Capacity는 인스턴스별 스택 용량을 결정하는 값 매개변수입니다.
   Initial_Capacity : Positive := 100;
package Generic_Stack is
   ...
end Generic_Stack;

이를 통해 사용자는 필요에 따라 각기 다른 용량을 가진 스택 인스턴스를 생성할 수 있습니다.

-- 기본값(100) 용량을 사용하는 스택
package Stack_1 is new Generic_Stack (Item => Integer);

-- 용량을 500으로 지정하는 스택
package Stack_2 is new Generic_Stack (Item => Integer, Initial_Capacity => 500);

12.2.2 타입 매개변수 (Generic Formal Types)

타입 매개변수는 제네릭의 가장 기본적인 요소로, 제네릭 유닛이 다양한 데이터 타입에 대해 동작하도록 만듭니다. Ada는 타입 매개변수를 선언하는 방식에 따라 제네릭 내부에서 해당 타입에 허용되는 연산을 제한하는 계약(contract) 을 정의합니다. 이는 타입 안전성을 보장하는 핵심적인 특징입니다.

  • type T is private; 가장 일반적인 형태로, T 타입에 대해 할당(:=)과 동등 비교(=, /=)만을 허용합니다. 데이터를 저장하고 꺼내는 단순 컨테이너(스택, 큐, 리스트 등)에 적합합니다.

  • type T is (<>); (박스 구문) T가 정수, 문자, 열거형과 같은 이산 타입(discrete type) 임을 명시합니다. 제네릭 내부에서 T'first, T'last, T'Pos, T'Val과 같은 이산 타입의 속성(attribute)을 사용할 수 있습니다.

  • type T is tagged; T태그드 타입임을 명시합니다. 이를 통해 객체 지향적인 제네릭 컴포넌트를 만들 수 있으며, 상속 및 다형성과 연계하여 사용할 수 있습니다.

  • type T is access ...; T접근 타입(access type) 임을 명시합니다.

12.2.3 서브프로그램 매개변수 (Generic Formal subprograms)

서브프로그램 매개변수는 제네릭 유닛에 특정 동작이나 알고리즘(전략)을 주입할 수 있게 해주는 매우 강력한 기능입니다. with 키워드를 사용하여 함수나 프로시저를 매개변수로 선언합니다.

예를 들어, 임의의 배열을 정렬하는 제네릭 프로시저를 작성한다고 가정해 봅시다. 정렬을 위해서는 두 요소의 대소를 비교하는 방법이 필요합니다. 이 비교 방법을 서브프로그램 매개변수로 전달받을 수 있습니다.

generic
   type Item is private;
   type Item_Array is array (Positive range <>) of Item;
   -- 두 'Item'을 비교할 함수를 매개변수로 받습니다.
   with function compare (left, right : Item) return Boolean;
procedure generic_sort (items : in out Item_Array);

더 나아가, is <> (박스 구문)를 사용하여 서브프로그램 매개변수의 기본값을 지정할 수 있습니다. is <>는 “인스턴스화되는 시점에 보이는 서브프로그램 중 이름과 프로파일이 일치하는 것을 사용하라”는 의미입니다.

generic
   type Item is private;
   type Item_Array is array (Positive range <>) of Item;
   -- 기본값으로 "<" 연산자를 사용하도록 지정
   with function "<" (left, right : Item) return Boolean is <>;
procedure generic_sort (items : in out Item_Array);

이제 이 generic_sortInteger 타입으로 인스턴스화하면, 컴파일러는 Integer에 대해 이미 정의된 < 연산자를 자동으로 찾아 매개변수로 사용합니다. 사용자가 정의한 레코드 타입에 대해 < 연산자를 직접 정의했다면, 그 또한 자동으로 사용됩니다.

-- Integer 타입을 위한 정렬 프로시저 인스턴스 생성
-- 별도로 "<" 함수를 지정하지 않아도, 표준 Integer의 "<"가 사용됨
procedure sort_integers is new generic_sort
  (Item       => Integer,
   Item_Array => Integer_Array);

Ada 제네릭은 값, 타입, 서브프로그램이라는 세 종류의 매개변수를 조합하여 매우 높은 수준의 추상화와 재사용성을 달성합니다.

  • 값 매개변수는 컴포넌트의 설정을 담당합니다.
  • 타입 매개변수는 데이터의 종류에 구애받지 않는 범용성을 제공합니다.
  • 서브프로그램 매개변수는 특정 동작이나 전략을 외부에서 주입하여 유연성을 극대화합니다.

이러한 다층적인 매개변수 시스템은 Ada가 단순한 코드 재사용을 넘어, 타입 안전성을 완벽하게 보장하면서도 고도로 적응 가능한 소프트웨어 컴포넌트를 구축할 수 있게 하는 근간이 됩니다.

12.2.4 [심화] 패키지 매개변수 (Packages)

지금까지 우리는 제네릭 매개변수로 값(value), 타입(type), 그리고 개별 서브프로그램(subprogram)을 사용하는 방법을 배웠습니다. 이는 알고리즘을 데이터와 그 데이터에 대한 개별 연산으로부터 분리할 수 있게 해주었습니다.

Ada 2005에서는 여기서 한 걸음 더 나아가, 패키지 자체를 제네릭 매개변수로 전달할 수 있는, 매우 강력한 추상화 기능인 제네릭 형식 패키지(generic formal package)가 도입되었습니다. 이는 관련된 타입, 상수, 서브프로그램, 예외 등 하나의 완전한 기능 모듈(패키지) 전체를 제네릭 유닛의 동작을 결정하는 ‘재료’로 사용하는 것입니다.

왜 패키지 매개변수가 필요한가?

복잡한 제네릭 컴포넌트를 작성한다고 상상해 봅시다. 예를 들어, 그래프(Graph)의 노드를 순회하는 알고리즘을 담은 제네릭 패키지 Graph_Algorithms를 만든다고 가정합시다. 이 알고리즘은 순회할 노드 목록을 저장할 ‘컨테이너’ 자료구조가 필요합니다.

사용자는 이 컨테이너로 Ada.Containers.Vectors를 쓸 수도 있고, Ada.Containers.Doubly_Linked_Lists를 쓸 수도 있습니다. 만약 패키지 매개변수가 없다면, Graph_Algorithms의 제네릭 선언부는 다음과 같이 매우 복잡하고 장황해질 것입니다.

generic
   type Node_Type is private;
   type Container_Type is private; -- Vector 또는 List
   type Cursor_Type is private;    -- Vector나 List의 커서
   procedure Append (Container : in out Container_Type; Element : in Node_Type);
   function First (Container : Container_Type) return Cursor_Type;
   -- ... 컨테이너에 필요한 모든 서브프로그램을 개별 매개변수로 전달 ...
package Graph_Algorithms_Complex;

이는 사용하기에 매우 불편하며, 컨테이너의 모든 인터페이스를 일일이 매개변수로 나열해야 하는 비현실적인 요구사항입니다.

패키지 매개변수 구문과 활용

제네릭 형식 패키지는 이러한 문제를 매우 우아하게 해결합니다. with package ... is new ... 구문을 사용하여, 특정 제네릭 패키지의 ‘인스턴스’를 매개변수로 요구할 수 있습니다.

-- 위 복잡한 선언을 패키지 매개변수로 단순화
generic
   type Node_Type is private;
   -- Container_Services는 Ada.Containers.Vectors의 인스턴스여야 한다는 '계약'
   with package Container_Services is new Ada.Containers.Vectors (<>);
package Graph_Algorithms_Simple;

with package Container_Services is new Ada.Containers.Vectors (<>); 구문은 다음과 같은 의미를 갖습니다.

  • with package Container_Services: Container_Services라는 이름의 형식 패키지 매개변수를 선언합니다.
  • is new Ada.Containers.Vectors (<>): 이 매개변수로 전달될 실제 패키지는 반드시 제네릭 패키지 Ada.Containers.Vectors의 인스턴스여야 한다는 계약을 명시합니다. <>는 인스턴스화 시 사용된 매개변수는 무엇이든 상관없다는 의미입니다.

전체 활용 예제:

-- 1. 그래프 노드 순회 알고리즘을 담은 제네릭 패키지 '틀'
generic
   type Node is private;
   -- 이 제네릭은 'List'와 'Append' 등을 제공하는 패키지를 필요로 함
   with package Node_Container is new Ada.Containers.Doubly_Linked_Lists (Element_Type => Node);
package Traverser is
   procedure DFS (Start_Node : Node); -- 깊이 우선 탐색
end Traverser;

package body Traverser is
   procedure DFS (Start_Node : Node) is
      -- 형식 패키지 매개변수로 받은 패키지의 타입과 서브프로그램을 사용
      Visited : Node_Container.List;
   begin
      Node_Container.Append (Visited, Start_Node);
      -- ...
   end DFS;
end Traverser;

-- 2. 실제 사용할 구체적인 타입을 정의
type My_Node is range 1 .. 100;

-- 3. 제네릭의 '재료'가 될 패키지 인스턴스를 먼저 생성
package My_Node_Lists is new Ada.Containers.Doubly_Linked_Lists (Element_Type => My_Node);

-- 4. '재료'를 전달하여 최종 인스턴스 생성
package My_Node_Traverser is new Traverser (Node => My_Node, Node_Container => My_Node_Lists);

-- 5. 최종 인스턴스 사용
-- My_Node_Traverser.DFS (5);

장점 및 의의

  • 최고 수준의 추상화: 데이터 타입이나 개별 연산을 넘어, ‘저장소 모듈’, ‘로깅 서비스’와 같은 기능의 집합 전체를 추상화하고 교체할 수 있게 해줍니다. 이는 의존성 주입(Dependency Injection) 패턴의 가장 강력한 형태 중 하나입니다.
  • API의 단순화: 수십 개의 제네릭 매개변수를 단 하나의 패키지 매개변수로 대체하여 제네릭 유닛의 인터페이스를 매우 깔끔하게 유지합니다.
  • 컴포넌트의 합성: 서로 다른 제네릭 컴포넌트들을 마치 레고 블록처럼 조립하여 더 크고 복잡한 시스템을 구축하는 것을 가능하게 합니다.

제네릭 형식 패키지는 매우 고급 기능이지만, 재사용 가능한 대규모 컴포넌트 라이브러리를 설계하거나 고도로 유연하고 분리된(decoupled) 아키텍처를 구축할 때 없어서는 안 될 핵심적인 도구입니다.

12.3 제네릭 인스턴스화 (Instantiation)

이전 절에서 우리는 타입, 값, 서브프로그램, 심지어 패키지까지 매개변수화하여 매우 유연하고 추상적인 제네릭 ‘틀’을 설계하는 방법을 배웠습니다. 하지만 이 ‘틀’ 자체는 아직 직접 사용할 수 있는 코드가 아닙니다. 제네릭 서브프로그램을 직접 호출하거나 제네릭 패키지의 타입을 선언할 수는 없습니다.

추상적인 설계도를 실제 건물로 짓는 과정이 필요하듯, 제네릭 ‘틀’을 우리가 실제로 사용할 수 있는 구체적인 코드 단위로 만들어내는 과정이 필요합니다. 이 과정을 인스턴스화(Instantiation)라고 합니다.

인스턴스화는 제네릭 유닛을 정의할 때 비워두었던 ‘구멍’(제네릭 형식 매개변수)에 Integer, Float 같은 구체적인 타입이나 실제 값과 서브프로그램을 채워 넣어, 완전한 기능을 갖춘 새로운 서브프로그램이나 패키지 인스턴스(instance)를 생성하는 행위입니다.

Ada에서는 new 키워드를 사용하여 이 과정을 수행합니다. 여기서 new는 동적 메모리 할당이 아닌, 컴파일러에게 “이 제네릭 틀을 사용하여 명시된 재료들로 새로운 코드 단위를 생성하라”고 지시하는 컴파일 시점의 선언입니다.

이번 절에서는 제네릭 프로그래밍의 완성 단계인 인스턴스화의 정확한 구문과, 다양한 종류의 제네릭 매개변수에 실제 인자를 전달하는 구체적인 방법을 예제를 통해 상세히 학습합니다.

12.3.1 new를 이용한 구체화

제네릭 ‘틀’에 생명을 불어넣어 실제 코드 단위로 만드는 인스턴스화(Instantiation) 과정은 is new 라는 구문을 통해 이루어집니다. 이 과정은 제네릭 유닛을 정의할 때 비워두었던 모든 제네릭 매개변수(formal parameter)에 구체적인 실제 인자(actual parameter)를 제공함으로써 완성됩니다.

컴파일러는 이 정보를 바탕으로, 제네릭 ‘틀’의 소스 코드에 실제 인자들을 채워 넣어 완전히 새로운 서브프로그램이나 패키지를 생성합니다.

인스턴스화 기본 구문

제네릭 서브프로그램의 인스턴스화:

procedure 새_프로시저_이름 is new 제네릭_프로시저_이름 (제네릭_매개변수_연관);
function 새_함수_이름 is new 제네릭_함수_이름 (제네릭_매개변수_연관);

제네릭 패키지의 인스턴스화:

package 새_패키지_이름 is new 제네릭_패키지_이름 (제네릭_매개변수_연관);

제네릭_매개변수_연관 부분에서는 명명된 연관(Formal => Actual)을 사용하여 각 제네릭 매개변수에 어떤 실제 값을 사용할지 명시합니다.

실제 매개변수 전달하기

1. 값 매개변수 (Value Parameters)

제네릭 선언부의 값 매개변수에는 그 타입과 일치하는 정적인(static) 값이나 상수를 전달합니다.

-- 제네릭 '틀'
generic
   Max_Size : Positive; -- 값 매개변수
package Generic_Bounded_Stack is
   -- ...
end Generic_Bounded_Stack;

-- 인스턴스화
-- Max_Size에 100이라는 구체적인 값을 전달
package Stack_100 is new Generic_Bounded_Stack (Max_Size => 100);

2. 타입 매개변수 (Type Parameters)

타입 매개변수에는 실제 타입을 지정합니다. 이때 전달하는 실제 타입은 제네릭 선언부에서 요구한 ‘계약’을 반드시 만족해야 합니다.

-- 제네릭 '틀'
generic
   type Element is (<>); -- 이산(discrete) 타입만 가능하다는 '계약'
procedure Generic_Discrete_Proc (Item : Element);

-- 인스턴스화
procedure Process_Integer is new Generic_Discrete_Proc (Element => Integer); -- OK
procedure Process_Day is new Generic_Discrete_Proc (Element => Day_Of_Week); -- OK

-- procedure Process_Float is new Generic_Discrete_Proc (Element => Float);
-- 컴파일 오류! Float은 이산 타입이 아니므로 계약 위반

3. 서브프로그램 매개변수 (Subprogram Parameters)

서브프로그램 매개변수에는 명세가 일치하는 실제 서브프로그램의 이름을 전달합니다. 이는 제네릭 알고리즘의 특정 동작을 외부에서 주입하는 의존성 주입(Dependency Injection)의 핵심적인 방법입니다.

-- 제네릭 '틀'
generic
   type Item is private;
   -- Item 타입 두 개를 비교하는 함수를 매개변수로 요구
   with function Compare (Left, Right : Item) return Boolean;
function Generic_Max (A, B : Item) return Item;

-- ... 구현부 ...

-- 실제 타입과 비교 함수 정의
type Person is record
   Name : String (1 .. 10);
   Age  : Natural;
end record;

function Older (P1, P2 : Person) return Boolean is
begin
   return P1.Age > P2.Age;
end Older;

-- 인스턴스화
-- Compare 매개변수에 Older 함수를 전달
function Max_Person is new Generic_Max (Item => Person, Compare => Older);

is <> (Box) 표기법: 서브프로그램 매개변수를 선언할 때, is <>를 기본값으로 지정할 수 있습니다. with function "=" (Left, Right : T) return Boolean is <>; 이는 “만약 사용자가 =에 해당하는 실제 함수를 명시적으로 전달하면 그것을 사용하고, 그렇지 않으면 T 타입에 대해 기본적으로 사용 가능한 = 연산자를 찾아 사용하라”는 의미입니다. 이는 매우 흔하고 편리한 관용구입니다.

이처럼 인스턴스화는 제네릭이라는 강력한 추상화 도구를, 우리의 구체적인 문제 해결에 맞게 재단하여 사용하는 실질적인 과정입니다.

12.3.2 제네릭 서브프로그램 인스턴스화

제네릭 서브프로그램(프로시저 또는 함수)은 타입에 독립적인 알고리즘의 ‘틀’을 제공합니다. 이 ‘틀’을 실제로 사용 가능한 코드로 만들기 위해서는, 특정 타입을 지정하여 구체적인 인스턴스(instance)를 생성해야 합니다.

기본 인스턴스화

가장 간단한 형태의 인스턴스화는 제네릭 타입 매개변수(type T is private 등)만을 갖는 제네릭 서브프로그램을 구체화하는 것입니다.

예제: Generic_Swap 프로시저 인스턴스화

앞서 정의한 Generic_Swap ‘틀’을 다시 상기해 봅시다.

generic
   type Element_Type is private;
procedure Generic_Swap (A, B : in out Element_Type);

이 ‘틀’로부터 IntegerFloat 타입을 위한 구체적인 Swap 프로시저를 생성하는 과정은 다음과 같습니다.

procedure Test_Swap_Instantiation is
   -- 1. Integer 타입을 위한 인스턴스 생성
   procedure Swap_Int is new Generic_Swap (Element_Type => Integer);

   -- 2. Float 타입을 위한 인스턴스 생성
   procedure Swap_Float is new Generic_Swap (Element_Type => Float);

   My_Int_1 : Integer := 10;
   My_Int_2 : Integer := 20;

   My_Float_1 : Float := 1.23;
   My_Float_2 : Float := 4.56;
begin
   -- 생성된 인스턴스는 일반 프로시저와 완전히 동일하게 호출
   Swap_Int (My_Int_1, My_Int_2);
   Swap_Float (My_Float_1, My_Float_2);
end Test_Swap_Instantiation;

이제 Swap_IntSwap_Float은 각각 IntegerFloat에 대해 완벽하게 동작하는 독립적인 프로시저가 되었습니다. 컴파일러는 인스턴스화 시점에 각 타입에 맞는 코드를 실제로 생성하므로, 런타임에 제네릭으로 인한 성능 저하는 전혀 없습니다.

서브프로그램 매개변수를 갖는 인스턴스화

제네릭 서브프로그램의 진정한 유연성은 다른 서브프로그램을 매개변수로 받을 때 드러납니다. 이는 알고리즘의 핵심적인 동작(예: 비교 방식)을 외부에서 주입할 수 있게 해줍니다.

예제: 최대값을 찾는 Generic_Find_Max 함수 인스턴스화

다음은 배열에서 가장 큰 요소를 찾는 제네릭 함수입니다. ‘크다’는 기준을 ">" 함수 매개변수를 통해 외부에서 전달받습니다.

type Integer_Array is array (Integer range <>) of Integer;

-- 제네릭 '틀' 정의
generic
   type Index is (<>);
   type Element is private;
   type Array_Type is array (Index range <>) of Element;
   -- '크다'를 비교하는 함수를 매개변수로 받음
   with function ">" (Left, Right : Element) return Boolean;
function Generic_Find_Max (Arr : Array_Type) return Element;

function Generic_Find_Max (Arr : Array_Type) return Element is
   Max_Element : Element := Arr (Arr'First);
begin
   for I in Arr'Range loop
      if Arr (I) > Max_Element then -- 여기서 매개변수로 받은 ">" 함수가 사용됨
         Max_Element := Arr (I);
      end if;
   end loop;
   return Max_Element;
end Generic_Find_Max;

Integer 타입에 대해 이 제네릭 함수를 인스턴스화해 보겠습니다.

procedure Test_Max_Instantiation is
   -- 1. Integer 타입을 위한 인스턴스 생성
   --    Integer 타입에 내장된 ">" 연산자를 비교 함수로 전달
   function Find_Max_Int is new Generic_Find_Max
     (Index      => Integer,
      Element    => Integer,
      Array_Type => Integer_Array,
      ">"        => Standard.">"); -- 표준 ">" 연산자 전달

   My_Data : constant Integer_Array := (10, 99, 54, 23, 78);
   Max_Val : Integer;
begin
   Max_Val := Find_Max_Int (My_Data); -- Max_Val은 99가 됨
end Test_Max_Instantiation;

만약 문자열의 길이를 기준으로 ‘크다’를 판별하고 싶다면, 해당 로직을 담은 새로운 비교 함수를 작성하여 전달하기만 하면 됩니다.

function Longer (S1, S2 : String) return Boolean is
begin
   return S1'Length > S2'Length;
end Longer;

-- 문자열 길이를 비교하는 Max 함수 인스턴스 생성
-- function Find_Longest_String is new Generic_Find_Max (..., ">" => Longer);

이처럼 제네릭 서브프로그램의 인스턴스화는 알고리즘구체적인 데이터 타입 및 그에 대한 연산을 분리하고, 컴파일 시점에 이들을 안전하게 조합하여 고도로 재사용 가능한 코드를 생성하는 핵심적인 과정입니다.

12.3.3 제네릭 패키지 인스턴스화

제네릭 서브프로그램이 단일 알고리즘의 ‘틀’이라면, 제네릭 패키지(Generic Package)는 관련된 타입, 변수, 상수, 서브프로그램, 예외 등 하나의 완전한 기능 모듈 전체의 ‘틀’입니다. 따라서 제네릭 패키지를 인스턴스화하면, 특정 목적에 맞게 특화된 완전한 기능의 패키지 하나가 생성됩니다.

이는 Ada의 재사용성 개념에서 가장 강력한 기능 중 하나로, 복잡한 컴포넌트 전체를 단 하나의 선언으로 만들어낼 수 있게 해줍니다.

인스턴스화 구문 및 사용법

제네릭 패키지의 인스턴스화 구문은 서브프로그램과 거의 동일하며, 그 결과로 생성된 인스턴스는 일반 패키지처럼 with 절에 명시하고 . 표기법으로 내부 요소에 접근하여 사용합니다.

package 새_패키지_이름 is new 제네릭_패키지_이름 (제네릭_매개변수_연관);

활용 예제 1: 정수 벡터(동적 배열) 생성

Ada.Containers.Vectors는 가장 널리 사용되는 제네릭 패키지 중 하나입니다. 이 ‘틀’을 인스턴스화하여 Integer 타입을 저장하는 동적 배열(벡터)을 관리하는 패키지를 만들어 보겠습니다.

1. Ada.Containers.Vectors의 제네릭 매개변수 확인 이 패키지는 주로 두 가지 타입 매개변수를 필요로 합니다.

  • Index_Type: 배열의 인덱스로 사용할 타입 (보통 Positive).
  • Element_Type: 벡터에 저장할 요소의 타입.

2. Integer 타입을 위한 인스턴스 생성 Element_TypeInteger를 지정하여 Integer_Vectors라는 새로운 패키지를 생성합니다.

with Ada.Containers.Vectors;

procedure Test_Vector_Instantiation is
   -- 제네릭 패키지 'Ada.Containers.Vectors'로부터
   -- 새로운 패키지 'Integer_Vectors'를 생성 (인스턴스화)
   package Integer_Vectors is new Ada.Containers.Vectors
     (Index_Type   => Positive,
      Element_Type => Integer);

   -- 생성된 패키지 내부의 타입을 사용하여 변수 선언
   My_Vector : Integer_Vectors.Vector; -- Vector 타입은 Integer_Vectors 안에 정의되어 있음
begin
   -- 생성된 패키지 내부의 서브프로그램 호출
   Integer_Vectors.Append (My_Vector, 10);
   Integer_Vectors.Append (My_Vector, 20);
   Integer_Vectors.Append (My_Vector, 30);

   -- My_Vector의 현재 길이는 3이며, 요소는 (10, 20, 30)
   -- Ada.Text_IO.Put_Line ("Length: " & Integer'Image (Integer_Vectors.Length (My_Vector)));
end Test_Vector_Instantiation;

위 예제에서 Integer_VectorsAppend, Length, Element 등 벡터를 조작하는 데 필요한 모든 타입과 서브프로그램을 포함하는 완벽한 기능의 패키지가 되었습니다. 만약 Student 레코드를 저장하는 벡터가 필요하다면, Element_Type => Student로 지정하여 또 다른 패키지를 인스턴스화하기만 하면 됩니다.

활용 예제 2: 열거형 입출력 패키지 생성

Ada.Text_IO.Enumeration_IO는 어떤 열거형 타입이든 그 값을 문자열로 출력하거나 읽을 수 있게 해주는 편리한 제네릭 패키지입니다.

with Ada.Text_IO;

procedure Test_Enum_IO is
   type Day is (Mon, Tue, Wed, Thu, Fri, Sat, Sun);

   -- Day 타입을 위한 입출력 패키지 인스턴스 생성
   package Day_IO is new Ada.Text_IO.Enumeration_IO (Enum => Day);

   Today : Day := Wed;
begin
   -- 생성된 패키지의 Put 프로시저를 사용하여 열거형 값 출력
   Day_IO.Put (Item => Today, Width => 5); -- "  WED" 출력
   Ada.Text_IO.New_Line;
end Test_Enum_IO;

Day_IO.Put을 호출하면 Today의 값인 Wed를 자동으로 문자열 “WED”로 변환하여 출력해 줍니다. 사용자가 직접 case 문을 만들어 문자열로 변환하는 코드를 작성할 필요가 없습니다.

이처럼 제네릭 패키지의 인스턴스화는 잘 만들어진 범용 ‘틀’을 가져와, 최소한의 노력으로 내가 원하는 특정 목적에 맞는 강력한 기능 모듈을 ‘생산’해내는 핵심적인 과정입니다. 이는 Ada의 컴포넌트 기반 개발(Component-Based Development) 사상의 근간을 이룹니다.

12.4 [활용] 표준 라이브러리 제네릭 컨테이너

지금까지 우리는 제네릭의 개념을 이해하고 Generic_Swap과 같은 간단한 예제를 통해 직접 제네릭 유닛을 정의해 보았습니다. 하지만 제네릭의 진정한 힘은 이처럼 간단한 유틸리티를 넘어, 복잡하고 재사용성 높은 컴포넌트 라이브러리를 구축할 때 드러납니다. 그 가장 훌륭한 모범 사례가 바로 Ada 표준 라이브러리 내에 존재합니다.

프로그래밍을 할 때 우리는 동적 배열(vector), 연결 리스트(list), 키-값 쌍을 저장하는 맵(map) 등 공통적인 자료구조를 반복적으로 필요로 합니다. Ada는 이러한 필수 자료구조들을 직접 구현하느라 시간을 낭비하지 않도록, Ada.Containers 라는 표준 패키지 아래에 잘 설계되고, 효율적이며, 신뢰성 높은 제네릭 컨테이너(generic container)들을 풍부하게 제공합니다.

이 표준 컨테이너들은 제네릭의 모든 강력함을 보여주는 집약체입니다. 개발자는 단지 자신이 저장하고 싶은 요소의 타입(예: Integer 또는 직접 만든 Student 레코드)을 지정하여 이 제네릭 컨테이너들을 인스턴스화하기만 하면, 곧바로 검증된 자료구조를 자신의 프로그램에서 사용할 수 있습니다.

이번 절에서는 이론을 넘어 실제적인 활용으로 나아갑니다. Ada 표준 라이브러리가 제공하는 가장 대표적인 제네릭 컨테이너인 Vectors, Doubly_Linked_Lists, Ordered_Maps 등을 직접 인스턴스화하고 사용하는 방법을 실습함으로써, 제네릭이 실제 문제를 얼마나 우아하고 효율적으로 해결하는지 체감하게 될 것입니다.

12.4.1 Ada.Containers.Vectors - 동적 배열

프로그램을 작성할 때, 요소의 개수가 런타임에 변할 수 있는 배열이 필요한 경우가 매우 흔합니다. Ada의 기본 array 타입은 한번 선언되면 길이가 고정되지만, 표준 라이브러리의 제네릭 패키지 Ada.Containers.Vectors를 사용하면 필요에 따라 크기가 자동으로 늘어나거나 줄어드는 동적 배열, 즉 벡터(vector)를 손쉽게 구현할 수 있습니다.

벡터는 인덱스를 통해 각 요소에 빠르게 접근할 수 있는 배열의 장점과, 길이를 유연하게 변경할 수 있는 리스트의 장점을 결합한 자료구조입니다.

1. 인스턴스화: 나만의 벡터 패키지 만들기

Ada.Containers.Vectors는 제네릭 ‘틀’이므로, 사용하기 전에 우리가 저장할 요소의 타입을 지정하여 인스턴스화해야 합니다. 주요 제네릭 매개변수는 다음과 같습니다.

  • Index_Type: 인덱스로 사용할 이산 타입 (일반적으로 1 기반 인덱싱을 위해 Positive를 사용).
  • Element_Type: 벡터에 저장할 요소의 타입 (Integer, String, 직접 만든 레코드 등).

예제: String을 저장하는 벡터 패키지 인스턴스화

with Ada.Containers.Vectors;

-- `String_Vectors` 라는 새로운 패키지를 생성.
-- 이 패키지는 String 타입의 동적 배열을 관리하는 모든 기능을 포함하게 됨.
package String_Vectors is new Ada.Containers.Vectors
  (Index_Type   => Positive,
   Element_Type => String);

이제 String_VectorsString 타입을 위한 완벽한 동적 배열 관리 패키지가 되었습니다.

2. 주요 타입과 연산

인스턴스화된 패키지(String_Vectors)는 벡터를 다루는 데 필요한 여러 타입과 서브프로그램을 제공합니다.

  • Vector 타입: 실제 동적 배열 컨테이너를 나타내는 타입입니다. My_List : String_Vectors.Vector;
  • Cursor 타입: 벡터 내의 특정 요소를 가리키는 ‘포인터’ 또는 ‘반복자’입니다. 인덱스를 직접 사용하는 것보다 더 안전하고 일반적인 방법으로 요소를 참조할 때 사용됩니다.

주요 연산 (서브프로그램):

  • 요소 추가/삭제
    • Append(Container, New_Item): 벡터의 맨 끝에 새 요소를 추가합니다.
    • Delete(Container, Index): 특정 인덱스의 요소를 삭제합니다.
    • Clear(Container): 벡터의 모든 요소를 삭제합니다.
  • 정보 조회
    • Length(Container): 현재 벡터에 저장된 요소의 개수를 반환합니다.
    • Is_Empty(Container): 벡터가 비어있는지 확인합니다.
    • Element(Container, Index): 특정 인덱스의 요소를 반환합니다.
    • First_Element(Container), Last_Element(Container): 첫 번째 또는 마지막 요소를 반환합니다.
  • 반복
    • for...of 루프: 컨테이너의 모든 요소를 순회하는 가장 현대적이고 권장되는 방법입니다.

3. 종합 활용 예제

with Ada.Containers.Vectors;
with Ada.Text_IO;

procedure Test_String_Vector is
   -- 1. String을 저장하는 벡터 패키지를 인스턴스화
   package String_Vectors is new Ada.Containers.Vectors
     (Index_Type   => Positive,
      Element_Type => String);

   -- 2. 생성된 패키지의 Vector 타입을 사용하여 변수 선언
   Shopping_List : String_Vectors.Vector;
begin
   -- 3. Append 프로시저로 요소 추가
   String_Vectors.Append (Shopping_List, "Apples");
   String_Vectors.Append (Shopping_List, "Bananas");
   String_Vectors.Append (Shopping_List, "Oranges");

   -- 4. 정보 조회 함수 사용
   Ada.Text_IO.Put_Line ("Number of items: " & Natural'Image (String_Vectors.Length (Shopping_List)));

   -- 5. for...of 루프를 이용한 순회 및 출력
   Ada.Text_IO.Put_Line ("--- Shopping List ---");
   for Item of Shopping_List loop
      Ada.Text_IO.Put_Line ("- " & Item);
   end loop;

   -- 6. 요소 삭제
   String_Vectors.Delete (Shopping_List, Index => 2); -- "Bananas" 삭제

   Ada.Text_IO.Put_Line ("--- After deleting Bananas ---");
   for Item of Shopping_List loop
      Ada.Text_IO.Put_Line ("- " & Item);
   end loop;

end Test_String_Vector;

출력 결과:

Number of items: 3
--- Shopping List ---
- Apples
- Bananas
- Oranges
--- After deleting Bananas ---
- Apples
- Oranges

이처럼 Ada.Containers.Vectors는 타입 안전성을 보장하면서도 필요에 따라 크기를 자유롭게 조절할 수 있는 배열이 필요할 때 가장 먼저 고려해야 할 표준적이고 강력한 해결책입니다.

12.4.2 Ada.Containers.Ordered_Maps - 정렬된 맵

맵(Map)은 키(key)와 값(value)을 하나의 쌍으로 묶어 저장하는 자료구조입니다. 사전에서 단어(키)를 찾아 그 뜻(값)을 찾듯이, 맵에서는 고유한 키를 통해 연관된 값을 저장하고 검색합니다. 이는 배열이나 벡터처럼 숫자 인덱스를 사용하는 대신, String이나 Integer 등 의미 있는 데이터를 키로 사용할 수 있어 매우 유용합니다.

Ada 표준 라이브러리는 여러 종류의 맵을 제공하는데, 그중 Ada.Containers.Ordered_Maps는 이름 그대로 키(key)를 기준으로 항상 정렬된 상태를 유지하는 맵입니다. 이 특성 덕분에 맵의 모든 요소를 순회할 때, 키의 오름차순으로 예측 가능한 순서에 따라 접근할 수 있습니다.

1. 인스턴스화: 비교 함수 제공하기

정렬된 상태를 유지하기 위해, Ordered_Maps는 두 키를 어떻게 비교해야 하는지 알아야 합니다. 따라서 인스턴스화할 때 키의 타입을 위한 ‘보다 작다’("<") 비교 함수를 반드시 지정해야 합니다.

  • 주요 제네릭 매개변수:
    • Key_Type: 키로 사용할 타입.
    • Element_Type: 값으로 사용할 타입.
    • "<": 두 Key_Type의 값을 비교하여 Boolean을 반환하는 함수. Key_Type에 대해 < 연산자가 이미 존재한다면, is <> 를 사용하여 기본 연산자를 그대로 활용할 수 있습니다.

예제: 학생 ID(Integer)를 키로, 점수(Float)를 값으로 갖는 맵 패키지 생성

with Ada.Containers.Ordered_Maps;

-- 'Student_Scores' 라는 새로운 맵 관리 패키지를 생성.
package Student_Scores is new Ada.Containers.Ordered_Maps
  (Key_Type     => Integer,
   Element_Type => Float);
   -- Key_Type이 Integer이므로, 표준 라이브러리에 정의된
   -- 정수 비교 연산자 "<"가 자동으로 사용됨 (is <> 와 유사)

2. 주요 타입과 연산

인스턴스화된 패키지는 맵을 조작하는 데 필요한 타입과 서브프로그램들을 제공합니다.

  • Map 타입: 맵 컨테이너 자체를 나타내는 타입입니다. Grade_Book : Student_Scores.Map;
  • Cursor 타입: 맵 내의 특정 키-값 쌍을 가리키는 반복자입니다.

주요 연산 (서브프로그램):

  • 요소 추가/수정/삭제
    • Insert(Container, Key, New_Item): 새로운 키-값 쌍을 삽입합니다. 만약 키가 이미 존재하면 Constraint_Error가 발생합니다. (중복 키를 허용하지 않음)
    • Replace(Container, Key, New_Item): 기존 키에 해당하는 값을 새로운 값으로 교체합니다.
    • Delete(Container, Key): 특정 키에 해당하는 쌍을 삭제합니다.
    • Clear(Container): 맵의 모든 요소를 삭제합니다.
  • 정보 조회
    • Length(Container): 저장된 키-값 쌍의 개수를 반환합니다.
    • Contains(Container, Key): 맵에 해당 키가 존재하는지 Boolean 값으로 확인합니다.
    • Element(Container, Key): 특정 키에 해당하는 값을 반환합니다. 키가 없으면 Constraint_Error가 발생하므로, Contains로 먼저 확인하는 것이 안전합니다.
  • 반복
    • Iterate: 맵의 모든 요소를 키의 정렬된 순서대로 순회할 수 있는 반복자(iterator)를 반환합니다.

3. 종합 활용 예제

with Ada.Containers.Ordered_Maps;
with Ada.Text_IO;

procedure Test_Grade_Book is
   -- 1. 맵 패키지 인스턴스화
   package Score_Maps is new Ada.Containers.Ordered_Maps
     (Key_Type => Integer, Element_Type => Float);
   use Score_Maps; -- Map, Cursor 등을 직접 사용하기 위함

   -- 2. Map 타입 변수 선언
   Grade_Book : Map;
begin
   -- 3. Insert 프로시저로 요소 추가
   Insert (Grade_Book, Key => 20250010, New_Item => 95.5);
   Insert (Grade_Book, Key => 20250003, New_Item => 88.0);
   Insert (Grade_Book, Key => 20250025, New_Item => 76.5);

   -- 4. 특정 키의 존재 여부 확인 및 값 접근
   if Contains (Grade_Book, Key => 20250003) then
      declare
         Score : constant Float := Element (Grade_Book, Key => 20250003);
      begin
         Ada.Text_IO.Put_Line ("ID 20250003's score: " & Float'Image (Score));
      end;
   end if;

   -- 5. 요소를 정렬된 순서(키 기준)로 순회 및 출력
   Ada.Text_IO.Put_Line ("--- Grade Book (Sorted by ID) ---");
   for C in Grade_Book.Iterate loop
      -- 커서(C)를 통해 키와 값에 접근
      Ada.Text_IO.Put ("ID: " & Integer'Image (Key (C)));
      Ada.Text_IO.Put (", Score: " & Float'Image (Element (C)));
      Ada.Text_IO.New_Line;
   end loop;
end Test_Grade_Book;

출력 결과:

ID 20250003's score:  8.80000E+01
--- Grade Book (Sorted by ID) ---
ID:  20250003, Score:  8.80000E+01
ID:  20250010, Score:  9.55000E+01
ID:  20250025, Score:  7.65000E+01

출력 결과에서 볼 수 있듯, for 루프는 키(학생 ID)의 오름차순으로 결과를 출력합니다.

Ada.Containers.Ordered_Maps는 데이터에 키를 통해 접근하면서도, 그 데이터를 정렬된 순서로 처리해야 할 필요가 있을 때 사용하는 강력하고 신뢰성 있는 해결책입니다.

12.5 [설계] 재사용 가능한 제네릭 컴포넌트 설계

제네릭은 코드 재사용을 위한 강력한 도구이지만, 제네릭 유닛을 만드는 것 자체가 자동으로 좋은 재사용성을 보장하지는 않습니다. 진정으로 훌륭한 제네릭 컴포넌트는 사용하기 쉽고, 유연하며, 다양한 상황에 적용될 수 있도록 신중하게 설계되어야 합니다. 단순히 타입을 매개변수화하는 것을 넘어, 어떻게 추상화할 것인가에 대한 깊은 고민이 필요합니다.

이번 절에서는 기술적인 구문을 넘어, 다른 개발자들이 즐겨 사용할 수 있는 고품질의 제네릭 컴포넌트를 설계하기 위한 몇 가지 핵심 원칙과 실용적인 지침들을 알아봅니다. 좋은 제네릭 설계는 마치 잘 만들어진 도구 세트와 같습니다. 사용자는 각 도구의 내부 구조를 몰라도, 명확한 인터페이스를 통해 자신이 원하는 작업을 쉽고 안전하게 수행할 수 있어야 합니다.

이를 위해 우리는 다음과 같은 질문들에 답해 나갈 것입니다.

  • 제네릭 컴포넌트는 외부 타입에 대해 어디까지 알고 있어야 하는가? (최소한의 가정 원칙)
  • 컴포넌트의 동작을 외부에서 주입하고 제어하려면 어떻게 해야 하는가? (의존성 주입)
  • 사용자가 제네릭을 올바르게 사용하도록 강제하고 안내하는 방법은 무엇인가? (명확한 계약)

이러한 설계 원칙들을 이해하고 적용함으로써, 여러분은 단순히 동작하는 코드를 넘어, 다른 사람들의 생산성을 높여주는 견고하고 우아한 소프트웨어 자산을 만들어낼 수 있을 것입니다.

12.5.1 최소한의 가정 원칙 (Principle of Minimal Assumptions)

재사용 가능한 제네릭 컴포넌트를 설계할 때 가장 중요한 원칙은 “컴포넌트가 알 필요가 없는 것은 요구하지 않는다”는 것입니다. 즉, 제네릭 유닛은 자신이 동작하는 데 필요한 최소한의 기능만을 제네릭 매개변수를 통해 가정(요구)해야 합니다.

가정을 많이 할수록 해당 제네릭 유닛은 특정 타입에 더 강하게 얽매이게 되어 재사용성이 떨어집니다. 반대로 가정을 적게 할수록, 더 다양한 타입에 적용될 수 있어 재사용성이 극대화됩니다.

타입 매개변수와 ‘계약’

이 원칙은 특히 제네릭 타입 매개변수를 선언할 때 명확하게 드러납니다. 어떤 타입 구문을 사용하느냐에 따라 제네릭 유닛 내부에서 해당 타입에 대해 사용할 수 있는 연산, 즉 ‘계약(contract)’의 내용이 달라집니다.

type T is private; (가장 약한 가정, 가장 높은 재사용성)

  • 가정: “T는 대입(:=)과 동등 비교(=, /=)가 가능한 어떤 타입이다.”
  • 장점: Integer, Float, String, 대부분의 레코드 등 거의 모든 타입에 대해 인스턴스화할 수 있어 재사용성이 가장 높습니다.
  • 제약: T 타입의 객체에 대해 +, *, <, > 같은 연산은 물론, 'Image 속성조차 사용할 수 없습니다. 오직 대입과 비교만 할 수 있다고 가정했기 때문입니다.

type T is (<>); (이산 타입이라는 가정)

  • 가정: “T는 정수 또는 열거형과 같은 이산(discrete) 타입이다.”
  • 장점: 대입과 비교 외에도, 'Succ, 'Pred, 'Pos 같은 속성과 for I in T'Range loop 형태의 반복문을 사용할 수 있습니다.
  • 제약: Float나 레코드 타입 등 이산 타입이 아닌 타입에 대해서는 인스턴스화할 수 없습니다.

type T is range <>; (정수 계열 타입이라는 강한 가정)

  • 가정: “T는 Integer, Positive 등 정수 계열의 타입이다.”
  • 장점: 산술 연산(+, -, *, /)을 자유롭게 사용할 수 있습니다.
  • 제약: 정수 계열 타입 외에는 인스턴스화가 불가능하여 재사용성이 크게 제한됩니다.

설계 예제: Find 프로시저

배열에서 특정 요소를 찾는 Find 프로시저를 설계하며 이 원칙을 적용해 봅시다.

나쁜 설계 (과도한 가정):

generic
   type Element is range <>; -- "요소는 반드시 정수 계열 타입이다" 라고 가정
   type Element_Array is array (Positive range <>) of Element;
procedure Find (Arr : Element_Array; Value : Element; Found : out Boolean);

-- 이 프로시저는 정수 배열에서 정수를 찾는 데만 사용할 수 있음

위 설계는 Element가 정수 계열 타입이라고 가정했기 때문에, 문자열 배열이나 Date 배열에서는 재사용할 수 없습니다.

좋은 설계 (최소한의 가정): Find 알고리즘의 본질은 ‘배열의 각 요소와 찾고자 하는 값을 비교하는 것’입니다. 이 비교는 = 연산자만 있으면 충분합니다. 따라서 가장 약한 가정인 is private를 사용하는 것이 올바른 설계입니다.

generic
   type Element is private; -- "요소는 비교(=)만 가능하면 된다" 라고 가정
   type Element_Array is array (Positive range <>) of Element;
procedure Find (Arr : Element_Array; Value : Element; Found : out Boolean);

procedure Find (Arr : Element_Array; Value : Element; Found : out Boolean) is
begin
   Found := False;
   for I in Arr'Range loop
      if Arr (I) = Value then -- '=' 연산은 'is private' 계약에 포함됨
         Found := True;
         return;
      end if;
   end loop;
end Find;

이 새로운 Find 프로시저는 이제 Integer, String, Date= 연산이 정의된 거의 모든 타입의 배열에 대해 인스턴스화하여 재사용할 수 있습니다.

만약 정렬(sort) 알고리즘처럼 ‘크기 비교’(< 또는 >)가 반드시 필요하다면 어떻게 해야 할까요? 이때 type Element is (<>); 처럼 타입에 대한 가정을 강화하는 대신, 타입 가정은 is private로 최소한으로 유지하고 필요한 "<" 연산만 서브프로그램 매개변수로 명시적으로 요구하는 것이 최선의 설계입니다. (12.5.2절에서 계속)

결론적으로, 훌륭한 제네릭 컴포넌트는 필요한 것이 무엇인지 정확히 알고, 그 이상의 것은 요구하지 않음으로써 자신의 적용 가능성을 최대한으로 넓힙니다.

12.5.2 의존성 주입과 서브프로그램 매개변수

앞선 ‘최소한의 가정 원칙’에 따르면, 제네릭 타입 매개변수는 가능한 한 type T is private; 처럼 가장 약한 가정을 사용하는 것이 재사용성 측면에서 바람직합니다. 하지만 만약 알고리즘을 구현하는 데 대입과 비교(=) 외에 정렬을 위한 ‘크기 비교’(<)나, 합계를 구하기 위한 ‘덧셈’(+) 같은 추가적인 연산이 필요하다면 어떻게 해야 할까요?

이때 타입 가정을 type T is (<>);type T is range <>; 처럼 강화하는 것은 나쁜 설계입니다. 이는 특정 타입만 받도록 재사용성을 스스로 제한하는 행위이기 때문입니다.

더 나은 해결책은, 타입 가정은 최소한으로 유지하되, 필요한 추가 기능(연산)을 서브프로그램 매개변수로 외부에서 주입(inject)받는 것입니다. 이를 의존성 주입(Dependency Injection)이라고 부르며, 유연하고 분리된(decoupled) 컴포넌트를 설계하는 핵심적인 기법입니다.

with subprogram 구문

제네릭 유닛은 with 키워드를 사용하여 자신이 필요로 하는 서브프로그램의 명세(specification)를 ‘계약’의 일부로 요구할 수 있습니다.

generic
   type Element is private;
   -- 이 제네릭은 'Element' 타입 두 개를 더하는 "+" 함수를 필요로 한다고 선언
   with function "+" (Left, Right : Element) return Element;
package Generic_Collection_Utils;

이제 이 제네릭 패키지를 인스턴스화하는 코드는 Element 타입뿐만 아니라, 해당 타입에 대한 "+" 함수의 구체적인 구현도 함께 제공해야 할 의무가 생깁니다.

설계 예제: 일반화된 Sum 함수

배열의 모든 요소의 합계를 구하는 Generic_Sum 함수를 설계해 봅시다. 이 알고리즘은 ‘덧셈’ 연산과 ‘덧셈의 항등원’(즉, ‘0’에 해당하는 값)이 필요합니다.

1. 제네릭 ‘틀’ 설계

generic
   type Element is private; -- 타입 가정은 최소한으로
   type Element_Array is array (Positive range <>) of Element;

   -- 필요한 의존성을 명시적으로 요구
   Zero : Element; -- 덧셈의 항등원 (e.g., 0, 0.0, 빈 문자열)
   with function "+" (Left, Right : Element) return Element; -- 덧셈 연산
function Generic_Sum (Arr : Element_Array) return Element;

function Generic_Sum (Arr : Element_Array) return Element is
   Total : Element := Zero; -- 주입받은 Zero 값으로 초기화
begin
   for I in Arr'Range loop
      Total := Total + Arr(I); -- 주입받은 "+" 함수로 덧셈 수행
   end loop;
   return Total;
end Generic_Sum;

2. Integer 타입에 대한 인스턴스화 Integer의 ‘0’과 표준 "+" 연산자를 주입하여 정수 합계 함수를 생성합니다.

function Sum_Integers is new Generic_Sum
  (Element       => Integer,
   Element_Array => Integer_Array,
   Zero          => 0,
   "+"           => Standard."+");

3. 사용자 정의 Vector 타입에 대한 인스턴스화 이제 이 Generic_Sum ‘틀’을 우리가 직접 만든 Vector 타입에도 재사용해 보겠습니다.

-- 사용자 정의 타입과 그에 대한 덧셈 연산 정의
type Vector is record X, Y : Float; end record;
function Add_Vectors (Left, Right : Vector) return Vector is
begin
   return (Left.X + Right.X, Left.Y + Right.Y);
end Add_Vectors;

type Vector_Array is array (Positive range <>) of Vector;

-- Vector 타입을 위한 인스턴스 생성
-- 우리가 직접 만든 Add_Vectors 함수와 영 벡터를 주입
function Sum_Vectors is new Generic_Sum
  (Element       => Vector,
   Element_Array => Vector_Array,
   Zero          => (0.0, 0.0),
   "+"           => Add_Vectors);

결론: 역할의 분리

의존성 주입 기법은 역할의 분리를 명확하게 합니다.

  • 제네릭 유닛의 역할: 일반화된 알고리즘을 제공합니다. Generic_Sum은 “어떻게 더하는지는 모르지만, 덧셈 연산만 주어진다면 모든 요소를 더해줄 수 있다”고 말합니다.
  • 사용자의 역할: 구체적인 타입과 그 타입에 맞는 동작(서브프로그램)을 제공합니다. 사용자는 Integer와 그에 맞는 +를, 또는 Vector와 그에 맞는 Add_Vectors를 제공하여 제네릭 유닛의 동작을 커스터마이징합니다.

이러한 설계 방식은 제네릭 컴포넌트의 재사용성을 극대화하고, 각 컴포넌트가 하나의 책임만 갖도록 하여 시스템 전체를 더 유연하고 테스트하기 쉽게 만듭니다.

12.5.3 명확한 계약과 인터페이스

훌륭한 제네릭 컴포넌트는 사용자가 그 사용법을 쉽게 유추할 수 있고, 잘못 사용하기 어렵도록 설계되어야 합니다. 이를 위해서는 제네릭 유닛의 선언부, 즉 인터페이스(interface)가 컴포넌트의 요구사항과 보장사항을 명확히 나타내는 ‘계약(contract)’으로서의 역할을 수행해야 합니다.

사용자는 제네릭 유닛의 복잡한 내부 구현을 들여다보지 않고도, 이 계약(인터페이스)만 보고도 컴포넌트를 올바르게 사용할 수 있어야 합니다.

제네릭 매개변수를 통한 계약 정의

제네릭 컴포넌트의 계약은 주로 제네릭 형식 매개변수(generic formal parameter)들을 통해 정의됩니다. 각 매개변수 선언은 계약서의 조항과 같습니다.

  • type Element is private;

    • 사용자에게 요구하는 것: “대입(:=)과 동등 비교(=, /=)가 가능한 어떤 타입이든 제공하시오.”
    • 컴포넌트가 보장하는 것: “나는 당신이 제공한 타입에 대해 오직 대입과 동등 비교 연산만을 사용할 것이오.”
  • with function Is_Less (Left, Right : Element) return Boolean;

    • 사용자에게 요구하는 것: “Element 타입의 두 값을 받아 Boolean을 반환하고, 이름이 Is_Less인 함수를 제공하시오.”
    • 컴포넌트가 보장하는 것: “나는 요소들의 순서를 비교해야 할 때, 반드시 당신이 제공한 Is_Less 함수를 사용할 것이오.”

이처럼 제네릭 인터페이스는 컴포넌트와 사용자 코드 사이의 책임을 명확하게 분배합니다.

명확한 이름을 통한 의도 표현

계약의 내용을 더 명확하게 전달하기 위해서는 식별자의 이름을 신중하게 짓는 것이 매우 중요합니다.

  • 제네릭 유닛의 이름: Generic_Sort, Generic_Stack 처럼 유닛의 전반적인 목적을 명확히 나타내야 합니다.
  • 제네릭 매개변수의 이름: 매개변수의 역할을 명확히 설명해야 합니다.

나쁜 예:

generic
   type T is private;
   V : T;
   with function F (A, B : T) return Boolean;
procedure P (Item : T);

위 인터페이스는 T, V, F, P가 무엇을 의미하는지 전혀 알 수 없어 사용하기가 매우 어렵습니다.

좋은 예:

generic
   type Message is private;
   Default_Message : Message; -- 'V' 대신 역할이 명확한 이름 사용
   with function Is_Urgent (M : Message) return Boolean; -- 'F' 대신 역할이 명...
procedure Dispatch (Item : Message); -- 'P' 대신 역할이 명확한 이름 사용

이름만으로도 이 제네릭 프로시저가 Message 타입을 다루며, ‘긴급’한지 판단하는 함수를 필요로 하고, 기본 메시지 값을 가진다는 사실을 쉽게 유추할 수 있습니다.

주석을 통한 의미적 계약 보강

제네릭 매개변수 선언은 문법적 계약은 강제할 수 있지만, 의미적 계약까지 강제하지는 못합니다. 예를 들어, 정렬(Sort) 알고리즘은 ‘크기 비교’ 함수가 수학적으로 올바른 ‘엄격한 약순서(strict weak ordering)’를 구현했다고 가정하고 동작합니다. 만약 사용자가 문법적으로는 맞지만 논리적으로는 엉터리인 비교 함수를 전달하면, 코드는 컴파일되겠지만 런타임에 정렬이 제대로 동작하지 않을 것입니다.

이러한 의미적 요구사항은 주석을 통해 명시적으로 문서화해야 합니다.

generic
   type Element is private;
   -- 주석: 제공되는 "Is_Less" 함수는 반드시 '엄격한 약순서'를 만족해야 합니다.
   -- (a < a)는 항상 거짓이어야 하며,
   -- a < b 이고 b < c 이면 a < c 여야 합니다.
   with function Is_Less (Left, Right : Element) return Boolean;
procedure Sort (Items : in out Item_Array);

이처럼 잘 설계된 제네릭 인터페이스는 최소한의 가정을 하고(12.5.1) 필요한 기능은 외부에서 주입받으며(12.5.2) 명확한 이름과 주석을 통해 계약을 명시함으로써, 사용자가 컴포넌트를 쉽고 안전하게 재사용할 수 있도록 안내하는 역할을 수행합니다.

12.5.4 제네릭의 조합 (Composition of Generics)

지금까지 우리는 개별 제네릭 컴포넌트를 설계하고 사용하는 방법을 배웠습니다. 하지만 제네릭의 진정한 힘은, 마치 레고 블록을 조립하듯 여러 제네릭 유닛을 조합(composition)하여 더 크고 복잡한 기능의 컴포넌트를 만들어낼 때 드러납니다.

제네릭의 조합은 소프트웨어 설계에서 ‘관심사의 분리(Separation of Concerns)’ 원칙을 극대화하는 방법입니다. 예를 들어, ‘정렬 알고리즘’과 ‘데이터 저장 컨테이너’라는 두 가지 관심사를 각각 독립적인 제네릭 패키지로 만든 후, 이 둘을 조합하여 ‘자동으로 정렬되는 컨테이너’를 만들어 낼 수 있습니다.

조합의 기본 패턴

제네릭의 조합은 보통 한 제네릭 유닛이 다른 제네릭 유닛의 인스턴스를 제네릭 매개변수로 요구하는 형태로 이루어집니다. 이는 with package ... is new ... 구문을 사용하는 패키지 매개변수를 통해 가장 우아하게 구현됩니다.

예제: 제네릭 정렬 알고리즘과 제네릭 벡터의 조합

  1. 관심사 1: 정렬 알고리즘 (Sort Algorithm) 먼저 ‘어떤 종류의 배열이든 정렬할 수 있는’ 제네릭 프로시저를 정의합니다.

    generic
       type Index is (<>);
       type Element is private;
       type Array_Type is array (Index range <>) of Element;
       with function "<" (L, R : Element) return Boolean is <>;
    procedure Generic_Sort (Container : in out Array_Type);
    
  2. 관심사 2: 동적 배열 컨테이너 (Vector Container) Ada.Containers.Vectors는 이미 잘 만들어진 제네릭 동적 배열 ‘틀’입니다.

  3. 조합: 두 제네릭을 조합하여 ‘정렬 가능한 벡터’ 만들기 이제 우리는 새로운 제네릭 패키지 Sortable_Vector를 만듭니다. 이 패키지는 내부적으로 Ada.Containers.Vectors를 인스턴스화하여 사용하며, 외부에는 Sort라는 새로운 기능을 추가로 제공합니다.

    with Ada.Containers.Vectors;
    with Generic_Sort; -- 앞서 정의한 제네릭 정렬 프로시저
    
    generic
       type Element_Type is private;
       with function "<" (L, R : Element_Type) return Boolean is <>;
    package Sortable_Vector is
    
       -- 내부적으로 사용할 Vector 패키지를 인스턴스화
       package Inner_Vector is new Ada.Containers.Vectors
         (Index_Type   => Positive,
          Element_Type => Element_Type);
    
       -- Vector 타입을 외부로 노출
       type Vector is new Inner_Vector.Vector with null record;
    
       -- 정렬 프로시저 인스턴스화
       -- Vector 타입을 배열처럼 다루기 위해 타입 변환이 필요할 수 있음
       -- (실제 구현은 더 복잡할 수 있으나 개념적 설명임)
    
       -- 새로운 기능 추가
       procedure Sort (Container : in out Vector);
    
       -- 기존 Vector의 다른 기능들도 이곳에 래핑(wrapping)하여 제공...
       procedure Append (Container : in out Vector; New_Item : Element_Type);
       function Length (Container : Vector) return Natural;
    
    end Sortable_Vector;
    

    위 설계에서 Sortable_VectorAda.Containers.Vectors의 기능과 Generic_Sort의 기능을 조합하여, ‘정렬’이라는 새로운 기능이 추가된 고수준의 컴포넌트를 만들어냈습니다.

제네릭 조합의 장점

  • 관심사의 명확한 분리: 알고리즘, 자료구조, 비교 로직 등 각기 다른 관심사를 독립적인 제네릭 유닛으로 개발하고 테스트할 수 있습니다.
  • 최상의 재사용성: 잘 만들어진 제네릭 ‘블록’들은 다양한 방식으로 조합되어 수많은 새로운 컴포넌트를 만들어낼 수 있습니다. Generic_Sort는 벡터뿐만 아니라 일반 배열이나 다른 컨테이너를 정렬하는 데에도 재사용될 수 있습니다.
  • 유연성과 확장성: 만약 더 효율적인 정렬 알고리즘(Generic_Quick_Sort)이 개발된다면, Sortable_Vector의 내부 구현에서 Generic_Sort 인스턴스화 부분만 교체하면 됩니다. 외부 인터페이스에는 아무런 영향이 없습니다.

이처럼 제네릭의 조합은 단순한 코드 재사용을 넘어, 각기 다른 역할을 수행하는 독립적인 컴포넌트들을 조립하여 복잡한 시스템을 구축하는 현대적인 컴포넌트 기반 개발(Component-Based Development)의 핵심적인 사상입니다. 이는 소프트웨어 아키텍처를 유연하고, 확장 가능하며, 유지보수하기 쉽게 만드는 가장 강력한 방법 중 하나입니다.

12.5.5 언제 제네릭을 사용하지 말아야 하는가?

제네릭은 코드 재사용성을 높이는 매우 강력한 도구이지만, 모든 문제에 대한 만병통치약은 아닙니다. 다른 모든 고급 기능과 마찬가지로, 제네릭은 일정 수준의 추상화와 복잡성을 동반합니다. 때로는 더 단순하고 직접적인 접근 방식이 더 나은 해결책일 수 있습니다.

훌륭한 엔지니어는 강력한 도구를 언제 사용해야 하는지 뿐만 아니라, 언제 사용하지 말아야 하는지도 아는 사람입니다. 이번 절에서는 제네릭을 사용하는 것이 오히려 과잉 설계(over-engineering)가 될 수 있는 몇 가지 경우를 살펴봅니다.

1. 문제가 진정한 일회성(One-Off)일 경우

만약 특정 로직이 단 하나의 구체적인 타입에 대해서만 필요하고, 앞으로 다른 타입에 대해 동일한 로직을 사용할 가능성이 거의 없다면 제네릭은 불필요합니다.

  • 상황: 애플리케이션의 특정 Customer_Order 레코드 타입의 유효성을 검사하는 프로시저를 작성해야 합니다. 이 유효성 검사 로직은 Customer_Order의 특정 필드들(예: Order_ID, Customer_ID, Order_Date)에 강하게 결합되어 있습니다.
  • 판단: 이 작업을 위해 Generic_Validator와 같은 ‘틀’을 만드는 것은 과잉 설계입니다. procedure Validate (Order : Customer_Order) 와 같은 단순하고 구체적인 서브프로그램을 작성하는 것이 훨씬 더 직접적이고 코드를 이해하기 쉽습니다. 제네릭이라는 추상화 계층을 추가하는 것이 아무런 재사용의 이득 없이 코드의 복잡성만 높일 뿐입니다.
  • 지침: 성급하게 일반화하지 마십시오. 먼저 구체적인 문제를 해결하고, 여러 다른 타입에 대해 동일한 패턴이 반복될 때 비로소 제네릭으로의 리팩토링(refactoring)을 고려하는 것이 좋습니다.

2. 타입별로 알고리즘의 차이가 너무 클 경우

개념적으로는 ‘직렬화(Serialize)’나 ‘문자열로 변환(To_String)’처럼 동일한 작업이라도, 실제 타입별 구현 내용이 완전히 다른 경우가 많습니다.

  • 상황: Integer, Date, 그리고 복잡한 Network_Packet 타입을 각각 문자열로 변환하는 기능을 만들어야 합니다.
    • Integer 변환: Integer'Image 속성을 사용하면 간단합니다.
    • Date 변환: “YYYY-MM-DD”와 같은 특정 포맷에 맞춰 필드를 조합해야 합니다.
    • Network_Packet 변환: 각 필드를 네트워크 바이트 순서(network byte order)로 변환하고, 비트 플래그를 해석하는 등 완전히 고유한 로직이 필요합니다.
  • 판단: 이 경우, 모든 타입을 아우르는 Generic_To_String을 만드는 것은 거의 의미가 없습니다. 제네릭 ‘틀’이 제공할 수 있는 공통 로직이 거의 없기 때문입니다. 핵심 로직은 모두 타입별로 특화된 부분에 있습니다. 이런 상황에서는 제네릭보다 서브프로그램 오버로딩(overloading)이 훨씬 더 명확하고 적절한 해법입니다.
    function To_String (Value : Integer) return String;
    function To_String (Value : Date) return String;
    function To_String (Value : Network_Packet) return String;
    
  • 지침: 제네릭으로 제공할 수 있는 ‘공통 알고리즘’이 거의 없고 대부분의 로직이 타입에 따라 달라진다면, 오버로딩을 통해 각 타입에 대한 명시적인 구현을 제공하는 것이 더 나은 설계입니다.

3. 극단적인 성능 최적화가 필요할 때

이 경우는 매우 드물지만, 하드 리얼타임 시스템이나 고성능 컴퓨팅(HPC) 분야에서는 고려될 수 있습니다.

  • 상황: 특정 연산에 대해 사이클(cycle) 단위의 극단적인 성능 최적화가 필요합니다.
  • 판단: 제네릭 인스턴스화는 컴파일 시점에 일어나므로 런타임 성능 저하는 없습니다. 하지만 범용성을 위해 작성된 제네릭 코드는, 컴파일러가 생성한 인스턴스 코드가 특정 타입에 대해 수작업으로 최적화한(hand-tuned) 코드보다 이론적으로 덜 효율적일 가능성이 아주 약간 존재할 수 있습니다. 예를 들어, 특정 타입의 크기나 정렬(alignment)을 알고 있어 가능한 최적화를 제네릭 코드는 수행하지 못할 수도 있습니다.
  • 지침: 이는 극히 예외적인 상황입니다. 항상 제네릭과 같이 명확하고 재사용 가능한 해법으로 시작해야 합니다. 그 후, 성능 프로파일링을 통해 해당 부분이 정말로 병목 지점임이 입증되었을 때만, 수작업으로 최적화된 구체적인 구현으로의 교체를 고려해야 합니다. “섣부른 최적화는 모든 악의 근원이다”라는 격언을 기억해야 합니다.

결론적으로, 제네릭은 복잡성을 관리하고 재사용성을 높이기 위한 강력한 도구입니다. 하지만 제네릭을 사용함으로써 얻는 이득보다 추상화로 인한 복잡성이 더 크다고 판단될 때는, 더 단순하고 직접적인 방법이 현명한 선택일 수 있습니다.

13. 명령형 프로그래밍

지금까지 우리는 Ada 언어의 기초부터 시작하여 타입, 제어 구조, 서브프로그램, 패키지, 그리고 제네릭에 이르기까지 프로그램을 구축하는 데 필요한 다양한 도구들을 학습하였습니다. 독자 여러분께서는 이제 변수를 선언하고, 조건에 따라 흐름을 바꾸며, 반복적인 작업을 수행하고, 코드를 재사용 가능한 단위로 묶는 등 프로그램을 작성하는 실질적인 기술에 익숙해졌을 것입니다.

이번 장에서는 한 걸음 물러나, 우리가 지금까지 당연하게 사용해온 프로그래밍 방식의 근본적인 ‘사고의 틀’, 즉 프로그래밍 패러다임(Programming Paradigm)에 대해 성찰해 보고자 합니다. 우리가 작성해 온 코드들은 모두 명령형 프로그래밍(Imperative Programming)이라는 거대한 패러다임에 속해 있습니다.

명령형 프로그래밍은 프로그램의 상태(state)를 어떻게 변경할 것인지에 대한 명령(command)의 연속으로 프로그램을 기술하는 방식입니다. 이는 컴퓨터가 동작하는 방식과 매우 닮아 있어 직관적이지만, 프로그램의 규모가 커질수록 복잡성을 관리하는 데 한계를 드러내기도 합니다.

본 장의 목표는 새로운 기술을 배우는 것이 아니라, 우리가 이미 알고 있는 지식을 ‘명령형 프로그래밍’이라는 이론적 관점에서 재정리하는 것입니다. 이를 통해 우리는 이 패러다임의 본질적인 특징과 장점, 그리고 한계를 명확히 이해하게 될 것입니다. 이 이해는 이어지는 장에서 다룰 객체 지향 프로그래밍과 함수형 프로그래밍이 어떤 문제를 해결하기 위해 등장했으며, 상태를 관리하는 방식에 있어 어떤 근본적인 차이를 보이는지를 깊이 있게 파악하는 견고한 토대가 되어 줄 것입니다.

13.1 패러다임의 정의

프로그래밍 패러다임(programming paradigm)이란, 소프트웨어를 설계하고 구현하는 데 있어 근간이 되는 사고의 틀(framework) 또는 스타일(style)을 의미합니다. 이는 특정 프로그래밍 언어의 문법을 넘어, 문제를 어떻게 바라보고, 코드의 구조를 어떻게 조직하며, 복잡성을 어떻게 관리할 것인지에 대한 근본적인 접근 방식을 정의합니다.

지금까지 독자 여러분께서는 Ada 언어의 다양한 기능들을 학습하며 자연스럽게 하나의 특정 패러다임에 익숙해졌습니다. 이번 13장은 바로 그 패러다임, 즉 명령형 프로그래밍의 본질을 명확히 정의하고 그 특징을 성찰하는 데 목적이 있습니다.

본 절에서는 명령형 프로그래밍이 컴퓨터의 동작 방식과 얼마나 닮아있는지, 그리고 ‘상태’를 ‘명령’으로 바꾸어 나간다는 핵심 개념이 무엇인지 살펴볼 것입니다. 또한, 초기의 단순한 명령어 나열에서 코드의 재사용과 구조화를 가능하게 한 ‘절차형 프로그래밍’으로의 중요한 발전을 추적하며, 우리가 사용하는 Ada가 어떤 위치에 있는지 조망해 보겠습니다.

13.1.1 명령(Commands)과 상태(State)의 개념

**명령형 프로그래밍(Imperative Programming)**의 본질을 이해하기 위한 첫걸음은 **‘상태(State)’**와 **‘명령(Command)’**이라는 두 가지 핵심 개념을 파악하는 것입니다. 이 패러다임의 이름은 “명령하다”를 의미하는 라틴어 ‘imperare’에서 유래했으며, 그 이름처럼 프로그램의 동작을 컴퓨터에 내리는 일련의 명령으로 기술합니다.

상태 (State)

프로그램의 상태란, 특정 시점에 프로그램의 메모리에 저장된 모든 값들의 집합을 의미합니다. 가장 단순하게는, 프로그램에 선언된 모든 변수들과 그 값들이 바로 프로그램의 상태를 구성합니다.

X : Integer := 10;
Y : Integer := 20;
Is_Ready : Boolean := False;

위 코드에서 프로그램의 현재 상태는 X10이고, Y20이며, Is_ReadyFalse인 상황으로 정의될 수 있습니다.

명령 (Command)

명령이란 바로 이 프로그램의 상태를 변경하는 가장 작은 실행 단위를 말합니다. Ada에서는 이를 **문장(statement)**이라고 부릅니다. 명령형 프로그래밍은 이러한 문장들을 순서대로 실행하여, 프로그램의 상태를 원하는 최종 상태로 점진적으로 바꾸어 나가는 과정 그 자체입니다.

가장 대표적인 명령은 변수의 값을 바꾸는 **대입문(:=)**입니다.

-- 현재 상태: X = 10
X := X + 1;
-- 이 명령이 실행된 후, 프로그램의 상태는 X = 11로 변경됩니다.

“어떻게(How)”에 초점을 맞추는 접근법

이처럼 명령형 프로그래밍은 “무엇을(What) 원하는가”보다는, 원하는 결과를 얻기 위해 “어떻게(How) 컴퓨터의 상태를 순서대로 변경해 나갈 것인가”에 대한 절차를 상세히 기술하는 방식입니다.

-- 10과 20의 합을 구하는 과정
Sum : Integer; -- 초기 상태: Sum은 정의되지 않음
Sum := 10;     -- 상태 변경 1: Sum은 10
Sum := Sum + 20; -- 상태 변경 2: Sum은 30

위 코드는 ‘합은 30이다’라는 결과를 선언하는 대신, ‘Sum이라는 상태를 만들고, 10을 넣은 뒤, 20을 더하라’는 구체적인 명령 절차를 통해 결과를 도출합니다.

지금까지 독자 여러분께서 작성해 온 대부분의 코드가 바로 이 명령형 패러다임에 속합니다. 변수를 선언하고, for 루프를 돌며 값을 바꾸고, if 문으로 흐름을 제어하는 모든 행위가 프로그램의 상태를 정해진 순서에 따라 변경하는 ‘명령’이기 때문입니다. 다음 절에서는 이 명령들을 구조화하는 ‘절차형 프로그래밍’으로의 발전에 대해 알아보겠습니다.

13.1.2 절차형 프로그래밍으로의 발전

초기의 명령형 프로그래밍은 단순히 명령어들을 순서대로 나열하는 방식이었습니다. 프로그램의 규모가 작을 때는 이 방식만으로도 충분했지만, 프로그램이 복잡해지면서 동일한 코드 묶음이 여러 곳에서 반복적으로 나타나는 문제가 발생했습니다. 이러한 코드 중복은 유지보수를 어렵게 하고 프로그램의 전체적인 구조를 파악하기 힘들게 만들었습니다.

이러한 한계를 극복하기 위해 등장한 것이 바로 **절차형 프로그래밍(Procedural Programming)**입니다. 절차형 프로그래밍은 명령형 프로그래밍의 하위 개념으로, 반복적으로 사용되는 일련의 명령들을 하나의 논리적 단위로 묶어 이름을 붙이고, 필요할 때마다 호출하여 재사용할 수 있도록 하는 프로시저(Procedure) 또는 서브루틴(Subroutine)이라는 개념을 도입했습니다.

프로시저(Procedure)를 통한 구조화와 재사용

프로시저는 특정 작업을 수행하는 코드 블록에 대한 추상화입니다. 예를 들어, 두 변수의 값을 교환하는 로직이 프로그램 여러 곳에서 필요하다면, 이 로직을 Swap이라는 단일 프로시저로 정의할 수 있습니다.

procedure Swap (A, B : in out Integer) is
   Temp : constant Integer := A;
begin
   A := B;
   B := Temp;
end Swap;

이제 프로그래머는 복잡한 교환 로직을 매번 다시 작성할 필요 없이, Swap (X, Y); 와 같이 단 한 줄의 호출만으로 해당 기능을 재사용할 수 있습니다. 이는 다음과 같은 중요한 이점을 가져옵니다.

  1. 코드 재사용성 (Reusability): 코드 중복을 제거하여 프로그램의 크기를 줄이고, 로직 수정이 필요할 때 단 한 곳(프로시저 본체)만 수정하면 되므로 유지보수성이 크게 향상됩니다.
  2. 문제 분해 (Decomposition): 거대하고 복잡한 문제를 여러 개의 작고 관리 가능한 프로시저 단위로 분해할 수 있습니다. 각 프로시저는 특정 기능에만 집중하므로, 전체 시스템의 구조가 명확해지고 개발과 테스트가 용이해집니다.

Ada: 대표적인 절차형 언어

우리가 지금까지 학습한 Ada의 핵심 기능들, 즉 procedurefunction으로 대표되는 서브프로그램은 바로 이 절차형 프로그래밍 패러다임의 정수를 보여줍니다. Ada는 단순히 프로시저를 제공하는 것을 넘어, 매개변수 모드(in, out, in out), 패키지를 통한 모듈화 등 강력한 기능을 통해 코드의 구조화와 재사용성을 극대화하도록 설계되었습니다.

결론적으로, 절차형 프로그래밍은 단순한 명령어의 나열이었던 초기 명령형 프로그래밍에 **‘구조’**라는 개념을 부여한 중요한 발전입니다. 이는 프로그래머가 더 높은 수준의 추상화에서 사고하고, 더 크고 복잡한 문제를 체계적으로 해결할 수 있는 길을 열었습니다. 우리가 배운 Ada의 기본 프로그래밍 방식이 바로 이 절차형, 그리고 더 큰 틀에서는 명령형 패러다임에 속해 있는 것입니다.

13.2 명령형 패러다임의 핵심 요소

앞선 절에서 우리는 명령형 프로그래밍이 프로그램의 상태(State)명령(Command)의 연속으로 변경해 나가는 패러다임임을 정의했습니다. 그렇다면 이러한 상태와 명령은 Ada 코드에서 구체적으로 어떤 모습으로 나타날까요?

이번 절에서는 명령형 패러다임을 구성하는 세 가지 핵심적인 건축 자재, 즉 변수와 대입, 제어 구조, 그리고 서브프로그램에 대해 자세히 살펴봅니다. 이 요소들은 독자 여러분께서 이미 이전 장들을 통해 익숙하게 사용해 온 기능들이지만, 여기서는 이들을 ‘명령형 패러다임’이라는 관점에서 재조명하여 그 역할과 의미를 명확히 하고자 합니다.

이 세 가지 요소가 어떻게 상호작용하여 프로그램의 상태를 관리하고, 실행 흐름을 제어하며, 복잡한 로직을 구조화하는지 이해함으로써, 우리는 명령형 패러다임의 동작 원리를 더 깊이 있게 파악하게 될 것입니다.

13.2.1 변수(Variables)와 대입(Assignment)

명령형 프로그래밍의 세계에서 **상태(state)**를 담는 물리적인 실체는 바로 **변수(variable)**입니다. 변수는 프로그램이 데이터를 저장하기 위해 사용하는 이름이 붙여진 메모리 공간이며, 명령형 패러다임의 가장 기본적인 구성 요소입니다. 프로그램의 전체 상태는 결국 모든 변수들의 값의 총합으로 표현될 수 있습니다.

이러한 변수의 상태를 변경하는 가장 핵심적인 **명령(command)**이 바로 **대입문(assignment statement)**입니다. Ada에서 대입은 := 연산자를 통해 이루어지며, 이는 “오른쪽 표현식의 값을 계산하여 왼쪽 변수의 상태를 그것으로 변경하라”는 명확하고 직접적인 명령입니다.

다음 코드는 변수와 대입을 통해 프로그램의 상태가 어떻게 점진적으로 변하는지를 보여줍니다.

procedure State_Change_Example is
   -- 1. 초기 상태 설정
   --    Counter 변수가 생성되고 상태는 0이 됩니다.
   Counter : Integer := 0;
begin
   -- Counter의 상태는 0

   -- 2. 첫 번째 상태 변경
   --    Counter 변수에 새로운 값 10을 대입합니다.
   Counter := 10;
   -- 이제 Counter의 상태는 10

   -- 3. 두 번째 상태 변경
   --    Counter의 현재 상태(10)를 읽고, 5를 더한 뒤,
   --    그 결과(15)를 다시 Counter에 대입합니다.
   Counter := Counter + 5;
   -- 이제 Counter의 최종 상태는 15
end State_Change_Example;

이처럼 명령형 프로그래밍의 본질은 변수라는 상태 저장소를 정의하고, 대입이라는 명령을 통해 그 상태를 원하는 목표에 도달할 때까지 순차적으로 변경해 나가는 과정입니다. 이어지는 객체 지향 및 함수형 패러다임은 바로 이 ‘변경 가능한 상태(mutable state)’를 어떻게 관리하거나 혹은 회피할 것인가에 대한 각기 다른 해법을 제시하게 됩니다.

13.2.2 제어 구조 (Control Structures)

변수와 대입문이 프로그램의 상태를 저장하고 변경하는 수단을 제공한다면, 제어 구조(Control Structure)는 이러한 상태 변경 명령들의 실행 순서를 결정하는 규칙과 구문을 제공합니다. 프로그램은 단순히 위에서 아래로만 실행되지 않습니다. 특정 조건에 따라 다른 경로를 선택하거나, 특정 작업을 반복적으로 수행해야 합니다.

명령형 패러다임은 컴퓨터 과학의 구조적 프로그래밍 정리(structured program theorem)에 따라, 모든 알고리즘을 세 가지 기본적인 제어 구조의 조합만으로 표현할 수 있다는 원칙에 기반합니다. 독자 여러분께서는 이미 7장에서 이 구조들을 학습했습니다.

  1. 순차 (Sequence): 가장 기본적인 제어 구조로, 문장들이 코드에 작성된 순서대로 하나씩 실행되는 것입니다. 이는 별도의 키워드 없이 beginend 사이의 모든 문장에 기본적으로 적용됩니다.

  2. 선택 (Selection): 특정 조건의 평가 결과에 따라 여러 실행 경로 중 하나를 선택합니다. 이는 프로그램이 결정을 내릴 수 있게 해주는 핵심적인 구조입니다.
    • if-then-elsif-else: 복잡한 논리 조건을 기반으로 분기합니다.
    • case: 단일 이산 값에 따라 여러 경로 중 하나로 명확하게 분기합니다.
  3. 반복 (Iteration): 특정 코드 블록을 여러 번 반복적으로 실행합니다.
    • for: 정해진 횟수만큼 반복합니다.
    • while: 특정 조건이 참인 동안 반복합니다.
    • loop with exit: 더 유연한 종료 조건을 가진 반복을 구현합니다.

명령형 패러다임에서의 역할 이러한 제어 구조는 명령형 패러다임에서 상태 변경의 흐름을 지휘하는 오케스트라의 지휘자와 같은 역할을 합니다. if 문은 “만약 Is_Ready 상태가 True이면, 이 명령들을 실행하라”고 지시하고, for 루프는 “이 배열의 모든 요소에 대해 상태 변경 명령을 반복하라”고 지시합니다.

결론적으로, 명령형 프로그램은 변수라는 ‘상태’를, 제어 구조라는 ‘흐름’에 따라, 대입문이라는 ‘명령’을 통해 점진적으로 변화시켜 나가는 과정이라고 정의할 수 있습니다. 이 세 가지 요소가 바로 명령형 패러다G임의 핵심적인 뼈대를 이룹니다.

13.2.3 서브프로그램 (Subprograms)

변수와 제어 구조만으로는 복잡한 프로그램을 체계적으로 관리하기 어렵습니다. 프로그램의 규모가 커지면, 특정 작업을 수행하는 일련의 명령들이 여러 곳에서 반복적으로 나타나게 됩니다. 이러한 코드 중복은 유지보수를 어렵게 하고 전체적인 구조를 파악하기 힘들게 만듭니다.

서브프로그램(Subprogram)은 이러한 문제를 해결하기 위해 명령형 패러다임 내에서 발전한 핵심적인 추상화 도구입니다. 서브프로그램은 관련된 일련의 명령(상태 변경)들을 하나의 논리적 단위로 묶어 고유한 이름을 부여한 것입니다. Ada에서는 이를 프로시저(procedure)함수(function)로 구현합니다.

명령형 패러다임에서의 역할

명령형 패러다임에서 서브프로그램의 본질적인 역할은 복잡한 상태 변경 과정을 하나의 단일 명령으로 캡슐화하는 것입니다.

  • 추상화 (Abstraction): 서브프로그램의 호출자는 그 내부가 얼마나 복잡한 상태 변경 과정을 거치는지 알 필요가 없습니다. 예를 들어, Sort_Array(My_Data);라는 단 한 줄의 프로시저 호출은, 그 내부에서 수많은 비교와 대입(상태 변경)이 일어남에도 불구하고, 호출자에게는 “배열을 정렬하라”는 하나의 단순한 명령으로 인식됩니다. 이처럼 서브프로그램은 “어떻게(How)”를 숨기고 “무엇을(What)” 할 것인지만을 드러냅니다.

  • 분해 (Decomposition): “사용자 입력을 받아, 데이터를 검증하고, 결과를 파일에 저장한다”는 거대한 작업을 Get_Input, Validate_Data, Save_Result라는 세 개의 작은 서브프로그램으로 분해할 수 있습니다. 각 서브프로그램은 명확하게 정의된 하나의 책임만 가지므로, 전체 시스템을 더 이해하고 관리하기 쉬운 단위들로 구조화할 수 있습니다.

결론적으로, 서브프로그램은 명령형 프로그래밍의 기본 요소인 ‘명령’들을 조직하고 재사용하며 추상화하는 가장 중요한 수단입니다. 이를 통해 개발자는 저수준의 개별 상태 변경을 넘어, 더 높은 수준의 논리적 동작 단위로 프로그램을 설계할 수 있게 됩니다. 이것이 바로 13.1.2절에서 언급한 절차형 프로그래밍의 핵심입니다.

13.3 장점과 한계

명령형 프로그래밍은 컴퓨터 하드웨어의 동작 방식(폰 노이만 아키텍처)을 가장 직접적으로 반영하므로, 프로그래머에게 가장 직관적이고 친숙한 패러다임입니다. 이 패러다임은 수십 년간 소프트웨어 개발의 주류를 이루며 수많은 성공적인 시스템을 구축하는 데 기여해 왔습니다.

이번 절에서는 이처럼 보편적인 명령형 패러다임이 갖는 본질적인 장점과, 동시에 소프트웨어의 규모가 커지고 복잡해짐에 따라 드러나는 근본적인 한계점을 분석하고자 합니다.

먼저, 하드웨어에 대한 직접적인 제어에서 비롯되는 성능상의 이점과 직관성을 살펴볼 것입니다. 이어서, 이 패러다’임의 핵심인 ‘공유된 가변 상태(Shared Mutable State)’가 어떻게 프로그램의 복잡도를 기하급수적으로 증가시키고, 특히 동시성 환경에서 경쟁 상태와 같은 심각한 문제를 야기하는지에 대해 깊이 있게 고찰합니다. 이 장점과 한계에 대한 명확한 이해는, 이어지는 장에서 객체 지향 및 함수형 패러다임이 왜 필요한지에 대한 설득력 있는 근거가 될 것입니다.

13.3.1 장점: 직관성과 하드웨어 친화성

명령형 프로그래밍이 수십 년간 소프트웨어 개발의 지배적인 패러다임으로 자리 잡을 수 있었던 이유는 두 가지 근본적인 장점, 즉 직관성하드웨어 친화성에 기인합니다.

1. 직관성

명령형 패러다임은 문제를 해결하기 위한 일련의 순차적인 단계를 기술하는 방식으로, 인간의 절차적 사고 과정과 매우 유사합니다. “첫 번째로 이 작업을 수행하고, 그 결과에 따라 다음 작업을 수행한다”는 식의 단계별 명령 흐름은 프로그래머가 코드의 동작을 예측하고 이해하기에 매우 직관적입니다.

2. 하드웨어 친화성

명령형 프로그래밍의 구조는 현대 컴퓨터의 근간을 이루는 폰 노이만 아키텍처(von Neumann architecture)의 동작 원리를 직접적으로 반영합니다. 폰 노이만 아키텍처는 중앙 처리 장치(CPU)가 메모리로부터 명령어를 순차적으로 읽어와 실행하고, 그 결과로 메모리의 데이터를 변경하는 방식으로 동작합니다.

명령형 프로그래밍의 핵심 요소는 이러한 하드웨어 구조에 거의 일대일로 대응됩니다.

명령형 프로그래밍 요소 하드웨어 아키텍처 요소
변수 (Variable) 메모리 위치 (Memory Location)
대입문 (Assignment) 데이터 저장 명령어 (Store Instruction)
제어 구조 (Control Flow) 분기/점프 명령어 (Branch/Jump Instruction)

이러한 밀접한 관계 덕분에, 명령형 코드는 컴파일러에 의해 매우 효율적인 기계어로 쉽게 변환될 수 있습니다. 이는 시스템의 자원을 최대한 활용하고 최고 수준의 성능을 달성해야 하는 시스템 프로그래밍에서 명령형 패러다임이 여전히 강력한 이유입니다.

결론적으로, 명령형 프로그래밍은 인간의 사고방식과 기계의 동작 방식 양쪽에 모두 잘 부합하므로, 배우기 쉽고 높은 성능을 발휘할 수 있다는 근본적인 장점을 가집니다.

13.3.2 한계: 복잡성 관리의 어려움

명령형 프로그래밍은 직관적이고 하드웨어 친화적이라는 명확한 장점을 가지지만, 소프트웨어의 규모가 커지고 복잡해짐에 따라 근본적인 한계에 직면합니다. 그 한계의 핵심에는 명령형 패러다임의 본질인 공유된 가변 상태(Shared Mutable State)가 있습니다.

1. 공유된 가변 상태로 인한 복잡성 증가

프로그램이 작을 때는 몇 개의 변수 상태를 추적하는 것이 어렵지 않습니다. 하지만 수백, 수천 개의 변수가 존재하고, 수십 개의 서브프로그램이 이 변수들을 공유하며 자유롭게 수정할 수 있는 대규모 시스템을 상상해 보십시오.

이러한 환경에서 특정 시점의 프로그램 상태를 정확히 파악하는 것은 거의 불가능에 가깝습니다. 코드의 한 부분을 수정했을 때, 이 변경이 공유 상태를 통해 시스템의 다른 어떤 부분에 예기치 않은 부작용(side effect)을 일으킬지 예측하기가 매우 어려워집니다. 이는 “스파게티 코드”의 근본적인 원인이 되며, 디버깅을 매우 고통스러운 과정으로 만듭니다.

2. 동시성 프로그래밍의 난제

이 문제는 여러 태스크가 동시에 실행되는 동시성 환경에서 극대화됩니다. 여러 태스크가 아무런 통제 없이 동일한 공유 변수를 수정하려고 시도하면, 실행 순서의 미세한 차이에 따라 결과가 완전히 달라지는 경쟁 상태(race condition)가 발생합니다. 이를 방지하기 위해 프로그래머는 잠금(lock)과 같은 복잡한 동기화 메커니즘을 직접 구현해야 하며, 이는 다시 교착 상태(deadlock)와 같은 또 다른 심각한 문제를 야기할 수 있습니다.

3. 유지보수성 저하

공유 상태는 프로그램의 여러 부분을 보이지 않는 실로 강하게 엮어놓습니다(강한 결합도, Tight Coupling). 이로 인해 시스템은 변화에 취약해집니다. 잘 격리된 모듈이라면 수정이 용이하지만, 공유 상태에 깊이 의존하는 코드는 작은 변경조차 예상치 못한 파급 효과를 일으켜 전체 시스템의 안정성을 위협할 수 있습니다.

결론적으로, 명령형 프로그래밍의 단순함과 유연성은 프로그램의 규모가 커짐에 따라 복잡성을 관리하는 데 있어 양날의 검이 됩니다. 바로 이 ‘상태 관리의 복잡성’이라는 한계를 극복하기 위해, 상태를 객체라는 단위로 체계적으로 캡슐화하는 객체 지향 프로그래밍과, 가변 상태 자체를 근본적으로 회피하려는 함수형 프로그래밍이라는 새로운 패러다임이 등장하게 된 것입니다.

13.4 다른 패러다임으로의 전환점

지금까지 우리는 명령형 프로그래밍의 본질적인 특징과 그 장점 및 한계를 살펴보았습니다. 이 패러다임은 컴퓨터의 작동 방식과 닮아 있어 효율적이고 직관적이지만, 프로그램의 규모가 커질수록 ‘공유된 가변 상태(Shared Mutable State)’를 관리하는 복잡성이 기하급수적으로 증가한다는 근본적인 도전에 직면합니다.

소프트웨어 공학의 역사는 바로 이 복잡성의 문제를 어떻게 더 효과적으로 해결할 것인가에 대한 끊임없는 탐구의 과정이었습니다. 이 과정에서 등장한 두 개의 거대한 해법이 바로 이어지는 장들에서 우리가 배울 객체 지향 프로그래밍과 함수형 프로그래밍입니다.

이번 마지막 절에서는 명령형 패러다임이 마주한 한계를 ‘전환점’으로 삼아, 이 두 새로운 패러다임이 어떤 철학적 배경에서 출발했으며, 상태를 다루는 방식에 있어 어떤 근본적인 차이를 보이는지 조망해 보겠습니다. 이는 우리가 왜 새로운 패러다임을 배워야 하는지에 대한 동기를 부여하고, 앞으로의 학습을 위한 개념적 지도를 제공하는 중요한 다리 역할을 할 것입니다.

13.4.1 상태를 어떻게 관리할 것인가?: 객체 지향으로의 길

명령형 패러다임의 근본적인 한계는 ‘공유된 가변 상태’를 효과적으로 통제할 수단이 부족하다는 데 있습니다. 프로그램의 규모가 커질수록, 수많은 프로시저들이 서로 얽혀있는 변수들의 상태를 무질서하게 변경하기 시작하면, 시스템 전체의 동작을 예측하고 유지보수하는 것은 거의 불가능에 가까워집니다.

이러한 상태 관리의 혼돈(chaos)에 대한 첫 번째 위대한 해답이 바로 객체 지향 프로그래밍(Object-Oriented Programming, OOP)입니다.

객체 지향 패러다임의 핵심적인 아이디어는 상태 자체를 없애는 것이 아니라, 상태를 더 작고 관리 가능한 단위로 ‘구조화’하고 ‘캡슐화(encapsulate)’하는 것입니다. 즉, 서로 관련된 데이터(상태)와 그 데이터를 조작하는 서브프로그램(명령)들을 하나의 ‘객체(Object)’라는 캡슐 안에 함께 묶어버립니다.

이 캡슐은 다음과 같은 중요한 규칙을 가집니다.

  1. 상태 보호: 객체의 데이터(상태)는 외부로부터 직접 접근할 수 없도록 숨겨집니다(private).
  2. 통제된 접근: 객체의 상태를 변경할 수 있는 유일한 방법은, 객체가 외부에 공개하는 잘 정의된 인터페이스, 즉 메서드(method)를 통해서만 가능합니다.

은행 계좌를 예로 들어보겠습니다. 명령형 방식에서는 balance라는 변수가 여기저기서 직접 수정될 수 있지만, 객체 지향 방식에서는 Account라는 객체만이 자신의 balance를 알며, 오직 withdraw(amount)deposit(amount)와 같은 메서드를 통해서만 잔고를 변경할 수 있습니다. withdraw 메서드는 잔고가 충분한지 검사하는 로직을 내부에 포함하여, 계좌의 상태가 항상 유효하도록(예: 음수가 되지 않도록) 스스로 책임집니다.

이처럼 객체 지향 프로그래밍은 상태를 무질서하게 흩어놓는 대신, 각 객체가 자신의 상태를 스스로 책임지도록 하는 방식으로 복잡성을 관리합니다. 이는 명령형 패러다임의 한계를 극복하고 더 크고 안정적인 시스템을 구축하는 길을 열었으며, 이어지는 14장에서 우리는 Ada가 패키지태그드 타입을 통해 이를 어떻게 구현하는지 자세히 배우게 될 것입니다.

13.4.2 상태를 어떻게 피할 것인가?: 함수형으로의 길

객체 지향 프로그래밍이 ‘상태 관리의 혼돈’을 캡슐화라는 벽을 통해 질서 있게 ‘관리’하려는 접근법이라면, 이와는 전혀 다른 철학에서 출발한 또 하나의 위대한 해답이 있습니다. 바로 함수형 프로그래밍(Functional Programming, FP)입니다.

함수형 패러다임은 문제의 근원인 가변 상태(mutable state) 자체를 가능한 한 피하거나 제거하려는, 더 급진적인 접근법을 취합니다.

함수형 프로그래밍은 계산(computation)을 ‘명령어의 연속’으로 보지 않고, ‘수학적 함수의 평가(evaluation)’로 간주합니다. 수학에서 함수 $f(x) = x + 1$은, 입력 $x$가 주어졌을 때 항상 결과 $x+1$을 내놓으며, 그 과정에서 $x$ 자체의 값을 바꾸거나 다른 외부 값에 영향을 주지 않습니다.

함수형 프로그래밍은 바로 이 ‘부작용이 없는(side-effect-free)’ 수학적 함수의 개념을 코드에 적용합니다.

  1. 불변성 (Immutability): 변수의 상태를 직접 변경하는 것을 지양합니다. X := X + 1; 과 같이 기존 X의 상태를 바꾸는 대신, Y := X + 1; 처럼 계산 결과를 담을 새로운 상수를 만듭니다. 데이터는 한번 생성되면 변하지 않습니다.
  2. 순수 함수 (Pure Functions): 함수의 결과는 오직 입력된 인자에 의해서만 결정됩니다. 함수는 외부의 어떤 상태도 변경하지 않으며, 동일한 입력에 대해 항상 동일한 출력을 보장합니다.

이는 상태 변경의 예측 불가능한 파급 효과와 경쟁 상태와 같은 동시성 문제를 근본적으로 제거하는 매우 강력한 접근법입니다. 프로그램의 동작은 더 이상 언제, 어떤 순서로 실행되었는지에 따라 달라지지 않게 됩니다.

이처럼 함수형 프로그래밍은 상태를 관리하는 복잡성에 정면으로 맞서는 대신, 상태 변경이라는 개념 자체를 최소화함으로써 복잡성을 회피하는 길을 제시합니다. 이어지는 15장에서 우리는 Ada가 표현식(expression)과 고차 함수 등을 통해 이러한 함수형 스타일을 어떻게 지원하는지 배우게 될 것입니다.

13.4.3 Ada의 다중 패러다임 접근법 요약

지금까지 우리는 명령형 프로그래밍의 한계를 극복하기 위해 객체 지향과 함수형이라는 두 가지 다른 길이 존재함을 확인했습니다. 그렇다면 Ada는 이 중 어떤 길을 선택했을까요?

Ada의 대답은 “어느 한쪽이 아닌, 둘 모두를 실용적으로 수용한다” 입니다. Ada는 특정 패러다임의 순수성을 고집하는 학문적인 언어가 아니라, 크고 복잡하며 신뢰성이 요구되는 시스템을 구축하기 위한 실용적인 공학 도구입니다. 이러한 철학에 따라 Ada는 여러 패러다임의 장점을 통합한 다중 패러다임 언어(Multi-paradigm Language)로 설계되었습니다.

Ada 안에서 각 패러다임은 다음과 같은 역할을 수행합니다.

  1. 명령형/절차형 패러다임 (기반): Ada의 가장 근간을 이루는 뼈대입니다. 언어의 기본적인 구조, 문장, 제어 흐름은 모두 명령형 패러다임에 뿌리를 두고 있습니다. 이는 하드웨어와 가깝고 직관적인 제어를 가능하게 합니다.

  2. 객체 지향 패러다임 (구조): 명령형 프로그래밍의 복잡성을 관리하기 위한 핵심적인 구조화 도구입니다. Ada는 패키지와 태그드 타입을 통해 상태와 행위를 객체 단위로 캡슐화하여, 대규모 시스템을 독립적인 컴포넌트들의 조합으로 구축할 수 있게 해줍니다.

  3. 함수형 패러다임 (보완): 프로그램의 신뢰성과 명료성을 높이는 보완적인 도구입니다. Ada는 표현식, 순수 함수, 불변 데이터 등의 함수형 기능들을 제공하여, 프로그래머가 상태 변경으로 인한 부작용을 최소화하고 더 안전하며 예측 가능한 코드를 작성할 수 있도록 지원합니다.

이처럼 Ada는 프로그래머에게 단 하나의 해결책을 강요하지 않습니다. 대신, 해결하려는 문제의 성격에 따라 가장 적합한 패러다임을 선택하거나 조합하여 사용할 수 있는 유연하고 풍부한 도구상자를 제공합니다.

이제 우리는 명령형 프로그래밍이라는 공통의 출발점을 명확히 이해했으니, 다음 장부터는 Ada가 상태를 ‘관리’하고 ‘회피’하기 위해 제공하는 객체 지향과 함수형이라는 두 개의 강력한 세계를 본격적으로 탐험할 준비가 되었습니다.

14. 객체 지향 프로그래밍 (Object-Oriented Programming)

이전 13장에서는 프로그램의 상태를 순차적인 명령으로 변경해 나가는 명령형 프로그래밍의 본질을 살펴보았습니다. 이 패러다임은 직관적이지만, 프로그램의 규모가 커질수록 ‘공유된 가변 상태’로 인한 복잡성이 증가한다는 근본적인 한계에 직면합니다.

이번 장에서는 이러한 복잡성을 관리하기 위한 강력하고 체계적인 해법, 바로 객체 지향 프로그래밍(Object-Oriented Programming, OOP)에 대해 깊이 있게 탐구합니다. OOP는 상태 자체를 없애는 대신, 서로 관련된 데이터(상태)와 그 데이터를 조작하는 행위(메서드)를 ‘객체(Object)’라는 하나의 단위로 캡슐화하는 접근법입니다.

우리는 Ada가 패키지와 태그드 타입(tagged type)이라는 두 가지 핵심 요소를 어떻게 조합하여 객체 지향의 세 기둥인 캡슐화, 상속, 다형성을 구현하는지 학습할 것입니다. 이를 통해 단순히 코드를 작성하는 것을 넘어, 재사용 가능하고, 확장하기 쉬우며, 유지보수하기 용이한 견고한 소프트웨어 아키텍처를 설계하는 능력을 갖추게 될 것입니다. 이 장을 통해 여러분은 명령형 프로그래밍의 한계를 넘어, 대규모 시스템을 구축하는 새로운 사고의 틀을 얻게 될 것입니다.

14.1 태그드 타입(Tagged Types)과 상속 (Inheritance)

객체 지향 프로그래밍의 핵심적인 아이디어 중 하나는 기존의 타입을 기반으로 새로운 타입을 확장(extension)하여, 코드의 재사용성을 높이고 타입 간의 관계를 명확하게 표현하는 것입니다. Ada에서는 이러한 상속(Inheritance)과 이어지는 다형성(Polymorphism)을 구현하기 위한 특별한 종류의 레코드, 즉 태그드 타입(Tagged Type)을 그 기반으로 사용합니다.

이번 절에서는 일반 레코드와 태그드 타입의 근본적인 차이점을 알아보고, tagged 키워드가 어떻게 레코드를 ‘확장 가능한’ 형태로 만드는지 살펴봅니다. 이어서, new 키워드를 사용해 부모 타입으로부터 자식 타입을 파생시키는 타입 확장(상속)의 구체적인 구문과, 상속 계층에서 동작(메서드)을 나타내는 프리미티브 연산의 개념까지 학습할 것입니다. 이 과정을 통해 여러분은 Ada에서 객체 지향 타입 계층을 설계하는 첫걸음을 내딛게 됩니다.

14.1.1 tagged 타입: 확장 가능한 레코드

객체 지향 프로그래밍(OOP)의 핵심 사상 중 하나는 기존의 개념을 바탕으로 새로운 개념을 확장(extension)하고 구체화하는 것입니다. 예를 들어, ‘도형’이라는 일반적인 개념을 먼저 정의하고, 이를 바탕으로 ‘원’, ‘사각형’과 같이 더 구체적인 개념을 만들어 나가는 방식입니다.

5장에서 배운 일반적인 레코드(record)는 구조가 한번 정의되면 더 이상 변경할 수 없는 ‘닫힌(sealed)’ 구조입니다. 하지만 OOP를 위해서는 이러한 레코드를 확장하여 새로운 필드를 추가하고 새로운 동작을 정의할 수 있는 ‘열린’ 구조가 필요합니다.

Ada에서는 tagged 라는 키워드를 통해 이처럼 확장 가능한 레코드를 정의합니다.

tagged 키워드의 역할

record 키워드 앞에 tagged를 붙이면, 해당 레코드는 이제 상속 계층의 부모가 될 수 있는 특별한 타입으로 취급됩니다.

구문:

type Graphic_Object is tagged record
   Position_X : Float;
   Position_Y : Float;
   Is_Visible : Boolean := True;
end record;

Graphic_Object는 일반 레코드와 거의 동일해 보이지만, tagged 키워드 하나로 인해 두 가지 중요한 특성을 갖게 됩니다.

  1. 확장 가능성 (Extensibility): tagged 타입은 다른 타입이 자신을 상속(확장)하는 것을 허용합니다. 즉, 이 타입은 상속 계층의 최상위 루트(root) 또는 중간 부모가 될 수 있는 자격을 얻습니다.

  2. 숨겨진 ‘태그(Tag)’: tagged 타입의 모든 객체(object)는, 컴파일러가 추가한 숨겨진 데이터 필드인 ‘태그’를 갖게 됩니다. 이 태그는 객체가 실행 중에 자신의 정확한 타입이 무엇인지(예: Graphic_Object인지, 아니면 이를 상속받은 Circle인지) 식별할 수 있는 ‘신분증’과 같은 역할을 합니다. 이 런타임 타입 정보는 이후에 배울 다형성(polymorphism)과 동적 디스패칭(dynamic dispatching)을 가능하게 하는 핵심적인 메커니즘입니다.


상속 계층의 시작점

Ada에서 모든 상속은 tagged 타입으로부터 시작됩니다. 어떤 타입을 상속받아 새로운 필드를 추가하고 싶다면, 그 부모 타입은 반드시 tagged로 선언되어 있어야 합니다.

Graphic_Object를 기반으로 다음과 같은 상속 계층을 구상할 수 있습니다.

       [ Graphic_Object (tagged) ]
              /        \
             /          \
  [ Circle (extends Graphic_Object) ]  [ Rectangle (extends Graphic_Object) ]

Graphic_Object 타입은 모든 도형이 공통으로 가질 위치(Position)나 가시성(Is_Visible)과 같은 속성을 정의하는 기반 역할을 합니다.

결론적으로, tagged 타입은 단순한 데이터 묶음이었던 레코드를, OOP의 핵심인 상속과 다형성을 구현할 수 있는 동적인 객체로 변환하는 첫걸음입니다. 이 키워드를 통해 우리는 비로소 재사용 가능하고 확장성 높은 타입 계층을 설계할 수 있게 됩니다.

14.1.2 타입 확장 (상속)

타입 확장(Type Extension)은 Ada에서 상속(Inheritance)을 구현하는 방식입니다. new 키워드를 사용하여 기존 tagged 타입으로부터 새로운 타입을 파생시킬 수 있습니다. 이렇게 생성된 자식 타입은 부모 타입의 모든 필드를 물려받으며, 자신만의 새로운 필드를 추가할 수 있습니다.

구문:

type <자식_타입> is new <부모_타입> with record
  -- 자식 타입에만 추가되는 필드들 ...
end record;

예시: 앞서 정의한 Shape 타입을 확장하여 CircleRectangle이라는 두 개의 자식 타입을 만들어 보겠습니다.

package Shapes is
  type Shape is tagged record
    X, Y : Float;
  end record;

  -- 'Shape'을 상속받고 'Radius' 필드를 추가
  type Circle is new Shape with record
    Radius : Float;
  end record;

  -- 'Shape'을 상속받고 'Width'와 'Height' 필드를 추가
  type Rectangle is new Shape with record
    Width  : Float;
    Height : Float;
  end record;
end Shapes;

이제 Circle 타입의 객체는 부모로부터 물려받은 X, Y 필드와 자신만의 Radius 필드를 갖게 됩니다. 마찬가지로 Rectangle 객체는 X, Y, Width, Height 필드를 모두 갖습니다.

14.1.3 기본(Primitive) 연산

tagged 타입 T에 대한 기본 연산(primitive operation)이란, T 타입의 값을 매개변수나 반환 값으로 가지면서 T와 동일한 패키지 명세에 선언된 서브프로그램을 의미합니다.

자식 타입은 부모 타입의 필드뿐만 아니라 모든 기본 연산도 상속받습니다. 상속받은 연산은 자식 타입에 맞게 재정의(override)하여 각 타입에 특화된 행위를 구현할 수 있으며, 이것이 바로 다형성의 시작점입니다.

package Shapes is
  type Shape is tagged record
    X, Y : Float;
  end record;
  -- 'Display'는 'Shape'의 기본 연산입니다.
  procedure Display (S : in Shape);

  type Circle is new Shape with record
    Radius : Float;
  end record;
  -- 'Circle'은 'Display'를 상속받습니다.
  -- 'Circle'에 맞게 'Display'를 재정의합니다.
  procedure Display (C : in Circle);
end Shapes;

Circle 타입은 ShapeDisplay 프로시저를 상속받은 뒤, 원을 그리는 데 필요한 자신만의 Display 프로시저를 새롭게 정의(재정의)할 수 있습니다.

14.1.4 프리미티브 연산의 재정의 (Overriding)

상속의 강력함은 단순히 부모의 데이터 필드를 물려받는 것에 그치지 않습니다. 자식 타입은 부모로부터 물려받은 프리미티브 연산(primitive operation), 즉 동작(메서드)을 자신만의 새로운 방식으로 재정의(overriding)할 수 있습니다. 이는 OOP의 핵심적인 특징으로, 일반적인 개념을 구체적인 상황에 맞게 특화시키는 것을 가능하게 합니다.

예를 들어, 모든 ‘도형(Shape)’은 ‘면적을 구한다(Area)’는 공통된 동작을 가질 수 있습니다. 하지만 그 면적을 구하는 구체적인 ‘방식’은 ‘원(Circle)’과 ‘사각형(Rectangle)’이 서로 다릅니다. 재정의는 바로 이 ‘방식’을 자식 타입에 맞게 새로 구현하는 메커니즘입니다.

재정의의 원리

자식 타입의 프리미티브 연산을 재정의하는 방법은 간단합니다. 부모 타입에 속한 프리미티브 연산과 완전히 동일한 서브프로그램 명세(이름, 매개변수 프로파일, 반환 타입 등)를 가진 서브프로그램을 자식 타입을 위해 새로 선언하고 구현하면 됩니다.

컴파일러는 이를 ‘의도적인 재정의’로 인지하고, 해당 자식 타입의 객체에 대해서는 부모로부터 물려받은 연산 대신 새로 구현된 연산이 호출되도록 연결해 줍니다.

활용 예제: 도형의 면적 계산하기

Graphic_Object를 확장하여 Circle 타입을 만들고, Area라는 프리미티브 연산을 재정의해 보겠습니다.

1. 부모 타입과 기본 연산 정의

package Shapes is
   Pi : constant := 3.14159;

   type Shape is tagged record
      null; -- 간단한 예제를 위해 필드 없음
   end record;

   -- Shape 타입을 위한 기본 Area 함수 (프리미티브 연산)
   -- 일반적인 도형의 면적은 정의할 수 없으므로 0.0을 반환
   function Area (S : Shape) return Float;

end Shapes;

package body Shapes is
   function Area (S : Shape) return Float is
   begin
      return 0.0;
   end Area;
end Shapes;

2. 자식 타입 정의 및 연산 재정의

with Shapes; use Shapes;
package Circles is

   type Circle is new Shape with record
      Radius : Float;
   end record;

   -- Shape로부터 상속받은 Area 함수를 Circle 타입에 맞게 재정의
   overriding -- 재정의임을 명시하는 것이 좋은 스타일 (Ada 2005+)
   function Area (C : Circle) return Float;

end Circles;

package body Circles is
   -- 재정의된 Area 함수의 구현
   function Area (C : Circle) return Float is
   begin
      return Pi * C.Radius ** 2; -- 원의 면적 계산 공식
   end Area;
end Circles;

이제 Circle 타입의 객체에 대해 Area 함수를 호출하면, 부모인 ShapeArea가 아닌, Circles 패키지에 새로 구현된 Area 함수가 호출됩니다.

overriding 키워드의 중요성

Ada 2005부터는 재정의하는 서브프로그램 앞에 overriding 키워드를 붙이는 것이 권장됩니다. 이는 단순한 스타일 가이드를 넘어, 코드의 안정성을 높이는 중요한 역할을 합니다.

  • 컴파일 시점의 안전장치: 만약 overriding을 명시했는데, 부모 타입에 해당 명세를 가진 프리미티브 연산이 없거나, 혹은 개발자의 실수로 서브프로그램 이름이나 매개변수에 오타가 발생하면 컴파일러가 오류를 발생시킵니다. 이는 의도치 않게 재정의가 아닌 새로운 연산을 만드는 실수를 방지해 줍니다.
  • 가독성 향상: 코드를 읽는 사람에게 이 서브프로그램이 상속받은 동작을 재정의하고 있다는 의도를 명확하게 전달합니다.

결론적으로, 재정의는 상속받은 타입을 특정 상황에 맞게 커스터마이징하는 핵심적인 OOP 기법입니다. 이를 통해 ‘도형’이라는 추상적인 개념에 대해 Area라는 단일한 인터페이스를 유지하면서도, 각 도형의 구체적인 타입에 따라 각기 다른 방식으로 동작하게 만드는 다형성(polymorphism)의 기반을 마련할 수 있습니다.

14.2 캡슐화: private 타입과 정보 은닉

객체 지향 프로그래밍(OOP)의 세 가지 기둥은 상속, 다형성, 그리고 캡슐화(Encapsulation)입니다. 캡슐화는 관련된 데이터(상태)와 그 데이터를 조작하는 서브프로그램(동작)을 하나의 ‘캡슐’, 즉 객체로 묶고, 객체의 내부 구현을 외부로부터 숨기는 것을 목표로 합니다.

이렇게 내부 구현을 숨기는 것을 정보 은닉(Information Hiding)이라고 하며, 이는 소프트웨어의 안정성과 유지보수성을 높이는 데 결정적인 역할을 합니다. 사용자는 자동차를 운전할 때 핸들과 페달(공개된 인터페이스)만 조작할 뿐, 엔진 내부의 복잡한 동작(내부 구현)을 알 필요가 없는 것과 같은 원리입니다.

정보 은닉의 필요성: 공개된 레코드의 문제점

만약 태그드 레코드의 필드를 외부에서 직접 접근할 수 있도록 공개한다면 어떻게 될까요?

-- 나쁜 설계: 필드가 외부에 완전히 노출된 패키지
package Unsafe_Bank is
   type Account is tagged record
      Balance : Integer; -- 🚨 잔고 필드가 외부에 공개됨
   end record;

   procedure Deposit (This : in out Account; Amount : Positive);
end Unsafe_Bank;

이 설계는 두 가지 심각한 문제를 가집니다.

  1. 데이터 무결성 훼손: 패키지 외부의 어떤 코드라도 My_Account.Balance := -1_000_000; 와 같이 객체의 상태를 마음대로 조작할 수 있습니다. 이는 “잔고는 음수가 될 수 없다”와 같은 객체의 핵심 규칙(불변성, invariant)을 깨뜨려 시스템 전체를 불안정하게 만듭니다.
  2. 강한 결합(Tight Coupling): 만약 내부 구현을 변경하여 Balance 필드의 이름을 Current_Balance로 바꾸게 되면, Balance 필드에 직접 접근하던 모든 외부 코드가 전부 깨져버립니다. 즉, 내부 구현의 변경이 외부에 막대한 파급 효과를 일으킵니다.

Ada의 해법: private 타입

Ada는 패키지의 private 부분을 이용하여 완벽한 캡슐화와 정보 은닉을 구현합니다.

1. 공개부(Public Part) - 인터페이스 정의 패키지의 공개부에는 타입의 이름만 is tagged private;으로 선언하여, 타입의 존재와 인터페이스(프리미티브 연산)만을 외부에 알립니다.

-- 좋은 설계: private 타입을 이용한 캡슐화
package Safe_Bank is
   -- 타입의 이름만 공개. 구조는 숨겨져 있음.
   type Account is tagged private;

   -- 객체를 조작할 수 있는 유일한 통로인 서브프로그램들
   procedure Deposit (This : in out Account; Amount : Positive);
   procedure Withdraw (This : in out Account; Amount : Positive);
   function Get_Balance (This : Account) return Integer;

private -- 이 아래는 Safe_Bank 패키지를 사용하는 외부 코드에서는 보이지 않음
   type Account is tagged record
      Balance : Natural := 0; -- Natural 타입을 사용하여 음수 방지
   end record;
end Safe_Bank;

2. 비공개부(Private Part) - 실제 구현 정의 패키지 명세의 private 키워드 아래에 Account 타입의 실제 레코드 구조를 정의합니다. 이 부분은 컴파일러에게는 보이지만, with Safe_Bank;를 통해 이 패키지를 사용하는 다른 개발자에게는 보이지 않고 접근 또한 불가능합니다.

캡슐화의 장점

  • 데이터 보호 및 무결성 유지: 객체의 상태(Balance)는 오직 공개된 서브프로그램(Deposit, Withdraw)을 통해서만 제어된 방식으로 변경될 수 있습니다. Deposit 프로시저 내부에서 금액의 유효성을 검사하는 등, 객체가 항상 유효한 상태를 유지하도록 강제할 수 있습니다.
  • 구현의 유연성 및 유지보수성: Account 레코드의 내부 구조(필드)가 변경되더라도, 공개된 서브프로그램의 명세만 그대로 유지된다면 패키지 외부의 코드는 전혀 영향을 받지 않습니다. 인터페이스와 구현이 완벽하게 분리되어 유지보수성이 극적으로 향상됩니다.
  • 명확한 인터페이스: 패키지의 공개부는 사용자가 해당 객체로 무엇을 할 수 있는지를 알려주는 명확한 ‘사용 설명서’ 역할을 합니다.

이처럼 private 타입을 통한 캡슐화는 객체의 데이터를 안전하게 보호하고, 소프트웨어의 각 부분을 독립적으로 개발하고 수정할 수 있도록 만드는 객체 지향 설계의 가장 기본적이고 중요한 원칙입니다.

14.3 클래스-와이드(Class-Wide) 타입과 다형성(Polymorphism)의 기초

지금까지 우리는 tagged 타입을 이용해 ShapeCircle로 확장하는 등 상속(inheritance) 계층을 만드는 방법과, private 타입을 통해 데이터의 내부 구현을 숨기는 캡슐화(encapsulation)에 대해 배웠습니다. 이제 객체 지향 프로그래밍의 마지막 핵심 기둥인 다형성(Polymorphism)으로 나아갈 시간입니다.

다형성은 그리스어로 “다양한(poly) 형태(morphos)”를 의미하며, 프로그래밍에서는 하나의 인터페이스가 서로 다른 여러 타입의 객체에 대해 동작할 수 있는 능력을 말합니다. 예를 들어, Draw라는 단일 명령이 Circle 객체에 대해서는 원을 그리고, Rectangle 객체에 대해서는 사각형을 그리도록 하는 것입니다.

하지만 한 가지 문제가 있습니다. Ada의 강력한 정적 타입 시스템에서는, Shape 타입의 변수는 오직 Shape 타입의 객체만 담을 수 있고, Circle 타입의 변수는 오직 Circle 타입의 객체만 담을 수 있습니다. 그렇다면 어떻게 CircleRectangle 객체를 하나의 배열에 담아두고, “모든 도형을 그려라” 와 같은 다형적인 명령을 내릴 수 있을까요?

이 문제를 해결하기 위해 Ada는 클래스-와이드(Class-Wide) 타입이라는 특별한 개념을 제공합니다. 이는 'Class 속성을 통해 만들어지며, 특정 타입 하나가 아닌 상속 계층 전체를 아우르는 타입을 의미합니다.

  • Shape'Class: 이 타입은 ‘Shape 타입 자기 자신과, Shape로부터 파생된 모든 자식 타입들(Circle, Rectangle 등) 전체’를 나타내는 타입 집합입니다.

클래스-와이드 타입을 사용하면, Shape'Class 타입의 변수 하나에 Shape 객체, Circle 객체, Rectangle 객체 등 그 계층에 속한 어떤 종류의 객체든 담을 수 있게 됩니다. 이는 서로 다른 형태의 객체들을 하나의 이름으로 다룰 수 있게 해주는 통로이며, Ada에서 다형성을 구현하는 가장 근본적인 메커니즘입니다.

이번 절에서는 'Class 속성을 이용해 클래스-와이드 타입을 만들고, 이를 활용하여 이종(heterogeneous) 데이터 구조를 구성하는 방법, 그리고 이것이 어떻게 다형성의 기초가 되는지를 학습합니다.

14.3.1 클래스-와이드 타입 (‘Class 속성)

Ada에서 다형성을 구현하는 핵심적인 문법 요소는 바로 'Class 속성입니다. 이 속성은 특정 태그드 타입(tagged type)에 적용되어, 해당 타입과 그로부터 파생된 모든 자식 타입을 아우르는 하나의 ‘타입 집합’을 만들어냅니다. 이를 클래스-와이드 타입(class-wide type)이라고 부릅니다.

'Class 속성의 정의와 구문

태그드 타입 T에 대해, T'Class는 다음과 같은 타입을 나타냅니다.

타입 T 자기 자신과, T를 직간접적으로 상속받는 모든 파생 타입을 포함하는 타입.

Shape 예제에서, Shape'ClassShape 타입, Circle 타입, Rectangle 타입 등을 모두 포함하는 하나의 넓은 개념이 됩니다.

'Class 속성은 반드시 태그드 타입에만 적용할 수 있으며, 일반 레코드나 스칼라 타입에는 사용할 수 없습니다.

클래스-와이드 변수와 접근 타입 (포인터)

한 가지 중요한 제약이 있습니다. 일반적으로 My_Object : Shape'Class; 와 같이 클래스-와이드 타입의 변수를 직접 선언할 수는 없습니다. 왜냐하면 컴파일러는 My_ObjectShape이 담길지, 더 큰 Circle이 담길지, 혹은 훨씬 더 큰 다른 자식 타입이 담길지 알 수 없으므로 변수의 크기를 결정할 수 없기 때문입니다.

이 문제를 해결하기 위해, 우리는 접근 타입(access type), 즉 포인터를 사용합니다. 클래스-와이드 타입을 위한 접근 타입은 access all 키워드를 사용하여 선언합니다.

-- Shape 계층의 어떤 객체든 가리킬 수 있는 접근 타입 선언
type Shape_Class_Access is access all Shape'Class;

이제 Shape_Class_Access 타입의 변수는 Shape 계층에 속한 어떤 종류의 객체든 (정확히는, 힙(heap)에 생성된 어떤 종류의 객체든) 가리킬 수 있게 됩니다.

with Shapes;       use Shapes;
with Circles;      use Circles;
with Rectangles;   use Rectangles;

procedure Test_Class_Wide_Access is
   -- Shape 계층의 어떤 객체든 가리킬 수 있는 '마법의 포인터'
   The_Shape : Shape_Class_Access;
begin
   -- 1. The_Shape가 Circle 객체를 가리키도록 함
   The_Shape := new Circle'(Radius => 10.0);

   -- 2. 나중에는 Rectangle 객체를 가리키도록 변경
   The_Shape := new Rectangle'(Width => 5.0, Height => 8.0);

   -- 3. 다시 기본 Shape 객체를 가리키도록 변경
   The_Shape := new Shape;
end Test_Class_Wide_Access;

이처럼 클래스-와이드 접근 타입을 사용하면, 하나의 변수가 런타임에 다양한 형태의 객체를 가리킬 수 있게 되어 다형적인 동작의 기반을 마련합니다.

클래스-와이드 매개변수

클래스-와이드 타입은 서브프로그램의 매개변수로 사용될 때 더욱 강력한 힘을 발휘합니다.

procedure Generic_Draw (Item : in Shape'Class);

Generic_Draw 프로시저는 Shape 계층에 속한 어떤 종류의 객체든 인자로 받을 수 있습니다. 이는 Draw (My_Circle); 호출과 Draw (My_Rectangle); 호출이 모두 유효함을 의미합니다.

프로시저 내부에서는 전달받은 Item의 실제 타입(태그)에 따라 적절한 동작을 수행할 수 있게 되며, 이것이 바로 다음 절에서 배울 동적 디스패칭의 핵심 원리입니다.

타입 멤버십 테스트 (in)

클래스-와이드 변수가 현재 어떤 구체적인 타입을 가리키고 있는지 확인해야 할 때가 있습니다. 이때 in 연산자를 사용하여 안전하게 타입을 검사할 수 있습니다.

if The_Shape.all in Circle then
   -- 이 블록 안에서는 The_Shape가 Circle 객체임이 보장됨
   Ada.Text_IO.Put_Line ("It's a Circle!");
end if;

'Class 속성은 이처럼 특정 타입에 얽매이지 않고 상속 계층 전체를 유연하게 다룰 수 있는 문을 열어주는, Ada 객체 지향 프로그래밍의 가장 핵심적인 도구입니다.

14.3.2 이종(Heterogeneous) 데이터 구조와 클래스-와이드 포인터

다형성의 진정한 힘은, 서로 다른 타입의 객체들을 하나의 컬렉션(collection)에 담아 일괄적으로 처리할 때 드러납니다. 예를 들어, 화면에 그려야 할 모든 도형 객체들을 하나의 목록으로 관리하고, “목록에 있는 모든 도형을 그려라”라는 단일 명령을 내리는 상황을 생각해 봅시다. 이 목록에는 원, 사각형, 삼각형 등 다양한 종류의 도형이 섞여 있어야 합니다.

이처럼 서로 다른 타입의 요소들을 함께 담을 수 있는 자료구조를 이종(heterogeneous) 데이터 구조라고 부릅니다.

문제점: 서로 다른 크기의 객체

Ada의 일반적인 배열이나 레코드는 모든 요소가 동일한 타입과 크기를 가져야 합니다. 하지만 Shape를 상속받은 CircleRadius 필드를 추가로 가지므로 Shape보다 크고, RectangleWidthHeight 필드를 가지므로 또 다른 크기를 가집니다. 따라서 이들을 array (...) of ??? 와 같은 일반 배열에 직접 담는 것은 불가능합니다.

해결책: 동일한 크기의 ‘포인터’를 저장하기

객체 자체의 크기는 각기 다르지만, 그 객체를 가리키는 포인터(접근 타입)의 크기는 모두 동일합니다. 따라서 우리는 객체 자체를 배열에 저장하는 대신, 객체를 가리키는 포인터들을 배열에 저장함으로써 이종 데이터 구조를 만들 수 있습니다.

이때 사용하는 것이 바로 앞 절에서 배운 클래스-와이드 접근 타입 (T'Class_Access)입니다.

-- Shape 계층의 어떤 객체든 가리킬 수 있는 포인터 타입
type Shape_Class_Access is access all Shape'Class;

-- 위 포인터들을 저장할 수 있는 배열 타입 선언
type Drawing_Board is array (Positive range <>) of Shape_Class_Access;

이제 Drawing_Board 타입의 배열은 Circle 객체를 가리키는 포인터와 Rectangle 객체를 가리키는 포인터를 한 배열 안에 동시에 저장할 수 있습니다.

종합 활용 예제: 모든 도형의 면적 계산하기

Shape, Circle, Rectangle 타입과 각 타입에 맞게 재정의된 Area 함수가 있다고 가정하고, 이들을 하나의 배열에 담아 전체 면적을 계산해 보겠습니다.

with Ada.Text_IO;
with Shapes;      use Shapes;
with Circles;      use Circles;
with Rectangles;   use Rectangles; -- 각 도형 타입과 패키지가 있다고 가정

procedure Calculate_Total_Area is
   -- Shape 계층의 포인터를 담는 배열 타입
   type Shape_Class_Access is access all Shape'Class;
   type All_Shapes is array (1 .. 3) of Shape_Class_Access;

   -- 1. 이종(Heterogeneous) 배열 생성 및 초기화
   My_Drawing : All_Shapes :=
     (1 => new Circle'(Radius => 10.0),      -- Circle 객체 포인터
      2 => new Rectangle'(Width => 5.0, Height => 4.0), -- Rectangle 객체 포인터
      3 => new Circle'(Radius => 2.0));      -- 또 다른 Circle 객체 포인터

   Total_Area : Float := 0.0;
begin
   -- 2. 배열을 순회하며 다형적으로 Area 함수 호출
   for I in My_Drawing'Range loop
      -- My_Drawing(I).all 이라는 단일한 호출 코드가
      -- 객체의 실제 타입에 따라 각기 다른 Area 함수를 호출함
      Total_Area := Total_Area + Area (My_Drawing(I).all);
   end loop;

   Ada.Text_IO.Put_Line ("Total area of all shapes: " & Float'Image (Total_Area));
   -- 예상 출력: 314.159 (원의 면적) + 20.0 (사각형 면적) + 12.566 (작은 원 면적) = 346.725
end Calculate_Total_Area;

핵심 동작 원리: for 루프 안의 Area (My_Drawing(I).all) 호출이 바로 다형성동적 디스패칭이 일어나는 순간입니다.

  • I가 1일 때, My_Drawing(1)Circle 객체를 가리킵니다. Ada 런타임은 객체의 숨겨진 태그를 보고 Circles 패키지에 정의된 Area 함수를 호출합니다.
  • I가 2일 때, My_Drawing(2)Rectangle 객체를 가리킵니다. 런타임은 이번에는 Rectangles 패키지에 정의된 Area 함수를 호출합니다.

컴파일 시점에는 Area 호출이 어떤 구체적인 함수를 부를지 알 수 없지만, 실행 시점에 객체의 실제 타입에 따라 호출할 함수가 동적으로 결정됩니다.

실제 애플리케이션에서는 고정 크기 배열 대신, Ada.Containers.VectorsShape_Class_Access 타입으로 인스턴스화하여 크기가 동적으로 변하는 이종 컬렉션을 만드는 것이 일반적입니다. package Shape_Vectors is new Ada.Containers.Vectors (Positive, Shape_Class_Access);

이처럼 클래스-와이드 포인터를 이용한 이종 데이터 구조는, 관련된 객체들을 하나의 그룹으로 묶어 다형적으로 처리하는 객체 지향 프로그래밍의 가장 중요하고 강력한 패턴입니다.

14.4 동적 디스패칭 (Dynamic Dispatching)

앞선 절에서 우리는 클래스-와이드 타입('Class)을 사용하여, 서로 다른 타입의 객체들을 하나의 배열에 담고 for 루프를 통해 일괄적으로 처리하는 예제를 보았습니다. 루프 안에서는 Area (My_Drawing(I).all) 라는 단 하나의 동일한 코드를 호출했지만, 프로그램이 실행될 때는 객체의 실제 타입에 따라 CircleArea가 호출되기도 하고 RectangleArea가 호출되기도 했습니다.

이처럼 컴파일 시점에는 어떤 서브프로그램이 호출될지 확정할 수 없다가, 프로그램 실행 시점에 객체의 실제 타입을 확인하여 그에 맞는 구체적인 서브프로그램을 호출해 주는 메커니즘동적 디스패칭(Dynamic Dispatching) 또는 동적 바인딩(Dynamic Binding)이라고 합니다.

이는 다형성을 실현하는 핵심적인 엔진입니다. 동적 디스패칭이 없다면, 우리는 다음과 같이 타입별로 ifcase 문을 사용하여 수동으로 코드를 분기해야 할 것입니다.

-- 동적 디스패칭이 없다면 해야 할 수동적인 타입 검사
for I in My_Drawing'Range loop
   if My_Drawing(I).all in Circle then
      Total_Area := Total_Area + Circles.Area (Circle (My_Drawing(I).all));
   elsif My_Drawing(I).all in Rectangle then
      Total_Area := Total_Area + Rectangles.Area (Rectangle (My_Drawing(I).all));
   end if;
end loop;

동적 디스패칭은 이처럼 번거롭고 새로운 도형이 추가될 때마다 수정이 필요한 코드를, Area (My_Drawing(I).all) 라는 단 한 줄의 우아한 코드로 대체해 줍니다.

이번 절에서는 Ada 런타임 시스템이 객체의 숨겨진 태그(tag)와 디스패치 테이블(dispatch table)을 이용하여 어떻게 이 마법 같은 일을 수행하는지, 그 원리를 자세히 들여다보고 다형성을 완성하는 동적 디스패칭의 강력함을 완전히 이해하게 될 것입니다.

14.4.1 다형성의 구현: 도형 예제

동적 디스패칭이 실제로 어떻게 다형성을 구현하는지, ‘도형’ 예제를 통해 전체 과정을 단계별로 살펴보겠습니다. 이 예제는 지금까지 배운 tagged, private, 상속, 재정의, 클래스-와이드 타입, 이종 데이터 구조의 모든 개념을 하나로 엮어 보여줍니다.

우리의 목표는 다양한 도형(Shape, Circle, Rectangle)들을 하나의 목록으로 관리하고, 각 도형의 종류를 일일이 확인하지 않고도 “모든 도형의 면적을 구하고 그려라”라는 단일 명령을 처리하는 것입니다.

1단계: 부모 타입 Shape 패키지 설계

먼저 모든 도형의 공통된 특징을 담을 부모 타입 Shape를 정의합니다.

shapes.ads (명세부)

package Shapes is
   -- 1. Shape 타입을 private으로 선언하여 캡슐화
   type Shape is tagged private;

   -- 2. 모든 Shape 파생 타입들이 가져야 할 프리미티브 연산(인터페이스) 정의
   function Area (S : Shape) return Float;
   procedure Draw (S : Shape);

private
   -- 3. Shape 타입의 실제 구조 정의 (외부에는 숨겨져 있음)
   type Shape is tagged record
      X, Y : Float; -- 모든 도형은 위치를 가짐
   end record;
end Shapes;

shapes.adb (구현부)

with Ada.Text_IO;
package body Shapes is
   function Area (S : Shape) return Float is
   begin
      return 0.0; -- 일반적인 도형의 면적은 0으로 정의
   end Area;

   procedure Draw (S : Shape) is
   begin
      Ada.Text_IO.Put_Line ("Drawing a generic shape.");
   end Draw;
end Shapes;

2단계: 자식 타입 Circle 패키지 설계

Shape를 상속받아 Circle 타입을 정의하고, AreaDraw 연산을 재정의합니다.

circles.ads (명세부)

with Shapes;

package Circles is
   -- 1. Shape를 상속받는 Circle 타입 선언 (역시 private)
   type Circle is new Shapes.Shape with private;

   -- 2. 프리미티브 연산을 Circle에 맞게 재정의(overriding)
   overriding
   function Area (C : Circle) return Float;

   overriding
   procedure Draw (C : Circle);

private
   Pi : constant := 3.14159;
   -- 3. Circle 타입의 실제 구조 정의 (Radius 필드 추가)
   type Circle is new Shapes.Shape with record
      Radius : Float;
   end record;
end Circles;

circles.adb (구현부)

with Ada.Text_IO;
package body Circles is
   function Area (C : Circle) return Float is
   begin
      -- 원의 면적 계산 공식으로 재정의
      return Pi * C.Radius ** 2;
   end Area;

   procedure Draw (C : Circle) is
   begin
      -- 원을 그리는 동작으로 재정의
      Ada.Text_IO.Put_Line ("Drawing a circle with radius" & Float'Image (C.Radius));
   end Draw;
end Circles;

(Rectangle 패키지도 이와 유사하게 만들 수 있습니다.)

3단계: 다형성 실행 - 메인 프로시저

이제 이종 데이터 구조를 만들고, 단일 인터페이스를 통해 다양한 객체를 처리하는 다형성을 실행해 보겠습니다.

with Ada.Text_IO;
with Shapes;   use Shapes;
with Circles;  use Circles;

procedure Draw_All_Shapes is
   -- 클래스-와이드 포인터 타입 선언
   type Shape_Class_Access is access all Shape'Class;

   -- Shape 계층의 객체들을 담을 이종(heterogeneous) 배열
   Drawing : constant array (1 .. 2) of Shape_Class_Access :=
     (1 => new Shape'(X => 0.0, Y => 0.0),        -- 기본 Shape 객체
      2 => new Circle'(X => 10.0, Y => 10.0, Radius => 5.0)); -- Circle 객체

begin
   Ada.Text_IO.Put_Line ("--- Processing all shapes ---");

   -- 다형적인 루프
   for Item_Ptr of Drawing loop
      -- 이 두 줄의 코드가 다형성의 핵심입니다.
      -- Item_Ptr.all의 실제 타입에 따라 각기 다른 Draw와 Area가 호출됩니다.
      Draw (Item_Ptr.all);
      Ada.Text_IO.Put_Line ("  Area is:" & Float'Image (Area (Item_Ptr.all)));
   end loop;

end Draw_All_Shapes;

실행 결과:

--- Processing all shapes ---
Drawing a generic shape.
  Area is: 0.00000E+00
Drawing a circle with radius 5.00000E+00
  Area is: 7.85397E+01

동작 원리 분석:

  • 루프의 첫 번째 반복에서 Item_PtrShape 객체를 가리킵니다. DrawArea를 호출하면 Shapes 패키지에 구현된 기본 버전이 디스패칭됩니다.
  • 두 번째 반복에서 Item_PtrCircle 객체를 가리킵니다. 똑같은 DrawArea 호출 코드가 이번에는 Circles 패키지에 재정의된 버전을 디스패칭합니다.

메인 프로시저는 Circle의 존재 자체를 알지 못하지만, Shape'Class라는 추상적인 인터페이스를 통해 모든 종류의 도형과 올바르게 상호작용합니다. 만약 나중에 Triangle이라는 새로운 도형을 추가하더라도, 이 메인 프로시저 코드는 단 한 줄도 수정할 필요가 없습니다. 이것이 바로 객체 지향 설계가 제공하는 유연성확장성입니다.

14.4.2 동적 디스패칭의 원리

앞선 예제에서 Draw(Item_Ptr.all)이라는 단 한 줄의 코드가 어떻게 객체의 실제 타입에 맞는 Draw 프로시저를 알아서 찾아 호출할 수 있었을까요? 이 ‘마법’의 배후에는 Ada의 컴파일러와 런타임 시스템이 자동으로 관리해주는 정교한 메커니즘이 있습니다. 이 메커니즘은 객체의 태그(Tag)와 타입의 디스패치 테이블(Dispatch Table)이라는 두 가지 핵심 요소로 이루어집니다.

1. 객체의 태그 (The Object’s Tag)

13.1.1절에서 설명했듯이, tagged 키워드가 붙은 타입의 모든 객체는 자신의 실제 타입을 식별하는 숨겨진 ‘신분증’, 즉 태그(tag)를 갖습니다.

이 태그는 본질적으로 자신의 구체적인 타입 정보가 담긴 곳을 가리키는 포인터입니다. 예를 들어, new Circle(...)을 통해 생성된 모든 Circle 객체들은 모두 동일한 ‘Circle 타입 정보’를 가리키는 태그 값을 갖게 됩니다. 이 태그는 객체가 생성되는 순간에 설정되어 절대 변하지 않으며, 객체가 어디를 가든 항상 함께 따라다닙니다.

2. 타입의 디스패치 테이블 (The Type’s Dispatch Table)

각각의 구체적인 태그드 타입(Shape, Circle, Rectangle 등)에 대해, 컴파일러는 디스패치 테이블이라는 정적인 데이터 구조를 생성합니다. 이는 다른 언어에서 가상 메소드 테이블(Virtual Method Table, VMT)이라고도 불립니다.

디스패치 테이블은 해당 타입의 모든 프리미티브 연산(메서드)들의 실제 메모리 주소를 담고 있는 배열과 같습니다.

  • Shape의 디스패치 테이블:

    • [0] -> Shapes.Area 함수의 주소
    • [1] -> Shapes.Draw 프로시저의 주소
  • Circle의 디스패치 테이블: CircleShape를 상속받으므로, 컴파일러는 먼저 Shape의 디스패치 테이블을 복사합니다. 그 후, Circle이 재정의(overriding)한 연산들의 주소를 덮어씁니다.

    • [0] -> Circles.Area 함수의 주소 (재정의됨)
    • [1] -> Circles.Draw 프로시저의 주소 (재정의됨)

객체의 태그는 바로 이 디스패치 테이블을 가리킵니다. 즉, Circle 객체의 태그는 Circle의 디스패치 테이블 주소를 담고 있습니다.

3. 디스패칭 호출 과정 (The Dispatching Sequence)

Draw(Item_Ptr.all) 호출이 일어날 때, 런타임 시스템은 다음과 같은 절차를 순식간에 수행합니다.

Item_PtrCircle 객체를 가리킨다고 가정해 봅시다.

  1. 태그 조회: 런타임은 Item_Ptr.all 객체의 메모리로 가서, 그곳에 저장된 숨겨진 태그 값을 읽습니다.
  2. 디스패치 테이블 접근: 이 태그 값은 Circle 타입의 디스패치 테이블을 가리키는 포인터이므로, 이 포인터를 따라 Circle의 디스패치 테이블로 이동합니다.
  3. 연산 주소 조회: 컴파일러는 Draw 프로시저가 이 테이블의 두 번째([1]) 항목이라는 것을 이미 알고 있습니다. 런타임은 Circle 디스패치 테이블의 두 번째 항목에 저장된 메모리 주소를 읽습니다. 이 주소는 바로 Circles.Draw 프로시저의 시작 주소입니다.
  4. 간접 호출: 런타임은 3단계에서 얻은 주소로 점프하여 프로시저를 호출하고, Item_Ptr.all 객체를 그 인자로 전달합니다.
digraph Dynamic_Dispatching {
    fontname="Arial";
    rankdir=LR;
    node [shape=record, style=filled, fontname="Arial"];
    edge [fontname="Arial"];

    // Object on the Heap
    obj_circle [
        label="{ <tag> 태그 (Tag) | Radius: 10.0 }",
        fillcolor="#cde4f2"
    ];

    // Dispatch Table in Static/Code Area
    dt_circle [
        label="{ <f0> Circle 디스패치 테이블 | <f1> Area | <f2> Draw }",
        fillcolor="#d2f2cd"
    ];

    // Pointer on the Stack
    item_ptr [
        label="{ Item_Ptr | <ptr> ● }",
        fillcolor="#fff2cc"
    ];

    // Functions in Code Area
    subgraph cluster_functions {
        label="코드 영역 (Code Area)";
        bgcolor="#f0f0f0";
        style=filled;
        node [shape=box, style=solid, fillcolor=white];
        func_circle_draw [label="Circles.Draw"];
        func_circle_area [label="Circles.Area"];
    }

    // Call representation
    call [
        label="Draw(Item_Ptr.all) 호출",
        shape=oval,
        style=filled,
        fillcolor="#e0e0e0"
    ];

    // Edges representing the flow
    item_ptr:ptr -> obj_circle:tag [style=dashed, label="1. 객체를 가리킴"];
    obj_circle:tag -> dt_circle:f0 [style=dashed, color=red, label="2. 태그가 테이블을 가리킴"];

    dt_circle:f2 -> func_circle_draw [label="3. 'Draw' 주소 조회"];

    call -> func_circle_draw [color=blue, style=bold, label="4. 최종 함수 호출"];
}

그림: 동적 디스패칭의 원리

이 모든 과정은 몇 번의 메모리 참조만으로 이루어지므로 매우 효율적입니다. 이처럼 동적 디스패칭은 마법이 아니라, 컴파일러와 런타임 시스템이 태그와 디스패치 테이블을 이용해 정교하게 관리하는 간접 호출(indirect call) 메커니즘입니다.

이 메커니즘 덕분에 개발자는 if-then-elsecase 문으로 객체의 타입을 일일이 검사할 필요 없이, 단일한 인터페이스를 통해 다형성을 안전하고 효율적으로 구현할 수 있습니다.

14.5 추상 타입 및 인터페이스: 유연한 설계를 위한 도구

이전 절에서는 태그드 타입(tagged type)과 클래스-와이드 타입('Class)을 통해 상속과 동적 다형성을 구현하는 방법을 살펴보았습니다. 이러한 기능은 “is a” 관계를 기반으로 타입을 확장하여 유연한 코드를 작성하는 데 매우 유용합니다.

하지만 때로는 완전한 기능을 갖춘 구체적인(concrete) 타입을 정의하는 대신, 여러 타입들이 반드시 지켜야 할 동작의 명세 또는 계약(specification or contract)만을 정의하고 싶을 때가 있습니다. 예를 들어, ‘Drawable’이라는 개념을 정의하고 싶다고 가정해 봅시다. ‘Drawable’은 draw라는 연산을 가져야 한다는 규칙을 설정하지만, draw가 실제로 원을 그리는지, 사각형을 그리는지는 각 타입이 스스로 결정하도록 위임하는 것입니다. 이처럼 “무엇을 해야 하는지”는 정의하되, “어떻게 해야 하는지”는 자식 타입에게 맡기는 추상화 메커니즘이 필요합니다.

Ada는 이러한 고수준의 추상화를 위해 추상 타입(Abstract Types)인터페이스(Interfaces)라는 두 가지 강력한 도구를 제공합니다.

  • 추상 타입은 일부 연산의 구현을 포함하면서도, 특정 연산은 “추상적”으로 남겨두어 자식 타입이 반드시 구현하도록 강제하는 타입입니다. 이는 공통된 기반을 공유하는 타입 계층을 설계할 때 유용합니다.
  • 인터페이스는 구현이 전혀 없이 순수한 연산의 명세만으로 이루어진 순수 계약입니다. 어떤 타입이든 상속 관계와 무관하게 특정 인터페이스를 구현함으로써 해당 “역할”이나 “능력”을 가질 수 있음을 보장합니다.

이 두 기능을 활용하면 컴포넌트 간의 결합도(coupling)를 낮추고, 확장과 재사용이 용이한 유연한 소프트웨어 아키텍처를 설계할 수 있습니다.

이번 장에서는 먼저 추상 타입과 인터페이스의 기본 개념과 문법을 각각 알아보고, 두 기능의 차이점과 사용 사례를 비교 분석할 것입니다. 나아가 여러 인터페이스를 동시에 구현하는 방법과 제네릭과의 시너지를 통해 Ada의 추상화 기능을 극한까지 활용하는 고급 설계 기법까지 탐구해 보겠습니다.

14.5.1 추상 타입과 추상 서브프로그램 (Abstract Types and subprograms)

추상 타입abstract 키워드로 선언되는 불완전한 태그드 타입으로, 직접 객체를 생성(인스턴스화)할 수 없습니다. 추상 타입의 주된 목적은 다른 구체적인(concrete) 타입들의 공통 기반, 즉 블루프린트(blueprint) 역할을 하는 것입니다.

추상 타입은 하나 이상의 추상 서브프로그램을 가질 수 있습니다. 추상 서브프로그램은 구현부가 없는 선언(is abstract;)으로, 해당 추상 타입을 상속받는 모든 구체적인 자식 타입이 반드시 재정의(override)해야 하는 연산을 정의합니다.

주요 규칙:

  • 추상 타입의 변수는 선언할 수 없습니다. (예: My_Var : Abstract_Shape;는 컴파일 오류)
  • 추상 타입을 상속받는 자식 타입은 상위의 모든 추상 서브프로그램을 구체적으로 구현해야 합니다. 만약 하나라도 구현하지 않으면, 그 자식 타입 또한 abstract로 선언되어야 합니다.

예제: Shape 타입을 추상으로 변환

9.3절의 Shape 예제는 “면적을 계산할 수 있다”는 공통 개념을 표현하지만, “도형” 그 자체의 면적을 구하는 것은 의미가 모호합니다. 따라서 Shape 타입을 추상으로 만들고 calculate_area를 추상 연산으로 정의하는 것이 더 논리적입니다.

-- shapes.ads
package Shapes is
   -- Shape는 이제 직접 객체를 생성할 수 없는 추상 타입입니다.
   type Shape is abstract tagged null record;

   -- Shape을 상속받는 모든 구체적인 타입은 이 함수를 반드시 구현해야 합니다.
   function calculate_area (s : Shape) return Float is abstract;

end Shapes;

이제 Shape 타입은 개념적 템플릿으로만 존재합니다. Circle이나 Rectangle과 같은 구체적인 타입은 calculate_area 함수를 재정의하여 구현해야만 컴파일이 가능합니다. 이로써 “도형 클래스에 속한 모든 객체는 면적을 계산할 수 있다”는 설계 규칙이 컴파일 시점에 강제됩니다.

14.5.2 인터페이스 (Interfaces)

인터페이스는 데이터 멤버를 전혀 포함하지 않고, 오직 추상 연산(또는 null 프로시저)의 집합으로만 구성된 순수한 계약입니다. 인터페이스는 특정 역할(Role)이나 능력(Capability)을 정의하는 데 사용됩니다.

Ada에서 인터페이스의 가장 중요한 특징은 다중 상속(Multiple Inheritance)을 지원한다는 점입니다. 하나의 태그드 타입은 단 하나의 부모 타입만을 상속받을 수 있지만, 여러 개의 인터페이스를 동시에 구현할 수 있습니다.

예제: Drawable 인터페이스

화면에 객체를 그리는 Drawable이라는 능력을 인터페이스로 정의해 보겠습니다. 도형뿐만 아니라 텍스트 상자 같은 다른 종류의 객체도 이 인터페이스를 구현할 수 있습니다.

-- drawable.ads
package Graphics is
   type Drawable is interface;

   -- Drawable 인터페이스를 구현하는 모든 타입은 draw 프로시저를 제공해야 합니다.
   procedure draw (item : in Drawable) is abstract;
end Graphics;

이제 Circle 타입이 Shape 타입을 상속받는 동시에 Drawable 인터페이스를 구현하도록 정의할 수 있습니다.

-- shapes-circles.ads
with Shapes;
with Graphics;

package Shapes.Circles is
   type Circle is new Shapes.Shape and Graphics.Drawable with private; -- 다중 상속

   overriding
   function calculate_area (c : Circle) return Float;

   overriding
   procedure draw (item : in Circle); -- Drawable의 draw 구현
private
   ...
end Shapes.Circles;

이렇게 하면 CircleShape의 일종이면서(is-a Shape), 동시에 Drawable 능력을 갖추게 됩니다. 이제 Drawable'Class를 사용하여 화면에 그릴 수 있는 모든 객체(도형, 텍스트 상자 등)를 다형적으로 처리하는 프로시저를 작성할 수 있습니다.

procedure Render_Scene (item : in Graphics.Drawable'Class) is
begin
   -- item의 실제 타입(Circle, TextBox 등)에 맞는 draw가 동적 디스패칭됩니다.
   Graphics.draw (item);
end Render_Scene;

14.5.3 추상 타입 vs. 인터페이스: 언제 무엇을 써야 하는가

추상 타입과 인터페이스는 모두 코드의 유연성과 재사용성을 높이는 강력한 추상화 도구이지만, 서로 다른 설계 목표를 가지고 있습니다. 두 기능의 차이점을 명확히 이해하고 상황에 맞는 도구를 선택하는 것은 견고하고 확장성 있는 아키텍처를 구축하는 데 매우 중요합니다.

핵심적인 선택 기준은 “무엇인가(Is-A)” 관계를 모델링하는지, 아니면 “무엇을 할 수 있는가(Can-Do)”의 능력을 정의하는지에 달려있습니다.

특징 추상 타입 (Abstract Type) 인터페이스 (Interface)
핵심 목적 “Is-A” (하나의 ‘종류’ 또는 ‘가족’) “Can-Do” (하나의 ‘능력’ 또는 ‘역할’)
데이터/구현 데이터 필드와 구현된 서브프로그램을 가질 수 있음 데이터 필드와 구현을 가질 수 없음 (순수 명세)
상속 단일 상속만 가능 다중 상속(구현)이 가능
주요 사용 사례 공통 데이터나 행동을 공유하는 타입 계층 설계 서로 다른 타입들에게 공통된 능력을 부여

“Is-A” 관계: 공통의 기반을 가진 ‘가족’ -> 추상 타입

추상 타입은 관련된 타입들의 ‘가족(family)’을 만들 때 사용합니다. 이 가족의 모든 구성원은 공통된 데이터 구조나 기본 행동을 공유합니다.

예를 들어, 모든 도형(Shape)은 화면상의 위치(Position)색상(Color)이라는 공통된 데이터를 가지며, 이동(Move)하는 행동은 모든 도형에 동일하게 적용될 수 있다고 가정해 봅시다. 이 경우, PositionColor 필드를 가지고 Move 프로시저를 직접 구현한 Abstract_Shape라는 추상 타입을 정의하는 것이 적합합니다. CircleSquare는 이 Abstract_Shape을 상속받아 Draw 연산만 각자 구현하면 됩니다. CircleSquare는 명백히 Shape의 한 ‘종류’입니다.

결정 가이드:

  • 여러 자식 타입들이 공통된 데이터 필드를 반드시 공유해야 하는가?
  • 일부 서브프로그램의 기본 구현을 물려주고 싶은가?
  • 설계하려는 관계가 명확한 “A는 B의 한 종류이다”에 해당하는가?

위 질문 중 하나라도 “예”라면 추상 타입이 올바른 선택일 가능성이 높습니다.

“Can-Do” 관계: 공통된 능력을 가진 ‘역할’ -> 인터페이스

인터페이스는 서로 관련 없는 타입들에게 공통된 ‘능력(capability)’이나 ‘역할(role)’을 부여할 때 사용합니다.

예를 들어, 시스템의 다양한 객체들을 로그 파일에 기록하는 기능을 생각해 봅시다. 사용자_활동(User_Action), 네트워크_패킷(Network_Packet), 센서_읽기(Sensor_Reading) 객체들은 근본적으로 서로 다른 것들이지만, 모두 “로그에 기록될 수 있는(Loggable)” 능력을 가질 수 있습니다. 이 경우, To_Log_Message라는 함수 하나만 가진 Loggable 인터페이스를 정의하면 됩니다. 그러면 세 개의 타입 모두 각자의 상속 관계와 상관없이 Loggable 인터페이스를 구현하여 “로그 기록이 가능한” 객체가 될 수 있습니다.

결정 가이드:

  • 서로 관련 없는 타입들에게 공통된 행동 명세(contract)를 부여하고 싶은가?
  • 하나의 타입이 여러 서로 다른 능력들(다중 상속)을 동시에 갖게 될 수 있는가? (예: Loggable이면서 동시에 Serializable인 타입)
  • 구현이나 데이터의 공유 없이, 오직 “무엇을 할 수 있는지”만 정의하고 싶은가?

위 질문들에 해당한다면 인터페이스가 더 유연하고 적절한 해결책입니다.


  • 추상 타입은 강하게 연관된 타입 계층을 만들어 코드를 재사용하는 데 중점을 둡니다.
  • 인터페이스는 서로 다른 타입들 간의 결합도를 낮추고 유연한 역할을 부여하는 데 중점을 둡니다.

올바른 도구를 선택하면 소프트웨어의 구조가 더 명확해지고, 미래의 변경 및 확장에 더 쉽게 대응할 수 있게 됩니다.

14.5.4 다중 인터페이스 구현: 기능의 조합

추상 타입과 인터페이스의 가장 근본적인 차이점이자, 인터페이스가 제공하는 가장 강력한 기능 중 하나는 바로 다중 상속(multiple inheritance)을 지원한다는 점입니다. 정확히는, Ada는 구현의 다중 상속은 허용하지 않지만, 인터페이스를 통해 명세의 다중 상속을 완벽하게 지원합니다.

이는 현실 세계의 객체들이 여러 독립적인 역할이나 능력을 동시에 갖는 것과 유사합니다. 예를 들어, 하나의 디지털_문서 객체는 화면에 ‘출력될 수 있는(Printable)’ 동시에, 네트워크를 통해 ‘전송될 수 있는(Serializable)’ 능력을 가질 수 있습니다. 단일 상속 모델에서는 이처럼 서로 다른 계층의 능력을 조합하기가 매우 어렵지만, 인터페이스를 사용하면 간결하게 해결할 수 있습니다.

문법과 규칙

한 타입이 여러 인터페이스를 구현하도록 하려면, 타입 선언부에 and 키워드를 사용하여 구현할 인터페이스들을 나열하면 됩니다.

type My_Concrete_Type is new Interface_1 and Interface_2 with record
  --  ... 데이터 필드 ...
end record;

이렇게 선언된 타입은 Interface_1Interface_2가 요구하는 모든 추상 서브프로그램을 반드시 구현해야 한다는 계약을 컴파일러와 맺게 됩니다. 컴파일러는 이 계약이 지켜졌는지 컴파일 타임에 엄격하게 검사합니다.

예제: SerializableLoggable 능력을 모두 가진 센서 데이터

서로 다른 두 능력, 즉 JSON으로 변환하는 Serializable과 로그 메시지를 생성하는 Loggable 인터페이스를 정의하고, 하나의 센서 데이터 타입이 이 둘을 모두 구현하는 예제를 살펴보겠습니다.

1. 인터페이스 정의

-- file: capabilities.ads
package Capabilities is
  -- JSON 변환 능력
  type Serializable is interface;
  function to_json (object : Serializable) return String is abstract;

  -- 로그 메시지 생성 능력
  type Loggable is interface;
  function to_log_message (object : Loggable) return String is abstract;
end Capabilities;

2. 구체 타입의 다중 인터페이스 구현

Sensor_Reading 타입을 선언하면서 SerializableLoggableand로 연결합니다.

-- file: sensors.ads
with Capabilities; use Capabilities;
with Ada.Calendar;

package Sensors is
  type Sensor_Reading is new Serializable and Loggable with private;

  -- ... 센서 관련 다른 서브프로그램들 ...
private
  type Sensor_Reading is new Serializable and Loggable with record
    id    : Integer;
    value : Float;
    stamp : Ada.Calendar.Time;
  end record;

  -- 두 인터페이스의 모든 추상 메소드 구현
  overriding
  function to_json (object : Sensor_Reading) return String;

  overriding
  function to_log_message (object : Sensor_Reading) return String;
end Sensors;

3. 구현 및 사용

이제 Sensor_Reading 타입은 두 인터페이스를 기반으로 하는 서로 다른 서브프로그램에 모두 전달될 수 있습니다.

-- file: main.adb
with Ada.Text_IO;
with Capabilities; use Capabilities;
with Sensors;      use Sensors;

procedure Main is
  -- 클라우드 저장은 JSON 변환 능력이 필요
  procedure save_to_cloud (item : Serializable'Class) is
  begin
    Ada.Text_IO.put_line ("Saving to cloud: " & to_json (item));
  end save_to_cloud;

  -- 로컬 로깅은 로그 메시지 생성 능력이 필요
  procedure write_log (item : Loggable'Class) is
  begin
    Ada.Text_IO.put_line ("LOG: " & to_log_message (item));
  end write_log;

  -- Sensor_Reading은 두 능력을 모두 가짐
  reading : Sensor_Reading;
begin
  -- ... reading 객체 초기화 ...

  -- 동일한 'reading' 객체가 두 프로시저에 모두 전달 가능
  save_to_cloud (reading);
  write_log (reading);
end Main;

실행 결과:

Saving to cloud: {"id": 1, "value": 3.14, "stamp": ...}
LOG: Sensor 1 reading is 3.14 at ...

이처럼 다중 인터페이스 구현은 강직한 상속 계층 구조에서 벗어나, 필요한 기능들을 자유롭게 조합하여 컴포넌트 기반의 유연한 아키텍처를 구축할 수 있게 해주는 강력한 설계 도구입니다.

14.5.5 인터페이스 활용 설계 패턴: 콜백(Callback) 예제

인터페이스의 진정한 힘은 단순히 타입을 분류하는 것을 넘어, 컴포넌트 간의 결합도를 낮추는 유연한 아키텍처를 설계할 수 있다는 데 있습니다. 이를 보여주는 가장 대표적인 예가 바로 콜백(Callback) 패턴입니다.

콜백은 한 컴포넌트(A)가 다른 컴포넌트(B)에게 특정 서브프로그램을 전달하여, 미래의 특정 이벤트가 발생했을 때 B가 A의 코드를 “다시 호출(call back)”하도록 하는 기법입니다. 이를 통해 범용적인 기능을 수행하는 컴포넌트 B는 구체적인 처리 로직을 담고 있는 A를 전혀 알지 못한 채 협력할 수 있습니다. 이를 제어의 역전(Inversion of Control)이라고도 합니다.

이번 절에서는 사용자가 GUI 버튼을 클릭했을 때, 버튼 자신은 무슨 일이 일어날지 모르지만 애플리케이션에 정의된 특정 동작이 실행되도록 하는 콜백 예제를 인터페이스로 구현해 보겠습니다.

1단계: 콜백 계약 정의 (인터페이스)

먼저, 모든 “클릭 리스너”가 지켜야 할 계약을 인터페이스로 정의합니다. 여기서는 On_Click이라는 프로시저를 가진 Click_Listener 인터페이스를 만듭니다.

-- file: event_listeners.ads
package Event_Listeners is
  type Click_Listener is interface;
  procedure on_click (listener : in out Click_Listener) is abstract;
end Event_Listeners;

2단계: 범용 컴포넌트 작성 (이벤트 소스)

다음으로, 어떤 애플리케이션에서든 재사용 가능한 Button 타입을 만듭니다. 이 버튼은 Click_Listener'Class 타입의 접근자(access)를 통해 리스너 객체를 저장합니다. 이를 통해 버튼은 리스너의 구체적인 타입을 알 필요 없이 오직 on_click을 호출할 수 있다는 사실만 알게 됩니다.

-- file: gui_button.ads
with Event_Listeners;

package GUI_Button is
  type Button is tagged private;
  type Button_Access is access all Button'Class;

  -- 애플리케이션이 리스너를 버튼에 등록
  procedure register_listener
    (object   : in out Button;
     listener : access Event_Listeners.Click_Listener'Class);

  -- 사용자가 버튼을 클릭하는 상황 시뮬레이션
  procedure simulate_click (object : in out Button);

private
  type Button is tagged record
    listener : access Event_Listeners.Click_Listener'Class := null;
  end record;
end GUI_Button;
-- file: gui_button.adb
package body GUI_Button is
  procedure register_listener
    (object   : in out Button;
     listener : access Event_Listeners.Click_Listener'Class) is
  begin
    object.listener := listener;
  end register_listener;

  procedure simulate_click (object : in out Button) is
  begin
    if object.listener /= null then
      -- 등록된 리스너의 on_click 메소드를 '콜백'
      object.listener.all.on_click;
    end if;
  end simulate_click;
end GUI_Button;

3단계: 특정 동작 구현 (구체적인 리스너)

이제 애플리케이션 레벨에서 ‘저장’ 버튼의 동작을 실제로 구현합니다. Click_Listener 인터페이스를 구현하는 Save_Action 타입을 정의하고 on_click 프로시저를 오버라이드합니다.

-- file: main.adb (일부)
with Ada.Text_IO;
with Event_Listeners;

-- '저장' 동작을 정의하는 구체적인 리스너
type Save_Action is new Event_Listeners.Click_Listener with null record;

overriding
procedure on_click (listener : in out Save_Action) is
begin
  Ada.Text_IO.put_line ("[ACTION] Document saved successfully.");
end on_click;

4단계: 시스템 조립 및 실행

메인 프로시저에서 위 컴포넌트들을 조립하여 실행합니다. Button 객체를 만들고, Save_Action 객체를 리스너로 등록한 뒤, 클릭을 시뮬레이션합니다.

-- file: main.adb
with Ada.Text_IO;
with Event_Listeners;
with GUI_Button;

procedure Main is
  -- '저장' 동작을 정의하는 구체적인 리스너
  type Save_Action is new Event_Listeners.Click_Listener with null record;

  overriding
  procedure on_click (listener : in out Save_Action) is
  begin
    Ada.Text_IO.put_line ("[ACTION] Document saved successfully.");
  end on_click;

  save_button : aliased GUI_Button.Button;
  save_handler : aliased Save_Action;
begin
  -- 1. 버튼에 핸들러(리스너)를 등록
  register_listener (save_button, save_handler'access);

  -- 2. 사용자가 버튼 클릭
  Ada.Text_IO.put_line ("User clicks the save button...");
  simulate_click (save_button);
end Main;

실행 결과:

User clicks the save button...
[ACTION] Document saved successfully.

simulate_click 내부에서는 on_click을 호출했을 뿐이지만, 동적 디스패칭을 통해 Save_Action에 구현된 구체적인 동작이 실행되었습니다. GUI_Button 패키지는 Save_Action 타입이나 “문서 저장”이라는 동작에 대해 전혀 알지 못합니다. 이처럼 인터페이스를 활용한 콜백 패턴은 결합도를 극적으로 낮추고, 재사용성과 확장성이 뛰어난 시스템을 만드는 핵심적인 기법입니다.

14.5.6 제네릭과 인터페이스의 시너지: 정적 및 동적 다형성의 결합

지금까지 우리는 Ada의 두 가지 강력한 다형성(polymorphism) 메커니즘을 각각 살펴보았습니다.

  1. 정적 다형성 (Static Polymorphism): 제네릭(Generics)을 통해 구현됩니다. 컴파일 시점에 타입 매개변수를 기반으로 코드가 생성되므로, 런타임 오버헤드 없이 높은 성능과 강력한 타입 안전성을 제공합니다.
  2. 동적 다형성 (Dynamic Polymorphism): 인터페이스(Interfaces)와 클래스-와이드 타입을 통해 구현됩니다. 런타임에 객체의 태그를 기반으로 호출할 서브프로그램이 결정되므로, 유연하고 확장성 있는 설계를 가능하게 합니다.

그렇다면, 제네릭의 컴파일 타임 안전성과 인터페이스의 런타임 유연성을 동시에 활용할 수는 없을까요? Ada는 이 두 가지를 결합하여 소프트웨어 추상화의 정점에 도달하는 길을 제공합니다.


메커니즘: 인터페이스로 제네릭 제약하기

핵심 아이디어는 제네릭 유닛을 정의할 때, 타입 매개변수가 특정 인터페이스를 구현해야 한다는 제약(constraint)을 거는 것입니다. 이는 제네릭에게 “이 템플릿은 아무 타입이나 받을 수 있는 것이 아니라, My_Interface라는 계약서에 서명한 타입만 받을 수 있다”고 알려주는 것과 같습니다.

문법은 다음과 같습니다.

generic
  -- Element_Type은 My_Interface를 구현하는 어떤 타입이든 될 수 있다.
  type Element_Type is new My_Interface with private;
package My_Generic_Package is
  -- ...
end My_Generic_Package;

이렇게 정의된 제네릭 패키지 내부에서는 Element_TypeMy_Interface의 모든 연산을 가지고 있음을 컴파일러가 보장합니다. 따라서 우리는 안심하고 해당 인터페이스의 서브프로그램을 호출할 수 있으며, 이 모든 것은 컴파일 타임에 안전하게 검사됩니다.

예제: 범용 아이템 처리기

Loggable 인터페이스를 구현하는 모든 타입의 아이템 리스트를 처리할 수 있는 범용 Process_Items 프로시저를 만들어 보겠습니다.

1. 재사용할 인터페이스 및 구체 타입 정의

이전 절에서 사용한 Loggable 인터페이스와, 이를 구현하는 서로 다른 두 타입 User_ActionNetwork_Packet이 있다고 가정합니다.

-- Loggable 인터페이스
type Loggable is interface;
function to_log_message (object : Loggable) return String is abstract;

-- 구체 타입 1
type User_Action is new Loggable with record ... end record;
overriding function to_log_message (object : User_Action) return String;

-- 구체 타입 2
type Network_Packet is new Loggable with record ... end record;
overriding function to_log_message (object : Network_Packet) return String;

2. 인터페이스로 제약된 제네릭 알고리즘 작성

이제 Loggable을 구현하는 Item 타입의 벡터를 받아 처리하는 제네릭 프로시저를 정의합니다.

with Ada.Containers.Vectors;
with Ada.Text_IO;
with Loggable; -- Loggable 인터페이스가 정의된 패키지

generic
  -- 제약 조건: Item 타입은 반드시 Loggable 인터페이스를 구현해야 함
  type Item is new Loggable with private;
  with package Item_Vectors is new Ada.Containers.Vectors (Positive, Item);
procedure Process_Items (list : Item_Vectors.Vector);

procedure Process_Items (list : Item_Vectors.Vector) is
begin
  for element of list loop
    -- Item이 Loggable임을 컴파일러가 알기 때문에 to_log_message 호출 가능
    Ada.Text_IO.put_line ("Processing: " & to_log_message (element));
  end loop;
end Process_Items;

3. 인스턴스화 및 사용

이제 이 제네릭 프로시저를 각 구체 타입에 맞게 인스턴스화하여 사용합니다.

-- User_Action을 처리하는 프로시저 생성
package User_Action_Vectors is new Ada.Containers.Vectors (Positive, User_Action);
procedure Process_User_Actions is
  new Process_Items (Item => User_Action, Item_Vectors => User_Action_Vectors);

-- Network_Packet을 처리하는 프로시저 생성
package Network_Packet_Vectors is new Ada.Containers.Vectors (Positive, Network_Packet);
procedure Process_Network_Packets is
  new Process_Items (Item => Network_Packet, Item_Vectors => Network_Packet_Vectors);

-- ... 메인 프로시저에서 ...
actions  : User_Action_Vectors.Vector;
packets  : Network_Packet_Vectors.Vector;
begin
  -- ... actions와 packets에 데이터 추가 ...

  Process_User_Actions (actions);
  Process_Network_Packets (packets);
end;

Process_Items라는 단 하나의 추상적인 알고리즘을 작성했지만, 컴파일러는 이를 기반으로 각각의 타입에 최적화된 안전한 코드를 생성해 주었습니다.


제네릭과 인터페이스의 결합은 관심사의 분리(Separation of Concerns)를 극대화하는 기법입니다. 알고리즘(제네릭)은 데이터의 구체적인 형태를 알 필요가 없고, 데이터(구체 타입)는 알고리즘의 존재를 알 필요가 없습니다. 이 둘은 오직 ‘인터페이스’라는 얇은 계약을 통해서만 연결됩니다. 이는 Ada를 사용하여 대규모의 복잡한 시스템을 구축할 때, 각 컴포넌트를 독립적으로 개발하고 테스트하며, 전체 시스템의 안정성과 재사용성을 극적으로 향상시키는 핵심적인 전략입니다.

14.6 제어되는 타입: 안정적인 자원 관리 (Ada.Finalization)

프로그램이 파일을 열거나, 동적 메모리를 할당하거나, 네트워크 연결을 생성하는 등 시스템 자원을 획득했을 때, 이 자원들을 언제나, 그리고 반드시 해제하도록 보장하는 것은 견고한 소프트웨어의 핵심 요건입니다. 그러나 서브프로그램이 여러 경로로 종료되거나, 특히 예외(exception)가 발생하여 실행 흐름이 예기치 않게 변경될 경우 자원 해제 코드가 누락되기 쉽습니다. 이러한 누락은 자원 누수(resource leak)로 이어져 시스템의 안정성을 심각하게 저해할 수 있습니다.

수동으로 모든 반환 지점과 모든 예외 핸들러에 자원 해제 코드를 삽입하는 것은 번거롭고 오류를 유발하기 쉬운 방식입니다. Ada는 이러한 문제를 언어 차원에서 체계적으로 해결하기 위해 제어되는 타입(Controlled Types)Ada.Finalization 패키지를 제공합니다.

제어되는 타입은 객체의 생명주기(life cycle)에 맞추어 초기화(initialization)종료 처리(finalization) 동작을 자동으로 관리하는 메커니즘을 제공합니다. 특히, 제어되는 타입의 객체가 스코프(scope)를 벗어날 때, Ada 런타임 시스템은 그 이유가 정상적인 종료이든, return 문에 의한 것이든, 예외 발생에 의한 것이든 상관없이 자동으로 finalize 프로시저를 호출해 줍니다. 이는 객체가 스스로 뒷정리를 하도록 보장하는 강력한 기능입니다.

이러한 패턴은 C++와 같은 다른 언어에서는 RAII(Resource Acquisition Is Initialization) 라는 이름으로 널리 알려져 있으며, 자원 관리와 예외 안전성(exception safety)을 동시에 달성하는 가장 효과적인 기법으로 인정받고 있습니다.

이번 절에서는 Ada.Finalization 패키지의 ControlledLimited_Controlled 타입을 상속하여 사용자 정의 타입을 만드는 방법을 학습합니다. 이를 통해 파일 핸들이나 메모리 포인터와 같은 자원을 객체 내부에 캡슐화하고, 어떠한 상황에서도 자원이 누수되지 않는 안정적인 프로그램을 작성하는 방법을 익히게 될 것입니다.

14.6.1 Ada.Finalization 패키지 소개

프로그래밍에서 자원(resource)은 유한하며 신중하게 다루어야 합니다. 파일 핸들, 네트워크 소켓, 동적으로 할당된 메모리, 데이터베이스 연결 등은 사용이 끝난 후 반드시 시스템에 반환되어야 합니다. 만약 프로그램이 자원을 반환하지 않으면 자원 누수(resource leak)가 발생하며, 이는 프로그램의 성능 저하를 넘어 시스템 전체의 안정성을 위협하는 심각한 결함으로 이어질 수 있습니다.

단순한 프로그램에서는 서브프로그램 종료 직전에 자원 해제 코드를 명시적으로 호출하는 것으로 충분할 수 있습니다. 하지만 복잡한 로직, 다중 반환 지점, 그리고 특히 예외(exception) 처리가 포함되면 문제는 훨씬 까다로워집니다. 예외가 발생하면 정상적인 실행 흐름을 벗어나게 되므로, 개발자가 모든 예외 발생 경로에 자원 해제 코드를 빠짐없이 배치하는 것은 매우 어렵고 오류가 발생하기 쉽습니다.

--  자원 누수가 발생할 수 있는 예시
procedure process_file (path : String) is
  handle : File_Handle := open_file (path);
begin
  --  ... 파일을 이용한 여러 작업 ...
  if some_error_condition then
    raise Error;  --  이 경우, close_file이 호출되지 않고 자원 누수 발생
  end if;
  --  ... 추가 작업 ...

  close_file (handle); -- 정상적인 경로에서만 호출됨
exception
  when Error =>
    --  여기서도 close_file(handle)을 호출해야 할까?
    --  만약 open_file 자체에서 예외가 발생했다면 handle은 유효하지 않다.
    put_line ("An error occurred.");
end process_file;

Ada는 이러한 문제를 언어 차원에서 체계적으로 해결하기 위해 표준 라이브러리인 Ada.Finalization 패키지를 제공합니다. 이 패키지는 제어되는 타입(Controlled Types)의 기반을 정의하며, 객체의 생명주기와 자원 관리를 강력하게 결합하는 메커니즘을 제공합니다.

이 접근법의 핵심 아이디어는 C++와 같은 언어에서 널리 알려진 RAII(Resource Acquisition Is Initialization) 패턴과 동일합니다.

  • 자원 획득은 초기화(Resource Acquisition Is Initialization): 객체가 생성될 때(초기화) 필요한 자원을 획득하고, 객체는 해당 자원을 소유합니다.
  • 자원 해제는 소멸(Resource Release Is Destruction): 객체의 생명주기가 끝나 스코프를 벗어날 때, 객체의 소멸자(destructor)가 자동으로 호출되어 소유했던 자원을 해제합니다.

Ada.Finalization의 제어되는 타입은 바로 이 RAII 패턴을 구현한 것입니다. 제어되는 타입의 객체는 자신이 소유한 자원을 스스로 정리할 책임을 집니다. 프로그래머는 더 이상 자원 해제를 위해 모든 예외 경로를 추적할 필요가 없습니다. 객체의 스코프가 끝나면, 그 원인이 무엇이든 Ada 런타임이 알아서 객체의 ‘파이널라이저(finalizer)’를 호출하여 뒷정리를 보장해 주기 때문입니다.

이를 통해 우리는 예외 상황에서도 자원 누수 걱정이 없는, 훨씬 더 견고하고 신뢰성 높은 코드를 작성할 수 있습니다. 다음 절부터 이 패키지의 핵심 구성 요소와 실제 사용법을 자세히 살펴보겠습니다.

14.6.2 제어되는 타입의 핵심 연산: initialize, adjust, finalize

Ada.Finalization 패키지의 Controlled 타입을 상속받아 새로운 타입을 정의하면, 우리는 세 가지 핵심적인 추상(abstract) 프로시저인 Initialize, Adjust, Finalize를 구현(override)해야 합니다. 이 프로시저들은 프로그래머가 직접 호출하는 것이 아니라, 객체의 생명주기 중 특정 시점에 Ada 런타임 시스템에 의해 자동으로 호출됩니다. 각 프로시저의 역할은 다음과 같습니다.

procedure initialize (object : in out Controlled)

  • 호출 시점: 제어되는 타입의 객체가 생성될 때, 해당 객체의 기본값이나 명시적 초기값이 설정된 직후에 호출됩니다.
  • 주요 역할: 객체가 필요로 하는 자원을 획득하고 내부 상태를 설정하는 생성자(constructor)와 유사한 역할을 합니다. 예를 들어, 동적 메모리를 할당하거나, 파일 핸들을 열거나, 내부 카운터를 초기화하는 작업을 수행합니다.

procedure finalize (object : in out Controlled)

  • 호출 시점: 제어되는 타입의 객체가 스코프를 벗어나 소멸하기 직전에 호출됩니다. 이 호출은 스코프가 정상적으로 종료되든, 예외 발생으로 인해 종료되든 반드시 보장됩니다.
  • 주요 역할: initialize에서 획득했던 모든 자원을 해제하는 소멸자(destructor)의 역할을 합니다. 할당했던 메모리를 해제하고, 열었던 파일을 닫는 등의 뒷정리 작업을 수행하여 자원 누수를 방지합니다.

procedure adjust (object : in out Controlled)

  • 호출 시점: 제어되는 타입의 객체가 대입문(:=)의 왼쪽에 위치할 때, 즉 다른 값으로 덮어쓰일 때 호출됩니다.
  • 주요 역할: 대입 연산 이후의 객체 상태를 올바르게 조정하는 역할을 합니다. Ada의 대입 연산은 기본적으로 비트 단위 복사(shallow copy)로 동작합니다. 만약 객체가 포인터와 같은 자원을 소유하고 있다면, 단순 복사만으로는 원본과 복사본이 동일한 자원을 공유하게 되는 문제가 발생할 수 있습니다. adjust는 이러한 상황에서 깊은 복사(deep copy)를 수행하여 객체가 자원의 독립적인 복사본을 소유하도록 만듭니다.

대입문 Target := Source; 가 실행될 때의 내부 동작은 다음과 같습니다.

  1. finalize (Target): Target 객체가 기존에 소유하던 자원을 해제합니다.
  2. Target의 모든 필드를 Source의 필드로 비트 단위 복사합니다.
  3. adjust (Target): 비트 단위 복사가 끝난 Target 객체의 상태를 조정합니다(예: 깊은 복사 수행).
연산 호출 시점 역할
initialize 객체 생성 직후 자원 획득 및 초기 설정
finalize 객체 소멸 직전 자원 해제
adjust 객체에 값 대입 직후 상태 조정 (예: 깊은 복사)

이 세 가지 연산을 올바르게 구현함으로써, 우리는 객체의 생성부터 소멸까지 전 생명주기에 걸쳐 자원을 안정적으로 관리할 수 있습니다. 다음은 이 연산들을 구현하는 기본적인 코드 구조입니다.

with Ada.Finalization;

package My_Package is
  type My_Type is new Ada.Finalization.Controlled with record
    --  ... 관리할 자원이나 데이터 ...
    Data : Some_Data_Type;
  end record;

private
  --  핵심 연산 오버라이딩
  overriding
  procedure initialize (object : in out My_Type);

  overriding
  procedure adjust (object : in out My_Type);

  overriding
  procedure finalize (object : in out My_Type);

end My_Package;

14.6.3 기본 제어 타입: Controlled

Ada.Finalization 패키지에서 제공하는 Controlled 타입은 대입(assignment)을 허용하면서 자원의 생명주기를 관리하고자 할 때 사용하는 가장 기본적인 제어 타입입니다. 이 타입을 상속받으면, 우리는 객체가 복사될 때의 동작(Adjust)과 생성(Initialize)/소멸(Finalize) 시의 동작을 직접 정의하여, 마치 언어에 내장된 타입처럼 자연스럽게 동작하는 동시에 자원을 안전하게 관리하는 새로운 타입을 만들 수 있습니다.

이번 절에서는 동적으로 할당된 메모리를 안전하게 감싸는(wrapping) 간단한 Buffer 타입을 구현하며 Controlled 타입의 사용법을 알아보겠습니다.

예제: 동적 메모리를 안전하게 관리하는 Buffer 타입

우리의 목표는 내부적으로 동적 문자 배열을 사용하지만, 사용자가 직접 메모리를 할당하거나 해제할 필요가 없는 Buffer 타입을 만드는 것입니다.

1. 패키지 명세 (safe_buffer_pkg.ads)

먼저 Buffer 타입을 Ada.Finalization.Controlled의 자식으로 선언하고, 동적 메모리를 가리킬 접근 타입(Chars_Ptr)을 필드로 추가합니다. 생성과 조회를 위한 기본 서브프로그램도 정의합니다.

-- file: safe_buffer_pkg.ads
with Ada.Finalization;
with Ada.Strings.Unbounded;

package Safe_Buffer_Pkg is

  type Buffer is new Ada.Finalization.Controlled with private;

  function create (content : String) return Buffer;
  function to_string (object : Buffer) return String;
  function length (object : Buffer) return Natural;

private

  --  System.Address와 유사하나, 타입 안정성을 위해 직접 정의
  type Character_Array is array (Positive range <>) of Character;
  type Chars_Ptr is access all Character_Array;

  type Buffer is new Ada.Finalization.Controlled with record
    handle : Chars_Ptr := null;
  end record;

  --  제어 연산 오버라이딩
  overriding
  procedure initialize (object : in out Buffer);

  overriding
  procedure adjust (object : in out Buffer);

  overriding
  procedure finalize (object : in out Buffer);

end Safe_Buffer_Pkg;

2. 패키지 본체 (safe_buffer_pkg.adb)

이제 세 가지 핵심 제어 연산과 공개 서브프로그램들을 구현합니다.

-- file: safe_buffer_pkg.adb
with Ada.Unchecked_Deallocation;

package body Safe_Buffer_Pkg is

  --  메모리 해제를 위한 Unchecked_Deallocation 인스턴스화
  procedure free is new Ada.Unchecked_Deallocation (Character_Array, Chars_Ptr);

  --  생성 시에는 특별한 동작이 없으므로 null로 둔다.
  --  실제 자원 할당은 create 함수가 담당한다.
  procedure initialize (object : in out Buffer) is
  begin
    null;
  end initialize;

  --  객체 소멸 시 메모리 해제
  procedure finalize (object : in out Buffer) is
  begin
    if object.handle /= null then
      free (object.handle);
    end if;
  end finalize;

  --  대입 연산 시 깊은 복사 수행
  procedure adjust (object : in out Buffer) is
    --  대입 연산은 비트 복사를 먼저 수행하므로,
    --  현재 object.handle은 원본 객체의 핸들을 가리키고 있다.
    source_handle : constant Chars_Ptr := object.handle;
  begin
    if source_handle = null then
      return; -- 원본이 비어있으면 할 일 없음
    end if;

    --  새로운 메모리를 할당하고 내용을 복사 (깊은 복사)
    object.handle := new Character_Array'(source_handle.all);
  end adjust;

  ----------- 공개 서브프로그램 구현 -----------

  function create (content : String) return Buffer is
    new_handle : constant Chars_Ptr := new Character_Array'(content);
  begin
    return (handle => new_handle);
  end create;

  function to_string (object : Buffer) return String is
  begin
    if object.handle = null then
      return "";
    else
      return object.handle.all;
    end if;
  end to_string;

  function length (object : Buffer) return Natural is
  begin
    if object.handle = null then
      return 0;
    else
      return object.handle'length;
    end if;
  end length;

end Safe_Buffer_Pkg;

3. 사용 예시

아래 코드는 Buffer 객체를 생성하고 대입한 뒤, 스코프를 벗어날 때 finalize가 자동으로 호출되어 메모리가 안전하게 해제되는 과정을 보여줍니다.

with Ada.Text_IO;
with Safe_Buffer_Pkg; use Safe_Buffer_Pkg;

procedure Main is
  b1 : Buffer;
begin
  Ada.Text_IO.put_line ("-- Entering outer block --");
  b1 := create ("Hello, Ada!");
  Ada.Text_IO.put_line ("b1: " & to_string (b1));

  declare
    b2 : Buffer := b1; -- 대입 발생: finalize(b2), adjust(b2) 자동 호출
  begin
    Ada.Text_IO.put_line ("-- Entering inner block --");
    Ada.Text_IO.put_line ("b2: " & to_string (b2));
    Ada.Text_IO.put_line ("-- Exiting inner block, b2 will be finalized --");
  end; -- 이 지점에서 b2의 finalize가 호출됨

  Ada.Text_IO.put_line ("-- Exiting outer block, b1 will be finalized --");
end Main; -- 이 지점에서 b1의 finalize가 호출됨

이처럼 Controlled 타입을 사용하면, 사용자는 자원 관리에 대한 부담 없이 객체를 값처럼 자유롭게 생성하고 복사할 수 있습니다. 언어의 런타임이 보이지 않는 곳에서 Initialize, Adjust, Finalize를 호출하여 모든 것을 안전하게 처리해 주기 때문입니다. 하지만 모든 자원이 복사(대입) 가능한 것은 아닙니다. 다음 절에서는 복사를 허용하지 않는 자원을 다루기 위한 Limited_Controlled 타입을 살펴보겠습니다.

14.6.4 대입을 금지하는 제어 타입: Limited_Controlled

앞서 살펴본 Controlled 타입은 객체의 복사, 즉 대입(:=)을 허용하는 자원을 관리하는 데 유용합니다. 하지만 세상의 모든 자원이 의미 있거나 안전하게 복사될 수 있는 것은 아닙니다.

예를 들어, 파일을 열 때 운영체제로부터 받는 파일 핸들(file handle)을 생각해 보십시오. 이 핸들을 복사한다고 해서 디스크에 새로운 파일이 생기는 것이 아닙니다. 대신 두 개의 변수가 동일한 파일을 가리키게 되어, 누가 파일을 닫아야 하는지에 대한 소유권 문제가 발생하고, 한쪽에서 파일을 닫으면 다른 쪽의 핸들은 무효화되는 등 혼란을 야기합니다. 네트워크 소켓이나 뮤텍스 락(mutex lock)과 같은 자원들도 마찬가지로 고유하며 복사의 개념이 무의미합니다.

이처럼 복사(대입)가 불가능하거나 위험한 고유 자원을 관리하기 위해 Ada는 Ada.Finalization.Limited_Controlled 타입을 제공합니다.

Limited_Controlled 타입의 가장 큰 특징은 이 타입을 상속받는 모든 자식 타입이 자동으로 제한된 타입(limited type)이 된다는 점입니다. 제한된 타입에는 대입 연산(:=)이 허용되지 않으므로, 컴파일러가 원천적으로 위험한 복사 시도를 차단해 줍니다. 대입이 불가능하므로 상태 조정을 위한 Adjust 프로시저 또한 필요 없으며, 우리는 오직 InitializeFinalize 두 연산만 구현하면 됩니다.

예제: 파일 핸들을 안전하게 래핑하는 File_Holder 타입

Ada.Text_IO의 파일 타입을 안전하게 감싸서, 어떤 경우에도 파일이 반드시 닫히도록 보장하는 File_Holder 타입을 구현해 보겠습니다.

1. 패키지 명세 (safe_file_pkg.ads)

File_Holder 타입을 Limited_Controlled의 자식으로 선언합니다. Adjust 프로시저는 존재하지 않습니다.

-- file: safe_file_pkg.ads
with Ada.Finalization;
with Ada.Text_IO;

package Safe_File_Pkg is

  type File_Holder is new Ada.Finalization.Limited_Controlled with private;

  -- 파일을 열고 File_Holder에 연결
  procedure open (object : in out File_Holder;
                  mode   : Ada.Text_IO.File_Mode;
                  name   : String);

  -- 파일에 쓰기
  procedure write (object : File_Holder; item : String);

  -- 명시적으로 파일 닫기
  procedure close (object : in out File_Holder);

private

  type File_Holder is new Ada.Finalization.Limited_Controlled with record
    handle : Ada.Text_IO.File_Type;
  end record;

  overriding
  procedure initialize (object : in out File_Holder);

  overriding
  procedure finalize (object : in out File_Holder);

end Safe_File_Pkg;

2. 패키지 본체 (safe_file_pkg.adb)

핵심은 finalize 프로시저입니다. Ada.Text_IO.Is_Open으로 파일이 열려있는지 확인하고, 그렇다면 Ada.Text_IO.Close를 호출하여 자원을 해제합니다.

-- file: safe_file_pkg.adb
package body Safe_File_Pkg is

  procedure initialize (object : in out File_Holder) is
  begin
    -- File_Type은 기본적으로 닫힌 상태로 초기화되므로
    -- 여기서 특별히 할 일은 없다.
    null;
  end initialize;

  procedure finalize (object : in out File_Holder) is
  begin
    if Ada.Text_IO.Is_Open (object.handle) then
      Ada.Text_IO.Close (object.handle);
    end if;
  end finalize;

  ---------------- 공개 서브프로그램 구현 ----------------

  procedure open (object : in out File_Holder;
                  mode   : Ada.Text_IO.File_Mode;
                  name   : String) is
  begin
    Ada.Text_IO.Open (File => object.handle, Mode => mode, Name => name);
  end open;

  procedure write (object : File_Holder; item : String) is
  begin
    Ada.Text_IO.put_line (File => object.handle, Item => item);
  end write;

  procedure close (object : in out File_Holder) is
  begin
    Ada.Text_IO.Close (object.handle);
  end close;

end Safe_File_Pkg;

3. 사용 예시

아래 코드는 File_Holder를 사용하여 파일을 생성하고, 대입 연산이 컴파일 오류를 일으키는 것을 보여줍니다. 또한 예외가 발생하더라도 finalize가 호출되어 파일이 안전하게 닫히는 것을 보장합니다.

with Ada.Text_IO;
with Safe_File_Pkg; use Safe_File_Pkg;

procedure Main is
  fh1 : File_Holder;
  --  fh2 : File_Holder; --  컴파일 오류를 보기 위한 선언
begin
  open (fh1, Mode => Ada.Text_IO.Out_File, Name => "test.txt");
  write (fh1, "RAII in Ada is robust!");

  --  fh2 := fh1;
  --  위 라인의 주석을 해제하면 컴파일러가 에러를 발생시킨다.
  --  "assignment to limited type 'Safe_File_Pkg.File_Holder' is not allowed"

  --  만약 이 지점에서 예외가 발생하더라도...
  --  raise Program_Error;

end Main; -- fh1이 스코프를 벗어날 때, finalize가 자동으로 호출되어 test.txt가 닫힌다.

Limited_Controlled는 자원의 소유권이 유일무이해야 하는 상황에서 컴파일 타임에 안전을 강제하는 매우 강력한 도구입니다. 이를 통해 프로그래머의 실수를 미연에 방지하고, 자원 관리의 논리를 명확하고 단순하게 유지할 수 있습니다.

14.6.5 제어되는 타입과 상호작용

제어되는 타입은 단독으로 사용될 때도 강력하지만, 진정한 가치는 접근 타입(access types)이나 표준 컨테이너(Ada.Containers)와 같은 다른 언어 기능과 결합될 때 드러납니다. Ada의 런타임 시스템은 이러한 상호작용을 예측 가능하고 안정적으로 처리하여, 복잡한 자료 구조 속에서도 자원 관리가 일관되게 유지되도록 보장합니다.

접근 타입과 제어되는 객체

접근 타입(포인터)이 제어되는 객체를 가리키는 경우를 생각해 보겠습니다. 이 객체의 finalize 프로시저는 언제 호출될까요? 접근 타입 변수 자체가 스코프를 벗어날 때가 아니라, 그 변수가 가리키는 대상 객체(designated object)가 메모리에서 해제될 때 호출됩니다.

메모리 해제는 Ada.Unchecked_Deallocation의 인스턴스를 통해 명시적으로 이루어집니다. Free(My_Ptr)와 같은 호출이 발생하면, 런타임은 다음과 같은 순서로 동작합니다.

  1. My_Ptr이 가리키는 객체의 finalize 프로시저를 호출하여 객체가 소유한 자원을 먼저 해제합니다.
  2. 객체의 finalize가 완료된 후, 객체 자체가 차지하던 메모리를 시스템에 반환합니다.
with Ada.Unchecked_Deallocation;
-- ... 이전 예제의 Safe_Buffer_Pkg 사용 ...
use Safe_Buffer_Pkg;

procedure Test_Access_Type is
  -- 제어되는 타입 Buffer를 가리키는 접근 타입 선언
  type Buffer_Ptr is access all Buffer;

  -- 메모리 해제 프로시저 인스턴스화
  procedure Free_Buffer is new Ada.Unchecked_Deallocation (Buffer, Buffer_Ptr);

  My_Handle : Buffer_Ptr;
begin
  -- 동적으로 제어되는 객체 생성
  My_Handle := new Buffer'(create ("Dynamic Buffer"));

  -- ... My_Handle을 이용한 다양한 작업 ...

  -- 메모리 해제
  Free_Buffer (My_Handle);
  -- 이 시점에 My_Handle이 가리키던 Buffer 객체의 finalize가 먼저 호출되고,
  -- 그 다음 메모리가 해제된다. 자원 누수는 발생하지 않는다.

end Test_Access_Type;

이러한 동작 덕분에, 동적으로 할당된 제어 객체 역시 자원 누수로부터 안전하게 관리될 수 있습니다.

컨테이너와 제어되는 요소

Ada.Containers 패키지의 벡터, 맵, 리스트 등은 제어되는 타입을 완벽하게 지원합니다. 컨테이너는 자신이 담고 있는 요소들의 생명주기를 책임지며, 다음과 같이 동작합니다.

  • 요소 추가: AppendInsert를 통해 제어되는 객체를 컨테이너에 추가하면, 컨테이너는 객체의 독립적인 복사본을 만들어 저장합니다. 만약 타입이 Controlled라면 Adjust가 호출되어 깊은 복사가 수행됩니다.
  • 요소 제거: Delete를 호출하여 특정 요소를 제거하거나 Clear로 모든 요소를 비우면, 컨테이너는 제거되는 각 객체의 finalize 프로시저를 자동으로 호출합니다.
  • 컨테이너 소멸: 컨테이너 변수 자체가 스코프를 벗어나 소멸될 때, 컨테이너는 자신이 담고 있던 모든 요소의 finalize를 순서대로 호출한 뒤, 자신의 내부 메모리를 해제합니다.
with Ada.Containers.Vectors;
with Ada.Text_IO;
with Safe_Buffer_Pkg; use Safe_Buffer_Pkg;

procedure Test_Container is
  -- 제어되는 타입 Buffer를 저장하는 벡터 패키지 인스턴스화
  package Buffer_Vectors is new Ada.Containers.Vectors (Positive, Buffer);
  use Buffer_Vectors;

  My_List : Vector;
begin
  My_List.Append (create ("First"));
  My_List.Append (create ("Second"));
  My_List.Append (create ("Third"));

  -- ... My_List를 이용한 작업 ...

end Test_Container;
-- 이 지점에서 My_List가 소멸된다.
-- 런타임은 My_List가 담고 있던 3개의 Buffer 객체 각각에 대해
-- finalize를 자동으로 호출한 뒤, 벡터의 내부 저장소를 해제한다.

이처럼 제어되는 타입과 표준 컨테이너의 긴밀한 통합은 프로그래머가 자원 관리에 대한 걱정 없이 고수준의 자료 구조를 자유롭게 사용할 수 있도록 해줍니다. 이는 대규모의 안정적인 시스템을 구축하는 데 있어 Ada가 제공하는 핵심적인 안전장치 중 하나입니다.

14.6.6 사용 지침 및 모범 사례

제어되는 타입은 Ada가 제공하는 강력한 자원 관리 도구이지만, 모든 상황에 필요한 것은 아니며 올바르게 사용해야 그 효과를 온전히 발휘할 수 있습니다. 이번 절에서는 제어되는 타입을 효과적으로 사용하기 위한 몇 가지 지침과 모범 사례를 정리합니다.

1. 자원을 ‘소유’할 때만 사용하라

제어되는 타입의 핵심 목적은 자원의 소유권(ownership)을 표현하는 것입니다. 어떤 타입이 파일 핸들이나 동적 메모리와 같이 명시적인 생성과 해제가 필요한 자원의 생명주기를 책임져야 할 때 제어되는 타입을 사용해야 합니다.

  • 적절한 사용 사례 ✅:
    • 파일 핸들, 네트워크 소켓, 뮤텍스 락(lock) 등 저수준 시스템 자원을 래핑(wrapping)할 때.
    • 동적으로 할당된 메모리를 관리하는 자료 구조를 만들 때.
    • 명시적인 createdestroy 함수 쌍을 요구하는 C 라이브러리와의 인터페이스를 구축할 때.
  • 부적절한 사용 사례 ❌:
    • 단순 데이터 집합(aggregation)에는 사용하지 마십시오. IntegerBoolean 필드만으로 구성된 레코드는 관리할 외부 자원이 없으므로 제어되는 타입으로 만들 필요가 없습니다. 불필요한 사용은 코드 복잡성을 높이고 약간의 런타임 오버헤드를 유발할 수 있습니다.

2. Limited_Controlled를 기본으로 고려하라

자원을 관리하는 타입을 설계할 때, 복사(대입)가 허용되어야 하는지 신중하게 결정해야 합니다.

  • Limited_Controlled를 우선적으로 선택: 대부분의 시스템 자원(파일 핸들, 소켓 등)은 고유하며, 복사하는 것이 의미가 없거나 위험합니다. 이런 경우 Limited_Controlled를 사용하여 컴파일 타임에 대입을 금지하는 것이 가장 안전한 접근법입니다. 이는 소유권 혼동과 같은 논리적 오류를 원천적으로 방지합니다.

  • Controlled는 깊은 복사가 가능할 때만 사용: Controlled는 대입 시 Adjust를 통해 의미 있는 깊은 복사(deep copy)를 정의할 수 있을 때만 사용해야 합니다. 동적 메모리를 기반으로 하는 문자열이나 버퍼 타입처럼, 값(value)처럼 동작해야 하는 타입이 여기에 해당합니다.

3. Finalize 연산은 단순하고 실패하지 않도록 작성하라

Finalize 프로시저는 어떤 경우에도 예외를 발생시켜서는 안 됩니다.

  • 이유: Finalize는 다른 예외가 발생하여 스택이 풀리는(unwinding) 과정에서 호출될 수 있습니다. 만약 이 과정에서 Finalize가 또 다른 예외를 발생시키면, 프로그램의 상태가 예측 불가능해지거나 Program_Error로 인해 즉시 종료될 수 있습니다.
  • 모범 사례: Finalize 내의 로직은 자원을 해제하는 최소한의 코드로만 구성해야 합니다(예: if Is_Open (FH) then Close (FH); end if;). 복잡한 오류 처리나 로깅은 Finalize가 아닌 다른 곳에서 처리해야 합니다.

4. 구현 세부사항을 숨겨라 (캡슐화)

타입이 제어되는 타입이라는 사실은 사용자가 알 필요 없는 구현 세부사항입니다.

  • 모범 사례: 제어되는 타입은 항상 패키지 명세의 private 부분에 선언하여 캡슐화하십시오. 패키지 사용자는 Open, Create, Write와 같은 공개된 API를 통해 객체를 조작해야 하며, 내부적인 자원 관리 방식에 대해서는 신경 쓰지 않아야 합니다. RAII의 장점은 바로 이러한 ‘보이지 않는 마법’에 있습니다.

이러한 지침들을 따르면, Ada의 제어되는 타입을 활용하여 자원 누수 걱정이 없고 예외 상황에서도 안정적으로 동작하는 매우 견고한 소프트웨어를 구축할 수 있습니다.

15. Ada를 활용한 함수형 프로그래밍

우리는 이전 장들을 통해 컴퓨터에 순차적인 명령을 내리는 ‘명령형 프로그래밍’과 데이터 및 관련 행위를 ‘객체’로 묶어 관리하는 ‘객체 지향 프로그래밍’을 학습하였습니다. 이번 장에서는 문제 해결에 대한 또 다른 접근법인 함수형 프로그래밍(Functional Programming, FP) 패러다임을 소개해 드리고자 합니다.

함수형 프로그래밍은 모든 계산 과정을 수학적인 함수의 평가 과정으로 간주하는 사고방식에서 출발합니다. 이 패러다임의 핵심은 프로그램의 상태를 직접 변경하거나 가변적인 데이터를 사용하기보다는, 순수한 함수들을 조합하여 원하는 결과를 얻는 데 있습니다. 이러한 접근법은 복잡한 시스템에서 발생할 수 있는 많은 오류를 줄이고, 코드의 동작을 더 쉽게 예측할 수 있도록 돕습니다.

본 장을 통해 독자 여러분께서는 함수형 프로그래밍의 기본 원칙을 이해하고, Ada 언어가 제공하는 기능들을 활용하여 이러한 원칙을 코드에 적용하는 구체적인 방법을 학습하게 될 것입니다.

15.1 함수형 프로그래밍 패러다임

함수형 프로그래밍은 프로그램을 일련의 상태 변화로 기술하는 대신, 부작용이 없는 순수한 함수들의 조합으로 구성합니다. 이는 “무엇을(What)” 계산할 것인지에 집중하며, “어떻게(How)” 계산할 것인지에 대한 절차적 세부 사항은 언어 자체에 위임하는 선언적(declarative) 스타일을 지향합니다. 본 절에서는 이러한 함수형 패러다임의 근간을 이루는 핵심적인 개념들을 구체적으로 살펴보도록 하겠습니다.

15.1.1 핵심 원칙: 순수성(purity)과 불변성(immutability)

함수형 프로그래밍 패러다임의 근간을 이루는 두 가지 핵심적인 원칙은 **순수 함수(pure Function)**와 **데이터의 불변성(Immutability)**입니다. 이 두 원칙은 프로그램의 동작을 이해하고 예측하기 쉽게 만들어, 코드의 안정성과 신뢰도를 높이는 데 결정적인 역할을 합니다.

순수 함수(Pure Function)

순수 함수란, 함수의 반환 값이 오직 함수에 전달된 **인자(Argument)**에 의해서만 결정되는 함수를 의미합니다. 이는 동일한 입력에 대해서는 언제나 동일한 결과를 반환함을 보장합니다.

순수 함수의 가장 중요한 특징은 관찰 가능한 **부작용(Side Effect)**을 일으키지 않는다는 점입니다. 부작용이란, 함수가 결과 값을 반환하는 주된 임무 외에 시스템의 다른 부분에 어떠한 변경이라도 가하는 행위를 포괄적으로 지칭합니다. 대표적인 부작용의 예는 다음과 같습니다.

  • 전역 변수 또는 정적 변수의 값을 수정하는 행위
  • in out 이나 out 모드의 매개변수 값을 변경하는 행위
  • 파일 시스템에 데이터를 읽거나 쓰는 행위
  • 화면 콘솔에 데이터를 출력하는 행위

아래는 정수 값을 제곱하는 순수 함수의 예시입니다.

-- 순수 함수의 예시
function square (x : in Integer) return Integer is
begin
   return X * X;
end square;

square 함수는 오직 입력 인자인 X의 값에만 의존하여 결과 값을 계산합니다. 함수 외부의 어떤 상태도 변경하지 않으며, 동일한 정수 값을 인자로 전달하면 항상 동일한 제곱 값을 반환합니다.

이러한 순수성은 **참조 투명성(Referential Transparency)**을 보장합니다. 참조 투명성이란, 프로그램 내에서 어떤 함수 호출 표현을 그 함수의 결과 값으로 완전히 대체하더라도 프로그램의 전체적인 동작에 아무런 변화가 없음을 의미합니다. 이는 코드의 특정 부분을 전체 시스템과 분리하여 독립적으로 분석하고 검증하는 것을 가능하게 하므로, 프로그램의 복잡도를 관리하는 데 매우 중요한 특성입니다.

불변성(Immutability)

불변성은 데이터가 한번 생성된 후에는 그 상태나 값을 변경할 수 없다는 원칙입니다. 명령형 프로그래밍에서는 변수의 값을 계속해서 갱신하는 것이 일반적이지만, 함수형 프로그래밍에서는 데이터의 수정을 피합니다.

만약 데이터의 변경이 필요하다면, 기존 데이터를 직접 수정하는 대신 변경 사항이 적용된 새로운 데이터를 생성하여 반환하는 방식을 사용합니다. 예를 들어, 배열의 특정 요소를 변경해야 할 경우, 원본 배열을 그대로 둔 채 해당 요소만 바뀐 새로운 배열을 만들어내는 것입니다.

이러한 불변성은 다음과 같은 중요한 이점을 제공합니다.

  • 예측 가능성 향상: 데이터의 상태가 변하지 않으므로, 특정 시점의 데이터 값은 항상 일정합니다. 이는 코드의 흐름을 추적하고 결과를 예측하기 쉽게 만듭니다.
  • 안전한 공유: 여러 스레드나 프로세스가 동일한 데이터를 동시에 접근하더라도, 데이터가 불변하므로 경쟁 조건(Race Condition)과 같은 동시성 관련 문제가 발생하지 않습니다. 이는 잠금(Lock)과 같은 복잡한 동기화 메커니즘의 필요성을 줄여줍니다.

Ada에서는 constant로 선언된 객체나 서브프로그램의 in 모드 매개변수를 통해 데이터의 불변성을 강제하고, 컴파일러가 의도치 않은 수정을 방지하도록 도울 수 있습니다. 이후에 학습할 ‘Delta Aggregates’와 같은 기능은 불변성을 유지하면서 데이터를 효율적으로 다루는 방법을 제공합니다.

15.1.2 일급 객체(First-Class Citizen)로서의 함수

함수형 프로그래밍의 유연성과 표현력은 함수를 **일급 객체(First-Class Citizen)**로 취급하는 개념에서 비롯됩니다. 프로그래밍 언어의 특정 구성 요소가 ‘일급 객체’라는 것은, 해당 요소가 일반적인 데이터 값(예: 정수, 레코드)과 동등한 권리를 가지며 자유롭게 다룰 수 있음을 의미합니다.

어떤 요소가 일급 객체가 되기 위해서는 통상적으로 다음 세 가지 조건을 만족해야 합니다.

  1. 변수나 데이터 구조에 저장(할당)될 수 있어야 합니다.
  2. 다른 함수의 매개변수로 전달될 수 있어야 합니다.
  3. 함수의 결과 값으로 반환될 수 있어야 합니다.

Ada는 **서브프로그램에 대한 접근 유형(Access to Subprogram Types)**을 통해 함수(또는 프로시저)를 일급 객체로 다룰 수 있는 명시적인 메커니즘을 제공합니다. 이는 서브프로그램의 메모리 주소를 가리키는 포인터를 안전하게 다룰 수 있도록 해주는 기능입니다.

다음은 정수를 인자로 받아 정수를 반환하는 함수를 가리킬 수 있는 접근 유형을 선언하고 사용하는 간단한 예시입니다.

-- 정수형 함수를 가리킬 접근 유형 선언
type Integer_Function_Access is access function (Item : in Integer) return Integer;

-- 위 유형에 맞는 함수들
function Double (Item : in Integer) return Integer is
begin
   return Item * 2;
end Double;

function Square (Item : in Integer) return Integer is
begin
   return Item * Item;
end Square;

-- 함수를 변수에 할당하고 사용하는 예시
procedure Demonstrate_First_Class is
   Op : Integer_Function_Access;
begin
   -- 1. 함수를 변수에 할당
   Op := Double'Access;
   -- Op.all은 Op가 가리키는 함수 자체를 의미
   Ada.Text_IO.Put_Line ("Double(10) = " & Integer'Image (Op.all (10))); -- 출력: Double(10) = 20

   Op := Square'Access;
   Ada.Text_IO.Put_Line ("Square(10) = " & Integer'Image (Op.all (10))); -- 출력: Square(10) = 100
end Demonstrate_First_Class;

위 예시에서, Integer_Function_Access라는 접근 유형을 선언하였고, Op라는 변수는 이 유형을 가집니다. 'Access 속성을 사용하여 DoubleSquare 함수의 주소를 Op 변수에 할당할 수 있었습니다. 그 후, Op.all(10) 구문을 통해 Op가 현재 가리키고 있는 함수를 호출하였습니다.

이처럼 함수를 일급 객체로 다룰 수 있는 능력은 단순한 유연성을 넘어, 고수준의 추상화를 구현하는 핵심적인 기반이 됩니다. 특정 조건에 따라 다른 연산을 수행하도록 하는 전략(Strategy) 패턴, 특정 이벤트 발생 시 호출될 함수를 등록하는 콜백(Callback) 메커니즘, 그리고 함수를 인자로 받아 새로운 함수를 반환하는 고차 함수(Higher-Order Function) 등 강력한 프로그래밍 기법들을 가능하게 합니다. 이에 대한 구체적인 활용법은 이후의 절에서 더 깊이 있게 다룰 것입니다.

15.1.3 Ada와 함수형 패러다임의 조화

Ada는 순수 함수형 언어는 아니지만, 그 설계 철학과 내장된 기능들은 함수형 프로그래밍 패러다임과 매우 효과적으로 조화를 이룰 수 있습니다. 언어의 핵심적인 특성들이 함수형 기법을 안전하고 신뢰성 있게 적용할 수 있는 견고한 토대를 제공하기 때문입니다.

무엇보다 Ada의 강력한 정적 타입 시스템은 함수형 코드의 안정성을 극대화하는 가장 중요한 요소입니다. 함수를 일급 객체로 다루어 변수에 할당하거나 다른 함수에 매개변수로 전달하는 과정에서 발생할 수 있는 잠재적인 타입 불일치 오류를 컴파일 시점에 엄격하게 검사합니다. 이는 동적 타입 언어에서 런타임에 발생할 수 있는 오류들을 사전에 방지하여, 프로그램의 신뢰성을 최우선으로 하는 Ada의 철학이 함수형 프로그래밍의 예측 가능성과 결합하여 강력한 시너지를 발휘하는 지점입니다.

예를 들어, 특정 함수 접근 유형으로 선언된 변수에는 반드시 해당 시그니처와 정확히 일치하는 서브프로그램의 접근자만 할당될 수 있습니다. 컴파일러는 매개변수의 개수, 타입, 순서, 그리고 반환 타입까지 모두 검증하여 프로그래머의 실수를 원천적으로 차단합니다.

Ada 코드에 함수형 스타일을 부분적으로라도 채택하는 것은 다음과 같은 실질적인 이점을 제공합니다.

  • 신뢰성 및 검증 용이성: 부작용이 없는 순수 함수로 구성된 모듈은 독립적으로 단위 테스트를 수행하기가 매우 용이합니다. 각 함수의 동작이 외부 상태에 영향을 받거나 주지 않으므로, 입력과 출력만 검증하면 해당 기능의 정확성을 보장할 수 있습니다. 이는 복잡한 시스템의 전체적인 신뢰도를 높이는 데 기여합니다.

  • 유지보수성 향상: 각 기능이 명확한 입력과 출력을 가지는 독립된 함수로 분리되면, 코드의 논리 구조를 이해하기 쉬워집니다. 특정 기능의 수정이 시스템의 다른 부분에 예기치 않은 영향을 미칠 가능성이 줄어들어, 코드의 유지보수가 훨씬 용이해집니다.

  • 안전한 동시성(Concurrency) 구현: 함수형 프로그래밍의 가장 큰 장점 중 하나는 병렬 처리와의 친화성입니다. 순수 함수는 공유 자원에 대한 상태 변경을 유발하지 않으므로, 여러 태스크가 동시에 실행될 때 발생할 수 있는 경쟁 조건(Race Condition)이나 교착 상태(Deadlock)와 같은 고질적인 동시성 문제에서 비교적 자유롭습니다. 이는 Ada의 강력한 태스킹 기능과 결합하여, 안전하고 효율적인 병렬 코드를 작성하는 작업을 훨씬 단순하게 만들어 줍니다.

이어지는 절들에서는 이러한 함수형 패러다임의 원칙들을 Ada 언어에서 실제로 구현할 수 있도록 지원하는 표현식(Expressions), 애그리게이트(Aggregates), 양자화 표현식(Quantified Expressions) 등 구체적인 언어 기능들을 깊이 있게 탐구해 보겠습니다. 이러한 기능들을 통해 독자 여러분께서는 Ada를 사용하여 더욱 안정적이고 표현력 높은 코드를 작성하는 방법을 학습하게 될 것입니다.

15.2 불변성을 위한 데이터 처리

앞서 14.1절에서 함수형 프로그래밍의 핵심 원칙 중 하나로 데이터의 불변성(Immutability)을 설명했습니다. 불변성이란 데이터가 생성된 후 그 상태를 변경하지 않는 원칙을 말하며, 이는 코드의 예측 가능성을 높이고 동시성 환경에서의 안정성을 확보하는 데 중요한 역할을 합니다.

이번 절에서는 이러한 불변성의 원칙을 실제 Ada 코드에서 어떻게 구현하고 실천할 수 있는지 구체적인 데이터 처리 기법들을 통해 학습하겠습니다. 단순히 데이터를 변경하지 않는 소극적인 방식을 넘어, 불변성을 유지하면서도 데이터의 수정이 필요한 상황에 효과적으로 대처하는 방법을 익히는 데 초점을 맞출 것입니다.

이를 위해 먼저 Ada의 기본적인 기능인 상수(Constant) 객체를 활용하여 데이터의 변경을 원천적으로 방지하는 방법을 살펴보고, 이어서 레코드와 같은 복합 데이터 구조를 비파괴적인(Non-destructive) 방식으로, 즉 원본을 수정하지 않고 수정된 복사본을 효율적으로 생성하는 ‘Delta Aggregates’ 구문에 대해 깊이 있게 탐구하겠습니다.

15.2.1 상수 객체와 불변 데이터 구조

데이터의 불변성을 보장하는 가장 직접적이고 명확한 방법은 Ada의 상수(Constant) 객체를 활용하는 것입니다. 어떤 객체를 constant 키워드로 선언하면, 그 객체는 초기화된 이후 프로그램 실행 중에 그 값을 절대로 변경할 수 없습니다. 컴파일러는 상수로 선언된 객체에 값을 대입하려는 모든 시도를 컴파일 시점에 오류로 처리하여, 의도치 않은 데이터 변경을 원천적으로 방지합니다.

다음은 상수를 선언하고 사용하는 예시입니다.

procedure Demonstrate_Constant is
   -- 컴파일 시점에 값이 결정되는 정적 상수
   MAX_CONNECTIONS : constant Integer := 128;

   -- 런타임에 값이 결정될 수 있는 상수 (예: 설정 파일로부터 읽은 값)
   Timeout_Ms : constant Natural := Get_Timeout_From_Config;

   Current_Connections : Integer := 0;
begin
   -- ... 로직 수행 ...
   if Current_Connections < MAX_CONNECTIONS then
      -- ...
   end if;

   -- 아래 코드는 컴파일 오류를 발생시킵니다.
   -- Error: assignment to constant is not allowed
   -- MAX_CONNECTIONS := 256;

end Demonstrate_Constant;

위 예시에서 MAX_CONNECTIONS는 상수로 선언되었으므로, 값을 변경하려는 시도는 컴파일러에 의해 차단됩니다. 이처럼 상수는 프로그램 전체에서 변하지 않아야 하는 명확한 값(예: 수학적 상수, 시스템의 최대 한계치)을 정의하는 데 매우 유용하며, 코드의 가독성과 안정성을 높여줍니다.

또한, 서브프로그램의 매개변수를 in 모드로 선언하는 것 역시 불변성을 강제하는 중요한 수단입니다. in 모드는 기본값이며, 해당 매개변수는 서브프로그램 내부에서 ‘읽기 전용’으로 취급됩니다. 즉, 함수나 프로시저가 외부로부터 전달받은 데이터를 내부에서 임의로 수정할 수 없도록 보장하여, 해당 서브프로그램이 호출자(Caller)의 상태에 예기치 않은 부작용을 일으키지 않음을 보장합니다.

-- 'in' 모드 매개변수는 서브프로그램 내에서 상수처럼 취급됩니다.
procedure Print_User_Data (User : in User_Record) is
begin
   Ada.Text_IO.Put_Line ("User ID: " & User.Id'Image);

   -- 아래 코드는 컴파일 오류를 발생시킵니다.
   -- User.Id := User.Id + 1;
end Print_User_Data;

이러한 상수 객체와 in 모드 매개변수의 사용은 불변 데이터 구조를 구축하는 가장 기본적인 단계입니다. 하지만 프로그램은 종종 기존 데이터에 기반한 새로운 데이터를 필요로 합니다. 예를 들어, 사용자의 프로필 레코드에서 이름만 변경된 새로운 레코드를 생성해야 할 수 있습니다. 이러한 경우, 원본 데이터의 불변성을 해치지 않으면서 어떻게 수정된 복사본을 효율적으로 만들 수 있을까요? 다음 절에서는 이 문제에 대한 해답으로 ‘Delta Aggregates’를 자세히 살펴보겠습니다.

15.2.2 Delta Aggregates를 이용한 비파괴적 수정

상수 객체만으로는 불변성을 유지하며 동적인 데이터를 다루기에 부족합니다. 프로그램은 종종 기존 데이터의 일부만 변경된 새로운 데이터가 필요한 경우가 많기 때문입니다. 예를 들어, 사용자의 프로필 정보가 담긴 레코드에서 주소만 변경된 새로운 레코드를 생성해야 할 때, 전통적인 방식은 새로운 변수를 선언하고 원본의 모든 필드를 일일이 복사한 후, 변경이 필요한 필드만 다시 대입하는 것이었습니다. 이 방식은 코드가 길어지고, 필드가 추가될 때마다 수정이 필요하여 유지보수가 어렵다는 단점이 있습니다.

Delta Aggregates는 이러한 비파괴적 수정(Non-destructive Modification)을 간결하고 안전하게 수행할 수 있도록 도입된 현대적인 Ada 구문입니다. 이 구문은 기존 레코드 객체를 기반으로, 지정된 일부 필드(delta, 즉 ‘변경분’)만 다른 값으로 설정한 새로운 레코드 애그리게이트를 생성합니다.

Delta Aggregate의 기본 구문은 다음과 같습니다.

New_Record := (Original_Record with delta Component_Name => New_Value, ...);

이 구문은 Original_Record의 모든 값을 그대로 가지되, 지정된 Component_Name의 값만 New_Value로 변경한 새로운 레코드 값을 생성합니다. 가장 중요한 점은 이 과정에서 Original_Record 자체는 전혀 변경되지 않는다는 것입니다.

다음은 사용자 프로필 레코드를 사용하여 Delta Aggregate의 동작을 보여주는 예시입니다.

type User_Profile is record
   Id      : Integer;
   Name    : Unbounded_String;
   Is_Active : Boolean;
end record;

procedure Demonstrate_Delta_Aggregate is
   -- 원본 사용자 프로필
   Original_User : constant User_Profile :=
     (Id => 101, Name => To_Unbounded_String ("Alice"), Is_Active => True);

   -- 원본 데이터의 Is_Active 필드만 False로 변경한 새로운 레코드 생성
   Deactivated_User : constant User_Profile :=
     (Original_User with delta Is_Active => False);
begin
   -- 원본 데이터 출력
   Ada.Text_IO.Put_Line ("Original User Active: " & Boolean'Image (Original_User.Is_Active));
   -- >> 출력: Original User Active: TRUE

   -- 새로 생성된 데이터 출력
   Ada.Text_IO.Put_Line ("Deactivated User Active: " & Boolean'Image (Deactivated_User.Is_Active));
   -- >> 출력: Deactivated User Active: FALSE

   -- 원본 데이터의 불변성 확인
   -- Original_User.Is_Active는 여전히 True 값을 유지합니다.
end Demonstrate_Delta_Aggregate;

위 예시에서 볼 수 있듯이, Deactivated_UserOriginal_User를 기반으로 Is_Active 필드만 False로 변경하여 생성되었습니다. 이 과정 후에도 Original_UserIs_Active 값은 True로 그대로 유지되어, 데이터의 불변성이 완벽하게 지켜졌습니다.

Delta Aggregates는 다음과 같은 명확한 이점을 제공합니다.

  • 가독성 및 의도 명확성: 코드를 읽는 즉시 어떤 레코드를 기반으로 어떤 필드가 변경되는지 명확하게 파악할 수 있습니다.
  • 유지보수성: 원본 레코드에 새로운 필드가 추가되더라도, Delta Aggregate를 사용하는 코드는 변경할 필요가 없습니다. 새 필드는 자동으로 원본 객체의 값이 복사됩니다.
  • 안전성: 모든 필드를 수동으로 복사할 때 발생할 수 있는 누락 실수를 방지합니다.

이처럼 Delta Aggregates는 불변성을 유지하면서 데이터의 변형을 다루는 함수형 프로그래밍 스타일에 필수적인 도구입니다. 이는 Ada에서 상태 변화를 명시적이고 안전하게 관리할 수 있도록 지원하는 핵심적인 기능이라 할 수 있습니다.

15.3 일급 객체로서의 함수 활용

앞서 14.1.2절에서는 함수를 변수에 할당하거나 매개변수로 전달할 수 있는 ‘일급 객체’의 개념에 대해 학습했습니다. 이는 함수형 프로그래밍의 유연성을 뒷받침하는 핵심적인 특성입니다.

이번 절에서는 이 개념이 단순한 이론에 그치지 않고, 실제 Ada 프로그래밍에서 어떻게 구체적인 구문으로 구현되고 활용될 수 있는지 심도 있게 탐구하겠습니다. 즉, ‘함수를 일급 객체로 다룬다’는 것이 실제 코드에서 어떤 모습으로 나타나며, 이를 통해 어떤 강력하고 유연한 설계를 할 수 있는지에 초점을 맞출 것입니다.

이를 위해 먼저 서브프로그램에 대한 접근 유형을 선언하고 사용하는 기본적인 방법부터 시작하여, 더 간결한 코드를 작성하게 해주는 익명 접근 유형, 그리고 접근 값이 아무것도 가리키지 않는 ‘null’ 상태를 안전하게 다루는 방법까지 단계적으로 살펴보겠습니다. 이 과정을 통해 독자 여러분께서는 고차 함수(Higher-Order Functions)와 같은 고급 프로그래밍 기법을 구현할 수 있는 단단한 기초를 마련하게 될 것입니다.

15.3.1 서브프로그램에 대한 접근 유형 (Access to Subprogram Types)

Ada에서 함수를 일급 객체로 다루는 가장 기본적이고 명시적인 방법은 **서브프로그램에 대한 접근 유형(Access to Subprogram Type)**을 선언하는 것입니다. 이는 특정 서브프로그램의 시그니처(Signature)와 정확히 일치하는 서브프로그램들만 가리킬 수 있는, 일종의 안전한 함수 포인터 타입을 정의하는 기능입니다.

이 접근 유형을 선언하는 구문은 다음과 같습니다.

type Type_Name is access {procedure|function} [Parameter_Profile];

여기서 Parameter_Profile은 매개변수의 타입, 모드, 순서와 (함수의 경우) 반환 타입을 명시하는 부분입니다. 이렇게 타입을 선언하고 나면, 해당 타입의 변수는 지정된 시그니처와 일치하는 서브프로그램의 주소만을 가질 수 있습니다. 컴파일러는 이 규칙이 엄격하게 지켜지는지 검증하여 타입 안정성을 보장합니다.

특정 서브프로그램의 주소 값(접근 값)을 얻기 위해서는 'Access 속성을 사용합니다. 이 속성은 정적으로 선언된 서브프로그램에 적용되어, 해당 서브프로그램을 가리키는 접근 값을 반환합니다.

이렇게 얻은 접근 값을 통해 실제 서브프로그램을 호출할 때는 .all 접미사를 사용하여 접근 값을 역참조(Dereference)한 후, 일반적인 서브프로그램 호출과 동일하게 인자를 전달합니다.

아래 예제는 정수 배열의 모든 요소에 특정 연산(함수)을 적용하는 범용적인 프로시저를 통해 서브프로그램 접근 유형의 활용법을 보여줍니다. 이는 함수형 프로그래밍의 map 연산과 유사한 개념을 구현한 것입니다.

-- 정수 배열 타입을 선언합니다.
type Integer_Array is array (Positive range <>) of Integer;

-- 정수 하나를 받아 정수 하나를 반환하는 함수를 가리킬 접근 유형을 선언합니다.
type Integer_Operator_Access is access function (Value : in Integer) return Integer;

-- Integer_Operator_Access 유형에 할당될 수 있는 함수들을 정의합니다.
function Increment (Value : in Integer) return Integer is
begin
   return Value + 1;
end Increment;

function Double (Value : in Integer) return Integer is
begin
   return Value * 2;
end Double;

-- 배열의 모든 요소에 주어진 연산을 적용하는 고차 함수(프로시저)입니다.
procedure Apply_To_All (Data : in out Integer_Array;
                        Op   : in     Integer_Operator_Access) is
begin
   for I in Data'Range loop
      -- Op.all을 통해 Op가 가리키는 실제 함수를 호출합니다.
      Data (I) := Op.all (Data (I));
   end loop;
end Apply_To_All;

-- 메인 프로시저에서 활용 예시를 보여줍니다.
procedure Demonstrate_Access_Type is
   My_Data : Integer_Array := (1, 2, 3, 4, 5);
begin
   -- 1. Increment 함수를 전달하여 모든 요소를 1씩 증가시킵니다.
   Apply_To_All (My_Data, Increment'Access);
   -- 이제 My_Data는 (2, 3, 4, 5, 6)이 됩니다.

   -- 2. Double 함수를 전달하여 모든 요소를 2배로 만듭니다.
   Apply_To_All (My_Data, Double'Access);
   -- 이제 My_Data는 (4, 6, 8, 10, 12)이 됩니다.
end Demonstrate_Access_Type;

위 예시의 Apply_To_All 프로시저는 두 번째 매개변수 Op를 통해 수행할 연산(함수)을 동적으로 전달받습니다. Increment'Access를 전달하면 모든 요소에 1을 더하고, Double'Access를 전달하면 모든 요소에 2를 곱합니다. 이처럼 실행 시점에 코드의 동작을 변경할 수 있게 해주는 설계 방식을 **전략 패턴(Strategy Pattern)**이라고도 부릅니다.

이와 같이 서브프로그램에 대한 접근 유형은 코드의 재사용성을 높이고, 특정 로직을 매개변수화하여 유연성을 극대화하는 강력한 도구입니다. 이는 Ada에서 고차 함수를 구현하고 함수형 스타일의 프로그래밍을 하는 데 있어 가장 근간이 되는 기능이라 할 수 있습니다.

15.3.2 익명 접근 유형 (Anonymous Access Types)

이전 절에서는 type 키워드를 사용하여 서브프로그램에 대한 접근 유형을 명시적으로 선언하고, 그 이름을 사용하는 방법을 학습했습니다. 하지만 특정 함수 시그니처가 한두 곳의 서브프로그램 매개변수에서만 제한적으로 사용될 경우, 별도의 타입 이름을 선언하는 것이 다소 번거롭게 느껴질 수 있습니다.

이러한 상황을 위해 Ada는 익명 접근 유형(Anonymous Access Type) 구문을 지원합니다. 이는 별도의 타입 이름을 정의하지 않고, 서브프로그램의 매개변수 목록이나 반환 타입 명세에 직접 access 키워드를 사용하여 접근 유형을 즉석에서 정의하는 방식입니다.

익명 접근 유형은 주로 서브프로그램의 매개변수를 선언할 때 다음과 같은 형태로 사용됩니다.

procedure Procedure_Name (Parameter_Name : in access {procedure|function} [Parameter_Profile]);

이 방식은 코드를 더 간결하게 만들고, 해당 접근 유형이 오직 그 서브프로그램의 내부적인 구현을 위해서만 사용된다는 의도를 명확히 드러내는 효과가 있습니다.

이전 절에서 다룬 Apply_To_All 예제를 익명 접근 유형을 사용하여 다시 작성하면 다음과 같습니다.

type Integer_Array is array (Positive range <>) of Integer;

-- Integer_Operator_Access 타입을 별도로 선언하지 않습니다.

-- ... Increment, Double 함수 정의는 동일 ...
function Increment (Value : in Integer) return Integer is (Value + 1);
function Double (Value : in Integer) return Integer is (Value * 2);

-- Op 매개변수에 익명 접근 유형을 직접 사용합니다.
procedure Apply_To_All_Anonymous (
   Data : in out Integer_Array;
   Op   : in     access function (Value : in Integer) return Integer) is
begin
   for I in Data'Range loop
      Data (I) := Op.all (Data (I));
   end loop;
end Apply_To_All_Anonymous;

-- 활용 예시
procedure Demonstrate_Anonymous_Type is
   My_Data : Integer_Array := (1, 2, 3, 4, 5);
begin
   Apply_To_All_Anonymous (My_Data, Increment'Access);
   -- My_Data는 (2, 3, 4, 5, 6)이 됩니다.

   Apply_To_All_Anonymous (My_Data, Double'Access);
    -- My_Data는 (4, 6, 8, 10, 12)이 됩니다.
end Demonstrate_Anonymous_Type;

Apply_To_All_Anonymous 프로시저의 선언부를 보면, Integer_Operator_Access라는 타입 이름 대신 access function (Value : in Integer) return Integer라는 구문이 직접 사용된 것을 확인할 수 있습니다. 코드의 동작은 명명된 타입을 사용했을 때와 완전히 동일하지만, 별도의 타입 선언이 필요 없어 코드가 더 간결해졌습니다.

사용 시 고려사항

익명 접근 유형은 편리하지만, 항상 최선의 선택은 아닐 수 있습니다. 다음과 같은 장단점을 고려하여 적절한 방식을 선택해야 합니다.

  • 장점: 코드가 간결해지며, 해당 타입의 사용 범위가 특정 서브프로그램으로 한정됨을 명확히 할 수 있습니다.
  • 단점: 동일한 함수 시그니처를 여러 서브프로그램에서 반복해서 사용해야 할 경우, 명명된 타입을 사용하는 것이 재사용성과 유지보수성 측면에서 더 유리합니다. 또한, 해당 접근 유형의 변수를 서브프로그램 외부에서 선언할 수 없습니다.

결론적으로, 익명 접근 유형은 특정 고차 함수(Higher-Order Function)의 구현을 위해 지역적으로 함수 시그니처를 정의할 때 매우 유용한 도구입니다. 반면, 시스템 전반에 걸쳐 공유되고 재사용될 필요가 있는 함수 시그니처의 경우에는 명시적인 타입 선언을 사용하는 것이 바람직한 설계 방식입니다.

15.3.3 Null 접근 값과 기본 동작 지정

다른 모든 접근 유형(Access Type)과 마찬가지로, 서브프로그램에 대한 접근 유형의 변수 또한 null 값을 가질 수 있습니다. null 값은 해당 접근 변수가 현재 어떠한 서브프로그램도 가리키고 있지 않음을 의미합니다.

null 상태인 접근 변수를 통해 서브프로그램을 호출하려고 시도하면, 프로그램은 Constraint_Error 예외를 발생시키며 중단됩니다. 따라서, 접근 변수를 사용하기 전에는 항상 null 여부를 확인하는 것이 안전한 프로그래밍의 기본입니다.

-- Op가 null이 아닐 경우에만 함수를 호출합니다.
if Op /= null then
   Result := Op.all (Value);
else
   -- null일 경우의 대체 동작을 수행합니다.
   ...
end if;

이러한 명시적인 null 확인은 코드를 안전하게 만들지만, 모든 호출 지점마다 반복적인 확인 구문을 추가해야 하므로 코드가 다소 장황해질 수 있습니다.

더 나은 접근법은 접근 변수가 null 상태에 빠지지 않도록 **기본 동작(Default Action)**을 지정하는 것입니다. 이는 변수를 선언할 때, 아무런 작업을 하지 않는 ‘더미(Dummy)’ 서브프로그램이나 기본값을 반환하는 함수의 접근 값을 초깃값으로 할당하는 방식입니다.

예를 들어, 특정 이벤트 발생 시 호출될 프로시저(콜백)를 매개변수로 받는 경우를 생각해 보겠습니다. 만약 호출자가 콜백을 지정하고 싶지 않다면, null을 전달하는 대신 ‘아무것도 하지 않는’ 프로시저를 기본값으로 사용하면 null 확인 로직을 완전히 제거할 수 있습니다.

아래는 이러한 기법을 적용한 예시입니다.

-- 프로시저를 가리킬 접근 유형을 선언합니다.
type Event_Callback is access procedure (Event_Code : in Integer);

-- 1. 기본 동작으로 사용될 '아무것도 하지 않는' 프로시저를 정의합니다.
procedure Do_Nothing (Event_Code : in Integer) is
begin
   null; -- 아무 작업도 수행하지 않음
end Do_Nothing;

-- 이벤트를 처리하는 메인 프로시저입니다.
-- Caller_Callback의 기본값으로 Do_Nothing'Access를 지정합니다.
procedure Process_Events (Caller_Callback : in Event_Callback := Do_Nothing'Access) is
   -- ... 여러 이벤트들을 처리하는 로직 ...
   An_Event_Code : constant Integer := 42;
begin
   -- 이제 Caller_Callback이 null인지 확인할 필요가 없습니다.
   -- 항상 유효한 프로시저를 가리키고 있기 때문입니다.
   Caller_Callback.all (An_Event_Code);
end Process_Events;

-- 지정된 콜백을 실행하는 프로시저
procedure My_Specific_Callback (Event_Code : in Integer) is
begin
   Ada.Text_IO.Put_Line ("Event " & Event_Code'Image & " occurred.");
end My_Specific_Callback;

-- 활용 예시
procedure Demonstrate_Default_Callback is
begin
   -- 1. 콜백을 지정하지 않고 호출 -> Do_Nothing이 실행됨 (아무 일도 일어나지 않음)
   Process_Events;

   -- 2. 특정 콜백을 지정하여 호출 -> My_Specific_Callback이 실행됨
   Process_Events (My_Specific_Callback'Access);
end Demonstrate_Default_Callback;

Process_Events 프로시저는 Caller_Callback 매개변수의 기본값을 Do_Nothing'Access로 지정했습니다. 덕분에 프로시저 내부에서는 Caller_Callbacknull일 가능성을 전혀 고려할 필요 없이 안전하게 호출할 수 있습니다. 호출하는 쪽에서는 필요에 따라 특정 콜백을 전달할 수도 있고, 생략할 수도 있으며, 두 경우 모두 Process_Events의 코드는 변경되지 않습니다.

결론적으로, 서브프로그램에 대한 접근 변수를 다룰 때는 두 가지 방식을 고려할 수 있습니다.

  1. 명시적 확인: null이 의도된 상태 중 하나일 경우, if Var /= null then ... 구문을 사용하여 명시적으로 확인합니다.
  2. 기본값 지정: 가능한 경우, ‘아무것도 하지 않거나’ 기본 행위를 하는 서브프로그램을 기본값으로 할당하여 null 상태 자체를 회피합니다. 이는 코드를 더 간결하고 안전하게 만드는 권장 방식입니다.

15.4 표현식 중심의 프로그래밍

전통적인 명령형 프로그래밍은 주로 문장(Statement)을 기반으로 합니다. if ... then ... end if; 문이나 대입문(:=)과 같은 문장은 특정 작업을 수행하도록 지시하지만, 그 자체가 값을 가지지는 않습니다. 반면, 표현식(Expression)은 평가(Evaluation)를 통해 항상 특정 타입의 값을 결과로 내놓습니다. A + B나 함수 호출 Square(10)이 대표적인 표현식의 예입니다.

함수형 프로그래밍은 부작용을 최소화하고 프로그램의 여러 부분을 조합하여 더 큰 기능을 만드는 것을 지향하기 때문에, 문장보다는 표현식을 선호하는 경향이 있습니다. 표현식은 그 자체로 값을 가지므로 다른 표현식의 일부가 될 수 있어, 데이터가 흐르는 파이프라인을 구축하는 것과 같은 조합적인 프로그래밍에 매우 적합합니다.

Ada는 전통적으로 문장 중심의 언어였지만, 현대에 이르러 함수형 스타일을 지원하기 위해 더욱 강력한 표현식 관련 기능들을 도입하였습니다. 이번 절에서는 이러한 기능들을 통해 어떻게 코드를 더 간결하고 선언적으로 작성할 수 있는지 학습합니다.

이를 위해, 함수의 본체를 단일 표현식으로 정의할 수 있는 표현식 함수(Expression Functions), ifcase와 같은 조건 논리를 표현식 내부에 포함시키는 조건부 표현식(Conditional Expressions), 그리고 표현식 내에서 임시 변수나 상수를 선언하여 가독성을 높이는 선언 표현식(Declare Expressions)에 대해 차례대로 살펴보겠습니다.

15.4.1 표현식 함수 (Expression Functions)

**표현식 함수(Expression Function)**는 함수의 본체가 begin ... end 블록 대신 단 하나의 표현식으로 구성되는 간결한 함수 정의 방식입니다. 이는 함수가 복잡한 절차를 수행하는 것이 아니라, 단순히 특정 계산을 통해 값을 반환하는 역할을 한다는 의도를 명확하게 드러냅니다.

표현식 함수의 구문은 다음과 같습니다.

function Function_Name (Parameters) return Return_Type is (Expression);

beginreturn 문이 사라지고, 괄호 ()로 둘러싸인 단일 표현식이 함수의 결과 값을 결정합니다.

예를 들어, 두 정수 중 더 큰 값을 반환하는 간단한 함수를 전통적인 방식과 표현식 함수 방식으로 비교해 보겠습니다.

전통적인 함수 선언:

function Max (A, B : in Integer) return Integer is
begin
   if A > B then
      return A;
   else
      return B;
   end if;
end Max;

표현식 함수 선언:

function Max (A, B : in Integer) return Integer is (if A > B then A else B);

두 번째 방식이 훨씬 간결하며, 이 함수가 조건부 표현식을 통해 값을 계산하여 반환하는 순수한 계산 장치임을 한눈에 파악할 수 있습니다. 표현식 함수를 사용하면 이처럼 불필요한 상용구(Boilerplate) 코드를 줄여 가독성을 크게 향상시킬 수 있습니다.

표현식 함수와 계약(Contracts)의 결합

표현식 함수는 전제조건(Precondition)이나 사후조건(Postcondition)과 같은 계약과도 자연스럽게 결합될 수 있습니다. 이는 함수의 선언부에 그 함수의 역할과 제약사항을 명시하는 선언적 프로그래밍 스타일을 더욱 강화합니다.

다음은 0으로 나누는 것을 방지하는 전제조건을 포함한 표현식 함수의 예시입니다.

-- 벡터의 길이를 계산하는 표현식 함수
function Length (V : in Vector_2D) return Float is (Sqrt (V.X**2 + V.Y**2));

-- 두 벡터의 내적을 계산하는 표현식 함수
function Dot_Product (A, B : in Vector_2D) return Float is (A.X * B.X + A.Y * B.Y);

-- 두 벡터 사이의 각도(라디안)를 계산하는 표현식 함수
-- 전제조건: 두 벡터의 길이가 0이 아니어야 함
function Angle_Between (A, B : in Vector_2D) return Float is
  (arccos (Dot_Product (A, B) / (Length (A) * Length (B))))
  with Pre => Length (A) /= 0.0 and then Length (B) /= 0.0;

이처럼 표현식 함수는 단순한 계산을 수행하는 작은 함수들을 정의할 때 매우 유용합니다. 이는 코드를 더 선언적이고 읽기 쉽게 만들어주며, 함수형 프로그래밍에서 지향하는 ‘함수는 계산을 위한 식’이라는 개념을 코드에 직접적으로 반영하는 효과적인 도구입니다.

15.4.2 조건부 표현식 (Conditional Expressions)

**조건부 표현식(Conditional Expression)**은 ifcase와 같은 조건 논리를 문장이 아닌 표현식의 일부로 포함시키는 기능입니다. 이를 통해 특정 조건에 따라 다른 값을 결과로 내놓는 표현식을 작성할 수 있습니다. 이는 조건에 따라 변수에 값을 대입하는 코드를 매우 간결하게 만들어 주며, 상수를 초기화할 때 특히 유용합니다.

If 표현식 (If Expressions)

if 표현식은 주어진 논리 조건(Boolean)에 따라 두 개 이상의 값 중 하나를 선택합니다. 그 구문은 다음과 같습니다.

(if Condition_1 then
    Expression_1
elsif Condition_2 then
    Expression_2
else
    Expression_N)

모든 분기(branch)의 표현식은 서로 같은 타입이거나, 공통된 상위 타입으로 변환될 수 있어야 합니다. if 표현식은 이전 Max 함수의 예시처럼, 간단한 조건에 따라 값을 결정하는 데 이상적입니다.

다른 예로, 점수에 따라 합격 여부를 결정하는 상수를 if 표현식으로 초기화할 수 있습니다. if 문장을 사용하면 상수를 초기화할 수 없지만, if 표현식을 사용하면 이것이 가능해집니다.

Score : constant Integer := Get_Score_From_Student (Id);

-- if 표현식을 사용하여 상수를 초기화
Pass_Status : constant String :=
  (if Score >= 60 then "Pass" else "Fail");

Case 표현식 (Case Expressions)

case 표현식은 특정 값(selector)에 따라 여러 선택지 중 하나에 해당하는 값을 결과로 내놓습니다. 이산적인 타입(정수, 열거형 등)의 값에 따라 다른 결과를 지정할 때 유용합니다. 구문은 다음과 같습니다.

(case Selector is
   when Choice_1 => Expression_1,
   when Choice_2 => Expression_2,
   ...
   when others   => Expression_N)

case 문장과 마찬가지로, Selector가 가질 수 있는 모든 가능한 값을 완벽하게 포함해야 합니다. 아래는 요일(열거형)에 따라 해당 날짜의 종류를 문자열로 반환하는 case 표현식의 예시입니다.

type Day_Of_Week is (Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday);

function Type_Of_Day (Day : in Day_Of_Week) return String is
  (case Day is
     when Monday .. Friday => "Weekday",
     when Saturday | Sunday => "Weekend");

위 함수는 if ... elsif ... else 구문을 길게 나열하는 것보다 훨씬 명료하고 가독성이 높습니다.

이처럼 조건부 표현식은 특정 조건에 따라 값을 결정하는 로직을 하나의 완전한 표현식으로 통합하여 코드의 선언적인 특성을 강화합니다. 이는 변수의 값을 결정하기 위해 여러 줄의 if 또는 case 문장을 사용하는 패턴을 대체하며, 특히 불변성을 지향하는 함수형 프로그래밍 스타일에서 상수나 단일 대입 변수를 초기화하는 데 매우 효과적인 도구입니다.

15.4.3 선언 표현식 (Declare Expressions)

때로는 하나의 복잡한 표현식 안에서 중간 계산 결과를 여러 번 재사용해야 하거나, 특정 하위 표현식에 의미 있는 이름을 부여하여 가독성을 높이고 싶을 때가 있습니다. **선언 표현식(Declare Expression)**은 이러한 요구사항을 충족시키기 위해 표현식 내부에 declare ... begin ... end 블록을 포함할 수 있도록 하는 기능입니다.

이 구문은 표현식의 일부로 지역 상수(Constant)나 객체(Object)를 선언할 수 있게 해줍니다. 이를 통해 복잡한 계산을 더 작은 단계로 나누고, 각 단계의 결과에 이름을 붙여 전체 표현식의 구조를 명확하게 만들 수 있습니다.

선언 표현식의 기본 구문은 다음과 같습니다.

(declare
   -- 이 곳에 지역 상수나 객체를 선언합니다.
   Local_Constant_Name : constant Type := Initial_Value;
begin
   -- 선언된 지역 상수를 사용하여 최종 값을 계산하는 표현식입니다.
   Expression_Using_Locals)

이 전체 블록은 begin ... end 사이의 최종 표현식이 평가된 값을 가지는 하나의 완전한 표현식으로 취급됩니다.

예를 들어, 두 점 AB 사이의 유클리드 거리를 계산하는 함수를 생각해 보겠습니다. 이 계산에는 x 좌표의 차이와 y 좌표의 차이를 제곱하여 더한 후 제곱근을 구하는 과정이 포함됩니다. 이 중간값들에 이름을 부여하면 코드가 훨씬 명확해집니다.

type Point is record
   X, Y : Float;
end record;

function Distance (A, B : in Point) return Float is
  (declare
     -- 1. 중간 계산 결과에 의미있는 이름을 부여합니다.
     Delta_X : constant Float := A.X - B.X;
     Delta_Y : constant Float := A.Y - B.Y;
  begin
     -- 2. 명명된 상수를 사용하여 최종 결과를 계산합니다.
     Sqrt (Delta_X**2 + Delta_Y**2))

만약 선언 표현식을 사용하지 않았다면, 함수는 다음과 같이 작성될 것입니다.

function Distance_Without_Declare (A, B : in Point) return Float is
  (Sqrt ((A.X - B.X)**2 + (A.Y - B.Y)**2))

두 번째 버전도 기능적으로는 동일하지만, 첫 번째 버전에 비해 가독성이 떨어지며, A.X - B.X와 같은 하위 표현식의 의미를 즉시 파악하기 어렵습니다. 또한, 만약 Delta_X와 같은 중간값이 표현식 내에서 여러 번 사용된다면, 선언 표현식은 해당 값을 한 번만 계산하도록 보장하여 잠재적인 성능 이점도 제공할 수 있습니다.

이처럼 선언 표현식은 다음과 같은 중요한 이점을 가집니다.

  • 가독성 향상: 복잡한 하위 표현식에 의미 있는 이름을 부여하여 코드의 의도를 명확하게 만듭니다.
  • 유지보수 용이성: 중간 계산 과정을 지역적으로 캡슐화하여 전체 표현식을 수정하거나 디버깅하기 쉽게 만듭니다.
  • 중복 계산 방지: 중간 결과를 상수에 저장하여 표현식 내에서 여러 번 재사용할 수 있습니다.

선언 표현식은 함수형 스타일에서 권장하는 ‘표현식 중심의 사고’를 지원하는 강력한 도구입니다. 이는 여러 단계의 계산을 하나의 표현식 안에 선언적으로 기술하면서도, 절차적 언어의 장점인 지역 변수를 통한 명료성을 유지할 수 있게 해주는 기능입니다.

15.5 고차(Higher-Order) 프로그래밍 구문

앞선 절들에서 우리는 함수를 일급 객체로 다루어 다른 서브프로그램에 매개변수로 전달하는 방법을 학습했습니다. 고차 프로그래밍(Higher-Order Programming)은 이러한 개념을 한 단계 더 발전시킨 것으로, 함수를 인자로 받거나 새로운 함수를 결과로 반환하는 고차 함수(Higher-Order Function)를 설계하고 활용하는 프로그래밍 패러다임을 의미합니다.

고차 함수는 동작(Action) 자체를 매개변수화하여 코드의 추상화 수준을 극적으로 높입니다. 예를 들어, 배열의 모든 요소를 순회하며 특정 작업을 수행하는 로직에서, ‘순회’라는 제어 구조와 ‘특정 작업’이라는 동작을 분리할 수 있습니다. 이를 통해 ‘순회’라는 공통된 패턴은 한 번만 작성하고, ‘특정 작업’에 해당하는 함수만 교체하여 다양한 기능을 구현하는 것이 가능해집니다. 이는 코드의 재사용성을 극대화하고, 많은 상용구 코드를 제거하여 핵심 로직에 더 집중할 수 있게 해줍니다.

이번 절에서는 Ada가 제공하는 강력한 기능들을 활용하여 이러한 고차 프로그래밍을 구현하는 구체적인 방법을 탐구합니다. 먼저, 제네릭(Generics)을 사용하여 Map, Filter와 같은 대표적인 고차 함수 패턴을 직접 설계하는 방법을 살펴볼 것입니다. 이어서, 배열이나 컨테이너의 모든 요소가 특정 조건을 만족하는지 검사하거나, 모든 요소를 하나의 값으로 집계하는 작업을 매우 간결하게 표현할 수 있도록 언어 차원에서 내장된 양자화 표현식(Quantified Expressions)감소 표현식(Reduction Expressions)에 대해 자세히 알아보겠습니다.

15.5.1 양자화 표현식 (Quantified Expressions)

**양자화 표현식(Quantified Expression)**은 배열이나 다른 컨테이너의 모든 요소 또는 일부 요소가 특정 조건을 만족하는지 여부를 검사하여 Boolean 값을 결과로 내놓는 강력하고 선언적인 표현식입니다. 이는 전통적인 루프(loop)와 if 문을 사용하여 플래그 변수를 설정하는 복잡한 코드를 단 한 줄의 표현식으로 대체할 수 있게 해줍니다.

양자화 표현식에는 두 가지 형태가 있습니다.

  1. for all (보편 양자화): 지정된 범위의 모든 요소가 주어진 조건을 만족하면 True를 반환합니다.
  2. for some (존재 양자화): 지정된 범위의 요소 중 하나라도 주어진 조건을 만족하면 True를 반환합니다.

이 표현식의 기본 구문은 다음과 같습니다.

(for {all | some} Element of Container => Predicate (Element))
  • Container: 검사할 대상이 되는 배열이나 컨테이너입니다.
  • Element: 순회 중인 각 요소를 가리키는 이름입니다.
  • Predicate: Element를 인자로 받아 Boolean 값을 반환하는 표현식입니다.

for all의 활용

for all은 “모든 요소가 ~인가?”라는 질문에 답하는 데 사용됩니다. 예를 들어, 주어진 정수 배열의 모든 요소가 짝수인지를 검사하는 코드는 다음과 같이 작성할 수 있습니다.

Numbers : constant array (1..5) of Integer := (2, 4, 6, 8, 10);

-- All_Even은 True 값을 가집니다.
All_Even : constant Boolean := (for all Value of Numbers => Value mod 2 = 0);

만약 Numbers 배열에 홀수가 하나라도 포함되어 있었다면, All_EvenFalse가 될 것입니다. 이는 루프를 돌며 조건이 거짓인 경우를 찾아 플래그를 내리는 기존 방식보다 훨씬 간결하고 의도가 명확합니다.

for some의 활용

for some은 “조건을 만족하는 요소가 하나라도 있는가?”라는 질문에 답하는 데 사용됩니다. 예를 들어, 사용자 계정 목록에 비활성화된 계정이 하나라도 포함되어 있는지 확인하는 코드는 다음과 같습니다.

type User_Account is record
   Name      : String (1..10);
   Is_Active : Boolean;
end record;

Accounts : constant array (1..3) of User_Account :=
  ((Name => "Alice     ", Is_Active => True),
   (Name => "Bob       ", Is_Active => False),
   (Name => "Charlie   ", Is_Active => True));

-- Has_Inactive_Account는 True 값을 가집니다.
Has_Inactive_Account : constant Boolean :=
  (for some Account of Accounts => not Account.Is_Active);

Accounts 배열에 Is_ActiveFalse인 “Bob” 계정이 존재하므로, Has_Inactive_AccountTrue가 됩니다. 만약 모든 계정이 활성 상태였다면 결과는 False일 것입니다.

양자화 표현식은 함수형 프로그래밍에서 데이터를 직접 조작하기보다는, 데이터에 대한 질문을 던지고 그 속성을 기술하는 선언적인 스타일을 완벽하게 지원합니다. 복잡한 루프 제어 로직을 언어 자체에 위임함으로써, 프로그래머는 “무엇을(What)” 검사하고 싶은지에만 집중할 수 있게 되어 코드의 신뢰성과 가독성을 크게 향상시킬 수 있습니다.

15.5.2 감소 표현식 (Reduction Expressions)

**감소 표현식(Reduction Expression)**은 배열의 모든 요소를 순회하며 단 하나의 값으로 집계(Aggregate)하거나 축약(Reduce)하는 강력한 기능입니다. 이는 다른 함수형 언어에서 fold 또는 reduce 연산으로 알려진 것과 동일한 개념으로, 배열의 모든 요소를 합산하거나, 최댓값을 찾거나, 모든 문자열을 하나로 연결하는 등의 작업을 매우 간결하게 표현할 수 있게 해줍니다.

감소 표현식은 배열 타입의 'Reduce 속성을 통해 사용되며, 두 가지 주요 요소인 **감소 함수(Reducer Function)**와 **초깃값(Initial Value)**을 인자로 받습니다.

기본 구문은 다음과 같습니다.

Array_Or_Slice'Reduce (Reducer_Function'Access, Initial_Value)
  • Reducer_Function: 두 개의 인자를 받는 함수입니다. 첫 번째 인자는 현재까지의 누적된 결과(Accumulator)이고, 두 번째 인자는 현재 처리 중인 배열 요소입니다. 이 함수는 두 인자를 사용하여 계산한 새로운 누적 값을 반환해야 합니다.
  • Initial_Value: 집계 과정이 시작될 때 사용되는 초깃값입니다.

아래는 정수 배열의 모든 요소의 합을 구하는 과정을 통해 감소 표현식의 동작을 보여주는 예시입니다.

type Integer_Array is array (Positive range <>) of Integer;

-- 1. 감소 함수(Reducer)를 정의합니다.
-- 누적값(Accumulator)과 현재 요소(Value)를 더해 새로운 누적값을 반환합니다.
function Sum_Reducer (Accumulator : in Integer; Value : in Integer) return Integer is
begin
   return Accumulator + Value;
end Sum_Reducer;

-- 메인 프로시저에서 활용 예시를 보여줍니다.
procedure Demonstrate_Reduction is
   Numbers : constant Integer_Array := (1, 2, 3, 4, 5);
   Total   : Integer;
begin
   -- 2. 'Reduce 속성을 사용하여 합계를 계산합니다.
   -- 초깃값으로 0을 사용합니다.
   Total := Numbers'Reduce (Sum_Reducer'Access, 0);

   Ada.Text_IO.Put_Line ("Total Sum: " & Integer'Image (Total));
   -- >> 출력: Total Sum: 15
end Demonstrate_Reduction;

위 예제의 'Reduce 속성은 내부적으로 다음과 같이 동작합니다.

  1. 누적 변수를 초깃값 0으로 설정합니다.
  2. 배열의 첫 번째 요소 1에 대해 Sum_Reducer(0, 1)을 호출하여 결과 1을 얻고, 이를 새로운 누적 값으로 합니다.
  3. 배열의 두 번째 요소 2에 대해 Sum_Reducer(1, 2)를 호출하여 결과 3을 얻고, 이를 새로운 누적 값으로 합니다.
  4. 이 과정을 배열의 마지막 요소까지 반복합니다.
  5. 최종 누적 값 15가 표현식의 최종 결과가 됩니다.

이처럼 감소 표현식은 합계뿐만 아니라 다양한 집계 작업에 활용될 수 있습니다. 예를 들어, 곱셈을 위한 감소 함수와 초깃값 1을 사용하면 모든 요소의 곱을 구할 수 있고, 비교 함수와 배열의 첫 번째 요소를 초깃값으로 사용하면 최댓값이나 최솟값을 찾을 수 있습니다.

감소 표현식은 for 루프와 상태 변수를 사용하여 수동으로 집계 로직을 작성하는 것보다 훨씬 선언적이고 안전합니다. “무엇을(What)” 할 것인지(예: 합산)에만 집중하게 하고, “어떻게(How)” 순회하고 집계할 것인지에 대한 복잡한 절차는 언어 자체에 위임합니다. 이는 코드의 의도를 명확히 드러내고 잠재적인 오류를 줄여주는 매우 효과적인 함수형 프로그래밍 도구입니다.

15.5.3 제네릭을 활용한 고차 함수 설계

지금까지 살펴본 양자화 표현식과 감소 표현식은 배열에 대한 강력한 내장 고차 연산을 제공합니다. 하지만 실무에서는 이보다 더 복잡하거나 특화된 고차 함수가 필요할 수 있습니다. 예를 들어, 배열의 모든 요소에 특정 함수를 적용하여 새로운 배열을 반환하는 Map 연산이나, 특정 조건에 맞는 요소만 걸러내는 Filter 연산이 대표적입니다.

이러한 범용적인 고차 함수를 직접 설계할 때, Ada의 제네릭(Generics) 기능은 필수적인 역할을 합니다. 제네릭을 사용하면 특정 타입에 종속되지 않고, 다양한 데이터 타입과 서브프로그램에 대해 동작할 수 있는 유연하고 재사용 가능한 함수나 패키지를 작성할 수 있습니다.

이번 절에서는 제네릭을 활용하여 가장 대표적인 고차 함수인 MapFilter를 직접 설계하는 방법을 통해, Ada에서 고차 프로그래밍을 구현하는 일반적인 패턴을 학습하겠습니다.

Map 함수 설계

Map 함수는 원본 배열의 모든 요소에 주어진 변환 함수(Transformer)를 각각 적용하여, 그 결과들로 구성된 새로운 배열을 반환하는 함수입니다.

-- 제네릭 선언부
generic
   type Source_Type is private;  -- 원본 요소 타입
   type Target_Type is private;  -- 결과 요소 타입
   type Source_Array_Type is array (Positive range <>) of Source_Type;
   type Target_Array_Type is array (Positive range <>) of Target_Type;
   with function Transform (Item : in Source_Type) return Target_Type is <>;
function Generic_Map (Source : in Source_Array_Type) return Target_Array_Type;

-- 제네릭 구현부
function Generic_Map (Source : in Source_Array_Type) return Target_Array_Type is
   Result : Target_Array_Type (Source'Range);
begin
   for I in Source'Range loop
      Result (I) := Transform (Source (I));
   end loop;
   return Result;
end Generic_Map;

Generic_Map 함수는 제네릭을 통해 원본/결과 타입 및 배열 타입을 매개변수화하고, Transform이라는 변환 함수를 제네릭 인자로 받습니다. 이제 이 제네릭 함수를 **인스턴스화(Instantiation)**하여 다양한 종류의 Map 함수를 생성할 수 있습니다.

-- 정수를 문자열로 변환하는 Map 함수 인스턴스화
function Integer_To_String_Map is new Generic_Map
  (Source_Type       => Integer,
   Target_Type       => String,
   Source_Array_Type => Integer_Array, -- 이전에 선언된 타입
   Target_Array_Type => String_Array,  -- 이전에 선언된 타입
   Transform         => Integer'Image); -- 변환 함수로 'Image 속성 사용

Filter 함수 설계

Filter 함수는 원본 배열의 요소 중 주어진 조건 함수(Predicate)를 만족하는(True를 반환하는) 요소들만 모아 새로운 배열을 반환합니다. 결과 배열의 크기는 원본보다 작거나 같을 수 있으므로, 결과 타입으로는 제약이 없는(Unconstrained) 배열을 사용하는 것이 일반적입니다.

-- 제네릭 선언부
generic
   type Element_Type is private;
   type Array_Type is array (Positive range <>) of Element_Type;
   with function Predicate (Item : in Element_Type) return Boolean is <>;
function Generic_Filter (Source : in Array_Type) return Array_Type;

-- 제네릭 구현부
function Generic_Filter (Source : in Array_Type) return Array_Type is
   -- 임시 저장을 위해 Vector 사용
   use Ada.Containers.Vectors;
   Result_Vector : Vector (Index_Type => Positive, Element_Type => Element_Type);
begin
   for Item of Source loop
      if Predicate (Item) then
         Result_Vector.Append (Item);
      end if;
   end loop;
   return To_Array (Result_Vector); -- Vector를 Array로 변환 (별도 함수 필요)
end Generic_Filter;

이처럼 제네릭은 특정 알고리즘(Map, Filter 등)의 구조는 유지하면서, 그 알고리즘이 다루는 데이터의 타입과 세부 동작을 외부에서 주입할 수 있게 해줍니다. 이는 함수형 프로그래밍에서 추구하는 코드의 재사용성과 조합성을 달성하기 위한 핵심적인 도구입니다. 제네릭을 통해 잘 설계된 고차 함수 라이브러리는 프로젝트의 생산성과 코드의 품질을 크게 향상시킬 수 있습니다.

15.6 함수형 접근법과 병렬 처리

지금까지 우리는 함수형 프로그래밍의 다양한 개념과 기법들이 어떻게 코드의 명확성과 재사용성을 높이는지 살펴보았습니다. 이번 절에서는 함수형 프로그래밍의 가장 중요한 실용적 이점 중 하나인 병렬 처리(Parallel Processing)와의 관계에 대해 탐구하겠습니다.

현대의 멀티코어 프로세서 환경에서는 프로그램의 성능을 극대화하기 위해 병렬 처리가 필수적입니다. 하지만 여러 스레드나 태스크가 공유된 데이터를 동시에 수정하려는 명령형 프로그래밍 스타일에서는 경쟁 조건(Race Condition)이나 교착 상태(Deadlock)와 같은 복잡한 동시성 문제들이 빈번하게 발생합니다.

함수형 프로그래밍의 핵심 원칙인 불변성(Immutability)순수 함수(Pure Function)는 이러한 문제에 대한 효과적인 해법을 제시합니다. 데이터가 변경되지 않고, 함수가 외부에 부작용을 일으키지 않는다면, 여러 태스크가 동일한 함수를 동시에 실행하더라도 서로에게 아무런 영향을 미치지 않습니다. 이는 복잡한 잠금(Locking) 메커니즘 없이도 코드의 병렬 실행을 안전하게 보장할 수 있음을 의미합니다.

이번 절에서는 이러한 함수형 접근법의 이론적 이점이 Ada의 강력한 동시성 기능과 결합하여 실제로 어떻게 안전하고 효율적인 병렬 코드를 작성하게 하는지 구체적으로 살펴볼 것입니다. 데이터 병렬화를 위한 병렬 루프 및 블록 구문과, 여러 데이터를 안전하게 병렬로 집계하는 감소 표현식의 활용법에 대해 자세히 알아보겠습니다.

15.6.1 부작용 없는 코드의 병렬화 이점

병렬 프로그래밍의 가장 근본적인 난제는 여러 실행 흐름(태스크 또는 스레드)이 공유된 가변 상태(Shared Mutable State)에 동시에 접근할 때 발생합니다. 하나의 태스크가 데이터를 수정하는 동안 다른 태스크가 그 데이터를 읽거나 수정하려고 할 때, 데이터의 일관성이 깨지거나 예측 불가능한 결과가 발생할 수 있습니다. 이러한 문제를 해결하기 위해 전통적인 명령형 프로그래밍에서는 뮤텍스(Mutex), 세마포어(Semaphore)와 같은 복잡한 잠금(Locking) 메커니즘을 사용해야 합니다.

그러나 잠금 메커니즘은 그 자체로 또 다른 문제를 야기합니다.

  • 복잡성 증가: 코드의 어느 부분에서 잠금을 획득하고 해제해야 하는지 정확히 관리하는 것은 매우 어렵고 오류가 발생하기 쉽습니다.
  • 성능 저하: 여러 태스크가 동일한 잠금을 얻기 위해 경쟁하게 되면, 대기 시간이 발생하여 병렬 처리의 이점이 상쇄될 수 있습니다.
  • 교착 상태(Deadlock): 두 개 이상의 태스크가 서로가 점유한 자원을 기다리며 무한 대기 상태에 빠지는 교착 상태가 발생할 위험이 있습니다.

함수형 프로그래밍의 핵심 원칙인 부작용 없는 순수 함수는 이러한 문제들에 대한 근본적인 해법을 제시합니다. 순수 함수는 정의에 따라 외부 상태를 변경하지 않으며, 오직 입력에만 의존하여 결과를 반환합니다. 이는 다음과 같은 중요한 병렬화 이점으로 직접 이어집니다.

1. 데이터 경쟁(Data Race)의 원천적 부재

순수 함수는 공유된 데이터를 수정하지 않으므로, 여러 태스크가 동일한 데이터를 동시에 ‘읽는’ 것은 전혀 문제가 되지 않습니다. 데이터가 불변(Immutable)하기 때문에, 어느 태스크도 데이터를 변경하지 않으므로 데이터 경쟁 자체가 발생할 수 없습니다. 이는 잠금 메커니즘의 필요성을 상당 부분 제거하여 코드를 훨씬 단순하고 안전하게 만듭니다.

2. 자동 병렬화의 용이성

참조 투명성(Referential Transparency)을 가진 순수 함수들의 집합은 실행 순서에 크게 의존하지 않습니다. 예를 들어, 거대한 데이터 배열의 각 요소에 동일한 순수 함수를 적용하는 작업(Map 연산)은 매우 쉽게 병렬화될 수 있습니다. 각 요소에 대한 함수 적용은 완전히 독립적이기 때문에, 컴파일러나 런타임 시스템은 이 작업을 여러 코어에 자동으로 분배하여 처리할 수 있습니다. 각 태스크는 다른 태스크의 작업에 전혀 신경 쓸 필요가 없습니다.

3. 결정론적 실행(Deterministic Execution)

부작용이 없는 코드는 동일한 입력에 대해 항상 동일한 결과를 생성합니다. 이는 병렬 실행 환경에서도 마찬가지입니다. 태스크 스케줄링 순서나 실행 타이밍에 따라 결과가 달라지는 비결정론적(Non-deterministic) 버그가 발생할 여지가 크게 줄어듭니다. 이는 프로그램의 동작을 예측하고 디버깅하는 것을 훨씬 쉽게 만듭니다.

결론적으로, 부작용 없는 코드는 병렬 처리를 위한 ‘이상적인 재료’와 같습니다. 복잡한 동기화 문제로부터 프로그래머를 해방시켜 주며, 병렬화로 인한 성능 향상 효과를 온전히 누릴 수 있도록 돕습니다. 다음 절에서는 이러한 이점을 활용하여 Ada에서 실제로 병렬 코드를 작성하는 구체적인 구문들을 살펴보겠습니다.

15.6.2 데이터 병렬화를 위한 병렬 루프 및 블록

함수형 접근법의 병렬화 이점을 Ada에서 실제로 구현할 수 있도록, 언어 차원에서는 고수준의 병렬 처리 구문을 제공합니다. 그중 가장 대표적인 것이 **병렬 루프(Parallel Loop)**와 **병렬 블록(Parallel Block)**입니다. 이 구문들은 개발자가 직접 태스크를 생성하고 동기화를 관리하는 복잡함 없이, 일반적인 병렬화 패턴을 간결하고 안전하게 적용할 수 있도록 돕습니다.

병렬 for 루프 (Parallel for Loops)

병렬 for 루프는 **데이터 병렬화(Data Parallelism)**를 위한 구문입니다. 이는 동일한 독립적인 연산을 거대한 데이터 묶음(주로 배열)의 모든 요소에 병렬로 적용하는 상황에 사용됩니다.

구문은 일반 for 루프에 parallel 키워드를 추가한 형태입니다.

for Element of Data loop          -- 순차 실행
for parallel Element of Data loop -- 병렬 실행

Ada 런타임 시스템은 루프의 각 반복(Iteration)을 내부적인 스레드 풀(Thread Pool)에 있는 여러 태스크에 자동으로 분배하여 동시에 실행합니다. 이때 가장 중요한 제약 조건은 각 반복이 서로 독립적이어야 한다는 것입니다. 즉, 한 반복의 실행이 다른 반복의 결과에 영향을 주거나 의존해서는 안 됩니다. 이는 순수 함수나 부작용 없는 프로시저를 호출하는 경우에 자연스럽게 만족됩니다.

아래는 큰 이미지의 모든 픽셀을 흑백으로 변환하는 작업을 병렬 루프로 처리하는 예시입니다.

type Pixel is record R, G, B : Natural; end record;
type Image_Data is array (Positive range <>, Positive range <>) of Pixel;

-- 단일 픽셀을 흑백으로 변환하는 부작용 없는 프로시저
procedure Convert_To_Grayscale (P : in out Pixel) is
   Grayscale : constant Natural := (P.R + P.G + P.B) / 3;
begin
   P.R := Grayscale;
   P.G := Grayscale;
   P.B := Grayscale;
end Convert_To_Grayscale;

procedure Process_Image_In_Parallel (Image : in out Image_Data) is
begin
   -- Image의 각 행(Row)을 병렬로 처리합니다.
   for parallel Row of Image loop
      -- 각 행 내부의 픽셀들은 순차적으로 처리될 수 있습니다.
      for P of Row loop
         Convert_To_Grayscale (P);
      end loop;
   end loop;
end Process_Image_In_Parallel;

위 예시에서 각 행을 변환하는 작업은 다른 행의 작업과 완전히 독립적이므로, for parallel 루프를 사용하여 안전하고 효율적으로 병렬화할 수 있습니다.

병렬 do 블록 (Parallel do Blocks)

병렬 do 블록은 **태스크 병렬화(Task Parallelism)**를 위한 구문입니다. 이는 서로 연관 없는 여러 개의 독립적인 작업을 동시에 실행하고자 할 때 사용됩니다.

parallel do 구문은 do ... end do 블록 안에 &로 구분된 여러 개의 프로시저 호출을 포함합니다.

parallel do
   Independent_Task_A;
   &
   Independent_Task_B;
   &
   Independent_Task_C;
end do;

Ada 런타임은 Independent_Task_A, B, C를 각각 별도의 태스크에서 동시에 실행하고, 세 가지 작업이 모두 완료될 때까지 기다립니다.

예를 들어, 시스템 초기화 시 네트워크 연결, 데이터베이스 로딩, 설정 파일 파싱 작업을 동시에 수행하는 코드는 다음과 같이 작성할 수 있습니다.

procedure Initialize_System is
begin
   parallel do
      Connect_To_Network_Service;  -- 작업 A
      &
      Load_Database_Cache;       -- 작업 B
      &
      Parse_Configuration_File;  -- 작업 C
   end do;

   -- 세 작업이 모두 완료된 후에 이어서 실행됩니다.
   Ada.Text_IO.Put_Line ("System initialization complete.");
end Initialize_System;

이러한 고수준 병렬 구문들은 개발자가 저수준의 동기화 문제에 신경 쓰지 않고, “어떤 작업을 병렬로 실행할 것인가”라는 로직의 본질에만 집중할 수 있게 해줍니다. 특히 부작용 없는 함수형 스타일과 결합될 때, 이 구문들은 최소한의 노력으로 멀티코어 환경의 성능을 최대한 활용하는 매우 효과적인 도구가 됩니다.

15.6.3 병렬 집계를 위한 감소 표현식 활용

앞서 14.5.2절에서 학습한 감소 표현식('Reduce)은 배열의 모든 요소를 순차적으로 순회하며 하나의 값으로 집계하는 기능입니다. 그렇다면 이러한 집계 과정 자체도 병렬로 처리하여 성능을 더욱 높일 수는 없을까요?

Ada는 **결합 법칙(Associative Law)**을 만족하는 연산에 한해, 이러한 집계 과정을 안전하고 효율적으로 병렬화할 수 있는 'Parallel_Reduce 속성을 제공합니다. 결합 법칙이란 연산의 순서가 결과에 영향을 주지 않는 성질을 의미합니다. 예를 들어, 덧셈 연산은 (a + b) + ca + (b + c)의 결과가 같으므로 결합 법칙을 만족합니다.

병렬 집계는 내부적으로 다음과 같이 동작합니다.

  1. 전체 배열을 여러 개의 작은 조각(Chunk)으로 나눕니다.
  2. 각 조각을 별도의 태스크에 할당하여, 해당 조각에 대한 부분적인 집계(Sub-total)를 동시에 계산합니다.
  3. 모든 태스크가 부분 집계를 완료하면, 각 태스크로부터 얻은 중간 결과들을 다시 하나로 모아 최종 결과를 계산합니다.

이러한 방식이 올바른 결과를 내기 위해서는, 각 태스크가 계산한 중간 결과들을 어떤 순서로 합치더라도 최종 결과가 동일해야 합니다. 바로 이 지점에서 결합 법칙이 필수적인 전제 조건이 됩니다.

'Parallel_Reduce 속성은 두 개의 함수를 인자로 받습니다.

  • Reducer: 각 병렬 태스크 내부에서, 할당된 작은 조각의 요소들을 집계하는 함수입니다. 이는 순차적인 'Reduce에서 사용하던 함수와 동일합니다.
  • Combiner: 각 태스크가 계산을 마친 중간 결과들을 다시 하나로 합치는 함수입니다. 덧셈이나 곱셈처럼 결합 법칙이 성립하는 단순한 연산의 경우, Combiner 함수는 Reducer 함수와 동일한 경우가 많습니다.

아래는 정수 배열의 합계를 병렬로 계산하는 예시입니다.

type Integer_Array is array (Positive range <>) of Integer;

-- Reducer와 Combiner로 사용될 함수 (덧셈은 결합 법칙을 만족하므로 동일 함수 사용)
function Sum (Left, Right : in Integer) return Integer is (Left + Right);

procedure Demonstrate_Parallel_Reduction is
   -- 매우 큰 데이터 배열을 가정합니다.
   Large_Numbers : constant Integer_Array := (1 .. 1_000_000 => 1);
   Total         : Integer;
begin
   -- 'Parallel_Reduce 속성을 사용하여 합계를 병렬로 계산합니다.
   Total := Large_Numbers'Parallel_Reduce (Reducer'Access  => Sum'Access,
                                            Combiner'Access => Sum'Access,
                                            Initial_Value   => 0);

   Ada.Text_IO.Put_Line ("Parallel Sum Total: " & Integer'Image (Total));
end Demonstrate_Parallel_Reduction;

위 코드가 실행될 때, Ada 런타임 시스템은 Large_Numbers 배열을 시스템의 코어 수에 맞춰 여러 조각으로 나눕니다. 예를 들어 4코어 시스템이라면, 배열을 4개의 조각으로 나누고 각 태스크가 Sum 함수를 Reducer로 사용하여 자신의 조각에 대한 부분 합계를 계산합니다. 그 후, 4개의 태스크로부터 얻은 4개의 부분 합계를 다시 Sum 함수를 Combiner로 사용하여 최종 합계로 집계합니다.

'Parallel_Reduce 속성은 개발자가 저수준의 데이터 분할, 태스크 관리, 중간 결과 동기화와 같은 복잡한 과정을 전혀 신경 쓰지 않게 해줍니다. 개발자는 단지 연산의 논리(Reducer와 Combiner)만 제공하면, 런타임이 알아서 최적의 병렬화를 수행합니다.

이는 부작용 없는 함수형 연산이 어떻게 하드웨어의 성능을 최대한 활용하는 선언적인 병렬 프로그래밍으로 자연스럽게 이어지는지를 보여주는 가장 강력한 사례 중 하나입니다.

15.7 실용 예제: 함수형 스타일의 문제 해결

지금까지 우리는 함수형 프로그래밍의 이론적 기반과 이를 Ada 언어에서 지원하는 다양한 구문(표현식, 고차 함수, 병렬 처리 구문 등)에 대해 개별적으로 학습하였습니다. 이론과 개별 기능에 대한 이해도 중요하지만, 진정한 학습은 이러한 요소들을 조합하여 실제 문제를 해결하는 과정에서 완성됩니다.

이번 절에서는 지금까지 배운 개념들을 종합적으로 활용하여, 함수형 프로그래밍 스타일로 구체적인 문제들을 해결하는 실용적인 예제를 다룰 것입니다. 목표는 단순히 코드 예시를 나열하는 것을 넘어, 문제에 접근하고 해결책을 구성해 나가는 ‘함수형적인 사고방식’을 독자 여러분께서 체득하도록 돕는 데 있습니다.

이를 위해, 여러 개의 작은 함수들을 파이프라인처럼 연결하여 데이터를 변환하고 필터링하는 데이터 처리 예제를 살펴보고, 이어서 상태 변화나 루프 없이 재귀(Recursion)를 사용하여 문제를 우아하게 해결하는 고전적인 알고리즘 예제를 구현해 보겠습니다. 이 예제들을 통해 함수형 스타일이 어떻게 코드의 명료성과 모듈성을 높이는지 직접 확인하실 수 있을 것입니다.

15.7.1 데이터 처리 파이프라인 구축

함수형 프로그래밍의 가장 강력한 활용 분야 중 하나는 복잡한 데이터 처리 작업을 여러 개의 작고 독립적인 단계로 나누어 조합하는 것입니다. 각 단계는 이전 단계로부터 데이터를 입력받아 특정 변환이나 필터링을 수행한 후, 그 결과를 다음 단계로 전달합니다. 이러한 일련의 흐름을 **데이터 처리 파이프라인(Data Processing Pipeline)**이라고 부릅니다.

이번 절에서는 구체적인 예제를 통해 이러한 파이프라인을 구축하는 방법을 살펴보겠습니다.

문제 정의

온라인 상점의 판매 거래 기록 배열이 있다고 가정해 보겠습니다. 우리의 목표는 다음 조건을 모두 만족하는 거래들의 총매출액을 계산하는 것입니다.

  1. 올해(2025년)에 발생한 거래여야 합니다.
  2. 유효한(Active) 거래여야 합니다.
  3. 거래 금액이 1,000,000원 이상인 ‘고액’ 거래여야 합니다.

데이터 구조 정의

먼저, 단일 거래를 나타내는 레코드 타입을 정의합니다.

with Ada.Calendar; use Ada.Calendar;

type Sales_Record is record
   Transaction_Id : Natural;
   Amount         : Long_Float; -- 거래 금액
   Timestamp      : Time;       -- 거래 시각
   Is_Active      : Boolean;    -- 유효 거래 여부
end record;

type Sales_History is array (Positive range <>) of Sales_Record;

전통적인 명령형 접근법

전통적인 명령형 스타일에서는 보통 하나의 for 루프와 여러 개의 중첩된 if 문, 그리고 합계를 누적할 가변 변수를 사용하여 이 문제를 해결합니다.

function Calculate_Revenue_Imperative (History : in Sales_History) return Long_Float is
   Total_Revenue : Long_Float := 0.0;
begin
   for R of History loop
      if Year (R.Timestamp) = 2025 then
         if R.Is_Active then
            if R.Amount >= 1_000_000.0 then
               Total_Revenue := Total_Revenue + R.Amount;
            end if;
         end if;
      end if;
   end loop;
   return Total_Revenue;
end Calculate_Revenue_Imperative;

이 코드는 기능적으로는 정확하지만, 모든 조건 로직이 하나의 루프 안에 얽혀 있어 가독성이 떨어지고, 새로운 조건을 추가하거나 기존 조건을 변경하기가 어렵습니다.

함수형 파이프라인 접근법

함수형 스타일에서는 문제의 각 단계를 별도의 순수 함수(Predicate)로 분리합니다.

-- 1. 각 단계를 위한 조건 함수(Predicate)들을 정의합니다.
function Is_In_Current_Year (R : in Sales_Record) return Boolean is
  (Year (R.Timestamp) = 2025);

function Is_Active_Record (R : in Sales_Record) return Boolean is
  (R.Is_Active);

function Is_High_Value (R : in Sales_Record) return Boolean is
  (R.Amount >= 1_000_000.0);

-- 2. 최종 합계를 위한 감소 함수를 정의합니다.
function Sum_Amount (Accumulator : in Long_Float; R : in Sales_Record) return Long_Float is
  (Accumulator + R.Amount);

이제 이 작은 함수들을 FilterReduce 같은 고차 함수(14.5.3절에서 설계한 제네릭 함수들을 인스턴스화했다고 가정)를 통해 파이프라인처럼 연결합니다.

function Calculate_Revenue_Functional (History : in Sales_History) return Long_Float is
   -- 각 필터링 함수에 대한 인스턴스를 생성합니다 (가정).
   function Filter_By_Year is new Generic_Filter (Element_Type => Sales_Record, ..., Predicate => Is_In_Current_Year);
   function Filter_By_Active is new Generic_Filter (..., Predicate => Is_Active_Record);
   function Filter_By_Value is new Generic_Filter (..., Predicate => Is_High_Value);
   function Reduce_To_Sum is new Generic_Reduce (..., Reducer => Sum_Amount);
begin
   -- 3. 함수들을 파이프라인처럼 연결하여 데이터의 흐름을 기술합니다.
   return Reduce_To_Sum
     (Source        => Filter_By_Value
       (Source      => Filter_By_Active
         (Source    => Filter_By_Year (Source => History))),
      Initial_Value => 0.0);
end Calculate_Revenue_Functional;

함수형 접근법의 코드는 “거래 기록(History)을 받아서, 연도별로 필터링하고, 그 결과를 다시 활성 상태로 필터링하고, 그 결과를 다시 고액 기준으로 필터링한 후, 남은 것들을 합산하라”는 문제의 기술(Description)과 거의 일치합니다.

이러한 파이프라인 방식의 이점은 명확합니다.

  • 가독성과 명료성: 코드의 구조가 문제 해결의 논리적 단계를 그대로 반영합니다.
  • 재사용성과 조합성: 각 조건 함수는 독립적이므로 다른 곳에서 재사용할 수 있으며, Filter_By_ValueFilter_By_Active의 순서를 바꾸는 등 파이프라인의 단계를 쉽게 재조합할 수 있습니다.
  • 테스트 용이성: 각 조건 함수와 파이프라인의 각 단계를 개별적으로 테스트할 수 있어 신뢰성이 높아집니다.

이처럼 데이터 처리 파이프라인은 복잡한 데이터 처리 로직을 관리하기 쉽고, 유연하며, 재사용 가능한 작은 단위들의 조합으로 구성하는 함수형 프로그래밍의 강력함을 보여주는 대표적인 사례입니다.

15.7.2 재귀를 통한 상태 없는 알고리즘 구현

명령형 프로그래밍에서 반복(Iteration)은 주로 forwhile과 같은 루프 구문을 통해 이루어집니다. 이러한 루프는 루프 카운터나 결과 누적 변수와 같이, 반복 과정에서 계속해서 상태가 변경되는 **가변 변수(Mutable Variable)**에 의존하는 경우가 많습니다.

함수형 프로그래밍에서는 이러한 가변 상태를 피하기 위해, 반복적인 작업을 **재귀(Recursion)**를 사용하여 구현하는 것을 선호합니다. 재귀란 함수가 자기 자신을 다시 호출하는 기법으로, 이를 통해 상태 변경 없이 반복적인 계산을 수행할 수 있습니다. 상태는 변경되는 변수를 통해 관리되는 대신, 각 재귀 호출에 새로운 인자(Argument)를 전달하는 방식으로 다음 단계로 이어집니다.

재귀적 팩토리얼 계산 예제

팩토리얼(Factorial)을 계산하는 함수를 통해, 상태를 사용하는 루프 방식과 상태 없는 재귀 방식을 비교해 보겠습니다.

1. 상태를 사용하는 루프 방식

function Factorial_Imperative (N : in Positive) return Positive is
   Result : Positive := 1; -- 결과를 누적할 가변 변수
begin
   for I in 2 .. N loop
      Result := Result * I; -- 매 반복마다 Result의 상태가 변경됨
   end loop;
   return Result;
end Factorial_Imperative;

위 코드는 Result라는 가변 변수를 사용하여 결과를 누적합니다. 이 변수의 상태는 루프가 반복될 때마다 계속해서 변경됩니다.

2. 상태 없는 재귀 방식

function Factorial_Recursive (N : in Natural) return Positive is
begin
   if N = 0 then
      return 1; -- 재귀의 종료 조건 (Base Case)
   else
      -- 자기 자신을 더 작은 문제로 호출하고, 그 결과와 현재 값을 조합
      return N * Factorial_Recursive (N - 1);
   end if;
end Factorial_Recursive;

이 재귀 함수에는 상태를 저장하는 변수가 없습니다. “상태”는 N이라는 매개변수를 통해 각 함수 호출로 전달됩니다. Factorial_Recursive(5)5 * Factorial_Recursive(4)를 계산하는 식으로 문제가 더 작은 단위로 분해되며, 각 단계는 이전 단계의 계산이 완료되기를 기다립니다.

꼬리 재귀 최적화 (Tail Call Optimization)

단순 재귀는 함수 호출이 깊어질 경우 스택 오버플로(Stack Overflow)가 발생할 수 있는 단점이 있습니다. 함수가 반환되기 전에 다른 함수(자기 자신)를 호출하고 그 결과를 기다려야 하기 때문에, 호출 스택이 계속해서 쌓이기 때문입니다.

이 문제를 해결하기 위해 꼬리 재귀(Tail Recursion) 기법을 사용합니다. 꼬리 재귀란, 재귀 호출이 함수의 가장 마지막 작업이 되는 형태를 말합니다. 즉, 재귀 호출의 반환 값을 가지고 추가적인 계산을 하지 않습니다. 현대적인 컴파일러는 이러한 꼬리 재귀 호출을 내부적으로 루프 구문으로 최적화하여(꼬리 호출 최적화, TCO), 스택이 불필요하게 쌓이는 것을 방지합니다.

팩토리얼 함수를 꼬리 재귀 형태로 수정하려면, 누적된 결과를 다음 재귀 호출에 매개변수로 직접 전달하는 도우미(Helper) 함수를 사용하는 것이 일반적입니다.

function Factorial_Tail_Recursive (N : in Natural) return Positive is
   -- 누적기(Accumulator)를 포함하는 내부 도우미 함수
   function Helper (Current_N : in Natural; Accumulator : in Positive) return Positive is
   begin
      if Current_N = 0 then
         return Accumulator; -- 종료 조건: 최종 누적 결과를 반환
      else
         -- 재귀 호출이 함수의 마지막 작업임 (꼬리 호출)
         return Helper (Current_N - 1, Accumulator * Current_N);
      end if;
   end Helper;
begin
   return Helper (N, 1); -- 누적기 초깃값 1로 시작
end Factorial_Tail_Recursive;

Helper 함수에서 재귀 호출 Helper(...)는 반환 값을 가지고 추가적인 연산(예: 곱셈)을 하지 않고, 그 자체가 바로 최종 반환 값이 됩니다. 이 때문에 컴파일러는 이 함수를 스택을 사용하지 않는 효율적인 코드로 변환할 수 있습니다.

이처럼 재귀, 특히 꼬리 재귀는 가변 상태에 의존하지 않고 반복적인 알고리즘을 구현하는 함수형 프로그래밍의 핵심적인 기법입니다. 이는 알고리즘의 각 단계를 외부 상태와 격리된 순수 함수 호출로 만들어, 코드의 논리를 더 명확하게 하고 검증을 용이하게 만듭니다.

15.7.3 컨테이너 라이브러리와의 결합

지금까지의 예제들은 주로 크기가 고정된 배열(Array)을 중심으로 함수형 기법을 다루었습니다. 하지만 실제 응용 프로그램에서는 데이터의 수가 동적으로 변하는 경우가 많으며, 이때는 Ada 표준 라이브러리의 **컨테이너(Ada.Containers)**가 매우 효과적인 해결책을 제공합니다.

이번 절에서는 함수형 기법의 진정한 힘이 컨테이너 라이브러리와 결합될 때 어떻게 발현되는지 살펴보겠습니다. 특히, 가변 크기 배열의 역할을 하는 **벡터(Vector)**를 사용하여, FilterMap 연산을 조합하는 데이터 처리 파이프라인을 구현해 보겠습니다.

문제 정의

직원 정보(Employee)를 담고 있는 벡터가 주어졌을 때, 다음 두 단계를 거친 최종 보고서를 생성하고자 합니다.

  1. Filter: ‘엔지니어(Engineer)’ 직책을 가진 직원들만 추려냅니다.
  2. Map: 필터링된 직원들의 이름(Name)만 추출하여, 문자열 벡터로 변환합니다.

데이터 및 컨테이너 타입 정의

with Ada.Strings.Unbounded; use Ada.Strings.Unbounded;
with Ada.Containers.Vectors;

type Position_Type is (Manager, Engineer, Designer);

type Employee is record
   Name     : Unbounded_String;
   Position : Position_Type;
end record;

-- 직원 정보를 담을 벡터 패키지를 인스턴스화합니다.
package Employee_Vectors is new Ada.Containers.Vectors
  (Index_Type => Positive, Element_Type => Employee);

-- 최종 결과(이름 목록)를 담을 벡터 패키지를 인스턴스화합니다.
package String_Vectors is new Ada.Containers.Vectors
  (Index_Type => Positive, Element_Type => Unbounded_String);

컨테이너를 위한 고차 함수 설계

14.5.3절에서 설계한 제네릭 함수들을 컨테이너와 함께 사용하려면, 컨테이너가 제공하는 **이터레이터(Iterator)**를 활용하도록 수정해야 합니다. 이터레이터는 컨테이너의 내부 구조에 상관없이 요소를 순차적으로 순회할 수 있게 해주는 표준화된 인터페이스입니다.

아래는 Vector를 위한 FilterMap 제네릭 함수입니다.

-- 제네릭 Vector Filter
generic
   with package Source_Container is new Ada.Containers.Vectors (<>);
   with function Predicate (Item : in Source_Container.Element_Type) return Boolean;
function Generic_Vector_Filter
  (Source : in Source_Container.Vector) return Source_Container.Vector;

function Generic_Vector_Filter ... is
   Result : Source_Container.Vector;
begin
   for Item of Source loop -- Vector의 for-of 루프는 이터레이터를 사용합니다.
      if Predicate (Item) then
         Result.Append (Item);
      end if;
   end loop;
   return Result;
end Generic_Vector_Filter;

-- 제네릭 Vector Map
generic
   with package Source_Container is new Ada.Containers.Vectors (<>);
   with package Target_Container is new Ada.Containers.Vectors (<>);
   with function Transform
     (Item : in Source_Container.Element_Type) return Target_Container.Element_Type;
function Generic_Vector_Map
  (Source : in Source_Container.Vector) return Target_Container.Vector;

function Generic_Vector_Map ... is
   Result : Target_Container.Vector;
begin
   for Item of Source loop
      Result.Append (Transform (Item));
   end loop;
   return Result;
end Generic_Vector_Map;

파이프라인 구축 및 실행

이제 필요한 함수들을 인스턴스화하고 파이프라인으로 연결합니다.

procedure Demonstrate_Container_Pipeline is
   use Employee_Vectors, String_Vectors;

   -- 1. 조건 및 변환 함수 정의
   function Is_Engineer (E : in Employee) return Boolean is (E.Position = Engineer);
   function Get_Name (E : in Employee) return Unbounded_String is (E.Name);

   -- 2. 제네릭 함수들을 구체적인 타입으로 인스턴스화
   function Filter_Engineers is new Generic_Vector_Filter
     (Source_Container => Employee_Vectors, Predicate => Is_Engineer);

   function Map_To_Names is new Generic_Vector_Map
     (Source_Container => Employee_Vectors,
      Target_Container => String_Vectors,
      Transform        => Get_Name);

   -- 3. 초기 데이터 준비
   All_Employees : Vector;
begin
   All_Employees.Append ((To_Unbounded_String ("Alice"), Manager));
   All_Employees.Append ((To_Unbounded_String ("Bob"), Engineer));
   All_Employees.Append ((To_Unbounded_String ("Charlie"), Engineer));

   -- 4. 파이프라인 실행
   declare
      Engineers     : constant Vector := Filter_Engineers (All_Employees);
      Engineer_Names : constant Vector := Map_To_Names (Engineers);
   begin
      -- 최종 결과 출력
      for Name of Engineer_Names loop
         Ada.Text_IO.Put_Line (To_String (Name));
      end loop;
      -- 출력:
      -- Bob
      -- Charlie
   end;
end Demonstrate_Container_Pipeline;

이 예제는 함수형 프로그래밍 기법이 고정된 크기의 배열뿐만 아니라, 동적이고 복잡한 데이터 구조에서도 얼마나 효과적으로 적용될 수 있는지를 명확히 보여줍니다. 각 단계(Filter, Map)는 독립적인 제네릭 함수로 캡슐화되어 재사용성이 높으며, 이들을 조합하여 데이터 처리의 흐름을 선언적으로 기술할 수 있습니다. 이처럼 컨테이너 라이브러리와의 결합은 함수형 Ada 프로그래밍의 활용 범위를 한 차원 더 높은 수준으로 끌어올립니다.

15.8 실용적 관점에서의 효용성 분석

Ada 언어에 함수형 프로그래밍 기법을 도입하는 것은 단순히 새로운 스타일을 적용하는 것을 넘어, Ada가 본질적으로 추구하는 핵심 가치인 소프트웨어의 신뢰성, 유지보수성, 그리고 효율성을 더욱 높은 수준에서 달성하기 위한 실용적인 전략입니다. 그 효용성은 다음의 세 가지 관점에서 구체적으로 분석할 수 있습니다.

1. 신뢰성의 극대화: 예측 가능한 코드

소프트웨어의 신뢰성은 그 동작을 얼마나 정확하게 예측하고 검증할 수 있는지에 달려있습니다. 함수형 스타일의 핵심인 순수 함수불변 데이터는 코드의 예측 가능성을 근본적으로 향상시킵니다.

  • 부작용의 격리: 순수 함수는 외부 상태에 영향을 주지 않으므로, 각 함수의 동작은 시스템의 나머지 부분과 완전히 독립적으로 분석될 수 있습니다. 이는 복잡한 시스템에서 특정 모듈의 수정이 다른 모듈에 예기치 않은 파급 효과를 일으키는 ‘나비 효과’를 방지합니다.
  • 안전한 병렬 처리: 공유된 가변 상태가 없으므로, 데이터 경쟁이나 교착 상태와 같은 고질적인 동시성 문제의 발생 가능성이 현저히 낮아집니다. 이는 Ada의 강력한 태스킹 기능과 결합하여, 멀티코어 환경에서 잠금(Lock)으로 인한 복잡성과 성능 저하 없이 안정적인 병렬 코드를 작성할 수 있게 해줍니다.

2. 유지보수성의 향상: 명료하고 조합 가능한 코드

소프트웨어 생명주기에서 가장 큰 비용을 차지하는 것은 유지보수입니다. 함수형 스타일은 코드의 구조를 명료하게 만들어 유지보수 비용을 절감하는 데 크게 기여합니다.

  • 높은 가독성: ifcase 표현식, 선언 표현식 등은 여러 줄의 명령형 코드를 단 하나의 선언적인 표현식으로 압축하여 코드의 의도를 한눈에 파악할 수 있게 합니다.
  • 재사용성과 조합성: 데이터 처리 파이프라인 예제에서 보았듯이, 각 기능은 작고 독립적인 함수 단위로 구현됩니다. 이 함수들은 마치 레고 블록처럼 자유롭게 조합하여 새로운 기능을 만들 수 있으며, 각 블록은 독립적으로 테스트되고 검증될 수 있어 전체 시스템의 품질을 높입니다.

3. 효율성의 증대: 현대 하드웨어의 활용

함수형 코드는 현대 하드웨어 아키텍처, 특히 멀티코어 프로세서의 성능을 최대한 활용하는 데 매우 유리합니다.

  • 컴파일러 최적화: 참조 투명성을 가진 코드는 컴파일러가 더 공격적인 최적화를 수행할 수 있는 여지를 제공합니다. 예를 들어, 함수의 결과 값을 캐싱(Memoization)하거나, 불필요한 계산을 제거하기가 더 용이합니다.
  • 선언적 병렬화: 'Parallel_Reduce와 같은 고수준 병렬 구문은 개발자가 저수준의 스레드 관리에 신경 쓸 필요 없이, “무엇을” 병렬로 처리할 것인지에만 집중하게 해줍니다. 이는 개발 생산성을 높이는 동시에, 하드웨어의 병렬 처리 능력을 최대한으로 이끌어냅니다.

본 장을 마무리하며, 우리는 함수형 패러다임의 기본 원칙에서 출발하여 표현식 중심의 구문, 고차 함수 설계, 그리고 병렬 처리와의 결합에 이르기까지 Ada를 활용한 함수형 프로그래밍의 다양한 측면을 탐구했습니다. 이 모든 여정을 통해 확인한 바와 같이, Ada에 함수형 기법을 접목하는 것은 전통적인 명령형, 객체 지향 패러다임의 강점 위에 선언적이고 안전한 프로그래밍 스타일의 이점을 더하는 것입니다. 이는 복잡하고, 안전이 중요하며(Safety-Critical), 고성능을 요구하는 현대 소프트웨어 시스템을 구축하는 데 있어 더욱 강력하고 효과적인 해법을 제공합니다.

16. 다른 언어와의 연동 (Interfacing with Other Languages)

현대 소프트웨어 시스템을 Ada와 같이 타입 안전성이 높고 구조적인 단일 언어로 전체를 구축하는 것은 일관성과 신뢰성 측면에서 가장 이상적인 시나리오일 것입니다. 하지만 현실 세계의 소프트웨어 공학은 종종 순수한 학문적 이상과는 다른 길을 걷습니다.

오늘날의 시스템은 완전히 새로운 기반 위에서 만들어지기보다는, 수십 년간 축적된 방대한 양의 기존 코드 자산 위에 구축되는 경우가 대부분입니다. 우리가 사용하는 운영체제의 핵심 API(POSIX, Win32 등)는 거의 예외 없이 C 언어로 작성되어 있으며, 고성능 수학 라이브러리, 그래픽 툴킷, 데이터베이스 클라이언트 등 수많은 핵심 라이브러리 역시 C나 C++로 구현되어 있습니다. 또한, 시스템의 특정 성능 한계를 극복하기 위해서는 하드웨어와 가장 가까운 언어인 어셈블리어를 직접 제어해야 하는 경우도 발생합니다.

이러한 현실 속에서 어떤 프로그래밍 언어의 실용성은 다른 언어로 작성된 코드와 얼마나 효과적으로 상호작용할 수 있는지에 따라 크게 좌우됩니다. Ada는 이러한 상호 운용성(interoperability)을 언어 설계의 핵심 요소로 간주하며, 이를 위해 Ada 2022 레퍼런스 매뉴얼의 부록 B(Annex B)에 명시된 표준화된 언어 연동 기능을 제공합니다. 이는 컴파일러 제작사가 임시로 추가한 기능이 아닌, 언어 표준에 깊이 통합된, 예측 가능하고 신뢰성 있는 메커니즘입니다.

본 장에서는 Ada를 고립된 섬이 아닌, 거대한 소프트웨어 생태계의 일원으로 만들어주는 이 강력한 연동 기능에 대해 학습합니다. 먼저 Interfaces 표준 라이브러리 패키지와 pragma convention을 통해 언어 연동의 기본 원칙을 이해할 것입니다. 이어서 가장 보편적이고 중요한 C 언어와의 연동 방법을 타입 매핑부터 포인터 처리, 구조체와 공용체 표현까지 깊이 있게 다룹니다. 이를 기반으로 이름 맹글링, 클래스 연동 등 더 복잡한 과제를 안고 있는 C++와의 연동 전략을 탐구하고, 마지막으로 하드웨어 직접 제어를 위한 어셈블리어 통합 방법까지 살펴볼 것입니다.

이 장을 마치고 나면, 독자께서는 기존의 C/C++ 라이브러리의 막강한 기능을 안전한 Ada 코드 안으로 통합하고, 운영체제의 저수준 API를 직접 호출하며, 심지어 하드웨어에 가장 가까운 수준의 제어까지 수행할 수 있는 능력을 갖추게 될 것입니다. 이는 Ada의 강력한 추상화 및 안전성 기능을 유지하면서도, 현실 세계의 어떤 시스템 요구사항에도 대응할 수 있는 실용적인 기술을 확보하는 중요한 과정입니다.

16.1 언어 연동의 기본 원칙

Ada 프로그램이 C나 Fortran과 같은 다른 언어로 작성된 코드와 성공적으로 상호작용하기 위해서는, 두 세계 간의 근본적인 차이점을 해소하고 일관된 규칙을 적용해야 합니다. 가장 대표적인 차이점은 데이터를 메모리에 배치하는 방식(data layout)과 서브프로그램을 호출하고 파라미터를 전달하는 방식, 즉 호출 규약(calling convention) 입니다.

예를 들어, 다차원 배열을 저장할 때 Ada와 Fortran은 열 우선(column-major) 순서를 사용하는 반면, C와 C++는 행 우선(row-major) 순서를 사용합니다. 또한, 함수 호출 시 파라미터를 스택에 쌓는 순서나, 호출이 끝난 후 누가 스택을 정리할 것인지에 대한 규칙도 언어나 컴파일러마다 다를 수 있습니다.

이러한 차이를 중재하지 않고 무작정 다른 언어의 함수를 호출한다면, 파라미터가 잘못된 순서로 전달되거나 데이터 구조가 완전히 다른 의미로 해석되어 프로그램은 예측 불가능한 오류를 일으키며 비정상적으로 종료될 것입니다.

Ada는 이러한 문제를 해결하기 위해 언어 연동에 대한 두 가지 기본 원칙을 제시합니다.

  1. 표준 인터페이스 패키지 사용: Ada 표준 라이브러리는 Interfaces라는 최상위 패키지와 그 자식 패키지들(예: Interfaces.C, Interfaces.Fortran)을 제공합니다. 이 패키지들은 다른 언어의 기본 타입(C의 int, double 등)에 정확히 대응되는 Ada 타입을 정의하고 있습니다. 이식성이 중요한 프로그램을 작성할 때는, Ada의 내장 타입(e.g., Integer)을 직접 사용하는 것보다 Interfaces.C.int 와 같이 이 표준 패키지에 정의된 타입을 사용하는 것이 가장 안전하고 명확한 방법입니다.

  2. pragma Convention을 통한 규칙 명시: pragma Convention은 특정 Ada 타입이나 서브프로그램이 다른 언어의 규칙을 따라야 함을 컴파일러에게 명시적으로 지시하는 핵심적인 지시어입니다. 예를 들어, pragma Convention (C, My_Record);My_Record 타입의 메모리 레이아웃이 C 언어의 struct와 동일한 방식으로 정렬되어야 함을 보장합니다. 또한, C에서 호출될 콜백(callback) 프로시저를 작성할 때도, 해당 프로시저가 C의 호출 규약을 따르도록 지정하는 데 사용됩니다.

본 절에서는 이러한 기본 원칙을 바탕으로, Interfaces 패키지의 전반적인 구조를 살펴보고, pragma Convention을 사용하여 서로 다른 언어의 세계를 안전하게 연결하는 첫걸음을 내딛게 될 것입니다.

16.1.1 Interfaces 패키지의 역할과 구조

Interfaces 패키지는 Ada가 다른 프로그래밍 언어와 소통하기 위해 마련한 표준화된 관문(standardized gateway)입니다. 이 패키지와 그 자식들은 다른 언어의 세계에 존재하는 개념들, 특히 기본 데이터 타입들을 Ada의 강력한 타입 시스템 안으로 안전하게 가져오는 역할을 수행합니다.

이 패키지의 핵심적인 설계 목표는 **이식성(portability)**과 **명확성(clarity)**입니다. Ada의 내장 타입인 Integer의 실제 크기(예: 32비트 또는 64비트)는 컴파일러나 하드웨어 아키텍처에 따라 달라질 수 있습니다. 하지만 C 언어의 int 타입과 데이터를 교환해야 할 때, 우리는 이 int가 현재 시스템에서 몇 비트인지 정확히 알고 그에 맞는 Ada 타입을 사용해야 합니다. Interfaces 패키지는 바로 이러한 불확실성을 제거해 줍니다.

Interfaces 패키지의 구조

Interfaces는 그 자체로는 거의 아무런 기능을 담고 있지 않은, 이름공간을 형성하기 위한 최상위 부모 패키지입니다. 실질적인 기능은 모두 그 자식 패키지들을 통해 제공됩니다.

package Interfaces is
   -- 시프트 및 회전 연산과 같은 저수준 비트 연산 함수들이 여기에 직접 정의되어 있습니다.
   -- 예: Shift_Left, Shift_Right, Rotate_Left, Rotate_Right
end Interfaces;

가장 중요하고 널리 사용되는 자식 패키지는 다음과 같습니다.

  • Interfaces.C: C 언어와의 연동을 위한 모든 것을 담고 있습니다.

    • C의 기본 타입에 직접 대응되는 Ada 타입을 제공합니다. (예: int, long, double, char, size_t)
    • Interfaces.C.Strings: C 스타일의 null-terminated 문자열을 다루기 위한 타입(char_array)과 서브프로그램(to_c, to_ada), 그리고 포인터(chars_ptr)를 정의합니다.
    • Interfaces.C.Pointers: 타입 안전성을 유지하면서도 유연한 포인터 연산을 지원하는 유틸리티를 제공합니다.
  • Interfaces.Fortran / Interfaces.COBOL: 각각 Fortran과 COBOL 언어와의 연동을 위한 타입과 상수를 정의합니다.

Interfaces.C의 활용 예시

C 라이브러리 함수가 unsigned long 타입의 파라미터를 받는다고 가정해 봅시다. 이식성을 보장하는 가장 안전한 Ada 코드는 다음과 같이 Interfaces.C에 정의된 타입을 사용하는 것입니다.

with Interfaces.C;
procedure Call_C_Function is
   -- C 함수의 Ada 측 선언
   procedure c_func (param : in Interfaces.C.unsigned_long);
   pragma Import (C, c_func, "c_library_function");

   value_to_pass : Interfaces.C.unsigned_long := 12345;
begin
   c_func (value_to_pass);
end Call_C_Function;

이 코드에서 Interfaces.C.unsigned_long 타입은 GNAT 컴파일러가 현재 대상 시스템의 C 컴파일러가 사용하는 unsigned long의 크기와 표현 방식에 정확히 일치하도록 보장해 줍니다. 만약 Interfaces.C를 사용하지 않고 Ada의 내장 타입인 Long_Unsigned_Integer를 직접 사용했다면, 이 타입이 모든 시스템에서 C의 unsigned long과 동일하다는 보장이 없으므로 이식성이 떨어지게 됩니다.

결론적으로, Interfaces 패키지는 다른 언어와의 경계에서 발생할 수 있는 데이터 표현의 모호함을 제거하는 역할을 합니다. 프로그래머는 이 표준 패키지를 통해, 컴파일러와 플랫폼에 상관없이 항상 올바르게 동작할 것이라고 예측할 수 있는 견고하고 이식성 높은 인터페이스 코드를 작성할 수 있습니다.

16.1.2 호출 규약(Calling Convention)과 pragma Convention

서로 다른 언어로 작성된 서브프로그램이 성공적으로 상호작용하기 위해서는, 데이터를 메모리에 표현하는 방식뿐만 아니라 서브프로그램을 호출하고 파라미터를 전달하는 방식에 대한 합의가 반드시 필요합니다. 이 규칙의 집합을 호출 규약(calling convention) 이라고 합니다.

호출 규약은 컴파일러가 기계어 코드를 생성할 때 따르는 저수준의 약속이며, 다음과 같은 세부 사항들을 결정합니다.

  • 파라미터 전달 방식: 파라미터가 CPU 레지스터를 통해 전달되는가, 아니면 스택(stack)을 통해 전달되는가? 스택을 사용한다면, 어떤 순서(오른쪽에서 왼쪽, 또는 왼쪽에서 오른쪽)로 스택에 쌓이는가?
  • 반환 값 처리: 함수가 반환하는 값은 특정 레지스터(예: EAX)에 저장되는가, 아니면 스택에 저장되는가?
  • 스택 정리(Cleanup): 호출이 끝난 후, 스택에 쌓였던 파라미터들을 정리하는 책임은 호출한 쪽(caller)에 있는가, 아니면 호출된 쪽(callee)에 있는가?
  • 이름 장식(Name Decoration/Mangling): 컴파일러가 링커를 위해 생성하는 서브프로그램의 심볼(symbol) 이름은 원본 이름과 동일한가, 아니면 파라미터 타입 정보 등을 포함하여 변형되는가?

Ada, C, C++, Pascal 등 각 언어는 자신만의 기본 호출 규약을 가지고 있으며, 같은 언어라도 컴파일러나 운영체제에 따라 다른 규약을 사용할 수 있습니다. 만약 호출하는 쪽과 호출되는 쪽이 서로 다른 호출 규약을 사용한다면, 파라미터는 엉뚱한 곳에 전달되고 프로그램은 거의 즉시 비정상적으로 종료될 것입니다.

pragma Convention의 역할

Ada는 이러한 문제를 해결하고 프로그래머가 호출 규약을 명시적으로 제어할 수 있도록 pragma Convention이라는 컴파일러 지시어를 제공합니다. 이 프라그마는 특정 Ada 개체(타입 또는 서브프로그램)가 Ada의 기본 규칙이 아닌, 지정된 다른 언어의 호출 규약이나 데이터 표현 규약을 따라야 함을 컴파일러에게 지시합니다.

pragma Convention의 기본 구문은 다음과 같습니다.

pragma Convention (Convention_Identifier, Entity_Name);
  • Convention_Identifier: 사용할 규약의 이름입니다. Ada 레퍼런스 매뉴얼은 Ada, C, Fortran, COBOL, Assembler 등을 표준으로 정의하며, GNAT과 같은 컴파일러는 CPP, Stdcall 등 추가적인 식별자를 지원합니다.
  • Entity_Name: 규약을 적용할 Ada 타입이나 서브프로그램의 이름입니다.

활용 예시

1. 데이터 타입의 메모리 레이아웃 지정

C 언어의 struct와 정확히 일치하는 메모리 구조를 갖는 Ada 레코드를 정의할 때 사용합니다.

type My_Record is record
   field1 : Integer;
   field2 : Boolean;
end record;
pragma Convention (C, My_Record);

이 프라그마를 통해 컴파일러는 My_Record의 필드들을 C 컴파일러가 struct를 정렬하는 방식과 동일하게 메모리에 배치합니다. (예: 패딩(padding) 규칙 등)

2. C에서 호출될 콜백(Callback) 함수 정의

C 라이브러리가 특정 이벤트 발생 시 호출할 콜백 함수를 Ada로 작성해야 하는 경우, 해당 Ada 프로시저가 C의 호출 규약을 따르도록 지정해야 합니다.

-- C 라이브러리에 전달할 콜백 프로시저
procedure my_callback (event_code : in Interfaces.C.int);
pragma Convention (C, my_callback);

이 선언이 없다면, my_callback 프로시저는 Ada의 기본 호출 규약으로 컴파일되어 C 라이브러리가 이를 올바르게 호출할 수 없습니다.

3. pragma Import/Export와의 관계

서브프로그램을 다른 언어와 연동하기 위한 pragma Importpragma Export를 사용할 때는, 호출 규약을 파라미터 형식으로 함께 지정하는 것이 일반적입니다.

function c_library_function return Interfaces.C.int;
pragma Import (Convention => C, Entity => c_library_function);

이처럼 pragma Convention과 관련 프라그마들은 서로 다른 언어의 세계가 만나는 경계면에서 발생할 수 있는 저수준의 비호환성 문제를 해결하고, 예측 가능하고 안정적인 상호 운용성을 보장하는 핵심적인 역할을 수행합니다.

16.2 C 언어와의 연동: 견고한 기반 다지기

수많은 프로그래밍 언어 중에서 C는 현대 컴퓨팅 시스템의 기반을 이루는 가장 보편적인 언어라고 할 수 있습니다. 운영체제 API, 임베디드 시스템의 하드웨어 드라이버, 그리고 수많은 고성능 라이브러리들이 C로 작성되어 있으며, 이는 오늘날에도 거대한 소프트웨어 생태계를 형성하고 있습니다. 따라서 Ada 프로그램이 이러한 방대한 C 자산을 활용할 수 있는 능력은 Ada의 실용성을 결정짓는 매우 중요한 요소입니다.

Ada와 C는 모두 절차적 프로그래밍에 뿌리를 두고 있어 상호 간의 개념적 유사성이 높고, 이로 인해 다른 패러다임의 언어들보다 비교적 직접적인 연동이 가능합니다. 레코드는 구조체에, 접근 타입은 포인터에, 서브프로그램은 함수에 대응될 수 있습니다.

그러나 두 언어 간에는 매우 중요한 철학적 차이가 존재합니다. 바로 타입 시스템의 엄격함입니다. Ada는 컴파일 시점에 최대한 많은 오류를 잡아내도록 설계된 강력한 타입 시스템(strong type system)을 기반으로 하여 프로그램의 신뢰성을 극대화합니다. 반면, C는 저수준의 메모리 조작을 허용하는 유연하지만 상대적으로 타입 안전성이 낮은 시스템을 가지고 있습니다.

C 연동의 핵심 과제는 바로 이 두 세계 사이의 간극을 안전하게 메우는 것입니다. Ada의 타입 안전성 원칙을 유지하면서도, C의 데이터 구조와 함수 호출 규약을 정확히 따르는 것이 중요합니다.

이러한 차이를 안전하게 중재하기 위해 Ada 2022 레퍼런스 매뉴얼은 Interfaces.C라는 표준 패키지를 제공합니다. 이 패키지는 C의 기본 타입(int, char, size_t 등)에 정확히 대응되는 Ada 타입을 정의하여, 프로그래머가 플랫폼에 독립적인 이식성 높은 연동 코드를 작성할 수 있도록 지원합니다.

본 절에서는 C 언어와의 연동을 위한 견고한 기반을 다지는 것을 목표로 합니다. 먼저 Interfaces.C를 사용한 기본 및 구조적 타입의 매핑 방법을 학습하고, C 연동에서 가장 까다로운 부분인 포인터와 null-terminated 문자열을 안전하게 다루는 기법을 익힐 것입니다. 마지막으로, pragma Importpragma Export를 사용하여 Ada와 C 서브프로그램이 서로를 호출하는 구체적인 방법을 예제와 함께 상세히 분석할 것입니다. 이 과정을 통해 독자께서는 방대한 C 언어 자산을 Ada의 안전한 환경 안으로 통합하는 핵심적인 기술을 습득하게 될 것입니다.

16.2.1 기본 타입 매핑: Interfaces.C와 그 자식 패키지들

Ada와 C 프로그램 간에 데이터를 정확하게 교환하기 위한 첫 번째 단계는, 두 언어의 가장 기본적인 데이터 타입들을 서로 일치시키는 것입니다. Ada의 내장 타입(Integer, Float 등)은 컴파일러와 하드웨어 아키텍처에 따라 그 크기가 달라질 수 있어, C 언어의 고정된 크기를 가정하는 타입(예: int, long, double)과 직접적으로 대응시키기에는 이식성 문제가 발생할 수 있습니다.

이러한 불확실성과 위험을 제거하기 위해, Ada 표준 라이브러리는 C의 모든 기본 스칼라(scalar) 타입을 Ada에서 명확하고 안전하게 표현하는 Interfaces.C 패키지를 제공합니다. 이 패키지를 사용하는 것은 C 연동 코드의 신뢰성과 이식성을 보장하는 가장 기본적인 원칙입니다.

Interfaces.C의 타입들

Interfaces.C 패키지 명세에는 C 언어의 표준 타입들에 직접 대응되는 Ada 타입들이 정의되어 있습니다. 다음은 그 일부입니다.

  • int
  • short, long
  • unsigned, unsigned_short, unsigned_long
  • char (C의 char가 부호 없는 8비트 정수 값을 나타내는 것을 보장)
  • size_t (C의 sizeof 연산 결과 타입을 나타냄)
  • float, double, long_double

C 코드에서 int process_data(int value); 와 같은 함수를 Ada에서 호출해야 한다면, 다음과 같이 Interfaces.C.int를 사용하는 것이 가장 정확하고 이식성 높은 방법입니다.

with Interfaces.C;
procedure Call_C_Process_Data is
   -- C 함수 선언
   function process_data (value : Interfaces.C.int) return Interfaces.C.int;
   pragma Import (C, process_data);

   input  : Interfaces.C.int := 100;
   result : Interfaces.C.int;
begin
   result := process_data (input);
end Call_C_Process_Data;

이 코드는 inputresult 변수가 현재 대상 시스템의 C 컴파일러가 사용하는 int 타입과 크기 및 표현 방식에서 완벽하게 일치함을 보장합니다.

Interfaces.C의 자식 패키지들

Interfaces.C는 기본 스칼라 타입 외에, C 연동에서 필수적인 포인터와 문자열 처리를 위한 기능을 자식 패키지를 통해 제공합니다.

  • Interfaces.C.Strings 이 패키지는 C 언어의 가장 특징적인 데이터 구조 중 하나인 null-terminated 문자열(널 종단 문자열)을 안전하게 다루기 위한 도구를 제공합니다.

    • char_array: C의 char[]에 해당하는, null로 끝나는 문자 배열 타입입니다.
    • chars_ptr: char *에 해당하는 불투명(opaque) 포인터 타입입니다.
    • To_C / To_Ada: Ada의 String 타입과 C의 char_array를 서로 변환하는 함수를 제공합니다.
    • New_String / Free: C 스타일 문자열을 위해 동적으로 메모리를 할당하고 해제하는 서브프로그램을 제공합니다.
  • Interfaces.C.Pointers C의 유연한 포인터 연산을 타입 안전성을 유지하면서 수행할 수 있도록 지원하는 제네릭 패키지입니다. +, - 와 같은 연산자를 오버로딩하여 포인터 산술 연산을 가능하게 해 주며, 포인터와 배열 간의 변환을 돕는 유틸리티를 제공합니다. 이 패키지의 활용법은 이후 포인터 처리 절에서 더 상세히 다룰 것입니다.

결론적으로, Interfaces.C와 그 자식 패키지들은 두 언어 간의 경계면 역할을 수행합니다. 프로그래머는 이 표준화된 인터페이스를 통해 C 세계의 데이터 타입을 Ada의 엄격한 타입 시스템 안으로 안전하게 가져올 수 있으며, 이를 통해 견고하고 예측 가능한 연동 코드를 구축할 수 있습니다.

16.2.2 C 구조체(struct) 및 공용체(union) 매핑

C 프로그램에서는 관련된 데이터들을 하나의 단위로 묶기 위해 구조체(struct)를, 여러 다른 타입의 데이터를 동일한 메모리 공간에 겹쳐서 저장하기 위해 공용체(union)를 사용합니다. Ada가 이러한 C의 데이터 구조체와 상호작용하기 위해서는, 이들의 메모리 레이아웃을 Ada의 타입 시스템 안에서 정확하게 표현할 수 있어야 합니다.

C 구조체(struct)와 Ada 레코드(record) 매핑

C의 struct에 가장 직접적으로 대응되는 Ada의 개념은 record입니다. 두 언어 모두 이종(heterogeneous)의 데이터 필드들을 하나의 타입으로 묶는 기능을 제공합니다.

그러나 두 언어의 컴파일러는 메모리 효율성이나 하드웨어 정렬(alignment) 요구사항에 따라 필드 사이에 보이지 않는 ‘패딩(padding)’ 바이트를 삽입할 수 있으며, 그 규칙이 서로 다를 수 있습니다. 따라서 C의 struct와 완벽하게 메모리 호환되는 Ada 레코드를 만들기 위해서는, 컴파일러에게 해당 레코드가 C의 데이터 레이아웃 규칙을 따라야 함을 명시적으로 지시해야 합니다. 이때 pragma Convention (C, ...)가 사용됩니다.

예시: C의 struct와 Ada record 매핑

다음과 같은 C 구조체가 있다고 가정해 보겠습니다.

// C Code
struct Point {
    double x;
    double y;
    int    is_valid;
};

이를 위한 Ada 레코드는 다음과 같이 정의합니다.

-- Ada Code
with Interfaces.C;

package Geometry is
   type Point is record
      x        : Interfaces.C.double;
      y        : Interfaces.C.double;
      is_valid : Interfaces.C.int;
   end record;
   pragma Convention (C, Point); -- 이 레코드의 메모리 레이아웃을 C 규칙에 맞춥니다.
end Geometry;

pragma Convention (C, Point); 구문은 컴파일러에게 Point 레코드의 필드들을 C 컴파일러가 struct Point를 메모리에 배치하는 방식과 동일하게 정렬하도록 지시합니다. 이를 통해 Ada 프로그램은 C 함수에 Point 객체를 올바르게 전달하거나, C 함수로부터 전달받은 Point 객체의 메모리를 정확하게 해석할 수 있습니다.

C 공용체(union)와 pragma Unchecked_Union

C의 union은 타입 안전성이 없는 저수준 기능입니다. union의 여러 멤버 중 어떤 멤버가 현재 유효한 데이터를 담고 있는지에 대한 정보는 프로그래머의 책임 하에 관리되어야 합니다.

Ada는 타입 안전성을 보장하는 가변 레코드(discriminated record)를 제공하지만, 이는 C의 union과 메모리 구조가 다릅니다. C의 union과 직접적으로 메모리 레이아웃을 일치시키기 위해, Ada 2005 표준부터는 pragma Unchecked_Union 이라는 지시어가 도입되었습니다. 이 프라그마는 Ada의 타입 시스템의 일부 안전성 검사를 우회하여, C의 union과 동일한 메모리 표현을 갖는 레코드 타입을 생성하도록 지시합니다.

주의: 이 프라그마는 이름에 ‘Unchecked’가 포함된 만큼, 사용 시 타입 안전성에 대한 책임이 전적으로 프로그래머에게 있음을 의미하므로 매우 신중하게 사용해야 합니다.

예시: C의 union과 Ada 레코드 매핑

다음과 같은 C 공용체가 있다고 가정해 보겠습니다.

// C Code
union Data_Value {
    int   i;
    float f;
};

이를 위한 Ada 레코드는 다음과 같이 정의합니다.

-- Ada Code
with Interfaces.C;

package Data_Representation is
   type Data_Value is record
      i : Interfaces.C.int;
      f : Interfaces.C.float;
   end record;
   pragma Unchecked_Union (Data_Value);
   pragma Convention (C, Data_Value); -- C와의 호환성을 위해 Convention도 지정
end Data_Representation;

pragma Unchecked_Union (Data_Value);를 통해 Data_Value 레코드의 필드 if는 별도의 공간이 아닌, 동일한 메모리 주소에서 시작하도록 배치됩니다. 따라서 이 레코드의 크기는 가장 큰 필드(이 경우 float 또는 int 중 더 큰 쪽)의 크기와 같아집니다. 이제 이 Data_Value 타입을 사용하여 C 라이브러리가 사용하는 union 데이터에 접근할 수 있습니다. 하지만 어떤 필드에 데이터를 쓰고 다른 타입의 필드로 읽으려고 할 때 발생하는 결과는 전적으로 프로그래머의 책임입니다.

16.2.3 포인터와 문자열 처리

C 프로그래밍의 핵심적인 요소이자 가장 많은 오류를 유발하는 근원 중 하나는 포인터(*)와 이를 기반으로 하는 null-terminated 문자열(char *)입니다. Ada는 이러한 저수준 구조체와의 상호작용을 위해, C의 유연성을 수용하면서도 Ada의 타입 안전성을 최대한 유지하려는 정교한 메커니즘을 제공합니다.

C 포인터와 Ada 접근 타입

Ada의 접근 타입(access)은 C의 포인터에 대응되는 개념입니다. 그러나 중요한 차이점은, Ada의 접근 타입은 기본적으로 강력한 타입 검사를 받으며 임의의 주소 연산이 허용되지 않는다는 것입니다. C와의 연동 시에는 이러한 제약을 넘어 저수준의 주소 값을 다뤄야 할 필요가 있습니다.

  • System.Address: 이 타입은 System 패키지에 정의되어 있으며, C의 void *와 같이 타입 정보가 없는 순수한 메모리 주소를 나타냅니다.
  • 'Address 속성: Ada로 선언된 모든 객체(변수 등)에 이 속성을 적용하면, 해당 객체의 시작 메모리 주소를 System.Address 타입으로 얻을 수 있습니다.

C 함수로부터 void * 타입의 주소를 받거나 C 함수에 Ada 객체의 주소를 전달해야 할 때, 이 System.Address 타입을 매개체로 사용합니다. System.Address 값을 특정 타입의 접근 타입으로 변환하기 위해서는 Ada.Unchecked_Conversion이라는 제네릭 함수를 사용해야 하며, 이 과정의 모든 타입 안전성에 대한 책임은 프로그래머에게 있습니다.

C의 NULL 포인터는 Ada의 null 값에 대응되며, System.Address 타입의 null 값은 System.Null_Address 상수로 표현됩니다.

C 문자열 처리: Interfaces.C.Strings 패키지

C 언어에서 문자열은 NUL(\0) 문자로 끝나는 문자들의 배열(char[])로 표현됩니다. 이는 문자열의 길이 정보를 별도로 가지고 있는 Ada의 String 타입과는 근본적으로 다른 구조입니다.

이러한 불일치를 안전하고 효율적으로 처리하기 위해, 표준 라이브러리는 Interfaces.C.Strings 패키지를 제공합니다. 이 패키지를 사용하는 것이 C 문자열을 다루는 표준적인 방법입니다.

주요 기능은 다음과 같습니다.

  • char_array: NUL 문자로 끝나는 C 스타일 문자 배열에 대응되는 Ada 타입입니다.
  • chars_ptr: C의 char *에 대응되는 불투명(opaque) 접근 타입입니다. 이 타입은 임의의 포인터 연산을 막아 안전성을 높입니다.
  • To_C: Ada의 String을 C가 이해할 수 있는 char_array로 변환합니다. 이 함수는 자동으로 문자열 끝에 NUL 문자를 추가합니다.
  • To_Ada: C의 char_array를 Ada의 String으로 변환합니다. 이 함수는 NUL 문자를 만나기 전까지의 문자들을 읽어 길이를 결정합니다.
  • Value: chars_ptr이 가리키는 C 문자열의 내용을 Ada String으로 반환합니다.

예제: C 함수와의 문자열 교환

다음은 const char* 타입의 이름을 받아 환영 메시지를 char* 타입으로 반환하는 C 함수를 Ada에서 호출하는 예제입니다.

// C 라이브러리 코드 (greeting.c)
#include <string.h>
#include <stdlib.h>

char* create_greeting(const char* name) {
    const char* prefix = "Hello, ";
    char* result = (char*)malloc(strlen(prefix) + strlen(name) + 2); // "!" + NUL
    strcpy(result, prefix);
    strcat(result, name);
    strcat(result, "!");
    return result;
}

void free_greeting(char* msg) {
    free(msg);
}
-- Ada 클라이언트 코드
with Interfaces.C;
with Interfaces.C.Strings;
with Ada.Text_IO;

procedure Greet_User is
   -- C 함수 임포트
   function create_greeting (name : Interfaces.C.Strings.chars_ptr)
      return Interfaces.C.Strings.chars_ptr;
   pragma Import (C, create_greeting);

   procedure free_greeting (msg : Interfaces.C.Strings.chars_ptr);
   pragma Import (C, free_greeting);

   ada_name       : constant String := "World";
   c_name_ptr     : Interfaces.C.Strings.chars_ptr;
   result_ptr     : Interfaces.C.Strings.chars_ptr;
   result_string  : String;

begin
   -- 1. Ada String -> C chars_ptr 변환
   c_name_ptr := Interfaces.C.Strings.new_string (ada_name);

   -- 2. C 함수 호출
   result_ptr := create_greeting (c_name_ptr);

   -- 3. C chars_ptr -> Ada String 변환
   result_string := Interfaces.C.Strings.value (result_ptr);
   Ada.Text_IO.put_line (result_string);

   -- 4. 메모리 해제
   Interfaces.C.Strings.free (c_name_ptr);
   free_greeting (result_ptr); -- C 라이브러리가 할당한 메모리 해제

exception
   when others =>
      Ada.Text_IO.put_line ("An error occurred.");
end Greet_User;

이 예제는 Ada가 어떻게 C의 저수준 포인터와 문자열을 직접 다루는 대신, Interfaces.C.Strings라는 잘 정의된 추상화 계층을 통해 이들을 안전하게 제어하는지를 보여줍니다. 이러한 접근 방식은 C 연동 시 발생할 수 있는 버퍼 오버플로우나 메모리 누수와 같은 흔한 오류들을 상당수 예방하는 데 도움을 줍니다.

16.2.4 C 함수 호출: pragma import

Ada 프로그램이 외부 C 라이브러리에 정의된 함수를 사용하기 위해서는, 해당 C 함수를 Ada 세계에 ‘소개’하고 두 세계를 연결하는 명시적인 선언이 필요합니다. 이 역할을 수행하는 핵심적인 언어 기능이 바로 **pragma import**입니다.

pragma Import는 특정 Ada 서브프로그램 선언에 대해, 그 구현(body)이 Ada 코드로 존재하지 않고 외부(external)에 다른 언어로 작성되어 있음을 컴파일러와 링커에게 알리는 지시어입니다. 즉, “이 서브프로그램의 본체는 찾지 마시오. 대신, 링킹 단계에서 지정된 외부 심볼(symbol)과 연결하시오.” 라는 의미를 전달합니다.

C 함수를 호출하는 과정은 다음과 같은 2단계로 이루어집니다.

  1. Ada 서브프로그램 선언: 호출하려는 C 함수와 동일한 기능을 하는 Ada 서브프로그램(프로시저 또는 함수)의 명세를 작성합니다. 이때 파라미터와 반환 값의 타입은 Interfaces.C 패키지의 타입을 사용하여 C 함수의 시그니처(signature)와 정확히 일치시켜야 합니다.
  2. pragma Import 적용: 작성한 Ada 서브프로그램 명세 바로 다음에 pragma Import를 위치시켜, 이 명세가 어떤 외부 함수에 연결되는지를 지정합니다.

pragma import 구문

pragma Import는 여러 인자를 가질 수 있으며, 가장 일반적으로 사용되는 형태는 다음과 같습니다.

pragma Import (
   Convention    => C,
   Entity        => Ada_Subprogram_Name,
   External_Name => "c_function_name_in_source",
   Link_Name     => "symbol_name_for_linker"
);
  • Convention: 연동할 언어의 호출 규약을 지정합니다. C 언어의 경우 C를 사용합니다.
  • Entity: 프라그마를 적용할 Ada 서브프로그램의 이름을 지정합니다.
  • External_Name: C 소스 코드에 실제로 작성된 함수의 이름(대소문자 구분)을 문자열로 지정합니다. 이 인자를 생략하면 Entity의 이름(일반적으로 소문자로 변환됨)이 사용됩니다.
  • Link_Name: 링커가 최종적으로 찾아야 할 심볼 이름을 지정합니다. 대부분의 경우 External_Name과 동일하지만, 특별한 링커 심볼 규칙이 적용될 때 명시적으로 지정할 수 있습니다.

예제: 외부 C 수학 함수 호출

간단한 C 라이브러리 math_lib.c에 다음과 같은 함수가 있다고 가정합니다.

// File: math_lib.h
double calculate_hypot(double a, double b);

// File: math_lib.c
#include <math.h>
double calculate_hypot(double a, double b) {
    return hypot(a, b);
}

이 함수를 Ada에서 호출하는 코드는 다음과 같습니다.

-- File: main.adb
with Interfaces.C;
with Ada.Text_IO;
with Ada.Float_Text_IO;

procedure Main is
   use Interfaces.C;
   use Ada;

   -- 1. C 함수에 대응하는 Ada 함수를 선언합니다.
   --    C의 double은 Interfaces.C.double에 매핑됩니다.
   function hypotenuse (a : double; b : double) return double;

   -- 2. pragma Import를 사용하여 Ada 선언과 C 함수를 연결합니다.
   --    Ada 이름(hypotenuse)과 C 이름(calculate_hypot)이 다르므로
   --    External_Name을 명시적으로 지정합니다.
   pragma Import (C, hypotenuse, "calculate_hypot");

   side1 : constant double := 3.0;
   side2 : constant double := 4.0;
   result : double;
begin
   -- 이제 hypotenuse는 일반 Ada 함수처럼 호출할 수 있습니다.
   result := hypotenuse (side1, side2);

   Text_IO.put ("The hypotenuse of a triangle with sides ");
   Float_Text_IO.put (side1, fore => 1, aft => 1, exp => 0);
   Text_IO.put (" and ");
   Float_Text_IO.put (side2, fore => 1, aft => 1, exp => 0);
   Text_IO.put (" is ");
   Float_Text_IO.put (result, fore => 1, aft => 1, exp => 0);
   Text_IO.new_line;
end Main;

(위 코드를 빌드하기 위해서는 C 소스를 먼저 컴파일한 후, Ada 컴파일 시 링커에게 해당 오브젝트 파일을 함께 링크하도록 지시해야 합니다: gcc -c math_lib.c, gnatmake main.adb -largs math_lib.o)

파라미터 전달 방식

pragma Import를 통해 연결된 서브프로그램의 파라미터는 Ada의 모드(in, out, in out)에 따라 C에서 다음과 같이 취급됩니다.

  • in 모드: 스칼라 타입은 값으로(pass-by-value), 복합 타입(레코드, 배열)은 const 포인터로 전달됩니다.
  • out, in out 모드: 모든 타입에 대해 포인터(T*)로 전달됩니다. Ada 컴파일러가 자동으로 포인터를 생성하고 역참조하는 코드를 만들어주므로, Ada 코드에서는 포인터를 직접 다루는 것처럼 보이지 않습니다.

이처럼 pragma Import는 Ada의 타입 시스템과 C의 저수언 구현 사이의 다리 역할을 수행하며, 이종 언어 간의 함수 호출을 가능하게 하는 근본적인 메커니즘입니다.

16.2.5 C에서 Ada 서브프로그램 호출: pragma Export

Ada와 C의 연동은 단방향 통행이 아닙니다. Ada가 C 라이브러리를 호출하는 것만큼이나, C 프로그램이 Ada로 작성된 고신뢰성 라이브러리나 모듈의 기능을 호출해야 하는 경우도 많습니다. 예를 들어, 핵심 비즈니스 로직은 Ada로 안전하게 구현하고, 사용자 인터페이스는 기존 C 기반의 프레임워크를 활용하는 시나리오를 생각해 볼 수 있습니다.

이러한 “Ada 코드 호출”을 가능하게 하는 메커지즘이 바로 pragma Export 입니다. 이 프라그마는 pragma Import의 정반대 역할을 수행합니다. 즉, 특정 Ada 서브프로그램을 외부 링커가 찾을 수 있는 심볼(symbol)로 만들어, 다른 언어에서 호출할 수 있도록 외부에 노출(export)하는 기능입니다.

pragma export 구문과 동작

pragma Export는 Ada 서브프로그램의 선언부(specification)에 적용되며, pragma Import와 매우 유사한 구문을 가집니다.

pragma Export (
   Convention    => C,
   Entity        => Ada_Subprogram_Name,
   External_Name => "c_callable_name",
   Link_Name     => "symbol_name_for_linker"
);
  • Convention: 호출 규약을 C로 지정하여, C 컴파일러가 이해할 수 있는 방식으로 코드가 생성되도록 합니다.
  • Entity: 외부에 노출할 Ada 서브프로그램의 이름을 지정합니다.
  • External_Name / Link_Name: C 코드에서 이 서브프로그램을 호출할 때 사용할 이름(심볼)을 문자열로 지정합니다. 이 이름을 명시적으로 지정하는 것이 C 코드와의 명확한 인터페이스를 위해 권장됩니다.

pragma Export가 적용된 서브프로그램의 모든 파라미터와 반환 타입은 반드시 C와 호환되는 타입이어야 합니다. 즉, Interfaces.C에 정의된 타입이나 pragma Convention (C)가 적용된 레코드 타입 등을 사용해야 합니다.

예제: C 프로그램에서 사용할 Ada 라이브러리 작성

두 정수를 더하는 간단한 함수를 포함하는 Ada 라이브러리를 작성하고, 이를 C main 함수에서 호출하는 예제는 다음과 같습니다.

1. Ada 라이브러리 코드 (ada_lib.ads, ada_lib.adb)

-- File: ada_lib.ads
with Interfaces.C;

package Ada_Lib is

   function add_integers (a : Interfaces.C.int; b : Interfaces.C.int)
      return Interfaces.C.int;
   -- C에서 'add_integers'라는 이름으로 호출할 수 있도록 함수를 Export 합니다.
   pragma Export (C, add_integers, "add_integers");

end Ada_Lib;
-- File: ada_lib.adb
package body Ada_Lib is

   function add_integers (a : Interfaces.C.int; b : Interfaces.C.int)
      return Interfaces.C.int is
   begin
      return a + b;
   end add_integers;

end Ada_Lib;

2. C 클라이언트 코드 (main.c)

C 코드에서는 Ada 함수를 외부(extern) 함수로 선언하여 사용합니다. 이때 함수 이름은 pragma ExportExternal_Name과 정확히 일치해야 합니다.

// File: main.c
#include <stdio.h>

/*
 * Ada 라이브러리에서 Export된 함수를 외부 함수로 선언합니다.
 * 파라미터와 반환 타입은 Ada 측의 Interfaces.C 타입과 대응되어야 합니다.
 */
extern int add_integers(int a, int b);

int main() {
    int result = add_integers(10, 20);
    printf("The result from Ada is: %d\n", result);
    return 0;
}

3. 빌드 및 링크 과정

이 프로그램은 Ada 컴파일러와 C 컴파일러를 모두 사용하여 빌드해야 합니다.

  1. Ada 라이브러리를 오브젝트 파일로 컴파일: gnatmake -c ada_lib.adb
  2. C 클라이언트를 오브젝트 파일로 컴파일: gcc -c main.c
  3. 두 오브젝트 파일을 Ada 런타임 라이브러리와 함께 링크하여 최종 실행 파일 생성: gnatbind -n ada_lib.ali 후 ` gcc main.o ada_lib.o -o main gnatlink ada\_lib.ali\`

파라미터 전달 방식의 이해

Ada의 outin out 파라미터는 C 측에서는 포인터로 나타납니다. 예를 들어, Ada 프로시저가 procedure Get_Value (val : out Interfaces.C.int); 로 선언되고 Export 되었다면, C에서는 void Get_Value(int* val); 로 선언하고 사용해야 합니다. Ada 런타임이 포인터 전달 및 역참조를 자동으로 처리해 줍니다.

이처럼 pragma Export는 Ada의 강력한 기능과 신뢰성을 C를 포함한 다른 언어 생태계에 제공하는 핵심적인 다리 역할을 합니다. 이를 통해 Ada를 범용 라이브러리 개발 언어로서 효과적으로 활용할 수 있습니다.

pragma Importpragma Export는 Ada 서브프로그램 선언과 외부 심볼(symbol)을 연결하는 기본적인 메커니즘을 제공합니다. 그러나 현실의 연동 시나리오에서는 Ada에서의 이름과 외부 라이브러리에서의 이름이 다르거나, 링커(linker)가 요구하는 심볼 이름에 특별한 규칙이 적용되는 등 더 정교한 제어가 필요한 경우가 많습니다.

이를 위해 pragma Importpragma ExportExternal_NameLink_Name이라는 두 가지 중요한 인자를 제공합니다.

External_Name의 역할과 사용법

External_Name은 연동하려는 서브프로그램이 외부 언어의 소스 코드에서 가지는 이름을 문자열로 명시하는 데 사용됩니다.

이것이 유용한 주된 이유는 다음과 같습니다.

  1. 이름의 불일치 해소: Ada에서의 명명 규칙이나 가독성을 위해 C 함수의 원래 이름과 다른 이름을 Ada 선언에 사용하고 싶을 때가 있습니다.
  2. Ada 예약어와의 충돌 회피: 만약 C 함수의 이름이 Ada의 예약어(예: begin, end, type)와 같다면, Ada에서 해당 이름을 직접 사용할 수 없으므로 다른 이름으로 선언하고 External_Name을 통해 원래 C 함수 이름을 지정해야 합니다.

Import 예시: C 라이브러리의 함수 이름은 get_system_status이지만, Ada에서는 fetch_status라는 이름으로 호출하고 싶은 경우

function fetch_status return Interfaces.C.int;
pragma Import (
   Convention    => C,
   Entity        => fetch_status,
   External_Name => "get_system_status" -- C 소스코드 상의 실제 이름
);

Export 예시: Ada로 작성된 Calculate_Monthly_Average 프로시저를 C에서는 calc_avg라는 짧은 이름으로 호출할 수 있도록 노출하는 경우

procedure Calculate_Monthly_Average (data : in Data_Array);
pragma Export (
   Convention    => C,
   Entity        => Calculate_Monthly_Average,
   External_Name => "calc_avg"
);

만약 External_Name이 생략되면, 컴파일러는 Entity에 지정된 Ada 이름(일반적으로 소문자로 변환)을 외부 이름으로 간주합니다. 하지만 코드의 명확성과 이식성을 위해 외부 이름을 명시적으로 지정하는 것이 좋은 설계 습관입니다.

Link_Name의 역할과 사용법

Link_NameExternal_Name보다 더 낮은 수준의 제어를 제공합니다. 이는 컴파일된 오브젝트 파일(.o, .obj)에 포함되어 링커가 최종적으로 참조해야 할 정확한 심볼 이름을 문자열로 지정합니다.

Link_Name이 필요한 경우는 다음과 같습니다.

  1. 컴파일러의 이름 장식(Name Decoration): 일부 C 컴파일러나 특정 플랫폼(예: 32비트 Windows)에서는 함수 이름에 접두사나 접미사(예: _, @8)를 붙여 최종 심볼 이름을 만듭니다. Link_Name을 사용하면 이렇게 변형된 이름을 직접 지정할 수 있습니다.
  2. 어셈블리와의 연동: 어셈블리 루틴의 레이블(label) 이름이 소스 코드에서의 호출 이름과 다를 경우 사용됩니다.

Link_NameExternal_Name의 관계:

  • Link_Name이 지정되면, 링커는 이 값을 사용합니다.
  • Link_Name이 생략되면, 링커는 External_Name의 값을 사용합니다.
  • 둘 다 생략되면, 링커는 Entity의 Ada 이름을 기반으로 한 이름을 사용합니다.

예시: C 함수 get_device가 컴파일 후 링커에게 _get_device라는 심볼로 노출되는 경우

function get_device return Device_Handle;
pragma Import (
   Convention    => C,
   Entity        => get_device,
   External_Name => "get_device", -- C 소스코드에서의 이름
   Link_Name     => "_get_device"  -- 링커가 찾아야 할 실제 심볼 이름
);

결론 및 권장 사항

  • **External_Name**은 ‘소스 코드 수준’에서의 이름 불일치를 해결하는 일반적인 도구입니다. 대부분의 표준적인 C 연동에서는 External_Name만으로 충분합니다.
  • **Link_Name**은 ‘오브젝트 코드/링커 수준’에서의 이름 불일치를 해결하는 저수준의 고급 도구입니다. 플랫폼 종속적인 이름 장식이나 특별한 링킹 요구사항이 있을 때 제한적으로 사용해야 합니다.

이 두 옵션을 올바르게 이해하고 사용함으로써, 프로그래머는 거의 모든 종류의 외부 라이브러리와의 이름 불일치 문제를 해결하고 매우 견고한 연동 코드를 작성할 수 있습니다.

16.3 C++ 언어와의 연동: 복잡성 다루기

C 언어와의 연동이 시스템 프로그래밍의 보편적인 요구사항이라면, C++ 연동은 현대적인 라이브러리와 프레임워크를 통합해야 하는 많은 응용 프로그램 개발자에게 주어진 현실적인 과제입니다. C++는 C와의 높은 호환성을 유지하므로, extern "C"로 선언된 C++ 함수나 간단한 데이터 구조체는 앞선 절에서 학습한 C 연동 기법을 그대로 사용하여 비교적 쉽게 연동할 수 있습니다.

그러나 C++는 C에는 없는 객체 지향 기능, 템플릿, 예외 처리 등 복잡한 언어적 특성을 가지고 있습니다. 이러한 특성들은 Ada와 C++의 경계를 넘나들 때 단순한 C 연동에서는 마주치지 못했던 새로운 차원의 복잡성을 야기합니다. 본 절의 부제인 ‘복잡성 다루기’는 바로 이러한 문제들을 해결하는 데 초점을 맞춥니다.

C++ 연동 시 마주하게 되는 주요 도전 과제는 다음과 같습니다.

  1. 이름 맹글링 (Name Mangling): C++ 컴파일러는 함수 오버로딩, 네임스페이스, 클래스 멤버 함수 등을 지원하기 위해, 원본 함수의 이름을 파라미터 타입 정보 등을 포함하는 고유한 심볼 이름으로 변환합니다. int MyClass::getValue(void) 와 같은 함수는 _ZN7MyClass8getValueEv 와 같은 복잡한 링커 심볼이 될 수 있습니다. 이 변환 규칙은 컴파일러마다 다르므로, Ada가 이 심볼을 정확히 찾아 연결하는 것은 매우 어려운 문제입니다.

  2. 객체 지향 특성:

    • 클래스와 생성자/소멸자: C++ 객체는 단순한 데이터 덩어리가 아니며, 반드시 생성자(constructor)를 통해 생성되고 소멸자(destructor)를 통해 정리되어야 합니다. Ada는 이러한 C++ 객체의 생명주기를 존중하는 방식으로 상호작용해야 합니다.
    • 상속과 가상 함수: C++의 다형성(polymorphism)은 가상 함수 테이블(v-table)이라는 저수준 메커니즘을 통해 구현됩니다. Ada의 태그드 타입이 C++의 클래스 계층 구조와 상호작용하기 위해서는 이 메모리 레이아웃을 정확히 맞추어야 합니다.
    • 예외 처리: C++의 throw와 Ada의 raise는 서로 다른 예외 처리 메커니즘입니다. 두 언어의 경계를 넘어 예외가 전파될 때, 프로그램의 안정성을 보장하기 위한 특별한 처리가 필요합니다.

이러한 복잡성을 다루기 위해, 본 절에서는 두 가지 주요 접근법을 학습합니다. 첫째는 C를 중간 매개체로 사용하여 C++의 복잡한 인터페이스를 단순한 C 함수로 감싸고, Ada는 이 C 인터페이스와 연동하는 안정적이고 이식성 높은 방법입니다. 둘째는 GNAT 컴파일러가 제공하는 pragma Convention (CPP)와 같은 강력한 확장 기능을 사용하여 C++의 이름 맹글링과 객체 레이아웃 규칙을 직접 이해하고 상호작용하는 직접적인 연동 방법입니다.

이 과정을 통해 독자께서는 C++ 클래스를 Ada 타입으로 표현하는 전략부터 생성자/소멸자 호출, 가상 함수 연동, 그리고 경계를 넘나드는 예외 처리 기법까지, C++와의 견고하고 안정적인 연동에 필요한 심층적인 지식과 기술을 습득하게 될 것입니다.

16.3.1 C++ 연동의 도전 과제: 이름 맹글링(Name Mangling)

C 언어와의 연동이 비교적 직관적인 이유는, C의 링커 심볼(linker symbol) 생성 규칙이 단순하기 때문입니다. C 함수 my_func는 컴파일된 오브젝트 파일에서 my_func 또는 _my_func와 같이 예측 가능한 이름의 심볼로 변환됩니다. 이러한 일대일 대응 관계 덕분에 Ada의 pragma Import에서 External_Name을 쉽게 지정할 수 있습니다.

그러나 C++는 C에는 없는 언어적 기능들—함수 오버로딩, 네임스페이스, 클래스 멤버 함수, 템플릿—을 지원해야 하므로, 훨씬 더 복잡한 심볼 이름 생성 규칙을 사용합니다. 예를 들어, 다음 두 C++ 함수는 이름이 같지만 파라미터가 다르므로 링커 수준에서 서로 구별되어야 합니다.

void print(int value);
void print(double value);

만약 두 함수가 모두 print라는 심볼로 변환된다면, 링커는 어떤 함수를 호출해야 할지 구분할 수 없습니다.

이 문제를 해결하기 위해, C++ 컴파일러는 함수 이름뿐만 아니라 해당 함수의 네임스페이스, 클래스, 파라미터 타입, const 한정자 등의 정보를 모두 포함하는, 매우 길고 복잡한 문자열을 생성하여 링커 심볼로 사용합니다. 이 과정을 이름 맹글링(Name Mangling) 또는 이름 장식(Name Decoration)이라고 합니다.

이름 맹글링 예시

다음과 같은 C++ 코드가 있다고 가정해 봅시다.

namespace Services {
  class Database {
  public:
    int get_connection_id() const;
  };
}

g++와 같은 일반적인 C++ 컴파일러는 Services::Database::get_connection_id 라는 멤버 함수를 다음과 유사한, 사람이 읽기 어려운 링커 심볼로 변환할 수 있습니다.

_ZNK8Services8Database17get_connection_idEv

이 심볼 이름은 특정 규칙(Itanium C++ ABI 등)에 따라 인코딩된 정보를 담고 있습니다.

  • _Z: 표준 심볼 시작 표시
  • N: 중첩된(Nested) 이름임을 표시 (네임스페이스나 클래스 안에 있음)
  • K: const 멤버 함수임을 표시
  • 8Services: 길이가 8인 Services 네임스페이스
  • 8Database: 길이가 8인 Database 클래스
  • 17get_connection_id: 길이가 17인 get_connection_id 함수
  • E: 중첩 종료
  • v: 파라미터가 void임을 표시

Ada 연동의 과제

이름 맹글링은 C++ 컴파일러와 링커에게는 유용한 정보를 제공하지만, Ada와 같은 다른 언어와의 연동에는 심각한 장벽이 됩니다. Ada의 pragma Import에 이 복잡하고 비표준적인 Link_Name을 수동으로 알아내어 지정하는 것은 매우 번거롭고 오류가 발생하기 쉬우며, C++ 컴파일러의 종류나 버전에 따라 맹글링 규칙이 바뀔 경우 코드가 깨지게 됩니다.

이것이 C++ 연동의 첫 번째이자 가장 큰 도전 과제입니다. 이 문제를 해결하기 위해 프로그래머는 두 가지 전략 중 하나를 선택해야 합니다.

  1. 회피 전략: C++ 코드에 extern "C"를 사용하여 특정 함수의 이름 맹글링을 비활성화하고, 단순한 C 스타일 인터페이스로 노출합니다. Ada는 이 C 인터페이스와 연동합니다.
  2. 정면 돌파 전략: GNAT 컴파일러가 제공하는 pragma Convention (CPP)를 사용하여, Ada 컴파일러가 C++의 이름 맹글링 규칙을 직접 이해하고 올바른 링커 심볼을 자동으로 생성하도록 합니다.

이어지는 절들에서는 이 두 가지 전략을 사용하여 이름 맹글링의 복잡성을 다루는 구체적인 방법을 학습하겠습니다.

16.3.2 pragma Convention (CPP)를 이용한 직접 연동

이름 맹글링(Name Mangling)이라는 복잡한 장벽을 우회하는 가장 안정적인 방법은 C++ 코드에 extern "C"를 사용하여 C 인터페이스를 만드는 것이지만, 이는 C++의 객체 지향적인 특성을 상당 부분 포기해야 하는 단점이 있습니다. 만약 C++의 클래스, 생성자, 멤버 함수 등을 Ada에서 직접적으로 사용하고 싶다면, GNAT 컴파일러가 제공하는 강력한 확장 기능인 **pragma Convention (CPP)**를 사용할 수 있습니다.

이 프라그마는 GNAT 컴파일러에게 연동 대상 코드가 C++의 규칙을 따름을 명시적으로 알리는 역할을 합니다. 이 지시어를 사용하면, GNAT는 Ada 코드의 구조를 분석하여 g++ 컴파일러의 이름 맹글링 규칙에 맞는 올바른 링커 심볼을 자동으로 생성해 줍니다. 이를 통해 프로그래머는 복잡한 맹글링된 이름을 수동으로 알아내야 하는 부담에서 벗어날 수 있습니다.

주의: pragma Convention (CPP)는 GNAT 컴파일러 고유의 기능입니다. 따라서 이 기능을 사용한 코드는 다른 Ada 컴파일러와는 호환되지 않을 수 있으므로, 프로젝트가 GNAT 툴체인에 기반하고 있을 때 사용하는 것이 적합합니다.

pragma convention (cpp)의 적용

  1. 타입에 대한 적용: C++ 클래스에 대응되는 Ada 레코드 타입에 pragma Convention (CPP, My_Type); 을 적용하면, GNAT는 해당 레코드의 메모리 레이아웃을 C++ 클래스와 호환되도록 조정합니다. 특히 tagged 타입의 경우, C++의 가상 함수 테이블(v-table) 포인터와 호환되는 구조를 만들어 다형적 연동의 기반을 마련합니다.

  2. 서브프로그램에 대한 적용: C++ 함수(멤버 함수 포함)를 pragma Import 할 때 Convention => CPP를 지정하면, GNAT는 Entity로 지정된 Ada 서브프로그램의 이름, 파라미터, 그리고 그것이 속한 패키지(C++ 클래스/네임스페이스에 대응) 정보를 조합하여 올바른 맹글링된 이름을 생성합니다.

예제: C++ 카운터 클래스 직접 호출

다음은 간단한 C++ Counter 클래스를 pragma Convention (CPP)를 이용해 Ada에서 직접 사용하는 예제입니다.

1. C++ 라이브러리 코드 (counter.h, counter.cpp)

// File: counter.h
class Counter {
public:
    Counter();          // 생성자
    ~Counter();         // 소멸자
    void increment();
    int get_value() const;
private:
    int value;
};
// File: counter.cpp
#include "counter.h"
#include <iostream>

Counter::Counter() : value(0) {
    std::cout << "C++ Counter created." << std::endl;
}
Counter::~Counter() {
    std::cout << "C++ Counter destroyed." << std::endl;
}
void Counter::increment() {
    this->value++;
}
int Counter::get_value() const {
    return this->value;
}

2. Ada 클라이언트 코드

Ada에서는 C++ 클래스를 tagged record로 매핑하고, 멤버 함수들을 해당 레코드를 첫 번째 파라미터로 받는 서브프로그램으로 선언합니다. 이 첫 번째 파라미터가 C++의 암묵적인 this 포인터에 해당됩니다.

-- File: main.adb
with Interfaces.C;
with Ada.Text_IO;
with Ada.Integer_Text_IO;

procedure Main is

   -- 1. C++ 클래스를 Ada 타입으로 매핑
   type Counter is tagged limited null record;
   pragma Convention (CPP, Counter);

   -- 2. 생성자, 소멸자, 멤버 함수들을 임포트
   function new_counter return Counter;
   pragma Import (CPP, new_counter, "Counter"); -- 생성자는 C++ 클래스 이름으로 임포트

   procedure free_counter (c : in out Counter);
   pragma Import (CPP, free_counter, "~Counter"); -- 소멸자는 ~클래스 이름으로 임포트

   procedure increment (c : in out Counter);
   pragma Import (CPP, increment); -- 멤버 함수 임포트

   function get_value (c : in Counter) return Interfaces.C.int;
   pragma Import (CPP, get_value);

   my_counter : Counter;
   value      : Interfaces.C.int;
begin
   Ada.Text_IO.put_line ("-- Creating Counter from Ada --");
   my_counter := new_counter; -- 생성자 호출

   increment (my_counter);
   increment (my_counter);

   value := get_value (my_counter);
   Ada.Text_IO.put ("Value from C++ object: ");
   Ada.Integer_Text_IO.put (value, 0);
   Ada.Text_IO.new_line;

   Ada.Text_IO.put_line ("-- Freeing Counter from Ada --");
   free_counter (my_counter); -- 소멸자 명시적 호출

end Main;

이 예제에서 볼 수 있듯이, 프로그래머는 External_Name에 복잡한 맹글링된 이름을 쓰는 대신 C++ 소스 코드에 나타나는 이름(Counter, ~Counter, increment)을 그대로 사용할 수 있습니다. GNAT 컴파일러가 Convention => CPP 지시어를 보고 나머지 복잡한 심볼 이름 생성 과정을 자동으로 처리해 주기 때문입니다.

pragma Convention (CPP)는 C++와의 긴밀한 통합이 필요할 때 매우 강력한 도구이지만, GNAT 종속성을 가진다는 점을 항상 인지하고 사용해야 합니다.

16.3.3 C++ 클래스 연동 설계 전략

pragma convention (cpp)pragma import는 C++의 기능을 Ada로 가져오는 저수준의 메커니즘을 제공합니다. 그러나 이 메커니즘을 바탕으로 ‘어떻게 C++ 클래스를 Ada의 타입 시스템 안에서 표현할 것인가’는 프로그래머가 결정해야 할 중요한 설계 문제입니다. 이 선택은 연동 코드의 안정성, 유지보수성, 그리고 Ada 코드와의 통합 수준을 결정합니다.

C++ 클래스를 연동하는 데에는 크게 두 가지의 상반된 설계 전략이 존재합니다.

전략 1: 불투명 포인터(Opaque Pointer)를 이용한 최소한의 연동

이 전략은 C++ 객체를 Ada 측에서는 그저 ‘정체를 알 수 없는 핸들(handle)’ 또는 ‘블랙박스를 가리키는 포인터’로 취급하는 방식입니다. C++ 클래스의 내부 데이터 구조나 크기에 대해서는 전혀 알 필요가 없으며, 오직 이 핸들을 받아 동작하는 C++ 함수들만을 Ada로 가져옵니다.

Ada 측 설계:

-- File: c_object_handle.ads
package C_Object_Handle is
   -- C++ 객체에 대한 핸들. 실제 객체의 내용은 Ada에 완전히 숨겨져 있습니다.
   type Object_Handle is limited private;

   -- C++의 'new'와 'delete'에 해당하는 함수를 임포트
   function create_object return Object_Handle;
   procedure free_object (handle : in out Object_Handle);

   -- C++의 멤버 함수에 해당하는 함수들을 임포트
   procedure do_something (handle : in Object_Handle; param : in Integer);

private
   type Object_Record; -- 불완전 타입 선언
   type Object_Handle is access all Object_Record;
end C_Object_Handle;

장점:

  • 단순함과 낮은 결합도(Loose Coupling): 설정이 매우 간단하며, C++ 클래스의 내부 구현(데이터 멤버 추가/삭제 등)이 변경되더라도, Ada 코드는 재컴파일할 필요가 없습니다. 인터페이스의 안정성이 매우 높습니다.
  • C-스타일 API에 적합: FILE* 핸들을 사용하는 C의 stdio 라이브러리와 같이, 이미 핸들 기반으로 설계된 API를 연동하는 데 이상적입니다.

단점:

  • 수동 자원 관리: C++ 객체의 생성(create_object)과 소멸(free_object)을 프로그래머가 직접 호출하여 관리해야 합니다. free_object 호출을 잊으면 메모리 누수가 발생합니다. Ada의 자동 자원 관리(RAII) 이점을 활용할 수 없습니다.
  • 제한된 통합: 이 핸들은 Ada의 객체 지향 모델(태그드 타입)에 통합될 수 없으므로, Ada 타입 계층 구조에 포함시키거나 다형적으로 활용할 수 없습니다.

전략 2: Ada 태그드 타입을 이용한 완전한 래핑(Wrapping)

이 전략은 C++ 클래스의 메모리 레이아웃과 동일한 구조를 갖는 Ada tagged 타입을 만들고, pragma Convention (CPP)를 통해 두 타입이 호환됨을 보장하는 방식입니다. 이는 C++ 객체를 Ada의 타입 시스템 안으로 완전히 가져와, 마치 원래 Ada 타입인 것처럼 다루는 것을 목표로 합니다.

Ada 측 설계:

-- File: cpp_class_wrapper.ads
package CPP_Class_Wrapper is
   type CPP_Object is tagged limited private;
   pragma Convention (CPP, CPP_Object);

   -- 프리미티브 연산으로 멤버 함수들을 임포트
   procedure do_something (this : in out CPP_Object; param : in Integer);

private
   -- C++ 클래스의 데이터 멤버와 정확히 일치하는 필드를 가져야 함
   type CPP_Object is tagged limited record
      field1 : Interfaces.C.int;
      field2 : Interfaces.C.double;
   end record;
end CPP_Class_Wrapper;

장점:

  • Ada다운(Ada-esque) 인터페이스: 클라이언트는 포인터가 아닌 일반 Ada 객체처럼 CPP_Object를 다룰 수 있습니다.
  • 자동 자원 관리(RAII) 가능:CPP_Object 타입을 Ada.Finalization.Controlled를 상속받는 타입 안에 멤버로 포함시키면, Ada 객체의 유효범위가 끝날 때 C++의 소멸자를 자동으로 호출하도록 구현할 수 있어 안전성이 크게 향상됩니다.
  • 완전한 객체 지향 통합: tagged 타입이므로, Ada 코드 내에서 이 타입을 상속받아 기능을 확장하거나 다른 Ada 타입들과 함께 다형적으로 사용하는 것이 가능합니다.

단점:

  • 높은 결합도(Tight Coupling): Ada 레코드의 정의가 C++ 클래스의 메모리 레이아웃에 강하게 의존합니다. 만약 C++ 클래스에 데이터 멤버가 추가되거나 순서가 바뀌면, Ada 코드 역시 반드시 수정하고 재컴파일해야 합니다.
  • 복잡성: C++ 클래스의 구조가 복잡하거나 가상 상속 등을 사용하는 경우, 동일한 메모리 레이아웃을 Ada에서 정확히 재현하는 것이 매우 어렵거나 불가능할 수 있습니다.

전략 선택 가이드

상황 권장 전략 이유
C++ 객체의 내부를 알 필요 없이, 제공된 함수만 호출하면 되는 경우 불투명 포인터 구현이 간단하고, C++ 측의 변경에 영향을 받지 않아 안정적입니다.
C++ 객체를 Ada의 자원 관리 체계에 통합하여 안전성을 높이고 싶은 경우 태그드 타입 래핑 RAII를 통해 메모리 누수를 방지하고, Ada다운 프로그래밍이 가능합니다.
C++ 클래스를 Ada의 타입 계층 구조에 포함시켜 확장/사용하고 싶은 경우 태그드 타입 래핑 tagged 타입의 특성을 활용하여 완전한 객체 지향 통합을 이룰 수 있습니다.

결론적으로, 두 전략은 단순성과 안정성, 그리고 추상화와 통합 수준 사이의 트레이드오프(trade-off) 관계에 있습니다. 프로젝트의 요구사항과 C++ 라이브러리의 특성을 고려하여 가장 적합한 전략을 신중하게 선택해야 합니다.

16.3.4 생성자(Constructor) 및 소멸자(Destructor) 호출

C++ 객체 지향 프로그래밍의 핵심 철학은 객체의 생명주기(lifecycle)를 엄격하게 관리하는 것입니다. 객체가 생성될 때는 반드시 **생성자(constructor)**가 호출되어 내부 상태를 초기화하고 필요한 자원을 할당하며, 객체가 소멸될 때는 반드시 **소멸자(destructor)**가 호출되어 사용했던 자원을 안전하게 해제합니다.

Ada가 C++ 객체를 연동할 때는 이 생명주기 규칙을 반드시 존중해야 합니다. Ada의 tagged recordpragma Convention (CPP)를 통해 C++ 클래스에 매핑했더라도, Ada 객체를 단순히 선언하는 것만으로는 C++ 생성자가 자동으로 호출되지 않습니다. 마찬가지로 Ada 객체의 유효범위(scope)가 끝나더라도 C++ 소멸자가 자동으로 호출되지 않습니다.

프로그래머는 pragma Import를 사용하여 C++의 생성자와 소멸자를 명시적으로 Ada로 가져와 호출해야 합니다.

생성자(Constructor) 임포트

C++ 생성자는 반환 값이 없고 클래스와 이름이 같은 특별한 함수입니다. Ada에서는 이를 해당 클래스 타입을 반환하는 함수로 매핑하여 임포트합니다.

pragma Import를 사용하여 생성자를 임포트할 때, External_Name으로는 C++ 클래스의 이름을 지정합니다. 파라미터가 없는 기본 생성자와 파라미터가 있는 생성자 모두 임포트할 수 있습니다.

예시:

// C++ Code
class Widget {
public:
    Widget();                 // 기본 생성자
    Widget(int initial_id);   // 파라미터가 있는 생성자
};
-- Ada Code
type Widget is tagged limited null record;
pragma Convention (CPP, Widget);

-- 기본 생성자 임포트
function new_widget return Widget;
pragma Import (CPP, new_widget, "Widget");

-- 파라미터가 있는 생성자 임포트
function new_widget (initial_id : Interfaces.C.int) return Widget;
pragma Import (CPP, new_widget, "Widget");

이제 Ada 코드에서는 이 함수들을 호출하여 C++ 생성자가 실행된, 올바르게 초기화된 객체를 얻을 수 있습니다.

my_widget_1 : Widget := new_widget;
my_widget_2 : Widget := new_widget (initial_id => 123);

소멸자(Destructor) 임포트

C++ 소멸자는 ~ 문자로 시작하는 특별한 이름의 멤버 함수입니다. Ada에서는 이를 해당 클래스 타입의 객체를 in out 파라미터로 받는 프로시저로 매핑하여 임포트합니다.

External_Name으로는 틸드(~)가 포함된 소멸자의 이름(예: ~Widget)을 지정합니다.

예시:

// C++ Code
class Widget {
public:
    ~Widget(); // 소멸자
};
-- Ada Code
type Widget is tagged limited ... ;
pragma Convention (CPP, Widget);

-- 소멸자 임포트
procedure free_widget (w : in out Widget);
pragma Import (CPP, free_widget, "~Widget");

Ada 코드에서 C++ 객체를 사용한 후에는, 반드시 이 free_widget 프로시저를 명시적으로 호출하여 C++ 소멸자가 실행되도록 해야 합니다. 그렇지 않으면 C++ 객체가 할당한 메모리나 자원이 해제되지 않아 누수가 발생합니다.

declare
   my_widget : Widget := new_widget;
begin
   -- my_widget 사용 ...
   free_widget (my_widget); -- 명시적인 소멸자 호출
end; -- 블록이 끝나도 자동 호출되지 않음

자동 자원 관리를 위한 설계 (RAII)

매번 수동으로 소멸자를 호출하는 것은 번거롭고 실수를 유발하기 쉽습니다. 이러한 문제를 해결하기 위한 더 나은 설계는, 임포트한 C++ 객체를 Ada.Finalization.Controlled를 상속받는 Ada 제어 타입(controlled type)으로 감싸는(wrapping) 것입니다.

이 래퍼(wrapper) 타입의 Finalize 프로시저를 재정의(override)하여 그 안에서 C++ 소멸자(free_widget)를 호출하도록 구현하면, 래퍼 객체의 유효범위가 끝날 때 Ada 런타임이 자동으로 Finalize를 호출해주므로 C++ 소멸자 역시 자동으로 호출되게 할 수 있습니다. 이 기법은 Ada의 강력한 RAII(Resource Acquisition Is Initialization) 패턴을 C++ 객체 관리에 적용하여 연동 코드의 안정성을 크게 향상시킵니다.

16.3.5 멤버 함수(Member Function) 호출

C++ 클래스의 멤버 함수는 특정 객체의 상태를 조회하거나 변경하는 연산을 수행합니다. C++ 컴파일러는 멤버 함수를 호출할 때, 해당 객체를 가리키는 this 포인터를 보이지 않는 첫 번째 파라미터로 함수에 전달합니다.

Ada에서 C++ 멤버 함수를 연동할 때는 바로 이 this 포인터의 존재를 명시적으로 모델링해야 합니다. 즉, C++의 멤버 함수는 해당 클래스에 대응되는 Ada 타입을 첫 번째 파라미터로 받는 Ada 서브프로그램으로 매핑됩니다.

멤버 함수 임포트 규칙

  1. this 포인터 매핑: 멤버 함수의 첫 번째 파라미터는 this 포인터에 해당하며, C++ 클래스에 대응되는 Ada 타입(예: My_Class_Type)으로 선언해야 합니다.
  2. const 멤버 함수: C++에서 const로 선언된 멤버 함수(객체의 상태를 변경하지 않음을 보증)는 Ada에서 in 모드의 파라미터로 매핑됩니다.
  3. 비-const 멤버 함수: 객체의 상태를 변경할 수 있는 비-const 멤버 함수는 Ada에서 in out 모드의 파라미터로 매핑됩니다.

예제: C++ 멤버 함수 호출

14.3.2절에서 사용했던 Counter 클래스 예제를 다시 사용하여 멤버 함수 연동을 살펴보겠습니다.

// C++ Code (counter.h)
class Counter {
public:
    // ... 생성자, 소멸자 ...
    void increment();           // 비-const 멤버 함수
    int get_value() const;      // const 멤버 함수
private:
    int value;
};

이 클래스의 멤버 함수들을 임포트하는 Ada 코드는 다음과 같습니다.

-- Ada Code
with Interfaces.C;
package Counter_Wrapper is

   type Counter is tagged limited null record;
   pragma Convention (CPP, Counter);

   -- ... 생성자, 소멸자 임포트 ...

   -- void increment();
   -- 비-const 멤버 함수이므로 'this' 포인터는 'in out' 모드
   procedure increment (c : in out Counter);
   pragma Import (CPP, increment);

   -- int get_value() const;
   -- const 멤버 함수이므로 'this' 포인터는 'in' 모드
   function get_value (c : in Counter) return Interfaces.C.int;
   pragma Import (CPP, get_value);

end Counter_Wrapper;

Ada 클라이언트 코드에서의 호출:

Ada 클라이언트는 일반적인 서브프로그램을 호출하듯이, 객체를 첫 번째 인자로 전달하여 C++ 멤버 함수를 호출합니다.

with Counter_Wrapper;
with Ada.Text_IO;
...
procedure Test_Counter is
   my_counter : Counter_Wrapper.Counter := Counter_Wrapper.new_counter;
   value      : Interfaces.C.int;
begin
   -- Counter_Wrapper.increment(my_counter) 호출은
   -- 내부적으로 C++의 my_counter.increment()를 호출하는 것과 같습니다.
   Counter_Wrapper.increment (my_counter);
   Counter_Wrapper.increment (my_counter);

   -- Counter_Wrapper.get_value(my_counter) 호출은
   -- 내부적으로 C++의 my_counter.get_value()를 호출하는 것과 같습니다.
   value := Counter_Wrapper.get_value (my_counter);

   Ada.Text_IO.put_line ("Value is: " & Interfaces.C.int'image (value));

   Counter_Wrapper.free_counter (my_counter);
end Test_Counter;

정적(static) 멤버 함수와의 차이점

C++의 정적 멤버 함수는 특정 객체에 소속되지 않고 클래스 자체에 소속되므로 this 포인터를 갖지 않습니다. 따라서 Ada에서 정적 멤버 함수를 임포트할 때는, 첫 번째 파라미터로 객체를 전달할 필요 없이 일반 C 함수와 동일한 방식으로 선언하고 pragma Import를 적용하면 됩니다.

이처럼 Ada는 C++의 멤버 함수 호출 규칙을 this 포인터에 대한 명시적인 파라미터 전달로 변환하여, 두 언어 간의 객체 지향적인 상호작용을 가능하게 합니다. 이 규칙을 올바르게 이해하는 것은 C++ 클래스의 기능을 Ada에서 온전히 활용하기 위한 필수적인 과정입니다.

16.3.6 [심화] 상속(Inheritance)과 가상 함수(Virtual Function) 연동

C++ 객체 지향 프로그래밍의 진정한 힘은 상속과 이를 통한 다형성(polymorphism)에서 나옵니다. 다형성은 부모 클래스 타입의 포인터나 참조를 통해 자식 클래스 객체의 특화된 동작(재정의된 가상 함수)을 호출할 수 있게 하는 능력입니다. 이 강력한 메커니즘은 가상 함수 테이블(virtual function table, 이하 v-table) 이라는 저수준 구현에 의존합니다.

C++ 컴파일러는 가상 함수를 하나 이상 가진 클래스의 객체 메모리 레이아웃에, 해당 클래스의 v-table을 가리키는 숨겨진 포인터(vptr)를 추가합니다. 어떤 객체의 가상 함수가 호출될 때, 프로그램은 이 vptr을 통해 v-table에 접근하고, 테이블에서 실제 호출되어야 할 함수의 주소를 찾아 동적으로 실행을 전환합니다.

Ada가 C++의 상속 구조 및 다형성과 연동하기 위해서는, 바로 이 v-table을 포함하는 메모리 레이아웃과 가상 함수 호출 메커니즘을 정확하게 모방할 수 있어야 합니다. 이는 GNAT 컴파일러와 pragma Convention (CPP)의 정교한 지원을 통해 가능합니다.

상속 관계 매핑

C++의 클래스 상속 관계는 Ada의 태그드 타입(tagged type) 확장(extension)에 직접적으로 매핑됩니다.

C++ 코드:

class Shape {
public:
    virtual double get_area() const;
};

class Circle : public Shape {
public:
    Circle(double radius);
    virtual double get_area() const override; // 재정의된 가상 함수
private:
    double r;
};

Ada 코드:

-- 부모 클래스 매핑
type Shape is tagged limited null record;
pragma Convention (CPP, Shape);

function get_area (s : in Shape) return Interfaces.C.double;
pragma Import (CPP, get_area);

-- 자식 클래스 매핑 (Ada의 타입 확장 사용)
type Circle is new Shape with record
   r : Interfaces.C.double;
end record;
pragma Convention (CPP, Circle);

-- 재정의된 가상 함수 임포트
overriding -- 'overriding' 키워드가 중요
function get_area (c : in Circle) return Interfaces.C.double;
pragma Import (CPP, get_area);

Ada의 is new Shape with record ... 구문은 C++의 public Shape 상속과 동일한 의미를 가집니다. GNAT 컴파일러는 이 구조를 보고, Circle 타입의 메모리 레이아웃이 C++의 Circle 클래스와 호환되도록(부모 부분 + 자식 부분 + vptr) 구성합니다.

가상 함수 호출

Ada 측에서 재정의된 가상 함수를 임포트할 때는 overriding 키워드를 반드시 명시해야 합니다. 이는 해당 함수가 부모의 프리미티브 연산을 재정의하는 것임을 명시하며, 다형적 호출의 대상이 됨을 알립니다.

이제 Ada 코드에서 클래스-와이드 타입('Class)을 사용하여 다형적인 호출을 수행할 수 있습니다.

procedure Print_Area (s : in Shape'Class) is
   -- s는 Shape일 수도, Circle일 수도, 또는 다른 자식 타입일 수도 있습니다.
   area : Interfaces.C.double;
begin
   -- s.get_area() 호출은 C++의 가상 디스패치와 동일하게 동작합니다.
   -- s의 실제 타입(tag)에 따라 올바른 get_area 함수가 동적으로 호출됩니다.
   area := get_area (s);
   -- ...
end Print_Area;

Print_Area 프로시저는 Shape의 모든 자식 타입을 받을 수 있습니다. 만약 Circle 객체를 인자로 전달하면, get_area(s) 호출은 Circle에 대해 임포트된 get_area를 실행하게 됩니다. 이 모든 과정은 GNAT 런타임이 C++의 v-table 호출 메커니즘과 호환되는 코드를 생성해주기 때문에 가능합니다.

주의사항 및 한계

  • 다중 상속 및 가상 상속: C++의 다중 상속(multiple inheritance)이나 가상 상속(virtual inheritance)과 같은 복잡한 상속 구조는 Ada에서 직접적으로 매핑하기 매우 어렵거나 불가능합니다. 이러한 경우, C++ 측에서 단순 상속을 사용하는 래퍼 클래스를 만들고 Ada는 이 래퍼와 연동하는 전략이 필요합니다.
  • 컴파일러 의존성: 이 기능은 GNAT 컴파일러와 대상 C++ 컴파일러 간의 ABI(Application Binary Interface) 호환성에 깊이 의존합니다. 따라서 컴파일러 버전이나 종류가 바뀌면 연동이 깨질 수 있습니다.

상속과 가상 함수 연동은 C++와의 통합 수준을 최대로 높일 수 있는 강력한 기능이지만, 그만큼 복잡성과 잠재적인 위험성을 내포하고 있습니다. 따라서 이 기능을 사용하기 전에는 반드시 연동하려는 C++ 클래스의 상속 구조를 명확히 이해하고, 충분한 테스트를 통해 호환성을 검증하는 과정이 필수적입니다.

16.3.7 [심화] 예외(Exception) 처리 경계 넘기

Ada와 C++는 모두 강력한 예외 처리 메커니즘을 가지고 있지만, 그 내부 구현 방식은 완전히 다릅니다. 이질적인 두 예외 처리 시스템이 만나는 언어 경계에서는, 예외가 적절히 처리되지 않을 경우 프로그램의 비정상적인 종료나 미정의 동작(undefined behavior)을 유발할 수 있습니다. 따라서 견고한 연동 시스템을 구축하기 위해서는 예외가 언어 경계를 넘나들 때의 동작을 명확히 이해하고 제어해야 합니다.

C++ 예외가 Ada로 전파되는 경우

GNAT 컴파일러 환경에서는 Ada에서 호출한 C++ 함수가 예외(throw)를 발생시키고 그 예외가 C++ 측에서 잡히지 않았을 때, GNAT 런타임 시스템이 이를 가로채서 특별한 Ada 예외로 변환하여 전파합니다.

1. 일반적인 처리 (when others)

가장 간단하고 일반적인 방법은 Ada의 when others 예외 핸들러를 사용하여 알 수 없는 외부 예외를 잡는 것입니다.

begin
   -- C++로 구현된 프로시저 호출
   imported_cpp_procedure;
exception
   when others =>
      -- C++에서 발생한 예외가 여기서 잡힙니다.
      Ada.Text_IO.put_line ("A foreign exception from C++ was caught.");
      -- 리소스 정리나 복구 로직 수행
end;

Ada.Exceptions 패키지를 사용하면 잡힌 예외가 외부 예외(foreign exception)인지 등을 확인할 수 있습니다.

2. 특정 C++ 예외 임포트 및 처리

더 정교한 제어를 위해, GNAT는 특정 C++ 예외 타입을 Ada의 예외 선언과 연결하는 기능을 제공합니다. 이를 통해 C++의 std::out_of_range 나 사용자 정의 예외 등을 Ada 코드에서 이름으로 직접 잡을 수 있습니다.

이는 pragma Import (CPP, ...)를 예외 선언에 적용하여 이루어집니다. 이때 C++ 예외 타입을 식별하기 위해 해당 타입의 RTTI(Run-Time Type Information) 심볼 이름을 External_Name으로 지정해야 합니다.

-- C++의 'My_Error' 클래스 예외를 임포트
package Cpp_Interface is
   My_Error : exception;
   pragma Import (CPP, My_Error, "_ZTI8My_Error'Class"); -- RTTI 심볼 이름 (컴파일러에 따라 다름)

   procedure do_risky_cpp_operation;
   pragma Import (CPP, do_risky_cpp_operation);
end Cpp_Interface;

-- 클라이언트 코드
begin
   Cpp_Interface.do_risky_cpp_operation;
exception
   when Cpp_Interface.My_Error =>
      -- 이제 특정 C++ 예외를 이름으로 처리할 수 있습니다.
      Ada.Text_IO.put_line ("Caught specific C++ exception: My_Error");
   when others =>
      Ada.Text_IO.put_line ("Caught some other exception.");
end;

이 기법은 C++ 라이브러리의 오류 조건을 Ada에서 세분화하여 처리해야 할 때 매우 강력한 기능을 제공합니다. GNAT은 GNAT.CPP_Exceptions와 같은 추가적인 패키지를 통해 잡힌 C++ 예외 객체의 주소나 타입 정보를 얻는 등의 고급 기능도 지원합니다.

Ada 예외가 C++로 전파되는 경우

Ada 예외를 C++ 코드로 전파시키는 것은 절대로 허용해서는 안 되는 매우 위험한 설계입니다. C++ 런타임은 Ada의 예외 처리 메커니즘을 전혀 알지 못하므로, Ada 예외를 올바르게 처리(스택 해제, 객체 소멸 등)할 수 없습니다. 이는 거의 항상 프로그램의 즉각적인 비정상 종료로 이어집니다.

따라서, C++에서 호출할 수 있도록 pragma Export된 모든 Ada 서브프로그램은 반드시 자신의 내부에 견고한 예외 처리 블록을 가져야 합니다.

올바른 설계 패턴: 예외를 에러 코드로 변환

C++에 노출되는 Ada 서브프로그램은 최상위 수준에 begin ... exception ... end 블록을 두어, 내부에서 발생하는 모든 예외를 잡아야 합니다. 그리고 예외 핸들러 안에서는 발생한 오류를 나타내는 **에러 코드(error code)**나 상태 값을 out 파라미터나 함수 반환 값을 통해 C++ 호출자에게 전달해야 합니다.

-- ada_module.ads
package Ada_Module is
   procedure do_something_safely (status : out Interfaces.C.int);
   pragma Export (C, do_something_safely, "do_something_safely");
end Ada_Module;

-- ada_module.adb
package body Ada_Module is
   procedure do_something_safely (status : out Interfaces.C.int) is
      OK      : constant Interfaces.C.int := 0;
      ERROR   : constant Interfaces.C.int := -1;
   begin
      -- ... 잠재적으로 예외를 발생시킬 수 있는 로직 ...
      status := OK; -- 성공 시 0 반환
   exception
      when others =>
         status := ERROR; -- 예외 발생 시 -1 반환
   end do_something_safely;
end Ada_Module;

C++ 코드는 이 do_something_safely 함수를 호출한 뒤, status 값을 확인하여 작업의 성공 여부를 판단할 수 있습니다. 이처럼 언어 경계에서는 예외를 전파하는 대신, 서로가 이해할 수 있는 가장 간단하고 안정적인 오류 보고 메커니즘(에러 코드)으로 변환하는 것이 원칙입니다.

16.4 [저수준] 어셈블리 코드 통합

고수준 프로그래밍 언어의 근본적인 목표 중 하나는 추상화를 통해 프로그래머를 특정 하드웨어 아키텍처의 세부사항으로부터 분리하는 것입니다. 그러나 시스템 프로그래밍의 특정 영역에서는 이러한 추상화 계층을 의도적으로 통과하여 기계 수준의 제어를 직접 수행해야 할 필요성이 발생합니다. 이러한 요구사항은 다음의 세 가지 범주로 분류될 수 있습니다.

  1. 성능 임계 구간 최적화 (Performance-Critical Section Optimization): 컴파일러의 최적화 알고리즘이 특정 프로세서의 SIMD(Single Instruction, Multiple Data) 연산과 같은 특수 명령어 셋을 충분히 활용하지 못할 경우, 수동으로 작성된 어셈블리 코드가 더 높은 연산 처리량을 달성할 수 있습니다.
  2. 특권 명령어 접근 (Privileged Instruction Access): 운영체제 커널이나 하드웨어 드라이버 개발 시, 인터럽트 제어, 캐시 관리, 또는 특정 시스템 레지스터 조작과 같이 사용자 모드에서는 접근할 수 없는 특권 명령어를 실행해야 합니다.
  3. 하드웨어 직접 인터페이스 (Direct Hardware Interface): 메모리 맵 입출력(Memory-Mapped I/O)을 통해 특정 포트에 값을 쓰거나 읽는 등, 언어의 표준 입출력 라이브러리를 통하지 않는 하드웨어 직접 제어가 요구될 때 사용됩니다.

어셈블리 코드의 통합은 필연적으로 프로그램의 이식성(portability) 상실과 유지보수성(maintainability) 저하를 야기하므로, 그 사용은 성능 및 기능 요구사항을 만족시키기 위한 다른 모든 대안이 소진되었을 때로 한정되어야 합니다.

Ada는 이러한 저수준 제어의 필요성을 인지하고, 비표준적인 컴파일러 확장에 의존하는 대신 System.Machine_Code 패키지와 pragma Import (Assembler)를 통해 어셈블리 코드를 통합하는 표준화된 방법을 제공합니다. 본 절에서는 이 표준 메커니즘들을 사용하여 Ada의 타입 안전성 경계 내에서 기계 코드를 제어된 방식으로 통합하는 기법과 그에 따르는 공학적 책임을 기술하고자 합니다.

16.4.1 하드웨어 직접 제어를 위한 어셈블리 연동

Ada 언어는 표현식 절(representation clause)이나 System 패키지와 같은 저수준 기능을 통해 변수나 타입을 특정 메모리 주소에 배치하는 등, 하드웨어에 대한 상당한 수준의 제어 능력을 제공합니다. 그러나 이러한 기능들만으로는 모든 하드웨어 제어 요구사항을 충족시킬 수 없습니다. 그 이유는 특정 하드웨어 제어 동작이 데이터의 ‘배치’ 문제가 아닌, 특정 기계 명령어를 ‘실행’하는 문제이기 때문입니다.

표준 Ada 구문으로는 직접 표현할 수 없는 대표적인 하드웨어 제어 시나리오는 다음과 같습니다.

  • 포트 기반 입출력 (Port-Mapped I/O): x86과 같은 일부 프로세서 아키텍처는 메모리 주소 공간과는 별개인 입출력 포트(I/O Port) 주소 공간을 가집니다. 이러한 포트에 데이터를 쓰거나 읽기 위해서는 OUT이나 IN과 같은 전용 어셈블리 명령어를 사용해야만 합니다. Ada의 일반적인 대입문(:=)은 메모리에 대한 읽기/쓰기(load/store) 명령으로 컴파일되므로, 이 포트 공간에 접근할 수 없습니다.

  • 특수 시스템 레지스터 조작: 프로세서는 자신의 동작을 제어하는 다양한 특수 목적 레지스터(예: 컨트롤 레지스터, 디버그 레지스터)를 가지고 있습니다. 이러한 레지스터의 값을 변경하는 작업은 일반적인 데이터 이동 명령어가 아닌, MOV CR0, EAX와 같은 특정 시스템 명령어를 요구합니다.

  • 프로세서 상태 제어: 인터럽트를 전역적으로 비활성화(CLI - Clear Interrupt Flag)하거나 활성화(STI - Set Interrupt Flag)하는 행위, 또는 캐시(cache)를 무효화(invalidate)하거나 TLB(Translation Lookaside Buffer)를 갱신하는 등의 동작은 모두 프로세서의 상태를 직접 변경하는 고유한 기계 명령어를 실행해야만 가능합니다.

이러한 연산들은 본질적으로 특정 CPU 아키텍처에 깊이 종속되어 있으며, 컴파일러가 일반적인 Ada 코드를 통해 안정적으로 생성해 줄 것을 기대할 수 없습니다. 컴파일러의 최적화 과정에서 의도치 않은 방식으로 명령의 순서가 바뀌거나, 여러 개의 명령으로 분리되거나, 혹은 아예 제거될 수도 있습니다.

따라서, 하드웨어의 동작을 원자적(atomic)이고 예측 가능한 단일 기계 명령어로 제어해야 한다는 요구사항이 발생했을 때, 어셈블리 코드를 직접 통합하는 것이 유일하고 필연적인 해결책이 됩니다. 어셈블리 연동은 프로그래머가 컴파일러의 개입을 최소화하고 원하는 기계어 코드가 정확히 생성되어 실행됨을 보증할 수 있는 가장 직접적인 수단을 제공합니다. 이어지는 절들에서는 이러한 어셈블리 코드를 Ada 프로그램에 안전하게 통합하는 표준적인 기법들을 학습하게 될 것입니다.

16.4.2 System.Machine_Code를 이용한 인라인 어셈블리

인라인 어셈블리(inline assembly)는 Ada와 같은 고수준 언어의 서브프로그램 내부에 어셈블리 명령어를 직접 삽입하는 기법입니다. 이는 별도의 어셈블리 파일을 작성하고 링크하는 번거로움 없이, 특정 코드 위치에서 기계어를 정밀하게 제어해야 할 때 사용되는 강력한 저수준 프로그래밍 기능입니다.

Ada 2022 레퍼런스 매뉴얼은 이러한 기능을 위해 System.Machine_Code라는 표준 패키지를 정의하고 있습니다. 이 패키지는 기계어 명령어를 표현하고 삽입하기 위한 표준적인 틀을 제공하는 것을 목표로 합니다.

그러나 기계어 명령어 자체가 특정 CPU 아키텍처에 깊이 종속되어 있으므로, System.Machine_Code의 실제 사용법은 컴파일러 구현체(implementation)에 따라 크게 달라집니다. 본 절에서는 가장 널리 사용되는 GNAT 컴파일러의 구현 방식을 중심으로 인라인 어셈블리 사용법을 설명합니다.

GNAT의 인라인 어셈블리: Asm 프로시저

GNAT 환경에서는 System.Machine_Code 패키지의 Asm이라는 프로시저를 통해 인라인 어셈블리를 사용합니다. 이 프로시저는 C/C++ 개발자에게 친숙한 GCC의 asm 구문과 매우 유사한 인터페이스를 가집니다.

가장 기본적인 형태의 Asm 프로시저는 실행할 어셈블리 명령어들을 담은 문자열을 파라미터로 받습니다.

with System.Machine_Code;
procedure Disable_Interrupts is
   use System.Machine_Code;
begin
   Asm ("cli"); -- x86 아키텍처에서 인터럽트를 비활성화하는 명령어
end Disable_Interrupts;

procedure Enable_Interrupts is
   use System.Machine_Code;
begin
   Asm ("sti"); -- x86 아키텍처에서 인터럽트를 활성화하는 명령어
end Enable_Interrupts;

위 예제에서 Asm ("cli"); 호출은 컴파일 시점에 단일 cli 기계어 명령어로 대체됩니다. 이를 통해 프로그래머는 Ada 코드 내에서 직접적으로 프로세서의 인터럽트 상태를 제어할 수 있습니다.

Ada 변수와의 데이터 교환

단순한 명령어 삽입을 넘어, Ada 변수의 값을 어셈블리 코드에서 읽거나 어셈블리 연산의 결과를 Ada 변수에 저장하는 것도 가능합니다. 이는 Asm 프로시저의 InputsOutputs 파라미터를 통해 이루어집니다.

이 과정은 컴파일러에게 Ada 변수와 CPU 레지스터 또는 메모리 피연산자(operand)를 어떻게 연결할지 알려주는 복잡한 ‘제약 조건 문자열(constraint string)’을 사용해야 합니다.

다음은 두 개의 32비트 부호 없는 정수를 더하는 x86-64 어셈블리 코드를 인라인으로 삽입하는 예제입니다.

with System.Machine_Code;

procedure Add_In_Assembly
  (A       : in  Interfaces.Unsigned_32;
   B       : in  Interfaces.Unsigned_32;
   Result  : out Interfaces.Unsigned_32)
is
   use System.Machine_Code;
begin
   Asm (Template => "mov %1, %0; add %2, %0", -- %1을 %0에 복사, %2를 %0에 더함
        Outputs  => (Unsigned_32'Asm_Output ("=r", Result)), -- %0 (출력, 레지스터)
        Inputs   => (Unsigned_32'Asm_Input ("r", A),      -- %1 (입력, 레지스터)
                     Unsigned_32'Asm_Input ("r", B)));     -- %2 (입력, 레지스터)
end Add_In_Assembly;
  • Template: 실행될 어셈블리 명령어들의 템플릿입니다. %0, %1 등은 피연산자를 가리키는 플레이스홀더입니다.
  • Outputs: 어셈블리 연산의 결과가 저장될 Ada 변수(Result)를 지정합니다. "=r"는 “쓰기 전용이며, 임의의 범용 레지스터를 사용하라”는 의미의 제약 조건입니다.
  • Inputs: 어셈블리 연산에 입력으로 사용될 Ada 변수(A, B)를 지정합니다. "r"은 “읽기 전용이며, 임의의 범용 레지스터를 사용하라”는 의미입니다.

컴파일러는 이 정보를 바탕으로 AB의 값을 적절한 레지스터에 로드하고, Template의 명령어를 실행한 뒤, 결과 레지스터의 값을 Result 변수에 다시 저장하는 코드를 생성합니다.

주의사항

인라인 어셈블리는 컴파일러의 최적화 과정을 방해할 수 있으며, 코드의 이식성을 완전히 파괴하는 매우 강력하고 위험한 도구입니다. 어셈블리 템플릿의 작은 실수 하나가 예측할 수 없는 시스템 오류를 유발할 수 있으므로, 해당 CPU 아키텍처에 대한 깊은 이해를 바탕으로 반드시 필요한 경우에만 최소한으로 사용해야 합니다.

16.4.3 외부 어셈블리 루틴 호출 (pragma import (assembler))

인라인 어셈블리가 Ada 코드 내에 짧은 기계어 시퀀스를 삽입하는 데 적합하다면, 더 크고 독립적인 기능을 어셈블리어로 작성해야 할 때는 구현부를 별도의 파일로 분리하는 것이 더 구조적인 방법입니다. Ada는 이렇게 외부 파일에 작성된 어셈블리 서브프로그램(루틴)을 pragma Import를 통해 호출할 수 있는 표준적인 방법을 제공합니다.

이 접근법은 pragma Import (C, ...)를 사용하여 외부 C 함수를 호출하는 것과 매우 유사한 원리로 동작합니다. Ada 컴파일러에게 특정 서브프로그램의 구현이 외부에 존재하며, 그 호출 규약이 Assembler임을 알려 링커가 최종 실행 파일을 생성할 때 해당 어셈블리 루틴의 오브젝트 파일과 연결하도록 지시합니다.

작업 흐름

외부 어셈블리 루틴을 연동하는 과정은 다음과 같은 단계로 이루어집니다.

  1. 어셈블리 소스 파일 작성: .s 또는 .asm 확장자를 가진 별도의 파일에 어셈블리 루틴을 작성합니다. 이 루틴은 반드시 해당 플랫폼의 표준 **ABI(Application Binary Interface)**를 준수해야 합니다. ABI는 파라미터가 어떤 레지스터와 스택을 통해 전달되고, 반환 값이 어떤 레지스터에 위치해야 하는지 등을 정의하는 저수준 호출 규약입니다.
  2. 어셈블: 작성된 어셈블리 소스 파일을 어셈블러(예: as, nasm)를 사용하여 오브젝트 파일(.o, .obj)로 변환합니다.
  3. Ada에서의 선언 및 임포트: Ada 코드에서는 호출하려는 어셈블리 루틴에 대응하는 서브프로그램 명세를 선언하고, 그 직후에 pragma Import (Assembler, ...)를 사용하여 외부 심볼임을 명시합니다.
  4. 링크: Ada 코드를 컴파일한 후, 2단계에서 생성된 어셈블리 오브젝트 파일과 함께 링크하여 최종 실행 파일을 생성합니다.

예제: 외부 어셈블리 덧셈 함수 호출

두 개의 64비트 정수를 더하는 함수를 어셈블리어로 작성하고 이를 Ada에서 호출하는 예제입니다. (x86-64 Linux ABI 기준)

1. 어셈블리 소스 파일 (math_utils.s)

x86-64 System V ABI에 따르면, 첫 번째 정수 인자는 %rdi, 두 번째는 %rsi 레지스터를 통해 전달되며, 반환 값은 %rax 레지스터에 저장해야 합니다.

# File: math_utils.s
.global add_longs   # 링커가 찾을 수 있도록 심볼을 전역(global)으로 선언
.text

# 64비트 정수 a와 b를 더하여 반환하는 함수
# a in %rdi, b in %rsi
add_longs:
    movq    %rdi, %rax      # 첫 번째 인자(a)를 반환 레지스터(%rax)로 복사
    addq    %rsi, %rax      # 두 번째 인자(b)를 %rax에 더함
    ret                     # %rax에 저장된 값을 반환하며 복귀

2. Ada 클라이언트 코드

Ada에서는 Long_Integer (일반적으로 64비트)를 사용하여 어셈블리 루틴의 인터페이스와 타입을 맞춥니다.

-- File: main.adb
with Ada.Text_IO;
with Ada.Long_Integer_Text_IO;

procedure Main is

   -- 외부 어셈블리 함수에 대한 Ada 선언
   function add_longs (a : in Long_Integer; b : in Long_Integer)
      return Long_Integer;

   -- pragma를 사용하여 위 선언을 외부 어셈블리 심볼 "add_longs"와 연결
   pragma Import (Assembler, add_longs, "add_longs");

   val1   : constant Long_Integer := 100;
   val2   : constant Long_Integer := 234;
   result : Long_Integer;

begin
   result := add_longs (val1, val2);

   Ada.Text_IO.put ("Result from Assembly: ");
   Ada.Long_Integer_Text_IO.put (result);
   Ada.Text_IO.new_line;
end Main;

3. 빌드 및 링크

# 1. 어셈블리 파일을 오브젝트 파일로 어셈블
as -o math_utils.o math_utils.s

# 2. Ada 코드를 컴파일하고 어셈블리 오브젝트 파일과 함께 링크
gnatmake main.adb -largs math_utils.o

ABI 준수의 중요성

이 기법의 성공은 전적으로 어셈블리 코드가 해당 플랫폼의 ABI를 정확히 준수하는지에 달려 있습니다. 파라미터를 받는 레지스터, 스택 사용 규칙, 반환 값을 저장하는 레지스터가 ABI와 일치하지 않으면, 프로그램은 올바르게 동작하지 않거나 예측할 수 없는 오류를 일으킬 것입니다. 따라서 외부 어셈블리 루틴을 작성하는 프로그래머는 대상 시스템의 ABI에 대한 명확한 이해가 반드시 필요합니다.

pragma Import (Assembler)는 인라인 어셈블리에 비해, 잘 정의된 독립적인 어셈블리 함수를 Ada 프로젝트에 통합하는 더 구조적이고 깔끔한 방법을 제공합니다.

16.5 기타 언어 연동 표준 (간략한 소개)

14.5 기타 언어 연동 표준 (간략한 소개)

지금까지 본 장에서는 현대 소프트웨어 개발에서 가장 빈번하게 마주치는 C, C++, 그리고 어셈블리와의 연동 기법에 대해 집중적으로 학습했습니다. 그러나 Ada의 상호 운용성(interoperability)에 대한 설계 철학은 이보다 더 넓은 범위를 포괄합니다. Ada는 특정 컴파일러의 확장에만 의존하지 않고, 언어 표준 차원에서 다른 주요 프로그래밍 언어와의 연동을 위한 인터페이스를 정의하고 있습니다.

이는 Ada가 다양한 언어로 구성된 대규모 레거시(legacy) 시스템의 유지보수 및 현대화 프로젝트에서도 중요한 역할을 수행할 수 있도록 설계되었음을 보여줍니다. 본 절에서는 C/C++ 외에, Ada 2022 레퍼런스 매뉴얼이 표준으로 정의하고 있는 Fortran과 COBOL 연동 인터페이스에 대해 간략히 소개하고자 합니다.

Fortran 연동 (Interfaces.Fortran)

Fortran은 과학 기술 계산 및 고성능 컴퓨팅(HPC) 분야에서 수십 년간 사용되어 온 언어로, 특히 다차원 배열(multi-dimensional array) 연산에 매우 강력한 성능을 보입니다. Ada는 이러한 Fortran 라이브러리의 수치 연산 능력을 활용할 수 있도록 Interfaces.Fortran 표준 패키지를 제공합니다.

이 패키지는 Fortran의 기본 타입(Integer, Real, Double_Precision, Complex 등)에 대응되는 Ada 타입을 정의합니다. 또한, 두 언어 간의 가장 큰 차이점 중 하나인 배열의 메모리 저장 방식(Ada는 행 우선, Fortran은 열 우선)을 처리하고, Fortran의 서브루틴 호출 규약을 따르도록 pragma Convention (Fortran)을 지원합니다.

COBOL 연동 (Interfaces.COBOL)

COBOL은 금융, 보험, 정부 기관 등 대규모 비즈니스 트랜잭션 처리를 위한 메인프레임(mainframe) 환경에서 여전히 핵심적인 역할을 수행하고 있는 언어입니다. COBOL로 작성된 수많은 기간계 시스템과의 데이터 교환 및 연동을 위해, Ada는 Interfaces.COBOL 표준 패키지를 제공합니다.

이 패키지는 COBOL의 고유한 데이터 타입, 특히 다양한 형식의 숫자 표현(예: Packed_Decimal, Display)과 복잡한 레코드 구조를 Ada에서 표현할 수 있는 타입들을 정의합니다. 또한 pragma Convention (COBOL)을 통해 COBOL 프로그램의 호출 규약을 지원합니다.

본 서에서 이 언어들의 연동 방법을 상세히 다루지는 않지만, 이들의 존재 자체는 Ada가 단순히 새로운 시스템을 구축하는 언어를 넘어, 여러 시대와 기술 스택에 걸쳐 존재하는 다양한 소프트웨어 자산을 통합하고 현대화하는 데 사용될 수 있는 강력하고 유연한 엔지니어링 도구임을 증명합니다.

16.5.1 Interfaces.Fortran 개요

Fortran은 과학 및 공학 분야의 수치 해석(numerical analysis)과 고성능 컴퓨팅(HPC) 영역에서 수십 년간 표준 언어로서의 지위를 유지해 왔습니다. BLAS, LAPACK 등 최적화된 수많은 선형대수 및 과학 계산 라이브러리들이 Fortran으로 작성되었으며, 이들의 성능과 신뢰성은 오늘날에도 여전히 타의 추종을 불허합니다.

Ada가 이러한 검증된 Fortran의 수치 연산 자산을 활용할 수 없다면, 과학 기술 컴퓨팅 분야에서의 적용 범위는 크게 제한될 것입니다. 이를 위해 Ada 2022 레퍼런스 매뉴얼은 Interfaces.Fortran 표준 패키지와 관련 연동 지시어를 통해 Fortran 자산을 Ada 프로그램에 통합할 수 있는 표준화된 경로를 제공합니다.

Interfaces.Fortran 패키지

Interfaces.C와 마찬가지로, Interfaces.Fortran 패키지는 Fortran의 내장 타입(intrinsic types)에 직접 대응되는 Ada 타입들을 정의합니다. 이를 통해 두 언어 간에 데이터를 전달할 때 타입의 크기와 표현이 일치함을 보장합니다.

주요 타입은 다음과 같습니다.

  • Fortran_Integer
  • Real
  • Double_Precision
  • Complex

Fortran 연동의 핵심 과제와 해결책

Fortran 연동 시에는 C 연동과는 다른 두 가지 주요한 기술적 차이점을 이해해야 합니다.

  1. 배열의 메모리 레이아웃 (Array Memory Layout): Fortran 연동에서 가장 핵심적이고 주의를 요하는 부분은 다차원 배열의 처리입니다. 두 언어는 배열 요소를 메모리에 저장하는 순서가 근본적으로 다르기 때문입니다.
    • Ada (및 C/C++): 행 우선 순서(Row-Major Order). 다차원 배열에서 같은 행(row)에 있는 요소들이 메모리상에 인접하게 배치됩니다.
    • Fortran: 열 우선 순서(Column-Major Order). 다차원 배열에서 같은 열(column)에 있는 요소들이 메모리상에 인접하게 배치됩니다.

    이 차이를 해결하기 위해, pragma Convention (Fortran, ...)을 Ada의 배열 타입에 적용합니다. 이 지시어는 Ada 컴파일러에게 해당 배열을 Fortran과 동일한 열 우선 순서로 메모리에 배치하도록 지시하여, 대규모 배열 데이터를 복사 없이 효율적으로 전달할 수 있게 합니다.

  2. 문자열 전달 방식 (String Parameter Passing): Ada의 String 타입은 길이 정보를 포함하는 기술자(descriptor)를 통해 관리됩니다. 반면, Fortran 서브루틴은 문자열 인자를 받을 때 일반적으로 문자 데이터의 시작 주소문자열의 길이라는 두 개의 분리된 파라미터를 암묵적으로 전달받습니다. pragma Convention (Fortran, ...)을 Ada 서브프로그램에 적용하면, String 타입의 파라미터가 Fortran으로 전달될 때 Ada 런타임이 이러한 변환을 자동으로 처리해 줍니다.

Interfaces.Fortranpragma Convention (Fortran)은 이처럼 두 언어 간의 미묘하지만 결정적인 차이점들을 중재하는 역할을 수행합니다. 이를 통해 프로그래머는 Ada의 강력한 타입 시스템과 모듈성을 활용하여 전체 시스템을 구축하면서, 성능이 결정적인 수치 연산 부분은 검증된 고성능 Fortran 라이브러리에 안전하게 위임할 수 있습니다.

16.5.2 Interfaces.COBOL 개요

COBOL(Common Business-Oriented Language)은 1959년에 개발된 이래로 금융, 보험, 정부, 그리고 다양한 기업의 기간계(mission-critical) 시스템에서 핵심적인 역할을 수행해 온 언어입니다. 오늘날에도 전 세계의 수많은 메인프레임(mainframe) 환경에서 COBOL로 작성된 애플리케이션들이 안정적으로 운영되고 있으며, 이러한 레거시(legacy) 자산과의 연동은 시스템 현대화 프로젝트에서 중요한 과제 중 하나입니다.

Ada는 이러한 엔터프라이즈 환경에서의 요구사항을 충족시키기 위해, Ada 2022 레퍼런스 매뉴얼Interfaces.COBOL 표준 패키지와 관련 연동 메커니즘을 명시하고 있습니다. 이는 Ada가 COBOL 시스템과 데이터를 교환하고 서브프로그램을 상호 호출할 수 있는 표준화된 방법을 제공함을 의미합니다.

COBOL 연동의 핵심 과제: 데이터 표현

COBOL 연동에서 가장 큰 기술적 과제는 COBOL 고유의 정교하고 다양한 데이터 표현 방식을 Ada에서 정확하게 모델링하는 것입니다. 특히 숫자 데이터 처리 방식이 다른 언어들과 크게 다릅니다. Interfaces.COBOL 패키지는 이러한 COBOL의 기본 타입들에 대응되는 Ada 타입들을 정의합니다.

주요 타입은 다음과 같습니다.

  • Packed_Decimal: 2진화 10진법(BCD, Binary-Coded Decimal) 형식의 숫자를 표현합니다. 각 10진수 자릿수가 4비트(nibble)로 표현되며, 부호 비트를 포함합니다. 이는 부동소수점 연산에서 발생할 수 있는 반올림 오류를 피해야 하는 금융 계산에서 널리 사용됩니다.
  • Zoned_Decimal (또는 Display): 각 10진수 자릿수를 하나의 문자(character)로 표현하는 형식입니다. PIC 9 형식에 해당합니다.
  • Alphanumeric: COBOL의 PIC X 형식에 대응되는 문자열 타입입니다.
  • Binary: 일반적인 부호 있는 이진 정수입니다.

이 외에도 Interfaces.COBOL 패키지는 이러한 특수한 COBOL 타입들과 Ada의 표준 타입(Integer, String 등) 간에 값을 변환할 수 있는 다양한 서브프로그램을 제공합니다.

pragma Convention (COBOL)

pragma Convention (COBOL)은 Ada의 데이터 타입이나 서브프로그램이 COBOL의 규칙을 따르도록 지시하는 역할을 합니다.

  • 레코드(Record)에 적용: Ada의 레코드가 COBOL의 GROUP 항목과 동일한 메모리 레이아웃(필드 순서, 패딩, 데이터 형식 등)을 갖도록 보장합니다.
  • 서브프로그램(Subprogram)에 적용: Ada 서브프로그램이 COBOL의 호출 규약(파라미터 전달 방식 등)을 따르도록 하여, COBOL 프로그램이 Ada 서브프로그램을 호출하거나 그 반대의 경우가 가능하도록 합니다.

Interfaces.COBOL의 존재는 Ada가 단순히 기술적으로 진보한 언어일 뿐만 아니라, 수십 년간 운영되어 온 산업계의 핵심 레거시 시스템과 통합될 수 있는 실용성과 안정성을 갖춘 엔지니어링 도구임을 보여주는 중요한 증거입니다.

16.6 [활용] 외부 라이브러리 통합 실습

지금까지 14장에서는 Ada가 다른 언어와 상호작용하기 위한 개별적인 기술 요소들, 즉 타입 매핑, 서브프로그램 임포트, 그리고 호출 규약 지정 등에 대해 학습했습니다. 그러나 실제 프로젝트에서 외부 라이브러리를 통합하는 작업은 이러한 개별 기술들을 유기적으로 조합하여 하나의 일관된 인터페이스를 구축하는 종합적인 과정입니다.

단순히 외부 함수의 목록을 pragma Import로 나열하는 것을 넘어, 잘 설계된 연동 코드는 다음과 같은 목표를 달성해야 합니다.

  • 안전성(Safety): 외부 라이브러리가 사용하는 포인터, 수동 메모리 관리, 오류 코드 반환과 같은 저수준의 위험한 메커니즘을 Ada의 강력한 타입 시스템과 예외 처리, 그리고 자원 관리(RAII) 기능 안으로 안전하게 캡슐화해야 합니다.
  • 추상화(Abstraction): 외부 라이브러리의 복잡하고 비직관적인 API를 숨기고, Ada 프로그래머가 더 쉽게 이해하고 사용할 수 있는 명확하고 Ada다운(Ada-esque) 인터페이스를 제공해야 합니다.
  • 독립성(Independence): Ada 애플리케이션의 나머지 부분이 특정 외부 라이브러리의 구현 세부사항에 직접적으로 의존하지 않도록, 연동 계층이 명확한 경계 역할을 수행해야 합니다.

본 활용 절에서는 지금까지 배운 모든 지식을 종합하여, 실제 C 라이브러리를 Ada 프로젝트에 통합하는 실습을 진행합니다. 이 과정은 단순히 외부 함수를 호출하는 것을 넘어, Ada 언어의 장점을 최대한 활용하여 견고하고 재사용 가능한 바인딩(binding) 또는 래퍼(wrapper) 패키지를 설계하고 구현하는 전 과정을 포함합니다.

먼저, pragma Linker_Options를 사용하여 Ada 소스 코드 내에서 직접 외부 라이브러리와의 링크를 설정하는 실용적인 기법을 학습합니다. 이어서, 좋은 바인딩을 설계하기 위한 원칙들을 고찰하고, 최종적으로 널리 사용되는 실제 C 라이브러리(예: SQLite, zlib 등)를 선택하여, 이를 위한 안전하고 구조적인 Ada 래퍼 패키지를 처음부터 끝까지 작성하는 구체적인 사례를 연구할 것입니다. 이 실습을 통해 독자께서는 이론적 지식을 실제 문제 해결에 적용하는 귀중한 경험을 얻게 될 것입니다.

16.6.1 링커 옵션 명시: pragma Linker_Options

Ada 프로그램이 pragma Import를 사용하여 외부 라이브러리의 함수를 호출하도록 선언했다면, 컴파일 단계에서는 해당 선언의 구문적 유효성만이 검증됩니다. 실제 외부 함수의 코드가 Ada 프로그램과 결합되는 과정은 컴파일 이후의 링크(link) 단계에서 **링커(linker)**에 의해 수행됩니다. 링커는 컴파일된 Ada 오브젝트 파일과 외부 라이브러리 파일(예: *.a, *.so, *.lib, *.dll)을 합쳐 최종 실행 파일을 생성하는 역할을 합니다.

따라서 링커에게 “어떤 라이브러리 파일과 링크해야 하는지”를 명시적으로 알려주어야만 빌드 과정이 성공적으로 완료될 수 있습니다. 전통적으로 이 작업은 컴파일러를 호출할 때 명령줄(command-line) 인자를 통해 수행됩니다. 예를 들어, math 라이브러리(libm.so 또는 math.lib)와 링크하기 위해서는 GNAT 빌드 명령어에 다음과 같은 인자를 추가합니다.

gnatmake my_program.adb -largs -lm

-largs 옵션 뒤의 -lm이 링커에게 전달되는 옵션입니다.

이 방식은 효과적이지만, 한 가지 중요한 단점이 있습니다. 바로 빌드 정보가 소스 코드와 분리된다는 점입니다. Ada 소스 코드만 가지고 있는 다른 개발자는 이 코드가 어떤 외부 라이브러리에 의존하고 있으며, 어떻게 빌드해야 하는지에 대한 정보를 별도의 문서나 빌드 스크립트를 통해 파악해야만 합니다.

이러한 문제를 해결하고, 소스 코드가 자신의 의존성을 스스로 기술하도록 만들기 위해 GNAT 컴파일러는 pragma Linker_Options 라는 매우 유용한 구현 정의 지시어를 제공합니다. 이 프라그마를 사용하면, 필요한 링커 옵션을 Ada 소스 코드 내부에 직접 문자열로 명시할 수 있습니다.

pragma Linker_Options 구문 및 사용법

이 프라그마는 라이브러리 단위(패키지 명세 등)의 선언부에 위치하며, 링커에 전달할 옵션을 문자열 파라미터로 받습니다.

pragma Linker_Options ("linker_option_string");

예제: C 수학 라이브러리 링크

외부 C 수학 라이브러리 libm에 있는 sqrt 함수를 사용하는 바인딩 패키지를 작성한다고 가정해 보겠습니다. 이 패키지 명세에 pragma Linker_Options를 추가하면, 이 패키지를 with하는 모든 프로그램은 자동으로 libm과 링크됩니다.

-- File: c_math_binding.ads
package C_Math_Binding is

   -- 링커에게 이 패키지가 'm' 라이브러리(libm)와 링크되어야 함을 알립니다.
   pragma Linker_Options ("-lm");

   function sqrt (x : in Interfaces.C.double) return Interfaces.C.double;
   pragma Import (C, sqrt);

   -- ... 기타 수학 함수들 ...

end C_Math_Binding;

이제 클라이언트 코드는 빌드 시 별도의 링커 옵션을 명시할 필요가 없습니다.

with C_Math_Binding;
procedure Main is
   -- ...
begin
   -- ... C_Math_Binding.sqrt 호출 ...
end Main;

-- 빌드 명령어: gnatmake main.adb
-- 별도의 -largs -lm 옵션이 필요 없습니다.

gnatmakemain.adbC_Math_Binding에 의존함을 파악하고, c_math_binding.ads 파일 안에 있는 pragma Linker_Options를 읽어 자동으로 -lm 옵션을 셔에게 전달합니다.

설계적 이점

pragma Linker_Options를 사용하는 것은 외부 라이브러리 바인딩을 설계할 때 매우 권장되는 방식입니다.

  • 자기 기술적 소스 코드(Self-Contained Source): 소스 코드 자체가 자신의 빌드 의존성을 포함하므로, 코드의 재사용성과 이식성이 향상됩니다.
  • 빌드 복잡성 감소: 사용자는 복잡한 빌드 스크립트나 Makefile을 이해할 필요 없이, 표준적인 컴파일 명령어만으로 프로그램을 빌드할 수 있습니다.
  • 문서화: 프라그마 자체가 이 패키지가 어떤 외부 라이브러리를 필요로 하는지에 대한 명확한 문서 역할을 합니다.

이처럼 pragma Linker_Options는 외부 라이브러리와의 연동을 더욱 견고하고 사용자 친화적으로 만드는 실용적인 도구입니다.

16.6.2 사례 연구: C/C++ 라이브러리를 위한 안전하고 Ada다운(Ada-esque) 바인딩 작성

이론적 지식을 실제 프로젝트에 적용하는 것은 언어를 깊이 있게 이해하는 가장 효과적인 방법입니다. 본 절에서는 지금까지 14장에서 학습한 모든 연동 기법을 총동원하여, 널리 사용되는 실제 C 라이브러리인 zlib를 위한 안전하고 Ada다운(Ada-esque) 바인딩(binding) 패키지를 설계하고 구현하는 전 과정을 단계별로 살펴보겠습니다.

이 사례 연구의 목표는 단순히 C 함수를 호출하는 것을 넘어, C 라이브러리의 저수준 특성과 잠재적 위험을 캡슐화하여 Ada 프로그래머에게는 높은 수준의 추상화와 안정성을 제공하는 견고한 인터페이스를 구축하는 것입니다.

1단계: 원본 C API 분석 (zlib.h)

zlib는 데이터 압축 및 해제를 위한 C 라이브러리입니다. 핵심적인 압축(deflate) 기능은 다음과 같은 요소들로 구성됩니다.

  • z_stream 구조체: 압축/해제 과정의 모든 상태 정보(입력/출력 버퍼 포인터, 남은 데이터 크기 등)를 담고 있는 핵심 데이터 구조입니다.
  • deflateInit_ 함수: z_stream 객체를 초기화합니다.
  • deflate 함수: 실제 압축을 수행합니다. z_stream의 포인터를 받아 상태를 갱신하며, 성공 또는 여러 종류의 오류를 나타내는 정수 에러 코드를 반환합니다.
  • deflateEnd 함수: z_stream 객체에 할당된 동적 자원을 해제합니다.

여기서 우리는 C API의 전형적인 특징, 즉 프로그래머가 직접 상태 객체(z_stream)의 생명주기를 관리해야 하고, 포인터를 직접 다루어야 하며, 반환되는 정수 값을 매번 확인하여 오류를 처리해야 하는 저수준의 상호작용 모델을 확인할 수 있습니다.

2단계: 저수준 “씬(Thin)” 바인딩 작성

첫 번째 단계는 C API를 Ada로 일대일(one-to-one) 매핑하는 저수준 바인딩을 만드는 것입니다. 이 패키지는 private 자식 패키지로 만들어 외부 사용자에게는 숨기는 것이 좋습니다.

-- File: zlib-c_interface.ads (private child package)
private package ZLib.C_Interface is
   type z_stream is record ... end record;
   pragma Convention (C, z_stream);

   -- C 함수들을 직접 임포트
   function deflateInit (...) return Interfaces.C.int;
   pragma Import (C, deflateInit, "deflateInit_");

   function deflate (...) return Interfaces.C.int;
   pragma Import (C, deflate);

   function deflateEnd (...) return Interfaces.C.int;
   pragma Import (C, deflateEnd);
end ZLib.C_Interface;

이 “씬 바인딩”은 C의 저수준 특성을 그대로 가지고 있으므로, 최종 사용자에게 직접 노출하기에는 위험하고 불편합니다.

3단계: 안전한 “씩(Thick)” 바인딩 설계 (Ada-esque API)

이제 저수준 바인딩을 기반으로, 안전하고 사용하기 쉬운 공개 API를 설계합니다. 이것이 바로 “Ada다운” 래퍼(wrapper) 패키지입니다.

설계 목표:

  1. 자동 자원 관리 (RAII): deflateInit_deflateEnd 호출을 자동화하여 메모리 누수를 방지합니다.
  2. 에러 코드의 예외 변환: C의 정수 에러 코드를 Ada의 구조적인 예외로 변환합니다.
  3. 포인터 숨기기: 버퍼 포인터와 크기를 직접 다루는 대신, Ada의 배열 타입을 파라미터로 받습니다.

구현:

-- File: zlib.ads (Public API)
with Ada.Finalization;
with Ada.Streams;

package ZLib is
   -- 1. z_stream 상태를 숨기는 private 타입 선언
   type Stream is new Ada.Finalization.Limited_Controlled with private;

   -- 2. 에러 코드를 대체할 예외 선언
   ZLib_Error : exception;

   -- 3. Ada다운 인터페이스 제공
   procedure Deflate (S       : in out Stream;
                      Input   : in Ada.Streams.Stream_Element_Array;
                      Output  : out Ada.Streams.Stream_Element_Array);
private
   package C renames ZLib.C_Interface; -- 씬 바인딩
   type Stream is new Ada.Finalization.Limited_Controlled with record
      strm : C.z_stream;
      initialized : Boolean := False;
   end record;

   -- 4. RAII 구현
   overriding procedure Initialize (S : in out Stream);
   overriding procedure Finalize (S : in out Stream);
end ZLib;
-- File: zlib.adb
package body ZLib is
   overriding procedure Initialize (S : in out Stream) is
   begin
      -- 객체 생성 시 자동으로 deflateInit_ 호출
      if C.deflateInit (S.strm, ...) /= C.Z_OK then
         raise ZLib_Error with "Initialization failed";
      end if;
      S.initialized := True;
   end Initialize;

   overriding procedure Finalize (S : in out Stream) is
   begin
      -- 객체 소멸 시 자동으로 deflateEnd 호출
      if S.initialized then
         declare
            retval : Interfaces.C.int := C.deflateEnd (S.strm);
         begin
            -- Finalize에서는 예외를 raise하면 안 되므로 로깅 등으로 처리
            null;
         end;
      end if;
   end Finalize;

   procedure Deflate (...) is
      retval : Interfaces.C.int;
   begin
      -- C 함수를 호출하고 에러 코드를 예외로 변환
      retval := C.deflate (S.strm, ...);
      if retval < C.Z_OK then
         raise ZLib_Error with "Deflate failed";
      end if;
   end Deflate;
end ZLib;

결과: 사용 편의성 및 안전성 비교

래퍼 사용 전 (저수준):

-- 수동으로 초기화, 오류 검사, 자원 해제를 모두 책임져야 함
strm : C.z_stream;
retval := C.deflateInit(strm, ...);
if retval /= C.Z_OK then ... end if;
-- ...
retval := C.deflateEnd(strm);

래퍼 사용 후 (Ada-esque):

-- 선언만으로 초기화와 자원 해제가 보장됨. 오류는 예외로 처리.
declare
   strm : ZLib.Stream;
begin
   ZLib.Deflate (strm, input, output);
exception
   when ZLib.ZLib_Error => ...
end;

이 사례 연구는 언어 연동이 단순한 함수 호출의 나열이 아님을 보여줍니다. 성공적인 바인딩은 다른 언어의 저수준 패러다임을 Ada의 강력한 안전성 및 추상화 기능 속으로 용해시켜, 최종 사용자에게는 견고하고 신뢰할 수 있으며 사용하기 쉬운 인터페이스를 제공하는 공학적인 과정입니다.

17. 동시성 프로그래밍 소개

현대의 컴퓨팅 환경은 복잡성의 증가와 성능 향상에 대한 끊임없는 요구에 직면해 있습니다. 단일 코어 프로세서의 성능 향상이 물리적 한계에 도달하면서, 하드웨어 제조업체들은 여러 개의 처리 코어를 하나의 칩에 집적하는 멀티코어 아키텍처로 전환했습니다. 이러한 변화는 소프트웨어 개발 패러다임에도 근본적인 전환을 요구합니다. 이제는 단일 순차 흐름으로 실행되는 프로그램을 넘어, 여러 작업을 동시에 처리하여 시스템의 잠재력을 최대한 활용하는 동시성(concurrency) 프로그래밍이 필수적인 기술이 되었습니다.

동시성 프로그래밍은 단순히 여러 작업을 빠르게 처리하는 것을 넘어, 응답성을 향상시키고, 실시간 제약 조건을 충족하며, 복잡한 시스템을 논리적으로 분리된 작은 단위로 모델링하는 강력한 수단을 제공합니다. 예를 들어, 그래픽 사용자 인터페이스(GUI) 애플리케이션에서 사용자의 입력을 처리하는 작업과 백그라운드에서 데이터를 처리하는 작업을 동시에 실행함으로써, 애플리케이션이 멈추지 않고 부드럽게 동작하도록 보장할 수 있습니다. 또한, 항공우주, 국방, 의료, 산업 자동화와 같이 신뢰성과 안전성이 최우선인 시스템에서는 수많은 센서 데이터 처리, 통신, 제어 로직이 동시에 안정적으로 수행되어야 합니다.

많은 프로그래밍 언어들이 라이브러리나 외부 도구를 통해 동시성 기능을 지원하지만, Ada는 언어 설계 초기부터 동시성을 핵심 개념으로 통합했습니다. 이는 Ada가 단순한 기능 제공을 넘어, 동시성 프로그래밍에서 발생할 수 있는 고질적인 문제들을 언어 차원에서 예방하고 해결할 수 있도록 설계되었음을 의미합니다. Ada는 태스크(task)보호 객체(protected object)라는 강력하고 직관적인 구조를 통해 동시성을 구현하며, 이를 통해 개발자는 복잡한 동시성 상호작용을 명확하고 안전하게 표현할 수 있습니다.

본 12장에서는 동시성 프로그래밍의 세계로 들어서는 첫걸음을 내딛습니다. 먼저 동시성의 기본 개념을 정의하고, 종종 혼용되는 병렬성(parallelism)과의 차이를 명확히 구분할 것입니다. 이어서 동시성 프로그래밍이 왜 현대 소프트웨어 개발에서 중요한지를 살펴보고, 공유 메모리 및 메시지 전달과 같은 고전적인 동시성 모델을 통해 Ada의 접근 방식이 갖는 독창성을 이해하게 될 것입니다. 마지막으로, 동시성 시스템을 설계할 때 반드시 고려해야 할 경쟁 상태(race conditions), 교착 상태(deadlocks)와 같은 도전 과제들을 소개하여, 앞으로 이어질 장들에서 Ada가 이러한 문제들을 어떻게 효과적으로 해결하는지에 대한 기초를 다질 것입니다.

이 장을 통해 독자 여러분은 동시성이라는 개념에 익숙해지고, Ada가 제공하는 견고한 동시성 모델의 필요성을 체감하게 될 것입니다. 이는 앞으로 우리가 배울 태스크, 랑데부, 보호 객체 등 Ada의 고급 동시성 기능을 깊이 있게 이해하기 위한 필수적인 토대가 될 것입니다.

17.1 동시성(concurrency)의 이해

앞서 동시성이 현대 소프트웨어 개발의 핵심 요소임을 확인했습니다. 이제 우리는 이 개념을 더 깊이 파고들어, 그 정의를 명확히 하고, 관련된 용어와 구별하며, 그 필요성을 구체적으로 이해해야 합니다. 동시성을 정확히 이해하는 것은 복잡한 시스템을 효과적으로 설계하고, Ada가 제공하는 강력한 동시성 도구들을 올바르게 활용하기 위한 첫걸음입니다.

이 절에서는 동시성의 세 가지 핵심 측면을 다룰 것입니다. 첫째, 동시성의 명확한 정의를 내리고, 논리적으로 여러 작업 흐름이 동시에 진행되는 상태가 무엇을 의미하는지 탐구합니다. 둘째, 동시성과 자주 혼동되는 병렬성(parallelism)의 개념을 비교하여 두 용어의 차이와 관계를 명확히 할 것입니다. 마지막으로, 이론적 개념을 넘어 현실 세계에서 동시성 프로그래밍이 왜 필수적인지, 그 구체적인 이유와 이점을 살펴보겠습니다.

17.1.1 동시성의 정의

동시성(concurrency)은 둘 이상의 작업(task)이 동시에 활성화되어 진행 중인 시스템의 특성을 의미합니다. 여기서 핵심은 ‘동시에 진행 중’이라는 개념입니다. 이는 모든 작업이 물리적으로 정확히 같은 시간에 실행되어야 함을 의미하지는 않습니다. 대신, 하나의 작업이 끝나기 전에 다른 작업이 시작되어 여러 작업의 실행 시간이 서로 겹치는 상태를 말합니다.

단일 코어 프로세서를 예로 들어보겠습니다. 프로세서는 한순간에 단 하나의 명령어만 처리할 수 있지만, 운영체제 스케줄러는 매우 짧은 시간 간격(time slice)으로 여러 작업을 번갈아 가며 실행합니다. 각 작업은 잠시 실행되다가 멈추고, 다른 작업에게 CPU 사용 권한을 넘겨줍니다. 이 전환이 매우 빠르게 일어나기 때문에, 사용자나 외부 시스템의 관점에서는 여러 작업이 동시에 처리되는 것처럼 보입니다. 이러한 실행 방식을 실행의 인터리빙(interleaving of execution)이라고 합니다.

결론적으로 동시성은 다음과 같이 정의할 수 있습니다.

동시성이란 시스템이 여러 개의 독립적인 논리적 실행 흐름(logical flows of control)을 관리하는 능력입니다. 이러한 흐름들은 물리적으로 동시에 실행될 수도 있고(멀티코어 환경), 번갈아 가며 실행될 수도 있습니다(싱글코어 환경).

이처럼 동시성은 문제 해결을 위한 논리적 구조에 초점을 맞춘 개념입니다. 즉, 여러 작업을 독립적으로 구성하고 이들 간의 상호작용과 실행 순서를 관리하는 프로그래밍 모델 그 자체를 의미합니다. Ada는 바로 이러한 논리적 흐름을 ‘태스크’라는 언어적 구조로 명확하게 표현하고 관리할 수 있도록 지원합니다.

17.1.2 동시성 대 병렬성 (concurrency vs. parallelism)

동시성과 병렬성은 밀접하게 관련되어 있어 자주 혼용되지만, 이 둘은 근본적으로 다른 개념입니다. 이 차이를 이해하는 것은 동시성 프로그래밍의 목표와 Ada의 역할을 명확히 하는 데 매우 중요합니다.

  • 동시성(Concurrency)문제의 구조에 관한 것입니다. 앞서 정의했듯이, 여러 작업을 독립적인 실행 흐름으로 나누고 이들의 상호작용을 관리하는 프로그래밍 모델입니다. 즉, 논리적(logical)인 개념입니다. 동시적인 프로그램은 단일 코어에서도 시간 분할(interleaving)을 통해 실행될 수 있습니다.

  • 병렬성(Parallelism)실행의 형태에 관한 것입니다. 둘 이상의 작업을 물리적으로(physically) 동시에 실행하여 성능을 높이는 것을 의미합니다. 이를 위해서는 멀티코어 프로세서나 분산 시스템과 같은 하드웨어 지원이 필수적입니다.

이 관계를 간단히 요약하면 다음과 같습니다.

구분 동시성 (Concurrency) 병렬성 (Parallelism)
초점 논리적 구조 (Logical Structure) 물리적 실행 (Physical Execution)
목표 복잡성 관리, 응답성 향상, 문제 분해 실행 속도 향상, 처리량 증대
핵심 여러 작업을 다루는 것 (Dealing with) 여러 작업을 수행하는 것 (Doing)
필요조건 단일 코어에서도 가능 다중 코어 또는 처리 장치 필수

아주 좋은 비유는 저글링(juggling)입니다.

  • 동시성은 한 명의 저글러가 여러 개의 공을 공중에 유지하는 것과 같습니다. 저글러는 한순간에 하나의 공만 잡거나 던지지만(interleaving), 모든 공은 계속해서 움직이는 상태(in-progress)를 유지합니다. 이는 하나의 프로세서가 여러 태스크를 관리하는 것과 같습니다.
  • 병렬성은 여러 명의 저글러가 각자 자신의 공들을 동시에 저글링하는 것과 같습니다. 이는 여러 프로세서 코어가 각자의 태스크를 동시에 실행하는 것과 같습니다.

결론적으로, 동시성은 병렬성을 달성하기 위한 전제 조건으로 볼 수 있습니다. 프로그램을 동시적으로 잘 설계해두면, 멀티코어 하드웨어에서 병렬로 실행하여 성능 향상의 이점을 누릴 수 있습니다. 반면, 동시적으로 설계되지 않은 순차적 프로그램은 코어가 아무리 많아도 병렬로 실행될 수 없습니다.

Ada는 개발자가 동시성에 집중하여 프로그램을 논리적으로 명확하게 구조화할 수 있도록 돕습니다. 이렇게 작성된 코드는 하드웨어 환경에 따라 자연스럽게 병렬성의 이점을 취할 수 있습니다.

17.1.3 동시성 프로그래밍의 필요성

동시성 프로그래밍이 단지 학문적 개념이나 선택적 최적화 기법에 머무르지 않고 현대 소프트웨어 개발의 필수 요소가 된 데에는 몇 가지 근본적인 이유가 있습니다.

1. 하드웨어 자원의 최대 활용

가장 명백한 이유는 하드웨어의 발전 방향 때문입니다. 개별 프로세서 코어의 클럭 속도를 높이는 방식은 물리적 한계(발열, 전력 소모)에 부딪혔습니다. 이에 대한 해답으로 업계는 여러 개의 코어를 하나의 칩에 통합하는 멀티코어 아키텍처를 표준으로 채택했습니다. 순차적으로 작성된 프로그램은 이 여러 개의 코어 중 단 하나만 사용하게 되어 나머지 코어들은 유휴 상태로 남게 됩니다. 동시성 프로그래밍은 여러 작업을 각기 다른 코어에 할당하여 병렬로 실행시킴으로써, 시스템의 컴퓨팅 자원을 최대한 활용하고 전체적인 성능을 극대화합니다.

2. 향상된 응답성

사용자 경험(UX)이 중요한 현대 애플리케이션에서 응답성은 매우 중요합니다. 예를 들어, 대용량 파일을 처리하거나 복잡한 연산을 수행하는 작업을 단일 스레드로 처리하면 작업이 완료될 때까지 전체 애플리케이션의 인터페이스가 멈추는 ‘프리징(freezing)’ 현상이 발생합니다. 동시성을 도입하면 시간이 오래 걸리는 작업을 백그라운드 태스크(background task)로 분리할 수 있습니다. 이를 통해 메인 태스크는 계속해서 사용자의 입력에 응답하며 원활한 상호작용을 제공할 수 있습니다.

3. 현실 세계의 자연스러운 모델링

많은 문제들은 본질적으로 동시적입니다. 웹 서버는 수많은 클라이언트의 요청을 동시에 처리해야 하고, 로봇 제어 시스템은 센서 데이터 수집, 모터 제어, 경로 계산 등 여러 활동을 동시에 수행해야 합니다. 이러한 문제들을 하나의 거대한 순차적 루프로 구현하려고 시도하면 코드가 매우 복잡해지고 이해하기 어려워집니다. 동시성 프로그래밍을 사용하면 현실 세계의 독립적인 행위자(actor)나 이벤트를 각각의 태스크로 모델링할 수 있습니다. 이는 소프트웨어의 구조를 문제의 본질과 일치시켜 설계를 더 단순하고 직관적으로 만들며, 유지보수를 용이하게 합니다.

4. 실시간 및 임베디드 시스템의 요구사항

항공우주, 산업 자동화, 의료 기기와 같은 실시간 시스템(real-time systems)에서는 논리적 정확성뿐만 아니라 시간적 정확성, 즉 정해진 시간 제약(deadline) 안에 작업을 완료하는 것이 매우 중요합니다. 이러한 시스템은 예측 불가능한 외부 이벤트를 처리하고 여러 제어 루프를 동시에 실행해야 합니다. 동시성은 각기 다른 우선순위를 가진 작업들을 효과적으로 관리하고, 긴급한 작업이 다른 작업을 선점(preempt)하여 실행될 수 있도록 보장하는 필수적인 메커지즘을 제공합니다.

이러한 이유들로 인해 동시성은 더 이상 전문가의 영역이 아닌 모든 개발자가 이해하고 활용해야 할 보편적인 프로그래밍 패러다임이 되었습니다. Ada는 바로 이러한 필요성을 충족시키기 위해 언어 차원에서 강력하고 신뢰성 높은 동시성 기능을 제공합니다.

17.2 동시성 모델

지금까지 동시성의 개념과 필요성을 살펴보았습니다. 이제 동시성을 프로그래밍 언어에서 실제로 어떻게 구현하고 관리하는지에 대한 방법론, 즉 동시성 모델(concurrency model)에 대해 알아볼 차례입니다. 동시성 모델은 여러 독립적인 실행 흐름(태스크)들이 서로 통신(communication)하고, 공유된 자원에 대한 접근을 조율하며, 실행 순서를 동기화(synchronization)하는 방식에 대한 규칙과 추상화를 제공합니다.

어떤 동시성 모델을 사용하는지에 따라 프로그램의 구조, 안정성, 그리고 해결할 수 있는 문제의 종류가 크게 달라집니다. 이 절에서는 프로그래밍 세계에서 널리 사용되는 두 가지 기본 모델인 공유 메모리 모델과 메시지 전달 모델을 먼저 살펴보겠습니다.

이러한 고전적인 모델들을 이해한 후, Ada가 제공하는 독창적이고 강력한 동시성 모델을 소개합니다. Ada는 태스크 간의 직접적인 통신을 위한 메커니즘과 공유 데이터를 안전하게 보호하는 메커니즘을 언어 차원에서 모두 제공함으로써, 다른 언어들과 차별화된 견고함과 명확성을 자랑합니다. 본 절을 통해 각 모델의 원리를 이해하고 Ada의 접근 방식이 갖는 이점을 파악하게 될 것입니다.

17.2.1. 공유 메모리 모델 (shared memory model)

공유 메모리 모델은 가장 전통적이고 널리 알려진 동시성 모델입니다. 이 모델에서 여러 태스크는 운영체제가 제공하는 공통된 메모리 공간(shared memory)에 직접 접근하여 정보를 교환합니다. 하나의 태스크가 공유 메모리에 데이터를 쓰면, 다른 태스크가 그 데이터를 읽어가는 방식으로 암묵적인 통신이 이루어집니다.

이 모델은 마치 하나의 공유 화이트보드를 여러 사람이 함께 사용하는 것과 비유할 수 있습니다 칠판 칠판.

  • 쓰기 (Write): 누구나 화이트보드에 정보를 쓸 수 있습니다.
  • 읽기 (Read): 누구나 화이트보드에 쓰인 정보를 읽을 수 있습니다.

이 방식은 데이터를 복사할 필요 없이 직접 접근하므로 잠재적으로 매우 빠르고 효율적입니다.

도전 과제: 동기화 (synchronization)

공유 메모리 모델의 가장 큰 도전 과제는 바로 접근 제어입니다. 아무런 규칙 없이 여러 태스크가 동시에 공유 변수에 접근하면 데이터의 일관성이 깨지는 심각한 문제가 발생합니다. 이를 경쟁 상태(race condition)라고 합니다.

예를 들어, 두 개의 태스크가 공유 변수 shared_counter를 1씩 증가시킨다고 가정해 보겠습니다.

-- 개념적 예시: 경쟁 상태
shared_counter : Integer := 0;

-- 태스크 1의 코드 조각
-- 1. 레지스터에 shared_counter 값(예: 5)을 읽어온다.
-- 2. 레지스터 값을 1 증가시킨다. (결과: 6)
-- 3. 레지스터 값을 다시 shared_counter에 쓴다.

-- 태스크 2의 코드 조각
-- 1. 레지스터에 shared_counter 값(예: 5)을 읽어온다.
-- 2. 레지스터 값을 1 증가시킨다. (결과: 6)
-- 3. 레지스터 값을 다시 shared_counter에 쓴다.

만약 태스크 1이 1번과 2번을 수행한 직후, 3번을 수행하기 전에 운영체제에 의해 실행이 중단되고 태스크 2가 실행된다고 상상해 보십시오. 태스크 2 역시 shared_counter의 값 5를 읽어와 1을 더한 후 6을 저장할 것입니다. 그 이후 다시 태스크 1이 실행되어 자신이 계산했던 값 6을 저장합니다. 결과적으로 두 태스크가 실행되었음에도 불구하고 shared_counter의 최종값은 7이 아닌 6이 됩니다.

이러한 문제를 해결하기 위해, 프로그래머는 뮤텍스(mutexes), 세마포(semaphores), 모니터(Monitors)와 같은 동기화 프리미티브(synchronization primitives)를 직접 사용하여 공유 자원에 대한 상호 배제(mutual exclusion)를 보장해야 합니다. 즉, 한 번에 하나의 태스크만이 공유 데이터를 수정할 수 있도록 잠금(lock) 메커니즘을 구현해야 합니다.

요약

  • 장점: 데이터 복사가 없어 통신 속도가 빠르다.
  • 단점: 프로그래머가 직접 동기화를 관리해야 하므로 코드가 복잡해지고, 경쟁 상태나 교착 상태(deadlock)와 같은 오류가 발생하기 매우 쉽다.

이 모델은 강력한 성능을 제공하지만, 개발자에게 신중한 동기화 처리라는 무거운 책임을 지게 합니다.

17.2.2. 메시지 전달 모델 (message passing model)

메시지 전달 모델은 공유 메모리 모델의 복잡성과 위험성을 해결하기 위한 대안으로 제시된 동시성 모델입니다. 이 모델의 핵심 철학은 “상태를 공유하여 통신하지 말고, 통신을 통해 상태를 공유하라 (Do not communicate by sharing memory; instead, share memory by communicating)” 라는 말로 요약할 수 있습니다.

이 모델에서 각 태스크는 다른 태스크가 접근할 수 없는 자신만의 독립된 메모리 공간(isolated memory)을 가집니다. 태스크 간의 정보 교환은 명시적인 메시지(message)를 서로 주고받는 방식으로만 이루어집니다.

이 모델은 우편 시스템에 비유할 수 있습니다.

  • 각 태스크는 자신만의 주소를 가진 집(독립된 메모리)과 같습니다.
  • 다른 태스크와 통신하려면, 편지(메시지)를 써서 상대방의 주소로 보내야 합니다.
  • 수신자는 자신의 우편함(메시지 큐)을 확인하여 편지를 받아 내용을 확인합니다.

이 방식은 태스크들이 서로의 내부 상태를 직접 수정하는 것을 원천적으로 차단하므로, 공유 메모리 모델의 고질적인 문제였던 경쟁 상태가 발생하지 않습니다.

통신 방식

메시지 전달은 주로 두 가지 방식으로 이루어집니다.

  1. 동기식 전달 (Synchronous/Blocking): 송신 태스크가 메시지를 보낸 후, 수신 태스크가 메시지를 받을 때까지 기다립니다. 이는 마치 등기 우편을 보내고 상대방이 수령했다는 확인을 받을 때까지 기다리는 것과 같습니다. 이 방식은 두 태스크의 실행 시점을 맞추는 강력한 동기화 메커니즘으로 작용합니다.

  2. 비동기식 전달 (Asynchronous/Non-blocking): 송신 태스크는 메시지를 보낸 직후, 수신 여부와 상관없이 즉시 자신의 다음 작업을 계속 수행합니다. 이는 일반 우편을 우체통에 넣고 바로 자기 갈 길을 가는 것과 같습니다.

장점과 단점

  • 장점:
    • 안전성: 공유 상태가 없으므로 경쟁 상태가 근본적으로 방지됩니다.
    • 명확성: 데이터의 흐름이 메시지 전달 경로를 통해 명확하게 드러나 코드의 의도를 파악하기 쉽습니다.
    • 확장성: 태스크 간의 물리적 위치에 구애받지 않으므로, 단일 시스템을 넘어 네트워크로 연결된 분산 시스템으로 확장하기 용이합니다.
  • 단점:
    • 성능 부하: 메시지를 생성하고 복사하여 전달하는 과정에서 공유 메모리 직접 접근 방식보다 추가적인 비용이 발생할 수 있습니다.
    • 교착 상태: 동기식 전달 방식에서 두 태스크가 서로에게 메시지를 보내고 받기를 기다리는 경우 교착 상태에 빠질 수 있습니다.

메시지 전달 모델은 동시성 코드를 훨씬 더 안전하고 예측 가능하게 만들어 줍니다. Ada의 랑데부(rendezvous) 메커니즘은 바로 이 메시지 전달 모델에 깊은 뿌리를 두고 있으며, 언어 차원에서 안전한 동기식 통신을 지원합니다.

17.2.3 Ada의 동시성 모델 (태스크와 보호 객체)

앞서 살펴본 공유 메모리 모델과 메시지 전달 모델은 각각 장단점을 가집니다. Ada는 어느 한 가지 모델만을 고수하지 않고, 두 모델의 장점을 모두 수용하여 언어 차원에서 통합한 매우 정교하고 실용적인 하이브리드(hybrid) 모델을 제공합니다.

Ada의 동시성은 두 개의 핵심적인 언어 구조, 즉 태스크(Task)보호 객체(Protected Object)를 통해 구현됩니다. 이 둘은 각기 다른 목적을 위해 설계되었으며, 개발자가 당면한 문제에 가장 적합한 도구를 선택하여 사용할 수 있게 해 줍니다.

태스크와 랑데부: 메시지 전달의 구현

태스크(Task)는 Ada에서 동시성의 기본 단위로, 독립적인 실행 흐름을 나타내는 능동적인(active) 개체입니다. 태스크 간의 통신과 동기화는 랑데부(rendezvous)라는 독특한 메커니즘을 통해 이루어집니다.

  • 랑데부(rendezvous): 이는 Ada가 구현한 동기식 메시지 전달 모델입니다. 하나의 태스크(호출자)가 다른 태스크(수신자)에 정의된 엔트리(entry)를 호출하면, 두 태스크는 만남(rendezvous)을 가집니다. 이 만남 동안 데이터가 안전하게 교환되며, 랑데부가 끝나면 두 태스크는 다시 각자의 경로를 따라 실행을 계속합니다.

이 모델은 태스크들이 서로에게 특정 서비스를 요청하고 제공하는 것과 같은 행위 중심(behavior-centric)의 상호작용에 매우 적합합니다.

보호 객체: 안전한 공유 메모리

보호 객체(Protected Object)는 공유 데이터를 보호하기 위해 설계된 수동적인(passive) 데이터 저장소입니다. 이는 공유 메모리 모델의 문제점인 경쟁 상태를 언어 차원에서 원천적으로 해결합니다.

  • 동기화의 자동화: 보호 객체는 내부 데이터와 해당 데이터에 접근할 수 있는 연산(프로시저, 함수, 엔트리)들을 하나의 단위로 캡슐화합니다. 프로그래머가 뮤텍스나 세마포어 같은 저수준 동기화 코드를 작성할 필요 없이, Ada 런타임 시스템이 자동으로 상호 배제(mutual exclusion)를 보장합니다. 즉, 여러 태스크가 동시에 보호 객체의 데이터를 수정하려 해도, 한 번에 하나의 태스크만 접근이 허용됩니다.

이 모델은 여러 태스크가 공유 데이터의 무결성을 유지하며 접근해야 하는 데이터 중심(data-centric)의 동기화 문제에 이상적인 해결책입니다.

요약: Ada의 이중적 접근법

Ada의 동시성 모델은 다음과 같이 요약할 수 있습니다.

구분 태스크 (Task) + 랑데부 보호 객체 (Protected Object)
역할 능동적 실행 단위 (Active) 수동적 데이터 캡슐화 (Passive)
기반 모델 메시지 전달 모델 안전성이 보장된 공유 메모리 모델
주요 목적 태스크 간 서비스 제공 및 통신 공유 데이터의 무결성 보호
핵심 비유 서로 약속을 잡고 만나는 행위자 🤝 접근 규칙이 엄격한 데이터 금고

이처럼 Ada는 문제의 성격에 따라 가장 적절하고 안전한 동시성 해법을 선택할 수 있는 유연성을 제공합니다. 이는 복잡한 동시성 관련 오류를 컴파일 시점과 런타임에 체계적으로 방지하여, 신뢰성이 극도로 중요한 시스템을 구축하는 데 있어 Ada를 독보적인 언어로 만들어 줍니다. 이어지는 장들에서 이 두 가지 강력한 메커니즘을 상세히 배우게 될 것입니다.

17.3 동시성 프로그래밍의 도전 과제

동시성 프로그래밍은 시스템의 성능과 응답성을 크게 향상시키는 강력한 도구이지만, 그 이면에는 순차적 프로그래밍에서는 찾아볼 수 없는 독특하고 미묘한 문제들이 존재합니다. 이러한 문제들은 여러 태스크의 실행 순서가 운영체제 스케줄러에 의해 비결정적(non-deterministic)으로 정해지기 때문에 발생하며, 재현하고 디버깅하기가 매우 까다롭습니다.

견고한 동시성 시스템을 구축하기 위해서는 이러한 잠재적 위험들을 명확히 이해하고, 이를 예방할 수 있는 설계 기법을 숙지하는 것이 필수적입니다. 이 절에서는 동시성 프로그래밍에서 가장 빈번하게 발생하는 네 가지 주요 도전 과제, 즉 경쟁 상태, 교착 상태, 활성 잠금, 그리고 기아 상태에 대해 알아봅니다.

이러한 문제들을 이해하는 것은 단순히 위험을 인지하는 것을 넘어, Ada가 제공하는 태스크, 보호 객체, 랑데부와 같은 고수준 동시성 기능들이 왜 그렇게 설계되었는지를 깊이 있게 이해하는 기반이 될 것입니다. Ada는 바로 이러한 고질적인 문제들을 언어 차원에서 체계적으로 해결하는 것을 목표로 합니다.

17.3.1 경쟁 상태 (race conditions)

경쟁 상태(Race Condition)는 동시성 프로그래밍에서 가장 기본적이고 흔하게 발생하는 문제입니다. 이는 둘 이상의 태스크가 공유된 자원(shared resource)에 접근하여 조작할 때, 그 실행 순서나 타이밍에 따라 시스템의 최종 결과가 달라지는 상황을 말합니다. 말 그대로 태스크들이 자원을 차지하기 위해 “경쟁”하는 상태이며, 누가 이기느냐에 따라 결과가 예측 불가능하게 바뀝니다.

경쟁 상태는 일반적으로 다음 세 가지 조건이 모두 충족될 때 발생합니다.

  1. 두 개 이상의 태스크가 존재한다.
  2. 하나 이상의 공유 자원(예: 변수, 메모리, 파일)에 접근한다.
  3. 적어도 하나의 태스크가 자원을 수정(write)한다.

전형적인 예시: 읽기-수정-쓰기 (Read-Modify-Write)

가장 전형적인 경쟁 상태는 ‘읽기-수정-쓰기’ 연산에서 나타납니다. shared_counter := shared_counter + 1; 과 같은 단순해 보이는 한 줄의 코드도, 저수준에서는 다음과 같은 여러 단계로 나뉩니다.

  1. 메모리에서 shared_counter의 현재 값을 읽어온다(Read).
  2. 읽어온 값을 1 수정(Modify)한다.
  3. 새로운 값을 다시 메모리의 shared_counter 위치에 쓴다(Write).

이제 두 개의 태스크가 shared_counter10일 때 이 연산을 동시에 수행한다고 가정해 보겠습니다. 우리가 기대하는 최종값은 12입니다.

잘못된 실행 순서 (경쟁 상태 발생):

  1. 태스크 Ashared_counter의 값 10을 읽습니다.
  2. (문맥 전환 발생)
  3. 태스크 Bshared_counter의 값 10을 읽습니다.
  4. 태스크 B10 + 1을 계산하여 11을 얻고, 그 결과를 shared_counter에 씁니다. (현재 shared_counter11)
  5. (문맥 전환 발생)
  6. 태스크 A는 이전에 읽었던 10을 기반으로 10 + 1을 계산하여 11을 얻고, 그 결과를 shared_counter에 씁니다.
  7. 최종 결과: shared_counter는 기대했던 12가 아닌 11이 됩니다. 갱신 연산 한 번이 소실된 것입니다.

임계 구역과 상호 배제

이처럼 공유 자원에 접근하여 경쟁 상태를 일으킬 수 있는 코드 영역을 임계 구역(Critical Section)이라고 합니다. 경쟁 상태를 해결하기 위한 유일한 방법은 이 임계 구역에 대해 상호 배제(Mutual Exclusion)를 보장하는 것입니다. 즉, 한 태스크가 임계 구역을 실행하고 있을 때는 다른 태스크가 절대 해당 구역에 진입할 수 없도록 막아야 합니다.

경쟁 상태는 비결정적인 특성 때문에 때로는 정상 동작하다가 아주 드물게 실패하여 찾아내기 매우 어려운 버그를 만듭니다. Ada의 보호 객체(Protected Object)는 바로 이러한 임계 구역을 안전하게 관리하고 상호 배제를 언어 차원에서 자동으로 보장하여, 경쟁 상태의 위험을 원천적으로 제거하도록 설계되었습니다.

17.3.2 교착 상태 (deadlocks)

교착 상태(Deadlock)는 경쟁 상태와 더불어 동시성 시스템에서 가장 경계해야 할 문제 중 하나입니다. 이는 두 개 이상의 태스크가 서로가 점유하고 있는 자원을 기다리며 더 이상 진행하지 못하고 영원히 멈춰 버리는 상황을 의미합니다. 옴짝달싹 못 하는 교착 상태에 빠진 태스크들은 시스템 자원을 점유한 채로 아무런 작업도 수행하지 못하므로, 시스템 전체의 성능 저하 또는 완전한 중단을 초래할 수 있습니다.

이 상황은 좁은 외나무다리 양 끝에서 두 사람이 마주친 상황에 비유할 수 있습니다 🤝.

  • 각 사람은 다른 사람이 비켜주기만을 기다립니다.
  • 아무도 양보하지 않으면(자원을 해제하지 않으면), 두 사람 모두 영원히 다리를 건너지 못합니다.

교착 상태의 발생 조건

교착 상태는 다음의 네 가지 조건(Coffman conditions)이 동시에 모두 충족될 때만 발생합니다. 하나라도 충족되지 않으면 교착 상태는 일어나지 않습니다.

  1. 상호 배제 (Mutual Exclusion): 최소한 하나의 자원은 한 번에 하나의 태스크만 사용할 수 있는 비공유 상태여야 합니다. (다리는 한 번에 한 사람만 지나갈 수 있습니다.)
  2. 점유 및 대기 (Hold and Wait): 태스크가 최소한 하나의 자원을 점유한 상태에서, 다른 태스크에 할당된 자원을 추가로 얻기 위해 대기해야 합니다. (각 사람은 다리 위 자신의 공간을 차지한 채, 상대방의 공간을 요구하며 기다립니다.)
  3. 비선점 (No Preemption): 다른 태스크가 점유한 자원을 강제로 빼앗을 수 없으며, 자원을 점유한 태스크가 자발적으로 해제해야만 합니다. (상대방을 밀쳐낼 수 없고, 스스로 비켜주기만 기다려야 합니다.)
  4. 순환 대기 (Circular Wait): 태스크들의 대기 관계가 원형을 이루어야 합니다. 즉, 태스크 1태스크 2가 가진 자원을 기다리고, 태스크 2태스크 1이 가진 자원을 기다리는 형태의 순환 고리가 형성되어야 합니다.

코드 예시

두 개의 잠금(lock) 자원 Lock_ALock_B를 사용하는 두 태스크를 통해 교착 상태를 쉽게 재현할 수 있습니다.

-- 개념적 예시: 교착 상태 발생
Lock_A : Mutex;
Lock_B : Mutex;

task body Task_1 is
begin
  Lock_A.acquire; -- 자원 A 획득
  -- ... 작업 수행 ...
  Lock_B.acquire; -- 자원 B를 기다림 (Task_2가 점유)  deadlock
end Task_1;

task body Task_2 is
begin
  Lock_B.acquire; -- 자원 B 획득
  -- ... 작업 수행 ...
  Lock_A.acquire; -- 자원 A를 기다림 (Task_1이 점유) deadlock
end Task_2;

Task_1Lock_A를 획득하고 Task_2Lock_B를 획득한 상태에서, 서로가 상대방의 잠금을 획득하려고 시도하면 순환 대기 조건이 만족되어 두 태스크 모두 영원히 대기하게 됩니다.

교착 상태를 예방하는 가장 일반적인 방법은 네 가지 조건 중 하나를 깨뜨리는 것이며, 특히 자원 획득 순서를 모든 태스크에서 동일하게 강제하여 순환 대기 조건을 원천적으로 차단하는 전략이 널리 사용됩니다. Ada는 우선순위 상한 프로토콜(Priority Ceiling Protocol)과 같은 고급 메커니즘을 제공하여 특정 조건 하에서 교착 상태를 방지하는 데 도움을 줍니다.

17.3.3 활성 잠금 (livelocks)

활성 잠금(Livelock)은 교착 상태와 유사하지만, 태스크들이 멈춰있는(blocked) 대신 계속해서 상태를 바꾸며 서로에게 반응하지만, 결과적으로 어떤 유용한 작업도 진행하지 못하는 상태를 말합니다. 즉, 태스크들은 활발하게(live) 움직이지만, 특정 상태에 갇혀(locked) 앞으로 나아가지 못합니다.

교착 상태와의 가장 큰 차이점은 CPU 사용 여부입니다.

  • 교착 상태 (Deadlock): 태스크들이 대기 상태에 빠져 CPU를 소모하지 않습니다.
  • 활성 잠금 (Livelock): 태스크들이 계속 실행 상태이므로 CPU를 소모하지만, 실질적인 진전이 없습니다.

가장 좋은 비유는 좁은 복도에서 마주친 두 명의 예의 바른 사람입니다 🕺💃.

  1. 두 사람이 마주치자, 서로 길을 비켜주기 위해 동시에 왼쪽으로 움직입니다.
  2. 여전히 길이 막힌 것을 확인하고, 이번에는 동시에 오른쪽으로 움직입니다.
  3. 이 과정을 무한히 반복합니다. 두 사람은 계속해서 움직이지만(live), 서로를 지나치지는 못합니다(locked).

발생 원인과 해결책

활성 잠금은 주로 교착 상태를 회피하려는 어설픈 알고리즘에서 발생합니다. 예를 들어, 여러 태스크가 자원을 획득하지 못하면, 자신이 가진 자원을 일단 해제하고 처음부터 다시 시도하도록 로직을 구현했다고 가정해 봅시다. 만약 두 태스크가 이 로직을 동시에 수행하면, 서로 자원을 획득했다가 해제하는 과정을 끊임없이 반복하며 활성 잠금에 빠질 수 있습니다.

개념적 예시:

  1. 태스크 A자원 1을 획득합니다.
  2. 태스크 B자원 2를 획득합니다.
  3. 태스크 A자원 2를 시도하지만 실패하자, 가진 자원 1을 포기하고 재시도합니다.
  4. 태스크 B자원 1을 시도하지만 실패하자, 가진 자원 2를 포기하고 재시도합니다.
  5. 이 과정이 잘못된 타이밍으로 계속 반복될 수 있습니다.

활성 잠금을 해결하는 가장 일반적인 방법은 이 반복되는 대칭 구조를 깨는 것입니다. 주로 무작위성(randomness)을 도입하여 해결합니다. 복도 비유에서 한 사람이 임의의 시간(예: 1초)을 기다린 후 움직이면, 두 사람의 움직임이 엇갈리게 되어 결국 길을 지나갈 수 있게 됩니다. 코드에서는 자원 획득을 재시도하기 전에 임의의 시간 동안 대기(randomized backoff)하도록 하여, 태스크들이 동일한 패턴으로 충돌하는 것을 방지할 수 있습니다.

Ada의 고수준 동시성 모델은 개발자가 이러한 저수준의 교착 회피 로직을 직접 구현하기보다, 보호 객체우선순위 상한 프로토콜과 같이 처음부터 문제가 발생하지 않도록 구조화된 설계를 장려함으로써 활성 잠금의 발생 가능성을 줄여줍니다.

17.3.4 기아 상태 (starvation)

기아 상태(Starvation)는 특정 태스크가 실행될 수 있는 상태임에도 불구하고, 스케줄러나 다른 태스크들의 자원 사용 패턴 때문에 CPU 시간이나 필요한 자원을 영원히 또는 매우 오랜 기간 할당받지 못하는 현상을 말합니다.

교착 상태나 활성 잠금과 달리, 기아 상태에서는 시스템 전체는 정상적으로 동작하며 다른 태스크들은 작업을 잘 수행하고 있을 수 있습니다. 문제는 특정 태스크만 소외되어 “굶주리는(starving)” 것입니다. 이는 동시성 시스템의 공정성(fairness)과 관련된 문제입니다.

이 현상은 신호등 없는 교차로에 비유할 수 있습니다 🚦.

  • 차량이 많은 주도로(high-priority tasks)는 계속해서 차가 지나갑니다.
  • 갓길(low-priority task)에 있는 차는 주도로에 진입할 기회를 계속 엿보지만, 틈이 나지 않아 영원히 기다릴 수도 있습니다.
  • 주도로의 교통 흐름은 원활하지만, 갓길의 차는 기아 상태에 빠진 것입니다.

주요 발생 원인

  1. 엄격한 우선순위 스케줄링: 가장 흔한 원인입니다. 만약 우선순위가 높은 태스크들이 끊임없이 시스템에 도착하여 실행된다면, 우선순위가 낮은 태스크들은 실행될 기회를 전혀 얻지 못할 수 있습니다.
  2. 불공정한 자원 관리: 자원 잠금(lock) 메커니즘이 요청 순서(FIFO)를 보장하지 않고, 무작위나 다른 기준으로 다음 태스크를 선택할 경우, 운이 없는 특정 태스크는 계속해서 자원 획득에 실패하고 기아 상태에 빠질 수 있습니다.

해결 방안

기아 상태를 해결하기 위한 핵심은 스케줄링과 자원 할당의 공정성을 높이는 것입니다.

  • 우선순위 노화 (Priority Aging): 오랫동안 대기한 태스크의 우선순위를 시간이 지남에 따라 점차 높여주는 기법입니다. 이를 통해 아무리 우선순위가 낮은 태스크라도 결국에는 실행될 기회를 보장받게 됩니다.
  • 공정한 큐잉 (Fair Queuing): 자원을 기다리는 태스크들을 선입선출(FIFO) 큐로 관리하여, 가장 오래 기다린 태스크에게 먼저 자원을 할당하는 방식입니다.

Ada는 이러한 문제에 대한 해결책을 언어 차원에서 제공합니다. 예를 들어, 보호 객체의 엔트리(entry)는 기본적으로 태스크들을 공정한 FIFO 큐로 관리합니다. 또한 실시간 부록(Real-Time Annex)에서 제공하는 다양한 스케줄링 정책(FIFO_Within_Priorities 등)을 통해 개발자는 시스템의 요구사항에 맞게 공정성을 조절하여 특정 태스크가 무한정 대기하는 기아 상태를 방지할 수 있습니다.

18. 태스크(task) - Ada 동시성의 기본 단위

이전 12장에서는 동시성의 개념과 그 모델, 그리고 경쟁 상태나 교착 상태와 같은 도전 과제들을 살펴보았습니다. 이제 우리는 이러한 이론적 배경을 바탕으로, Ada가 동시성을 구현하는 핵심 요소인 태스크(Task)에 대해 본격적으로 학습할 것입니다.

다른 많은 언어들이 라이브러리 형태로 스레드(thread)를 제공하는 것과 달리, Ada의 태스크는 언어 자체에 내장된 일급 시민(first-class citizen)입니다. 이는 태스크의 생성, 실행, 통신 및 종료에 이르는 모든 과정이 언어의 문법과 규칙에 의해 명확하게 정의되고 관리됨을 의미합니다. 이러한 접근 방식은 동시성 프로그램을 더욱 구조적이고 예측 가능하며, 무엇보다도 안전하게 만듭니다.

태스크는 독립적인 실행 흐름을 나타내는 Ada 동시성의 기본 단위입니다. 각 태스크는 자신만의 생명주기를 가지며 다른 태스크와 병행하여 작업을 수행할 수 있습니다. 본질적으로 하나의 작은 독립 프로그램처럼 동작한다고 생각할 수 있습니다.

이번 장에서는 먼저 태스크의 기본 개념과 종류를 알아보고, 태스크가 생성되고, 활성화되며, 실행을 마친 후 종료되기까지의 생명주기를 상세히 추적할 것입니다. 또한, 태스크의 명세(specification)와 몸체(body)를 선언하고 정의하는 구체적인 구문과 규칙을 배웁니다. 마지막으로 태스크가 언제 실행을 시작하고, 어떻게 정상적으로 혹은 비정상적으로 종료되는지에 대한 명확한 규칙을 익히게 될 것입니다.

이 장을 통해 독자 여러분은 Ada 태스크를 독립적인 실행 단위로서 완벽히 이해하게 될 것입니다. 이는 다음 장에서 다룰 태스크 간의 통신과 동기화, 즉 랑데부(rendezvous)를 이해하기 위한 필수적인 초석이 될 것입니다.

18.1 태스크의 개념

Ada에서 동시성을 이해하는 여정은 그 기본 구성 요소인 태스크(task)의 개념을 파악하는 것에서 시작합니다. 태스크는 단순히 병렬로 실행되는 코드 조각이 아니라, 그 자체로 명확한 구조와 규칙을 갖는 정교한 언어적 구성 요소입니다. 패키지나 프로시저처럼, 태스크도 명세와 몸체를 가지며 캡슐화와 추상화의 원칙을 따릅니다.

이 절에서는 태스크의 본질을 깊이 있게 탐구합니다. 먼저 태스크가 무엇인지 명확히 정의하고, 프로그램 내에서 독립적인 실행 흐름으로서 어떤 역할을 하는지 살펴볼 것입니다. 이어서, 여러 개의 유사한 태스크를 생성하기 위한 템플릿인 태스크 타입(task type)과, 유일무이한 단일 실행 단위인 단일 태스크(single task)의 차이점을 배웁니다. 마지막으로, 모든 태스크가 거치는 생성, 활성화, 실행, 종료의 4단계로 이루어진 생명주기를 이해함으로써 태스크의 동적인 동작 방식을 파악하게 될 것입니다.

18.1.1 태스크란 무엇인가?

태스크(Task)는 프로그램 내에서 다른 코드와 독립적으로, 그리고 동시에(concurrently) 실행될 수 있는 하나의 실행 흐름(flow of control)을 나타내는 Ada의 언어적 구성 요소입니다. 각 태스크는 자신만의 스택(stack)과 상태를 가지며, 기본적으로 프로그램 내의 작은 자율적인 하위 프로그램처럼 동작합니다.

프로시저나 패키지와 같은 다른 프로그램 단위와의 가장 큰 차이점은 태스크가 능동적인(active) 개체라는 점입니다.

  • 수동적 객체 (Passive Object): 프로시저나 함수는 호출될 때만 실행됩니다.
  • 능동적 객체 (Active Object): 태스크는 일단 활성화되면, 누가 명시적으로 호출하지 않아도 스스로 자신의 코드 실행을 시작하고 부모 코드(master)와 병행하여 작업을 수행합니다.

이는 마치 주방장이 요리를 하면서 동시에 재료를 준비할 보조 요리사를 고용하는 것과 같습니다 👨‍🍳. 주방장(메인 프로그램)이 보조 요리사(태스크)에게 할 일을 알려주면, 보조 요리사는 주방장의 작업과 동시에 자신의 일을 자율적으로 처리하기 시작합니다.

모든 태스크는 다른 프로그램 단위와 마찬가지로 두 부분으로 구성됩니다.

  • 태스크 명세 (Task Specification): 태스크의 공개적인 인터페이스입니다. 다른 태스크와 통신하기 위한 접점인 엔트리(entry)를 선언하는 곳입니다. 외부와의 상호작용이 없는 태스크라면 명세가 비어있을 수도 있습니다.
  • 태스크 몸체 (Task Body): 태스크가 실제로 수행할 코드의 연속입니다. 태스크가 활성화되면 이 몸체에 정의된 문장들이 순차적으로 실행됩니다.

요약하자면, 태스크는 Ada가 제공하는 자율적인 작업 단위로, 언어 차원에서 그 생명주기와 상호작용이 관리되어 개발자가 저수준의 스레드 관리에 신경 쓰지 않고 동시성 로직 자체에 집중할 수 있도록 돕습니다.

18.1.2 태스크 타입과 단일 태스크

Ada는 태스크를 선언하는 두 가지 방법을 제공하여, 설계의 유연성을 높입니다. 바로 단일 태스크(single task)와 태스크 타입(task type)입니다. 이 둘의 차이는 정수(Integer) 타입과 정수형 변수의 관계와 유사합니다.

1. 단일 태스크 (Single Task)

단일 태스크는 이름 그대로, 프로그램 내에서 유일무이한 하나의 태스크 객체를 직접 선언하는 방식입니다. 별도의 타입 정의 없이 태스크 객체 자체가 선언과 동시에 생성됩니다.

이는 시스템 전체에서 단 하나만 존재해야 하는 고유한 서비스나 관리자를 표현할 때 유용합니다. 예를 들어, 시스템의 모든 로그를 기록하는 중앙 로거(Logger)나 특정 하드웨어를 제어하는 관리자 태스크 등이 이에 해당합니다.

선언 방식: task 키워드 뒤에 바로 태스크의 이름을 명시합니다.

-- 단일 태스크 선언
task Central_Logger is
  entry log_message (msg : in String);
end Central_Logger;

task body Central_Logger is
  -- ... 로그 메시지를 파일이나 콘솔에 기록하는 구현 ...
begin
  -- ... 태스크의 주 실행 로직 ...
  null;
end Central_Logger;

위 코드에서 Central_Logger는 타입 이름이 아니라, 프로그램에 단 하나 존재하는 태스크 객체 그 자체를 가리킵니다.

2. 태스크 타입 (Task Type)

태스크 타입은 태스크를 생성하기 위한 템플릿(template) 또는 설계도를 정의하는 것입니다. task type 선언 자체는 실행되는 태스크를 만들지 않습니다. 대신, 이 타입을 사용하여 여러 개의 동일한 구조와 동작을 가진 태스크 객체들을 변수처럼 선언할 수 있습니다.

이는 서버에서 다수의 클라이언트 요청을 각각 처리하는 핸들러들이나, 병렬 계산을 위해 여러 개의 동일한 작업자(worker)들을 생성하는 경우에 매우 유용합니다.

선언 방식: task type 키워드 뒤에 타입의 이름을 명시합니다.

-- 태스크 타입 선언
task type Worker is
  entry assign_job (job_data : in Some_Data);
end Worker;

task body Worker is
  -- ... 할당된 작업을 처리하는 구현 ...
begin
  -- ...
  null;
end Worker;

이제 Worker 타입을 사용하여 실제 태스크 객체들을 생성할 수 있습니다.

-- 태스크 객체 생성
worker_1 : Worker;
worker_2 : Worker;

-- 태스크 객체 배열 생성
worker_pool : array (1 .. 10) of Worker;

worker_1, worker_2, 그리고 worker_pool의 각 요소들은 모두 Worker 타입의 독립적인 실행 흐름을 갖는 태스크 객체들입니다.

핵심 비교

구분 단일 태스크 (Single Task) 태스크 타입 (Task Type)
개념 하나의 고유한 태스크 객체 태스크 객체를 위한 템플릿/설계도
선언 문법 task <이름> is ... task type <이름> is ...
인스턴스화 선언과 동시에 1개 생성 타입을 이용해 변수처럼 여러 개 생성 가능
주요 용도 시스템의 유일한 서비스 ⚙️ 다수의 동일한 작업자들 👨‍👩‍👧‍👦

이처럼 Ada는 풀고자 하는 문제의 성격에 맞춰 동시성 단위를 유연하게 모델링할 수 있는 강력한 기능을 제공합니다.

18.1.3 태스크의 생명주기 (생성, 활성화, 실행, 종료)

모든 Ada 태스크는 예측 가능하고 명확하게 정의된 생명주기(lifecycle)를 따릅니다. 이 구조화된 생명주기는 태스크가 안전하게 시작되고 소멸되도록 보장하여, 많은 동시성 오류를 원천적으로 방지합니다. 생명주기는 생성, 활성화, 실행, 종료의 네 가지 주요 단계로 구성됩니다.

(이 과정은 상태 전이 다이어그램(state transition diagram)으로 시각화하면 이해하기 더욱 쉽습니다.)

1. 생성 (Creation)

  • 무엇인가? 태스크 객체가 선언되고 관련 메모리가 할당되는 단계입니다. 이 시점에서 태스크 객체는 존재하지만, 아직 독립적인 실행 흐름을 시작하지는 않은 휴면 상태입니다.
  • 언제? 태스크가 선언된 범위(scope)의 선언부가 처리될 때(elaborated) 발생합니다.

2. 활성화 (Activation)

  • 무엇인가? 태스크가 실행될 준비를 마치고, 본격적인 실행에 앞서 내부 초기화를 수행하는 단계입니다. 태스크 몸체의 isbegin 사이에 있는 선언부가 이때 실행됩니다. 활성화가 끝나면 태스크는 실행 가능한(runnable) 상태가 되어 스케줄러의 선택을 기다립니다.
  • 언제? 활성화 시점은 Ada의 안정성을 보장하는 핵심 규칙 중 하나입니다. 태스크는 자신을 포함하는 부모(master) 단위의 선언부 실행이 모두 끝난 직후, 그리고 부모 단위의 begin 뒤 실행문이 시작되기 전에 활성화됩니다. 이는 태스크가 실행을 시작하기 전에 필요한 모든 환경이 준비되었음을 보장합니다.

3. 실행 (Execution)

  • 무엇인가? 태스크의 실제 작업이 이루어지는 단계입니다. 태스크는 스케줄러에 의해 CPU 시간을 할당받아, 태스크 몸체의 beginend 사이의 문장들을 다른 태스크들과 병행하여 실행합니다.
  • 언제? 활성화 이후, 스케줄러의 정책과 태스크의 우선순위에 따라 실행됩니다.

4. 종료 (Termination)

  • 무엇인가? 태스크가 모든 실행을 마치고 시스템에서 사라지는 마지막 단계입니다.
  • 언제? 태스크의 실행이 몸체의 마지막 end에 도달하면, 태스크는 완료(completed) 상태가 됩니다. 하지만 즉시 사라지지는 않습니다. 태스크는 자신이 만든 모든 자식 태스크(child task)들이 종료될 때까지 기다려야 합니다. 그 후, 자신을 포함하는 부모(master) 단위가 종료될 준비가 되었을 때 비로소 종료(terminated)됩니다. 이 의존성 규칙은 부모 스코프가 아직 실행 중인 자식 태스크를 남겨두고 먼저 사라지는 위험한 상황을 방지합니다.

생명주기 요약

단계 이름 설명 트리거
1 생성 태스크 객체의 메모리 할당 태스크 선언부 처리 시
2 활성화 실행 준비 및 내부 초기화 부모(Master)의 선언부 종료 직후
3 실행 태스크 몸체의 주 로직 실행 스케줄러에 의한 CPU 할당
4.1 완료 태스크 몸체의 실행을 마침 태스크 몸체의 마지막 end 도달
4.2 종료 태스크의 완전한 소멸 완료 후, 자식/부모와의 의존성 해결 시

이처럼 엄격하게 관리되는 생명주기는 Ada 동시성 프로그래밍의 신뢰성을 뒷받침하는 핵심적인 특징입니다.

18.2 태스크 선언 및 정의

앞 절에서 태스크의 개념과 생명주기를 이해했다면, 이제는 이 개념들을 실제 Ada 코드로 구현하는 방법을 배울 차례입니다. 이 절에서는 태스크를 선언하고 그 동작을 정의하는 구체적인 구문(syntax)을 다룹니다.

Ada의 다른 주요 구조(패키지, 서브프로그램 등)와 마찬가지로, 태스크 역시 명세(specification)몸체(body)의 분리 원칙을 따릅니다. 이 구조는 태스크의 공개 인터페이스와 내부 구현을 명확하게 분리하여 코드의 가독성과 모듈성을 높입니다.

  • 태스크 명세는 태스크의 ‘얼굴’로서, 다른 태스크와 상호작용할 수 있는 통신 지점, 즉 엔트리(entry)들을 정의합니다.
  • 태스크 몸체는 태스크가 실제로 수행할 코드의 집합으로, 그 내부 로직을 담고 있습니다.

이 절의 각 소절을 통해 태스크 타입을 선언하는 방법, 태스크 객체를 생성하는 방법, 그리고 명세와 몸체를 올바르게 작성하는 규칙을 상세하게 학습할 것입니다.

18.2.1 태스크 타입 선언

태스크 타입(task type)은 동일한 동작과 인터페이스를 갖는 여러 태스크 객체를 생성하기 위한 템플릿 또는 설계도를 정의하는 방법입니다. 이 선언 자체는 실행 중인 태스크를 만들지 않으며, 오직 타입의 명세(specification)만을 정의합니다.

기본 구문

태스크 타입의 선언은 task type 키워드로 시작하며, 태스크의 공개 인터페이스를 정의합니다. 여기에는 다른 태스크와의 통신 지점인 0개 이상의 엔트리(entry) 선언이 포함될 수 있습니다.

task type <Task_Type_Name> is
  entry <Entry_Name_1> (formal_parameters);
  entry <Entry_Name_2> (formal_parameters);
  -- ... 추가 엔트리 선언 ...
end <Task_Type_Name>;
  • <Task_Type_Name>: Pascal_Case 명명 규칙을 따르는 타입의 이름입니다.
  • entry: 외부에서 이 태스크와 통신(랑데부)하기 위해 호출할 수 있는 진입점입니다. 프로시저 선언과 유사한 형태를 가집니다.

선언 예시

다수의 클라이언트 연결을 처리하는 작업자(worker) 태스크를 위한 타입을 선언하는 예시는 다음과 같습니다.

package Server is

  -- 클라이언트 연결을 처리하는 작업자 태스크의 타입을 정의합니다.
  -- 이 타입의 태스크 객체들은 외부로부터 `Connection_Id`를 받아
  -- 해당 연결을 처리하는 단일 진입점을 가집니다.
  task type Connection_Handler is
    entry process_new_connection (connection_id : in Integer);
  end Connection_Handler;

end Server;

위의 Connection_Handler 선언은 하나의 설계도를 만든 것입니다. 이 자체만으로는 어떠한 동시성 활동도 일어나지 않습니다. 이 타입을 사용하여 실제 태스크 객체를 변수처럼 선언해야 비로소 독립적인 실행 흐름이 생성됩니다.

이처럼 태스크 타입을 사용하면 코드 재사용성을 높이고, 워커 풀(worker pool)이나 동적 태스크 생성과 같이 유연하고 확장 가능한 동시성 패턴을 구현할 수 있습니다.

18.2.2 태스크 객체 선언

태스크 타입을 정의했다면, 다음 단계는 이 설계도를 바탕으로 실제 동작하는 태스크 객체(task object)를 만드는 것입니다. 태스크 객체를 선언하는 순간, 프로그램 내에 새로운 동시성 실행 흐름이 생성될 준비를 합니다.

태스크 객체는 일반적인 변수와 매우 유사한 방식으로 선언할 수 있습니다. 이는 Ada 태스크가 언어의 일급 시민이라는 강력한 증거입니다.

태스크 타입으로부터 객체 선언

앞서 13.2.1절에서 정의한 task type을 사용하여 다음과 같이 태스크 객체를 생성할 수 있습니다.

-- 13.2.1절에서 정의한 Server.Connection_Handler 타입을 사용합니다.

-- 단일 태스크 객체 선언
-- handler_1은 독립적으로 실행되는 하나의 태스크입니다.
handler_1 : Server.Connection_Handler;

-- 태스크 객체 배열 선언
-- 10개의 독립적인 Connection_Handler 태스크를 담는 배열입니다.
-- 10개의 클라이언트 요청을 동시에 처리하는 워커 풀(worker pool)을 구성합니다.
handler_pool : array (1 .. 10) of Server.Connection_Handler;

-- 레코드(record)의 구성요소(component)로 선언
type Session_Info is
  record
    session_id : Integer;
    handler    : Server.Connection_Handler; -- 각 세션은 자신만의 처리 태스크를 가짐
  end record;

위 예시에서 handler_1handler_pool의 각 요소는 모두 개별적인 생명주기를 가지며 병행 실행되는 독립적인 태스크 객체입니다.

단일 태스크 객체 선언

만약 시스템에 단 하나만 필요한 고유한 태스크라면, 13.1.2절에서 배운 단일 태스크 선언을 사용합니다. 이 구문은 타입 정의와 객체 선언을 한 번에 수행하는 간결한 방법입니다.

-- 시스템의 중앙 데이터베이스 접근을 관리하는 단 하나의 태스크 객체
task Database_Manager is
  entry read_data (key : in String; value : out Data);
  entry write_data (key : in String; value : in Data);
end Database_Manager;

여기서 Database_Manager는 타입이 아닌, 그 자체로 유일한 태스크 객체의 이름입니다.

동적 태스크 객체 생성

태스크 타입을 사용하면 접근 타입(access type, 포인터)을 통해 힙(heap)에 태스크 객체를 동적으로 생성할 수도 있습니다. 이는 프로그램 실행 중에 필요한 만큼 태스크를 만들고 없애야 하는 유연한 설계에 사용됩니다.

type Handler_Access is access Server.Connection_Handler;

-- 처음에는 태스크가 없는 포인터 변수
handler_ptr : Handler_Access;
...
-- new 키워드로 새로운 태스크 객체를 동적으로 생성하고 활성화
handler_ptr := new Server.Connection_Handler;

이처럼 태스크 객체 선언은 정적인 배열부터 동적인 할당에 이르기까지, 일반적인 데이터 타입과 동일한 유연성을 제공하여 다양한 동시성 아키텍처를 효과적으로 구축할 수 있게 합니다.

18.2.3 태스크 명세(specification)와 몸체(body)

Ada의 핵심 설계 철학인 ‘명세와 구현의 분리’ 원칙은 태스크에도 동일하게 적용됩니다. 이는 태스크의 공개적인 약속(인터페이스)과 실제 내부 동작(구현)을 분리하여 프로그램의 모듈성과 가독성을 크게 향상시킵니다.

태스크 명세 (Task Specification)

태스크 명세는 태스크의 공개 계약(public contract)입니다. 다른 프로그램 단위가 이 태스크와 어떻게 상호작용할 수 있는지를 정의하며, 태스크의 내부 구현은 완벽하게 숨깁니다.

명세의 가장 중요한 역할은 다른 태스크가 통신을 요청할 수 있는 진입점인 엔트리(entry)들을 선언하는 것입니다.

-- 태스크 명세 예시
-- 외부 세계에 공개되는 태스크의 '얼굴'입니다.
task Central_Data_Store is
  -- 데이터를 저장하는 'store' 서비스를 제공합니다.
  entry store (key : in Integer; value : in String);

  -- 키에 해당하는 데이터를 조회하는 'retrieve' 서비스를 제공합니다.
  entry retrieve (key : in Integer; value : out String);
end Central_Data_Store;

위 명세는 Central_Data_Store라는 태스크가 storeretrieve라는 두 가지 동기화된 서비스를 제공함을 외부 세계에 약속합니다.

태스크 몸체 (Task Body)

태스크 몸체는 명세에서 약속한 서비스들의 실제 구현을 담고 있습니다. 이 부분은 태스크의 내부 로직이며, 외부에서는 접근하거나 알 필요가 없습니다. 태스크가 활성화되면 바로 이 몸체에 정의된 코드가 병행 실행됩니다.

몸체는 주로 명세에 선언된 entry에 대한 accept 문으로 구성됩니다. accept 문은 해당 엔트리로의 호출이 올 때까지 태스크의 실행을 멈추고 기다리는 역할을 합니다.

-- 태스크 몸체 예시
-- 명세에서 약속한 기능의 실제 동작을 구현합니다.
task body Central_Data_Store is
  -- 이 태스크만 사용하는 내부 변수들 (외부에 비공개)
  type Data_Map is new Ada.Containers.Ordered_Maps (Integer, String);
  data : Data_Map;
begin
  -- 태스크는 보통 무한 루프를 돌며 서비스 요청을 기다립니다.
  loop
    select
      -- 'store' 엔트리 호출을 받아들입니다.
      accept store (key : in Integer; value : in String) do
        -- 이 do..end 블록이 '랑데부(Rendezvous)' 구간입니다.
        -- 호출한 태스크는 이 구간의 실행이 끝날 때까지 대기합니다.
        data.insert (key, value);
      end store;
    or
      -- 'retrieve' 엔트리 호출을 받아들입니다.
      accept retrieve (key : in Integer; value : out String) do
        value := data.element (key);
      end retrieve;
    or
      -- 더 이상 수신할 요청이 없으면 종료를 허용하는 대안
      terminate;
    end select;
  end loop;
end Central_Data_Store;

select 문은 태스크가 여러 종류의 엔트리 호출을 동시에 기다릴 수 있게 해주는 강력한 구문입니다. 어느 쪽이든 먼저 도착하는 요청을 받아 처리할 수 있어, 응답성 좋은 서버와 같은 태스크를 쉽게 만들 수 있습니다. (이는 14장에서 자세히 다룹니다.)

이처럼 명세와 몸체의 분리는 태스크의 내부 구현 방식을 변경하더라도, 명세(공개 계약)만 동일하다면 태스크를 사용하는 외부 코드에 전혀 영향을 주지 않도록 보장합니다. 이는 대규모 동시성 시스템의 유지보수와 개발에 있어 매우 중요한 장점입니다.

18.3 태스크 활성화와 종료

태스크의 정적인 구조를 선언하고 정의하는 방법을 배웠으니, 이제는 태스크의 동적인 생명주기, 즉 태스크가 어떻게 ‘생명을 얻고’ 또 어떻게 ‘생을 마감하는지’에 대한 규칙을 알아볼 차례입니다.

Ada는 태스크의 활성화(activation)종료(termination)에 대해 매우 명확하고 엄격한 규칙을 적용합니다. 이러한 규칙들은 동시성 시스템의 예측 가능성을 보장하고, 자원 누수나 부모 객체의 조기 소멸과 같은 고질적인 오류들을 방지하기 위해 신중하게 설계되었습니다.

이 절에서는 태스크가 정확히 어느 시점에 실행을 시작하는지, 그리고 정상적인 상황과 예외적인 상황에서 어떻게 안전하게 종료되는지를 다룹니다. 이 규칙들을 이해하는 것은 올바른 동시성 프로그램을 작성하기 위한 필수적인 지식입니다.

18.3.1 태스크 활성화 규칙

태스크 활성화(Task Activation)는 생성된 태스크 객체가 비로소 생명을 얻어 실행 가능한 상태가 되는 과정을 말합니다. Ada는 이 활성화 시점을 매우 명확하게 규정하여, 동시성 동작의 예측 가능성과 안정성을 보장합니다.

핵심 활성화 규칙

태스크는 자신을 선언한 부모(master) 단위선언부(declarative part)가 끝난 직후, 그리고 부모의 실행부(sequence of statements)가 시작되기 바로 전에 활성화됩니다.

여기서 부모(master)란 태스크 객체가 선언된 서브프로그램, 블록, 패키지 몸체, 또는 다른 태스크 몸체를 의미합니다.

실행 흐름 예시

다음 프로시저의 실행 흐름을 통해 활성화 규칙을 이해해 보겠습니다.

procedure Test_Activation is
  -- 부모(Test_Activation)의 선언부 시작
  Start_Time : Time := Clock;
  My_Worker  : Worker;  -- 1. My_Worker 태스크 객체가 '생성'됩니다.
  End_Time   : Time := Clock;
begin -- 2. '활성화 지점': 선언부 처리가 모두 끝났습니다.
      --    이 지점에서 My_Worker 태스크가 '활성화'됩니다.
      --    (My_Worker의 몸체 선언부가 실행됩니다)

  -- 3. 아래 문장은 My_Worker 태스크와 병행하여 실행됩니다.
  put_line ("Test_Activation 프로시저 실행 시작");

  -- ... 다른 작업 수행 ...

end Test_Activation; -- 4. 부모는 여기서 My_Worker가 종료될 때까지 기다립니다.

흐름 요약:

  1. My_Worker 태스크 객체가 생성됩니다. 아직 실행되지는 않습니다.
  2. 부모 프로시저의 선언부(isbegin 사이)가 모두 처리된 후, begin에 이르렀을 때가 활성화 지점입니다.
  3. My_Worker의 활성화가 시작되고(태스크 몸체의 선언부 코드가 실행됨), 이와 동시에 부모 프로시저의 begin 이하 코드도 실행을 시작합니다.
  4. 이제 My_WorkerTest_Activation 프로시저는 각자의 길을 가는 두 개의 병행 실행 흐름이 됩니다.

규칙의 존재 이유

이 규칙은 안전성을 위한 핵심 장치입니다. 태스크가 자신의 주변 환경(부모에 선언된 변수나 상수 등)에 의존하는 경우, 그 환경이 완전히 초기화된 이후에 태스크가 실행을 시작하도록 보장합니다. 이는 불안정한 상태에서 태스크가 실행되어 발생하는 미묘한 초기화 오류를 원천적으로 차단합니다.

활성화 실패

만약 태스크의 활성화 과정(태스크 몸체의 선언부 실행) 중 예외가 발생하면, 해당 태스크는 즉시 종료(Terminated) 상태가 됩니다. 그리고 부모 단위의 활성화 지점에서 Tasking_Error 예외가 발생하여, 시스템이 비정상적인 상황을 인지하고 대처할 수 있도록 합니다.

18.3.2 태스크의 정상적인 종료

Ada에서 태스크의 종료는 단순히 코드 실행이 끝나는 것을 의미하지 않습니다. 이는 관련된 다른 태스크 및 부모(master) 단위와의 세심한 조율(coordination)을 통해 이루어지는 구조화된 과정입니다. 이 “뒷정리” 규칙은 시스템 전체의 안정성을 보장하고 자원 누수를 방지하는 핵심적인 역할을 합니다.

정상적인 종료는 완료(Completion)와 종료(Termination)라는 두 단계로 구분됩니다.

1. 완료 (Completed)

태스크가 자신의 몸체(task body)에 있는 마지막 end 문에 도달하면, 해당 태스크는 완료(completed) 상태가 됩니다. 이는 태스크가 자신의 모든 코드 실행을 마쳤음을 의미합니다.

비유: 직원이 그날 자신에게 할당된 모든 업무를 마치고 책상을 정리한 상태와 같습니다. 퇴근할 준비는 되었지만, 아직 회사를 떠나지는 않았습니다.

완료된 태스크는 더 이상 자신의 코드를 실행하지 않지만, 시스템에서 완전히 사라진 것은 아니며 종료 조건을 기다립니다.

2. 종료 (Terminated)

종료(Terminated)는 태스크가 시스템에서 완전히 소멸되고 모든 자원이 회수되는 최종 단계입니다. 태스크가 종료되기 위한 핵심 규칙은 다음과 같습니다.

종료 규칙: 태스크는 자신이 완료(completed)된 상태이고, 자신을 포함하는 부모(master) 단위가 종료될 준비가 되었을 때 비로소 종료(terminated)될 수 있다.

이 규칙은 중요한 의존성을 만듭니다. 부모는 자신의 자식 태스크들이 모두 종료될 때까지 자신의 실행을 마칠 수 없습니다.

procedure Main is
  task Worker_Task is
    -- ...
  end Worker_Task;

  task body Worker_Task is
  begin
    put_line ("일꾼: 작업 시작!");
    -- ... 작업 수행 ...
    put_line ("일꾼: 작업 완료!");
  end Worker_Task; -- 1. Worker_Task는 여기서 '완료(completed)' 상태가 됩니다.

begin -- 부모(Main)의 실행부
  put_line ("주인: 메인 작업 시작!");
  -- ... 다른 작업 수행 ...
  put_line ("주인: 메인 작업 완료!");
end Main; -- 2. 부모(Main)는 자신의 실행을 마쳤습니다.
          --    이제 자식인 Worker_Task가 종료되기를 기다립니다.
          -- 3. Worker_Task가 이미 '완료' 상태이므로 즉시 '종료'됩니다.
          -- 4. 자식이 모두 종료되었으므로, Main 프로시저도 비로소 종료됩니다.

Main 프로시저의 마지막 end에서, Worker_Task가 종료될 때까지 암묵적인 대기가 발생합니다. 이 “어떤 태스크도 남겨두고 가지 않는다”는 정책 덕분에, 프로그램이 종료될 때 백그라운드 태스크가 여전히 실행 중인 위험한 상황을 자동으로 방지할 수 있습니다.

terminate 대안

서버처럼 무한 루프를 도는 태스크는 자연적으로 end에 도달할 수 없습니다. 이런 태스크들을 위해 select 문 안에 terminate 대안을 사용할 수 있습니다.

select
  accept some_entry (...) do ... end;
or
  terminate;
end select;

terminate 대안은 오직 부모가 종료되기를 기다리는 상황일 때만 선택될 수 있습니다. 이는 시스템 전체가 종료 수순에 들어갔을 때, 서버 태스크가 스스로 루프를 빠져나와 우아하게 종료될 수 있는 협력적인 메커니즘을 제공합니다.

18.3.3 abort 문을 이용한 비정상적 종료

정상적인 종료가 태스크 간의 협력적인 과정이라면, abort 문은 다른 태스크를 강제적으로, 그리고 즉시 중단시키는 비협력적인 명령입니다. 이는 시스템의 비상 브레이크 🚨 와 같아서, 반드시 필요할 때만 극도의 주의를 기울여 사용해야 하는 강력하고 위험한 기능입니다.

abort 문의 동작

abort 문은 하나 이상의 지정된 태스크를 즉시 비정상적으로 완료(abnormally completed) 상태로 만듭니다.

abort Worker_1, Worker_Pool (3);

abort가 실행되면, 대상 태스크는 현재 수행하던 작업이 무엇이든 그 자리에서 즉시 멈춥니다.

  • 실행 중이던 문장을 완료하지 않습니다.
  • 예외 처리기(exception 핸들러)나 최종 처리기(finally 블록 등)가 실행될 기회를 갖지 못합니다.
  • 태스크는 즉시 ‘완료’ 상태가 되고, 정상 종료와 마찬가지로 종료 규칙(자식 태스크 대기 등)에 따라 최종적으로 ‘종료’됩니다.

핵심적인 차이는 ‘완료’ 상태에 도달하는 방식입니다. 정상 종료는 스스로의 작업을 마치는 것이고, 비정상적 종료는 외부의 강제 명령에 의해 실행이 단절되는 것입니다.


abort 사용의 위험성

abort는 태스크가 스스로 뒷정리를 할 기회를 박탈하므로, 심각한 부작용을 초래할 수 있습니다.

  1. 자원 누수 (Resource Leaks): 태스크가 잠금(lock), 파일, 네트워크 연결 등의 자원을 획득한 상태에서 abort되면, 이 자원들을 해제하는 코드가 실행되지 않아 자원이 영원히 잠기거나 유출될 수 있습니다. 이는 다른 태스크의 교착 상태나 시스템 전체의 불안정성으로 이어집니다.
  2. 데이터 불일치 (Data Inconsistency): 공유 데이터 구조를 여러 단계에 걸쳐 수정하는 도중에 태스크가 abort되면, 해당 데이터는 불완전하고 오염된 상태로 남게 됩니다.
  3. 예측 불가능성: 시스템의 일부가 예측 불가능한 상태에 빠지게 되어, 전체 프로그램의 신뢰성을 심각하게 훼손합니다.

abort 문은 태스크가 제어 불능의 무한 루프에 빠지는 등, 다른 어떤 방법으로도 멈출 수 없는 명백한 오류 상태에 있을 때만 고려해야 하는 최후의 수단입니다.

훌륭한 동시성 설계는 shutdown 엔트리를 만들어 태스크가 협력적으로 종료하도록 유도하는 등, abort에 의존하지 않고도 시스템을 제어할 수 있는 메커니즘을 갖추어야 합니다. Ada의 안전한 동시성 모델의 장점을 최대한 활용하기 위해서는 abort 문의 사용을 가급적 피하는 것이 바람직합니다.

19. 태스크 간 통신과 동기화: 랑데부(rendezvous)

이전 13장에서는 Ada 동시성의 기본 단위인 태스크를 생성하고, 그 생명주기를 관리하는 방법을 배웠습니다. 하지만 독립적으로 실행되는 태스크들만으로는 복잡한 협력 작업을 수행할 수 없습니다. 진정한 동시성 시스템의 힘은 태스크들이 서로 통신(communication)하고, 작업 순서를 맞추며 동기화(synchronization)할 때 발휘됩니다.

Ada는 태스크 간의 직접적이고 안전한 상호작용을 위해 랑데부(Rendezvous)라는 우아하고 강력한 메커니즘을 제공합니다. ‘랑데부’는 프랑스어로 ‘만남’을 의미하며, 이름 그대로 두 태스크가 약속된 지점에서 만나 데이터를 교환하고 실행을 동기화하는 과정을 완벽하게 표현합니다.

이 모델은 데이터를 공유 메모리에 두고 잠금(lock)으로 제어하는 복잡하고 오류가 발생하기 쉬운 방식 대신, 명시적인 메시지 전달 방식을 언어 차원에서 구현한 것입니다. 하나의 태스크(호출자)가 다른 태스크(제공자)의 특정 서비스(entry)를 호출하면, 두 태스크는 랑데부 지점에서 동기화됩니다. 이 ‘만남’ 동안 안전하게 정보가 교환되고, 만남이 끝나면 두 태스크는 다시 각자의 길을 갑니다.

이번 장에서는 랑데부를 구성하는 핵심 요소인 entry, accept, 그리고 select 구문을 상세히 배울 것입니다. 먼저 랑데부의 기본 개념과 동작 방식을 이해하고, when 조건절(가드)을 통해 엔트리 호출을 선택적으로 수락하는 방법을 학습합니다. 마지막으로, 시간제한(timed) 및 조건부(conditional) 호출과 같은 고급 통신 패턴을 구현하는 select 문의 다양한 활용법을 익히게 될 것입니다.

이 장을 통해 독자 여러분은 Ada 태스크들이 어떻게 안전하고 구조적으로 협력하는지를 이해하고, 신뢰성 높은 동시성 시스템의 핵심 상호작용을 구현할 수 있게 될 것입니다.

19.1 랑데부의 개념

태스크 간의 통신을 이해하기 위한 첫걸음은 랑데부(Rendezvous)의 기본 개념과 그 구성 요소를 파악하는 것입니다. 랑데부는 단순한 데이터 교환을 넘어, 두 태스크의 실행 흐름을 일시적으로 하나로 묶는 강력한 동기화(synchronization) 메커니즘입니다. 이 동기화된 ‘만남’을 통해 데이터는 경쟁 상태 없이 안전하게 전달됩니다.

이 절에서는 랑데부를 구성하는 네 가지 핵심 요소를 순서대로 학습합니다. 먼저 랑데부의 전체적인 동작 방식을 명확히 정의하고, 이어서 랑데부의 ‘약속 장소’에 해당하는 엔트리(entry) 선언, 약속 장소에서 호출을 기다리는 accept 문, 그리고 다른 태스크에게 만남을 요청하는 엔트리 호출에 대해 구체적으로 알아볼 것입니다. 이 구성 요소들을 이해하면 Ada의 기본적인 태스크 통신 모델을 파악할 수 있습니다.

19.1.1 랑데부란 무엇인가?

랑데부(Rendezvous)는 두 개의 태스크, 즉 서비스를 요청하는 호출자(caller)와 서비스를 제공하는 제공자(callee) 사이에서 일어나는 동기화된 상호작용입니다. 이 ‘만남’ 동안 두 태스크는 시간적으로 묶이며, 이 기회를 통해 안전하게 데이터를 교환합니다.

랑데부의 핵심은 “먼저 도착한 쪽이 다른 쪽을 기다린다”는 것입니다.

  • 시나리오 1: 제공자(callee)가 먼저 도착
    1. 제공자 태스크가 accept 문에 도달하여 서비스 제공 준비를 합니다.
    2. 아직 호출자가 없으므로, 제공자accept 문에서 실행을 멈추고 대기(block)합니다.
    3. 이후 호출자가 엔트리를 호출하면, 랑데부가 시작됩니다. 🤝
  • 시나리오 2: 호출자(caller)가 먼저 도착
    1. 호출자 태스크가 제공자의 엔트리를 호출합니다.
    2. 제공자가 아직 accept 문에 도착하지 않았으므로, 호출자는 실행을 멈추고 해당 엔트리의 대기 큐(queue)에서 대기합니다.
    3. 이후 제공자accept 문에 도착하면, 대기 중이던 호출자를 받아들여 랑데부가 시작됩니다. 🤝

랑데부의 진행 과정

일단 랑데부가 시작되면, accept 문의 do ... end 블록에 있는 코드가 실행됩니다. 이 코드는 항상 제공자 태스크에 의해 실행됩니다. 이 시간 동안 호출자 태스크는 계속 대기 상태에 머물러 있습니다. accept 블록의 실행이 모두 끝나면 랑데부가 종료되고, 비로소 두 태스크는 각자의 실행을 독립적으로 재개합니다.

이 과정은 마치 은행 창구의 고객과 은행원의 관계와 같습니다.

  • 고객(호출자)은 업무를 요청하고, 은행원(제공자)은 그 요청을 처리합니다.
  • 창구에 은행원이 없으면 고객은 줄을 서서 기다리고, 고객이 없으면 은행원은 기다립니다.
  • 업무 처리(랑데부) 자체는 전적으로 은행원의 몫이며, 고객은 그동안 기다립니다.
  • 업무가 끝나고 영수증을 주고받으면(데이터 교환), 각자 다른 일을 하러 갑니다.

이처럼 랑데부는 동기화와 통신을 하나의 개념으로 묶어, 개발자가 저수준의 잠금(lock) 없이도 태스크 간의 상호작용을 안전하고 명확하게 구현할 수 있도록 돕는 강력한 추상화 메커니즘입니다.

19.1.2 엔트리(entry) 선언

엔트리(entry)는 태스크 명세 안에 선언되는 특별한 종류의 서브프로그램으로, 다른 태스크가 랑데부를 요청할 수 있는 공식적인 진입점(entry point) 또는 서비스 창구 역할을 합니다. 엔트리를 통해서만 태스크는 외부로부터 동기화된 통신 요청을 받아들일 수 있습니다.

엔트리 선언은 프로시저 선언과 문법적으로 매우 유사하며, in, out, in out 모드의 파라미터를 가질 수 있습니다. 이 파라미터들은 랑데부 동안 호출자와 제공자 사이에 데이터를 안전하게 교환하는 통로가 됩니다.

기본 구문

entry <Entry_Name> (formal_parameters);
  • <Entry_Name>: 랑데부 서비스의 이름으로, snake_case 명명 규칙을 따릅니다.
  • (formal_parameters): 랑데부 시 교환될 데이터의 타입과 모드를 정의합니다.
    • in: 호출자 → 제공자로 데이터 전달
    • out: 제공자 → 호출자로 데이터 반환
    • in out: 양방향 데이터 교환

선언 예시

간단한 키-값 저장소 역할을 하는 태스크의 명세에 다양한 엔트리를 선언하는 예시는 다음과 같습니다.

task type Key_Value_Store is
  -- 'in' 파라미터: 키와 값을 받아 저장하는 서비스
  entry put (key : in String; value : in Integer);

  -- 'out' 파라미터: 키에 해당하는 값을 조회하여 반환하는 서비스
  entry get (key : in String; value : out Integer);

  -- 파라미터 없음: 순수한 동기화 신호용 서비스 (예: 삭제 요청)
  entry delete (key : in String);
end Key_Value_Store;

이처럼 엔트리 선언은 태스크가 어떤 서비스를 제공하는지 외부 세계에 명확하게 알려주는 계약과 같습니다. 이 계약은 컴파일 시점에 타입 검사가 이루어지므로, 잘못된 데이터로 통신을 시도하는 오류를 미연에 방지할 수 있습니다. 엔트리는 오직 태스크 명세(또는 이후에 배울 보호 객체 명세)에만 선언될 수 있습니다.

19.1.3 accept

accept 문은 태스크 몸체(task body) 내부에서, 해당 태스크 명세에 선언된 특정 entry로의 호출을 대기하고 수락하기 위해 사용하는 실행문입니다. accept 문은 entry 선언에 대한 구체적인 구현부 역할을 합니다.

기본 구문

accept 문은 두 가지 형태로 사용됩니다.

  1. do..end 블록이 있는 형태 이 구문은 랑데부 동안 특정 연산을 수행할 때 사용됩니다.

    accept <Entry_Name> (formal_parameters) do
      -- 랑데부 구간(Rendezvous Section)
      -- 이 블록 안의 문장들은 랑데부 동안 실행됩니다.
    end <Entry_Name>;
    

    호출자(caller) 태스크는 doend 사이의 블록 실행이 완료될 때까지 대기 상태를 유지합니다. 이 블록은 호출자와의 상호 배제가 보장되는 구간으로, 파라미터를 이용한 데이터 처리가 안전하게 이루어집니다.

  2. do..end 블록이 없는 형태 이 구문은 데이터 처리 없이 동기화 자체만이 목적일 때 사용됩니다.

    accept <Entry_Name> (formal_parameters);
    

    이 경우 랑데부는 accept 문이 실행되는 즉시 시작되고 종료됩니다. 호출자와 제공자의 실행 시점을 일치시키는 효과가 있습니다.

사용 예시

14.1.2절의 Key_Value_Store 태스크의 몸체를 accept 문으로 구현한 예시는 다음과 같습니다.

task body Key_Value_Store is
  -- ... 내부 데이터 저장소 선언 ...
begin
  loop
    select
      -- 'put' 엔트리 호출을 수락
      accept put (key : in String; value : in Integer) do
        -- 랑데부 동안 데이터 저장 연산 수행
        my_data.insert (key, value);
      end put;
    or
      -- 'get' 엔트리 호출을 수락
      accept get (key : in String; value : out Integer) do
        -- 랑데부 동안 데이터 조회 연산 수행
        value := my_data.element (key);
      end get;
    or
      -- 'delete' 엔트리 호출을 수락
      accept delete (key : in String);

      -- 랑데부는 위 accept 문에서 즉시 종료된다.
      -- 호출자는 자신의 실행을 재개하고, 제공자는 아래 삭제 연산을 수행한다.
      -- 이 구조는 시스템의 동시성 수준을 높일 수 있다.
      my_data.delete (key);
    end select;
  end loop;
end Key_Value_Store;

accept 문은 제공자 태스크가 entry 호출을 받아들이고 동기화된 연산을 수행하는 지점을 정의합니다. 랑데부 구간(do..end) 내부에 어떤 연산을 포함시킬지 결정하는 것은 동시성 시스템의 동작과 성능에 직접적인 영향을 미치는 설계 고려사항입니다.

19.1.4 엔트리 호출

엔트리 호출(Entry Call)은 하나의 태스크(호출자)가 다른 태스크(제공자)에게 서비스를 요청하고 랑데부를 시작하기 위해 사용하는 문장입니다. 구문상으로는 프로시저 호출과 동일하며, 동시성 환경에서 클라이언트-서버 상호작용을 개시하는 역할을 합니다.

기본 구문

<target_task_object>.<entry_name> (actual_parameters);
  • <target_task_object>: 서비스를 제공하는 태스크 객체의 이름입니다.
  • .<entry_name>: 호출하고자 하는 entry의 이름입니다.
  • (actual_parameters): entry 선언에 명시된 파라미터에 전달할 실제 값 또는 변수입니다.

실행 동작

엔트리 호출의 핵심 동작은 호출자의 실행이 일시 중단된다는 점입니다. 호출자 태스크는 엔트리를 호출하는 즉시 실행을 멈추고(block), 제공자 태스크가 해당 호출을 accept하여 랑데부가 완전히 종료될 때까지 대기 상태를 유지합니다.

이러한 강제적인 대기 상태는 두 태스크 간의 동기화를 보장하며, 서비스 요청이 완전히 처리되었음을 호출자가 확신할 수 있게 합니다.


사용 예시

14.1.2절에서 정의한 Key_Value_Store 타입의 태스크 객체 my_store가 존재한다고 가정하고, 클라이언트 측에서 이 태스크의 엔트리를 호출하는 예시는 다음과 같습니다.

-- my_store는 Key_Value_Store 타입의 태스크 객체라고 가정
my_store : Key_Value_Store;

procedure Client_Logic is
  retrieved_value : Integer;
  status          : Status_Code;
begin
  -- 'put' 엔트리를 호출합니다.
  -- Client_Logic은 my_store가 이 호출에 대한 랑데부를
  -- 완료할 때까지 이 지점에서 대기합니다.
  my_store.put (key => "item_1", value => 25);

  -- 'get' 엔트리를 호출합니다.
  -- 랑데부가 완료되면 retrieved_value 변수에 결과가 저장됩니다.
  my_store.get (key => "item_1", value => retrieved_value);
end Client_Logic;

Client_Logic 프로시저의 실행은 my_store.put을 호출하는 지점에서 일시 중단됩니다. my_store 태스크가 해당 accept 문을 실행하여 랑데부를 마치면, Client_Logic은 다음 문장인 my_store.get으로 진행하여 다시 동일한 대기 과정에 들어갑니다.

이처럼 엔트리 호출은 프로시저 호출과 유사한 직관적인 구문을 사용하지만, 그 내부적으로는 동시성 실행 흐름을 안전하게 동기화시키는 명확한 규칙을 포함하고 있습니다.

19.2 가드(guard)를 이용한 엔트리 제어

지금까지 학습한 accept 문은 엔트리 호출이 있다면 항상 수락할 준비가 되어 있었습니다. 하지만 실제 동시성 시스템에서는 태스크의 현재 상태에 따라 특정 요청을 받아들이거나 거부해야 하는 경우가 많습니다. 예를 들어, 버퍼가 가득 찬 상태에서는 생산자의 아이템 추가(put) 요청을 받아들여서는 안 되며, 버퍼가 비어있을 때는 소비자의 아이템 인출(get) 요청을 받아들일 수 없습니다.

이러한 상태 종속적인(state-dependent) 제어를 위해 Ada는 가드(guard)라는 강력한 메커니즘을 제공합니다. 가드는 accept 문에 연결된 불리언(boolean) 조건으로, 이 조건이 True일 때만 해당 엔트리를 “열고(open)” 호출을 받아들일 수 있도록 합니다. 조건이 False이면 엔트리는 “닫힌(closed)” 상태가 되어 호출을 수락하지 않습니다.

이 절에서는 when 조건절을 사용하여 가드를 구현하는 방법과, 이를 통해 엔트리 접근을 동적으로 제어하여 시스템을 안전하고 효율적으로 만드는 기법을 배웁니다.

19.2.1 when 조건절

when 조건절은 select 문 내에서 accept 문 앞에 위치하여, 해당 accept 문이 활성화될지 여부를 결정하는 가드(guard) 조건을 명시하는 구문입니다.

기본 구문

select
  when <boolean_condition_1> =>
    accept <Entry_Name_1> ... ;
or
  when <boolean_condition_2> =>
    accept <Entry_Name_2> ... ;
  ...
end select;

실행 규칙

select 문의 실행은 다음 규칙을 따릅니다.

  1. 가드 평가: select 문에 진입하면, 먼저 모든 when 조건절의 불리언 조건(boolean condition)들을 평가합니다.
  2. 열린 대안 식별: 조건의 결과가 Trueaccept 문을 열린 대안(open alternative)이라고 합니다. 조건이 False인 경우는 닫힌 대안(closed alternative)이라고 합니다.
  3. 대기 및 수락: 태스크는 오직 열린 대안에 해당하는 엔트리 호출만을 고려합니다.
    • 열린 엔트리에 이미 대기 중인 호출이 있다면, 그중 하나를 선택하여 즉시 랑데부를 시작합니다.
    • 열린 엔트리에 대기 중인 호출이 없다면, 열린 엔트리 중 어느 쪽으로든 호출이 올 때까지 대기합니다.
    • 닫힌 엔트리로의 호출은 무시되며, 해당 호출자들은 계속 대기 큐에 남아있게 됩니다.
  4. 예외: 만약 평가 결과 모든 대안이 닫혀 있다면, 더 이상 진행할 경로가 없으므로 Program_Error 예외가 발생합니다.

사용 예시 (유한 버퍼)

유한 버퍼(bounded buffer)를 구현한 태스크에서 when 조건절을 사용하여 버퍼의 상태에 따라 put(저장)과 get(인출) 동작을 제어하는 예시는 다음과 같습니다.

-- 태스크 내부에 버퍼의 아이템 개수를 추적하는 변수가 있다고 가정
-- count : Natural := 0;
-- BUFFER_CAPACITY : constant := 10;

task body Bounded_Buffer is
begin
  loop
    select
      -- 버퍼에 공간이 있을 때만 put 엔트리가 열립니다.
      when count < BUFFER_CAPACITY =>
        accept put (item : in Data) do
          -- 버퍼에 아이템을 저장하는 로직
          count := count + 1;
        end put;
    or
      -- 버퍼에 아이템이 존재할 때만 get 엔트리가 열립니다.
      when count > 0 =>
        accept get (item : out Data) do
          -- 버퍼에서 아이템을 인출하는 로직
          count := count - 1;
        end get;
    or
      terminate;
    end select;
  end loop;
end Bounded_Buffer;
  • count0이면 get 엔트리는 닫히고 put 엔트리만 열립니다.
  • countBUFFER_CAPACITY와 같으면 put 엔트리는 닫히고 get 엔트리만 열립니다.
  • count가 그 사이의 값이면 두 엔트리 모두 열려 어느 쪽의 호출이든 받아들일 수 있습니다.

이처럼 when 조건절은 태스크의 내부 상태에 따라 서비스 제공 여부를 동적으로 제어하는 핵심적인 수단입니다.

19.2.2 엔트리 접근 제어

엔트리 접근 제어는 when 조건절을 활용하여, 태스크가 자신의 내부 상태나 특정 규칙에 따라 외부의 서비스 요청을 동적으로 허용하거나 차단하는 핵심적인 동시성 프로그래밍 기법입니다. 이 기법을 통해 태스크는 자신의 불변식(invariant)을 유지하고, 정해진 프로토콜(protocol)을 강제하며, 자원의 상태를 안전하게 관리할 수 있습니다.

주요 목적

  1. 상태 불변식 보장: 태스크가 항상 유효한 상태를 유지하도록 강제합니다. 예를 들어, 유한 버퍼의 count 변수가 0CAPACITY 사이의 값을 벗어나지 않도록 보장하는 것이 이에 해당합니다.
  2. 프로토콜 강제: 정해진 순서대로만 엔트리가 호출되도록 제어합니다. 예를 들어, initialize 엔트리가 호출되기 전에는 readwrite 엔트리를 받아들이지 않도록 강제할 수 있습니다.
  3. 자원 가용성에 따른 제어: 특정 자원을 사용할 수 있을 때만 관련 서비스를 제공하도록 제한합니다.

구현 예시 (자원 제어 프로토콜)

초기화(initialize), 획득(acquire), 해제(release)의 정해진 순서로만 접근 가능한 자원을 관리하는 태스크의 예시는 다음과 같습니다.

task body Resource_Manager is
  -- 자원의 상태를 나타내는 내부 변수
  initialized : Boolean := False;
  in_use      : Boolean := False;
begin
  loop
    select
      -- 초기화되지 않았을 때만 'initialize' 엔트리 개방
      when not initialized =>
        accept initialize do
          -- ... 리소스 초기화 ...
          initialized := True;
        end initialize;
    or
      -- 초기화되었고, 사용 중이 아닐 때만 'acquire' 엔트리 개방
      when initialized and not in_use =>
        accept acquire do
          -- ... 리소스 획득 처리 ...
          in_use := True;
        end acquire;
    or
      -- 사용 중일 때만 'release' 엔트리 개방
      when in_use =>
        accept release do
          -- ... 리소스 해제 처리 ...
          in_use := False;
        end release;
    or
      terminate;
    end select;
  end loop;
end Resource_Manager;

Resource_Manager 태스크는 가드를 통해 다음과 같은 프로토콜을 강제합니다.

  • 최초에는 initialize 호출만 가능합니다.
  • initialize 후에는 acquire 호출만 가능합니다.
  • acquire 후에는 release 호출만 가능합니다.
  • release 후에는 다시 acquire 호출이 가능합니다.

이처럼 when 조건절을 이용한 엔트리 접근 제어는 서비스 제공자(callee)가 스스로 자신의 상태를 책임지고 호출을 관리하도록 합니다. 이는 호출자(client)가 제공자의 상태를 추측하거나 확인하는 코드를 작성할 필요가 없게 만들어, 시스템 전체의 설계를 더 단순하고 견고하게 만듭니다.

19.2.3 바쁜 대기(busy-waiting) 회피

바쁜 대기(Busy-waiting) 또는 스피닝(spinning)은 특정 조건이 충족되기를 기다리기 위해, 태스크가 루프 안에서 반복적으로 조건을 검사하며 CPU 자원을 소모하는 비효율적인 프로그래밍 패턴입니다.

문제점: 바쁜 대기의 비효율성

가드(guard)가 없는 환경을 가정해 보겠습니다. 버퍼에서 아이템을 가져오려는 소비자 태스크는 다음과 같이 바쁜 대기를 사용할 수 있습니다.

-- !! 비효율적인 설계: 바쁜 대기 의사코드 !!
loop
  -- 루프를 계속 실행하며 버퍼의 상태를 반복적으로 확인
  if not buffer.is_empty then
    item := buffer.get_item; -- 조건 충족 시 아이템 인출
    exit; -- 루프 탈출
  end if;
  -- 조건이 충족되지 않으면, CPU를 낭비하며 루프를 계속 실행함
end loop;

이 방식은 태스크가 유용한 작업을 하지 않음에도 불구하고 계속 실행 상태로 CPU를 점유하여, 다른 중요한 태스크가 실행될 기회를 빼앗고 시스템 전체의 성능을 저하시킵니다.


해결책: 가드를 이용한 효율적인 대기

Ada의 when 조건절은 바쁜 대기의 필요성을 완전히 제거합니다. 가드를 사용하면 태스크는 조건을 반복적으로 검사하는 대신, 조건이 충족될 때까지 효율적인 대기(suspension) 상태로 들어갑니다.

-- 효율적인 설계: 가드를 이용한 대기
select
  -- 1. 'when' 조건절의 조건을 '한 번'만 평가합니다.
  when count > 0 =>
    accept get (item : out Data) do
      -- ...
    end get;
or
  -- ... 다른 대안 ...
end select;

when 조건절의 동작 방식은 다음과 같습니다.

  1. select 문에 진입 시, count > 0 조건을 단 한 번 평가합니다.
  2. 만약 조건이 False이면, 태스크는 루프를 도는 대신 즉시 실행이 중단(suspend)됩니다. 이 상태에서는 CPU 자원을 전혀 소모하지 않습니다.
  3. 이후 다른 태스크(생산자)가 put 엔트리를 호출하여 count 값을 변경시키는 등, 가드 조건에 영향을 줄 수 있는 이벤트가 발생하면 Ada 런타임 시스템은 중단된 태스크의 가드를 다시 평가합니다.
  4. 가드 조건이 True가 되면, 비로소 get 엔트리가 열리고 태스크는 호출을 받아들일 준비를 합니다.

이처럼 when 조건절은 프로그래머가 저수준의 비효율적인 대기 로직을 구현할 필요 없이, “조건이 만족될 때까지 기다린다”는 의도를 선언적으로 표현할 수 있게 합니다. 실제 대기 관리는 Ada 런타임 시스템이 최적의 방식으로 처리하여, 안전하고 효율적인 동시성 시스템을 보장합니다.

19.3 select

select 문은 Ada 동시성 프로그래밍에서 비결정성(non-determinism)을 다루는 핵심적인 제어 구조입니다. 이 구문을 통해 태스크는 여러 개의 동시적 이벤트(예: 여러 종류의 엔트리 호출, 시간의 경과) 중 하나를 선택하여 처리할 수 있습니다. 이는 단 하나의 accept 문만으로는 구현할 수 없는 복잡하고 유연한 상호작용을 가능하게 합니다.

이번 절에서는 select 문의 다양한 형태를 체계적으로 학습합니다. 제공자 측의 선택적 accept 외에도, 호출자 측에서 사용하는 시간제한(timed) 엔트리 호출과 조건부(conditional) 엔트리 호출을 배웁니다. 또한, 즉시 실행할 수 있는 랑데부가 없을 때 대체 동작을 제공하는 else 절과 협력적 종료를 위한 terminate 대안에 대해서도 알아볼 것입니다.

19.3.1 선택적 accept

선택적 accept(selective accept)는 select 문의 가장 기본적인 형태로, 서비스를 제공하는 태스크(제공자)가 여러 개의 accept 문 중 하나를 비결정적으로 선택하여 실행할 수 있도록 합니다. 이를 통해 하나의 태스크가 여러 종류의 서비스를 동시에 제공하고, 가장 먼저 도착하는 요청에 응답할 수 있습니다.

기본 구문 및 동작

select 문은 하나 이상의 accept 대안(alternative)들을 or로 연결하여 구성합니다.

select
  [when <condition_1> =>]
    accept <Entry_1> ... ;
      -- 랑데부 이후 추가 실행문 (선택 사항)
or
  [when <condition_2> =>]
    accept <Entry_2> ... ;
      -- 랑데부 이후 추가 실행문 (선택 사항)
end select;

실행 규칙:

  1. 먼저 모든 가드(when 조건절)를 평가하여 열린 대안(open alternative)의 집합을 결정합니다.
  2. 열린 대안의 엔트리 큐에 이미 대기 중인 호출이 있는지 확인합니다.
    • 대기 중인 호출이 있다면, 그중 하나를 임의로 선택하여 랑데부를 즉시 시작합니다.
    • 대기 중인 호출이 없다면, 열린 대안 중 어느 쪽으로든 첫 번째 호출이 도착할 때까지 대기(suspend)합니다.
  3. 선택된 accept 문의 랑데부가 완료된 후, 만약 해당 accept 문 뒤에 추가적인 실행문이 있다면 순차적으로 실행합니다.
  4. 이후 select 문 전체가 종료되고, 태스크는 end select 다음의 코드를 계속 실행합니다.

사용 예시

센서 값 읽기와 모터 구동이라는 두 가지 독립적인 서비스를 제공하는 장치 관리자 태스크의 예시는 다음과 같습니다.

task body Device_Manager is
  -- ... 내부 상태 변수 ...
begin
  loop
    -- 센서 읽기 요청과 모터 구동 요청 중 먼저 도착하는 것을 처리
    select
      accept read_sensor (value : out Sensor_Reading) do
        value := read_from_hardware;
      end read_sensor;
      put_line ("Sensor read complete."); -- 랑데부 종료 후 실행되는 문장
    or
      accept actuate_motor (command : in Motor_Command) do
        send_to_hardware (command);
      end actuate_motor;
      put_line ("Motor actuation complete."); -- 랑데부 종료 후 실행되는 문장
    end select;
  end loop;
end Device_Manager;

Device_Manager 태스크는 select 문에서 두 accept 문을 동시에 기다립니다. read_sensor 호출이 먼저 도착하면 해당 랑데부를 수행하고 “Sensor read complete.”를 출력합니다. actuate_motor 호출이 먼저 도착하면 그쪽의 랑데부를 수행하고 “Motor actuation complete.”를 출력합니다.

이처럼 선택적 accept는 여러 서비스 창구를 동시에 열어두고 비결정적인 요청들을 순서대로 처리하는 다중 서비스 제공자 태스크를 구현하는 표준적인 방법입니다.

19.3.2 시간제한(timed) 엔트리 호출

일반적인 엔트리 호출은 제공자(callee)가 호출을 수락할 때까지 무한정 대기합니다. 만약 제공자 태스크에 문제가 생겨 영원히 응답하지 않는다면, 호출자(caller) 태스크 역시 영원히 중단되는 교착 상태와 유사한 상황에 빠집니다.

시간제한 엔트리 호출(timed entry call)은 이러한 위험을 방지하기 위해 호출자 측에서 사용하는 select 문의 한 형태입니다. 이를 통해 호출자는 특정 시간 동안만 랑데부를 기다리고, 시간이 초과되면 호출을 포기하고 다른 동작을 수행할 수 있습니다.

기본 구문

select
  <entry_call_statement>;
  -- 랑데부 성공 시 실행될 문장들
or
  delay <duration_expression>;
  -- 시간 초과 시 실행될 문장들
end select;
  • <duration_expression>: Duration 타입의 값으로, 대기할 상대적인 시간(초 단위)을 나타냅니다.

실행 규칙

  1. select 문이 시작되면, 시스템은 엔트리 호출을 시도하는 동시에 지정된 시간만큼의 타이머를 설정합니다.
  2. 랑데부 성공: 만약 타이머가 만료되기 전에 제공자가 호출을 수락하여 랑데부가 시작되면, delay 대안은 즉시 취소됩니다. 랑데부가 정상적으로 완료된 후, 엔트리 호출 아래의 문장들이 실행됩니다.
  3. 시간 초과: 만약 랑데부가 시작되기 전에 타이머가 먼저 만료되면, 시도했던 엔트리 호출은 자동으로 취소되고 해당 엔트리의 대기 큐에서 제거됩니다. 그 후, delay 대안 아래의 문장들이 실행됩니다.

사용 예시

최대 2.0초까지만 서버의 응답을 기다리는 클라이언트의 예시는 다음과 같습니다.

declare
  REQUEST_TIMEOUT : constant Duration := 2.0;
begin
  select
    -- 서버에 작업 요청 시도
    Some_Server.do_work (job_data);
    put_line ("작업 요청 성공.");
  or
    -- 2.0초 동안 do_work 랑데부가 시작되지 않으면 실행됨
    delay REQUEST_TIMEOUT;
    put_line ("오류: 서버가 시간 내에 응답하지 않았습니다.");
    -- ... (오류 처리 로직) ...
  end select;
end;

delay until 사용

상대적인 시간(delay) 대신, 특정 절대 시간까지 기다리게 하는 delay until을 사용할 수도 있습니다. 이는 실시간 시스템에서 정해진 마감 시간(deadline)을 준수해야 할 때 유용합니다.

select
  -- ... entry call ...
or
  delay until Ada.Real_Time.Clock + Milliseconds (500);
  -- ... timeout actions ...
end select;

시간제한 엔트리 호출은 응답하지 않는 태스크로부터 호출자를 보호하여, 시스템의 안정성과 응답성을 높이는 필수적인 오류 처리 기법입니다.

19.3.3 조건부(conditional) 엔트리 호출

조건부 엔트리 호출(conditional entry call)은 호출자(caller)가 랑데부를 시도하되, 만약 랑데부가 즉시 시작될 수 없는 경우에는 전혀 기다리지 않고 대안 동작을 수행하고자 할 때 사용합니다. 이는 “지금 당장 서비스를 받을 수 없다면, 그냥 다른 일을 하겠다”는 의미의 비대기(non-blocking) 통신 방식입니다.

시간제한 엔트리 호출이 특정 시간만큼의 대기 의사를 표현하는 반면, 조건부 엔트리 호출은 대기 시간이 0인 것과 같습니다.

기본 구문

select
  <entry_call_statement>;
  -- 랑데부 성공 시 실행될 문장들
else
  -- 랑데부가 즉시 불가능할 때 실행될 문장들
end select;

실행 규칙

  1. select 문이 시작되면, 시스템은 제공자(callee)가 해당 entry에 대한 accept 문에서 즉시 호출을 받아들일 준비가 되었는지 확인합니다.
  2. 랑데부 성공: 만약 제공자가 즉시 랑데부를 시작할 수 있는 상태라면, 랑데부가 시작되고 정상적으로 완료된 후 엔트리 호출 아래의 문장들이 실행됩니다. else 부분은 무시됩니다.
  3. 랑데부 실패: 만약 제공자가 다른 작업을 하고 있거나 다른 accept 문에서 대기 중이어서 랑데부를 즉시 시작할 수 없다면, 엔트리 호출은 시도조차 되지 않습니다. 호출자는 대기 큐에 들어가지 않고, 즉시 else 부분으로 넘어가 그 아래의 문장들을 실행합니다.

사용 예시

주기적으로 여러 센서의 상태를 폴링(polling)하는 태스크가 있다고 가정해 봅시다. 센서가 즉시 응답할 수 있을 때만 값을 읽고, 그렇지 않으면 그냥 건너뛰고 싶을 때 조건부 호출을 사용할 수 있습니다.

loop
  -- Sensor_1의 상태를 폴링합니다.
  select
    Sensor_1.read_status (current_status => status_1);
    log_status ("Sensor_1", status_1);
  else
    -- Sensor_1이 즉시 응답할 수 없으면, 아무것도 하지 않습니다.
    null;
  end select;

  -- Sensor_2의 상태를 폴링합니다.
  select
    Sensor_2.read_status (current_status => status_2);
    log_status ("Sensor_2", status_2);
  else
    null;
  end select;

  delay 0.5; -- 0.5초 주기로 폴링
end loop;

위 코드는 Sensor_1이나 Sensor_2 중 하나가 응답이 늦더라도, 전체 폴링 로직이 해당 센서 때문에 중단되는 일 없이 계속해서 다른 작업을 수행할 수 있도록 보장합니다.

조건부 엔트리 호출은 이처럼 대기가 허용되지 않는 비동기적인 폴링 로직이나, 서비스의 즉각적인 가용성을 확인해야 하는 성능이 중요한 시스템을 구현할 때 유용하게 사용됩니다.

19.3.4 else 절과 terminate 대안

select 문은 accept 문 외에도 특정 조건 하에 대체 동작을 수행하는 else 절과 terminate 대안을 포함할 수 있습니다. 이 둘은 select 문의 동작 방식을 제어하는 중요한 수단이지만, 하나의 select 문 안에서 함께 사용할 수는 없습니다.

else

else 절은 즉시 시작할 수 있는 랑데부가 하나도 없을 때 실행될 코드 블록을 제공합니다. 이는 select 문 전체를 비대기(non-blocking) 방식으로 동작하게 만듭니다.

구문 및 동작:

select
  accept Some_Entry (...);
else
  -- 처리할 엔트리 호출이 즉시 없을 때 실행될 문장들
end select;
  1. select 문에 진입 시, 열린 accept 대안의 큐에 대기 중인 호출이 있는지 확인합니다.
  2. 호출이 있다면 즉시 랑데부를 시작하고, else 절은 무시됩니다.
  3. 호출이 없다면 태스크는 대기하지 않고, 즉시 else 절의 문장들을 실행한 뒤 select 문을 빠져나옵니다.

사용 예시: else 절은 처리할 요청이 없을 때 다른 유용한 배경 작업을 수행하는 태스크를 구현하는 데 적합합니다.

loop
  select
    accept process_urgent_request (req : in Request) do
      -- 긴급 요청 처리
    end process_urgent_request;
  else
    -- 즉시 처리할 긴급 요청이 없으면, 덜 중요한 배경 작업 수행
    perform_background_logging;
  end select;
end loop;

terminate 대안

terminate 대안은 무한 루프 구조를 가진 서버 태스크가 시스템 종료 시점에 협력적으로 종료할 수 있도록 하는 표준 메커니즘입니다.

구문 및 동작:

select
  accept Some_Entry (...);
or
  terminate;
end select;

terminate 대안은 매우 특별한 조건 하에서만 선택됩니다.

terminate 선택 조건:

  1. 태스크의 부모(master) 단위가 자신의 실행을 마치고 자식 태스크들이 종료하기를 기다리는 상태여야 합니다.
  2. 해당 부모에게 의존하는 다른 모든 형제 태스크(sibling task)들도 이미 종료되었거나, 자신들의 terminate 대안에서 대기 중이어야 합니다.

이 조건들은 시스템 전체가 종료 수순에 들어갔음을 의미하며, 이때 비로소 태스크는 terminate를 선택하여 스스로 생을 마감할 수 있습니다.

사용 예시: terminate가 없는 서버 태스크는 무한 루프에 갇혀 프로그램 전체의 종료를 막게 됩니다.

task body Server is
begin
  loop
    select
      accept handle_request (...) do
        -- 요청 처리
      end handle_request;
    or
      terminate; -- 이 구문이 없으면 태스크는 절대 종료되지 않음
    end select;
  end loop;
end Server;

terminate 대안은 이처럼 Ada 프로그램이 안정적으로 완전히 종료되도록 보장하는 필수적인 부분입니다.

주요 제약사항:

  • select 문은 else 절과 terminate 대안을 동시에 가질 수 없습니다.
  • select 문은 else 절과 delay 대안을 동시에 가질 수 없습니다.

19.3.5 [참고] select 문의 내부 최적화

Ada select 문의 가장 큰 장점 중 하나는, kqueue(FreeBSD)나 epoll(Linux)과 같은 운영체제 종속적인 저수준 API를 직접 다루지 않고도 이식성 있는 표준 문법만으로 강력한 동시성 제어가 가능하다는 점입니다.

성능 또한 뛰어납니다. 현대적인 Ada 컴파일러(GNAT)의 런타임 시스템은 내부적으로 해당 운영체제에서 가장 효율적인 I/O 다중화 메커니즘을 사용하도록 구현되어 있습니다.

  • 리눅스 (Linux): epoll 사용
  • FreeBSD: kqueue 사용
  • 윈도우 (Windows): IOCP (I/O Completion Ports) 사용

즉, 프로그래머는 select라는 표준적이고 안전한 고수준 문법으로 코드를 작성하면, 컴파일러와 런타임 시스템이 자동으로 타겟 운영체제에 최적화된 가장 빠른 시스템 콜로 변환해 줍니다. 따라서 개발자는 복잡한 저수준 코드가 아닌 비즈니스 로직에 집중하면서도 성능은 최고 수준으로 확보할 수 있습니다. 이것이 Ada가 미션 크리티컬한 실시간 시스템이나 고신뢰성 서버 개발에 사용되는 중요한 이유 중 하나입니다.

20. 보호 객체(protected objects): 데이터 중심 동기화

이전 15장에서는 랑데부를 통한 태스크 간의 통신과 동기화를 학습했습니다. 랑데부는 하나의 태스크가 다른 태스크에게 특정 서비스를 요청하는 행위 중심(behavior-centric)의 상호작용에 매우 효과적인 모델입니다.

하지만 여러 태스크가 단지 공유 변수나 데이터 구조에 안전하게 접근하는 것이 목적인 경우, 별도의 서버 태스크와 랑데부를 사용하는 것은 비효율적이고 필요 이상으로 복잡한 설계가 될 수 있습니다. 이는 데이터 중심(data-centric) 동기화 문제로, Ada는 이 유형의 문제를 해결하기 위해 보호 객체(Protected Object)라는 특화되고 효율적인 메커니즘을 제공합니다.

보호 객체는 자체적인 실행 흐름이 없는 수동적인(passive) 데이터 저장소입니다. 이 객체는 보호할 데이터를 캡슐화하고, 해당 데이터에 접근하는 연산들(프로시저, 함수, 엔트리)을 함께 묶어 정의합니다. 가장 큰 특징은 상호 배제(mutual exclusion)가 언어 런타임에 의해 자동으로 보장된다는 점입니다. 개발자가 직접 잠금(lock)을 관리할 필요 없이, 여러 태스크가 보호 객체의 데이터를 수정하려 해도 경쟁 상태(race condition)가 원천적으로 방지됩니다.

이번 장에서는 랑데부 방식이 데이터 공유에 적합하지 않은 이유를 통해 보호 객체의 필요성을 알아보고, 보호 객체를 구성하는 프로시저, 함수, 엔트리의 정의와 사용법을 학습합니다. 또한, 엔트리 배리어(barrier)와 requeue 문을 이용한 고급 제어 기법까지 익히게 될 것입니다.

20.1 보호 객체의 필요성

랑데부는 태스크 간의 복잡하고 동기화된 상호작용을 모델링하는 강력한 도구이지만, 모든 동시성 문제에 대한 최적의 해결책은 아닙니다. 특히 여러 태스크가 단순히 공유 데이터에 접근하는 시나리오에서는 랑데부 모델의 본질적인 특성이 오히려 비효율과 불필요한 복잡성을 야기할 수 있습니다.

이 절에서는 먼저 데이터 공유 문제에 랑데부를 적용했을 때 발생하는 한계를 분석하여, 왜 데이터 중심 동기화를 위한 별도의 메커니즘이 필요한지를 설명합니다. 이어서 이러한 한계를 극복하기 위해 설계된 보호 객체의 핵심 개념을 소개하고, 컴퓨터 과학의 고전적인 동기화 개념인 ‘모니터(monitor)’와 비교하여 그 이론적 배경을 이해할 것입니다.

20.1.1 데이터 공유 시 랑데부의 한계

단순한 데이터 공유를 위해 태스크와 랑데부 모델을 사용하는 것은 가능하지만, 이는 몇 가지 본질적인 한계와 비효율을 수반합니다. 이 문제를 해결하기 위해 고안된 것이 서버 태스크(server task) 패턴으로, 하나의 태스크가 공유 데이터를 소유하고 다른 태스크들은 이 서버 태스크의 엔트리를 호출하여 데이터에 접근하는 방식입니다.

이 패턴의 구체적인 한계는 다음과 같습니다.

1. 과도한 성능 부하 (Overhead)

  • 문맥 교환 (Context Switch): 공유 데이터에 한 번 접근할 때마다 최소 두 번의 문맥 교환이 필수적으로 발생합니다. (1) 호출자 → 제공자, (2) 제공자 → 호출자. 단순히 카운터를 1 증가시키는 등의 가벼운 연산을 위해 완전한 태스크 전환을 수행하는 것은 상당한 성능 부하를 유발합니다.
  • 자원 소모: 모든 태스크는 자신만의 실행 스택과 제어 블록을 위한 메모리를 필요로 합니다. 변수 하나를 보호하기 위해 완전한 태스크를 하나 생성하는 것은 자원 낭비입니다.

2. 불필요한 스케줄링

서버 태스크는 자신만의 우선순위를 갖는 독립적인 실행 단위입니다. 이로 인해 불필요한 스케줄링 복잡성이 생깁니다. 이 서버 태스크의 우선순위를 어떻게 설정해야 하는지는 간단한 문제가 아니며, 시스템의 다른 부분에 의도치 않은 영향을 줄 수 있습니다. 단순한 데이터 구조는 스케줄링의 대상이 될 필요가 없습니다.

3. 읽기 연산의 직렬화 (Serialization of Reads)

데이터 공유 문제의 고전적인 형태는 읽기-쓰기 문제(Readers-Writer Problem)입니다. 여러 태스크가 동시에 데이터를 읽는 것은 안전하므로 허용되어야 하지만, 쓰기 연산은 한 번에 하나만 허용되어야 합니다.

하지만 랑데부 모델에서는 accept 문이 한 번에 하나의 호출만 처리하므로, 읽기 요청과 쓰기 요청 모두가 직렬화됩니다. 즉, 여러 태스크가 동시에 안전하게 데이터를 읽을 수 있는 상황임에도 불구하고, 랑데부 모델에서는 한 번에 한 태스크만 읽을 수 있어 불필요한 성능 저하가 발생합니다.

서버 태스크 예시:

-- 랑데부를 이용한 비효율적인 공유 카운터
task Counter_Server is
  entry increment;
  entry get_value (value : out Natural);
end Counter_Server;

task body Counter_Server is
  count_value : Natural := 0;
begin
  loop
    select
      accept increment do
        count_value := count_value + 1;
      end increment;
    or
      accept get_value (value : out Natural) do
        value := count_value;
      end get_value;
    or
      terminate;
    end select;
  end loop;
end Counter_Server;

increment 또는 get_value를 호출할 때마다 완전한 랑데부가 필요하므로, 이 설계는 매우 비효율적입니다. 이러한 한계점들은 랑데부와 다른, 데이터 접근에 최적화된 새로운 동기화 메커니즘의 필요성을 명확하게 보여줍니다.

20.1.2 보호 객체의 개념

보호 객체(Protected Object)는 랑데부의 한계를 극복하고, 공유 데이터에 대한 안전하고 효율적인 접근을 제공하기 위해 설계된 Ada의 데이터 중심 동기화 메커니즘입니다.

보호 객체의 핵심 개념은 다음과 같은 세 가지 특성으로 요약할 수 있습니다.

1. 수동적 객체 (Passive Object)

태스크와 가장 근본적인 차이점으로, 보호 객체는 자체적인 실행 흐름(thread of control)을 갖지 않는 수동적인 객체입니다. 이는 스케줄링의 대상이 되지 않으며, 오직 외부 태스크가 자신의 서브프로그램이나 엔트리를 호출할 때만 코드가 실행됩니다. 이 특성 덕분에 태스크에 비해 자원 소모와 성능 부하가 현저히 적습니다.

2. 데이터 캡슐화 (Data Encapsulation)

보호 객체는 보호가 필요한 공유 데이터를 자신의 private 부분에 캡슐화합니다. 외부에서는 이 데이터에 직접 접근할 수 없으며, 오직 해당 보호 객체가 제공하는 공개된 연산(프로시저, 함수, 엔트리)을 통해서만 접근이 허용됩니다.

3. 자동화된 상호 배제 (Automatic Mutual Exclusion)

보호 객체의 가장 강력한 특징은 데이터 수정을 동반하는 모든 연산에 대해 상호 배제가 자동으로 보장된다는 점입니다. Ada 런타임 시스템이 객체에 대한 잠금(lock)을 내부적으로 관리하므로, 개발자는 저수준의 동기화 코드를 작성할 필요가 없습니다. 이는 설계적으로 경쟁 상태(race condition)를 방지합니다.


보호 객체는 세 종류의 연산을 제공하며, 각 연산은 서로 다른 접근 규칙을 가집니다.

  • 보호 프로시저 (Protected Procedures): 객체의 데이터에 대한 배타적인 읽기-쓰기(exclusive read-write) 접근을 제공합니다.
  • 보호 함수 (Protected Functions): 객체의 데이터에 대한 공유된 읽기 전용(shared read-only) 접근을 제공합니다. 여러 태스크가 동시에 함수를 호출할 수 있습니다.
  • 보호 엔트리 (Protected Entries): 프로시저와 같이 배타적인 읽기-쓰기 접근을 제공하지만, 실행을 위한 가드(guard) 조건이 추가된 형태입니다.

이러한 구조는 읽기-쓰기 문제와 같은 고전적인 동시성 문제를 명확하고 효율적으로 해결할 수 있는 수단을 제공합니다.

구조 예시:

protected type Shared_Counter is
  procedure increment;
  function get_value return Natural;
private
  value : Natural := 0;
end Shared_Counter;

protected body Shared_Counter is
  procedure increment is
  begin
    value := value + 1;
  end increment;

  function get_value return Natural is
  begin
    return value;
  end get_value;
end Shared_Counter;

20.1.3 보호 객체와 모니터(monitor) 비교

Ada의 보호 객체는 완전히 새로운 개념이 아니라, 컴퓨터 과학의 고전적인 동시성 추상화 메커니즘인 모니터(Monitor)에 그 뿌리를 두고 있습니다. 모니터는 공유 데이터와 해당 데이터에 대한 연산, 그리고 상호 배제 규칙을 하나의 단위로 묶어 동시성 프로그래밍을 단순화하기 위해 제안되었습니다.

유사점

보호 객체와 모니터는 다음과 같은 핵심적인 특징을 공유합니다.

  1. 데이터 캡슐화 (Data Encapsulation): 공유 데이터를 내부에 감추고, 오직 정의된 인터페이스를 통해서만 접근을 허용합니다.
  2. 자동 상호 배제 (Automatic Mutual Exclusion): 객체(또는 모니터)의 연산이 실행될 때, 여러 태스크가 동시에 데이터를 수정하지 못하도록 런타임이 자동으로 상호 배제를 보장합니다.

차이점

Ada의 보호 객체는 고전적 모니터 개념을 발전시켜 몇 가지 중요한 개선점을 도입했습니다.

  1. 읽기-쓰기 연산의 구분:
    • 모니터: 일반적으로 모든 연산을 배타적으로(read-write) 취급하여, 여러 태스크의 동시 읽기를 허용하지 않습니다.
    • 보호 객체: 보호 함수(protected function)를 통해 공유된 읽기 전용(shared read-only) 접근을 명시적으로 지원합니다. 이를 통해 읽기 위주의 작업에서 월등히 높은 수준의 동시성을 허용하여 성능을 향상시킵니다.
  2. 조건부 대기 메커니즘:
    • 모니터: 조건 변수(condition variable)와 명시적인 wait, signal 연산을 사용합니다. 태스크는 조건이 거짓일 경우 wait를 호출하여 스스로 대기 상태에 들어가고, 다른 태스크가 signal을 호출하여 깨워주기를 기다려야 합니다. 깨어난 후에는 조건이 다시 유효한지 직접 재검사해야 하는 부담이 있습니다.
    • 보호 객체: 엔트리와 가드(entry and guard)를 사용합니다. 태스크는 엔트리를 호출하기만 하면 됩니다. 가드 조건이 False이면 런타임이 태스크를 자동으로 대기 큐에 넣고 중단시킵니다. 이후 다른 태스크의 연산으로 가드가 True가 되면, 런타임은 대기 중인 태스크의 랑데부를 허용합니다. 이때 조건 충족이 보장되므로 프로그래머가 조건을 재검사할 필요가 없습니다.
  3. 시그널링 방식:
    • 모니터: signal 연산을 프로그래머가 직접, 명시적으로 호출해야 하므로, 호출을 누락하거나 잘못된 시점에 호출하는 오류가 발생하기 쉽습니다.
    • 보호 객체: 별도의 signal 연산이 없습니다. 보호 프로시저나 엔트리 몸체가 종료될 때, 런타임이 자동으로 가드들을 재평가하여 대기 중인 태스크를 깨워야 할지 결정합니다. 이러한 암묵적인 방식은 프로그래머의 실수를 줄여 코드의 안정성을 크게 높입니다.
특징 고전적 모니터 (Classic Monitor) Ada 보호 객체 (Protected Object)
읽기/쓰기 구분 없음 (모든 연산이 배타적) 함수(읽기)프로시저/엔트리(쓰기) 명확히 구분
조건부 대기 wait 연산 (명시적) 엔트리 호출 (암묵적)
조건 재검사 wait 이후 프로그래머가 필수적으로 재검사 랑데부 시작 시 조건 충족 보장됨 (재검사 불필요)
“깨우기” 신호 signal 연산 (명시적) 없음 (런타임이 종료 시점에 가드를 자동으로 재평가)

결론적으로, Ada의 보호 객체는 모니터의 기본 개념을 계승하되, 읽기-쓰기 구별, 가드 기반의 조건부 대기, 암묵적 시그널링을 도입하여 더 높은 수준의 안전성과 명확성, 그리고 성능을 제공하는 진보된 동기화 메커니즘입니다.

20.2 보호 객체 정의

보호 객체의 개념과 필요성을 이해했으니, 이제 Ada 코드로 이를 직접 정의하고 구현하는 방법을 학습할 차례입니다.

패키지나 태스크와 마찬가지로, 보호 객체 역시 명세(specification)몸체(body)의 두 부분으로 나뉘는 구조를 가집니다. 이 분리 원칙은 보호 객체의 공개 인터페이스와 내부 구현을 명확하게 구분하여, 캡슐화와 코드 모듈성을 강화합니다.

이 절에서는 보호 객체의 명세를 선언하는 방법부터 시작하여, 그 안에 포함될 수 있는 세 가지 종류의 연산, 즉 보호 프로시저, 보호 함수, 보호 엔트리를 정의하는 규칙을 배웁니다. 마지막으로, 이 연산들의 실제 동작을 구현하는 보호 몸체를 작성하는 방법을 다룰 것입니다.

20.2.1 보호 타입 명세

보호 타입 명세(protected type specification)는 보호 객체의 공개 인터페이스와 내부 데이터를 정의하는 선언부입니다. 이는 외부 세계에 어떤 연산들이 제공되는지를 명시하고, 보호 대상이 되는 데이터는 무엇인지 캡슐화하는 역할을 합니다.

기본 구문

보호 객체의 명세는 protected type 키워드로 시작하며, 공개부(public part)와 비공개부(private part)로 나뉩니다.

protected type <Protected_Type_Name> is
  -- 공개부: 외부에서 호출 가능한 연산들을 선언합니다.
  procedure <procedure_name> (parameters);
  function <function_name> (parameters) return <type>;
  entry <entry_name> (parameters);
private
  -- 비공개부: 보호 대상이 되는 공유 데이터를 선언합니다.
  -- 이 데이터는 오직 해당 보호 객체의 몸체(body) 안에서만 접근 가능합니다.
  <shared_data_declarations>;
end <Protected_Type_Name>;
  • 단일 보호 객체는 protected <Object_Name> is ... 구문을 사용합니다.

구성 요소

  1. 공개부 (Public Part): isprivate 사이의 영역입니다. 외부 태스크가 호출할 수 있는 보호된 연산들의 시그니처(signature)를 선언합니다.

    • procedure: 배타적 읽기-쓰기 접근을 위한 연산입니다.
    • function: 공유 읽기 전용 접근을 위한 연산입니다.
    • entry: 조건부 배타적 읽기-쓰기 접근을 위한 연산입니다.
  2. 비공개부 (Private Part): private 키워드 이하의 영역입니다. 보호하고자 하는 공유 데이터를 변수로 선언합니다. 여기에 선언된 데이터는 외부에서 직접 접근이 불가능하며, 오직 해당 보호 객체의 몸체에 구현된 연산을 통해서만 조작될 수 있습니다.

선언 예시 (유한 버퍼)

생산자-소비자 문제에 사용되는 유한 버퍼를 보호 타입으로 명세하는 예시는 다음과 같습니다.

-- 여러 태스크가 안전하게 데이터를 공유할 수 있는 유한 버퍼 타입
protected type Bounded_Buffer (capacity : Positive) is
  -- 버퍼가 비어있지 않을 때만 진행 가능한 'get' 엔트리
  entry get (item : out Data);

  -- 버퍼가 가득 차지 않았을 때만 진행 가능한 'put' 엔트리
  entry put (item : in Data);

private
  -- 보호 대상이 되는 내부 데이터
  storage     : Data_Array (1 .. capacity);
  count       : Natural := 0;
  in_idx      : Positive := 1;
  out_idx     : Positive := 1;
end Bounded_Buffer;
  • getput은 버퍼의 상태(count)에 따라 호출 가능 여부가 결정되어야 하므로 entry로 선언합니다.
  • capacity는 보호 타입의 판별자(discriminant)로, Bounded_Buffer 타입의 객체를 생성할 때 버퍼의 크기를 지정할 수 있게 합니다.
  • 버퍼의 실제 저장 공간인 storage와 상태 변수 count, in_idx, out_idxprivate 부에 선언되어 외부로부터의 직접적인 접근이 차단됩니다.

20.2.2 보호 프로시저와 함수

보호 프로시저와 함수는 보호 객체의 데이터를 조작하고 조회하는 기본적인 연산 수단입니다. 이 둘은 일반적인 서브프로그램과 유사한 형태를 가지지만, 동시성 제어와 관련하여 명확하게 구분되는 접근 규칙을 가집니다.

보호 프로시저 (Protected Procedures)

보호 프로시저는 보호 객체의 내부 데이터에 대한 배타적인 읽기-쓰기(exclusive read-write) 접근 권한을 가집니다.

실행 규칙:

  • 한 태스크가 보호 프로시저를 실행하는 동안, 해당 객체에 대한 배타적 잠금(write lock)이 설정됩니다.
  • 이 잠금이 유지되는 동안, 다른 어떤 태스크도 해당 객체의 프로시저, 함수, 또는 엔트리를 실행할 수 없으며, 호출 시 대기 상태에 들어갑니다.
  • 이는 프로시저의 몸체가 실행되는 동안 데이터의 일관성이 완벽하게 보장됨을 의미합니다.

구현 예시: Bounded_Buffer의 모든 상태를 초기화하는 clear 프로시저의 구현은 다음과 같습니다.

-- Bounded_Buffer 타입의 몸체 내부
protected body Bounded_Buffer is
  procedure clear is
  begin
    -- 이 블록은 객체에 대한 배타적 접근 권한을 가집니다.
    -- 이 연산이 실행되는 동안 다른 태스크는 이 객체에 접근할 수 없습니다.
    count   := 0;
    in_idx  := 1;
    out_idx := 1;
  end clear;

  -- ... entry들의 구현 ...
end Bounded_Buffer;

my_buffer.clear 호출은 버퍼의 상태를 안전하게 수정하는 원자적(atomic) 연산으로 동작합니다.

보호 함수 (Protected Functions)

보호 함수는 보호 객체의 데이터에 대한 공유된 읽기 전용(shared read-only) 접근을 제공합니다.

실행 규칙:

  • 보호 함수는 내부 데이터를 절대 수정할 수 없으며, 이는 컴파일러에 의해 강제됩니다.
  • 여러 태스크가 동시에 하나의 보호 함수 또는 여러 개의 다른 보호 함수들을 실행할 수 있습니다.
  • 단, 배타적 잠금(프로시저 또는 엔트리 실행)이 걸려있는 동안에는 함수 호출이 대기 상태에 들어갑니다.

이 규칙은 여러 태스크가 서로를 방해하지 않고 안전하게 객체의 상태를 동시에 조회할 수 있게 하여, 읽기-쓰기 문제(Readers-Writer Problem)를 효율적으로 해결합니다.

구현 예시: Bounded_Buffer에 저장된 아이템의 개수를 반환하는 함수의 구현은 다음과 같습니다.

-- Bounded_Buffer 타입의 몸체 내부
protected body Bounded_Buffer is
  function item_count return Natural is
  begin
    -- 이 함수는 데이터를 읽기만 허용됩니다. (예: value := value + 1; 과 같은 코드 불가)
    -- 여러 태스크가 동시에 item_count를 호출할 수 있습니다.
    return count;
  end item_count;

  -- ... procedure, entry들의 구현 ...
end Bounded_Buffer;
특징 보호 프로시저 (Protected Procedure) 보호 함수 (Protected Function)
접근 유형 배타적 읽기-쓰기 (Exclusive Read-Write) 공유 읽기 전용 (Shared Read-Only)
동시 실행 한 번에 하나만 실행 가능 여러 개 동시 실행 가능
데이터 수정 가능 불가능 (컴파일러 강제)
주요 목적 객체 상태의 안전한 변경 객체 상태의 효율적인 동시 조회

20.2.3 보호 엔트리

보호 엔트리(Protected Entry)는 보호 프로시저의 배타적 읽기-쓰기 접근 규칙when 조건절로 대표되는 가드(guard) 조건을 결합한 보호 객체의 연산입니다. 엔트리는 특정 조건이 충족될 때만 연산을 허용하는, 정교한 상태 종속적 동기화를 구현하는 데 사용됩니다.

기본 구문

엔트리는 명세에 선언되고, 몸체에서 배리어(barrier)라 불리는 when 조건절과 함께 구현됩니다.

  • 명세(specification) 선언:

    entry <Entry_Name> (parameters);
    
  • 몸체(body) 구현:

    entry <Entry_Name> (parameters) when <barrier_condition> is
    begin
      -- 엔트리 몸체: 배리어 조건이 True일 때만 실행됩니다.
    end <Entry_Name>;
    

실행 규칙

  1. 호출 및 배리어 평가: 태스크가 엔트리를 호출하면, 런타임은 해당 객체를 잠그고 배리어 조건(when 이하의 불리언 표현식)을 평가합니다.

  2. 조건이 True일 경우 (열린 엔트리):

    • 호출은 즉시 받아들여지고, 호출 태스크는 엔트리 몸체를 실행합니다. 이 실행은 보호 프로시저와 같이 객체에 대한 배타적 접근 권한을 가집니다.
  3. 조건이 False일 경우 (닫힌 엔트리):

    • 호출 태스크는 해당 엔트리의 대기 큐(queue)에 추가되고 즉시 실행이 중단(suspend)됩니다.
    • 객체에 대한 잠금은 해제되어, 다른 태스크들이 다른 프로시저나 함수를 호출할 수 있게 됩니다.
  4. 배리어 재평가 (암묵적 신호):

    • 해당 객체의 보호 프로시저나 다른 엔트리의 실행이 완료될 때마다, Ada 런타임 시스템은 대기 중인 태스크가 있는 모든 엔트리의 배리어들을 자동으로 재평가합니다.
    • 재평가 결과 배리어 조건이 True로 변경되면, 해당 엔트리의 큐에서 대기하던 태스크 하나가 깨어나 엔트리 몸체를 실행할 권한을 얻습니다.

구현 예시 (유한 버퍼)

Bounded_Buffergetput 엔트리는 배리어를 사용하여 버퍼의 상태를 검사합니다.

protected body Bounded_Buffer is
  -- 'get' 엔트리: count가 0보다 클 때만 실행 가능
  entry get (item : out Data) when count > 0 is
  begin
    item := storage (out_idx);
    out_idx := (out_idx mod capacity) + 1;
    count := count - 1;
  end get;

  -- 'put' 엔트리: count가 capacity보다 작을 때만 실행 가능
  entry put (item : in Data) when count < capacity is
  begin
    storage (in_idx) := item;
    in_idx := (in_idx mod capacity) + 1;
    count := count + 1;
  end put;

  -- ... 함수 및 프로시저 구현 ...
end Bounded_Buffer;
  • 소비자가 get을 호출했을 때 count0이면, 소비자는 get의 큐에서 대기합니다.
  • 이후 생산자가 put을 호출하여 count1로 만들고 put 엔트리를 빠져나가는 순간, 런타임은 get의 배리어(count > 0)를 재평가합니다.
  • 조건이 이제 True이므로, 대기하던 소비자 태스크가 깨어나 get의 몸체를 실행합니다.

이 과정은 바쁜 대기나 명시적인 signal 호출 없이, 언어 차원에서 안전하고 효율적으로 처리됩니다.

20.2.4 보호 몸체

보호 몸체(protected body)는 보호 타입 명세에 선언된 연산들의 실제 구현 코드를 담는 부분입니다. 명세가 “무엇을 할 수 있는지”를 정의한다면, 몸체는 “그것을 어떻게 하는지”를 구체적으로 정의합니다.

기본 구문

보호 몸체는 protected body 키워드로 시작하며, 명세에 선언된 모든 프로시저, 함수, 엔트리에 대한 완전한 서브프로그램 구현을 포함해야 합니다.

protected body <Protected_Type_Name> is

  -- 명세에 선언된 프로시저의 구현
  procedure <procedure_name> (parameters) is
  begin
    -- ... 실행 코드 ...
  end <procedure_name>;

  -- 명세에 선언된 함수의 구현
  function <function_name> (parameters) return <type> is
  begin
    -- ... 실행 코드 ...
  end <function_name>;

  -- 명세에 선언된 엔트리의 구현
  entry <entry_name> (parameters) when <barrier_condition> is
  begin
    -- ... 실행 코드 ...
  end <entry_name>;

end <Protected_Type_Name>;

주요 규칙

  1. 이름 일치: 보호 몸체의 이름은 반드시 대응하는 보호 타입 명세의 이름과 일치해야 합니다.
  2. 구현 의무: 명세에 선언된 모든 연산(프로시저, 함수, 엔트리)은 몸체에 반드시 구현되어야 합니다.
  3. 데이터 접근: 오직 보호 몸체 내의 코드만이 명세의 private 부분에 선언된 공유 데이터에 직접 접근할 수 있습니다. 컴파일러는 이 규칙을 강제하여 데이터의 캡슐화를 보장합니다.

구현 예시 (유한 버퍼)

앞선 절들에서 정의한 Bounded_Buffer의 완전한 몸체 구현은 다음과 같습니다.

protected body Bounded_Buffer is
  -- 'get' 엔트리의 구현
  entry get (item : out Data) when count > 0 is
  begin
    item := storage (out_idx);
    out_idx := (out_idx mod capacity) + 1;
    count := count - 1;
  end get;

  -- 'put' 엔트리의 구현
  entry put (item : in Data) when count < capacity is
  begin
    storage (in_idx) := item;
    in_idx := (in_idx mod capacity) + 1;
    count := count + 1;
  end put;

  -- 'is_full' 함수의 구현 (명세에 선언되었다고 가정)
  function is_full return Boolean is
  begin
    return count = capacity;
  end is_full;

  -- 'is_empty' 함수의 구현 (명세에 선언되었다고 가정)
  function is_empty return Boolean is
  begin
    return count = 0;
  end is_empty;

end Bounded_Buffer;

이처럼 보호 몸체는 보호 객체의 내부 동작 로직을 외부로부터 완전히 숨기는 역할을 합니다. 명세라는 공개 계약만 변경되지 않는다면, 몸체의 내부 구현을 수정하거나 최적화하더라도 보호 객체를 사용하는 외부 코드에는 아무런 영향을 주지 않습니다. 이는 모듈화되고 유지보수하기 쉬운 소프트웨어를 구축하는 데 있어 핵심적인 장점입니다.

20.3 보호 객체 활용

보호 객체를 정의하는 방법을 학습했으므로, 이제 클라이언트 태스크의 관점에서 이 객체들을 실제로 활용하는 방법을 알아볼 차례입니다. 보호 객체의 연산을 호출하는 구문은 일반적인 서브프로그램 호출과 매우 유사하지만, 그 내부 동작에는 동시성 제어를 위한 특별한 규칙이 적용됩니다.

이 절에서는 보호 객체의 프로시저, 함수, 엔트리를 호출하는 방법과 그에 따른 동작을 상세히 살펴봅니다. 또한, 엔트리 배리어의 동작 원리를 더 깊이 이해하고, requeue 문을 사용하여 하나의 엔트리에서 다른 엔트리로 호출을 재배치하는 고급 동시성 제어 패턴까지 학습할 것입니다.

20.3.1 보호된 서브프로그램 호출

클라이언트 태스크가 보호 객체의 연산을 호출하는 구문은 일반적인 서브프로그램 호출과 동일합니다. 동기화를 위한 복잡한 메커니즘은 호출 구문 뒤에 완전히 감추어져 있으며, Ada 런타임에 의해 자동으로 처리됩니다.

보호 프로시저/엔트리 호출

보호 프로시저와 엔트리는 객체에 대한 배타적인 접근을 요청하므로 호출 방식과 규칙이 유사합니다.

구문:

<protected_object_name>.<procedure_or_entry_name> (actual_parameters);

실행 규칙:

  1. 잠금 획득 시도: 호출 태스크는 해당 보호 객체에 대한 배타적 잠금(exclusive lock) 획득을 시도합니다.
  2. 엔트리 배리어 평가 (엔트리 호출 시): 만약 호출 대상이 엔트리라면, 잠금을 획득하기 전에 먼저 해당 엔트리의 배리어(when 조건)를 평가합니다. 배리어가 False이면, 태스크는 해당 엔트리 고유의 대기 큐에서 중단되고 객체 잠금은 해제됩니다. 배리어가 True가 될 때까지 기다린 후에야 2단계로 진행합니다.
  3. 대기 및 실행: 다른 태스크가 이미 객체에 대한 배타적 잠금을 보유하고 있다면, 호출 태스크는 해당 객체의 주(main) 대기 큐에서 중단됩니다. 잠금이 해제되면, 큐의 순서에 따라 잠금을 획득하고 프로시저 또는 엔트리 몸체를 실행합니다.

호출 예시:

-- Bounded_Buffer 타입의 객체 my_buffer가 선언되었다고 가정
my_buffer : Bounded_Buffer (capacity => 10);
new_item  : Data;
...

-- 'put' 엔트리를 호출합니다.
-- 이 호출은 먼저 배리어(count < capacity)를 통과한 후,
-- 객체에 대한 배타적 잠금을 획득해야만 실행됩니다.
my_buffer.put (item => new_item);

-- 'clear' 프로시저를 호출합니다.
-- 이 호출은 즉시 객체에 대한 배타적 잠금을 획득하려 시도합니다.
my_buffer.clear;

보호 함수 호출

보호 함수는 객체에 대한 공유된 접근을 요청합니다.

구문:

result := <protected_object_name>.<function_name> (actual_parameters);

실행 규칙:

  1. 잠금 획득 시도: 호출 태스크는 해당 객체에 대한 공유 잠금(shared lock) 획득을 시도합니다.
  2. 대기 및 실행: 다른 태스크가 배타적 잠금을 보유하고 있지 않다면, 호출 태스크는 즉시 공유 잠금을 획득하고 함수를 실행합니다. 여러 태스크가 동시에 공유 잠금을 획득하고 함수를 병행 실행할 수 있습니다. 만약 다른 태스크가 배타적 잠금을 보유 중이라면, 해당 잠금이 해제될 때까지 대기합니다.

호출 예시:

if not my_buffer.is_full then
  my_buffer.put (item => new_item);
end if;

위 코드에서 my_buffer.is_full 호출은 객체를 잠그는 다른 연산이 없을 경우 즉시 실행됩니다. 하지만 이 if문 자체는 경쟁 상태를 막아주지 못합니다. if문 통과와 put 호출 사이에 다른 태스크가 put을 먼저 호출할 수 있기 때문입니다. 올바른 사용법은 put 엔트리의 배리어가 이 조건을 처리하도록 신뢰하는 것입니다.

20.3.2 엔트리 배리어(barrier)

엔트리 배리어(entry barrier)는 보호 엔트리의 when 절에 명시되는 불리언(boolean) 조건으로, 해당 엔트리의 개방(open) 또는 폐쇄(closed) 상태를 결정하는 가드(guard)입니다. 배리어는 보호 객체가 특정 상태에 있을 때만 서비스 요청을 수락하도록 하는 핵심적인 조건부 동기화 메커니즘입니다.

배리어의 평가와 재평가

배리어의 동작은 Ada 런타임 시스템에 의해 정교하게 관리됩니다.

  1. 최초 평가: 태스크가 엔트리를 호출하면, 런타임은 해당 보호 객체에 대한 잠금을 획득한 상태에서 배리어 조건을 한 번 평가합니다.
    • True (열림): 조건이 참이면 엔트리는 열린 것으로 간주되고, 호출 태스크는 엔트리 몸체를 실행하기 위한 배타적 잠금을 얻기 위해 진행합니다.
    • False (닫힘): 조건이 거짓이면 엔트리는 닫힌 것으로 간주되고, 호출 태스크는 해당 엔트리 고유의 대기 큐로 이동하여 중단됩니다. 객체에 대한 잠금은 즉시 해제되어 다른 태스크가 접근할 수 있게 됩니다.
  2. 자동 재평가 (암묵적 신호): 가장 중요한 특징은 배리어의 재평가가 자동으로 이루어진다는 점입니다. 프로그래머가 signal과 같은 별도의 신호 연산을 호출할 필요가 없습니다. 재평가는 다음 시점에 자동으로 발생합니다.

    보호 객체의 보호 프로시저 또는 보호 엔트리의 실행이 완료되는 시점

    이 시점은 보호 객체의 내부 데이터가 변경되었을 가능성이 있는 유일한 시점입니다. 런타임은 이 때, 대기 큐에 태스크가 있는 모든 엔트리의 배리어들을 다시 평가합니다. 재평가 결과 배리어가 True로 바뀐 엔트리가 있다면, 해당 큐에서 대기하던 태스크가 깨어나 실행을 재개합니다.

동작 예시 (유한 버퍼)

Bounded_Buffer 객체에서 count가 0일 때의 동작 흐름은 다음과 같습니다.

  1. 소비자 호출: 소비자 태스크가 get을 호출합니다. 배리어 when count > 0False이므로, 소비자는 get의 대기 큐에서 중단됩니다.
  2. 생산자 호출: 생산자 태스크가 put을 호출합니다. 배리어 when count < capacityTrue이므로 put의 몸체가 실행되고, count1이 됩니다.
  3. put 종료 및 재평가: put 엔트리의 실행이 끝나는 순간, 런타임은 대기 중인 get 엔트리의 배리어(count > 0)를 자동으로 재평가합니다.
  4. get 실행: 배리어는 이제 True이므로, get 큐에서 대기하던 소비자 태스크가 깨어나 get의 몸체를 실행합니다.

이러한 자동 재평가 메커니즘은 조건부 대기 로직을 선언적으로 명시할 수 있게 하여, wait/signal 방식에서 발생할 수 있는 신호 누락이나 불필요한 재검사 등의 오류를 원천적으로 방지하고 코드의 안정성을 높입니다.

20.3.3 requeue 문을 이용한 태스크 재배치

requeue 문은 보호 객체나 태스크의 엔트리 내부에서 사용되는 고급 제어 구문으로, 현재 실행 중인 태스크를 다른 엔트리의 대기 큐로 재배치(requeue)하는 역할을 합니다. 이는 단순한 배리어(barrier)만으로는 해결하기 어려운 복잡한 조건부 대기나 스케줄링 정책을 구현할 때 사용됩니다.

requeue의 필요성

하나의 엔트리로 진입한 태스크가, 내부 상태를 확인한 후 현재 조건이 만족스럽지 않아 다른 조건이 충족될 때까지 다시 기다려야 하는 경우가 있습니다. requeue는 이 과정에서 객체에 대한 잠금을 해제하지 않고 원자적(atomically)으로 태스크를 다른 대기 큐로 이동시켜, 다른 태스크가 중간에 진입하는 경쟁 상태를 방지합니다.

기본 구문

requeue 문은 accept 문이나 보호 엔트리 몸체 안에서만 사용할 수 있습니다.

-- 다른 엔트리로 재배치
requeue <entry_name>;

-- 중단 가능한(abortable) 형태로 재배치
requeue <entry_name> with abort;
  • requeue <entry_name>: 현재 태스크를 지정된 <entry_name>의 대기 큐로 이동시킵니다.
  • with abort: requeue로 대기하는 동안 원래의 엔트리 호출이 중단(abort)되면, 재배치된 큐에서도 안전하게 제거되도록 보장합니다. 이 옵션은 안정성을 위해 사용하는 것이 표준적인 관례입니다.

실행 규칙

  1. requeue 문이 실행되면, 현재 실행 중인 태스크는 즉시 중단되고 지정된 엔트리의 대기 큐의 맨 뒤에 추가됩니다.
  2. 이 재배치 과정은 원자적으로 일어납니다. 즉, 태스크가 큐로 이동하는 동안 보호 객체에 대한 잠금은 해제되지 않습니다.
  3. 태스크가 새로운 큐로 완전히 이동한 후에야 객체 잠금이 해제되어, 다른 태스크가 객체에 접근할 수 있게 됩니다.
  4. 재배치된 태스크는 이제 새로운 엔트리의 배리어 조건이 참이 되기를 기다립니다.

사용 예시 (우선순위 기반 처리)

외부에는 단일 진입점(get_message)만 제공하지만, 내부적으로는 우선순위가 높은 메시지를 우선 처리하는 메시지 큐의 예시는 다음과 같습니다.

protected Priority_Queue is
  entry get_message (msg : out Message);
private
  entry get_high_priority (msg : out Message);
  -- ... 큐 데이터 구조 ...
end Priority_Queue;

protected body Priority_Queue is
  entry get_message (msg : out Message) when True is -- 항상 진입 허용
  begin
    if high_priority_message_is_available then
      -- 고우선순위 메시지가 있다면, 고우선순위 큐로 재배치하여 대기
      requeue get_high_priority with abort;
    elsif low_priority_message_is_available then
      -- 저우선순위 메시지만 있다면, 즉시 처리
      msg := get_low_priority_message;
    else
      -- 큐가 비었다면, 고우선순위 메시지를 기다리기 위해 재배치
      requeue get_high_priority with abort;
    end if;
  end get_message;

  entry get_high_priority (msg : out Message)
    when high_priority_message_is_available is
  begin
    msg := get_high_priority_message;
  end get_high_priority;
end Priority_Queue;

이 설계에서 get_message로 진입한 태스크는 객체의 상태에 따라 더 구체적인 조건(high_priority_message_is_available)을 가진 get_high_priority 큐로 재배치될 수 있습니다. 이를 통해 저수준의 복잡한 로직 없이도 정교한 우선순위 처리 정책을 안전하게 구현할 수 있습니다. requeue는 이처럼 복잡한 상태 기반의 동시성 제어 흐름을 구현하기 위한 강력한 도구입니다.

21. 고급 동시성 패턴과 기법

이전 장들에서는 Ada 동시성의 기본 구성 요소인 태스크, 랑데부, 보호 객체를 각각 학습했습니다. 이제 우리는 이 기본 요소들을 조합하여, 실제 동시성 시스템 설계에서 반복적으로 나타나는 문제들을 해결하는 검증된 해법, 즉 동시성 디자인 패턴(concurrency design patterns)을 익힐 차례입니다.

이번 장에서는 생산자-소비자, 읽기-쓰기 문제와 같이 컴퓨터 과학 분야에서 널리 알려진 고전적인 동시성 문제들을 Ada의 동시성 기능을 이용해 풀어봅니다. 이러한 패턴들을 학습함으로써, 개별 기능에 대한 이해를 넘어 실제 문제에 적용하는 능력을 기르게 될 것입니다.

또한, 동시성 환경에서 예외가 어떻게 동작하고 전파되는지, 그리고 Tasking_Error 예외는 언제 발생하는지와 같은 고급 예외 처리 기법을 다룹니다. 마지막으로, 태스크의 상태를 질의할 수 있는 여러 유용한 태스크 속성(attribute)에 대해 배우며 동시성 시스템을 더욱 정교하게 제어하는 방법을 모색합니다.

21.1 고전적 동시성 문제와 해결 패턴

동시성 프로그래밍의 이론과 Ada의 기본 기능들을 학습한 지금, 이 지식들을 통합하여 실제 문제 해결에 적용할 차례입니다. 이 절에서는 수십 년간 동시성 시스템 연구에서 핵심적인 사례로 다루어져 온 고전적인 동시성 문제들을 살펴보고, Ada를 이용한 해결책을 구현합니다.

이 문제들은 단순한 학술적 예제가 아니라, 운영체제, 데이터베이스, 통신 시스템 등 현실의 복잡한 시스템에서 마주치는 다양한 동기화 및 자원 공유 문제들의 본질을 담고 있습니다. 이러한 디자인 패턴(design patterns)을 학습하는 것은 Ada의 동시성 기능들을 올바르고 효과적으로 사용하는 방법을 익히고, 재사용 가능한 해결책의 구조를 이해하는 과정입니다.

본 절에서는 생산자-소비자 문제, 읽기-쓰기 문제, 그리고 식사하는 철학자들 문제를 차례로 다룰 것입니다.

21.1.1 유한 버퍼 (생산자-소비자 문제)

생산자-소비자(Producer-Consumer) 문제는 동시성 프로그래밍에서 가장 기본적이고 널리 알려진 문제입니다. 이 문제는 데이터를 생성하는 하나 이상의 생산자 태스크와, 그 데이터를 소비하는 하나 이상의 소비자 태스크가 고정된 크기의 공유 버퍼를 통해 통신하는 상황을 모델링합니다.

문제 정의

이 패턴의 동기화 제약 조건은 다음과 같습니다.

  1. 생산자는 버퍼가 가득 차 있을 때(full), 새로운 데이터를 추가할 수 없으며 버퍼에 공간이 생길 때까지 대기해야 합니다.
  2. 소비자는 버퍼가 비어 있을 때(empty), 데이터를 가져갈 수 없으며 버퍼에 데이터가 채워질 때까지 대기해야 합니다.
  3. 여러 생산자나 소비자가 동시에 버퍼에 접근하여 데이터가 손상되는 것을 막기 위해, 버퍼 접근은 상호 배제되어야 합니다.

Ada를 이용한 해결책

이 문제는 공유 데이터(버퍼)와 상태 종속적인 규칙을 포함하므로, Ada의 보호 객체(Protected Object)를 사용하여 가장 명확하고 효율적으로 해결할 수 있습니다. 보호 객체는 버퍼 데이터와 그에 대한 접근 규칙(배리어)을 하나의 단위로 캡슐화하여 문제의 요구사항을 직접적으로 구현합니다.

구현 코드

재사용성을 위해 제네릭(generic) 패키지를 사용하여 모든 데이터 타입에 대해 동작하는 유한 버퍼를 구현할 수 있습니다.

1. 명세 (bounded_buffer_generic.ads)

generic
  type Data_Item is private;
package Bounded_Buffer_Generic is

  -- 생산자-소비자 패턴을 위한 보호 버퍼 타입
  protected type Buffer (capacity : Positive) is
    -- 소비자를 위한 get 엔트리
    entry get (item : out Data_Item);

    -- 생산자를 위한 put 엔트리
    entry put (item : in Data_Item);
  private
    storage : array (1 .. capacity) of Data_Item;
    count   : Natural := 0;
    in_idx  : Positive := 1;
    out_idx : Positive := 1;
  end Buffer;

end Bounded_Buffer_Generic;

2. 몸체 (bounded_buffer_generic.adb)

package body Bounded_Buffer_Generic is

  protected body Buffer is
    entry get (item : out Data_Item) when count > 0 is
    begin
      item    := storage (out_idx);
      out_idx := (out_idx mod capacity) + 1;
      count   := count - 1;
    end get;

    entry put (item : in Data_Item) when count < capacity is
    begin
      storage (in_idx) := item;
      in_idx  := (in_idx mod capacity) + 1;
      count   := count + 1;
    end put;
  end Buffer;

end Bounded_Buffer_Generic;

해결책 분석

  • 상호 배제: getput이 보호 엔트리이므로, Ada 런타임은 이들의 몸체가 동시에 실행되지 않도록 자동적으로 보장합니다 (제약 조건 3 충족).
  • 생산자 대기: put 엔트리의 배리어 when count < capacity는 버퍼가 가득 차면(count = capacity) False가 됩니다. 이때 put을 호출한 생산자 태스크는 런타임에 의해 자동으로 중단되어 대기합니다 (제약 조건 1 충족).
  • 소비자 대기: get 엔트리의 배리어 when count > 0은 버퍼가 비어 있으면(count = 0) False가 됩니다. 이때 get을 호출한 소비자 태스크는 자동으로 중단되어 대기합니다 (제약 조건 2 충족).

이처럼 Ada의 보호 객체는 생산자-소비자 문제의 동기화 제약 조건들을 언어 차원에서 선언적으로, 그리고 안전하게 구현할 수 있는 이상적인 해법을 제공합니다.

21.1.2 읽기-쓰기 문제 (readers-writer problem)

읽기-쓰기 문제(Readers-Writer Problem)는 다수의 태스크가 하나의 공유 자원에 접근할 때 발생하는 고전적인 동기화 문제입니다. 이 문제의 목표는 읽기 작업의 동시성을 최대한 허용하면서 데이터의 일관성을 보장하는 것입니다.

문제 정의

동기화 제약 조건은 다음과 같습니다.

  1. 자원의 상태를 변경하지 않는 판독자(Readers)들은 여러 명이 동시에 자원에 접근하여 읽을 수 있어야 한다.
  2. 자원의 상태를 변경하는 기록자(Writers)는 한 번에 오직 한 명만 자원에 접근할 수 있어야 한다 (배타적 접근).
  3. 기록자가 자원에 접근하고 있는 동안에는, 어떤 판독자도 자원에 접근할 수 없어야 한다.

Ada를 이용한 해결책

이 문제는 Ada의 보호 객체가 가진 보호 함수(읽기)와 보호 프로시저(쓰기)의 내장된 규칙을 이용하면 매우 명확하고 간결하게 해결할 수 있습니다. 언어의 기능 자체가 문제의 요구사항과 직접적으로 부합합니다.

구현 코드

공유 자원을 캡슐화하는 보호 객체의 구현은 다음과 같습니다.

1. 명세 (shared_resource.ads)

package Shared_Resource_Package is
  type Shared_Data is record
    -- ...
  end record;

  protected type Resource_Controller is
    -- 기록자는 배타적 접근을 위해 프로시저를 사용합니다.
    procedure write (data : in Shared_Data);

    -- 판독자는 공유 접근을 위해 함수를 사용합니다.
    function read return Shared_Data;
  private
    protected_data : Shared_Data;
  end Resource_Controller;

end Shared_Resource_Package;

2. 몸체 (shared_resource.adb)

package body Shared_Resource_Package is
  protected body Resource_Controller is
    procedure write (data : in Shared_Data) is
    begin
      protected_data := data;
    end write;

    function read return Shared_Data is
    begin
      return protected_data;
    end read;
  end Resource_Controller;
end Shared_Resource_Package;

해결책 분석

  • 판독자 동시성 (규칙 1): read 연산을 보호 함수로 구현했습니다. Ada 런타임은 기록자가 없는 한, 여러 태스크가 동시에 보호 함수를 호출하는 것을 자동으로 허용합니다.
  • 기록자 배타성 (규칙 2, 3): write 연산을 보호 프로시저로 구현했습니다. Ada 런타임은 write 프로시저가 실행되는 동안 다른 어떤 writeread 호출도 허용하지 않고 자동으로 차단합니다.

이처럼 프로그래머는 판독자 수나 잠금 상태를 수동으로 관리할 필요가 전혀 없습니다. 단순히 연산의 성격(읽기 또는 쓰기)에 맞춰 function 또는 procedure를 선택하는 것만으로, Ada 런타임이 모든 복잡한 동기화 제어를 안전하게 처리합니다.

잠재적 고려사항: 기록자 기아 상태

이 단순한 구현은 판독 요청이 끊임없이 들어오는 경우, 기록자가 자원에 대한 접근 권한을 얻지 못하고 무한정 대기하는 기록자 기아 상태(writer starvation)를 유발할 수 있습니다. 만약 기록자 우선 정책과 같은 더 정교한 제어가 필요하다면, 엔트리와 배리어를 사용하여 더 복잡한 로직을 구현할 수 있습니다. 하지만 많은 응용 프로그램에서는 이 간결한 기본 모델만으로도 충분합니다.

21.1.3 식사하는 철학자들 문제 (dining Philosophers problem)

식사하는 철학자들 문제는 교착 상태(deadlock)와 기아 상태(starvation)의 위험성을 설명하기 위해 사용되는 전형적인 동시성 문제입니다. 이는 여러 태스크가 한정된 자원을 놓고 경쟁할 때 발생할 수 있는 미묘한 상호작용을 보여줍니다.

문제 정의

  1. 설정: 다섯 명의 철학자가 원형 테이블에 앉아 있습니다. 각 철학자의 앞에는 접시가 있고, 철학자들 사이에는 각각 하나의 젓가락이 놓여 있습니다. 즉, 5명의 철학자, 5개의 접시, 5개의 젓가락이 존재합니다.
  2. 동작: 철학자는 생각하기식사하기의 두 가지 상태를 반복합니다.
  3. 제약 조건: 철학자가 식사를 하기 위해서는 자신의 왼쪽과 오른쪽에 있는 젓가락 두 개가 모두 필요합니다. 젓가락은 한 번에 하나씩만 집을 수 있습니다.

교착 상태의 함정

가장 직관적인 해결책은 각 철학자가 다음의 알고리즘을 따르는 것입니다.

  1. 생각한다.
  2. 왼쪽 젓가락을 집는다.
  3. 오른쪽 젓가락을 집는다.
  4. 식사한다.
  5. 두 젓가락을 내려놓는다.

이 설계는 심각한 결함이 있습니다. 만약 모든 철학자가 거의 동시에 식사를 시작하려 한다면, 모두 성공적으로 자신의 왼쪽 젓가락을 집을 것입니다. 그 후, 각자 자신의 오른쪽 젓가락(옆 철학자의 왼쪽 젓가락)을 집으려고 시도하지만, 이미 다른 철학자가 점유하고 있으므로 모두 대기 상태에 빠집니다. 모든 철학자가 자원 하나를 점유한 채, 다른 철학자가 점유한 자원을 무한정 기다리는 순환 대기(circular wait)가 발생하여 교착 상태에 이르게 됩니다.

Ada를 이용한 해결책 (중재자 패턴)

교착 상태를 피하는 한 가지 효과적인 방법은 젓가락을 직접 집는 대신, 중재자(Arbitrator)에게 식사 허가를 요청하는 것입니다. 이 중재자는 젓가락들의 상태를 관리하며, 한 철학자가 두 개의 젓가락을 모두 사용할 수 있을 때만 식사를 허락합니다. 이 중재자 역할은 보호 객체로 구현하기에 매우 적합합니다.

구현 코드: 젓가락들의 사용 가능 여부를 관리하는 Chopstick_Manager 보호 객체와, 이를 사용하는 Philosopher 태스크의 구현은 다음과 같습니다.

-- Chopstick_Manager.ads
protected type Chopstick_Manager is
  -- 철학자가 식사를 요청하는 엔트리
  entry request_to_eat (id : in Philosopher_Id);

  -- 철학자가 식사를 마친 후 젓가락을 반납하는 프로시저
  procedure release_chopsticks (id : in Philosopher_Id);
private
  chopsticks_available : array (Philosopher_Range) of Boolean := (others => True);
  function left_chopstick_of (id : Philosopher_Id) return Natural;
  function right_chopstick_of (id : Philosopher_Id) return Natural;
end Chopstick_Manager;

-- Chopstick_Manager.adb
protected body Chopstick_Manager is
  entry request_to_eat (id : in Philosopher_Id)
    when chopsticks_available(left_chopstick_of(id)) and
         chopsticks_available(right_chopstick_of(id)) is
  begin
    chopsticks_available(left_chopstick_of(id))  := False;
    chopsticks_available(right_chopstick_of(id)) := False;
  end request_to_eat;

  procedure release_chopsticks (id : in Philosopher_Id) is
  begin
    chopsticks_available(left_chopstick_of(id))  := True;
    chopsticks_available(right_chopstick_of(id)) := True;
  end release_chopsticks;
  -- ... left_chopstick_of, right_chopstick_of 함수 구현 ...
end Chopstick_Manager;

-- Philosopher Task
task type Philosopher (id : Philosopher_Id; manager : access Chopstick_Manager);
task body Philosopher is
begin
  loop
    -- ... 생각하기 ...
    manager.request_to_eat (id); -- 젓가락 두 개를 모두 사용할 수 있을 때까지 대기
    -- ... 식사하기 ...
    manager.release_chopsticks (id);
  end loop;
end Philosopher;

해결책 분석

이 설계에서는 철학자가 request_to_eat 엔트리를 호출합니다. Chopstick_Manager의 배리어는 해당 철학자의 양쪽 젓가락이 모두 사용 가능할 때True가 됩니다. 따라서 철학자는 젓가락 두 개를 동시에 할당받거나, 아니면 둘 다 할당받지 않고 대기하게 됩니다. 젓가락 하나만 점유한 채 다른 하나를 기다리는 상태가 원천적으로 불가능하므로 순환 대기가 발생하지 않아 교착 상태가 해결됩니다.

Ada의 보호 객체와 배리어는 이처럼 복잡한 자원 할당 문제를 안전하고 구조적으로 해결할 수 있는 강력한 추상화를 제공합니다.

21.2 태스크와 예외 처리

순차적 프로그램에서 예외 처리는 비교적 명확한 규칙을 따르지만, 여러 실행 흐름이 상호작용하는 동시성 환경에서는 예외의 전파와 처리가 훨씬 더 복잡해집니다. 하나의 태스크에서 발생한 예외가 통신 중인 다른 태스크에 어떤 영향을 미치는지, 또는 태스크가 비정상적으로 종료될 때 시스템은 어떻게 반응하는지를 이해하는 것은 견고한 동시성 시스템을 구축하는 데 필수적입니다.

이 절에서는 동시성 환경에서의 예외 처리 규칙을 체계적으로 다룹니다. 먼저 랑데부 과정에서 예외가 발생했을 때 호출자와 제공자에게 각각 어떤 일이 일어나는지 분석합니다. 이어서 태스크 몸체에서 처리되지 않은 예외의 결과를 살펴보고, 마지막으로 Ada의 태스킹 시스템 자체가 비정상적인 상황을 알리기 위해 발생하는 특별한 예외인 Tasking_Error에 대해 학습합니다.

21.2.1 랑데부 중 발생하는 예외

랑데부가 진행되는 동안, 즉 accept 문의 do..end 블록 내부에서 예외가 발생하는 경우, Ada는 두 태스크 모두에게 실패를 알리는 명확한 규칙을 적용합니다.

예외 전파 규칙

accept 문 내부에서 발생한 예외는 accept 문을 즉시 종료시키고, 해당 예외는 호출자(caller)와 제공자(callee) 양쪽 모두에게 동일하게 다시 발생(re-raise)됩니다.

  • 제공자(callee) 측: 예외는 accept 문 위치에서 발생합니다.
  • 호출자(caller) 측: 예외는 entry를 호출한 위치에서 발생합니다.

이 규칙은 랑데부가 원자적인 트랜잭션(atomic transaction)처럼 동작하도록 보장합니다. 트랜잭션 도중 문제가 생기면, 관련된 양측 모두에게 실패가 통보되어 어느 한쪽만 정상적으로 완료되었다고 착각하는 상황을 방지합니다.

예외 처리 예시

음수 값을 처리할 수 없는 서버 태스크와 이를 호출하는 클라이언트의 예시는 다음과 같습니다.

1. 제공자 (서버) 태스크

task body Request_Handler is
begin
  loop
    begin -- 예외 처리를 위한 내부 블록
      accept process (data : in Integer) do
        -- 랑데부 구간
        if data < 0 then
          raise Constraint_Error with "음수 데이터는 처리할 수 없습니다.";
        end if;
        -- ... 정상적인 데이터 처리 ...
      end process;
    exception
      -- 랑데부에서 발생한 예외를 여기서 처리
      when E : Constraint_Error =>
        put_line ("서버 오류: " & Exception_Message (E));
        -- 서버는 오류를 기록하고 계속 다음 요청을 기다림
    end;
  end loop;
end Request_Handler;

2. 호출자 (클라이언트) 코드

procedure Client_Code is
begin
  begin -- 엔트리 호출 중 발생할 예외를 처리
    -- 의도적으로 예외를 유발하는 호출
    Request_Handler.process (-1);

    put_line ("클라이언트: 요청 성공"); -- 이 라인은 실행되지 않음
  exception
    when E : Constraint_Error =>
      put_line ("클라이언트 오류: " & Exception_Message (E));
      -- 클라이언트는 요청이 실패했음을 인지하고 대안 동작 수행 가능
  end;
end Client_Code;

실행 흐름:

  1. Client_CodeRequest_Handler.process(-1)을 호출하여 랑데부가 시작됩니다.
  2. accept 블록 내부의 if 문에서 Constraint_Error가 발생합니다.
  3. 랑데부가 즉시 비정상적으로 종료됩니다.
  4. 동일한 Constraint_Error 예외가 Request_Handleraccept 문 위치와 Client_Codeprocess 호출 위치에 동시에 발생합니다.
  5. 각각의 exception 핸들러가 예외를 포착하여 처리합니다.

이처럼 대칭적인 예외 전파 규칙은 양측의 상태를 일관되게 유지시켜, 동시성 시스템의 안정성과 예측 가능성을 높이는 중요한 역할을 합니다.

21.2.2 태스크 몸체에서의 예외 처리

랑데부 외부, 즉 태스크 몸체의 일반 실행 구간에서 예외가 발생하고 처리되지 않을 경우, 그 결과는 랑데부 중 예외와는 다른 규칙을 따릅니다.

예외 전파 규칙

태스크 몸체에서 발생한 예외가 해당 태스크 내의 예외 핸들러에 의해 처리되지 않으면, 그 예외는 다른 곳으로 전파되지 않습니다. 대신, 해당 태스크는 즉시 완료(completed)된 후 종료(terminated)됩니다.

이 규칙은 결함 격리(fault isolation) 원칙을 따릅니다. 하나의 자율적인 컴포넌트(태스크)의 실패가 연쇄적으로 다른 컴포넌트의 실패를 유발하지 않도록, 예외를 해당 태스크의 경계 안에 가두는 것입니다. 태스크는 외부로 예외를 전파하는 대신, 조용히 종료됩니다.

종료된 태스크와의 통신

다른 태스크들은 종료된 태스크의 실패를 직접 통지받지 못합니다. 대신, 종료된 태스크와 통신을 시도할 때 그 사실을 알게 됩니다.

이미 종료된 태스크의 엔트리를 호출하려고 시도하면, 호출자 측에서 즉시 Tasking_Error 예외가 발생합니다.

이는 서버 태스크의 비정상적인 종료를 감지하는 주된 메커니즘입니다.

예외 처리 예시

내부 로직 수행 중 처리되지 않은 예외로 인해 종료되는 서버 태스크와, 이를 호출하는 클라이언트의 예시는 다음과 같습니다.

1. 비정상 종료되는 서버 태스크

task body Faulty_Server is
  request_count : Natural := 0;
begin
  loop
    accept process_request; -- 랑데부 자체는 정상 완료

    -- 랑데부 외부의 로직
    request_count := request_count + 1;
    if request_count = 3 then
      -- 처리되지 않는 예외 발생
      raise Storage_Error with "치명적인 내부 오류";
    end if;
  end loop;
  -- 이 태스크에는 예외 핸들러가 없으므로, 예외 발생 시 즉시 종료됨
end Faulty_Server;

2. 클라이언트 코드

procedure Client_Code is
begin
  Faulty_Server.process_request; -- 성공
  Faulty_Server.process_request; -- 성공. 이제 서버의 request_count는 2

  Faulty_Server.process_request; -- 이 호출 후 서버는 내부 예외로 종료됨

  delay 1.0; -- 서버가 종료될 시간적 여유

  put_line ("종료된 서버에 재호출 시도...");
  Faulty_Server.process_request; -- 이 호출에서 Tasking_Error 발생

exception
  when Tasking_Error =>
    put_line ("오류: 서버 태스크가 응답하지 않습니다 (종료됨).");
end Client_Code;

바람직한 구현

견고한 서버 태스크는 예측하지 못한 예외로 인해 전체가 종료되는 것을 막기 위해, 주 실행 루프를 begin..exception..end 블록으로 감싸는 것이 일반적입니다. 이를 통해 예외를 포착하여 기록하고, 필요한 복구 작업을 수행한 뒤, 루프를 계속하여 다른 클라이언트에게 서비스를 지속할 수 있습니다.

task body Robust_Server is
begin
  loop
    begin -- 루프 전체를 위한 예외 핸들러
      -- ... accept 및 다른 로직 ...
    exception
      when others => -- 예상치 못한 모든 예외 포착
        log_error;   -- 오류 기록
    end;
  end loop;
end Robust_Server;

21.2.3 Tasking_Error 예외

Tasking_ErrorAda.Tasking 패키지에 정의된 내장된 예외(predefined exception)로, 애플리케이션 로직의 오류가 아닌 태스크 간의 상호작용이나 태스크의 생명주기 관리 과정에서 근본적인 문제가 발생했을 때 Ada 런타임 시스템에 의해 발생하는 특별한 예외입니다.

이는 태스킹 시스템 자체가 “더 이상 정상적인 상호작용을 계속할 수 없다”고 알리는 중요한 신호입니다.

Tasking_Error의 주요 발생 원인

  1. 종료된 태스크와의 통신: 가장 일반적인 원인입니다. 이미 종료(terminated)된 태스크의 엔트리를 호출하려고 시도하면, 호출자 측에서 즉시 Tasking_Error가 발생합니다. 이는 통신 대상이 더 이상 존재하지 않음을 의미합니다.

    -- Faulty_Server 태스크가 내부 오류로 이미 종료된 상태
    
    begin
      Faulty_Server.request (...); -- 이 호출은 즉시 Tasking_Error를 유발
    exception
      when Tasking_Error =>
        -- 서버가 사라졌음을 감지하고 처리
    end;
    
  2. 태스크 활성화 실패: 자식 태스크가 활성화(activation) 과정에서 처리되지 않은 예외를 일으켜 즉시 종료되면, 그 부모(master) 태스크의 활성화 지점에서 Tasking_Error가 발생합니다. 이는 부모에게 자신이 의존하는 하위 컴포넌트 중 하나가 시작에 실패했음을 알리는 신호입니다.

  3. abort된 태스크와의 통신: abort 문에 의해 강제 종료된 태스크와 통신을 시도하는 경우도 1번과 마찬가지로 Tasking_Error를 유발합니다.

Tasking_Error의 역할

Tasking_Error는 동시성 시스템의 견고성을 높이는 데 중요한 역할을 합니다. 클라이언트 태스크는 이 예외를 처리함으로써, 자신이 의존하는 서버 태스크의 실패를 감지하고 그에 대한 복구 로직(예: 백업 서버 사용, 작업 취소, 오류 보고)을 수행할 수 있습니다.

즉, Tasking_Error는 개별 태스크의 실패가 시스템 전체의 치명적인 오류로 번지지 않도록 막고, 다른 태스크들이 해당 실패에 대처할 기회를 제공하는 결함 감지 및 격리 메커니즘의 핵심 요소입니다.

21.3 태스크 속성(attribute)

Ada는 태스크 객체의 현재 상태나 고유한 특성에 대한 정보를 질의할 수 있는 여러 속성(attribute)을 제공합니다. 속성은 Task_Object'Attribute_Name과 같이 어포스트로피(')를 사용하여 접근하며, 이를 통해 프로그램이 실행 중에 다른 태스크를 모니터링하거나 관리하는 로직을 구현할 수 있습니다.

이 절에서는 가장 빈번하게 사용되는 세 가지 태스크 속성을 학습합니다. 태스크가 종료되거나 비정상 상태에 빠져 호출을 받을 수 없는지 확인하는 'callable 속성, 태스크의 생명주기가 완전히 끝났는지 검사하는 'Terminated 속성, 그리고 각 태스크에 부여된 고유 식별자를 얻는 'Identity 속성에 대해 알아볼 것입니다.

이러한 속성들은 태스크의 상태를 외부에서 확인할 수 있는 유용한 수단을 제공하지만, 사용 시 주의가 필요합니다. 속성을 읽는 시점과 그 정보를 사용하는 시점 사이에도 태스크의 상태는 계속 변할 수 있으므로, 결정적인 동기화 로직보다는 주로 디버깅, 로깅, 또는 비결정적인 상태 모니터링 용도로 활용됩니다.

21.3.1 'callable 속성

'callable은 태스크의 현재 상태를 질의하는 불리언(boolean) 속성으로, 해당 태스크에 엔트리를 호출했을 때 Tasking_Error가 즉시 발생하지 않을 것인지를 알려줍니다.

정의: T가 태스크 객체일 때, T'callable은 다음 경우에 False를 반환합니다.

  • 태스크 T가 완료(completed)된 경우
  • 태스크 T가 종료(terminated)된 경우
  • 태스크 T의 활성화가 실패하여 비정상 상태인 경우

그 외의 모든 상태(실행 중, accept 대기 중 등)에서는 True를 반환합니다.

사용 목적 및 구문

'callable 속성은 주로 다른 태스크와 통신을 시도하기 전에, 대상 태스크가 살아있는지 확인하는 방어적인 검사 목적으로 사용됩니다. 이를 통해 Tasking_Error 예외의 발생 가능성을 줄일 수 있습니다.

if Server'callable then
  -- 서버가 호출 가능한 상태이므로, 통신을 시도합니다.
  Server.process_request (my_data);
else
  -- 서버가 이미 종료되었으므로, 대안적인 동작을 수행합니다.
  log_error ("서버가 종료되어 요청을 처리할 수 없습니다.");
end if;

주의사항: 경쟁 상태 (Race Condition)

'callable 속성을 사용할 때 반드시 인지해야 할 중요한 한계가 있습니다. 속성 값을 확인하는 시점과 그 값을 사용하는 시점 사이에는 경쟁 상태가 존재합니다.

  1. if Server'callable then 문장에서 True를 반환합니다.
  2. (제어권이 다른 태스크로 넘어감)
  3. Server 태스크가 어떤 이유로든 바로 이 순간에 종료됩니다.
  4. Server.process_request 호출이 실행됩니다.
  5. 결과적으로 종료된 태스크에 호출을 시도하게 되어 Tasking_Error 예외가 발생합니다.

이러한 경쟁 상태 때문에, 'callable 속성은 Tasking_Error가 발생하지 않을 것임을 보장할 수 없습니다. 따라서 이 속성은 사전 검사용으로 유용하지만, 견고한 코드는 'callable 속성을 사용하더라도 만약을 대비하여 항상 Tasking_Error 예외 핸들러를 구비해야 합니다.

21.3.2 'Terminated 속성

'Terminated는 태스크의 생명주기가 완전히 끝났는지를 확인하는 불리언(boolean) 속성입니다.

정의: T가 태스크 객체일 때, T'Terminated는 태스크 T종료(terminated) 상태인 경우에만 True를 반환하고, 그 외의 모든 상태(실행 중, 완료됨, 대기 중 등)에서는 False를 반환합니다.

'callable과의 차이점

'callable'Terminated는 미묘하지만 중요한 차이가 있습니다. 태스크가 자신의 코드 실행을 마치면 완료(completed) 상태가 되지만, 아직 자식 태스크나 부모와의 관계가 정리되지 않아 종료(terminated)되지 않았을 수 있습니다.

  • T완료되었지만 아직 종료되지 않은 상태:
    • T'callableFalse입니다.
    • T'TerminatedFalse입니다.

'Terminated는 태스크가 시스템에서 완전히 사라졌음을 확인하는 최종적인 신호입니다.

사용 목적 및 구문

'Terminated 속성은 주로 부모 태스크가 자식 태스크의 완전한 종료를 확인하고 후속 작업을 처리해야 할 때 사용됩니다. 예를 들어, 여러 작업자 태스크가 모두 종료된 후에야 프로그램을 끝내고 싶을 때 유용합니다.

-- Worker_1과 Worker_2 태스크 객체가 있다고 가정
...
-- 두 태스크가 모두 종료될 때까지 대기하는 루프
while not Worker_1'Terminated or not Worker_2'Terminated loop
  -- 모든 작업이 끝날 때까지 메인 태스크는 다른 일을 할 수 있습니다.
  delay 0.1; -- 바쁜 대기를 피하기 위한 지연
end loop;

put_line ("모든 작업자 태스크가 종료되었습니다. 시스템을 종료합니다.");

주의사항: 경쟁 상태

'callable과 마찬가지로, 'Terminated 속성 역시 그 값을 읽는 시점의 스냅샷일 뿐입니다. 이 속성을 동기화 목적으로 사용하는 것은 경쟁 상태를 유발할 수 있으므로 주의해야 합니다. 주된 용도는 상태 모니터링이나 시스템의 최종 정리 단계에서 종료 여부를 확인하는 것입니다. 견고한 코드는 태스크의 종료를 확인하는 주된 수단으로 Tasking_Error 예외 처리나 다른 명시적인 동기화 메커니즘을 사용해야 합니다.

21.3.3 'Identity 속성

'Identity 속성은 각 태스크 객체에 부여된 고유한 식별자를 반환합니다. 이 속성은 동기화보다는 주로 디버깅, 로깅, 또는 태스크 관리에 사용됩니다.

정의: T가 태스크 객체일 때, T'IdentityAda.Task_Identification 표준 패키지에 정의된 Task_ID 타입의 값을 반환합니다. 이 값은 해당 태스크가 활성화되어 있는 동안 시스템 내의 다른 모든 활성 태스크의 ID와 중복되지 않음이 보장됩니다.

Task_ID 타입은 private 타입이므로 그 내부 구조를 직접 조작할 수 없으며, 비교(=)나 Ada.Task_Identification 패키지가 제공하는 다른 서브프로그램들과 함께 사용해야 합니다.

사용 목적 및 구문

'Identity 속성의 주된 용도는 다음과 같습니다.

  1. 로깅 및 디버깅: 여러 태스크가 동시에 실행될 때, 로그 메시지에 각 태스크의 ID를 포함시켜 어떤 태스크가 어떤 동작을 수행했는지 추적할 수 있습니다.
  2. 데이터 연관: 태스크 ID를 맵(map)이나 다른 데이터 구조의 키로 사용하여, 특정 태스크와 연관된 부가적인 정보(예: 통계, 세션 데이터)를 저장할 수 있습니다.

Ada.Task_Identification 패키지는 현재 실행 중인 태스크 자신의 ID를 반환하는 Current_Task 함수와, Task_ID 값을 문자열로 변환하는 Image 함수를 제공합니다.

with Ada.Text_IO;             use Ada.Text_IO;
with Ada.Task_Identification; use Ada.Task_Identification;

procedure Show_Task_Identity is
  task type Worker (name : String);

  -- 두 개의 작업자 태스크 생성
  worker_a : Worker ("A");
  worker_b : Worker ("B");

  task body Worker is
    my_id : constant Task_ID := Current_Task; -- 자신의 ID 획득
  begin
    put_line (
      "Worker " & name & " executing with ID: " & Image (my_id)
    );
  end Worker;
begin
  put_line (
    "Main task ID is: " & Image (Current_Task)
  );
  put_line (
    "Worker A's ID from outside is: " & Image (worker_a'Identity)
  );
end Show_Task_Identity;

실행 결과 예시:

Main task ID is: 87620300
Worker A's ID from outside is: 87624A70
Worker A executing with ID: 87624A70
Worker B executing with ID: 87624C40

'Identity 속성은 이처럼 각 태스크에 고유한 “이름표”를 붙여주어, 복잡한 동시성 시스템의 동작을 분석하고 관리하는 데 필수적인 도구입니다.

22. 실시간 시스템과 동시성

지금까지 우리는 Ada의 동시성 기능들을 사용하여 프로그램의 논리적 정확성을 보장하는 방법을 학습했습니다. 하지만 항공우주, 국방, 의료, 산업 제어와 같은 수많은 임베디드 시스템에서는 논리적 정확성만으로는 부족합니다. 이러한 시스템에서는 시간적 정확성(temporal correctness), 즉 정해진 시간 제약(deadline) 내에 연산을 완료하는 것이 성공과 실패를 가르는 기준이 됩니다.

실시간 시스템(real-time system)이란 이처럼 외부 이벤트에 대해 예측 가능한 시간 내에 반응해야 하는 컴퓨팅 시스템을 의미합니다. Ada는 설계 초기부터 이러한 실시간 시스템 개발을 핵심 목표 중 하나로 삼았으며, 언어 표준의 실시간 부록(Real-Time Annex)을 통해 시간 관리, 우선순위 기반 스케줄링, 그리고 관련 문제 해결을 위한 정교한 기능들을 명시적으로 제공합니다.

이번 장에서는 Ada의 동시성 모델이 실시간 제약 조건과 어떻게 결합되는지를 탐구합니다. 먼저 실시간 시스템의 종류를 알아보고, Ada의 시간 표현 방식과 정밀한 지연 기능을 학습합니다. 이어서 태스크에 우선순위를 할당하고 스케줄링하는 방법과, 실시간 시스템의 고질적인 문제인 우선순위 역전(priority inversion)을 해결하기 위한 우선순위 상한 프로토콜(priority ceiling protocol)과 같은 고급 기법들을 배우게 될 것입니다.

22.1. 실시간 시스템 개요

실시간 시스템의 핵심은 예측 가능성(predictability)입니다. 즉, 특정 작업이 주어진 시간 제약 내에 완료될 것을 보장하는 것입니다. 이 절에서는 실시간 시스템을 그 제약 조건의 엄격함에 따라 분류하고, Ada가 이러한 시스템을 지원하기 위해 표준에 명시한 실시간 부록(Real-Time Annex)에 대해 알아봅니다. 이 개념들을 이해하는 것은 우리가 만들고자 하는 시스템의 시간적 요구사항을 명확히 하고, 그에 맞는 Ada 기능을 선택하는 첫걸음입니다.

22.1.1 경성(hard), 연성(soft), 확정적(firm) 실시간 시스템

실시간 시스템은 마감 시간(deadline)을 준수하지 못했을 때 발생하는 결과의 심각성에 따라 크게 세 가지로 분류됩니다.

1. 경성 실시간 시스템 (Hard Real-Time Systems)

경성 실시간 시스템에서는 정해진 마감 시간을 지키지 못하는 것이 곧 치명적인 시스템 실패(catastrophic system failure)로 간주됩니다. 결과의 논리적 정확성뿐만 아니라 시간적 정확성이 절대적으로 보장되어야 합니다.

  • 결과: 인명 손실, 임무 실패, 막대한 재산 피해 등을 유발할 수 있습니다.
  • 요구사항: 시스템의 모든 동작이 예측 가능해야 하며, 최악의 경우(worst-case)에도 마감 시간을 준수함을 증명할 수 있어야 합니다.
  • 예시: 항공기 비행 제어 시스템 ✈️, 자동차의 에어백 및 ABS, 원자력 발전소 제어, 인공 심박동기 ❤️.

2. 연성 실시간 시스템 (Soft Real-Time Systems)

연성 실시간 시스템에서는 마감 시간을 지키지 못하더라도 치명적인 실패로 이어지지는 않습니다. 대신 시스템의 성능이나 서비스 품질(quality of service)이 저하됩니다.

  • 결과: 마감 시간이 지나면 결과의 유용성이 점차 감소합니다.
  • 요구사항: 평균적인 응답 시간을 단축하고, 대부분의 경우 마감 시간을 준수하는 것을 목표로 합니다.
  • 예시: 실시간 비디오 스트리밍 🎞️ (일부 프레임 누락은 수용 가능), 온라인 게임 (네트워크 지연은 불편하지만 시스템 실패는 아님), 주식 시세 표시 시스템.

3. 확정적 실시간 시스템 (Firm Real-Time Systems)

확정적 실시간 시스템은 경성과 연성의 중간적 성격을 가집니다. 마감 시간을 지키지 못하면 해당 결과는 완전히 가치가 없어지지만(useless), 시스템 전체가 실패하지는 않습니다.

  • 결과: 늦게 도출된 결과는 그냥 폐기됩니다.
  • 요구사항: 개별 결과값의 시간적 유효성이 중요합니다.
  • 예시: 공장 자동화 라인의 제어 신호 (특정 공정 시점을 놓친 제어 신호는 무의미함), 기상 예측을 위한 데이터 수집 시스템 (수집 시점을 놓친 데이터는 폐기됨).

구분 경성 실시간 (Hard) 연성 실시간 (Soft) 확정적 실시간 (Firm)
마감 시간 위반 결과 치명적 시스템 실패 성능 저하 결과값의 가치 소멸
핵심 목표 최악의 경우 보장 (Worst-case Guarantee) 평균 응답 시간 최소화 개별 결과의 유효성 보장
예시 비행 제어, 의료 기기 비디오 스트리밍, 온라인 게임 공장 자동화, 데이터 수집

Ada는 특히 경성 실시간 시스템 개발에 필요한 결정론적이고 예측 가능한 기능을 제공하는 데 중점을 두고 설계되었습니다.

22.1.2 실시간 부록 (real-time annex)

Ada 언어 표준은 핵심(Core) 언어와 여러 부록(Annex)으로 구성됩니다. 실시간 부록(Real-Time Annex)은 이 중 하나로, 실시간 시스템 개발에 필수적인 기능들을 표준화하여 정의한 부분입니다. 컴파일러가 부록의 기능을 지원하는 것은 선택 사항이지만, 실시간 및 임베디드 분야를 대상으로 하는 대부분의 주요 Ada 컴파일러는 이 부록을 완전하게 구현합니다.

목적과 역할

실시간 부록의 주된 목적은 실시간 프로그램의 예측 가능성(predictability)이식성(portability)을 보장하는 것입니다. 특정 컴파일러나 운영체제에 종속적인 기능 대신, 표준화된 인터페이스와 동작 규칙을 제공함으로써 어떤 환경에서도 일관되게 동작하는 실시간 코드를 작성할 수 있도록 지원합니다.

주요 내용

실시간 부록은 다음과 같은 핵심 기능들을 정의하며, 이어지는 절들에서 이 내용들을 상세히 다룰 것입니다.

  1. 우선순위 기반 스케줄링 (Priority-Based Scheduling):
    • 태스크에 우선순위를 부여하고, 높은 우선순위의 태스크가 낮은 우선순위의 태스크를 선점(preempt)하는 스케줄링 모델을 표준화합니다.
    • 동일 우선순위 내에서는 선입선출(FIFO)로 처리하는 규칙 등을 포함합니다.
  2. 시간 관리 (Time Management):
    • 시스템 시간의 변화에 영향을 받지 않는 고해상도의 단조 시계(monotonic clock)를 제공하는 Ada.Real_Time 패키지를 정의합니다.
    • 특정 절대 시간까지 정밀하게 실행을 지연시키는 delay until 문을 지원합니다.
  3. 우선순위 역전 제어 (Priority Inversion Control):
    • 실시간 시스템의 고질적인 문제인 우선순위 역전 현상을 방지하기 위한 메커니즘을 정의합니다.
    • 특히 보호 객체에 적용되는 우선순위 상한 프로토콜(Priority Ceiling Protocol)pragma Locking_Policy를 통해 표준으로 제공합니다.
  4. 기타 기능:
    • 태스크 중단(abort)에 대한 제어, 동기식 태스크 제어, 타이머 이벤트 핸들링 등 예측 가능한 시스템을 구축하기 위한 다양한 저수준 제어 기능들을 포함합니다.

실시간 부록은 Ada를 단순한 동시성 지원 언어를 넘어, 신뢰성이 요구되는 경성 실시간 시스템을 개발하기 위한 전문적인 언어로 만들어주는 핵심적인 부분입니다.

22.2 시간과 시계

실시간 시스템에서 작업을 스케줄링하고 마감 시간을 준수하기 위해서는 시간을 정밀하고 예측 가능하게 측정하고 관리하는 능력이 필수적입니다. 일반적인 컴퓨터의 시계는 사용자에 의해 변경될 수 있어 실시간 작업에는 부적합하므로, Ada는 실시간 부록을 통해 시간의 흐름을 안정적으로 표현하고 제어하는 표준화된 방법을 제공합니다.

이 절에서는 Ada.Real_Time 패키지가 제공하는 시간 관련 타입들을 살펴보고, 이를 이용하여 특정 절대 시간까지 실행을 지연시키는 delay until 문의 사용법을 학습합니다. 또한, 실시간 시스템에서 왜 시간의 역행이 없는 단조 시계(monotonic clock)가 중요한지를 이해하게 될 것입니다.

22.2.1 Ada.Real_Time 패키지

Ada.Real_Time은 실시간 부록에 정의된 핵심 패키지로, 고해상도의 정밀한 시간 관리를 위한 표준화된 타입과 서브프로그램들을 제공합니다. 이 패키지는 실시간 시스템의 시간적 동작을 예측 가능하고 이식성 있게 구현하기 위한 기반이 됩니다.

주요 타입

  1. Time: 특정 시점, 즉 절대 시간을 나타내는 private 타입입니다. 이 타입의 값은 외부 시간 설정 변경(예: 서머타임)에 영향을 받지 않고 오직 증가하기만 하는 단조 시계(monotonic clock)를 기준으로 합니다.
  2. Time_Span: 시간의 길이, 즉 상대적인 기간을 나타내는 private 타입입니다. 시간 연산에 사용됩니다.

주요 기능

  • Clock 함수: 현재 시간을 Time 타입으로 반환합니다.
    current_time : Time := Ada.Real_Time.Clock;
    
  • 시간 연산: TimeTime_Span 타입을 위한 기본적인 산술 연산자를 제공합니다.
    • Time + Time_SpanTime (미래 시점 계산)
    • Time - Time_SpanTime (과거 시점 계산)
    • Time - TimeTime_Span (두 시점 간의 기간 계산)
  • 기간 생성 함수: Nanoseconds, Microseconds, Milliseconds 등의 함수를 통해 정밀한 Time_Span 값을 생성할 수 있습니다.
    one_millisecond : constant Time_Span := Milliseconds (1);
    

사용 예시 (코드 실행 시간 측정)

Ada.Real_Time 패키지를 사용하여 특정 코드 블록의 실행 시간을 측정하는 예시는 다음과 같습니다.

with Ada.Text_IO;   use Ada.Text_IO;
with Ada.Real_Time; use Ada.Real_Time;

procedure Time_Measurement is
  start_time : Time;
  end_time   : Time;
  elapsed    : Time_Span;
begin
  -- 1. 시작 시간 기록
  start_time := Clock;

  -- 2. 시간 측정 대상 작업
  for i in 1 .. 1_000_000 loop
    null;
  end loop;

  -- 3. 종료 시간 기록
  end_time := Clock;

  -- 4. 경과 시간 계산
  elapsed := end_time - start_time;

  put_line ("경과 시간: " & Time_Span'image (elapsed) & " 초");
end Time_Measurement;

Ada.Real_Time 패키지는 이처럼 실시간 시스템이 요구하는 안정적이고 정밀한 시간 관리 기능을 표준화하여 제공함으로써, 코드의 신뢰성과 이식성을 보장합니다.

22.2.2 delay until

delay until 문은 태스크의 실행을 Ada.Real_Time.Time 타입으로 명시된 특정 절대 시점까지 정밀하게 중단시키는 제어문입니다. 이는 실시간 시스템에서 주기적인(periodic) 작업을 구현하는 표준적인 방법입니다.

delay 와의 차이점

단순 delay 문은 상대적인 시간만큼 실행을 지연시킵니다. 예를 들어, delay 1.0;은 “지금부터 최소 1.0초 동안” 지연하라는 의미입니다. 루프 안에서 delay를 사용하면, 루프 내 다른 연산 시간 때문에 주기가 누적되어 점점 밀리는 현상(drift)이 발생합니다.

반면 delay until은 절대 시점을 목표로 하므로 이러한 오차 누적이 발생하지 않습니다. “시계가 특정 값 T를 가리킬 때까지” 지연하라는 의미이므로, 중간에 다른 연산으로 시간이 소요되더라도 다음 대기 시간이 자동으로 보정되어 전반적인 주기를 정확하게 유지합니다.

기본 구문

delay until <time_expression>;
  • <time_expression>: Ada.Real_Time.Time 타입의 값으로, 태스크가 실행을 재개할 목표 시점을 나타냅니다.

사용 예시 (주기적인 태스크)

20 밀리초(millisecond)마다 주기적으로 센서 값을 읽는 태스크의 구현은 다음과 같습니다.

with Ada.Real_Time; use Ada.Real_Time;

task body Periodic_Task is
  -- 주기를 20 밀리초로 정의
  period : constant Time_Span := Milliseconds (20);

  -- 다음 실행 시간을 저장할 변수
  next_execution_time : Time;
begin
  -- 첫 번째 실행 시간을 현재 시간 기준으로 설정
  next_execution_time := Clock;

  loop
    -- 다음 실행 시간까지 실행을 정밀하게 지연
    delay until next_execution_time;

    -- --- 주기적인 작업 수행 ---
    read_sensor_value;
    process_data;
    -- -------------------------

    -- 다음 실행 시간을 이전 목표 시간 기준으로 계산 (오차 누적 방지)
    next_execution_time := next_execution_time + period;
  end loop;
end Periodic_Task;

위 코드에서 delay until은 다음 실행 시간을 정확히 목표로 하여 대기합니다. 만약 주기적인 작업 수행 시간이 길어져 next_execution_time이 이미 과거가 되었다면, delay until은 지연 없이 즉시 다음으로 진행하여 태스크가 최대한 스케줄을 따라잡도록 합니다.

이처럼 delay until 문은 실시간 시스템에서 요구되는 결정론적이고 예측 가능한 주기적 행위를 구현하는 핵심적인 도구입니다.

22.2.3 단조 시계(monotonic clock)

단조 시계(monotonic clock)는 시간이 오직 앞으로만 흐르는 것이 보장되는 시간 측정 기준입니다. 이 시계는 시스템 부팅과 같은 특정 시점에서 시작하여, 외부의 시간 변경에 전혀 영향을 받지 않고 일정하게 증가하기만 합니다.

일반 시계(Wall-Clock)와의 차이점

우리가 일상적으로 사용하는 일반 시계(wall-clock time)는 실시간 시스템에 부적합합니다. 그 이유는 다음과 같습니다.

  • 사용자나 네트워크 시간 프로토콜(NTP)에 의해 시간이 임의로 변경될 수 있습니다.
  • 서머타임(Daylight Saving Time) 적용으로 시간이 갑자기 앞으로나 뒤로 이동할 수 있습니다.

만약 일반 시계를 기준으로 delay until을 사용한다면, 시계가 과거로 조정될 경우 태스크는 의도치 않게 매우 긴 시간 동안 지연될 수 있어 시스템의 예측 가능성을 파괴합니다.

단조 시계의 필요성

Ada.Real_Time.Clock이 제공하는 단조 시계는 이러한 문제를 해결하며, 실시간 시스템에서 다음과 같은 필수적인 역할을 합니다.

  1. 예측 가능한 지연: delay until 문이 항상 미래의 특정 시점까지 안정적으로 대기하도록 보장합니다.
  2. 정확한 기간 측정: end_time - start_time과 같은 연산이 외부 시간 변경에 오염되지 않은, 순수한 경과 시간을 나타내도록 보장합니다.
  3. 신뢰성 있는 마감 시간: 주기적인 태스크의 다음 실행 시간(next_execution_time)을 계산할 때, 기준이 되는 시계가 임의로 변하지 않으므로 마감 시간 계산의 신뢰성이 보장됩니다.

결론적으로, 단조 시계는 실시간 시스템의 시간적 결정성(determinism)을 위한 가장 기본적인 전제 조건입니다. Ada는 Ada.Real_Time 패키지를 통해 이식성 있는 단조 시계 인터페이스를 표준으로 제공함으로써, 개발자가 신뢰성 높은 실시간 애플리케이션을 구축할 수 있는 안정적인 기반을 마련해 줍니다.

22.3. 태스크 스케줄링과 우선순위

실시간 시스템에서는 여러 태스크가 동시에 실행 준비 상태가 되었을 때, 어떤 태스크를 먼저 실행할지 결정하는 스케줄링(scheduling) 정책이 시스템의 예측 가능성을 좌우합니다. Ada는 실시간 부록을 통해 우선순위(priority)에 기반한 선점형 스케줄링 모델을 표준으로 정의합니다.

이 모델의 핵심은 시스템의 중요도에 따라 각 태스크에 고유한 우선순위를 할당하는 것입니다. 스케줄러는 항상 실행 가능한 태스크들 중 가장 높은 우선순위를 가진 태스크에게 CPU를 할당합니다.

이 절에서는 태스크에 우선순위를 부여하는 방법과 그에 따른 스케줄링 동작 규칙을 학습합니다. 더 나아가, 우선순위 기반 시스템의 고질적인 문제인 우선순위 역전 현상을 분석하고, 이를 해결하기 위한 Ada의 정교한 메커니즘인 우선순위 상한 프로토콜을 살펴볼 것입니다.

22.3.1 Priority 타입

Priority는 표준 패키지 System에 정의된 정수 기반 타입으로, 태스크의 스케줄링 우선순위를 지정하는 데 사용됩니다. 이 타입의 정확한 범위는 구현(컴파일러)에 따라 다르지만, 실시간 부록은 최소한의 범위를 보장합니다.

일반적으로 더 높은 숫자 값이 더 높은 우선순위를 의미합니다. System 패키지는 Any_Priority(전체 우선순위 범위)와 Default_Priority(명시적으로 지정하지 않았을 때 부여되는 기본 우선순위)와 같은 유용한 서브타입을 제공합니다.

우선순위 할당

태스크의 기본 우선순위(base priority)는 pragma Priority를 사용하여 태스크 명세 내에 정적으로 할당하는 것이 일반적입니다.

구문 및 예시:

with System;

package Critical_Tasks is

  -- 시스템에서 가장 중요한 태스크
  task Failure_Recovery_Handler is
    pragma Priority (System.Any_Priority'last); -- 가장 높은 우선순위 할당
  end Failure_Recovery_Handler;

  -- 일반적인 데이터 처리 태스크
  task Data_Processor is
    pragma Priority (System.Default_Priority); -- 기본 우선순위 할당
  end Data_Processor;

end Critical_Tasks;

Failure_Recovery_Handler 태스크는 Data_Processor 태스크보다 우선순위가 높습니다. 따라서, 두 태스크가 동시에 실행 가능한 상태가 되면 스케줄러는 항상 Failure_Recovery_Handler를 먼저 실행합니다. Data_ProcessorFailure_Recovery_Handler가 블록(block) 상태(예: delay until 또는 I/O 대기)에 있을 때만 실행될 기회를 얻습니다.

이처럼 pragma Priority를 통해 각 태스크의 정적인 중요도를 명시하는 것은, 예측 가능한 실시간 스케줄링을 구현하기 위한 가장 기본적인 단계입니다. 태스크의 실제 실행 우선순위(active priority)는 이후에 배울 우선순위 상속 등에 의해 동적으로 변할 수 있지만, 기본 우선순위는 변하지 않습니다.

22.3.2 우선순위 기반 스케줄링

Ada 실시간 부록이 정의하는 스케줄링 모델은 우선순위에 기반한 선점형 스케줄링(priority-based preemptive scheduling)입니다. 이 모델의 동작 규칙은 간단하고 결정론적입니다.

실행 가능한 상태에 있는 태스크들 중에서, 가장 높은 우선순위를 가진 태스크가 항상 CPU를 점유하여 실행됩니다.

선점 (Preemption)

이 모델의 핵심은 선점(preemption)입니다. 만약 낮은 우선순위의 태스크가 실행 중일 때 더 높은 우선순위의 태스크가 실행 가능한 상태가 되면(예: delay until 시간이 도래), 스케줄러는 현재 실행 중인 낮은 우선순위 태스크를 즉시 중단시키고, 더 높은 우선순위의 태스크에게 CPU 제어권을 넘겨줍니다.

낮은 우선순위 태스크는 자신보다 높은 우선순위를 가진 모든 태스크들이 블록(block) 상태에 있을 때만 다시 실행될 수 있습니다.

동작 순서 예시:

  1. T_Low (우선순위 5) 태스크가 CPU를 사용하며 실행 중입니다.
  2. T_High (우선순위 10) 태스크가 대기 상태에서 깨어나 실행 가능한 상태가 됩니다.
  3. 선점 발생: 스케줄러는 즉시 T_Low를 멈추고 T_High를 실행시킵니다.
  4. T_High는 자신의 작업을 수행한 후, 다시 대기 상태로 들어갑니다.
  5. 이제 T_High보다 높은 우선순위의 태스크가 없으므로, 중단되었던 T_Low가 자신의 이전 상태 그대로 실행을 재개합니다.

동일 우선순위 스케줄링: FIFO

만약 실행 가능한 여러 태스크의 우선순위가 같다면, 표준 스케줄링 정책은 선입선출(FIFO, First-In-First-Out) 방식을 따릅니다. 먼저 실행 가능 상태가 된 태스크가 먼저 실행되고, 해당 태스크가 블록 상태가 될 때까지 실행을 계속합니다. 일반적인 운영체제의 시분할(time-slicing) 방식과 달리, 동일 우선순위의 태스크끼리 CPU를 번갈아 사용하지 않는 것이 기본 정책입니다. 이는 비결정성을 줄이고 예측 가능성을 높입니다.

이러한 우선순위 기반 스케줄링 규칙은 시스템의 가장 중요한 작업이 항상 즉시 처리됨을 보장합니다. 하지만 이 모델은 공유 자원과 함께 사용될 때 우선순위 역전이라는 심각한 문제를 야기할 수 있으며, 이는 다음 절에서 다룰 주제입니다.

22.3.3 우선순위 역전(priority inversion)과 우선순위 상한 프로토콜(priority ceiling protocol)

우선순위 기반 스케줄링은 논리적으로 명확하지만, 여러 태스크가 공유 자원(예: 보호 객체)을 사용할 때 우선순위 역전(priority inversion)이라는 심각한 문제를 야기할 수 있습니다.

문제 정의: 우선순위 역전

우선순위 역전은 높은 우선순위의 태스크(H)가, 낮은 우선순위의 태스크(L)가 필요로 하는 자원을 기다리느라, 중간 우선순위의 태스크(M)에게 실행을 빼앗기는 현상입니다.

발생 시나리오:

  1. 낮은 우선순위 태스크 L이 실행되어 공유 자원(보호 객체) R에 대한 잠금(lock)을 획득합니다.
  2. 높은 우선순위 태스크 H가 실행 가능 상태가 되어, L을 선점하고 실행을 시작합니다.
  3. H가 자원 R에 접근하려고 시도하지만, L이 잠금을 보유하고 있으므로 H는 대기 상태에 들어갑니다.
  4. 스케줄러는 이제 L을 실행시켜 자원 잠금을 해제하도록 해야 합니다.
  5. 이때, H보다는 낮지만 L보다는 높은 우선순위를 가진 중간 우선순위 태스크 M이 실행 가능 상태가 됩니다.
  6. 역전 발생: 스케줄러는 우선순위 규칙에 따라 L 대신 M을 실행시킵니다. 결과적으로 M이 실행되는 동안 H는 무한정 대기할 수 있습니다. 가장 중요한 태스크 H가 자신과 무관한 M 때문에 실행되지 못하는, 스케줄링의 우선순위가 뒤집히는 현상이 발생합니다.

이 문제는 1997년 화성 탐사선 패스파인더(Mars Pathfinder) 미션에서 주기적인 시스템 리셋을 유발한 원인으로 잘 알려져 있습니다.

Ada의 해결책: 우선순위 상한 프로토콜

Ada는 실시간 부록을 통해 이 문제를 해결하기 위한 검증된 해법인 우선순위 상한 프로토콜(Priority Ceiling Protocol, PCP) 또는 실링 잠금(Ceiling Locking)을 언어 차원에서 제공합니다.

이 프로토콜은 pragma를 통해 간단히 활성화할 수 있습니다.

pragma Locking_Policy (Ceiling_Locking);

동작 원리:

  1. 실링 우선순위(Ceiling Priority) 할당: 시스템은 컴파일 시점에 각 보호 객체에 대해 실링 우선순위를 계산합니다. 이 값은 해당 보호 객체에 접근하는 모든 태스크들의 우선순위 중 가장 높은 우선순위로 설정됩니다.

  2. 우선순위 상속 (Priority Inheritance): 태스크가 보호 객체에 대한 잠금을 획득하는 순간, 해당 태스크의 실행 우선순위는 즉시 그 보호 객체의 실링 우선순위까지 일시적으로 상속받아 올라갑니다. 자원 잠금을 해제하면 원래의 기본 우선순위로 돌아옵니다.

시나리오 재현 (PCP 적용):

  1. L이 자원 R을 잠그는 순간, L의 실행 우선순위는 R의 실링 우선순위(H의 우선순위)까지 올라갑니다.
  2. H가 실행 가능 상태가 되어도, 현재 L의 실행 우선순위가 H와 같으므로 L을 선점할 수 없습니다.
  3. 이후 M이 실행 가능 상태가 되어도, M의 우선순위는 현재 L의 실행 우선순위보다 낮으므로 M은 L을 선점할 수 없습니다.
  4. 우선순위가 상속된 L은 방해받지 않고 빠르게 임계 구역(critical section)을 벗어나 자원 잠금을 해제합니다.
  5. L이 잠금을 해제하는 순간, L의 우선순위는 원래대로 돌아가고, 대기 중이던 H가 즉시 잠금을 획득하여 실행을 시작합니다.

이처럼 우선순위 상한 프로토콜은 낮은 우선순위의 태스크가 공유 자원을 사용하는 동안 잠시 높은 우선순위를 빌려와, 중간 우선순위 태스크의 간섭을 원천적으로 차단함으로써 우선순위 역전 문제를 해결합니다. Ada는 이 복잡한 프로토콜을 pragma 하나로 자동화하여, 개발자가 신뢰성 높은 경성 실시간 시스템을 안전하게 구축할 수 있도록 지원합니다.

22.4 [심화] Ravenscar 프로파일: 결정론적 동시성

지금까지 우리는 우선순위 기반 스케줄링과 우선순위 상한 프로토콜을 통해 실시간 시스템의 예측 가능성을 높이는 방법을 학습했습니다. 하지만 Ada의 완전한 동시성 모델은 여전히 동적 태스크 생성, 임의의 delay 문, select 문의 복잡한 형태 등, 시간적 동작을 정적으로 완벽하게 분석하기 어렵게 만드는 유연한 기능들을 포함하고 있습니다.

경성 실시간 시스템, 특히 항공전자공학의 DO-178C나 자동차의 ISO 26262와 같이 최고 수준의 안전 무결성이 요구되는 분야에서는, 프로그램의 모든 가능한 실행 경로와 최악 실행 시간(WCET)을 수학적으로 증명할 수 있어야 합니다. 이러한 정적 분석을 가능하게 하려면, 동시성 모델의 비결정적 요소를 제거한, 더 엄격하고 예측 가능한 규칙의 집합이 필요합니다.

이러한 요구에 부응하기 위해 정의된 것이 바로 Ravenscar 프로파일입니다. Ravenscar 프로파일은 Ada 동시성 기능의 표준화된 서브셋(subset)으로, 정적 분석이 가능하고 완전한 결정론적(deterministic) 동작을 보장하도록 설계되었습니다.

이 프로파일을 따름으로써, 개발자는 복잡한 동시성 상호작용이 예측 불가능한 시간 지연이나 교착 상태를 유발하지 않음을 높은 신뢰도로 보증할 수 있습니다. 본 절에서는 Ravenscar 프로파일이 왜 필요한지, 어떤 구체적인 제약 조건들을 포함하는지, 그리고 pragma Profile을 통해 이를 어떻게 코드에 적용하고 강제하는지를 상세히 학습할 것입니다.

22.4.1 Ravenscar 프로파일의 필요성

Ada의 완전한 동시성 모델은 태스크, 랑데부, 보호 객체 등 매우 풍부하고 강력한 기능들을 제공합니다. 이러한 유연성은 다양한 동시성 문제를 해결하는 데 효과적이지만, 동시에 시스템의 시간적 동작을 완벽하게 예측하는 것을 어렵게 만드는 비결정적(non-deterministic) 요소들을 포함하고 있습니다.

예를 들어, 다음과 같은 기능들은 프로그램의 실행 시간을 정적으로 분석하는 것을 매우 복잡하게 만듭니다.

  • 동적 태스크 생성: new 연산자를 사용하면 프로그램 실행 중에 태스크를 동적으로 생성할 수 있지만, 얼마나 많은 태스크가 생성될지, 그리고 언제 생성될지 미리 알 수 없습니다.
  • select 문의 복잡한 형태: 시간제한(delay)이나 조건부(else) 호출, terminate 대안 등은 실행 경로를 매우 복잡하게 만듭니다.
  • 상대적 delay: delay 1.0;과 같은 구문은 이전 작업의 실행 시간에 따라 실제 대기 시작 시점이 달라져 오차가 누적될 수 있습니다.

항공기 비행 제어 소프트웨어나 원자력 발전소 안전 시스템과 같은 최고 수준의 고신뢰성(high-integrity) 경성 실시간 시스템에서는, 이러한 비결정성이 허용되지 않습니다. 이러한 시스템들은 배포되기 전에 다음과 같은 사실이 수학적으로 증명되어야 합니다.

  1. 시간적 예측 가능성: 모든 태스크의 최악 실행 시간(WCET)을 계산하고, 모든 마감 시간(deadline)을 반드시 준수함을 보장해야 합니다.
  2. 동시성 안전성: 교착 상태(deadlock), 활성 잠금(livelock), 경쟁 상태(race condition)와 같은 동시성 관련 오류가 절대 발생하지 않음을 보장해야 합니다.

이러한 엄격한 증명을 가능하게 하려면, 분석을 방해하는 복잡하고 비결정적인 언어 기능들을 의도적으로 배제한, 더 단순하고 예측 가능한 동시성 모델이 필요합니다.

바로 이러한 필요성 때문에 Ravenscar 프로파일이 탄생했습니다. Ravenscar 프로파일은 Ada 동시성 기능의 표준화된 서브셋(subset)으로, 시간적 동작에 대한 완전한 정적 분석이 가능하도록 특별히 설계되었습니다. 이 프로파일을 따름으로써, 개발자는 복잡한 동시성 상호작용이 예측 불가능한 시간 지연이나 오류를 유발하지 않음을 높은 신뢰도로 보증할 수 있는 코드를 작성할 수 있습니다.

22.4.2 프로파일의 제약 조건

Ravenscar 프로파일은 정적 분석을 가능하게 하기 위해, Ada의 완전한 동시성 모델에 여러 가지 엄격한 제약 조건을 부과합니다. 이 제약 조건들은 시간적 동작을 예측하기 어렵게 만드는 비결정적 요소를 제거하는 데 초점을 맞춥니다. 모든 제약 사항은 Ada 2022 레퍼런스 매뉴얼 D.13.1절에 상세히 기술되어 있습니다.

주요 제약 조건은 다음과 같이 세 가지 범주로 나눌 수 있습니다.

1. 태스킹 모델의 단순화

  • 동적 태스크 금지: new를 이용한 태스크 동적 할당이나 abort 문을 사용한 태스크 강제 종료가 금지됩니다. 모든 태스크는 프로그램 시작 시 정적으로 생성되어야 합니다. 이는 시스템에 존재하는 태스크의 수와 종류를 컴파일 시점에 파악할 수 있게 합니다.
  • 태스크 계층 제한: 태스크는 라이브러리 수준에서만 선언될 수 있으며, 서브프로그램 내부에 중첩하여 선언할 수 없습니다.

2. 동기화 및 통신 모델의 제한

  • 랑데부(Rendezvous) 제한: 태스크 간의 복잡한 통신 메커니즘인 select 문의 대부분 형태와 일반 accept 문이 금지됩니다. 태스크 간의 상호작용은 오직 보호 객체를 통해서만 이루어져야 합니다.
  • 보호 객체(Protected Objects) 사용 규칙:
    • 보호 객체는 태스크를 포함할 수 없습니다.
    • 보호 객체의 엔트리(entry)는 최대 하나만 가질 수 있으며, 이 엔트리의 대기 큐(queue)에는 최대 하나의 태스크만 대기할 수 있습니다. 이는 복잡한 다중 대기 상황을 방지합니다.
    • 엔트리의 배리어(barrier)는 반드시 해당 보호 객체의 private 부분에 선언된 단순한 Boolean 변수여야 합니다.

3. 시간 관리 및 실행 제어의 결정론적 보장

  • delay 문 금지: 상대적인 시간을 지정하는 delay 문은 금지됩니다. 시간 지연은 오직 절대 시점을 목표로 하는 delay until 문만 허용됩니다. 이는 주기적인 작업의 시간 오차 누적을 방지합니다.
  • 동일 우선순위 태스크 제한: 동일한 우선순위를 가진 태스크를 두 개 이상 허용하지 않습니다. 이는 스케줄링의 비결정성을 제거합니다.

이러한 제약 조건들을 종합하면, Ravenscar 프로파일은 다음과 같은 단순하고 예측 가능한 동시성 모델을 형성합니다.

시스템은 고정된 수의 태스크로 구성되며, 이 태스크들은 오직 보호 객체(또는 중단 없는 서브프로그램)를 통해서만 상호작용하고, delay until을 통해 주기적으로 실행됩니다.

이 단순화된 모델은 정적 분석 도구가 시스템의 모든 가능한 상태와 시간적 동작을 분석하고, 교착 상태가 없으며 모든 마감 시간을 준수함을 수학적으로 증명할 수 있는 기반을 제공합니다.

22.4.3 pragma Profile (Ravenscar)의 사용

Ravenscar 프로파일의 제약 조건들을 프로그래머가 수동으로 일일이 기억하고 준수하는 것은 매우 어렵고 실수를 유발하기 쉽습니다. 이러한 부담을 덜고 프로파일 준수를 자동화하기 위해, Ada는 **pragma Profile**이라는 컴파일러 지시어를 제공합니다.

pragma Profile (Ravenscar)는 “이 코드와 이 코드가 의존하는 모든 코드는 Ravenscar 프로파일의 모든 제약 조건을 준수해야 한다”고 컴파일러에게 명시적으로 알리는 **구성 프라그마(configuration pragma)**입니다.

pragma Profile의 동작

이 프라그마가 적용되면, 컴파일러는 단순한 코드 번역기를 넘어, 프로파일 준수 검사관의 역할을 수행합니다. 컴파일 과정에서 코드의 모든 부분을 분석하여 22.4.2절에서 설명한 Ravenscar 제약 조건 중 하나라도 위반하는 코드가 발견되면, 컴파일러는 경고가 아닌 컴파일 오류를 발생시켜 빌드를 중단시킵니다.

이를 통해 Ravenscar 프로파일의 규칙이 개발 과정에서부터 강력하게 강제되므로, 프로그래머의 실수가 최종 실행 파일에 포함되는 것을 원천적으로 방지할 수 있습니다.

적용 방법

pragma Profile (Ravenscar)는 일반적으로 다음 두 가지 방법 중 하나로 적용합니다.

  1. GNAT 프로젝트 파일 (.gpr) 사용 (권장): 프로젝트 전체에 일관된 프로파일을 적용하는 가장 좋은 방법입니다.

    -- file: my_project.gpr
    project My_Project is
       -- ...
       pragma Profile (Ravenscar);
    end My_Project;
    
  2. 메인 유닛 최상단 선언: 프로젝트의 주 진입점이 되는 서브프로그램의 가장 첫 줄에 프라그마를 위치시킵니다. 이 프라그마는 해당 유닛과 그 유닛이 의존하는 모든 코드에 전파되어 영향을 미칩니다.

    -- file: main.adb
    pragma Profile (Ravenscar);
    with Ada.Text_IO;
    
    procedure Main is
       -- ...
    end Main;
    

컴파일 오류 예시

만약 Ravenscar 프로파일이 활성화된 환경에서 금지된 delay 문을 사용하면, 컴파일러는 다음과 같은 오류를 보고합니다.

procedure Test_Ravenscar is
   pragma Profile (Ravenscar);
begin
   delay 1.0; -- 🚨 컴파일 오류!
end Test_Ravenscar;

컴파일러 출력 (GNAT): error: "delay" statement not allowed by Ravenscar profile

이처럼 pragma Profile (Ravenscar)는 고신뢰성 실시간 시스템 개발에서 코드의 결정론적 특성을 보장하기 위한 자동화된 안전장치 역할을 합니다. 이는 인간의 실수를 최소화하고, 시스템이 표준 프로파일을 준수함을 컴파일 시점부터 보증하는 핵심적인 도구입니다.

22.4.4 적용 예시: Ravenscar 프로파일 준수하기

Ravenscar 프로파일의 제약 조건들은 이론적으로는 명확하지만, 실제 코드에 어떻게 적용되는지 구체적인 예시를 통해 확인하는 것이 중요합니다. 이번 절에서는 Ravenscar 프로파일을 위반하는 간단한 동시성 코드를 작성하고, 이를 프로파일을 **준수하도록 수정(refactor)**하는 과정을 단계별로 살펴보겠습니다.

시나리오: 비결정적(Non-deterministic) 주기적 작업

주기적으로 작업을 수행하고, 작업 횟수가 특정 값에 도달하면 동적으로 새로운 로거(Logger) 태스크를 생성하여 메시지를 남기는 간단한 프로그램을 가정해 보겠습니다.

1. Ravenscar 프로파일을 위반하는 초기 코드

-- non_ravenscar_example.adb
with Ada.Text_IO;
with Ada.Task_Identification;

procedure Non_Ravenscar_Example is
   task Logger; -- 동적으로 생성될 로거 (선언 방식부터 문제 소지)

   task body Logger is
   begin
      Ada.Text_IO.Put_Line ("LOGGER: Task " &
        Ada.Task_Identification.Image (Ada.Task_Identification.Current_Task) &
        " has finished its work.");
   end Logger;

   task Periodic_Worker;
   task body Periodic_Worker is
      Counter : Natural := 0;
   begin
      loop
         Counter := Counter + 1;
         Ada.Text_IO.Put_Line ("WORKER: Cycle " & Counter'Image);

         if Counter = 5 then
            -- 위반 1: 동적 태스크 생성 (new)
            declare
               Log_Task : access Logger := new Logger;
            begin
               null;
            end;
         end if;

         -- 위반 2: 상대적 delay 사용
         delay 1.0;

         exit when Counter > 10;
      end loop;
   end Periodic_Worker;
begin
   null;
end Non_Ravenscar_Example;

위 코드는 다음과 같은 Ravenscar 프로파일의 핵심 규칙들을 위반합니다.

  • 동적 태스크 생성: new Logger 구문은 힙(heap)에 태스크를 동적으로 할당합니다.
  • 상대적 delay: delay 1.0;은 시간 오차 누적을 유발할 수 있습니다.
  • 태스크 선언 위치: 태스크가 메인 프로시저 내부에 선언되었습니다.

2. Ravenscar 프로파일을 준수하도록 수정

이제 pragma Profile (Ravenscar)를 적용하고 컴파일러의 지침에 따라 코드를 수정합니다.

수정 방향:

  1. 모든 태스크와 보호 객체는 라이브러리 수준(패키지)으로 이동시킵니다.
  2. 동적 태스크 생성을 정적(static) 객체 선언으로 변경합니다.
  3. delaydelay until로 변경하여 결정론적 주기를 보장합니다.
  4. 태스크 간 통신은 랑데부가 아닌 보호 객체를 통해 수행합니다.

수정된 코드 (Ravenscar-compliant)

-- ravenscar_compliant_example.ads (패키지 명세)
package Ravenscar_Compliant_Example is
   pragma Profile (Ravenscar);

   -- 모든 동시성 객체는 라이브러리 수준에 선언
   protected Log_Server is
      entry Log (Message : in String);
   private
      Last_Message : String (1 .. 80);
      Got_Message  : Boolean := False;
   end Log_Server;

   task Periodic_Worker;
end Ravenscar_Compliant_Example;
-- ravenscar_compliant_example.adb (패키지 본체)
with Ada.Text_IO;
with Ada.Real_Time;

package body Ravenscar_Compliant_Example is

   protected body Log_Server is
      entry Log (Message : in String) when not Got_Message is
      begin
         Last_Message (1 .. Message'Length) := Message;
         Got_Message := True;
      end Log;
   end Log_Server;

   task body Periodic_Worker is
      use Ada.Real_Time;
      Counter             : Natural   := 0;
      Next_Execution_Time : Time      := Clock;
      Period              : constant Time_Span := Milliseconds (500);
   begin
      loop
         -- 수정 2: 'delay until' 사용
         delay until Next_Execution_Time;

         Counter := Counter + 1;
         Ada.Text_IO.Put_Line ("WORKER: Cycle " & Counter'Image);

         if Counter = 5 then
            -- 수정 1: 보호 객체를 통한 통신
            Log_Server.Log ("Worker reached cycle 5.");
         end if;

         exit when Counter > 10;
         Next_Execution_Time := Next_Execution_Time + Period;
      end loop;
   end Periodic_Worker;

end Ravenscar_Compliant_Example;

수정 결과 분석:

  • 결정론적 구조: 모든 태스크(Periodic_Worker)와 동기화 객체(Log_Server)가 정적으로 선언되어, 시스템의 전체 구조를 컴파일 시점에 파악할 수 있습니다.
  • 예측 가능한 시간 동작: Periodic_Workerdelay until을 사용하여 정확한 주기로 실행됨이 보장됩니다.
  • 안전한 통신: 태스크 간 통신은 Ravenscar 프로파일이 허용하는 유일한 방법인 보호 객체(Log_Server)를 통해 안전하게 이루어집니다.

이 “Before & After” 예시는 Ravenscar 프로파일이 어떻게 비결정적이고 잠재적으로 위험한 동시성 코드를, 시간적 동작을 정적으로 분석하고 보증할 수 있는 견고하고 예측 가능한 구조로 변환하는지를 명확하게 보여줍니다.

22.4.5 [참고] Jorvik 프로파일: 완화된 제약

Ravenscar 프로파일은 경성 실시간 시스템의 정적 분석 가능성을 보장하기 위해 매우 엄격한 제약 조건을 부과합니다. 하지만 모든 고신뢰성 시스템이 Ravenscar의 극단적인 수준의 단순성을 요구하는 것은 아닙니다. 때로는 더 유연하고 표현력 있는 동시성 모델이 필요할 수 있습니다.

이러한 요구를 충족시키기 위해 Ada 2022 표준은 Jorvik 프로파일이라는 또 다른 표준 프로파일을 Ravenscar와 함께 정의하고 있습니다. Jorvik은 Ravenscar의 제약을 일부 완화하여, 예측 가능성을 유지하면서도 더 넓은 범위의 동시성 패턴을 허용하도록 설계되었습니다.

Ravenscar와의 관계

두 프로파일의 가장 중요한 관계는 Ravenscar가 Jorvik의 더 엄격한 부분집합(subset)이라는 것입니다. 즉, Ravenscar 프로파일을 준수하는 모든 코드는 자동으로 Jorvik 프로파일도 준수합니다.

Jorvik이 완화하는 주요 제약 조건

Jorvik은 Ravenscar에 비해 다음과 같은 유연성을 허용합니다.

  • 다중 엔트리: 보호 객체는 하나 이상의 엔트리(entry)를 가질 수 있습니다.
  • 다중 대기자: 하나의 엔트리 큐에 여러 태스크가 대기하는 것이 허용됩니다.
  • 더 유연한 배리어: 엔트리의 배리어(when 조건)가 private 부분에 없는 변수도 참조할 수 있습니다.

사용 시점

Jorvik 프로파일은 Ravenscar의 제약이 과도하여 필요한 동시성 패턴(예: 다중 서비스 창구를 가진 보호 객체)을 구현하기 어려울 때 고려할 수 있는 유용한 대안입니다. 이는 완전한 정적 분석 가능성보다는 높은 수준의 신뢰성과 더 풍부한 표현력 사이의 균형점을 제공합니다.

pragma Profile (Jorvik);을 사용하여 컴파일러가 이 프로파일의 규칙을 강제하도록 할 수 있습니다.

23. 계약 기반 설계 (Design by Contract)

소프트웨어 컴포넌트 간의 상호작용이 복잡해질수록, 각 컴포넌트의 책임과 역할을 명확히 정의하는 것이 중요해집니다. 계약 기반 설계(Design by Contract, DbC)는 소프트웨어 시스템의 구성 요소들을 상호 간에 계약(contract)을 맺는 주체로 간주하는 설계 방법론입니다. 이 계약은 각 서브프로그램(공급자, supplier)과 그 호출자(클라이언트, client)가 서로에게 기대하는 권리와 이행해야 할 의무를 명시적으로 규정합니다.

Ada는 Ada 2012 표준부터 이러한 계약 기반 설계를 언어 차원에서 직접 지원합니다. 전제조건(Precondition), 후제조건(Postcondition), 타입 불변식(Type Invariant) 과 같은 기능을 통해, 설계 명세를 단순한 주석이 아닌 검증 가능한 코드로 전환할 수 있습니다. 이는 소프트웨어의 신뢰성을 획기적으로 향상시키는 현대적인 개발 패러다임입니다.

23.1 서브프로그램 전제조건 (Pre), 후제조건 (Post)

서브프로그램의 계약은 주로 전제조건과 후제조건을 통해 정의됩니다. 이들은 서브프로그램의 명세(specification)에 with 키워드와 함께 기술되어, 인터페이스의 일부가 됩니다.

23.1.1 전제조건 (Precondition): 클라이언트의 의무

전제조건은 서브프로그램이 올바르게 동작하기 위해, 호출되기 전에 반드시 만족되어야 하는 조건입니다. 이 조건을 만족시킬 책임은 전적으로 서브프로그램을 호출하는 클라이언트에게 있습니다.

  • 구문: with pre => <부울_표현식>;
  • 의미: 만약 전제조건이 만족되지 않은 상태에서 서브프로그램이 호출된다면, 이는 클라이언트 측의 버그를 의미합니다.
  • 이점: 서브프로그램의 구현부(body)는 전제조건이 이미 충족되었다고 가정할 수 있으므로, 해당 조건을 검사하는 방어적인 코드를 중복해서 작성할 필요가 없습니다.

예제: 실수의 제곱근 계산

function Square_Root (X : Float) return Float with
  pre => X >= 0.0; -- 전제조건: X는 음수가 아니어야 한다.

Square_Root 함수를 호출하는 코드는 X가 0 이상임을 보장해야 합니다. 만약 음수 값을 전달하면, 런타임에 Assertion_Error 예외가 발생하여 호출부의 오류를 명확히 알려줍니다.

23.1.2 후제조건 (Postcondition): 공급자의 보증

후제조건은 전제조건이 만족되었다는 가정 하에, 서브프로그램이 실행을 마친 후에 반드시 만족시켜야 하는 조건입니다. 이 조건을 만족시킬 책임은 서브프로그램의 구현(공급자)에 있습니다.

  • 구문: with post => <부울_표현식>;
  • 의미: 만약 서브프로그램이 후제조건을 만족시키지 못하고 종료된다면, 이는 공급자, 즉 서브프로그램 구현 자체의 버그를 의미합니다.
  • 'old 속성: 후제조건 내에서는 'old 속성을 사용하여 서브프로그램 호출 전의 파라미터 값을 참조할 수 있습니다. 이는 in out 이나 out 모드의 파라미터가 어떻게 변경되었는지를 명시하는 데 필수적입니다.

예제: 카운터 값 증가

procedure increment (Counter : in out Natural) with
  post => Counter = Counter'old + 1; -- 후제조건: Counter의 값은 호출 전보다 1 증가해야 한다.

increment 프로시저는 어떤 방식으로 구현되든, 종료 시점에는 Counter의 값이 호출 전(Counter'old)보다 정확히 1만큼 커져 있음을 보증해야 합니다.

23.1.3 전통적 방식과 계약 기반 설계의 비교

서브프로그램이 올바르게 동작하도록 보장하려는 노력은 이전부터 존재해 왔습니다. 계약 기반 설계(Design by Contract, DbC)가 등장하기 전, 프로그래머들은 주로 코드 내 수동 검사주석을 통한 문서화라는 두 가지 전통적인 방법에 의존했습니다.

이 두 접근 방식을 비교해 보면, 계약 기반 설계가 왜 더 안전하고 체계적인지를 명확히 알 수 있습니다. 비교를 위해, 음수가 아닌 실수의 제곱근을 구하는 간단한 Square_Root 함수를 예로 들어보겠습니다.

이 함수의 암묵적인 계약은 다음과 같습니다.

  • 전제조건: 입력값 X는 0 이상이어야 한다.
  • 후제조건: 반환된 결과 ResultResult**2이 원래 X와 거의 같아야 한다.

1. 전통적 방식: 주석과 수동 검사

전통적인 방식에서는 이러한 계약을 주석으로 설명하고, 함수의 구현부 시작 지점에서 if 문을 사용하여 전제조건을 직접 검사합니다.

-- 전통적인 방식의 함수
function Square_Root_Traditional (X : Float) return Float is
   -- 주석으로 전제조건을 설명
   -- X는 반드시 0.0 이상이어야 합니다.
   Result : Float;
begin
   -- 1. 구현부 내에서 전제조건을 수동으로 검사
   if X < 0.0 then
      raise Constraint_Error with "음수 값에 대한 제곱근은 허용되지 않습니다.";
   end if;

   -- 2. 핵심 로직 수행
   Result := -- ... 제곱근 계산 알고리즘 ...

   -- 3. 후제조건은 보통 주석으로만 남기거나, 디버그용 단정문으로 검사
   -- pragma Assert (abs(Result**2 - X) < 1.0E-6);

   return Result;
end Square_Root_Traditional;

단점:

  • 계약과 코드의 분리: 계약의 일부는 주석(문서)에, 다른 일부는 구현부 코드에 흩어져 있습니다. 주석은 코드가 변경될 때 갱신된다는 보장이 없어 오래된 정보가 될 수 있습니다.
  • 로직의 혼재: 함수의 핵심 목적인 ‘제곱근 계산’ 로직과, 부가적인 ‘입력값 검증’ 로직이 구현부 안에 섞여 있어 코드가 복잡해지고 가독성이 떨어집니다.
  • 비공식성: 주석은 컴파일러나 다른 정적 분석 도구가 이해할 수 있는 공식적인 정보가 아닙니다.
  • 일관성 부족: 오류 처리 방식(예외 발생, 에러 코드 반환 등)이 프로그래머마다 달라질 수 있어 API의 일관성을 해칠 수 있습니다.

2. 계약 기반 설계 방식: 명세의 일부인 계약

계약 기반 설계에서는 이러한 계약을 서브프로그램의 명세(specification) 부분으로 옮겨, 공식적인 인터페이스의 일부로 만듭니다.

-- 계약 기반 설계 방식의 함수 명세
function Square_Root_Contract (X : Float) return Float
  with
    Pre  => X >= 0.0,
    Post => abs(Square_Root_Contract'Result**2 - X) < 1.0E-6;

-- 구현부는 오직 핵심 로직에만 집중
function Square_Root_Contract (X : Float) return Float is
begin
   -- 전제조건과 후제조건 검사 코드가 전혀 필요 없음
   -- ... 순수한 제곱근 계산 알고리즘 ...
   return ...;
end Square_Root_Contract;

장점:

  • 계약의 중앙화: 계약(Pre, Post)이 서브프로그램의 명세부에 명시되므로, 이 서브프로그램을 사용하는 사람은 누구나 구현부를 보지 않고도 사용법과 보증사항을 명확히 알 수 있습니다. 실행 가능한 문서가 되는 것입니다.
  • 관심사의 분리: “무엇을 하는가(What)”를 정의하는 계약은 명세부에, “어떻게 하는가(How)”를 정의하는 핵심 로직은 구현부에 위치하여 역할이 명확히 분리됩니다.
  • 공식성과 도구 지원: 계약은 언어의 일부이므로 컴파일러가 이해하고, 정적 분석 도구가 활용할 수 있으며, pragma Assertion_Policy를 통해 검사 여부를 일괄적으로 제어할 수 있습니다.
  • 표준화된 동작: 전제조건 위반은 항상 함수 실행 전에 Assertion_Error를, 후제조건 위반은 항상 함수 실행 후에 Assertion_Error를 발생시키는 등 동작이 표준화되어 예측 가능합니다.
기준 전통적 방식 계약 기반 설계 (DbC)
계약 위치 주석, 구현부 내 코드 명세부 (Specification)
검사 주체 프로그래머가 작성한 if 컴파일러 및 런타임 시스템
가독성 핵심 로직과 검사 로직이 혼재 로직과 계약이 명확히 분리
도구 지원 제한적 정적 분석, 테스트 도구와 연동 용이
제어 가능성 수동 제어 pragma Assertion_Policy로 일괄 제어

이처럼 계약 기반 설계는 서브프로그램의 인터페이스를 비공식적인 약속에서 공식적이고 강제적인 계약으로 격상시켜, 소프트웨어의 신뢰성과 유지보수성을 한 차원 높은 수준으로 끌어올립니다.

23.1.4 런타임 검사와 정적 분석

PrePost 조건은 단순한 주석이 아니라, 컴파일러 옵션(-gnata)을 통해 활성화할 수 있는 실행 가능한 단정(assertion)입니다. 개발 및 테스트 중에는 이 검사를 활성화하여 계약 위반을 즉시 발견하고, 성능이 중요한 최종 배포 버전에서는 비활성화할 수 있습니다.

또한, 계약은 비활성화 상태에서도 그 자체로 매우 가치 있는 문서가 되며, SPARK(16장)와 같은 정적 분석 도구는 이 계약을 이용해 프로그램의 정확성을 컴파일 시점에 수학적으로 증명하기도 합니다.


전제조건과 후제조건은 서브프로그램과 그 사용자의 책임을 명확히 분리하고, 이를 코드 수준에서 강제하는 강력한 도구입니다. 이는 모호함을 줄이고, 통합 과정에서 발생하는 오류를 조기에 발견하며, 소프트웨어의 신뢰성과 유지보수성을 크게 향상시킵니다. 계약 기반 설계는 고신뢰성이 요구되는 현대 소프트웨어 공학에서 Ada가 제공하는 핵심적인 가치 중 하나입니다.

23.1.5 [심화] 계약을 이용한 버퍼 오버플로우 방지

계약 기반 설계는 버퍼 오버플로우와 같은 고전적인 메모리 관련 오류를 예방하는 강력한 도구입니다. 전제조건(Pre)을 사용하여, 서브프로그램이 호출되기 전에 모든 인자가 안전한 상태임을 강제할 수 있습니다.

다음은 배열의 특정 범위에 값을 채우는 프로시저입니다. Pre 조건은 전달된 범위(first에서 last까지)가 대상 배열(buffer)의 실제 인덱스 범위 내에 포함되는지를 검사하여, 잠재적인 Index_Error나 메모리 오염을 원천적으로 차단합니다.

코드 예시 19-1: 버퍼 접근을 위한 안전 계약

procedure fill_buffer
  (buffer : in out Data_Array;
   first  : Index_Type;
   last   : Index_Type;
   value  : Data_Item)
with
  Pre => first >= buffer'first and
         last <= buffer'last and
         (if first <= last then first in buffer'range and last in buffer'range);

이 계약을 통해 fill_buffer 프로시저의 구현부는 firstlast 인덱스가 항상 유효하다고 확신할 수 있으며, 호출자는 자신의 책임(안전한 인덱스 전달)을 명확히 인지하게 됩니다. 만약 런타임 검사가 활성화된 상태에서 이 계약이 위반되면 Assertion_Error가 발생하여 즉시 오류를 알려줍니다.

23.2 타입 불변식 (Type_Invariant)

전제조건과 후제조건이 서브프로그램의 동작을 규정하는 계약이라면, 타입 불변식(Type Invariant)은 특정 타입의 객체가 항상 유지해야 하는 내부적인 일관성(internal consistency) 또는 상태의 유효성을 규정하는 계약입니다.

불변식이란, 해당 타입의 객체가 ‘안정된’ 상태에 있을 때, 즉 공개된 서브프로그램의 실행 중인 순간을 제외한 모든 시점에서, 항상 참으로 유지되어야 하는 속성입니다. 이는 객체가 생성된 후부터 소멸될 때까지 결코 ‘깨지거나’ ‘유효하지 않은’ 상태에 놓이지 않음을 보장하는 강력한 안전장치입니다.

23.2.1 타입 불변식의 정의와 규칙

타입 불변식은 패키지 명세의 private 부분에서 private 타입 선언에 with 키워드를 사용하여 정의됩니다.

  • 구문: with type_invariant => <해당_타입에_대한_부울_표현식>;
  • 적용 대상: private 타입 또는 limited private 타입에만 적용할 수 있습니다.
  • 검사 시점: 타입 불변식은 프로그래머가 직접 호출하는 것이 아니라, 런타임 시스템에 의해 특정 시점에 자동으로 검사됩니다.
    • 객체 생성 직후 (기본값 또는 new 연산자로 초기화된 후)
    • 해당 타입을 파라미터로 갖는 모든 공개(public) 서브프로그램의 실행이 끝나는 시점

중요한 점은, 공개 서브프로그램의 실행 도중에는 일시적으로 불변식이 깨질 수 있다는 것입니다. 예를 들어, 한 필드를 변경하고 그에 맞춰 다른 필드를 조정하는 중간 과정에서는 객체가 잠시 불일치 상태일 수 있습니다. 하지만 서브프로그램이 호출자에게 제어권을 반환하기 직전에는 반드시 불변식이 다시 만족되어야 합니다.

23.2.2 예제: 은행 계좌 (A Bank Account)

은행 계좌 객체는 “잔액(Balance)이 음수가 될 수 없다”는 핵심적인 규칙을 가집니다. 이 규칙을 타입 불변식으로 표현할 수 있습니다.

패키지 명세

package Bank_Accounts is
   type Account is tagged private;

   -- 공개 인터페이스
   procedure deposit (this : in out Account; amount : Positive);
   procedure withdraw (this : in out Account; amount : Positive);
   function get_balance (this : Account) return Integer;

private
   type Account is tagged record
      balance : Integer := 0;
   end record;

   -- 타입 불변식: Account 객체의 잔액은 항상 0 이상이어야 한다.
   with type_invariant => Account.balance >= 0;

end Bank_Accounts;

패키지 본체 및 동작 설명

package body Bank_Accounts is
   procedure deposit (this : in out Account; amount : Positive) is
   begin
      this.balance := this.balance + amount;
   end deposit; -- 이 지점에서 불변식(this.balance >= 0)이 자동으로 검사됨 (항상 참)

   procedure withdraw (this : in out Account; amount : Positive) is
   begin
      if this.balance >= amount then
         this.balance := this.balance - amount;
      else
         raise Insufficient_Funds; -- 예외 처리
      end if;
   end withdraw; -- 이 지점에서 불변식(this.balance >= 0)이 자동으로 검사됨

   -- ... get_balance 구현 ...
end Bank_Accounts;

withdraw 프로시저가 호출될 때, 만약 amountbalance보다 커서 잔액이 음수가 될 가능성이 있는 로직이 실행된다면, 프로시저가 종료되는 시점에 Type_Invariant 검사에 실패하여 Assertion_Error 예외가 발생합니다. 이는 withdraw 프로시저의 구현에 버그가 있음을 명확히 알려주며, Account 객체가 유효하지 않은 상태(음수 잔액)에 빠지는 것을 원천적으로 방지합니다.

23.2.3 전제/후제조건과의 관계

타입 불변식은 전제조건 및 후제조건과 긴밀하게 연관되어 함께 동작합니다.

  • 타입 불변식은 해당 타입의 모든 공개 서브프로그램에 대한 암묵적인 전제조건이자 후제조건으로 생각할 수 있습니다.
  • 즉, 모든 공개 연산은 “객체가 유효한 상태에서 시작해서(전제조건), 유효한 상태로 끝나야 한다(후제조건)”는 책임을 가집니다.
  • 타입 불변식을 사용하면, 모든 서브프로그램의 PrePostObject.balance >= 0과 같은 동일한 유효성 검사를 반복해서 명시할 필요가 없어지므로 계약을 더 간결하고 명확하게 만들 수 있습니다.

타입 불변식은 데이터 추상화의 무결성을 보장하는 강력한 선언적 도구입니다. 객체가 가져야 할 ‘항상 유효한 상태’에 대한 규칙을 타입 정의에 단 한 번 명시함으로써, 개발자는 방어적인 유효성 검사 코드를 반복적으로 작성하는 부담을 덜고, 컴파일러가 객체의 일관성을 자동으로 검증하도록 위임할 수 있습니다. 이는 계약 기반 설계의 핵심 요소로서, 견고하고 신뢰성 높은 객체 지향 시스템을 구축하는 데 크게 기여합니다.

23.3 계약과 타입 확장 (상속)

지금까지 우리는 개별 서브프로그램과 타입에 대한 계약을 정의하는 방법을 배웠습니다. 이제 두 가지 강력한 패러다임, 즉 13장에서 배운 객체 지향 프로그래밍(OOP)계약 기반 설계(DbC)가 만나는 지점으로 나아갑니다.

여기서 핵심적인 질문은 다음과 같습니다.

“자식 타입이 부모 타입의 연산을 재정의(override)할 때, 부모 타입에 명시된 계약은 어떻게 되는가?”

만약 계약에 대한 규칙 없이 자식 타입이 마음대로 연산의 동작을 바꿀 수 있다면, 다형성은 예측 불가능해지고 깨지기 쉬워집니다. Shape'Class 타입의 변수에 Circle 객체를 넣었을 때, 이 Circle 객체가 Shape인 것처럼 행동할 것이라는 최소한의 신뢰가 무너지기 때문입니다.

이러한 신뢰를 보장하기 위해, 계약은 상속 계층에도 일관된 규칙에 따라 적용됩니다. 이 규칙의 근간에는 리스코프 치환 원칙(Liskov Substitution Principle, LSP)이라는 중요한 객체 지향 설계 원리가 자리 잡고 있습니다. 이 원칙을 간단히 계약의 언어로 표현하면 다음과 같습니다.

자식 타입의 객체는 부모 타입의 객체 대신 사용될 수 있어야 하며, 이 때 부모의 계약을 위반해서는 안 된다.

즉, 자식 타입은 부모보다 더 적게 요구하고(전제조건 완화), 더 많이 보장해야(후제조건 강화) 합니다. Ada는 이러한 원칙을 언어 차원에서 지원하기 위해 Pre'Class, Post'Class, Invariant'Class 와 같은 특별한 클래스-와이드 계약을 제공합니다.

이번 절에서는 상속 계층에서 계약이 어떻게 동작하는지, 그리고 Ada의 클래스-와이드 계약을 통해 어떻게 다형적인 코드를 더욱 안전하고 신뢰성 있게 만들 수 있는지를 자세히 살펴봅니다.

23.3.1 리스코프 치환 원칙과 계약의 역할

객체 지향 프로그래밍에서 상속은 “Is-A” 관계를 나타냅니다. “원은 도형이다(A Circle Is-A Shape)” 와 같이, 자식 타입의 객체는 부모 타입의 한 종류로 간주될 수 있습니다. 그렇다면 자식 타입의 객체는 언제나 부모 타입의 객체가 사용되는 곳에 안전하게 대체될 수 있어야 합니다.

이러한 개념을 형식화한 것이 바로 리스코프 치환 원칙(Liskov Substitution Principle, LSP)입니다. 이 원칙을 간단히 풀어쓰면 다음과 같습니다.

자식 타입(예: Circle)의 객체는, 부모 타입(예: Shape)의 객체 대신 사용되더라도 프로그램의 정확성이나 동작 방식에 문제를 일으키지 않아야 한다.

예를 들어, Shape'Class 타입의 객체들을 다루는 어떤 프로시저가 있다면, 그곳에 Circle 객체를 전달하든 Rectangle 객체를 전달하든 프로시저는 여전히 일관되고 예측 가능하게 동작해야 합니다. Circle 객체가 Shape의 기본적인 규칙을 어겨서는 안 됩니다.

계약의 역할: LSP를 강제하는 메커니즘

그렇다면 “프로그램의 정확성”이나 “기본적인 규칙”과 같은 추상적인 개념을 코드로 어떻게 보장할 수 있을까요? 바로 이 지점에서 계약 기반 설계(Design by Contract)가 핵심적인 역할을 수행합니다.

서브프로그램의 계약(Pre, Post)과 타입의 불변식(Invariant)은 리스코프 치환 원칙을 코드 수준에서 강제하는 구체적인 메커니즘입니다. 부모 타입에 정의된 계약은, 그 자식 타입들이 반드시 지켜야 할 최소한의 행동 규범을 설정합니다.

LSP를 만족시키기 위해, 자식 타입이 부모의 연산을 재정의(override)할 때 계약은 반드시 다음 규칙을 따라야 합니다.

  1. 전제조건(Precondition)은 더 관대해져야 한다: 자식은 부모보다 더 까다로운 요구를 해서는 안 됩니다. 부모가 “0 이상의 모든 수”를 처리할 수 있었다면, 자식이 갑자기 “10 이상의 짝수만 받겠다”고 요구할 수 없습니다. 이는 부모 타입에 맞춰 코드를 작성한 클라이언트를 망가뜨리는 행위입니다.
    • 규칙: 자식의 전제조건은 부모의 전제조건보다 약하거나 같아야 한다 (Weaker or Equal Preconditions).
  2. 후제조건(Postcondition)은 더 강력해져야 한다: 자식은 부모가 보장했던 것보다 더 적게 보장해서는 안 됩니다. 부모가 “결과는 양수”라고 보장했다면, 자식이 음수를 반환할 수 없습니다. 오히려 “결과는 100 이상의 양수”와 같이 더 강력한 조건을 보장하는 것은 허용됩니다.
    • 규칙: 자식의 후제조건은 부모의 후제조건보다 강하거나 같아야 한다 (Stronger or Equal Postconditions).
  3. 불변식(Invariant)은 반드시 유지되어야 한다: 부모 타입의 불변식은 자식 타입에서도 여전히 유효해야 합니다.

이 규칙들은 상속 계층 전체의 일관성과 예측 가능성을 보장합니다. 이어지는 절에서는 Ada가 Pre'Class, Post'Class 등의 구문을 통해 이러한 계약 규칙을 어떻게 지원하는지 구체적으로 살펴볼 것입니다.

23.3.2 전제조건의 약화 (Pre'Class)

리스코프 치환 원칙에 따르면, 자식 타입의 연산은 부모 타입의 연산보다 더 까다로운 요구사항을 가져서는 안 됩니다. 즉, 자식의 전제조건(precondition)은 부모의 전제조건보다 약하거나(weaker) 같아야 합니다.

예를 들어, 부모 클래스의 Process 메소드가 “0 이상의 모든 정수”를 처리할 수 있었다면, 이를 상속받은 자식 클래스가 갑자기 “100 이상의 짝수만 받겠다”고 전제조건을 강화(strengthen)하는 것은 허용되지 않습니다. 왜냐하면 이는 Parent'Class 타입의 변수를 통해 Child 객체를 다형적으로 사용하는 클라이언트 코드의 기대를 저버리는 행위이기 때문입니다.

Ada는 이러한 규칙을 지원하기 위해 클래스-와이드 전제조건(class-wide precondition)인 Pre'Class를 제공합니다.

Pre vs. Pre'Class

PrePre'Class는 상속 계층에서 서로 다른 역할을 수행합니다.

  • Pre (타입-특정 전제조건, Type-Specific Precondition):

    • 해당 서브프로그램이 정적으로(statically) 특정 타입(Shape, Circle 등)의 객체와 함께 호출될 때만 적용되는 전제조건입니다.
    • 자식 타입에서 재정의될 때, 부모의 Pre 조건과는 독립적으로 새로운 조건을 설정할 수 있습니다. (하지만 이는 LSP 위반의 소지가 있어 주의해야 합니다.)
  • Pre'Class (클래스-와이드 전제조건, Class-Wide Precondition):

    • 서브프로그램이 클래스-와이드 타입(Shape'Class 등)의 객체와 함께 다형적으로(polymorphically) 호출될 때 적용되는 전제조건입니다.
    • 상속 규칙: 자식 타입에서 재정의되는 연산의 Pre'Class는 반드시 부모의 Pre'Class 조건을 논리적으로 포함해야 합니다 (Child_Pre'ClassParent_Pre'Class보다 약하거나 같아야 함). 즉, (Parent_Pre'Class) or else (Some_Other_Condition) 와 같은 형태여야 합니다.

활용 예제: 문서 처리기

다양한 종류의 문서를 처리하는 Document 타입을 가정해 봅시다. 기본 Document는 내용이 비어있지 않기만 하면 처리할 수 있습니다. 하지만 특별한 자식 타입인 Encrypted_Document는 암호가 해제된 상태여야만 처리할 수 있습니다.

package Documents is
   type Document is tagged private;
   procedure Process (Doc : in Document) with
     Pre'Class => not Is_Empty (Doc); -- 클래스-와이드 전제조건

   type Encrypted_Document is new Document with private;
   overriding
   procedure Process (Doc : in Encrypted_Document) with
     Pre'Class => (if Doc in Document'Class then not Is_Empty (Doc)) -- 부모의 계약
                  and then Is_Decrypted (Doc); -- 🚨 잘못된 설계: 전제조건 강화!
private
   -- ... private 정의들 ...
end Documents;

위 설계는 LSP를 위반합니다. Encrypted_Document는 부모보다 더 강력한 전제조건(Is_Decrypted)을 요구하고 있습니다. Document'Class 타입으로 모든 문서를 다루는 클라이언트는 Encrypted_Document가 처리될 수 없는 상황을 예상하지 못할 것입니다.

올바른 설계 (전제조건 약화): 올바른 설계는 자식이 부모의 조건을 포함하며, 자신만의 처리 가능 조건을 추가하는 것입니다. 예를 들어, Signed_Document는 서명이 없어도 처리할 수 있지만(부모 조건 만족), 서명이 있다면 반드시 유효해야 한다는 식으로 조건을 확장(약화)할 수 있습니다.

package Correct_Documents is
   type Document is tagged private;
   procedure Save (Doc : in Document) with
     Pre'Class => Is_Valid (Doc);

   type Signed_Document is new Document with private;
   overriding
   procedure Save (Doc : in Signed_Document) with
     -- 부모의 전제조건(Is_Valid)을 만족하거나, 또는 서명된 상태이면 됨
     -- 이는 부모보다 더 많은 경우를 허용하므로 전제조건이 약화된 것임.
     Pre'Class => (if Doc in Document'Class then Is_Valid (Doc)) or else Is_Signed (Doc);
private
   -- ... private 정의들 ...
end Correct_Documents;

Pre'Class를 사용하면, 다형적인 호출 상황에서도 각 객체가 자신의 타입에 맞는 전제조건을 검사받게 됩니다. 컴파일러는 자식 타입의 Pre'Class가 부모의 Pre'Class를 약화시키는 규칙을 따르는지 정적으로 검사하여, 리스코프 치환 원칙을 위반하는 코드를 사전에 방지하도록 돕습니다. 이는 상속 계층 전체의 논리적 일관성과 신뢰성을 보장하는 강력한 도구입니다.

23.3.3 후제조건의 강화 (Post'Class)

리스코프 치환 원칙(LSP)의 또 다른 중요한 측면은 보증(guarantee)에 관한 것입니다. 자식 타입의 객체는 부모 타입의 객체를 대체할 때, 부모가 약속했던 것보다 더 적게 보장해서는 안 됩니다. 오히려 자식은 부모의 보증을 모두 만족시키면서, 거기에 더해 자신만의 더 강력한 보증을 추가할 수 있습니다.

이를 계약의 언어로 표현하면, 자식 타입이 재정의한 연산의 후제조건(postcondition)은 부모의 후제조건보다 강하거나(stronger) 같아야 합니다.

예를 들어, 부모 클래스의 Save 메소드가 “데이터를 파일에 저장한다”고 보증했다면, 이를 재정의한 자식 클래스가 “데이터를 파일에 저장하고 로그를 남긴다”고 더 많은 것을 보증하는 것은 괜찮습니다. 하지만 단순히 “데이터를 메모리에만 임시 저장한다”고 더 약하게 보증하는 것은 허용되지 않습니다.

Ada는 이러한 규칙을 클래스-와이드 후제조건(class-wide postcondition)인 Post'Class를 통해 지원하고 강제합니다.

Post vs. Post'Class

PrePre'Class의 관계와 마찬가지로, PostPost'Class는 서로 다른 범위의 계약을 정의합니다.

  • Post (타입-특정 후제조건, Type-Specific Postcondition):

    • 정적인(statically) 호출에만 적용됩니다.
    • 자식 타입에서 재정의될 때, 부모의 Post 조건은 무시되고 완전히 새로운 조건으로 대체됩니다. (LSP를 위반할 위험이 있음)
  • Post'Class (클래스-와이드 후제조건, Class-Wide Postcondition):

    • 다형적인(polymorphically) 호출에 적용되는 계약입니다.
    • 상속 규칙: 자식 타입에서 재정의되는 연산의 유효 후제조건은 부모의 Post'Class 조건과 자식의 Post'Class 조건이 논리적으로 AND 연산된 것입니다. 즉, 자식은 부모의 보증과 자신의 보증을 모두 만족시켜야 합니다.

활용 예제: 문서 저장 기능 확장

모든 문서를 저장하는 Save 프로시저를 가진 Document 타입을 가정해 봅시다. 기본 Document는 저장 후 ‘저장됨’ 상태가 됨을 보증합니다. 자식 타입인 Signed_Document는 여기에 더해 ‘서명됨’ 상태가 됨을 추가로 보증해야 합니다.

package Documents is
   type Document is tagged private;
   procedure Save (Doc : in out Document) with
     Post'Class => Is_Saved (Doc); -- 부모의 보증: "저장됨" 상태여야 한다.

   type Signed_Document is new Document with private;
   overriding
   procedure Save (Doc : in out Signed_Document) with
     -- 자식의 추가 보증: "서명됨" 상태여야 한다.
     Post'Class => Is_Signed (Doc);

private
   -- ... private 정의들 ...
end Documents;

Signed_Document 타입의 Save 프로시저가 다형적으로 호출될 때, 이 프로시저가 종료된 후 검사되는 전체 유효 후제조건은 다음과 같습니다.

Is_Saved (Doc) and then Is_Signed (Doc)

이처럼 Post'Class는 상속 계층을 따라 내려가면서 계약이 누적되고 강화되도록 보장합니다.

함수에서의 'Result 활용: 함수의 반환 값에 대한 후제조건을 정의할 때는 'Result 속성을 사용합니다.

-- 부모 타입: 양수를 반환한다고 보증
function Parent_Func (...) return Integer with
  Post'Class => Parent_Func'Result > 0;

-- 자식 타입: 양수이면서 동시에 짝수임을 추가로 보증
overriding
function Child_Func (...) return Integer with
  Post'Class => Child_Func'Result mod 2 = 0;

Child_Func의 유효 후제조건은 Child_Func'Result > 0 and then Child_Func'Result mod 2 = 0 이 됩니다.

Post'Class는 상속을 사용하더라도 시스템의 신뢰성이 약화되지 않고 오히려 강화되도록 만드는 핵심적인 장치입니다. 클라이언트 코드는 부모 타입의 계약만 보고도, 어떤 자식 타입의 객체가 오더라도 최소한 그 계약은 지켜질 것이라고 확신할 수 있습니다. 이는 예측 가능하고 안정적인 객체 지향 시스템을 구축하는 데 필수적입니다.

23.3.4 클래스-와이드 불변식 (Invariant'Class)

타입 불변식(Type_Invariant)은 특정 타입의 객체가 항상 만족해야 하는 ‘건강 상태’ 또는 ‘일관성 규칙’을 정의합니다. 상속 계층에서는 이 불변식 또한 리스코프 치환 원칙(LSP)을 따라야 합니다. 즉, 자식 타입의 객체는 자식으로서의 규칙뿐만 아니라, 부모로서의 규칙도 반드시 지켜야 합니다.

예를 들어, 모든 BankAccount의 불변식이 “잔고는 0 이상이어야 한다” 라면, 이를 상속받는 CreditAccount(신용 계좌) 역시 잔고가 0 이상이어야 한다는 기본 규칙을 따라야 하며, 여기에 더해 “신용 한도를 초과할 수 없다”는 자신만의 규칙을 추가할 수 있습니다.

Ada는 이러한 계층적 불변식을 지원하기 위해 클래스-와이드 불변식(class-wide invariant)인 Invariant'Class를 제공합니다.

Invariant vs. Invariant'Class

Pre'ClassPost'Class와 마찬가지로, Invariant'Class는 다형적인 상황을 위한 계약입니다.

  • Invariant (타입-특정 불변식, Type-Specific Invariant):

    • 오직 해당 특정 타입의 객체에만 적용됩니다.
    • 상속되지 않습니다. 즉, 자식 타입은 부모의 Invariant를 따를 의무가 없습니다. (LSP 위반 가능성이 있어 거의 사용되지 않음)
  • Invariant'Class (클래스-와이드 불변식, Class-Wide Invariant):

    • 상속 규칙: 자식 타입의 유효 불변식은 부모의 Invariant'Class와 자식의 Invariant'Class가 논리적으로 AND 연산된 것입니다.
    • 즉, 자식 객체의 ‘건강 상태’는 자신의 불변식과 부모의 불변식을 모두 만족할 때만 유효한 것으로 간주됩니다. 이는 LSP를 자동으로 강제합니다.
    • 이 클래스-와이드 불변식은 해당 타입 계층의 객체를 다루는 다형적(dispatching) 호출 후에 검사됩니다.

활용 예제: 은행 계좌의 확장

기본 Account 타입은 잔고가 음수가 될 수 없다는 불변식을 가집니다. 이를 확장하는 Savings_Account(적금 계좌)는 기본 규칙에 더해, 이자가 0 이상이어야 한다는 추가적인 불변식을 가집니다.

package Bank is
   type Money is delta 0.01 digits 15;

   -- 1. 부모 타입과 클래스-와이드 불변식
   type Account is tagged private;
   function Get_Balance (This : Account) return Money;
private
   type Account is tagged record
      Balance : Money := 0.0;
   end record
   with Invariant'Class => Get_Balance (Account) >= 0.0;
end Bank;

with Bank; use Bank;
package Savings is
   -- 2. 자식 타입과 추가적인 클래스-와이드 불변식
   type Savings_Account is new Account with private;
   function Get_Interest_Rate (This : Savings_Account) return Float;
private
   type Savings_Account is new Account with record
      Interest_Rate : Float := 0.0;
   end record
   with Invariant'Class => Get_Interest_Rate (Savings_Account) >= 0.0;
end Savings;

유효 불변식의 결합: 이제 Savings_Account 타입의 객체에 대한 공개된 프리미티브 연산(예: Deposit, Withdraw 등)이 호출된 후, 런타임 시스템은 다음의 결합된 불변식을 검사합니다.

Get_Balance (Savings_Account) >= 0.0 and then Get_Interest_Rate (Savings_Account) >= 0.0

Savings_Account의 어떤 메서드도 이 두 가지 조건을 모두 만족시키지 못하면, Assertion_Error가 발생합니다.


Invariant'Class는 상속 계층 전체에 걸쳐 데이터의 일관성과 무결성을 보장하는 강력한 장치입니다. 부모에서 자식으로 내려오면서 불변식이 자동으로 상속되고 결합(AND)되는 규칙은, 자식 타입이 부모 타입의 기본 규칙을 어기지 못하도록 강제합니다.

이를 통해 개발자는 Shape'Class 변수에 어떤 종류의 도형 객체가 담겨 있더라도, 그 객체가 최소한 Shape으로서 유효한 상태임을 신뢰할 수 있습니다. 이는 다형성을 사용하면서도 시스템 전체의 안정성을 유지하는 객체 지향 설계의 핵심 원칙을 구현한 것입니다.

23.4 (서브)타입 술어 (Subtype Predicates)

지금까지 배운 계약들—전제조건, 후제조건, 불변식—은 주로 서브프로그램의 동작이나 private 타입의 상태를 검증하는 데 초점을 맞추었습니다. 하지만 만약 타입 그 자체에 유효성 규칙을 내장하여, 해당 타입의 어떤 변수든 항상 그 규칙을 만족하도록 강제할 수 있다면 어떨까요?

예를 들어, “주말이 아닌 요일만 저장할 수 있는 타입”이나 “짝수만 저장할 수 있는 정수 타입”을 만들 수 있다면, 프로그래머는 더 이상 이 변수들에 값을 할당할 때마다 유효성을 수동으로 검사할 필요가 없을 것입니다.

Ada 2012에서 도입된 (서브)타입 술어(Subtype Predicate)는 바로 이러한 “똑똑한 타입(smart type)”을 만들기 위한 기능입니다. 술어는 타입이나 서브타입을 선언할 때, 해당 타입의 객체가 항상 만족해야 하는 불리언(Boolean) 조건을 직접 명시하는 계약입니다.

타입 불변식(Type_Invariant)과의 차이점

타입 술어는 타입의 유효성을 검사한다는 점에서 타입 불변식과 유사해 보이지만, 적용 대상과 검사 시점에서 중요한 차이를 보입니다.

  • 타입 불변식 (Type_Invariant):
    • 대상: private 타입에만 적용할 수 있습니다.
    • 검사 시점: 해당 타입의 객체를 다루는 공개된 서브프로그램의 실행이 끝난 후에 검사됩니다. 즉, 객체의 상태가 최종적으로 일관된지를 확인합니다.
  • (서브)타입 술어 (Subtype Predicate):
    • 대상: Integer, 열거형 등 모든 종류의 타입에 적용할 수 있습니다.
    • 검사 시점: 변수에 값이 할당될 때, 서브프로그램에 매개변수로 전달될 때, 타입 변환이 일어날 때 등, 해당 타입의 값이 사용되는 모든 지점에서 검사가 이루어집니다. 즉, 애초에 유효하지 않은 값이 변수에 들어오는 것을 막는 ‘문지기’ 역할을 합니다.

두 종류의 술어

Ada는 두 가지 종류의 술어를 제공하며, 각각 다른 목적을 가집니다.

  1. Dynamic_Predicate: 런타임에 검사되는 유효성 규칙입니다. 변수의 실제 값에 따라 참/거짓이 결정되는 대부분의 유효성 검사에 사용됩니다.
  2. Static_Predicate: 컴파일 타임에 또는 정적 분석 도구를 통해 검사될 수 있는 타입의 속성을 명시합니다.

이번 절에서는 이 두 가지 술어를 사용하여 어떻게 타입 자체에 계약을 내장하고, 이를 통해 코드의 안정성을 근본적으로 향상시킬 수 있는지 자세히 알아봅니다.

23.4.1 Dynamic_Predicate를 이용한 런타임 유효성 규칙

Dynamic_Predicate는 타입이나 서브타입에 런타임에 검사되는 유효성 규칙을 직접 명시하는 계약입니다. 이 술어가 명시된 타입의 변수는, 자신의 수명 동안 항상 이 규칙을 만족하는 값만을 가져야 함이 보장됩니다.

단순한 범위 제약(range L .. R)으로는 표현할 수 없는 더 복잡하고 정교한 제약 조건을 타입 자체에 부여할 수 있게 해주는 강력한 기능입니다.

구문 및 동작

Dynamic_Predicate는 타입 또는 서브타입 선언 시 with 키워드를 사용하여 추가합니다. 술어 표현식 내에서는, 검사 대상이 되는 객체를 해당 (서브)타입의 이름으로 참조합니다.

서브타입 선언: subtype 서브타입명 is 기본타입 with Dynamic_Predicate => 표현식;

새로운 타입 선언: type 새타입명 is new 기본타입 with Dynamic_Predicate => 표현식;

검사 시점: 이 술어는 다음과 같이 해당 타입의 값이 사용되는 모든 중요한 지점에서 런타임에 자동으로 검사됩니다.

  • 변수에 값이 할당될 때
  • 변수가 초기화될 때
  • 서브프로그램에 매개변수로 전달될 때
  • 타입 변환이 일어날 때
  • 함수가 해당 타입의 값을 반환할 때

만약 술어 표현식의 결과가 False이면, 즉 규칙을 위반하는 값이 감지되면 즉시 Assertion_Error 예외가 발생합니다.

활용 예제

1. 열거형 타입 제약하기: “주중(Weekday)” 서브타입

Day라는 열거형 타입에서 주말(토, 일)을 제외한 ‘주중’만을 나타내는 서브타입을 정의할 수 있습니다.

procedure Test_Weekday is
   type Day is (Mon, Tue, Wed, Thu, Fri, Sat, Sun);

   -- 술어를 이용한 서브타입 정의
   subtype Weekday is Day with
     Dynamic_Predicate => Weekday in Mon .. Fri;

   Today    : Weekday := Mon;    -- OK: Mon은 조건을 만족
   Tomorrow : Weekday;
begin
   Today := Fri;               -- OK: Fri는 조건을 만족

   -- Tomorrow := Sat;            -- 🚨 런타임 오류!
                                 -- Sat은 Weekday의 술어를 위반하므로 Assertion_Error 발생
end Test_Weekday;

이제 Weekday 타입의 변수는 프로그램의 어느 곳에서든 Mon에서 Fri 사이의 값만 가질 수 있음이 언어 차원에서 보장됩니다.

2. 숫자 타입 제약하기: “양의 짝수” 서브타입

단순한 범위(range)로는 표현할 수 없는 “짝수”라는 규칙을 술어로 정의할 수 있습니다.

subtype Even_Positive is Positive with
  Dynamic_Predicate => Even_Positive mod 2 = 0;

My_Num : Even_Positive;
-- My_Num := 10;   -- OK
-- My_Num := 100;  -- OK
-- My_Num := 5;    -- 🚨 Assertion_Error 발생

3. 문자열 타입 제약하기: “빈 문자열이 아닌” 서브타입

사용자 이름이나 파일 경로 등 절대 비어 있으면 안 되는 문자열을 위한 타입을 정의할 수 있습니다.

subtype Non_Empty_String is String with
  Dynamic_Predicate => Non_Empty_String'Length > 0;

User_Name : Non_Empty_String (1 .. 10);
-- User_Name := "AdaLover";    -- OK
-- User_Name := (others => ' '); -- 🚨 Assertion_Error 발생 ('Length가 0이 됨)

Dynamic_Predicate는 유효성 검사 로직을 if 문과 같은 절차적인 코드에서 떼어내어, 데이터의 정의 자체에 통합시키는 패러다임의 전환을 가져옵니다. 이를 통해 타입 자체가 자신의 유효성을 보증하게 되므로, 코드 전체의 안정성이 크게 향상됩니다. 프로그래머는 변수가 항상 유효한 값을 가질 것이라고 신뢰할 수 있으며, 이는 더 깨끗하고 신뢰성 높은 코드로 이어집니다.

23.4.2 Static_Predicate를 이용한 컴파일 타임 속성

Dynamic_Predicate가 런타임에 값의 유효성을 검사하는 데 초점을 맞추었다면, Static_Predicate는 한 걸음 더 나아가 타입의 정적인(static) 속성을 명시하고, 가능한 경우 컴파일 타임에 그 속성을 검증하기 위해 사용됩니다.

이는 일반적인 버그 방지를 넘어, 정적 분석(static analysis) 도구나 형식 검증(formal verification) 도구와 연동하여 프로그램의 수학적 정확성을 증명하는 데 사용되는 매우 강력한 고급 기능입니다.

구문 및 의미

구문은 Dynamic_Predicate와 유사하지만, 술어 표현식은 반드시 정적 표현식(static expression)이어야 한다는 핵심적인 차이가 있습니다. 즉, 컴파일러가 컴파일 시점에 그 값을 계산할 수 있는 표현식이어야 합니다.

type 새타입명 is new 기본타입 with Static_Predicate => 정적_표현식;

Static_Predicate의 주된 목적은 런타임 검사를 생성하는 것이 아니라, 컴파일러와 다른 분석 도구에게 해당 타입에 대한 중요한 정보를 제공하는 것입니다.

  • 컴파일러에게: 이 타입의 모든 값은 이 속성을 만족하므로, 이를 이용한 최적화를 수행할 수 있다.
  • 정적 분석기에게: 이 타입의 변수는 항상 이 속성을 만족한다고 가정하고 프로그램의 정확성을 증명하는 데 사용하라.

만약 술어에 사용된 값을 컴파일 시점에 알 수 없는 경우(예: 사용자 입력), Static_PredicateDynamic_Predicate처럼 런타임에 검사를 수행합니다. ‘정적’이라는 이름은 컴파일 타임에 검사될 가능성을 내포하는 것입니다.

활용 예제: 2의 거듭제곱(Power of 2) 타입

특정 하드웨어 버퍼의 크기는 반드시 2의 거듭제곱(예: 64, 128, 256, 512…)이어야 한다고 가정해 봅시다. 이러한 규칙을 Static_Predicate로 타입에 내장할 수 있습니다.

1. 정적 검사 함수 정의 먼저 어떤 수가 2의 거듭제곱인지 판별하는 함수를 static으로 선언합니다. 이를 통해 컴파일러는 이 함수를 컴파일 시점에 실행할 수 있습니다.

-- 정적 함수 선언
static function Is_Power_Of_Two (N : Positive) return Boolean is
  (N > 0 and (N and (N - 1)) = 0); -- 비트 연산을 이용한 효율적인 검사

2. Static_Predicate를 가진 타입 정의

type Buffer_Size is new Positive with
  Static_Predicate => Is_Power_Of_Two (Buffer_Size);

3. 컴파일 시점 검증 이제 Buffer_Size 타입의 상수를 선언하면, 컴파일러(또는 정적 분석기)는 선언 시점에 그 값이 술어를 만족하는지 검사할 수 있습니다.

-- OK: 256은 2의 거듭제곱이므로 컴파일러가 유효하다고 판단
Default_Size   : constant Buffer_Size := 256;

-- 컴파일 오류 또는 정적 분석 경고 발생!
-- 100은 2의 거듭제곱이 아니므로 Static_Predicate 위반
-- Invalid_Size   : constant Buffer_Size := 100;

-- 런타임에 값이 결정되는 경우
procedure Set_Size (Value : Positive) is
   My_Size : Buffer_Size := Value; -- 런타임에 Is_Power_Of_Two(Value) 검사 수행
begin
   -- ...
end Set_Size;

Dynamic_Predicate vs. Static_Predicate

특징 Dynamic_Predicate Static_Predicate
주된 목적 런타임 값의 유효성 검사 타입의 정적 속성 명시, 정적 분석 지원
검사 시점 항상 런타임 컴파일 타임 (가능한 경우), 또는 런타임
표현식 일반 불리언 표현식 정적(Static) 불리언 표현식
사용 사례 “주말이 아닌 요일”, “0이 아닌 분모” “값이 소수(Prime)인 타입”, “크기가 짝수인 타입”

Static_Predicate는 일반적인 애플리케이션 개발보다는, SPARK와 같은 정적 분석 도구를 사용하여 코드의 완전한 정확성을 증명해야 하는 항공우주, 국방, 의료 등 고신뢰성(high-integrity) 시스템 개발에서 그 진가를 발휘하는 전문가 수준의 기능입니다. 이는 Ada가 단순한 프로그래밍 언어를 넘어, 시스템의 신뢰성을 수학적으로 보증하는 도구로 사용될 수 있음을 보여주는 대표적인 예입니다.

23.5 단정문 (Assert Pragma)

지금까지 우리는 서브프로그램의 시작과 끝(Pre, Post), 그리고 타입의 상태(Invariant, Predicate)에 대한 계약을 정의하는 방법을 배웠습니다. 이러한 계약들은 주로 컴포넌트의 ‘경계(boundary)’에서 그 유효성을 검사합니다.

하지만 때로는 서브프로그램의 실행 중간에, 특정 지점에서 어떤 조건이 반드시 참이어야 한다고 보증하고 싶을 때가 있습니다. 이는 서브프로그램의 진입 조건이나 최종 결과는 아니지만, 알고리즘의 중간 단계에서 반드시 유지되어야 하는 논리적 가정을 명시하는 것입니다.

이러한 코드 내의 특정 지점에서의 ‘논리적 가정’을 검사하기 위해 Ada는 Assert 프라그마, 즉 단정문을 제공합니다.

pragma Assert는 “이 코드 위치에서는 이 조건이 반드시 참이어야 한다. 만약 거짓이라면, 이는 예상된 오류가 아니라 내 코드 어딘가에 심각한 버그가 있다는 뜻이다.”라고 프로그래머가 선언하는 것과 같습니다.

만약 단정문에 명시된 조건이 False로 평가되면, 이는 프로그램의 논리적 일관성이 깨졌음을 의미하므로 즉시 Assertion_Error 예외가 발생하여 실행을 중단시킵니다.

Assert 프라그마는 if 문과는 근본적으로 다릅니다. if 문은 예상 가능한 조건에 따라 프로그램의 흐름을 제어하는 반면, Assert절대 발생해서는 안 되는, 불가능해야 할 상황을 감지하여 개발자에게 프로그램의 결함을 알리는 역할을 합니다. 다른 계약들과 마찬가지로, Assert 프라그마의 검사 여부 또한 pragma Assertion_Policy를 통해 제어할 수 있습니다.

이번 절에서는 Assert 프라그마의 구문과, 이를 활용하여 코드의 내부적인 정확성을 보강하고 디버깅을 용이하게 만드는 주요 활용 사례들을 살펴봅니다.

23.5.1 pragma Assert의 정의 및 구문

pragma Assert는 실행 가능한 코드의 흐름 중간에 특정 불리언(Boolean) 조건이 참(True)임을 단정하는 데 사용되는 프라그마(pragma)입니다. 이 프라그마는 서브프로그램의 경계가 아닌, 알고리즘의 내부 특정 지점에서의 논리적 상태를 검증하는 데 사용되는 가장 직접적인 형태의 계약입니다.

이는 프로그래머가 코드의 특정 지점에 대해 “여기서 이 조건이 거짓이라면, 그것은 예상된 런타임 오류가 아니라 명백한 프로그래밍 버그다”라고 선언하는 것과 같습니다.

구문

Assert 프라그마는 검사할 불리언 표현식을 첫 번째 인자로 받으며, 선택적으로 실패 시 출력할 메시지를 두 번째 인자로 받을 수 있습니다.

pragma Assert (불리언_표현식 [, Message => 문자열_표현식]);
  • 불리언_표현식 (필수): 이 시점에서 True여야 하는 조건입니다.
  • Message (선택): 만약 조건이 False일 경우, Assertion_Error 예외와 함께 전달될 메시지입니다. 이 메시지는 디버깅 시 오류의 원인과 문맥을 파악하는 데 매우 큰 도움을 줍니다.

동작 방식

pragma Assert가 포함된 코드가 실행될 때, Assert에 대한 어설션 정책이 Check로 활성화되어 있다면 다음과 같이 동작합니다.

  1. 조건이 참(True)일 경우: 아무 일도 일어나지 않습니다. 프로그램은 Assert 프라그마가 없는 것처럼 다음 문장으로 실행을 계속합니다.

  2. 조건이 거짓(False)일 경우:

    • 프로그램의 실행이 그 지점에서 즉시 중단됩니다.
    • Ada.Assertions.Assertion_Error 예외가 발생(raise)합니다.
    • 만약 Message 인자가 제공되었다면, 해당 문자열이 발생한 예외 객체에 포함되어 전달됩니다.

예제: Assert 프라그마 사용하기

procedure Process_Data (Pointer : access Data_Record) is
   Value : Integer;
begin
   -- 이 프로시저를 호출하는 코드는 유효한 포인터를 전달할 책임이 있다.
   -- Assert는 그 책임이 잘 지켜졌는지, 즉 내부 로직의 가정이 유효한지 검사한다.
   pragma Assert (Pointer /= null, Message => "Process_Data received a null pointer!");

   -- 위 단정이 통과했으므로, 이 지점부터 Pointer.all 접근은 안전함이 보장된다.
   Value := Pointer.Value;

   -- ... 복잡한 처리 수행 ...
   Value := Value * 2 + 5;

   -- 처리 후, Value는 반드시 양수여야 한다는 내부 규칙 검사
   pragma Assert (Value > 0);

end Process_Data;

위 예제에서, 만약 Process_Datanull 포인터와 함께 호출된다면, 첫 번째 Assert에서 Assertion_Error가 발생하여 위험한 Pointer.Value 접근을 시도하기 전에 프로그램이 안전하게 중단됩니다. 또한, Message를 통해 오류의 원인이 무엇인지 명확하게 알 수 있습니다.

어설션 정책과의 연동

Assert 프라그마는 다른 계약과 마찬가지로 pragma Assertion_Policy의 영향을 받습니다. Assert에 대한 정책이 Check일 때만 실제 검사가 이루어집니다.

-- Assert 프라그마에 대한 검사를 활성화
pragma Assertion_Policy (Assert => Check);

-- Assert 프라그마에 대한 검사를 비활성화 (코드는 생성되나 실행되지 않음)
pragma Assertion_Policy (Assert => Ignore);

이 기능을 통해 개발 및 테스트 중에는 단정문을 모두 활성화하여 내부적인 버그를 철저히 검출하고, 최종 릴리스 버전에서는 비활성화하여 성능에 미치는 영향을 제거할 수 있습니다.

23.5.2 코드 중간에 논리적 가정을 명시하기

pragma Assert는 서브프로그램의 경계가 아닌, 코드의 흐름 중간중간에 프로그래머의 논리적 가정이 깨지지 않았는지를 확인하는 ‘중간 점검’ 역할을 합니다. 이는 복잡한 알고리즘의 내부 안정성을 보강하고, 잠재적인 버그를 조기에 발견하는 데 매우 효과적입니다.

1. 알고리즘 불변식(Invariant) 검사

많은 알고리즘, 특히 루프(loop)를 사용하는 알고리즘들은 루프의 매 반복마다 항상 참이어야 하는 루프 불변식(loop invariant)이라는 조건을 가집니다. Assert 프라그마는 이 불변식이 매번 지켜지는지를 검사하여 알고리즘의 정확성을 보증하는 데 사용될 수 있습니다.

예제: 이진 탐색(Binary Search)의 불변식 이진 탐색 알고리즘의 핵심 불변식은 “만약 찾는 값이 배열에 존재한다면, 그 값은 반드시 LowHigh 인덱스 사이에 있다”는 것입니다.

function Binary_Search (Arr : Sorted_Array; Target : Element) return Index is
   Low  : Index := Arr'First;
   High : Index := Arr'Last;
   Mid  : Index;
begin
   while Low <= High loop
      -- 루프 불변식: Target은 Arr(Low .. High) 범위 안에 있어야 함
      -- 이 가정이 깨지면 알고리즘 어딘가에 논리적 오류가 있는 것임
      pragma Assert (Target >= Arr(Low) and then Target <= Arr(High));

      Mid := Low + (High - Low) / 2;
      if Arr(Mid) < Target then
         Low := Mid + 1;
      elsif Arr(Mid) > Target then
         High := Mid - 1;
      else
         return Mid; -- 값을 찾음
      end if;
   end loop;
   return 0; -- 값을 찾지 못함
end Binary_Search;

만약 LowHigh 인덱스를 계산하는 로직에 버그가 있어 이 불변식이 깨진다면, Assert가 즉시 실패하여 오류의 원인을 정확히 지적해 줍니다.

2. 도달해서는 안 되는 코드 경로 확인

case 문이나 if-elsif 체인에서, 논리적으로 모든 경우를 이미 처리하여 특정 elsewhen others 분기에는 절대 도달해서는 안 되는 경우가 있습니다. Assert (False)는 이러한 가정을 명시하고 검증하는 효과적인 방법입니다.

type State is (Idle, Running, Paused);
procedure Process_State (S : State) is
begin
   case S is
      when Idle    => -- ...
      when Running => -- ...
      when Paused  => -- ...

      -- 'State' 타입은 3가지 값만 가지므로, 이 경로는 절대 실행될 수 없음
      when others =>
         pragma Assert (False, Message => "Impossible state reached in Process_State!");
   end case;
end Process_State;

이 단정문은 현재로서는 불필요해 보일 수 있습니다. 하지만 미래에 어떤 개발자가 State 열거형에 Stopping이라는 새로운 상태를 추가하고 Process_State 프로시저를 수정하는 것을 잊는다면, 이 Assert(False)가 즉시 실패하여 로직이 불완전함을 알려주는 안전장치 역할을 합니다.

3. 복잡한 연산 후의 상태 검증

데이터 구조를 조작하는 복잡한 연산을 수행한 후, 그 결과가 기대하는 상태와 일치하는지를 검증하는 데 Assert를 사용할 수 있습니다.

procedure Normalize_Vector (V : in out Vector) is
   Length : constant Float := -- ... 벡터의 길이를 계산하는 복잡한 로직 ...
begin
   -- 전제조건: 길이가 0인 벡터는 정규화할 수 없음
   pragma Assert (Length > 0.0);

   V.X := V.X / Length;
   V.Y := V.Y / Length;
   V.Z := V.Z / Length;

   -- 후속 검증: 정규화 후 벡터의 길이는 1.0에 매우 가까워야 함
   pragma Assert (abs (New_Length (V) - 1.0) < TOLERANCE);
end Normalize_Vector;

이는 Postcondition과 유사한 역할을 코드 내부에 수행하는 것으로, 복잡한 알고리즘이 올바르게 동작했는지에 대한 확신을 높여줍니다.

이처럼 Assert 프라그마는 프로그래머의 머릿속에만 있던 논리적 가정들을 코드에 명시적으로 표현하고 검증함으로써, 프로그램 내부의 숨겨진 버그를 찾아내고 코드의 신뢰성을 높이는 중요한 도구입니다.

23.6 [활용] 어설션 정책 관리 (pragma Assertion_Policy)

지금까지 우리는 Pre, Post, Type_Invariant, Predicate, Assert 등 프로그램의 정확성을 보장하기 위한 다양한 종류의 계약과 단정을 배웠습니다. 이러한 계약들은 개발 과정에서 버그를 조기에 발견하고 코드의 신뢰성을 높이는 매우 강력한 안전망 역할을 합니다.

하지만 한 가지 현실적인 문제가 있습니다. 모든 계약 검사는 프로그램 실행 중에 추가적인 연산을 수행하는 것을 의미하며, 이는 필연적으로 약간의 성능 저하(overhead)를 유발합니다. 개발 및 테스트 단계에서는 이러한 안전망이 필수적이지만, 모든 검증이 끝나고 최고 성능이 요구되는 최종 제품(release)에서는 이 오버헤드가 부담이 될 수 있습니다.

그렇다면 어떻게 우리는 개발 중에는 계약의 모든 이점을 누리면서도, 최종 제품에서는 불필요한 성능 저하를 피할 수 있을까요?

이 질문에 대한 Ada의 해답이 바로 pragma Assertion_Policy 입니다.

이 프라그마는 프로그램의 특정 영역 또는 전체에 대해, 어떤 종류의 계약을 실제로 검사할지(Check), 아니면 검사 코드를 생성하지 않고 무시할지(Ignore)를 컴파일러에게 지시하는 ‘스위치’ 역할을 합니다.

이를 통해 개발자는 계약의 정의(specification)와 그것의 실행(enforcement)을 분리할 수 있습니다. 즉, 계약은 코드에 항상 명시하여 문서화와 정적 분석의 이점을 유지하되, 런타임 검사 여부는 빌드 목적(디버그용, 테스트용, 릴리스용)에 따라 자유롭게 제어할 수 있습니다.

이번 절에서는 pragma Assertion_Policy의 구체적인 사용법과, 이를 활용하여 개발 주기 단계별로 계약 검사를 효과적으로 관리하는 실용적인 전략에 대해 알아봅니다.

23.6.1 계약 검사의 활성화 및 비활성화

pragma Assertion_Policy는 계약 검사를 위한 중앙 제어 스위치 역할을 합니다. 이 프라그마를 사용하여, 특정 코드 영역에 대해 어떤 종류의 계약을 검사할지를 컴파일러에게 지시할 수 있습니다.

이 프라그마는 패키지나 서브프로그램의 선언부 맨 앞에 위치하며, 그 효력은 해당 선언 영역이 끝날 때까지 유지됩니다.

구문 및 정책 식별자

기본 구문:

pragma Assertion_Policy (정책_식별자);
pragma Assertion_Policy (계약_종류 => 정책_식별자);

정책 식별자 (Policy Identifier): Assertion_Policy에 지정할 수 있는 정책은 세 가지입니다.

  1. Check:

    • 동작: 컴파일러에게 해당 계약을 검사하는 코드를 생성하고 실행하도록 지시합니다. 만약 런타임에 계약이 위반되면 Ada.Assertions.Assertion_Error 예외가 발생합니다.
    • 용도: 개발 및 테스트 단계에서 모든 계약을 활성화하여 버그를 철저히 찾아내는 데 사용됩니다.
  2. Ignore:

    • 동작: 컴파일러에게 해당 계약을 완전히 무시하도록 지시합니다. 계약 검사를 위한 어떤 코드도 생성되지 않습니다.
    • 용도: 최종 릴리스(release) 빌드에서 성능에 미치는 영향을 완전히 제거하기 위해 사용됩니다. 계약은 코드에 남아 문서의 역할을 하지만, 런타임 오버헤드는 0이 됩니다.
  3. Disable:

    • 동작: Ignore와 마찬가지로 런타임 검사 코드를 생성하지 않습니다. 하지만, SPARK와 같은 정적 분석 도구에게는 해당 계약이 항상 참이라고 가정해도 좋다는 힌트를 줍니다.
    • 용도: 형식 검증(formal verification)을 사용하는 고신뢰성 시스템 개발에서 사용되는 고급 옵션입니다.

계약 종류 (Assertion Aspect Mark): 정책을 전체 계약에 일괄 적용하는 대신, 다음과 같이 특정 종류의 계약에 대해서만 개별적으로 지정할 수 있습니다.

  • Pre
  • Post
  • Type_Invariant (또는 줄여서 Invariant)
  • Predicate (Dynamic_Predicate, Static_Predicate 모두 포함)
  • Assert

활용 예제

1. 패키지 전체의 모든 계약 활성화 패키지 명세나 바디의 최상단에 선언하여 해당 패키지 전체에 정책을 적용합니다.

package My_Debug_Package is
   pragma Assertion_Policy (Check); -- 이 패키지의 모든 계약을 검사

   -- ... 타입 및 서브프로그램 선언 ...
end My_Debug_Package;

2. 특정 계약만 선별적으로 활성화 성능이 중요한 릴리스 빌드에서도, 치명적인 오류를 막기 위한 최소한의 전제조건(Pre) 검사는 남겨두고 싶을 수 있습니다.

procedure Critical_Operation is
   -- 전제조건과 불변식은 검사하되, 후제조건과 단정문은 무시
   pragma Assertion_Policy (Pre => Check, Invariant => Check, Post => Ignore, Assert => Ignore);
begin
   -- ...
end Critical_Operation;

3. 컴파일러 스위치와의 연동 실제 프로젝트에서는 소스 코드에 직접 정책을 명시하기보다는, 컴파일러 스위치를 통해 전역 정책을 설정하는 것이 일반적입니다. 예를 들어, GNAT 컴파일러에서는 -gnata 스위치를 사용하여 모든 어설션을 활성화(Check)할 수 있습니다.

  • 디버그 빌드: -gnata 스위치를 켜서 모든 계약을 검사합니다.
  • 릴리스 빌드: -gnata 스위치를 꺼서(기본값) 모든 계약을 무시(Ignore)합니다.

코드 내의 pragma Assertion_Policy는 컴파일러 스위치로 설정된 기본 정책을 해당 영역에 한해 재정의(override)하는 역할을 합니다.

이처럼 Assertion_Policy를 통해 개발자는 계약의 명세와 실행을 분리하여, 개발 중에는 최대한의 안전성을 확보하고 최종 제품에서는 최고의 성능을 달성하는 유연한 개발 전략을 구사할 수 있습니다.

23.6.2 빌드 유형에 따른 정책 설정

소프트웨어 개발은 일반적으로 두 가지 주요 빌드(build) 유형을 구분하여 진행합니다. 바로 버그를 찾는 데 집중하는 디버그 빌드(Debug Build)와, 사용자에게 배포하기 위해 성능을 최적화하는 릴리스 빌드(Release Build)입니다. Assertion_Policy는 이 두 가지 빌드 유형에 따라 계약 검사 수준을 체계적으로 관리하는 핵심적인 역할을 합니다.

권장 전략: 전역 스위치 우선, 프라그마는 예외 처리

가장 효율적이고 널리 사용되는 전략은 다음과 같습니다.

  1. 전역 정책은 컴파일러 스위치로 제어한다: 대부분의 계약 검사 여부는 컴파일러 옵션을 통해 프로젝트 전체에 일괄적으로 설정합니다.
  2. 소스 코드 내 프라그마는 예외적인 경우에만 사용한다: 전역 정책을 특정 부분에서 재정의(override)해야 하는 특별한 경우에만 코드 내에 pragma Assertion_Policy를 명시합니다.

1. 디버그 빌드: 모든 것을 검사하라

  • 목표: 가능한 한 많은 오류를 조기에 발견하는 것.
  • 설정: GNAT 컴파일러를 사용하는 경우, 컴파일 시 -gnata 스위치를 추가합니다.
    gnatmake -gnata my_project.adb
    
  • 동작: -gnata 스위치는 프로젝트 전체의 기본 어설션 정책을 Check로 설정합니다. 이로써 코드에 명시된 모든 종류의 계약(Pre, Post, Invariant, Predicate, Assert)이 활성화되어, 개발 및 테스트 중에 잠재적인 버그를 최대한으로 검출할 수 있는 강력한 안전망을 제공합니다.

2. 릴리스 빌드: 성능을 위해 대부분을 무시하라

  • 목표: 최종 사용자에게 최고 수준의 성능을 제공하는 것.
  • 설정: -gnata 스위치를 사용하지 않고 컴파일합니다. 보통 -O2(최적화 레벨 2)와 같은 최적화 옵션을 함께 사용합니다.
    gnatmake -O2 my_project.adb
    
  • 동작: -gnata 스위치가 없으면, GNAT의 기본 어설션 정책은 Ignore입니다. 따라서 컴파일러는 계약 검사를 위한 코드를 전혀 생성하지 않으며, 계약으로 인한 런타임 성능 저하는 0이 됩니다.

3. 프라그마를 이용한 정책 재정의: 중요한 것은 남기기

때로는 릴리스 빌드에서도, 시스템의 안정성이나 보안과 직결되는 매우 중요한 계약만큼은 계속 검사하고 싶을 수 있습니다. 이때 소스 코드 내에 pragma Assertion_Policy를 사용하여 컴파일러의 기본 정책을 재정의합니다.

예제: 은행 시스템의 핵심 모듈 은행 계좌 패키지에서는 잔고가 음수가 되어서는 안 된다는 불변식과, 입출금 전제조건이 시스템의 무결성을 위해 매우 중요합니다. 따라서 릴리스 빌드에서도 이 두가지는 반드시 검사하도록 강제할 수 있습니다.

package Bank_Accounts is
   -- 릴리스 빌드(기본 정책: Ignore)에서도 Pre와 Invariant는 반드시 검사하도록 재정의
   pragma Assertion_Policy (Pre => Check, Invariant => Check, Post => Ignore);

   type Account is tagged private;

   procedure Withdraw (This : in out Account; Amount : Money) with
     Pre => Amount > 0 and then Get_Balance (This) >= Amount; -- 이 Pre는 항상 검사됨

private
   type Account is tagged record
      Balance : Money;
   end record with
     Invariant => Balance >= 0; -- 이 Invariant는 항상 검사됨
end Bank_Accounts;

컴파일 시 동작:

  • gnatmake -gnata ... (디버그 빌드): -gnata가 모든 것을 Check로 강제하므로 Pre, Invariant, Post 모두 검사됩니다.
  • gnatmake -O2 ... (릴리스 빌드): 기본 정책은 Ignore이지만, 위 프라그마가 PreInvariant에 대한 정책을 Check로 재정의했으므로 이 두 가지만 검사되고, Post는 무시됩니다.

이처럼 컴파일러 스위치와 pragma Assertion_Policy를 조합하면, 개발 단계에서는 폭넓은 안전망을, 최종 제품에서는 선별적인 방어막을 구축하는 정교하고 실용적인 개발 전략을 구사할 수 있습니다.

24. 저수준 프로그래밍과 메모리 관리 (Low-Level Programming and Memory Management)

Ada 언어 설계의 핵심 철학 중 하나는 높은 수준의 추상화(abstraction)를 통해 프로그래머를 복잡하고 오류가 발생하기 쉬운 하드웨어의 세부사항으로부터 보호하는 것입니다. 패키지를 통한 캡슐화, 강력한 타입 시스템, 그리고 제어 타입(controlled type)을 이용한 자동 자원 관리(RAII)와 같은 기능들은 모두 이러한 철학을 반영하며, 프로그램의 신뢰성과 유지보수성을 높이는 데 기여합니다.

그러나 이러한 추상화가 때로는 해결책이 아닌 문제 자체가 되는 특수한 프로그래밍 영역이 존재합니다. 바로 예측 불가능한 시간 지연이나 메모리 고갈이 시스템 전체의 실패로 이어질 수 있는 항공, 국방, 의료, 우주항공과 같은 고신뢰성 실시간(high-integrity real-time) 시스템입니다.

이러한 시스템에서, 언어 런타임이 제공하는 일반적인 목적의 메커니즘, 특히 표준 동적 메모리 할당, 즉 힙(heap)은 다음과 같은 근본적인 한계를 가집니다.

  • 비결정적 실행 시간 (Non-deterministic Execution Time): new 연산자를 통한 메모리 할당 요청에 걸리는 시간은 힙의 현재 상태(단편화 정도 등)에 따라 크게 달라질 수 있습니다. 이는 마이크로초(μs) 단위의 엄격한 실행 마감 시간(deadline)을 준수해야 하는 경성 실시간(hard real-time) 태스크에 치명적일 수 있습니다.
  • 메모리 단편화 (Memory Fragmentation): 프로그램 실행 중 반복적인 메모리 할당과 해제가 누적되면, 전체적으로는 충분한 여유 공간이 있음에도 불구하고 프로그램이 요구하는 연속된 큰 메모리 블록을 할당하지 못하는 상태에 이를 수 있습니다. 이는 장시간 중단 없이 운영되어야 하는 시스템의 안정성을 저해하는 주요 원인입니다.

많은 고수준 언어들은 프로그래머를 이러한 저수준의 문제로부터 완전히 차단하지만, 시스템 프로그래밍 언어로서 Ada는 다른 접근법을 취합니다. Ada는 프로그래머가 이러한 추상화의 이면을 직접 제어해야 할 필요성을 인정하고, Ada 2022 레퍼런스 매뉴얼에 명시된 표준화된 ‘안전 해치(escape hatch)’들을 제공합니다.

본 장에서는 Ada의 추상화 계층을 넘어, 시스템의 동작과 자원 사용을 기계 수준에서 직접 제어하는 고급 기법들을 학습합니다. System 패키지가 제공하는 AddressStorage_Element와 같은 저수준 타입을 다루는 방법부터 시작하여, 이 장의 핵심 주제인 System.Storage_Pools를 이용해 기본 힙 할당자를 완전히 대체하는 사용자 정의 메모리 관리자를 구현하는 방법을 심도 있게 탐구할 것입니다. 또한, Ada.Unchecked_Deallocation이나 Ada.Unchecked_Conversion과 같이, 타입 시스템의 보호를 의도적으로 우회하는 대신 프로그래머에게 시스템 무결성에 대한 모든 책임을 부여하는 비검사(unchecked) 프로그래밍의 개념과 그 위험성에 대해서도 고찰합니다.

이 장에서 다루는 기법들은 강력한 만큼 큰 책임을 요구하며, 반드시 필요한 경우에만 신중하게 사용되어야 합니다. 그러나 이 기술들을 마스터함으로써, 프로그래머는 Ada를 사용하여 가장 엄격한 수준의 성능, 예측 가능성, 그리고 안정성이 요구되는 시스템을 구축할 수 있는 능력을 갖추게 될 것입니다.

24.1 사용자 정의 메모리 관리의 필요성

대부분의 범용 응용 프로그램에서, 프로그래머는 Ada 런타임 시스템이 제공하는 기본 동적 메모리 할당자, 즉 힙(heap)을 통해 메모리를 할당(new)하고 해제하는 것에 만족할 수 있습니다. 이 기본 힙은 다양한 크기의 메모리 요청을 유연하게 처리하고, 평균적인 경우에 대해 우수한 성능을 보이도록 최적화되어 있습니다.

그러나 이러한 ‘유연성’과 ‘평균적인 성능’이라는 특성은, 프로그램의 모든 동작이 시간적으로 예측 가능하고 공간적으로 안정적이어야 하는 고신뢰성 및 실시간 시스템의 요구사항과는 정면으로 상충합니다. 이러한 시스템에서 기본 힙을 사용하는 것은 두 가지 심각한 문제를 야기하며, 이는 사용자 정의 메모리 관리 기법의 필요성을 역설합니다.

24.1.1 기본 힙(Heap) 할당자의 한계: 비결정성과 단편화

Ada 프로그램에서 new 키워드를 사용하여 동적으로 객체를 생성할 때, 이 요청은 Ada 런타임 시스템에 내장된 범용 동적 메모리 할당자, 즉 힙(heap)에 의해 처리됩니다. 기본 힙 할당자는 다양한 크기의 메모리 요청을 효율적으로 처리하고, 평균적인 응용 프로그램 환경에서 우수한 처리량을 보이도록 설계되었습니다. 그러나 이러한 범용성은 고신뢰성 시스템의 관점에서는 치명적일 수 있는 두 가지 근본적인 한계를 내포하고 있습니다.

1. 시간적 비결정성 (Temporal Non-determinism)

경성 실시간 시스템은 모든 연산의 최대 실행 시간, 즉 최악 실행 시간(WCET, Worst-Case Execution Time)이 사전에 분석되고 보장되어야 합니다. 그러나 표준 힙 할당자의 메모리 할당 및 해제 시간은 그 상한을 보장할 수 없어 비결정적입니다.

new T 연산이 호출되었을 때, 힙 관리자가 수행하는 작업은 다음과 같은 여러 요인에 따라 실행 시간이 크게 변동될 수 있습니다.

  • 탐색 시간 (Search Time): 할당자는 요청된 크기를 만족하는 메모리 블록을 찾기 위해 내부적으로 관리하는 자유 공간 리스트(free-list)를 탐색해야 합니다. 이 탐색 시간은 힙의 현재 상태, 사용 중인 탐색 알고리즘(예: First-Fit, Best-Fit), 그리고 리스트의 길이에 따라 달라집니다.
  • 분할 및 병합 (Splitting and Coalescing): 요청보다 큰 블록을 찾으면 이를 두 조각으로 나누는 ‘분할’ 작업이 필요하며, 인접한 자유 공간 블록들을 하나의 큰 블록으로 합치는 ‘병합’ 작업이 해제 시에 발생할 수 있습니다. 이러한 작업들은 추가적인 처리 시간을 소요합니다.
  • 운영체제 상호작용 (Operating System Interaction): 힙에 가용한 공간이 부족할 경우, 런타임 시스템은 운영체제에 시스템 콜(system call)을 통해 추가적인 메모리 페이지를 요청해야 합니다. 이 과정은 커널 모드 전환을 포함하므로 상당한 시간 지연을 유발할 수 있으며, 그 지연 시간 또한 예측이 어렵습니다.

이러한 요인들의 조합으로 인해, 동일한 크기의 메모리를 요청하더라도 호출 시점의 힙 상태에 따라 그 처리 시간이 수십 배에서 수백 배까지 차이 날 수 있습니다. 이는 실시간 시스템의 시간적 예측 가능성을 근본적으로 훼손합니다.

2. 공간적 비효율성: 메모리 단편화 (Memory Fragmentation)

메모리 단편화는 장시간 운영되는 시스템의 안정성을 점진적으로 저해하는 문제입니다. 이는 프로그램이 다양한 크기의 메모리 블록을 할당하고 해제하는 과정을 반복하면서, 힙의 여유 공간이 잘게 쪼개진 작은 조각들로 흩어지는 현상을 말합니다.

이 현상이 심화되면, 시스템은 다음과 같은 역설적인 상황에 직면하게 됩니다. 즉, 전체 가용 메모리의 총합은 충분히 크더라도, 요청된 크기의 단일 연속 메모리 블록(single contiguous block)이 존재하지 않아 새로운 메모리 할당이 실패하게 되는 것입니다.

예를 들어, 1MB의 전체 여유 공간이 1KB 크기의 조각 1024개로 흩어져 있다면, 2KB 크기의 메모리 요청조차도 실패하게 됩니다. 이러한 외부 단편화(external fragmentation)는 메모리 누수(memory leak)가 없음에도 불구하고, 시간이 지남에 따라 시스템이 메모리 고갈로 인해 결국 실패하게 되는 주요 원인입니다. 이는 재부팅 없이 수년 이상 동작해야 하는 임베디드 시스템이나 서버 환경에서는 반드시 해결해야 할 문제입니다.

결론적으로, 표준 힙 할당자의 시간적 비결정성과 공간적 단편화라는 두 가지 한계는, 고신뢰성 실시간 시스템의 요구사항을 만족시키지 못합니다. 따라서 이러한 시스템을 구축하기 위해서는, 애플리케이션의 메모리 사용 패턴에 최적화된, 예측 가능하고 안정적인 사용자 정의 메모리 관리 전략이 필수적으로 요구됩니다.

24.1.2 고신뢰성 시스템의 요구사항: 예측 가능성, 실시간성, 안정성

일반적인 데스크톱이나 웹 애플리케이션과 달리, 실패가 치명적인 결과(예: 인명 손실, 막대한 재산 피해, 임무 실패)로 이어질 수 있는 시스템을 고신뢰성 시스템(High-Integrity System)이라고 합니다. 항공기 비행 제어 소프트웨어, 원자력 발전소 제어 시스템, 자동차의 에어백 전개 장치, 그리고 이식형 의료 기기 등이 이에 해당합니다.

이러한 시스템의 소프트웨어는 단순히 논리적으로 올바른 결과를 계산하는 것을 넘어, 정해진 제약 조건 하에서 항상 올바르게 동작함을 증명(prove)할 수 있어야 합니다. 이 증명 가능성의 근간을 이루는 세 가지 핵심 요구사항은 예측 가능성, 실시간성, 그리고 안정성입니다. 기본 힙 할당자는 이 세 가지 요구사항을 모두 만족시키지 못합니다.

1. 예측 가능성 (Predictability)

예측 가능성은 시스템의 모든 동작, 특히 시간과 관련된 동작을 실행하기 전, 즉 정적 분석(static analysis) 단계에서 미리 파악하고 증명할 수 있는 성질을 의미합니다. 이는 단순히 ‘빠르다’는 것과는 다른 개념입니다. 아무리 평균 실행 시간이 빠르더라도, 최악의 경우 얼마나 오래 걸릴지 상한선을 알 수 없다면 그 시스템은 예측 불가능한 것입니다.

고신뢰성 시스템에서는 모든 태스크(task)와 서브프로그램의 최악 실행 시간(WCET, Worst-Case Execution Time)을 계산할 수 있어야 합니다. 그러나 22.1.1절에서 분석했듯이, 기본 힙 할당자의 실행 시간은 비결정적이므로 WCET 분석을 불가능하게 만듭니다. 이는 시스템의 시간적 동작을 수학적으로 증명할 수 없게 만드는 근본적인 결함입니다.

2. 실시간성 (Real-Time Performance)

실시간성은 예측 가능성의 한 형태로, 시스템이 외부 이벤트에 대해 정해진 시간 제약, 즉 마감 시간(deadline) 내에 반드시 응답해야 함을 의미합니다. 실시간 시스템은 그 제약의 엄격함에 따라 다음과 같이 분류됩니다.

  • 경성 실시간 시스템 (Hard Real-Time System): 단 한 번의 마감 시간 위반이라도 시스템 전체의 실패로 간주됩니다. 자동차 에어백은 충돌 감지 후 수 밀리초(ms) 내에 반드시 전개되어야 하며, 이보다 늦는 것은 전개되지 않는 것과 마찬가지로 치명적인 실패입니다. new 연산의 예측 불가능한 지연은 이러한 경성 실시간 제약을 위반할 직접적인 원인이 될 수 있습니다.
  • 연성 실시간 시스템 (Soft Real-Time System): 마감 시간을 위반하는 것이 바람직하지는 않지만, 시스템 전체의 실패로 이어지지는 않고 서비스 품질(QoS)의 저하만을 유발합니다. 예를 들어, 동영상 스트리밍에서 프레임 몇 개를 놓치는 경우가 여기에 해당합니다.

고신뢰성 시스템은 대부분 경성 실시간 요구사항을 가지므로, 시간적 비결정성을 내포한 기본 힙 할당자는 원칙적으로 사용할 수 없습니다.

3. 안정성 (Stability)

안정성은 시스템이 장시간에 걸쳐 중단 없이 운영될 때, 성능의 저하나 자원의 고갈 없이 초기 상태와 동일한 신뢰도로 계속 동작할 수 있는 능력을 의미합니다. 이는 시스템의 장기적인 신뢰성을 나타냅니다.

메모리 단편화는 이러한 안정성을 서서히 침식하는 주된 원인입니다. 단편화가 진행될수록 시스템은 점차 메모리 할당에 더 많은 시간을 소요하게 되어 실시간성을 해치고, 궁극적으로는 가용한 메모리가 충분함에도 불구하고 할당에 실패하여 시스템 전체의 중단을 야기할 수 있습니다. 재부팅 없이 수년 이상 동작해야 하는 인공위성이나 통신 장비와 같은 시스템에서 이러한 점진적인 자원 고갈은 허용될 수 없습니다.

결론적으로, 고신뢰성 시스템을 구축하기 위해서는 예측 가능하고, 실시간 제약을 준수하며, 장기적으로 안정적인 메모리 관리 전략이 필수적입니다. 표준 힙 할당자가 이러한 요구사항을 충족시키지 못하기 때문에, 프로그래머는 시스템의 특성에 맞는 사용자 정의 메모리 관리 기법을 직접 구현해야만 합니다.

24.2 저수준 메모리 조작 도구: System 패키지 심화

사용자 정의 메모리 관리자를 구현하기 위해서는, 먼저 Ada의 추상화된 타입 시스템을 벗어나 프로그램이 실행되는 물리적인 머신의 메모리와 직접적으로 상호작용할 수 있는 기본 도구들을 이해해야 합니다. Ada는 이러한 저수준 기능을 System이라는 표준 라이브러리 패키지를 통해 제공합니다.

Ada 언어의 대부분은 특정 하드웨어나 운영체제에 독립적으로 이식 가능하도록 설계되었지만, System 패키지는 의도적으로 플랫폼 종속적(platform-dependent)입니다. 이 패키지는 Ada 2022 레퍼런스 매뉴얼 13.7절에 정의되어 있으며, 프로그램이 실행되는 대상 시스템의 특성(예: 메모리 워드(word)의 크기, 주소 표현 방식 등)을 노출하는 표준화된 인터페이스 역할을 합니다.

사용자 정의 메모리 관리자를 구축하는 것은 본질적으로 원시 메모리 덩어리(raw memory chunk)를 할당받아, 이를 애플리케이션의 요구에 맞게 분배하고 관리하는 작업입니다. 이를 위해 System 패키지와 그 자식 패키지들은 다음과 같은 핵심적인 저수준 도구들을 제공합니다.

  • System.Address 타입: C의 void *에 해당하는 개념으로, 타입 정보가 없는 순수한 메모리 주소를 표현하는 private 타입입니다. Ada의 접근 타입(access)이 특정 데이터 타입을 가리키는 것과 달리, Address는 순수한 위치 정보만을 담습니다. System.Null_Address 상수는 C의 NULL 포인터에 해당합니다.

  • 'Address 속성: 모든 Ada 객체(변수, 상수 등)에 적용할 수 있는 속성으로, 해당 객체가 할당된 메모리의 시작 주소를 System.Address 타입으로 반환합니다. 이는 고수준의 Ada 데이터로부터 저수준의 주소 정보를 얻는 표준적인 방법입니다.

  • System.Storage_Elements 패키지: 원시 메모리를 바이트(byte) 단위로 조작하기 위한 핵심적인 도구들을 포함하는 자식 패키지입니다.

    • Storage_Element: 컴퓨터가 주소를 지정할 수 있는 가장 작은 메모리 단위(일반적으로 8비트 바이트)를 나타냅니다. C에서 메모리를 조작할 때 charunsigned char를 사용하는 것과 유사한 역할을 합니다.
    • Storage_Array: Storage_Element의 배열 타입입니다. 사용자 정의 메모리 관리자는 일반적으로 운영체제로부터 큰 Storage_Array를 할당받아, 이 메모리 블록을 잘게 나누어 애플리케이션에 제공하는 방식으로 동작합니다.
    • Storage_Offset: 두 메모리 주소 간의 차이를 나타내는 정수 타입입니다. Ada에서는 Address 타입을 직접 정수처럼 더하거나 빼는 것을 허용하지 않으며, 반드시 이 Storage_Offset을 이용한 주소 산술 연산을 수행해야 합니다. 이는 타입 안전성을 최대한 유지하려는 Ada의 설계 철학을 보여줍니다.

본 절에서는 이러한 System 패키지의 핵심 구성 요소들을 심도 있게 분석할 것입니다. 원시 메모리 블록을 Storage_Array로 표현하는 방법, Address를 통해 메모리 위치를 다루는 방법, 그리고 Storage_Offset을 이용해 주소 연산을 수행하는 방법을 학습함으로써, 독자께서는 이 장의 최종 목표인 System.Storage_Pools를 이용한 완전한 사용자 정의 메모리 관리자를 구현하는 데 필요한 견고한 기반 지식을 갖추게 될 것입니다.

24.2.1 원시 메모리 표현: System.Storage_Elements

사용자 정의 메모리 관리의 본질은 프로그램이 사용할 메모리 공간을 운영체제가 제공하는 표준 힙(heap)이 아닌, 프로그래머가 직접 제어하는 별도의 영역에서 관리하는 것입니다. 이를 위해서는 먼저, 타입 정보가 없는 순수한 원시 메모리(raw memory) 덩어리를 표현하고 조작할 수 있는 표준적인 방법이 필요합니다. Ada에서는 이 역할을 System.Storage_Elements라는 표준 라이브러리 패키지가 담당합니다.

이 패키지는 메모리를 가장 기본적인 구성 단위로 다룰 수 있는 도구들을 제공하며, C 언어에서 unsigned char *void *를 사용하여 메모리를 바이트(byte) 단위로 조작하는 것과 유사한 개념을 Ada의 타입 시스템 안에서 구현합니다.

패키지의 핵심 구성 요소

System.Storage_Elements 패키지는 다음과 같은 핵심적인 선언들을 포함하고 있습니다.

  1. Storage_Element 타입: 이 타입은 컴퓨터 아키텍처에서 **주소 지정이 가능한 최소 메모리 단위(smallest addressable unit of memory)**를 나타냅니다. 대부분의 현대 컴퓨터 시스템에서 이는 8비트, 즉 1 바이트(byte)에 해당합니다. Storage_Element는 부호 없는 모듈러(modular) 정수 타입으로 정의되어 있어, 비트 연산과 같은 저수준 조작에 적합합니다.

  2. Storage_Array 타입: Storage_Element의 배열 타입 (array (Integer range <>) of Storage_Element)입니다. 사용자 정의 메모리 풀(pool)을 구현할 때, 관리할 전체 메모리 영역은 바로 이 Storage_Array 타입의 객체로 표현됩니다. 프로그래머는 운영체제로부터 거대한 Storage_Array 하나를 할당받은 후, 이 배열을 논리적으로 분할하여 애플리케이션의 작은 메모리 할당 요청에 응답하게 됩니다.

  3. Storage_Count 타입: 메모리 공간의 크기나 오프셋(offset)을 나타내기 위한 부호 없는 정수 타입입니다. 이는 Storage_Array의 인덱스나 길이를 표현하는 데 사용되며, Integer가 음수 값을 가질 수 있는 것과 달리 크기를 나타내는 용도에 더 적합합니다.

  4. Address_To_Integer / Integer_To_Address 함수: (일부 구현체에서 제공) System.Address 타입과 정수 타입 간의 변환을 수행합니다. 이는 메모리 주소를 로깅하거나, 특정 주소 값을 기준으로 연산을 수행해야 하는 매우 특수한 경우에 사용될 수 있지만, 이식성을 저해하므로 사용에 극도의 주의가 필요합니다.

사용 예시: 메모리 풀의 기본 골격

다음은 사용자 정의 메모리 풀의 기본 골격을 System.Storage_Elements를 사용하여 구현하는 개념적인 예시입니다.

with System.Storage_Elements;

package My_Memory_Pool is

   -- ...

private
   use System.Storage_Elements;

   POOL_SIZE : constant Storage_Count := 1_048_576; -- 1 MiB 크기의 풀
   -- 메모리 풀의 실제 저장 공간
   pool_buffer : aliased Storage_Array (1 .. POOL_SIZE);

   -- 다음 할당 가능한 위치를 가리키는 내부 포인터
   next_free_offset : Storage_Count := 1;

end My_Memory_Pool;

위 예제에서 pool_buffer는 1 MiB 크기의 원시 메모리 블록을 나타냅니다. 메모리 관리자는 이 pool_buffer를 내부적으로 관리하며, 새로운 할당 요청이 들어오면 next_free_offset 위치에서부터 요청된 크기만큼의 Storage_Array 슬라이스(slice)를 잘라내어 그 주소를 반환하는 방식으로 동작하게 될 것입니다.

이처럼 System.Storage_Elements는 Ada의 추상화된 데이터 타입들 아래에 존재하는 물리적인 메모리를 직접 다룰 수 있게 해주는 가장 기본적인 도구입니다. 이 패키지를 통해 프로그래머는 타입 시스템의 제약을 벗어나 바이트 단위로 메모리를 정밀하게 제어할 수 있으며, 이는 사용자 정의 메모리 관리자를 포함한 모든 종류의 저수준 시스템 프로그래밍을 위한 필수적인 기반이 됩니다.

24.2.2 주소 표현과 연산: System.Address'Address 속성

원시 메모리 블록을 다루기 위해서는, 해당 메모리의 특정 위치를 가리키고 그 위치를 기반으로 연산을 수행할 수 있는 방법이 필요합니다. Ada는 이러한 요구사항을 위해 System.Address 타입과 'Address 속성이라는 두 가지 핵심적인 언어 요소를 제공합니다. 이들은 Ada의 추상적인 데이터 객체와 물리적인 기계 메모리 사이를 잇는 다리 역할을 합니다.

System.Address 타입: 추상화된 메모리 주소

Ada 2022 레퍼런스 매뉴얼 13.7절에 정의된 System 패키지는 Address라는 타입을 제공합니다.

type Address is private;

이 타입은 C 언어의 void *와 유사하게, 특정 데이터 타입과 연결되지 않은 순수한 기계 주소를 표현하기 위해 사용됩니다. 여기서 주목할 가장 중요한 사실은 Addressprivate 타입이라는 점입니다. 이는 Address가 단순한 정수 타입이 아님을 의미하며, 프로그래머가 My_Address := My_Address + 4; 와 같이 임의의 정수 연산을 주소에 직접 적용하는 것을 금지합니다. 이러한 제약은 C에서 흔히 발생하는 잘못된 포인터 연산 오류를 원천적으로 방지하는 핵심적인 안전장치입니다.

System 패키지는 또한 Null_Address라는 상수를 제공하여, 어떤 객체도 가리키지 않는 주소를 명확하게 표현할 수 있도록 합니다. 이는 C의 NULL에 해당합니다.

'Address 속성: 객체의 주소 획득

특정 Ada 객체의 메모리 주소를 얻기 위해서는 'Address 속성을 사용합니다. 변수, 상수, 심지어 서브프로그램과 같은 모든 Ada 개체(entity)에 'Address 속성을 적용하면, 해당 개체가 시작되는 메모리의 주소를 System.Address 타입으로 반환받을 수 있습니다.

with System;
with Ada.Text_IO;

procedure Show_Address is
   my_variable   : aliased Integer := 10;
   address_of_var : constant System.Address := my_variable'Address;
begin
   -- System.Address_To_Access_Conversions를 사용하면 주소를 출력할 수 있습니다 (구현 정의).
   Ada.Text_IO.put_line ("Address of my_variable is: " & System.Address'image (address_of_var));
end Show_Address;

위 예제에서 my_variablealiased 키워드를 사용한 점에 주목해야 합니다. 로컬 변수에 대해 'Address 속성을 안전하게 사용하기 위해서는, 해당 변수가 주소를 통해 참조될 수 있음을 컴파일러에게 알리는 aliased를 명시하는 것이 좋습니다. 이는 컴파일러가 해당 변수를 레지스터에만 저장하는 등의 최적화를 수행하여 주소가 유효하지 않게 되는 상황을 방지합니다.

주소 연산: System.Storage_Elements와의 연동

System.Address가 정수가 아니라면, 포인터 산술 연산(예: 특정 주소로부터 16바이트 떨어진 위치 계산)은 어떻게 수행해야 할까요?

Ada는 이러한 연산을 위해 System.Storage_Elements 패키지에 정의된 Storage_Offset 타입과 관련 연산자들을 사용하도록 강제합니다. Storage_Offset은 두 주소 간의 거리, 즉 바이트 단위의 오프셋을 나타내는 정수 타입입니다.

System.Storage_ElementsAddressStorage_Offset을 위한 다음과 같은 연산자들을 오버로딩하여 제공합니다.

  • function "+"(Left : Address; Right : Storage_Offset) return Address;
  • function "-"(Left : Address; Right : Storage_Offset) return Address;
  • function "-"(Left, Right : Address) return Storage_Offset;

이러한 설계는 주소 연산에 강력한 타입 검사를 적용합니다. 프로그래머는 임의의 정수를 주소에 더하는 대신, Storage_Offset이라는 명확한 의미를 가진 타입을 사용해야만 합니다.

with System;
with System.Storage_Elements;

procedure Address_Arithmetic is
   use System;
   use System.Storage_Elements;

   buffer       : aliased Storage_Array (1 .. 1024);
   base_address : constant Address := buffer'Address;
   offset       : constant Storage_Offset := 100;
   target_address : Address;
begin
   -- buffer의 시작 주소에서 100바이트 떨어진 주소를 계산합니다.
   target_address := base_address + offset;
end Address_Arithmetic;

결론적으로, System.Address는 메모리 주소에 대한 추상적이고 안전한 표현을 제공하며, 'Address는 Ada 객체와 물리적 메모리를 연결하는 통로 역할을 합니다. 그리고 모든 주소 연산은 Storage_Offset을 통해 타입 안전성을 보장하는 방식으로 수행됩니다. 이처럼 Ada는 저수준 프로그래밍 영역에서조차 프로그래머의 실수를 방지하기 위한 체계적인 안전장치를 마련해 두고 있습니다.

24.2.3 표현식 절과 객체 레이아웃 제어 (개요)

Ada 컴파일러는 프로그래머가 레코드(record)와 같은 복합 타입을 선언했을 때, 해당 타입의 객체를 메모리에 배치하는 방식, 즉 메모리 레이아웃(memory layout)을 결정할 재량을 가집니다. 일반적으로 컴파일러는 대상 CPU 아키텍처에서 가장 효율적으로 데이터에 접근할 수 있도록 필드의 순서를 재배치하거나, 정렬(alignment)을 맞추기 위해 필드 사이에 패딩(padding) 바이트를 삽입하는 등의 최적화를 수행합니다.

이러한 컴파일러의 자율성은 대부분의 응용 프로그램에서 바람직하지만, Ada 프로그램이 외부 세계와 정해진 이진(binary) 규격에 따라 상호작용해야 할 때는 문제가 됩니다. 예를 들어,

  • 하드웨어 제어: 마이크로컨트롤러의 특정 제어 레지스터는 메모리의 고정된 주소에 위치하며, 각 비트(bit) 필드가 특정한 기능을 제어하도록 약속되어 있습니다.
  • 네트워크 프로토콜: TCP/IP 헤더와 같은 네트워크 패킷의 구조는 플랫폼에 상관없이 모든 시스템이 동일하게 해석해야 하는 엄격한 비트 단위 규격을 따릅니다.
  • 파일 형식: 이미지 파일이나 데이터베이스 파일과 같은 이진 파일 형식은 데이터를 저장하는 구조가 사전에 정확히 정의되어 있습니다.

이러한 경우, 프로그래머는 컴파일러의 기본 레이아웃 결정에 의존할 수 없으며, 타입의 메모리 표현을 비트 수준까지 직접 제어할 수 있어야 합니다. Ada는 이러한 저수준 제어 요구사항을 충족시키기 위해 표현식 절(Representation Clause) 이라는 표준화된 기능을 제공합니다.

표현식 절은 Ada 타입이나 객체의 논리적인 선언과 그 물리적인 표현(메모리 레이아웃)을 연결하는 언어 구문입니다. Ada 2022 레퍼런스 매뉴얼 13절에 상세히 기술된 이 기능들을 통해, 프로그래머는 컴파일러의 기본 동작을 재정의(override)하고 데이터의 표현 방식을 직접 명시할 수 있습니다.

본 절에서는 주요 표현식 절의 개념을 간략히 소개합니다.

  • 레코드 표현식 절 (for ... use record ...): 레코드의 각 필드가 레코드 시작점으로부터 정확히 어느 위치(바이트 오프셋)의 몇 번째 비트부터 몇 비트를 차지할지를 지정합니다. 이는 하드웨어 레지스터나 통신 프로토콜 헤더를 모델링하는 가장 직접적이고 강력한 방법입니다.

  • 주소 절 (for ...'Address use ...): 변수와 같은 객체를 특정 메모리 주소에 직접 배치하도록 지정합니다. 이는 메모리 맵 입출력(Memory-Mapped I/O)을 통해 하드웨어 레지스터에 직접 접근하는 데 필수적인 기능입니다.

  • 크기 절 (for ...'Size use ...): 특정 타입의 객체가 차지해야 할 정확한 크기를 비트 단위로 지정합니다.

  • 정렬 절 (for ...'Alignment use ...): 특정 타입의 객체가 메모리에 배치될 때 준수해야 할 정렬 경계(alignment boundary)를 지정합니다.

  • 열거형 표현식 절 (for ... use (...)): 열거형 타입의 각 리터럴(literal)이 어떤 내부 정수 값에 대응되는지를 명시적으로 지정합니다. 이는 외부 시스템과 약속된 상태 코드를 교환할 때 사용됩니다.

이러한 표현식 절들은 Ada를 단순한 고수준 언어를 넘어, 하드웨어와 가장 가까운 수준의 시스템 프로그래밍까지 포괄할 수 있게 만드는 핵심적인 기능입니다. System 패키지가 원시 주소와 메모리를 다루는 도구를 제공한다면, 표현식 절은 그 위에 구조화된 데이터 타입을 정밀하게 얹을 수 있는 틀을 제공합니다.

24.3 표준 인터페이스: System.Storage_Pools 패키지

앞선 절들에서 우리는 사용자 정의 메모리 관리의 필요성을 인지하고(22.1), System 패키지를 통해 원시 메모리와 주소를 조작하는 저수준 도구들을 학습했습니다(22.2). 이제 남은 과제는 “어떻게 우리가 직접 만든 메모리 관리 알고리즘을 Ada의 new 연산자와 연결할 것인가?” 입니다. 만약 표준화된 방법이 없다면, 모든 개발자가 각자의 비표준적인 방식으로 이 연결을 시도하게 되어 코드의 이식성과 일관성이 크게 저해될 것입니다.

이러한 문제를 해결하기 위해, Ada는 System.Storage_Pools 라는 표준 라이브러리 패키지를 제공합니다. 이 패키지는 특정 메모리 관리 알고리즘의 ‘구현’을 제공하는 것이 아니라, 모든 사용자 정의 메모리 관리자가 준수해야 할 **표준 인터페이스(standard interface)**를 정의합니다. 즉, Ada의 new 연산자가 어떤 메모리 풀(memory pool)과도 일관된 방식으로 상호작용할 수 있도록 하는 객체 지향적인 프레임워크를 제공하는 것입니다.

이 설계의 핵심은 제어의 역전(Inversion of Control) 입니다. 프로그래머는 런타임 시스템을 호출하는 대신, 런타임 시스템이 호출할 수 있는 표준화된 연산들을 구현합니다. Ada 2022 레퍼런스 매뉴얼 13.11절에 상세히 기술된 이 메커니즘의 중심에는 다음과 같은 요소들이 있습니다.

Root_Storage_Pool 추상 태그드 타입

System.Storage_Pools 패키지는 Root_Storage_Pool이라는 abstract tagged limited private 타입을 정의합니다. 이것이 모든 스토리지 풀의 최상위 부모 클래스 역할을 합니다. 사용자 정의 메모리 관리자를 만들기 위해서는, 반드시 이 Root_Storage_Pool 타입을 상속받는 새로운 태그드 타입을 정의해야 합니다.

type My_Pool_Type is new Root_Storage_Pool with record
   -- 풀 관리에 필요한 내부 데이터 (예: 메모리 버퍼, 프리 리스트 등)
   ...
end record;

재정의(Override)가 필요한 핵심 연산

Root_Storage_Pool은 메모리 관리자의 기본 동작을 나타내는 몇 가지 핵심적인 추상(abstract) 서브프로그램을 프리미티브 연산으로 가집니다. 사용자는 자신의 풀 타입을 정의할 때, 이 서브프로그램들을 반드시 재정의하여 구체적인 메모리 관리 알고리즘을 구현해야 합니다.

  • procedure Allocate (Pool : in out Root_Storage_Pool; ...): 이 프로시저는 new 연산자가 호출될 때 런타임 시스템에 의해 호출됩니다. 프로시저는 요청된 메모리 크기(Size_In_Storage_Elements)를 인자로 받아, 자신의 풀에서 해당 크기의 메모리 블록을 찾아 그 시작 주소(Address)를 반환해야 합니다.

  • procedure Deallocate (Pool : in out Root_Storage_Pool; ...): Ada.Unchecked_Deallocation이 호출될 때 런타임에 의해 호출됩니다. 해제할 메모리 블록의 주소를 인자로 받아, 해당 블록을 자신의 풀에 반납하는 로직을 구현해야 합니다.

  • function Storage_Size (Pool : Root_Storage_Pool) return ...: 해당 풀이 관리하는 전체 메모리의 크기를 반환합니다.

'Storage_Pool 속성을 이용한 연결

마지막으로, 이렇게 구현된 사용자 정의 스토리지 풀을 특정 접근 타입과 연결하기 위해 'Storage_Pool 속성을 사용합니다.

type My_Data is record ... end record;
type My_Data_Access is access My_Data;

my_pool : My_Pool_Type; -- 우리가 구현한 풀 타입의 객체

for My_Data_Access'Storage_Pool use my_pool;

위의 for ... use 구문이 실행된 이후부터, new My_Data와 같은 할당 요청은 더 이상 기본 힙을 사용하지 않습니다. 대신, Ada 런타임은 my_pool 객체의 Allocate 프로시저를 자동으로 호출하여 메모리를 할당받게 됩니다.

본 절에서는 이 System.Storage_Pools 인터페이스의 각 구성 요소를 상세히 분석하고, 이들이 어떻게 상호작용하여 Ada의 고수준 new 연산자와 프로그래머가 정의한 저수준 메모리 관리 로직을 연결하는지 학습할 것입니다. 이는 이 장의 최종 목표인 완전한 사용자 정의 메모리 관리자 구현을 위한 마지막 이론적 기반이 될 것입니다.

24.3.1 Root_Storage_Pool 추상 타입의 역할

System.Storage_Pools 패키지가 제공하는 사용자 정의 메모리 관리 프레임워크는 Ada의 객체 지향(Object-Oriented) 기능을 기반으로 설계되었습니다. 그 중심에는 모든 스토리지 풀(storage pool)의 최상위 부모 클래스 역할을 하는 Root_Storage_Pool 타입이 존재합니다.

Ada 2022 레퍼런스 매뉴얼 13.11.1절에 정의된 이 타입의 선언은 그 자체로 이 프레임워크의 핵심적인 설계 사상을 담고 있습니다.

package System.Storage_Pools is
   type Root_Storage_Pool is abstract tagged limited private;
   ...

이 선언에 포함된 각 키워드의 의미와 역할을 분석하면 다음과 같습니다.

  • abstract (추상): Root_Storage_Pool추상 타입입니다. 이는 프로그래머가 My_Pool : Root_Storage_Pool; 와 같이 이 타입의 객체를 직접 생성할 수 없음을 의미합니다. 이 타입의 유일한 목적은 다른 구체적인(concrete) 스토리지 풀 타입이 상속받을 부모로서, 모든 스토리지 풀이 반드시 구현해야 하는 최소한의 인터페이스(interface), 즉 연산의 목록을 정의하는 것입니다.

  • tagged (태그드): 이 키워드는 Root_Storage_Pool이 객체 지향 프로그래밍의 대상이 됨을 명시합니다. tagged이므로, 프로그래머는 type My_Pool is new Root_Storage_Pool with ... 구문을 사용하여 이 타입을 확장(상속)할 수 있습니다. 더 중요한 것은, AllocateDeallocate와 같은 이 타입의 프리미티브 연산(primitive operation)들이 다형적으로(polymorphically) 동작할 수 있다는 점입니다. Ada 런타임 시스템은 Root_Storage_Pool 타입의 객체를 통해, 실제 객체의 구체적인 타입(예: My_Pool)에 맞게 재정의(override)된 Allocate 프로시저를 동적으로 호출(dynamic dispatch)할 수 있습니다.

  • limited (제한): Root_Storage_Pool 타입과 이를 상속받는 모든 자식 타입은 제한 타입입니다. 이는 대입(:=) 연산이 금지됨을 의미합니다. 스토리지 풀은 특정 메모리 영역을 독점적으로 관리하는 상태를 가진(stateful) 자원 관리자입니다. 만약 이러한 풀 객체를 단순 대입으로 복사한다면, 두 개의 풀 객체가 동일한 메모리 영역을 동시에 관리하게 되어 예측할 수 없는 오류를 유발할 것입니다. limited 키워드는 이러한 위험한 연산을 컴파일 시점에 원천적으로 차단하여 각 스토리지 풀 객체의 유일성과 무결성을 보장합니다.

  • private (사유): Root_Storage_Pool 타입의 내부 구현은 System.Storage_Pools 패키지 외부에는 숨겨집니다. 이는 클라이언트가 풀의 내부 구조에 의존하지 않도록 하는 정보 은닉의 원칙을 따릅니다.

인터페이스 계약(Interface Contract)으로서의 역할

결론적으로, Root_Storage_Pool은 하나의 인터페이스 계약으로 기능합니다. 사용자 정의 메모리 관리자를 만들고자 하는 프로그래머는 이 계약에 서명해야 하며, 이는 다음 두 가지 의무를 가집니다.

  1. 정의하려는 풀 타입이 반드시 Root_Storage_Pool을 상속받아야 합니다.
  2. 부모 타입의 추상 프리미티브 연산인 Allocate, Deallocate, Storage_Size를 반드시 구체적으로 재정의하여 구현해야 합니다.

이 계약을 준수하면, Ada 런타임 시스템은 프로그래머가 만든 어떤 종류의 메모리 관리자라도 이 표준화된 인터페이스를 통해 일관되게 상호작용할 것을 보장합니다. 즉, new 연산자는 Root_Storage_Pool'Class 타입의 객체를 통해, 실제로는 프로그래머가 작성한 특정 풀의 Allocate 메서드를 동적으로 호출하게 됩니다.

이처럼 Root_Storage_Pool은 그 자체로 사용되는 타입이 아니라, Ada의 고수준 언어 기능과 프로그래머가 정의한 저수준 메모리 관리 로직을 잇는, 타입 안전하고 확장 가능한 객체 지향 프레임워크의 초석입니다.

24.3.2 핵심 연산: Allocate, Deallocate, Storage_Size

Root_Storage_Pool 타입은 그 자체로 완전한 기능이 아닌, 모든 구체적인(concrete) 스토리지 풀이 구현해야 할 **추상 연산(abstract operation)**의 목록을 정의하는 계약(contract)입니다. 프로그래머는 Root_Storage_Pool을 상속받는 새로운 타입을 만들 때, 반드시 이 세 가지 핵심 프리미티브 연산을 재정의(override)하여 자신만의 메모리 관리 로직을 구현해야 합니다.

이 연산들은 Ada 런타임 시스템이 new 연산자나 Unchecked_Deallocation 프로시저와 같은 언어의 표준 기능을 사용자 정의 메모리 관리 로직과 연결하는 표준화된 호출 지점(hook) 역할을 합니다.

procedure Allocate

Allocate 프로시저는 사용자 정의 스토리지 풀의 가장 핵심적인 부분으로, 실제 메모리 할당을 수행합니다. Ada 2022 레퍼런스 매뉴얼에 정의된 이 추상 프로시저의 명세는 다음과 같습니다.

procedure Allocate
  (Pool            : in out Root_Storage_Pool'Class;
   Storage_Address : out System.Address;
   Size_In_Storage_Elements : in  System.Storage_Elements.Storage_Count;
   Alignment       : in  System.Storage_Elements.Storage_Count) is abstract;
  • 호출 시점: 이 프로시저는 해당 풀과 연관된 접근 타입에 대해 new 연산자가 평가될 때 Ada 런타임 시스템에 의해 암묵적으로 호출됩니다.
  • 파라미터의 역할:
    • Pool: 연산이 수행될 대상 풀 객체입니다. in out 모드인 이유는 할당 과정에서 풀의 내부 상태(예: 다음 할당 가능 위치, 여유 공간 크기 등)가 변경되기 때문입니다.
    • Storage_Address: 프로시저의 주된 출력입니다. 구현부는 할당에 성공한 메모리 블록의 시작 주소를 이 파라미터에 저장하여 반환해야 합니다. 할당에 실패한 경우, 반드시 System.Null_Address를 반환해야 하며, 이 경우 런타임은 Storage_Error 예외를 발생시킵니다.
    • Size_In_Storage_Elements: 런타임이 요청하는 메모리의 크기(바이트 단위)입니다. 구현부는 이 크기를 만족하는 연속된 메모리 블록을 찾아야 합니다.
    • Alignment: 요청된 데이터 타입이 요구하는 정렬(alignment) 값입니다. 구현부는 이 정렬 요구사항을 만족하는 주소를 반환해야 합니다 (예: 4바이트 정렬이 필요하면 주소는 4의 배수여야 함).
  • 구현 책임: 프로그래머는 Pool의 내부 자료구조를 탐색하여 SizeAlignment 요구사항을 만족하는 가용 메모리 블록을 찾고, 그 블록을 ‘사용 중’으로 표시한 뒤, 시작 주소를 Storage_Address를 통해 반환하는 로직을 작성해야 합니다.

procedure Deallocate

Deallocate 프로시저는 이전에 Allocate를 통해 할당되었던 메모리 블록을 풀에 반납하는 역할을 합니다.

procedure Deallocate
  (Pool            : in out Root_Storage_Pool'Class;
   Storage_Address : in  System.Address;
   Size_In_Storage_Elements : in  System.Storage_Elements.Storage_Count;
   Alignment       : in  System.Storage_Elements.Storage_Count) is abstract;
  • 호출 시점: Ada.Unchecked_Deallocation의 인스턴스가 해당 풀에서 할당된 객체를 가리키는 접근 값에 대해 호출될 때, 런타임 시스템에 의해 암묵적으로 호출됩니다.
  • 파라미터의 역할:
    • Storage_Address: 풀에 반납할 메모리 블록의 시작 주소입니다.
    • Size_In_Storage_Elements, Alignment: 해당 블록이 처음 Allocate될 때 사용되었던 크기와 정렬 값입니다. 이 정보는 일부 메모리 관리 알고리즘(예: 크기별로 자유 공간 리스트를 관리하는 경우)에서 유용하게 사용될 수 있습니다.
  • 구현 책임: 프로그래머는 Storage_Address가 가리키는 메모리 블록을 다시 ‘사용 가능’ 상태로 만들고, 풀의 내부 자료구조(예: 자유 공간 리스트)에 반납하는 로직을 작성해야 합니다.

function Storage_Size

Storage_Size 함수는 해당 풀이 관리하는 전체 메모리의 크기를 반환합니다.

function Storage_Size (Pool : Root_Storage_Pool'Class)
  return System.Storage_Elements.Storage_Count is abstract;
  • 호출 시점: 해당 풀과 연관된 접근 타입에 'Storage_Size 속성을 사용할 때 명시적으로 호출될 수 있습니다.
  • 구현 책임: 프로그래머는 해당 풀 객체가 생성될 때 할당받은 전체 메모리 영역의 크기를 바이트 단위로 반환하도록 구현해야 합니다.

이 세 가지 핵심 연산을 재정의함으로써, 프로그래머는 Ada의 표준 메모리 할당 메커니즘을 자신이 직접 작성한 코드로 완전히 대체할 수 있습니다. 이는 고신뢰성 시스템의 요구사항을 만족시키기 위한 예측 가능하고 안정적인 메모리 관리 전략을 구현하는 표준화된 방법론을 제공합니다.

24.3.3 풀(Pool)과 접근 타입의 관계

앞선 절들에서 사용자 정의 메모리 관리자를 Root_Storage_Pool을 상속받는 타입으로 구현하는 방법을 학습했습니다. 그러나 이처럼 잘 정의된 풀(pool) 객체를 만드는 것만으로는 충분하지 않습니다. Ada의 new 연산자가 기본 힙(heap) 대신 우리가 만든 특정 풀 객체를 사용하도록 명시적으로 연결해주는 과정이 반드시 필요합니다.

이 연결을 설정하는 표준 메커니즘이 바로 접근 타입에 대한 'Storage_Pool 속성입니다.

'Storage_Pool 속성은 특정 접근 타입(access type)이 사용할 스토리지 풀 객체를 지정하는 데 사용됩니다. 이 속성의 값은 for ... use 구문을 통해 설정되며, 이는 일종의 표현식 절(representation clause)로 취급됩니다.

'Storage_Pool 속성을 이용한 연결

연결을 위한 구문은 다음과 같습니다.

for <Access_Type_Name>'Storage_Pool use <Pool_Object_Access>;
  • <Access_Type_Name>: 스토리지 풀을 지정할 접근 타입의 이름입니다.
  • <Pool_Object_Access>: 사용할 스토리지 풀 객체를 가리키는 접근 값(access value)입니다. 여기서 중요한 점은 타입의 이름이 아닌, 실제 메모리에 존재하는 특정 풀 객체를 지정한다는 것입니다.

이 선언이 정교화(elaborated)되고 나면, 해당 접근 타입(Access_Type_Name)에 대한 모든 new 연산자는 더 이상 기본 힙을 사용하지 않습니다. 대신, Ada 런타임은 <Pool_Object_Access>가 가리키는 풀 객체의 Allocate 프로시저를 암묵적으로 호출하여 메모리를 할당받게 됩니다.

예제: 접근 타입과 스토리지 풀 연결

다음은 My_Pool이라는 사용자 정의 풀을 Data_Access라는 접근 타입과 연결하는 전체 과정을 보여주는 예제입니다.

1. 스토리지 풀과 데이터 타입 정의

with System.Storage_Pools;
with Ada.Text_IO;

-- (개념 설명을 위해 Allocate 프로시저의 구현은 간략화)
package My_Pool_Package is
   type My_Pool is new System.Storage_Pools.Root_Storage_Pool with null record;

   overriding procedure Allocate
     (Pool            : in out My_Pool;
      Storage_Address : out System.Address;
      Size_In_Storage_Elements : in  System.Storage_Elements.Storage_Count;
      Alignment       : in  System.Storage_Elements.Storage_Count);
   -- Deallocate, Storage_Size 등도 구현 필요
end My_Pool_Package;

package body My_Pool_Package is
   overriding procedure Allocate (...) is
   begin
      Ada.Text_IO.put_line ("Custom Allocate called!");
      -- 실제 할당 로직... (여기서는 생략)
      Storage_Address := System.Null_Address; -- 예제이므로 실제 할당은 안 함
   end Allocate;
end My_Pool_Package;

-- 풀을 사용할 데이터 타입 및 접근 타입
type Data_Record is record
   id   : Integer;
   name : String (1 .. 10);
end record;

type Data_Access is access Data_Record;

2. 풀 객체 생성 및 연결

procedure Test_Custom_Pool is
   -- 1. 사용할 풀 객체를 생성합니다.
   --    'aliased'를 통해 주소를 얻을 수 있도록 합니다.
   custom_pool : aliased My_Pool_Package.My_Pool;

   -- 2. for...use 구문을 사용하여 접근 타입과 풀 객체를 연결합니다.
   --    'Storage_Pool 속성은 풀 객체에 대한 접근 값을 요구합니다.
   for Data_Access'Storage_Pool use custom_pool'Access;

   ptr : Data_Access;
begin
   -- 3. new 연산자를 호출합니다.
   --    이 호출은 이제 기본 힙이 아닌 custom_pool.Allocate를 실행합니다.
   ptr := new Data_Record;
end Test_Custom_Pool;

위 프로그램을 실행하면, new Data_Record가 실행되는 시점에 화면에 “Custom Allocate called!“라는 메시지가 출력될 것입니다. 이는 new 연산자의 동작이 성공적으로 재정의(override)되었음을 증명합니다.

'Storage_Size 속성

어떤 접근 타입에 'Storage_Pool이 지정되면, 관련된 'Storage_Size 속성을 사용하여 해당 풀의 전체 크기를 조회할 수 있습니다. My_Access_Type'Storage_SizeMy_Pool_Object.Storage_Size 함수를 호출한 결과와 동일한 값을 반환합니다. 이는 할당을 수행하기 전에 풀에 충분한 공간이 있는지 등을 확인하는 데 사용될 수 있습니다.

결론적으로, 'Storage_Pool 속성은 Ada의 고수준 추상화(접근 타입과 new)와 프로그래머가 정의한 저수준 구현(스토리지 풀)을 연결하는 핵심적인 ‘접착제’ 역할을 합니다. 이 명시적인 연결을 통해, 프로그래머는 프로그램의 각기 다른 부분에 대해 각기 다른 메모리 관리 전략을 선택적으로 적용하는 정교한 설계가 가능해집니다.

24.4 [구현] 고정 크기 블록 할당자 (Fixed-Size Block Allocator)

지금까지 22장에서는 사용자 정의 메모리 관리의 이론적 배경과 Ada가 제공하는 저수준 도구 및 표준 인터페이스 프레임워크(System.Storage_Pools)에 대해 학습했습니다. 이론적 지식을 실제 동작하는 코드로 구체화하는 것은 개념을 완전히 체득하는 데 필수적인 과정입니다. 본 절에서는 지금까지 배운 모든 지식을 종합하여, 처음부터 끝까지 완전한 형태의 사용자 정의 스토리지 풀을 직접 구현해 보겠습니다.

이 사례 연구를 위해 우리가 구현할 메모리 관리 전략은 고정 크기 블록 할당자(Fixed-Size Block Allocator)입니다. 이 방식은 고신뢰성 실시간 시스템에서 널리 사용되는 기법으로, 그 구조가 비교적 단순하면서도 22.1절에서 제기된 기본 힙 할당자의 두 가지 핵심 문제점, 즉 시간적 비결정성과 메모리 단편화를 효과적으로 해결합니다.

고정 크기 블록 할당자의 기본 원리는 다음과 같습니다.

  1. 초기화: 프로그램 시작 시, 비교적 큰 연속된 메모리 영역(Pool)을 단 한 번 할당받습니다.
  2. 분할: 이 큰 메모리 영역을 모두 동일한 크기를 갖는 작은 블록(block)들의 집합으로 미리 분할합니다.
  3. 자유 목록(Free List) 관리: 모든 비사용 블록들을 연결 리스트(linked list)와 같은 자료구조로 관리합니다. 이를 ‘자유 목록’이라고 합니다.
  4. 할당(Allocate): 새로운 메모리 할당 요청이 들어오면, 자유 목록의 맨 앞에서 블록 하나를 떼어내어 그 주소를 즉시 반환합니다. 여기에는 복잡한 탐색이나 분할 과정이 전혀 없습니다.
  5. 해제(Deallocate): 사용이 끝난 메모리 블록이 반납되면, 해당 블록을 다시 자유 목록의 맨 앞에 추가합니다.

이러한 접근법은 고신뢰성 시스템의 요구사항을 다음과 같이 만족시킵니다.

  • 예측 가능성 및 실시간성: 할당 및 해제 연산은 단순히 연결 리스트의 노드를 떼어내거나 붙이는 작업이므로, 매우 빠르고 결정적인(deterministic) 시간 복잡도(일반적으로 O(1))를 가집니다. 이는 경성 실시간 시스템의 시간적 제약을 만족시키는 데 이상적입니다.
  • 안정성 (단편화 해결): 모든 블록의 크기가 동일하므로, 블록을 할당하고 해제하는 과정에서 메모리 파편이 발생할 수 없습니다. 즉, 외부 단편화(external fragmentation)가 원천적으로 발생하지 않습니다.

물론 이 전략은 모든 블록의 크기가 같아야 하므로, 동일한 크기의 객체들만을 할당하는 용도로 제한된다는 명확한 한계를 가집니다. 그러나 통신 시스템의 메시지 버퍼, 자료구조의 노드 등 동일한 타입의 객체를 반복적으로 생성하고 소멸시키는 많은 현실적인 시나리오에서 매우 효과적인 솔루션입니다.

본 절에서는 System.Storage_Pools.Root_Storage_Pool을 상속받는 새로운 타입을 정의하고, System.Storage_Elements를 이용해 메모리 버퍼와 자유 목록을 구현하며, 최종적으로 Allocate, Deallocate, Storage_Size 연산을 완성하는 전 과정을 상세한 코드와 함께 설명할 것입니다.

24.4.1 설계: Storage_Array를 이용한 메모리 버퍼와 프리 리스트(Free List)

고정 크기 블록 할당자를 구현하기 위한 구체적인 설계를 시작하겠습니다. 우리의 목표는 System.Storage_Pools.Root_Storage_Pool을 상속받는 새로운 타입을 정의하고, 그 타입이 내부적으로 메모리를 관리하는 데 필요한 자료구조를 구축하는 것입니다. 이 설계는 두 가지 핵심적인 구성 요소, 즉 전체 메모리 공간을 담을 **메모리 버퍼(memory buffer)**와 할당 가능한 블록들을 추적할 **프리 리스트(free list)**를 기반으로 합니다.

1. 메모리 버퍼: Storage_Array

메모리 풀이 관리할 전체 원시 메모리 영역은 System.Storage_Elements.Storage_Array 타입의 단일 객체로 표현됩니다. 이 접근법은 다음과 같은 장점을 가집니다.

  • 연속성 보장: Storage_Array는 물리적으로 연속된 메모리 블록을 나타내므로, 단편화 문제를 고려할 필요가 없습니다.
  • 타입 중립성: Storage_Array의 요소인 Storage_Element는 타입 정보가 없는 순수한 바이트(byte)이므로, 이 버퍼 위에 어떤 종류의 데이터 타입이라도 (정렬 요구사항만 만족한다면) 배치할 수 있습니다.

우리가 설계할 스토리지 풀 타입은 이 Storage_Array를 자신의 레코드 필드로 포함하게 됩니다. 초기화 시점에 이 거대한 배열은 논리적으로 Block_Size 크기를 갖는 여러 개의 작은 조각, 즉 블록(block)으로 분할됩니다.

-- 설계 개념
package body My_Fixed_Pool is
   BLOCK_SIZE   : constant := 64;  -- 각 블록의 크기는 64바이트
   BLOCK_COUNT  : constant := 1024; -- 총 1024개의 블록
   POOL_SIZE    : constant := BLOCK_SIZE * BLOCK_COUNT;

   type My_Pool is new Root_Storage_Pool with record
      -- 풀이 관리할 전체 메모리 버퍼
      buffer : aliased Storage_Array (1 .. POOL_SIZE);
      -- ... 기타 관리 정보 ...
   end record;

2. 프리 리스트(Free List): 침입형 연결 리스트 (Intrusive Linked List)

이제 분할된 블록들 중에서 현재 사용 가능(free)한 블록들을 효율적으로 관리할 방법이 필요합니다. 이를 위해 **프리 리스트(free list)**라는 자료구조를 사용합니다. 프리 리스트는 모든 가용 블록들을 단일 연결 리스트(singly linked list) 형태로 엮어놓은 것입니다.

이때, 연결 리스트를 구현하기 위해 별도의 메모리를 할당하는 것은 메모리 관리자를 만드는 목적에 위배됩니다. 대신, 우리는 **침입형 연결 리스트(intrusive linked list)**라는 매우 효율적인 기법을 사용합니다. 이 기법은 “사용되지 않고 있는 메모리 블록 자체를 연결 리스트의 노드(node)로 재활용”하는 방식입니다.

설계는 다음과 같습니다.

  1. Free_Block 노드 정의: 연결 리스트의 노드 역할을 할 타입을 정의합니다. 이 타입은 다음 노드를 가리키는 포인터 하나만 필드로 가집니다.
  2. 포인터 타입 정의:Free_Block 노드를 가리킬 접근 타입을 정의합니다.
  3. 타입 재해석: 메모리 블록이 ‘자유로운’ 상태일 때, 해당 블록의 시작 부분을 Free_Block 타입으로 간주합니다. 즉, 블록의 첫 4바이트 또는 8바이트(포인터의 크기)를 다음 자유 블록의 주소를 저장하는 용도로 사용합니다.

이를 Ada 코드로 표현하면 다음과 같습니다.

-- 설계 개념 (계속)
private
   -- 침입형 리스트의 노드 구조
   type Free_Block;
   type Free_Block_Access is access all Free_Block;
   type Free_Block is record
      next : Free_Block_Access;
   end record;

   -- ...

   type My_Pool is new Root_Storage_Pool with record
      buffer       : aliased Storage_Array (1 .. POOL_SIZE);
      -- 프리 리스트의 시작을 가리키는 헤드 포인터
      free_list_head : Free_Block_Access := null;
   end record;

Ada.Unchecked_Conversion이나 주소 변환을 통해, Storage_Array의 특정 슬라이스의 주소(System.Address)를 Free_Block_Access 타입으로 변환할 수 있습니다. 이를 통해 비어있는 메모리 공간을 직접 연결 리스트 노드로 활용하는 것이 가능해집니다.

초기 상태 및 동작 원리

  • 초기화: 스토리지 풀이 생성될 때, buffer 배열을 BLOCK_SIZE 크기로 순차적으로 잘라냅니다. 각 블록의 시작 부분을 Free_Block_Access로 변환하고, next 필드를 설정하여 모든 블록이 하나의 긴 연결 리스트를 이루도록 만듭니다. free_list_head는 이 리스트의 첫 번째 블록을 가리킵니다.
  • Allocate 연산: free_list_head가 가리키는 블록을 리스트에서 떼어냅니다. free_list_head를 그 다음 블록으로 갱신하고, 떼어낸 블록의 주소를 반환합니다. 이 연산은 O(1) 시간 복잡도를 가집니다.
  • Deallocate 연산: 반납된 블록의 주소를 받아, 해당 블록을 free_list_head가 가리키는 새로운 첫 번째 노드로 만들고, 기존의 free_list_head를 이 새로운 노드의 next로 연결합니다. 이 연산 역시 O(1) 시간 복잡도를 가집니다.

이처럼 Storage_Array와 침입형 프리 리스트의 조합은, 메모리 오버헤드가 거의 없으면서도 매우 빠르고 결정적인 시간 성능을 제공하며, 외부 단편화를 원천적으로 제거하는 고정 크기 블록 할당자의 핵심적인 설계 기반을 이룹니다.

24.4.2 Root_Storage_Pool 타입 확장 및 연산 구현

22.4.1절에서 수립한 설계 원칙을 바탕으로, 이제 실제 Ada 코드를 작성하여 고정 크기 블록 할당자를 구현할 차례입니다. 이 과정은 System.Storage_Pools.Root_Storage_Pool을 상속받는 새로운 태그드 타입을 정의하고, Allocate, Deallocate, Storage_Size라는 세 가지 핵심 추상 연산을 구체적으로 재정의(override)하는 것을 포함합니다.

1. 패키지 명세 (fixed_size_pool.ads)

먼저, 우리의 스토리지 풀을 정의할 패키지의 명세를 작성합니다. 이 명세는 재사용성을 높이기 위해, 생성할 블록의 크기(Block_Size)와 개수(Block_Count)를 판별자(discriminant)로 받는 제네릭(generic)과 유사한 형태로 설계합니다.

-- File: fixed_size_pool.ads
with System.Storage_Pools;

package Fixed_Size_Pool is

   -- Block_Size와 Block_Count를 판별자로 받아 풀의 크기를 결정합니다.
   type Pool (Block_Size, Block_Count : Positive) is
     new System.Storage_Pools.Root_Storage_Pool with private;

   -- 현재 할당 가능한 블록의 개수를 조회하는 함수를 추가로 제공합니다.
   function free_blocks_count (p : in Pool) return Natural;

private
   -- 침입형 프리 리스트(intrusive free list)를 위한 타입 선언
   type Free_Block;
   type Free_Block_Access is access all Free_Block;
   type Free_Block is record
      next : Free_Block_Access;
   end record;

   -- Pool 타입의 전체 정의
   type Pool (Block_Size, Block_Count : Positive) is
     new System.Storage_Pools.Root_Storage_Pool with record
      buffer         : aliased System.Storage_Elements.Storage_Array
                         (1 .. Storage_Count (Block_Size * Block_Count));
      free_list_head : Free_Block_Access := null;
      free_blocks    : Natural := 0;
   end record;

end Fixed_Size_Pool;

이 명세에서 Pool 타입은 Root_Storage_Pool의 자식으로 선언되었으며, private 부분에서 실제 데이터 멤버들—메모리 버퍼(buffer), 프리 리스트의 시작점(free_list_head), 그리고 가용 블록 수(free_blocks)—을 정의합니다.

2. 패키지 본체 (fixed_size_pool.adb)

본체에서는 세 가지 핵심 연산과 초기화 로직을 구현합니다. 이 과정에서 저수준의 주소와 타입을 변환하기 위해 Ada.Unchecked_Conversion이 필수적으로 사용됩니다.

-- File: fixed_size_pool.adb
with Ada.Unchecked_Conversion;
with System.Storage_Elements;

package body Fixed_Size_Pool is

   use System;
   use System.Storage_Elements;

   -- Address를 Free_Block_Access로 변환하는 함수
   function to_free_block_access is new Ada.Unchecked_Conversion
     (Source => Address, Target => Free_Block_Access);

   -- Free_Block_Access를 Address로 변환하는 함수
   function to_address is new Ada.Unchecked_Conversion
     (Source => Free_Block_Access, Target => Address);

   -- Pool 객체가 생성될 때 호출될 초기화 프로시저
   overriding procedure Initialize (p : in out Pool) is
      current_address : Address := p.buffer'Address;
      current_node    : Free_Block_Access;
   begin
      -- 버퍼를 블록 크기만큼 잘라 프리 리스트를 구성합니다.
      for i in 1 .. p.Block_Count loop
         current_node := to_free_block_access (current_address);
         current_node.next := p.free_list_head;
         p.free_list_head := current_node;
         -- 다음 블록의 주소로 이동
         current_address := current_address + Storage_Offset (p.Block_Size);
      end loop;
      p.free_blocks := p.Block_Count;
   end Initialize;

   -- 핵심 연산 구현
   overriding procedure Allocate
     (p               : in out Pool;
      storage_address : out Address;
      size_in_storage_elements : in  Storage_Count;
      alignment       : in  Storage_Count)
   is
   begin
      if p.free_list_head = null then -- 가용한 블록이 없는 경우
         storage_address := Null_Address;
         return;
      end if;

      -- 프리 리스트의 첫 번째 블록을 떼어냅니다.
      storage_address := to_address (p.free_list_head);
      p.free_list_head := p.free_list_head.next;
      p.free_blocks := p.free_blocks - 1;
   end Allocate;

   overriding procedure Deallocate
     (p               : in out Pool;
      storage_address : in  Address;
      size_in_storage_elements : in  Storage_Count;
      alignment       : in  Storage_Count)
   is
      node_to_free : constant Free_Block_Access := to_free_block_access (storage_address);
   begin
      -- 반납된 블록을 프리 리스트의 맨 앞에 추가합니다.
      node_to_free.next := p.free_list_head;
      p.free_list_head := node_to_free;
      p.free_blocks := p.free_blocks + 1;
   end Deallocate;

   overriding function Storage_Size (p : Pool) return Storage_Count is
   begin
      return Storage_Count (p.Block_Size * p.Block_Count);
   end Storage_Size;

   function free_blocks_count (p : in Pool) return Natural is
   begin
      return p.free_blocks;
   end free_blocks_count;

end Fixed_Size_Pool;

구현 분석

  • Initialize: Pool 타입은 Ada.Finalization.Controlled를 간접적으로 상속하므로, 객체가 생성될 때 Initialize 프로시저가 자동으로 호출됩니다. 이 프로시저는 buffer를 순회하며 모든 블록을 프리 리스트에 연결하는 역할을 수행합니다.
  • Unchecked_Conversion: AddressFree_Block_Access 간의 타입 변환은 이 저수준 메모리 관리자의 핵심입니다. 프로그래머는 이 변환이 안전함을 스스로 보증해야 합니다. 예를 들어, Allocate에서는 Free_Block_AccessAddress로 변환하여 사용자에게 전달하고, Deallocate에서는 사용자가 반납한 Address를 다시 Free_Block_Access로 변환하여 프리 리스트에 연결합니다.
  • Allocate / Deallocate: 두 연산 모두 프리 리스트의 헤드(head) 포인터를 조작하는 단순한 연산으로 구성되어 있으므로, 실행 시간이 매우 빠르고 일정(O(1))합니다.
  • Storage_Size: 풀의 전체 크기는 판별자로부터 쉽게 계산되므로, 간단히 곱셈 결과를 반환합니다.

이 구현은 Ada의 객체 지향 기능과 저수준 메모리 조작 기능을 결합하여, 고신뢰성 시스템의 요구사항을 만족하는 예측 가능하고 안정적인 메모리 할당자를 만드는 전 과정을 보여줍니다.

24.4.3 [심화] 스레드 안전성(Thread-Safety) 고려사항

지금까지 우리가 구현한 Fixed_Size_Pool 패키지는 단일 제어 스레드(single thread of control), 즉 Ada의 관점에서는 단일 태스크(single task) 환경에서만 안전하게 동작합니다. 만약 두 개 이상의 태스크가 동일한 풀 객체에 동시에 접근하여 AllocateDeallocate 프로시저를 호출한다면, **경쟁 조건(race condition)**이 발생하여 풀의 내부 자료구조가 손상되고 시스템 전체가 치명적인 오류에 빠질 수 있습니다.

경쟁 조건의 발생

문제의 핵심은 프리 리스트(free list)의 헤드 포인터(free_list_head)를 조작하는 연산이 원자적(atomic)이 아니라는 점에 있습니다. Allocate 프로시저의 동작을 기계어 수준에서 분해해 보면 다음과 같은 단계로 이루어집니다.

  1. free_list_head의 현재 값을 레지스터로 읽어온다. (read)
  2. 읽어온 노드의 next 필드 값을 레지스터로 읽어온다. (read)
  3. 2단계에서 읽어온 값을 free_list_head에 다시 쓴다. (write)

만약 태스크 A가 1단계를 실행한 직후 운영체제 스케줄러에 의해 선점(preemption)되고, 태스크 B가 Allocate를 실행하여 1, 2, 3단계를 모두 마쳤다고 가정해 봅시다. 그 후 다시 태스크 A가 실행되면, 태스크 A는 자신이 처음에 읽었던 낡은(stale) free_list_head 값을 기반으로 2, 3단계를 계속 진행할 것입니다.

이러한 시나리오의 결과는 재앙적입니다.

  • 이중 할당: 두 태스크가 동일한 메모리 블록을 할당받게 됩니다.
  • 프리 리스트 손상: 프리 리스트의 연결 구조가 깨져, 이후의 모든 할당/해제 연산이 미정의 동작을 유발합니다.

해결 방안: 상호 배제(Mutual Exclusion) 구현

이 문제를 해결하기 위해서는, 프리 리스트를 조작하는 코드 영역, 즉 임계 영역(critical section)에 한 번에 오직 하나의 태스크만 진입할 수 있도록 보장하는 상호 배제(mutual exclusion) 메커니즘이 필요합니다.

Ada는 이러한 동시성 문제를 해결하기 위해 언어 차원에서 **보호 객체(protected object)**라는 매우 강력하고 효율적인 동기화 장치를 제공합니다.

가장 직접적이고 Ada다운(Ada-esque) 해결책은, 스레드에 안전하지 않은 기존의 Fixed_Size_Pool.Pool 객체를 보호 객체 내부에 캡슐화하는 것입니다.

설계 개념: 보호 객체를 이용한 래퍼(Wrapper)

with Fixed_Size_Pool; -- 22.4.2에서 구현한 스레드 비안전 풀

package Thread_Safe_Fixed_Size_Pool is

   -- 생성자 역할을 할 프로시저
   procedure Create_Pool
     (Block_Size, Block_Count : Positive;
      Pool : out Thread_Safe_Pool_Access);

   -- ...

protected type Thread_Safe_Pool is

   -- 인터페이스는 Root_Storage_Pool의 연산과 유사하게 정의
   procedure Allocate
     (Storage_Address : out System.Address;
      Size_In_Storage_Elements : in  System.Storage_Elements.Storage_Count;
      Alignment       : in  System.Storage_Elements.Storage_Count);

   procedure Deallocate
     (Storage_Address : in  System.Address;
      Size_In_Storage_Elements : in  System.Storage_Elements.Storage_Count;
      Alignment       : in  System.Storage_Elements.Storage_Count);

private
   -- 스레드에 안전하지 않은 풀 객체를 내부에 데이터 멤버로 포함
   internal_pool : Fixed_Size_Pool.Pool (1, 1); -- 판별자는 나중에 초기화
end Thread_Safe_Pool;

type Thread_Safe_Pool_Access is access all Thread_Safe_Pool;

Thread_Safe_Pool 보호 객체의 본체(body)에서, AllocateDeallocate 보호 프로시저들은 내부적으로 internal_pool 객체의 해당 연산을 호출합니다. 보호 프로시저에 진입하는 것 자체가 Ada 런타임에 의해 상호 배제가 보장되므로, internal_pool의 프리 리스트 조작은 이제 여러 태스크로부터 안전하게 보호됩니다.

이처럼 보호 객체로 감싸는(wrapping) 것은 기존의 스레드 비안전(non-thread-safe) 자료구조를 스레드 안전하게 만드는 Ada의 표준적인 설계 패턴입니다.

고급 기법: 락-프리(Lock-Free) 프로그래밍

최고 수준의 성능이 요구되는 다중 코어(multi-core) 시스템에서는, 단일 락(lock)을 사용하는 보호 객체가 경합(contention)으로 인한 성능 병목이 될 수도 있습니다. 이러한 극단적인 시나리오에서는, 락을 사용하지 않고 원자적(atomic) 기계 명령어를 통해 자료구조를 직접 조작하는 락-프리(lock-free) 알고리즘을 고려할 수 있습니다. 이는 System.Atomic_Operations 패키지나 인라인 어셈블리를 통해 ‘Compare-and-Swap’(CAS)과 같은 연산을 구현하여 프리 리스트의 헤드 포인터를 갱신하는 방식입니다.

그러나 락-프리 프로그래밍은 극도로 복잡하고 미묘한 오류를 유발하기 쉬우므로, 최고 수준의 전문가에 의해서만 시도되어야 합니다. 대부분의 다중 태스크 환경에서는 보호 객체를 이용한 상호 배제 방식이 안정성과 성능 사이의 가장 합리적인 균형점을 제공합니다.

24.5 사용자 정의 풀 바인딩 및 활용

앞선 절들에서는 고신뢰성 시스템의 요구사항을 만족시키는 Fixed_Size_Pool이라는 완전한 기능의 사용자 정의 메모리 관리자를 성공적으로 설계하고 구현했습니다. 그러나 이 스토리지 풀은 그 자체로는 독립된 패키지일 뿐, 아직 Ada의 표준 메모리 할당 메커-니즘과 연결되어 있지는 않습니다.

즉, 프로그래머가 new 연산자를 사용할 때, Ada 런타임이 기본 힙 대신 우리가 만든 이 특정 풀을 사용하도록 명시적으로 지시하는 마지막 연결 단계가 필요합니다. 이 과정을 풀 바인딩(pool binding)이라고 합니다.

본 절에서는 이 마지막 단계를 수행하는 방법과, 이를 통해 특정 애플리케이션 시나리오의 문제를 해결하는 활용 사례를 학습합니다. 이 과정은 22.3.3절에서 개념적으로 소개했던 'Storage_Pool 속성을 실제로 적용하는 것입니다.

for <Access_Type_Name>'Storage_Pool use <Pool_Object_Access>;

위 구문을 통해 특정 접근 타입에 대한 모든 메모리 할당 및 해제 요청이, 지정된 풀 객체의 AllocateDeallocate 프로시저로 재지향(redirect)됩니다. 이 강력한 기능을 통해 프로그래머는 애플리케이션의 필요에 따라 메모리 관리 전략을 타입별로 다르게 적용하는 정교한 설계가 가능해집니다.

예를 들어, 실시간성이 중요한 태스크가 사용하는 데이터 타입은 직접 구현한 결정적 시간 성능의 메모리 풀에 바인딩하고, 상대적으로 덜 중요한 백그라운드 태스크가 사용하는 데이터 타입은 여전히 편리한 기본 힙을 사용하도록 할 수 있습니다.

본 활용 절에서는, 네트워크 통신 시스템에서 수많은 고정 크기 메시지 패킷을 처리해야 하는 구체적인 시나리오를 설정할 것입니다. 그리고 이 시나리오의 성능과 안정성 문제를 해결하기 위해, 우리가 직접 구현한 Fixed_Size_Pool을 해당 메시지 패킷의 접근 타입에 바인딩하는 완전한 예제 코드를 작성하고 분석할 것입니다.

이 최종 실습을 통해 독자께서는 다음의 전 과정을 관통하는 경험을 하게 될 것입니다.

  1. 문제 분석 및 사용자 정의 메모리 관리의 필요성 도출
  2. 저수준 도구를 이용한 메모리 풀의 설계 및 구현
  3. 'Storage_Pool 속성을 이용한 풀의 바인딩
  4. 최종 애플리케이션에서의 성능 및 안정성 향상 확인

이는 Ada가 제공하는 저수준 프로그래밍 기능들이 어떻게 결합되어, 고수준의 언어적 추상성을 유지하면서도 시스템의 핵심 동작을 프로그래머의 의도대로 완벽하게 제어할 수 있게 하는지를 보여주는 여정이 될 것입니다.

24.5.1 접근 타입에 사용자 정의 풀 지정하기 (for'Storage_Pool)

사용자 정의 스토리지 풀의 구현을 완료한 후, 이 풀을 실제 메모리 할당에 사용하도록 Ada 런타임에 지시하는 마지막 단계는 특정 접근 타입(access type)과 생성된 풀 객체(pool object)를 **바인딩(binding)**하는 것입니다. 이 명시적인 연결은 'Storage_Pool 속성에 대한 속성 표현식 절(attribute representation clause)을 통해 이루어집니다.

이 구문은 특정 접근 타입에 대한 모든 new 연산자의 동작을 재정의하도록 컴파일러와 런타임 시스템에 알리는 표준적이고 타입 안전한 방법입니다.

for'Storage_Pool 구문과 의미

'Storage_Pool 속성을 설정하는 구문은 다음과 같습니다.

for <Access_Type_Name>'Storage_Pool use <Pool_Object_Access>;
  • <Access_Type_Name>: 사용자 정의 풀을 사용하도록 지정할 접근 타입의 이름입니다.
  • 'Storage_Pool: 대상이 되는 속성입니다. 이 속성은 System.Storage_Pools.Pool_Access 타입, 즉 Root_Storage_Pool 클래스를 가리키는 접근 타입입니다.
  • use <Pool_Object_Access>: 이 속성에 값을 할당하는 부분입니다. 여기서 <Pool_Object_Access>는 반드시 스토리지 풀 객체를 가리키는 **접근 값(access value)**이어야 합니다. 풀 객체 자체를 사용하는 것이 아니라, 풀 객체에 대한 'Access를 전달하는 점에 유의해야 합니다.

이 선언이 정교화(elaborated)된 시점부터, <Access_Type_Name>에 대한 모든 new 할당은 더 이상 기본 힙을 사용하지 않고, <Pool_Object_Access>가 가리키는 풀 객체의 Allocate 프로시저를 호출하게 됩니다. 마찬가지로, 해당 접근 타입에 대한 Ada.Unchecked_Deallocation은 지정된 풀 객체의 Deallocate 프로시저를 호출하게 됩니다.

활용 예제: 고정 크기 풀 바인딩

22.4.2절에서 구현한 Fixed_Size_Pool을 특정 데이터 타입과 연결하는 완전한 예제는 다음과 같습니다. 통신 시스템에서 고정 크기(128 바이트)의 메시지 패킷을 처리하는 상황을 가정합니다.

with Ada.Text_IO;
with Ada.Unchecked_Deallocation;
with Fixed_Size_Pool; -- 22.4.2에서 구현한 패키지
with System;

procedure Test_Packet_Processing is

   PACKET_SIZE : constant := 128;

   -- 1. 풀에서 할당할 데이터 타입을 정의합니다.
   type Packet is record
      id      : Integer;
      payload : String (1 .. 120);
   end record;
   -- 크기를 블록 사이즈에 맞도록 설계
   for Packet'Size use PACKET_SIZE * 8;

   -- 2. 해당 데이터 타입을 가리킬 접근 타입을 선언합니다.
   type Packet_Access is access Packet;

   -- 3. 사용할 Fixed_Size_Pool 객체를 생성합니다.
   --    'aliased'를 통해 'Access 속성을 사용할 수 있도록 합니다.
   packet_pool : aliased Fixed_Size_Pool.Pool (
      Block_Size  => PACKET_SIZE,
      Block_Count => 100);

   -- 4. 'Storage_Pool 속성을 사용하여 접근 타입과 풀 객체를 바인딩합니다.
   for Packet_Access'Storage_Pool use packet_pool'Access;

   -- 5. Unchecked_Deallocation을 위한 인스턴스를 생성합니다.
   procedure Free is new Ada.Unchecked_Deallocation (Packet, Packet_Access);

   ptr1, ptr2 : Packet_Access;

begin
   Ada.Text_IO.put_line ("Allocating two packets...");
   -- 이 new 연산자들은 이제 packet_pool.Allocate를 호출합니다.
   ptr1 := new Packet'(id => 1, payload => (others => 'A'));
   ptr2 := new Packet'(id => 2, payload => (others => 'B'));

   Ada.Text_IO.put_line ("Deallocating two packets...");
   -- 이 호출들은 이제 packet_pool.Deallocate를 호출합니다.
   Free (ptr1);
   Free (ptr2);

   Ada.Text_IO.put_line ("Test complete.");

exception
   when Storage_Error =>
      Ada.Text_IO.put_line ("Error: Storage_Error raised. Pool might be full.");

end Test_Packet_Processing;

이 예제는 Fixed_Size_PoolAllocate 프로시저 내부에 로그 메시지를 추가했다면, new Packet이 실행될 때 해당 로그가 출력되는 것을 통해 동작을 확인할 수 있습니다.

이처럼 for'Storage_Pool 절은 Ada의 고수준 추상화와 저수준 구현을 연결하는 마지막 고리입니다. 이 명시적인 바인딩을 통해 프로그래머는 프로그램의 각 부분에 가장 적합한 메모리 관리 전략을 선택적으로, 그리고 타입 안전성을 유지하며 적용할 수 있습니다.

24.5.2 new 연산자의 동작 변화

접근 타입에 for'Storage_Pool 절을 사용하여 사용자 정의 풀을 성공적으로 바인딩하고 나면, 해당 접근 타입에 대한 new 연산자의 동작은 근본적으로 변화합니다. 겉보기에는 ptr := new My_Type; 와 같이 동일한 구문을 사용하지만, 내부적으로 일어나는 일은 완전히 달라집니다.

이 변화의 핵심은 Ada 런타임 시스템이 메모리 할당의 책임을 기본 힙 관리자로부터 프로그래머가 지정한 특정 스토리지 풀 객체로 이관한다는 점입니다.

재지향(Redirection)된 할당 과정

for'Storage_Pool 절이 적용된 접근 타입에 대해 new 연산자가 평가될 때, Ada 런타임은 다음과 같은 순서로 동작합니다.

  1. 대상 풀 식별: 런타임은 해당 접근 타입의 'Storage_Pool 속성을 확인하여, 메모리 할당을 담당할 스토리지 풀 객체의 주소를 식별합니다.

  2. 요구사항 계산: 할당할 객체(예: My_Type)의 크기('Size)와 정렬 요구사항('Alignment)을 계산합니다. 이 값들은 바이트 단위의 Storage_Count로 변환됩니다.

  3. Allocate 프로시저 호출: 런타임은 기본 힙 관리자를 호출하는 대신, 1단계에서 식별된 풀 객체의 Allocate 프로시저를 직접 호출합니다. 이때 2단계에서 계산된 크기와 정렬 값을 Size_In_Storage_ElementsAlignment 파라미터로 전달합니다.

  4. 결과 처리:

    • 할당 성공: 만약 사용자 정의 Allocate 프로시저가 유효한 메모리 주소를 Storage_Address 출력 파라미터에 담아 반환하면, 런타임은 이 주소를 받아 객체를 초기화(필요한 경우)하고, 최종적으로 해당 타입의 접근 값을 new 연산자의 결과로 반환합니다.
    • 할당 실패: 만약 Allocate 프로시저가 할당에 실패하여 System.Null_Address를 반환하면, 런타임 시스템은 이를 감지하고 표준 예외인 Storage_Error를 발생시킵니다.

예외 처리의 일관성

여기서 주목할 매우 중요한 설계적 특징은, new 연산자를 사용하는 클라이언트 코드의 관점에서는 예외 처리 방식이 전혀 달라지지 않는다는 점입니다. 클라이언트는 메모리 할당이 기본 힙에서 일어나는지, 아니면 고도로 최적화된 사용자 정의 풀에서 일어나는지에 대해 알 필요가 없습니다. 어떤 경우든 할당 실패는 Storage_Error라는 일관된 예외로 나타납니다.

-- 이 코드는 메모리 풀의 종류와 상관없이 동일하게 동작합니다.
begin
   ptr := new Packet;
exception
   when Storage_Error =>
      -- 할당 실패 시의 복구 로직.
      -- 실패의 원인이 기본 힙의 고갈이든, 우리 풀의 공간 부족이든
      -- 클라이언트는 동일한 방식으로 처리할 수 있습니다.
      handle_allocation_failure;
end;

이러한 일관성은 추상화의 원칙을 유지시켜 줍니다. 즉, 메모리 관리 정책(어떻게 할당할 것인가)의 복잡한 구현 세부사항은 스토리지 풀 내부에 완벽하게 캡슐화되며, new 연산자를 사용하는 클라이언트는 메모리 할당 요청(무엇을 할당할 것인가)이라는 자신의 본질적인 역할에만 집중할 수 있습니다.

결론적으로, for'Storage_Pool 절은 new 연산자라는 고수준의 언어 구문에 대한 동작을 프로그래머가 직접 재정의(override)하고 대체할 수 있게 하는 강력한 메커니즘입니다. 이를 통해 Ada는 높은 수준의 코드 가독성과 추상화를 유지하면서도, 시스템의 가장 핵심적인 동작 중 하나인 동적 메모리 할당을 사용자의 요구에 맞게 완벽하게 제어할 수 있는 독보적인 능력을 제공합니다.

24.5.3 [사례] 특정 태스크 전용 메모리 풀 할당

사용자 정의 스토리지 풀의 가장 강력한 활용 사례 중 하나는, 시스템의 특정 부분, 특히 단일 태스크(single task)를 위한 전용 메모리 영역을 할당하는 것입니다. 이 설계 패턴은 다수의 태스크가 공유하는 전역 힙(global heap)으로 인해 발생하는 고질적인 동시성 문제들을 우아하게 해결하며, 고신뢰성 시스템의 핵심 요구사항인 **독립성(isolation)**과 **예측 가능성(predictability)**을 크게 향상시킵니다.

문제 시나리오: 공유 자원 경합

항공 전자 시스템에서 들어오는 센서 데이터를 고속으로 처리하는 고우선순위(high-priority) 태스크가 있다고 가정해 보겠습니다. 이 태스크는 수신된 데이터 패킷을 담을 객체를 동적으로 반복해서 생성하고, 처리한 뒤 해제해야 합니다.

만약 이 고우선순위 태스크가 시스템의 다른 저우선순위 태스크들(예: 로깅, 상태 보고)과 동일한 전역 힙을 공유한다면 다음과 같은 심각한 문제가 발생할 수 있습니다.

  1. 자원 경합 (Resource Contention): 여러 태스크가 동시에 new를 호출하면, 힙 관리자는 내부 자료구조의 일관성을 유지하기 위해 락(lock)과 같은 동기화 메커니즘을 사용해야 합니다. 이때, 저우선순위 태스크가 힙의 락을 점유하고 있는 동안 고우선순위 태스크는 메모리를 할당받지 못하고 대기해야 합니다. 이는 **우선순위 역전(priority inversion)**을 유발하여 실시간 시스템의 시간적 예측 가능성을 심각하게 훼손합니다.
  2. 메모리 고갈 및 단편화 간섭: 중요하지 않은 저우선순위 태스크가 전역 힙을 과도하게 사용하거나 심각한 단편화를 유발할 경우, 정작 중요한 고우선순위 태스크가 필요한 메모리를 할당받지 못해 실패할 수 있습니다. 즉, 한 태스크의 잘못된 메모리 사용 패턴이 시스템 전체의 안정성에 영향을 미칩니다.

해결책: 태스크 전용 로컬 풀

이 문제에 대한 가장 효과적인 해결책은, 고우선순위 태스크가 사용할 메모리를 전역 힙이 아닌, **오직 그 태스크만이 접근할 수 있는 전용 로컬 풀(local pool)**에서 할당하도록 하는 것입니다.

이 설계는 다음과 같이 구현할 수 있습니다.

  1. 데이터 처리 태스크를 task type으로 정의합니다.
  2. 태스크 타입의 선언부(declarative part)에 사용자 정의 스토리지 풀 객체를 로컬 변수로 선언합니다.
  3. 동일한 선언부 내에서, for'Storage_Pool 절을 사용하여 해당 태스크가 사용할 접근 타입을 이 로컬 풀 객체에 바인딩합니다.

이러한 구조는 for'Storage_Pool 바인딩의 유효범위(scope)가 해당 태스크 인스턴스 내부로 한정되는 Ada의 규칙을 활용합니다.

구현 예제

with Fixed_Size_Pool;
with Ada.Text_IO;
with Ada.Unchecked_Deallocation;

procedure Sensor_Processing_System is

   -- 처리할 센서 데이터 패킷
   SENSOR_DATA_SIZE : constant := 256;
   type Sensor_Data is array (1 .. SENSOR_DATA_SIZE) of Byte;
   type Sensor_Data_Access is access Sensor_Data;

   -- 고속 데이터 처리를 담당하는 태스크 타입
   task type Data_Processor is
      -- 이 태스크 타입의 각 인스턴스는 자신만의 전용 풀을 가집니다.
   end Data_Processor;

   task body Data_Processor is
      -- 1. 태스크 내부에 로컬 풀 객체를 생성합니다.
      --    이 풀은 오직 이 태스크 인스턴스에 의해서만 사용됩니다.
      processor_pool : aliased Fixed_Size_Pool.Pool (
         Block_Size  => SENSOR_DATA_SIZE,
         Block_Count => 50); -- 50개의 패킷을 저장할 수 있는 풀

      -- 2. 접근 타입을 로컬 풀에 바인딩합니다.
      --    이 바인딩은 'Data_Processor' 태스크 내부에서만 유효합니다.
      for Sensor_Data_Access'Storage_Pool use processor_pool'Access;

      -- 3. 이 태스크 전용 Deallocation 프로시저
      procedure Free is new Ada.Unchecked_Deallocation
        (Sensor_Data, Sensor_Data_Access);

      packet : Sensor_Data_Access;
   begin
      for i in 1 .. 10 loop
         -- 4. 'new'는 이제 processor_pool.Allocate를 호출합니다.
         --    다른 태스크와 락 경합이 전혀 없습니다.
         packet := new Sensor_Data;

         -- ... 패킷 처리 로직 ...
         Ada.Text_IO.put_line ("Processor Task: Packet processed.");

         Free (packet);
      end loop;
   end Data_Processor;

   -- 태스크 인스턴스 생성
   processor_1 : Data_Processor;

begin
   null; -- 메인 프로시저는 태스크가 종료될 때까지 대기
end Sensor_Processing_System;

설계의 이점 분석

  • 완벽한 독립성: processor_1 태스크의 동적 메모리 할당은 processor_pool이라는 자신만의 고립된 영역에서 일어납니다. 다른 태스크의 메모리 사용 패턴이 이 태스크의 동작에 전혀 영향을 미칠 수 없습니다.
  • 경합 제거: 이 풀은 단일 태스크에 의해 독점적으로 사용되므로, AllocateDeallocate 연산에 대한 동기화(락)가 전혀 필요 없습니다. 이는 22.4.3절에서 논의된 스레드 안전성 문제를 근본적으로 회피하여, 최고의 예측 가능성과 성능을 보장합니다.
  • 정적 분석 용이성: processor_1 태스크가 동적으로 사용할 수 있는 최대 메모리 양은 SENSOR_DATA_SIZE * 50 바이트로 명확하게 제한됩니다. 이를 통해 시스템 전체의 메모리 사용량을 정적으로 분석하고 검증하는 것이 매우 용이해집니다.

이처럼 특정 태스크에 전용 메모리 풀을 할당하는 기법은, Ada의 저수준 메모리 제어 기능이 어떻게 동시성 프로그래밍과 결합하여, 고신뢰성 시스템의 핵심 요구사항인 독립성, 예측 가능성, 그리고 안정성을 달성하는지를 보여주는 강력한 사례입니다.

24.6 [심화] 비검사 프로그래밍과 그 책임

Ada 언어의 설계 철학은 근본적으로 프로그래머의 실수를 방지하는 데 있습니다. 강력한 타입 시스템, 런타임 범위 검사, 그리고 체계적인 예외 처리 메커니즘은 모두 컴파일러와 런타임 시스템이 프로그래머의 동맹이 되어, 프로그램의 논리적 결함이 예측 불가능한 시스템 오류로 이어지는 것을 최대한 방지하는 안전망(safety net) 역할을 합니다.

그러나 언어의 추상화된 안전망은, 그 자체로 특정 공학적 목표 달성에 제약으로 작용하는 경우가 있습니다. 예를 들어, 하드웨어 레지스터에 직접 접근하거나, 특정 프로세서 명령어셋을 활용하여 성능을 최적화하거나, 혹은 외부 언어의 저수준 인터페이스와 상호작용해야 하는 요구사항은 Ada의 표준적인 타입 시스템 규칙만으로는 충족시키기 어렵습니다. 이러한 경우, 언어의 엄격한 규칙을 의도적으로 우회하는 것이 공학적으로 정당화되는 예외적 상황이 발생합니다.

Ada는 이러한 전문가 수준의 요구사항을 위해, 비검사 프로그래밍(unchecked programming) 이라는 표준화된 ‘안전 해치(escape hatch)’를 제공합니다. 이는 프로그래머가 컴파일러에게 “이 특정 연산에 대해서는, 내가 모든 안전성을 보증하니, 너의 일반적인 검사를 잠시 중단하고 내가 지시하는 대로 코드를 생성하라”고 명시적으로 지시하는 기능입니다.

Ada.Unchecked_Deallocation이나 Ada.Unchecked_Conversion과 같이 의도적으로 길고 명시적인 이름을 가진 이 기능들을 사용하는 순간, 프로그램의 정확성과 무결성에 대한 책임은 컴파일러와 런타임 시스템으로부터 전적으로 프로그래머에게 이전됩니다. 만약 프로그래머의 가정이 틀렸다면(예: 아직 사용 중인 메모리를 해제하거나, 호환되지 않는 타입으로 비트 패턴을 변환하는 경우), 그 결과는 Storage_Error와 같이 잘 정의된 예외가 아닙니다. 그 결과는 Ada가 방지하고자 했던 바로 그것, 즉 오류적인 실행(erroneous execution)과 예측 불가능한 미정의 동작(undefined behavior) 입니다.

본 절에서는 Ada가 제공하는 주요 비검사 기능들을 살펴보고, 그 강력함과 그에 수반되는 막중한 책임을 함께 고찰합니다.

  • Ada.Unchecked_Deallocation: C의 free와 같이 수동으로 동적 메모리를 해제하는 기능을 제공하며, 댕글링 포인터(dangling pointer)와 이중 해제(double-free)의 위험성을 프로그래머가 직접 관리해야 합니다.
  • Ada.Unchecked_Conversion: 한 타입의 객체가 가진 비트 패턴(bit pattern)을 전혀 다른 타입으로 재해석합니다. 이는 우리가 스토리지 풀을 구현할 때 원시 메모리 주소를 프리 리스트 포인터로 변환하는 등, 저수준 자료구조 구현에 필수적이지만, 오용될 경우 시스템을 즉시 손상시킬 수 있습니다.

이 기능들은 일상적인 프로그래밍 도구가 아닌, 고도로 숙련된 시스템 프로그래머를 위한 정밀한 수술 도구와 같습니다. 그 사용은 반드시 필요한 곳에 최소한으로 한정되어야 하며, 왜 이 안전하지 않은 기능이 필요한지에 대한 명확한 근거와 문서화가 수반되어야 합니다. 진정한 전문성은 이러한 기능들을 사용하는 능력뿐만 아니라, 언제 이것들을 사용하지 않아야 하는지를 아는 지혜에 있습니다.

24.6.1 Ada.Unchecked_Deallocation: 명시적 해제와 위험성

Ada의 설계는 프로그래머를 수동 메모리 관리의 복잡성과 위험으로부터 보호하는 것을 지향합니다. new를 통해 할당된 객체의 메모리는 접근이 불가능해졌을 때 가비지 컬렉터(Garbage Collector)에 의해 수거되거나(구현에 따라), Ada.Finalization의 제어 타입을 통해 유효범위(scope)가 끝날 때 자동으로 정리되는 것이 일반적입니다.

그러나 프로그래머가 C의 free 함수처럼 메모리를 명시적으로 해제해야 하는 저수준 제어가 필요한 경우가 있습니다. 이를 위해 Ada 표준 라이브러리는 **Ada.Unchecked_Deallocation**이라는 제네릭(generic) 프로시저를 제공합니다.

Unchecked_Deallocation의 인스턴스화 및 사용법

Ada.Unchecked_Deallocation은 그 자체로 호출할 수 있는 프로시저가 아니며, 반드시 해제하려는 대상 객체의 타입과 그에 대한 접근 타입을 인자로 주어 **인스턴스화(instantiation)**해야 합니다.

Ada 2022 레퍼런스 매뉴얼 13.11.2절에 정의된 제네릭 명세는 다음과 같습니다.

generic
   type Object (<>) is limited private;
   type Name is access Object;
procedure Ada.Unchecked_Deallocation (X : in out Name);

이를 사용하는 과정은 다음과 같습니다.

  1. 해제할 대상 타입(My_Data)과 접근 타입(My_Data_Access)을 정의합니다.
  2. Ada.Unchecked_Deallocation을 이 타입들로 인스턴스화하여 새로운 프로시저(예: Free)를 생성합니다.
  3. 생성된 Free 프로시저를 호출하여 메모리를 해제합니다.
with Ada.Unchecked_Deallocation;

procedure Manual_Memory_Management is
   type My_Data is record
      id   : Integer;
      data : String (1 .. 100);
   end record;

   type My_Data_Access is access My_Data;

   -- My_Data 타입에 대한 Deallocation 프로시저 "Free"를 생성합니다.
   procedure Free is new Ada.Unchecked_Deallocation (My_Data, My_Data_Access);

   ptr : My_Data_Access := null;
begin
   ptr := new My_Data; -- 메모리 할당
   -- ... ptr이 가리키는 객체 사용 ...

   Free (ptr); -- ptr이 가리키는 메모리를 명시적으로 해제
   -- 이 호출 이후 ptr은 이제 '댕글링 포인터'가 됩니다.
   ptr := null; -- 댕글링 포인터를 즉시 null화하는 것이 안전한 습관입니다.
end Manual_Memory_Management;

만약 My_Data_Access 타입이 사용자 정의 스토리지 풀에 바인딩되어 있다면, Free (ptr) 호출은 해당 풀의 Deallocate 프로시저를 호출하게 됩니다. 그렇지 않다면, 기본 힙 관리자에게 메모리를 반납합니다.

“Unchecked”의 의미: 책임의 이전과 위험성

이 프로시저의 이름에 **Unchecked**라는 단어가 포함된 것은 매우 중요한 의미를 가집니다. 이는 컴파일러나 런타임 시스템이 이 연산의 안전성을 전혀 검사하지 않음을 의미합니다. Free (ptr)를 호출하는 순간, 프로그래머는 런타임에게 “나는 이 메모리 해제가 안전함을 보증한다”고 약속하는 것과 같습니다. 이 약속이 깨졌을 때, C 계열 언어에서 발생하는 가장 심각한 메모리 오류들이 동일하게 발생합니다.

  1. 댕글링 포인터 (Dangling Pointer) / 사용 후 해제 (Use-After-Free): Free (ptr)를 호출한 후에도 ptr 변수 자체는 여전히 해제된 메모리의 주소를 담고 있습니다. 이 주소는 이제 유효하지 않으며, 운영체제는 이 공간을 다른 데이터나 코드를 위해 재할당할 수 있습니다. 만약 프로그래머가 실수로 이 ptr을 다시 역참조(ptr.all)하여 값을 읽거나 쓰려고 시도한다면, 프로그램은 완전히 예측 불가능한 데이터를 읽거나, 다른 중요 데이터를 덮어쓰거나, 혹은 즉시 비정상적으로 종료될 수 있습니다.

  2. 이중 해제 (Double Free): 이미 해제된 포인터에 대해 Free를 다시 호출하는 경우입니다. 이는 힙 관리자의 내부 자료구조를 손상시켜, 이후의 모든 메모리 할당/해제 동작을 불안정하게 만들고 결국 시스템을 다운시킬 수 있습니다.

적용 시나리오

Ada.Unchecked_Deallocation은 특정 시스템 프로그래밍 시나리오에서 명시적인 메모리 해제를 위해 필요한 기능을 제공합니다. 주요 적용 분야는 다음과 같습니다.

  • 사용자 정의 스토리지 풀: 22.5.1절에서 기술된 바와 같이, 사용자 정의 풀의 Deallocate 연산을 호출하는 표준적인 방법입니다.
  • 가비지 컬렉터가 없는 환경: 가비지 컬렉터를 제공하지 않는 Ada 런타임 환경에서 동적 메모리를 사용한 경우, 메모리 누수를 방지하기 위한 수동 해제 수단으로 사용됩니다.
  • C 라이브러리와의 연동: C의 malloc 계열 함수로 할당된 메모리를 Ada 측에서 free에 해당하는 C 함수를 호출하여 해제해야 할 때, 그 호출을 캡슐화하는 데 사용될 수 있습니다.

Ada.Unchecked_Deallocation은 언어의 표준 런타임 검사를 우회하여 동적 메모리의 명시적 해제를 허용하는 저수준 기능입니다. 이 기능의 오용은 댕글링 포인터나 이중 해제와 같은 메모리 관리 오류를 유발할 수 있으며, 이는 Ada 레퍼런스 매뉴얼에서 정의하는 오류적인 실행(erroneous execution)으로 이어질 수 있습니다.

따라서 이 기능의 사용은, 스토리지 풀 구현이나 저수준 인터페이스 래퍼와 같이 그 동작의 정확성을 국소적으로 검증하고 캡슐화할 수 있는 모듈 내부로 한정하는 것이 공학적으로 타당한 설계 원칙입니다.

24.6.2 Ada.Unchecked_Conversion: 타입 시스템 우회와 사용 지침

Ada의 가장 근본적인 안전장치는 **강력한 타입 시스템(strong typing system)**입니다. 컴파일러는 서로 호환되지 않는 타입 간의 대입이나 변환을 엄격히 금지함으로써, 데이터가 의도치 않은 방식으로 해석되어 발생하는 수많은 종류의 논리적 오류를 원천적으로 방지합니다.

그러나 하드웨어 레지스터를 직접 조작하거나, 사용자 정의 메모리 관리자를 구현하는 등 극히 저수준의 프로그래밍 영역에서는, 이러한 타입 시스템의 보호를 의도적으로 우회하여 특정 메모리 영역에 있는 비트 패턴(bit pattern)을 다른 타입으로 재해석(reinterpret)해야 할 필요가 있습니다.

이러한 요구사항을 위해 Ada는 **Ada.Unchecked_Conversion**이라는 제네릭(generic) 함수를 제공합니다. 이 함수는 Ada 언어에서 가장 강력하면서도 동시에 가장 위험한 기능 중 하나로, 사용 시 극도의 주의와 책임이 요구됩니다.

Unchecked_Conversion의 메커니즘

Ada.Unchecked_Conversion은 소스(Source) 타입의 값을 타겟(Target) 타입의 값으로 변환하는 함수를 생성하는 제네릭입니다.

Ada 2022 레퍼런스 매뉴얼 13.9절에 정의된 제네릭 명세는 다음과 같습니다.

generic
   type Source (<>) is limited private;
   type Target (<>) is limited private;
function Ada.Unchecked_Conversion (S : Source) return Target;

이 제네릭을 인스턴스화할 때 컴파일러가 확인하는 유일한 규칙은 Source'Size = Target'Size 입니다. 즉, 두 타입의 객체가 차지하는 비트(bit) 수가 동일해야 한다는 것입니다. 컴파일러는 이 크기 조건만 만족하면, 두 타입 간의 논리적 호환성이나 값의 유효성에 대해서는 전혀 검사하지 않습니다.

Unchecked_Conversion 함수가 호출되면, 런타임은 Source 타입 객체의 메모리 비트 패턴을 그대로 복사하여 Target 타입의 객체로 반환합니다. 어떠한 값의 변환이나 조정도 일어나지 않으며, 오직 컴파일러의 ‘해석’만이 달라질 뿐입니다.

잘못된 사용 예시:

with Ada.Unchecked_Conversion;
...
   function Float_To_Integer is new Ada.Unchecked_Conversion (Float, Integer);
   my_float  : Float   := 123.0;
   my_int    : Integer;
begin
   my_int := Float_To_Integer (my_float);
   -- my_int의 값은 123이 아님!
   -- IEEE 754 표준에 따른 123.0의 32비트 비트 패턴을
   -- 정수로 재해석한, 의미 없는 큰 값이 됩니다.

적용 시나리오 및 설계 원칙

Ada.Unchecked_Conversion은 언어의 타입 안전성 보증을 의도적으로 우회하는 기능이지만, 특정 저수준 프로그래밍 시나리오에서는 대체 불가능한 역할을 수행합니다. 이 기능의 사용이 공학적으로 정당화되는 대표적인 적용 분야는 다음과 같습니다.

  1. 저수준 자료구조 구현: 22.4절의 고정 크기 블록 할당자 구현이 이에 대한 가장 명확한 사례입니다. 원시 메모리 주소(System.Address)를 프리 리스트(free list)의 노드를 가리키는 접근 타입(Free_Block_Access)으로 변환하는 과정은 Unchecked_Conversion을 통해서만 구현 가능합니다. 이 경우, 변환의 유효성은 스토리지 풀의 내부 로직에 의해 보장되며, 그 정확성에 대한 책임은 전적으로 프로그래머에게 있습니다.

  2. 하드웨어 인터페이스와의 상호작용: 하드웨어 장치 레지스터의 값을 Unsigned_32와 같은 단일 정수 타입으로 읽어온 후, 이 비트 패턴을 각 비트 필드가 정의된 레코드 타입으로 재해석하여 다루는 경우에 사용될 수 있습니다. (단, 이러한 목적을 위해서는 표현식 절을 사용하여 타입 자체의 메모리 레이아웃을 직접 지정하는 것이 더 구조적이고 안전한 설계일 수 있습니다.)

사용 지침

Ada.Unchecked_Conversion의 사용은 다음 원칙을 엄격하게 준수해야 합니다.

  • 최소화 원칙: 반드시 필요한 경우에만, 가능한 가장 좁은 유효범위(scope) 내에서 사용해야 합니다.
  • 캡슐화 원칙: Unchecked_Conversion의 사용은 반드시 패키지 본체나 서브프로그램 내부로 숨겨야 합니다. 이를 통해 저수준의 위험한 연산을 캡슐화하고, 외부에는 타입 안전성이 보장되는 안전한 인터페이스만을 노출해야 합니다. 앞서 구현한 스토리지 풀 패키지가 바로 이 원칙을 따른 좋은 예입니다.
  • 문서화 원칙: Unchecked_Conversion을 사용하는 모든 곳에는, 왜 이 기능이 필요하며 왜 이 변환이 해당 문맥에서 안전한지에 대한 명확한 주석을 반드시 남겨야 합니다.

결론적으로, Ada.Unchecked_Conversion은 Ada의 타입 시스템이라는 안전망을 의도적으로 제거하는 행위입니다. 이는 프로그래머에게 시스템의 비트 하나하나까지 제어할 수 있는 궁극의 자유를 부여하는 동시에, 그 결과에 대한 모든 책임을 지게 합니다. 진정한 전문성은 이 기능을 사용하는 방법을 아는 것뿐만 아니라, 대부분의 경우에 이 기능을 사용하지 않고 문제를 해결하는 방법을 찾는 데 있습니다.

24.6.3 저수준 기능 사용 시 프로그래머의 책임

23장에서 우리는 Ada가 제공하는 다양한 저수준 프로그래밍 기능들을 학습했습니다. System 패키지를 통한 원시 메모리 조작, 표현식 절을 이용한 데이터 레이아웃 제어, 사용자 정의 스토리지 풀, 그리고 타입 시스템을 우회하는 비검사(unchecked) 기능들은 모두 프로그래머에게 하드웨어와 런타임 시스템에 대한 거의 완전한 통제권을 부여합니다.

그러나 이러한 강력한 통제권에는 그에 상응하는 막중한 책임이 뒤따릅니다. Ada의 일반적인 기능들은 컴파일러와 런타임 시스템이 프로그램의 안전성과 정확성을 보증하는 데 적극적으로 참여합니다. 하지만 프로그래머가 저수준 기능을 사용하는 것은, 이러한 언어의 안전망으로부터 의도적으로 벗어나겠다는 선언과 같습니다. 이 순간, 시스템의 무결성(integrity)을 보장해야 할 책임은 언어와 컴파일러로부터 전적으로 프로그래머에게 이전됩니다.

고신뢰성 시스템을 구축하는 프로그래머가 저수준 기능을 사용할 때 반드시 준수해야 할 공학적 책임은 다음과 같습니다.

  1. 사용의 정당화 및 최소화 (Justification and Minimization): 저수준 기능의 사용은 항상 최후의 수단이어야 합니다. 표준적이고 안전한 Ada의 고수준 기능으로는 해결할 수 없는, 명확하고 문서화된 기술적 요구사항(예: 결정론적 시간 보장, 특정 하드웨어 레지스터 접근)이 존재할 때만 그 사용이 정당화될 수 있습니다. 또한, 그 사용 범위는 반드시 필요한 최소한의 코드 영역으로 한정되어야 합니다.

  2. 철저한 캡슐화 (Rigorous Encapsulation): Unchecked_Conversion이나 Unchecked_Deallocation과 같은 위험한 연산은 절대로 패키지의 공개 인터페이스(.ads 파일)에 노출되어서는 안 됩니다. 이러한 저수준의 복잡성과 위험성은 반드시 패키지의 본체(.adb 파일) 내부에 완벽하게 숨겨져야 하며, 외부에는 잘 정의되고 안전하며 추상화된 인터페이스만을 제공해야 합니다. 우리가 구현한 Fixed_Size_PoolUnchecked_Conversion의 위험성을 내부적으로 처리하고 외부에는 안전한 Allocate/Deallocate 인터페이스를 제공하는 좋은 예시입니다.

  3. 안전성의 수동 검증 (Manual Verification of Safety): 컴파일러가 더 이상 안전성을 검증해주지 않으므로, 프로그래머는 자신이 작성한 저수준 코드의 안전성을 스스로 증명해야 할 의무가 있습니다. 이는 단순히 “주의해서 코딩한다”는 차원을 넘어섭니다. 예를 들어, Unchecked_Deallocation을 호출하기 전에 해당 메모리가 다른 곳에서 참조되지 않음을, 그리고 Unchecked_Conversion의 결과 비트 패턴이 목표 타입의 유효한 값을 나타냄을 논리적으로 증명할 수 있어야 합니다. 고신뢰성 환경에서는 이를 위해 정적 분석 도구의 사용이나 동료 검토(peer review)와 같은 엄격한 검증 절차가 요구됩니다.

  4. 이식성에 대한 명시 (Documentation of Non-Portability): 주소 절, 표현식 절, 인라인 어셈블리 등은 본질적으로 특정 하드웨어 아키텍처나 운영체제에 종속적인 코드입니다. 프로그래머는 이러한 비이식성의 존재와 그에 따른 제약 조건을 코드 내에 명확하게 문서화하여, 다른 환경으로의 이식이나 재사용 시 발생할 수 있는 문제를 사전에 방지해야 할 책임이 있습니다.

결론적으로, Ada가 제공하는 저수준 기능들은 언어의 안전성 철학에 대한 예외입니다. Ada는 이러한 예외적인 기능들을 Unchecked_ 와 같이 눈에 띄는 이름으로 명명함으로써, 프로그래머가 자신이 지금 얼마나 큰 책임을 지고 있는지를 항상 인지하도록 합니다. 진정한 전문가의 역량은 이러한 기능들을 사용하는 능력뿐만 아니라, 그 사용을 최소화하고 그 위험을 완벽하게 통제하여 궁극적으로는 안전하고 신뢰할 수 있는 시스템을 구축하는 엔지니어링 규율(discipline)에 있습니다.

부록: Clair Coding Style Guide

  • Indentation: Use 2 spaces for indentation, not tabs.

    • Reserved Words & Aspects: Use snake_case (all lowercase).

      • Rationale: To distinguish language keywords and aspects from user-defined identifiers.
      • Example: package, is, begin, end, if, procedure, with, pre, post
  • Pragmas: Use snake_case (all lowercase) for the pragma name and its convention identifier.
    • Rationale: For consistency with other language keywords and attributes. This applies to both the pragma itself (e.g., import) and standard convention identifiers (e.g., c, intrinsic).
    • Example: pragma import (c, my_c_func, "my_c_func"), pragma convention (c, My_Data_Type), pragma no_return
  • Spacing:
    • Subprogram Calls & Declarations: Use a single space between the subprogram name and the opening parenthesis (.
      • Rationale: To visually distinguish subprogram names from type conversions or other language constructs that use parentheses, improving overall code clarity.
      • Example (Call): Clair.Error.get_error_message (errno_code);
      • Example (Declaration): procedure exit_process (status : Integer := EXIT_SUCCESS);
  • Variables, Subprograms, & Entries: Use snake_case (all lowercase with underscores).

    • Rationale: To maintain a consistent and readable style for all user-defined, executable, or data-holding identifiers.
    • Example (Variables & Subprograms): my_variable, get_pid
    • Example (Entries): get_item, put_message
      protected body Buffer is
          entry get_item (item : out Data) when not is_empty is
            -- ...
          end get_item;
      end Buffer;
      
    • Return Value Variables: For variables holding a subprogram’s return value, especially status codes (e.g., 0, -1), prefer using retval.
      • Rationale: This is a widely understood convention that avoids potential conflicts with the result identifier. For return values representing specific data, use a more descriptive name (e.g., bytes_written, new_fd).
      • Example: retval := dlfcn_h.dlclose (self.handle);
  • Attributes: Use snake_case (all lowercase).
    • Rationale: To distinguish language-defined attributes from user-defined types and subprograms.
    • Example: errmsg'length, c_path'address
  • Types, Subtypes, Exceptions & Protected Objects:
    • Use Pascal_Case for single-word identifiers.
      • Rationale: Within a package like File, a name like Descriptor is self-evident when used as File.Descriptor. Adding a redundant prefix, such as in File.File_Descriptor, can harm readability.
      • Example: Descriptor, Flags, Object
    • Use Pascal_Case_With_Underscores for multi-word identifiers.
      • Rationale: To clearly distinguish the words within a multi-word type name, improving readability.
      • Example: Library_Load_Error, Symbol_Lookup_Error
  • Constants:
    • Compile-Time Constants: Use UPPER_CASE_WITH_UNDERSCORES. This rule applies to all static constants, including those from the standard library.
      • Rationale: To clearly distinguish static, fixed values from all other identifiers.
      • Project-Defined Example: EXIT_SUCCESS, MAX_BUFFER_SIZE
      • Standard Library Example: System.NULL_ADDRESS, Interfaces.C.NUL, Interfaces.C.Strings.NULL_PTR
    • Runtime Constants: Use snake_case (like variables).
      • Rationale: Used for constants within a subprogram that are initialized with a dynamic value (e.g., from a parameter). Treat these as ‘read-only variables’.
      • Example: final_message : constant String := "Error: " & message;
  • Packages: Use Pascal_Case.
    • Example: Clair.Process
    • Exception: In the case of Dl, which consists of two letters, it is written as DL. (e.g., Clair.DL, not Clair.Dl which can look like Clair.D1).
  • Standard Library Naming:
    • Interfaces.C: Types and subprograms from the Interfaces.C package and its children should use snake_case to match the C standard library’s naming convention. Constants from this package follow the global UPPER_CASE rule for compile-time constants.
      • Rationale: To maintain a clear and consistent mental mapping between Ada and C, while ensuring all constants in the project have a uniform appearance.
      • Example (Types/Subprograms): Interfaces.C.int, Interfaces.C.char_array, Interfaces.C.Strings.chars_ptr
      • Example (Constants): Interfaces.C.NUL, Interfaces.C.Strings.NULL_PTR