테스트 코드 작성하시나요?

 

저는 얻는 효용에 비해 들어가는 비용이 더 크다고 생각해 커버리지라고 할 만큼 작성하지는 않습니다. 그래도 코드를 작성하면서 '아 이거는 작성하는게 더 이득이다' 싶었던 일부 경우를 공유하려 합니다.

 

1. 리팩토링을 위한 통합 테스트

간단한 투두리스트로 예시를 들어보겠습니다.

export default function App() {
  const [todos, setTodos] = useState([])
  const [input, setInput] = useState('')

  const addTodo = (todo) => {
    setTodos([...todos, todo]);
  }

  const toggleTodo = (id) => {
    setTodos(todos.map(todo => 
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    ))
  }
  
  const onChange = (e) => {
    setInput(e.target.value)
  }

  return (
    todos 렌더링 UI 
    toggleTodo 버튼 UI
    todo 추가하는 input UI
    todo 추가하는 버튼 UI
  )

 

 

 

App 컴포넌트에 대한 통합테스트를 작성해줍니다

describe('App 통합테스트', () => {
  it('전체 투두리스트 플로우가 정상 작동한다', async () => {
    const user = userEvent.setup()
    render(<App />)

    // 1. 초기 상태 확인
    expect(screen.getByText('투두리스트')).toBeInTheDocument()

    // 2. 할일 추가
    const input = screen.getByPlaceholderText('새 할일 입력')
    await user.type(input, '첫 번째 할일')
    await user.click(screen.getByText('추가'))

    expect(screen.getByText('첫 번째 할일')).toBeInTheDocument()
    expect(screen.getByText('전체: 1, 완료: 0')).toBeInTheDocument()

    // 3. 할일 완료 처리
    const checkboxes = screen.getAllByRole('checkbox')
    await user.click(checkboxes[0])

    expect(screen.getByText('전체: 2, 완료: 1')).toBeInTheDocument()
  })

  ~~~~ 기타 테스트 코드
})

 

 

App 컴포넌트는 잘 작성되었고 테스트 코드를 통해 모든 기능들이 정상 동작하는지도 확인했습니다🎉

그런데 App 컴포넌트가 너무 많은 기능을 하는 것 같습니다.

코드를 리팩토링 해봅니다.

export default function App() {
	return (
    	<>
            <Todos />
            <TodoInput />
            <AddTodoButton />
        </>
    )
}

export default function Todos() {
  const [todos, setTodos] = useState([])

  const toggleTodo = (id) => {
    setTodos(todos.map(todo => 
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    ))
  }

  return (
    todos 렌더링 UI
    toggleTodo 버튼 UI
  )
}
  
export default function TodoInput() {
  const [input, setInput] = useState('')

  const onChange = (e) => {
    setInput(e.target.value)
  }

  return (
    todo 추가하는 input UI
  )
}

export default function AddTodoButton() {
  const addTodo = (todo) => {
    setTodos([...todos, todo]);
  }

  return (
    todo 추가하는 버튼 UI
  )
}

 

리팩토링 후에는 기존에 동작했던 기능들이 동일하게 동작하는지 테스트를 하는 번거로운 작업을 진행해야됩니다.

하지만 저희는 App 컴포넌트에 대해 테스트 코드를 미리 작성해놨습니다. 테스트를 돌리니 모든 테스트코드가 통과했습니다!

덕분에 각 기능들이 정상적으로 동작하는지 별도로 확인할 필요 없이 테스트 코드를 통해 검증이 완료되었습니다 🎉

 

2. 에러 시나리오 테스트

API 엔드 포인트에 따라 적절한 UI처리를 해주어야 하는데, 에러를 내달라고 백엔드에 요청하기는 번거롭습니다

그럴 때 MSW로 의도적으로 에러를 발생시키고 적절한 UI가 나오는지 테스트하기 좋습니다.

 

유저정보를 가져오는 API를 패칭합니다.

export default function UserList() {
  const [users, setUsers] = useState([])
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)

  useEffect(() => {
    fetch('/api/users')
      .then(res => res.json())
      .then(data => {
        setUsers(data)
        setLoading(false)
      })
      .catch(err => {
        setError(err.message)
        setLoading(false)
      })
  }, [])

  if (loading) return <div>Loading...</div>
  if (error) return <div>Error: {error}</div>

  return (
    <div>
      <h1>Users</h1>
      <ul>
        {users.map(user => (
          <li key={user.id}>{user.name} - {user.email}</li>
        ))}
      </ul>
    </div>
  )
}

 

API 호출을 모킹해 일부러 에러를 발생하고 적절한 UI가 나오는지 테스트 해보겠습니다.

import { server } from '../mocks/server'

it('API 에러 시 에러 메시지를 표시한다', async () => {
    server.use(
      http.get('/api/users', () => {
        return new HttpResponse(null, { status: 500 })
      })
    )

    render(<UserList />)

    await waitFor(() => {
      expect(screen.getByText(/Error:/)).toBeInTheDocument()
    })
})

 

백엔드에게 일부러 에러를 내달라고 요청하지 않고도 에러 발생 시 적절한 UI 가 나오는지 테스트 할 수 있게되었습니다🎉

(비슷한 느낌으로 delay를 걸어서 api 요청이 늦게오는 테스트를 진행할 수도 있습니다.)

 

3. 요구사항이 복잡한 비즈니스 함수의 단위 테스트

 

계산이 복잡한 함수의 경우에 테스트 코드를 작성하면 코드 안정성과 신뢰성 그리고 자신감 까지 올라갑니다🙎‍♂️

저의 경우엔 mongoDB Aggregation 파이프라인에 대한 검증에 테스트 코드를 작성했습니다.

 

Aggregation 메서드는 $match, $group, $project, $unwind, $sort, $limit 등이 있습니다.

SQL 구문과 비교해보면 아래와 같습니다.

$match WHERE 조건에 따라 필터링
$group GROUP BY 조건에 따라 그룹화하고 집계 함수를 적용
$project SELECT 특정 필드만 선택
$unwind UNNEST 배열 요소 평면화 (flatMap 기능)
$sort ORDER BY 결과를 정렬
$lookup JOIN 컬렉션 간의 관계형 데이터를 연결

 

 

$match: 50만원 초과 제품 필터

$project: name, price 필드만 선택 

 const products = [
        {name: '노트북', price: 1200000, manufacturer: '삼성'},
        {name: '스마트워치', price: 350000, manufacturer: '삼성'},
        {name: '스마트폰', price: 980000, manufacturer: '애플'},
        {name: '무선이어폰', price: 250000, manufacturer: '애플'},
    ];
 
 test('$match: 조건에 맞는 문서 필터링', async () => {
    const expensiveProducts = await productsCollection.aggregate([
      { $match: { price: { $gt: 500000 } } },
      { $project: { _id: 0, name: 1, price: 1 } }
    ]).toArray();
    
    // 검증
    expect(expensiveProducts).toEqual([{"name": "노트북", "price": 1200000}, {"name": "스마트폰", "price": 980000}]);
  });

 

간단한 함수에는 테스트 코드까지 작성할 필요를 못느꼈지만, 복잡한 집계 파이프라인을 작성하니 코드에 검증이 필요했습니다.

아래 예시를 보겠습니다.

test('복합 집계 파이프라인: 카테고리별 평균 평점', async () => {
    // 카테고리별 평균 평점 계산
    const categoryRatings = await productsCollection.aggregate([
        { $unwind: '$reviews' },
        {
            $group: {
                _id: {
                    category: '$category',
                    product: '$name'
                },
                avgRating: { $avg: '$reviews.rating' },
                reviewCount: { $sum: 1 }
            }
        },
        {
            $group: {
                _id: '$_id.category',
                avgCategoryRating: { $avg: '$avgRating' },
                products: {
                    $push: {
                        name: '$_id.product',
                        rating: '$avgRating',
                        reviews: '$reviewCount'
                    }
                }
            }
        },
        { $sort: { avgCategoryRating: -1 } }
    ]).toArray();

    // 검증
    expect(categoryRatings[0]).toHaveProperty('_id');
    expect(categoryRatings[0]).toHaveProperty('avgCategoryRating');
    expect(categoryRatings[0]).toHaveProperty('products');
    expect(categoryRatings[0].products.length).toBeGreaterThan(0);
});

 

파이프라인이 조금 복잡해지니 검증 필요성이 느껴저 테스트코드를 작성해서 문제를 해결했습니다.

 

서두에 말씀드렸듯이 저도 테스트코드를 많이 작성하지는 않습니다. 기능 쳐내기 바쁜데 테스트 코드는 사치 아닌가 싶은 생각도 많이듭니다.. 그래도 위의 경우엔 꽤 효용이 있다고 생각해 글로 공유해봤습니다😊 그리고 테스트 코드를 작성하면서 뭔가 코드를 입체적으로 보는 시각이 조금 길러지는 장점도 있는 것 같습니다 (Typescript를 배울때와 유사한 느낌적인 느낌입니다..)

 

이번 프로젝트는 꽤나 재밌었습니다. 도메인도 좋고 IoT 기기와 연동하는 웹사이트는 처음이라 스마트팜과 여러 프로토콜을 열심히 학습하면서 개발했던 기억이나네요. 기능 개발도 좋았지만 시스템을 어떻게 구축할지 고민했던 것이 흥미로워 재밌게 개발했습니다. 

당시 어떤 환경을 구축했었는지, 어떤 고민을 했었는지 정리해서 공유해보려고 합니다.

 

1. 구성 요소

1) IoT 기기 : 식물재배기입니다. 카메라와 환경센서가 부착되어있습니다.

2) FTP 서버: IoT 기기로부터 이미지 파일을 수신하고 저장합니다.

3) Python 스크립트 : 특정 폴더의 변경사항을 감지하고 추론 알고리즘으로 이미지를 분석해 데이터베이스에 저장합니다.

4) Node.js 웹 서버 : 데이터베이스의 변경사항을 감지하고 SSE를 통해 클라이언트에 알림

 

2. 데이터 흐름

1)  IoT 기기가 이미지를 캡처하고  <DeviceId-날짜> 형식의 파일명으로 FTP 서버에 전송합니다.

2) Python 스크립트로 폴더 변경을 감지하여 전송된 이미지를 추론하여 데이터를 추출합니다.

3) 추출된 데이터를 데이터베이스에 저장합니다.

4) 파일명을 바탕으로 파일을 이동시켜줍니다. (폴더명: DeviceId/날짜/example.jpg)

5) 데이터베이스 업데이트 발생 시 웹 서버가 SSE를 통해 클라이언트에 알립니다.

6) 웹 클라이언트가 실시간으로 업데이트된 정보를 표시합니다.

 

3. 구현사항

1) FTP 서버인 FileZilla 서버 연결 설정

IoT 기기에 설정한 FTP 설정, 호스트 / 사용자명 / 비밀번호 / 포트를 일치시킵니다

 - 호스트 : example.com

 - 사용자명: user

 - 비밀번호: root

 - 포트: 21 (기본 FTP 포트)

 

2) 자동화 설정 

FileZilla 인터페이스를 통해 FTP 서버에서 파일을 자동으로 다운로드 합니다.

filezilla -c "open ftp://user:root@example.com; cd /uploads; lcd C:/local/image/folder; get *.jpg; close; exit"

 

 

3) Python 스크립트 (파일 감지 및 추론 알고리즘 실행)

Python 스크립트의 추론 알고리즘을 만드는 부분은 외부 기관과 협력했습니다. 식물의 생육 데이터를 구축하고 적절한 모델을 정의하여 학습시키고 현재 프로젝트에 알맞은 모델을 만드는 과정이 흥미로웠습니다. 

 

약간의 파라미터 변화로 결과물이 완전히 달라지는 모습에 뜬금없이 영화 나비효과가 생각나기도 했습니다.

(아직도 어린 에반이 칼 들고 서있는 장면보면 놀랍니다, 개인적으로 감독판 엔딩은 너무하다 생각해요..)

 

처음엔 Python 스크립트에 따른 결과물을 DB에 별도로 저장하고 웹 서버에서 DB 내용을 한번 가공해서 다른 collection 에 저장하도록 구현했는데 프로젝트가 수정되면서 추론 데이터를 사용하는 일이 없어졌습니다. 파일명을 기준으로 파일을 이동시키는 기능도 웹서버에서 진행하다가 해당 과정은 Python 스크립트에게 맡겨 중간 과정을 하나 생략했습니다.

 

큰 차이는 아니라고 생각했는데 중간 단계를 하나 줄이니 소요 시간이 3s -> 2.5s로 단축됐습니다. 

 

 

4) 웹 서버 설정 (Node.js)

웹 서버에서는 다음과 같은 기능을 합니다.

- MongoDB 감지

- MongoDB 업데이트 시 SSE를 통해 클라이언트에 알림

- 저장된 이미지 파일 제공 (아래 예시에선 다루지 않음)

 

4.1)(최적화 x)

let clients = []; // 연결된 SSE 클라이언트 리스트

// SSE 엔드포인트
app.get('/events', (req, res) => {
  // SSE 헤더 설정
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');

  res.write(`data: ${JSON.stringify({ message: 'Connected to event stream' })}\n\n`);

  clients.push(res); // 클라이언트 연결 추가

  // 연결 종료 시 clients 리스트에서 제거
  req.on('close', () => {
    clients = clients.filter((clientRes) => clientRes !== res);
  });
});

// MongoDB Change Streams
async function startChangeStream() {
  const changeStream = collection.watch();

  changeStream.on('change', (change) => {
    if (change.operationType === 'update') {
      // 연결된 client 모두에게 이벤트 전송
      clients.forEach((res) => res.write(`data: rendering\n\n`));
    }
  });
}

 

시연을 목표로 프로젝트를 진행하니 유저가 많은 경우를 생각하지 못했습니다.

DB가 업데이트되면 모든 클라이언트에게 알림을 준다는 걸 나중에야 알게되었습니다🫢 

 

4.2) (최적화 o)

클라이언트를 Id 별로 접속하고 서버는 해당 userId 에 대한 데이터만 전송하게 변경했습니다.

// 유저별 연결 저장
const clients = new Map();

// SSE 연결 엔드포인트
app.get('/events/:userId', (req, res) => {
  const userId = req.params.userId;

  // 연결 저장
  clients.set(userId, res);

  // 연결 종료 처리
  req.on('close', () => {
    clients.delete(userId);
  });
});

// MongoDB Change Streams 설정
async function watchMongoChanges() {
  const changeStream = collection.watch();

  changeStream.on('change', (change) => {
    const userId = change.fullDocument?.userId; // event에 해당하는 user 추출
    if (!userId) return;

    const userClient = clients.get(userId);
    if (userClient) {
      userClient.write('data: rendering\n\n');
    }
  });
}

 

 

5) 클라이언트에서 SSE 설정 (Next.js)

// 클라이언트 측 SSE 연결 설정
const eventSource = new EventSource('/events');

// 이벤트 수신 시 화면 업데이트
eventSource.onmessage = (event) => {
  const data = JSON.parse(event.data);
  if (data === 'rendering') {
    // UI 업데이트 로직
    updateUI(data);
  }
};

eventSource.onerror = (error) => {
  console.error('SSE connection error:', error);
  eventSource.close();
};

 

 

4. FTP, MQTT, SSE 프로토콜 선택 이유

 FTP: 이미지 파일을 전송할 때 먼저 HTTP를 생각했습니다. 그런데 사용하기로 했던 IoT 기기가 비용 이슈로 다운그레이드 되면서 성능 이슈가 있었고 HTTP는 기본적으로 Header에 데이터가 많이 들어가기 때문에 보다 오버헤드가 적고 안정적인 FTP를 사용했습니다. MQTT는 경량화된 메시징 전송에 특화된 프로토콜로 대용량 파일 전송에는 적합하지 않았습니다.

 

MQTT: FTP와 동일한 이슈로 HTTP는 사용하지 않았습니다.

 

SSE: WebSocket을 사용하려고 했는데 서버 pc가 이미 하는 일이 많다고 생각했고 특히, 양방향이 아닌 단방향 통신이 필요해 서버 리소스도 덜 사용하는 SSE를 사용했습니다.

 

5. 후기

위 과정을 통해 IoT 에서 이미지를 취득 -> 추출한 데이터를 클라이언트에게 실시간으로 전달하는 환경을 구축할 수 있었습니다.

환경데이터를 주고받은 내용은 MQTT 프로토콜을 이용했는데 내용을 추가하고 싶었지만 본문 구성이 많이 달라져서 제외했습니다

(이럴때마다 글 잘쓰는 분들이 부럽네요..)

 

FTP, MQTT, SSE 프로토콜을 다뤄보면서 네트워크 통신의 전반적인 흐름을 이해할 수 있었습니다. 그리고 생각보다 재밌어서 TCP/IP 랑 기본적인 네트워크도 같이 스터디했습니다. 아주 좋은 현상이네요 

 

AI가 점점 세상을 가속화하고 있는걸 체감하는 요즈음입니다. 그런데 AI 도구를 열심히 이용하고 있는것과 별개로 저는 책에 손이 가네요. 인스타 릴스, 유튜브 쇼츠 등 대기업에서 점점 짧고 강력한 유혹으로 시선을 뺐어가는데, 제 나름대로 도파민을 컨트롤할 방법을 찾다 보니 책 만한게 없더라구요.

김영하 작가님을 아시나요? 저는 검은꽃을 시작으로 작가님 책을 찾아보기 시작했는데 최근 읽은 빛의 제국은 나름 생각할 화두를 던져줬습니다. 주인공은 북에서 온 간첩이지만 인생의 절반을 평범한 한국인인척 연기합니다.

그 모습을 보며 나도 누군가에게 연기를 하며 지내고 있구나 싶었어요. 적당한 회사 동료들에게는 사무적인 모습으로, 교회 아이들에게는 상냥한 선생님의 모습으로, 부모님에게는 그래도 노력하는 아들로.

 

다들 저마다의 방법으로 적당히 연기를 하며 지내고 계시나요? 저는 뭐가됐든 제 모습이 다른 사람에게 긍정적인 영향을 줄수 있으면 좋겠네요.

'On-Premise' 카테고리의 다른 글

On-Premise 환경에 React, Express 프로젝트 배포하기  (0) 2025.04.30

규모가 작거나 개인 프로젝트를 진행하면 MongoDB를 자주 사용하게 되는것 같습니다. 아무래도 모델링을 스킵해도 되고 확장성도 좋아서 손이 가는것 같습니다. 처음엔 SQL구문과 다른 특유의 메서드가 번거로웠는데 나름 익숙해지니 오히려 편하네요 (Tailwind에 익숙해지기 시작했을때와 비슷한 느낌입니다)

 

최근 대용량 데이터를 MongoDB로 다루면서 성능 이슈가 발생했고 이를 해결했던 방법을 소개해보려고 합니다.

공식문서에 있는 최적화방법을 참고했습니다.

MongoDB Aggregation Pipeline

 

Aggregation 파이프라인은 Document들을 여러 단계의 처리 과정을 거쳐 원하는 결과를 도출해내는 데이터 처리 파이프라인입니다. 이전 단계의 출력이 다음 단계의 입력으로 전달됩니다.

단순한 쿼리 (find)로는 처리하기 어려운 복잡한 데이터의 집계, 변환, 분석 작업을 수행할 수 있습니다.

 

1. 기본 예시

아래 기본 예시를 보겠습니다.

orders collection에서 $match로 필터 후 _id로 그루핑하여 값을 반환합니다.

db.orders.aggregate( [
   { $match: { size: "medium" } }, // size가 "medium" 인 document 필터
   { $group: { _id: "$name" } } 
   // _id로 그루핑하여 document들을 반환
] )

 

각각의 파이프라인마다 단계적으로 처리하는 것이 특징입니다.

마치 방식이 JavaScript 고차함수를 사용하는것 같습니다.

const result = orders
  .filter(order => order.size === "medium") // $match와 동일한 기능
  .reduce((groups, order) => {
    // $group과 동일한 기능
    const key = order.name;
    if (!groups[key]) {
      groups[key] = { _id: key };
    }
    return groups;
  }, {});

const finalResult = Object.values(result);

 

2. 주요 연산자

$match, $project, $group, $sort, $unwind 등이 있습니다.

$match는 조건에 맞는 문서를 필터링을 하고, $group은 표현식을 기준으로 그룹화합니다. 

$project는 특정 필드만 포함하여 값을 반환하고 $sort는 정렬 후 값을 반환합니다. 

 

SQL구문과 비교하면 아래와 같습니다.

$match -> WHERE

$group -> GROUP BY

$project -> SELECT

$sort -> ORDER BY

 

아래는 $project와 $sort 예시입니다.

db.orders.aggregate([
  {
    $project: {
      _id: 1,
      price: 1,
      size: 0 // size 필드는 제외
    }
  }
])

// 결과
[
    { "_id": "Bob", "price": 3000 },
    { "_id": "Alice", "price": 2000 },
    { "_id": "Alice", "price": 1000 }
]

 

db.orders.aggregate([
  {
    $project: {
      _id: 1,
      price: 1,
      size: 0
    }
  },
  { $sort: { price: 1 } } // price 기준으로 오름차순
])

// 결과
[
    { "_id": "Alice", "price": 1000 },
    { "_id": "Alice", "price": 2000 },
    { "_id": "Bob", "price": 3000 }
]

 

 

$unwind 는 배열 필드를 풀어서 각 배열 요소마다 별도의 문서를 생성하는 연산자입니다. 자바스크립트의 normalize(정규화) 메서드와 유사합니다.

 

아래는 $unwind 사용 예시입니다

db.products.insertMany([
  {
    _id: 1,
    name: "티셔츠",
    sizes: ["S", "M", "L", "XL"]
  },
  {
    _id: 2,
    name: "청바지",
    sizes: ["30", "32", "34"]
  }
]);

db.products.aggregate([
  { $unwind: "$sizes" } // size로 정규화한다
]);

// 결과
[
    { "_id" : 1, "name" : "티셔츠", "sizes" : "S" }
    { "_id" : 1, "name" : "티셔츠", "sizes" : "M" }
    { "_id" : 1, "name" : "티셔츠", "sizes" : "L" }
    { "_id" : 1, "name" : "티셔츠", "sizes" : "XL" }
    { "_id" : 2, "name" : "청바지", "sizes" : "30" }
    { "_id" : 2, "name" : "청바지", "sizes" : "32" }
    { "_id" : 2, "name" : "청바지", "sizes" : "34" }
]

 

 

이번엔 $unwind를 사용한 응용 예제입니다.

db.orders.insertMany([
  {
    _id: 1,
    customer: "김철수",
    items: [
      { product: "노트북", price: 1200000, quantity: 1 },
      { product: "마우스", price: 35000, quantity: 2 }
    ]
  },
  {
    _id: 2,
    customer: "이영희",
    items: [
      { product: "키보드", price: 89000, quantity: 1 },
      { product: "모니터", price: 350000, quantity: 1 },
      { product: "마우스", price: 35000, quantity: 1 }
    ]
  }
]);

db.orders.aggregate([
  { $unwind: "$items" }, // items로 정규화
  
  // 제품별로 그룹화
  { $group: {
    _id: "$items.product",
    totalQuantity: { $sum: "$items.quantity" },
    totalPrice: { $sum: { $multiply: ["$items.price", "$items.quantity"] } }
    // $multiply 는 배열 값을 곱합니다
  }},
  
  // 결과 정렬
  { $sort: { totalPrice: -1 } }
]);

// 결과
[
    { "_id" : "노트북", "totalQuantity" : 1, "totalPrice" : 1200000 },
    { "_id" : "모니터", "totalQuantity" : 1, "totalPrice" : 350000 },
    { "_id" : "키보드", "totalQuantity" : 1, "totalPrice" : 89000 },
    { "_id" : "마우스", "totalQuantity" : 3, "totalPrice" : 105000 }
]

 

3. 파이프라인 최적화

공식문서에는 파이프라인 최적화로 아래 방법을 소개하고 있습니다.

1) 필터링 우선 적용

  • $match를 파이프라인 앞부분에 배치하여 처리할 문서 수를 조기에 줄입니다.

2) 프로젝션 우선 적용

  • $project를 사용하여 필요한 필드만 선택하여 메모리 사용량을 줄입니다.

3) 인덱스 활용

  • $match, $sort 스테이지에서 인덱스 필드를 활용합니다.
  • 인덱스가 없는 경우 MongoDB는 컬렉션의 모든 문서를 스캔하여 쿼리 결과를 반환해야 합니다. 동일한 필드에서 반복적으로 쿼리를 실행하는 경우 해당 필드에 인덱스를 생상하여 성능을 개선할 수 있습니다.

4) 연산 최적화

  • $group 전 데이터 필터링을 통해 처리할 문서 수 줄이기

5) 불필요한 $unwind 피하기

  • 문서 수를 급격히 증가시킬 수 있는 $unwind를 지양

 

아래는 3) 인덱스 활용 예시입니다. 

db.users.insertMany([
  { name: "김철수", age: 25, city: "서울"},
  { name: "이영희", age: 30, city: "부산"},
  { name: "박지민", age: 28, city: "서울"},
]);

// age 필드에 인덱스 생성
db.users.createIndex({ age: 1 });

db.users.aggregate([
  // age 필드에 인덱스가 있으므로 빠르게 필터링됩니다
  { $match: { age: { $gt: 25 } } }
]);

 

쿼리 패턴을 고려해 복합 인덱스를 활용할 수 있습니다.

// 자주 사용되는 쿼리 패턴: city로 필터링 후 age로 정렬
db.users.createIndex({ city: 1, age: 1 });

db.users.aggregate([
  // city와 age 복합 인덱스 활용
  { $match: { city: "서울" } },
  { $sort: { age: 1 } }
]);

 

위 과정을 통해 최적화를 진행하기 전 후로 브라우저 랜더링 속도가 약 70% 감소했습니다.

pc 성능이 좋아져도 데이터가 복잡해지고 계산이 많아지면 체감할 만큼 성능이 안좋아지네요

 

요새는 간단한 코드는 GPT가 모두 작성해주니 공식문서를 읽고 고급기능을 잘 활용하는 능력이 더 필요해지는 것 같습니다.

얼마전에 에디터를 Vscode 에서 Windsurf 로 갈아탔는데 '딸깍'으로 할 수 있는일이 점점 많아져서 놀랐습니다.

곧 다가올 Ai 해일에 휩쓸리지 않으려면 미리 서핑하는법을 배워둬야겠다 싶은 요즈음입니다.🏄‍♂️

1. 들어가며

클라우드 환경이 대세인건 맞지만 보안이나 여러가지 이유로 On-premise 환경에서 애플리케이션을 운영하는 경우도 많습니다.

저의 경우 회사에 유휴 pc가 있어 On-premise에 배포하게 되었고 그 과정을 정리해보려합니다.

 

2. 환경 구성

기본 환경을 정리하면 다음과 같습니다.

OS(윈도우)

Node.js

PM2

Nginx

SSL 인증서

방화벽

포트포워딩

 

3. 사전준비

1) 환경 설정 

  • Node.js 설치 : 공식 웹사이트 다운로드
  • PM2 설치 : npm install -g pm2
  • Nginx 설치 : 공식 웹사이트 다운로

2) SSL 인증서 구입 (가비아 발급)

무료인 Let's Encrypt도 고려해봤으나 다음의 문제로 가비아로 결정했습니다

  • 인증서 문제 발생에 따른 보증이 확실하다
  • Let's Encrypt는 90일마다 갱신해야하는 번거로움이 있다.
  • 도메인을 가비아에서 구매해서 SSL 연동하기가 더 간편했다.

 

4. 배포 과정

1) PC에 React, Express 프로젝트 설치

  • git clone
  • npm install

2) React 빌드

  • npm run build

3) Nginx 설정

server {
    listen 80;
    server_name your-domain.com www.your-domain.com;
    
    # HTTP를 HTTPS로 리다이렉트
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    server_name your-domain.com www.your-domain.com;
    
    # SSL 인증서 설정
    ssl_certificate /etc/ssl/certs/your-domain/fullchain.crt;
    ssl_certificate_key /etc/ssl/private/your-domain.key;
    
    # SSL 프로토콜 설정
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers on;
    ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-SHA384;
    
    # SSL 세션 캐시 설정
    ssl_session_timeout 1d;
    ssl_session_cache shared:SSL:10m;
    ssl_session_tickets off;
    
    # HSTS 설정 (선택사항)
    add_header Strict-Transport-Security "max-age=63072000" always;
    
    # React 앱 서빙 (정적 파일)
    location / {
        root /path/to/your-react-app/build;
        index index.html;
        try_files $uri $uri/ /index.html;
    }
    
    # Express API 요청 프록시
    location /api {
        proxy_pass http://localhost:5000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
    }
}

 

4) Nginx 재시작

  • nginx -s reload

 

5) PM2 시작

  • pm2 start server.js --name my-server

6) 방화벽에서 Nginx 포트 열기

 

  • 제어판 → 시스템 및 보안 → Windows Defender 방화벽 → 고급 설정 클릭
  • 왼쪽 메뉴에서 인바운드 규칙 클릭
  • 오른쪽에서 새 규칙(New Rule) 선택
  • 포트(Port) 선택 후 다음(Next)
  • TCP, 특정 로컬 포트: 80, 443 입력
  • 연결 허용(Allow the connection) 선택
  • 규칙이 적용될 네트워크(도메인, 개인, 공용) 선택
  • 규칙 이름: 예) Nginx HTTP/HTTPS

 

7) 포트포워딩

포트포워딩으로 라우팅을 해줘야 외부에서 On-premise 환경에 배포한 프로젝트에 접근할 수 있습니다.

ipconfig // # 내부 ip 확인 (ex: 192.168.0.10)

 

  • 공유기 / 라우터 관리자 페이지 접속
  • 포트포워딩 설정 메뉴에서 아래 내용 설정
더보기

- 외부포트 80(HTTP), 443(HTTPS)

- 내부 IP (192.168.0.10)

- 내부 포트 80

- 프로토콜 TCP

 

5. 유지보수

1) 로그 관리

로그는 PM2 자체적인 로그관리 시스템을 활용했습니다

# 로그 위치 확인
pm2 logs       # 실시간 로그 보기
pm2 logs my-server  # 특정 앱 로그

# 로그 파일 경로
~/.pm2/logs/my-server-out.log      # 일반 로그
~/.pm2/logs/my-server-error.log    # 에러 로그

 

2) 자동 재시작

OS가 윈도우인 PC다보니 한번은 윈도우 업데이트로 서버가 내려간적이 있었습니다

다행히 테스트 단계인 프로젝트여서 큰 문제는 없었지만 실제 상용화 중인 프로젝트에서 서버가 내려갔다고 생각하니까 문제가 심각했습니다

PC의 윈도우 업데이트를 수동으로 관리하게 변경하였고 불가피하게 PC가 재시작되어도 PM2가 자동 재시작되게 설정해줬습니다

pm2 start server.js --name my-server # 서버 실행 상태에서
pm2 save                      # 현재 상태 저장
pm2 startup                   # 부팅 시 자동 시작 등록

 

6. 마치며

AWS, Vercel, FileZilla, Heroku, Firebase 등등 많은 클라우드에 배포해봤지만 On-Premise 배포는 안해봤는데 하나하나 환경 설정해주는 맛이 있었습니다. 특히 환경설정 명령어가 리눅스 명령어가 대부분이라 순순히 설치되어주지 않더군요..

Nginx도 꽤 흥미로웠습니다. 그동안 정적 파일 서빙만 한다고 생각했는데 리버스 프록시와 SSL 설정을 하면서 이것저것 시도해볼 수 있었습니다. Next.js 프로젝트도 배포해봤는데 React랑은 config 파일 작성이 많이 달라 삽질을 많이 한 기억이 나네요.

개발에서 서버/인프라 중요성이 큰 만큼 다뤄볼 수 있는 기회가 왔을 때 해보는건 참 좋은 것 같습니다.

그래도 아직은 프론트엔드 개발에 집중하고 싶네요..😊

 

1990년대 에 만들어진 디자인 패턴
디자인 패턴에 대한 오해

 

5주차는 그동안 가졌던 편견이 많이 깨지는 시간이었습니다.

소프트웨어에는 다양한 디자인 패턴이 존재합니다. 싱글톤, 옵저버, MVC, MVVM 등등..

그런데 지금까지 실무에서 사용한적이 없고 필요가 없었으니 반쪽짜리 이해만 한 상태였습니다.

 

애초에 디자인패턴은 객체지향 언어가 주류였을 시절 여러 문제들을 해결하기 위해 나왔었고 

함수형 프로그래밍이 가능한 javascript 언어의 특성상 20년도 전에 만들어진 디자인 패턴들을 사용할 일이 많이 없었습니다.

 

게다가 React 가 많은 것들을 추상화 레이어로 한단계 가려주니 레이어 안쪽에서 여러 디자인 패턴들을 사용하는 것과 별개로

hooks와 커스텀hook 만으로 충분히 개발이 가능했습니다. 

 

그럼 전통적인 디자인패턴은 배울 필요가 없는건가?

 

직접 사용하지 않는 것과 별개로 우리가 사용하는 대부분의 라이브러리 내부에는 이러한 패턴들이 감추어져 있습니다. 

우리가 '딸깍'으로 만든 전역 상태의 깊은 곳에선 싱글톤 패턴과 옵저버 패턴의 조합이 있었습니다.

 

그리고 개발자간 커뮤니케이션을 할 때도 필요합니다.

디자인패턴을 알아야 기능의 설명이 아닌 개념의 전달이 된다고 생각합니다.

"하나의 스토어를 두고 해당 스토어를 구독을해서 구독을 하고있는 모든 곳에서 변경 사항을 감지하게 했어요" 를
=> "옵저버 패턴을 썼어요"  로 간단히 설명할 수 있습니다.

 

 

프론트엔드가 진짜 배워야 할 패턴들

 

1. 함수형 프로그래밍

함수형 프로그래밍은 순수 함수, 불변성, 선언적 프로그래밍 등의 개념을 중심으로 하는 프로그래밍 패러다임입니다. 이는 코드의 예측 가능성과 테스트 용이성을 높입니다.

 

2. 컴포넌트 기반 아키텍쳐

UI를 재사용 가능한 독립적인 부분들로 나누는 설계 방식으로, 현대 프론트엔드 프레임워크의 핵심 개념입니다.

 

3. 상태 관리 패턴

애플리케이션의 데이터 흐름을 관리하는 방식으로, Context Api, Redux, Zustand 등의 라이브러리가 이를 구현합니다.

 

4. 비동기 프로그래밍 패턴

Promise, async/await 등 비동기 작업을 효과적으로 다루는 패턴들입니다. 

 

 

함수형 프로그래밍과 개발의 상관관계

 

함수형 프로그래밍이 뭔데요? 그거 하면 개발 잘하나요? 

 

함수형 프로그래밍은 프로그램을 더 간단하고, 예측 가능하며, 테스트하기 쉽게 만드는 것을 목표로합니다.

위의 목표를 향해 조금 더 딥 다이브 해보겠습니다. 

 

1. 코드를 데이터 / 계산 / 액션으로 분류해야합니다.

const 유저데이터 = {
  name: "김항해",
  age: 7
};

const 계산함수 = (a, b) => {
  return a + b;

  // 동일한 input에 대해 동일한 output을 보장합니다 (*반드시 input과 output이 있습니다)
  // 외부 상태에 의존하지 않고, 실행 시점에 관계없이 결과가 동일합니다.
};

const [name, setName] = useState("김항해")
const 액션함수 = () => {
  setName('양금명');

  // 외부 세계와 상호작용 하는 함수입니다
  // side effect를 발생시키거나 외부 상태에 의존합니다
  // 실행 시점과 환경에 따라 결과가 달라질 수 있습니다
  // 예: 파일 읽기/쓰기, DB 조회/수정, setState 등 
}

 

데이터는 구분하기 쉽습니다. 하지만 계산함수와 액션함수는 그렇지 않은것 같습니다.

 

2. 계산을 분리해서 액션을 최대한 작게 만드는 것이 핵심입니다

예시를 통해 액션에서 계산을 분리해보겠습니다.

/// 예시 코드
var shopping_cart = [];
var shopping_cart_total = 0;

// 액션 함수
function add_item_to_cart(name, price) {
  // 외부 환경을 (전역 변수) 변경시키니 액션입니다
  shopping_cart.push({
    name: name,
    price: price,
  });
}

// 액션 함수
function calc_cart_total() {
  // 외부 환경을 (전역 변수) 변경시키니 액션입니다
  shopping_cart_total = 0;
  for (var i = 0; i < shopping_cart.length; i++) {
    var item = shopping_cart[i];
    shopping_cart_total += item.price;
  }

  // 외부 환경을 변경시키니 액션입니다
  set_cart_total_dom();
}

 

모든 함수에서 side effect가 발생해 테스트하기 어렵습니다.

계산함수를 최대한 분리하고 액션함수를 작게 만들어보겠습니다.

// 액션 함수 덩어리를 (계산 -> 액션) 으로 나누었습니다
function add_item_to_cart(cart, name, price) {
  const newCart = get_added_cart(cart, name, price);

  setCart(newCart); // 이 함수가 종착지라면 액션을 호출합니다.
}


// get_added_cart는 계산함수여서 테스트 가능합니다🎉
const get_added_cart = (cart, name, price) => {
  return [
    ...cart,
    {
      name: name,
      price: price,
    },
  ];
};

// 액션 함수 덩어리를 (계산 -> 액션) 으로 나누었습니다
function calc_cart_total(cart) {
  const shopping_cart_total = calc_total_price(cart);

  set_cart_total_dom(shopping_cart_total);
}

// calc_total_price는 계산함수여서 테스트 가능합니다🎉
function calc_total_price(cart) {
  return cart.reduce((val, item) => {
    val + item.price;
  }, 0);
}

 

get_added_cart 함수

1. cart를 인자로 받아 불변성을 유지해 외부환경에 영향을 주지 않습니다
2. 동일한 input에 동일한 output이 나옵니다

 

calc_total_price 함수
1. 명령형인 for문 대신 *고차 함수(선언적) reduce로 작성해 코드를 추상화합니다

2. 동일한 input에 동일한 output이 나옵니다
* 함수형 프로그래밍에선 고차 함수 (map, filter, reduce 등)를 사용하는 방식이 선호됩니다.

 

 

함수형 프로그래밍을 적용하니 테스트 가능한 계산함수가 많아졌고 그만큼 액션함수가 작아졌습니다.

side effect가 적어져 보다 안정적인 환경에서 프로그래밍을 할 수 있게 되었습니다🥳

 

  

이번엔 함수형 프로그래밍을 리액트에 적용해보겠습니다.

장바구니에 관련된 컴포넌트입니다. 클릭한 상품을 카트에 넣어주는 함수가 있네요.

export const CartPage = () => {
    const [cart, setCart] = useState([]);
    
    const addToCart = (product) => {
    	setCart(prevCart => {
          const existingItem = prevCart.find(item => item.product.id === product.id);
          if (existingItem) {
            return prevCart.map(item =>
              item.product.id === product.id
                ? { ...item, quantity: Math.min(item.quantity + 1, product.stock) }
                : item
            );
          }
          return [...prevCart, { product, quantity: 1 }];
        });
    }

	return (
    	<div>
        	<button onClick={() => addToCart(product)}></button>
            
            ... 나머지 코드
        </div>
    )
}

 

 

setCart 가 외부환경에 변화를 주기 때문에 addToCart는 액션함수입니다.

그런데 순수함수로 추출 할 수 있는 부분이 있는것 같습니다.

 

// 액션 함수 덩어리를 (계산 -> 액션) 으로 나누었습니다
const addToCart = (product: Product) => {
  setCart((prev) => {
    return getAddedToCart(prev, product);
  });
};

// getProductIndexInCart는 계산함수여서 테스트 가능합니다🎉
const getProductIndexInCart = (cart: CartItem[], productId: string) => {
  return cart.findIndex((item) => item.product.id === productId);
}

// getAddedToCart는 계산함수여서 테스트 가능합니다🎉
const getAddedToCart = (cart: CartItem[], product: Product) => {
  const targetIndex = getProductIndexInCart(cart, product.id);

  if (targetIndex === -1) {
    return [...cart, { product, quantity: 1 }];
  }

  return cart.map((item, index) =>
    index === targetIndex ? { ...item, quantity: item.quantity + 1 } : item
  );
};

 

 

액션이 작아진건 좋은데 코드양이 더 늘어나서 보기가 좋지 않습니다.

커스텀 훅으로 관심사를 분리하면 좋을것 같습니다.

 

export const CartPage = () => {
  const {cart, addToCart} = useCart();

  return (
    <div>
        <button onClick={() => addToCart(product)}></button>

        ... 나머지 코드
    </div>
  )
}

 

컴포넌트가 클린해 졌네요.

 

계층형 설계 (aka. 추상화 레이어 )

 

함수형 프로그래밍에는 계층형 설계라는 내용이 있습니다.

코드를 추상화 계층으로 구성해서 각 계층을 볼 때 다른 계층의 구체적인 내용은 몰라도 됩니다.

위의 커스텀 훅은 계층형 설계입니다.

 

useCart 를 모듈화 시키고 cart, addToCart만 인터페이스로 제공했습니다.

컴포넌트 계층에선 addToCart 가 어떻게 동작하는지 몰라도 됩니다. 그냥 사용해서 카트에 물건을 담기만 하면 됩니다.

다만, 인터페이스만 제공되니 함수 이름을 잘 지어야합니다.

 

이렇게 이번 5주차에는 디자인 패턴과 함수형 프로그로밍에 대해서 알아봤습니다.

테오 코치가 추천해준 <쏙쏙 들어오는 함수형 코딩> 을 읽었더니 이번 회고는 뭔가 학습지처럼 진행이 되어버렸네요🤔 


함수형 프로그래밍에 대한 내용만 다루었지만 자바스크립트는 많은 부분 객체지향 언어의 특징을 가집니다.

리액트도 얼마전까지 객체지향인 클래스 컴포넌트를 사용했으니 지금 사용하지 않더라도

알아두는게 좋지 않을까 생각됩니다 (뭐.. 클래스 컴포넌트를 유지보수 할 일이 있을수도 있잖아요..?)

 

여러가지를 배웠지만 핵심은 "액션을 작게 만들자" 인거 같습니다.

사실 <쏙쏙>만 읽어서는 리액트에 적용하기 쉽지 않았을거 같은데 항해 커리큘럼의 도움으로 리액트에 적용하는 부분까지

큰 어려움 없이 해낸것 같습니다. 

 

그럼 과제하면서 추가로 알게된 내용을 끝으로 회고 마칩니다🌠

+ 할인 코드 추가 

 

전 기수에게 지급되는 할인 코드 2CFT40

항해플러스에 합류하실 때 요 할인코드(2CFT40) 를 입력하면 20만원 할인혜택이 있습니다!

4/30일 까지는 프로모션 기간으로 30만원 할인되니 합류하시려면 서두르세요 🔥🔥

저에게도 혜택이 있으니 제 할인코드를 적용하신 분은 댓글 남겨주시면 제가받은 혜택 절반을 페이백 해드리겠습니다🎉 

 

합류하러 가기🦖

출처: https://kofearticle.substack.com/p/korean-fe-article-771

 

setState 사용법
setState 사용에는 함수형 업데이트 방식이 있습니다.

위의 내용 중
const addToCart = (product: Product) => {
  setCart((prev) => getAddedToCart(prev, product));
};
가 그렇습니다.

아래 코드처럼 작성했다고 가정해보겠습니다.
이 코드도 동일한 기능을 합니다.
const [cart, setCart] = useState([]);

const addToCart = (product: Product) => {
  const newCart = getAddedToCart(cart, product);
  setCart(newCart);
};

다만, 이 코드는 버그가 생길 여지가 있습니다.
React의 상태 업데이트가 비동기적으로 처리되어 빠르게 여러번 업데이트가 되거나
다른 이벤트 처리 중에 상태 업데이트가 일어나면, 클로저에 갇힌 cart 변수는 최신 상태를 반영하지 못할 수 있습니다.

이때 함수형 업데이트 setCart((prev) => ...) 를 사용하면 React는 항상 최신 상태값을 함수에 전달하므로 이런 문제를 방지할 수 있습니다.

실제로 수동 테스트로는 문제가 없었지만 테스트 코드를 통해 addToCart 를 테스트 해보니 
cart가 최신 상태를 반영하지 못하는 버그가 있었습니다. 

 

이번에는 냅다 더티코드가 주어지고 더티코드를 클린코드로 바꾸는 주차였다.

어떤게 클린코드인지 왜 클린코드인지에 대해 고민하는 시간이었다.

 

 

// 과제 일부 발췌

var prodList, sel, addBtn, cartDisp, sum, stockInfo;
var lastSel, bonusPts=0, totalAmt=0, itemCnt=0;
function main() {
  prodList=[
    {id: 'p1', name: '상품1', val: 10000, q: 50 },
    {id: 'p2', name: '상품2', val: 20000, q: 30 },
  ];

  var root=document.getElementById('app');
  let cont=document.createElement('div');
  cartDisp.id='cart-items';
  sum.id='cart-total';

  stockInfo.id='stock-status';
  wrap.className='max-w-md mx-auto bg-white rounded-xl shadow-md overflow-hidden md:max-w-2xl p-8';
  hTxt.className='text-2xl font-bold mb-4';
  hTxt.textContent='장바구니';
  addBtn.textContent='추가';
  updateSelOpts();
  wrap.appendChild(hTxt);
  wrap.appendChild(addBtn);
  root.appendChild(cont);
  calcCart();
  setTimeout(function () {
    setInterval(function () {
      var luckyItem=prodList[Math.floor(Math.random() * prodList.length)];
      if(Math.random() < 0.3 && luckyItem.q > 0) {
        luckyItem.val=Math.round(luckyItem.val * 0.8);
        alert('번개세일! ' + luckyItem.name + '이(가) 20% 할인 중입니다!');
        updateSelOpts();

      }
    }, 30000);
  }, Math.random() * 10000);

function calcCart() {
  totalAmt=0;
  itemCnt=0;
  var cartItems=cartDisp.children;
  var subTot=0;
  for (var i=0; i < cartItems.length; i++) {
    (function () {
      var curItem;
      for (var j=0; j < prodList.length; j++) {
        if(prodList[j].id === cartItems[i].id) {
          curItem=prodList[j];
          break;
        }
      }

      var q=parseInt(cartItems[i].querySelector('span').textContent.split('x ')[1]);
      var itemTot=curItem.val * q;
      var disc=0;
      itemCnt += q;
      subTot += itemTot;
      if(q >= 10) {
        if(curItem.id === 'p1') disc=0.1;
        else if(curItem.id === 'p2') disc=0.15;
        else if(curItem.id === 'p3') disc=0.2;
        else if(curItem.id === 'p4') disc=0.05;
        else if(curItem.id === 'p5') disc=0.25;
      }
      totalAmt += itemTot * (1 - disc);
    })();
  }
  let discRate=0;
  if(itemCnt >= 30) {
    var bulkDisc=totalAmt * 0.25;
    var itemDisc=subTot - totalAmt;
    if(bulkDisc > itemDisc) {
      totalAmt=subTot * (1 - 0.25);
      discRate=0.25;
    } else {
      discRate=(subTot - totalAmt) / subTot;
    }
  } else {
    discRate=(subTot - totalAmt) / subTot;
  }

  if(new Date().getDay() === 2) {
    totalAmt *= (1 - 0.1);
    discRate=Math.max(discRate, 0.1);
  }
  sum.textContent='총액: ' + Math.round(totalAmt) + '원';
  if(discRate > 0) {
    var span=document.createElement('span');
    span.className='text-green-500 ml-2';
    span.textContent='(' + (discRate * 100).toFixed(1) + '% 할인 적용)';
    sum.appendChild(span);
  }
  updateStockInfo();
  renderBonusPts();
}

main();
addBtn.addEventListener('click', function () {
  var selItem=sel.value;
  var itemToAdd=prodList.find(function (p) { return p.id === selItem; });
  if(itemToAdd && itemToAdd.q > 0) {
    var item=document.getElementById(itemToAdd.id);
    if(item) {
      var newQty=parseInt(item.querySelector('span').textContent.split('x ')[1]) + 1;
      if(newQty <= itemToAdd.q) {
        item.querySelector('span').textContent=itemToAdd.name + ' - ' + itemToAdd.val + '원 x ' + newQty;
        itemToAdd.q--;
      } else {
        alert('재고가 부족합니다.');
      }
    } else {
      var newItem=document.createElement('div');
      newItem.id=itemToAdd.id;
      newItem.className='flex justify-between items-center mb-2';
      newItem.innerHTML='<span>' + itemToAdd.name + ' - ' + itemToAdd.val + '원 x 1</span><div>' +
        '<button class="quantity-change bg-blue-500 text-white px-2 py-1 rounded mr-1" data-product-id="' + itemToAdd.id + '" data-change="-1">-</button>' +
        '<button class="quantity-change bg-blue-500 text-white px-2 py-1 rounded mr-1" data-product-id="' + itemToAdd.id + '" data-change="1">+</button>' +
        '<button class="remove-item bg-red-500 text-white px-2 py-1 rounded" data-product-id="' + itemToAdd.id + '">삭제</button></div>';
      cartDisp.appendChild(newItem);
      itemToAdd.q--;
    }
    calcCart();
    lastSel=selItem;
  }
});

 

처음 더티코드가 주어졌을 때 막막했다.
중복되고 주석과 들여쓰기가 불규칙적인데다 조건문도 매우매우 복잡하고, 무엇보다 네이밍센스가 말이 안됐다.
(ex. sel, cartDisp 등 무슨 내용의 변수인지 파악하기가 힘들다)

상상 이상의 코드에 뭐부터 해야 좋을지 과제 체크리스트를 살펴봤다

1. 의미 있는 이름 사용
2. 함수 추출 (단일 책임)
3. 매직 넘버 제거
4. 조건문 단순화

... 기타 등등

1. 의미 있는 이름 사용
참고 문서도 좀 읽어보다가 심각한 함수명 & 변수명부터 수술에 들어갔다
그동안은 크게 고민안하고 이름을 지었는데 '클린코드' 란 명목으로 이름을 지으려니 고려해야될게 생각보다 많았다
내가 그동안 사용했던 이름들은 나의 주관이 강하게 반영된 내가 알아보기 쉬운 이름이었다. 

어떤 이름이 좋은 것인가 찾아보던 중 
참 와닿은 내용이 있어서 발췌한다.

 좋은 것은 특이한게 아니라 보편적이라서 좋은 것이다. 단, 그 의미를 분명하게 알고 있어야 한다  -테오


그럼 어떤게 보편적인가?
무언가를 배열에 넣는 함수는 add를 써야하는가 push를 써야하는가? insert는 안되나?
addProduct vs pushProduct vs insertProduct

나는 느낌적으로 addProduct가 맞는거 같다. 

팀별로 변수명을 논의했는데 아래 변수명을 뭘로 바꿀지였다.

interface UserInfoData {
    name: string; 
    age: number; 
    activeStatusFlag: boolean; 
    value: string; 
    detailsString: string;
}


UserInfo, UserData, User 정도 안건이 나왔는데 나름 타당한 이유들을 갖고있다
User) 간단하게 User로만 지어도 명확하다. 짧은게 좋다. 
UserInfo) 그냥 User는 컴포넌트 이름과 겹치는 경우가 가끔있다 그럴때 User as  를 하느니 UserInfo로 하자
UserData) 그동안 컨벤션을 UserData로 가져가서 이게 더 맞는 느낌이다.
  
별거아닌 변수명에도 다양한 의견이 있었고 정말 '느낌'이 정답인 영역이었다.
그래서 합의의 과정이 필요했고 우리가 정한 roll대로 가는것이 정답이었다.
실제로 조금 안맞는 이름도 일관되게 적용해보고 아 이거 이래서 불편하네 싶은것들을 아는것도 클린코드의 과정이라고 그러더라 (4주차 끝나갈때쯤 들었다..)

2. 함수 추출 (단일 책임)
이전에는 리팩토링을 할 때 특히, 하나의 함수를 추출해서 단일 책임의 여러함수로 만들 때 내용이 파편화되고 가독성이 오히려 떨어진다고 생각한 적이 있었는데 이번 세션을 통해서 그 생각이 많이 깨졌다.
함수 추출에 layer를 고려해 단계별로 추상화를 통해 더티 코드가 상당히 읽기 좋은 코드가되었다.

 

3. 매직 넘버 제거

상품 별로 적용되는 할인율, 대량 구매 시 할인 (대량 구매의 수량) 등을 이해할 수 있는 변수로 분류했다

export const DISCOUNT_RATE = {
  p1: 0.1,
  p2: 0.15,
  p3: 0.2,
  p4: 0.05,
  p5: 0.25,
};

export const NUMBER_OF_BULK = 30;

// productId를 변수로 받아 할인율 return 
function getDiscountRate(productId) {
  return DISCOUNT_RATE[productId] || 0;
}

// bulk 수량인 경우 bulk 할인율 적용
if (quantity >= NUMBER_OF_BULK) { // NUMBER_OF_BULK: number
	discountRate = calculateBulkDiscountRate(quantity, discountedPrice, price);
}

 

 


아키텍쳐 적용
리팩토링을 어느정도 적용하고 1~2주차에 진행했던 컴포넌트 기반 아키텍쳐를 적용했다.

// App.js

export class AppComponent {
  // private 변수 선언
  #cartBox;
  #totalPrice;
  #select;
  #addBtn;
  #stockStatus;
  #container;
  #wrap;
  #h1;

  constructor(rootId) {
    this.rootId = rootId;

    // element 변수 선언
    this.#cartBox;
    this.#totalPrice;
    this.#select;
    this.#addBtn;
    this.#stockStatus;
    this.#container;
    this.#wrap;
    this.#h1;

    // 마지막 선택한 상품 id
    this.lastSelectedProductId;
  }

  // DOM 요소 생성
  #createElements() {
    this.#cartBox = document.createElement("div");
    this.#totalPrice = document.createElement("div");
    this.#select = document.createElement("select");
    this.#addBtn = document.createElement("button");
    this.#stockStatus = document.createElement("div");
    this.#container = document.createElement("div");
    this.#wrap = document.createElement("div");
    this.#h1 = document.createElement("h1");
  }

  // DOM 요소 textContent 설정
  #setTextContent() {
    this.#totalPrice.textContent = "총액: 0원(포인트: 0)";
    this.#h1.textContent = "장바구니";
  }

  // DOM 요소 property 설정
  #setProperties() {
    this.#cartBox.id = "cart-items";
    this.#totalPrice.id = "cart-total";
    this.#select.id = "product-select";
    this.#addBtn.id = "add-to-cart";
    this.#stockStatus.id = "stock-status";
    this.#totalPrice.className = "text-xl font-bold my-4";
    this.#select.className = "border rounded p-2 mr-2";
    this.#addBtn.className = "bg-blue-500 text-white px-4 py-2 rounded";
    this.#stockStatus.className = "text-sm text-gray-500 mt-2";
    this.#addBtn.textContent = "추가";

    this.#container.className = "bg-gray-100 p-8";
    this.#wrap.className =
      "max-w-md mx-auto bg-white rounded-xl shadow-md overflow-hidden md:max-w-2xl p-8";
    this.#h1.className = "text-2xl font-bold mb-4";
  }

  // 상품 select 옵션 설정
  #setSelectOptions() {
    this.#select.innerHTML = "";
    productList.forEach((item) => {
      const $opt = document.createElement("option");
      $opt.value = item.id;
      $opt.textContent = item.name + " - " + item.val + "원";
      if (item.quantity === 0) $opt.disabled = true;
      this.#select.appendChild($opt);
    });
  }

  // DOM 요소 appendChild 설정
  #appendChilds() {
    this.#wrap.appendChild(this.#h1);
    this.#wrap.appendChild(this.#cartBox);
    this.#wrap.appendChild(this.#totalPrice);
    this.#wrap.appendChild(this.#select);
    this.#wrap.appendChild(this.#addBtn);
    this.#wrap.appendChild(this.#stockStatus);
    this.#container.appendChild(this.#wrap);

    const $root = document.getElementById(this.rootId);
    $root.appendChild(this.#container);
  }

  // 이벤트 설정
  #setEvent() {
    // 상품 추가 버튼 이벤트
    this.#addBtn.addEventListener("click", () => {
    	//생략
    });

    // 상품 + - 버튼 이벤트 (수량 변경)
    this.#cartBox.addEventListener("click", (event) => {
    	//생략
    });

    // 장바구니 삭제 버튼 이벤트
    this.#cartBox.addEventListener("click", (event) => {
    	//생략
    });
  }

  // AppComponent 렌더링
  render() {
    this.#createElements();
    this.#setProperties();
    this.#setTextContent();
    this.#setSelectOptions();
    this.#appendChilds();

    // DOM 요소 생성 후 이벤트 설정
    this.#setEvent();
  }
}

 

// main.js

const App = new AppComponent("app");

function init() {
  App.render();
}

init();


 1~2주차에서 배운 컴포넌트 아키텍처를 이런식으로 사용하는구나 싶어 좀 뿌듯했다

'하지만 현업에서 이런 코드 안쓰지않나..' 싶은 생각이 들때즈음 기본과제가 끝났고 

심화과제는 위 내용을 고대로 React & Typescript 스펙으로 마이그레이션 하는 내용이었다. 

 

기능 자체는 별게 없는데..

기존의 HTML 템플릿을 리액트 컴포넌트로 바꾸고 이벤트 핸들러를 리액트 방식으로 변경하는게 생각보다 까다로웠다

그냥 동일한 기능을 하는 코드를 새로 작성하는게 훨씬 나았다고 생각했는데 테오는 한번에 모든 코드를 변경하는게 아닌 점진적으로 리팩토링하는 감각을 기르는게 중요하다고한다

 

4주차 진행하면서 코딩은 정말 운동같은거다.. 라는걸 체감했다. 이전에도 클린코드에 대해서 문서나 블로그들을 많이 봤었는데

더티코드를 직접 클린코드로 변환하기 전까진 잘 모르는 내용이었다는걸 깨달았다.

이번주도 코딩근육이 조금 생긴거 같다. (아직 빈약한거 같다)

 

전 기수에게 지급되는 할인 코드 2CFT40

 

항해플러스에 합류하실 때 요 할인코드(2CFT40) 를 입력하면 20만원 할인혜택이 있습니다!

4/30일 까지는 프로모션 기간으로 30만원 할인되니 합류하시려면 서두르세요 🔥🔥

저에게도 혜택이 있으니 제 할인코드를 적용하신 분은 댓글 남겨주시면 제가받은 혜택 절반을 페이백 해드리겠습니다🎉 

 

합류하러 가기🦖

 

이번 주차는 raect의 다양한 hooks들을 만들고 사용해보며 동작원리를 체득해보는 시간이었다.

useRef, useMemo, useCallback, memo를 직접 구현해보면서 메모이제이션에 대해 더 깊게 생각해볼 수 있었고,

useContext로 랜더링을 최적화 하는 과정을 통해 보다 응집도 있는 코드 작성하는 법에 대해 배울수 있었다.

 

1. useRef

렌더링 사이에 값을 유지하는 ref 객체를 생성

export function useRef<T>(initialValue: T): { current: T } {
  const [ref] = useState({ current: initialValue });
  return ref;
}

 

처음엔 아래처럼 object에 값을 넣어도 동일하게 동작할 줄 알았다

const ref = { currnet: initialValue };

그런데 이렇게하면 렌더링이 될때마다 값이 초기화돼버렸다.

새로고침 하기 전까지는 값을 유지하는 useState 내부 저장소에 값을 저장했다

useState에 저장한 값을 변경하는 방법으로 리랜더링하지 않고 랜더링 사이에도 값을 유지하는 useRef를 구현했다. 

ref.current = "값 변경";

 

 


 

그런데 useRef를 구현하고보니 useState에 궁금증이 생겼다.

useState는 어디에 값을 저장하길래 object처럼 초기화되지 않는거지? 

 

React는 특정 공간에 state 값을 저장하고있다.

공식문서에서도 일반 변수는 랜더링하면 값 초기화되니까 렌더링에 사용할 변수는 useState를 사용하라고 강조하고 있다 (https://react.dev/learn/state-a-components-memory#)

 

클로저

React는 state의 값을 저장하기위해 클로저를 사용하고있다.

클로저를 통해 useState가 호출될 때마다 초기화되지 않고 기존 상태를 유지할 수 있게된다.

// 클로저를 통해 useState 구현

const createUseState = (initialValue = 0) => {
  let value = initialValue; // 클로저로 상태를 저장

  const state = () => value; // 상태를 반환하는 함수
  const setState = (newValue) => {
    value = newValue; // 상태를 업데이트
  };

  return [state, setState]; // 반환
};

const customUseState = createUseState(0); // 초기 상태를 0으로 설정
export default customUseState;
// 컴포넌트에서 사용

const [state, setState] = customUseState;
  const onClick = () => {
    setState(state() + 1);
    console.log(state());
  };

 

 

 

useState를 비슷하게 만들어보니 호출할 때마다 useState를 구분해줘야되는 문제점이 있었다.

리액트는 어떻게 해결했나 공식문서를 보니 역시 해답이 나와있었다.

  

useState는 각각의 useState 인덱스를 key 값으로 구분하고있다.

let componentHooks = [];
let currentHookIndex = 0;

function useState(initialState) {
  let pair = componentHooks[currentHookIndex];
  if (pair) {
	// 이미 호출된 useState 쌍이면 return 해서 호출 대기
	currentHookIndex++;
    return pair;
  }

  // 처음 호출된 useState 는 우리가 흔히 사용하는 [state, setState] 쌍을 만든다
  pair = [initialState, setState];

  function setState(nextState) {
    // setState가 변경되면 state 값을 변경하고 DOM을 update한다.
    pair[0] = nextState;
    updateDOM();
  }

  // 이후 렌더링을 위해 쌍을 저장하고 다른 useState 호출 대기를 위해 인덱스 +1
  componentHooks[currentHookIndex] = pair;
  currentHookIndex++;
  return pair;
}

 

2. useMemo

react는 useCallback, useMemo, memo 등의 의존성을 판단할 때 얕은 비교를 한다.

만약 깊은 비교를 통해 위 hook들을 사용하고 싶다면 별도의 커스텀 훅을 만들어 진행해야한다.

export function useMemo<T>(
  factory: () => T,
  _deps: DependencyList,
  _equals = shallowEquals,
): T {
  const prevDeps = useRef<null | DependencyList>(null);
  const prevFactory = useRef<null | T>(null);

  const isEqual = shallowEquals(prevDeps.current, _deps); // 얕은 비교
  if (!isEqual) {
    prevDeps.current = _deps;
    // 참조값 _deps 가 변경되면 factory함수를 다시 생성한다.
    prevFactory.current = factory();
  }

  return prevFactory.current as T;
}

 

3. useCallback

usememo는 값을 useCallback은 함수를 메모이제이션하므로 아래와 같이 간단하게 구현 가능하다. 

export function useCallback<T extends Function>(
  factory: T,
  _deps: DependencyList,
) {
  const _factory = useMemo(() => factory, _deps);

  return _factory as T;
}

 

4. memo

memo도 useMemo, useCallback과 동일하게 얕은비교를 하는데 react element를 다시 생성해줘야한다.

이때 넘겨준 props와 children을 return 하는 createElement라는 react 메서드를 사용한다.

export function memo<P extends object>(
  Component: ComponentType<P>,
  _equals = shallowEquals,
) {
  return function MemoizedComponent(props: P) {
    const prevProps = useRef<P | null>(null);
    const prevComponent = useRef<ReactElement | null>(null);

    const isEqual = shallowEquals(prevProps.current, props);
    if (!isEqual) {
      prevProps.current = props;
      prevComponent.current = createElement(Component, props); element 재생성
    }

    return prevComponent.current;
  };
}

 

전 기수에게 지급되는 할인 코드 2CFT40

 

항해플러스에 합류하실 때 요 할인코드(2CFT40) 를 입력하면 20만원 할인혜택이 있습니다!

4/30일 까지는 프로모션 기간으로 30만원 할인되니 합류하시려면 서두르세요 🔥🔥

저에게도 혜택이 있으니 제 할인코드를 적용하신 분은 댓글 남겨주시면 제가받은 혜택 절반을 페이백 해드리겠습니다🎉 

 

합류하러 가기🦖

항해 2주차가 뚝딱 지나갔다.

이번 주차에서는 항해가 커리큘럼을 정말 잘 짰다고 느꼈다. 매주 과제를 통과해야되는데 단계적으로 작성해놓은 테스트코드를 통과하는 과정이 자연스럽게 성장할 수 있는 발판이 된다.

 

1주차에선 자바스크립트로 라우팅과 이벤트 주입, 컴포넌트 기반 설계등을 구현해보면서 기본을 다지는 시간이었고,

2주차는 자바스크립트로 가상돔을 만들고 React처럼 동작하게 하면서 React의 내부 동작 원리와 이벤트 관리에 대해 더 깊게 이해해보는 시간이었다.

 

처음엔 가상돔 만드는 주제 보면서 '잉? 그걸 어떻게 만들지?' 라는 생각이 들었다

 

Hello, Virtual DOM

 

가상돔은 거창한게 아니라 DOM을 복사한 객체 덩어리다.

객체 덩어리 A, B를 만들어서 변경된 부분만 다시 렌더링해줘서 성능을 올리는 컨셉이다.

 

DOM에 변경 사항이 있을 때마다 매번 DOM을 새롭게 그리는게 아닌

가상돔으로 비교해 변경된 부분을 모아서 DOM을 새롭게 그린다.

실제론 DOM tree를 만들어 tree를 비교하게 된다

 

처음엔 그냥 DOM끼리 비교해도 되지 않나 싶었는데 Real DOM도 객체 덩어리인건 맞지만 덩어리 규모차이가 상당하다.

아래 코드처럼 간단한 div를 만들어 콘솔에 찍어보면 매우 많은 객체들이 있는것을 확인할 수 있다.

const $divElement = document.createElement('div'); 
console.log($divElement);


때문에 DOM 자체를 만들어 비교하려면 그만큼 비용이 많이 들고 DOM 자체가 브라우저 기능이어서 런타임 환경에서야 비교가 가능했다. (가상돔은 순수한 객체)  


그리고 가상돔의 다른 이점은 개발자 경험 향상에 있는데

자바스크립트의 명령형 코드로 UI를 만드는 것이 아닌 jsx를 통해 선언적으로 UI를 만들 수 있게 해준다. 

// 선언형
<button onClick={console.log("버튼입니다")}>버튼</button>
const $btn = document.createElement("button");
$btn.textContent = "버튼";
$btn.addEventListener("click", () => console.log("버튼입니다"));

 

 

추가로 가상돔이 항상 성능 향상에 기여하지는 않는다. 애플리케이션 규모나 기능에 따라 Real DOM을 조작하는 방식이 성능이 좋을 수 있으니 'React가 정답이야' 라는 것보다는 조금 객관적으로 트레이드오프를 고려해 볼 필요가 있다.

 


자바스크립트로 가상돔을 만들어 React처럼 동작하게 하는 과정은 크게 5가지 과정을 거친다.

 

1. 가상DOM 생성

RealDOM을 가상돔으로 변환해보자.

// Real DOM
<div id="parent">
  <span className="child">Child</span>
</div>

 

위의 Real DOM을 가상돔으로 구성하면 아래와 같이된다.

function createVDOM(type, props, ...children) {
	return { type, props, children.flat() }
}

createVDOM('div', { id: 'parent' },
	createVDOM('span', { className: 'child' }, 'Child');
);

 

그렇게 변환된 객체 덩어리는 아래와 같이된다.

// 가상돔
{
  type: "div",
  props: { id: "parent" },
  children: [
    {
      type: "span",
      props: { className: "child" },
      children: ["Child"],
    },
  ],
},

 

2. 가상DOM 정규화

 

정규화 함수를 통해 일관된 형식의 가상DOM을 반환하도록 한다

function normalizeVDOM(vDom) {
	// vDom이 null, undefined 또는 boolean 타입일 경우 빈 문자열을 반환합니다.
    if (vDom === null || vDom === undefined || typeof vDom === "boolean") {
        return "";
    }
    
    // vDom 문자열 또는 숫자일 경우 문자열로 변환하여 반환
    if (typeof vDom === "string" || typeof vDom === "number") {
        return String(vDom);
    
    
    // VDom이 함수일 경우 해당 함수를 호출해 반환된 결과를 재귀적으로 정규화 
    if (type vDom.type === "function") {
    	const props = { ...vDom.props, children: vDom:children  }
        return normalizeVDOM(vDom.type(props))
    }
    
    // 그 외의 경우 vDom의 자식 요소들을 재귀적으로 정규화
    const normalizedChildren = vDom.children.map((child) => normalizeVDOM(child))
    
    return { ...vDom, children: normalizedChildren }
}

 

아래 컴포넌트화 시킨 함수를 정규화해보자

  const TestComponent = () => (
    <ul>
      <li id="item-1">Item 1</li>
      <li id="item-2">Item 2</li>
      <li id="item-3" className="last-item">Item 3</li>
    </ul>
  );
  
  const normalized = normalizeVNode(<TestComponent />);

 

이런 결과가 나온다

{
  type: "ul",
  props: {
  },
  children: [
    {
      type: "li",
      props: {
        id: "item-1",
        className: "list-item ",
      },
      children: [
        "- ",
        "Item 1",
      ],
    },
    {
      type: "li",
      props: {
        id: "item-2",
        className: "list-item ",
      },
      children: [
        "- ",
        "Item 2",
      ],
    },
    {
      type: "li",
      props: {
        id: "item-3",
        className: "list-item last-item",
      },
      children: [
        "- ",
        "Item 3",
      ],
    },
  ],
}

 

3. 가상DOM을 Real DOM으로 변환

function createDOM(vDom) {
	// vDom이 null, undefined, boolean일 경우, 빈 텍스트 노드 생성하여 반환
    // vDom이 문자열이나 숫자면 텍스트 노드 생성하여 반환
    // vDom이 배열이면 DocumentFragment 생성 후 각 자식에 대해 createDOM 재귀 호출하여 추가
    
    // 그 외의 경우 (vDom이 컴포넌트인 경우) DOM 생성
    const { type, props, children } = vDom;
    const $el = document.createElement(type);
    // $el에 props 주입
    updateAttributes($el, props);
    
    // children 있는 경우 (부모와 동일하게 DOM생성, props 주입)
    children.forEach((child) => {
        const $child = createElement(child);
        updateAttributes($child, child?.props);
        $el.appendChild($child);
	});
    
    return $el;
}

// DOM에 props 주입
function updateAttributes($el, props) {
	Object.entries(props).forEach(([key, value] => {
    	// 이벤트인 경우 jsx -> js 로 변경 (ex. onClick -> click)
		if (key.startsWith("on") && typeof value === "function") {
			const event = key.toLowerCase().slice(2);
            // 이벤트는 별도로 저장해놨다가 이벤트 위임방식으로 등록
            addEvent($el, event, value);
		} else {
			$el.setAttribute();
		}
	});
}

 

4. 이벤트 주입

createDOM 함수가 실행될 때 이벤트인 경우에 별도로 저장해놨다가 DOM이 생성된 후에  저장해둔 이벤트를

부모 DOM에 등록한다 (이벤트 위임 방식으로 주입)

 

const eventStore = new Map();

function addEvent(element, eventType, handler) {
  let depthMap = eventStore.get(eventType);
  if (!depthMap) {
    depthMap = new Map();
    eventStore.set(eventType, depthMap);
  }
  depthMap.set(element, handler);
}
function setupEventListeners(root) {
  if (eventStore.size < 1) return;

  Array.from(eventStore.entries()).forEach(([eventType]) => {
    const depthMap = eventStore.get(eventType);
    Array.from(depthMap.entries()).forEach(([element, handler]) => {
      if (rootStore.get(element)?.has(handler)) {
        return;
      }
      const eventHandler = (e) => {
        if (element === e.target) {
          handler(e);
        }
      };
      root.addEventListener(eventType, eventHandler);
    });
  });
}

 

5. 렌더링

그동안 작성한 코드를 이용해 SPA 처럼 렌더링시켜준다.

function renderDOM(vDom, container) {.
  const normalizedvDom = normalizeVNode(vDom);
  const $el = createElement(normalizedvDom);
  
  // 최초 렌더링시에는 createElement로 DOM을 생성
  if (!container.__oldVDom) {
    container.appendChild($el); // 최초 렌더링 시 DOM 생성
    
  // 이후에는 변경된 부분만 기존 DOM을 업데이트한다.
  } else {
    container.replaceChild($el); // 대충 퉁쳤는데 변경된 가상돔을 check해서 update하는 부분 필요
  }

  container.__oldVDom = normalizedVDom;

  // 렌더링이 완료되면 container에 이벤트를 등록한다
  setupEventListeners(container);
}

 

전 기수에게 지급되는 할인 코드 2CFT40

항해플러스에 합류하실 때 요 할인코드(2CFT40) 를 입력하면 20만원 할인혜택이 있습니다!

4/30일 까지는 프로모션 기간으로 30만원 할인되니 합류하시려면 서두르세요 🔥🔥

저에게도 혜택이 있으니 제 할인코드를 적용하신 분은 댓글 남겨주시면 제가받은 혜택 절반을 페이백 해드리겠습니다🎉 

 

합류하러 가기🦖

+ Recent posts