로그인 병렬 - 예외 처리 흐름 설계

 

로그인을 처리하는 부분에서 오류가 났을 때의 처리에 대해 추가적으로 생각해봐야 할 부분이 있다. 먼저 현재 #onSubmit 메소드 부분의 코드를 확인해 보면

 

import template from './login.template';
import TextField from '../views/text-field';
import axios  from 'axios';

export default class Login {
  #template = template;
  #data;
  #container;
  #loginFail = false;
  #fields = [];

  constructor(container, data) {
    ...
  }

  #initialize = () => {
    ...
  }

  #onSubmit = e => {
    e.preventDefault();

    const loginData = this.#fields
      .map(field => ({ [field.name]: field.value }))
      .reduce((a, b) => ({ ...a, ...b }), {});

    axios.post('/api/authentication', loginData)
      .then(result => {
        return result.data.result;
      })
      .then(({ id, token }) => {
        const options = { headers: { token } };
        this.#data.store.token = token;

        return axios.all([
          axios.get(`/api/user/${id}`, options),
          axios.get(`/api/user/${id}/posts`, options),
        ]);
      })
      .then(([profile, posts]) => {
        this.#data.store.userProfile = profile.data.result;
        this.#data.store.userPosts = posts.data.results;

        location.href = '/#/profile';
      })
      .catch(error => {      
        this.#loginFail = true;
        this.render();
      });
  }

  render = () => {
    ...
  }
}

데이터를 처리하는 부분의 Promise리턴을 3번에 걸쳐서 한다. 여기서 한번 주의해서 봐야할 점은 catch() 부분이 하나라는 것이다. 그 말은 로그인을 실패해도 하나 있는 catch() 부분에 걸리고 로그인은 성공하더라도 사용자의 정보를 받아오는 부분, axios의 데이터를 가져오는 부분에서 실패하게되도 하나의 catch()에서 걸리게 된다. 따라서, 동작이 실패가 됐을 때, 어디에서 실패를 했는지 정확하게 알 수가 없다는 것이다. 

 

이 작업에서 로그인이 실패했을 경우, 로그인이 실패했다는 정보나 데이터를 가져오는 부분에서 실패 했을 시에 데이터를 가져오는 부분에서 실패를 했다고 나타내주는 로직이 추가되면 좋을 것이다.

 

import template from './login.template';
import TextField from '../views/text-field';
import axios  from 'axios';

export default class Login {
  #template = template;
  #data;
  #container;
  #loginFail = false;
  #fields = [];

  constructor(container, data) {
    ...
  }

  #initialize = () => {
    ...
  }

  #onSubmit = e => {
    e.preventDefault();

    const loginData = this.#fields
      .map(field => ({ [field.name]: field.value }))
      .reduce((a, b) => ({ ...a, ...b }), {});

    axios.post('/api/authentication', loginData)
      .then(result => {
        return result.data.result;
      })
      .then(({ id, token }) => {
        const options = { headers: { token } };
        this.#data.store.token = token;

        axios.all([
          axios.get(`/api/user/${id}`, options),
          axios.get(`/api/user/${id}/posts`, options),
        ]).then(([profile, posts]) => {
          this.#data.store.userProfile = profile.data.result;
          this.#data.store.userPosts = posts.data.results;
  
          location.href = '/#/profile';
        }).catch(error => {      
          this.#loginFail = true;
          this.render();
        });
      })
      .catch(error => {      
        this.#loginFail = true;
        this.render();
      });
  }

  render = () => {
    ...
}

로그인 처리가 발생 했을 때 성공인지 에러인지의 처리와 데이터를 가져오는 부분에서의 성공과 에러의 처리를 나눴다. axios.all 메소드를 통해서 데이터를 불러 올때 return 하는 것이 아니라 또 다른 하나의 Promise를 반환하여 성공 시 then()을 통해 데이터를 반환하고 실패시 데이터를 불러오는 부분에서 실패하는 로직을 구현하면 두 작업을 구분해서 실행 할 수 있다.

 

 

 

 

https://bit.ly/37BpXiC

 

패스트캠퍼스 [직장인 실무교육]

프로그래밍, 영상편집, UX/UI, 마케팅, 데이터 분석, 엑셀강의, The RED, 국비지원, 기업교육, 서비스 제공.

fastcampus.co.kr

 

본 포스팅은 패스트캠퍼스 환급 챌린지 참여를 위해 작성되었습니다.

 

#패스트캠퍼스 #패캠챌린지 #직장인인강 #직장인자기계발 #패스트캠퍼스후기 #김민태의프론트엔드아카데미:제1강JavaScript&TypeScriptEssential

로그인 병렬 - 연속적 비동기 API 처리의 이해

 

로그인 처리를 하는 서버와의 통신에는 비동기 처리가 필요하다. 단순히 화면이 바뀌는 동작만이 아니라면 서버와의 통신간에 연결이 끊어질 수도 있고 다른 에러가 발생할 경우가 있기 때문에 비동기 처리를 이용한다.

 

하지만 이번 로그인 처리에선 2가지의 API를 병렬적으로 처리한다. 그렇게 데이터를 받기 위해서 axios 라이브러리를 사용한다. axios에서 지원하는 메소드중에 all 메소드를 통해 2가지의 API 데이터를 받아서 두가지의 서버 비동기 처리를 한 후에 완료 후에 데이터를 반환 받아서 사용한다.

 

import template from './login.template';
import TextField from '../views/text-field';
import axios  from 'axios';

export default class Login {
  #template = template;
  #data;
  #container;
  #loginFail = false;
  #fields = [];

  constructor(container, data) {
    this.#container = document.querySelector(container);
    this.#data = data;

    this.#initialize();
  }

  #initialize = () => {
    ...
  }

  #onSubmit = e => {
    e.preventDefault();

    const loginData = this.#fields
      .map(field => ({ [field.name]: field.value }))
      .reduce((a, b) => ({ ...a, ...b }), {});

    axios.post('/api/authentication', loginData)
      .then(result => {
        return result.data.result;
      })
      .then(({ id, token }) => {
        const options = { headers: { token } };
        this.#data.store.token = token;

        return axios.all([
          axios.get(`/api/user/${id}`, options),
          axios.get(`/api/user/${id}/posts`, options),
        ]);
      })
      .then(([profile, posts]) => {
        this.#data.store.userProfile = profile.data.result;
        this.#data.store.userPosts = posts.data.results;

        location.href = '/#/profile';
      })
      .catch(error => {      
        this.#loginFail = true;
        this.render();
      });
  }

  render = () => {
    ...
  }
}

#onSubmit 메소드를 실행하게되면 고유의 submit 메소드의 동작은 막는다. 기존의 submit 기능은 페이지의 전환이 일어나는데 페이지의 이동을 막기 위해 사용한다. fields 배열에 데이터를 배열로 해당하는 데이터는 Promise 패턴을 이용해서 then의 실행이 완료 될때마다 Promise를 반환하는 형식이다. 

 

 

 

 

https://bit.ly/37BpXiC

 

패스트캠퍼스 [직장인 실무교육]

프로그래밍, 영상편집, UX/UI, 마케팅, 데이터 분석, 엑셀강의, The RED, 국비지원, 기업교육, 서비스 제공.

fastcampus.co.kr

 

본 포스팅은 패스트캠퍼스 환급 챌린지 참여를 위해 작성되었습니다.

 

#패스트캠퍼스 #패캠챌린지 #직장인인강 #직장인자기계발 #패스트캠퍼스후기 #김민태의프론트엔드아카데미:제1강JavaScript&TypeScriptEssential

로그인 앱 설계

 

로그인을 하기위해 서버에서는 로그인 할 당시에 권한처리나, 토큰 유지, API를 통해서 프로필 데이터를 JSON 형식으로 받아오는 등의 데이터 처리나 라우팅에 관한 작업들을 진행했다.

 

이번에는 클라이언트에서 서버에서 가져오는 데이터에 맞게 또는 라우팅이 변함에 따라 화면 부분을 변경하는 작업을 할 것이다.

 

import Store from './store';
import Login from './page/login';
import Profile from './page/profile';
import PageNotFound from './page/page-not-found';

const store = new Store();

function router() {
  const path = location.hash;

  switch(path) {
    case '':
    case '#/login':
      const login = new Login('#root', {
        store,
        title: 'JS & TS Essential'
      });
      login.render();
      break;
    case '#/profile':
      const profile = new Profile('#root', { store });
      profile.render();
      break;
    default:
      const pageNotFound = new PageNotFound('#root');
      pageNotFound.render();
      break;
  }
}

window.addEventListener('hashchange', router);
document.addEventListener('DOMContentLoaded', router);

클라이어트에서 라우팅 처리를 하기위해서 hash 형식을 사용한다. 이벤트리스너에 hashChange를 등록하고 해시가 변경됨에 따라 화면을 변경하는 방식인데 SPA를 구현하는 하나의 방식이다. 또한 Store 객체를 선언해서 전역적으로 Store에 관련된 필드나 변수 등을 사용한다. 로그인 페이지와 프로필 페이지로 나뉘고 해당하는 해시 값으로 url이 변경 될때마다 각각 해당하는 Login, Profile 인스턴스가 생성되고 render 메소드로 렌더링을 한다. 만약 처리하는 페이지에 url이 다른 값이 입력 됐을 시 미리 만들어 둔 pageNotFound 를 불러온다.

 

 

 

 

https://bit.ly/37BpXiC

 

패스트캠퍼스 [직장인 실무교육]

프로그래밍, 영상편집, UX/UI, 마케팅, 데이터 분석, 엑셀강의, The RED, 국비지원, 기업교육, 서비스 제공.

fastcampus.co.kr

 

본 포스팅은 패스트캠퍼스 환급 챌린지 참여를 위해 작성되었습니다.

 

#패스트캠퍼스 #패캠챌린지 #직장인인강 #직장인자기계발 #패스트캠퍼스후기 #김민태의프론트엔드아카데미:제1강JavaScript&TypeScriptEssential

로그인 앱 설계

 

server를 다루는 index.js 파일엔 인증처리를 하는 부분 말고 로그인한 유저의 프로필을 반환하는 API가 있다. 해당 API는 GET 방식을 사용하고 불러온 유저 아이디의 값을 통해 그에 맞는 데이터를 반환 해주는 역할을 한다.

 

const path = require('path');
const express = require('express');
const morgan = require('morgan');
const { urlencoded, json } = require('body-parser');
const { v4 } = require('uuid');
const axios = require('axios');
const app = express();

app.use(express.static('dist'));
app.use(morgan('dev'));
app.use(urlencoded({ extended: false }))
app.use(json())

app.get('/api/user/:id', (req, res) => {
  if(!req.headers['token']) {
  	return res.status(403).send({
	  status: 'Error'
    });
  }
  
  axios.get('https://randomuser.me/api')
    .then(result => {
      const {userProfile} = result.data.results;
      const {name, picture, phone, email, country} = userProfile;
      
      res.status(200).send({
        status: 'OK',
        result: {
          name, picture, phone, email, country
        }
      })
    })
    .catch(e => {
      res.status(400).send({
        status: 'Error'
      });
    });
});

app.get('/api/user/:id/posts', (req, res) => {
  ...
});

app.post('/api/authentication', (req, res) => {
  ...
});

app.listen(8080, () => {
  console.log('ready to dumy signup server');

 

/api/user/:id 에서 :id 부분에 입력 받은 아이디의 데이터를 가져온다. 해당 API로 진입 시 req(request)의 header에 토큰이 있는지 없는지를 체크하고 토큰 값이 존재하지 않는다면 403에러를 반환한다. 여기서 프로필은 무작위로 가상의 프로필을 제공해주는 API를 이용할 것이고, 서버에 요청을 보내는 것으로 axios 라이브러리를 사용한다. axios는 xmlHttpRequest나 fetch와 같은 기능을 하고 node.js 등의 서버 환경에서 편리한 기능을 가지고 사용할 수 있다. 이어서 axios로 받아온 API에서 사용할 데이터를 변수로 만들고 res(resopnse)로 상태값 성공(2xx)과 해당하는 데이터를 반환하고 이 과정에서 어떠한 이유로 전송이 되지 않으면 실패(4xx)를 반환한다.

 

 

 

 

https://bit.ly/37BpXiC

 

패스트캠퍼스 [직장인 실무교육]

프로그래밍, 영상편집, UX/UI, 마케팅, 데이터 분석, 엑셀강의, The RED, 국비지원, 기업교육, 서비스 제공.

fastcampus.co.kr

 

본 포스팅은 패스트캠퍼스 환급 챌린지 참여를 위해 작성되었습니다.

 

#패스트캠퍼스 #패캠챌린지 #직장인인강 #직장인자기계발 #패스트캠퍼스후기 #김민태의프론트엔드아카데미:제1강JavaScript&TypeScriptEssential

로그인 앱 설계

 

지난번까지 로그인 앱에 대한 폴더 구조를 알아봤다. 지금까지 했던 작업들과 다르게 로그인 앱 설계 부분에서는 node.js를 통한 서버 환경을 구현했다. server 폴더 하위에 index.js 파일에 서버를 구현하는 로직이 있다.

 

const path = require('path');
const express = require('express');
const morgan = require('morgan');
const { urlencoded, json } = require('body-parser');
const { v4 } = require('uuid');
const axios = require('axios');
const app = express();

app.use(express.static('dist'));
app.use(morgan('dev'));
app.use(urlencoded({ extended: false }))
app.use(json())

app.get('/api/user/:id', (req, res) => {
  ...
});

app.get('/api/user/:id/posts', (req, res) => {
  ...
});

app.post('/api/authentication', (req, res) => {
  if (Math.floor(Math.random() * 10) % 2 === 0) {
    res.status(200).send({
      status: 'OK',
      result: {
        id: Math.floor(Math.random()*10),
        token: v4(),
      }
    });
  } else {
    res.status(400).send({
      status: 'Error'
    });
  }
});

app.listen(8080, () => {
  console.log('ready to dumy signup server');
});

 

코드를 보면 상단엔 import 하는 부분이 모여있다. require를 통해서 여러가지 라이브러리를 사용할 수 있다. npm을 통해서 라이브러리를 다운 받은 파일도 마찬가지로 사용한다. 여기선 웹 서버를 사용할 것이기 때문에 node.js의 웹 프레임워크인 express.js를 사용한다. 서버처리가 필요한 작업들은 해당하는 파일에서 작업할 것이다. 클라이언트 사이드에서 / 를 통한 경로는 화면을 변경하는 라우팅 경로가 되는것이지만 서버에서는 데이터를 전달하거나 조건에 맞는 데이터를 가공하는 역할을 한다. 현재 작업에서는 로그인 작업이 필요하기 때문에 로그인 처리를 해주는 로직을 사용할 것이다.

 

로그인을 하는 전반적인 처리를 하기 위해서 우리는 HTTP 프로토콜을 사용할 것이다. HTTP의 특징으로는 1. 비동기 처리를 하고 2. 무상태(상태가 없어야 함)여아 하는 특징이 있기 때문에 로그인한 유저인지 아닌지 알기 위해서는 로직에서 토큰이라는 값이 필요하다. 따라서 서버에서 토근을 처리해주는 로직이 들어 갈 것이다.

 

 

 

 

https://bit.ly/37BpXiC

 

패스트캠퍼스 [직장인 실무교육]

프로그래밍, 영상편집, UX/UI, 마케팅, 데이터 분석, 엑셀강의, The RED, 국비지원, 기업교육, 서비스 제공.

fastcampus.co.kr

 

본 포스팅은 패스트캠퍼스 환급 챌린지 참여를 위해 작성되었습니다.

 

#패스트캠퍼스 #패캠챌린지 #직장인인강 #직장인자기계발 #패스트캠퍼스후기 #김민태의프론트엔드아카데미:제1강JavaScript&TypeScriptEssential

로그인 앱 설계

 

로그인 앱 설계 파트에서는 여러가지 API들을 호출하는 관점에서 어떻게 코드를 작성할 것인가에 대해, 두번째는 이전의 회원가입 웹앱에서 사용하고 구분했었던 필드부분(Text, Password, Address 등)의 클래스에서 공통적으로 사용했던 (vaildate나 render, template와 같은) 반복적인 처리를 어떻게 할 것인가에 대해 다룬다.

 

또한 이번 장에서는 API 여러처리를 위해 서버를 갖는다. node.js로 구성되어 있으며 서버를 거쳐야하는 여러가지의 처리를 제어할 것이다.

 

- 폴더 구조

server
  |- index.js
src
  |- page
    - login.js
    - login.template.js
    - page-not-found.js
    - page-not-found.template.js
    - profile.js
    - profile.template.js
  |- utils
    - api.js
    - index.js
  |- views
    - core.js
    - text-field.js
    - text-field.template.js
  - constant.js
  - index.js
  - store.js

이전까지의 폴더구조와 다르게 루트 경로에 server폴더와 안에 index.js라는 서버처리를 할 파일이 있다. 또 같은 경로엔 마찬가지로 src폴더가 있으며 클라이언트 사이드에서 사용하는 화면 처리 부분은 src 하위에서 작업한다. 먼저 index.js는 라우팅 처리를 하는 담당하는 파일이다. store.js는 스토어 패턴이라해서 getter와 setter로 이루어져 값을 변경하거나 반환할때 전역적으로 사용할 것이다. constant.js는 vaildateRules을 정의하는 파일이고 page폴더 안에 있는 파일들은 로직처리를 하는 파일과 템플릿을 지정한 파일 쌍으로 로그인, 404에러 페이지, 프로필 등으로 나누어져 있다. 또한 utils 폴더안의 api.js 파일은 api를 사용할 때 공통적으로 사용하는 get과 post방식의 구조를 정의한 파일이며 index.js 파일은 공통적으로 사용할 setTimeout을 통해 비동기처리를 정의한 파일이다.

 

로그인 파트에서 아이디를 입력할 때와 패스워드를 입력하는 필드를 사용하는데 views 폴더 하위에 core.js 파일을 정의했다. 회원가입 웹앱 부분에서는 텍스트나 패스워드, 어드레스 등을 따로 구분했지만 해당 파트에서는 core.js 파일에서 해당하는 파일의 필드의 공통적으로 사용되는 부분을 정의하고 다른 파일에서 상속을 통해 사용하여 코드를 줄이고 구조화 할 수 있다.

 

 

 

 

https://bit.ly/37BpXiC

 

패스트캠퍼스 [직장인 실무교육]

프로그래밍, 영상편집, UX/UI, 마케팅, 데이터 분석, 엑셀강의, The RED, 국비지원, 기업교육, 서비스 제공.

fastcampus.co.kr

 

본 포스팅은 패스트캠퍼스 환급 챌린지 참여를 위해 작성되었습니다.

 

#패스트캠퍼스 #패캠챌린지 #직장인인강 #직장인자기계발 #패스트캠퍼스후기 #김민태의프론트엔드아카데미:제1강JavaScript&TypeScriptEssential

회원 가입 폼 - 정보 UX 선택하기

 

회원가입 폼에서도 그렇지만 어떤 기능을 하는 웹에는 API를 사용하는 경우가 많다. API를 구현하는 것은 그렇게 어렵지 않지만 사용하려고 하는 API가 만들려고 하는 서비스에 부합하는 기능을 지원하는지, 해당하는 API의 지속 가능성이라던지 문서화가 얼마나 잘 되어있는지 등의 요건들을 맞추고 선택하는 것이 더 중요할 것이다.

 

회원가입 웹앱에서는 다음의 지도 API를 사용할 것이다.

 

<script src="//t1.daumcdn.net/mapjsapi/bundle/postcode/prod/postcode.v2.js"></script>
export default class AddressField {
  private template = template;
  private container: string;
  private data: Props;
  private address1?: string;
  private zipcode?: string;

  constructor(container: string, data: Props) {
    this.container = container;
    this.data = { ...DefaultProps, ...data };
  }

  ...

  public render = (append: boolean = false) => {
    ...

    container.querySelector(`#search-address`)?.addEventListener('click', () => {
      new window.daum.Postcode({
        oncomplete: (data: DaumAddress) => {
          this.address1 = data.roadAddress;          
          this.zipcode = data.sigunguCode;
          
          (container.querySelector('#address1') as HTMLInputElement).value = `(${this.zipcode}) ${this.address1}`;
        }
      }).open();  
    });
  }
}

 

위에 있는 script 태그의 url은 카카오(다음)에서 지도를 사용하기 위해 사용하는 스크립트 파일이며 아래의 코드는 API 가이드에서 기능을 사용하는 방식이다. 렌더 메소드에서 address 필드를 선택자로 지정하고 클릭 이벤트를 통해 API 인스턴스를 생성한다. 주소를 입력하게되면 address 인스턴스에 address1, zipcode 필드에 API에서 가져오는 데이터의 주소와 우편번호를 저장할 수 있다.

 

 

 

 

https://bit.ly/37BpXiC

 

패스트캠퍼스 [직장인 실무교육]

프로그래밍, 영상편집, UX/UI, 마케팅, 데이터 분석, 엑셀강의, The RED, 국비지원, 기업교육, 서비스 제공.

fastcampus.co.kr

 

본 포스팅은 패스트캠퍼스 환급 챌린지 참여를 위해 작성되었습니다.

 

#패스트캠퍼스 #패캠챌린지 #직장인인강 #직장인자기계발 #패스트캠퍼스후기 #김민태의프론트엔드아카데미:제1강JavaScript&TypeScriptEssential

회원 가입 폼 - 정보 UX 선택하기

 

기본적으로 buildData 메소드를 통해 기준에따라 맞는 데이터를 만들고 template에 반영하게 되면 회원가입 화면 필드에 입력된 값에 따라 변경되는 일련의 작업이 이루어진다.

 

이런 전체적인 구조는 비슷하지만 각 필드마다 약간의 차이가 있다. 패스워드 필드 같은 경우 기본 밸리데이션을 제외한 추가적인 밸리데이션 구조를 갖는다. 

 

import { nextTick } from '../utils';
import { ValidateRule } from '../types';
import template from './password-field.template';
import { RequireRule } from '../constant';

enum StrongLevel {
  None = 0,
  Light,
  Medium,
  Havey,
}

type Props = {
  id: string;
  label: string;
  text?: string;
  require?: boolean;
  placeholder?: string;
  strong?: StrongLevel;
}

const StrongMessage: [string, string, string, string] = [
  '금지된 수준',
  '심각한 수준',
  '보통 수준',
  '강력한 암호',
];

const DefaultProps: Props = {
  id: '',
  label: 'label',
  text: '',
  require: true,
  placeholder: '',
  strong: StrongLevel.None,
};

export default class PasswordField {
  private template = template;
  private container: string;
  private data: Props;
  private updated: boolean = false;
  private validateRules: ValidateRule[] = [];

  constructor(container: string, data: Props) {
    ...
  }

  private onChange = (e: Event) => {
    ...
  }

  private attachEventHandler = () => {
    ...
  }

  private buildData = () => { 
    let strongLevel = -1;
    const isInvalid: ValidateRule | null = this.validate();

    if (this.data.text!.length > 0) {
      strongLevel++;
    }

    if (this.data.text!.length > 12) {
      strongLevel++;
    }

    if (/[!@#$%^&*()]/.test(this.data.text!)) {
      strongLevel++;
    }

    if (/\d/.test(this.data.text!)) {
      strongLevel++;
    }

    return {
      ...this.data, 
      updated: this.updated,
      valid: this.updated ? !isInvalid : true,
      strongMessage: strongLevel < 0 ? '' : StrongMessage[strongLevel],
      strongLevel0: strongLevel >= 1,
      strongLevel1: strongLevel >= 2,
      strongLevel2: strongLevel >= 3,
      strongLevel3: strongLevel >= 4,
    };
  }

  private validate = (): ValidateRule | null => {
    ...
  }

  private update = () => {
    ...
  }

  public get name(): string {
    ...
  }

  public get value(): string {
    ...
  }

  public get isValid(): boolean {
    ...
  }

  public addValidateRule = (rule:ValidateRule) => {
    ...
  }

  public render = (append: boolean = false) => {
    ...
  }
}

 

password-filed.ts는 다른 필드와 다르게 추가적으로 StrongLevel이라는 enum과 StrongMessage라는 튜플을 갖는다. 기본적으로 가지고 있는 밸리데이션 안에서 추가적으로 체크하는 밸리데이션이며 이 밸리데이션은 충족하지 않아도 데이터를 전송할 수 있다. 다만 입력된 데이터의 유효성의 단계를 체크한다. StrongLevel은 숫자 형태의 값이며 StrongMessage는 그 숫자에 따라 메세지가 달라진다.

 

이 작업은 password-filed.ts의 buildData 메소드에서 진행한다. 유효성 체크에서 만족하는 부분이 없으면 -1 값이며 각각의 밸리데이션을 만족하게되면 1씩 숫자가 올라가고 리턴할 때 해당하는 값을 전달한다.

 

 

 

 

https://bit.ly/37BpXiC

 

패스트캠퍼스 [직장인 실무교육]

프로그래밍, 영상편집, UX/UI, 마케팅, 데이터 분석, 엑셀강의, The RED, 국비지원, 기업교육, 서비스 제공.

fastcampus.co.kr

 

본 포스팅은 패스트캠퍼스 환급 챌린지 참여를 위해 작성되었습니다.

 

#패스트캠퍼스 #패캠챌린지 #직장인인강 #직장인자기계발 #패스트캠퍼스후기 #김민태의프론트엔드아카데미:제1강JavaScript&TypeScriptEssential

+ Recent posts