├── README.md ├── code-and-architecture └── principles-of-writing-code.md ├── deployment └── forward-and-backward-compatibility.md ├── distributed-system ├── distributed-system-concerns.md └── eventual-consistency.md ├── general ├── be-careful-with-data.md ├── how-to-grow.md ├── images │ └── mece.png ├── useful-habits.md └── useful-thinking-strategies.md ├── project ├── release-plan.md ├── roles-of-server-developer.md └── working-with-proposals.md ├── reactive-programming ├── how-project-reactor-works.md └── images │ ├── how-spring-reactor-works-1.png │ ├── how-spring-reactor-works-2.png │ └── how-spring-reactor-works-3.png └── situations-and-patterns ├── batch-job.md ├── native-client.md └── server-application-patterns.md /README.md: -------------------------------------------------------------------------------- 1 | # Basics of Server Development 2 | 3 | ## 개요 4 | 5 | 이 레포지토리는 내가 시니어의 직접적인 케어 없이 홀로 개발을 할 수 있게 되는 데에 가장 중요했던 요소를 모아놓은 저장소이다. 굳이 "요소"라는 애매한 워딩을 사용한 이유는 이 문서에 작성된 글이 다양한 분야의 다양한 주제를 다루기 때문이다. 각 문서의 주제는 개발할 때의 원칙/태도일 수도, 개발 전략일 수도, 혹은 특정 지식일 수도 있다. 이는 개발자로서 1인분을 하기 위해서는 특정 분야에 치우치지 않고 다방면으로 학습하는 게 중요하다는 반증이기도 하다. 6 | 7 | 이 문서는 레이 달리오의 \를 읽은 것이 계기가 되어 작성하게 되었다. 많은 생각을 하게 만들어준 책이었는데, 그 중 하나는 개발을 함에 있어서도 일반적으로 지켜야 하는 원칙이 꽤 많다는 점이었다. 레이 달리오가 인생과 투자의 원칙을 세운 것처럼, 나도 개발의 원칙을 세워 나보다 내가 세운 원칙을 기준으로 개발을 하고, 이런 원칙들을 다른 사람들과 공유하면 좋겠다는 생각이 들었다. 8 | 9 | ## 목차 10 | 11 | 아래 목차 순서는 중요도 순이 아니며, 프로젝트의 라이프 사이클을 기반으로 하여 내 임의로 결정한 순서이다. 12 | 13 | 최대한 특정 기술 스택에 관련되지 않은 내용만을 포함하려고 했으나, 일부 예외가 있다. Reactive Programming이나 모바일 클라이언트 구현 등의 내용이 대표적인데, 이 기술을 사용하지 않더라도 알고 있으면 좋은 지식이라 생각하여 포함하였다. 14 | 15 | 이미 좋은 글이 있거나 내가 이전에 작성한 글이 있어서 내가 별도로 작성할 필요가 없는 문서는 링크를 대신 걸어두었다. 16 | 17 | * 일반론 18 | * [어떻게 성장할 것인가?](/general/how-to-grow.md) 19 | * [개발 시 유용한 개념과 사고 전략](/general/useful-thinking-strategies.md) 20 | * [유용한 개발 습관](/general/useful-habits.md) 21 | * 커뮤니케이션 잘하기 22 | * 코드와 설계 23 | * [코드 작성 원칙](/code-and-architecture/principles-of-writing-code.md) 24 | * [소프트웨어 설계](https://suhwan.dev/2020/04/11/backend-application-design-202004/) 25 | * 프로젝트 26 | * [서버 개발자의 역할은 무엇인가?](/project/roles-of-server-developer.md) 27 | * [기획서와 친해지기](/project/working-with-proposals.md) 28 | * [출시 계획 고려하기](/project/release-plan.md) 29 | * 데이터와 DB 30 | * [데이터의 중요성과 위험성](/general/be-careful-with-data.md) 31 | * DB index 32 | * [Lock으로 이해하는 트랜잭션 isolation level](https://suhwan.dev/2019/06/09/transaction-isolation-level-and-lock/) 33 | * [Foreign key S lock issue](http://www.chriscalender.com/advanced-innodb-deadlock-troubleshooting-what-show-innodb-status-doesnt-tell-you-and-what-diagnostics-you-should-be-looking-at/) 34 | * 데이터베이스 리팩토링 35 | * 쓰레드 관리 36 | * multi-thread vs. event loop 37 | * multi-thread 사용 예시 - Spring MVC 기반 어플리케이션의 threading 전략 38 | * Reactive Programming (Spring Reactor 기준) 39 | * [Project Reactor 동작 원리 & 쓰레드 관리 (`publishOn()` vs. `subscribeOn()`)](/reactive-programming/how-project-reactor-works.md) 40 | * Reactive Programming에서 일반적으로 맞닥뜨리는 까다로운 상황들 41 | * 분산 시스템 개발 42 | * [분산 시스템 개발 시 고려해야 할 사항](/distributed-system/distributed-system-concerns.md) 43 | * [Eventual consistency의 달성](/distributed-system/eventual-consistency.md) 44 | * 멱등성의 실제 구현 - state machine의 활용 45 | * 다양한 개발 상황과 유의사항 46 | * [서버 개발의 일반적인 어플리케이션 패턴](/situations-and-patterns/server-application-patterns.md) 47 | * [Batch job 작성 시 유의사항](/situations-and-patterns/batch-job.md) 48 | * [네이티브 클라이언트 개발 시 서버의 유의사항](/situations-and-patterns/native-client.md) 49 | * 테스팅 50 | * 테스트가 필요한 이유 51 | * 테스트의 분류 52 | * 배포 전략 53 | * [상위호환성과 하위호환성](/deployment/forward-and-backward-compatibility.md) 54 | * 배포할 때 데이터 휘발을 방지하기 55 | * 기타 유용한 개념들 56 | * [캐릭터 인코딩이란?](https://www.joelonsoftware.com/2003/10/08/the-absolute-minimum-every-software-developer-absolutely-positively-must-know-about-unicode-and-character-sets-no-excuses/) 57 | -------------------------------------------------------------------------------- /code-and-architecture/principles-of-writing-code.md: -------------------------------------------------------------------------------- 1 | # 코드 작성 원칙 2 | 3 | 이전에 이규원님이 작성하신 글 중에 [좋은 코드란 없고, 상황에 따른 적절한 코드만 존재한다는 논지의 글](https://gyuwon.github.io/blog/2020/07/31/what-is-good-code.html)이 있었다. 여기에 동의한다. 하지만 어떤 상황에서도 되도록이면 지키기 위해 노력해야 하는 공통된 개발 원칙들이 있다고 생각한다. 기본적으로는 이 원칙을 지키고, 필요에 따라 이 원칙과 다른 가치(e.g. 빠른 개발을 통한 proof of concept)를 트레이드 오프 하는 것이다. 4 | 5 | 이런 트레이드 오프가 가능하려면 우선 소프트웨어를 개발할 때 어떤 원칙을 지켜야 하고, 각 원칙이 어떤 효과를 가져다 주는지를 잘 이해하고 있어야 한다. 6 | 7 | ## 프로덕션 레벨의 소프트웨어가 가져야 하는 성질 8 | 9 | 나는 직군에 관계 없이 개발자가 두 가지 역할을 수행해야 한다고 생각한다. 10 | 11 | - 요구사항을 만족하는 올바른 소프트웨어를 작성한다. 12 | - 비즈니스 가치를 빠르게 전달할 수 있는 소프트웨어를 작성한다. 13 | 14 | 하지만 이 두 가지를 지키는 소프트웨어를 작성하는 것은 매우 어려운 일이다. 사업이 발전함에 따라 소프트웨어는 점점 거대해지고, 필연적으로 복잡해진다. 한 명의 개발자가 읽을 수 있는 코드의 양에는 한계가 있고, 엔터프라이즈 어플리케이션의 코드베이스 규모는 이 한계를 빠른 속도로 훌쩍 뛰어 넘는다. 기존에 있는 기능을 조금 수정해야 하는 상황을 생각해보자. 코드의 어떤 부분을 수정해야 하는지 파악하는 데에 한참이 걸리고, 기능 하나를 수정하기 위해 온갖 코드를 수정해야 하고, 코드를 조금 수정했을 뿐인데 엉뚱한 곳에서 버그가 터지는 일은 비일비재하게 발생한다. 15 | 16 | 따라서 위 두 가지 역할을 제대로 수행하기 위해, 개발자는 소프트웨어가 다음의 성질을 가지도록 소프트웨어를 작성해야 한다. 17 | 18 | - **소프트웨어의 가독성** - 얼마나 빠르게 코드를 파악할 수 있는가? 19 | - **소프트웨어의 자율성** - 얼마나 독립적으로 코드를 변경하고 프로덕션으로 배포할 수 있는가? 20 | - **소프트웨어의 개발 속도** - 같은 기능을 얼마나 빠르게 반영할 수 있는가? 21 | 22 | 다행히 이미 많은 소프트웨어 개발의 대가들이 같은 고민을 했고, 위와 같은 소프트웨어를 만들기 위한 수많은 방법론을 고안해냈다. 이런 방법론을 공부할 때 공통적으로 등장하는 개념과 추구하는 목표가 몇몇 존재하는데, 이들을 정리해보았다. 23 | 24 | ## 원칙 1. 의존성의 올바른 관리 25 | 26 | 거대하고 복잡한 소프트웨어는 수많은 소프트웨어 컴포넌트들의 통합과 협업을 통해 만들어진다. 코드 레벨에서는 다른 클래스의 변수 및 전역 변수를 사용하거나 함수를 호출한다. 프로세스 레벨에서는 네트워크 통신을 통해 다른 서버가 노출하는 API를 사용하거나, 다른 프로세스가 저장해놓은 데이터를 가져다 사용할 수도 있다. 이렇게 한 소프트웨어 컴포넌트가 다른 소프트웨어 컴포넌트의 기능을 직/간접적으로 가져다 사용하는 것을 '**의존**'이라고 부른다. 27 | 28 | 의존은 소프트웨어를 작성할 때 필연적으로 발생하지만, 의존을 올바르게 하지 않으면 문제가 발생한다. A라는 소프트웨어 컴포넌트가 B라는 소프트웨어 컴포넌트에 의존하는 상황을 생각해보자(A→B). 이 경우 A를 변경하는 것은 큰 문제가 안 된다. 반면, B를 수정하는 것은 조금 골치 아프다. B를 수정할 때 A가 올바르게 동작하는 것까지 고려해야 하기 때문이다. 29 | 30 | - B의 기능을 변경하려면 B에 대한 새 요구사항을 지키는 것뿐만 아니라 A가 B의 기능을 사용하는 유즈 케이스에 대해서도 요구사항이 지켜져야만 한다. 31 | - 만약 B의 인터페이스를 바꿔야 하는 상황이라면 더욱 문제가 크다. 필연적으로 A에서 B를 사용하는 코드도 함께 수정해야 하기 때문이다. 32 | - 만약 A가 다른 팀이 관리하고 있는 컴포넌트라면 B의 변경 비용은 더욱 커질 것이다. B를 수정해야 하는 필요성에 대해 다른 팀을 설득하고, A에 대한 하위호환성도 고려해야 한다. 33 | - 만약 B에 의존하는 소프트웨어 컴포넌트가 더 많다면(C→B, D→B, ...) B의 변경 비용은 더욱 커질 것이다. 34 | 35 | **위에서 본 것처럼, 의존성은 소프트웨어 변경의 비용을 높인다. 따라서 의존성을 올바르게 관리하는 것이 필요하다.** 36 | 37 | 의존성의 관리는 결국 코드를 어떻게 분리하고 어디에 연관시킬 것이냐에 대한 이야기이다. 컴포넌트를 분리할 경계를 신중하게 선택하지 않으면 클래스/함수가 예상치 못한 이유로 서로에게 의존하게 되고, 소프트웨어는 점점 거대한 진흙덩어리(big ball of mud)가 되어간다. 38 | 39 | - 한 클래스에서 기능을 개발할 때 다른 객체의 필드를 자꾸만 조회/변경할 일이 생긴다. 40 | - 같은 코드를 여기저기로 복사해서 사용해야 하는 상황에 처한다. 41 | - 수많은 분기로 인해 점점 유지보수하기 어려운 코드가 만들어진다. 42 | - cyclic dependency가 자꾸만 발생한다. 43 | 44 | 반대로, 적절한 경계를 기준으로 클래스/함수를 나누면 서로 관련 있는 코드가 한 클래스/함수에 묶일 것이기 때문에 의존성을 최소화할 수 있다. 45 | 46 | 다음은 의존성을 올바르게 관리하기 위한 몇 가지 팁이다. 47 | 48 | ### 계층 아키텍처를 사용한다. 49 | 계층 아키텍처란, 소프트웨어를 여러 레이어로 나누어서 설계하는 것을 의미한다. 여기서는 필자가 선호하는 DDD(Domain-Driven Design)의 4계층 아키텍처를 설명한다. 50 | 51 | - 유저 인터페이스 계층 - 유저 인터페이스와의 인터렉션을 담당하는 계층이다. 인증, form에 대한 validation, DTO의 변환 등을 담당한다. 52 | - 어플리케이션 계층 - 어플리케이션의 유스케이스가 구현되는 계층이다. 각 메소드는 유스케이스 하나를 표현하며, 도메인 계층의 함수를 적절한 순서로 호출하여 유즈케이스의 요구사항을 만족시킨다. 또한 트랜잭션 경계의 조절, 유저에 대한 notification(SMS/푸시/이메일 등) 등이 이루어지는 계층이다. 53 | - 도메인 계층 - DDD의 표현을 빌리자면 "비즈니스 로직이 살아 숨쉬는 계층"이다. 필자는 이 표현이 잘 와닿지 않아 별로 좋아하지 않는데, 필자 마음대로 고쳐서 설명자하면 "어플리케이션 계층에서 사용할 수 있는 정제된 동작의 집합"이라고 생각하고 있다. 54 | - 인프라 계층 - 위의 세 가지 계층에 대한 기술적인 지원을 하는 계층이다. ORM, 네트워크 통신, serialization/deserialization 등을 담당한다. 55 | 56 | 계층 아키텍처의 장점은 설계에 대한 고민을 보다 간단하게 만들어준다는 것이다. 이 4계층 아키텍처는 엔터프라이즈 어플리케이션에서 주로 발생하는 기술적인 측면의 설계 고민을 많이 해결해준다. 덕분에 개발자는 각 계층, 특히 대다수의 복잡성이 발생하는 도메인 계층을 어떻게 설계할지에 대해서만 집중해서 고민할 수 있게 된다. 57 | 58 | ### 구체적인 것이 추상적인 것에 의존한다. 59 | 조금 다른 말로 바꿔서 이야기하면, 재사용성이 낮은 것이 재사용성이 높은 것에 의존해야 한다. 60 | 61 | 위의 계층 구조를 예시로 들어보자. 도메인 계층에는 어플리케이션의 각 유즈케이스에서 재사용할 동작들이 모여 있다. 이런 상황에서 도메인 계층이 어플리케이션 계층에 의존하면 어떻게 될까? 도메인 계층의 동작이 특정 유즈케이스에만 종속적인 동작을 포함하게 될 수도 있다. 이러면 크게 두 가지 문제가 발생한다. 62 | 63 | - 도메인 계층의 재사용성이 떨어진다 - 도메인 계층의 동작이 의존하는 유즈케이스 외의 다른 유즈케이스에서는 해당 동작을 재사용하기 어려워질 수 있다. 64 | - 코드의 응집력이 떨어진다 - 유즈케이스에 대한 지식이 도메인 계층으로 퍼지게 되면서, 해당 유즈케이스와 관련된 코드가 도메인 계층와 어플리케이션 계층 모두에 존재하게 된다. 이러면 나중에 해당 유즈케이스를 변경해야 하는 시점에 두 군데의 코드를 수정해야 해서 변경이 더 어려워진다. 65 | 66 | 하지만 함수의 호출 플로우를 언제나 구체적인 것에서 추상적인 것으로 만들 수 있는 것은 아니다. 예를 들어, 도메인 계층의 특정 동작이 끝난 경우 어플리케이션 계층의 동작이 실행되어야 한다고 하자. 이 경우에는 어떤 방식으로든 도메인 계층이 어플리케이션 계층의 함수를 호출해야만 한다. 하지만 위에서 확인한 대로 이는 별로 바람직하지 않은 의존성이다. 67 | 68 | 의존성 역전 원칙(Dependency Inversion Principle, DIP)은 바로 이런 문제를 해결하기 위해 탄생한 것이다. 의존성 역전 원칙의 개념은 callback을 통해 아주 쉽게 이해할 수 있다. 자바스크립트의 `setTimeout()` 예시를 들어보자. 이 함수는 함수 하나와 숫자 하나를 인자로 받아서, 해당 숫자만큼의 millisecond가 지나면 함수를 실행하는 단순한 함수이다. 이 때 우리가 다음과 같은 코드를 작성했다고 하자. 이 때 의존성은 어떻게 될까? 69 | 70 | ```jsx 71 | const func = () => console.log('Hello, world!'); 72 | setTimeout(func, 1000); 73 | ``` 74 | 75 | 함수 호출의 플로우는 `setTimeout()` → `func` 이다. 하지만 `setTimeout()`의 구현은 우리가 작성한 코드와 전혀 관계가 없기 때문에, `setTimeout()`은 우리 코드에 의존하지 않는다. 반면, 우리 코드는 `setTimeout()`을 호출하므로 `setTimeout()`에 의존한다. 좀 더 정확히 말하면, 우리는 `setTimeout(callback, millis)`이라는 인터페이스, "즉 `millis` millisecond 이후에 `callback` 이라는 함수를 실행시킨다"는 '약속'에 의존하는 것이다. 즉, 함수 호출 플로우에 역전된 의존성을 가지게 된다. 76 | 77 | 이처럼, DIP를 사용하여 추상적인 계층이 인터페이스를 노출하고 구체적인 계층이 해당 인터페이스에 의존하는 방식으로 의존성을 함수 호출 플로우에 역전시킬 수 있고, 이를 통해 구체적인 것이 추상적인 것에 의존한다는 규칙을 지켜나갈 수 있다. 78 | 79 | ### 의존 포인트를 통합한다. 80 | 다음과 같은 상황을 생각해보자. 한 어플리케이션에 결제 로직을 처리하는 소프트웨어들이 모여 있는 payment 모듈이 있다. 이 모듈에는 결제를 담당하는 `PayService`, 전체 환불을 담당하는 `RefundService`, 부분환불을 담당하는 `PartialRefundService`가 구현되어 있다. 그리고 어플리케이션의 다른 소프트웨어 컴포넌트는 이 3개의 서비스에 직접 의존한다. 이러한 상황은 payment 모듈의 변경을 어렵게 만든다. 전체 환불과 부분 환불을 담당하는 두 서비스를 하나의 서비스로 합치거나, 비대해진 `PayService`를 분리하는 등의 리팩토링을 하려면 payment 모듈에 의존하는 모든 소프트웨어 컴포넌트를 변경해야 한다. 81 | 82 | 이런 문제를 방지하기 위해, 시스템과 시스템이 서로 의존할 때는 의존 포인트를 하나로 통일해서 관리하는 것이 좋다. 예를 들어, 위의 예시에서 payment 모듈은 `PaymentService`라는 인터페이스(혹은 클래스)를 노출할 수 있다. 해당 인터페이스는 `pay()`, `refund()`, `partialRefund()`를 메소드로 가진다. 어플리케이션에서 결제 로직을 태우고 싶은 다른 컴포넌트는 `PayService`, `RefundService`, `PartialRefundService`가 아니라 `PaymentService`에만 의존한다. 이러면 `PaymentService`를 제외한 payment 모듈의 다른 코드는 변경으로부터 비교적 자유로워진다. 이들이 지켜야 하는 건 `PaymentService`라는 인터페이스의 semantic 뿐이다. 83 | 84 | ## 원칙 2. 분명한 인터페이스 디자인 85 | 86 | 아무런 계획 없이 소프트웨어를 작성하다 보면, 구현하기 편한 방식으로 끊어서 컴포넌트를 묶게 된다. 예를 들어 함수를 작성하다가 단순히 길어져서 함수를 분리하는 경우를 생각해보자. 이 때 분리된 함수의 시그니처는 어떻게 될까? 분리된 함수 내에서 사용되지만 선언하지 않아서 IDE에 빨간색으로 표시되는 변수를 인자로 받게 될 가능성이 크다. 이러면 함수의 의미를 파악하기 힘들고, 재사용성이 떨어지게 된다. 87 | 88 | **따라서 구현하기 편한 인터페이스가 아니라, 읽고 가져다 쓰기 편한 인터페이스를 작성해야 한다.** 이는 소프트웨어를 작성하는 빈도보다 해당 소프트웨어를 읽고 가져다가 사용하는 빈도가 훨씬 잦기 때문이다. 인터페이스를 통해서 해당 컴포넌트가 하는 일이 분명하게 드러나지 않으면, 해당 컴포넌트를 사용하는 사람은 불안해서 컴포넌트의 내부 구현을 까보게 된다. 이러면 코드를 분리한 이유가 사라진다. 내부 구현을 몰라도 인터페이스만 보면 명확하게 목적과 용도가 파악되는 소프트웨어를 작성해야 한다. 89 | 90 | 여기서 말하는 인터페이스는 단순히 클래스의 인터페이스만을 포함하는 게 아니다. 소프트웨어 컴포넌트 간의 의존이 발생하는 모든 '접점'을 의미한다. 클래스의 퍼블릭 변수 및 전역 변수의 이름과 타입, 함수의 시그니처, HTTP API, DB 테이블 스키마 등 수많은 다양한 접점이 존재하고, 이들은 모두 일종의 인터페이스로 생각할 수 있다. 91 | 92 | 좋은 인터페이스를 설계하기 위해서는 아래와 같은 방법이 도움이 된다. 93 | 94 | ### 단일 시멘틱 원칙을 지킨다. 95 | 단일 시멘틱 원칙은 내가 임의로 붙인 이름으로, 하나의 필드 / 함수 / 클래스는 하나의 의미만을 가져야 한다는 원칙이다. 이에 대한 마틴 파울러의 훌륭한 코멘트가 있다. "역할이 둘 이상인 변수가 있다면 쪼개야 한다. 예외는 없다. 역할 하나당 변수 하나다."(\) 96 | 97 | 단일 시멘틱 원칙은 예상치 못한 동작을 방지하기 위함이다. 인터페이스는 결국 내부의 구현을 숨기기 위함(캡슐화)인데, 하나의 필드가 상황에 따라 if문을 타면서 다르게 해석되거나 하나의 함수가 상황에 따라 if문을 타면서 다른 방식으로 동작할 경우에는 인터페이스를 보고 해당 필드 / 함수가 무슨 역할을 하는지 확신하기 어려워진다. 이러면 해당 인터페이스에 의존하고자 하는 개발자는 내부 구현을 까서 읽어봐야 하고, 최악의 경우에는 해당 내부 구현에 맞춰서 자신이 개발하는 컴포넌트 내에서도 분기를 태워야 할 수 있다. 이러면 인터페이스를 사용하는 이유인 캡슐화의 장점을 완전히 잃어버리게 된다. 98 | 99 | ### 커맨드 쿼리 분리(Command-Query Separation, CQS) 100 | CQS는 단일 시멘틱 원칙과 마찬가지로 예상치 못한 동작을 방지하기 좋은 개발 방법론이다. CQS에 따르면, 개발자는 메소드를 두 가지 종류로 엄격하게 분리해서 작성해야 한다. 101 | 102 | - 커맨드(Command) - 사이드 이펙트를 발생시키는 액션. 여기서의 사이드 이펙트는 보통 클래스 내부의 필드 값을 변화시키는 것을 의미하긴 하지만, DB 값 변경이나 네트워크 호출 등의 다른 사이드 이펙트도 모두 포함한다. 103 | - 쿼리(Query) - 데이터를 조회하는 행위. 쿼리에서는 사이드 이펙트가 절대 발생해서는 안 된다. 104 | 105 | 예를 들어 쿼리 메소드처럼 생긴 함수(`getXXX()`)가 알고 보니 DB 테이블을 수정하고 있었다면 예상치 못한 사이드 이펙트가 발생할 수 있고, 시스템이 잘못 동작할 수 있다. 106 | 107 | ### 테스트를 작성한다. 108 | 테스트 작성은 수많은 이점을 가져다주는데, 여기서는 인터페이스 설계에 초점을 맞춰서 이야기하겠다. 109 | 110 | 테스트를 작성하면 좋은 인터페이스를 설계하는 데에 큰 도움이 된다. 특정 클래스에 대한 테스트를 작성하면 실제로 해당 클래스를 사용하는 코드를 작성하게 되는데, 이 과정에서 개발자는 인터페이스가 가진 예상치 못한 문제를 맞닥뜨릴 때가 많다. 111 | 112 | - 함수의 인자가 부적절하다. e.g. 함수의 인자로 엔티티의 ID를 받게 하고 함수 내부에서 DB를 조회하여 엔티티를 가져오게 했는데, 해당 함수를 호출하는 시점에 엔티티가 이미 존재한다. 113 | - 함수의 반환값이 부적절하다. e.g. 처리의 성공/실패를 boolean으로 반환하게 만들었는데, 실패했을 때의 사유가 추가적으로 필요하다. 114 | - 함수의 책임을 잘못 설정했다. e.g. 두 개 이상의 함수가 항상 같이 호출되거나, 인자의 전처리나 반환값의 후처리를 위한 코드가 반복되어 작성된다. 115 | 116 | 테스트는 이러한 문제를 사전에 발견하게 해주어, 다른 코드에서 사용하기 더 편한 인터페이스 설계를 유도한다. 117 | 118 | ## 원칙 3. 높은 코드 응집도 119 | 120 | 소프트웨어를 작성하다 보면 하나의 기능과 관련된 코드가 여기저기 흩뿌려지는 경우가 많다. 이런 코드베이스에서는 한 가지 기능을 수정할 때 코드의 수많은 부분을 건드려야만 한다. 이는 개발 속도를 현저하게 감소시킨다. 어디를 고쳐야 하는지 일일이 찾아야 하고, 각 부분을 올바르게 수정해야 하고, 수정했을 때 해당 코드에 의존하는 다른 기능들이 영향을 받지 않았는지 확인해야 한다. 함께 고쳐야 하는 부분이 서로 다른 서버에 퍼져 있을 때는 배포 전략도 추가적으로 수립해야 한다. 121 | 122 | **따라서 특정한 기능을 수정할 때 코드의 여러 부분을 수정하는 게 아니라 한 부분만 수정하면 되도록 소프트웨어를 유지해야 한다.** 그래야 코드를 수정할 때의 비용이 낮아지고, 비즈니스 가치를 시장에 빠르게 전달할 수 있다. 123 | 124 | ### 중복된 코드를 만들지 않는다. 125 | 코드 중복은 기능의 응집도가 낮다는 것을 알리는 대표적인 시그널이다. 반복되는 코드는 하나로 합쳐야 한다. 이는 간단하고 당연해 보이지만, 실제 프로덕선 레벨에서는 코드 중복이 빈번하게 발생한다. 왜냐하면 생각보다 지키기 어렵기 때문이다. 126 | 127 | 함수를 예시로 들어보자. 완전히 동일한 함수를 복사-붙여넣기해서 중복이 만들어지는 경우는 흔치 않다. 이런 복사-붙여넣기가 잘못되었다는 건 많은 사람들이 알기 때문이다. 128 | 129 | 문제가 되는 경우는 함수의 일부분만 중복되는 경우이다. 함수 중간에 실행되어야 하는 로직만 다른 경우, 복사-붙여넣기 이후 달라져야 하는 부분만 코드를 수정하는 일이 종종 생기는데, 이런 식으로 중복 코드가 점점 늘어나게 되면 나중에 공통 로직 부분을 바꿔야 하는 경우 해당 구현을 복붙한 곳을 일일이 찾아다니면서 코드를 변경해야만 한다. 130 | 131 | 이런 중복을 방지하기 위해서는 다양한 디자인 패턴이 도움이 될 수 있다. e.g. 템플릿 메서드 패턴(template method pattern), 전략 패턴(strategy pattern) 등. 132 | 133 | 또 한 가지 중요한 포인트는 코드의 중복을 판단하는 기준이다. 코드의 중복은 '코드의 diff가 얼마나 적은가'가 아니라 '앞으로 코드가 변경할 방향성이 얼마나 동일한가'로 판단해야 한다. 지금 필요한 코드가 동일하더라도 앞으로 서로 독립적으로 변경될 수 있다면 과감히 복사-붙여넣기로 새로운 코드를 작성하는 게 더 나은 선택일 수 있다. 134 | 135 | ### 다형성을 활용한다. 136 | 다형성이란, 하나의 인터페이스 / symbol이 여러가지 의미를 가질 수 있는 성질을 의미한다. 상당히 추상적인 설명인데, 가장 대표적인 예시가 바로 인터페이스의 상속을 통한 다형성이다. 다음과 같은 코틀린 인터페이스를 생각해보자. 137 | 138 | ```kotlin 139 | interface A { 140 | fun a() 141 | } 142 | 143 | class A1 : A { 144 | override fun a() { 145 | println("I'm A1") 146 | } 147 | } 148 | 149 | class A2 : A { 150 | override fun a() { 151 | println("I'm A2") 152 | } 153 | } 154 | 155 | fun runA(a: A) { 156 | a.a() // ???? 157 | } 158 | ``` 159 | 160 | 위 예시에서, `runA()`를 실행시키면 인자 `a`에 어떤 클래스의 인스턴스가 들어오느냐에 따라 `I'm A1`이 출력될 수도, `I'm A2`가 출력될 수도 있다. 이 때 `a.a()`는 `A1.a()`가 될 수도 있고 `A2.a()`가 될 수도 있으므로 여러가지 의미를 가진다. 따라서 `a.a()`는 다형적이라고 할 수 있다. 161 | 162 | 다형성은 높은 코드 응집도에 큰 도움이 되는데, 기능이 자주 변경되는 축으로 다형성을 활용하면 코드의 응집도를 높일 수 있다. 다형성을 활용하여 코드의 응집도를 높일 수 있는 다양한 패턴은 GoF의 \에 아주 상세히 소개되어 있으므로, 이 글에서 별도로 추가 서술하지는 않으려고 한다. 163 | 164 | ## Refs 165 | 166 | - \ 167 | - [https://en.wikipedia.org/wiki/Dependency_inversion_principle](https://en.wikipedia.org/wiki/Dependency_inversion_principle) 168 | - [https://en.wikipedia.org/wiki/Command–query_separation](https://en.wikipedia.org/wiki/Command%E2%80%93query_separation) 169 | - [https://en.wikipedia.org/wiki/Polymorphism_(computer_science)](https://en.wikipedia.org/wiki/Polymorphism_(computer_science)) 170 | -------------------------------------------------------------------------------- /deployment/forward-and-backward-compatibility.md: -------------------------------------------------------------------------------- 1 | # 상위호환성과 하위호환성 2 | 3 | 구 버전의 소프트웨어와 새 버전의 소프트웨어가 공존하는 상황은 피할 수 없는 숙명이다. 네이티브 앱은 자동 업데이트를 꺼놓은 경우 수년간 구버전이 유지될 수 있다. 비교적 자유롭게 배포할 수 있는 서버 사이드에서도, 모든 서버를 동시에 배포하는 것은 서비스 중단 점검을 하지 않고서야 불가능하다. 따라서 호환성을 지키는 것은 시스템이 올바르게 동작하고 유저에게 비즈니스 가치를 최대한으로 전달하기 위해 반드시 필요한 작업이다. 4 | 5 | ## 호환성의 종류 6 | 7 | 호환성에는 크게 두 가지 종류가 있다. 8 | 9 | - 상위호환성 - 새로운 시스템이 기존 시스템의 출력을 이해하는 성질 10 | - 하위호환성 - 기존 시스템이 새로운 시스템의 출력을 이해하는 성질 11 | 12 | 헷갈리는 개념이라서, 예시를 통해 알아보도록 하자. 커스텀한 포맷으로 파일을 쓰고 읽는 어떤 소프트웨어가 있다. 이 때 구 버전의 소프트웨어가 적은 파일을 새 버전의 소프트웨어가 잘 읽고 처리할 수 있으면, 해당 소프트웨어는 하위호환성을 가진다고 이야기한다. 반대로, 새 버전의 소프트웨어가 적은 파일을 구 버전의 소프트웨어가 잘 읽고 처리할 수 있으면, 해당 소프트웨어는 상위호환성을 가지는 것이다. 13 | 14 | 일반적으로 상위호환성은 지키기 어렵다. 미래에 소프트웨어가 어떻게 변할지를 미리 예측해야 하기 때문이다. 위의 데이터 상위호환성을 설명할 때 든 예시를 보면, 미래에 데이터 포맷이 어떤 식으로 변할지 지금 시점에서 어떻게 알겠는가? 그래서 상위호환성은 보통 지키는 것이 아니라 지켜야 하는 상황을 피해가는 것이 중요하다. 15 | 16 | 한편, 하위호환성은 이미 배포된 소프트웨어가 어떤 식으로 동작하는지 잘 알고 있기 때문에 비교적 잘 지킬 수 있다. 개발자는 분기 로직을 적절히 추가하기만 하면 된다. 예를 들어 위의 파일을 쓰고 적는 소프트웨어의 경우, 하위환성을 지키기 위해서는 읽어들이는 파일의 포맷 버전을 알아낸 뒤 각 버전에 맞는 서로 다른 처리 로직을 적용할 수 있다. 17 | 18 | 서버 개발을 하면서 호환성을 고려해야 하는 부분은 크게 두 가지가 있다. 19 | 20 | - API 21 | - 데이터 22 | 23 | ## API의 호환성 24 | 25 | 어떤 서버가 노출한 API를 다른 클라이언트가 사용하는 경우 호환성이 고려되어야 한다. 26 | 27 | API의 경우, 상위호환성은 보통 고려할 필요가 없다. 왜냐하면 API를 노출하는 소프트웨어는 API를 사용하는 소프트웨어보다 항상 먼저 배포되어야 하기 때문이다. 따라서 API 작성에서는 하위호환성만 고려하면 된다. 28 | 29 | API의 하위호환성은 세 가지 포인트만 기억하면 어렵지 않게 지킬 수 있다. 30 | 31 | - API에는 새로운 필드 추가만 한다 - 기존에 존재하는 API의 기능을 확장하고 싶다면 반드시 새로운 필드를 추가하기만 한다. 이는 클라이언트가 보내는 요청과 서버가 내려주는 응답 모두에 해당한다. API에 존재하는 기존 필드를 변경하거나 삭제하지 않으면 구 클라이언트는 요청과 응답에 새로운 필드가 추가된지 모른 채 정상 동작할 것이다. 32 | - 추가한 필드에 대해 구 클라가 default로 올리는 값을 고려한다 - request body에 새 필드를 추가하는 경우, 구 클라이언트는 해당 필드를 비운 채로 요청을 보낼 것이다. 이 때 서버가 빈 필드를 deserialize하는 로직에 대해 잘 알고, 구 클라이언트가 비워서 올려준 케이스를 잘 처리할 수 있도록 서버 로직을 짜야 한다. 33 | 34 | 예를 들어, API 요청에 boolean 필드를 하나 추가했는데, 클라이언트가 해당 필드를 비워서 요청을 날리면 deserialization 과정에서 false로 변환된다고 하자. 이러면 서버 입장에서 요청을 받았을 때 해당 값에 false가 들어 있으면 구 클라이언트가 비워서 보낸 건지, 새 클라이언트가 false로 채워서 보낸 건지 알 수 없게 된다. 이게 문제인지 아닌지는 API가 수행하는 로직에 따라 다를 것이다. 35 | 36 | 이 default 값이 문제가 되지 않도록 하기 위해서는 여러가지 조치가 필요할 수도 있다. 예를 들어 필드 타입을 nullable로 바꾸거나, 서버 로직을 보강하거나, 새 클라이언트는 default 값을 올리지 않도록 서버 개발자와 클라이언트 개발자가 합의를 볼 수 있다. 37 | 38 | - 클라이언트는 API 응답에 존재하는 모르는 필드를 무시한다 - serialization / deserialization 도구 중에는 deserialization 과정에서 모르는 값이 있으면 에러를 내는 경우가 종종 있다. 이는 하위호환성에 치명적인데, 위에서 본 필드 추가를 통한 API의 확장과 개선을 불가능하게 만들기 때문이다. 따라서 클라이언트는 서버가 내려준 응답에 모르는 필드가 포함된 경우 deserialization 과정에서 이를 무시하고 에러를 던지지 않도록 구현되어야 한다. 39 | 40 | API의 하위호환성에서 고려해야 할 한 가지 포인트는 클라이언트의 종류이다. 웹이나 다른 서버 등 즉시 배포되어 구 클라이언트가 오래 남지 않을 수 있는 경우면 하위호환성을 고려하지 않아도 괜찮을 수 있다. 잠시 클라이언트가 동작하지 않을 수는 있지만, 금방 복구된다. 하지만 네이티브 앱이나 레거시 서버와 같이 구 버전의 클라이언트가 오래 남아 있을 수밖에 없는 경우에는 하위호환성을 반드시 고려해야 한다. 41 | 42 | ## 데이터의 호환성 43 | 44 | 어떤 소프트웨어가 publish한 데이터를 다른 소프트웨어가 사용하는 경우 호환성이 고려되어야 한다. 45 | 46 | 내용을 더 진행하기에 앞서, 두 가지 용어를 정의하고 가려고 한다. 앞으로 이 글에서는 데이터를 생산하고 저장하는 소프트웨어를 publisher, 데이터를 읽고 소비하는 소프트웨어를 consumer라고 부르겠다. 서버 - 클라이언트라고 부르지 않는 이유는, 보통 데이터를 생산하고 소비하는 양쪽이 모두 서버일 가능성이 높기 때문이다. 47 | 48 | 일반적인 엔터프라이즈 어플리케이션은 보통 데이터를 저장하고 전파시키기 위한 다양한 도구를 포함한다. 서버는 수시로 데이터베이스, 태스크 큐, 데이터 스트림, 캐시 등의 도구에 데이터를 새롭게 쌓고, 읽고, 수정한다. 이를 다른 관점에서 보면, 데이터 관련 도구는 서버간의 통합을 도와주는 하나의 인터페이스로 작용한다. 따라서 데이터의 스키마와 데이터에 적히는 값에 대해 호환성을 유지하는 것이 필요하다. 49 | 50 | 데이터의 호환성을 고려해야 하는 상황은 크게 데이터의 스키마가 변경되는 경우와 적재되는 값이 변경되는 경우로 나눌 수 있다. 51 | 52 | ### 데이터의 스키마가 변경되는 경우 53 | 54 | 가장 자주 일어나는 케이스는 RDBMS에서 새로운 테이블이나 컬럼을 추가하는 경우이다. 이 경우, 스키마를 변경하기 전에 서버를 배포하면 서버가 데이터를 읽는 데에 실패할 수 있다. 따라서 해당 테이블을 참조하는 서버를 배포하기에 앞서 반드시 데이터베이스 스키마부터 배포되어야 한다. 55 | 56 | 여기에서의 가정은, API의 호환성에서 이야기했던 것처럼 서버가 모르는 column을 무시하고 데이터를 읽을 수 있도록 작성되어 있다는 것이다. 스키마를 먼저 배포하면 데이터베이스 테이블에는 새로운 column이 추가되었는데 서버가 아직 배포되지 않아 새 column을 알지 못하는 상태가 반드시 존재하게 되는데, 이 경우 서버가 정상 동작해야 한다. 57 | 58 | 이를 일반화하면 다음과 같은 원칙을 세울 수 있다 : **스키마가 변경되는 경우, 스키마부터 배포하라.** 59 | 60 | ### 적재되는 값이 변경되는 경우 61 | 62 | 두 개의 서버 1, 서버 2가 하나의 데이터베이스 테이블을 공유하는 시스템을 예시로 들어보자. 해당 테이블에는 enum 값이 저장되어 있는 column이 있다. 현재 가능한 enum 값은 A, B, C 3가지이다. 이 때 이 enum에 새로운 값 D를 추가하려고 한다. 만약 서버 1이 먼저 배포되어서 테이블에 값 D가 적히기 시작했다면, 서버 2에서 장애가 발생할 수 있다. 왜냐하면 서버 2는 아직 D라는 enum 값을 모르는 상태이므로, 해당 테이블의 값을 읽어서 객체로 변환할 때 D라는 값을 해당 enum type으로 형변환하는 데에 실패할 것이기 때문이다. 63 | 64 | 이 외에도 다양한 상황이 있을 수 있다. 태스크 큐에 새로운 종류의 태스크가 추가됐는데 태스크를 처리하는 워커가 배포되지 않아 새 태스크를 인식할 수 없다거나, 데이터 스트림에 적재하는 데이터의 값이 달라졌는데 데이터 스트림 처리기가 배포되지 않아 데이터를 해석하지 못할 수도 있다. 65 | 66 | 위에서 든 예시는 모두 상위호환성이 지켜지지 않는 상황들인데, consumer가 해석할 수 없는 데이터를 producer가 생산하기 때문에 발생한다. 따라서 producer보다 consumer를 먼저 배포하면 이런 상위호환성 문제를 피할 수 있다. enum 예시에서는 서버 2를 서버 1보다 먼저 배포했다면 서버 2에서 장애가 발생하는 일은 없었을 것이다. 태스크 큐와 데이터 스트림의 경우도 마찬가지로 워커와 데이터 스트림 처리기를 먼저 배포하면 문제가 없다. 67 | 68 | 즉, 우리는 다음과 같은 원칙을 얻는다 : **producer 보다 consumer 먼저 배포하라.** 69 | 70 | ### 적재되는 값이 달라지는 경우 - producer와 consumer가 명확히 나뉘지 않는다면? 71 | 72 | 대부분의 엔터프라이즈 어플리케이션은 scaling과 SPOF 회피를 위해 한 종류의 서버를 여러 대 띄운다. 이런 상황에서 취하는 일반적인 배포 전략인 rolling update와 canary 배포에서는 배포 도중 한 서버의 구 버전과 신 버전이 공존하는 시기가 존재한다. 이 때 하나의 서버가 동시에 producer이자 consumer인 경우, consumer를 먼저 배포한다는 원칙을 지킬 수 없다. 73 | 74 | 이로 인해 발생하는 문제는 상위호환성을 지켜야 할 필요성이 생긴다는 점이다. rolling update나 canary 배포가 진행되는 동안 구 버전의 서버는 새 버전의 서버가 적는 데이터를 정상적으로 읽고 처리할 수 있어야만 한다. 특히 canary는 배포 기간이 길기 때문에 더욱 오래 상위호환성을 유지해야 한다. 하지만 상위호환성은 지키는 것이 아예 불가능할 수도 있다. enum이 추가되는 상황에서 도대체 어떻게 상위호환성을 지킬 수 있는가? 75 | 76 | 그래서 우리는 상위호환성을 지켜야 하는 상황을 피해가야 한다. 피해가는 기본적인 전략은 간단하다. 데이터를 produce 하는 **코드** 전에 consume 하는 **코드**를 먼저 배포하는 것이다. 이를 기반으로, 위 문제를 피할 수 있는 두 가지 방법에 대해 알아보자. 77 | 78 | - multi-step 배포 -  새 데이터를 읽을 수 있는 상태로 서버를 먼저 배포하고, 그 이후에 새 데이터를 적재하는 로직이나 읽고 처리하는 로직을 배포하면 상위호환성 문제를 회피할 수 있다. 아까 위에서 살펴본 enum 문제를 예시로 들면, enum type에 값 D를 추가한 코드만 먼저 배포하고, 그 다음 값 D를 적재하고 사용하는 코드를 다시 배포하는 것이다. 79 | - feature flag - feature flag를 활용해서 consumer 부터 배포되는 것과 동일한 효과를 발휘할 수 있다. consumer 부터 배포하라는 원칙의 핵심은, 데이터를 생산하기 전에 서버가 데이터를 소비할 수 있는 상태로 만들어두라는 뜻이다. 이 말은 producer가 먼저 배포되더라도 새 종류의 값을 적지만 않는다면 문제가 없다는 뜻이다. 따라서, 특정 시점 이후로 데이터를 produce 하게끔 feature flag를 통해 구현해놓고 서버를 미리 배포하면 상위호환성 문제가 발생하지 않는다. 80 | 81 | 그러므로, 아까 살펴본 두 가지 원칙을 다시 정리하면 다음과 같이 수정할 수 있을 것이다. 82 | 83 | - **스키마가 변경되는 경우, 스키마부터 배포하라.** 84 | - **produce 하기 전에 consumer 먼저 배포하라.** 85 | -------------------------------------------------------------------------------- /distributed-system/distributed-system-concerns.md: -------------------------------------------------------------------------------- 1 | # 분산 시스템 개발 시 고려해야 할 사항 2 | 3 | 서버 개발을 하다 보면 분산 시스템 개발을 해야 하는 다양한 경우를 마주하게 된다. 서버를 여러 개로 나누어서 운영할 수도 있고, 외부 시스템과 연동을 해야 할 수도 있고, 데이터 팀이 제공하는 API를 사용해야 할 수도 있다. 이런 다양한 상황에서도 공통적으로 고민해야 하는 몇 가지 중요한 요소가 있다. 4 | 5 | ## 분산 시스템 개발의 어려움 6 | 7 | 분산 시스템을 개발하는 것은 하나의 프로세스로 동작하는 서버를 개발하는 것보다 일반적으로 더 어렵다. 분산 시스템 개발을 어렵게 만드는 이유에는 크게 두 가지가 있다. 8 | 9 | ### 1. 네트워크 호출이 언제든지 실패할 수 있다. 10 | 11 | 프로세스 바깥의 세상은 아주 불완전하고 불안하다. 통신하려는 서버에 장애가 발생했을 수 있다. DNS 서버에 오류가 발생하여 상대 서버의 IP를 조회하지 못할 수 있다. 네트워크 망에 장애가 발생했을 수 있다. 트래픽이 몰려 네트워크가 너무 느려지는 바람에 timeout이 날 수 있다. 이런 다양한 이유로 인해 외부와의 통신은 언제든지 실패할 가능성이 있다. 12 | 13 | 이는 마치 서버 코드에서 함수를 호출했는데 함수 호출이 랜덤한 확률로 실패하는 것과 같다. 분산 시스템을 개발할 때는 이러한 우연한 실패를 올바르게 다루는 방법이 필요하다. 14 | 15 | ### 2. 트랜잭션이 없다 → 상태가 깨질 수 있다. 16 | 17 | 트랜잭션은 비즈니스 로직 개발을 아주 쉽게 만들어주는 강력한 툴이다. 트랜잭션이 없다면 우리는 비즈니스 규칙에 따른 데이터 무결성과 정합성을 보장하기 위해 엄청난 노력을 기울여야 했을 것이다. 우리는 트랜잭션이 있기에 데이터 무결성과 정합성에 대해 큰 걱정을 하지 않아도 된다. 18 | 19 | 하지만 분산 시스템을 개발할 때는 트랜잭션을 활용할 수 없다. 서로 다른 두 개의 서버를 하나의 트랜잭션으로 묶을 수 없기 때문이다. 따라서 분산 시스템 개발을 하면 두 서버의 상태가 일치하지 않는, 즉 상태가 깨지는 순간이 반드시 존재할 수 있게 된다. 분산 시스템 개발을 할 때는 이러한 상태의 깨짐을 올바르게 다루는 방법이 필요하다. 20 | 21 | ## 어려움을 극복하는 방법 22 | 23 | 이제부터 각각의 어려움을 현명하게 극복하는 방법을 예시를 통해 알아보자. 24 | 25 | ### 1. 동기적인 네트워크 호출이 실패했을 경우 26 | 27 | 넷플릭스의 서버를 생각해보자. 넷플릭스 UI를 보면 유저에게 최적화된 추천 영화 리스트가 보여진다. 이러한 추천 영화 목록은 다른 전문적인 데이터팀이 개발한 ML 모델 서버가 노출한 API로부터 동기적으로 받아온다. 넷플릭스의 메인 서버는 유저가 넷플릭스 페이지를 켜면 이 API를 동기적으로 호출하고, 그 결과를 활용하여 추천 영화 목록을 보여준다. 하지만 이 API 호출은 (위에서 본 것처럼) 언제든지 실패할 수 있다. 이 경우 우리가 취할 수 있는 전략은 무엇일까? 28 | 29 | 1. 서버가 얼만큼의 autonomy(자율성)을 가져야 하는지 판단하자. 30 | 31 | 가장 먼저 해야할 일은, 네트워크 요청이 실패하는 게 얼마나 "괜찮은" 일인지를 판단하는 것이다. 크게 두 가지 방향성이 있다. 32 | 33 | - 네트워크 요청이 실패하면 전체 요청을 실패시켜도 괜찮다. (서버가 automonous 하지 않다) 34 | - 네트워크 요청이 실패하더라도 나머지 로직은 정상적으로 동작해야 한다. (서버가 autonomous 하다) 35 | 36 | 서버가 autonomous 한 게 무조건 좋아보일 수 있지만, 소프트웨어 개발은 언제나 트레이드 오프가 있다. 잠시 후에 살펴보겠지만, autonomy를 구현하기 위해서는 추가적인 노력이 필요하다. 상황에 따라 이 노력의 양이 엄청나게 커질 수도 있고, 아예 불가능에 가까울 수도 있다. 따라서 자신의 상황에 맞게 서버가 얼마나 autonomous 해야 하는지를 적절히 판단하는 것이 중요하다. 37 | 38 | 넷플릭스의 예제에서는 아무래도 두 번째 방식을 택해야 할 것 같다. 즉, 추천 영화 목록 조회 API가 실패하더라도 넷플릭스 웹은 제대로 보여주는 게 좋을 것이다. 이 결정을 내렸으니 이제 다음 단계로 넘어가자. 39 | 40 | 2. autonomy를 구현하자. 41 | 42 | autonomy를 구현하는 방법을 알아보기에 앞서, 우리는 autonomy를 구현하려는 API의 성격을 분석해야 한다. 서버의 API에는 크게 두 가지 종류가 있다. 첫 번째는 서버에서 사용하는 엔티티의 상태를 변경하지 않고 조회만 하는 것이고, 두 번째는 엔티티의 상태를 변경시키는 것이다. 43 | 44 | 2-1. 상대 서버의 상태를 변경하지 않는 경우 45 | 46 | 이 경우 autonomy를 구현하는 방법에는 크게 두 가지 방식이 있다. 47 | 48 | - 로컬 서버에 fallback 로직을 구현한다 - 추쳔 영화 목록을 뽑아내는 fallback 로직을 로컬 서버에도 구현해서, API 요청이 실패하면 이 fallback 로직을 사용하게 한다. 이 때 fallback 로직은 매우 복잡할 필요는 없다. 그저 사용자가 위화감을 느끼지 않을 정도면 된다. 예를 들어 최근 한 달 영화 중 별점이 가장 높은 20개를 보여주는 방법이 있을 수 있다. 49 | - API의 결과를 로컬에 캐시한다 - 기존에 유저가 접속한 적이 있다면, 해당 유저에 대해 추천 영화 목록을 로컬 DB에 캐시한다. API 요청이 실패하면, 캐시가 너무 오래 되지 않은 경우에 한해 캐시된 추천 영화 목록을 보여준다. 50 | 51 | 전자는 별도의 로직을 구현해야 하고 추천의 정확도가 떨어진다는 부담이 있지만, 언제나 문제 없이 동작할 것이다. 반면 후자는 추천의 정확도가 높지만, 유저가 오랜만에 접속했거나 한 번도 접속하지 않은 유저의 경우에는 동작하지 않는 방식이다. 그리고 추가적으로 유저가 많아질 경우 DB 저장공간을 많이 차지하게 될 수도 있다. 꼭 둘 중 하나를 선택하라는 법은 없다. 두 가지 방식을 모두 채택할 수도 있을 것이다. 하지만 이 경우에는 더 많은 시간을 투자하게 될 것이다. 52 | 53 | 이 중 어떤 것을 선택하는 것이 적절한지는 상황에 따라 달라질 것이다. 필자라면 이 상황에서는 전자를 택할 것 같다. 캐시 데이터가 많아지는 게 부담이고, 추천 영화 목록 API가 실패할 가능성이 극히 낮아 보이기 때문이다. 54 | 55 | 2-2. 상대 서버의 상태를 변경하는 경우 56 | 57 | 이 경우에는 autonomy를 구현하기가 힘들다. 왜 그럴까? 58 | 59 | 예시를 하나 들어보자. 어떤 서비스의 메인 서버 A가 있고, 이 서비스의 쿠폰 관련 로직을 담당하는 쿠폰 서버 B가 있다. 서버 A는 쿠폰 코드를 입력받아서 쿠폰을 발급하는 API를 노출하는데, 이 API가 호출되면 서버 A는 마치 프록시처럼 서버 B의 쿠폰 발급 API를 그대로 호출한다. 이 때 서버 A가 서버 B의 API를 호출하는 것이 실패할 수 있는데, 크게 두 가지 상황이 있을 수 있다. 60 | 61 | - 서버 B에서 요청이 정상적으로 처리되지 않았다. 62 | - 서버 B에서 요청이 정상적으로 처리되지 않았지만, 응답이 서버 A에 도달하지 않았다. 63 | 64 | 이 두 가지 상황 중 서버 A는 어떤 상황이 발생했는지 모른다. 이 경우 서버 A가 autonomous 하기 위해서는 어떻게 해야 할까? 서버 B의 API 요청 실패에 대해 어떻게 처리하는 것이 "올바르게" 동작하는 것일까? 유저에게는 어떤 응답을 돌려줘야 할까? 유저에게는 쿠폰이 등록됐다고 해야 할까, 등록이 안 됐다고 해야 할까? 65 | 66 | 상대 서버의 상태를 변경하는 API의 경우 상대 서버의 상태를 변경하는 데에 성공했는지 실패했는지를 알 수 없기 때문에 어떤 동작이 올바른 fallback인지 판단할 수 없다. 따라서 동기적인 호출로는 autonomy를 달성하기 어려워진다. 67 | 68 | 이런 경우에는 동기적인 호출을 비동기적인 방식으로 변경하는 것이 하나의 해답일 수 있는데, 이는 eventual consistency 항목에서 더 자세히 알아보겠다. 69 | 70 | ### 2. 상태가 깨지는 경우 71 | 72 | 분산 시스템 개발 시에는 언제든지 두 서버의 데이터 무결성이 깨지는 경우가 발생할 수 있다. 예를 들어 일반적인 커머스 서비스를 생각해보자. 여기에는 유저의 주문을 받아주는 주문 서버와 주문대로 배송을 해주는 배송 서버가 있다. 주문이 들어오면 다음과 같은 일이 일어난다. 73 | 74 | 1. 주문 서버는 자신의 서버에 주문을 저장하고 75 | 2. 배송 서버에 주문을 전달해준다. 76 | 3. 배송 서버는 주문을 전달받으면 "배송 준비" 상태로 배송 건을 저장한다. 77 | 4. 배송 담당자는 어드민에 표시된 "배송 준비"인 배송 건을 보고 배송을 준비하기 시작한다. 78 | 79 | 하지만 주문 서버는 다양한 이유로 배송 서버에 주문이 들어왔다는 사실을 전달하는 데에 실패할 수 있다. 이 경우 우리는 어떻게 해야 할까? 80 | 81 | - 상태가 깨지는 상황을 기획에 녹이기 82 | 83 | 주문 서버가 배송 서버로 주문을 전달하는 데에 실패하는 것을 기술적으로 100% 막을 방법은 없다. 트랜잭션이 없기 때문이다. 100% 전달하려면 주문 서버와 배송 서버를 하나의 트랜잭션으로 묶는 수밖에 없다. 즉, 두 서버를 하나의 서버로 합치면 된다. 하지만 이런 방향이 언제나 우리가 원하는 방향은 아닐 뿐더러, 서버 통합이 아예 불가능한 상황도 빈번하게 있다. 84 | 85 | 그렇다면 상태가 깨지는 상황을 극복할 방법은 정말 없는 것인가? 여기서는 생각의 전환이 필요하다. 주문 서버가 배송 서버에 주문을 전달해주지 못한 상황이 그렇게 큰 문제인가? 86 | 87 | 커머스 서비스 없이 이 작업을 사람이 한다고 생각해보자. 주문을 받는 사람 A가 주문 서버가 하는 일을 대신하고, 배송을 준비하는 사람 B가 배송 서버가 하는 일을 대신한다. A는 주문을 받으면 이를 "주문 목록" 스프레드시트에 기록하고, B에게 이 주문을 전달한다. 이 때 A가 B에게 주문을 전달하는 데에는 어느 정도 시간이 걸리며, 이 시간 동안은 주문은 존재하지만 배송 건은 존재하지 않는 상태가 유지된다. 88 | 89 | 여기서 하고 싶은 말은, 주문은 존재하지만 배송 건이 존재하지 않는 상태는 사람과 사람(혹은 서버와 서버)이 협력하는 상황에서는 아주 자연스러운 상태일 수 있다는 것이다. 현실에서는 모든 일이 동기적으로 일어나지 않는다. 마찬가지로 주문과 배송 건의 상태가 반드시 "즉시" 일치해야 하는 건 아니다. 적절한 시간 내에 주문에 맞는 배송 건이 생성되기만 하면 된다. 90 | 91 | 따라서 상태가 깨지는 상황을 또 하나의 유효한 상태로 인정하고, 이를 기획과 프로덕트에 녹여내자. 이 상황에서는 주문이 존재하지만 배송 건이 존재하지 않으면 유저 주문 목록에 "배송 준비"로 보여주는 것이 합리적인 선택일 것이다. 92 | 93 | - 깨진 상태를 복구하기 - eventual consistency 94 | 95 | 위의 절에서 필자는 이렇게 이야기했다. "적절한 시간 내에 주문에 맞는 배송 건이 생성되기만 하면 된다." 상태가 즉시 일치해야 하는 건 아니지만, 언젠가는 일치하긴 해야 한다. 영원히 주문에 맞는 배송 건이 생성되지 않는다면 이는 주문이 누락된 것이다. 96 | 97 | 이 "언젠가는 일치하는 것"이 바로 결과적 일관성, 즉 eventual consistency이다. 우리는 언젠가는 깨진 상태를 복구하여 무결성과 정합성이 유지되는 상태로 되돌아가야 한다. 주문이 들어왔으면 언젠가는 그에 맞는 배송 건이 생성되어야 한다. 98 | 99 | eventual consistency에 대한 더 자세한 내용은 [별도의 문서](/distributed-system/eventual-consistency.md)로 다룬다. 100 | 101 | ## Reference 102 | 103 | - \ 104 | -------------------------------------------------------------------------------- /distributed-system/eventual-consistency.md: -------------------------------------------------------------------------------- 1 | # Eventual consistency의 달성 2 | 3 | 분산 시스템 환경에서는 분산된 서버에서 발생하는 여러 개의 변경을 하나의 트랜잭션으로 묶을 수 없다. 이 때문에 transactional consistency(트랜잭션적 일관성)를 달성할 수 없다. 즉, 분산된 서버의 여러 변경이 항상 동시에, 동기적으로 발생함을 보장할 수 없다는 뜻이다. 4 | 5 | 트랜잭션을 사용할 수 없다면 우리는 각 서버간의 consistency를 포기해야만 하는 것일까? 그렇지 않다. 이런 상황을 위해 우리는 eventual consistency(결과적 일관성)이라는 개념을 도입할 수 있다. 6 | 7 | ## Eventual consistency 8 | 9 | ### 개념 10 | 11 | eventual consistency는 말 그대로 언젠가는 consistency가 지켜진다는 개념이다. 조금 더 풀어서 설명해보면, eventual consistency는 한쪽 서버에서 발생한 변화가 다른 쪽 서버에 즉시 전파되어 두 서버의 상태가 항상 일관되게 유지되는 것을 보장해주지는 않는다. 대신 어느 정도 시간이 흐르면 모종의 메커니즘으로 인해 양쪽 서버의 상태가 맞춰지고, 결과적으로 일관성을 달성할 수 있게 된다. 12 | 13 | ### 구현 전략 14 | 15 | eventual consistency를 달성하려면 서버(이벤트를 생산하는 쪽)와 클라이언트(이벤트를 소비하는 쪽)의 올바른 협력이 필요하다. 기본적으로 다음의 두 가지를 구현해야 한다. 16 | 17 | 1. 성공할 때까지 재시도하기 (at least once) - 분산 시스템에서 서버의 변경이 클라이언트로 전파되기 위해서는 모종의 네트워크 통신이 필요하다. 그리고 이러한 네트워크 통신은 다양한 이유로 언제든지 실패할 수 있다. 따라서 eventual consistency를 달성하기 위해서는 네트워크 통신이 실패했을 때 서버의 변경을 다시 클라이언트로 전파하는 메커니즘이 필요하다. 즉, 변경의 전파가 성공할 때까지 재시도하는 로직이 필요하다. 18 | 19 | 이러한 로직은 최소 한 번 이상 시도하는 로직이라고 해서 at least once 라고도 불린다. 20 | 21 | 2. 클라이언트의 멱등성 - 1번을 구현하면 추가적으로 발생하는 문제가 있다. 바로 클라이언트가 같은 이벤트를 여러번 수신할 가능성이 생긴다는 것이다. 다음과 같은 시나리오를 생각해보자. 22 | 1. 서버가 클라이언트에 이벤트를 전파한다. 23 | 2. 클라이언트는 이벤트를 수신하여 처리하고, 처리했다는 응답을 서버에 돌려준다. 24 | 3. ii의 응답이 네트워크 오류로 인해 누락되어 서버에 전달되지 못한다. 25 | 26 | 이 경우 서버는 클라이언트가 이벤트를 제대로 처리하지 못했다고 판단할 것이고, 1번의 재시도 로직에 의해 다시 한 번 클라이언트에 동일한 이벤트를 전파하려고 시도할 것이다. 이러면 클라이언트는 같은 이벤트를 두 번 수신하게 된다. 27 | 28 | 이러한 경우를 대비하여 클라이언트는 이벤트 핸들러를 멱등적으로 구현해야 한다. 멱등적으로 구현한다는 것은 같은 이벤트를 중복 수신하더라도 마치 한 번만 수신한 것처럼 동작해야 한다는 뜻이다. 29 | 30 | 이제 각각의 요소를 구현할 때 구체적으로 고려해야 하는 부분에 대해 알아보자. 31 | 32 | ## at least once의 구현 33 | 34 | at least once를 구현하기 위해서는 크게 두 가지 결정을 내려야 한다. 35 | 36 | ### 1. 이벤트 전파의 주체 - server push vs. client pull 37 | 38 | "이벤트를 전파한다"는 표현을 들으면 마치 서버가 클라이언트에게 네트워크 요청을 날려야 하는 것처럼 들린다. 이러한 방식을 server push 방식이라고 부른다. 하지만 이벤트의 전파는 다른 메커니즘으로도 가능하다. 클라이언트가 주기적인 polling 방식을 통해 아직 처리하지 못한 이벤트 목록을 서버로부터 조회해올 수도 있다. 이러한 방식은 client pull 방식이라고 한다. 39 | 40 | 이 두 가지 방식 중 어떤 것을 사용할지 선택할 때는 다양한 요소를 고려해야 한다. 41 | 42 | - 한쪽 서버의 API가 이미 고정되어 있는 경우 - 외부 시스템을 연동할 경우, 일반적으로 해당 시스템의 API는 변경할 수 없다. 이런 경우에는 외부 시스템의 API에 맞는 전파 방식을 선택할 수밖에 없다. 43 | 44 | 예를 들어 PG 서비스를 연동한다고 해보자. 해당 서비스가 결제 이벤트를 수신하는 API를 노출하고 있는 경우, 우리는 해당 API에 맞춰 서버를 구현해야 한다. PG사에 "우리가 결제건 조회 API를 노출할테니 그쪽에서 긁어가주세요"라고 하며 구현을 바꾸라고 할 수는 없는 노릇이다. 이런 경우에는 무조건 server push 방식을 사용해야 한다. 45 | 46 | - 실시간성이 중요한 경우 - 기술적인 관점에서 조금 벗어나서, 통계적인 관점으로 눈을 돌려보자. 네트워크 호출의 성공과 실패는 각각 얼마나 자주 일어나는 일인가? 빈도로 따진다면 첫 네트워크 요청이 성공하여 재시도할 필요가 없는 경우가 대부분을 차지할 것이다. 47 | 48 | 이런 통계적인 수치를 기반으로, 다음의 프렌차이즈 커피숍 예시를 생각해보자. 커피숍 앱으로 커피를 주문하면 이 주문 요청을 메인 서버가 저장하고, 해당 주문을 각 커피숍 지점의 기기로 전파해주고 있다. 이 경우에 client pull 방식을 사용하면 polling 주기 만큼 주문 전파에 딜레이가 생긴다. 예를 들어 polling 주기가 1분이라면 커피숍 지점이 주문을 받는 데는 최대 1분의 지연 시간이 발생하는 것이다. 반면, server push 방식을 택하면 대부분의 경우 유저가 주문을 넣자마자 커피숍 지점으로 주문이 전달될 수 있을 것이다. 49 | 50 | 위와 같이 비즈니스 가치의 달성에 실시간성이 중요하면 중요할 수록 polling을 활용한 client pull 방식은 적합하지 않을 수 있다. 51 | 52 | - 이벤트의 순서를 맞춰야 하는 경우 - [뒤에서 더 자세히 살펴보겠지만](/distributed-system/eventual-consistency.md#이벤트에-순서가-존재하는-경우), 비즈니스 로직을 구현하다 보면 서로 연관된 여러 이벤트가 발생하는 경우가 있다. 이럴 경우에는 eventual consistency의 달성이 더 까다로워진다. 53 | 54 | 예를 들어 서버에서 서로 연관된 이벤트 1, 2, 3이 시간 순으로 발생한다고 해보자. 이 때 네트워크 통신의 불안정성 때문에 클라이언트는 1 → 2 → 3이 아닌 순서로 이벤트를 수신할 수도 있다. 예를 들어 이벤트 2의 전파만 누락되어 나중에 재시도가 된다면 클라이언트는 1 → 3 → 2의 순서로 이벤트를 수신할 것이다. 55 | 56 | 이런 상황에서는 서버와 클라이언트 사이에는 이벤트의 순서를 맞추는 메커니즘이 필요하다. 이 때 client pull 방식을 사용하면 클라이언트는 적어도 중간에 발생한 이벤트를 누락시키고 확인할 염려는 없다. 즉, 이벤트 목록을 pull 했는데 2가 누락되고 1과 3만 보일 일은 없다는 뜻이다. 그래서 클라이언트는 그저 이벤트를 시간순으로 정렬해서 처리하기만 하면 된다. 한편 server push 방식은 이벤트의 순서를 맞추는 복잡한 메커니즘을 별도로 구현해야 한다. 57 | 58 | ### 2. 재시도의 구현 방식 - message queue vs. 자체 구현 59 | 60 | 이벤트 전달을 재시도하는 방식에는 크게 두 가지 방법이 있다. 61 | 62 | - message queue 미들웨어 사용 - 이벤트를 message queue에 던지고, message queue가 at least once를 대신 수행하는 방식이다. 63 | 64 | message queue를 사용했을 때의 가장 큰 장점은 빠르게 구현할 수 있다는 점일 것이다. 우리는 재시도 로직을 직접 구현할 필요 없이 message queue가 알아서 재시도를 해준다. 65 | 66 | message queue를 사용할 때 주의해야 할 사항은 이벤트를 message queue에 넣는 행위가 실패할 수 있다는 것이다. 즉, 서버 쪽에서는 특정 변경이 이루어졌지만, 해당 변경에 대한 이벤트가 message queue에 들어가지 않을 수 있다. 이를 해결하기 위한 방법 중 하나로 로컬 DB를 message queue의 source로 활용하는 방법이 있다. 67 | 68 | - 자체 구현 - 로컬 DB에 이벤트를 저장하고, 직접 server push 혹은 client pull을 구현하는 방식이다. cronjob을 활용하여 아직 클라이언트가 처리하지 못한 이벤트를 전송(server push) 혹은 조회(client pull)한다. 69 | 70 | 이 방식은 message queue보다 비용이 많이 들지만, 자유도가 높다는 장점이 있다. message queue를 사용하면 message queue에서 지원하는 기능 이외에는 사용할 수 없지만, 직접 구현하면 원하는 만큼 기능을 확장할 수 있다. 71 | 72 | ## 멱등성의 구현 73 | 74 | 이벤트 처리에 대한 멱등성을 구현할 때는 두 가지 원칙이 필요하다. 75 | 76 | - 이벤트의 식별자(ID) - 클라이언트가 특정 이벤트를 중복으로 수신한 건지 아닌지를 판단하기 위해 해당 이벤트에 전역적으로 유일한 식별자가 달려 있어야 한다. 클라이언트는 해당 식별자가 동일한 이벤트를 2회 이상 중복 수신한 경우 같은 이벤트를 수신했다고 판단한다. 77 | - 멱등한 이벤트 핸들러 구현 - 같은 이벤트를 중복 수신한 경우 핸들러의 로직을 실행하지 않고 즉시 리턴한 뒤 처리 성공 응답을 돌려줘야 한다. 78 | 79 | 멱등성을 구현하는 원칙 자체는 상당히 간단해서, 이를 더 설명하기보다는 예시를 통해 멱등성을 실제로 어떻게 구현해야 하는지를 표현하는 게 더 좋을 것 같다. 80 | 81 | ### 예시 : PG사 연동 82 | 83 | PG사를 연동하는 상황을 예시로 들어보겠다. 우리는 PG사 서버가 노출하고 있는 결제 요청 API를 연동해야 한다. 우리 서버가 결제 요청 API를 호출하면 PG사 서버는 동기적으로 결제 요청을 처리하고 결제 성공 / 실패 중 한 가지 응답을 내려줄 것이다. 84 | 85 | 이 경우 전파되는 이벤트는 우리 서버 → PG사 서버로 전파되는 결제 요청 이벤트일 것이다. 위에서 알아본 원칙에 따르면 우리는 결제 요청 이벤트에 유일한 식별자를 붙여서 PG사에 전송해야 한다. 아마 이벤트는 아래와 같이 생겼을 것이다. 86 | 87 | ``` 88 | { 89 | "transactionId": "ASDFASDFASDF", # globally unique ID 90 | # 결제 금액, 카드 정보 등 결제에 필요한 정보 91 | } 92 | ``` 93 | 94 | 위와 같은 이벤트에 대해 PG사 서버는 멱등적으로 이벤트 핸들러를 구현해야 한다. 여기서는 중복된 결제 요청을 받았을 경우 기존 결과(성공 or 실패)를 그대로 돌려주게 구현해야 할 것이다. 95 | 96 | ```kotlin 97 | // PG사 서버 98 | fun handlePayRequest(request: PayRequest): PayResult { 99 | val prevTransactionResult = transactionResultRepository.findByTransactionId(request.transactionId) 100 | if (prevTransactionResult != null) { 101 | return PayResult( 102 | transactionId = prevTransactionResult.transactionId, 103 | result = prevTransactionResult.result, 104 | transactedAt = prevTransactionResult.transactedAt, 105 | // 기타 필요한 정보 106 | ) 107 | } 108 | 109 | // 결제 처리 로직 110 | 111 | val transactionResult = transactionResultRepository.save(TransactionResult( 112 | transactionId = request.transactionId, 113 | result = result, 114 | transactedAt = now, 115 | // 기타 필요한 정보 116 | )) 117 | 118 | return PayRequest( 119 | transactionId = transactionResult.transactionId, 120 | result = transactionResult.result, 121 | transactedAt = transactionResult.transactedAt, 122 | // 기타 필요한 정보 123 | ) 124 | } 125 | ``` 126 | 127 | 짜잔, 우리는 안전하게 멱등성을 구현했다. 정말 그럴까? 우리는 한 가지 이벤트 전파를 놓치고 있다. 바로 결제 처리 완료 응답이다. 128 | 129 | 예를 들어 결제 처리 완료 응답을 수신하기 전에 우리 서버의 재시도 로직이 동작해서 결제 요청이 두 번 전달됐다고 해보자. 만약 결제가 성공했다면 우리 서버는 같은 `transactionId`에 대해 결제 성공이라는 응답을 두 번 수신하게 될 것이다. 만약 결제 성공 시에 발생하는 추가적인 로직(e.g. 유저에게 문자 발송 등)이 있다면 해당 로직이 두 번 실행될 수 있다. 따라서 우리는 결제 처리 완료 응답에 대한 핸들러도 멱등적으로 구현해야 한다. 130 | 131 | ```kotlin 132 | // 우리 서버 133 | fun handlePayResult(payResult: PayResult) { 134 | val paymentRequest = paymentRequestRepository.findByTransactionId(payResult.transactionId) 135 | val payResultStatus = PaymentRequestStatus.fromPayResult(payResult.result) 136 | if (paymentRequest.status == payResultStatus) { 137 | return 138 | } 139 | 140 | // 결제 처리 완료 응답 처리 141 | } 142 | ``` 143 | 144 | ## 이벤트에 순서가 존재하는 경우 145 | 146 | 우리는 지금까지 한 가지 요청, 한 가지 이벤트를 연동하는 경우에 대해서만 이야기했다. 하지만 비즈니스 로직을 구현하다 보면 서로 연관된 여러 이벤트가 발생할 수도 있다. 이들 사이에는 순서가 존재하며, 이 순서대로 다른 서버에 이벤트가 전파되지 않는다면 해당 이벤트를 수신한 서버는 잘못된 상태로 빠질 수도 있다. 즉, eventual consistency를 달성하는 데에 실패할 수도 있다. 147 | 148 | 예를 들어 지난 글에서 들었던 주문 서버와 배송 서버의 예시를 들어보자. 유저는 주문을 넣은 뒤에 배송지를 수정할 수 있는데, 배송지를 수정하면 다음과 같은 일이 일어난다. 149 | 150 | 1. 주문 서버는 변경된 배송지에 따라 배송비가 변경되었는지 확인하고 추가 결제 / 부분 환불을 일으킨다. 151 | 2. 주문 서버는 변경된 배송지를 배송 서버에 전달한다. 152 | 153 | 이 때 유저가 배송지를 A → B로, B → C로 변경했다고 하자. 만약 A → B 변경 이벤트의 전파가 누락 & 재시도되어 B → C 이벤트보다 늦게 주문 서버에 전파될 경우 배송 서버는 최종적으로 배송지를 B라고 잘못 알게 될 것이다. 154 | 155 | 이러한 문제를 방지하기 위해 크게 두 가지 전략을 사용할 수 있다. 156 | 157 | ### 1. 이벤트를 순서대로 처리하도록 보장하기 158 | 159 | 서버와 클라이언트는 적절히 협력하여 이벤트를 순서대로 처리하는 메커니즘을 구현할 수 있다. 이를 구현하는 방식은 server push와 client pull 중 어떤 방법으로 at least once를 구현했는지에 따라 상당히 달라진다. 160 | 161 | - client pull - 서버는 이벤트를 저장할 때 서로 연관되어 있는 이벤트를 구분할 수 있는 이벤트 그룹 ID와 이벤트 발생 순서를 함께 저장한다. 클라이언트는 서버에 저장된 이벤트를 조회했을 때 같은 이벤트 그룹 ID를 가진 이벤트끼리는 이벤트 발생 순서에 맞춰서 이벤트를 처리한다. 162 | - server push - 두 가지 방법이 있을 수 있다. 163 | - 첫 번째 방법은 서버 쪽에서 이벤트를 순서대로 전파하도록 보장하는 것이다. 서로 연관되어 있는 이벤트 1, 2, 3이 순서대로 발생하는데, 클라이언트가 1만 처리하고 2를 처리하지 못한 상태에서 3이 발생했다고 하자. 그러면 서버는 이벤트 3을 클라이언트에 전달하지 않고 이벤트 2의 전달을 계속 재시도한다. 클라이언트가 이벤트 2를 처리했다는 응답을 받으면 서버는 그제서야 이벤트 3을 전달하기 시작한다. 164 | 165 | 이 방식의 가장 큰 문제는 직접 재시도를 구현하지 않는 상황에서는 사용하기 어렵다는 점이다. 예를 들어 message queue를 사용하고 있었다면, message queue에서 이벤트 순서에 맞춰 이벤트를 전파하는 기능을 제공해주지 않는 한 이 기능을 사용하기는 어렵다. 이럴 경우에는 반드시 클라이언트 쪽에서 이벤트 순서를 맞추는 메커니즘을 구현해야 한다. 166 | 167 | - 두 번째 방법은 클라이언트가 이벤트를 순서대로 처리할 수 있도록 이벤트 프로토콜을 설계하는 것이다. 이는 마치 TCP에서 순서를 맞추는 로직과 같다. 서버는 1. 서로 연관된 이벤트임을 알 수 있는 이벤트 그룹 ID와 2. 클라이언트가 이벤트의 순서를 알 수 있도록 1씩 단조 증가하는 sequence number를 이벤트에 달아서 저장한다. 클라이언트는 각 이벤트 그룹 ID마다 마지막으로 수신한 이벤트의 sequence number를 기억해두고, 그 다음 sequence number가 아닌 이벤트가 들어오면 처리에 실패했다고 응답을 돌려준다. 각 이벤트마다 at least once가 잘 구현되어 있다면 클라이언트는 언젠가는 이벤트 순서에 맞춰서 모든 이벤트를 처리할 수 있다. 168 | 169 | 개인적으로 client pull 방식이 가장 간단해 보이지만, 언제나 주어진 조건과 상황에 맞는 방법을 선택하는 것이 중요할 것이다. 170 | 171 | ### 2. 이벤트 대신 최종 상태를 연동하기 172 | 173 | 지금까지는 개별 이벤트를 연동하는 방법에 대해 논의했다. 하지만 개별 이벤트를 하나하나 연동하는 대신 최종 상태를 한 번에 연동하는 방법도 있다. 174 | 175 | 구현 방식은 비교적 간단하다. 주기적으로 상대 서버의 상태를 확인하여 현재 내 서버의 상태와 다를 경우 맞춰주면 된다. server push 방식일 경우 상대 서버가 내게 상태를 주기적으로 전송해줄 것이고, client pull 방식일 경우 내가 상대 서버의 상태를 주기적으로 조회해올 것이다. 176 | 177 | 위의 주문 서버 & 배송 서버의 예시를 다시 살펴보자. 주문 서버와 배송 서버는 각 배송지 변경 이벤트를 연동하는 대신, 최종적으로 변경된 배송지를 주기적으로 연동한다. 배송 서버는 같은 주문 건에 대해 주문 서버와 배송지가 다르면 주문 서버의 배송지로 자신의 배송지를 수정한다. 필요하면 배송 담당자에게 알림을 보낼 수도 있다. 178 | 179 | 얼핏 보면 상태를 연동하는 것보다 훨씬 간단해보이지만, 몇 가지 고려해야 하는 사항이 있다. 180 | 181 | - 주고받는 데이터의 양 - 위 예시에서 사업이 엄청나게 성공해서 매일 주문이 100만건씩 들어온다고 해보자. 그러면 주문 서버와 배송 서버가 배송지를 한 번 연동할 때마다 어마어마한 양의 네트워크 트래픽이 발생할 것이다. 182 | 183 | 이러한 문제를 방지하기 위해 서버 쪽에서 연동이 필요한 주문 건을 별도로 마킹해두고, 해당 마킹이 있는 주문 건만 배송 서버와 연동하는 전략을 사용할 수 있다. 예를 들면 주문 건에 version과 lastSyncedVersion 필드를 둔다. 주문 건의 배송지가 변경될 경우 version을 1 올리고, 배송 서버와 상태 연동에 성공했을 때 lastSyncedVersion 필드를 연동에 성공한 주문 건의 버전으로 갱신한다. 배송 서버에 연동할 때는 version > lastSyncedVersion인 주문 건만 데이터를 전송한다. 184 | 185 | - 상태 sync 외에 다른 동작이 필요한 경우 - 위 예시에서는 배송지라는 상태만 연동하면 괜찮았기 때문에 최종 상태를 연동하는 방법을 사용할 수 있었다. 하지만 각각의 이벤트로 인해 클라이언트에서 추가적인 동작이 발생할 수 있고, 해당 동작이 비즈니스적으로 중요하다면 최종 상태를 연동하는 방법은 사용할 수 없다. 186 | 187 | ## Reference 188 | 189 | - \ 190 | -------------------------------------------------------------------------------- /general/be-careful-with-data.md: -------------------------------------------------------------------------------- 1 | # 데이터의 중요성과 위험성 2 | 3 | 이 문서에서는 데이터의 중요성과 다루기 어려움에 대해 이야기한다. 4 | 5 | ## 데이터의 중요성 6 | 7 | 서버 개발자의 핵심 업무 중 하나는 바로 데이터를 올바른 형태로 다루는 것이다. 8 | 9 | 데이터는 비즈니스 가치 달성의 핵심이다. 서비스는 데이터를 일정한 방식으로 저장하고, 변경하고, 조회하는 것으로 유저에게 서비스를 제공한다. 은행 서비스를 생각해보자. 송금이라는 행위는 어떻게 이루어지는가? 이는 송금 트랜잭션 이벤트를 저장하고, 한 유저의 잔고 데이터를 감소시키고, 다른 유저의 잔고 데이터를 증가시킴으로써 달성할 수 있다. 즉, 송금이라는 서비스를 제공하는 것은 오로지 데이터를 원하는 대로 조작함으로써 이루어지는 것이다. 다른 많은 서비스도 마찬가지이다. 데이터 없이 순수히 computation으로 제공할 수 있는 비즈니스 가치는 별로 없다. 10 | 11 | 여기에 더해, 데이터는 사업과 프로덕트의 방향성을 결정할 때 매우 중요한 요소이다. BI 리포트, A/B 테스팅 결과, 프로덕트의 퍼널별 이탈율 등 수많은 핵심 지표는 바로 적재된 데이터를 분석함으로로써 도출하는 것이다. 이러한 데이터 기반의 지표는 사업 전략, 사업 기획, 프로덕트 기획 등 회사 전반에서 일어나는 결정에 큰 결정을 미친다. 12 | 13 | API가 실행되고 나면 남는 것은 DB에 저장된 데이터 뿐이다. 서버는 다운돼도 다시 띄우면 되지만, 데이터는 날라가면 그대로 서비스 종료다. 데이터는 IT 비즈니스의 핵심이다. 14 | 15 | 그리고, 이렇게 중요한 데이터를 관리하는 것은 바로 서버팀의 책임이다. 서버 개발자는 비즈니스 규칙을 올바르게 구현하여 데이터를 회사가 원하는 대로 다루고, 적절한 데이터 저장 기술을 사용하여 적재된 데이터를 관리한다. 16 | 17 | ## 데이터 리팩토링은 어렵다 18 | 19 | 애자일 방법론에서는 지속적 리팩토링의 중요성을 강조한다. 모든 비즈니스 요구사항을 완벽하게 수집하는 것보다 현재 알고 있는 비즈니스 요구사항을 만족시킨 뒤 빠르게 이터레이션을 돌리는 게 중요한데, 여기에 있어 새로운 요구사항을 수용할 수 있도록 지속적으로 코드 베이스를 리팩토링하는 건 필수불가결이다. 20 | 21 | 여기서의 리팩토링은 코드 뿐만 아니라 데이터의 리팩토링도 포함된다. 비즈니스가 점차 복잡해질수록 요구사항이 추가되고, 기존에 존재하던 모델이 비대해진다. 이것이 일정 수준을 넘기면 우리는 데이터 모델을 리팩토링하고 싶다는 생각을 하게 된다. 그 때 당시에는 최선이었던 데이터 모델이, 비즈니스가 고도로 복잡해진 현재는 최선이 아닐 수 있다. 22 | 23 | 하지만, 데이터는 몇 가지 이유로 인해 변경하기가 까다롭다. 24 | 25 | ### 데이터는 누적된다 26 | 27 | 데이터를 변경하기 어려운 첫 번째 이유는 바로 **휘발되지 않고 누적된다는 데이터의 성질**이다. 서버 코드는 기본적으로 가장 최신 버전만 프로덕션 환경에 영향을 준다. git과 같은 버전 관리 시스템에 과거의 코드가 저장되어 있긴 하지만, 프로덕션 환경에 배포되는 코드는 가장 마지막 버전의 코드 한 벌이다. 데이터는 다르다. 데이터는 서비스 런칭 직후부터 꾸준히 쌓이고, 일부러 지우지 않는 한 사라지지 않는다. 28 | 29 | 이렇게 쌓인 데이터는 우리의 자산이기도 하지만, 우리를 기민하게 움직이기 어렵게 만드는 장애물이 되기도 한다. 30 | 31 | - RDB - RDB의 스키마는 코드처럼 그냥 변경할 수 없다. 이미 저장되어 있는 데이터가 존재하기 때문이다. RDB 스키마를 리팩토링하려면 그 데이터들을 전부 마이그레이션 하고, 그 과정에서 스키마에 과도기를 두는 등 번거로운 과정이 필요하다. 32 | - NoSQL - 스키마를 배포할 필요가 없는 NoSQL을 사용하더라도 마찬가지다. NoSQL은 write 할 때 스키마가 없는 거지, read 할 때는 RDB와 마찬가지로 스키마가 존재한다. Read 스키마를 변경하면 어플리케이션 딴에서 기존 버전의 스키마로 저장되어 있는 데이터와 새 버전의 스키마로 저장되어 있는 데이터를 둘 다 고려해서 코드를 작성해야 한다. 스키마 변경을 여러번 했다면? 해당 버전들을 모두 고려한 서버 코드를 작성해야 한다. 33 | 34 | ### 데이터는 의존하는 컴포넌트가 많다 35 | 36 | 데이터를 변경하기 어려운 두 번째 이유는 **데이터에 의존하고 있는 컴포넌트가 많다는 것**이다. [데이터의 중요성](/general/be-careful-with-data.md#데이터의-중요성) 단락에서 이야기했듯이, 데이터는 비즈니스에 매우 중요한 요소여서 여기저기 사용된다. 이 말은 데이터를 변경할 경우 영향을 받는 컴포넌트가 매우 많다는 뜻이다. 의존을 많이 받는 코드가 변경하기 어렵듯, 의존을 많이 받는 데이터 역시 변경하기 어렵다. 37 | 38 | 테이블의 이름을 변경하거나, 일부 필드를 다른 테이블로 옮기는 경우를 생각해보자. 어떤 컴포넌트가 영향을 받을까? 우선 당연히 해당 테이블을 직접 조회/수정하는 서버 코드가 영향을 받을 것이다. 하지만 여기서 끝이 아니다. 이 변경은 해당 DB에 접속해서 데이터를 긁어가는 데이터 파이프라인과 데이터 웨어하우스에도 영향을 미친다. 데이터 웨어하우스에 영향을 미친다는 것은, 해당 데이터 웨어하우스에 날리는 모든 SQL 문이 영향을 받는다는 뜻이다. 회사에 존재하는 모든 SQL 기반의 리포트도 변경에 영향을 받는 대상이다. DB 테이블을 변경할 때는 이 모든 것을 고려해야 한다. 당연히 DB 테이블 리팩토링의 비용은 어마어마하다. 39 | 40 | ## 데이터 조작(mutation)은 두렵다 41 | 42 | 앞서 말했듯, 서버 개발자의 주된 업무는 비즈니스 규칙에 맞춰 데이터를 조작하는 것이다. 일정한 규칙에 따라 데이터를 변경해야 데이터의 정합성이 유지될 수 있다. 43 | 44 | 하나의 비즈니스 로직을 올바르게 작성하는 것은 그리 어렵지 않다. 하지만, 가끔 올바르게 작성한 비즈니스 로직도 다른 곳에 예상치 못한 영향을 끼치기도 한다. 예를 들어, 도서관에서 책을 빌리는 서비스를 생각해보자. 유저가 책을 빌리려면 우선 `대여 요청`을 날려야 한다. 그리고 사서가 `대여 요청`을 승인하면 `대여` 객체가 만들어지면서 대여 상태가 된다. 이 때 유저가 `대여 요청`을 취소하는 비율인 "대여 요청 취소율"이라는 지표가 있다고 해보자. 이 지표는 단순히 (1 - `대여` 숫자 / `대여 요청` 숫자)로 계산된다. 이때, 만약 운영 상의 이유로 어드민에서 `대여 요청` 없이 바로 `대여`를 해주는 새로운 기능이 생겼다면? 이 기능 때문에 대여 요청 취소율이 소폭 감소할 수 있을 것이다. 이 영향은 일어나도 괜찮은 영향인가? 45 | 46 | 이 예시는 **우리가 작성하는 비즈니스 로직이 미치는 영향이 어마어마하게 넓은 범위로 전파될 수 있음**을 보여준다. [데이터는 의존하는 컴포넌트가 많다](/general/be-careful-with-data.md#데이터는-의존하는-컴포넌트가-많다) 장에서 언급했듯이, 데이터에 의존하는 요소가 어마어마하게 많기 때문이다. 47 | 48 | 또한, 서비스를 운영하다 보면 피치 못하게 데이터를 강제로 조작해야 하는 상황이 종종 발생한다. 이는 서버 개발자가 DB에 `UPDATE` 쿼리를 직접 날리는 것일 수도 있고, 이런 `UPDATE` 쿼리를 날리는 작업을 자동화하여 사내 어드민에 넣은 것일 수도 있다. **이러한 강제 DML은 비즈니스 규칙을 따른다는 게 테스트로 보장이 되지 않기 때문에 정합성이 깨질 가능성이 매우 높고, 따라서 매우 위험하다.** 위의 도서관 예시를 들면, `대여` 객체와 매핑되는 `대여 요청` 객체가 반드시 존재한다고 가정하고 작성한 로직이 서버 어딘가에 있다고 해보자. 만약 어드민에서 강제로 꽂아준 `대여` 객체가 이 로직을 타게 된다면 NullPointerException 같은 에러가 발생할 것이다. 49 | 50 | ## 어떻게 해야 하는가? 51 | 52 | 데이터는 다루기 어렵고, 두렵다. 하지만 서버 개발자로서 데이터를 다루는 것은 피할 수 없는 일이다. 따라서 어려움과 두려움을 이겨낼 정도의 지식을 갖춰야 한다. 53 | 54 | - 내가 하려는 로직 / 데이터 변경이 어떤 영향을 미칠지 예측하기 위해, 비즈니스 도메인을 아주 잘 이해해야 한다. 55 | - 데이터에 의존하는 요소에 어떤 것이 있는지 정확히 파악하고 있어야 한다. 56 | - 데이터를 리팩토링하는 올바른 방법을 공부해야 한다. \가 큰 도움이 될 것이다. 57 | -------------------------------------------------------------------------------- /general/how-to-grow.md: -------------------------------------------------------------------------------- 1 | # 어떻게 성장할 것인가? 2 | 3 | 누구나 빠르게 성장하고 싶다. 얼른 신입 티를 벗고 1인분을 하고 싶고, 어떤 개발적인 문제든 능숙하게 해결하는 슈퍼 개발자가 되고 싶고, 다른 팀원들의 신뢰와 인정을 받고 싶고, 높은 연봉을 받고 싶다. 하지만 당연하게도 이런 사람은 그리 많지 않다. 전체 개발자 중에 손에 꼽는 사람만이 가파르고 지속적인 성장을 통해 돋보이는 수준의 성취를 이룰 수 있을 것이다. 4 | 5 | 그럴 수밖에 없는 이유는, 성장이 정말 어려운 일이기 때문일 것이다. 나는 성장을 더 많은 역할을 수행할 수 있게 되는 것이라고 생각한다. 새로운 역할을 수행할 수 있으려면 새로운 지식을 익히고, 새로운 종류의 고민을 하고, 익숙하지 않은 업무로 인해 새로운 실패를 맛보고, 실패를 인내하고 극복해야 한다. 이 과정은 많은 노력과 인내를 필요로 하는데, 충분한 의지나 의욕을 가진 사람은 드물다. 6 | 7 | 성장은 분명 어려운 일이지만, 누군가는 주변보다 훨씬 더 빠르게 성장한다. 이는 성장에도 '요령'이 있기 때문이다. 같은 양의 시간만큼 노력했더라도 그 시간 동안 무엇을 공부했고, 무엇을 고민했고, 어떤 경험을 쌓았느냐에 따라 성장의 정도는 천차만별이 될 수 있다. 8 | 9 | 그렇기 때문에, 성장에 목마른 사람에게 가장 중요한 것은 시간을 효율적으로 사용하는 것이다. 시간을 어떻게 활용해야 효율적인 걸까? 10 | 11 | ## 목표 세우기 - 어떤 개발자가 되고 싶은가? 12 | 13 | 세상에는 다양한 종류의 개발자가 있다. 직군만 하더라도 네이티브 앱 / 웹 / 서버 / DevOps / 데이터 엔지니어링 등이 있고, 커리어 트랙도 크게 매니저/디렉터/CTO 트랙과 스페셜리스트 트랙으로 나뉜다. 같은 직군과 직책의 사람이더라도 회사의 상황과 개인의 성향에 따라 모두 조금씩 다른 역할을 수행하고 서로 다른 장단점을 가진다. 14 | 15 | 욕심이 많은 우리는 당연히 네이티브 앱과 웹과 서버와 DevOps와 데이터 엔지니어링을 모두 잘하고 싶다. 모든 분야가 흥미로워 보인다. 하지만 현실적으로 이 모든 분야에서 전문성을 갖추는 건 불가능하다. 시간이 부족하기 때문이다. 세상에는 어마어마하게 많은 개발 지식이 존재하고, 앞으로도 수많은 기술과 패턴, 개념이 쏟아져 나올 것이다. 이를 모두 공부하는 것은 한 사람의 인생을 쏟아부어도 불가능하다. 커리어 트랙에 대해서도 마찬가지다. 기술적으로도 뛰어나고 커뮤니케이션도 훌륭히 수행할 수 있어서 스페셜리스트 역할과 CTO 역할을 둘 다 수행할 수 있는 사람이 존재할 수는 있다. 하지만 능력이 충분하더라도 현실적으로 둘 다 수행할 수 있는 시간이 없을 것이다. 아무리 열심히 일해도 하루에 24시간 이상 일할 수는 없기 때문이다. 16 | 17 | 결국 우리는 선택과 집중을 해야만 한다. 우리는 무한한 가능성 속에서 어떤 지식을 습득할 것인지, 어떤 역할을 맡을 것인지 선택하기를 강요받는다. 18 | 19 | 이러한 선택의 순간에 만족스러운 결정을 내리기 위해서는 좋은 기준, 즉 목표가 필요하다. 개발 지식을 익히는 것 자체가 재미있는 개발자는 그 시점에 가장 흥미로운 개발 경험을 할 수 있는 선택지를 고를 것이다. 현재 커리어를 밀고 나가고 싶은 개발자는 현재 커리어에 가장 도움이 되는 선택지를 고를 것이다. 반면 커리어를 전환하고자 하는 개발자는 상반된 선택지를 고를 수도 있다. 어쨌든 중요한 건 선택의 기준이 되는 자신만의 목표를 가지는 것이다. 목표는 그 사람의 선택, 그 사람의 시간, 그 사람의 노력에 더 많은 의미를 부여한다. 20 | 21 | '나는 어떤 개발자가 되고 싶은가?' 빠르게 성장하고 싶다면 반드시 이 질문에 대한 자신만의 답을 가지고 있어야 한다. 원하는 성장의 방향성이 없다면 성장의 효율이 떨어지고, 심지어 성장이 무엇인지 정의할 수도 없게 된다. 이 질문에 대한 답을 가진 사람과 그렇지 않은 사람은 선택이 누적되면 누적될수록 어마어마한 성장 차이를 보이게 될 것이다. 22 | 23 | ## 꾸준한 회고 24 | 25 | 목표를 이루기 위해서는 두 가지 요소가 필요하다. 첫 번째는 좋은 계획을 수립하는 것이고, 두 번째는 계획을 올바르게 수행하는 것이다. 회고는 이 두 가지를 실천하는 데에 필수적이다. 26 | 27 | 우선 계획을 세우는 측면에 대해 이야기해보자. 계획을 세우려면 우선 현재 상황과 목표에 대한 구체적인 이해를 해야 한다. 내가 목표하는 개발자가 되기 위해 어떤 능력을 갖추어야 하는지를 알고, 내가 지금 갖추고 있는 능력이 어느 정도인지를 인지해야만 그 차이를 메꾸기 위한 계획을 세울 수 있다. 하지만, 자신이 얼마만큼의 능력을 갖추고 있는지를 객관적으로 알아내기란 매우 어렵다. 개발 지식의 측면에서는 그나마 알기 쉽지만, 자신의 소프트 스킬을 가만히 앉아서 수치화하는 것은 거의 불가능에 가깝다. 28 | 29 | 회고는 자신을 객관적으로 이해하는 데에 큰 도움을 준다. 회사에서 다양한 프로젝트와 상황을 마주하는 과정에서 자기도 모르는 자신의 장단점이 자연스럽게 드러난다. 이를 그 시점에서 인지하기는 어렵지만, 시간이 지나고 넓은 시야로 자신의 행동을 되돌아보면 충분히 캐치해낼 수 있다. 이를 통해 자신에 대해 조금씩 알아가게 되고, 이를 기반으로 더 좋은 계획을 세울 수 있다. 30 | 31 | 다음은 올바른 계획의 실행과 관련된 측면이다. 당연하게도, 계획을 세운다고 해서 항상 모든 것이 계획대로 흘러가지는 않는다. 의지와 시간이 부족해서 계획을 제대로 못 따라갔을 수도 있고, 제어할 수 없는 외부 변수로 인해 상황이 달라졌을 수도 있다. 이 때문에 상황에 맞게 계획을 보완하고 수정해줘야 한다. 32 | 33 | 회고는 계획을 보완하고 수정하기에 최적의 방법이다. 계획의 실행은 나무를 보는 것이고, 회고는 숲을 보는 것이다. 계획을 우직하게 실행하다 보면 현재 상황을 잘 파악하기가 힘들다. 가끔은 멈춰서서 지도를 펼치고, 지금 자신이 어디에 서 있는지 인지하고, 목표 지점을 다시 확인하는 작업이 필요한 법이다. 회고는 정확히 이런 역할을 한다. 회고를 통해 현재 상황을 파악하고, 계획이 얼마나 순조롭게 실행되고 있는지를 판단할 수 있다. 그리고 현 상황에 맞춰 계획을 보완할 수 있다. 34 | 35 | ## 당장 필요한 것을 공부하라 36 | 37 | 위에서 어떤 목표를 설정했든 간에, 나는 신입과 주니어가 가져야 하는 최우선 목표는 1인분을 하게 되는 것이라고 생각한다. 즉, 시니어의 도움 없이도 혼자 프로젝트에 참여해서 개발을 진행할 수 있는 능력을 갖춰야 한다. 이는 1인분을 하는 게 모든 커리어의 시발점이기 때문이다. 혼자서는 개발을 할 수 없고 다른 사람의 케어가 필요하다는 것은 개발자라면 응당 갖춰야 할 지식과 능력을 아직 갖추지 못했다는 뜻이다. 이러한 상황에서 다른 방향으로 성장하겠다는 것은 어불성설이다. 38 | 39 | 1인분을 한다는 목표를 이루는 가장 효율적인 방법은 '지금 몰라서 가장 불편한 지식을 익히는 것'이다. 그 이유는 지식을 체화하는 속도에 있다. 40 | 41 | 개발 지식은 단순히 알고 있다고 내 것이 되는 게 아니다. 필요한 순간에 필요한 지식을 떠올리고, 지식과 지식을 연관짓고, 지식을 활용해도 되는 상황인지 아닌지를 판단할 수 있어야 내 것이라고 할 수 있다. 이렇게, 지식을 자유자재로 활용할 수 있게 되는 것을 나는 지식의 체화라고 부른다. 지식은 체화했을 때만 비로소 가치를 가진다. 42 | 43 | 회사 프로젝트는 지식을 체화하기에 가장 좋은 환경이다. 회사에서 진행하는 프로젝트만큼 다양한 요구사항이 존재하는 토이 프로젝트는 없다. 다양한 요구사항은 기술을 다각도로 응용할 수밖에 없는 상황을 제공하여, 개발자로 하여금 기술에 대해 깊은 이해를 가지도록 강제한다. 또한 회사 프로젝트에 배운 것을 잘못 적용하면 보통 시니어가 리뷰를 통해 잘못된 부분을 지적해주기 때문에 지식의 활용 방법을 더욱 빠르게 익힐 수 있다. 44 | 45 | ### 회사의 코드베이스를 잘 이해하라 46 | 47 | 강조하고 싶은 한 가지 포인트는 회사의 코드베이스를 공부하는 것이다. 여기서 말하는 코드베이스에는 개발팀이 사용하는 기술 뿐만 아니라 회사의 도메인과 비즈니스 로직, 그리고 히스토리까지 포함된다. 회사의 코드베이스는 회사의 비즈니스 로직을 작성하는 데에 최적화된 일종의 프레임워크라고 생각할 수 있다. 이를 모른다면 기능을 개발하는 데에 어려움을 겪고, 다른 팀원에게 기대야만 하는 상황이 자꾸만 발생한다. 예를 들어 해당 도메인의 히스토리를 몰라 지금 고친 코드가 어떤 곳까지 영향을 미칠지 제대로 파악하기 어렵거나, 서버 구조를 잘 몰라 배포를 할 줄 모를 수 있다. 이런 상황에서는 결국에는 다른 팀원의 손을 빌리게 된다. 이는 성장의 기회를 크게 제한한다. 이런 상황에서 벗어나기 위해, 회사의 코드베이스를 이해하는 데에 많은 노력을 기울이자. 48 | -------------------------------------------------------------------------------- /general/images/mece.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zeniuus/basics-of-server-development/91172e68db81e39f50e80dc7dc37141239ac9ec4/general/images/mece.png -------------------------------------------------------------------------------- /general/useful-habits.md: -------------------------------------------------------------------------------- 1 | # 유용한 개발 습관 2 | 3 | 필자는 생산성 향상이 좋은 습관을 기르는 것으로부터 온다고 생각한다. 습관을 기른다는 것은 곧 고민하지 않고도 무의식적으로 무언가를 처리할 수 있게 된다는 뜻이다. 더 많은 것을 무의식의 영역에서 처리할 수 있게 됨으로써, 우리는 더욱 어렵고 중요하고 가치 있는 문제에 대해 고민할 수 있는 리소스를 확보하게 된다. 필자가 아주 좋아하는 켄트 백의 말이 있다. "난 뛰어난 프로그래머가 아니에요. 단지 뛰어난 습관을 지닌 괜찮은 프로그래머일 뿐이에요." 4 | 5 | 이 문서에서는 필자의 생산성 향상에 큰 도움이 된 습관들을 요약해보았다. 6 | 7 | ## 코딩 전에 설계하기 8 | 9 | 좋은 코드를 작성하기 위해서는 코딩보다 설계가 앞서야 한다. 설계라고 이야기하면 거창하게 들릴 수도 있으니 조금 바꿔서 표현하면, **어디에 어떤 코드를 추가/변경할 것인지 미리 고민을 한 뒤 코딩을 시작해야 한다.** 10 | 11 | 좋은 설계 - 변경을 쉽게 수용할 수 있는 설계 - 의 핵심 중 하나는 **당장 구현이 편한 코드를 짜는 게 아니라 갖다 쓰기 쉬운 코드를 짜는 것**이다. 하지만 별다른 고민 없이 개발하다 보면 갖다 쓰기 쉬운 코드가 아니라 당장 구현이 편한 코드를 짜게 되기가 쉽다. 12 | 13 | 이는 마치 global optima가 아니라 local optima에 빠지는 것과 같다. 당장의 문제를 빠르고 편하게 해결하기 위해 쉽게 짜면 다음에 개발할 때 더 많은 문제를 마주하게 된다. 함수를 재활용하기가 어려워서 중복 코드가 발생하거나, 의존성이 복잡해져서 코드를 변경하기 어려워지거나, 함수 호출 플로우가 복잡해서 코드를 파악하기 어려워지는 등 다양한 문제가 발생할 수 있다. 14 | 15 | 반면 설계를 마친 개발은 주저가 없다. 발생할 수 있는 다양한 문제를 이미 충분히 고려했기 때문에, 마치 타자 연습을 하듯이 설계한 내용을 따라가면서 코드를 타이핑하기만 하면 된다. 어쩌다 예상치 못한 문제에 마주칠 수 있지만, 그런 경우에는 코딩을 멈추고 새롭게 발생한 문제까지 고려해서 다시 설계를 하면 된다. 실제 코드를 짜는 과정에서 겪는 문제가 훨씬 적기에, 설계 없이 코딩하는 것보다 충분한 고민을 거쳐 설계를 한 뒤 코딩하는 게 전체 개발 시간은 훨씬 짧게 걸린다. 스펙이 복잡하면 복잡할 수록. 16 | 17 | 이는 누구나 알고 있는 이야기지만, 코딩 전에 설계하기를 지키는 건 생각보다 쉽지 않다. 일반적인 어플리케이션을 개발하는 상황, 특히나 입사한지 얼마 되지 않은 신입이라면 큰 규모의 작업에 참여하는 일은 별로 없다. 대부분은 수 줄에서 수십 줄 사이의 간단한 코딩으로 끝낼 수 있는 작업일 것이다. 작업이 작고 어떻게 구현하면 될지가 뻔히 보이니 일단 코드부터 짜게 되는데, 이게 반복되다 보면 습관이 된다. 이런 습관이 들지 않도록 경계해야 한다. 18 | 19 | ## 셀프 리뷰하기 20 | 21 | 많은 고민을 통해 설계를 결정하고 코딩을 시작했다고 하더라도, 실제로 코딩을 진행하는 과정에서 다양한 문제가 발생할 수 있다. 22 | 23 | - 프로덕션 레벨의 코드는 일반적으로 매우 복잡하기 때문에, 코드 변경으로 인해 영향을 받는 부분이 설계 단계에서 생각했던 것보다 더 많을 수 있다. 이런 경우 보통 기존 설계를 점진적으로 보완/수정하며 개발을 진행하는데, 이 과정에서 설계가 최선을 벗어날 수 있다. 24 | - 설계가 아니라 구현의 영역에서 문제가 발생할 수 있다. 변수명/함수명이 적절하지 않거나, 작성한 코드에 기술적인 이슈가 존재하거나, 가독성이 떨어지는 등의 문제가 발생한다. 25 | - 휴먼 에러가 발생할 수 있다. 설계를 코드로 옮기는 과정에서 누락한 부분이 있거나, typo 등의 단순 실수도 종종 발생한다. 26 | 27 | 이런 문제가 발생하는 이유는 코딩을 하는 동안 개발자의 시야가 좁혀지기 때문이다. 실제로 코드를 작성하다 보면 개발자의 사고는 자연스럽게 한 줄 한 줄의 코드를 잘 작성하는 것에 집중하게 되는데, 이러면 실수나 설계 미스가 잘 보이지 않게 된다. 28 | 29 | 이러한 문제를 완화하기 위한 좋은 방법으로 본인의 코드를 리뷰하는 **셀프 리뷰**를 진행할 수 있다. 즉, 코드를 작성할 때 시야가 나무를 보는 것으로 좁아졌다면, 코드를 작성한 후에 셀프 리뷰를 통해 다시 시야를 넓혀 숲을 보는 것이다. 이 과정을 통해 개발자는 최종적으로 구현된 코드가 충분히 좋은 설계를 가졌는지 되돌아보고, 단순 실수나 기술적인 이슈를 1차적으로 걸러낼 수 있다. 30 | 31 | ## 멀티태스킹을 피하기 32 | 33 | 회사와 업무에 익숙해지고, 점차 역할이 커지면서 대부분의 사람이 마주치게 되는 어려움이 하나 있다. 바로 멀티태스킹이다. 34 | 35 | 간단한 태스크를 받아서 개발하는 시기가 지나면 특정 도메인의 업무에 연속성을 가지고 참여하게 되거나 프로젝트 내에서 더 다앙한 role을 수행하게 되는데, 이 순간부터 갑자기 처리해야 하는 업무의 종류와 수가 폭발적으로 증가한다. 다른 팀과의 커뮤니케이션, QA 이슈 처리, 운영 등의 업무에 모두 참여하게 된다. 이때까지 지라 이슈를 한 개씩 가져가서 처리하기만 했는데, 갑작스럽게 커뮤니케이션 요청이 쏟아지니 잦은 컨텍스트 스위칭으로 인해 업무 효율이 급격하게 떨어진다. 36 | 37 | 다행인 점은, 이러한 어려움은 혼자만 겪는 게 아니라는 점이다. 선천적으로 멀티태스킹을 잘하는 사람도 분명 있겠지만, 대부분의 사람은 멀티태스킹을 제대로 하지 못한다. 이전에 멀티태스킹이 안 돼서 고통받던 2년차 시절, <나는 아마존에서 미래를 다녔다>라는 책을 읽다가 깜짝 놀랐다. 아마존에 다니는 훌륭한 개발자도 나랑 똑같이 멀티태스킹이 안 돼서 고생하고 있었기 때문이다. 38 | 39 | > 그 덕분에 내 책상에도 깔끔하게 마무리짓지 못한 일들이 쌓여갔고, 어느 시점부터 내 삶의 고삐를 아마존에 내주고 말았다. 40 | ... 41 | 한 과목만 공부하면 시험을 잘 볼 텐데 언제나 인생은 동시에 여러 과목을 요구해서 나를 한계 상황으로 몰아넣곤 했다. 특히 어떤 날은 아침에 자리에 앉으면 머리에 여러 개의 못이 박혀 있는 것 같은 때도 있었다. 그 수는 내가 처리해야 할 일들의 숫자와 일치했다. 42 | 43 | 멀티태스킹의 어려움을 극복하는 방법은 단순하다. 바로 멀티태스킹을 피하는 것이다. **모든 일이 중요하고 빠르게 처리되어야 하는 건 아니다.** 어떤 일은 덜 중요하고, 어떤 일은 좀 덜 급하다. 매우 중요하고 급한 업무는 생각보다 그리 많지 않다. 대부분의 업무는 높은 시급성을 가지지 않기 때문에 바로 처리해주지 않아도 큰 문제가 발생하지 않는다. 44 | 45 | 그러니, **의도적으로 한 번에 한 개의 업무에만 집중하라. 집중할 수 있는 본인만의 환경을 구성하라. 한 번 시작한 일은 최소한 2~30분 정도는 집중할 수 있도록 하라.** 여기에는 몇 가지 좋은 방법이 있다. 46 | 47 | - 우선순위에 따른 TODO 리스트를 만들고, 위에서부터 한 개씩 처리한다. 이는 마치 내가 해야 할 일의 queue처럼 사용할 수 있는데, 그날 새롭게 추가된 업무(e.g. 사내 메신저로 요청이 들어옴)가 있으면 우선순위에 맞춰서 적당한 곳에 TODO 아이템을 끼어넣으면 된다. 매일 아침 출근해서 TODO 리스트를 만들면 한 번에 한 개의 업무에 집중하는 데 큰 도움이 된다. 48 | - 집중해서 일해야 하면 사내 메신저를 잠시 꺼두는 게 좋다. 많은 경우 사내 메신저에서 오는 알람이 집중을 방해하는 가장 큰 요소로 작용하기 때문이다. 단, 너무 오래 꺼두면 정말 중요한 연락을 받지 못할 수도 있으니 주의해야 한다. 49 | - 복잡한 개발을 진행할 때는 커밋을 자주 하면 도움이 된다. 개발, 특히 리팩토링을 진행하다 보면 개발하는 도중에 새로운 이슈를 발견하는 경우가 있다. 이 때 동시에 여러 이슈를 해결하려고 하면 놓치는 부분이 발생하기 쉽고, 개발하던 코드가 마음에 안 들어서 롤백할 때도 더 많은 코드를 날려야 한다. 한 번에 하나의 이슈만 처리한 뒤 커밋을 하면 이러한 문제를 완화할 수 있다. 50 | -------------------------------------------------------------------------------- /general/useful-thinking-strategies.md: -------------------------------------------------------------------------------- 1 | # 개발 시 유용한 개념과 사고 전략 2 | 3 | ## 하향식 접근법 4 | 5 | 어딘가 먼 장소로 이동하는 상황을 생각해보자. 예를 들어, 서울에 있는 집에서 제주도에 있는 호텔까지 이동한다고 해보자. 이 경우 보통 큼지막한 규모로 계획을 세울 것이다. 예를 들어 "김포공항에서 제주공항까지 비행기를 타고 가고, 집 → 김포공항과 제주공항 → 호텔은 택시를 타고 간다" 정도. 그리고 실제로 이동을 할 때는 각 계획의 단계를 더 작은 행동들로 분해해서 수행한다. 6 | 7 | - "택시를 타고 간다" - 택시 호출 → 택시가 도착하면 탑승 → 택시를 타고 김포공항까지 이동 → 요금 결제 → 택시 하차 → 김포공항 내부까지 걸어서 이동 8 | - "비행기를 타고 간다" - 탑승 수속 → 보안 검색대 통과 → 탑승구까지 이동 → 탑승 후 좌석 찾기 → 착석 → 비행기를 타고 제주공항까지 이동 → 내리기 9 | 10 | 개발도 마찬가지이다. 특정한 동작을 구현하려면 그 동작을 컴파일러가 이해할 수 있는 수준인 변수 할당, 연산, 조건문, 반복문의 수준까지 분해해야만 한다. 일반적으로 우리가 원하는 동작은 코드 수준과는 상당히 먼 추상적인 동작이다. 때문에 한 번에 코드 레벨까지 분해하기에는 쉽지 않다. 이를 하려면 원하는 동작을 조금 더 상세한 세부 동작의 합으로 표현하는 작업을 반복적으로 수행해야만 한다. 11 | 12 | 이렇게, 최상위의 추상적인 요구사항을 구체적인 동작으로 분해하여 시스템을 설계하는 것을 하향식 접근법이라고 한다. 하향식 접근법은 원하는 방식으로 동작하는 코드를 작성하는 가장 기본적인 전략이다. 이것을 제대로 할 수 없으면 코딩 속도가 현저히 느려지고, 요구사항에 맞는 올바른 코드를 작성하기가 어렵다. 13 | 14 | 한 가지 주의해야 할 것은, 하향식 접근법은 대규모 설계에는 적합하지 않은 방식이라는 점이다. 대규모 설계에는 시스템의 컴포넌트 사이의 협력과 컴포넌트의 책임을 더 중요시하는 객체지향식 접근법이 더 적합하고, 하향식 접근법은 각 컴포넌트의 동작을 실제로 구현할 때 더 적합하다. 15 | 16 | 아직 원하는 대로 동작하는 코드를 작성하는 데 어려움을 느낀다면 협력과 책임을 고민하기보다는 하향식 접근법에 익숙해지는 게 우선이다. 설계는 올바른 동작 다음이다. 17 | 18 | ## MECE하게 상태를 나누기 19 | 20 | MECE는 Mutually Exclusive, Collectively Exhaustive의 약자이다. Mutually Exclusive는 서로 겹치지 않는 것들의 모임이라는 뜻이고, Collectively Exhaustive는 모두 모았을 때 전체가 된다는 뜻이다. 즉 MECE하게 상태를 나눈다는 건 전체를 겹치지 않은 부분들로 나눈다는 뜻이다. 21 | 22 | ![MECE](/general/images/mece.png) 23 | 24 | MECE는 컨설팅 업체인 맥킨지가 가장 먼저 도입한 개념이라고 하는데, 개발을 할 때도 여러 상황에서 상당히 유용하게 사용할 수 있는 개념이다. 25 | 26 | - 기획에서 누락된 시나리오를 챙기기에 적합하다. 기획과 화면이 받아주지 않고 있는 코너 케이스, 특정 상태에서 어떻게 동작할지에 대한 정의가 누락된 부분 등을 발견하기 좋다. 27 | - 3가지 이상의 시나리오가 존재하는 복잡한 기획이 있는 경우, MECE하게 분리해서 조건문을 작성하면 의도치 않은 동작을 방지할 수 있다. 28 | 29 | 무언가 놓치고 있는 듯한 불안한 느낌이 들면 상태를 MECE하게 나눠보도록 하자. 불안함의 원인을 찾을 수 있을지도 모른다. 30 | 31 | ## State Machine 32 | 33 | State machine, 혹은 finite state machine은 컴퓨터 과학 쪽에서 나오는 개념으로, 연산을 모델링하는 방법 중 하나이다. state machine은 연산을 다음과 같은 4가지 요소로 모델링한다. 34 | 35 | - 시스템이 지닐 수 있는 상태의 목록 36 | - 시스템이 가동됐을 때의 초기 상태 37 | - 상태의 전이가 발생하는 규칙 38 | - 전이됐을 때 시스템이 종료되는 상태 39 | 40 | State machine은 복잡한 life cycle을 가진 도메인을 설계할 때 유용하다. 필자가 특히 유용하게 사용했던 영역은 다음과 같다. 41 | 42 | - 상태를 가진 엔티티의 행동 정의 - 긴 life cycle을 가지고 상태 변화를 추적해야 하는 엔티티를 설계할 때 큰 도움이 된다. 어떤 상태에서 어떤 행동이 가능해야 하는지, 가능한 상태 정의는 무엇인지를 파악하면 역으로 각 상태에서 하면 안 되는 행동과 상태 전이를 알아낼 수 있다. 이는 엔티티가 잘못된 상태로 빠질 위험을 감소시켜, 보다 견고하고 안전한 도메인 설계를 이끌어낼 수 있도록 도와준다. 43 | - 분산 시스템 설계 - 분산 시스템에서는 올바른 재시도 로직을 구현하는 것이 중요하다. 올바른 재시도 로직에는 재시도를 하는 조건, 재시도를 중지해야 하는 조건 등이 포함되는데, 원격 프로세스의 진행 상황을 처리 중 / 재시도 필요 / 재시도 불가 / 처리 완료의 4가지 상태로 나누어서 추적하면 깔끔하고 명확한 재시도 로직을 구현할 수 있다. 44 | 45 | ## Refs 46 | 47 | - [https://en.wikipedia.org/wiki/Finite-state_machine](https://en.wikipedia.org/wiki/Finite-state_machine) 48 | -------------------------------------------------------------------------------- /project/release-plan.md: -------------------------------------------------------------------------------- 1 | # 출시 전략 고려하기 2 | 3 | [기획서와 친해지기](https://github.com/Zeniuus/basics-of-server-development/blob/main/project/working-with-proposals.md) 문서에서 이야기한 것처럼, 개발자는 기획서를 보고 요구사항을 뽑아내고 이를 코드로 옮기는 작업에 능숙해야 한다. 이 과정에서 놓치기 쉬운 요구사항 중 하나는 바로 출시 전략이다. 출시 전략이란 기능을 언제, 어떤 방식으로 출시할 것인지에 대한 계획이다. 출시 전략은 종종 기능 외의 또 하나의 요구사항이 되곤 하는데, 출시 전략에 따라 개발해야 하는 요소들이 급격하게 늘어날 수도 있다. 따라서 설계 단계부터 출시 전략을 파악하고 이를 고려한 설계를 하는 것이 중요하다. 4 | 5 | ## 일반적인 출시 전략 6 | 7 | 일반적으로 사용하는 출시 전략은 크게 3가지 정도이다. 8 | 9 | - 서버가 배포되자마자 출시하기 10 | - 클라이언트가 배포되자마자 출시하기 11 | - 특정 시점 이후에 출시하기 12 | 13 | ### 서버가 배포되자마자 출시하기 14 | 15 | 서버 내부적인 로직의 수정이나 기존 API로 수용할 수 있는 수준의 기능 추가/변경의 경우, 출시 일정을 빡빡하게 관리할 필요가 없으면 보통 서버가 출시되자마자 변경사항이 적용되도록 출시 전략을 짠다. 16 | 17 | 이 경우는 고려해야 할 사항이 딱히 없다. 서버 배포가 되자마자 기능이 출시되어도 되기 때문에, 그냥 기능을 잘 개발하고 너무 늦어지지 않게 서버를 배포하기만 하면 된다. 18 | 19 | ### 클라이언트가 배포되자마자 출시하기 20 | 21 | 클라이언트 UX 변경, 기존 API로 수용할 수 없는 수준의 기능 추가/변경 등의 경우, 보통 클라이언트가 출시되는 순간부터 기능이 적용되도록 출시 전략을 짠다. 22 | 23 | 새 기능은 서버를 필요로 할 수도 있고 아닐 수도 있다. 클라이언트 UX 변경과 같은 경우에는 클라이언트 작업만 필요로 하지만, 신규 기능의 출시는 서버에서 개발한 새로운 API를 필요로 할 것이다. 만약 새 기능이 서버를 필요로 한다면, 반드시 클라이언트보다 서버를 먼저 배포해놓아야 한다. 안 그러면 새로운 클라이언트 버전이 배포되어 새 API를 호출하려고 하면 404 에러가 뜰 것이다. 24 | 25 | 클라이언트보다 서버를 먼저 배포해야 하기 때문에, 서버를 먼저 배포해도 문제가 없게끔 기능을 개발하는 것이 중요하다. 변경사항이 기존 클라이언트에 영향을 주면 안 되는 경우 클라이언트의 버전을 보고 버전별로 서로 다른 로직을 적용하는 게 필요할 수도 있다. 26 | 27 | ### 특정 시점 이후에 출시하기 28 | 29 | 특정 시점부터 새로운/변경된 기능을 도입하고 싶은 경우, 추가적인 개발을 통해 출시 전략을 구현해야 한다. 30 | 31 | 여기서의 추가적인 개발은 보통 [feature flag](https://en.wikipedia.org/wiki/Feature_toggle)를 의미한다. feature flag란 코드의 실행 흐름을 제어할 수 있는 flag를 둠으로써 빠르게 실행 흐름을 변경할 수 있는 개발 기법이다. feature flag를 사용하면 미리 개발해둔 두 개 이상의 로직 중에 어떤 로직을 적용할지를 빠르게 변경할 수 있다. 대략 아래와 같은 구조이다. 32 | 33 | ```kotlin 34 | fun doSomething() { 35 | if (featureFlag.isEnabled()) { 36 | runNewLogic() 37 | } else { 38 | runLegacyLogic() 39 | } 40 | } 41 | ``` 42 | 43 | 시간에 따라 변경되는 feature flag를 만들고 싶으면 아래와 같이 구현하면 된다. 44 | 45 | ```kotlin 46 | fun doSomething() { 47 | if (featureFlag.isEnabled(now)) { 48 | runNewLogic() 49 | } else { 50 | runLegacyLogic() 51 | } 52 | } 53 | 54 | // featureFlag의 구현 55 | fun isEnabled(now: Instant): Boolean { 56 | return newLogicStartAt <= now 57 | } 58 | ``` 59 | 60 | 언제나 위와 같이 깔끔한 if문으로 feature flag를 구현할 수 있는 것은 아니다. 로직을 분기타야 하는 부분이 여러군데 흩어져 있을 수도 있고, 현재 시각이 아닌 데이터의 생성 시각으로 분기를 타야할 수도 있다. 중요한 것은 feature flag를 나중에 걷어내기 쉽도록 응집도 높게 구현하는 것이다. 61 | 62 | 클라이언트의 UI 컴포넌트에 대해서도 feature flag를 구현해야 할 수도 있다. 예를 들어 특정 버튼을 특정 시각까지 숨겼다가 그 이후부터 노출해주고 싶을 수 있다. 이런 경우 클라이언트에서 feature flag를 구현해야 하는데, 이 때 사용할 flag를 클라이언트에서 직접 구현할 수도 있겠지만 서버에서 flag를 내려주는 게 보다 좋은 선택이다. 왜냐하면 출시 일정은 상황에 따라 변할 수 있는데, 클라이언트에 flag를 넣어서 출시하면 출시 일정을 변경하는 게 불가능하기 때문이다. 63 | 64 | ## 고려사항 65 | 66 | 출시 계획과 관련하여 몇 가지 도움이 될 만한 고려사항이 있다. 67 | 68 | ### 출시 계획 협상하기 69 | 70 | 출시 계획이 개발을 어렵게 만드는 경우, 다른 팀과의 협상을 시도해볼 수 있다. 예를 들어 현재 기획 상 feature flag를 구현해야 하는데, 이게 너무 어려운 작업이라면 서버 출시 직후에 적용하면 안 되겠냐고 물어볼 수 있다. 출시 계획도 기획의 일부임을 명심하자. 71 | 72 | ### 기능을 쪼개서 출시 전략을 고려하기 73 | 74 | 한 프로젝트 내에서도 각 기능마다 출시 전략이 다를 수 있다. 어떤 건 클라이언트가 출시되자마자 적용되어도 괜찮고, 어떤 건 반드시 시간 분기를 타야 할 수도 있다. 예를 들어 새로운 상품을 판매한다고 해보자. 구매 페이지는 미리 노출하되, 특정 시점부터 구매가 가능하게 막고 판매 시작 전에는 구매 버튼을 누르면 커밍순 팝업을 보여주려고 한다. 이 때 출시 계획은 다음과 같이 되어야 할 것이다. 75 | 76 | - 구매 페이지로 접근하는 버튼 - 클라이언트가 배포되자마자 출시되어야 함 77 | - 구매 페이지 - 웹서버가 먼저 배포되어 있어야 함 78 | - 구매 API - 시간 분기를 태워야 함 79 | 80 | ### 팀의 배포 일정을 고려하기 81 | 82 | PR을 메인스트림에 통합할 때는 개발하는 기능의 출시 전략과 팀의 출시 일정을 잘 고려해야 한다. 클라이언트가 배포되자마자 출시되는 기능이나 feature flag를 구현한 경우에는 보통 아무때나 메인스트림에 PR을 통합해도 괜찮다. 하지만 서버가 배포되자마자 기능이 풀리는 경우, 예정된 서버 배포일이 언제인지 유의깊게 살펴서 메인 스트림에 머지해야 한다. 잘못 통합한 경우 출시를 위해 롤백해야 하는 불편함이 생길 수 있다. 83 | 84 | ### 하위호환성 지키기 85 | 86 | 새로운 기능을 출시할 때는 반드시 하위호환성이 지켜져야 한다. 이에 대한 자세한 내용은 [상위호환성과 하위호환성](https://github.com/Zeniuus/basics-of-server-development/blob/main/deployment/forward-and-backward-compatibility.md#api%EC%9D%98-%ED%98%B8%ED%99%98%EC%84%B1) 문서를 참고하자. 87 | -------------------------------------------------------------------------------- /project/roles-of-server-developer.md: -------------------------------------------------------------------------------- 1 | # 서버 개발자의 역할은 무엇인가? 2 | 3 | 서버 개발자로서 빠르게 성장하기 위해서는 서버 개발자가 주로 수행하는 역할에 대해 알아야 한다. 이러한 역할 중 자신이 수행할 수 있는 것과 없는 것을 면밀히 검토하고, 부족한 점을 보완하고 잘하는 점을 더욱 날카롭게 만드는 노력이 필요하다. 4 | 5 | 서버 개발자의 역할은 크게 두 가지 관점으로 정의할 수 있을 것 같다. 첫 번째는 기술적인 측면이고, 두 번째는 프로젝트의 측면이다. 6 | 7 | ## 기술적인 측면 8 | 9 | 내가 1~2년차를 보냈던 VCNC에서는 서버팀이 담당하는 작업의 범위가 매우 넓어서 "이걸 다 서버팀이 하는 게 맞아?"라는 생각이 들었었다. 그런데 몇몇 회사 이야기를 들어보니 다른 회사에서도 상당히 넓은 범위의 업무를 담당하고 있었다. 내가 VCNC에서 담당했던 기술적인 업무에는 주로 다음과 같은 것들이 있다. 10 | 11 | * 프로덕트의 API 개발 - 프로덕트가 있는 회사라면 서버팀의 가장 기본적인 업무이다. 서버팀은 프로덕트 요구사항에 맞는 API를 설계하고 개발한다. 보통 엔티티 모델링과 서비스 설계를 필요로 한다. 12 | * 데이터 팀과의 협업 - 요즘은 회사의 핵심적인 비즈니스 가치가 데이터 기반의 알고리즘에서 나오는 경우가 많다. 하지만 알고리즘이 실제적인 비즈니스 가치를 만들어내기 위해서는 결국 서버와 통합되어야 한다. 이를 위해 서버팀은 데이터 팀이 만든 외부 시스템(e.g. ML 모델 서버)을 서버와 통합하거나, 데이터 팀이 정의한 알고리즘을 서버에 직접 구현한다. 13 | * 데이터 로깅 - 비즈니스에 대한 다양한 인사이트를 얻거나, 진행한 프로젝트가 얼마나 효과를 봤는지를 확인하기 위해서는 데이터가 필수적이다. 서버팀은 다른 팀이 활용할 수 있는 각종 데이터를 적절히 로깅한다. 14 | * 외부 서비스 연동 - 프로덕트가 필요로 하는 몇몇 기능은 직접 구현하기 보다 요금을 지불하고 외부 서비스를 사용하는 게 더 나을 수 있다. 카드 결제, 문자, 이메일 서비스 등 도메인에 구애받지 않는 일반적인 기능이 보통 그렇다. 서버팀은 필요한 경우 외부 서비스의 API 스펙 문서를 보고 외부 서비스와의 연동을 진행한다. 15 | * CI/CD 파이프라인 구성 - 요즘의 서버 개발에서는 지속적 통합(Continuous Integration, CI), 지속적 전달(Continuous Delivery, CD)이 거의 필수가 되었다. CI/CD가 중요한 이유는 새로운 비즈니스 가치를 빠르고 안전하게 사용자에게 전달하는 데에 큰 도움을 주기 때문이다. 서버팀은 비즈니스 가치의 빠른 전달을 위해 CI/CD 파이프라인을 구축하고 관리한다. 16 | * 서버 운영(DevOps) - 서버에는 다양한 이유로 장애가 발생할 수 있는데, 장애로 인해 서비스가 중단되는 일을 최대한 방지해야 한다. 만일 서비스에 장애가 발생했다면 장애가 다른 곳으로 퍼지지 않게 막아야 하며, 장애로부터 빠르게 복구할 수 있어야 한다. 서버팀은 장애 방지 / 장애 전파 방지 / 빠른 장애 복구를 위한 아키텍처를 설계하고 로깅 / 모니터링 시스템, auto-scaling 시스템 등을 구축한다. 17 | * 사내 어드민 개발 - 사내의 다양한 팀은 저마다의 니즈로 서버에 저장되어 있는 데이터를 관리하고 히스토리를 확인해야 한다. 서버팀은 다른 팀 사람들이 이용할 수 있는 사내 어드민을 개발한다. 18 | 19 | 회사가 점점 성장하면 하나의 역할에 집중한 다른 팀이 생길 수도 있다. 대표적으로 DevOps 팀, 데이터 엔지니어링 팀, 백오피스 개발 팀 등이 있다. 20 | 21 | ### 프로젝트의 측면 22 | 하나의 프로젝트에는 다양한 팀과 조직이 관여한다. 보통 PM팀, UI/UX팀, 서버팀, 클라이언트 팀, 데이터팀, QA팀, BX팀, 마케팅 팀 정도가 참여하고, 사업이나 운영과 긴밀하게 엮여 있는 경우에는 사업팀 / 운영팀에서도 참여할 수 있다. 프로젝트 내에서 각 팀은 고유한 역할과 책임을 맡고 있으며, 서버팀에게도 서버팀 고유의 역할과 책임이 있다. 프로젝트의 일반적인 라이프 사이클을 따라가며 서버팀의 역할을 짚어보면 다음과 같다. 23 | 24 | - 기획 검토 및 조언 - 서버팀은 기획 단계에서 기획서를 검토하여 개발적으로 문제가 되는 부분이나 빠진 부분을 짚어준다. 더 나아가서 능동적으로 스펙을 잘라 기획을 간단하게 만들거나 기획의 목적을 달성하기 위한 다른 기술적인 방법을 제시할 수도 있다. 25 | - 일정 산출 - 서버팀은 기획서를 검토하고 서버 개발이 어느 정도 걸릴지를 산출하여 제시한다. 26 | - 기능 개발 및 테스트 작성 - 기획서의 요구사항을 분석하여 소프트웨어를 개발하고, 개발한 소프트웨어가 올바르게 동작하는 것을 확인하기 위한 테스트를 작성한다. 또한, 해당 프로젝트가 얼마나 성공했는지를 확인하기 위한 데이터를 로깅해야 한다. 27 | - QA 전략 수립 - 일부 프로젝트의 경우 QA팀이 원하는 테스트 상황을 만들기 어려워서 QA를 진행하기 까다로울 수 있다. 이러한 경우 서버팀에서 최소한의 추가 개발을 통해 QA를 쉽게 할 수 있는 방법을 제시하는 게 좋을 수 있다. 28 | - QA 대응 - 서버팀은 QA 단계에서 발견된 이슈를 해결한다. 29 | - 배포 전략 수립 및 배포 - 서버팀은 배포를 할 때 발생할 수 있는 다양한 문제를 고려하여 배포 전략을 수립하고, 이를 이행한다. 대부분의 경우 기능 개발 단계에서부터 배포 전략을 고려해야 한다. 30 | -------------------------------------------------------------------------------- /project/working-with-proposals.md: -------------------------------------------------------------------------------- 1 | # 기획서와 친해지기 2 | 3 | ## 기획서에 맞게 동작하는 소프트웨어를 만들어야 한다 4 | 5 | [코드 작성 원칙](https://github.com/Zeniuus/basics-of-server-development/blob/main/code-and-architecture/principles-of-writing-code.md) 문서에서 필자는 개발자의 두 가지 역할을 언급한 적이 있다. 6 | 7 | - 요구사항을 만족하는 올바른 소프트웨어를 작성한다. 8 | - 비즈니스 가치를 빠르게 전달할 수 있는 소프트웨어를 작성한다. 9 | 10 | 개발을 잘하기 위한 많은 방법론은 이 두 가지 역할 중 두 번째에 집중되어 있다. 왜냐하면 비즈니스 가치를 빠르게 수용하도록 소프트웨어를 유지보수하는 것이 훨씬 더 어렵고, 요구사항을 만족하도록 소프트웨어를 작성하는 것은 너무나도 당연해 보이기 때문이다. 11 | 12 | 하지만 너무 당연하기 때문일까, 의외로 신입 개발자가 회사에 들어와서 프로덕션 레벨의 소프트웨어를 처음 작성하면 요구사항을 '정확히' 만족하는 경우가 잘 없다. 기획서에 적혀 있는 여러 플로우 중 가장 중요해 보이는 플로우만 고려하고 코드를 작성해서, 세세한 부분에 대해 잘못 동작하는 경우가 빈번히 발생한다. 13 | 14 | 기획서는 마치 코드 상에서 선언하는 인터페이스와 같다. 기획서는 소프트웨어가 이런 방식으로 동작할 것이라는 다른 팀과의 약속이다. 사소한 부분이라고 기획과 다르게 구현하면 약속을 어기는 것이고, 문제가 발생할 수 있다. 예를 들어, 개발자는 사소한 부분이라고 생각해서 조금 다르게 구현했지만, 개발자가 파악하지 못한 매우 중요한 기획 의도가 깔려 있었던 부분이라(e.g. 법무적인 이유 등) 회사가 큰 리스크에 노출될 수 있다. QA팀에서는 기획서를 보고 QA를 진행할 텐데, 개발자가 마음대로 대충 구현한 부분이 버그인지 아닌지 알 수 없으므로 불필요한 커뮤니케이션 비용이 발생할 수도 있다. 15 | 16 | 물론 기획 의도를 파악하고 더 좋은 방향으로 기획을 바꾸는 능력도 매우 중요하다. 하지만 이는 구현할 때 기획을 마음대로 바꾸는 게 아니라, 다른 팀과의 활발하고 공개적인 커뮤니케이션을 통해 다 함께 바꿔가는 것이다. 또한, 개발자가 가장 기본으로 지켜야 하는 '올바른 소프트웨어의 개발'조차 수행하지 못한다면 더 좋은 기획을 제시하는 것은 아무런 의미를 가지지 못한다. 17 | 18 | 따라서, 기획서를 완벽하게 만족하는 소프트웨어를 개발해야 한다. 특히 경험이 부족할 수록 기획서가 개발자의 전부라고 생각해야 한다. 19 | 20 | ## 기획서를 읽고 분석하는 연습을 하라 21 | 22 | 기획서에 맞게 동작하는 소프트웨어를 개발하는 건 생각보다 쉽지 않은 일이다. 며칠 안에 개발할 수 있는 스펙이라면 기획서가 단순하겠지만, 수 주 혹은 수 개월 단위의 프로젝트가 되면 기획서의 분량이 수십장으로 늘어나고 내용도 상당히 복잡해진다. 기획서를 읽고 분석하는 연습을 미리미리 해놓지 않으면 이런 크고 중요한 프로젝트에 참여할 때 어려움을 겪게 될 것이다. 23 | 24 | 그래서 기획서를 읽고 분석하는 방법을 미리미리 연습해두는 게 중요하다. 개발 지식과 실력이 자연스럽게 늘어나는 게 아니듯, 기획서를 분석하는 능력 역시 연습이 필요하다. 입사 초기에는 작은 작업을 주로 배정받게 되는데, 이런 작은 작업부터 기획서 분석을 차근차근 연습해두고 습관으로 만들면 나중에 큰 도움이 된다. 켄트 백이 말했다. "난 뛰어난 프로그래머가 아니에요. 단지 뛰어난 습관을 지닌 괜찮은 프로그래머일 뿐이에요." 25 | 26 | ### 1. 기획서에서 요구사항 뽑아내기 27 | 28 | 기획서는 개발자의 언어가 아니라 기획자(혹은 PM/PO)의 언어로 작성되어 있다. 즉, 기획서에는 '서버팀이 개발해야 하는 것은 이러이러합니다'가 적혀 있는 게 아니라 주로 프로덕트의 화면과 화면에 대한 설명이 적혀 있다. 따라서 서버팀은 이 기획서를 보고 서버팀이 해야 하는 일이 무엇인지를 뽑아낼 줄 알아야 한다. 뽑아낸 서버팀 업무는 TODO list로 만들어, 바로 뒤에 나올 설계 단계에서 쉽게 볼 수 있게 정리한다. 29 | 30 | 일반적으로 서버팀 업무에는 다음과 같은 것들이 포함된다. 31 | 32 | - 새로운 API 작성 33 | - 이미 작성되어 있는 API의 동작 방식을 수정 34 | - 비즈니스 로직의 작성 혹은 수정 35 | - 네이티브 앱에 노출되는 문구 수정 36 | - 어드민 수정 37 | 38 | ### 2. 요구사항을 만족하기 위해 어떻게 구현할지 설계하기 39 | 40 | 요구사항을 전부 뽑아냈다면, 이 요구사항을 만족시킬 방법에 대해 고민해야 한다. 주로 다음과 같은 사항을 고민하게 된다. 41 | 42 | - 요구사항을 만족하기 위해 DB에 적재할 새로운 엔티티 / 엔티티 필드를 추가할 필요가 있는가? 43 | - 현재 코드 베이스에서 어떤 부분을 어떻게 고쳐야 요구사항을 만족할 수 있는가? 44 | - 어떤 테스트 케이스를 작성할 것인가? 45 | 46 | 고민해야 하는 사항을 읽어보면 알겠지만, 설계 단계에서 가장 중요한 것은 회사의 코드 베이스 구조를 잘 이해하는 것이다. 코드 베이스가 어떻게 구성되어 있고, 어떤 기능이 어디에 어떻게 구현되어 있는지를 이해하고 있는 정도가 깊어지면 깊어질수록 설계가 수월해진다. 47 | 48 | 이 외에도 QA 전략이나 배포 전략 등 고려해야 할 부분이 많지만, 초기에는 코드를 작성하는 법을 고민하는 데에 집중하고 코드 외의 부분에 대해서는 다른 동료들의 도움에 의지하는 게 좋다고 생각한다. 49 | 50 | ### 3. 일정 산출하기 51 | 52 | 설계가 완료됐으면 일정을 산출할 준비가 된 것이다. 각 TODO list 항목 별로 구현에 어느 정도가 걸릴지를 생각한다. 이 때 단위는 working hour / working day 단위로 계산하는 것이 좋다. 예를 들어 2시간 정도가 걸릴 것 같으면 2h, 4시간 정도가 걸릴 것 같으면 0.5d(1 working day는 보통 8 working hour), 3일 정도가 걸릴 것 같으면 3d로 잡는 것이다. 53 | 54 | 일정 산출은 처음에는 굉장히 부정확할 수 있다. 본인의 코드 작성 속도에 대한 이해가 떨어지고, 설계에 미스가 있을 가능성이 높으며, 코드 리뷰를 하는 회사라면 리뷰에 생각보다 오랜 시간이 걸릴 수 있기 때문이다. 따라서 본인이 생각한 것보다 좀 더 넉넉한 일정을 부르는 게 좋다. 경험상 생각한 일정에서 곱하기 2~3 정도를 하면 적당했던 것 같다. 55 | 56 | ### 4. 1~3을 짧은 시간 내에 수행하기 & 기획의 문제점을 제시하기 57 | 58 | 입사 직후에는 1\~3이 매우 느린 게 정상이다. 애초에 코드 베이스를 하나도 모르니 설계 단계에서 관련 코드를 읽는데만 며칠씩 걸리기도 한다. 하지만 코드 베이스에 대한 이해가 점점 쌓이고, 기획자의 언어에 익숙해지고, 위의 1~3을 반복 연습하면서 이에 숙달됨에 따라 1\~3을 수행하는 데 걸리는 시간이 점점 짧아질 것이다. 59 | 60 | 1\~3을 얼마만큼 빠르게 해야 하는가? 2\~3주짜리 프로젝트의 기획서를 공유하는 자리에서 바로 1~3을 생각해낼 수 있을 정도로 빨라지면 충분히 숙달된 것이다. 이 정도로 빠르면 커뮤니케이션 비용을 크게 줄일 수 있기 때문이다. 기획서에서 요구사항을 뽑아내고, 설계를 진행하고, 일정을 산출하는 과정에서 여러가지 문제가 발생할 수 있다. 61 | 62 | - 요구사항을 구현하기 너무 오래 걸리거나 요구사항의 구현 난이도가 너무 높다. 63 | - 기획에서 누락된 중요한 고려사항을 발견했다. 64 | 65 | 이런 문제가 발생하면 다른 팀들과 문제를 공유하고, 추가 논의를 진행해야 한다. 하지만 이런 문제 제기를 첫 기획 공유 회의 때 바로 할 수 있다면, 해당 회의에서 추가 논의를 진행할 수 있으므로 모든 팀의 시간을 절약할 수 있게 된다. 66 | 67 | ### 5. 기획에 참여하기 68 | 69 | 4번까지만 해도 제 역할을 충분히 잘 수행하는 개발자라고 할 수 있다. 하지만 정말 뛰어난 개발자는 4번에서 언급한 다양한 문제를 해결할 수 있는 기술적인 제안을 하고, 기획/구현 난이도/일정을 두고 기획자와 적절한 협상을 할 수 있어야 한다. 70 | 71 | 앞서 말했듯, 기획서는 기획자의 언어로 작성된다. 그들은 기획 의도와 목적을 달성하기 위해 그들이 생각한 최선의 방법을 제시하지만, 그들의 최선은 개발자가 생각하는 최선과 종종 다르다. 기획자는 개발의 프로페셔널이 아니기에 기술적으로 무엇이 가능하고 불가능한지, 무엇이 구현하기에 쉽고 어려운지에 대한 감각이 부족하기 때문이다. 기획자가 구현이 너무 어려울 것 같아서 포기한 방법이 실제로는 구현이 쉬울 수도 있고, 쉬울 거라고 생각해서 선택한 방법이 실제로는 기술적으로 불가능할 수도 있다. 72 | 73 | 이러한 배경을 인지하고 생각하면, 개발자가 적극적으로 기획에 참여하는 건 필수적인 일인 것 같다. 하지만 이는 쉽지 않다. 기획자가 개발을 모르는 만큼이나 개발자도 기획을 모르기 때문이다. 구현도 쉽고 기획 의도도 만족하고 사용성도 좋은 대안을 제시하는 건 어려운 일이다. 이를 제대로 수행하려면 기획 의도도 정확히 꿰뚫어봐야 하고, 기획자와 디자이너와 개발자가 각자 양보할 수 있는 선이 어디까지인지도 정확히 피악해야 한다. 74 | 75 | 이것이 가능하려면 많은 경험과 더 높은 차원의 사고 방식이 필요하다. 많은 프로젝트를 경험하고, 고민하고, 해결하는 과정을 거치면서 문제해결능력을 키우고, 문제를 재정의하는 방법을 배우고, 다른 팀의 사고 방식에 익숙해져야 한다. 76 | -------------------------------------------------------------------------------- /reactive-programming/how-project-reactor-works.md: -------------------------------------------------------------------------------- 1 | # Project Reactor 동작 원리 & 쓰레드 관리 2 | 3 | ## 동작 원리 4 | 5 | ### 구성 요소 6 | 7 | - `Publisher`: 구독의 대상이 되는 인터페이스. 이벤트를 방출하는 책임을 가지고 있다. 8 | - `subscribe(Subscriber)` - `Subscriber`가 `Publisher`를 구독하는 메소드. 9 | - `Subscriber`: `Publisher`을 구독하는 인터페이스. `Publisher`가 방출한 이벤트를 수신하여 원하는 작업을 진행하는 책임을 가지고 있다. 10 | - `onSubscribe()` - `Subscriber`가 `Publisher`을 구독 완료했을 때 호출되는 이벤트 callback 메소드. 아래의 상호작용 부분에서 더 자세히 다룬다. 11 | - `onNext()`, `onError()`, `onComplete()` - `Publisher`가 `Subscriber`에게 이벤트를 전파할 때 부르는 callback 메소드. 이벤트가 정상적으로 전파되면 `onNext()`, 에러가 발생하면 `onError()`, 이벤트를 모두 전파하면 `onComplete()`가 호출된다. 12 | - `Subscription`: `Publisher`와 `Subscriber` 의 구독 관계를 표현하는 객체. 13 | - `request()`, `cancel()` - 아래의 상호작용 부분에서 더 자세히 다룬다. 14 | 15 | ### 상호작용 16 | 17 | 위 세 가지 컴포넌트의 상호작용은 크게 구독 단계와 이벤트 전파 단계라는 두 가지 단계로 이루어진다. (구독 단계 / 이벤트 전파 단계는 필자가 임의로 명명한 것으로, 일반적으로 사용되는 이름이 아닐 수 있다) 18 | 19 | 1. 구독 단계 20 | 1. `Subscriber`가 `Publisher`을 subscribe한다. (`Publisher.subscribe(Subscriber)`) 21 | 2. `Publisher`가 `Subscriber`와의 subscription을 만든다. 22 | 3. `Publisher`가 `Subscriber`에게 subscribe가 완료되었다고 이벤트를 전파한다. (`Subscriber.onSubscribe(Subscription)`) 23 | 2. 이벤트 전파 단계 24 | 1. `Subscriber`가 `Subscription`에서 데이터를 요청한다. (`Subscription.request()`) 25 | 2. `Subscription`은 값이 준비되면 `Subscriber`에게 데이터를 전파한다. (`Subscriber.onNext()`) 26 | 3. `Subscription`은 모든 데이터를 전파했을 경우 `Subscriber`에게 데이터 전파가 끝났다고 알린다. (`Subscriber.onComplete()`) 27 | 28 | `Mono.map`과 같이 `Mono`나 `Flux`가 제공하는 여러가지 유틸 함수를 사용하더라도 큰 흐름은 달라지지 않는다. 단지 `Publisher.subscribe()`, `Subscriber.onSubscribe()`, `Subscription.request()`, `Subscriber.onNext()`가 연쇄적으로 불릴 뿐이다. 29 | 30 | ![Spring Reactor 동작 원리](/reactive-programming/images/how-spring-reactor-works-1.png) 31 | 32 | 여기서 한 가지 강조하고 넘어가고 싶은 것이 있는데, 바로 `Mono.fromCallable()`이나 `Mono.map()`과 같은 operator의 로직이 실행되는 타이밍이다. 위의 상호작용을 보면 operator의 로직이 구독 단계가 아닌 이벤트 전파 단계에서 실행되는 것을 알 수 있다. 구독 단계에서는 단지 `Subscription`을 `Subscriber`에게 전달하는 일만 일어나고, 실제 operator의 로직은 이벤트 전파 단계, 좀 더 구체적으로는 위 그림의 4번째 단계인 `onNext()` chain에서 일어난다. 이 이야기는 아래 쓰레드 관리 항목에서 중요하게 다룰 예정이다. 33 | 34 | ### `Subscription` 인터페이스는 왜 필요한가? 35 | 36 | 필자가 Spring Reactor 내부 코드를 뜯어보기 시작했을 때 가장 이해가 안 됐던 부분 중 하나는 `Subscription` 인터페이스의 존재였다. `Publisher`와 `Subscriber`만 있으면 reactive programming이 가능할 것 같은데, 왜 굳이 `Subscription`이라는 인터페이스를 따로 만들었을까? 37 | 38 | 이는 한 `Publisher`를 여러 `Subscriber`가 구독할 수 있기 때문이다. 이것이 가능하려면 각 "구독"은 독립적인 상태와 컨텍스트를 가져야 한다. 1부터 10까지의 정수를 순서대로 방출하는 `Publisher`를 예시로 들어보자. 두 개의 `Subscriber`가 이 `Publisher`를 구독하면, 각 `Subscriber`는 서로 다른 페이스로 이벤트를 수신할 수 있다. 예를 들어 첫 번째 `Subscriber`는 3까지 받았고, 두 번째 `Subscriber`는 6까지 받았을 수 있다. 이 때 각 `Subscriber`가 다음 데이터를 `Publisher`에 요청하면 첫 번째 `Subscriber`는 4를 받고 두 번째 `Subscriber`는 7을 받아야 한다. 이것이 가능하려면 각각의 구독의 상태가 어디선가 관리되고 있어야 한다. 39 | 40 | 이 구독 상태의 관리를 `Publisher`가 직접 수행할 수도 있지만, 이러면 `Publisher`가 너무 많은 책임을 진다. 이는 `Publisher`와 `Subscriber`의 1대1 구독 관계를 표현하는 별도의 매핑 인터페이스를 만드는 것이 자연스럽다. 이것이 바로 `Subscription` 인터페이스이다. 41 | 42 | ### 왜 두 단계의 상호작용이 필요한가? 43 | 44 | 두 번째 의문은 왜 구독 단계와 이벤트 전파 단계를 분리했냐는 점이다. 결론부터 말하면 이는 backpressure를 구현하기 위함이다. 45 | 46 | backpressure란, 이벤트를 전파하는 행위의 주도권을 `Publisher`가 아니라 `Subscriber`에게 쥐어주는 메커니즘이다. 즉, `Publisher`가 마음대로 이벤트를 전파하는 게 아니라 `Subscriber`가 요청했을 때만 이벤트를 전파하게 만드는 것이다. 이러한 메커니즘이 필요한 이유는 클라이언트가 스스로의 CPU/메모리 사용 상황에 따라 수신하는 이벤트의 양을 조절하게 해주기 위함이다. `Publisher`가 엄청나게 많은 이벤트를 한꺼번에 내려줄 경우 `Subscriber` 쪽에서는 OOM이 발생하거나 과도하게 많은 CPU를 사용하여 다른 작업에 문제가 발생할 수도 있다. 이러한 문제를 방지하기 위해 `Subscriber`가 요청했을 때, `Subscriber`가 요청한 만큼의 이벤트만 `Publisher`가 전파하는 메커니즘이 필요하다. 47 | 48 | 상호작용이 한 단계로 이루어지면 backpressure를 구현할 수 없다. `Subscriber`가 `Publisher.subscribe()`를 호출하는 순간부터 `Subscriber`는 아무것도 할 수 없다. 이벤트 방출에 대한 모든 제어권은 `Publisher`가 가져가게 된다. 하지만 상호작용이 구독 단계와 이벤트 전파 단계로 나뉘게 되면 위에서 본 것처럼 `Subscriber`가 이벤트를 `request()` 할 수 있게 되므로, `Subscriber`가 이벤트 방출을 조절할 수 있게 된다. 49 | 50 | ## 쓰레드 관리 51 | 52 | ### 동작 원리 53 | 54 | Spring Reactor를 사용할 때 가장 중요한 것 중 하나는 쓰레드 관리이다. 왜냐하면 Spring Reactor가 주로 사용되는 상황이 네트워크 호출이나 무거운 연산 등 쓰레드 관리가 필요한 상황이기 때문이다. 55 | 56 | Spring Reactor에서는 쓰레드 관리를 위해 크게 `subcribeOn()`과 `publishOn()`이라는 두 가지 메소드를 제공하는데, 처음에는 각각을 어떤 상황에서 사용해야 하는지 알기가 쉽지 않다. 하지만 Spring Reactor의 동작 원리를 알면 이 두 가지의 사용처를 쉽게 판단할 수 있다. 57 | 58 | 두 가지 메소드의 동작 방식을 간단히 알아보자. 59 | 60 | - `subscribeOn()`-`subscribeOn()`은 동작 방식의 두 단계 중 구독 단계에서 쓰레드를 변경한다. 이를 그림으로 표현하면 아래와 같다. 61 | 62 | ![`subscribeOn()` 동작 원리](/reactive-programming/images/how-spring-reactor-works-2.png) 63 | 64 | - `publishOn()` - `publishOn()`은 동작 방식의 두 단계 중 이벤트 전파 단계에서 쓰레드를 변경한다. 이를 그림으로 표현하면 아래와 같다. 65 | 66 | ![`publishOn()` 동작 원리](/reactive-programming/images/how-spring-reactor-works-3.png) 67 | 68 | ### `subscribeOn()` vs. `publishOn()` 69 | 70 | 아까 위에서 `Mono.map()`과 같은 operator의 로직은 전부 `onNext()` chain에서 실행된다고 했다. 이러한 특성과 앞에서 살펴본 각 메소드의 동작 원리를 합치면, 우리는 `subscribeOn()`과 `publishOn()`을 각각 어떤 상황에서 사용해야 하는지에 대한 원칙을 명확히 세울 수 있다. 71 | 72 | 1. `subscribeOn()` 은 자신이 구독한 `Publisher`와 operator chain의 모든 동작을 다른 쓰레드에서 처리하고 싶을 때 호출한다. 73 | 2. `publishOn()`은 `publishOn()`을 호출한 이후의 동작을 다른 쓰레드에서 처리하고 싶을 때 호출한다. 74 | 3. `subscribeOn()`은 operator chain의 어느 단계에서 호출하든 같은 효과를 얻는다. 75 | 76 | 일반적인 예시를 통해 이 원칙에 대한 이해도를 높여보자. 77 | 78 | 1. 무거운 작업 79 | 80 | ```kotlin 81 | function someHeavyCalculation(): Int { 82 | // some heavy calculation 83 | } 84 | 85 | Mono.fromCallable { someHeavyCalculation() } 86 | // a - .subscribeOn(Schedulers.elastic())??? 87 | // b - .publishOn(Schedulers.elastic())??? 88 | .onNext { result -> 89 | notifyResult(result) 90 | } 91 | // c - .subscribeOn(Schedulers.elastic())??? 92 | // d - .publishOn(Schedulers.elastic())??? 93 | .subscribe() 94 | ``` 95 | 96 | a, b, c, d 중 어떤 것을 사용해야 할까? 97 | 98 | 어떤 것을 사용할지 판단하기 위해서는 우선 목적이 무엇인지 정해야 한다. 즉, 이 상황에서 어떻게 쓰레드를 관리해야 안전한지를 아는 것이 먼저이다. 99 | 100 | `someHeavyCalculation()`은 수 초 이상 걸리는 아주 무거운 연산 작업이다. 이러한 작업이 웹서버 쓰레드에서 실행되면 문제가 된다. 우리는 이 작업이 웹서버 쓰레드를 점유하지 않고 독자적인 쓰레드에서 진행되도록 하고 싶다. 101 | 102 | 이 상황과 목표에 우리가 세운 세 가지 원칙을 적용해보자. 103 | 104 | - 2번 원칙에 따르면 b는 `.onNext {}` 부터 `Schedulers.elastic()` 쓰레드에서 실행시킨다. 즉, `someHeavyCalculation()`은 웹서버 쓰레드에서 실행된다는 뜻이다. 이는 d도 마찬가지이다. 105 | - 1번 원칙에 따르면 a는 `Mono.fromCallable {}`의 동작을 `Schedulers.elastic()` 쓰레드에서 실행시킨다. 이는 정확히 우리가 원하던 동작이다. 106 | - 3번 원칙에 따르면 c는 a와 같은 효과를 보인다. 107 | 108 | 따라서 우리는 a와 c 중 하나를 선택해서 사용하면 된다. 둘 중 어느 것이 나은지는 취향의 영역이라고 생각한다. 109 | 110 | 2. 네트워크 통신 111 | 112 | 이번에는 조금 더 어려운 예시이다. 113 | 114 | ```kotlin 115 | function doSomeNetworkCommunication(): Mono { 116 | // some api call 117 | } 118 | 119 | doSomeNetworkCommunication() 120 | // a - .subscribeOn(Schedulers.elastic())??? 121 | // b - .publishOn(Schedulers.elastic())??? 122 | .onNext { result -> 123 | if (result) { 124 | saveSuccessToDB() 125 | } else { 126 | saveFailureToDB() 127 | } 128 | } 129 | // c - .subscribeOn(Schedulers.elastic())??? 130 | // d - .publishOn(Schedulers.elastic())??? 131 | .subscribe() 132 | ``` 133 | 134 | a, b, c, d 중 어떤 것을 사용해야 할까? 135 | 136 | 이번에도 쓰레드 관리의 목적을 먼저 파악해보자. 137 | 138 | 일반적으로 네트워크 통신을 처리하는 library는 독자적인 쓰레드 풀을 관리하고 있을 가능성이 높다. 즉, `doSomeNetworkCommunication()`의 리턴 값을 구독해서 네트워크 호출이 일어나는 순간 이 메소드를 호출한 쓰레드에서 네트워크 통신 library가 관리하는 쓰레드 풀의 쓰레드로 전환될 것이다. 만약 a, b, c, d 중 아무것도 하지 않으면 `.onNext {}` 안에서 DB를 다녀오는 무거운 작업이 네트워크 통신 library의 쓰레드를 점유하며 blocking하게 될 것이다. 이는 우리가 원하는 상황이 아니다. 139 | 140 | 이제 우리는 이 상황에서의 쓰레드 관리 목표를 분명하게 정의할 수 있다. 바로 DB를 다녀오는 무거운 작업이 네트워크 통신 library의 쓰레드를 점유하지 않게 하는 것이다. 141 | 142 | 이 상황과 목표에 우리가 세운 세 가지 원칙을 적용해보자. 143 | 144 | - 1번 원칙에 따르면 a는 네트워크 통신 library 동작을 `Schedulers.elastic()` 쓰레드 풀에서 실행시킨다. 이것이 목적을 달성하는 것이라고 생각하기 쉽지만, 여기에는 한 가지 함정이 있다. 바로 "네트워크 통신 library의 동작"이 무엇이냐에 대한 것이다. `onNext()` chain에서 네트워크 통신 library는 "자신의 쓰레드로 전환하여 네트워크 통신을 진행한다"라는 동작을 수행한다. 즉, a를 사용하면 쓰레드의 흐름이 다음과 같이 된다 : 145 | 1. `Schedulers.elastic()` 쓰레드로 변경하여 네트워크 통신 library의 동작을 실행시킨다. 146 | 2. 네트워크 통신 library가 자신이 관리하는 쓰레드로 변경하여 실제 네트워크 통신을 날린다. 147 | 3. 통신이 완료되면 `.onNext {}`의 동작이 실행된다. 148 | 149 | 따라서 `.onNext {}`는 네트워크 통신 library의 쓰레드에서 실행되게 된다. 150 | 151 | - 3번 원칙에 따르면 c는 a와 같은 효과를 보인다. 152 | - 2번 원칙에 따르면 d는 d 이후에 오는 operator를 `Schedulers.elastic()` 쓰레드 풀에서 실행시킨다. 그 말은 `.onNext {}` 까지는 네트워크 통신 library에서 실행된다는 뜻이다. 153 | - 2번 원칙에 따르면 b는 `.onNext {}`부터 `Schedulers.elastic()` 쓰레드 풀에서 실행시킨다. 154 | 155 | 따라서 이 상황에서는 b를 사용하면 된다. 156 | -------------------------------------------------------------------------------- /reactive-programming/images/how-spring-reactor-works-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zeniuus/basics-of-server-development/91172e68db81e39f50e80dc7dc37141239ac9ec4/reactive-programming/images/how-spring-reactor-works-1.png -------------------------------------------------------------------------------- /reactive-programming/images/how-spring-reactor-works-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zeniuus/basics-of-server-development/91172e68db81e39f50e80dc7dc37141239ac9ec4/reactive-programming/images/how-spring-reactor-works-2.png -------------------------------------------------------------------------------- /reactive-programming/images/how-spring-reactor-works-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zeniuus/basics-of-server-development/91172e68db81e39f50e80dc7dc37141239ac9ec4/reactive-programming/images/how-spring-reactor-works-3.png -------------------------------------------------------------------------------- /situations-and-patterns/batch-job.md: -------------------------------------------------------------------------------- 1 | # Batch job 작성 시 유의사항 2 | 3 | Batch job은 비즈니스 로직을 작성할 때 빈번하게 사용되는 패턴이다. Batch job은 수많은 작업을 한 번에 처리하는데, 이 때문에 batch job을 작성할 때는 단순히 API를 작성할 때보다 더 세심한 주의를 기울여야 한다. 어떤 점을 주의해야 하는지 알아보기 위해, 매년 개인정보 이용내역 안내 메일을 발송하는 간단한 batch job 예시를 가져왔다. (Spring + Kotlin 스택에서 작성되었다) 4 | 5 | ```kotlin 6 | @Transactional(isolation = Isolation.SERIALIZABLLE) 7 | fun process() { 8 | val users = userRepository.findPrivacyUsageMailSendingTargets() 9 | users.forEach { user -> 10 | sendPrivacyUsageMail(user) 11 | } 12 | } 13 | 14 | private fun sendPrivacyUsageMail(user: User) { 15 | emailService.sendPrivacyUsageMail(user) 16 | user.privacyUsageMailSentAt = clock.instant() 17 | userRepository.save(user) 18 | } 19 | ``` 20 | 21 | 겉보기엔 멀쩡해 보이지만, 위 batch job 코드에는 많은 문제가 숨어 있다. 지금부터 문제를 하나씩 개선해보자. 22 | 23 | ## 하나의 실패가 전체 실패로 이어지지 않도록 하기 24 | 25 | 첫 번째 문제로, 한 유저에게 메일을 발송하다가 exception이 던져지면 그 이후의 모든 유저에게 메일 발송이 실패한다. 예를 들어 첫 번째 유저에게 메일을 전송하다가 네트워크 오류 때문에 exception이 던져졌다고 해보자. 그러면 해당 exception은 `process()` 함수 전체를 중단시킬 것이다. 26 | 27 | 이를 방지하는 방법은 아주 간단하다. `users`를 순회하면서 적용하는 로직에 try-catch를 걸면 된다. 28 | 29 | ```kotlin 30 | @Transactional(isolation = Isolation.SERIALIZABLLE) 31 | fun process() { 32 | val users = userRepository.findPrivacyUsageMailSendingTargets() 33 | users.forEach { user -> 34 | try { 35 | sendPrivacyUsageMail(user) 36 | } catch (t: Throwable) { 37 | logger.error(t.message, t) 38 | } 39 | } 40 | } 41 | ``` 42 | 43 | ## 트랜잭션 경계 잘 설정하기 44 | 45 | 위 batch job의 로직을 잘 보면 메일을 발송한 이후 메일 발송 시각을 DB에 적는다. 이 때문에 user에 X lock이 잡히게 된다. 하지만 지금 트랜잭션 경계를 보면 `process()` 함수 전체에 잡혀 있다. 이런 긴 batch job이 X lock을 잡으면 batch job이 끝날 때까지 user를 변경하는 모든 쿼리가 막히기 때문에 치명적인 장애로 이어질 수 있다. 46 | 47 | 이를 방지하려면 트랜잭션의 범위를 잘 분리하면 된다. 바로 아래와 같이 말이다. 48 | 49 | ```kotlin 50 | fun process() { 51 | val users = transactionTemplate.execute { 52 | userRepository.findPrivacyUsageMailSendingTargets() 53 | }!! 54 | users.forEach { user -> 55 | try { 56 | serializableTransactionTemplate.execute { 57 | sendPrivacyUsageMail(user) 58 | } 59 | } catch (t: Throwable) { 60 | logger.error(t.message, t) 61 | } 62 | } 63 | } 64 | ``` 65 | 66 | 여기서 주의할 점은, serializable 트랜잭션을 try-catch 안에 잡는 것이다. serializable 트랜잭션은 데드락으로 인해 exception이 던져질 가능성이 높기 때문이다. 만약 serializable 트랜잭션을 try-catch 바깥에 잡으면 이런 exception을 잡지 못할 것이고, 앞서 보았던 **하나의 실패가 전체 실패로 이어지지 않도록 하기**에서 보았던 문제가 재발할 것이다. 67 | 68 | ## 동시 수정에 안전하게 만들기 (혹은 멱등적으로 만들기) 69 | 70 | 위의 개선으로 인해 발생한 새로운 문제가 있다. 예를 들어, batch job이 아닌 다른 곳에서 `user.privacyUsageMailSentAt`을 갱신하는 로직이 있다고 해보자. 만약 해당 로직이 `users`를 가져오는 트랜잭션과 실제로 메일을 발송하는 트랜잭션 사이에 실행된다면, 메일이 중복 발송되는 일이 발생할 수도 있다. 71 | 72 | 이런 문제가 발생하는 근본적인 이유는 `user`를 조회해오는 시점과 메일을 발송하는 로직이 하나의 트랜잭션으로 묶이지 않았기 때문이다. 원래대로라면 하나의 serializable 트랜잭션에서 실행되어야 할 두 개의 작업이 batch job의 특성 때문에 서로 다른 트랜잭션에서 실행돼서 도중에 `user`의 상태가 변할 수 있게 된 것이다. 73 | 74 | 이를 방지하려면 유저에게 메일을 발송하는 트랜잭션에서 최신 상태의 유저를 다시 조회해와야 한다. 즉, 두 개의 작업을 하나의 serializable 트랜잭션에서 실행하면 된다. 75 | 76 | ```kotlin 77 | fun process() { 78 | val users = transactionTemplate.execute { 79 | userRepository.findPrivacyUsageMailSendingTargets() 80 | }!! 81 | users.forEach { oldUser -> 82 | try { 83 | serializableTransactionTemplate.execute { 84 | val user = userRepository.getOne(oldUser.id)!! 85 | if (shouldSendPrivacyUsageMail(user)) { 86 | sendPrivacyUsageMail(user) 87 | } 88 | } 89 | } catch (t: Throwable) { 90 | logger.error(t.message, t) 91 | } 92 | } 93 | } 94 | 95 | private fun shouldSendPrivacyUsageMail(user: User): Boolean { 96 | return user.privacyUsageMailSentAt == null || 97 | user.privacyUsageMaillSentAt + Duration.days(364) < clock.instant() 98 | } 99 | ``` 100 | 101 | 이는 결국, 메일 발송 로직을 멱등적으로 만들어야 한다는 것과 같다. 멱등성이란, 같은 로직이 두 번 이상 트리거 된 경우에도 한 번만 처리되는 성질이다. 위에서 언급한 이유 말고도 다양한 원인으로 인해 동일한 `user` 상태에 대해 메일 발송 로직이 두 번 이상 트리거될 수 있다. 예를 들어, 같은 batch job이 두 번 이상 실행된다면? 이런 모든 상황에 대해 메일 발송을 안전하게 수행할 수 있는 유일한 방법은, 메일 발송 로직을 멱등적으로 만드는 것뿐이다. 102 | 103 | ## Timeout 방지하기 104 | 105 | 어플리케이션의 내부 스케쥴러가 아니라 외부 cronjob과 curl 커맨드에 의해 실행되는 batch job을 생각해보자. 해당 batch job은 curl 커맨드의 request timeout과 웹서버에 등록된 response timeout의 제한을 받는다. 예를 들어 batch job 실행에는 수 분이 걸리는데, request timeout이 30초로 설정되어 있다고 해보자. 이러면 batch job 실행 자체는 성공하지만 curl 커맨드는 timeout으로 요청이 실패했다고 판단할 것이다. 더 심각한 건 response timeout이 30초로 설정되어 있는 경우인데, 이러면 batch job 실행 자체가 30초 이후에 죽어버린다. 106 | 107 | 이런 문제를 해결하기 위해 batch job을 실행시키기 위한 thread pool을 별도로 둘 수 있다. 108 | 109 | ```kotlin 110 | private val executor = Executors.newSingleThreadExecutor() 111 | 112 | fun process() { 113 | executor.submit { 114 | val users = transactionTemplate.execute { 115 | userRepository.findPrivacyUsageMailSendingTargets() 116 | }!! 117 | users.forEach { oldUser -> 118 | try { 119 | serializableTransactionTemplate.execute { 120 | val user = userRepository.getOne(oldUser.id)!! 121 | if (shouldSendPrivacyUsageMail(user)) { 122 | sendPrivacyUsageMail(user) 123 | } 124 | } 125 | } catch (t: Throwable) { 126 | logger.error(t.message, t) 127 | } 128 | } 129 | } 130 | } 131 | ``` 132 | 133 | 이제 `process()` 함수는 `executor`에게 task를 던지고 바로 종료되므로, request timeout / response timeout으로부터 안전하다. 134 | 135 | ## 로드 분산시키기 136 | 137 | 해당 기능을 배포한 뒤 batch job이 처음 실행될 경우, 상당히 많은 유저가 메일 발송 타겟이 될 것이다. 이 경우 두 가지가 문제가 된다. 138 | 139 | - 메일 발송 API를 너무 높은 빈도로 호출하게 된다. 잘못하면 메일 발송 서버가 우리 서버의 IP를 차단할 수도 있다. 140 | - 너무 많은 유저를 메모리에 들고 와서 메모리가 터질 수도 있다. 예를 들어 대상인 유저가 100만명이고 각 유저 객체가 100Byte라고 하면 약 100MB 정도의 메모리를 차지하게 된다. 100MB 정도면 그렇게 크지 않다고 할 수 있지만, 이런 batch job이 여러개가 되면 문제가 될 수 있다. 141 | 142 | 우선 메일 발송 API 호출 빈도 문제부터 해결해보자. 이는 단순히 발송 로직에 rate limiter를 걸면 해결된다. 아래 코드는 메일 발송 API를 최대 초당 10회까지만 호출하도록 방지한다. 143 | 144 | ```kotlin 145 | private val rateLimiter = RateLimiter.create(10.0) 146 | 147 | fun process() { 148 | executor.submit { 149 | val users = transactionTemplate.execute { 150 | userRepository.findPrivacyUsageMailSendingTargets() 151 | }!! 152 | users.forEach { oldUser -> 153 | rateLimiter.acquire() 154 | try { 155 | serializableTransactionTemplate.execute { 156 | val user = userRepository.getOne(oldUser.id)!! 157 | if (shouldSendPrivacyUsageMail(user)) { 158 | sendPrivacyUsageMail(user) 159 | } 160 | } 161 | } catch (t: Throwable) { 162 | logger.error(t.message, t) 163 | } 164 | } 165 | } 166 | } 167 | ``` 168 | 169 | 여기서 주의할 점은 `rateLimiter.acquire()`을 트랜잭션 바깥에서 호출한다는 점이다. 만약 트랜잭션을 열고 `rateLimiter.acquire()`를 하면 의도치 않게 트랜잭션을 오래 잡아 문제가 발생할 수도 있다. 170 | 171 | 이제 두 번째 문제인 메모리 문제를 해결해보자. 이는 유저를 단계별로 가져와서 처리하는 방식으로 해결할 수 있다. 유저가 점점 많아져서 메모리 pressure가 커지면 GC가 이미 메일을 발송한 유저를 삭제할 것이다. 172 | 173 | ```kotlin 174 | fun process() { 175 | executor.submit { 176 | var page = transactionTemplate.execute { 177 | userRepository.findPrivacyUsageMailSendingTargets(limit = 1000, page = 1) 178 | }!! 179 | while (page.content.isNotEmpty()) { 180 | val users = page.content 181 | users.forEach { oldUser -> 182 | rateLimiter.acquire() 183 | try { 184 | serializableTransactionTemplate.execute { 185 | val user = userRepository.getOne(oldUser.id)!! 186 | if (shouldSendPrivacyUsageMail(user)) { 187 | sendPrivacyUsageMail(user) 188 | } 189 | } 190 | } catch (t: Throwable) { 191 | logger.error(t.message, t) 192 | } 193 | } 194 | 195 | page = transactionTemplate.execute { 196 | userRepository.findPrivacyUsageMailSendingTargets(limit = 1000, page = page.currentPage + 1) 197 | }!! 198 | } 199 | } 200 | } 201 | ``` 202 | 203 | ## 요약  : 최종 코드 204 | 205 | 더 이상 큰 문제가 없어 보인다. 이제 처음 작성한 batch job 코드와 개선된 최종 결과물을 비교해보자. 206 | 207 | ```kotlin 208 | @Transactional(isolation = Isolation.SERIALIZABLLE) 209 | fun process() { 210 | val users = userRepository.findPrivacyUsageMailSendingTargets() 211 | users.forEach { user -> 212 | sendPrivacyUsageMail(user) 213 | } 214 | } 215 | 216 | private fun sendPrivacyUsageMail(user: User) { 217 | emailService.sendPrivacyUsageMail(user) 218 | user.privacyUsageMailSentAt = clock.instant() 219 | userRepository.save(user) 220 | } 221 | ``` 222 | 223 | ```kotlin 224 | private val executor = Executors.newSingleThreadExecutor() 225 | private val rateLimiter = RateLimiter.create(10.0) 226 | 227 | fun process() { 228 | executor.submit { 229 | var page = transactionTemplate.execute { 230 | userRepository.findPrivacyUsageMailSendingTargets(limit = 1000, page = 1) 231 | }!! 232 | while (page.content.isNotEmpty()) { 233 | val users = page.content 234 | users.forEach { oldUser -> 235 | rateLimiter.acquire() 236 | try { 237 | serializableTransactionTemplate.execute { 238 | val user = userRepository.getOne(oldUser.id)!! 239 | if (shouldSendPrivacyUsageMail(user)) { 240 | sendPrivacyUsageMail(user) 241 | } 242 | } 243 | } catch (t: Throwable) { 244 | logger.error(t.message, t) 245 | } 246 | } 247 | 248 | page = transactionTemplate.execute { 249 | userRepository.findPrivacyUsageMailSendingTargets(limit = 1000, page = page.currentPage + 1) 250 | }!! 251 | } 252 | } 253 | } 254 | 255 | private fun shouldSendPrivacyUsageMail(user: User): Boolean { 256 | return user.privacyUsageMailSentAt == null || 257 | user.privacyUsageMaillSentAt + Duration.days(364) < clock.instant() 258 | } 259 | 260 | private fun sendPrivacyUsageMail(user: User) { 261 | emailService.sendPrivacyUsageMail(user) 262 | user.privacyUsageMailSentAt = clock.instant() 263 | userRepository.save(user) 264 | } 265 | ``` 266 | -------------------------------------------------------------------------------- /situations-and-patterns/native-client.md: -------------------------------------------------------------------------------- 1 | # 네이티브 클라이언트 개발 시 서버의 유의사항 2 | 3 | ## 앱 vs 웹 4 | 5 | 네이티브 클라이언트(이하 앱)과 웹의 차이는 크게 두 가지 측면으로 나눠서 볼 수 있다. 6 | 7 | 첫 번째는 사용성 측면이다. 대체로 앱의 사용성이 웹보다 훨씬 뛰어나다. 8 | 9 | - 웹은 리소스를 항상 다운받아야 한다. 반면 앱은 설치할 때 리소스를 대부분 다운받아 놓을 수 있다. 따라서 앱은 일단 설치하기만 하면 네트워크에 의한 딜레이가 많이 줄어든다. 10 | - 앱은 푸시를 보낼 수 있지만, 웹은 불가능하다. 11 | - 앱은 오프라인에서도 동작할 수 있지만, 웹은 페이지 로딩조차 안 된다. 12 | 13 | 하지만, 서비스에 대한 제어(controllability) 측면에서는 웹이 앱보다 매우 유리하다. 웹은 웹서버를 새로 배포하면 그 이후로 웹에 접근하는 사람들은 모두 새로운 버전을 본다(캐시 예외). 반면 앱은 강제 업데이트를 시키거나 유저가 업데이트 하지 않는 이상 구 버전의 앱이 그대로 남아 있다. 따라서 앱으로 서비스를 운영하는 경우, 새로운 기능을 개발하거나 사용성을 개선하더라도 유저가 직접 업데이트를 하지 않는 한 해당 패치를 적용받을 수 없다. 14 | 15 | 이러한 특성을 고려하여, 최근에는 기본적으로 앱으로 구현하되 자주 변경될 여지가 있는 부분은 [웹뷰](https://developer.android.com/reference/android/webkit/WebView)를 통해 웹으로 처리하는 경우가 많은 것 같다. 16 | 17 | 위 내용 중 서버에서 중요시 여겨야 할 부분은, 앱으로 구현하는 경우 앱 버전을 제어하기 어렵다는 점이다. 위에서 언급한 대로 앱의 업데이트는 앱을 개발하는 쪽이 아니라 사용하는 쪽이 자발적으로 해주어야 한다. 따라서 사용자가 앱을 오랫동안 업데이트하지 않는다면 구 버전의 앱이 상당히 오래 남아 있을 수 있다. 강제 업데이트를 하는 방법이 있지만, 이는 유저의 사용성을 크게 해치므로 웬만하면 피해야 한다. 따라서 서버는 구 버전의 앱이 오래 남아 있을 것을 대비해야 한다. 18 | 19 | 구체적으로, 앱으로 서비스를 개발할 때 서버에서 고려해야 할 것은 크게 두 가지이다. 20 | 21 | - 하위호환성 고려 22 | - 강제 업데이트 전략 23 | 24 | ## 하위호환성 고려 25 | 26 | API의 하위호환성이란, 구 버전의 앱이 서버의 API를 호출해도 정상 동작함을 보장하는 성질이다. 서버는 새로운 버전의 앱이 배포되더라도 구 버전의 앱이 남아 있을 수 있다는 것을 충분히 고려해서, 구 버전의 앱도 정상적으로 동작할 수 있도록 해야 한다. 27 | 28 | - 서버는 구 버전의 앱이 사용하는 API endpoint를 계속 노출해야 한다. 새로운 버전의 앱에서 더 이상 사용하지 않는 API도 바로 삭제하면 안 되고, 그 API를 쓰는 버전의 앱이 더 이상 없다는 것이 보장될 때만 삭제해야 한다. 보통 API를 deprecate 시켜놓고, 이후에 앱을 강제 업데이트하면 API를 삭제한다. 29 | - 서버는 구 버전의 앱이 올려주는 request body를 이해할 수 있어야 한다. 이는 크게 request body에 필드를 추가하는 케이스와 삭제하는 케이스로 구분해서 볼 수 있다. 필드의 시멘틱이나 타입을 변경하는 건 하위호환성을 지키는 게 까다로워지므로 최대한 지양해야 한다. 30 | - 필드 추가 - 새 버전의 앱을 위해 request body에 새로운 필드가 추가된 경우이다. 이 경우, 구 버전의 앱은 이 필드를 비운 채로 API 요청을 보내게 된다. 서버는 필드가 비워져서 온 요청을 핸들링할 수 있어야 한다. 31 | 32 | 이를 위해서는 request body의 deserializer가 적절히 구현되어 있어야 한다. 필드가 비워져 있을 경우 에러를 내는 게 아니라 default value(e.g. 문자열 타입이면 빈 문자열, 숫자 타입이면 0 등)나 null로 변환해줘야 한다. 33 | 34 | deserializer가 빈 필드를 default value로 변환하는 경우 한 가지 고려해야 할 점이 있다. 이 경우, 서버는 앱이 애초에 default value를 올려준 건지, 아니면 앱은 해당 필드를 비워서 올려줬는데 deserializer가 default value로 변환해준 건지 구분할 수 없다. 만약 이 두 가지를 서버가 구분할 필요가 있는 상황이라면 request body에 필드를 추가할 때 nullable한 필드로 추가하는 것이 좋다. 35 | 36 | - 필드 삭제 - request body에서 더 이상 필요하지 않은 필드를 삭제한 경우이다. 이 경우, 구 버전의 앱은 이 필드를 계속 채워준 상태로 API 요청을 보내게 된다. 서버는 프로토콜 상에는 없는 필드가 request body에 담겨 있는 상황을 핸들링할 수 있어야 한다. 37 | 38 | 이 문제는 간단하게 해결할 수 있다. deserializer가 모르는 필드를 무시하도록 설정해놓으면 된다. 39 | 40 | - 서버는 구 버전의 앱이 이해할 수 있도록 response를 내려줘야 한다. 이는 바로 위의 고려사항에서 앱과 서버의 입장이 뒤바뀐 것이라고 할 수 있다. 따라서 response body에서 필드가 추가/삭제된 경우는 앱쪽에서 제대로 처리해줘야 한다. 앱의 deserializer는 response body의 필드가 비워져 있는 경우와 모르는 필드가 존재하는 경우를 적절히 처리할 수 있어야 한다. 41 | 42 | ## 강제 업데이트 전략 43 | 44 | 앱의 강제 업데이트는 유저의 사용성을 크게 해치고 앱 사용자를 이탈시킬 가능성이 높기 때문에 최대한 피해야 한다. 하지만 골든 패스에 영향을 주는 큰 변경의 경우 반드시 모든 유저에게 적용되어야 할 필요가 있을 때도 있다. 이런 경우에는 앱을 강제로 업데이트해야 한다. 45 | 46 | 앱의 강제 업데이트는 앱을 처음 출시할 때부터 고려해놓아야 가능하다. 구체적으로, 다음과 같은 기반 작업이 미리 선행되어야 한다. 47 | 48 | 1. 앱은 서버에 자신의 버전을 알려줄 수 있어야 한다. e.g. 사전에 약속된 방식으로 HTTP Header에 앱 버전을 담아서 요청을 보낸다. 이는 서버가 앱의 버전을 보고 강제 업데이트가 필요한지 판단하기 위함이다. 49 | 2. 서버는 앱이 강제 업데이트가 필요한 경우 앱에 강제 업데이트가 필요하다는 것을 알려줄 수 있어야 한다. e.g. 강제 업데이트가 필요한 경우 사전에 약속된 에러를 발생시킨다. 50 | 3. 앱은 2번을 통해 강제 업데이트가 필요함을 인지하면 앱 사용을 막고 앱마켓으로 보내야 한다. 51 | 52 | 강제 업데이트를 위한 기반 작업을 설계할 때 몇 가지 주의해야 할 사항이 있다. 53 | 54 | - 앱에 강제 업데이트가 필요한 경우, 사용자의 앱 사용을 막아야 한다. 이를 위해서 서버는 강제 업데이트가 필요한 앱이 API를 호출한 경우 비즈니스 로직을 실행하는 대신 에러를 내야 한다. 55 | - 앱의 사용성을 고려하여 강제 업데이트 시점을 신중하게 선택해야 한다. 서비스 특성 상 앱이 강제 업데이트가 되면 안 되는 시점이 존재할 수 있는데, 이런 플로우는 피해서 2번과 3번 로직을 심어야 한다. 56 | 57 | ## Refs 58 | 59 | - [https://vwo.com/blog/10-reasons-mobile-apps-are-better/](https://vwo.com/blog/10-reasons-mobile-apps-are-better/) 60 | - [https://developer.android.com/reference/android/webkit/WebView](https://developer.android.com/reference/android/webkit/WebView) 61 | -------------------------------------------------------------------------------- /situations-and-patterns/server-application-patterns.md: -------------------------------------------------------------------------------- 1 | # 서버 개발의 일반적인 어플리케이션 패턴 2 | 3 | 서버 작업을 할 때 자주 사용하게 되는 어플리케이션 패턴을 모아둔 문서이다. 항목 별로 위계가 잘 맞진 않는데, 개수가 얼마 되지 않아 하나로 모아두었다. 나중에 "어플리케이션 패턴"보다 더 정확한 용어를 깨닫게 되면 수정하려고 한다. 4 | 5 | ## API 6 | 7 | 가장 많이 사용하는 패턴이다. 서버는 네트워크를 통해 호출할 수 있는 엔드포인트를 열고, 클라이언트는 해당 API를 호출하여 원하는 목표를 달성한다. 여기서 클라이언트는 유저가 사용하는 웹이나 앱일 수도 있고, 같은 팀/회사에서 운영하는 다른 내부 서버일 수도 있고, 회사 외부의 서버일 수도 있다. 8 | 9 | 크게 두 가지 목적으로 API를 활용하는 것 같다. 10 | 11 | - 서버가 특정 기능을 외부 클라이언트에 제공 - 일반적인 API의 목적은 이 범주에 속하게 된다. API를 호출하여 데이터를 조회/수정하거나, 특정한 연산을 할 수 있다. 12 | - 클라이언트가 서버에게 이벤트를 전달 - API를 호출하는 쪽이 우리 서버로 무언가 전달하고 싶은 경우이다. e.g. 훅, 싱크를 위한 데이터 전달 등. 클라이언트가 서버에서 하는 일에 별로 관심이 없다는 점에서 위와 다르다. 13 | 14 | ## Job 15 | 16 | 일정 주기로 동일한 작업을 반복하는 패턴. API와 마찬가지로 상당히 많은 시나리오에서 사용할 수 있다. 17 | 18 | - 주기적으로 통계 정보를 저장해야 하는 경우 19 | - 특정 시각에 뭔가를 처리해야 하는 경우 - 짧은 주기로 job을 돌리면서 처리할 수 있다. (e.g. 특정 시각에 status 변경, 특정 시각에 문자 발송 등) 20 | 21 | 구현 방법에는 크게 두 가지 방법이 있다. 22 | 23 | - API를 노출하고 외부 cron 으로 curl을 실행하여 해당 API를 주기적으로 호출하기 24 | - 언어나 서버 프레임워크 차원에서 cron과 동일한 기능을 제공하는 경우, 해당 API를 사용할 수 있다. e.g. Java Executors API 25 | 26 | job을 작성할 때의 주의사항은 [Batch job 작성 시 유의사항](/situations-and-patterns/batch-job.md)을 보도록 하자. 27 | 28 | ## Queue & Worker 29 | 30 | 서버에서 처리해야 하는 태스크를 큐에 던지고, 워커가 해당 태스크를 큐에서 뽑아서 처리하는 형식의 패턴이다. 31 | 32 | 큐를 이용하면 다음과 같은 장점을 얻을 수 있다. 33 | 34 | - 트래픽의 분산 - 큐를 사용하면 트래픽의 집중으로 인한 서버 부하를 어느정도 완화시킬 수 있다. 워커의 처리량을 상회하는 양의 트래픽이 들어올 경우, 큐가 넘치는 트래픽을 잠시 버퍼링해줄 수 있다. 트래픽의 피크 시간이 끝나고 워커의 처리량이 트래픽의 인입량을 넘어서게 되면 다시 큐의 사이즈가 줄어들게 될 것이다. 35 | - 편리한 scale out/in - 외부 트래픽을 수신하는 API 서버는 많은 API를 노출하고, 다양한 로직을 처리하고 있을 가능성이 높다. 따라서 특정 작업 때문에 API 서버를 스케일링하는 것은 느리고 비효율적일 수 있다. 큐와 워커를 사용하면 특정 작업만 처리하는 서버만을 정확히 타겟팅하여 스케일링할 수 있어 보다 빠르고 효율적인 스케일링이 가능하다. 36 | - 큐의 다양한 기능 사용 가능 - 일부 큐는 처리 시간을 딜레이 시켜주는 등의 편리한 기능을 제공한다. 큐와 워커를 사용하면 이런 기능을 십분 활용하여 더 다양한 요구사항을 간편하게 만족할 수 있게 된다. 37 | 38 | 그렇다고 큐가 항상 만능인 것은 아니다. 기본적으로 큐를 사용한다는 것은 작업을 비동기적으로 처리하겠다는 의미다. 어플리케이션의 특성에 따라 비동기적으로 처리하는 게 자연스러운 작업도 있고, 그렇지 않은 작업도 있을 것이다. 비동기적으로 처리되면 안 되는 작업에는 큐를 사용하는 게 적합하지 않다. 39 | 40 | ## Background Task 41 | 42 | 서버에서 처리해야 하는 작업을 다른 쓰레드에 던져 백그라운드에서 처리하는 패턴이다. 43 | 44 | 백그라운드 태스크와 큐&워커는 얼핏 보면 비동기 작업을 처리하기 위한 방법으로 유사해보일 수 있지만, 실제로 활용해보면 둘의 유사성은 그리 크지 않다. 45 | 46 | - 백그라운드 태스크는 작업의 "비동기성"에 초점을 맞춘다. API 요청을 받았을 때 응답을 빠르게 돌려주기 위해 일부 비동기적으로 처리해도 되는 로직을 다른 쓰레드로 던지는 것이다. 47 | - 반면 큐와 워커는 비동기성도 중요하긴 하지만, 위의 언급된 큐와 워커의 다른 장점을 노리고 사용하는 경우가 많다. 단순히 작업을 비동기적으로 처리하는 것만을 위해 큐와 워커를 사용하기에는 비용이 너무 많이 든다. 큐와 워커를 띄워야 하고, 워커를 띄우려면 코드도 기존 서버로부터 분리해내야 한다. 48 | 49 | 백그라운드 태스크를 사용할 때 주의해야 할 점은 쓰레드 관리이다. 작업을 실행시키려는 쓰레드가 어떤 풀에서 관리되고 있는지, 해당 풀의 사이즈는 어떤 로직으로 결정되는지 등에 주의하자. 50 | --------------------------------------------------------------------------------