인터넷 프로토콜은 송신 호스트와 수신 호스트가 패킷 교환 네트워크에서 정보를 주고받는데 사용하는 정보의 약속(프로토콜)이고, OSI 네트워크 계층에서 호스트의 주소지정과 패킷 분할 및 조립 기능을 담당한다.
예를 들어, 외국에 사는 친구에게 편지를 보낸다고 가정해보면, 편지 안에는 편지의 내용이 들어있고, 편지 봉투에는 보내는 사람의 이름, 받는 사람의 이름 그리고 수신자와 송신자의 주소 정보가 포함되어 있다. 이를 IP에 빗대어 표현해보면 보내는 사람의 주소는 출발지의 IP, 받는 사람의 주소는 도착지의 IP 그리고 편지안의 내용은 전송하고자 하는 데이터라고 할 수 있다.
IP의 역할
- 지정한 IP 주소에 데이터를 전달한다.
- 패킷(Packet) 이라는 통신 단위로 데이터를 전달한다.
IP Packet
전송하고자 하는 데이터의 형식을 Packet 이라는 하나의 단위로 구성해서 출발지의 IP에서 도착지의 IP까지 패킷을 통해서 전달하고자 하는 데이터를 전달한다. 우편물 박스라고 생각하면 편하다.
IP Packet의 구조
실제로는 이렇게 많은 정보들을 담은 상자이다. Source IP Address + Destination IP Address 부분이 수신자/송신자의 IP주소라고 할 수 있고, 그 외에 TTL, CheckSum, 전체 길이 등 다양한 정보가 있다. 섬세하게 까보고 싶지만 이건 조만간 Network 관련해서 책을 사서 읽을 계획인데 그때 한번 정리해보려고 한다.
IP의 한계
(1) 비연결성
패킷을 받을 대상이 없거나 대상이 서비스 불능 상태여도 패킷을 전송하는 특성이 있다. 보내고자 하는 대상 서버가 패킷을 받을 수 있는 상태인지 없는 상태인지를 보내는 서버 입장에서는 알 수가 없다. 하지만 보내는 사람은 신경 안쓰고 그냥 무작정 보낸다는 것이다.
(2) 비신뢰성
중간에 패킷이 유실되어도 IP에서 전송하는 데이터가 정확하게 전달이 되었는지 확인하지 않는다. 패킷이 중간에 소실되어도 어쩔 수 없다는 한계점이 있다. 택배를 상대방에게 보냈는데 택배기사가 택배를 잃어버린 것과 똑같은 상황이다.
그리고 패킷의 길이가 너무 길 경우에는 쪼개서 전송하기도 한다. 이 때 패킷이 상대방에게 순서대로 전송된다는 보장이 없다. 예를 들어, Hello, world! 라고 보냈는데 world! Hello, 라고 올 수도 있는 상황이라는 것이다.
(3) 프로그램 구분
같은 IP를 사용하는 서버에서 통신하는 애플리케이션이 여러개일 경우 구분하기가 어렵다. 예시를 들어보면, 하나의 PC에서 인터넷으로 게임도 하고 노래도 듣는다면 어떤 프로그램인지 알고 전송할 수 있는지에 대한 이야기이다.
안녕하세요! 현재 NodeJS로 백엔드 취업을 희망하고 있는 이강욱이라고 합니다. 최대한 블로그 포스팅을 보시는 분들에게 자세한 정보와 즐거움을 드리기 위해 노력하며 포스팅을 쓰고 있습니다 ㅎㅎ
읽어보시고 문제가 있거나 잘못되었다고 생각되는 부분들은 언제든 댓글로 작성해주시면 최대한 빠르게 조치하겠습니다!
감사합니다.
Helmet은 웹서버를 외부의 공격으로부터 보호해주는 대표적인 Node 보안 모듈이다. Helmet 모듈은 다양한 HTTP Header를 자동으로 설정해줘서 서버 애플리케이션의 보안을 강화해준다.
// helmet install
npm install helmet
참고로 helmet은 express의 미들웨어 모듈이고 여러 미들웨어 모듈을 합쳐놓은 미들웨어 패키지 모듈이다. 다시 말해서, express 기반 애플리케이션에서 HTTP Response Header를 설정하는 여러 개의 작은 미들웨어 함수 유형 모음이라고 생각하면 된다.
// main.ts
import helmet from 'helmet'
...
app.use(helmet());
// 다양한 옵션들을 적용할 때 사용할 수 있는 기능들
app.use(helmet.contentSecurityPolicy());
app.use(helmet.crossOriginEmbedderPolicy());
app.use(helmet.crossOriginOpenerPolicy());
app.use(helmet.crossOriginResourcePolicy());
app.use(helmet.dnsPrefetchControl());
app.use(helmet.expectCt());
app.use(helmet.frameguard());
app.use(helmet.hidePoweredBy());
app.use(helmet.hsts());
app.use(helmet.ieNoOpen());
app.use(helmet.noSniff());
app.use(helmet.originAgentCluster());
app.use(helmet.permittedCrossDomainPolicies());
app.use(helmet.referrerPolicy());
app.use(helmet.xssFilter());
Helmet의 기본설정과 추가설정
기본 설정
(1) dnsPrefetchControl
도메인이 미리 로딩되는 Prefetch에 대해 컨트롤하기 위해 X-DNS-Prefetch-Control 헤더를 설정한다. 대부분의 브라우저에서는 성능을 향상시키기 위해 페이지의 링크에 대한 DNS Record를 미리 추출한다. 그렇게 되면 사용자가 링크를 클릭할 때 대상에 대한 IP가 이미 알려져 있다는 것이다.
이로 인해서 DNS Service가 과도하게 사용될 수 있고, 개인정보 보호문제 등이 야기될 수 있다. 따라서 보안 요구가 높은 경우에는 성능 저하를 감수하면서 DNS Prefetch를 비활성화 시킬 수 있다.
(2) frameguard
X-Frame-Options 헤더를 설정해서 Click-jacking 공격을 방지한다. Click-jacking은 사용자가 자신이 클릭하고 있다고 인지하는 것과 다른 것을 클릭하도록 하여 속이는 해킹 기법이다. 속이기 위해서 보이지 않는 레이어에 보이지 않는 버튼을 만드는 방법이 있다.
(3) hidePoweredBy
응답 헤더에 있는 X-Powered-By에 서버 소프트웨어가 표기되는데 이를 숨겨준다. 이 정보는 악의적으로 활용될 가능성이 높기 때문에 helmet을 통해서 제거해 주는 것이 좋다.
참고로 필자는 main.ts 부분에 app.disable('x-powered-by') 라고 프로젝트 시작 전에 선언해주는 편이었다. 하지만 helmet 라이브러리를 적용하면서 이 부분은 제거해도 괜찮을 것 같다.
helmet을 적용하지 않았을 때의 Response Header
실제로 Postman을 가지고 테스트 해봤을 때 helmet 라이브러리를 사용하지 않은경우 X-Powered-By가 노출되는 것을 확인할 수 있었다. 이제 helmet 라이브러리를 적용해보고 스샷을 찍어보겠다.
helmet을 적용하고 난 후의 Response Header
Helmet 라이브러리를 적용하니까 이전과 다르게 뭔가 여러개가 빡빡 나오는 것을 볼 수 있다. 하지만 확실한건 x-powered-by 노출을 막아준다는 것을 확인할 수 있다.
(4) hsts
Strict-Transport-Security 헤더를 설정해준다. 이 헤더는 서버에 대한 안전한 연결을 적용시켜주는 헤더이다. 안전한 연결이라 함은 SSL 또는 TLS를 통한 HTTP 연결을 말한다. 그래서 브라우저에게 HTTPS 만을 통해 특정 사이트에 Access 할 수 있도록 요청한다.
브라우저는 Default로 보통 HTTP로 먼저 접속을 시도하게 되는데 이 때 HTTPS를 지원하는 사이트였다면 301/302 Redirect로 HTTP 응답을 해서 HTTPS롤 적용해서 사이트에 접근시켜줍니다.
하지만 해커가 중간에 Proxy 서버를 세팅함으로써 본인과 해커 사이에서는 HTTP 통신을 하고, 해커와 웹사이트와는 HTTPS 통신을 하게 된다면 사용자의 개인정보가 HTTP 통신을 통해 해커에게 전달되는 현상이 야기될 수 있습니다.
위의 시나리오를 SSL Stripping 이라고 하며, 이런 공격을 방지하기 위해 HSTS를 설정한다고 합니다.
(5) ieNoOpen
X-Download-Options 헤더를 설정해서 IE8 (Internet Explorer) 이상에서만 사용할 수 있도록 설정한다.
(6) noSniff
X-Content-Type-Options 헤더를 설정해서 선언된 콘텐츠 유형으로부터 벗어난 응답에 대한 브라우저의 MIME Sniffing을 방지한다. MIME은 Multipurpose Internet Mail Extensions의 약자로 클라이언트에게 전송된 문서의 다양성을 알려주기 위한 포맷이다. 참고로 브라우저는 리소르를 내려받을 때 MIME 타입을 보고 동작하기 때문에 정확한 설정이 중요하다.
MIME Sniffing은 브라우저가 특정 파일을 읽을 때 파일의 실제 내용과 Content-Type에 설정된 내용이 다르면 파일로부터 형식을 추측하여 실행하는 것인데 공격자에게 악용이 될 가능성이 있다고 한다.
(7) xssFilter
X-XSS-Protection 헤더를 0으로 설정하면서 XSS 공격 스크립트를 비활성화해서 예방할 수 있다.
추가설정
(1) contentSecurityPolicy (CSP)
콘텐츠 보안 정책 설정 및 구성을 통해 의도하지 않은 내용이 페이지에 삽입되는 것을 방지해준다. 이 헤더를 설정해주면서 XSS나 Cross-site injection을 방지해준다. 참고로, 다른 사이트의 script를 불러오는 것도 막기 때문에 helmet 적용 전 별도로 설정이 필요하다.
(2) crossOriginEmbedderPolicy (COEP)
Cross-Origin-Embedder-Policy 헤더를 require-corp로 설정한다. COEP는 교차 출처 삽입 정책이라고도 부르며, 원본 간 리소스를 문서에 포함하도록 해주는 헤더이다. require-corp 옵션 또한 다른 옵션들에 대해서는 링크를 확인해보면 더욱 자세하게 알 수 있다.
(3) crossOriginOpenerPolicy (COOP)
Cross-Origin-Opener-Policy 헤더를 설정한다. COOP는 top-level 문서가 cross-origin 상태의 문서와 browsing context group을 공유하지 못하도록 제한해주는 헤더이다.
(4) crossOriginResourcePolicy (CORP)
Cross-Origin-Resource-Policy 헤더를 설정한다. CORP는 교차 출처 리소스 정책이라고도 부르며, 브라우저가 지정된 리소스에 대한 출처 또는 사이트 간 요청을 차단하지 않는다는 요청을 하는 헤더이다.
(5) exprectCt
Expect-CT 헤더를 설정해서 SSL 인증서 오발급을 예방해준다.
(6) originAgentCluster
Origin-Agent-Cluster 헤더를 설정해서 Origin 간 문서를 별도 Agent Cluster로 분리한다.
(7) permittedCrossDomainPolicies
X-Permitted-Cross-Domain-Policies 헤더를 설정해서 Cross-Domain-Content-Policy를 설정한다. 이 헤더는 일부 클라이언트에 도메인 간 콘텐츠 로드에 대한 도메인 정책을 처리한다.
(8) referrerPolicy
참조 referrer 헤더를 숨겨준다.
후기
이렇게 helmet에 대해서 조금 자세하게 알아봤다. 모든 옵션들을 필히 알아야 하는 건 아니지만, 그래도 어떤 기능들이 있구나, 이래서 helmet 라이브러리를 적용하는구나 알고 써야한다고 생각되어서 한번 정리해봤다.
안녕하세요! 현재 NodeJS로 백엔드 취업을 희망하고 있는 이강욱이라고 합니다. 최대한 블로그 포스팅을 보시는 분들에게 자세한 정보와 즐거움을 드리기 위해 노력하며 포스팅을 쓰고 있습니다 ㅎㅎ
읽어보시고 문제가 있거나 잘못되었다고 생각되는 부분들은 언제든 댓글로 작성해주시면 최대한 빠르게 조치하겠습니다!
감사합니다.
오늘은 그냥 가벼운 마음으로 한번 블로그에 써놔야겠다 했었던 RDBMS와 NoSQL은 각각 무엇이고, 장단점은 무엇이 있을지 한번 쭈욱 정리해보려고 한다. 약간 이제... 면접 준비용? 이라고 할 수 있겠다 ㅎㅎ!
DB와 SQL 그리고 DBMS
- DB는 컴퓨터 시스템에 전자방식으로 저장되어 있는 구조화된 정보 또는 데이터의 체계적인 집합을 말한다.
- SQL은 Structed Query Language의 약자로 관계형 데이터베이스 관리시스템인 RDBMS의 관리를 위해 제작된 언어이고, 자료의 검색과 재조합 그리고 스키마 생성등 데이터베이스 객체의 접근을 조정하고 관리하기 위해 나온 개념이다.
- DBMS는 Database Management System의 약자로 사용자와 데이터베이스 사이에서 사용자의 요구에 따라 정보를 생성해주고 데이터베이스를 관리해주는 소프트웨어를 말한다.
RDBMS는 뭐야?
- Relational Database Management System의 약자로 관계형 데이터베이스 관리 시스템을 의미한다.
- RDB를 관리하는 시스템이다. 참고로 RDB는 관계형 데이터 모델을 기초로 두고 모든 데이터를 2차원 테이블 형태로 표현하는 데이터베이스를 의미한다.
- RDBMS에서는 테이블끼리 관계를 맺을 수 있는데 이러한 관계를 나타내기 위해 사용하는 키가 바로 외래키(foreign key)이다. 즉, 테이블간의 관계에서 외래키를 활용한 Join이 가능하다는 것이 RDBMS의 가장 큰 특징이다.
- ACID (Atomicity, Consistency, Isolation, Durability) 원칙을 기본으로 구성된 방식이다. (ACID는 나중에 트랜잭션에 대해 자세히 정리할 때 추가하겠다!)
그러면 NoSQL은 뭐야?
- Not Only SQL의 약자로 RDB 형태의 관계형 데이터베이스가 아닌 테이블 간 상호관계가 없다는 것이 특징이다. 따라서 관계가 없기 때문에 다른 테이블과 Join도 사용할 수 없다.
- 다시 말해, 관계형 데이터베이스의 형태가 아닌 다른 형태의 데이터 저장 기술을 의미한다. 그래서 RDBMS와는 달라 테이블 간 관계를 정의하지 않는다.
- 빅데이터가 등장하면서 데이터와 트래픽이 빠르게 증가하면서 RDBMS의 단점인 성능을 향상시키기 위해 고안된 기술이다.
- RDBMS의 성능을 보완하기 위해서는 Scale-Up 기법을 사용해야 한다. 하지만 이는 비용이 많이 발생한다. 따라서 데이터의 일관성은 포기하고 비용을 고려하면서 여러대의 데이터에 분산하여 저장하는 Scale-Out 기법을 목표로 한다.
NoSQL에는 여러가지 타입이 있다. 대표적으로 Key-Value Database, Document Database, Wide Column Database, Graph Database가 있다.
우리가 자주 접하는 Redis나 Amazon Dynamo DB는 Key-Value Database에 속하고, MongoDB와 CouthDB는 Document Database에 속한다. 이 정도로만 숙지하고 있어도 될 것 같다.
각 타입에 대해 더 자세하게 알아보고 싶다면 아래 필자가 참고한 링크에 들어가서 확인하면 좋을 것 같다!
RDBMS와 NoSQL의 장단점
RDBMS
장점
- 정해진 Schema에 따라 데이터를 저장해야 하기 때문에 명확한 데이터 구조를 보장하고 있다.
- 관계는 각 데이터를 중복없이 한 번만 저장할 수 있다.
단점
- 테이블 끼리 관계를 맺고 있기 때문에 시스템이 확장하게 된다면 Join문이 많은 복잡한 쿼리가 만들어질 수 있으며 이는 성능저하를 일으킬 수 있다.
- 성능 향상을 위해서는 서버의 성능을 향상시켜야 하는 Scale-Up만 지원한다. 이로 인해서 비용이 급격하게 늘어날 수 있다.
- Schema로 인해서 데이터가 유연하지 못하는데 혹여나 나중에 Schema가 변경되게 되면 골치아프다.
NoSQL
장점
- Schema가 없기 때문에 데이터가 유연하고 자유로운 구조를 가질 수 있다. 따라서 저장된 데이터를 쉽게 조정할 수 있고 언제나 새로운 필드를 추가할 수 있다.
- 데이터 분산이 용이하고 성능 향상을 위한 Scale-Up 뿐만 아니라 Scale-Out도 가능하다.
단점
- 데이터의 중복이 발생할 수 있고 중복된 데이터가 변경될 경우 수정을 모든 컬렉션에서 해야한다는 단점이 있다.
- Schema라는 것이 존재하지 않기 때문에 명확한 데이터 구조를 보장하지 않고 데이터 구조 결정이 어려울 수 있다.
마지막! RDBMS와 NoSQL은 각각 언제 사용하는게 좋을까?
필자는 프로젝트를 이제 시작할 때 항상 고민한다. 어떤 걸 사용해야 할까?
사실 뭐 정답은 없는 것 같다? 아닌가? 사실 모르겠다. 그냥 느낌적으로는 어떤 걸 선택해도 구현은 할 수 있을 것이라고 생각한다.
하지만 추구하는 방향(이상향)은 있다고 생각한다. 아리까리 했던 기준을 이제 정리해보자.
RDBMS는 데이터 구조가 명확하고 변경될 여지가 없으며 명확한 스키마가 중요하다면 사용하는 것이 좋다. 그리고, 중복된 데이터가 없어서 변경이 용이하기 때문에 관계를 맺고 있는 데이터가 자주 변경이 이루어지는 시스템에 적합하다.
반면 NoSQL은 정확한 데이터 구조를 알 수 없고 데이터가 변경 및 확장이 될 수 있는 여지가 있다면 선택하는 것이 좋다. 하지만, 데이터의 중복이 발생할 수 있고 중복된 데이터가 변경되면 모든 컬렉션에서 수정해야 하기 때문에 신중해야 한다. 때문에 UPDATE가 자주 실행되는 애플리케이션은 좋지 않을 것 같고, Database를 Scale-Out 해야되는 시스템에 적합할 것 같다.
참고로 필자는 지금까지 RDBMS로만 프로젝트를 해봤는데.. 물론 Redis로 NoSQL을 접한 적도 있다..ㅎ
안녕하세요! 현재 NodeJS로 백엔드 취업을 희망하고 있는 이강욱이라고 합니다. 최대한 블로그 포스팅을 보시는 분들에게 자세한 정보와 즐거움을 드리기 위해 노력하며 포스팅을 쓰고 있습니다 ㅎㅎ
읽어보시고 문제가 있거나 잘못되었다고 생각되는 부분들은 언제든 댓글로 작성해주시면 최대한 빠르게 조치하겠습니다!
감사합니다.
안녕하세요! 현재 NodeJS로 백엔드 취업을 희망하고 있는 이강욱이라고 합니다. 최대한 블로그 포스팅을 보시는 분들에게 자세한 정보와 즐거움을 드리기 위해 노력하며 포스팅을 쓰고 있습니다 ㅎㅎ
읽어보시고 문제가 있거나 잘못되었다고 생각되는 부분들은 언제든 댓글로 작성해주시면 최대한 빠르게 조치하겠습니다!
감사합니다.
결론은 객체지향 적으로 설계 (캡슐화) 하기 위해서 Exclude와 Expose를 사용하게 되었다. 참고로 일반적인 경우에는 User 테이블의 password와 같은 칼럼을 숨기기 위해 사용하곤 한다.
어떤 이슈가 있었냐?
먼저, Class-transformer의 exclude와 expose를 사용하기 때문에 main.ts 부분에 Global 적으로 ClassSerializeInterceptor를 등록했다. 그렇게 되면 Response로 반환될 때 Serialize 과정을 거쳐서 원하는 결괏값을 얻을 수 있다.
이후 나는 Caching을 적용하고 싶었다. 원래는 Controller의 Method 단에 @UseInterceptor(CacheInterceptor)와 CacheKey, CacheTTL 데코레이터를 적용하고 싶었지만, 필자는 각 사용자의 데이터를 저장하고 싶어서 CacheKey의 userId를 넣고 싶었다.
하지만, @CacheKey 데코레이터에는 그렇게 하지 못한다. 코드를 보자. ---- (1)
데코레이터를 통해 Caching을 진행할 순 없다고 판단되어서 Controller 단에서 Redis에 해당 Key로 저장된 Value가 있는지 확인하고 있는 경우에는 Service로 넘어가지 않고 바로 Return 하고, 그렇지 않은 경우에는 Service로 넘어가서 비즈니스 로직을 거쳐서 Return 하게 작성해 봤다.
위와 같이 작성하게 되면 일단 유동적으로 CacheKey를 설정할 수 있긴 하다. 하지만 여기서 문제점이 있으니....
만약, API를 요청했을 때 Redis에 저장되어 있는 값이 없으면 Service로 넘어가서 로직들을 처리하고 result 변수에 값이 할당되어 있을 것이다. 그리고 위의 코드를 보면 ResponseEntity로 클라이언트에게 반환하기 전에 Redis에 key-value 형태로 저장을 한다. 하지만 Redis에 저장할 때는 위의 json 형태로 저장되지 않는다.
Expose와 Exclude가 처리되지 않은 상태로 Redis에 저장되는 것이다. 즉, 위의 결괏값에서 언더바(_)가 붙은 상태로 저장이 된다. 이렇게 되면 다음에 추가적으로 같은 API를 요청하게 되면 Redis를 거치게 될 텐데 위의 결괏값과 다른 언더바가 붙은 상태로 반환이 될 것이다. 이렇게 되면 클라이언트에서는 화면을 렌더링 할 수 없게 된다.
즉, 필자가 원하는 것은 Expose와 Exclude가 처리된 상태(SerializeInterceptor를 거친 상태)를 캐싱하고 싶다는 것이다.
어느 부분에서 이슈가 있었던 것 같아요?
ClassSerializeInterceptor가 CacheInterceptor보다 늦게 동작을 하기 때문이었다. Response가 흘러가는데 CacheInterceptor를 먼저 들른 격이 되는 것이다.
우리는 위의 내용을 구현하기 위해서는 CacheInterceptor를 ClassSerializeInterceptor보다 늦게 실행시키면 되는 것이다!
NestJS에서의 Life-Cycle와 위의 이슈랑 연관 짓기
1. Incoming request 2. Globally bound middleware 3. Module bound middleware 4. Global guards 5. Controller guards 6. Route guards 7. Global interceptors (pre-controller) 8. Controller interceptors (pre-controller) 9. Route interceptors (pre-controller) 10. Global pipes 11. Controller pipes 12. Route pipes 13. Route paramter pipes 14. Controller (method handler) 15. Service (if exists) 16. Route interceptor (post-request) 17. Controller interceptor (post-request) 18. Global interceptor (post-request) 19. Exception filters (route, then controller, then global) 20. Server response
위의 내용들을 보면 먼저 Controller 위에 작성하는 UseInterceptor는 pre-controller 및 controller interceptor(post-request) 단계이기 때문에 Expose와 Exclude를 처리한 결괏값을 캐싱할 수는 없다. 이유는 ClassSerializeInterceptor는 18번 단계의 Global interceptor이기 때문이다. (위의 글에서 볼 수 있는 (1) 번)
그렇게 되면 물론 위의 코드에서 작성한 Controller에서 처리한 것도 불가능할 것이다. (위의 글에서 볼 수 있는 (2) 번)
우리는 결국 18번을 공략해야 할 것 같다. 19번의 Exception Filter는 사용하지 않을 것이기 때문이다.
바로 app.module.ts에 전역으로 Provider에 등록해 주는 것이다. 참고로 CustomCacheV2 Interceptor는 필자가 커스텀 한 CacheInterceptor인데 이것은 다음 포스팅에서 기본 Nest의 CacheInterceptor를 분석하는 것과 어떻게 커스텀했는지에 대해서 알아보려고 한다.
위의 사진은 main.ts 파일이다. 맨 밑의 줄을 보면 GlobalInterceptor로 ClassSerializerInterceptor를 등록해 줬다.
여기서 알아버린 사실은 app.module.ts에 등록한 provider가 main.ts에 등록한 provider 보다 우선이라는 것이다!
후기
내가 정확하게 인지한 부분인지는 모르겠다. 그래서 블로그를 통해서 혹시나 보시는 분들에게 지적을 받고 싶다.
커뮤니티 쪽에서도 여쭤보고 틀린 정보가 있다면 수정을 해보도록 하겠다.
어쨌든 문제는 해결했으니 끝!
#### 2023.11.18 후기 추가
- 지금 위와 같은 방법으로 하니 GET 이외의 메서드에서 캐시를 삭제하는 과정에서 많은 이슈가 발생하고 있다. 따라서, 위의 방법은 일단 보류하면서 추후에 해결하면 링크를 달아두도록 하려고 한다.
안녕하세요! 현재 NodeJS로 백엔드 취업을 희망하고 있는 이강욱이라고 합니다. 최대한 블로그 포스팅을 보시는 분들에게 자세한 정보와 즐거움을 드리기 위해 노력하며 포스팅을 쓰고 있습니다 ㅎㅎ
읽어보시고 문제가 있거나 잘못되었다고 생각되는 부분들은 언제든 댓글로 작성해주시면 최대한 빠르게 조치하겠습니다!
감사합니다.
필자는 Nest를 개발할 때 Service와 Repository를 거쳐온 결괏값을 Client로 반환할 때 Response DTO 객체로 만들어서 Controller 계층으로 넘기고 Controller에서 클라이언트로 결괏값을 반환할 때 ResponseEntity 객체에 넣어서 반환한다.
ResponseEntity를 사용한 이유는 무엇인가요?
ResponseEntity는 뭐 특별한 라이브러리나 그런것이 아니다. 원래 초기에는 개발할 때 그냥 return { age: 10, name: '' } 이런 식으로 했었는데 가시성 측면으로도 좋지 않아 보였고, 추후 유지보수적으로도 좋지 않다고 생각했다. Response DTO를 도입한 것도 그 이유다.
그래서 뭔가 객체를 하나 만들어서 반환해준다면 코드적으로 더욱 깔끔할 것 같다고 판단되었다. 그래서 블로그 글들을 많이 찾아봤는데 ResponseEntity를 직접 커스텀해서 만드신 개발자 분이 있었다.
하지만 필자가 의도한 이유로 ResponseEntity를 사용한 것이 아니었다. 블로그 개발자님은 원래는 성공 Response를 interceptor를 활용해서 코드를 구성했었는데 Controller 계층에서 전달하고 있는 데이터 타입과 API의 응답 타입이 맞지 않는 문제로 인해 도입하셨다고 했다.
그래서 필자는 interceptor를 활용할 생각은 못했지만, 위와 같은 이유로 인해 ResponseEntity를 커스텀했다.
필자는 평소에 API Response에서는 공통적으로 isSuccess, statusCode 그리고 result를 반환한다. 그리고 실패 Response인 경우에는 여기에 timestamp, url, error message 등을 추가한다.
위의 코드는 성공 Response 객체를 생성할 수 있는 코드만 작성했다. Error Response는 ExceptionFilter가 마무리되면 비슷한 구조로 추가할 예정이다.
그리고 글을 작성하면서 생각났지만 위에서 statusCode parameter는 없어도 될 것 같다. 최근에 클린코드 책을 읽고있는데 메서드의 파라미터 개수는 없으면 너무 좋고 1개까지가 좋다고 써져 있어서 최대한 지켜보려고 노력하고 있기 때문에 statusCode 매개변수는 없어도? 충분히 구현이 가능할 것 같다.
그래서 위 ResponseEntity를 가지고 Controller에서는 다음과 같이 코드를 작성한다.
위와 같이 작성하면 Service에서 어떤 Type의 결과값을 반환했는지도 확인할 수 있고 깔끔하게 코드를 작성할 수 있다.
여기서 눈여겨 볼 수 있는 점은 new 키워드를 사용하지 않았다는 것이다. 이유가 무엇인지 보자!
정적 팩토리 메서드
필자는 생각없이 다른 개발자님의 블로그를 따라한 것 같아서 지금이라도 이유를 한번 곰곰이 생각해보려고 한다. 나처럼 하지 마시길 ㅠ
정적 팩토리 메서드는 객체 생성의 역할을 하는 클래스 메서드라고 할 수 있다. 그러면 여기서 질문을 던질 수 있는 것이 생성자를 써도 충분히 구현이 가능할텐데 왜 생성자를 private으로 선언하면서 정적 팩토리 메서드를 사용했느냐이다.
(1) 이름을 가질 수 있다.
이름을 가질 수 있다는 점이 가장 큰 장점이다. 우리는 new 라는 키워드를 알고 있다. new를 통해 객체를 생성하려고 한다면 Class의 내부 구조를 알고 있어야 객체를 올바르게 생성할 수 있다. 하지만 정적 팩토리 메서드를 사용하게 되면 메서드 이름에 객체의 생성 목적을 부여할 수 있다.
위의 코드를 보면 나는 OK와 OK_WITH 이라고 네이밍을 정했다. 물론 여기에 ERROR라는 키워드를 추가할 수 있을 것 같다. 이렇게 정적 팩토리 메서드를 적용하면서 Controller 계층이나 외의 다른 계층에서 성공 응답인지 실패 응답인지 쉽게 구분할 수 있다.
만약에 생성자를 통해 객체를 생성했다면 일일이 Depth를 들어가면서 확인을 했어야 했기 때문에 개발적인 측면에서 불편했을 수 있을 것 같다.
안녕하세요! 현재 NodeJS로 백엔드 취업을 희망하고 있는 이강욱이라고 합니다. 최대한 블로그 포스팅을 보시는 분들에게 자세한 정보와 즐거움을 드리기 위해 노력하며 포스팅을 쓰고 있습니다 ㅎㅎ
읽어보시고 문제가 있거나 잘못되었다고 생각되는 부분들은 언제든 댓글로 작성해주시면 최대한 빠르게 조치하겠습니다!
감사합니다.
나중에 어느정도 숙련이 되면 Nestia 초기 세팅할 때는 어떤 명령어를 작성해야 하고, 각 Nestia 라이브러리에는 어떤 것들이 있는지를 좀 더 세세하게 분석해보려고 한다.
문제점은?
우리는 root에 nestia.config.ts 파일을 만들고 npx nestia init 명령어를 통해 초기 세팅을 해줄 수 있다.
import { INestiaConfig } from '@nestia/sdk';
export const NESTIA_CONFIG: INestiaConfig = {
/**
* Accessor of controller classes.
*
* You can specify it within two ways.
*
* - Asynchronous function returning `INestApplication` instance
* - Specify the path or directory of controller class files
*/
input: ['src/controllers'],
/**
* Building `swagger.json` is also possible.
*
* If not specified, you can't build the `swagger.json`.
*/
swagger: {
/**
* Output path of the `swagger.json`.
*
* If you've configured only directory, the file name would be the `swagger.json`.
* Otherwise you've configured the full path with file name and extension, the
* `swagger.json` file would be renamed to it.
*/
output: 'dist/swagger.json',
},
/**
* Output directory that SDK would be placed in.
*
* If not configured, you can't build the SDK library.
*/
output: 'src/api',
/**
* Target directory that SDK distribution files would be placed in.
*
* If you configure this property and runs `npx nestia sdk` command,
* distribution environments for the SDK library would be generated.
*
* After the SDK library generation, move to the `distribute` directory,
* and runs `npm publish` command, then you can share SDK library with
* other client (frontend) developers.
*/
// distribute: "packages/api",
/**
* Whether to use propagation mode or not.
*
* If being configured, interaction functions of the SDK library would
* perform the propagation mode. The propagation mode means that never
* throwing exception even when status code is not 200 (or 201), but just
* returning the {@link IPropagation} typed instance, which can specify its body
* type through discriminated union determined by status code.
*
* @default false
*/
// propagate: true,
/**
* Allow simulation mode.
*
* If you configure this property to be `true`, the SDK library would be contain
* simulation mode. In the simulation mode, the SDK library would not communicate
* with the real backend server, but just returns random mock-up data
* with requestion data validation.
*
* For reference, random mock-up data would be generated by `typia.random<T>()`
* function.
*
* @default false
*/
// simulate: true,
};
export default NESTIA_CONFIG;
그러면 다음과 같이 코드가 작성되어 있을 것이다. 여기서 swagger 쪽을 보면 swagger.json이 생성될 위치를 언급해주는 것인데 필자는 dist 파일의 root에 생성되게끔 설정해주었다.
여기서 문제는 생성은 잘되지만 npm run start:dev 명령어를 통해 서버를 구동시키고 나면 dist에 swagger.json이 없어진다는 것이다.
물론 새로 디렉토리를 생성해서 그쪽으로 경로 설정을 해주면 되긴하지만 왜 안되는지 궁금해서 알아보기로 했다.
결론
dist는 기본적으로 빌드하면 삭제옵션이 있다고 한다. nest-cli.json 파일을 확인해보면 compilerOptions - deleteOurDir이 true로 되어있을 것이다.
그래서 간단하게 로직을 정리해보자면..
- 우리는 npx nestia swagger를 통해 swagger.json을 생성한다. 그러면 dist의 root에 swagger.json이 생성된 것을 확인할 수 있다.
- 만약 이후에 npm run start:dev를 통해 서버를 구동시킬 경우 deleteOurDir가 true이면 dist 파일이 삭제되었다가 새로 생성이 되기 때문에 swagger.json을 읽어오지 못하는 것이다.
안녕하세요! 현재 NodeJS로 백엔드 취업을 희망하고 있는 이강욱이라고 합니다. 최대한 블로그 포스팅을 보시는 분들에게 자세한 정보와 즐거움을 드리기 위해 노력하며 포스팅을 쓰고 있습니다 ㅎㅎ
읽어보시고 문제가 있거나 잘못되었다고 생각되는 부분들은 언제든 댓글로 작성해주시면 최대한 빠르게 조치하겠습니다!
감사합니다.
지금 하는 맵필로그 프로젝트 이전에는 모두의 여행, 트리퍼라는 프로젝트를 했는데, 그 프로젝트는 express와 javascript로 개발을 했었다. 단순히 express로 개발을 할 때는 DTO와 Entity 그리고 OOP에 대해서는 진짜 아예 사용하지도 않았고 무지했던 것 같다.
하지만, Nest를 시작해 보면서 자연스럽게 DTO와 Entity를 알아야 했고, 프레임워크 특성상 OOP의 개념도 가지고 있어야 개발에 차질이 없을 것 같아서 이렇게 공부를 시작하게 되었다.
그래서 이번 포스팅에서는 DTO와 Entity 개념이 간단하게 무엇인지, 그리고 Nest에서 그 둘의 상관관계에 대해서 알아보도록 하자.
Entity
Entity는 실제 DB 테이블과 매핑되는 핵심 클래스이고, 데이터베이스 테이블에 있는 칼럼들을 필드로 가지는 객체이다. 테이블과 1:1로 매핑이 되고 테이블이 가지고 있지 않은 컬럼을 필드로 가지고 있으면 안 된다.
그리고 Entity와 실제 데이터베이스의 테이블이 연결이 되어있기 때문에 수정이 되어선 안된다. 그렇기 때문에 setter 메서드를 사용하지 않는다. 이유는 setter 메서드를 사용하게 되면 Entity에 접근이 가능해지고 객체의 일관성과 안전성을 보장하기 힘들어진다.
그래서 보통 Entity에서는 setter 메서드 대신에 constructor(생성자)를 사용한다. 생성자를 사용해서 Entity 객체를 초기화하면 단단한 객체 (변하지 않는, 불변한)로 Layer에서 활용이 가능하기 때문에 데이터가 변조되지 않음을 보장할 수 있다.
위의 코드는 지금 하고 있는 프로젝트에서 색깔 Entity를 구현한 것이다. Nest에서는 위와 같이 정의하면 될 것 같다. 참고로 CommonEntity는 필자가 Custom 한 것인데, 테이블에 보통 필수로 넣는 PK(id), createdAt, updatedAt, deletedAt을 가지고 있는 기본 칼럼들을 의미한다. TypeORM의 BaseEntity를 상속받는다.
그냥 Nest에서는 이런 식으로 정의한다~ 고 보여주고 싶어서 올려봤다!
DTO (Data Transfer Object)
DTO는 계층 간 데이터 교환이 이루어질 수 있도록 하는 객체이다. 원래는 DAO (Data Access Object) 패턴에서 유래된 단어인데, DB 처리 로직을 숨기고 DTO라는 결괏값을 내보내는 용도로 활용했다.
보통 Controller와 같은 클라이언트 단과 근접한 계층에서는 Entity 대신 DTO를 사용해서 데이터를 교환한다. 대표적인 예로는 우리가 클라이언트로부터 Request를 받는데, Body나 Param, Query 등 Parameter를 DTO Class에 매핑시켜서 Nest의 Class-Validator를 통해 유효성 검증을 하곤 한다.
그 외에도 다양한 경우나 Layer에서도 DTO를 사용할 수 있지만 보통은 MVC 구조에서 View와 Controller 사이에서 데이터를 주고받을 때 사용한다고 생각하자.
참고로 DTO는 Entity와 다르게 getter, setter 메서드를 포함할 수 있다.
import { IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { setValidatorContext } from 'src/common/common';
import { UserSnsTypeEnum } from '../constants/user.enum';
import { CheckColumnEnum } from 'src/constants/enum';
import { CommonExceptionCode } from 'src/common/exception-code/common.exception-code';
import { UserExceptionCode } from 'src/common/exception-code/user.exception-code';
export class LoginOrSignUpRequestDto {
@IsString(setValidatorContext(CommonExceptionCode.MustStringType))
@IsNotEmpty(setValidatorContext(UserExceptionCode.SocialAccessTokenEmpty))
socialAccessToken: string;
@IsEnum(
UserSnsTypeEnum,
setValidatorContext(UserExceptionCode.SocialVendorErrorType),
)
@IsNotEmpty(setValidatorContext(UserExceptionCode.SocialVendorEmpty))
socialVendor: UserSnsTypeEnum;
@IsString(setValidatorContext(CommonExceptionCode.MustStringType))
@IsOptional()
fcmToken?: string | undefined;
@IsEnum(
CheckColumnEnum,
setValidatorContext(CommonExceptionCode.MustCheckColumnType),
)
@IsOptional()
isAlarmAccept?: CheckColumnEnum | undefined;
}
위의 코드는 회원가입 그리고 로그인 요청 DTO이다. 아까 위에서 설명한 DTO 대표적인 예시에 대한 코드이다. 이 LoginOrSignUpRequestDto를 회원가입 및 로그인 요청 API의 controller 부분의 Body를 받는 부분에 Type으로 지정해 주면 된다.
참고로 setValidatorContext는 그냥 내 개인적인 스타일인데 Nest에서는 에러 메시지를 기본적으로 배열에 담아서 준다. 하지만 나는 클라이언트 분들이랑 협업할 때 메세지 string으로 1개만 주는 것을 원하고 각 에러 메시지에 대한 error code를 할당해 주는 것이 좋아서 이렇게 코드를 작성한다. 조금 더럽긴 하지만...? ㅎㅎ..
그래서 위에 보이는 IsString이나 IsEnum 등 각 Class-validator에서 제공하는 decorator에서 context를 지정해 줄 수 있는데 그 context 요소를 만들어주는 custom helper 함수이다.
Entity와 DTO를 분리하는 이유는?
(1) MVC 패턴에서 View와 Model의 분리로 인한 코드 가독성과 유지보수 용이함
먼저, DTO는 간단하게 정리해 보면 Entity와 달리 각 계층끼리 주고받는 물건이다. 어떻게 보면 Entity랑 비슷하지 않나?라는 생각이 들 수 있지만 DTO는 마음대로 쓰고~ 버리고 할 수 있는 일회성 객체라고 생각하면 좋을 것 같다.
조금 어렵게 설명하자면 DTO는 View와 Controller 간의 인터페이스 역할을 하고, Entity는 Model의 역할을 한다. 이렇게 분리를 하게 되면 MVC 패턴에 적용되어 코드 가독성과 유지보수성을 용이하게 가져갈 수 있다.
(2) Entity 구조가 변경되어도 괜찮다!
Entity 구조가 변경되어도 DTO를 사용해서 데이터를 처리하게 되면 변경사항이 클라이언트에게 직접적으로 영향을 미치지는 않는다. 따라서 DTO를 사용하게 되면 Entity의 변경으로 인한 영향을 최소화할 수 있고, 클라이언트에게 필요한 데이터만 전달할 수 있다.
Layered Architecture에서 DTO와 Entity
지금 내가 참여해 있는 맵필로그 프로젝트에서는 Layered Architecture를 사용하고 있다. Layered Architecture를 적용한 이유는 내가 개인적으로 생각하기에 관심사를 분리하기에도 좋은 것 같고, 다른 아키텍처에 비해 개발하는 데에 어려움이 덜한 것 같다고 생각한다.
Layered Architecture에서 Controller는 DTO로 변환된 Request Parameter를 가지고 Service Layer로 넘어가게 되고, Service에서는 DTO를 비즈니스 로직을 거쳐서 Entity로 변환된 객체를 Repository를 이용해서 DB에 저장하게 된다.
일단 요즘 느끼는 건 정답은 없다고 생각한다. 물론 이런 글들이 많이 올라오는 건 그만큼 검증이 된 것이기 때문이라고 생각은 하지만..
필자는 Repository 계층에도 DTO를 사용해도 될 것 같다고 생각하는 편이긴 한다.
그래서 나는 그냥 필요에 따라서 Service -> Repository에서 DTO를 사용해야겠다고도 생각한다.
위의 그림을 보면 DTO에서 Entity로 변환을 해야 하는 타이밍이 온다. Service Layer에서 Entity로 변환을 해주고 Repository로 넘겨서 DB 작업을 해주면 된다.
DTO 파일에서 변수들은 private으로 선언해 줘서 Service에서 DTO 필드로 접근할 수 없게 하면서 DTO에서 자기의 Property 들을 가지고 UserEntity를 만들어 주면 된다. 위와 같이 코드를 작성해 주면 각각의 객체가 책임과 역할을 가지게 되고 Service Layer에서 toEntity 메서드를 통해 쉽게 Entity로 변환할 수 있다.
안녕하세요! 현재 NodeJS로 백엔드 취업을 희망하고 있는 이강욱이라고 합니다. 최대한 블로그 포스팅을 보시는 분들에게 자세한 정보와 즐거움을 드리기 위해 노력하며 포스팅을 쓰고 있습니다 ㅎㅎ
읽어보시고 문제가 있거나 잘못되었다고 생각되는 부분들은 언제든 댓글로 작성해주시면 최대한 빠르게 조치하겠습니다!
감사합니다.
위의 코드는 Query Parameter로 들어온 Mark의 ID를 가지고 Mark를 조회하는 로직이 있는 메서드이다. 위의 코드에서 findOne을 주의 깊게 보자!
일단 Mark는 MarkDto라는 타입을 가지고 있다. Mark는 카테고리를 가질 수도 있고 가지지 않을 수도 있기 때문에 nullable한 값이다.
이후 mark 변수에서 getter를 통해 markCategoryId를 가져와서 where 조건에 넣어줬는데...!
처음에는 markCategoryName 변수가 null로 할당될 줄 알았다. 하지만 보니까 값이 출력이 되는 것이다. 확인해보니까 find 메서드를 사용하니까 그냥 where true로 되어서 모든 카테고리 값들이 나오는 것이었다. 그래서 findOne은 출력되는 값들 중에 제일 위의 값이 출력이 된 것이었다.
우리는 이런 상황을 방지해야 한다. 위의 문제를 파악하지 못하고 그대로 코드를 작성했더라면 문제가 분명히 발생했을 것이다.
그래서 위의 상황을 어떻게 해결할 수 있느냐?
TypeORM의 Equal 메서드를 사용하면 된다. id: mark.getMarkCategoryId 부분에서 Equal 메서드를 적용해주면 Filter가 정확히 적용이 되어서 정확하게 원하는 데이터를 얻을 수 있다. 물론 다른 방법도 있는 것 같으니 참고하는 것도 좋을 듯!
안녕하세요! 현재 NodeJS로 백엔드 취업을 희망하고 있는 이강욱이라고 합니다. 최대한 블로그 포스팅을 보시는 분들에게 자세한 정보와 즐거움을 드리기 위해 노력하며 포스팅을 쓰고 있습니다 ㅎㅎ
읽어보시고 문제가 있거나 잘못되었다고 생각되는 부분들은 언제든 댓글로 작성해주시면 최대한 빠르게 조치하겠습니다!
감사합니다.
최근에 express를 가지고 프로젝트를 진행해보고 요즘에는 Node.js 기반 백엔드 개발하시는 분이시라면 알고 계시는 NestJS를 가지고 프로젝트를 시작해 봤다. 프로젝트의 주제는 캘린더 기능 및 일정을 기록할 수 있는 애플리케이션이다. 모임 기능도 있지만 모임 기능은 추후 2차 개발에서 진행될 계획이다.
서론은 이정도로 마치고 캘린더 화면을 어떻게 구현을 했는지, 어떻게 마무리를 했는지에 대해서 쭉 정리를 해보려고 한다.
참고로 길이 아주 길 수 있으니,,, 참고!
캘린더 화면
기획하고 있는 애플리케이션 캘린더 화면
사진을 보시면 달력에 일정블록들을 배치할 수 있는 그런 화면이다. 일정이 추가될 때마다 유동적으로 블록을 잘 배치해줘야 하는 부분이 제일 골치 아픈 부분이다.
구현 스토리
1. 처음 필자가 생각한 로직
일단 설명하기 전에 일정 Table을 공유를 해야 할 것 같다.
일정 테이블에는 색깔을 정해줄 수 있기 때문에 색깔 아이디, 그리고 일정을 작성한 사용자의 아이디가 기본적으로 있으며 일정 제목과 시작날짜와 종료날짜, 그다음에 isAlarm은 일정에 알림 기능을 적용할 수 있는데 알림을 적용했는지 안 했는지에 대한 여부이다.
원래 맨 처음에 API를 설계할 때는 저 화면에 있는 데이터들을 각 날짜마다 어떤 일정들이 있는지를 보여주려고 했다. 다시 말해서 5월 4일에는 일정 몇개가 있고, 5월 6일에는 또 몇 개가 있고.. 이런 식으로 보여주려고 했었다.
하지만 굳이 그렇게 해야하나? 싶기도 했었고, 네이버와 구글 캘린더 API 문서를 한번 봤었는데 더욱 간단하게 Response를 줘도 괜찮겠다 싶었다. 위의 사진을 보면 일정이 총 9개인데 이제 일정의 startDate와 endDate만 제공해도 클라이언트에서 알아서 블록 배치를 할 수 있겠다고 생각했다.
현재 맵필로그 프로젝트에서는 ios와 Android 모두 출시를 준비하고 있다. 확인을 해보니 ios 개발자님은 완전 커스텀으로 캘린더를 구현하셨고, Android 개발자님은 특정 캘린더 라이브러리에 일부분 기능을 위해 커스텀으로 구현을 하셨다고 들었다.
ios 개발자님은 위의 Response를 가지고 캘린더 블록 배치를 구현하셨다고 들었다. 물론 여러 테스트 케이스를 더욱 테스트해봐야겠지만 일단은 그렇게 답변을 받았었다. 하지만 Android 개발자님은 Response 형태를 아예 바꿔야 할 것 같다고 연락을 받았다.
Android 개발자님은 Response 형태를 어떻게 요청하셨냐면..!
- 각 날짜별로 어떤 일정이 보여줘야 한다.
- 블록들의 순서도 알맞게 전달을 해줘야 그대로 캘린더에 블록을 배치시켜서 랜더링을 할 수 있다.
- 또한, 각 일정에 대해 해당 날짜가 일정의 시작날짜인지, 종료날짜인지 그리고 중간 날짜인지를 알려줘야 한다. 단, 일요일 날짜는 무조건 START로, 토요일 날짜들은 END로 보내주시면 될 것 같다.
참고로 START와 END는 블록들을 연결하는 것 때문에도 그렇고 예를 들어 일정이 2주차에서 3주 차까지 연결될 때 3주 차의 일정 이름이 무조건 일요일에 배치가 되기 때문에 그런 것도 있다는 것 같다.
3. 개발 시작 및 문제점
< 각 주의 주말을 START와 END로 처리 >
일단 해당 년도와 월에 일요일은 며칠이 있고, 토요일은 며칠이 있는지 알아야 한다. 여기서 중요한 점은 이전 달과 이후 달의 날짜도 포함이 되어야 한다는 것이다. 예를 들어보면 위의 사진에서 5월 달력이지만 첫 주에는 4월 30일과, 막주에는 6월 3일 토요일까지 포함이 되어야 한다.
import * as moment from 'moment';
// 이번달의 첫째 날 요일을 구하는 함수
export const getFirstDay = (year: number, month: number): number => {
const currentDate = moment({ year, month: month - 1 });
const firstDayOfMonth = moment(currentDate).startOf('month');
return firstDayOfMonth.day();
};
// 이번 달의 일요일, 토요일 리스트를 가져오는 함수 (달력 기준)
export const getWeekends = (year: number, month: number): string[][] => {
const firstDay = getFirstDay(year, month);
const firstSunday = moment({ year, month: month - 1 }).subtract(
firstDay,
'day',
);
const firstSaturday = moment({ year, month: month - 1 }).add(
6 - firstDay,
'day',
);
const result = [
[firstSunday.format('YYYY-MM-DD'), firstSaturday.format('YYYY-MM-DD')],
];
for (let i = 0; i < 4; i++) {
result.push([
firstSunday.add(7, 'day').format('YYYY-MM-DD'),
firstSaturday.add(7, 'day').format('YYYY-MM-DD'),
]);
}
if (
firstSunday.month() + 1 === month &&
firstSaturday.month() + 1 === month
) {
result.push([
firstSunday.add(7, 'day').format('YYYY-MM-DD'),
firstSaturday.add(7, 'day').format('YYYY-MM-DD'),
]);
}
return result;
};
위의 코드는 Javascript의 moment.js 라이브러리를 통해 구현했다. 코드가 조금 더러울 수도 있는 점 양해부탁드립니다 :)
시나리오를 하나 제공을 해보도록 하겠다.
- 2023년도 5월의 첫날은 5월 1일 월요일이다. 참고로 월요일은 숫자 1이다. 달력을 보면 첫날이 월요일이면 그 앞에 전달의 일요일 날짜가 있다.
- 그렇기 때문에 5월 1일에서 요일 코드만큼 빼면 된다. 그러면 달력상의 첫 일요일 날짜를 구할 수 있다. 토요일도 마찬가지이지만 다른 점은 빼는 게 아니라 더한다는 것이다.
- 첫 일요일 날짜와 토요일 날짜를 구했으면, 그 다음주들의 첫 주를 제외하고 넷째 주까지 있을 수 있고, 다섯째 주까지 있을 수 있다. 그렇기 때문에 일단 4번은 무조건 반복문을 돌아야 한다. 이때 위에서 구한 첫째 주의 일요일과 토요일 날짜에서 7일을 더해주면 된다.
- 여기서 고려해야 할 점은 5째주까지 있는 경우이다. 대표적으로 2023년도 12월 달력이다. 예를 들어 마지막주에 31일 일요일과 그다음 달의 토요일이 있다고 가정해 볼 때 7일을 더했을 때 일요일은 우리가 조회하려는 달이고, 토요일은 그다음 달이 나오기 때문에 한번 더 반복문을 돌아줘야 하는 것이다. 이유는 어떤 달력에서도 토요일 기준으로 다음 달 날짜가 나오지 일요일 기준으로 다음 달이 나오는 달력은 없기 때문이다.
< 지금까지의 상황 총정리 >
그래서 위의 코드와 Query문을 잘 작성해서 START와 END처리를 할 수 있었고 기본적으로 일정들을 조회할 수는 있었다. 여기서 문제점은 위의 사진에서 일정 사이의 공백과, N번째 주에서 N+1번째 주로 넘어갈 때 블록 순서를 그대로 가져가는 것이다.
백엔드 쪽에서 어떻게 저 일정 사이의 공백을 구현해야 하는지 많은 고민을 하게 되었다. 그리고 블록 순서에 대해 이야기 하자면 일정은 startDate을 가지고 orderby를 하고 있어서 블록이 위에서 아래로 내려간다.
예를 들어, 위의 사진에서 주황색 일정이 부산여행 일정보다 startDate가 빠르다. 그러면 첫 번째 주차에서는 잘 정렬이 되어있는데 2번째 주를 보면 주황색 블록이 위에 있어야 한다. 왜냐면 startDate가 빠르기 때문이다. 하지만 지금 부산여행 일정이 위로 올라와있다. 이유는 1주 차에서 부산여행 일정이 위에 배치되어 있기 때문이다.
그래서 다시 말하면 지금은 각 날짜의 일정은 잘 나오지만 일정의 블록 순서가 구현이 안된다는 점이다.
4. 너무 많은 시간소요와 그에 따른 스트레스 ㅠ
2주가 넘게 소요되고 약 1달이라는 시간이 소요되었다. 공백과 블록 순서를 어떻게 구현을 해야 하는지 너무 감이 안 잡혀서 많은 개발 커뮤니티에 들어가서 여쭤보았다. 하지만 대부분의 개발자님들에게 처음에 설계한 Response 대로 클라이언트에게 전달을 하고 클라이언트에서 직접 블록을 배치하는 것이 맞다는 답변을 받았고 실제로 그렇게 구현을 하신 Android 개발자님도 계셨다.
그래서 정중하게 우리팀 Android 개발자분에게 공유를 드리고 직접 블록을 배치하는 로직도 한번 고민해 달라고 요청을 드렸다.
이후에 잠깐 머리도 식힐겸 다른 도메인을 개발하고 있었는데, 결국 구현하기 조금 어려울 것 같다고 답변을 받았다.
5. 그래서 이 문제점은 어떻게 구현을 했는지?
팀원들과 매주 화요일마다 회의를 하는데, 오랫동안 이 캘린더에 대해서 회의를 진행했다. 결국 일단은 배포를 지금 생각하고 있기 때문에 안전책으로 다음과 같이 화면구성을 변경했다. 위의 화면은 블록이 아닌 점으로 구현을 했다. 하지만 이 화면은 Android 한정이다. 다시 말해서 iOS에서는 블록 버전의 화면으로 배포가 되고, Android에서는 위와 같이 점 버전의 화면으로 배포가 된다는 것이다. 위와 같이 점으로 된다면 필자가 생각한 Response로 구현이 되기는 한다고 답변을 받았다.
6. 느낀점
일단 아직 배포는 안 했지만 너무너무 아쉽다.
뭔가 구현을 할 수 있을 것 같지만 안되고,, 시간이 너무 오래 걸리다 보니 일단 안전책으로 이렇게 하긴 했지만 2차 배포에서는 꼭 블록 배치가 될 수 있도록 기능을 구현해보고 싶다.
그리고 생각의 전환을 하게 된 계기가 있는데 캘린더 조회 로직을 고민해 보면서 어쩌다 보니 클라이언트에서 구현을 해야 하는 거 아닌가?라는 생각이 계속계속 들었었는데 사실 이거는 어느 파트에서 해야 한다. 어느 파트에서 해야 한다는 절대절대 없는 것 같다. 그냥 갈라 치기밖에 되지 않는 것 같다.
원래 두 파트를 나누지는 않았는데 어쩌다 보니 그렇게 생각하게 된 것 같다. 이 부분에 대해서 반성을 하게 되었다.
<2023.11.09 추가>
Android 개발자님과 로직을 또 얘기를 해봤었는데.. 이런 방법은 어떤가 싶다.
남은 기능들을 전부 구현해 보고 적용을 해볼까 한다.
(1) 주 단위로 일정들을 가져온다. 이때 일정은 시작 날짜를 기준으로 정렬한다.
(2) 가져온 일정들을 일자별로 계산 배열에 추가한다. 예를 들어서, 일요일에는 일요일에 시작되는 일정 하나가 계산용 배열에 들어가고, 월요일에는 월요일에 시작되는 아이템 하나가 추가되는 식인데 아이템은 일정이 종료되기 전까지 스택 자료구조를 사용한다.
(3) 종료된 일정이 있는 경우에는 해당 자리를 지우는 것이 아닌 공백으로 둔다.
(4) 이후 종료된 일정이 있다면 그다음 일정 (계산용 배열에 2번 일정까지 있다면 3번 일정)을 계산용 배열의 공백자리에 추가해 준다.
위 방법을 사용하면 뭔가 공백자리도 해결할 수 있을 것 같고 빈 공간에 일정까지 추가할 수 있을 것 같다..?
물론 조회할 때 로직이 빡빡하기 때문에 캐싱 등 최적화가 필요할 것 같기는 하다.
되게 많은 걸 느낄 수 있는 기능이었고 많이 아쉽긴 하지만 그래도 포기하지 않고 끝까지 내가 맡아서 해결해보고 싶다.
안녕하세요! 현재 NodeJS로 백엔드 취업을 희망하고 있는 이강욱이라고 합니다. 최대한 블로그 포스팅을 보시는 분들에게 자세한 정보와 즐거움을 드리기 위해 노력하며 포스팅을 쓰고 있습니다 ㅎㅎ
읽어보시고 문제가 있거나 잘못되었다고 생각되는 부분들은 언제든 댓글로 작성해주시면 최대한 빠르게 조치하겠습니다!
감사합니다.