FE 개발자로 일한지 2년정도 되는 요즈음 문득 이런 생각이 들었다

"나 잘하고 있는거 맞나?"

 

일은 하고 있는데 동료 개발자도 거의 없다시피 작은회사에서 쌓은 시간이라 객관적으로 나를 측정하고 싶었다

그렇게 항해플러스를 시작했다

 

2주차가 끝나는 지금 항해플러스에 합류한건 참 잘한 선택이었다. 자바스크립트의 본질에 대해 이해하고 배우는 좋은 시간이고 비슷한 연차에서의 나의 위치를 정확히 알 수 있었다. 비슷한 고민을 하는 개발자들 사이에서 과제 뿐 아니라 생각을 교류하는 좋은 환경과 커리큘럼이라고 생각한다. 이런 것들을 나누면서 자연스레 좋은 개발자, 함께 일하고 싶은 개발자가 뭔지 알듯하다.

 

갓 1년차가 되었을 때부터 항해플러스를 신청할까 말까 고민을 많이했다. 그러다 끝내 신청하지 못했던건 가격적인 측면도 있었지만 '코어 자바스크립트에 대한 것, 아키텍쳐도, TDD도 지금 사용을 안하는데 궂이 해야되나?' 싶었다  

그것도 지금은 잘못 생각했다고 느끼는게 코어 자바스크립트도 아키텍쳐도 내가 모르니까 사용을 안했구나 싶다.

물론, React 환경에서 지금 배우고있는 DOM 조작과 class문법 사용 등은 지양되고 있지만, React 라는 프레임워크 기저에는 이런 것들이 다 녹아져있었고 알면 기술이지만 모르면 마법이라는 생활코딩님의 말이 종종 생각나는 시간을 보내고있다.

 

그래서 요번주에 뭘했냐면 

 

 

자바스크립트로 React만들기

 

자바스크립트로 React처럼 화면전환이 가능하게 route를 구현하고 가상돔과 diff 알고리즘을 만들어 변경되는 node만 업데이트해주는게 기본 골자다.

 

위의 기능을 위해서 아래의 구현이 필요하다

 

1. 라우팅

자바스크립트에서 기본으로 제공하는 history api를 이용해 페이지를 새로고침 하지 않고 URL 을 변경한다.

 

여러 메서드들이 있는데 하나씩 알아보자.

  • pushState

기본 골자는 이렇다. history.pushState(state, title, url);

State : 브라우저 이동  넘겨줄 데이터 (popstate 에서 받아서 원하는 처리를 해줄  있음)

Title : 변경할 브라우저 제목 (변경 원치 않으면 null)

Url : 변경할 주소

 

아래 예시를 보자.

// html
<nav>
  <div id="navi">/</div>
  <div id="navi">/about</a>
</nav>
document.querySelector("nav").addEventListener("click", (e) => {
  if (e.target.id === "navi") {
    history.pushState(null, "", e.target.innerText);
  }
});

 

위와 같이 사용하면 새로고침 없이 "/" 와 "/about" url로 변경할 수 있다.

 

이때 각 링크에 따라 html만 변경해주면 SPA처럼 동작한다.

아래 예시를 보자

// main.js

class Router {
  constructor() {
    this.routes = {};
  }

  addRoute(path, handler) {
    this.routes[path] = handler;
  }

  navigateTo(path) {
    history.pushState(null, "", path);
    this.handleRoute(path);
  }


  handleRoute(path) {
    const handler = this.routes[path];
    if (handler) {
      handler();
    } else {
      console.log("404 Not Found");
    }
  }
}

// router 등록
const router = new Router();
router.addRoute("/", () => renderPage(mainPage()));
router.addRoute("/about", () => renderPage(aboutPage()));

// 페이지 이동 시 router.navigateTo() 호출
document.querySelector("nav").addEventListener("click", (e) => {
  if (e.target.id === "navi") {
    router.navigateTo(e.target.innerText);
  }
});

// URL에 따라 html을 갈아준다
function renderPage(page) {
  document.getElementById("app").innerHTML = page;
}

function mainPage() {
  return `
    <h1>메인 페이지입니다</h1>
`;
}

function aboutPage() {
  return `
    <h1>About 페이지입니다</h1>
`;
}

 

요약하면 아래 순서로 진행된다.

1. addRoute 함수로 사용할 route를 등록하고 ("/", "/about")
2. navigateTo함수로 새로고침 없이 url을 변경하고 (history.pushState)

3. 2를통해 url 변경이 일어나면 등록한 route의 콜백함수를 실행해준다

4. 이때 콜백함수는 html을 render 해주는 함수

 

 

여기서 아래 코드를 추가하면 뒤로가기 앞으로가기를 통해 변경되는 url을 대응할 수 있다

class Router {
  constructor() {
    this.routes = {};
    
    // (New!)
    window.addEventListener("popstate", this.handlePopState.bind(this));
  }

  addRoute(path, handler) {
    this.routes[path] = handler;
  }

  navigateTo(path) {
    history.pushState(null, "", path);
    this.handleRoute(path);
  }

  // (New!)
  handlePopState() {
    this.handleRoute(window.location.pathname);
  }

  handleRoute(path) {
    const handler = this.routes[path];
    if (handler) {
      handler();
    } else {
      console.log("404 Not Found");
    }
  }
}

 

위의 과정을 통해 SPA 처럼 페이지 이동이되게 했는데 이벤트는 어떻게 관리하지?

 

2. 이벤트 주입 (아키텍쳐를 곁들인)

element가 존재해야(이하 $el) $el에 이벤트를 주입할 수 있다.

그러면 html을 먼저 파싱하고 그 후에 이벤트를 주입해야 되는건데 요 과정에서 아키텍쳐에 대한 중요성을 배웠다

그동안은 react로만 구현해서 몰랐는데 자바스크립트로 구현하자니 코드가 많아지고 관리하기가 힘들었다

(선언형과 명령형의 차이 +exp) 

 

...컴포넌트 기반 구조 설계를 위한 아키텍쳐 구성은 곧 추가하겠습니다!🙌

 

 

 

 

 

앗.. 아아...

 

프로젝트 중 Ai 기능이 있어 외부와 연계해서 작업을 해야했다.

서버pc에 있는 이미지에 접근해 해당 이미지를 추론하고 추론 이미지 생성 & 추론 data를 DB에 담아주면

나는 이미지랑 data를 가져다 쓰는 방식으로 진행했다.

  

난 파일에 접근 가능하니까 업로드도 가능한줄 알았다 (웨 그렇게 생각하셨죠?? -> 나도 모름, 진짜 모름)

기존에 사용하는 이미지가 많아 서버 PC에 이미지를 저장하고 외부에서 접근 가능하게 해놓은 상태여서

(port & 포트포워딩)

 

외부 인력와서 추론 알고리즘 pc에 설치해줄때도 파일 시스템에 접근이 되는 거니까 업로드도 가능한건가 보다~ 하고 할일 하고 있었다 

 

아래는 예시 백엔드 코드

const express = require('express');
const path = require('path');
const cors = require('cors');

const app = express();
const PORT = 3000;

// CORS 설정: 모든 도메인 허용, GET과 POST 메서드만 허용
app.use(cors({
    methods: ['GET', 'POST']
}));

app.use(express.json());

// 'public/image' 폴더 경로 설정
const folderPath = path.join(__dirname, 'public', 'image');

// 정적 파일 제공
app.use('/images', express.static(folderPath));

// 서버 시작
app.listen(PORT, () => {
    console.log(`이미지 server 실행`);
});

 

근데 작업하시다가 음 405 에러 나는데 왜그러징.. 하는걸 한참 뒤에 들었다 서로 다른 공간에서 작업하니까 작업하는 분은 본인이 잘못해서 그런갑다 하고 이것저것 해보고있었는데 공유한 url로 파일 시스템에 접근해서 이미지 다운로드는 되는데 업로드가 안되니까 본인이 잘못 한건줄 알고 계속 시도해보고 있었던것.. (안타깝게도 외부 인력도 나도 주니어여서 귀책사유를 명확히 알지 못했음)

 

결론부터 말하면 왜 안됐냐면...

 

그야.....

 

업로드 api가 없으니 업로드가 안되지!!

 

정말 부끄러운 이유로 에러가 나고있었고 "405 에러 나는데용?" 라는 말을 듣고 GPT 답변을 이것저것 읽어보고 생각했다.

(와 나 뭐함..?)  

 

호다닥 GPT 도움을 받아 코드를 수정했고 다행히 바로 업로드가 됐다

예시 코드

const multer = require('multer');

// multer 설정: 파일 저장 경로 및 파일 이름 설정
const storage = multer.diskStorage({
    destination: (req, file, cb) => {
        cb(null, YOUR/PATH); // YOUR/PATH 에 업로드 할 경로 입력
    },
    filename: (req, file, cb) => {
        cb(null, file.originalname); // 원래 파일 이름으로 저장
    }
});

const upload = multer({ storage: storage });

// 파일 업로드 api를 만들어줘야 업로드가 되는 지극히 당연한 사실!!
app.post('/api/upload', upload.single('file'), (req, res) => {
    if (!req.file) {
        return res.status(400).json({ message: '파일이 업로드되지 않았습니다.' });
    }
    res.json({
        message: '파일이 성공적으로 업로드되었습니다.',
        file: req.file
    });
});

 

multer 분명 공부할때 썼었는데..

 

경험해보지 않으면 실전에선 절대 생각이 안나는듯..?

*multer는 주로 파일 업로드에 사용하고 미들웨어로 에러 같은거 나면 next에 처리를 위임해줘서 사용하기 좋다

 

회사에서 진행하는 프로젝트가 1인 프로젝트 치고 규모도 좀 있고 경력도 거의 프론트만 1년 정도 한게 다여서 나름 부담감에  MongoDB 강의도 듣고 React deep dive 도 하고 코테도 깔짝깔짝 풀고 있었는데 이번 일 겪은 후로 다 치우고 백엔드 위주로 사이드 프로젝트 하면서 공부 중이다..

 

이것저것 하고있긴한데 교류하고있는 개발자 커뮤니티가 없으니 가끔 헤매고 있는거 같아 좀 불안허네

 

뭐 다들 화이팅합시다~🙌

'Node.js' 카테고리의 다른 글

백엔드 시작하기  (4) 2024.10.26
음.. 프론트엔드만 할 줄 알았는데

 

다른 개발자들 상황을 잘 아는건 아니지만,

회사성향에 따라 그리고 규모에따라 결국 풀스텍을 하게되는 경우가 왕왕 있는듯 하다

뭐 나도 그렇다

 

첫 회사에선 일렉트론으로 데스크탑 애플리케이션 만들다보니 node.js도 어찌어찌 작성했었는데

db 안쓰는 프로젝트고 api 통신도 아닌것이 뭔가 애매해서 백엔드 코드는 대충대충 했던 기억이 난다.

 

근데 지금회사에선 좀 본격적으로 백엔드 개발도 해야돼서 복습겸 회고하려고 한다.

프론트엔드 역량에 더 집중하고 싶기도한데 지금 회사에 필요한 건 이것저것 할 줄 아는 개발자인듯 하고 이렇게 역량 늘리는 것도 나쁘지 않긴하다.

기왕하는거 즐깁시다.

 

 

내가 생각하는 백엔드

프론트에서 필요한게 있으면 서버로 요청하고 서버는 응답한다.

백엔드는 응답하는 로직을 담당하는 부분이라고 생각한다

간단한 비즈니스 로직부터 db 조작까지해주는 정도?

 

 

근데 비즈니스 로직도 프론트엔드에서 private 하게 다룰수 있고 (feat. class 문법)

Next.js 13부터 서버 컴포넌트에서 바로 db CRUD가 가능해져서 프론트와 백엔드 경계가 모호하다고 느끼고있긴하다.

 

그래도 기본은 중요한거니까 기초를 다져봅시다.

 

어떻게 공부할까 하다가 전에 사둔 [리액트를 다루는 기술] 책에 백엔드 프로그래밍 부분이 있어서 진도를 따라가고 있다.

책에 수록된 스택은 Koa(기존 Express 팀이 개발한 더욱 가벼운 프레임워크) + Javascript + mongoose 인데,

 

Express + Typescript + mongoose 로 공부하고 싶어서 해당 스택으로 진행했다.

 

 

환경설정

 

1) express 설치

$ npm init
$ npm install express --save

 

2) Typescript 관련 설치

npm i -D typescript     // typescript 설치 (-D 는 개발모드로)
npm i ts-node		// 아래 후술
npm i @types/node	 
npm i @types/express

 

<ts-node> 
- TypeScript를 JavaScript로 변환하여 사전 컴파일 없이 Node.js에서 TypeScript를 직접 실행할 수 있도록 합니다(문서 발췌) 이거 없으면 ts 파일 하나당 컴파일 된js 파일 하나 만들어지고 디버깅도 컴파일된 js 파일에서 해야돼서 매우 피곤함

 

<types/node> 

- 이 패키지에는 노드에 대한 유형 정의가 포함되어 있습니다. (문서 발췌)

없으면 ts 파일에서 Node.js의 내장 모듈(예: fs, http, path 등)을 사용할 때 Type 에러남.

 

<types/express>

 

- 이 패키지에는 express  에 대한 유형 정의가 포함되어 있습니다. (문서 발췌)

없으면 ts 파일에서 express 사용할 때 Type 에러남

 

3) tsconfig.json 파일을 만들어 준다

npx tsc --init

 

4) nodemon 설치

npm i -D nodemon //  ts파일이 변환되었을때 알아서 서버를 재시작해주는 유틸 라이브러리

 

5) npm 명령어로 nodemon 실행

// package.json

"scripts": {
    "start": "nodemon server.ts"
},

 

 

 

Express

 

express 는 백엔드를 쉽게 즐기게 해주는 프레임워크.

Http 메서드 및 미들웨어를 통해 API 작성에 도움을 준다 

1) express 시작하기
 
// 코드 몇줄 딸깍으로 서버를 시작할 수 있다

const express = require('express')
const app = express()
const port = 3000

app.get('/', (req, res) => {
  res.send('Hello World!')
})

app.listen(port, () => {
  console.log(`Example app listening on port ${port}`)
})

 

2) 기본 라우팅

// metods를 구분해 하나의 route에 4개의 요청과 응답이 가능하다
// postman을 사용해 작성한 api를 test 해보자

app.get('/', function (req, res) {
  res.send('Hello World!');
});

app.post('/', function (req, res) {
  res.send('Got a POST request');
});

app.put('/', function (req, res) {
  res.send('Got a PUT request');
});

app.delete('/', function (req, res) {
  res.send('Got a DELETE request');
});

 

 

 

mongoDB

 

몽고DB는 크로스 플랫폼 도큐먼트 지향 데이터베이스 시스템이다. NoSQL 데이터베이스로 분류되는 몽고DB는 JSON과 같은 동적 스키마형 도큐먼트들을 선호함에 따라 전통적인 테이블 기반 관계형 데이터베이스 구조의 사용을 삼간다

(나무 위키 발췌)

 

음.. 그냥 처음 db 써보기 좋은 친구.

처음 용어도 좀 헷갈렸는데 몽고DB에서 Document는 JSON 객체라고 보면 될듯하다

const dummy = [
  {name: "철수"},
  {name: "영희"},
];

위 dummy 배열을 몽고DB에 넣는다고 하면

1번 Document에는 {name: "철수"} 가
2번 Document에는 {name: "영희"} 가 들어가는 느낌?

(Document마다 _id 를 고유값으로 가진다.)

 

SQL 진형의 DB랑 비교해보면 스키마가 유동적이라 매우 쓰기 편하다.

RDBMS와 DB 설계 차이를 비교해보자

 

SQL 진형에서 포스팅 / 댓글 기능을 구현한다고 하면

user / post / comment 테이블을 설계한다. 

출처: https://kkt9102.github.io/db/MongoDB%EB%9E%80/

 

하지만 NoSQL 에서는 하나의 Document에 쑤셔넣는다.

{
  _id: ObjectId,
  title: String,
  body: String,
  user_id: String,
  reg_dt: Date,
  commments: [
      {
          _id: ObjectId,
          text: String,
          reg_dt: Date
      },
  ],
}

 

백엔드 개발을 시작했을때 이 DB구조에서 애먹었다.

적당히 설계했다가 필요한 데이터를 찾기위해 비슷한 컬렉션을 추가하는 등의 일이 왕왕 발생했다

(뭐 지금도 크게 다르진 않다..)

 

 

mongoose

 

mongoDB 확장판 정도로 데이터 모델링, 유효성 검사, 미들웨어 등 다양한 기능을 통해 개발자의 생산성을 높여준다

스키마가 고정되어있지 않은 mongoDB를 그냥 쓰면 같은 컬렉션의 Document 마다 data 형식이 다르게 들어가  점점 관리하기 어려워진다. (미들웨어 같은 기능은 express와 유사해 같이 공부하면서 자동으로 복습도 되고 좋았다.)

 

 

1) mongoose 시작하기

import mongoose from "mongoose";
import "dotenv/config";

const { MONGO_URI } = process.env;

async function connectDB() {
  if (!MONGO_URI) throw new Error("환경변수 설정 필수!");

  try {
    await mongoose
      .connect(MONGO_URI)
      .then(() => {
        console.log("mongodb connect!");
      })
      .catch((err) => console.error(err));
  } catch (err) {
    console.error(err);
  }
}

export { connectDB };

 

2) 스키마(schema)를 만들기

스키마는 Document에 들어가는 데이터가 어떤 형식으로 되어 있는지 정의하는 객체.

모델은 스키마를 사용해 만드는 인스턴스로, 데이터베이스에서 실제 작업을 처리할 수 있는 함수들을 가진 객체.

 

모델을 사용하려면 아래와 같이 사전에 스키마를 만들어주어야됨.

import mongoose from "mongoose";
const { Schema } = mongoose;

// post 
const PostSchema = new Schema({
  title: String,
  body: String,
});

const Post = mongoose.model("Post", PostSchema);
export default Post; // 이 Post 모델 객체를 사용해 db 조작이 이루어진다

 

 

3) 모델(model) 사용

import { Post } from "./models/post"; // 만들어준 Post 사용

app.post("/post", async (req, res) => {
  const { title, body } = req.body;
  const post = new Post({ 	// 인스턴스를 만들어 사용
    title: title,
    body: body,
  });
  try {
    await post.save();
    res.send("db 저장 성공!", JSON.stringify(post));
  } catch (err) {
    console.error(err);
    res.status(500).send("db 저장 실패!");
  }
});

 

 

 

Typescript

 

express도 mongoose 도 공식문서가 친절해서 참 좋다.

그중 어려웠던 mongoose 의 인스턴스 메서드와 스태틱 메서드에 대해서 작성하겠다

 

1) 스키마 생성

const userSchema = new Schema({
  userName: String,
  hashedPassword: String,
});

const User = mongoose.model("User", userSchema);
export default User;

 

2) 인스턴스 메서드

// 인스턴스 메서드 정의
userSchema.methods.hello = function () {
  return 'hello!!';
};

// 인스턴스를 만들어 사용
const user = new User({ userName: 'jack' });
console.log(user.hello()); // "hello!!"

 

3) 스태틱 메서드

// 스태틱 메서드 정의
userSchema.statics.findByUserName = function (userName: string) {
  return this.findOne({ userName });
};

// User 모델에 바로 접근해서 사용
User.findByUserName("jack").then(name => {
  console.log(name); // "jack", "jack" 이름을 가진 user 출력
});

 

4) Type 추가 (공식 문서 링크)

interface IUser {
  userName: string;
  password?: string;
  hashedPassword?: string;
}

// 인스턴스 메서드 type 정의
interface IUserMethods {
  hello(): string;
}

// static 메서드 type 정의
interface UserModel extends Model<IUser, {}, IUserMethods> {
  findByUserName(
    userName: string
  ): Promise<HydratedDocument<IUser, IUserMethods>>;
}

// user 스키마 type 정의
const userSchema = new Schema<IUser, UserModel, IUserMethods>({
  userName: { type: String, required: true },
  password: { type: String },
  hashedPassword: { type: String },
});

userSchema.methods.hello = function () {
  return 'hello!!';
};

userSchema.statics.findByUserName = function (userName: string) {
  return this.findOne({ userName });
};

const User = model<IUser, UserModel>("User", userSchema);

export { User };

 

 

끝! 읽어주셔서 감사합니다~

 

'Node.js' 카테고리의 다른 글

서버pc에 파일 업로드  (3) 2024.11.09

 

serverless 프레임워크를 통해 lambda를 배포하자

 

serverless 설치

npm i serverless -g

 

명령어로 시작

serverless

 

docs 에는 starter 로 시작하라고 하는데 최근 업데이트를 해서 선택지가 없다. (docs는 업데이트가 안됐다..)

Simple Function을 선택하자

 

AWS 자격 증명 설정

여기 중요하다. 처음에 Easy & Recommended로 만들었는데 한번 만들면 serverless 에 등록이 되어버려 삭제하고 다시 Iam을 등록해줘야된다. 최근 업데이트 된거라 docs에도 삭제관련 내용이 없어서 한참 해맸다.. 우린 이미 만들어준 iam 을 연결해야되니 Easy & Recommended 로 만들어주는 iam은 필요가없다.

2번째 Save AWS Credentials을 선택해준다.

만약 1번을 선택했다면 다음 과정을 따라해보자.

serverless login 후에 나오는 serverless app 에서 등록되어버린 iam을 직접 삭제해준다.

저 provider 부분은 lambda 배포할때 필요한거긴 한데 저게있으면 serverless 가 AWS 자격 증명 설정 선택지를 안준다..

그래서 저거 삭제하고 2번으로 선택한 후 저 provider iam을 만들어서 다시 넣어줬다

(업데이트 된지 얼마 안되서인지 docs에 관련 내용이 없어서 이부분은 계속 오류나면서 이것저것 해보다가 된거라 맞는 방법인지 확신은 없다)

 

플러그인 설치

npm install --save-dev serverless-lambda-edge-pre-existing-cloudfront

이미 있는 cloudfront에 lambda를 배포하려면 추가 작업이 필요하다. (관련 docs)

docs에 나온대로 yml 파일도 작성해주고

// serverless.yml

functions:
  hello:
    handler: index.hello // index 파일의 hello 함수 배포
    events:
      - preExistingCloudFront:
          # ---- Mandatory Properties -----
          distributionId: ID # CloudFront distribution ID you want to associate
          eventType: origin-response # Choose event to trigger your Lambda function, which are `viewer-request`, `origin-request`, `origin-response` or `viewer-response`
          pathPattern: "*" # Specifying the CloudFront behavior
          includeBody: false # Whether including body or not within request
          # ---- Optional Property -----
          # stage: dev # Specify the stage at which you want this CloudFront distribution to be updated
plugins:
  - serverless-lambda-edge-pre-existing-cloudfront

 

추가로 1탄에서 만든 iam 을 입력해준다

// serverless.yml

provider:
  name: aws
  runtime: nodejs20.x
  iam:
    role:
      arn:aws:iam::[YOUR IAM ARN]

 

 

이제 적당한 예시 함수를 배포해보자

// index.js

export const hello = async (event) => {
  console.log(JSON.stringify(event, null, 2));
  return event;
};

 

배포 명령어 입력

serverless depoly

 

배포가 성공하면 버지니아 북부 > Lambda > 함수 에서 확인할 수 있다. 

 

최신 버전으로 들어가면

 

트리거 Cloudfront가 잘 연결되어있다 

 

테스트 코드도 잘 들어가있고

 

잘 되는지 테스트도 할수있다.

querystring 으로 선택

 

아래 로그는 cloudwatch 로 연결되는데 로그를 더 정확하게 보고싶을때 사용한다.

(무수한 에러의 요청에 많이 보게된다..)

 

test 함수가 잘 배포되었으니 실제 리사이징 코드를 작성하자.

"use strict";
import Sharp from "sharp";
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";

// S3 클라이언트 초기화
const S3 = new S3Client({
  region: "ap-northeast-2",
});

// 쿼리스트링에서 특정 키의 값을 가져오는 함수
const getQuerystring = (querystring, key) => {
  return new URLSearchParams("?" + querystring).get(key);
};

// 이미지 리사이즈 함수
export const imageResize2 = async (event, context) => {
  console.log("imageResize 실행!!");

  // 이벤트에서 요청 데이터 가져오기
  const { request, response } = event.Records[0].cf;

  // 쿼리스트링 가져오기
  const querystring = request.querystring;
  
  // 쿼리스트링이 없으면 원본 이미지 반환
  if (!querystring) {
    console.log("querystring is empty!! return origin");
    return response;
  }

  // URI 디코딩
  const uri = decodeURIComponent(request.uri);

  // 파일 확장자 추출
  const extension = uri.match(/(.*)\.(.*)/)[2].toLowerCase();
  console.log("extension", extension);

  // GIF 파일은 리사이징하지 않고 원본 반환
  if (extension === "gif") {
    console.log("extension is gif!! return origin");
    return response;
  }

  // 쿼리스트링 파싱
  const width = Number(getQuerystring(querystring, "w")) || null;
  const height = Number(getQuerystring(querystring, "h")) || null;
  const fit = getQuerystring(querystring, "f");
  const quality = Number(getQuerystring(querystring, "q")) || null;
  console.log({ width, height, fit, quality });

  // S3 버킷 이름 및 경로 추출
  const s3BucketDomainName = request.origin.s3.domainName;
  let s3BucketName = s3BucketDomainName.replace(
    ".s3.ap-northeast-2.amazonaws.com",
    ""
  ).replace(".s3.amazonaws.com", "");
  console.log("s3BucketName", s3BucketName);
  
  const s3Path = uri.substring(1);
  console.log("s3Path: ", s3Path);

  // S3에서 이미지 가져오기
  let s3Object = null;
  try {
    s3Object = await S3.send(
      new GetObjectCommand({
        Bucket: s3BucketName,
        Key: s3Path,
      })
    );
    console.log("S3 GetObject Success");
  } catch (err) {
    console.log("S3 GetObject Fail!!", err);
    return err;
  }

  // 이미지 리사이즈 수행
  const s3Uint8ArrayData = await s3Object.Body.transformToByteArray();

  let resizedImage = null;
  try {
    resizedImage = await Sharp(s3Uint8ArrayData)
      .resize({ width, height, fit })
      .toFormat(extension, { quality })
      .toBuffer();
    console.log("Sharp Resize Success");
  } catch (err) {
    console.log("Sharp Resize Fail!!", err);
    return err;
  }

  // 리사이징한 이미지 크기 확인
  const resizedImageByteLength = Buffer.byteLength(resizedImage, "base64");
  console.log("resizedImageByteLength:", resizedImageByteLength);

  // 리사이징한 이미지가 1MB 이상일 경우 원본 반환
  if (resizedImageByteLength >= 1048576) {
    console.log("resizedImageByteLength >= 1048576!! return origin");
    return response;
  }

  // 리사이징한 이미지 응답 설정
  response.status = 200;
  response.body = resizedImage.toString("base64");
  response.bodyEncoding = "base64";
  console.log("imageResize 종료!!");
  return response;
};

(코드 출처)

 

실제 쿼리스트링에 따라 lambda 함수가 실행되는지 확인해보자

Miss from cloudfront 인 경우( cloudfront 캐싱 전일 때)

 

위의 lambda에 배포한 리사이징 함수가 실행된다.

 

새로고침 후 Hit fron cloudfront ( cloudfront 캐싱됐을때 )

 

lambda 함수가 실행되지 않는다

 

s3, cloudfront, lambda 등등 aws 인프라 이용하면서 DevOps에 흥미가 생겼고 단순히 기능을 구현하는 건 정말 개발의 일부구나 생각했다. (그리고 aws 주식을 사고 싶어졌다.)

다음은 요새 뜨고있는 cloudflare 인프라도 이용해보고 싶은데 도메인 연결해서 cloudflare 이미지 리사이징을 해보는것도 재밌겠다.

 

출처 및 참고했던 블로그

aws docs

serverless docs

 

그대로 따라하기 정말 좋았던 연우리님 블로그

inpa 블로그

inpa 블로그-2 (개념)

올리브영 테크블로그

'aws' 카테고리의 다른 글

AWS cloudfront & lambda@edge 로 이미지 리사이징 -1  (0) 2024.08.22

이번글은 회고보다는 이미지 리사이징하면서 만났던 무수한 에러들과 해결했던 부분을 정리하고자 한다.

(정리 안해놓으면 까먹을거 같아서..)

 

프로젝트 초기 구상 때부터 걸렸지만 외면하고 있던 부분이 있었다.

그것은 바로 매우매우 많은 이미지..

메인 페이지부터 이미지가 잔뜩인 나의 NFT 마켓플레이스..

Next.js 로 이미지 리사이징 하는것도 좋고 아님 node.js 서버 하나 돌려서 이미지 리사이징 처리해도 좋은데 

일단 스택을 React로 잡기도했고 이미지 리사이징 하려고 서버를 상시 돌린다는 것도 배보다 배꼽이 큰 느낌이라 다른 방법을 찾아봤다.

 

 Cloudflare vs AWS cloudfront & lambda@edge

serverless 컨셉을 유지하면서 이미지 리사이징 처리하는 방법으로 둘중에 고민했는데 Cloudflare가 요새 뜨기도 하고 사용법도 훨씬 간편한데 AWS 인프라를 사용해보고 싶어서 AWS로 선택했다.

Cloudflare의 이미지 관련 내용을 잠깐 설명하면 Next.js 이미지 리사이징과 AWS cloudfront(cdn)를 합친걸로 생각하면 되는데 아래 예시를 참고하면 쉽게 이해될듯하다.

//Next.js 이미지 리사이징

import Image from 'next/image';
<Image
    src="/path/to/your/image.jpg" // 이미지 경로
    width={500} // 원하는 너비
    height={300} // 원하는 높이
    layout="responsive" // 레이아웃 옵션
    quality={75} // 이미지 품질 (0-100)
/>
// cloudflare image 리사이징

// Docs: https://developers.cloudflare.com/images/url-format
export default function cloudflareLoader({ src, width, quality }) {
    const params = [`width=${width}`, `quality=${quality || 75}`, 'format=auto']
    return `https://example.com/cdn-cgi/image/${params.join(',')}/${src}`
}
요렇게 함수를 만들어서 호출하면 cloudflare 에 등록한 
url(https://example.com/ 도메인 등록 해줘야 쓸수 있음!)
을 통해 이미지를 리사이징 할 수 있다

위 cloudflareLoader를 호출해 url로 요청을 하면 cdn이 해당 내용을 캐싱해서 일정 시간동안은 리사이징을 진행하지 않고 캐싱해온 콘텐츠를 가지고와서 리소스 낭비가 없다.

 

처음엔 그냥 next.js 더 써보고 싶은데 Next.js 로 마이그레이션 해버릴까.. 를 생각했지만 이번 리사이징 진행하면서 좀더 알아보니 편하게만 생각했던 next/image 리사이징은 그만한 대가가 있었다.

 

사이드 프로젝트로 next/image 사용하던 중에 본인 노트북이 다운된적이 있다.

좀 오래 쓰긴했어도 그정도는 아닌데.. 같은 작업중에 몇번 더 다운되길래 디버깅해봤다.

image 리사이징이 많이 들어간 컴포넌트가 랜더링 되는 순간 다운되길래 CPU 사용량을 측정해보니 순간적으로 100%를 찍고 내려오고 있었다.

 

..잉?

이미지 프로세싱은 CPU 리소스를 많이 사용한다. 리사이징은 각 픽셀에 대한 연산을 수행하는데,

1920x1080 해상도의 이미지는 200만 픽셀이 넘는다. 모든 픽셀에 연산을 수행하고 다시 픽셀을 새로운 크기에 맞게 재배치 하는 과정이 CPU 부하를 준다. JPEG, Webp 확장자 처럼 용량을 압축한 포맷들도 압축을 해제 후 리사이징을 진행하기 때문에 원본 px값의 연산이 고스란히 진행된다.

 

만약 AWS 프리티어 계정의 서버에 올린다고 하면

AWS EC2 ts.micro의 1CPU, 1GB RAM 인스턴스 사양으로 이미지 리사이징이 몇개만 진행되도 CPU가 금방 100% 되는걸 예상할 수 있고(본인 노트북 사양 4CPU, 8GB RAM) RAM 사용량도 금방 100%가 넘어갈듯하다. 

AWS ts.micro 사양

 

이미지 프로세싱은 메모리에 원본 이미지를 로드하고 프로세싱 중의 buffer와 결과 값을 저장하는 등에 RAM 이 사용되는데 적당히 5MB PNG 파일로 계산해보자.

원본의 50%만 압축되었다고 가정해도 (2:1 ~ 5:1 압축률을 가진다)

압축해제 10MB( 5MB * 2  ) + 결과 값 10MB + (리사이징 중 Buffer 값) = 20MB 보다 크고

운영체제와 Next.js 서버 메모리 사용량을 제외하면  40개의 이미지 리사이징 만으로 RAM 사용량이 100%가 되버린다. 

 

(그리고 Next서운 이야기.. )

이미지 최적화의 캐싱 관련해서 가비지 컬렉터가 정상적으로 작동하지 않아 메모리가 100%가 되어버린다?

열려있는 메모리 누수 관련 Git issue

 

 

등등의 next/image의 그림자를 보고나니 lambda 함수로 로컬 서버 외의 리소스를 사용하는 접근이 맞다고 생각했다.

 

AWS cloudfront & lambda@edge (feat.s3)

 

AWS 인프라를 통해 이미지 리사이징을 처리하는 과정을 아래 아키텍처와 같다

0. (리사이징할) 이미지를 s3에 업로드 한다.

1. 사용자가 cloudfront url을 통해 s3 에 업로드한 이미지를 요청한다

2. s3에 요청한다 (cloudfront에 리사이징한 이미지가 캐싱되어 있으면 바로 주고 과정은 끝남)

3. cloudfront에 설정해놓은 origin response 트리거로 lambda 함수가 호출된다

4. 요청한 url(&query) 로 이미지를 리사이징하고 리사이징된 콘텐츠는 cloudfront가 캐싱한다.

5. url을 요청한 사용자는 해당 이미지를 받아 본다.

출처: https://medium.com/daangn/lambda-edge%EB%A1%9C-%EA%B5%AC%ED%98%84%ED%95%98%EB%8A%94-on-the-fly-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EB%A6%AC%EC%82%AC%EC%9D%B4%EC%A7%95-f4e5052d49f3

 

그럼 아키텍처대로 구현해보자.

 

1. s3 버킷 만들기

처음에 리전 선택 잘못했다가 다시 만들었다. 버킷 사용자가 주로 위치한 지역과 가까운 리전을 선택해야 데이터 전송 시간을 줄일 수 있다. cloudfront로 데이터를 캐싱해서 주더라도 첫 요청은 s3에서 응답하기 때문에 사용자가 많은 곳으로 리전 선택을 해주도록 하자.

 

2. cloudfront 배포 생성하기

origin domain에 위에서 만든 s3를 설정한다.

-> cloudfront가 콘텐츠를 가져올 오리진(원본) 서버를 설정하는것.

s3 버킷, ec2 인스턴스 또는 다른 http 서버일 수 있고 origin 의 위치와 성격에 따라 성능에 영향을 미칠수 있다.

원본 엑세스 제어 설정을 하면 s3로의 직접 접근이 막히고 cloudfront를 통한 접근만 가능해진다.

create new OAC(Origin access control) 를 만들어준다

OAC 생성

위의 과정은 cloudfront 의 OAC(원본 액세스 제어)로 s3 원본을 보호하게 해주는데 요청 응답 플로우는 다음과 같다.

1. 클라이언트가 HTTPS 요청을 Cloudfront로 보냄

2. Cloudfront 엣지 로케이션이 요청을 수신하고 요청 객체가 아직 캐시되지 않은 경우

  OAC 서명 프로토콜을 사용해 요청에 서명.

3. S3는 요청에 응답 (올바른 서명이면 승인, 아니면 거부)

-> 위의 서명 요청을 선택하면 위 과정 중 2번에서 CloudFront IAM 서비스 보안 주체가 수신 요청 발생 시  Authorization 헤더에 서명한다. (관련 docs)

OAC 워크플로우 (출처: https://aws.plainenglish.io/b1a9f49dfab3)

 

 

WAF 방화벽

그리고 중간에 WAF 선택 란이 있는데 test용이면 비활성화 하면된다.

aws 설명에 혹해서 활성화했는데 웹 사이트 이용자가 거의 없는데 WAF비용만 한달에 $10 정도씩 나가서 비활성화했다..  

-> WAF은 SQL 인젝션, XSS, 파일 포함 공격, 악성 봇 차단, DDoS 공격에 대한 방어를 해준다.

 

+ 좀더 알아보니 이런 방어를 cloudfront 이후에 해서 url 요청 자체는 모두 받아들인다. 예를들어 DDos 같은 경우 Origin에 대한 요청은 방지하지만 cloudfront 요청은 무수히 발생하고 WAF도 어쨌든 요청에 대한 deny response가 무수히 발생하는 상황이라 해당 요금이 과도하게 발생할 여지가있다. 

추가적인 내용은 WAF 보안 관련해서 알아보다가 매우 도움이 되었던 블로그를 링크해놓겠다. 

Cloudfront 이후에 동작하는 WAF

 

https://changmyeong.tistory.com/77

 

AWS CloudFront에서 CloudFlare로 이관한 후기

2023년 6월, 여러 게임의 편의 기능을 제공하며 유저를 조금씩 모아보겠다는 취지로 사이드 프로젝트를 개발하여 오픈했다. 개발 시간은 1.5일 정도 걸렸지만 오픈한지 한 달이 되던 때에 하루에 7

changmyeong.tistory.com

 

3 cloudfront 배포 후 해당 정책 s3 버킷 정책에 등록

해당 과정을 진행해야 cloudfront를 통해 s3 에 접근할 수 있다.

cloudfront 의 정책 복사
s3 > 권한 > 버킷정책에 붙여넣어준다

 

이렇게하면 cloudfront를 통해 s3 이미지에 접근할 수 있다.

 

4. cloudfront 캐시 정책 생성

쿼리 문자열에 따라 콘텐츠를 캐싱하기위해서는 cloudfront에 쿼리 문자열을 포함하도록 설정해야한다.

 

5. iam 

-> iam (identity and access management) 리소스에 대한 엑세스를 관리하고 제어하는 서비스로 iam을 통해 aws 리소스에 대한 권한을 세밀하게 설정하고 관리할 수 있다. 우린 아래 방법 중 2) 역할을 가진 iam 이 필요하다.

 

1) 사용자에게 권한 부여

 - aws 계정의 리소스에 액세스할 수 있는 개별 사용자를 생성할 수 있다. (사용자에게 필요한만큼 권한 부여)

 - JSON 형식의 정책을 통해 사용자의 권한을 정의한다.

2) 역할 (role)

 - 주로 ec2 인스턴스나 lambda 함수와 같은 aws 서비스가 다른 aws 리소스에 액세스 할 때 사용.

 

iam 역할을 생성한다.
권한은 AWSLambdaExecute를 선택한다.

lambda 관련 권한 정책이 다양한데 그중 AWSLambdaExecute는 lambda 함수를 호출할 수 있는 권한을 포함하고, s3의 GET에 대한 액세스를하게 해준다. (관련 docs)

 

iam 생성 후 신뢰관계를 편집해서 lambdaedge 에 대한 권한을 추가해준다. (관련 docs)

 

이러면 lambda 함수 실행시 우리에게 필요한 iam이 완성되었다.

다음편 serverless 프레임워크로 lambda함수 배포하기 

'aws' 카테고리의 다른 글

AWS cloudfront & lambda@edge 로 이미지 리사이징-2  (2) 2024.08.29
webpack 설정하기 loader, plugin

 

Webpack 개념에서 실무활용까지 -1 탄에서는 js, css, html까지만 번들링했는데 이번엔 자주 사용하는 loader와 plugin의 사용법에 대해 알아보자.

 

1. balel-loader 

babel은 최신 javascript 코드를 구형 브라우저에서도 호환되도록 변환하는 역할을 한다. 개발자는 최신 기능(es6 +)을 사용해도 다양한 환경에서 애플리케이션이 정상적으로 작동하도록 해줌. (ES6 + -> ES5로 변환한다.)

필수 loader라 1번으로 설정해준다.

 

설치 후 module에 아래와 같이 작성한다.

(webpack babel-loader docs)

npm install -D babel-loader @babel/core @babel/preset-env webpack
module: {
  rules: [
    {
      test: /\.(?:js|mjs|cjs)$/,
      exclude: /node_modules/, // node_modules 은 변환하지 않는다
      use: {
        loader: 'babel-loader',
        options: {
          presets: [
            ['@babel/preset-env', { targets: "defaults" }]
          ]
        }
      }
    }
  ]
}

 

2. file-loader

image, font 등의 asset 파일을 type 경로에 복사해준다. 파일 경로가 자동으로 관리되므로 가독성과 유지보수가 좋아진다

webpack5 부터는 별도로 설치 안해도 된다.

module.exports = {
  module: {
    rules: [
      {
        test: /\.(png|jpe?g|gif|svg)$/i, // 이미지 파일 처리
        type: 'asset/resource', // 파일을 복사해 해당 경로에 저장
      },
      {
        test: /\.(woff|woff2|eot|ttf|otf)$/i, // 폰트 파일 처리
        type: 'asset/resource', 
      },
    ],
  },
};

 

3. sass-loader ()

scsss/sass 파일을 css로 변환

 

설치 ( webpack sass-loader docs)

npm install sass-loader sass webpack --save-dev
module.exports = {
  module: {
    rules: [
      {
        test: /\.(sa|sc|c)ss$/i,,
        use: [
          "style-loader", "css-loader" // 기존의 css loader
          "sass-loader", // sass-loader 추가!
        ],
      },
    ],
  },
};

 

4. MiniCssExtractPlugin

1탄에서 설명하 html 파일처럼 css 파일을 별도의 파일로 추출한다.

production용으로 빌드하는 경우엔 js와 병렬 로딩이 가능하도록 css를 별도로 추출하는것이 좋다.

docs에는 style-loader와 함께 사용하지 말라고 권장하니 아래와 같이 사용해보자. (관련 git docs)

npm install --save-dev mini-css-extract-plugin
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const devMode = process.env.NODE_ENV !== "production";

module.exports = {
  module: {
    rules: [
      {
        test: /\.(sa|sc|c)ss$/i,
        use: [
          devMode ? "style-loader" : MiniCssExtractPlugin.loader, // mode에 따라 다르게 실행해준다
          "css-loader",
          "sass-loader",
        ],
      },
    ],
  },
  plugins: [].concat(devMode ? [] : [new MiniCssExtractPlugin()]), // plugin 사용법
};

 

5. dotenv-webpack (feat .env)

.env 파일에 작성한 환경변수도 아래와 같이 번들해주면 웹 브라우저에서도 사용 가능하다.

// .env 파일

API_URL: localhost:5500
NODE_ENV: development
const Dotenv = require("dotenv-webpack");

module.exports {
  entry: "./src/index.js",
  output: {
    path: path.resolve(__dirname, "dist"),
  },
  plugins: [
    new Dotenv(), // .env 파일을 읽도록 설정
  ],
};

 

또는 아래와 같이 각각 설정해준다

const webpack = require('webpack');
const dotenv = require('dotenv');

dotenv.config(); // .env 파일 변수를 가져온다

module.exports = {
  plugins: [
    new webpack.DefinePlugin({ webpack.DefinePlugin 으로 하나하나 정의한다.
      'process.env.API_URL': JSON.stringify(process.env.API_URL),
      'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
    }),
  ],
  mode: process.env.NODE_ENV || 'development',
};

 

그럼 아래와 같이 웹브라우저에서 구동하는 js파일에서 사용 가능하다.

 

// index.js

console.log(process.env.API_URL) // localhost:5500
console.log(process.env.NODE_ENV) // development

 

6. typescript 설정

typescript 를 사용한다면 아래와 같이 babel도 함께 작성하면 좋다.

module.exports = {
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        use: [
          {
            loader: 'babel-loader', // Babel을 먼저 실행
            options: {
              presets: ['@babel/preset-env', '@babel/preset-typescript'], // TypeScript와 ES6+ 지원
            },
          },
          'ts-loader', // TypeScript 처리
            ],
            exclude: /node_modules/,
          },
        ],
      },
  }

 

그리고 build용량 줄이려고 webpack 많이 사용하는데 mode 지정에 따라 아래의 설정이 자동으로 적용된다.

 

mode: development

 - 소스 맵 생성, 코드 압축 비활성화(디버깅 용이), 경고 메시지 활성화 등 개발에 최적화된 기본 설정

mode: production

 - 코드 압축, 최적화, 트리 셰이킹 등 프로덕션에 최적화된 기본 설정

 

3탄은 회사에서 진행했던 electron webpack 설정과 배포 자동화까지의 내용을 담으려 합니당

읽어주셔서 감사합니다.👩‍💻

'Webpack' 카테고리의 다른 글

Webpack 개념에서 실무활용까지 -1  (0) 2024.08.21
webpack 이 뭐에요?

 

webpack은 최신 자바스크립트 애플리케이션을 위한 정적 모듈 번들러입니다. (webpack 공식 정의)

정의는 역시 잘 와닿지 않죠.

 

그럼 모듈은 뭐고 번들은 뭘까?

 

모듈은 기능을 가진 (작은) 코드 단위이다. 대규모 애플리케이션에서 모듈화는 관리와 유지보수를 쉽게 해주고, 코드의 재사용성을 높이고 협업에 도움을 준다. 

// 작게는 아래 함수부터
function sum(a, b) {
  return a + b;
}

// 일반적으로 많이 사용하는 node_modules 에서 불러오는 것들 (패키지 기반 모듈)
import path from 'path'; 경로를 쉽게 설정할 수 있게 해주는 path 모듈

// 또는 HTML, CSS, Js, Images 파일들 하나하나도 모듈이다 (파일 기반 모듈)
index.html
main.css
main.js
image.jpg

 

모듈은 관리하기 위한 시스템이 존재하는데 대표적으로는 CommonJS, ES6 모듈 시스템이 있다.

CommonJS) require module.exports 를 사용해 모듈을 가져오고 내보낸다.

ES6) import export 로 모듈을 가져오고 내보낸다.

 

번들은 관련 파일들을 하나로 묶어주는 것인데 (연필 12개를 한다스로 묶듯이)

모듈 번들러는 웹 애플리케이션을 구성하는 여러 모듈(파일 등)을 하나의 파일로 묶고 압축해주는 것을 모듈 번들링이라고 한다. (흔히 빌드한다고 하는데 번들링과 같은 말임)

webpcak docs 중 발췌

 

webpack 어디다 쓰나요?

 

웹팩은

1. 자바스크립트 모듈(파일)들의 관리와 

2. 웹 애플리케이션 빠른 로딩 속도와 성능

등의 이점이 있다.

 

1. 자바스크립트 모듈(파일)들의 관리

react, next.js는 자동으로 webpack 설정을 해줘서 종종 잊지만 한번씩 html, css, js 파일로 작은 애플리케이션을 만들다 보면 webpack 설정은 필요하다.

 

아래 번들링 없이 전역 변수를 사용하는 예시를 보자.

hello.js, world.js 파일을 불러와 innerHTML에 파싱한다

// index.html
<html>
    <body>
      <h1>hello, webpack!</h1>
      <div id="root"></div>
        <script src="./src/hello.js"></script>
        <script src="./src/world.js"></script>
        <script>
          document.getElementById('root').innerHTML = v; // world 만 출력 
        </script>
    </body>
</html>

// hello.js
var v = "Hello";

// world.js
var v = "world";

 

결과는 늦게 가져온 world.js 값만 파싱되었다 (덮어 씌워짐)

변수의 유효 범위 때문에 전역 변수의 예상치 못한 충돌이 발생한다.

 

-> ES6 모듈 시스템을 사용해 이름 충돌을 피할 수 있다. 

<script type="module">
  import v_hello from './src/hello.js';
  import v_world from './src/world.js';
  document.getElementById('root').innerHTML = v_hello + " " + v_world; // hello world 출력
</script>

위와 같이 ES6 모듈만 사용해도 전역 변수 충돌 해결된다.

하지만 webpack을 사용하면 더 깔끔하게 관리 가능하다.

 

index.js 파일에 위 코드를 작성하고 

// index.js
import v_hello from './hello.js';
import v_world from './world.js';

document.getElementById('root').innerHTML = v_hello + " " + v_world;

 

webpack으로 번들링해보자.

그리고 index.html 의 script 파일 경로를 번들링된 js로 변경

// index.html

<body>
  <h1>hello, webpack!</h1>
  <div id="root"></div>
  <script src="./build/main.js"></script> // bundle 된 js 파일 경로
</body>

이렇게 하면 위의 기능과 동일하게 root 안에 hello world 가 출력된다.

-> webpack을 통해 2개의 js파일을 하나로 관리하고 import export 로 전역 변수 충돌문제를 해결하는 es6 모듈 시스템을 더욱 잘 관리할 수 있게됐다. 

 

물론 요새는 var 사용 안하기도 하고 예시가 잘 와닿지 않을 수 있지만 위와 같은 번들링이 없던 시절엔 점점 규모가 커지면서 위의 전역변수 충돌, 모듈 관리의 문제점이 있었고 이를 방지하기 위해 여러 방법 중에 하나인 웹팩이 등장했다고한다.

 

+ react에 설정된 자동 번들러없이 js 파일 만들때 아래 에러 한번씩은 만나봤을겁니다..


Uncaught TypeError: Failed to resolve module specifier "lodash". Relative references must start with either "/", "./", or "../". 

// hello.js

import _ from "lodash"; // react에서 처럼 그냥 이렇게 node_modules 에서 가져오면 에러남

var v = _.join(["Hello", " ", "world"], ""); // Hello world

 

위와 같이 상대 경로로 가져오라는 에러가 나는데 웹 브라우저는 기본적으로 상대 경로로 시작하는 경로만 지원하고

빌드없이 해당 모듈을 불러오려면 import _ from "../node_modules/lodash/lodash.js" 요런식으로 해야되는 번거로움이 발생한다. (빌드 후에는 최종 파일에 lodash 와 같은 import 한 모듈이 포함되어 있어 바로 접근 가능한것)

이와같이 개발 과정에서 빌드 도구를 사용하면 모듈 관리를 편하게 할수있다.

 

2. 웹 애플리케이션 빠른 로딩 속도와 성능

많은 js 파일을 하나의 js 파일로 묶어서 압축해주기 때문에 용량이 작아져 웹 브라우저가 빨리 읽을수 있고 

여러 HTTP 통신을 줄여줘 웹 애플리케이션 성능을 높여줌. 

 

간단한 예제

webpack 과 webpack-cli 설치

webpack-cli 를 통해 npx webpack, npx webpack server 등의 명령어를 사용할 수 있다

npm init
npm install webpack webpack-cli --save-dev

 

// package.json

// script는 취향에 따라 커스텀해준다
"scripts": {
  "build": "npx webpack --watch" // 해당 명령어는 바라보는 js 파일이 변하면 다시 build 해줌
}
// webpack.config.js

const path = require('path');

module.exports = {
  entry: './src/index.js', // 해당 경로 index.js을 build 해서
  output: {
    filename: 'main.js',
    path: path.resolve(__dirname, 'dist'), // 해당 경로에 main.js 로 output 해준다
  },
};

// entry 와 output 은 위 설정이 default 지만 여러 프로젝트마다 폴더구조도 다르고하니 알아두자

 

css 번들 

css도 js 파일로 함께 말수있다. 

 

css 관련 모듈 설치

npm install --save-dev css-loader
npm install --save-dev style-loader
module.exports = {
  // ...
  module: {
    rules: [
      {
        test: /\.css$/i, // 파일 확장자가 .css로 끝나는 모든 파일을 build 한다
        use: ["style-loader", "css-loader"],
      },
    ],
  },
};

loader는 파일을 전처리 시켜준다. loader를 통해 js 파일만이 아닌 자산(css, image, json 파일 등등)을 번들링 할 수 있다.

(css -> js, image -> base64 로 변환)

 

html도 함께 번들링해야 배포하기 편함

const HtmlPlugins = require("html-webpack-plugin");

module.exports = {
  plugins: [ // css와 다르게 plugins에서 인스턴스를 실행한다
    new HtmlPlugins({
      filename: "index.html", // output 시 파일명
      template: "./src/index.html", // input html 경로
    }),
  ],
  module: {
    rules: [
      {
        test: /\.css$/i,
        use: ["style-loader", "css-loader"],
      },
    ],
  },
};

로더는 특정 유형의 모듈(css, image)을 변환하고, 플러그인은 번들을 최적화하거나, 에셋 관리, 환경 변수 주입 등의 다양한 작업을 할 수 있다. (HTML 파일 생성, 코드 압축, .env 파일의 환경 변수 주입 등)

'Webpack' 카테고리의 다른 글

Webpack 개념에서 실무활용까지 -2  (0) 2024.08.21
Context API 와 Recoil

 

이제 1년차인 응애 개발자여서 처음에는 랜더링을 크게 신경쓰지 않고 우선 기능에 초점을 맞춰 개발해나갔다.

하지만 애플리케이션이 점점 커지고 child component들이 많아지던 어느 순간부터 '어? 랜더링 최적화 해야겠는데..?' 라고 생각 될 정도로 성능이 지수함수 형태로 안좋아지기 시작했다.

 

랜더링 최적화의 방법은 React.memo, useMemo, useCallback, 상태와 props의 좋은 아키텍쳐 설계, 상태관리 라이브러리 사용, key값 사용(diffing 알고리즘) 등등 많지만 여기서는 contextAPI와 recoil을 통해 해결했던 방법을 서술하겠음!

 

전역 상태관리를 라이브러리로 recoil을 사용했는데, 당시엔 개발할 애플리케이션 규모가 작아 복잡한 상태관리가 없을거라 판단해 직관적인 recoil을 선택했다. recoil 상태와 setter 함수를 가져와 커스텀훅도 만들고 열심히 개발할때는 몰랐는데 성능 최적화에 손을 대는 순간 내가 react도 recoil도 잘못 사용하고 있었다는것을...  깨달았다.

 

react는 상태가 바뀌면 리랜더링이되고, recoil은 기본적으로 setter 함수와 상태를 구분해서 불러올 수 있다.

아래 예시를 보자.

// atom 생성
export const numberState = atom({
  key: "numberState",
  default: 0,
});


// Test 컴포넌트
const Test = () => {
  const number = useRecoilValue(numberState); // numberState atom에서 value 값만 사용 
  console.log('number: ', number);

  return (
    <div>
      <h1>{number}</h1>
    </div>
  );
}


// Test2 컴포넌트
const Test2 = () => {
  const setNumber = useSetRecoilState(numberState); // numberState atom에서 setter함수만 사용
  const addNumberHandler = () => {
    setNumber((prev) => prev + 1);
  }
  console.log('Test2 리렌더링');

  return (
    <div>
      <button onClick={addNumberHandler}>
        <h1>test2</h1>
      </button>
    </div>
  );
}

 

 

아래 화면을 보면 같은 key의 atom에서 가져왔지만

Test2의 setter 함수를 실행시키면 state를 가진 Test 컴포넌트만 리랜더링 된다.

Test 컴포넌트만 리랜더링 된 모습

 

하지만 이걸 커스텀 훅으로 만들면?

// 커스텀훅 생성
const useRecoilCustomhook = () => {
  const [number, setNumber] = useRecoilState(numberState);

  const addNumberHandler = () => {
    setNumber((prev) => prev + 1);
  };

  return {
    number,
    addNumberHandler,
  };
};

export default useRecoilCustomhook;

// Test 컴포넌트
const Test = () => {
  const { number } = useRecoilCustomhook(); // 커스텀훅에서 state 사용
  console.log('number: ', number);

  return (
    <div>
      <h1>{number}</h1>
    </div>
  );
}

// Test2 컴포넌트
const Test2 = () => {
  const { addNumberHandler } = useRecoilCustomhook(); // 커스텀훅에서 함수 사용
  console.log('Test2 리렌더링');

  return (
    <div>
      <button onClick={addNumberHandler}>
        <h1>Test2</h1>
      </button>
    </div>
  );
}

 

당연하게 Test2 컴포넌트도 함께 랜더링된다.

addNumberHanlder 가 실행되면 커스텀훅에 있는 number state가 랜더링되며 커스텀훅을 사용하는 모든 컴포넌트에서 랜더링이 발생하는 당연한 현상이 일어납니다. (지금은 당연하지만 그때는 아니었습니다.. 또륵)

커스텀훅을 사용하는 모든 컴포넌트에서 랜더링 발생

 

물론 위 예시는 간단하니까 금방 최적화 할수 있지만 당시 커스텀훅 남발로 인해 리팩토링이 시급했고..

꼬인 코드를 떼어낼 방법으로 contextAPI 로 state 관심사를 분리해줬다.

그리고 context에서 상태와 setter 함수를 분리해서 상태를 가진 컴포넌트가 아니면 리랜더링이 되지않도록 했다.

관련 내용 docs

export const StateContext = createContext(); // 상태context 
export const SetterContext = createContext(); // setter 함수 관련 context

const GlobalProvider = ({ children }) => {

  const [number, setNumber] = useState(0);
  const addNumberHandler = () => {
    setNumber(prev => prev + 1);
  };

  const stateValue = useMemo(() => ({
    number
  }), [number]);

  const setterValue = useMemo(() => ({
    addNumberHandler
  }), []);

  return (
    <StateContext.Provider value={stateValue}>
      <SetterContext.Provider value={setterValue}>
        {children}
      </SetterContext.Provider>
    </StateContext.Provider>
  );
}

export default GlobalProvider;

 

아래와 같이 SetterContext를 사용한 컴포넌트는 StateContext 와 달리 랜더링 되지 않는다

const Test = () => {
  const { number } = useContext(StateContext); // StateContext 구독
  console.log('number: ', number);

  return (
    <div>
      <h1>{number}</h1>
    </div>
  );
}

const Test2 = () => {
  const { addNumberHandler } = useContext(SetterContext); // SetterContext 구독
  console.log('Test2 리렌더링');

  return (
    <div>
      <button onClick={addNumberHandler}>
        <h1>Test2</h1>
      </button>
    </div>
  );
}

  

state 와 setter context를 별도로 나눠 state쪽 context를 구독한 컴포넌트만 랜더링된 모습

 

위 context 분리 방법의 이점은 전역상태와 setter 함수를 별도로 관리하니 코드 관리와 유지보수가 용이한 점과 전역으로 사용할 state와 컴포넌트를 캡슐화 시킬수 있는 점이었는데, 위에서 특정 recoil state와 setter 함수로 커스텀훅을 사용하듯 전역으로 사용할 state와 settter 함수를 특정 컴포넌트들과 매칭시켜 코드를 큰 덩어리끼리 분리할수 있었다.

 

그리고 recoil은 goole-maps-api의 map 객체와 같이 복잡한 객체를 state에 저장할 수가 없다. 처음엔 필요한 부분만 뽑아서 state에 부분저장 하거나 ref로 어떻게 어떻게 처리하다가 만들던 애플리케이션이 maps의 꽤 많은 기능들을 사용하다보니 처음엔 다른 상태관리 라이브러리를 사용할까 하다가 contextAPI 가 react에 내장된 기능이기도하고 다른 라이브러리를 사용하기보다 이쪽으로 해결해보고 싶었다.

 

Recoil Selector

 

recoil로 남발해 놓은 커스텀훅을 덜어내려고 고민하다보니 기존에 외면해왔던 기능들이 눈에 들어왔다.

몇번 docs 읽어보긴했었는데 음..음.. 그렇군 왜 사용하는지 모르겠군! 난 커스텀 훅을 사용하겠어! 왜냐하면 react는 커스텀훅을 권장하니까! 라는 근거도 없는 논리로 사용하지 않았었는데 다시보니 선녀였다.

 

contextAPI로 큰 덩어리끼리 컴포넌트와 상태를 어느정도 분리시켰지만 찐 전역으로 사용하는 상태는 여전히 많았고 해당 상태들은 여전히 recoil로 다뤄야했다.

selector 는 리팩토링 중에 상태들의 분리와 응집도를 높여주는데 많은 도움을 줬다.

이번엔 조금 바뀐 예제다

export const itemState = atom({ // 과일이 3개 있는 배열이다
  key: "itemState",
  default: [
    {
      id: 1,
      name: "사과",
      price: 1000,
    },
    {
      id: 2,
      name: "바나나",
      price: 2000,
    },
    {
      id: 3,
      name: "물렁한 복숭아",
      price: 3000,
    },
  ],
});

export const filterState = atom({ // filter 기능으로 사용할 상태
  key: "filterState",
  default: "all",
});


// selector는 다른 상태를 가져와 가공할 수 있게하는 순수함수(view만 제공)
export const filteredItemState = selector({ 
  key: "filteredItemState",
  get: ({ get }) => {
    const items = get(itemState); // 과일 상태 get
    const filter = get(filterState); // filter 상태 get

    switch (filter) { // filter 상태에 따라 과일 상태 가공
      case "cheap":
        return items.filter((item) => item.price <= 1000);
      case "normal":
        return items.filter((item) => item.price > 1000 && item.price <= 2000);
      case "expensive":
        return items.filter((item) => item.price > 2000);
      default:
        return items;
    }
  },
});
const Test = () => {
  // 원본 itemState 가 아닌 가공된 filteredItemState
  const items = useRecoilValue(filteredItemState);

  return (
    <div>
      {items?.map(item =>
        <ul key={item.id}>
          <li>상품명: {item.name}</li>
          <li>가격: {item.price}</li>
        </ul>
      	)
      }
    </div>
  );
}

const Test3 = () => {
  const setFilter = useSetRecoilState(filterState);
  const onChangeFilter = (e) => {
    const value = e.target.value;
    setFilter(value);
  }

  return (
    <div>
      <select defaultValue={"all"} onChange={onChangeFilter}>
        <option value={"all"}>all</option>
        <option value={"cheap"}>cheap</option>
        <option value={"normal"}>normal</option>
        <option value={"expensive"}>expensive</option>
      </select>
    </div>
  )
}

selector로 관련 state와 로직을 분리하고 모아뒀다 (유지보수 올라감)

 

원래는 Test 컴포넌트 안에 있어야할 필터 로직들이 분리되었고 상태도 관련있는 상태끼리 모아서 관리하는 이점이있다.

아래는 필터 로직이 컴포넌트에 있는 코드

const Test = () => {
  const items = useRecoilValue(itemState); // 원본 과일 상태 get
  const filter = useRecoilValue(filterState); // filter 상태 get

  // 필터링 로직
  const filteredItems = items.filter(item => {
    switch (filter) {
      case "cheap":
        return items.filter((item) => item.price <= 1000);
      case "normal":
        return items.filter((item) => item.price > 1000 && item.price <= 2000);
      case "expensive":
        return items.filter((item) => item.price > 2000);
      default:
        return items;
    }
  });

  return (
    <div>
      {filteredItems.map(item => (
        <ul key={item.id}>
          <li>상품명: {item.name}</li>
          <li>가격: {item.price}</li>
        </ul>
      ))}
    </div>
  );
}

 

 recoil 함께해서 즐거웠고 다신 보지말자~ 

 

뭐 여차저차 리팩토링에 도움은 많이 되었는데 recoil이 알아볼수록 관련 괴담이 많았다.

일단 selectorFamily, family는 selector나 atom 사용 시에 동적으로 파라미터를 전달해 상태를 사용할 수 있게하는 기능이다. 아래처럼 사용하면 된다.

const itemState = atom({ // 기존의 과일 상태
  key: 'itemState',
  default: [
    { id: 1, name: '사과', price: 1000 },
    { id: 2, name: '바나나', price: 2000 },
    { id: 3, name: '복숭아', price: 3000 },
  ],
});

// Family Selector 정의
const itemByIdState = selectorFamily({
  key: 'itemByIdState',
  get: (id) => ({ get }) => { // id 값에 따라 해당 값만 return
    const items = get(itemState);
    return items.find(item => item.id === id);
  },
});

// 컴포넌트에서 사용
function ItemDetail({ itemId }) {
  const item = useRecoilValue(itemByIdState(itemId));
  return (
    <div>
      <h3>상품명: {item?.name}</h3>
      <p>가격: {item?.price} 원</p>
    </div>
  );
}

 

But, selector 나 family 나 기본적으로 캐싱을 해주는데 매개변수가 변경되어 기존 값을 사용하지 않아도 가비지 컬렉터에 제대로 수집되지 않아 해당 데이터가 메모리에 남아있어 메모리 누수가 발생한다.

아직도 열려있는 21년도 gitissue..

현재 기준 최신 버전 업그레이드도 1년전이고 무엇보다 활발한 커뮤니티가 있는 유사한 atom 개념인 jotai 도 있고 곰돌이 zustand도 있고 앞으로는 recoil을 프로젝트에 적용하는 일은 없을 것 같다.

 

그래도 찍먹해봤으니 만족~

 

참고 글

recoil Git_issue

Recoil, 이제는 떠나 보낼 시간이다

https://tech.osci.kr/recoil-selector/

+ Recent posts