(아직 Line Coverage가 16% 정도 밖에 되지 않아 갈 길이 머네요... 😵💫)
서비스는 그레이들 멀티 모듈을 활용한 여러 서비스를 모듈화해두었습니다.
gradle-multi-module
ㄴㅡ a-service
ㄴㅡ b-service
ㄴㅡ c-service
ㄴㅡ d-service
ㄴㅡ ...
시작은 테스트 3~400개가 되었을 무렵부터 였습니다. 평소와 다름 없이 기능 개발을 위해 별도의 feature 브랜치를 만들고 비즈니스 로직과 테스트 코드를 추가했는데 개발 장비에서 잘 돌던 테스트가 CI가 실패하기 시작했습니다. 로그를 보니 OOM이 발생하는 것이였는데요. 여기서 테스트 케이스가 많아져서 그런가 보다... 라는 짧은 생각으로 다음과 같은 처리를 했습니다. 😅
test {
maxHeapSize = "1G"
}
그리고 다시 700개가 넘어갈 무렵 문제는 다시 시작되었습니다. 이 글을 작성하는 시점에는 이 문제에 대한 원인을 알고 있기 때문에 다시 시작되었다기보다는 고이 덮어둔 문제(?)가 커질때로 커진 것이죠. CI 로그를 확인해보았고, 이는 역시나 OOM이 발생해 있었습니다.
여기서 여러가지 생각이 들었습니다.
테스트가 수천개, 수만개를 가진 오픈소스들을 보면 1000개도 안된 테린이(?) 수준이였기 때문에 사실상 결코 많은 수준이 아니였습니다.
물론, 주로 통합 테스트 @SpringBootTest
를 활용한 통합테스트를 작성하고 있습니다. 하지만, 주기적으로 데이터베이스 초기화 등으로 상태를 가지는 객체들을 일괄적으로 비워주고 있기 때문에 문제가 된다고 생각되진 않았습니다.
다행스럽게도 로컬 환경에서도 손쉽게 재현이 가능했기 때문에 특정 모듈에 대한 전체 테스트 실행하여 힙 덤프를 떠보았습니다.
(tool: https://www.eclipse.org/mat/)
DefaultListableBeanFactory
수십개...스크린샷에 전부 표현되지 않았지만, 상당히 많은 빈 팩토리가 여러개 생긴 것을 볼 수 있습니다. 대략 50MB
사이즈의 빈 팩토리가 20개만 생겨도 1000MB
이기 때문에 상당히 비정상적인 경우라고 볼 수 있을 것 같습니다.
DefaultListableBeanFactory
는 왜 많아지는가?공식문서를 확인해본 결과, 다음과 같은 목차가 있는 것을 알 수 있습니다. (목차만 봐도 느낌이 쌔합니다...)
대략 요약을 해보자면, 테스트에서 ApplicationContext
를 재사용하기 위해 테스트에 적용된 설정들을 참고하여 이를 결정합니다. 여기서 문제가 되었던 부분은 @MockBean
이였습니다. 지금 생각해보면, 스프링 빈을 자연스럽게 mock으로 만들어주는데 이게 어떻게 동작하는걸까를 깊게 생각해보지 않는 것이 결정적인 문제였던 것 같습니다. 🤣
@MockBean
를 사용하는가?우선 @MockBean
은 mockito에서 제공해주는 mock을 스프링부트에서 테스트시에 스프링 빈을 만들때 mock으로 만들어주는 애노테이션입니다. (보다 자세한 내용은 MockitoPostProcessor
, MockitoTestExecutionListener
를 찾아보시면 더욱 도움이 될 것 같습니다.)
저도 그렇지만, 프로젝트 대부분의 테스트 코드의 @MockBean
을 사용하는 부분이 특정되어 있습니다. 이 부분은 서비스 구조와 연관이 있는데요. 모듈로 분리된 여러 서비스는 다양하게 서로의 정보를 주고 받는데, 이 중 서로 http 요청을 하는 부분이 있습니다.
Classicist 측면에서 접근해보자면 테스트에 활용할 각각의 테스트 서버들을 준비해두고 이를 활용하면 됩니다. 하지만, 아직 테스트 숙련도가 높지도 않고, 비용/시간/우선순위면에서도 그렇게 해야할 만큼 고도화가 필요한 상황이 아니였기에 적절한 해결책은 아니였습니다.
그렇디면 이를 어떻게 해야할까요? API 요청에 대한 클래스들을 미리 mock으로 만들 순 없을까요?
BeanPostProcessor
스프링에는 BeanPostProcessor
라는 것이 있습니다. 이에 대한 자세한 내용은 Spring - @Autowired는 어떻게 동작하는 걸까?을 참고하시면 됩니다.
정말 다행스럽게도 우리는 공통적으로 open-feign
을 활용하여 API 요청을 하고 있습니다. 그렇기에 별도의 BeanPostProcessor
를 만들고 만들어진 open-feign
을 선별하여 mock으로 바꿔주면 테스트마다 별도의 mock을 만들지 않고 다음과 같이 사용할 수 있습니다.
//@MockBean
@Autowired // 👈 👈
private AServiceFeign feign;
물론, 팀에 이 사실을 전파하지 않는다면 어떻게 이게 되지? 라고 할 수 있겠네요...
결과는 성공적이였습니다.
maxHeapSize
을 더 늘려보고 테스트해보면 정확한 heap 차이를 알 수 있겠지만, 1G
이라고 볼 때 약 5배이상 메모리 절감을 보여줬고, ApplicationContext
또한 새로 만들지 않았기 때문에 속도 측면에서도 약 2배 가량 빨라진 것을 볼 수 있었습니다.
운영하는 서비스에 종속적인 기능들이 아니라면 한번 찾아보면 이미 만들어져 있을 법한 것들이 있기 때문에 한번 서칭해보면 좋을 듯 한데요. 그 예로, 로깅 중앙화, 제가 사용해본 서버리스 이미지 핸들러 등등이 존재합니다.
https://github.com/aws-solutions/
< 출처: https://github.com/aws-solutions/serverless-image-handler >
해당 기능은 말그대로 서버리스 환경에서 이미지를 요청시에 즉시 핸들링해주는 솔루션입니다. 제가 맡고 있는 서비스는 원본 이미지를 다양한 크기로 이미지를 노출시킬 수 있어야 했는데요. 미리 만드는 방법도 있지만 이는 정적이고, 마이그레이션이 다소 불편하며, 관리하는 측면에서 만족스럽지 못 했습니다.
이것 이외에도 CloudFront + Lambda@Edge를 사용하는 방법도 있으니 참고하시기 바랍니다.
sharp
를 사용하고 있습니다.signature
라는 파라미터를 받아서 이를 변조하지 못하도록 제공하고 있습니다. (해당 기능은 secret-manager를 통해 별도의 키를 만들어서 사용합니다.)const imageRequest = JSON.stringify({
bucket: "<myImageBucket>",
key: "<myObjectKey>",
edits: {
resize: {
width: ...,
height: ...
}
}
});
const url = `${CloudFrontUrl}/${btoa(imageRequest)}`;
<img src=`${url}` />
위 방식은 url을 json문자열을 base64으로 인코딩해서 전달하는 방식으로 json 안에는 bucket, object key, sharp 정보(?) 등을 포함되어 있습니다. sharp 관련된 내용은 관련 문서를 참고해서 적용하면 손쉽게 적용할 수 있습니다.
const url = `${CloudFrontUrl}/filter:format(webp)/${object_key}`;
<img src=`${url}` />
위 방식은 기존 url과 비슷하게 유지하면서 사용할 수 있는 방법 중에 하나로, path 중간에 filters:~~~
를 넣어 옵션을 적용할 수 있습니다.
CloudFormation으로 제공되기 때문에 손쉽게 구축할 수 있습니다.
CloudFront를 사용하기 때문에 한번 요청되면 캐시되어 비용 절감이 가능하다.
On the fly 방식이기 때문에 관리가 용이합니다.
람다 자체가 요청/응답에 대한 페이로드가 6MB 제한이 있기 때문에 큰 이미지를 전달할 수 없습니다.
다소 과할 수 있다.
6.0.0 버전까진 gif 대응이 안된다.
sharp
가 0.30.0
버전부터 gif를 지원하기 시작했지만, 0.27.0
버전을 사용하고 있기 때문에 대응이 안된다.결국, 1,3번 단점 때문에 나의 서비스는 api gateway에 별도 path를 추가 및 직접 S3 서비스를 연결해서 사용하고 있습니다.
]]>사실 큰 이유는 없습니다. (잉...?😓) 단지, 테마를 변경하고 싶었으나, 사용하던 기존 테마에 의존성이 강하다 보니 이를 다른 테마로 변경하는 비용과 다른 SSG로 변경하는 비용이 비슷하다고 생각했고 그렇게 다른 SSG 도구(?)들을 찾아보게 되었습니다.
대표적으로 https://jamstack.org/generators/ 를 참고하시면 여러 도구들을 찾아볼 수 있는데요. 이왕, 바꾸는김에 jvm 계열 언어로 변경해볼까도 생각했지만 관련 플러그인이나 테마가 너무 적었기에 결국에는 javascript 쪽으로 다시 찾아보게 되었습니다.
요즘 이 도구가 가장 많이 보여 한번 시도를 해봤습니다. 결과적으로 사용하지 않았지만, 다음과 같은 이유로 Gatsby
를 사용하지 않았습니다.
React
를 깊게 알지 못하기 때문에 오히려 불편함Gatsby
의존성이 강해질 것이라고 판단facebook에서 만든 SSG 도구로 개인적으로는 커스텀이 그나마 적었고 쉬웠습니다. 그리고 또 다른 개인적인 이유로는 초록색을 좋아하기 때문에 vue 공식 홈페이지를 모방한(?) hexo-theme-vexo를 사용했었는데요. docusaurus는 기본적으로 초록색이기 때문에(?) 별다른 테마를 적용하지 않아도 될 만큼 만족스러운 테마였습니다.
https://docusaurus.io/docs/installation 를 참고하시면 명령어 한줄로 손쉽게 만들 수 있는데요. 만들어진 기본구조는 공식 홈페이지를 참고하시면 자세히 설명되어 있습니다.
npx create-docusaurus@latest [name] [template]
npx create-docusaurus@latest website classic
hexo로 사용하던 기능은 그대로 옮겨가는게 최종 목표였는데요.
대부분의 SSG도구들과 마찬가지로 기본적으로 해주기 때문에 따로 설정하지 않았습니다.
플러그인이 존재하긴 했지만, 간단한 기능이였기 때문에 local plugin을 만들었습니다.
참고) https://docusaurus.io/docs/api/plugin-methods / injectHtmlTags
Google Analytics 4 경우에는 gtag
를 사용한다고 하여, gtag로 변경했습니다.
참고) 보다 많은 기능을 제공하며, 기존에 UA-
로 시작하는 ID로도 매핑되니 문제없이 적용할 수 있습니다.
Google, Naver 같은 검색 서비스에 잘 노출이 되도록 meta tag를 추가해야하는데요. themeConfig#metadata를 활용하면 손쉽게 추가할 수 있습니다.
// ...
metadata: [
{name: 'google-site-verification', content: 'XXXXXXX'},
{name: 'naver-site-verification', content: 'XXXXXXX'}
],
// ...
공식 홈페이지를 참고하시면 손쉽게 해결할 수 있습니다. (deployment)
Docusaurus
문서를 보면서 알게된 사실은 DocSearch라는게 존재하는데 이게 sitemap을 크롤링해서 인덱싱을 해주고 UI까지 만들어줍니다. hexo를 사용할 땐 html, css, script를 전부 직접 만들었었는데... 그럴 필요가 없어졌습니다. 👍
hexo-tag-gdemo
제일 난감했던 부분인데요. hexo에서는 tag라는 기능이 있어 별도의 문법을 통해 커스텀 기능을 추가할 수 있었습니다. 그래서 이를 활용해서 hexo-tag-gdemo
라는걸 만들었는데... 이를 다른 방법으로 해결해야했습니다. (군데군데 사용하고 있었습니다. 🥲)
처음에는 plugable하게 remark
, reshape
개념을 좀 더 공부해보고 적용해볼까 했지만, 아직은 너무 과하단 생각이들어서 React로 간단하게 컴포넌트를 만들어 마무리 했습니다.
그 외에도, disqus 연동이나 경로를 동일하게 잡아주는 등의 자잘한 작업도 있었습니다. 경로 같은 경우는 hexo를 사용할때 년/월/일/prelink를 적용했었는데 docusaurus에서는 아직 관련 기능을 제공하지 않아, slug를 이용하거나 디렉토리 구조를 변경하면 동일하게 가능합니다.
사실 필요없기도 한데, docusaurus에서는 블로그 기능 이외에도 doc 기능이 있어 별도의 문서 페이지를 만들 수 있습니다. 이를 활용해 자기소개라거나 포트폴리오나 등등 별도 페이지를 구성해볼 생각입니다.
]]>올해는 상당히 많은 일들이 있었다. 개인적인 일들과 공적인 일들, 좋은 일들과 나쁜 일들... 전부 언급할 순 없지만 개발 관련된 내용만 언급해보고자한다.
사실 전부 다 기여한 것도 아니고, 기여했어도 대부분 typo 수정 정도다... 😆
올해는 작년보다 꽤나 많은 오픈소스에 기여를 해왔다. 개인적으로 좋았던 점은 드디어 스프링 프로젝트에 내 이름 한줄을 넣어보았다는 것이다. 단순한 기능 추가였기에 살짝(?) 아쉽지만, 그래도 (언제였는지 기억은 안나지만...) 목표로했던 스프링 프로젝트에 이름을 올려본 셈이다.
https://github.com/spring-projects/spring-security/pull/10278 (빨리 5.7.0 GA 됐으면 좋겠네요 🙏🙏)
그리고 어김없이 아르메리아에 지속적인 기여도 해왔다. 횟수를 거듭할 수록 발전해나가야 하는데... 그런 모습은 보이지 않아서 개인적으로 많이 노력해야겠다고 다짐했다. 그 와중에 조금 나아진게 있다면, 바로, 테스트 작성하기이다. 메인테이너분들이 항상 테스트를 작성해달라는 needs가 있었고, 덕분에 계속 작성하다보니 그래도 이전보다는 '테스트 좀 작성해본 사람이다' 라고 얘기할 수 있을 것 같다.
테스트를 작성하면서 느낀 것은 테스트는 다다익선이다.
이러한 오픈소스 활동 덕분인지, 젯브레인에 오픈소스로서 라이센스를 받을 수 없냐고 문의를 넣자마자 답변으로 라이센스를 받을 수 있었다. 오픈 소스 기여를 장려하기 위해 이와 같은 라이센스가 있다는 걸 알고 있었기에, 어떻게 신청하는지 내가 해온 이력과 함께 문의를 넣었는데, 스피드하게 줘서 굉장히 당황스럽고, 놀랍고, 기뻤다..! 😍
(사랑해요~ 젯브레인 😍)
19년에 달성하고 20년에는 달성하지 못한 hackoberfest를 올해는 달성했다. 올해는 상품 중에 약간 특이하게 내 이름으로 기증하는(?) 나무를 심을 수 있다고 하여 나무를 선택했다. 🌳
그리곤 아직까지 연락이 없는데... 뭐 언젠가 연락주지 않을까 싶은...
올해는 그토록 바랬던 사내 스터디도 해보았고, 색다른 스터디도 이것저것 해보았다. 현재진행형으로 이펙티브자바 3판을 하고 있는데, 항상 느끼는거지만 이펙티브자바는 여러번 읽어야할 정도로 너무 어려운 책인 것 같다.
스터디를 해오면서 아쉬운 점이 있다면, 바쁠때 하던 '블로깅하기'를 제대로 참여하지 못했다. 이를 계기로 소홀했던 블로그도 보다 더 작성해보고, 이를 발표(?), 공유(?)하면서 말하는 연습도 하고 싶었지만, 시기가 적절하지 못 했던지, 마음가짐이 삐뚤었던 것 인지...(음?)
JPA 프로그래밍, 함께 자라기, 테스트 관련 도서들... (분명 더 읽은 것 같은데 기억이 잘 나질 않는다...🤣)
가장 좋았던 책은 '함께 자라기' 이다. 평소의 나의 태도를 다시 돌아보게 되고, 내가 앞으로 어떤 개발자가 되어야할지 고민을 하게된 책이다. 이제 시니어로 넘어가게 되는 년차이다보니 더더욱 와닿았던 책이였음이 분명하다... 👍
올해는 대략 5,6개의 블로그를 작성해보았다. 대다수는 서비스를 개발하면서, 운영하면서 분석해보고, 이를 기록한 것들인데 내년에는 이 보다 디테일하고 면밀히 살펴보며 보다 나은 개발자가 되어야 겠다...! 끝!
]]>Postgres 환경에서 Hibernate NativeQuery를 사용할 때 생기는 버그를 디버깅해본 나름의 결과(?)를 공유해봅니다.
우선, 다음과 같은 테이블이 존재한다고 가정해보자.
create table message (
id int8 generated by default as identity,
body varchar(255),
count int8,
primary key (id)
)
그리고 다음과 같은 hibernate 네이티브 쿼리를 실행하면 테스트는 실패합니다. 이 버그는 Postgres에서만 발생하는데요. 보다 자세한 내용을 아래서 설명하도록 하겠습니다.
class ApplicationTests {
@Autowired
private EntityManager entityManager;
@Test
void nativeQuery() {
assertThatThrownBy(() -> {
final Long id = 1L;
final Query query =
entityManager.createNativeQuery("UPDATE message SET count = :count WHERE id = :id")
.setParameter("count", null)
.setParameter("id", id);
query.executeUpdate();
})
.hasCauseInstanceOf(SQLGrammarException.class)
.hasRootCauseMessage(
"ERROR: column \"count\" is of type bigint but expression is of type bytea\n" +
" Hint: You will need to rewrite or cast the expression.\n" +
" Position: 28");
}
}
위 버그는 하이버네이트 이슈로 몇 달전에 리포팅되어 있으니 참고바랍니다. (참고: https://hibernate.atlassian.net/browse/HHH-14778)
위 에러는 query.executeUpdate()
시점에 발생합니다. 어디가 어떻게 문제일까요? 🤔
ERROR: column \"count\" is of type bigint but expression is of type bytea
메시지를 보면 bytea
으로 bigint
를 표현할 수 없다(?)고 합니다. 결국 하이버네이트에서 count 파라미터를 bytea
으로 인식하여 쿼리를 실행한다고 볼 수 있습니다. (PrepareStatement
를 만들게 되는 것이지요.)
QueryParameterBindingsImpl
그렇게 하이버네이트를 분석해보면서 다음과 같은 코드를 찾았습니다.
쿼리를 분석하고 적절한 타입을 바인딩하기 위해 추론하고 적절한 바인더를 만들기 위해 bindType
을 정의하는데요. 결국 적절한 bindType
을 찾지 못할 경우, SerializableType.INSTANCE
로 초기화하게 됩니다. SerializableType.INSTANCE
은 아래와 같은 타입을 갖게됩니다.
Serializable
Types.VARBINARY
(bytea)VarbinaryTypeDescriptor
(BasicBinder
)실제 쿼리에 파라미터들을 바인딩하기 위해 PrepareStatement
에 다음과 같이 셋팅을 합니다. 인자로 넘어가는 sqlDescriptor
가 VarbinaryTypeDescriptor
인 것을 알 수 있습니다.
PgPrepareStatement
PgPrepareStatement는 전달받은 sqlType
으로 케이스별로 파라미터에 반영합니다. 이러한 과정때문에 테스트가 깨진 것인데요.
public void setNull(int parameterIndex, int sqlType) throws SQLException {
int oid;
switch (sqlType) {
case Types.SQLXML:
oid = Oid.XML;
break;
case Types.INTEGER:
oid = Oid.INT4;
break;
// ...
그렇다면, mysql에서는 어떻게 처리하길래 문제가 되지 않을까요?
@Override
public void setNull(int parameterIndex, int sqlType) throws SQLException {
synchronized (checkClosed().getConnectionMutex()) {
((PreparedQuery<?>) this.query).getQueryBindings().setNull(getCoreParameterIndex(parameterIndex)); // MySQL ignores sqlType
}
}
mysql의 ClientPreparedStatement를 살펴보면 다음과 같이 sqlType
을 무시하는 것을 알 수 있었습니다. 🤔
참고로
ServerPreparedStatement
도ClientPreparedStatement
를 상속받았기 때문에 동일한 동작을 합니다.
결국, 하이버네이트에서 각 드라이버의 호환성 문제로 인한 버그라고 볼 수 있을 것 같습니다. (개인적으로는 postgres가 더 정교한 것 같습니다만...) 그런데 한편으로는 HQL같은 엔티티로 쿼리를 작성하면 타입 추론이 가능하지만, 네이티브 쿼리는 어떻게 가능할까 라는 생각이 들기도 합니다.
이렇더라도 우리는 버그를 퇴치(?)를 해야하니 해결방법에 대해 알아보도록 하겠습니다.
우리는 다음과 같이 NamedQuery
로 변경하여, 이를 해결할 수 있습니다.
@NamedQuery(
name = "fixedCount",
query = "UPDATE Message m SET m.count = :count WHERE m.id = :id"
)
public class Message { ...
final Long id = 1L;
final Query query = entityManager.createNamedQuery("fixedCount") // <--
.setParameter("count", null)
.setParameter("id", id);
query.executeUpdate();
TypedParameterValue
를 사용하여 타입 추론이 가능하도록 변경setParameter
를 살펴보다가 TypedParameterValue
를 사용하면 타입을 추론이 가능하다는 것을 알게되었습니다. (AbstractProducedQuery)
final Long id = 1L;
final Query query =
entityManager.createNativeQuery("UPDATE message SET count = :count WHERE id = :id")
.setParameter("count", new TypedParameterValue(LongType.INSTANCE, null)) // <--
.setParameter("id", id);
query.executeUpdate();
이미 우리는 bytea
로 바인딩되다는 것을 알고 있습니다. 이를 인지하고 있다면 cast()
함수를 통해 해결할 수 있습니다. 하지만, 이는 버그가 수정되거나 하이버네이트 내부 로직에 의존하는 것이기 때문에 좋은 방법은 아닌 듯 합니다.
final Long id = 1L;
final Query query =
entityManager.createNativeQuery("UPDATE message SET count = cast(cast(:count as text) as bigint) WHERE id = :id")
.setParameter("count", new TypedParameterValue(LongType.INSTANCE, null))
.setParameter("id", id);
query.executeUpdate();
PrepareStatement
에 직접 액세스하기하이버네이트를 사용하는 경우, EntityManager
에서 Session
을 꺼내어 Connection
을 직접 핸들링할 수 있습니다.
final Session session = entityManager.unwrap(Session.class);
session.doWork((connection) -> ...);
그러므로 우리는 PrepareStatement
에 직접 타입을 기입할 수 있습니다.
session.doWork(connection -> {
try (final PreparedStatement ps = connection.prepareStatement("UPDATE message SET count = ? WHERE id = ?")) {
ps.setNull(1, Types.INTEGER);
ps.setLong(2, id);
ps.executeUpdate();
}
});
해당 코드들은 hibernate_postgres_HHH-14778에서 확인할 수 있습니다.
최근 AWS의 Redis Service인 ElastiCache를 사용하면서 겪었던 유지관리 기간(Maintenance)에 대한 이슈 해결과정을 적어보고자 합니다.
https://aws.amazon.com/ko/elasticache/elasticache-maintenance/
보안 패치나 안정성을 위해 이용자가 지정한 시기에 노드를 교체하거나 클러스터가 다운되거나 특정 샤드의 노드들이 변경됩니다. 문서상에선 몇 초의 다운타임이 발생한다고 하는데요.
제가 경험한 바로는 약간 틀린 점(?)이 있었습니다.
5.3.7
※ lettuce의 경우, 사용하고 있는 버전(5.1.1
)에서 버그가 있어 5.3.7
으로 변경했었는데요. 자세한 내용은 아래서 다시 설명드리겠습니다.
Maintenance는 요일과 시간대(1시간 간격)를 지정해두면, AWS에서 event 알림과 함께 Maintenance 스케줄을 잡습니다. (물론, 갑작스럽게 스케줄이 잡히고 그러진 않습니다.) 이 때, 클러스터가 내려가거나 샤드의 노드들이 변경되거나 재배치하게 되는데요. lettuce client에서는 각 노드들을 캐싱하고 있는 정보들(Partitions)을 리로드해야 하기 때문에 이에 맞는 추가 옵션이 필요합니다.
spring-data-redis
를 사용한다면, 다음과 같이 적용할 수 있습니다.
private static LettuceClientConfiguration lettuceClientConfiguration() {
final ClusterClientOption clientOptions =
ClusterClientOptions.builder()
.topologyRefreshOptions(
ClusterTopologyRefreshOptions.builder()
.enablePeriodicRefresh(...) // <--
.enableAllAdaptiveRefreshTriggers() // <--
.build())
.timeoutOptions(...)
.build();
return LettuceClientConfiguration.builder()
.commandTimeout(...)
.shutdownTimeout(...)
.clientOptions(clientOptions)
.build();
}
주기적으로 connection을 갱신해주는 옵션 활성화와 기간을 지정할 수 있습니다. 기간은 기본 60초입니다.
문제가 될만한 operation이 발생한다면 즉시 connection을 갱신해주는 이벤트를 트리거 시켜주는 설정입니다. 해당 기능은 rate-limit 같은(?) 처리가 되어 있어서 퍼포먼스에 문제가 되지 않습니다.
aws에서는 aws-cli
를 통해 test-failover
라는 커맨드를 제공해주고 있으며, 이를 통해 위 현상을 재현해볼 수 있습니다.
해당 기능은 첫 시도 기준 5회의 제한을 두고 있기 때문에 무분별한 시도는 안하는게 좋습니다.
aws-cli
를 통해 test-failover
를 실행하면 클러스터가 내려갑니다. 해당 갭은 1분 정도 되는데요. 위 문서에서 얘기한 몇 초의 다운타임보다 많은 시간이 소요됨을 알 수 있습니다. 이를 해결할 방법은 딱히 없는 걸로 보여 '서비스 운영에서 있어 다운타임을 최소화 했다'라는 것에 의의를 두고 해당 이슈를 마무리 짓기로 했습니다. 기존에서는 10분정도의 다운타임이 발생했습니다.
다만, 클러스터가 내려가는게 아닌 샤드의 노드들이 변경되거나 재배치되는 것은 다운타임이 아예 없었습니다.
혹시 다른 방법이 있으면 공유해주시면 감사하겠습니다 :)
옵션을 추가하고 배포를 몇 번 해보니, 배포 중 다음 에러로그와 함께 애플리케이션 서버가 내려가지 않는 현상이 발생합니다. 해당 버전은 lettuce 5.1.1
에서 발생했습니다.
ERROR: Failed to submit a listener notification task. Event loop shut down?
java.util.concurrent.RejectedExecutionException: event executor terminated
at io.netty.util.concurrent.SingleThreadEventExecutor.reject(SingleThreadEventExecutor.java:845)
at io.netty.util.concurrent.SingleThreadEventExecutor.offerTask(SingleThreadEventExecutor.java:328)
at io.netty.util.concurrent.SingleThreadEventExecutor.addTask(SingleThreadEventExecutor.java:321)
at io.netty.util.concurrent.SingleThreadEventExecutor.execute(SingleThreadEventExecutor.java:756)
at io.netty.util.concurrent.DefaultPromise.safeExecute(DefaultPromise.java:768)
at io.netty.util.concurrent.DefaultPromise.notifyListeners(DefaultPromise.java:432)
at io.netty.util.concurrent.DefaultPromise.addListener(DefaultPromise.java:162)
at io.netty.channel.DefaultChannelPromise.addListener(DefaultChannelPromise.java:95)
at io.netty.channel.DefaultChannelPromise.addListener(DefaultChannelPromise.java:30)
...
jstack
을 확인해보니, 해당 애플리케이션 서버에 thread 중 WAITING
이 존재하는 것을 알 수 있었습니다. 해당 stack은 shutdown시에 쓰레드 경합이 발생하면서 생긴 문제로 보였는데요. (아쉽게도... 로그가 유실되어 첨부하지 못 했습니다.) 이는 lettuce 저장소 issue#989를 통해 바로 해결할 수 있었습니다.
해당 이슈의 커밋 로그를 잠깐 살펴보자면, eventloop가 활성화되어 있는지 여부를 판단하는 방어코드가 추가된 것을 알 수 있습니다.
private boolean isEventLoopActive() {
EventExecutorGroup eventExecutors = clientResources.eventExecutorGroup();
return !eventExecutors.isShuttingDown();
}
저희는 이것을 5.2.0
에 해결된 것으로 보였으나, 그냥 마이너 최신버전(5.3.7
)까지 올려서 테스트 해보기로 했습니다. 다행히 배포시에 위 현상이 해결되어 안정적인 스무스하게(?) 배포할 수 있었습니다.
최근 웹소켓을 활용한 서비스를 개발하면서 알게된 내용을 간략하게나마 적어봅니다.
웹소켓은 보통 http, long polling, sse와 비교되어 언급되는 기술 중 하나입니다. WebSocket
을 검색해보면 위키피디아에 다음과 같이 기록되어 있습니다.
웹소켓(WebSocket)은 하나의 TCP 접속에 전이중 통신 채널을 제공하는 컴퓨터 통신 프로토콜이다. https://ko.wikipedia.org/wiki/%EC%9B%B9%EC%86%8C%EC%BC%93
전이중 통신 채널을 제공한다는 말은 무엇일까요? 조금 더 읽어보면 다음과 같은 문장이 있습니다.
웹소켓 프로토콜은 HTTP 폴링과 같은 반이중방식에 비해 더 낮은 부하를 사용하여 웹 브라우저(또는 다른 클라이언트 애플리케이션)과 웹 서버 간의 통신을 가능케...
단순 HTTP 통신은 반이중방식이라고 적혀있는데요. 우리가 흔히 사용하는 HTTP 통신은 요청을 해야 그것에 대한 응답을 전달받을 수 있습니다. 이를 단방향통신이라고도 부르기도 하지요. 반면에 WebSocket은 양방향 통신으로 요청을 하지 않아도 서버에서 응답을 전달할 수 있습니다. 뿐만 아니라, 서버와 클라이언트는 양방향 통신을 하기 위해 서로 연결을 유지하고 있기 때문에 여러가지 장점이 있습니다.
웹소켓은 현재 텍스트와 바이너리 형태의 메시지를 지원합니다. (이외에도 확장성을 염두해두고 있기도 합니다.) RFC 6455#section-1.2 문서를 보면 TCP frame에 Opcode라는 값으로 메시지 타입을 결정하는데요. Spring에서는 이를 사용자가 처리할 수 있도록 WebSocketHandler
인터페이스 하위에 TextWebSocketHandler
, BinaryWebSocketHandler
를 제공합니다. 하지만, 본문에 대한 포맷이라던지(plain text? json? xml?) 정의된 것이 하나도 없습니다. 다르게 말하면 자유롭게 정의할 수 있다는 뜻이죠.
그렇기때문에 때로는 이러한 정의를 내리는데 상당히 많은 시간을 소비하기도 합니다. (이러한 과정은 우리가 이벤트기반 시스템을 설계할 때 메시지를 정의하는 것과 비슷하다고 볼 수 있을 것 같네요.) Spring에서는 공식적으로 Stomp를 상위 레벨의 프로토콜로 사용할 수 있도록 지원하고 있습니다. Stomp란 텍스트 지향 메시징 프로토콜로 여러 방면에서 이를 사용하고 있습니다.
Stomp는 very simple 이라는 강점을 내세울 수 있을 정도로 단순하며 사용하가 쉽습니다. 형태는 다음과 같습니다.
COMMAND
header1:value1
header2:value2
Body^@
공식 문서에 있는 그림으로, 한 방(?)에 정리할 수 있습니다.
SimpAnnotationMethodMessageHandler
를 통해 @Controller
를 호출합니다.SimpleBrokerMessageHandler
를 통해 구독자들을 가져옵니다.하면서 편했던 점은 날 것(?)에 경우, 별도 처리해줘야 할 부분이 상당히 많았겠지만 이를 프레임워크 레벨에서 Stomp와 결합해 상당히 많은 것을 지원해주고있어 단순 command만으로 손쉽게 처리할 수 있다는 점입니다.
CONNECT, DISCONNECT, SUBSCRIBE, SEND, ....
- 커넥션 연결/끊기,
- 구독을 하게된 유저들을 별도로 관리할 필요가 없다는 점, (물론 필요할 수도 있음)
- 메시지 전송
각 채널에는 (cpu processor * 2) 갯수 만큼의 쓰레드를 가진 쓰레드풀을 기본적으로 가집니다. blocking IO가 거의 없는 경우라면 기본설정으로도 충분하다고 생각합니다만, 문서에도 나와있듯이 blocking io가 많은 경우에는 적절한 설정을 해야합니다. 이번에 개발한 서비스의 경우는 거의 모든 메시지마다 외부 api를 호출하기 때문에 stress test를 통해 적정 수치를 맞추어 사용하고 있습니다.
본 서비스를 기존 서비스랑 다르게(rolling 배포) 약간 변형된 blue/green 배포전략을 사용하고 있습니다. 웹소켓은 결국 커넥션을 연결하고 있고 배포를 하게되면 기존 애플리케이션이 내려가면서 커넥션이 끊기게 됩니다. 그럼 중간 로드밸런서를 통해 다른 애플리케이션에 다시 커넥션을 맺고자 시도할텐데요. 이를 rolling 배포로 하게되면 최악의 경우, 서버 댓수만큼 커넥션을 연결/끊기를 반복할 것 입니다. 그렇기 떄문에 새배포 버전에 트래픽을 스위칭하는 방식의 배포 전략인 blue/green을 사용하는 것이 사용자 경험 측면에서 좋을 수 있습니다.
쿼리를 실행시에 예상했던 인덱스를 타지 않는 현상을 알아보고자 합니다.
다음과 같은 테이블이 있다고 가정해보자.
CREATE TABLE `payment_history` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, -- 결제 id
`user_id` varchar(100) DEFAULT NULL, -- 유저 정보
`status` varchar(10) DEFAULT NULL, -- 결제 상태
...
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_user_id_status` (`user_id`, `status`),
...
)
그리고 다음과 같은 쿼리를 실행한다면 어떤 인덱스를 타게 될까요?
SELECT *
FROM `payment_history`
WHERE `user_id` = 'AAAAAAA'
AND `status` = 'Completed'
다음과 같은 실행 계획을 예상해볼 수 있습니다.
... | possible_keys | key | ... | rows | ...
------------------------------------------------------------------------
... | idx_user_id, idx_user_id_status | idx_user_id_status | ... | 17714 | ...
하지만 경우에 따라, 이는 다른 실행 계획 결과가 도출될 수 있습니다.
... | possible_keys | key | ... | rows | ...
------------------------------------------------------------------------
... | idx_user_id, idx_user_id_status | idx_user_id | ... | 16670 | ...
그 이유는 옵티마이저가 예상과 다른 인덱스를 탐색했기 때문입니다. 옵티마이저는 최적의 실행계획을 세우기 위해 인덱스 통계 정보를 의존하게 되는데, MySQL 같은 경우는 mysql.innodb_index_stats
라는 테이블에서 해당 정보를 얻을 수 있습니다.
SELECT *
FROM `mysql.innodb_index_stats`
WHERE `database_name` = 'test'
AND `table_name` = 'payment_history';
... | table_name | index_name | ... | stat_name | stat_value | ... | stat_description
-------------------------------------------------------------------------------------------------------------------------
... | payment_history | PRIMARY | ... | n_diff_pfx01 | ... | ... | id
... | payment_history | PRIMARY | ... | n_leaf_pages | ... | ... | Number of leaf pages in the index
... | payment_history | PRIMARY | ... | size | ... | ... | Number of pages in the index
... | payment_history | idx_user_id | ... | n_diff_pfx01 | 1,360,401 | ... | id
... | payment_history | idx_user_id | ... | n_diff_pfx02 | 17,432,752 | ... | user_id,id
... | payment_history | idx_user_id | ... | n_leaf_pages | 66,084 | ... | Number of leaf pages in the index
... | payment_history | idx_user_id | ... | size | 75,904 | ... | Number of pages in the index
... | payment_history | idx_user_id_status | ... | n_diff_pfx01 | 1,566,677 | ... | user_id
... | payment_history | idx_user_id_status | ... | n_diff_pfx02 | 1,660,688 | ... | user_id,status
... | payment_history | idx_user_id_status | ... | n_diff_pfx03 | 15,970,551 | ... | user_id,status,id
... | payment_history | idx_user_id_status | ... | n_leaf_pages | 81,288 | ... | Number of leaf pages in the index
... | payment_history | idx_user_id_status | ... | size | 93,540 | ... | Number of pages in the index
여기서 각 인덱스에 대한 stat_value
를 보고 대략적으로 판단할 수 있는건, 위 쿼리에서 idx_user_id
를 탐색하는 것이 보다 빠르다는 것을 기대할 수 있을 것 같습니다.
innodb_stats_persistent은 MySQL 5.6.2에서 생겼으며, 5.6.6부터 기본값이 ON입니다.
innodb_stats_auto_recalc은 자동 계산 여부를 지정하는 옵션입니다. (기본값은 ON) 테이블의 10% 변경이 있을 때 재계산을 하는데, 아쉽게도 이 값은 하드코딩 되어있어 변경할 수 없습니다. (mysql/mysql-server)
innodb_stats_on_metadata을 ON하면 다음 쿼리시 재계산을 할 수 있습니다. (기본값은 OFF)
인덱스 통계 테이블을 재계산합니다.
하지만 역시 막쓰는게 아닌 것이... 너무 느리기도 하고 테이블 read lock이 걸리기 때문에 조심해서 사용해야 합니다
During the analysis, the table is locked with a read lock for InnoDB and MyISAM.
또한 재계산한다고 좋아진다는 보장이 없습니다. 결국 랜덤으로 샘플링을 하기 때문에 좋아질 수도, 나빠질 수도 있습니다.
innodb_stats_persistent_sample_pages
를 조절하자ANALYZE TABLE
를 사용하자개발 초기, 개발서버에서 /var/log
에 상당히 많은 데이터가 쌓이면서 골치가 아픈 적이 있다. 리눅스에 대해 많은 지식이 없는 상황에서 다행히 팀원의 도움으로 빨리 해결 방법을 알게 되었지만, 모르는 내용이 있어 이를 블로깅하여 기록해두려 한다.
리눅스에서는 내부에서 발생하는 이벤트에 대한 로그를 관리하는 시스템이 존재한다. 일단 한 놈(?)만 파보고자 회사에서 사용하는 amazon linux2
의 로깅 시스템에 대해서 알아보고자 한다.
공식문서는 여기서 확인할 수 있다.
Amazon Linux는 CentOS처럼 RedHat계열의 리눅스이다.
systemd
이란?systemd는 일부 리눅스 배포판에서 유닉스 시스템 V나 BSD init 시스템 대신 사용자 공간을 부트스트래핑하고 최종적으로 모든 프로세스들을 관리하는 init 시스템이다.
Amazone Linux2
는 systemd를 init 시스템으로 채택하여 사용하고 있다. 여기서 init 시스템이란 컴퓨터 시스템의 부팅 과정 중 최초의 프로세스이다.
systemd
구조위 그림과 같은 구조를 가지고 있다. 이 글에서 모두 언급하기 어렵기도 하고 이슈를 해결하기 위해 봐야했던 부분만 언급해보고자 한다.
systemd
로깅 시스템systemd-journald is a daemon responsible for event logging, with append-only binary files serving as its logfiles. The system administrator may choose whether to log system events with systemd-journald, syslog-ng or rsyslog. The potential for corruption of the binary format has led to much heated debate
현재 실행 중인 systemd-journald, rsyslog를 찾아보자!
/var/log/journal
하위에 바이너리 형태로 쌓임/etc/systemd/journald.conf
systemd-journald.service
systemd-journal-flush.service
journalctl
fluentd와 흡사한 도구라고 느껴짐
/etc/rsyslog.conf
amazonlinux2
에는 다음과 같은 input 설정이 기본으로 되어 있음 (uxsock
,journal
)omusrmsg
, omfile
)그런데 /var/log/messages
를 검색해보면 날짜별로 rotate되는 것으로 보인다. 또 이건 어떤 서비스가 이를 처리하는 것 일까?
그것이 바로 logrotate라는 녀석이다. 요약하자면, /etc/cron.daily/logrotate
이 스크립트가 매일 실행되면서 /etc/logrotate.conf
과 /etc/logrotate.d/
하위에 파일들의 설정으로 rotate가 되는 것인데, 자세한 설명은 이 블로그에 자세히 설명되어 있어 이를 대신한다.
실제로
/etc/logrotate.d/syslog
설정을 확인 해보면/var/log/messages
가 rotate 되는 목록에 포함되어 있는 것을 알 수 있다.
/var/log
에 굉장히 다양한 로그가 쌓이는 것을 알게되었다. (부팅, 메일, 메시지, ... 등등)systemd
에 기본적인 로그 시스템만으로도 다양한 로그를 가공 및 처리를 할 수 있다. 그렇기 떄문에 이를 생각하지 않고 별도의 로그를 가공한다고 작업을 추가하면, 이중으로 작업하게 되는 것이라고 판단된다.systemd-journald
을 사용하면 /var/log/journal
에 바이너리 형태로 로그가 쌓이는데 이게 맞는 것일까? 🤔journalctl
으로도 볼 수 있지만, /var/log/messages
에 쌓이는 것만으로도 충분하지 않나라는 생각이 든다. (이것도 이중 작업이지 않나...? 🤔)/var/log/messages
에 쌓이는 것을 경험했다. 🤣crontab
으로 별도의 스크립트를 등록하여 rotate하고 있는데 logrotate
으로 처리하면 보다 깔끔한 처리가 되지 않을까 싶다.진행했던 플로우는 다음과 같습니다.
slave 스케일 업
slave를 master로 승격(failover)
slave 스케일 업
1번의 경우, 인스턴스가 내려갔다 올라오기 때문에 잠시 동안 slave를 사용할 수 없게 됩니다. 하지만, cluster end-point를 사용하게 되면 자동적으로 slave로 가던 트래픽이 master를 바라보게 됩니다. 2번의 경우, master와 slave가 바뀌니 아주 잠깐의 순단이 있겠지만 이는 내부적으로 커넥션이 다시 맺어 정상 동작할것이라고 생각했습니다.
하지만... 2번을 진행함과 동시에 서버에서는 다음과 같은 예외가 출력되고 있었습니다.
The MySQL server is running with the --read-only option so it cannot execute this statement
자동으로 커낵션을 다시 맺어줄꺼란 생각이 틀렸고, 구글링을 통해 다음과 같은 게시물을 찾을 수 있었습니다. (참고: https://aws.amazon.com/ko/premiumsupport/knowledge-center/aurora-mysql-db-cluser-read-only-error/)
우리 서비스에서는 이미 cluster endpoint를 사용하고 있기 때문에 해당이 안되는 내용이였습니다. 혹시 instance endpoint를 사용하고 있다면 cluster endpoint를 사용하길 권장드립니다.
이번에 알게된 사실이지만 JVM 애플리케이션이 실행된 이우에 DNS 캐시하게 됩니다. 이는 jdk 구현체 마다 옵션이 다르다고 알고 있지만 오라클 jdk를 사용하는 경우는 이를 무기한으로 가지게 됩니다. 변경이 되지 않는다는 얘기죠. 그래서 우리는 networkaddress.cache.ttl
를 추가하여 테스트 해보기로 했습니다만... 동일한 예외가 발생했습니다.
마지막 방법으로 커넥터를 변경하는 것입니다. 현재 사용 중인 커넥터는 mysql-connector
였고, 이를 mariadb-connector
로 변경하는 것이였습니다. 조금 더 찾아보니 mariadb-connector
에는 mysql-connector
와 달리 failover에 대한 대응이 가능한 옵션을 제공해주고 있었습니다.
(참고: https://mariadb.com/kb/en/failover-and-high-availability-with-mariadb-connector-j/#specifics-for-amazon-aurora)
jdbc:mysql:aurora:.....
AWS 커뮤니티에도 질문을 남겼지만 대다수의 분들이 mariadb-connector
로 변경해서 해결했다는 것을 알 수 있었습니다.
그리고 이제는 마지막일거라고 생각하고 테스트를 했고 성공했습니다..! 조금 더 코드를 살펴보니 실패 시에 내부적으로 커낵션을 다시 맺는 과정이 포함되어 있는 것을 알 수 있었습니다.
커낵션에 대한 부분은 해결했지만... 다른 예외가 발생합니다. connector를 변경함으로써 발생한 문제인데요. 이런 문제가 발생할 수 있구나 했습니다.
안타까운 현실이지만 서비스에서 SQL Mapper인 Mybatis
를 사용하며, datetime의 컬럼 값을 String으로 받는 케이스가 있었습니다. 기존 mysql-connector
에서는 이 값이 2021-02-07 17:19:00
으로 할당 됐었다면, mariadb-connector
를 사용하면 2021-02-07 17:19:00.0
으로 할당 됩니다.
AS-IS:
- mysql-connector
- 2021-02-07 17:19:00
TO-BE
- mariadb-connector
- 2021-02-07 17:19:00.0
어떻게 된 일까요? 잠깐 코드를 디버깅해보니 이는 커넥터 구현체의 차이에서 서로 다른 응답을 주는 것을 알 수 있었습니다.
public String createFromTimestamp(InternalTimestamp its) {
return String.format("%s %s", createFromDate(its), // 2021-02-07 17:19:00
createFromTime(new InternalTime(its.getHours(), its.getMinutes(), its.getSeconds(), its.getNanos(), its.getScale())));
}
case DATETIME:
Timestamp timestamp = getInternalTimestamp(columnInfo, cal, timeZone);
if (timestamp == null) {
if ((lastValueNull & BIT_LAST_ZERO_DATE) != 0) {
lastValueNull ^= BIT_LAST_ZERO_DATE;
return new String(buf, pos, length, StandardCharsets.UTF_8);
}
return null;
}
return timestamp.toString(); // 2021-02-07 17:19:00.0
대략적으로 정리하자면, mysql-connector
는 내부적으로 String.format을 사용하여 YYYY-MM-dd HH:mm:ss
형태를 만들어주는 듯 보입니다. 반면, mariadb-connector
는 Timestamp.toString()
을 사용합니다.
우리는 이것 때문에 비즈니스 로직을 건드는 것은 크리티컬한 이슈가 발생할 수 있다고 판단하여 고민 끝에 Mybatis의 TypeHandler를 이용하기로 했습니다. (JPA를 사용하는 경우, AttributeConverter
를 사용할 수 있습니다.)
이미 기본적인 typeHandler로 구현되어 있는 것을 어느정도 커스텀하면 비즈니스 로직을 수정하지 않고도 간단히 커넥터 변경 이슈를 수정할 수 있었고, 성공적으로 테스트를 완료할 수 있었습니다.
]]>별다른 일이 없다면, 꾸준히 Armeria에 컨트리뷰션 해볼 생각이다. 아직은 부족한게 많으니 부족한 점을 하나씩 채우면서 보다 딥한 이슈를 처리해보는 것이 첫번째 목표 중 하나이다.
2019년에 이어 armeria에 십여 개의 다양한 PR을 진행했다. (내가 만들어낸 버그도 수정해보고...) 언제나 그렇듯 덕분에 너무 재밌었고, 많은 인사이트를 얻어 갈 수 있었다. 그리고 뜻 밖의 선물도 받아 내년에도 열심히 기여를 해야겠다는 생각이 든다. (음?)
(개개인에게 다른 메시지를 보낸 준 것에 또 한 번 감동이다.)
2019년에 이어 2020년에도 컨트리뷰톤에 참여했다. 이번 오픈소스는 pinpoint 였으며, 개발자로서 APM 개발이라는 색다른 영역의 경험을 할 수 있었다. 다만, 아쉬운 부분이라면 활발히 참여하지 못해 하고자 했던 목표를 달성하지 못 하고 간단한 작업밖에 하지 못 했다.
그리고 장려상을 수상했다.
그 외에도 여러 오픈소스에 다양한 기여를 해보았다.
typo
)new feature
)clean up
)suggestion
)마지막으로 올해는 아쉽게도 Hacktoberfest를 완주하지 못 했다.
현 회사에서 내년에 전체적인 아키텍처 개선을 할 것이라고 하여 업무에 집중할 생각이다. 물론, 개선 과정에서 트러블 슈팅이 있다면 이를 명확히 알고 블로깅 해보는 것도 생각하고 있다.
결론을 얘기하자면, 아키텍처 개선은 없었다...
하지만 기술적인 큰 변화가 있었다면, 스프링 버전 업그레이드(4.3
-> 5.1
)와 레디스 클라이언트 변경(jedis
-> lettuce
)을 하였다. lettuce
는 적은 커넥션으로 높은 처리량을 자랑하여 많이들 사용하는 라이브러리이다. 하지만 spring-data-redis
을 기반으로 사용한다면 특정 버전, 특정 환경에서 문제가 있었고, 이는 2.1.0.RELEASE
에서 해결되었다. 혹시 클러스터 환경에서 lettuce
를 사용한다면, spring-data-redis
를 사용한다면, 2.1.0.RELEASE
이상을 사용하고 있는지 확인해보기 바란다.
그리고 이메일 발송 기능이 필요하여 AWS SES를 활용해보기도 했다. 처음 경험해보는 도메인이지만, 잘 정리된 문서, 쉬운 인터페이스덕분에 러닝커브가 많이 높지 않았다. 다만, 평판이라는 개념은 아직도 익숙하지 않다... (이것 때문에 꽤 고생을 했다... - AWS - SES 사용 후기)
읽기로한 개발 도서 읽는 것에 소홀히 하지 않아야 겠다. 다독이 아닌 정독! 2020년 첫 도서로는 Netty 관련 내용이 될 것 같은데.. Armeria 컨트리뷰션에 조금 보탬이 되지 않을까싶다.
정독이 목표였지만... 애초에 책을 읽는 것에 소홀해졌다.
스프링 인 액션
http 완벽 가이드
프로그래밍 관련은 아니지만 그 동안 몸에 너무 소홀히 한 것 같아 운동을 할 생각이다. 헬스 정기권 같은 것만 하면 금방 안할게 뻔하니 P.T나 다른 대안책을 찾아보는 중 이다.
상반기까지는 식단 관리도 하고 필라테스도 하며 몸이 건강해졌지는 것을 느낄 수 있었다. 하지만 하반기부터는 여러가지 이유로 못 하게되어 다시 소홀해졌고 원상복구 중 이다...
2020년은 코로나 19로 인해 몸도 마음도 느러지는 한 해였던 것 같다. 다시 마음을 다 잡고 보다 의미있는 일, 보람찬 일, 재미있는 일을 찾아 2021년에는 뜻깊은 한 해를 보내야겠다.
network-json-filter
는 주고받는 네트워크 JSON 응답을 필터링하여 보고자 개발한 크롬 익스텐션으로, 다음과 같은 경우에 유용할 수 있다.
chrome 웹 스토어 - network-json-filter
※ 해당 스크린샷은 https://httpbin.org
를 활용한 예시
▶/▼
)https://github.com/heowc/network-json-filter-chrome-extension/issues
]]>@Autowired
의 동작 원리를 간단하게 이해해보자.
스프링에서 Bean으로 등록된 객체에 특정 Bean에 대한 의존성을 주입할 때, 스프링에서 제공(@Autowired
, @Value
)하는 혹은 자바 제공(@Inject
, @Resource
)하는 애노테이션들이 어떤 원리로 주입이 되는 것?에 대한 궁금증이 생겼다. 그것은 바로 BeanPostProcessor
이라는 클래스에 해답을 얻을 수 있다.
BeanPostProcessor
는 스프링 컨테이너 안에서 만든 bean에 전/후처리 작업을 할 수 있도록 만든 인터페이스이다. 여러 구현체들을 보면 AutowiredAnnotationBeanPostProcessor
, CommonAnnotationBeanPostProcessor
등등 다양한 BeanPostProcessor들이 존재하며, 서드파티 라이브러리 중에서도 스프링 위에서 동작할 수 있도록 BeanPostProcessor
를 추가로 제공하기도 한다. (MeterRegistryBeanPostProcessor
, ArmeriaBeanPostProcessor
, ...)
public interface BeanPostProcessor {
// 빈 생성 이전에 실행되는 메소드
Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException;
// 빈 생성 이후에 실행되는 메소드
Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException;
}
인터페이스는 굉장히 깔끔하고 단순하다.
그렇다면 BeanPostProcessor
의 생성시점은 언제일까? 코드를 들여다 보면 다음과 같다.
@Override
public void refresh() throws BeansException, IllegalStateException {
synchronized (this.startupShutdownMonitor) {
// Prepare this context for refreshing.
prepareRefresh();
// Tell the subclass to refresh the internal bean factory.
ConfigurableListableBeanFactory beanFactory = obtainFreshBeanFactory();
// Prepare the bean factory for use in this context.
prepareBeanFactory(beanFactory);
try {
// Allows post-processing of the bean factory in context subclasses.
postProcessBeanFactory(beanFactory);
// Invoke factory processors registered as beans in the context.
invokeBeanFactoryPostProcessors(beanFactory);
// Register bean processors that intercept bean creation.
registerBeanPostProcessors(beanFactory);
...
호출 시점만 놓고 보자면 Bean 작업 이전에 BeanPostProcessor에 대한 작업을 한 후에 Bean 작업이 되는 것을 볼 수 있다.
간단한 컴포넌트와 커스텀 BeanPostProcessor를 준비하자.
@Component
public class Person {
private String name;
public void setName(String name) {
this.name = name;
}
public String getName() {
return name;
}
}
// ...
@Configuration
public MyBeanPostProcessor implements BeanPostProcessor {
@Override
public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
if (bean instanceof Person) {
Person heowc = (Person) bean;
heowc.setName("heowc");
}
return bean;
}
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
return bean;
}
}
간단하게 이름을 필드로 갖는 Person이라는 클래스를 만들었다. 그리고 이것이 Bean으로 등록이 된다면, Person 객체 name 필드에 'heowc'를 초기화해주는 코드다. 이를 테스트한 코드는 아래와 같다.
@SpringBootTest
class MyBeanPostProcessorTest {
@Autowired
private Person heowc;
@Test
void test_personInjection() {
assertThat(heowc.getName()).isEqualTo("heowc");
}
}
AWS SES 사용 후기이다.
Amazon Simple Email Service(SES)
는 AWS에서 제공하는 클라우드 기반 이메일 발송 서비스이다.
인스턴스를 하나 할당받는 것처럼 SES를 사용하기 위한 신청을 해야 되는데, 정상적으로 신청이 되었다면 이 상태가 샌드박스 상태이다. 만약, 실서비스에서 사용하려면 샌드박스 상태를 벗어나야한다.
샌드박스 상태는 일일(200건), 초당(1건) 발송 제한이 걸려있고, 등록된 이메일에만 메일 전송이 가능하다.
당연할 수 도 있겠지만, SES에 대한 가이드 문서를 꼭 읽어보고 진행하길 권한다. 이메일 도메인 지식이 없는 사람도 이메일 도메인 지식 습득할 수 있을 뿐만 아니라 SES 기능 숙지도할 수 있다.
SMTP 인터페이스 또는 AWS SDK, HTTP API형태로 제공한다.
※ AWS SDK를 사용한다면 이메일 템플릿을 저장해두고 사용하는 방법도 존재한다.
메일은 수신거부, 반송 등의 알림을 받을 수 있다. 이를 받기 위한 수단(방식)으로는 AWS SNS 또는 별도의 email를 등록하여 받아볼 수 있다.
알림에 대한 후처리기를 빠르게 테스트하기 위해 시뮬레이터를 제공한다. 특정 메일으로 메일을 전송하면 각각의 시나리오를 테스트해볼 수 있다. 예를 들면, bounce@simulator.amazonses.com
에 메일을 전송하면 반송 알림을 받을 수 있다.
평판이라는 개념이 존재하여, 평판 낮아지면 할당받은 일일/초당 발송 횟수가 낮아질 수 있고 나중에는 계정이 정지될 수 있다. 평판에 대한 관리가 지속적으로 되어야 하며, 이에 대한 대책을 강구해야 한다. 그렇다보니,
평판은 반송율과 수신거부율로 인해 정해지는데 권장사항으로는
실서비스로 가기까지 생각보다 실제 반송/수선거부 처리를 유도하기가 어려웠다. (방법이 잘 못된 것일 수도 있겟지만) 스팸처리, 자동응답 등등을 설정해도 정상적으로 메일이 전송되는 것으로 처리되었다. 그래서 실서비스 반영까지 시뮬레이터 테스트와 제한된 이메일을 저장해둘 별도의 테이블을 만들어 필터링이 잘되는지 정도의 테스트만 진행했었다.
Google Gmail경우, 반송/수선거부 데이터를 제공하지 않는다.
Yahoo 이메일에 대한 반송율이 꽤 높았다. (초반 데이터로만 봤을 때, 50%정도 되었다.) 확실하지는 않지만 2019년 3월쯤 관련 내용을 볼 수 있는데, 장기 미사용 유저에 대해 휴먼계정으로 전환되어 나타난 현상으로 추측된다. 이로 인해, 각종 포럼이나 stackoverflow에도 여러 질의응답이 오가는 것을 볼 수 있다. 그래서 우리가 할 수 있는 처리는 다른 이메일을 등록하도록 유도하거나 유효한 이메일인지, 반송율이 높은 이메일이 알 수 있는 별도 API를 사용하여 반송율을 낮춰야 할 것이다.
AWS 콘솔 > SES > 평판 대시보드
에서 반송율, 수신거부율을 볼 수 있는데, 기준 날짜도 다르고 언제 기준이 바뀌는지도 알 수가 없다.GitHub Action
을 통해 실행된 테스트가 실패시, test reports
를 업로드하는 기능을 소개하고자 합니다.
이전 글에 이어 보다 개선된 지속적 통합을 하고자 2가지 개선이 있었다.
GitHub Action
은 특정 경로에 대해서 캐싱할 수 있는 action을 제공하고 있다. 이에 대한 자세한 스펙과 설명은 actions/cache를 참고해볼 수 있다.
heowc/SpringBootSample에 적용할 수 있는 포인트는 두 곳이다. (적용된 코드: 7064ed1
)
gradle-wrapper
가 설치된 부분build.gradle
에 정의된 dependency
이 설치된 부분 꼭 Gradle이 아니더라도 다른 언어와 빌드 툴에 대한 샘플도 나열되어 있으니 참고하면 좋다.
gradle-wrapper
설치하는 부분gradle-wrapper
를 사용하는 경우에는 gradle/wrapper/gradle-wrapper.properties
파일을 참고하면 현재 사용 중인 버전을 알 수 있다. 결국에는 해당 파일이 변경되었다면 버전 변경이 되었다고 판단할 수 있으므로 이를 기준으로 cache-key를 가질 수 있다.
build.gradle
정의된 dependency
이미 action/cache의 예시를 참고했다면 쉽게 알 수 있듯이, 이 또한 build.gradle
를 기준으로 cache-key를 가질 수 있다.
아직 히스토리로 남은 전체 워크플로우 갯수도 적고 테스트 갯수도 적어 (또한, 대부분의 워크플로우가 디펜던시 변경이기 때문에) 단정지을 순 없지만 초기에는 7분대가 보였던 것에 반해 cache를 적용한 이후에는 7분대는 거의 볼 수 없었다. 대략 10퍼센트 정도의 속도 개선이 있었다는 것을 알 수 있다.
워크플로우를 보면 대략적으로 실패했다는 것을 알 수 있지만 자세히는 알 수 없다. 그래서 테스트 결과물로 나온 reports
를 어디선가 볼 수 없을까? 라는 생각과 내 주변 프로젝트는 어찌하고 있을까? 싶어 찾아보았다.
요즘 라인에서 만든 Armeria
에 관심이 많아 조금씩 기여도 하고 유용한 코드 조각이 있으면 활용하는 편이다. Armeria
는 appveyor라는 CI 도구와 codecov라는 코드 커버리지 도구를 사용하는데, appveyor에서 테스트가 깨지면 file.io
서비스를 이용해 하나의 test-reports로 압축하여 업로드하고 있다.
file.io
는 무료이고 유효기간을 지정할 수 있어 굉장히 유용한 도구이다.
최근 베타 기능으로 GitHub에서 artifact API라는 파일 업로드 기능이 추가되었고, 이 또한 action으로 활용할 수 있었다. 하지만 아쉽게도 몇 가지 문제점이 있다.
V2 버전에는 2번이 해결될 것으로 보이지만 아직까진 유효기간 설정에 대한 기능이 이슈로만 얘기되고 있어 file.io
를 사용하고 나중에 actions/upload-artifact
를 변경하기로 마음 먹었다. (적용된 코드: 3d43675
)
무조건 실패하는 테스트를 만들어보고 workflow를 확인해 보았다.
@Test
void test_failure() {
assertThat(true).isFalse();
}
실패시, https://file.io/m5KLSY
에 test-report
가 업로드되고 이를 14일간 다운로드 받을 수 있다.
Dependabot
를 적용하고 GitHub Action
을 활용하여 CI까지 적용해 예제 코드를 개선한 내용을 소개하고자 합니다.
spring-boot를 처음 접하고 이에 대한 간단한 예제를 만든지 꽤 시간이 지났다. 2020년 1월 기준으로는 약 30여개의 예제와 400여개의 커밋으로 이루어진 예제 저장소가 되었는데 처음에는 나만을 위해 나만이 참고하기 위한 공개 저장소 목적으로 만들었다.
하지만 해를 거듭하면서 다른 개발자분들이 간접적으로 관심을 가져주시는 표현으로 가끔씩 스타도 눌러주시고 포크도 해가신다. 공개 저장소이니 간단한 예제지만 쓰시는 분들에게 제대로된 예제 코드를 노출시키고 싶고 관리가 잘 되고 있다는 느낌을 들게하고 싶었다. (현재로선 안돌아가는 예제도 있다.)
우선적으로 디펜던시를 최신 버전으로 올리고 싶었다. 처음에는 가끔씩 크게 버전업을 하고 이에 맞게 코드도 수정했으나, 하나하나 알기도 어렵고 귀찮았다.
그러다가 Dependabot
라는 녀석을 알게되었다. 지정한 주기로 디펜던시를 찾아 추가 버전 릴리즈가 있는지 찾아 pull request를 날려주는 봇이다.
해당 방법은 preview 단계의 설정이므로, 다음 문서를 참고하여 .github/dependabot.yml
를 작성해보도록 하자.
두번째로는 이렇게 머지되는 코드들 혹은 내가 직접 수정하거나 추가 작성한 코드가 정말 문제가 없는지 알고 싶었다. (보통 지속적 통합이라고 하죠.) 최근까지는 Dependabot
을 활용해서 손쉽게 많은 디펜던시들을 주기적으로 갱신되거나 (개인적인 판단하에) 굳이 갱신될 필요가 없다고 판단되면 닫고 있었다.
그런데 이 많은 디펜던시들이 갱신되면서 '예제가 정상적으로 빌드가 될까' 라는 의문이 생겼고...
...
역시나 예제가 정상적으로 안돌아갔다. 이런 코드는 Revert를 하거나 예제를 수정하기도 했다.
그리하여 쓸만한 CI 도구를 찾다가 약 1년 전에 작성한 'GitHub Action을 활용한 GitHub Page 배포'이 생각이나서 github-action
을 적용해보고자 한다. 사실 이를 적용할 생각은 예전부터 있었으나 1년 전에는 베타였고 자료도 많이 없어 꽤나 불편함이 조금 있었다.
다시 찾아보니 마켓플레이스에 편리한 많은 액션들이 추가되었다. (거의 2천개정도 되더란다.)
디테일한 작업은 추후작업으로 미루고 우선 push와 pull request 이벤트를 트리거하여 test를 실행하는 것이다. 코드는 다음과 같다.
name: Test
on:
push:
pull_request:
types: [opened, reopened]
jobs:
test:
runs-on: ubuntu-18.04
steps:
- uses: actions/checkout@v2
- name: Set up JDK 1.8
uses: actions/setup-java@v1
with:
java-version: 1.8
- name: Test task with Gradle Wrapper
# 제일 중요한 부분
run: |
chmod +x gradlew
./gradlew --version
./gradlew test
Dependabot
에는 Bump now
이라는 버튼 있어, 이를 클릭하면 주기에 상관없이 즉시 디펜던시를 찾아 pull request를 날려준다. 그러면 반영된 디펜던시로 test가 돌아 코드에 문제가 없는지 확인할 수 있다. 이로써 목표로 했던 안정적인 예제 만들기에 한 걸음 나아갔다. (아직 개선할게 많다...)
Dependabot
,github-action
은 완전 무료이기 때문에 간단한 저장소에 부담없이 사용하기 굉장히 좋은 기능인 것 같다.
여러 프로젝트에는 나름 짜여진 테스트 코드들도 있고 테스트 코드 자체가 없거나 무의미한 테스트 코드가 있다. 정말 안정적인 코드가 되려면 필수적으로 테스트 코드를 작성해야 한다.
트리거될 때마다 grade을 다운로드하는데 사용량 제한이 있는 github-action, 보다 빠른 피드백을 위해 개선해야 한다. 물론 아직은 넉넉하다. (public repo
는 무료이다.)
Dependabot를 적용해보니 push과 pr이 동시에 트리거되어 2배의 소요시간이 걸리고 사용량 또한 2배로 늘어난다. 추후에는 둘 중 하나만 트리거 되게끔 개선해야되지 않을까 싶다.
...
]]>2018년에 세웠 계획과 2019년 일을 되돌아 보며...
'2019 컨트리뷰톤 (feat. Armeria)'라는 글을 통해 언급했지만, 이 행사를 통해 Line에서 개발하는 오픈소스인
Armeria
에 기여를 해봄으로써, 2018년에 목표로 했던 '컨트리뷰터 되기'를 달성했다.
단순히 오타나 문서 수정이 아닌 나를 포함한 다른 개발자들이 직접적으로/간접적으로 사용하는 코드를 수정해 보았다. 전체적인/부분적인 맥락을 이해하고, 복잡하지 않고 읽기 쉬운 코드를 만들어내는데 직접 고민해보고 의견을 나누어 볼 수 있었다. (그 만큼 PR에 많은 코멘트가 달렸다는 의미입니다...ㅎ)
이 글을 쓰는 시점에도 여전히 기여하고 있는데, 최근에는 일코딩 이외에 개발 시간을 여기에 투자하는 편이다. 최근에는 코틀린으로 작성한 스프링부트 + 아르메리아 예제를 만들어 컨트리뷰션하기도 했다.(#2335) 왜 하느냐? 흠... 재밌다. 오픈소스에 기여하는 것은 아직 낯설고 새롭지만, 말로 글로 표현 못할 흥미진진함과 즐거움이 있다. 그 외에도 그 만큼 도전해볼 주제도 많고, 소프트웨어에 변화에 어느정도 따라가기 위한 하나의 수단이 되는 것 같기도 하다는 생각이 든다.
또, 다른 개발자의 의견이나 코드도 보면서 배우는 점도 많아 많은 인사이트를 얻어갈 수 있다. 그래서인지 요즘 주변 개발자 지인을 만나면 오픈소스하라고 적극! 추천하는 편이다.
최근에는 Armeria 공식 슬랙 채널에 한국어 채널이 개설되어 언어의 장벽도 많이 낮춰진 셈이다.
정독이란걸... 아예 못 했다. 이런저런 핑계도 많고 귀차니즘으로 인해 안 했다. (역시 안했다라는 표현이 더 적절한...) 물론 아예 안읽는 것은 아니지만, 대강 읽어 읽는둥 마는둥 했다.
이를 반성하며 나에게 어려운 계획이라 판단하고 내년에는 보다 쉽고 계획다운 계획을 만들어 봐야겠다는 생각이 든다.
스터디를 하며 주기적으로 책을 읽고 있는데, 올해는 중간에 잠깐 쉬어서 그런지 몇권 읽지 못 했다. 일이 바쁘기도 해서 그랬나..
결과적으로, 많이 읽는 것보다 한권을 읽더라도 제대로 읽어야 했다. 내년에는 많이 읽어야 하는 것에 집착하지 말고 하나를 읽더라도 제대로 읽어야 겠다.
15분방의 존재 의미는 사실 사라진지 오래됐기도 했고... 깃헙 잔디밭(?)을 보면 커밋량도 거의 절반가량으로 뚝 떨어졌다. 2018년 910 커밋을 했고, 2019년 503 커밋을 했다.
[https://github.com/heowc?from=2018-01-01&to-2018-12-31]
[https://github.com/heowc?from=2019-01-01&to-2019-12-31]
그래도 블로깅 수는 좀 늘었다. 2018년 2개, 2019년 10개... 그래도 티스토리에서 했던 거에 비하면 적긴도 하고, 구글 애널리틱스만 보더라도 엄청 심하게 차이가 나기도 한다.
다양한 세미나 참여하거나 스탭(?)으로 참여했다.
또한, 여전히 스터디도 하고 있다. 방법론/언어 등등 관심있는 분야의 프로그래밍 서적을 읽고 토론(?)하는 스터디도 있고, 중간에 오픈소스 관련 스터디도 있긴 했으나 점차 취지에 맞는 방향으로 가고있어 스터디장이 파했다. (빠른 결단력 리스펙!)
CQRS => 성능, 장애 격리, 데이터 동기화
데이터 싱크 장애 대응
캐시
서킷 브레이커
비동기 논블로킹
결과적으로, 크게 16개의 서비스로 분리
null
이라는 값이 존재한다. 때로는 이 존재가 문제를 야기시키기도 하는데 자바에서는 null
인 객체를 참조하게 되면 NullPointException
이라는 런타임 에러를 던지게 된다.String message = "Hello Would!";
message = null;
message.toLowerCase(); // throw NullPointException
물론, 위와 같은 코드를 작성하진 않겠지만 상황에 따라서 휴먼 에러는 언제나 발생할 수 있고 발견하기 어려울 수 있다.
널 레퍼런스를 처음 만든 토니 호어가 '10억 불짜리 실수'(billion dollar mistake)였다고 회고한 적 있다. - 나무위키 中
테스트 코드를 만들면 이를 방지할 수 있겠지만 근본적으로 해결할 수 없는(?) 문제이기 때문에 관련 프레임워크나 대체 언어(ex. kotlin
)가 나오고 있다. 그 중 uber에서 만든 NullAway
를 알아보자.
NullAway
@Nullable
, @NonNull
등)자바의 대표적인 프레임워크인 Spring을 보면 @Nullable
가 필드나 파라미터, 반환값 등에 붙은 것을 볼 수 있는데, js-305에서 제공하는 메타 에노테이션들 중 하나이다.
※ js-305: 자바 소프트웨어 오류 발견 도구를 위한 표준
error-prone
이 내장되어 있다.error-prone은 구글에서 만든 정적 분석 툴로 프로그래밍에서 흔히 하기 쉬운 패턴을 찾아 알려준다. 해당 패턴은 공식문서에서 찾아볼 수 있고, 커스텀하게 추가할 수도 있다. 잠깐 훑어봤는데 이를 참고하여 자신의 잘못된 프로그래밍도 개선될 수 있지 않을까 싶다.
간단히 문자열을 소문자로 만들어주는 메소드가 있다고 가정하자.
private static String foo(String bar) {
return bar.toLowerCase();
}
본의 아니게 파라미터에 null
을 넣는다면 컴파일 과정에서 다음과 같은 에러 메시지가 출력된다.
System.out.println(foo(null));
// error: [NullAway] passing @Nullable parameter 'null' where @NonNull is required
설정이나 샘플 코드는 공식 문서나 JavaBasePractice - NullAway를 참고할 수 있다.
]]>올해는 끝나가고 뭔가 해논게 없어서 좌절하고 있을 때 쯤 공개SW컨트리뷰톤을 접했다.
여러 프로젝트 중에 아르메리아가 눈에 들어왔다. 아르메리아는 라인에서 만들고 있는 마이크로서비스 프레임워크로, 최근 자바 진영에서 마이크로서비스 프레임워크가 나와서 서치 중에 보던 프레임워크 중에 하나였던 것으로 기억한다. 그 외에도 여러 프로젝트가 있었지만 내가 할 수 있는 것은 별로없었다. 자바스크립트, 파이썬, 딥러닝...
그렇게 아르메리아를 로컬PC에 Clone을 받으며 README를 읽어가며 Project 셋팅을 맞추었다. 이제와서 생각하는거지만 조금 더 꼼꼼히 보았으면 좋았을 것 같다는 후회를 해본다. (PR에 엄청 많은 코멘트가...) 이슈를 해결하기 위해 제안해주신 이슈들을 포함해 총 200 여개의 이슈들을 하나씩 살펴보고, 그 중에서 good-first-issue
를 최우선적으로 보았다.
오픈소스에 기여를 해보고 싶다면, 등록된 이슈 중에
good-first-issue
라벨을 찾아보세요. 쉬운 진입점이 될 수 있습니다.
컨트리뷰톤 기간에 진행했던 첫 이슈이다. 간략히 설명하자면, 아르메리아에서는 Swagger같은 DocService를 제공한다. 하지만 스프링 부트를 연동하게 되면 특정 기능에서는 예제 요청과 헤더값를 추가하는 기능을 제공하지 않아 이를 추가해야 한다는 이슈였다.
거의 3주라는 기간이 걸렸고 약 51개의 코멘트를 주고 받았다. 중간에 회사 워크샵과 추석이랑 껴있어서 진행이 더딘감이 있기도 했지만 간단한 기능임에도 많은 시간이 걸렸다. 그 만큼 놓친 부분도 많았고 미숙한 영어로 인한 딜레이도 있었다.
그 이후로도 컨튜리뷰톤이 진행되면서 여러가지를 진행했다.
총 4개의 PR를 마무리하였고,
1개의 기능 제안을 했으며
다른 개발자의 코드리뷰도 해보았다.
컨트리뷰톤기간 이외에도 지속적인 관심을 가지며 이슈 제기, 코드 기여를 하고 있다.
Hacktoberfest이라는 국제 개발자 행사(?)도 알게되어 참여했다. 10월 동안 PR 4개가 완료되면 한정판 티셔츠를 준다. (완료~!)
그리하여 최종적으로 전체 20팀 중 우수상을 차지 했다. 개인적으로 아쉬운 성적인데 내가 발표를 좀 더 잘했더라면.. 이라는 아쉬운 마음이...ㅠ
]]>직접 APNs를 사용하면서 경험한 삽질기이다.
Push(Android, iOS 등)를 고려한다면 고민말고 FCM(Firebase Cloud Messaging)을 사용하자. Android 뿐만 아니라 다른 플랫폼도 지원한다.
OtherLevels이라는 메시징 플랫폼을 사용 중에 있으며, 여러가지 이유(?)로 이를 사용하지 않고 직접 메시징 시스템을 구축하려고 한다. 인수인계 받은바로는 Android, iOS 각각 플랫폼별로 별도로 처리해야 하는 상황이다.
앞에서 언급했던 것 처럼 APNs만 다룰 예정이다.
APNs(Apple Push Notification Service)는 Apple Device에 Push를 보내줄 수 있는 서비스이다. 지원하는 방식으로는 다음과 같다.
HTTP/1.1 통신을 지원하지 않는다.
fernandospr/javapns-jdk16 테스트 진행. 하지만 마지막 커밋이 2017-05-17으로 현재 기준(2019-05-14)으로 만 2년이 넘은 상태이다. 이와 같이 Legacy Push에 대한 오픈소스 관리는 거의 안되는 상태이다. 또한, Thread Pool 관리가 안되기 때문에 코드의 복잡성이 늘어나거나 리소스 관리가 안될 수 있으니 조심해서 사용하자.
/* fernandospr/javapns-jdk16 샘플 */
PushNotificationBigPayload payload = PushNotificationBigPayload.complex();
payload.addAlert("Message received from Bob");
/* ... */
Push.payload(payload, p12File, password, isProduction, deviceTokens);
CleverTap/apns-http2와 relayrides/pushy, RestTemplate 테스트 진행. 하지만 지금 서버가 Java 8이기 때문에 HTTP/2을 지원하지 않아 별도의 작업이 필요하다. (사실 relayrides/pushy는 없어도 된다.)
※ 참고) ALPN 지원
/* CleverTap/apns-http2 샘플 */
FileInputStream cert = new FileInputStream("/path/to/certificate.p12");
final ApnsClient client = new ApnsClientBuilder()
.withProductionGateway()
.inSynchronousMode()
.withCertificate(cert)
.withPassword("<password>")
.build();
Notification n = new Notification.Builder("<the device token>")
.alertBody("Hello").build();
NotificationResponse result = client.push(n);
그리고... 방법 중에 하나로 alpn-boot-{version}.jar
다운받고, 실행시에 다음 옵션을 추가해야한다.
2017년을 시작으로 2018년, 2019년, ... 세번째로 참석하게 되는 Spring Camp 후기 이다.
첫 Spring Camp를 참석했을 때가 생각나는데, KSUG 10주년 기념을 이틀간 행사를 진행되었던 것으로 기억한다. 그 때가 내 인생에 있어 첫 개발 행사이고 그 만큼 기억에 많이 남는 행사였다.
올해는 다른 때와는 다르게 일꾼단으로써 Spring Camp를 참석하게 되었다. 작년 쿠팡에서 할 때도 지원했었으나, 떨어지고 (일꾼단 지원도 빡시네..) 올해도 지원하여 결국에는 일꾼단에 선정되었다. 그렇게 올해의 Spring Camp가 어떻게 만들어지고 모습을 갖춰지는지 지켜볼 수 있었다.
다만 아쉬운 점이 있다면, 업무로 인해서 회의나 리뷰에 거의 참여하지 못했다...
의자 나르고 배치하고... 기념품 나눠드리고...
다행인지(?) 운이 좋은 탓인지(?) 세션장 보조 역할을 맡게되어 Track 2
대부분의 세션을 들을 수 있었다. 물론 일반 참석자로 오신 개발자분들처럼 열심히 들을 순 없었지만, 한편으로는 작년보다 표 구매가 더 어려웠던 것 같은데 일꾼단이 아니였으면 과연 참석할 수 있었을까? 라는 생각이 들면서 들을 수 있는 상황에 감사했다. (주변 개발자분들은 티켓팅에 전부 실패하심...)
김태완님의 "GraalVM과 스프링, 이상과 현실"을 시작으로 세션이 시작되었다.
GraalVM이라는 또 다른 형태의 JVM이 소개되었고 굉장히 새로웠다. 물론 아직 정식 릴리즈가 아니지만 곧 릴리즈 예정이라고 한다. 장점은 하나하나 자세히 설명할 순 없지만 간단히 나열하자면 High Performance, Polyglot, Native Code, AOT Compiler, Java로 만들어진 JVM 등... 아쉬운 점이 있다면 Reflection 미지원이다 보니 아직은 스프링에서 사용할 수 없다. 하지만 스프링쪽에서 관심을 가졌는지 5.1부터 GraalVM을 지원하기 위한 feature가 진행 중인 것으로 알고 있다. 그리고 실제로 Twitter에서는 JVM을 바꾼 것만으로도 20%이상의 성능 개선이 되었다는 내용도 있다고 한다.
Spring 4.x로 넘어오면서 지원하기 시작한 WebSocket에 대한 내용이다. 보통 WebSocket은 node진영에서 socket.io가 주로 언급되는데, Spring Camp에서 이 주제를 듣게되니 반갑기도 했다. 커넥션 횟수를 줄여 네트워크 비용을 줄일 수 있는 장점이 있는데 이 세션을 듣기 전까지 커넥션을 계속 유지하므로 비용이 클 수도 있지 않을까?라는 궁금증에 대한 해답도 얻을 수 있었다.
역시 영향력 있으신 분이다. 다른 세션보다 짧은 세션 시간임에도 많은 분들이 참석해주셨고 개인적으로 내용도 너무 좋았다. 특히, 언급하셨던 checker framework는 시간내서라도 찾아봐야겠다. 슬라이드도 다시 봐야지...
[일꾼단 슬랙 상황]
1 Track에 계신분이 43명이시면 나머지분들은 어디로...?
가장 인상 깊었던 말이 있다. "테스트할 수 없는 것으로 인해 테스트할 수 있는 것을 오염시키지말자" (맞나..?)
내용도 정말 좋았다. Test Double, 상호종속적이지 않은 테스트, Embedded System 사용, ... 하지만 나는 테스트를 잘 작성하지 않는다. 세션 내내 반성의 시간 그 자체인 듯하다. 반성하고 또 반성..
오늘부터 Test! 라면서 또 안하겠지..? 마치 오늘부터 다이어트! 라는 느낌이랄까...
Track 2
중에서 가장 재밌던(?) 세션이 아니였나 싶다. 그 만큼 공감되고 현실적인 내용이 포인트였다.
Spring 3.x에서 Spring Boot로, 성능 튜닝, 코드 개선 등 흥미진진한 얘기가 많았다. 당장 대다수의 개발자들이 레거시에 마주하며 몸부림치는 상황이기 때문에 더더욱 그랬을지도 모른다는 생각이 든다. 그 중 가장 으뜸은 replaceAll 6000개...
회사에서 캐싱용도로 redis, ehcache를 사용하고 있어서 EVCache는 무슨 장점이 있을까?라는 궁금증으로 세션을 듣게 되었다. EVCache는 Netflix에서 만든 분산 인메모리 데이터 저장소이다. 기억에 남는건 효율 극대화를 위해 hot data는 RAM(Memcached)에 cold data는 SSD(Mnemonic)에 유지시킨다고 한다.
종종 KSUG 페이스북에 작성하신 글을 공유해주시는 개발자분인데 최근에 작성하신 내용 관련해서 발표하신다. 앞서 말했듯 회사에서 사용하는 Redis, Ehcache와 무슨 차이가 있는지도 궁금했다. Hazelcast는 IMDG(In Memory Data Grid)이다. 이분이 Hazelcast를 선택한 결정적인 이유(?)는 Invalidation Message Propagation 기능 제공이라고 볼 수 있을 것 같다.
사내에서 Local Cache로 Ehcache, Distributed Cache로 Redis를 사용하는데 이에 대한 복잡성을 HazelCast로 해결할 수 있지 않을까란 생각이 들기도 한다.
Cluster 구성 시, 클라우드를 이용한다면 각각의 클라우드 기능을 활용한 플러그인도 제공한다.
나에게 있어 들을 것도 배울 것도 참 많은 Spring Camp는 역시나 옳다. 일꾼단이라는 색다른 경험도 했다. 물론 참여가 저조하여 함께한 분들과 많은 대화를 하지 못해 아쉽고 미안한 점이 더 많지만, 내가 좋아하는 행사에 조금이나마 보탬이 되었다는 생각에 뿌듯하기도 하다.
배움의 끝은 어디인가... GraalVM, WebSocket, Testing, EVCache, Hazelcast, Kotlin, ...
]]>데이터베이스에 한 번에 많은 데이터 써야 한다면 어떻게 해야할까?
아마 주변에서 제일 간단하게 찾을 수 있는 상황은 엑셀 파일 업로드가 있을 수 있다. 많게는 수 만건, 수 십만건까지 데이터베이스에 삽입(insert
)를 하는 것인데 열당 삽입하게 된다면 굉장히 오랜 작업이 될 수 있다.
이를 보통 BatchInsert
또는 BulkInsert
라고 말하는데, 여러 Insert 구문을 하나로 하나의 Insert 구문으로 작업하도록 하는 것을 의미한다. 그와 더불어 속도도 빠르다.
예를 들면, 다음 쿼리들이
INSERT INTO message (`content`, `status`, `created_by`, `created_at`,`last_modified_at`)
VALUES (:content, :status, :created_by, :created_at, :last_modified_at);
INSERT INTO message (`content`, `status`, `created_by`, `created_at`,`last_modified_at`)
VALUES (:content, :status, :created_by, :created_at, :last_modified_at);
INSERT INTO message (`content`, `status`, `created_by`, `created_at`,`last_modified_at`)
VALUES (:content, :status, :created_by, :created_at, :last_modified_at);
// ...
아래의 쿼리로 대체될 수 있다.
INSERT INTO message (`content`, `status`, `created_by`, `created_at`,`last_modified_at`)
VALUES (:content, :status, :created_by, :created_at, :last_modified_at)
, (:content, :status, :created_by, :created_at, :last_modified_at)
, (:content, :status, :created_by, :created_at, :last_modified_at)
, ...;
이전에 MySQL - 큰 테이블을 다루는 jdbc 활용법 ①를 분석하면서 작성했던 간단한 예제이다. 내용은 10만건의 데이터를 100개씩 batchInsert
하는 코드 이다.
MySQL Driver의 경우, rewriteBatchedStatements=true
옵션을 추가해야 한다.
spring.datasource.url=jdbc:mysql://localhost:3306/test?rewriteBatchedStatements=true
# ...
ApplicationRunner
는 애플리케이션 로딩 시에 최초 작업을 정의하는 인터페이스이다. 중점적으로 봐야한 곳은 jdbcTemplate.batchUpdate(...)
부분이다. 내부적으로 batchList
를 batchSize
만큼 처리해주고 있다. (참고)
private static final String INSERT_SQL = "INSERT INTO push_message (`content`, `status`, `created_by`, `created_at`, `last_modified_at`) VALUES (?, ?, ?, ?, ?)";
@Autowired
private JdbcTemplate jdbcTemplate;
@Bean
public ApplicationRunner runner() {
return args -> {
int insertCount = 100_000;
int batchSize = 100;
List<PushMessage> batchList = IntStream.range(0, insertCount)
.mapToObj(i -> new PushMessage("content" + i, "wait", "heowc", LocalDateTime.now(), LocalDateTime.now()))
.collect(Collectors.toList());
StopWatch stopWatch = new StopWatch();
stopWatch.start();
jdbcTemplate.batchUpdate(INSERT_SQL, batchList, batchSize, (ps, arg) -> {
ps.setString(1, arg.getContent());
ps.setString(2, arg.getStatus());
ps.setString(3, arg.getCreatedBy());
ps.setTimestamp(4, Timestamp.valueOf(arg.getCreatedAt()));
ps.setTimestamp(5, Timestamp.valueOf(arg.getLastModifiedAt()));
});
stopWatch.stop();
System.out.println(stopWatch.prettyPrint());
};
}
rewriteBatchedStatements | batch size | time(ms) |
---|---|---|
O | 200 | 2500ms |
O | 100 | 5000ms |
X | x | 200000ms |
역시나 하드웨어 사양, 환경마다 다르겠지만 rewriteBatchedStatements
여부에 따라 batch size
에 따라 속도 차이가 많이 났다.
이것도 역시 패킷을 분석해보면, Length가 16388인 패킷을 볼 수 있는데
net_buffer_length
의 기본값(16384)과 관련이 있다. 이를 적절히 늘리는 것도 속도 향상에 도움이 될 수 있다(?)
데이터베이스에서 한 번에 많은 데이터 읽어야 한다면 어떻게 해야할까?
간단한 통계 자료를 생각해보자.
우선, 통계 자료를 만들기 위해서는 축적된 데이터가 필요하다. 축적된 데이터는 10만 row가 될 수 도 있고, 1000만 row가 될 수 있고, 1억 row가 될 수 있다. 그리고 데이터를 기반으로 여러가지 결과를 도출해낼 것 이다. 그런데 사용자가 요청할 때마다 매번 결과를 도출해낼 것인가? 아니다. 하루에 한번이던, 한 주에 한번이던 지정한 시간에 맞춰 주기적으로 작업을 수행할 것 이다. 물론 요즘에는 이를 해결하기 위한 다양한 메커니즘과 오픈소스를 제공하기 때문에 굳이 MySQL 같은 RDB를 활용하지 않고도 가능하다. 하지만, 오히려 배보다 배꼽이 더 커질 수 있기 때문에 RDB로 이와 같은 작업을 해결해 보고자 한다. 보통 이를 batch-processing이라 한다.
그나마 아는 Spring Framework를 참고해보자.
Spring Framework은 많은 노하우와 모범 사례가 녹아있는 자바 프레임워크 중 하나로, 그 중, Spring Batch를 훑어보면 RDB를 활용하는 몇 가지 방법이 있다. 크게는 총 2가지를 지원하는데 Cursor를 활용하는 방법과 Paging 처리하는 방법이다.
글의 주제가 Spring이 아니기 때문에 'Cursor나 Paging을 활용하면 된다'정도 힌트만 가지고 다음 단계로 넘어가자. 또한 이 글에서는 Cursor만 활용해볼 것 이다.
데이터베이스 커서(Cursor)는 일련의 데이터에 순차적으로 액세스할 때 검색 및 "현재 위치"를 포함하는 데이터 요소이다. (참고: 데이터베이스 커서 - 위키백과)
여기서 고려해야할 점은 적절한 갯수만큼 row(데이터 요소)를 가져오는 것이다. 한번에 그 많은 row를 가져온다고 생각해보자. 아마도 메모리 사용량이 초과하여 OOM(Out Of Memory)가 발생할 것이다. 반대로 적은 갯수 가져온다고 생각해보자. 아마도 굉장히 오래 걸릴 것이다. 이를 설정할 수 있는 방법은 fetchSize
를 지정하는 것이다.
순수 JDBC를 사용하면 다음과 같이 지정할 수 있다.
Statement stmt = connection.createStatement("select ... from ...");
stmt.setFetchSize(fetchSize);
// ...
그럼 이제 설정이 끝난걸까? 간단하게 테스트를 해보자. 여러 값을 적용하여 소요시간을 측정해보면 될 것 이다.
10만 row가 들어있는 간단한 테이블을 준비 해놓고 Integer.MIN_VALUE, 5, 10, 50, 100, 500, 1000, 2500, 5000 순으로 측정해 보았다. 결과는 이상하게도 fetchSize
와 무관하게 서로 비슷한 소요시간이 측정되었다. (약 320 ~ 400ms)
여러 삽질을 하고 결국 문서를 통해 해답을 얻을 수 있었는데, 이는 바로 useCursorFetch=true
를 지정하는 것이다. (참고)
Connection connection =
DriverManager.getConnection("jdbc:mysql://localhost/?useCursorFetch=true", "...", "...");
Statement stmt = connection.createStatement("select ... from ...");
stmt.setFetchSize(fetchSize);
// ...
mysql-connector-java@8.0 - ResultsetRowsCursor.fetchMoreRows()를 보면 fetch가 어떻게 동작하는지 이해할 수 있다.
fetch size | time(ms) |
---|---|
Integer.MIN_VALUE | 682 |
5 | 12367 |
10 | 6401 |
50 | 1632 |
100 | 1115 |
500 | 606 |
1000 | 527 |
2500 | 508 |
5000 | 464 |
물론 하드웨어 사양, 환경마다 다르겠지만 fetchSize
에 따라 서로 다른 소요시간이 나온 것을 볼 수 있다. 앞서 예상했던 것 같이 너무 작으면 굉장히 오랜 시간이 소요되므로 적절한 값을 지정해야 한다.
앞에서 같이 삽질을 하지 않기 위해 소요시간 이외에 보다 디테일한 분석을 해보자. 메모리 사용량은? 패킷은?
Java Profiling Tool 중 하나인 VisualVM으로 대략적인 메모리 사용량을 측정해본 결과이다.
useCursorFetch=false
경우
useCursorFetch=true
경우
useCursorFetch=true
경우, 메모리 사용량이 더 적을 것을 볼 수 있다.
jdbc 프로토콜 패킷을 분석하기 위해 WireShark를 활용해본 결과이다.
useCursorFetch=false
경우
useCursorFetch=true
와 fetchSize=5
경우
useCursorFetch=true
와fetchSize=5
경우,fetchSize
만큼 데이터를 전달하기 때문에 네트워크 통신이 보다 많은 것을 볼 수 있다.
useCursorFetch
옵션이 없다. (다른 데이터베이스에도 없다.) 물론 fetchSize 적용 여부에 따라 메모리 사용량은 약간 다른 것을 볼 수 있는데, 이는 MariaDB JDBC 드라이버 자체에서 최적화 해주는 것이 아닐까 싶다.sslMode=DISABLED
추가하여 해당 Layer를 제거할 수 있었다.useCursorFetch=false
경우의 WireShark 스크린샷을 보면 length가 16388인 패킷을 볼 수 있는데, 이는 net_buffer_length
의 default(= 16384)와 유사한 것을 알 수 있다. 고로, 많은 데이터를 read하더라도 네트워크 통신 자체는 net_buffer_length
만큼 나눠서 하는 것을 알 수 있다.GitHub Action
에 대한 간단한 소개와 이를 활용한 GitHub Page 배포 방법를 얘기해보려 한다.
GitHub 이벤트(push, issue, release 등)를 트리거하여 개발 워크플로를 구성할 수 있는 GitHub 서비스이다. 기존에는 Travis CI
, Circle CI
등이 대표적인 서드파티 서비스라고 할 수 있으며, 이를 연동하여 정적 분석, 테스트, 빌드하는 것이 보통 이였다. 하지만 이제는 GitHub에서 직접 지원하기 시작하였다.
위에서 언급한 바와 같이 GitHub Action을 이용해서 배포 자동화(=지속적인 배포)를 해보려고 한다. push 한 번으로 GitHub Page에 게시물이 반영되는 것이 최종 목표이다.
GitHub Action를 사용하려면 해당 페이지에서 승인 과정을 거쳐야 한다. [click]
블로그는 Hexo로 만들었다.
게시글을 보관하기 위한 아카이빙용 레포지토리(Private Repository)와 실제 {GitHub ID}.github.io 에 반영될 GitHub Page용 레포지토리(Public Repository)가 있다.
아카이빙용 레포지토리를 Private Repository로 지정한 이유?
- 민감한 정보가 설정에 포함되어있다.
아직 베타 수준인 GitHub Action은 Public Repository에 지원이 제한되어있다.
※ 2019년 1월 7일 기준으로, 일반 사용자도 무료로 private 레포지토리를 만들 수 있다. (관련 기사)
간단하게 시퀀스 다이어그램으로 표현하면 아래와 같다.
① workflow를 push 이벤트에 트리거 될 수 있도록 설정한다. 보다 많은 이벤트를 설정할 수 있으니 공식 문서를 참고하면 좋다.
workflow "Blog Deploy" {
on = "push"
resolves = "Hexo Deploy"
}
② resoleves
으로 action을 지정할 수 있다. 그리고 resoleves
에 지정한 action 이전에 필요한 action은 각 action에서 needs
를 활용하면 된다.
workflow "Blog Deploy" {
on = "push"
resolves = "Hexo Deploy"
}
// ...
action "Hexo Deploy" {
needs = ["Hexo Generate"]
uses = "heowc/action-hexo@master"
args = "deploy"
// ...
}
uses
에 해당 action을 지정하면 된다.③ public repository에 deploy하는 방법은 기존과 동일하게 hexo-deployer-git
를 사용하면 된다. 기존에는 직접 hexo deploy
했다면, 이제는 push 마다 GitHub Action이 동작하여 이를 자동화해줄 것 이다.
deploy:
type: git
repo: https://{ACCESS TOKEN}@github.com/heowc/heowc.github.io.git
branch: master
Build
→ Algolia
→ Hexo Clean
→ Hexo Generate
→ Hexo Deploy
순차 처리된다.
workflow "Blog Deploy" {
on = "push"
resolves = "Hexo Deploy"
}
action "Build" {
uses = "actions/npm@master"
args = "install"
}
action "Hexo Clean" {
needs = ["Build"]
uses = "heowc/action-hexo@master"
args = "clean"
}
action "Hexo Generate" {
needs = ["Hexo Clean"]
uses = "heowc/action-hexo@master"
args = "generate"
}
action "Hexo Deploy" {
needs = ["Hexo Generate"]
uses = "heowc/action-hexo@master"
args = "deploy"
env = {
NAME = "heowc"
EMAIL = "heowc1992@gmail.com"
}
}
workflow에 대한 결과는 다음과 같이 확인해볼 수 있다.
]]>Amazon DynamoDB를 비용 지불없이 쉽게 테스트하는 방법을 소개하려고 한다.
우선, Amazon DynamoDB
가 무엇인지 찾아보자. 공식문서에는 다음과 같이 적혀있다.
Amazon DynamoDB는 종합 관리형 NoSQL 데이터베이스 서비스로서 원활한 확장성과 함께 빠르고 예측 가능한 성능을 제공합니다. DynamoDB를 사용하면 분산 데이터베이스를 운영하고 조정하는 데 따른 관리 부담을 줄일 수 있으므로 하드웨어 프로비저닝, 설정 및 구성, 복제, 소프트웨어 패치 또는 클러스터 조정...
개발 프로세스상 dev
, qa
, stage
, test
, prod
같이 영역을 나누어 구성하는 것이 보통이다. 그러다보니 각 영역마다 별도의 인스턴스가 필요하게 되고 불가피하게 클라우드 비용이 늘어나게 상황에 직면하게 된다. 하지만, Amazon DynamoDB는 인스턴스를 만들지 않고도 테스트 가능하도록 제공한다. 즉, 비용이 발생하지 않는다.
여기서
test
정도는 데이터가 휘발성으로 있어도 되는 부분이라 생각한다.
DockerHub에 amazon/dynamodb-local
퍼블릭 이미지로 제공되고 있다.
DynamoDB를 포함한 다른 서비스(ex. Amazon S3, Amazon RedShift, ...)를 쉽게 사용할 수 있도록 라이브러리를 제공한다. 이를 디펜던시로 추가한다.
compile('com.amazonaws:aws-java-sdk-dynamodb:1.11.466')
// ...
AmazonDynamoDB
빈 등록DynamoDB에 액세스할 클래스인 AmazonDynamoDB
를 빈으로 등록해준다.
@Configuration
public class DynamoDBConfig {
@Bean
public AmazonDynamoDB amazonDynamoDB(
@Value("${aws.region}") String region,
@Value("${aws.dynamo.endpoint}") String dynamoEndpoint,
@Value("${aws.access-key}") String accessKey,
@Value("${aws.secret-key}") String secretKey) {
return AmazonDynamoDBClientBuilder.standard()
.withEndpointConfiguration(new AwsClientBuilder.EndpointConfiguration(dynamoEndpoint, region))
.withCredentials(new AWSStaticCredentialsProvider(new BasicAWSCredentials(accessKey, secretKey)))
.build();
}
}
당연한 얘기지만 AmazonDynamoDB
를 빈으로 등록해두었기 때문에 @Autowired
를 활용하여 인스턴스를 주입받을 수 있다. 그리고 테스트를 작성하면 된다. 코드는 도커를 활용하든, 실제 AWS 인스턴스를 활용하든 동일하므로 AWS 공식문서의 예제를 참고하거나, 간단한 CRUD 예제가 있으니 참고하자.
@RunWith(SpringRunner.class)
@SpringBootTest
public class SpringBootDynamoApplicationTests {
// ...
@Autowired
private AmazonDynamoDB dynamoDB;
// ...
}
하지만, 테스트를 위해 매번 docker run -p 8000:8000 amazon/dynamodb-local
를 치는 것은 귀찮기도 하고 약간(?) 수동적이다. testcontainers
를 사용하면 이를 해결할 수 있다.
testcontainers
는 자바코드에서 docker container를 실행시켜주는 라이브러리이다. 직접 run
을 실행시킬 수 도 있고, Dockerfile
이나 docker-compose.yml
을 활용할 수도 있다. 물론, docker가 설치되어 있어야 한다.
testCompile('org.testcontainers:testcontainers:1.10.2')
// ...
AbstractIntegrationTest
작성다행히 Spring Boot에서 활용 가능한 예제 샘플을 제공한다. 아래 샘플을 참고하여 다음과 같이 작성해볼 수 있다. testcontainers/testcontainers-java-examples - AbstractIntegrationTest
// ...
public abstract class AbstractIntegrationTest {
private static final String DOCKER_IMAGE = "amazon/dynamodb-local";
private static final String DOCKER_TAG = "latest";
private static final int EXPOSED_PORT = 8000;
@ClassRule
public static GenericContainer dynamodb =
new GenericContainer(String.format("%s:%s", DOCKER_IMAGE, DOCKER_TAG)).withExposedPorts(EXPOSED_PORT);
public static class Initializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {
@Override
public void initialize(ConfigurableApplicationContext configurableApplicationContext) {
String endpoint = String.format("aws.dynamo.endpoint=http://%s:%s",
dynamodb.getContainerIpAddress(),
dynamodb.getMappedPort(EXPOSED_PORT));
// ...
예제에도 나와있듯이, 다른 docker container도 활용 가능하다.
// ...
public class SpringBootDynamoApplicationTests extends AbstractIntegrationTest {
// ...
}
Spring Boot를 안전하게 종료시키는 방법에 대한 소개이다.
※ 읽기 전 참고!! Spring Boot 2.3.0.RELEASE 이후에는
server.shutdown=graceful
속성을 추가하여 안전하게 종료시킬 수 있다. spring-boot-features.html#boot-features-graceful-shutdown
우선, Spring Boot를 종료시키기 내용을 언급하기 이전에 kill
이라는 리눅스 명령어에 대해 알아보자.
kill
은 의미하는 바와 같이 죽이는(?) 것과 연관이 있다. 이것은 프로세스를 죽이는 명령어으로 프로세스가 시작되면 부여되는 PID(프로세스 ID)를 활용하면 된다.
kill -9 PID
위 명령어는 프로세스를 종료시킬 때 사용하는 명령어로 많은 블로그나 스택오버플로우에서 가장 많이 언급되는 명령어이자 나 또한 주로 사용하던 명령어이다. 하지만 위 명령어는 권장하지 않는 방법이다.
여기서 숫자 9는 리소스를 정리하는 핸들러를 지정하지 않고 프로세스를 바로 죽이겠다는 의미이다. 만약, 실행 중인 쓰레드가 있더라도 이를 무시하고 중단하는데 혹시라도 굉장히 중요한 작업 중 이라면 최악의 상황이 일어날 수 있기 때문이다.
숫자는 9 이외에도 다른 숫자도 존재하며 다른 의미를 갖고 있다.
tomcat 종료 스크립트를 찾아보자.
$TOMCAT_HOME/bin/shutdown.sh
$TOMCAT_HOME/bin/catalina.sh stop
# https://github.com/apache/tomcat/blob/ffc4b76e42fd39d88c9417d0ba2b3d697c16f5b5/bin/catalina.sh#L543
kill -15 `cat "$CATALINA_PID"` >/dev/null 2>&1
대부분의 애플리케이션은 1(INT), 2(HUP), 15(TERM)를 이용하여 리소스를 정리하는 핸들러 코드를 실행하고 안전하게 종료가 가능하다. 일반적으로 15를 사용한다.
우선 편리한 테스트를 위해 긴 작업 상태를 유지하기 위한 메소드를 작성해보자.
@GetMapping
public String pause() throws InterruptedException {
Thread.sleep(5_000L);
return "Process finished";
}
그리고 긴 작업 중간에 Spring Boot 웹 애플리케이션을 종료시켜 보겠다. 추측으로는 5초를 기다렸다가 'Process finished'라는 문구가 표시될 것이라고 예상된다. 하지만, 예상대로 동작하지 않는다.
curl http://localhost:8080
5000ms 이내에 아래 명령어 실행하자.
kill -15 PID
그 이유는 리소스를 정리할 핸들러는 주어졌지만 Spring Boot에서 리소스를 정리하는 핸들러 코드가 존재하지 않기 때문이다.
ContextClosedEvent
를 활용해서 리소스를 정리하는 핸들러 코드를 추가 할 수 있고, 이 방법은 'Marcos Barbero's Blog'를 참고하였다. 간단히 설명하자면, request에 활용되는 ThreadPool를 리소스가 정리될 때까지 기다린 후 내린다. 그 이후에도 내려가지 않는다면 강제로 내려버리는 코드이다.
@Override
public void onApplicationEvent(ContextClosedEvent event) {
this.connector.pause();
Executor executor = this.connector.getProtocolHandler().getExecutor();
if (executor instanceof ThreadPoolExecutor) {
try {
ThreadPoolExecutor threadPoolExecutor = (ThreadPoolExecutor) executor;
threadPoolExecutor.shutdown();
if (!threadPoolExecutor.awaitTermination(TIMEOUT, TimeUnit.SECONDS)) {
log.warn("Tomcat thread pool did not shut down gracefully within "
+ TIMEOUT + " seconds. Proceeding with forceful shutdown");
threadPoolExecutor.shutdownNow();
if (!threadPoolExecutor.awaitTermination(TIMEOUT, TimeUnit.SECONDS)) {
log.error("Tomcat thread pool did not terminate");
}
}
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
}
}
}
PID는 어디서 얻을 수 있을까?
여러가지 방법이 있겠지만 Spring Boot에서는 ApplicationPidFileWriter
라는 클래스를 제공해주고 있으며 이를 이용히여 pid가 담긴 파일을 만들어준다. 자세한 내용은 공식 문서를 참고하자.
public static void main(String[] args) {
SpringApplication application = new SpringApplicationBuilder()
.sources(SpringBootGracefulShutdownApplication.class)
.listeners(new ApplicationPidFileWriter("./application.pid"))
.build();
application.run(args);
}
application.pid
는 jar파일과 같은 경로에 만들어진다.
키워드를 나열해 보자면 오픈소스
, 컨트리뷰터
, 아쉬움
가 될 수 있을 것 같다. 그리고 이 키워드를 가지고 간략하게 몇 자 적어보려 한다.
처음으로 npm에 내 이름으로 오픈소스를 publish
했다!
간단히 설명하자면, 자바스크립트로 code demo를 만들어주는 라이브러리(glorious-demo)를 이 블로그('Spring Boot - 시작하기')에서 손쉽게 사용할 수 있도록 만든 hexo 태그 라이브러리이다. 물론, hexo를 사용한다면 누구나 손쉽게 사용할 수 있다. (링크)
처음에는 실행 코드를 javascript를 직접 넣기도 했었다. 하지만 markdown에 javascript를 넣은 것도 보기 이상하고... 너무 불편하다.
'개발자는 약간의 귀차니즘이 있어야 한다.'라고 했던가...
그래서 끄적여 봤다.
Issue
을 처리할 겸 메인테이너에게 hexo 라이브러리를 만들어도 되는지 동의를 구했고 긍정적인 답변도 받을 수 있었다. Issue
부터 Pull Request
까지 몇 마디 못 보탰지만, "지구 반대편에 사는 사람과 개발 관련 얘기를 다 해보는구나..!"라는 생각에 너무 짜릿했다.
(참고: glorious-codes/glorious-demo/issues#36)
하지만 200줄 정도 밖에 안되는 오픈소스임에도 사소한 우여곡절도 있었다. npm에 문서만 수정 하려다가 실수로 unpublish
를 날려서 30분도 안되서 오픈소스가 내려가 버린 것 이다. 버전 사용도 못하는 상황을 경험했다. (심지어 24시간동안 publish
도 안된다...)
올해, 2개의 문서 수정과 1개의 이슈 제보를 했다. 이슈 제보는 #오픈소스에서 이미 언급을 했다.
하면서 느낀 점이 있다면 영어의 부족함이다. 정말 쉬운 영어를 쓰는 것도 구글의 힘을 의존할 수 밖에 없었고 나 자신이 너무 초라해 보였다. 물론 하는 동안 재미도 있었고, 프로젝트에 기여 할 수 있다는 것에 대한 기쁨도 느낄 수 있었다. 굉장히 좋은 경험이고 앞으로도 이런 경험을 많이 하고 싶다.
+) 구글 짱!
2018년 목표가 있었다. Hashicorp 제품을 훑어보는 것 이였고, 결과로만 보자면 그 중 절반도 보지 못했다. (Vagrant, Terraform, ...) 너무 막연하고 계획도 없었다고 생각한다.
역시 회고는 나를 까는 맛에 하는... 음?
2018년 말 임에도 불구하고 '[devops] hashicorp 제품 맛보기'는 아직 closed
하지 못했다.
관심있다고 다 할 수 있는 것이 아니다. 적당한 선에서 포기할 줄도 알아야 한다고 생각한다. 아직 주니어인 나는 영어와 애플리케이션 아키텍처, 클린 코드, ... 마지막으로 업무에 주로 사용하는 Spring만 해도 벅찰 것 이다. 그런 의미에서 Hashicorp 제품은 2019년에도 업무상 필요하지 않고선 볼 일이 없을 듯 싶다. (언젠간 closed
하겠지..?)
진짜 컨트리뷰터가 되는 것은 어쩌면 소스 코드에 내 이름 한 줄을 추가하는 것이 아닐까 싶다. 요즘 주니어 또는 예비 개발자들이 오픈소스를 참여하려고 많이 노력한다. 나 또한 이제 걸음마를 떼고 있고 지속적인 관심과 노력할 것 이다.
Spring 프로젝트에 내 이름이 들어가는 그 날까지...
feedly를 이용하여 관심 기술 내용을 훑어보는데 요즘은 너무 대강 보는게 아닌가 싶다. 그래서 영어공부도 할 겸 "일주일에 하나 정도는 제대로 읽어보자"라는 생각이 들었고 이를 실천해보려 한다. (가끔은 번역도 좀 해보고...)
2018년에는 여러 기회를 통해 나름 책을 많이 읽었다고 생각한다. 그리고 2019년에도 어떤 방법으로든 총 6권 이상을 목표로 꾸준히 읽어나갈 예정이다.
이펙티브 자바 3rd, 엔터프라이즈 애플리케이션 아키텍처 패턴, 도메인 주도 설계, ...
2018년 초, 15분 공부 오픈채팅방을 우연히 들어가서 좋은 습관을 가지게 되었다. 각자 공부를 하고 (양심적으로) 인증을 하는 방식인데 나름 자극도 되고 여러 분야의 정보도 얻을 수 있었다. Android, Web, ... 요즘은 Flutter 얘기가 핫하다. 현재 3기가 진행 중 이고, 1기를 제외하곤 제대로 하지 못 해서 2019년에는 다시 열심히 성실히 해봐야겠다.
같이 진행했던 요콩님의 1기 후기(http://ykyh.tistory.com/9)
]]>hexo-tag-gdemo는 glorious-demo를 hexo에서 손쉽게 사용할 수 있도록 작성한 태그 플러그인이며, glorious-demo는 터미널이나 에디터에 코드를 타이핑하는 일련의 동작을 시연하는 자바스크립트 라이브러리이다.
Hexo가 뭔지 모르신다면? 클릭
npm install @heowc/hexo-tag-gdemo
최대한 단순하게 만들기 위해 2가지 타입(gdemo_terminal
, gdemo_editor
)과 기본적인 옵션만 넣을 수 있도록 구성했다.
gdemo_terminal
태그
'250px' 'bash' '500' '$' 'demo-teriminal'
는 생략 가능하다.
{% gdemo_terminal 'node ./demo' '250px' 'bash' '500' '$' 'demo-teriminal' %}
Hello World!
{% endgdemo_terminal %}
gdemo_terminal
태그[command]는 ';'을 기준으로 여러 [command]로 나눠서 표현할 수 있다.
{% gdemo_terminal 'cd /usr/bin;node ./demo' '250px' 'bash' '500' '$' 'demo-teriminal' %}
Hello World!
{% endgdemo_terminal %}
gdemo_editor
태그
'250px' 'bash' '500' 'demo-editor'
는 생략 가능하다.
{% gdemo_editor '250px' 'bash' '500' 'demo-editor' %}
function greet(){
console.log("Hello World!");
}
greet();
{% endgdemo_editor %}
테마로 인해 CSS가 깨져서 예외적인 스타일를 추가했다.
.desktop {
height: 250px;
width: 600px;
margin: 0 auto 50px;
}
Spring Camp 2018 후기이다.
작년에 이어 Spring Camp를 다녀왔다. 올해는 MSA가 주된 주제이다.
의도치 않게 지금 회사에서는 업무로 자바를 사용하고 있지 않지만, 회사에서 관심을 갖고 있는 영역(Java, MSA 등)이기에 공유할 겸 참여하게 되었다. 물론, 나 또한 아직 관심을 갖고 있다.
사실 후기랄 건 없고, 회사에서 발표한 자료를 공유하고자 한다. 앞서 말했듯이, 회사에서는 아직 자바나 Spring, Spring Cloud을 쓰고 있지 않기 때문에 간단하게 작성할 목적도 있었지만 관련 레퍼런스를 가기 3~4주 정도 밖에 못 보고 참여했던터라 내용이 많이 부족하다.
주로 Spring Cloud
관련 내용을 공유했다. 보다 많은 자료를 원한다면, 약 3개월 뒤에 자료가 오픈된다고 하니 참고하길 바란다.
Java 8에서는 새로운 날짜 API가 추가되었다.
기존 레거시 코드 중, Date
가 있다. 물론 Calender
를 사용하면 되지만 Java 8에서 새로운 Time API가 나왔으니 이를 써보도록 하자. 핵심 클래스는 다음과 같다.
LocalTime
LocalDate
LocalDateTime
ZoneDateTIme
...
간단히 API를 살펴보기로 하자. 개인적인 생각이지만 기존 관련 API보다 다양한 메소드를 제공하고 사용하기 편하다는 느낌을 받았다.
LocalTime.now(); // ex) 20:00:00
LocalDate.now(); // ex) 2018-03-18
LocalDateTime.now(); // 2017-03-18T20:00:00
LocalTime.of(20, 0, 0); // 20:00:00
LocalDate.of(2018, 3, 18); // 2017-02-07
LocalDateTime(LocalDate.of(2018, 3, 18), LocalTime.of(20,0,0)); // 2017-02-07T20:00:00
withXXXX()
를 이용하여 값을 변경할 수 있다.
LocalDateTime localDateTime = LocalDateTime.now(); // 2018-03-18T20:00:00
localDateTime.withYear(2016); // 2016-03-18T20:00:00
plusXXXX()
, minusXXXX()
를 이용하여 연산이 가능하다.
LocalDateTime localDateTime = LocalDateTime.now(); // 2018-03-18T20:00:00
localDateTime.plusDays(1); // 2018-03-19T20:00:00
localDateTime.minusDays(1); // 2018-03-17T20:00:00
isAfter()
, isBefore()
, isEqual()
등 이 있다.
LocalDateTime localDateTime = LocalDateTime.now(); // 2018-03-18T20:00:00
LocalDateTIme compareDateTime = localDateTime.plusDays(1); // 2018-03-19T20:00:00
localDateTime.isBefore(compareDateTime); // true
그 외에도 간단한 메소드들을 제공한다.
LocalDateTime localDateTime = LocalDateTime.now(); // 2017-02-07T20:00:00
localDateTime.getYear(); // 2017
localDateTime.getDayOfYear(); // 38 (년 기준으로 38일째)
localDateTime.getDayOfMonth(); // 7 (달 기준으로 7일째)
localDateTime.getDayOfWeek(); // TUSEDAY (주 기준으로 화 요일)
localDateTime.getMonth(); // FEBRUARY
localDateTime.getMonthValue(); // 2
localDateTime.isLeapYear(); // false (윤년 여부)
Period
와 Duration
Period
와 Duration
는 날짜의 차이를 표현해주는 클래스이다. 차이가 있다면, Period
는 기간(년,월,일)을 구할 때 사용 할 수 있고, Duration
은 Period
보다 세세하게 시간, 분, 초 단위까지 가능하다.
LocalDate localDate = LocalDateTime.now(); // 2017-02-07
LocalDate compareDate = localDate.plusDays(1) // 2017-02-08
// 1.
Period period = localDateTime.until(compareDate)
period.getDays() // 1
// 2.
Period period = Period.between(locaDate, compareDate)
period.getDays() // 1
LocalDateTime localDateTime = LocalDateTime.now() // 2017-02-07T20:00:00
LocalDateTime compareDateTime = localDateTime.plusDays(1) // 2017-02-08T20:00:00
Duration duration = Duration.between(localDateTime, compareDateTime)
duration.toMinutes() // -1 * (24 * 60) -> 하루 차이를 분 단위로 표시
TemporalQuery<R>
를 이용하여 사용자 정의 기능을 사용할 수 있다.
// ex) 2월이라면 참, 아니면 거짓
TemporalQuery<Boolean> query = t -> t.get(ChronoField.MONTH_OF_YEAR) == Month.FEBRUARY.getValue();
LocalDateTime localDateTime = LocalDateTime.now() // 2018-03-18T20:00:00
localDateTime.query(query) // true
기존(Date
, Calender
) 클래스에서는 형식 클래스인 SimpleDateFormat
로 패턴을 변경했지만, DateTimeFormatter
로 패턴을 변경할 수 있습니다.
LocalDate localDate = LocalDate.of(2017, 2, 7) // 2017-02-07
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy년 MM월 dd일")
localDate.format(formatter) // 2017년 02월 07년
이외에도 TemporalAdjusters
를 이용하여 보다 세부적인 날짜 처리를 해볼 수 있다.
Java8Sample - Java8TimeAPI
브라우저 초기에 보안상의 이유로 스크립트 내에서 시작된 교착 출처 HTTP 요청을 제한하는데, 이를 SOP(Same-Origin Policy, 동일 출처 정책)라 한다.
SOP는 두 Origin 간에 프로토콜, 포트, 호스트가 같아야 동일 Origin라고 할 수 있다.
예를 들어, http://www.heowc.com
이라는 URL이 있다면 다음과 같은 상황이 발생한다.
그래서 이를 보완하기 위해 브라우저측에서 JSONP를 사용하거나, 서버측에서 CORS를 이용하여 해결할 수 있다. 여기서 CORS(Cross-Origin Resource Sharing) 란, 웹 서버 도메인간 액세스 제어 기능을 제공하여 보안 도메인간 데이터 전송을 가능하게 해준다.
우선, 서버에서는 브라우저에 다음과 같은 키를 header에 보내줘야 한다.
Access-Control-Allow-Orgin
: 요청을 보내는 페이지의 출처 (*, 도메인)Access-Control-Allow-Methods
: 요청을 허용하는 메소드 (Default : GET, POST, HEAD)Access-Control-Max-Age
: 클라이언트에서 pre-flight의 요청 결과를 저장할 시간 지정. 해당 시간 동안은 pre-flight를 다시 요청하지 않는다.Access-Control-Allow-Headers
: 요청을 허용하는 헤더그리고 Spring과 Spring Boot에서는 아래의 2가지 방법으로 CORS를 해결할 수 있다.
개별적으로 허용하는 방법으로는 @CrossOrigin
를 사용하는 것이다.
@CrossOrigin("*")
@GetMapping("{value}")
public String get(@PathVariable String value) {
return value;
}
WebMvcConfigurer
를 구현하거나 다음과 같이 @Bean
으로 등록하여 addCorsMappings
에 원하는 path를 추가하면 된다.
@Bean
public WebMvcConfigurer webMvcConfigurer() {
return new WebMvcConfigurer() {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/message/**")
.allowedOrigins("*")
.allowedMethods(HttpMethod.POST.name())
.allowCredentials(false)
.maxAge(3600);
}
};
}
Google Keep: 구글 Extension. URL만 별도로 모아둘 수 있는 서비스이다.
Pocket: Google Keep과 유사한 서비스이다.
Json Formatter: JSON 데이터를 브라우저상에서 깔끔하게 보여준다.
LiveReload: IDE와 브라우저간의 HTML 수정시 동기화 해준다.
Vue.js devtools: Vue 개발 Extension
React Developer Tools: React 개발 Extension
Gmail Checker: Gmail 메일 알람을 노티로 띄워준다.
octotree: GitHub 관련 Extension. 좌측 사이드바 메뉴가 생기며 해당 repository 구조를 볼 수 있다.
gitlab tree: GitLab 관련 Extension. 좌측 사이드바 메뉴가 생기며 해당 repository 구조를 볼 수 있다.
Color Picker: 색상코드를 알려준다
Wappalyzer: 해당 URL에서 사용하는 spec을 보여준다.
http://troy.labs.daum.net/: 모바일 기기의 해상도를 한번에 테스트해볼 수 있는 페이지
https://www.ssllabs.com/: SSL 확인
]]>SimpleAsyncTaskExecutor
는 Thread Pool이 아니다.
SimpleAsyncTaskExecutor
는 단순히 Thread를 계속 만들어내는 객체이다. Thread는 자원이 많이 들기 때문에 가급적이면 Thread Pool 관리하에 사용이 되어야 한다.
그렇다면, 이를 확인해보자. 일단 가시화를 해보기 위해 VisualVM을 사용해보고자 한다.
기본적인 설정은 Spring Boot - Async에서 간략하게 다뤄봤다. 보다 자세한 내용을 원한다면 공식 문서나 가이드를 참고하기를 권장한다.
@Override
public Executor getAsyncExecutor() {
return new SimpleAsyncTaskExecutor("heowc-async-");
}
테스트는 10번의 HTTP 호출해본다. 당연한 결과이지만 앞서 말한바와 같이 10개의 Thread가 생성된 것을 볼 수 있다.
그렇다면 Thread Pool을 만들면 어떤 결과가 나오게 될까?
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(500);
executor.setThreadNamePrefix("heowc-async-");
executor.initialize();
return executor;
}
앞서 했던 테스트와 동일하게 진행 했다. 결과는 10개 보다 작은 Thread를 생성하게 된다.
AsyncRestTemplate
는 RestTemplate
를 비동기로 처리하기 위한 방법이다. (Spring 5에서는 deprecated되었고 WebClient
를 사용해야 한다.)
AsyncRestTemplate
또한 Async와 동일하게 SimpleAsyncTaskExecutor
를 기본적으로 사용한다. 물론 Thread Pool를 만들어주는 것도 좋지만, AsyncRestTemplate
를 보다 효율적으로 사용하기 위해서는 NIO 라이브러리를 사용하는 것이 좋다.
NIO 라이브러리는 Apache와 Netty에서 제공한다.
dependencies {
compile('org.apache.httpcomponents:httpasyncclient:4.1.3')
compile('io.netty:netty-all:4.1.11.Final')
}
Apache에서 제공해주는 것은 HttpCoponentsAsyncClientHttpRequestFactory
를 사용하면 되고, Netty에서 제공해주는 것은 Netty4ClientHttpRequestFactory
를 이용하면 된다.
@Bean
public AsyncRestTemplate asyncRestTemplate() {
// return new AsyncRestTemplate(new Netty4ClientHttpRequestFactory());
return new AsyncRestTemplate(new HttpComponentsAsyncClientHttpRequestFactory());
}
SimpleAsyncTaskExecutor
사용
HttpCoponentsAsyncClientHttpRequestFactory
사용
비동기 통신: 자료를 일정한 크기로 정하여 순서대로 전송하는 자료의 전송방식(참고 : 비동기 전송방식)
Spring 3.1 이후에 사용 가능 하다. (spring-context
에 포함되어 있다.)
dependencies {
compile('org.springframework.boot:spring-boot-starter-web')
testCompile('org.springframework.boot:spring-boot-starter-test')
}
@EnableAsync
만 추가하면 기본적인 설정은 끝이다.
하지만, 기본값인 SimpleAsyncTaskExecutor
클래스는 매번 Thread를 만들어내는 객체이기 때문에 Thread Pool이 아니다. Thread Pool을 설정해기 위해 AsyncConfigurerSupport
를 상속받아 재구현하자.
@Configuration
@EnableAsync
public class AsyncConfig extends AsyncConfigurerSupport {
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(500);
executor.setThreadNamePrefix("heowc-async-");
executor.initialize();
return executor;
}
}
비동기 작업을 하기 위한 메소드에 @Async
를 추가하면 된다. 만약 callback이 필요하다면, Future
클래스 등의 객체로 감싸서 반환하면 된다.
@Service
public class BasicServiceImpl implements BasicService {
private static final Logger logger = Logger.getLogger(BasicServiceImpl.class);
@Async
@Override
public void onAsync() {
try {
Thread.sleep(1000);
logger.info("onAsync");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
@Override
public void onSync() {
try {
Thread.sleep(1000);
logger.info("onSync");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
Test Case로는 비동기를 확인할 수 없기 때문에(?) HTTP 요청으로 이를 확인해보자.
@RestController
public class BasicController {
@Autowired
private final BasicService service;
private static final Logger logger = Logger.getLogger(BasicController.class);
@GetMapping("/async")
public String goAsync() {
service.onAsync();
String str = "Hello Spring Boot Async!!";
logger.info(str);
logger.info("==================================");
return str;
}
@GetMapping("/sync")
public String goSync() {
service.onSync();
String str = "Hello Spring Boot Sync!!";
logger.info(str);
logger.info("==================================");
return str;
}
}
http://localhost:8080/async
~ : Hello Spring Boot Async!!
~ : ==================================
~ : onAsync
http://localhost:8080/sync
~ : onSync
~ : Hello Spring Boot Sync!!
~ : ==================================
Process: 운영체제에서 하나의 어플리케이션 Thread: Process에서 하나의 작업
여러 Thread를 동시에 만들어 실행(병렬처리)할 수 있다. Java에 경우, Thread
, Runnable
를 이용해야 한다.
동시성은 싱글 코어에서 멀티 스레드를 동작시키기 위한 방식으로 멀티 태스킹을 위해 여러 개의 스레드가 번갈아가면서 실행되는 성질을 말한다. 동시성을 이용한 싱글 코어의 멀티 태스킹은 각 스레드들이 병렬적으로 실행되는 것처럼 보이지만 사실은 번갈아가면서 조금씩 실행되고 있는 것이다.
병렬성은 멀티 코어에서 멀티 스레드를 동작시키는 방식으로, 한 개 이상의 스레드를 포함하는 각 코어들이 동시에 실행되는 성질을 말한다. 병렬성은 데이터 병렬성(Data parallelism)과 작업 병렬성(Task parallelism)으로 구분된다.
데이터 병렬성은 전체 데이터를 쪼개 서브 데이터들로 만든 뒤, 서브 데이터들을 병렬 처리하여 작업을 빠르게 수행하는 것을 말한다. 자바 8에서 지원하는 병렬 스트림이 데이터 병렬성을 구현한 것이다. 서브 데이터는 멀티 코어의 수만큼 쪼개어 각각의 데이터들을 분리된 스레드에서 병렬 처리한다.
작업 병렬성은 서로 다른 작업을 병렬 처리하는 것을 말한다. 대표적인 예는 웹 서버로, 각각의 브라우저에서 요청한 내용을 개별 스레드에서 병렬로 처리한다.
≪신용권 - ‘이것이 자바다’≫
그렇다고 해서 Thread를 계속 늘려가는 건 좋은 것 일까? 당연히 아니다. 하드웨어의 제한적인 사항(CPU, Memory 등)이 있기 때문에 관리할 필요가 있다. 그래서 Thread Pool이라는 개념을 이용한다.
Thread Pool은 작업 처리에 사용되는 스레드를 제한된 개수만큼 정해 놓고 작업 큐(Queue)에 들어오는 작업들을 하나씩 스레드가 맡아 처리하는 것을 말한다. Java에 경우, 기본적으로 Executors
, ExecutorService
를 이용하여 Thread Pool를 만들 수 있습니다.
ExecutorService executorService = Executors.newSingleThreadExecutor();
ExecutorService executorService = Executors.newFixedThreadPool(int nThreads);
ExecutorService executorService = Executors.newCachedThreadPool();
ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(int corePoolSize);
ExecutorService executorService = Executors.newWorkStealingPool(int parallelism);
AOP는 Aspect Orient Programming 관점 지향 프로그래밍으로, 기능을 비지니스 로직과 공통 모듈로 구분한 후에 필요한 시점에 비지니스 로직에 삽입하여 실행되게끔 도와준다.
실 예로, @Transactional
, @Cache
같은 애노테이션들은 AOP를 활용하여 동작하게 된다.
JoinPoint
: 모듈의 기능이 삽입되어 동작할 수 있는 실행 가능한 특정 위치PointCut
: 어떤 클래스의 어느 JoinPoint를 사용할 것인지를 결정Advice
: 각 JoinPoint에 삽입되어져 동작할 수 있는 코드Interceptor
: InterceptorChain 방식의 AOP 툴에서 사용하는 용어로 주로 한개의 호출 메소드를 가지는 AdviceWeaving
: PointCut에 의해서 결정된 JoinPoint에 지정된 Advice를 삽입하는 과정(CrossCutting) Introduction
: 정적인 방식의 AOP 기술Aspect
: PointCut + Advice + (Introduction)JDK DynamicProxy를 이용하여 AOP 기능을 사용할 수 있지만 가장 많이 사용되는 AspectJ
를 사용해보도록 한다.
dependencies {
compile('org.springframework.boot:spring-boot-starter-web')
compile('org.springframework.boot:spring-boot-starter-aop')
testCompile('org.springframework.boot:spring-boot-starter-test')
}
@Configuration
@EnableAspectJAutoProxy
public class AspectJConfig {
}
구성요소들을 적절히 활용하여 로그를 찍어볼 수 있도록 Aspect
를 만든다.
@Aspect
@Component
public class TestAspect {
private static final Logger logger = LoggerFactory.getLogger(TestAspect.class);
@Before("execution(* com.example.service.*.*Aop(..))")
public void onBeforeHandler(JoinPoint joinPoint) {
logger.info("=============== onBeforeThing");
}
@After("execution(* com.example.service.*.*Aop(..))")
public void onAfterHandler(JoinPoint joinPoint) {
logger.info("=============== onAfterHandler");
}
@AfterReturning(pointcut = "execution(* com.example.service.*.*Aop(..))",
returning = "str")
public void onAfterReturningHandler(JoinPoint joinPoint, Object str) {
logger.info("@AfterReturning : " + str);
logger.info("=============== onAfterReturningHandler");
}
@Pointcut("execution(* com.example.service.*.*Aop(..))")
public void onPointcut(JoinPoint joinPoint) {
logger.info("=============== onPointcut");
}
}
딱히 Controller
와 Service
가 필요한 것은 아니지만, 로그 결과를 보기 위해서 간단하게 만들어 보자.
@RestController
public class TestController {
@Autowired
private TestService service;
@GetMapping(value = "/noAop")
public String noAop(){
return service.test();
}
@GetMapping(value = "/aop")
public String aop(){
return service.testAop();
}
}
@Service
public class TestServiceImpl implements TestService {
private static final Logger logger = LoggerFactory.getLogger(TestServiceImpl.class);
@Override
public String test() {
String msg = "Hello, Spring Boot No AOP";
logger.info(msg);
return msg;
}
@Override
public String testAop() {
String msg = "Hello, Spring Boot AOP";
logger.info(msg);
return msg;
}
}
http://localhost:8080/aop
~: =============== onBeforeThing
~ : Hello, Spring Boot AOP
~ : =============== onAfterHandler
~ : @AfterReturning : Hello, Spring Boot AOP
~ : =============== onAfterReturningHandler
http://localhost:8080/noAop
~ : Hello, Spring Boot No AOP
Interceptor는 가로채는 것, 요격기 라는 뜻이다.
다시 말해서, Url Mapping된 Controller를 거치는 전, 후 처리를 할 수 있도록 도와주는 요소를 말하며 세션 검증, 로그 처리 같은 행위가 간단한 예시가 될 수 있다.
Interceptor는 spring-webmvc
에 포함되어 있다. Spring Boot에서는 spring-boot-starter-web
을 가져옴으로 해결할 수 있다.
dependencies {
compile('org.springframework.boot:spring-boot-starter-web')
testCompile('org.springframework.boot:spring-boot-starter-test')
}
기본 인터페이스는 HandlerInterceptor
이고, 이를 구현할 수 해도 되지만, 추상 클래스인 HandlerInterceptorAdapter
를 구현할 수도 있다. Java 1.8 부터는 굳이 HandlerInterceptorAdapter
를 이용하지 않아도 된다. 하지만, 예시를 위해 모두 구현 해본다.
@Component
public class HttpInterceptor extends HandlerInterceptor {
private static final Logger logger = Logger.getLogger(HttpInterceptor.class);
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) {
logger.info("================ Before Method");
return true;
}
@Override
public void postHandle( HttpServletRequest request,
HttpServletResponse response,
Object handler,
ModelAndView modelAndView) {
logger.info("================ Method Executed");
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex) {
logger.info("================ Method Completed");
}
}
preHandle()
: 맵핑되기 전 처리를 해주면 됩니다.postHandle()
: 맵핑되고난 후 처리를 해주면 됩니다.afterCompletion()
: 모든 작업이 완료된 후 실행 됩니다.Interceptor를 등록하기 위해서 WebMvcConfigurer
를 이용한다. Interceptor를 등록한 후 적용할 경로, 제외할 경로를 지정해줄 수 있다.
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Autowired
@Qualifier(value = "httpInterceptor")
private HandlerInterceptor interceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(interceptor)
.addPathPatterns("/**")
.excludePathPatterns("/user/**");
}
}
Test를 위해 콘솔에 해당 로그를 찍어보자.
http://localhost:8080
http://localhost:8080/user
~~ : Hello, User!
~~ : ================ Before Method
~~ : Hello, Spring Boot Interceptor
~~ : ================ Method Executed
~~ : ================ Method Completed
Interceptor 후처리를 이용하여 값을 가공하거나 header에 키값을 추가하는 등의 작업을 할 수 없다. (해도 header에서 해당 키값을 찾아볼 수 없다.) 이런 경우에는 ResponseBodyAdvice
를 구현해야 한다.(참고)
캐시(cache, 문화어: 캐쉬, 고속완충기, 고속완충기억기)는 컴퓨터 과학에서 데이터나 값을 미리 복사해 놓는 임시 장소를 가리킨다.
Spring의 장점 중 PSA(Portable Service Abstractions)라는 것이 있다. 이는 쉬운 서비스 추상화라고 하는데, 각각의 외부 서비스를 간단한 인터페이스만으로 쉽게 사용할 수 있도록 설계되어있다. Cache에서는 Redis, Ehcache, ConcurrentMap 등을 CacheManager
인터페이스로 추상화되어 있고, CacheManager
인터페이스를 이용하여 또 다른 cache 라이브러리를 사용할 수도 있다.
아래 그림과 같은 메뉴가 단적인 예시가 될 수 있다.
dependencies {
compile('org.springframework.boot:spring-boot-starter-cache')
testCompile('org.springframework.boot:spring-boot-starter-test')
}
@EnableCaching
을 명시해주면 바로 사용할 수 있고 추가적인 설정이 없다면 ConcurrentMap
를 사용하여 Caching하게 된다. 또한 Redis나 Ehcache 라이브러리를 추가하면 Spring Boot의 Auto Detect 기능으로 인해 해당 라이브러리를 자동적으로 이용하게 된다.
@Configuration
@EnableCaching
public CacheConfig {
}
무거운 비즈니스 로직이 있다고 가정하고 약 3초의 sleep을 주도록 해보자. 아래 코드를 간단히 설명하자면, book이라는 캐시 영역에 isbn을 키로 갖는 데이터를 Caching 해두는 것이다.
@Component
public class SimpleBookRepository implements BookRepository {
private static final Logger logger = LoggerFactory.getLogger(SimpleBookRepository.class);
@Override
@Cacheable(value="book", key="#isbn")
public Book getByIsbn(String isbn) {
simulateSlowService();
return new Book(isbn, "Some book");
}
private void simulateSlowService() {
try {
long time = 3000L;
Thread.sleep(time);
} catch (InterruptedException e) {
throw new IllegalStateException(e);
}
}
@Override
@CacheEvict(value="book", key="#isbn")
public void refresh(String isbn) {
logger.info("cache clear => " + isbn);
}
}
@Cacheable
: 캐시 생성
@CacheEvict
: 캐기 초기화
로직에 대한 소요시간을 측정해 보았다.
@RunWith(SpringRunner.class)
@SpringBootTest
public class SpringBootCacheApplicationTests {
@Autowired
private BookRepository repository;
private long startTime;
private long endTime;
private static final Logger logger = LoggerFactory.getLogger(SpringBootCacheApplicationTests.class);
@Before
public void onBefore() {
startTime = System.currentTimeMillis();
}
@After
public void onAfter() {
endTime = System.currentTimeMillis();
logger.info("소요시간: {}ms", endTime - startTime);
}
@Test
public void test1() {
repository.getByIsbn("a");
}
@Test
public void test2() {
repository.getByIsbn("a");
}
@Test
public void test3() {
repository.getByIsbn("b");
}
@Test
public void test4() {
repository.getByIsbn("a");
}
@Test
public void test5() {
repository.refresh("a");
repository.getByIsbn("a");
}
}
소요시간: 3215ms
소요시간: 10ms
소요시간: 3006ms
소요시간: 8ms
cache clear => a
소요시간: 3017ms
데이터에 대한 유효성 검증을 효과적으로 도와줄 수 있다.
Bean-validation: JSR-380, 애노테이션을 이용하여 bean 유효성 검사를 위한 Java API 스펙
Hibernate-validator: Bean Validation을 구현한 Java API
dependencies {
compile('org.springframework.boot:spring-boot-starter-web')
compile('org.springframework.boot:spring-boot-starter-validation')
compile('org.projectlombok:lombok')
runtime('com.h2database:h2')
testCompile('org.springframework.boot:spring-boot-starter-test')
}
애노테이션을 사용하면 쉽게 Entity에 대한 유효성 검사를 할 수 있다. 해당 애노테이션은 javax.validation.constraints패키지에 정의되어 있으며, 이를 아래와 같이 활용할 수 있다.
@Data
public class Member {
private Long idx;
@NotNull(message="name null")
private String name;
@Min(value=14, message="min 14")
private Integer age;
@NotNull(message="tel null")
private String tel;
}
@NotNull
: null 검증
@Min
, @Max
: 최소값, 최대값 검증
@Size
: 범위 검증
@Email
: e-mail 검증
@AssertTrue
: true 검증
@NotEmpty
: null이나 size가 0 검증 (String, Collection)
@NotBlank
: null이나 whitespace 검증 (String)
@Positive
, @PositiveOrZero
: 숫자 검증
@Negative
, @NegativeOrZero
: 숫자 검증
@Past
, @PastOrPresent
: 날짜 검증
@Future
, @FutureOrPresent
: 날짜 검증
검증하고자 하는 Entity에 @Valid를 붙이며, 이에 대한 결과를 받기 위해 BindingResult를 추가하여 사용할 수 있다.
@RestController
@RequestMapping("member")
public class MemberController {
private final static Logger logger = Logger.getLogger(MemberController.class);
private final static int ZERO = 0;
//...
@PostMapping
public ResponseEntity<?> add(@Valid @RequestBody Member member, BindingResult bindingResult){
if(bindingResult.hasErrors()){
String errorMessage = bindingResult.getAllErrors().get(ZERO).getDefaultMessage();
return new ResponseEntity<>(errorMessage, HttpStatus.BAD_REQUEST);
}
return new ResponseEntity<>(member, HttpStatus.OK);
}
}
제공해주는 애노테이션도 많지만 사용자 정의 검증 애노테이션을 만들어야 하는 상황이 있을 수 있다.
@Documented
@Constraint(validatedBy = PhoneValidator.class)
@Target( { ElementType.METHOD, ElementType.FIELD })
@Retention(RetentionPolicy.RUNTIME)
public @interface Phone {
String message() default "Invalid phone number";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class PhoneValidator implements ConstraintValidator<Phone, String> {
@Override
public void initialize(Phone phone) {
}
@Override
public boolean isValid(String field, ConstraintValidatorContext cxt) {
return field != null && field.matches("[0-9]+")
&& (field.length() > 8) && (field.length() < 14);
}
}
@Phone
private String phone;
자세한 코드는 GitHub을 참고
@RunWith(SpringRunner.class)
@WebMvcTest(MemberController.class)
public class MemberControllerTest {
@Autowired
private MockMvc mvc;
@Autowired
private ObjectMapper objectMapper;
// ...
@Test
public void test_success() throws Exception {
Member member = new Member(TEST_NAME, TEST_AGE, TEST_PHONE);
String memberToJson = objectMapper.writeValueAsString(member);
mockRequest(memberToJson, status().isOk(), memberToJson);
}
// ...
private void mockRequest(String memberToJson, ResultMatcher matcher, String result) throws Exception {
mvc.perform(post(TEST_END_POINT)
.content(memberToJson)
.contentType(MediaType.APPLICATION_JSON_UTF8)
.accept(MediaType.APPLICATION_JSON_UTF8))
.andExpect(matcher)
.andExpect(content().json(result));
}
}
SpringBootSample / SpringBootValidator Baeldung / spring-mvc-custom-validator
]]>Spring Boot에서 간단한 설정으로 Hibernate를 사용할 수 있다.
JPA: Java Persistence API의 줄임말로, 관계형 데이터베이스의 관리를 표현하는 자바 API이다.
Hibernate: 자바를 위한 오픈소스 ORM(Object-relational mapping) 프레임워크를 제공한다. 버전 3.2와 그 이후 버전에서는 JPA를 위한 구현을 제공한다.
Data JPA: JPA기반의 Repository를 쉽게 구현할 수 있도록 Spring Data에서 제공한다.
간단한 예제를 만들기 위해 h2를 사용하겠다.
dependencies {
compile('org.springframework.boot:spring-boot-starter-data-jpa')
compile('org.projectlombok:lombok')
runtime('com.h2database:h2')
testCompile('org.springframework.boot:spring-boot-starter-test')
}
spring.jpa.database=H2
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.url=jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
@Entity
@Data
@AllArgsConstructor
public class Customer {
@Id
@GeneratedValue(strategy=GenerationType.AUTO)
private Long idx;
@Column(length=50)
private String name;
@Column(length=14)
private String tel;
private String etc;
protected Customer() {}
}
@Entity
는 해당 클래스가 JPA의 Entity임을 나타낸다.
public interface CustomerRepository extends JpaRepository<Customer, Long> {
}
JpaRepository
를 상속받는 것 만으로도 기본적인 CRUD가 가능하다. 또한, query-creation을 이용하여 쿼리를 작성할 수 있다.`
@RunWith(SpringRunner.class)
@DataJpaTest
public class CustomerRepositoryTests {
@Autowired
private CustomerRepository repository;
private Customer customer() {
return new Customer("heo won chul", "010-xxxx-xxxx", "developer");
}
@Test
public void test_insert() {
assertEquals(repository.save(customer().getIdx()), customer().getIdx());
}
@Test
public void test_select() {
assertNull(repository.findOne(1L));
}
}
Spring Boot는 적은 설정만으로 독립 실행형 Spring 애플리케이션을 쉽게 만들 수 있다.
application.properties
이나 application.yml
를 이용하여 다양한 설정을 할 수 있다.. __ _ _ _ /\ / ' _ () __ __ \ \ \ \ ( ( )_ | ' | '| | ' \/ ` | \ \ \ \ \/ _)| |)| | | | | || (| | ) ) ) ) ' |__| .|| ||| |_, | / / / / =========||==============|__/=//// :: Spring Boot :: (v2.1.0.RELEASE)
]]>