최근 주어진 요구사항에 맞게 구현하는 프로젝트를 하나 진행했는데, 그동안 잘 만들어진 프레임워크에 익숙해져 뭔가 착각을 하고 있었던 것 같다. 또 타입스크립트를 사용하면서 단순한 형태의 타입 시스템만 이용해 어중간한 코드를 작성하고 있었다는 생각이 들었다. 그래서 이 글은 이펙티브 타입스크립트를 읽으며 타입스크립트를 어떻게 더 타입스크립트답게 사용할 수 있을지 고민? 회고? 하는 글이다.
아이템 33. string 타입보다 더 구체적인 타입 사용하기
type RecordingType = 'studio' | 'live';
// OR
enum RecordingType {
STUDIO = 'studio',
LIVE = 'live',
}
타입스크립트에서 문자열 타입인 string 외에도 유니온으로 문자열 리터럴을 타입으로 사용할 수 있다. 이 경우 유니온에 포함된 문자열 리터럴에 해당하는 값만 가질 수 있도록 타입 체커가 검사해 실수를 막을 수 있다. 또는 열거형을 통해 제공되는 상태를 제한할 수 있는데 진행했던 프로젝트를 다시 살펴보니 이 부분을 놓쳤던 것 같다. (적어도 enum은 평소에도 잘 사용했던 키워드인데 왜 놓친 걸까..)
아이템 13. 타입과 인터페이스 차이점 알기
타입스크립트에서 type과 interface는 유사한 점이 많지만 동일한 것은 아니다. 일단 유사한 점은 두 키워드 모두 인덱스 시그니쳐, 함수 타입 선언, 제네릭, 구현(implements) 등 명명된 타입에 대해선 차이점이 없다. 또 두 키워드는 서로를 확장할 수도 있다. 다음은 두 키워드의 차이점으로 유니온 타입은 있지만 유니온 인터페이스는 없다. 그래서 복잡한 유니온의 확장은 타입만 가능하다. 그래서 일반적으로 type은 interface보다 쓰임새가 많은데 앞서 설명했듯이 type은 유니온이 될 수도 있고, 매핑된 타입, 튜플 또는 조건부 타입 같은 고급 기능에 활용될 수도 있다.
interface A {
a: string;
}
interface A {
b: number;
}
const a: A = {
a: 'hello',
b: 20,
} // ok
인터페이스의 경우는 보강을 지원하는데 위 코드는 선언적 병합으로 점진적으로 확장될 수 있는 형태라고 볼 수 있다. 이 부분은 타입과 인터페이스의 차이점도 중요하지만 이번 프로젝트를 진행하면서 컨벤션을 일괄적으로 유지하지 않고 이곳저곳에 I(인터페이스), T(타입)을 붙이고 안 붙이고를 반복했다. 일단 이펙티브 타입스크립트에선 이런 접두사를 붙이는 것은 C#에서 비롯된 관례로 현재는 지양해야 할 스타일로 소개된다.
고민
책을 읽으면서 이해한 내용을 간단하게 표현하면, "라이브러리 만들거 아니면 type으로 만드는 게 좋아"였다. 인터페이스의 보강은 라이브러리를 사용하는 사용자 입장에서 직접 접근할 수 없는 라이브러리 코드라도 확장을 가능하게 하지만 그게 아니면 코드가 복잡해지고 파일 간의 관계성으로 인터페이스의 구조를 예측하기 어렵기 때문이라 이해했다. 하지만 개인적으로 interface는 구조를 나타내고 type은 하나의 값에 대한 형태를 의미한다는 느낌이 강해서 어떤 식으로 사용할지 고민해 볼 필요가 있을 것 같다.
아이템 39. any를 구체적으로 변형해서 사용하기
타입스크립트를 공부하면서 느낀 any는 필요악이라고 개인적으로 생각한다. any는 타입스크립트의 주목적인 타입 시스템을 무너트려 any가 적용되는 구간에서 타입스크립트 존재 의미를 없애는 키워드다. 그렇다고 any를 없애면 그건 그것대로 또 문제인 게 코드상으로 당연하지만 경우에 따라서 타입이 다르다고 오류가 나는 경우도 왕왕 있기 때문이다. 그렇기 때문에 any를 다루는 개발자가 조심히 다루지 않으면 any의 저주는 코드 이곳저곳에 뻗어나가서 타입 시스템을 무너트리게 될지도 모른다.
export async function query(query: string, params: any | any[]) {
// ...중략
}
당시 코드를 작성할 때 내 생각은 아마 이랬을 거라 예상된다. "인자로 뭐가 넘어올지도 모르니깐 any로 받고, 하나가 아니라 배열로 넘어올지도 모르니깐 any[]도 받자...! 역시 난 다양한 상황을 고려할 줄 아는 멋진 개발자야!"라는 바보 같은 생각을 했을 것 같다. 이 경우에 내가 선택했어야 할 타입은 any[]라고 생각된다. any는 정말 모든 타입을 다 받을 수 있지만 any[]는 적어도 넘어올 인자가 배열이란 약속을 하고 있고, 또 나는 항상 배열을 넘기고 있기 때문에 any는 타입 체커를 방해하는 용도 그 이상이기 때문이다. (any 타입을 가진 매개변수 params는 해당 함수 몸체 전부에 영향을 끼치기 때문에 진짜 그 이상으로 문제가 될 수 있다..)
무지성 import
이번 프로젝트를 하면서 많이 느낀 건 타입스크립트 프로젝트 구조를 깔끔하게 만드는 방법이 많이 미약하다는 것을 느꼈다. 특히 모듈화를 어떻게 하는지에 대해서 많이 부족했다는 생각이 든다.
import A from './commands/A';
// ...중략
import Y from './commands/Y';
import Z from './commands/Z';
예를 들어 이번 프로젝트에서 커맨드 패턴을 적용하는 부분이 있었는데 각 커맨드 객체를 위와 같이 import 하여 사용했다. 모두 같은 폴더에 있는 동일한 유형들이지만 각기 다른 유형의 모듈처럼 다들 분리되어 import 하고 있었다. 이렇게 하다 보니 import가 지저분해지고 같은 유형의 모듈이 무엇인지 파악하기 힘들어지게 된다.
// index.ts
import { A } from './A';
import { B } from './B';
// ...중략
import { Y } from './Y';
import { Z } from './Z';
export {
A, B, Y, Z
}
// utils.ts
import { A, B } from './commands';
그래서 분산된 모듈을 하나로 모아 외부로 export 해 분리된 모듈이지만 하나의 import로 묶이도록 수정했다.
절차지향적 개발
프로젝트를 하면서 ORM이 아닌 pg 또는 mysql2 같은 데이터베이스 클라이언트 라이브러리를 사용했는데, 예전이라면 익숙했던 방식이 최근 ORM에 너무 익숙해져서 곤란했다. 과거의 나였다면 당연하게 데이터베이스를 위한 클래스를 만들어 사용했을 텐데 어째 이런 실수를...
import mysql from "mysql2/promise";
import { dbConfig } from "../../config/database";
export const pool = mysql.createPool(dbConfig);
export async function query(query: string, params: any | any[]) {
const d = await pool.getConnection();
try {
d.beginTransaction();
const [result, error] = await d.query(query, params);
d.commit();
return result;
} catch (e) {
d.rollback();
throw e;
} finally {
d.release();
}
}
샘플 코드에서나 쓰일 법한 코드를 아무런 개선도 없이 사용했던 과거의 나에게 꿀밤을 먹이고 싶은 코드다. (이 코드 아래에는 여러 개의 SQL을 수행하기 위한 queryAll이란 완전 똑같이 생긴 코드가 있다...😱)
class MySqlClientImpl {
private pool: mysql.Pool;
constructor(
host: string,
port: number,
user: string,
password: string,
database: string
) {
this.pool = mysql.createPool({
host,
port,
user,
password,
database,
});
}
query(sql: string, params: any[]) {
return this.pool.query(sql, params);
}
// ...
}
// Node.js 모듈 시스템에 의해 캐시된 인스턴스를 받음 (특별한 경우가 아니면 싱글톤이 유지됨)
export const mysqlClient = new MySqlClientImpl("", 3306, "", "", "");
적어도 위 코드처럼 연결 상태를 자신이 관리하는 클래스를 만들었다면 좀 더 유연한 처리를 할 수 있도록 확장성이 있는 구조로 만들었어야 했는데...
잘못 사용한 상태 코드
export const deleteMovie = (
req: DeleteMovieReqRoute,
res: Response,
next: NextFunction
) => {
// ...중략
// res.status(201).json({ ok }))
res.status(HttpStatusCode.NoContent).send();
};
개발 당시에 이 부분을 신경 쓴다고 생각했지만 HTTP 상태 코드를 매우 잘못 사용하고 있었다. 무려 삭제에 해당하는 요청에 성공 시 201(Created)을 돌려주고 있었던 것.. 이 부분은 테스트 코드와 숫자가 아닌 명시적인 상태를 나타내는 상수(예를 들어 ResponseStatus.Ok 같은..)를 만들어 사용했어야 했다고 생각된다. 항상 이런 상수는 의미 파악이 어려우니 따로 빼내어 이름을 붙여주라고 많이 봤지만 실천하지 못한 잘못이 크다. 그리고 HTTP 관련 지식과 Restful API를 제대로 이해하지 못한 상태라고 생각된다. 이 부분은 이후 학습을 통해 채워나가도록 하자.
데이터베이스 지식 부족
프로젝트를 하면서 좀 더 프로덕션 환경을 고려해 Soft delete, 갱신 일자 기록을 적용해봤는데 데이터베이스 쿼리 작성에 부족한 부분이 많아 구현에 매끄럽지 못한 부분이 많았다.
update(entity: MovieEntity): Promise<boolean> {
entity.renewalUpdate(new Date());
return this.executor.executeQuery(new UpdateMovieQueryCommand(entity));
}
먼저 갱신 일자 기록 부분은 해당 칼럼에 MySQL의 ON UPDATE CURRENT_TIMESTAMP를 적용하면 쉽게 구현할 수 있는데 나는 update 같은 메서드에 직접 대상 엔티티의 갱신 일자를 의미하는 프로퍼티에 현재 시간을 넣어주고 저장했다. 단점으로는 갱신 관련 메서드가 많아질수록 갱신하는 코드를 빠트려 실수하기 쉽고(갱신을 하지 않았다고 저장이 안 되는 것은 아니기 때문에 논리 오류를 발생시킨다.), 장점으로는 데이터베이스에 의존하지 않는다고 생각된다.
Generator
ES2015의 Generator는 마치 Java나 코틀린의 시퀀스와 같이 수행된다. Javascript에서 Array.prototype.map(), Array.prototype.filter(), etc... 같이 side-effect 없이 배열을 순회할 수 있는 메서드들을 제공해주지만 이 메서드들은 단순한 순회로 지연 수행이나 무한방출, 병렬 처리 같은 고급진 동작을 하지 못한다. 이 부분은 강의를 들어서 장점을 충분히 이해했지만 막상 프로젝트에 적용하지 못해서 너무 아쉬운 부분이다.
협업하기 좋지 못한 코드
확장을 고려하지 않은 방식
기술적인 고민없이 기술 선택
주석
처음 개발을 접했을 때 주석을 잘 다는 개발자가 협업을 잘하는 개발자라는 소리를 많이 들었다. 하지만 대부분의 프로젝트를 혼자 하다 보니 주석을 안다는 코드 작성에 익숙했고, 코드에서도 가장 좋은 코드는 주석으로 표현 안 해도 읽기 쉬운 코드이고, 주석은 코드의 변화에 따라가지 못하는 순간 방해 요소가 된다는 말이 떠올라 더욱 주석을 달지 않은 것 같다. 하지만 내 실력은 주석을 안 달아도 읽기 쉬운 코드를 짤 수준은 아니고, 책에서 말한 주석과 코드의 불일치에 대한 주의는 주석이 코드의 동작을 설명할 경우에 발생하는 문제점이고, 함수&매개변수 설명 정도의 내용은 알고리즘이 변해도 영향이 없기 때문에 지향해야 할 습관이라 생각된다.
테스트
Nest.js 도움 없이는 제대로된 테스트 환경도 구성 못하는 바보 멍청이!! 테스트 관련해서는 따로 글을 작성해야겠다..
'프로젝트 > 개발' 카테고리의 다른 글
Redisson 분산락 (1) | 2024.01.02 |
---|---|
동시성 이슈 없는 조회수⏳ 기능 개발 고민 (0) | 2023.12.05 |
I am메모리에요~ 🤗 (0) | 2023.11.10 |
첫 번째, TL;DR (0) | 2023.11.10 |