-
Notifications
You must be signed in to change notification settings - Fork 0
[CS 총 정리]Summary
본 페이지는 23년도 7월을 기준으로 주말의집 서버 파트에서 적용한 기술들에 대한 이론을 정리를 담고 있습니다.
기술 적용 과정은 각 페이지별로 작성되어 있으며, 적용 외에 이론적인 내용도 함께 기록으로 남겨두고자 작성하게 되었습니다.
주말의집 아키텍처는 2가지가 있습니다.
-
인프라 아키텍처
-
어플리케이션 아키텍처
주말의집 인프라 구성은 다음과 같습니다.
CI는 Continuous Integration로 어플리케이션의 새로운 코드 변경 사항이 정기적으로 빌드 및 테스트 되어 공유 레포지토리에 통합히는 것을 의미합니다.
주말내집의 경우, dev
브랜치로 PR이 발생할 경우 Github Actions를 통해 CI를 진행합니다.
CI에서 테스트를 진행하고 Jacoco와 SonarCloud를 통해 정적 코드 분석을 합니다. 테스트만을 위한 자체 환경 구성을 위해 Github Actions 마켓 플레이스에서 제공하는 MySQL과 Redis를 도커 컨테이너로 구동하는 플로우를 구성하였습니다.
테스트만을 위한 자체 환경 구성을 한 이유는 DB/Redis 서버 내에 적재된 데이터로 인해 테스트 코드의 실행 결과에 영향이 발생할 수 있는 가능성을 배제하기 위함입니다.
CD는 Continuous Delivery & Continuous Deployment의 축약어 입니다. Continuous Delivery는 공유 레포지토리로 자동으로 Release 하는 것, Continuous Deployment는 Production 레벨까지 자동으로 deploy 하는 것을 의미합니다.
주말내집의 경우, main
브랜치로 push가 발생할 경우 Github Actions를 통해 CD를 진행합니다.
CD에서 jar 파일을 압축하여 AWS S3에 업로드 한 뒤, CodeDeploy를 통해 EC2에 배포합니다.
주말내집 서비스의 인프라 구성에는 EC2, RDS, S3, Route53, ACM, CloudFront, CodeDeploy, Lambda, CloudWatch, ALB가 있습니다.
- EC2
한 대의 서버 내에 Nginx와 Spring Application 2개가 동작하고 있습니다.
서버를 별도로 분리 구성하지 않은 이유는 비용 발생 때문입니다. 서비스의 수익이 발생하지 않은 시점이기도 하며, 서버가 부하를 감당하지 못할 정도의 트래픽이 발생하지 않고 있기에 처음부터 분리하여 구성하기 보다는 언제든 확장할 수 있는 형태로 구성하는 것이 효율적이라고 판단했습니다.
Nginx는 웹서버의 역할을 하며, 리버스 프록시와 Spring APP의 로드 밸럴싱, Front-end의 정적 콘텐츠 제공을 담당합니다.
Spring APP을 이중화 처리한 이유는 무중단 배포를 하기 위함입니다. 기능 개발 혹은 유지보수를 위해 코드 수정 후 배포를 하게 되면 서비스에 다운타임이 발생하게 됩니다. 이를 최소화하기 위해 이중화 처리를 하고 Nginx에서 라우팅하도록 하였습니다.
- RDS
DB 서버로, RDMBS 중 MySQL 엔진을 사용하고 있습니다. 서비스의 목적이 상업용은 아니기에 별도의 라이센스를 구입하지 않아도 되며, 많은 서비스에서 해당 엔진을 기반으로 서비스를 개발 및 운영하고 있어 레퍼런스가 충분하고 신뢰성이 높다고 판단하여 해당 엔진을 선택하였습니다.
- S3
스토리지로, 대용량 데이터 혹은 비정형 데이터 ( 예: 이미지 )를 저장하는 저장소입니다. 버킷에 하위 폴더를 두어 서버 Jar 파일, Front-end 코드, 이미지 파일, 서버 로그 데이터를 관리하고 있습니다.
- Route 53
AWS에서 제공하는 DNS 서비스로, duaily.net으로 접속할 경우 이를 IP주소로 변환하여 컴퓨터 간의 통신을 가능하게 해줍니다.
- ACM
HTTPS 프로토콜 기반으로 통신하기 위해서는 SSL 인증서가 필요합니다. AWS에서 SSL 인증서 발급 및 등록 서비스를 제공하는 것이 ACM 입니다.
HTTPS 프로토콜로 통신하는 이유
주말의집 서비스에는 사용자로부터 개인정보를 제공 받아 서비스를 제공하고 있습니다. 따라서 개인 정보와 같은 민감한 데이터를 주고 받는 과정에서 제3자로부터의 개인 정보 탈취 등의 위험성으로부터 벗어나고자 HTTPS 프로토콜을 적용하기로 결정하였습니다.
HTTP에 데이터 암호화가 추가된 프로토콜로, HTTP와 다르게 443번 포트를 사용하며, 네트워크 상에서 중간에 제3자가 정보를 볼 수 없도록 암호화를 지원하고 있는 것이 HTTPS의 특징입니다.
- HTTP vs. HTTPS
HTTP(Hyper Text Transfer Protocol)란 서버/클라이언트 모델을 따라 데이터를 주고 받기 위한 프로토콜로 80번 포트를 사용합니다.
HTTP는 L7의 프로토콜로 TCP/IP 위에서 동작합니다. HTTP는 상태를 가지고 있지 않는 Stateless 프로토콜이며 Method, Path, Version, Headers, Body 등으로 구성됩니다.
암호화가 되지 않은 평문 데이터를 전송하는 프로토콜이기에 개인정보나 비밀번호 등 노출되어서는 안되는 정보를 주고 받기에 위험성이 존재하여 HTTPS가 등장하게 되었습니다.
HTTPS(Hyper Text Transfer Protocol Secure)는 HTTP에 데이터 암호화가 추가된 프로토콜입니다.
HTTPS는 대칭키 암호화 방식과 비대칭키 암호화 방식을 모두 사용하고 있습니다.
- 대칭키 암호화
- 클라이언트와 서버가 동일한 키를 사용해 암호화/복호화를 진행함
- 키가 노출되면 매우 위험하지만 연산 속도가 빠름
- 비대칭키 암호화
- 1개의 쌍으로 구성된 공개키와 개인키를 암호화/복호화 하는데 사용함
- 키가 노출되어도 비교적 안전하지만 연산 속도가 느림
공개키 : 모두에게 공개해도 되는 키
개인키 : 나만 알고 있어야 하는 키
HTTPS는 클라이언와 서버가 통신하기 위해 최초 연결 시, 비대칭키 방식으로 세션키를 공유하고 이후에는 빠른 연산 속도를 위해 대칭키 방식으로 동작한다.
- CloudFront
AWS에서 제공하는 CDN 서비스로, 캐싱을 통해 사용자에게 좀 더 빠른 전송 속도를 제공함을 목적으로 사용합니다. 주말의집에서는 이미지 데이터를 빠르게 제공하기 위해 도입하였습니다.
- CodeDeploy
AWS에서 제공하는 코드 자동 배포를 위한 서비스입니다.
주말의집에서는 Github Actions와 CodeDeploy를 이용해 자동화된 배포 프로세스를 구축하였습니다.
이를 위해 EC2 인스턴스 내에 CodeDeploy Agent를 설치해야 합니다.
Main 브랜치로 push 이벤트가 발생하면, Github Actions에서 bootJar를 통해 jar 파일을 생성하고 이를 압축하여 S3에 업로드합니다. 이후, CodeDeploy에서 S3에 업로드된 jar 파일을 인스턴스 내에서 실행시킵니다.
주말의집 어플리케이션은 크게 백오피스, 주말의집, 테크 블로그 3가지 서비스로 구분됩니다.
SSR 방식으로 구현된 백오피스(관리자페이지) 소스 코드입니다. SSR로 구현한 이유는 주말의집 서비스와는 달리 관리자 유저의 수가 극히 적고, 요구되는 기능이 많지 않기에 서버 측에서 화면과 기능 모두 구현하는 것이 리소스 절약의 방법이라고 생각했기 때문입니다. ( 여기서 리소스에는 인적 리소스도 해당합니다. )
백오피스에서 제공하는 기능은 크게 아래와 같습니다.
- 유저 관리
- 게시글 관리
- 신고 관리
유저 관리
- 공인중개사 회원가입 승인 (공인중개사 협회에 등록된 사용자인지 검증)
- 일반 사용자/공인중개사 회원탈퇴
- 사용자 회원가입 경로 및 연령대 데이터 관리 ( 그래프 시각화 )
게시글 관리
- 홍보 게시판 게시글 상단 고정
- 커뮤니티 게시판 게시글 영구 삭제
신고 관리
- 빈집 매물 게시글 신고 ( 신고된 게시글 삭제 혹은 수정 )
주말의집은 오도이촌을 알리는 소개 게시판과 사용자들 간의 자유로운 소통을 할 수 있는 커뮤니티, 빈집 매물을 거래할 수 있는 빈집 거래 3가지의 서비스로 구성됩니다.
이 중, 소개 게시판과 커뮤니티는 하나의 Board 테이블로 관리되고 있습니다.
각 게시판 고유의 특성이 존재함에 따라 별도의 테이블로 구성하여 관리하기로 결정했었습니다. 하지만 기능 개발을 진행하면서 중복되는 코드가 많아지고, 코드 관리의 어려움이 발생함에 따라 하나의 테이블로 관리하기로 결정하였으며, 각 게시판이 갖는 고유의 특성은 ENUM 클래스를 활용하는 방식으로 적용하였습니다.
테크 블로그 서비스는 관리자들에게만 작성 권한이 주어지는 서비스로, 회고록을 작성하고 공유하는 것을 목적으로 합니다. 이를 위해 Record 테이블로 각 게시글을 관리하고 있습니다. 테크 블로그 서비스는 팀 오도리에서 사용자에게 전하고자 하는 가치에 집중하기 보단 개인과 팀의 성장에 좀 더 집중한 서비스로, 새로운 시도와 변화를 적용해보기로 했습니다.
Record 테이블을 super-sub 타입으로 분리하여 회고/기술적용/문화 3가지 타입의 게시글로 구분됩니다.
CORS 대응을 위한 WebConfig,
인증/인가 처리를 위한 UserReSolver,
트랜잭션 및 API에 대한 Logging,
DDoS 대응을 위한 RateLimit,
Caching을 위한 CacheConfig,
쿼리 성능 향상을 위한 QueryDsl,
유효성 검사를 위한 커스터마이징 Validator,
공통 응답 객체 정의를 위한 ApplicationResponse,
공통 에러 정의를 위한 ApplicationError
를 Global로 정의하여 사용하고 있습니다.
- CrudRepository의
findByIdOrNull
커스터마이징
목적 : Service 계층에서 거의 모든 로직에서 findById
함수를 이용해 값을 조회하고 조회된 값이 없는 경우에 대한 오류 처리를 매번 해주고 있습니다. 코드 라인 수 감소와 주말의집에서 관리하는 공통 에러 형식으로 관리하기 위해 커스터마이징을 진행했습니다.
결과 : 기존의 작성 방식과 비교했을 때, 코드 라인 수가 줄어들었음이 직관적으로 보여지며, 유지보수에 있어 용이해졌습니다.
- Soft Delete 적용
목적 : 요구사항의 변경 및 추후 히스토리 추적에 용이하게 하기 위해 논리적 삭제 방식을 도입하였습니다. 서비스의 주된 테이블인 게시글에 대해 useYn 플래그를 두어 사용자가 삭제를 했음을 관리하며, 영구 삭제의 권한은 관리자에게만 부여했습니다.
결과 : 확장 기능으로 사용자가 삭제한 이력 혹은 사용자가 남기는 데이터 중 개인정보를 제외한 데이터에 한해 서비스 분석에 사용될 수 있습니다.
soft delete vs. hard delete
우리가 흔히 아는 삭제는 hard delete 방식입니다. 이는 물리적으로 데이터베이스 서버에 저장된 데이터를 지우는 행위로, 삭제된 데이터는 복구할 수 없습니다. ( 별도의 백업을 거치지 않는다고 가정했을 때 )
이와는 반대되는 개념이 논리적 삭제 방식입니다. 사용자가 삭제 행위를 하였음에도 실제 데이터베이스 서버에서 데이터를 삭제하는 것이 아닌 사용자로 하여금 데이터가 삭제되었다고 여겨지게 하는 방식입니다. 이를 위해 주말의집에서는 별도의 플래그를 두어 사용자에게 노출 여부를 조회 시, 조건으로 걸어 필터링 합니다.
이렇게 논리적 삭제 방식을 도입하는 또다른 이유는 cascade 옵션이 걸려 있는 테이블 관계에서 부모 테이블 혹은 자식 테이블의 삭제로 인해 영향이 발생할 수 있기 때문에 이를 방지하기 위한 목적이기도 합니다.
- ArgumentResolver를 커스터마징하여 인증/인가 구현
목적 : 스프링에서는 Spring Security 프레임워크를 통해 인증/인가 구현을 용이하게 해줍니다. 주말의집 서비스에서는 해당 프레임워크 없이 직접 구현함으로써 인증/인가의 흐름에 대한 이해를 다지고자 합니다.
ArgumentResolver를 커스터마이징한 UserResolver는 JWT 토큰을 검증하고 토큰으로부터 사용자 정보를 추출하여 사용자 객체 자체를 호출 시점에서 반환합니다.
컨트롤러에서 @Auth 어노테이션을 붙힌 메소드에 대해 AOP 방식으로 인증 과정을 거치며, 인증에 통과된 요청에 대해 메소드의 Argument로 사용자 객체를 사용할 수 있도록 반환합니다.
이 과정을 통해 Service 계층에서 사용자 email 혹은 id 등의 식별자로 사용자 객체를 조회하는 반복적인 코드를 줄일 수 있습니다.
또한, @Auth 어노테이션에 Authority enum 값을 넘겨주어 사용자에 대한 인가처리 역시 가능합니다.
인증과 인가
인증은 특정 서비스에 일정 권한이 주어진 사용자인지 확인하는 과정을 말합니다. 대표적으로 로그인이 인증 과정에 해당합니다.
인가는 한 번 인증을 받은 사용자에 대해 로그인이 되어있는지를 확인하고 서비스의 기능을 사용할 수 있도록 허가해주는 것을 말합니다.
인가를 구현하는 방식에는 여러가지가 있습니다.
대표적으로 세션이 있습니다.
세션은 입장권이라고 생각하면 쉽습니다. 서버에서 사용자에 대해 입장권을 발급하고, 입장권의 반쪽은 사용자에게 남은 반쪽은 서버에서 관리합니다. 사용자에게 부여된 입장권이 Session ID이며 이를 브라우저에 저장합니다. 이후 사용자가 서버에 요청을 보낼 때, 브라우저는 Session ID를 쿠키에 자동으로 담습니다. 서버는 이를 확인하고, 서버측에서 저장해둔 세션 정보를 토대로 사용자를 확인합니다.
Session ID를 이용해 사용자가 서버에 로그인한 상태임을 나타내는 것이 세션입니다.
그러나, 세션 방식에는 문제점이 존재합니다. 서버 측에서 세션 정보를 저장할 때, 대개 메모리에 저장해둡니다. 메모리가 아닌, 데이터베이스 혹은 디스크에 저장해두기도 하지만, 속도 측면에서 메모리를 선택합니다.
많은 사용자가 동시에 서버에 접속하게 된다면, 서버 측에서 세션 정보를 저장하기 위한 메모리 공간이 부족해질 수 있고 혹여라도 서버가 다운된다면 메모리에서 관리하던 세션 정보를 모두 잃을 수 있습니다.
혹은 분산된 서버에서 동일한 사용자 요청을 처리한다고 했을 때, 세션 클러스터링이 되어 있지 않다면 사용자는 로그인을 했음에도 서비스를 이용하지 못할 수도 있습니다.
이러한 문제를 해결하기 위해 등장한 것이 토큰 방식입니다.
JWT는 JSON Web Token의 줄임말입니다. JWT는 헤더, 페이로드, 서명으로 구성되어 있으며 이는 각각 누가 누구에게 토큰을 발급했고 언제까지 유효하며 서비스가 사용자에게 공개하기 원하는 내용을 담고 있습니다. 이때 사용자 정보를 Claim이라고 합니다.
JWT는 서버 측에서 생성한 암호키를 이용해 암호화를 하기 때문에 토큰을 탈취당해 페이로드 값을 변경해도 뒤에 오는 서명 값이 완전히 달라지기 때문에 조작이 불가능해집니다.
세션처럼 stateful하지 않기에, 사용자가 PC에서 로그인하고 모바일 기기로 다시 로그인했을 때, PC의 세션을 종료시킬 수 없습니다.
이를 보완하기 위해 등장한 방법이 access token과 refresh token을 이용하는 것입니다.
유효시간을 짧게 갖는 access 토큰으로 서버에 요청을 주고 받다가 토큰 시간이 만료되면 유효시간을 길게 갖는 refresh 토큰을 이용해 access 토큰을 재발급 받습니다.
이 방식 역시 앞서 말한 문제를 해결할 수는 있습니다. refresh 토큰을 저장소에서 제거하여 access 토큰을 갱신할 수 없도록 하고 요청을 보내고자 할 때에는 로그인을 하도록 할 수 있습니다. 다만, access 토큰의 유효시간이 지나지 않은 상태라면, 이렇게 처리를 하더라도 세션이 유지되는 상태가 발생할 수 있기에 근본적으로 해결할 수 있다고 보기는 어렵습니다.
따라서 서비스의 특징에 따라 적절한 방식을 적용해야 합니다.
- 주말의집 서비스와 백오피스 인가 구현
- 주말의집 서비스
주말의집 서비스는 일반 사용자뿐만 아니라 공인중개사 사용자가 존재하며, 백오피스에 비해 상대적으로 유저 수가 많습니다. 세션 방식과 토큰 방식 중 서버 측 메모리에 부담이 가지 않는 토큰 방식을 통해 인가를 구현하였습니다.
Refresh Token을 서버 측에서 Redis 서버에 저장하고 클라이언트 측에서 Local Storage에서 저장하고 있었습니다. 유효 시간이 긴 Refresh Token을 브라우저에서 관리하는 것은 XSS 공격 등으로 탈취 당할 위험이 있다고 판단해서 Refresh Token을 쿠키에 담아 저장하는 방식으로 변경하였습니다.
쿠키를 사용하는 방식이 무조건적으로 안전하다고 할 수는 없으나 Access Token을 재발급 받기 위한 요청에만 사용되고 인가가 필요한 요청에는 접근할 수 없기에 탈취의 위험성보다는 상대적으로 안전하다고 판단하였습니다.
단, 자바스크립트에서 쿠키의 Refresh Token으로 접근이 불가능하도록 HTTP Only 옵션을 적용하였으며 HTTPS의 암호화를 사용해 secure 옵션을 걸어주었습니다.
HTTP Only && HTTPS Secure
쿠키는 클라이언트에서 자바스크립트로 조회할 수 있기에 해킹의 대상이 되기 쉽습니다. 이를 방지하고자 설정하는 옵션이 바로 HTTP Only입니다.
자바스크립트 상에서가 아닌 네트워크 상에서의 직접 요청을 가로채 정보를 탈취할 수도 있습니다. 이를 막기 위해 HTTPS 프로토콜을 이용해 Secure 옵션으로 데이터를 암호화 처리하여 제 3자로부터 보호할 수 있습니다.
- 백오피스
백오피스는 팀 오도리에서 관리하는 계정 4개의 관리자만 접근할 수 있는 서비스이며, 상대적으로 유저 수가 적고 요청 수도 적습니다. 따라서 세션 방식을 적용하여 인가를 구현하였습니다.
- 회원 탈퇴 로직 고도화
사용자 테이블의 데이터 중 개인 정보가 포함된 것은 닉네임, 전화번호, 이메일, 비밀번호가 있습니다. 주 서비스로 커뮤니티를 제공함에 따라 사용자가 작성한 게시글과 댓글 데이터를 히스토리로써 보관하고자 데이터베이스에서 사용자 데이터를 물리적으로 삭제하기 보다 논리적으로 삭제하는 방식으로 구현하였습니다.
앞선 게시글 데이터의 논리적 삭제와는 달리 사용자가 회원 탈퇴를 할 경우, 개인정보 데이터를 "" 공백 데이터로 update 하고 닉네임은 "탈퇴한 회원"으로 변경합니다.
- 정적 쿼리에서 동적 쿼리로 조회 API 통일화
목적 : 주말의집 서비스 중 오도이촌 소개 게시판과 커뮤니티에서 사용되는 게시글 데이터는 Board 테이블 하나로 관리됩니다. 각 게시판을 구분하기 위해 카테고리와 말머리를 ENUM class로 사용하고 있었으며, 게시글 조회 시 필터링 조건으로 필수 값으로써 사용됩니다.
각 게시판에 맞는 데이터를 조회하기 위해서는 카테고리에 따른 조회 API와 말머리와 검색어에 따른 조회 API를 호출해야 합니다.
이는 유지보수 측면에서 서버와 클라이언트에게 비용 발생 구간으로 작용하기에 이를 조회 조건에 따른 동적 쿼리를 통해 하나의 API에서 처리할 수 있도록 개선하였습니다.
결과 : @ModelAttribute를 사용하여 여러 개의 Param을 하나로 묶어 관리하였으며, 동적 쿼리에서 이를 Enum 으로 필터링하여 처리하도록 구성하였습니다. 이를 통행 양측의 유지보수 비용을 줄일 수 있었으며 확장성 역시 높아졌습니다. ( 분리되었던 3개의 조회 API -> 1개의 동적 쿼리를 수행하는 조회 API )
@RequestParam vs. @ModelAttribute
클라이언트가 서버로 요청을 보낼 때, 쿼리 파라미터로 데이터를 넘겨주는 경우가 있습니다.
예를 들어, 아래와 같은 요청이 있을 수 있습니다.
GET localhost:8080
/api/v1/boards/search?title=안녕하세요
이는 Spring에서 다음과 같이 사용됩니다.
class BoardController {
... 중략
@GetMapping("/api/v1/boards/search")
fun search(
@RequestParam title : String
) : String {
return "안녕하세요"
}
}
@RequestParam은 이처럼 클라이언트가 보내는 요청에 쿼리 파라미터가 서버의 Controller의 인자로 1:1 매핑이 되도록 해주는 어노테이션입니다.
하지만, 사용자가 게시글 데이터에 대해 검색하고자 하는 조건이 여러개로 늘어나게 된다면 이를 모두 argument로 받는 것이 효율적일까요?
class BoardController {
... 중략
@GetMapping("/api/v1/boards/search")
fun search(
@RequestParam title : String,
@RequestParam content : String,
@RequestParam author : String,
@RequestParam tag : String,
@RequestParam createdAt : String,
) : String {
return boardService.search(title, content, author, tag, createdAt)
}
}
위의 코드를 보면, 검색 조건이 1개에서 5개로 늘어난 것을 확인할 수 있습니다. 여기서 조건의 추가 혹은 삭제가 발생하게 된다면 개발자는 controller에서의 argument
, boardService.search() 호출시 넘겨주는 매개변수
,
BoardService 클래스의 search() 메소드의 시그니쳐
3군데를 수정해야 합니다.
이는 유지보수 비용 발생 지점이 되며, 확장성 측면에서 비효율적입니다.
여러 개의 Param을 확장에 용이하도록 받아내기 위해서 사용할 수 있는 것이 바로 @ModelAttribute 어노테이션입니다.
위의 코드를 @ModelAttribute 어노테이션을 적용해서 재작성 하면 아래와 같습니다.
data class BoardListDto(
private title: String,
private content : String,
private author: String,
private tag: String,
private createdAt: String
)
class BoardController {
... 중략
@GetMapping("/api/v1/boards/search")
fun search(
@ModelAttribute boardListDto: BoardListDto
) : String {
return boardService.search(boardListDto)
}
}
@ModelAttribute 어노테이션은 여러 개의 쿼리 파라미터를 하나의 객체로 바인딩 시켜주며, 이때 객체에는 getter,setter가 정의되어야 합니다.
- Validator 커스터마이징
클라이언트가 서버로 데이터를 전송할 때, 사전에 정의한 유효성 기준에 따라 서버에서는 유효성 검증 과정을 거칩니다. 이때 @Validated
어노테이션을 이용해 @RequestBody 객체에 대해 유효성 검증 과정을 거치고 검증에서 오류가 발생할 경우 MethodArgumentNotValidException 오류를 발생시킵니다.
Spring에서 개발을 할 때, 유효성 검증에 사용되는 어노테이션은 2가지가 있습니다.
- @Valid
java에서 제공하는 유효성 검증 어노테이션으로, @PathVariable, @RequestParam에도 적용할 수 있습니다.
- @Validated
Spring Framework에서 제공하는 유효성 검증 어노테이션으로, @RequestBody와 함께 사용되며 JSON 형태의 데이터에 대해 검증을 거칩니다.
두 어노테이션은 유효성 검증을 수행한다는 점과 MethodArgumentNotValidException 오류를 발생시킨다는 점에서 동일한 기능을 수행합니다. 차이가 있다면 검증 대상의 그룹 지정 여부입니다.
여기서 그룹의 의미는 DTO 내에 유효성 검증을 원하는 데이터에 대해 묶어서 사용할 수 있음을 뜻합니다.
주말의집에서는 @Validated 어노테이션을 이용해 DTO에 대해 유효성 검증을 거칩니다.
Validation 의존성을 주입하면, JSR303 라이브러리에서 제공하는 기본 어노테이션으로 검증을 수행할 수 있습니다. 이는 보편적으로 사용되는 검증에 대해 사전에 정의된 것으로 복잡한 비즈니스 로직 혹은 선택적으로 필수값이 되는 요구사항에 있어서는 검증 수행에 있어 어려움이 있습니다.
이를 해소하기 위해 ConstraintValidator
를 사용할 수 있습니다.
목적: 해당 인터페이스를 상속받아 Validator를 커스터마이징하여 다음과 같은 요구사항을 충족하는 유효성 검증을 수행하고자 합니다.
- 빈집 매물 게시글에는 빈집 매물에 대한 상세 정보가 담깁니다.
- 빈집 매물 게시글은 Editor를 이용해 자유롭게 글의 형식을 바꿀 수 있습니다.
- 빈집 매물 게시글에는 이미지가 여러장 담길 수 있습니다.
- 빈집 매물 게시글의 게시글 내용은 최대 10,000자를 넘길 수 없습니다.
요구사항을 보면, 게시글은 단순 글자로만 구성된 것이 아닌 Editor로 작성되며 이미지를 포함하고 있습니다. 따라서 서버로 넘어올 때, 아래와 같은 형식이 될 것입니다.
"code" : "<body> <div> <h2>글자수가 10,000자를 넘어서는지 테스트해보기 위한 글입니다. 행복부동산 최신 매물입니다.</h2> </div> <div> 오도이촌, 세컨하우스를 꿈꾸고 있는 여러분, 오도이촌으로 어떤 지역을 꿈꾸고 계시나요? 오도이촌을 위해 세컨하우스를 택할 지역을 고를 때 가장 중요한 기준으로는 ‘편리한 교통’, ‘수려한 자연환경’, ‘뛰어난 편의시설’ 등이 있는데요. 이번 컨텐츠에서는 어떤 지역이 여러분의 세컨하우스 기준을 충족하는 지역일지 제시해 드리고자 합니다. 경기도 도심과 가까운 지역, 양평 양평은 도심과 가깝고 교통이 좋아 오도이촌으로 특히 인기가 많은 지역입니다. 1) 용이한 교통 양평은 서울에서 차로 약 50분 거리에 위치해있는데요. 경의중앙선 용문역, 6번 국도 등 교통 인프라가 잘 갖추어져 있습니다. 인근에는 서울 송파~양평 고속도로 개통(2023년 예정)도 앞두고 있어 교통환경이 더욱 개선될 전망입니다. 2) 관광지/자연환경 양평은 용문 5일장, 용문사, 중원계곡, 용문 관광단지 등으로도 유명합니다. 볼거리와 먹을거리, 놀거리가 많고 레저활동을 하기 좋은 여건을 갖추고 있습니다. 또한 용문산과 북한강이 어우러진 입지로, 하늘과 숲과 물이 하나가 되는 자연환경을 누릴 수 있습니다.
<img src="img_url"/>
</body>"
여기서 순수한 게시글의 내용만을 추출하여 글자수가 10,000자 이하인지를 판별하기 위해서는 @Validated에서 제공하는 @Size 어노테이션으로는 한계가 있습니다.
class CodeValidator : ConstraintValidator<CodeValid, String>{
override fun isValid(value: String, context: ConstraintValidatorContext?): Boolean {
val contents = getContent(value)
return contents.length <= 10000
}
}
@Target(*[AnnotationTarget.FIELD])
@Retention(AnnotationRetention.RUNTIME)
@Constraint(validatedBy = [CodeValidator::class])
@Documented
annotation class CodeValid(
val message: String = "게시글의 내용이 10,000자를 넘을 수 없습니다.",
val groups: Array<KClass<*>> = [],
val payload: Array<KClass<out Payload>> = []
)
위와 같이 ConstraintValidator 인터페이스를 상속받아 Validator를 구현하고 내부에 isValid() 를 override 해줍니다.
이때 getContent()는 글로벌로 정의된 함수로, HTML 태그를 파싱해주는 함수입니다. 해당 함수는 Matcher와 Pattern을 이용합니다.
결과: 이렇게 커스터마이징을 했을 때의 이점은 다음과 같습니다.
- 일관된 방식의 유효성 검증
JSR303 라이브러리에서 제공하는 검증 어노테이션과 같이 컨트롤러 진입 직전 Interceptor에서 검증할 수 있다는 점에서 검증 시점을 일관되게 가져갈 수 있습니다.
- 일관된 ApplicationException 발생
서버 측에서 global 하게 정의한 에러 객체를 사용할 수 있습니다. 통일화된 에러 반환은 클라이언트와 서버 간의 일관된 방식으로 비즈니스 로직을 관리할 수 있습니다.
- 조회 성능 개선을 위한 캐싱 전략 고안
목적: 주말의집 서비스는 오도이촌 소개, 커뮤니티, 빈집 거래 3가지 서비스를 제공합니다. 비회원일 경우에도 주말의집에서 제공하는 게시글 데이터를 조회할 수 있기에 CUD 보다 R의 API 요청의 발생이 높을 것으로 예상합니다.
따라서 조회 요청에 대한 성능 개선으로 Redis를 이용한 캐싱을 시도해보려 합니다.
캐싱
캐싱은 나중에 요청될 결과를 미리 저장해두었다가 빠르게 서비스를 해주는 것을 의미합니다.
캐싱 전략은 아래와 같습니다.
- Look Aside (Lazy Loading)
사용자가 요청한 데이터가 캐시에 존재하는지의 여부를 확인하고 있다면(cache hit), DB 서버에 요청을 날리지 않고 바로 반환합니다. 만약, 캐시에 존재하지 않더라도(cache miss) DB 서버에서 조회 후, 캐시에 저장해두면 되기에 서비스 장애로 이어지지 않습니다.
이 전략은 데이터베이스와 캐싱 스토어 간의 데이터 정합성 문제가 발생할 수 있습니다. 초기 조회 시, 무조건 데이터베이스에서 조회해야 하기에 반복적으로 동일한 쿼리를 날리는 시스템에 적합합니다.
- Read Through
캐시에서만 데이터를 조회하는 전략으로 사용자가 요청한 데이터가 캐시에 존재하지 않는다면, 데이터베이스에서 읽어와 캐시에 저장한 뒤, 캐시에서 읽어 응답으로 반환합니다. 이는 데이터 조회를 캐시에 의존하기에 캐시 서버가 다운되면 서비스에 영향이 발생할 수 있습니다.
데이터베이스로의 요청을 최소화하고, READ에 소모되는 자원을 줄일 수 있다는 점이 특징입니다. Redis 서버의 고가용성을 보장하기 위해 Replica 혹은 cluster로 구성하여 서비스의 장애 발생 지점을 줄여야 합니다.
- Write Back
데이터를 저장할 경우, 데이터베이스에 바로 요청을 보내는 것이 아닌 Redis에서 보유하고 있다가 일정 시간에 요청을 한 번에 보내는 방식입니다. 그렇기에 쓰기 작업에 대한 요청 쿼리 수가 줄어들어 부하를 감소시킬 수 있으며 쓰기 작업이 빈번하면서 읽기 작업이 많은 서비스의 경우 이 전략이 적합합니다.
다만, 캐시에서 오류가 발생할 경우 원본 데이터를 모두 잃을 수 있습니다.
- Write Through
데이터베이스와 캐시 모두에 저장하는 방식입니다. 앞선 방식과의 차이는 캐시에 저장한 뒤 바로 데이터베이스에도 저장한다는 점입니다. 이를 통해 캐시와 데이터베이스 간의 데이터 정합성이 항상 보장됩니다. 다만, 매번 쓰기 작업이 2번씩 발생하며, 수정/생성이 잦은 서비스의 경우 성능 이슈가 발생할 수 있습니다.
- Write Around
모든 데이터를 데이터베이스에 저장합니다. cache miss가 발생하는 경우에만 데이터베이스에서 조회하여 캐시에 저장합니다. 그렇기에 캐시와 데이터베이스 간의 데이터 정합성이 보장되지 않을 수 있습니다.
따라서 데이터의 CUD가 발생할 때마다 캐시도 갱신해주어야 하며 캐시의 TTL을 짧게 조정하는 방식으로 데이터 정합성을 높여야 합니다.
주말의집에서 선택한 캐싱 전략은 Look Aside + Write Around 방식
입니다.
조회 시, 캐시에 존재하면 이를 반환하고 없을 경우 데이터베이스에서 조회합니다. 또한, 캐싱된 데이터에 대해 수정/삭제/생성 등의 이벤트가 발생할 경우 기존의 캐시와 비교하여 갱신합니다.
해당 전략을 도입한 위치는 메인 페이지의 좋아요 수를 가장 많이 받은 게시물 top5 목록 조회
, 커뮤니티 페이지의 게시글 카테고리와 검색어에 따른 게시물 조회(페이징 처리)
입니다.
메인 페이지는 서비스에서 가장 많이 호출되는 페이지로, 해당 부분에서 성능 개선이 우선적으로 적용되어야 한다고 판단했습니다. 추후 서버의 scale-out을 고려하여 글로벌하게 사용하기 위해 Redis를 선택하였습니다.
scale-out을 고려하는 이유는 스프링 어플리케이션 서버를 각각 별도의 서버로 분리하고 이를 auto-saciling으로 고가용성을 보장하게 되는 아키텍처로 확장하게 될 경우를 대비하기 위함입니다. 이 경우, 서버 간의 캐싱된 데이터에 대해 데이터 정합성이 떨어지게 되므로 외부 서버를 두어 캐싱을 하는 것이 바람직하다고 판단했습니다.
페이징 객체를 반환하는 부분에 캐싱을 적용하면서 직렬화 이슈를 마주했씁니다. 응답 객체의 wrapper로 Page 인터페이스를 사용하고 있기에 기본 생성자가 없어 직렬화/역직렬화를 수행할 수 없어 발생한 이슈였습니다. 이를 해결하기 위해 Page의 구현체인 PageImpl을 상속하여 커스터마이징하였습니다.
결과: 같은 요청에 대한 응답 시간을 158ms -> 39ms 로 단축시켰습니다.
Redis와 같이 외부 서버에 캐싱을 적용하는 방식이 아닌 로컬 캐싱 방식도 적용시켜 보며, 서비스의 성격과 캐싱 전략 사이의 효율성을 찾아보고자 Ehcache 라이브러리를 적용해보았습니다.
EhCache
EhCache는 오픈소스 기반 라이브러리로 속도가 빠르며 경량 Cache라는 특징이 있습니다. Local Cache로 사용되기에 디스크와 메모리에 저장할 수 있으며, 서버 간 분산 캐시(동기/비동기)를 지원합니다.
적용이 쉬우며, 경량 캐시, 빠른 속도라는 점에서 시도해보면 좋을 것 같아 빈집 매물 게시글 목록 조회에 캐싱을 적용하였습니다.
앞선 페이징처리와 마찬가지로 PageImpl 클래스를 커스터마이징하여 DTO를 캐싱하도록 하였습니다.
로컬 캐싱의 경우, 분산 서버 간의 데이터 정합성 문제가 발생할 수 있습니다. 따라서 한 번 저장된 데이터 중 수정/삭제가 빈번하지 않을 서비스에 적용하면 좋을 것 같다고 판단하였습니다. 주말의집 서비스에서 그것에 해당하는 것이 빈집 거래입니다. 사용자 혹은 공인중개사로부터 빈집 매물을 얻는 것이 힘듦을 잘 알고 있습니다. 적은 사용자로부터 많은 데이터가 유입될 것이라고 예상하지 않기에 해당 서비스에 적용해보면 좋을 것 같다고 판단했습니다.
결과 : 66ms -> 10ms 로 평균적으로 요청에 대한 응답 처리 속도는 10ms 내외입니다.
정리
사용자의 접근성이 쉬운 커뮤니티와 관리자가 자주 업데이트 하는 소개 페이지의 경우, 분산된 환경에서 각 서버간의 데이터 정합성이 중요하다고 생각했습니다.
핵심 서비스이나 서비스 초기 단계에서 적은 사용자로부터 많은 데이터를 확보하기 힘든 빈집 거래 서비스는 CUD 요청의 발생 빈도가 현저히 적을 것이라 판단했습니다.
따라서 서비스의 특징을 고려해 글로벌 캐싱은 커뮤니티와 메인 페이지에, 로컬 캐싱은 빈집 거래 서비스에 적용했습니다.
- 이미지 캐싱 및 리사이징
목적: 사용자가 게시글에 추가하는 이미지의 크기가 가변적이어서 전체적인 게시글의 가독성이 떨어지는 문제를 해결하고자 이미지의 크기를 리사이징하기로 했습니다.
이는 AWS Lambda 서비스를 통해 적용하였으며, on-the-fly 방식으로 진행하였습니다. on-the-fly 이미지 리사이징은 클라이언트에서 썸네일 이미지를 요청할 때, 실시간으로 이미지를 리사이징하여 제공하는 방식입니다.
이미지가 업로드되고 최초로 해당 이미지를 요청하게 되면 클라이언트 입장에서는 리사이징까지 시간 지연이 발생합니다. 이를 개선하고자 AWS의 CDN 서비스인 CloudFront를 사용하였습니다. CDN에 이미지가 캐싱되면 클라이언트에서 해당 이미지를 요청할 때, 캐싱된 이미지를 제공하기에 시간 지연의 문제를 해소할 수 있습니다.
또한, 이미지를 스토리지에 업로드하는 시점에 on-the-fly 방식으로 리사이징하게 되면 스토리지에서 이미지가 차지하는 데이터의 용량이 줄어들게 되므로 스토리지의 용량의 증가폭을 줄일 수 있다는 이점이 있습니다.
결과: Cache Miss의 경우, 2.36s 소요되나 Cache Hit의 경우, 25ms로 응답 속도가 대폭 줄어듦을 확인할 수 있습니다.
- 개인정보 보호를 위한 사용자 테이블 암호화
목적: 사용자로부터 제공 받는 개인정보를 관리자 및 제3자로부터 보호하기 위해 어플리케이션에서 암호화 처리를 하여 데이터베이스에 저장합니다.
암호화/복호화 처리를 위한 util 클래스를 만들어 데이터베이스와 통신할 경우 converter를 통해 util 클래스에서 정의한 암/복호화를 하도록 구현하였습니다.
결과: 어플리케이션 계층에서 암/복호화가 이루어지기 때문에 데이터베이스에 콘솔로 접근하더라도 개인정보의 내용을 열람할 수 없습니다.
- DDoS 방지 대응
목적: 특정 사용자가 서비스에서 제공되는 기능을 과도하게 이용해서 DDoS 적인 현상을 발생시킬 수 있음으로 이를 사전에 방지하고자 합니다.
이를 위해 자바 진영에서는 Bucket4J 라이브러리를 제공합니다. Rate Limit 알고리즘이 적용된 라이브러리로, 특정 IP와 특정 Access-key 등에 대해 일정 횟수로 사용 제한을 하거나, 사용 시간 제한 등을 적용해서 사용 제한을 넘는 요청에 대해 거부할 수 있습니다.
하나의 버킷에 토큰의 개수를 정할 수 있고, 한 번의 요청마다 한 개의 토큰이 사용되는 방식입니다.
주말의집에서는 외부 API를 사용해야 하는 요청에 대해 버킷에 총 100개의 토큰을 설정해두어 0.6초에 1개의 토큰을 생성합니다.
그 외의 요청에 대해서는 버킷에 총 10000개의 토큰을 설정해두어 0.006초 동안 1개의 토큰을 생성합니다.
- 무중단 배포
목적: 서버 배포 중에 서비스의 다운타임을 최소화하기 위해 무중단 배포 방식을 적용하고자 합니다.
CodeDeploy 서비스를 통해 배포를 진행하는데, 배포과정에서 ALB에 등록된 대상그룹에 대해 모든 트래픽을 차단하면서 503 에러가 발생하였습니다. 이를 해결하기 위해 CodeDeploy의 배포 그룹 설정에서 ALB 활성 상태를 비활성 상태로 변경하습니다.
결과: 배포 시간을 6분 -> 20초
로 단축시켰습니다.
- AOP를 이용한 로깅처리
목적: 트랜잭션의 실행시간을 측정하고, 이를 로그로 출력하기 위해 AOP를 사용하였습니다.
AOP
관점 지향 프로그래밍의 약자로, 어떤 로직을 기준으로 핵심적인 관점, 부가적인 관점으로 나누어서 보고 그 관점을 기준으로 각각 모듈화하는 것을 의미합니다.
주말의집 서비스에서 핵심적인 관점은 트랜잭션의 수행이며, 부가적인 관점은 트랜잭션의 실행시간 측정입니다. 이 2가지 관점을 분리하여 각각 모듈화를 하기 위해 AOP 방식으로 로깅을 합니다.
주말의집 서비스에서 서비스 계층에 한정해서 로깅을 적용한 이유는 트랜잭션 처리시간을 확인하는 것이 로깅의 주 목적이었기 때문입니다. 쿼리 튜닝의 지표로써 활용될 수 있기에 해당 데이터가 유의미하다고 판단하였습니다.
서비스 계층과 레포지토리 계층을 분리하여 각각 로깅할 경우, 서비스 계층에서 발생하는 더티 체킹에 의한 트랜잭션과 순수 레포지토리에서 발생하는 트랜잭션에 대한 처리가 모호해진다고 판단하여 서비스 계층에 한정했습니다.
스프링 AOP는 프록시 방식을 기반에 두고 있으므로 join point가 메소드 호출 시점으로 한정됩니다. 서비스계층의 각각의 메소드를 가리키도록 point cut을 설정하였으며, 서비스 내부의 메소드가 실행되기 이전과 이후의 시간을 계산하여 실행시간을 구하였습니다. 이때 실행시간이 1초를 초과하면 warn 레벨로 로그를 출력하도록 하였으며, 각각의 요청 별로 로그를 구분하기 위해 임의의 문자열을 Thread-ID로 설정하였습니다.
Thread-ID 부여로 인해 동시성 이슈가 발생할 수 있어 해당 데이터를 ThreadLocal에 저장하였습니다.
ThreadLocal
쓰레드 단위로 지역 변수를 할당하는 기능을 제공하는 클래스입니다. 메소드 안에서 선언된 지역 변수는 메소드가 끝날 때 라이프 사이클이 함께 종료되고, 리턴하거나 파라메터로 전달해주지 않으면 다른 메소드에서 사용할 수 없습니다.
ThreadLocal의 get()를 통해 고유의 식별값을 가져올 수 있습니다.