[!attention] 이 장의 목표
- 분기 구성 — if와 select case로 실행 경로를 나눈다.
- 반복 제어 — do와 do while로 반복을 만들고 cycle·exit으로 흐름을 조절한다.
- do concurrent 맛보기 — 병렬 반복의 출발점인 do concurrent를 소개 수준에서 다룬다.
- 안전한 종료 — stop과 error stop으로 프로그램을 의도대로 멈춘다.
프로그램이 고정된 순서로만 계산된다면 복잡한 현실의 문제를 해결할 수 없다. 실제 계산은 값에 따라 다르게 처리하고, 같은 작업을 여러 번 반복해야 한다. 이번 장에서는 프로그램에 '판단력'을 부여하는 제어 구조를 배운다. 이 장을 마치면 다음 문제를 풀 수 있다.
a~f)으로 분류하고, 입력이 음수이면 계산을 멈추는 프로그램을 작성한다.제어 흐름은 분기(if, select case), 반복(do, do while, do concurrent), 흐름 제어(cycle, exit, stop, error stop)로 나뉜다. 차례대로 살펴보자.
[!important] 5.1 if 구문과 분기
조건이 참(.true.)일 때만 특정 명령을 실행하도록 흐름을 가르는 가장 기본적인 도구가 if 구문이다. Fortran에서는 구조의 복잡성에 따라 두 가지 형태의 if 문을 제공한다.
조건이 참일 때 한 줄의 명령문만으로 사용하는 가장 간결한 구조다. 문법 규격은 아래와 같다.
if (logical-expr) statement
logical-expr 자리에는 관계 연산자나 논리 연산자로 이루어진, 결과가 참 혹은 거짓인 수식이 들어간다.
조건이 참이면 우측의 statement(명령문)를 실행하고, 거짓이면 우측 명령을 무시한 채 다음 줄로 진행한다.
아래 예시와 같이 사용된다.
if (temperature < 0.0_dp) is_freezing = .true.
if (abs(x - target) < 1.0e-9_dp) print *, "Target reached!"
조건이 참일 때 실행해야 할 명령이 여러 줄이거나, 여러 개의 조건에 따라 실행 구역을 나누어야 할 때는 아래와 같이 블록 구조를 사용해야 한다.
if (logical-expr) then
block
[else if (logical-expr) then
block]
[else
block]
end if
then과 end if: 블록 if 문의 시작점에는 반드시 then 키워드가 와야 하며, 조건 영역의 마침표 역할을 하는 end if로 닫아주어야 한다. 이 짝을 맞추지 않으면 컴파일러가 빌드를 차단한다.
else if ... then: 앞선 조건이 거짓일 때, 또 다른 대안 조건을 검사하기 위해 얼마든지 연속해서 이어 사용할 수 있다.
else: 제시된 모든 조건이 전부 거짓일 때 마지막으로 튕겨 내려와 실행되는 '최후의 보루' 영역이며, 최대 1개만 올 수 있다. 별도의 조건을 적지 않는다.
위에서부터 아래로 차례대로 조건을 확인하며, 처음으로 참(.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]
expr(조건식)은 비교의 기준이 되는 변수나 연산식이다.
value-list에는 조건식과 매칭할 후보 상수를 적는다. 처음으로 일치하는 case 블록 하나만 실행하고 end select 밖으로 빠져나간다.
case default: 제시된 후보군 중 어느 조건에도 해당되지 않을 때 최종적으로 실행되는 영역이다 (if 문의 else와 같은 역할).
case 후보 값의 다양한 범위 지정 방식value-list에는 아래와 같이 단일 수치뿐만 아니라 범위를 지정하는 콜론(:) 기호를 활용해 다양한 형태로 조건을 형성할 수 있다.
단일 값 지정 (case (3)): 조건식의 결과가 정확히 해당 값 하나와 일치할 때 실행된다.
여러 값 나열 (case (1, 4, 9)): 쉼표로 나열된 후보 중 단 하나라도 일치하면 실행된다.
닫힌 범위 지정 (case (80:89)): $80$ 이상 $89$ 이하의 연속된 범위에 포함될 때 실행된다.
상한이 없는 열린 범위 (case (90:)): $90$ 이상인 모든 값에 해당할 때 실행된다.
하한이 없는 열린 범위 (case (:59)): $59$ 이하인 모든 값에 해당할 때 실행된다.
case 문 사용시 주의 사항실수형(real)은 사용 불가: select case 문은 값의 경계가 정확한 정수형(integer), 문자형(character), 논리형(logical) 스칼라 데이터에서만 사용할 수 있다. 부동소수점 오차를 가지는 실수형 변수는 case 비교 대상에서 배제된다.
반드시 상수만 허용: case (value-list) 자리에 변수를 집어넣을 수 없으며, 컴파일 시점에 이미 값이 확정된 리터럴이나 parameter로 선언된 명명 상수만 올 수 있다.
순서가 있는 자료형의 범위: 콜론(:)을 이용한 범위 지정은 정수형이나 알파벳 순서가 있는 문자형에서만 유효하며, 논리형(.true., .false.) 구조에서는 범위를 지정할 수 없다.
앞서 공부한 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
case (90:) 구조는 상한선 없이 $90$점 이상의 모든 점수를 처리하고, 마지막 case default 블록은 앞선 조건들에 걸러지고 남은 $60$점 미만의 모든 점수를 한번에 모아서 처리한다.문자형(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
복수 후보 나열 처리: case ("thu", "fri") 구문과 같이 쉼표(,)를 사용하여 여러 개의 문자열 매칭 후보를 묶을 수 있다. 목요일이든 금요일이든 해당 블록으로 분기해 "late week"라는 동일한 출력을 수행하게 된다.
나머지 문자열 총괄: case default 블록은 앞서 명시한 월, 화, 수, 목, 금 이외의 모든 문자열 입력(예: "sat", "sun" 등)을 "weekend"로 분류한다.
[!note]
select case문에서 버그 방지 팁
교집합 영역의 절대 금지:
select case문을 설계할 때 각case에 지정된 범위나 후보는 단 하나도 겹쳐서는 안 된다. 앞서 보았듯case (80:89)와case (85:95)처럼 공통 분모($85 \sim 89$)가 발생하는 순간, 컴파일러는 코드의 모호성을 지적하며 즉시 빌드 에러를 낸다.
case내부에는 오직 '상수'만 허용:case (value-list)의 괄호 안에는 리터럴이나parameter로 고정된 명명 상수만 올 수 있다. 실행 중에 수시로 변하는 일반 '변수'를 이 자리에 넣는 것은 문법적으로 원천 봉쇄되어 있다. 만약 고정된 상수가 아니라 유동적인 변수의 값끼리 크기를 서로 비교해가며 분기해야 하는 상황이라면,select case문을 포기하고if ... else if구조를 선택하는 것이 올바른 해법이다.
[!important] 5.3 do 반복과 do while
do 문정해진 횟수만큼 명령을 반복하는 계수형(counted) do 반복문은 Fortran에서 가장 자주 사용되는 제어 구조이다. 반복 횟수가 명확하게 예측되는 상황에서 흐름을 안전하고 간결하게 통제할 수 있다.
[name:] do var = start, end [, step]
block
end do [name]
반복 변수 (변수): 루프(loop)가 돌 때마다 값이 자동으로 변하는 기준으로 반드시 정수형(integer) 변수이어야 한다. 과거 레거시 표준에서는 실수형 변수도 허용했으나 부동소수점 오차로 인해 반복 횟수가 미세하게 틀어지는 버그를 유발하므로 현대 표준에서는 정수형만 사용하도록 엄격히 제한했다.
시작값, 끝값, 증감폭: 반복의 시작점과 한계선, 그리고 한 번 루프를 돌 때마다 건너뛸 크기를 지정한다. 증감폭([, 증감폭])은 선택 사항이며, 이를 생략하면 기본값인 $1$이 자동 적용된다. 증감폭에 음수를 넣어 값이 점차 작아지는 역방향 루프를 구성하는 것도 가능하다.
루프 이름 ([이름:]): 선택 사항이지만, 루프의 시작과 끝에 고유한 이름을 지정해 두면 코드의 가독성이 향상된다. 특히 여러 개의 루프가 겹쳐진 중첩 루프 구조에서 어떤 do 문이 어디서 닫히는지를 명확하게 식별할 수 있어 실무에서 적극적으로 사용된다.
계수형 do 반복문은 루프에 진입하기 직전, 단 한 번 반복 횟수를 계산하여 고정한다. 이를 수치 컴퓨터 구조학에서는 trip count라고 부른다.
block) 내부에서 시작값(start)이나 끝값(end)으로 사용되었던 변수의 값을 바꾸더라도 이미 결정된 반복 횟수에는 영향을 미치지 않는다. 이는 실행 도중에 조건이 뒤틀려 무한 루프에 빠지는 오류를 예방하는 기법이다.[!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$로 순차적으로 증가하며 블록 내부의 출력 명령을 수행한 뒤 루프를 탈출한다.
계수형 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
정수 넘침(overflow) 방어: 계승 연산은 숫자가 조금만 커져도 결과가 기하급수적으로 증가한다. 기본 32비트 정수형은 약 21억까지 담을 수 있으므로 조금만 넘어가도 값이 음수로 뒤집힌다. 따라서 본 예제에서는 대형 연산에 대비해 누적 변수인 result를 integer(int64)(64비트 정수형)로 선언하여 오류를 방어하였다.
표준 종류 지정 사수: 레거시 코드에서 흔히 쓰이던 integer*8을 배제하고, iso_fortran_env 모듈에서 불러온 int64를 사용함으로써 현대 Fortran 표준 규격과 이식성을 동시에 충족시켰다.
do while 문계수형 do 문과 달리, do while 문은 루프를 도는 매 바퀴마다 조건의 변화를 감시하여 반복 실행 유지 여부를 결정한다.
[name:] do while (logical-expr)
block
end do [name]
조건식 (logical-expr): 매 반복 루프가 새로 시작되기 직전, 맨 위에서 평가되는 논리식이다. 이 식의 판정 결과가 오직 참(.true.)일 때만 루프 내부의 block코드가 실행된다.
루프 이름 ([이름:]): 계수형 do 문과 마찬가지로 선택 사항이다. 복잡한 수치 알고리즘에서 루프에 이름을 붙여두면 코드의 가독성이 올라간다.
무한 루프 방지: block 내부에서 logical-expr의 결과값을 변경하는 로직이 반드시 포함되어야 한다. 그렇지 않으면 조건이 영원히 참으로 남아 무한 루프에 빠질 위험이 있다.
계수형 do 루프보다 유연한 특성 덕분에, do while 문은 수치 시뮬레이션 알고리즘에서 주로 다음 상황에 사용된다.
수치 모델의 수렴 판정: 뉴턴-랩슨(Newton-Raphson) 기법이나 비선형 방정식의 반복 해법을 풀 때 활용된다. 연산 오차(residual)가 우리가 설정한 과학적 허용 한계치(예: 1.0e-6) 이하로 뚝 떨어져서 값이 특정한 정답에 '수렴'할 때까지 계산을 무한히 반복시키는 구조에 가장 적합하다.
사용자 입력 및 스트리밍 데이터 대기: 외부 관측 장비나 파일로부터 실시간 데이터 스트리밍을 받아오거나, 사용자가 키보드로 특정 종료 명령(예: ctrl + c 입력)을 내리기 전까지 프로그램이 입력을 대기해야 하는 상시 구동 제어 시스템을 구축할 때 유용하다.
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와 명명 구문
반복문 내부에서 특정 조건에 따라 연산을 건너뛰거나, 원하는 답을 찾는 즉시 루프를 강제로 빠져나와야 할 때가 있다. Fortran은 실행 흐름을 유연하게 조정할 수 있는 두 가지 강력한 키워드를 제공한다.
cycle: 현재 반복 루프의 남은 명령문들을 전부 무시하고, 루프의 맨 위(다음 바퀴)로 즉시 이동한다.
exit: 반복문의 잔여 루프 수와 상관없이, 반복문 자체를 즉시 빠져나간다.
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 상수로 정의하여 코드 내부에서 매직 넘버를 제거한 설계 방식도 눈여겨보자.
do 루프 안에 또 다른 do 루프가 맞물려 돌아가는 '중첩 반복문' 환경에서 아무런 조치 없이 exit나 cycle을 호출하면, 컴퓨터는 기본적으로 자신이 속한 가장 가까운 안쪽(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는 컴파일러에게 "이 루프는 순서가 중요하지 않으니 마음껏 효율적으로 최적화하라"고 지시하는 것과 같다. 컴파일러가 안심하고 초고속 병렬 최적화를 수행할 수 있다.
실행 순서 미보장: 반복 변수가 반드시 순서대로 실행된다고 가정해서는 안 된다. 컴파일러는 하드웨어 환경에 따라 루프의 실행 순서를 임의로 뒤섞거나 조율할 수 있다.
독립성 보장 (의존성 금지): 루프 내부에서 다른 반복 단계의 값을 참조하거나 수정하는 행위는 엄격히 금지된다. 예를 들어, 안쪽 루프(i)에서 직전 루프(i-1)의 계산 결과를 가져와 쓰는 행위는 완전히 차단된다. 이를 어길 경우, 계산 결과에 오류가 발생하는 정의되지 않은 동작(undefined behavior)을 유발한다.
do concurrent의 문법 구조는 아래와 같다.
do concurrent (index = lower:upper [, mask-expr])
block
end do
index: 루프를 제어하는 반복 변수이다. lower:upper: 계산을 수행할 상한과 하한의 정수 범위를 지정한다. mask-expr: 선택 사항으로, 루프을 실행할지 결정하는 일종의 필터링 마침표 역할을 한다. 이 조건이 참(.true.)인 인덱스 단계만 골라내어 루프 블록을 실행된다.do concurrent은 다음 특징이 있다.
고성능 최적화: 순서 의존성이 없는 순수 계산(예: 거대한 격자 배열의 각 요소에 독립적인 물리 공식 적용)을 수행할 때, 컴파일러가 자동 벡터화(vectorization)나 멀티 코어를 활용한 병렬화(parallelization)를 최고 수준으로 적용할 수 있다.
데이터 무결성 사수: 반복 간에 데이터를 주고받거나 공유하는 행위가 원천 차단되므로, 병렬 실행 시 여러 코어가 하나의 메모리를 동시에 건드려 오류를 일으키는 데이터 경합(data race) 위험이 없는 코드 설계가 가능하다.
do concurrent 블록 내부에는 루프 간 독립성을 해칠 수 있는 다음 요소들의 진입이 문법적으로 엄격히 제한된다.
흐름 제어 명령 금지: 루프의 제어권을 강제로 쥐고 흔드는 return, exit, cycle 등의 문장을 내부에 포함할 수 없다.
입출력 및 외부 호출 제한: 화면에 글자를 찍는 print, write 등의 I/O 명령이나, 독립성이 검증되지 않은 외부 프로시저(함수)를 함부로 호출할 수 없다.
데이터 의존성 유발 금지: 당연히 루프 간에 계산 결과가 꼬리를 물고 이어지도록 만드는 모든 형태의 의존성 수식은 작성할 수 없다.
앞서 정립한 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
반복 간 의존성 제로: i가 $3$일 때의 계산인 3 * 3 = 9를 처리하기 위해 i가 $1$이나 $2$였을 때의 계산 결과를 가져와 쓰는 행위가 존재하지 않는다. 즉, 각 반복 단계가 자기 자신에게 주어진 독립 변수 i만 제어하고 다른 루프의 연산 데이터는 배제한다. 오직 이런 반복 간 의존성이 소거된 계산 상태에서 do concurrent 구문을 활용한다.
출력 순서의 무작위성: 위 실행 결과는 루프의 규모가 작아 단일 스레드가 순식간에 처리했기 때문 우연히 $1$부터 $5$까지 정렬되어 출력되었을 뿐이다. 병렬 컴파일 옵션을 켜고 대규모 연산을 수행하면 컴퓨터 하드웨어 스케줄링에 따라 $i=4$가 먼저 출력되고 $i=1$이 나중에 출력되는 등 줄 출력 순서는 전혀 보장되지 않는다는 점을 기억하자.
[!warning] [흔한 실수] 누적 계산 만약
do concurrent구문 내에서 연산 가속을 해보겠다고total = total + i나fact = 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 [stop-code]: 프로그램을 의도된 흐름에 따라 정상 종료한다.
error stop [stop-code]: 예측하지 못한 결함이나 치명적인 버그가 발생했음을 운영체제에 알리며 오류 종료한다.
stop-code)와 운영체제와의 소통두 구문 뒤에 선택적으로 붙일 수 있는 stop-code는 정수 상수 또는 문자 상수여야 한다. 이 코드를 지정하는 이유는 프로그램을 실행한 주체(Linux 쉘 스크립트 등)에게 "이 프로그램이 어떤 상태로 끝났는지" 명확한 종료 상태(exit status)를 전달하기 위함이다.
stop: 코드를 생략하거나 0을 주면 운영체제에 정상 종료 상태(0)를 돌려준다.
error stop: 프로그램이 비정상적으로 끝났음을 선언하는 것이므로, 운영체제에 0이 아닌 오류 상태 코드(흔히 1 이상)를 넘겨준다. 이를 통해 후속 자동화 스크립트가 "이 연산이 성공했으니 다음 단계로 넘어갈지" 혹은 "에러가 났으니 전체 공정을 중단하고 알림을 보낼지"를 정확히 판정할 수 있다.
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
방어적 데이터 검증: 프로그램 초입에서 변수 n의 값이 -5로 선언되어 있다. 제어변수가 양수가 아닌 음수로 들어왔으므로 if (n < 0) 조건문이 참이 된다.
오류 메시지와 시스템 상태 코드 반환: 화면에 명확한 에러 원인을 출력한 뒤 error stop 1을 실행한다. 이때 ERROR STOP 1이라는 종료 신호와 함께 실행을 종료한다. 그 결과 하단에 위치한 실제 메인 연산 구문인 print '(a, i0)', "processing n = ", n 코드는 실행되지 않는다.
이 예제에서는 수치 해석에서 가장 널리 쓰이는 방정식의 근사해 해법인 뉴턴-랩슨(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]]
이중 안전장치가 설계된 루프 구조: 본 예제에서 reference 변수는 Fortran 내장 함수 sqrt가 도출한 계산용 참값이며, 오직 수렴 성능(절대 오차)을 측정하여 외부 파일로 내보내기 위한 용도로만 쓰인다. 참값을 미리 모르는 상황을 가정하여, 종료 판정식은 abs(x_next - x) < tol과 같이 '이전 루프의 근사값과 현재 루프의 근사값 차이가 허용 오차 미만으로 좁혀졌는가'를 검사하는 방식이다. 혹시 모를 수렴 실패에 대비해 계수형 do 문으로 max_iter = 50이라는 최대 한계선을 설정하여 무한 루프에 갇히는 문제를 방어했다.
압도적인 2차 수렴(quadratic convergence) 속도: 계산 결과 단 6 바퀴 만에 배정밀도 실수가 표현할 수 있는 극한의 정밀도 한계선($10^{-15}$)을 넘어 수렴에 성공했다. 파이썬이 그려낸 로그 축 그래프를 보면, 일반적인 선형 수렴보다 훨씬 가파르게 꺾이는 것을 확인할 수 있다.
이 결과는 루프를 한 번 돌 때마다 소수점 아래 올바른 유효 자릿수가 대략 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] 요약
if (cond) statement — 한 문장만 조건 실행.if (cond) then ... [else if] ... [else] ... end if — 위에서부터 처음 참인 가지 하나만 실행.select case (expr) ... case (...) ... case default ... end select — 정수·문자·논리 스칼라를 상수 후보와 비교. 범위(80:89, 90:)·다중값("thu", "fri") 가능, 범위 중복 금지.do var = start, end [, step] — var는 정수, 반복 횟수는 진입 시 고정.do while (cond) — 횟수를 모르는 반복(수렴 판정 등), 종료 조건은 부등호로.do concurrent (i = lo:hi [, mask]) — 반복 간 의존성이 없는 독립 반복, 실행 순서 미보장.cycle [name] — 현재 반복의 남은 본문을 건너뛰고 다음 반복으로.exit [name] — 반복을 즉시 벗어남. 이름을 붙인 구문으로 바깥 반복까지 제어.stop [code] — 정상 종료(상태 0 또는 지정 코드).error stop [code] — 오류 종료(0이 아닌 상태).==로 비교하지 말고 부등호·허용 오차로 판정한다.parameter 이름으로 대체한다.[!important] 연습 문제
parameter 하나를 받아 짝수면 "even", 홀수면 "odd"를 출력하는 프로그램을 if로 작성한다.do로 구해 출력한다.select case로 1~7의 정수를 요일 이름(월~일)으로 바꿔 출력한다. 범위를 벗어난 값은 case default로 "invalid"를 출력한다.do while을 사용해 어떤 정수 parameter가 1이 될 때까지 2로 나눈 횟수를 세어 출력한다. 단, 2로 나눠떨어지지 않으면 멈춘다.cycle로 건너뛴다.do로 2**0부터 2**10까지를 차례로 출력한다.if와 mod 사용).do로 구구단(2단~9단)을 출력하되, 곱이 50을 넘는 항은 출력하지 말고 cycle로 건너뛴다.parameter n에 대해, 2부터 차례로 나눠 보며 약수를 처음 찾으면 exit로 멈춰 소수 여부를 판정하는 프로그램을 작성한다.1 + r + r^2 + ...을 do while로 누적하되, 새로 더할 항이 1.0e-6_real64보다 작아지면 멈추고 항 수와 합을 출력한다(r = 0.5_real64).