[!attention] 이 장의 목표
- 다섯 가지 자료형 — integer, real, complex, logical, character를 구분한다.
- 정밀도의 선택 — 종류 매개변수(kind)로 변수의 정밀도를 결정한다.
- 상수 다루기 — parameter 속성으로 변하지 않는 값 상수를 선언한다.
- 올바른 초기화 — 변수를 선언하고 초기화하는 방법을 익힌다.
2장에서 우리는 프로그램의 뼈대(program ... end program)와 implicit none을 익혔다. 그러나 아직 "값을 담는 그릇"인 변수를 제대로 다루지 않았다. 이 장을 마치면 다음 세 가지 문제를 해결할 수 있다.
문제 1. 반지름이 2.5인 원의 넓이를 정확히 계산하려 한다. 그런데 어떤 사람은 area = 3.14 * r * r로, 어떤 사람은 area = pi * r**2로 쓴다. 어느 쪽이 더 정확하며 그 "정확함"은 변수의 무엇으로 결정되는가?
문제 2. 0.1을 만 번 더하면 10000.0이 되어야 한다. 그런데 어떤 프로그램은 9999.99…을, 다른 어떤 프로그램은 10000.000000001을 출력한다. 무엇이 이 차이를 만드는가?
문제 3. 레거시 코드에서 종종 보이는 real*8은 무슨 뜻이며, 왜 현대 표준에서는 쓰지 않는가? 같은 코드를 다른 컴퓨터·다른 컴파일러에서 돌려도 같은 정밀도를 얻으려면 어떻게 해야 하는가?
위 모든 문제의 답은 자료형(type) 과 정밀도(precision), 그리고 이식성(portability) 에 있다. Fortran은 "이 변수가 정수인가 실수인가", "실수라면 몇 자리까지 믿을 수 있는가"를 프로그래머가 명시하도록 요구한다. 이 장에서는 몇 가지 내장 자료형을 둘러보고, 정밀도를 종류(kind)로 제어하는 방법과 상수와 변수 선언 방법을 배운다. 이 과정에서 어느 시스템에서 컴파일해도 같은 결과를 내는 kind 선택의 관용적인 코드 표현도 함께 익힌다.
[!알아두기] CPU 비트 수에 따른 컴퓨터 연산 차이
CPU가 32비트 컴퓨터이든 64비트 컴퓨터이든 ‘1바이트(Byte)’의 크기는 똑같이 ‘8비트’이다. 1바이트가 8비트라는 정의는 전 세계 컴퓨터 하드웨어의 표준 규격이기 때문에 바뀌지 않는다. 그렇다면 32비트 컴퓨터와 64비트 컴퓨터의 진짜 차이는 무엇일까?
32비트와 64비트는 컴퓨터가 한 번에 처리하는 데이터의 기본 단위(워드, word)이다. - 32비트 컴퓨터: CPU가 한 번에 처리할 수 있는 데이터의 양이 32비트이고, 바이트로 환산하면 4바이트($32 \div 8$)씩 묶어서 데이터를 처리한다.
- 64비트 컴퓨터: CPU가 한 번에 처리할 수 있는 데이터의 양이 64비트이고, 바이트로 환산하면 8바이트($64 \div 8$)씩 묶어서 처리한다.바이트의 크기는 같지만, 컴퓨터가 이 바이트들을 늘어놓고 "메모리 공간 주소"를 할당할 수 있는 범위에서 차이가 발생한다. 컴퓨터는 메모리의 1바이트마다 주소를 1개씩 부여한다. - 32비트 컴퓨터의 한계: 주소를 지정하는 주소창의 크기가 32비트이므로, $2^{32}$개의 방 번호만 만들 수 있다. 이를 바이트로 계산하면 정확히 4GB가 된다. 따라서 32비트 컴퓨터는 아무리 많은 메모리를 꽂아도 시스템 구조상 딱 4GB까지만 인식하고 사용할 수 있다. - 64비트 컴퓨터의 확장성: 주소창의 크기가 64비트여서 $2^{64}$개의 방 번호를 만들 수 있으며, 이는 약 1,600만 TB에 달하는 대용량 메모리를 주소로 지정할 수 있다는 뜻이다. 덕분에 초대형 행렬 데이터를 메모리에 전부 올려두고 연산하는 슈퍼컴퓨팅이 가능해진다.
32비트 CPU는 숫자 하나를 처리하기 위해 연산 장치를 두 번 작동(4바이트 + 4바이트)시켜야 했다. 반면 현대의 64비트 컴퓨터는 8바이트 데이터를 한 번의 연산 명령으로 곧바로 처리한다. 동일한 정밀도의 계산 결과를 산출하지만, 32비트 컴퓨터는 일을 여러 단계로 나누어 해야 하므로 연산속도가 훨씬 오래 걸린다.
[!important] 3.1 내장 자료형
Fortran에는 아래 표와 같이 다섯 가지 내장 자료형(intrinsic type) 이 있다.
| 자료형 | 키워드 | 담는 값 | 예 |
|---|---|---|---|
| 정수형 | integer |
부호 있는 정수 | -3, 0, 42 |
| 실수형 | real |
부동소수점 실수 | 3.14, -0.5 |
| 복소수형 | complex |
실수부·허수부 쌍 | (3.0, -4.0) |
| 논리형 | logical |
참/거짓 | .true., .false. |
| 문자형 | character |
글자·문자열 | "Texas", "Busan" |
- 정수와 실수는 다르다. 3은 정수, 3.0은 실수다. 같은 숫자처럼 보여도 메모리에 저장되는 방식과 연산 규칙이 다르다. |
|||
- 복소수는 실수 두 개의 묶음이다. (a, b)는 수학의 a + bi를 뜻한다. |
|||
- 문자형은 길이를 가진다. character(len=20)은 "20개의 글자 칸"을 만든다는 뜻이다. 칸이 남으면 뒤를 공백으로 채우고, 길이를 초과한 글자는 뒤에서부터 잘려서 별다른 경고(warning) 없이 앞부분만 저장된다. |
변수를 선언할 때는 자료형을 지정하고 :: 구분자를 사용하여 변수 목록을 나열한다.
| 자료형 | 문법 | 설명 |
|---|---|---|
| 정수형 | integer :: 변수목록 |
정수를 저장한다. |
| 실수형 | real :: 변수목록 |
부동소수점 실수를 저장한다. |
| 복소수형 | complex :: 변수목록 |
복소수를 저장한다. |
| 논리형 | logical :: 변수목록 |
참(.true.) 또는 거짓(.false.) 값을 저장한다. |
| 문자형 | character(len=길이) :: 변수목록 |
지정된 길이만큼의 문자열을 저장한다. |
- 길이 생략: character :: c와 같이 길이를 명시하지 않으면, 해당 변수는 한 글자(길이 1)만 저장할 수 있는 변수로 처리된다. |
::는 자료형과 변수 이름을 명확히 분리하는 역할을 하며, 포트란에서 변수 선언 시 권장되는 문법이다. Fortran은 과학 계산과 데이터 처리에 최적화된 언어답게 기본적으로 다섯 가지 핵심 자료형을 제공한다. 정수와 실수는 물론, 복소수, 참·거짓을 판별하는 논리형, 그리고 문자열까지 포함된다. 아래 예제를 통해 각 자료형을 어떻게 선언하고 출력하는지 그 흐름을 살펴보자.
%%writefile types_intro.f90
program types_intro
implicit none
integer :: count
real :: temperature
complex :: impedance
logical :: is_valid
character(len=20) :: city
count = 42
temperature = 36.5
impedance = (3.0, -4.0)
is_valid = .true.
city = "Busan"
print *, "count =", count
print *, "temperature =", temperature
print *, "impedance =", impedance
print *, "is_valid =", is_valid
print *, "city =", trim(city)
end program types_intro
컴파일·실행:
!gfortran -O2 -std=f2018 -Wall types_intro.f90 -o types_intro
!./types_intro
실행 결과:
count = 42
temperature = 36.5000000
impedance = (3.00000000,-4.00000000)
is_valid = T
city =Busan
선언부를 살펴보면 다섯 가지 기본 자료형이 모두 등장한다.
integer (정수형): 소수점이 없는 정수를 저장한다.
real (실수형): 소수점이 포함된 실수를 저장한다.
complex (복소수형): 수학의 복소수를 표현하기 위해 (실수부, 허수부) 형태로 값을 대입한다. 예제에서는 $3.0 - 4.0i$를 나타낸다.
logical (논리형): 참과 거짓을 저장하는 자료형으로, 값을 대입할 때는 양쪽에 마침표를 붙인 .true. 또는 .false. 기호를 사용해야 한다. 화면에 출력될 때는 깔끔하게 T 또는 F로 번역되어 나타난다.
character(len=20) (문자형): 총 20칸짜리 문자열 방을 만든다. city = "Busan"을 대입하면 나머지 15칸은 빈 공백으로 채워진다.
실행부의 출력 명령을 보면 문자형 변수 city를 출력할 때 trim(city)이라는 함수를 적용했다. trim 함수는 Fortran의 내장 함수로, 문자열 변수가 가진 뒤쪽의 필요없는 빈 공백들을 떼어내고 글자만 남겨주는 역할을 한다(14장).
[!important] 3.2 종류 매개변수(kind)와 정밀도
같은 실수형(real) 자료형이라도 얼마나 정밀하게 값을 저장할지는 종류 매개변수(kind)가 결정한다. kind는 자료형의 크기와 특성을 지정한다. 수치 계산에서는 흔히 다음 두 가지 정밀도가 쓰인다.
단정밀도(single precision): 유효숫자가 약 $7$자리이며, 보통 real32로 표현된다.
배정밀도(double precision): 유효숫자가 약 $15$자리이며, 보통 real64로 표현된다.
과학·공학 계산에서는 복잡한 연산을 반복할 때 누적 오차가 빠르게 커지는 것을 막기 위해 배정밀도를 기본으로 사용한다. 과거 레거시 코드에서 자주 쓰이던 real*8 같은 표현은 Fortran 표준이 아니므로, 최신 컴파일러에서 에러를 내거나 정밀도 문제를 유발할 수 있어 지양해야 한다.
Fortran 표준이 제안하는 올바른 접근법은 메모리의 물리적인 바이트 크기를 직접 지정하는 것(real*8 같은 표현)이 아니라, "내가 하려는 계산에 몇 자리의 정밀도(p)와 얼마큼의 지수 범위(r)가 필요한가"를 선언하는 것이다. 예를 들어, 프로그래머가 "소수점 아래 $15$자리까지 정확해야 하고, 지수는 $10^{\pm307}$까지 표현할 수 있어야 한다"라고 요구사항을 제시하면, 컴파일러가 해당 컴퓨터 시스템 하드웨어에 가장 적합한 종류(kind 값)를 알아서 찾아 할당한다.
이런 kind 값을 얻는 표준 방법에는 selected_real_kind()와 iso_fortran_env 두 가지가 있다.
selected_real_kind(p, r) : 이식성 우선 표현"유효숫자 p자리 이상, 십진 지수 범위 $r$ 이상을 보장하는 종류를 달라"고 컴파일러에 요청하는 내장 함수다. 조건을 만족하는 하드웨어 지원 형식이 있으면 해당하는 양의 정수(kind 값)를 반환한다. 만약 시스템 성능이 부족하여 요청을 만족하지 못하면 다음과 같이 음수 값을 반환한다.
-1 반환 -2 반환 -3 반환이 방식은 소스 코드를 다른 컴퓨터나 운영체제로 옮겨 컴파일해도 동일한 정밀도를 보장받을 수 있어 프로그램의 이식성을 높이는 데 유리하다. 정수형 데이터를 처리할 때는 이와 유사한 selected_int_kind(r) 함수를 사용할 수 있다.
iso_fortran_env 모듈의 real32, real64 : 명시성 우선 표현Fortran 표준 라이브러리 모듈인 iso_fortran_env를 불러와 real32(4바이트 단정밀도), real64(8바이트 배정밀도)와 같이 데이터의 비트 폭을 직접 지정하는 방식이다.
이 방식은 코드가 다루는 데이터의 크기를 직관적으로 보여주므로 코드의 명시성을 높여준다. 특히 다른 프로그래밍 언어와 데이터를 주고받거나, 특정한 바이트 크기를 맞춰야 하는 바이너리 파일 입출력 작업을 수행할 때 의도를 분명히 드러낼 수 있어 유용하다.
[!note] [알아두기] 과거에는 컴퓨터마다 계산결과가 달랐던 이유
Fortran의 역사나 수치 모델의 발전 과정에서 "32비트 환경과 64비트 환경에서 계산한 값이 미세하게 달라서 기상 예보 결과가 틀어졌다"라는 이야기를 들은 적이 있을 것이다. 이런 현상이 발생하는 이유는 하드웨어나 데이터 전송의 결함이 아니라, 다음 정밀도와 컴퓨터 내부 연산의 특성 때문이다.
1. 기본값(Default) 자료형의 크기 변화 과거 32비트 운영체제와 컴파일러 환경에서는 개발자가 소스 코드에 별도의 정밀도 설정을 하지 않으면, 실수를 기본적으로 4바이트 단정밀도(소수점 약 7자리)로 처리하는 경우가 많았다. 반면 시스템 아키텍처가 64비트 환경으로 전환되면서, 많은 컴파일러가 실수 연산의 기본값을 8바이트 배정밀도(소수점 약 15자리)로 상향하여 적용하기 시작했다. 이로 인해 소스 코드가 동일하더라도 어떤 환경에서 컴파일하고 실행하느냐에 따라 표현 가능한 유효숫자의 자릿수가 달라져 결괏값의 차이가 발생하게 된다.
2. 컴파일러 최적화와 부동소수점 처리 방식의 미세한 차이 CPU가 나눗셈이나 삼각함수, 로그 같은 복잡한 초월함수를 연산할 때, 소수점 아래 최하위 비트 영역에서 반올림이나 버림을 처리하는 방식은 컴파일러 제조사의 최적화 알고리즘이나 CPU 하드웨어의 명령어 집합에 따라 미세한 차이를 보인다. 예를 들어, 기상 수치 모델은 초기 상태의 아주 작은 변화가 시간이 흐를수록 거대한 결과의 차이를 만들어내는 카오스(Chaos) 이론의 나비효과가 극단적으로 작용하는 영역이다. 따라서 계산 과정에서 발생한 소수점 아래 수십 번째 자리의 아주 미세한 반올림 오차라 할지라도, 수백만 번의 시간 전진 격자 연산을 거치며 눈덩이처럼 증폭되어 결국 최종 일기예보 결과를 완전히 바꾸어 놓는 현상이 발생하게 된다.
따라서, 이런 문제를 방지하기 위해 현대 고성능 수치 계산 프로그래밍에서는 앞서 살펴본
selected_real_kind나real64를 활용하여 하드웨어 환경에 종속되지 않도록 정밀도를 명시적으로 고정하는 선언부 작성이 필수적으로 강조된다.
Fortran에서 배정밀도(소수점 약 $15$자리 보장) 실수를 사용할 때는 코드의 가독성과 이식성을 높이기 위해 종류 매개변수(kind)에 직접 숫자를 적지 않고, 상수를 정의하여 선언부와 결합하는 아래와 같은 방식을 사용한다.
use iso_fortran_env, only: real64
integer, parameter :: dp = real64 ! 종류 값에 이름을 붙인다
real(dp) :: 변수목록 ! 그 종류로 변수 선언
! 또는 정밀도·범위 요청 방식
integer, parameter :: dp = selected_real_kind(15, 307)
real64는 사실 정수형 상수. real64라는 명칭 때문에 이를 자료형 자체로 오해하기 쉬우나, 이는 표준 모듈(iso_fortran_env) 내에 정의된 정수형 상수다. 대부분의 현대 컴퓨터 시스템과 컴파일러에서 이 값은 8바이트를 뜻하는 숫자 8로 지정되어 있다.
integer, parameter를 활용한 별명 붙이기. 매번 변수를 선언할 때마다 real(real64) 혹은 real(8)이라고 직접 기입하는 것은 번거롭고 실수를 유발할 수 있다. 따라서 integer, parameter를 사용해 이를 dp라는 상수로 고정하여 사용한다. 여기서 parameter 지시어는 이 값이 프로그램 실행 중에 절대 변하지 않는 상수임을 컴파일러에 알려준다.
관례적인 약속 dp. 전 세계 과학 계산 현업에서 dp는 double precision의 앞 글자를 딴 표준적인 약어로 통용된다.
현대 Fortran에서는 시스템이나 컴파일러의 환경에 상관없이, 수치 데이터의 내부 정밀도를 온전하게 제어하기 위해 kind 값을 명시적으로 확인하고 다룬다. 아래 예제를 통해 단정밀도(single precision)와 배정밀도(double precision)의 물리적 차이를 통계 함수와 상수를 통해 면밀히 검증해 보자.
1.0_sp나 3.0_dp처럼 숫자 뒤에 언더바(_)와 함께 종류 상수를 붙여주면, 해당 숫자가 컴파일될 때부터 그 정밀도로 완벽하게 고정된다.
%%writefile kind_demo.f90
program kind_demo
use iso_fortran_env, only: real32, real64, int32, int64
implicit none
integer, parameter :: sp = selected_real_kind(6, 37)
integer, parameter :: dp = selected_real_kind(15, 307)
real(sp) :: x_single
real(dp) :: x_double
x_single = 1.0_sp / 3.0_sp
x_double = 1.0_dp / 3.0_dp
print *, "kind values:"
print *, " sp =", sp, " real32 =", real32
print *, " dp =", dp, " real64 =", real64
print *, "1/3 in single:", x_single
print *, "1/3 in double:", x_double
print *, "single precision digits:", precision(x_single)
print *, "double precision digits:", precision(x_double)
print *, "single range exponent :", range(x_single)
print *, "double range exponent :", range(x_double)
print *, "double epsilon:", epsilon(x_double)
print *, "double huge :", huge(x_double)
print *, "double tiny :", tiny(x_double)
print *, "int32 huge:", huge(0_int32)
print *, "int64 huge:", huge(0_int64)
end program kind_demo
컴파일·실행:
!gfortran -O2 -std=f2018 -Wall kind_demo.f90 -o kind_demo
!./kind_demo
실행 결과:
kind values:
sp = 4 real32 = 4
dp = 8 real64 = 8
1/3 in single: 0.333333343
1/3 in double: 0.33333333333333331
single precision digits: 6
double precision digits: 15
single range exponent : 37
double range exponent : 307
double epsilon: 2.2204460492503131E-016
double huge : 1.7976931348623157E+308
double tiny : 2.2250738585072014E-308
int32 huge: 2147483647
int64 huge: 9223372036854775807
종류 값의 일치: selected_real_kind(6, 37)과 selected_real_kind(15, 307)이 돌려주는 정수 값이 각각 real32, real64와 동일한 4와 8임을 알 수 있다. 두 방식이 동일한 하드웨어 표현형을 가리키는 것이다. 다만, 이 숫자 4나 8 자체를 코드에 직접 하드코딩해서는 안 된다. 시스템과 컴파일러에 따라 이 종류 식별 숫자는 달라질 수 있기 때문이다.
유효숫자의 차이: $1/3$을 연산했을 때, 단정밀도는 소수점 아래 7번째 자리부터 수치가 달라지기 시작하지만(0.333333343), 배정밀도는 16번째 자리까지 완벽하게 소수점을 유지한다. precision 내장 함수가 알려주는 정밀도 한계(단정밀도 6자리, 배정밀도 15자리)와 일치한다.
표현 범위와 한계치: range 함수는 표현 가능한 10진수 지수의 범위를 알려준다. 배정밀도는 최소 $10^{-307}$부터 $10^{308}$ 근처까지의 수치를 다룰 수 있다.
부동소수점 관련 내장 함수들: epsilon 함수가 반환하는 값(약 $2.2 \times 10^{-16}$)은 배정밀도 환경에서 두 실수를 비교하거나 오차 범위를 설정할 때, 대략 16번째 자리에서부터 구분이 무의미해진다는 물리적 기준을 제시한다. huge와 tiny는 각각 해당 자료형이 표현할 수 있는 최대와 최소 양수 값을 나타내며, 수치 연산에서 범위의 한계를 알려준다.
정수형 한계 확인: 32비트 기본 정수형(int32)의 최댓값이 약 21억(2147483647)이라는 점도 유심히 보아 두자. 이 한계를 넘어가는 거대한 인덱스나 연산을 다룰 때는 반드시 64비트 정수형(int64)을 사용해야 데이터 유실을 막을 수 있다.
[!important] 3.3 리터럴과 상수, parameter 속성
소스 코드에 변수명을 거치지 않고 실제 수치나 문자를 그대로 직접 적어 넣은 값들을 리터럴(literal) 이라고 한다. 리터럴은 소스 코드에 하드코딩 된 값으로 변수와 달리 데이터 자체가 곧 식별자가 되는 고정 데이터이다. Fortran에서 다루는 리터럴의 대표적인 종류와 표기법은 다음과 같다.
42: 기본 정수형 리터럴
3.14: 기본 단정밀도(single precision) 실수형 리터럴
3.14_dp: 배정밀도(double precision) 실수형 리터럴 (뒤에 _dp 접미사를 붙인다)
(3.0, -4.0): 복소수(complex) 리터럴 (괄호 안에 (실수부, 허수부) 형태로 나란히 적는다)
.true. / .false.: 논리(logical) 리터럴 (양옆에 반드시 마침표를 찍어야 한다)
"Busan": 문자(character) 리터럴 (작은따옴표 또는 큰따옴표로 감싸서 표현한다)
3.14처럼 접미사가 없는 실수 리터럴은 단정밀도라는 점이 자주 사고를 일으킨다. 배정밀도가 필요하면 반드시 3.14_dp처럼 종류를 붙여야 한다.
[!warning] [흔한 실수] 접미사가 없는 실수값 접미사가 없는
3.14나1.0같은 실수 리터럴은 컴파일러가 기본적으로 단정밀도로 해석한다는 점을 기억하자. 배정밀도로 선언된 변수에 대입할 때조차 접미사를 빠뜨리면 단정밀도 수준의 덜 정밀한 값이 먼저 생성된 후 변수에 들어가므로, 대형 수치 계산에서 미세한 반올림 오차 버그를 일으키는 주범이 된다. 고정밀도 계산이 필요할 때는 반드시3.14_dp와 같이 종류 접미사를 꼬리표처럼 붙이는 습관을 들이자.
소스 코드 여기저기에 의미를 정확히 알 수 없는 숫자들(예: 3.141592, 110 등)이 흩어져 있는 것을 '매직 넘버(magic number)' 라고 한다. 이런 매직 넘버는 가독성을 떨어뜨리고 유지보수를 어렵게 만든다.
이를 해결하기 위해 한 번 정하면 프로그램 실행 중에 절대 바꿀 수 없는 고정된 값(원주율, 물리 상수, 배열의 최대 크기 등)에 안전하게 이름을 붙여 두는 것을 명명 상수(named constant)라고 하며, 아래와 같이 parameter 속성을 사용하여 선언한다. 이는 코드 곳곳에 매직 넘버가 흩어지는 것을 방지하고 의미를 명확하게 할 수 있다.
자료형, parameter :: 이름 = 초기식
parameter 속성이 붙은 상수는 컴파일되는 시점에 값이 완전히 고정되며, 프로그램 실행 도중에 강제로 값을 바꾸려고 시도하면 컴파일러가 에러를 내며 진행을 차단한다.
상수를 초기화하는 식에는 이미 선언되어 값을 알고 있는 다른 명명 상수를 조합하여 수학적 연산식 형태로 대입할 수 있다. (예: two_pi = 2.0_dp * pi)
문자로 명명 상수를 만들 때는 길이를 일일이 손으로 세어 기입하지 않고 character(len=*) 형태로 길이를 별표(*) 처리해 두면, 오른쪽에 대입되는 실제 문자열의 길이에 맞춰 컴파일러가 메모리 공간 크기를 알아서 조절해 준다.
명명 상수 선언의 올바른 예시는 다음과 같다.
use iso_fortran_env, only: real64
integer, parameter :: dp = real64
real(dp), parameter :: pi = 3.141592653589793_dp
real(dp), parameter :: two_pi = 2.0_dp * pi
character(len=*), parameter :: project_name = "Micrometeorology Simulation"
앞서 배운 명명 상수(parameter)와 배정밀도(dp) 설정을 활용하여 반지름이 2.5인 원의 넓이를 정밀하게 계산하는 예제이다. 이를 통해 실제 고정밀도 수치 계산에서 매직 넘버를 제거하고 상수를 안전하게 선언하여 활용하는 흐름을 확인해 보자.
%%writefile constants.f90
program constants
use iso_fortran_env, only: real64
implicit none
integer, parameter :: dp = real64
real(dp), parameter :: pi = 3.141592653589793_dp
real(dp), parameter :: two_pi = 2.0_dp * pi
integer, parameter :: max_iter = 1000
character(len=*), parameter :: app_name = "Wave Solver"
real(dp) :: radius, area
radius = 2.5_dp
area = pi * radius**2
print *, "app : ", app_name
print *, "pi :", pi
print *, "2*pi :", two_pi
print *, "iters:", max_iter
print *, "area :", area
end program constants
컴파일·실행:
!gfortran -O2 -std=f2018 -Wall constants.f90 -o constants
!./constants
실행 결과:
app : Wave Solver
pi : 3.1415926535897931
2*pi : 6.2831853071795862
iters: 1000
area : 19.634954084936208
이 장 첫 부분에 언급했던 수치 정밀도 문제의 해답을 이 코드에서 확인할 수 있다.
만약 접미사(_dp)나 정밀도 지정 없이 단순하게 3.14라는 단정밀도 값을 그대로 사용했다면, 계산 결과는 소수점 아래 몇 자리 가지 못하고 오차가 빠르게 누적되었을 것이다. 그러나 배정밀도 명명 상수인 pi를 3.141592653589793_dp로 선언해 두면, 동일한 넓이 계산식 pi * radius2를 수행할 때 소수점 아래 15자리까지 신뢰할 수 있는 고정밀도의 면적 값 19.634954084936208을 얻을 수 있다다.
이처럼 parameter를 활용해 상수를 엄격하게 정의하는 습관은 대규모 수치 모델이나 유체 역학 시뮬레이션과 같은 고난도 계산에서 정확도를 확보하고 수치적 안정성을 유지하는 핵심이다.
[!important] 3.4 변수 선언과 초기화
변수는 선언과 동시에 초기값을 부여할 수 있다. 컴파일과 동시에 메모리에 공간을 만들고 원하는 값을 미리 채워두는 방식이다.
변수 선언과 초기화의 공식적인 문법 규격은 다음과 같다.
자료형[, 속성목록] :: 이름[ = 초기식][, 이름2[ = 초기식2] ...]
[, 속성목록]은 선택 항목이며, 이곳에는 한 번 정하면 바꿀 수 없는 parameter, 동적 할당을 위한 allocatable(9장), 배열의 크기를 지정하는 dimension(7장) 등의 키워드가 들어갈 수 있다.
[ = 초기식] 역시 생략이 가능하지만, 선언과 동시에 대입할 값이 있다면 이 자리에 리터럴이나 상수를 지정하여 컴파일 시점에 방을 초기화할 수 있다. 쉼표(,)를 이용해 한 줄에 여러 변수를 나란히 선언하고 동시에 각각 초기화하는 것도 가능하다.
아래는 전형적인 초기화의 예시이다.
integer :: i = 0
real(dp) :: total = 0.0_dp
변수를 선언하고 초기화할 때는 다음 두 가지 사항을 주의해야 한다.
초기화하지 않은 변수의 값은 정해져 있지 않다(쓰레기 값의 위험): Fortran에서 초기화하지 않은 변수의 초깃값을 0이나 공백이라고 멋대로 가정하면 안 된다. 컴파일러 종류, 운영체제의 성격, 그리고 당시의 메모리 상태에 따라 그 방에 남아 있던 무작위의 '쓰레기 값(Garbage Value)'이 들어갈 수 있기 때문이다. 이로 인해 프로그램의 결과가 매번 달라지는 함정에 빠질 수 있으므로, 변수를 연산에 쓰기 전에는 사용하고자 하는 값을 반드시 지정해 주어야 한다.
실수 변수에 단정밀도 리터럴을 넣으면 정밀도를 잃는다: 배정밀도 변수를 만들면서 real(dp) :: x = 0.1과 같이 선언하면, 컴파일러는 0.1을 단정밀도(32비트) 크기로 먼저 만든 뒤 배정밀도(64비트) 메모리 공간에 집어넣는다. 이 과정에서 미세한 단정밀도 버림 오차가 이미 발생하여 0.1이 정확히 들어가지 않는다. 고정밀도가 유지되도록 하려면 반드시 0.1_dp와 같이 접미사를 명시해야 한다.
변수를 선언하는 것 만큼이나 중요한 것이 대입하는 리터럴 데이터의 정밀도를 일치시키는 일이다. 아래 예제를 통해 동일한 배정밀도 변수라도 단정밀도 리터럴(0.1)과 배정밀도 리터럴(0.1_dp)을 대입했을 때 실제 메모리에 저장되는 값이 얼마나 차이가 나는지 살펴보자.
%%writefile declaration.f90
program declaration
use iso_fortran_env, only: real64
implicit none
integer, parameter :: dp = real64
integer :: i = 0
real(dp) :: total = 0.0_dp
real(dp) :: wrong, right
! literal 0.1 is SINGLE precision, then widened -> loses accuracy
wrong = 0.1
! literal 0.1_dp is DOUBLE precision from the start -> accurate
right = 0.1_dp
total = total + 1.0_dp
i = i + 1
print *, "i =", i
print *, "total =", total
print *, "wrong =", wrong
print *, "right =", right
print *, "diff =", abs(wrong - right)
end program declaration
컴파일·실행:
!gfortran -O2 -std=f2018 -Wall declaration.f90 -o declaration
!./declaration
실행 결과:
i = 1
total = 1.0000000000000000
wrong = 0.10000000149011612
right = 0.10000000000000001
diff = 1.4901161138336505E-009
결과를 보면 wrong과 right가 모두 똑같은 배정밀도(64비트 실수) 주소 방으로 선언되었음에도 불구하고 출력된 결과가 전혀 다르다.
right: 처음부터 배정밀도로 명시한 0.1_dp를 대입했기 때문에 소수점 아래 16번째 자리 근처까지 오차 없이 정밀하게 0.1을 유지한다.
wrong: 접미사를 빠뜨린 채 0.1을 대입하는 순간, 컴파일러는 이 숫자를 32비트 단정밀도로 자른 뒤 배정밀도 메모리 공간에 채워 넣는다. 이 과정에서 버림 오차가 발생한 값(0.10000000149011612)이 들어가 버린다.
두 변수의 절대 차이(diff)를 구해 보면 약 $1.49 \times 10^{-9}$만큼 어긋나 있다. 겉보기에는 소수점 아래 아주 작은 숫자로 느껴질 수 있다. 하지만 수치 해석 연산처럼 수만 번, 수억 번의 누적 루프를 돌리는 대형 연산 체계에서는 이 미세한 틈새가 눈덩이처럼 불어나 결국 예측을 완전히 빗나가게 하는 치명적인 런타임 오류를 초래한다.
실수의 정밀도 차이가 실제 누적 연산에서 얼마나 치명적인 오류 결과물로 나타나는지 확인해 보자. 컴퓨터가 정확하게 표현하지 못하는 소수인 0.1을 십만 번($100,000$번) 연속해서 더해가면서, 단정밀도(real32)와 배정밀도(real64)가 각각 수학적 참값에서 얼마나 멀어지는지를 추적해 로그 그래프로 비교 분석한다.
이 예제에서는, 연속적인 데이터를 추출하기 위해 아직 배우지 않은 do 반복문과 if 조건문, 그리고 나머지 연산 함수인 mod를 사용한다. 이 장의 핵심은 어디까지나 real32와 real64의 정밀도 차이므로, 지금은 "변수 i를 1부터 n까지 1씩 늘려가며 안쪽 명령들을 반복한다" 정도로만 이해하면 된다.
%%writefile precision_error.f90
program precision_error
use iso_fortran_env, only: real32, real64
implicit none
integer, parameter :: sp = real32
integer, parameter :: dp = real64
integer, parameter :: n = 100000
integer :: i, u
real(sp) :: sum_single
real(dp) :: sum_double
real(dp) :: exact, err_single, err_double
sum_single = 0.0_sp
sum_double = 0.0_dp
open(newunit=u, file="precision.csv", status="replace", action="write")
write(u, '(A)') "step,error_single,error_double"
do i = 1, n
! add 0.1 once per step in each precision
sum_single = sum_single + 0.1_sp
sum_double = sum_double + 0.1_dp
exact = real(i, dp) * 0.1_dp
err_single = abs(real(sum_single, dp) - exact)
err_double = abs(sum_double - exact)
! record every 100th step to keep the file small
if (mod(i, 100) == 0) then
write(u, '(I0, ",", ES13.6, ",", ES13.6)') i, err_single, err_double
end if
end do
close(u)
print *, "precision.csv written, rows =", n / 100
end program precision_error
real(i, dp)는 정수형 변수 i를 배정밀도 실수형으로 바꾸는 형변환 문법이며, real(sum_single, dp)는 단정밀도 합산 결과를 배정밀도로 올려 동일한 기준으로 오차를 비교하기 위한 것이다.
컴파일·실행:
!gfortran -O2 -std=f2018 -Wall precision_error.f90 -o precision_error
!./precision_error
실행 결과:
precision.csv written, rows = 1000
이제 precision.csv을 파이썬으로 불러와 로그 스케일 그래프로 그려보자.
import csv
import matplotlib.pyplot as plt
steps, e_single, e_double = [], [], []
with open("precision.csv") as f:
reader = csv.reader(f)
next(reader) # skip header
for row in reader:
steps.append(int(row[0]))
e_single.append(float(row[1]))
e_double.append(float(row[2]))
plt.figure(figsize=(6, 4.5))
plt.semilogy(steps, e_single, label="single precision (real32)")
plt.semilogy(steps, e_double, label="double precision (real64)")
plt.xlabel("number of additions")
plt.ylabel("absolute error (log scale)")
plt.title("Accumulated error of repeated 0.1 addition")
plt.legend()
plt.grid(True, which="both", alpha=0.3)
plt.tight_layout()
plt.savefig("precision.png", dpi=120)
plt.show()
코드를 실행하면 두 정밀도의 오차 누적 흐름을 비교하는 로그 스케일 그래프가 아래와 같이 나타난다.
![[Pasted image 20260710125727.png]]
세로축이 일반 눈금이 아니라 10의 거듭제곱 단위로 급격히 증가하는 로그(log) 눈금이라는 점에 주의한다.
단정밀도: 0.1을 누적하여 더해감에 따라 오차가 가파르게 우상향한다. 십만 번을 모두 더한 최종 지점에서는 절대 오차가 무려 약 1.4에 도달한다. 십만 번을 더했을 때의 이론적인 수학적 참값이 $10,000.0$이므로, 고작 소수점 아래 오차인 줄 알았던 버그가 누적되어 실제 정수 자리의 값인 1 이상을 완전히 틀려버렸다는 뜻이다.
배정밀도: 반복 연산이 십만 번에 도달할 때까지 오차가 시종일관 $10^{-8}$ 안팎의 극도로 미세한 영역에 안정적으로 묶여 있다.
두 곡선 사이에 벌어진 간격을 보면, 왜 정밀한 시뮬레이션에서 단정밀도를 배제하고 배정밀도를 기본으로 채택하는지 알 수 있다.
참고로 각 곡선이 간간이 아래로 향하는 뾰족한 골(dip) 지점들은, 플러스 오차와 마이너스 오차가 반복문 안에서 누적되다가 일시적으로 합산 오차가 0을 순간 스쳐 지나간 결과이다.
[!important] 오류 학습 새로운 프로그래밍 언어를 배울 때 가장 빨리 성장하는 방법은 타인이 먼저 겪은 실수를 분석하는 것이다. 아래에서는 Fortran 입문자들이 선언과 자료형을 다룰 때 가장 자주 범하는 몇 가지 치명적인 실수를 설명하고 대처를 설명하였다.
앞서 강조했듯, 변수를 배정밀도(dp)로 선언해 두었다고 해서 안심해서는 안 된다. 대입하는 리터럴 숫자에 접미사를 빠뜨리면 정밀도가 무너진다.
real(dp) :: x
x = 0.1 ! [실수] 0.1은 단정밀도로 먼저 생성됨 -> 이미 약 1.5e-9 오차를 안고 방에 들어감
x = 0.1_dp ! [올바름] 처음부터 64비트 배정밀도로 생성되어 방에 안착함
컴파일러는 = 오른쪽의 데이터를 먼저 해석한다. 접미사가 없는 0.1은 32비트 크기의 데이터로 먼저 만들어진 후 배정밀도 메모리 공간에 들어가므로, 이미 유실된 정밀도를 되찾을 방법이 없다. 고정밀도 수치 계산을 진행할 때는 숫 뒤에 반드시 _dp를 꼬리표처럼 붙여야 한다.
REAL*8 표기법 사용오래된 레거시 코드를 보면 바이트 크기를 직접 명시하는 real*8 :: x 혹은 integer*4 :: i 같은 별표(*) 표기법을 흔히 볼 수 있다. 하지만 이는 Fortran 공식 표준이 아닌 컴파일러들의 개별 확장(extension)일 뿐이다. 책의 권장 사양인 -std=f2018 표준 옵션을 켜고 컴파일하면 즉시 빌드가 차단된다.
real*8 :: x ! 비표준 표기법
컴파일 오류 메시지:
Error: GNU Extension: Nonstandard type declaration REAL*8 at (1)
따라서, 표준을 준수하는 현대적 코드를 위해서는 반드시 선언부 머리에 use iso_fortran_env, only: real64를 명시한 뒤 real(real64) :: x 또는 별칭 상수를 활용한 real(dp) :: x 규규격으로 통일해야 이식성 높은 코드가 된다.
selected_real_kind 함수는 개발자가 원하는 정밀도를 요청할 수 있어 유용하지만, 현재 시스템과 컴파일러가 물리적으로 지원할 수 없는 터무니없는 수치를 넣으면 오류가 발생한다.
integer, parameter :: qp = selected_real_kind(40) ! 유효숫자 40자리를 달라고 떼를 쓰면?
real(qp) :: high_precision_val
컴파일 오류 메시지:
Error: Kind -1 not supported for type REAL at (1)
selected_real_kind 함수는 하드웨어가 감당할 수 없는 과도한 정밀도를 요청받으면 지원 불가를 뜻하는 에러 값인 -1을 돌려준다. 그리고 컴파일러는 종류 값 자리에 -1이 들어온 것을 발견하고 즉시 빌드를 중단한다.
일반적인 컴퓨터 CPU 환경에서 단정밀도(4바이트)는 약 6~7자리, 배정밀도(8바이트)는 약 15~17자리의 유효숫자를 갖는다. 40자리 수준의 초고정밀도는 16바이트(사정밀도, quadruple precision) 실수형 사양을 지원하는 특수 HPC 하드웨어나 컴파일러에서만 처리할 수 있다. 사용하고자 하는 하드웨어 환경을 충분히 이해하고 정밀도를 요청해야 한다.
선언만 해두고 아무런 값도 대입하지 않은 변수의 내부 알맹이는 완전한 빈 방이 아니다. 메모리 공간에 이전에 남아 있던 무작위의 쓰레기 값이 잔존해 있다.
integer :: total
total = total + 1 ! [치명적 실수] total의 시작 주소에 무슨 값이 들었는지 모른다!
이 상태에서 곧바로 누적 연산하면, 매 실행 마다 엉뚱한 결과물이 튀어나와 시스템을 교란한다. 누적 계산이나 카운팅에 사용되는 변수는 선언과 동시에 혹은 실행부 시작점에서 반드시 total = 0 또는 total = 0.0_dp와 같이 명시적으로 초기화하고 사용해야 한다.
32비트 기본 정수형(int32)이 담을 수 있는 숫자의 한계선은 약 21억, 정확히는 2147483647이다. 이 수치 범위를 넘어서면 연산 결과에 치명적 오류가 발생한다.
integer :: large_num
large_num = 2147483647 + 1
print *, large_num
실행 결과:
-2147483648
가장 주의할 점은 최댓값을 넘으면 값이 가장 큰 음수 방향으로 뒤집히는데, 그럼에도 컴파일러가 에러나 경고로 알려주지 않을 경우이다. 대규모 행렬의 총 요소 개수나 거대한 루프 인덱스를 다룰 때는 안전하게 64비트 정수 종류인 int64를 선택해 메모리 공간 크기를 확장해 두어야 오버플로를 미연에 방지할 수 있다.
[!note] 빌드 환경을 log에 남겨라
iso_fortran_env모듈에는 정밀도 제어를 위한 종류 값뿐만 아니라, 현재 코드가 어떤 환경에서 빌드되었는지 추적할 수 있는 환경 정보 내장 함수가 포함되어 있다. -compiler_version(): 컴파일러의 정확한 이름과 버전을 문자열로 반환한다. -compiler_options(): 컴파일에 적용한 최적화 옵션 및 표준 준수 플래그 문자열을 반환한다.
fortran use iso_fortran_env, only: compiler_version, compiler_options print '(a)', compiler_version() print '(a)', compiler_options()수치 계산 결과를 학술 보고서나 논문에 실어 발표할 때, 위 두 줄의 출력(메타 데이터)을 로그 파일 헤더에 함께 기록하는 습관은 매우 중요하다.
시간이 흐른 뒤 타인이나 저자 자신이 동일한 계산을 수행할 때, 어떤 컴파일러의 몇 버전으로 어떤 최적화 옵션(
-O2등)을 주어 실행했는지를 명확히 알 수 있어 재현성(reproducibility)을 극대화 할 수 있기 때문이다. 컴파일러 고유의 최적화 알고리즘에 따라 미세한 부동소수점 끝자리가 달라질 수 있는 과학 계산 분야에서는 일종의 안전장치 역할을 한다.실제 로그 파일의 이상적인 예시 컴퓨터가 연산을 마친 후 내뱉는 최종 로그 파일이 아래와 같은 형태로 구성되어야 정석이다.
```
[BUILD ENVIRONMENT METADATA] Compiler Version : gcc version 13.2.0 (Ubuntu 13.2.0-23ubuntu4) Compiler Options : -g -O2 -std=f2018 -Wall -fintrinsic-modules-path ... Execution Date : 2026-07-10 13:05:00 ========================================================================
[SIMULATION RESULTS] Step 100: Error = 1.4523E-08 Step 200: Error = 2.1104E-08 ... Step 100000: Simulation successfully finished. ```
위 예시처럼 상단에 컴파일러의 프로필을 명시해 두면, 전 세계 어느 컴퓨터에서 돌려도 똑같은 조건으로 코드를 다시 빌드하여 동일한 수치 결과를 완벽하게 재현(replication)해 낼 수 있다. 과학 계산의 신뢰성을 확보하기 위한 일종의 '실험 노트 기록 습관'이라고 이해하면 된다.
[!important] 요약
integer, real, complex, logical, character.kind 가 결정하며, iso_fortran_env의 real64(배정밀도)를 기본으로 쓴다. selected_real_kind(p, r)로 정밀도·범위를 요청할 수도 있다. 두 방식 모두 표준이며, 종류 값에 이름(dp)을 붙여 한 곳에서 관리한다.precision·range·epsilon·huge·tiny로 형의 성질을 직접 확인할 수 있다.3.14는 단정밀도다. 배정밀도가 필요하면 3.14_dp.parameter 속성으로 명명 상수를 만들어 매직 넘버를 없앤다.[!important] 연습 문제
iso_fortran_env를 써서 int8, int16, int32, int64의 huge 값을 각각 출력하라. 어떤 종류가 약 21억까지, 어떤 종류가 약 922경까지 담는가?z = (3.0_dp, 4.0_dp)를 선언하고, 내장 함수 real(z)·aimag(z)·abs(z)로 실수부·허수부·크기를 출력하라.pi를 배정밀도 명명 상수로 정의하고, 반지름 1.0·2.0·3.0인 원의 넓이를 차례로 출력하라(반복문 없이 세 줄로).character(len=*) 명명 상수로 자신의 이름을 담고, len 내장 함수로 그 길이를 함께 출력하라.selected_real_kind(6)과 real32, selected_real_kind(15)와 real64가 같은 종류 값을 주는지 출력으로 확인하라.
배정밀도 변수 a에 0.1(접미사 없이), 배정밀도 변수 b에 0.1_dp를 넣고 a - b를 출력하라. 차이가 0이 아닌 이유를 주석으로 한 줄 설명하라.
precision과 range 내장 함수를 단정밀도·배정밀도 변수에 각각 적용해, 두 종류의 유효숫자 자릿수와 지수 폭을 표 형태로 출력하라.c = 36.5_dp를 화씨로 바꿔(f = c * 9.0_dp / 5.0_dp + 32.0_dp) 출력하라. 식 안의 모든 리터럴에 _dp를 붙여야 하는 이유를 한 줄로 적어라.5와 2로 5 / 2와 5.0_dp / 2.0_dp를 각각 계산해 출력하고, 두 결과가 다른 이유를 주석으로 설명하라.