3장: 순수 함수로 순수한 행복을

아, 다시 순수해지는 것

우리가 분명히 해야 할 한 가지는 순수 함수의 개념입니다.

순수 함수란 동일한 입력을 받으면 항상 동일한 출력을 반환하고, 관찰 가능한 부작용이 없는 함수입니다.

slice와 splice를 예로 들어보겠습니다. 이 두 함수는 본질적으로 같은 작업을 하지만, 매우 다른 방식으로 수행합니다. slice는 매번 동일한 입력에 대해 동일한 출력을 반환하기 때문에 순수합니다. 반면 splice는 배열을 변경하여 항상 변화를 일으키므로 관찰 가능한 효과가 있습니다.

var xs = [1, 2, 3, 4, 5];

// 순수
xs.slice(0, 3);
//=> [1, 2, 3]

xs.slice(0, 3);
//=> [1, 2, 3]

xs.slice(0, 3);
//=> [1, 2, 3]

// 비순수
xs.splice(0, 3);
//=> [1, 2, 3]

xs.splice(0, 3);
//=> [4, 5]

xs.splice(0, 3);
//=> []
 

함수형 프로그래밍에서는 splice와 같은 데이터 변형 함수는 선호하지 않습니다. 우리는 항상 동일한 결과를 반환하는 신뢰할 수 있는 함수를 추구하기 때문에 splice와 같은 함수는 적합하지 않습니다.

다른 예를 보겠습니다.

// 비순수
var minimum = 21;

var checkAge = function(age) {
  return age >= minimum;
};

// 순수
var checkAge = function(age) {
  var minimum = 21;
  return age >= minimum;
};
 

비순수 코드에서는 checkAge가 결과를 결정하기 위해 변경 가능한 변수 minimum에 의존합니다. 즉, 시스템 상태에 의존하므로 외부 환경을 도입하여 인지 부하를 증가시킵니다.

이 예제에서는 큰 문제가 아닌 것처럼 보이지만, 상태 의존성은 시스템 복잡성을 증가시키는 주요 원인 중 하나입니다 . 이 checkAge 함수는 입력 외부의 요인에 따라 다른 결과를 반환할 수 있으며, 이는 순수하지 않을 뿐만 아니라 소프트웨어를 이해하는 데 부담을 줍니다.

순수한 형태에서는 완전히 독립적입니다. minimum을 변경할 수 없도록 만들어 순수성을 유지할 수도 있습니다. 이를 위해 객체를 생성하고 고정해야 합니다.

var immutableState = Object.freeze({
  minimum: 21,
});

SideEffect...

"SideEffect"을 더 살펴보고 직관을 개선해 봅시다. 순수 함수 정의에서 언급된 분명히 불길한 부작용이란 무엇일까요? 우리는 효과를 결과 계산 외에 발생하는 모든 것으로 정의할 것입니다.

효과 자체는 본질적으로 나쁘지 않으며, 다음 장에서 곳곳에서 사용할 것입니다. 문제는 SideEffect가 부정적인 의미를 가진다는 점입니다. 물은 자체로는 애벌레를 키우는 것이 아니지만, 정체된 물이 그렇듯이, 부작용도 프로그램에서 비슷한 문제를 일으킵니다.

SideEffect란 결과를 계산하는 동안 발생하는 시스템 상태의 변화 또는 외부 세계와의 관찰 가능한 상호작용입니다.

SideEffect의 예는 다음과 같습니다.

  • 파일 시스템 변경
  • 데이터베이스에 레코드 삽입
  • HTTP 호출
  • 데이터 변형
  • 화면 출력 / 로깅
  • 사용자 입력 받기
  • DOM 쿼리
  • 시스템 상태 접근

이 목록은 계속 이어집니다. 함수 외부와의 모든 상호작용은 부작용입니다. 이를 통해 부작용 없이 프로그래밍하는 것이 실용적이지 않을 수 있다고 의심할 수 있습니다. 함수형 프로그래밍 철학은 부작용이 잘못된 동작의 주요 원인이라고 가정합니다.

부작용을 사용하지 말라는 것이 아니라, 이를 제어된 방식으로 실행하고자 합니다. 나중 장에서 펑터와 모나드를 통해 이를 배우겠지만, 지금은 이러한 부정적인 함수를 순수한 함수와 분리해 보겠습니다.

부작용은 함수를 순수하지 않게 합니다. 이는 순수 함수가 동일한 입력에 대해 항상 동일한 출력을 반환해야 한다는 정의와 일치하지 않기 때문입니다.

왜 동일한 입력에 대해 동일한 출력을 고집하는지 자세히 살펴보겠습니다. 중학교 수학 수준으로 돌아가 봅시다.

중학교 수학

mathisfun.com에 따르면:

함수란 값들 사이의 특별한 관계입니다: 각 입력값은 정확히 하나의 출력값을 반환합니다.

 

즉, 함수는 입력과 출력 사이의 관계일 뿐입니다. 각 입력은 정확히 하나의 출력을 가지지만, 그 출력이 반드시 고유할 필요는 없습니다. 왼편은 x에서 y로의 완벽하게 유효한 함수의 다이어그램입니다;

 

 

대조적으로, 오른편의 다이어그램은 입력값 5가 여러 출력을 가리키기 때문에 함수가 아닌 관계를 보여줍니다:

 

 

함수는 입력과 출력의 쌍 집합으로 설명될 수 있습니다: [(1,2), (3,6), (5,10)] (이 함수는 입력을 두 배로 하는 것처럼 보입니다).

또는 표로 표현할 수 있습니다:

Input Output
1 2
2 4
3 6

 

또는 x를 입력으로, y를 출력으로 하는 그래프로 표현할 수도 있습니다:

입력이 출력을 결정하면 구현 세부 사항은 필요하지 않습니다. 함수는 입력에서 출력으로의 매핑일 뿐이므로, 객체 리터럴을 작성하고 () 대신 []로 실행할 수 있습니다.

var toLowerCase = {
  'A': 'a',
  'B': 'b',
  'C': 'c',
  'D': 'd',
  'E': 'e',
  'F': 'f',
};

toLowerCase['C'];
//=> 'c'

var isPrime = {
  1: false,
  2: true,
  3: true,
  4: false,
  5: true,
  6: false,
};

isPrime[3];
//=> true

물론, 직접 계산하고 싶을 수도 있지만, 이는 함수를 다른 방식으로 생각하는 것을 보여줍니다. (여러 인수를 가진 함수는 어떨까요? 물론, 이는 수학적으로 생각할 때 불편함을 줍니다. 지금은 배열로 묶거나 arguments 객체를 입력으로 생각할 수 있습니다. 커링을 배우면 수학적 함수 정의를 직접 모델링할 수 있습니다.)

여기서 중요한 점은: 순수 함수는 수학적 함수이며, 이는 함수형 프로그래밍의 핵심입니다. 이러한 작은 천사들로 프로그래밍하는 것은 큰 이점을 제공할 수 있습니다. 순수성을 유지하기 위해 많은 노력을 기울이는 이유를 살펴보겠습니다.

순수성을 지키는 이유

캐시 가능

먼저, 순수 함수는 입력에 따라 항상 캐시될 수 있습니다. 이는 일반적으로 메모이제이션이라는 기법을 사용하여 수행됩니다:

var squareNumber = memoize(function(x) {
  return x * x;
});

squareNumber(4);
//=> 16

squareNumber(4); // 입력 4에 대한 캐시 반환
//=> 16

squareNumber(5);
//=> 25

squareNumber(5); // 입력 5에 대한 캐시 반환
//=> 25

다음은 간단한 구현이지만, 더 강력한 버전도 많이 있습니다.

var memoize = function(f) {
  var cache = {};

  return function() {
    var arg_str = JSON.stringify(arguments);
    cache[arg_str] = cache[arg_str] || f.apply(f, arguments);
    return cache[arg_str];
  };
};

어떤 비순수 함수도 평가를 지연시켜 순수하게 만들 수 있습니다:

var pureHttpCall = memoize(function(url, params) {
  return function() {
    return $.getJSON(url, params);
  };
});
 

흥미로운 점은 실제 HTTP 호출을 하는 대신, 호출 시 특정 HTTP 호출을 할 함수를 반환한다는 것입니다. 이 함수는 동일한 입력에 대해 항상 동일한 출력을 반환하기 때문에 순수합니다: url과 params를 주어진 HTTP 호출을 수행할 함수입니다.

memoize 함수는 잘 작동하며, HTTP 호출의 결과를 캐시하지 않고, 생성된 함수를 캐시합니다.

이제까지는 크게 유용하지 않을 수 있지만, 곧 더 유용한 방법을 배울 것입니다. 중요한 점은 모든 함수를 순수하게 만들어 캐시할 수 있다는 것입니다.

이식 가능 / 자기 문서화

순수 함수는 완전히 독립적입니다. 함수가 필요로 하는 모든 것이 명시적으로 전달됩니다. 이는 어떻게 유리할까요? 우선, 함수의 의존성이 명시적이므로 더 쉽게 이해할 수 있습니다.

// 비순수
var signUp = function(attrs) {
  var user = saveUser(attrs);
  welcomeUser(user);
};

var saveUser = function(attrs) {
    var user = Db.save(attrs);
    ...
};

var welcomeUser = function(user) {
    Email(user, ...);
    ...
};

// 순수
var signUp = function(Db, Email, attrs) {
  return function() {
    var user = saveUser(Db, attrs);
    welcomeUser(Email, user);
  };
};

var saveUser = function(Db, attrs) {
    ...
};

var welcomeUser = function(Email, user) {
    ...
};
 

순수 함수는 의존성을 명확히 하며, 함수 시그니처만으로도 필요한 것들을 알 수 있습니다. 이는 프로그램을 이해하는 데 큰 도움이 됩니다.

순수 형태에서는 "의존성 주입"을 통해 데이터베이스나 메일 클라이언트를 인자로 전달하므로 더 유연한 애플리케이션을 만들 수 있습니다. 다른 데이터베이스를 사용하고 싶다면 함수 호출 시 이를 전달하면 됩니다.

자바스크립트 환경에서는 이식성이 함수 직렬화 및 소켓을 통한 전송을 의미할 수 있습니다. 이는 웹 워커에서 모든 애플리케이션 코드를 실행할 수도 있음을 의미합니다. 이식성은 강력한 특성입니다.

순수 함수는 공유 메모리에 접근할 필요가 없고, 부작용으로 인한 경쟁 상태가 없으므로 병렬로 실행할 수 있습니다.

요약

순수 함수가 무엇인지, 왜 함수형 프로그래머들이 순수함수를 중요하게 여기는지 살펴보았습니다. 앞으로 모든 함수를 순수하게 작성하려고 노력할 것입니다. 이를 위해 몇 가지 도구가 필요하지만, 그동안 순수한 코드와 그렇지 않은 코드를 분리해 보겠습니다.

순수 함수로 프로그래밍하는 것은 몇 가지 추가 도구 없이 다소 힘들 수 있습니다. 데이터를 인자로 전달해야 하고, 상태와 효과를 사용할 수 없습니다. 이러한 프로그래밍을 어떻게 할 수 있을까요? 다음 장에서는 커리를 배우며 이 문제를 해결해 보겠습니다.

'Functional Programming' 카테고리의 다른 글

mostly-adequate-guide-02  (1) 2024.07.16
mostly-adequate-guide-01  (2) 2024.07.16

2장: 일급 함수

빠른 복습

함수가 "일급"이라는 말은, 다른 것들과 다르지 않다는 뜻입니다. 즉, 일반 클래스와 같습니다. 우리는 함수를 다른 데이터 타입처럼 취급할 수 있으며, 그들에 대해 특별히 특별할 것은 없습니다. 함수는 배열에 저장될 수도 있고, 전달될 수도 있으며, 변수에 할당될 수도 있습니다.

이것은 자바스크립트의 기초이지만, GitHub에서 간단히 코드 검색을 해보면 이 개념을 회피하거나, 혹은 널리 무시하고 있다는 것을 알 수 있습니다. 가짜 예시를 하나 들어볼까요? 그렇게 해보죠.

var hi = function(name) {
  return 'Hi ' + name;
};

var greeting = function(name) {
  return hi(name);
};
 

여기서 greeting 함수의 hi를 감싼 부분은 완전히 중복입니다. 왜일까요? 자바스크립트에서 함수는 호출할 수 있기 때문입니다. hi가 끝에 ()가 있으면 실행되어 값을 반환합니다. 그렇지 않으면, 단순히 변수에 저장된 함수를 반환합니다. 확실히 하기 위해 확인해 보세요:

 
hi;
// function(name) {
//  return 'Hi ' + name
// }

hi('jonas');
// "Hi jonas"

greeting은 같은 인수로 hi를 호출할 뿐이므로, 단순히 다음과 같이 쓸 수 있습니다:

hi;
// function(name) {
//  return 'Hi ' + name
// }

hi('jonas');
// "Hi jonas"

즉, hi는 이미 하나의 인수를 기대하는 함수이므로, 동일한 인수로 hi를 호출하는 또 다른 함수를 둘러싸는 것은 말이 되지 않습니다. 그것은 7월 한가운데서 가장 무거운 파카를 입고 에어컨을 틀고 아이스바를 요구하는 것과 같습니다.

이것은 지나치게 장황하고, 평가를 지연시키기 위해 함수를 또 다른 함수로 둘러싸는 것은 나쁜 습관입니다. (이유는 곧 보겠지만, 유지보수와 관련이 있습니다.)

이해하기 어려운 부분이므로, 몇 가지 더 재미있는 예시를 살펴보겠습니다. npm 모듈에서 발굴된 것들입니다.

// 무지한 예시
var getServerStuff = function(callback) {
  return ajaxCall(function(json) {
    return callback(json);
  });
};

// 계몽된 예시
var getServerStuff = ajaxCall;

세상에는 이와 같은 ajax 코드가 널려 있습니다. 이 둘이 동일한 이유는 다음과 같습니다:

// 이 줄
return ajaxCall(function(json) {
  return callback(json);
});

// 이 줄과 같습니다
return ajaxCall(callback);

// 따라서 getServerStuff를 리팩토링합니다
var getServerStuff = function(callback) {
  return ajaxCall(callback);
};

// ...이것은 이와 같습니다
var getServerStuff = ajaxCall; // <-- 보세요, mum, 괄호가 없습니다

이것이 그렇게 하는 방법입니다. 다시 한 번 보죠, 왜 제가 이렇게 고집하는지 알 수 있습니다.

 
var BlogController = (function() {
  var index = function(posts) {
    return Views.index(posts);
  };

  var show = function(post) {
    return Views.show(post);
  };

  var create = function(attrs) {
    return Db.create(attrs);
  };

  var update = function(post, attrs) {
    return Db.update(post, attrs);
  };

  var destroy = function(post) {
    return Db.destroy(post);
  };

  return {
    index: index,
    show: show,
    create: create,
    update: update,
    destroy: destroy,
  };
})();

이 우스꽝스러운 컨트롤러는 99%가 쓸모없는 부분입니다. 우리는 다음과 같이 다시 쓸 수 있습니다:

var BlogController = {
  index: Views.index,
  show: Views.show,
  create: Db.create,
  update: Db.update,
  destroy: Db.destroy,
};

...혹은 아예 없애버릴 수도 있습니다. 이는 Views와 Db를 묶는 것 외에는 아무것도 하지 않기 때문입니다.

왜 일급 함수를 선호해야 하는가?

이제 일급 함수를 선호해야 하는 이유를 살펴보겠습니다. getServerStuff와 BlogController 예시에서 보았듯이, 실제로는 아무런 가치도 없고 유지보수와 검색을 위한 코드만 늘리는 간접층을 추가하기 쉽습니다.

게다가, 우리가 불필요하게 감싸고 있는 함수가 변경되면, 래퍼 함수도 변경해야 합니다.

var BlogController = {
  index: Views.index,
  show: Views.show,
  create: Db.create,
  update: Db.update,
  destroy: Db.destroy,
};

httpGet이 에러를 보낼 가능성이 생기면, 우리는 "접착제"를 변경해야 합니다.

 
// 애플리케이션의 모든 httpGet 호출로 돌아가서 명시적으로 err을 전달합니다.
httpGet('/post/2', function(json, err) {
  return renderPost(json, err);
});

일급 함수로 작성했더라면, 변경해야 할 부분이 훨씬 적었을 것입니다:

 
// renderPost는 httpGet 내에서 원하는 만큼의 인수를 사용하여 호출됩니다.
httpGet('/post/2', renderPost);

불필요한 함수를 제거하는 것 외에도, 우리는 인수를 이름짓고 참조해야 합니다. 이름에는 약간의 문제가 있습니다. 잠재적 오해의 소지가 있기 때문입니다 - 특히 코드베이스가 오래되고 요구사항이 변경될 때 그렇습니다.

프로젝트에서 동일한 개념에 여러 이름을 사용하는 것은 흔한 혼란의 원인입니다. 또한 일반적인 코드의 문제도 있습니다. 예를 들어, 이 두 함수는 정확히 같은 일을 하지만, 하나는 훨씬 더 일반적이고 재사용 가능하다고 느껴집니다:

// 현재 블로그에 특화됨
var validArticles = function(articles) {
  return articles.filter(function(article) {
    return article !== null && article !== undefined;
  });
};

// 미래의 프로젝트에 훨씬 더 관련됨
var compact = function(xs) {
  return xs.filter(function(x) {
    return x !== null && x !== undefined;
  });
};

특정한 명명을 사용함으로써, 우리는 특정 데이터(articles)에 스스로를 묶어버린 것처럼 보입니다. 이는 꽤 자주 발생하며, 많은 재발명의 원인이 됩니다.

객체 지향 코드와 마찬가지로, 여러분의 목을 물어뜯는 this를 조심해야 한다는 점을 언급해야 합니다. 기본 함수가 this를 사용하고 우리가 일급 함수로 호출하면, 우리는 이 누출되는 추상화의 분노에 노출됩니다.

 
var fs = require('fs');

// 무서운 예시
fs.readFile('freaky_friday.txt', Db.save);

// 덜 무서운 예시
fs.readFile('freaky_friday.txt', Db.save.bind(Db));

Db가 스스로에 묶여 있으면, 프로토타입적 쓰레기 코드를 자유롭게 접근할 수 있습니다. 저는 더러운 기저귀처럼 this를 사용하는 것을 피합니다. 함수형 코드를 작성할 때는 정말 필요하지 않습니다. 그러나 다른 라이브러리와 인터페이스할 때는, 우리 주변의 미친 세상에 순응해야 할 것입니다.

어떤 사람들은 this가 속도에 필요하다고 주장할 것입니다. 미세 최적화를 좋아하는 타입이라면, 이 책을 닫으십시오. 돈을 돌려받을 수 없다면, 더 복잡한 무언가로 교환할 수 있을 것입니다.

이제, 우리는 다음으로 넘어갈 준비가 되었습니다.

'Functional Programming' 카테고리의 다른 글

mostly-adequate-guide-03  (0) 2024.07.24
mostly-adequate-guide-01  (2) 2024.07.16

1장: 우리는 무엇을 하고 있는가?

소개

안녕하세요! 저는 프랭클린 리스비 교수입니다. 만나서 반갑습니다. 앞으로 우리가 함께 할 시간이 있을 겁니다. 저는 여러분에게 함수형 프로그래밍에 대해 조금 가르쳐야 합니다. 하지만 저에 대해서는 이쯤 하고, 여러분에 대해서는 어떤가요? 여러분이 자바스크립트 언어에 익숙하고, 약간의 객체 지향 경험이 있으며, 스스로를 평범한 프로그래머라고 생각하길 바랍니다. 곤충학 박사 학위가 필요하지는 않습니다. 버그를 찾고 고치는 방법만 알면 됩니다.

함수형 프로그래밍에 대한 사전 지식을 가정하지는 않겠습니다. 왜냐하면 우리가 잘 알고 있듯이, 가정하면 어떤 일이 벌어질지 알기 때문입니다. 하지만 저는 여러분이 변경 가능한 상태, 무제한적인 부작용, 원칙 없는 설계로 인해 발생하는 불리한 상황을 경험해 보았을 것이라고 기대합니다. 이제 제대로 소개를 했으니, 본격적으로 시작해 보겠습니다.

이 장의 목적은 함수형 프로그램을 작성할 때 우리가 추구하는 것이 무엇인지 감을 잡게 하는 것입니다. 프로그램을 함수형으로 만드는 것이 무엇인지 알지 못하면, 객체를 피하려고 무작정 낙서를 하게 될 것이고, 그것은 매우 어색한 일이 될 것입니다. 우리는 우리의 코드를 향해 던질 과녁이 필요하며, 물결이 거칠어질 때 우리의 길을 안내할 천체 나침반이 필요합니다.

프로그래밍의 일반적인 원칙들 - DRY(반복하지 말라), YAGNI(당신은 그것이 필요하지 않을 것이다), 낮은 결합도 높은 응집도, 최소한의 놀라움의 원칙, 단일 책임 원칙 등 - 이 있습니다. 저는 수년간 들어온 모든 지침들을 나열하지는 않겠습니다. 요점은 이 원칙들이 함수형 환경에서도 유효하다는 것입니다. 하지만 그것들은 우리의 목표에 있어 단지 부수적인 것들일 뿐입니다. 이제 우리는 키보드를 두드릴 때의 의도를 이해하고, 함수형 이상향을 추구해 보려 합니다.

짧은 만남

약간의 미친 짓으로 시작해 봅시다. 여기 갈매기 애플리케이션이 있습니다. 무리가 합쳐지면 더 큰 무리가 되고, 번식할 때는 번식하는 갈매기의 수만큼 증가합니다. 이제 이 코드는 객체 지향적으로 잘 작성된 코드가 아님을 명심하세요. 이 코드는 현대의 할당 기반 접근 방식의 위험성을 강조하기 위해 존재합니다. 보시죠:

 
var Flock = function(n) {
  this.seagulls = n;
};

Flock.prototype.conjoin = function(other) {
  this.seagulls += other.seagulls;
  return this;
};

Flock.prototype.breed = function(other) {
  this.seagulls = this.seagulls * other.seagulls;
  return this;
};

var flock_a = new Flock(4);
var flock_b = new Flock(2);
var flock_c = new Flock(0);

var result = flock_a.conjoin(flock_c)
    .breed(flock_b).conjoin(flock_a.breed(flock_b)).seagulls;
//=> 32

이런 끔찍한 괴물을 누가 만들었을까요? 내부 상태가 계속 변하는 것을 추적하는 것은 매우 어렵습니다. 게다가 답이 틀렸습니다! 원래 16이어야 했지만, flock_a는 과정에서 영구적으로 변경되었습니다. 불쌍한 flock_a. 이는 IT의 무정부 상태입니다! 이것은 야생 동물 산수입니다!

이 프로그램을 이해하지 못해도 괜찮습니다, 저도 이해하지 못합니다. 요점은 상태와 변경 가능한 값이 따라가기 어렵다는 것입니다, 심지어 이렇게 작은 예제에서도요.

더 함수형 접근 방식으로 다시 시도해 봅시다:

var conjoin = function(flock_x, flock_y) { return flock_x + flock_y; };
var breed = function(flock_x, flock_y) { return flock_x * flock_y; };

var flock_a = 4;
var flock_b = 2;
var flock_c = 0;

var result = conjoin(
  breed(flock_b, conjoin(flock_a, flock_c)), breed(flock_a, flock_b)
);
//=>16

이번에는 정답을 얻었습니다. 코드도 훨씬 적습니다. 함수 중첩이 약간 혼란스럽지만...(이 문제는 5장에서 해결할 것입니다). 더 나아졌지만, 더 깊이 파고들어 봅시다. 스페이드를 스페이드라 부르는 것의 이점이 있습니다. 그렇게 했다면 우리가 단순한 덧셈(conjoin)과 곱셈(breed)을 다루고 있다는 것을 알 수 있었을 것입니다.

이 두 함수에는 이름 외에는 특별한 것이 없습니다. 우리의 사용자 정의 함수를 그들의 진짜 정체로 이름을 바꿔 봅시다.

var add = function(x, y) { return x + y; };
var multiply = function(x, y) { return x * y; };

var flock_a = 4;
var flock_b = 2;
var flock_c = 0;

var result = add(
  multiply(flock_b, add(flock_a, flock_c)), multiply(flock_a, flock_b)
);
//=>16

이로써 우리는 고대의 지식을 얻었습니다:

// 결합법칙
add(add(x, y), z) === add(x, add(y, z));

// 교환법칙
add(x, y) === add(y, x);

// 항등원
add(x, 0) === x;

// 분배법칙
multiply(x, add(y,z)) === add(multiply(x, y), multiply(x, z));

아, 그렇습니다. 이 오래된 신뢰할 수 있는 수학적 특성들은 우리에게 유용할 것입니다. 이 특성들을 바로 기억해내지 못해도 걱정하지 마세요. 우리 중 많은 사람들이 이 정보를 검토한 지 오래되었을 것입니다. 이 특성을 사용하여 우리의 작은 갈매기 프로그램을 단순화할 수 있는지 봅시다.

// 원래의 코드
add(multiply(flock_b, add(flock_a, flock_c)), multiply(flock_a, flock_b));

// 항등원 특성을 적용하여 불필요한 add 제거
// (add(flock_a, flock_c) == flock_a)
add(multiply(flock_b, flock_a), multiply(flock_a, flock_b));

// 분배법칙을 적용하여 결과를 얻음
multiply(flock_b, add(flock_a, flock_a));

훌륭합니다! 우리는 호출 함수 외에 한 줄의 사용자 정의 코드도 작성할 필요가 없었습니다. 우리는 완전성을 위해 여기 add와 multiply 정의를 포함하지만, 실제로는 이들을 작성할 필요가 없습니다 - 이미 작성된 라이브러리에 add와 multiply가 있을 것입니다.

여러분은 "이렇게 수학적인 예제를 앞에 두다니 얼마나 교활한가" 혹은 "실제 프로그램은 이렇게 단순하지 않아서 이런 방식으로 논리적으로 접근할 수 없다"고 생각할 수 있습니다. 이 예제를 선택한 이유는 대부분의 사람들이 덧셈과 곱셈에 대해 이미 알고 있어서 수학이 여기서 우리에게 얼마나 유용할 수 있는지를 쉽게 이해할 수 있기 때문입니다.

실망하지 마세요 - 이 책 전반에 걸쳐 카테고리 이론, 집합 이론, 람다 계산법을 조금씩 섞어 사용하여 우리의 갈매기 예제와 같은 단순함과 결과를 달성하는 실제 예제를 작성할 것입니다. 여러분이 수학자가 될 필요는 없습니다. 일반적인 프레임워크나 API를 사용하는 것처럼 느껴질 것입니다.

함수형 프로그래밍의 아날로그와 같은 방식으로 일상적인 애플리케이션을 작성할 수 있다는 사실에 놀랄 수 있습니다. 우리는 확고한 특성을 가진 프로그램을 작성할 수 있습니다. 간결하면서도 이해하기 쉬운 프로그램을 작성할 수 있습니다. 매번 바퀴를 재발명하지 않는 프로그램을 작성할 수 있습니다. 무법은 범죄자에게는 좋지만, 이 책에서는 수학의 법칙을 인정하고 준수하고자 합니다.

모든 조각이 매우 정중하게 맞아 떨어지는 이론을 사용하고자 합니다. 우리는 우리의 특정 문제를 일반적이고 조합 가능한 조각들로 표현하고 그 특성을 이용하여 우리에게 유리하게 활용하고자 합니다. 이는 절제되지 않은 명령형 프로그래밍 접근 방식보다는 더 많은 규율을 요구할 것입니다 (명령형의 정확한 정의는 책 후반부에서 다룰 것이지만, 지금은 함수형 프로그래밍이 아닌 모든 것으로 간주합니다). 그러나 원칙적이고 수학적인 프레임워크 내에서 작업하는 보상은 여러분을 놀라게 할 것입니다.

우리는 함수형 북극성을 잠깐 보았지만, 본격적으로 여정을 시작하기 전에 몇 가지 구체적인 개념을 이해할 필요가 있습니다.

https://github.com/enshahar/mostly-adequate-guide-kr/blob/master/ch1.md

'Functional Programming' 카테고리의 다른 글

mostly-adequate-guide-03  (0) 2024.07.24
mostly-adequate-guide-02  (1) 2024.07.16

Server Action And Mutations

  • server action을 실행하면 현재 경로로 네트워크 POST 요청이 실행됩니다.

server-actions

 


useOptimistic(실험 기능) + server action

useOptimistic + useTransition을 이용한 UI 동시성 처리

'use client';

import { useOptimistic, useTransition } from 'react';

import {
  AccordionPanel,
  AspectRatio,
  Button,
  Flex,
  Input,
  InputGroup,
  InputRightElement,
  Text,
} from '@chakra-ui/react';

import {
  IndividualInquiryRetrieveType,
  IndividualReplyType,
} from '@/swagger/@types/data-contracts';

import ImageAsNext from '@/components/ImageAsNext';
import { MyPageTranslations } from '@/containers/MyPage/MyPage';

import { AnswerIcon } from '@/icons';

import { createReply } from '../actions/create-reply';
import useQnaReplyForm from '../hooks/useQnaReplyForm';

interface QnaDetailProps {
  data: IndividualInquiryRetrieveType | undefined;
  qnaTranslations: MyPageTranslations['main']['qna'];
}
const QnaDetail = ({ data, qnaTranslations }: QnaDetailProps) => {
  const {
    handleSubmit,
    reset,
    register,
    formState: { isDirty },
  } = useQnaReplyForm();

  const [isPending, startTransition] = useTransition();
  const [optimisticReplies, addOptimisticReplies] = useOptimistic(
    // 초기 데이터 설정
    data?.replySet,
    (
      state: IndividualReplyType[] | undefined,
      newReply: IndividualReplyType,
    ) => {
      if (state) {
        state.push(newReply);
        return [...state];
      }
      return [newReply];
    },
  );

  const onSubmit = handleSubmit((res) => {
    if (!data) return;
    startTransition(async () => {
    // useOptimistic에서 반환한 addOptimisticReplies 로 낙관 업데이트 가능
    // 사용자의 작업을 실시간 반영
      addOptimisticReplies({
        id: optimisticReplies ? optimisticReplies[0].id + 1 : 0,
        isAdmin: false,
        body: res.reply,
        createdAt: res.createdAt,
      });
      reset();
      await createReply({
        data: {
          individualInquiry: data.id,
          body: res.reply,
        },
      });
    });
  });

  return (
    <AccordionPanel p="8px 16px" minH="65px">
      <Flex direction="column">
        {/* 답변 */}
        {data?.inquiryState === 2 && (
          <Flex direction="column">
            {optimisticReplies?.map(({ id, isAdmin, body }) => {
              return (
                <Flex
                  key={reply.id}
                  p={{ base: '0 0 20px', sm: '0 16px 20px' }}
                  gap="8px"
                >
                  <AnswerIcon boxSize="24px" fill="none" />
                  {isAdmin && (
                    <Text color="fanRed.500" textStyle="Body1_E">
                      {qnaTranslations.reply.admin}
                    </Text>
                  )}
                  <Text textStyle="Body1_R" whiteSpace="pre">
                    {body}
                  </Text>
                </Flex>
              );
            })}
          </Flex>
        )}
      </Flex>
    </AccordionPanel>
  );
};

export default QnaDetail;

useOptimistic + useTransition을 이용한 UI 동시성 처리

server action과 react api들을 조합해서 사용했을 때 UI 동시성 처리에 용이합니다.


server actions와 함께 useActionState, useFormStatus를 사용하면 react-hook-form을 대체할 수 있을까?

useActionState와 useFormStatus를 사용하면 자바스크립트가 실행되기 이전에 폼과 상호작용 하도록 할 수 있다는 큰 장점이 있습니다.
useActionState를 통하여 초기 상태 및 업데이트된 상태 표시가 가능하며, server action을 통한 응답 결과를 통해서 성공 및 에러 상태 표시가 가능합니다. 또한 useFormStatus의 pending을 통한 button disabled 및 loading 처리 또한 가능합니다.
위의 훅과 서버액션 조합을 사용하면 Core Web Vital 중 INP 수치가 향상되어 SEO에 좋은 영향을 줄 것 같습니다.

하지만 react-hook-form에서 제공하는 isValid, isDirty 등의 상태는 직접 구현해야 할 것으로 보이며 이벤트 핸들러 기반의 즉각적인 validation을 해야 할 경우라면 react-hook-form을 대체하긴 어려워 보입니다.

 


서버액션을 사용할 때 react에서 제공하는 API인 useTransition useOptimistic 등의 훅을 함께 사용하여 UI 동시성 처리가 가능하여 유저 경험을 향상할 수 있습니다.

서버액션은 내 현재 경로로 POST 요청이 가기 때문에 필요시에만 사용하는 것을 권장합니다.

 

📌 Only plain objects can be passed to Client Components from Server Components ⇒ server action의 반환값은 plain objects 이어야 합니다. 응답 전문을 그대로 클라이언트로 넘기면 안 됩니다.

react@canary에서 소개된 API들은 react 19에서 정식으로 릴리즈 됩니다.

React Server Component

정적 렌더링

정적 렌더링을 사용하면 경로는 빌드 시 렌더링 되거나 revalidate 후 백그라운드에서 렌더링 됩니다.

vercel을 사용하여 배포하면 개발자가 설정한 vercel function region (CDN)에 캐시 된 정적 콘텐츠가 푸시되며 해당 region에서 콘텐츠를 가져옵니다.


동적 렌더링

정적 렌더링은 빌드 시 렌더링 되지만, 동적 렌더링은 사용자가 요청 시 렌더링 됩니다.

여기에서 설명한 것처럼 cookies(), headers(), noStore() 등의 동적 함수를 만나면 동적 렌더링으로 전환됩니다.


스트리밍

  • html 스트리밍은 서버에서 콘텐츠가 다운로드될 때까지 웹 페이지를 사용자에게 점진적으로 렌더링 하는 기술입니다. 페이지의 모든 콘텐츠가 완전히 로드될 때까지 기다리는 대신, 일부 콘텐츠가 로드되면 사용자에게 더 빠르게 보여줄 수 있도록 합니다.

Suspense와 통합

import { Suspense } from 'react';

import { productList } from '@/apis/product/productApi';

import {
  PaginatedCategoryListType
} from '@/swagger/@types/data-contracts';

import ShopLayout from '@/components/@Layout/ShopLayout';
import ShopCategory from '@/components/Shop/ShopCategory';
import ShopCategorySkeleton from '@/components/Shop/ShopCategorySkeleton';
import ShopItemList from '@/components/Shop/ShopItemList';
import ShopItemSkeleton from '@/components/Shop/ShopItemSkeleton';

export default function ShopPage() {
  return (
    <ShopLayout>
      <Suspense fallback={<ShopCategorySkeleton />}>
	    // server component   
        <ShopCategory />
      </Suspense>
      <Suspense
        key={shopQueryParams.toString()}
        fallback={<ShopItemSkeleton listLength={12} />}
      >
        <ShopItemList
          productListPromise={productList({
            query,
          })}
        />
      </Suspense>
    </ShopLayout>
  );
}
import { CategoryType } from '@/swagger/@types/data-contracts';

export default async function ShopCategory() {
  const categories = categoryList({ query: { category_type: '1' } })
  return (
    <>
     {JSON.stringify(categories)}
    </>
  );
};

 

suspense

비동기 작업(data fetching.. 등)을 수행하는 동안 React Suspense의 fallback으로 대체 UI를 표시할 수 있습니다.

가령 A, B, C 컴포넌트가 각 각 다른 비동기 작업을 처리한다면, 모든 비동기 작업이 끝날 때까지 기다리지 않고 작업이 완료되는 순서대로 hydration을 시작할 수 있습니다.

suspense chunk

요청에 대한 응답 (분할된 chunk)을 점진적으로 서버에서 클라이언트로 전송합니다.

📌 Suspense는 일부 요청을 병렬화 시켜 전체 응답이 완료될 때까지 기다리지 않고 데이터 스트림을 수신하여 클라이언트 렌더링을 차단하지 않고, network waterfall를 방지합니다.
📌 loading.tsx를 추가해서 해당 경로의 모든 비동기 작업이 끝날 때까지 fallback UI를 표시할 수 있지만 suspense를 비동기 작업 단위로 감싸는 것이 유저 경험에 좋습니다.

 


부분 사전 렌더링

  • 정적 및 동적 콘텐츠가 혼합된 페이지의 경우, 전체가 동적으로 렌더링 되는 것이 아니라, 정적 및 동적 렌더링을 혼합해서 사용할 수 있습니다.
  • 현재 이 기능은 next.js 실험적인 기능으로, 아직 공식적으로 출시되지 않았습니다. 이 렌더링 모델을 사용하려면 동적 콘텐츠를 Suspense로 감싸기만 하면 됩니다. 이렇게 하면 PPR이 안정적 버전으로 변경될 때, 소스코드의 큰 변경 없이 기능의 이점을 누릴 수 있습니다.
  • PPR을 사용하려면 next.config.js 에서 아래와 같이 설정할 수 있습니다.
/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    ppr: true,
  },
}
 
module.exports = nextConfig

 

2024.06.19 - [Next.js] - Next.js에서 Data Fetching & Caching 알아보기

이전 글에서는 Next.js에서 data fetching과 caching에 대해서 다뤘습니다.


이번 글에서는 Next.js 에서 데이터 페칭 시 고민한 문제들에 대해서 이야기해 보겠습니다.

App Router를 사용하면서 기존에 사용하던 tanstack-query를 제거하고 오직 fetch 만을 사용하여 데이터 캐시 관리를 했습니다.

  1. fetch의 기능이 강력하므로 tanstack-query가 꼭 필요하지 않았습니다.
  2. tanstack-query 공식 문서에서 아래의 권장사항을 발견했습니다.

It's hard to give general advice on when it makes sense to pair React Query with Server Components and not. If you are just starting out with a new Server Components app, we suggest you start out with any tools for data fetching your framework provides you with and avoid bringing in React Query until you actually need it. This might be never, and that's fine, use the right tool for the job!

🤔 위와 같은 이유로 라이브러리 도움 없이 fetch를 사용하다가 다음과 같은 고민이 생겼습니다.

 


1. API 호출 비용을 최대한 줄이면서 유저에게 빠르게 데이터를 보여줄 수 있는 방법이 있을까?

  • 이전 글에서 언급한 대로, 개인화된 요청은 원격 서버에 캐싱되어서는 안 됩니다. 따라서 fetch를 사용할 때 no-store 옵션을 설정합니다. 이 경우, 새로고침 및 라우트 캐시가 만료될 때마다 API 호출이 발생하므로 client-side에서 tanstack-query를 사용하여 브라우저 메모리에 개인화된 요청에 대한 응답을 캐시하고 queryKey와 staleTime으로 캐시를 관리합니다.
  • 개인화되지 않은 요청은 server-side에서 force-cache 옵션을 사용하여 API를 호출하고, 적절한 방법을 통해 갱신합니다.
  개인화 되지 않은 요청  개인화된 요청
server-side fetch x
client-side x tanstack-query

1번 고민에 대한 결과를 정리하면, 개인화된 요청은 검색 엔진에 노출될 필요가 없으므로 client-side에서 tanstack-query로 요청 후 캐시를 관리합니다.

그 반대의 경우 server-side에서 fetch 하면 첫 번째 요청 시 데이터가 원격 서버에 캐싱되며 Suspense의 fallback을 이용하여 유저에게 데이터를 가져오는 중임을 알립니다. 두 가지 방법을 적절히 섞어 사용하면 API 호출 비용을 줄이고, 유저에게 빠르게 데이터를 보여줄 수 있습니다.

이때, 2번의 고민이 발생합니다.


2. server-side에서 fetching 한 데이터를 사용하는 곳까지 props drilling 하는 것을 최소화할 수 있는 방법이 있을까?

props drilling 방식으로 데이터를 전달하면 상위 컴포넌트가 변경되면 하위 컴포넌트까지 변경해야 하는 문제가 있습니다. 이는 유지보수 관점에서 좋지 않습니다.

 

2-1. tanstack-query를 사용하여 prefetch 및 de/hydrating 하기

개발환경

(next14.2.2, react18^, typescript^5.1.3, tanstack-query^5.40.0)

초기 설정

import { Query, defaultShouldDehydrateQuery } from "@tanstack/react-query";

const queryClientOptions = {
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5,
      retry: 1,
      refetchOnMount: false,
      refetchOnWindowFocus: false,
    },
    dehydrate: {
      // per default, only successful Queries are included,
      // this includes pending Queries as well
      shouldDehydrateQuery: (query: Query) =>
        defaultShouldDehydrateQuery(query) || query.state.status === "pending",
    },
  },
};

export default queryClientOptions;

import { QueryClient } from "@tanstack/react-query";
import queryClientOptions from "@/configs/tanstack-query/query-client-options";

function makeQueryClient() {
  return new QueryClient(queryClientOptions);
}

let browserQueryClient: QueryClient | undefined = undefined;
export default function getQueryClient() {
  if (typeof window === "undefined") {
    // Server: always make a new query client
    return makeQueryClient();
  } else {
    // Browser: make a new query client if we don't already have one
    // This is very important so we don't re-make a new client if React
    // suspends during the initial render. This may not be needed if we
    // have a suspense boundary BELOW the creation of the query client
    if (!browserQueryClient) browserQueryClient = makeQueryClient();
    return browserQueryClient;
  }
}

"use client";

import { PropsWithChildren } from "react";
import { QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import getQueryClient from "@/configs/tanstack-query/get-query-client";

function AppProvider({ children }: PropsWithChildren) {
  const queryClient = getQueryClient();
  return (
    <QueryClientProvider client={queryClient}>
      {children}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

export default AppProvider;

Prefetch + de/hydrating data

서버에서는 마크업을 생성/렌더링 하기 전에 data를 prefetch 하고, 해당 데이터를 마크업에 포함할 수 있는 직렬화 가능한 형식으로 de/hydration 하며, 클라이언트에서는 해당 데이터를 React 쿼리 캐시로 hydration 합니다.

PhotoPrefetchQuery 클래스를 생성하고, QueryClient 인스턴스를 받습니다.

import { QueryClient } from "@tanstack/react-query";
import {
  RequestFnReturn,
  QueryHookParams,
} from "../@types/tanstack-query-type";
import { todoApi } from "./Todo.api";
import { QUERY_KEY_TODO_API } from "./Todo.query";

export class TodoPrefetchQuery {
  private queryClient: QueryClient;

  constructor(queryClient: QueryClient) {
    this.queryClient = queryClient;
  }

  /**
   * No description
   *
   * @tags todos
   * @name TodosList
   * @summary Todos 목록 조회
   * @request GET:/todos
   * @secure */

  public useTodoListPrefetchQuery = <
    TData = RequestFnReturn<typeof todoApi.todoList>
  >(
    params?: QueryHookParams<typeof todoApi.todoList, TData>
  ) => {
    const queryKey = QUERY_KEY_TODO_API.LIST(params?.variables);
    return this.queryClient.prefetchQuery({
      queryKey,
      queryFn: () => todoApi.todoList(params?.variables),
      ...params?.options,
    });
  };

  /**
   * No description
   *
   * @tags todos
   * @name TodosRetrieve
   * @summary Todos 상세 조회
   * @request GET:/todos/{id}
   * @secure */

  public useTodoRetrievePrefetchQuery = <
    TData = RequestFnReturn<typeof todoApi.todoRetrieve>
  >(
    params: QueryHookParams<typeof todoApi.todoRetrieve, TData>
  ) => {
    const queryKey = QUERY_KEY_TODO_API.RETRIEVE(params.variables);
    return this.queryClient.prefetchQuery({
      queryKey,
      queryFn: () => todoApi.todoRetrieve(params.variables),
      ...params?.options,
    });
  };
}

"use server";

import { TodoPrefetchQuery } from "@/apis/Todo/Todo.prefetchQuery";
import {
  HydrationBoundary,
  QueryClient,
  dehydrate,
} from "@tanstack/react-query";
import QueryTodoList from "@/components/Todo/QueryTodoList";

export default async function HydratedTodoList() {
	// Next.js는 이미 fetch()를 활용하는 요청을 중복 제거하므로 data를 fetch 하는 각 서버 컴포넌트에
	// 새로운 queryClient를 만듭니다.
  const queryClient = new QueryClient();
  const todoPrefetchQuery = new TodoPrefetchQuery(queryClient);
  todoPrefetchQuery.useTodoListPrefetchQuery({
    variables: {
      params: {
        cache: "force-cache",
      },
    },
  });

  return (
    // Neat! Serialization is now as easy as passing props.
    // HydrationBoundary is a Client Component, so hydration will happen there.
    <HydrationBoundary state={dehydrate(queryClient)}>
      <QueryTodoList isSuspense />
    </HydrationBoundary>
  );
}

"use client";

import { useTodoListQuery } from "@/apis/Todo/Todo.query";
interface QueryTodoListProps {
  isSuspense?: boolean;
}
const QueryTodoList = ({ isSuspense }: QueryTodoListProps) => {	
  // This useQuery could just as well happen in some deeper
  // child to <TodoList>, data will be available immediately either way
  const { data: todos } = useTodoListQuery({
    options: {
      staleTime: 1000 * 5,
      suspense: isSuspense,
    },
  });

  return (
    <div className="flex flex-col gap-2">
      {todos?.map(({ userId, title, completed }) => (
        <div
          key={`${userId}_${title}`}
          className="flex flex-col h-36 rounded border border-gray-400 justify-center p-5"
        >
          <p>userId: {userId}</p>
          <p>title: {title}</p>
          <p>completed: {String(completed)}</p>
        </div>
      ))}
    </div>
  );
};
import ListSkeleton from "@/components/ListSkeleton";
import ParentA from "app/dehydrate-with-streaming/_source/components/ParentA";
import HydratedPhotoList from "app/dehydrate-with-streaming/_source/components/HydratedPhotoList";
import Link from "next/link";
import { Suspense } from "react";

export default async function DehydrateWithStreamingPage() {
  return (
    <div className="flex flex-col items-center p-6">
      <p className="text-5xl font-bold mb-6">dehydrate with streaming</p>
      <Link href="/" className="text-blue-500 mb-8 hover:underline">
        Go to Home
      </Link>
      <div className="flex w-full max-w-screen-xl gap-10 justify-center flex-wrap">
        <div className="flex flex-col items-center w-full max-w-md">
          <p className="text-2xl font-medium mb-4">Todo List</p>
          <Suspense fallback={<ListSkeleton />}>
            <ParentA />
          </Suspense>
        </div>
        <div className="flex flex-col items-center w-full max-w-md">
          <p className="text-2xl font-medium mb-4">Photo List</p>
          <Suspense fallback={<ListSkeleton />}>
            <HydratedPhotoList />
          </Suspense>
        </div>
      </div>
    </div>
  );
}

"use server";
import ParentB from "./ParentB";

export default function ParentA() {
  return <ParentB />;
}

///////////////////////////////////////////////////////////
"use server";
import ParentC from "./ParentC";

export default function ParentB() {
  return <ParentC />;
}

///////////////////////////////////////////////////////////
"use server";
import HydratedTodoList from "./Todo/hydratedTodoList";

export default function ParentC() {
  return <HydratedTodoList />;
}

ParentC 컴포넌트(사용하는 곳과 가까운 위치)에서 prefetch 및 de/hydrate를 수행하여 props drilling을 피하고 코드를 더 깔끔하게 유지할 수 있습니다.

하지만 이 방법은 보일러 플레이트 코드가 길어지고, fetch와 tanstack-query 두 곳에서 캐시 관리를 해줘야 하는 단점이 있습니다.

그렇다면 좀 더 좋은 방법이 있을까요?

 

2-2. 서버와 클라이언트 컴포넌트 인터리빙 하기

이 접근 방식을 사용하면 <ClientComponent>및 <ServerComponent>가 분리되어 독립적으로 렌더링 될 수 있습니다.

이 경우 children은 클라이언트에서 렌더링 되기 훨씬 전에 서버에서 렌더링 될 수 있습니다.

import { Suspense } from "react";
import { revalidateTag } from "next/cache";
import Link from "next/link";

import ClientComponent from "app/fetch-with-streaming/_source/components/ClientComponent";
import ListSkeleton from "@/components/ListSkeleton";
import FetchPhotoList from "@/components/Photo/FetchPhotoList";
import PromiseResolveHelper from "@/components/PromiseResolveHelper";
import RevalidateButton from "app/fetch-with-streaming/_source/components/RevalidateButton";
import FetchTodoList from "@/components/Todo/FetchTodoList";
import { todoApi } from "@/apis/Todo/Todo.api";
import { PhotoType } from "@/apis/Todo/types/model/photo";
import { photoApi } from "@/apis/Photo/Photo.api";

export default async function FetchWithStreamingPage() {
  async function revalidateTodo() {
    "use server";
    revalidateTag("TODO_LIST");
  }

  return (
    <div className="flex flex-col items-center p-6">
      <p className="text-5xl font-bold mb-6">fetch with streaming</p>
      <Link href="/" className="text-blue-500 mb-6">
        go to example list
      </Link>
      <ClientComponent>
        <div className="flex w-full max-w-screen-xl gap-10 justify-center flex-wrap">
          <div className="flex flex-col items-center w-full max-w-md">
            <div className="flex flex-col items-center gap-4 w-full mb-4">
              <p className="text-2xl font-medium">Todo List</p>
              <div className="flex gap-2">
                <RevalidateButton revalidate={revalidateTodo} />
              </div>
            </div>
            <Suspense fallback={<ListSkeleton />}>
              <FetchTodoList
                todosPromise={todoApi.todoList({
                  params: {
                    cache: "force-cache",
                  },
                })}
              />
            </Suspense>
          </div>
          <div className="flex flex-col items-center w-full max-w-md">
            <p className="text-2xl font-medium mb-4">Photo List</p>
            <Suspense fallback={<ListSkeleton />}>
              <FetchPhotoList />
            </Suspense>
          </div>
        </div>
      </ClientComponent>
    </div>
  );
}
"use client";

import { Fragment, PropsWithChildren, useState } from "react";

export default function ClientComponent({ children }: PropsWithChildren) {
  const [count, setCount] = useState(0);
  return (
    <Fragment>
      <p>{count}</p>
      <button
        className="bg-blue-500 text-white px-4 py-2 rounded"
        onClick={() => setCount((prev) => prev + 1)}
      >
        up
      </button>
      {children}
    </Fragment>
  );
}

use를 사용하여 클라이언트 컴포넌트에서 프로미스 해제하기

컴포넌트 내부에서 아래 항목을 사용하면 클라이언트 컴포넌트로 전환되어 use를 사용하여 프로미스를 해제합니다.

  • Event listeners
  • State and Lifecycle Effects
  • Use of browser-only APIs
  • Custom hooks that depend on state, effects, or browser-only APIs
  • React Class components
"use client";

import { TodoType } from "@/apis/Photo/types/model/todo";
import { use } from "react";

interface FetchTodoListProps {
  todosPromise: Promise<TodoType[]>;
}

const FetchTodoList = ({ todosPromise }: FetchTodoListProps) => {
  // Streaming data from the server to the client
  const todos = use(todosPromise);
  return (
    <div className="flex flex-col gap-2">
      {todos?.map(({ userId, title, completed }) => (
        <div
          key={`${userId}_${title}`}
          className="flex flex-col h-36 rounded border border-gray-400 justify-center p-5"
        >
          <p>userId: {userId}</p>
          <p>title: {title}</p>
          <p>completed: {String(completed)}</p>
        </div>
      ))}
    </div>
  );
};

export default FetchTodoList;

서버 컴포넌트에서 프로미스 해제하기

서버 컴포넌트를 사용할 수 있는 제약 조건에 위배되지 않는 경우 서버에서 프로미스를 해제합니다. 해당 코드는 자바스크립트 번들에 포함되지 않습니다.

import { PhotoType } from "@/apis/Todo/types/model/photo";
import Image from "next/image";

/**
 * @description
 * Restricted Items in Server Component
 *
 * - Event listeners
 * - State and Lifecycle Effects
 * - Use of browser-only APIs
 * - Custom hooks that depend on state, effects, or browser-only APIs
 * - React Class components
 */
export default async function FetchPhotoList() {
  const photos = await photoApi.photoList();
  return (
    <div className="flex flex-col gap-2">
      {photos?.map(({ id, albumId, title, url }) => (
        <div
          key={id}
          className="flex flex-row h-36 border border-gray-400 rounded justify-between p-5 items-center gap-4"
        >
          <div className="flex flex-col justify-between flex-1 h-full">
            <p>albumId: {albumId}</p>
            <p>title: {title}</p>
          </div>
          <div className="relative w-16 h-16">
            <Image src={url} alt="image" fill sizes="60px" />
          </div>
        </div>
      ))}
    </div>
  );
}

결론

여러 가지 시행착오를 겪은 후 fetch와 tanstack-query를 Win-Win 하면서 사용하는 방법은 아래와 같습니다.

  • Case 1 - Authorization을 포함한 GET 요청
    • fetch cache option no-store + tanstack-query staleTime 설정 및 Client-Side에서 useQuery 사용을 권장합니다.
  • Case 2 - Authorization을 포함하지 않는 GET 요청
    • fetch를 독립적으로 사용하는 것을 권장합니다.
  • Case 3 - mutate 요청
    • fetch cache option no-store + Client-Side에서 useMutation 사용을 권장합니다.
  • Case 4 - props drilling 깊은 컴포넌트
    • 서버와 클라이언트 컴포넌트의 InterLeaving을 권장합니다.
    • tanstack-query Dehydrate
      • 해당 방식은 사용 가능하지만, 보일러 플레이트 코드가 길어지고, 별도의 캐시 관리가 필요하므로 권장하지 않습니다.
  • 다만 Case 1Case 3의 경우 상황(ex. 다국적 서비스)에 따라서 모두 fetch 만을 사용할 수도 있을 것 같습니다.
    만약 백엔드와 프런트엔드 서버가 서울이고 엔드 유저가 해외에 있거나 네트워크 환경이 좋지 않다면 latency가 발생합니다.
    이 경우 fetch나 server action을 사용하면 내 서버에 요청하기 때문에 latency가 줄어듭니다.

이와 같이, 상황에 맞게 적절한 전략을 선택할 수 있을 것 같습니다.

 

좀 더 자세한 코드는 아래에서 확인하실 수 있습니다. 

https://github.com/Eunkyung-Son/fetch-with-tanstack-query-practice

1. Fetching

개인화되지 않은 요청

  • fetch의 기본 캐시 옵션은 force-cache로 요청의 응답이 원격 서버에 캐싱됩니다
    • force-cache는 개인화되지 않고 GET 메서드로 요청할 때에만 사용합니다.

개인화된 요청

‼ 기존에는 tanstack-query를 사용하여 client-side에서 백엔드 API 호출 시 사용자의 네트워크 비용이 발생했습니다. 그러나 서버 컴포넌트에서 fetch를 사용한 요청은 프런트 서버 비용이 발생합니다. 캐시 관리를 효율적으로 하지 않으면, 불필요한 프론트 서버 비용과 백엔드 API 호출 비용이 동시에 발생할 수 있습니다.

 


 

2. Caching

Data Cache

💡 Next.js 에서는 서버 요청 및 배포 전반에 걸쳐 fetch 요청에 대한 결과를 유지하는 데이터 캐시가 내장되어 있습니다.
이 데이터 캐시는 클라이언트 캐시가 아닌 서버 캐시를 의미합니다.

 

 

Duration

fetch의 캐시 옵션을 force-cache로 설정 후 배포하게 되면, revalidate 하거나 opt-out 하지 않는 이상 모든 배포에서 캐싱된 데이터가 유지됩니다.

  • Vercel에서 Project 캐시를 삭제하는 방법

Vercel Purge Cache
Purge Everything 버튼을 클릭해 캐시된 데이터를 삭제할 수 있습니다. 하지만 적절한 갱신 방법을 통해 API를 갱신하는 것이 바람직합니다.

 

Revalidating

revalidate 요청이 들어오면 Vercel 인프라의 게이트웨이에서 서버리스 함수를 호출하여 revalidate 후 새로운 데이터를 반환합니다.

 

Time-Based

revalidate 주기를 설정하여 API를 백그라운드에서 revalidate 시킬 수 있습니다.

// Revalidate at most every hour
  fetch('https://...', { next: { revalidate: 3600 } })
  

 

On-Demand

  • revalidateTag를 이용하여 사용자의 동작에 따라 API를 백그라운드에서 revalidate 할 수 있습니다.
fetch(
    `https://.../v1/individual_inquiry/?${individualInquiryQueryParams?.toString()}`,
    {
      next: {
        revalidate: 60,
        // fetch tags에 api 고유의 키 + query params 조합의 키 추가
        tags: [FETCH_TAGS_INDIVIDUAL_INQUIRY_API.LIST,`${
            FETCH_TAGS_INDIVIDUAL_INQUIRY_API.LIST
          }${individualInquiryQueryParams?.toString()}`],
      },
    },
  );
  • FETCH_TAGS_INDIVIDUAL_INQUIRY_API.LIST가 삽입된 모든 API revalidate
revalidateTag(FETCH_TAGS_INDIVIDUAL_INQUIRY_API.LIST)

 

 

  • FETCH_TAGS_INDIVIDUAL_INQUIRY_API.LIST + query params 가 삽입된 API revalidate
revalidateTag(`${FETCH_TAGS_INDIVIDUAL_INQUIRY_API.LIST
  }${individualInquiryQueryParams?.toString()}`)
  


  • revalidatePath를 이용하여 해당 경로의 데이터 캐시와, 경로 캐시를 revalidate 할 수 있습니다.

revalidatePath('/ko');
  


  • route handlers를 생성하고 API 갱신하는 로직을 구현합니다.
  • CMS에서 새로운 콘텐츠가 업로드될 때 route API(ex. {FRONT_DOMAIN}/api/revalidate/{revalidateName})를 호출하여 데이터를 갱신합니다.
  • 해당 방법은 읽기 요청이 쓰기(갱신 후 data cache set)요청보다 많을 때 api 요청 비용이 효과적으로 절감됩니다.

 

import { revalidateTag } from 'next/cache';
  import { NextRequest } from 'next/server';

  import { ENV } from '@/configs/env';

  const API_KEY = ENV['X-API-KEY'];

  async function invalidateCache(revalidateName: string, id: string | null) {
    if (id) {
      revalidateTag(`${revalidateName}${id}`);
      revalidateTag(revalidateName);
    } else {
      revalidateTag(revalidateName);
    }

    return new Response(JSON.stringify({}), {
      status: 200,
      statusText: 'success',
    });
  }

  async function invalidRequest() {
    return new Response(JSON.stringify({}), {
      status: 401,
      statusText: 'unauthorized',
    });
  }

  export async function DELETE(
    req: NextRequest,
    {
      params,
    }: {
      params: {
        revalidateName: string;
      };
    },
  ) {
    const isValidApiKey = req.headers.get('X-API-KEY') === API_KEY;

    if (!isValidApiKey) {
      return invalidRequest();
    }

    const { revalidateName } = params;

    const searchParams = new URLSearchParams(req.nextUrl.searchParams);
    const id = searchParams.get('id');

    try {
      return invalidateCache(revalidateName, id);
    } catch (error) {
      return new Response(JSON.stringify({}), {
        status: 500,
        statusText: 'internal server error',
      });
    }
  }

 

Opting out

 

  • 요청 단위의 데이터 캐시 해제
// Opt out of caching for an individual `fetch` request
  fetch(`https://...`, { cache: 'no-store' })
  
  • 경로 단위의 모든 요청 데이터 캐시 해제
// Opt out of caching for all data requests in the route segment
  export const dynamic = 'force-dynamic'
  

 

Request Memoization

  • fetch를 사용할 때 동일한 url & option의 Request를 자동으로 메모합니다.
  • layout page component generateMetadata generateStaticParams에서 같은 요청 시 첫 번째 Request에서 캐싱되고, 후속 Request 시 함수를 실행하지 않고 메모리에서 반환합니다.
  • fetch를 사용한 Request Memoization은 GET method에서만 적용됩니다.
// a
  <Suspense fallback={...}>
      <CelebSection
        data={celebList({
          query: celebQueryParams,
        })}
      />
  </Suspense>
        
  // a
  <Suspense fallback={...}>
      <CelebSection
        data={celebList({
          query: celebQueryParams,
        })}
      />
  </Suspense>
        
  // a-1
  <Suspense fallback={...}>
      <CelebSection
          data={celebList({
            query: celebQueryParams1,
          })}
      />
  </Suspense>

같은 경로에서 a api 2번 호출, query params가 다른 a-1 api 1번 호출

Duration

요청 메모는 서버 요청 간에 공유되지 않고 React 컴포넌트 트리 안에서 렌더링 중에만 적용되므로 재검증할 필요가 없습니다.

Opting out

fetch를 사용한 요청에서 아래의 방법으로 요청 메모를 해제할 수 있습니다.

const { signal } = new AbortController()
  fetch(url, { signal })
  

+ Recent posts