제 5 부 — 자료 구조와 파일



13장 · 파생형과 포인터


Building Your Own Types



[!attention] 이 장의 목표


지금까지 다룬 변수는 정수 하나, 실수 하나처럼 단일 데이터이거나, 동일한 자료형의 값을 한데 모아둔 배열에 국한되었다. 그러나 실무에서 다루는 데이터는 다음과 같이 서로 다른 자료형의 값들이 하나의 구조 안에 유기적으로 묶여 있는 경우가 훨씬 많다.

이런 데이터를 위치는 위치대로, 속도는 속도대로 서로 다른 배열에 분산하여 저장하게 되면 관리하기가 까다로워진다. 예컨대 "3번 입자의 속도"를 조회하거나 수정하려 할 때마다 각 배열의 인덱스를 일일이 수동으로 일치시켜야 하므로 코드가 복잡해지고 논리적 실수가 자주 발생한다.

이 장에서는 이와 같은 한계를 극복하기 위해 서로 다른 자료형의 변수들을 하나의 새로운 자료형으로 통합하는 파생형(derived type)을 학습한다. 아울러 실행 중에 데이터의 크기가 동적으로 변하는 유연한 자료 구조를 구축하도록 지원하는 포인터(pointer)의 개념과 활용법을 함께 배운다. 파생형과 포인터는 각각 독립적으로도 훌륭한 도구이지만, 이 둘을 유기적으로 결합하여 사용할 때 Fortran의 구조적이고 강력한 데이터 처리 능력이 온전히 발휘된다.


[!important] 13.1 파생형 정의와 구성 요소

파생형(derived type)은 기본 자료형(정수형, 실수형, 문자형 등)이나 이미 정의된 또 다른 파생형들을 하나의 단위로 묶어 사용자가 직접 설계하는 맞춤형 자료형이다. 이는 C 언어의 구조체(struct)나 파이썬의 데이터 클래스(dataclass)와 같은 개념이다.

파생형을 도입하면 밀접하게 연관된 값들이 하나의 변수 영역 안에 유기적으로 통합된다. 따라서 이 데이터들을 다른 프로시저로 전달할 때 여러 개의 인수를 넘길 필요 없이, 단 하나의 파생형 변수만으로 간결하게 주고받을 수 있다.

파생형 내부의 개별 구성 요소에 접근할 때는 아래와 같이 퍼센트 기호(%)를 사용한다. 파생형 변수 p가 2차원 평면의 좌표 성분(x,y)을 담고 있다면, 메모리와 코드 상에서 다음 구조로 매핑된다.

변수 p ──┬── p%x  (실수형 데이터가 저장되는 공간)
         └── p%y  (실수형 데이터가 저장되는 공간)

파생형의 구조를 설계할 때는 아래와 같이 typeend type 블록을 사용하며, 정의된 형식에 맞춰 변수를 선언하고 내부 요소에 접근한다.

type :: 형-이름
   구성요소-자료형 :: 구성요소-이름 [= 초깃값}]
   ! ... 추가 구성 요소 선언
end type [형-이름]

[예제] 2차원 평면 위의 점 표현

%%writefile point_demo.f90
program point_demo
   use, intrinsic :: iso_fortran_env, only: real64
   implicit none

   type :: point2d
      real(real64) :: x
      real(real64) :: y
   end type point2d

   type(point2d) :: p
   real(real64)  :: dist

   p%x = 3.0_real64
   p%y = 4.0_real64

   dist = sqrt(p%x**2 + p%y**2)

   print '(a, f0.2, a, f0.2, a)', "point = (", p%x, ", ", p%y, ")"
   print '(a, f0.2)', "distance from origin = ", dist
end program point_demo

컴파일·실행:

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

실행하면 평면 위의 좌표 정보와 원점으로부터의 거리가 다음과 같이 출력된다.

point = (3.00, 4.00)
distance from origin = 5.00

type :: point2d ... end type point2d 은 새로운 구조의 설계도를 알려주는 선언부이다. 이 단계에서는 메모리가 할당되지 않는다.

설계도에 따라 실제로 메모리에 변수 공간을 구축하는 시점은 type(point2d) :: p 문장을 만났을 때이다. 이후 코드 영역에서는 퍼센트(%) 연산자를 활용하여 p%xp%y로 개별 공간에 값을 나누어 보관하고, 원점과의 유클리드 거리를 계산하는 연산을 안전하게 수행하게 된다.


[!important] 13.2 형 생성자와 중첩 구조

파생형의 구성 요소에 값을 대입할 때, 멤버 변수를 하나씩 지정하여 대입하는 대신 형 생성자(type constructor)를 사용하면 단 한 줄로 모든 값을 동시에 초기화할 수 있다.

형 생성자의 이름은 사용자가 정의한 파생형의 이름과 완전히 동일하다. 값을 대입할 때는 정의된 구성 요소 순서대로 값을 나열하는 순서 지정 방식과, 구성 요소의 이름을 직접 명시하는 키워드 지정 방식을 모두 지원한다.

형 생성자 호출 문법은 아래와 같이 두가지로 나눌 수 있다.

type-name(value1, value2, ...)
type-name(comp1=value1, comp2=value2, ...)

파생형을 정의하는 시점에 구성 요소 이름 옆에 = 초깃값 형태로 대입 식을 지정해 두면, 변수가 선언될 때 자동으로 이 값이 채워지는 기본 초기화(default initialization)가 이루어진다.

기본 초기화가 설정된 파생형을 생성자로 호출할 때는 해당 구성 요소를 생략할 수 있다. 단, 일부 구성 요소를 생략한 채 생성자를 호출할 때는 컴파일러가 누락된 위치를 혼동하지 않도록 반드시 키워드 지정 방식(이름=값)을 사용하여 호출해야 한다.

파생형의 구성 요소 내부의 데이터 형으로 또 다른 파생형을 선언하여 포함하는 중첩 파생 구조(nested derived types) 를 형성할 수 있다. 예를 들어, 기하학적 원(circle)은 중심 좌표를 나타내는 point2d 파생형과 실수형 반지름 필드를 내포하는 구조로 정의된다. 이때 중첩 구조 내부에 자리 잡은 가장 하위의 필드에 접근하려면 아래와 같이 퍼센트 기호(%)를 사슬처럼 연이어 연결하여 탐색해야 한다.

type :: circle
    type(point2d) :: center
    real          :: radius
end type circle

type(circle) :: c

! 퍼센트 기호를 단계적으로 연결하여 중첩된 하위 구성 요소에 접근
c%center%x = 0.0  

[예제] 파생형 중첩 구조 및 기본 초기화 활용

파생형 내부에 또 다른 파생형을 포함하는 중첩 구조를 형성하고, 각 필드에 기본 초기화 값을 할당하여 형 생성자를 유연하게 호출하는 예제이다.

%%writefile circle_demo.f90
program circle_demo
   use, intrinsic :: iso_fortran_env, only: real64
   implicit none

   real(real64), parameter :: pi = 3.141592653589793_real64

   type :: point2d                        ! 2차원 좌표(x,y)를 가진 파생형
      real(real64) :: x = 0.0_real64      
      real(real64) :: y = 0.0_real64      ! 각 구성요소에 기본값을 설정
   end type point2d

   type :: circle          ! circle은 center로 point2d를 포함하고 radius를 가지는 중첩구조
      type(point2d) :: center
      real(real64)  :: radius = 1.0_real64
   end type circle

   type(circle) :: c1, c2        
   real(real64) :: area

   c1 = circle(center = point2d(2.0_real64, 1.0_real64), radius = 3.0_real64)
                       ! c1은 키워드 방식으로 명시적으로 초기화
   c2 = circle()       ! c2는 모든 구성요소가 기본값을 가짐

   area = pi * c1%radius**2

   print '(a, f0.2, a, f0.2, a, f0.2)',                                        &
         "c1: center=(", c1%center%x, ",", c1%center%y, "), r=", c1%radius
                          ! % 연산자를 사용하여 원의 반지름에 접근
   print '(a, f0.3)', "c1 area = ", area
   print '(a, f0.2, a, f0.2, a, f0.2)',                                        &
         "c2: center=(", c2%center%x, ",", c2%center%y, "), r=", c2%radius
end program circle_demo

실행하면 중첩된 구성 요소의 도출 값과 기본 초기화가 적용된 결과가 다음과 같이 나타난다.

c1: center=(2.00,1.00), r=3.00
c1 area = 28.274
c2: center=(.00,.00), r=1.00

[!warning] [흔한 실수] 생성자 호출 시 기본값이 없는 구성 요소의 누락 형 생성자를 호출하여 변수를 초기화할 때, 파생형 정의 단계에서 기본값이 지정되지 않은 필드는 생성자의 인수 목록에서 단 하나라도 누락되면 안 된다. 예를 들어, point2d 구조를 정의할 때 기본값을 선언하지 않았다면, 임의로 point2d(1.0_real64)와 같이 인수를 하나만 채워 호출할 경우 다음과 같이 초기화 인수가 부족하다는 에러가 발생한다. Error: No initializer for component 'y' given in the structure constructor at (1)

따라서 파생형 설계 시 기본 초기화 식을 누락한 변수가 있다면 생성자 연산 시 반드시 순서대로 모든 구성 요소를 채우거나 키워드 지정 방식으로 명확하게 값을 할당해야 한다.

[예제] 파생형 배열 선언과 구성 요소 일괄 연산

파생형 역시 기본 자료형과 동일하게 배열 형태로 선언하여 다룰 수 있다. Fortran의 파생형 배열이 가진 강력한 장점은 배열 전체를 대상으로 내부 구성 요소를 참조하면, 해당 성분들만 추출된 새로운 구조의 배열이 동적으로 반환된다는 점이다. 예를 들어, 본 예제의 class%score 구조는 학급 배열에 속한 모든 학생의 점수만을 모은 배열이 되므로, 반복문 없이 내장 함수 sum을 즉시 적용할 수 있다.

%%writefile students.f90
program students
   use, intrinsic :: iso_fortran_env, only: real64
   implicit none

   type :: student
      character(len=20) :: name
      integer           :: id
      real(real64)      :: score
   end type student

   type(student) :: class(3)
   integer       :: i
   real(real64)  :: avg

   class(1) = student("Kim",  101, 88.5_real64)
   class(2) = student("Lee",  102, 92.0_real64)
   class(3) = student("Park", 103, 79.5_real64)

   avg = sum(class%score) / size(class)

   do i = 1, size(class)
      print '(i3, 1x, a10, 1x, f5.1)', class(i)%id, class(i)%name, class(i)%score
   end do
   print '(a, f5.2)', "average = ", avg
end program students

컴파일·실행:

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

실행하면 개별 학생의 정보와 일괄 연산으로 도출된 전체 평균 점수가 다음과 같이 출력된다.

101 Kim         88.5
102 Lee         92.0
103 Park        79.5
average = 86.67

위 예제에서 사용한 방식은 다음 장점이 있다.

파생형 배열 구조의 주요 장점은 다음과 같다.

이런 파생형 배열 처리 메커니즘은 대규모 격자 구조 데이터를 다룰 때 코드의 복잡성을 낮추고 연산 효율을 극대화하는 방안이다.


[!important] 13.3 포인터 기초와 상태 관리

포인터(pointer)는 독립적인 메모리 공간에 직접 값을 보유하는 일반 변수와 달리, 메모리 상의 다른 대상이나 동적으로 할당된 공간을 가리키는 특수한 변수이다. 포인터는 스스로 값을 저장하지 않고 가리키는 대상의 '별명(alias)' 역할을 수행하며, 다음 두 가지 유형의 대상을 참조할 수 있다.

포인터 메커니즘을 정확하게 이해하기 위한 핵심은 연관(포인터 대입)과 값 대입(일반 대입)이라는 두 가지 형태의 대입 연산자를 명확하게 구분하여 사용하는 것이다.

p => t 구문을 통해 두 변수를 연관시킨 후 p = 5와 같이 값을 대입하면, 값 5는 포인터 p 자체에 저장되는 것이 아니라 p가 가리키고 있는 실제 타겟 t의 메모리 공간에 기록된다. 결과적으로 원본인 t의 값이 5로 변경된다. 이런 현상을 별명 효과(aliasing) 라고 부르며, 이는 포인터의 본질적인 기능인 동시에 논리적 혼동을 가장 자주 유발하는 원인이 되므로 주의해야 한다.

포인터의 3가지 상태

포인터 변수의 현재 상태는 항상 다음 세 가지 중 하나에 속하게 된다.

  1. 연관됨 (Associated): 메모리상의 유효한 대상 변수나 동적 할당 공간을 올바르게 가리키고 있어 안전하게 접근할 수 있는 상태

  2. 해제됨 (Disassociated): null()이나 nullify 문을 통해 명시적으로 연결을 끊어 아무것도 가리키지 않는 상태 (안전한 참조 차단 가능)

  3. 정의되지 않음 (Undefined): 선언만 이루어지고 초기화가 되지 않아 메모리 어디를 가리키고 있는지 알 수 없는 극히 위험한 상태

[!note] 안전한 포인터 사용을 위한 개발 수칙

포인터 및 타겟 선언 문법

포인터로 사용할 변수에는 pointer 속성을 지정해야 하며, 포인터가 가리킬 대상 변수에는 반드시 target 속성을 부여해야 컴파일 단계에서 참조 관계가 성립된다.

아래 표는 연관 제어 및 상태 검사 기능을 정리한 것이다.

제어 목적 문법 형식 세부 동작 설명
포인터 연관 (포인터 대입) p => t 포인터 p가 타겟 변수 t가 위치한 메모리 주소를 가리키도록 연결을 생성한다.
연관 해제 (Nullify) nullify(p) 포인터 p의 기존 메모리 연결을 안전하게 끊고 상태를 '해제됨'으로 즉시 변경한다.
연관 여부 확인 (내장 함수) associated(p) 포인터 p가 현재 임의의 유효한 대상을 가리키고 있다면 논리형인 .true.를 반환하고, 해제된 상태라면 .false.를 반환한다.
특정 타겟 검증 associated(p, t) 포인터 p가 가리키고 있는 대상이 우측에 지정한 특정 타겟 변수 t와 정확히 일치하는지 검증하여 논리값을 반환한다.

[!warning] [흔한 실수]포인터 사용 시 주의해야 할 흔한 실수

1. 대입 연산자 구조의 오용과 의도하지 않은 별명 버그

포인터 대입 연산자 =>와 일반 값 대입 연산자 =를 혼동하면 심각한 데이터 오염이 발생한다. 포인터 p를 타겟 t와 별개의 독립적인 데이터 복사본으로 생성하려는 의도였으나 실수로 p => t라고 기술하면, 두 변수는 메모리를 공유하는 연관 관계가 된다. 이 상태에서 나중에 p 내부의 값을 수정하게 되면 타겟인 t의 원본 데이터까지 함께 변경되는 별명 버그가 발생한다.

2. 초기화되지 않은 포인터에 대한 상태 검사 시도

선언만 수행하고 초기화(=> null())를 빠뜨린 포인터는 '정의되지 않음' 상태가 된다. 이 상태에서는 위험을 감지하기 위해 if (associated(p))와 같이 상태 검사를 수행하는 것조차 안전하지 않으며, 검사 구문 자체에서 런타임 오류가 발생할 수 있다. 선언과 동시에 명시적으로 null() 객체를 대입해 두어야만 associated() 함수가 정상적으로 .false.를 반환하여 안전한 조건 제어가 가능해진다.

[예제] 포인터 연관, 역참조 및 해제 제어

%%writefile pointer_demo.f90
program pointer_demo
   use, intrinsic :: iso_fortran_env, only: real64
   implicit none

   real(real64), target  :: a = 10.0_real64
   real(real64), target  :: b = 20.0_real64
   real(real64), pointer :: p => null()

   print '(a, l1)', "p associated? ", associated(p)

   p => a                                     ! associate p with a
   print '(a, l1, a, f0.1)', "p associated? ", associated(p), ", p = ", p

   p = 99.0_real64                            ! writes through p into a
   print '(a, f0.1)', "a = ", a

   p => b                                     ! re-point to b
   print '(a, f0.1)', "p now points to b = ", p
   print '(a, l1)', "p associated with a? ", associated(p, a)
   print '(a, l1)', "p associated with b? ", associated(p, b)

   nullify(p)
   print '(a, l1)', "after nullify, associated? ", associated(p)
end program pointer_demo

컴파일·실행:

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

실행하면 포인터가 대상을 가리키고 해제되는 흐름에 따라 다음 상태 변화가 출력된다.

p associated? F
p associated? T, p = 10.0
a = 99.0
p now points to b = 20.0
p associated with a? F
p associated with b? T
after nullify, associated? F

p = 99.0a를 99.0으로 바꾼 점에 주목하라. pa의 별명이었기 때문이다.

[!warning] [흔한 실수] 포인터가 가리키는 대상은 반드시 target 속성이 명시되어 선언되었거나, 다른 활성 포인터 변수여야만 한다. 만약 target 속성이 부여되지 않은 일반 평범한 변수를 가리키도록 포인터 대입을 시도하면, 컴파일러는 정적 체크 단계에서 엄격하게 이를 감지하고 다음 에러 메시지를 발생시키며 빌드를 중단한다: fortran Error: Pointer assignment target is neither TARGET nor POINTER at (1)`

[!note] 포인터 변수 명명 요령 Fortran은 변수명에 포인터 여부를 강제하는 접두사(prefix)나 접미사(suffix)를 요구하지 않는다. 하지만, 코드의 가독성을 높이고 프로그래머의 실수를 줄이기 위해 관습적인 명명 규칙(naming convention)을 사용하는 것이 좋다.

다음은 프로그래밍 현장에서 자주 쓰이는 포인터 구분 전략이다.

  1. 접미사(suffix) 활용 변수명 뒤에 _p 또는 _ptr과 같은 식별자를 붙이는 방식으로 명확하게 포인터임을 알 수 있다.

    • temp_p (포인터) vs temp (일반 변수)
    • data_ptr (포인터) vs data (일반 변수)
  2. 접두사(prefix) 활용 변수명 앞에 p_를 붙이는 방식으로, C 언어 등 다른 언어에서도 흔히 사용한다.

    • p_temp (포인터) vs temp (일반 변수)
  3. 상태 기반 명명 포인터가 가리키는 대상의 특성을 살려 이름을 짓는 방식이다.

    • target_val (일반 변수)
    • val_ref (참조 역할을 하는 포인터)

[예제] 배열 구간을 가리키는 포인터 활용

포인터는 단일 변수뿐만 아니라 배열의 특정 구간(슬라이스)에도 연관될 수 있다. 이 특징을 활용하면 대규모 배열의 특정 영역만을 바라보는 일종의 창(window)을 개설할 수 있으며, 이 창을 통해 값을 수정하면 원본 배열의 해당 구간 데이터가 실시간으로 변경된다.

%%writefile pointer_array.f90
program pointer_array
   use, intrinsic :: iso_fortran_env, only: real64
   implicit none

   real(real64), target  :: grid(10)
   real(real64), pointer :: window(:) => null()
   integer :: i

   do i = 1, 10
      grid(i) = real(i, real64)
   end do

   window => grid(4:7)                        ! alias elements 4..7

   print '(a, *(f5.1))', "window before: ", window
   window = window * 10.0_real64              ! modifies grid(4:7)
   print '(a, *(f5.1))', "grid after   : ", grid
end program pointer_array

컴파일·실행:

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

실행하면 window 포인터를 통한 연산이 원본 grid 배열에 직접 반영된 결과가 다음과 같이 나타난다.

window before:   4.0  5.0  6.0  7.0
grid after   :   1.0  2.0  3.0 40.0 50.0 60.0 70.0  8.0  9.0 10.0

배열 구간 포인터의 주요 장점


[!important] 13.4 연결 리스트(Linked List)의 개념과 구현

연결 리스트(linked list)는 데이터를 연속적인 메모리 공간에 배치하는 배열과 달리, 데이터 노드들을 메모리 곳곳에 분산하여 저장하고 각 노드가 다음 노드의 메모리 주소를 가리키는 방식으로 연결한 동적 자료구조이다.

배열과 연결 리스트의 특성 비교

노드(Node)의 내부 구조

연결 리스트는 노드(Node)라 불리는 객체들의 사슬로 구성된다. 각 노드는 다음 두 가지 핵심 정보를 내포하는 파생형 구조를 가진다.

  1. 데이터 필드 (Value): 노드가 실제로 저장하고자 하는 수치나 문자 등의 원본 데이터 값

  2. 포인터 필드 (Next): 다음 노드가 메모리의 어느 위치에 존재하고 있는지 가리키는 주소 이정표

연결 리스트의 핵심 제어 연산

연산 유형 동작 메커니즘
추가 (Push) 새로운 노드를 생성한 뒤, 기존 리스트의 시작(Head) 또는 끝(Tail) 부분에 포인터를 연결하여 사슬을 확장한다.
순회 및 탐색 (Traversal / Search) 맨 처음 노드(Head)를 가리키는 시작 포인터부터 출발하여, 각 노드의 next 포인터를 타고 마지막 노드(null)에 도달할 때까지 차례대로 이동하며 데이터를 처리하거나 원하는 값을 찾는다.
삭제 (Delete) 대상 노드의 이전 노드가 대상 노드의 '다음 노드'를 직접 가리키도록 포인터 방향을 우회시킨 후, 삭제 대상 노드의 메모리를 해제한다.

[!important] 13.5 Fortran에서의 연결 리스트 정의

Fortran에서 연결 리스트를 구현하기 위해서는 파생형 구조와 포인터를 결합해야 한다. Fortran은 파생형 정의 내부에서 자기 자신과 동일한 타입의 포인터 구성 요소를 멤버로 선언하는 것을 완벽하게 지원한다.

type :: node
    real :: value
    type(node), pointer :: next => null()
end type node

연결 리스트는 메모리 공간 곳곳에 흩어져 있는 데이터들을 포인터라는 유기적인 실로 꿰어 연결해 놓은 꾸러미와 같다. 대규모 수치 해석 모델에서 격자 요소가 동적으로 추가되거나, 원소의 삽입과 삭제가 빈번하게 발생하는 알고리즘을 설계할 때 필수적으로 활용되는 핵심적인 자료구조이다.

[예제] 연결 리스트

이 예제는 포인터로 연결된 자료구조가 어떻게 메모리를 동적으로 관리하며 리스트를 만들고, 출력하고, 최종적으로 해제하는 과정을 체계적으로 보여주는 가장 완벽한 기본 모델이다.

%%writefile linked_list.f90
module list_mod
   implicit none
   private
   public :: node, push_front, print_list, free_list

   type :: node
      integer             :: value
      type(node), pointer :: next => null()
   end type node

contains

   subroutine push_front(head, v)
      type(node), pointer, intent(inout) :: head
      integer,             intent(in)    :: v
      type(node), pointer :: new_node

      allocate(new_node)
      new_node%value = v
      new_node%next  => head                  ! new node points at old head
      head           => new_node              ! head now points at new node
   end subroutine push_front

   subroutine print_list(head)
      type(node), pointer, intent(in) :: head
      type(node), pointer :: cur

      cur => head
      do while (associated(cur))
         write(*, '(i0, 1x)', advance="no") cur%value
         cur => cur%next
      end do
      write(*, '(a)') ""
   end subroutine print_list

   subroutine free_list(head)
      type(node), pointer, intent(inout) :: head
      type(node), pointer :: cur, tmp

      cur => head
      do while (associated(cur))
         tmp => cur%next                       ! save next before freeing
         deallocate(cur)
         cur => tmp
      end do
      nullify(head)
   end subroutine free_list

end module list_mod


program linked_list
   use list_mod
   implicit none

   type(node), pointer :: head => null()
   integer :: i

   do i = 1, 5
      call push_front(head, i * 10)
   end do

   call print_list(head)
   call free_list(head)

   print '(a, l1)', "list empty after free? ", .not. associated(head)
end program linked_list

컴파일·실행:

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

실행하면 전방 삽입에 따라 역순으로 정렬된 데이터가 출력되고, 메모리가 안전하게 해제되었음을 알 수 있다.

50 40 30 20 10
list empty after free? T

push_front를 1~5로 부르며 맨 앞에 끼워 넣었으므로, 순회하면 역순(50 40 30 20 10)으로 나온다. free_listtmp => cur%next가 삭제 전에 다음 노드를 미리 저장하는 부분이 핵심이다.

[!note] 포인터 보다 동적 배열 동적 배열만 필요하다면 9장의 allocatable이 더 안전하다. 메모리 누수와 잘못된 참조를 컴파일러가 더 잘 막아 준다. 포인터는 연결 리스트나 트리처럼 노드끼리 서로를 가리켜야 하는 구조, 또는 별명이 꼭 필요한 경우에 사용한다.

[예제] 입자 군집의 포물선 분수 시뮬레이션

위치와 속도 성분을 내포하는 particle 파생형을 정의하고, 동일한 초기 속력을 지닌 여러 입자를 다양한 각도로 공중으로 쏘아 올렸을 때 중력장 하에서 운동하는 물리계를 시뮬레이션하는 종합 예제이다.

이 프로그램은 각 시간 단계(Time step)마다 모든 입자의 좌표 정보를 particles.csv 파일로 기록한다.

%%writefile particles.f90
program particles
   use, intrinsic :: iso_fortran_env, only: real64
   implicit none

   real(real64), parameter :: pi      = 3.141592653589793_real64
   real(real64), parameter :: gravity = 9.81_real64       ! m/s^2
   real(real64), parameter :: speed   = 20.0_real64       ! m/s
   real(real64), parameter :: dt      = 0.05_real64       ! s
   integer,      parameter :: n_part  = 9
   integer,      parameter :: n_step  = 80

   type :: particle
      real(real64) :: pos(2)
      real(real64) :: vel(2)
   end type particle

   type(particle) :: cloud(n_part)
   integer        :: i, step, u
   real(real64)   :: angle

   do i = 1, n_part
      angle = (20.0_real64 + 60.0_real64 * real(i - 1, real64)        &
               / real(n_part - 1, real64)) * pi / 180.0_real64
      cloud(i)%pos = [0.0_real64, 0.0_real64]
      cloud(i)%vel = [speed * cos(angle), speed * sin(angle)]
   end do

   open(newunit=u, file="particles.csv", status="replace", action="write")
   write(u, '(a)') "step,id,x,y"

   do step = 0, n_step
      do i = 1, n_part
         write(u, '(i0, ",", i0, ",", f0.4, ",", f0.4)')              &
               step, i, cloud(i)%pos(1), cloud(i)%pos(2)
      end do
      do i = 1, n_part
         if (cloud(i)%pos(2) < 0.0_real64) cycle      ! landed: stop updating
         cloud(i)%pos    = cloud(i)%pos + cloud(i)%vel * dt
         cloud(i)%vel(2) = cloud(i)%vel(2) - gravity * dt
      end do
   end do

   close(u)
   print '(a)', "particles.csv written"
end program particles

컴파일·실행:

!gfortran -O2 -std=f2018 -Wall particles.f90 -o particles
!./particles
particles.csv written

!head particles.csv 명령으로 데이터 파일을 열어보면, 물리 엔진 연산이 수행되며 정상적으로 생성되었음을 확인할 수 있다.

step,id,x,y 
0,1,.0000,.0000 
0,2,.0000,.0000 
0,3,.0000,.0000 
0,4,.0000,.0000 
0,5,.0000,.0000 
0,6,.0000,.0000 
0,7,.0000,.0000 
0,8,.0000,.0000 
0,9,.0000,.0000

출력된 particles.csv 파일의 데이터를 기반으로 각 시점별 입자의 좌표를 평면에 그려보자.

import csv
import matplotlib.pyplot as plt

ids, xs, ys = [], [], []
with open("particles.csv") as f:
    for row in csv.DictReader(f):
        ids.append(int(row["id"]))
        xs.append(float(row["x"]))
        ys.append(float(row["y"]))

plt.figure(figsize=(8, 4))
sc = plt.scatter(xs, ys, c=ids, cmap="viridis", s=10)
plt.colorbar(sc, label="particle id")
plt.xlabel("x (m)")
plt.ylabel("y (m)")
plt.title("Projectile fountain: Fortran simulation, Python plot")
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig("particles.png", dpi=120)
plt.show()

공기 저항이 없는 중력장 내의 전형적인 등가속도 포물선 운동 궤적들이 아름다운 분수 형태의 군집으로 시각화된다. pos(2), vel(2)처럼 배열을 구성 요소로 갖는 파생형과, 그 파생형의 배열 cloud(n_part)가 결합해 입자계가 자료 구조 하나로 깔끔하게 표현된다. 위치 갱신 cloud(i)%pos = cloud(i)%pos + cloud(i)%vel * dt는 길이 2 배열의 요소별 연산이다(8장).

![[Pasted image 20260714143358.png]]


[!important] 오류 학습

[예제] 연결 리스트 해제 시 메모리 선점 오류(Segmentation Fault)

동적 연결 리스트의 모든 노드를 파괴할 때 사용하는 free_list 서브루틴에서, 연결 정보를 임시 저장하지 않고 메모리를 먼저 해제했을 때 발생하는 치명적인 런타임 오류를 규명하는 예제이다. 단, 본 코드의 테스트를 위해 node 파생형 구조는 앞서 구현한 list_mod 모듈의 스펙을 그대로 활용한다고 가정한다.

%%writefile bad_free.f90
subroutine free_list(head)
   type(node), pointer, intent(inout) :: head
   type(node), pointer :: cur

   cur => head
   do while (associated(cur))
      deallocate(cur)          ! freed here ...
      cur => cur%next          ! ... then read freed memory
   end do
   nullify(head)
end subroutine free_list

아래와 같이 엄격한 메모리 검사 -fcheck=all 및 디버깅 -g 옵션을 활성화하고 컴파일한 후 실행한다.

!gfortran -O2 -std=f2018 -Wall -fcheck=all -g bad_free.f90 -o bad_free
!./bad_free

임의의 노드 객체들을 삽입한 뒤 해당 루틴으로 리스트를 해제하려고 하면, 정상적으로 종료되지 않고 프로세스를 강제로 차단하며 다음 예외 바이트 에러를 출력한다.

Program received signal SIGSEGV: Segmentation fault - invalid memory reference.

Backtrace for this error:
...
#3  ...  in free_list

이 오류가 발생하는 원인은 동적 할당 메모리의 해제 메커니즘과 포인터 변수의 유효 범위를 오해했기 때문이다. deallocate(cur) 명령문이 수행되는 순간, 해당 노드가 차지하고 있던 물리 메모리 공간은 시스템에 즉각 반환되어 소유권을 잃게 된다. 당연히 그 공간 내부의 필드였던 데이터 값(value)과 다음 주소 이정표(next) 정보도 함께 소멸하거나 접근 불가능한 영역으로 격하된다.

그러나 바로 다음 문장에서 이미 소멸한 주소 공간의 컴포넌트인 cur%next를 참조하여 다음 노드로 이동(cur => cur%next)하려고 시도한다. 시스템에서 회수한 메모리 주소를 다시 읽으려고 역참조하는 행위는 운영체제의 보안 규격을 위반하는 행동이다. 이처럼 이미 해제되어 실체가 없는 대상을 계속해서 가리키고 있는 포인터를 허상 포인터(dangling pointer)라고 부르며, 이를 강제로 참조(역참조)하는 순간 예외 없이 세그멘테이션 오류(segmentation fault)를 유발하며 프로그램이 다운된다.

이 문제를 예방하려면 13.4절의 정석적인 설계 방식을 적용해야 한다. 현재 소유하고 있는 메모리를 안전하게 시스템에 반납하기 전에, 그 내부에 기록되어 있는 다음 목적지 링크 주소를 독립된 임시 포인터 변수에 사전 보관하는 방어적 로직이 선행되어야 한다.

do while (associated(cur))
   tmp => cur%next             ! save the link first
   deallocate(cur)             ! then it is safe to free cur
   cur => tmp
end do

단순히 두 실행 문의 연산 순서만을 뒤바꾸었을 뿐이지만 결과는 완전히 상반된다. 포인터를 기반으로 연결 구조나 그래프 자료 구조를 직접 구축하고 관리할 때는, "메모리 공간을 물리적으로 해제하기 직전, 그 메모리가 들고 있던 다음 단계 연동 정보(링크 주소)를 완벽하게 대피시켰는가"를 검증하는 코딩 습관을 들이자.


[!important] 요약


[!important] 연습 문제 1. 구성 요소 re, im(둘 다 real64)을 가진 파생형 complex_t를 정의하고, (3, 4)를 만들어 크기 sqrt(re**2 + im**2)를 출력하라(내장 complex형은 쓰지 말 것). 2. 13.2의 student 형을 그대로 써서 5명을 초기화하고, 최고 점수 학생의 이름과 점수를 출력하라. 3. point2d 두 개 a, b를 받아 두 점 사이 거리를 돌려주는 함수 distance(a, b)를 작성하라(11장 인수 전달 활용). 4. real64 변수 temperaturetarget 속성을 주고, 포인터 p로 가리킨 뒤 p를 통해 값을 36.5로 바꿔 원본이 바뀌었음을 확인하라. 5. 길이 8의 target 배열을 만들고, 포인터로 짝수 인덱스 구간 arr(2:8:2)을 가리켜 그 구간에만 0을 써 넣어라. 출력으로 원본 변화를 보여라. 6. 13.4의 연결 리스트에 노드 개수를 세는 함수 list_length(head)를 추가하고, 5개를 넣은 뒤 길이가 5임을 확인하라. 7. point2d의 배열 poly(:)로 다각형 꼭짓점을 표현하고, 둘레 길이(이웃 꼭짓점 거리의 합, 마지막에서 처음으로 돌아오는 변 포함)를 계산하는 프로그램을 작성하라. 8. particle 형을 사용해 입자 1개를 중력 아래 자유낙하시키되, 땅(y < 0)에 닿으면 수직 속도를 반전(vel(2) = -0.8 * vel(2))시켜 튕기게 하라. 위치를 csv로 출력하고 Python으로 높이-시간 그래프를 그려라. 9. 연결 리스트에 맨 뒤에 노드를 추가하는 push_back(head, v)를 작성하라. 빈 리스트일 때와 아닐 때를 모두 처리해야 한다. 10. name(문자), mass(real64)를 가진 body 형의 배열로 천체 데이터를 담고, 질량이 평균보다 큰 천체만 골라 이름을 출력하라.