Published on

tWIL 2022.08 2주차

Authors

이번 주는 폭우가 내내 와서 라이딩은 토요일에서야 할 수 있었다. 출발은 했지만 목적지에 도착하지 못하고 돌아왔는데 이유는 진흙 라이딩. 아끼던 벨로쌈바 클릿슈즈가 진흙으로 뒤덮혔고 자전거도 진흙으로 범벅이 되어 돌아올 수 밖에 없었다. 아 그냥 비가 아니라 역대급 폭우 이후에 라이딩을 나가면 안되는 것이었다. (자전거길 닦아 주세요 ㅠㅠ)

클라우드 벤더 제품 커스터마이징

AWS Cognito

이번 주도 벤더 제품의 커스터마이징의 어려움이 많았다. 예를들어 AWS Cognito를 사용할 경우 비밀번호 찾기는 사용자 sub, code, 그리고 신규 비밀번호를 한번에 입력해야 했다. code를 verify하는 함수가 SDK에도 없었다. 만약 code verify를 한 후 비밀번호 수정이 들어가는 방침을 정했다면 기획이 수정되어야 하는데 만약 그렇게 개발쪽에서 비즈니스 로직이 흘러가지 않는다면 문제라고 본다. 또한 SMS, 이메일로 6자리 코드를 받는 로직도 커스터마이징이 어려운 사유가 나중에 문제가 될 수 있다.

  • 운영: "개발측에서 그거 수정 안될까요?"
  • 개발: "네 안됩니다."
  • 기획: "왜 안되죠?"
  • 개발: "그건 이 제품을 선택하여 사용했기 때문입니다. 변경이 안되니 기획을 수정해 주시기 바랍니다."
  • 기획: "???"

의 결론이 오갈 것 같은 예측이 간다. 이건 개발자로서 좀 창피한 일이 될 것 같다.

  • 기획: "왜 개발측은 그걸 개발하지 못하고 그 제품을 써야했나요?"
  • 개발: "빠르게 개발하기 위해서 입니다."
  • 기획: "그래서 빠르게 개발이 되었나요?"
  • 개발: ""..."

그리고 국민 메시징 앱인 카카오 알림톡이 지원될 리가 없어보인다. 국내 서비스에 해외에서 발송될 수 밖에 없는(서울리전에서 AWS SMS 지원이 되지 않음)것도 사용자 입장에서는 이상하다고 생각할 수 있는 여지가 많다.

  • 기획: "국내 서비스인데 왜 해외에서 문자가 가나요?"
  • 개발: "이 제품의 SMS서비스는 서울 리전에 없기 때문입니다."
  • 기획: "타사 제품 서비스들은 여러 메시징 서비스를 지원하는데 다른 선택지는 없었나요?"
  • 개발: "있지만, AWS 서울리전에서 서비스가 가능할 때까지 기다려 보시죠?"
  • 기획: "???"

불편하다고 느끼면 수정이 되어야 할텐데 서울리전 서비스를 기다리는 것도 문제가 아닌가? 인포뱅크 같은 서비스를 이용한다면 오히려 국내서비스는 알림톡과 SMS까지 한번에 해결될 것이라고 보인다. 더 해결하기 어려운 문제는 AWS Cognito에서 사용자를 삭제하는 Lambda trigger가 없다. 이건 참 너무 답답하다. 결국 역방향으로 해야했다. DB에서 삭제가 걸리면 Cognito Provider API로 Admin 로직을 수행해야 한다. 이러면 나중에 데이터 Sync의 문제가 불거지며 시스템 관리자는 AWS Cognito로 사용자풀을 관리하면 안된다. 그러면 Cognito를 쓰는 의미가 없어질 것 같은데... 이 문제는 DynamoDB를 쓰면 DB PostHook으로 해결한다고 되어있지만, 이것도 해봐야알고 일단 Cognito로는 다른 트리거를 추가할 수 없다. 그리고 DynamoDB 거기는 어떤 더 복잡한 문제가 도사리고 있을 것 같아서 두렵다. Unauth Role은 사실 API에서 충분히 구현해 낼 수 있는 것이라 큰 장점은 아니라고 본다. 그렇다면 난 이것을 왜 써야하는 것인지 답을 듣고 싶다. 더 스트레스 받는 점은 로컬환경 테스트가 어렵기 때문에 테스트는 매번 Dev 배포를 해야 가능하다. 내가 수정하는 코드 바로바로 테스트가 안되고 배포해봐야 오류를 찾아볼 수 있다. 결국 개발시간은 길어지며 테스트 환경은 복잡해 지고 로컬 환경 Unit Test 없이 E2E 테스트만 개발자가 수동으로 수행해야 한다. 참 난감하면서도 개발과정이 너무나 번거롭다.

AWS Amplify

AWS Amplify를 쓰면서 봉착한 한계점에 그나마 Auth는 쓸만하겠지 싶었는데 Storage, API, Lambda 더불어 심각한 문제다. 지난 tWIL에서도 언급했듯이 useAuthentication이 상태업데이트 하지 않는 문제도 커스텀하게 해결해버렸고, 타입스크립트를 지원하지 않는 Amplify로 커스텀하게 프로젝트를 구성해서 prehook을 만들어 빌드해야 하며, Storage는 protected의 경우 기형적으로 파일이름만을 key값으로 반환하며 사용자풀이 아닌 IdentityPool의 폴더가 생성되어 Admin 롤을 가진 사용자도 그 파일을 공유받을 수 없다. 결국 S3 SDK를 써서 Presigend URL를 만들어서 써야한다. 결과적으로 무언가 하나를 동작 시키기 위해 다른 코드를 쓰거나 다른 패키지를 요구하게 된다. 그 패키지도 신규 JavaScript SDK와 구 JavaScript SDK가 혼재하게 되어있어서 두 SDK를 적절히 찾아서 써야한다. 여기서 빠른 결론을 지으면 Amplify 에서 쓸만한 건 하나도 없는 것 같다. 오히려 개발 코드량도 많아지고 이런 저런 패키지로 커스텀으로 풀어야 하니 개발자에게 편리함을 준다고 전혀 볼 수 없다. 차라리 그 시간동안 이러한 제품 없이 비즈니스 로직을 만들었으면 더욱 빨리 끝냈을 일들이다.

결론

Amplify를 써서 프로젝트 개발을 하는 과정은 결국 빠르지 않다. 기획자가 Amplify를 공부하고 이해해야 하며, Amplify UI로 스튜디오를 쓴다면 디자이너에게 Amplify 컴포넌트를 이해시켜야 한다. 그렇다면 결론은 Amplify는 섬세한 기획따위는 무시한 채 개발독자적으로 초급개발자가 토이프로젝트를 진행할 때 적절해 보인다. 계속 Amplify를 까는 글을 쓰고 있지만, 어쩔 수 없다. 뒤쳐져도 너무 뒤쳐졌다.

  • Amplify CLI: 요즘 여느 오픈소스 진영 개발 컨퍼런스를 보면 발표자가 청중들에게 "AWS CLI 쓰는 사람 손들어 보실까요?"(여기서는 부정적인 의미로 손들면 민망한 상황) 할 정도이다. 클라우드의 한계로 보아야할 지 이 제품만 그런지 알 수 없다. 다른 클라우드는 사용해보질 않아서 아무래도 Amplify는 슬며시 버리는 것이 아닌지 의심스럽다. 아니면 다른 제품을 만들던가... 아무래도 이 봉착점을 해결해 주려면 AWS는 더많은 리소스를 투입해야할 것으로 보인다. 그렇게 리소스를 투입해서 현시점을 따라잡았어도 오픈소스는 더 발전해 있는 모습에 좌절할 수도 있지만...

  • Amplify UI: 헤드리스 컴포넌트 RadixHeadless UI 그리고 배터리드 컴포넌트 Mui, Chakra 사이에 있다고 한다. 응? 써보면 알게된다 절대 동의할 수 없기 때문이다. 말그대로 개발자를 여기로 끌어모으려고 한 워딩으로 밖에 볼 수 없다. 그 사이에 어디에 있는 것은 아닌것 같고 헤드리스 컴포넌트도 아니고 배터리드 컴포넌트도 아닌 것이다. 사기꾼으로 보긴 그렇지만 잘 모르는 초보 개발자들을 끌어들이려고 쓴 워딩으로 보인다.(대기업이라면 그러지 말았으면 한다. 아니면 개발자 서포트를 잘해주던지) Flutter와 Vuejs등등을 지원하는데 한가지라도 잘했으면 좋겠다. 다른 프론트 진영도 아마 비슷한 문제를 겪을 것이라고 본다. 프론트 개발자가 MUI를 쓴다면 Amplify UI는 말리고 싶다. 호환성에서 문제가 발생할 확률이 높아보인다. 즉, Amplify UI를 쓰면 그것만 쓰는 방법을 채택해야 할 것 같다. 다른 테마를 함께쓰는 건 무리일 것으로 보인다. 그리고 자동생성되는 컴포넌트는 jsxd.ts로 만들어진다. 우리가 자주 사용하던 상위 props 타입을 하위 루트 Element에 rest타입으로 내려주기는 하나 getOverrideProps으로 overrides시킨다. 그런데 타입을 보니...

export declare const getOverrideProps: (
  overrides: EscapeHatchProps | null | undefined,
  elementHierarchy: string
) => {
  [k: string]: string;
};
export declare type EscapeHatchProps = {
  [elementHierarchy: string]: Record<string, string>;
};

Figma 싱크하면서 컴포넌트 style Prop으로 사용해야 할 Props이 다 Root props에 있다. 이건 큰 문제가 아니라고 생각할 수 있지만, 위의 코드처럼 getOverrideProps처럼 "잘 쓰여진 any타입"을 오버라이드 시킨다. 이걸 어찌하나요.

뭐 더 면밀히 살펴보면 여기저기 터질 것이 한 두개가 아닌 것 같아 초반부터 이 워크플로는 제외시켰지만 이건 답이 아니란 것은 확실히 알겠고, 잘 만들어진 헤드리스 컴포넌트 기반에서 한땀한땀 Theming을 하고 한땀한땀 컴포넌트를 만들어 두어야 나중에 문제 없을 것 같다는 것은 확실하다.

Amplify를 한마디로 표현하면 "우리가 개발자 경험을 최대로 할 수 있는 미로는 다 만들었습니다만 대부분 출구를 찾을 수 없을 것입니다." 라고 말할 수 있겠다.

IaC

IaC는 AWS copilot 에서 CDK로 전환하는 것 같은데, CDK는 TypeScript로 코딩할 수 있다. 이것은 좀 희소식인데 시간날 때 CDK로 인프라를 구축해보고 파악해봐야 겠다.

API Key 생성

현재 Cognito를 사용하기 위해 여러 마이크로 서비스에서 메인으로 동작하는 모놀리식 API에 접근할 경우가 있다. 느슨한 연결에서도 서로의 데이터는 필요하면서 DB에 값을 쓸 경우도 있기도 하고, 데이터를 주고 받는 일은 불가피하다. 여러 방법을 찾아보았다. 그 중에 JWT를 사용하여 인증토큰을 설정하는 방법도 있지만 payload의 값의 보안을 유지해야할 필요가 있을 수 있다. 그런 경우의 API 토큰 workaround 를 찾아 보았지만 best practice를 설명한 곳이 없었다. PEM키를 만들어서 private키와 public키를 만들어 쓰는 예제는 클라이언트에 public 키를 담기에도 사이즈가 너무 크고, Redis 같은 곳에 랜덤키를 발급하고 저장 한 후에 API요청이 있을 때 마다 키를 찾는 방식이 있었지만 키를 캐시 스토어에 저장한다 해도, 스케일링되는 컨테이너에서는 각자가 가지고 있을 수 없기에 별도로 캐시 스토어 컨테이너를 띄워야 한다. 해시값을 salt로 생성하는 방식 (이것은 password값을 DB에 저장할 때 많이 쓰임)의 문제는 누구든지 payload를 알 수 없고, payload값을 비교해서 일치하는지 여부만 있었다. 그리고 나머지 대부분은 클라이언트에서 요청할때 만드는 키의 예제들이었다. 하지만 비밀키를 API만 알고 있고 DB 쿼리를 하지 않으며, Payload값을 서버만 알 수 있는 방법은 찾기 어려웠다.

생각한 방식에서는 JSON Web token을 사용하는 방식이 있는데, Payload 내용까지 암호화(encrypt) 하여 토큰을 만들 수 있겠다. 이것도 괜찮은 방법이긴 하다. 하지만 Payload의 키를 굳이 누군가가 읽어야 할 필요가 없기 때문에 best practice라고 보기 어려울 것 같다. 결국 Node APIcrypto를 사용하여 만들어 보았다.

여기서는 appKeysecretKey를 발급하여 마이크로 서비스의 환경변수에 셋팅하기 위한 방법을 Node API를 참고하여 구현하였다. 필요하다면 만료일을 Payload에 담지 않고도 이 2개의 키(만료일 필드 없이)를 JSON Web token(만료일 셋팅)에 담아서 인증토큰에 셋팅하는 방법도 구현해도 될 것 같다. 이 만료일 부분은 요청 시점에 키에 대한 유효시점을 둘 지, 발급한 키 자체의 유효기간을 설정할지 아래 함수를 사용하는 사람의 정책에 따라 유연하게 쓸 수 있을 것 같다.

API Key generator

Generating appKey and secretKey by using privateKey(length: 16) 여기서는 privateKey를 Initialization vector로 활용하며, API가 노출시키면 안되는 키로 설정한다.

Payload는 마이크로 서비스의 정보를 담을 임의의 타입이다. 여기서는 내가 사용할 타입으로 만들었고, 키 자체의 보안 강화를 위해 expiredAt을 설정하여 키를 만료시켜야할 필요도 있다.

createApiKey.ts
import { createHmac, randomBytes, createCipheriv } from "crypto";

export enum Env {
  dev = "development",
  prod = "production",
}

export interface Payload {
  serviceName: string;
  appId: string;
  env: Env;
  issuedAt: Date;
  expiredAt: Date;
}
/**
 * It takes a private key and a payload, and returns an appKey and a secretKey
 * @param {string} privateKey - The private key that you can find in your account settings.
 * @param {Payload} payload - This is the data that you want to encrypt.
 * @returns An object with two keys, appKey and secretKey.
 */
export function createApiKey(privateKey: string, payload: Payload) {
  const key = randomBytes(16);
  const appKey = createHmac("sha256", privateKey)
    .update(key)
    .digest("hex")
    .substring(0, 16);
  const cipher = createCipheriv("aes-256-cbc", Buffer.from(privateKey), appKey);
  const encrypted = cipher.update(JSON.stringify(payload));
  const secretBuffer = Buffer.concat([encrypted, cipher.final()]).toString(
    "base64",
  );
  const secretKey = Buffer.from(secretBuffer, "utf8").toString("base64");
  return { appKey, secretKey };
}

이 함수는 16자리 privateKey를 받아 payload값을 저장하는 appKeysecretKey를 생성한다. privateKey는 API secret 환경변수에 저장되어 있어야 하고 노출되면 안된다.

Validating & Check payload

Node API의 cryptocreateDecipheriv 함수를 사용하여 privateKey를 받아 secretKey에 담긴 payload를 얻는다.

verifyApiKey.ts
import { createDecipheriv } from "crypto";

/**
 * It decrypts the secret key using the private key and app key
 * @param {string} privateKey - The private key that was generated when the app was created.
 * @param {string} appKey - The app key that you received from the API.
 * @param {string} secretKey - The secret key that you received from the API.
 * @returns Payload string
 */
export function verifyApiKey(
  privateKey: string,
  appKey: string,
  secretKey: string,
) {
  const secretKeyDecoded = Buffer.from(secretKey, "base64").toString("ascii");
  const decryptor = createDecipheriv("aes-256-cbc", privateKey, appKey);
  const payloadString = `${decryptor.update(
    secretKeyDecoded,
    "base64",
    "utf8",
  )}${decryptor.final("utf8")}`;
  return payloadString;
}

JSON.stringify()하여 string으로 넘겼기 때문에 이 Payload string값을 JSON.parse() 해야한다. 하지만 이 JSON 형태의 string이 아닌 경우 JSON.parse()는 오류를 발생시킨다. 이 오류가 발생할 경우 catch도 하면서 우리가 정해놓은 Payload 타입을 가지고 Open API specification 스키마를 만든 후 이 Payload 값을 validation을 해야 정상적으로 Payload 값이 넘어 왔음을 확정할 수 있다.

직접 OAS 스키마를 만들어도 좋으나, 여기서 자동화 해주는 CLI 패키지 typescript-json-validator가 있다.

아래의 Payload타입에 대한 validator를 생성하면

export interface Payload {
  serviceName: string;
  appId: string;
  env: Env;
  /**
   * @TJS-format date
   */
  issuedAt: Date;
  /**
   * @TJS-format date
   */
  expiredAt: Date;
}
npx typescript-json-validator src/createApiKey.ts Payload

실행하면, src/createApiKey.validator.ts가 생성된다. 파일명에 validator를 postFix 한 validator이다.

아래는 자동 생성된 validator 이다.

createApiKey.validator.ts
/* tslint:disable */
// generated by typescript-json-validator
import { inspect } from "util";
import Ajv = require("ajv");
import Payload from "./createApiKey";
export const ajv = new Ajv({
  allErrors: true,
  coerceTypes: false,
  format: "fast",
  nullable: true,
  unicode: true,
  uniqueItems: true,
  useDefaults: true,
});

ajv.addMetaSchema(require("ajv/lib/refs/json-schema-draft-06.json"));

export { Payload };
export const PayloadSchema = {
  $schema: "http://json-schema.org/draft-07/schema#",
  defaultProperties: [],
  definitions: {
    Env: {
      enum: ["development", "production"],
      type: "string",
    },
  },
  properties: {
    appId: {
      type: "string",
    },
    env: {
      $ref: "#/definitions/Env",
    },
    expiredAt: {
      description: "Enables basic storage and retrieval of dates and times.",
      format: "date-time",
      type: "string",
    },
    issuedAt: {
      description: "Enables basic storage and retrieval of dates and times.",
      format: "date-time",
      type: "string",
    },
    serviceName: {
      type: "string",
    },
  },
  required: ["appId", "env", "expiredAt", "issuedAt", "serviceName"],
  type: "object",
};
export type ValidateFunction<T> = ((data: unknown) => data is T) &
  Pick<Ajv.ValidateFunction, "errors">;
export const isPayload = ajv.compile(
  PayloadSchema,
) as ValidateFunction<Payload>;
export default function validate(value: unknown): Payload {
  if (isPayload(value)) {
    return value;
  } else {
    throw new Error(
      ajv.errorsText(
        isPayload.errors!.filter((e: any) => e.keyword !== "if"),
        { dataVar: "Payload" },
      ) +
        "\n\n" +
        inspect(value),
    );
  }
}

쓰기 편한 이름으로 파일명을 변경하고, 몇가지 수정해야할 부분이 있다. interface Payload는 named export 했으므로 변경하고, json-schema-draft-06.json를 상단에 import한다. 따라서 tsconfig.json 설정에서 "resolveJsonModule": true 켜준다.

validate함수의 인자는 unknown이 아니라 string으로 변경하고, 여기서 JSON.parse()를 수행하도록 한다. 수정된 validator는

validator.ts
/* tslint:disable */
// generated by typescript-json-validator
import { inspect } from "util";
import Ajv from "ajv";
import schema from "ajv/lib/refs/json-schema-draft-06.json";
import { Payload } from "./createApiKey";
export const ajv = new Ajv({
  allErrors: true,
  coerceTypes: false,
  format: "fast",
  nullable: true,
  unicode: true,
  uniqueItems: true,
  useDefaults: true,
});

ajv.addMetaSchema(schema);

export { Payload };
export const PayloadSchema = {
  $schema: "http://json-schema.org/draft-07/schema#",
  defaultProperties: [],
  definitions: {
    Env: {
      enum: ["development", "production"],
      type: "string",
    },
  },
  properties: {
    appId: {
      type: "string",
    },
    env: {
      $ref: "#/definitions/Env",
    },
    expiredAt: {
      description: "Enables basic storage and retrieval of dates and times.",
      format: "date-time",
      type: "string",
    },
    issuedAt: {
      description: "Enables basic storage and retrieval of dates and times.",
      format: "date-time",
      type: "string",
    },
    serviceName: {
      type: "string",
    },
  },
  required: ["appId", "env", "expiredAt", "issuedAt", "serviceName"],
  type: "object",
};
export type ValidateFunction<T> = ((data: unknown) => data is T) &
  Pick<Ajv.ValidateFunction, "errors">;
export const isPayload = ajv.compile(
  PayloadSchema,
) as ValidateFunction<Payload>;
export default function validate(value: string): Payload {
  const payload = JSON.parse(value);
  if (isPayload(payload)) {
    return payload;
  } else {
    throw new Error(
      ajv.errorsText(
        // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
        isPayload.errors!.filter((e: any) => e.keyword !== "if"),
        { dataVar: "Payload" },
      ) +
        "\n\n" +
        inspect(payload),
    );
  }
}

Payload validator를 만들었다. 이제 verifyApiKey 함수에 적용해보면,

verifyApiKey.ts
import { createDecipheriv } from "crypto";
import payloadValidator from "./validator";

/**
 * It decrypts the secret key using the private key and app key
 * @param {string} privateKey - The private key that was generated when the app was created.
 * @param {string} appKey - The app key that you received from the API.
 * @param {string} secretKey - The secret key that you received from the API.
 * @returns The secret key
 */
export function verifyApiKey(
  privateKey: string,
  appKey: string,
  secretKey: string,
) {
  const secretKeyDecoded = Buffer.from(secretKey, "base64").toString("ascii");
  const decryptor = createDecipheriv("aes-256-cbc", privateKey, appKey);
  const payloadString = `${decryptor.update(
    secretKeyDecoded,
    "base64",
    "utf8",
  )}${decryptor.final("utf8")}`;
  const payload = payloadValidator(payloadString);
  return payload;
}

마지막 이 validator에 원하는 조건을 더 추가할 수 있다. 예를들어 등록된 appId인지 확인하는 방법도 있을 수 있고, 키의 만료일이 지났는지 등 API 정책에 맞게 수정하면 된다.

Testing

generate-api-key 패키지를 사용하여 16자리 privateKey를 생성한다. 그리고 키 생성 및 키 검증을 수행해본다.

testing.ts
import { createApiKey } from "./createApiKey";
import { generateApiKey } from "generate-api-key";

const privateKey = gen({ length: 16 });
const key = createApiKey(pk, payload);

const payload: Payload = {
  serviceName: "AWS Lambda",
  appId: "lambda-12345678",
  env: Env.dev,
  issuedAt: new Date(),
  expiredAt: new Date("2100-12-31T15:00:00.000Z"),
};

// API 키 생성
const key = createApiKey(pk, payload);

console.log(key);
//
// {
//   appKey: 'e7e1....775',
//   secretKey: 'bWl....DJZdz09'
// }

// API 키 검증
const result = verifyApiKey(pk, key.appKey, key.secretKey);

// Payload
console.log(result);
//
// {
//   serviceName: 'AWS Lambda',
//   appId: 'lambda-12345678',
//   env: 'development',
//   issuedAt: '2022-08-09T15:56:54.762Z',
//   expiredAt: '2100-12-31T15:00:00.000Z'
// }

이제 키발급과 키 사용 이 verifyApiKey는 GraphQL context에 담아 인증 처리를 하면 된다.

GraphQL Codgen apollo-nextjs-ssr plugin

이제 슬슬 Admin도 커스터마이징을 할 시기가 도래할 것 같다. 운영에서 CRUD만 가지고 쓸 수 없는 경우가 있고 운영환경에 적합한 테이블을 만들어주어야 하기 때문이다. 현 next에서는 PrismaTable을 쓰고 있지만 여기 커스터마이징은 복잡할 것으로 보인다. 그래서 결국 우리가 오래전부터 해오던 graphql-codegen으로 CRUD 오퍼레이션을 생성하고, 각종 필터와 페이지네이션을 구현해야 한다. 그래서 graphql-codegen-apollo-next-ssr을 붙여서 getServerSideProps으로 SSR을 사용할 수 있고, 초기 렌더링 전 Hydrate를 위한 getStaticProps함수들을 자동 생성하도록 초기 작업을 수행했다.

codegen.yml
generates:
  src/generated/page.tsx:
    config:
      documentMode: external
      importDocumentNodeExternallyFrom: ./types
      reactApolloVersion: 3
      withHooks: true
      contextType: ApolloClientContext
      contextTypeRequired: true
      apolloClientInstanceImport: ../withApollo
    preset: import-types
    presetConfig:
      typesPath: ./types
    plugins:
      - graphql-codegen-apollo-next-ssr

generated 폴더에 page.tsx로 코드젠을 한다. withApollo로 HOC를 구성하고, Apollo 코드젠을 했던 TypeScript 타입은 자동 생성된 타입으로 DocumentNode를 사용하도록 설정하였다.

GraphQL-codegen은 hooks API도 제공했지만, prettier가 되지 않는 플러그인들이 있었는데 이 플러그인도 마찬가지였다. 결국 post:generate hook을 만들어 prettier로 해당 코드들을 수정하도록 했다.

package.json
{
  "scripts": {
    // ...omitted
    "post:generate": "prettier --write src/generated/**/*.*",
  }
}

이번 주부터 여기도 빌드업해가야 한다. 갈길이 멀다. 휴.

Cloud Software

클라우드에서 제공하는 서비스들 생각해보면 오픈 소스 진영에서 대부분 가져온 프레임워크 혹은 라이브러리 일 것임을 누구도 부정할 수 없다. DevX 혹은 No-Code 함정에 빠지면 많은 길을 돌아가야한다. 그리고 행여나 사용한다 해도 완벽할 수 없다. 오픈 소스 진영은 계속해서 진화하기 때문에 그 속도에 따라갈 수 없다. 그 이유는 클라우드 서비스를 기업의 직원들이 만들어 나간다면, 오픈 소스 진영은 무한대에 가까운 전세계 개발자들이 참여하고 피드백하고 반영되고 있기 때문이다. 오픈 소스는 소스를 공개했지만 오픈 소스 진영은 수많은 베타테스터들을 얻었다. 이 차이는 극복되어질 수 없는 것이라고 본다. 초보 개발자라면 이러한 속임수에 속아 소프트웨어 선택하는데 공을 들이지 말았으면 한다. 이 오픈 소스 진영에서 개발되는 속도는 빠르다. 공부하고 배울 것이 너무나 많다.

우려되는 점도 몇가지 있다. 이 오픈 소스들은 대기업에서 관리되는 경우가 많다. 결국 여기도 투자를 받고 매출을 만들어야 하기 때문에 PaaS에는 비용을 받고 소스는 오픈 소스 형태인 하이브리드 개념으로 오픈 소스로 변화되고 있는 것 같다. (코드는 오픈되어 있지만 더 좋은 서비스 혹은 개발자 서포트 및 PaaS를 제공하며 사용료을 요구) 중요한 점은 소스는 공개되어 있으며(공개되어 있어야 하며) 이 장점을 최대한 활용하여야 한다고 본다. 대신 이 오픈 소스들은 서비스를 제공하는 대신 베타테스터들을 얻는다. 요즘 클라우드로 진출하는 이 오픈 소스들은 다시 DevX를 제공하며 사용료를 요구한다. 이 점이 조금 불편하지만 이해는 된다. 엔터프라이즈급 서비스를 원하는 기업들의 요구 그래서 다시 생각해보면 고품질의 오픈 소스에 대한 믿음은 대기업의 손을 타게 되어있다는 아이러니가 남아있다.

그렇다고 우리가 기존 오픈 소스를 버리고 신생하는 오픈 소스에 눈을 돌릴 필요는 없다. 언제까지나 오픈 소스로 참여한 코드들은 전세계 개발자의 것(MIT License). 제공하는 오픈 소스 서비스는 마음껏 활용하면서 DevX는 알아서 한땀한땀 만들어가며 챙겨야할 필요가 있다.

클라우드는 대기업들이 잘 구축해놓은 성공한 인프라만 사용하도록 하자. IaaS로만 말이다. netlify CEO가 얘기하듯 "Where is my data?" 내 생각은 컨테이너와 DB 클러스터는 클라우드에서 IaaS 관리 형태로 최소한으로 이용하고 데이터는 클라우드에게 내어주지 말자.

그리고 초기 단계에서부터 MSA라는 선택지는 무리수를 둔것 같다. 디자인 패턴으로 유명한 마틴 파울러는 "Don't even consider microservices unless you have a system that's too complex to manage as a monolith."" 즉, "모놀리식으로 관리하기에 특별히 복잡한 시스템을 운영할 상황이 아니면 마이크로서비스는 고려할 필요조차 없다" 유행한다고 모두다 따라할 필요는 없다. DAU 1백만 이상 성능상의 필요에 의한 것이 아니라면 MSA는 나중에 고려해도 될 일이라고 생각한다.

마이크로 서비스의 한계의 글에 따르면 마이크로 서비스 도입에 대한 근거를 아래 사항에 두고 있다.

  • 비용: 특정 서비스 아키텍처를 도입할 경우 비용을 얼마나 절감할 수 있는가?
  • 개발 생산성: 마이크로서비스를 요구할 만큼 시스템 복잡도가 높은가? 또는 복잡도를 지나치게 높인 마이크로서비스가 생산성을 저해하고 있지는 않은가?
  • 운영: 개발팀에게 개발과 운영을 동시에 요구할 만큼 인프라가 준비되어 있는가?

이 세가지 다 부합하지 못하고 있는 상황에서 처음부터 MSA를 고려할 필요는 없어보인다. 프로젝트 성공 이후 MSA로 옮겨가는 방법이 전혀 없는 것도 아니기 때문이다. 결국 초기부터 인프라를 넘어 플랫폼 그리고 소프트웨어까지 빌려 쓰는 상황은 적절해 보이지 않는다.