GithubHelp home page GithubHelp logo

elegantstar / api-homework-review Goto Github PK

View Code? Open in Web Editor NEW
0.0 1.0 0.0 107 KB

API 개발 과제를 진행하면서 배운 점을 잊지 않기 위한 기록입니다. 과제 내용은 드러나지 않도록 작성하였습니다.

Java 100.00%

api-homework-review's Introduction

취지

Triple Backend 엔지니어 채용 과제 전형을 진행하면서 느낀 점과 배운 점 등을 기록하기 위한 마크다운 문서입니다.
과제 내용이 직접적으로 드러나지 않도록 기술할 것이고, 토이 프로젝트가 아닌 실무라는 관점에서 개발을 진행하면서 배운 점들을 잊지 않기 위해 기록하려고 합니다.
당시의 생각, 사고 과정을 가감 없이 기록한 것이기 때문에 글의 내용은 지극히 개인적이고, 깔끔하게 정제되지 않을 수 있습니다.


기획에 대하여

토이 프로젝트를 진행할 때에는 내가 만들고 싶은 기능을 개발하거나, 학습한 내용을 프로젝트에서 실제로 사용하며 익히고자 하는 목적이 큰 부분을 차지했었다. 게다가 개인 프로젝트였기 때문에 내가 기획자이자 개발자인 상황이었다. 그러다 보니 의사 결정의 상황에서 기획자의 입장, 개발자의 입장에서 생각하기 보다는 '나'라는 한 사람의 입장에서 생각하게 되는 경우가 더러 있었던 것 같다.

이번 트리플 과제는 기획 내용을 바탕으로 개발을 진행하는 것이었기 때문에 부족했던 실무 경험을 직간접적으로 경험할 수 있는 좋은 기회가 되었다. 기획자의 의도와 목적을 이해하면서, 개발자의 입장에서 그것을 실현시키기 위해 어떻게 해야 할까를 먼저 고민할 수 있었기 때문이다. 처음 기획 내용을 받고 본격적으로 개발에 착수하기 전에, 요청 사항을 꼼꼼하게 이해하고 분석하는 과정에서 고민했던 부분들과 배운 점들을 아래에 간략히 정리한다. 내용의 순서와 중요도는 무관하다.


1. 현재 기획 내용만으로 요구 사항 실현이 가능한지 고민하자.

각 컴포넌트가 분리되어 있기 때문에 우리 컴포넌트의 DB와 클라이언트 컴포넌트의 DB가 다르다. 그렇기 때문에 요구 사항을 충족하는 서비스를 개발하기 위해 기획 내용이 충분히 상세하게 쓰여 있는지 분석하는 것이 중요하다고 느꼈다.

유저가 글을 게시할 때마다 A 컴포넌트로부터 B 컴포넌트에 특정 요청을 보내는 상황을 예로 들어 보자. 이때 글이 작성된 순서에 따라 순차적으로 요청하는 것이 보장되는 상황이라고 해도, 실제로 B 컴포넌트에서 요청을 처리하여 DB에 결과 데이터가 저장되는 순서는 요청 순서와 다를 수 있다. 쓰레드의 작업 처리 순서는 보장되지 않기 때문이다. 그런데 만약 글의 작성 순서를 보장하는 것이 중요한 요구 사항 중 하나라면, B 컴포넌트에서 글의 순서를 파악할 수 있도록 작성 시간 등의 데이터가 API 스펙 내에 포함되어 있어야 할 것이다.

또 다른 예로, 어떤 글이 '1자 이상' 입력되었을 때 특정 로직을 타야 하는데 공백 문자로 들어온 경우에 대해서는 어떻게 처리해야 하는지 명확하게 정의되어 있지 않았다. 물론 비즈니스적 관점으로 공백에 대해서는 필터를 거는 것이 맞다고 생각했지만, 기획이 명확하지 않을 때 개발자가 고민해야 하는 요소는 늘어날 수 있겠다는 걸 느꼈다.

물론 이것이 실무였다면 커뮤니케이션을 통해서 부족한 부분이나 오해가 있는 부분을 빠르게 해소할 수 있었을 것이다. 그러나 과제였기 때문에 다소 모호하다고 느낀 부분들이 존재했던 것도 사실이다. 이런 점들을 사전에 잘 캐치하고, 커뮤니케이션을 통해서 빠르게 해소해 나가는 것이 실무에서는 매우 중요한 역량 중 하나이지 않을까 생각한다.


2. MSA 관점에서 사고하자.

앞의 내용과 연결되는 내용일 수도 있는데, 컴포넌트가 분리되어 있기 때문에 우리 컴포넌트에서는 DB를 어떻게 설계할 것인지에 대한 고민도 중요한 포인트였다고 생각한다.
MSA라는 것은 커다란 단일 프로그램을 여러 개의 컴포넌트로 나누어 작은 서비스의 조합으로 전체 프로그램을 구축하는 방법이다. 각 컴포넌트는 API를 통해 통신하고, 독립된 서비스로 개발되기 때문에 타 컴포넌트에 대한 의존성이 없다. 이러한 특징은 DB에도 적용되어 각 컴포넌트는 하나의 DB를 공유하는 것이 아니라, 각 서비스에 맞게 별도의 DB를 구축하여 사용한다. 따라서 각 컴포넌트의 역할과 책임을 잘 이해하고, 그에 적합한 방식으로 DB를 설계해야 한다는 걸 배울 수 있었다.

이런 부분이 개인 프로젝트를 진행할 때에는 경험하지 못했던 중요한 부분이라고 느꼈다. 요청 시에 받은 데이터를 우리 컴포넌트의 서비스에 맞게 가공하여 저장하고, 각 엔티티의 연관 관계 또한 우리 컴포넌트의 사용 목적에 맞게 설계하도록 하자.


3. 현재 기획만 생각하지 말고, 확장성을 고민하자.

개발자는 항상 확장성을 고려하여 개발 해야 한다는 이야기를 많이 들었는데, 이번 기회를 통해서 이런 부분들이 왜 중요한지 체감할 수 있었다. 현재 기획에서 요구하는 정책이 미래에도 영원히 유지되는 서비스는 극히 드물 것이다. 따라서 개발자는 추후 정책이 변경되었을 때 지금의 코드를 크게 수정하지 않고도 대응할 수 있는지를 고민하는 훈련이 필요하다. 나 역시도 이번 과제를 진행하면서, 현재 기획 상에서 제한되어 있던 부분들이 확장 되었을 때 유연하게 대처할 수 있을지 고민하려고 노력했다.

완벽하게 동일한 상황으로 볼 수는 없지만, 마케터로 근무했던 경험이 의외로 도움이 되었던 점도 있었다. 개발 외적으로 기획이나 비즈니스적인 부분, VOC 유발 요소에 대한 고민들은 항상 달고 살던 것들이기 때문에 좀 더 잘 캐치할 수 있었던 것 같다. 그럼에도 불구하고 놓치고 있는 부분이 있을 수도 있지만, 개발자로서 기획을 받고 설계하는 시점에 어떤 고민들을 해야 할지 경험할 수 있었다는 점에서 한 단계 더 성장할 수 있는 경험이었다고 생각한다.


4. 장애 상황과 후속 대응에 대해서도 고민해야 한다.

서버 개발자는 특히 장애 상황에 대한 고민이 중요하다고 알고 있다. 토이 프로젝트를 진행할 당시에는 장애 상황에 대해 크게 고민하지는 못했던 것 같다. 아무래도 당장 공부했던 것들을 적용하기에도 벅찬 상황이었고, 실무가 아니라는 생각이 컸기 때문일 것이다.

그러나 이번 과제를 받고 나니, 내가 개발자로 입사하면 이것이 현실이라는 자각이 크게 들었다. 그 생각이 들자마자 '우리 서버에 장애가 발생하여 API가 동작하지 않는다면 어떻게 해야 할까?' 라는 고민이 스쳤다.

실무 경험이 부족하기 때문에 정답은 아닐 수도 있지만 내가 생각한 시나리오는 이렇다.

  1. 클라이언트 컴포넌트에서 요청이 실패했을 때 일정 횟수 이상 재시도를 하도록 사전에 협의를 한다.
  2. 지정된 횟수 이상 실패하는 경우, 서버에 장애가 발생했다고 판단하여 요청하지 못한 데이터를 따로 저장해 둔다.
  3. 이후 서버가 정상화되었을 때, 밀린 요청들을 일괄적으로 받아서 처리하도록 한다.
  4. 이때, 로직 구조 상 동일한 데이터에 대한 요청은 최신 요청만 반영하면 되므로 클라이언트 컴포넌트 측에서는 최신 요청 순으로 요청하도록 협의한다.
  5. 하지만 이렇게 해도 요청이 반드시 최신순으로 처리된다고 보장할 수가 없다. 우리 컴포넌트 측에서는 받은 요청이 반드시 최신순임을 확인할 방도가 없고, 최신순이 확실하더라도 쓰레드가 요청을 받아서 처리하는 것은 순서를 보장하지 않기 때문이다. 그렇기 때문에 꼭 요청 데이터에 이벤트 발생 시간 정보를 추가하는 협의가 필요하다.


개발 과정에서 알게 된 것들

토이 프로젝트가 끝나고 개발자인 동생과 간단히 리뷰를 진행하면서 지적/조언 받은 부분이 꽤 많았다. 고맙게도 실무적인 조언들을 많이 해주었던 덕분에 실무에서 중요하게 고민해야 하는 부분들에 대해 어느 정도 감을 잡을 수 있었다. 본격적인 채용 준비를 시작하느라 피드백을 프로젝트에 반영하지는 못했고, 따로 메모만 해두었었는데 이번 과제에 최대한 반영해 보려고 노력했다.

그래서 이후 이어질 내용들은 다음과 같다.

  • 들을 때는 이해할 수 없었지만, 직접 경험해 보니 이해하게 된 것
  • 전혀 몰랐는데 이번 기회에 알게 된 것
  • 잊어 버리기 전에 기록해 두고 싶은 것

Custom Response의 필요성 (feat. Cutom Exception)

이번 프로젝트에서 그 필요성을 여실히 체감할 수 있었던 것 하나를 꼽으라면 Custom Response를 꼽고 싶다. 왜냐하면 제대로 된 API를 개발해 본 경험이 없었기 때문에 머리로는 그 필요성을 알 것 같으면서도, 실제로는 전혀 체감되지 않았던 부분이기 때문이다. 따라서 Custom Response를 도입하게 된 과정을 간략하게 기록해 두려고 한다.

1. HttpStatusCode의 한계

처음에는 ResponseEntity를 사용하여 데이터를 반환하는 방식으로 개발했다. 이전 프로젝트에서도 RestController를 사용하긴 했으나 그 수가 적었고, 예외가 터지면 에러 페이지를 반환하면 된다는 생각 뿐이었다. 어차피 서버는 하나고, 개발자도 나 하나였기 때문이다. 따라서 서비스에 따라 서버가 분리되어 서로 API로 통신하는 상황을 생각해 보지 못했다.

그러나 막상 개발을 하다 보니, 예외가 발생할 수 있는 상황이 예상보다 많았다. 그제서야 알게 되었다. 클라이언트 컴포넌트 측에 왜 요청이 제대로 처리 되지 못했는지를 알려줄 무언가가 필요하다는 것을.
HttpStatusCode는 그 모든 내용을 표현하기에 매우 제한적이라는 것을 확실하게 체감할 수 있었다.

2. Custom Exception (feat. ReturnCode)

Controller, Service, Repository 각 계층에서 발생할 수 있는 예외 케이스들을 생각해보니 꽤 다양한 예외들이 존재했다. 이를 쉽게 다루기 위해서 각 예외 상황에 대해 Custom Exception을 만들었다. 그리고 각각의 예외가 어떤 예외인지를 표현할 ReturnCode enum을 만들었다. 이전에 프로젝트를 진행하면서 동생한테 조언을 들었던 내용이었기에 많은 도움이 되었다.

ReturnCode의 code는 HttpStatusCode에서 아이디어를 얻어 4자리 숫자로 만들었다. 성공은 2000, 내부 서버 에러는 5000, 잘못된 요청에 대해서는 4000번 대, DB 에러는 5100번 대 숫자를 사용하여 임의로 설정하였다.

사실 그 전까지는 예외 처리에 대해서도 잘 이해하고 있지 못했다. 돌이켜 보면 이전 프로젝트에서도 제대로 사용하지 못했던 것 같다. 그러나 필요성을 여실히 체감하고 예외 처리에 대한 부분을 다시 공부해 보니 확실히 이해할 수 있었다. @ControllerAdvice@ExceptionHandler를 이용하여 예외를 처리하도록 코드를 수정했다.

3. Custom Response

여기까지 진행한 뒤, ReturnCode를 담을 ApiResponse 클래스를 만들었다. ApiResponse 초기에는 ReturnCode의 code와 message만을 담고 있었으나, 조회 Api를 만들면서 data 필드를 추가하였다. 이로써 응답 코드와 응답 메시지, 응답 데이터를 모두 담는 Custom Response가 완성되었다.

말로만 듣고 제대로 이해하지 못했던 Custom Response였는데 이 과정을 한 번 겪고 나니 확실하게 이해할 수 있었다. 실무를 경험해 본 것은 아니지만, 실무적 조언을 이해하고 직접 적용해 볼 수 있었다는 점에서 이번 과제의 가장 큰 수확 중 하나라고 생각한다.


테스트의 중요성

테스트 코드는 토이 프로젝트에서도 작성해 본 경험이 있지만, 그때는 Repository와 Service Layer에 대해서만 테스트 코드를 작성했었다. 이번 기회에 Controller Layer에 대한 단위 테스트 코드도 작성해 볼 수 있었고, 통합 테스트 코드도 작성해 볼 수 있었다. 토이 프로젝트를 진행할 때와 지금 상황에서의 차이라고 한다면, 이전 보다는 지금이 아무래도 개발에 대한 이해도가 좀 더 생긴 시점이기 때문에 그 필요성과 목적 또한 좀 더 명확하게 인지하고 있다는 점이라고 본다. 그 덕분에 의무적으로 테스트 코드를 짜기 보다는 테스트 케이스를 파악하고, 검증하는 과정을 연습한다는 생각으로 임할 수 있었다. 그 과정에서 IntelliJ의 Test with Coverage 기능을 알게 되어 놓치고 있는 부분은 없는지 확인하며 꼼꼼하게 테스트하였다.

중요한 것은 실제로 테스트 코드 덕분에 미처 파악하지 못했던 이슈를 발견하여 해결할 수 있었다는 것이다. 단위 테스트를 통해 발견한 버그를 수정하기도 했고, 막바지에는 통합 테스트를 진행하는 과정에서 미처 파악하지 못했던 동시성 이슈를 발견하여 해결하기도 했다. 통합 테스트에서 발견한 이슈에 대해서는 이후 이슈 항목에서 정리하고자 한다.


INDEXING 전략

조회 쿼리의 성능 향상을 위해 인덱스를 생성하였다. 토이 프로젝트에서도 Mysql의 실행 계획을 확인하며 Full Table Scan이 발생하지 않도록 인덱스를 생성하였기 때문에 어려운 일은 아니었다. 다만, 기존에는 인덱스를 개별 컬럼에 대해서만 만들어서 사용하였는데, 이번에는 조회에 필요한 여러 컬럼들에 대한 인덱스를 만들게 되었다. Fulltext search를 위한 fulltext index를 생성할 때에도 여러 컬럼을 묶어서 인덱스를 생성했던 것이 생각났기 때문이다. 이렇게 인덱스를 생성하므로써 한 개의 컬럼에 대한 인덱스만 이용하는 것보다 조회 성능을 향상할 수 있었다. 데이터베이스 과목을 공부할 때 분명 공부했던 내용이었는데, 왜 이를 실제 개발에서는 적용해 보지 못했을까 하는 생각이 들었다.

물론 불필요한 인덱스의 생성은 오히려 삽입/삭제/수정 쿼리에 대한 성능을 저하시키는 요소이기 때문에 주의가 필요하다는 것도 알고 있다. 만약 현재 인덱스를 생성한 컬럼보다 더 중요한 컬럼이 추가되거나, 성능의 저하가 나타나는 경우에는 그에 맞춰서 인덱스를 변경하는 편이 좋을 것으로 보인다. 과제 상세 내용에 인덱스에 대한 언급이 있었기 때문에 지금 이 인덱스가 최선의 방법일까 의심하는 과정에서 또 하나를 배울 수 있었다. 이론적으로 공부했던 내용을 개발 과정에서는 사용하지 못하고 있는 케이스가 꽤 많이 있는 것 같다. 그런 부분들을 찾아서 계속해서 채워나갈 수 있도록 노력해야겠다.



주요 이슈 사항

이어질 내용은 개발 중에 맞닥뜨렸던 이슈들에 대한 내용이다. 동시성 이슈에 대한 내용이 많아질 것 같은데, 동시성 이슈를 고려해 본 경험이 없기 때문에 멀티 쓰레드 환경에 대한 생각이 많이 어려웠다. 이번 트리플 과제 덕분에 동시성 이슈에 대해 많이 고민해 볼 수 있었고, 확실하게 이해하고 있어야 하는 부분이므로 이번 기회에 정리해 두고자 한다.


[동시성 이슈 1] LOCK (Pessimistic Lock vs Optimistic Lock)

이번 과제에서 동시성 이슈를 처음으로 마주한 순간이다. 동시성 이슈는 여러 쓰레드가 하나의 데이터에 접근하여 변경을 시도할 때 기대한 결과와 다른 결과가 나타나는 문제를 말한다. 당시 코드에서는 @Transaction 외에는 별다른 작업을 한 것이 없었기 때문에 동시성 이슈가 발생하지는 않을지 테스트를 해보았다. 테스트는 간단하게 Executors를 이용하여 100개의 쓰레드를 갖는 쓰레드 풀을 만들고, 반복문과 RestTemplate을 이용하여 동시에 요청을 보내는 방식으로 진행했다. 서버에서는 그 요청을 받아서 기존의 값을 변경하는 작업을 진행하는데 이 부분에서 문제가 발생하였다. 최종 결과값이 기대값과 전혀 다른 값으로 나타난 것이다.

문제는 너무나 당연하게도 여러 쓰레드에서 동시에 값의 변경을 시도했기 때문이다. 사실 그 전까지는 @Transaction만 붙여주면 Lock이 걸릴 것이라고 오해하고 있었다. Lock을 직접 걸어 본 적이 없기 때문에 나 혼자 그렇게 생각하고 있었던 것 같다. 이후 Lock에 대해 공부를 하면서 Pessimistic LockOptimistic Lock이 존재한다는 것을 알게 되었다. 간단히 정리하면 Pessimistic Lock은 DB에 직접 Lock을 거는 것이고, Optimistic Lock은 DB에 Lock을 거는 것이 아니라 application 레벨에서 충돌 상황이 발생하는 것을 방지하는 것이다.


Optimistic Lock

낙관적 락(Optimistic Lock)이란 DB 충돌 상황을 개선하기 위한 방법으로 어떤 데이터를 수정할 때 해당 데이터가 수정되었음을 명시하므로써 다른 쓰레드에서 값을 변경하지 못하도록 막는다.
즉, DB의 기능을 이용하는 것이 아니라 application level에서 Lock을 거는 방법이다. 따라서 Optimistic Lock은 구현 아이디어와 기술에 따라 그 방법이 무궁무진하다.

Spring Data Jpa에서는 @Version이라는 애너테이션을 이용할 수 있는데 Optimistic Lock을 걸고 싶은 엔티티에 version이라는 필드를 추가하고 @Version 애너테이션을 붙여주면 된다. version의 data type은 int, Integer, long, Long, short, Short 등의 숫자 타입이 가능하고, Timestamp 또한 가능하다.

원리는 매우 간단한데, 데이터를 update하는 경우 version의 값을 1 증가시킨다. update를 위해 select 할 때 version 값과 update 직전의 version 값이 다르면 다른 곳에서 그 사이에 변경 작업을 진행한 것이기 때문에 update하지 않도록 하는 것이다.

Pessimistic Lock

비관적 락(Pessimistic Lock)이란 트랜잭션이 시작될 때 Shared Lock 또는 Exclusive Lock을 걸고 시작하는 방법. 실질적으로 DB에 직접 락을 걸어 다른 쓰레드의 접근을 제한한다.
비관적 락은 동시성 이슈를 확실하게 제어할 수 있는 방법이지만, 다른 쓰레드의 접근을 제한하기 때문에 락의 범위가 넓은 경우에는 성능의 저하가 커질 수 있다.
또한, 데드락(Deadlock)이 발생할 가능성이 있기 때문에 사용에 주의가 필요하다. 데드락은 DB 접근 순서를 항상 동일하게 만드는 것으로 해결할 수 있다.

Spring Data Jpa를 사용하고 있다면, @Lock 애너테이션으로 간단하게 사용할 수 있다. @Lock 애너테이션은 @Transaction과 마찬가지로 AOP로 동작하며, Repository 내의 쿼리에 붙이거나 Service 계층의 메서드에 붙여서 사용할 수 있다.
Service 메서드에 락을 거는 경우는 락의 범위가 커지기 때문에 퍼포먼스를 고려해야 한다.


해결 방법: Optimistic Lock에서 Pessimistic Lock로

이러한 내용을 알고 처음에는 Optimistic Lock으로 구현해 보았다. 그러나 결과값은 기대값과 달랐다. 왜냐하면 Optimistic Lock은 변경에 실패했을 경우 그대로 작업이 끝나버리기 때문이다. 따라서 변경에 실패하면 그 후속 처리 과정이 필요하다. 문제는 그 후속 처리 과정에서 또 실패하게 되었을 경우에 대한 의문이 또 다시 따라오게 된다. 그렇기 때문에 Pessimistic Lock으로 눈을 돌렸다.

Pessimistic Lock은 성능을 저하시킬 수 있다는 것을 알고 있었다. 그러나 해당 작업이 그렇게 무거운 작업이 아니고, 업데이트를 위한 조회 쿼리에만 Lock을 걸어도 되는 부분이었기 때문에 Lock의 범위를 매우 좁힐 수 있었다. 또한, Lock을 걸어 둔 데이터에 동시에 접근하는 사례가 그렇게 많지 않지는 않을 것으로 판단하여 퍼포먼스 부분에서 크게 문제가 되지는 않을 것으로 보았다. 만약 서비스의 규모가 커지고 Pessimistic Lock으로 인한 성능 저하가 발생하게 된다면 그때 코드를 수정해도 늦지 않을 것이다.

이런 과정을 거쳐서 업데이트를 위한 조회 쿼리에는 @Lock(LockModeType.PESSIMISTIC_WRITE) 애너테이션을 붙였고, 만약 장시간 Lock을 취득하지 못하는 경우에는 Exception이 발생할 수 있도록 @QueryHints를 이용하여 timeout을 설정했다. timeout 시간은 5초로 설정하였는데, mysql의 기본 설정은 아마 50초였던 것으로 기억한다. 해당 Repository의 메서드는 다음과 같다.

@Lock(LockModeType.PESSIMISTIC_WRITE)
    @QueryHints({@QueryHint(name = "javax.persistence.lock.timeout", value ="5000")})
    Optional<Point> findForUpdateByUserId(String userId);

그리고 당연하게도, LockAcquisitionException이 발생할 경우 이 예외를 잡아서 Custom Exception으로 변환하여 처리하는 로직도 추가하였다. 또한, 해당 쿼리는 select ... for update 쿼리이기 때문에 단순 조회용 쿼리는 따로 만들어 용도를 분리하였다. 이렇게 Pessimistic Lock을 적용하는 방법으로 동시성 이슈를 해결할 수 있었다.


후기

이번 이슈는 가장 기본적인 동시성 이슈로서, Transaction과 Lock의 개념이 혼재되어 있던 것을 바로 잡을 수 있는 계기가 되었다. 멀티 쓰레드 환경에서는 Race Condition이 발생할 수 있는 상황을 항상 파악하고 있어야 하며, Lock을 걸어야 하는 상황이라면 어떤 전략을 선택하는 것이 좋을지도 판단할 수 있어야 한다는 걸 배울 수 있었다. Pessimistic Lock과 Optimistic Lock의 차이와 각각의 장단점을 이해할 수 있었고, Optimistic Lock은 다양한 방법으로 구현이 가능하다는 것도 공부할 수 있었기 때문에 좋은 이슈였다고 생각한다.


[동시성 이슈 2] DEADLOCK with GAP LOCK

제출 기한 마지막 날, 통합 테스트 코드를 작성하고 발견한 이슈. 제출 당일 점심 먹고 발견한 이슈였기에 당황스러웠다. 통합 테스트 시나리오는 100개의 삽입 요청을 동시에 날리는 것으로부터 시작하는데, 이 삽입 요청에 대해서 Deadlock이 발생했다. 당황스러웠던 이유는 Deadlock이 발생할 부분이 딱히 없어 보였기 때문이다. 이 이슈를 발견하게 된 과정은 다음과 같다.

  • 제출 후 채용담당자님께서 실행하는 과정에 문제는 없을지 리허설을 진행했다.
  • Docker를 이용하여 Mysql을 연결했고, 테이블이 비어있는 상태에서 통합 테스트를 진행했다.
  • 데이터 삽입 요청에 대해서 Deadlock이 발생했고, Deadlock이 발생했기 때문에 LockAcquisitionException이 터졌다.

Daedlock이 발생했기 때문에 Lock이 걸린 부분이 문제가 되는 것은 확실했기에 구글링을 시도했고, 원인을 알게 되었다. 바로 Index에 걸리는 Lock이 문제였다. 이슈 발생 과정은 다음과 같다.

  • 빈 테이블에 최초의 select ... for update 쿼리가 실행된다.
  • InnoDB는 Table row가 아닌 Index record에 Lock을 걸기 때문에 해당 index를 찾아 Locking을 시도한다.
  • index record가 없는 부분을 Gap이라고 하는데, select ... for update 쿼리를 실행하게 되면 해당 index가 존재하는 Gap에 Lock이 걸린다. 이를 Gap Lock이라고 한다.
  • Gap Lock이 걸리면 다른 트랜잭션이 해당 Gap에 존재하는 인덱스에 대한 삽입/삭제/수정 작업이 불가능하다.
  • 문제는 테이블 전체가 비어있는 상태이기 때문에 모든 인덱스 구간이 Gap이 되므로 index 전체에 gap lock이 걸리는 것이다.
  • 이런 이유로 Daedlock이 발생하게 되고, Lock을 취득하지 못한 트랜잭션들은 Timeout 설정에 의해 LockAcquisitionException을 던지는 것이었다.

해결 방법

이 문제가 발생하게 된 이유는 어떤 데이터가 존재하지 않으면 해당 데이터를 삽입하고, 존재하면 수정하는 작업을 하나의 메서드에서 실행하려고 했기 때문이다. 즉, 삽입과 수정을 하나의 메서드에서 구현한 것이다.
그렇기 때문에 삽입과 수정 작업을 따로 분리하는 방식으로 문제를 해결하였다. 이렇게 분리하면 로직은 다음과 같다.

  1. 데이터가 없으면 기본 값으로 삽입 -> 충돌이 발생하면(그 사이에 다른 쓰레드에서 데이터를 삽입하는 경우) 예외를 잡아서 무시
  2. 기본값의 데이터를 원하는 값으로 변경(select ... for update)

이로써 Gap Lock에 의한 데드락 이슈는 해결되었다. 다만, 1번 로직에서 또 다른 이슈가 발생했는데 충돌이 발생할 경우 예외를 잡는 코드가 정상 동작을 하지 못하고 롤백을 하게 된 것이다. 바로 다음에 나오는 이슈가 바로 이것이다.


후기

바로 이전 이슈를 통해 Lock을 이용하여 동시성 이슈를 제어하는 방법을 배웠다면, 이번 이슈는 MySql InnoDB의 Locking 방식에 대해 배울 수 있는 계기였다. Lock을 적용하는 방식은 DB Engine마다 다르기 때문에 이번 이슈는 InnoDB의 특징을 이해하지 못해서 발생한 이슈이다. 두 개의 동시성 이슈를 순차적으로 겪으면서 깨달은 사실은 동시성 이슈가 발생할 것을 예상했어도, DB를 정확히 알지 못하면 해결에 많은 공수가 들어갈 수 있다는 것이었다. 따라서 개발자가 DB를 잘 알고 있어야 하는 이유는 이런 문제 상황을 예측하거나, 예상치 못한 DB 문제가 발생하였을 때 빠르게 대처할 수 있어야 하기 때문이라는 생각을 하게 되었다. 좋은 개발자가 되기 위해서 앞으로 이런 부족한 DB 지식 역시 계속해서 채워나가야겠다.


참고: InnoDB Lock의 종류

Record Lock

레코드 자체만을 잠그는 것을 레코드 락이라고 하며, 다른 사용 DBMS의 레코드 락과 동일한 역할을 한다. 한 가지 중요한 차이는 InnoDB 스토리지 엔진은 레코드 자체가 아니라 인덱스의 레코드를 잠근다는 점이다. 만약 인덱스가 하나도 없는 테이블이라고 하더라도 내부적으로 자동 생성된 클러스터 인덱스를 이용해 락을 건다. InnoDB에서는 대부분 보조 인덱스를 이용한 변경 작업은 Next Key Lock 또는 Gap Lock을 사용하지만, Primary Key 또는 Unique Index에 의한 변경 작업은 Gap에 Lock을 걸지 않고 레코드 자체에 대해서만 Lock을 건다.

Gap Lock

다른 DBMS와의 또 다른 차이가 바로 GAP Lock이라는 것이다. Gap Lock은 레코드 그 자체가 아니라 레코드와 바로 인접한 레코드 사이의 간격만을 잠그는 것을 말한다. Gap Lock의 역할은 레코드 사이의 간격에 새로운 레코드가 생성(INSERT)되는 것을 제어하는 것이다. 사실 Gap Lock이라는 것은 개념일 뿐 자체적으로 사용되지는 않으며, Next Key Lock의 일부로 사용된다.

Next Key Lock

Record Lock과 Gap Lock을 합쳐 놓은 형태의 Lock을 Next Key Lock이라고 한다.

Index와 Lock

InnoDB의 Lock은 레코드를 잠그는 것이 아니라 인덱스를 잠그는 방식으로 처리된다. 즉, 변경해야 할 레코드를 찾기 위해 검색한 인덱스의 모든 레코드를 잠가야 한다. InnoDB의 이러한 특성을 잘 이해하고 있지 않으면 Next Key Lock 또는 Gap Lock으로 인한 Dead Lock이 발생하거나 다른 Transaction을 대기하게 만드는 일이 자주 발생한다.

이런 이슈는 어떤 회원의 전화번호를 변경하는 쿼리를 예로 들어 이해해 볼 수 있다. 상황은 다음과 같다.

  1. 인덱스는 firstname_idx만 존재한다.
  2. 'Ethan'이라는 first_name을 가진 회원은 231명이 존재한다.
  3. first_name='Ethan'이고, last_name='Park'인 회원은 단 1명만 존재한다.
UPDATE member SET phone_number='010-1234-5678'
WHERE first_name='Ethan' AND last_name='Park';

UPDATE 문장이 실행되면 1건의 레코드가 업데이트 될 것이다. 하지만 이 1건의 업데이트를 위해 몇 개의 레코드에 락을 걸어야 할까? 이 UPDATE 문장의 조건에서 인덱스를 이용할 수 있는 조건은 first_name='Ethan'이며, last_name 컬럼은 인덱스가 없기 때문에 first_name='Ethan'인 231건의 레코드에 전부 Lock이 걸린다.

현재 예시에서는 몇 건 안 되는 레코드가 잠기지만 UPDATE 문장을 위해 적절한 인덱스가 준비되어 있지 않다면 각 클라이언트 간의 동시성이 상당히 떨어져서 한 세션에서 UPDATE 작업을 하고 있는 중에는 다른 클라이언트는 그 테이블을 업데이트하지 못하고 기다려야 하는 상황이 발생할 것이다.

만약 테이블에 인덱스가 하나도 없다면 어떻게 될까? 이러한 경우에는 테이블을 풀 스캔하면서 UPDATE 작업을 하는데, 이 과정에서 테이블에 있는 모든 레코드에 Lock을 걸게 된다. 이것이 MySQL의 Locking 방식이고, MySQL의 InnoDB에서 인덱스 설계가 중요한 이유이다.


@Transactional의 Propagation (이게 왜 롤백이 되지?)

트랜잭션 메서드 내부에서 또 다른 트랜잭션 메서드를 호출할 때, 내부 트랜잭션 메서드에서 예외를 던지면 발생하는 문제. 내부 트랜잭션 메서드에서 예외를 던지면, 외부 트랜잭션 메서드의 try/catch로 해당 예외를 처리하지 못하고 rollback이 되는 문제가 발생했다.

문제의 로직.

@Transactional
public void createPointIfAbsent(String userId) {
  try {
    if (pointRepository.findByUserId(userId).isEmpty) {
      pointRepository.save(Point.createPoint(userId, 0));
    }
  } catch (Exception e) {
    //ignore
  }
}

Point를 저장하는 과정에서 중복 등록이 감지되어 예외가 터지면, 예외를 잡아서 무시하고 끝내는 로직이다. 테스트 과정에서 pointRepository.save()에 실패하여 예외가 발생하였고, 의도대로라면 try/catch로 잡아서 처리되어야 했다.
그런데 실제로는 예외를 잡지 못했고, 그대로 롤백이 되었다. 이때 찍힌 에러 로그는 다음과 같다.

UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only at o.s.transaction.support.

'예상치 못한 롤백 예외', 이름부터가 직관적이다. 내용을 보면 transaction과 관련된 문제임을 알 수 있다. 중요한 키워드는 marked as rollback-only이다.

문제 발생 과정

문제가 발생하는 원리는 다음과 같다.

  • createPointIfAbsent 메서드로부터 최초의 트랜잭션이 시작된다.
  • pointRepository의 save 메서드를 호출하면서 해당 메서드가 이미 만들어진 트랜잭션에 참여한다. pointRepository.save()가 트랜잭션 메서드인 이유는, JpaRepository를 상속하는 Repository 인터페이스의 구현체가 SimpleJpaRepository인데 SimpleJpaRepository의 save() 메서드에 @Transactional이 걸려있기 때문이다. 또한, 이미 만들어진 최초의 트랜잭션에 참여하는 이유는 @Transnactionalpropagation default 속성이 PROPAGATION_REQUIRED이기 때문이다.
  • pointRepository.save() 트랜잭션 작업 중 문제가 발생하여 예외가 터진다. 여기서 알아두어야 할 사실은, 전파 속성(propagation)에 의해 실제로는 트랜잭션이 재사용되더라도 각 트랜잭션의 반환 시점마다 완료 처리(completion)가 일어난다는 것이다. 물론 commit/rollback 같은 최종 완료 처리는 최초의 트랜잭션이 리턴될 때 일어난다.
  • save 메서드 내부에서 따로 try/catch로 예외를 처리하지 않으므로 RuntimeExcption을 던지고 트랜잭션이 완료 처리 된다. @Transansactional은 RuntimeException 발생 시 Rollback하는 것이 기본 설정이므로 rollback이 일어나야 한다. 그러나 rollback은 최초의 트랜잭션이 완료되는 시점에 진행되어야 하므로 즉시 rollback 하는 대신, 참여한 트랜잭션의 실패를 선언하고 rollback-only를 marking한다. 이제 에러 로그의 has been marked as rollback-only가 의미하는 바를 알게 되었다.
  • 여기까지가 예외가 발생한 내부 트랜잭션의 완료 처리 과정이다.
  • 내부 트랜잭션에서 발생한 예외를 최초의 트랜잭션에서 catch하고, 최초 트랜잭션 메서드가 완료 처리를 진행한다.
  • 예외는 잡았고, 다른 문제는 발생하지 않았으니 최종 commit을 진행한다. 그런데 commit하려는 순간 rollback-only가 마킹되어 있음을 감지하고 롤백이 진행된다.

결론은 트랜잭션 내부에 또 다른 트랜잭션이 있는 경우, 내부 트랜잭션에서 예외가 던져지면 예외를 잡지 못하고 롤백이 진행된다. 즉, 참여 중인 트랜잭션이 실패하면 전역 롤백 처리하는 것이 스프링의 기본 정책인 것이다. 물론, propagation 설정을 PROPAGATION_REQUIRED가 아닌 REQUIRES_NEW로 선언하는 방법도 있지만, 설정 변경으로 야기될 수 있는 문제도 있을 것이기에 사용에 주의해야 할 것 같다.


해결 방법

구글링을 통해 방법을 찾아 보았으나 propagation 속성을 REQUIRES_NEW로 바꾸는 것 외에 다른 방법을 찾지 못했다. 스프링 트랜잭션에 대해 깊이 있게 이해하고 있는 것이 아니기에 전파 속성을 변경하는 것은 리스크가 크다고 생각하여 사용하고 싶지 않았다.

결국 내가 선택한 방법은 트랜잭션을 하나만 두는 것이다. Repository의 save 메서드에서 발생하는 예외는 내가 직접적으로 컨트롤할 수 없기 때문에 createPointIfAbsent 메서드의 트랜잭션을 제거하는 방법으로 문제를 해결하였다.

내가 원하는 동작은 save가 실패하면 아무 것도 하지 않고 그대로 메서드를 종료하고 빠져나오는 것이었다. save 메서드는 그 자체로 트랜잭션 메서드이기 때문에 createPointIfAbsent 메서드를 트랜잭션으로 묶지 않아도 문제가 발생하지 않기 때문이다.

단, 동작에는 문제가 없었지만 SQL 쿼리 실패로 인한 에러 로그가 강제로 찍히는 것은 여전히 감수해야 했다.


후기

Spring의 트랜잭션 동작에 대해서 새롭게 알게 되는 계기였다. 이 문제를 겪기 전까지 Transaction의 propagation 속성이 있다는 것조차 모르고 있었기 때문이다. 다행히 우아한형제들 기술 블로그에 나와 똑같은 현상을 겪고, 이에 대해 자세하게 정리해 놓은 글이 있어서 많은 도움이 되었다. 덕분에 복잡한 내부 로직을 쉽게 이해할 수 있었다.

처음 개발을 시작할 때에는 '그래, 내가 모르는 건 당연하지!' 라는 생각으로 공부했었다. 하지만 지금은 '나는 얼마나 알고 사용하고 있는 걸까?' 하는 생각이 들 때가 많다. 지금까지 공부했던 내용에 비해 한 층 더 깊게 내려가야 할 시기가 온 게 아닐까? 그러기 위해서 내가 공부했던 내용들에 대해서 한 번 더 정확하게 정리하는 시간을 가져야 할 것 같다.

[참고] https://techblog.woowahan.com/2606/


api-homework-review's People

Contributors

elegantstar avatar

Watchers

 avatar

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.