제 1 부 — 기초



3장 · 자료형과 변수


What a Variable Really Holds



[!attention] 이 장의 목표


2장에서 우리는 프로그램의 뼈대(program ... end program)와 implicit none을 익혔다. 그러나 아직 "값을 담는 그릇"인 변수를 제대로 다루지 않았다. 이 장을 마치면 다음 세 가지 문제를 해결할 수 있다.

위 모든 문제의 답은 자료형(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

선언부를 살펴보면 다섯 가지 기본 자료형이 모두 등장한다.

실행부의 출력 명령을 보면 문자형 변수 city를 출력할 때 trim(city)이라는 함수를 적용했다. trim 함수는 Fortran의 내장 함수로, 문자열 변수가 가진 뒤쪽의 필요없는 빈 공백들을 떼어내고 글자만 남겨주는 역할을 한다(14장).


[!important] 3.2 종류 매개변수(kind)와 정밀도

같은 실수형(real) 자료형이라도 얼마나 정밀하게 값을 저장할지는 종류 매개변수(kind)가 결정한다. kind는 자료형의 크기와 특성을 지정한다. 수치 계산에서는 흔히 다음 두 가지 정밀도가 쓰인다.

과학·공학 계산에서는 복잡한 연산을 반복할 때 누적 오차가 빠르게 커지는 것을 막기 위해 배정밀도를 기본으로 사용한다. 과거 레거시 코드에서 자주 쓰이던 real*8 같은 표현은 Fortran 표준이 아니므로, 최신 컴파일러에서 에러를 내거나 정밀도 문제를 유발할 수 있어 지양해야 한다.

Fortran 표준이 제안하는 올바른 접근법은 메모리의 물리적인 바이트 크기를 직접 지정하는 것(real*8 같은 표현)이 아니라, "내가 하려는 계산에 몇 자리의 정밀도(p)와 얼마큼의 지수 범위(r)가 필요한가"를 선언하는 것이다. 예를 들어, 프로그래머가 "소수점 아래 $15$자리까지 정확해야 하고, 지수는 $10^{\pm307}$까지 표현할 수 있어야 한다"라고 요구사항을 제시하면, 컴파일러가 해당 컴퓨터 시스템 하드웨어에 가장 적합한 종류(kind 값)를 알아서 찾아 할당한다.

이런 kind 값을 얻는 표준 방법에는 selected_real_kind()iso_fortran_env 두 가지가 있다.

1. selected_real_kind(p, r) : 이식성 우선 표현

"유효숫자 p자리 이상, 십진 지수 범위 $r$ 이상을 보장하는 종류를 달라"고 컴파일러에 요청하는 내장 함수다. 조건을 만족하는 하드웨어 지원 형식이 있으면 해당하는 양의 정수(kind 값)를 반환한다. 만약 시스템 성능이 부족하여 요청을 만족하지 못하면 다음과 같이 음수 값을 반환한다.

이 방식은 소스 코드를 다른 컴퓨터나 운영체제로 옮겨 컴파일해도 동일한 정밀도를 보장받을 수 있어 프로그램의 이식성을 높이는 데 유리하다. 정수형 데이터를 처리할 때는 이와 유사한 selected_int_kind(r) 함수를 사용할 수 있다.

2. 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_kindreal64를 활용하여 하드웨어 환경에 종속되지 않도록 정밀도를 명시적으로 고정하는 선언부 작성이 필수적으로 강조된다.

Fortran에서 배정밀도(소수점 약 $15$자리 보장) 실수를 사용할 때는 코드의 가독성과 이식성을 높이기 위해 종류 매개변수(kind)에 직접 숫자를 적지 않고, 상수를 정의하여 선언부와 결합하는 아래와 같은 방식을 사용한다.

use iso_fortran_env, only: real64
integer, parameter :: dp = real64        ! 종류 값에 이름을 붙인다
real(dp) :: 변수목록                       ! 그 종류로 변수 선언

! 또는 정밀도·범위 요청 방식
integer, parameter :: dp = selected_real_kind(15, 307)

[예제] kind와 정밀도

현대 Fortran에서는 시스템이나 컴파일러의 환경에 상관없이, 수치 데이터의 내부 정밀도를 온전하게 제어하기 위해 kind 값을 명시적으로 확인하고 다룬다. 아래 예제를 통해 단정밀도(single precision)와 배정밀도(double precision)의 물리적 차이를 통계 함수와 상수를 통해 면밀히 검증해 보자.

1.0_sp3.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

[!important] 3.3 리터럴과 상수, parameter 속성

소스 코드에 변수명을 거치지 않고 실제 수치나 문자를 그대로 직접 적어 넣은 값들을 리터럴(literal) 이라고 한다. 리터럴은 소스 코드에 하드코딩 된 값으로 변수와 달리 데이터 자체가 곧 식별자가 되는 고정 데이터이다. Fortran에서 다루는 리터럴의 대표적인 종류와 표기법은 다음과 같다.

3.14처럼 접미사가 없는 실수 리터럴은 단정밀도라는 점이 자주 사고를 일으킨다. 배정밀도가 필요하면 반드시 3.14_dp처럼 종류를 붙여야 한다.

[!warning] [흔한 실수] 접미사가 없는 실수값 접미사가 없는 3.141.0 같은 실수 리터럴은 컴파일러가 기본적으로 단정밀도로 해석한다는 점을 기억하자. 배정밀도로 선언된 변수에 대입할 때조차 접미사를 빠뜨리면 단정밀도 수준의 덜 정밀한 값이 먼저 생성된 후 변수에 들어가므로, 대형 수치 계산에서 미세한 반올림 오차 버그를 일으키는 주범이 된다. 고정밀도 계산이 필요할 때는 반드시 3.14_dp와 같이 종류 접미사를 꼬리표처럼 붙이는 습관을 들이자.

소스 코드 여기저기에 의미를 정확히 알 수 없는 숫자들(예: 3.141592, 110 등)이 흩어져 있는 것을 '매직 넘버(magic number)' 라고 한다. 이런 매직 넘버는 가독성을 떨어뜨리고 유지보수를 어렵게 만든다.

이를 해결하기 위해 한 번 정하면 프로그램 실행 중에 절대 바꿀 수 없는 고정된 값(원주율, 물리 상수, 배열의 최대 크기 등)에 안전하게 이름을 붙여 두는 것을 명명 상수(named constant)라고 하며, 아래와 같이 parameter 속성을 사용하여 선언한다. 이는 코드 곳곳에 매직 넘버가 흩어지는 것을 방지하고 의미를 명확하게 할 수 있다.

자료형, parameter :: 이름 = 초기식

명명 상수 선언의 올바른 예시는 다음과 같다.

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라는 단정밀도 값을 그대로 사용했다면, 계산 결과는 소수점 아래 몇 자리 가지 못하고 오차가 빠르게 누적되었을 것이다. 그러나 배정밀도 명명 상수인 pi3.141592653589793_dp로 선언해 두면, 동일한 넓이 계산식 pi * radius2를 수행할 때 소수점 아래 15자리까지 신뢰할 수 있는 고정밀도의 면적 값 19.634954084936208을 얻을 수 있다다.

이처럼 parameter를 활용해 상수를 엄격하게 정의하는 습관은 대규모 수치 모델이나 유체 역학 시뮬레이션과 같은 고난도 계산에서 정확도를 확보하고 수치적 안정성을 유지하는 핵심이다.


[!important] 3.4 변수 선언과 초기화

변수는 선언과 동시에 초기값을 부여할 수 있다. 컴파일과 동시에 메모리에 공간을 만들고 원하는 값을 미리 채워두는 방식이다.

변수 선언과 초기화의 공식적인 문법 규격은 다음과 같다.

자료형[, 속성목록] :: 이름[ = 초기식][, 이름2[ = 초기식2] ...]

아래는 전형적인 초기화의 예시이다.

integer  :: i = 0
real(dp) :: total = 0.0_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

결과를 보면 wrongright가 모두 똑같은 배정밀도(64비트 실수) 주소 방으로 선언되었음에도 불구하고 출력된 결과가 전혀 다르다.

두 변수의 절대 차이(diff)를 구해 보면 약 $1.49 \times 10^{-9}$만큼 어긋나 있다. 겉보기에는 소수점 아래 아주 작은 숫자로 느껴질 수 있다. 하지만 수치 해석 연산처럼 수만 번, 수억 번의 누적 루프를 돌리는 대형 연산 체계에서는 이 미세한 틈새가 눈덩이처럼 불어나 결국 예측을 완전히 빗나가게 하는 치명적인 런타임 오류를 초래한다.

[예제] 단정밀도 vs 배정밀도 오차 비교

실수의 정밀도 차이가 실제 누적 연산에서 얼마나 치명적인 오류 결과물로 나타나는지 확인해 보자. 컴퓨터가 정확하게 표현하지 못하는 소수인 0.1을 십만 번($100,000$번) 연속해서 더해가면서, 단정밀도(real32)와 배정밀도(real64)가 각각 수학적 참값에서 얼마나 멀어지는지를 추적해 로그 그래프로 비교 분석한다.

이 예제에서는, 연속적인 데이터를 추출하기 위해 아직 배우지 않은 do 반복문과 if 조건문, 그리고 나머지 연산 함수인 mod를 사용한다. 이 장의 핵심은 어디까지나 real32real64의 정밀도 차이므로, 지금은 "변수 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) 눈금이라는 점에 주의한다.

두 곡선 사이에 벌어진 간격을 보면, 왜 정밀한 시뮬레이션에서 단정밀도를 배제하고 배정밀도를 기본으로 채택하는지 알 수 있다.

참고로 각 곡선이 간간이 아래로 향하는 뾰족한 골(dip) 지점들은, 플러스 오차와 마이너스 오차가 반복문 안에서 누적되다가 일시적으로 합산 오차가 0을 순간 스쳐 지나간 결과이다.


[!important] 오류 학습 새로운 프로그래밍 언어를 배울 때 가장 빨리 성장하는 방법은 타인이 먼저 겪은 실수를 분석하는 것이다. 아래에서는 Fortran 입문자들이 선언과 자료형을 다룰 때 가장 자주 범하는 몇 가지 치명적인 실수를 설명하고 대처를 설명하였다.

1. 배정밀도 변수에 단정밀도 리터럴 넣기

앞서 강조했듯, 변수를 배정밀도(dp)로 선언해 두었다고 해서 안심해서는 안 된다. 대입하는 리터럴 숫자에 접미사를 빠뜨리면 정밀도가 무너진다.

real(dp) :: x
x = 0.1        ! [실수] 0.1은 단정밀도로 먼저 생성됨 -> 이미 약 1.5e-9 오차를 안고 방에 들어감
x = 0.1_dp     ! [올바름] 처음부터 64비트 배정밀도로 생성되어 방에 안착함

컴파일러는 = 오른쪽의 데이터를 먼저 해석한다. 접미사가 없는 0.1은 32비트 크기의 데이터로 먼저 만들어진 후 배정밀도 메모리 공간에 들어가므로, 이미 유실된 정밀도를 되찾을 방법이 없다. 고정밀도 수치 계산을 진행할 때는 숫 뒤에 반드시 _dp를 꼬리표처럼 붙여야 한다.

2. 비표준 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 규규격으로 통일해야 이식성 높은 코드가 된다.

3. 하드웨어가 지원하지 않는 무리한 정밀도 요청

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 하드웨어나 컴파일러에서만 처리할 수 있다. 사용하고자 하는 하드웨어 환경을 충분히 이해하고 정밀도를 요청해야 한다.

4. 미초기화 변수를 0으로 짐작

선언만 해두고 아무런 값도 대입하지 않은 변수의 내부 알맹이는 완전한 빈 방이 아니다. 메모리 공간에 이전에 남아 있던 무작위의 쓰레기 값이 잔존해 있다.

integer :: total

total = total + 1   ! [치명적 실수] total의 시작 주소에 무슨 값이 들었는지 모른다!

이 상태에서 곧바로 누적 연산하면, 매 실행 마다 엉뚱한 결과물이 튀어나와 시스템을 교란한다. 누적 계산이나 카운팅에 사용되는 변수는 선언과 동시에 혹은 실행부 시작점에서 반드시 total = 0 또는 total = 0.0_dp와 같이 명시적으로 초기화하고 사용해야 한다.

5. 정수 표현 범위를 넘어서는 오버플로(Overflow)

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] 요약


[!important] 연습 문제

  1. 정수형 변수 하나, 단정밀도·배정밀도 실수 변수 하나씩, 논리형 변수 하나를 선언해 적당한 값을 넣고 모두 출력하는 프로그램을 작성하라.
  2. iso_fortran_env를 써서 int8, int16, int32, int64huge 값을 각각 출력하라. 어떤 종류가 약 21억까지, 어떤 종류가 약 922경까지 담는가?
  3. 복소수 변수 z = (3.0_dp, 4.0_dp)를 선언하고, 내장 함수 real(z)·aimag(z)·abs(z)로 실수부·허수부·크기를 출력하라.
  4. 원주율 pi를 배정밀도 명명 상수로 정의하고, 반지름 1.0·2.0·3.0인 원의 넓이를 차례로 출력하라(반복문 없이 세 줄로).
  5. character(len=*) 명명 상수로 자신의 이름을 담고, len 내장 함수로 그 길이를 함께 출력하라.
  6. selected_real_kind(6)real32, selected_real_kind(15)real64가 같은 종류 값을 주는지 출력으로 확인하라.

  7. 배정밀도 변수 a0.1(접미사 없이), 배정밀도 변수 b0.1_dp를 넣고 a - b를 출력하라. 차이가 0이 아닌 이유를 주석으로 한 줄 설명하라.

  8. precisionrange 내장 함수를 단정밀도·배정밀도 변수에 각각 적용해, 두 종류의 유효숫자 자릿수와 지수 폭을 표 형태로 출력하라.
  9. 섭씨온도 명명 상수 c = 36.5_dp를 화씨로 바꿔(f = c * 9.0_dp / 5.0_dp + 32.0_dp) 출력하라. 식 안의 모든 리터럴에 _dp를 붙여야 하는 이유를 한 줄로 적어라.
  10. 정수 525 / 25.0_dp / 2.0_dp를 각각 계산해 출력하고, 두 결과가 다른 이유를 주석으로 설명하라.