약 2주간 진행되었던 1차 프로젝트가 3월 17일 발표로 종료되었다. 혼자서 진행했던 개인 프로젝트와 달리 백엔드만 전담했으며 프론트엔드와 협업을 했는데 배운 점과 느낀 점이 많은 프로젝트였다. 크게 데이터베이스 모델링, API, 의사소통, 회고로 나누어서 말해보겠다.
데이터베이스 모델링
데이터베이스 모델링은 프론트엔드와의 의사소통을 제외하면 단연코 가장 중요한 작업이라고 말할 수 있다. 데이터베이스 세션에서 시니어 개발자들이 참여하고 주니어 개발자는 API 구현을 주로 한다고 들었는데 그 이유를 확실하게 느꼈다. 원래 이 작업은 사용자의 요구사항을 분석(요구사항 도출 → 요구사항 분석 → 요구사항 기록)한 다음 데이터에 대한 요구사항을 정의하고 분석하여 추상화하는 데이터 모델링 과정을 거치는데 일반적으로 세 단계로 진행된다.
즉, 데이터에 초첨을 맞추어 개별적 데이터의 특징(데이터 타입, 속성, 관계, 제약조건 등)을 분리하는 개념적 데이터 모델링, 특정 DBMS에 맟추어 데이터를 표현(스키마)하는 논리적 데이터 모델링, 논리 스키마는 데이터의 논리적 구성만 명시하고 있기 때문에 데이터베이스 파일의 물리적 저장 방식(파일 구성, 인덱스, 접근 경로 등)을 결정하는 물리적 데이터 모델링을 거친다.
나이키를 참고한 이번 프로젝트에서 사실 첫 단계인 사용자 요구사항 분석부터 제대로 하지 못했다. 이를 분석하는 과제가 있었지만 심도있게 하지 못했으며 ER 모델에 따라 ERD를 사용해서 개념적 데이터 모델링을 거치고 ERD를 관계형 모델의 스키마로 변환하는 논리적 데이터 모델링을 하는데 급급했다. 사용자 요구사항 분석을 제대로 하지 않았기 때문에 개체 집합 구성 개체 집합 사이의 사상수를 표현하는 관계 집합, 키 속성 제약조건 때문에 고생을 했다. 주문 내역을 표현하는 테이블이 있어야 주문 취소 시 상품의 재고 수를 수정할 수 있는데 주문 API 구현을 하는 시점에 가서야 생각이 나서 주문 내역 테이블을 추가했고 상품은 여러 개의 이미지를 가질 수 있다는 점을 간과해서 상품 테이블의 이미지 속성을 제거하고 일대다 관계를 이루는 이미지 테이블을 추가했다. 또한, 옵션 테이블의 경우 상품 아이디, 색상, 크기가 UNIQUE 키를 구성해야 하는데 이를 상품을 추가하는 과정에 알게 되어서 수정하는데 시간을 허비했다.
단순하게 정규화가 많을수록 좋다는 것이 아니라는 것을 깨달았는데 처음에는 색상과 크기가 상품과 다대다 관계를 이루기 때문에 중간 테이블을 사용해서 각각 분리했다. 하지만, 리뷰 세션에서 정규화의 정도는 기획에 따라 천차만별이라고 한다. 이를 수정했음에도 장바구니 목록을 가져오는 쿼리문을 작성하는데 JOIN문을 7번 사용했다. 발표 후 들은 세션에서 이렇게 JOIN문을 과도하게 사용해야 할 경우 DAO에서 2개의 함수를 작성하고 Service에서 두 함수를 호출하고 결과를 결합해서 사용할 수 있다는 점을 배웠으며 2차 프로젝트에서 만약 JOIN문을 많이 사용해야 할 경우 실제로 적용할 생각이다.
API
API 작성은 데이터베이스 모델링과 달리 큰 어려움은 없었지만 코드를 더 간결하게 작성하는 방법을 배웠고 예외 조건을 항상 고려해야 한다. 일단 가장 먼저 떠오르는 기능은 상품 목록 API이다. 처음에는 검색, 필터, 정렬 함수를 각각 구현해야 한다고 생각했는데 코드 리뷰를 받고 나니 그게 아니었다. 쿼리 매개변수에서 이 모든 것을 받아온 다음에 조건에 맞게 쿼리문을 만드는 쿼리 빌더를 사용해야 한다는 것이다. 예제 코드가 처음에는 약간 이해하기 어려웠는데 직접 사용을 몇 번 해보고 나니 코드 작동 방식을 알게되었다. 작성한 코드를 보자!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 | // Controller const listProduct = catchAsync(async (req, res) => { const { limit = DEFAULT_LIMIT, // DEFAULT_LIMIT == 10 offset = DEFAULT_OFFSET, // DEFAULT_OFFSET == 0 search = "", sort = "date", ...filters } = req.query; const data = await productService.listProduct( limit, offset, search, sort, filters ); return res.status(200).json({ data }); }); // Service const listProduct = async (limit, offset, search, sort, filters) => { // 필터가 카테고리일 경우, LIKE를 사용하기 때문에 큰따옴표 제거 if (filters.hasOwnProperty("category")) { let value = filters["category"]; value = value.replaceAll('"', ""); filters["category"] = value; } // LIKE를 사용하기 때문에 큰따옴표 제거 search = search.replaceAll('"', ""); sort = sort.replaceAll('"', ""); return await productDao.listProduct(limit, offset, search, sort, filters); }; // Model const listProduct = async (limit, offset, search, sort, filters) => { // 검색, 필터, 정렬 조건을 만드는 쿼리 빌더 const filterQuery = new ProductQueryBuilder( limit, offset, search, sort, filters ).build(); return await dataSource.query( ` SELECT p.id AS id, p.name AS name, p.price AS price, IF(p.discount_rate > 0, p.price * (1 - p.discount_rate / 100) , p.price) AS discounted_price, p.gender, IF(p.is_new = 1, "신상품", "") AS new, COUNT(DISTINCT(o.color)) AS color_count, p.discount_rate AS discount_rate, DATE_FORMAT(p.release_date, "%Y-%m-%d") AS release_date, ij.url AS images, pcj.category AS categories FROM products AS p JOIN options AS o ON o.product_id = p.id JOIN ( SELECT product_id, JSON_ARRAYAGG(i.url) AS url FROM images AS i GROUP BY product_id ) ij ON ij.product_id = p.id JOIN ( SELECT product_id, JSON_ARRAYAGG(c.name) AS category FROM product_categories AS pc JOIN categories AS c ON c.id = pc.category_id GROUP BY product_id ) pcj ON pcj.product_id = p.id ${filterQuery} ` ); }; class ProductQueryBuilder { constructor(limit, offset, search, sort, filters) { this.limit = limit; this.offset = offset; this.search = search; this.sort = sort; this.filters = filters; // 검색 타입: 상품 이름, 상품 설명, 상품 색상, 상품 카테고리, 상품 크기 this.searchTypes = [ `p.name`, `p.description`, `o.color`, `category`, `p.gender`, `o.size`, ]; } // 정렬 기본값은 최신순 sortFilterBuilder() { switch (this.sort) { case "date": return `ORDER BY p.release_date DESC`; case "high": return `ORDER BY p.price DESC`; case "low": return `ORDER BY p.price ASC`; default: return `ORDER BY p.release_date DESC`; } } // 검색 타입에 따라서 검색 쿼리를 만드는 함수 searchFilterBuilder(keyword) { const searchTypesLength = this.searchTypes.length; let clause = []; for ( let searchTypeIndex = 0; searchTypeIndex < searchTypesLength; searchTypeIndex++ ) { clause.push(this.searchTypes[searchTypeIndex] + ` LIKE "%${keyword}%"`); } if (clause.length !== 0) { clause = `${clause.join(` OR `)}`; } clause = `WHERE (` + clause; return keyword === `` ? `` : `${clause})`; } // 다음 4개는 필터 타입 categoryFilterBuilder(category) { return `category LIKE "%${category}%"`; } genderFilterBuilder(gender) { return `p.gender IN (${gender})`; } sizeFilterBuilder(size) { return `o.size IN (${size})`; } colorFilterBuilder(color) { return `o.color IN (${color})`; } orderByBuilder() { return this.sortFilterBuilder(); } limitBuilder() { return `LIMIT ${this.limit}`; } offsetBuilder() { return `OFFSET ${this.offset}`; } groupByBuilder() { return `GROUP BY p.id`; } // 필터 쿼리를 만들고 검색 쿼리를 연결하는 함수 buildWhereClause() { const builderSet = { categoryFilter: this.categoryFilterBuilder, genderFilter: this.genderFilterBuilder, sizeFilter: this.sizeFilterBuilder, colorFilter: this.colorFilterBuilder, }; let searchClause = this.searchFilterBuilder(this.search); // 순회하면서 필터 타입에 맞는 함수를 호출 let filterClause = Object.entries(this.filters).map(([key, value]) => { switch (key) { case "category": return builderSet["categoryFilter"](value); case "gender": return builderSet["genderFilter"](value); case "color": return builderSet["colorFilter"](value); case "size": return builderSet["sizeFilter"](value); } }); if (filterClause.length !== 0) { filterClause = `${filterClause.join(" AND ")}`; if (searchClause.length !== 0) { filterClause = ` AND (${filterClause})`; } else { filterClause = `WHERE ${filterClause}`; } } const completeClause = searchClause + filterClause; return completeClause; } // 검색, 필터, 정렬이 포함된 쿼리 build() { const filterQuery = [ this.buildWhereClause(), this.groupByBuilder(), this.orderByBuilder(), this.limitBuilder(), this.offsetBuilder(), ]; return filterQuery.join(" "); } } | cs |
쿼리 빌더를 통해서 코드가 훨씬 간결해졌고 가독성도 향상되었다. 만약 쿼리 빌더는 사용하지 않았으면 if문으로 가득한 매우 지저분한 코드로 화면이 가득찼을 것이다.
예외 조건은 이를테면 경로 매개변수에 해당하는 상품 혹은 장바구니라고 할 수 있다. 기능 구현 초반에 바로 생각이 나야하는데 이상하게 나지 않았고 상품, 장바구니 기능 구현 이후에 추가했다. 처음에는 그냥 경로 매개변수에 해당하는 상품 혹은 장바구니가 데이터베이스에 존재하는지 여부만 판별했는데 뭔가 부족한 느낌이 들어서 숫자인지 확인하는 정규식을 추가했다. 사실 이렇게 해도 예외 조건은 항상 발생할 수 있다. 이 때문에 테스트 코드를 반드시 작성해야 하는데 테스트 코드 세션이 1차 프로젝트 이후에 있어서 이번 프로젝트에서는 생략했다.
만약 시간이 있었다면 비로그인 장바구니, 장바구니 자동 삭제 기능을 구현하고 싶었다. 물론 시간은 당연히 부족했다! 비로그인의 경우 세션과 쿠키를 사용하면 구현할 수 있다고 검색으로 찾아는데 실제로 어떻게 코드로 구현하는지는 모르겠다. 또한 장바구니 자동 삭제 기능은 CRON과 시간 기반 작업 스케줄러를 사용하는데 이 역시 구체적인 구현 방법은 더 알아봐야 한다.
의사소통
이번 프로젝트에서 가장 중요하다고 느낀 점이다! 특히 프론트엔드와의 의사소통은 프로젝트 전체를 좌지우지 한다고 해도 과언이 아닐 것이다. 의사소통의 부재는 치명적인 실수로 이어졌다. 1번째는 회원가입과 로그인 통신 연결에서 발생했다. 프론트엔드의 페이지 구성을 보지 않고 회원가입과 로그인 API를 만들었는데 알고보니 이메일로 사용자 중복 확인 API가 필요했다. 임시처방으로 금방 만들었지만 프론트엔드도 문제가 발생했다. 이 때문에 통신 테스트가 하루 연기되는 상황이 발생했다. 2주번째는 완전히 나의 잘못인데 관리자 기능이 기획 단계에서 없었는데 이를 인지하지 못하고 그냥 상품 추가 기능을 구현했는데 이 때문에 상당한 시간을 낭비했다. 다행스럽게도 이 이후로 프론트엔드가 구성한 페이지를 보면서 API를 구현했기에 더 이상의 의사소통 부재로 인한 큰 문제는 없었지만 왜 멘토님들이 의사소통이 그렇게 중요하다고 말하는지 뼈져리게 느꼈다.
느낀 점
PM(Project Manager)를 자청해서 전반적인 프로젝트 진행 상황을 관리했는데 프론트엔드를 잘 모르다보니 솔직히 말해서 프론트엔드 부분이 얼마나 진행되고 있는지 잘 알 수가 없었으며 단지 해당 작업의 종료 여부로만 진행 사항을 판단했다. 프론트엔드에 대한 기본적인 지식 정도는 가지고 있어야 한다는 점을 느꼈고 처음에는 굉장히 못했는데 1차 리뷰 세션을 통해서 나름대로 발전했다. 단순하게 스탠드업 회의를 하는 것에 그치치 않고 회의록에 어제와 오늘을 나누어서 작업을 기록했고 공유라는 목록을 만들어서 프론트엔드가 받아야 할 키, JSON 형식, 정규식 등을 공유했다. 그리고 달성율 항목을 통해서 프론트엔드와 백엔드가 작업을 순조롭게 진행하고 있는지 막히는 부분이 있는지를 판별할 수 있었다. 다만 아쉬운 점은 작업에 난이도를 설정하지 않았다는 것이다. 2차 프로젝트에서는 만약 PM을 다시하면 개인적으로 이를 적용하고 싶다. 우리팀만 프론트엔드가 2명이라서 개인적으로 느끼는 업무량이 상당했음에도 불구하고 프론트엔드 분들이 정말 새벽까지 열심히 해주셔서 만족스러운 결과를 이루었다. 발표 전날은 통신 연결 테스트를 위해서 새벽까지 같이 작업을 했는데 그 전에는 일찍가서 미안한 마음이 크다(백엔드가 없으면 테스트를 할 수 없으니까!). 2차 프로젝트에서는 프론트엔드를 위해서 늦게까지 같이 작업할 것이다.
댓글 없음:
댓글 쓰기