728x90

올 한해도 뭔가 얻어간게 많았던 것 같은 시기였다.

작년에는 회고글도 못쓰고 바쁘게 지나갔던 것 같은데, 이번에는 또 어떻게 시간이 나서 이렇게 정리글을 쓸 수 있게 되었다.

 

1. 이직

기존 회사에서 자비스앤빌런즈(삼쩜삼)로 이직하게 되었다.

이전 회사에 비해서 인원이 일단 굉장히 많아졌고 그리고 개발자분들이 되게 많아서 놀랐다.

물론 나머지 회사들이 아깝게 떨어져서 마지막으로 여길 오게된건 운명이 이끈걸까? 싶다.

 

이번에도 서류를 합격하는 맛을 보았기 때문에 이제 더 위로 계속 갈 수 있을거라고 많이 생각하고 있다.

 

계속 은행과 돈이 관련된 업무들을 쭉 하고 싶다는 생각에 아래 회사들을 넣었었다.

- 카카오뱅크 (과제 불합)

- 토스뱅크 (2차면접 불합 -> 재시도 후 1차면접 불합)

- 카카오페이증권 (과제 취소) -> 이 부분 좀 아쉬웠었다. 삼쩜삼 붙었어도 도전해볼걸

- 삼쩜삼 (합격)

 

결과가 그렇게 막 좋다라고 볼 수는 없지만, 이전에 비해서는 많이 나아진것 같다고 생각하고 있다.

서류도 다른 분들은 엄청 넣으시는 분들도 있을 수 있는데 딱 이렇게만 넣었었다.

내년에도 내가 어느정도 수준인지 가늠해볼 수 있는 좋은 기회들이 많이 생겼으면 좋겠다!!

 

올해 3월에 이직을 하게되고, 여태까지 계속 적응하며 지냈다. 중간에 팀이 변경이 되면서 조금은 혼란스러웠지만 그래도 이전보다 더 긍정적인 흐름으로 변화한 것 같아서 만족스럽게 일하고 있다.

 

이전 회사에서는 많아야 n만건(n <= 100,000) 정도인 데이터만 처리했다면, 계속해서 유저분들이 많이 사용해주시면서 1억건 이상의 데이터를 보면서 성능 튜닝을 한 경험도 가지게 되어서 만족스러웠다.

 

그리고 내가 개발자분들이 많아지고 회사 규모가 이전보다 커져서 그런지 뭔가 적응하기가 개인적으론 되게 어려웠고, 기존 문화에 녹아드는게 쉽지만은 않았다.

 

특히 협업 관련해서 좀 많이 힘들어했었다. 

  • 타 부서의 기존 구성되어있는 로직을 알 수 없어 뭘 모르는지 모르는 두루뭉술한 설명
  • 모르는걸 모른다고 말할 수 있는 자신감

지내면서 시간이 해결해주겠지 하고 넘기곤 했는데, 점심 식사를 언제 한번 하면서 조금은 더 노력해보면 좋겠다는 피드백도 주셨던 분이 계셨어서 소프트 스킬을 계속해서 개선하고 발전시켜야겠다는 생각을 지속적으로 하게되었다!!

 

그래서 지금은 이제 탄력받아 업무를 수행하면서 명확하게 질문하려고 하고 있고, 잘 되는것 같아서 기분 좋다~

 

2. 면접경험

위에서 서류와 면접들의 결과를 적어두었는데, 개인적으로 이제는 면접 경험도 점점 좋은 경험이 쌓이는 것 같아서 좋았다.

내가 싫어하는 면접들이 있는데 그건 약간 무조건적으로 같은 대답을 할 수 있는 그런 질문들을 조금 싫어했다.

사실 어디든 똑같은 질문을 리스트화해서 묻는거랑 다를게 없다고 생각해서 그런가보다.

e.g) 인터페이스와 추상클래스의 차이 등

 

처음은 가벼운 질문으로 시작해서 이 사람이 어디까지 고민해봤는가? or 고민해보지 않았다면 지금이라도 생각해봤을 때 어떤 방식으로 했으면 좋겠는지? 같은 내용들을 협업하는 관점에서 물어봐주시고 핑퐁을 하는 과정이 너무 재밌었고, 좋았던 시간이었다.

내가 이 부분에서 좀 더 노력해야겠다 생각했던 부분은 환경을 좀 더 명확히 하면서 역질문을 조금 더 하여 답을 원하는 방향으로 좀 더 조여가는 방식으로 했다면 더 원활한 소통이 되고, 좋은 결과를 가질 수 있지 않았을까 생각해본다.

 

3. 간단하게 월별정리

1~3월초

이 때는 이제 전 회사에서 개발자들을 전부 없앤다는 방향으로 가고있어서 지속적으로 위에서 얘기했던 면접을 봤던 시즌이었다.

배운것도 많고 이 부분은 위에서 더 자세하게 다뤘어서 이정도만 해도 될듯?ㅋㅋ

 

3월중 ~ 6월

이직한 회사에서의 수습기간 생활이었다.

되게 들어오자마자 운이 좋았던 케이스였다. 신규 회원이 2천만을 달성해서 받았던 상품권, 생각지도 못했던 상여금 등등

그리고 문화생활도 지원되고 식대도 제공되기 때문에 너무 좋았다.(한국인은 역시 밥심인걸까? 아무튼 이게 너무 좋았다.)

 

아! 그리고 이 때부터 사이클에 재미를 느끼기 시작했었다.

친구따라 시작했던 사이클인데 친구는 접고 이제는 나혼자 쭉 타게된...ㅋㅋㅋ 그러면서 길을 알고싶어 동호회 활동도 시작했다.

세상은 넓었고, 고수는 많았다. 뒤에 따라가며 많은 체력을 키웠던 것 같다! ㅋㅋㅋ

그러면서 뭔가 고수가 되고 싶어서 이 때부터 집부터 회사까지 25km 정도되는 거리를 자전거로 출퇴근 하기 시작했었다!

 

처음 출퇴근 했던 기록이다...!

그렇게 되면서 이걸 하기 위한 장비들을 계속해서 샀던게 기억에 남긴 많이 남았다 ㅋㅋㅋㅋ 옷이며 심박계며 기타 등등..

자전거 피팅도 받았었네 ! 아무튼 그러면서 이전 건강검진에선 지방간에 요산수치가 높고 혈압도 조금 높았던 것으로 기억하는데, 

이 출퇴근 이후로 다시 검진을 받아보니 그런것들이 싹 사라져있었다! 그래서 겨울이라 지금은 못타지만 다시 25년 3월부터는 또 계속해서 자전거를 탈 생각이다.

 

7월 ~ 10월

이때는 내가 우리 앱에있는 커뮤니티 그리고 만보기 서비스 등등이 우리 앱에 붙게 되어 너무 재밌게 개발했던 것 같다.

그리고 입사했던 초반보다 점점 사용해주시는 유저분들이 많아져서 tps가 전보다 늘어나는걸보고 너무 좋아했던게 기억난다!

그리고 10월이 지나면서 이제 부서가 변경된다고 얘기를 들었던 것 같다.

커뮤니티에 뭔가 안된다는 버그제보같은게 올라오는 구조가 되어서 어떨때는 되게 식겁해서 신속하게 버그를 처리했었지만, 또 한편으로는 커뮤니티인데 취지에 맞지않는 글 작성 빈도가 늘어나서 되게 안타깝기도 했었다...

 

굉장히 비쌌던 조선팰리스 뷔페에서 회식했던 경험도 있네!

이런데서 회식하는걸 처음 경험해봤다.. ㅋㅋㅋ 확실히 뭔가 큰 회사에 매출도 잘 나고 있어서 그런가 이런게 되게 좋았다.

그래서 안에 가서 대게를 계속 먹었었던 기억이 난다 ㅋㅋㅋㅋㅋ

 

그리고 굉장히 바쁜 나날을 지속했던 것 같다. 이 때 퇴근하고서는 잇잇(Eat-it) 이라는 푸드트럭 연계 주문 서비스 앱의 서버개발을 맡아서 3인이 한팀이 되어서 개발을 또 했었다. 각기 다른 회사 그리고 대학생분 까지 계셨어서 같은 기능이지만 정말 많은 경우의 수가 있구나를 깨달으며 다른 분들이 구성해주신 코드를 보면서 또 많이 학습했던 시즌이었다.

새로 구성하는걸 코틀린으로 구성해보려고도 했고, 지금도 역시 혼자 개발하려면 코틀린으로 어떻게 잘 구성해보려고 하고있다.

-> 근데 퇴근하고 개발하는게 요즘 힘이 부치는것같다 ㅠㅠㅠㅠ 다시 힘내서 잘 해보고싶다.

 

10월 ~ 12월

회사에서의 부서이동이 끝났다. 그랬는데 이전보다 분위기가 확살더라. 나는 정말 지금까지 우리 부서 사람들이 이렇게 쾌활하고 말이 많고 재밌는 분들인지도 몰랐었다 ㅋㅋㅋ 뭔가 꽉 옥죄고 있던 무언가가 있었나보다

이때는 이제 새로운 분들도 들어오시고 적응하시면서 회식도 많아지고 친분을 많이 쌓아서 사무실에서도 대화를 많이하는 정도가 됐던 것 같다. 그래서 너무 긍정적이었고, 협업을 요청하는데에 있어서 이전보단 더 어렵지 않아서 되게 만족하고있다.

 

아 그리고 이 시기에 허리가 굉장히 좋지 않았다. 주사를 맞고 치료하면서 글을 쓰는 지금은 굉장히 많이 호전되었는데, 이 때 다시 헬스를 해야겠다고 마음을 먹으며 헬스를 시작했다. ㅋㅋㅋㅋ 골격근이 많이 줄었더라 열심히 해야지!

 

그리고 이전 스터디에서 만나셨던 분중에 지속적으로 디스코드를 통해 조언을 구하시고 또 나도 소프트 스킬에서의 부족함을 여쭙고 도움을 받고 계신 분이 있는데 직접 카페에서 만나서 대화해주시고 방향성을 좋게 주셔서 아직도 많이 도움받고 있고 항상 감사하게 생각하고 있다.

 

그리고 12월부터는 회사 일이 바빠지기 시작해서 사이드로 앱 개발하던 잇잇을 내가 탈퇴하게 된다.

아마 다시 들어가기도 정말 힘들 것 같다 ㅠㅠ

 

그리고 DDD를 좀 더 명확하게 이해하고 이 부분을 공유하면 회사 백엔드 개발자분들에게 도움이 될 수 있을까 해서 스터디를 참여하게 됐다. 내년 1월부터 시작할 예정이다.

 

그리고 글을 쓰는 지금도 12월이니까!!

이제 내년부터면 나도 독립해서 서울에서 출퇴근을 할 것 같다. 나이도들고 체력이 좀 안좋아진 것인지 점점 왕복 3시간이라는 출퇴근 시간이 굉장히 힘들어져서 원룸이더라도 월세가 조금은 비싸더라도 이 시간을 줄이고 뭐라도 의미있는걸 하자! 라는 생각이 강해서 이제는 혼자 살아보자 라고 생각했던 것 같다.

마무리

올 한해도 내 나름대로 고생한 것 같고, 내년에도 더 고생해서 내가 목표하는 곳 까지 쭉쭉 나아갈 수 있었으면 좋겠다!

그리고 뭐든 잘할필요 없이 뭘 한다고 맘먹었다면 꾸준하기만해도 성공할 수 있을 것 같다는 생각도 들고, 그래서 포기하지 않고 중꺾마 유지하면 난 잘될거라고 믿고있다.

그리고 내년부터는 3시간 출퇴근 시간이 30분 이내로 줄어들 예정이라서 벌어들인 2시간 30분 정도를 개발공부를 하는데에 시간을 더 쓰는 그런 2025년이 됐으면 좋겠다. 잘가라 2024년!

728x90

'Diary' 카테고리의 다른 글

2022년 회고  (8) 2022.12.30
NHN Forward  (1) 2022.11.24
라즈베리파이 사용  (0) 2022.08.31
ATDD, 클린 코드 with Spring 5기 수료 회고  (0) 2022.08.14
728x90

짧은 근황을 먼저 얘기하자면...

올해 3월부터 이직을 하게되어 삼쩜삼(자비스앤빌런즈) 백엔드 엔지니어로 현재 이직하여 회사를 다니고 있다.

나중에 다른 포스팅으로 해당 부분은 잘 작성해보도록 하겠다.

이전 포스팅인 @Transactional 제대로 알고쓰기 에서는 무지성 중첩 트랜잭션에 대한 포스팅이었는데,
이번에는 좀 다른 케이스였다.

해당 예외가 발생하는 것이었다.
회사에서는 Mysql을 RDBMS로 채택하여 사용해서 아래와 같은 예외가 보였지만, 예제에서는 H2 DB를 사용하기 때문에 같은 에러메시지는 아니지만,
내용은 똑같다는 점을 일단 알고 넘어가면 좋을 것 같다.

HHH000099: an assertion failure occurred (this may indicate a bug in Hibernate, but is more likely due to unsafe use of the session): org.hibernate.AssertionFailure: null id in com.lsj8367.github.DemoEntity entry (don't flush the Session after an exception occurs)

이러한 예외가 나서 에러 로그에 StackTrace를 같이 찍어주고 있었기 때문에 지켜보게 되었다.

해당 예제를 구현한 것은 깃허브에 있다.
코틀린으로 코드가 구성되어있지만, 알아보기는 정말 쉬운정도의 코드라서 문제없이 읽을 수 있을 것이다.

 

구성하기 좋게 Facade -> Service -> Repository 로 단방향으로 흐르는 구조로 구성하였다.

실제 코드와는 완전히 다르고 정말 예제만을 위해 이렇게 구성하였다.

 

 

DemoFacade
DemoService

 

DemoEntity

 

이제 Service Layer의 save 로직은 name 값엔 "name"을 code값엔 "code" 를 무조건적으로 저장하도록 구성했고, entity 조건엔 unique 제약조건으로 code 값을 설정해주었다.

 

그래서 첫 1회는 저장이 정상 수행되며, 2회 수행시에는 uk 조건을 만족하지 못해 예외가 발생할 것이다.

 

정말 잘 발생하는것을 볼 수 있다. ㅋㅋㅋㅋ

 

근데 이제 여기서 문제인것은,

중복 예외인 DataIntegrityViolationException 을 catch 절에서 잡고있는데 어떻게 된 것일까?

 

이 부분을 생각해봐야 한다.

해당 부분을 알기 위해서는 JPA를 사용할 당시에 1차캐시를 기억해야 한다.

1차 캐시는 말 그대로 영속성 컨텍스트에 해당 엔티티 값들을 저장하는 방식인데, 우리는 당연스레 쿼리를 나가는 것을 생각하지 않으니 저장 객체를 핸들링 하는 것처럼 코드를 작성하게 되는데 이부분에서 문제가 발생했던 것이다.

 

일단 당연한 얘기지만 @Transactional 내부에 있기 때문에 로직들은 전부 트랜잭션이 끝나는 부분에 맞춰서 쿼리가 나갈 것이다.

그렇기 때문에 우리는 DataIntegrityViolationException을 catch로 잡아줘서 커스텀하게 던져주는 IllegalArgumentException은 잡히지 않는 것을 볼 수 있다.

이런식으로 IllegalArgumentException을 던지는 것으로 체크가 되지 않는 것을 볼 수 있다.

 

그러면 왜 때문에 DataIntegrityViolationException이 중간에 발생했을까?

해당 사항은 위의 코드에선 flush() 를 명시적으로 호출해주는 경우에 발생했다. (실제 쿼리가 이때 반영되기 때문이다.)

그러면서 ExceptionHandler를 통해 500예외를 핸들링하는 방식으로 구현되었다.

 

실제 업무 로직에서는 findAll을 해주는 쿼리가 같이 들어있었기 때문에 이 로직이 실행되기 이전에 flush를 수행하여 save가 제대로 반영되지 않고 DataIntegrityViolationException이 발생하게 된 것이다.

 

이를 예방하기 위해서는 너무 DB레이어의 UK 제약조건만 믿고 try-catch만을 심어서 데이터를 체크해주는 그런 부분을 지양해야 된다고 생각이 들었다.


1. 반드시 중복 데이터가 들어가지 못하게 만드는 기능이라면, 이전에 이미 등록된 데이터가 있는지 여부를 확인하는 로직을 넣어주는 것도 좋은 구성인 것 같다.

2. 만약에 db uk 예외가 발생하는 경우라면 @Transactional 을 적재적소에 알맞는 위치에 넣어 구성해주고, 그 @Transactional 을 사용하는 부분에 정말로 try-catch가 필요한지 다시한번 생각해볼 필요가 있다. (무지성 트랜잭셔널 금지!!)

 

다시는 재발하지 않게 로직을 구성해주자~

728x90

'JPA' 카테고리의 다른 글

JPA template 이슈  (0) 2022.08.07
고급 주제와 성능 최적화 1  (0) 2022.08.06
컬렉션과 부가기능  (0) 2022.08.06
준영속 상태의 지연로딩을 해결하는 방법  (0) 2022.08.06
728x90

인덱스란?
번역을하면 바로 색인이라는 단어로 번역된다.
색인은, 검색하면 책속의 낱말이나 어떤 챕터나 구절들을 빠르게 찾아볼 수 있게 쪽수 정보를 나타내주는 것을 뜻한다.
이 개념을 DB에 적용시킨 것이다.

인덱스는 어떻게 구성하는가?

인덱스는 CREATE INDEX 키워드로 구성이 가능하다.

Team 이라는 테이블이 이렇게 있다.

id(primary key) name member_id grade
1 홍길동 1 1
2 가나다 2 2
3 고길동 3 3
4 나길동 4 4

 

이름만으로 인덱스를 구성하고 싶다면 아래와 같이 수행해주면 된다.
CREATE INDEX <인덱스명> 테이블(칼럼);

CREATE INDEX team_index team(name);

이렇게 해주게 되면 name의 오름차순 순서로 정렬되게 된다.

왜 정렬이 되는가?
기본적으로 Mysql에서는 BTree 자료구조로 인덱스를 구성하게 된다.
b트리는 이진트리와 같게 기본적으로 정렬을 통해 구성해주게 된다.
-> 데이터 탐색에 용이하도록 구성하는 것이다.

인덱스를 생성하면 아래와같이 주소값을 참조하고 있는 구성이 완료된다.
| name | 주소값 |
|------|-----------------------|
| 팀1 | name이 팀1인 어떤 데이터의 주소값 |
| 팀2 | name이 팀2인 어떤 데이터의 주소값 |

이렇게 되었을 때 다시 SELECT 쿼리를 수행하게되면,

SELECT * FROM team WHERE name = '팀1';

name만을 인덱스 구성한 인덱스 주소 구성을 바라보게 될 것이다.
여기서 이제 트리구조의 탐색 알고리즘이 뒷받침하여 탐색하게 되는데,
가장 가운데 row부터 맞는지 검색을 들어가서 팀1 조건에 부합하는 데이터를 찾게된다.

찾게되면 name이 팀1 인 데이터가 예시에선 지금 2개로 구성했으니, 2개만을 조회해서 결과 반환을 해주게 될 것이다.

이런식으로 탐색을 빠르게 해줄 수 있다.

그럼 한개만의 칼럼만 인덱스를 구성할 수 있나요?

놉. 그렇지는 않다.
예를 들어서 팀에 속한 멤버가, 항상 등번호를 갖고있어야하고 적어도 그 팀에있는 멤버인 홍길동, 1번은 같이 묶여다닌다.
그렇게 된다면 인덱스에 그 둘을 같이 묶어 구성해주는 것이다.
말로 표현한걸 도식화하면 아래와 같다.
| member_id | number | 주소값 |
|-----------|--------|--------------------------|
| 1 | 1 | id가 1이며 등번호도 1인 데이터의 주소값 |
이게 근데 겹칠 수 있는 데이터라면 일반적인 인덱스로 구성시킬 수 있겠지만,
등번호가 유니크한 고유값으로 묶인다면 Unique Index를 구성해줄 수 있다.

CREATE INDEX member_index member(name, number);

인덱스로 탐색한 이후는?

자, 이제 우리는 인덱스로 탐색하는 방법을 조금은 안 것 같다.
여기서 인덱스가 유니크가 아닌경우를 좀 더 보려고한다.
일단 유니크 인덱스가 아니라면, 같은 조건으로 묶여있는 데이터가 여러건 있다는 것인데,
이미 인덱스에서 한번 체로 거른 수준처럼 데이터가 걸러졌는데 이후는 full scan을 수행하여 그 데이터들 중 완전한 조건에 부합하는 데이터만 추려 조회하여 결과를 내준다.

이렇게 되니까 인덱스 참 좋은것 같은데 그럼 칼럼마다 다 생성해주면 좋은거 아닌가?

놉. 그렇지 않다.
왜냐면 데이터는 테이블 자체에 저장이 될텐데, 인덱스는 처음에 b-tree 구조이고 정렬을 한다고 했었다.
인덱스를 많이 구성하게 되면, 그 인덱스들이 원하는 조건대로 재정렬을 해주어야 하기 때문에 성능 저하가 발생할 수 있다.
그리고 이 인덱스가 구성되는게 논리적인게 아니고 주소값을 참조하는 값들이 계속 생성하여 디스크에 저장되기 때문에 저장 용량의 한 부분을 차지하게 된다.

Where 조건에서 복합 칼럼 인덱스를 안타게 구성할 수도 있는지?

당연히 구성해볼 수 있다.
이것은 테이블을 만들고 실제 select 후 실행계획을 분석해보도록 하자.

demo2 테이블 DDL


자 테이블 구조는 위와같이 DDL을 정의해놓은 상태이다.

이제 EXPLAIN 키워드를 사용해서 인덱스를 타는지 안타는지 보게 될 것이다.
일단 기본적으로 member_id와 grade 팀순위를 묶었다.
-> 대충 구성하려고 하다보니 이상한 데이터 구조가 되어버렸다. ㅋㅋㅋㅋㅋㅋㅋ
일단 이 부분은… 넘어가도록…ㅎㅎ
각설하고!

EXPLAIN SELECT *
FROM test.demo2
WHERE member_id = 1;

EXPLAIN SELECT *
FROM test.demo2
WHERE grade = 1;

 

위의 두개 쿼리를 각각 실행한 결과이다.

위의 DDL에서 member_id를 먼저 구성하고, 그 뒤에 grade를 넣은 인덱스를 구성했다.

member_id를 먼저 구성해주었기 때문에 member_id로 선정렬된 인덱스를 탐색하게 될 것이니

member_id를 조건에 넣어주면 인덱스를 통해 데이터를 추려주는게 가능하다.

member_id를 통한 조회

 

grade를 통한 조회

하지만, 복합으로 구성된 상태에서는 member_id로 선정렬이 되어있기에 grade만을 where조건에 넣어주면 인덱스 탐색이 불가능하여 보는것처럼 인덱스를 타지 못하게 쿼리가 구성된다.

왜 이렇게 됐을까?
인덱스 구성한 ddl을 보면 member_id 로 시작하게 된다.

 

그래서 member_id가 Index에 먼저 구성되어 있기 때문에 우선적으로 추려볼 수는 있는 과정을 거치는 것이다.
그래서 grade만을 조회할 때는 grade로만 정렬이되거나, index의 가장 앞단에 grade로 잡혀있는 둘다 없기 때문에 가장 좋지 않은 Full scan 데이터를 조회하게 되는 것이다.

 

자 그럼 커버링 인덱스는 뭐야?

 

우선 앞의 내용을 천천히 다시 되짚어보자. (누구나 충분히 이해할 수 있을거라고 생각한다.)

 

우리는 여태 인덱스를 구성할 때 인덱스로 정할 n개의 칼럼들 + 주소 참조값을 가진 별도의 인덱스를 구성한다고 했다.

 

근데 해당 조건에 부합하는 row의 모든 데이터가 아니라 인덱스에 포함된 데이터만 조회한다면 사실 테이블 스캔이 필요 없는거 아냐?

-> 이게 바로 커버링 인덱스이다.

 

조건에 부합하는걸 갖고 디스크가서 데이터를 조회할 필요를 줄이기 때문에 성능상으로 굉장히 이득을 볼 수 있다!

 

그러면 이제 인덱스를 구성하기 좋게 만드려는 조건들을 나열해볼 수 있지 않나?

그렇다. 인덱스를 잘 설계하기 위해서는 어떻게 만들어줘야 하는지 이쯤 되면 조금은 이해가 될 수 있어보인다.

 

일단 단일 칼럼 인덱스라면 중복도가 낮은 데이터를 잡아주는게 무조건 유리할 것이다.

-> 이래야 조건에 부합하는 데이터의 Full scan을 하더라도 빠르게 찾아낼 수 있을 것이다.

 

복합 컬럼 인덱스라면...

일단 자주 엮이는 칼럼들을 우선적으로 묶어주는데, 그 조합의 유니크함이 필요할 것이다.

칼럼의 갯수가 너무 많아지면 반대로 또 인덱스 용량이 무거워지기 때문에 좋지 않을 것이다.

 

정리

일단 정리 차원에서 인덱스를 정리해봤다.

이전에 공부했던 것보다 지금 공부하면서 정리하는게 좀 더 많이 이해할 수 있게 된 것 같다.

계속 조금씩 점진적으로 깊게 공부하는 방법을 천천히 체득시켜야겠다.

728x90

'CS > 데이터베이스' 카테고리의 다른 글

쿼리 개선 2  (0) 2022.08.11
쿼리 작성 및 최적화  (0) 2022.08.11
728x90

개요

당연하게 사용하던 Java의 람다 기능에 대해서 의문을 갖지 않고 막 써댔다.

그렇지만, 면접에서의 질문을 받았을 때 당황했다. 뜬금포로 final을 쓰고 안쓰고의 차이를 물어보셨는데 순간 답변을 하지 못했다.

그래서 정리해본다.

 

람다 캡쳐링에 대해서 알아보기 이전에 Effectively final에 대해 먼저 알아보자.

Effectively Final 이란?

해당 단어를 deepL을 통해 해석해보면 사실상 최종 이라고 해석해준다.

final을 선언한 상수와 같이 변경되지 않았다면 그와 같은 수준으로 컴파일러가 해석해준다.

 

effectively final이 되려면 아래의 3가지 조건을 만족해야 한다.

아래 3가지 조건은 공식문서를 통해서 나와있는 정보들이다.

  1. 명시적인 final을 선언하지 않았다.
  2. 재 할당을 하지 않아야 한다.
  3. 접두 또는 후미에 증감연산자를 추가해서 데이터를 바꾸지 않아야한다.

객체라면 참조 주소값만 바뀌지 않는다면 그대로 계속 effectively final 로 유지할 수 있다.

 

정상적인 Effectively Final

정상적인 lambda식

Effectively Final이 제대로 되지않은 경우

비정상적인 lambda식

자바가 친절하게 설명을 해준다.

 

자, 이제 변수값을 내부에서 변경하지 않으면 잠정 final로 보고 람다식에 데이터를 명확하게 넣어줄 수 있다.

Lambda Capturing에 대해 알아볼 시간이다!

Lambda Capturing이란?

외부에서 정의한 변수를 사용할 때 람다식(익명 클래스의 function)에서 복사본을 생성하게 된다.

외부라는 의미는 지역변수나 전역변수(인스턴스)와 클래스 변수들을 전부 아우르는 표현이다.

그럼 Capturing을 적용하지 않는 경우도 있을거 아닌가?

당연히 사용하지 않을 수 있다. 그래서 변수를 넣지 않고 동작할 수 있는데 이때는 외부의 변수를 주입받아 사용하는게 아니라서 캡쳐링이

적용되지 않아서 non-capturing이라고 한다.

Lambda Capturing은 왜 복사본을 만드는가?

일단 지역변수는 메모리 구조상 스택 영역에 할당된다.

스택은 스레드가 실행됐을 때 고유한 영역으로 가지고 있게된다. 그래서 스레드끼리는 공유할 수 없고, 스레드가 종료되면 해당 스택 영역도 사라진다.

여기서 이제 문제가 발생하는 것이다.

 

아래 코드에서 ss 라는 문자열을 복사해서 갖고있지 않는다면 new Thread 부분에선 지역변수로 묶여있는 test()가 스레드보다 더 빨리 수행되고 끝날 가능성이 존재하기 때문에 null을 줄 수도 있을 것이다. 이렇기 때문에 복사본을 만들어 유지하는 것이다.

public void test() {
    String ss = "test";

    new Thread(() -> {
        try {
            Thread.sleep(1000L);
            System.out.println("thread1 ss : " + ss);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }).start();

    System.out.println("ss : " + ss);
}

 

여기서 근데 effectively final이어야 하는 이유는 위에서 봤듯이 멀티 스레드 환경에서 람다식이 동작할 수 있기 때문에

지역변수는 또 스레드마다 공유하지 않기도 한다. 때문에 어떤 복사본이 최신인지를 자바 입장에서는 확인할 방법이 없기 때문에 final변수로만 지역변수를 사용해야 하는것이다.

조용하던 인스턴스 변수나 클래스 변수는?

인스턴스 변수는 메모리 구조상 힙에 할당된다.

우리는 알고있다. 힙은? -> 모든 스레드가 공유할 수 있는 메모리이다.

클래스변수는 static 변수가 이 부분에 포함되는데, 메소드 영역에 할당이 된다.

그래서 값이 바뀌던 말던 그 데이터는 항상 같게끔 유지할 수 있기 때문에 할당할 수 있는 것이다.

정리

람다식 내부에서 지역변수를 사용하는 경우 final이나 effectively final 변수를 사용해야 한다.

-> 이유는 메모리 구조의 stack 영역에 저장되기 때문

final이 아니라면 복사되는 값이 어떤 스레드에서 바꾼것이 가장 최신의 복사본인지 알 수 있는 방도가 전혀 없다.

 

728x90

'Java' 카테고리의 다른 글

참조 유형  (0) 2022.09.12
Checked Exception, Unchecked Exception  (0) 2022.09.07
변성  (0) 2022.08.11
일급 컬렉션  (0) 2022.08.09
728x90

개요

회사의 기술스택에서 카프카를 사용하고 있다.
내가 이번에 정리하는 글은 막연하게 ack을 날리는 부분에 있어서 간과했던 로직 때문에 벌어진 이슈를 정리한다.

어떻게 로직이 생겼었는가?

우선 카프카를 사용하기 위해선 스프링 부트에서 Spring for Apache Kafka 를 사용해야 한다.
나는 로직에서 try-catch-finally 를 붙여 사용했는데 동료 개발자분께서 의도하신 것인지는 모르겠으나 catch절을 빼고 로직을 구성했다.

@KafkaListener
public void consumeExample(final ScrapeUpdateDto dto, final Acknowledgement ack) {
    try {
        // do something
    } finally {
        ack.acknowledge();
    }
}

이러한 방식으로 구현을 했었고, try 내부에서는 throw를 하는 부분이 군데군데 적용이 되어있었다.

에러는?

샘플 코드에서 do something에 해당하는 로직에서 문제가 발생하게 되는데, 그것이 바로 재시도 로직은 어디에도 존재하지 않았다.
하물며 Spring측에서 제공하는 @Retryable 이나 Spring Cloud Openfeign에서 설정하는 Retry 를 어디에도 구성하지 않았는데 실제 호출은 원본 호출까지 합쳐 10번을 수행했다.

디버깅 시 당연하다고 생각한 부분

일단 디버깅을 진행했을 때 아 그럼 예외 핸들러가 뭐 처리를 했겠지 라는 생각으로 대충 ErrorHandler를 검색했다.
검색하고 보니까 뭔가 힌트는 얻었다.

그것이 바로 DefaultErrorHandler
근데 이 클래스가 10회가 발생하고 백오프 9번 재시도 한다고 하는데 이 부분을 생성해주는 곳은 어디에도 없었으며, 변수로 명시한 setter 메소드에도 디버깅 포인트를 집어놓아도 null만을 주입하고 있었다.

이것이 바로 그 에러 핸들러의 생성자 부분이다.

아직 잘 모르겠으니 차례대로 디버깅을 수행해보도록 하자!

디버깅

우선 KafkaAnnotationDrivenConfiguration 을 통해 ConcurrentKafkaListenerContainerFactoryConfigurer 를 생성한다.

모든 값이 default 설정이라 그런가 전부 errorHandler 부분이 null을 가지고 있다.
여기서 DefaultErrorHandler는 절대 사용되지 않는다라고 확정지었다.

이후에 ConcurrentKafkaListenerContainerFactory를 커스텀한 설정을 넣지 않는다면 자동생성 빈으로 구성해준다.


죄다 이런 default 클래스들로 구성을 해준다.

이렇게 기본 팩토리 구성을 끝마친다.

팩토리 생성 이후

팩토리가 만들어졌다면 당연히 Listener들을 생성시켜주어야 하는데, 이 부분을 createListenerContainer 메소드로 생성해주고 있다.

아래 로직은 ConcurrentKafkaListenerContainerFactory 가 상속한 AbstractKafkaListenerContainerFactory 에 구성된 로직을 오버라이딩한 메소드이다.

createListenerContainerInstance 로 리스너에 대한 부분을 생성해주게 된다.

여기서 추가적으로 알게된 것은 카프카를 설정하기 위해 yaml파일에 구성하는 concurrency 값을 토대로 리스너 컨테이너를 생성한다.

어 잠깐만! 근데 리스너 컨테이너 생성하면 리스너가 여기서 설정되는거 아냐?

위의 리스너 컨테이너도 마찬가지로 추상 컨테이너를 상속받고 있는데, 이 부분을 살펴봤다.

왜?

당연히 리스너면 컨슈머에 대한 설정 부분이 어느정도 구현이 되어있을 것이라고 추측하고 들어가보았다.

변수에는 롤백 프로세서 변수가 default 설정이 되어있는 것을 볼 수 있다.

아래 코드는 AbstractMessageListenerContainer의 변수로 선언된 일부를 가져왔다.

private AfterRollbackProcessor<? super K, ? super V> afterRollbackProcessor = new DefaultAfterRollbackProcessor<>();

DefaultAfterRollbackProcessor를 따라가보면 정답이 나올 것 같아서 바로 들어갔다~~

아래와 같은 설정 구성이 들어가있었고, DefaultErrorHandler와 같이 최초 1회 수행한 후 로직내부의 별도의 Exception을 처리해주는 부분이 없다면 9회를 재시도하도록 구성이 되어있었다.

디버깅 끝낸 후

이 부분을 끝내고 난 뒤 공식문서를 봤는데,

공식문서에서 deafult error handler 설정 값이 어떻게 들어가있는지 알려주고 있다.

공식문서 바로가기

 

예외를 처리해줄 핸들러를 구현할 때 참고할 점

추가적으로 코드에서 설명해주고 있는 부분은

AbstractKafkaListenerContainerFactory내부에 있는데,

deprecated 처리된 메소드들

  1. 2.2버전 이상
    1. setErrorHandler
    2. setBatchHandler
  2. 2.8버전 이상
    1. setRetryTemplate
    2. setRecoveryCallback

전부 setCommonErrorHandlersetAfterRollbackProcessor 메소드를 사용하도록 추천하고 있다.

결론

결론은 무조건적으로 ack을 날리더라도 dead letter를 바로바로 처리해줄 수 있는 또 하나의 프로듀서, 컨슈머 한쌍을 만들어 두던지, 아니면 db에 따로 오류난 메시지들을 적재하고 배치로 수행해주던지 구성이 필요할 것 같고,

에러를 명확하게 던진 부분을 컨슘하는 곳에서 명확하게 처리해줄 상황이 아니라면,

Custom한 ErrorHandler를 구성하는게 바람직할 것 같다.

728x90

'디버깅' 카테고리의 다른 글

Stream 오류 제거  (2) 2023.04.21
@Transactional 제대로 알고쓰기  (4) 2023.02.27
FeignClient Logging level 디버깅  (0) 2022.12.17
@Async 사용시 에러 해결  (0) 2022.11.04
728x90

회사에서 어떻게 하면 좀 더 편하게 어떤 기능을 사용하게 할 수 있을까?

라는 생각이 계속해서 들었는데, 좋은 기회가 생겼다.

회사에서 영업을 하시는분들이 컨퍼런스, 세미나 등등을 많이 참석하여 홍보하고 고객을 유치하는 과정에서 기회가 생겼다.

그것은 회사의 카드를 발급을 받았는지에 대한 여부 확인을 따로 할 수 없었다는 것인데, 당연히 그럴 것이 회원가입할 때 법인 유무를 체크하지는 않으니 말이다.

그래서 종이로든 아니면 엑셀시트던 특정 자료를 세미나 전에 준비해서 가는 부분이 매우 불편해보였다. (갈 때마다 추가 법인이 있으니까 매번 정리해야 된다.)

자! 그래서 슬랙에서 /법인찾기 법인명 하면 우리에게 가입된 법인인지를 찾는 기능을 구현하고 싶었다.

그래서 데모로 슬래시 커맨드를 만드는 법을 데모로 만드는 과정을 포스팅한다.

항상 다는 얘기지만 모든 코드는 깃허브 에 있다.

슬랙 설정

Slack Slash Commands

https://api.slack.com/apps 페이지에서 앱을 설정하여 들어온 후 다음과 같이 설정할 수 있다.

  1. 봇을 먼저 만들어야 한다.
    나는 봇을 만드는 과정을 진행하는 포스트는 아니기에 다른 포스트에서 알아보도록 합시다.
  2. Basic Information Signing Secret
    Settings > Basic Information > App Credentials 탭의 Signing Secret 정보를 따로 저장한다.
  3. Features > OAuth & Permissions > OAuth Tokens for Your Workspace 탭의 Bot User OAuth Token 정보를 따로 저장한다.

이렇게 저장하면 모든 설정은 준비됐다.

애플리케이션을 만들자

모든 코드는 회사에선 자바를 사용하지만, 집에서는 공부 목적 + 나중 을 위해 코틀린을 사용한다.

Slack은 SDK를 사용할 수 있도록 자체 라이브러리를 제공해주는데,

나는 bolt를 사용하여 구현할 계획이다. 자세한 정보는 https://slack.dev/java-slack-sdk/guides/slash-commands 에 있다.

build.gradle

의존성에는 위에서 말한것과 같이 bolt에 대한 설정을 추가해준다.

dependencies {
    implementation("com.slack.api:bolt:1.29.2")
    implementation("com.slack.api:bolt-servlet:1.29.2")
}

SlackConfiguration

슬래시 커맨드에 대한 수신 정보들을 해당 클래스에서 미리 설정한다.

@Configuration
class SlackConfiguration {
    @Bean
    fun testApp(): App {
        // 슬랙 앱 설정
        val app = App(
            AppConfig.builder()
                     .signingSecret("") //위에서 설명했던 Signing Secret 정보 넣기
                        .singleTeamBotToken("") // 위에서 설명했던 OAuth Token 정보 넣기
                     .build()
        )

        app.command("/echo", (req, ctx) -> {
              String commandArgText = req.getPayload().getText(); // text에 대한 값을 가져온다.
            String channelId = req.getPayload().getChannelId(); // 채널 id 수신
            String channelName = req.getPayload().getChannelName(); //채널명 수신
            String text = "테스트 발송";
            return ctx.ack(text); //슬랙으로 원하는 정보를 전송
        });
    }
}

Bean으로 각 호출할 커맨드들을 앱을 통해 등록해준다.
여기서 좀 더 고도화 하자면 AppConfig는 따로 분리하여 전역적으로 사용해도 무방하다.

SlackSender

이제 bolt에서 추가해준 라이브러리를 통해 SlackServlet을 생성할 것이다.

@WebServlet("/slack/events/test")
class SlashCommand(@Qualifier("testApp") app: App?) : SlackAppServlet(app)

구현은 끝났다.

이렇게 되고 나서는 슬랙에서 추가적으로 설정해주어야 하는 부분이 존재한다.

아래와 같이 커맨드 옵션을 추가해주어야 한다.

localhost를 사용한다면 내가 구현한 것처럼 ngrok등의 터널링 도구를 사용해서 서버를 라우팅해주어야 한다.

도메인이 있다면 도메인명을 바로 적어주면 실행된다.

requestURL에는 ngrok 주소 + /slack/events 를 붙여주고 맨 마지막은 command 경로를 넣어준다.

자 이제 실행해보자!

그러면 이제 서버를 켠 후 슬랙에서 /command 를 실행해서 해보자.

기본값이라면 나처럼 나오게 될 것이다.

왜 안되는 것인가...?

@ServletComponentScan

우리는 슬랙 웹 서블릿이라는 클래스를 추가해주었다.

그 클래스는 내부에서 HttpServlet 을 상속받아 구현하고 있기에 servlet을 수신할 수 있는 설정을 구현해주어야 한다.

이 문단의 제목인 @ServletComponentScan 을 통해 서블릿 객체들을 스캔해줄 수 있도록 설정을 해주어야 한다.

@ServletComponentScan(basePackages = ["com.github.lsj8367.slack.command"])

이런식으로 Application 클래스 위에 설정을 해주거나, 또는 별도의 설정 클래스를 통해 지정해주면 된다.

설정해주고 다시 실행해주게 되면?

위의 코드랑 조금은 상이하게 테스트라는 단어 3번을 출력하도록 해주었다.

잘 나오게 된다.

그리고 기본적으로는 슬랙의 문법을 다 알아듣기 때문에 https://api.slack.com/reference/surfaces/formatting 여기서 사용할만한 문법을 적용해서 추가해주면 되겠다.

이 부분을 이제 회사에 적용하면? 불편했던 점을 자동화한 개발자 타이틀에 1%는 달성하지 않았나? 싶다~ 👏👏👏

728x90

'Spring' 카테고리의 다른 글

분산 락  (0) 2023.04.15
@ModelAttribute, @RequestBody 커맨드 객체  (3) 2022.09.15
@Valid, @Validated 차이  (0) 2022.08.10
AOP  (0) 2022.08.10
728x90

의문점

스크래핑 로직 실행 시간에 대해 의문을 가졌다.

평균 시간이 그럭저럭 다들 비슷한 수준에 머무는데, 이상하게 한 부분만 너무 느렸다. 증권사에 대한 스크래핑 내역이었는데,

특정 증권사만 중복 로그인이 감지되었을 경우 30초를 대기했다가 다시 수행할 수 있게 하는 로직이 들어있었고,

테스트 코드는 그에맞는 정말 30초를 기다리는지에 대한 여부를 테스트 하고 있었다.

🤔 그냥 30초를 기다리는것보단 특정한 법인이 아니라면 30초를 기다리지 않는다 를 테스트하면 되지 않을까?

이렇게 생각했던 이유는 30초를 테스트에서 기다리는 비용이 비싸다고 생각했고, 전체 테스트를 돌릴 때 이 부분때문에 30초를 더 기다려야 한다는 것이다. 그리고 milliseconds 차이값을 비교했기에 간헐적으로 실패하는 경우도 있었다.

 

그래서 더 고치고 싶었는데 일단 테스트를 돌려봤다.

 

디버깅

바로 디버깅 시작했는데 내가 stream 로직을 잘 구성하지 못하는 것인가? 라는 생각도 들었다.

stream 내부에 stream을 또 구성하고 있는 상황이었다.

 

이말은, stream 내부의 stream에선 어떤 값을 반환하던 간에 바깥에서 Stream으로 래핑을 하고 있으니 뭘해도 값이 존재한다는 가정이 먹힌다는 뜻이다.

그래서 위에서 나는 에러점이 보였던 것이다.

해당 부분을 사진을 좀 가져오려고 했는데 워낙에 적나라하게 코드를 다 보여주는 것 같아서 예시 코드를 가져ㅇ와봤다.

 

가장 짤막하게 예시를 들어볼 수 있는게 햄버거가 아닐까 싶었는데

안에 토핑을 생각하다가 간단하게 햄버거 이름, 빵종류, 소스가 무엇인지만 적어봤다.

물론 햄버거 재료중에 패티가 빠지면 섭한데 대충 보자. ㅋㅋㅋ

 

Java 14버전부터 해당 record 클래스가 등장했다.

나는 집에서는 java 17버전을 쓰고있기에 record로 적었고

이미지와 아래의 코드는 같은것이라고 생각하면 되겠다.

그러면서 getter가 기본으로 구현되어있고 getXXX() 방식이 아닌 바로 변수 이름과 같은 메소드를 호출할 수있다.

public final class Hamburger {
    private final String name;
    private final String bread;
    private final List<Sauce> sauces;
}

 

아무튼 이런 햄버거와 안에 들어간 소스를 봐야하는데 소스도 정말 대충 value라고 이름을 지었다.

무튼 이렇게 두개 클래스가 있는데

 

이런 코드를 예시로 만들었다. 로직만 다를뿐이지 이슈가 있던 로직과 같은 구현법이다.

햄버거들에서 소스만 발라내서 매운 소스가 들어간지 여부를 확인하여 있으면 true 없으면 false를 반환하려고 했었던 로직인 것 같다.

문제가 있던 로직

잘보면 뭐 햄버거들중에 소스를 발라내고 그 소스들을 뒤져가면서 null이 아니며 spicy 소스인 데이터를 찾으면 바로 true 반환하려고 했던 것 같다.

 

위에서 설명했던 30초 로직은 해당 메소드에서 true가 반환되면 타게 되어있었다.

public으로 구현했지만 회사의 로직은 private으로 구현되어 있는 메소드였다.

 

각설하고, 이부분에 대해 테스트를 해보니 "spicy" 라는 값을 넣지 않아도 무조건 테스트가 항상 true였다.

그래서 걸러주어야 할 로직들도 모두 참이 되어버리는 것이다.

 

소스에 살사와 머스타드를 넣었음에도 불구하고 spicy 소스가 없는데 참이 나온다.

 

자세히 보면

stream내부에 stream이 있지 않는가?

내부에서 뭘 수행하던간에 아직 stream 연산을 끝맺어주지 않았기 때문에 Stream<> 객체를 반환해줄 것이고 그렇기 때문에 항상 그 내부 연산이 존재하던 않던 상관없이 Stream 객체 자체가 null이 아니고 존재하기 때문에 findFirst() 를 수행해도 항상 참을 반환하는 것이었다.

 

개선하기

그래서 어떻게 이 부분을 개선했는가?

public boolean isExistSpicySauce(List<Hamburger> hamburgers) {

        final Set<Sauce> sauces = hamburgers.stream()
            .map(hamburger -> hamburger.sauces())
            .flatMap(List::stream)
            .collect(Collectors.toSet());

        return sauces
            .stream()
            .filter(sauce -> sauce.value() != null)
            .anyMatch(sauce -> "spicy".equals(sauce.value()));
}

이런식으로 개선했다.

결과는 당연히 spicy가 포함되지 않았기 때문에 false를 반환했고 테스트도 성공한것을 볼 수 있다.

stream으로 엮어주고 Set으로 변환한 이유는 어떤 햄버거든 소스들만 추려 spicy만 있으면 됐기 때문에 이런식으로 구현을 해주었다.

회사 코드에선 한 법인의 여러개의 공동인증서를 가지고 판별하는 문제였기 때문에 이것처럼 구현해보았다.

 

stream내부에서 stream을 또 사용할 때에 주의해서 써야겠다고 이 부분을 수정하면서 느꼈다.

어쩌면 너무 당연한 얘기일 수 있었지만, 이 부분 때문에 모든 스크래핑에서 30초 정도의 중복 로그인 대기 시간이 해소되었고 30초 * n개의 증권사 스크래핑 시간을 특정 기관에서만 중복로그인 30초만 대기할 수 있도록 수정되었다.

무의미한 스크래핑 대기 시간을 해소했다는 얘기이다!!

아무튼 한동안 너무 신규 기능개발건에 대해 초점이 맞춰져서 힘들었었는데, 계속해서 기존 코드 부수고 고치며 성능개선하며 이전 코드들을 돌아볼 수 있는 시간을 생각을 전환하니 어느새 갖고있게 되었다.

해당 생각의 전환은 https://jojoldu.tistory.com/710 여기서 자극을 많이 받게 되었다.

728x90
728x90

분산 락

회사에서 스크래핑 작업을 수행하다가 분산락을 구현하게 되어 너무 잦은 야근으로 인해 이제야 포스팅을 끄적여본다.

한번의 스크래핑요청은 곧바로 비용 + 시간이 요구된다.

비용이 요청때마다 들고, 내가 구현했던 정부기관의 스크래핑 시간은 길면 15초까지 걸리는 작업도 있다. (의존도가 너무 높은것도 사실이다)

이런 비용과 시간이 드는 스크래핑 작업을 동시 다발적으로 같은 작업을 수행해준다?

이것도 문제가 있을 것이다. 한번의 스크래핑 데이터를 계속해서 돌려쓰면 되지않을까?

이러한 스크래핑 데이터를 공유자원이라 생각했고 이미 요청중인 작업이 있다면 이미 작업중인 스크래핑이 있다고 오류를 내려주는게 오히려 적합하다고 생각했다.

Synchronized

우선 Java에서 제공해주는 synchronized 키워드를 사용하여 동시성을 제어하는 것은 어떨까 하고 생각해봤다.

자바 내부의 monitor를 기반으로 상호 배제 기능을 제공해준다.

Monitor란?

3. JVM Synchronization이란?

해당부분에서 책 스터디를 하며 monitor에 대해 정리했었다.

여기까지만 보면 오! 동시성 제어 완벽하게 할 수 있겠지만, monitor는 1개니까 비용이 비쌀것으로 예상해 적재적소에 꼭 사용해야 하는 곳만 사용해야겠다! 라고 처음 생각했다.

근데 생각해보니 문제점은 아래와 같았다.

정상 개발환경 - 이 환경은 서버 1개로만 운영되어도 문제없다

문제 운영환경 - 이 환경은 이중화 구성이 되어있고 부하분산 되어있다.

분산락

그래서 등장한게 바로 이 분산락이다.

Redis를 사용하여 구현을 했는데 락이라는 단어가 나왔다고 해서 DB에서의 락을 생각하면 다른 개념이므로 주의하자.

DB의 락을 이용하여 동시성 제어를 할 수는 있지만, 분산락으로 풀어내는게 더 많을 것 같다.

그럼 왜?

왜냐면 일단 DB의 락을 건다는 것 자체가 결국 DB에 대한 I/O가 한번은 일어나야 락상태인지 어떤지 알 수 있기 때문에 DB 조회 1회를 무조건 1번은 한다는 전제를 깔고 간다 생각해서 동시성을 컨트롤 해주려면 그 앞단에서부터 끊어줘야 될 것이라고 생각했다.

그래서 대표적으로 많이 쓰는 Redis를 사용하여 분산락을 구현했다.

결국 이 개념은 키 하나를 갖고 있어서 이 키가 있다면 락이 잡혀있다고 생각하면 된다.

그래서 이중화 구성이 된 서버들이 하나의 Redis를 바라보며 임계영역에 대해 접근을 할 수 있는지를 체크한다.

이득은?

이렇게되면 맨 위에서 언급했던 비용 + 시간을 절약할 수 있는 구성이 되었다.

여러 사업자 등록번호로 스크래핑하는 것은 가능하나, 같은 법인의 경우 스크래핑을 요청하는 순간에는 이런 분산락을 구현해주어 요청을 차단하는 효과를 가지게 되었다.

 

이전엔 이런 기능 없이 무조건 요청이 들어온다면 그냥 계속 스크래핑을 수행했으며 스크래핑 이력, 받아온 데이터또한 남기지 않았다.

 

회사의 코드를 구성할 순 없으니 최대한 비슷한 구성으로 코드를 구성해보려 한다.

항상 말하지만 모든 코드는 나의 깃허브에 있다.

 

scrapeSomething 메소드를 실행하는 경우 synchronized옵션을 주어 설정한다면

각 프로세스마다의 실행이 1회씩 수행될 것이다.

한 프로세스에서 실행됐을때 이중화 이상이 구성된 프로세스에서도 실행되지 않게 하려면 레디스로 아래 이미지와 같이 수행해주자!

코틀린으로 구현해본 분산락 로직

결론

결국 이 분산락을 통해 새로고침하고 다시 스크래핑 수행하는 요청을 막아서 비용이 비싼 스크래핑 로직을 안태울 수 있게 로직을 변경했다.

728x90

'Spring' 카테고리의 다른 글

Slack Slash Commands(슬랙 슬래시 커맨드) 사용하기  (1) 2023.07.06
@ModelAttribute, @RequestBody 커맨드 객체  (3) 2022.09.15
@Valid, @Validated 차이  (0) 2022.08.10
AOP  (0) 2022.08.10
728x90

예제는 깃허브에 있다.

트랜잭션을 공부했다고 생각하고 업무에 임했던 나였는데, 도저히 풀리지않는 느낌으로 예외를 받았던게 있다.
이전에 한번 포스팅했던 Stream Closed 에러였는데 이게 또 한번 나를 붙잡았다.
처음 개발환경에서 OpenFeign을 사용하면서 또 logging level에서 IOException이 나는줄알고 이부분으로 삽질을 했다.

근데 그게 아니어서 이 포스팅을 작성했다.

  • HelloController.java
@RestController
@RequiredArgsConstructor
public class TestController {

    private final TransactionParentService transactionParentService;

    @GetMapping("/transaction/test")
    public ResponseEntity<?> test() {
        transactionParentService.size();
        return ResponseEntity.ok().build();
    }
}
  • TransactionParentService.java
@Service
@RequiredArgsConstructor
public class TransactionParentService {

    private final MemberService memberService;
    private final TransactionErrorService transactionErrorService;

    @Transactional
    public int size() {
        transactionErrorService.throwExceptionLog();
        return memberService.allSize();
    }
}
  • TransactionErrorService.java
@Slf4j
@Service
@RequiredArgsConstructor
public class TransactionErrorService {

    private final MemberService memberService;

    @Transactional
    public void throwExceptionLog() {
        try {
            memberService.saveAndException();
        } catch (RuntimeException e) {
            log.error("error : {}", e.getMessage());
        }
        System.out.println("끝");
    }
}
  • MemberService.java
@Service
@Transactional
@RequiredArgsConstructor
public class MemberService {

    private final MemberRepository memberRepository;

    public void save() {
        memberRepository.save(new Member(1L, "홍길동"));
    }

    @Transactional
    public void saveAndException() {
        memberRepository.save(new Member(null, "홍길동"));
        throw new RuntimeException();
    }

    public int allSize() {
        return memberRepository.findAll().size();
    }

}

TransactionParentService -> TransactionErrorService -> MemberService 순서로 서비스는 동작하게 되고,
기본적으로 Service에는 메소드마다 @Transactional 어노테이션이 붙어있다.

아래는 application.yaml에

logging:
  level:
    org.springframework.transaction: trace

이와 같은 로그설정을 해주고 찍어본 결과이다.

TRACE 66119 --- [nio-8080-exec-1] o.s.t.i.TransactionInterceptor           : Getting transaction for [com.github.lsj8367.service.TransactionParentService.size]
TRACE 66119 --- [nio-8080-exec-1] o.s.t.i.TransactionInterceptor           : Getting transaction for [com.github.lsj8367.service.TransactionErrorService.throwExceptionLog]
TRACE 66119 --- [nio-8080-exec-1] o.s.t.i.TransactionInterceptor           : Getting transaction for [com.github.lsj8367.service.MemberService.saveAndException]
TRACE 66119 --- [nio-8080-exec-1] o.s.t.i.TransactionInterceptor           : Getting transaction for [org.springframework.data.jpa.repository.support.SimpleJpaRepository.save]

TRACE 66119 --- [nio-8080-exec-1] o.s.t.i.TransactionInterceptor           : Completing transaction for [org.springframework.data.jpa.repository.support.SimpleJpaRepository.save]
TRACE 66119 --- [nio-8080-exec-1] o.s.t.i.TransactionInterceptor           : Completing transaction for [com.github.lsj8367.service.MemberService.saveAndException] after exception: java.lang.RuntimeException

TRACE 66119 --- [nio-8080-exec-1] o.s.t.i.TransactionInterceptor           : Completing transaction for [com.github.lsj8367.service.TransactionErrorService.throwExceptionLog]
TRACE 66119 --- [nio-8080-exec-1] o.s.t.i.TransactionInterceptor           : Getting transaction for [com.github.lsj8367.service.MemberService.allSize]
TRACE 66119 --- [nio-8080-exec-1] o.s.t.i.TransactionInterceptor           : Getting transaction for [org.springframework.data.jpa.repository.support.SimpleJpaRepository.findAll]

TRACE 66119 --- [nio-8080-exec-1] o.s.t.i.TransactionInterceptor           : Completing transaction for [org.springframework.data.jpa.repository.support.SimpleJpaRepository.findAll]
TRACE 66119 --- [nio-8080-exec-1] o.s.t.i.TransactionInterceptor           : Completing transaction for [com.github.lsj8367.service.MemberService.allSize]
TRACE 66119 --- [nio-8080-exec-1] o.s.t.i.TransactionInterceptor           : Completing transaction for [com.github.lsj8367.service.TransactionParentService.size]

여기서 보면 정상적으로 트랜잭션을 처음엔 쭉 획득한다.
그리고나서 memberService.save()를 통해 SimpleJpaRepository까지 하기위한 transaction을 얻게된다.

에러가 나는 부분

우리의 코드에서는 save 이후 throw new RuntimeException()이 있다.
여기서 부터 after Exception : java.lang ...으로 보이는 rollback 마킹이 존재한다.

org.springframework.transaction.UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only
    at org.springframework.transaction.support.AbstractPlatformTransactionManager.processCommit(AbstractPlatformTransactionManager.java:752) ~[spring-tx-5.3.22.jar:5.3.22]
    at org.springframework.transaction.support.AbstractPlatformTransactionManager.commit(AbstractPlatformTransactionManager.java:711) ~[spring-tx-5.3.22.jar:5.3.22]
    at org.springframework.transaction.interceptor.TransactionAspectSupport.commitTransactionAfterReturning(TransactionAspectSupport.java:654) ~[spring-tx-5.3.22.jar:5.3.22]
    at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:407) ~[spring-tx-5.3.22.jar:5.3.22]
    at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:119) ~[spring-tx-5.3.22.jar:5.3.22]
    ...생략
    at java.base/java.lang.Thread.run(Thread.java:833) ~[na:na]

이게 도대체 어떻게 동작하는것인가?
추상클래스인 TransactionAspectSupport.invokeWithinTransaction 메소드를 사용하는 TransactionInterceptor의 invoke를 호출해서 안쪽에 추상클래스의 메소드를 사용한다.

스크린샷 2023-02-27 오후 10 37 23

해당 @Transactional이 붙은 메소드에서 던져진 exception을 Throwable 객체로 catch절에서 받고있다.

스크린샷 2023-02-27 오후 10 40 03

  • DefaultTransactionAttribute

스크린샷 2023-02-27 오후 10 43 31

  • rollbackOn 메소드

스크린샷 2023-02-27 오후 11 04 58

이 스프링 기본 구성인 DefaultTransactionAttribute구현에서 rollbackOn 메소드를 사용하게 되는데
이 rollbackOn에서 그토록 얘기했던 말들이 나온다.

RuntimeException이 여기서 채택되어 instanceof로 체크하고 있다!!!

그래서 rollback 마크를 하나를 진행해두고 나머지 트랜잭션 어노테이션에 대해서도 commit을 할거냐 rollback을 할거냐에 대한 작업을 이 해당 aop를 통해 동작한다.

TransactionManager

각 트랜잭션 매니저들은 AbstractPlatformTransactionManager 추상클래스를 상속하여 사용한다.

스크린샷 2023-02-27 오후 10 53 18

그래서 트랜잭션 매니저가 전부 commit을 수행하는데,
rollback 전용 마크가 하나라도 붙게되면 UnexpectedRollbackException 이 에러가 나타나게 된다.
그래서 보이게되는 예외 메시지가 Transaction silently rolled back because it has been marked as rollback-only로 나오게 된다.

그럼 Stream Closed 는?

ㅋㅋㅋㅋ.. 그러게 말이다. 이거 왜뜬건지 도저히 이해가 되지 않는데, dev환경에선 이 로그만 보였지 위의 로그가 안보였었다.
근데 로컬에서 이 부분을 수정하니 말끔히 해결됐다.
아무튼 원초적인 문제는 이 무지성 붙이기 @Transactional이었다.
막판에 이게 떠올라서 지웠더니 해결되서 꿀잠각이다.

아무튼 @Transactional에 대한 디버깅 정리를 해본다.

728x90

'디버깅' 카테고리의 다른 글

Kafka가 내 로직을 9번이나 재시도를 했다  (0) 2023.11.02
Stream 오류 제거  (2) 2023.04.21
FeignClient Logging level 디버깅  (0) 2022.12.17
@Async 사용시 에러 해결  (0) 2022.11.04

+ Recent posts