Frontend

내가 선언적인 코드를 좋아하는 이유 (Feat. FP로 만드는 애니메이션) - 2

AlgoRoot 2025. 6. 22. 19:53
반응형


안녕하세요. 지난 글 「내가 선언적인 코드를 좋아하는 이유 (Feat. React) – 1」에서 저는 선언적인 코드를 작성하는 방식이 어떻게 코드 품질의 방향키가 되어 주었는지, 그리고 React 렌더링 과정에서 그 철학이 어떤 식으로 구현되는지 살짝 살펴봤습니다. 혹시 1편을 아직 못 보셨다면, 먼저 읽고 오시면 흐름이 조금 더 자연스러울 거예요.
 
 
이번 글에서는 선언적 프로그래밍과 깊이 연결된 함수형 프로그래밍(FP, Functional Programming)에 대해 이야기하려 합니다. 1편에서 설명한 것처럼 저는 선언적 프로그래밍은 "제가 알아야 하는 컨텍스트가 적어지는 코드"라고 정의했는데요, FP는 바로 이 개념을 한층 더 깊이 있게 다루는 패러다임입니다.
 

FP를 처음 접했던 순간

사실 FP를 처음 접한 건 작년 전 회사의 코드 리뷰 세션이었습니다. 당시 팀 리더님이 고차 함수(Higher Order Function)와 커링(Currying)을 설명해 주셨는데요, 커링에 대해서는 개념적으로는 명확히 이해됐지만 실제 코드에 적용하자니 손에 잘 익지 않아 필요할 때만 조금씩 사용하는 정도였습니다.
시간이 흐르며 코드의 복잡도가 높아질 때마다 "이거 커링을 사용하면 더 깔끔하지 않을까?"라는 생각이 점점 자주 들었고, 자연스럽게 FP에 대한 관심이 다시 커지게 됐습니다.
 

함수형 프로그래밍(FP)이란?

FP는 "함수로 사고한다"는 철학을 중심에 둔 프로그래밍 패러다임입니다. 특히 상태를 직접 변경하지 않고, 입력을 받아 출력을 반환하는 ‘순수 함수’를 조합해 프로그램 전체 흐름을 만들어가는 방식입니다.
 
쉽게 말해, 우리가 filter, map, reduce를 즐겨 쓰는 것도 이미 FP의 일부를 자연스럽게 활용하고 있는 셈입니다. 이 함수들은 원본 배열을 변경하지 않고 새로운 값을 반환하며, 동작 방식을 외부에서 주입받는 함수(콜백)를 통해 정의합니다. 이처럼 "무엇을 할 것인지"를 선언하는 코드 구조는 명령형보다 훨씬 간결하고 의도를 잘 드러냅니다.

const result = [1, 2, 3, 4]
  .filter(n => n % 2 === 0) // 짝수만 남기고
  .map(n => n * n)          // 각 값 제곱

 

위 코드 한 줄로 "짝수를 골라 제곱한다"는 의도를 명확히 전달할 수 있습니다. 어떻게 동작하는지를 일일이 따라가지 않아도, 무엇을 하고자 하는지를 직관적으로 이해할 수 있죠. 이처럼 동작 방식보다 목적이 더 잘 드러난다는 점에서, 이 코드는 선언적이면서 함수형 프로그래밍 사고를 담은 코드이기도 합니다.

 

커링(Currying)은 무엇인가?

 
FP를 실제로 적용해 보면, 재사용성과 조합 가능성을 높이는 핵심 기법으로 커링이 자주 등장합니다.
커링은 여러 인자를 한 번에 받는 함수 대신, 하나의 인자만 받고 나머지를 기다리는 함수들을 단계적으로 반환하는 방식입니다.
 
예를 들어 일반적인 add 함수는 이렇게 작성됩니다:

const add = (a, b) => a + b
add(1, 2) // → 3

 
커링을 적용하면 아래와 같은 형태가 됩니다:

const add = a => b => a + b
add(1)(2) // → 3

 
이렇게 작성하면 첫 번째 인자를 고정한 상태로 새로운 함수를 만들 수 있습니다.

const increment = add(1)
increment(5) // → 6

const addTen = add(10)
addTen(2) // 12

 

커링(Curring)을 직접 써보며 느낀 장점 – 흐름을 조립하는 힘

처음엔 단순히 “인자를 쪼개서 받는다” 정도로 이해했던 커링이었지만, 실제 프로젝트에서 반복되는 조건이나 동작을 함수로 추상화해 보니 의미 있는 흐름을 조립하는 도구처럼 느껴졌습니다.
 
예를 들어 다음과 같은 구조가 있다고 해보겠습니다.

const isLongerThan = min => str => str.length > min

const isLongerThan5 = isLongerThan(5)
isLongerThan5('hello') // false
isLongerThan5('functional') // true

 
이제 이 함수를 다른 고차 함수들과 자연스럽게 조합할 수 있습니다.

const filter = pred => arr => arr.filter(pred)

const longWordsOnly = filter(isLongerThan5)
longWordsOnly(['hi', 'world', 'composition', 'js']) 
// → ['composition']

 
각 기능을 작은 함수로 쪼개 두고 조합하다 보니, 자연스럽게 의미 있는 동작들이 만들어졌습니다.
"이걸 어떻게 하지?"가 아니라, "이미 만든 함수들이 있으니까 그냥 붙이면 되겠다."라는 식으로 생각이 바뀌더라고요.
커링은 생각보다 단순한 개념인데, 막상 써보면 흐름을 만들고 재사용하는 데 꽤 유용한 도구라는 걸 느꼈습니다.
 

데이터는 나중에 Data Last!

커링을 설계할 때 제가 중요한 팁 중 하나는, 중요한 설정값은 앞에 받고, 다루는 데이터는 마지막에 받도록 순서를 설계하는 것이었습니다.
보통 인자는 data first방식으로 설계되지만, curring에서는 반대가 되어야지 curring에 유리한 함수가 됩니다. 

const withPrefix = prefix => str => `${prefix}-${str}`

const labelWithDev = withPrefix('dev')
labelWithDev('server') // 'dev-server'

 
이렇게 하면 withPrefix('dev')는 "dev 환경의 접두사를 붙이는 함수"로 남겨두고, 이후 어떤 문자열이 오든 같은 규칙을 적용할 수 있습니다. 무엇을 한다는 설정이 앞에 오고, 무엇에 한다는 데이터는 가장 마지막에 받게 되면, 나중에 합성하거나 조합하기가 훨씬 수월해집니다.
 

loadsh/fp 소개 

이러한 커링된 fp형태는 loadsh에서도 제공하는데요. 기존 lodash는 map(array, iteratee) 순서로 받지만, lodash/fp는 map(iteratee)(array)처럼 설계되어 있어 flow와 함께 합성하기 쉽게 커링 구조로 구성되어 있습니다.

가이드 문서도 있으니 궁금하신 분은 살펴보셔도 좋을 것 같습니다.
import { flow, filter, map } from 'lodash/fp'

const posts = [
	{ id: 1, title: 'Title_1', isPublished: true },
	{ id: 2, title: 'Title_2', isPublished: false },
	{ id: 3, title: 'Title_3', isPublished: true },
]

// 게시된 글의 제목만 소문자로 추출하자!

const getPublishedTitles = flow(
  filter((post) => post.isPublished),
  map((post) => post.title.toLowerCase())
)

getPublishedTitles(posts)
// → ['title_1', 'title_3']

 

이 예시는 게시글 데이터 중에서 isPublished: true인 항목만 필터링한 뒤, 그 제목을 소문자로 변환해 리턴하는 흐름을 함수형 스타일로 표현한 것입니다. 이렇게 FP를 활용하면 불필요한 반복 없이 의도를 그대로 코드에 담을 수 있습니다.

 

 

FP 스타일을 통해 얻는 장점 정리

커링과 합성을 바탕으로 흐름을 선언형으로 구성하는 함수형 프로그래밍(FP)의 매력을 조금이나마 느끼셨다면 좋겠습니다.
정리하자면, 아래와 같은 장점들을 얻을 수 있습니다:

  • 컨텍스트 최소화
    각 함수가 외부 상태에 의존하지 않고 입력 → 출력만 다루기 때문에, 전체 흐름을 이해하기가 한결 수월해졌습니다.
  • 예측 가능성
    동일한 입력에 대해 항상 같은 결과를 내는 순수 함수 덕분에, 디버깅이나 테스트도 훨씬 편해집니다.
  • 합성 가능성
    작게 나눈 함수들을 조합해 흐름을 만들 수 있어, 코드 재사용성과 가독성이 자연스럽게 올라갑니다.

 

다시 FP를 꺼내 든 이유 – GSAP를 사용하면서

작년에 잠깐 GSAP을 꽤 활발히 썼는데, 시간이 지나 손에서 놓고 나니 금세 감이 흐려졌습니다.
특히 작은 애니메이션 단위를 재사용 가능한 구조로 만들어보는 과정은, 좋은 코드를 설계하는 연습이 되기에도 꽤 괜찮은 경험이었습니다.
그래서 최근 다시 GSAP + CSS 조합으로 애니메이션을 만들어보다가, 타임라인 코드가 길어질수록 명령형 스타일이 더 눈에 밟히기 시작했습니다.

그 흐름을 더 명확하게 표현할 수 없을까 고민하던 와중, 다시 FP 방식을 꺼내 들게 되었습니다.
이번에는 단순히 기능을 분리하는 수준을 넘어서, 함수의 커링과 합성 구조를 바탕으로, 애니메이션 단계를 선언적으로 조립할 수 있는 구조를 만들어보자는 접근이었습니다.

앞서 이야기했던 FP의 원칙들을 실제 코드에 녹여낸 과정이기도 했고요.
 
예를 들어 GSAP은 보통 아래처럼 사용합니다.

gsap.to(box, { x: 100 })
gsap.to(box, { y: 50, delay: 0.3 })
...
 

GSAP 특유의 명령형 호출 방식이 편리한 건 맞지만, 단계가 늘어나면 흐름을 눈으로 따라가기가 힘들다고 생각을 했습니다. 이런 코드를 보며 "FP의 파이프라인을 적용하면 흐름을 더 명확히 표현할 수 있지 않을까?"라는 생각이 들었고, 실제로 적용해보기로 했습니다.
 
 

구현 애니메이션 소개

구현하려는 동작은 토스 어플의 내부 동작 애니메이션 중 한 부분입니다. 정해져 있는 n 개의 카드 리스트에서 구현해야 하는 애니메이션은 총 두 가지 기능을 담고 있으며 GSAP를 통해 구현해보려고 했습니다.

  1. 카드가 우측에서 좌측으로 순차적으로 나타나는(slide in)하는 효과 
  2. 카드가 위쪽에서 아래쪽으로 뒤집어지는 (flip) 효과 

* 애니메이션 출처: 토스 블로그
 
아래 좌측은 토스의 애니메이션과 우측에는 제가 구현한 애니메이션입니다. 
제 목적은 애니메이션 구현과, 코드를 잘 짜는 연습을 하는 것이었기 때문에 퍼블리싱에 신경을 쓰지는 않았음을 알려드립니다. 

(좌)토스 애니메이션 (우)구현


 
 

구현 전략

해당 애니메이션을 봤을 때 컴포넌트와 기능을 어떻게 구현하면 좋을까요? 추상화 전 전체 동작과정을 먼저 소개드리면 좋을 것 같습니다.
 

import { useGSAP } from '@gsap/react'
import gsap from 'gsap'

const SlideAndFlipList = ({ children }) => {
	const ref = useRef(null)

	useGSAP(() => {
		const container = ref.current
		if (!container) return

		const targets = gsap.utils.toArray(container.children)
		const fronts = '.card-front'
		const backs = '.card-back'

		const tl = gsap.timeline()

		// 1. Slide 효과 (우측에서 좌측으로 들어옴)
		tl.fromTo(
			targets,
			{ x: '100%', opacity: 0 },
			{ x: '0%', opacity: 1, stagger: 0.1, ease: 'power3.out' },
		)

		// 2. Flip 효과 (카드를 뒤집음)
		tl.set(targets, { perspective: '1000px', transformStyle: 'preserve-3d' })
		tl.set(fronts, { backfaceVisibility: 'hidden' })
		tl.set(backs, { rotateX: 180, backfaceVisibility: 'hidden' })

		tl.to(targets, {
			rotateX: -180,
			stagger: 0.1,
			duration: 0.8,
			ease: 'power2.inOut',
		})
	}, [])

	return <div ref={ref}>{children}</div>
}

 

SlideAndFlipList에서는 카드 요소에 SlideIn + Flip 애니메이션을 순차적으로 적용하기 위해 gsap.timeline()을 사용했습니다. timeline을 사용하면 복잡한 애니메이션을 순차적으로 제어할 수 있고, 각 단계의 흐름을 명확히 구조화할 수 있습니다.

 

selector element에 별로 css초기를 설정하지 않는다면 set()으로 초기화를 시켜줘야 하고, 이후 fromTo()나 to() 메서드를 통해 selector에 애니메이션 효과를 넣어줍니다.
 
위 코드는 동작은 잘 하지만, 여러 개선사항이 보였습니다.

  • slide와 flip 기능을 동시에 적용하는 경우에만 사용할 수 있어 재사용성이 낮습니다.
  • 내부에서 정적인 selector를 사용해 외부에서 주입할 children이 특정 구조를 의존하게 되었습니다.
  • 명령형 호출이 늘어나 흐름을 명확히 파악하기 어렵습니다.

그럼, 어떻게 개선하면 좋을까요? 저는 다음 두 가지에 대해 큰 모듈과 작은 모듈로 나누어 설계했습니다.

  1. List를 받기만하고, animation이 실행되는 컴포넌트 별도 분리 (AnimationList)
  2. 각 Item에 적용할 작은 단위의 애니메이션 함수 구성

 

1단계: 반복되는 구조 추상화하기 (AnimationList)

1 차적으로 분리를 하고 싶었던 부분은, 애니메이션을 실행시키는 부분과, 리스트를 그리는 부분이었습니다.

애니메이션이 없어도 리스트 자체로만 랜더링 될 수 있도록 하면, 추후 애니메이션이 제거되거나, 다른 효과를 주더라도 로직이 분리되니 관리가 용이할 것이라는 생각을 했습니다.

 

그래서 저는  "애니메이션을 List에 입히는"AnimationList라는 컴포넌트를 제작했습니다.

AnimationList 컴포넌트는 gsap의 timeline() 을 사용하여 순차적으로 진행할 수 있는 helper컴포넌트입니다.

 

해당 컴포넌트는 실행할 animation과 그려줄 children,

모든 애니메이션이 완료 후 실행 될 onCompleted callback함수,

animation을 trigger 시킬 (의존성 배열에 들어가는) watch 배열을 받습니다. 

interface AnimationListProps extends ComponentProps<'div'> {
  animate: (tl: gsap.core.Timeline, targets: HTMLElement[]) => void;
  onComplete?: () => void;
  watch?: any[];
}

const AnimationList = ({ animate, children, watch = [], onComplete, ...props }: AnimationListProps) => {
  const containerRef = useRef<HTMLDivElement>(null);

  useGSAP(() => {
    const targets = gsap.utils.toArray(containerRef.current?.children);
    const tl = gsap.timeline({ onComplete });
    animate(tl, targets);
  }, watch);

  return <div ref={containerRef} {...props}>{children}</div>;
};

 
이 컴포넌트 덕분에, 이제 React 컴포넌트 안에서는 gsap 타임라인 설정과 DOM 선택 로직을 매번 작성할 필요가 없어졌습니다. 사용할 때는 이렇게만 써주면 됩니다.

<AnimationList
  animate={(tl, targets) => {
    tl.fromTo(targets, { x: 100 }, { x: 0, stagger: 0.1 })
    tl.to(targets, { opacity: 1 })
  }}
  onComplete={() => {
    console.log('전체 플로우 끝')
  }}
>
  {items.map((_, idx) => <Item key={idx} />)}
</AnimationList>

 
덕분에 컴포넌트 바깥에서는 tl 생성이나 children 탐색을 신경 쓸 필요가 없어지게 되었습니다. 하지만 여기서도 여전히 animate 함수 내부는 명령형 호출로 채워져 있었고, 로직이 많아질수록 컨텍스트를 머릿속에 담아야 하는 부담은 여전했습니다.
 

const animate = (tl, targets) => {
	tl.fromTo(targets, ...)
	tl.set(targets, ...)
	tl.to(targets, ...)
}

 
명령형으로 흐름이 명확하게 드러나는 장점은 있지만, 조금 더 “선언적인” 모습으로 코드를 다룰 방법은 없을지 고민이 시작됐습니다.

 

2단계: FP로 선언적 흐름 만들기

저는 여기서 본격적으로 FP의 "작은 함수의 합성"이라는 철학을 도입해 보기로 했습니다. GSAP의 애니메이션 단계를 각각의 독립된 함수로 쪼갠 뒤, 이를 자연스럽게 연결할 수 있는 FP 스타일의 유틸 함수를 만들었습니다.
 

흐름 제어 유틸: timeline

먼저, 여러 애니메이션 단계를 순차적으로 실행할 수 있도록, GSAP의 timeline을 기반으로 한 작은 유틸을 만들었습니다.
lodash/fp의 flow처럼 여러 함수를 순서대로 연결하는 함수형 스타일이지만, 내부적으로는 gsap.timeline()을 통해 애니메이션을 단계별로 쌓아가는 구조입니다.

export type TimelineFn = (
	tl: gsap.core.Timeline,
	targets: HTMLElement[],
) => void

export const timeline =
	(...steps: TimelineFn[]) =>
	(...args: Parameters<TimelineFn>) => {
		steps.forEach((step) => step(...args))
	}

 

  • TimelineFn은 timeline에 한 단계의 애니메이션을 등록하는 함수입니다.
  • timeline(...)은 이런 단계들을 차곡차곡 실행할 수 있는 흐름 제어자입니다.
  • 결과적으로 timeline(a, b, c)는 a → b → c 순서로 타임라인에 애니메이션을 쌓아주는 조립 함수가 됩니다 

여기서 핵심은 제가 위에서 설명드렸던 tl, targets 같은 공통으로 반복 전달되는 인자는 뒤로 밀고,
각 애니메이션 단계 함수에서는 실행할 애니메이션만 미리 받아서 재사용할 수 있게 구성한 점입니다.
이런 식으로 만들면 slideIn, flip 같은 함수들을 아래처럼 “선언적으로” 조립할 수 있게 됩니다.
 
아래는 각 애니메이션 기능을 curring 된 형태로 제작한 함수입니다. 이 함수는 TimelineFn 타입을 리턴하는 커링된 함수입니다.

const slideIn = (
	{ dir = 'right', stagger = 0.1, duration = 0.5, ...vars }: SlideOptions,
	position: gsap.Position = '>',
): TimelineFn => {
	return (tl, targets) => {
		const from: gsap.TweenVars = { opacity: 0 }
		const to: gsap.TweenVars = {
			...vars,
			opacity: 1,
			stagger,
			duration,
			ease: 'power3.out',
		}

		switch (dir) {
			case 'left':
				from.x = '-100%'
				to.x = 0
				break
			case 'right':
				from.x = '100%'
				to.x = 0
				break
			case 'top':
				from.y = '-100%'
				to.y = 0
				break
			case 'bottom':
				from.y = '100%'
				to.y = 0
				break
		}

		tl.fromTo(targets, from, to, position)
	}
}

const flip = (
	{
		front,
		back,
		axis = 'x',
		stagger = 0.1,
		duration = 0.8,
		invert = true,
		...restOptions
	}: FlipOptions,
	position: gsap.Position = '>',
): TimelineFn => {
	return (tl, targets) => {
		const rotateProp = axis === 'x' ? 'rotateX' : 'rotateY'
		const invertFlag = invert ? -1 : 1

		const parent = targets?.[0].parentElement

		// 카드 기본 설정
		tl.set(parent, {
			perspective: '1000px',
		})
		tl.set(targets, {
			width: '100%',
			position: 'relative',
			transformStyle: 'preserve-3d',
			transition: 'transform 0.6',
		})

		// front 초기 상태
		tl.set(front, {
			position: 'absolute',
			opacity: 1,
			backfaceVisibility: 'hidden',
		})

		// back 초기 상태
		tl.set(back, {
			position: 'absolute',
			opacity: 1,
			[rotateProp]: 180 * invertFlag,
			backfaceVisibility: 'hidden',
		})

		// 카드 회전
		tl.to(
			targets,
			{
				[rotateProp]: 180 * -invertFlag,
				duration,
				stagger,
				ease: 'power2.inOut',
				...restOptions,
			},
			position,
		)
	}
}

 
그리고 이 함수들을 timeline 유틸로 합성하면 다음처럼 매우 선언적으로 표현할 수 있게 됩니다.

<AnimationList 
    animate={
    	timeline(
          slideIn({ dir: 'right' }),
          flip({ axis: 'x', front: '.card-front', back: '.card-back' })
	)}
>
  {/* children */}
</AnimationList>

 
 

3단계: FP 스타일로 재구성한 최종 코드

최종적으로 구성한 구조는 아래와 같습니다 (styling은 제외하였습니다.):

<AnimationList
  animate={timeline(
    slideIn({ dir: 'right' }),
    flip({ axis: 'x', front: '.card-front', back: '.card-back' })
  )}
  onComplete={()=>{
  	console.log("전체 플로우 끝")
  }
>
  {items.map((_, idx) => (
    <article key={idx}>
      <div className="card-front">Front-{idx}</div>
      <div className="card-back">Back-{idx}</div>
    </article>
  ))}
</AnimationList>

 
이처럼 timeline 유틸과 커링된 애니메이션 함수들을 조합하면, 각 단계가 명확히 분리되고 전체 흐름이 한눈에 들어옵니다.

 

특히 중요한 건,

  • 각 블록이 독립적이라 수정이나 순서 변경이 자유롭고,
  • 새 애니메이션도 기존 패턴에 맞춰 쉽게 확장할 수 있다는 점입니다.

지금은 slideIn, flip 정도만 있지만, 동일한 방식으로 fadeIn, scaleUp, shake 등도 같은 구조 안에 자연스럽게 녹일 수 있습니다. 

 

선언형 사고를 코드 구조로 풀기

 
1편에서는 선언적인 코드를 작성한다는 게 어떤 의미인지, 그리고 그 철학이 리액트 렌더링 구조와 어떻게 연결되는지를 살펴봤습니다.
 
이번 글에서는 그 연장선에서, 실제 코드의 흐름과 구조를 어떻게 선언적으로 바꿔볼 수 있을까라는 고민을 함수형 프로그래밍(FP)을 통해 풀어봤습니다.
 
물론 모든 상황에서 FP가 정답은 아니지만, 애니메이션처럼 흐름이 길고 재사용이 많은 구조에선 훨씬 깔끔하고 명확하게 정리할 수 있었고,
“코드가 그대로 설명이 되는 구조”가 주는 이점을 체감할 수 있었습니다. 앞으로도 복잡한 흐름을 설계할 때는

“이걸 함수 단위로 쪼갤 수 있을까?”
“조립 가능한 구조로 만들 수 있을까?”

 
같은 질문을 계속 던져보게 될 것 같습니다.
1편이 선언형 사고의 방향을 정리한 글이었다면, 이번 2편은 그 방향을 실제 코드 구조로 구현해 본 기록이었습니다.
 
읽어주셔서 감사합니다.
 

반응형