arch-spatula / arch-spatula.github.io

Arch-Spatula의 레시피
https://arch-spatula.github.io/
3 stars 0 forks source link

[Draft] 25주차 #88

Closed arch-spatula closed 1 year ago

arch-spatula commented 1 year ago

글쓰기 주제

blog/2023-06-25

참고할 자료

arch-spatula commented 1 year ago

vite alias 설정법

https://dev.to/tilly/aliasing-in-vite-w-typescript-1lfo

예전에 발견하고 저장 했다가 나중에 다시 발견한 자료입니다.

Danet

Danet을 시도해보고 싶은 이유는 더 좋은 백엔드 엔지니어링을 하고 싶었기 때문입니다. 물론 원래 의미인 테스트 가능성은 퇴색되었습니다.

그리고 deno에서 통신 관련 테스트할 때는 그냥 fetch사용하면 되는 것이었습니다. 너무 복잡하게 생각했습니다.

Danet이란 무엇인가?

Danet이란 무엇인가? Nest.js의 Deno 런타임 버전입니다. 물론 흉내 내보려고 시도한 다른 라이브러리들이 있습니다.

Name Star Fork Watch
Dero 19 0 4
DestJS 61 2 1
danet 139 14 2

위 3개는 모두 Nest.js를 Deno에서 구현하기 위한 라이브러리들입니다. 하지만 여기서 비교할 것은 star 개수입니다. 가장 성숙도가 높아 보이는 것은 danet입니다.

공식 블로그도 있습니다. 하지만 자료가 2023년 06월 18일 기준 블로그 포스트가 4개밖에 없습니다.

설치

Nest의 장점은 CLI가 좋다고 합니다. Danet도 거기에 맞게 활용하면 됩니다.

deno install --allow-read --allow-write --allow-run --allow-env -n danet https://deno.land/x/danet_cli/main.ts

만약을 위해 설치하면 경로를 잘 확인하도록 합니다.

예를 들면 export PATH="/Users/usernmae/.deno/bin:$PATH"처럼 피드백을 줄 것입니다.

이렇게 되면 cli가 설치된 것입니다.

danet new my-danet-project

여기서 주의할 점들이 있습니다. 실행하고 command가 zshrc에 설정이 안되어 있을 수 있습니다.

cd #루트 디렉토리 이동
nvim .zshrc #zshrc 를 편집합니다.

위 설치할 때 경로를 활용해서 .zshrc을 편집해야 합니다.

# deno danet
export PATH="/Users/username/.deno/bin:$PATH"

위 내용을 추가하고 :wq로 저장합니다.

source ~/.zshrc

그리고 실행 명령하고 터미널을 재시작하고 다시 danet의 연결을 확인해봅니다.

danet -h

피드백을 주면 연결에 성공한 것입니다.

참고로 deno에는 uninstall 명령도 있습니다.

deno uninstall danet

위 명령으로 필요하면 삭제하면 됩니다. 물론 .zshrc설정도 다시 필요할 것입니다.

arch-spatula commented 1 year ago

카드 form 양끝 맞추기

props 하나더 추가해서 수동으로 맞췄습니다.

토큰 갱신 처리하기

/**
 * - access_token을 갱신하는 함수
 * - 함수 아래 interceptors만 의존해야 하기 때문에 export하지 않음
 * - API 명세의 요구에 맞춰 header를 활용하고 refresh token을 설정
 */
async function refreshAccessAPI() {
  try {
    const sessionToken = sessionStorage.getItem('sessionToken');
    if (!sessionToken) throw Error('sessionToken');

    const {
      data: { access_token },
    } = await authClient.post<{
      success: boolean;
      access_token: string;
    }>(API_URLS.REFRESH, null, {
      headers: {
        Authorization: `Bearer ${sessionToken.slice(
          1,
          sessionToken.length - 1
        )}`,
      },
    });

    localStorage.setItem('accessToken', `"${access_token}"`);

    return access_token;
  } catch (error) {
    localStorage.clear();
    sessionStorage.clear();
    if (error instanceof AxiosError) {
      return error.response?.data;
    }
  }
}

refreshAccessAPI 함수를 구현해서 갱신 요청을 만들었습니다.

원래 authClientheader 없이 요청을 보낼 수 있도록 설정했습니다. 하지만 갱신을 위해 header에 토큰을 설정하고 갱신 요청을 보내도록 구현했습니다.

만약에 요청에 실패했으면 로그아웃 판정을 하고 웹 스토리지를 비우도록 했습니다.

axiosClient.interceptors.response.use(
  (res) => res,
  async (err) => {
    const {
      config,
      response: { status },
    } = err;
    if (config.url === API_URLS.REFRESH || status !== 401 || config.sent) {
      return Promise.reject(err);
    }

    config.sent = true;
    const accessToken = await refreshAccessAPI();
    if (accessToken) {
      config.headers.Authorization = `Bearer ${accessToken}`;
    }
    return axiosClient(config);
  }
);

직접 구현하면서 제가 만든 백엔드에도 문제가 있었습니다. 원래 401에러를 돌려줘야 하는데 406에를 돌려주고 있었습니다.

오늘 배운 것 중 하나는 테스트 코드에는 status code도 포함해서 테스트 해야한다는 것입니다. 일단 msw를 보류하기 잘한 것 같습니다. 저의 백엔드 엔지니어링을 신뢰할 수 없습니다.

https://github.com/arch-spatula/flash-card-frontend/pull/42

https://github.com/arch-spatula/flash-card-backend/pull/46

프론트엔드 백엔드 모두 결합테스트가 필요합니다.

그리고 요청관련 함수는 그냥 button에 따로 구현하는 것이 정신 건강에 좋은 것 같습니다.

이상 오늘 2시간 짜리 삽질이었습니다.

로딩은 지원하지만 너비가 고정되어 있습니다.

import { ButtonWrapper, TextWrapper, LoaderWrapper } from './Button.style';
import { PulseLoader } from 'react-spinners';

type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
  children: React.ReactNode;
  onClick?: React.MouseEventHandler<HTMLButtonElement>;
  isLoading?: boolean;
};

/** loader는 텍스트 너비가 필요해서 2개의 VisibilityWrapper 활용 */
export function Button({
  children,
  onClick,
  isLoading = false,
  ...other
}: ButtonProps) {
  return (
    <ButtonWrapper
      onClick={onClick}
      disabled={isLoading}
      isLoading={isLoading}
      {...other}
    >
      <TextWrapper isLoading={isLoading}>{children}</TextWrapper>
      {isLoading && (
        <LoaderWrapper>
          <PulseLoader
            color="#ffffff"
            loading
            margin={4}
            size={12}
            speedMultiplier={0.5}
          />
        </LoaderWrapper>
      )}
    </ButtonWrapper>
  );
}

전략을 바꿨습니다. loading은 조건부 랜더링을 계속 처리합니다. 하지만 로딩하는 동안 텍스트도 조건부 스타일링으로 안보이게 만듭니다.

존재하지만 가시적으로 보이지 않게 때문에 텍스트 크기만큼 영역을 확보합니다.

import styled from '@emotion/styled';

export const ButtonWrapper = styled.button<{ isLoading: boolean }>`
  all: unset;
  ${(props) => props.theme.fonts.body16Regular}
  border-radius: 0.5rem;
  border: none;
  /* disabled 이면 gray가 되고 loading이면 green을 유지 */
  background-color: ${(props) =>
    props.disabled && !props.isLoading
      ? props.theme.colors.gray400
      : props.theme.colors.green};
  color: ${(props) => props.theme.colors.white};
  height: 2.75rem;
  position: relative;
  width: fit-content;
  min-width: 5.25rem;
  display: flex;
  align-items: center;
  justify-content: center;
`;

export const TextWrapper = styled.span<{ isLoading: boolean }>`
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  max-width: 100%;
  color: ${(props) =>
    props.isLoading ? 'transparent' : props.theme.colors.white};
  margin: 0 1rem;
`;

export const LoaderWrapper = styled.div`
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  display: flex;
  align-items: center;
  justify-content: center;
`;

이렇게 되면 레이아웃 쉬프트 없이 버튼에 로딩 상태를 보여줄 수 있습니다.

falsy를 조심합시다.

tl;dr 0을 숫자로 전달하면 0은 falsy해서 에러를 던졌습니다.

const { question, answer, submitDate, stackCount } = await request.body().value;
if (!question || !answer || !submitDate || stackCount === undefined)
  // stackCount의 0은 falsy 하기 때문에 undefined으로 활용
  throw Error('question, answer, submitDate, stackCount 중 값이 1개 없습니다.');

백엔드 엔지니어링 할 때는 테스트 코드를 정말 성실하게 작성하고 테스트 방법론을 열심히 공부합시다.

유효성 검증

const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

export function checkEmail(email: string) {
  return emailRegex.test(email);
}

이메일 유효성 검증을 합니다.

import { describe, it } from 'vitest';
import { checkEmail } from '.';

describe('Email Validation', () => {
  it('should return true for valid email addresses', () => {
    expect(checkEmail('test@example.com')).toBe(true);
    expect(checkEmail('user1234@gmail.com')).toBe(true);
    expect(checkEmail('john.doe@company.co.uk')).toBe(true);
    expect(checkEmail('info@openai.com')).toBe(true);
  });

  it('should return false for invalid email addresses', () => {
    expect(checkEmail('notanemail')).toBe(false);
    expect(checkEmail('invalid@')).toBe(false);
    expect(checkEmail('invalid@example')).toBe(false);
    expect(checkEmail('invalid@domain')).toBe(false);
    expect(checkEmail('spaces in@domain.com')).toBe(false);
  });
});

단점은 사용자에게 의미있는 피드백을 제공하기 위해서 여러 정규 표현식을 분리해야 합니다. 이것은 다음에 걱정하겠습니다.

아마 정규표현식에 switch case 문을 활용하고 각 case마다 독립적으로 검사하고 실패하는 이유를 피드백하도록 설계했던 기억이 납니다.

르블랑의 법칙에 당하겠지만 상관없습니다.

const passwordRegex = /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d!@#$%^&*]*$/;

export function checkPassword(password: string) {
  return passwordRegex.test(password);
}

비밀번호는 영어, 숫자 포함 8자리 이상이고 특수문자는 선택으로 뒀습니다.

import { describe, it } from 'vitest';
import { checkPassword } from '.';

describe('Password Validation', () => {
  it('should return true for valid passwords', () => {
    expect(checkPassword('Abc12345')).toBe(true);
    expect(checkPassword('Password123')).toBe(true);
    expect(checkPassword('secureP@ssw0rd')).toBe(true);
    expect(checkPassword('1#2345678Ab')).toBe(true);
  });

  it('should return false for invalid passwords', () => {
    expect(checkPassword('weak')).toBe(false);
    expect(checkPassword('password')).toBe(false);
    expect(checkPassword('12345678')).toBe(false);
  });
});

사전에 테스트 설정해서 좋습니다. 한땀한땀 타이핑했으면 검증 지옥이었을 것입니다.

chatGPT 만세! 이런 고달프고 고통받는 작업을 저 대신 고통받아줬습니다.

Creating a Deno https server

Creating a Deno https server - stackoverflow

import { serveTLS } from 'https://deno.land/std/http/server.ts';

const body = new TextEncoder().encode('Hello HTTPS');
const options = {
  hostname: 'localhost',
  port: 443,
  certFile: './path/to/localhost.crt',
  keyFile: './path/to/localhost.key',
};
// Top-level await supported
for await (const req of serveTLS(options)) {
  req.respond({ body });
}

https를 설정하는 방법이 있었습니다.

여기서도 인증이 필요한 것은 동일합니다.

arch-spatula commented 1 year ago

잡생각

글을 못쓰면 개발을 못한다.

개발을 잘하는 사람은 글도 잘쓴다. 이 명제의 대우명제는 글을 못쓰면 개발도 못한다입니다. 글을 잘 쓰는 사람이 개발을 잘 하는 것은 아닙니다. 즉 역은 성립하지 않을 수 있습니다. 개발을 못하는 사람은 글도 못쓴다. 부정도 성립하지 않을 수 있습니다. 직업적으로 글을 잘써야 하지만 개발과 무관하게 일하는 사람들은 많습니다.

이런 관점으로 보면 글을 못쓰는 것을 피하면 개발을 못 하는 것을 피할 수 있습니다.

자신감이 많이 하락했다면 하락한 만큼 학습하는 것일지도...

프로젝트 진행하면서 만약 자신감이 많이 하락했다면 본인의 부족한 점을 자각했을 가능성이 있습니다.

부족한 점을 자각하면 다음은 대응하고 성장하면 됩니다.

개발적인 고민이 많다면 개발적으로 성장하고 있을 가능성이 있습니다. 고민은 성장의 기반 중 하나입니다.

고민을 한다는 것은 문제를 인식하고 해결을 생각하고 있을 것입니다.

시작은 인식부터입니다. 다음은 문제를 정의하는 것입니다. 문제를 정의하고나면 해결하기 위해 고민하고 수정하는 것입니다.

카드 시간 보여주기

튜토리얼 리소스 접근하기

자바스크립트에서 가장 이상한 Date 문법 - 코딩애플

먼저 쉬운 것부터 학습해보겠습니다.

const date = new Date();
console.log(date); // Tue Jun 20 2023 06:01:27 GMT+0900 (한국 표준시)

Tue Jun 20 2023 06:01:27 GMT+0900 (한국 표준시)이런 형식을 보고 RFC 형식 시간이라고 부르는 듯합니다.

const date = new Date();
console.log(date.toISOString()); // 2023-06-19T21:02:35.889Z

2023-06-19T21:02:35.889Z 가독성이 당황스러울 정도로 좋아졌습니다.

라이브러리 활용하는 방법이 있지만 프로젝트가 한참 작아서 아직 설치하지 않기로 했습니다.

const date = new Date();
const foo = Intl.DateTimeFormat('kr').format(date);
console.log(foo); // 2023. 6. 20.

깔끔해졌습니다.

const foo = new Intl.RelativeTimeFormat('kr').format(-10, 'days');
console.log(foo); // 10일 전

이렇게 하면 표현을 할 수 있게 됩니다.

Time is Relative, even in JavaScript - Beyond Fireship

const formatter = new Intl.RelativeTimeFormat('kr');
const diff = new Date() - new Date('2023/06/18');

formatter.format(Math.round(-diff / (1000 * 60 * 60 * 24)), 'days'); // 2일 전

오늘 6월 20일 시점에 2일 전으로 설정할 수 있게 되었습니다.

2가지 형식

Wed May 17 2023 21:11:26 GMT+0900 (한국 표준시)

서버에서 통신할 때는 위과 같은 형식으로 생성했습니다.

2023-06-19T08:05:14.613Z

클라이언트에서 통신할 때는 위와 같은 형식으로 리소스가 생성되었습니다.

하지만 문자열로 대입하면 포멧팅 문제는 해결됩니다.

const submitDate = '2023-06-19T08:05:14.613Z'; // Wed May 17 2023 21:11:26 GMT+0900 (한국 표준시)
const formatter = new Intl.RelativeTimeFormat('kr');
const diff = new Date() - new Date(submitDate);

const showDate = formatter.format(
  Math.round(diff / (1000 * 60 * 60 * 24)),
  'days'
); // 2일 전

The right-hand side of an arithmetic operation must be of type 'any', 'number', 'bigint' or an enum type.

The right-hand side of an arithmetic operation must be of type 'any', 'number', 'bigint' or an enum type.

이런 에러가 발생했습니다.

const diff = new Date() - new Date('2023-06-19T08:05:14.613Z');

그냥 모두 number로 변환하면 됩니다.

const diff = +new Date() - +new Date('2023-06-19T08:05:14.613Z');

TS2362 - Error when doing arithmetic operations on date #5710

date 포멧팅 관심사 분리

화면상 랜더링을 보여주는데 형식 변환하는 관심사를 분리하는 것이 필요해보였습니다. 그리고 관심사를 분리하면 테스트 코드 작성도 쉬워보였습니다.

export function formatDate(
  submitDate: Date | string | number,
  stackCount: number
) {
  const formatter = new Intl.RelativeTimeFormat('ko');
  const diff = +new Date() - +new Date(submitDate);

  const showDate = formatter.format(
    Math.round(diff / (1000 * 60 * 60 * 24)),
    'days'
  );
  return showDate;
}
import { describe, expect, it } from 'vitest';
import { formatDate } from '.';

describe('dateFormat', () => {
  it('며칠 후', () => {
    const submitDate = new Date('2023-06-19T08:05:14.613Z');

    const showDate = formatDate(submitDate, 0);

    expect(showDate).toBe('1일 후');
  });
});

며칠까지는 문제가 없지만 달을 표시하는 것이 문제입니다.

또 현재를 표현하는 방법을 모르겠습니다.

테스트 코드를 작성하면서 생기는 단점인데 테스트가 일관된 결과를 갖으려면 기준일을 인자로 넘겨줘야 합니다. 저 테스트는 오늘은 맞아도 내일은 틀릴 것입니다. 다른 방법은 given에 date 전제에 추가 처리하는 방법도 있습니다.

JavaScript Intl API 사용하기

위 아티클이 좋아보입니다.

풀이까지 남은 기간 구하기

describe('intervalDate', () => {
  const baseDate = new Date('2023-06-01T00:00:00');

  it('should return the same date when count is negative', () => {
    const result = intervalDate(baseDate, -1);
    expect(result).toStrictEqual(baseDate);
  });

  it('should return the correct date when count is within range', () => {
    const result = intervalDate(baseDate, 0);
    expect(result).toStrictEqual(new Date('2023-06-01T00:10:00'));

    const result2 = intervalDate(baseDate, 3);
    expect(result2).toStrictEqual(new Date('2023-06-03T00:00:00'));

    const result3 = intervalDate(baseDate, 7);
    expect(result3).toStrictEqual(new Date('2023-06-15T00:00:00'));
  });

  it('should return the date with incremented year when count exceeds the range', () => {
    const result = intervalDate(baseDate, 12);
    expect(result).toStrictEqual(new Date('2024-06-01T00:00:00'));

    const result2 = intervalDate(baseDate, 15);
    expect(result2).toStrictEqual(new Date('2024-06-01T00:00:00'));
  });
});

구현할 테스트 케이스들입니다. 물론 실제로는 구현하고 테스트 케이스 작성하면서 확인했습니다.

export function intervalDate(date: Date, count: number) {
  const newDate = new Date(date);

  const intervalMap = [
    10, // 0 틀림 10분
    60, // 1번 맞춤 1시간
    60 * 24, // 2번 맞춤 내일
    60 * 24 * 2,
    60 * 24 * 3,
    60 * 24 * 4,
    60 * 24 * 7, // 6번 맞춤 다음주
    60 * 24 * 14, // 7번 맞춤 다다음주
    60 * 24 * 30.4375, // 8번 맞춤 다음달
    60 * 24 * 30.4375 * 2, // 9번 다다음달
    60 * 24 * 91.3125, // 10번 맞춤 다음분기
    60 * 24 * 182.625, // 11번 맞춤 다음반기
  ];

  if (count < 0) return newDate;

  if (count > 11) {
    newDate.setFullYear(newDate.getFullYear() + 1);
    return newDate;
  }

  newDate.setMinutes(newDate.getMinutes() + intervalMap[count]);

  return newDate;
}

배열을 활용해서 읽기만 하니까 이렇게 보면 구현은 단순합니다. 그전에는 if문, switch case문을 활용하고 있었습니다. map을 활용할까? 했었는데 읽기 위주는 굳이 사용할 필요가 없고 또 맞춘횟수가 인덱스랑 일치해서 배열을 활용했습니다.

순수함수라 비교적 테스트 작성이 쉬웠습니다.

남은 기간 포멧팅

import { describe, expect, it } from 'vitest';
import { formatDate, intervalDate } from '.';

describe('formatDate', () => {
  const baseDateStr = '2023-06-01T00:00:00';
  const baseDate = new Date(baseDateStr);

  it('should return "지금" when there is no remaining time', () => {
    const result = formatDate(baseDate, -1, baseDate);
    expect(result).toBe('지금');
  });

  it('should return formatted remaining time', () => {
    const now = new Date('2023-06-01T00:09:30'); // 9분 30초 후
    const result1 = formatDate(baseDate, 0, now);
    expect(result1).toBe('30초 후');

    const now2 = new Date('2023-06-01T00:55:00'); // 55분 후
    const result2 = formatDate(baseDate, 1, now2);
    expect(result2).toBe('5분 후');

    const now3 = new Date('2023-06-01T12:00:00'); // 12시간 후
    const result3 = formatDate(baseDate, 2, now3);
    expect(result3).toBe('12시간 후');

    const now4 = new Date('2023-06-02T00:00:00'); // 1일 후
    const result4 = formatDate(baseDate, 3, now4);
    expect(result4).toBe('1일 후');

    const now5 = new Date('2023-06-01T06:00:00'); // 2일 6시간 후
    const result5 = formatDate(baseDate, 4, now5);
    expect(result5).toBe('2일 후');

    const now6 = new Date('2023-06-01T12:00:00'); // 3일 12시간 후
    const result6 = formatDate(baseDate, 5, now6);
    expect(result6).toBe('3일 후');

    const now7 = new Date('2023-06-01T00:00:00'); // 7일 후
    const result7 = formatDate(baseDate, 6, now7);
    expect(result7).toBe('1주 후');

    const now8 = new Date('2023-06-01T00:00:00'); // 14일 후
    const result8 = formatDate(baseDate, 7, now8);
    expect(result8).toBe('2주 후');

    const now9 = new Date('2023-06-01T00:00:00'); // 1달 후
    const result9 = formatDate(baseDate, 8, now9);
    expect(result9).toBe('1개월 후');

    const now10 = new Date('2023-06-01T00:00:00'); // 2달 후
    const result10 = formatDate(baseDate, 9, now10);
    expect(result10).toBe('2개월 후');

    const now11 = new Date('2023-06-01T00:00:00'); // 3달 후
    const result11 = formatDate(baseDate, 10, now11);
    expect(result11).toBe('3개월 후');

    const now12 = new Date('2023-06-01T00:00:00'); // 6달 후
    const result12 = formatDate(baseDate, 11, now12);
    expect(result12).toBe('6개월 후');

    const now13 = new Date('2023-06-01T00:00:00'); // 1년 후
    const result13 = formatDate(baseDate, 12, now13);
    expect(result13).toBe('1년 후');
  });
});

구현할 징그러운 테스트 케이스입니다. 물론 ChatGPT를 활용해 작성했습니다.

/**
 * 다음 풀이까지 남은 시간을 포멧팅하고 표시합니다.
 */
export function formatDate(
  submitDate: Date | string | number,
  stackCount: number,
  now = new Date()
) {
  const diff = Math.max(
    +intervalDate(new Date(submitDate), stackCount) - +now,
    0
  );

  if (diff === 0) {
    return '지금';
  }

  const diffInSeconds = Math.floor(diff / 1000);
  const diffInMinutes = Math.floor(diff / (1000 * 60));
  const diffInHours = Math.floor(diff / (1000 * 60 * 60));
  const diffInDays = Math.floor(diff / (1000 * 60 * 60 * 24));
  const diffInWeeks = Math.floor(diff / (1000 * 60 * 60 * 24 * 7));
  const diffInMonths = Math.floor(diff / (1000 * 60 * 60 * 24 * 30.4375)); // 평균 월 수 (365.25 / 12)
  const diffInQuarters = Math.floor(diff / (1000 * 60 * 60 * 24 * 91.3125)); // 평균 분기 수 (365.25 / 4)
  const diffInHalfYears = Math.floor(diff / (1000 * 60 * 60 * 24 * 182.625)); // 평균 반년 수 (365.25 / 2)
  const diffInFullYears = Math.floor(diff / (1000 * 60 * 60 * 24 * 365.25));

  const formatter = new Intl.RelativeTimeFormat('ko');

  if (diffInSeconds < 60) {
    return formatter.format(diffInSeconds, 'second');
  } else if (diffInMinutes < 60) {
    return formatter.format(diffInMinutes, 'minute');
  } else if (diffInHours < 24) {
    return formatter.format(diffInHours, 'hour');
  } else if (diffInDays < 7) {
    return formatter.format(diffInDays, 'day');
  } else if (diffInWeeks < 4) {
    return formatter.format(diffInWeeks, 'week');
  } else if (diffInMonths < 12) {
    return formatter.format(diffInMonths, 'month');
  } else if (diffInQuarters < 4) {
    return formatter.format(diffInQuarters, 'quarter');
  } else if (diffInHalfYears < 2) {
    return formatter.format(diffInHalfYears, 'quarter');
  } else {
    return formatter.format(diffInFullYears, 'years');
  }

물론 ChatGPT가 대신 작성해준 것이지만 순수함수만 호출하고 있기 때문에 테스트 케이스 작성이 수월했습니다.

지금 코드에서 아쉬운점이 많이 있습니다. 하지만 테스트코드가 있으면 리팩토링은 나중에 언제든지 쉽게 할 수 있습니다. 지금은 구현 달성에 성공한 것으로 간주하고 다음 작업을 진행하겠습니다.

회원가입 페이지 통신

import { useMutation } from '@tanstack/react-query';
import { Button, Input, PageHeading } from '../../Components';
import { useInput } from '../../hooks';
import { checkEmail, checkPassword } from '../../utils';
import { ButtonWrapper, MainContainer, MainWrapper } from './SignUp.style';
import { signUpAPI } from '../../api/authClient';
import { useNavigate } from 'react-router-dom';
import { ROUTE_PATHS } from '../../constant/config';
import { useState } from 'react';

function SignUp() {
  const {
    inputVal: email,
    changeInputVal: changeEmail,
    inputRef: emailRef,
    focusInput: focusEmail,
  } = useInput();
  const { inputVal: password, changeInputVal: changePassword } = useInput();
  const { inputVal: conformPassword, changeInputVal: changeConformPassword } =
    useInput();

  const { mutate, isLoading } = useMutation({ mutationFn: signUpAPI });

  const navigate = useNavigate();

  const [emailHelper, setEmailHelper] = useState<
    '' | '이미 가입한 Email입니다.'
  >('');

  const disabled = [
    checkEmail(email),
    checkPassword(password),
    conformPassword,
    password === conformPassword,
  ].some((elem) => !elem);

  const handleSignUp = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    setEmailHelper('');

    mutate(
      { email, password },
      {
        onSuccess: (data) => {
          if (data.status === 201) {
            navigate(ROUTE_PATHS.SIGN_IN);
          } else {
            const { msg } = data as { success: boolean; msg: string };
            if (msg.startsWith('Error: 이미 가입한 아이디입니다.')) {
              focusEmail();
              setEmailHelper('이미 가입한 Email입니다.');
            }
          }
        },
        onError: (error) => {
          console.log('error??', error);
        },
      }
    );
  };

  return (
    <MainContainer>
      <MainWrapper onSubmit={handleSignUp}>
        <PageHeading>Sign Up</PageHeading>
        <Input
          value={email}
          onChange={changeEmail}
          type="email"
          placeholder="user@email.com"
          customRef={emailRef}
          helperText={emailHelper}
        />
        <Input
          value={password}
          onChange={changePassword}
          type="password"
          placeholder="8자리 이상 영어, 숫자 모두 입력해주세요"
        />
        <Input
          value={conformPassword}
          onChange={changeConformPassword}
          type="password"
          placeholder="동일하게 입력해주세요"
        />
        <ButtonWrapper>
          <Button disabled={disabled} isLoading={isLoading} width={'grow'}>
            회원가입
          </Button>
          <Button type="button" href={ROUTE_PATHS.SIGN_IN} width={'grow'}>
            로그인
          </Button>
        </ButtonWrapper>
      </MainWrapper>
    </MainContainer>
  );
}

export default SignUp;

그렇게 특별한 이슈는 없습니다.

점점 비대해지는 버튼

조기 최적화는 만악의 근원이라고 하는데 신입은 그거 실력없는 사람들이 하나는 거니까 브랜치 2개 파라고 했습니다. 하나는 최적화하고 하나는 안하고 이렇게 해야 한다고 했습니다.

Everything you didn't know you need to know about buttons

다시봐도 짧고 좋은 튜토리얼입니다.

import {
  ButtonWrapper,
  LinkWrapper,
  LoaderWrapper,
  TextWrapper,
} from './Button.style';
import { PulseLoader } from 'react-spinners';

type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
  children: React.ReactNode;
  width?: number | 'grow';
  href?: string;
  onClick?: React.MouseEventHandler<HTMLButtonElement>;
  isLoading?: boolean;
  hierarchy?: 'primary' | 'secondary';
};

/**
 * @todo hierarchy "ghost" 추가하기
 */
export function Button({
  children,
  onClick,
  isLoading = false,
  href,
  width,
  hierarchy = 'primary',
  ...other
}: ButtonProps) {
  if (href) {
    return (
      <LinkWrapper
        to={href}
        disabled={isLoading}
        width={width}
        hierarchy={hierarchy}
      >
        <TextWrapper isLoading={isLoading}>{children}</TextWrapper>
      </LinkWrapper>
    );
  }
  return (
    <ButtonWrapper
      onClick={onClick}
      disabled={isLoading}
      isLoading={isLoading}
      width={width}
      hierarchy={hierarchy}
      {...other}
    >
      <TextWrapper isLoading={isLoading}>{children}</TextWrapper>
      {isLoading && (
        <LoaderWrapper>
          <PulseLoader
            color="#ffffff"
            loading
            margin={4}
            size={12}
            speedMultiplier={0.5}
          />
        </LoaderWrapper>
      )}
    </ButtonWrapper>
  );
}

일단 <a><button>을 위한 각각의 스타일을 적용할 수 있는 전략이 있습니다. href로 타입 가드가 일단 최선인 것 같습니다.

이렇게 하면 다른 문제가 생깁니다.

import styled from '@emotion/styled';
import { Link } from 'react-router-dom';

export const ButtonWrapper = styled.button<{
  isLoading: boolean;
  width?: number | 'grow';
  hierarchy: 'primary' | 'secondary';
}>`
  all: unset;
  ${(props) => props.theme.fonts.body16Regular}
  border-radius: 0.5rem;

  display: flex;
  align-items: center;
  justify-content: center;
  position: relative;
  box-sizing: border-box;

  border: none;
  ${(props) => !props.disabled && 'cursor: pointer;'}

  /* disabled 이면 gray가 되고 loading이면 green을 유지 */
  background-color: ${(props) =>
    props.disabled && !props.isLoading
      ? props.theme.colors.gray400
      : props.theme.colors.green};
  color: ${(props) => props.theme.colors.white};
  height: 2.75rem;

  width: ${(props) => {
    // 숫자 입력시 숫자만큼 채우기
    if (!props.width) return 'fit-content';
    if (props.width !== 'grow') return (props.width / 16).toString() + 'rem';
    return 'fit-content';
  }};
  ${(props) => props.width === 'grow' && 'flex: 1 1 0px;'}
  min-width: 5.25rem;
`;

export const LinkWrapper = styled(Link)<{
  width?: number | 'grow';
  disabled?: boolean;
  hierarchy: 'primary' | 'secondary';
}>`
  all: unset;
  ${(props) => props.theme.fonts.body16Regular}
  border-radius: 0.5rem;

  display: flex;
  align-items: center;
  justify-content: center;
  position: relative;
  box-sizing: border-box;

  border: none;
  ${(props) => !props.disabled && 'cursor: pointer;'}

  /* disabled 이면 gray가 되고 loading이면 green을 유지 */
  background-color: ${(props) => {
    return props.theme.colors.green;
  }};
  color: ${(props) => props.theme.colors.white};
  height: 2.75rem;

  width: ${(props) => {
    // 숫자 입력시 숫자만큼 채우기
    if (!props.width) return 'fit-content';
    if (props.width !== 'grow') return (props.width / 16).toString() + 'rem';
    return 'fit-content';
  }};
  ${(props) => props.width === 'grow' && 'flex: 1 1 0px;'}
  min-width: 5.25rem;

  /* 링크를 위한 스타일링 */
  text-decoration: none;
  color: ${(props) => props.theme.colors.white};
`;

바로 중복입니다. 하나의 컴포넌트에서 자원공유를 했으면 좋겠습니다.

How to style React Router links with styled-components

이런 측면으로 보면 다시 원래대로 돌려야 하는데 a태그의 width, hight를 100%로 설정할 수 있는 전략이 있는지 봐야 합니다.

스타일링 결합

하나로 결합하는 것하는 것에 난관이 생겼습니다.

Multiple inheritance (Composition) #773

레포 이슈에서는

import { css } from 'styled-components';

const rounded = css`
  border-radius: 5px;
`;
import { css } from 'styled-components';

const column = css`
  display: block;
  flex-shrink: 0;
  flex-grow: 0;
  flex-basis: 25%;
  width: 25%;
`;
import styled from 'styled-components';

const Card = styled.div`
  ${rounded}
  ${column}
`;

이런 방법을 권장하고 있습니다. 이렇게 되면 생기는 단점은 props가 없다는 것입니다. 그래서 한계가 생깁니다.

회사 규모에서는 더 복잡한 버튼의 상태를 갖을 것인데 고민이 많아집니다. 다시 생각해보면 설계가 잘못되어 있습니다.

arch-spatula commented 1 year ago

잡생각

PR 관리

백엔드 작업에 실수한 것을 한번더 검토하기로 했습니다.

백엔드 수정 규모가 작아서 문제가 없던 것 같습니다.

인터셉터 관심사 분리

모듈 상호참조 문제가 있는지 의문이 생겼습니다.

딱히 문제는 없는 것 같습니다.

Feat/config interceptor

flex-grow는 부모가 flex여야 적용가능

제곧내

복습이 되었습니다. CSS cookbook을 따로 만들어야 할 것 같습니다.

height는 기본적으로 auto로 되어 있습니다. 콘텐츠 크기만큼 큽니다. div에 남은 길이만큼 채우기 위해서는 flex 혹은 gird 중 하나를 선택해야 합니다.

dropdown menu 만들기

svg 찾기

Tabler Icons

UX/UI 디자이너 당시 저장해뒀습니다.

This rule can't verify that export * only export components

This rule can't verify that export * only export components

이 에러가 발생하는 조건이 무엇인지 모르겠습니다.

tsx 확장자여서 생긴 문제였습니다. ts 확장자로 바꾸니까 해결되었습니다. ㅂㄷㅂㄷ...

svg import

import reactLogo from './assets/react.svg'; // assets
import viteLogo from '/vite.svg'; // public

이미지 import를 자주 까먹습니다.

타입에 따라 조건부 랜더링

import { useState } from 'react';
import theme from '../../../styles/theme';
import {
  DropdownMenuContainer,
  DropdownOpen,
  MenuList,
} from './DropdownMenu.style';

type LinkItem = {
  label: string;
  href: string;
};

type ButtonItem = {
  label: string;
  cb: () => void;
};

type DropdownMenuProps = {
  menuItem: (LinkItem | ButtonItem)[];
};

export function DropdownMenu({ menuItem }: DropdownMenuProps) {
  const [isOpen, setIsOpen] = useState(false);

  const handleOpenMenu = () => {
    setIsOpen((prev) => !prev);
  };

  return (
    <DropdownMenuContainer>
      <DropdownOpen type="button" onClick={handleOpenMenu} isOpen={isOpen}>
        <Icon />
      </DropdownOpen>
      {isOpen && (
        <MenuList>
          {menuItem.map((item, idx) => (
            <li key={idx}>
              {'href' in item ? (
                <a href={item.href}>{item.label}</a>
              ) : (
                <button onClick={item.cb}>{item.label}</button>
              )}
            </li>
          ))}
        </MenuList>
      )}
    </DropdownMenuContainer>
  );
}

in 연산자를 이렇게 응용하는 방법이 있었습니다. 일단 리팩토링이 필요하지만 상당히 신기한 전략입니다. 다양한 타입을 받도록 처리해볼 수 있을 것 같습니다.

원래 자동완성도 label만 지원하고 있었지만 조건부랜더링에 가드로 타입이 narrowing으로 다른 자동완성도 지원하는 것을 확인했습니다.

function Foo(menuItem: (LinkItem | ButtonItem)[]) {
  return (
    <MenuList>
      {menuItem.map((item, idx) => (
        <li key={idx}>
          {'href' in item ? (
            <a href={item.href}>{item.label}</a>
          ) : (
            <button onClick={item.cb}>{item.label}</button>
          )}
        </li>
      ))}
    </MenuList>
  );
}

instanceof는 소용없었습니다. 여기서 의문이 생겼습니다. 지금은 타입의 종류가 1개인데 만약에 3 ~ 4개가 된다면 어떻게 될지 궁금합니다.

function Foo(menuItem: (LinkItem | ButtonItem)[]) {
  return (
    <MenuList>
      {menuItem.map((item, idx) => (
        <li key={idx}>
          {'href' in item && <a href={item.href}>{item.label}</a>}
          {'cb' in item && <button onClick={item.cb}>{item.label}</button>}
        </li>
      ))}
    </MenuList>
  );
}

이런 전략들이 있습니다.

타입의 속성이 여러개가 되면 있는지 확인하는 in 연산자로 조건부 랜더링을 처리할 수 있습니다.

onClink CSS

https://developer.mozilla.org/ko/docs/Web/CSS/:active

a:active {
  background-color: springgreen;
}
b:active {
  background-color: springgreen;
}

클릭 시점에 적용되어야 할 스타일은 이렇게 적용할 수 있습니다.

arch-spatula commented 1 year ago

2023.06.24.

https://github.com/arch-spatula/arch-spatula.github.io/assets/84452145/fffbcadb-0f0b-48e8-8d61-f7cf9e1e522f

provider 활용 안함

https://github.com/arch-spatula/arch-spatula.github.io/assets/84452145/065ef1de-f344-4879-91be-a2d396e544bc

provider 활용함

arch-spatula commented 1 year ago

image

arch-spatula commented 1 year ago

https://github.com/arch-spatula/arch-spatula.github.io/assets/84452145/ab904d7f-8197-4c22-b096-2e2833d68368

arch-spatula commented 1 year ago

Jun-25-2023 16-35-55

arch-spatula commented 1 year ago

Jun-25-2023 17-42-33 Jun-25-2023 17-43-22

arch-spatula commented 1 year ago

image