글 목록으로 이동

봄가을 블로그

기술2024년 10월 27일--views

조건 검사하는 데이터 구조 만들기 (TypeScript)

Node.js + TypeScript 환경에서 조건 검사를 데이터화하는 방법을 다룹니다. 조건 데이터와 현재의 상황을 검사 함수에 집어넣으면 참/거짓이 나오도록 하는 과정입니다. 마지막에는 만든 함수가 잘 동작하는지 Vitest로 테스트까지 해봅니다.

타게팅을 하는 소년
Photo by Norbert Braun on Unsplash

개요

본 글은 Node.js + TypeScript 환경에서 조건 검사를 데이터화하는 방법을 다룹니다. 조건 데이터현재의 상황을 어떤 함수에 집어넣으면 참/거짓이 나오도록 하는 과정입니다. 마지막에는 만든 함수가 잘 동작하는지 Vitest로 테스트까지 해봅니다. 이렇게 말만 들어서는 어떤 상황인지 알기 힘들 거 같아요. 요구사항을 먼저 살펴보고 이후 구현을 해보도록 하겠습니다.

프론트엔드 관련 내용은 없고, 타입스크립트와 관련된 설명도 없습니다.

상황(Situation)

조건에 따라 행동하는 간단한 예시

조건에 따라 행동을 다르게 한다,라는 건 일반적인 현상입니다. 그러나 현실 세계에서 일어나는 일들은 컴퓨터가 잘 이해하지 못하기 마련이죠. 그래서 컴퓨터가 알아듣기 좋도록 정확한 기준점을 마련해줘야 합니다. 프로그래머는 코딩을 하면 됩니다. 컴퓨터과학과 1학년 부터 if문을 배우죠. if문은 if (조건) { 조건이 true라면 할 행동 } 와 같이 작성합니다. 유구한 역사의 시스템 프로그래밍 언어인 C부터 웹서비스에서 굉장히 많이 쓰이는 자바스크립트까지 같은 형태를 띠고 있습니다.

예를 들어 25라는 나이 값을 변수 age 에 넣는다면 다음과 같은 코드로 표현할 수 있겠죠.


if (age > 20) {
pay();
} else {
say당신은술을못사요();
}

저렇게 조건이 항상 고정되어 있기만 하면 얼마나 좋을까요? 최근에 맞닥뜨린 요구사항은 저런 조건과 그에 따른 행동을 클라이언트가 언제든지 편집하게 새로 만들고 없애고 싶다라는 겁니다. 클라이언트가 직접 if 문을 짤 수도 없는 노릇이고, 클라이언트가 설정한 대로 로직을 착착 검사하는 걸 만들어라고 하니, 이건 마치 프로그램을 만드는 프로그램을 만들어라고 하는 것 같네요.

위 요구사항을 좀 더 구체화해보겠습니다.

김사장은 쇼핑몰을 운영하고 있습니다. 이번에 새롭게 들여온 샤넬 립스틱 홍보 배너를 메인 홈페이지에 대문짝하게 띄우고 싶습니다. 다만 이 립스틱은 여성들에게만 띄우고 싶습니다. 립스틱은 아무래도 여성들이 훨씬 관심이 많기도 하고, 남성들에게는 지난 주에 출시한 면도크림에 훨씬 관심이 많을 테니까 그걸 메인에 띄워두고 싶거든요.

자 이제 여기서 변동되는 것들을 좀 검토해봅시다. 그에 따라서 아마 데이터 구조가 결정될 것입니다.

  • 갑자기 최인플루언서에 의해 1년 전에 출시했던 헤어드라이기가 대박 광풍을 일으키고 있습니다. 김사장은 재빨리 그 드라이기를 홍보하는 배너를 새로 만들어 홈페이지에 띄우고 싶습니다. 즉 배너를 언제든지 새롭게 만들 수 있어야 합니다.
  • 샤넬 립스틱이 엄청난 인기를 입음에 따라 와이프에게 결혼기념일 선물을 노리는 남편들에게도 립스틱 배너를 띄우고 싶습니다. 즉 모두에게 노출하는 식으로 바꾸려고 합니다. 그럴려면 기존 배너의 조건을 언제든지 수정할 수 있어야 합니다.
  • 개발자가 열심히 노력하여 이제 고객의 나이대를 조건에 포함시킬 수 있게 되었습니다. 김사장은 10대 여성에겐 에뛰드 립스틱을, 20대 여성에겐 샤넬 립스틱을 홍보하고 싶습니다. 즉 조건의 타입이 추가되었을 때에도 기존 로직과 호환이 어느정도 잘 되어야 합니다.

데이터의 구조는 짜기 나름이지만, 조건과 관련된 데이터를 배너에 귀속시키도록 하겠습니다. 즉 배너를 먼저 만든 다음에 그 배너가 언제 어떻게 띄워질지 설정한다는 뜻입니다. 조건을 먼저 만들고 거기에 맞는 배너를 부여해줄 수도 있겠지만 그 방식은 지금은 검토하지 않겠습니다.

지금까지 했던 이야기를 도식화해서 풀어보면 아래와 같습니다.

조건 요구사항 도식화
  • 헤어드라이기 배너를 새로 만들고 싶다면 → 기존 배너들은 비공개하고 새로운 배너를 만들고 조건을 무조건 노출하도록 설정하면 될 것 같네요.
  • 립스틱 배너를 모두에게 보여주고 싶다면 → 립스틱 배너의 조건을 무조건 노출로 설정하고 면도크림 배너를 비공개로 바꾸면 되겠네요.
  • 나이대 조건을 추가하고 싶다면 → 글쎄요… 어떻게 해야 할까요?

요구사항을 좀 더 분석해봅시다.

"배너는 조건을 만족하면 뜰 것이고 만족하지 못하면 뜨지 않는다."

여기에는 조금 더 생각해봐야 할 부분이 있습니다. 만약 김사장의 쇼핑몰에 방문한 사용자가 아직 로그인을 하지 않아서 성별이 여자인지 남자인지 판단할 수 없다면 어떻게 될까요? 띄우는 것이 나을까요, 그냥 안보여주는 게 나을까요? 흠, 뭐든 하나를 일단 띄우는 게 더 나아 보입니다. 타겟이 안맞을 수는 있어도 그게 큰 문제는 안될 것 같아요.

그러다 어느날 김사장은 기가 막힌 마케팅 캠페인을 하나 생각했는데요, 바로 VIP에게만 한정판 제품을 30% 싸게 제공한다는 이벤트입니다! 다만 VIP께서 잘 모를 수도 있으니 그들에게만 보이는 배너를 띄우고 싶다고 가정합시다. 이럴 때 방문한 사용자가 VIP인지 판단하기 어려울 때는요? VIP에게 너무 과도한 혜택을 주는 것이 아니냐 라고 일반 회원들이 반발할 수도 있으니 그냥 안 보여주는 게 더 나을 겁니다.

배너는 결국 뜨거나 안뜨거나 둘 중 하나일 테지만, 조건을 판단할 수 없을 때에는 상황에 따라 배너가 뜨는 게 더 나을 수도 있고 가려지는 게 더 나을 수도 있습니다. 즉 조건을 판단할 수 없는 상태도 있어야 합니다.

또한 아까 새로운 조건이 추가되면 기존 것과 호환이 잘 되어야 한다고 했었죠? 기존에는 "성별"조건 밖에 없었다면 이제 "나이대"조건도 추가되어 서로 조화롭게 동작해야 합니다. 각 조건은 서로 독립적이면서 결합 방식에 따라 다르게 동작해야 합니다. 즉 성별+나이대 조건을 모두 만족해야 할 수도 있고(AND), 성별 또는 나이대 조건이 따로 동작할 수도 있습니다(OR).

지금까지 배너의 이야기들을 종합하면 다음과 같습니다.

  • 배너는 자유롭게 추가될 수 있습니다.
  • 배너에는 조건이 붙습니다.
  • 배너는 조건을 어느 하나 판단할 수 없을 때 기본 동작이 있습니다. (노출되든지, 가려지든지)

그리고 배너의 노출 조건에 관한 요구사항은 아래와 같습니다.

  • 조건은 언제든지 자유롭게 추가될 수 있습니다.
  • 조건의 타입도 새로운 것이 등장할 수 있습니다.
  • 조건들은 서로 결합하거나 분리될 수 있어야 합니다.
  • 조건을 판단할 수 없는 상태가 있어야 합니다.

배너와 관련된 부분은 이 글에서 다루고자 하는 건 아니니 패스하도록 하겠습니다. 이제 조건에 대한 요구사항 네 가지를 충족하도록 만들어봅시다.

타입 잡기


type Target = IGenderTarget | IAgeTarget | ITargetGroup | IRootTarget;
interface IRootTarget {
type: "root";
default: boolean;
child: Target;
}
interface ITargetGroup {
type: "group";
operator: "and" | "or";
children: Target[];
}
interface IGenderTarget {
type: "gender";
value: "male" | "female";
}
interface IAgeTarget {
type: "age";
operator: "<" | ">" | "<=" | ">=";
value: number;
}

type 필드는 Discriminated Unions 라는 겁나 어려운 단어로 조합된 유니온(|)을 사용할 때 흔히 쓰는 방식입니다. 타입스트립트에서 Narrowing이 필요한 이유라는 글에서 더 자세히 다루었으니 참조해주세요.

Target은 광범위한 타입이고 IRootTarget등의 타입은 세부적인 타입입니다.

모든 조건은 root에서 시작하도록 할 겁니다. 이렇게 시작점을 확실히 해두면, 외부에서 이 노출 조건과 관련한 데이터에 접근할 때 편리해집니다. 즉 배너 입장에서는 자신이 갖고 있는 조건 데이터에 바로 접근할 때 Target이 아니라 IRootTarget로 간주해도 되는 것이죠. IRootTarget에는 무조건 child가 있는 셈이라 오류 없이 스무스하게 접근할 수 있지만 Target이라는 광범위한 타입일 때에는 필드가 있을 수도 있고 없을 수도 있어서 타입 에러가 나기 쉽습니다.

조건을 결합시키기 위한 IGroupTarget을 주목해주세요. 얘네들은 operatorand또는 or를 가지고 있고 자식들 Target을 가질 수 있습니다. 즉 트리 구조를 가능케 합니다. 그리고 성별 조건을 표현하기 위한 IGenderTarget, 나이 조건을 표현하기 위한 IAgeTarget이 있습니다.

이 타입들을 바탕으로 "20대 여성들에게만 보이도록 한다"를 다음과 같이 표현할 수 있습니다.


// 20대 여성 조건을 나타내는 데이터
const target20female: Target = {
type: "root",
child: {
type: "group",
operator: "and",
children: [
{
type: "age",
operator: ">=",
value: 20,
},
{
type: "age",
operator: "<",
value: 30,
},
{
type: "gender",
value: "female",
},
],
},
};

and 안에 세 개가 묶여 있어요. 나이가 20 이상이고 + 30 미만이며 + 성별이 여성이어야 한다. 저 말을 데이터로 잘 풀어냈습니다! 이제 저 데이터를 바탕으로 조건을 검사하는 로직이 필요하겠지요.

검사하는 함수 구현하기

우선 함수의 시그니처를 먼저 살펴봅시다.


interface IEnv {
user: IUser;
}
interface IUser {
age?: number;
gender?: "male" | "female";
}
type Result =
| {
type: "success";
}
| {
type: "failure";
reason: string;
}
| {
type: "ignore";
};
type CheckTarget = (target: Target, env: IEnv) => Result;

checkTarget 이라는 함수를 하나 만들 예정입니다. 이 함수는 두 개의 인자를 받습니다. 위에서 만든 노출 조건을 의미하는 target, 그리고 현재 사용자의 상황을 포함해서 조건을 검사해야 할 대상으로 묶여 있는 env로 있습니다. 사용자의 나이(age)나 성별(gender) 정보는 실제로 없을 수 있으므로 optional하게 뒀습니다.

중요한 얘기는 아니지만 user가 아니라 env로 굳이 한 단계 더 뺀 이유는, 사용자의 정보 말고 다른 정보도 담을 수 있어야 하기 때문입니다. 예를 들어 김사장이 배너를 "결제 완료 페이지"에만 노출하고 싶다고 가정합시다. 그렇다면 페이지 조건을 추가하고 페이지 정보도 추가해줘야 할텐데, 그러한 사용자와 관련 없는 정보도 모두 env에 담을 수 있도록 한 것이죠.

결과(Result)는 세 종류가 있습니다. 성공하거나, 실패하거나, 알 수 없거나. 아까 이야기했던 것처럼 "알 수 없는 상태"를 만들기 위해 결과에 "알 수 없음"(ignore)을 추가했습니다.

이제 검사하는 로직을 만들어봅시다. 먼저 checkTarget 입니다.


const checkTarget: CheckTarget = (target, env) => {
switch (target.type) {
case "root":
return checkTarget(target.child, env);
case "group":
return checkGroup(target, env);
case "age":
return checkAge(target, env);
case "gender":
return checkGender(target, env);
}
return { type: "ignore" };
};

checkTarget은 단순히 type을 확인해서 타입을 좁혀준 다음 아래로 내려주는 역할을 하는 것처럼 보입니다. 그런데 이 구조는 한가지 장점이 있습니다. 바로 상위호환성까지 어느정도 커버가 되어 상당히 안정적이라는 겁니다.

상위호환성이 좋다는 말은 다음과 같습니다: 데이터는 최신인데 로직이 옛날일 때도 문제 없이 잘 동작함. 예를 들어 조건의 타입이 사는곳(location)이 추가되었고 김사장이 배너 설정에서 location 조건을 추가하여 그것이 포함된 새로운 데이터가 생겼다고 가정합시다(최신의 데이터). 그런데 쇼핑몰 방문자가 컴퓨터를 가만히 켜놓기만 하는 바람에 옛날 로직이 그대로 남아있어서 여기에는 location 관련된 처리가 없을 수 있습니다(옛날 로직). 그럼에도 불구하고 옛 로직 입장에서는 switch-case 문에 location이 없을 뿐이니 가장 아래에서 ignore 처리를 해버릴 것입니다. 새로운 데이터가 생겼다고 해서 앱이 아예 먹통이 되는 상황은 안온다는 뜻이죠.

다음은 그룹입니다.


const checkGroup = (target: ITargetGroup, env: IEnv): Result => {
const results = target.children
.map((child) => checkTarget(child, env))
.filter((result) => result.type !== "ignore");
if (results.length === 0) {
return { type: "ignore" };
}
const failures = results.filter(
(result): result is Extract<typeof result, { type: "failure" }> =>
result.type === "failure",
);
// And
if (target.operator === "and") {
if (failures.length === 0) {
return { type: "success" };
}
return {
type: "failure",
reason: failures.map((failure) => failure.reason).join(", "),
};
}
// Or
if (target.operator === "or") {
if (failures.length !== results.length) {
return { type: "success" };
}
return {
type: "failure",
reason: failures.map((failure) => failure.reason).join(", "),
};
}
return { type: "ignore" };
};

AND와 OR의 동작은 컴퓨터공학적으로 통용되는 논리를 거의 따릅니다. AND는 하나라도 실패하면 실패고 OR는 하나라도 성공하면 성공입니다. 그런데 빈 배열일 때는 어떨까요? 본래는 AND → 실패한 게 하나도 없으므로 성공, OR → 하나라도 성공한 게 없으므로 실패입니다. 그러나 우리의 프로그램은 그런 애매한 경우는 그냥 없는 것으로 취급합시다. 이렇게 하면 추후 로직을 간단하게 할 수 있어서 좋습니다. 유효하지 않다고 처리해버리는 부분은 가장 처음 ignore 필터링 및 results.length === 0 을 체크하는 부분에서 확인할 수 있습니다.

재귀적으로 처리하기 위해 중간에 checkTarget을 호출하는 부분을 확인할 수 있습니다.

각각의 함수는 다음과 같이 구현됩니다.


const checkAge = (target: IAgeTarget, env: IEnv): Result => {
if (typeof env.user.age !== "number") {
return { type: "ignore" };
}
switch (target.operator) {
case "<":
return env.user.age < target.value
? { type: "success" }
: { type: "failure", reason: "Age is not less than " + target.value };
case ">":
return env.user.age > target.value
? { type: "success" }
: {
type: "failure",
reason: "Age is not greater than " + target.value,
};
case "<=":
return env.user.age <= target.value
? { type: "success" }
: {
type: "failure",
reason: "Age is not less than or equal to " + target.value,
};
case ">=":
return env.user.age >= target.value
? { type: "success" }
: {
type: "failure",
reason: "Age is not greater than or equal to " + target.value,
};
}
};
const checkGender = (target: IGenderTarget, env: IEnv): Result => {
if (typeof env.user.gender !== "string") {
return { type: "ignore" };
}
return env.user.gender === target.value
? { type: "success" }
: { type: "failure", reason: "gender is not " + target.value };
};

코드의 길이가 길어보이지만 구조가 굉장히 간단한 함수들입니다.

체험하기

조건과 방문자 데이터의 설정에 따라 Result가 어떻게 달라지는지 체크해보세요.

조건 설정 (AND)


나이가
라면 노출
나이가
라면 노출
성별이
라면 노출

조건 데이터

{
  "type": "root",
  "child": {
    "type": "group",
    "operator": "and",
    "children": [
      {
        "type": "age",
        "operator": ">=",
        "value": 20
      },
      {
        "type": "age",
        "operator": "<",
        "value": 30
      },
      {
        "type": "gender",
        "value": "female"
      }
    ]
  }
}

방문자

나이
성별

실행

const result = checkTarget(root, { user });

결과

{"type":"failure","reason":"gender is not female"}

테스트

이제 테스트코드를 통해서 어떻게 동작하는지 봅시다. https://github.com/echoja/check-target 여기서 직접 npm install, npm test 하기만 하면 테스트 코드가 실제로 돕니다! 테스트 라이브러리는 Vitest라는 걸 사용했습니다.

테스트 코드는 test 함수로 테스트의 단위를 지정하고, 각각의 테스트에서 expect 함수로 예상했던 결과가 맞는지를 검사합니다. 예상대로라면 테스트 성공이고 예상과 다르다면 테스트가 실패합니다.


// 20대 여성 조건을 나타내는 데이터. 아까 만들었죠?
const target20female: Target = {
type: "root",
child: {
type: "group",
operator: "and",
children: [
{
type: "age",
operator: ">=",
value: 20,
},
{
type: "age",
operator: "<",
value: 30,
},
{
type: "gender",
value: "female",
},
],
},
};
// 20대 여성 타겟 테스트
test("20s female target check", () => {
expect(
checkTarget(target20female, { user: { age: 25, gender: "female" } }),
).toEqual({ type: "success" });
expect(
checkTarget(target20female, { user: { age: 19, gender: "female" } }),
).toEqual({
type: "failure",
reason: "Age is not greater than or equal to 20",
});
expect(
checkTarget(target20female, { user: { age: 30, gender: "female" } }),
).toEqual({ type: "failure", reason: "Age is not less than 30" });
expect(
checkTarget(target20female, { user: { age: 25, gender: "male" } }),
).toEqual({ type: "failure", reason: "gender is not female" });
});

아래 내용만 좀 더 자세히 뜯어봅시다.


expect(
checkTarget(target20female, { user: { age: 25, gender: "male" } }),
).toEqual({ type: "failure", reason: "gender is not female" });

  1. 배너의 노출 조건은 "20대 여성"이야.
  2. 그런데 실제로 방문한 사용자(env.user)는 25세 남성이야.
  3. 노출 조건 검사 결과는 "실패(failure)"라고 예상(expect)해. (왜냐하면 남성이니까…)
  4. 예상대로 실패라면 테스트는 성공이고, 예상과 달리 성공(success)이나 무시(ignore)가 나온다면 테스트는 실패해.

checkTarget 의 인자는 조건 값(target)과 현재 상태(env)이고 결과는 세 가지 중 하나로 정해져있습니다. 이 함수는 순수 함수입니다. 즉 인자 값이 똑같다면, 결과 값도 무조건 같습니다. 사이드이펙을 주지도 않고 받지도 않습니다. 이러한 순수 함수는 테스트하기가 굉장히 좋습니다. 아주 적합합니다. 경우의 수를 적절히 넣어주면 그 함수의 동작은 신뢰가 두터워집니다.

마치며

우리는 이 타입 시스템과 함수로 이제 조건 검사를 유연한 구조로 만들었습니다. checkTarget의 결과에 따라서 배너를 노출할지 말지 결정할 수 있게 되었습니다. 김사장이 이런저런 요구를 하더라도 잘 들어줄 수 있을 것 같네요!

다음 편에는 비동기 조건이 있으면 어떻게 할지를 살펴보도록 하겠습니다. 예를 들어 나이대 정보는 바로 알 수 있지만 성별 정보는 API 요청을 해야 알 수 있는 상황을 다루겠습니다. 또한 기본값을 미리 설정해둔 다음 "알 수 없음"으로 판명난다면 기본값으로 Fallback하도록 해보겠습니다.

감사합니다.