제 3 부 — 배열



7장 · 배열 기초


Rank, Shape, and Bounds



[!attention] 이 장의 목표


지금까지는 단일 데이터를 처리하기 위해 값 하나 당 변수 하나를 독립적으로 할당했다. 그러나 실제 과학 계산에서는 동일한 물리적 특성을 지닌 연속 데이터를 수십 개에서 수백만 개씩 동시에 다루어야 한다. 아래는 대표적인 예이다.

만약 이 데이터들을 단일 변수로 처리한다면 t1, t2, t3, $\dots$, t1000과 같이 수백 또는 수천 개의 변수 이름을 코드에 일일이 선언해야 하므로 구현 자체가 불가능하다. 배열(array)은 이런 문제를 해결하기 위해 고안된 자료 구조다. 동일한 자료형을 가진 수많은 물리량을 하나의 대표 변수명과 인덱스(index), 즉, 첨자(subscript)를 결합하여 컴퓨터 메모리에 연속적으로 배치한다.


주요 프로그래밍 언어별 배열(Array) 특징 비교

배열은 다량의 데이터를 하나의 이름으로 묶어 처리하는 가장 기본적인 자료구조이다. 하지만 언어의 설계 철학과 주된 사용 목적에 따라 인덱스의 시작점, 메모리 저장 방식, 연산 매커니즘에서 명확한 차이를 보인다. 아래 표는 몇 가지 주요 언어의 배열 특성을 비교한 것이다.

언어 인덱스 시작점 메모리 저장 순서 배열의 주요 특징 및 연산 방식
Fortran 1 열 우선 (column-major): 메모리에 저장할 때 첫 번째 인덱스(행 번호)가 가장 먼저 바뀐다. 즉, 같은 열의 값들이 연속적으로 배치되어 있다. 배열 전체에 수학 공식을 직접 적용하는 벡터화 연산(whole-array operation)을 기본 지원하므로 대규모 행렬 계산에서 독보적인 고속 연산 성능을 발휘한다.
C / C++ 0 행 우선 (row-major): 메모리에 저장할 때 마지막 인덱스(열 번호)가 연속적으로 변한다. 즉, 같은 행의 값들이 연속적으로 배치되어 있다. 배열을 연속된 단일 메모리 주소 블록(포인터)으로 취급한다. 기본적으로 배열 전체 일괄 연산을 지원하지 않기 때문에 반드시 반복문을 사용해 원소별로 접근해야 한다.
Java 0 다차원 배열은 배열 안에 또 다른 배열을 담는 구조로 행 우선 방식으로 처리된다. 모든 배열이 독립된 객체로 취급된다. 프로그램 실행 중에 배열 허용 범위를 넘어서면 자동으로 시스템 오류를 방지하므로 안전성이 높다.
Python 0 기본 리스트는 동적 객체 배열이며, NumPy 배열은 행 우선을 기본으로 하고 열 우선 변환도 지원한다. 대규모 수치 해석을 할 때는 NumPy 라이브러리를 결합하여 Fortran과 유사한 방식의 고속 벡터화 연산을 수행할 수 있다.
R 1 Fortran의 전통을 이어받아 메모리에 열 우선 방식으로 데이터를 배열한다. 통계 분석과 데이터 과학에 최적화된 언어이다. 벡터, 행렬(matrix), 다차원 배열이 핵심 데이터 타입이며, 루프 선언 없이 배열 전체에 대량의 통계 수식을 곧바로 적용하는 특징을 지닌다.

[!important] 7.1 배열 선언: 계수·형상·경계

배열을 이해하기 위한 다음의 세 가지 용어들을 먼저 알아두자.

아래 예시를 보자. 한 차원의 범위(extent)는 상한 − 하한 + 1이고, 배열 전체의 크기(size)는 모든 차원 범위를 곱한 것이다. Fortran 배열의 하한은 기본적으로 1이고, 필요에 따라 하한을 직접 지정할 수 있다.

scores(5)      ! rank 1, shape [5],    bounds 1:5,    size 5
grid(3, 4)     ! rank 2, shape [3, 4], bounds 1:3,1:4, size 12
offset(-2:2)   ! rank 1, shape [5],    bounds -2:2,    size 5

배열은 아래 두 가지 방식으로 선언할 수 있는데, 모두 기능적으로 동일하게 작동한다.

배열 조회 내장 함수

프로그램 실행 도중에 선언된 배열의 정보를 확인하려면, 아래와 같은 내장 함수들을 사용하여 배열이 정확히 맞는는 코딩을 할 수 있다.

함수 설명
size(a) 배열 a의 전체 요소 개수를 반환한다.
size(a, dim) dim번째 차원의 크기(요소 수)를 반환한다.
shape(a) 배열 a의 형상을 담은 1차원 배열을 반환한다.
lbound(a, dim) dim번째 차원의 하한 인덱스(lower bound)를 반환한다.
ubound(a, dim) dim번째 차원의 상한 인덱스(upper bound)를 반환한다.

이런 배열 선언 방식과 조회 함수들은 정적 배열(dynamic array)과 동적 배열(static array)을 다룰 때 코드의 안전성과 유연성을 높여준다. 특히 배열의 경계를 정확히 파악하여 인덱스 범위를 벗어나는 오류를 방지하는 데 필수적으로 사용된다.

[예제] 배열의 계수, 형상 그리고 경계 확인

%%writefile array_shapes.f90
program array_shapes
   implicit none
   integer :: scores(5)        ! rank 1, shape [5], bounds 1:5
   integer :: grid(3, 4)       ! rank 2, shape [3, 4]
   integer :: offset(-2:2)     ! explicit bounds: -2 to 2
   integer :: i

   do i = 1, 5
      scores(i) = i * 10
   end do

   do i = -2, 2
      offset(i) = i * i
   end do

   print '(a, i0)', "size(scores) = ", size(scores)
   print '(a, i0)', "size(grid)   = ", size(grid)
   print '(a, i0, a, i0)', "offset bounds: ", &
         lbound(offset, 1), " .. ", ubound(offset, 1)
   print '(a, i0)', "offset(-2) = ", offset(-2)
   print '(a, i0)', "offset( 2) = ", offset(2)
end program array_shapes

컴파일·실행:

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

실행 결과:

size(scores) = 5
size(grid)   = 12
offset bounds: -2 .. 2
offset(-2) = 4
offset( 2) = 4

[!warning] [흔한 실수] C나 파이썬 코딩 습관으로 scores(0)에 첫 값을 넣으면 안 된다. integer :: scores(5)의 유효 첨자는 1부터 5까지다. scores(0)은 하한을 벗어난 접근이다. 이런 경우, 컴파일은 통과하지만 실행 과정에서 의도치 않은 메모리의 값을 불러온다. 첫 요소는 항상 scores(1)이다.

[!note] 1차원 배열의 물리적 메모리 구조

프로그램에서 선언한 배열 내부에 할당된 값들은 컴퓨터 메모리(RAM) 상에서 연속된 공간에 한 줄로(선형으로) 배치된다. 예를 들어 integer :: a(5)라는 배열을 선언하고 [10, 20, 30, 40, 50]이라는 값을 대입하면, 메모리 공간에는 아래와 같이 다섯 개의 정수 데이터가 순서대로 나란히 저장된다.

index a(1) a(2) a(3) a(4) a(5) +---------+---------+---------+---------+---------+ value | 10 | 20 | 30 | 40 | 50 | +---------+---------+---------+---------+---------+ offset +0 +4 +8 +12 +16 (bytes) ^ base address (a(1)이 위치한 시작 주소)

Fortran에서 배열의 하한 인덱스를 변경하더라도 실제 메모리의 물리적 배치와 데이터의 연속성은 변하지 않는다. 각 메모리 칸(방)을 가리키는 인덱스의 주소 매핑만 달라질 뿐이다.


[!important] 7.2 배열 생성자

do 반복문을 사용하여 값을 하나씩 일일이 메모리에 입력하는 대신, 배열 생성자(array constructor)를 활용하면 여러 개의 값을 한 번에 생성하여 배열에 대입할 수 있다. 배열 생성자는 대괄호 [ ... ]로 표기한다. 레거시 FORTRAN에서 사용하던 (/ ... /) 표기도 같은 의미를 지니지만, 현대적인 방법으로 대괄호를 주로 사용한다.

배열 생성자의 효율성은 묵시적 do(implied-do) 루프와 결합할 때 드러난다. [(식, i = 1, n)] 형태로 작성하면 제어 변수 i가 1부터 n까지 증가하며 식을 평가하고, 그 결과값들을 배열에 차례대로 담아낸다.

대괄호 [ ]를 사용하여 배열의 요소들을 직접 나열하거나, 묵시적 do 반복문을 활용하여 생성할 수 있다.

사용 시 주의할 점은 다음과 같다.

아래는 배열 생성자를 사용하지 않고 일반 do 를 사용한 예시이다.

    integer :: i 

    ! 배열 선언 (선언과 동시에 초기화할 수 없으므로 크기만 지정)
    integer :: arr1(3)     
    integer :: arr2(5) 
    integer :: arr3(3)

    ! 직접 나열 방식의 do 루프 
    do i = 1, 3
        arr1(i) = i
    end do

    ! 1부터 5까지 생성하는 do 루프
    do i = 1, 5
        arr2(i) = i
    end do

    ! 2씩 증가하는 배열을 생성하는 do 루프
    do i = 1, 3
        arr3(i) = i * 2
    end do

배열 생성자를 사용하면 특히 동적 배열을 초기화하거나, 특정 규칙을 가진 배열 데이터를 한 번에 생성할 때 코드를 간결하게 만들어 준다. 묵시적 do를 사용하면 복잡한 초기화 로직을 루프 없이 단 한 줄로 처리할 수 있음을 일반 do를 사용한 위 코드와 비교해 보라.

    ! 직접 나열
    integer :: arr1(3) = [1, 2, 3]

    ! 묵시적 do를 사용한 1부터 5까지 생성
    integer :: arr2(5) = [(i, i = 1, 5)]

    ! 2씩 증가하는 배열 생성
    integer :: arr3(3) = [(i*2, i = 1, 3)]

[!note] Fortran 의 묵시적 do 와 명시적 do 구조

1. 일반 do 루프 (명시적 do)

블록 형태로 작성하여 제어 변수의 시작 값, 끝 값, (선택적인) 증감 값을 지정하고 블록 내부의 문장들을 반복 실행하는 가장 기본적인 구조이다. 반복할 코드가 여러 줄이거나 구조가 복잡할 때 사용하며, 프로그램의 실행 흐름을 직접 제어한다.

Fortran do i = 1, 10 print *, i end do

2. 무한 루프와 조건부 종료 (do - exit)

반복 횟수를 미리 지정하지 않고, 특정 조건이 만족될 때까지 계속해서 반복하는 구조이다. exit 문을 함께 사용하여 루프를 빠져나간다. 파일의 끝(End Of File, EOF)을 만나거나 특정 입력값이 들어올 때까지 반복해야 하는 데이터 처리 작업에 주로 활용된다.

Fortran do read(*, *) data if (data == -999) exit ! 특정 조건에서 루프 탈출 sum = sum + data end do

3. do while 루프

조건식이 '참'인 동안에만 블록 내부의 코드를 반복해서 실행하는 구조이다. 수치해석에서 오차 한계(tolerance) 내로 값이 수렴할 때까지 계산을 반복하는 반복법(iterative method) 알고리즘에 자주 사용된다.

fortran do while (error > 0.001) ! 값 업데이트 및 계산 수행 error = abs(new_val - old_val) end do
4. concurrent do 루프 (현대 Fortran 표준)

반복 주기 간에 서로 의존성이 없어 병렬 처리가 가능함을 컴파일러에게 명시적으로 알려주는 구조이다. 컴파일러가 코드를 최적화하거나 자가 병렬화(auto-parallelization)를 수행하여 대규모 격자 데이터나 행렬 연산 속도를 높이는 데 유리하다.

fortran do concurrent (i = 1:n, j = 1:m) array(i, j) = target_matrix(j, i) end do

[예제] 배열을 채우는 세 가지 방식

직접 값으로 채우는 방법, 반복문처럼 암시적-do를 쓰는 방법, 그리고 실수형 변환을 포함한 암시적-do 세 가지를 보여주는 예제이다.

%%writefile constructors.f90
program constructors
   use iso_fortran_env, only: real64
   implicit none
   integer :: primes(5)
   integer :: squares(6)
   real(real64) :: ramp(10)
   integer :: i

   ! list of explicit values
   primes = [2, 3, 5, 7, 11]

   ! implied-do inside a constructor: i * i for i = 1..6
   squares = [(i * i, i = 1, 6)]

   ! real values built with an implied-do
   ramp = [(real(i, real64) * 0.5_real64, i = 1, 10)]

   print '(a, *(i0, 1x))', "primes:  ", primes
   print '(a, *(i0, 1x))', "squares: ", squares
   print '(a, *(f4.1, 1x))', "ramp:    ", ramp
end program constructors

컴파일·실행:

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

실행 결과:

primes:  2 3 5 7 11
squares: 1 4 9 16 25 36
ramp:    0.5  1.0  1.5  2.0  2.5  3.0  3.5  4.0  4.5  5.0

[예시] 중첩 묵시적 do 루프를 활용한 배열 생성

묵시적 do 루프는 다중으로 중첩하여 사용할 수 있다. 이 경우 가장 안쪽에 위치한 제어 변수가 먼저 변화하며 순차적으로 연산이 수행된다. 또한 중첩 루프 구조를 거치더라도 최종 결과물은 다차원 형태가 아니라, 1차원 배열로 평탄화(flattening)되어 차례대로 메모리에 펼쳐진다는 특징이 있다.

%%writefile nested_impdo.f90
program nested_impdo
   implicit none
   integer :: v(6)
   integer :: i, j

   ! nested implied-do: inner i varies fastest (column-major order)
   v = [((i + j, i = 1, 3), j = 1, 2)]

   print '(a, *(i0, 1x))', "v = ", v
end program nested_impdo

컴파일·실행:

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

실행 결과:

v = 2 3 4 3 4 5

이처럼 중첩 구조 내에서 첫 번째에 위치한 안쪽 첨자(i)가 바깥쪽 첨자(j)보다 더 빨리 변화하며 데이터를 나열하는 것은, Fortran의 열 우선(column-major order) 저장 규칙과 일치한다.

[!warning] [흔한 실수] 배열 생성시 정수, 실수 섞기 Fortran의 기본 배열은 태생적으로 동일한 데이터 타입만 가질 수 있도록 설계되었기 때문에, 하나의 배열 안에 정수와 실수를 있는 그대로 섞어 넣을 수는 없다.

만약 a = [1, 2, 3.0]처럼 정수와 실수를 섞으면 안 된다. gfortran은 다음과 같이 거절한다.

Error: Element in INTEGER(4) array constructor at (1) is REAL(4)

정수 배열을 원하면 [1, 2, 3], 실수 배열을 원하면 [1.0_real64, 2.0_real64, 3.0_real64]처럼 종류를 통일해야 한다.

하지만, 굳이 두 종류를 섞어서 배열을 할당할 경우가 있다면, 아래와 같은 기법들을 사용할 수 있다.

1. 형변환 함수를 이용한 강제 통일 (type conversion) 수치 계산을 위해 정수와 실수를 하나의 배열에 담아야 한다면, 내장 함수인 real()을 사용하여 정수를 실수로 명시적으로 변환한 뒤 배열에 할당해야 한다.

Fortran real :: a(3) ! 정수 1과 2를 실수형으로 강제 변환하여 데이터 타입을 일치시킨다. a = [real(1), real(2), 3.0]

2. 파생 타입(derived type)을 이용한 데이터 그룹화 만약 수치 계산용 행렬이 아니라, 서로 다른 속성의 데이터(예: 정수형 번호와 실수형 측정값)를 하나의 묶음으로 관리하고 싶다면 파생 타입을 정의하여 사용한다.

```Fortran type :: record integer :: id real :: value end type record

type(record) :: item item = record(1, 3.0) ! 정수 1과 실수 3.0을 하나의 레코드로 묶어서 저장한다. ```


[!important] 7.3 배열 슬라이스와 인덱싱

배열의 일부분만 추출하는 것을 Fortran 국제 표준에서는 단면(section)이라고 부른다. 이 책에서는 단면 대신 관용적인 슬라이스(slice) 라는 용어를 사용한다.

배열 슬라이스는 배열의 특정 부분만을 선택적으로 접근하거나 수정할 수 있게 해주는 유용한 기능으로, 이를 활용하면 루프 없이도 배열의 특정 부분만을 제어하거나, 데이터를 복사·이동하는 작업을 효율적으로 처리할 수 있다.

배열 슬라이스는 아래 예시와 같이 start : end [ : stride ] 형식을 사용하여 범위를 지정한다.

표기 예시 설명
a(3:7) 3번째부터 7번째 요소까지 선택한다 (양 끝 포함, 총 5개 요소).
a(1:10:2) 1부터 10까지 보폭(stride) 2만큼 건너뛰며 선택한다 (1, 3, 5, 7, 9).
a(10:1:-1) 10부터 1까지 거꾸로 선택한다 (보폭이 -1인 경우).
a(:) 해당 차원의 전체 요소를 선택한다.
- 대입의 좌변 사용: 배열 슬라이스는 읽기 뿐만 아니라, 대입문의 좌변으로도 사용할 수 있다. 이 경우, 지정한 슬라이스의 크기와 우변의 크기가 일치해야 한다.
- 예: a(2:4) = [20, 30, 40]은 배열 a의 2, 3, 4번째 요소만 20, 30, 40으로 바꾼다.

배열 슬라이스 사용 시 주의할 점은 다음과 같다.

[예제] 1차원 배열 슬라이스의 활용

배열의 특정 영역을 지정하여 부분 배열을 추출하는 배열 슬라이스 기법은 시작인덱스:끝인덱스[:보폭] 구조를 사용한다.

%%writefile sections.f90
program sections
   implicit none
   integer :: a(10)
   integer :: i

   a = [(i, i = 1, 10)]   ! a = 1, 2, ..., 10

   print '(a, *(i3))', "a          : ", a
   print '(a, *(i3))', "a(3:7)     : ", a(3:7)
   print '(a, *(i3))', "a(1:10:2)  : ", a(1:10:2)
   print '(a, *(i3))', "a(10:1:-1) : ", a(10:1:-1)

   ! assign into a section (3 values into a(2:4))
   a(2:4) = [20, 30, 40]
   print '(a, *(i3))', "after edit : ", a
end program sections

컴파일·실행:

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

실행 결과:

a          :   1  2  3  4  5  6  7  8  9 10
a(3:7)     :   3  4  5  6  7
a(1:10:2)  :   1  3  5  7  9
a(10:1:-1) :  10  9  8  7  6  5  4  3  2  1
after edit :   1 20 30 40  5  6  7  8  9 10

[!warning] [흔한 실수] a(3:7)은 4개가 아니라 5개(3,4,5,6,7)다. 파이썬의 a[3:7](상한 미포함, 4개)과 다르다. 슬라이스의 요소 수는 end − start + 1(보폭 1일 때)로 계산한다. 슬라이스를 좌변에 쓸 때는 우변의 크기를 이 값에 정확히 맞춰야 한다.

[!note] 2차원 배열의 메모리 할당

컴퓨터의 물리적 메모리(RAM)는 2차원 평면이 아닌 선형 구조를 가진 1차원 공간이다. 따라서 프로그램에서 정의한 2차원 배열 역시 실제 메모리에 저장될 때는 데이터를 일렬로 길게 펼쳐서 담아야 한다.

예를 들어 m(3, 4)라는 크기의 배열을 선언하면, 수학적인 행렬 구조에서는 아래와 같이 3행 4열의 평면 형태로 시각화되지만 메모리 내부 공간은 이와 다르게 전개된다.

col 1 col 2 col 3 col 4 +---------+---------+---------+---------+ row 1 | m(1,1) | m(1,2) | m(1,3) | m(1,4) | | 11 | 12 | 13 | 14 | +---------+---------+---------+---------+ row 2 | m(2,1) | m(2,2) | m(2,3) | m(2,4) | | 21 | 22 | 23 | 24 | +---------+---------+---------+---------+ row 3 | m(3,1) | m(3,2) | m(3,3) | m(3,4) | | 31 | 32 | 33 | 34 | +---------+---------+---------+---------+

Fortran은 2차원 배열을 1차원으로 변환할 때 첫 번째 인덱스(행 첨자)가 가장 먼저 변화하는 방식을 채택하고 있다. 즉, 하나의 열(column)을 기준으로 위에서 아래로 데이터를 메모리에 순차적으로 채운 뒤 다음 열로 이동하는 열 우선(column-major order) 방식을 따른다.

``` memory order (왼쪽 -> 오른쪽으로 주소 증가):

+----+----+----+ +----+----+----+ +----+----+----+ +----+----+----+ | 11 | 21 | 31 | | 12 | 22 | 32 | | 13 | 23 | 33 | | 14 | 24 | 34 | +----+----+----+ +----+----+----+ +----+----+----+ +----+----+----+ _ col 1 _/ __ col 2 / ____ col 3 / ____ col 4 __/ ```

반면, C 언어나 파이썬의 NumPy는 두 번째 첨자가 먼저 변하는 행 우선(row-major order) 방식을 사용하므로 메모리에 펼쳐지는 순서가 다르다. 따라서 이 두 가지 언어 계열 사이에서 데이터를 주고받을 때 메모리 배치 차이를 고려하지 않으면 행과 열이 뒤바뀌는 오류가 발생한다.

column-major (Fortran): 11 21 31 | 12 22 32 | 13 23 33 | 14 24 34 row-major (C / NumPy): 11 12 13 14 | 21 22 23 24 | 31 32 33 34

Fortran에서 2차원 배열의 원소 m(i, j)가 물리 메모리에서 몇 번째 칸(인덱스)에 위치하는지는 다음 공식으로 결정된다. 이때 nrows는 배열의 첫 번째 차원의 크기(총 행의 개수)를 의미한다.

              linear index (0-based) = (j - 1) * nrows + (i - 1)

예를 들어, m(3, 4), nrows = 3 인 배열에서 마지막 원소인 m(3,4)의 메모리 위치를 계산하면 (4 - 1) * 3 + (3 - 1) = 11 으로 마지막 칸 = 34과 같다. 계산 결과에 따른 메모리 공간의 칸 번호와 실제 저장되는 데이터의 매칭 관계는 아래와 같다.

칸 번호: 0 1 2 3 4 5 6 7 8 9 10 11 값: 11 21 31 12 22 32 13 23 33 14 24 34

이런 Fortran의 물리적인 저장 특성은 중첩 반복문을 구성할 때 루프 제어 순서의 중요한 근거가 된다. 대형 데이터를 다룰 때 가장 안쪽 반복문의 제어 변수를 첫 번째 첨자인 i로 설정하면, 메모리 접근 순서가 11 → 21 → 31 → 12 → …와 같이 물리적 주소의 증가 방향과 정확히 일치하게 된다. 이런 연속적인 메모리 참조 방식은 캐시 적중률(cache hit rate)을 극대화하여 대규모 배열 연산에서 압도적인 속도 향상을 가져온다.

[!warning] [흔한 실수] 경계 검사 옵션(-fcheck) 예를 들어, a(11)처럼 배열 범위 밖을 읽으려 하면, 컴파일 단계에서는 넘어가지만 실행 단계에서 엉뚱한 값을 주거나 프로그램이 강제 종료된다.

오류의 원인을 알고 싶으면 아래와 실행 시 경계 검사 옵션(-fcheck)을 켠다.

bash !gfortran -std=f2018 -fcheck=bounds bug_bounds.f90 -o bug_bounds !./bug_bounds

아래와 같이 에러 원인을 출력해 준다.

Fortran runtime error: Index '11' of dimension 1 of array 'a' above upper bound of 10

개발 단계에서는 -fcheck=all을 늘 켜 두고, 검증이 끝난 뒤 실행 성능을 위해 끄고 다시 컴파일하도록 한다.

[예제] 2차원 인덱싱과 행·열 슬라이스 활용

2차원 배열은 m(i, j) 형태로 두 개의 첨자를 사용하며, 관례적으로 첫 번째 첨자 i는 행(row)을, 두 번째 첨자 j는 열(column)을 가리킨다. 특정 차원의 첨자 위치에 전체 범위를 의미하는 콜론(:)을 지정하면 해당 줄(행 또는 열) 전체를 배열 슬라이스로 쉽게 추출할 수 있다. 예를 들어 m(:, 2)는 2번째 열 전체를, m(1, :)는 1번째 행 전체를 의미하는 1차원 부분 배열이 된다.

%%writefile two_d.f90
program two_d
   implicit none
   integer :: m(3, 4)
   integer :: i, j

   ! column-major friendly order: inner loop over the first index
   do j = 1, 4
      do i = 1, 3
         m(i, j) = i * 10 + j
      end do
   end do

   print '(a)', "matrix (row by row):"
   do i = 1, 3
      print '(*(i4))', m(i, :)
   end do

   print '(a, *(i4))', "column 2  m(:,2): ", m(:, 2)
   print '(a, *(i4))', "row 1     m(1,:): ", m(1, :)
end program two_d

컴파일·실행:

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

실행 결과:

matrix (row by row):
  11  12  13  14
  21  22  23  24
  31  32  33  34
column 2  m(:,2):   12  22  32
row 1     m(1,:):   11  12  13  14

Fortran은 다차원 배열을 물리적 메모리에 저장할 때 열 우선(column-major order) 방식을 채택하고 있다. 이에 따라 행렬 m(3, 4)는 물리적으로 m(1,1) → m(2,1) → m(3,1) → m(1,2) → m(2,2) → ... 순서로 메모리의 연속된 방에 배치된다. 즉, 첫 번째 첨자가 가장 빠르게 변화하며 일렬로 나열되는 구조이다.

따라서 위 예제의 다중 do 루프와 같이 첫 번째 첨자인 행 제어 변수 i를 가장 안쪽 루프에 배치해야 컴퓨터가 가깝고 연속된 메모리 주소를 순차적으로 접근하게 된다. 이는 하드웨어 수준에서 부하를 대폭 줄여준다. 대규모 격자 유체 역학 시뮬레이션과 같이 다루는 배열의 스케일이 비대해질수록, 이 접근 순서를 올바르게 설계하는 것이 프로그램의 실제 연산 속도를 결정짓는다.

[!note] Fortran의 열 우선(column-major) 저장의 의미

Fortran은 2차원 배열을 m(1,1), m(2,1), m(3,1), m(1,2), … 순으로 메모리에 할당한다. 즉, 첫 첨자가 가장 빨리 변한다. 2차원 표(행과 열)를 메모리라는 1차원 선형으로 늘어놓는다 것은 '세로줄(열)'을 먼저 길게 붙여서 저장한다는 뜻이다.

컴퓨터 CPU는 메모리에서 데이터를 하나만 쏙 빼오는 게 아니라, 그 주변 데이터까지 덩어리(캐시)로 미리 가져온다. 이때 반복문의 가장 안쪽 루프가 첫 번째 첨자(i)를 움직이게 만들면, CPU가 미리 가져다 놓은 방(캐시)을 순서대로 방문하며 일한다. 만약 반대로 짜면, CPU가 데이터를 찾으려고 메모리 이곳저곳을 멀리 돌아다녀야 하므로 속도가 현저히 떨어진다.

데이터 용량이 작을 때는 티가 안 나지만, 대형 물리 시뮬레이션처럼 수십 기가바이트 단위의 거대한 배열을 다룰 때는, 이 순서 하나 맞추지 않은 것 때문에 연산 속도가 수십 배에서 수백 배까지 느려질 수 있다. 큰 배열일수록 이 저장 순서가 속도를 가른다.

[예제] 2차원 배열을 히트맵으로 시각화

2차원 격자점 배열을 정의하고, 격자 위 함수 $f(x, y) = e^{-(x^2 + y^2)} \cdot \cos(3x)$를 계산하여 공간 상의 분포를 히트맵(heatmap)으로 표출해 보자. 이 함수는 $x$축을 따라 진동하는 $\cos(3x)$ 성분에, 중심부에서 전방위로 감쇠하는 가우시안 방사형(radial) 성분인 $e^{-(x^2+y^2)}$이 곱해진 형태이다.

계산된 2차원 배열 데이터를 csv 파일로 내보낼 때, 행렬의 한 줄에 한 행씩 기록되도록 앞서 학습한 행 슬라이스(row slice) field(i, :)를 출력 목록에 그대로 전달한다. 이때 적용된 서식 '(*(f0.6, :, ","))' 내부의 콜론(:) 편집 기술자는 출력 버퍼에 기록할 데이터 원소가 남아있지 않으면 그 즉시 서식 적용을 종료하므로, 줄 끝에 불필요한 쉼표(,)를 남기지 않고 깔끔한 csv 행렬 구조를 형성한다.

%%writefile field_data.f90
program field_data
   use iso_fortran_env, only: real64
   implicit none
   integer, parameter :: nx = 60, ny = 60
   real(real64) :: field(nx, ny)
   real(real64) :: x, y, dx, dy
   integer :: i, j, u

   dx = 6.0_real64 / real(nx - 1, real64)
   dy = 6.0_real64 / real(ny - 1, real64)

   ! fill a 2-D array with f(x, y) = exp(-(x^2 + y^2)) * cos(3x)
   do j = 1, ny
      do i = 1, nx
         x = -3.0_real64 + real(i - 1, real64) * dx
         y = -3.0_real64 + real(j - 1, real64) * dy
         field(i, j) = exp(-(x * x + y * y)) * cos(3.0_real64 * x)
      end do
   end do

   ! write one matrix row per line; ":" stops the trailing comma
   open(newunit=u, file="field.csv", status="replace", action="write")
   do i = 1, nx
      write(u, '(*(f0.6, :, ","))') field(i, :)
   end do
   close(u)

   print '(a)', "field.csv written"
end program field_data

컴파일·실행:

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

실행 결과:

!cat ./field.csv 명령으로 파일을 열어보면, 컴퓨터 내부에 60행 $\times$ 60열 구조를 가지는 쉼표 구분 행렬 데이터가 다음과 같이 기록되어 있다.

-.000000,-.000000,-.000000,-.000000,-.000000,-.000000,-.000000,-.000001,-.000001,-.000001,-.000002,-.000003,-.000005,-.000007,-.000009,-.000013,-.000017,-.000022,-.000029,-.000036,-.000044,-.000053,-.000063,-.000073,-.000082,-.000091,-.000099,-.000105,-.000110,-.000112,-.000112,-.000110,-.000105,-.000099,-.000091,-.000082,-.000073,-.000063,-.000053,-.000044,-.000036,-.000029,-.000022,-.000017,-.000013,-.000009,-.000007,-.000005,-.000003,-.000002,-.000001,-.000001,-.000001,-.000000,-.000000,-.000000,-.000000,-.000000,-.000000,-.000000
-.000000,-.000000,-.000000,-.000000,-.000000,-.000000,-.000001,-.000001,-.000001,-.000002,-.000003,-.000005,-.000007,-.000010,-.000014,-.000019,-.000025,-.000033,-.000043,-.000054,-.000066,-.000079,-.000094,-.000108,-.000123,-.000136,-.000148,-.000157,-.000164,-.000167,-.000167,-.000164,-.000157,-.000148,-.000136,-.000123,-.000108,-.000094,-.000079,-.000066,-.000054,-.000043,-.000033,-.000025,-.000019,-.000014,-.000010,-.000007,-.000005,-.000003,-.000002,-.000001,-.000001,-.000001,-.000000,-.000000,-.000000,-.000000,-.000000,-.000000

                               (중략)

-.000000,-.000000,-.000000,-.000000,-.000000,-.000000,-.000000,-.000001,-.000001,-.000001,-.000002,-.000003,-.000005,-.000007,-.000009,-.000013,-.000017,-.000022,-.000029,-.000036,-.000044,-.000053,-.000063,-.000073,-.000082,-.000091,-.000099,-.000105,-.000110,-.000112,-.000112,-.000110,-.000105,-.000099,-.000091,-.000082,-.000073,-.000063,-.000053,-.000044,-.000036,-.000029,-.000022,-.000017,-.000013,-.000009,-.000007,-.000005,-.000003,-.000002,-.000001,-.000001,-.000001,-.000000,-.000000,-.000000,-.000000,-.000000,-.000000,-.000000

저장된 csv 텍스트 파일을 파이썬 환경으로 불러와 2차원 평면 격자 그래프로 표출하는 코드는 다음과 같다.

import numpy as np
import matplotlib.pyplot as plt

# loadtxt reads the whole matrix at once; data[i, j] == field(i+1, j+1)
data = np.loadtxt("field.csv", delimiter=",")

# transpose so the x-index runs horizontally and the y-index vertically
plt.figure(figsize=(8, 6))
plt.imshow(data.T, origin="lower", extent=[-3, 3, -3, 3],
           cmap="viridis", aspect="auto")
plt.colorbar(label="value")
plt.xlabel("x")
plt.ylabel("y")
plt.title("2D field from a Fortran array")
plt.tight_layout()
plt.savefig("field.png", dpi=120)
plt.show()

시각화된 결과를 살펴보면, 원점으로부터의 거리에 따라 감쇠하는 가우시안 성분과 $x$축 방향으로 진동하는 주기적 성분이 곱해져 복합적인 파동의 강도(intensity) 분포를 만들어낸다.

파이썬 코드에서 data.T로 전치 연산을 수행한 이유는, Fortran에서 행 단위(i 인덱스)로 저장한 텍스트 행렬을 파이썬 imshow에서 화면의 수평 축인 $x$축과 수직 축인 $y$축 좌표계에 매칭시키기 위함이다.

공간 데이터(예: 온도, 풍속 등)를 다룰 때 보통 $x$축은 수평(가로), $y$축은 수직(세로)을 의미한다. 그런데 Fortran에서 A(i, j) 형태의 2차원 배열을 텍스트로 저장한 뒤 파이썬의 imshow로 읽으면 배열의 첫 번째 인덱스(i, 행)를 모니터 화면의 수직축($y$축)으로, 두 번째 인덱스(j, 열)를 수평축($x$축)으로 인식하여 그림을 그린다. 즉, 원래 $x$축이어야 할 데이터가 세로로 배치되면서 공간 방향이 90도 돌아가게 된다. 따라서, 이를 원래의 공간 방향과 일치시키기 위해 전치(transpose) 연산 data.T를 수행한 것이다.

![[Pasted image 20260722103712.png]]

[!note] csv는 한 줄에 field(i, :)(첫 첨자 i가 고정된 한 행)를 썼으므로, 읽어 들인 data[i, j]는 Fortran의 field(i+1, j+1)에 대응한다. Fortran 첫 첨자 i는 x, 둘째 j는 y로 정했으니, 관례대로 x를 가로·y를 세로로 두려면 data.T로 전치한다. 배열-그림의 첨자 대응을 의식하는 습관이 2차원 데이터에서 방향 실수를 막아 준다.


[!important] 오류 학습

배열에서 처음 만나는 오류는 대부분 "Error: Different shape for array assignment"이다. "형상이 맞지 않다"는 의미인데, 컴파일러는 이런 실수를 실행 전에 잡아 주는데, 메시지를 정확히 읽어보면 원인이 곧바로 보인다. 전형적인 두 가지 경우를 살펴보자.

[예제] 형상 불일치: 배열 대입에서 개수 오류

a(2:4)는 세 요소(2, 3, 4 번째)를 가리킨다. 그런데 바꿔 넣을 값을 두 개([20,30])만 적으면 좌변과 우변의 크기가 불일치 한다.

%%writefile shape_mismatch.f90
program shape_mismatch
   implicit none
   integer :: a(10)
   integer :: i

   a = [(i, i = 1, 10)]
   a(2:4) = [20, 30]   ! left side has 3 elements, right side has 2
   print *, a
end program shape_mismatch

컴파일하면 다음과 같이 거절된다.

!gfortran -std=f2018 -Wall shape_mismatch.f90 -o shape_mismatch
shape_mismatch.f90:7:3: 

    7 |    a(2:4) = [20, 30] ! left side has 3 elements, right side has 2
      |   1 
Error: Different shape for array assignment at (1) on dimension 1 (3 and 2)

에러 메시지에서 "(3 and 2)"은 좌변 슬라이스는 3개, 우변 생성자는 2개이므로 맵핑할 수 없다는 뜻이다. 슬라이스 요소 수는 end − start + 1로 4 − 2 + 1 = 3 이므로, 우변을 정확히 그 개수에 맞춰야 한다. 즉 a(2:4) = [20, 30, 40]으로 수정한다.

[예제] 차원(계수) 불일치: 1차원 값을 2차원 배열에 넣는다

1차원 생성자로 만든 12개 값을 3 × 4 배열에 대입하려는 시도는 자연스러워 보이지만, 차원(계수)가 다르면 대입할 수 없다. 아래에서 발생한 에러를 살펴보자.

%%writefile rank_mismatch.f90
program rank_mismatch
   implicit none
   integer :: m(3, 4)
   integer :: i

   m = [(i, i = 1, 12)]   ! rank-1 constructor into a rank-2 array
   print *, m
end program rank_mismatch

컴파일 실행:

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

실행 결과:

rank_mismatch.f90:6:3: 

     6 |   m = [(i, i = 1, 12)] ! rank-1 constructor into a rank-2 array 
       |  1
Error: Incompatible ranks 2 and 1 in assignment at (1) 
/bin/bash: line 1: ./rank_mismatch: No such file or directory

위 컴파일 에러는 좌변의 배열 m은 2차원인 반면, 우변의 배열 생성자는 1차원이기 때문에 두 변수의 계수(rank)가 서로 일치하지 않아 발생한다. Fortran에서는 대입 연산 시 좌변과 우변의 형상과 계수가 일치해야 한다.

1차원 형태로 생성된 연속된 값들을 2차원 배열의 모양으로 변환하여 대입하려면 reshape 내장 함수를 사용해야 하며, 이에 대한 자세한 내용은 8장에서 다룬다.

위 두 오류는 모두 컴파일 단계에서 발견된다. 하지만, 슬라이스 끼리 대입하면서 크기가 우연히 같지만, a(1:3) = b(5:7)에서 인덱스를 잘못 지정한 경우 등은 컴파일러가 오류를 잡아내지 못한다. 이는 개발자 오류이다. 형상이 같은지 뿐만 아니라 어떤 요소를 가리키는지도 함께 확인해야 한다.


[!important] 요약


[!important] 연습 문제

  1. 크기 7의 정수 배열을 선언하고, do 반복으로 각 요소에 첨자 × 첨자(제곱)를 넣은 뒤 전부 출력하라.
  2. 배정밀도 실수 배열을 생성자 [10, 20, 30, 40, 50](실수형으로)로 채우고, size로 크기를, 세 번째 요소 값을 함께 출력하라.
  3. 경계가 0:9인 정수 배열을 선언해 첨자 × 2를 채우고, lboundubound로 하한· 상한을 출력하라.
  4. 크기 12의 정수 배열을 1~12로 채운 뒤, 슬라이스 a(3:9)a(12:1:-2)를 각각 출력하라.
  5. 4 × 3 정수 배열을 m(i, j) = i + j로 채우고, 한 행씩 줄 바꿔 출력하라.
  6. 묵시적 do를 쓰는 배열 생성자로 [1, 4, 9, …, 100](1부터 10까지의 제곱)을 만들어 출력하라.
  7. 크기 8의 실수 배열을 생성자로 채우고, do 반복으로 최댓값과 최솟값을 직접 찾아 출력하라(배열 내장 함수는 아직 쓰지 않는다).
  8. 크기 n의 배열을 1~n으로 채운 뒤, 보폭 −1 슬라이스만으로 뒤집힌 순서를 출력하라. 이어서 do 반복으로 원본 배열 자체를 제자리에서 뒤집어 출력하라.
  9. 5 × 5 정수 배열을 만들어 대각선은 1, 나머지는 0으로 채우고(중첩 반복과 if), 행 단위로 출력하라.
  10. 1차원 배열에 y(i) = i × i − 10 × i를 0~20에서 계산해 담고 csv로 내보낸 뒤, 그림 예제 1의 방식으로 선그래프를 그려라(포물선이 보여야 한다).