레이블이 Memoir인 게시물을 표시합니다. 모든 게시물 표시
레이블이 Memoir인 게시물을 표시합니다. 모든 게시물 표시

6/24/2023

2차 프로젝트

1차 프로젝트가 예선이라면 2차 프로젝트는 본선이었다. 배운 내용을 최대한 사용해서 가능한 많은 기능을 구현하고 싶었기에 그에 따라 작업량도 많았고 스트레스도 많이 받았지만 결과는 달콤했다. 이번 회고는 기획, 데이터베이스 모델링, 웹소켓, 테스트 코드, 느낀 점으로 나누어 회고를 작성했다.


기획

2차 프로젝트는 직접 기획을 했다는 점에서 1차 프로젝트와 큰 차이가 있다. 단순히 나이키를 바탕으로 한국 10~20대 소비자 타켓으로 기획한 1차 프로젝트와 달리 KREAM의 한정판 판매와 경매라는 특징을 뽑아내 국내 예술품을 판매하는 경매 사이트라는 기획으로 녹여냈다. 사실 기획은 개발자의 영역이 아닌데 굳이 따로 기획을 만들어서 귀찮게 할 필요가 있을까라는 생각이 들었다. 하지만 지금 생각해보면 기획은 참신했고 그로 인해서 우리의 프로젝트가 다른 팀들의 프로젝트보다 더 돋보였던 것 같다.


데이터베이스 모델링

1차 프로젝트와 마찬가지로 데이터베이스 모델링은 역시나 힘들었다. 경매라는 개념을 구체적으로 생각한 적이 없기에 필요한 테이블을 만들고 관계를 설정하는데 많은 시간을 소비했고 완벽까지는 아니더라도 필수적인 부분을 고려했다고 생각했지만 이 역시 착각에 불과했다. 프로젝트를 진행하면서 필요한 컬럼 추가, 제약 조건 설정 및 해제 등 문제들이 터져나왔다... 팀원분과의 의논을 통해서 수정했지만 1차 프로젝트의 실수가 다시 그대로 표출되서 너무 아쉬웠다. 단순히 이론을 안다고 되는 것이 아니라 수많은 연습과 시행착오를 거쳐 경험이 중요한 역할을 하는 작업인 것 같다.


웹소켓

프로젝트의 핵심 중의 핵심인 웹소켓! 기획에 따라 경매는 실시간으로 진행되어야 한다. 이를 보자마자 소켓 통신이 바로 생각이 났지만 사실 2주라는 시간 내에 제대로 구현할 수 있을지 확신이 들지 않았다. 멘토들에게 기획을 설명하는 세션에서 멘토님 한 분이 실시간이면 두 가지 방법이 있는데 그 중 하나가 웹소켓 프로토콜이었다. 하지만 그 멘토님도 시간이 많지 않고 어려울 수 있기 때문에 프론트엔드에서 setInterval 함수를 사용하는 방안을 제시하셨다. 이 경우 실제 실시간은 아니지만 1초마다 페이지를 갱신해서 경매가 실시간이라는 착시를 줄 수 있다. 하지만 개인적으로 네트워크에 관심이 많고 새로운 기술을 적용하고 싶었기에 주말 동안 과연 구현할 수 있을지에 대해서 연구했다. 그리고 웹소켓으로 구현하기로 결정내렸다! 

공식 문서와 블로그를 보면서 코드를 작성했는데 구현 자체는 크게 어렵지 않았지만 문제가 발생했다. 상품마다 오직 하나의 경매방을 만들어야 하는데 경매방을 하나만 생성되도록 구현한 것이었다. 이 문제는 공식 문서의 HTTP/HTTPS를 공유하는 다수의 웹 소켓 서버를 만드는 코드를 참조해서 이를 수정할 수 있었지만 지금 생각해도 많이 허술하기 때문에 리팩토링 시 많은 수정이 필요할 것 같다. 이렇게 해서 구현이 완료될 줄 알았는데 또 다른 문제가 남아있었다. 바로 MVC에 패턴에 맞게 웹 소켓 코드를 분해하는 것이었다. 하지만 이는 프로젝트 발표 전까지도 해결할 수 없었다. 여러 시도를 했지만 해결할 수 없었고 검색을 계속 한 끝에 ExpressJS와 함께 사용되는 웹 소켓 패키지가 있었는데 그 패키지가 2019년 이후부터 업데이트가 되어있지 않아 사용하지 않았다(내가 사용한 패키지는 ws 패키지이다.).

발표날 하루 전 시연 연습을 하는데 큰 문제가 발생했다. 바로 상품의 경매방에 모든 참가자들이 이상하게 같은 식별자를 할당받아서 데이터베이스에서 항상 같은 튜플에 값이 갱신되었다. 아쉽게도 오류의 원인을 몰라서 코드 수정없이 시연을 했는데 겉으로 보기에 문제는 없어서 깔끔하게 끝났다. 발표 이후 공식 문서를 꼼꼼히 읽은 결과 handleUpgrade 메소드에서 참가자 아이디를 할당해야 한다는 것을 알게 되었고 문제를 해결했다. 공식 문서는 어렵지만 역시 답은 공식 문서에 있는 것 같다.


테스트 코드

1차 프로젝트에서 예외 조건에 대해서 적었는데 2차 프로젝트에서는 이를 방지하기 위해 테스트 코드 작성법을 배웠다. API를 만들고 Postman으로 단순히 몇 개의 케이스를 테스트하는 것은 소프트웨어가 정상적으로 작동하는지 검증하는데 결코 충분하지 않다. 테스트는 크게 단위 테스트, 통합 테스트, 인수 테스트로 구성되는데 주로 이 중에서 클래스와 함수를 테스트하는 단위 테스트 코드를 중점적으로 작성했다. Jest 패키지를 사용해서 단위 테스트 코드를 작성했는데 생각보다 시간도 많이 걸렸고 상당히 어려웠다. 세션을 진행하신 멘토님이 API 코드 작성보다 테스트 코드 작성이 더 많은 시간이 걸리고 어렵다고 하셨는데 그 말이 무슨 말인지 이해할 수 있었다. 카카오 로그인, 웹 소켓과 같은 기능은 테스트 코드 작성 방법도 몰랐고 시간이 많지 않아서 테스트 코드를 작성할 수 없었다. 리팩토링에는 모든 API에 대해서 테스트 코드를 작성할 것이다.


느낀 점

1차 프로젝트에서는 PM(Project Manager)를 맡았고 2차 프로젝트는 PM(Product Manager)를 맡았다. 이름 그대로 프로젝트 관리가 아니라 제품/서비스에 대해서 분석하고 파악해서 기획안을 제시하고 프로젝트 시연을 하는 역할이다. KREAM을 분석한 후 경매와 한정판 판매라는 특성을 기반으로 국내 예술품 경매라는 기획을 도출하게 되었다고 멘토님들에게 말했는데 이 점을 높이 평가하셨다. 발표날 아침부터 긴장했지만 이상하게 발표 시작 5분 전부터 마음이 편해졌다. 더 이상한 것은 발표를 하는데 내가 이거를 즐기고 있다는 느낌을 받았다. 이번 프로젝트에서 느낀 점은 하기 싫은 것 혹은 어렵다고 느끼는 작업이 있으며 도전 정신을 가지고 해야 한다는 것이다. 영어 회화 공부를 위해서 구독한 라이브 아카데미의 빨모 선생님이 자기는 하기 싫다는 느낌을 받으면 오히려 한다고 말씀하셨는데 나도 그게 무슨 말인지 이해할 수 있는 프로젝트였다.

3/19/2023

1차 프로젝트

약 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차 프로젝트에서는 프론트엔드를 위해서 늦게까지 같이 작업할 것이다.