본문으로 건너뛰기

ESLint 오류 해결기 - await을 for...of 반복문에서 사용하지 않기

· 약 4분

ESLint에서 다음과 같은 두 가지 오류가 발생했습니다:

  1. no-restricted-syntax: for...of 반복문을 사용할 수 없다는 경고
  2. no-await-in-loop: 반복문 내에서 await을 사용하면 안 된다는 경고 이 오류를 해결하려면 어떻게 해야 할지에 대해 고민한 결과, for...of 루프 대신 Promise.all을 사용하여 비동기 작업을 병렬로 처리하는 방법을 적용할 수 있었습니다. 이번 포스팅에서는 이 문제를 해결하기 위해 사용한 방법을 공유하려고 합니다.

🙄 문제 원인

ESLint에서 for...of 루프에서 await을 사용할 때 오류가 발생합니다. 두 가지 규칙이 겹치는 문제로, for...of와 같은 반복문을 사용할 수 없다는 규칙(no-restricted-syntax)과 반복문 안에서 await을 사용하는 것을 금지하는 규칙(no-await-in-loop)이 문제를 일으켰습니다.

🤗 해결 방법

이 문제를 해결하기 위해 **Promise.all**을 사용하여 비동기 작업을 병렬로 처리하는 방법을 적용했습니다. 병렬 처리로 각 작업이 독립적으로 실행되므로, 반복문 내에서 await을 사용할 필요가 없어집니다.

🕶️ 수정 전 코드

for (const guest of guests) {
const { name, start_location, end_location, path, marker_style } = guest;
await addGuestInDB(channel.id, name, start_location, end_location, path, marker_style, host_id);
}

위와 같은 코드에서는 for...of 반복문 내에서 await을 사용하고 있어 ESLint 경고가 발생합니다.

🕶️ 수정 후 코드

const guestPromises = guests.map((guest) => {
const { name, start_location, end_location, path, marker_style } = guest;
return addGuestInDB(channel.id, name, start_location, end_location, path, marker_style, host_id);
});
// Promise.all을 사용하여 병렬로 실행
await Promise.all(guestPromises);

설명

  1. map: guests 배열의 각 요소를 순회하면서 비동기 작업(addGuestInDB)을 반환합니다. map은 새로운 배열을 반환하는데, 이 배열은 각 비동기 작업을 Promise 객체로 감쌉니다.
  2. Promise.all: Promise.all을 사용하여 모든 Promise가 해결될 때까지 기다립니다. 이로 인해 병렬로 모든 비동기 작업이 실행되며, 반복문 내에서 await을 사용할 필요가 없어집니다.

🖼️ 장점

  • 성능 최적화: 모든 비동기 작업을 병렬로 처리하기 때문에 for...of보다 더 효율적으로 실행됩니다.
  • ESLint 오류 해결: for...ofawait을 조합하는 방식에서 발생하는 ESLint 오류를 해결할 수 있습니다.
  • 가독성 개선: mapPromise.all을 사용하여 코드를 더 직관적이고 간결하게 만들 수 있습니다.

🛒 결론

ESLint에서 for...ofawait을 동시에 사용할 때 발생하는 경고를 해결하기 위해, 반복문을 mapPromise.all로 대체하여 병렬로 비동기 작업을 처리하는 방법을 적용했습니다. 이 방법은 성능 최적화와 코드 가독성 개선에도 도움을 주며, ESLint 오류도 해결할 수 있습니다.

PostgreSQL 데이터베이스 설계 고민- Channel 테이블에 Guests 정보를 포함할지

· 약 7분

📝 프로젝트 배경

이번 프로젝트에서는 사용자들이 방(채널)을 생성하고 그 안에 다수의 게스트를 초대하여 특정 위치와 경로 정보를 공유할 수 있는 기능을 구현 중입니다. 사용자는 방을 만들어 자신이 호스트가 되고, 방 안에 여러 게스트가 위치 및 경로 정보를 공유하며 참여할 수 있습니다. 이때 PostgreSQL을 사용해 방과 게스트 데이터를 효율적으로 저장하고 관리하고자 channel 테이블과 guest 테이블을 설계했으며, 두 테이블이 서로 연결된 구조로 설계되어야 했습니다. 처음 설계한 erd는 아래와 같았습니다. (channel 테이블에 게스트 객체 배열이 존재합니다)

🔍 문제 상황: Channel 테이블에 Guests 정보를 포함할 것인가?

처음에는 방과 게스트 정보 모두를 channel 테이블에 JSONB 타입으로 넣어 guests라는 컬럼으로 관리하고자 했습니다. 이렇게 하면 한 번의 쿼리로 channel 테이블에서 방 정보와 게스트 정보를 모두 조회할 수 있어 단순하게 보였습니다. 그러나 이렇게 guests 정보를 포함하는 방식에는 몇 가지 문제점이 발견되었습니다:

  1. 데이터 중복 및 관리 이슈: 게스트 정보를 JSONB로 channel 테이블에 넣으면, 게스트 정보에 수정 사항이 생길 때마다 channel 테이블에 있는 JSON 데이터를 업데이트해야 합니다. 이는 데이터 중복 및 불필요한 수정 작업이 필요하다는 문제점이 있습니다.
  2. 유연성 및 확장성 저하: 게스트 정보가 channel 테이블 안에 JSON으로 들어가면, 테이블 구조가 고정되지 않아 확장하기 어렵습니다. 예를 들어 게스트 정보에 새로운 속성이 추가된다면, channel 테이블 안의 JSON 구조를 모두 수정해야 하는 부담이 생깁니다.
  3. 성능 문제: PostgreSQL은 JSONB 타입의 데이터를 효율적으로 처리할 수 있지만, channel 테이블의 데이터를 조회할 때마다 불필요하게 큰 JSON 데이터를 가져오게 되어 성능이 저하될 수 있습니다. channel 테이블에 데이터를 저장하면 아래와 같이 guests 가 저장되었는데, 너무 비효율적이라는 생각이 들었습니다.

💡 해결책: Guest 테이블을 별도로 분리하기

이러한 이유로 고민 끝에 channel 테이블에서 guests 컬럼을 제거하고, guest 테이블을 독립적으로 구성하기로 결정했습니다. 각 게스트는 guest 테이블에 저장되며, channel_id로 해당 방을 참조하는 관계를 가지게 됩니다.

⚙️ 최종 설계 구조

  • channel 테이블: 채널의 고유 ID, 이름, 호스트 ID(host_id), 생성 날짜(generated_at) 등의 채널 자체 정보만 포함합니다.
  • guest 테이블: 채널과 연결된 각 게스트의 정보를 관리합니다. guest 테이블에는 channel_id 외에도 start_location, end_location, path, marker_style, host_id 등의 필드가 JSONB 형태로 저장되어 각 게스트의 구체적인 정보를 유연하게 관리할 수 있습니다.

👍 장점 및 개선 사항

이렇게 테이블을 분리하면서 가장 크게 유지보수를 하는 데에 훨씬 편리해진다는 장점을 가질 수 있었습니다. 얻게된 이점에 대해 간략히 정리해보면 아래 3가지 정도로 정리됩니다.

  • 데이터 일관성: 게스트 정보를 변경할 때 guest 테이블만 수정하면 되므로 channel 테이블은 영향을 받지 않게 됩니다.
  • 유지보수 용이성: 게스트 정보에 필드가 추가되거나 구조가 변경되어도 guest 테이블만 수정하면 되므로, 유지보수가 쉬워졌습니다.
  • 성능 최적화: channel 테이블을 조회할 때는 방에 대한 기본 정보만 가져오고, 게스트 정보가 필요할 때만 guest 테이블을 조인하여 조회할 수 있습니다.

📌 결론

초기 설계 때는 channel 테이블에 게스트 정보를 JSON으로 포함하는 것이 간단해 보였지만, 실질적으로는 유지보수와 성능에 문제가 생길 수 있다는 것을 깨달았습니다. 최종적으로 guest 테이블을 독립적으로 관리하는 방식으로 전환하면서 설계의 유연성과 확장성을 확보할 수 있었습니다. 이와 같은 데이터베이스 설계 결정은 프로젝트의 향후 확장성에 큰 영향을 주기 때문에 신중히 고려할 필요가 있었습니다. 조금 더 깊게 고려해볼 필요가 있다는 생각을 한번 더 하게 된 계기가 되었습니다. (물론 나름 처음 설계할 때도 엄청 고민한 거였는데..... 하하.....ㅜㅜ)

🎨 Header 컴포넌트로 확인하는 내가 컴포넌트를 설계하고 구현하는 방식

· 약 5분

📚 정리

완전 범용적인 컴포넌트와, 도메인이 반영된 컴포넌트는 애초에 목적 자체가 다르다고 생각한다.

범용적일수도 좋겠지만, 당연하게도 사용성은 떨어지기 마련이다. 특히 껍데기만 만들고 모든 의존성을 주입해야하는 경우라면 더더욱.

이번에는 도메인 종속적이면서, 도메인 곳곳에서 사용되는 컴포넌트를 내가 어떻게 개발하고 있는지 서술하고자 한다.

🔬 개요

네이버 부스트캠프의 그룹 프로젝트를 진행하면서, Header 컴포넌트를 구현하게 되었다.

사실 헤더 구현에는 별 게 없긴 한데 프론트앤드 관점에서 요구사항을 내가 어떻게 분석하고 있으며, 어떻게 이를 코드로 바꾸는지 기록하고자 적게 되었다.

🤔 요구사항

missingmissingmissingmissing
페이지 1페이지 2페이지 3페이지 4

위의 4개의 페이지에서 Header 컴포넌트가 사용되고 있음을 파악할 수 있었다.

🤔 공통 요소 분석

먼저 다음과 같은 요소를 추려내었다.

missingmissingmissing
헤더 1헤더 2헤더 3

그리고 이를 분석한 내용은 다음과 같다.

분석한 내용 사진

5개의 컴포넌트가 Header라는 Layout 요소에 필요함을 알게 되었다.

잘 보면 배치는 일정하다. 그에 따라서, 미리 배치를 해두고 값이 들어올 때는 보여주고, 들어오지 않을때는 보여주지 않는 상태를 유지하면 된다는 것을 확인하였다.

🧑‍💻 구현

리액트에서의 구현 방식은 보통 2가지를 따른다.

Component Composition Pattern
Atomic Design

위 두가지인데, 둘의 핵심은 같다. 최대학 작은 단위의 컴포넌트를 만들고, 레고처럼 부품을 하나씩 조립해서 완성체를 만드는 것.

작은 것에서 시작해서 점점 큰 요소를 만드는 건데, 일반적인 패턴은 다음과 같은 것 같다.

Atomic Design Pattern 사진

이런 것처럼 제일 작은 단위로 쪼개고, 이를 합쳐서 최종적으로 페이지를 만든다는 의미의 패턴이다.

완벽하게 해당 구조를 따라가면 좋겠지만, 이는 좀처럼 쉽지 않기도 하고, 보통 이렇게 까지 필요 없는 경우도 많아서 몇 가지 과정을 스킵하고 사용하는 경우도 있는 것 같다.

📚Dev-Log란?

· 약 1분

📚 Dev-Log란?

팀원 각자가 개발을 하면서 겪은 문제점, 해결방법, 새로운 기술을 공유하는 공간입니다.

PostgreSQL에서 생성 시간 자동 설정하기 (generated_at의 NOT NULL 제약 조건)

· 약 3분

⏱️ PostgreSQL에서 생성 시간 자동 설정하기: generated_at 필드 오류 해결

프로젝트를 진행하며 generated_at 컬럼에서 null value 오류가 발생했습니다. 이 오류는 생성된 시간을 자동으로 기록해야 하는 컬럼에 데이터가 없는 경우 발생하는데, 이 문제를 PostgreSQL에서 해결하는 방법을 정리합니다.


1️⃣ 문제 상황

generated_at 필드는 각 채널이 생성된 시간을 저장하는데, NOT NULL 제약 조건이 적용되어 있었습니다. 새로운 채널을 생성할 때 이 필드에 값을 넣지 않으면 null value in column "generated_at" of relation "channel" violates not-null constraint라는 오류가 발생했습니다.

2️⃣ 해결 방법: 기본값으로 CURRENT_TIMESTAMP 설정

이 문제를 해결하기 위해, 데이터베이스에서 generated_at 필드의 기본값을 현재 시간으로 설정했습니다. CURRENT_TIMESTAMP를 기본값으로 설정하면 새 레코드를 추가할 때 자동으로 현재 시간이 입력됩니다.

ALTER TABLE channel
ALTER COLUMN generated_at SET DEFAULT CURRENT_TIMESTAMP;

이 명령어는 channel 테이블의 generated_at 컬럼에 CURRENT_TIMESTAMP 기본값을 설정해줍니다.


3️⃣ 확인 결과

기본값 설정 후, 채널 생성 API를 다시 호출했을 때 generated_at 필드에 현재 시간이 자동으로 기록되었습니다. 이를 통해 오류 없이 새 채널을 생성할 수 있었습니다.

🔄 앞으로의 적용

이와 같은 timestamp 필드는 기본값을 자동으로 설정하면 편리하게 사용할 수 있는 것 같았습니다. 특히 생성이나 update 시각을 보통 데이터에 넣어두는데, created_at, updated_at 등의 컬럼에 CURRENT_TIMESTAMP를 활용하면 손쉽게 시간 기록을 관리할 수 있다는 것을 알게 되었습니다. 결론적으로 PostgreSQL에서 현재 시간을 기본값으로 설정하여 NOT NULL 제약 조건을 지킬 수 있었습니다. 8:05

Node.js WebSocket 연결 문제 해결기

· 약 7분

⚒️ Node.js WebSocket 연결 문제 해결하기: node_modules 폴더 중복으로 인한 오류

최근 프로젝트에서 Node.js를 사용하여 WebSocket 서버를 구축하는 도중, 이상한 오류에 직면하게 되었습니다. WebSocket 서버가 정상적으로 동작하지 않아서 여러 가지 방법을 시도해 보았고, 결국 원인을 파악하고 해결할 수 있었습니다. 오늘은 그 과정을 공유해보려 합니다. 참고로 저는 pnpm 모듈을 사용하고 있었고, ws 모듈에서 오류가 발생했습니다. (Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './lib/stream.js' is not defined by "exports") 저의 폴더구조를 설명드리면, 기본적으로 모노레포로 관리하고 있었고,

root/
├── backend/
│ ├── src/
│ │ ├── index.js
│ │ ├── websocketServer.js
│ │ ├── db/
│ │ │ └── db.js
│ │ ├── routes/
│ │ │ └── authRouter.js
│ │ ├── constants/
│ │ │ └── constants.js
│ │ │ └── ...
│ ├── package.json
│ ├── swaggerConfig.js
│ └── node_modules/
└── frontend/
├── src/
│ ├── App.tsx
│ ├── index.tsx
│ ├── components/
│ │ ├── WebSocketClient.tsx
│ │ └── ...
│ └── assets/
├── package.json
├── node_modules/
└── public/

🤔 문제의 시작

저는 expressws 모듈을 사용하여 WebSocket 서버를 구축하고 있었고, 이를 HTTP 서버와 함께 동작시키기 위해 http.createServerws.WebSocketServer를 연결하는 구조로 작업을 진행했습니다. 하지만 WebSocket 서버를 초기화하려고 하던 중, initializeWebSocketServer(server)를 호출하는 코드에서 예상치 못한 오류가 발생했습니다. 오류 메시지는 아래와 같았습니다:

Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './lib/stream.js' is not defined by "exports"

이 오류는 WebSocket 모듈을 불러오는 과정에서 node_modules가 중복된 환경에서 발생한 문제였습니다.

:돋보기: 시도한 해결 방법들

  1. Node.js 버전 확인 먼저 Node.js의 버전이 오래되었을 가능성도 염두에 두고 최신 버전으로 업데이트했습니다. 하지만 여전히 문제가 해결되지 않았습니다.
  2. ws 모듈의 버전 변경 ws 모듈의 버전이 문제일 수 있다고 판단하여, pnpm으로 ws 모듈을 8.x에서 7.x로 다운그레이드했습니다. 그런데 이 방법 역시 문제를 해결하지 못했습니다.
  3. importrequire의 호환성 문제 프로젝트 설정을 살펴본 결과, importrequire 방식이 혼용된 상태에서 발생한 문제일 가능성이 높다고 생각했습니다. 그래서 require로 모듈을 불러오는 방식으로 변경해보았습니다. 여전히 오류가 발생했습니다.
  4. node_modules 폴더 중복 문제 여러 가지 방법을 시도하던 중, node_modules 폴더가 두 개 존재한다는 사실을 발견했습니다. 하나는 backend 폴더에, 또 하나는 src 폴더에 있었습니다. 이 중복된 node_modules 폴더가 문제의 원인이라는 사실을 확인한 후, node_modules 폴더를 삭제하고 다시 설치해보았습니다. 저조차 믿기지 않는 폴더구조였지만…… 제가 드래그를 잘못해서 복사가 되었는지…. 정말 왜인지 모르겠지만 src 폴더 내에 node_modules 폴더가 하나 더 존재한다는 것을 발견하였습니다.

🔑 문제 해결

최종적으로 문제는 node_modules 폴더의 중복으로 인한 의존성 충돌이었습니다. 두 개의 node_modules 폴더가 각각 독립적인 의존성 트리를 만들었고, 이로 인해 WebSocket 모듈을 제대로 불러올 수 없었던 것입니다........

해결 방법:

  1. backend 폴더와 src 폴더에서 중복된 node_modules 폴더를 삭제했습니다.
  2. pnpm install 명령어로 한 번만 의존성을 설치하여 node_modules 폴더를 하나로 통합했습니다.
  3. 이후 initializeWebSocketServer를 호출하여 WebSocket 서버를 초기화했더니 모든 것이 정상적으로 동작했습니다.

결론

이번 문제는 중복된 node_modules 폴더가 원인이라는 것을 알게 되었습니다. 너무 어이없는 실수이고, 이런 곳에 시간을 허비했다는 것이 황당하지만… node_modules 폴더가 중복되지 않게 하고, 혹시 이런 의존성 문제가 또 발생하면 이런 기초적인 것부터 확인해야겠다는 생각을 하게 되었습니다.


앞으로도 비슷한 문제나 새로운 에러를 만날 때마다 해결 과정을 기록하고 포스팅해보려 합니다. 여태껏 프로젝트를 하면서 새로 알게된 내용들은 종종 포스팅 했었는데, 정작 문제를 어떻게 해결해나갔는지는 포스팅하지 않았던 것 같아 앞으로는 에러 발생 시 해결 방법을 블로그에 남기는 습관을 들여가면서 더 나은 개발자가 되어가보려 합니다.

PostgreSQL 스키마로 인한 오류 해결하기

· 약 4분

🔍 PostgreSQL 스키마 문제로 인한 relation "user" does not exist 오류 해결하기

백엔드 개발 중 relation "user" does not exist 오류를 만났습니다. PostgreSQL에서는 대소문자나 스키마 이름 등 세부 사항이 쿼리에 영향을 미칠 수 있어, 이를 해결하기 위해 몇 가지를 시도해보았고, 이번 포스팅에서는 문제의 원인과 해결 과정을 정리했습니다.

💡 문제 상황

로그인 API에서 user 테이블을 쿼리하는 과정에서 아래와 같은 오류가 발생했습니다.

Login error: error: relation "user" does not exist

PostgreSQL에서 "relation does not exist" 오류는 특정 테이블이나 컬럼이 존재하지 않거나, 대소문자/스키마 이슈로 인해 발생할 수 있습니다. 저의 경우 테이블 이름이 정확하게 user임에도 불구하고 오류가 발생했기 때문에 다른 가능성을 살펴보았습니다. 물론 user는 예약어이기 때문에 그대로 적어서는 안된다는 것을 잘 알고 있었고, 그래서 큰 따옴표 ( )를 넣어 테이블을 제대로 명시해주었는데, 그래도 해결되지 않았기에 문제를 찾아보았습니다.

🔍 시도해본 해결 방안들

오류의 원인을 찾기 위해 다음과 같은 단계를 거쳤습니다.

  1. 테이블 확인: psql에서 \dt 명령을 통해 user 테이블이 데이터베이스에 실제로 존재하는지 확인했습니다.
  2. 대소문자 문제 해결: PostgreSQL에서는 소문자로만 구성된 테이블명은 쿼리에서 이중 따옴표로 묶어주어야 합니다. 따라서 쿼리문을 'SELECT * FROM "user" WHERE id = $1'로 수정해보았습니다.
  3. 스키마 확인: user 테이블이 public이 아닌 다른 스키마(main)에 만들어두었다는 것을 기억해냈습니다. (db 잘 다룰 줄 몰라서… 새로운 스키마를 만들어야 하는 줄 알고 초기에 main 스키마를 만들어서 그 안에 구현해두었습니다……)

✅ 최종 해결 방법

어쟀든 테이블이 main 스키마에 있었기 때문에 스키마 이름을 명시한 쿼리로 수정하여 문제를 해결할 수 있었습니다.

// repositories/authRepository.js
import { pool } from '../db/db.js';
export const findUserById = async (id) => {
const result = await pool.query('SELECT * FROM "main"."user" WHERE id = $1', [id]);
return result.rows[0];
};

이렇게 스키마를 명시하니 오류가 사라지고 정상적으로 쿼리가 수행되었습니다.


📝 결론

이 오류는 스키마를 잘못 지정해 발생한 것이었습니다. PostgreSQL에서는 특정 테이블이 public 이외의 스키마에 있을 경우 반드시 스키마 이름을 명시해야 한다는 점을 꼭 기억해두어야겠습니다. (다음번에는… public 스키마에 db를 구축해야 할 것 같습니다);;

비밀번호 검증 오류 해결기

· 약 3분

🔐 비밀번호 검증 오류 해결기

로그인 api를 구현한 후 테스트만을 위해 비밀번호를 임의로 11111111로 db에 저장하고 테스트해보았는데, 비밀번호를 제대로 입력했음에도 자꾸 틀린 비밀번호라는 오류가 발생했습니다. bcrypt를 사용함으로써 발생한 문제였는데, 이 문제 해결에 대해 정리해보았습니다.


1. 문제 상황

로그인 시도 중 Invalid password라는 오류가 발생했습니다. 데이터베이스에 저장된 비밀번호가 명백하게 11111111인 것을 확인했음에도 불구하고 오류가 발생했죠. 로그인 로직은 아래와 같았습니다.

const isPasswordValid = await bcrypt.compare(password, user.password);

2. 원인 분석

비밀번호 검증에는 bcrypt.compare 메서드를 사용했는데, 이는 해시된 비밀번호와 평문 비밀번호를 비교하는 방식입니다. 문제는 데이터베이스에 비밀번호가 평문으로 저장되어 있었기 때문에, bcrypt는 이 값을 해시로 인식하지 못하고 오류를 일으킨 것이었습니다.

3. 해결 방법

로그인에 사용될 모든 비밀번호는 데이터베이스에 저장하기 전에 반드시 해싱해야 합니다.

1) 테스트용 비밀번호를 수동으로 해싱

테스트용 비밀번호 11111111을 다음과 같이 bcrypt로 해싱했습니다.

const bcrypt = require('bcrypt');
(async () => {
const hashedPassword = await bcrypt.hash('11111111', 10);
console.log(hashedPassword); // 이 해시된 값을 DB에 직접 저장
})();

2) 회원가입 로직에서 비밀번호 해싱 추가

회원가입 기능이 있다면, 비밀번호를 데이터베이스에 저장하기 전에 아래와 같이 해싱을 추가해주어야 합니다.

const hashedPassword = await bcrypt.hash(password, 10);
// 이후 hashedPassword를 DB에 저장

4. 결론

비밀번호를 검증할 때에는 평문 비밀번호가 아닌 해시된 비밀번호를 사용해야 합니다. 만약 해싱을 잊으면, bcryptInvalid password 오류를 반환합니다.

🖥️React, TypeScript, Tailwind로 Dropdown 구현하기

· 약 10분

📚 정리

🔬 개요

네이버 부스트캠프의 그룹 프로젝트를 진행하면서, Dropdown 컴포넌트를 구현하게 되었다.

평소라면 React를 사용해서 HTML로 드랍다운을 구현하는 것처럼 간단하게 구현했겠지만, 팀의 핵심 가치는 사용자 관점에서의 완성도 였기에 좀 더 디테일한 구현을 시도해보게 되었다.

처음에는 단순하게 이벤트 한두개 정도만 추가하고, 공통 컴포넌트만 뽑아내면 되겠지? 라고 생각하던게 unmount 시에 이벤트를 적용하는 문제를 만나고 나서.. 하루종일 고민하고 탐구하는 작업이 되어버렸다...

그래서 이번 글에서는 Dropdown을 구현하면서 배운 것들과 더불어서 unmount 시에 이벤트를 적용하는 방법에 대해 정리해보려 한다.

🎯 구현의 목표

missing
구현해야만 했던 페이지

내가 구현해야 했던 페이지는 위와 같았다.

missing
구현하고자 했던 컴포넌트

그리고 위에 보이는 Dropdown 컴포넌트가 내가 구현하고자 했던 컴포넌트였다.

🤔 구현시 고려해야 했던 점

앞서 말했듯이 우리 팀은 사용자 관점에서의 완성도를 중요시하고 있었다.

여기에 더해서 기본적인 프로젝트 기간 이후에도 지속적으로 이 프로젝트를 유지보수하면서 끌고 가고 싶다는 생각이 있었다.

이에 따라서 다음의 사항을 고려해야할 필요가 있었다.

  1. 완벽하게 까지는 아니더라도, 사용자 관점에서 시각적인 완성도는 개발기한 내에서 최대한 고려해야 한다.
  2. 프로젝트 이후의 유지보수를 위해서 컴포넌트 구조는 최대한 작게 쪼개야 한다.
  3. 컴포넌트가 재사용 가능하게 만들어져야 한다. (DX 및 협업 관점)

그렇게 이런 원칙을 바탕으로 개발을 진행하게 되었다.

🤔 내가 고민했던 구조

missing
내가 고민했던 구조

내가 고민했던 구조는 위와 같았다.

missing
분해해서 살펴본 구조

이를 조금 더 뜯어보면 위와 같은 구조가 된다.

최대한 작은 컴포넌트로 구성하고자 하였으며, 이를 바탕으로 구현하게 되었다.

🤔 재사용 범위에 대한 고민

컴포넌트를 작게 나누는 것 까지는 좋았는데, 이들을 어디까지 재사용가능하게 할 것인가에 대한 의문이 생겼다.

처음에는 shadcn처럼 구현을 할까 생각을 했었다.

최대한 범용적으로 만들고, 나중에 필요한 정보를 따로 외부에서 주입을 받게 하는 식으로 갈까 하는 고민이 있었다.

다만, 여기서 한 가지가 마음에 걸리게 되었다. shadcn은 진짜 범용적인 목적을 갖고 나온 라이브러리이다. 그리고 당연하게도 이게 개발되기까지 정말 많은 컨트리뷰터들의 기여가 있었을 것이다.

하지만, 프로젝트에서는 이렇게까지 큰 범용성이 필요하지 않았다. 그래서 실제로 기획서 Figma를 살펴보았고, 사용되는 부분을 확인할 수 있었다.

missing
드랍다운이 사용되는 장소 1
missing
드랍다운이 사용되는 장소 2

위의 두 경우에서만 사용되는 것을 확인할 수 있었다.

추후에 뭔가 추가적인 요소가 들어온다고 하지만, 모든 경우를 고려하기 보다는 조금 더 좁혀서 다음과 같은 범위만 고려하면 될 것 같았다.

▸ 버튼의 경우 여러 종류의 아이콘에 대응이 될 수 있어야 한다. 나아가서, 기존의 요소에 영향을 주면 안된다.
▸ 버튼에 텍스트가 들어왔을 때 배치해줄 수 있도록 children으로 받아서 처리하는 로직을 구현한다.
▸ 드랍다운 아이템은 외부로부터 입력받는다.
▸ 드랍다운 아이템에 대한 이벤트 처리도 외부로부터 입력받는다. 본 드랍다운은 wrapper에 대한 이벤트 핸들러만 입력받는다. 여기서 e.target.closest와 같은 이벤트 버블링 방식을 사용한다.
▸ 드랍다운에서의 외부로부터 받는 이벤트는 onClick에만 적용하도록 한다.
Figma에 표시된 디자인은 전부 구현한다. 추가로 사용자 편의성에 대한 부분은 주어진 개발 기한 내에 최대한 고려한다.


위와 같은 생각을 가지고 개발에 착수를 하게 되었다.

🧑‍💻 계층 구조

드랍다운을 구현하기 위해 내가 꾸린 계층 구조는 다음과 같다.

/dropdown
- Dropdown
- Trigger
- Menu
- Item

사실 처음부터 이렇게 나온 것은 아니다.

내 개발 원칙 중 하나인 2번 구현하기라는 게 있는데.. 일단 먼저 빠르게 구현을 해보고, 그 다음에 다시 구현하면서 그때 완성도를 높이자는 마인드셋이다.

하도 개발 기간을 제대로 못맞춰서 내 나름대로 세운 원칙이다.

사실 처음부터 이런 구조가 나왔던 것은 아니었다.

기존에 DropdownHTML이나 Vanilla JS 등으로 구현만 해봤고, React에서는 만들어지는 컴포넌트를 가져다썼었다.

이에 따라, 처음 만들어보게 되는 것이었고.. 호기롭게 시작은 했지만.. 재사용성이 높게 만드는 것이 생각보다 쉽지 않음을 깨달았다.

missing
코드 1
missing
코드 2
missing
코드 3
missing
코드 4

위는 내가 처음에 작성했던 코드들이다. 보여주기 부끄러워서 일부러 굉장히 작게 표시를 했다.

missing
처음에 작성한 구조

그리고 이에 따른 구조는 위와 같았다.

그냥 무턱대고 짜다보니, 나 스스로도 알아보기 어려운 지저분한 코드가 나왔다.

그에 따라서, 구현했던 것을 바탕으로 다시금 갈아엎을 필요가 있었는데 그게 앞서서 보여준 구조이다.

이 과정에서 컴포넌트 컴파운드 패턴이 뭔지 알게 되었고, 각 컴포넌트를 속성으로 넘겨서 사용하는 방법이 있음을 배울 수 있었다.

🧑‍💻 구현 과정