제 3 부 — 배열



8장 · 배열 연산


Whole-Array Thinking



[!attention] 이 장의 목표


지금까지는 배열의 값을 다룰 때 여전히 do 루프를 사용하여 각 요소에 하나씩 접근하며 반복 연산을 수행했다. 이번 장에서 학습할 핵심 내용은 루프를 사용하지 않고 배열 전체를 한 번에 계산하는 방법이다.

다음의 두 가지 연산 문제를 살펴보자.

Fortran은 배열을 하나의 독립된 값처럼 다룰 수 있는 문법을 제공한다. 사칙연산과 같은 기본적인 산술 연산자는 물론이고 sin, sqrt 등의 수학 내장 함수, 합계나 최댓값을 구하는 통계 함수, 그리고 행렬 곱셈(matrix multiplication)까지 배열 전체에 곧바로 적용할 수 있다.

이런 접근법은 코드의 길이를 획기적으로 줄여줄 뿐만 아니라, 컴파일러가 내부적으로 벡터화(vectorization) 및 최적화를 수행하기에 매우 유리한 환경을 제공한다. 과학 및 공학 계산 분야에서 Fortran이 여전히 독보적인 강세를 유지하는 이유 중 하나가 바로 이 전체 배열 연산(whole-array operation) 기능이다.


[!important] 8.1 요소별 연산과 전체 배열 연산

배열에 산술 연산자를 적용하면 배열 내의 동일한 위치(index)에 있는 요소끼리 계산한다. 이를 요소별 연산(element-by-element operation)이라 한다. 크기와 차원이 동일한 두 배열 ab가 있을 때, 다음 대입 연산은 배열의 전체 길이에 대한 덧셈을 한 번에 수행한다.

c = a + b      ! 모든 인덱스 i에 대해 c(i) = a(i) + b(i) 수행

덧셈(+)뿐만 아니라 뺄셈(-), 곱셈(*), 나눗셈(/), 거듭제곱(**) 등의 산술 연산자 역시 모두 요소별로 동작한다. 이때 가장 중요한 핵심 규칙은 두 배열의 형상 정합(shape conformance) 이다. 즉, 연산에 참여하는 두 배열은 서로 차원의 수(rank)와 각 차원의 크기(extent)가 정확히 일치해야만 연산이 가능하다. 만약 형상이 서로 다르면 컴파일 오류가 발생한다.

만약 배열과 단일 값인 스칼라(scalar)를 섞어서 연산하는 경우에는, 스칼라 값이 배열의 모든 원소에 동일하게 적용되도록 컴파일러가 내부적으로 배열의 형상만큼 확장하여 연산한다. 이를 스칼라 확장(scalar expansion) 이라 한다.

c = 2.0 * a + 1.0   ! 모든 인덱스 i에 대해 c(i) = 2.0 * a(i) + 1.0 수행

또한 sin, cos, sqrt, exp, abs 등과 같은 포트란의 표준 수학 내장 함수들은 대부분 요소별 함수(elemental function)로 설계되어 있다. 따라서 기존에 스칼라 변수를 인수로 넣던 방식 그대로 배열 변수를 인수로 전달할 수 있다. 예를 들어, sqrt(b)를 호출하면, 배열 b에 포함된 각 요소의 제곱근($\sqrt{x}$)을 계산하여 그 결과값들을 동일한 형상의 배열로 반환한다.

배열의 문법 규격은 아래와 같다.

array = array_expression

Fortran의 배열 연산은 개발자가 명시적인 do 루프를 작성하지 않고도 배열 전체에 대해 직관적인 수학적 연산을 수행하도록 돕는 매우 강력하고 효율적인 기능이다.

[예제] 요소별 연산과 스칼라 확장

예제를 통해 배열 연산 방법을 구체적으로 익혀보자.

%%writefile whole_array.f90
program whole_array
   implicit none
   integer, parameter :: n = 5
   real :: a(n), b(n), c(n)
   integer :: i

   a = [(real(i), i = 1, n)]        ! 1.0 2.0 3.0 4.0 5.0
   b = [(real(i)**2, i = 1, n)]     ! 1.0 4.0 9.0 16.0 25.0

   c = a + b                        ! element-wise addition
   print '(a, *(f7.1))', "a + b   =", c

   c = a * b                        ! element-wise multiplication
   print '(a, *(f7.1))', "a * b   =", c

   c = 2.0 * a + 1.0                ! scalar broadcast over the array
   print '(a, *(f7.1))', "2a + 1  =", c

   c = sqrt(b)                      ! elemental intrinsic applies per element
   print '(a, *(f7.1))', "sqrt(b) =", c
end program whole_array

컴파일·실행:

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

실행 결과:

a + b   =    2.0    6.0   12.0   20.0   30.0
a * b   =    1.0    8.0   27.0   64.0  125.0
2a + 1  =    3.0    5.0    7.0    9.0   11.0
sqrt(b) =    1.0    2.0    3.0    4.0    5.0

[!warning] [흔한 실수] 형상이 다르면 배열 연산 불가 두 배열을 더하는 등의 연산을 수행하려면 형상이 같아야 한다. 길이 5와 4를 더하려 하면 컴파일러가 에러를 출력한다.

fortran real :: a(5), b(4), c(5) c = a + b ! shapes [5] and [4] are not conformable

gfortran 메시지:

Error: Shapes for operands at (1) and (2) are not conformable

정합 오류는 대개 인덱스 실수나 잘못된 크기 선언에서 기인한다. 슬라이싱을 할 때도 좌변과 우변의 크기(길이)를 일치시켜야 한다. 예를 들어, a(2:5) = b(1:4)는 둘 다 길이 4라 정합이다.

[!warning] [흔한 실수] 정수 나눗셈은 배열에서도 정수다 real 배열이 아니라 integer 배열을 나누면 요소마다 정수 나눗셈이 일어난다. 즉, [(i, i=1,4)] / 2[0, 1, 1, 2]가 된다. 실수 결과가 필요하면 피연산자를 실수로 만들어야 한다(real(...) 사용). 4장의 정수 나눗셈 규칙이 배열에서도 똑같이 적용된다.


[!important] 8.2 where 구문과 마스킹

배열 데이터를 처리할 때, 특정 조건을 만족하는 요소에만 선택적으로 연산을 수행해야 하는 경우가 자주 있다. 예를 들어, 격자 데이터 중 음수 값만 0으로 일괄 변환하거나, 특정 임계값(threshold)을 초과하는 이상치(outlier)만 낮추는 등의 작업이 이에 해당한다. 이런 연산은 if 문을 포함한 do 반복문의 조합으로도 구현할 수 있지만, 이 절에서 배우는 where 구문을 사용하여 더욱 직관적이고 효율적인 코딩이 가능하다.

where 구문은 각 요소의 참/거짓 상태를 담은 논리 배열(logical array)을 인수로 받아, 해당 위치의 값이 참(.true.)인 요소에만 대입 연산을 선택적으로 적용한다.

이때 기준이 되는 논리 배열을 마스크(mask)라고 부른다. 마스크는 x < 0.0과 같은 관계식을 배열 전체에 적용하여 얻은 결과물로, 원본 배열과 동일한 차원과 크기(형상)를 가지는 논리형 배열 구조를 가진다.

1. 단일 where

특정 조건을 만족하는(참인) 요소에 대해서만 대입 연산을 단 한 줄로 명시한다.

where (mask) array = expression

[예제] 음수 클리핑

이 예제는 where 문을 사용한 음수 클리핑(negative clipping)은 데이터 배열에서 0보다 작은 값(음수)을 모두 찾아내어 0(또는 사용자가 지정한 특정 값)으로 강제로 바꾸는 기법을 소개한다. 여기서 '클리핑(clipping)'은 특정 범위를 벗어나는 데이터의 끝을 잘라내어 지정한 범위에 맞추는 것을 의미한다.

%%writefile where_basic.f90
program where_basic
   implicit none

   integer, parameter :: n = 8
   real :: x(n), y(n)
   integer :: i

   x = [(-4.0 + real(i), i = 0, n - 1)]   ! -4 -3 -2 -1 0 1 2 3

   y = x
   where (y < 0.0)         ! mask: which elements are negative
      y = 0.0              ! assign only to the masked elements
   end where

   print '(a, *(f6.1))', "x =", x
   print '(a, *(f6.1))', "y =", y         ! negatives clipped to 0

end program where_basic

실행 결과:

x =  -4.0  -3.0  -2.0  -1.0   0.0   1.0   2.0   3.0
y =   0.0   0.0   0.0   0.0   0.0   1.0   2.0   3.0

2. 블럭 where

조건에 따른 대입 연산이 여러 줄이거나, 여러 단계 조건 분기가 필요할 때 구조적 제어와 가독성을 제공하기 위해 사용한다.

where (mask1)
   assignments_1
[elsewhere (mask2)
   assignments_2]
[elsewhere
   assignments_3]
end where

이 구문은 다차원 격자 데이터에서 특정 조건에 부합하는 물리량만 추출할 때 매우 유용하게 활용된다. 주의 사항은 다음과 같다.

[예제] 부호 분류(elsewhere)

%%writefile where_else.f90
program where_else
   implicit none
   integer, parameter :: n = 6
   real :: x(n), sgn(n)

   x = [-2.0, -1.0, 0.0, 1.0, 2.0, 3.0]

   where (x > 0.0)
      sgn = 1.0
   elsewhere (x < 0.0)
      sgn = -1.0
   elsewhere
      sgn = 0.0
   end where

   print '(a, *(f6.1))', "x   =", x
   print '(a, *(f6.1))', "sgn =", sgn
end program where_else

실행 결과:

x   =  -2.0  -1.0   0.0   1.0   2.0   3.0
sgn =  -1.0  -1.0   0.0   1.0   1.0   1.0

복잡하고 다양한 분기 조건을 처리할 때는 where 구문을 활용하는 것이 코드의 가독성을 높이고 개발자의 구현 의도를 명확하게 드러내는 데 훨씬 유리하다.

[!warning] 흔한 실수 where는 if가 아니다 where의 마스크는 배열 전체에 대한 요소별 조건이지, if처럼 한 번 평가되는 스칼라 조건이 아니다. where (n > 0)처럼 스칼라를 넣으면 의도와 다르게 동작하거나 오류가 난다. 분기 전체를 건너뛸지 결정하는 일반 조건문은 if, 요소별 선택 대입은 where로 구분해서 써야함을 주의하자.


[!important] 8.3 배열 내장 함수

배열을 한 번에 요약하거나 변형하는 내장 함수들을 크게 축약 함수, 형상 함수, 선형대수 함수로 구분할 수 있다.

1. 축약 함수(reduction functions)

축약 함수는 배열 데이터를 인수로 받아 가공하여 하나의 단일 값인 스칼라(또는 원본보다 더 낮은 차원 배열)로 변환하여 돌려주는 함수이다. Fortran에서 제공하는 대표적인 축약 함수들의 기능은 다음과 같다.

[예제] 여러가지 축약 함수들

아래 예제로 축약 함수들을 다루어 보자.

%%writefile reductions.f90
program reductions
   implicit none
   integer, parameter :: n = 6
   real :: a(n)

   a = [3.0, 1.0, 4.0, 1.0, 5.0, 9.0]

   print '(a, f6.1)', "sum(a)      = ", sum(a)
   print '(a, f8.1)', "product(a)  = ", product(a)
   print '(a, f6.1)', "maxval(a)   = ", maxval(a)
   print '(a, f6.1)', "minval(a)   = ", minval(a)
   print '(a, i0)',   "maxloc(a)   = ", maxloc(a, dim=1)
   print '(a, i0)',   "count(a>3)  = ", count(a > 3.0)
   print '(a, l1)',   "any(a>8)    = ", any(a > 8.0)
   print '(a, l1)',   "all(a>0)    = ", all(a > 0.0)
end program reductions

실행 결과:

sum(a)      =   23.0
product(a)  =    540.0
maxval(a)   =    9.0
minval(a)   =    1.0
maxloc(a)   = 6
count(a>3)  = 3
any(a>8)    = T
all(a>0)    = T

2. 형상 함수

reshape(source, shape) 함수는 1차원 배열 데이터를 사용자가 원하는 차원 형상으로 재배치하는 역할을 한다. 또한, sum과 같은 축약 함수를 사용할 때 dim 인수를 추가로 지정하면, 배열 전체를 하나의 스칼라로 만드는 대신 특정 차원만 선택적으로 줄여나갈 수 있다.

[예제] reshape와 차원 지정 축약

%%writefile reshape_dim.f90
program reshape_dim
   implicit none
   integer :: m(2, 3)
   integer :: j

   ! reshape fills column by column (column-major order)
   m = reshape([1, 2, 3, 4, 5, 6], [2, 3])

   print '(a)', "matrix m (filled column-major):"
   do j = 1, 2
      print '(3i4)', m(j, :)        ! print row j
   end do

   print '(a, i0)',  "sum(m)      = ", sum(m)
   print '(a, 3i4)', "sum dim=1   = ", sum(m, dim=1)   ! collapse rows
   print '(a, 2i4)', "sum dim=2   = ", sum(m, dim=2)   ! collapse cols
end program reshape_dim

실행 결과:

matrix m (filled column-major):
   1   3   5
   2   4   6
sum(m)      = 21
sum dim=1   =    3   7  11
sum dim=2   =    9  12

[!warning] [흔한 실수] reshape는 행이 아니라 열부터 채운다. 출력된 형식이 일반적인 표 형식 [1,2,3,4,5,6]으로 정령되어 보인다고 행 우선([[1,2,3],[4,5,6]])이 아니다. Fortran은 열 우선이라 [[1,3,5],[2,4,6]]이 된다. 행 우선처럼 할당하려면 transpose하거나, reshapeorder 인수를 사용할 수 있다.

3. 선형대수 함수

Fortran은 별도의 외부 라이브러리 없이도 기본적인 선형대수 연산을 수행할 수 있는 내장 함수를 제공한다. 아래는 몇 가지 예시 함수수이다.

[예제] matmul, dot_product, transpose

%%writefile matmul_demo.f90
program matmul_demo
   implicit none
   real :: a(2, 3), b(3, 2), c(2, 2), at(3, 2)
   real :: u(3), v(3)
   integer :: j

   a = reshape([1.0, 2.0, 3.0, 4.0, 5.0, 6.0], [2, 3])
   b = reshape([1.0, 0.0, 1.0, 0.0, 1.0, 1.0], [3, 2])

   c = matmul(a, b)                 ! (2x3) times (3x2) gives (2x2)
   print '(a)', "matmul(a, b):"
   do j = 1, 2
      print '(2f7.1)', c(j, :)
   end do

   u = [1.0, 2.0, 3.0]
   v = [4.0, 5.0, 6.0]
   print '(a, f6.1)', "dot_product(u, v) = ", dot_product(u, v)

   at = transpose(a)                ! shape becomes (3x2)
   print '(a)', "transpose(a):"
   do j = 1, 3
      print '(2f7.1)', at(j, :)
   end do
end program matmul_demo

실행 결과:

matmul(a, b):
    6.0    8.0
    8.0   10.0
dot_product(u, v) =   32.0
transpose(a):
    1.0    2.0
    3.0    4.0
    5.0    6.0

[!warning] [흔한 실수] a * b는 행렬 곱이 아니다 정사각 행렬이라 형상이 맞더라도 a * b요소별 곱이지 행렬 곱이 아니다.

fortran real :: a(2,2), b(2,2) a = reshape([1.0, 2.0, 3.0, 4.0], [2, 2]) b = reshape([5.0, 6.0, 7.0, 8.0], [2, 2]) print '(a, 4f7.1)', "a * b = ", a * b ! 5 12 21 32 print '(a, 4f7.1)', "matmul(a,b) = ", matmul(a, b) ! 23 34 31 46

두 결과는 전혀 다르다. 행렬 곱이 필요하면 반드시 matmul을 쓴다.

[예제] 행렬 곱 결과를 히트맵으로

matmul 함수를 통해 생성된 행렬을 격자 형태의 물리량 데이터로 간주하면 시각적으로 구조를 훨씬 빠르게 이해할 수 있다.

열벡터 $u$($\sin$ 함수)와 행벡터 $v$($\cos$ 함수)의 외적(outer product, $(n \times 1) \cdot (1 \times n) = (n \times n)$)을 수행하여 생성된 행렬 데이터를 csv로 내보내고 히트맵(heatmap)으로 시각화해 보자. 벡터 외적의 각 원소는 $m(i, j) = u(i) \times v(j)$로 계산되므로, 두 삼각함수의 곱이 만드는 독특한 물결무늬가 격자 상에 나타나게 된다.

%%writefile heatmap_data.f90
program heatmap_data
   implicit none
   integer, parameter :: n = 40
   real, parameter :: pi = 3.14159265
   real :: u(n, 1), v(1, n), m(n, n)
   integer :: i, j, ux

   ! build a column vector u and a row vector v
   do i = 1, n
      u(i, 1) = sin(2.0 * pi * real(i - 1) / real(n - 1))
   end do
   do j = 1, n
      v(1, j) = cos(2.0 * pi * real(j - 1) / real(n - 1))
   end do

   ! outer product via matmul: (n x 1) times (1 x n) gives (n x n)
   m = matmul(u, v)

   open(newunit=ux, file="heatmap.csv", status="replace", action="write")
   do i = 1, n
      write(ux, '(*(f9.5, :, ","))') m(i, :)   ! one matrix row per line
   end do
   close(ux)

   print '(a, i0, a, i0)', "heatmap.csv written: ", n, " x ", n
end program heatmap_data

컴파일·실행:

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

실행 결과 생성된 heatmap.csv 파일을 읽어 들여 파이썬 환경에서 격자 그래프로 표출하는 시각화 코드는 다음과 같다.

import numpy as np
import matplotlib.pyplot as plt

# loadtxt reads the whole matrix; data[i, j] == m(i+1, j+1) from Fortran
m = np.loadtxt("heatmap.csv", delimiter=",")

plt.figure(figsize=(8, 6))
plt.imshow(m, origin="lower", cmap="coolwarm", aspect="auto")
plt.colorbar(label="value")
plt.title("outer product u v^T computed with matmul")
plt.xlabel("column index j")
plt.ylabel("row index i")
plt.tight_layout()
plt.savefig("heatmap.png", dpi=120)
plt.show()

![[Pasted image 20260709171501.png]]

시각화된 히트맵을 살펴보면, 양수 영역은 붉은색으로 표현되고 음수 영역은 푸른색으로 대조되어 나타난다. $\sin$ 함수와 $\cos$ 함수의 주기적인 상호작용이 행렬 곱으로 결합되면서 네 개의 마루(peak)와 골(trough)이 바둑판 형태로 대칭 배열된 규칙적인 무늬를 형성한다.

이처럼 대규모 수치 연산 결과물을 행렬의 순수한 숫자 텍스트로만 대할 때보다, 2차원 공간 상의 형상으로 시각화할 때 데이터의 물리적 구조와 경향성을 명확하게 파악할 수 있다.


[!important] 오류 학습

[예제] 행렬 곱의 안쪽 차원 불일치 오류

행렬 곱셈(matmul)이 성립하려면 앞 행렬의 열 개수와 뒤 행렬의 행 개수가 반드시 일치해야 한다. 겉보기에 행렬의 크기나 형상이 비슷해 보여 무심코 연산자로 결합하면 컴파일 단계에서 오류가 발생한다.

%%writefile matmul_dim.f90
program matmul_dim
   implicit none
   real :: a(2, 3), b(2, 2), c(2, 2)

   a = 1.0
   b = 1.0
   c = matmul(a, b)   ! inner dims 3 and 2 do not match
   print *, c
end program matmul_dim

컴파일·실행:

!gfortran -O2 -std=f2018 -Wall matmul_dim.f90 -o matmul_dim
Error: Different shape on dimension 2 for argument 'matrix_a' and
dimension 1 for argument 'matrix_b' at (1) for intrinsic matmul

[예제] where 구문에 스칼라 조건을 지정한 오류

where 구문의 괄호 안에는 반드시 연산 대상 배열과 형상이 일치하는 논리 배열(마스크)이 위치해야 한다. 일반 if 문처럼 단일 논리값을 반환하는 스칼라 조건을 지정하면 컴파일 오류가 발생한다.

%%writefile where_scalar.f90
program where_scalar
   implicit none
   integer :: n
   real :: y(5)

   n = 3
   y = 1.0
   where (n > 0)      ! mask must be a logical ARRAY, not a scalar
      y = 0.0
   end where
   print *, y
end program where_scalar

컴파일·실행:

!gfortran -O2 -std=f2018 -Wall where_scalar.f90 -o where_scalar
!./where_scalar
Error: WHERE/ELSEWHERE clause at (1) requires a LOGICAL array

[!important] 요약


[!important] 연습 문제

  1. 크기 5인 실수 배열 a, b를 생성자로 채우고, a+b·a-b·a*b·a/b를 전체 배열 연산으로 계산해 각각 한 줄씩 출력하라.
  2. 섭씨 온도 배열 [-10.0, 0.0, 15.0, 25.0, 37.0]을 화씨로 변환하는 식 f = c * 9.0 / 5.0 + 32.0을 전체 배열 연산 한 줄로 적용해 출력하라. 정수 나눗셈이 섞이지 않도록 실수 리터럴을 쓴 이유를 한 줄로 설명하라.
  3. 크기 8인 정수 배열을 만들고 sum, maxval, minval, count(a > 0)를 구해 출력하라.
  4. 크기 10인 실수 배열에서 where로 음수 요소만 절댓값으로 바꿔라(abs 사용 금지, where만으로).
  5. 정수 12개를 reshape[3, 4] 배열에 담아 행 단위로 출력하고, 열 우선으로 채워졌음을 출력으로 확인하라.
  6. 크기 4인 두 벡터의 내적을 dot_product로 구하고, do 루프로 직접 누적한 값과 같은지 비교 출력하라.
  7. 크기 n인 실수 배열을 표준화(z-점수)하라. 평균은 sum(a)/n, 표준편차는 편차 제곱합으로 구한다. 결과 배열의 평균이 0에 가깝고 표준편차가 1에 가까움을 출력으로 확인하라.
  8. 2×3 행렬과 3×2 행렬을 matmul로 곱하고, 같은 곱을 삼중 do 루프로 직접 계산해 두 결과가 일치하는지 maxval(abs(...))로 검증하라.
  9. 점수 배열을 where/elsewhere로 구간 분류하라(예: 90 이상 3, 70 이상 2, 그 외 1). 분류 코드 배열을 출력하라.
  10. 4×5 측정값 행렬에서 sum(m, dim=1)으로 열별 합, sum(m, dim=2)로 행별 합을 구해 출력하고, 두 합의 총계가 sum(m)과 같은지 확인하라.