728x90

캐시


자주 사용하는 데이터나 값을 미리 저장해 놓는 임시 저장소 서버나 데이터베이스에 가해지는 부하를 줄이고 성능을 높이기 위해 사용한다. 자주 변경되고 삭제되는 데이터에 적용하면 오히려 성능 저하를 일으킬 수 있다.

 

 

종류


Local Cache

서버 내부에 저장한다.

  • 서버 내부에서 동작하기 때문에 속도가 빠르다.
  • 서버 Resource(Memory, Disk)를 사용한다.

서버 간에 데이터를 공유할 수 없다.

  • 분산 서버인 경우 데이터 정합성이 깨지는 문제가 발생할 수 있다.

종류

  • Ehcache, Caffeine

 

Global Cache

Cache Server를 별도로 사용한다.

서버 간에 데이터를 공유할 수 있다.

  • 네트워크 트래픽이 발생하기 때문에 Local Cache보다 속도가 느리다.

종류

  • Redis, Memcached

 

CDN (Content Delivery Network), Web Caching

프록시 서버(Proxy Server)를 물리적으로 분산하여 사용자 위치를 기준으로 가장 가까운 프록시 서버에서 캐싱되어 있는 웹 콘텐츠를 제공함으로써 웹 페이지 로드 속도를 높이는 서버 네트워크

트래픽이 각 서버로 분산된다.

 

 

 

고려 사항


일반적으로 캐시는 메모리(RAM)에 저장되기 때문에 무분별하게 저장해버리면 용량 부족 현상이 발생하여 시스템이 다운될 수 있다.

캐시 서버에 장애가 발생하면 트래픽이 데이터베이스로 몰리게 되어 과부하로 인해 데이터베이스가 다운될 수 있다. 캐시 서버가 장애로부터 복구되는 동안 데이터베이스가 버틸 수 있도록 대비해야 한다. (캐시 서버를 계층적으로 구축하기도 한다)

캐시를 언제까지 유지시킬 건지 Expire Time 또는 Time-To-Live(TTL)정책과 삭제 알고리즘을 고려해야 한다.

  • Cache Stampede: 부하가 높은 상태에서 캐시가 만료되어 순간적으로 데이터베이스 읽기 작업과 캐시 쓰기 작업이 중복으로 발생하는 현상
  • Hot Keys: 하나의 키에 읽기가 집중될 때도 성능이 떨어질 수 있습니다. 위 글에서는 그 대책으로 키 이름 앞에 Prefix를 붙여 여러 복제본을 만든 뒤, 그 Prefix가 붙은 복제본에 랜덤으로 읽기를 분산시키는 방법을 소개하고 있습니다.
  • Compression: 크기가 큰 데이터를 레디스에 저장할 때도 성능 저하가 발생할 수 있습니다. 이때 적절한 압축을 적용하는 것만으로도 속도와 메모리 사용량에서 큰 이득을 볼 수 있습니다. 적절한 압축 방법과 비율은 상황과 환경에 따라 다를 수 있기 때문에, 이를 적용할 때는 사용 환경을 재현한 벤치마크 테스트가 필수입니다.
  • 만료 주기가 너무 길면 메모리 부족 현상이 발생하거나 데이터 정합성이 깨질 수 있다.
  • 중요한 정보나 민감한 정보는 저장하지 않는다.

 

Local Cache VS Global Cache

  • 데이터 정합성이 깨져도 비즈니스에 영향이 없는 부분은 Local Cache, 데이터 정합성이 중요한 부분에는 Global Cache를 선택할 수 있다.
  • Cloud 환경(Docker, AWS EC2 등)이라면 JVM Memory를 사용하는 Cache는 적합하지 않을 수 있다.
  • Typical caching setupLocal Cache를 첫 번째 수준 Cache로 사용하고 Global Cache를 두 번째 수준 Cache로 사용할 수 있다.
  • Local Cache는 Memory를, Global Cache는 네트워크 트래픽을 많이 사용하는 점을 고려하여 하나 이상을 결합해서 사용하면 효율성이 높아질 수 있다.

 

 

캐시(Cache) 전략


캐싱 전략은 웹 서비스 환경에서 시스템 성능 향상을 기대할 수 있는 중요한 기술이다.

일반적으로 캐시(cache)는 메모리(RAM)를 사용하기 때문에 데이터베이스 보다 훨씬 빠르게 데이터를 응답할 수 있어 이용자에게 빠르게 서비스를 제공할 수 있다. 하지만 기본적으로 RAM의 용량은 커봐야 16 ~ 32G 정도라, 데이터를 모두 캐시에 저장해버리면 용량 부족 현상이 일어나 시스템이 다운될 수 있다.

 

따라서 어느 종류의 데이터를 캐시에 저장할지, 얼만큼 데이터를 캐시에 저장할지, 얼마동안 오래된 데이터를 캐시에서 제거하는지에 대한 '지침 전략' 을 숙지할 필요가 있다.

Tip

캐시를 효율적으로 이용하기 위해서는 캐시에 저장할 데이터 특성도 고려해야 한다. 예를 들어 자주 조회되는 데이터, 결괏값이 자주 변동되지 않고 일정한 데이터, 조회하는데 연산이 필요한 데이터를 캐싱해 두면 좋다.

 

 

노드나 스프링으로 서버를 만드는데 있어, 자신의 프로젝트에 캐시 메모리를 적용하려 할 때, 요청(request)이 라우터에 오면 어느 상황에서 어떤 식으로 캐시를 사용할 것인지 보다 효율적으로 서비스가 빠릿빠릿하게 돌아가도록 설계하는 데 있어 참고가 되기를 바란다.

참고로 들어가기 앞서 선수 지식이 필요한데, 바로 cache hit 과 cache miss이다.

  • cache hit : 캐시 스토어(redis)에 데이터가 있을 경우 바로 가져옴 (빠름)
  • cache miss : 캐시 스토어(redis)에 데이터가 없을 경우 어쩔 수 없이 DB에서 가져옴 (느림)

 

 

 

캐싱 전략 패턴 종류


캐시를 이용하게 되면 반드시 닥쳐오는 문제점이 있는데 바로 데이터 정합성 문제이다. 데이터 정합성이란, 어느 한 데이터가 캐시(Cache Store)와 데이터베이스(Data Store) 이 두 곳에서 같은 데이터임에도 불구하고 데이터 정보값이 서로 다른 현상을 말한다.

 

쉽게 말하면, 캐시에는 어떤 게시글의 좋아요 개수가 10개로 저장되어 있는데 데이터베이스에는 7개로 저장되어 있을 경우 정보 불일치가 발생하게 된다. 이전에는 그냥 DB에서 데이터 조회와 작성을 처리하였기 때문에 데이터 정합성 문제가 나타나지 않았지만, 캐시라는 또 다른 데이터 저장소를 이용하기 때문에, 결국 같은 종류의 데이터라도 두 저장소에서 저장된 값이 서로 다를 수 있는 현상이 일어날 수밖에 없는 것이다.

 

따라서 적절한  캐시 읽기 전략(Read Cache Strategy)캐시 쓰기 전략(Write Cache Strategy)을 통해 캐시와 DB 간의 데이터 불일치 문제를 극복하면서도 빠른 성능을 잃지 않게 하기 위해 고심히 연구를 할 필요가 있다.

 

 

캐시 읽기 전략 (Read Cache Strategy)


Look Aside 패턴

  • Cache Aside 패턴이라고도 불림.
  • 데이터를 찾을 때 우선 캐시에 저장된 데이터가 있는지 우선적으로 확인하는 전략. 만일 캐시에 데이터가 없으면 DB에서 조회함.
  • 반복적인 읽기가 많은 호출에 적합.
  • 캐시와 DB가 분리되어 가용되기 때문에 원하는 데이터만 별도로 구성하여 캐시에 저장
  • 캐시와 DB가 분리되어 가용되기 때문에 캐시 장애 대비 구성이 되어있음. 만일 redis가 다운되더라도 DB에서 데이터를 가져올 수 있어 서비스 자체는 문제가 없음.
  • 대신에 캐시에 붙어있던 connection이 많았다면, redis가 다운된 순간순간적으로 DB로 몰려서 부하 발생.

 

Look Asdie Cache 패턴은 애플리케이션에서 캐싱을 이용할 때 일반적으로 사용되는 기본적인 캐시 전략이다.

이 방식은 캐시에 장애가 발생하더라도 DB에 요청을 전달함으로써 캐시 장애로 인한 서비스 문제는 대비할 수 있지만, Cache Store와 Data Store(DB) 간 정합성 유지 문제가 발생할 수 있으며, 초기 조회 시 무조건 Data Store를 호출해야 하므로 단건 호출 빈도가 높은 서비스에 적합하지 않다. 대신 반복적으로 동일 쿼리를 수행하는 서비스에 적합한 아키텍처이다.

이런 경우 DB에서 캐시로 데이터를 미리 넣어주는 작업을 하기도 하는데 이를 Cache Warming이라고 합니다.

Info

[ Cache Warming ] 미리 cache로 db의 데이터를 밀어 넣어두는 작업을 의미한다. 이 작업을 수행하지 않으면 서비스 초기에 트래픽 급증 시 대량의 cache miss 가 발생하여 데이터베이스 부하가 급증할 수 있다. (Thundering Herd) 다만, 캐시 자체는 용량이 적어 무한정으로 데이터를 들고 있을 수는 없어 일정시간이 지나면 expire 되는데, 그러면 다시 Thundering Herd가 발생될 수 있기 때문에 캐시의 TTL을 잘 조정할 필요가 있다. (뒤에서 자세히 설명)

Tip

Thundering Herd는 모든 지점에서 발생되는 것은 아니고, 서비스의 첫 페이지와 같은 대부분의 조회가 몰리는 지점에서 주로 발생된다고 보면 된다.

 

Read Through 패턴

  • 캐시에서만 데이터를 읽어오는 전략 (inline cache)
  • Look Aside와 비슷하지만 데이터 동기화를 라이브러리 또는 캐시 제공자에게 위임하는 방식이라는 차이가 있음.
  • 따라서 데이터를 조회하는 데 있어 전체적으로 속도가 느림.
  • 또한 데이터 조회를 전적으로 캐시에만 의지하므로, redis가 다운될 경우 서비스 이용에 차질이 생길 수 있음.
  • 대신에 캐시와 DB 간의 데이터 동기화가 항상 이루어져 데이터 정합성 문제에서 벗어날 수 있음
  • 역시 읽기가 많은 워크로드에 적합

 

 

Read Through 방식은 Cache Aside 방식과 비슷하지만, Cache Store에 저장하는 주체가 Server이냐 또는 Data Store 자체이냐에서 차이점이 있다.

이 방식은 직접적인 데이터베이스 접근을 최소화하고 Read에 대한 소모되는 자원을 최소화할 수 있다.

하지만 캐시에 문제가 발생하였을 경우 이는 바로 서비스 전체 중단으로 빠질 수 있다. 그렇기 때문에 redis과 같은 구성 요소를 Replication 또는 Cluster로 구성하여 가용성을 높여야 한다.

Tip

이 방식 또한 서비스 운영 초반에 cache warming을 수행하는 것이 좋다.

 

 

캐시 쓰기 전략 (Write Cache Strategy)


Write Back 패턴

  • Write Behind 패턴 이라고도 불림.
  • 캐시와 DB 동기화를 비동 기하기 때문에 동기화 과정이 생략
  • 데이터를 저장할 때 DB에 바로 쿼리하지않고, 캐시에 모아서 일정 주기 배치 작업을 통해 DB에 반영
  • 캐시에 모아놨다가 DB에 쓰기 때문에 쓰기 쿼리 회수 비용과 부하를 줄일 수 있음
  • Write가 빈번하면서 Read를 하는데 많은 양의 Resource가 소모되는 서비스에 적합
  • 데이터 정합성 확보
  • 자주 사용되지 않는 불필요한 리소스 저장.
  • 캐시에서 오류가 발생하면 데이터를 영구 소실함.

 

 

Write Back 방식은 데이터를 저장할때 DB가 아닌 먼저 캐시에 저장하여 모아놓았다가 특정 시점마다 DB로 쓰는 방식으로 캐시가 일종의 Queue 역할을 겸하게 된다.

캐시에 데이터를 모았다가 한 번에 DB에 저장하기 때문에 DB 쓰기 횟수 비용과 부하를 줄일 수 있지만, 데이터를 옮기기 전에 캐시 장애가 발생하면 데이터 유실이 발생할 수 있다는 단점이 존재한다. 하지만 오히려 반대로 데이터베이스에 장애가 발생하더라도 지속적인 서비스를 제공할 수 있도록 보장하기도 한다.

Tip

이 전략 또한 캐시에 Replication이나 Cluster 구조를 적용함으로써 Cache 서비스의 가용성을 높이는 것이 좋으며, 캐시 읽기 전략인 Read-Through와 결합하면 가장 최근에 업데이트된 데이터를 항상 캐시에서 사용할 수 있는 혼합 워크로드에 적합하다.

 

Write Through 패턴

  • 데이터베이스와 Cache에 동시에 데이터를 저장하는 전략
  • 데이터를 저장할 때 먼저 캐시에 저장한 다음 바로 DB에 저장 (모아놓았다가 나중에 저장이 아닌 바로 저장)
  • Read Through와 마찬가지로 DB 동기화 작업을 캐시에게 위임
  • DB와 캐시가 항상 동기화되어 있어, 캐시의 데이터는 항상 최신 상태로 유지
  • 캐시와 백업 저장소에 업데이트를 같이 하여 데이터 일관성을 유지할 수 있어서 안정적
  • 데이터 유실이 발생하면 안 되는 상황에 적합
  • 자주 사용되지 않는 불필요한 리소스 저장.
  • 매 요청마다 두 번의 Write가 발생하게 됨으로써 빈번한 생성, 수정이 발생하는 서비스에서는 성능 이슈 발생
  • 기억장치 속도가 느릴 경우, 데이터를 기록할 때 CPU가 대기하는 시간이 필요하기 때문에 성능 감소

 

 

Write Through 패턴은 Cache Store에도 반영하고 Data Store에도 동시에 반영하는 방식이다. (Write Back은 일정 시간을 두고 나중에 한꺼번에 저장)

그래서 항상 동기화가 되어 있어 항상 최신정보를 가지고 있다는 장점이 있다.

하지만 결국 저장할 때마다 2단계 과정을 거쳐 치기 때문에 상대적으로 느리며, 무조건 일단 Cache Store에 저장하기 때문에 캐시에 넣은 데이터를 저장만 하고 사용하지 않을 가능성이 있어서 리소스 낭비 가능성이 있다.

Tip

write throuth 패턴과 write back 패턴 둘 다 모두 자주 사용되지 않는 데이터가 저장되어 리소스 낭비가 발생되는 문제점을 안고 있기 때문에, 이를 해결하기 위해 TTL을 꼭 사용하여 사용되지 않는 데이터를 반드시 삭제해야 한다. (expire 명령어)

Tip

Write-Through 패턴과 Read-Through 패턴을 함께 사용하면, 캐시의 최신 데이터 유지와 더불어 정합성 이점을 얻을 수 있다. 대표적인 예로 AWS의 DynamoDB Accelerator(DAX)가 있다. DAX 패턴을 통해 DynamoDB에 대한 읽기 및 쓰기를 효율적으로 수행할 수 있다.

 

Write Around 패턴

  • Write Through 보다 훨씬 빠름
  • 모든 데이터는 DB에 저장 (캐시를 갱신하지 않음)
  • Cache miss가 발생하는 경우에만 DB와 캐시에도 데이터를 저장
  • 따라서 캐시와 DB 내의 데이터가 다를 수 있음 (데이터 불일치)

 

 

 

Write Around 패턴은 속도가 빠르지만, cache miss가 발생하기 전에 데이터베이스에 저장된 데이터가 수정되었을 때, 사용자가 조회하는 cache와 데이터베이스 간의 데이터 불일치가 발생하게 된다.

따라서 데이터베이스에 저장된 데이터가 수정, 삭제될 때마다, Cache 또한 삭제하거나 변경해야 하며, Cache의 expire를 짧게 조정하는 식으로 대처해야 한다.

Tip

Write Around 패턴은 주로 Look aside, Read through와 결합해서 사용된다. 데이터가 한 번 쓰이고, 덜 자주 읽히거나 읽지 않는 상황에서 좋은 성능을 제공한다.

 

 

기타


Refresh Ahead

자주 사용되는 데이터를 캐시 만료 전에 미리 TTL (Expire time)을 갱신합니다.

캐시 미스 발생을 최소화할 수 있지만 Warm Up 작업과 마찬가지로 자주 사용되는 데이터를 잘 예측해야 합니다.

 

 

캐시 읽기 + 쓰기 전략 조합


Look Aside + Write Around 조합

  • 가장 일반적으로 자주 쓰이는 조합

 

 

Read Through + Write Around 조합

  • 항상 DB에 쓰고, 캐시에서 읽을 때 항상 DB에서 먼저 읽어오므로 데이터 정합성 이슈에 대한 완벽한 안전장치를 구성할 수 있음

 

 

Read Through + Write Through 조합

  • 데이터를 쓸 때 항상 캐시에 먼저 쓰므로, 읽어올때 최신 캐시 데이터 보장
  • 데이터를 쓸때 항상 캐시에서 DB로 보내므로, 데이터 정합성 보장

 

 

캐시 저장 방식 지침


캐시 솔루션은 자주 사용되면서 자주 변경되지 않는 데이터의 경우 캐시 서버에 적용하여 반영할 경우 높은 성능 향상을 이뤄낼 수 있다. 이를 Cache Hit Rating이라고 한다. 일반적으로 캐시는 메모리에 저장되는 형태를 선호한다.

 

메모리 저장소로는 대표적으로 Redis와 MemCached가 있으며 이와 같은 솔루션은 메모리를 1차 저장소로 사용하기 때문에 디스크와 달리 제약적인 저장 공간을 사용하게 된다. 많아야 수십 기가 정도의 저장소를 보유하게 되며, 이는 결국 자주 사용되는 데이터를 어떻게 뽑아 캐시에 저장하고 자주 사용하지 않는 데이터를 어떻게 제거해 나갈 것이냐를 지속적으로 고민해야 할 필요성이 있다.

 

따라서 캐시를 저장하는 시점은 자주 사용되며 자주 변경되지 않는 데이터를 기준으로 하는 것이 좋다.

또한 한 가지 고민해야 할 사항은 캐시 솔루션은 언제든지 데이터가 날아갈 수 있는 휘발성을 기본으로 한다는 점이다.

이는 데이터를 주기적으로 디스크에 저장함으로서 어느 정도 해결을 볼 수는 있지만, 실시간으로 모든 데이터를 디스크에 저장할 경우 성능 저하를 일으킬 수 있어 어느 정도 데이터 수집과 저장 주기를 가지도록 설계해야 된다.

 

즉 데이터의 유실 또는 정합성이 일정 부분 깨질 수 있다는 점을 항상 고려해야 한다.

따라서 캐시에 저장되는 데이터는 중요한 정보, 민감 정보 등은 저장하지 않는 것이 좋으며, 캐시 솔루션이 장애가 발생했을 경우 적절한 대응방안을 모색해 두는 것이 바람직하다. (TimeOut, 데이터베이스 조회 병행 등)

 

[ 파레토 법칙 - 8:2 법칙 ] 파레토 법칙이란 전체 결과의 80%가 전체 원인의 20%에서 일어나는 현상을 가리킨다. 서비스에 빗대어 표현하자면 80%의 활동을 20%의 유저가 하기 때문에 20%의 데이터만 캐시 해도 서비스 대부분의 데이터를 커버할 수 있게 된다는 말이다. 즉, 캐시에 모든 데이터를 저장할 필요 없이 "파레토 법칙"에 따라 일부만 저장해도 대부분의 데이터를 커버할 수 있다는 저장 지침이다.

 

캐시 제거 방식 지침


캐시 데이터의 경우 캐시 서버에만 단독으로 저장되는 경우도 있지만, 대부분 영구 저장소에 저장된 데이터의 복사본으로 동작하는 경우가 많다. 이는 2차 저장소(영구 저장소)에 저장되어 있는 데이터와 캐시 솔루션의 데이터를 동기화하는 작업이 반드시 필요함을 의미하며, 개발 과정에 고려사항이 늘어난다는 점을 반드시 기억해야 한다.

 

예를 들어 캐시 서버와 데이터베이스에 저장되는 데이터의 commit 시점에 대한 고려 등이 예가 될 수 있다.

캐시의 만료 정책이 제대로 구현되지 않은 경우 클라이언트는 데이터가 변경되었음에도 오래된 정보가 캐싱되어있어 오래된 정보를 사용할 수 있다는 문제점이 발생한다.

 

따라서 캐시를 구성할 때 기본 만료 정책을 설정해야 한다. 캐시 된 데이터가 기간 만료 되면 캐시에서 데이터가 제거되고, 응용 프로그램은 원래 데이터 저장소에서 데이터를 검색해야 한다. 그래서 캐시 만료 주기가 너무 짧으면 데이터는 너무 빨리 제거되고 캐시를 사용하는 이점은 줄어든다. 반대로 너무 기간이 길면 데이터가 변경될 가능성과 메모리 부족 현상이 발생하거나, 자주 사용되어야 하는 데이터가 제거되는 등의 역효과를 나타낼 수도 있다.

 

 

Cache Stampede 현상


 

 

대규모 트래픽 환경에서 TTL 값이 너무 작게 설정하면 cache stampede 현상이 발생할 가능성이 있다.

캐시 공간은 한정되어 있으므로 저장된 데이터에 만료시간(TTL)을 정하는 것이 보통인데, 해당 데이터에 계속 읽기 요청이 들어오고 있을 때 캐시 만료시간이 닥치면 순간적으로 DB로 가서 찾게 되는 duplicate read 가 발생한다.

또 읽어온 값을 각 각 redis에 쓰는 duplicate write 도 발생되어, 처리량도 다 같이 느려질 뿐 아니라 불필요한 작업이 굉장히 늘어나 요청량 폭주로 장애로 이어질 가능성 도 있다.

 

PER(Probablistic Early Recomputation)

  • 이 현상을 해결하기 위해 PER 알고리즘을 도입할 수 있습니다. 이 알고리즘은 키의 TTL이 실제로 만료되기 전에 일정 확률로 캐시를 갱신하는 방법입니다.
  • 데이터베이스에서 키가 완전히 만료되기 전에 데이터를 먼저 읽어오게 함으로써 Cache Stampede 현상을 막을 수 있습니다.

Debouncing

  • 여러 번 반복되는 이벤트 중 마지막 이벤트만 실행하기

 

 

캐시 공유 방식 지침


캐시는 애플리케이션의 여러 인스턴스에서 공유하도록 설계된다. 그래서 각 애플리케이션 인스턴스가 캐시에서 데이터를 읽고 수정할 수 있다. 따라서 어느 한 애플리케이션이 캐시에 보유하는 데이터를 수정해야 하는 경우, 애플리케이션의 한 인스턴스가 만드는 업데이트가 다른 인스턴스가 만든 변경을 덮어쓰지 않도록 해야 한다.

 

그렇지 않으면 데이터 정합성 문제가 발생하기 때문이다. (각 애플리케이션마다 표시되는 개수가 달라지는 현상)

데이터의 충돌을 방지하기 위해 다음과 같은 애플리케이션 개발 방식을 취해야 한다.

 

먼저, 캐시 데이터를 변경하기 직전에 데이터가 검색된 이후 변경되지 않았는지 일일이 확인하는 방법이다. 변경되지 않았다면 즉시 업데이트하고 변경되었다면 업데이트 여부를 애플리케이션 레벨에서 결정하도록 수정해야 한다. 이와 같은 방식은 업데이트가 드물고 충돌이 발생하지 않는 상황에 적용하기 용이하다.

 

두 번째로, 캐시 데이터를 업데이트하기 전에 Lock을 잡는 방식이다. 이와 같은 경우 조회성 업무를 처리하는 서비스에 Lock으로 인한 대기현상이 발생한다. 이 방식은 데이터의 사이즈가 작아 빠르게 업데이트가 가능한 업무와 빈번한 업데이트가 발생하는 상황에 적용하기 용이하다.

 

캐시 가용성 지침


캐시를 구성하는 목적은 빠른 성능 확보와 데이터 전달에 있으며, 데이터의 영속성을 보장하기 위함이 아니라는 점을 기억하고 설계해야 한다. 데이터의 영속성은 기존 데이터 스토어에 위임하고, 캐시는 데이터 읽기에 집중하는 것이 성능 확보의 지침 사항이 될 수 있다.

 

또한, 캐시 서버가 장애로 인해 다운되었을 경우나 서비스가 불가능할 경우에도 지속적인 서비스가 가능해야 한다. 이는 캐시에 저장되는 데이터는 결국 기존 영구 데이터 스토어에 동일하게 저장되고 유지된다는 점을 뒷받침 하는 설계방식이다. (Write Through)

즉, 캐시 서버가 장애로부터 복구되는 동안 성능상의 지연은 발생할 수 있지만, 서비스가 불가능한 상태가 되지 않도록 고려해야 한다는 말이다.

 

 

참고 출처

https://dico.me/back-end/articles/326/ko

https://inpa.tistory.com/entry/REDIS-📚-캐시Cache-설계-전략-지침-총정리

https://velog.io/@soongjamm/Caffeine-Cache-를-적용해본-경험

https://velog.io/@soongjamm/Eventual-Consistency-란

https://bcp0109.tistory.com/364

https://meetup.nhncloud.com/posts/251

728x90
728x90

Spring Camp 늦은 후기


2024년 5월 한국 스프링 사용자 모임(KSUG)이 주관하는 스프링 캠프 컨퍼런스에 다녀왔다. 선착순이라 큰 기대하지 않았는데 운 좋게도 성공을 했다. 총 두가지의 트랙으로 진행이 되었기 때문에 관심이 있는 트랙을 선택하며 들을 수밖에 없었다.

이외에 세션들은 유튜브를 통해서 공개될 예정으로 알고 있어서 크게 걱정하지 않았다.

현재는 1개월전에 이미 모두 업로드 완료 상태이다.

 

 

1. 동시성의 미래 - 코루틴과 버츄얼 스레드 (이상훈 님)


이상훈 님은 수천, 수만의 동시 요청을 처리하는 서버를 구현하기 위한 다양한 기술 중, 전통적인 웹 방식(thread per request)부터 시작해 리액티브 프로그래밍, 그중에서 코틀린의 코루틴과 JDK 21에서 정식 릴리즈된 버추얼 스레드를 중점적으로 기술의 차이와 통합 방법에 대해 설명해 주셨다.

 

코루틴

핵심 내용 중 하나는 Kotlin 코루틴의 작동 방식을 이해하는 것이었다. 다수의 코루틴플랫폼 스레드에 매핑되어 사용되기 때문에 매우 가벼워서 기존 스레드보다 더 효율적이다. 그러나 코루틴은 여전히 플랫폼 스레드에서 실행되므로 결국 스레드가 블로킹되는 환경에서 성능이 저하될 수 있다.

 

버츄얼 스레드

그다음으로는 버츄얼 스레드를 설명해 주셨는데 해당 키워드를 이 강의를 통해 처음 알게 되었다. 버츄얼 스레드는 jdk 21에 릴리즈 된 기능으로 코루틴과 유사하게 JVM 레벨의 경량 스레드다수의 버츄얼 스레드캐리어 스레드에 매핑되어 사용된다는 것이다. 따라서 코루틴과 다르게 전통적인 동기식으로 코드 작성이 가능하며 논블로킹 환경의 코루틴 성능과 비슷하다는 것을 알았다. 하지만 아직 성능을 100% 내기는 어려운 환경이므로 리서치가 필수적으로 보인다.

 

코루틴과 버츄얼 스레드의 통합

이뿐만 아니라, 코루틴의 환경과 버츄얼 스레드를 통합하여 사용하면 코루틴의 단점인 블로킹 상황의 성능 하락 이슈버츄얼 스레드가 해결 가능할 것으로 보이며 상호운용을 통해 서로의 단점을 해결할 수 있어 보인다는 말씀도 주셨는데 굉장히 인상 깊었다.

특히 이번 스프링 캠프에서 전반적으로 코루틴에 대한 이야기가 꽤 많았다. 커지는 서비스에 비례하는 요청량, 다수의 MSA로 분리되는 서비스들, 길어지는 네트워크 구간을 해결하기 위해 모두 동시성 기법을 많이 채택하여 사용하는 것 같아 코루틴에 관심이 많이 가지게 되었다.

코루틴은 다음 포스팅을 통해 살펴보며 프로젝트에 사용해 볼 생각이다.

 

 

2.Spring Coroutine in action (최진영 님)


최진영 님은 spring에서 kotlin coroutine 활용에 초첨을 맞추어, 비동기 프로그래밍과 coroutine의 동작구조부터 spring 프레임워크에서의 실제 적용을 중점적으로 이야기해 주셨다.

 

코루틴과 구조적 프로그래밍

기존의 비동기 코드는 새 스레드의 실행이 새로운 실행 흐름으로 넘어가므로 흐름의 제어가 되고 있지 않기 때문에 이러한 관점에서 구조적 프로그래밍이라고 볼 수 없다는 것이다.

 

하지만 비동기를 사용하지 않을 수 없는 것은 모두 다 알 것이다.

코루틴은 각 스레드의 실행 후 실행한 부모 스레드의 흐름으로 돌아오게끔 보장하여 Structured Concurrency로 인해 코루틴이 손실되거나 누수되지 않음 등 코루틴의 전반적인 내용들은 굉장히 상세하게 말씀 주셨다.

 

해당 강의는 코루틴에 대해 공부할 때 한번 더 자세히 파악해보려고 한다.

 

3. 왜 나는 테스트를 작성하기 싫을까? (조성아 님)


조성아 님은 정말 직관적인 강의 제목으로 이론적이거나 추상적인 테스트 이야기 대신에, 간단한 예제와 실제 경험을 토대로 빠르게 작성하고 피드백을 받을 수 있는 테스트에 대해 얘기해 주셨다.
특히 픽스쳐 몽키란 테스트 관련 오픈소스 라이브러리를 개발하신 분으로 픽스쳐 몽키를 사용해 본 적 있던 나에게는 내적친밀감을 주셨다.

 

테스트 코드를 작성하기 싫은 이유

특히 요즘은 테스트 코드에 중요성에 대해 모두 다 알고 있는 만큼 왜 작성하기 싫은지에 대해 아주 간결하게 말씀 주시는 부분이 인상 깊었다. 테스트 코드란 무안단물이 아니며 테스트 코드를 작성하기 싫은 이유비용이득보다 크기 때문이라고 하셨다.
테스트 코드의 비용이란 작성 비용유지 비용이 있는데 테스트에 많은 기대를 하면 그게 모두 비용이 되며 각 테스트 단계마다 기대하는 정도가 다르기 때문이라고 하셨다.

 

비용을 결정하는 요소

특히 가장 공감이 됐던 부분은 테스트 코드 작성 비용을 줄일 수 있는 부분을 말씀 주셨는데 그게 바로 giventhen이었다.
필요하지 않은 복잡한 테스트 작성(given) 많은 검증(then)하려 하는 부분들을 줄여야 한다고 말씀 주셨는데 통합테스트와 인수테스트에 무거운 given, then 공감하고 있던 나에게 의미가 깊던 강의였다.

 

물론 각 프로젝트와 조직에 맞춰서 적절한 테스트 비용을 결정하는 것이 가장 좋은 방법일 것 같다.
끝으로 픽스쳐 몽키의 유용성에 대해서도 굉장히 많이 말씀 주셨는데 한번 사용해 볼 때 굉장히 간단히 기능들을 사용해 보았으나 꼭 필요한가? 란 생각이 들던 나에게 다시 한번 해당 라이브러리의 자세히 기능들을 살펴봐야겠다고 생각이 들었던 강의였다.

 

4. 실전! MSA 개발 가이드 (김용욱 님)


 

김영욱 님은 마이크로서비스를 개발할 때 가장 어려워하는 부분인 서비스 별로 데이터베이스를 분리하여 API로 대체한다는 점에 걱정하는 부분과 실전에서 마이크로서비스를 개발할 때의 구현 이슈를 소개하고, 실전에서는 어떻게 풀어내는지 소개해주셨다.

 

API로 속도가 나올까??

서비스가 api로 데이터를 많이 조회하고 많이 조합할 때 속도 저하가 일어날 수 있다. 그러나 이런 부분도 데이터의 성격을 데이터의 참조빈도변경 빈도 를 확인해보아야 한다.
참조 빈도가 높고, 변경 빈도가 높다면 매우 다루기 까다롭다. 하지만 이런 유형의 데이터의 종류는 다행히 많지 않으며 잘 알려진 분야이기에 솔루션도 많다

ex) 로그인 상태 정보 -> JWT 등을 이용

참조 빈도가 높고, 변경 빈도가 낮다면 다양한 방법을 통해 튜닝을 고려해볼 수 있다.

  • 데이터 복제
    • 동기화 하는 부분은 물론 단점이다.
    • 하지만 “필요한 부분”만 복사하는 방향은 괜찮을 수 있다.
    • 원본 소스의 서비스가 다운되어도 문제없기 때문이다.
  • 모델링 변경
  • 일괄 조회
    • n+1 problem 같은 경우
    • api 를 매번 조회 하지 말아야 한다.
    • 마치 in query 같은 경우
  • 병렬 조회?
    • 대부분 안티 패턴이다.
    • 순간적으로 큰 부하가 생길 수 있다.
    • 꼭 필요한 경우에만, “일괄 조회”를 병렬로 실행해야 한다.
  • 로컬 캐시
    • ex) eh cache
    • 실질적 네트워크 호출을 줄여주기 때문에 성능적으로 좋아지는 경우 많다.
    • 사이즈는 작게 유지하는게 좋고 모니터링 하는게 좋다.
    • 로컬 캐시는 동기화하지 않는다.
      • 안티 패턴이다.
      • 노드가 늘어날수록 동기화의 효율이 적기 때문이다.

 

트랜잭션 없이 정합성이 보장될까??

마찬가지로 API를 사용하는 경우 원자성과 독립성이 보장이 안된다.
- 원자성: db rollback을 못함
- 독립성: read committed이 안되고 read uncommited 수준으로 떨어진다.

 

원자성 보완이 필요한 경우

DB 롤백이 불가능하기 때문에 원자성이 보장되지 않는다.

  • ex) 만약 A 서비스에 쓰기 요청에 B서비스의 쓰기 요청이 의존되는 경우
  • 원칙적으로는 쓰기에 실패하면 타 서비스에서 커밋된 데이터를 API로 직접 삭제하고, 로컬에 생성된 데이터는 롤백하여 직접 처리해야 한다.
  • 하지만 만약 B서비스의 데이터 삭제하다 실패하면?
    • 그럼 A 서비스에서는 롤백되기 때문에 정합성 깨진다.
  • API Retry??
    • 어차피 재시도를 바로 해도 이미 안될 가능성이 높다.
    • 안티 패턴
  • 따라서 차라리 이벤트의 재시도하는 방식이 나을 수 있다.
    • 이벤트는 무조건 전달 보장한다.
    • 간혹 여러번 전달될수도 있을 수 있다.
    • 그러므로 여러번 이벤트가 일어나도 결과는 동일하도록 구현해야한다.
    • 결국 네트워크로 쓰기 작업을 하면 멱등성을 보장해야한다.
  • 긴 TX 나누기
    • 실패해도 전체를 취소할 필요가없다면 이벤트로 분리한다.
    • 취소할 수 없는 쓰기는 이벤트로 분리한다.
  • 역할 분리
    • 다른 서비스들이 각자 알아서 하는 경우가 좋을수도 있다.
    • 책임 분리, 의존 분리
  • 모델링 변경
    • 고객 <> 상담
    • 상담 유의사항 속성이 필요하여 고객 참고 정보 테이블이 필요한 상황
    • 해당 테이블을 고객 디비에 배치하는 것보단, 상담 서비스 디비에 하는것이 좋음
    • DDD의 바운더리 컨텍스트 단위 개념과 비슷
    • 즉 데이터는 오너십 가진 서비스에서 가지고 있기
  • 서비스 경계 변경
    • 너무 구현하기 힘든 상황이라면 서비스를 합치거나 경계를 변경해도 괜찮다.

 

독립성 보완이 필요한 경우

MSA는 서비스 간의 트랜잭션 격리 레벨은 Read Uncommitted 이다.

  • 따라서 서비스 간의 데이터가 순간적으로 일치하지 않을 수 있다
    • 정교하게 맞아야 한다면 어플리케이션 코드레벨에서 조절해야함
  • 데이터베이스의 동기화 메커니즘을 사용할 수 없음
    • select for update, 등 db 락 사용
      • 원래도 사실 안티패턴이다.
    • 어플리케이션 lock
      • msa는 이 경우만 사용한다.

평소 MSA에 대해서 많은 정의가 있지만 구체적으로 어떤 것이 MSA를 의미하는지 항상 헷갈리고 구체적인 구현측면에서 어떤점이 맞는지 헷갈려왔던 나에게 어느정도 판단할 수 있는 기준점이 되는 내용들이라고 생각이 들었고 많은 의미가 있다고 생각이 들었다. 특히 안티패턴을 피하는 것만으로도 큰 도움이 될 것 같다.

 

5. 구해줘 홈즈! 은행에서 3천만 트래픽의 홈 서비스 새로 만들기 (이영규님)


이영규님은 안정성이 중요한 은행 환경에서 트래픽이 많은 홈 서비스를 새로 개발하며, 동료들과 함께 고민하고 결정했던 경험과 회고들을 중점적으로 공유해주셨다.

특히 다음과 같이 구조적 문제성능 문제를 중점적으로 해결하려 하셧다고 한다.

 

구조적 문제

  • 레거시 프로젝트로 인한 계층간 의존성 꼬여 있음
  • 외부 의존성과 도메인 정책이 혼재되어 섞여 있음

이러한 문제들을 헥사고날 아키텍쳐를 기반으로 도메인 계층을 외부 의존성과 독립되도록 분리하셨다.

 

성능 문제 해결 - 기술 부채

  • 외부 서비스 호출 증가에 따른 성능 이슈
  • MSA로 인한 길어지는 네트워크 구간

마찬가지로 코루틴을 사용하셨고 async - await 패턴을 통해 해결하셨다고 말씀 주셨다.

 

안정적 이관 전략

개인적으로 이쪽 파트를 말씀주실때 가장 크게 인상깊게 다가왔던 것 같다.
서버만의 분리 작업이기 때문에 외부 인터페이스는 변하면 안되므로 안정적인 이관을 위해 정말 다양한 전략들을 사용하셨는데 모두 도움이 되었다고 하셨다.

  • 응답 비교
    • 기존 서비스 → (응답 전달(async), 응답 비교서비스) → 신규 서비스
    • 기존 서비스에서 응답 비교 서비스 호출 한다.
    • 응답 비교 서비스에서 신규 서비스 호출하여 비교 한다.
    • 단점
      • 응답 비교 서비스에서 호출하는 찰나의 순간 데이터 변경으로 달라지는 경우 있었다.
      • 기존 다른 서비스들의 트래픽 2배가 된다.
  • 표본 검사
    • 하루 약 3천만 호출
    • 검증 비율을 동적으로 변경할 수 있도록 설정
    • 점진적으로 응답 비교 비율을 확대 및 모니터링
  • A/B
    • 게이트웨이에서 A/B 테스트 하듯이 트래픽을 일정 비율로 전환 가능하도록 설계
      • 기존서비스, 신규서비스
    • 단점
      • 하이럼의 법칙
      • 별칭 보정 기능 이슈
        • 별칭이 존재하지 않으면 보정해주는 기능이 있었음.
        • 신규 서비스에서는 이 기능을 제거
          • 만약의 상황을 위한 기능이었기 떄문에
          • 최근 호출 기록도 확인
        • 하지만 나중에 다른 시스템에서 이 별칭 보정 기능을 의존하고 있었다는 걸 알게 됨
  • fallback
    • 신규 서비스에서 예외가 발생하면 기존 서비스 응답으로 fallback
    • nginx가 다운된 사례가 있었는데 fallback 기능을 통해 보완

이러한 안정적 이관을 위해 사용하셨던 전략들을 알게 되어서 굉장히 의미 있었고 아주 무거운 레거시 프로젝트를 전환하게 된다면 안정적인 이관을 위해 노력을 많이 쏟아야 한다는 것을 알게 되었다. 물론 이관용 서비스를 구축하는 것이 쉽지만은 않은 것은 알지만 안정성이 매우 중요한 서비스라면 필요해보인다고 생각이 들었다.

 

 

후기


이렇게 인상깊었던 세션들의 내용을 정리해봤는데 오프라인 세션에서는 사진 촬영과 한정된 시간으로 인해 모두 이해하고 맥락을 짚기엔 어려운 부분들도 많았지만 오프라인으로 참여하여 직접 들으며 경험과 노하우를 들을 수 있던 부분들이 좋았다. 아무래도 QnA는 오프라인 참여자들의 가장 큰 장점이지 않을까 싶다.

 

이 외에도 IT 회사들의 부스들에서 진행하는 채용 관련 상담과 굿즈, 간식 타임까지 있어서 준비를 많이 하셨다고 생각이 들었고 1분도 안되어 선착순 마감에 오프라인 세션을 열심히 듣던 개발자 분들을 보며 많은 자극을 얻었고, 유튜브를 통해 자세히 한번 볼 예정이다.

 

728x90
728x90

등장배경


글로벌 IT 서비스를 제공하는 회사가 늘어나면서 방대한 양의 데이터를 충분히 빠른 속도로 제공할 수 있는 데이터베이스에 대한 필요성이 높아졌다.

그러나 이를 상용 RDBMS로 이를 처리하기에는 라이선스 비용 문제가 발생하였기 때문에 MySQL 서버가 많이 발전하고 대용량을 전담하는 DBMS로 활용하였으나 부족한 부분은 많았다.

Scale Out 하기에는 쉽지 않기 때문이다. 그러나 2000년대 후반까진 NoSQL DBMS 들이 기능들이 미성숙하여 상용으로 쓰긴 힘들었으나 아래와 같은 장점으로 인해 몽고디비 활용이 늘어나기 시작했다.

  • 트랜잭션지원, 분산처리, 재해복구, 샤딩 & 리밸런싱, 데이터복제 자동복구 지원
  • WiredTige 엔진 장착 이후

 

라이선스


몽고디비는 “Mongo DB, Inc”에 의해서 개발 및 유지 보수되는 오픈 소스 데이터베이스다.

또한 기술 지원과 추가 기능을 사용할 수 있는 유료 라이센스 모델인 프로페셔널과 엔터프라이즈 서비스도 제공하고 있다.

몽고디비 버전은 일반적으로 많이 사용되는 3개의 숫자로 구성된 버전으로 관리된다.

특이한 점은 홀수 번호는 개발 버전을, 짝수 버전은 릴리즈 버전을 의미한다. 현재 릴리즈 버전이 3.2.3 이라면 이에 해당되는 개발 버전은 3.3.3 인 것 이다.

 

아키텍처


몽고디비의 간단한 아키텍처는 다음과 같다.

응용 프로그램들은 각 언어별로 적절한 드라이버를 이용하여 몽고디비 서버와 통신한다.

그리고 몽고디비 서버의 네트워크 모듈은 쿼리 프로세서 모듈로 전달한다. 쿼리 프로세서 모듈은 여러 과정을 거쳐 사용자 데이터를 지정된 스토리지 엔진으로 주고받는다.

가장 아래쪽 구성요소에 위치한 스토리지 엔진은 사용자 데이터를 디스크에 저장하거나 디스크에서 데이터를 읽어서 쿼리 프로세서 모듈로 전달한다.

https://osoriandomori.github.io/posts/Real-Mongo-DB/

 

 

배포 형태


몽고디비는 클러스터 형태로 서비스할 수 있도록 구현된 데이터베이스 서버다. 하지만 MySQL 서버와 같이 단일 서버로도 사용할 수 있을 뿐더러 복제 또는 샤딩된 구조로도 활용할 수 있다.

 

단일 노드

  • 단일 노드로 사용하므로 아무런 관리 컴포넌트가 필요하지 않음
  • 복제를 위한 로그도 필요하지 않으며 다른 노드와의 통신도 불필요
  • 단일 노드 구성에는 몽고 DB 드라이버가 직접 몽고디비 서버로 직접 연결하게 되며, 별도의 레플리카 셋을 두지 않으니 몽고 DB 서버가 응답 불가일 경우 자동 fail over나 HA 기능이 작동할 수가 없음

 

단일 레플리카 셋

  • 단일 레플리카 셋에도 별도의 관리용 컴포넌트가 필요하지 않지만 레플리카 셋 구축을 위해 추가적인 몽고디비 서버가 필요
  • 레플리카 셋은 자동복구를 위한 최소 단위
  • 몽고 디비 드라이버는 직접 몽고디비 서로 접속하지만 replicaSet 옵션을 사용해야한다.
  • 아래의 그림처럼 레플리카 셋에는 항상 1개의 프라이머리 노드와 1개 이상의 세컨더리 노드로 구성되며 primary 노드는 사용자의 데이터 변경 요청을 받아 처리하며, 세컨더리 노드는 변경 내용을 전달받아 서로의 데이터를 동기화한다.
  • 읽기 쿼리는 프라이머리 노드뿐만 아니라 필요하면 세컨더리 노드로 요청할 수 있다.
  • 레플리카 셋은 투표로 프라이머리 노드를 결정하므로 홀수 개의 노드로 구성하면 좋다.

https://blog.thecloudside.com/setting-up-a-fault-tolerant-mongodb-replica-set-on-google-kubernetes-engine-gke-a48cb0bebd6

단일 레프리카 셋으로 구성된 서버에 접속할 때, 응용 프로그램에서는 레플리카 셋을 구성하는 멤버들의 목록을 connection string에 사용해야 하며, 나열된 목록의 몽고 디비 서버만 접속할 것으로 보이지만, 몽고디비 드라이버는 나열된 서버 목록들을 seed로만 사용할 뿐이다. 즉, connection string에 나열된 멤버 중 가용 멤버에 접속해서 레플리카 셋을 구성하는 멤버 목록들을 확인하여 모든 멤버들의 적절히 접속한다.

 

샤딩된 클러스터

  • 아래와 같이 샤딩된 클러스터 구조에서는 하나 이상의 레플리카 셋이 필요하며 각 레플리카 셋은 자신만의 파티션 된 데이터를 가지게 된다.
  • 샤딩된 클러스터에 참여하고 있는 각각의 레플리카 셋을 샤드라고 하는데, 이 샤드들이 어떤 데이터를 가지는지에 대한 정보는 몽고디비 Config 서버거 관리한다.
  • 샤딩된 클러스터 구조에서는 응용 프로그램의 드라이버가 직접 몽고디비 서버에 접근하면 안된다. 몽고 디비 드라이버는 몽고 디비 라우터(mongos)로 연결하고, 라우터는 컨피그 서버로 부터 각 샤드가 가지고 있는 메타 정보들을 참조하여 쿼리를 실행
  • 그 뿐만 아니라 라우터는 사용자를 대신해서 모든 샤드로 쿼리를 요청하고 결과를 정렬 및 병합해서 반환하는 처리도 수행

https://www.mongodb.com/resources/products/capabilities/sharding

 

 

 

참고 자료

  • 도서: Real MongoDB
728x90

'mongo' 카테고리의 다른 글

[Real MongoDB] 스토리지 엔진  (0) 2024.11.09
728x90

JVM이란 무엇인가

JVM은 Java virtual machine을 줄인 것으로 자바를 실행하기 위한 가상 컴퓨터이다.

자바로 작성된 애플리케이션은 모두 JVM에서만 실행되기 때문에, 자바 애플리케이션을 실행하기 위해서는 JVM이 반드시 필요하다. 컴파일러는 Java 파일을 .class 파일로 컴파일한 다음 해당 .class 파일을 JVM에 입력하여 클래스 파일을 로드하고 실행한다.

WORA(Write once, run anywhere)

이 과정을 통하여 Java 는 높은 이식성이라는 큰 장점을 얻을 수가 있었다. 어느 기기나 운영체제에 상관없이 JVM 이 설치 및 구동될 수 있는 환경이라면 Java 로 작성한 프로그램은 실행이 가능하기 때문에 다른 운영체제에 맞춰서 컴파일을 해줘야 하는 다른 언어보다 높은 이식성을 가질 수 있게 되었다.

단, JVM은 OS에 종속적이기 때문에 해당 OS에서 실행가능한 JVM이 필요하다.

JVM은 바이트 코드를 이해하는 것이지 자바 코드를 이해하는 것이 아니다. 코틀린 또한 코틀린 코드를 바이트 코드로 컴파일해서 JVM 위에서 동작한다.

JVM의 특성

  • 스택 기반의 가상 머신
  • 단일 상속 형태의 객체 지향 프로그래밍을 가상 머신 수준에서 구현
  • 포인터를 지원. 단, C와 같이 주소 값을 임의로 조작이 가능한 포인터 연산은 불가능
  • Garbage collection 수행
  • 플랫폼의 독립성 보장
  • Data Flow Analysis에 기반한 자바 바이트코드 검증기를 통해 문제를 실행 전에 검증하여 실행 시 안전을 보장하고 별도의 부담을 줄여줌

바이트코드란 무엇인가

바이너리 코드

CPU가 이해하기 위한 기계어는 0과 1로 구성된 바이너리 코드(이진 코드)이다. 기계어가 이진 코드로 이루어졌을 뿐 모든 이진 코드가 기계어인 것은 아니다.

바이너리 코드 != 기계어

바이트 코드

0과 1로 이루어진 이진 코드이지만 바이너리 코드와 달리 가상머신이 이해할 수 있는 코드이다. 사람에게 친숙한 고급 언어보다는 덜 추상적이지만 기계어보다는 추상적이다.

고급언어로 작성된 코드를 가상머신이 이해할 수 있도록 컴파일한 것이다. CPU에게 넘어가기 전에 실시간 번역기 또는 JIT(just-in-time) 컴파일러에 의해 바이너리 코드로 변환된다.

정리

Java는 OS와 직접적으로 대화할 수 없다. 오로지 JVM하고만 상호작용을 한다. 자바는 JVM을 거쳐야만 OS와 대화할 수 있다.

바이너리 코드와 바이트 코드 둘 다 0과 1로 이루어져 있다. 바이너리 코드는 CPU가 이해할 수 있는 언어, 바이트 코드는 가상 머신이 이해할 수 있는 언어이다.

그 중에 JVM을 위한 바이트 코드를 “자바 바이트코드”라고 한다.


JVM 구성 요소

JVM은 크게 세 가지 구성요소로 볼 수 있다.

  • Class Loader
  • Runtime Data Area
  • Execution Engine


출처: https://dzone.com/articles/jvm-architecture-explained

Class Loader

JDK 에서 개발하고, JRE 를 통해서 환경을 제공받은 JVM 은 compile 된 바이트 코드를 탑재하여 로직을 실행하게 됩니다. 그렇다면 JVM 에 Class 는 어떻게 로드되는 것일까??

바로 그 역할을 하는 것이 자바 클래스로더이다. 클래스 로더는 자바 클래스를 JVM으로 동적 로드하는 JRE(자바 런타임 환경)의 일부이다. 클래스 파일을 로드하는데 사용되는 하위 시스템이다.

Compile time이 아닌, Runtime시 처음으로 한 번만 동적으로 클래스를 로드하며, jar 파일 내에 저장된 클래스들을 JVM 위에 탑재하고 사용하지 않는 클래스들은 메모리에서 삭제한다.변환된 바이트 코드 파일(.class)을 JVM이 운영체제로부터 할당 받은 메모리 영역인 Runtime Data Area로 “적재”하는 역할을 한다.

ClassLoader 는 클래스 파일을 찾아서 탑재하는 역할뿐만이 아니라 jvm 에 관련된 다른 일들도 같이 한다.

크게 Loading, Linking, 그리고 Initialization 3가지 역할을 맡게 된다.

  • Loading 은 클래스 파일을 탑재하는 과정
  • Linking 은 클래스 파일을 사용하기 위해 검증하고, 기본 값으로 초기화하는 과정
  • Initialization 은 static field 의 값들을 정의한 값으로 초기화를 하는 과정

Runtime Data Area

이렇게 탑재하는 클래스 파일들은 JVM 에서 어떤 영역을 차지하고 있을까? JVM 의 Run-Time Data Area는 프로그램을 수행하기 위해 OS에서 할당받은 메모리 공간이며, 크게 Method Area , Heap , Java Stacks , PC registers 그리고 Native Method Stacks 가 존재한다.

출처: https://tecoble.techcourse.co.kr/post/2021-08-09-jvm-memory/

Method Area

  • Method Area 에는 인스턴스 생성을 위한 객체 구조, 생성자, 필드 등이 저장된다. Runtime Constant Pool 과 static 변수, 그리고 메소드 데이터와 같은 Class 데이터들도 이곳에서 관리 된다.
  • 즉, 정적 변수를 포함하여 모든 클래스 수준 데이터가 여기에 저장된다.
  • JVM당 하나의 메소드 영역만 있으며 공유 자원입니다.다른 스레드에서도 활용 가능한 공유자원이다.
  • 다중 스레드에 대한 메모리를 공유하므로 저장된 데이터는 스레드에 안전하지 않다

Heap

  • 모든 객체와 해당 인스턴스(instance) 변수 및 배열, String pool이 여기에 저장됩니다.
  • JVM 당 역시 하나만 생성이 되고, 해당 영역이 가진 데이터는 모든 Java Stack 영역에서 참조되어, Thread 간 공유가 됩니다.
  • 다중 스레드에 대한 메모리를 공유하므로 저장된 데이터는 스레드에 안전하지 않다
  • GC의 주 대상

Native Method Stack

  • 순수하게 Java 로 구성된 코드만을 사용할 수 없는 시스템의 자원이나 API 가 존재합니다.
  • 다른 프로그래밍 언어로 작성된 메소드들을 Native Method 라고 합니다.
  • 일반적인 메소드를 실행하는 경우 JVM Language Stack에 적재되지만, 네이티브 메소드 스택은 네이티브 라이브러리에 따라 네이티브 코드 명령(C언어와 같이 네이티브 방식으로 작성된 메소드)을 보관한다.

PC Register

  • Java 에서 Thread 는 각자의 메소드를 실행하게 됩니다. 이때, Thread 별로 동시에 실행하는 환경이 보장되어야 하므로 최근에 실행 중인 JVM 에서는 명령어 주소값을 저장할 공간이 필요합니다.
  • 이 부분을 PC Registers 영역이 관리하여 추적해주게 됩니다. Thread 들은 각각 자신만의 PC Registers 를 가지고 있습니다.
  • 만약 실행했던 메소드가 네이티브하다면 undefined 가 기록이 됩니다. 실행했던 메소드가 네이티브하지 않다면, PC Registers 는 JVM 에서 사용된 명령의 주소 값을 저장하게 됩니다.

Stack

  • 프로그램 실행과정에서 임시로 할당되었다가 메소드를 빠져나가면 바로 소멸되는 특성의 데이터를 저장하기 위한 영역이다.
  • 각종 형태의 변수나 임시 데이터, 스레드나 메소드의 정보를 저장하고 호출된 메서드의 매개변수, 지역변수, 리턴 값 및 연산 시 일어나는 값들을 임시로 저장한다.
  • 각 스레드에는 자체 JVM 스택이 있고, 스레드가 생성될 때 동시에 생성된다.
  • 각 Thread 별로 따로 할당되는 영역이므로 Heap 메모리 영역보다 비교적 빠르다는 장점이 있다. 또한, 각각의 Thread 별로 메모리를 따로 할당하기 때문에 동시성 문제에서 자유롭다.

Execution Engine

런타임 데이터 영역에 할당된 바이트코드는 실행 엔진에 의해 실행된다. Execution Engine은 바이트코드를 읽고 자바 바이트 코드를 JVM 내부에서 컴퓨터가 실행할 수 있는 형태인 바이너리 코드로 변경하며 하나씩 실행한다.

변경하는 방식은 두가지가 있는데, 인터프리터 방식과 JIT 방식이 있다.

인터프리터 방식

  • 기본 바이트 코드를 실행하는 방법은 인터프리터 방식이 기본이다. 자바 바이트 코드를 명령어 단위로 읽어서 실행하기 때문에 느리다.

JIT(just-in-time) Compiler

  • JIT 컴파일러는 인터프리터의 단점을 해결한다. 실행 엔진은 바이트 코드를 변환하는 데 인터프리터의 도움을 사용할 것이지만 반복되는 코드를 발견하면 전체 바이트코드를 컴파일하여 네이티브 코드로 변경하는 JIT 컴파일러를 사용한다. 이 네이티브 코드는 반복 메서드 호출에 직접 사용되어 시스템 성능을 향상시킨다.

Garbage Collector

  • 참조되지 않은 객체를 수집하고 제거한다. JVM의 가비지 컬렉션은 생성된 객체를 수집한다.

JVM 동작 간단 정리

JVM 구성 요소는 다음과 같다.

  1. 클래스 로더 컴파일러가 내부에 만든 .class(바이트 코드)를 런타임 데이터 공간에 “적재”한다.
  2. 런타임 데이터 공간 OS로부터 메모리를 할당받은 공간으로 스택, 힙, 메소드, 네이티브 메소드, PC 레지스터가 있다.
  3. 실행 엔진인터프리터 방식 또는 JIT 컴파일러를 이용하여 데이터 영역에 배치된 바이트 코드를 실행한다.
  • JIT 컴파일러는 바이트 코드를 바이너리 코드로 변환하는 속도가 느린 인터프리터 방식을 보완하기 위해 나온 것이다.
  • 인터프리터 방식으로 기계어 코드를 생성하면서 그 코드를 캐싱하여, 같은 함수가 여러 번 불릴 때 매번 기계어 코드를 생성하는 것을 방지한다.
  • JVM 내부에서는 자바 컴파일러가 자바 프로그램 코드를 바이트 코드로 변환시킨 후 실제 바이트 코드가 실행하는 시점에서 JIT 컴파일러를 통해 기계어로 변환한다.
  1. GC는 JVM 상에서 더 이상 사용되지 않는 데이터가 할당되어있는 메모리를 해제시킨다.

컴파일 하는 방법

컴파일이란?

컴파일러는 특정 프로그래밍 언어로 쓰여 있는 문서를 다른 프로그래밍 언어로 옮기는 프로그램을 말한다. 기존 문서를 소스 코드 혹은 원시 코드라고 부르고, 출력된 문서를 목적 코드라고 부른다.원시 코드에서 목적 코드로 옮기는 과정을 컴파일이라고 한다.

자바 컴파일 과정

  1. 소스 파일 생성한다. (Hello.java)
  2. 자바 컴파일러(javac.exe)를 사용하여 컴파일한다. $ javac Hello.java
  3. 컴파일이 정상적으로 완료되면 클래스 파일 생성된다. (Hello.class)

실행하는 방법

자바 인터프리터(java.exe)로 실행한다. $ java Hello실행 시에는 확장자를 붙이지 않는다.

내부적인 진행순서는 다음과 같다.

  1. 프로그램의 실행에 필요한 클래스(*.class파일)을 로드한다.
  2. 클래스파일을 검사한다.(파일형식, 악성코드 체크)
  3. 지정된 클래스(Hello)에서 main(String[] args)을 호출한다.

JDK와 JRE의 차이

JDK란?

JDK는 Java Development Kit으로 자바 프로그래밍 시 필요한 컴파일러 등을 포함한다. JDK는 JRE를 포함하며, 개발을 위해 필요한 도구(java, javac 등)를 포함한다.

JRE란?

JRE는 Java Runtime Enviroment로 컴파일된 자바 프로그램을 실행시킬 수 있는 자바 환경을 말한다. JRE는 JVM의 실행환경을 구현했다고 볼 수 있으며, JVM이 자바 프로그램을 동작시킬 때 필요한 라이브러리 파일들과 기타 파일들을 가지고 있다.


javac 옵션

더 자세한 javac의 standard options은 공식 문서에서 볼 수 있다.

$ javac <options> <source files>

-cp path or -classpath path

  • 컴파일러가 참조할 클래스 파일들을 찾기 위해서 컴파일 시 파일경로를 지정해주는 옵션이다.해당 옵션을 쓰지 않는 경우(classpath가 지정되지 않는 경우) 사용자 클래스 경로는 현재 디렉터리가 된다.

-d directory

  • 클래스 파일의 대상 디렉터리를 설정한다. javac가 별도의 디렉터리를 만들지 않기 때문에 디렉터리는 미리 만들어둬야 한다.

-deprecation

  • 사용되지 않는 멤버 또는 클래스의 사용 또는 오버라이드에 대한 설명을 표시한다.해당 옵션이 없는 javac는 사용되지 않는 멤버나 클래스를 사용하거나 재정의하는 소스 파일의 요약을 보여준다.

-g

  • 로컬 변수를 포함한 모든 디버깅 정보를 생성한다.g:none : 디버깅 정보를 생성하지 않는다.g:{source, lines, vars} : 소스파일 정보, 라인 정보, 지역변수의 디버깅 정보를 생성한다.

-source release

  • 소스 코드의 버전을 지정한다.

-target version

  • 가상 시스템의 지정된 릴리스를 대상으로 하는 클래스 파일을 생성한다. 클래스 파일은 지정된 대상 및 이후 릴리스에서 실행되지만 이전 릴리스의 JVM에서는 실행되지 않는다.

참고 출처

728x90
728x90

발단

사용자가 어떤 게시글을 작성하면 조건에 맞는 다른 사용자에게 쪽지같은 알림을 구현해야 하는 상황

  • 게시글 작성
  • 알림

처음에는 하나의 transaction으로 처리로 구현을 진행했으나 알림 기능은 부가적인 기능이고 게시글 작성 기능에 영향을 주면 안된다고 생각이 들었다. 따라서 게시글 작성 후 알림 처리가 지연되는 경우 게시글 작성 자체를 지연하는 것이 아니라, 게시글 작성은 완료시키고 다른 Thread에서 알림을 처리하도록 비동기 처리를 진행할 수 있을 것이다.

스프링에서는 @Async Annotation을 이용하여 간단하게 비동기 처리를 할 수 있다.

Java 비동기방식 처리

그전에 먼저 자바의 비동기 작업 처리를 알아보자.

따라서 method가 실행되면 새로운 thread를 만들고 그 thread에서 메시지를 저장하도록 처리하면 될 것 같다.

public class AsyncService {
    public void asyncMethod(String message) throws Exception {
        // do something
    }
}

하지만 해당 방법은 thread를 관리할 수 없기 때문에 위험한 방법이다.

Thread를 관리하기 위해서 ExecutorService 사용해보자.

ExecutorService는 쉽게 비동기로 작업을 실행할 수 있도록 도와주는 JDK(1.5부터)에서 제공하는 interface 이다. 일반적으로 ExecutorService는 작업 할당을 위한 스레드 풀과 API를 제공한다.

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class AsyncService {

    private static ExecutorService executorService = Executors.newFixedThreadPool(10);

    public void asyncMethod(final String message) throws Exception {
        executorService.submit(new Runnable() {
            @Override
            public void run() {
                // do something
            }            
        });
    }

}

하지만 비동기방식으로 처리하고 싶은 method마다 반복적으로 동일한 수정 작업들을 진행 해야할 것이다.

@Async with SimpleAsyncTaskExecutor

@Async Annotation은 Spring에서 제공하는 Thread Pool을 활용하는 비동기 메소드 지원 Annotation이다.

만약 Spring Boot에서 간단히 사용하고 싶다면, 단순히 Application Class에 @EnableAsync Annotation을 추가하고, 원하는 method 위에 @Async Annotation을 붙여주면 사용할 수 있다.

@EnableAsync
@SpringBootApplication
public class SpringBootApplication {
    ...
}
public class AsyncService {

    @Async
    public void asyncMethod(String message) throws Exception {
        ....
    }
}

하지만 @Async의 기본설정은 SimpleAsyncTaskExecutor를 사용하도록 되어있기 때문입니다.

본인의 개발 환경에 맞게 Customize하기에는 직접 AsyncConfigurerSupport를 상속받는 Class를 작성하는 것이 좋다.

@Async with ThreadPoolTaskExecutor

Thread pool을 이용해서 thread를 관리가능한 방식다. 아래와 같은 AsyncConfigurerSupport를 상속받는 Customize Class를 구현하자.

그리고 Application 클래스에 @EnableAutoConfiguration(혹은 @SpringBootApplication) 설정이 되어있다면 런타임시 @Configuration가 설정된 SpringAsyncConfig 클래스의 threadPoolTaskExecutor bean 정보를 읽어들이기 때문에 앞서 설정한 Application 클래스의 @EnableAsync을 제거한다.

import java.util.concurrent.Executor;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.AsyncConfigurerSupport;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

@EnableAsync
@Configuration
public class AsyncConfig extends AsyncConfigurerSupport {

    @Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecuto();
        executor.setCorePoolSize(10);
        executor.setMaxPoolSize(20);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("my thread-");
        executor.initialize();
        return executor;
    }
}

여기서 설정한 요소들은 아래와 같다.

  • @Configuration : Spring 설정 관련 Class로 @Component 등록되어 Scanning 될 수 있다.
  • @EnableAsync : Spring method에서 비동기 기능을 사용가능하게 활성화 한다.
  • CorePoolSize : 기본 실행 대기하는 Thread의 수**
  • MaxPoolSize : 동시 동작하는 최대 Thread의 수
  • QueueCapacity : MaxPoolSize 초과 요청에서 Thread 생성 요청시,해당 요청을 Queue에 저장하는데 이때 최대 수용 가능한 Queue의 수,Queue에 저장되어있다가 Thread에 자리가 생기면 하나씩 빠져나가 동작
  • ThreadNamePrefix : 생성되는 Thread 접두사 지정

위와 같이 작성한 후 비동기 방식 사용을 원하는 method에 @Async Annotation을 지정해주면 된다.

@Async annotation에 bean의 이름을 제공하면 SimpleAsyncTaskExecutor가 아닌 설정한 TaskExecutor로 thread를 관리하게 된다.

@EnableAsync
@Configuration
public class AsyncConfig extends AsyncConfigurerSupport {

    @Bean(name = "threadPoolTaskExecutor")
    public Executor threadPoolTaskExecutor()
    {
        ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor();
        taskExecutor.setCorePoolSize(3);
        taskExecutor.setMaxPoolSize(30);
        taskExecutor.setQueueCapacity(10);
        taskExecutor.setThreadNamePrefix("Executor-");
        taskExecutor.initialize();
        return taskExecutor;
    }
}

---

public class AsyncService {

        @Async("threadPoolTaskExecutor")
    public void asyncMethod(String message) throws Exception {
        // do something
    }
}

Thread Pool의 종류를 여러개 설정하고자한다면 SpringAsyncConfig 안에 bean을 여러개 만들고 @Async를 설정시 원하는 bean의 이름을 설정하면 된다.

제약사항

@Async Annotation을 사용할 때 아래와 같은 사항을 주의 해야한다.

  1. private method는 사용 불가, public method만 사용 가능
  2. self-invocation(자가 호출) 불가, 즉 inner method는 사용 불가

제약의 원인은 간단한데 @AsyncAOP에 의해 동작하고 있기 때문에 해당 메서드는 프록시될 수 있어야 하기 때문이다.

https://media.vlpt.us/images/gillog/post/5bb64a29-5263-4fcc-9f02-cffea4162137/image.png

출처 : https://dzone.com/articles/effective-advice-on-spring-async-part-1

해당 @Async method를 가로챈 후, 다른 Class에서 호출이 가능해야 하므로,private method는 사용할 수 없는 것이다. 또한 inner method의 호출은 해당 메서드를 직접호출 하기 때문에 self-invocation이 불가능하다. @Transactional 사용시 주의점과 비슷하다고 할 수 있다.

@Slf4j
@Service
public class TestService {

    @Async
    public void innerAsyncMethod(int i) {
        log.info("async i = " + i);
    }

    public void asyncMethod(int i) {
        innerAsyncMethod(i);
    }

}

리턴 타입

@Async 메서드는 AsyncExecutionAspectSupport 클래스의 doSubmit 메서드에서 선택한 실행자와 함께 지정된 작업을 실제로 실행하도록 위임한다.

리턴타입은 크게 두가지로 나뉠 수 있다.

  • 리턴값이 없는 경우
  • 있는 경우(Futrue)

Future에 경우에도 여러 타입이 존재하는데 해당 리턴 값에 대한 것은 여기 에서 자세히 보면 좋을 것 같다

@Nullable
protected Object doSubmit(Callable<Object> task, AsyncTaskExecutor executor, Class<?> returnType) {
    if (CompletableFuture.class.isAssignableFrom(returnType)) {
        return CompletableFuture.supplyAsync(() -> {
            try {
                return task.call();
            }
            catch (Throwable ex) {
                throw new CompletionException(ex);
            }
        }, executor);
    }
    else if (ListenableFuture.class.isAssignableFrom(returnType)) {
        return ((AsyncListenableTaskExecutor) executor).submitListenable(task);
    }
    else if (Future.class.isAssignableFrom(returnType)) {
        return executor.submit(task);
    }
    else {
        executor.submit(task);
        return null;
    }
}

예외 처리

메서드 반환 형식이 Futre인 경우 예외 처리가 쉽다. Future.get() 메서드에서 예외가 발생하기 때문이다.

하지만 반환값이 없는 void이면 예외가 호출 스레드에 전파되지 않는다. 즉 해당 thread가 소리없이 죽기 때문에 예외 처리가 관리되지 않는다.

이러한 예외 처리를 위해서는 AsyncUncaughtExceptionHandler 인터페이스를 구현하여 사용자 지정 비동기 예외 처리기를 생성한다. handleUncaughtException() 메서드는 캐치되지 않은 비동기 예외가 있을 때 호출된다.


public class CustomAsyncExceptionHandler implements AsyncUncaughtExceptionHandler {

    @Override
    public void handleUncaughtException(Throwable ex, Method method, Object... params) {
        log.warn("Exception message - {}", ex.getMessage());
        log.warn("Method name - {}", method.getName());
    }
}

추가

추후 해당 처리를 Spring event를 이용하거나 AOP를 이용하여 리팩토링도 진행할 예정이다.

참고 출처

728x90

'spring' 카테고리의 다른 글

WebClient의 DataBufferLimitException 해결방법  (0) 2024.11.15
TestExecutionListener를 이용한 테스트 격리 방법  (1) 2024.11.05
Spring Webclient  (0) 2024.08.02
Spring AOP  (0) 2024.08.02
Spring Boot Slice Test  (0) 2024.08.02
728x90

개요

스프링 어플리케이션에서 HTTP 요청할 때 사용하는 방법으로 RestTemplate과 WebClient가 있다. 스프링 5.0 이전까지는 클라이언트에서 HTTP 접근을 위해 사용한 것은 RestTemplate 이었다. 스프링 5.0 에서 WebClient가 나왔고 현재는 WebClient를 사용하기를 권고하고 있다. 이번 팀 프로젝트를 진행하면서 외부 api호출 시 WebClient을 사용해보았다. 그럼 RestTemplate과 WebClient는 어떤 특징이 있으며 왜 WebClient를 사용하길 권고하는지 알아보도록 하자.


RestTemplate

스프링 3.0에서부터 지원하며 HTTP 통신에 유용하게 쓸 수 있는 템플릿이다. REST 서비스를 호출하도록 설계되어 HTTP 프로토콜의 메서드 (GET, POST, DELETE, PUT)에 맞게 여러 메서드를 제공한다. RestTemplate은 다음과 같은 특징이 있다

RestTemplate 특징

  • 통신을 단순화하고 RESTful 원칙을 지킨다
  • 멀티쓰레드 방식을 사용
  • Blocking 방식을 사용

1) 클라이언트 애플리케이션 구동 시 쓰레드 풀을 만든다.

2) Request는 먼저 queue에 쌓이고 가용 쓰레드가 있으면 해당 쓰레드에 할당된다. (1요청 당 1쓰레드 할당)

3) 각 쓰레드는 블로킹 방식이기 때문에 완료 응답이 올 때까지 다른 요청에 할당될 수 없다.

4) 쓰레드가 다 찼다면 이후 요청은 queue에 대기하게 된다.


동작원리

RestTemplate은 Multi-Thread와 Blocking방식을 사용한다.

HttpClient는 HTTP를 사용하여 통신하는 범용 라이브러리이고, RestTemplate은 HttpClient 를 추상화(HttpEntity의 json, xml 등)해서 제공해준다. 따라서 내부 통신(HTTP 커넥션)에 있어서는 Apache HttpComponents 를 사용한다.

  1. 어플리케이션이 RestTemplate를 생성하고, URI, HTTP메소드 등의 헤더를 담아 요청한다.
  2. RestTemplate 는 HttpMessageConverter 를 사용하여 requestEntity 를 요청메세지로 변환한다.
  3. RestTemplate 는 ClientHttpRequestFactory 로 부터 ClientHttpRequest 를 가져와서 요청을 보낸다.
  4. ClientHttpRequest 는 요청메세지를 만들어 HTTP 프로토콜을 통해 서버와 통신한다.
  5. RestTemplate 는 ResponseErrorHandler 로 오류를 확인하고 있다면 처리로직을 태운다.
  6. ResponseErrorHandler 는 오류가 있다면 ClientHttpResponse 에서 응답데이터를 가져와서 처리한다.
  7. RestTemplate 는 HttpMessageConverter 를 이용해서 응답메세지를 java object(Class responseType) 로 변환한다.
  8. 어플리케이션에 반환된다.

출처: https://sjh836.tistory.com/141


RestTemplate 사용

RestTemplate을 생성할 때 어떤 HttpClient를 사용할 것인지 ClientHttpRequestFactory를 전달하여 지정할 수 있다. 기본 생성자의 경우 내부적으로 ClientHttpRequestFactory 의 구현체SimpleClientHttpRequestFactory를 사용하여 초기화한다.

RestTemplate을 사용하기 위해서는 restTemplate.메소드명() 을 사용하면 된다.

출처 : https://velog.io/@soosungp33/스프링-RestTemplate-정리요청-함


Connection Pool

RestTemplate 객체를 생성할때 별도의 파리미터 없이 new RestTempalte()으로 생성한다면 Connection Pool을 활용하지 않는 객체이다. 이말은 즉, 요청때 마다 새로운 TCP Connection 을 연결한다는 의미이며 이 때 소켓이 close () 된 이후 소켓이 "TIME_WAIT" 상태가 되는데 만약 요청이 많아진다면 TIME_WAIT 상태의 소켓들을 재사용하지 못해서 요청에 대한 응답에 지연이 생길 수 있다.

이러한 응답 지연 상황을 대비하여 DB가 Connection Pool을 이용하듯이 RestTemplate도 Connection Pool을 이용할 수 있다. 그러기 위해선 RestTemplate 내부 구성을 설정해줘야한다.

단, 호출하는 API 서버가 Keep-Alive를 지원해야지 RestTemplate의 Connection Pool을 활용할 수 있다. 타겟 서버가 Keep-Alive를 지원하지 않는다면 미리 Connection Pool을 만들어 놓지 못하고 요청마다 새로운 Connection이 연결되어 매번 핸드쉐이크가 발생된다. 따라서 Connection Pool을 위한 RestTemplate의 내부 설정이 무의미하게 된다.

@Configuration
public class RestTemplateConfig {
    @Bean
    HttpClient httpClient() {
        return HttpClientBuilder.create()
            .setMaxConnTotal(100)    //최대 오픈되는 커넥션 수
            .setMaxConnPerRoute(5)   //IP, 포트 1쌍에 대해 수행할 커넥션 수
            .build();
    }

    @Bean
    HttpComponentsClientHttpRequestFactory factory(HttpClient httpClient) {
        HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory();
        factory.setReadTimeout(5000);        //읽기시간초과, ms
        factory.setConnectTimeout(3000);     //연결시간초과, ms
        factory.setHttpClient(httpClient);

        return factory;
    }

    @Bean
    RestTemplate restTemplate(HttpComponentsClientHttpRequestFactory factory) {
        return new RestTemplate(factory);
    }
}

WebClient

WebCleint는 스프링 5.0에서 추가된 인터페이스다. Spring WebFlux는 HTTP request를 수행하는 client인 WebClient 를 포함하고 있으며 반응형으로 동작하도록 설계되었다. 스프링 5.0 이후부터는 RestTemplate 의 대안으로 WebClient를 사용할 것을 권장한다. 실제로는 spring-webflux 라이브러리에 속하지만 이 솔루션은 동기 및 비동기 작업을 모두 지원하므로 서블릿 스택에서 실행되는 애플리케이션에도 적용 가능하다.

WebClient는 다음과 같은 특징이 있다.

  • 싱글 스레드 방식을 사용
  • Non-Blocking 방식을 사용
  • JSON, XML을 쉽게 응답받는다.

출처: https://luminousmen.com/post/asynchronous-programming-blocking-and-non-blocking

Core 당 1개의 Thread를 이용한다.

각 요청은 Event Loop내에 Job으로 등록이 되어 Event Loop는 각 Job을 제공자에게 요청한 후, 결과를 기다리지 않고 다른 Job을 처리한다.

Event Loop는 제공자로부터 callback으로 응답이 오면, 그 결과를 요청자에게 제공한다.


의존성 설정

webflux 의존성을 추가해줘야 한다. Gradle 기준으로 아래와 같이 의존성을 추가해주면 된다.

implementation 'org.springframework.boot:spring-boot-starter-webflux'

WebClient 생성

WebClient를 생성하는 데는 2가지의 방법이 있다.

  • static factory method (WebClient.create();)
  • 다른 옵션을 사용하기 위해 Builder를 활용한 클래스 생성 (WebClient.builder())
    • uriBuilderFactory : base url을 커스텀한 UriBuilderFactory
    • defaultHeader : 모든 요청에 사용할 헤더
    • defaultCookie : 모든 요청에 사용할 쿠키
    • defaultRequest : 모든 요청을 커스텀할 Consumer
    • filter : 모든 요청에 사용할 클라이언트 필터
    • exchangeStrategies : HTTP 메시지 reader & writer 커스텀
    • clientConnector : HTTP 클라이언트 라이브러리 세팅
DefaultUriBuilderFactory factory = new DefaultUriBuilderFactory(baseUrl);
        factory.setEncodingMode(DefaultUriBuilderFactory.EncodingMode.VALUES_ONLY);

Webclient webClient = WebClient
                        .builder()
                      .uriBuilderFactory(factory)
                        .baseUrl("http://localhost:8080")
                        .defaultCookie("쿠키","쿠키값")
                        .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                        .build();

Response 받아오기

response를 받아오는 방법에는 두 가지가 있다.

  • retrieve() → body를 받아 디코딩하는 간단한 메서드
  • exchange() → ClientResponse를 상태값 그리고 헤더와 함께 가져온다

exchange()를 통해 세세한 컨트롤이 가능하지만, Response 컨텐츠에 대한 모든 처리를 직접 하면서 메모리 누수 가능성 때문에 retrieve()를 권고하고 있다.

WebClient client = WebClient.create("https://example.org");

Mono<Person> result = client.get()
      .uri("/persons/{id}", id).accept(MediaType.APPLICATION_JSON)
      .retrieve()
      .bodyToMono(Person.class);

bodyToMono 는 가져온 body를 Reactor의 Mono 객체로 바꿔준다. Mono 객체는 0-1개의 결과를 처리하는 객체이다. Flux는 0-N개의 결과를 처리하는 객체이다.

block() 을 사용하면 RestTemplate 처럼 동기식으로 사용할 수 있다.

4xx, 5xx의 응답 코드를 받으면 WebClientResponseException 또는 HTTP 상태에 해당하는 WebClientResponseException.BadRequest 등 과 같은 하위 exception을 던진다. onStatus 메서드로 상태별 exception을 커스텀도 가능하다.

Mono<Person> result = client.get()
      .uri("/persons/{id}", id).accept(MediaType.APPLICATION_JSON)
      .retrieve()
      .onStatus(HttpStatus::is4xxClientError, response -> ...)
      .onStatus(HttpStatus::is5xxServerError, response -> ...)
      .bodyToMono(Person.class);
  • onStatus 를 사용하는 경우, response에 body가 있다면 onStatus 콜백에서 소비하지 않으면 리소스 반환을 위해 body를 자동으로 비우므로 주의해야 한다.

RestTempalte 과 WebClient의 차이

결국 RestTemplate과 WebClient의 가장 큰 차이점은 Non-Blocking과 비동기화 가능 여부일 것이다. 결국 이러한 차이점이 스프링에서 RestTemplate을 사용하는 것 보다 WebClinet의 사용을 권장하는 이유라고 생각한다.

해당 글을 참고해 보면 차이점을 가장 쉽게 이해할 수 있을 것이다

https://user-images.githubusercontent.com/63634505/126900349-905377fe-27ac-4d7a-8b9a-371fb22aee74.png

Non-Blocking?

시스템을 호출한 직후에 프로그램으로 제어가 다시 돌아와서 시스템 호출의 종료를 기다리지 않고 다음 동작을 진행한다. 호출한 시스템의 동작을 기다리지 않고 동시에 다른 작업을 진행할 수 있어서 작업의 속도가 빨라진다는 장점이 있다.


참고 - Spring WebFlux 성능

https://user-images.githubusercontent.com/63634505/126900385-ffdecfe7-f5b6-4c7a-a2ed-69145cc85390.png

출처 : https://alwayspr.tistory.com/44


참고 출처

728x90

'spring' 카테고리의 다른 글

WebClient의 DataBufferLimitException 해결방법  (0) 2024.11.15
TestExecutionListener를 이용한 테스트 격리 방법  (1) 2024.11.05
Spring Async  (0) 2024.08.02
Spring AOP  (0) 2024.08.02
Spring Boot Slice Test  (0) 2024.08.02
728x90

AOP(Aspect Oriented Programming)

AOP는 관점 지향 프로그래밍. Spring의 핵심 개념중 하나인 DI가 애플리케이션 모듈들 간의 결합도를 낮춰준다면, AOP는 애플리케이션 전체에 걸쳐 사용되는 기능을 재사용하도록 지원하는 것

쉽게 말해 어떤 로직을 기준으로 핵심적인 관점, 부가적인 관점으로 나누어서 보고 그 관점을 기준으로 각각 모듈화하겠다는 것이다.

예로들어 핵심적인 관점은 결국 우리가 적용하고자 하는 핵심 비즈니스 로직이 된다. 또한 부가적인 관점은 핵심 로직을 실행하기 위해서 행해지는 데이터베이스 연결, 로깅, 파일 입출력 등을 예로 들 수 있다.

AOP에서 각 관점을 기준으로 로직을 모듈화한다는 것은 코드들을 부분적으로 나누어서 모듈화하겠다는 의미다. 이때, 소스 코드상에서 다른 부분에 계속 반복해서 쓰는 코드들을 발견할 수 있는 데 이것을 흩어진 관심사 (Crosscutting Concerns)라 부른다.

위와 같이 흩어진 관심사를 Aspect로 모듈화하고 핵심적인 비즈니스 로직에서 분리하여 재사용하겠다는 것이 AOP의 취지다.

https://user-images.githubusercontent.com/56240505/123369146-27997800-d5b8-11eb-9be7-dfd7a34a4f86.png


기존 핵심 비즈니스 로직

@Service
@RequiredArgsConstructor
public class UserService {

    private final PlatformTransactionManager transactionManager;

    public void someSevice() {
            TransactionStatus transaction = transactionManager.getTransaction(new DefaultTransactionDefinition());
        try {
        // 부가 기능 - 로깅, 보안 등등
        // 핵심 기능
        someServiceMethod();
        // 부가 기능
        transactionManager.commit(transaction);
        } catch (RuntimeException runtimeException) {
            transactionManager.rollback(transaction);
            throw runtimeException;
        }
    }
}

서비스 로직의 원자성 보장을 위해 내부적으로 트랜잭션을 적용한 코드입니다. 문제는 UserService의 클래스에는 someServiceMethod() 핵심 비즈니스 로직 이외에도 트랜잭션 경계 설정이라는 부가 기능 관심사들이 존재한다.

현재 예제 코드는 부가 기능 관심사가 트랜잭션 하나 뿐이지만, 또 다른 부가 기능의 관심사가 추가된다면 부가 기능이 필요한 메서드마다 비슷한 코드를 중복해서 작성해야 한다.

가장 큰 문제는 UserService와 비슷하게 수행해야 하는 클래스가 100개가 더 있을 수 있기 때문에 필요한 클래스 마다 UserService와 같이 중복되는 코드를 반복해서 작성해야 함을 의미한다.

만약 코드를 변경한다면 클래스를 변경하는 이유는 비즈니스 로직의 변경 및 부가 기능 코드 또한 변경해야 하기 때문에 서비스 클래스의 응집도가 떨어지고 가독성 또한 나빠지며, 변경할 부분이 명확하게 드러나지 않게 되는등 유지보수 측면에서 아쉬운 점이 많아진다.


Proxy를 사용하여 개선

프록시 객체에 트랜잭션 등 부가 기능 관련 로직을 위치시키고, 클라이언트 요청이 발생하면 실제 타깃 객체는 프록시로부터 요청을 위임받아 핵심 비즈니스 로직을 실행합니다. 이를 데코레이터 패턴이라고 한다.

public interface UserService {

    void someSevice();
}
@Service
@Primary
@RequiredArgsConstructor
public class SimpleUserService implements UserService {

    @Override
    public void someSevice() {
                ...
    }
}
@Service
@RequiredArgsConstructor
public class UserServiceProxy implements UserService {

    private final UserService target;
    private final PlatformTransactionManager transactionManager;

    @Override
    public void someSevice() {
            TransactionStatus transaction = transactionManager.getTransaction(new DefaultTransactionDefinition());
        try {
        // 부가 기능 - 로깅, 보안 등등
        // 핵심 기능
        someServiceMethod();
        // 부가 기능
        transactionManager.commit(transaction);
        } catch (RuntimeException runtimeException) {
            transactionManager.rollback(transaction);
            throw runtimeException;
        }
    }
}

프록시 객체를 이용하여 핵심 메소드가 호출되기 이전에 부가 기능을 적용하였다.


기존 프록시의 문제점

  • 인터페이스의 모든 메소드를 구현해 위임하도록 코드를 만들어야 한다.
  • 부가기능인 기능이 모든 메소드에 중복돼서 나타난다.

프록시 객체를 이용하여 핵심 비즈니스 로직과 부가 기능 관심사를 분리할 수 있었지만 여전히 한계가 존재한다.. 100개의 클래스가 이와 비슷한 기능을 요구한다면, 100개의 프록시 클래스를 생성하고 인터페이스 메서드를 일일이 구현해야 합니다.

다행히 이러한 별도의 프록시를 번거롭게 생성하는 작업을 생략하는 방법이 존재합니다. Java의 Reflection API를 이용하거나, Spring의 ProxyFactoryBean 등을 사용하는 방법이 존재한다.

  • 더 자세한 설명은 토비의 스프링 or 해당 글 참고!!

Spring AOP

Spring AOP는 Proxy를 기반으로 한 Runtime Weaving 방식이다

  • 프록시 패턴 기반의 AOP 구현체
  • 스프링 빈에만 AOP를 적용 가능
  • 메소드 조인포인트만 제공

스프링이 사용하는 다이나믹 프록시에는 2가지 방법이 있다.

  • JDK Dynamic Proxy
  • CGLib Proxy

스프링에서는 기본적으로 jdk dynamic proxy, 스프링 부트에서는 CGLib Proxy 방식으로 AOP를 사용한다.

JDK Dynamin Proxy, CGLib Proxy의 차이를 알아보자.


JDK Dynamic Proxy

JDK Dynamic Proxy는 Proxy Factory에 의해 런타임시 동적으로 만들어지는 오브젝트이다. JDK Dynamic Proxy는 반드시 인터페이스가 정의되어있고, 인터페이스에 대한 명세를 기준으로 Proxy를 생성한다. 즉, 인터페이스 선언에 대한 강제성이 있다는 단점이 있다.

내부적으로 JDK Dynamic Proxy에서는 InvationHandler라는 인터페이스를 구현해 만들어지는데, invoke 함수를 오버라이딩하여 Proxy의 위임 기능을 수행한다. 이 과정에서 객체에 대한 Reflection 기능을 사용해 구현하기 때문에 퍼포먼스 하락의 원인이 되기도 한다.

  • 이 방식이 Spring AOP의 근간이 되는 방식이다.
  • 인터페이스를 기준으로 Proxy 객체를 생성해준다.
  • 인터페이스가 반드시 필요하다.
  • 리플렉션을 사용하기때문에 성능적으로 좋지 못 하다.

CGLib Proxy

CGLIB Proxy는 순수 Java JDK 라이브러리를 이용하는 것이 아닌 CGLIB라는 외부 라이브러리를 추가해야만 사용할 수 있다. CGLIB의 Enhancer 클래스를 바탕으로 Proxy를 생성하며, 인터페이스가 없어도 Proxy를 생성할 수 있다. CGBLIB Proxy는 타겟 클래스를 상속받아 생성하기 때문에 Proxy를 생성하기 위해 인터페이스를 만들어야하는 수고를 덜 수 있다.

하지만, 상속을 이용하므로 final이나 private와 같이 상속에 대해 오버라이딩을 지원하지 않는 경우에는 Aspect를 적용할 수 없다는 단점이 있다.

CGLIB Proxy는 바이트 코드를 조작해서 프록시 객체를 생성하므로 JDK Dynamic Proxy보다 퍼포먼스가 빠른 장점이 있다.

  • 상속을 이용하기 때문에 클래스나 메소드에 final이 있으면 안된다.
  • 스프링 부트에서 AOP 사용을 위해 채택했다.
  • CGLIB은 고성능의 코드 생성 라이브러리로 인터페이스를 필요로 하는 JDK Dynamic Proxy 대신 사용될 수 있다. 바이트코드를 조작하는 프레임워크인 ASM을 사용하여 리플렉션보다 빠르다.

스프링 부트에서는 왜 cglib?

  • 스프링에선 CGLib은 3가지 한계가 존재했다.
    • 해당 라이브러리를 추가해야 한다.
    • CGLib을 구현하기 위해 반드시 파라미터가 없는 defalut 생성자가 필요하다.
    • 생성된 Proxy의 메소드를 호출하게 되면 타깃의 생성자가 2번 호출된다.
  • 하지만 문제되는 부분들을 개선하여 안정화 시켰다.
  • 스프링 부트에서는 AOP를 사용할 때 인터페이스로 선언되어 있어도 CGLib 방식으로 프록시 객체를 생성한다.

참고 출처 : https://gmoon92.github.io/spring/aop/2019/04/20/jdk-dynamic-proxy-and-cglib.html


AOP 주요 개념

  • Target : Aspect를 적용하는 곳 (클래스, 메서드 .. )
  • Aspect : 위에서 설명한 흩어진 관심사를 모듈화 한 것. 주로 부가기능을 모듈화함.
  • Advice : 실질적으로 어떤 일을 해야할 지에 대한 것, 실질적인 부가기능을 담은 구현체
  • JointPoint : Advice가 적용될 위치, 끼어들 수 있는 지점. 메서드 진입 지점, 생성자 호출 시점, 필드에서 값을 꺼내올 때 등 다양한 시점에 적용가능
  • PointCut : JointPoint의 상세한 스펙을 정의한 것. 'A란 메서드의 진입 시점에 호출할 것'과 같이 더욱 구체적으로 Advice가 실행될 지점을 정할 수 있음

참고 출처

728x90

'spring' 카테고리의 다른 글

WebClient의 DataBufferLimitException 해결방법  (0) 2024.11.15
TestExecutionListener를 이용한 테스트 격리 방법  (1) 2024.11.05
Spring Async  (0) 2024.08.02
Spring Webclient  (0) 2024.08.02
Spring Boot Slice Test  (0) 2024.08.02
728x90

Spring Data JPA 사용하는 미션에서 멘토님께 리뷰를 받으며 다음과 같은 피드백을 받으며 슬라이스 테스트 존재에 대해 알게 되었다.

  • Repository Test시 @SpringBootTest를 @DataJpaTest로 변경해서 테스트 작성하기

슬라이스 테스트란 무엇이고 왜 사용하는 것일까??


Spring Boot 슬라이스 테스트


슬라이스 테스트란?

  • 즉 스프링은 레이어 별로 잘라서 특정 레이어에 대해서 Bean을 최소한으로 등록시켜 테스트 하고자 하는 부분에 최대한 단위 테스트를 지원해주고 있다.
  • 그렇다면 @SpringBootTest 대신 슬라이스 테스트를 하는 이유는 무엇일까??

F.I.R.S.T 테스트 원칙

단위 테스트는 응용 프로그램에서 테스트 가능한 가장 작은 소프트웨어를 실행하여 예상대로 동작하는지 확인하는 테스트이며 로버트 마틴의 클린코드에서 깨끗한 테스트를 위한 다섯 가지 F.I.R.S.T 규칙을 말한다.

  • Fast — 테스트는 빨라야 한다.
  • Isolated — 각 테스트는 서로 의존하면 안된다.
  • Repeatable — 테스트는 어떤 환경에서도 반복 가능해야 한다.
  • Self-validating — 테스트는 bool 값으로 결과를 내야 한다.
  • Timely — 테스트는 적시에 작성해야 한다.

@SpringBootTest 어노테이션을 사용하는 경우 단점은 아래와 같다.

  • 실제 구동되는 애플리케이션의 설정, 모든 Bean을 로드하기 때문에 시간이 오래걸리고 무겁다.
  • 테스트 단위가 크기 때문에 디버깅이 어려운 편이다.
  • 외부 API 콜같은 Rollback 처리가 안되는 테스트 진행을 하기 어려움

따라서 repository 레이어의 단위테스트의 경우 @SpringBootTest 대신 @DataJpaTest 사용하여 테스트를 작성하는 경우 통해 속도적인 측면과 의존성 측면에서 이점을 가질 수 있다.


슬라이스 테스트 어노테이션 종류

아래는 대표적인 슬라이스 테스트 어노테이션이 존재하는데 해당 글에서는 중 @WebMvcTest, @DataJpaTest 살펴보도록 할 것이다.

  • @WebMvcTest
  • @WebFluxTest
  • @DataJpaTest
  • @JsonTest
  • @RestClientTest

@WebMvcTest

  • MVC를 위한 테스트.
  • 웹에서 테스트하기 힘든 컨트롤러를 테스트하는 데 적합.
  • 웹상에서 요청과 응답에 대해 테스트할 수 있음.
  • 시큐리티, 필터까지 자동으로 테스트하며, 수동으로 추가/삭제 가능.
  • @SpringBootTest 어노테이션보다 가볍게 테스트할 수 있음.
  • 다음과 같은 내용만 스캔하도록 제한함.@Controller, @ControllerAdvice, @JsonComponent, Converter, GenericConverter, Filter, HandlerInterceptor,
    • 따라서 의존성이 끊기기 때문에, 예를 들면 서비스와 같은 객체들은 @MockBean을 사용해서 만들어 사용해야 한다.
@WebMvcTest(ShelterPostController.class)
public class ShelterPostControllerTest {

    @Autowired
    protected MockMvc mockMvc;

    @Autowired
    protected ObjectMapper objectMapper;

    @MockBean
    protected ShelterPostService shelterPostService;

    @Test
    @DisplayName("게시글 리스트 조회 테스트")
    void getShelterPostsTest() throws Exception {
        // given, when, then
                ...
    }
}

@MockBean

spring-boot-test 패키지는 Mockito를 포함하고 있기 때문에 기존에 사용하던 방식대로 Mock 객체를 생성해서 테스트하는 방법도 있지만, spring-boot-test에서는 새로운 방법도 제공한다.

  • 바로 @MockBean 어노테이션을 사용해서 이름 그대로 Mock 객체를 빈으로써 등록할 수 있다.
  • 기존에 사용되던 스프링 Bean이 아닌 Mock Bean을 주입한다.
  • 그렇기 때문에 만일 @MockBean으로 선언된 빈을 주입받는다면 Spring의 ApplicationContext는 Mock 객체를 주입한다.
  • 새롭게 @MockBean을 선언하면 Mock 객체를 빈으로써 등록하지만, 만일 @MockBean으로 선언한 객체와 같은 이름과 타입으로 이미 빈으로 등록되어있다면 해당 빈은 선언한 Mock 빈으로 대체된다.

해당 어노테이션은 테스트 내용 중 외부 서비스를 호출하는 부분을 Mock해서 쉽게 처리할 수 있다.

@SpringBootTest
public class XXXControllerTest {

    @MockBean  // 외부 서비스 호출에 사용되는 RestTemplate Bean을 Mock
    private RestTemplate mockRT;

    @MockBean  // 외부 서비스 호출에 사용되는 Service Bean을 Mock
    private XXXService xXXService;

}

@DataJpaTest

Spring Data JPA를 테스트하고자 한다면 @DataJpaTest 기능을 사용해볼 수 있다.

  • 해당 테스트는 기본적으로 in-memory embedded database를 생성하고 @Entity 클래스를 스캔한다.
  • 일반적인 다른 컴포넌트들은 스캔하지 않는다. 따라서 특정 bean의 의존성이 필요한 경우 아래의 방법 사용
    • @import
    • @DataJpaTest(includeFilters = @Filter(..))

@DataJpaTest@Transactional 어노테이션을 포함하고 있다.

  • 따라서 테스트가 완료되면 자동으로 롤백된다.

만약 @Transactional 기능이 필요하지 않다면 아래와 같이 줄 수 있다.

@DataJpaTest
@Transactional(propagation = Propagation.NOT_SUPPORTED)
class SomejpaTest {
    ...
}

@DataJpaTest 기능을 사용하면 @Entity를 스캔하고 repository를 설정하는 것 이외에도 테스트를 위한 TestEntityManager라는 빈이 생성된다.

  • 이 빈을 사용해서 테스트에 이용한 데이터를 정의할 수 있다.
@DataJpaTest
class SomejpaTest {

    @Autowired
    private TestEntityManager entityManager;

    @Test
    @DisplayName("게시글 아이디로 댓글 목록 삭제 테스트")
    void deleteAllByMissingPostIdTest() {
        // given
        LongStream.rangeClosed(1, 3).forEach(idx ->
            entityManager.persist(Comment.builder()
                .missingPost(missingPost)
                .content("내용")
                .account(account)
                .build()
            )
        );

        // when
        commentRepository.deleteAllByMissingPostId(missingPost.getId());
        List<Comment> comments = commentRepository.findAll();

        // then
        SoftAssertions.assertSoftly(softAssertions -> {
                softAssertions.assertThat(comments).hasSize(3);
                comments.forEach(foundComment -> softAssertions.assertThat(foundComment.isDeleted()).isTrue());
            }
        );
    }

}

만약 테스트에 내장된 임베디드 데이터베이스를 사용하지 않고 real database를 사용하고자 하는 경우, @AutoConfigureTestDatabase 어노테이션을 사용하면 손쉽게 설정할 수 있다.

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class SomejpaTest {
    ...
}

사용시 주의할 점

슬라이스 테스트 시, 하위 레이어는 Mock 기반으로 만들기 때문에 주의할 점들이 있다.

  • 의존성 객체를 Mocking하기 때문에 완벽한 테스트는 아님
  • Mocking 처리하기 위한 시간이 소요
  • Mocking 라이브러리에 대한 학습 비용 발생
  • Mock 기반 으로 테스트하기 때문에 실제 환경에서는 결과가 다를 수 있음

참고 출처

728x90

'spring' 카테고리의 다른 글

WebClient의 DataBufferLimitException 해결방법  (0) 2024.11.15
TestExecutionListener를 이용한 테스트 격리 방법  (1) 2024.11.05
Spring Async  (0) 2024.08.02
Spring Webclient  (0) 2024.08.02
Spring AOP  (0) 2024.08.02
728x90

헤드 퍼스트 디자인 패턴을 읽고 정리한 글입니다.

템플릿 메소드 패턴은 알고리즘의 골격을 정의한다. 템플릿 메소드를 사용하면 알고리즘의 일부 단계를 서브클래스에서 구현할 수 있으며, 알고리즘의 구조는 그대로 유지하면서 알고리즘의 특정 단계를 서브클래스에서 재정의할 수도 있다.

  • 서브클래스에서 언제든 필요할 때마다 알고리즘을 가져다가 쓸 수 있도록 캡슐화 해보자
  • 할리우드 디자인 원칙을 알아보자

개요 - 커피와 홍차 만들기

커피와 홍차는 둘 다 카페인을 가지고 있고, 가장 중요한 점은 매우 비슷한 방법으로 만들어진다는 것이다.

커피 만드는 법

  1. 물을 끓인다.
  2. 끓는 물에 커피를 우려낸다.
  3. 커피를 컵에 따른다.
  4. 설탕과 우유를 추가한다.

홍차 만드는 법

  1. 물을 끓인다.
  2. 끓는 물에 찻잎을 우려낸다.
  3. 홍차를 컵에 따른다.
  4. 레몬을 추가한다.

Coffee 클래스와 Tea 클래스 만들기

이제 커피와 홍차를 만드는 클래스를 준비해 보자.

public class Coffee {

    public void prepareRecipe() {
        boilWater();
        brewCoffeeGrinds();
        pourInCup();
        addSugarAndMilk();
    }

    private void boilWater() {
        System.out.println("물 끓이는 중");
    }

    private void brewCoffeeGrinds() {
        System.out.println("필터로 커피를 우려내는 중");
    }

    private void pourInCup() {
        System.out.println("컵에 따르는 중");
    }

    private void addSugarAndMilk() {
        System.out.println("설탕과 우류를 추가하는 중");
    }

}
public class Tea {

    public void prepareRecipe() {
        boilWater();
        steepTeaBag();
        pourInCup();
        addLemon();
    }

    private void boilWater() {
        System.out.println("물 끓이는 중");
    }

    private void steepTeaBag() {
        System.out.println("필터로 커피를 우려내는 중");
    }

    private void pourInCup() {
        System.out.println("컵에 따르는 중");
    }

    private void addLemon() {
        System.out.println("설탕과 우류를 추가하는 중");
    }

}

조금 전에 Coffee 클래스에서 구현했던 방법과 비슷한 것을 느낄 수 있다.

두 번째와 네 번째 단계가 조금 다르지만 기본적으로 같다고 할 수 있다. 이렇게 공통적으로 코드가 중복된다면 디자인 수정을 고려해보자.

혹시 Coffee와 Tea 클래스는 거의 같으니까 두 클래스의 공통된 부분을 추상화해서 베이스 클래스로 만드는 것을 어떨까??


Coffee 클래스와 Tea 클래스 추상화하기

https://books.google.co.kr/books?id=Lw8LEAAAQBAJ&printsec=frontcover&hl=ko&source=gbs_ge_summary_r&cad=0#v=onepage&q&f=false

CaffeineBeverage

  • prepareRecipe() 메소드는 서브클래스마다 다르기 때문에 추상 메서드로 선언
  • boilWater()와 putInCup() 메서드는 두 클래스에서 공통으로 사용되므로 슈퍼클래스에 정의

서브 클래스(Coffee, Tea)

  • 서브 클래스는 prepareRecipe() 메소드를 오버라이드해서 음료 제조법을 구현
  • Coffee나 Tea 클래스에만 있던 메소드는 서브 클래스에 그대로 남김

추상화 방법 들여다보기

제조법을 다시 살펴보면 커피와 홍차 제조법의 알고리즘이 똑같다는 사실을 알 수 있다.

  1. 물을 끓인다.
  2. 뜨거운 물을 사용해서 커피 또는 찻잎을 우려낸다.
  3. 만들어진 음료를 컵에 따른다.
  4. 각 음료에 맞는 첨가물을 추가한다.

https://books.google.co.kr/books?id=Lw8LEAAAQBAJ&printsec=frontcover&hl=ko&source=gbs_ge_summary_r&cad=0#v=onepage&q&f=false

  • 2, 4 이 둘은 추상화되지 않았지만 똑같다. 서로 다른 음료에 적용될 뿐이다.
  • 이제 prepareRecipe() 까지 추상화하는 방법을 찾아보자. 생각해보면 커피를 필터로 우려내는 일과 티백을 물에 넣어서 홍차를 우려내는 일은 별로 다르지 않다. 사실 거의 같다고 볼 수 있기 때문에 brew() 메서드를 만들어서 커피를 우려내는 홍차를 우려내는 똑같은 메서드를 사용해보자.
  • 이와 마찬가지로 설탕과 우유를 추가하는 일이나 레몬을 추가하는 일도 마찬가지다. 음료에 첨가물을 넣는다는 사실 자체는 똑같기 때문이다. 따라서 addConfiments() 메소드를 양쪽에 사용해보자
public abstract class CaffeineBeverage {

    public void prepareRecipe() {
        boilWater();
        brew();
        pourInCup();
        addCondiments();
    }

    protected abstract void addCondiments();

    protected abstract void brew();

    private void boilWater() {
        System.out.println("물 끓이는 중");
    }

    private void pourInCup() {
        System.out.println("컵에 따르는 중");
    }

}
  • 이제 다시 만든 메소드를 prepareRecipe()에 넣어보자.
public class Coffee extends CaffeineBeverage {

    @Override
    protected void brew() {
        System.out.println("필터로 커피를 우려내는 중");
    }

    @Override
    protected void addCondiments() {
        System.out.println("설탕과 우류를 추가하는 중");
    }

}

------------------------------------------------------------------

public class Tea extends CaffeineBeverage{

    @Override
    protected void brew() {
        System.out.println("필터로 커피를 우려내는 중");
    }

    @Override
    protected void addCondiments() {
        System.out.println("설탕과 우류를 추가하는 중");
    }

}
  • 두 클래스에서 음료를 만드는 방법은 CaffeinBeverage에 의해 결정되므로 음료를 우려내는 brew()와 첨가물을 추가하는 addCondiments()를 수정하자.

추상화 과정 다시 살펴보기

https://books.google.co.kr/books?id=Lw8LEAAAQBAJ&printsec=frontcover&hl=ko&source=gbs_ge_summary_r&cad=0#v=onepage&q&f=false

조금 다른 방식으로 구현해야 하는 부분이 있긴 하지만, 만드는 방법이 사실상 똑같으므로 제조법을 일반화해서 베이스 클래스에 넣었다.

그 후 베이스 클래스에서 전체 처리 과정을 관리하며, 첫 번째와 세 번째 단계는 직접 처리하고 두 번째와 네 번째 단계는 Tea와 coffee 서브클래스에 의존한다.


템플릿 메소드 패턴 알아보기

지금까지 Coffee와 Tea 클래스에 템플릿 메소드 패턴을 적용했다고 할 수 있다. 템플릿 메소드는 CaffeinBeverage 클래스에 들어있다.

https://books.google.co.kr/books?id=Lw8LEAAAQBAJ&printsec=frontcover&hl=ko&source=gbs_ge_summary_r&cad=0#v=onepage&q&f=false

  • prepareRecipe()는 템플릿 메소드이다.
  • 템플릿 메소드란 어떤 알고리즘의 템플릿 역할을 하는 메서드이다.
  • 템플릿 내에서 알고리즘의 각 단계는 메소드로 표현한다.
  • 어떤 메소드는 해당 클래스에서 처리되기도 하고, 서브 클래스에서 처리되는 메소드도 있다.
  • 서브 클래스에서 구현해야 하는 메소드는 abstract로 선언해야 한다.

즉 템플릿 메소드는 알고리즘의 각 단계를 정의하며, 서브 클래스에서 일부 단계를 구현할 수 있도록 유도한다.


템플릿 메소드 패턴의 장점

기존 Tea & Coffee 클래스

  • 각 클래스가 각각 작업을 처리한다.
    • 두 클래스에서 각자 알고리즘을 수행
  • Coffee 와 Tea 클래스에 중복된 코드가 존재
  • 알고리즘이 바뀌면 서브클래스를 일일이 열어서 여러 군데를 고쳐야 한다.
  • 클래스 구조상 새로운 음료를 추가하려면 꽤 많은 일을 해야 한다.
  • 알고리즘 지식과 구현 방법이 여러 클래스에 분산되어 있다.

템플릿 메소드를 사용한 CaffeinBeverage

  • CaffeinBeverage 클래스에서 작업을 처리한다.
    • 알고리즘을 독점
  • 서브 클래스에서 코드를 재사용할 수 있다.
  • 알고리즘이 한 군데에 모여 있으므로 한 부분만 고치면 된다.
  • 다른 음료도 쉽게 추가할 수 있는 프레임워크를 제공한다.
    • 음료를 추가할 때 몇 가지 메소드만 더 만들면 된다.
  • CaffeinBeverage 클래스에 알고리즘 지식이 집중되어 있으며 일부 구현만서브클래스에 의존한다.

템플릿 메소드 패턴의 정의

이제 패턴의 정의와 특징을 자세히 알아보자.

간단하게 말하면 템플릿 메소드 패턴은 알고리즘의 템플릿(틀)을 만든다. 템플릿이란 일련의 단계로 알고리즘을 정의한 메소드이다. 여러 단계 가운데 하나 이상의 단계가 추상 메소드로 정의되며, 그 추상 메소드는 서브 클래스에서 구현된다. 이러면 서브 클래스가 일부분의 구현을 처리하게 하면서도 알고리즘의 구조는 바꾸지 않아도 된다.

클래스 다이어그램

https://books.google.co.kr/books?id=Lw8LEAAAQBAJ&printsec=frontcover&hl=ko&source=gbs_ge_summary_r&cad=0#v=onepage&q&f=false

  • 템플릿 메소드는 알고리즘을 구현할 때 primitiveOperation을 활용한다.
    • 알고리즘은 이 단계들의 구체적인 구현으로부터 분리되어 있다.
  • 서브 클래스가 알고리즘의 각 단계를 마음대로 건드리지 못하게 final로 선언합니다.
  • 추상 클래스 내에 구상 메소드로 정의된 단계도 있다. 해당 메소드는 fianl로 선언되었으므로 서브 클래스에서 오버라이드할 수 없다.
    • 이 메소드는 템플릿 메소드에서 직접 호출할 수도 있고
    • 서브클래스에서 호출해서 사용할 수도 있다.
  • 기본적으로 아무것도 하지 않는 구상 메소드를 정의할 수도 있다.
    • 이런 메소드를 후크(hook) 라고 부른다.
    • 서브 클래스에서 오버라이드할 수도 있지만, 반드시 그래야 하는건 아니다.

후크 알아보기

후크는 추상 클래스에서 선언되지만 기본적인 내용만 구현되어 있거나 아무 코드도 들어있지 않은 메소드이다.

이러면 서브 클래스는 다양한 위치에서 알고리즘에 끼어들 수 있다. 물론 그냥무시하고 넘어갈 수도 있다. 후크는 다양한 용도로 사용된다. 한 가지 사용법의 예시를 알아보자.

public abstract class CaffeineBeverage {

    public void prepareRecipe() {
        boilWater();
        brew();
        pourInCup();
        if (customerWantsCondiments()) {
            addCondiments();
        }
    }

    protected abstract void addCondiments();

    protected abstract void brew();

    private void boilWater() {
        System.out.println("물 끓이는 중");
    }

    private void pourInCup() {
        System.out.println("컵에 따르는 중");
    }

    private boolean customerWantsCondiments() { // hook
        return true;
    }

}
  • 별 내용이 없는 기본 메소드를 구현해 놓았다.
  • 해당 메소드는 true만 리턴할 뿐 다른 작업은 하지 않는다.
  • 이 메소드는 서브클래스에서 필요할 때 오버라이드할 수 있는 메소드이므로 후크이다.

후크 활용하기

후크를 사용하려면 서브 클래스에서 후크를 오버라이드해야 한다.

위의 예제에서는 알고리즘의 특정 부분을 처리할지 말지를 결정하는 용도로 후크를 사용했다.

즉, 음료에 첨가물을 추가할지 말지를 결정하는 메소드다.

public class CoffeeWithHook extends CaffeineBeverage { // 후크를 오버라이드해서 원하는 기능을 추가

    @Override
    protected void brew() {
        System.out.println("필터로 커피를 우려내는 중");
    }

    @Override
    protected void addCondiments() {
        System.out.println("설탕과 우류를 추가하는 중");
    }

    @Override
    protected boolean customerWantsCondiments() {
        String answer = getUserInput();
        if (answer.toLowerCase().startsWith("y")) {
            return true;
        }
        return false;
    }

    private String getUserInput() {
        String answer = null;
        System.out.println("커피에 우유와 설탕을 넣을까요? (y/n)? ");
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        try {
            answer = br.readLine();
        } catch (IOException exception) {
            System.out.println("io exception");

        }
        if (answer == null) {
            return "no";
        }
        return answer;
    }
}

후크 코드 테스트

public class BeverageTestDrive {

    public static void main(String[] args) {
        CoffeeWithHook coffeeWithHook = new CoffeeWithHook();
        System.out.println("커피 준비 중");
        coffeeWithHook.prepareRecipe();
    }

}

===================================================================

커피 준비 중
물 끓이는 중
필터로 커피를 우려내는 중
컵에 따르는 중
커피에 우유와 설탕을 넣을까요? (y/n)? 
y
설탕과 우류를 추가하는 중

Q) 템플릿을 만들 때 추상 메소드를 써야할 때와 후크를 써야할 때를 어떻게 구분할 수 있을까??

서브 클래스가 알고리즘의 특정 단계를 제공해야만 한다면 추상 메소드를 써야 한다. 알고리즘의 특정 단계가 선택적으로 적용된다면 후크를 쓰면 된다. 후크를 쓰면 서브클래스에서 필요할 때 후크를 구현할 수도 있지만, 꼭 구현해야 하는건 아니기 때문이다.

Q) 후크는 정확하게 어떤 용도로 쓰이는 걸까??

여러 가지 용도로 쓰인다. 알고리즘에서 필수적이지 않은 부분을 서브클래스에서 구현하도록 만들고 싶을 때 후크를 쓸 수 있다. 또한 템플릿 메소드에서 앞으로 일어날 일이나 막 일어난 일에 서브클래스가 반응할 수 있도록 기회를 제공하는 용도로도 쓰일 수 있다.

예를 들면, 내부적으로 특정 목록을 재정렬한 후에 서브 클래스에서 특정 작업을 수행하도록 싶을 때, justReOrderedList() 같은 이름을 가진 후크 메소드를 쓸 수도 있다. 또한 앞에 예제에서 봤듯이 서브클래스가 추상 클래스에서 진행되는 작업을 처리할지 말지 결정하게 하는 기능을 부여하는 용도로 후크를 쓸 수도 있다.

Q) 서브클래스에서 AbstractClass에 있는 모든 추상 메소드를 구현해야 할까??

그렇다. 모든 서브클래스에서 모든 추상 메소드를 정의해야 한다.

즉, 템플릿 메소드에 있는알고리즘의 단계 중에서 정의되지 않은 부분을 모두 채워 줘야 한다.

Q) 추상 메소드가 너무 많아지면 서브 클래스에서 일일이 추상 메소드를 구현해야 하니까 별로 좋지 않을 수 있지 않을까??

맞다. 템플릿 메소드를 만들 때는 그 점을 꼭 생각해 봐야 한다.

알고리즘의 단계를 너무 잘게 쪼개지 않는 것도 한 가지 방법이 될 수 있다. 하지만 알고리즘을 큼직한 몇 가지 단계로만 나누어 놓으면 유연성이 떨어진다는 단점도 있으니 잘 생각해서 결정해야 한다.

그리고 모든 단계가 필수는 아니라는 점도 기억하자. 필수가 아닌 부분을 후크로 구현하면 그 추상 클래스의 서브 클래스를 만들 때 부담이 조금 줄어들 것이다.


할리우드 원칙

디자인 원칙 중 할리우드 원칙이 있다. 이 원칙은 보통 다음과 같이 정의될 수 있다.

할리우드에서 배우들과 연락하는 것과 비슷하게, 슈퍼 클래스에서 모든 것을 관리하고 필요한 서브클래스를 불러서 써야 한다는 원칙이다. 이런 할리우드 원칙을 활용하면 의존성 부패(dependency rot)를 방지할 수 있다.

어떤 고수준 구성 요소가 저수준 구성 요소에 의존하고, 그 저수준 구성 요소는 다시 고수준 구성 요소에 의존하고, 그 고수준 구성 요소는 다시 또 다른 구성 요소에, 그 다른 구성 요소는 또 저수준 구성 요소에 의존하는 것과 같은 식으로 의존성이 복잡하게 꼬여있는 상황을 의존성이 부패했다고 부른다. 이렇게 의존성이 부패하면 시스템 디자인이 어떤 식으로 되어 있는지 아무도 알아볼 수 없다.

할리우드 원칙을사용하면, 저수준 구성 요소가 시스템에 접속할 수는 있지만 언제, 어떻게 그 구성 요소를 사용할지는 고수준 구성 요소가 결정한다.

즉 고수준 구성 요소가 저수준 구성 요소에게 “먼저 연락하지 마세요. 제가 먼저 연락 드리겠습니다.” 라고 이야기 하는 것과 같다.

https://books.google.co.kr/books?id=Lw8LEAAAQBAJ&printsec=frontcover&hl=ko&source=gbs_ge_summary_r&cad=0#v=onepage&q&f=false

  • 저수준 구성 요소도 컴퓨테이션에 참여할 수 있다.
  • 하지만 언제, 어떻게 쓰일지는 고수준 구성 요소가 결정한다.
    • 저수준 구성 요스는 절대 고수준 구성 요소를 직접 호출할 수 없다.

할리우드 원칙과 템플릿 메소드 패턴

할리우드 원칙과 템플릿 메소드 패턴의 관계는 쉽게 알 수 있다. 템플릿 메소드 패턴을 써서 디자인 하면 서브클래스에게 “우리가 연락할 테니까 먼저 연락하지마”라고 얘기하는 구조이기 대문이다.

디자인을 다시 한번 살펴보자

https://books.google.co.kr/books?id=Lw8LEAAAQBAJ&printsec=frontcover&hl=ko&source=gbs_ge_summary_r&cad=0#v=onepage&q&f=false

  • CaffeineBeverage 는 고수준 구성 요소이다.
    • 음료를 만드는 방법에 해당하는 알고리즘을 장악하고 있고, 메소드 구현이 필요한 상황에만 서브클래스를 불러낸다.
  • CaffeineBeverage 클래스의 클라이언트는 Tea, Coffee 같은 구상 클래스가 아닌 CaffeineBeverage 에 추상화되어 있는 부분에 의존한다. 이러면 전체 시스템의 의존성을 줄일 수 있다.
  • 서브 클래스는 자질 구레한 메소드 구현을 제공하는 용도로만 쓰인다.
  • Tea와 Coffee는 호출 당하기 전까지는 추상 클래스를 직접 호출하지 않는다.

Q) 할리우드 원칙과 의존성 뒤집기 원칙은 어떤 관계일까??

의존성 뒤집기 원칙은 될 수 있으면 구상 클래스 사용을 줄이고 추상화된 것을 사용해야 한다는 원칙이다. 할리우드 원칙은 저수준 구성 요소가 컴퓨테이션에 참여하면서도 저수준 구성 요소와 고수준 계층 간 의존을 없애도록 프레임워크는 구성 요소를 구축하는 기법이다.

따라서 두 원칙은 객체를 분리한다는 하나의 목표를 공유하지만, 의존성을 피하는 방법에 있어서 의존성 뒤집기 원칙이 훨씬더 강하고 일반적인 내용을 담고 있다.

할리우드 원칙은 저수준 구성요소를 다양하게 사용할수 있으면서도 다른 클래스가 구성 요소에 너무 의존하지 않게 만들어주는 디자인 구현 기법을 제공한다.

Q) 저수준 구성 요소에서는 고수준 구성 요소에 있는 메소드를 호출할 수 없는 것일까??

그렇지 않다. 사실 저수준 구성 요소에서도 상속 계층구조 위에 있는 클래스가 정의한 메소드를, 상속으로 호출하는 경우도 빈번하게 있다. 하지만 저수준 구성 요소와 고수준 구성 요소 사이에 순환 의존성이 생기지 않도록 해야한다.


자바 api 속 템플릿 메서드 패턴 알아보기

바로 Arrays.sort 메소드이다.

private static void legacyMergeSort(Object[] a) {
    Object[] aux = a.clone();
    mergeSort(aux, a, 0, a.length, 0);
}

private static void mergeSort( // 템플릿 메소드
        Object[] src,
    Object[] dest,
    int low,
    int high,
    int off
    ) {
      // Insertion sort on smallest arrays
      if (length < INSERTIONSORT_THRESHOLD) {
          for (int i=low; i<high; i++)
              for (int j=i; j>low &&
                       ((Comparable) dest[j-1]).compareTo(dest[j])>0; j--) // 템플릿 메소드를 완성하려면 comapreTo() 메소드를 구현해야 한다.
                  swap(dest, j, j-1); // Arrays 클래스에 이미 정의되어 있는 구상 ㅇ메솓,
          return;
      }
        // ...

}

만약 배열 속 오리 클래스들을 정렬해야 한다면 Arrays에 있는 정렬용 템플릿 메소드에서 알고리즘을 제공하지만, 오리 비교 방법은 comapreTo() 메소드로 구현해야 한다.

하지만 템플릿 메소드 패턴을 배울 때 서브 클래스에서 일부 단계를 구현한다고 배웠는데, 해당 예제에서는 서브 클래스를 만들지 않고 있다.

sort() 메소드는 정적 메소드이고 정적 메소드 자체는 크게 문제가 되지 않는다. 슈퍼 클래스에 들어있다고 생각하면 되기 때문이다. 하지만 sort() 자체가 특정 슈퍼클래스에 정의되어 있는게 아니므로 sort() 메소드가 우리가 comapreTo() 메소드를 구현했는지 알아낼 수 있는 방법이 필요하다는 점이다.

이러한 문제를 해결하기 위해 Comaprable 인터페이스가 도입되었다. 이제 해당 인터페이스를 구현하기만 하면 문제가 해결 된다.

public class Duck implements Comparable<Duck> {

    private final String name;
    private final int weight;

    public Duck(String name, int weight) {
        this.name = name;
        this.weight = weight;
    }

    public String getName() {
        return name;
    }

    public int getWeight() {
        return weight;
    }

    @Override
    public int compareTo(Duck otherDuck) {
        return Integer.compare(this.weight, otherDuck.getWeight());
    }
}

Q) 오리 정렬 코드가 정말 템플릿 메소드 패턴일까? 억지스러운 것일까?

Arrays.sort() 메소드는 분명 템플릿 메소드 패턴의 정의의 방법을 사용하지 않고 있지만 실전에서 패턴을 적용하는 방법이 책에 나와 있는 방법과 완전히 같을 수는 없다. 주어진 상황과 구현ㄴ상 제약조건에 맞게 고쳐서 적용해야 한다.

일반적으로 자바에서는 배열의 서브클래스를 만들 수 없지만, 어떤 배열에서도 정렬 기능을 사용할 수 있도록 만들어야 했다. 그래서 sort() 메소드를 정적 메소드로 정의한 다음, 대소를 비교하는 부분은 정렬될 객체에서 구현되도록 만든 것이다.

온전한 템플릿 메소드라고 할 순 없겠지만 템플릿 메소드 패턴의 기본 정신을 충실히 따르고 있다. 또한 서브클래스를 만들어야 한다는 제약 조건을 없앰으로써 오히려 더 유연하면서 유용한 정렬 메소드를 만들었다.

Q) 구현해놓은 것을 보니 템플릿 메소드 패턴 보다는 전략 패턴과 가까워 보이는데 템플릿 메소드 패턴이라고 볼 수 있는 근거는 무엇일까?

전략 패턴에서 객체 구성을 사용하니까 어떻게 보면 일리가 있지만 전략 패턴에서는 구성할 때 사용하는 클래스에서 알고리즘을 완전히 구현한다.

Arrays 클래스에서 사용하는 알고리즘은 불완전한다. comapreTo() 를 다른 클래스에서 제공해 줘야 하기 때문이다. 따라서 템플릿 메소드 패턴이 적용되었다고 볼 수 있다.


개념이 비슷해 보이는 패턴

  • 템플릿 메소드 패턴 : 알고리즘의 어떤 단계를 구현하는 방법을 서브클래스에서 결정
  • 전략 패턴 : 바꿔 쓸 수 있는 행동을 캡슐화하고, 어떤 행동을 사용할지는 서브클래스에 맡김
  • 팩토리 메소드 패턴 : 구상 클래스의 인스턴스 생성을 서브클래스에서 결정

템플릿 메소드 vs 전략 패턴

두 가지 모두 같은 요구사항을 구현할 수 있지만 템플릿 메소드 패턴은 알고리즘의 개요를 정의하는 역할을 한다. 진짜 작업 중 일부는 서브클래스에서 처리하며 각 단계마다 다른 구현을 사용하면서도 알고리즘 구조 자체는 그대로 유지할 수 있다. 따라서 알고리즘을 더 강하게 제어할 수 있고, 코드 중복도 거의 없다. 만약 알고리즘이 전부 똑같고 코드 한 줄씩만 다르다면 템플릿 메서드 패턴을 사용한 클래스가 전략 패턴을 사용한 클래스보다 효율적일 수 있다.

하지만 전략 패턴은 상속이 아닌 객체 구성을 사용하기 때문에 상속에서 오는 단점들이 없고 훨씬 더 유연하다는 장점이 있다. 부모 같이 어떤 것에도 의존하지 않고 알고리즘을 전부 알아서 구현할 수 있기 때문이다.


핵심 정리

  • 템플릿 메소드는 알고리즘의 단계를 정의하며 일부 단계를 서브클래스에서 구현하도록 할 수 있다.
  • 템플릿 메소드 패턴은 코드 재사용에 큰 도움이 된다.
  • 템플릿 메소드가 들어있는 추상 클래스는 구상 메소드, 추상 메소드, 후크를 정의할 수 있다.
  • 추상 메소드는 서브클래스에서 구현한다.
  • 후크는 추상 클래스에 들어있는 메소드로 아무 일도 하지 않거나 기본 행동만을 정의한다.
    • 서브 클래스에서 후크를 오버라이드 할 수 있다.
  • 할리우드 원칙에 의하면, 저수준 모듈을 언제 어떻게 호출할지는 고수준 모듈에서 결정하는 것이 좋다.
  • 템플릿 메소드 패턴은실전에서도 꽤 자주 쓰이지만 반드시 교과서적인 방식으로 적용되진 않는다.
  • 전략 패턴과 템플릿 메소드 패턴은 모두 알고리즘을 캡슐화하는 패턴이지만 전략 패턴은 상속을, 템플릿 메소드 패턴은 구성을 사용합니다.
  • 팩토리 메소드 패턴은 특화된 템플릿 메소드 패턴입니다.

객체지향 도구 상자

  • 객체지향의 기초(4요소)
    • 캡슐화
    • 상속
    • 추상화
    • 다형성
  • 객체지향 원칙
    • 바뀌는 부분을 캡슐화한다.
    • 상속보다는 구성을 활용한다.
    • 구현이 아닌 인터페이스(super type)에 맞춰서 프로그래밍한다.
    • 서로 상호작용을 하는 객체 사이에서는 가능하면 느슨하게 결합하는 디자인을 사용해야 한다.
    • 클래스는 확장에 대해서는 열려 있지만 변경에 대해서는 닫혀 있어야 한다. (OCP)
    • 추상화된 것에 의존하라. 구상 클래스에 의존하지 않도록 한다.
    • 진짜 절친에게만 이야기해야 한다.
    • 먼저 연락하지 마세요. 저희가 연락 드리겠습니다.
  • 객체지향 패턴
    • 스트레지티 패턴 : 알고리즘군을 정의하고 각각의 알고리즘을 정의하고 각각을 캡슐화하여 교환해서 사용할 수 있도록 만든다. 전략을 사용하면 알고리즘을 사용하는 클라이언트와는 독립적으로 알고리즘을 변경할 수 있다.
    • 옵저버 패턴 : 한 객체의 상태가 바뀌면 그 객체에 의존하는 다른 객체들한테 연락이 가고 자동으로 내용이 갱신되는 방식으로 일대다 의존성을 정의한다.
    • 데코레이터 패턴: 객체에 추가 요소를 동적으로 더할 수 있습니다. 데코레이터를 사용하면 서브 클래스를 만드는 경우에 비해 훨씬 유연하게 기능을 확장할 수 있습니다.
    • 추상 팩토리 패턴 : 서로 연관된, 또는 의존적인 객체들로 이루어진 제품군을 생성하기 위한 인터페이스를 제공한다. 구상 클래스는 서브 클래스에 의해 만들어진다.
    • 팩토리 메소드 패턴 : 객체를 생성하기 위한 인터페이스를 만든다. 어떤 클래스의 인스턴스를 만들지는 서브클래스에서 결정하도록 한다. 팩토리 메소드를 이용하면 인스턴스를 만드는 일을 서브클래스로 미룰 수 있다.
    • 싱글턴 패턴 : 클래스 인스턴스가 하나만 만들어지도록 하고, 그 인스턴스에 대한 전역 접근을 제공한다.
    • 커맨드 패턴 : 요청 내역을 객체로 캡슐화해서 객체를 서로 다른 요청 내역에 따라 매개변수화할 수 있다. 이러면 요청을 큐에 저장하거나 로그로 기록하거나 작업 취소 기능을 사용할 수 있다.
    • 어댑터 패턴 : 특정 클래스 인터페이스를 클라이언트에서 요구하는 다른 인터페이스로 변환한다. 인터페이스가 호환되지 않아 같이 쓸 수 없었던 클래스를 사용할 수 있게 도와준다.
    • 퍼사드 패턴 : 서브시스템에 있는 일련의 인터페이스를 통합 인터페이스로 묶어 준다. 또한 고수준 인터페이스도 정의하므로 서브시스템을 더 편리하게 사용할 수 있다.
    • 템플릿 메소드 패턴 : 알고리즘의 골격을 정의한다. 템플릿 메소드를 사용하면 알고리즘의 일부 단계를 서브클래스에서 구현할 수 있으며, 알고리즘의 구조는 그대로 유지하면서 알고리즘의 특정 단계를 서브클래스에서 재정의할 수도 있다.
728x90
728x90

어댑터 패턴 & 퍼사드 패턴

헤드 퍼스트 디자인 패턴을 읽고 정리한 글입니다.

어댑터 패턴 : 특정 클래스 인터페이스를 클라이언트에서 요구하는 다른 인터페이스로 변환한다. 인터페이스가 호환되지 않아 같이 쓸 수 없었던 클래스를 사용할 수 있게 도와준다.

퍼사드 패턴 : 서브시스템에 있는 일련의 인터페이스를 통합 인터페이스로 묶어 준다. 또한 고수준 인터페이스도 정의하므로 서브시스템을 더 편리하게 사용할 수 있다.


개요 - 어댑터

https://secretroute.tistory.com/entry/Head-First-Design-Patterns-제7강-Adapter-패턴과-Facade-패턴

우리 주변에서 볼 수 있는 어댑터의 역할은 전원 소켓의 인터페이스를 플러그에서 필요로 하는 인터페이스로 바꿔 준다고 할 수 있다. 객체지향 어댑터도 똑같이 어떤 인터페이스를 클라이언트에서 요구하는 형태로 적응시키는 역할을 한다.


객체지향 어댑터

어떤 소프트웨어 시스템에서 새로운 업체에서 제공한 클래스 라이브러리를 사용해야 하는데 그 업체에서 사용하는 인터페이스가 기존에 사용하던 인터페이스와 다르다고 가정해 보자.

https://codingsmu.tistory.com/59

그런데 기존 코드를 바꿔서 이 문제를 해결할 수 없는 상황이고, 업체에서 공급받은 클래스도 변경할 수 없다면 어떻게 해야 할까?? 바로 새로운 업체에서 사용하는 인터페이스를 기존에 사용하던 인터페이스에 적응시켜 주는 클래스를 만들면 된다.

https://secretroute.tistory.com/entry/Head-First-Design-Patterns-제7강-Adapter-패턴과-Facade-패턴

어댑터는 기존 시스템에서 사용하던 인터페이스를 구현해서 새로운 업체에서 제공한 클래스에 요구 내역을 전달할 수 있다. 어댑터는 클라이언트로부터 요청을 받아서 새로운 업체에서 제공하는 클래스를 클라이언트가 받아들일 수 있는 형태의 요청으로 변환해 주는 중개인 역할을 하는 것이다.


어댑터 사용방법

어댑터를 어떻게 사용하는지 한번 살펴보자

public interface Duck {

    void quack();
    void fly();

}

public class MallardDuck implements Duck {

    @Override
    public void quack() {
        System.out.println("quack");
    }

    @Override
    public void fly() {
        System.out.println("fly");
    }
}
public interface Turkey {

    void gobble();
    void fly();

}

public class WildTurkey implements Turkey {

    @Override
    public void gobble() {
        System.out.println("gobble");
    }

    @Override
    public void fly() {
        System.out.println("short fly");
    }
}

Duck 객체가 모자라서 Turkey 객체를 대신 사용해야 하는 상황이라고 가정해 보자. 물론 인터페이스가 다르기에 Turkey 객체를 바로 사용할 수는 없다. 이 때 필요한 것이 어댑터이다.

// 우선 적응시킬 형식의 인터페이스를 구현해야 한다. 즉 클라이언트에서 원하는 인터페이스를 구현해야 한다.
public class TurkeyAdapter implements Duck { 


    private final Turkey turkey;

    public TurkeyAdapter(Turkey turkey) { // 그리고 기존 형식 객체의 레퍼런스가 필요한다.
        this.turkey = turkey;
    }

    @Override
    public void quack() {
        turkey.gobble();
    }

    /*
     두 인터페이스에 모두 fly가 있지만 turkey의 fly() 메소드를 Duck의 fly() 메소드에 대응시키도록 작성 
     */
    @Override
    public void fly() {
        for (int i = 0; i < 5; i++) {
            turkey.fly();
        }
    }
}
public class DuckTestDrive {

    public static void main(String[] args) {
        Duck duck = new MallardDuck();
        Turkey turkey  = new WildTurkey();
        Duck turkeyAdapter = new TurkeyAdapter(turkey);

        System.out.println(" turkey said that");
        turkey.gobble();
        turkey.fly();

        System.out.println("\n duck said that");
        testDuck(duck);

        System.out.println("\n turkeyAdapter said that");
        testDuck(turkeyAdapter);
    }

    private static void testDuck(Duck duck) {
        duck.quack();
        duck.fly();
    }

}

=======================================================
turkey said that
gobble
short fly

duck said that
quack
fly

turkeyAdapter said that
gobble
short fly
short fly
short fly
short fly
short fly
  • 클라이언트의 testDuck() 메소드는 오리와 칠면조를 전혀 구분하지 못한다.

어댑터 패턴

https://codingsmu.tistory.com/59

이제 어댑터가 어떤 식으로 작동하는지 살펴보자

  1. 클라이언트는 타깃 인터페이스에 맞게 구현되어 있으며, 타깃 인터페이스로 메소드를 호출해서 어댑터에 요청을 보낸다.
  2. 어댑터는 타깃 인터페이스를 구현하며, 어댑티 인스턴스를 가지고 있다. 어댑터는 어댑티 인터페이스로 그 요청을 어댑티에 관한(하나 이상의) 메소드 호출로 변환한다.
  3. 클라이언트는 호출 결과를 받긴 하지만 중간에 어댑터가 있다는 사실을 모르므로, 클라이언트와 어댑티는 서로 분리되어 있다.

Q. 어댑터가 얼마나 적응시켜 줘야 할까? 대형 타깃 인터페이스를 구현해야 한다면 할 일이 정말 많아지지 않을까?

어댑터 구현은 타깃 인터페이스로 지원해야 하는 인터페이스의 크기에 비례해서 복잡해진다. 하지만 다른 대안이 없다. 클라이언트에서 호출하는 부분을 새로운 인터페이스에 맞춰서 고치려면 정말 많은 부분을 고려해야 하고, 코드도 많이 고쳐야 한다. 이런 방법보다는 모든 변경 사항을 캡슐화할 어댑터 클래스 하나만 제공하는 방법이 더 나을 것이다.

Q. 하나의 어댑터는 하나의 클래스만 감싸야 할까?

어댑터 패턴은 하나의 인터페이스를 다른 인터페이스로 변환하는 용도로 쓰인다. 하나의 어댑터에서 타깃 인터페이스를 구현하려고 2개 이상의 어댑티를 감싸야 하는 상황도 생길 수 있다. 사실 이런 내용은 퍼사드 패턴과 관련이 있으므로 퍼사드 패턴 때 다시 보자.

Q. 시스템에 오래된 부분과 새로 만든 부분이 섞여 있으면 어떻게 해야할까?? 어떤 곳에는 어댑터를 사용하고 다른 곳에서 어댑터로 감싸지 않은 인터페이스를 사용하면 헷갈리지 않을까?

이런 상황에서는 두 인터페이스를 모두 지원하는 다중 어댑터(Two Way Adapter)를 만들면 된다. 다중 어댑터로 필요한 인터페이스를 둘 다 구현해서 어댑터가 기존 인터페이스와 새로운 인터페이스 역할을 할 수 있게 하면 된다.


어댑터 패턴의 정의

이제 어댑터 패턴의 정의를 알아보자.

이 패턴을 사용하면 호환되지 않는 인터페이스를 사용하는 클라이언트를 그대로 활용할 수 있다. 인터페이스를 변환해 주는 어댑터를 만들면 되기 때문이다. 이러면 클라이언트와 구현된 인터페이스를 분리할 수 있으며, 변경 내역이 어댑터에 캡슐화되기에 나중에 인터페이스가 바뀌더라도 클라이언트를 바꿀 필요가 없다.

클래스 다이어그램

https://secretroute.tistory.com/entry/Head-First-Design-Patterns-제7강-Adapter-패턴과-Facade-패턴

클라이언트

  • 클라이언트는 타킷 인터페이스만 볼 수 있다.

어댑터

  • 어댑터에서 타깃 인터페이스를 구현한다.
  • 어댑터는 어댑티로 구성되어 있다.

어댑티

  • 모든 요청은 어댑티에 위임된다.

어댑터 패턴은 여러 객체지향 원칙을 반영하고 있다. 어댑티를 새로 바뀐 인터페이스로 감쌀 때는 Composition을 사용한다. 이런 접근번은 어댑티의 모든 서브클래스에 어댑터를 쓸 수 있다는 장점이 있다.

그리고 어댑터 패턴은 클라이언트를 특정 구현이 아닌 인터페이스에 연결한다. 서로 다른 백엔드 클래스로 변환시키는 여러 어댑터를 사용할 수도 있다. 이렇게 인터페이스를 기준으로 코딩했기에 타깃 인터페이스만 제대로 유지한다면 나중에 다른 구현을 추가하는 것도 가능하다.


객체 어댑터와 클래스 어댑터

사실 어댑터에는 두 종류가 있다. 하나는 객체 어댑터, 다른 하나는 클래스 어댑터이다.

지금까지 본 예제와 다이어그램 모두 객체 어댑터에 해당하는 내용들이다. 그렇다면 클래스 어댑터란 무엇이고 왜 살펴보지 않았을까? 클래스 어댑터 패턴을 쓰려면 상속이 필요한데 자바에서는 다중 상속이 불가능하므로 자바에서는 불가능하다. 하지만 다중 상속이 가능한 언러를 사용하다 보면 클래스 어댑터를 써야 할 때도 있으니 클래스 다이어그램을 살펴보자

https://secretroute.tistory.com/entry/Head-First-Design-Patterns-제7강-Adapter-패턴과-Facade-패턴

어댑티를 적응시킬때 구성을 사용하는 대신, 어댑터를 어댑티와 타깃 클래스의 서브클래스로 만든다. 상속을 사용하는 클래스 어댑터에 비해 객체 어댑터는 composition을 사용하므로 상속을 통한 코드 분량을 줄이지는 못하지만, 어댑티한테 필요한 일을 시키는 코드만 작성하면 되기 때문에 작성해야할 코드가 적고 유연성을 확보할 수 있다.


어댑터 패턴 실전 적용

https://swk3169.tistory.com/255

Enumeration

Enumeration을 리턴하는 elements() 메소드가 구현되어 있었던, 초기 컬렉션 형식(Vector, Stack, Hashtable 등)은 Enumeration 인터페이스를 이용하면 컬렉션 내에서 각 항목이 관리되는 방식에는 신경 쓸 필요 없이 컬렉션의 모든 항목에 접근이 가능하다.

Iterator

최근에는 Enumeration과 마찬가지로 컬렉션에 있는 일련의 항목들에 접근할 수 있게 해 주면서 항목을 제거할 수 도 있게 해 주는 iterator라는 인터페이스를 이용하기 시작했다.

Enumeration vs Iterator

Enumeration 인터페이스를 사용하는 구형 코드를 다뤄야 할 때도 가끔 있지만 새로운 코드를 만들 때는 Iterator만 사용하는 것이 좋다. 이때 어댑터 패턴을 적용해보자.

https://codingsmu.tistory.com/59

  • Itrerator : 타깃 인터페이스
  • Enumeration : 어댑티 인터페이스
  • 그런데 Iterator의 remove() 메소드는 Enumeration에는 이런 기능을 제공하는 메소드가 없다.
    • 어떻게 해야할까??

어댑터 디자인하기

클래스 다이어그램은 다음과 같다. 먼저 타깃 인터페이스를 구현하고, 어댑티 객체로 구성된 어댑터를 구현해야 한다. hasNext()와 next() 메소드는 타깃에서 업대티로 바로 연결된다.

https://codingsmu.tistory.com/59

remove()는 어떻게 처리할까?

  • 어댑터 차원에서 완벽하게 작동하는 remove() 메소드의 구현 방법은 없다. 따라서 그나마 좋은 방법은 런타임 예외를 던지는 것이다.
  • Iterator 인터페이스는 remove()는 default method로 UnsupportedOperationException을 던지고 있다.

이처럼 메소드가 일대일로 대응되지 않는 상황에서는 어댑터를 완벽하게 적용할 수 없다. 클라이언트는 예외 발생 가능성을 염두에 두고 있어야 하기 때문이다. 하지만 클라이언트에서 주의를 기울이고, 어댑터 문서를 잘 만들어 두면 괜찮을 것이다.

EnumerationIteraotr

public class EnumerationIterator implements Iterator<Object> {

    private final Enumeration<?> enumeration;

    public EnumerationIterator(Enumeration<?> enumeration) {
        this.enumeration = enumeration;
    }

    @Override
    public boolean hasNext() {
        return enumeration.hasMoreElements();
    }

    @Override
    public Object next() {
        return enumeration.nextElement();
    }

    @Override
    public void remove() {
        throw new UnsupportedOperationException();
    }
}

퍼사드 패턴

지금까지 어댑터 패턴을 써서 어떤 클래스의 인터페이스를 클라이언트가 원하는 인터페이스로 변환하는 방법을 어댑터 패턴을 이용하여 구현했다.

이제 조금 다른 이유로 인터페이스를 변경하는 또 다른 패턴을 알아보자. 바로 퍼사드 패턴이다.

퍼사드 패턴은 인터페이스를 단순하게 바꾸려고 인터페이스를 변경하다. 하나 이상의 클래스 인터페이스를 깔끔하면서도 효과적인 퍼사드(겉모양, 외관)으로 덮어주기 때문이다.

데코레이터 vs 어댑터 vs 퍼사드

해당 패턴 모두 객체를 감싸고 있는 공통점을 가지고 있다. 하지만 모두 사용하는 용도가 다르다.

  • 데코레이터는 인터페이스는 바꾸지 않고 감싸고 있는 객체의 행동과 책임을 확장하는 용도로 사용한다.
  • 어댑터는 하나의 인터페이스를 다른 인터페이스로 변환하는 용도로 사용한다.
  • 퍼사드는 인터페이스를 간단하게 변경하는 용도로 사용한다.

개요 - 홈시어터 만들기

패턴을 알아보기 전에 영화나 tv 시리즈 몰아보기가 유행에 따라 각광받고 있는 홈시어터를 구축해보자. 스트리밍 플레이어, 프로젝터, 자동 스크린, 서라운드 음향, 팝콘 기계 등 클래스들이 서로 복잡하게 얽혀 있다.

https://invincibletyphoon.tistory.com/22

영화를 보기위한 일련의 작업

이제 영화를 보려고 하지만 영화를 보려면 몇 가지 일을 더해야 한다.

  1. 팝콘 기계를 켠다
  2. 팝콘을 튀긴다.
  3. 조명을 어둡게 조절한다.
  4. 스크린을 내린다.
  5. 프로젝터를 켠다.
  6. 프로젝터 입력을 스트르밍 플레이어로 설정한다.
  7. 프로젝터를 와이드 스크린 모드로 전환한다.
  8. 앰프를 켠다.
  9. 앰프 입력을 스트리밍 플레이어로 설정한다.
  10. 앰프를 서라운드 음향 모드로 전환한다.
  11. 앰프 볼륭을 중간인 5로 설정한다.
  12. 스트리밍 플레이어를 켠다.
  13. 영화를 재생한다.

이제 이 작업들을 처리하기 위한 어떤 클래스와 메소드가 필요한지 살펴보자.

popper.on();
popper.pop();

lights.dim(10);
screen.down();
projector.on();
projector.setInput(player);
projector.wideScreenMode();

amp.on();
amp.setDvd(player);
amp.setSurroundSound();
amp.setVolume(5);

player.on();
player.play(movie);

클래스가 6개나 필요하고, 만약 영화가 끝나면 어떻게 해야할까?, 방금 했던 일을 전부 역순으로 처리해야 하지 않을까? 다른 라디오나 시스템이 업그레이드하면 이런 복잡한 작동 방법을 또 배워야 하지 않을까?

  • 이렇게 복잡한 일을 퍼사드 패턴으로 간단하게 처리할 수 있는지 알아보자

퍼사드 작동 원리

쓰기 쉬운 인터페이스를 제공하는 퍼사드 클래스를 구현함으로써 복잡한 시스템을 훨씬 편리하게 사용할 수 있다. 물론 기존의 시스템을 직접 건드리고 싶다면 기존 인터페이스를그대로 사용하면 된다.

  1. 홈시어터 시스템용 퍼사드를 만들어보자.
    • watchMovie()와 같이 몇 가지 간단한 메소드만 들어있는 HomeTheaterFacade 클래스를 새로 만들어야 한다.
  2. 퍼사드 클래스는 홈시어터 구성 요소를 하나의 서브시스템으로 간주한다.
    • watchMovie() 메서드는 서브시스템의 메소드를 호출해서 필요한 작업을 처리한다.
  3. 이제 클라이언트 코드는 서비스시템이 아닌 홈시어터 퍼사드에 있는 메서드를 호출한다.
    • watchMovie() 메서드만 호출하면 조명, 스트리밍 플레이어, 앰프 등 알아서 준비된다.
  4. 퍼사드를 쓰더라도 서브시스템에 여전히 직접 접근할 수 있다.
    • 서브시스템 클래스의 고급 기능이 필요하면 언제든지 사용 가능하다.

Q. 퍼사드로 서브시스템 클래스를 캡슐화하면 저수준 기능을 원하는 클라이언트는 어떻게 서브시스템 클래스에 접근할 수 있을까?

퍼사드 클래스는 서브시스템 클래스를 캡슐화하지 않는다. 서브시스템의 기능을 사용할 수 있는 간단한 인터페이스를 제공할 뿐이다. 클라이언트에서 특정 인터페이스가 필요하다면 서브시스템 클래스를 그냥 사용하면 된다. 이점이 퍼사드 클래스의 대표적인 장점이다. 단순화된 인터페이스를 제공하면서도, 클라이언트에서 필요로 한다면 시스템의 모든 기능을 사용할 수 있도록 해줍니다.

Q. 퍼사드에서 기능을 추가하거나 각각의 요청을 서브시스템에 그대로 전달하기도 할까?

퍼사드는 단순화된 서브시스템의 기능을 활용하게 해주는 일 외에도 ‘스마트’한 기능을 알아서 추가한다. 예를 들어, 홈시어터 퍼사드는 새로운 행동을 구현하지는 않지만, 팝콘을 튀기기 전에 팝콘 기계를 켜야 한다는 사실을 알고 있습니다. 그래서 팝콘 기계를 알아서 킨다. 그리고 각 구성 요소를 켜고 적절한 모드를 선택하는 것도 알아서 잘할 정도로 ‘스마트’하다.

Q. 하나의 서브시스템에는 하나의 퍼사도만 만들수 있을까?

그렇지 않다. 특정 서브시스템에 대해 만들 수 있는 퍼사드의 개수에는 제한이 없다.

Q. 더 간단한 인터페이스를 만들 수 있다는 점 말고 퍼사드의 또 다른 장점은 없을까?

퍼사드를 사용하면 클라이언트 구현과 서브시스템을 분리할 수 있다. 예를 들어 홈시어터 시스템을 업그레이드 하기로 가정해보자. 이런 경우 인터페이스가 크게 달라질 수 있을 것이다. 만약 클라이언트를 퍼사드로 만들었다면 클라이언트 코드는 고칠 필요 없이 퍼사드만 바꾸면 된다.

Q. 어댑터는 한 클래스만 감싸고 퍼사드는 여러 클래스를 감쌀 수 있는 것일까?

그렇지 않다. 어댑터 패턴은 하나 이상의 클래스 인터페이스를 클라이언트에서 필요로 하는 인터페이스로 변환한다. 클라이언트가 여러 클래스를 사용할 수도 있기 대문이다. 반대로 퍼사드도 꼭 여러 클래스를 감싸야만 하는 건 아니다. 아주 복잡한 인터페이스를 가지고 있는 단 하나의 클래스에 대한 퍼사드를 만들 수도 있다.

어댑터와 퍼사드의 차이점은 감싸는 클래스의 개수에 있는 것이 아니라 용도에있다. 어댑터 패턴은 인터페이스를 변경해서 클라이언트에서 필요로 하는 인터페이스로 적응시키는 용도로 쓰인다. 반면 퍼사드 패턴은 어떤 서브시스템에 대한 간단한 인터페이스를 제공하는 용도로 쓰인다.


홈시어터 퍼사드

public class HomeTheaterFacade {

    // composition 부분, 사용하고자 하는 서브시스템의 모든 구성 요소가 인스턴스 변수 형태로 저장된다.
    private final Amplifier amp;
    private final Tuner tuner;
    private final StreamingPlayer player;
    private final Projector projector;
    private final TheaterLights lights;
    private final Screen screen;
    private final PopcornPopper popper;

    public HomeTheaterFacade(Amplifier amp, Tuner tuner, StreamingPlayer player,
        Projector projector,
        TheaterLights lights, Screen screen, PopcornPopper popper) {
        this.amp = amp;
        this.tuner = tuner;
        this.player = player;
        this.projector = projector;
        this.lights = lights;
        this.screen = screen;
        this.popper = popper;
    }

    public void watchMovie(String movie) {
        System.out.println("영화 볼 준비 중");
        popper.on();
        popper.pop();

        lights.dim(10);
        screen.down();
        projector.on();
        projector.wideScreenMode();
        projector.setInput(player);

        amp.on();
        amp.setDvd(player);
        amp.setSurroundSound();
        amp.setVolume(5);

        player.on();
        player.play(movie);
    }

    public void endMovie() {
        System.out.println("홈시어터 끄는 중");
        popper.off();
        lights.on();
        screen.up();
        projector.off();
        amp.off();
        player.stop();
        player.off();
    }
}
  • 각 서브시스템에 들어있는 구성요소에게 위임하며 단순화된 인터페이스를 제공한다.
public class HomeTheaterTestDrive {

    public static void main(String[] args) {
        // 구성 요소 초기화
        // 지금은 구성 요소를 직접 생성하지만 보통은 클라이언트에 퍼사드가 주어지므로 직접 구성 요소를 생성하지 않아도 된다.

        Amplifier amp = new Amplifier();
        Tuner tuner = new Tuner();
        StreamingPlayer player = new StreamingPlayer();
        Projector projector = new Projector();
        TheaterLights lights = new TheaterLights();
        Screen screen = new Screen();
        PopcornPopper popper = new PopcornPopper();
        HomeTheaterFacade homeTheater = new HomeTheaterFacade(
            amp,
            tuner,
            player,
            projector,
            lights,
            screen,
            popper
        );
        // 단순화된 인터페이스를 사용
        homeTheater.watchMovie("king kong");
        homeTheater.endMovie();
    }

}

퍼사드 패턴의 정의

퍼사드 패턴을 사용하려면 어떤 서브시스템에 속한 일련의 복잡한 클래스를 단순하게 바꿔서 통합한 클래스를 만들어야 한다. 다른 패턴과 달리 퍼사드 패턴은 상당히 단순한 편이다. 복잡한 추상화 같은 게 필요 없다. 하지만 퍼사드 패턴을 사용하면 클라이언트와 서브시스템이 서로 긴밀하게 연결되지 않아도 되고, 최소 지식 객체지향 원칙을 준수하는데도 도움이 된다.

퍼사드 패턴의 정의는 다음과 같다.

여기서 가장 중요한 점은 패턴의 용도이다. 정의를 보면 퍼사드 패턴은 단순화된 인터페이스로 서브시스템을 더 편리하게 사용하려고 쓰인다는 사실을 알 수 있다. 퍼사드 패턴의 클래스 다이어그램에서도 이 사실을 확인할 수 있다.

클래스 다이어그램

https://secretroute.tistory.com/entry/Head-First-Design-Patterns-제7강-Adapter-패턴과-Facade-패턴


최소 지식 원칙

최소 지식 원칙(Principle of Least Knowledge)에 따르면 객체 사이의 상호자용은 될 수 있으면 아주 가까운 ‘친구’ 사이에서만 허용하는 편이 좋다. 이 원칙은 보통 다음과 같이 정의될 수 있다.

그런데 이게 정확히 무슨 소리일까? 시스템을 디자인할 때 어떤 객체든 그 객체와 상호작용을 하는 클래스의 개수와 상호작용 방식에 주의를 기울여야 한다는 뜻이다.

이 원칙을 잘 따르면 여러 클래스가 복잡하게 얽혀 있어서, 시스템의 한 부분을 변경했을 때 다른 부분까지 줄줄이 고쳐야 하는 상황을 미리 방지할 수 있다. 여러 클래스가 서로 복잡하게 의존하고 있다면 관리하기도 힘들고, 남들이 이해하기 어려운 불안정한 시스템이 만들어진다.

친구를 만들지 않고 다른 객체에 영향력 행사하기

그런데 어떻게 하면 여러 객체와 친구가 되는 것을 피할 수 있을까?

이 원칙은 친구를 만들지 않는 4개의 가이드라인을 제시한다.

  1. 객체 자체
  2. 메소드에 매개변수로 전ㄴ달된 객체
  3. 메소드를 생성하거나 인스턴스를 만든 객체
  4. 객체에 속하는 구성 요소

해당 가이드라인에 따르면 다른 메소드를 호출해서 리턴받은 객체의 메소드를 호출하는 일도 바람직 하지 않다. 따라서 꽤 까다로운 가이드라인이다. 메소드를 호출한 결과로 리턴받은 객체에 들어있는 메소드를 호출하면 다른 객체의 일부분에 요청하게 되고, 직접적으로 알고 지내는 객체의 수가 늘어나는 단점이 있다.

이러한 상황에서 최소 지식 원칙을 따르려면 객체가 대신 요청하도록 만들어야 한다. 그러면 그 객체의 한 구성 요소를 알고 지낼 필요가 없어지고 친구의 수를 줄이는 데도 도움이 된다.

Before

public float getTemp() {
        Thermometer thermometer = station.getThermometer();
        return thermometer.getTemperature();
}
  • station으로 부터 thermometer 객체를 받은 다음, 그 객체의 getTemperature() 메소드를 직접 호출

After

public float getTemp() {
        return station.getTemperature();
}
  • 최소 지식 원칙을 적용해서 thermometer 에게 요청을 전달하는 메소드를 station 클래스에 추가
  • 의존해야 하는 클래스의 개수를 줄인다.

절친에게만 메소드 호출하기

다음은 자동차를 나타내는 Car 클래스이다. 이 클래스르 살펴보면 최소 지식 원칙을 따르면서 메소드를 호출하는 방법을 어느 정도 파악할 수 있다.

public class Car {

    Engine engine; // 해당 클래스의 구성요소, 구성요소의 메소드는 호출해도 된다.

    public Car(Engine engine) {
        this.engine = engine;
    }

    public void start(Key key) {            // 매개변수로 전달된 객체의 메소드는 호출 가능하다.
        Doors doors = new Doors();          // 새로운 객체를 생성, 해당 객체의 메소드 호출 가능
        boolean authorized = key.turns();   // 매개변수로 전달된 객체
        if (authorized) {
            engine.start();                 // 이 객체의 구성 요소를 대상으로 메소드 호출 가능
            updateDashboardDisplay();       // 객체 내에 있는 메소드 호출 가능
            doors.lock();                   // 직접 생성하거나 인스턴스를 만든 객체의 메소드 호출 가능
        }
    }

    private void updateDashboardDisplay() {
        // update display
    }
}

Q. 데메테르의 법칙이라는 것도 있던데, 최소 지식 원칙과 어떤 관계일까?

데메테르의 법칙과 최소 지식 원칙은 완전히 똑같은 말이다. 하지만 좀 더 직관적이고 법칙이라는 단어가 없는 최소 지식 원칙을 선호한다. 모든 원칙은 상황에 따라서 적절하게 따라야 한다.

Q. 최소 지식 원칙도 단점이 있을까?

물론 존재한다. 이 원칙을 잘 따르면 객체 사이의 의존성을 줄일 수 있으며 소프트웨어 관리가 더 편해지지만, 메소드 호출을 처리하는 ‘래퍼’ 클래스를 더 만들어야 할 수도 있다. 그러면 시스템이 복잡해지고, 개발 시간도 늘어나고, 성능도 떨어진다.


핵심 정리

  • 기존 클래스를 사용하려고 하는데 인터페이스가 맞지 않으면 어댑터를 쓰면 된다.
  • 큰 인터페이스와 여러 인터페이스를 단순하게 바꾸거나 통합해야 하면 퍼사드를 쓰면 된다.
  • 어댑터는 인터페이스를 클라이언트에서 원하는 인터페이스로 바꾸는 역할을 한다.
  • 퍼사드는 클라이언트를 복잡한 서브시스템과 분리하는 역할을 한다.
  • 어댑터를 구현할 때는 타깃 인터페이스의 크기와 구조에 따라 코딩해야 할 분량이 결정된다.
  • 퍼사드 패턴에서드는 서브시스템으로 퍼사드를 만들고 진짜 작업은 서브클래스에 맡긴다.
  • 어댑터 패턴에는 객체 어댑터 패턴과 클래스 어댑터 패턴이 있다.
    • 클래스 어댑터를 사용하려면 다중 상속이 가능해야 한다.
  • 한 서브시스템에 퍼사드를 여러 개 만들어도 된다.
  • 어댑터는 객체를 감싸서 인터페이스를 바꾸는 용도로, 데코레이터는 객체를 감싸서 새로운 행동을 추가하는 용도로, 퍼사드는 일련의 객체를 감싸서 단순하게 만드는 용도로 쓰인다.

객체지향 도구 상자

  • 객체지향의 기초(4요소)
    • 캡슐화
    • 상속
    • 추상화
    • 다형성
  • 객체지향 원칙
    • 바뀌는 부분을 캡슐화한다.
    • 상속보다는 구성을 활용한다.
    • 구현이 아닌 인터페이스(super type)에 맞춰서 프로그래밍한다.
    • 서로 상호작용을 하는 객체 사이에서는 가능하면 느슨하게 결합하는 디자인을 사용해야 한다.
    • 클래스는 확장에 대해서는 열려 있지만 변경에 대해서는 닫혀 있어야 한다. (OCP)
    • 추상화된 것에 의존하라. 구상 클래스에 의존하지 않도록 한다.
    • 진짜 절친에게만 이야기해야 한다.
  • 객체지향 패턴
    • 스트레지티 패턴 : 알고리즘군을 정의하고 각각의 알고리즘을 정의하고 각각을 캡슐화하여 교환해서 사용할 수 있도록 만든다. 전략을 사용하면 알고리즘을 사용하는 클라이언트와는 독립적으로 알고리즘을 변경할 수 있다.
    • 옵저버 패턴 : 한 객체의 상태가 바뀌면 그 객체에 의존하는 다른 객체들한테 연락이 가고 자동으로 내용이 갱신되는 방식으로 일대다 의존성을 정의한다.
    • 데코레이터 패턴: 객체에 추가 요소를 동적으로 더할 수 있습니다. 데코레이터를 사용하면 서브 클래스를 만드는 경우에 비해 훨씬 유연하게 기능을 확장할 수 있습니다.
    • 추상 팩토리 패턴 : 서로 연관된, 또는 의존적인 객체들로 이루어진 제품군을 생성하기 위한 인터페이스를 제공한다. 구상 클래스는 서브 클래스에 의해 만들어진다.
    • 팩토리 메소드 패턴 : 객체를 생성하기 위한 인터페이스를 만든다. 어떤 클래스의 인스턴스를 만들지는 서브클래스에서 결정하도록 한다. 팩토리 메소드를 이용하면 인스턴스를 만드는 일을 서브클래스로 미룰 수 있다.
    • 싱글턴 패턴 : 클래스 인스턴스가 하나만 만들어지도록 하고, 그 인스턴스에 대한 전역 접근을 제공한다.
    • 커맨드 패턴 : 요청 내역을 객체로 캡슐화해서 객체를 서로 다른 요청 내역에 따라 매개변수화할 수 있다. 이러면 요청을 큐에 저장하거나 로그로 기록하거나 작업 취소 기능을 사용할 수 있다.
    • 어댑터 패턴 : 특정 클래스 인터페이스를 클라이언트에서 요구하는 다른 인터페이스로 변환한다. 인터페이스가 호환되지 않아 같이 쓸 수 없었던 클래스를 사용할 수 있게 도와준다.
    • 퍼사드 패턴 : 서브시스템에 있는 일련의 인터페이스를 통합 인터페이스로 묶어 준다. 또한 고수준 인터페이스도 정의하므로 서브시스템을 더 편리하게 사용할 수 있다.
728x90

+ Recent posts