관리 메뉴

개발 블로그

일체형 아키텍처(monolithic architecture) 분리하기 - 1 본문

백엔드 개발

일체형 아키텍처(monolithic architecture) 분리하기 - 1

anomie7 2019.04.01 18:21

일체형 아키텍처(monolithic architecture) 분리하기 - 1

세 가지 형태의 서버 구조

개인프로젝트에서 사용자 로그를 저장하고 조회하는 기능을 기존의 리소스 서버에서 분리했다.
수정 이전의 리소스 서버는 아동 컨텐츠 검색 기능과 사용자의 컨텐츠 조회 사항을 기록하고 메인페이지에 최근에 조회한 컨텐츠를 제공하는 역할을 함께 해주었다.
이번에 사용자의 검색 조건을 사용자 로그로 저장을 하는 기능을 추가하면서 리소스 서버에서 유저 로그와 관련된기능을 분리했다.
이번 글에서는 세가지 서버의 구조를 볼 것이다.

  • 관계형 데이터베이스에서 모든 기능을 처리하는 일체형 아키텍처
  • 사용자 로그용 저장소를 추가한 일체형 아키텍처
  • 사용자 로그만을 다루는 마이크로서비스 아키텍처
    점점 개선되는 구조를 보면서 마이크로서비스 아키텍처 (이하 MSA(Microservice Architecture)로 생략)의 장점을 알아보자

관계형 데이터베이스에 유저 로그를 저장하는 구조

나에게 가장 익숙한 처리방식이 바로 이런 구조였다.
컨텐츠의 조회와 해당 컨텐츠를 유저가 조회했다는 기록을 남기는 것은 밀접한 관련이 있다.
그래서 위와 같은 워크 플로를 형성하는 것이 가장 직관적이고 쉽다.

  1. 서버에 api 명세에 따라 요청을 보낸다.
  2. DB에서 content 테이블에서 요청받은 Id에 해당하는 레코드를 읽는다.
  3. 유저 로그를 기록한다.

테이블 구조는 대략 아래와 같다.

위 구조의 문제점은 무엇 일까?

  • 애플리케이션 서버 관점에서 getContent(), saveUserLog()가 묶여있다.
    • 사용자 입장에서 중요한 기능은 getContent()이다. 그런데 만약 saveUserLog()에서 Exception이 발생한다면 사용자의 요청에 대해서 에러(40x, 50x)를 반환할 수 있다.
    • 위 문제의 해결 방법은 try/catch문으로 content는 정상적으로 반환하는 방법이 있다.
  • '/content/{contentId}' 요청은 빈번한데 getContent()는 읽기 작업이고 saveUserLog()는 쓰기 작업이다.
    • 유저 로그를 기록하는 것은 가장 빈번한 쓰기 작업이다. 데이터의 양도 많이 쌓이고 쓰기작업을 하면 읽기 작업보다 DB에 부하는 많이 생긴다.
    • 일반적으로 주문, 회원가입, 장바구니 등등 중요한 기능을 위한 테이블들이 하나의 데이터베이스에 생성되어 여러 작업이 처리된다.
    • 필자는 회사에서 단순 호기심으로 몇 만건 씩 쌓인 유저 로그를 조회하다가 DB가 죽는 경험을 한적도 있다. (다행히 개발용 DB라 실 서비스에 영향은 없었지만 아찔한 경험이었다.)
    • RDS를 사용하면 이런 상황에 대응하기 용이하지만 비교적 중요하지 않은 유저 로그 테이블 때문에 더 중요한 비즈니스에 장애가능성이 생기는 것은 문제다.

유저 로그 작업을 위한 저장소를 추가한 구조

위와 같은 문제점을 인지하고 Cache로 사용하던 레디스에 유저 로그를 기록하도록 수정했다.
'/contentLog'라는 유저로그를 기록하는 컨트롤러를 새로 만들어서 유저 로그 기록 요청을 분리했다.
클라리언트 단에서 비동기적으로 서버에 요청을 두번 보낸다. 한번은 search()라는 메소드에서 컨텐츠를 10개씩 가져오는 요청이고, 개별 컨텐츠의 컴포넌트를 클릭한다면 showModel()이라는 메소드가 실행되면서 storeLog()라는 메소드가 redis에 로그를 저장하는 작업을 한다.

 

  1. content를 검색하는 http 요청을 애플리케이션 서버의 controller에 보냄
  2. query string으로 받은 검색 조건으로 cache에 저장되는 형태의 키를 만들고 cache에 존재하는지 확인한다.(cache는 key-balue 저장소인 redis이다.)
  3. cache에서 key의 존재 유무에 따라 로직이 다르다
    1. 3-a에서 검색조건에 해당하는 키가 조회된다면 그대로 cache에서 내용을 불러온다. 그리고 사용자에게 데이터를 보낸다.
    2. 3-b에서 검색조건에 해당하는 키가 없다면 RDBMS에서 관련 내용을 조회하기 위해서 ContentService에서 searchAllContent()를 호출한다.
  4. repository class에서 rdbms에 접근해서 데이터를 불러온다.
  5. 불러온 데이터를 Cache에도 저장해준다. 그리고 사용자에게 데이터를 보낸다.
  6. 사용자가 컨텐츠의 상세 내용을 확인하기 위해 클릭하는 이벤트가 발생한다.
  7. 해당 컨텐츠를 조회했다는 사실을 저장하기 위해 '/contentLog'라는 http 요청을 애플리케이션 서버에 보낸다.
  8. 유저의 Log를 저장하기 위해 saveContentLog()라는 메소드가 실행된다.
  9. repository class가 cache에 요청받은 내용을 입력한다.

위 구조의 문제점은 무엇 일까?

  • 우선 단일 책임 원칙에 어긋난다.
    • MSA에서 컴포넌트들은 실행환경의 측면에서 자기완비적인 형태 여야하고, 독립적이고 자율적인 하나의 서비스의 형태로 배포될 수 있어야한다.
    • 현재 리소스 서버는 로그를 저장하는 기능과 사용자가 검색한 컨텐츠를 제공하는 기능을 함께 수행하고 있다.
    • 로그 저장소인 redis가 캐시 역할을 같이 하고 있다.
    • 고작 로그 저장하는 기능 때문에 왜 굳이 번거롭게 애플리케이션을 분리하냐고 생각할 수 있다. 그러나 유저 로그와 관련된 요구사항이 추가되었다. 그래서 로그 기능을 분리할 필요성이 더욱 커졌다.
      • 웹앱의 메인화면에서 사용자가 최근 조회한 컨텐츠를 보여주어야 한다.
      • 사용자가 검색한 조건을 저장소에 따로 보관하고 최근 7일 동안 가장 많이 검색한 조건을 메인화면에 노출해야한다.
      • 추후 데이터가 쌓이면 사용자 추천 엔진을 개발해서 유저 로그 서비스에 접근이 빈번하게 생길 예정이다.
  • 늘어나는 트래픽에 유연하게 수평적인 확장할 수 없다.
    • 컨텐츠 검색 기능과 사용자 로그를 관리하는 기능 중 더 요청이 많은 기능은 검색 기능이다. 이용자 증가로 검색 기능에 요청이 늘어나서 컨테이너를 하나 더 띄웠는데 유저 로그 사용량은 현상유지 수준이라면 수평적 확장을 했지만 유저 로그 서비스에 사용되는 자원 만큼의 낭비가 발생하는 것이다.
    • 컨테이너를 더 띄우지 않고 EC2 이미지를 업그레이드하는 수직적 확장을 할 수 있겠지만 이 것도 자원의 낭비라는 측면에서 위의 사례와 크게 다르지 않다 수직적 확장으로는 사용자 요청의 증감에 대한 유연한 대응이 불가능하다.
    • 인프라는 컨테이너 오케스트레이션 툴을 AWS 인스턴스에서 사용해서 수평적인 확장이 이미 쉽게 가능한데 애플리케이션 서버가 유연적인 확장이 불가능한 구조라면 애써 생산적인 툴과 cloud 서비스를 사용하는 의미가 반감된다.

유저 로그 기능을 완전히 분리한 구조

위와 같은 문제점을 개선하기 위해 유저 로그와 관련된 기능을 모두 별도의 애플리케이션 서버로 분리했다.
저장소는 firestore로 변경했다. 모델의 저장 구조가 redis 보다는 안정적이라고 생각했기 때문이다.
그리고 표현형 쿼리, 트랜잭션, 일괄 처리 등의 기능을 제공하고 무엇보다 관리형 서비스라는 점에서 안정적이고 편하게 사용할 수 있다.
사용자가 최근 조회한 컨텐츠를 노출해주는 기능에서 resource server에서 content를 불러와야하는데.
이때 RestTemplate로 http 요청을 보내서 받아온다.

  1. 사용자가 최근 조회한 컨텐츠를 조회하기 위해서 유저 로그 서버에 http 요청을 보낸다.
  2. 사용자가 최근 조회한 컨텐츠를 불러오기 위한 getEvents()를 호출한다.
  3. firestore에서 contentLog를 불러온다. rdbms에서 inner join같은 로직을 처리하기 위해서 contentLog를 불러오는 것이다. contentLog의 속성은 eventId와 userId, timestamp가 있다. firestore의 표현형 쿼리를 이용해서 userId가 같은 로그 중 최근의 로그 일정 수를 조회힌다.
  4. firestore에서 가져온 contentLog에서 id들을 추출하고 중복을 제거하는 등의 처리를 수행한다.
  5. restTemplate로 http 요청을 리소스 서버에 보내서 컨텐츠를 제공받는다. 데이터는 JsonNode 객체로 받지 않고 DTO를 만들어서 전달받아 사용자에게 반환한다.

위 구조의 문제점은 없을까?

  • 유저 로그 관련 기능은 완전히 분리를 했다. 앞으로 유지보수할 때 머리가 덜 아플 것이다. MSA는 컴포넌트들을 단일한 서비스 단위로 구성하면서 일체형 아키텍처에 비해서 기능 추가와 유지보수가 더 용이하다는 장점이 있다. 그리고 두번째 구조에서 말했던 수평적 확장이 용이해졌다.
  • MSA는 비즈니스 로직과 실행환경까지 모두 추상화하는 서버의 구조라고 알고있다. 현재 ContentDTO가 존재하고 있어서 리소스 서버에서 Content에 새로운 속성이 추가되거나 수정이 가해지면 유저 로그 서버에서 ContentDTO도 같이 수정해야하는 문제가 있다. 그래서 단순히 JSON 형태로 받아서 그대로 사용자에게 뿌려주는 것으로 구현했었지만 테스트 코드를 작성하기 어렵고 fallback method를 작성하기 힘든 점이 있어서 DTO를 만들었다. DTO 정도는 어쩔 수 없다고 생각한다. DTO를 향후 jar로 만들어서 gradle 등으로 제공받는 것도 고려해봐야겠다.
  • MSA에서는 컴포넌트 자체가 하나의 독립되고 자율적인 서비스여야 한다. 그런데 새로 분리한 유저 로그 서버가 리소스 서버에 의존성이 있다. 컨텐츠를 조회하는데 의존하고 있기 때문이다. 이 경우 장애전파가 안 되도록 하는 것이 중요하다. 그래서 Cirecuit Breaker인 Hystrix를 도입했고 fallback 메소드도 구현해서 리소스 서버가 죽더라도 일정한 결과를 사용자가 받을 수 있도록 했다.

마치며

세가지 구조를 제시하면서 점점 개선되어가는 형태를 설명하면서 MSA의 특징이나 MSA로 개선되면서 얻는 장점에 대해서 다루어 보았다.
다음 글에서는 구현하면서 경험한 여러 주제를 다루어 보겠다. hystrix를 적용해서 fallback 메소드를 구현하는 것과 수평적 확장의 방식, 그 밖에 생각나는 주제들을 정리해서 올릴 생각이다.
구현한 내용은 필자의 개인 프로젝트 결과물들이며 아래 링크들로 확인할 수 있다.
백엔드 구성에 관한 링크
위드키즈

2 Comments
  • 프로필사진 지나가다 2019.04.02 17:11 생산자 소비자 패턴으로 로그등의 처리를 위한 큐를 만들고. 해당 큐를 소모하는 소비자를 excutor로 적용하는 방법은 어떤가요?
    MSA도입전 일단 기본 구조를 로그기록과 컨텐츠 로드를 비동기화 시키면 제시하신 문제는 (로그쪽 에러 발생시 서비스 멈춤) 사라질 거라 예상합니다.

    큐를 통해 처리하면 추후에 msa 로 전환시에도 소비자쪽에서 다른 서비스(로그서비스)서버로 call를 하여 로그기록 하는 것으로 간단히 적용 가능할듯 하고요..

    뭐 그냥 지나가다 적어 봅니다.
  • 프로필사진 anomie7 2019.04.05 21:47 신고 네 현재 고려하고 있는 패턴입니다. 간단한 예제를 통해서 RabbitMQ와 스프링 애플리케이션을 연동해서 테스트도 해봤구요.

    하지만 아직 처음 접하는 패턴이라 어색하네요. 개인적으로 프론트엔드 라이브러리에서 Flux 패턴을 구현한 Redux나 vuex같은 라이브러리가 연상되는 구조였던 것 같습니다. 아마 프론트 엔드 라이브러리들이 비동기적이고 이벤트 기반으로 작동되어서 그런거 같습니다.

    현재 컴포넌트간의 의존성 문제는 Hystrix와 fallback method로 장애 전파를 막는 정도로 대응하고 있습니다.
댓글쓰기 폼