고차함수 알아보기

김장훈·2023년 7월 4일
0

1. 고차함수란?

  • 함수를 signature 로 받거나 함수를 return 하는 함수
  • 이를 위해선 함수를 일급객체로 취급할 수 있어야 하며 언어에서 지원해줘야한다.
  • 고계함수라고도 불림

1.1. 연관된 개념

1.1.1. closure

  • 함수가 실행될 당시의 환경을 기억해두고 있는 것

2. 고차함수의 어떤 점이 좋은가?

2.1. 행동의 추상화

  • print('name'), print('hello') 와 같은 함수가 있다고 한다면 우리는 이를 추상화 할 수 있으며 이럴 경우 print 는 string 을 singature 로 받게끔 설계할 것이다.
  • 이 처럼 단순 값 뿐만 아니라 행동 역시 추상화가 가능하다.
  • 아래와 같은 시나리오를 바탕으로 코드를 작성해보자

    남성 유저들을 대상으로 이벤트를 진행한다.
    이벤트 메시지는 다음과 같다 > '여름 맞이 반바지 90% 할인'
    이벤트 메시지가 전송된 유저에게는 할인 쿠폰이 지급 된다.


type eventMessage = {
  userId:number,
  message:string
}

const getUserIds = (gender:string) =>{
  if(gender == 'male'){
    return [1,3,5]
  }
  return [2,4,6]
}

const sendMessages = (dataList:eventMessage[]):[number[], number[]]=>{
  const userIds = dataList.map((data)=>{
    console.log(`send to ${data.userId}, message: ${data.message}`)
    return data.userId
  })

  return [userIds, [0]]
}

const publishDiscountCoupon = (userIds:number[])=>{
  userIds.map((userId)=>{
    console.log(`create discount coupon, ${userId}`)
  })
}

const mapMessageToUser = (userIds:number[], message:string)=>{
  return userIds.map((userId)=>{
    return {userId, message}
  })
}

export const sendNotification = (gender:string, message:string)=>{
  try {
    const users = getUserIds(gender);
    const dataList = mapMessageToUser(users, message)
    const [successUsers, failUsers] = sendMessages(dataList)
    publishDiscountCoupon(successUsers)
  } catch(error){
    console.log(error)
  }
}
  • 로직 순서 그대로 작성한 코드이며 딱히 문제되는 부분은 없어 보인다. 아래 시나리오를 적용해보자

    모든 유저들을 대상으로 이벤트를 진행한다.
    이벤트 메시지는 다음과 같다
    -- 홀수 user_id: '모든 상의 10% 할인'
    -- 짝수 user_id: '모든 바지 10% 할인'
    이벤트 메시지가 전송된 유저에게는 할인 쿠폰이 지급 된다.
    이벤트 메시지 전송이 실패된 유저들은 로그만 남긴다.

  • 우선 유저를 추출하는 부분이 바뀌었다. 따라서 getUserIds 의 변화는 불가피 하다.

  • message 를 계산하는 로직이 생겼다. 그렇기에 message 를 바깥에서 계산해서 주던가 아니면 내부에서 계산을 해야한다.

  • 할인 쿠폰 지급 로직은 동일하므로 재사용이 가능하다.

  • 전송실패시 핸들링이 추가 되었다.

const getAllUserIds = () => {
  return [1, 2, 3, 4, 5, 6];
};

const mapMessageToUserByIds = (userIds: number[]) => {
  return userIds.map((userId) => {
    let message = "상의 10% 할인";
    if (userId % 2 !== 0) {
      message = "하의 10% 할인";
    }
    return { userId, message };
  });
};

const logError = (userIds: number[]) => {
  userIds.map((userId) => {
    console.log(`error occur, ${userId}`);
  });
};

export const sendNotificationToAll = () => {
  try {
    const users = getAllUserIds();
    const dataList = mapMessageToUserByIds(users);
    const [successUsers, failUsers] = sendMessages(dataList);
    publishDiscountCoupon(successUsers);
    logError(failUsers);
  } catch (error) {
    console.log(error);
  }
};
  • 두가지 case 에 대해서 벌써 서로 다른 function 이 나오기 시작했다. 두 send function 은 매우 비슷해보인다.
  • 위 두가지 case 를 추상화 하자면 아래와 같은 flow 를 가질 수 있다.
  1. 유저를 추출한다.
  2. 이벤트 메시지를 유저에 맞춰 생성한다.
  3. 노티피케이션을 전송한다.
  4. (optional) 성공 유저에 대해서 액션을 취한다.
    4.1. (optional) 실패 유저에 대해서 액션을 취한다.
  • 이를 function 으로 만들면 아래와 같다.

function sendTMP(
 getUserFunc, 
 mapMessageFunc, 
 createEventFunc, 
 eventWhenSuccess, 
 eventWhenFail){
   ...
}
  • 저렇게만 있으면 오히려 사용하기 어렵다. 따라서 각 function 을 interface 로 선언하여 구체화 시키자.

2.2. 사용하는 곳에서 제어가 가능

  • 주어진 signature 의 interface 만 준수한다면 사용하는 쪽에선 다양하게 변형을 일으키면서 사용이 가능하다.
  • 이는 마치 DI 를 하는 것과 비슷하다 생각할 수 있다.

3. 고차함수 활용해보기

3.1. 각 function 의 interface 적용

3.1.1. getUser

  • getUser의 경우 male 등을 받는 경우도 있고 아닌 경우도 있다. 따라서 singatue 는 다양할 수 있으나 userId 를 return 하는 것은 동일하다. 따라서 이를 interface 로 변경하여 적용해보자
interface getUsersType {
  (...args: any[]): number[];
}

export const sendTMP = (getUserFunc: getUsersType) => {
  return getUserFunc();
};
# test
  it("send tmp", () => {
    const getUsers = (gender: string) => {
      return [1, 2, 3];
    };

    const res = sendTMP(getUsers("male"));
    expect(res).not.toBeNull;
  });
  • 아뿔싸, 문제가 생겼다. getUsers("male") 을 signature 로 사용하게 되면 이미 실행(평가)이 되었으므로 funciton 이 아닌 number[] 가 주어진 것으로 인식된다. 우리는 해당 function 사용될때 실행되기를 원하므로 해당 function 은 signature 정보를 가진 상태로 대기하는 형태가 되어야한다. 따라서 이를 클로저로 변경해보자.
    it("send tmp", () => {
    const getUsers = (gender: string) => () => {
      if (gender == "male") {
        return [1, 3, 5];
      }
      return [2, 4, 6];
    };

    let res = sendTMP(getUsers("male"));
    expect(res).toEqual([1, 3, 5]);

    res = sendTMP(getUsers("female"));
    expect(res).toEqual([2, 4, 6]);
  });

3.1.2. mapUser

  • user_id 에 맞춰서 각각 message 를 mapping 해준다.
  • input, output 이 명확하므로 interface 역시 쉽게 구현 가능하다.
type eventMessage = {
  userId: number;
  message: string;
};
interface mapUserType {
  (userIds: number[]): eventMessage[];
}

export const sendTMP = (
  getUserFunc: getUsersType,
  mapUserFunc: mapUserType
) => {
  const userIds = getUserFunc();
  const messages = mapUserFunc(userIds);
  return messages;
};
# test

  it("send tmp", () => {
    const getUsers = (gender: string) => () => {
      if (gender == "male") {
        return [1, 3, 5];
      }
      return [2, 4, 6];
    };

    const mapMessage = (userIds: number[]) => {
      return userIds.map((userId) => {
        return {
          userId,
          message: "sample message",
        };
      });
    };

    const res = sendTMP(getUsers("male"), mapMessage);
    console.log(res);
  });
  • mapMessage 를 보면 message 가 고정되어있어 불편을 느낄 수도 있다. message 를 동적으로 할당하려면 위에 getUsers 를 한 것 처럼 클로저를 활용하면 된다.
    const mapGivenMessage = (message: string) => (userIds: number[]) => {
      return userIds.map((userId) => {
        return {
          userId,
          message,
        };
      });
    };

    const res2 = sendTMP(getUsers("male"), mapGivenMessage("my-message"));
    console.log(res2);

3.1.3. create event

  • 노티피케이션을 전송하는 것을 추상화 하여 event 를 생성한다 라는 의미로 변경하였다.
  • 해당 function 은 event list 를 받고 성공 & 실패를 구분하여 return 해야한다.
type eventResults = {
  successUserIds: number[];
  failUserIds: number[];
};

interface createEventType {
  (eventMessages: eventMessage[]): eventResults;
}

export const sendTMP = (
  getUserFunc: getUsersType,
  mapUserFunc: mapUserType,
  createEventFunc: createEventType
) => {
  const userIds = getUserFunc();
  const messages = mapUserFunc(userIds);
  const results = createEventFunc(messages);
  return results;
};
# test
it('...', ()=>{
  	...
	const sendNotification = (data: eventMessage[]) => {
      data.map((event) => {
        console.log(event);
      });
      return { successUserIds: [1, 2, 3], failUserIds: [4, 5, 6] };
    };

    const res = sendTMP(getUsers("male"), mapMessage, sendNotification);
  	console.log(res)
}

3.1.4. optional func 대응

  • 성공 유저 & 실패 유저 등에 대응해야하는 것들이 있다. 비즈니스 로직 상으로 대응 하는 경우가 있고 안하는 경우가 있는데 이럴 경우 nulllabe 대응 보다는 empty logic 으로 대응해보자

interface handleEventType {
  (results: eventResults): void;
}

interface doNothingType {
  (...args: any[]): void;
}

export const sendTMP = (
  getUserFunc: getUsersType,
  mapUserFunc: mapUserType,
  createEventFunc: createEventType,
  handleEventWhenSuccess: handleEventType | doNothingType,
  handleEventWhenFail: handleEventType | doNothingType
) => {
  const userIds = getUserFunc();
  const messages = mapUserFunc(userIds);
  const results = createEventFunc(messages);
  handleEventWhenSuccess(results);
  handleEventWhenFail(results);
  return results;
};
  • 아무것도 안 할 경우 null 을 주게 될 경우 function type 이 아니므로 if not null 등의 type 체크가 필요로 하다. 그럴 바엔 차라리 아무것도 하지 않는 function 을 사용하는게 나으므로 doNothingType 을 추가하였다.
# test
	it('...', ()=>{
	...
    
    const res = sendTMP(
      getUsers("male"),
      mapMessage,
      sendNotification,
      () => "",
      () => ""
    );

    const doSomethingWhenFail = (data: eventResults) => {
      data.failUserIds.map((userId) => {
        console.log(userId);
      });
    };

    const res2 = sendTMP(
      getUsers("male"),
      mapGivenMessage("my-message"),
      sendNotification,
      () => "",
      doSomethingWhenFail
    );
    console.log(res2);
  }
  • 아무것도 안할 경우, fail user 만 무언가 하는 경우 등으로 구분하여 사용할 수 있다.

3.2. function interface 다시 보기

export const sendTMP = (
  getUserFunc: getUsersType,
  mapUserFunc: mapUserType,
  createEventFunc: createEventType,
  handleEventWhenSuccess: handleEventType | doNothingType,
  handleEventWhenFail: handleEventType | doNothingType
) => {
  const userIds = getUserFunc();
  const messages = mapUserFunc(userIds);
  const results = createEventFunc(messages);
  handleEventWhenSuccess(results);
  handleEventWhenFail(results);
  return results;
};
  • 위 형태에 try-catch 등만 씌워서 사용하게 되면 주어진 interface 를 준수하는 선에서 우리는 원하는 형태롤 마음껏 갈아 끼울 수 있다(DI, 전략 패턴, composition 패턴)
  • 다만 위의 flow 에서 또 다른 variation 이 나온다고 하면 추상화 레벨이 올라가서 더욱 난해한 function 이 될 가능성이 크다...

3.2.1. 고차함수에서 중요한 것은 바로 약속된 singature

  • 대표적인 고차함수인 filter 를 보면 callback 함수는 return 이 boolean 이 되도록 의도되었다.
  • 위 sendTmp 역시 여러개의 function 을 받는 만큼 약속된 singature 가 매우 중요하기에 interface 를 명시적으로 작성했다.

4. advanced

4.1. 위 방식의 문제점

  1. 전체를 컨트롤 하는 function, 각 signature 의 interface 등 만들어야하는 것들이 많다.
  2. 필요에 따라서 클로저를 만들어야 한다.
  3. 전체 flow의 변경 시 비슷한 function 을 만들어야하므로 코드양은 동일하게 증가 될 수 있다.

4.2. 우리가 원하는 것.

  • 재사용이 가능한 function 만을 작성하여 유지보수를 쉽게 하는 것.
  • 원자적 단위 또는 최소 단위의 function 을 활용하여 원하는 기능을 구성 하는것.
  • 최소한의 코드를 작성하는 것.

4.3. function compoistion

  • 결국 우리가 원하는 것은 함수들을 쉽게 합성하는 것이다. 이를 위해서 사용할 수 있는 것이 pipe 로 불리는 FP 방식이다.

4.3.1. pipe 사용

  • pipe function 이란 쉽게 생각해서 function 을 쉽게 합성하여 결과물을 내도록 도와주는 helper 함수 이다.

	 const getUsers = (gender: string) => {
      if (gender == "male") {
        return [1, 3, 5];
      }
      return [2, 4, 6];
    };

    const mapMessage = (userIds: number[]) => {
      return userIds.map((userId) => {
        return {
          userId,
          message: "sample message",
        };
      });
    };

    const sendNotification = (data: eventMessage[]) => {
      data.map((event) => {
        console.log(event);
      });
      return { successUserIds: [1, 2, 3], failUserIds: [4, 5, 6] };
    };

    const res = pipe("male", 
                     getUsers, 
                     mapMessage, 
                     sendNotification);
    console.log(res);
  • getUser 의 결과물을 mapMessage 가 받아서 여기서의 결과물을 sendNotification 사용한다.
  • 따라서 위의 순서를 코드로 나타내면 아래와 같은데 코드의 실행 순서와 사람이 이해하는 순서가 다르다. 이를 해결해줄 수 있는 것이 pipe function 이다
sendNotification(mapMessage(getUsers('male')))
  • 비슷한 helper 함수로 flow 가 있다. pipe 의 경우 실행하여 결과를 나타낸다고 하면 flow 는 함수를 만들어낸다.
    const res = pipe("male", getUsers, mapMessage, sendNotification);
    console.log(res);

    const sendTemp = flow(getUsers, mapMessage, sendNotification);
    console.log(sendTemp("male"));
  • 즉 1회성으로 사용하여 결과물을 만들어 낼때는 pipe 를, 재사용해야하는 function 을 만들어낼때는 flow 를 사용한다.

5. 결론

  • 행동을 추상화 하여 사용할 경우(=고차함수 활용시) 코드의 유연성을 증대할 수 있다.

    filter 사용하는 경우에도 작성하는 callback function 에 따라서 다양한 결과를 만들어 낼 수 있다.

  • 단 너무 많은 행동을 추상화 하는 경우 오히려 코드를 이해하기 어렵게 만든다.
  • function 을 signature 로 또는 return 하는 형태는 절때 어려운 코드가 아니다.
  • 합성을 적극적으로 사용할 경우 helper 함수를 잘 사용하자.
profile
읽기 좋은 code란 무엇인가 고민하는 백엔드 개발자 입니다.

0개의 댓글