제 2 부 — 제어 구조와 입출력



5장 · 제어 흐름


Choices, Loops, and the Flow of Control



[!attention] 이 장의 목표


프로그램이 고정된 순서로만 계산된다면 복잡한 현실의 문제를 해결할 수 없다. 실제 계산은 값에 따라 다르게 처리하고, 같은 작업을 여러 번 반복해야 한다. 이번 장에서는 프로그램에 '판단력'을 부여하는 제어 구조를 배운다. 이 장을 마치면 다음 문제를 풀 수 있다.

제어 흐름은 분기(if, select case), 반복(do, do while, do concurrent), 흐름 제어(cycle, exit, stop, error stop)로 나뉜다. 차례대로 살펴보자.


[!important] 5.1 if 구문과 분기

조건이 참(.true.)일 때만 특정 명령을 실행하도록 흐름을 가르는 가장 기본적인 도구가 if 구문이다. Fortran에서는 구조의 복잡성에 따라 두 가지 형태의 if 문을 제공한다.

1. 단일 명령을 위한 '논리 if' (Logical if)

조건이 참일 때 한 줄의 명령문만으로 사용하는 가장 간결한 구조다. 문법 규격은 아래와 같다.

if (logical-expr) statement

아래 예시와 같이 사용된다.

if (temperature < 0.0_dp) is_freezing = .true.
if (abs(x - target) < 1.0e-9_dp) print *, "Target reached!"

2. 여러 흐름을 위한 '블록 if' (Block if)

조건이 참일 때 실행해야 할 명령이 여러 줄이거나, 여러 개의 조건에 따라 실행 구역을 나누어야 할 때는 아래와 같이 블록 구조를 사용해야 한다.

if (logical-expr) then
   block
[else if (logical-expr) then
   block]
[else
   block]
end if

위에서부터 아래로 차례대로 조건을 확인하며, 처음으로 참(.true.)이 되는 단 하나의 블록 구역만 실행한 뒤 전체 if 문을 빠져나간다. 뒤에 아무리 맞는 조건이 또 있더라도 앞에서 이미 조건과 일치되었다면 하단 블록들은 검사하지 않고 건너뛴다. 가독성을 높이기 위해 블록 안쪽의 코드들은 들여쓰기(indentation)를 적용하자.

[예제] 실수의 부호 분류

앞서 학습한 블록 if 구문을 활용하여 실수형 변수의 값이 양수인지, 음수인지, 혹은 0인지 판별하는 프로그램이다. 이 예제는 실전 수치 계산에서 부동소수점 오차의 함정을 우회하는 분기 설계 패턴을 보여준다.

%%writefile sign_check.f90
program sign_check
   use, intrinsic :: iso_fortran_env, only: real64
   implicit none
   real(real64), parameter :: x = -3.5_real64

   if (x > 0.0_real64) then
      print '(a)', "positive"
   else if (x < 0.0_real64) then
      print '(a)', "negative"
   else
      print '(a)', "zero"
   end if
end program sign_check

컴파일·실행:

!gfortran -O2 -std=f2018 -Wall sign_check.f90 -o sign_check
!./sign_check

실행 결과:

negative

수학적으로 어떤 실수는 양수, 음수, $0$이라는 세 가지 상태만 존재한다. 이때 대다수 입문자는 else if (x == 0.0_real64)와 같이 $0$인 조건을 직접 == 연산자로 검사하려고 시도한다. 하지만 부동소수점 데이터를 ==로 비교하는 행위는 절단 오차 문제로 인해 항상 오작동할 위험을 내포하고 있다.

따라서 위 예제처럼 >(양수영역)와 <(음수영역)라는 부등호 경계선으로 두 가지 영역을 먼저 걸러낸 뒤, 그 어디에도 속하지 않는 남은 마지막 하나의 지점(원점)을 else 로 받아내는 설계가 훨씬 안전하다.


[!important] 5.2 select case

하나의 변수나 수식의 결과값을 여러 후보군과 일치시켜 분기할 때는, if ... else if 를 길게 사용하는 것보다 select case 문을 사용하는 것이 가독성 측면에서 유리하고, 컴파일러 최적화에도 뛰어나다.

select case 의 문법 규격은 아래와 같다.

select case (expr)
   case (value-list)
      block
   [case (value-list)
      block]
   [case default
      block]
end select [select_case_name]

case 후보 값의 다양한 범위 지정 방식

value-list에는 아래와 같이 단일 수치뿐만 아니라 범위를 지정하는 콜론(:) 기호를 활용해 다양한 형태로 조건을 형성할 수 있다.

case 문 사용시 주의 사항

[예제] 점수를 등급으로 변환

앞서 공부한 select case 문의 범위 지정 기법(:)을 활용하여, 정수 점수 데이터를 알파벳 학점 등급(a, b, c, d, f)으로 변환하는 프로그램이다. case 문의 직관적 구조를 확인해 보자.

%%writefile grade.f90
program grade
   implicit none
   integer, parameter :: score = 84
   character(len=1) :: letter

   select case (score)
   case (90:)
      letter = "a"
   case (80:89)
      letter = "b"
   case (70:79)
      letter = "c"
   case (60:69)
      letter = "d"
   case default
      letter = "f"
   end select

   print '(a, i0, a, a)', "score ", score, " -> grade ", letter
end program grade

컴파일·실행:

!gfortran -O2 -std=f2018 -Wall grade.f90 -o grade
!./grade

실행 결과:

score 84 -> grade b

[예제]문자열 코드 분기

문자형(character) 변수 역시 대소문자나 알파벳 순서에 따라 동일한 콜론(:) 방식으로 구간 분기가 가능하다. 또한 한 줄의 case 안에 case ("Y", "y", "Yes")와 같이 쉼표로 후보 값을 얼마든지 나열하여 매칭시킬 수 있으므로, 사용자 키보드 입력 제어 등의 로직을 설계할 때 매우 강력한 도구가 된다.

정수 뿐만 아니라 요일 코드나 관측 지점 이름 같은 문자열 데이터를 기반으로도 흐름을 얼마나 깔끔하게 제어할 수 있는지 확인해 보자.

%%writefile weekday.f90
program weekday
   implicit none
   character(len=3), parameter :: code = "wed"

   select case (code)
   case ("mon")
      print '(a)', "monday"
   case ("tue")
      print '(a)', "tuesday"
   case ("wed")
      print '(a)', "wednesday"
   case ("thu", "fri")
      print '(a)', "late week"
   case default
      print '(a)', "weekend"
   end select
end program weekday

컴파일·실행:

!gfortran -O2 -std=f2018 -Wall weekday.f90 -o weekday
!./weekday

실행 결과:

wednesday

[!note] select case문에서 버그 방지 팁

  1. 교집합 영역의 절대 금지: select case 문을 설계할 때 각 case에 지정된 범위나 후보는 단 하나도 겹쳐서는 안 된다. 앞서 보았듯 case (80:89)case (85:95)처럼 공통 분모($85 \sim 89$)가 발생하는 순간, 컴파일러는 코드의 모호성을 지적하며 즉시 빌드 에러를 낸다.

  2. case 내부에는 오직 '상수'만 허용: case (value-list)의 괄호 안에는 리터럴이나 parameter로 고정된 명명 상수만 올 수 있다. 실행 중에 수시로 변하는 일반 '변수'를 이 자리에 넣는 것은 문법적으로 원천 봉쇄되어 있다. 만약 고정된 상수가 아니라 유동적인 변수의 값끼리 크기를 서로 비교해가며 분기해야 하는 상황이라면, select case 문을 포기하고 if ... else if 구조를 선택하는 것이 올바른 해법이다.


[!important] 5.3 do 반복과 do while

1. do

정해진 횟수만큼 명령을 반복하는 계수형(counted) do 반복문은 Fortran에서 가장 자주 사용되는 제어 구조이다. 반복 횟수가 명확하게 예측되는 상황에서 흐름을 안전하고 간결하게 통제할 수 있다.

[name:] do var = start, end [, step]
   block
end do [name]

계수형 do 반복문은 루프에 진입하기 직전, 단 한 번 반복 횟수를 계산하여 고정한다. 이를 수치 컴퓨터 구조학에서는 trip count라고 부른다.

[!warning] [흔한 실수] 계수 do의 반복 변수로 실수(real)를 사용 do x = 0.0, 1.0, 0.1은 현행 표준에서 삭제된 기능이라 컴파일되지 않으며, 설령 허용되더라도 0.1의 부동소수점 오차 때문에 반복 횟수가 어긋날 수 있다. 정수로 세고 x = real(i, real64) / real(n, real64)처럼 값을 계산한다.

[예제] do 반복문의 기초 작동

제시된 규칙이 실제 코드에서 어떻게 안전하게 구동되는지 예제를 통해 확인해 보자.

%%writefile loop_demo.f90
program loop_demo
   implicit none
   integer :: i

   ! 1부터 5까지 2씩 증가하며 반복
   do i = 1, 5, 2
      print *, "현재 값: ", i
   end do
end program loop_demo

컴파일·실행:

!gfortran -O2 -std=f2018 -Wall loop_demo.f90 -o loop_demo
!./loop_demo

실행 결과:

 현재 값:            1
 현재 값:            3
 현재 값:            5

이 루프는 시작값이 $1$, 끝값이 $5$, 증감폭이 $2$로 설정되어 있다. 진입 직전 공식에 대입하면 $\frac{5 - 1 + 2}{2} = 3$이 되므로 총 $3$번의 반복 횟수가 고정된다. 그 결과 변수 i의 값은 $1$, $3$, $5$로 순차적으로 증가하며 블록 내부의 출력 명령을 수행한 뒤 루프를 탈출한다.

[예제] 12의 계승(factorial)

계수형 do 반복문을 활용하여 수학의 계승(Factorial, $n!$)을 계산하는 실전 프로그램을 구현해 보자.

%%writefile factorial.f90
program factorial
   use, intrinsic :: iso_fortran_env, only: int64
   implicit none
   integer, parameter :: n = 12
   integer(int64) :: result
   integer :: k

   result = 1_int64
   do k = 2, n
      result = result * k
   end do
   print '(a, i0, a, i0)', "factorial of ", n, " = ", result
end program factorial

컴파일·실행:

!gfortran -O2 -std=f2018 -Wall factorial.f90 -o factorial
!./factorial

실행 결과:

factorial of 12 = 479001600

2. do while

계수형 do 문과 달리, do while 문은 루프를 도는 매 바퀴마다 조건의 변화를 감시하여 반복 실행 유지 여부를 결정한다.

[name:] do while (logical-expr)
   block
end do [name]

계수형 do 루프보다 유연한 특성 덕분에, do while 문은 수치 시뮬레이션 알고리즘에서 주로 다음 상황에 사용된다.

[예제] 반감기 계산

do while 반복문의 성격을 가장 잘 보여주는 물리적 현상 중 하나인 '반감기(Half-life)' 혹은 '값의 감쇠'를 수치적으로 시뮬레이션하는 예제이다. 초기값 1.0에서 시작하여 매 바퀴마다 값을 절반($0.5$)으로 줄여나가다가, 우리가 설정한 특정 임계값(threshold) 아래로 떨어지는 순간 루프를 안전하게 탈출하는 구조를 확인해 보자.

%%writefile halving.f90
program halving
   use, intrinsic :: iso_fortran_env, only: real64
   implicit none
   real(real64), parameter :: threshold = 1.0e-3_real64
   real(real64) :: value
   integer :: steps

   value = 1.0_real64
   steps = 0
   do while (value > threshold)
      value = value * 0.5_real64
      steps = steps + 1
   end do
   print '(a, i0, a)', "below threshold after ", steps, " halvings"
end program halving

컴파일·실행:

!gfortran -O2 -std=f2018 -Wall halving.f90 -o halving
!./halving

실행 결과:

below threshold after 10 halvings

이 예제의 핵심은은 do while (value > threshold) 문장이다. 종료를 판정하는 경계선을 ==을 사용한 동치 비교가 아니라 > 부등호로 설정한 것이 중요하다. 매 바퀴마다 value 값을 반으로 자르다 보면, 10번째 바퀴에서 $(\frac{1}{2})^{10} = \frac{1}{1024} \approx 0.00097656$이 되면서 임계값인 $0.001$($1.0 \times 10^{-3}$)보다 작아지므로 조건이 거짓(.false.)이 되어 루프를 무사히 탈출하게 된다.

[!warning] [흔한 실수] 부동소수점 동치 종료 만약 코드를 설계할 때 안일하게 do while (value /= threshold)do while (value == 0.0_real64)처럼 실수의 동치 연산 기호를 사용하여 반복을 제어하려고 시도하면 프로그램이 무한 반복(무한 루프)의 늪에 빠질 수 있다.

컴퓨터는 이진 부동소수점의 한계로 인해 소수를 완벽하게 표현하지 못하고 미세한 버림 오차를 품는다. 따라서 연산 과정에서 우리가 목표로 한 수치와 소수점 아래 먼 자리가 $10^{-17}$만큼이라도 어긋나면 컴퓨터는 "두 값은 엄연히 다르다"고 판단하여 루프 탈출이 불가능하다.

실수를 다루는 반복 조건식에서는 절대로 직접적인 일치 여부를 묻지 말고, 본 예제처럼 value > threshold와 같은 부등호 경계선을 그어주거나 abs(a - b) < tol과 같이 '절댓값 차이가 허용 오차 이내인가'를 묻는 방식을 기계적으로 적용해야 안전하다.


[!important] 5.4 cycle·exit와 명명 구문

1. cycle과 exit

반복문 내부에서 특정 조건에 따라 연산을 건너뛰거나, 원하는 답을 찾는 즉시 루프를 강제로 빠져나와야 할 때가 있다. Fortran은 실행 흐름을 유연하게 조정할 수 있는 두 가지 강력한 키워드를 제공한다.

[예제] 특정 배수 건너뛰기(cycle)

%%writefile skip_multiples.f90
program skip_multiples
   implicit none
   integer, parameter :: n = 12
   integer, parameter :: skip_factor = 3
   integer :: i

   do i = 1, n
      if (mod(i, skip_factor) == 0) cycle
      print '(i0)', i
   end do
end program skip_multiples

컴파일·실행:

!gfortran -O2 -std=f2018 -Wall skip_multiples.f90 -o skip_multiples
!./skip_multiples

실행 결과:

1
2
4
5
7
8
10
11

변수 i가 $3, 6, 9, 12$가 되는 순간 mod(i, 3) == 0 조건이 참이 되면서 cycle 명령이 실행된다. cycle이 호출되면 하단의 print 문으로 내려가지 않고, 다음 바퀴($i+1$)로 넘어가기 때문에 실행 결과에서 $3$의 배수들이 잘 소거된 것을 볼 수 있다. 아울러 반복 한계선(n)과 건너뛸 인수(skip_factor)를 parameter 상수로 정의하여 코드 내부에서 매직 넘버를 제거한 설계 방식도 눈여겨보자.

2. 명명(이름) 구문과 중첩 반복

do 루프 안에 또 다른 do 루프가 맞물려 돌아가는 '중첩 반복문' 환경에서 아무런 조치 없이 exitcycle을 호출하면, 컴퓨터는 기본적으로 자신이 속한 가장 가까운 안쪽(inner) 루프 하나에만 작용한다. 따라서, 만약 루프를 계산하다가 특정 조건을 만족했을 때, 안쪽(inner) 루프뿐만 아니라 바깥쪽(outer) 루프까지 한번에 탈출하고 싶다면, 구문에 이름을 붙여 제어하면 된다. 바깥 반복에 이름을 붙이고(명명) exit name 또는 cycle name으로 가리키면, 컴파일러는 지정된 이름의 루프를 추적하여 제어한다.

[예제] 조건을 만족하는 첫 쌍 찾기

$i$와 $j$를 각각 $1$부터 $9$까지 조합하여 곱해가다가, 두 수의 곱이 최초로 임계값($40$)을 돌파하는 순간 중첩 루프 전체를 단 한 번에 빠져나가는 정석적인 예제다.

%%writefile first_pair.f90
program first_pair
   implicit none
   integer, parameter :: n = 9
   integer, parameter :: threshold = 40
   integer :: i, j

   search: do i = 1, n
      do j = 1, n
         if (i * j > threshold) then
            print '(a, i0, a, i0, a, i0)', &
               "first pair: i=", i, ", j=", j, ", product=", i * j
            exit search
         end if
      end do
   end do search
end program first_pair

컴파일·실행:

!gfortran -O2 -std=f2018 -Wall first_pair.f90 -o first_pair
!./first_pair

실행 결과:

first pair: i=5, j=9, product=45

만약 search라는 이름없이 단순하게 exit만 적었다면, 컴퓨터는 가장 가까운 안쪽 j 반복만 탈출한 뒤 바깥쪽 i 반복으로 넘어가 다음 계산($i=6$)을 계속 징검다리처럼 이어갔을 것이다.

하지만 바깥쪽 do 문 시작점에 search:라는 고유 이름을 새겨두고 조건 만족 시 exit search를 발동시켰기 때문에, 이 명령은 안쪽 j 반복만이 아니라 이름이 search인 바깥쪽 i 반복까지 지체 없이 한 번에 모두 벗어난다.

이처럼 구문에 이름을 붙이면 어느 반복을 제어하는지가 분명히 드러나기 때문에, 중첩이 깊어지더라도 알고리즘 설계 의도를 명확하게 전달할 수 있다.


[!important] 5.5 do concurrent

일반적인 do 루프는 반복 변수가 $1, 2, 3 \dots$과 같이 순차적으로 증가하며 실행되지만, do concurrent는 각 반복 단계가 서로 완벽하게 독립적임을 보장하는 현대 Fortran의 강력한 제어 구조이다. 즉, do concurrent는 컴파일러에게 "이 루프는 순서가 중요하지 않으니 마음껏 효율적으로 최적화하라"고 지시하는 것과 같다. 컴파일러가 안심하고 초고속 병렬 최적화를 수행할 수 있다.

do concurrent의 문법 구조는 아래와 같다.

do concurrent (index = lower:upper [, mask-expr])
   block
end do

do concurrent은 다음 특징이 있다.

do concurrent 블록 내부에는 루프 간 독립성을 해칠 수 있는 다음 요소들의 진입이 문법적으로 엄격히 제한된다.

[예제] 독립 반복의 형태

앞서 정립한 do concurrent 구문의 동작 원리와 독립성 개념이 실제 코드에서 어떻게 구현되는지 살펴보자. 아래 예제는 각 루프가 주변의 연산 환경에 구애받지 않고 오직 자신에게 할당된 인덱스 정보만을 가지고 계산을 완수하는 구조이다.

%%writefile concurrent_intro.f90
program concurrent_intro
   implicit none
   integer :: i

   do concurrent (i = 1:5)
      print '(a, i0, a, i0)', "i = ", i, ", i*i = ", i * i
   end do
end program concurrent_intro

컴파일·실행:

!gfortran -O2 -std=f2018 -Wall concurrent_intro.f90 -o concurrent_intro
!./concurrent_intro

실행 결과:

i = 1, i*i = 1
i = 2, i*i = 4
i = 3, i*i = 9
i = 4, i*i = 16
i = 5, i*i = 25

[!warning] [흔한 실수] 누적 계산 만약 do concurrent구문 내에서 연산 가속을 해보겠다고 total = total + ifact = fact * i와 같이, 이전 루프의 연산 결과 위에 현재 루프의 값을 누적하는 종속적인 수식에는 절대로 do concurrent를 사용해서는 안 된다. 루프 순서가 섞이는 순간 누적합이 엉망이 되는 치명적인 연산 오류가 발생한다.

[!note] 거대 격자(grid) 연산에서 빛을 발하는 do concurrent

do concurrent 구문의 진정한 성능은 거대한 격자 공간의 물리량 배열을 요소별로 한 번에 후처리 계산할 때 나타난다.

일반 do 루프는 격자의 1번 격자점, 2번 격자점, 3번 격자점 순서대로 차례대로 계산해야 하므로 앞의 루프가 계산을 끝낼 때까지 뒤의 루프가 기다려야 한다. 반면 do concurrent는 "순서가 상관없다"고 선언하기 때문에, 컴파일러가 격자 공간을 조각조각 쪼개어 여러 CPU 코어에 동시에 계산을 할당한다. 1번 코어는 북태평양 격자, 2번 코어는 아시아 대륙 격자 등으로 나누어서 동시에 연산해도 문제가 없는 구조가 되는 것이다.

기상 수치 시뮬레이션에서는 3차원 격자점마다 온도, 습도, 풍속 같은 물리량 배열을 수백만 개씩 쌓아두고 계산한다. 이때 특정 격자점의 물리량을 계산할 때 다른 격자점의 결과와 상관없이 오직 그 지점의 데이터만 가지고 독립적으로 계산하는 작업(특정 단위 변환 등)이 상당량 존재한다. 이런 후처리 연산에 do concurrent를 적용하면 컴퓨터의 많은 코어(core)들이 격자점들을 나누어 맡아 동시에 계산하기 때문에 극적인 성능 향상을 기대할 수 있다.


[!important] 5.6 stop·error stop

루프를 탈출하는 exit와 달리, 특정 조건이나 심각한 데이터 왜곡이 발생했을 때 프로그램 실행 자체를 끝내버리는 방식에는 두 가지 명령어가 있다.

종료 코드(stop-code)와 운영체제와의 소통

두 구문 뒤에 선택적으로 붙일 수 있는 stop-code정수 상수 또는 문자 상수여야 한다. 이 코드를 지정하는 이유는 프로그램을 실행한 주체(Linux 쉘 스크립트 등)에게 "이 프로그램이 어떤 상태로 끝났는지" 명확한 종료 상태(exit status)를 전달하기 위함이다.

[예제] 조건 만족 시 정상 종료(stop)

이 예제는 반복문을 수행하다가 특정 조건에 도달했을 때 stop 0을 발동하여 전체 프로세스를 안전하게 정상 착륙시키는 구조를 보여준다.

%%writefile normal_stop.f90
program normal_stop
   implicit none
   integer, parameter :: items = 3
   integer :: i

   do i = 1, items
      print '(a, i0)', "item ", i
      if (i == 2) then
         print '(a)', "stopping early"
         stop 0
      end if
   end do
end program normal_stop

컴파일·실행:

!gfortran -O2 -std=f2018 -Wall normal_stop.f90 -o normal_stop
!./normal_stop

실행 결과:

item 1
item 2
stopping early
STOP 0

루프 한계선인 items는 $3$으로 설정되어 있지만, i가 $2$가 되는 루프에서 if (i == 2) 조건문 내부로 진입하게 된다. 화면에 "stopping early"라는 안내 문자를 출력한 뒤 바로 아래 stop 0을 만나는 순간, 컴퓨터는 남은 루프($i=3$)나 하단의 잔여 코드가 있든 없든 상관하지 않고 즉시 제어권을 내려놓으며 프로그램을 종료한다.

[예제] 잘못된 입력에서 오류 종료(error stop)

이 프로그램은 입력 데이터의 타당성을 검사(validation)하다가, 도저히 처리할 수 없는 비정상적인 값(음수)을 발견했을 때 error stop을 통해 프로그램을 치명적 오류 상태로 급제동하는 구조를 보여준다.

%%writefile validate.f90
program validate
   implicit none
   integer, parameter :: n = -5

   if (n < 0) then
      print '(a)', "error: n must be non-negative"
      error stop 1
   end if

   print '(a, i0)', "processing n = ", n
end program validate

컴파일:

!gfortran -O2 -std=f2018 -Wall validate.f90 -o validate

실행:

!./validate
!echo "exit status: $?"

실행 결과:

error: n must be non-negative
ERROR STOP 1 Error termination. Backtrace:
#0 0x780060144960 in ??? 
#1 0x7800601454d9 in ??? 
#2 0x5c52370c6221 in MAIN__ 
#3 0x5c52370c60be in main 
exit status: 0

[예제] 뉴턴 반복의 빠른 수렴

이 예제에서는 수치 해석에서 가장 널리 쓰이는 방정식의 근사해 해법인 뉴턴-랩슨(Newton-Raphson) 기법을 활용하여 $\sqrt{2}$의 값을 정밀하게 추적하는 프로그램을 구현한다. 제곱근을 구하는 뉴턴 공식 $x \leftarrow \frac{1}{2}(x + \frac{a}{x})$는 매 반복 루프마다 유효 자릿수가 대략 배로 늘어나는 빠른 수렴 속도를 자랑한다. 매 루프의 절대 오차를 csv 파일로 기록하고, 파이썬을 이용해 로그 축(log scale) 그래프로 시각화하여 수렴 속도를 눈으로 검증해 보자.

%%writefile newton_sqrt.f90
program newton_sqrt
   use, intrinsic :: iso_fortran_env, only: real64
   implicit none
   integer, parameter :: max_iter = 50
   real(real64), parameter :: target_value = 2.0_real64
   real(real64), parameter :: tol = 1.0e-15_real64
   integer :: iter, u
   real(real64) :: x, x_next, abs_error, reference

   reference = sqrt(target_value)
   x = target_value
   open(newunit=u, file="newton.csv", status="replace", action="write")
   write(u, '(a)') "iter,value,abs_error"
   do iter = 1, max_iter
      x_next = 0.5_real64 * (x + target_value / x)
      abs_error = abs(x_next - reference)
      write(u, '(i0, ",", es20.12, ",", es20.12)') iter, x_next, abs_error
      if (abs(x_next - x) < tol) exit
      x = x_next
   end do
   close(u)
   print '(a, i0, a)', "converged in ", iter, " iterations"
end program newton_sqrt

여기서 reference는 내장 sqrt로 구한 비교용 참값이며, 오차의 크기를 측정하기 위해서만 쓴다. 종료 판정은 abs(x_next - x) < tol처럼 두 근사값의 차이가 충분히 작은지를 기준으로 하고, 최대 반복 횟수 max_iter로 무한 반복을 막는다.

컴파일·실행:

!gfortran -O2 -std=f2018 -Wall newton_sqrt.f90 -o newton_sqrt
!./newton_sqrt

실행 결과:

converged in 6 iterations

Fortran이 계산한 오차의 추이 데이터 newton.csv를 넘겨받아 matplotlib의 세로축 로그 그래프(semilogy) 함수로 시각화한다.

import csv
import matplotlib.pyplot as plt

iters, errors = [], []
with open("newton.csv") as f:
    reader = csv.reader(f)
    next(reader)  # skip header
    for row in reader:
        iters.append(int(row[0]))
        errors.append(float(row[2]))

plt.figure(figsize=(8, 5))
plt.semilogy(iters, errors, marker="o")
plt.xlabel("iteration")
plt.ylabel("absolute error (log scale)")
plt.title("Newton iteration for sqrt(2)")
plt.grid(True, which="both")
plt.savefig("newton.png", dpi=120)
plt.show()

![[Pasted image 20260710190710.png]]

이 결과는 루프를 한 번 돌 때마다 소수점 아래 올바른 유효 자릿수가 대략 2배씩 늘어나는 기하급수적인 성능을 보여준다. 이는 반복 횟수가 수천 번을 넘어도 수렴이 더딘 원주율 라이프니츠 급수 등의 일반 무한급수 연산과 대조를 이루는 특징이다.


[!important] 오류 학습

[예제] 실수 반복 변수의 금기와 정수 스케일링

수치 해석 코드를 짜다 보면 소수점 단위(예: $0.0$부터 $1.0$까지 $0.1$씩 증가)로 격자를 쪼개며 연산하기 위해, do 루프의 반복 변수에 실수형을 쓰고 싶은 강력한 유혹에 빠지기 쉽다. 레거시 포트란 코드에서는 이것이 문법적으로 허용되기도 했다. 하지만 현대 Fortran 표준에서는 이 방식을 엄격하게 금지하고 있다. 왜 이 방식이 금기시되었는지 오류 예제와 정석적인 해결법을 통해 알아보자.

%%writefile bad_loop.f90
program bad_loop
   use, intrinsic :: iso_fortran_env, only: real64
   implicit none
   real(real64) :: x

   do x = 0.0_real64, 1.0_real64, 0.1_real64
      print '(f6.2)', x
   end do
end program bad_loop

컴파일:

!gfortran -std=f2018 -Wall bad_loop.f90 -o bad_loop

컴파일하면 오류가 난다. 컴파일러 메시지:

bad_loop.f90:7:9:

    7 |    do x = 0.0_real64, 1.0_real64, 0.1_real64
      |         1
Error: Deleted feature: Loop variable at (1) must be integer

0.1은 이진 부동소수점으로 표현할 때 무한소수가 되므로 컴퓨터 내부에 미세한 절단 오차를 포함한 채 저장된다.0.1을 루프를 돌며 계속 누적해서 더하다 보면 소수점 아래 먼 자리에서 오차가 쌓이게 되고, 그 결과, 마지막 루프에 도달했을 때 누적된 값이 1.0000000000000002 같은 형태로 비대해져서 컴파일러가 "끝값(1.0)을 초과했다"고 판단하여 루프를 예상보다 한 바퀴 더 일찍 끝내버리는 치명적인 런타임 수치 버그가 발생한다.

이 문제의 해법은 매우 간단하다. 루프 수는 오차가 없는 정수(integer)로 설정하고, 원하는 실수 값은 매 바퀴마다 정수 인덱스로부터 도출하는 것이다.

%%writefile good_loop.f90
program good_loop
   use, intrinsic :: iso_fortran_env, only: real64
   implicit none
   integer, parameter :: steps = 10
   integer :: i
   real(real64) :: x

   do i = 0, steps
      x = real(i, real64) / real(steps, real64)
      print '(f6.2)', x
   end do
end program good_loop

이렇게 하면 반복 횟수가 steps로 정확히 정해지고, x는 매번 누적 오차 없이 i / steps로 다시 계산된다.

컴파일:

!gfortran -std=f2018 -Wall good_loop.f90 -o good_loop
!./good_loop

실행 결과:

  0.00
  0.10
  0.20
  0.30
  0.40
  0.50
  0.60
  0.70
  0.80
  0.90
  1.00

이 구조를 취하면 do i = 0, steps에 의해 총 반복 횟수가 steps + 1 (총 11번)으로 소수점 오차의 간섭 없이 정확하게 고정된다. 그리고 실수 변수 x는 매 바퀴마다 real(i, real64) / real(steps, real64) 구조를 통해 이전 루프의 누적 오차에 영향받지 않고 그 지점의 독립적인 몫으로 매번 새로 계산된다.


[!important] 요약


[!important] 연습 문제

  1. 정수 parameter 하나를 받아 짝수면 "even", 홀수면 "odd"를 출력하는 프로그램을 if로 작성한다.
  2. 1부터 50까지의 합을 계수 do로 구해 출력한다.
  3. select case로 1~7의 정수를 요일 이름(월~일)으로 바꿔 출력한다. 범위를 벗어난 값은 case default"invalid"를 출력한다.
  4. do while을 사용해 어떤 정수 parameter가 1이 될 때까지 2로 나눈 횟수를 세어 출력한다. 단, 2로 나눠떨어지지 않으면 멈춘다.
  5. 1부터 30까지 출력하되, 5의 배수는 cycle로 건너뛴다.
  6. 계수 do2**0부터 2**10까지를 차례로 출력한다.
  7. 1부터 100까지 정수 중 3의 배수의 합과 5의 배수의 합을 각각 구해 출력한다(ifmod 사용).
  8. 중첩 do로 구구단(2단~9단)을 출력하되, 곱이 50을 넘는 항은 출력하지 말고 cycle로 건너뛴다.
  9. 어떤 정수 parameter n에 대해, 2부터 차례로 나눠 보며 약수를 처음 찾으면 exit로 멈춰 소수 여부를 판정하는 프로그램을 작성한다.
  10. 등비수열의 부분합 1 + r + r^2 + ...do while로 누적하되, 새로 더할 항이 1.0e-6_real64보다 작아지면 멈추고 항 수와 합을 출력한다(r = 0.5_real64).