Ada 프로그래밍
이 저작물은 크리에이티브 커먼즈 저작자표시-비영리-변경금지 4.0 국제 라이선스에 따라 이용할 수 있습니다.
목차
- 1. Ada 소개
- 1.1 Ada의 역사
- 1.2 Ada의 설계 철학
- 1.3 Ada의 주요 특징
- 1.3.1 강타입 시스템과 서브타입 (strong typing system and subtypes)
- 1.3.2 패키지를 통한 모듈화 및 정보 은닉 (modularity and information hiding via packages)
- 1.3.3 태스크를 통한 내장된 동시성 (built-in concurrency with tasks)
- 1.3.4 보호 객체를 이용한 안전한 자원 공유 (safe resource sharing with protected objects)
- 1.3.5 제네릭을 이용한 재사용성 (reusability with generics)
- 1.3.6 구조화된 예외 처리 (structured exception handling)
- 1.3.7 계약 기반 설계 (Design by Contract)
- 1.3.8 시스템 프로그래밍 및 저수준 제어 (systems programming and low-level control)
- 1.3.9 다른 언어와의 비교
- 1.4 Ada의 주요 응용 분야
- 2. 개발 환경 구축
- 3. Ada 프로그램의 기본 구조와 구성 요소
- 4. 스칼라 타입 (Scalar Types)
- 5. 제어 구조
- 6. 복합 타입 (composite types)
- 7. 서브프로그램: 코드의 재사용
- 8. 패키지
- 9. 예외 처리
- 10. 접근 타입과 메모리 관리
- 11. 제네릭 (generics)
- 12. 객체 지향 프로그래밍 (Object-Oriented Programming)
- 13. 동시성 프로그래밍 소개
- 14. 태스크(task) - Ada 동시성의 기본 단위
- 15. 태스크 간 통신과 동기화: 랑데부(rendezvous)
- 16. 보호 객체(protected objects): 데이터 중심 동기화
- 17. 고급 동시성 패턴과 기법
- 18. 실시간 시스템과 동시성
- 19. 계약 기반 설계 (Design by Contract)
- 20. 저수준 프로그래밍과 표현 명세
- 21. 인터페이싱 (Interfacing)
- 22. SPARK - 신뢰할 수 있는 소프트웨어 구축
- 부록: Clair Coding Style Guide
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는 특정 언어를 즉시 선택하는 대신, 철저한 요구사항 기반 접근 방식을 채택했습니다. 이는 언어의 기능이 실제 사용자의 필요를 충족해야 한다는 원칙에 기반한 것입니다. 이 과정은 다음과 같은 일련의 요구사항 문서들을 통해 체계적으로 진행되었습니다.
- STRAWMAN (1975년 4월): 토론을 자극하기 위한 초기 요구사항 초안. 전 세계 군사 및 민간 커뮤니티에 배포되어 의견을 수렴했습니다.
- WOODENMAN (1975년 8월): 수렴된 의견을 바탕으로 다듬어진 두 번째 요구사항 문서.
- TINMAN (1976년 1월): 각 군의 공식적인 요구사항과 전 세계 기술 자문을 통합하여 작성된 문서. 이 시점에서 항공, 통신, 지휘통제 등 다양한 분야의 요구사항이 본질적으로 동일하다는 중요한 결론에 도달했으며, 이는 단일 언어의 실현 가능성을 뒷받침했습니다.
- IRONMAN (1977년 1월): 기존 언어 평가를 통해 일관성과 기술적 실현 가능성을 검증한 후, 언어 설계의 기반이 될 수 있도록 정제된 요구사항 명세.
- 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는 특정 응용 분야의 요구사항을 충족하기 위해 명확하고 일관된 설계 철학을 바탕으로 개발되었습니다. 이 철학은 대규모, 장기 수명, 고신뢰성이 요구되는 임베디드 및 실시간 시스템의 개발을 목표로 합니다.
Ada의 설계 철학은 세 가지 핵심 원칙, 즉 신뢰성 (reliability), 유지보수성 (maintainability), 그리고 효율성 (efficiency) 에 기반을 둡니다. 이 세 원칙은 상호 보완적으로 작용하여, 개발 초기 단계부터 잠재적 오류를 최소화하고, 수십 년에 걸쳐 시스템을 안정적으로 운영하며, 요구되는 성능을 충족시키는 소프트웨어의 구축을 지원합니다.
이어지는 절에서는 이 세 가지 핵심 원칙이 Ada 언어의 구체적인 기능과 제약 사항에 어떻게 반영되었는지 상세히 분석합니다. 각 원칙이 실제 소프트웨어 개발 생명주기에서 가지는 의미와 그 구현 방안에 대해 심도 있게 탐구할 것입니다.
1.2.1 신뢰성 (reliability)
Ada의 설계 철학 최상단에 위치하는 가치는 신뢰성입니다. 여기서 신뢰성이란 소프트웨어가 명세된 대로 정확하게, 예측 가능하게, 그리고 일관되게 동작하는 능력을 의미합니다. 특히 항공우주, 국방, 의료, 원자력 등 인간의 생명이나 막대한 자산이 걸린 고신뢰성 시스템(high-integrity systems)에서 소프트웨어의 사소한 결함은 치명적인 결과로 이어질 수 있습니다. 따라서 Ada는 프로그래머의 부주의나 실수로 인해 발생할 수 있는 오류를 언어 차원에서 원천적으로 방지하고, 설령 오류가 발생하더라도 이를 체계적으로 처리하여 시스템의 붕괴를 막도록 설계되었습니다.
Ada가 신뢰성을 확보하는 핵심 원칙은 “오류는 가능한 한 빨리, 가급적 컴파일 시점에 발견되어야 한다”는 것입니다. 런타임에 발생하는 오류는 시스템의 불안정성을 야기하고 디버깅을 어렵게 만들지만, 컴파일 시점에 발견된 오류는 비용이 거의 들지 않고 즉시 수정할 수 있습니다. 이를 위해 Ada는 다음과 같은 다층적 방어 메커니즘을 언어의 구문과 의미론에 깊숙이 내장하고 있습니다.
첫째, 강타입 시스템(strong typing system)은 신뢰성의 가장 근본적인 토대입니다. Ada는 서로 다른 타입 간의 암시적 변환을 허용하지 않습니다. 예를 들어, 거리(Distance
)를 나타내는 타입과 속도(Velocity
)를 나타내는 타입을 별도로 정의하면, 프로그래머가 실수로 두 변수를 더하려고 할 때 컴파일러가 이를 즉시 오류로 처리합니다. 이는 단순히 정수와 실수를 구분하는 수준을 넘어, 프로그램의 의미론적 정확성을 컴파일러가 강제하도록 만듭니다. 이로써 데이터의 오용으로 인해 발생할 수 있는 광범위한 논리적 오류를 사전에 차단합니다.
둘째, 명시성(explicitness)을 강조하는 문법입니다. Ada의 문법은 if ... end if;
, loop ... end loop;
와 같이 다소 장황하게 보일 수 있습니다. 그러나 이러한 명시성은 코드의 모호함을 제거하고 가독성을 극대화하여, 동료 검토(peer review)나 장기적인 유지보수 과정에서 발생할 수 있는 오해석과 실수를 줄입니다. 프로그램의 동작이 문법적으로 명확하게 드러나므로, 숨겨진 버그가 자리 잡을 가능성이 감소합니다.
셋째, 런타임 검사(run-time checks)입니다. 모든 오류를 컴파일 시점에 잡을 수는 없습니다. 배열의 인덱스가 범위를 벗어나거나, 특정 값의 범위 제약을 위반하는 경우(예: 1..31
범위의 날짜 타입에 40을 할당하는 경우)는 프로그램 실행 중에만 확인할 수 있습니다. Ada는 이러한 상황을 감지하기 위한 런타임 검사를 기본적으로 활성화하며, 위반 시 Constraint_Error
와 같은 예외(exception)를 발생시켜 프로그램이 정의되지 않은 위험한 상태로 계속 실행되는 것을 막습니다.
마지막으로, 구조화된 예외 처리(structured exception handling)입니다. 런타임 오류가 발생했을 때, Ada는 시스템을 즉시 종료하는 대신 이를 처리하고 복구할 수 있는 체계적인 메커니즘을 제공합니다. 이는 오류 코드를 반환하고 프로그래머가 이를 확인하기를 기대하는 방식보다 훨씬 견고합니다. 예외 처리를 통해 시스템은 예상치 못한 문제에 직면했을 때 미리 정의된 안전한 절차에 따라 대응할 수 있습니다.
결론적으로 Ada에서 신뢰성은 단순히 권장되는 프로그래밍 스타일이 아니라, 언어의 문법, 타입 시스템, 런타임 환경 전반에 걸쳐 강제되는 핵심 원칙입니다. 이러한 다층적인 안전장치는 프로그래머가 더 견고하고 예측 가능한 코드를 작성하도록 유도하며, 이것이 바로 Ada가 수십 년간 가장 중요한 시스템들의 개발 언어로 선택받아 온 근본적인 이유입니다.
1.2.2 유지보수성 (maintainability)
소프트웨어 시스템의 생명주기에서 가장 큰 비용을 차지하는 단계는 초기 개발이 아니라, 수십 년에 걸쳐 이루어지는 유지보수입니다. 유지보수에는 결함 수정, 성능 개선, 기능 추가, 그리고 변화하는 운영 환경에 대한 적응 등 모든 활동이 포함됩니다. 특히, 원 개발자가 더 이상 프로젝트에 참여하지 않는 상황에서 새로운 엔지니어가 코드를 빠르고 정확하게 이해하고 수정할 수 있는 능력은 시스템의 생존과 직결됩니다. Ada는 이러한 장기적인 유지보수성을 언어 설계의 핵심 철학으로 삼고 있습니다.
Ada가 유지보수성을 보장하는 가장 근본적인 원칙은 “코드는 작성되는 횟수보다 훨씬 더 많이 읽힌다”는 사실을 인지하는 것입니다. 따라서 Ada의 문법과 구조는 간결함(terseness)이나 작성의 용이성보다 가독성(readability)과 명시성(explicitness)을 최우선으로 고려하여 설계되었습니다.
첫째, 패키지(package)를 통한 모듈화(modularity)는 Ada의 유지보수성을 지탱하는 가장 중요한 기둥입니다. 패키지는 관련된 데이터 타입, 변수, 서브프로그램들을 하나의 논리적 단위로 묶는 메커니즘을 제공합니다. 여기서 핵심은 명세부(specification, .ads
파일)와 구현부(body, .adb
파일)의 완전한 분리입니다.
- 명세부는 패키지가 외부에 제공하는 ‘공개 인터페이스’ 또는 ‘계약’ 역할을 합니다. 다른 개발자는 구현부의 복잡한 코드를 보지 않고도 명세부만 읽으면 해당 모듈을 어떻게 사용해야 하는지 명확히 알 수 있습니다.
- 구현부에는 실제 동작 로직이 포함됩니다. 이 구현부의 알고리즘을 개선하거나 내부 버그를 수정하더라도, 명세부에 변경이 없다면 해당 패키지를 사용하는 다른 코드에는 아무런 영향을 주지 않습니다. 이러한 분리 원칙은 수정 사항의 파급 효과를 최소화하여, 대규모 시스템의 변경을 안전하고 예측 가능하게 만듭니다.
둘째, 데이터 추상화(data abstraction)와 정보 은닉(information hiding)입니다. 좋은 모듈은 내부의 복잡성을 외부에 감추어야 합니다. Ada는 패키지 명세부에서 private
타입을 사용하여 이를 강제합니다. private
으로 선언된 타입의 내부 구조는 패키지 외부에서 접근할 수 없습니다. 예를 들어, Stack
이라는 타입을 private
으로 정의하면, 사용자는 push
와 pop
연산만을 사용할 수 있을 뿐, 스택이 내부적으로 배열로 구현되었는지 연결 리스트로 구현되었는지 알 수 없으며, 이에 의존하는 코드를 작성할 수도 없습니다. 덕분에 추후에 스택의 내부 구현을 더 효율적인 방식으로 변경하더라도, Stack
을 사용하는 코드는 단 한 줄도 수정할 필요가 없게 됩니다. 이는 변경의 영향을 특정 모듈 내부로 국지화(localize)시켜 유지보수의 복잡성을 극적으로 낮춥니다.
셋째, 가독성을 극대화하는 명시적인 문법입니다. Ada는 중의적으로 해석될 여지가 있는 암호 같은 기호({
, &&
, ||
) 대신, begin
, end
, and then
, or else
와 같이 의미가 명확한 영어 키워드를 사용합니다. 또한, if ... end if;
, case ... end case;
처럼 모든 제어 구조는 명시적인 종료 키워드를 가집니다. 이는 코드의 구조를 시각적으로 명확하게 만들어, 사람이 코드를 읽고 이해하는 과정에서 발생하는 실수를 줄여줍니다. 잘 읽히는 코드는 그 자체로 훌륭한 문서이며, 이는 유지보수 과정에서 가장 중요한 자산입니다.
결론적으로, Ada의 유지보수성은 프로그래머의 선의나 잘 정립된 코딩 규칙에만 의존하지 않습니다. 언어 자체가 모듈화, 정보 은닉, 가독성을 강제하는 구조를 가지고 있습니다. 이러한 설계 철학은 수십 년 이상 운영되어야 하는 국방, 항공, 철도 시스템과 같이 높은 신뢰성과 장기적인 안정성이 요구되는 분야에서 Ada가 왜 필수적인 선택인지를 명확히 보여줍니다.
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)과 같이 실행 시간을 예측하기 어려운 메커니즘을 실시간 프로파일에서 배제합니다. 메모리 할당과 해제는 명시적으로 이루어지므로, 시스템의 시간적 동작을 정확하게 분석하고 보장할 수 있습니다. 또한, 내장된 동시성 모델(task
와 protected
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;
위 예제에서 Distance
와 Mass
는 모두 부동소수점 수이지만, 논리적으로는 전혀 다른 개념입니다. 프로그래머가 실수로 거리와 질량을 더하려고 시도하면, 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;
Percentage
는 Integer
의 부분집합이므로 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 with tasks)
현대의 소프트웨어 시스템은 여러 작업을 동시에 처리해야 하는 요구사항을 갖는 것이 일반적입니다. 대부분의 프로그래밍 언어는 이러한 동시성(concurrency)을 운영체제가 제공하는 스레드(thread)를 감싸는 외부 라이브러리를 통해 지원합니다. 그러나 이 접근 방식은 컴파일러가 동시성의 존재를 인지하지 못하므로, 프로그래머의 세심한 주의에 전적으로 의존하여 스레드를 관리해야 하며 이는 교착 상태(deadlock)이나 경쟁 상태(race condition)와 같은 심각한 오류의 원인이 되곤 합니다.
Ada는 이와 근본적으로 다른 접근 방식을 취합니다. 동시성은 라이브러리가 아닌 언어 자체에 내장(built-in)된 핵심 기능입니다. task
라는 언어의 구성요소를 통해 독립적인 실행 흐름을 정의하고, 언어 차원에서 이들의 상호작용을 안전하게 관리하는 메커니즘을 제공합니다. 이는 동시성 코드를 훨씬 더 구조적이고, 예측 가능하며, 신뢰성 있게 만들어 줍니다.
태스크: 독립적인 실행 흐름
Ada에서 동시성의 기본 단위는 태스크(task)입니다. 태스크는 자신만의 독립적인 실행 흐름을 가지는 ‘살아있는’ 객체로 생각할 수 있습니다. 각 태스크는 다른 코드와 병렬적으로(in parallel) 또는 시분할(time-sharing) 방식으로 동시에 실행됩니다.
패키지와 마찬가지로 태스크 역시 명세부(specification)와 구현부(body)로 구성될 수 있습니다.
- 태스크 명세부: 다른 태스크와의 상호작용 지점인 엔트리(entry)를 선언하여 공개 인터페이스를 정의합니다.
- 태스크 구현부: 태스크가 생성되어 소멸될 때까지 독립적으로 수행할 작업의 절차를 기술합니다.
코드 예시: 독립적으로 실행되는 태스크
with Ada.Text_IO; use Ada.Text_IO;
procedure demonstrate_task is
-- 간단한 메시지를 출력하는 태스크 정의
task printer;
task body printer is
begin
-- 메인 프로시저와 독립적으로 실행되는 코드
put_line ("Printer task is running concurrently.");
end printer;
begin
-- 메인 프로시저가 시작되면, 그 안에 선언된 'printer' 태스크도
-- 자동으로 실행을 시작합니다.
put_line ("Main procedure is running.");
end demonstrate_task;
위 코드를 실행하면 두 put_line
호출의 순서가 보장되지 않습니다. 이는 demonstrate_task
프로시저의 실행 흐름과 printer
태스크의 실행 흐름이 독립적으로 동시에 진행되기 때문입니다.
랑데부를 통한 통신과 동기화
여러 태스크가 독립적으로 실행될 때 가장 큰 난제는 이들 간의 데이터를 안전하게 교환(통신)하고 실행 순서를 맞추는(동기화) 것입니다. Ada는 랑데부(rendezvous)라고 하는 고수준 동기화 메커니즘을 통해 이 문제를 해결합니다.
랑데부는 한 태스크(호출자, caller)가 다른 태스크(수신자, callee)의 엔트리(entry)를 호출하고, 수신자 태스크가 accept
구문을 통해 해당 호출을 수락할 때 발생하는 ‘동기화된 만남’입니다.
- 호출: 호출자 태스크가 수신자 태스크의 엔트리를 호출하면, 수신자가 해당 호출을
accept
할 때까지 실행을 멈추고 대기합니다. - 수락: 수신자 태스크가 자신의 코드 흐름에 따라
accept
구문에 도달하면, 대기 중인 호출과 연결됩니다. 만약 호출이 아직 없다면, 호출이 들어올 때까지 대기합니다. - 실행: 두 태스크가 만나면(랑데부 성공),
accept
구문 안의 코드가 실행됩니다. 이 시간 동안 호출자 태스크는 계속 대기 상태를 유지합니다. - 완료:
accept
구문의 실행이 끝나면 랑데부가 완료되고, 두 태스크는 다시 각자의 경로를 따라 독립적으로 실행을 계속합니다.
이 랑데부 모델은 뮤텍스(mutex)나 세마포어(semaphore) 같은 저수준 동기화 기본 요소를 직접 사용하는 것보다 훨씬 안전하고 구조적입니다. 데이터 교환과 동기화가 하나의 원자적(atomic)인 작업으로 묶여 있어, 프로그래머의 실수로 발생할 수 있는 많은 동시성 오류를 방지합니다.
결론적으로, Ada의 내장된 태스크 모델은 동시성 프로그래밍을 저수준의 복잡한 작업에서 고수준의 구조화된 설계의 영역으로 끌어올렸습니다. 컴파일러와 런타임이 동시성을 직접 지원함으로써, 개발자는 더 신뢰성 높고 분석하기 쉬운 병렬 시스템을 구축할 수 있으며, 이는 실시간 시스템과 같이 타이밍과 안전이 중요한 분야에서 특히 장점으로 작용합니다.
1.3.4 보호 객체를 이용한 안전한 자원 공유 (safe resource sharing with protected objects)
태스크 간의 통신과 동기화를 위한 랑데부(rendezvous) 모델은 매우 강력하지만, 단순히 데이터를 공유하는 일반적인 시나리오에서는 다소 무겁고 비효율적일 수 있습니다. 여러 태스크가 하나의 공유 변수나 데이터 구조에 접근해야 할 때, 더 가볍고 특화된 동기화 메커니즘이 필요합니다. Ada는 이러한 요구에 완벽하게 부응하는 보호 객체(protected object)라는 우아하고 효율적인 해법을 제공합니다.
보호 객체는 상호 배제(mutual exclusion)를 언어 차원에서 자동화하여 경쟁 상태(race condition)를 원천적으로 방지하는 특별한 데이터 구조입니다. 자체적인 실행 흐름을 가진 능동적인(active) task
와 달리, 보호 객체는 데이터와 그 데이터를 조작하는 연산들을 캡슐화하는 수동적인(passive) 객체입니다.
자동화된 상호 배제: 잠금(lock) 없는 동시성
보호 객체의 핵심 철학은 프로그래머가 뮤텍스(mutex)나 세마포어(semaphore) 같은 저수준의 잠금(lock) 메커니즘을 직접 다루지 않도록 하는 것입니다. 잠금을 잊거나 잘못 사용하는 실수는 동시성 프로그래밍에서 가장 흔하고 잡기 어려운 버그의 원인이 됩니다. 보호 객체는 이러한 잠금 과정을 완전히 자동화하고 컴파일러가 그 규칙을 강제합니다.
보호 객체는 세 종류의 연산을 통해 데이터 접근을 제어합니다.
- 프로시저 (procedures): 보호된 데이터에 대한 배타적 쓰기 접근(exclusive write access)을 제공합니다. 한 번에 단 하나의 태스크만이 보호 객체의 프로시저를 실행할 수 있습니다. 다른 태스크가 프로시저를 호출하면, 현재 실행 중인 프로시저가 완료될 때까지 자동으로 대기합니다.
- 함수 (functions): 보호된 데이터에 대한 동시적 읽기 접근(concurrent read access)을 제공합니다. 여러 태스크가 동시에 보호 객체의 함수를 호출하여 데이터를 읽을 수 있습니다. 단, 프로시저가 실행 중일 때는 모든 함수 호출이 대기해야 합니다. 언어 규칙상 함수는 보호된 데이터를 수정할 수 없습니다.
- 엔트리 (entries): 프로시저와 같이 배타적 접근을 제공하지만, 배리어(barrier)라는 진입 조건을 추가로 가집니다. 배리어 조건이
True
일 때만 태스크가 엔트리 내부로 진입할 수 있으며,False
일 경우 조건이 만족될 때까지 자동으로 대기 큐에 등록됩니다.
이러한 구조는 컴파일러가 자동으로 최적의 ‘다중 독자/단일 저자(multiple-readers, single-writer)’ 잠금 모델을 구현하게 해주며, 프로그래머는 동시성 제어의 복잡성 대신 비즈니스 로직에 집중할 수 있습니다.
코드 예시: 안전한 공유 카운터
-- 여러 태스크가 안전하게 접근할 수 있는 공유 카운터
protected type Shared_Counter is
-- 카운터 값을 1 증가시키는 배타적 쓰기 연산
procedure increment;
-- 현재 카운터 값을 읽는 동시적 읽기 연산
function value return Natural;
private
-- 보호된 데이터
count : Natural := 0;
end Shared_Counter;
protected body Shared_Counter is
procedure increment is
begin
count := count + 1;
end increment;
function value return Natural is
begin
return count;
end value;
end Shared_Counter;
여러 태스크가 동시에 Shared_Counter
객체의 increment
프로시저를 호출하더라도, Ada 런타임은 한 번에 하나의 태스크만 count
를 수정하도록 보장합니다. 따라서 count
값이 누락되거나 덮어쓰이는 경쟁 상태(race condition)가 절대 발생하지 않습니다.
엔트리를 이용한 조건부 동기화
단순한 상호 배제를 넘어, 특정 조건이 만족될 때까지 작업 실행을 대기시켜야 하는 경우가 많습니다. 예를 들어, 공유 버퍼에서 데이터를 읽는 태스크는 버퍼에 데이터가 하나 이상 존재할 때만 작업을 진행해야 합니다. 보호 객체의 엔트리는 이러한 조건부 동기화를 매우 명확하고 안전하게 구현합니다.
엔트리의 배리어는 when
뒤에 오는 불리언(boolean) 조건식으로 표현됩니다. 예를 들어, 공유 버퍼의 get
엔트리는 when current_size > 0 => ...
와 같은 배리어를 가질 수 있습니다. current_size
가 0이면, get
을 호출한 태스크는 current_size
가 0보다 커질 때까지 자동으로 대기 상태에 들어갑니다. 이는 복잡한 조건 변수(condition variable)를 사용하는 것보다 훨씬 직관적이고 오류 발생 가능성이 적습니다.
결론적으로, 보호 객체는 Ada의 동시성 모델에서 태스크와 상호 보완적인 역할을 수행하는 핵심 요소입니다. 이는 동시성 프로그래밍의 가장 고질적인 문제인 공유 자원 접근을 위한 매우 효율적이고 지극히 안전하며 높은 수준의 추상화를 제공합니다. 프로세스 중심의 동시성에는 태스크를, 데이터 중심의 동시성에는 보호 객체를 사용하는 이원적 모델은 개발자가 문제의 성격에 맞는 최적의 도구를 선택할 수 있게 하여, 신뢰성과 성능을 모두 극대화합니다.
1.3.5 제네릭을 이용한 재사용성 (reusability with generics)
소프트웨어 공학의 핵심 목표 중 하나는 코드 재사용성(reusability)을 극대화하는 것입니다. 한 번 작성하여 검증된 코드를 여러 곳에서 재사용할 수 있다면, 개발 시간과 비용이 절감되고 잠재적인 오류의 수도 줄어들게 됩니다. Ada는 제네릭(generics)이라는 기능을 통해, 특정 타입에 종속되지 않는 일반화된 코드 템플릿(template)을 작성하고 이를 필요에 맞게 재사용하는 것을 체계적으로 지원합니다.
제네릭: 타입에 독립적인 코드 템플릿
많은 경우, 자료 구조나 알고리즘의 본질적인 로직은 그것이 다루는 데이터의 타입과 무관합니다. 예를 들어, 스택(Stack) 자료 구조의 push
와 pop
연산 로직은 스택에 정수(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_Stack
과 String_Stack
은 Generic_Stack
이라는 단 하나의 템플릿으로부터 생성되었지만, 완전히 독립적이고 타입 안전성을 갖춘 별개의 패키지입니다. Integer_Stack
에 String
을 push
하려는 시도는 컴파일 시간에 즉시 발견되어 차단됩니다.
결론적으로, Ada의 제네릭 기능은 단순한 코드 복사를 넘어, 높은 수준의 추상화와 타입 안전성을 동시에 달성하는 핵심적인 재사용 메커니즘입니다. 로직을 한 번만 작성하고 검증한 뒤, 필요한 모든 타입에 대해 안전하게 재사용할 수 있게 함으로써 소프트웨어의 생산성과 유지보수성을 극적으로 향상시킵니다. 이는 잘 설계된 컴포넌트 라이브러리를 구축하는 기반 기술이며, Ada가 대규모의 복잡한 시스템을 구축하기 위한 공학적 언어임을 보여주는 대표적인 특징입니다.
1.3.6 구조화된 예외 처리 (structured exception handling)
완벽하게 설계된 프로그램이라 할지라도, 실행 중에는 예측하지 못한 문제들이 발생할 수 있습니다. 예를 들어, 사용자가 존재하지 않는 파일의 이름을 입력하거나, 네트워크 연결이 끊어지거나, 할당할 메모리가 부족해지는 등의 상황이 이에 해당합니다. 이러한 예외적인 상황에 대처하는 전통적인 방식은 오류 코드(error code)를 반환하여 호출자가 일일이 확인하도록 하는 것이지만, 이 방식은 정상적인 로직과 오류 처리 로직이 뒤섞여 코드를 복잡하게 만들고, 프로그래머가 오류 확인을 누락하여 프로그램이 위험한 상태로 계속 실행되게 만드는 원인이 되곤 합니다.
Ada는 이러한 문제들을 해결하기 위해 구조화된 예외 처리(structured exception handling) 메커니즘을 언어 차원에서 제공합니다. 이는 예상치 못한 오류, 즉 예외(exception)가 발생했을 때, 프로그램의 정상적인 실행 흐름을 중단하고 이를 처리하기 위해 미리 정의된 코드로 제어를 이전하는 강력하고 신뢰성 있는 방법입니다.
오류 처리 코드와 정상 로직의 분리
예외 처리의 가장 큰 장점은 정상적인 경우의 실행 로직과 예외적인 경우의 오류 처리 로직을 명확하게 분리하여 코드의 가독성과 유지보수성을 크게 향상시킨다는 점입니다. 핵심 로직은 begin
과 exception
키워드 사이의 코드 블록에 집중적으로 기술하고, 모든 종류의 오류 상황에 대한 대처는 exception
키워드 뒤에 모아서 작성합니다.
begin
-- 정상적인 경우에 실행될 '행복한 경로(happy path)' 코드
-- (파일 열기, 데이터 처리, 네트워크 통신 등)
...
exception
-- 오류가 발생했을 경우에만 실행될 코드
when File_Not_Found_Error =>
-- 파일이 없을 때의 처리
when Network_Error =>
-- 네트워크 오류 처리
when others =>
-- 그 외 예상치 못한 모든 오류 처리
end;
이러한 구조 덕분에 개발자는 주된 알고리즘의 흐름을 한눈에 파악할 수 있으며, 발생 가능한 모든 오류에 대한 처리 방식을 한 곳에서 체계적으로 관리할 수 있습니다.
예외의 발생, 전파, 그리고 처리
Ada 예외 처리 메커니즘은 발생(raise), 전파(propagation), 처리(handling)라는 세 단계의 생명주기를 가집니다.
- 발생 (raising): 예외는 두 가지 방식으로 발생합니다. 첫째는
raise
문을 사용하여 코드에서 명시적으로 발생시키는 것입니다. 둘째는 런타임 시스템이 오류 조건을 감지하여 암시적으로 발생시키는 경우입니다. 예를 들어, 허용된 범위를 벗어나는 값을 변수에 할당하면Constraint_Error
예외가 자동으로 발생합니다. - 전파 (propagation): 일단 예외가 발생하면, 해당 코드 블록의 실행은 즉시 중단됩니다. 런타임 시스템은 현재 블록의
exception
부분에서 해당 예외를 처리할 수 있는 핸들러(when
절)를 찾습니다. 만약 적절한 핸들러가 없다면, 예외는 처리되지 않은 채로 현재 서브프로그램을 호출한 지점으로 되돌아가며 전파됩니다. 이 과정은 적절한 핸들러를 찾거나, 프로그램의 최상위 레벨(태스크 레벨)에 도달할 때까지 호출 스택을 따라 계속됩니다. - 처리 (handling):
when
절에 명시된 예외와 발생한 예외가 일치하면, 해당when
절의 코드가 실행됩니다. 이 코드는 오류를 기록하거나, 자원을 해제(예: 파일 닫기)하거나, 시스템을 안전한 상태로 전환하는 등의 복구 작업을 수행할 수 있습니다. 핸들러의 실행이 끝나면 예외는 소멸되고, 프로그램의 실행은end
키워드 다음 문장부터 정상적으로 계속됩니다.
Ada의 중요한 원칙 중 하나는, 예외가 절대 무시될 수 없다는 것입니다. 만약 예외가 최상위 레벨까지 전파되었음에도 처리되지 않으면, 해당 태스크는 즉시 종료됩니다. 이는 오류를 모른 척하고 넘어가서 시스템이 예측 불가능한 상태에 빠지는 것을 막는 ‘안전 우선(fail-safe)’ 설계 철학을 반영합니다.
결론적으로, Ada의 구조화된 예외 처리는 단순히 오류를 처리하는 편리한 방법을 넘어, 견고하고(robust) 고장 감내성(fault-tolerant)이 있는 소프트웨어를 구축하기 위한 필수적인 아키텍처입니다. 오류 처리 로직을 분리하여 코드의 명료성을 높이고, 예외의 체계적인 전파와 처리를 강제함으로써, 시스템이 예기치 않은 상황에서도 안정적으로 동작하거나 최소한 안전하게 종료되도록 보장합니다.
1.3.7 계약 기반 설계 (Design by Contract)
소프트웨어의 신뢰성을 한 차원 높은 수준으로 끌어올리는 Ada의 현대적인 특징 중 하나는 계약 기반 설계(Design by Contract, DbC)를 언어 차원에서 직접 지원하는 것입니다. 이는 서브프로그램(함수, 프로시저)과 그 사용자(호출자) 사이의 권리와 의무를 형식적인 ‘계약’으로 명시하는 설계 방법론입니다. 이 계약은 단순한 주석이나 외부 문서가 아니라, 컴파일러와 런타임 시스템이 직접 확인하고 강제할 수 있는 실행 가능한 명세입니다.
이 패러다임은 소프트웨어 컴포넌트 간의 상호작용을 명확하게 정의함으로써, “누가 무엇을 책임져야 하는가”를 코드 상에서 명시적으로 만듭니다. 이를 통해 버그의 원인을 신속하게 파악하고, 코드 자체를 정확한 문서로 활용할 수 있게 됩니다. 계약은 주로 전제조건, 후제조건, 그리고 타입 불변식이라는 세 가지 요소로 구성됩니다.
계약의 구성 요소: 전제조건과 후제조건
1. 전제조건 (precondition)
전제조건은 서브프로그램을 호출하는 호출자(client)의 의무입니다. 이는 서브프로그램이 올바르게 실행되기 위해 호출 전에 반드시 만족되어야 하는 조건입니다. 만약 전제조건이 위반되면, 그것은 서브프로그램 내부의 버그가 아니라 호출 코드의 잘못입니다. Ada에서는 with pre => ...
구문을 사용하여 이를 명시합니다.
2. 후제조건 (postcondition)
후제조건은 호출을 받아 실행되는 서브프로그램(supplier)의 의무입니다. 이는 전제조건이 만족되었다는 가정 하에, 서브프로그램이 실행을 완료한 후 보장해야 하는 결과 또는 상태입니다. 만약 후제조건이 위반되면, 그것은 서브프로그램 내부에 버그가 있음을 의미합니다. Ada에서는 with post => ...
구문을 사용하며, 특히 'old
속성을 이용해 서브프로그램 실행 전의 값과 실행 후의 값을 비교하는 검증을 수행할 수 있습니다.
코드 예시: 은행 계좌 출금 프로시저
procedure withdraw (account : in out Account; amount : Money)
with
pre => amount > 0 and then account.balance >= amount,
post => account.balance = account.balance'old - amount;
위 계약은 다음과 같은 의미를 가집니다.
- 전제조건 (호출자의 의무):
withdraw
를 호출하려면,amount
는 양수여야 하고(amount > 0
), 계좌 잔고는 출금액 이상이어야 한다(account.balance >= amount
). - 후제조건 (프로시저의 의무): 성공적으로 실행된 후, 계좌의 잔고는 ‘실행 전의 잔고(
account.balance'old
)’에서amount
를 뺀 값과 정확히 같아야 한다.
타입 불변식: 데이터의 일관성 유지
전제조건과 후제조건이 개별 서브프로그램의 동작을 규정한다면, 타입 불변식(Type Invariant)은 특정 타입의 객체가 항상 유지해야 하는 데이터의 핵심적인 일관성 또는 유효성 규칙을 정의합니다. 주로 private
타입과 함께 사용되며, 해당 타입의 객체에 대한 공개적인 연산이 끝날 때마다 이 불변식이 항상 참임을 보장해야 합니다.
코드 예시: 범위 객체의 불변식
package Ranges is
type Range_Type is private;
-- ... (Range_Type을 조작하는 서브프로그램들)
private
type Range_Type is record
low_bound : Integer;
high_bound : Integer;
end record
with type_invariant => Range_Type.low_bound <= Range_Type.high_bound;
end Ranges;
위의 Type_Invariant
는 Range_Type
객체에 대한 어떤 공개적인 작업(생성, 수정 등)이 끝난 후에도, low_bound
는 항상 high_bound
보다 작거나 같아야 한다는 규칙을 강제합니다. 만약 어떤 연산의 결과로 이 규칙이 깨진다면, 이는 해당 연산의 구현에 버그가 있음을 즉시 알려줍니다.
결론적으로, 계약 기반 설계는 프로그램의 정확성에 대한 가정을 명시적이고 검증 가능한 코드로 전환하는 패러다임입니다. 이는 단순한 버그 감지를 넘어, 소프트웨어의 각 부분이 수행해야 할 역할을 명확히 하여 디버깅을 단순화하고, 코드 자체를 신뢰할 수 있는 문서로 만들어 줍니다. Ada는 이러한 계약을 언어의 일부로 통합함으로써, 개발자가 의도한 대로 정확하게 동작하는 고신뢰성 소프트웨어를 구축할 수 있도록 지원합니다.
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 convention
과 pragma 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, C#과 같은 다른 현대 프로그래밍 언어의 개념과 비교해 볼 수 있습니다. 아래 표는 Ada의 핵심 기능이 다른 언어의 어떤 기능과 유사하며, 어떤 근본적인 차이점을 갖는지 요약한 것입니다. 이러한 비교는 다른 언어에 익숙한 개발자가 Ada의 설계 철학과 장점을 더 빠르게 파악하는 데 도움을 줄 것입니다.
Ada 개념 | 유사한 C++ 개념 | 유사한 Java/C# 개념 | Ada의 주요 차별점 |
---|---|---|---|
패키지 (Package) | 네임스페이스, 헤더/소스 분리 | 패키지 | 명세(.ads)와 구현(.adb)의 엄격한 분리를 언어 차원에서 강제하여 모듈 간 의존성 관리가 명확합니다. |
태스크 (Task) | std::thread |
Thread |
라이브러리가 아닌 언어 내장 기능으로, 랑데부(accept , select )와 같은 고수준 동기화 구문과 직접 연동됩니다. |
보호 객체 (Protected Object) | std::mutex + 데이터 |
synchronized 메서드 |
읽기(함수)와 쓰기(프로시저) 연산을 구분하여 다중-읽기/단일-쓰기 잠금을 자동으로 구현하며, 경쟁 상태를 원천적으로 방지합니다. |
제네릭 (Generics) | 템플릿 | 제네릭 | 타입뿐만 아니라 값, 서브프로그램도 제네릭 매개변수로 사용할 수 있어 유연성이 더 높습니다. |
접근 타입 (Access Type) | 포인터(* ), 참조(& ) |
참조 | 포인터 산술 연산을 금지하고, 컴파일 시점의 접근성 검사를 통해 댕글링 포인터를 방지하는 등 안전성이 크게 강화되었습니다. |
계약 기반 설계 (Contracts) | (표준 기능 없음) | (표준 기능 없음) | 전제조건, 후제조건, 불변식을 언어의 일부로 포함하여, 실행 및 정적 분석이 가능한 명세를 코드에 직접 기술할 수 있습니다. |
이러한 차이점들은 “오류는 가능한 한 빨리, 가급적 컴파일 시점에 발견되어야 한다”는 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 컴파일러란?
GNAT은 GNU 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’ 페이지를 통해 배포됩니다.
-
웹 브라우저를 통해 다음 주소로 이동합니다:
https://github.com/alire-project/alire/releases
-
가장 최신 버전의 릴리스에서 자신의 운영체제에 맞는 압축 파일을 다운로드합니다.
- 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
- Windows:
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 컴파일러를 설치하는 것이 가장 일반적인 수동 설치 방법입니다.
-
MSYS2 설치: msys2.org에서 MSYS2 설치 프로그램을 다운로드하여 설치합니다.
-
패키지 데이터베이스 업데이트: MSYS2 터미널을 실행하고 다음 명령으로 패키지 데이터베이스와 기본 패키지를 최신 상태로 업데이트합니다.
$ pacman -Syu
업데이트 과정에서 터미널을 닫았다가 다시 열어야 할 수 있습니다.
-
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
-
PATH 환경 변수 설정: 컴파일러를 일반 명령 프롬프트(cmd)나 PowerShell에서 사용하려면 MSYS2의 MinGW64
bin
디렉터리를 Windows 시스템PATH
에 추가해야 합니다.- 기본 설치 경로:
C:\msys64\mingw64\bin
- ‘시스템 속성’ -> ‘환경 변수’에서 ‘Path’ 변수에 위 경로를 추가합니다.
- 기본 설치 경로:
-
설치 확인: 새로 연 명령 프롬프트에서 다음 명령을 실행하여 설치를 확인합니다.
$ gnat --version
2.3.2 macOS
macOS에서는 Homebrew 패키지 매니저를 사용하는 것이 가장 간편한 방법입니다.
-
Homebrew 설치: Homebrew가 설치되어 있지 않다면, brew.sh의 지침에 따라 설치합니다.
-
GNAT 설치: 터미널을 열고 다음 명령을 실행하여 GCC를 설치합니다. Homebrew의 GCC는 Ada 컴파일러(GNAT)를 포함하고 있습니다.
$ brew install gcc
-
PATH 환경 변수 설정: Homebrew는 일반적으로
gnat
과 같은 컴파일러에gcc-14
,gnat-14
처럼 버전 번호를 붙여 설치하며, 심볼릭 링크를/opt/homebrew/bin
등에 생성합니다. 대부분의 경우brew install
과정에서 경로 설정이 자동으로 처리되지만, 그렇지 않은 경우 셸 설정 파일(~/.zshrc
등)에 경로를 직접 추가해야 할 수 있습니다. -
설치 확인: 터미널에서 다음 명령을 실행하여 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-ada
와 gprbuild
를 설치합니다.
$ 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
에 직접 추가해야 합니다.
-
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
(대부분 자동으로 설정됨)
- (Windows/MSYS2)
- Alire 사용자: GNAT 툴체인은 홈 디렉터리 내의
-
PATH
환경 변수 편집:- Windows: [제어판] → [시스템 및 보안] → [시스템] → [고급 시스템 설정] → [환경 변수]로 이동하여 사용자 또는 시스템 변수
Path
에 위에서 찾은bin
디렉터리 전체 경로를 추가합니다. - macOS/Linux: 셸 설정 파일(
~/.zshrc
,~/.bashrc
,~/.profile
등)을 열고 파일의 끝에 다음 라인을 추가합니다.export PATH="/path/to/your/gnat/bin:$PATH"
/path/to/your/gnat/bin
부분은 실제bin
디렉터리 경로로 수정해야 합니다.
- Windows: [제어판] → [시스템 및 보안] → [시스템] → [고급 시스템 설정] → [환경 변수]로 이동하여 사용자 또는 시스템 변수
-
변경 사항 적용:
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)를 통해 간단하게 새로운 프로젝트를 생성하는 기능을 제공합니다.
-
GNAT Studio를 실행한 후, 상단 메뉴에서 File → New Project… 를 선택하거나 시작 화면의 Create project 버튼을 클릭합니다.
-
‘New Project’ 대화 상자가 나타나면, 생성할 프로젝트의 유형을 선택합니다. 가장 기본적인 프로젝트를 위해 Simple project with main 템플릿을 선택합니다. 이 템플릿은 하나의 주(main) 서브프로그램을 포함하는 실행 가능한(executable) 프로젝트의 기본 구조를 생성합니다.
-
프로젝트의 세부 정보를 설정하는 화면에서 다음 항목들을 입력합니다.
- Name: 생성될 프로젝트 파일의 이름입니다 (예:
my_app.gpr
). - Location: 프로젝트가 생성될 디렉터리 경로를 지정합니다. GNAT Studio는 이 경로에 프로젝트 파일과 하위 디렉터리(예:
src
,obj
)를 생성합니다. - Main: 프로젝트의 진입점(entry point)이 될 Ada 소스 파일의 이름을 지정합니다. 기본값은
main.adb
입니다.
- Name: 생성될 프로젝트 파일의 이름입니다 (예:
-
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 뷰에서 특정 소스 파일을 클릭하면 중앙의 편집기 창에서 해당 파일의 내용을 확인하고 수정할 수 있습니다.
프로젝트 속성 관리
생성된 프로젝트의 설정을 변경하려면 프로젝트 속성 편집기를 사용합니다.
-
메뉴에서 Project → Properties… 를 선택합니다.
-
‘Project Properties’ 대화 상자에서 프로젝트의 다양한 측면을 수정할 수 있습니다. 주요 설정 탭은 다음과 같습니다.
- Sources: 프로젝트에 포함할 소스 디렉터리를 추가하거나 제거합니다.
- Switches: 컴파일러, 바인더(binder), 링커(linker)에 전달할 옵션(스위치)을 설정합니다. 예를 들어, ‘Ada’ 스위치 페이지에서
Style checks
와Warnings
를 활성화하여 코드 품질을 관리하는 스위치(-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)로 변환하는 과정입니다.
- 빌드 실행: 프로젝트를 빌드하려면 다음 방법 중 하나를 사용합니다.
- 메뉴:
Build
→Project
→Build All
- 단축키:
F4
- 도구 모음: 톱니바퀴 모양의 ‘Build All’ 아이콘 클릭
- 메뉴:
- 결과 확인: 빌드 과정은 GNAT Studio 하단의 Messages 창에 실시간으로 표시됩니다.
- 성공: 컴파일과 링크가 오류 없이 완료되면
Build successful
이라는 메시지가 나타납니다. 생성된 실행 파일은 프로젝트의bin
또는exec
디렉터리에 위치하며, 오브젝트 파일은obj
디렉터리에 저장됩니다. - 실패: 소스 코드에 문법 오류나 기타 문제가 있으면, Messages 창에 오류의 종류, 발생 위치(파일 및 줄 번호), 그리고 설명이 출력됩니다. 특정 오류 메시지를 클릭하면 편집기 창이 자동으로 해당 오류가 발생한 코드로 이동하므로 신속한 수정이 가능합니다.
- 성공: 컴파일과 링크가 오류 없이 완료되면
프로그램 실행 (Run)
프로젝트가 성공적으로 빌드되면, 생성된 실행 파일을 GNAT Studio 내에서 직접 실행하여 동작을 테스트할 수 있습니다.
- 실행 명령: 프로그램을 실행하려면 다음 방법을 사용합니다.
- 메뉴:
Build
→Run
→main
(또는 프로젝트의 주 실행 파일 이름) - 단축키:
Shift+F2
- 메뉴:
- 실행 결과: 콘솔 기반 애플리케이션의 경우, GNAT Studio는 별도의 터미널 창을 열어 프로그램을 실행합니다.
Ada.Text_IO.put_Line
으로 출력하는 모든 내용은 이 창에 표시되며,Get_Line
과 같은 입력 요구도 이 창을 통해 처리됩니다.
디버깅 기초 (Debugging)
디버깅은 프로그램 실행을 단계별로 추적하고 특정 지점에서의 변수 상태를 검사하여 논리적 오류(버그)의 원인을 찾는 과정입니다. GNAT Studio는 GDB(GNU Debugger)를 위한 그래픽 인터페이스를 제공합니다.
기본 디버깅 절차
-
중단점(Breakpoint) 설정: 프로그램 실행을 잠시 멈추고 싶은 코드 라인의 편집기 여백(줄 번호 왼쪽)을 클릭합니다. 빨간색 점이 나타나며, 이는 해당 라인이 실행되기 직전에 디버거가 실행을 일시 중지할 위치임을 의미합니다.
- 디버거 시작: 다음 방법 중 하나로 디버깅 세션을 시작합니다.
- 메뉴:
Debug
→Initialize
→main
- 단축키:
F5
- 메뉴:
- 실행 제어: 프로그램이 중단점에서 멈추면, 디버그 도구 모음이나
Debug
메뉴를 통해 실행 흐름을 제어할 수 있습니다.- Continue (
F6
또는F7
): 다음 중단점까지 또는 프로그램이 종료될 때까지 실행을 계속합니다. - Step Over (
F8
): 현재 줄을 실행하고 같은 서브프로그램 내의 다음 줄로 이동합니다. 현재 줄이 다른 서브프로그램 호출문이어도 그 내부로 들어가지 않고 실행만 완료합니다. - Step Into (
F9
): 현재 줄이 서브프로그램 호출문일 경우, 해당 서브프로그램의 첫 번째 줄로 진입합니다. - Step Out (
F10
): 현재 서브프로그램의 실행을 완료하고, 이 서브프로그램을 호출했던 코드로 복귀합니다.
- Continue (
- 데이터 검사: 실행이 중단된 상태에서 변수의 값을 확인할 수 있습니다.
- Data 창: 디버거 뷰의 ‘Data’ 창에 현재 유효 범위(scope)에 있는 지역 변수들의 이름과 값이 실시간으로 표시됩니다.
- 마우스 오버: 편집기에서 변수 이름 위로 마우스 커서를 가져가면 해당 변수의 현재 값이 툴팁(tooltip)으로 나타납니다.
- 디버거 종료: 디버그 도구 모음의 ‘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
): 두 변수가 각각0
과10
이라는 상수 값을 가지므로,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
는 특정 작업을 수행하는 코드 블록에 이름을 붙인 것으로, 프로그램의 진입점이자 가장 큰 틀이 됩니다.
이 골격은 세 가지 핵심 예약어로 구성됩니다.
procedure
: 프로그램의 시작을 알립니다.procedure
키워드 뒤에는 프로그래머가 정한 프로그램의 이름(식별자)이 따라옵니다.begin
: 실제 프로그램의 실행 코드가 시작되는 지점을 표시합니다.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' 프로시저 끝
코드 분석
-
with Ada.Text_IO;
(컨텍스트 절)- 화면에 글자를 출력하는 기능(
put_line
)은Ada.Text_IO
라는 표준 라이브러리 패키지 안에 들어있습니다. 이 코드는 “우리 프로그램에서Ada.Text_IO
패키지의 기능을 사용하겠습니다”라고 컴파일러에 미리 알려주는 역할을 합니다.
- 화면에 글자를 출력하는 기능(
-
procedure Hello is
(프로시저 선언)- 우리 프로그램의 이름이
Hello
임을 선언하고, 프로그램의 본체가 시작됨을 알립니다. - 이 예제는 별도의 변수나 상수를 선언할 필요가 없으므로
is
와begin
사이의 선언부가 비어 있습니다.
- 우리 프로그램의 이름이
-
begin
~end Hello;
(실행부)begin
과end 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
에 해당하며,FAILURE
는1
또는 다른 음이 아닌 값에 해당합니다.
사용 예제
다음은 특정 조건에 따라 프로그램의 성공 또는 실패 종료 상태를 설정하는 예제입니다. 가상 연산의 결과에 따라 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)이라고 합니다. 컨텍스트 절은 with
와 use
라는 두 가지 핵심 키워드를 통해 작성됩니다.
이번 절에서는 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 use
절: 이름의 직접 사용
use
절은 with
로 가져온 패키지 내부의 자원들을 점 표기법(dot notation) 없이 이름만으로 직접 사용할 수 있게 해주는 편의 기능입니다. Ada.Text_IO.put_Line
처럼 매번 패키지 이름을 전부 입력하는 것이 번거로울 때 use
절을 사용하면 코드를 더 간결하게 작성할 수 있습니다.
구문 (Syntax)
use
절은 일반적으로 with
절 바로 다음에 위치하며, 특정 패키지의 이름을 직접 사용하겠다고 선언합니다.
with Package_Name;
use Package_Name;
with
단독 사용 vs. use
함께 사용
두 방식의 차이를 “Hello, World!” 예제로 비교해 보겠습니다.
1. with
만 사용한 경우 (점 표기법 필요)
with Ada.Text_IO;
procedure Hello_With is
begin
Ada.Text_IO.put_line ("Hello!");
end Hello_With;
2. with
와 use
를 함께 사용한 경우 (직접 사용 가능)
with Ada.Text_IO;
use Ada.Text_IO; -- Ada.Text_IO의 이름을 직접 사용하겠다고 선언
procedure Hello_Use is
begin
-- 'Ada.Text_IO.' 접두사 없이 바로 put_line 호출
put_line ("Hello!");
end Hello_Use;
use
절 사용 시 주의사항
use
절은 코드를 간결하게 만들지만, 남용할 경우 오히려 코드의 명확성을 떨어뜨릴 수 있습니다. 만약 이름이 같은 put_line
프로시저를 가진 여러 패키지를 동시에 use
한다면, 어떤 패키지의 put_line
이 호출되는지 모호해질 수 있습니다.
권장 사항:
Ada.Text_IO
처럼 매우 보편적으로 사용되는 표준 패키지에 대해서는use
절을 사용하는 것이 일반적입니다.- 여러 패키지를 사용하며 이름 충돌의 여지가 있는 복잡한 프로그램에서는, 명확성을 위해
use
절 사용을 최소화하고 점 표기법을 사용하는 것이 더 좋은 선택일 수 있습니다.
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_variable
과 My_Variable
은 동일하지만, 사람에게는 다르게 보일 수 있습니다. 좋은 코드는 컴퓨터뿐만 아니라 사람이 읽고 유지보수하기 쉬워야 합니다. 따라서 프로젝트나 팀 전체에서 일관된 명명 규칙(coding convention)을 따르는 것은 매우 중요합니다.
이 책에서는 명확성과 일관성을 위해 Clair 코딩 스타일 가이드
를 기준으로 모든 예제 코드를 작성합니다. 이 규칙을 익히면 이 책의 코드를 더 쉽게 이해할 수 있으며, 여러분의 프로젝트에 적용하여 전문적이고 가독성 높은 코드를 작성할 수 있습니다.
주요 명명 규칙은 다음과 같습니다.
-
타입, 서브타입, 예외, 보호 객체 (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;
- 여러 단어일 경우 밑줄(
-
변수, 서브프로그램, 엔트리 (Variables, subprograms, Entries):
snake_case
(소문자)- 모든 실행 가능하거나 데이터를 담는 식별자를 일관된 스타일로 유지하여 가독성을 높입니다.
- 예시:
current_temperature : Float; procedure print_report (report_data : in String); -- 보호 객체 내 엔트리 예시 entry get_item (item : out Data);
-
상수 (Constants):
UPPER_CASE
- 프로그램 실행 중에 변하지 않는 고정된 값임을 시각적으로 강조합니다.
- 예시:
PI : constant := 3.14159; MAX_BUFFER_SIZE : constant Natural := 1024;
-
패키지 (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 언어의 문법에서 특별한 의미를 갖도록 미리 정의된 단어입니다. 이 단어들은 컴파일러가 코드의 구조와 동작을 이해하는 데 사용되므로, 프로그래머가 변수, 서브프로그램, 타입 등의 이름, 즉 식별자(identifier)로 사용할 수 없습니다.
예를 들어, if
는 조건문을 시작하는 예약어이고, procedure
는 프로시저 정의를 시작하는 예약어입니다. 만약 if
라는 이름의 변수를 만들려고 시도하면, 컴파일러는 이를 변수 선언이 아닌 조건문으로 해석하려다 문법 오류를 발생시킵니다.
-- 잘못된 사용 예시
procedure invalid_example is
-- 'begin'은 예약어이므로 변수명으로 사용할 수 없습니다.
begin : Integer := 10; -- 컴파일 오류 발생!
begin
null;
end invalid_example;
Ada 언어의 예약어 목록을 미리 숙지해 두면 명명 규칙을 정할 때 실수를 피할 수 있습니다. 다음은 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 |
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
타입의 값을 나타내며, String
은 Character
의 배열로 정의됩니다.
여러 줄 문자열
문자열이 너무 길어서 한 줄에 모두 표시하기 어려운 경우, 앰퍼샌드(&
) 연산자를 사용하여 여러 줄에 걸쳐 문자열을 이어 붙일 수 있습니다.
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에서 주석을 작성하는 방법은 매우 간단하고 일관됩니다. 두 개의 하이픈(--
)을 사용하면, 그 지점부터 해당 라인의 끝까지 모든 텍스트가 주석으로 처리됩니다.
주석의 위치
주석은 코드의 두 가지 주요 위치에 사용될 수 있습니다.
-
한 라인 전체 사용: 주석이 한 라인 전체를 차지하는 경우입니다. 보통 코드 블록 전체의 목적을 설명하거나, 여러 줄에 걸쳐 상세한 설명을 제공할 때 사용됩니다.
-
코드 끝에 추가: 코드 문장이 끝난 뒤, 같은 라인에 주석을 추가하는 경우입니다. 특정 변수 선언이나 한 줄의 코드에 대한 간결한 설명을 덧붙일 때 유용합니다.
다음은 주석 사용법을 보여주는 예제입니다.
-- 이것은 전체 라인을 차지하는 주석입니다.
-- 프로시저의 목적이나 전반적인 동작을 설명할 수 있습니다.
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)
문장(statement)은 프로그램의 실행부에서 컴퓨터에게 “무엇을 할 것인지” 지시하는 하나의 완전한 명령 단위입니다.
이는 마치 우리가 일상 언어에서 사용하는 ‘문장’과 같습니다. 여러 단어가 모여 하나의 완전한 의미를 가진 문장을 만들듯, Ada에서는 식별자, 리터럴, 연산자 등이 모여 하나의 실행 가능한 문장을 구성합니다. 프로그램은 이러한 문장들이 순서대로 나열된 집합체입니다.
Ada의 모든 문장은 반드시 세미콜론(;
)으로 끝나야 합니다. 세미콜론은 문장의 끝을 알리는 마침표와 같은 역할을 하며, 각각의 명령을 명확하게 구분해 줍니다.
이번 절에서는 값을 변수에 저장하는 대입문, 다른 서브프로그램을 호출하는 프로시저 호출문 등 가장 기본적이면서도 중요한 문장의 종류에 대해 알아보겠습니다.
3.6.1 문장(statement)과 세미콜론(;
)의 역할
문장 (statement)
문장(statement)은 프로그램을 구성하는 가장 작은 실행 단위입니다. 프로그램의 실행부에 있는 각 문장은 컴퓨터가 수행해야 할 하나의 완전한 명령을 나타냅니다. 예를 들어, 변수에 값을 할당하는 것, 화면에 글자를 출력하는 것 모두 하나의 문장으로 표현됩니다.
세미콜론 (semicolon)
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 블록문
블록문(block statement)은 실행부 내에서 지역적인 유효 범위(local scope)를 만들 수 있는 강력한 기능입니다. 이를 통해 특정 작업에만 필요한 변수를 임시로 선언하고 사용하여, 코드의 다른 부분에 영향을 주지 않도록 할 수 있습니다.
블록문은 이름 없는 작은 프로시저처럼 자신만의 선언부(declare
~ begin
)와 실행부(begin
~ end;
)를 가집니다.
구문 (Syntax)
블록문은 declare
키워드로 시작하며, 전체 구조가 하나의 문장으로 취급되어 마지막 end
뒤에 세미콜론(;
)이 붙습니다.
declare
-- 이 블록 안에서만 사용할 지역 변수, 상수 등을 선언
begin
-- 선언된 지역 변수를 사용하여 수행할 작업
exception
-- 이 블록에서 발생한 예외를 처리 (선택 사항)
end;
코드 예시
두 변수의 값을 맞바꾸는(swap) 고전적인 예제를 통해 블록문의 유용성을 살펴보겠습니다. 값을 바꾸기 위해서는 임시 변수가 필요하지만, 이 변수는 맞바꾸는 작업 외에는 쓸모가 없습니다.
procedure Block_Example is
X : Integer := 10;
Y : Integer := 20;
begin
-- X와 Y의 값을 바꾸기 위한 블록문
declare
-- Temp는 이 블록 안에서만 생성되고 사용된 후 사라집니다.
Temp : Integer;
begin
Temp := X;
X := Y;
Y := Temp;
end; -- 이 지점에서 Temp 변수는 메모리에서 사라짐
-- 이제 X는 20, Y는 10이 됨
end Block_Example;
이처럼 블록문은 임시 변수가 불필요하게 전체 프로시저에 노출되는 것을 막아주어, 코드의 가독성을 높이고 잠재적인 실수를 줄이는 데 도움을 줍니다.
3.6.5 null
문
null
문은 아무런 동작도 수행하지 않는, 실행 가능한 문장입니다. 이것의 주된 목적은 구문적으로는 문장이 반드시 위치해야 하지만, 논리적으로는 아무런 작업도 필요하지 않은 경우에 코드의 의도를 명확히 하는 것입니다.
null
문을 사용함으로써, 프로그래머는 해당 위치의 코드 누락이 실수가 아니라 의도된 부재(intentional absence)임을 명시적으로 표현할 수 있습니다.
구문 (syntax)
null
문의 구문은 단순히 키워드 null
과 세미콜론으로 이루어집니다.
null;
주요 활용 사례
null
문은 조건문이나 예외 처리기 등에서 특정 경우를 의도적으로 무시하고 싶을 때 매우 유용합니다.
-
case
문에서 특정 선택지 무시:case
문에서 일부 선택지에 대해서는 아무런 동작도 수행할 필요가 없을 때 사용됩니다.코드 예시 4-20: 일부
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_Update
와User_Login
이벤트는 의도적으로 아무런 처리를 하지 않음을null;
을 통해 명확히 보여줍니다. -
예외 처리기에서 예외 무시: 특정 예외가 발생했을 때 이를 인지하고는 있지만, 복구 조치 없이 정상적으로 실행을 계속하고자 할 때 사용됩니다.
코드 예시 4-21: 특정 예외를 의도적으로 무시
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;
-
개발 중 스텁(Stub) 역할: 아직 구현되지 않은 서브프로그램(프로시저, 함수)의 몸체를 임시로 채우는 용도로 사용할 수 있습니다.
procedure initialize_hardware is begin -- TODO: 하드웨어 초기화 로직 구현 예정 null; end initialize_hardware;
결론적으로 null
문은 코드를 실행하지 않는다는 소극적인 역할을 하지만, 코드의 명확성과 가독성을 높여 “비어 있는” 것이 아니라 “의도적으로 비워둔” 것임을 전달하는 중요한 소통 도구입니다.
3.7 프라그마 (pragma) 소개
프라그마(pragma)는 프로그램의 동작을 직접 서술하는 일반적인 코드와 달리, Ada 컴파일러에게 특정 행동을 지시하거나 정보를 제공하는 특별한 명령어입니다.
프로그래머는 프라그마를 통해 코드 최적화 방식을 제어하거나, 다른 언어로 작성된 함수를 가져오거나, 특정 검사를 비활성화하는 등 컴파일러의 동작에 영향을 줄 수 있습니다. 즉, 프라그마는 프로그램의 로직이 아닌, 컴파일 과정 자체를 제어하기 위한 언어 내의 공식적인 소통 창구입니다.
이번 절에서는 프라그마의 정확한 정의와 기본 구문, 그리고 실제 코드에서 자주 사용되는 몇 가지 중요한 프라그마의 예시를 통해 그 쓰임새를 알아보겠습니다.
3.7.1 프라그마의 정의: 컴파일러에 대한 지시어
프라그마(Pragma)는 소스 코드 내에 위치하여 컴파일러에게 특정 정보를 전달하거나 특별한 동작을 지시하는 명령어입니다.
일반적인 Ada 코드가 “프로그램이 실행 시간에 무엇을 할 것인가”를 정의한다면, 프라그마는 “컴파일러가 이 코드를 어떻게 처리할 것인가”를 제어합니다. 즉, 프로그램의 알고리즘을 바꾸는 것이 아니라 컴파일 과정이나 최종 결과물에 영향을 미치는 메타(meta) 명령어라고 할 수 있습니다.
예를 들어, 프로그래머는 프라그마를 사용하여 다음과 같은 작업을 수행할 수 있습니다.
- 특정 코드 영역의 최적화 수준을 조절합니다.
- C나 Fortran 같은 다른 언어로 작성된 함수를 Ada 프로그램으로 가져옵니다.
- 특정 예외 검사를 일시적으로 비활성화하여 성능을 높입니다.
이러한 기능들은 컴파일러 제조사마다 다른 방식으로 제공될 수 있지만, Ada는 프라그마를 언어 표준에 포함함으로써 이식성 높은 방식으로 컴파일러를 제어할 수 있는 강력하고 일관된 수단을 제공합니다.
3.7.2 프라그마의 기본 구문
프라그마는 예약어 pragma
로 시작하며, 그 뒤에 프라그마의 이름과 필요한 인자(argument)를 기술하는 형태로 구성됩니다. 프라그마는 두 가지 기본 형식을 가집니다.
인자가 없는 프라그마
일부 프라그마는 단순히 그 이름만으로 컴파일러에 특정 지시를 내립니다.
형식:
pragma 프라그마_이름;
예시:
-- 현재 컴파일 단위의 본문이 정교화(elaborated)될 필요가 있음을 알림
pragma elaborate_body;
인자가 있는 프라그마
대부분의 프라그마는 괄호 ()
안에 하나 이상의 인자를 전달받아 동작을 더 구체적으로 지정합니다. 인자는 식별자, 리터럴 등이 될 수 있습니다.
형식:
pragma 프라그마_이름 (인자1, 인자2, ...);
예시:
-- C 언어의 함수를 가져오는 예시
-- pragma import (규약, 내부_이름, 외부_이름);
pragma import (c, puts, "puts");
-- 특정 검사를 비활성화하는 예시
-- pragma suppress (검사_이름);
pragma suppress (index_check);
프라그마는 보통 선언부나 문장이 올 수 있는 위치에 작성하여, 특정 범위에만 영향을 미치도록 할 수 있습니다.
3.7.3 일반적인 프라그마 예시 (elaborate_body
, suppress
, import
)
프라그마는 매우 다양하지만, 실제 개발에서 자주 사용되는 몇 가지 중요한 프라그마들이 있습니다. 여기서는 세 가지 대표적인 프라그마를 통해 그 쓰임새를 알아보겠습니다.
pragma elaborate_body
pragma elaborate_body
는 패키지의 명세 파일(.ads
)에 위치하며, 해당 패키지의 본문(.adb
) 코드가 다른 코드 유닛보다 먼저 실행(정교화)되어야 함을 컴파일러에 알립니다.
패키지 본문에는 종종 프로그램 시작 시 단 한 번 실행되어야 하는 초기화 코드가 포함됩니다. 만약 다른 패키지가 이 초기화가 끝나기 전에 해당 패키지의 기능을 사용하려고 하면 Program_Error
예외가 발생할 수 있습니다. pragma elaborate_body
는 이러한 의존성 순서를 명시하여 프로그램의 실행 순서를 안정적으로 보장하는 역할을 합니다.
구문:
package My_Package is
pragma elaborate_body;
-- ...
end My_Package;
pragma suppress
pragma suppress
는 특정 코드 영역에서 런타임에 수행되는 안전성 검사를 일시적으로 비활성화하는 데 사용됩니다. 🤫
Ada는 배열의 인덱스 범위를 벗어나는지, 숫자 타입의 범위를 초과하는지 등을 실행 시간에 자동으로 검사합니다. 이러한 검사는 매우 유용하지만, 성능이 극도로 중요한 코드 영역에서는 오버헤드가 될 수 있습니다. pragma suppress
를 사용하면 개발자가 안전하다고 확신하는 부분의 검사를 꺼서 성능을 최적화할 수 있습니다.
구문:
procedure Critical_loop is
-- 이 프로시저 내에서는 인덱스 검사를 수행하지 않음
pragma suppress (index_check);
My_Array : array (1 .. 10) of Integer;
begin
for I in 1 .. 10 loop
-- ...
end loop;
end Critical_loop;
⚠️ 주의:
pragma suppress
는 코드의 안전성을 저하시킬 수 있으므로 매우 신중하게 사용해야 합니다. 해당 코드 영역이 절대로 오류를 발생시키지 않는다는 것이 완벽하게 증명된 경우에만 제한적으로 사용해야 합니다.
pragma import
pragma import
는 C, Fortran 등 다른 프로그래밍 언어로 작성된 서브프로그램(함수)을 Ada 코드에서 호출할 수 있도록 연결하는 역할을 합니다. 🤝
이 프라그마는 특정 서브프로그램의 구현이 Ada 코드 내에 있는 것이 아니라 외부에 있다는 사실을 컴파일러에 알립니다.
구문:
-- C 언어의 표준 출력 함수 puts를 가져오는 예시
package C_Interface is
-- C 언어의 puts 함수를 Ada의 puts 프로시저로 가져온다.
procedure Puts (S : Interfaces.C.Strings.chars_ptr);
pragma import (c, Puts, "puts");
end C_Interface;
위 예시에서 프라그마는 컴파일러에게 다음 정보를 전달합니다.
c
: 외부 함수의 언어 규약이 C 언어임을 알립니다.Puts
: Ada 코드 내에서 사용할 이름입니다."puts"
: 외부 라이브러리에 있는 실제 함수의 이름(심볼)입니다.
4. 스칼라 타입 (Scalar Types)
스칼라 타입은 분해할 수 없는 단일 값을 나타내는 데이터 타입입니다. Ada의 스칼라 타입은 크게 정수 타입, 실수 타입, 열거형 타입으로 나뉘며, 이는 프로그램이 다루는 데이터의 특성을 명확히 하고 컴파일러가 엄격한 검사를 수행할 수 있는 기반을 제공합니다.
4.1 정수 타입 (Integer Types)
정수 타입은 소수부가 없는 완전한 수(whole number)를 표현하기 위해 사용됩니다. Ada는 단일 int
타입만을 제공하는 여러 언어와 달리, 표현 범위와 부호 유무에 따라 다양한 정수 타입을 제공하여 프로그래머가 데이터의 특성을 더 정확하게 모델링할 수 있도록 지원합니다.
4.1.1 Integer
와 부호 있는 정수
Integer
는 Ada의 표준 라이브러리에 미리 정의된 기본적인 부호 있는(signed) 정수 타입입니다. 이 타입의 정확한 범위(Integer'First
.. Integer'Last
)는 컴파일러와 하드웨어 아키텍처에 따라 달라질 수 있지만, 최소 16비트 범위(–32,767 .. +32,767) 이상을 보장합니다. 현대의 32비트 또는 64비트 시스템에서는 통상적으로 훨씬 더 넓은 범위를 가집니다.
프로그래머는 내장된 Integer
타입 외에도, range
키워드를 사용하여 특정 범위를 갖는 새로운 정수 타입을 직접 정의할 수 있습니다.
type My_Integer is range -100 .. 100;
이렇게 정의된 My_Integer
는 Integer
와는 호환되지 않는 완전히 새로운 타입입니다. 따라서 서로 다른 정수 타입 간의 값 대입은 명시적인 형 변환 없이는 허용되지 않아, 논리적으로 다른 종류의 데이터를 실수로 혼용하는 것을 방지합니다.
Clair 라이브러리에서는 C 언어와의 호환성을 위해 Interfaces.C.int
타입을 주로 사용하며, 이는 시스템의 int
와 동일한 크기를 갖는 부호 있는 정수 타입으로 동작합니다.
4.1.2 Natural
과 Positive
: 서브타입을 이용한 제약
서브타입(subtype)은 새로운 타입을 만드는 것이 아니라, 기존 타입(기반 타입)에 추가적인 제약(주로 범위 제약)을 가하여 새로운 이름을 부여하는 것입니다. 서브타입의 변수는 기반 타입의 변수와 자유롭게 값을 주고받을 수 있지만, 서브타입이 정의한 제약을 위반하는 값은 할당될 수 없습니다.
Ada 표준 라이브러리는 Integer
의 서브타입으로 Natural
과 Positive
를 미리 정의하여 제공합니다.
Natural
:0 .. Integer'Last
범위를 갖는 서브타입입니다. 음수가 될 수 없는 값(예: 배열의 인덱스, 개수, 크기)을 표현하는 데 매우 유용합니다.Positive
:1 .. Integer'Last
범위를 갖는 서브타입입니다. 반드시 양수여야 하는 값을 표현하는 데 사용됩니다.
이러한 서브타입을 사용하면 코드의 의도가 명확해지고, 런타임에 제약 조건을 위반하는 값이 할당될 경우 Constraint_Error
예외가 발생하여 잠재적인 논리적 버그를 즉시 발견할 수 있습니다.
Clair 라이브러리의 clair-file.adb
파일은 Natural
타입을 효과적으로 사용합니다. 시스템 호출이 실패하여 재시도하는 횟수나, read
함수가 반환하는 읽어들인 바이트 수는 음수가 될 수 없으므로 Natural
타입으로 선언되어 있습니다.
-- file: src/clair-file.adb
-- 재시도 횟수는 0 이상이어야 하므로 Natural 타입을 사용합니다.
retry_count : Natural := 0;
-- ...
-- read 함수는 읽어들인 바이트 수(0 또는 양수)를 반환합니다.
function read (fd : in Descriptor;
buffer : in out System.Storage_Elements.Storage_Array)
return Natural is
-- ...
4.1.3 모듈러 타입 (Modular Types)
모듈러 타입은 정해진 범위를 초과하는 연산이 발생했을 때 예외를 발생시키는 대신, “순환(wrap-around)”하는 부호 없는(unsigned) 정수 타입입니다. 이는 하드웨어 레지스터, 해시 함수, 암호화 알고리즘 등 특정 값(모듈러스)을 기준으로 순환하는 연산이 필요한 경우에 필수적입니다.
모듈러 타입은 mod
키워드를 사용하여 선언합니다.
type Byte is mod 256; -- 0..255 범위의 8비트 부호 없는 정수를 표현합니다.
type Byte is mod 256;
로 선언된 변수 V
의 값이 255
일 때, V + 1
연산의 결과는 Constraint_Error
가 아니라 0
이 됩니다. 이러한 특성은 저수준 하드웨어 제어나 특정 수학적 연산을 구현할 때 정확하고 예측 가능한 동작을 보장합니다.
4.2 실수 타입 (Real Types)
실수 타입은 소수부를 포함할 수 있는 숫자를 표현하는 데 사용됩니다. Ada는 실수를 표현하기 위한 두 가지 명확히 구분되는 메커니즘을 제공합니다: 과학 및 공학 계산에 적합한 부동소수점 타입과, 재무 계산과 같이 절대적인 정밀도가 요구되는 응용 분야에 적합한 고정소수점 타입입니다.
4.2.1 부동소수점 타입 (Floating-Point Types)
부동소수점 타입은 숫자를 가수(mantissa)와 지수(exponent)의 조합으로 저장하여 매우 넓은 범위의 값을 표현할 수 있습니다. 이는 값의 절대적인 정밀도보다는 유효 숫자의 개수에 기반한 상대적인 정밀도를 제공하므로, 과학 계산이나 그래픽 처리 등 값의 크기가 다양하게 변하는 분야에 적합합니다.
Ada의 표준 부동소수점 타입으로는 Float
가 있으며, 더 높은 정밀도가 필요할 경우 Long_Float
등을 사용할 수 있습니다. 사용자는 digits
키워드를 사용하여 원하는 최소 유효 십진수 자릿수를 지정하는 새로운 부동소수점 타입을 직접 정의할 수 있습니다.
type Acceleration is digits 8 range -1.0E9 .. 1.0E9;
위 선언은 최소 8자리의 십진수 정밀도를 가지는 Acceleration
타입을 정의합니다. 컴파일러는 이 요구사항을 만족하는 가장 효율적인 하드웨어 부동소수점 표현(예: IEEE 754 단정도 또는 배정도)을 자동으로 선택합니다. 이 방식은 single
이나 double
과 같이 하드웨어에 종속적인 키워드를 사용하는 것보다 이식성이 높습니다.
부동소수점 연산은 미세한 반올림 오차를 내재하고 있으므로, 정확한 금액 계산이 필요한 재무 응용 프로그램 등에서는 사용에 주의가 필요합니다.
4.2.2 고정소수점 타입 (Fixed-Point Types)
고정소수점 타입은 소수점 이하 자릿수가 고정되어 있어 값의 절대적인 정밀도를 보장합니다. 이는 부동소수점 타입에서 발생하는 반올림 오차를 원천적으로 제거하므로, 재무 계산이나 정밀한 측정이 필요한 센서 데이터 표현에 이상적입니다.
고정소수점 타입은 delta
키워드를 사용하여 정의하며, delta
값은 해당 타입이 표현할 수 있는 최소 정밀도(절대 오차 한계)를 지정합니다.
type Currency is delta 0.01 range 0.0 .. 1_000_000.00;
위 선언은 0.01
단위로 값을 표현하는 Currency
타입을 정의합니다. 이 타입의 변수는 항상 소수점 두 번째 자리까지의 정밀도를 유지하며, 이보다 작은 값은 표현되지 않습니다. 따라서 금액과 같이 정확한 소수부 연산이 필요할 때 매우 유용합니다.
Ada에는 시간을 표현하기 위한 내장된 고정소수점 타입인 Duration
이 있습니다. Duration
은 초 단위의 시간 간격을 나타내며, delay
문에서 사용됩니다.
-- file: src/clair-file.adb
-- delay 문에 사용된 실수 리터럴 0.0은
-- 내장된 고정소수점 타입인 Duration의 값으로 해석됩니다.
delay 0.0;
이처럼 Ada는 응용 분야의 요구사항에 따라 상대적 정밀도의 부동소수점과 절대적 정밀도의 고정소수점 중에서 적절한 타입을 선택할 수 있도록 하여, 프로그램의 정확성과 신뢰성을 높입니다.
4.3 열거형 타입 (Enumeration Types)
열거형 타입은 서로 연관된 순서 있는 값들의 집합을 명명된 상수로 정의하는 데이터 타입입니다. 이는 정수와 같은 “매직 넘버(magic number)”를 사용하는 것에 비해 코드의 가독성과 타입 안전성을 획기적으로 향상시킵니다.
4.3.1 열거형 타입의 정의와 장점
프로그램에서 특정 상태나 옵션을 표현할 때, 0
은 부모 프로세스
, 1
은 자식 프로세스
와 같이 정수 상수로 약속하여 사용하는 경우가 많습니다. 이러한 접근 방식은 다음과 같은 문제점을 가집니다.
- 가독성 저하:
if status = 1
과 같은 코드는1
이 무엇을 의미하는지 즉시 파악하기 어렵습니다. - 타입 불안정성:
status
변수에 약속되지 않은 값(예:2
또는-1
)이 할당되는 것을 컴파일러가 막을 수 없어, 런타임에 예기치 않은 버그를 유발할 수 있습니다.
열거형 타입은 이러한 문제를 해결하기 위해, 가능한 모든 값을 의미 있는 이름으로 나열하여 새로운 타입을 정의합니다. 컴파일러는 해당 타입의 변수에 열거된 값들만 대입될 수 있도록 강제하여 타입 안전성을 보장합니다.
4.3.2 선언 및 사용
열거형 타입은 괄호 안에 가능한 값(식별자)들을 쉼표로 구분하여 나열하는 방식으로 선언합니다. Clair 라이브러리의 Clair.Process
패키지는 fork
시스템 호출의 결과를 나타내기 위해 다음과 같은 열거형 타입을 정의합니다.
-- file: src/clair-process.ads
-- fork 호출의 결과를 나타내는 열거형 타입
type Fork_Status is (Parent, Child);
Fork_Status
타입의 변수는 오직 Parent
또는 Child
라는 두 가지 값만 가질 수 있습니다.
이렇게 정의된 열거형 타입은 변수 선언 및 제어 구조에서 자연스럽게 사용되어 코드의 의도를 명확하게 만듭니다.
-- file: tests/test_clair_process.adb
declare
-- fork의 결과는 status 필드에 Fork_Status 값을 가집니다.
result : Clair.Process.Fork_Result := Clair.Process.fork;
begin
-- case 문을 통해 fork의 결과를 명확하게 처리합니다.
case result.status is
when Clair.Process.Parent =>
Ada.Text_IO.put_line ("OK (Parent sent signal)");
when Clair.Process.Child =>
-- ... 자식 프로세스의 로직 ...
null;
end case;
end;
case
문에서 Fork_Status
타입이 가질 수 있는 모든 값(Parent
, Child
)을 처리했는지 컴파일러가 검사하므로, 새로운 상태가 추가되었을 때 코드 수정을 누락하는 실수를 방지할 수 있습니다.
4.3.3 열거형 타입의 속성
Ada는 열거형 타입에 대한 유용한 정보를 얻을 수 있는 여러 속성(Attribute)을 제공합니다. 주요 속성은 다음과 같습니다.
-
'First
와'Last
: 타입 선언에서 첫 번째와 마지막 값을 반환합니다.Fork_Status'First
는Parent
를 반환합니다.Fork_Status'Last
는Child
를 반환합니다.
-
'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.4 Boolean
타입과 논리 연산
Boolean
타입은 조건부 논리를 표현하고 프로그램의 실행 흐름을 제어하는 데 사용되는 핵심적인 스칼라 타입입니다. 이 타입은 오직 두 가지 값, 즉 True
와 False
만을 가질 수 있습니다.
4.4.1 Boolean
타입
Boolean
은 Ada 표준 라이브러리에 다음과 같이 미리 정의된 열거형 타입입니다.
type Boolean is (False, True);
이 정의에 따라 False
는 True
보다 작은 값으로 취급되며 (Boolean'Pos(False)
는 0, Boolean'Pos(True)
는 1), 이는 논리 연산의 기반이 됩니다.
Boolean
값은 주로 비교 연산자(=
, /=
, <
, >
, <=
, >=
)의 결과로 생성되며, if
, while
, exit when
과 같은 제어문의 조건으로 사용되어 프로그램의 흐름을 결정합니다.
is_ready : Boolean := False;
counter : Integer := 10;
...
-- 비교 연산의 결과로 Boolean 값이 할당됩니다.
is_ready := (counter > 0); -- counter가 10이므로 is_ready는 True가 됩니다.
if is_ready then
-- ...
end if;
4.4.2 논리 연산자 (Logical Operators)
Ada는 Boolean
값들을 조합하여 더 복잡한 논리 조건을 만들기 위한 여러 연산자를 제공합니다.
연산자 | 종류 | 의미 |
---|---|---|
not |
단항 | 논리 부정 (결과를 반전) |
and |
이항 | 논리 곱 (두 피연산자 모두 True 일 때 True ) |
or |
이항 | 논리 합 (두 피연산자 중 하나라도 True 일 때 True ) |
xor |
이항 | 배타적 논리합 (두 피연산자가 서로 다를 때 True ) |
이 연산자들은 항상 양쪽 피연산자를 모두 평가합니다. 하지만 Ada는 특정 조건에서 불필요한 연산을 생략하여 효율성과 안전성을 높이는 단락(short-circuit) 제어문을 제공합니다.
-
and then
(단락 논리 곱)A and then B
에서,A
가False
로 평가되면B
는 아예 평가되지 않고 전체 결과는 즉시False
가 됩니다. 이는 두 번째 피연산자를 평가하는 과정에서 오류가 발생할 수 있는 상황을 안전하게 방지하는 데 매우 유용합니다.예를 들어, 널(null)일 수 있는 접근(포인터) 변수를 검사할 때
and then
을 사용하면 안전합니다.-- Ptr이 null인 경우, Ptr.all 접근은 Program_Error를 유발합니다. -- 'and then'을 사용하면 Ptr이 null일 때 뒷부분이 실행되지 않아 안전합니다. if Ptr /= null and then Ptr.all.value > 10 then -- ... Ptr.all에 안전하게 접근 ... end if;
-
or else
(단락 논리 합)A or else B
에서,A
가True
로 평가되면B
는 평가되지 않고 전체 결과는 즉시True
가 됩니다. 이는 비용이 많이 드는 연산을 불필요하게 수행하는 것을 방지하여 성능을 향상시킬 수 있습니다.-- Is_Cached가 True이면, 비용이 많이 드는 DB 조회를 수행하지 않습니다. if Is_Cached or else Data_Is_Available_In_DB then -- ... 데이터를 사용 ... end if;
단락 제어문은 오류를 방지하고 코드를 최적화하는 도구이므로, 일반 논리 연산자보다 우선적으로 사용하는 것이 권장됩니다.
4.5 Character
타입과 문자 처리
Character
타입은 단일 문자를 표현하기 위해 사용되는 Ada의 기본적인 스칼라 타입입니다. 이는 단순한 데이터 단위를 넘어, 타입 안전성을 보장하고 명확한 문자 연산을 지원하기 위한 다양한 내장된 기능과 표준 라이브러리를 갖추고 있습니다.
4.5.1 Character
타입
Ada의 Character
타입은 표준 라이브러리에 미리 정의된 열거형 타입입니다. 이는 Character
가 문자의 집합일 뿐만 아니라, 각 문자가 명확한 순서를 가지는 값들의 목록임을 의미합니다.
-
문자 집합 (Character Set): Ada의 표준
Character
타입은 256개의 문자로 구성된 ISO/IEC 8859-1 (Latin-1) 표준을 기반으로 합니다. Latin-1은 7비트 ASCII 문자 집합을 완전히 포함하며, 서유럽 언어에서 사용되는 추가적인 문자들을 포함합니다. -
열거형 속성:
Character
는 열거형 타입이므로, 다른 열거형 타입과 마찬가지로 다양한 속성(Attribute)을 가집니다.'Pos
와'Val
속성을 사용하여 문자와 그에 해당하는 코드 번호 사이를 변환할 수 있습니다. 예를 들어,Character'Pos('A')
는 65를 반환하고,Character'Val(65)
는'A'
를 반환합니다.- 순서가 정의되어 있으므로 비교 연산(
'<'
,'>'
등)이 가능합니다. 예를 들어,'a' > 'A'
는True
로 평가됩니다.
-
리터럴: 문자 리터럴은 작은따옴표(
'
)로 묶인 단일 문자로 표현됩니다. (예:'A'
,'%'
,' '
) -
확장 문자 타입: 국제화(Internationalization)를 지원하기 위해 Ada는
Wide_Character
(16비트, 유니코드 BMP)와Wide_Wide_Character
(32비트, 유니코드 전체) 타입도 제공합니다.
4.5.2 문자 처리와 관련 패키지
개별 문자의 속성을 검사하거나 대소문자를 변환하는 등의 복잡한 문자 처리는 표준 라이브러리 패키지인 Ada.Characters.Handling
을 통해 수행하는 것이 일반적입니다. 이 패키지는 이식성 있고 명확한 코드를 작성하도록 돕습니다.
Ada.Characters.Handling
패키지의 주요 기능은 다음과 같습니다.
-
문자 분류 (Classification): 주어진 문자가 특정 범주에 속하는지 검사하는 함수들입니다.
Is_Letter(C)
,Is_Digit(C)
,Is_Alphanumeric(C)
Is_Upper(C)
,Is_Lower(C)
Is_Control(C)
,Is_Space(C)
-
대소문자 변환 (Case Conversion): 문자를 대문자 또는 소문자로 변환합니다.
To_Upper(C)
,To_Lower(C)
사용 예시:
with Ada.Text_IO;
with Ada.Characters.Handling;
procedure Character_Test is
My_Char : Character := 'f';
begin
if Ada.Characters.Handling.Is_Letter (My_Char) then
Ada.Text_IO.put_line ("It's a letter.");
end if;
-- 'f'를 대문자 'F'로 변환하여 출력
Ada.Text_IO.put_line (Ada.Characters.Handling.To_Upper (My_Char));
end Character_Test;
Clair와 같은 시스템 프로그래밍 라이브러리에서는 C 언어 함수와의 연동이 빈번하게 발생합니다. 이 경우, Ada의 String
타입(Character
의 배열)은 C 언어가 사용하는 char
배열로 변환되어야 합니다. Interfaces.C
패키지의 To_C
함수가 이 역할을 수행하며, 이는 Ada의 문자 타입이 저수준 시스템 프로그래밍에서도 어떻게 활용되는지를 보여주는 실용적인 사례입니다.
4.6 서브타입 (subtype)과 범위 제약
Ada의 타입 시스템에서 서브타입은 프로그램의 신뢰성을 높이는 매우 강력하고 독자적인 기능입니다. 서브타입은 기존의 타입에 제약을 추가하여 새로운 이름을 부여함으로써, 데이터가 가질 수 있는 값의 범위를 더욱 정밀하게 제어할 수 있게 합니다.
4.6.1 서브타입의 개념과 필요성
새로운 타입을 만드는 type
선언과 달리, subtype
선언은 새로운 타입을 만들지 않습니다. 대신, 기존 타입(기반 타입)의 부분집합에 새로운 이름을 붙이고 제약 조건을 명시합니다. 이로 인해 서브타입은 자신의 기반 타입과 완벽하게 호환되므로, 별도의 형 변환 없이 값을 서로 대입할 수 있습니다.
type
과 subtype
의 차이:
type T is new Integer;
:Integer
와 호환되지 않는 완전히 새로운 타입을 만듭니다.subtype S is Integer;
:Integer
와 완벽히 호환되는 타입의 별칭(또는 제약된 버전)을 만듭니다.
서브타입은 다음과 같은 핵심적인 이점을 제공합니다.
- 가독성 향상:
subtype Day_Of_Month is Integer range 1 .. 31;
과 같이 서브타입에 의미 있는 이름을 부여하면, 코드 그 자체가 문서의 역할을 하여 데이터의 의도를 명확하게 전달합니다. - 신뢰성 강화: 서브타입에 정의된 제약(예: 범위)은 컴파일러와 런타임 시스템에 의해 강제됩니다. 제약 조건을 위반하는 값을 할당하려는 시도는 프로그램 실행 중에 즉시 감지되어 오류로 처리됩니다.
4.6.2 서브타입 선언 및 사용
서브타입은 subtype
키워드를 사용하여 선언하며, 가장 일반적인 제약은 range
를 이용한 범위 제약입니다.
subtype <서브타입_이름> is <기반_타입> range <최소값> .. <최대값>;
Clair 라이브러리의 clair-signal.ads
에서는 타입에 더 명확한 의미를 부여하기 위해 서브타입을 사용합니다.
-- file: src/clair-signal.ads
package Clair.Signal is
-- Interfaces.C.int에 'Number'라는 의미 있는 이름을 부여합니다.
subtype Number is Interfaces.C.int;
-- sys_signal_h 패키지에 정의된 타입을 더 간결하게 사용하기 위한 서브타입 선언입니다.
subtype Action is sys_signal_h.sigaction;
subtype Info is sys_signal_h.siginfo_t;
subtype Set is sys_signal_h.sigset_t;
end Clair.Signal;
범위 제약을 활용하는 가장 대표적인 예는 앞서 다룬 Natural
과 Positive
입니다. 이들은 Integer
의 서브타입으로, 각각 음수가 아니거나 0이 아닌 값을 강제합니다. Clair의 read
함수는 반환값으로 Natural
을 사용하여, 읽어들인 바이트 수가 결코 음수가 될 수 없음을 보장합니다.
-- file: src/clair-file.adb
function read (fd : in Descriptor; ...) return Natural is ...
이 선언은 read
함수가 음수를 반환하려 할 경우, 런타임 오류가 발생함을 명시적으로 보장하여 프로그램의 안정성을 높입니다.
4.6.3 Constraint_Error
예외
만약 프로그램 실행 중에 서브타입의 제약 조건이 위반되면, Ada의 런타임 시스템은 내장된 예외인 Constraint_Error
를 발생시킵니다. 이는 Ada의 핵심적인 안전장치 중 하나입니다.
예를 들어, 0부터 100까지의 범위를 갖는 서브타입 변수에 범위를 벗어나는 값을 할당하면 Constraint_Error
가 발생합니다.
procedure Grade_Test is
subtype Grade is Integer range 0 .. 100;
My_Grade : Grade;
begin
-- 유효하지 않은 값 할당 시도
My_Grade := 101; -- 이 지점에서 Constraint_Error가 발생합니다.
exception
when Constraint_Error =>
-- 예외 처리 로직
Ada.Text_IO.put_line ("Error: 유효하지 않은 점수입니다.");
end Grade_Test;
C/C++과 같은 언어에서 조용히 데이터 손상이나 미정의 동작(undefined behavior)으로 이어질 수 있는 오류가, Ada에서는 즉각적이고 명시적인 Constraint_Error
예외로 전환됩니다. 이를 통해 프로그래머는 오류의 원인을 정확히 파악하고 대응할 수 있어 훨씬 견고한 소프트웨어를 구축할 수 있습니다.
5. 제어 구조
(도입부)
5.1 조건문 (conditional statements)
프로그램이 실행되는 과정에서 특정 조건의 참(true) 또는 거짓(false) 여부에 따라 실행 경로를 동적으로 결정해야 하는 경우가 빈번하게 발생합니다. 예를 들어, 사용자의 입력 값이 특정 범위 내에 있는지 확인하거나, 시스템의 현재 상태에 따라 다른 동작을 수행해야 할 수 있습니다. 이처럼 조건에 기반한 결정을 내릴 때 사용하는 구문을 조건문 (conditional statements)이라고 합니다.
Ada는 두 가지 주요 조건문을 제공합니다.
if
문: 가장 보편적인 조건문으로, 하나 이상의 논리적 조건을 평가하여 실행할 코드 블록을 선택합니다. 복잡한 논리 관계를 표현하는 데 유용합니다.case
문: 단일 변수나 표현식의 값이 여러 가능한 값 중 하나와 일치하는 경우를 검사할 때 사용됩니다.if
문으로 복잡하게 표현될 수 있는 다중 분기 조건을 간결하고 명확하게 만들어 줍니다.
이번 절에서는 이 두 가지 조건문의 정확한 구문, 의미, 그리고 각각의 문법이 어떤 상황에서 가장 효과적으로 사용될 수 있는지에 대해 상세히 탐구할 것입니다. 올바른 조건문을 선택하는 것은 코드의 가독성과 유지보수성을 향상시키는 중요한 요소입니다.
5.1.1 if
문
if
문은 프로그램의 실행 흐름을 조건에 따라 분기시키는 가장 핵심적인 제어 구조입니다. 주어진 불리언 표현식(Boolean Expression)을 평가하여 그 결과에 따라 특정 코드 블록의 실행 여부를 결정합니다. Ada의 if
문은 end if;
로 블록의 끝을 명시적으로 닫아주어야 하므로, 코드의 범위가 명확하고 구조적 모호성이 없습니다.
기본 구조와 다중 조건 처리
if
문은 else
와 elsif
절을 사용하여 단순한 조건 분기부터 복잡한 다중 조건 처리까지 구성할 수 있습니다.
-
기본
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 then
과 or else
를 제공합니다.
연산자 | 동작 | 주요 용도 |
---|---|---|
A and then B |
A 가 False 이면 B 를 평가하지 않음. |
🚨 오류 방지 (e.g., 널 포인터 검사) |
A or else B |
A 가 True 이면 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_string
이 NULL_PTR
일 경우에도 Is_Terminated
함수를 호출하여 런타임 오류가 발생할 것입니다. and then
을 사용하면 첫 번째 조건이 False
일 때 두 번째 조건은 평가되지 않으므로 프로그램의 안정성이 보장됩니다.
5.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>
)는 여러 형태로 지정하여 코드를 단순화할 수 있습니다.
- 단일 값: 하나의 특정 값으로 분기합니다. (
when 200 => ...
) - 범위 (
..
): 연속적인 값의 범위를 지정합니다. (when 90 .. 100 => ...
) - 대안 (
|
): 여러 개의 비연속적인 값을|
(수직선) 문자로 묶어 지정합니다. (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;
5.1.3 if
문과 case
문의 선택 기준
특징 | case 문 |
if 문 |
---|---|---|
분기 기준 | 단일 이산 타입 표현식 | 복잡한 논리 조건 |
주요 장점 | 가독성, 컴파일러의 완전성 검사, 최적화 가능성 | 유연성, 범용성, 단락 평가(and then /or else ) |
사용 시나리오 | 상태 코드 처리, 메뉴 옵션 선택 등 | 여러 변수 비교, 비-이산 타입(Float, String) 비교 |
결론적으로, “분기 조건이 단일 이산 값에 기반한다면 case
문을 우선적으로 고려하고, 그 외 모든 복잡한 조건에는 if
문을 사용한다” 는 것이 가장 명확한 선택 기준입니다.
5.2 반복문 (loop statements)
프로그래밍에서 특정 작업을 여러 번 반복해야 하는 경우는 매우 흔합니다. 예를 들어, 배열의 모든 요소를 처리하거나, 특정 조건이 만족될 때까지 사용자 입력을 기다리거나, 파일의 끝에 도달할 때까지 데이터를 읽는 작업 등이 있습니다. 반복문 (loop statements)은 이처럼 코드의 특정 블록을 반복적으로 실행하기 위한 제어 구조입니다.
Ada는 명확성과 제어력을 중시하는 언어의 설계 철학에 따라, 각기 다른 상황에 최적화된 세 가지 종류의 반복문을 제공합니다. 올바른 반복문을 선택하는 것은 코드의 의도를 명확히 하고, 잠재적인 오류(예: 무한 루프)를 방지하며, 프로그램의 논리를 간결하게 표현하는 데 도움이 됩니다.
이번 절에서는 다음과 같은 Ada의 주요 반복문에 대해 학습합니다.
- 기본
loop
문: 가장 유연한 형태의 반복문으로,exit
조건을 통해 반복을 제어합니다. 무한 루프나 복잡한 종료 조건을 가진 루프를 구성하는 데 사용됩니다. while
루프: 반복을 시작하기 전에 조건을 검사하여, 조건이 참인 동안에만 반복을 계속합니다. 반복 횟수를 미리 알 수 없는 상황에 적합합니다.for
루프: 지정된 이산 범위(discrete range)나 컨테이너의 요소들을 순회하며 정해진 횟수만큼 반복합니다. 가장 구조적이고 예측 가능한 형태의 반복문입니다.
각 반복문의 구문, 동작 방식, 그리고 어떤 상황에서 가장 효과적으로 사용될 수 있는지를 상세히 탐구하여, 효율적이고 신뢰성 높은 코드를 작성하는 능력을 기를 것입니다.
5.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;
-
if
와exit
: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;
이처럼 루프 이름은 복잡한 중첩 구조에서 제어 흐름을 명확하고 안전하게 만들어, 코드의 가독성과 신뢰성을 크게 향상시킵니다.
5.2.2 while
루프
while
루프는 반복을 시작하기 전에 특정 조건을 검사하여, 그 조건이 참(True
)인 동안에만 코드 블록을 반복 실행하는 선-검사(pre-condition) 반복문입니다. 반복 횟수가 사전에 정해져 있지 않고, 오직 특정 상태가 유지되는 동안에만 작업을 계속해야 할 때 유용합니다.
구문 및 동작 원리
while
루프의 구조는 while
키워드와 반복을 계속할 조건, 그리고 loop
와 end loop;
로 구성됩니다.
-
구문 (syntax):
while <condition> loop <sequence_of_statements> end loop;
-
동작 원리:
while
키워드 뒤의<condition>
을 평가합니다.- 평가 결과가
True
이면,loop
와end loop;
사이의 문장들을 실행합니다. 실행이 끝나면 다시 1번 단계로 돌아가 조건을 재평가합니다. - 평가 결과가
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;
5.2.3 while
루프와 기본 loop
문의 비교
while
루프는 기본 loop
와 exit when
을 사용해 동일한 로직으로 구현할 수 있지만, 구조와 의도에서 차이가 있습니다.
-
조건 검사 위치:
while
은 루프의 시작에서 조건을 검사합니다. 반면, 기본loop
는exit 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
버전이 루프의 진입 조건 자체를 명확히 드러낸다는 점에서 해당 시나리오에 더 적합하다고 볼 수 있습니다.
5.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
루프가 갖는 매우 중요한 특징은 루프 변수의 불변성입니다.
- 암묵적 선언: 루프 변수(
i
등)는for
루프에 의해 암묵적으로 선언됩니다. 따라서 별도로declare
블록에 선언할 필요가 없으며, 선언해서도 안 됩니다. 변수의 타입은 범위로부터 자동으로 유추됩니다. - 읽기 전용: 루프 변수는 루프 본체 내에서 상수(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;
5.3 goto
문
goto
문은 프로그램의 제어 흐름을 지정된 레이블(label)로 즉시 이동시키는 분기문입니다. 이러한 직접적인 제어 흐름 변경은 코드의 논리 구조를 순차적으로 파악하기 어렵게 만들어 유지보수성을 저해하는 요인이 됩니다. 이러한 이유로, 비록 Ada를 포함한 많은 언어에 이 기능이 존재하지만 현대적인 구조적 프로그래밍에서는 사용을 지양합니다.
5.3.1 구문
goto
문은 레이블 선언과 goto
호출, 두 부분으로 구성됩니다.
- 레이블 선언:
<<레이블_이름>>
과 같이 이중 꺾쇠괄호로 선언합니다. goto
호출:goto 레이블_이름;
형태로 사용합니다.
-- 구문 예시 (좋은 사용 사례는 아님)
<<my_label>>
-- ... 코드 ...
goto my_label;
5.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.
5.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
플래그 변수를 두어 바깥쪽 루프의 탈출 조건을 제어합니다.
5.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
로 인해 발생할 수 있는 잠재적인 혼란과 논리적 오류를 언어 차원에서 체계적으로 방지합니다.
6. 복합 타입 (composite types)
복합 타입은 여러 개의 구성 요소(component)를 묶어서 하나의 단위로 다루는 데이터 타입입니다. Ada의 대표적인 복합 타입으로는 동일한 타입의 요소들을 집합으로 다루는 배열(Array)과, 서로 다른 타입의 요소들을 묶는 레코드(Record)가 있습니다.
6.1 배열 (arrays)
배열은 동일한 타입의 요소(element)들이 연속적인 집합을 이루는 복합 타입입니다. 배열의 각 요소는 인덱스(index)를 통해 개별적으로 접근할 수 있으며, 이 인덱스로는 정수뿐만 아니라 열거형과 같은 모든 이산 타입(discrete type)을 사용할 수 있습니다.
6.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 ... |
6.1.2 배열 속성 (Array Attributes)
Ada는 배열 객체의 특성(크기, 범위 등)을 질의할 수 있는 여러 속성(Attribute)을 제공합니다. 이 속성들은 특히 비제약 배열을 다루는 일반적인 서브프로그램을 작성할 때 필수적입니다.
속성 | 설명 |
---|---|
A'First |
배열 A 의 첫 번째 인덱스 값을 반환합니다. |
A'Last |
배열 A 의 마지막 인덱스 값을 반환합니다. |
A'Length |
배열 A 의 요소 개수를 반환합니다. |
A'Range |
A'First .. A'Last 범위 자체를 반환합니다. for 루프와 함께 사용하면 매우 유용합니다. |
Clair 라이브러리에서는 String
타입(비제약 배열)의 길이를 확인하기 위해 'length
속성을 사용합니다.
-- file: src/clair-dl.adb
if errmsg'length > 0 then
raise Symbol_Lookup_Error
with "dlsym lookup for '" & sym_name & "' failed: " & errmsg;
end if;
'Range
속성을 사용하면 어떤 크기의 배열이든 안전하게 순회하는 코드를 작성할 수 있습니다.
-- Vector 타입의 크기에 상관없이 모든 요소를 출력하는 프로시저
procedure Print_Vector (V : in Vector) is
begin
for I in V'Range loop -- V'First .. V'Last 범위를 순회
-- ...
end loop;
end Print_Vector;
6.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
를 사용한 코드는 수정할 필요 없이 새로운 범위에 맞춰 올바르게 동작합니다. 이처럼 이름 지정 애그리게이트는 변경에 유연하고 오류가 적은 코드를 작성하는 데 핵심적인 역할을 합니다.
6.1.4 배열 슬라이싱 (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') 값을 가집니다.
이처럼 슬라이싱은 서로 다른 인덱스 범위를 가진 배열 간에도 데이터를 유연하게 복사하고 조작할 수 있는 안전하고 효율적인 방법을 제공하여 코드의 가독성과 유지보수성을 크게 향상시킵니다.
6.1.5 다차원 배열 (Multi-dimensional arrays)
Ada는 쉼표로 인덱스 범위를 구분하여 다차원 배열을 선언할 수 있습니다.
type Matrix is array (1 .. 3, 1 .. 4) of Float; -- 3x4 행렬 타입
my_matrix : Matrix;
다차원 배열의 요소에 접근할 때는 my_matrix(2, 3)
과 같이 쉼표로 인덱스를 구분하여 지정합니다.
6.2 레코드 (records)
레코드는 서로 다른 데이터 타입의 요소들을 하나의 논리적 단위로 묶는 복합 타입입니다. 레코드의 각 구성 요소를 필드(field) 또는 컴포넌트(component)라고 하며, 각 필드는 고유한 이름과 데이터 타입을 가집니다. 이는 모든 요소가 동일한 타입이어야 하는 배열과 구분되는 특징입니다.
6.2.1 기본 레코드 구조
기본적인 레코드는 고정된 필드들의 집합으로 구성됩니다. 레코드 타입은 record
와 end record
키워드 사이에 필드들의 이름과 타입을 나열하여 정의합니다.
Clair 라이브러리의 clair-file.ads
에서는 pipe
시스템 호출의 결과로 생성된 두 개의 파일 디스크립터(읽기용, 쓰기용)를 함께 관리하기 위해 다음과 같은 Pipe_Ends
레코드 타입을 사용합니다.
-- file: src/clair-file.ads
type Pipe_Ends is record
read_end : Descriptor;
write_end : Descriptor;
end record;
이렇게 선언된 레코드 타입의 변수가 있을 때, 각 필드에는 점(.) 표기법을 사용하여 접근합니다.
-- pipe 함수가 Pipe_Ends 타입의 객체를 반환한다고 가정
My_Pipe : Pipe_Ends := pipe;
-- 레코드의 각 필드에 접근하여 사용
Clair.File.close (fd => My_Pipe.read_end);
Clair.File.close (fd => My_Pipe.write_end);
레코드를 사용하면 연관된 데이터들을 하나의 객체로 묶어 관리할 수 있어, 데이터를 개별 변수로 다룰 때보다 코드의 구조가 명확해지고 데이터 전달이 용이해집니다.
6.2.2 판별자를 이용한 가변 레코드
Ada는 특정 값에 따라 레코드의 구조가 달라질 수 있는 가변 레코드(Variant Record)를 지원합니다. 이는 판별자(discriminant)라는 특별한 매개변수를 통해 구현되며, C 언어의 union
과 유사한 기능을 제공하지만 완전한 타입 안전성을 보장한다는 점에서 근본적인 차이가 있습니다.
판별자는 레코드 타입 선언 시 이름과 함께 정의되며, 레코드 내부에서는 case
문을 사용하여 판별자의 값에 따라 서로 다른 필드를 갖도록 구조를 정의합니다.
Clair.Process
패키지의 Fork_Result
는 가변 레코드의 전형적인 활용 사례를 보여줍니다. fork
시스템 호출은 현재 프로세스가 부모인지 자식인지에 따라 반환 값의 의미가 달라지는데, Fork_Result
는 이를 판별자 status
를 이용해 안전하게 표현합니다.
-- file: src/clair-process.ads
-- 'status' 판별자의 값에 따라 레코드의 구조가 결정됩니다.
type Fork_Result (status : Fork_Status) is record
case status is
when Parent =>
-- status가 Parent일 경우에만 child_pid 필드가 존재합니다.
child_pid : Clair.Types.pid_t;
when Child =>
-- status가 Child일 경우에는 추가 필드가 없습니다.
null;
end case;
end record;
이 구조의 가장 큰 장점은 안전성입니다. Fork_Result
객체의 판별자 status
가 Child
인 경우, child_pid
필드는 메모리에 존재하지 않으며, 해당 필드에 접근하려는 시도는 컴파일 시점에 오류로 처리됩니다. 이는 런타임에 발생할 수 있는 데이터 손상이나 메모리 오류를 원천적으로 방지합니다.
6.2.3 레코드 초기화
레코드 객체는 애그리게이트(aggregate)를 사용하여 초기화할 수 있습니다. 필드의 순서에 맞춰 값을 나열하는 위치 기반(positional) 방식과, 필드 이름을 명시하는 이름 기반(named) 방식이 있습니다.
-- 이름 기반 애그리게이트 (권장되는 방식)
A_Pipe : Pipe_Ends := (read_end => 3, write_end => 4);
-- 가변 레코드 초기화 시에는 판별자 값을 반드시 지정해야 합니다.
A_Fork_Result : Fork_Result := (status => Parent, child_pid => 1234);
6.3 문자열 처리
문자열은 텍스트 데이터를 다루는 모든 프로그램에서 필수적인 요소입니다. Ada는 두 가지 주요 문자열 처리 방식을 제공합니다: 선언 시점에 길이가 고정되는 내장된 String
타입과, 런타임에 길이를 동적으로 변경할 수 있는 Ada.Strings.Unbounded
패키지입니다.
6.3.1 기본 String
타입: 고정 길이 문자열
Ada의 내장된 String
타입은 앞서 설명한 비제약 배열(Unconstrained array)의 한 종류로, 표준 라이브러리에 다음과 같이 정의되어 있습니다.
type String is array (Positive range <>) of Character;
String
은 비제약 배열이므로 타입 자체의 길이는 정해져 있지 않지만, String
타입의 객체(변수 또는 상수)를 선언하는 시점에 그 길이는 고정되며 이후 변경할 수 없습니다.
-- "Hello" 리터럴의 길이에 맞춰 크기 5 (인덱스 1..5)의 문자열 변수가 생성됩니다.
Message : String := "Hello";
-- 크기를 10으로 명시적으로 지정합니다.
Buffer : String(1 .. 10);
장점:
- 효율성: 동적 메모리 할당이 없어 성능이 예측 가능하고 빠릅니다.
- 안전성: 버퍼 오버플로우와 같은 오류가 발생하지 않습니다. 선언된 크기를 벗어나는 슬라이싱이나 대입은
Constraint_Error
예외를 유발합니다.
단점:
- 유연성 부족: 문자열의 길이를 변경하려면, 새로운 크기의
String
객체를 선언하고 기존 내용을 복사해야 하므로 비효율적일 수 있습니다.
String
타입은 길이가 예측 가능하거나 최대 길이가 명확한 경우에 매우 적합합니다. Clair 라이브러리는 주로 오류 메시지를 생성하고 전달하는 용도로 String
타입을 광범위하게 사용합니다. 앰퍼샌드(&
) 연산자를 사용하여 여러 문자열과 값을 결합하는 방식이 흔히 사용됩니다.
-- file: src/clair-dl.adb
-- get_dl_error 함수가 반환하는 String을 상수에 저장합니다.
errmsg : constant String := get_dl_error;
-- ...
raise Symbol_Lookup_Error
with "dlsym lookup for '" & sym_name & "' failed: " & errmsg;
6.3.2 Ada.Strings.Unbounded
패키지: 가변 길이 문자열
문자열의 길이를 런타임에 동적으로 변경해야 하는 경우, 표준 라이브러리인 Ada.Strings.Unbounded
패키지를 사용합니다. 이 패키지는 Unbounded_String
이라는 타입을 제공하며, 내부적으로 동적 메모리를 사용하여 문자열의 추가, 삽입, 삭제를 효율적으로 관리합니다.
Unbounded_String
을 사용하려면 with Ada.Strings.Unbounded;
절을 추가해야 합니다.
주요 기능:
-
타입 변환:
to_unbounded_string (S : String)
: 일반String
을Unbounded_String
으로 변환합니다.to_string (U : Unbounded_String)
:Unbounded_String
을String
으로 변환합니다.
-
문자열 수정:
append (Source, New_Item)
:Source
의 끝에New_Item
(문자,String
, 또는 다른Unbounded_String
)을 추가합니다.
-
길이 조회:
Length (Source)
: 현재 문자열의 길이를 반환합니다.
장점:
- 유연성: 문자열 길이를 자유자재로 늘리거나 줄일 수 있어 사용이 편리합니다.
단점:
- 성능 오버헤드: 내부적으로 동적 메모리 할당(힙 메모리 사용)이 발생하므로, 내장된
String
타입에 비해 성능 오버헤드가 있습니다. 이로 인해 일부 고신뢰성 실시간 시스템에서는 사용이 제한될 수 있습니다.
사용 예시:
Clair와 같은 저수준 라이브러리는 성능 예측 가능성을 위해 Unbounded_String
을 사용하지 않지만, 일반적인 응용 프로그램에서는 다음과 같이 유용하게 사용될 수 있습니다.
with Ada.Text_IO;
with Ada.Strings.Unbounded;
use Ada.Strings.Unbounded;
procedure unbounded_test is
report : Unbounded_String;
begin
-- Unbounded_String 객체 생성 및 초기화
report := to_unbounded_string ("Log Report: ");
-- 루프를 돌며 동적으로 문자열 추가
for I in 1 .. 5 loop
append (report, "Event " & I'image & ";");
end loop;
Ada.Text_IO.put_line (to_string (report));
-- 출력: Log Report: Event 1;Event 2;Event 3;Event 4;Event 5;
end unbounded_test;
결론적으로, 문자열의 최대 길이가 알려져 있고 성능이 중요한 경우에는 내장된 String
타입을, 문자열의 내용과 길이가 동적으로 변경되어야 하는 경우에는 Ada.Strings.Unbounded
를 사용하는 것이 바람직합니다.
6.3.3 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
은 정해진 경계 안에서 안전하게 문자열을 다룰 수 있는 강력한 도구입니다. 다음 절에서는 지금까지 배운 세 가지 문자열 타입을 언제 사용해야 하는지 종합적으로 비교하고 선택 기준을 제시합니다.
6.3.4 문자열 처리 패키지 선택 가이드 (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)
다음 질문을 순서대로 따라가면 가장 적합한 타입을 선택할 수 있습니다.
- 문자열의 길이가 컴파일 시점에 알려져 있고, 절대 변하지 않는가?
- 예 (Yes):
String
을 사용하십시오.
- 상수 문자열(
constant String := "...";
), 고정된 형식의 메시지, 성능이 극도로 중요한 저수준 프로그래밍에 가장 적합합니다. 가장 빠르고 메모리 오버헤드가 없습니다.
- 예 (Yes):
- 길이가 변해야 하지만, 동적 힙 메모리 사용을 피해야 하는가? (예: 실시간/임베디드 시스템)
- 예 (Yes):
Ada.Strings.Bounded
를 사용하십시오.
- 사용자 이름(최대 30자)이나 파일 경로(최대 255자)처럼, 길이가 변하더라도 합리적인 최대치를 예측할 수 있는 모든 경우에 이상적입니다. 예측 가능성과 안전성이 중요한 시스템의 표준적인 선택입니다.
- 예 (Yes):
- 문자열 길이를 전혀 예측할 수 없고, 유연성이 가장 중요한가?
- 예 (Yes):
Ada.Strings.Unbounded
를 사용하십시오.
- 크기를 알 수 없는 파일을 읽어 처리하거나, 복잡한 사용자 입력에 따라 동적으로 문자열을 조립하는 일반적인 데스크톱 또는 서버 애플리케이션에 적합합니다. 사용하기 가장 편리하지만, 실시간 시스템에서는 사용을 피해야 합니다.
- 예 (Yes):
이처럼 Ada는 문제의 특성과 요구사항에 맞춰 프로그래머가 직접 트레이드오프를 결정하고 가장 적절한 도구를 선택할 수 있도록 지원합니다. 이는 소프트웨어의 신뢰성과 효율성을 중시하는 Ada의 핵심 철학을 보여줍니다.
7. 서브프로그램: 코드의 재사용
(도입부)
7.1 서브프로그램의 개념과 종류
7.2 프로시저 (procedures)
프로시저는 특정 동작이나 일련의 작업들을 수행하는 서브프로그램입니다. 프로시저는 값을 직접 반환하지 않으며, 작업의 결과는 주로 매개변수(out
또는 in out
모드)를 통해 호출자에게 전달되거나 시스템의 상태를 변경하는 방식으로 나타납니다.
구문:
procedure <프로시저_이름> (매개변수_목록) is
-- 지역 선언부
begin
-- 실행문
end <프로시저_이름>;
Clair 라이브러리의 Clair.DL.close
는 프로시저의 역할을 명확히 보여줍니다. 이 프로시저는 동적 라이브러리 핸들을 받아 라이브러리를 닫는 ‘동작’을 수행합니다.
-- file: src/clair-dl.adb
procedure close (lib : in out Handle) is
retval : Interfaces.C.int;
begin
-- 핸들이 이미 null인지 확인
if lib = NULL_HANDLE then
return;
end if;
-- C 함수를 호출하여 라이브러리를 닫는 동작 수행
retval := c_dlclose (System.Address (lib));
-- 반환 값을 확인하여 오류 처리
if retval /= 0 then
raise Library_Close_Error with "dlclose failed: " & get_dl_error;
end if;
-- 핸들을 null로 만들어 상태 변경
lib := NULL_HANDLE;
end close;
close
프로시저는 값을 계산하여 반환하는 대신, 시스템의 상태(라이브러리 닫기)를 변경하고 매개변수 lib
의 값을 수정하는 일련의 작업을 수행합니다.
7.3 함수 (functions)
함수는 특정 값을 계산하여 그 결과를 반환하는 서브프로그램입니다. 함수 호출은 그 자체가 하나의 값이므로, 표현식(expression)의 일부로 사용될 수 있습니다.
구문:
function <함수_이름> (매개변수_목록) return <반환_타입> is
-- 지역 선언부
begin
-- 실행문
return <반환할_값>;
end <함수_이름>;
함수의 본체에는 반드시 하나 이상의 return
문이 포함되어야 하며, return
문이 반환하는 값의 타입은 함수 선언에 명시된 반환 타입과 일치해야 합니다.
좋은 프로그래밍 관례상, 함수는 부작용(side effect)이 없도록 작성하는 것이 권장됩니다. 즉, 함수는 전역 변수나 in out
매개변수의 값을 수정하지 않고, 오직 입력된 매개변수를 기반으로 값을 계산하여 반환하는 역할에만 충실해야 합니다. 이는 프로그램의 동작을 예측하고 디버깅하기 쉽게 만듭니다.
Clair 라이브러리의 get_dl_error
함수는 부작용이 없는 함수의 좋은 예입니다. 이 함수의 유일한 목적은 시스템 오류 메시지를 조회하여 String
타입의 값으로 반환하는 것입니다.
-- file: src/clair-dl.adb
function get_dl_error return String is
errmsg_ptr : constant Interfaces.C.Strings.chars_ptr := c_dlerror;
begin
if errmsg_ptr /= Interfaces.C.Strings.NULL_PTR then
return Interfaces.C.Strings.value (errmsg_ptr);
else
return "";
end if;
end get_dl_error;
이 함수는 ... & get_dl_error
와 같이 다른 표현식의 일부로 호출되어, 반환된 문자열 값을 제공하는 역할을 합니다.
7.4 매개변수 전달 기법
7.4.1 매개변수 모드: in
, out
, in out
Ada는 서브프로그램을 호출할 때 데이터가 전달되는 방향을 명시적으로 제어하기 위해 매개변수 모드(parameter mode)를 사용합니다. 모든 매개변수는 반드시 in
, out
, in out
세 가지 모드 중 하나로 선언되어야 하며, 이는 프로그래머의 의도를 명확히 하고 컴파일러가 데이터 흐름의 정확성을 검사할 수 있게 하는 핵심적인 안전장치입니다. 만약 모드를 생략할 경우, 기본값은 in
입니다.
in
모드
in
모드는 매개변수가 서브프로그램으로 데이터를 입력하는 용도로만 사용됨을 나타냅니다.
- 동작: 호출자의 변수 값이 서브프로그램으로 전달됩니다.
- 권한: 서브프로그램 내에서
in
모드 매개변수는 상수(constant)처럼 취급됩니다. 즉, 그 값을 읽을 수는 있지만 수정할 수는 없습니다. - 용도: 함수의 입력 값이나, 동작을 수행하는 데 필요한 데이터를 프로시저에 전달할 때 사용됩니다.
Clair 라이브러리의 find_symbol
함수는 in
모드의 전형적인 사용 예를 보여줍니다.
-- file: src/clair-dl.adb
function find_symbol (lib : in Handle;
sym_name : in String) return System.Address is ...
lib
와 sym_name
은 심볼을 찾는 데 필요한 입력 정보입니다. 함수는 이 값들을 사용하지만, 이들을 변경하지는 않습니다. 컴파일러는 함수 내에서 lib := ... ;
와 같은 할당을 시도하는 코드를 오류로 처리합니다.
out
모드
out
모드는 서브프로그램이 호출자에게 데이터를 출력하는 용도로만 사용됨을 나타냅니다.
- 동작: 서브프로그램이 매개변수에 값을 할당하면, 서브프로그램 실행이 끝난 뒤 그 값이 호출자의 변수에 반영됩니다.
- 권한: 서브프로그램 내에서
out
모드 매개변수는 초기화되지 않은 변수처럼 취급됩니다. 호출자가 전달한 초기 값은 읽을 수 없으며(write-only), 서브프로그램은 반환하기 전에 반드시 값을 할당해야 합니다. - 용도: 프로시저가 하나 이상의 결과를 반환해야 할 때 사용됩니다.
clair-error.adb
에서 C 함수 strerror_r
을 임포트하는 선언은 out
모드를 사용합니다.
-- file: src/clair-error.adb
function strerror_r (errno_code : in Interfaces.C.int;
buffer : out Interfaces.C.char_array;
buf_len : in Interfaces.C.size_t)
return Interfaces.C.int;
여기서 buffer
는 오류 메시지 문자열을 받아오기 위한 출력용 매개변수입니다. strerror_r
함수는 이 buffer
에 값을 채워 넣는 역할을 합니다.
in out
모드
in out
모드는 매개변수가 데이터를 입력받는 동시에, 수정된 결과를 출력하는 양방향 데이터 전달에 사용됨을 나타냅니다.
- 동작: 호출자의 변수 값이 서브프로그램으로 전달되고, 서브프로그램 내에서 수정될 수 있으며, 실행이 끝나면 최종적으로 수정된 값이 호출자의 변수에 다시 반영됩니다.
- 권한: 서브프로그램 내에서
in out
모드 매개변수는 일반적인 변수처럼 취급됩니다. 값을 자유롭게 읽고 쓸 수 있습니다. - 용도: 특정 데이터 구조를 “제자리에서(in-place)” 수정해야 할 때 사용됩니다.
Clair.DL.close
프로시저는 in out
모드의 완벽한 사례입니다.
-- file: src/clair-dl.adb
procedure close (lib : in out Handle) is ...
이 프로시저는 먼저 입력된 lib
핸들의 값을 읽어서 C 함수 c_dlclose
에 전달합니다. 라이브러리가 성공적으로 닫히면, lib
핸들 자체의 값을 NULL_HANDLE
로 수정하여 호출자에게 유효하지 않은 핸들임을 알립니다. 이처럼 in out
모드는 입력 값을 기반으로 동작을 수행한 뒤, 그 결과를 다시 해당 매개변수에 갱신하는 데 사용됩니다.
이러한 명시적인 매개변수 모드는 서브프로그램의 인터페이스를 그 자체로 명확한 문서로 만들어 주며, 데이터의 흐름을 컴파일러가 강제하도록 하여 잠재적인 버그를 예방하는 Ada의 핵심적인 신뢰성 기능입니다.
7.4.2 명명된(named) 매개변수와 기본(default) 매개변수
Ada는 서브프로그램 호출의 가독성과 유연성을 향상시키기 위해 명명된 매개변수 표기법과 기본 매개변수 값이라는 두 가지 기능을 제공합니다. 이 기능들은 서브프로그램의 인터페이스를 더욱 명확하고 사용하기 쉽게 만들어 줍니다.
위치 기반 매개변수와 그 한계
기본적으로 서브프로그램을 호출할 때 전달되는 인자(argument)는 서브프로그램 선언에 나열된 매개변수(parameter)의 순서, 즉 위치에 따라 대응됩니다. 이를 위치 기반 표기법(positional notation)이라고 합니다.
-- 선언: procedure Display (Message : String; Count : Natural);
-- 호출:
Display ("Error", 3); -- "Error"는 Message에, 3은 Count에 대응
이 방식은 매개변수가 적을 때는 간결하지만, 매개변수의 개수가 많아지거나 타입이 비슷할 경우 어떤 값이 어떤 매개변수에 해당되는지 파악하기 어려워 가독성이 떨어지고 실수를 유발할 수 있습니다.
명명된 매개변수 표기법
명명된 매개변수 표기법(named parameter notation)은 화살표 기호(=>
)를 사용하여 전달할 인자를 특정 매개변수의 이름과 명시적으로 연결하는 방식입니다.
이 표기법은 다음과 같은 장점을 가집니다.
- 가독성 향상: 코드가 그 자체로 문서의 역할을 합니다. 어떤 값이 어떤 목적으로 사용되는지 호출문만 보고도 명확히 알 수 있습니다.
- 순서 독립성: 매개변수의 순서를 지킬 필요 없이 원하는 순서대로 인자를 전달할 수 있습니다.
- 유지보수 용이성: 서브프로그램에 새로운 매개변수가 추가되더라도 기존의 명명된 호출 코드는 영향을 받지 않을 수 있습니다.
Clair 라이브러리의 duplicate_to
함수를 예로 들어 비교해 보겠습니다.
선언:
function duplicate_to (fd : in Descriptor; new_fd : Descriptor) return Descriptor;
호출 방식 비교:
Old_FD : constant Descriptor := ...;
New_FD : Descriptor;
-- 1. 위치 기반 호출 (fd와 new_fd의 순서를 기억해야 함)
New_FD := Clair.File.duplicate_to (Old_FD, 3);
-- 2. 명명된 호출 (각 값의 의미가 명확함)
New_FD := Clair.File.duplicate_to (fd => Old_FD, new_fd => 3);
명명된 표기법을 사용한 두 번째 호출이 어떤 파일 디스크립터가 원본이고 대상인지 훨씬 명확하게 보여줍니다. 위치 기반 표기법과 명명된 표기법을 혼용할 수도 있지만, 한번 명명된 표기법을 사용하기 시작하면 그 뒤의 모든 인자도 명명된 표기법을 사용해야 합니다.
기본 매개변수 값
Ada는 서브프로그램 선언 시 in
모드의 매개변수에 :=
연산자를 사용하여 기본값(default value)을 지정할 수 있습니다.
서브프로그램을 호출할 때 기본값이 지정된 매개변수에 대한 인자를 생략하면, 컴파일러는 자동으로 선언부에 명시된 기본값을 사용합니다. 이 기능은 out
또는 in out
모드의 매개변수에는 적용되지 않습니다.
Clair의 exit_process
프로시저는 이 기능을 사용하여 일반적인 성공적 종료를 더 간편하게 호출할 수 있도록 합니다.
선언:
-- file: src/clair-process.ads
procedure exit_process (status : Integer := EXIT_SUCCESS);
호출:
-- 1. 인자를 명시적으로 전달 (실패로 종료)
Clair.Process.exit_process (status => EXIT_FAILURE);
-- 2. 인자를 생략 (기본값인 EXIT_SUCCESS가 사용됨)
Clair.Process.exit_process;
기본 매개변수 기능은 선택적으로 사용되는 옵션을 제공하는 서브프로그램의 API를 매우 유연하고 편리하게 만들어 줍니다.
7.5 서브프로그램 오버로딩 (Overloading)
서브프로그램 오버로딩(overloading)은 동일한 유효 범위(scope) 내에서 여러 개의 서브프로그램이 같은 이름을 공유할 수 있도록 하는 기능입니다. 이는 이름은 같지만 서로 다른 매개변수나 반환 타입을 갖는 여러 버전의 서브프로그램을 정의할 수 있게 하여, 코드의 가독성과 API의 직관성을 높여줍니다.
7.5.1 오버로딩의 개념과 규칙
컴파일러는 서브프로그램 호출이 발생했을 때, 어떤 버전의 서브프로그램을 호출해야 할지 결정하기 위해 각 서브프로그램의 프로파일(profile)을 분석합니다. 서브프로그램의 프로파일은 다음 요소들로 구성됩니다.
- 매개변수의 개수
- 각 매개변수의 순서와 기반 타입(base type)
- (함수의 경우) 반환 타입
컴파일러는 호출 시 제공된 인자들의 타입과 개수, 그리고 (함수의 경우) 반환값이 사용되는 문맥을 종합하여 가장 적합한 프로파일을 가진 서브프로그램을 선택합니다. 만약 호출이 모호하여 두 개 이상의 프로파일과 일치할 수 있는 경우, 컴파일러는 오류를 보고하여 프로그래머가 명확한 호출을 하도록 유도합니다.
7.5.2 오버로딩의 활용 사례
오버로딩은 주로 동일한 논리적 연산을 다른 종류의 데이터나 다른 개수의 인자에 대해 수행하고자 할 때 유용하게 사용됩니다.
1. 동일 연산에 대한 다양한 매개변수 조합 지원
가장 일반적인 활용 사례는 하나의 개념적인 동작(예: ‘열기’, ‘쓰기’, ‘생성’)에 대해 여러 가지 방법으로 인자를 제공할 수 있도록 하는 것입니다.
Clair 라이브러리의 Clair.File.open
함수가 이 사례를 명확하게 보여줍니다. 파일 시스템에서 파일을 열 때, 생성 모드(O_CREAT
플래그)를 사용하는 경우에만 파일 권한(mode
)을 지정해야 합니다. Clair는 이 두 가지 경우를 위해 open
이라는 동일한 이름을 가진 두 개의 함수를 제공합니다.
-- file: src/clair-file.ads (개념적 표현)
-- 버전 1: 경로와 플래그만으로 파일을 여는 함수
function open (path : String; flags : File.Flags) return Descriptor;
-- 버전 2: 경로, 플래그, 그리고 생성 모드까지 지정하여 파일을 여는 함수
function open (path : String;
flags : File.Flags;
mode : Clair.Types.mode_t) return Descriptor;
사용자가 Clair.File.open
을 호출할 때, 컴파일러는 전달된 인자의 개수를 보고 어떤 버전의 open
함수를 호출할지 자동으로 결정합니다.
my_fd := Clair.File.open ("readonly.txt", O_RDONLY);
-> 버전 1 호출my_fd := Clair.File.open ("new.txt", O_CREAT or O_WRONLY, Mode_Value);
-> 버전 2 호출
이를 통해 사용자는 내부적으로 다른 함수가 호출된다는 사실을 신경 쓸 필요 없이, open
이라는 일관되고 직관적인 이름으로 파일 열기 기능을 사용할 수 있습니다.
2. 타입 변환의 명확화
서로 다른 타입의 데이터를 공통된 타입(예: String
)으로 변환하는 여러 함수에 to_string
과 같은 동일한 이름을 부여할 수 있습니다.
function to_string (value : in Integer) return String is
(value'image);
function to_string (value : in Boolean) return String is
(if value then "True" else "False");
-- ...
my_integer_string : String := to_string (123);
my_boolean_string : String := to_string (True);
이처럼 오버로딩은 연관된 기능들을 하나의 이름으로 묶어 API를 단순화하고, 코드의 의도를 더 명확하게 전달하는 데 기여하는 기능입니다.
7.6 재귀 (Recursion)
재귀(Recursion)는 하나의 서브프로그램이 자기 자신을 직접 또는 다른 서브프로그램을 통해 간접적으로 다시 호출하는 프로그래밍 기법입니다. 재귀는 특정 종류의 문제, 특히 자기 유사성(self-similarity)을 가진 문제들을 매우 간결하고 우아하게 해결할 수 있는 도구입니다.
7.6.1 재귀의 개념
모든 올바른 재귀적 서브프로그램은 반드시 두 가지 핵심적인 요소를 포함해야 합니다.
-
종료 조건 (Base Case): 재귀 호출을 멈추는 조건입니다. 이 조건이 충족되면, 서브프로그램은 더 이상 자신을 호출하지 않고 단순한 값을 반환합니다. 종료 조건이 없다면 재귀는 무한히 계속되어 결국 스택 오버플로(stack overflow) 오류를 유발합니다.
-
재귀 호출 (Recursive Call): 문제를 더 작은 단위로 축소하여 자기 자신을 다시 호출하는 부분입니다. 이 호출을 통해 문제는 점차적으로 종료 조건에 가까워집니다.
재귀는 복잡한 문제를 “현재 단계에서 처리할 수 있는 간단한 부분”과 “나머지 더 작은 문제”로 분해하는 방식으로 작동합니다.
7.6.2 재귀 함수의 예시: 팩토리얼
팩토리얼(Factorial) 함수는 재귀의 개념을 설명하는 데 사용되는 가장 고전적인 예입니다. 양의 정수 n의 팩토리얼(n!
)은 1부터 n까지의 모든 정수를 곱한 것입니다.
n! = n * (n-1)!
0! = 1
(정의에 따라)
이 수학적 정의는 재귀의 두 요소와 직접적으로 대응됩니다.
- 종료 조건:
n
이 0일 때, 팩토리얼은 1입니다. - 재귀 호출:
n
이 0보다 클 때,n
의 팩토리얼은n
과n-1
의 팩토리얼을 곱한 값입니다.
이를 Ada 함수로 구현하면 다음과 같습니다.
-- N의 팩토리얼을 계산하는 재귀 함수
function Factorial (N : in Natural) return Positive is
begin
if N = 0 then
-- 1. 종료 조건 (Base Case)
return 1;
else
-- 2. 재귀 호출 (Recursive Call)
-- 문제를 N-1로 축소하여 자기 자신을 다시 호출합니다.
return N * Factorial (N - 1);
end if;
end Factorial;
Factorial(3)
을 호출하면, 실행 흐름은 다음과 같습니다.
Factorial(3)
은3 * Factorial(2)
를 반환하기 위해 대기합니다.Factorial(2)
는2 * Factorial(1)
을 반환하기 위해 대기합니다.Factorial(1)
은1 * Factorial(0)
을 반환하기 위해 대기합니다.Factorial(0)
은 종료 조건에 도달하여1
을 반환합니다.- 이 반환 값이 연쇄적으로 계산되어, 최종적으로
3 * 2 * 1 * 1 = 6
이 반환됩니다.
7.6.3 재귀의 장점과 단점
재귀는 모든 상황에 적합한 만능 해결책이 아니며, 사용 시 장점과 단점을 명확히 이해해야 합니다.
장점:
- 코드의 간결성 및 명확성: 트리(Tree) 순회, 분할 정복(Divide and Conquer) 알고리즘, 특정 수학 공식 등 본질적으로 재귀적인 구조를 가진 문제의 경우, 재귀를 사용하면 반복문(iteration)과 스택을 직접 사용하는 것보다 훨씬 짧고 이해하기 쉬운 코드를 작성할 수 있습니다.
단점:
- 성능 오버헤드: 함수를 호출할 때마다 매개변수와 지역 변수 등을 저장하기 위한 스택 메모리 공간이 소요되며, 함수 호출 자체에도 비용이 발생합니다. 재귀의 깊이가 매우 깊어지면 성능 저하의 원인이 될 수 있습니다.
- 스택 오버플로 위험: 재귀 호출이 너무 깊어지거나 종료 조건이 잘못된 경우, 할당된 스택 공간을 모두 소진하여 프로그램이 비정상적으로 종료될 수 있습니다.
따라서 재귀는 문제의 구조가 재귀적 표현에 자연스럽게 부합하여 코드의 명확성을 크게 향상시키는 경우에 선택하는 것이 바람지며, 단순한 선형 반복이나 메모리 및 성능이 매우 중요한 경우에는 반복문을 사용하는 것이 일반적입니다.
8. 패키지
8.1 패키지 명세(specification)와 본체(body)
패키지(package)는 Ada에서 모듈화, 정보 은닉, 추상화를 구현하는 가장 핵심적인 구조입니다. 패키지는 논리적으로 관련된 타입, 상수, 변수, 서브프로그램, 예외 등의 요소들을 하나의 이름 공간(namespace) 아래에 묶어 캡슐화합니다. Ada의 패키지는 일반적으로 명세(specification)와 본체(body)라는 두 개의 분리된 소스 파일로 구성되며, 이는 인터페이스와 구현의 완전한 분리를 강제합니다.
8.1.1 패키지 명세 (package Specification - .ads
파일)
패키지 명세는 외부 세계에 공개되는 공개 인터페이스(public interface)를 정의합니다. 다른 프로그래밍 단위에서 해당 패키지를 with
절을 통해 참조할 때, 명세에 선언된 요소들만 접근할 수 있습니다. 명세는 일종의 “계약”으로서, 패키지가 무엇을 할 수 있는지를 정의하지만 어떻게 하는지에 대한 세부 사항은 숨깁니다.
패키지 명세에 포함될 수 있는 요소는 다음과 같습니다.
- 공개적으로 사용될 타입 및 서브타입 선언
- 공개 상수 및 변수 선언
- 공개 예외 선언
- 서브프로그램의 선언 (시그니처)
Clair 라이브러리의 clair-process.ads
파일은 패키지 명세의 명확한 예시입니다.
-- file: src/clair-process.ads
with Clair.Signal;
with Clair.Types;
package Clair.Process is
EXIT_SUCCESS : constant := 0;
EXIT_FAILURE : constant := 1;
-- 공개적으로 사용될 타입 선언
type Fork_Status is (Parent, Child);
type Fork_Result (status : Fork_Status) is record
case status is
when Parent =>
child_pid : Clair.Types.pid_t;
when Child =>
null;
end case;
end record;
-- 공개 서브프로그램 선언 (구현은 없음)
procedure exit_process (status : Integer := EXIT_SUCCESS);
pragma no_return (exit_process);
function get_pid return Clair.Types.pid_t;
function fork return Fork_Result;
procedure send_signal_to (pid : Clair.Types.pid_t; signo : Clair.Signal.Number);
end Clair.Process;
이 명세 파일을 통해 Clair.Process
패키지 사용자는 fork
함수를 호출하면 Fork_Result
타입을 반환받는다는 사실을 알 수 있지만, fork
함수가 내부적으로 어떻게 C 함수를 호출하는지에 대해서는 알 필요가 없습니다.
8.1.2 패키지 본체 (Package Body - .adb
파일)
패키지 본체는 명세에 선언된 서브프로그램들의 실제 구현(implementation)을 포함합니다. 또한, 패키지 외부에서는 접근할 수 없는 비공개(private) 타입, 변수, 또는 내부적으로만 사용되는 헬퍼(helper) 서브프로그램 등을 정의할 수 있습니다.
clair-process.adb
파일은 Clair.Process
패키지의 본체입니다.
-- file: src/clair-process.adb
with Clair.Error;
with Interfaces.C;
package body Clair.Process is
-- 본체 내에서만 사용되는 C 함수 임포트 (외부에 비공개)
function c_fork return Clair.Types.pid_t;
pragma import (c, c_fork, "fork");
-- 명세에 선언된 서브프로그램의 실제 구현
function fork return Fork_Result is
retval : constant Clair.Types.pid_t := Clair.Types.pid_t (c_fork);
begin
case retval is
when 0 =>
return (status => Child);
when -1 =>
-- ... 오류 처리 로직 ...
when others =>
return (status => Parent, child_pid => retval);
end case;
end fork;
-- exit_process, get_pid 등의 다른 서브프로그램 구현 ...
end Clair.Process;
본체는 package body
키워드로 시작하며, 명세에 선언된 모든 서브프로그램의 구현을 반드시 제공해야 합니다.
8.1.3 컴파일 의존성
명세와 본체의 분리는 대규모 시스템의 개발 및 유지보수에 큰 이점을 제공합니다. Ada의 컴파일 시스템은 다음과 같은 단방향 의존성 규칙을 따릅니다.
- 패키지 본체(
.adb
)를 컴파일하려면, 해당하는 명세(.ads
)가 먼저 컴파일되어 있어야 합니다. - 다른 패키지를
with
하는 클라이언트 코드를 컴파일하려면, 해당 패키지의 명세(.ads
)만 컴파일되어 있으면 됩니다.
이는 패키지 본체의 구현이 변경되더라도, 명세가 변경되지 않는 한 해당 패키지를 사용하는 클라이언트 코드를 다시 컴파일할 필요가 없음을 의미합니다. 이 특성은 빌드 시간을 크게 단축시키고, 여러 팀이 독립적으로 각자의 모듈을 개발할 수 있는 안정적인 환경을 제공합니다.
8.2 정보 은닉(Information Hiding)과 캡슐화(Encapsulation)
캡슐화는 연관된 데이터와 그 데이터를 조작하는 서브프로그램들을 하나의 패키지로 묶는 것을 의미하며, 정보 은닉은 패키지 내부의 구현 세부 사항을 외부에 감추어, 공개된 인터페이스를 통해서만 상호작용하도록 강제하는 원칙입니다. Ada는 패키지의 private
부분과 private
및 limited private
타입을 통해 이 두 가지 원칙을 강력하게 지원하여, 모듈의 독립성과 유지보수성을 극대화합니다.
8.2.1 private
타입을 이용한 정보 은닉
만약 패키지 명세의 공개된 부분에 레코드 타입을 선언하면, 해당 패키지를 사용하는 모든 클라이언트 코드는 레코드의 내부 필드에 직접 접근하고 수정할 수 있습니다. 이는 클라이언트 코드가 패키지의 내부 구현에 강하게 의존하게 만들어, 향후 레코드의 구조가 변경될 때 모든 클라이언트 코드를 수정해야 하는 문제를 야기합니다.
private
타입은 이러한 문제를 해결하기 위해 타입의 구현을 숨깁니다. 패키지 명세의 공개부에는 타입의 이름만 is private;
로 선언하고, 실제 구조(예: 레코드 필드)는 명세 파일의 private
영역에 정의합니다.
구조:
package Counter_Package is
type Counter is private; -- 타입의 이름만 공개하고, 구조는 숨깁니다.
-- Counter 타입을 조작하는 공개 인터페이스
procedure increment (C : in out Counter);
function value (C : in Counter) return Natural;
private
-- 명세의 private 영역에 타입의 실제 구현을 정의합니다.
-- 이 부분은 클라이언트 코드에 보이지 않습니다.
Max_Count : constant := 1000;
type Counter is record
Current_Value : Natural := 0;
end record;
end Counter_Package;
이러한 구조에서, Counter_Package
를 사용하는 클라이언트는 다음과 같은 제약을 받습니다.
- 허용:
Counter
타입의 변수를 선언하고,increment
나Value
와 같은 공개된 서브프로그램에 매개변수로 전달할 수 있습니다. 또한, 대입(:=
) 및 동등 비교(=
,/=
) 연산은 기본적으로 허용됩니다. - 금지:
My_Counter.Current_Value
와 같이 레코드의 내부 필드에 직접 접근할 수 없습니다. 이는 컴파일 오류를 유발합니다.
private
타입을 통해 데이터의 일관성(예: Current_Value
가 Max_Count
를 넘지 않도록 increment
프로시저 내에서 제어)을 패키지 내부에서 책임지고 보장할 수 있습니다.
Clair 라이브러리의 루트 패키지인 clair.ads
는 private
영역을 사용하여 라이브러리 내부의 자식 패키지들만 공유하는 헬퍼 함수 to_chars_ptr
를 정의하고, 최종 사용자에게는 이 함수의 존재를 숨깁니다[cite: 467].
8.2.2 limited private
타입을 이용한 완전한 제어
limited private
타입은 private
타입보다 한 단계 더 강력한 정보 은닉을 제공합니다. private
타입의 모든 특성을 가지면서, 추가적으로 클라이언트에 의한 대입(:=
)과 동등 비교(=
, /=
) 연산까지 금지합니다.
선언:
type File_Handle is limited private;
limited private
타입은 다음과 같은 경우에 필수적입니다.
- 고유 자원 표현: 파일 핸들, 네트워크 소켓, 뮤텍스(Mutex) 등 복사될 경우 의미가 모호해지거나 위험한 자원을 표현할 때 사용됩니다.
Handle_1 := Handle_2;
와 같은 대입 연산은 단순히 참조만 복사할 뿐 실제 자원을 복사하는 것이 아니므로, 혼란(예: 이중 자원 해제)을 야기할 수 있습니다.limited private
은 이러한 위험한 대입을 원천적으로 차단합니다. - 완전한 제어: 해당 타입의 객체를 생성, 복사, 비교하는 모든 행위를 패키지가 제공하는 서브프로그램을 통해서만 수행하도록 강제할 수 있습니다.
Clair의 Clair.DL.Handle
타입은 동적 라이브러리를 가리키는 시스템 주소로, 고유 자원의 특성을 가집니다. 이 타입을 더 안전하게 설계한다면 다음과 같이 limited private
으로 선언할 수 있습니다.
개선된 설계 예시:
package Clair.DL is
type Handle is limited private; -- 대입과 비교 연산 금지
NULL_HANDLE : constant Handle;
function Open (Path : String) return Handle;
procedure Close (Lib : in out Handle);
-- ...
private
type Handle is new System.Address;
NULL_HANDLE : constant Handle := Handle(System.NULL_ADDRESS);
end Clair.DL;
이 설계에서 사용자는 My_Handle := Another_Handle;
과 같은 코드를 작성할 수 없으며, 오직 Open
과 Close
를 통해서만 Handle
객체를 다룰 수 있습니다. 이로써 Handle
타입에 대한 모든 제어권은 Clair.DL
패키지가 갖게 되어, 자원 누수나 오용을 방지하는 매우 견고한 코드를 작성할 수 있습니다.
8.3 자식 패키지 (Child Packages)
Ada는 패키지를 계층적으로 구성하여 거대한 소프트웨어 시스템을 체계적으로 관리할 수 있는 자식 라이브러리(child library) 메커니즘을 제공합니다. 자식 패키지는 기존 패키지의 논리적인 확장으로, 점(.) 표기법을 사용하여 Parent.Child
와 같이 부모-자식 관계를 표현합니다. 이는 관련된 기능들을 하나의 큰 서브시스템으로 묶어 구조화하는 강력한 방법입니다.
8.3.1 자식 패키지의 개념
하나의 거대한 패키지에 모든 기능을 담는 대신, 자식 패키지를 사용하면 관련된 기능들을 논리적인 단위로 분할하여 별도의 패키지로 구성할 수 있습니다. 예를 들어, 그래픽 라이브러리를 만든다면 Graphics
라는 부모 패키지 아래에 Graphics.Widgets
, Graphics.Drawing
, Graphics.Text
와 같은 자식 패키지들을 둘 수 있습니다.
이러한 계층적 구조는 다음과 같은 장점을 가집니다.
- 조직화: 연관된 패키지들을 하나의 서브시스템으로 묶어 코드의 논리적 구조를 명확히 합니다.
- 이름 공간 관리:
Graphics.Widgets.Button
과 같이 계층적인 이름은 이름 충돌을 방지하고 식별자의 출처를 명확하게 보여줍니다. - 가시성 제어: 자식 패키지는 부모 패키지의
private
영역에 접근할 수 있는 특별한 가시성 규칙을 가집니다.
8.3.2 가시성 규칙과 private
자식
자식 패키지의 가장 강력한 특징은 부모 패키지와의 특별한 가시성 관계에 있습니다.
1. 일반 자식 패키지 (Public Children)
일반적으로 선언된 자식 패키지(Parent.Child
)는 다음과 같은 가시성 규칙을 따릅니다.
- 자식 패키지의 본체(
Parent.Child.adb
)는 부모 패키지 명세(Parent.ads
)의 공개(public
) 영역과 비공개(private
) 영역 모두에 접근할 수 있습니다.
이 규칙은 자식 패키지들이 부모 패키지의 구현 세부 사항을 공유할 수 있게 하는 통제된 방법입니다. 즉, 부모 패키지는 자신의 private
영역에 특정 타입이나 헬퍼(helper) 서브프로그램을 정의해두고, 외부 클라이언트에게는 숨긴 채 오직 자신의 자식 패키지들에게만 해당 기능을 노출할 수 있습니다. 이는 서브시스템 내의 강한 결합은 허용하면서도, 외부로부터의 캡슐화는 유지하는 효과를 가집니다.
Clair 라이브러리는 이 기능을 활용합니다. 루트 패키지인 clair.ads
는 private
영역에 C 포인터 변환 함수를 정의합니다.
-- file: src/clair.ads
package Clair is
-- 공개 API는 없음
private
-- 자식 패키지들만 공유하는 비공개 헬퍼 함수
function to_chars_ptr is new Ada.Unchecked_Conversion (
source => System.Address,
target => Interfaces.C.Strings.chars_ptr
);
end Clair;
이 to_chars_ptr
함수는 Clair
패키지의 자식인 Clair.DL
이나 Clair.File
등의 본체(adb
파일)에서 자유롭게 호출될 수 있지만, Clair
서브시스템 외부의 사용자에게는 완전히 숨겨져 있습니다.
2. 비공개 자식 패키지 (private
Children)
자식 패키지 자체를 private
으로 선언할 수도 있습니다. private package Parent.Implementation is ...
와 같이 선언된 비공개 자식 패키지는 오직 부모 패키지의 본체나 다른 자식 패키지들 내에서만 with
하여 사용할 수 있으며, 서브시스템 외부에는 전혀 노출되지 않습니다. 이는 서브시스템의 구현이 너무 복잡하여 여러 파일로 나누어야 하지만, 그 어떤 부분도 공개 API로 만들고 싶지 않을 때 사용됩니다.
8.3.3 Clair 라이브러리의 계층 구조
Clair 라이브러리는 자식 패키지를 이용한 계층적 설계의 모범적인 사례입니다.
Clair
: 최상위 부모 패키지입니다. 공개된 기능은 없으며, 모든 자식 패키지가 공유할 비공개 요소를 담는 이름 공간의 역할을 합니다.Clair.File
,Clair.Process
,Clair.Error
등: 각각 파일 처리, 프로세스 제어, 오류 처리 등 특정 POSIX 기능을 캡슐화하는 공개 자식 패키지들입니다. 라이브러리 사용자는with Clair.File;
과 같이 필요한 자식 패키지를 직접 참조하여 관련 기능을 사용합니다.
이러한 설계는 라이브러리의 구조를 논리적이고 이해하기 쉽게 만들며, Clair.File.open
과 같은 이름을 통해 함수의 소속과 역할을 명확하게 전달합니다.
8.4 use
와 renames
절의 효과적인 사용
패키지의 요소를 참조할 때는 Package_Name.Element_Name
과 같은 전체 경로 이름(fully qualified name)을 사용하는 것이 가장 명확하고 안전합니다. 하지만 때로는 이름이 너무 길거나 반복적으로 사용되어 코드의 가독성을 해칠 수 있습니다. Ada는 이러한 경우를 위해 use
와 renames
라는 두 가지 절을 제공하여 코드의 간결성을 높일 수 있도록 지원하지만, 남용될 경우 부작용이 따르므로 신중하게 사용해야 합니다.
8.4.1 use
절: 직접적인 가시성 확보
use <패키지_이름>;
절은 지정된 패키지의 공개 선언들을 현재 유효 범위로 가져와, 패키지 이름 없이 해당 요소들을 직접 참조할 수 있게 합니다.
with Ada.Text_IO;
use Ada.Text_IO; -- Text_IO 패키지의 선언들을 직접 사용할 수 있게 함
procedure Hello_Use is
begin
put_line ("Hello, World!"); -- Ada.Text_IO.put_line 대신 사용
end Hello_Use;
장점:
- 코드가 간결해집니다.
단점 (이름 공간 오염):
use
절의 가장 큰 단점은 이름 공간 오염(namespace pollution)의 위험입니다. 만약 서로 다른 두 패키지(예: P1
, P2
)를 use
하고 두 패키지 모두에 Action
이라는 이름의 프로시저가 있다면, 코드에서 Action;
을 호출할 때 컴파일러는 P1.Action
을 호출해야 할지 P2.Action
을 호출해야 할지 결정할 수 없어 모호성 오류(ambiguity error)를 보고합니다. 이 문제는 코드의 유지보수성을 심각하게 저해할 수 있습니다.
이러한 위험 때문에, 일반 use
절은 매우 제한적으로, 가급적 서브프로그램 본체와 같이 유효 범위가 좁은 곳에서만 사용하는 것이 권장됩니다.
8.4.2 use type
절: 안전한 연산자 가시성
use type
절은 일반 use
절의 위험성을 해결한 훨씬 안전하고 제한적인 형태입니다. use type <타입_이름>;
은 해당 타입에 대해 정의된 중위(infix) 연산자(예: +
, -
, =
, <
)들만 직접 사용할 수 있게 합니다.
이는 일반 서브프로그램이나 다른 타입의 이름을 현재 이름 공간으로 가져오지 않으므로 이름 공간 오염을 일으키지 않습니다. use type
절은 연산자 오버로딩을 사용하는 타입의 표현식을 자연스럽게 작성하는 데 매우 유용합니다.
Clair 라이브러리의 본체 파일들은 C 타입과의 연산을 자연스럽게 표현하기 위해 use type
절을 적극적으로 사용합니다.
-- file: src/clair-process.adb
package body Clair.Process is
-- 이 선언으로 인해 Interfaces.C.int 타입의 '=' 연산자를 직접 사용할 수 있습니다.
use type Interfaces.C.int;
-- 이 선언으로 인해 Clair.Types.pid_t 타입의 '=' 연산자를 직접 사용할 수 있습니다.
use type Clair.Types.pid_t;
...
function set_sid return Clair.Types.pid_t is
retval : constant Clair.Types.pid_t := ...;
begin
-- 'use type'이 없다면, Clair.Types."="(retval, -1) 와 같이
-- 어색한 형태로 호출해야 할 수 있습니다.
if retval = -1 then
-- ...
end if;
return retval;
end set_sid;
end Clair.Process;
이처럼 use type
은 연산자에 한해서만 직접적인 가시성을 제공하므로, use
절의 편리함과 타입 안전성을 모두 만족시키는 권장되는 방식입니다.
8.4.3 renames
절: 식별자 별칭 부여
renames
절은 이미 선언된 패키지, 서브프로그램, 객체, 예외 등의 이름이 너무 길어 반복적으로 사용하기 불편할 때, 더 짧거나 의미 있는 별칭(alias)을 부여하는 기능입니다. renames
는 새로운 개체를 만들지 않고 오직 기존 개체에 다른 이름을 추가할 뿐입니다.
문법:
새_이름 : <개체_종류> renames <기존_전체_경로_이름>;
사용 예시:
with Ada.Strings.Unbounded;
procedure Rename_Test is
-- 패키지 이름 변경
package U renames Ada.Strings.Unbounded;
-- 함수 이름 변경
function To_UB_String (S : String) return U.Unbounded_String
renames U.to_unbounded_string;
My_String : U.Unbounded_String := To_UB_String ("Test");
begin
-- ...
end Rename_Test;
renames
는 긴 이름을 간결하게 만들어 코드의 지역적인 가독성을 높이거나, use
절로 인해 발생할 수 있는 모호성을 해결하는 데 사용될 수 있습니다.
결론적으로, 명확성과 안전성을 위해 전체 경로 이름을 사용하는 것을 기본으로 하되, 연산자를 사용할 때는 use type
을, 긴 이름이 반복되어 가독성을 해칠 때는 renames
를 제한적으로 사용하는 것이 효과적인 Ada 프로그래밍 전략입니다.
9. 예외 처리
아무리 완벽하게 프로그램을 설계하고 작성하더라도, 예상치 못한 오류는 언제나 발생할 수 있습니다. 사용자가 존재하지 않는 파일을 열려고 시도하거나, 네트워크 연결이 갑자기 끊기거나, 계산 결과가 허용된 숫자 범위를 초과하는 등 프로그램의 정상적인 실행을 방해하는 상황은 무수히 많습니다.
이러한 예외적인(exceptional) 상황을 어떻게 처리하느냐가 소프트웨어의 견고함(robustness)과 신뢰성(reliability)을 결정합니다. 전통적인 방식에서는 함수가 반환하는 상태 코드를 모든 호출 지점에서 일일이 확인해야 했습니다. 이러한 방식은 주된 로직을 파악하기 어렵게 만들고, 프로그래머가 오류 확인을 누락할 경우 시스템 전체를 위험에 빠뜨릴 수 있습니다.
Ada는 이러한 문제를 해결하기 위해 예외 처리(Exception Handling)라는 체계적이고 강력한 메커니즘을 제공합니다. 예외 처리의 핵심 철학은 정상적인 실행 흐름과 오류 처리 로직을 명확하게 분리하는 것입니다. 이를 통해 우리는 프로그램의 주된 로직을 깔끔하게 유지하면서, 예외적인 상황이 발생했을 때 어떻게 대응할지를 구조적으로 관리할 수 있습니다.
이번 9장에서는 Ada의 예외 처리 메커니즘을 기초부터 심도 있게 학습합니다. 예외를 선언하고 발생시키는 방법부터 시작하여, 예외가 프로그램 내에서 어떻게 전파되고 처리되는지, 그리고 동시성 환경에서는 어떻게 동작하는지를 살펴볼 것입니다. 나아가 고급 예외 관리 기법과 실제 사례 연구를 통해 다양한 상황에 대처하는 모범 사례를 익힙니다.
이 장을 마치고 나면, 여러분은 단순히 동작하는 프로그램을 넘어, 어떠한 예외 상황에서도 안정적으로 대처할 수 있는 견고하고 신뢰성 높은 소프트웨어를 구축할 수 있는 능력을 갖추게 될 것입니다.
9.1 견고한 프로그래밍의 필요성
소프트웨어의 품질을 평가할 때 ‘견고함(Robustness)’은 핵심적인 척도 중 하나입니다. 견고한 프로그램이란, 단순히 주어진 명세를 완벽하게 수행하는 것을 넘어, 예상치 못한 입력이나 비정상적인 실행 환경에 직면했을 때도 치명적인 오류로 중단되지 않고 안정적으로 동작하거나 예측 가능한 방식으로 실패(fail gracefully)하는 프로그램을 의미합니다.
프로그램의 오류는 단순히 프로그래머의 실수(bug)에 국한되지 않습니다. 사용자의 잘못된 데이터 입력, 디스크 공간 부족, 네트워크 단절, 하드웨어 센서의 비정상적인 값 반환 등 프로그램 외부 환경에서 비롯되는 경우가 훨씬 많습니다.
견고하지 못한 소프트웨어는 단순히 멈추는 것에서 그치지 않고, 데이터를 손상시키거나, 항공기, 원자력 발전소, 의료 기기와 같은 고신뢰성 시스템에서는 인명과 재산에 직접적인 위협이 될 수 있습니다.
따라서 견고한 소프트웨어를 구축하기 위해서는 오류를 다루는 체계적인 접근법이 필수적입니다. 이번 절에서는 먼저 프로그램에서 발생할 수 있는 오류의 종류를 이해하고, 전통적인 오류 처리 방식이 왜 현대적인 고신뢰성 시스템에 부적합한지 살펴볼 것입니다. 그리고 이를 통해 Ada가 제공하는 구조적인 예외 처리 방식의 필요성을 자연스럽게 이해하게 될 것입니다.
9.1.1 프로그램 오류의 이해
견고한 프로그램을 작성하기 위한 첫걸음은 프로그램에서 발생할 수 있는 ‘오류(Error)’의 종류를 이해하는 것입니다. 오류는 그것이 발견되는 시점에 따라 크게 컴파일-시간 오류, 링크-시간 오류, 그리고 런타임 오류로 나눌 수 있습니다.
컴파일-시간 오류 (Compile-Time Errors)
컴파일-시간 오류는 소스 코드를 실행 파일로 변환하는 컴파일 과정에서 발견되는 오류입니다. 이는 대부분 프로그래밍 언어의 문법이나 정적 규칙을 위반했을 때 발생합니다.
- 예시:
- 키워드 오타 (
procedure
를procedur
로 작성) - 문장의 끝에 세미콜론(
;
) 누락 - 타입 불일치 (정수 타입 변수에 문자열을 대입)
- 키워드 오타 (
Ada의 컴파일러는 강력한 정적 분석 기능을 통해 이러한 오류를 매우 엄격하게 검사합니다. 컴파일-시간 오류는 프로그램이 실행되기 전에 발견되므로 가장 안전하고 수정하기 쉬운 “좋은” 오류입니다. Ada의 설계 철학은 가능한 한 많은 오류를 이 단계에서 잡아내는 것입니다.
링크-시간 오류 (Link-Time Errors)
링크-시간 오류는 각 소스 파일이 성공적으로 컴파일된 후, 이를 하나의 실행 파일로 묶는 링크(link) 과정에서 발생하는 오류입니다. 이는 주로 프로그램의 여러 구성 요소 간의 연결이 맞지 않을 때 발생합니다.
- 예시:
- 패키지 명세(
ads
)에 선언만 하고, 본체(adb
)에 구현하지 않은 서브프로그램을 호출 - 존재하지 않는 라이브러리와 연결을 시도
- 패키지 명세(
이 역시 프로그램 실행 전에 발견되므로 비교적 안전하지만, 프로그램의 구조나 빌드 설정에 문제가 있음을 나타냅니다.
런타임 오류 (Run-Time Errors)
런타임 오류는 프로그램이 실행되는 도중에 발생하는, 가장 다루기 까다롭고 위험한 오류입니다. 코드의 문법은 완벽하고 성공적으로 실행 파일이 만들어졌지만, 특정 실행 조건 하에서 문제가 발생하는 경우입니다. 런타임 오류는 다시 두 가지로 나눌 수 있습니다.
-
논리적 오류 (Logical Errors / Bugs): 프로그램이 중단되지는 않지만, 알고리즘의 결함으로 인해 의도와 다른 잘못된 결과를 출력하는 경우입니다. 예외 처리가 이 문제를 직접 해결하지는 않지만, 계약 기반 설계(Design by Contract)의 단정(Assert) 등을 통해 비정상적인 상태를 감지하는 데 도움을 줄 수 있습니다.
-
예외적인 상황 (Exceptional Situations): 정상적인 상황에서는 문제가 없으나, 외부 환경이나 계산 과정에서 발생하는 예기치 못한 상황입니다. 이번 장에서 다루는 ‘예외 처리’는 바로 이러한 상황을 관리하기 위한 것입니다.
- 외부 요인: 존재하지 않는 파일을 열려고 시도, 디스크 공간 부족, 네트워크 연결 끊김.
- 내부 요인: 0으로 나누기, 숫자 타입의 표현 범위를 초과하는 연산(
Constraint_Error
),null
접근 타입 참조.
이러한 예외적인 상황을 적절히 처리하지 않으면 프로그램은 비정상적으로 중단되거나 데이터를 손상시킬 수 있습니다. 다음 절에서는 이러한 런타임 오류를 처리하던 전통적인 방식의 한계를 살펴보고, 왜 Ada의 예외 처리 메커니즘이 필요한지 알아보겠습니다.
9.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;
이 방식의 한계는 명확합니다.
-
주 로직과 오류 처리 로직의 혼합: 프로그램의 정상적인 실행 흐름(happy path) 중간중간에 오류를 확인하는
if-then-else
문이 계속해서 삽입됩니다. 이로 인해 정작 중요한 알고리즘의 흐름을 파악하기가 매우 어려워지고 코드가 복잡해집니다. -
오류 처리의 누락 가능성: 프로그래머가 반환된 상태 코드를 확인하는 것을 잊거나 의도적으로 무시하기 쉽습니다. 언어가 오류 확인을 강제하지 않기 때문입니다. 무시된 오류는 사라지지 않고 잠복해 있다가 프로그램의 다른 부분에서 훨씬 더 심각한 데이터 손상이나 비정상 종료를 유발하여 디버깅을 어렵게 만듭니다.
-
오류 정보 전파의 어려움: 여러 단계의 서브프로그램 호출(A → B → C)에서 가장 안쪽(C)에서 발생한 오류를 최상위(A)까지 전달하려면, 중간의 모든 서브프로그램(B)이 오류 코드를 받아서 그대로 다시 반환하는 코드를 추가로 작성해야 합니다. 이는 오류와 직접 관련 없는 중간 단계의 코드를 불필요하게 오염시킵니다.
전역 오류 변수 (Global Error Variables)
오류가 발생했을 때 약속된 전역 변수(C 언어의 errno
처럼)에 오류 코드를 설정하는 방식입니다. 호출자는 서브프로그램 호출 직후 이 전역 변수를 확인하여 오류 상태를 파악합니다.
이 방식은 상태 반환 코드의 일부 문제를 해결하는 것처럼 보이지만, 더 심각한 문제를 야기합니다. 특히 동시성 프로그래밍(concurrency) 환경에서는 여러 태스크가 동시에 전역 변수를 수정하려 할 때 경쟁 상태(race condition)가 발생하여 신뢰할 수 없는 오류 값을 읽게 될 위험이 있습니다.
이처럼 전통적인 오류 처리 방식은 코드의 가독성을 해치거나, 잠재적인 오류를 무시할 위험을 감수하게 만듭니다. 결국 깔끔하면서도 견고한 코드를 작성하기 어렵게 만드는 근본적인 한계를 가집니다. 다음 절에서는 Ada의 예외 처리 메커니즘이 이러한 문제들을 어떻게 구조적으로 해결하는지 알아보겠습니다.
9.1.3 구조적 접근법: 예외(exception) 소개
전통적인 오류 처리 방식의 한계를 극복하기 위해, Ada는 예외 처리(Exception Handling)라는 구조적이고 강력한 접근법을 제공합니다. 이 메커니즘의 핵심 철학은 관심사의 분리(Separation of Concerns), 즉 정상적인 실행 흐름과 오류 처리 흐름을 코드 상에서 명확하게 분리하는 것입니다.
핵심 철학: 정상 흐름과 오류 흐름의 분리
예외 처리를 사용하면, 프로그램의 주 로직은 모든 연산이 성공할 것이라는 가정하에 “성공 경로(happy path)”에만 집중하여 작성할 수 있습니다. 오류를 확인하는 if
문이 코드 곳곳에 흩어져 있을 필요가 없습니다. 이로써 주된 알고리즘은 간결하고 명확해져 가독성과 유지보수성이 크게 향상됩니다.
그리고 예상치 못한 문제가 발생했을 때의 처리 코드는 exception
이라는 별도의 블록에 모아서 작성합니다. 이 블록은 오직 예외가 발생했을 때만 실행됩니다.
이는 마치 건물의 주 출입구와 비상구를 분리하는 것과 같습니다. 평상시에는 주 출입구를 이용하며, 비상 상황(예외)이 발생했을 때만 비상구(예외 핸들러)라는 정해진 경로를 통해 대처하는 것과 동일한 원리입니다.
동작 원리: 발생(Raise)과 처리(Handle)
Ada의 예외 처리 메커니즘은 두 가지 주요 동작으로 이루어집니다.
- 예외 발생 (Raise): 런타임 오류가 발생하면, 해당 지점에서 예외가 발생(raise)합니다. 이 순간 프로그램의 정상적인 실행은 즉시 중단됩니다.
- 예외 처리 (Handle): 제어권은 런타임 시스템으로 넘어가고, 시스템은 현재 실행 중인 코드 블록의
exception
부분에서 해당 예외를 처리할 수 있는 핸들러(handler)를 찾습니다. 적절한 핸들러를 찾으면 그 코드를 실행하고, 만약 찾지 못하면 예외는 호출 스택을 따라 상위 서브프로그램으로 전파(propagate)됩니다.
구조적 예외 처리의 장점
이러한 접근법은 전통적인 방식의 한계를 명확하게 해결합니다.
- 가독성 향상: 주 로직과 오류 처리 로직이 분리되어 코드를 이해하기 쉽습니다.
- 오류 처리 강제: 발생한 예외는 무시되지 않습니다. 만약 프로그램의 어떤 수준에서도 처리되지 않으면, 최종적으로 프로그램은 중단됩니다. 이는 오류를 실수로라도 누락하는 것을 방지하여 프로그램의 신뢰성을 높입니다.
- 오류 정보의 자동 전파: 중간 단계의 서브프로그램들이 오류를 전달하기 위한 별도의 코드를 작성할 필요 없이, 예외가 자동으로 상위로 전파되어 필요한 곳에서 처리될 수 있습니다.
다음은 예외 처리의 기본 구조를 보여주는 개념적인 코드입니다.
begin
-- 정상적인 실행 로직
-- 이 코드는 모든 연산이 성공할 것이라고 가정하고 작성됩니다.
Step_1;
Step_2;
Step_3;
exception
when Error_In_Step_2 =>
-- Step_2에서 특정 오류가 발생했을 때 실행될 복구 코드
when others =>
-- 그 외 예측하지 못한 다른 오류가 발생했을 때 실행될 코드
end;
이제 우리는 왜 예외 처리가 필요한지 이해했습니다. 다음 섹션부터는 Ada에서 예외를 실제로 어떻게 선언하고, 발생시키며, 처리하는지에 대한 구체적인 구문과 기법을 학습할 것입니다.
9.2 Ada 예외의 기초
앞선 9.1절에서 우리는 왜 전통적인 오류 처리 방식에 한계가 있으며, 왜 구조적인 예외 처리가 견고한 소프트웨어에 필수적인지를 살펴보았습니다. 이제 이론을 넘어, Ada에서 예외를 다루는 구체적인 방법과 문법을 배울 차례입니다.
이번 9.2절에서는 예외 처리의 가장 기본적인 구성 요소들을 하나씩 학습합니다. 예외가 무엇인지 정확히 정의하고, 우리만의 예외를 선언(declare)하며, raise
문을 통해 예외를 발생(raise)시키고, exception
블록으로 이를 처리(handle)하는 전체 과정을 다룰 것입니다.
또한, 우리가 직접 선언하는 예외 외에도 0으로 나누거나 타입의 범위를 벗어나는 등의 특정 규칙 위반 시 언어 런타임 시스템이 자동으로 발생시키는 사전 정의된 예외들에 대해서도 알아봅니다.
이 절을 마치면 여러분은 Ada 프로그램에서 예외를 정의하고, 기본적인 예외 상황을 제어할 수 있는 핵심적인 문법 지식을 갖추게 될 것입니다.
9.2.1 예외란 무엇인가?
프로그램 실행 중에 발생하는 오류나 예상치 못한 상황을 예외(exception)라고 합니다. 예외는 프로그램의 정상적인 명령어 흐름을 방해하는 이벤트입니다.
Ada에서 예외는 오류를 처리하기 위한 체계적이고 구조적인 메커니즘을 제공합니다. 오류가 발생할 수 있는 지점에서 상태 코드를 반환하고 모든 호출자가 이를 확인하도록 요구하는 전통적인 방식과 달리, 예외는 오류 처리 로직을 주된 프로그램 로직과 분리합니다. 이러한 분리는 다음과 같은 장점을 가집니다.
- 가독성 향상: 주된 알고리즘이 오류 검사 코드로 인해 복잡해지는 것을 방지하여 코드의 가독성과 유지보수성을 높입니다.
- 오류 처리 강제: 발생한 예외는 반드시 처리되어야 합니다. 만약 현재 유효 범위 내에 적절한 예외 핸들러가 없다면 예외는 호출 스택을 따라 상위로 전파됩니다. 최상위 수준까지 전파된 예외가 처리되지 않으면 프로그램은 종료됩니다. 이 특성은 프로그래머가 오류를 무시하고 넘어가는 것을 방지합니다.
Ada에서 예외 처리의 기본 흐름은 다음과 같습니다.
- 예외 발생 (Raise): 프로그램 실행 중 예외적인 상황이 발생하면, 해당 지점에서 예외를
raise
합니다. - 실행 중단 및 핸들러 탐색: 예외가 발생하면, 현재 코드 블록의 정상적인 실행은 즉시 중단됩니다. 이후 런타임 시스템은 해당 예외를 처리할 수 있는 예외 핸들러(exception handler)를 찾기 시작합니다.
- 예외 처리 (Handle): 적절한 핸들러를 찾으면, 해당 핸들러의 코드가 실행됩니다. 핸들러의 실행이 완료되면, 프로그램 제어는 예외가 발생했던 코드 블록의 다음으로 이동합니다.
개념적으로, 이는 공장의 비상 정지 시스템과 유사합니다. 정상적인 생산 라인(프로그램의 정상 흐름)에서 치명적인 문제가 발생(예외 발생)하면, 비상 벨이 울리면서 라인이 즉시 멈춥니다. 그 후, 작업자들은 사전에 정의된 비상 대응 매뉴얼(예외 핸들러)에 따라 문제를 해결합니다.
다음은 예외 처리 구조를 보여주는 개념적인 코드 형식입니다.
begin
-- 프로그램의 정상적인 실행 흐름
-- 이 블록 안에서 예외가 발생할 수 있습니다.
exception
when 특정_예외 =>
-- '특정_예외'가 발생했을 때 실행될 코드
when others =>
-- 그 외 다른 모든 예외가 발생했을 때 실행될 코드
end;
이후 섹션에서는 Ada에서 이러한 예외를 직접 선언하고, 발생시키며, 처리하는 구체적인 구문과 기법에 대해 학습할 것입니다.
9.2.2 예외 타입 선언
Ada는 Constraint_Error
와 같이 사전 정의된 예외들을 제공하지만, 대부분의 실제 응용 프로그램에서는 해당 도메인에 맞는 특정한 오류 상황을 표현하기 위해 우리만의 예외를 직접 만들어 사용해야 합니다. 예를 들어, ‘네트워크 연결 실패’나 ‘잔액 부족’과 같은 오류는 언어에 내장된 예외만으로는 표현하기 어렵습니다.
사용자 정의 예외를 만드는 것은 코드의 가독성을 높이고 오류의 원인을 명확하게 전달하는 첫걸음입니다.
예외 선언 구문
Ada에서 예외는 다음과 같이 간단하게 선언할 수 있습니다. 식별자 뒤에 exception
키워드를 붙이면 됩니다.
식별자 : exception;
예외의 이름은 해당 오류 상황을 명확히 설명하도록 짓는 것이 중요합니다. 일반적으로 _Error
접미사를 붙여 다른 식별자와 구분하는 명명 규칙을 많이 사용합니다.
-- 예외 선언 예시
Invalid_Input_Error : exception;
Network_Timeout : exception;
Insufficient_Funds : exception;
예외의 선언 위치와 유효 범위(Scope)
예외는 변수나 타입과 마찬가지로 선언부에 선언되며, 선언된 위치에 따라 유효 범위가 결정됩니다.
-
패키지 명세 (Package Specification): 가장 일반적인 선언 위치입니다. 패키지 명세에 선언된 예외는 해당 패키지를
with
하는 모든 클라이언트 코드에서 접근할 수 있습니다. 이는 패키지가 제공하는 기능에서 발생할 수 있는 오류를 외부에 알리는 공식적인 방법입니다. -
서브프로그램 또는 패키지 본체 (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;
이처럼 적절한 이름으로 예외를 선언하고, 용도에 맞게 유효 범위를 지정함으로써 우리는 추상적인 런타임 오류를 구체적이고 의미 있는 프로그램의 이벤트로 만들 수 있습니다.
9.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
문은 단순한 조건 검사를, 무시할 수 없는 강력한 오류 신호로 바꾸어주는 핵심적인 도구입니다.
9.2.4 exception
블록을 이용한 예외 처리
raise
문으로 예외를 발생시키거나 시스템이 예외를 자동으로 발생시켰을 때, 이 예외를 붙잡아 처리하는 “안전망” 역할을 하는 것이 바로 exception
블록입니다. exception
블록은 발생한 예외의 전파를 멈추고, 정의된 복구 코드를 실행하여 프로그램의 제어권을 되찾아오는 역할을 합니다.
예외 처리 구문
예외 처리는 일반적으로 begin
으로 시작하는 블록의 끝에 exception
키워드를 추가하여 구성합니다.
begin
-- 예외가 발생할 수 있는 보호된 코드 블록
-- ...
exception
when 예외_이름_1 =>
-- 예외_이름_1을 처리하는 코드
when 예외_이름_2 | 예외_이름_3 =>
-- 예외_이름_2 또는 예외_이름_3을 처리하는 코드
when others =>
-- 위에서 명시되지 않은 다른 모든 예외를 처리하는 코드
end;
begin ... exception
:begin
과exception
사이의 코드는 보호된 영역입니다. 이 영역에서 예외가 발생하지 않으면,exception
이하의 모든 코드는 실행되지 않고 건너뜁니다.when 예외_이름 =>
: 특정 예외를 처리하는 핸들러(handler)를 정의합니다.|
(수직 막대): 여러 종류의 예외를 동일한 핸들러로 처리하고 싶을 때 사용합니다.when others =>
: 앞에서 명시되지 않은 다른 모든 예외를 처리하는 ‘만능’ 핸들러입니다. 예상치 못한 오류에 대한 최종적인 안전장치 역할을 하므로 매우 중요합니다.
실행 흐름
begin
블록 내에서 예외가 발생하면 다음과 같은 순서로 실행됩니다.
- 보호된 영역의 정상적인 실행이 즉시 중단됩니다.
- 런타임 시스템은
exception
블록의when
절을 위에서부터 순서대로 확인하여 발생한 예외와 일치하는 첫 번째 핸들러를 찾습니다. - 일치하는 핸들러의 코드를 실행합니다.
- 핸들러의 실행이 끝나면, 프로그램 제어는
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 예외 처리의 근간입니다.
9.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
등 특정 라이브러리에서 정의한 예외들도 표준적으로 사용됩니다.
이처럼 사전 정의된 예외들은 언어의 안전장치 역할을 합니다. 이 예외들이 왜 발생하는지 이해하고 적절히 처리함으로써, 우리는 프로그램의 실행 흐름에 대한 제어권을 잃지 않고 안정적으로 오류에 대처할 수 있습니다.
9.3 구조적 예외 처리
앞선 9.2절에서 우리는 예외를 선언하고, raise
문으로 발생시키며, exception
블록으로 처리하는 기본적인 ‘문법’을 익혔습니다. 이제 한 걸음 더 나아가, 예외가 프로그램의 ‘구조’ 속에서 어떻게 동작하는지 살펴보겠습니다.
Ada의 예외 처리가 ‘구조적’이라고 불리는 이유는, 예외가 단일 블록에 갇혀있지 않고 프로그램의 호출 스택을 따라 체계적으로 전파(propagate)되는 규칙을 가지고 있기 때문입니다. 이번 절의 핵심 질문은 이것입니다: “만약 특정 예외에 대한 핸들러가 없는 곳에서 예외가 발생하면, 그 예외는 어떻게 되는가?”
이 질문에 답하기 위해, 우리는 예외가 중첩된 블록과 서브프로그램 호출을 거슬러 올라가는 예외 전파의 원리를 배우고, 특정 예외를 선택적으로 처리하는 방법, 그리고 처리한 예외를 의도적으로 다시 상위로 전달하는 재발생(re-raising) 기법 등을 학습할 것입니다.
이 절을 통해 여러분은 단일 블록에서의 예외 처리를 넘어, 프로그램 전체에 걸친 다층적인 오류 처리 전략을 설계할 수 있는 시야를 갖게 될 것입니다.
9.3.1 begin
-end
블록과 예외 핸들러
Ada에서 예외 처리의 가장 기본적인 단위는 begin
과 end
로 둘러싸인 블록입니다. declare
를 포함하는 선언 블록, 서브프로그램 본체, 패키지 본체 등 begin
키워드를 사용하는 모든 곳에는 예외를 처리하기 위한 exception
핸들러 섹션을 둘 수 있습니다.
블록의 구조
예외 핸들러를 포함하는 블록의 전체적인 구조는 다음과 같습니다.
[declare
-- 선언부 (Declarative Part)]
begin
-- 실행부 (Sequence of Statements)
-- 이 영역이 예외로부터 '보호되는 영역'입니다.
exception
-- 예외 핸들러 (Exception Handlers)
-- begin 블록에서 발생한 예외를 처리하는 부분입니다.
end;
- 보호 영역 (Protected Area):
begin
과exception
키워드 사이에 있는 코드 영역을 의미합니다.exception
블록에 있는 핸들러는 오직 이 보호 영역 내에서 발생한 예외만을 처리할 수 있습니다. - 예외 핸들러 (Exception Handlers):
exception
키워드 아래에when ... =>
구문으로 정의된 코드 조각들입니다. 예외가 발생했을 때 실행되는 일종의 작은 비상 대응 절차라고 할 수 있습니다.
실행 흐름과 제어권 이동
예외가 발생했을 때, 프로그램의 제어권이 어떻게 이동하는지 이해하는 것이 매우 중요합니다.
- 보호 영역에서 예외가 발생하면, 해당 영역의 실행은 즉시 중단됩니다.
- 런타임 시스템은 같은 블록의
exception
섹션에서 일치하는 핸들러를 찾습니다. - 핸들러를 찾아 실행하면, 예외는 처리된 것(handled)으로 간주됩니다.
- 핸들러 실행이 끝나면, 제어권은 해당
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)되기 시작하며, 다음 절에서 이 주제를 자세히 다루겠습니다.
9.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.");
-- ... 프로그램 종료 절차 수행 ...
특정 예외를 명시적으로 처리하는 것은 오류의 성격을 명확히 구분하고, 각 상황에 가장 적절한 조치를 취하게 함으로써 소프트웨어의 신뢰도를 높이는 가장 기본적인 단계입니다.
9.3.3 when others
선택지
지금까지 특정 예외를 개별적으로 처리하는 방법을 배웠습니다. 하지만 만약 우리가 예상하지 못한, 혹은 일일이 처리하기 번거로운 다른 모든 예외를 한 번에 처리하고 싶다면 어떻게 해야 할까요? 이때 사용되는 것이 바로 when others
선택지입니다.
when others
는 이름 그대로, 해당 exception
블록 내의 다른 when
절에서 처리되지 않은 다른 모든 종류의 예외를 처리하는 ‘만능’ 핸들러입니다.
구문과 규칙
when others
는 exception
블록의 가장 마지막에 위치해야 합니다. 그 뒤에는 다른 when
절이 올 수 없습니다. 만약 when others
뒤에 다른 핸들러가 온다면, 그 코드는 절대 도달할 수 없으므로 컴파일러가 오류로 처리합니다.
exception
when Specific_Error_1 =>
-- ...
when Specific_Error_2 =>
-- ...
when others => -- 반드시 가장 마지막에 위치해야 함
-- 위에서 명시된 두 예외를 제외한 모든 예외를 처리
end;
when others
의 올바른 사용과 오용
when others
는 프로그램의 견고함을 위한 최종 방어선이 될 수 있지만, 잘못 사용하면 오히려 문제의 원인을 숨겨버리는 독이 될 수 있습니다.
올바른 사용 사례
-
최상위 레벨에서의 최종 처리: 프로그램의 메인 프로시저나 태스크의 주 루프와 같이, 더 이상 예외를 전파할 곳이 없는 최상위 레벨에서 사용됩니다. 여기서의 역할은 예상치 못한 오류로 인해 프로그램이 아무런 흔적도 없이 비정상 종료되는 것을 막고, 오류를 로그로 기록한 뒤 프로그램을 안전하게 종료시키는 것입니다.
-
자원 해제 보장 및 재발생: 특정 자원(예: 파일, 잠금(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
를 사용하되, 그 안에서는 반드시 오류를 기록하고, 자원을 정리하며, 대부분의 경우 예외를 다시 발생시켜 문제 상황을 상위 로직에 알려야 합니다.
9.3.4 예외 전파: 처리되지 않은 예외의 이동 경로
begin-end
블록에서 예외가 발생했지만, 그 블록의 exception
부분에 일치하는 핸들러가 없다면 어떻게 될까요? 예외는 사라지지 않습니다. 대신, 더 적절한 핸들러를 찾아 프로그램의 구조를 따라 바깥쪽으로 이동하기 시작하는데, 이 과정을 예외 전파(Exception Propagation)라고 합니다. ⬆️
예외 전파는 혼란스러운 과정이 아니라, 매우 체계적이고 예측 가능한 규칙을 따릅니다.
전파 메커니즘
처리되지 않은 예외는 현재 블록을 즉시 탈출하여, 자신을 감싸고 있는 바로 바깥쪽의 상위 블록이나 자신을 호출한 호출자(caller)에게로 제어권을 넘깁니다.
이는 마치 여러 개의 상자가 중첩된 것과 같습니다. 가장 안쪽 상자에서 문제가 발생했는데 해결할 수 없다면, 문제를 바로 바깥 상자로 넘깁니다. 바깥 상자도 해결할 수 없다면, 다시 그 바깥 상자로 넘기는 과정이 반복됩니다.
- 현재 블록의 실행 종료: 처리되지 않은 예외가 발생한
begin-end
블록은 즉시 실행을 종료합니다. - 상위로 예외 재발생: 예외는 자신을 감싸고 있는 블록이나 자신을 호출한 서브프로그램의 위치에서 다시 발생한 것처럼 취급됩니다.
예제: 서브프로그램 간의 예외 전파
다음 예제는 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_Proc
의 Inner_Proc;
라인으로 전파됩니다. Outer_Proc
에는 Test_Error
를 처리할 수 있는 핸들러가 있으므로, 3번 문장이 실행됩니다. Outer_Proc
가 예외를 성공적으로 처리했으므로, 프로그램은 정상적으로 메인 프로시저로 복귀하여 4번 문장을 실행합니다.
최종 전파와 프로그램 종료
만약 예외가 호출 스택을 따라 계속 전파되어 프로그램의 가장 최상위인 메인 프로시저에 도달했는데도 처리되지 못하면, 프로그램은 최종적으로 비정상 종료됩니다. 이 경우 Ada 런타임 환경은 일반적으로 처리되지 않은 예외의 이름과 발생 위치 등의 정보를 출력하여 디버깅을 돕습니다.
예외 전파는 오류를 그것이 발생한 저수준의 위치에서만 처리하도록 강제하는 대신, 프로그램의 구조 내에서 가장 적절한 수준(level)에서 오류를 처리할 수 있도록 유연성을 제공합니다. 이를 통해 우리는 세부적인 복구는 안쪽 블록에서, 포괄적인 정책 결정은 바깥쪽 블록에서 처리하는 등 다층적인 오류 처리 전략을 설계할 수 있습니다.
9.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_Work
의 when others
핸들러는 자신이 획득한 Lock
을 해제하는 책임만 다했습니다. raise;
를 통해 예외를 다시 전파함으로써, 메인 프로시저의 핸들러는 Database_Error
가 발생했다는 사실을 인지하고 그에 맞는 상위 수준의 대응을 할 수 있었습니다.
이처럼 예외를 다시 발생시키는 기법은, 각 모듈이 자신의 책임만 다하도록 하여 코드의 역할을 명확히 분리하고, 예외를 무시하지 않으면서도 안전하게 자원을 관리할 수 있게 해주는 핵심적인 패턴입니다.
9.4 서브프로그램 및 태스크에서의 예외
앞선 9.3절에서는 예외가 처리되지 않았을 때 중첩된 블록을 따라 어떻게 전파되는지 학습했습니다. 이제 우리는 이 개념을 실제 프로그램의 기본 구성 단위인 서브프로그램(subprogram)과 Ada의 강력한 기능인 태스크(task)로 확장하고자 합니다.
서브프로그램 호출 경계를 넘어갈 때 예외는 어떻게 동작할까요? 더 나아가, 독립적으로 실행되는 태스크에서 발생한 예외는 일반적인 서브프로그램처럼 호출자에게 전파될 수 있을까요? 만약 아니라면, 시스템의 다른 부분은 태스크의 실패를 어떻게 감지할 수 있을까요?
이번 절에서는 이러한 질문에 답하기 위해, 서브프로그램 호출 시의 예외 전파 규칙을 명확히 하고, 태스크라는 동시성 환경에서의 예외 처리, 태스크 종료와의 관계, 그리고 태스크 간의 오류 통신 방법 등을 자세히 살펴볼 것입니다.
이 절을 통해 여러분은 프로그램의 구조적, 동시적 경계를 넘나드는 예외를 안정적으로 관리하는 방법을 익혀, 모듈화되고 신뢰성 높은 대규모 애플리케이션을 구축하는 데 필요한 핵심 역량을 갖추게 될 것입니다.
9.4.1 서브프로그램 호출 중 발생하는 예외
서브프로그램(프로시저 또는 함수) 내부에서 발생한 예외가 처리되지 않을 때, 예외는 중첩된 begin-end
블록에서와 동일한 전파(propagation) 규칙을 따릅니다. 즉, 예외는 서브프로그램의 실행을 즉시 중단시키고, 해당 서브프로그램을 호출한 호출자(caller)에게로 전파됩니다.
전파 메커니즘
- 피호출자(callee, 호출된 서브프로그램) 내부에서 예외가 발생합니다.
- 피호출자 내부에 해당 예외를 처리할 핸들러가 없다면, 피호출자의 실행은 그 즉시 완전히 종료됩니다.
- 예외는 피호출자를 호출했던 바로 그 문장으로 되돌아와 다시 발생한 것처럼 취급됩니다.
- 이제 호출자의
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)를 구성하는 중요한 일부입니다. 서브프로그램을 작성할 때는 어떤 상황에 어떤 예외를 발생시킬 수 있는지 명확히 해야 하며, 다른 서브프로그램을 호출할 때는 해당 서브프로그램이 전파할 수 있는 예외를 처리할 준비를 해야 합니다.
이러한 구조적 전파 메커니즘을 통해, 오류를 감지하는 책임(피호출자)과 오류를 처리하는 정책을 결정하는 책임(호출자)을 명확하게 분리할 수 있습니다.
9.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
를 포함한 예외 핸들러를 두어, 어떤 예외가 발생하더라도 스스로 복구하거나, 최소한 자신의 상태를 외부에 알리고 안전하게 종료되도록 설계해야 합니다.
9.4.3 예외와 태스크 종료
앞 절에서 우리는 처리되지 않은 예외가 태스크를 조용히 종료시킨다는 것을 확인했습니다. 이번 절에서는 이 종료(termination)의 의미를 좀 더 깊이 살펴보고, 예외가 태스크의 생명주기(lifecycle)에 어떤 영향을 미치는지 알아보겠습니다.
태스크 종료의 두 가지 경로
태스크의 실행이 끝나는 것, 즉 ‘종료’에는 두 가지 경로가 있습니다.
-
정상 종료 (Normal Termination): 태스크의
begin-end
블록에 있는 모든 문장이 성공적으로 실행을 마치고, 태스크의 가장 마지막end
에 도달했을 때 정상적으로 종료됩니다. 무한loop
를 가진 태스크는 일반적으로 외부의 종료 요청이 없는 한 정상적으로 종료되지 않습니다. -
비정상 종료 (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
속성은 이렇게 “죽은” 태스크를 외부에서 감지할 수 있는 수동적인 방법을 제공합니다.
이러한 특성은 견고한 동시성 시스템을 설계할 때 매우 중요합니다. 오랫동안 실행되어야 하는 태스크는 자신의 생존을 위해 내부적으로 예외를 처리해야만 합니다. 만약 태스크가 실패할 수 있다면, 시스템의 다른 부분은 해당 태스크의 종료를 감지하고 적절한 복구 절차(예: 새로운 작업자 태스크 생성)를 수행할 수 있어야 합니다.
9.4.4 태스크 간 예외 정보 통신
태스크 내의 처리되지 않은 예외는 전파되지 않고 해당 태스크를 조용히 종료시킬 뿐입니다. 'Terminated
속성은 태스크가 실패했다는 사실은 알려주지만, 왜(why) 실패했는지에 대한 정보는 주지 않습니다.
하지만 내결함성 시스템을 구축하려면, 관리자(supervisor) 태스크는 작업자(worker) 태스크가 왜 실패했는지 알아야만 올바른 복구 절차를 수행할 수 있습니다. 예를 들어, ‘네트워크 일시 단절’ 오류는 재시도를, ‘설정 파일 오류’는 안전 모드 진입이라는 다른 대응이 필요하기 때문입니다. 이를 위해서는 태스크 간에 예외 정보를 전달할 명시적인 통신 채널이 필요합니다. 📡
통신 패턴: 보호 객체를 이용한 실패 보고
Ada에서 태스크 간의 안전한 데이터 공유는 보호 객체(Protected Object)를 통해 이루어집니다. 이 보호 객체를 일종의 “실패 보고 우체통”으로 사용하여, 실패한 태스크가 자신의 예외 정보를 남기고 종료되도록 설계할 수 있습니다.
설계:
- 실패 보고자 (Failure_Reporter): 보호 객체를 정의합니다. 이 객체는 예외 정보를 저장할 변수와, 예외를 기록하는 프로시저(
Set_Failure
), 그리고 기록된 예외 정보를 가져가는 엔트리(Get_Failure
)를 가집니다. - 작업자 태스크 (Worker Task): 자신의 모든 로직을
begin-exception
블록으로 감쌉니다.when others
핸들러 안에서, 종료되기 직전에Failure_Reporter.Set_Failure
를 호출하여 자신의 예외 정보를 “우체통”에 넣습니다. - 관리자 태스크 (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) 능력을 갖추게 됩니다.
9.5 고급 예외 관리
지금까지 우리는 Ada의 예외 처리 기본 메커니즘을 학습했습니다. 예외를 선언하고, 발생시키고, 처리하며, 그것이 프로그램 구조를 따라 어떻게 전파되는지 이해했습니다. 이 지식만으로도 많은 오류 상황에 대처할 수 있습니다.
하지만 만약 발생한 예외에 대한 더 상세한 정보, 예를 들어 어떤 예외가 발생했는지, 어떤 메시지를 담고 있는지, 혹은 어디서 발생했는지와 같은 정보를 얻고 싶다면 어떻게 해야 할까요?
이러한 고급 요구사항을 위해 Ada는 Ada.Exceptions
라는 표준 라이브러리 패키지를 제공합니다. 이 패키지는 예외 ‘발생 정보(occurrence)’ 자체를 데이터 객체처럼 다룰 수 있는 타입과 서브프로그램들을 제공하여, 예외를 훨씬 더 정교하게 제어할 수 있게 해줍니다.
이번 9.5절에서는 Ada.Exceptions
패키지를 중심으로 다음의 고급 기법들을 탐구할 것입니다.
Exception_Occurrence
타입을 이용해 예외 발생 정보 얻기Raise_Exception
프로시저를 이용해 예외에 사용자 정의 메시지 첨부하기- 제네릭 유닛(Generic Units)에서의 예외 처리 시 고려사항
이 절을 학습하고 나면, 여러분은 예외를 단순한 제어 흐름의 신호로만 보는 것을 넘어, 디버깅, 로깅, 오류 보고 시스템에 활용할 수 있는 풍부한 정보를 담은 데이터 객체로 다룰 수 있게 될 것입니다. 이는 대규모 시스템의 유지보수성을 한 차원 높여주는 핵심 기술입니다.
9.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 : others
와 Ada.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
구문을 통해 어떤 예외든 가로채서 그 내용을 분석하고 기록할 수 있는 능력은, 디버깅이 용이하고 유지보수성이 높은 대규모의 견고한 시스템을 구축하는 데 필수적인 기술입니다.
9.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_B
와Action_A
를 거쳐 메인 프로시저까지 전파된 전체 경로를 보여주어, 오류의 근원을 추적하는 데 결정적인 단서를 제공합니다.
Ada.Exceptions
패키지의 정보 획득 함수들은 추상적인 오류 이벤트를 분석 가능한 구체적인 데이터로 바꾸어주는 강력한 도구입니다. 특히 Exception_Information
을 활용하여 상세한 로그를 남기는 것은 복잡한 시스템의 오류를 진단하고 수정하는 데 있어 필수적인 기법입니다.
9.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 := "");
이 프로시저는 두 개의 매개변수를 받습니다.
E
: 발생시킬 예외의 고유 식별자(Exception_Id
)입니다. 특정 예외의Exception_Id
는'Identity
(어포스트로피-Identity) 속성을 통해 얻을 수 있습니다. (예:My_Error'Identity
)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
구문을 사용하여 동적인 컨텍스트를 제공하면, 훨씬 더 유용한 로그를 생성하고, 사용자에게 친절한 오류 메시지를 보여주며, 디버깅 과정을 크게 단축할 수 있습니다.
9.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;
를 통해 예외를 숨기지 않고 호출자에게 전파하는 것입니다. 이를 통해 재사용 가능하면서도 견고한 제네릭 컴포넌트를 만들 수 있습니다.
9.6 모범 사례 및 설계 패턴
지금까지 우리는 Ada 예외 처리의 ‘어떻게(how)’에 해당하는 문법과 메커니즘을 모두 학습했습니다. 이제는 ‘어떻게 잘(how well)’ 사용할 것인가에 대한 지혜, 즉 설계의 영역으로 넘어갈 차례입니다.
예외 처리는 양날의 검과 같습니다. 올바르게 사용하면 프로그램의 견고함을 극적으로 향상시키지만, 남용하거나 잘못된 방식으로 사용하면 오히려 제어 흐름을 이해하기 어렵게 만들고 유지보수를 악몽으로 만들 수 있습니다.
이번 9.6절에서는 단순히 예외를 사용하는 것을 넘어, 예외를 ‘잘’ 사용하기 위한 핵심적인 모범 사례와 검증된 설계 패턴들을 탐구합니다. 다음의 주제들을 다룰 것입니다.
- 예외를 일반적인 제어 흐름 대신 오류 처리에만 사용하는 기준
- 관련 오류들을 묶어주는 체계적인 예외 계층 구조 설계
- 예외 발생 시에도 자원 누수를 막는
Ada.Finalization
활용법 - 예외 안전성(Exception Safety)의 개념과 이를 보장하는 코드 작성 기법
이 절의 목표는 새로운 문법을 배우는 것이 아니라, 예외 처리에 대한 올바른 ‘설계적 관점’과 ‘판단력’을 기르는 것입니다. 이 원칙들을 이해하고 나면, 여러분은 예외 처리 기능을 효과적으로 활용하여 전문가 수준의 신뢰성과 유지보수성을 갖춘 소프트웨어를 만들 수 있게 될 것입니다.
9.6.1 예외 사용 시점과 지양 시점
예외는 강력한 도구이지만, 그 힘은 올바른 목적으로 사용될 때 발휘됩니다. 가장 중요한 원칙은 이것입니다: “예외는 이름 그대로, 진정으로 예외적인(exceptional) 상황에만 사용해야 한다.”
‘예외적인 상황’이란, 해당 서브프로그램이 자신의 임무나 계약(contract)을 더 이상 완수할 수 없게 만드는 심각한 오류 조건을 의미합니다. 즉, 정상적인 결과 중 하나가 아니라 명백한 실패(failure)를 나타냅니다.
예외를 사용해야 하는 경우
다음과 같은 상황은 예외를 사용하기에 적합합니다.
-
계약 위반 (Contract Violations): 서브프로그램이 명세된 작업을 수행할 수 없을 때.
- 비어 있는 스택에서
Pop
연산을 시도할 때 (Stack_Empty_Error
). - 음수에 대해 제곱근(
sqrt
) 계산을 요청받았을 때 (Argument_Error
). - 서브프로그램의 전제조건(precondition)이 만족되지 않았을 때.
- 비어 있는 스택에서
-
외부 환경의 실패 (External Failures): 프로그램이 제어할 수 없는 외부 요소가 실패했을 때.
- 필요한 파일을 찾을 수 없을 때 (
Name_Error
). - 네트워크 연결이 유실되었을 때 (
Connection_Lost
). - 하드웨어 장치가 응답하지 않을 때 (
Device_Error
).
- 필요한 파일을 찾을 수 없을 때 (
-
자원 고갈 (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
매개변수 등 일반적인 제어 구조를 사용해야 합니다.
이 원칙을 지키는 것은 진짜 ‘예외적인’ 상황과 ‘정상적인’ 로직을 분리하여, 코드를 더 깨끗하고, 효율적이며, 유지보수하기 쉽게 만드는 핵심입니다.
9.6.2 예외 계층 구조 설계
애플리케이션이 복잡해지면 수십 개의 서로 다른 예외가 생겨날 수 있습니다. 이때 모든 예외를 평평한(flat) 구조로 관리하면, 예외 핸들러가 매우 길어지고 유지보수가 어려워집니다. 예를 들어, 모든 종류의 ‘입출력 오류’에 대해 동일한 복구 절차를 수행하고 싶을 때, 모든 I/O 관련 예외를 when
절에 |
로 묶어 나열하는 것은 비효율적입니다.
이러한 문제를 해결하기 위해, 관련된 예외들을 계층 구조(hierarchy)로 설계하는 패턴을 사용합니다. 🏛️
Ada의 예외 계층 구현: renames
다른 객체 지향 언어와 달리 Ada의 예외는 타입 상속을 지원하지 않습니다. 대신, renames
절을 사용하여 매우 효과적인 예외 계층을 구성할 수 있습니다.
- 특정 서브시스템이나 오류의 범주를 대표하는 루트(root) 예외를 하나 선언합니다.
- 더 구체적인 하위 예외들을 선언할 때, 이 루트 예외를
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;
예외 계층의 장점
- 계층적 처리: 호출자는 자신이 처리하고 싶은 예외는 구체적으로, 나머지는 상위 계층에서 포괄적으로 처리할 수 있는 유연성을 갖게 됩니다.
- 유지보수성 및 확장성: 나중에
My_IO
패키지에Disk_Full_Error : exception renames IO_Error;
와 같은 새로운 예외를 추가하더라도, 기존에when My_IO.IO_Error =>
핸들러를 가지고 있던 클라이언트 코드는 아무런 수정 없이도 새로운Disk_Full_Error
를 처리할 수 있습니다. - 가독성: 오류들 간의 관계를 명확히 표현하여, 시스템의 오류 모델을 이해하기 쉽게 만듭니다.
잘 설계된 예외 계층은 대규모 시스템의 복잡성을 관리하는 핵심적인 설계 패턴입니다. 이는 오류 처리 로직을 유연하고 확장 가능하게 만들어, 시간이 지나도 소프트웨어를 건강하게 유지하는 데 크게 기여합니다. 여러분의 모듈을 설계할 때, 발생 가능한 오류들을 “가족” 단위로 묶어 계층화하는 습관을 들이는 것이 좋습니다.
9.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)할 수 있습니다.
Initialize (Object : in out My_Type)
: 해당 타입의 객체가 생성된 직후 자동으로 호출됩니다.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 런타임이 Guard
의 Finalize
프로시저를 먼저 호출해 줍니다. 따라서 잠금은 100% 해제가 보장됩니다. 더 이상 핸들러마다 Release_Lock
을 써줄 필요가 없습니다.
Ada.Finalization.Controlled
를 활용한 RAII 패턴은 예외 안전성을 보장하는 가장 중요한 기법입니다. 이는 자원 관리를 자동화하고 인간의 실수를 원천적으로 방지하여, 복잡한 시스템에서도 자원 누수 없이 견고한 코드를 작성할 수 있도록 돕습니다.
9.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)” 기법이 대표적입니다.
- 수정하려는 객체의 임시 복사본을 만듭니다.
- 모든 연산을 임시 복사본에 수행합니다. 이 과정에서 예외가 발생할 수 있습니다.
- 모든 연산이 성공적으로 끝나면, 그때서야 원본 객체와 임시 복사본을 맞바꿉니다(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 코드를 작성하기 위한 필수적인 역량입니다.
9.7 실제 적용 시나리오 및 사례 연구
지금까지 우리는 예외의 기본 문법부터 시작하여 예외 전파 규칙, 고급 관리 기법, 그리고 모범 설계 패턴에 이르기까지 Ada의 예외 처리 메커니즘을 다각도로 학습했습니다. 이론적 지식을 실제 문제 해결 능력으로 전환하는 가장 좋은 방법은 구체적인 사례를 통해 그 쓰임새를 직접 확인하는 것입니다.
이번 9.7절에서는 서로 다른 특성을 가진 세 가지 시나리오를 통해, 지금까지 배운 예외 처리 기법이 어떻게 적용되어 시스템의 견고함(robustness)과 신뢰성(reliability)을 높이는지 살펴볼 것입니다.
- 파일 입출력: 모든 프로그래머가 마주하는 가장 흔한 시나리오로, 외부 환경의 실패에 우아하게 대처하는 방법을 배웁니다.
- 임베디드 시스템: 자원이 제한되고 안전이 최우선인 환경에서, 오류 복구와 시스템 상태를 제어하는 기법을 다룹니다.
- 내결함성 네트워크 서비스: 여러 클라이언트가 동시에 접속하는 환경에서, 일부의 장애가 전체 시스템에 영향을 주지 않도록 오류를 격리하는 방법을 연구합니다.
이러한 사례 연구들을 통해, 여러분은 추상적인 예외 처리 원칙이 어떻게 다양한 응용 분야에서 신뢰성 높은 소프트웨어를 구축하는 실질적인 설계 패턴으로 구현되는지 명확하게 이해하게 될 것입니다.
9.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;
분석 및 결론
위 코드에는 두 가지 수준의 예외 처리가 적용되었습니다.
-
세밀한 제어 (Fine-grained Control):
while
루프 안의declare-begin-exception
블록은 파일의 내용 한 줄에 대한 오류(Data_Error
)를 처리합니다. 문제가 있는 줄은 경고 메시지를 출력하고 건너뛸 뿐, 전체 파일 읽기 과정을 중단시키지 않습니다. -
전역적 제어 (Global Control): 프로시저의 메인
exception
블록은 파일 열기(Open
)와 같이 작업 전체에 영향을 미치는 치명적인 오류를 처리합니다.Name_Error
의 경우 기본값으로 프로그램을 계속 실행하는 복구 전략을 선택했고,Use_Error
와 같이 더 심각한 문제는 프로그램을 안전하게 중단하도록 처리했습니다.
이처럼 파일 I/O 로직을 begin-end
블록으로 감싸고 발생 가능한 예외들을 종류별로 처리함으로써, 우리는 다양한 실패 시나리오에 유연하게 대응하는 신뢰성 높은 소프트웨어를 만들 수 있습니다.
9.7.2 임베디드 시스템에서의 오류 처리
임베디드 시스템은 데스크톱이나 서버 환경과는 근본적으로 다른 제약 조건과 요구사항을 가집니다. 메모리와 처리 능력이 제한적이고, 사람의 개입 없이 장시간 독립적으로 동작해야 하며, 때로는 시스템의 작은 오작동이 물리적인 손상이나 안전 문제로 직결될 수 있습니다. ☢️
따라서 임베디드 시스템에서의 오류 처리는 단순히 프로그램을 종료하는 것이 아니라, 어떻게든 시스템을 안전한 상태(safe state)로 전환하고, 가능하다면 스스로 복구(recover)하여 임무를 계속 수행하는 데 초점을 맞춥니다.
핵심 전략: 예측 가능성과 상태 관리
임베디드 시스템의 예외 처리는 예측 불가능한 상황을 예측 가능한 상태로 바꾸는 과정입니다.
패턴 1: 로그, 안전 상태 진입, 그리고 리셋
가장 보편적이고 강력한 패턴 중 하나입니다. 시스템의 최상위 레벨에서 예외를 처리하여, 예상치 못한 모든 오류에 대한 최종 방어선을 구축합니다.
- 로그(Log): 오류가 발생하면, 원인 분석을 위해 예외 정보(발생 위치, 종류 등)를 플래시 메모리 같은 비휘발성 저장소에 기록합니다.
- 안전 상태 진입(Enter Safe Mode): 모터를 끄거나, 밸브를 잠그는 등 시스템이 물리적으로 위험하지 않은 상태로 즉시 전환합니다.
- 리셋(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의 구조적 예외 처리는 이러한 예측 가능하고 신뢰성 높은 복구 로직을 구현하는 데 매우 적합한, 강력하고 체계적인 방법을 제공합니다.
9.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의 구조적 예외 처리를 활용하면, 각 작업 단위의 실패를 국지적으로 격리하고 처리하여 시스템 전체의 안정성을 보장하는 내결함성 설계를 명확하고 안정적으로 구현할 수 있습니다.
10. 접근 타입과 메모리 관리
(도입부)
10.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
값에 대해 배울 것입니다. 이를 통해 여러분은 정적인 데이터를 넘어 동적으로 살아 움직이는 프로그램을 만들 수 있는 강력한 도구를 얻게 될 것입니다.
10.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 접근 타입의 설계는 잠재적인 프로그래밍 오류를 컴파일러와 런타임 시스템이 체계적으로 감지하고 방지하도록 하여, 소프트웨어의 견고성과 예측 가능성을 높이는 것을 목표로 합니다.
10.1.2 접근 타입 선언: access all
vs. access
Ada에서 접근 타입은 지정(designate)할 수 있는 객체의 생명주기와 메모리 영역(storage pool)에 따라 두 가지 형태로 선언할 수 있습니다. 바로 범용 접근 타입(general access type)과 풀-특정 접근 타입(pool-specific access type)입니다. 이 둘은 all
키워드의 유무로 구분되며, 현대 Ada 프로그래밍에서는 access all
을 사용하는 범용 접근 타입이 표준적인 방식으로 간주됩니다.
범용 접근 타입 (General Access Types: access all
)
범용 접근 타입은 access all
키워드를 사용하여 선언하며, 가장 유연하고 보편적으로 사용되는 형태입니다. 이 타입의 변수는 동적으로 할당된 객체(힙 메모리)뿐만 아니라, 스택에 선언된 지역 변수나 라이브러리 레벨에 선언된 전역 변수까지 지정할 수 있습니다.
선언 구문:
type Name is access all Designated_Type;
all
키워드는 접근 타입이 특정 메모리 풀에 종속되지 않고 모든 종류의 메모리 영역에 있는 객체를 가리킬 수 있음을 의미합니다. 이러한 유연성은 Ada의 엄격한 접근성 검사(accessibility checks) 규칙과 결합되어, 허상 포인터(dangling pointer) 문제없이 안전하게 다양한 객체를 참조하는 것을 가능하게 합니다.
사용 예시:
procedure Main is
type Integer_Access is access all Integer; -- 범용 접근 타입 선언
-- 스택에 선언된 지역 변수
An_Integer : aliased Integer := 100;
-- 범용 접근 타입 변수는 스택 변수를 지정할 수 있음
Pointer_A : Integer_Access := An_Integer'access;
begin
null;
end Main;
위 예제에서 Integer_Access
타입의 변수 Pointer_A
는 스택에 선언된 변수 An_Integer
를 성공적으로 지정합니다. 객체 이름 뒤에 'access
속성을 붙여 해당 객체에 대한 접근 값을 얻을 수 있습니다.
풀-특정 접근 타입 (Pool-Specific Access Types: access
)
풀-특정 접근 타입은 all
키워드 없이 access
만을 사용하여 선언합니다. 이 타입의 변수는 오직 new
연산자를 통해 특정 저장소 풀(storage pool)에서 동적으로 할당된 객체만을 지정할 수 있습니다. 일반적으로 이는 기본 힙(heap) 메모리를 의미합니다.
선언 구문:
type Name is access Designated_Type;
가장 중요한 제약 사항은 이 타입의 변수가 스택에 선언된 객체(aliased
키워드가 붙었더라도)를 가리킬 수 없다는 것입니다. 만약 이를 시도하면 컴파일러는 오류를 보고합니다.
잘못된 사용 예시:
procedure Main is
type Integer_Pool_Access is access Integer; -- 풀-특정 접근 타입 선언
An_Integer : aliased Integer := 200;
-- 컴파일 오류 발생: 풀-특정 접근 타입은 스택 변수를 지정할 수 없음
Pointer_B : Integer_Pool_Access := An_Integer'access; -- Legal_Main.adb:7:40: error: prefix of "Access" attribute cannot be a non-library-level, aliased object
begin
null;
end Main;
이러한 제한은 과거 버전의 Ada와의 호환성을 위해 유지되고 있으나, access all
이 도입된 Ada 95 이후로는 사용 빈도가 크게 줄었습니다.
요약 및 권장 사항
두 접근 타입의 차이점은 다음과 같이 요약할 수 있습니다.
구분 | 범용 접근 타입 (access all ) |
풀-특정 접근 타입 (access ) |
---|---|---|
선언 구문 | type T is access all D; |
type T is access D; |
지정 가능 객체 | 동적 할당 객체, 스택 객체, 전역 객체 등 모든 객체 | 오직 동적으로 할당된 객체 |
주요 용도 | 일반적인 모든 포인터 연산, 동적 및 정적 객체 참조 | 동적 할당 객체만 다루는 제한적인 경우, 레거시 코드 |
권장 사항 | 항상 사용을 권장 | 특별한 이유가 없는 한 사용을 지양 |
결론적으로, 현대 Ada 프로그래밍에서는 명확성, 일관성 및 유연성을 확보하기 위해 access all
을 사용한 범용 접근 타입을 선언하는 것이 유리합니다.
10.1.3 역참조 및 객체 접근 (Dereferencing and Object Access)
접근 타입 변수에 객체의 유효한 참조가 할당되면, 이 변수를 통해 지정된(designated) 객체의 값에 접근하거나 수정할 수 있습니다. Ada는 이를 위해 명시적 역참조와 암시적 역참조(구성 요소 접근) 두 가지 메커니즘을 제공합니다.
명시적 역참조: .all
역참조(Dereferencing)는 접근 타입 변수가 가리키는 메모리 위치에서 실제 객체 자체를 가져오는 연산입니다. Ada에서는 .all
접미사를 사용하여 이를 명시적으로 수행합니다. Access_Variable.all
은 접근 변수가 지정하는 객체 전체를 의미하며, 해당 객체 타입의 변수처럼 동작합니다.
구문:
Access_Variable.all
.all
을 통해 얻은 객체는 값을 읽거나 새로운 값으로 전체를 덮어쓰는 데 사용될 수 있습니다.
사용 예시:
with Ada.Text_IO;
procedure Main is
type Person is record
Id : Integer;
Name : String (1 .. 10);
end record;
type Person_Access is access all Person;
-- 스택에 선언된 객체들
Person_1 : aliased Person := (Id => 101, Name => "Clair ");
Person_2 : Person;
-- Person_1 객체를 가리키는 접근 변수
Ptr : Person_Access := Person_1'access;
begin
-- 역참조: Ptr.all은 Person_1 객체 전체를 의미한다.
Person_2 := Ptr.all;
Ada.Text_IO.put_line ("Person_2's Name: " & Person_2.Name); -- "Clair " 출력
-- 역참조를 통해 원본 객체의 값을 변경할 수도 있다.
Ptr.all := (Id => 202, Name => "Ada ");
Ada.Text_IO.put_line ("Person_1's Name: " & Person_1.Name); -- "Ada " 출력
end Main;
위 예제에서 Ptr.all
은 Person_1
객체 그 자체와 동일하게 취급됩니다. 따라서 Person_2 := Ptr.all;
구문은 Person_1
의 내용을 Person_2
로 복사합니다.
구성 요소 접근 (Implicit Dereferencing)
지정된 객체가 레코드와 같은 복합 타입일 경우, 전체 객체가 아닌 특정 구성 요소(필드)에만 접근해야 할 때가 많습니다. 이때는 점(.
) 표기법을 사용하여 객체의 구성 요소에 직접 접근할 수 있습니다.
구문:
Access_Variable.Component_Name
이 구문은 (Access_Variable.all).Component_Name
의 축약형입니다. Ada 컴파일러는 점 표기법을 만나면 접근 변수를 암시적으로 역참조한 후 해당 구성 요소에 접근합니다. 이는 (Access_Variable.all).Component_Name
구문보다 간결한 코드 작성을 위해 언어 차원에서 제공되는 기능입니다.
사용 예시:
with Ada.Text_IO;
procedure Main is
type Person is record
Id : Integer;
Name : String (1 .. 10);
end record;
type Person_Access is access all Person;
Person_Obj : aliased Person := (Id => 101, Name => "Clair ");
Ptr : Person_Access := Person_Obj'access;
begin
-- 점 표기법을 사용해 객체의 구성 요소 읽기
Ada.Text_IO.put_line ("ID: " & Integer'Image (Ptr.Id)); -- Ptr.Id는 (Ptr.all).Id 와 같다.
-- 점 표기법을 사용해 객체의 구성 요소 수정
Ptr.Name := "Lovelace ";
Ada.Text_IO.put_line ("New Name: " & Ptr.Name);
Ada.Text_IO.put_line ("Original's Name: " & Person_Obj.Name); -- "Lovelace " 출력
end Main;
이처럼 점(.
) 표기법을 사용하면 접근 변수를 마치 실제 레코드 변수처럼 직관적으로 다룰 수 있습니다.
.all
은 접근 변수가 지정하는 객체 전체를 하나의 단위로 다루고자 할 때 사용하는 명시적 역참조 연산자입니다..
(점 표기법)은 지정된 객체의 특정 구성 요소에 접근할 때 사용합니다. 이 경우 역참조는 암시적으로 일어나며, 이는 더 간결하고 명확한 코드 작성을 지원하기 위해 언어 차원에서 제공되는 구문입니다.
10.1.4 null
접근 값과 초기화 (null
Access Value and Initialization)
모든 접근 타입에는 null
이라는 특별한 리터럴 값이 존재합니다. null
은 해당 접근 타입의 변수가 어떠한 객체도 지정하고 있지 않음(not pointing to any object)을 나타내는 명시적인 상태입니다. 이는 초기화되지 않은 불확실한 상태와는 구별되는, 명확하게 정의된 상태입니다.
null
의 사용: 비교와 할당
null
값의 주된 용도는 접근 변수가 유효한 객체를 가리키고 있는지 확인하는 것입니다. 등호(=
) 또는 부등호(/=
) 연산자를 사용하여 접근 변수의 값을 null
과 비교할 수 있습니다. 이를 통해 null
값을 역참조하려는 시도를 사전에 방지할 수 있습니다.
또한, 할당문(:=
)을 통해 접근 변수에 null
을 대입하여 기존의 참조를 제거하고 ‘아무것도 가리키지 않는’ 상태로 만들 수 있습니다.
사용 예시:
procedure Main is
type Integer_Access is access all Integer;
An_Integer : aliased Integer := 100;
Pointer : Integer_Access := An_Integer'access;
begin
-- 포인터가 유효한지 확인
if Pointer /= null then
Pointer.all := 200;
end if;
-- 포인터의 참조를 제거
Pointer := null;
-- 이제 포인터는 null 상태임
if Pointer = null then
-- 이 블록이 실행됨
null;
end if;
end Main;
접근 타입 변수의 초기화
Ada는 변수가 초기화되기 전에 사용되는 것을 방지하기 위해 다양한 메커니즘을 제공하며, 접근 타입도 예외는 아닙니다. 선언 시 명시적으로 초기화되지 않은 접근 변수는 유효하지 않은 값을 가질 수 있으며, 이는 예측 불가능한 동작으로 이어질 수 있습니다.
따라서 접근 타입 변수를 선언할 때는 반드시 특정 객체의 접근 값 또는 null
로 초기화하는 것이 바람직합니다. 변수를 null
로 초기화하면, 프로그램이 해당 변수를 사용하기 전에 null
여부를 검사하는 일관된 패턴을 적용할 수 있어 안정성이 향상됩니다.
초기화 구문:
-- null 로 초기화
Pointer_A : My_Access_Type := null;
-- 특정 객체의 접근 값으로 초기화
Pointer_B : My_Access_Type := Some_Object'access;
null
역참조와 예외 처리
만약 null
값을 가지는 접근 변수를 역참조하려고 시도하면(null_ptr.all
또는 null_ptr.component
등), C/C++과 같이 미정의 동작(undefined behavior)이 발생하지 않습니다. 대신, Ada 런타임 시스템은 이 상황을 감지하고 명확하게 정의된 예외인 Constraint_Error
를 발생시킵니다.
이러한 동작은 프로그램의 비정상적인 종료를 방지하고, 예외 처리 블록을 통해 오류 상황을 구조적으로 처리하거나 기록하는 것을 가능하게 합니다. 이는 Ada의 신뢰성 높은 소프트웨어 구축 철학을 보여주는 핵심적인 안전 장치입니다.
예외 발생 예시:
with Ada.Text_IO;
procedure Main is
type Integer_Access is access all Integer;
Null_Pointer : Integer_Access := null;
begin
-- 이 지점에서 Constraint_Error 예외가 발생한다.
Null_Pointer.all := 10;
Ada.Text_IO.Put_Line ("This will not be printed.");
exception
when Constraint_Error =>
Ada.Text_IO.Put_Line ("Error: Attempted to dereference a null pointer.");
end Main;
10.2 동적 할당 (new) 및 할당 해제 (Unchecked_Deallocation
)
10.1절에서 접근 타입이 기존에 선언된 객체나 서브프로그램을 가리키는 방법을 살펴보았다면, 이번 절에서는 접근 타입의 주된 용도인 동적 메모리 할당에 대해 다룹니다. 동적 할당은 프로그램 실행 중에 필요한 만큼의 메모리를 ‘힙(heap)’이라는 특별한 메모리 공간에서 할당받아 객체를 생성하는 것을 의미합니다.
10.2.1 동적 객체 생성: new
연산자
Ada에서 동적 할당은 new
연산자를 통해 이루어집니다. new
연산자는 지정된 타입의 객체를 저장할 만큼의 메모리를 저장소 풀(storage pool)에서 할당하고, 새로 생성된 객체를 가리키는 접근 값을 반환합니다.
-
기본 할당:
My_Ptr := new <타입_이름>;
<타입_이름>
의 객체를 생성하고, 해당 타입의 기본값으로 초기화합니다.
-
초기값 지정 할당:
My_Ptr := new <타입_이름>'(<초기값>);
- 객체를 생성함과 동시에 지정된 초기값으로 초기화합니다.
연결 리스트 노드 생성 예제
동적 할당의 가장 대표적인 예는 연결 리스트(linked list)나 트리(tree)와 같은 재귀적인 데이터 구조를 구현하는 것입니다. 연결 리스트의 각 노드(Node)는 데이터와 다음 노드를 가리키는 접근 값을 포함합니다.
-- 'Node' 타입을 완전히 정의하기 전에, 'Node'를 가리킬 접근 타입을 위해
-- 불완전한 타입 선언(incomplete type declaration)이 필요합니다.
type Node;
type Node_Access is access All Node;
-- 이제 'Node' 타입을 완전하게 정의할 수 있습니다.
type Node is record
data : Integer;
next : Node_Access := null; -- 다음 노드를 가리키는 접근 값
end record;
-- 새로운 노드를 동적으로 생성하고 초기화합니다.
List_Head : Node_Access;
New_Node : Node_Access;
-- ...
New_Node := new Node'(data => 42, next => null);
List_Head := New_Node;
10.2.2 명시적 메모리 해제: Ada.Unchecked_Deallocation
new
로 할당된 메모리는 더 이상 필요 없을 때 시스템에 반환하여 다른 용도로 사용될 수 있도록 해야 합니다. 이를 메모리 해제(deallocation)라고 하며, Ada에서는 Ada.Unchecked_Deallocation
이라는 제네릭 프로시저를 통해 명시적으로 수행할 수 있습니다.
정의 및 위험성
이름에 ‘비검사(Unchecked)’가 붙은 이유는 C의 free
함수처럼, 이 프로시저의 안전성을 프로그래머가 전적으로 책임져야 하기 때문입니다.
- 댕글링 포인터: 메모리가 해제된 후에도 여전히 그 주소를 가리키고 있는 접근 값(댕글링 포인터)이 남아있을 수 있습니다. 이 포인터를 사용하면 예측 불가능한 동작이나 시스템 충돌이 발생합니다.
- 이중 해제 (Double Free): 이미 해제된 메모리를 다시 해제하려고 시도하면 런타임 시스템이 오염될 수 있습니다.
인스턴스화 및 사용법
Unchecked_Deallocation
은 제네릭이므로, 사용하기 전에 해제할 객체의 타입(Object
)과 해당 객체를 가리키는 접근 타입(Name
)을 지정하여 인스턴스화해야 합니다.
-
인스턴스화:
procedure Free is new Ada.Unchecked_Deallocation (Object => Node, Name => Node_Access);
-
호출:
Old_Node : Node_Access := List_Head; List_Head := List_Head.all.next; -- 리스트에서 노드 분리 Free (Old_Node); -- Old_Node가 가리키던 메모리 해제
안전 수칙: 메모리를 해제한 후에는 해당 접근 타입 변수에 null
을 할당하여, 의도치 않게 댕글링 포인터를 사용하는 것을 방지하는 것이 매우 중요합니다.
Free (Old_Node);
Old_Node := null; -- 이제 이 포인터는 안전하게 '아무것도 가리키지 않음'
10.2.3 가비지 컬렉션과의 관계
일부 Ada 구현(컴파일러 및 런타임 시스템)은 더 이상 어떤 접근 변수도 가리키지 않는 동적 객체를 자동으로 탐지하여 메모리를 회수하는 가비지 컬렉터(Garbage Collector)를 제공할 수 있습니다. 만약 가비지 컬렉터가 활성화된 환경이라면, Unchecked_Deallocation
을 사용해서는 안 됩니다. 수동 해제와 자동 수집이 혼용될 경우 시스템에 심각한 오류를 유발할 수 있습니다.
Unchecked_Deallocation
은 메모리 해제 시점을 프로그래머가 직접 제어해야 하는 실시간 시스템이나, 가비지 컬렉터의 비결정적인 지연을 허용할 수 없는 고신뢰성 시스템에서 주로 사용됩니다.
new
와 Ada.Unchecked_Deallocation
은 Ada에서 동적 메모리를 직접 제어하는 기본 수단을 제공합니다. new
는 타입-안전한 방식으로 객체를 생성하며, Unchecked_Deallocation
은 프로그래머에게 메모리를 명시적으로 반환할 책임을 부여합니다. ‘Unchecked’라는 이름이 암시하듯, 메모리 해제는 신중한 설계와 주의 깊은 구현을 요구하는 작업이며, 댕글링 포인터와 같은 고전적인 메모리 관리 오류를 피하기 위한 노력이 반드시 필요합니다.
10.3 접근 타입의 안전성: 접근성 검사 (Accessibility Checks)
C/C++과 같은 언어에서 가장 잡기 어렵고 치명적인 버그 중 하나는 댕글링 포인터(Dangling Pointer), 즉 이미 해제된 메모리를 가리키는 포인터를 사용하는 것입니다. 이러한 포인터를 역참조하면 예측 불가능한 값이나 메모리 오염을 유발하여 프로그램 전체를 불안정하게 만듭니다.
Ada는 이러한 위험을 방지하기 위해, 컴파일 시점에 포인터의 유효성을 정적으로 검사하는 접근성 검사(Accessibility Checks)라는 독자적이고 강력한 안전장치를 갖추고 있습니다.
10.3.1 댕글링 포인터의 발생 원인
댕글링 포인터는 일반적으로 포인터 변수(P
)가 자신이 가리키는 객체(X
)보다 더 오래 살아남을 때 발생합니다.
- 바깥쪽 스코프에 포인터
P
가 선언됩니다. - 안쪽 스코프에 객체
X
가 선언됩니다. P
에X
의 주소가 할당됩니다. (P := X'access;
)- 안쪽 스코프가 종료되면서
X
는 메모리에서 제거(소멸)됩니다. P
는 여전히 존재하지만, 이제는 유효하지 않은 메모리 주소를 가리키는 ‘댕글링 포인터’가 됩니다.
10.3.2 접근성 규칙 (The Accessibility Rule)
Ada는 이러한 상황을 원천적으로 차단하기 위해, 모든 객체와 접근 타입에 대해 생명주기(lifetime) 또는 접근성 수준(accessibility level) 이라는 개념을 도입합니다. 객체의 생명주기는 해당 객체가 선언된 스코프와 연관됩니다. 컴파일러는 이 생명주기를 기반으로 다음과 같은 핵심 규칙을 강제합니다.
접근성 규칙: “어떤 접근 타입의 생명주기가 자신이 가리키게 될 객체의 생명주기보다 길어서는 안 된다.”
간단히 말해, 수명이 긴 포인터가 수명이 짧은 객체를 가리키도록 허용하지 않습니다. 이 검사는 프로그램 실행 중이 아닌 컴파일 시점에 수행되므로, 댕글링 포인터가 발생할 수 있는 코드는 아예 컴파일조차 되지 않습니다.
10.3.3 예제를 통한 이해
-
[부적합한 경우] 수명이 짧은 지역 변수의 주소를 반환
다음은 함수 내의 지역 변수 주소를 함수 밖으로 반환하려는 시도입니다. 이는 전형적인 댕글링 포인터 생성 코드입니다.
procedure Test_Dangling_Pointer is type Integer_Access is access all Integer; Global_Ptr : Integer_Access := null; function Get_Invalid_Address return Integer_Access is Local_Var : aliased Integer := 10; begin -- Local_Var는 이 함수가 끝나면 소멸된다. -- 따라서 함수 밖에서도 살아있는 포인터가 이 주소를 가리키면 안 된다. return Local_Var'access; -- 컴파일 오류 발생! end Get_Invalid_Address; begin Global_Ptr := Get_Invalid_Address; -- 이 코드는 컴파일되지 않음 end Test_Dangling_Pointer;
Ada 컴파일러는
Get_Invalid_Address
함수가 반환하는 접근 타입의 생명주기(Integer_Access
)가 함수 내 지역 변수인Local_Var
의 생명주기보다 길다는 것을 인지합니다. 접근성 규칙에 따라, 이는 잠재적인 댕글링 포인터를 생성하므로 컴파일을 거부합니다. -
[적합한 경우] 수명이 긴 전역 변수를 가리킴
반대로, 수명이 짧은 포인터가 수명이 긴 객체를 가리키는 것은 안전하며 허용됩니다.
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;
clair
라이브러리에서 시그널 핸들러 프로시저의 주소를 얻는custom_sigint_handler'access
와 같은 코드 역시 안전합니다.custom_sigint_handler
는 라이브러리 레벨에 선언되어 프로그램 전체의 생명주기를 가지므로, 어떠한 포인터보다도 생명주기가 길거나 같기 때문입니다.
10.3.4 Unchecked_Deallocation
과의 관계
매우 중요한 점은, 접근성 검사가 스코프 규칙에 의해 발생하는 댕글링 포인터만 방지한다는 것입니다. 프로그래머가 Ada.Unchecked_Deallocation
을 사용하여 수동으로 메모리를 해제하는 경우, 이로 인해 발생하는 댕글링 포인터는 컴파일러가 막아주지 못합니다. 명시적 메모리 해제에 대한 책임은 여전히 프로그래머에게 있습니다.
접근성 검사는 Ada의 안전 철학을 보여주는 독보적인 기능입니다. 컴파일러가 객체와 포인터의 생명주기를 정적으로 분석하고 위반 사항을 강제함으로써, 디버깅하기 가장 어려운 종류의 메모리 오류를 프로그램 실행 전에 근절합니다. 포인터 산술 금지, 강력한 타입 검사와 더불어 접근성 검사는 Ada의 접근 타입을 전통적인 포인터보다 근본적으로 안전하게 만들며, 고신뢰성 시스템을 구축하는 데 크게 기여합니다.
10.3.5 'Unchecked_Access
속성: 접근성 검사 우회
지금까지 살펴본 접근성 검사는 Ada가 컴파일 시점에 댕글링 포인터를 방지하는 강력하고 핵심적인 안전장치입니다. 'access
속성은 이 규칙을 철저히 따릅니다. 하지만 극히 예외적인 저수준 프로그래밍 상황에서는, 프로그래머가 컴파일러보다 객체의 생명주기를 더 잘 알고 있음을 확신하고 이 안전장치를 의도적으로 우회해야 할 필요가 있습니다.
이를 위해 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
를 사용하는 순간, 댕글링 포터를 방지할 책임은 전적으로 컴파일러에서 프로그래머에게로 이전됩니다. 만약 프로그래머의 보증이 잘못되었다면, 그 결과 생성된 댕글링 포인터는 예측 불가능한 런타임 오류나 메모리 오염을 유발할 것입니다.
불가피한 사용 사례
이처럼 위험함에도 불구하고 'Unchecked_Access
가 반드시 필요한 경우가 있습니다.
- 저수준 인터럽트 핸들러: 인터럽트 핸들러는 프로그램의 어떤 위치에서든 비동기적으로 호출될 수 있습니다. 이때 핸들러는 자신보다 더 짧은 생명주기를 가진 스코프에 선언된 데이터에 접근해야 할 수 있습니다.
'Unchecked_Access
는 이러한 데이터에 대한 참조를 핸들러에 전달하는 유일한 방법일 수 있으며, 이 경우 프로그래머는 해당 데이터의 스코프가 끝나기 전에 인터럽트가 비활성화됨을 보장해야 합니다. - 커스텀 메모리 관리자: 프로그램의 메모리 관리를 직접 제어하는 경우, 객체의 실제 생명주기가 Ada의 스코프 규칙과 일치하지 않을 수 있습니다. 이때는 컴파일러의 정적 분석을 우회하여 프로그래머가 직접 관리하는 생명주기를 따르도록 해야 합니다.
'Unchecked_Access
는 Ada의 핵심 안전장치인 접근성 검사를 의도적으로 우회하는 기능으로, 언어의 정적 안전 모델에서 벗어나는 저수준 프로그래밍을 위해 제공됩니다. 이 속성의 사용은 프로그램의 안전 보장 책임을 컴파일러에서 프로그래머에게로 이전시키므로, 일반적인 애플리케이션 프로그래밍에서의 사용은 엄격히 제한되어야 합니다. 따라서 코드 내에 이 속성이 나타나는 것은 코드 리뷰 시 특별한 검토가 필요함을 의미하며, 그 사용의 정당성은 명확한 기술적 근거를 통해 입증되어야 합니다.
10.4 Ada.Containers 라이브러리 활용
이전 절들에서 접근 타입을 사용하여 동적 메모리를 직접 할당(new
)하고 해제(Unchecked_Deallocation
)하는 저수준 기법을 살펴보았습니다. 이러한 수동 메모리 관리는 유연성을 제공하지만, 프로그래머에게 댕글링 포인터나 메모리 누수와 같은 심각한 오류에 대한 모든 책임을 부여합니다.
현대적인 Ada 프로그래밍에서는 이러한 위험을 피하고 생산성을 높이기 위해, 잘 검증되고 사용하기 쉬운 표준 라이브러리인 Ada.Containers
의 사용이 적극적으로 권장됩니다. 이 라이브러리는 동적 데이터 관리를 위한 포괄적인 자료구조(Data Structures) 집합을 제공합니다.
10.4.1 Ada.Containers
개요
Ada.Containers
는 가장 흔하게 사용되는 자료구조들을 구현한 제네릭 패키지들의 모음입니다. 이 컨테이너들은 내부적으로 메모리 관리를 자동으로 수행하므로, 프로그래머는 저수준의 포인터 조작이나 명시적 메모리 해제에 대해 신경 쓸 필요가 없습니다.
주요 컨테이너 패키지는 다음과 같습니다.
Ada.Containers.Vectors
: 크기가 동적으로 변하는 배열(동적 배열)입니다. C++의std::vector
와 유사하며, 인덱스를 통한 빠른 임의 접근을 지원합니다.Ada.Containers.Doubly_Linked_Lists
: 양방향 연결 리스트입니다. 리스트의 중간에 요소를 삽입하거나 삭제하는 작업이 매우 효율적입니다.Ada.Containers.Hashed_Maps
: 키(Key)-값(Value) 쌍을 저장하는 해시 맵(해시 테이블)입니다. 키를 통해 값을 매우 빠르게 조회할 수 있습니다.Ada.Containers.Ordered_Sets
: 정렬된 상태로 요소들을 저장하는 집합입니다. 중복된 요소를 허용하지 않습니다.
10.4.2 사용 패턴: 벡터(Vector) 예제
Ada.Containers
의 사용법은 일반적으로 세 단계의 패턴을 따릅니다.
-
패키지 인스턴스화: 사용하고자 하는 컨테이너 제네릭 패키지를 저장할 요소의 타입에 맞게 인스턴스화합니다.
with Ada.Containers.Vectors; ... -- 정수(Integer)를 요소로 갖는 벡터 패키지를 인스턴스화합니다. package Integer_Vectors is new Ada.Containers.Vectors (Index_Type => Positive, Element_Type => Integer);
-
컨테이너 객체 선언: 인스턴스화된 패키지 내부의 컨테이너 타입(예:
Vector
)으로 변수를 선언합니다.use Integer_Vectors; -- 'Vector' 타입을 직접 사용하기 위해 my_vector : Vector; -- 비어있는 벡터 객체 생성
-
API를 통한 조작: 컨테이너가 제공하는 풍부한 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); -- 반복문을 이용한 순회 for Item of my_vector loop Ada.Text_IO.Put (" " & Item'image); end loop;
위 코드에서
my_vector
의 메모리는append
호출 시 자동으로 늘어나며,my_vector
가 스코프를 벗어날 때 자동으로 해제됩니다. 프로그래머는new
나Free
를 전혀 호출할 필요가 없습니다.
10.4.3 수동 구현과의 비교
10.2절에서 연결 리스트를 수동으로 구현하기 위해 불완전 타입 선언, 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);
이 접근 방식은 단순히 코드가 간결해지는 것을 넘어, 메모리 누수나 댕글링 포인터와 같은 치명적인 버그가 발생할 가능성을 원천적으로 차단하므로 훨씬 더 안전하고 신뢰성이 높습니다.
10.4.4 언제 무엇을 사용해야 하는가?
-
Ada.Containers
: 거의 모든 일반적인 애플리케이션 개발에서 동적 데이터 집합을 다룰 때 우선적으로 사용해야 하는 선택지입니다. 안전하고, 효율적이며, 생산성을 극대화합니다. -
수동 메모리 관리 (
new
/Unchecked_Deallocation
): 다음과 같은 매우 제한적인 상황에서만 고려해야 합니다.- 표준 라이브러리가 제공하지 않는 특수한 자료구조를 직접 구현해야 할 때.
- 런타임 라이브러리가 없는 초소형 임베디드 환경과 같이
Ada.Containers
를 사용할 수 없는 경우. - C 라이브러리와 같이 메모리 소유권 규칙이 명확하게 정해진 외부 시스템과 직접 상호작용해야 할 때.
결론적으로, Ada.Containers
는 Ada의 강력한 타입 시스템과 안전 철학을 동적 자료구조 영역으로 확장한 현대적인 솔루션입니다. 특별한 이유가 없는 한, 동적 데이터 관리는 Ada.Containers
를 활용하는 것이 안전성과 생산성 측면에서 효율적인 접근 방식입니다.
10.5 접근-서브프로그램 타입 (Access-to-Subprogram Types)
지금까지 10장에서 다룬 접근 타입은 모두 데이터 객체(object)를 가리키는 것이었습니다. 하지만 Ada의 접근 타입은 여기서 멈추지 않고, 서브프로그램(프로시저 또는 함수) 자체를 가리키는 능력까지 제공합니다. 이를 접근-서브프로그램 타입(access-to-subprogram type)이라고 하며, 이는 C나 C++의 함수 포인터(function pointer)에 해당하는 Ada의 타입-안전(type-safe)한 기능입니다.
이 기능을 통해 서브프로그램을 변수에 저장하거나, 다른 서브프로그램에 매개변수로 전달하는 등 동적인 방식으로 호출할 대상을 결정할 수 있습니다. 이는 콜백(callback) 메커니즘, 디스패치 테이블(dispatch table), 전략(strategy) 디자인 패턴 등 유연하고 확장 가능한 소프트웨어 아키텍처를 구축하는 데 필수적인 도구입니다.
10.5.1 선언 및 사용법
접근-서브프로그램 타입은 access
키워드 뒤에 대상이 되는 서브프로그램의 완전한 프로파일(profile)을 명시하여 선언합니다.
선언 구문:
-- 프로시저를 가리키는 접근 타입
type <Type_Name> is access procedure (parameter_profile);
-- 함수를 가리키는 접근 타입
type <Type_Name> is access function (parameter_profile) return <Return_Type>;
Ada의 강력한 타입 시스템은 이 프로파일을 엄격하게 검사합니다. 매개변수의 개수, 순서, 타입, 모드(in
, out
, in out
), 그리고 함수의 경우 반환 타입까지 모두 일치하는 서브프로그램만 해당 접근 타입의 변수에 할당될 수 있습니다.
사용법:
- 주소 얻기 (
'access
속성): 객체와 마찬가지로, 서브프로그램의 이름에'access
속성을 붙여 해당 서브프로그램의 주소를 얻습니다. - 호출: 접근 타입 변수를 통해 서브프로그램을 호출할 때는 일반적인 서브프로그램 호출과 동일한 구문을 사용합니다. (
My_Handler (Arg1, Arg2);
)
예시:
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;
procedure Log_To_File (Message : in String) is
begin
-- 파일에 기록하는 로직 (생략)
Ada.Text_IO.Put_Line ("FILE: Logged '" & Message & "'");
end Log_To_File;
-- 현재 사용할 이벤트 핸들러를 저장할 변수
Current_Handler : Event_Handler;
begin
-- 1. Log_To_Console의 주소를 할당
Current_Handler := Log_To_Console'access;
-- 2. 할당된 프로시저를 동적으로 호출
Current_Handler ("Application started."); -- Log_To_Console 호출
-- 1. Log_To_File의 주소를 할당
Current_Handler := Log_To_File'access;
-- 2. 다시 호출 (이번에는 Log_To_File이 호출됨)
Current_Handler ("User logged in.");
end Subprogram_Pointer_Demo;
실행 결과:
CONSOLE: Application started.
FILE: Logged 'User logged in.'
10.5.2 활용 사례: 콜백 메커니즘
접근-서브프로그램 타입의 가장 대표적인 활용 사례는 콜백(callback) 메커니즘입니다. 콜백은 범용적인 기능을 수행하는 서브프로그램이, 특정 이벤트가 발생했을 때 호출해야 할 구체적인 동작을 외부로부터 전달받는 설계 패턴입니다.
다음은 정수 배열에서 특정 조건을 만족하는 모든 원소를 찾아, 발견할 때마다 외부에서 제공한 ‘액션’ 프로시저를 호출해주는 범용 Find_And_Act
프로시저의 예입니다.
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
-- 3. 조건 만족 시, 전달받은 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 ("짝수 찾기:");
-- 1. '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
)”에 대한 구체적인 내용을 전혀 모릅니다. 오직 약속된 프로파일을 가진 서브프로그램을 호출할 뿐입니다. 이처럼 접근-서브프로그램 타입을 사용하면 알고리즘과 정책을 완벽하게 분리하여 코드의 재사용성과 유연성을 극대화할 수 있습니다.
10.5.3 C 함수 포인터와의 비교: 타입 안전성
C 언어의 함수 포인터도 유사한 기능을 제공하지만, Ada의 접근-서브프로그램 타입은 타입 안전성 측면에서 근본적인 우위를 가집니다.
- C 함수 포인터: C에서는 서로 다른 시그니처를 가진 함수 포인터들 사이에 캐스팅(casting)이 가능하며, 이는 프로그래머의 실수로 인해 스택을 오염시키거나 정의되지 않은 동작을 유발하는 심각한 런타임 오류의 원인이 될 수 있습니다.
- Ada 접근-서브프로그램 타입: Ada 컴파일러는 서브프로그램의 프로파일 전체(모든 파라미터의 타입, 모드, 순서 및 반환 타입)가 정확히 일치하는지 컴파일 시점에 엄격하게 검사합니다. 프로파일이 일치하지 않는 서브프로그램의
'access
를 할당하려는 시도는 즉시 컴파일 오류로 처리됩니다.
이러한 정적 검증은 Ada가 동적인 기능을 제공할 때조차 신뢰성을 최우선으로 고려하는 설계 철학을 명확하게 보여줍니다.
11. 제네릭 (generics)
소프트웨어 공학의 핵심 목표 중 하나는 재사용성(Reusability) 입니다. 동일하거나 유사한 로직이 반복적으로 작성되는 것을 방지하고, 한 번 검증된 컴포넌트를 다양한 상황에서 활용할 수 있도록 하는 것은 생산성과 안정성을 모두 높이는 길입니다. Ada에서는 제네릭(generics) 이라는 강력한 기능을 통해 이러한 목표를 달성합니다.
제네릭은 특정 타입에 종속되지 않는 범용적인(generic) 패키지나 서브프로그램의 템플릿(template) 을 정의하는 기능입니다. 이 템플릿을 기반으로, 사용자는 원하는 타입을 지정하여 구체적인 패키지나 서브프로그램 인스턴스(instance) 를 생성할 수 있습니다.
본 장에서는 제네릭 유닛을 정의하고 사용하는 방법과, 이를 통해 재사용 가능한 컴포넌트를 설계하는 원리를 탐구합니다.
11.1 제네릭 유닛의 개념 및 정의
제네릭 유닛(Generic Unit) 은 그 자체로 완성된 패키지나 서브프로그램이 아니라, 실제 컴포넌트를 생성하기 위한 ‘설계도’ 또는 ‘틀’입니다. 이 설계도에는 타입, 값, 또는 서브프로그램과 같은 요소들이 ‘비어 있는’ 상태로 존재하며, 이를 제네릭 형식 매개변수(Generic Formal Parameter) 라고 부릅니다.
제네릭을 사용하는 과정은 두 단계로 나뉩니다.
- 정의(Definition): 재사용 가능한 로직을 담은 제네릭 템플릿을 작성합니다.
- 인스턴스화(Instantiation): 정의된 제네릭 템플릿의 비어 있는 매개변수를 구체적인 값으로 채워, 실제 사용 가능한 패키지나 서브프로그램을 생성합니다.
11.1.1 제네릭의 필요성
만약 정수(Integer
) 값을 저장하는 스택(Stack)이 필요하다고 가정해 봅시다. Integer_Stack
패키지를 작성할 수 있습니다. 이후 실수(Float
) 값을 위한 스택이 필요하다면, Integer_Stack
의 코드를 거의 그대로 복사하여 타입만 Float
으로 변경한 Float_Stack
을 만들어야 할 것입니다. 이는 코드 중복을 유발하고, 원본 로직에 버그가 발견되었을 때 모든 복사본을 일일이 수정해야 하는 유지보수의 어려움을 야기합니다.
제네릭은 이 문제를 해결합니다. 데이터의 타입과 무관하게 동작하는 스택의 로직을 단 한 번만 제네릭 패키지로 정의해두면, Integer
, Float
, 또는 사용자가 정의한 어떠한 타입에 대해서도 스택 인스턴스를 쉽게 생성하여 사용할 수 있습니다.
11.1.2 제네릭 유닛의 정의
제네릭 유닛은 generic
키워드로 시작하는 선언부(generic part)를 가집니다. 이 선언부에는 인스턴스화 시에 채워져야 할 제네릭 형식 매개변수들이 기술됩니다.
다음은 임의의 타입을 저장할 수 있는 스택의 제네릭 패키지 정의 예시입니다.
-- Generic_Stack 패키지 명세
generic
-- 제네릭 형식 매개변수 선언
type Item is private; -- 스택에 저장될 요소의 타입 (비공개 타입)
Initial_Capacity : Positive := 100; -- 스택의 초기 용량 (기본값 100)
package Generic_Stack is
procedure push (value : in Item);
procedure pop (value : out Item);
function is_empty return Boolean;
function is_full return Boolean;
end Generic_Stack;
generic
: 제네릭 유닛의 시작을 알립니다.type Item is private;
: 제네릭 형식 타입 매개변수입니다.is private
는 제네릭 패키지 내부에서Item
타입의 객체에 대해 할당(:=
)과 동등 비교(=
) 연산 외에는 사용할 수 없음을 의미하는 계약입니다. 이는Item
으로 어떤 타입이 오든 최소한의 연산만을 가정하므로, 제네릭의 범용성을 높입니다.Initial_Capacity : Positive := 100;
: 제네릭 형식 값 매개변수입니다. 인스턴스화 시 용량을 지정하지 않으면 기본값으로 100이 사용됩니다.
11.1.3 제네릭 유닛의 인스턴스화
정의된 제네릭 유닛은 new
키워드를 사용하여 인스턴스화해야 비로소 실제 패키지로서 사용할 수 있습니다.
-- 정수를 저장하는 스택 인스턴스 생성
package Integer_Stack is new Generic_Stack (Item => Integer);
-- 문자열을 저장하는 스택 인스턴스 생성 (용량 50으로 지정)
package String_Stack is new Generic_Stack (Item => String, Initial_Capacity => 50);
이제 Integer_Stack
과 String_Stack
은 각각의 타입을 위한 완벽한 기능을 갖춘 독립적인 패키지입니다.
Integer_Stack.push (10);
String_Stack.push ("Hello, Ada!");
Ada의 표준 라이브러리 자체도 수많은 제네릭 유닛으로 구성되어 있습니다. 예를 들어, Ada.Unchecked_Conversion
은 서로 다른 두 타입 간의 비트 패턴을 그대로 복사하는 제네릭 함수입니다. 저수준 시스템 프로그래밍 라이브러리인 clair
에서도 C 언어와의 호환성을 위해 이 제네릭을 인스턴스화하여 사용합니다.
[cite_start]-- clair.ads [cite: 467]
-- System.Address 타입을 Interfaces.C.Strings.chars_ptr 타입으로
-- 변환하는 구체적인 함수 'to_chars_ptr'을 생성함.
function to_chars_ptr is new Ada.Unchecked_Conversion (
source => System.Address,
target => Interfaces.C.Strings.chars_ptr
);
이처럼 제네릭은 고수준의 데이터 구조뿐만 아니라, 저수준의 시스템 기능 구현에 이르기까지 Ada 프로그래밍 전반에서 재사용성을 극대화하는 핵심적인 역할을 담당합니다.
11.2 제네릭 매개변수: 값, 타입, 서브프로그램
Ada 제네릭의 유연성과 강력함은 인스턴스화 시에 전달할 수 있는 제네릭 형식 매개변수(Generic Formal Parameter) 의 다양성에서 나옵니다. 다른 언어의 제네릭이 주로 타입(type)에 국한되는 것과 달리, Ada는 값(value), 타입(type), 서브프로그램(subprogram) 세 가지 종류의 매개변수를 모두 허용합니다. 이를 통해 컴포넌트의 동작을 매우 정밀하게 조정하고 확장할 수 있습니다.
11.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);
11.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) 임을 명시합니다.
11.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_sort
를 Integer
타입으로 인스턴스화하면, 컴파일러는 Integer
에 대해 이미 정의된 <
연산자를 자동으로 찾아 매개변수로 사용합니다. 사용자가 정의한 레코드 타입에 대해 <
연산자를 직접 정의했다면, 그 또한 자동으로 사용됩니다.
-- Integer 타입을 위한 정렬 프로시저 인스턴스 생성
-- 별도로 "<" 함수를 지정하지 않아도, 표준 Integer의 "<"가 사용됨
procedure sort_integers is new generic_sort
(Item => Integer,
Item_Array => Integer_Array);
Ada 제네릭은 값, 타입, 서브프로그램이라는 세 종류의 매개변수를 조합하여 매우 높은 수준의 추상화와 재사용성을 달성합니다.
- 값 매개변수는 컴포넌트의 설정을 담당합니다.
- 타입 매개변수는 데이터의 종류에 구애받지 않는 범용성을 제공합니다.
- 서브프로그램 매개변수는 특정 동작이나 전략을 외부에서 주입하여 유연성을 극대화합니다.
이러한 다층적인 매개변수 시스템은 Ada가 단순한 코드 재사용을 넘어, 타입 안전성을 완벽하게 보장하면서도 고도로 적응 가능한 소프트웨어 컴포넌트를 구축할 수 있게 하는 근간이 됩니다.
11.3 제네릭 유닛의 인스턴스화 (Instantiation)
제네릭 유닛은 그 자체로 직접 사용할 수 있는 실체가 아닌, 템플릿(template) 또는 설계도(blueprint)입니다. 이 설계도를 바탕으로 실제 동작하는 코드, 즉 인스턴스(instance)를 만들어내는 과정을 인스턴스화(Instantiation)라고 합니다. Ada에서 인스턴스화는 new
키워드를 통해 이루어지며, 이 과정은 프로그램 실행 시점이 아닌 컴파일 시점에 발생합니다.
11.3.1 인스턴스화 구문
제네릭 유닛을 인스턴스화하는 구문은 매우 명시적입니다. new
키워드 뒤에 인스턴스화할 제네릭 유닛의 이름을 명시하고, 괄호 안에 제네릭 형식 매개변수에 대응하는 실제 매개변수(generic actual parameter)를 전달합니다.
-- 일반적인 구문
package <인스턴스_이름> is new <제네릭_패키지_이름> (<매개변수_전달>);
function <인스턴스_이름> is new <제네릭_함수_이름> (<매개변수_전달>);
매개변수를 전달하는 방식에는 이름 지정 방식과 위치 지정 방식이 있습니다.
- 이름 지정 방식 (Named Association):
형식_매개변수_이름 => 실제_매개변수
형태로 명시적으로 전달합니다. 코드의 가독성과 유지보수성이 높아 적극적으로 권장되는 방식입니다. - 위치 지정 방식 (Positional Association): 제네릭 선언부에 정의된 순서대로 실제 매개변수를 전달합니다. 간결하지만, 매개변수의 순서가 변경될 경우 오류를 유발할 수 있습니다.
10.2절의 Generic_Stack
을 인스턴스화하는 예시는 다음과 같습니다.
-- 이름 지정 방식을 사용한 인스턴스화 (권장)
package Integer_Stack is new Generic_Stack
(Item => Integer, Initial_Capacity => 200);
-- 위치 지정 방식을 사용한 인스턴스화
package Float_Stack is new Generic_Stack (Float, 50);
11.3.2 컴파일 시점의 코드 생성
인스턴스화는 C 언어의 전처리기 매크로처럼 단순한 텍스트 치환이 아닙니다. 컴파일러는 new
키워드를 만나면 다음과 같은 정교한 과정을 수행합니다.
- 타입 및 매개변수 검증: 컴파일러는 전달된 실제 매개변수가 제네릭 형식 매개변수의 요구사항(계약)을 만족하는지 엄격하게 검사합니다. 예를 들어, 형식 매개변수가
type T is (<>);
로 선언되었다면, 이산 타입이 아닌 레코드 타입을 전달할 경우 컴파일 오류가 발생합니다. 이 과정을 통해 제네릭의 유연성과 Ada의 강력한 타입 안전성이 양립할 수 있습니다. - 코드 생성: 검증이 완료되면, 컴파일러는 제네릭 템플릿의 내용과 실제 매개변수를 조합하여 완전히 새로운 코드 유닛을 생성합니다.
Integer_Stack
의 경우, 마치 프로그래머가Item
을Integer
로,Initial_Capacity
를200
으로 직접 바꾸어 쓴 별개의 패키지 코드가 생성된 것과 동일한 효과를 냅니다.
따라서 Integer_Stack
과 Float_Stack
은 메모리상에서 코드를 공유하는 것이 아니라, 각각 독립적인 코드를 가지는 별개의 패키지가 됩니다.
11.3.3 제네릭 서브프로그램의 인스턴스화
이러한 원리는 패키지뿐만 아니라 서브프로그램에도 동일하게 적용됩니다. clair
라이브러리가 Ada.Unchecked_Conversion
제네릭 함수를 사용하는 예시는 이를 명확히 보여줍니다[cite: 468].
Ada.Unchecked_Conversion
은 Source
타입의 비트 패턴을 Target
타입으로 그대로 복사하는 변환 함수를 생성하는 제네릭 템플릿입니다.
[cite_start]-- clair.ads의 실제 코드 [cite: 468]
package Clair is
-- ...
private
-- 제네릭 함수 Ada.Unchecked_Conversion을 인스턴스화
function to_chars_ptr is new Ada.Unchecked_Conversion (
source => System.Address,
target => Interfaces.C.Strings.chars_ptr
);
end Clair;
위 코드는 System.Address
타입을 Interfaces.C.Strings.chars_ptr
타입으로 변환하는 to_chars_ptr
라는 새로운 함수 인스턴스를 생성합니다. 이처럼 제네릭 서브프로그램의 인스턴스화는 특정 용도에 맞는 고유한 기능을 가진 함수나 프로시저를 동적으로 만들어내는 강력한 기법입니다.
인스턴스화는 제네릭 템플릿에 생명을 불어넣어 구체적이고 사용 가능한 컴포넌트를 만드는 컴파일 시점의 과정입니다. 이 과정은 Ada의 강력한 타입 시스템에 의해 철저히 검증되므로, 높은 수준의 재사용성을 달성하면서도 프로그램의 신뢰성을 해치지 않습니다. 개발자는 제네릭과 인스턴스화를 통해 잘 검증된 범용 알고리즘과 데이터 구조를 특정 응용 분야에 맞게 안전하게 적용할 수 있으며, 이는 현대적인 Ada 소프트웨어 개발의 핵심적인 패러다임입니다.
11.4 재사용 가능한 컴포넌트 설계
제네릭 구문을 사용하는 것만으로 재사용성이 높은 우수한 컴포넌트가 보장되는 것은 아닙니다. 진정으로 효과적이고 널리 적용 가능한 제네릭 컴포넌트를 만들기 위해서는 몇 가지 중요한 설계 원칙을 고려해야 합니다. 이러한 원칙들은 컴포넌트의 유연성, 이식성, 그리고 유지보수성을 극대화하는 것을 목표로 합니다.
11.4.1 설계 원칙 1: 최소한의 가정 (Principle of Minimal Assumptions)
가장 중요한 원칙은 제네릭 컴포넌트가 자신의 형식 매개변수에 대해 가능한 한 최소한의 것만을 가정해야 한다는 것입니다. 매개변수에 대한 요구사항(계약)이 적을수록, 더 많은 종류의 실제 매개변수를 받아들일 수 있으므로 재사용의 범위가 넓어집니다.
예를 들어, 단순히 데이터를 저장하고 꺼내는 스택을 만든다고 가정해 봅시다.
- 나쁜 설계:
type Item is range <>;
(정수 타입만 가정) - 좋은 설계:
type Item is private;
(할당과 비교만 가정)
is private
으로 선언하면 정수뿐만 아니라 레코드, 배열, 접근 타입 등 거의 모든 타입을 스택에 저장할 수 있습니다. is (<>)
나 is tagged
와 같이 더 구체적인 계약은 제네릭의 구현 로직상 해당 타입의 속성(예: T'First
)이나 기능(예: 디스패칭)이 반드시 필요할 때만 사용해야 합니다.
11.4.2 설계 원칙 2: 의존성 주입 (Dependency Injection)
재사용 가능한 컴포넌트는 특정 환경이나 외부의 구체적인 구현에 의존해서는 안 됩니다. 필요한 기능이나 동작이 있다면, 이를 제네릭 서브프로그램 매개변수를 통해 외부에서 주입받는(inject) 형태로 설계해야 합니다.
10.2절의 generic_sort
예제에서 정렬에 필요한 비교 연산(<
)을 with function "<" ...
구문을 통해 외부에서 주입받은 것이 대표적인 사례입니다. 이를 통해 generic_sort
알고리즘은 ‘어떻게 비교할 것인가’라는 구체적인 정책으로부터 완벽하게 분리(decouple)됩니다. 비교 정책은 인스턴스화 시점에 사용자가 결정하므로, 제네릭 컴포넌트는 순수한 알고리즘의 역할에만 집중할 수 있습니다.
11.4.3 설계 원칙 3: 명확한 계약과 인터페이스
제네릭 유닛의 선언부 전체는 사용자와의 명확한 계약(contract) 입니다. 형식 매개변수의 이름과 타입, 그리고 순서는 사용자가 이 컴포넌트를 어떻게 사용해야 하는지를 알려주는 가장 중요한 문서입니다.
- 이름의 명료성:
Item
,Key
,Value
와 같이 매개변수의 역할을 명확히 드러내는 이름을 사용해야 합니다. - 문서화: 제네릭 선언부에 주석을 통해 각 매개변수가 왜 필요하며, 어떤 제약을 가지는지 설명하는 것이 중요합니다.
- 최소한의 인터페이스: 제네릭 패키지의 명세에는 인스턴스 사용자에게 꼭 필요한 서브프로그램과 타입만을 노출해야 합니다. 내부 구현에 사용되는 세부 사항은 본체(body)에 숨겨 정보 은닉을 강화해야 합니다.
11.4.4 설계 원칙 4: 제네릭의 조합 (Composition of Generics)
복잡한 제네릭 컴포넌트는 더 작고 단순한 제네릭들을 조합하여 계층적으로 구축할 수 있습니다. 예를 들어, 그래프(Graph)를 표현하는 제네릭 패키지는 내부적으로 노드(Node)들을 관리하기 위해 ‘키-값’ 쌍을 저장하는 제네릭 맵(Map) 컨테이너를 사용할 수 있습니다.
이때, 제네릭 패키지 자체를 다른 제네릭의 매개변수로 전달하는 with package ... is new ...
구문을 활용하여 컴포넌트 간의 관계를 정의할 수 있습니다. 이는 작고 검증된 부품을 조립하여 더 크고 신뢰성 있는 시스템을 만드는 소프트웨어 공학의 기본 원칙과 일치합니다.
11.4.5 언제 제네릭을 사용하지 말아야 하는가? (clair
의 사례)
모든 컴포넌트가 제네릭으로 만들어져야 하는 것은 아닙니다. 제네릭이 부적합한 경우를 이해하는 것 또한 중요한 설계 결정입니다.
clair
라이브러리는 저수준 POSIX 시스템 콜에 대한 구체적인 Ada 바인딩(concrete binding) 을 제공하는 것이 목적입니다. Clair.Process.fork
나 Clair.File.open
과 같은 함수는 특정 운영체제의 특정 기능을 일대일로 매핑합니다. 이러한 함수들은 본질적으로 범용적인 알고리즘이 아니므로, 이를 제네릭으로 만드는 것은 의미가 없습니다.
하지만 clair
라이브러리를 기반으로 새로운 제네릭 컴포넌트를 만들 수는 있습니다. 예를 들어, 임의의 자원을 관리하는 제네릭 Resource_Pool
을 설계하고, 이 제네릭을 Clair.File.Descriptor
타입으로 인스턴스화하여 ‘파일 디스크립터 풀’을 만드는 것은 매우 실용적인 접근 방식입니다. 이는 특정 플랫폼에 대한 구체적인 바인딩과, 그 위에서 동작하는 재사용 가능한 추상 로직을 분리하는 좋은 설계의 예시입니다.
잘 설계된 제네릭 컴포넌트는 현대적인 Ada 프로그래밍에서 코드 재사용과 신뢰성 확보의 핵심입니다. 최소한의 가정, 의존성 주입, 명확한 계약, 그리고 조합의 원칙을 따름으로써, 개발자는 특정 구현에 얽매이지 않는 유연하고 강력한 컴포넌트를 만들 수 있습니다. 이는 단순히 코드를 재사용하는 것을 넘어, 추상적이고 검증 가능한 패턴을 통해 소프트웨어 시스템 전체의 품질을 향상시키는 설계 철학이라 할 수 있습니다.
12. 객체 지향 프로그래밍 (Object-Oriented Programming)
Ada는 절차적, 구조적 프로그래밍뿐만 아니라 객체 지향 프로그래밍(Object-Oriented Programming, OOP) 패러다임을 완벽하게 지원합니다. OOP는 데이터(속성)와 해당 데이터를 조작하는 행위(메서드)를 하나의 “객체(object)”로 묶어 관리하는 프로그래밍 방식입니다. Ada는 class
키워드를 사용하는 다른 언어들과 달리, 기존의 강력한 레코드(record) 타입을 확장(extending)하는 방식으로 OOP의 핵심 개념인 상속, 다형성, 캡슐화를 구현합니다.
12.1 태그드 타입 (Tagged Types) 과 상속 (Inheritance)
Ada에서 상속과 다형성의 기반이 되는 것은 태그드 타입(Tagged Type)입니다.
12.1.1 tagged
타입: 확장 가능한 레코드
일반 레코드 타입에 tagged
키워드를 붙여 선언하면, 해당 타입은 확장 가능한 타입이 됩니다. 즉, 이 타입을 부모로 삼아 새로운 자식 타입을 파생(상속)시킬 수 있습니다.
tagged
라는 이름은 tagged
타입으로 생성된 모든 객체가 런타임에 자신의 정확한 타입을 식별할 수 있는 숨겨진 “태그(tag)” 정보를 가지고 있다는 사실에서 유래합니다. 이 태그는 프로그램이 실행되는 동안 특정 객체가 어떤 타입에 속하는지 동적으로 판단하는 데 사용되며, 이는 다형성(polymorphism)을 구현하는 핵심 메커니즘입니다.
구문:
type <타입_이름> is tagged record
-- 필드(데이터 속성)들 ...
end record;
예시: Clair 라이브러리는 시스템 프로그래밍에 중점을 두어 OOP 기능을 사용하지 않지만, OOP의 개념을 설명하기 위해 그래픽 객체를 모델링하는 일반적인 예시를 사용할 수 있습니다.
package Shapes is
type Shape is tagged record
X, Y : Float; -- 모든 도형이 공통으로 가지는 위치 속성
end record;
end Shapes;
Shape
는 이제 다른 구체적인 도형 타입의 부모(parent)가 될 수 있는 기본 타입입니다.
12.1.2 타입 확장 (상속)
타입 확장(Type Extension)은 Ada에서 상속(Inheritance)을 구현하는 방식입니다. new
키워드를 사용하여 기존 tagged
타입으로부터 새로운 타입을 파생시킬 수 있습니다. 이렇게 생성된 자식 타입은 부모 타입의 모든 필드를 물려받으며, 자신만의 새로운 필드를 추가할 수 있습니다.
구문:
type <자식_타입> is new <부모_타입> with record
-- 자식 타입에만 추가되는 필드들 ...
end record;
예시:
앞서 정의한 Shape
타입을 확장하여 Circle
과 Rectangle
이라는 두 개의 자식 타입을 만들어 보겠습니다.
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
필드를 모두 갖습니다.
12.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
타입은 Shape
의 Display
프로시저를 상속받은 뒤, 원을 그리는 데 필요한 자신만의 Display
프로시저를 새롭게 정의(재정의)할 수 있습니다.
12.2 타입 확장 (Type Extension) 과 클래스-와이드(Class-Wide) 타입
tagged
타입을 통해 구현된 상속은 객체 지향 프로그래밍의 한 부분일 뿐입니다. 진정한 OOP의 유연성은 서로 다른 타입의 객체들을 공통된 방식으로 다루는 능력, 즉 다형성(polymorphism)에서 비롯됩니다. Ada는 이를 클래스-와이드(Class-Wide) 타입이라는 개념을 통해 안전하고 명시적으로 지원합니다.
12.2.1 타입 확장과 “Is-A” 관계
앞서 type Circle is new Shape with ...
구문을 통해 Circle
이 Shape
을 확장(상속)하는 것을 보았습니다. 이는 객체 지향 용어로 “Is-A” 관계를 형성합니다. 즉, “모든 Circle
은 일종의 Shape
이다 (Every Circle
Is-A Shape
).”라고 말할 수 있습니다.
하지만 Ada의 강력한 타입 시스템 하에서 Shape
타입의 변수는 정확히 Shape
타입의 객체만 담을 수 있으며, 자식 타입인 Circle
객체를 직접 담을 수는 없습니다. 그렇다면 Circle
, Rectangle
등 Shape
에서 파생된 모든 종류의 도형을 하나의 배열에 담거나, 어떤 종류의 도형이든 처리할 수 있는 단일 서브프로그램을 작성하려면 어떻게 해야 할까요? 이 질문에 대한 해답이 바로 클래스-와이드 타입입니다.
12.2.2 클래스-와이드 타입 ('Class
속성)
모든 tagged
타입 T
에 대해, 'Class
속성을 사용하여 T'Class
라는 클래스-와이드 타입을 얻을 수 있습니다.
T
: 특정(specific) 타입을 의미합니다.Shape
타입의 변수는 오직Shape
객체만 가질 수 있습니다.T'Class
:T
와 그로부터 파생된 모든 자식 타입을 포함하는 하나의 “클래스(class)”를 의미합니다.Shape'Class
타입의 변수는Shape
객체,Circle
객체,Rectangle
객체 등Shape
계층에 속하는 어떤 객체든 가리킬 수 있습니다.
클래스-와이드 타입의 객체는 런타임에 어떤 특정 타입의 값을 가지고 있는지 식별할 수 있는 정보(이전에 언급한 “태그”)를 항상 유지합니다. 이를 통해 프로그램은 실행 시점에 객체의 실제 타입을 파악하고 그에 맞는 동작을 수행할 수 있습니다.
구문: 클래스-와이드 타입은 주로 접근(포인터) 타입이나 서브프로그램의 매개변수를 선언하는 데 사용됩니다.
-- Shape 계층에 속하는 모든 타입의 객체를 가리킬 수 있는 접근 타입
type Shape_Access is access all Shape'Class;
-- Shape 계층에 속하는 모든 타입의 객체를 매개변수로 받을 수 있는 프로시저
procedure Process_Any_Shape (S : in Shape'Class);
12.2.3 클래스-와이드 타입의 활용
클래스-와이드 타입의 주된 용도는 이종(heterogeneous) 데이터 구조를 생성하고, 이를 통해 다형적 디스패칭을 구현하는 것입니다.
1. 이종 컨테이너 (Heterogeneous Containers)
Shape'Class
타입을 사용하면, Circle
과 Rectangle
객체를 동일한 배열에 저장할 수 있습니다.
type Shape_Access is access all Shape'Class;
type Drawing is array (Positive range <>) of Shape_Access;
My_Circle : constant Shape_Access := new Circle'(X => 1.0, Y => 1.0, Radius => 5.0);
My_Rectangle : constant Shape_Access := new Rectangle'(X => 2.0, Y => 2.0, Width => 3.0, Height => 4.0);
-- Shape'Class 덕분에 Circle과 Rectangle을 하나의 배열에 담을 수 있습니다.
My_Drawing : Drawing := (My_Circle, My_Rectangle);
2. 다형적 디스패칭 (Polymorphic Dispatching)
클래스-와이드 타입의 객체를 통해 기본 연산(primitive operation)을 호출하면, 호출되는 서브프로그램의 버전이 컴파일 시점이 아닌 런타임에 객체의 실제 타입(태그)에 따라 결정됩니다. 이를 동적 디스패칭(dynamic dispatch) 또는 다형성이라고 합니다.
-- My_Drawing 배열의 모든 도형을 순회하며 Display를 호출합니다.
for Element of My_Drawing loop
-- 이 Display 호출은 다형적으로 동작합니다.
-- Element.all이 Circle이면 Circle의 Display가,
-- Element.all이 Rectangle이면 Rectangle의 Display가 호출됩니다.
Display (Element.all);
end loop;
위 루프 안의 Display (Element.all);
호출은 단 하나지만, 루프가 돌면서 Element
가 가리키는 객체의 실제 타입에 따라 서로 다른 버전의 Display
프로시저가 동적으로 호출됩니다. 이것이 객체 지향 프로그래밍의 핵심적인 유연성입니다.
12.3 다형성 (Polymorphism) 과 동적 디스패칭 (Dynamic Dispatching)
다형성(Polymorphism)은 객체 지향 프로그래밍의 핵심 원리 중 하나로, ‘여러 형태를 가질 수 있는 능력’을 의미합니다. 프로그래밍 관점에서 이는 하나의 인터페이스(서브프로그램 호출)가 서로 다른 기본 타입(구현)에 대해 동작할 수 있게 하는 능력을 말합니다. Ada에서는 태그드 타입 계층과 클래스-와이드(Class-Wide) 타입을 통해 다형성을 실현합니다.
동적 디스패칭(Dynamic Dispatching)은 다형성을 가능하게 하는 기본 메커니즘입니다. 이는 어떤 서브프로그램을 호출할지 컴파일 시점에 결정하는 정적 바인딩(Static Binding)과 달리, 실행 시점에 객체의 실제 타입(태그)에 따라 호출할 서브프로그램을 결정하는 과정을 말합니다.
12.3.1 다형성의 구현: 도형 예제
다형성의 개념을 이해하기 위해, 여러 종류의 도형(Shape)을 처리하는 그래픽 시스템을 예제로 사용합니다. 각 도형은 면적을 계산하는 기능은 공통적으로 가지지만, 계산 방식은 도형의 종류에 따라 다릅니다.
-
기본 타입 및 파생 타입 정의
먼저, 모든 도형의 기반이 될
Shape
타입을 태그드 타입으로 정의합니다. 이 타입은 면적 계산을 위한 원시(primitive) 함수calculate_area
를 가집니다.package Shapes is type Shape is tagged private; function calculate_area (s : Shape) return Float; private type Shape is tagged null record; end Shapes;
이제
Shape
를 확장하여 구체적인 도형인Circle
과Rectangle
을 정의합니다. 각 파생 타입은 자신만의 데이터를 가지고,calculate_area
함수를 자신의 계산 방식에 맞게 재정의(override)합니다.-- Shapes 패키지 본체 (일부) package body Shapes is -- Shape의 기본 구현 function calculate_area (s : Shape) return Float is begin return 0.0; end calculate_area; end Shapes; -- Circle 타입 정의 package Shapes.Circles is type Circle is new Shape with private; overriding function calculate_area (c : Circle) return Float; private type Circle is new Shape with record radius : Float; end record; end Shapes.Circles; -- Rectangle 타입 정의 package Shapes.Rectangles is type Rectangle is new Shape with private; overriding function calculate_area (r : Rectangle) return Float; private type Rectangle is new Shape with record width : Float; height : Float; end record; end Shapes.Rectangles;
-
클래스-와이드 타입을 이용한 다형적 처리
이제
Shape
클래스에 속한 모든 타입의 객체(Shape
,Circle
,Rectangle
등)를 처리하는 코드를 작성할 수 있습니다. 이는 클래스-와이드 타입Shape'Class
를 통해 가능합니다.Shape'Class
타입의 변수는Shape
타입의 객체뿐만 아니라 그로부터 파생된 어떠한 타입의 객체도 담을 수 있습니다.with Ada.Text_IO; with Shapes; use Shapes; procedure Process_Shapes is -- Shape 클래스에 속한 모든 객체를 담을 수 있는 배열 all_shapes : constant array (1 .. 2) of Shape'Class := (1 => Circle'(radius => 11.0), -- Circle 타입 객체 2 => Rectangle'(width => 5.0, height => 4.0)); -- Rectangle 타입 객체 begin for s of all_shapes loop -- 이 호출이 바로 동적 디스패칭이 일어나는 지점입니다. declare area : constant Float := calculate_area (s); begin Ada.Text_IO.put_line ("Area: " & area'image); end; end loop; end Process_Shapes;
위 코드의
all_shapes
배열은 서로 다른 타입(Circle
과Rectangle
)의 객체를 동시에 저장하고 있습니다.for s of all_shapes loop
구문 안에서calculate_area(s)
를 호출할 때 다형성이 발현됩니다.
12.3.2 동적 디스패칭의 원리
Process_Shapes
프로시저의 루프에서 calculate_area(s)
가 호출될 때, 프로그램은 다음과 같은 과정을 거칩니다.
- 태그 확인: 실행 시점에 프로그램은 변수
s
가 현재 담고 있는 객체의 숨겨진 태그(tag)를 확인합니다. 이 태그는 해당 객체의 구체적인 타입이 무엇인지(Circle
인지,Rectangle
인지) 식별하는 정보를 담고 있습니다. - 서브프로그램 선택 (Dispatching): 태그를 통해 확인된 구체적인 타입에 맞는
calculate_area
함수를 선택하여 호출합니다.- 루프의 첫 번째 반복에서
s
의 태그가Circle
을 가리키면,Shapes.Circles.calculate_area
가 호출됩니다. - 루프의 두 번째 반복에서
s
의 태그가Rectangle
을 가리키면,Shapes.Rectangles.calculate_area
가 호출됩니다.
- 루프의 첫 번째 반복에서
이처럼 동일한 calculate_area(s)
라는 코드 한 줄이 실행 시점의 객체 타입에 따라 서로 다른 함수 구현을 호출하게 되는 메커니즘이 바로 동적 디스패칭입니다.
만약 변수가 클래스-와이드 타입이 아닌 구체적인 타입(예: s : Shape
)이었다면, calculate_area(s)
호출은 항상 Shapes.calculate_area
의 기본 구현을 호출하도록 컴파일 시점에 결정(정적 바인딩)되었을 것입니다.
다형성과 동적 디스패칭은 코드의 유연성과 확장성을 극대화합니다. 위 예제에서 Triangle
이라는 새로운 도형 타입을 추가하더라도, Process_Shapes
프로시저의 루프 코드는 전혀 수정할 필요가 없습니다. 새로운 타입을 클래스-와이드 배열에 추가하기만 하면, 동적 디스패칭 메커니즘이 알아서 올바른 calculate_area
함수를 찾아 호출해 줄 것입니다.
이러한 특성 덕분에 개발자는 변화에 쉽게 적응하고 재사용성이 높은 소프트웨어 컴포넌트를 구축할 수 있습니다. Ada의 강력한 타입 시스템은 이러한 동적 기능들을 타입 안전성을 보장하는 틀 안에서 제공하여, 신뢰성과 유연성을 모두 만족시키는 객체 지향 설계를 가능하게 합니다.
12.4 추상 타입 및 인터페이스: 유연한 설계를 위한 도구
이전 절에서는 태그드 타입(tagged type
)과 클래스-와이드 타입('Class
)을 통해 상속과 동적 다형성을 구현하는 방법을 살펴보았습니다. 이러한 기능은 “is a” 관계를 기반으로 타입을 확장하여 유연한 코드를 작성하는 데 매우 유용합니다.
하지만 때로는 완전한 기능을 갖춘 구체적인(concrete) 타입을 정의하는 대신, 여러 타입들이 반드시 지켜야 할 동작의 명세 또는 계약(specification or contract)만을 정의하고 싶을 때가 있습니다. 예를 들어, ‘Drawable’이라는 개념을 정의하고 싶다고 가정해 봅시다. ‘Drawable’은 draw
라는 연산을 가져야 한다는 규칙을 설정하지만, draw
가 실제로 원을 그리는지, 사각형을 그리는지는 각 타입이 스스로 결정하도록 위임하는 것입니다. 이처럼 “무엇을 해야 하는지”는 정의하되, “어떻게 해야 하는지”는 자식 타입에게 맡기는 추상화 메커니즘이 필요합니다.
Ada는 이러한 고수준의 추상화를 위해 추상 타입(Abstract Types)과 인터페이스(Interfaces)라는 두 가지 강력한 도구를 제공합니다.
- 추상 타입은 일부 연산의 구현을 포함하면서도, 특정 연산은 “추상적”으로 남겨두어 자식 타입이 반드시 구현하도록 강제하는 타입입니다. 이는 공통된 기반을 공유하는 타입 계층을 설계할 때 유용합니다.
- 인터페이스는 구현이 전혀 없이 순수한 연산의 명세만으로 이루어진 순수 계약입니다. 어떤 타입이든 상속 관계와 무관하게 특정 인터페이스를 구현함으로써 해당 “역할”이나 “능력”을 가질 수 있음을 보장합니다.
이 두 기능을 활용하면 컴포넌트 간의 결합도(coupling)를 낮추고, 확장과 재사용이 용이한 유연한 소프트웨어 아키텍처를 설계할 수 있습니다.
이번 장에서는 먼저 추상 타입과 인터페이스의 기본 개념과 문법을 각각 알아보고, 두 기능의 차이점과 사용 사례를 비교 분석할 것입니다. 나아가 여러 인터페이스를 동시에 구현하는 방법과 제네릭과의 시너지를 통해 Ada의 추상화 기능을 극한까지 활용하는 고급 설계 기법까지 탐구해 보겠습니다.
12.4.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
함수를 재정의하여 구현해야만 컴파일이 가능합니다. 이로써 “도형 클래스에 속한 모든 객체는 면적을 계산할 수 있다”는 설계 규칙이 컴파일 시점에 강제됩니다.
12.4.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;
이렇게 하면 Circle
은 Shape
의 일종이면서(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;
12.4.3 추상 타입 vs. 인터페이스: 언제 무엇을 써야 하는가
추상 타입과 인터페이스는 모두 코드의 유연성과 재사용성을 높이는 강력한 추상화 도구이지만, 서로 다른 설계 목표를 가지고 있습니다. 두 기능의 차이점을 명확히 이해하고 상황에 맞는 도구를 선택하는 것은 견고하고 확장성 있는 아키텍처를 구축하는 데 매우 중요합니다.
핵심적인 선택 기준은 “무엇인가(Is-A)” 관계를 모델링하는지, 아니면 “무엇을 할 수 있는가(Can-Do)”의 능력을 정의하는지에 달려있습니다.
특징 | 추상 타입 (Abstract Type) | 인터페이스 (Interface) |
---|---|---|
핵심 목적 | “Is-A” (하나의 ‘종류’ 또는 ‘가족’) | “Can-Do” (하나의 ‘능력’ 또는 ‘역할’) |
데이터/구현 | 데이터 필드와 구현된 서브프로그램을 가질 수 있음 | 데이터 필드와 구현을 가질 수 없음 (순수 명세) |
상속 | 단일 상속만 가능 | 다중 상속(구현)이 가능 |
주요 사용 사례 | 공통 데이터나 행동을 공유하는 타입 계층 설계 | 서로 다른 타입들에게 공통된 능력을 부여 |
“Is-A” 관계: 공통의 기반을 가진 ‘가족’ -> 추상 타입
추상 타입은 관련된 타입들의 ‘가족(family)’을 만들 때 사용합니다. 이 가족의 모든 구성원은 공통된 데이터 구조나 기본 행동을 공유합니다.
예를 들어, 모든 도형(Shape
)은 화면상의 위치(Position)
와 색상(Color)
이라는 공통된 데이터를 가지며, 이동(Move)
하는 행동은 모든 도형에 동일하게 적용될 수 있다고 가정해 봅시다. 이 경우, Position
과 Color
필드를 가지고 Move
프로시저를 직접 구현한 Abstract_Shape
라는 추상 타입을 정의하는 것이 적합합니다. Circle
과 Square
는 이 Abstract_Shape
을 상속받아 Draw
연산만 각자 구현하면 됩니다. Circle
과 Square
는 명백히 Shape
의 한 ‘종류’입니다.
결정 가이드:
- 여러 자식 타입들이 공통된 데이터 필드를 반드시 공유해야 하는가?
- 일부 서브프로그램의 기본 구현을 물려주고 싶은가?
- 설계하려는 관계가 명확한 “A는 B의 한 종류이다”에 해당하는가?
위 질문 중 하나라도 “예”라면 추상 타입이 올바른 선택일 가능성이 높습니다.
“Can-Do” 관계: 공통된 능력을 가진 ‘역할’ -> 인터페이스
인터페이스는 서로 관련 없는 타입들에게 공통된 ‘능력(capability)’이나 ‘역할(role)’을 부여할 때 사용합니다.
예를 들어, 시스템의 다양한 객체들을 로그 파일에 기록하는 기능을 생각해 봅시다. 사용자_활동(User_Action)
, 네트워크_패킷(Network_Packet)
, 센서_읽기(Sensor_Reading)
객체들은 근본적으로 서로 다른 것들이지만, 모두 “로그에 기록될 수 있는(Loggable
)” 능력을 가질 수 있습니다. 이 경우, To_Log_Message
라는 함수 하나만 가진 Loggable
인터페이스를 정의하면 됩니다. 그러면 세 개의 타입 모두 각자의 상속 관계와 상관없이 Loggable
인터페이스를 구현하여 “로그 기록이 가능한” 객체가 될 수 있습니다.
결정 가이드:
- 서로 관련 없는 타입들에게 공통된 행동 명세(contract)를 부여하고 싶은가?
- 하나의 타입이 여러 서로 다른 능력들(다중 상속)을 동시에 갖게 될 수 있는가? (예:
Loggable
이면서 동시에Serializable
인 타입) - 구현이나 데이터의 공유 없이, 오직 “무엇을 할 수 있는지”만 정의하고 싶은가?
위 질문들에 해당한다면 인터페이스가 더 유연하고 적절한 해결책입니다.
- 추상 타입은 강하게 연관된 타입 계층을 만들어 코드를 재사용하는 데 중점을 둡니다.
- 인터페이스는 서로 다른 타입들 간의 결합도를 낮추고 유연한 역할을 부여하는 데 중점을 둡니다.
올바른 도구를 선택하면 소프트웨어의 구조가 더 명확해지고, 미래의 변경 및 확장에 더 쉽게 대응할 수 있게 됩니다.
12.4.4 다중 인터페이스 구현: 기능의 조합
추상 타입과 인터페이스의 가장 근본적인 차이점이자, 인터페이스가 제공하는 가장 강력한 기능 중 하나는 바로 다중 상속(multiple inheritance)을 지원한다는 점입니다. 정확히는, Ada는 구현의 다중 상속은 허용하지 않지만, 인터페이스를 통해 명세의 다중 상속을 완벽하게 지원합니다.
이는 현실 세계의 객체들이 여러 독립적인 역할이나 능력을 동시에 갖는 것과 유사합니다. 예를 들어, 하나의 디지털_문서
객체는 화면에 ‘출력될 수 있는(Printable
)’ 동시에, 네트워크를 통해 ‘전송될 수 있는(Serializable
)’ 능력을 가질 수 있습니다. 단일 상속 모델에서는 이처럼 서로 다른 계층의 능력을 조합하기가 매우 어렵지만, 인터페이스를 사용하면 간결하게 해결할 수 있습니다.
문법과 규칙
한 타입이 여러 인터페이스를 구현하도록 하려면, 타입 선언부에 and
키워드를 사용하여 구현할 인터페이스들을 나열하면 됩니다.
type My_Concrete_Type is new Interface_1 and Interface_2 with record
-- ... 데이터 필드 ...
end record;
이렇게 선언된 타입은 Interface_1
과 Interface_2
가 요구하는 모든 추상 서브프로그램을 반드시 구현해야 한다는 계약을 컴파일러와 맺게 됩니다. 컴파일러는 이 계약이 지켜졌는지 컴파일 타임에 엄격하게 검사합니다.
예제: Serializable
과 Loggable
능력을 모두 가진 센서 데이터
서로 다른 두 능력, 즉 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
타입을 선언하면서 Serializable
과 Loggable
을 and
로 연결합니다.
-- 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 ...
이처럼 다중 인터페이스 구현은 강직한 상속 계층 구조에서 벗어나, 필요한 기능들을 자유롭게 조합하여 컴포넌트 기반의 유연한 아키텍처를 구축할 수 있게 해주는 강력한 설계 도구입니다.
12.4.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
타입이나 “문서 저장”이라는 동작에 대해 전혀 알지 못합니다. 이처럼 인터페이스를 활용한 콜백 패턴은 결합도를 극적으로 낮추고, 재사용성과 확장성이 뛰어난 시스템을 만드는 핵심적인 기법입니다.
12.4.6 제네릭과 인터페이스의 시너지: 정적 및 동적 다형성의 결합
지금까지 우리는 Ada의 두 가지 강력한 다형성(polymorphism) 메커니즘을 각각 살펴보았습니다.
- 정적 다형성 (Static Polymorphism): 제네릭(Generics)을 통해 구현됩니다. 컴파일 시점에 타입 매개변수를 기반으로 코드가 생성되므로, 런타임 오버헤드 없이 높은 성능과 강력한 타입 안전성을 제공합니다.
- 동적 다형성 (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_Type
이 My_Interface
의 모든 연산을 가지고 있음을 컴파일러가 보장합니다. 따라서 우리는 안심하고 해당 인터페이스의 서브프로그램을 호출할 수 있으며, 이 모든 것은 컴파일 타임에 안전하게 검사됩니다.
예제: 범용 아이템 처리기
Loggable
인터페이스를 구현하는 모든 타입의 아이템 리스트를 처리할 수 있는 범용 Process_Items
프로시저를 만들어 보겠습니다.
1. 재사용할 인터페이스 및 구체 타입 정의
이전 절에서 사용한 Loggable
인터페이스와, 이를 구현하는 서로 다른 두 타입 User_Action
과 Network_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를 사용하여 대규모의 복잡한 시스템을 구축할 때, 각 컴포넌트를 독립적으로 개발하고 테스트하며, 전체 시스템의 안정성과 재사용성을 극적으로 향상시키는 핵심적인 전략입니다.
12.5 제어되는 타입: 안정적인 자원 관리 (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
패키지의 Controlled
와 Limited_Controlled
타입을 상속하여 사용자 정의 타입을 만드는 방법을 학습합니다. 이를 통해 파일 핸들이나 메모리 포인터와 같은 자원을 객체 내부에 캡슐화하고, 어떠한 상황에서도 자원이 누수되지 않는 안정적인 프로그램을 작성하는 방법을 익히게 될 것입니다.
12.5.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)’를 호출하여 뒷정리를 보장해 주기 때문입니다.
이를 통해 우리는 예외 상황에서도 자원 누수 걱정이 없는, 훨씬 더 견고하고 신뢰성 높은 코드를 작성할 수 있습니다. 다음 절부터 이 패키지의 핵심 구성 요소와 실제 사용법을 자세히 살펴보겠습니다.
12.5.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;
가 실행될 때의 내부 동작은 다음과 같습니다.
finalize (Target)
:Target
객체가 기존에 소유하던 자원을 해제합니다.Target
의 모든 필드를Source
의 필드로 비트 단위 복사합니다.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;
12.5.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
타입을 살펴보겠습니다.
12.5.4 대입을 금지하는 제어 타입: Limited_Controlled
앞서 살펴본 Controlled
타입은 객체의 복사, 즉 대입(:=
)을 허용하는 자원을 관리하는 데 유용합니다. 하지만 세상의 모든 자원이 의미 있거나 안전하게 복사될 수 있는 것은 아닙니다.
예를 들어, 파일을 열 때 운영체제로부터 받는 파일 핸들(file handle)을 생각해 보십시오. 이 핸들을 복사한다고 해서 디스크에 새로운 파일이 생기는 것이 아닙니다. 대신 두 개의 변수가 동일한 파일을 가리키게 되어, 누가 파일을 닫아야 하는지에 대한 소유권 문제가 발생하고, 한쪽에서 파일을 닫으면 다른 쪽의 핸들은 무효화되는 등 혼란을 야기합니다. 네트워크 소켓이나 뮤텍스 락(mutex lock)과 같은 자원들도 마찬가지로 고유하며 복사의 개념이 무의미합니다.
이처럼 복사(대입)가 불가능하거나 위험한 고유 자원을 관리하기 위해 Ada는 Ada.Finalization.Limited_Controlled
타입을 제공합니다.
Limited_Controlled
타입의 가장 큰 특징은 이 타입을 상속받는 모든 자식 타입이 자동으로 제한된 타입(limited type)이 된다는 점입니다. 제한된 타입에는 대입 연산(:=
)이 허용되지 않으므로, 컴파일러가 원천적으로 위험한 복사 시도를 차단해 줍니다. 대입이 불가능하므로 상태 조정을 위한 Adjust
프로시저 또한 필요 없으며, 우리는 오직 Initialize
와 Finalize
두 연산만 구현하면 됩니다.
예제: 파일 핸들을 안전하게 래핑하는 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
는 자원의 소유권이 유일무이해야 하는 상황에서 컴파일 타임에 안전을 강제하는 매우 강력한 도구입니다. 이를 통해 프로그래머의 실수를 미연에 방지하고, 자원 관리의 논리를 명확하고 단순하게 유지할 수 있습니다.
12.5.5 제어되는 타입과 상호작용
제어되는 타입은 단독으로 사용될 때도 강력하지만, 진정한 가치는 접근 타입(access types)이나 표준 컨테이너(Ada.Containers
)와 같은 다른 언어 기능과 결합될 때 드러납니다. Ada의 런타임 시스템은 이러한 상호작용을 예측 가능하고 안정적으로 처리하여, 복잡한 자료 구조 속에서도 자원 관리가 일관되게 유지되도록 보장합니다.
접근 타입과 제어되는 객체
접근 타입(포인터)이 제어되는 객체를 가리키는 경우를 생각해 보겠습니다. 이 객체의 finalize
프로시저는 언제 호출될까요? 접근 타입 변수 자체가 스코프를 벗어날 때가 아니라, 그 변수가 가리키는 대상 객체(designated object)가 메모리에서 해제될 때 호출됩니다.
메모리 해제는 Ada.Unchecked_Deallocation
의 인스턴스를 통해 명시적으로 이루어집니다. Free(My_Ptr)
와 같은 호출이 발생하면, 런타임은 다음과 같은 순서로 동작합니다.
My_Ptr
이 가리키는 객체의finalize
프로시저를 호출하여 객체가 소유한 자원을 먼저 해제합니다.- 객체의
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
패키지의 벡터, 맵, 리스트 등은 제어되는 타입을 완벽하게 지원합니다. 컨테이너는 자신이 담고 있는 요소들의 생명주기를 책임지며, 다음과 같이 동작합니다.
- 요소 추가:
Append
나Insert
를 통해 제어되는 객체를 컨테이너에 추가하면, 컨테이너는 객체의 독립적인 복사본을 만들어 저장합니다. 만약 타입이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가 제공하는 핵심적인 안전장치 중 하나입니다.
12.5.6 사용 지침 및 모범 사례
제어되는 타입은 Ada가 제공하는 강력한 자원 관리 도구이지만, 모든 상황에 필요한 것은 아니며 올바르게 사용해야 그 효과를 온전히 발휘할 수 있습니다. 이번 절에서는 제어되는 타입을 효과적으로 사용하기 위한 몇 가지 지침과 모범 사례를 정리합니다.
1. 자원을 ‘소유’할 때만 사용하라
제어되는 타입의 핵심 목적은 자원의 소유권(ownership)을 표현하는 것입니다. 어떤 타입이 파일 핸들이나 동적 메모리와 같이 명시적인 생성과 해제가 필요한 자원의 생명주기를 책임져야 할 때 제어되는 타입을 사용해야 합니다.
- 적절한 사용 사례 ✅:
- 파일 핸들, 네트워크 소켓, 뮤텍스 락(lock) 등 저수준 시스템 자원을 래핑(wrapping)할 때.
- 동적으로 할당된 메모리를 관리하는 자료 구조를 만들 때.
- 명시적인
create
와destroy
함수 쌍을 요구하는 C 라이브러리와의 인터페이스를 구축할 때.
- 부적절한 사용 사례 ❌:
- 단순 데이터 집합(aggregation)에는 사용하지 마십시오.
Integer
나Boolean
필드만으로 구성된 레코드는 관리할 외부 자원이 없으므로 제어되는 타입으로 만들 필요가 없습니다. 불필요한 사용은 코드 복잡성을 높이고 약간의 런타임 오버헤드를 유발할 수 있습니다.
- 단순 데이터 집합(aggregation)에는 사용하지 마십시오.
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의 제어되는 타입을 활용하여 자원 누수 걱정이 없고 예외 상황에서도 안정적으로 동작하는 매우 견고한 소프트웨어를 구축할 수 있습니다.
13. 동시성 프로그래밍 소개
현대의 컴퓨팅 환경은 복잡성의 증가와 성능 향상에 대한 끊임없는 요구에 직면해 있습니다. 단일 코어 프로세서의 성능 향상이 물리적 한계에 도달하면서, 하드웨어 제조업체들은 여러 개의 처리 코어를 하나의 칩에 집적하는 멀티코어 아키텍처로 전환했습니다. 이러한 변화는 소프트웨어 개발 패러다임에도 근본적인 전환을 요구합니다. 이제는 단일 순차 흐름으로 실행되는 프로그램을 넘어, 여러 작업을 동시에 처리하여 시스템의 잠재력을 최대한 활용하는 동시성(concurrency) 프로그래밍이 필수적인 기술이 되었습니다.
동시성 프로그래밍은 단순히 여러 작업을 빠르게 처리하는 것을 넘어, 응답성을 향상시키고, 실시간 제약 조건을 충족하며, 복잡한 시스템을 논리적으로 분리된 작은 단위로 모델링하는 강력한 수단을 제공합니다. 예를 들어, 그래픽 사용자 인터페이스(GUI) 애플리케이션에서 사용자의 입력을 처리하는 작업과 백그라운드에서 데이터를 처리하는 작업을 동시에 실행함으로써, 애플리케이션이 멈추지 않고 부드럽게 동작하도록 보장할 수 있습니다. 또한, 항공우주, 국방, 의료, 산업 자동화와 같이 신뢰성과 안전성이 최우선인 시스템에서는 수많은 센서 데이터 처리, 통신, 제어 로직이 동시에 안정적으로 수행되어야 합니다.
많은 프로그래밍 언어들이 라이브러리나 외부 도구를 통해 동시성 기능을 지원하지만, Ada는 언어 설계 초기부터 동시성을 핵심 개념으로 통합했습니다. 이는 Ada가 단순한 기능 제공을 넘어, 동시성 프로그래밍에서 발생할 수 있는 고질적인 문제들을 언어 차원에서 예방하고 해결할 수 있도록 설계되었음을 의미합니다. Ada는 태스크(task)와 보호 객체(protected object)라는 강력하고 직관적인 구조를 통해 동시성을 구현하며, 이를 통해 개발자는 복잡한 동시성 상호작용을 명확하고 안전하게 표현할 수 있습니다.
본 12장에서는 동시성 프로그래밍의 세계로 들어서는 첫걸음을 내딛습니다. 먼저 동시성의 기본 개념을 정의하고, 종종 혼용되는 병렬성(parallelism)과의 차이를 명확히 구분할 것입니다. 이어서 동시성 프로그래밍이 왜 현대 소프트웨어 개발에서 중요한지를 살펴보고, 공유 메모리 및 메시지 전달과 같은 고전적인 동시성 모델을 통해 Ada의 접근 방식이 갖는 독창성을 이해하게 될 것입니다. 마지막으로, 동시성 시스템을 설계할 때 반드시 고려해야 할 경쟁 상태(race conditions), 교착 상태(deadlocks)와 같은 도전 과제들을 소개하여, 앞으로 이어질 장들에서 Ada가 이러한 문제들을 어떻게 효과적으로 해결하는지에 대한 기초를 다질 것입니다.
이 장을 통해 독자 여러분은 동시성이라는 개념에 익숙해지고, Ada가 제공하는 견고한 동시성 모델의 필요성을 체감하게 될 것입니다. 이는 앞으로 우리가 배울 태스크, 랑데부, 보호 객체 등 Ada의 고급 동시성 기능을 깊이 있게 이해하기 위한 필수적인 토대가 될 것입니다.
13.1 동시성(concurrency)의 이해
앞서 동시성이 현대 소프트웨어 개발의 핵심 요소임을 확인했습니다. 이제 우리는 이 개념을 더 깊이 파고들어, 그 정의를 명확히 하고, 관련된 용어와 구별하며, 그 필요성을 구체적으로 이해해야 합니다. 동시성을 정확히 이해하는 것은 복잡한 시스템을 효과적으로 설계하고, Ada가 제공하는 강력한 동시성 도구들을 올바르게 활용하기 위한 첫걸음입니다.
이 절에서는 동시성의 세 가지 핵심 측면을 다룰 것입니다. 첫째, 동시성의 명확한 정의를 내리고, 논리적으로 여러 작업 흐름이 동시에 진행되는 상태가 무엇을 의미하는지 탐구합니다. 둘째, 동시성과 자주 혼동되는 병렬성(parallelism)의 개념을 비교하여 두 용어의 차이와 관계를 명확히 할 것입니다. 마지막으로, 이론적 개념을 넘어 현실 세계에서 동시성 프로그래밍이 왜 필수적인지, 그 구체적인 이유와 이점을 살펴보겠습니다.
13.1.1 동시성의 정의
동시성(concurrency)은 둘 이상의 작업(task)이 동시에 활성화되어 진행 중인 시스템의 특성을 의미합니다. 여기서 핵심은 ‘동시에 진행 중’이라는 개념입니다. 이는 모든 작업이 물리적으로 정확히 같은 시간에 실행되어야 함을 의미하지는 않습니다. 대신, 하나의 작업이 끝나기 전에 다른 작업이 시작되어 여러 작업의 실행 시간이 서로 겹치는 상태를 말합니다.
단일 코어 프로세서를 예로 들어보겠습니다. 프로세서는 한순간에 단 하나의 명령어만 처리할 수 있지만, 운영체제 스케줄러는 매우 짧은 시간 간격(time slice)으로 여러 작업을 번갈아 가며 실행합니다. 각 작업은 잠시 실행되다가 멈추고, 다른 작업에게 CPU 사용 권한을 넘겨줍니다. 이 전환이 매우 빠르게 일어나기 때문에, 사용자나 외부 시스템의 관점에서는 여러 작업이 동시에 처리되는 것처럼 보입니다. 이러한 실행 방식을 실행의 인터리빙(interleaving of execution)이라고 합니다.
결론적으로 동시성은 다음과 같이 정의할 수 있습니다.
동시성이란 시스템이 여러 개의 독립적인 논리적 실행 흐름(logical flows of control)을 관리하는 능력입니다. 이러한 흐름들은 물리적으로 동시에 실행될 수도 있고(멀티코어 환경), 번갈아 가며 실행될 수도 있습니다(싱글코어 환경).
이처럼 동시성은 문제 해결을 위한 논리적 구조에 초점을 맞춘 개념입니다. 즉, 여러 작업을 독립적으로 구성하고 이들 간의 상호작용과 실행 순서를 관리하는 프로그래밍 모델 그 자체를 의미합니다. Ada는 바로 이러한 논리적 흐름을 ‘태스크’라는 언어적 구조로 명확하게 표현하고 관리할 수 있도록 지원합니다.
13.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는 개발자가 동시성에 집중하여 프로그램을 논리적으로 명확하게 구조화할 수 있도록 돕습니다. 이렇게 작성된 코드는 하드웨어 환경에 따라 자연스럽게 병렬성의 이점을 취할 수 있습니다.
13.1.3 동시성 프로그래밍의 필요성
동시성 프로그래밍이 단지 학문적 개념이나 선택적 최적화 기법에 머무르지 않고 현대 소프트웨어 개발의 필수 요소가 된 데에는 몇 가지 근본적인 이유가 있습니다.
1. 하드웨어 자원의 최대 활용
가장 명백한 이유는 하드웨어의 발전 방향 때문입니다. 개별 프로세서 코어의 클럭 속도를 높이는 방식은 물리적 한계(발열, 전력 소모)에 부딪혔습니다. 이에 대한 해답으로 업계는 여러 개의 코어를 하나의 칩에 통합하는 멀티코어 아키텍처를 표준으로 채택했습니다. 순차적으로 작성된 프로그램은 이 여러 개의 코어 중 단 하나만 사용하게 되어 나머지 코어들은 유휴 상태로 남게 됩니다. 동시성 프로그래밍은 여러 작업을 각기 다른 코어에 할당하여 병렬로 실행시킴으로써, 시스템의 컴퓨팅 자원을 최대한 활용하고 전체적인 성능을 극대화합니다.
2. 향상된 응답성
사용자 경험(UX)이 중요한 현대 애플리케이션에서 응답성은 매우 중요합니다. 예를 들어, 대용량 파일을 처리하거나 복잡한 연산을 수행하는 작업을 단일 스레드로 처리하면 작업이 완료될 때까지 전체 애플리케이션의 인터페이스가 멈추는 ‘프리징(freezing)’ 현상이 발생합니다. 동시성을 도입하면 시간이 오래 걸리는 작업을 백그라운드 태스크(background task)로 분리할 수 있습니다. 이를 통해 메인 태스크는 계속해서 사용자의 입력에 응답하며 원활한 상호작용을 제공할 수 있습니다.
3. 현실 세계의 자연스러운 모델링
많은 문제들은 본질적으로 동시적입니다. 웹 서버는 수많은 클라이언트의 요청을 동시에 처리해야 하고, 로봇 제어 시스템은 센서 데이터 수집, 모터 제어, 경로 계산 등 여러 활동을 동시에 수행해야 합니다. 이러한 문제들을 하나의 거대한 순차적 루프로 구현하려고 시도하면 코드가 매우 복잡해지고 이해하기 어려워집니다. 동시성 프로그래밍을 사용하면 현실 세계의 독립적인 행위자(actor)나 이벤트를 각각의 태스크로 모델링할 수 있습니다. 이는 소프트웨어의 구조를 문제의 본질과 일치시켜 설계를 더 단순하고 직관적으로 만들며, 유지보수를 용이하게 합니다.
4. 실시간 및 임베디드 시스템의 요구사항
항공우주, 산업 자동화, 의료 기기와 같은 실시간 시스템(real-time systems)에서는 논리적 정확성뿐만 아니라 시간적 정확성, 즉 정해진 시간 제약(deadline) 안에 작업을 완료하는 것이 매우 중요합니다. 이러한 시스템은 예측 불가능한 외부 이벤트를 처리하고 여러 제어 루프를 동시에 실행해야 합니다. 동시성은 각기 다른 우선순위를 가진 작업들을 효과적으로 관리하고, 긴급한 작업이 다른 작업을 선점(preempt)하여 실행될 수 있도록 보장하는 필수적인 메커지즘을 제공합니다.
이러한 이유들로 인해 동시성은 더 이상 전문가의 영역이 아닌 모든 개발자가 이해하고 활용해야 할 보편적인 프로그래밍 패러다임이 되었습니다. Ada는 바로 이러한 필요성을 충족시키기 위해 언어 차원에서 강력하고 신뢰성 높은 동시성 기능을 제공합니다.
13.2 동시성 모델
지금까지 동시성의 개념과 필요성을 살펴보았습니다. 이제 동시성을 프로그래밍 언어에서 실제로 어떻게 구현하고 관리하는지에 대한 방법론, 즉 동시성 모델(concurrency model)에 대해 알아볼 차례입니다. 동시성 모델은 여러 독립적인 실행 흐름(태스크)들이 서로 통신(communication)하고, 공유된 자원에 대한 접근을 조율하며, 실행 순서를 동기화(synchronization)하는 방식에 대한 규칙과 추상화를 제공합니다.
어떤 동시성 모델을 사용하는지에 따라 프로그램의 구조, 안정성, 그리고 해결할 수 있는 문제의 종류가 크게 달라집니다. 이 절에서는 프로그래밍 세계에서 널리 사용되는 두 가지 기본 모델인 공유 메모리 모델과 메시지 전달 모델을 먼저 살펴보겠습니다.
이러한 고전적인 모델들을 이해한 후, Ada가 제공하는 독창적이고 강력한 동시성 모델을 소개합니다. Ada는 태스크 간의 직접적인 통신을 위한 메커니즘과 공유 데이터를 안전하게 보호하는 메커니즘을 언어 차원에서 모두 제공함으로써, 다른 언어들과 차별화된 견고함과 명확성을 자랑합니다. 본 절을 통해 각 모델의 원리를 이해하고 Ada의 접근 방식이 갖는 이점을 파악하게 될 것입니다.
13.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)와 같은 오류가 발생하기 매우 쉽다.
이 모델은 강력한 성능을 제공하지만, 개발자에게 신중한 동기화 처리라는 무거운 책임을 지게 합니다.
13.2.2. 메시지 전달 모델 (message passing model)
메시지 전달 모델은 공유 메모리 모델의 복잡성과 위험성을 해결하기 위한 대안으로 제시된 동시성 모델입니다. 이 모델의 핵심 철학은 “상태를 공유하여 통신하지 말고, 통신을 통해 상태를 공유하라 (Do not communicate by sharing memory; instead, share memory by communicating)” 라는 말로 요약할 수 있습니다.
이 모델에서 각 태스크는 다른 태스크가 접근할 수 없는 자신만의 독립된 메모리 공간(isolated memory)을 가집니다. 태스크 간의 정보 교환은 명시적인 메시지(message)를 서로 주고받는 방식으로만 이루어집니다.
이 모델은 우편 시스템에 비유할 수 있습니다.
- 각 태스크는 자신만의 주소를 가진 집(독립된 메모리)과 같습니다.
- 다른 태스크와 통신하려면, 편지(메시지)를 써서 상대방의 주소로 보내야 합니다.
- 수신자는 자신의 우편함(메시지 큐)을 확인하여 편지를 받아 내용을 확인합니다.
이 방식은 태스크들이 서로의 내부 상태를 직접 수정하는 것을 원천적으로 차단하므로, 공유 메모리 모델의 고질적인 문제였던 경쟁 상태가 발생하지 않습니다.
통신 방식
메시지 전달은 주로 두 가지 방식으로 이루어집니다.
-
동기식 전달 (Synchronous/Blocking): 송신 태스크가 메시지를 보낸 후, 수신 태스크가 메시지를 받을 때까지 기다립니다. 이는 마치 등기 우편을 보내고 상대방이 수령했다는 확인을 받을 때까지 기다리는 것과 같습니다. 이 방식은 두 태스크의 실행 시점을 맞추는 강력한 동기화 메커니즘으로 작용합니다.
-
비동기식 전달 (Asynchronous/Non-blocking): 송신 태스크는 메시지를 보낸 직후, 수신 여부와 상관없이 즉시 자신의 다음 작업을 계속 수행합니다. 이는 일반 우편을 우체통에 넣고 바로 자기 갈 길을 가는 것과 같습니다.
장점과 단점
- 장점:
- 안전성: 공유 상태가 없으므로 경쟁 상태가 근본적으로 방지됩니다.
- 명확성: 데이터의 흐름이 메시지 전달 경로를 통해 명확하게 드러나 코드의 의도를 파악하기 쉽습니다.
- 확장성: 태스크 간의 물리적 위치에 구애받지 않으므로, 단일 시스템을 넘어 네트워크로 연결된 분산 시스템으로 확장하기 용이합니다.
- 단점:
- 성능 부하: 메시지를 생성하고 복사하여 전달하는 과정에서 공유 메모리 직접 접근 방식보다 추가적인 비용이 발생할 수 있습니다.
- 교착 상태: 동기식 전달 방식에서 두 태스크가 서로에게 메시지를 보내고 받기를 기다리는 경우 교착 상태에 빠질 수 있습니다.
메시지 전달 모델은 동시성 코드를 훨씬 더 안전하고 예측 가능하게 만들어 줍니다. Ada의 랑데부(rendezvous) 메커니즘은 바로 이 메시지 전달 모델에 깊은 뿌리를 두고 있으며, 언어 차원에서 안전한 동기식 통신을 지원합니다.
13.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를 독보적인 언어로 만들어 줍니다. 이어지는 장들에서 이 두 가지 강력한 메커니즘을 상세히 배우게 될 것입니다.
13.3 동시성 프로그래밍의 도전 과제
동시성 프로그래밍은 시스템의 성능과 응답성을 크게 향상시키는 강력한 도구이지만, 그 이면에는 순차적 프로그래밍에서는 찾아볼 수 없는 독특하고 미묘한 문제들이 존재합니다. 이러한 문제들은 여러 태스크의 실행 순서가 운영체제 스케줄러에 의해 비결정적(non-deterministic)으로 정해지기 때문에 발생하며, 재현하고 디버깅하기가 매우 까다롭습니다.
견고한 동시성 시스템을 구축하기 위해서는 이러한 잠재적 위험들을 명확히 이해하고, 이를 예방할 수 있는 설계 기법을 숙지하는 것이 필수적입니다. 이 절에서는 동시성 프로그래밍에서 가장 빈번하게 발생하는 네 가지 주요 도전 과제, 즉 경쟁 상태, 교착 상태, 활성 잠금, 그리고 기아 상태에 대해 알아봅니다.
이러한 문제들을 이해하는 것은 단순히 위험을 인지하는 것을 넘어, Ada가 제공하는 태스크, 보호 객체, 랑데부와 같은 고수준 동시성 기능들이 왜 그렇게 설계되었는지를 깊이 있게 이해하는 기반이 될 것입니다. Ada는 바로 이러한 고질적인 문제들을 언어 차원에서 체계적으로 해결하는 것을 목표로 합니다.
13.3.1 경쟁 상태 (race conditions)
경쟁 상태(Race Condition)는 동시성 프로그래밍에서 가장 기본적이고 흔하게 발생하는 문제입니다. 이는 둘 이상의 태스크가 공유된 자원(shared resource)에 접근하여 조작할 때, 그 실행 순서나 타이밍에 따라 시스템의 최종 결과가 달라지는 상황을 말합니다. 말 그대로 태스크들이 자원을 차지하기 위해 “경쟁”하는 상태이며, 누가 이기느냐에 따라 결과가 예측 불가능하게 바뀝니다.
경쟁 상태는 일반적으로 다음 세 가지 조건이 모두 충족될 때 발생합니다.
- 두 개 이상의 태스크가 존재한다.
- 하나 이상의 공유 자원(예: 변수, 메모리, 파일)에 접근한다.
- 적어도 하나의 태스크가 자원을 수정(write)한다.
전형적인 예시: 읽기-수정-쓰기 (Read-Modify-Write)
가장 전형적인 경쟁 상태는 ‘읽기-수정-쓰기’ 연산에서 나타납니다. shared_counter := shared_counter + 1;
과 같은 단순해 보이는 한 줄의 코드도, 저수준에서는 다음과 같은 여러 단계로 나뉩니다.
- 메모리에서
shared_counter
의 현재 값을 읽어온다(Read). - 읽어온 값을 1 수정(Modify)한다.
- 새로운 값을 다시 메모리의
shared_counter
위치에 쓴다(Write).
이제 두 개의 태스크가 shared_counter
가 10
일 때 이 연산을 동시에 수행한다고 가정해 보겠습니다. 우리가 기대하는 최종값은 12
입니다.
잘못된 실행 순서 (경쟁 상태 발생):
태스크 A
가shared_counter
의 값10
을 읽습니다.- (문맥 전환 발생)
태스크 B
가shared_counter
의 값10
을 읽습니다.태스크 B
는10 + 1
을 계산하여11
을 얻고, 그 결과를shared_counter
에 씁니다. (현재shared_counter
는11
)- (문맥 전환 발생)
태스크 A
는 이전에 읽었던10
을 기반으로10 + 1
을 계산하여11
을 얻고, 그 결과를shared_counter
에 씁니다.- 최종 결과:
shared_counter
는 기대했던12
가 아닌11
이 됩니다. 갱신 연산 한 번이 소실된 것입니다.
임계 구역과 상호 배제
이처럼 공유 자원에 접근하여 경쟁 상태를 일으킬 수 있는 코드 영역을 임계 구역(Critical Section)이라고 합니다. 경쟁 상태를 해결하기 위한 유일한 방법은 이 임계 구역에 대해 상호 배제(Mutual Exclusion)를 보장하는 것입니다. 즉, 한 태스크가 임계 구역을 실행하고 있을 때는 다른 태스크가 절대 해당 구역에 진입할 수 없도록 막아야 합니다.
경쟁 상태는 비결정적인 특성 때문에 때로는 정상 동작하다가 아주 드물게 실패하여 찾아내기 매우 어려운 버그를 만듭니다. Ada의 보호 객체(Protected Object)는 바로 이러한 임계 구역을 안전하게 관리하고 상호 배제를 언어 차원에서 자동으로 보장하여, 경쟁 상태의 위험을 원천적으로 제거하도록 설계되었습니다.
13.3.2 교착 상태 (deadlocks)
교착 상태(Deadlock)는 경쟁 상태와 더불어 동시성 시스템에서 가장 경계해야 할 문제 중 하나입니다. 이는 두 개 이상의 태스크가 서로가 점유하고 있는 자원을 기다리며 더 이상 진행하지 못하고 영원히 멈춰 버리는 상황을 의미합니다. 옴짝달싹 못 하는 교착 상태에 빠진 태스크들은 시스템 자원을 점유한 채로 아무런 작업도 수행하지 못하므로, 시스템 전체의 성능 저하 또는 완전한 중단을 초래할 수 있습니다.
이 상황은 좁은 외나무다리 양 끝에서 두 사람이 마주친 상황에 비유할 수 있습니다 🤝.
- 각 사람은 다른 사람이 비켜주기만을 기다립니다.
- 아무도 양보하지 않으면(자원을 해제하지 않으면), 두 사람 모두 영원히 다리를 건너지 못합니다.
교착 상태의 발생 조건
교착 상태는 다음의 네 가지 조건(Coffman conditions)이 동시에 모두 충족될 때만 발생합니다. 하나라도 충족되지 않으면 교착 상태는 일어나지 않습니다.
- 상호 배제 (Mutual Exclusion): 최소한 하나의 자원은 한 번에 하나의 태스크만 사용할 수 있는 비공유 상태여야 합니다. (다리는 한 번에 한 사람만 지나갈 수 있습니다.)
- 점유 및 대기 (Hold and Wait): 태스크가 최소한 하나의 자원을 점유한 상태에서, 다른 태스크에 할당된 자원을 추가로 얻기 위해 대기해야 합니다. (각 사람은 다리 위 자신의 공간을 차지한 채, 상대방의 공간을 요구하며 기다립니다.)
- 비선점 (No Preemption): 다른 태스크가 점유한 자원을 강제로 빼앗을 수 없으며, 자원을 점유한 태스크가 자발적으로 해제해야만 합니다. (상대방을 밀쳐낼 수 없고, 스스로 비켜주기만 기다려야 합니다.)
- 순환 대기 (Circular Wait): 태스크들의 대기 관계가 원형을 이루어야 합니다. 즉,
태스크 1
은태스크 2
가 가진 자원을 기다리고,태스크 2
는태스크 1
이 가진 자원을 기다리는 형태의 순환 고리가 형성되어야 합니다.
코드 예시
두 개의 잠금(lock) 자원 Lock_A
와 Lock_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_1
이 Lock_A
를 획득하고 Task_2
가 Lock_B
를 획득한 상태에서, 서로가 상대방의 잠금을 획득하려고 시도하면 순환 대기 조건이 만족되어 두 태스크 모두 영원히 대기하게 됩니다.
교착 상태를 예방하는 가장 일반적인 방법은 네 가지 조건 중 하나를 깨뜨리는 것이며, 특히 자원 획득 순서를 모든 태스크에서 동일하게 강제하여 순환 대기 조건을 원천적으로 차단하는 전략이 널리 사용됩니다. Ada는 우선순위 상한 프로토콜(Priority Ceiling Protocol)과 같은 고급 메커니즘을 제공하여 특정 조건 하에서 교착 상태를 방지하는 데 도움을 줍니다.
13.3.3 활성 잠금 (livelocks)
활성 잠금(Livelock)은 교착 상태와 유사하지만, 태스크들이 멈춰있는(blocked) 대신 계속해서 상태를 바꾸며 서로에게 반응하지만, 결과적으로 어떤 유용한 작업도 진행하지 못하는 상태를 말합니다. 즉, 태스크들은 활발하게(live) 움직이지만, 특정 상태에 갇혀(locked) 앞으로 나아가지 못합니다.
교착 상태와의 가장 큰 차이점은 CPU 사용 여부입니다.
- 교착 상태 (Deadlock): 태스크들이 대기 상태에 빠져 CPU를 소모하지 않습니다.
- 활성 잠금 (Livelock): 태스크들이 계속 실행 상태이므로 CPU를 소모하지만, 실질적인 진전이 없습니다.
가장 좋은 비유는 좁은 복도에서 마주친 두 명의 예의 바른 사람입니다 🕺💃.
- 두 사람이 마주치자, 서로 길을 비켜주기 위해 동시에 왼쪽으로 움직입니다.
- 여전히 길이 막힌 것을 확인하고, 이번에는 동시에 오른쪽으로 움직입니다.
- 이 과정을 무한히 반복합니다. 두 사람은 계속해서 움직이지만(live), 서로를 지나치지는 못합니다(locked).
발생 원인과 해결책
활성 잠금은 주로 교착 상태를 회피하려는 어설픈 알고리즘에서 발생합니다. 예를 들어, 여러 태스크가 자원을 획득하지 못하면, 자신이 가진 자원을 일단 해제하고 처음부터 다시 시도하도록 로직을 구현했다고 가정해 봅시다. 만약 두 태스크가 이 로직을 동시에 수행하면, 서로 자원을 획득했다가 해제하는 과정을 끊임없이 반복하며 활성 잠금에 빠질 수 있습니다.
개념적 예시:
태스크 A
가자원 1
을 획득합니다.태스크 B
가자원 2
를 획득합니다.태스크 A
가자원 2
를 시도하지만 실패하자, 가진자원 1
을 포기하고 재시도합니다.태스크 B
가자원 1
을 시도하지만 실패하자, 가진자원 2
를 포기하고 재시도합니다.- 이 과정이 잘못된 타이밍으로 계속 반복될 수 있습니다.
활성 잠금을 해결하는 가장 일반적인 방법은 이 반복되는 대칭 구조를 깨는 것입니다. 주로 무작위성(randomness)을 도입하여 해결합니다. 복도 비유에서 한 사람이 임의의 시간(예: 1초)을 기다린 후 움직이면, 두 사람의 움직임이 엇갈리게 되어 결국 길을 지나갈 수 있게 됩니다. 코드에서는 자원 획득을 재시도하기 전에 임의의 시간 동안 대기(randomized backoff)하도록 하여, 태스크들이 동일한 패턴으로 충돌하는 것을 방지할 수 있습니다.
Ada의 고수준 동시성 모델은 개발자가 이러한 저수준의 교착 회피 로직을 직접 구현하기보다, 보호 객체나 우선순위 상한 프로토콜과 같이 처음부터 문제가 발생하지 않도록 구조화된 설계를 장려함으로써 활성 잠금의 발생 가능성을 줄여줍니다.
13.3.4 기아 상태 (starvation)
기아 상태(Starvation)는 특정 태스크가 실행될 수 있는 상태임에도 불구하고, 스케줄러나 다른 태스크들의 자원 사용 패턴 때문에 CPU 시간이나 필요한 자원을 영원히 또는 매우 오랜 기간 할당받지 못하는 현상을 말합니다.
교착 상태나 활성 잠금과 달리, 기아 상태에서는 시스템 전체는 정상적으로 동작하며 다른 태스크들은 작업을 잘 수행하고 있을 수 있습니다. 문제는 특정 태스크만 소외되어 “굶주리는(starving)” 것입니다. 이는 동시성 시스템의 공정성(fairness)과 관련된 문제입니다.
이 현상은 신호등 없는 교차로에 비유할 수 있습니다 🚦.
- 차량이 많은 주도로(high-priority tasks)는 계속해서 차가 지나갑니다.
- 갓길(low-priority task)에 있는 차는 주도로에 진입할 기회를 계속 엿보지만, 틈이 나지 않아 영원히 기다릴 수도 있습니다.
- 주도로의 교통 흐름은 원활하지만, 갓길의 차는 기아 상태에 빠진 것입니다.
주요 발생 원인
- 엄격한 우선순위 스케줄링: 가장 흔한 원인입니다. 만약 우선순위가 높은 태스크들이 끊임없이 시스템에 도착하여 실행된다면, 우선순위가 낮은 태스크들은 실행될 기회를 전혀 얻지 못할 수 있습니다.
- 불공정한 자원 관리: 자원 잠금(lock) 메커니즘이 요청 순서(FIFO)를 보장하지 않고, 무작위나 다른 기준으로 다음 태스크를 선택할 경우, 운이 없는 특정 태스크는 계속해서 자원 획득에 실패하고 기아 상태에 빠질 수 있습니다.
해결 방안
기아 상태를 해결하기 위한 핵심은 스케줄링과 자원 할당의 공정성을 높이는 것입니다.
- 우선순위 노화 (Priority Aging): 오랫동안 대기한 태스크의 우선순위를 시간이 지남에 따라 점차 높여주는 기법입니다. 이를 통해 아무리 우선순위가 낮은 태스크라도 결국에는 실행될 기회를 보장받게 됩니다.
- 공정한 큐잉 (Fair Queuing): 자원을 기다리는 태스크들을 선입선출(FIFO) 큐로 관리하여, 가장 오래 기다린 태스크에게 먼저 자원을 할당하는 방식입니다.
Ada는 이러한 문제에 대한 해결책을 언어 차원에서 제공합니다. 예를 들어, 보호 객체의 엔트리(entry)는 기본적으로 태스크들을 공정한 FIFO 큐로 관리합니다. 또한 실시간 부록(Real-Time Annex)에서 제공하는 다양한 스케줄링 정책(FIFO_Within_Priorities
등)을 통해 개발자는 시스템의 요구사항에 맞게 공정성을 조절하여 특정 태스크가 무한정 대기하는 기아 상태를 방지할 수 있습니다.
14. 태스크(task) - Ada 동시성의 기본 단위
이전 12장에서는 동시성의 개념과 그 모델, 그리고 경쟁 상태나 교착 상태와 같은 도전 과제들을 살펴보았습니다. 이제 우리는 이러한 이론적 배경을 바탕으로, Ada가 동시성을 구현하는 핵심 요소인 태스크(Task)에 대해 본격적으로 학습할 것입니다.
다른 많은 언어들이 라이브러리 형태로 스레드(thread)를 제공하는 것과 달리, Ada의 태스크는 언어 자체에 내장된 일급 시민(first-class citizen)입니다. 이는 태스크의 생성, 실행, 통신 및 종료에 이르는 모든 과정이 언어의 문법과 규칙에 의해 명확하게 정의되고 관리됨을 의미합니다. 이러한 접근 방식은 동시성 프로그램을 더욱 구조적이고 예측 가능하며, 무엇보다도 안전하게 만듭니다.
태스크는 독립적인 실행 흐름을 나타내는 Ada 동시성의 기본 단위입니다. 각 태스크는 자신만의 생명주기를 가지며 다른 태스크와 병행하여 작업을 수행할 수 있습니다. 본질적으로 하나의 작은 독립 프로그램처럼 동작한다고 생각할 수 있습니다.
이번 장에서는 먼저 태스크의 기본 개념과 종류를 알아보고, 태스크가 생성되고, 활성화되며, 실행을 마친 후 종료되기까지의 생명주기를 상세히 추적할 것입니다. 또한, 태스크의 명세(specification)와 몸체(body)를 선언하고 정의하는 구체적인 구문과 규칙을 배웁니다. 마지막으로 태스크가 언제 실행을 시작하고, 어떻게 정상적으로 혹은 비정상적으로 종료되는지에 대한 명확한 규칙을 익히게 될 것입니다.
이 장을 통해 독자 여러분은 Ada 태스크를 독립적인 실행 단위로서 완벽히 이해하게 될 것입니다. 이는 다음 장에서 다룰 태스크 간의 통신과 동기화, 즉 랑데부(rendezvous)를 이해하기 위한 필수적인 초석이 될 것입니다.
14.1 태스크의 개념
Ada에서 동시성을 이해하는 여정은 그 기본 구성 요소인 태스크(task)의 개념을 파악하는 것에서 시작합니다. 태스크는 단순히 병렬로 실행되는 코드 조각이 아니라, 그 자체로 명확한 구조와 규칙을 갖는 정교한 언어적 구성 요소입니다. 패키지나 프로시저처럼, 태스크도 명세와 몸체를 가지며 캡슐화와 추상화의 원칙을 따릅니다.
이 절에서는 태스크의 본질을 깊이 있게 탐구합니다. 먼저 태스크가 무엇인지 명확히 정의하고, 프로그램 내에서 독립적인 실행 흐름으로서 어떤 역할을 하는지 살펴볼 것입니다. 이어서, 여러 개의 유사한 태스크를 생성하기 위한 템플릿인 태스크 타입(task type)과, 유일무이한 단일 실행 단위인 단일 태스크(single task)의 차이점을 배웁니다. 마지막으로, 모든 태스크가 거치는 생성, 활성화, 실행, 종료의 4단계로 이루어진 생명주기를 이해함으로써 태스크의 동적인 동작 방식을 파악하게 될 것입니다.
14.1.1 태스크란 무엇인가?
태스크(Task)는 프로그램 내에서 다른 코드와 독립적으로, 그리고 동시에(concurrently) 실행될 수 있는 하나의 실행 흐름(flow of control)을 나타내는 Ada의 언어적 구성 요소입니다. 각 태스크는 자신만의 스택(stack)과 상태를 가지며, 기본적으로 프로그램 내의 작은 자율적인 하위 프로그램처럼 동작합니다.
프로시저나 패키지와 같은 다른 프로그램 단위와의 가장 큰 차이점은 태스크가 능동적인(active) 개체라는 점입니다.
- 수동적 객체 (Passive Object): 프로시저나 함수는 호출될 때만 실행됩니다.
- 능동적 객체 (Active Object): 태스크는 일단 활성화되면, 누가 명시적으로 호출하지 않아도 스스로 자신의 코드 실행을 시작하고 부모 코드(master)와 병행하여 작업을 수행합니다.
이는 마치 주방장이 요리를 하면서 동시에 재료를 준비할 보조 요리사를 고용하는 것과 같습니다 👨🍳. 주방장(메인 프로그램)이 보조 요리사(태스크)에게 할 일을 알려주면, 보조 요리사는 주방장의 작업과 동시에 자신의 일을 자율적으로 처리하기 시작합니다.
모든 태스크는 다른 프로그램 단위와 마찬가지로 두 부분으로 구성됩니다.
- 태스크 명세 (Task Specification): 태스크의 공개적인 인터페이스입니다. 다른 태스크와 통신하기 위한 접점인 엔트리(entry)를 선언하는 곳입니다. 외부와의 상호작용이 없는 태스크라면 명세가 비어있을 수도 있습니다.
- 태스크 몸체 (Task Body): 태스크가 실제로 수행할 코드의 연속입니다. 태스크가 활성화되면 이 몸체에 정의된 문장들이 순차적으로 실행됩니다.
요약하자면, 태스크는 Ada가 제공하는 자율적인 작업 단위로, 언어 차원에서 그 생명주기와 상호작용이 관리되어 개발자가 저수준의 스레드 관리에 신경 쓰지 않고 동시성 로직 자체에 집중할 수 있도록 돕습니다.
14.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는 풀고자 하는 문제의 성격에 맞춰 동시성 단위를 유연하게 모델링할 수 있는 강력한 기능을 제공합니다.
14.1.3 태스크의 생명주기 (생성, 활성화, 실행, 종료)
모든 Ada 태스크는 예측 가능하고 명확하게 정의된 생명주기(lifecycle)를 따릅니다. 이 구조화된 생명주기는 태스크가 안전하게 시작되고 소멸되도록 보장하여, 많은 동시성 오류를 원천적으로 방지합니다. 생명주기는 생성, 활성화, 실행, 종료의 네 가지 주요 단계로 구성됩니다.
(이 과정은 상태 전이 다이어그램(state transition diagram)으로 시각화하면 이해하기 더욱 쉽습니다.)
1. 생성 (Creation)
- 무엇인가? 태스크 객체가 선언되고 관련 메모리가 할당되는 단계입니다. 이 시점에서 태스크 객체는 존재하지만, 아직 독립적인 실행 흐름을 시작하지는 않은 휴면 상태입니다.
- 언제? 태스크가 선언된 범위(scope)의 선언부가 처리될 때(elaborated) 발생합니다.
2. 활성화 (Activation)
- 무엇인가? 태스크가 실행될 준비를 마치고, 본격적인 실행에 앞서 내부 초기화를 수행하는 단계입니다. 태스크 몸체의
is
와begin
사이에 있는 선언부가 이때 실행됩니다. 활성화가 끝나면 태스크는 실행 가능한(runnable) 상태가 되어 스케줄러의 선택을 기다립니다. - 언제? 활성화 시점은 Ada의 안정성을 보장하는 핵심 규칙 중 하나입니다. 태스크는 자신을 포함하는 부모(master) 단위의 선언부 실행이 모두 끝난 직후, 그리고 부모 단위의
begin
뒤 실행문이 시작되기 전에 활성화됩니다. 이는 태스크가 실행을 시작하기 전에 필요한 모든 환경이 준비되었음을 보장합니다.
3. 실행 (Execution)
- 무엇인가? 태스크의 실제 작업이 이루어지는 단계입니다. 태스크는 스케줄러에 의해 CPU 시간을 할당받아, 태스크 몸체의
begin
과end
사이의 문장들을 다른 태스크들과 병행하여 실행합니다. - 언제? 활성화 이후, 스케줄러의 정책과 태스크의 우선순위에 따라 실행됩니다.
4. 종료 (Termination)
- 무엇인가? 태스크가 모든 실행을 마치고 시스템에서 사라지는 마지막 단계입니다.
- 언제? 태스크의 실행이 몸체의 마지막
end
에 도달하면, 태스크는 완료(completed) 상태가 됩니다. 하지만 즉시 사라지지는 않습니다. 태스크는 자신이 만든 모든 자식 태스크(child task)들이 종료될 때까지 기다려야 합니다. 그 후, 자신을 포함하는 부모(master) 단위가 종료될 준비가 되었을 때 비로소 종료(terminated)됩니다. 이 의존성 규칙은 부모 스코프가 아직 실행 중인 자식 태스크를 남겨두고 먼저 사라지는 위험한 상황을 방지합니다.
생명주기 요약
단계 | 이름 | 설명 | 트리거 |
---|---|---|---|
1 | 생성 | 태스크 객체의 메모리 할당 | 태스크 선언부 처리 시 |
2 | 활성화 | 실행 준비 및 내부 초기화 | 부모(Master)의 선언부 종료 직후 |
3 | 실행 | 태스크 몸체의 주 로직 실행 | 스케줄러에 의한 CPU 할당 |
4.1 | 완료 | 태스크 몸체의 실행을 마침 | 태스크 몸체의 마지막 end 도달 |
4.2 | 종료 | 태스크의 완전한 소멸 | 완료 후, 자식/부모와의 의존성 해결 시 |
이처럼 엄격하게 관리되는 생명주기는 Ada 동시성 프로그래밍의 신뢰성을 뒷받침하는 핵심적인 특징입니다.
14.2 태스크 선언 및 정의
앞 절에서 태스크의 개념과 생명주기를 이해했다면, 이제는 이 개념들을 실제 Ada 코드로 구현하는 방법을 배울 차례입니다. 이 절에서는 태스크를 선언하고 그 동작을 정의하는 구체적인 구문(syntax)을 다룹니다.
Ada의 다른 주요 구조(패키지, 서브프로그램 등)와 마찬가지로, 태스크 역시 명세(specification)와 몸체(body)의 분리 원칙을 따릅니다. 이 구조는 태스크의 공개 인터페이스와 내부 구현을 명확하게 분리하여 코드의 가독성과 모듈성을 높입니다.
- 태스크 명세는 태스크의 ‘얼굴’로서, 다른 태스크와 상호작용할 수 있는 통신 지점, 즉 엔트리(entry)들을 정의합니다.
- 태스크 몸체는 태스크가 실제로 수행할 코드의 집합으로, 그 내부 로직을 담고 있습니다.
이 절의 각 소절을 통해 태스크 타입을 선언하는 방법, 태스크 객체를 생성하는 방법, 그리고 명세와 몸체를 올바르게 작성하는 규칙을 상세하게 학습할 것입니다.
14.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)이나 동적 태스크 생성과 같이 유연하고 확장 가능한 동시성 패턴을 구현할 수 있습니다.
14.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_1
과 handler_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;
이처럼 태스크 객체 선언은 정적인 배열부터 동적인 할당에 이르기까지, 일반적인 데이터 타입과 동일한 유연성을 제공하여 다양한 동시성 아키텍처를 효과적으로 구축할 수 있게 합니다.
14.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
라는 태스크가 store
와 retrieve
라는 두 가지 동기화된 서비스를 제공함을 외부 세계에 약속합니다.
태스크 몸체 (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장에서 자세히 다룹니다.)
이처럼 명세와 몸체의 분리는 태스크의 내부 구현 방식을 변경하더라도, 명세(공개 계약)만 동일하다면 태스크를 사용하는 외부 코드에 전혀 영향을 주지 않도록 보장합니다. 이는 대규모 동시성 시스템의 유지보수와 개발에 있어 매우 중요한 장점입니다.
14.3 태스크 활성화와 종료
태스크의 정적인 구조를 선언하고 정의하는 방법을 배웠으니, 이제는 태스크의 동적인 생명주기, 즉 태스크가 어떻게 ‘생명을 얻고’ 또 어떻게 ‘생을 마감하는지’에 대한 규칙을 알아볼 차례입니다.
Ada는 태스크의 활성화(activation)와 종료(termination)에 대해 매우 명확하고 엄격한 규칙을 적용합니다. 이러한 규칙들은 동시성 시스템의 예측 가능성을 보장하고, 자원 누수나 부모 객체의 조기 소멸과 같은 고질적인 오류들을 방지하기 위해 신중하게 설계되었습니다.
이 절에서는 태스크가 정확히 어느 시점에 실행을 시작하는지, 그리고 정상적인 상황과 예외적인 상황에서 어떻게 안전하게 종료되는지를 다룹니다. 이 규칙들을 이해하는 것은 올바른 동시성 프로그램을 작성하기 위한 필수적인 지식입니다.
14.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가 종료될 때까지 기다립니다.
흐름 요약:
My_Worker
태스크 객체가 생성됩니다. 아직 실행되지는 않습니다.- 부모 프로시저의 선언부(
is
와begin
사이)가 모두 처리된 후,begin
에 이르렀을 때가 활성화 지점입니다. My_Worker
의 활성화가 시작되고(태스크 몸체의 선언부 코드가 실행됨), 이와 동시에 부모 프로시저의begin
이하 코드도 실행을 시작합니다.- 이제
My_Worker
와Test_Activation
프로시저는 각자의 길을 가는 두 개의 병행 실행 흐름이 됩니다.
규칙의 존재 이유
이 규칙은 안전성을 위한 핵심 장치입니다. 태스크가 자신의 주변 환경(부모에 선언된 변수나 상수 등)에 의존하는 경우, 그 환경이 완전히 초기화된 이후에 태스크가 실행을 시작하도록 보장합니다. 이는 불안정한 상태에서 태스크가 실행되어 발생하는 미묘한 초기화 오류를 원천적으로 차단합니다.
활성화 실패
만약 태스크의 활성화 과정(태스크 몸체의 선언부 실행) 중 예외가 발생하면, 해당 태스크는 즉시 종료(Terminated) 상태가 됩니다. 그리고 부모 단위의 활성화 지점에서 Tasking_Error
예외가 발생하여, 시스템이 비정상적인 상황을 인지하고 대처할 수 있도록 합니다.
14.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
대안은 오직 부모가 종료되기를 기다리는 상황일 때만 선택될 수 있습니다. 이는 시스템 전체가 종료 수순에 들어갔을 때, 서버 태스크가 스스로 루프를 빠져나와 우아하게 종료될 수 있는 협력적인 메커니즘을 제공합니다.
14.3.3 abort
문을 이용한 비정상적 종료
정상적인 종료가 태스크 간의 협력적인 과정이라면, abort
문은 다른 태스크를 강제적으로, 그리고 즉시 중단시키는 비협력적인 명령입니다. 이는 시스템의 비상 브레이크 🚨 와 같아서, 반드시 필요할 때만 극도의 주의를 기울여 사용해야 하는 강력하고 위험한 기능입니다.
abort
문의 동작
abort
문은 하나 이상의 지정된 태스크를 즉시 비정상적으로 완료(abnormally completed) 상태로 만듭니다.
abort Worker_1, Worker_Pool (3);
abort
가 실행되면, 대상 태스크는 현재 수행하던 작업이 무엇이든 그 자리에서 즉시 멈춥니다.
- 실행 중이던 문장을 완료하지 않습니다.
- 예외 처리기(
exception
핸들러)나 최종 처리기(finally
블록 등)가 실행될 기회를 갖지 못합니다. - 태스크는 즉시 ‘완료’ 상태가 되고, 정상 종료와 마찬가지로 종료 규칙(자식 태스크 대기 등)에 따라 최종적으로 ‘종료’됩니다.
핵심적인 차이는 ‘완료’ 상태에 도달하는 방식입니다. 정상 종료는 스스로의 작업을 마치는 것이고, 비정상적 종료는 외부의 강제 명령에 의해 실행이 단절되는 것입니다.
abort
사용의 위험성
abort
는 태스크가 스스로 뒷정리를 할 기회를 박탈하므로, 심각한 부작용을 초래할 수 있습니다.
- 자원 누수 (Resource Leaks): 태스크가 잠금(lock), 파일, 네트워크 연결 등의 자원을 획득한 상태에서
abort
되면, 이 자원들을 해제하는 코드가 실행되지 않아 자원이 영원히 잠기거나 유출될 수 있습니다. 이는 다른 태스크의 교착 상태나 시스템 전체의 불안정성으로 이어집니다. - 데이터 불일치 (Data Inconsistency): 공유 데이터 구조를 여러 단계에 걸쳐 수정하는 도중에 태스크가
abort
되면, 해당 데이터는 불완전하고 오염된 상태로 남게 됩니다. - 예측 불가능성: 시스템의 일부가 예측 불가능한 상태에 빠지게 되어, 전체 프로그램의 신뢰성을 심각하게 훼손합니다.
abort
문은 태스크가 제어 불능의 무한 루프에 빠지는 등, 다른 어떤 방법으로도 멈출 수 없는 명백한 오류 상태에 있을 때만 고려해야 하는 최후의 수단입니다.
훌륭한 동시성 설계는 shutdown
엔트리를 만들어 태스크가 협력적으로 종료하도록 유도하는 등, abort
에 의존하지 않고도 시스템을 제어할 수 있는 메커니즘을 갖추어야 합니다. Ada의 안전한 동시성 모델의 장점을 최대한 활용하기 위해서는 abort
문의 사용을 가급적 피하는 것이 바람직합니다.
15. 태스크 간 통신과 동기화: 랑데부(rendezvous)
이전 13장에서는 Ada 동시성의 기본 단위인 태스크를 생성하고, 그 생명주기를 관리하는 방법을 배웠습니다. 하지만 독립적으로 실행되는 태스크들만으로는 복잡한 협력 작업을 수행할 수 없습니다. 진정한 동시성 시스템의 힘은 태스크들이 서로 통신(communication)하고, 작업 순서를 맞추며 동기화(synchronization)할 때 발휘됩니다.
Ada는 태스크 간의 직접적이고 안전한 상호작용을 위해 랑데부(Rendezvous)라는 우아하고 강력한 메커니즘을 제공합니다. ‘랑데부’는 프랑스어로 ‘만남’을 의미하며, 이름 그대로 두 태스크가 약속된 지점에서 만나 데이터를 교환하고 실행을 동기화하는 과정을 완벽하게 표현합니다.
이 모델은 데이터를 공유 메모리에 두고 잠금(lock)으로 제어하는 복잡하고 오류가 발생하기 쉬운 방식 대신, 명시적인 메시지 전달 방식을 언어 차원에서 구현한 것입니다. 하나의 태스크(호출자)가 다른 태스크(제공자)의 특정 서비스(entry)를 호출하면, 두 태스크는 랑데부 지점에서 동기화됩니다. 이 ‘만남’ 동안 안전하게 정보가 교환되고, 만남이 끝나면 두 태스크는 다시 각자의 길을 갑니다.
이번 장에서는 랑데부를 구성하는 핵심 요소인 entry, accept, 그리고 select 구문을 상세히 배울 것입니다. 먼저 랑데부의 기본 개념과 동작 방식을 이해하고, when 조건절(가드)을 통해 엔트리 호출을 선택적으로 수락하는 방법을 학습합니다. 마지막으로, 시간제한(timed) 및 조건부(conditional) 호출과 같은 고급 통신 패턴을 구현하는 select 문의 다양한 활용법을 익히게 될 것입니다.
이 장을 통해 독자 여러분은 Ada 태스크들이 어떻게 안전하고 구조적으로 협력하는지를 이해하고, 신뢰성 높은 동시성 시스템의 핵심 상호작용을 구현할 수 있게 될 것입니다.
15.1 랑데부의 개념
태스크 간의 통신을 이해하기 위한 첫걸음은 랑데부(Rendezvous)의 기본 개념과 그 구성 요소를 파악하는 것입니다. 랑데부는 단순한 데이터 교환을 넘어, 두 태스크의 실행 흐름을 일시적으로 하나로 묶는 강력한 동기화(synchronization) 메커니즘입니다. 이 동기화된 ‘만남’을 통해 데이터는 경쟁 상태 없이 안전하게 전달됩니다.
이 절에서는 랑데부를 구성하는 네 가지 핵심 요소를 순서대로 학습합니다. 먼저 랑데부의 전체적인 동작 방식을 명확히 정의하고, 이어서 랑데부의 ‘약속 장소’에 해당하는 엔트리(entry) 선언, 약속 장소에서 호출을 기다리는 accept 문, 그리고 다른 태스크에게 만남을 요청하는 엔트리 호출에 대해 구체적으로 알아볼 것입니다. 이 구성 요소들을 이해하면 Ada의 기본적인 태스크 통신 모델을 파악할 수 있습니다.
15.1.1 랑데부란 무엇인가?
랑데부(Rendezvous)는 두 개의 태스크, 즉 서비스를 요청하는 호출자(caller)와 서비스를 제공하는 제공자(callee) 사이에서 일어나는 동기화된 상호작용입니다. 이 ‘만남’ 동안 두 태스크는 시간적으로 묶이며, 이 기회를 통해 안전하게 데이터를 교환합니다.
랑데부의 핵심은 “먼저 도착한 쪽이 다른 쪽을 기다린다”는 것입니다.
- 시나리오 1: 제공자(callee)가 먼저 도착
제공자
태스크가accept
문에 도달하여 서비스 제공 준비를 합니다.- 아직
호출자
가 없으므로,제공자
는accept
문에서 실행을 멈추고 대기(block)합니다. - 이후
호출자
가 엔트리를 호출하면, 랑데부가 시작됩니다. 🤝
- 시나리오 2: 호출자(caller)가 먼저 도착
호출자
태스크가제공자
의 엔트리를 호출합니다.제공자
가 아직accept
문에 도착하지 않았으므로,호출자
는 실행을 멈추고 해당 엔트리의 대기 큐(queue)에서 대기합니다.- 이후
제공자
가accept
문에 도착하면, 대기 중이던호출자
를 받아들여 랑데부가 시작됩니다. 🤝
랑데부의 진행 과정
일단 랑데부가 시작되면, accept
문의 do ... end
블록에 있는 코드가 실행됩니다. 이 코드는 항상 제공자
태스크에 의해 실행됩니다. 이 시간 동안 호출자
태스크는 계속 대기 상태에 머물러 있습니다. accept
블록의 실행이 모두 끝나면 랑데부가 종료되고, 비로소 두 태스크는 각자의 실행을 독립적으로 재개합니다.
이 과정은 마치 은행 창구의 고객과 은행원의 관계와 같습니다.
- 고객(호출자)은 업무를 요청하고, 은행원(제공자)은 그 요청을 처리합니다.
- 창구에 은행원이 없으면 고객은 줄을 서서 기다리고, 고객이 없으면 은행원은 기다립니다.
- 업무 처리(랑데부) 자체는 전적으로 은행원의 몫이며, 고객은 그동안 기다립니다.
- 업무가 끝나고 영수증을 주고받으면(데이터 교환), 각자 다른 일을 하러 갑니다.
이처럼 랑데부는 동기화와 통신을 하나의 개념으로 묶어, 개발자가 저수준의 잠금(lock) 없이도 태스크 간의 상호작용을 안전하고 명확하게 구현할 수 있도록 돕는 강력한 추상화 메커니즘입니다.
15.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;
이처럼 엔트리 선언은 태스크가 어떤 서비스를 제공하는지 외부 세계에 명확하게 알려주는 계약과 같습니다. 이 계약은 컴파일 시점에 타입 검사가 이루어지므로, 잘못된 데이터로 통신을 시도하는 오류를 미연에 방지할 수 있습니다. 엔트리는 오직 태스크 명세(또는 이후에 배울 보호 객체 명세)에만 선언될 수 있습니다.
15.1.3 accept
문
accept
문은 태스크 몸체(task body) 내부에서, 해당 태스크 명세에 선언된 특정 entry
로의 호출을 대기하고 수락하기 위해 사용하는 실행문입니다. accept
문은 entry
선언에 대한 구체적인 구현부 역할을 합니다.
기본 구문
accept
문은 두 가지 형태로 사용됩니다.
-
do..end
블록이 있는 형태 이 구문은 랑데부 동안 특정 연산을 수행할 때 사용됩니다.accept <Entry_Name> (formal_parameters) do -- 랑데부 구간(Rendezvous Section) -- 이 블록 안의 문장들은 랑데부 동안 실행됩니다. end <Entry_Name>;
호출자(caller) 태스크는
do
와end
사이의 블록 실행이 완료될 때까지 대기 상태를 유지합니다. 이 블록은 호출자와의 상호 배제가 보장되는 구간으로, 파라미터를 이용한 데이터 처리가 안전하게 이루어집니다. -
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
) 내부에 어떤 연산을 포함시킬지 결정하는 것은 동시성 시스템의 동작과 성능에 직접적인 영향을 미치는 설계 고려사항입니다.
15.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
으로 진행하여 다시 동일한 대기 과정에 들어갑니다.
이처럼 엔트리 호출은 프로시저 호출과 유사한 직관적인 구문을 사용하지만, 그 내부적으로는 동시성 실행 흐름을 안전하게 동기화시키는 명확한 규칙을 포함하고 있습니다.
15.2 가드(guard)를 이용한 엔트리 제어
지금까지 학습한 accept
문은 엔트리 호출이 있다면 항상 수락할 준비가 되어 있었습니다. 하지만 실제 동시성 시스템에서는 태스크의 현재 상태에 따라 특정 요청을 받아들이거나 거부해야 하는 경우가 많습니다. 예를 들어, 버퍼가 가득 찬 상태에서는 생산자의 아이템 추가(put
) 요청을 받아들여서는 안 되며, 버퍼가 비어있을 때는 소비자의 아이템 인출(get
) 요청을 받아들일 수 없습니다.
이러한 상태 종속적인(state-dependent) 제어를 위해 Ada는 가드(guard)라는 강력한 메커니즘을 제공합니다. 가드는 accept
문에 연결된 불리언(boolean) 조건으로, 이 조건이 True
일 때만 해당 엔트리를 “열고(open)” 호출을 받아들일 수 있도록 합니다. 조건이 False
이면 엔트리는 “닫힌(closed)” 상태가 되어 호출을 수락하지 않습니다.
이 절에서는 when
조건절을 사용하여 가드를 구현하는 방법과, 이를 통해 엔트리 접근을 동적으로 제어하여 시스템을 안전하고 효율적으로 만드는 기법을 배웁니다.
15.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
문의 실행은 다음 규칙을 따릅니다.
- 가드 평가:
select
문에 진입하면, 먼저 모든when
조건절의 불리언 조건(boolean condition)들을 평가합니다. - 열린 대안 식별: 조건의 결과가
True
인accept
문을 열린 대안(open alternative)이라고 합니다. 조건이False
인 경우는 닫힌 대안(closed alternative)이라고 합니다. - 대기 및 수락: 태스크는 오직 열린 대안에 해당하는 엔트리 호출만을 고려합니다.
- 열린 엔트리에 이미 대기 중인 호출이 있다면, 그중 하나를 선택하여 즉시 랑데부를 시작합니다.
- 열린 엔트리에 대기 중인 호출이 없다면, 열린 엔트리 중 어느 쪽으로든 호출이 올 때까지 대기합니다.
- 닫힌 엔트리로의 호출은 무시되며, 해당 호출자들은 계속 대기 큐에 남아있게 됩니다.
- 예외: 만약 평가 결과 모든 대안이 닫혀 있다면, 더 이상 진행할 경로가 없으므로
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;
count
가0
이면get
엔트리는 닫히고put
엔트리만 열립니다.count
가BUFFER_CAPACITY
와 같으면put
엔트리는 닫히고get
엔트리만 열립니다.count
가 그 사이의 값이면 두 엔트리 모두 열려 어느 쪽의 호출이든 받아들일 수 있습니다.
이처럼 when
조건절은 태스크의 내부 상태에 따라 서비스 제공 여부를 동적으로 제어하는 핵심적인 수단입니다.
15.2.2 엔트리 접근 제어
엔트리 접근 제어는 when
조건절을 활용하여, 태스크가 자신의 내부 상태나 특정 규칙에 따라 외부의 서비스 요청을 동적으로 허용하거나 차단하는 핵심적인 동시성 프로그래밍 기법입니다. 이 기법을 통해 태스크는 자신의 불변식(invariant)을 유지하고, 정해진 프로토콜(protocol)을 강제하며, 자원의 상태를 안전하게 관리할 수 있습니다.
주요 목적
- 상태 불변식 보장: 태스크가 항상 유효한 상태를 유지하도록 강제합니다. 예를 들어, 유한 버퍼의
count
변수가0
과CAPACITY
사이의 값을 벗어나지 않도록 보장하는 것이 이에 해당합니다. - 프로토콜 강제: 정해진 순서대로만 엔트리가 호출되도록 제어합니다. 예를 들어,
initialize
엔트리가 호출되기 전에는read
나write
엔트리를 받아들이지 않도록 강제할 수 있습니다. - 자원 가용성에 따른 제어: 특정 자원을 사용할 수 있을 때만 관련 서비스를 제공하도록 제한합니다.
구현 예시 (자원 제어 프로토콜)
초기화(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)가 제공자의 상태를 추측하거나 확인하는 코드를 작성할 필요가 없게 만들어, 시스템 전체의 설계를 더 단순하고 견고하게 만듭니다.
15.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
조건절의 동작 방식은 다음과 같습니다.
select
문에 진입 시,count > 0
조건을 단 한 번 평가합니다.- 만약 조건이
False
이면, 태스크는 루프를 도는 대신 즉시 실행이 중단(suspend)됩니다. 이 상태에서는 CPU 자원을 전혀 소모하지 않습니다. - 이후 다른 태스크(생산자)가
put
엔트리를 호출하여count
값을 변경시키는 등, 가드 조건에 영향을 줄 수 있는 이벤트가 발생하면 Ada 런타임 시스템은 중단된 태스크의 가드를 다시 평가합니다. - 가드 조건이
True
가 되면, 비로소get
엔트리가 열리고 태스크는 호출을 받아들일 준비를 합니다.
이처럼 when
조건절은 프로그래머가 저수준의 비효율적인 대기 로직을 구현할 필요 없이, “조건이 만족될 때까지 기다린다”는 의도를 선언적으로 표현할 수 있게 합니다. 실제 대기 관리는 Ada 런타임 시스템이 최적의 방식으로 처리하여, 안전하고 효율적인 동시성 시스템을 보장합니다.
15.3 select
문
select
문은 Ada 동시성 프로그래밍에서 비결정성(non-determinism)을 다루는 핵심적인 제어 구조입니다. 이 구문을 통해 태스크는 여러 개의 동시적 이벤트(예: 여러 종류의 엔트리 호출, 시간의 경과) 중 하나를 선택하여 처리할 수 있습니다. 이는 단 하나의 accept
문만으로는 구현할 수 없는 복잡하고 유연한 상호작용을 가능하게 합니다.
이번 절에서는 select
문의 다양한 형태를 체계적으로 학습합니다. 제공자 측의 선택적 accept
외에도, 호출자 측에서 사용하는 시간제한(timed) 엔트리 호출과 조건부(conditional) 엔트리 호출을 배웁니다. 또한, 즉시 실행할 수 있는 랑데부가 없을 때 대체 동작을 제공하는 else
절과 협력적 종료를 위한 terminate
대안에 대해서도 알아볼 것입니다.
15.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;
실행 규칙:
- 먼저 모든 가드(
when
조건절)를 평가하여 열린 대안(open alternative)의 집합을 결정합니다. - 열린 대안의 엔트리 큐에 이미 대기 중인 호출이 있는지 확인합니다.
- 대기 중인 호출이 있다면, 그중 하나를 임의로 선택하여 랑데부를 즉시 시작합니다.
- 대기 중인 호출이 없다면, 열린 대안 중 어느 쪽으로든 첫 번째 호출이 도착할 때까지 대기(suspend)합니다.
- 선택된
accept
문의 랑데부가 완료된 후, 만약 해당accept
문 뒤에 추가적인 실행문이 있다면 순차적으로 실행합니다. - 이후
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
는 여러 서비스 창구를 동시에 열어두고 비결정적인 요청들을 순서대로 처리하는 다중 서비스 제공자 태스크를 구현하는 표준적인 방법입니다.
15.3.2 시간제한(timed) 엔트리 호출
일반적인 엔트리 호출은 제공자(callee)가 호출을 수락할 때까지 무한정 대기합니다. 만약 제공자 태스크에 문제가 생겨 영원히 응답하지 않는다면, 호출자(caller) 태스크 역시 영원히 중단되는 교착 상태와 유사한 상황에 빠집니다.
시간제한 엔트리 호출(timed entry call)은 이러한 위험을 방지하기 위해 호출자 측에서 사용하는 select
문의 한 형태입니다. 이를 통해 호출자는 특정 시간 동안만 랑데부를 기다리고, 시간이 초과되면 호출을 포기하고 다른 동작을 수행할 수 있습니다.
기본 구문
select
<entry_call_statement>;
-- 랑데부 성공 시 실행될 문장들
or
delay <duration_expression>;
-- 시간 초과 시 실행될 문장들
end select;
<duration_expression>
:Duration
타입의 값으로, 대기할 상대적인 시간(초 단위)을 나타냅니다.
실행 규칙
select
문이 시작되면, 시스템은 엔트리 호출을 시도하는 동시에 지정된 시간만큼의 타이머를 설정합니다.- 랑데부 성공: 만약 타이머가 만료되기 전에 제공자가 호출을 수락하여 랑데부가 시작되면,
delay
대안은 즉시 취소됩니다. 랑데부가 정상적으로 완료된 후, 엔트리 호출 아래의 문장들이 실행됩니다. - 시간 초과: 만약 랑데부가 시작되기 전에 타이머가 먼저 만료되면, 시도했던 엔트리 호출은 자동으로 취소되고 해당 엔트리의 대기 큐에서 제거됩니다. 그 후,
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;
시간제한 엔트리 호출은 응답하지 않는 태스크로부터 호출자를 보호하여, 시스템의 안정성과 응답성을 높이는 필수적인 오류 처리 기법입니다.
15.3.3 조건부(conditional) 엔트리 호출
조건부 엔트리 호출(conditional entry call)은 호출자(caller)가 랑데부를 시도하되, 만약 랑데부가 즉시 시작될 수 없는 경우에는 전혀 기다리지 않고 대안 동작을 수행하고자 할 때 사용합니다. 이는 “지금 당장 서비스를 받을 수 없다면, 그냥 다른 일을 하겠다”는 의미의 비대기(non-blocking) 통신 방식입니다.
시간제한 엔트리 호출이 특정 시간만큼의 대기 의사를 표현하는 반면, 조건부 엔트리 호출은 대기 시간이 0인 것과 같습니다.
기본 구문
select
<entry_call_statement>;
-- 랑데부 성공 시 실행될 문장들
else
-- 랑데부가 즉시 불가능할 때 실행될 문장들
end select;
실행 규칙
select
문이 시작되면, 시스템은 제공자(callee)가 해당entry
에 대한accept
문에서 즉시 호출을 받아들일 준비가 되었는지 확인합니다.- 랑데부 성공: 만약 제공자가 즉시 랑데부를 시작할 수 있는 상태라면, 랑데부가 시작되고 정상적으로 완료된 후 엔트리 호출 아래의 문장들이 실행됩니다.
else
부분은 무시됩니다. - 랑데부 실패: 만약 제공자가 다른 작업을 하고 있거나 다른
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
중 하나가 응답이 늦더라도, 전체 폴링 로직이 해당 센서 때문에 중단되는 일 없이 계속해서 다른 작업을 수행할 수 있도록 보장합니다.
조건부 엔트리 호출은 이처럼 대기가 허용되지 않는 비동기적인 폴링 로직이나, 서비스의 즉각적인 가용성을 확인해야 하는 성능이 중요한 시스템을 구현할 때 유용하게 사용됩니다.
15.3.4 else
절과 terminate
대안
select
문은 accept
문 외에도 특정 조건 하에 대체 동작을 수행하는 else
절과 terminate
대안을 포함할 수 있습니다. 이 둘은 select
문의 동작 방식을 제어하는 중요한 수단이지만, 하나의 select
문 안에서 함께 사용할 수는 없습니다.
else
절
else
절은 즉시 시작할 수 있는 랑데부가 하나도 없을 때 실행될 코드 블록을 제공합니다. 이는 select
문 전체를 비대기(non-blocking) 방식으로 동작하게 만듭니다.
구문 및 동작:
select
accept Some_Entry (...);
else
-- 처리할 엔트리 호출이 즉시 없을 때 실행될 문장들
end select;
select
문에 진입 시, 열린accept
대안의 큐에 대기 중인 호출이 있는지 확인합니다.- 호출이 있다면 즉시 랑데부를 시작하고,
else
절은 무시됩니다. - 호출이 없다면 태스크는 대기하지 않고, 즉시
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
선택 조건:
- 태스크의 부모(master) 단위가 자신의 실행을 마치고 자식 태스크들이 종료하기를 기다리는 상태여야 합니다.
- 해당 부모에게 의존하는 다른 모든 형제 태스크(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
대안을 동시에 가질 수 없습니다.
15.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가 미션 크리티컬한 실시간 시스템이나 고신뢰성 서버 개발에 사용되는 중요한 이유 중 하나입니다.
16. 보호 객체(protected objects): 데이터 중심 동기화
이전 15장에서는 랑데부를 통한 태스크 간의 통신과 동기화를 학습했습니다. 랑데부는 하나의 태스크가 다른 태스크에게 특정 서비스를 요청하는 행위 중심(behavior-centric)의 상호작용에 매우 효과적인 모델입니다.
하지만 여러 태스크가 단지 공유 변수나 데이터 구조에 안전하게 접근하는 것이 목적인 경우, 별도의 서버 태스크와 랑데부를 사용하는 것은 비효율적이고 필요 이상으로 복잡한 설계가 될 수 있습니다. 이는 데이터 중심(data-centric) 동기화 문제로, Ada는 이 유형의 문제를 해결하기 위해 보호 객체(Protected Object)라는 특화되고 효율적인 메커니즘을 제공합니다.
보호 객체는 자체적인 실행 흐름이 없는 수동적인(passive) 데이터 저장소입니다. 이 객체는 보호할 데이터를 캡슐화하고, 해당 데이터에 접근하는 연산들(프로시저, 함수, 엔트리)을 함께 묶어 정의합니다. 가장 큰 특징은 상호 배제(mutual exclusion)가 언어 런타임에 의해 자동으로 보장된다는 점입니다. 개발자가 직접 잠금(lock)을 관리할 필요 없이, 여러 태스크가 보호 객체의 데이터를 수정하려 해도 경쟁 상태(race condition)가 원천적으로 방지됩니다.
이번 장에서는 랑데부 방식이 데이터 공유에 적합하지 않은 이유를 통해 보호 객체의 필요성을 알아보고, 보호 객체를 구성하는 프로시저, 함수, 엔트리의 정의와 사용법을 학습합니다. 또한, 엔트리 배리어(barrier)와 requeue
문을 이용한 고급 제어 기법까지 익히게 될 것입니다.
16.1 보호 객체의 필요성
랑데부는 태스크 간의 복잡하고 동기화된 상호작용을 모델링하는 강력한 도구이지만, 모든 동시성 문제에 대한 최적의 해결책은 아닙니다. 특히 여러 태스크가 단순히 공유 데이터에 접근하는 시나리오에서는 랑데부 모델의 본질적인 특성이 오히려 비효율과 불필요한 복잡성을 야기할 수 있습니다.
이 절에서는 먼저 데이터 공유 문제에 랑데부를 적용했을 때 발생하는 한계를 분석하여, 왜 데이터 중심 동기화를 위한 별도의 메커니즘이 필요한지를 설명합니다. 이어서 이러한 한계를 극복하기 위해 설계된 보호 객체의 핵심 개념을 소개하고, 컴퓨터 과학의 고전적인 동기화 개념인 ‘모니터(monitor)’와 비교하여 그 이론적 배경을 이해할 것입니다.
16.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
를 호출할 때마다 완전한 랑데부가 필요하므로, 이 설계는 매우 비효율적입니다. 이러한 한계점들은 랑데부와 다른, 데이터 접근에 최적화된 새로운 동기화 메커니즘의 필요성을 명확하게 보여줍니다.
16.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;
16.1.3 보호 객체와 모니터(monitor) 비교
Ada의 보호 객체는 완전히 새로운 개념이 아니라, 컴퓨터 과학의 고전적인 동시성 추상화 메커니즘인 모니터(Monitor)에 그 뿌리를 두고 있습니다. 모니터는 공유 데이터와 해당 데이터에 대한 연산, 그리고 상호 배제 규칙을 하나의 단위로 묶어 동시성 프로그래밍을 단순화하기 위해 제안되었습니다.
유사점
보호 객체와 모니터는 다음과 같은 핵심적인 특징을 공유합니다.
- 데이터 캡슐화 (Data Encapsulation): 공유 데이터를 내부에 감추고, 오직 정의된 인터페이스를 통해서만 접근을 허용합니다.
- 자동 상호 배제 (Automatic Mutual Exclusion): 객체(또는 모니터)의 연산이 실행될 때, 여러 태스크가 동시에 데이터를 수정하지 못하도록 런타임이 자동으로 상호 배제를 보장합니다.
차이점
Ada의 보호 객체는 고전적 모니터 개념을 발전시켜 몇 가지 중요한 개선점을 도입했습니다.
- 읽기-쓰기 연산의 구분:
- 모니터: 일반적으로 모든 연산을 배타적으로(read-write) 취급하여, 여러 태스크의 동시 읽기를 허용하지 않습니다.
- 보호 객체: 보호 함수(protected function)를 통해 공유된 읽기 전용(shared read-only) 접근을 명시적으로 지원합니다. 이를 통해 읽기 위주의 작업에서 월등히 높은 수준의 동시성을 허용하여 성능을 향상시킵니다.
- 조건부 대기 메커니즘:
- 모니터: 조건 변수(condition variable)와 명시적인
wait
,signal
연산을 사용합니다. 태스크는 조건이 거짓일 경우wait
를 호출하여 스스로 대기 상태에 들어가고, 다른 태스크가signal
을 호출하여 깨워주기를 기다려야 합니다. 깨어난 후에는 조건이 다시 유효한지 직접 재검사해야 하는 부담이 있습니다. - 보호 객체: 엔트리와 가드(entry and guard)를 사용합니다. 태스크는 엔트리를 호출하기만 하면 됩니다. 가드 조건이
False
이면 런타임이 태스크를 자동으로 대기 큐에 넣고 중단시킵니다. 이후 다른 태스크의 연산으로 가드가True
가 되면, 런타임은 대기 중인 태스크의 랑데부를 허용합니다. 이때 조건 충족이 보장되므로 프로그래머가 조건을 재검사할 필요가 없습니다.
- 모니터: 조건 변수(condition variable)와 명시적인
- 시그널링 방식:
- 모니터:
signal
연산을 프로그래머가 직접, 명시적으로 호출해야 하므로, 호출을 누락하거나 잘못된 시점에 호출하는 오류가 발생하기 쉽습니다. - 보호 객체: 별도의
signal
연산이 없습니다. 보호 프로시저나 엔트리 몸체가 종료될 때, 런타임이 자동으로 가드들을 재평가하여 대기 중인 태스크를 깨워야 할지 결정합니다. 이러한 암묵적인 방식은 프로그래머의 실수를 줄여 코드의 안정성을 크게 높입니다.
- 모니터:
특징 | 고전적 모니터 (Classic Monitor) | Ada 보호 객체 (Protected Object) |
---|---|---|
읽기/쓰기 구분 | 없음 (모든 연산이 배타적) | 함수(읽기)와 프로시저/엔트리(쓰기) 명확히 구분 |
조건부 대기 | wait 연산 (명시적) |
엔트리 호출 (암묵적) |
조건 재검사 | wait 이후 프로그래머가 필수적으로 재검사 |
랑데부 시작 시 조건 충족 보장됨 (재검사 불필요) |
“깨우기” 신호 | signal 연산 (명시적) |
없음 (런타임이 종료 시점에 가드를 자동으로 재평가) |
결론적으로, Ada의 보호 객체는 모니터의 기본 개념을 계승하되, 읽기-쓰기 구별, 가드 기반의 조건부 대기, 암묵적 시그널링을 도입하여 더 높은 수준의 안전성과 명확성, 그리고 성능을 제공하는 진보된 동기화 메커니즘입니다.
16.2 보호 객체 정의
보호 객체의 개념과 필요성을 이해했으니, 이제 Ada 코드로 이를 직접 정의하고 구현하는 방법을 학습할 차례입니다.
패키지나 태스크와 마찬가지로, 보호 객체 역시 명세(specification)와 몸체(body)의 두 부분으로 나뉘는 구조를 가집니다. 이 분리 원칙은 보호 객체의 공개 인터페이스와 내부 구현을 명확하게 구분하여, 캡슐화와 코드 모듈성을 강화합니다.
이 절에서는 보호 객체의 명세를 선언하는 방법부터 시작하여, 그 안에 포함될 수 있는 세 가지 종류의 연산, 즉 보호 프로시저, 보호 함수, 보호 엔트리를 정의하는 규칙을 배웁니다. 마지막으로, 이 연산들의 실제 동작을 구현하는 보호 몸체를 작성하는 방법을 다룰 것입니다.
16.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 ...
구문을 사용합니다.
구성 요소
-
공개부 (Public Part):
is
와private
사이의 영역입니다. 외부 태스크가 호출할 수 있는 보호된 연산들의 시그니처(signature)를 선언합니다.procedure
: 배타적 읽기-쓰기 접근을 위한 연산입니다.function
: 공유 읽기 전용 접근을 위한 연산입니다.entry
: 조건부 배타적 읽기-쓰기 접근을 위한 연산입니다.
-
비공개부 (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;
get
과put
은 버퍼의 상태(count
)에 따라 호출 가능 여부가 결정되어야 하므로entry
로 선언합니다.capacity
는 보호 타입의 판별자(discriminant)로,Bounded_Buffer
타입의 객체를 생성할 때 버퍼의 크기를 지정할 수 있게 합니다.- 버퍼의 실제 저장 공간인
storage
와 상태 변수count
,in_idx
,out_idx
는private
부에 선언되어 외부로부터의 직접적인 접근이 차단됩니다.
16.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) |
동시 실행 | 한 번에 하나만 실행 가능 | 여러 개 동시 실행 가능 |
데이터 수정 | 가능 | 불가능 (컴파일러 강제) |
주요 목적 | 객체 상태의 안전한 변경 | 객체 상태의 효율적인 동시 조회 |
16.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>;
실행 규칙
-
호출 및 배리어 평가: 태스크가 엔트리를 호출하면, 런타임은 해당 객체를 잠그고 배리어 조건(
when
이하의 불리언 표현식)을 평가합니다. -
조건이
True
일 경우 (열린 엔트리):- 호출은 즉시 받아들여지고, 호출 태스크는 엔트리 몸체를 실행합니다. 이 실행은 보호 프로시저와 같이 객체에 대한 배타적 접근 권한을 가집니다.
-
조건이
False
일 경우 (닫힌 엔트리):- 호출 태스크는 해당 엔트리의 대기 큐(queue)에 추가되고 즉시 실행이 중단(suspend)됩니다.
- 객체에 대한 잠금은 해제되어, 다른 태스크들이 다른 프로시저나 함수를 호출할 수 있게 됩니다.
-
배리어 재평가 (암묵적 신호):
- 해당 객체의 보호 프로시저나 다른 엔트리의 실행이 완료될 때마다, Ada 런타임 시스템은 대기 중인 태스크가 있는 모든 엔트리의 배리어들을 자동으로 재평가합니다.
- 재평가 결과 배리어 조건이
True
로 변경되면, 해당 엔트리의 큐에서 대기하던 태스크 하나가 깨어나 엔트리 몸체를 실행할 권한을 얻습니다.
구현 예시 (유한 버퍼)
Bounded_Buffer
의 get
과 put
엔트리는 배리어를 사용하여 버퍼의 상태를 검사합니다.
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
을 호출했을 때count
가0
이면, 소비자는get
의 큐에서 대기합니다. - 이후 생산자가
put
을 호출하여count
를1
로 만들고put
엔트리를 빠져나가는 순간, 런타임은get
의 배리어(count > 0
)를 재평가합니다. - 조건이 이제
True
이므로, 대기하던 소비자 태스크가 깨어나get
의 몸체를 실행합니다.
이 과정은 바쁜 대기나 명시적인 signal
호출 없이, 언어 차원에서 안전하고 효율적으로 처리됩니다.
16.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>;
주요 규칙
- 이름 일치: 보호 몸체의 이름은 반드시 대응하는 보호 타입 명세의 이름과 일치해야 합니다.
- 구현 의무: 명세에 선언된 모든 연산(프로시저, 함수, 엔트리)은 몸체에 반드시 구현되어야 합니다.
- 데이터 접근: 오직 보호 몸체 내의 코드만이 명세의
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;
이처럼 보호 몸체는 보호 객체의 내부 동작 로직을 외부로부터 완전히 숨기는 역할을 합니다. 명세라는 공개 계약만 변경되지 않는다면, 몸체의 내부 구현을 수정하거나 최적화하더라도 보호 객체를 사용하는 외부 코드에는 아무런 영향을 주지 않습니다. 이는 모듈화되고 유지보수하기 쉬운 소프트웨어를 구축하는 데 있어 핵심적인 장점입니다.
16.3 보호 객체 활용
보호 객체를 정의하는 방법을 학습했으므로, 이제 클라이언트 태스크의 관점에서 이 객체들을 실제로 활용하는 방법을 알아볼 차례입니다. 보호 객체의 연산을 호출하는 구문은 일반적인 서브프로그램 호출과 매우 유사하지만, 그 내부 동작에는 동시성 제어를 위한 특별한 규칙이 적용됩니다.
이 절에서는 보호 객체의 프로시저, 함수, 엔트리를 호출하는 방법과 그에 따른 동작을 상세히 살펴봅니다. 또한, 엔트리 배리어의 동작 원리를 더 깊이 이해하고, requeue
문을 사용하여 하나의 엔트리에서 다른 엔트리로 호출을 재배치하는 고급 동시성 제어 패턴까지 학습할 것입니다.
16.3.1 보호된 서브프로그램 호출
클라이언트 태스크가 보호 객체의 연산을 호출하는 구문은 일반적인 서브프로그램 호출과 동일합니다. 동기화를 위한 복잡한 메커니즘은 호출 구문 뒤에 완전히 감추어져 있으며, Ada 런타임에 의해 자동으로 처리됩니다.
보호 프로시저/엔트리 호출
보호 프로시저와 엔트리는 객체에 대한 배타적인 접근을 요청하므로 호출 방식과 규칙이 유사합니다.
구문:
<protected_object_name>.<procedure_or_entry_name> (actual_parameters);
실행 규칙:
- 잠금 획득 시도: 호출 태스크는 해당 보호 객체에 대한 배타적 잠금(exclusive lock) 획득을 시도합니다.
- 엔트리 배리어 평가 (엔트리 호출 시): 만약 호출 대상이 엔트리라면, 잠금을 획득하기 전에 먼저 해당 엔트리의 배리어(
when
조건)를 평가합니다. 배리어가False
이면, 태스크는 해당 엔트리 고유의 대기 큐에서 중단되고 객체 잠금은 해제됩니다. 배리어가True
가 될 때까지 기다린 후에야 2단계로 진행합니다. - 대기 및 실행: 다른 태스크가 이미 객체에 대한 배타적 잠금을 보유하고 있다면, 호출 태스크는 해당 객체의 주(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);
실행 규칙:
- 잠금 획득 시도: 호출 태스크는 해당 객체에 대한 공유 잠금(shared lock) 획득을 시도합니다.
- 대기 및 실행: 다른 태스크가 배타적 잠금을 보유하고 있지 않다면, 호출 태스크는 즉시 공유 잠금을 획득하고 함수를 실행합니다. 여러 태스크가 동시에 공유 잠금을 획득하고 함수를 병행 실행할 수 있습니다. 만약 다른 태스크가 배타적 잠금을 보유 중이라면, 해당 잠금이 해제될 때까지 대기합니다.
호출 예시:
if not my_buffer.is_full then
my_buffer.put (item => new_item);
end if;
위 코드에서 my_buffer.is_full
호출은 객체를 잠그는 다른 연산이 없을 경우 즉시 실행됩니다. 하지만 이 if
문 자체는 경쟁 상태를 막아주지 못합니다. if
문 통과와 put
호출 사이에 다른 태스크가 put
을 먼저 호출할 수 있기 때문입니다. 올바른 사용법은 put
엔트리의 배리어가 이 조건을 처리하도록 신뢰하는 것입니다.
16.3.2 엔트리 배리어(barrier)
엔트리 배리어(entry barrier)는 보호 엔트리의 when
절에 명시되는 불리언(boolean) 조건으로, 해당 엔트리의 개방(open) 또는 폐쇄(closed) 상태를 결정하는 가드(guard)입니다. 배리어는 보호 객체가 특정 상태에 있을 때만 서비스 요청을 수락하도록 하는 핵심적인 조건부 동기화 메커니즘입니다.
배리어의 평가와 재평가
배리어의 동작은 Ada 런타임 시스템에 의해 정교하게 관리됩니다.
- 최초 평가: 태스크가 엔트리를 호출하면, 런타임은 해당 보호 객체에 대한 잠금을 획득한 상태에서 배리어 조건을 한 번 평가합니다.
True
(열림): 조건이 참이면 엔트리는 열린 것으로 간주되고, 호출 태스크는 엔트리 몸체를 실행하기 위한 배타적 잠금을 얻기 위해 진행합니다.False
(닫힘): 조건이 거짓이면 엔트리는 닫힌 것으로 간주되고, 호출 태스크는 해당 엔트리 고유의 대기 큐로 이동하여 중단됩니다. 객체에 대한 잠금은 즉시 해제되어 다른 태스크가 접근할 수 있게 됩니다.
- 자동 재평가 (암묵적 신호):
가장 중요한 특징은 배리어의 재평가가 자동으로 이루어진다는 점입니다. 프로그래머가
signal
과 같은 별도의 신호 연산을 호출할 필요가 없습니다. 재평가는 다음 시점에 자동으로 발생합니다.보호 객체의 보호 프로시저 또는 보호 엔트리의 실행이 완료되는 시점
이 시점은 보호 객체의 내부 데이터가 변경되었을 가능성이 있는 유일한 시점입니다. 런타임은 이 때, 대기 큐에 태스크가 있는 모든 엔트리의 배리어들을 다시 평가합니다. 재평가 결과 배리어가
True
로 바뀐 엔트리가 있다면, 해당 큐에서 대기하던 태스크가 깨어나 실행을 재개합니다.
동작 예시 (유한 버퍼)
Bounded_Buffer
객체에서 count
가 0일 때의 동작 흐름은 다음과 같습니다.
- 소비자 호출: 소비자 태스크가
get
을 호출합니다. 배리어when count > 0
은False
이므로, 소비자는get
의 대기 큐에서 중단됩니다. - 생산자 호출: 생산자 태스크가
put
을 호출합니다. 배리어when count < capacity
는True
이므로put
의 몸체가 실행되고,count
는1
이 됩니다. put
종료 및 재평가:put
엔트리의 실행이 끝나는 순간, 런타임은 대기 중인get
엔트리의 배리어(count > 0
)를 자동으로 재평가합니다.get
실행: 배리어는 이제True
이므로,get
큐에서 대기하던 소비자 태스크가 깨어나get
의 몸체를 실행합니다.
이러한 자동 재평가 메커니즘은 조건부 대기 로직을 선언적으로 명시할 수 있게 하여, wait/signal
방식에서 발생할 수 있는 신호 누락이나 불필요한 재검사 등의 오류를 원천적으로 방지하고 코드의 안정성을 높입니다.
16.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
)되면, 재배치된 큐에서도 안전하게 제거되도록 보장합니다. 이 옵션은 안정성을 위해 사용하는 것이 표준적인 관례입니다.
실행 규칙
requeue
문이 실행되면, 현재 실행 중인 태스크는 즉시 중단되고 지정된 엔트리의 대기 큐의 맨 뒤에 추가됩니다.- 이 재배치 과정은 원자적으로 일어납니다. 즉, 태스크가 큐로 이동하는 동안 보호 객체에 대한 잠금은 해제되지 않습니다.
- 태스크가 새로운 큐로 완전히 이동한 후에야 객체 잠금이 해제되어, 다른 태스크가 객체에 접근할 수 있게 됩니다.
- 재배치된 태스크는 이제 새로운 엔트리의 배리어 조건이 참이 되기를 기다립니다.
사용 예시 (우선순위 기반 처리)
외부에는 단일 진입점(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
는 이처럼 복잡한 상태 기반의 동시성 제어 흐름을 구현하기 위한 강력한 도구입니다.
17. 고급 동시성 패턴과 기법
이전 장들에서는 Ada 동시성의 기본 구성 요소인 태스크, 랑데부, 보호 객체를 각각 학습했습니다. 이제 우리는 이 기본 요소들을 조합하여, 실제 동시성 시스템 설계에서 반복적으로 나타나는 문제들을 해결하는 검증된 해법, 즉 동시성 디자인 패턴(concurrency design patterns)을 익힐 차례입니다.
이번 장에서는 생산자-소비자, 읽기-쓰기 문제와 같이 컴퓨터 과학 분야에서 널리 알려진 고전적인 동시성 문제들을 Ada의 동시성 기능을 이용해 풀어봅니다. 이러한 패턴들을 학습함으로써, 개별 기능에 대한 이해를 넘어 실제 문제에 적용하는 능력을 기르게 될 것입니다.
또한, 동시성 환경에서 예외가 어떻게 동작하고 전파되는지, 그리고 Tasking_Error
예외는 언제 발생하는지와 같은 고급 예외 처리 기법을 다룹니다. 마지막으로, 태스크의 상태를 질의할 수 있는 여러 유용한 태스크 속성(attribute)에 대해 배우며 동시성 시스템을 더욱 정교하게 제어하는 방법을 모색합니다.
17.1 고전적 동시성 문제와 해결 패턴
동시성 프로그래밍의 이론과 Ada의 기본 기능들을 학습한 지금, 이 지식들을 통합하여 실제 문제 해결에 적용할 차례입니다. 이 절에서는 수십 년간 동시성 시스템 연구에서 핵심적인 사례로 다루어져 온 고전적인 동시성 문제들을 살펴보고, Ada를 이용한 해결책을 구현합니다.
이 문제들은 단순한 학술적 예제가 아니라, 운영체제, 데이터베이스, 통신 시스템 등 현실의 복잡한 시스템에서 마주치는 다양한 동기화 및 자원 공유 문제들의 본질을 담고 있습니다. 이러한 디자인 패턴(design patterns)을 학습하는 것은 Ada의 동시성 기능들을 올바르고 효과적으로 사용하는 방법을 익히고, 재사용 가능한 해결책의 구조를 이해하는 과정입니다.
본 절에서는 생산자-소비자 문제, 읽기-쓰기 문제, 그리고 식사하는 철학자들 문제를 차례로 다룰 것입니다.
17.1.1 유한 버퍼 (생산자-소비자 문제)
생산자-소비자(Producer-Consumer) 문제는 동시성 프로그래밍에서 가장 기본적이고 널리 알려진 문제입니다. 이 문제는 데이터를 생성하는 하나 이상의 생산자 태스크와, 그 데이터를 소비하는 하나 이상의 소비자 태스크가 고정된 크기의 공유 버퍼를 통해 통신하는 상황을 모델링합니다.
문제 정의
이 패턴의 동기화 제약 조건은 다음과 같습니다.
- 생산자는 버퍼가 가득 차 있을 때(full), 새로운 데이터를 추가할 수 없으며 버퍼에 공간이 생길 때까지 대기해야 합니다.
- 소비자는 버퍼가 비어 있을 때(empty), 데이터를 가져갈 수 없으며 버퍼에 데이터가 채워질 때까지 대기해야 합니다.
- 여러 생산자나 소비자가 동시에 버퍼에 접근하여 데이터가 손상되는 것을 막기 위해, 버퍼 접근은 상호 배제되어야 합니다.
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;
해결책 분석
- 상호 배제:
get
과put
이 보호 엔트리이므로, Ada 런타임은 이들의 몸체가 동시에 실행되지 않도록 자동적으로 보장합니다 (제약 조건 3 충족). - 생산자 대기:
put
엔트리의 배리어when count < capacity
는 버퍼가 가득 차면(count = capacity
)False
가 됩니다. 이때put
을 호출한 생산자 태스크는 런타임에 의해 자동으로 중단되어 대기합니다 (제약 조건 1 충족). - 소비자 대기:
get
엔트리의 배리어when count > 0
은 버퍼가 비어 있으면(count = 0
)False
가 됩니다. 이때get
을 호출한 소비자 태스크는 자동으로 중단되어 대기합니다 (제약 조건 2 충족).
이처럼 Ada의 보호 객체는 생산자-소비자 문제의 동기화 제약 조건들을 언어 차원에서 선언적으로, 그리고 안전하게 구현할 수 있는 이상적인 해법을 제공합니다.
17.1.2 읽기-쓰기 문제 (readers-writer problem)
읽기-쓰기 문제(Readers-Writer Problem)는 다수의 태스크가 하나의 공유 자원에 접근할 때 발생하는 고전적인 동기화 문제입니다. 이 문제의 목표는 읽기 작업의 동시성을 최대한 허용하면서 데이터의 일관성을 보장하는 것입니다.
문제 정의
동기화 제약 조건은 다음과 같습니다.
- 자원의 상태를 변경하지 않는 판독자(Readers)들은 여러 명이 동시에 자원에 접근하여 읽을 수 있어야 한다.
- 자원의 상태를 변경하는 기록자(Writers)는 한 번에 오직 한 명만 자원에 접근할 수 있어야 한다 (배타적 접근).
- 기록자가 자원에 접근하고 있는 동안에는, 어떤 판독자도 자원에 접근할 수 없어야 한다.
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
프로시저가 실행되는 동안 다른 어떤write
나read
호출도 허용하지 않고 자동으로 차단합니다.
이처럼 프로그래머는 판독자 수나 잠금 상태를 수동으로 관리할 필요가 전혀 없습니다. 단순히 연산의 성격(읽기 또는 쓰기)에 맞춰 function
또는 procedure
를 선택하는 것만으로, Ada 런타임이 모든 복잡한 동기화 제어를 안전하게 처리합니다.
잠재적 고려사항: 기록자 기아 상태
이 단순한 구현은 판독 요청이 끊임없이 들어오는 경우, 기록자가 자원에 대한 접근 권한을 얻지 못하고 무한정 대기하는 기록자 기아 상태(writer starvation)를 유발할 수 있습니다. 만약 기록자 우선 정책과 같은 더 정교한 제어가 필요하다면, 엔트리와 배리어를 사용하여 더 복잡한 로직을 구현할 수 있습니다. 하지만 많은 응용 프로그램에서는 이 간결한 기본 모델만으로도 충분합니다.
17.1.3 식사하는 철학자들 문제 (dining Philosophers problem)
식사하는 철학자들 문제는 교착 상태(deadlock)와 기아 상태(starvation)의 위험성을 설명하기 위해 사용되는 전형적인 동시성 문제입니다. 이는 여러 태스크가 한정된 자원을 놓고 경쟁할 때 발생할 수 있는 미묘한 상호작용을 보여줍니다.
문제 정의
- 설정: 다섯 명의 철학자가 원형 테이블에 앉아 있습니다. 각 철학자의 앞에는 접시가 있고, 철학자들 사이에는 각각 하나의 젓가락이 놓여 있습니다. 즉, 5명의 철학자, 5개의 접시, 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의 보호 객체와 배리어는 이처럼 복잡한 자원 할당 문제를 안전하고 구조적으로 해결할 수 있는 강력한 추상화를 제공합니다.
17.2 태스크와 예외 처리
순차적 프로그램에서 예외 처리는 비교적 명확한 규칙을 따르지만, 여러 실행 흐름이 상호작용하는 동시성 환경에서는 예외의 전파와 처리가 훨씬 더 복잡해집니다. 하나의 태스크에서 발생한 예외가 통신 중인 다른 태스크에 어떤 영향을 미치는지, 또는 태스크가 비정상적으로 종료될 때 시스템은 어떻게 반응하는지를 이해하는 것은 견고한 동시성 시스템을 구축하는 데 필수적입니다.
이 절에서는 동시성 환경에서의 예외 처리 규칙을 체계적으로 다룹니다. 먼저 랑데부 과정에서 예외가 발생했을 때 호출자와 제공자에게 각각 어떤 일이 일어나는지 분석합니다. 이어서 태스크 몸체에서 처리되지 않은 예외의 결과를 살펴보고, 마지막으로 Ada의 태스킹 시스템 자체가 비정상적인 상황을 알리기 위해 발생하는 특별한 예외인 Tasking_Error
에 대해 학습합니다.
17.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;
실행 흐름:
Client_Code
가Request_Handler.process(-1)
을 호출하여 랑데부가 시작됩니다.accept
블록 내부의if
문에서Constraint_Error
가 발생합니다.- 랑데부가 즉시 비정상적으로 종료됩니다.
- 동일한
Constraint_Error
예외가Request_Handler
의accept
문 위치와Client_Code
의process
호출 위치에 동시에 발생합니다. - 각각의
exception
핸들러가 예외를 포착하여 처리합니다.
이처럼 대칭적인 예외 전파 규칙은 양측의 상태를 일관되게 유지시켜, 동시성 시스템의 안정성과 예측 가능성을 높이는 중요한 역할을 합니다.
17.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;
17.2.3 Tasking_Error
예외
Tasking_Error
는 Ada.Tasking
패키지에 정의된 내장된 예외(predefined exception)로, 애플리케이션 로직의 오류가 아닌 태스크 간의 상호작용이나 태스크의 생명주기 관리 과정에서 근본적인 문제가 발생했을 때 Ada 런타임 시스템에 의해 발생하는 특별한 예외입니다.
이는 태스킹 시스템 자체가 “더 이상 정상적인 상호작용을 계속할 수 없다”고 알리는 중요한 신호입니다.
Tasking_Error
의 주요 발생 원인
-
종료된 태스크와의 통신: 가장 일반적인 원인입니다. 이미 종료(terminated)된 태스크의 엔트리를 호출하려고 시도하면, 호출자 측에서 즉시
Tasking_Error
가 발생합니다. 이는 통신 대상이 더 이상 존재하지 않음을 의미합니다.-- Faulty_Server 태스크가 내부 오류로 이미 종료된 상태 begin Faulty_Server.request (...); -- 이 호출은 즉시 Tasking_Error를 유발 exception when Tasking_Error => -- 서버가 사라졌음을 감지하고 처리 end;
-
태스크 활성화 실패: 자식 태스크가 활성화(activation) 과정에서 처리되지 않은 예외를 일으켜 즉시 종료되면, 그 부모(master) 태스크의 활성화 지점에서
Tasking_Error
가 발생합니다. 이는 부모에게 자신이 의존하는 하위 컴포넌트 중 하나가 시작에 실패했음을 알리는 신호입니다. -
abort
된 태스크와의 통신:abort
문에 의해 강제 종료된 태스크와 통신을 시도하는 경우도 1번과 마찬가지로Tasking_Error
를 유발합니다.
Tasking_Error
의 역할
Tasking_Error
는 동시성 시스템의 견고성을 높이는 데 중요한 역할을 합니다. 클라이언트 태스크는 이 예외를 처리함으로써, 자신이 의존하는 서버 태스크의 실패를 감지하고 그에 대한 복구 로직(예: 백업 서버 사용, 작업 취소, 오류 보고)을 수행할 수 있습니다.
즉, Tasking_Error
는 개별 태스크의 실패가 시스템 전체의 치명적인 오류로 번지지 않도록 막고, 다른 태스크들이 해당 실패에 대처할 기회를 제공하는 결함 감지 및 격리 메커니즘의 핵심 요소입니다.
17.3 태스크 속성(attribute)
Ada는 태스크 객체의 현재 상태나 고유한 특성에 대한 정보를 질의할 수 있는 여러 속성(attribute)을 제공합니다. 속성은 Task_Object'Attribute_Name
과 같이 어포스트로피('
)를 사용하여 접근하며, 이를 통해 프로그램이 실행 중에 다른 태스크를 모니터링하거나 관리하는 로직을 구현할 수 있습니다.
이 절에서는 가장 빈번하게 사용되는 세 가지 태스크 속성을 학습합니다. 태스크가 종료되거나 비정상 상태에 빠져 호출을 받을 수 없는지 확인하는 'callable
속성, 태스크의 생명주기가 완전히 끝났는지 검사하는 'Terminated
속성, 그리고 각 태스크에 부여된 고유 식별자를 얻는 'Identity
속성에 대해 알아볼 것입니다.
이러한 속성들은 태스크의 상태를 외부에서 확인할 수 있는 유용한 수단을 제공하지만, 사용 시 주의가 필요합니다. 속성을 읽는 시점과 그 정보를 사용하는 시점 사이에도 태스크의 상태는 계속 변할 수 있으므로, 결정적인 동기화 로직보다는 주로 디버깅, 로깅, 또는 비결정적인 상태 모니터링 용도로 활용됩니다.
17.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
속성을 사용할 때 반드시 인지해야 할 중요한 한계가 있습니다. 속성 값을 확인하는 시점과 그 값을 사용하는 시점 사이에는 경쟁 상태가 존재합니다.
if Server'callable then
문장에서True
를 반환합니다.- (제어권이 다른 태스크로 넘어감)
Server
태스크가 어떤 이유로든 바로 이 순간에 종료됩니다.Server.process_request
호출이 실행됩니다.- 결과적으로 종료된 태스크에 호출을 시도하게 되어
Tasking_Error
예외가 발생합니다.
이러한 경쟁 상태 때문에, 'callable
속성은 Tasking_Error
가 발생하지 않을 것임을 보장할 수 없습니다. 따라서 이 속성은 사전 검사용으로 유용하지만, 견고한 코드는 'callable
속성을 사용하더라도 만약을 대비하여 항상 Tasking_Error
예외 핸들러를 구비해야 합니다.
17.3.2 'Terminated
속성
'Terminated
는 태스크의 생명주기가 완전히 끝났는지를 확인하는 불리언(boolean) 속성입니다.
정의:
T
가 태스크 객체일 때, T'Terminated
는 태스크 T
가 종료(terminated) 상태인 경우에만 True
를 반환하고, 그 외의 모든 상태(실행 중, 완료됨, 대기 중 등)에서는 False
를 반환합니다.
'callable
과의 차이점
'callable
과 'Terminated
는 미묘하지만 중요한 차이가 있습니다. 태스크가 자신의 코드 실행을 마치면 완료(completed) 상태가 되지만, 아직 자식 태스크나 부모와의 관계가 정리되지 않아 종료(terminated)되지 않았을 수 있습니다.
T
가 완료되었지만 아직 종료되지 않은 상태:T'callable
은False
입니다.T'Terminated
도False
입니다.
'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
예외 처리나 다른 명시적인 동기화 메커니즘을 사용해야 합니다.
17.3.3 'Identity
속성
'Identity
속성은 각 태스크 객체에 부여된 고유한 식별자를 반환합니다. 이 속성은 동기화보다는 주로 디버깅, 로깅, 또는 태스크 관리에 사용됩니다.
정의:
T
가 태스크 객체일 때, T'Identity
는 Ada.Task_Identification
표준 패키지에 정의된 Task_ID
타입의 값을 반환합니다. 이 값은 해당 태스크가 활성화되어 있는 동안 시스템 내의 다른 모든 활성 태스크의 ID와 중복되지 않음이 보장됩니다.
Task_ID
타입은 private
타입이므로 그 내부 구조를 직접 조작할 수 없으며, 비교(=
)나 Ada.Task_Identification
패키지가 제공하는 다른 서브프로그램들과 함께 사용해야 합니다.
사용 목적 및 구문
'Identity
속성의 주된 용도는 다음과 같습니다.
- 로깅 및 디버깅: 여러 태스크가 동시에 실행될 때, 로그 메시지에 각 태스크의 ID를 포함시켜 어떤 태스크가 어떤 동작을 수행했는지 추적할 수 있습니다.
- 데이터 연관: 태스크 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
속성은 이처럼 각 태스크에 고유한 “이름표”를 붙여주어, 복잡한 동시성 시스템의 동작을 분석하고 관리하는 데 필수적인 도구입니다.
18. 실시간 시스템과 동시성
지금까지 우리는 Ada의 동시성 기능들을 사용하여 프로그램의 논리적 정확성을 보장하는 방법을 학습했습니다. 하지만 항공우주, 국방, 의료, 산업 제어와 같은 수많은 임베디드 시스템에서는 논리적 정확성만으로는 부족합니다. 이러한 시스템에서는 시간적 정확성(temporal correctness), 즉 정해진 시간 제약(deadline) 내에 연산을 완료하는 것이 성공과 실패를 가르는 기준이 됩니다.
실시간 시스템(real-time system)이란 이처럼 외부 이벤트에 대해 예측 가능한 시간 내에 반응해야 하는 컴퓨팅 시스템을 의미합니다. Ada는 설계 초기부터 이러한 실시간 시스템 개발을 핵심 목표 중 하나로 삼았으며, 언어 표준의 실시간 부록(Real-Time Annex)을 통해 시간 관리, 우선순위 기반 스케줄링, 그리고 관련 문제 해결을 위한 정교한 기능들을 명시적으로 제공합니다.
이번 장에서는 Ada의 동시성 모델이 실시간 제약 조건과 어떻게 결합되는지를 탐구합니다. 먼저 실시간 시스템의 종류를 알아보고, Ada의 시간 표현 방식과 정밀한 지연 기능을 학습합니다. 이어서 태스크에 우선순위를 할당하고 스케줄링하는 방법과, 실시간 시스템의 고질적인 문제인 우선순위 역전(priority inversion)을 해결하기 위한 우선순위 상한 프로토콜(priority ceiling protocol)과 같은 고급 기법들을 배우게 될 것입니다.
18.1. 실시간 시스템 개요
실시간 시스템의 핵심은 예측 가능성(predictability)입니다. 즉, 특정 작업이 주어진 시간 제약 내에 완료될 것을 보장하는 것입니다. 이 절에서는 실시간 시스템을 그 제약 조건의 엄격함에 따라 분류하고, Ada가 이러한 시스템을 지원하기 위해 표준에 명시한 실시간 부록(Real-Time Annex)에 대해 알아봅니다. 이 개념들을 이해하는 것은 우리가 만들고자 하는 시스템의 시간적 요구사항을 명확히 하고, 그에 맞는 Ada 기능을 선택하는 첫걸음입니다.
18.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는 특히 경성 실시간 시스템 개발에 필요한 결정론적이고 예측 가능한 기능을 제공하는 데 중점을 두고 설계되었습니다.
18.1.2 실시간 부록 (real-time annex)
Ada 언어 표준은 핵심(Core) 언어와 여러 부록(Annex)으로 구성됩니다. 실시간 부록(Real-Time Annex)은 이 중 하나로, 실시간 시스템 개발에 필수적인 기능들을 표준화하여 정의한 부분입니다. 컴파일러가 부록의 기능을 지원하는 것은 선택 사항이지만, 실시간 및 임베디드 분야를 대상으로 하는 대부분의 주요 Ada 컴파일러는 이 부록을 완전하게 구현합니다.
목적과 역할
실시간 부록의 주된 목적은 실시간 프로그램의 예측 가능성(predictability)과 이식성(portability)을 보장하는 것입니다. 특정 컴파일러나 운영체제에 종속적인 기능 대신, 표준화된 인터페이스와 동작 규칙을 제공함으로써 어떤 환경에서도 일관되게 동작하는 실시간 코드를 작성할 수 있도록 지원합니다.
주요 내용
실시간 부록은 다음과 같은 핵심 기능들을 정의하며, 이어지는 절들에서 이 내용들을 상세히 다룰 것입니다.
- 우선순위 기반 스케줄링 (Priority-Based Scheduling):
- 태스크에 우선순위를 부여하고, 높은 우선순위의 태스크가 낮은 우선순위의 태스크를 선점(preempt)하는 스케줄링 모델을 표준화합니다.
- 동일 우선순위 내에서는 선입선출(FIFO)로 처리하는 규칙 등을 포함합니다.
- 시간 관리 (Time Management):
- 시스템 시간의 변화에 영향을 받지 않는 고해상도의 단조 시계(monotonic clock)를 제공하는
Ada.Real_Time
패키지를 정의합니다. - 특정 절대 시간까지 정밀하게 실행을 지연시키는
delay until
문을 지원합니다.
- 시스템 시간의 변화에 영향을 받지 않는 고해상도의 단조 시계(monotonic clock)를 제공하는
- 우선순위 역전 제어 (Priority Inversion Control):
- 실시간 시스템의 고질적인 문제인 우선순위 역전 현상을 방지하기 위한 메커니즘을 정의합니다.
- 특히 보호 객체에 적용되는 우선순위 상한 프로토콜(Priority Ceiling Protocol)을
pragma Locking_Policy
를 통해 표준으로 제공합니다.
- 기타 기능:
- 태스크 중단(
abort
)에 대한 제어, 동기식 태스크 제어, 타이머 이벤트 핸들링 등 예측 가능한 시스템을 구축하기 위한 다양한 저수준 제어 기능들을 포함합니다.
- 태스크 중단(
실시간 부록은 Ada를 단순한 동시성 지원 언어를 넘어, 신뢰성이 요구되는 경성 실시간 시스템을 개발하기 위한 전문적인 언어로 만들어주는 핵심적인 부분입니다.
18.2 시간과 시계
실시간 시스템에서 작업을 스케줄링하고 마감 시간을 준수하기 위해서는 시간을 정밀하고 예측 가능하게 측정하고 관리하는 능력이 필수적입니다. 일반적인 컴퓨터의 시계는 사용자에 의해 변경될 수 있어 실시간 작업에는 부적합하므로, Ada는 실시간 부록을 통해 시간의 흐름을 안정적으로 표현하고 제어하는 표준화된 방법을 제공합니다.
이 절에서는 Ada.Real_Time
패키지가 제공하는 시간 관련 타입들을 살펴보고, 이를 이용하여 특정 절대 시간까지 실행을 지연시키는 delay until
문의 사용법을 학습합니다. 또한, 실시간 시스템에서 왜 시간의 역행이 없는 단조 시계(monotonic clock)가 중요한지를 이해하게 될 것입니다.
18.2.1 Ada.Real_Time
패키지
Ada.Real_Time
은 실시간 부록에 정의된 핵심 패키지로, 고해상도의 정밀한 시간 관리를 위한 표준화된 타입과 서브프로그램들을 제공합니다. 이 패키지는 실시간 시스템의 시간적 동작을 예측 가능하고 이식성 있게 구현하기 위한 기반이 됩니다.
주요 타입
Time
: 특정 시점, 즉 절대 시간을 나타내는 private 타입입니다. 이 타입의 값은 외부 시간 설정 변경(예: 서머타임)에 영향을 받지 않고 오직 증가하기만 하는 단조 시계(monotonic clock)를 기준으로 합니다.Time_Span
: 시간의 길이, 즉 상대적인 기간을 나타내는 private 타입입니다. 시간 연산에 사용됩니다.
주요 기능
Clock
함수: 현재 시간을Time
타입으로 반환합니다.current_time : Time := Ada.Real_Time.Clock;
- 시간 연산:
Time
과Time_Span
타입을 위한 기본적인 산술 연산자를 제공합니다.Time + Time_Span
→Time
(미래 시점 계산)Time - Time_Span
→Time
(과거 시점 계산)Time - Time
→Time_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
패키지는 이처럼 실시간 시스템이 요구하는 안정적이고 정밀한 시간 관리 기능을 표준화하여 제공함으로써, 코드의 신뢰성과 이식성을 보장합니다.
18.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
문은 실시간 시스템에서 요구되는 결정론적이고 예측 가능한 주기적 행위를 구현하는 핵심적인 도구입니다.
18.2.3 단조 시계(monotonic clock)
단조 시계(monotonic clock)는 시간이 오직 앞으로만 흐르는 것이 보장되는 시간 측정 기준입니다. 이 시계는 시스템 부팅과 같은 특정 시점에서 시작하여, 외부의 시간 변경에 전혀 영향을 받지 않고 일정하게 증가하기만 합니다.
일반 시계(Wall-Clock)와의 차이점
우리가 일상적으로 사용하는 일반 시계(wall-clock time)는 실시간 시스템에 부적합합니다. 그 이유는 다음과 같습니다.
- 사용자나 네트워크 시간 프로토콜(NTP)에 의해 시간이 임의로 변경될 수 있습니다.
- 서머타임(Daylight Saving Time) 적용으로 시간이 갑자기 앞으로나 뒤로 이동할 수 있습니다.
만약 일반 시계를 기준으로 delay until
을 사용한다면, 시계가 과거로 조정될 경우 태스크는 의도치 않게 매우 긴 시간 동안 지연될 수 있어 시스템의 예측 가능성을 파괴합니다.
단조 시계의 필요성
Ada.Real_Time.Clock
이 제공하는 단조 시계는 이러한 문제를 해결하며, 실시간 시스템에서 다음과 같은 필수적인 역할을 합니다.
- 예측 가능한 지연:
delay until
문이 항상 미래의 특정 시점까지 안정적으로 대기하도록 보장합니다. - 정확한 기간 측정:
end_time - start_time
과 같은 연산이 외부 시간 변경에 오염되지 않은, 순수한 경과 시간을 나타내도록 보장합니다. - 신뢰성 있는 마감 시간: 주기적인 태스크의 다음 실행 시간(
next_execution_time
)을 계산할 때, 기준이 되는 시계가 임의로 변하지 않으므로 마감 시간 계산의 신뢰성이 보장됩니다.
결론적으로, 단조 시계는 실시간 시스템의 시간적 결정성(determinism)을 위한 가장 기본적인 전제 조건입니다. Ada는 Ada.Real_Time
패키지를 통해 이식성 있는 단조 시계 인터페이스를 표준으로 제공함으로써, 개발자가 신뢰성 높은 실시간 애플리케이션을 구축할 수 있는 안정적인 기반을 마련해 줍니다.
18.3. 태스크 스케줄링과 우선순위
실시간 시스템에서는 여러 태스크가 동시에 실행 준비 상태가 되었을 때, 어떤 태스크를 먼저 실행할지 결정하는 스케줄링(scheduling) 정책이 시스템의 예측 가능성을 좌우합니다. Ada는 실시간 부록을 통해 우선순위(priority)에 기반한 선점형 스케줄링 모델을 표준으로 정의합니다.
이 모델의 핵심은 시스템의 중요도에 따라 각 태스크에 고유한 우선순위를 할당하는 것입니다. 스케줄러는 항상 실행 가능한 태스크들 중 가장 높은 우선순위를 가진 태스크에게 CPU를 할당합니다.
이 절에서는 태스크에 우선순위를 부여하는 방법과 그에 따른 스케줄링 동작 규칙을 학습합니다. 더 나아가, 우선순위 기반 시스템의 고질적인 문제인 우선순위 역전 현상을 분석하고, 이를 해결하기 위한 Ada의 정교한 메커니즘인 우선순위 상한 프로토콜을 살펴볼 것입니다.
18.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_Processor
는 Failure_Recovery_Handler
가 블록(block) 상태(예: delay until
또는 I/O 대기)에 있을 때만 실행될 기회를 얻습니다.
이처럼 pragma Priority
를 통해 각 태스크의 정적인 중요도를 명시하는 것은, 예측 가능한 실시간 스케줄링을 구현하기 위한 가장 기본적인 단계입니다. 태스크의 실제 실행 우선순위(active priority)는 이후에 배울 우선순위 상속 등에 의해 동적으로 변할 수 있지만, 기본 우선순위는 변하지 않습니다.
18.3.2 우선순위 기반 스케줄링
Ada 실시간 부록이 정의하는 스케줄링 모델은 우선순위에 기반한 선점형 스케줄링(priority-based preemptive scheduling)입니다. 이 모델의 동작 규칙은 간단하고 결정론적입니다.
실행 가능한 상태에 있는 태스크들 중에서, 가장 높은 우선순위를 가진 태스크가 항상 CPU를 점유하여 실행됩니다.
선점 (Preemption)
이 모델의 핵심은 선점(preemption)입니다. 만약 낮은 우선순위의 태스크가 실행 중일 때 더 높은 우선순위의 태스크가 실행 가능한 상태가 되면(예: delay until
시간이 도래), 스케줄러는 현재 실행 중인 낮은 우선순위 태스크를 즉시 중단시키고, 더 높은 우선순위의 태스크에게 CPU 제어권을 넘겨줍니다.
낮은 우선순위 태스크는 자신보다 높은 우선순위를 가진 모든 태스크들이 블록(block) 상태에 있을 때만 다시 실행될 수 있습니다.
동작 순서 예시:
- T_Low (우선순위 5) 태스크가 CPU를 사용하며 실행 중입니다.
- T_High (우선순위 10) 태스크가 대기 상태에서 깨어나 실행 가능한 상태가 됩니다.
- 선점 발생: 스케줄러는 즉시 T_Low를 멈추고 T_High를 실행시킵니다.
- T_High는 자신의 작업을 수행한 후, 다시 대기 상태로 들어갑니다.
- 이제 T_High보다 높은 우선순위의 태스크가 없으므로, 중단되었던 T_Low가 자신의 이전 상태 그대로 실행을 재개합니다.
동일 우선순위 스케줄링: FIFO
만약 실행 가능한 여러 태스크의 우선순위가 같다면, 표준 스케줄링 정책은 선입선출(FIFO, First-In-First-Out) 방식을 따릅니다. 먼저 실행 가능 상태가 된 태스크가 먼저 실행되고, 해당 태스크가 블록 상태가 될 때까지 실행을 계속합니다. 일반적인 운영체제의 시분할(time-slicing) 방식과 달리, 동일 우선순위의 태스크끼리 CPU를 번갈아 사용하지 않는 것이 기본 정책입니다. 이는 비결정성을 줄이고 예측 가능성을 높입니다.
이러한 우선순위 기반 스케줄링 규칙은 시스템의 가장 중요한 작업이 항상 즉시 처리됨을 보장합니다. 하지만 이 모델은 공유 자원과 함께 사용될 때 우선순위 역전이라는 심각한 문제를 야기할 수 있으며, 이는 다음 절에서 다룰 주제입니다.
18.3.3 우선순위 역전(priority inversion)과 우선순위 상한 프로토콜(priority ceiling protocol)
우선순위 기반 스케줄링은 논리적으로 명확하지만, 여러 태스크가 공유 자원(예: 보호 객체)을 사용할 때 우선순위 역전(priority inversion)이라는 심각한 문제를 야기할 수 있습니다.
문제 정의: 우선순위 역전
우선순위 역전은 높은 우선순위의 태스크(H)가, 낮은 우선순위의 태스크(L)가 필요로 하는 자원을 기다리느라, 중간 우선순위의 태스크(M)에게 실행을 빼앗기는 현상입니다.
발생 시나리오:
- 낮은 우선순위 태스크 L이 실행되어 공유 자원(보호 객체)
R
에 대한 잠금(lock)을 획득합니다. - 높은 우선순위 태스크 H가 실행 가능 상태가 되어, L을 선점하고 실행을 시작합니다.
- H가 자원
R
에 접근하려고 시도하지만, L이 잠금을 보유하고 있으므로 H는 대기 상태에 들어갑니다. - 스케줄러는 이제 L을 실행시켜 자원 잠금을 해제하도록 해야 합니다.
- 이때, H보다는 낮지만 L보다는 높은 우선순위를 가진 중간 우선순위 태스크 M이 실행 가능 상태가 됩니다.
- 역전 발생: 스케줄러는 우선순위 규칙에 따라 L 대신 M을 실행시킵니다. 결과적으로 M이 실행되는 동안 H는 무한정 대기할 수 있습니다. 가장 중요한 태스크 H가 자신과 무관한 M 때문에 실행되지 못하는, 스케줄링의 우선순위가 뒤집히는 현상이 발생합니다.
이 문제는 1997년 화성 탐사선 패스파인더(Mars Pathfinder) 미션에서 주기적인 시스템 리셋을 유발한 원인으로 잘 알려져 있습니다.
Ada의 해결책: 우선순위 상한 프로토콜
Ada는 실시간 부록을 통해 이 문제를 해결하기 위한 검증된 해법인 우선순위 상한 프로토콜(Priority Ceiling Protocol, PCP) 또는 실링 잠금(Ceiling Locking)을 언어 차원에서 제공합니다.
이 프로토콜은 pragma
를 통해 간단히 활성화할 수 있습니다.
pragma Locking_Policy (Ceiling_Locking);
동작 원리:
-
실링 우선순위(Ceiling Priority) 할당: 시스템은 컴파일 시점에 각 보호 객체에 대해 실링 우선순위를 계산합니다. 이 값은 해당 보호 객체에 접근하는 모든 태스크들의 우선순위 중 가장 높은 우선순위로 설정됩니다.
-
우선순위 상속 (Priority Inheritance): 태스크가 보호 객체에 대한 잠금을 획득하는 순간, 해당 태스크의 실행 우선순위는 즉시 그 보호 객체의 실링 우선순위까지 일시적으로 상속받아 올라갑니다. 자원 잠금을 해제하면 원래의 기본 우선순위로 돌아옵니다.
시나리오 재현 (PCP 적용):
- L이 자원 R을 잠그는 순간, L의 실행 우선순위는 R의 실링 우선순위(H의 우선순위)까지 올라갑니다.
- H가 실행 가능 상태가 되어도, 현재 L의 실행 우선순위가 H와 같으므로 L을 선점할 수 없습니다.
- 이후 M이 실행 가능 상태가 되어도, M의 우선순위는 현재 L의 실행 우선순위보다 낮으므로 M은 L을 선점할 수 없습니다.
- 우선순위가 상속된 L은 방해받지 않고 빠르게 임계 구역(critical section)을 벗어나 자원 잠금을 해제합니다.
- L이 잠금을 해제하는 순간, L의 우선순위는 원래대로 돌아가고, 대기 중이던 H가 즉시 잠금을 획득하여 실행을 시작합니다.
이처럼 우선순위 상한 프로토콜은 낮은 우선순위의 태스크가 공유 자원을 사용하는 동안 잠시 높은 우선순위를 빌려와, 중간 우선순위 태스크의 간섭을 원천적으로 차단함으로써 우선순위 역전 문제를 해결합니다. Ada는 이 복잡한 프로토콜을 pragma
하나로 자동화하여, 개발자가 신뢰성 높은 경성 실시간 시스템을 안전하게 구축할 수 있도록 지원합니다.
19. 계약 기반 설계 (Design by Contract)
소프트웨어 컴포넌트 간의 상호작용이 복잡해질수록, 각 컴포넌트의 책임과 역할을 명확히 정의하는 것이 중요해집니다. 계약 기반 설계(Design by Contract, DbC)는 소프트웨어 시스템의 구성 요소들을 상호 간에 계약(contract)을 맺는 주체로 간주하는 설계 방법론입니다. 이 계약은 각 서브프로그램(공급자, supplier)과 그 호출자(클라이언트, client)가 서로에게 기대하는 권리와 이행해야 할 의무를 명시적으로 규정합니다.
Ada는 Ada 2012 표준부터 이러한 계약 기반 설계를 언어 차원에서 직접 지원합니다. 전제조건(Precondition), 후제조건(Postcondition), 타입 불변식(Type Invariant) 과 같은 기능을 통해, 설계 명세를 단순한 주석이 아닌 검증 가능한 코드로 전환할 수 있습니다. 이는 소프트웨어의 신뢰성을 획기적으로 향상시키는 현대적인 개발 패러다임입니다.
19.1 서브프로그램 전제조건 (Pre), 후제조건 (Post)
서브프로그램의 계약은 주로 전제조건과 후제조건을 통해 정의됩니다. 이들은 서브프로그램의 명세(specification)에 with
키워드와 함께 기술되어, 인터페이스의 일부가 됩니다.
19.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
예외가 발생하여 호출부의 오류를 명확히 알려줍니다.
19.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만큼 커져 있음을 보증해야 합니다.
19.1.3 전통적 방식과 계약 기반 설계의 비교
저수준 라이브러리인 clair
는 계약 기반 설계 대신, 전통적인 방식의 런타임 검사와 예외 처리를 사용합니다. 두 방식을 비교하면 DbC의 장점을 명확히 알 수 있습니다.
-
전통적 방식:
Clair.Process.exit_process
프로시저의 명세는 다음과 같습니다.procedure exit_process (status : Integer := EXIT_SUCCESS);
이 명세만으로는
status
값에 어떤 제약이 있는지, 프로시저가 어떤 상태를 보장하는지 알 수 없습니다. 사용자는 문서를 찾아보거나 구현 코드를 직접 분석해야 합니다. -
계약 기반 설계 방식: 만약
Divide
함수를 DbC 스타일로 작성한다면 다음과 같습니다.function Divide (Numerator, Denominator : Integer) return Float with pre => Denominator /= 0, post => Result * Float(Denominator) = Float(Numerator); -- 부동소수점 오차는 일단 무시
이 방식에서는 “분모는 0이 아니어야 한다”는 호출자의 의무가 인터페이스에 명확하게 드러납니다. 구현을 보지 않고도 함수의 계약을 이해할 수 있으며, 이 계약은 실행 가능한 검증 코드가 됩니다.
19.1.4 런타임 검사와 정적 분석
Pre
와 Post
조건은 단순한 주석이 아니라, 컴파일러 옵션(-gnata
)을 통해 활성화할 수 있는 실행 가능한 단정(assertion)입니다. 개발 및 테스트 중에는 이 검사를 활성화하여 계약 위반을 즉시 발견하고, 성능이 중요한 최종 배포 버전에서는 비활성화할 수 있습니다.
또한, 계약은 비활성화 상태에서도 그 자체로 매우 가치 있는 문서가 되며, SPARK(16장)와 같은 정적 분석 도구는 이 계약을 이용해 프로그램의 정확성을 컴파일 시점에 수학적으로 증명하기도 합니다.
전제조건과 후제조건은 서브프로그램과 그 사용자의 책임을 명확히 분리하고, 이를 코드 수준에서 강제하는 강력한 도구입니다. 이는 모호함을 줄이고, 통합 과정에서 발생하는 오류를 조기에 발견하며, 소프트웨어의 신뢰성과 유지보수성을 크게 향상시킵니다. 계약 기반 설계는 고신뢰성이 요구되는 현대 소프트웨어 공학에서 Ada가 제공하는 핵심적인 가치 중 하나입니다.
19.2 타입 불변식 (Type_Invariant)
전제조건과 후제조건이 서브프로그램의 동작을 규정하는 계약이라면, 타입 불변식(Type Invariant)은 특정 타입의 객체가 항상 유지해야 하는 내부적인 일관성(internal consistency) 또는 상태의 유효성을 규정하는 계약입니다.
불변식이란, 해당 타입의 객체가 ‘안정된’ 상태에 있을 때, 즉 공개된 서브프로그램의 실행 중인 순간을 제외한 모든 시점에서, 항상 참으로 유지되어야 하는 속성입니다. 이는 객체가 생성된 후부터 소멸될 때까지 결코 ‘깨지거나’ ‘유효하지 않은’ 상태에 놓이지 않음을 보장하는 강력한 안전장치입니다.
19.2.1 타입 불변식의 정의와 규칙
타입 불변식은 패키지 명세의 private
부분에서 private
타입 선언에 with
키워드를 사용하여 정의됩니다.
- 구문:
with type_invariant => <해당_타입에_대한_부울_표현식>;
- 적용 대상:
private
타입 또는limited private
타입에만 적용할 수 있습니다. - 검사 시점: 타입 불변식은 프로그래머가 직접 호출하는 것이 아니라, 런타임 시스템에 의해 특정 시점에 자동으로 검사됩니다.
- 객체 생성 직후 (기본값 또는
new
연산자로 초기화된 후) - 해당 타입을 파라미터로 갖는 모든 공개(public) 서브프로그램의 실행이 끝나는 시점
- 객체 생성 직후 (기본값 또는
중요한 점은, 공개 서브프로그램의 실행 도중에는 일시적으로 불변식이 깨질 수 있다는 것입니다. 예를 들어, 한 필드를 변경하고 그에 맞춰 다른 필드를 조정하는 중간 과정에서는 객체가 잠시 불일치 상태일 수 있습니다. 하지만 서브프로그램이 호출자에게 제어권을 반환하기 직전에는 반드시 불변식이 다시 만족되어야 합니다.
19.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
프로시저가 호출될 때, 만약 amount
가 balance
보다 커서 잔액이 음수가 될 가능성이 있는 로직이 실행된다면, 프로시저가 종료되는 시점에 Type_Invariant
검사에 실패하여 Assertion_Error
예외가 발생합니다. 이는 withdraw
프로시저의 구현에 버그가 있음을 명확히 알려주며, Account
객체가 유효하지 않은 상태(음수 잔액)에 빠지는 것을 원천적으로 방지합니다.
19.2.3 전제/후제조건과의 관계
타입 불변식은 전제조건 및 후제조건과 긴밀하게 연관되어 함께 동작합니다.
- 타입 불변식은 해당 타입의 모든 공개 서브프로그램에 대한 암묵적인 전제조건이자 후제조건으로 생각할 수 있습니다.
- 즉, 모든 공개 연산은 “객체가 유효한 상태에서 시작해서(전제조건), 유효한 상태로 끝나야 한다(후제조건)”는 책임을 가집니다.
- 타입 불변식을 사용하면, 모든 서브프로그램의
Pre
와Post
에Object.balance >= 0
과 같은 동일한 유효성 검사를 반복해서 명시할 필요가 없어지므로 계약을 더 간결하고 명확하게 만들 수 있습니다.
타입 불변식은 데이터 추상화의 무결성을 보장하는 강력한 선언적 도구입니다. 객체가 가져야 할 ‘항상 유효한 상태’에 대한 규칙을 타입 정의에 단 한 번 명시함으로써, 개발자는 방어적인 유효성 검사 코드를 반복적으로 작성하는 부담을 덜고, 컴파일러가 객체의 일관성을 자동으로 검증하도록 위임할 수 있습니다. 이는 계약 기반 설계의 핵심 요소로서, 견고하고 신뢰성 높은 객체 지향 시스템을 구축하는 데 크게 기여합니다.
19.3 단정 (Assert) 프라그마
Pre
, Post
조건 및 Type_Invariant
가 서브프로그램이나 타입의 인터페이스, 즉 외부 계약을 정의하는 데 사용된다면, pragma Assert
는 구현부 내부의 특정 지점에서 반드시 참이어야 하는 조건을 명시하는 범용적인 도구입니다.
단정(Assertion)은 프로그램의 실행 흐름 속에서 “이 지점에서는 이 조건이 반드시 참일 것으로 확신한다”는 프로그래머의 가정을 코드에 명시적으로 표현하는 것입니다. 이는 실행 가능한 문서(executable documentation) 역할을 하여, 코드의 가독성을 높이고 논리적 오류를 조기에 발견하는 데 큰 도움을 줍니다.
19.3.1 정의 및 구문
pragma Assert
는 주어진 부울 표현식을 평가하여, 그 결과가 True
가 아니면 Assertion_Error
예외를 발생시키는 컴파일러 지시어입니다.
- 기본 구문:
pragma Assert (<부울_표현식>);
- 메시지 포함 구문:
pragma Assert (Check => <부울_표현식>, Message => "오류 시 출력할 메시지");
Assert
프라그마는 Pre
, Post
와 달리 서브프로그램의 명세가 아닌, begin
과 end
사이의 실행문(statement)이 위치할 수 있는 곳이라면 어디든 사용할 수 있습니다.
19.3.2 주요 활용 사례
-
알고리즘의 중간 결과 검증 복잡한 계산을 수행한 후, 그 결과가 예상 범위나 조건을 만족하는지 검증할 수 있습니다.
function Calculate_Something (...) return Result_Type is Intermediate_Value : Float; begin -- ... 복잡한 계산 수행 ... Intermediate_Value := ...; pragma Assert (Intermediate_Value > 0.0, Message => "중간 계산값이 양수여야 함"); -- ... 다음 계산 수행 ... end Calculate_Something;
-
루프 불변식(loop Invariant) 보증 루프의 매 반복이 시작되거나 끝날 때 항상 참이어야 하는 속성, 즉 루프 불변식을 단정을 통해 검사할 수 있습니다.
while Index <= Last loop pragma Assert (Total >= 0); -- 루프 진입 시 Total은 항상 0 이상이다. -- ... 루프 본문 ... Index := Index + 1; end loop;
-
“도달할 수 없는” 코드 명시 (Marking “Unreachable” Code) 논리적으로 절대 실행되어서는 안 되는 코드 경로에
pragma Assert (False);
를 위치시켜, 만약 해당 경로가 실행될 경우 즉시 오류를 발생시킬 수 있습니다. 이는 주로case
문의when others
절에서 모든 경우가 이미 처리되었다고 가정할 때 사용됩니다.case status is when OK => ... when Warning => ... when Error => ... when others => pragma Assert (False, Message => "예상치 못한 status 값 발생"); end case;
19.3.3 전통적인 런타임 검사와의 비교
if not <조건> then raise ...; end if;
형태의 코드도 런타임 검사를 수행할 수 있지만, pragma Assert
는 다음과 같은 중요한 차이점을 가집니다.
- 의도의 명확성:
if-then-raise
구문은 일반적인 오류 처리(예: 잘못된 사용자 입력 처리)에 사용될 수 있지만,pragma Assert
는 프로그래머의 논리적 오류, 즉 버그를 찾는 것이 목적임을 명확히 합니다. - 컴파일러 제어:
pragma Assert
는 컴파일러 스위치(-gnata
등)를 통해 전체 프로그램에 대해 쉽게 활성화하거나 비활성화할 수 있습니다. 이를 통해 테스트 빌드에서는 안전성을 강화하고, 릴리즈 빌드에서는 성능 저하 없이 코드를 배포하는 것이 용이합니다.if-then-raise
코드는 항상 실행 코드에 포함됩니다. - 정적 분석 지원:
pragma Assert
는 SPARK와 같은 정적 분석 도구에게 중요한 정보를 제공합니다. 분석 도구는 이 단정을 ‘증명해야 할 정리’로 간주하고, 해당 조건이 항상 참임을 수학적으로 입증하려고 시도할 수 있습니다.
pragma Assert
는 계약 기반 설계를 완성하는 유연하고 강력한 도구입니다. 서브프로그램의 외부 계약을 넘어, 구현 내부의 논리적 흐름과 가정들을 코드 자체에 명시함으로써, 단순한 런타임 검사를 넘어섭니다. 이는 코드의 신뢰성을 높이고, 디버깅을 용이하게 하며, 궁극적으로는 정적 분석을 통해 더욱 견고한 소프트웨어를 만드는 데 기여하는 핵심적인 실천 방법입니다.
20. 저수준 프로그래밍과 표현 명세
Ada는 타입 안전성과 높은 수준의 추상화를 통해 견고한 소프트웨어를 구축하는 데 강점을 보이는 언어입니다. 하지만 때로는 운영체제의 커널과 상호작용하거나, 하드웨어 장치를 직접 제어하거나, 특정 메모리 영역을 조작하는 등 저수준(low-level) 프로그래밍이 반드시 필요합니다.
Ada는 이러한 요구사항을 충족하기 위해, 언어의 안전성을 유지하면서도 기계 수준의 세밀한 제어를 가능하게 하는 강력한 기능들을 제공합니다. 본 장에서는 System
패키지를 시작으로, 메모리 주소를 직접 다루고 데이터의 물리적 표현을 명시하는 방법을 탐구합니다.
20.1 System 패키지와 Address 타입
System
패키지는 Ada 프로그램이 실행되는 기본 하드웨어 및 런타임 환경에 대한 정보와 접근 수단을 제공하는 표준 라이브러리 패키지입니다. 이는 저수준 프로그래밍의 가장 기본적이고 핵심적인 통로 역할을 합니다.
20.1.1 System
패키지의 주요 구성 요소
System
패키지는 대상 시스템의 특성에 따라 달라지는 다양한 상수와 타입을 포함하고 있습니다.
Address
타입: 기계의 메모리 주소를 표현하는 데이터 타입입니다. C 언어의void*
와 개념적으로 동일하며, 특정 타입과 연결되지 않은 원시적인 메모리 위치를 나타냅니다.Address
타입의 객체에는 직접 산술 연산을 수행할 수 없으며, 이는 타입 안전성을 위한 Ada의 설계 철학을 반영합니다.NULL_ADDRESS
: 아무것도 가리키지 않는 주소를 나타내는Address
타입의 상수입니다.- 스토리지 관련 타입:
Storage_Element
: 메모리의 기본 단위(일반적으로 1 바이트)를 표현하는 모듈러(modular) 타입입니다.Storage_Array
:Storage_Element
의 배열로, 원시 메모리 버퍼(raw memory buffer)를 다룰 때 유용하게 사용됩니다.
20.1.2 주소 얻기: 'address
속성
Ada의 모든 객체(변수, 상수 등)에 'address
속성을 적용하면 해당 객체가 위치한 메모리의 시작 주소를 System.Address
타입으로 얻을 수 있습니다.
이 기능은 C 함수가 포인터를 매개변수로 요구할 때, Ada 변수의 주소를 전달하는 가장 일반적인 방법입니다. POSIX 저수준 인터페이스 라이브러리인 clair
는 이 패턴을 적극적으로 활용합니다. 다음은 clair
의 파일 읽기 함수에서 버퍼의 주소를 C의 read
함수에 전달하는 예시입니다.
[cite_start]-- clair-file.adb [cite: 224]
function read (fd : in Descriptor;
buffer : in out System.Storage_Elements.Storage_Array)
return Natural is
bytes_read : Clair.Types.ssize_t;
begin
-- ...
bytes_read :=
c_read (Interfaces.C.int (fd), buffer'address, buffer'length); -- 'address 속성 사용
-- ...
end read;
20.1.3 특정 주소에 변수 배치하기
'address
속성이 기존 변수의 주소를 읽어오는 기능이라면, 반대로 특정 변수를 미리 정해진 절대 주소에 배치하는 것도 가능합니다. 이는 for ... use
구문을 통해 이루어지며, 주로 메모리 맵 입출력(Memory-Mapped I/O) 방식을 사용하는 하드웨어 장치를 제어할 때 사용됩니다.
-- 16진수 주소 FFFF_A000에 위치하는 하드웨어 제어 레지스터
Device_Control_Register : Register_Type;
for Device_Control_Register'address use
System.Storage_Elements.To_Address(16#FFFF_A000#);
위 코드는 Device_Control_Register
라는 변수를 물리 메모리의 FFFF_A000
번지에 직접 매핑합니다. 이제 이 변수에 값을 읽고 쓰는 것은 해당 메모리 주소에 직접 접근하는 것과 동일한 효과를 가집니다.
20.1.4 System.Storage_Elements
패키지
System.Storage_Elements
는 System
의 자식 패키지로, 원시 주소와 저장 요소를 다루기 위한 변환 함수 등을 제공합니다. To_Address
와 To_Integer
함수는 정수 값을 System.Address
타입으로, 또는 그 반대로 변환하는 데 사용되며, 위 예시처럼 특정 주소를 상수로 지정할 때 필수적입니다.
System
패키지와 Address
타입, 그리고 'address
속성은 Ada에서 저수준 메모리 조작을 위한 기본 도구입니다. 이 기능들을 통해 Ada는 C와 같은 저수준 언어처럼 메모리 주소를 직접 다룰 수 있는 능력을 갖추게 됩니다. 이는 다른 언어와의 인터페이싱, 디바이스 드라이버 작성, 임베디드 시스템 개발 등에서 Ada가 강력한 성능을 발휘할 수 있게 하는 근간이 됩니다.
20.2 표현 명세 (representation clauses)
Ada 컴파일러는 일반적으로 가독성과 실행 효율성에 최적화된 방식으로 데이터 타입의 메모리 표현을 결정합니다. 하지만 하드웨어 레지스터에 직접 접근하거나, 정해진 프로토콜에 따라 데이터를 주고받아야 하는 저수준 프로그래밍에서는 데이터의 비트(bit) 수준 레이아웃까지 프로그래머가 직접 제어해야 합니다.
표현 명세(representation clause)는 데이터 타입의 물리적인 표현(크기, 정렬, 필드 위치 등)을 프로그래머가 명시적으로 지정할 수 있도록 하는 Ada의 강력한 기능입니다. 이는 for ... use
구문을 통해 이루어집니다.
20.2.1 열거형 표현 명세 (Enumeration representation clause)
열거형 타입의 각 리터럴(literal)이 내부적으로 어떤 정수 값에 대응될지를 직접 지정할 수 있습니다. 이는 하드웨어의 상태 레지스터가 특정 정수 값으로 상태를 나타내거나, C 언어의 enum
과 값을 맞추어야 할 때 유용합니다.
type Status_Code is (OK, Warning, Error, Critical);
-- 각 열거형 리터럴에 특정 정수 값을 매핑합니다.
for Status_Code use (OK => 0,
Warning => 4,
Error => 128,
Critical => 255);
이제 Status_Code
타입의 객체는 내부적으로 지정된 정수 값을 가지게 됩니다.
20.2.2 레코드 표현 명세 (Record representation clause)
레코드 표현 명세는 저수준 프로그래밍에서 가장 중요하고 강력한 기능 중 하나로, 레코드의 각 필드가 메모리상에서 차지하는 정확한 비트 위치를 지정할 수 있게 해줍니다. 이를 통해 Ada의 레코드 타입을 하드웨어 장치의 제어 레지스터나 네트워크 패킷 구조에 직접적으로 매핑할 수 있습니다.
레코드 표현 명세는 at
(바이트 오프셋)과 range
(비트 범위) 키워드를 사용합니다.
예제: 하드웨어 디바이스 제어 레지스터
다음은 16비트 크기의 가상 디바이스 제어 레지스터를 Ada 레코드로 모델링하는 예시입니다.
-- 제어 레지스터의 각 필드에 대응하는 타입들을 먼저 정의합니다.
type Mode_Type is (Mode_A, Mode_B, Mode_C, Mode_D);
for Mode_Type use (Mode_A => 0, Mode_B => 1, Mode_C => 2, Mode_D => 3);
-- 제어 레지스터를 나타내는 레코드 타입
type Device_Register is record
enable : Boolean; -- 0번 바이트, 0번 비트
ready : Boolean; -- 0번 바이트, 1번 비트
error_flag : Boolean; -- 0번 바이트, 2번 비트
device_mode : Mode_Type; -- 0번 바이트, 4-5번 비트
data_value : Interfaces.Unsigned_8; -- 1번 바이트, 0-7번 비트
end record;
-- 레코드의 전체 크기를 16비트로 지정
for Device_Register'Size use 16;
-- 각 필드의 정확한 위치를 비트 단위로 지정
for Device_Register use record
enable at 0 range 0 .. 0;
ready at 0 range 1 .. 1;
error_flag at 0 range 2 .. 2;
device_mode at 0 range 4 .. 5;
data_value at 1 range 0 .. 7;
end record;
이제 Device_Register
타입의 객체를 생성하고, 14.1절에서 배운 Address
속성을 이용해 특정 메모리 주소에 위치시키면, 해당 하드웨어 레지스터를 직접 읽고 쓸 수 있게 됩니다.
20.2.3 기타 표현 명세
'Size
속성:for My_Type'Size use 24;
와 같이 타입의 전체 크기를 비트 단위로 강제할 수 있습니다.'Alignment
속성:for My_Type'Alignment use 4;
와 같이 타입의 객체가 메모리에서 정렬될 기준(바이트)을 지정할 수 있습니다. 이는 성능 최적화나 특정 하드웨어의 접근 요구사항을 만족시키기 위해 사용됩니다.
20.2.4 사용 지침 및 주의사항
표현 명세는 매우 강력하지만, 시스템의 이식성을 저해하는 주된 요인이 됩니다. 하드웨어 아키텍처나 컴파일러에 의존적인 코드를 생성하므로, 반드시 필요한 경우에만 제한적으로 사용해야 합니다. 일반적으로 이러한 저수준 코드는 별도의 패키지로 캡슐화하여 시스템의 다른 부분과 격리하는 것이 바람직한 설계 방식입니다.
표현 명세는 Ada가 단순한 고수준 언어를 넘어, 하드웨어와 직접 상호작용하는 임베디드 및 시스템 프로그래밍 분야에서 강력한 역량을 발휘하게 하는 핵심 기능입니다. 이를 통해 프로그래머는 데이터의 물리적 표현을 완벽하게 제어할 수 있으며, 이식성이 중요한 상위 계층의 코드와 하드웨어에 종속적인 최하위 계층의 코드를 명확히 분리하여 신뢰성 높은 시스템을 구축할 수 있습니다.
20.3 비검사 변환 (Unchecked_Conversion)
Ada는 강력한 타입 시스템을 통해 서로 다른 타입 간의 무분별한 데이터 할당을 엄격히 금지함으로써 프로그램의 안정성을 보장합니다. 하지만 다른 언어와의 인터페이싱이나 하드웨어에 직접 접근하는 극히 예외적인 저수준 프로그래밍 상황에서는 이러한 타입 시스템을 의도적으로 우회해야 할 필요가 있습니다.
Ada.Unchecked_Conversion
은 이러한 목적을 위해 제공되는 표준 라이브러리의 제네릭 함수입니다. 이 기능의 이름에 ‘비검사(Unchecked)’라는 단어가 포함된 것은, 컴파일러가 어떠한 안전성 검사도 수행하지 않으므로 사용할 때 극도의 주의가 필요함을 경고하는 것입니다.
20.3.1 정의 및 사용법
Ada.Unchecked_Conversion
은 한 타입(Source
)의 변수가 가진 비트 패턴(bit pattern)을 전혀 변경하지 않고, 그대로 다른 타입(Target
)의 값으로 재해석하는 변환 함수를 생성하는 제네릭 템플릿입니다.
사용하기 위해서는 먼저 변환하고자 하는 Source
와 Target
타입을 지정하여 새로운 함수 인스턴스를 생성해야 합니다.
-- Source_Type을 Target_Type으로 변환하는 함수 인스턴스를 생성
function Convert is new Ada.Unchecked_Conversion
(Source => Source_Type, Target => Target_Type);
-- 사용
Target_Variable := Convert (Source_Variable);
이 변환 과정에서는 어떠한 유효성 검사나 데이터 변환도 일어나지 않습니다. 단지 소스 객체의 메모리 비트 열을 타겟 타입으로 간주할 뿐입니다.
20.3.2 clair
라이브러리의 활용 사례
Unchecked_Conversion
의 가장 주된 정당한 사용 사례는 C 언어 등에서 넘어온 타입 없는 포인터(void*
)를 Ada의 특정 타입 포인터로 변환하는 경우입니다. Ada에서 void*
는 일반적으로 System.Address
타입으로 표현됩니다.
시스템 프로그래밍 라이브러리인 clair
는 C 함수로부터 받은 System.Address
값을 구체적인 C 포인터 타입으로 변환하기 위해 Unchecked_Conversion
을 적극적으로 활용합니다. clair
패키지의 비공개(private) 부분에는 다음과 같은 인스턴스화 코드가 있습니다.
[cite_start]-- clair.ads [cite: 467]
function to_chars_ptr is new Ada.Unchecked_Conversion (
source => System.Address,
target => Interfaces.C.Strings.chars_ptr
);
이 코드는 System.Address
타입의 원시 주소 값을 C 문자열 포인터 타입인 chars_ptr
로 재해석하는 to_chars_ptr
라는 새로운 함수를 생성합니다. [cite: 467] 이를 통해 dlerror
같은 C 함수가 반환한 주소 값을 Ada에서 C 문자열로 안전하게 다룰 수 있게 됩니다.
유사한 패턴이 clair-signal.ads
에서도 발견되며, 이는 System.Address
를 시그널 핸들러의 접근 타입(Handler1_Access
)으로 변환하는 데 사용됩니다. [cite: 454]
20.3.3 안전한 사용을 위한 지침
Unchecked_Conversion
은 타입 시스템을 무력화시키므로, 잘못 사용하면 메모리 오염, 예측 불가능한 동작 등 심각한 문제를 야기할 수 있습니다. 안전한 사용을 위해 다음 지침을 반드시 준수해야 합니다.
-
크기 일치 조건: 변환의 유효성이 보장되려면
Source
타입과Target
타입의 크기('Size
속성)가 반드시 일치해야 합니다. 만약 두 타입의 크기가 다를 경우, 그 동작은 정의되지 않으며 이식성을 완전히 상실하게 됩니다. 정적 단정(Assert (Source'Size = Target'Size);
)을 통해 컴파일 시점에 크기 일치를 검사하는 것이 바람직합니다. -
캡슐화 (Encapsulation):
Unchecked_Conversion
의 사용은 시스템의 특정 부분으로 엄격히 제한하고 캡슐화해야 합니다.clair
가 패키지의private
부분에 인스턴스를 선언한 것처럼[cite: 467], 외부에서는 변환 함수의 존재를 알지 못하도록 하여 무분별한 사용을 막는 것이 매우 중요합니다. -
필요성 검토: 이 기능은 타입 안전성을 포기하는 대가가 따르므로, 타입 변환, 표현 명세 등 다른 방법으로 해결할 수 없는 문제에 한해 최후의 수단으로 사용해야 합니다. 코드 리뷰 시
Unchecked_Conversion
의 등장은 그 필요성에 대한 명확한 근거 제시를 요구하는 신호가 되어야 합니다.
Ada.Unchecked_Conversion
은 Ada의 타입 시스템을 우회할 수 있는 강력한 ‘탈출구’입니다. 저수준 인터페이싱이라는 필수적인 작업을 가능하게 하지만, 그 이름이 경고하듯 내재적인 위험을 안고 있습니다. 따라서 이 기능은 반드시 필요한 곳에 최소한으로 사용되어야 하며, 크기 일치와 캡슐화 원칙을 통해 그 위험을 통제해야 합니다. 이를 통해 시스템 전체의 견고성을 유지하면서 다른 시스템과의 연동이라는 실용적인 목표를 달성할 수 있습니다.
21. 인터페이싱 (Interfacing)
Ada는 높은 수준의 추상화와 강력한 타입 시스템을 통해 소프트웨어의 신뢰성을 보장하는 언어이지만, 현실의 시스템 개발에서는 다른 프로그래밍 언어로 작성된 라이브러리를 활용하거나 하드웨어에 직접 접근해야 하는 경우가 빈번합니다. 인터페이싱(Interfacing)은 이처럼 Ada 프로그램이 외부 세계와 상호작용할 수 있도록 하는 메커니즘을 총칭합니다.
Ada는 다른 언어와의 연동 및 저수준 프로그래밍을 위해 표준화된 Interfaces
패키지를 제공하며, 이를 통해 안전하고 예측 가능한 방식으로 추상화의 경계를 넘어설 수 있습니다. 본 장에서는 Interfaces
패키지를 시작으로 C, 포트란 등 다른 언어와의 연동 기법을 탐구합니다.
21.1 Interfaces 패키지
Interfaces
는 Ada 언어에 내장된 표준 패키지로, 저수준 프로그래밍과 다른 언어와의 연동을 위한 모든 기능의 출발점 역할을 합니다. 이 패키지는 크게 두 가지 핵심 영역의 기능을 제공합니다.
- 언어 간 인터페이싱: 다른 프로그래밍 언어의 타입 및 규약과 호환되는 기능 제공
- 저수준 프로그래밍: 하드웨어의 데이터 표현 방식을 직접 다루는 기능 제공
21.1.1 언어 간 인터페이싱
Interfaces
패키지는 다른 프로그래밍 언어와의 연동을 전담하는 자식 패키지들을 포함하고 있습니다. 이 자식 패키지들은 대상 언어의 기본 타입에 정확히 대응하는 Ada 타입을 정의하여, 두 언어 간에 데이터를 원활하게 교환할 수 있도록 합니다.
Interfaces.C
: C 언어와의 연동을 위한 핵심 패키지입니다.int
,long
,char
,size_t
등 C의 표준 타입에 대응하는 Ada 타입을 제공합니다. 시스템 프로그래밍 라이브러리인clair
는 POSIX C API와 상호작용하기 위해 이 패키지를 광범위하게 사용합니다. [cite: 55, 117, 137, 337, 391]Interfaces.C.Strings
: C 언어 스타일의 널-종료(null-terminated) 문자열(char *
)을 다루기 위한 타입(chars_ptr
)과 관련 서브프로그램을 제공합니다. [cite: 56, 117, 467]Interfaces.Fortran
: 포트란(Fortran) 언어와의 연동을 위한 타입과 규약을 제공합니다.Interfaces.COBOL
: 코볼(COBOL) 언어와의 연동을 위한 타입과 규약을 제공합니다.
21.1.2 하드웨어 및 저수준 프로그래밍
Interfaces
패키지는 특정 하드웨어 아키텍처에 의존적인 저수준 코드를 작성할 수 있도록, 이식성 있는 방법을 제공합니다.
-
고정 너비 정수 타입 (Fixed-Width Integer Types) Ada의 표준 타입인
Integer
나Long_Integer
는 컴파일러나 플랫폼에 따라 표현하는 비트 수가 다를 수 있습니다. 하지만 디바이스 레지스터를 제어하거나 네트워크 프로토콜을 구현할 때는 데이터의 비트 수가 정확히 고정되어야 합니다.Interfaces
패키지는Integer_8
,Unsigned_16
,Integer_32
,Unsigned_64
와 같이 비트 수가 명시된 고정 너비 정수 타입을 제공하여, 플랫폼에 상관없이 일관된 데이터 표현을 보장합니다. -
비트 단위 연산 (Bitwise Operations) 저수준 프로그래밍에서는 비트(bit) 단위의 조작이 필수적입니다.
Interfaces
패키지는 고정 너비 정수 타입을 대상으로 하는 다양한 비트 단위 연산 함수를 제공합니다.- Shift 연산:
Shift_Left
,Shift_Right
,Shift_Right_Arithmetic
- Rotate 연산:
Rotate_Left
,Rotate_Right
- 논리 연산:
And
,Or
,Xor
,Not
이 함수들은 대부분 하드웨어의 기계어 명령에 직접 대응되므로 매우 효율적으로 동작합니다. 예를 들어, 특정 비트 마스크를 적용하는 코드는 다음과 같이 작성할 수 있습니다.
Masked_Value := Interfaces.And (Initial_Value, Mask);
- Shift 연산:
Interfaces
패키지는 Ada가 제공하는 높은 수준의 추상화와 현실 세계의 저수준 요구사항 사이를 잇는 필수적인 다리입니다. 이 패키지를 통해 개발자는 다른 언어로 작성된 방대한 라이브러리 자산을 안전하게 활용하고, 하드웨어의 특성을 직접 제어하는 고성능 코드를 작성할 수 있습니다. 시스템 프로그래밍, 임베디드 개발, 혼합 언어 프로젝트에 참여하는 Ada 개발자에게 Interfaces
패키지에 대한 깊은 이해는 필수적입니다.
21.2 C 언어와의 인터페이싱 (pragma import, pragma convention)
C 언어는 현대 운영체제의 구현 언어이자 시스템 프로그래밍의 사실상 표준(de facto standard)입니다. 따라서 Ada 프로그램이 운영체제 서비스에 직접 접근하거나 방대한 C 라이브러리 생태계를 활용하기 위해서는 C와의 원활한 인터페이싱이 필수적입니다. Ada는 이를 위해 표준화되고 타입-안전(type-safe)한 메커니즘을 제공합니다.
핵심적인 구성 요소는 Interfaces.C
패키지와 import
, convention
, Export
프라그마입니다.
21.2.1 Interfaces.C
패키지: 타입 호환성
두 언어 간에 데이터를 교환하려면 양쪽의 데이터 타입이 동일한 크기와 표현 방식을 가져야 합니다. Interfaces.C
패키지와 그 자식 패키지들은 C 언어의 기본 타입에 정확히 대응하는 Ada 타입을 제공하여 타입 호환성을 보장합니다.
- 기본 타입:
Interfaces.C.int
,Interfaces.C.unsigned_char
,Interfaces.C.size_t
등 C의 표준 타입들이 정의되어 있습니다. [cite: 55, 117] - 문자열: C의
char*
형태 null-종료 문자열은Interfaces.C.Strings
패키지를 통해 다룹니다. 이 패키지는 C 문자열 포인터 타입인chars_ptr
와 관련 유틸리티 함수(예:Value
,To_C
)를 제공합니다. [cite: 55]
clair
라이브러리의 get_dl_error
함수는 Interfaces.C.Strings
를 사용하여 C 함수가 반환한 char*
를 Ada의 String
으로 변환하는 전형적인 예시를 보여줍니다. [cite: 61, 62]
21.2.2 C 함수 호출하기: pragma import
pragma import
는 외부 C 함수를 Ada에서 호출할 수 있도록 연결하는 가장 핵심적인 프라그마입니다. 이는 Ada 서브프로그램 선언을 특정 C 심볼(symbol)에 바인딩합니다.
일반적인 구문:
pragma import (c, Ada_서브프로그램_이름, "C_함수_이름");
clair
라이브러리는 이 프라그마를 광범위하게 사용합니다. 예를 들어 clair-dl.adb
에서는 C의 동적 라이브러리 로딩 함수인 dlopen
을 다음과 같이 임포트합니다.
-- clair-dl.adb
function c_dlopen (path : Interfaces.C.Strings.chars_ptr;
mode : Interfaces.C.int) return System.Address;
pragma import (c, c_dlopen, "dlopen"); -- "dlopen" C 함수를 c_dlopen에 바인딩
[cite: 57, 58]
때로는 이식성을 높이거나 C의 복잡한 매크로를 처리하기 위해 직접적인 시스템 콜 대신 C로 작성된 래퍼 함수(wrapper function) 를 임포트하는 것이 더 나은 전략일 수 있습니다. clair-error-c.c
파일은 errno
와 strerror_r
같은 C 표준 라이브러리 기능을 안정적으로 접근할 수 있는 clair_error_get_errno
와 clair_error_strerror_r
함수를 제공합니다. [cite: 102, 114] Ada 측에서는 이 래퍼 함수들을 임포트하여 사용합니다. [cite: 119, 574]
21.2.3 데이터 구조 정렬: pragma convention
Ada 컴파일러는 기본적으로 레코드(record)의 필드를 메모리 공간이나 접근 속도에 최적화되도록 재배열할 수 있습니다. 하지만 C의 구조체(struct
)는 항상 선언된 순서대로 메모리에 배치됩니다. 두 언어 간에 레코드/구조체를 주고받으려면 Ada 레코드의 메모리 레이아웃이 C 구조체와 정확히 일치해야 합니다.
pragma convention (c, 레코드_타입_이름);
은 해당 레코드 타입의 필드를 C 구조체처럼 선언된 순서대로, 패딩(padding) 규칙도 C의 관례를 따르도록 컴파일러에 지시합니다.
-- C struct:
-- typedef struct {
-- int item_id;
-- double item_value;
-- } C_Record;
-- Corresponding Ada record:
type C_Record is record
item_id : Interfaces.C.int;
item_value : Interfaces.C.double;
end record;
pragma convention (c, C_Record); -- 메모리 레이아웃을 C와 호환되게 함
21.2.4 Ada 서브프로그램 노출하기: pragma export
pragma export
는 import
의 반대 역할을 합니다. Ada로 작성된 서브프로그램을 C 코드에서 호출할 수 있도록 외부로 노출(export)합니다. 이는 Ada로 작성된 라이브러리를 C/C++ 애플리케이션에서 사용하거나, C 라이브러리가 요구하는 콜백(callback) 함수를 Ada로 구현할 때 필수적입니다.
일반적인 구문:
pragma export (c, Ada_서브프로그램_이름, "C에서_사용할_이름");
pragma import
, pragma export
, pragma convention
, 그리고 Interfaces.C
패키지는 Ada와 C 언어 간의 상호작용을 위한 완벽하고 표준화된 도구 집합을 제공합니다. clair
라이브러리가 보여주듯, 이 메커니즘을 통해 기존의 방대한 C 라이브러리 자산을 활용하고 운영체제의 저수준 기능에 직접 접근하면서도, Ada가 제공하는 타입 안전성과 높은 신뢰성을 그대로 유지할 수 있습니다. 이는 단순한 언어 연동 기능을 넘어, 검증되고 안정적인 혼합 언어 시스템을 구축하는 견고한 기반이 됩니다.
21.3 Ada와 어셈블리 연동
Ada는 높은 수준의 추상화와 안전성을 제공하지만, 때로는 성능을 극한까지 최적화하거나, 특정 하드웨어 명령어를 직접 실행하거나, 운영체제 커널의 가장 깊은 부분과 상호작용하기 위해 기계어에 가장 가까운 언어인 어셈블리(Assembly)를 사용해야 할 필요가 있습니다.
다른 언어들이 컴파일러에 종속적인 비표준 확장 기능에 의존하는 것과 달리, Ada는 이러한 저수준 통합을 위한 표준화되고 구조적인 방법을 언어 차원에서 제공합니다. 이는 어셈블리 코드의 사용을 예측 가능하게 만들고, 코드의 다른 부분과의 인터페이스를 명확하게 하여 이식성과 유지보수성을 유지하려는 Ada의 설계 철학을 반영합니다.
이번 절에서는 Ada 프로그램에 어셈블리 코드를 통합하는 두 가지 주요 방법을 학습합니다. 첫째는 System.Machine_Code
패키지를 사용하여 Ada 코드 내에 직접 어셈블리 명령어를 삽입하는 인라인 어셈블리 방식이고, 둘째는 별도의 어셈블리 파일에 작성된 루틴을 pragma import
를 통해 안전하게 연동하는 외부 어셈블리 연동 방식입니다.
21.3.1 인라인 어셈블리 (System.Machine_Code
)
인라인 어셈블리는 Ada 코드 내에 어셈블리 명령어를 직접 삽입하는 가장 직접적인 방법입니다. 이 방식은 특정 하드웨어 레지스터를 조작하거나, 몇 개의 명령어로 이루어진 고도로 최적화된 연산을 수행하는 등, 짧고 명확한 목적을 가진 저수준 상호작용에 매우 적합합니다.
Ada에서는 System.Machine_Code
패키지의 Asm
프로시저를 통해 이 기능을 표준화된 방식으로 제공합니다.
Asm
프로시저의 구조
System.Machine_Code.Asm
프로시저는 일반적으로 세 가지 주요 매개변수를 가집니다.
- 어셈블리 템플릿 (Template): 실행할 어셈블리 명령어들을 담은 문자열입니다. 이 문자열 안에는
%0
,%1
과 같은 플레이스홀더를 사용하여 Ada 변수에 대응하는 레지스터나 메모리 피연산자를 참조할 수 있습니다. - 입력 (Inputs): Ada 변수의 값을 어셈블리 코드로 전달하기 위한 설정입니다.
'Asm_Input
속성을 사용합니다. - 출력 (Outputs): 어셈블리 코드의 실행 결과를 Ada 변수에 저장하기 위한 설정입니다.
'Asm_Output
속성을 사용합니다.
예제: show_asm_result.adb
다음은 Ada 변수 input_value
에 100을 넣고, 어셈블리 코드에서 이 값에 23을 더한 뒤, 그 결과를 다시 Ada 변수 result_from_asm
으로 가져오는 완전한 예제입니다.
with Ada.Text_IO;
with System.Machine_Code;
procedure show_asm_result is
input_value : Integer := 100;
result_from_asm : Integer;
begin
Ada.Text_IO.put_line (" Ada -> 어셈블리로 전송: " & Integer'image(input_value));
System.Machine_Code.asm (
-- 어셈블리 명령어 템플릿:
-- %0은 첫 번째 출력(Outputs) 피연산자, %1은 첫 번째 입력(Inputs) 피연산자를 가리킵니다.
Template => "movl %1, %0; addl $23, %0",
-- [Inputs]
-- 'input_value' 변수를 어셈블리 코드에 대한 입력으로 지정합니다.
-- "r": 'r'은 'register'의 약자로, 컴파일러에게 이 변수 값을
-- 아무 범용 레지스터(general-purpose register)에 넣어달라고 요청합니다.
-- 어셈블리 템플릿에서는 %1으로 이 레지스터를 참조할 수 있습니다.
Inputs => (Integer'asm_input ("r", input_value)),
-- [Outputs]
-- 어셈블리 코드의 결과를 'result_from_asm' 변수에 저장하도록 지정합니다.
-- "=": 이 피연산자가 출력 전용(write-only)임을 나타내는 제약 조건입니다.
-- "r": 결과값 또한 범용 레지스터에 저장됨을 의미합니다.
-- 어셈블리 템플릿에서는 %0으로 이 레지스터를 참조할 수 있습니다.
Outputs => (Integer'asm_output ("=r", result_from_asm))
);
Ada.Text_IO.put_line (" Ada <- 어셈블리로부터 수신: " & Integer'image(result_from_asm));
Ada.Text_IO.put_line ("------------------------------------");
if result_from_asm = 123 then
Ada.Text_IO.put_line ("성공: 결과가 정확합니다!");
else
Ada.Text_IO.put_line ("실패: 결과가 부정확합니다.");
end if;
end show_asm_result;
분석:
Asm
프로시저 호출은 컴파일러에게 다음과 같이 지시합니다.
input_value
(100)를 범용 레지스터(예:%esi
)에 로드합니다.result_from_asm
이 저장될 다른 범용 레지스터(예:%eax
)를 준비합니다.- 템플릿의
%1
을%esi
로,%0
을%eax
로 치환하여movl %esi, %eax; addl $23, %eax
명령어를 생성합니다. - 이 어셈블리 코드를 실행하면,
%eax
레지스터의 값은 123이 됩니다. - 마지막으로
%eax
레지스터의 값을 Ada 변수result_from_asm
에 저장합니다.
이처럼 System.Machine_Code.Asm
은 Ada의 타입 시스템과 컴파일러의 레지스터 할당 기능을 활용하여, Ada 변수와 저수준 어셈블리 코드 간의 데이터 교환을 비교적 안전하고 구조적으로 수행할 수 있는 방법을 제공합니다.
21.3.2 외부 어셈블리 연동 (pragma import
)
어셈블리 코드가 길어지거나 여러 루틴으로 구성될 경우, Ada 소스 코드 내에 직접 삽입하는 것보다 별도의 어셈블리 파일(.s
)로 분리하여 관리하는 것이 훨씬 더 깔끔하고 모듈적인 접근 방식입니다.
Ada는 pragma import
에 assembler
규약을 사용하여, 이렇게 외부 파일에 작성된 어셈블리 함수를 Ada 서브프로그램에 안전하게 연결할 수 있는 기능을 제공합니다. 이 방식은 C 함수를 연동하는 과정과 매우 유사합니다.
단계별 예제
두 개의 정수를 더하는 간단한 함수를 어셈블리로 작성하고, 이를 Ada에서 호출하는 전체 과정을 살펴보겠습니다.
1단계: 어셈블리 코드 작성 (math_ops.s
)
먼저, 두 숫자를 더하는 어셈블리 함수 my_add
를 math_ops.s
파일에 작성합니다. x86-64 아키텍처의 System V ABI 호출 규약에 따라, 첫 번째와 두 번째 정수 인자는 각각 EDI
와 ESI
레지스터를 통해 전달됩니다. 반환 값은 EAX
레지스터를 통해 전달됩니다.
.global my_add
.type my_add, @function
my_add:
movl %edi, %eax
addl %esi, %eax
ret
# 이 섹션은 스택이 실행 가능할 필요가 없음을 선언하여,
# 흔한 링커 경고를 해결하고 보안을 향상시킵니다.
.section .note.GNU-stack,"",@progbits
2단계: Ada 인터페이스 선언 (math_functions.ads
)
다음으로, my_add
어셈블리 함수를 호출하기 위한 Ada 패키지 명세를 작성합니다. pragma import
를 사용하여 Ada 함수 선언을 외부 어셈블리 심볼에 연결합니다.
package Math_Functions is
function my_add (x, y : Integer) return Integer;
private
-- 'assembler' 규약을 사용하여 어셈블리 루틴임을 명확히 합니다.
-- 외부 심볼 "my_add"를 Ada 함수 my_add에 연결합니다.
pragma import (assembler, my_add, "my_add");
end Math_Functions;
3단계: Ada 메인 프로시저 작성 (main.adb
)
이제 Math_Functions
패키지를 사용하여 my_add
함수를 일반적인 Ada 함수처럼 호출할 수 있습니다.
with Ada.Text_IO;
with Math_Functions;
procedure main is
result : Integer;
begin
result := Math_Functions.my_add(10, 5);
Ada.Text_IO.put_line ("외부 어셈블리 결과: " & Integer'image(result));
end main;
컴파일 및 링크
Ada 소스 코드와 어셈블리 소스 코드를 함께 빌드해야 하므로, 컴파일 과정이 조금 더 복잡합니다.
방법 A: gprbuild
사용 (권장)
여러 언어가 혼합된 프로젝트를 빌드하는 가장 현대적이고 쉬운 방법은 GNAT 프로젝트 파일(.gpr
)과 gprbuild
도구를 사용하는 것입니다.
-
GNAT 프로젝트 파일 (
my_project.gpr
) 생성: 프로젝트에 Ada와 Assembly 언어가 포함되어 있음을 명시합니다.project My_Project is for Source_Dirs use ("."); for Object_Dir use "obj"; for Main use ("main.adb"); for Languages use ("Ada", "Assembly"); end My_Project;
-
빌드:
gprbuild
명령어로 프로젝트를 빌드합니다.gprbuild
가 자동으로.s
파일을 어셈블하고.adb
파일을 컴파일하여 함께 링크해 줍니다.gprbuild -P my_project.gpr
방법 B: 수동 컴파일
gprbuild
를 사용하지 않을 경우, 각 소스 파일을 수동으로 컴파일하고 링크할 수 있습니다.
-
어셈블리 파일 어셈블:
gcc
를 사용하여.s
파일을 오브젝트 파일(.o
)로 변환합니다.gcc -c math_ops.s -o math_ops.o
-
Ada 코드 컴파일 및 링크:
gnatmake
로 Ada 코드를 컴파일하면서,-largs
스위치를 사용하여 링커에게 어셈블된 오브젝트 파일을 함께 링크하도록 지시합니다.gnatmake main.adb -largs math_ops.o
이처럼 pragma import
와 assembler
규약은 더 크고 복잡한 어셈블리 루틴을 Ada 프로젝트에 체계적으로 통합할 수 있는 모듈적인 방법을 제공합니다.
21.4 포트란 및 다른 언어와의 연동
Ada의 인터페이싱 능력은 C 언어에만 국한되지 않습니다. Ada 표준은 과학 및 공학 계산 분야에서 널리 사용되는 포트란(Fortran) 과의 직접적인 연동을 지원하며, C 언어를 매개로 하여 사실상 C와 연동이 가능한 모든 현대 언어(Python, Java, C++ 등)와 상호 운용할 수 있는 길을 열어두고 있습니다.
21.4.1 포트란(Fortran)과의 직접 연동
Ada와 포트란은 고성능 수치 계산, 국방, 항공우주 등 여러 분야에서 함께 사용되는 경우가 많아, 두 언어 간의 원활한 상호 운용성이 중요합니다. Ada는 이를 위해 C 언어와 유사한 수준의 체계적인 지원을 제공합니다.
-
Interfaces.Fortran
패키지 이 표준 패키지는 포트란의 내장된 타입에 대응하는 Ada 타입을 정의합니다. 예를 들어Fortran_Integer
,Real
,Double_Precision
,Logical
,Complex
등이 있으며, 이를 통해 타입 변환 없이 데이터를 주고받을 수 있습니다. -
convention (Fortran)
프라그마 이 프라그마는 Ada의 레코드나 배열 타입을 포트란의 메모리 레이아웃 규칙에 맞게 정렬하도록 컴파일러에 지시합니다. 이는 두 언어 간에 복합 데이터를 정확하게 전달하기 위해 필수적입니다. -
호출 규칙 (calling conventions)
pragma import (Fortran, ...)
와pragma export(Fortran, ...)
를 사용하여 포트란의 서브루틴 및 함수를 Ada에서 호출하거나, 반대로 Ada 서브프로그램을 포트란에서 호출할 수 있도록 지정합니다. 컴파일러는 이 프라그마를 통해 포트란의 호출 규약(인자 전달 방식, 이름 변환 규칙 등)에 맞는 코드를 생성합니다.-- 포트란의 SUBROUTINE F_CALC (A, B, C)를 Ada에서 호출 procedure F_Calc (A, B : in Interfaces.Fortran.Real; C : out Interfaces.Fortran.Real); pragma import (Fortran, F_Calc, "f_calc_");
주요 고려사항: 배열 순서 (array Ordering)
포트란 연동 시 가장 주의해야 할 차이점은 다차원 배열의 메모리 저장 순서입니다.
- Ada/C: 행 우선 순서 (Row-Major Order).
A(i, j)
에서 인덱스j
가 먼저 증가합니다. - 포트란: 열 우선 순서 (Column-Major Order).
A(i, j)
에서 인덱스i
가 먼저 증가합니다.
이 차이로 인해, 두 언어 간에 2차원 배열을 주고받을 때는 논리적으로 인덱스가 뒤바뀐 것처럼 다루어야 합니다. 즉, Ada 코드의 My_Array(I, J)
는 포트란의 MY_ARRAY(J, I)
에 해당합니다. convention (Fortran)
프라그마는 이러한 변환을 컴파일러가 올바르게 처리하도록 돕습니다.
21.4.2 C를 통한 다른 언어와의 연동
Ada가 직접 지원하지 않는 언어(예: Python, Java, C#, Rust 등)와의 연동은 대부분 C 언어를 공통의 다리(bridge)로 사용하는 간접적인 방식으로 이루어집니다. 거의 모든 현대 언어는 C와 상호 작용할 수 있는 잘 정의된 FFI(Foreign Function Interface)를 제공하기 때문입니다.
기본 원리: Ada <--> C <--> Other Language
-
Ada -> 다른 언어:
- Ada 서브프로그램을
pragma export (c, ...)
를 사용하여 C에서 호출 가능한 함수로 만듭니다. - Ada 코드를 공유 라이브러리(
.so
,.dll
)로 컴파일합니다. - 대상 언어의 C FFI(예: Python의
ctypes
, Java의 JNI, Rust의extern "C"
)를 사용하여 공유 라이브러리의 함수를 로드하고 호출합니다.
- Ada 서브프로그램을
-
다른 언어 -> Ada:
- 대상 언어의 함수를 C에서 호출 가능한 형태로 만듭니다.
- Ada 코드에서
pragma import (c, ...)
를 사용하여 해당 함수를 임포트하여 호출합니다.
데이터 마샬링 (data marshaling)
이 방식의 핵심 과제는 데이터 마샬링, 즉 언어 경계를 넘어 데이터를 전달하기 위해 한쪽의 복잡한 데이터 구조를 양쪽 모두가 이해할 수 있는 단순한 C 호환 포맷(기본 타입, C 구조체, 포인터 등)으로 변환하고 다시 복원하는 과정입니다. 이는 종종 양쪽에 데이터 변환을 담당하는 래퍼(wrapper) 코드를 작성하는 작업을 수반합니다.
Ada는 C뿐만 아니라, 과학 기술 컴퓨팅의 핵심 언어인 포트란과의 직접적인 인터페이싱을 표준으로 지원하여 높은 수준의 상호 운용성을 제공합니다. 또한, C 언어를 매개로 하는 FFI 전략을 통해 사실상 모든 주요 프로그래밍 언어와 연동할 수 있는 확장성을 갖추고 있습니다.
성공적인 언어 간 인터페이싱은 단순히 함수를 호출하는 것을 넘어, 각 언어의 데이터 표현 방식과 호출 규약을 정확히 이해하고 그 차이를 신중하게 다루는 것에 달려있습니다. 이를 통해 Ada는 기존의 방대한 언어 생태계와 자산을 활용하면서도 자체의 신뢰성과 안정성의 강점을 유지할 수 있습니다.
21.5 [심화 학습] 중첩 함수와 트램펄린
Ada는 높은 수준의 추상화를 제공하지만, 때로는 그 이면의 컴파일러 동작 방식을 이해해야만 해결할 수 있는 문제와 마주칩니다. 트램펄린(trampoline)은 그러한 컴파일러의 내부 동작 중 하나로, 주로 중첩 함수(nested function)와 관련된 특정 상황을 처리하기 위해 사용됩니다.
이 장에서는 트램펄린이 필요한 이유와 동작 원리, 그리고 그로 인해 발생하는 보안 문제와 해결책까지 심도 있게 살펴보겠습니다.
21.5.1 트램펄린의 필요성
트램펄린은 함수 내부에 정의된 중첩 함수의 주소를 외부(예: C 라이브러리, 운영체제 콜백)로 전달해야 할 때 발생하는 문제를 해결합니다.
아래 코드 예제를 통해 문제 상황을 명확히 이해해 보겠습니다.
procedure Outer_Procedure is
-- Outer_Procedure의 지역 변수
X : Integer := 10;
-- 중첩된 함수
procedure Inner_Handler is
begin
-- 이 함수는 부모 함수의 변수 X를 사용해야 합니다.
Ada.Text_IO.put_line ("X is: " & Integer'image(X));
end Inner_Handler;
begin
-- Inner_Handler의 주소를 외부 C 함수에 전달한다고 가정
Some_C_Function (Inner_Handler'address);
end Outer_Procedure;
Inner_Handler
는 부모 함수 Outer_Procedure
의 지역 변수 X
에 접근해야 합니다. 하지만 외부 C 함수는 Inner_Handler
의 코드 주소만 가지고 있으므로, 호출 시점에 X
가 메모리 어디에 있는지 알 방법이 없습니다. 이 문제를 해결하기 위해 컴파일러는 트램펄린 기법을 사용합니다.
21.5.2 트램펄린의 동작 원리
트램펄린은 컴파일러가 동적으로 생성하는 작은 기계어 코드 조각입니다. 그 동작 방식은 다음과 같습니다.
-
트램펄린 생성:
outer_procedure
가 실행될 때, 컴파일러는 스택 메모리에 트램펄린 코드를 생성합니다. -
정보 저장: 이 트램펄린 코드 안에는 다음 두 가지 핵심 정보가 포함됩니다.
- 실제
inner_handler
함수의 코드 주소 - 부모(
outer_procedure
)의 환경 정보(스택 프레임) 주소, 즉 변수X
에 접근하기 위해 필요한 정보
- 실제
-
주소 전달: 컴파일러는 외부 C 함수에 실제
inner_handler
의 주소가 아닌, 트램펄린의 주소를 전달합니다. -
실행 및 점프: 외부 C 함수가 전달받은 주소를 호출하면 트램펄린 코드가 실행됩니다. 이 코드는 다음 두 가지 작업을 순서대로 수행합니다.
- 저장된 부모의 환경 정보 주소를 특정 레지스터에 설정합니다.
- 곧바로 실제
inner_handler
함수의 코드로 제어를 이전(jump)합니다.
이 과정을 통해 inner_handler
는 자신의 부모 환경에 정상적으로 접근할 수 있게 됩니다.
21.5.3 트램펄린과 보안: 실행 가능한 스택의 위험성
트램펄린 기법은 실행 가능한 스택(executable stack)을 요구한다는 중요한 특징이 있습니다. 트램펄린은 스택에 위치하는 ‘코드’이므로, 운영체제는 해당 스택 메모리 영역에 실행 권한을 부여해야 합니다.
실행 가능한 스택은 시스템에 심각한 보안 취약점을 만듭니다. 가장 대표적인 공격이 버퍼 오버플로우(buffer overflow)입니다. 공격자는 프로그램의 입력 버퍼를 넘치게 하여 스택에 악성 코드를 주입한 뒤, 함수의 복귀 주소를 변조하여 해당 악성 코드를 실행시킬 수 있습니다. 이러한 공격은 스택에 실행 권한이 있을 때만 성공할 수 있습니다.
결론적으로 트램펄린이라는 합법적인 코드를 실행하기 위해 스택 실행을 허용하는 것이, 잠재적으로 악성 코드의 실행 경로를 열어주는 보안 문제를 야기합니다. 이것이 바로 최신 운영체제와 CPU가 하드웨어 수준에서 데이터 실행 방지(DEP, NX-bit) 기술로 스택 실행을 막고, 컴파일러가 관련 경고를 보내는 이유입니다.
21.5.4 현실에서의 문제: 링킹, 최적화, 그리고 해결책
그렇다면 중첩 함수는 항상 트램펄린을 사용할까요? 그렇지 않습니다. 트램펄린의 사용 여부는 링킹 방식이 아닌, 컴파일러의 판단에 달려있습니다.
- 트램펄린 불필요: 중첩 함수가 단지 부모 함수 내부에서만 직접 호출될 경우, 컴파일러는 호출 관계를 명확히 알므로 트램펄린 없이 최적화된 코드를 생성합니다.
- 트램펄린 필요: 중첩 함수의 주소가 외부로 전달될 경우, 컴파일러는 호출 시점의 환경을 보장할 수 없으므로 트램펄린을 사용합니다.
이러한 컴파일러의 결정은 실제 프로그래밍에서 예상치 못한 문제로 나타나기도 합니다.
- 링커 경고 및 에러: 트램펄린이 사용되면 ‘실행 가능한 스택’이 필요하고, 이는 정적 링킹 시 링커 경고를 유발할 수 있습니다. 만약
-z,noexecstack
과 같은 보안 옵션을 적용하면, 트램펄린 코드가 실행될 수 없어STORAGE_ERROR
와 함께 프로그램이 비정상 종료됩니다. - 최종 해결책: 가장 안전하고 확실한 방법은, 함수의 주소를 외부에 전달해야 할 때 중첩 함수를 사용하지 않는 것입니다. 대신 최상위 레벨(library-level)에 서브프로그램을 선언하면, 트램펄린과 관련된 복잡성과 보안 문제에서 벗어나 명확하고 안전한 코드를 작성할 수 있습니다.
22. SPARK - 신뢰할 수 있는 소프트웨어 구축
소프트웨어의 신뢰성과 안전성이 극도로 중요한 시스템, 예를 들어 항공우주, 국방, 원자력, 의료 기기 등에서는 단순한 테스트만으로는 충분한 보증을 제공하기 어렵습니다. 이러한 고신뢰(high-integrity) 시스템은 실행 시점 오류의 부재(Absence of Run-Time Errors), 명세에 부합하는 기능적 정확성 등을 수학적으로 증명할 것을 요구합니다. SPARK는 이러한 요구사항을 만족시키기 위해 설계된 프로그래밍 언어, 도구 집합, 그리고 설계 방법론입니다.
본 장에서는 신뢰할 수 있는 소프트웨어 구축을 위한 SPARK의 핵심 개념을 소개하고, 정적 분석과 정형 검증(formal verification)을 통해 코드의 무결성을 증명하는 원리를 탐구합니다.
22.1 SPARK 개요 및 Ada와의 관계
22.1.1 SPARK의 정의와 목적
SPARK는 Ada 프로그래밍 언어에 기반한, 정형적으로 정의된 부분집합(formally-defined subset) 입니다. 이는 SPARK로 작성된 코드가 수학적 분석, 즉 정형 검증에 적합하도록 설계되었음을 의미합니다. SPARK의 주된 목적은 개발 초기 단계부터 코드에 내재된 잠재적 결함을 정적 분석(Static Analysis)을 통해 체계적으로 발견하고 제거함으로써, 소프트웨어의 신뢰성을 최고 수준으로 확보하는 것입니다.
SPARK 도구 집합, 특히 GNATprove는 다음과 같은 속성을 증명하는 데 사용됩니다.
- 실행 시점 오류의 부재 (Absence of Run-Time Errors, AoRTE): 버퍼 오버플로우, 0으로 나누기, 정수 오버플로우, 초기화되지 않은 변수 접근 등과 같은 모든 잠재적 실행 시점 오류가 코드 내에 존재하지 않음을 수학적으로 증명합니다.
- 데이터 흐름의 무결성 (Integrity of Data Flows): 정보가 의도된 경로로만 흐르고, 중요 데이터가 오염되지 않음을 보장합니다.
- 기능적 정확성 (Functional Correctness): 서브프로그램의 코드가 명시된 계약(contract), 즉 전제조건(precondition)과 후제조건(postcondition)을 완벽하게 만족함을 증명합니다. 이는 코드가 설계 명세대로 정확히 동작함을 의미합니다.
22.1.2 Ada와 SPARK의 관계
SPARK와 Ada의 관계는 ‘상호 보완적인 전문화’로 정의할 수 있습니다. SPARK는 Ada의 신뢰성 철학을 극대화하기 위한 선택과 집중의 결과물입니다.
-
부분집합 관계 (Subset Relationship) SPARK의 가장 핵심적인 특징은 Ada의 엄격한 부분집합이라는 점입니다. 모든 유효한 SPARK 코드는 그 자체로 유효한 Ada 코드입니다. 따라서 SPARK 코드는 어떠한 표준 Ada 컴파일러로도 컴파일 및 실행이 가능합니다. 그러나 그 역은 성립하지 않습니다. 즉, 모든 Ada 코드가 유효한 SPARK 코드는 아닙니다.
- 제거된 기능 (Removed Features)
SPARK는 정형 분석을 어렵거나 불가능하게 만드는 Ada의 특정 기능들을 의도적으로 배제하거나 제한합니다. 이러한 기능들은 본질적으로 예측 불가능한 프로그램 동작을 유발할 수 있기 때문입니다. 대표적인 예는 다음과 같습니다.
- 예외 처리 (exception Handling): 예외는 제어 흐름을 예측하기 어렵게 만듭니다. SPARK는 예외가 발생할 가능성 자체를 원천적으로 증명하여 제거하는 접근 방식을 취합니다.
- 일반적인 접근 타입 (General access types): 포인터와 관련된 복잡한 앨리어싱(aliasing) 문제를 방지하여 데이터 접근을 명확하게 분석할 수 있도록 합니다.
- 부작용이 있는 함수 (Functions with Side Effects): 함수의 결과는 오직 입력 매개변수에 의해서만 결정되어야 한다는 원칙을 강제하여 프로그램 상태 변화의 추론을 용이하게 합니다.
- 추가된 기능: 계약과 명세 (Added Features: Contracts and Annotations)
SPARK는 Ada 2012에 도입된 계약 기반 설계(Design by Contract)를 한층 더 발전시켜 정형 검증에 활용합니다. SPARK는 프라그마(pragma)나 애스펙트(aspect) 형태의 추가적인 어노테이션(annotation)을 통해 코드의 의도를 명확히 기술하도록 요구합니다.
Global
: 서브프로그램이 접근하는 전역 변수를 명시합니다.Depends
: 서브프로그램의 출력이 어떤 입력에 의존하는지를 명세합니다.Pre
,Post
: 서브프로그램의 실행 전후 상태를 수학적으로 기술하는 전제조건과 후제조건입니다.
이러한 계약들은 단순한 실행 시점 검사를 넘어, GNATprove와 같은 정적 분석 도구가 코드의 논리적 정확성을 컴파일 시간에 증명하는 기반이 됩니다.
22.1.3 통합 및 상호운용성
SPARK는 전체 시스템을 모두 SPARK로 작성할 것을 강요하지 않습니다. 하나의 프로젝트 내에서 SPARK로 작성된 신뢰성이 증명된 부분과, 일반 Ada로 작성된 부분을 함께 사용하는 것이 가능합니다. 이 경우, 두 영역 간의 인터페이스를 명확하게 정의하고 검증하는 것이 핵심 과제가 됩니다. 이를 통해 시스템의 가장 치명적인(critical) 부분에 SPARK를 적용하여 신뢰성을 확보하고, 그 외의 부분은 표준 Ada의 유연성을 활용하여 개발 효율성을 높일 수 있습니다.
결론적으로, SPARK는 Ada 언어의 안정성과 표현력을 기반으로, 정형 검증이라는 수학적 엄격함을 추가하여 ‘실패가 허용되지 않는’ 시스템을 구축하기 위한 강력한 해법을 제공합니다. 이는 Ada의 신뢰성 철학을 계승하고 논리적으로 확장한 결과물이라 할 수 있습니다.
22.2 흐름 분석 (Flow Analysis)
흐름 분석(Flow Analysis)은 SPARK의 정적 분석(Static Analysis) 능력의 핵심을 이루는 기술입니다. 이는 프로그램을 실행하지 않고 소스 코드의 구조를 검사하여 정보가 프로그램 내에서 어떻게 흐르는지를 추적하고 검증하는 과정입니다. GNATprove 도구는 흐름 분석을 통해 변수의 초기화 상태, 서브프로그램 간의 데이터 종속성, 그리고 전역 변수 접근과 같은 중요한 속성들을 증명합니다.
이 분석은 16.3절에서 다룰 정형 검증(Formal Verification)의 토대가 되며, 코드의 논리적 정확성을 보장하는 첫 번째 단계 역할을 합니다.
22.2.1 주요 분석 영역
흐름 분석은 크게 데이터 흐름(Data Flow)과 제어 흐름(Control Flow)의 두 가지 측면에서 이루어집니다.
-
데이터 흐름 분석 (Data Flow Analysis):
- 초기화 분석 (Initialization Analysis): 모든 변수가 읽히기 전에 반드시 초기화(쓰기)되었음을 보장합니다. 이는 초기화되지 않은 메모리 접근으로 인해 발생하는 예측 불가능한 동작을 원천적으로 차단합니다.
- 데이터 종속성 분석 (Data Dependency Analysis): 서브프로그램의 출력(out, in out 매개변수 또는 함수 결과)이 어떤 입력(in 매개변수)에 의존하는지를 검증합니다. 이를 통해 의도치 않은 데이터가 결과에 영향을 미치거나, 특정 입력이 아무런 효과를 주지 않는 논리적 오류를 찾아낼 수 있습니다.
- 정보 흐름 분석 (Information Flow Analysis): 보안이 중요한 시스템에서, 특정 보안 등급을 가진 데이터가 허가되지 않은 경로로 유출되지 않음을 보장합니다. 예를 들어, ‘비밀(Secret)’ 등급의 정보가 ‘공개(Public)’ 채널로 흐르는 것을 방지합니다.
-
제어 흐름 분석 (Control Flow Analysis):
- 실행 시점 오류 부재(AoRTE) 검증: 제어 흐름의 모든 경로를 따라 변수가 가질 수 있는 값의 범위를 추적하여, 0으로 나누기, 정수 오버플로우, 배열 인덱스 초과와 같은
Constraint_Error
를 유발할 수 있는 조건이 없음을 증명합니다. - 예외 없는 실행 (exception-Free Execution): SPARK는 예외 처리를 허용하지 않는 대신, 예외를 유발할 수 있는 모든 조건이 실행 경로상에서 발생 불가능함을 흐름 분석을 통해 증명하도록 요구합니다.
- 종료 분석 (Termination Analysis): 모든 루프와 재귀 호출이 반드시 종료됨을 증명하여, 무한 루프나 무한 재귀로 인한 시스템 정지를 방지합니다. 이는 실시간 시스템에서 특히 중요합니다.
- 실행 시점 오류 부재(AoRTE) 검증: 제어 흐름의 모든 경로를 따라 변수가 가질 수 있는 값의 범위를 추적하여, 0으로 나누기, 정수 오버플로우, 배열 인덱스 초과와 같은
22.2.2 흐름 분석을 위한 계약 (Contracts for Flow Analysis)
SPARK는 개발자가 코드의 의도를 명확히 표현할 수 있도록 계약(Contract)이라는 주석(Annotation)을 사용합니다. 흐름 분석기는 이 계약을 기준으로 코드의 정확성을 검증합니다.
-
Global
계약: 서브프로그램이 어떤 전역 변수를 읽고 쓸 수 있는지 명시합니다. 이를 통해 서브프로그램의 부작용(side effect)을 명확하게 제어하고 분석할 수 있습니다.Global => null
은 해당 서브프로그램이 어떠한 전역 변수도 접근하지 않음을 의미합니다. -
Depends
계약: 서브프로그램의 출력이 어떤 입력과 전역 변수에 의존하는지를 명시합니다.package Counter_Package is counter : Integer := 0; procedure increment (value : in Integer) with Global => (In_Out => counter), Depends => (counter => (counter, value)); end Counter_Package;
위 예제에서
increment
프로시저의Depends
계약은 실행 후의counter
값이 실행 전의counter
값과 입력value
에만 의존함을 명시합니다. 만약 프로시저 구현이 다른 변수에 의존한다면, GNATprove는 흐름 분석 중 오류를 보고합니다.
22.2.3 사례 연구: Ada 예외 처리와 SPARK 흐름 분석의 비교
전통적인 Ada와 SPARK가 오류를 다루는 방식의 차이를 이해하기 위해, clair
라이브러리의 파일 읽기 함수를 SPARK의 관점에서 비교 분석할 수 있습니다.
-
전통적인 Ada 접근 방식 (
clair
라이브러리)Clair.File.read
함수는 POSIXread
시스템 콜을 래핑하며, 오류 발생 시errno
값을 확인하여 다양한 예외를 발생시킵니다[cite: 228]. 예를 들어, 파일 디스크립터가 유효하지 않으면Bad_File_Descriptor
예외를, 읽기 도중 신호에 의해 중단되면EINTR
을 확인하여 재시도 로직을 수행합니다[cite: 213, 229, 230].-- Clair.File.read의 일부 -- An error occurred (bytes_read = -1), check errno. declare [cite_start]errno_code : constant Interfaces.C.int := Clair.Error.get_errno; [cite: 228] begin if errno_code = Clair.Error.EINTR then -- Interrupted by a signal, prepare to retry. [cite_start]retry_count := retry_count + 1; [cite: 230] -- ... else -- A real, unrecoverable error occurred. declare error_msg : constant String := Clair.Error.format_posix_error_message (errno_code => errno_code, function_name => "Clair.File.read", context_info => "on fd " & fd'image); begin case errno_code is when Clair.Error.EBADF => [cite_start]raise Clair.Error.Bad_File_Descriptor with error_msg; [cite: 236] when Clair.Error.EIO => [cite_start]raise Clair.Error.Input_Output_Error with error_msg; [cite: 239] -- ... 기타 여러 예외 처리 when others => [cite_start]raise Clair.Error.Unmapped_Error with error_msg; [cite: 249] end case; end; end if; end;
이 방식은 실행 시점에서 발생하는 다양한 오류 상황을 감지하고 처리하는 데 효과적입니다.
-
SPARK 접근 방식
SPARK에서는 이러한 실행 시점 오류를 예외로 처리하는 대신, 애초에 발생하지 않음을 증명하는 것을 목표로 합니다. SPARK로 동일한 기능을 구현한다면 다음과 같은 접근 방식을 취할 것입니다.
-
전제조건(Precondition) 강화:
read
서브프로그램에Pre
계약을 추가하여, 호출되기 전에 파일 디스크립터(fd
)가 반드시 ‘유효하고 열린 상태’여야 함을 명시합니다.procedure read (...) with pre => is_open (fd) and is_readable (fd);
흐름 분석기는 이
read
프로시저를 호출하는 모든 코드에서is_open(fd)
와is_readable(fd)
가 참임을 증명하도록 요구합니다. 이 증명에 성공하면,EBADF
와 같은 오류는 원천적으로 발생할 수 없게 됩니다. -
반환값을 통한 상태 명시:
read
동작이 디스크 공간 부족(ENOSPC
)과 같은 환경적 요인으로 실패할 수 있다면, 예외 대신 결과 상태를 나타내는 열거형 타입과 출력 매개변수를 함께 반환하도록 설계합니다.type Read_Status is (Success, End_Of_File, Device_Error); procedure read (fd : in Descriptor; ... ; status : out Read_Status);
이후
Post
계약을 통해status
가 특정 값을 가질 때의 다른 출력값들의 상태를 명시합니다.
SPARK의 흐름 분석은 이처럼 계약을 기반으로 프로그램의 모든 경로를 분석하여, 개발자가 정의한 속성(초기화, 데이터 종속성, 예외 부재 등)이 위반되지 않음을 보장합니다. 이는 실행 시점의 불확실성을 제거하고 소프트웨어의 예측 가능성과 신뢰성을 크게 향상시킵니다.
-
22.3 정형 검증 (Formal Verification) 증명
정형 검증(Formal Verification)은 SPARK가 제공하는 최고 수준의 보증(Assurance) 단계입니다. 16.2절의 흐름 분석이 변수 초기화, 데이터 흐름, 실행 시점 오류 부재(AoRTE)와 같은 코드의 내재적 속성을 증명하는 데 중점을 둔다면, 정형 검증은 프로그램의 기능적 정확성(Functional Correctness)을 수학적으로 증명하는 과정입니다. 즉, 프로그램이 주어진 명세(specification)에 따라 정확하게 동작하는지를 증명합니다.
이를 위해 SPARK는 서브프로그램의 계약(Contract)을 논리적 명제로 변환하고, 자동화된 정리 증명기(Automated Theorem Prover)를 사용하여 해당 명제가 참임을 입증합니다.
22.3.1 정형 검증의 핵심 요소: 계약(Contracts)
기능적 정확성을 증명하기 위한 명세는 주로 서브프로그램의 전제조건과 후제조건을 통해 정의됩니다.
Pre
(전제조건, Precondition): 서브프로그램이 호출되기 전에 반드시 참이어야 하는 조건입니다. 이 조건을 만족시킬 책임은 서브프로그램을 호출하는 측(caller)에 있습니다.Post
(후제조건, Postcondition): 서브프로그램의 실행이 성공적으로 완료된 후에 보장되어야 하는 조건입니다. 전제조건이 만족되었다는 가정 하에, 이 조건을 만족시킬 책임은 서브프로그램의 구현 자체에 있습니다. 후제조건 내에서는'old
속성을 사용하여 서브프로그램 실행 전의 변수 값을 참조할 수 있습니다.
예시: 두 정수 값을 교환하는 프로시저
procedure swap (x : in out Integer; y : in out Integer) with
post => (x = y'old and y = x'old);
위 swap
프로시저의 후제조건은 “프로시저 실행이 끝났을 때, x
의 값은 실행 전('old
) y
의 값과 같고, y
의 값은 실행 전 x
의 값과 같다”는 논리적 명세를 정의합니다. GNATprove는 이 프로시저의 본문(body)이 이 후제조건을 항상 만족시키는지를 수학적으로 증명하려고 시도합니다.
만약 다음과 같이 잘못된 구현이 제공된다면:
procedure swap (x : in out Integer; y : in out Integer) is
begin
x := y; -- 여기서 y의 원래 값이 사라짐
y := x; -- y는 결국 변경되지 않은 y의 값을 할당받게 됨
end swap;
GNATprove는 y = x'old
라는 후제조건을 증명할 수 없다고 보고하며, 논리적 결함을 지적할 것입니다.
22.3.2 증명 과정 (The Proof Process)
- 증명 의무 생성 (Generation of Proof Obligations): GNATprove는 Ada 코드와 SPARK 계약을 분석하여 여러 개의 증명 의무(Proof Obligation, PO) 또는 검증 조건(Verification Condition, VC)을 생성합니다. 예를 들어
swap
프로시저의 경우, “구현 로직을 거치면x = y'old and y = x'old
가 참이 되는가?”라는 증명 의무가 생성됩니다. - 정리 증명기로 전달: 생성된 증명 의무는 Z3, CVC5, Alt-Ergo와 같은 SMT(Satisfiability Modulo Theories) 솔버 기반의 자동화된 정리 증명기로 전달됩니다.
- 증명 결과 분석: 증명기는 각 의무에 대해 다음 중 하나의 결과를 반환합니다.
- 증명 완료 (Proved): 명제가 수학적으로 참임이 입증되었습니다. 코드가 명세를 만족합니다.
- 증명 불가 (Unprovable): 명제를 증명하지 못했습니다. 이는 코드의 논리적 오류, 불충분한 계약, 또는 증명기가 해결하기에 너무 복잡한 문제일 수 있습니다. GNATprove는 종종 반례(counterexample)를 제시하여 오류의 원인을 파악하는 데 도움을 줍니다.
- 시간 초과 (Timeout): 제한된 시간 내에 증명을 완료하지 못했습니다.
22.3.3 사례 연구: 동적 테스팅과 정형 검증의 비교
소프트웨어의 신뢰성을 확보하는 두 가지 접근 방식, 즉 동적 테스팅과 정형 검증의 차이를 clair
라이브러리의 테스트 코드와 SPARK의 접근 방식을 통해 비교할 수 있습니다.
-
동적 테스팅 접근 방식 (
clair
라이브러리)clair
의tests/test_clair_process.adb
파일에는fork
와send_signal_to
함수의 동작을 검증하기 위한 테스트 케이스가 포함되어 있습니다. [cite: 471, 477]-- tests/test_clair_process.adb의 일부 procedure test_fork_and_send_signal_to is begin [cite_start]Ada.Text_IO.put ("Testing Clair.Process.fork and .send... "); [cite: 477] declare [cite_start]result : Clair.Process.Fork_Result := Clair.Process.fork; [cite: 478] begin case result.status is when Clair.Process.Parent => [cite_start]delay 0.1; [cite: 479] [cite_start]Clair.Process.send_signal_to (result.child_pid, Clair.Signal.SIGTERM); [cite: 480] [cite_start]Ada.Text_IO.put_line ("OK (Parent sent signal)"); [cite: 481] when Clair.Process.Child => loop -- ... 자식 프로세스는 신호를 받을 때까지 대기 end loop; end case; end; end test_fork_and_send_signal_to;
이 테스트는 하나의 특정 실행 경로에 대해서만 동작을 검증합니다. 부모 프로세스가 자식에게
SIGTERM
신호를 보내는 시나리오가 성공하는지 확인하지만, 다른 신호를 보내거나,delay
값의 변화에 따른 경쟁 조건(race condition)이 발생하는지, 또는 예상치 못한 다른 상호작용이 있는지에 대해서는 보장하지 못합니다. 즉, 테스트는 표본 검사(sampling) 방식입니다. -
정형 검증 접근 방식 (The Formal Verification Approach)
SPARK는 특정 시나리오를 테스트하는 대신, 모든 가능한 실행 경로에 대해 속성을 증명합니다.
fork
와 같은 저수준 기능 자체를 SPARK로 검증하는 것은 복잡하지만, 이들을 기반으로 구축된 고수준 추상화의 기능적 정확성을 증명할 수 있습니다.예를 들어,
Create_And_Monitor_Worker
라는 프로시저를 SPARK로 작성한다고 가정해 봅시다.procedure Create_And_Monitor_Worker (task_id : in Task_Type) with pre => Can_Create_Process and Is_Valid (task_id), post => (Exists_Process_For (task_id) and Is_Monitored (Process_Of (task_id)));
- Pre: “프로세스를 생성할 권한이 있고,
task_id
가 유효하다.” - Post: “해당
task_id
를 위한 프로세스가 존재하며, 해당 프로세스는 모니터링 상태에 있다.”
GNATprove는
Create_And_Monitor_Worker
의 구현이fork
,exec
, 시그널 핸들러 설정 등 어떤 복잡한 로직을 포함하더라도, 전제조건이 만족되는 모든 경우에 대해 후제조건을 만족함을 증명하려고 시도합니다. 이 증명에 성공하면, 테스트 케이스가 다루지 못하는 수많은 엣지 케이스(edge case)를 포함하여 프로시저의 기능적 정확성이 수학적으로 보장됩니다. - Pre: “프로세스를 생성할 권한이 있고,
결론적으로, 정형 검증은 동적 테스팅을 보완하거나 대체하여 소프트웨어의 신뢰성을 극도로 높은 수준까지 끌어올리는 강력한 방법론입니다. 이는 결함이 치명적인 결과를 초래할 수 있는 시스템을 개발하는 데 있어 필수적인 기술이라 할 수 있습니다.
부록: 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
- Rationale: For consistency with other language keywords and attributes. This applies to both the pragma itself (e.g.,
- 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);
- Subprogram Calls & Declarations: Use a single space between the subprogram name and the opening parenthesis
-
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);
- Rationale: This is a widely understood convention that avoids potential conflicts with the
- 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 likeDescriptor
is self-evident when used asFile.Descriptor
. Adding a redundant prefix, such as inFile.File_Descriptor
, can harm readability. - Example:
Descriptor
,Flags
,Object
- Rationale: Within a package like
- 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
- Use
- 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;
- Compile-Time Constants: Use
- 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
, notClair.Dl
which can look likeClair.D1
).
- Example:
- Standard Library Naming:
Interfaces.C
: Types and subprograms from theInterfaces.C
package and its children should usesnake_case
to match the C standard library’s naming convention. Constants from this package follow the globalUPPER_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
-
Ada - The Project, The DoD High Order Language Working Group
http://archive.adaic.com/pol-hist/history/holwg-93/holwg-93.htm ↩ -
https://gcc.gnu.org/onlinedocs/gnat_ugn/Warning-Message-Control.html ↩
-
https://www.gnu.org/software/bash/manual/html_node/Exit-Status.html ↩