[!attention] 이 장의 목표
- 연산자 다루기 — 산술·관계·논리 연산자로 식을 구성한다.
- 우선순위 읽기 — 연산자 우선순위를 알고 괄호로 의도를 분명히 한다.
- 형 변환 이해 — 혼합형 연산에서 형이 어떻게 변환되는지 파악한다.
- 수학 함수 활용 — 표준 수학 내장 함수를 식에 끌어 쓴다.
3장에서 변수를 선언하고 값을 담는 법을 익혔다. 이번 장에서는 담긴 데이터를 가공하고 연산하는 수식의 법칙을 배운다. 이 장을 마치고 나면 다음 두 가지 문제를 해결할 수 있다.
문제 1: 이차방정식 $ax^2 + bx + c = 0$의 두 실근을 구하는 공식을 코드로 구현한다. 이를 위해서는 거듭제곱, 제곱근, 괄호가 얽힌 연산의 우선순위를 정확하게 제어해야 한다.
문제 2: 점수 7점을 두 사람이 똑같이 나누면 한 사람당 몇 점인가? Fortran 수식에 단순히 7 / 2라고 적으면 우리가 기대하는 3.5가 아니라 고장 난 듯한 3이 출력된다. 그 기저에 깔린 이유와, 정확한 수치인 3.5를 얻어내는 올바른 방법을 규명한다.
두 문제의 핵심은 같다. "컴퓨터 내부에서 어떤 연산이 먼저 일어나는가", 그리고 "연산에 참여하는 데이터의 자료형이 무엇인가"이다. 수치 계산의 정확도를 결정짓는 연산자 우선순위와 식 내부에서의 형 변환(type conversion) 규칙을 면밀히 살펴보자.
[!important] 4.1 산술·관계·논리 연산자
Fortran의 연산자는 결과의 종류에 따라 세 가지로 나뉜다. 산술 연산자는 숫자를 관계 연산자와 논리 연산자는 참·거짓(logical)을 반환한다.
Fortran은 수학 공식을 가장 직관적으로 코드로 옮길 수 있도록 설계된 언어다. 사칙연산을 비롯해 거듭제곱까지 총 5가지의 기본적인 산술 연산자를 제공한다.
| 연산자 | 의미 | 예 | 결과 |
|---|---|---|---|
+ |
덧셈 | 3 + 4 |
7 |
- |
뺄셈 | 3 - 4 |
-1 |
* |
곱셈 | 3 * 4 |
12 |
/ |
나눗셈 | 7.0 / 2.0 |
3.5 |
** |
거듭제곱 | 2 ** 10 |
1024 |
Fortran에는 나머지를 구하는 별도 기호 연산자는 없고, 대신 내장 함수 mod와 modulo를 사용한다. 정수끼리의 /는 나눗셈의 몫만 남기고 소수부를 버린다.
하나의 수식에 여러 연산자가 섞여 있을 때, 수학의 사칙연산 법칙과 거의 동일한 우선순위에 따라 순서대로 계산을 수행한다.
괄호 (): 우선순위와 상관없이 괄호로 감싸인 부분이 무조건 최우선으로 계산된다. 복잡한 수식일수록 괄호를 명시하여 의도를 명확히 하는 것이 좋다.
거듭제곱 : 사칙연산보다 거듭제곱이 먼저 계산된다. 예를 들어 a + b**2는 $a + (b^2)$으로 해석되지, $(a+b)^2$이 아니다.
곱셈 *과 나눗셈 /: 거듭제곱 다음으로 높은 순위를 가지며, 수식 안에서 동등한 순위일 경우 왼쪽에서 오른쪽 방향으로 순서대로 계산된다.
덧셈 +와 뺄셈 -: 가장 낮은 순위를 가지며, 이 역시 동등한 순위끼리는 왼쪽에서 오른쪽 방향으로 순서대로 계산된다.
[!note] Fortran만의 거듭제곱의 우결합성(Right-to-Left) 곱셈이나 나눗셈은 왼쪽에서 오른쪽으로 계산되지만, 거듭제곱 연산자가 연속해서 나타나는
2 ** 3 ** 2같은 식은 특이하게 오른쪽에서 왼쪽 방향(right-to-left)으로 거꾸로 계산된다. 즉, $(2^3)^2 = 8^2 = 64$가 아니라, $2^{(3^2)} = 2^9 = 512$로 계산된다. 혼란을 피하기 위해서는 반드시(2**3)**2와 같이 괄호를 쳐서 표현한다.
산술 연산자와 우선순위 규칙이 실제 코드에서 어떻게 적용되어 출력되는지 확인해 보자. 특히 도입부 문제에서 '나눗셈 자료형에 따른 결과의 차이'에 집중해서 살펴볼 필요가 있다.
%%writefile arithmetic_ops.f90
program arithmetic_ops
implicit none
integer :: a, b
real :: x, y
a = 17
b = 5
print *, "a + b =", a + b
print *, "a - b =", a - b
print *, "a * b =", a * b
print *, "a / b =", a / b ! integer division -> 3
print *, "mod(a, b) =", mod(a, b) ! remainder -> 2
print *, "a ** 2 =", a ** 2
x = 17.0
y = 5.0
print *, "x / y =", x / y ! real division -> 3.4
end program arithmetic_ops
컴파일·실행:
!gfortran -O2 -std=f2018 -Wall arithmetic_ops.f90 -o arithmetic_ops
!./arithmetic_ops
실행 결과:
a + b = 22
a - b = 12
a * b = 85
a / b = 3
mod(a, b) = 2
a ** 2 = 289
x / y = 3.40000010
$17$과 $5$를 정수끼리 나누었음에도 불구하고 참여하는 변수의 성격에 따라 결과가 달라진다.
정수형 나눗셈 (a / b): 정수형 변수끼리 연산(17 / 5)을 수행하면 Fortran은 결과 역시 무조건 정수형태로 보존한다. 수학적 결과인 3.4에서 소수점 아래 자리를 반올림 없이 '소수점 버림(truncation)'이 일어나 최종값 3이 된다. 만약 나눈 나머지를 따로 구하고 싶다면 내장 함수인 mod(a, b)를 호출해야 정수 2를 얻을 수 있다.
실수형 나눗셈 (x / y): 실수형 변수끼리 연산(17.0 / 5.0)을 수행하면 소수점이 온전히 보존되어 3.4라는 값이 정확하게 계산된다.
[!note] 실수 나눗셈 결과가
3.4가 아니라3.40000010인 이유 이는 컴퓨터의 계산 오류나 고장이 아니다. 기본 단정밀도 실수형(real)이 가지는 물리적인 유효자릿수(약 7자리) 한계** 때문에 발생하는 부동소수점 표현식의 고유한 근사값 현상이다. 이런 미세 오차까지 통제하여 완벽한3.4수준의 정밀도를 확보하는 상세한 연산 규칙과 형 변환 메커니즘은 다음 절에서 깊이 있게 다룬다.
관계 연산자는 두 수치나 변수의 크기를 비교하여 참(.true.) 또는 거짓(.false.)을 반환하는 연산자다. 연산의 결과는 언제나 논리형인 logical 자료형이 된다.
현대 Fortran은 다른 주류 언어들과 마찬가지로 직관적인 기호 표기법을 사용한다. 레거시 코드를 읽기 위한 옛 표기법과 나란히 비교해 보면 다음과 같다.
| 현대 표기 | 옛 표기 | 의미 |
|---|---|---|
< |
.lt. |
작다 |
<= |
.le. |
작거나 같다 |
> |
.gt. |
크다 |
>= |
.ge. |
크거나 같다 |
== |
.eq. |
같다 |
/= |
.ne. |
같지 않다 |
현대 Fortran 코딩에서는 < 나 == 같은 기호 표기만을 전적으로 사용한다.
마침표와 알파벳으로 구성된 .lt., .eq. 같은 형태의 옛 표기법은 수십 년 전 구현된 방대한 수치 해석 레거시 코드에 여전히 존재하므로 알아두자.
C 언어나 파이썬 등에서는 '같지 않다'를 !=로 표기하지만, Fortran에서는 /=를 사용한다는 점을 기억하자.
논리 연산자는 참(.true.) 또는 거짓(.false.)을 가지는 논리형(logical) 데이터끼리 복잡한 조건을 만들어낼 때 사용한다. 예를 들어, 특정 온도와 습도 조건을 동시에 만족하는지 판별할 때처럼 빈번하게 사용되는 연산자다.
논리 연산자는 관계 연산자와 달리 양옆에 반드시 마침표(.)를 붙여서 표기해야 컴파일러가 올바르게 인식한다.
| 연산자 | 의미 | 예시 (p=.true., q=.false.) |
결과 |
|---|---|---|---|
.not. |
부정 (NOT): 상태를 반대로 뒤집는다. | .not. p |
.false. |
.and. |
논리곱 (AND): 둘 다 참이어야만 참이다. | p .and. q |
.false. |
.or. |
논리합 (OR): 둘 중 하나만 참이어야 참이다. | p .or. q |
.true. |
.eqv. |
동치 (Equivalent): 두 논리값이 서로 같으면 참이다. | p .eqv. q |
.false. |
.neqv. |
배타적 논리합 (XOR): 두 논리값이 서로 다르면 참이다. | p .neqv. q |
.true. |
아래 예제는 관계 연산자로 도출한 논리값을 논리 연산자로 다시 묶어내는 전형적인 복합 조건 패턴을 보여준다.
%%writefile logic_ops.f90
program logic_ops
implicit none
integer :: age
logical :: is_adult, has_ticket, can_enter
age = 20
has_ticket = .true.
is_adult = age >= 19
can_enter = is_adult .and. has_ticket
print *, "is_adult =", is_adult
print *, "can_enter =", can_enter
print *, "20 == 20 :", 20 == 20
print *, "3 /= 5 :", 3 /= 5
print *, "not adult :", .not. is_adult
end program logic_ops
컴파일·실행:
!gfortran -O2 -std=f2018 -Wall logic_ops.f90 -o logic_ops
!./logic_ops
실행 결과:
is_adult = T
can_enter = T
20 == 20 : T
3 /= 5 : T
not adult : F
logical 변수나 연산식의 결과는 화면에 출력될 때는 . 없이 T 또는 F라는 단일 문자로 표시된다.[!note] 관계식을 변수에 직접 대입함으로 직관성을 높이자. 위 예제의
is_adult = age >= 19구조를 보자. 대부분의 입문자들이 복잡한if구문(5장)을 사용해서 변수에 참·거짓을 넣으려고 한다. 하지만,age >= 19라는 관계식 자체도 컴퓨터 내부에서는 하나의 완전한 논리값(.true.)으로 취급된다. 따라서 계산 결과를 별도의 과정 없이 논리형 변수에 곧바로 대입할 수 있으며, 이 방식을 활용하면 코드의 직관성을 높일 수 있다.** 1. 입문자가 자주 쓰는 비효율적인 예시** 보통 참·거짓을 판별할 때 먼저
if구문을 떠올리기 때문에 다음과 같이 코드가 길어지기 쉽다. ```fortran program check_adult_bad implicit none integer :: age logical :: is_adultage = 20
! 불필요하게 복잡한 if-else 구조를 사용하는 경우 if (age >= 19) then is_adult = .true. else is_adult = .false. end if print , "Is adult?", is_adult end program check_adult_bad ``` 2. 관계식을 곧바로 대입하는 효율적인 방식*
age >= 19라는 수식 자체가 컴퓨터 내부에서는 이미.true.또는.false.라는 하나의 결괏값이다. 따라서if구문으로 블럭을 만들 필요 없이 변수에 그대로 넣으면 된다.```fortran program check_adult_good implicit none integer :: age logical :: is_adult
age = 20
! 관계식의 결과를 논리형 변수에 직접 대입 is_adult = (age >= 19)
print *, "Is adult?", is_adult end program check_adult_good ```
직접 대입 방식을 사용하면 코드가 단 한 줄로 줄어들 뿐만 아니라, "성인(
is_adult)의 기준은 나이가 19세 이상(age >= 19)인 상태다"라는 의미가 명확해지므로 코드의 직관성이 크게 향상된다. 코드를 읽는 사람 입장에서도 불필요한 분기문(if)을 거치지 않아 구조를 파악하기 훨씬 쉽다.
[!important] 4.2 연산자 우선순위
한 식 안에 여러 연산자가 섞이면 Fortran은 정해진 우선순위(precedence)에 따라 계산 순서를 결정한다. 만약 우선순위가 같다면 결합 방향(associativity) 규칙을 따른다.
Fortran의 연산자 우선순위는 가장 높은 곳에서 낮은 순서로 다음과 같이 정의된다.
| 순위 | 연산자 | 결합 방향 |
|---|---|---|
| 1 (가장 높음) | ** |
오른쪽 → 왼쪽 |
| 2 | * / |
왼쪽 → 오른쪽 |
| 3 | 단항 + -, 이항 + - |
왼쪽 → 오른쪽 |
| 4 | 관계 연산자 (< <= > >= == /=) |
— |
| 5 | .not. |
— |
| 6 | .and. |
왼쪽 → 오른쪽 |
| 7 | .or. |
왼쪽 → 오른쪽 |
| 8 (가장 낮음) | .eqv. .neqv. |
왼쪽 → 오른쪽 |
거듭제곱이 가장 먼저 계산되고, 곱셈과 나눗셈이 그다음, 덧셈과 뺄셈이 그다음이다. 즉, 모든 산술 연산이 완전히 끝난 뒤에 대·소를 비교하는 관계 연산이 일어나고, 비교가 끝난 후에 비로소 참·거짓을 엮는 논리 연산이 수행된다.
거듭제곱(**)은 오른쪽부터 묶어 계산하고, 나눗셈(/)은 왼쪽부터 묶는다. 이 결합 방향의 차이가 연산 결과를 완전히 바꾼다.
%%writefile precedence.f90
program precedence
implicit none
integer :: r1, r2, r3
real :: r4
r1 = 2 + 3 * 4 ! 14 : multiply first
r2 = 2 ** 3 ** 2 ! 512: right to left, 2 ** (3 ** 2) = 2 ** 9
r3 = -2 ** 2 ! -4 : power first, then unary minus: -(2 ** 2)
r4 = 10.0 / 2.0 / 5.0 ! 1.0: left to right, (10 / 2) / 5
print *, "2 + 3 * 4 =", r1
print *, "2 ** 3 ** 2 =", r2
print *, "-2 ** 2 =", r3
print *, "10/2/5 =", r4
end program precedence
컴파일·실행:
!gfortran -O2 -std=f2018 -Wall precedence.f90 -o precedence
!./precedence
실행 결과:
2 + 3 * 4 = 14
2 ** 3 ** 2 = 512
-2 ** 2 = -4
10/2/5 = 1.00000000
2 ** 3 ** 2를 일반적인 사칙 연산 처럼 왼쪽부터 묶어 계산하면 (2**3)**2 = 64가 되어야 할 것 같지만, **은 오른쪽 결합 법칙을 따르므로 2**(3**2) = 2**9 = 512가 된다.
또한 -2 ** 2는 단항 마이너스보다 우선순위가 높기 때문에 -(2**2) = -4이다. 만약 음수 $-2$ 자체를 제곱한 결과인 $4$를 얻고 싶다면 반드시 괄호를 사용한다.
아래 두 식은 문법적으로 완전히 같은 값을 내지만, 두 번째 식이 프로그래머의 의도를 훨씬 더 또렷하게 보여준다.
y = a + b * c ! works, but the order is implicit
y = a + (b * c) ! same result, intent is explicit
[!warning] [흔한 실수] 거듭제곱 적용 방향 수학에서 $-2^2 = -4$ 인 것과 마찬가지로 Fortran에서도
-2**2는-4이다. 음수를 제곱하려면 반드시 괄호를 쓴다. 음수 자체를 거듭제곱하려는 목적이라면 반드시(-2)**2와 같이 괄호를 씌워야 한다. 거듭제곱 연산자가 부호 연산자보다 먼저 적용된다는 사실을 기억하자.
[!important] 4.3 형 변환과 혼합형 연산
하나의 연산식 안에 정수와 실수가 섞여 있는 상태를 혼합형(mixed-mode) 연산이라고 한다. Fortran은 이런 혼합형 연산이 발생하면 더 넓은 표현 범위를 가진 자료형인 실수로 정수를 자동 변환(올림 변환)한 뒤 계산을 수행한다.
여기서 주의해야 할 점은, 이 자동 변환이 식 전체를 보고 한 번에 일어나는 것이 아니라 연산자 단위로 좌우의 피연산자(값)만 보고 단계적으로 일어난다는 사실이다. 이 규칙 때문에 식의 일부분이 정수끼리 먼저 계산되어 버리는 치명적인 함정이 발생할 수 있다.
| 함수 | 하는 일 | 예 | 결과 |
|---|---|---|---|
real(i) / real(i, kind) |
정수 → 실수 (종류 지정 가능) | real(5) |
5.0 |
int(x) |
실수 → 정수 (0 방향으로 버림) | int(3.9) |
3 |
nint(x) |
실수 → 가장 가까운 정수 (반올림) | nint(3.9) |
4 |
cmplx(a, b, kind) |
두 실수 → 복소수 | cmplx(1.0, 2.0) |
(1.0, 2.0) |
int와 nint는 소수점을 처리하는 방식이 완전히 다르다. 예를 들어 $4.5$라는 값을 정수로 바꿀 때 int(4.5)는 4가 되지만, nint(4.5)는 5가 된다. 연산의 목적에 맞는 함수를 정확히 구분해서 사용해야 한다.
실수형 변환을 수행할 때도 정밀도를 안전하게 보장받기 위해서는 real*8 같은 비표준 표기법 대신 종류 매개변수(kind)를 명시해야 한다. 표준 모듈인 iso_fortran_env에서 real64를 불러와 적용하는 것이 올바른 방법이다.
use iso_fortran_env, only: real64
real(real64) :: x
x = real(total, real64) / real(count, real64)
real(total, real64) 구문은 정수형인 total 변수를 8바이트 배정밀도 실수로 정밀하게 변환한다. 만약 두 번째 인자인 종류 값을 생략하고 real(total) 형태로만 사용하면 시스템 기본값인 4바이트 단정밀도 실수로 변환되므로, 정밀도가 중요한 대규모 과학 계산에서는 항상 종류(kind)를 명시하는 습관을 들이자.
[!warning] [흔한 실수]
real(7 / 2)는3.5가 아니라3.0이다. 괄호 안의7 / 2라는 정수 나눗셈이 컴파일러에 의해 먼저 계산되어 정수3이 되고, 그 변환된3이real함수를 거쳐 실수3.0으로 바뀌기 때문이다. 정확한 정밀도 계산을 원한다면real(7) / real(2)와 같이 나누기 연산이 수행되기 전 단계에 형 변환 함수를 각각 적용해야 한다.
다음 예제 코드를 통해 가장 많이 실수하는 정수 나눗셈의 특성과 올바른 형 변환 방식을 확인해 보자.
%%writefile conversion.f90
program conversion
use iso_fortran_env, only: real64
implicit none
integer :: total, count
real :: avg_wrong
real(real64) :: avg_right
total = 7
count = 2
avg_wrong = total / count ! 3.0 : integer division first
avg_right = real(total, real64) / real(count, real64) ! 3.5 : convert before dividing
print *, "avg_wrong =", avg_wrong
print *, "avg_right =", avg_right
print *, "int(3.9) =", int(3.9) ! 3 : truncates toward zero
print *, "nint(3.9) =", nint(3.9) ! 4 : rounds to nearest
print *, "real(5) =", real(5) ! 5.0 : integer -> real
end program conversion
컴파일·실행:
!gfortran -O2 -std=f2018 -Wall conversion.f90 -o conversion
!./conversion
실행 결과:
avg_wrong = 3.00000000
avg_right = 3.5000000000000000
int(3.9) = 3
nint(3.9) = 4
real(5) = 5.00000000
avg_wrong = total / count 연산에서 오른쪽에 있는 두 변수는 모두 정수형이다. 컴퓨터는 대입될 대상 변수(avg_wrong)가 실수형이라는 사실을 전혀 고려하지 않고, 오직 우변의 total / count 자체만 바라본다. 정수 $7$을 정수 $2$로 나누면 소수점이 버려진 정수 $3$이 되며, 이 최종 결과물 $3$이 실수형 변수에 들어가면서 3.0이라는 잘못된 값이 저장된다.
원하는 올바른 평균값($3.5$)을 얻으려면 avg_right처럼 나누기 연산을 시작하기 전에 각 피연산자를 명시적으로 실수로 바꾸어 주어야 한다.
[!important] 4.4 수학 내장 함수
Fortran은 과학·공학 계산을 위해 풍부한 수학 내장 함수(intrinsic functions)를 언어 표준으로 지원한다. 별도의 외부 라이브러리를 호출(include나 import)할 필요 없이 코드 어디서든지 즉시 사용할 수 있다는 장점이 있다.
자주 쓰는 Fortran의 주요 내장 함수는 연산 목적에 따라 다음 표와 같이 분류된다.
| 분류 | 함수 | 설명 및 수학적 의미 |
|---|---|---|
| 거듭제곱 및 거듭제곱근 | sqrt(x) |
입력값의 제곱근(√x)을 계산한다. |
exp(x) |
밑이 e인 지수 함수(eˣ)를 계산한다. | |
log(x) |
자연로그(ln x)를 구한다. | |
log10(x) |
밑이 10인 상용로그(log₁₀x)를 구한다. | |
| 삼각 함수 | sin(x) / cos(x) / tan(x) |
라디안 단위를 인수로 받아 사인, 코사인, 탄젠트 값을 각각 계산한다. |
| 역삼각 함수 | asin(x) / acos(x) / atan(x) |
역사인, 역코사인, 역탄젠트 값을 계산한다. |
atan2(y, x) |
점 (x, y)가 위치한 사분면을 자동으로 고려하여 올바른 역탄젠트 각도를 반환한다. | |
| 절댓값 및 부호 | abs(x) |
값의 크기만을 나타내는 절댓값(|x|)을 구한다. |
sign(a, b) |
a의 크기에 b의 부호(양수 또는 음수)를 결합한 값을 반환한다. | |
| 나머지 연산 | mod(a, p) / modulo(a, p) |
a를 p로 나눈 나머지를 구하는 함수로, 두 함수는 음수가 포함되었을 때 부호를 처리하는 규칙이 서로 다르다. |
| 최대 및 최소 | max(a, b, ...) / min(a, b, ...) |
나열된 여러 인자들 중에서 가장 큰 값과 가장 작은 값을 각각 찾아낸다. |
| 올림 및 버림 | floor(x) / ceiling(x) |
소수점을 내리는 바닥값 연산과 소수점을 올리는 천장값 연산을 수행한다. |
aint(x) / anint(x) |
소수점을 0 방향으로 무조건 버리거나, 가장 가까운 정수 형태로 반올림하여 반환한다. |
여기에 나열된 대부분의 내장 함수는 인수로 전달되는 변수의 종류 매개변수(real32, real64 등)를 컴파일러가 스스로 파악하여 결과도 같은 정밀도로 돌려주는 포괄(generic) 함수다. 즉, sqrt 함수에 단정밀도 실수를 넣으면 단정밀도 결과가, 배정밀도 실수를 넣으면 배정밀도 결과가 자동으로 반환된다.
이차방정식 $x^2 - 3x + 2 = 0$ 의 두 근을 근의 공식 $x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}$ 으로 구해 보자.
%%writefile quadratic.f90
program quadratic
use iso_fortran_env, only: real64
implicit none
real(real64) :: a, b, c, disc, x1, x2
a = 1.0_real64
b = -3.0_real64
c = 2.0_real64
disc = b**2 - 4.0_real64 * a * c
x1 = (-b + sqrt(disc)) / (2.0_real64 * a)
x2 = (-b - sqrt(disc)) / (2.0_real64 * a)
print *, "discriminant =", disc
print *, "x1 =", x1
print *, "x2 =", x2
end program quadratic
컴파일·실행:
!gfortran -O2 -std=f2018 -Wall quadratic.f90 -o quadratic
!./quadratic
실행 결과:
discriminant = 1.0000000000000000
x1 = 2.0000000000000000
x2 = 1.0000000000000000
이 코드에서 주목해야 할 부분은 다음과 같다.
(2.0_real64 * a) 전체를 괄호로 감싸주었다는 점이다. 만약 괄호가 없다면 나눗셈(/)과 곱셈(*)은 우선순위가 동일하므로 왼쪽부터 차례대로 연산이 수행된다. 즉, 분자를 2.0_real64로 먼저 나눈 결과값에 변수 a를 다시 곱하는 형태가 되어 수학적으로 완전히 잘못된 수식이 된다. 4.0_real64, 2.0_real64와 같이 수식 내의 리터럴 상수 뒤에도 종류 매개변수(kind)를 명시적으로 붙여 배정밀도로 계산하도록 설정했다.mod와 modulo의 차이이름이 비슷해 보이는 mod와 modulo 함수는 음수 데이터를 처리할 때 결괏값의 부호 규칙이 서로 다르다.
mod(a, p): 최종 결과의 부호가 첫 번째 인수(피제수 a)의 부호를 그대로 따른다. modulo(a, p): 최종 결과의 부호가 두 번째 인수(제수 p)의 부호를 그대로 따른다.%%writefile mod_vs_modulo.f90
program mod_vs_modulo
implicit none
print *, "mod(7, 3) =", mod(7, 3)
print *, "modulo(7, 3) =", modulo(7, 3)
print *, "mod(-7, 3) =", mod(-7, 3) ! sign follows dividend -> -1
print *, "modulo(-7, 3) =", modulo(-7, 3) ! sign follows divisor -> 2
end program mod_vs_modulo
컴파일·실행:
!gfortran -O2 -std=f2018 -Wall mod_vs_modulo.f90 -o mod_vs_modulo
!./mod_vs_modulo
실행 결과:
mod(7, 3) = 1
modulo(7, 3) = 1
mod(-7, 3) = -1
modulo(-7, 3) = 2
mod(-7, 3)은 수학적인 몫을 구한 뒤 남은 부호가 그대로 유지되어 -1을 반환한다. 반면, modulo(-7, 3)은 시계 방향 순환 구조나 격자 배열의 주기적 인덱스 제어처럼 결괏값을 항상 $0$부터 $p-1$ 범위($0, 1, 2$) 안으로 감싸서서 정렬해야 할 때 사용되며, 제수 2의 부호를 따라 2를 반환한다. modulo가 적합하다.[!warning] [흔한 실수] Fortran 의 모든 삼각 함수 인수는 일반적인 '도(Degree)' 단위가 아니라 '라디안(Radian)' 단위를 기준으로 작동한다. 따라서
sin(90.0)이라고 코드를 작성하면 우리가 기대하는 $\sin(90^\circ) = 1.0$이 아니라, $90\text{ rad}$에 대한 사인 값을 계산한다.fortran real(real64), parameter :: pi = 3.141592653589793_real64 real(real64) :: result result = sin(90.0_real64 * pi / 180.0_real64)각도 단위를 사용하여 삼각 함수를 계산하고 싶다면 다음과 같이 호도법 변환식을 연산 내에 직접 포함해야 한다.
여기서는 산술 연산, 거듭제곱, 그리고 수학 함수들을 모두 사용하여 복합적인 수치 데이터를 생성해 본다. 독립 변수 $x$의 범위를 $[0, 4\pi]$ 구간으로 설정하고, 이 안에서 $\sin x$, $\cos x$, 그리고 진폭이 서서히 줄어드는 감쇠 사인 함수인 $e^{-x/5}\sin x$ 세 곡선의 값을 정밀하게 계산하여 외부 파일로 저장한다.
연속적인 격자점 위의 함숫값들을 대량으로 생성하기 위해 do 반복문을 활용한다. do 구문의 상세한 제어 방식은 5장에서 다루므로, 여기서는 "변수 i를 이용해 0부터 n까지 안쪽 문장을 반복하며 데이터를 축적한다" 정도로 이해하고 예제의 흐름을 따라가 보자.
%%writefile fig04_functions.f90
program functions_plot
use iso_fortran_env, only: real64
implicit none
integer, parameter :: n = 200
real(real64), parameter :: pi = 3.141592653589793_real64
integer :: i, u
real(real64) :: x, f1, f2, f3
open(newunit=u, file="functions.csv", status="replace", action="write")
write(u, '(A)') "x,sin,cos,damped"
do i = 0, n
x = real(i, real64) / real(n, real64) * 4.0_real64 * pi
f1 = sin(x)
f2 = cos(x)
f3 = exp(-x / 5.0_real64) * sin(x)
write(u, '(F12.6, 3(",", F12.6))') x, f1, f2, f3
end do
close(u)
print *, "functions.csv written"
end program functions_plot
컴파일·실행:
!gfortran -O2 -std=f2018 -Wall fig04_functions.f90 -o fig04_functions
!./fig04_functions
functions.csv written
실행이 성공적으로 완료되면 작업 디렉토리에 functions.csv 파일이 생성된다. !head -4 functions.csv 명령으로 파일의 상단부를 열면 다음과 같이 나타난다.
x,sin,cos,damped
0.000000, 0.000000, 1.000000, 0.000000
0.062832, 0.062791, 0.998027, 0.062006
0.125664, 0.125333, 0.992115, 0.122223
이제 csv 수치 데이터를 파이썬으로 넘겨 받아 시각화해 보자.
import csv
import matplotlib.pyplot as plt
x, s, c, d = [], [], [], []
with open("functions.csv") as f:
reader = csv.DictReader(f)
for row in reader:
x.append(float(row["x"]))
s.append(float(row["sin"]))
c.append(float(row["cos"]))
d.append(float(row["damped"]))
plt.figure(figsize=(8, 4.5))
plt.plot(x, s, label="sin(x)")
plt.plot(x, c, label="cos(x)")
plt.plot(x, d, label="exp(-x/5) sin(x)")
plt.xlabel("x")
plt.ylabel("y")
plt.title("Math functions: Fortran compute, Python plot")
plt.legend()
plt.grid(True)
plt.savefig("functions.png", dpi=120, bbox_inches="tight")
plt.show()
![[Pasted image 20260719120110.png]]
코드를 실행하면 세 개의 삼각함수 기반 곡선들이 하나의 좌표축 위에 조화롭게 겹쳐진 그래프가 완성된다.
그래프를 분석해 보면, 다음 물리적·수학적 특성이 뚜렷하게 관찰된다.
기본 파동 ($\sin x$, $\cos x$): 두 함수는 동일한 진폭($1.0$)을 유지한 채 정확히 $90^\circ$($\pi/2$)의 위상(Phase) 차이를 두고 매끄럽게 맞물려 흘러간다.
감쇠 파동 ($e^{-x/5}\sin x$): 지수 감쇠 성분인 exp(-x / 5.0_real64)가 곱해진 물리적 특성을 반영하듯, $x$축 값이 우측으로 진행함에 따라 진폭이 점진적으로 잦아드는 감쇠 거동이 나타난다.
[!important] 오류 학습
일상에서는 10진법을 사용하므로 0.1이나 0.2 같은 숫자가 소수점이 깔끔하게 떨어지는 숫자이지만, 컴퓨터는 모든 숫자를 2진법(0과 1)으로 바꾸어 저장하기 때문이다.
10진수에서 분모가 2나 5의 거듭제곱으로 떨어지는 분수(예: $1/2 = 0.5$, $1/4 = 0.25$)는 소수점 아래 자리가 유한하게 끝난다. 2진수는 분모가 2의 거듭제곱($1/2, 1/4, 1/8, 1/16 \dots$)인 분수만 유한소수로 깨끗하게 떨어진다.
0.5는 분수로 $\frac{1}{2}$이므로, 2진수로 바꾸면 0.1(2)로 정확하게 표현된다.0.1은 분수로 $\frac{1}{10}$이다. 이를 2진수 분수들의 합으로 표현하려고 시도하면 다음과 같이 소수점 이하 값이 끝이 나지 않는다. $$\frac{1}{10} = \frac{1}{16} + \frac{1}{32} + \frac{1}{256} + \frac{1}{512} + \dots$$
이를 2진수 소수로 나열하면 0.00011001100110011... (2)과 같이 0011이 무한히 반복되는 무한소수(순환소수)가 되어버린다.컴퓨터의 메모리 공간(비트 수)은 32비트(단정밀도)든 64비트(배정밀도)든 항상 물리적으로 한정되어 있다. 끝없이 이어지는 무한소수를 정해진 방 안에 다 밀어 넣을 수 없기 때문에, 컴퓨터는 가장 가까운 근사값 지점에서 꼬리를 잘라내고 메모리에 저장한다.
이런 미세한 버림 오차 때문에, 현대 컴퓨터 환경에서 0.1 + 0.2를 계산하면 정밀한 수학적 참값인 0.3이 나오지 않는다.
! Fortran (real64 배정밀도 환경)
print *, 0.1_real64 + 0.2_real64
실행 결과:
0.30000000000000004
소수점 아래 17번째 자리에서 아주 작은 4가 붙어있는 것을 볼 수 있다. 이는 Fortran 언어 자체의 설계 결함이나 오류가 아니라, 현대 컴퓨터 하드웨어의 국제 표준인 IEEE 754 부동소수점 규격을 따르는 모든 프로그래밍 언어(C, C++, Python, Java 등)의 물리적인 공통 한계이이다.
이런 부동소수점의 특성 때문에 수치 해석 코드를 짤 때 금기 사항은 두 실수를 == 연산자로 직접 같은지 비교하는 것이다.
! [매우 위험한 코드]
if (x == 0.3_dp) then ! x가 내부 오차로 0.30000000000000004라면 거짓(false)이 됨
미세한 근사값 오차 때문에 논리적으로 참이어야 할 조건문이 거짓으로 판정되어 조건을 실행하지 못할 수 있다. 따라서 두 실수가 일치하는지 판정하고 싶을 때는, 두 값의 차이를 뺀 뒤 절댓값(abs)을 취한 아주 미세한 오차 범위(에프실론, $\epsilon$)보다 작은지를 검증하는 방식으로 아래와 같이 코드를 설계해야 안전하다.
! [올바르고 안전한 코드]
if (abs(x - 0.3_dp) < 1.0e-12_dp) then
! 두 수의 차이가 소수점 아래 12번째 자리보다 작다면 사실상 '같다'고 판정한다
end if
컴파일러는 코드를 단순히 기계어로 번역하는 기계가 아니다. 옵션을 적절히 켜두면 번역 과정에서 인간이 미처 발견하지 못한 자료형의 모순이나 잠재적 버그를 찾아내는 훌륭한 감시자 역할을 한다. 입문 단계에서 가장 자주 마주치는 두 가지 오류 패턴을 통해 컴파일러의 메시지를 해석하는 법을 배워보자.
Fortran의 내장 함수들은 인수로 가질 수 있는 자료형이 문법적으로 엄격하게 정해져 있다. 이를 어기면 컴파일러는 즉시 번역을 중단하고 에러를 발생시킨다.
%%writefile bad_sqrt.f90
program bad_sqrt
implicit none
integer :: n
real :: r
n = 16
r = sqrt(n) ! 오류 발생: sqrt 함수는 정수형(integer) 인수를 받을 수 없다
print *, r
end program bad_sqrt
아래와 같이 컴파일하면, 7번째 줄에서 번역을 멈추고 다음과 같이 명확한 이유를 알려준다.
!gfortran -O2 -std=f2018 -Wall bad_sqrt.f90 -o bad_sqrt
bad_sqrt.f90:7:12:
7 | r = sqrt(n)
| 1
Error: 'x' argument of 'sqrt' intrinsic at (1) must be REAL or COMPLEX
수학의 제곱근 함수인 $\sqrt{x}$는 연속적인 실수를 다루는 연산이므로, 정수형 변수인 n을 바로 대입하면 컴파일러가 문법적으로 기계어로 번역을 거부한다. 이를 해결하려면 형 변환 내장 함수인 real을 써서 r = sqrt(real(n))과 같이 정수를 실수로 수정해야 한다.
위 코드는 컴파일은 무사히 통과했지만 실행 단계에서 엉뚱한 값을 출력하는 버그보다 그나마 다행인 오류이다. 더 심각한 아래 예제를 살펴보자.
이번에는 컴파일은 무사히 통과하지만, 실행했을 때 논리적 오류(런타임 버그)가 발생하는 치명적인 예제이다. 앞서 다룬 부동소수점의 물리적 한계가 실제 조건 비교에서 어떻게 오류를 만들어내는지 살펴보자.
%%writefile bad_eq.f90
program bad_eq
use iso_fortran_env, only: real64
implicit none
real(real64) :: x
x = 0.1_real64 + 0.2_real64
print *, "x =", x
print *, "x == 0.3 :", x == 0.3_real64
print *, "abs diff :", abs(x - 0.3_real64)
end program bad_eq
이번에는 잠재적 위험을 컴파일러가 잡아낼 수 있도록 경고 확장 플래그인 -Wall -Wextra 옵션을 모두 켜고 빌드한다.
!gfortran -O2 -std=f2018 -Wall -Wextra bad_eq.f90 -o bad_eq
!./bad_eq
컴파일 명령에 추가한 -Wall -Wextra 플래그 덕분에, 컴파일러는 빌드 과정에서 다음 경고를 미리 알려준다.
bad_eq.f90:8:32:
8 | print *, "x == 0.3 :", x == 0.3_real64
| 1
Warning: Equality comparison for REAL(8) at (1) [-Wcompare-reals]
x = 0.30000000000000004
x == 0.3 : F
abs diff : 5.5511151231257827E-017
위 경고(warning)는 에러와 달라서 컴파일은 통과하지만, "코드 내 이 비교문은 부동소수점 오차 때문에 나중에 실행할 때 정상적으로 작동하지 않을 확률이 매우 높다"라고 컴파일러가 개발자에게 보낸다. 따라서 실수를 비교할 때는 경고 메시지를 수용하여 앞에서 배운 abs(x - 0.3_real64) < 1.0e-12_real64와 같은 '허용 오차 범위 판정 방식'으로 코드를 개조해야 안전하다.
실행 결과:
x = 0.30000000000000004
x == 0.3 : F
abs diff : 5.5511151231257827E-017
수학적으로 $0.1 + 0.2$는 명백히 $0.3$이 맞지만, 컴퓨터가 출력한 x 내부를 보면 부동소수점 절단오차로 인해 끝자리에 미세한 꼬리가 붙은 0.30000000000000004로 되어 있다. 이 미세한 차이로 인해 x == 0.3_real64라는 조건 비교식은 완벽한 거짓(F)으로 판정된다. 두 수의 절대 차이(abs diff)를 계산해 보면 약 $5.55 \times 10^{-17}$이라는 미세한 오차가 실재함을 알 수 있다.
[!important] 요약
+ - * / ** 다섯개 이다. 나머지 연산자는 없고 mod/modulo 함수를 쓴다.< <= > >= == /=)와 논리 연산자(.not. .and. .or. .eqv. .neqv.)의 결과는 logical이다.** → * / → + - → 관계 → .not. → .and. → .or. → .eqv./.neqv. 순이다. 헷갈리면 괄호로 의도를 적는다.**는 오른쪽 결합이며(2**3**2 = 512), -2**2는 -(2**2) = -4이다./는 몫만 남긴다. 실수 결과가 필요하면 나누기 전에 real(..., real64)로 변환한다.real(→실수), int(버림), nint(반올림), cmplx(→복소수)이다.real*8이 아니라 iso_fortran_env의 real64와 kind로 지정한다.==로 비교하지 않고, 차이의 절댓값을 허용 오차와 비교한다.mod는 피제수의 부호를, modulo는 제수의 부호를 따른다.[!important] 연습 문제
a = 23, b = 4에 대해 a + b, a - b, a * b, a / b, mod(a, b), a ** 2를 각각 출력하는 프로그램을 작성하라.7과 2를 나눈 몫을 정수로, 실수 나눗셈 결과를 배정밀도로 각각 출력하라. 두 결과가 왜 다른지 주석으로 설명하라.2 + 3 * 4 - 5, (2 + 3) * (4 - 5), 2 ** 2 ** 3, -3 ** 2 의 값을 손으로 먼저 예측해 적고, 프로그램으로 확인하라.age와 회원 여부 is_member(logical)를 선언하고, "65세 이상이거나 회원이면 할인 대상"이라는 조건을 하나의 논리식으로 만들어 결과를 출력하라.int(2.9), int(-2.9), nint(2.5), nint(-2.5), floor(-2.1), ceiling(-2.1) 을 출력하고, 각 함수가 어느 방향으로 처리하는지 표로 정리하라.mod(-9, 4)와 modulo(-9, 4)를 출력하고, 두 결과의 부호가 다른 이유를 한 문장으로 설명하라.
섭씨 온도 c를 화씨 f = c * 9.0 / 5.0 + 32.0 로 변환해 출력하라. 정수 나눗셈 함정을 피하도록 모든 상수를 실수로 적을 것.
a, b(배정밀도)가 주어졌을 때 빗변 sqrt(a**2 + b**2) 의 길이를 구하라. a = 3, b = 4로 확인하라.deg(도)를 라디안으로 바꾼 뒤 sin, cos, tan 값을 출력하라. 변환 상수 $\pi/180$ 은 parameter로 선언하라.p, 연이율 r(소수), 기간 t(년)로 복리 원리합계 $p(1+r)^t$ 를 배정밀도로 계산하라. 거듭제곱 연산자 **를 사용할 것.