─── Toy Project/Firebase - FriendlyEats

11. 트랜잭션으로 데이터 쓰기

Mary's log 2024. 10. 12. 19:15

 


Firebase Codelab :  11. 트랜잭션으로 데이터 쓰기

Firebase github    :  View Source 

 

사용자가 레스토랑에 리뷰를 제출할 수 있는 기능...

리뷰를 제출한 다음 음식점의 평점 count 및 average rating을 업데이트...

둘 중 하나가 실패하고 다른 하나는 실패하지 않으면 데이터베이스의 한 부분에 있는 데이터가 다른 부분의 데이터와 일치하지 않는 일관되지 않은 상태...

 

이 트랙잭션은 단지 firebase라는 db에 국한되지 않는다. 앱이나 서비스 자체에서도 매우 중요한 개념이기 때문에 꼭 이해,숙지, 외워야 한다.

또 이번 문서 함수에서는 콜백callback 개념이 중요하니 이것 또한 꼭 이해,숙지,외워야한다.

 

Cloud Firestore는 트랜잭션 기능을 제공하므로 데이터의 일관성을 유지...

java는 @Transaction 어노테이션으로 트랜잭션을 쓰기도 한다

Firestore 제작자가 Firestore에 기본 기능으로 넣어놨으니 (개발자가 따로 안 만들어도 되고) 있는거 쓰라는 의미.


 

로컬에 클론해서 받았던 'friendlyeats-web' 경로에서 vs code 실행.

vanilla-js \ scripts \ FriendlyEats.Data.js 열기

 

function addRating

FriendlyEats.prototype.addRating = function(restaurantID, rating) {
  var collection = firebase.firestore().collection('restaurants');
  var document = collection.doc(restaurantID);
  var newRatingDocument = document.collection('ratings').doc();
};

 

Nosql 형식이 소스 분석만으로 이해하기 힘들다면

일단 리뷰가 있다는 가정 하에 firebase Console를 이미지 형태로 보고 이해한다.

 

 

firebase(의 여러 서비스 중).firestore()(firestore 서비스를 가져오고).collection('레스토랑들') 컬렉션 데이터를 가져온다.

 

 

그 컬렉션의.doc('화면에서 누른 레스토랑ID를') 넘겨서 그 레스토랑 문서를 가져온다.

 

그 레스토랑 문서 중' rating'이란 컬렉션 데이터를 가져온다.

여기 newRatingDocument 는 return으로 쓰일거다.

위에서 collection('rating')의 문서를 각각 누르면 각 문서의 필드를 볼 수 있다.

update에 필요한 기존 db 데이터를 먼저 가져온 거라고 이해하면 된다.

 

 

그 다음에 함수 소스를 본다.

 

 

runTransaction하고 함수 안에서 return... function...

return firebase.firestore().runTransaction(function(transaction) {
    return transaction.get(document).then(function(doc) {
      var data = doc.data();

      var newAverage = 
        (data.numRatings * data.avgRating + rating.rating) / 
        (data.numRatings + 1);

      transaction.update(document, {
        numRatings: data.numRatings + 1,
        avgRating : newAverage
      });
      return transaction.set(newRatingDocument, rating);
    });
  });

처음 접하면 runTransaction이 어떻게 돌아가는 함수인지 모른다.

firebase 뿐만 아니라 java, js, sql, swift, nodejs 등... 잘 모를 땐

제작자가 안내 가이드 마냥 공식 사이트 Document에 잘 정리해서 올려놓아주고 그걸 참고하면 된다.

 

참고 문헌

공식 문서 - 트랜잭션 및 일괄 쓰기 - 트랜잭션을 사용한 데이터 업데이트 ('Web namespaced API' 참고)

 

일단 sample 소스의 return 부터 본다. (위 sfDocRef는 아래서 볼거다)

 

연결해둔 나의 firebase.firestore()   =>  db

.runTransaction(function(transaction) {  => sample 화살표 함수와 동일. 주석 This..는 Codelab 최하단 '경고'와 동일한 의미.

transaction.get(document) =>  문서 존재여부 확인. if ( !문서.존재여부)  = 없으면 { "문서 없어!" 라고 하기 }

궁금한게 그럼 transaction.get(document) 리턴값은 true / false로 바로 갈리는게 아니라 .then까지 들어가서 throw를 던지나보다?

그럼 .then이란 함수가 약간 ajax의 success / error... 아무튼 do catch exception throw 그런건가..?

아무튼 문서가 있으면 다음 소스를 수행하고

transaction.update(document, { n.. , r..  } ) => 앞의 sfDocRef 문서를 뒤의 문서로 덮어써라?

return db.runTransaction((transaction) => {
    // This code may get re-run multiple times if there are conflicts.
    return transaction.get(sfDocRef).then((sfDoc) => {
        if (!sfDoc.exists) {
            throw "Document does not exist!";
        }

        // Add one person to the city population.
        // Note: this could be done without a transaction
        //       by updating the population using FieldValue.increment()
        var newPopulation = sfDoc.data().population + 1;
        transaction.update(sfDocRef, { population: newPopulation });
    });
}).then(() => {
    console.log("Transaction successfully committed!");
}).catch((error) => {
    console.log("Transaction failed: ", error);
});

 

그렇다면 friendlyeats 함수 소스도 대충 풀어낼 수 있다.

 

transaction.get(document) 

(1) 인자 document는 위쪽에서 (restaurantID)를 넘겨서 가져온 '문서'다. 그렇다면 transaction.get의 param 타입은 document 타입이겠구나 도 추측 가능.

(2) 연결시킨 db firebase에 document 문서 존재 여부 .then(function(doc)  = 갑툭튀 doc은 보통 이럴때 콜백이 돌려준 데이터다.

컴퓨터 : "연결시킨 db에 그 문서 있구나? 그럼 그 문서, 이름 doc이란 곳에 담아서 돌려줄게." 대충 이런 의미.

 

고로 (*db에 restaurantID로 문서가 반드시 있다는 가정 하에) 정말!! 이해만 하기 위해 간단하게 보자면

return firebase.firestore().runTransaction(function(transaction) {
    return transaction.get(document).then(function(doc) {
      var data = doc.data();

      var newAverage = 
        (data.numRatings * data.avgRating + rating.rating) / 
        (data.numRatings + 1);

      transaction.update(document, {
        numRatings: data.numRatings + 1,
        avgRating : newAverage
      });
      return transaction.set(newRatingDocument, rating);
    });
  });

collection.doc(restaurantID) =  transaction.get(document)  =  doc > 다 같은 문서라는 것! (라고만 이해)

그럼 이제 그 문서는 아직 firebase NoSql 포맷 형식이니  인간이 다루기 좋게 .data() data화 해준다.

저 데이터를 console.log로 찍으면 아래와 같은 구조로 보인다.

문서가 갖고 있는 기존 데이터와 , 앱(=firendlyeats 사이트)에서 클라이언트(=리뷰 고객)한테서 넘어온 데이터를 계산한게

newAverage 새로운 평균이 된다.

 

numRating = 총 리뷰 갯수 = 기존 총 갯수 + 1개(지금 새로 1개 추가된거니까)

avRating = 평균 점수 = newAverage로 덮어버린다.

 

이 2개 필드를 .update( document에, {값으로}) 한다.

 

 

마지막에 return transaction.set(newRatingDocument, rating); 가 있다.

.set함수가 뭔지 모른다. 그러면 또 제작자가 올려준 공식 문서에서 찾아보면 되는데 아까 위에서 말했던 sfDocRef 부분을 지금 보면 된다.


참고 문헌

공식 문서 - 트랜잭션 및 일괄 쓰기 - 트랜잭션을 사용한 데이터 업데이트 ('Web namespaced API' 참고)

 

// Create a reference to the SF doc.
var sfDocRef = db.collection("cities").doc("SF");

// Uncomment to initialize the doc.
// sfDocRef.set({ population: 0 });

db 컬렉션(도시)에 문서(SF)를 만든다.

밑에 주석 보면 // 만든 문서에.set({ 인구 수는 : 0인 걸 }) 세팅해라~ 

 

그렇다면 newRatingDocument는 컬렉션(ratings)의 문서였는데

newRatingDocument = document.collection('ratings').doc();

다른 점은 

sfDocRef.set({ population: 0 });   < 여긴 파라미터가 1개지만

transaction.set(newRatingDocument, rating); < 여긴 파라미터가 2개다. 인자 갯수가 다를 땐 주의할 필요가 있다.


참고 문헌

공식 문서 - Cloud Firestore에 데이터 추가 - 문서 설정 ('Web namespaced API' 참고)

 

문서가 없으면 생성됩니다. 문서가 있으면 새로 제공한 데이터로 내용을 덮어쓰지만, 다음과 같이 데이터를 기존 문서와 병합하도록 지정한 경우는 예외입니다.

var setWithMerge = cityRef.set({capital: true}, { merge: false });

문서가 있는지 확실하지 않은 경우 전체 문서를 실수로 덮어쓰지 않도록 새 데이터를 기존 문서와 병합하는 옵션을 전달하세요. 대상 맵이 포함된 문서가 비어 있으면 대상 문서의 맵 필드를 덮어씁니다. 

 

만약 위 샘플 소스에서  merge가 capital이었다면

var setWithMerge = cityRef.set({capital: true}, { capital: false });

앞의 데이터 true는 뒤 데이터false로 덮어써지는 것이다.

 

 

transaction.update는 db에 (뭐를, 뭐로 ) 바꿔라. < 라는 걸 한거지만..

우리가 리뷰를 작성하고 나면 그 리뷰가 추가된 데이터가 새로 화면에 renderer해서 보여야한다. 그 로직은 이제 봐야한다.

 

return (newRatingDocument라는 이름의 변수에, 새로운 'rating 문서'를 담은) 것은

블록을 나가면 다시 return이 되고

그 return(값은 계속 set(newRatingDocument, rating))은 runTransaction의 return이 된다.

노랑 function이 return하면 > 초록 function이 return하고 > 파랑 function이 return한다.

 

그러면 함수 addRating은 이 return 새로운 문서를 > 어디에 돌려주는 것인가?

 

vanilla-js \ scripts \ FriendlyEats.Data.js  \  function addRating

이 함수가 호출되는 곳을 찾아본다.

 

(1) vanilla-js \ scripts \ FriendlyEats.View.js \ FriendlyEats.prototype.initReviewDialog

(2) vanilla-js \ scripts \ FriendlyEats.Mock.js \ FriendlyEats.prototype.addMockRatings

각각 함수 쪽에 console.log를 적고, 새로운 rating 리뷰를 추가하면 어느 쪽에서 호출되는 건지 확인할 수 있다.

(1) 에서 호출되고 있으니 (1) 소스를 본다.

 

Data.js  \  function addRating 는 return에 값을 담아서 돌려줬지만

View.js \ function initReviewDialog 는 어느 변수에도 받지 않고 .then .rerender() 한다.

아예 화면을 다시 렌더링해서 그냥 [8. Get() 데이터] Data.js  \  function getRestaurants하도록 해놓았다.

FriendlyEats.prototype.initReviewDialog = function() {
  var dialog = document.querySelector('#dialog-add-review');
  this.dialogs.add_review = new mdc.dialog.MDCDialog(dialog);

  var that = this;
  this.dialogs.add_review.listen('MDCDialog:accept', function() {
    var pathname = that.getCleanPath(document.location.pathname);
    var id = pathname.split('/')[2];

    that.addRating(id, {
      rating: rating,
      text: dialog.querySelector('#text').value,
      userName: 'Anonymous (Web)',
      timestamp: new Date(),
      userId: firebase.auth().currentUser.uid
    }).then(function() {
      that.rerender();
    });
  });

  // ...
};

 

 

다르게 세팅하는 로직도 있을텐데 왜 이렇게 했을까 궁금할 수 있다.

firebase 제작자도 온갖 예외 생각을 다 생각해서 만들었을 것이다. 그래서 Codelab 마지막 참고,경고를 본다.

사용자(=고객)이  특정 레스토랑을 비방하기 위해 평점을 조작할 수 있다. '앱'에서 어찌저찌해서 그런 조작을 못하도록 

Data.js  \  function addRating 에서 newAverage를 계산하고 .update하라고 설명한다.

 

Data.js  \  function addRating 의 function() {

  return function() {

    return...   < 이런 형태를 콜백callback 함수라고 하는데

로직을 잘 못 짠 줄 모르고 '앱'을 돌릴 경우, 소스 수행이 실패하게 되어 "콜백 지옥callback hell에 빠진다"라고 한다.

그래서 '경고: 콜백 함수에 '앱' 상태 수정하는거.. 넣으면.. 님 지옥에 빠짐..'라고 친절하게 설명을 적어줬다.

 

그러면 아하. 다르게 세팅하는 로직도 있을텐데 저런 이슈가 있어서 저렇게 했구나. 하면 된다.

 

 


아까 위에서 .then() 함수는 비동기 Promise의 함수인 건 알고 있었는데 정확한 Cycle을 모르고 지나갔다.

그렇구나... 역시 그랬군...