IT

[Javascript / 리팩토링] 객체 프로퍼티 재할당 (feat. ESLint : no-param-reassign 에러 발생) 본문

개발

[Javascript / 리팩토링] 객체 프로퍼티 재할당 (feat. ESLint : no-param-reassign 에러 발생)

abcee 2022. 11. 16. 14:04

Smell Code

함수의 매개변수로 보내진 객체의 프로퍼티를 해당 함수에서 재할당하는 경우

  • AirBnb Eslint를 사용하는 경우 no-param-reassign 에러가 발생한다.
  • 객체 지향 프로그래밍 관점에서의 문제점
    • Javascript의 경우 Class 선언없이 Object가 생성되는 레거시 코드가 많기 때문에 객체의 프로퍼티에 setter/getter에 준하는 메소드 없이 직접적으로 할당하는 경우가 많은데 이 경우 객체 지향 프로그래밍 측면에서 문제가 발생한다.
    • 객체지향 프로그래밍에서는 객체의 정보를 캡슐화하여 객체의 정보 변경을 내부에서 하도록 책임을 부여하고 외부에서 해당 객체의 정보를 변경하는 것을 제한하도록 설계하여 중복을 방지하고 Side Effect 를 최소화하는 코드 작성을 권장한다.
    • 그런데 함수 내부에서 외부 데이터를 변경하게되면(캡슐화 없이 dot notation 을 통한 직접적인 데이터 변경) 해당 객체의 데이터 변경 책임이 외부 객체로 분산되어 코드 중복과 예상치 못한 Side Effect 발생 등 유지보수에 어려움이 생긴다.
  • 함수형 프로그래밍 관점에서의 문제점
    • Javascript의 경우 Dot notationBracket notation 을 사용하여 함수의 매개변수로 보내진 객체의 데이터를 동적으로 생성하는 레거시 코드가 많은데 이 경우 함수형 프로그래밍 측면에서의 문제가 발생한다.
    • 함수형 프로그래밍에서는 함수가 Side Effect 없이 동일한 매개변수에 대해 동일한 동작과 결과을 보장하도록 순수함수를 설계하여 예측 가능하고 테스트하기 쉬운 코드 작성을 권장한다.
    • 그런데 함수 내부에서 외부 데이터를 변경하게되면(dot notation 과 setter 등 모든 프로퍼티 재할당) 변경된 데이터가 함수 동작에 영향을 끼칠 가능성이 존재하므로 Side Effect 발생 가능성이 생기고 예측이 어려워져 순수함수를 만족하지 않게 된다.
  • Data Code
// ./main.js file
const jsonString = `
  {
    "school" : {
      "nm" : "b",
      "students": [
        {
          "nm" : "s1",
          "age" : 10,
          "checkCards" : [
            { "balance": 10, "validDate": "2022-01-01 GMT" },
            { "balance": 20, "validDate": "2022-02-01 GMT" },
            { "balance": 30, "validDate": "2022-03-01 GMT" }  
          ],
          "house" : { "nm" : "my_home" }
        }
      ]    
    } 
  }
`;
  • Smell Source Code
// ./main.js file
const { school: schoolObj } = JSON.parse(jsonString);

function useCard(student) {
  student.checkCards.forEach((checkCard) => {
    // this syntax occur "ESLint: Assignment to property of function parameter 'checkCard'.(no-param-reassign)"
    // From an object-oriented programming perspective, there is a problem of lack of encapsulation.
    // ESLint 에러가 발생한다.
    // 객체 지향 관점에서 캡슐화가 되지 않은 문제가 있다.
    checkCard.balance -= 5;
  });
}

function reissuedCard(cards) {
  cards.forEach((checkCard) => {
    // this syntax occur "ESLint: Assignment to property of function parameter 'checkCard'.(no-param-reassign)"
    // ESLint 에러가 발생한다.
    checkCard.validDate = new Date(`${checkCard.validDate.getFullYear() + 1}-${checkCard.validDate.getMonth() + 1}-${checkCard.validDate.getDate()} GMT`);
    // You can solve the ESLint error by using the setFullYear function as below to increment the year,
    // but it still has side effects, which is problematic from a functional programming point of view.
    // Date 객체의 setFullYear() 함수를 사용해 ESLint 에러를 해결할 수는 있지만,
    // 여전히 함수형 관점에서 reissuedCard() 함수 내 외부 cards의 데이터를 변조하는 Side Effect가 존재하는 문제가 있다.
    checkCard.validDate.setFullYear(checkCard.validDate.getFullYear() + 1);
  });
}

useCard(schoolObj.students[0]);
reissuedCard(schoolObj.students[0].checkCards);
console.log(JSON.stringify(schoolObj, null, 2));

Refactoring

  • AirBnb Eslint의 no-param-reassign 에러를 해결하기 위해선 현재 소프트웨어 설계구조와 로직에 적절한 방안을 적용해야 한다.
    • 예제의 useCard 함수처럼 기능의 의미상 기존 객체 인스턴스의 데이터만 변경하는 게 맞는다면 객체 지향 프로그래밍 관점에서 리팩토링을 해야한다.
    • 예제의 reissuedCard 함수처럼 기능의 의미상 새로운 객체 인스턴스를 생성하는 게 맞는다면 함수형 프로그래밍 관점에서 리팩토링을 해야한다.
  • 객체 지향 프로그래밍 관점에서의 문제점 해결
    • 해당 프로퍼티 할당의 책임을 프로퍼티를 소유하고 있는 객체에 부여한다.
      1. 함수 외부에서 setter 메소드가 존재하는 Class를 선언하고 해당 Class 의 prototype으로 object를 재할당 한다.
      2. 그리고 함수 내부에서는 setter 메소드를 통해서 object의 프로퍼티를 변경한다.
    • useCard 함수에 존재하던 학생이 가지고 있는 체크카드 잔액 변경 책임CheckCard 객체에 부여한다.
  • 함수형 프로그래밍 관점에서의 문제점 해결
    • 순수함수를 만들기 위해 함수 내부에서 객체를 shallow copy 후 데이터를 변경하고 새로 생성된 객체를 return 한다.
    • reissuedCard 함수에서 cards의 유효일을 변경하던 구조를 reissuedCard 함수에서 변경된 유효일로 새로운 카드를 만들어서 return 하고 객체가 생성된 곳에서 .checkCards 프로퍼티를 재할당 하도록 구조를 변경한다.
// ./main.js file
const Student = require('./vo/Student');
const CheckCard = require('./vo/CheckCard');
const House = require('./vo/House');
const { ValueObject, JsonParseAndAssignPrototype, VoReassignPrototype } = require('./util');

const VO = {
  Students: new ValueObject(
    'students',
    true,
    Student,
  ),
  CheckCards: new ValueObject(
    'checkCards',
    true, 
    CheckCard,
  ),
  House: new ValueObject(
    'house',
    false,
    House,
  ),
};

const voList = [VO.Students, VO.CheckCards, VO.House];
// jsonString 을 Object로 만들어서 parameter passing 할때
const { school: schoolObj } = JsonParseAndAssignPrototype(jsonString, voList);
// 기생성된 object를 parameter passing 할때
// const schoolObj = VoReassignPrototype(JSON.parse(jsonString).school, voList);

// 객체지향 프로그래밍 관점에서의 문제점 해결
function useCard(student) {
  student.checkCards.forEach((checkCard) => {
      // `CheckCard` 객체에 부여된 체크카드 잔액 변경 책임이 동작하도록 변경됨
    checkCard.updateBalance(-5);
  });
}

// 함수지향 프로그래밍 관점에서의 문제점 해결
function reissuedCard(cards) {
  // 변경된 유효일로 새로운 카드를 만들어서 return 하도록 변경됨
  return cards.map((checkCard) => ({
    ...checkCard,
    validDate: new Date(`${checkCard.validDate.getFullYear() + 2}-${checkCard.validDate.getMonth() + 1}-${checkCard.validDate.getDate()} GMT`),
  }));
}

useCard(schoolObj.students[0]);
// 변경된 유효일의 새로운 카드를 schoolObj.students[0].checkCards 에 재할당
schoolObj.students[0].checkCards = reissuedCard(schoolObj.students[0].checkCards);
console.log(JSON.stringify(schoolObj, null, 2));
// ./vo/Student.js file
class Student {
      setNM(nm) {
        this.nm = nm;
      }

      setAge(age) {
        this.age = age;
      }
}
module.exports = Student;

// ./vo/CheckCard.js file
class CheckCard {
  updateBalance(diff) {
    this.balance += diff;
  }
}
module.exports = CheckCard;

// ./vo/House.js file
class House {
  setNM(nm) {
    this.nm = nm;
  }
}
module.exports = House;
// ./util.js file
const { isEmpty } = require('lodash');

class ValueObject {
  constructor(jsonKey, isArray, classInstance) {
    this.jsonKey = jsonKey;
    this.isArray = isArray;
    this.classInstance = classInstance;
  }
}

const reassignPrototypeOfElement = (prototype, element) => (element ? Object.create(prototype, Object.getOwnPropertyDescriptors(element)) : null);
const reassignPrototypeOnArrayElement = (prototype, arr) => (arr instanceof Array ? arr.map((item) => reassignPrototypeOfElement(prototype, item)) : null);
const reassignPrototype = (vo, obj) => (vo.isArray ? reassignPrototypeOnArrayElement(vo.classInstance.prototype, obj) : reassignPrototypeOfElement(vo.classInstance.prototype, obj));
const reassignPrototypeReviver = (voList, reviverWrapper) => (key, value) => {
  const vo = voList.find((item) => key === item.jsonKey);
  const newValue = reviverWrapper ? reviverWrapper(key, value) : value;
  return vo ? reassignPrototype(vo, newValue) : newValue;
};
/**
 * jsonString 을 Object로 변경할때 Prototype 할당
 * @param {String} jsonString
 * @param {Array} voList
 * @param {function(key, value)} reviver (optional)
 * @returns {Object} parsing result
 */
const jsonParseAndAssignPrototype = (jsonString, voList, reviver = null) => JSON.parse(jsonString, reassignPrototypeReviver(voList, reviver));
const createNodes = (obj) => (obj instanceof Object ? Object.entries(obj).map(([key, value]) => ({ key, value, parent: obj })) : []);
/**
 * 기생성된 Object를 그대로 사용할때 Prototype 재할당
 * @param {Object} rootVO
 * @param {Array} voList
 * @returns {Object} rootVO
 */
const voReassignPrototype = (rootVO, voList) => {
  const stack = createNodes(rootVO);
  while (!isEmpty(stack)) {
    const { key, value, parent } = stack.pop();
    const vo = voList.find((item) => key === item.jsonKey);
    if (vo) {
      parent[key] = reassignPrototype(vo, value);
    }
    stack.push(...createNodes(parent[key]));
  }
  return rootVO;
};

module.exports = {
  ValueObject,
  JsonParseAndAssignPrototype: jsonParseAndAssignPrototype,
  VoReassignPrototype: voReassignPrototype,
};
Comments