Tech Blog

직접 구현하고 검토를 거친 기술적 선택과 설계를 정리해 남깁니다

전체 게시글 (6)

static 키워드의 메커니즘 해부 - Java / Kotlin 편

시리즈 집필의 계기 AI를 활용한 코딩이 일상화되면서, 더 이상 특정 언어의 API나 문법에 대한 ‘암기’가 개발자의 핵심 역량이 되진 않는 시대가 되었습니다. 이제는 “어떤 자료구조를 쓰겠다”, “이런 방식으로 구성하겠다”는 정도의 아이디어만 정리하면, AI가 문법과 구현은 대부분 완성해줍니다. 더불어, 언어들 자체도 점차 닮아가고 있습니다. 각 언어가 가진 고유한 문법 차이보다는, 객체지향 프로그래밍(OOP)이라는 공통 패러다임 아래 구조적 유사성이 강해졌습니다. 특히 실무에서 주력으로 사용되는 TypeScript, Java, Kotlin, Python 네 언어는 모던한 문법과 도구 지원을 바탕으로, 빠르게 수렴 중입니다. 이런 맥락에서 이제 중요한 것은 개별 언어의 경험적 숙련이 아니라, 언어를 관통하는 핵심 메커니즘에 대한 깊은 이해입니다. 이 시리즈는 각 언어에서 동일한 개념이 어떻게 구현되고, 어떤 철학을 기반으로 설계되었는지를 분석해보려 합니다. static이란? static은 객체지향 언어를 배울 때 가장 먼저 마주치는 키워드 중 하나입니다. 클래스 수준에서 정의되지만, 클래스의 인스턴스와는 별개로 동작하며, 상태를 공유하거나 유틸리티 메서드를 구현할 때 자주 사용됩니다. 하지만 이 단순해 보이는 키워드는 언어마다 해석이 다르고, 내부적으로 작동하는 방식도 제각각입니다. 어떤 언어는 이를 명시적 키워드로 선언하고, 어떤 언어는 싱글톤 객체나 데코레이터로 대체합니다. 또 어떤 언어는 정적 바인딩을 통해 컴파일 타임에 결정되며, 어떤 언어는 런타임 싱글톤 객체로 처리합니다. 이 글에서는 Java, Kotlin, Python, TypeScript 네 언어를 기준으로 static 키워드가 어떤 방식으로 표현되고 구현되는지, 그리고 그 배경에 깔린 언어 설계 철학은 무엇인지를 깊이 있게 분석해보겠습니다. Java의 static 키워드 Java에서 static 키워드는 클래스 로딩 시점에 JVM의 메서드 영역(Method Area)에 정적으로 할당되는 클래스 수준 멤버를 선언할 때 사용됩니다. 하지만 표면적인 “클래스 멤버”라는 정의만으로 static의 실제 동작을 설명하긴 부족합니다. 실제로는 상속, shadowing(은닉), 정적 초기화, 동적 로딩, 접근 제한 등 다양한 메커니즘이 얽혀 있습니다. 메모리 구조와 static static으로 선언된 필드는 JVM의 Runtime Data Area 중 Method Area에 저장됩니다. 이는 모든 인스턴스가 동일한 메모리 공간을 공유함을 의미합니다. 클래스가 로딩될 때 단 한 번 할당됨 인스턴스가 아닌 클래스 단위로 존재함 공용 상태를 가지므로 멀티스레드 환경에서 동기화 주의 필요 static 초기화 블록과 순서 static 필드 및 static 블록은 클래스 로딩 시점에, 선언 순서대로 실행됩니다. 만약 static 블록 내에서 예외가 발생하면, 해당 클래스는 로딩 자체가 실패하여 NoClassDefFoundError가 발생할 수 있습니다. 관련해서 여러 예시를 알아보겠습니다. x가 초기화되기 위해 printX() 호출 이 시점에 y는 아직 선언되지 않았으므로 0 (원시타입 기본값) JVM의 static 초기화는 선언 순서대로 진행되며, 다른 static 멤버를 참조해도 그 시점에 초기화되지 않았을 수 있음 클래스 로딩시점에, 선언순서대로 실행된다는 말의 의미를 보여주는 예시코드 입니다. System.out.println("Inner static block"); 이 초기화 코드 블럭에 있는 코드가 Inner.class라인에선 실행되지 않다가 정적필드에 접근했을 때 비로소 실행되는 모습을 볼 수 있습니다. static 필드가 첫 접근을 받을 때 (단, final이 아닌 경우) static 메서드 호출 시 Class.forName("...") 사용 시 new 연산자로 인스턴스를 생성할 때 단순히 Inner.class로 로딩하는 것은 초기화 요건에 해당되지 않으니 주의가 필요합니다. static의 상속과 shadowing(은닉) Java에서 static 멤버는 인스턴스가 아닌 클래스에 종속됩니다. 하지만 상속 관계에서 static 필드/메서드가 어떻게 동작하는지는 오해의 소지가 많습니다. static 필드/메서드는 ‘참조 변수의 타입’에 따라 바인딩됩니다. 실제 인스턴스 타입이 아닌 정적 타입(컴파일 타임에 결정) 기준입니다. 이를 static shadowing이라고 부릅니다. 오버라이딩(override)이 아니라, 은닉(hide)일 뿐이며, 인스턴스 메서드와 다르게 동적 바인딩이 전혀 일어나지 않습니다. 정적 import와 네임스페이스 오염 Java 5부터는 import static 구문으로 static 멤버를 직접 import할 수 있습니다. 하지만 static import를 남용하면 이름 충돌(Name Collision)의 위험이 커집니다. 가급적 사용하지 않는걸 권장 드립니다. static 내부 클래스(정적 중첩 클래스)와 외부 참조 Java에서 클래스 안에 선언된 내부 클래스(nested class) 중 static 키워드가 붙은 클래스는 정적 중첩 클래스라고 부릅니다. 일반 내부 클래스는 암묵적으로 외부 클래스의 인스턴스를 참조하고 있습니다. 컴파일 시 외부 클래스의 인스턴스 참조(outerRef)를 자동으로 생성자에 포함시킵니다. 이 경우 GC가 외부 객체를 수거하지 못하는 메모리 누수 위험이 생길 수 있습니다.  반면 static 내부 클래스는 외부 클래스와 완전히 분리된 독립 클래스이므로, 외부 클래스의 인스턴스를 암묵적으로 참조하지 않습니다. 즉, GC의 수거 대상에서 자유롭고, 메모리 해제가 명확하게 관리됩니다. 주로 유틸리티성 도우미 클래스가 외부 클래스의 상태와 무관한 경우  ResponseDto.Success response = ResponseUtil.success(data) Enum, Builder 패턴, DSL 구조 등에서 캡슐화된 논리적 구성 단위로 활용 User user = new User.Builder().name("Astor").build(); VO, DTO 등을 하나의 클래스로 응집하고 싶을 때 AuthInfo.Simple initialize(AuthCommand.Initialize request) 같은 케이스에서 활용할 수 있습니다. Kotlin의 static: Companion Object와 객체지향적 대체 Kotlin은 static 키워드를 제거한 대표적인 JVM 언어입니다. 그렇다면 Kotlin에서 ‘클래스 단위의 멤버’, 즉 Java의 static은 어떻게 구현될까요? Kotlin이 택한 방식은 단순한 문법적 대체가 아니라, JVM 메커니즘 위에 객체지향 설계 철학을 얹은 형태입니다. Companion Object의 메커니즘 Kotlin에서 companion object는 클래스 당 하나만 존재하는 싱글톤 객체입니다. Java의 static처럼 클래스명으로 접근할 수 있지만, 실제로는 클래스 로딩 시 생성된 진짜 객체입니다. Kotlin 컴파일러는 클래스명.필드 또는 클래스명.메서드 형식의 호출을 내부적으로 클래스명.Companion.필드 형태로 변환합니다. 예를 들어 다음 Kotlin 코드를 보겠습니다: Kotlin에서는 Counter.count와 Counter.increment()로 접근하지만, 이 코드는 컴파일 후 JVM에서는 다음과 같이 변환됩니다: 즉 Counter.count는 내부적으로 Counter.Companion.getCount() 호출로 바뀌며, 이는 클래스에 소속된 정적 필드가 아니라 Companion 객체의 멤버에 접근하는 것입니다. 코틀린 파일을 아래처럼 자바에서 직접 호출해보시면 쉽게 이해할 수 있습니다. Java Interop, @JvmStatic, 그리고 진짜 static Kotlin은 static 키워드를 제거한 대신, companion object라는 객체 기반 구조를 통해 Java의 정적 멤버 역할을 대체합니다. 이 방식은 Kotlin 내부에선 매우 일관된 객체지향 구조를 유지해주지만, Java와의 상호 운용성에서는 다소 불편함을 유발할 수 있습니다. 이를 보완하기 위해 Kotlin은 @JvmStatic 어노테이션을 제공합니다. 이 어노테이션은 해당 메서드 또는 프로퍼티를 진짜 static으로 만들어줍니다. 다음과 같이 선언하면: Java에서는 이렇게 바로 사용할 수 있게 됩니다: Companion 역시 여전히 사용가능한걸 볼 수 있는데, @JvmStatic을 붙이면 Counter.Companion.reset()과 Counter.reset() 둘 다 호출 가능해지며, Java 코드 입장에서는 훨씬 깔끔한 API가 됩니다. 상속: 객체이므로 상속과 구현 가능 Kotlin의 companion object는 실제 객체이기 때문에, 클래스처럼 상속하거나 인터페이스를 구현할 수 있습니다. 이는 Java의 static 멤버와는 근본적으로 다른 특징입니다. 여기서 companion object는 Factory 인터페이스를 구현하며, 외부에서 다음과 같이 사용할 수 있습니다: 이처럼 Product 클래스 자체가 Factory로 사용될 수 있는 이유는 companion object가 인터페이스를 구현한 싱글톤 객체이기 때문입니다. 이 구조는 팩토리, 전략, 서비스 로케이터 등 다양한 객체지향 패턴에 응용될 수 있습니다. 다형성: 정적 멤버처럼 보이지만 동적 디스패치 불가 companion object는 객체이긴 하지만, 클래스 간 상속 관계 내에서 다형성(polymorphism)은 지원하지 않습니다. Kotlin은 companion object를 자동으로 상속하지 않기 때문입니다. 이 구조에서 각각 hello를 호출해보면 위와 같은 결과를 알 수 있습니다. 서로 상속 관계지만, 클래스의 companion object는 완전히 독립된 객체입니다. 따라서 Derived의 companion은 Base의 companion을 상속하거나 오버라이딩하지 않으며, 다형적 호출도 불가능합니다. Shadowing: 컴파일 타임 기준의 정적 참조 Kotlin의 companion object 내부 멤버는 정적 바인딩(static binding) 됩니다. 즉, 어떤 클래스명을 통해 호출하느냐에 따라 어떤 메서드가 호출될지가 컴파일 타임에 결정됩니다. 이 때문에 Shadowing(은닉)이 발생합니다. 위의 예시를 통해 코틀린에서 생길 수 있는 companion object의 은닉 케이스들을 모두 확인하실 수 있습니다. object 키워드 Kotlin은 static 키워드를 제거하면서도, 정적 멤버 또는 전역 객체처럼 사용 가능한 구조를 제공하기 위해 object 키워드를 도입했습니다. object는 "단 하나만 존재하는 객체(Singleton)"를 선언하는 구문입니다. 클래스의 인스턴스를 생성하지 않고도, 상태와 동작을 담은 객체를 바로 정의하고 사용할 수 있습니다. 이 객체는 다음과 같이 바로 사용할 수 있습니다: Java에서 static 메서드로 구성되던 유틸리티 클래스를 Kotlin에서는 object로 선언 등 활용할 수 있습니다.  Top-level 함수의 정적화 코틀린은 파일 수준(최상위)의 함수 및 변수를 통해 정적 멤버처럼 사용할 수 있는 또 다른 구조를 제공합니다. 이것이 바로 Top-level 함수와 프로퍼티입니다. 클래스나 객체 내부가 아닌, 파일 자체의 루트 레벨에 선언된 함수를 Kotlin에서는 Top-level 함수라고 부릅니다. 이 함수는 Kotlin에서는 별다른 클래스나 객체 없이 이렇게 호출됩니다: Kotlin 내부에서는 매우 자연스럽고 간결한 표현 방식입니다. 하지만 이 함수는 실제로 JVM에선 어떻게 표현될까요? JVM에서의 정적 함수 변환 방식 Top-level 함수는 Kotlin 컴파일러에 의해 JVM의 정적(static) 메서드로 변환됩니다. 위의 MathUtils.kt 파일을 Java로 디컴파일하면 다음과 같은 클래스가 자동 생성됩니다: 즉, Kotlin의 Top-level 함수는 실제로는 정적 메서드이며, 클래스명은 \[파일명 + Kt] 형식으로 자동 생성됩니다. Top-level 함수는 Kotlin의 파일 지향 문법 구조 덕분에 가능한 방식입니다. Kotlin은 파일 단위로 컴파일 단위를 만들고, 파일 내 정의된 함수들을 자동으로 정적 메서드로 컴파일합니다.


static 키워드의 메커니즘 해부 - Java / Kotlin 편
OOP
Java
+2

NestJS에 자연스럽게 녹아드는 모듈러 모놀리식 아키텍처 가이드

시작하며: NestJS와 아키텍처 NestJS는 Node.js 생태계에서 보기 드물게, 강력한 객체지향 기반의 설계 철학을 갖춘 백엔드 프레임워크입니다. Angular에서 영향을 받은 구조와 함께, 실질적으로는 Spring Framework에 가까운 모듈 기반 구조와 DI(Dependency Injection) 컨테이너, 그리고 데코레이터 중심의 선언적 구성 방식을 갖추고 있습니다. 하지만 NestJS는 Spring과는 다릅니다. Spring이 @Bean, @ComponentScan 등의 클래스 스캐닝 기반 설정과 런타임 리플렉션 기반의 자동 조립을 전제로 한다면, NestJS는 정적이고 명시적인 모듈 시스템을 중심으로 구성됩니다. 클래스를 어떤 모듈에서 등록하고, 어디에 의존하는지 명확히 선언해야만 동작하며, 이는 추상적인 객체 생성보다는 구조적으로 모듈화된 코드 설계를 자연스럽게 유도합니다. (물론 NestJS도 내부 로직은 런타임 리플렉션 기반이긴 합니다. 다만 스프링이 전역 단일 애플리케이션 컨테이너가 쭉 스캔을 해주고 처리해주는 방식이라면, NestJS는 모듈 단위로 해당 동작이 일어나죠.) 이러한 특성 때문에 NestJS는 어느 정도 이상 규모가 되는 서비스에서 아키텍처적 선택을 고민하게 됩니다. 이 글에서는 제가 3년간 NestJS 관련 다양한 아키텍처를 직접 짜보면서 NestJS의 모듈 시스템 특성과 가장 궁합이 좋았던 계층형 모듈러 모놀리식 아키텍처를 소개합니다. 실제 개발 과정에서 구조적 명확성과 확장성을 어떻게 확보했는지를 중심으로, 실용적인 설계 기준을 함께 정리해보려 합니다. 계층의 구분 계층형 아키텍처는 운영체제부터 대규모 백엔드 시스템에 이르기까지, 복잡도를 제어하고 변경 비용을 낮추는 구조적 해법으로 오랫동안 검증되어 왔습니다. 그러나 NestJS에 이 구조를 그대로 적용하기엔 몇 가지 실질적인 제약이 존재합니다. Spring Framework처럼 전역 DI 컨테이너를 기반으로 동작하는 프레임워크에서는, 서로 다른 계층 간에도 의존성 역전(DIP: Dependency Inversion Principle)이 자연스럽게 적용됩니다. 상위 계층에서 하위 계층을 주입받는 구조뿐만 아니라, 인터페이스를 통해 하위 계층이 상위 계층에 의존하도록 재구성하는 것 역시 어렵지 않습니다. 반면 NestJS는 명시적으로 모듈 단위로 DI 컨텍스트를 구성하기 때문에, 서로 다른 계층 간 의존성을 구성하려면 해당 provider들을 동일한 모듈 내에 등록해야만 동작합니다. 즉, 의존성 역전을 위해선 오히려 여러 계층이 하나의 모듈에 응집되어야 하는 역설적 구조가 발생할 수 있습니다. 이러한 특성 때문에 NestJS에서는 전통적인 4계층 구조(표현/응용/도메인/인프라)보다는 "계층"과 "모듈"을 함께 고려한 하이브리드 접근법이 더 적합합니다. 제가 제안하는 방식은 모듈을 도메인 단위로 나누고, 각 모듈 내부에 도메인 로직과 인프라스트럭처 계층을 함께 포함하는 구조입니다. 이렇게 하면 단일 모듈 내에서 계층 간 책임을 응집력 있게 다루면서도, NestJS의 DI 제약을 자연스럽게 피할 수 있습니다. 다만 시스템 전체로 보았을 때는 여전히 모듈 간의 수직적인 계층적 참조 관계를 구성합니다. 예를 들어, payment, coupon과 같은 도메인 모듈은 상위의 payment-processing, promotion 같은 응용 모듈에서 참조되고, 이 응용 모듈들은 다시 컨트롤러나 이벤트 컨슈머가 포함된 BC(Bounded Context) 단위의 서비스 모듈에서 불려집니다. 즉, 모듈 간의 계층적 의존 방향은 다음과 같은 상향식 참조 흐름을 가집니다 사진을 보시면 각 모듈간의 참조관계를 확인할 수 있습니다. 레이어는 도메인 주도 설계의 전술적 설계 테크닉에서 파생된 presentation - application -domain -infrastructure 4계층 전략을 사용합니다. 서비스 모듈 결제와 환불, 쿠폰 등 금전적인 요소들을 응집해서 처리하는 최상위 billing 서비스가 존재한다고 했을 때, 이에 대응하는 billing module을 우선 최상위에 모듈로 둡니다. 이 모듈은 응용 계층들의 모듈을 import하고 controller를 등록합니다. 위와 같은 구성이 될 수 있죠. 추가적으로 횡단 관심사 모듈들인 캐싱이나 리더 선출 모듈을 주입받고 있습니다. 이 모듈에서 controller <-> application 레이어를 조합하고 처리합니다. 애플리케이션 모듈 여러 도메인 로직을 조합하고, 외부 채널 등과 소통하며 비즈니스 로직을 직접적으로 처리하는 것을 애플리케이션 모듈에서 맡아서합니다. 아래와 같이 도메인 모듈을 조합해서 사용합니다. 과정에서 특정 서비스 내지 bounded-context 내부에서 공통적으로 사용할만한 응용 로직이 존재하면 애플리케이션의 SupportModule로 등록해서, 타 애플리케이션 모듈들에서 재활용해서 씁니다. 가령 다른 서비스와 직접적으로 소통할 일이 있는 경우 외부 포트 등을 SupportModule에 넣어서 사용하는 식입니다. 내부에서는 여러 클래스들 조합해서 OOP를 하시면 됩니다. 다만 상위 모듈로 넘겨줄 땐, facade class에 응집하여서 내부 상세 로직을 캡슐화합니다. 도메인 모듈 도메인 모듈은 시스템의 비즈니스 개념과 규칙을 가장 순수한 형태로 담고 있는 계층입니다. 이곳에는 핵심 엔터티, 밸류 오브젝트(Value Object), 도메인 서비스, 리포지토리 인터페이스 등 도메인의 불변성과 일관성을 보장하는 요소들만 포함됩니다. 도메인 모듈은 응용 계층이나 인프라 계층과는 기술적·상황적 의존 없이 독립적으로 설계되어야 하며, 외부 시스템이나 채널, 프레임워크와의 연결 지점은 인프라 레이어를 통해 위임합니다. 즉, 외부와의 상호작용(알림, 외부 API 호출 등)은 도메인 내부에 포함되지 않고, 그에 대한 계약(인터페이스)만 정의한 채 구현은 바깥 계층에 맡깁니다. 이러한 설계는 도메인 모듈의 변경 가능성을 최소화하고, 비즈니스 규칙의 순수성과 가독성을 유지하는 데 목적이 있습니다. 모듈의 구성입니다. 응용 계층과 마찬가지로 Service는 도메인 계층의 Facade역할을 합니다. 또한 class를 직접 provide하지 않고 인터페이스 기반 DI가 들어간 요소들이 있는데, 이는 인프라 레이어에 구현체가 등록된 케이스에 해당합니다. 디렉토리를 보겠습니다. 인프라계층에 해당하는 요소를 위와 같이 분리합니다. CouponReader 등은 인터페이스만 정의되어있습니다. models쪽에는 실제 도메인 메서드를 지니고 있는 AggregateRoot나 DomainEntity, ValueObject 들을 정의해 놓습니다. 이 엔티티는 도메인 레이어 외부에 누설되는 순간 도메인 모듈 내부에 대한 캡슐화가 깨지기 때문에, 서비스 계층에서 return해 상위 모듈로 올릴 때는 순수히 데이터만 지닌 info라는 객체로 매핑해서 올립니다. 앞서 말한 외부 세계와의 의존성, 기술적 상세 구현에 대한 내용은 infrastructures로 몰아 넣어 놓습니다. 여기서 Orm에서 사용하는 영속성 Entity는 은닉합니다. 인자와 리턴 값 모두 도메인레이어의 모델을 사용하고, 내부에서 처리할 때 영속성 Entity로 매핑합니다. https://deviq.com/principles/persistence-ignorance 모듈러 모놀리스 billing과 같은 서브도메인 단위의 서비스 모듈을 Bounded Context 또는 팀 단위로 설계하고, 이러한 모듈들을 응집하여 하나의 통합된 서버에서 구동하는 구조가 바로 모듈러 모놀리식 아키텍처(Modular Monolith Architecture)입니다. 이렇게 구성된 각 모듈은 하나의 명확한 서브 도메인을 책임지며, 전체 시스템은 이들을 조합함으로써 도메인 주도 설계에서 말하는 도메인 전체의 구성을 형성합니다. https://www.sktenterprise.com/bizInsight/blogDetail/dev/7765 NestJS의 모듈 시스템은 이 아키텍처와 자연스럽게 맞물립니다. NestJS는 기능 단위가 아닌 도메인 단위의 모듈 구성을 중심으로 하고 있으며, 각 모듈은 독립적인 DI 컨텍스트를 갖고 책임과 경계를 분리할 수 있도록 설계되어 있습니다. 이는 모듈러 모놀리식 아키텍처의 핵심인 명확한 Bounded Context 구분 및 내부 응집도와 정확히 일치합니다. 서비스 모듈 간 통신은 이벤트 기반 또는 명시적인 API 호출을 통해 이루어집니다. NestJS 환경에서는 이를 별도의 메시지 브로커 없이도 인메모리 이벤트 큐로 구현할 수 있으며, 이 구조를 통해 메시지 큐 기반 아키텍처와 유사한 decoupling 효과를 확보할 수 있습니다. 이벤트 핸들러를 각 모듈에 분산 등록하면, 마치 컨슈머 그룹을 갖춘 메시지 큐처럼 동작할 수 있으며, graceful shutdown과 이벤트 처리 보장만 신중히 설계하면 운영 안정성도 확보할 수 있습니다. 이 방식은 초기에는 인프라 리소스를 최소화한 채 서비스 간 의존도를 느슨하게 유지할 수 있는 장점을 가지며, 추후 Kafka, Redis Streams 등 외부 메시지 브로커로 이관할 수 있는 확장성도 열어둡니다. 번외) 메시지 브로커 추상화에 대한 고려 물론 메시지 큐 인프라를 사용하게 되는 경우, 인터페이스를 추상화해두는 것이 이관에 도움이 될 수 있습니다. 다만 Kafka, Redis 등은 각각의 고유 기능과 최적화 방식이 다르기 때문에, 너무 일반화된 추상 레이어는 오히려 각 인프라의 특화된 기능을 쓰기 어렵게 만드는 비용이 발생할 수 있습니다. 따라서 인메모리 이벤트 기반을 사용할 경우, 초기부터 과도한 추상화보다는, 사용하는 메시징 인프라에 맞춘 구체적 설계를 하는 것이 나을 수 도 있습니다.


NestJS에 자연스럽게 녹아드는 모듈러 모놀리식 아키텍처 가이드
NestJS
아키텍처
+2

TS & NestJS 디자인패턴 총정리

시작하며: nest.js & typescript의 특성 nest.js는 spring처럼 ts에서도 프레임워크에게 템플릿을 의존한 채, 견고한 백엔드 설계를 도와줍니다. 다만, java & kotlin과 ts의 언어적 차이 및 Angular에서 파생한 nest.js의 설계 철학 자체가 spring과는 꽤나 차이가 있어 spring 진영에서 사용하는 설계 패턴을 그대로 사용하기에는 적합하지 않습니다. 구체적인 예시는 본문에서 다룰 예정이지만, 가장 큰 차이는 TS의 특성상 런타임에서 소실되는 JS에 존재하지 않는 기능들, Module 기반으로 작동하는 Nest.js 시스템에서 기인합니다. 이 글에선 제가 실제로 ts & nest.js 서비스를 운용하면서 꽤나 재미를 본 디자인 패턴 테크닉들을 담아 정리합니다.&#x20; 런타임 타입 보존: 추상 클래스 vs 인터페이스 + 심볼 DI를 위해서는 프레임워크가 원형(interface || abstract class || class)을 런타임에서 알아야하며, 어떤 구현체를 사용할 건지 또한 정의가 되어 있어야 합니다. nest.js와 spring등 DI를 제공하는 프레임워크들은 인터페이스와 데코레이터(어노테이션)을 활용하여 서버의 bootstrap 시점에서 주입할 구현체를 결정합니다. 다만, TypeScript에서 interface는 컴파일 시 제거되어 런타임에 흔적이 남지 않으므로, NestJS DI에서는 추상 클래스로 런타임 타입을 유지하거나 동일 인터페이스 구현체임을 식별할 수 있는 토큰을 제공하는 것 중 선택해야 합니다. 우선 typescript 활용에 있어 추상클래스와 인터페이스 사용의 유스케이스 차이에 대해 한번 알아보고 지나가겠습니다. 인터페이스 사용 런타임 타입 정보 가 전혀 필요 없고, 컴파일 타임 만으로 충분할 때 객체 리터럴 이나 여러 implements 구현체가 느슨하게 결합되어야 할 때 JS 번들 크기 최적화를 위해 런타임 코드 생성 없이 타입만 정의할 때 추상 클래스 사용 부분 구현 (default 메서드, protected 필드)을 통해 코드 재사용이 필요할 때 런타임 타입 확인 ( instanceof )을 사용해야 할 때 단일 상속 을 활용해 공통 기능을 중앙에서 제어할 때 java 진영에서는 런타임 타입 정보가 둘 다 모두 존재하기 때문에 부분 구현이 필요하지 않는 경우엔 인터페이스 활용이 권장됩니다. 그마저도 인터페이스에서 default 로직을 선언할 수 있게 되면서 가급적 인터페이스를 활용하는 편이죠. 다만 ts는 interface 사용 시 give and take가 있습니다. 바로 런타임 정보를 활용할 수 없다는 점이죠. 가장 크리티컬한 점은 nest.js DI 시점에서도 이미 인터페이스는 컴파일 되며 사라져서 어떤 것이 인터페이스 구현체임을 알 수가 없다는 점이죠. interface를 통한 DI를 하려면 결국 명시적으로 DI 가능한 대상임을 아래와 같이 모듈과 주입받는 class에 알릴 필요가 있습니다 . 굉장히 귀찮고, 의존성 주입 부분에 대한 구현이 분산되는 구조이죠. 이에 대한 대안으로 괜찮은 방식을 아래에 2가지 제안합니다. 추상 클래스 사용 추상 클래스를 사용하면 굳이 provider로 임의의 문자열이나 심볼 등 토큰을 제공할 필요가 없습니다. class 자체는 컴파일 되어도 보존되기 때문이죠. 다만 extends를 통한 class 구현은 오직 1개만 가능합니다. 이 때문에 추상클래스를 활용하면 일부 패턴을 사용할 수 없습니다. 구현 예시입니다. 런타임에 유실되지 않기 때문에 모듈 선언부나 사용부의 코드가 굉장히 심플해집니다. 개인적으로는 ts에선 위와 같은 추상 클래스 패턴 사용을 꽤나 권장하는 편인데, 주로 설계도 : 구현체가 일대일 대응될 확률이 높은 경우 위 패턴 사용을 추천합니다. java로 치면 service -> serviceImpl 혹은 DefaultService 만 존재하는 케이스에 속하죠. 애초에 단일한 구현체만 존재한다면 굳이 설계도를 따로 정의할 필요가 있는가? 라는 부분에 대해서는 이견이 갈릴 수 있다 생각합니다. 저는 협업적인 면, 코드 가독성 및 시스템의 설계 측면에 있어서라도 느슨한 결합이 주는 메리트가 꽤 있다고 생각하는 편이여서 단일 구현체여도 가급적 설계도를 선언합니다. 테스트 할 때 모킹하기도 수월하구요. 이부분은 취향 껏 하시면 될 것 같네요. abstract class나 interface 안쓰시면 그냥 DIP 굳이 안하고 직접 class 자체를 참조하셔도 됩니다.&#x20; 인터페이스 + Symbol 토큰 반면, 인터페이스는 TypeScript 상에서 컴파일 타임 타입 체크만 수행하고, 컴파일 후에는 런타임에 타입 정보가 완전히 사라집니다. 따라서, NestJS에서는 런타임 식별자(Injection Token) 를 명시적으로 제공해야 합니다. 이때 단순 문자열보다는, 충돌 방지와 안전성 확보를 위해 Symbol을 사용한 토큰 정의를 권장합니다. 이 방식 역시 런타임에서 적절한 구현체를 사용할 수 있도록 느슨한 결합과 유연성을 확보할 수 있습니다. 다만 코드가 조금 더러워지고, 심볼을 따로 선언 및 관리할 필요가 있습니다. 대신 장점으로는 interface의 특성상 한 클래스가 여러 인터페이스의 구현체로 기능하게 할 수 있다는 점입니다. 바로 살펴보겠습니다. 단일 구현체 전략 여러 인터페이스를 모두 관장하는 하나의 구현체를 사용하는게 효과적일 때도 있습니다. 가령 ReservationReader, ReservationStore, ReservationValidator를 활용할 필요가 있는데, 이들이 저장소(repository)의 구현 방식에 따라서만 분기할 때, InMemoryReservationManager과 TypeOrmReservationManager 로 분기 방식에 따라 단일 구현체로 나누는게 개별 구현체를 따로 다 만드는 것 보다 효과적이지 않을까요? 이럴 경우 활용할 수 있는 패턴입니다. 이 패턴은 인터페이스를 통해 DI 하여야만 활용할 수 있습니다. useExisting을 활용하면 provider의 구현체로 모듈에 이미 등록한 특정 class를 사용할 수 있습니다. 이 경우 provide에서 제공하는 token을 통해 inject된 부분의 구현체에 대해 해당 모듈에 한해, 해당 구현체를 사용하게 됩니다. (꼭 useExisting을 써서 delegation 패턴을 사용할 필요는 없습니다. TodoFind에 대한 useClass로 DefaultTodoManager을 직접 주입하는 등으로 구현 가능합니다.) 구현체 쪽은 여러 interface를 아래와 같이 구현하도록 강제해 설계합니다. 사용측은 심볼 혹은 토큰을 명시적으로 주입해야 합니다. 이런 패턴은 활용도가 되게 무궁무진한데, 특히 useCase별로 class를 지정해서 OOP를 할 때 효과적입니다. 비즈니스 로직을 담은 상위 계층의 레이어에선 useCase에 대한 인터페이스만 정의합니다. OauthLoginUseCase, RefrestOauthTokenUseCase 등. 이 인터페이스에 대한 구현체는 여럿이 될 수도 있고, 구현 사정 및 책임 분리에 대한 요구사항에 따라 delegation 패턴을 활용해 하나(혹은 유스케이스보단 적은 수)가 될 수 있습니다. OauthLoginUseCase에 login 메서드가, RefrestOauthTokenUseCase 에 refresh 메서드가 정의되어 있다고 가정하면, KakaoOauthService에서 두 메서드를 모두 구현할 수도 있겠죠. List DI 여기까지 여러 역할에 하나의 구현체를 주입하는 방법을 다뤘습니다. 그렇다면, 구현체가 여럿일 때, 이를 "리스트" 형태로 한 번에 주입할 수 있을까? 결론부터 말하면: NestJS 기본 DI 시스템은 List 주입을 자동 지원하지 않는다. Spring처럼 List\<Interface>를 바로 주입하는 기능은 없으며, NestJS는 명시적 DI(Explicit DI)를 지향하기 때문에 수동으로 List Provider를 생성해 주입해야 합니다. 개별 구현체 등록 리스트 Provider 작성 사용처 이렇게 각 인터페이스의 구현체를 하나의 Token을 통해 묶으면서, 해당 토큰에 리스트 형태로 명시적으로 provide하여야 여러 인터페이스에 대한 복수의 구현체를 주입할 수 있습니다. 어떻게 보면 해당 token에 대한 주입체 자체를 이미 선언 시 리스트 형태로 해놓는 것이죠. 설계도에 대한 여러 구현체가 존재할 경우 List가 아닌 형태로 주입하려 하면 에러가 뜨는 스프링 보다는 명시적이면서도.. 귀찮습니다. 이 방식을 통해 스프링에서 사용하는 패턴도 마음 껏 사용할 수 있습니다. 가령 oauth의 provider 별로 enum인 OauthProvider를 만들어놓고, oauthProviderService의 구현체에 support(provider: OauthProivider)를 둔 뒤에 사용부에선 모든 oauthProvider 구현체를 주입받고 support를 호출해 적절한 provider를 주입받는 방식이죠. 마찬가지로 oauth 말고도 PG사 등 유사한 통신 규격을 준수하는 여러 구현체가 존재할 때 활용 가능합니다. 사용자 정의 타입 가드 활용하기 typescript는 type predicate를 통해 타입 추론을 정교하게 제어할 수 있습니다. &#x20; 이는 제가 주로 사용하는 class-validator 내부의 isDefined()함수 입니다. 이 함수가 true를 반환하면, TypeScript는 value의 타입을 T로 좁혀서 추론합니다. 만약, 통일된 인자를 사용하되, ID의 존재 유무에 따라 없으면 생성, 있으면 수정을 처리하려 한다고 합시다. 위와 같이 타입가드를 활용해 처리하면, 해당 타입가드가 통과된 이후에는 컴파일러가 타입을 생성용 Props로 narrowing 해줍니다. 보통 이런 시스템을 활용하지 않고 코드를 짜시는 경우, 타입적으로는 nullable한데, 논리적으로는 더이상 null이 아니게 된 경우 something as string 이런 식으로 강제 캐스팅을 많이하는데, 굉장히 나중에 위험한 코드가 됩니다. as를 최대한 쓰지 않는 코드를 작성하는 데 큰 도움을 줍니다.


TS & NestJS 디자인패턴 총정리
NestJS
디자인 패턴
+1

AWS ECS로 만드는 무중단 인프라: OIDC, CD, 로드밸런싱까지

시작하며: 문제 및 필요성 초기 서비스 단계에서는 관리의 직관성과 환경의 단순성을 이유로 EC2와 같은 가상 머신(VM) 기반의 인프라를 선택하는 경우가 많습니다. 이는 빠른 배포와 소규모 운영에는 일정 수준의 유효성을 지니지만, 점차 서비스 트래픽이 증가하고 배포 주기가 짧아질수록 인프라 운영의 비효율성과 확장성의 한계가 드러납니다. 가장 큰 제약 중 하나는 수평 확장의 어려움입니다. VM 기반 서버는 물리적 또는 논리적 자원을 기준으로 스케일링해야 하며, 신규 인스턴스를 프로비저닝하는 데 시간이 소요되고, 설정과 이미지 관리도 번거롭습니다. 무중단 배포 또한 복잡한 로직과 헬스체크 전략을 요구하며, 배포 실패 시 롤백 관리가 어렵습니다. 이로 인해 운영 리스크가 누적되며, 배포 자동화 파이프라인의 안정성 확보 또한 어렵습니다. 이러한 한계를 해결하기 위해 컨테이너 기반의 오케스트레이션 환경이 필요하며, Amazon ECS는 그 대안으로써 명확한 이점을 제공합니다. 특히 AWS Fargate 기반의 ECS는 인프라 자원 관리 없이 컨테이너 단위로 애플리케이션을 정의하고 실행할 수 있게 해 주며, Auto Scaling 및 롤링 배포가 기본적으로 내장되어 있습니다. 결과적으로 ECS는 서비스의 민첩성과 확장성을 동시에 확보할 수 있게 해주는 현실적인 대안이자, MSA 아키텍처로의 전환에도 적합한 기반 인프라로 작용합니다. 저희가 최종적으로 구축할 인프라는 위와 같은 형태가 될 예정입니다. 전제 조건 이 글의 범위는 클라우드 인프라 구성에 한정되며, 애플리케이션 레벨에서 발생할 수 있는 문제(예: 동시성 이슈, 리더 선출, graceful shutdown 등)는 이미 해결되었다고 가정합니다. 다시 말해, 이 포스팅에서는 ECS 기반 배포 환경 구성에만 집중합니다. 또한 VPC 설정은 별도 심화 주제로 다루지 않으며, 본 글에서는 기본적으로 Public Subnet만 사용하는 구조를 전제로 합니다. Private Subnet을 활용하고자 하는 경우, 예를 들어 Secrets Manager 등 외부 서비스와의 통신이 필요한 환경에서는 NAT Gateway 설정이 필수적이며, 이 경우 비용 및 복잡도가 증가합니다. 따라서 이러한 아키텍처를 설계를 원하실 경우, 본 포스팅 이외로도 조사를 철저히 하시길 권합니다. ACM 및 도메인 인증서에 대해서도 다루지 않습니다. 사전에 도메인을 구매, ACM에 등록해두시기 바랍니다. ECS 런타임 - Fargate vs EC2 AWS Fargate 출시 – 서버리스 콘테이너 서비스 | Amazon Web Services 한국 블로그 ECS는 EC2 기반과 Fargate 기반이라는 두 가지 런타임 모드를 제공합니다. 본 포스팅에서는 Fargate 기반 ECS를 전제로 하며, 이는 단순히 인프라 관리의 편의성 때문만이 아니라, 자원 회수 구조의 근본적인 효율성 차이 때문입니다. EC2 기반 ECS는 무중단 배포(Rolling Update)를 위해 신규 버전의 태스크를 실행할 때, 기존 태스크와의 공존을 위해 일시적으로 더 많은 리소스를 필요로 합니다. 이 과정에서 다음과 같은 문제가 발생합니다: 새로운 태스크를 수용할 수 있는 여유 자원이 부족할 경우, ECS는 오토스케일링 그룹을 통해 추가 EC2 인스턴스를 생성 합니다. 배포가 완료되고 기존 태스크가 중지되더라도, 인스턴스 레벨의 리소스는 즉시 회수되지 않습니다. 이는 특히 태스크 단위 리소스 요구량이 작고, 배포 주기가 짧은 서비스 일수록 인스턴스가 부분적으로만 활용된 채 계속 유지 되며 자원 낭비 로 이어집니다. 예를 들어, 한 EC2 인스턴스가 4개의 태스크를 수용할 수 있는데, 배포 과정에서 잠시 2개 태스크가 더 필요해 인스턴스가 하나 추가되었다면, 이후 기존 태스크가 종료되더라도 그 신규 인스턴스는 일부 리소스만 사용된 채 계속 떠 있는 구조가 됩니다. ECS는 인스턴스가 부분적으로 비워져도 이를 자동으로 정리하지 않기 때문에, 리소스 릴리즈가 비효율적으로 작동합니다. 반면 Fargate는 태스크 단위로 리소스를 생성하고, 종료 시 해당 자원을 완전히 릴리즈합니다. 인프라 수준의 리소스를 사용자 측에서 직접 관리하지 않기 때문에, 배포 중 리소스가 잠시 증가하더라도, 배포 완료 후에는 불필요한 자원이 자동으로 제거 됩니다. 이는 자원이 부분적으로 유휴 상태로 남아있는 상황을 방지하고, 실제 사용한 만큼만 과금되도록 함으로써, 운영 비용 최적화에 있어 구조적으로 유리합니다. 대상그룹 & 로드 밸런서 세팅하기 먼저 ECS를 생성하기 전에 로드 밸런서를 생성하겠습니다. ECS 생성 페이지에서 로드 밸런서를 만드는 방식도 있지만, 해당 방식으로 만든 경우 서비스 삭제 시 로드 밸런서가 삭제된다던가, 정밀한 제어를 못한다던가 여러 면에서 조금 귀찮아지기 때문에 먼저 필요한 인프라들을 만들어놓고 가겠습니다. 대상 그룹 생성 EC2 >대상 그룹에서 IP 주소를 대상 그룹으로 하여 대상그룹을 만들어줍니다. 세팅에서 주의하실 점은 대상 그룹의 포트설정입니다. 추후 ECS에서 실행할 서버에 대한 정의(task-definition)을 설정할텐데, Fargate 기반의 서비스는 컨테이너의 실행 포트와 호스트의 포트가 같아야합니다. 즉 서버를 3000번 포트로 띄운다고 하면, 기존 도커에서 하던 것 처럼 443:3000 처럼 포트 매핑하는게 불가능합니다. 대상그룹 생성 시 부터, 서버가 리스닝하고 있는 포트와 맞추어서 생성해줍시다. 나머지는 쭉 만들되, 헬스체크는 서버 측에서 엔드포인트 하나를 설정해주시기 바랍니다. 고급 상태 검사 설정 쪽을 보시면 성공 코드가 명시되어있습니다. 여기서 만약 아무 설정 없이 대상그룹을 만든다면 기본값이 /(루트), 200이 될텐데 보통 서버앱의 root path는 사용하지 않으실테니 404가 뜰 겁니다. 정상적으로 서버가 부팅되어도 실패하죠. 따라서 서버 부팅을 확인할 수 있고, 200 응답을 주는 엔드포인트 하나를 개설한 뒤에 대상 그룹에 연결해주세요. IP 지정 및 포트 정의는 빈 값으로 둡니다. 생성이 완료되셨으면 대상 그룹의 속성 탭으로 가서 등록 취소 지연을 줄여줍니다. CICD 때 시간을 잡아먹는 주범입니다. 이 지연 시간이 끝나야 기존 인스턴스가 내려가는데, 수십초 이상의 연산이 필요한 작업을 수행하는 게 아닌 이상 60초 아래로 설정하셔도 좋습니다. 보통 graceful shutdown과 트랜잭션을 잘 구현해두셨다면 지연이 필요 없는 경우가 많습니다. 로드 밸런서 생성 이제 로드 밸런서를 생성해줍니다. HTTPS를 활용할테니 Application Load Balancer를 설정합니다. 이름이나 서브넷 쭉 설정해주시고 리스너 및 라우팅에서 아까 만든 대상그룹을 지정해줍니다. ACM에서 만들어둔 인증서 가져와줍니다. 안 만들어두신 분들은 잠깐 구글링 하셔서 만드시고 오세요! 이 글에선 범위가 넓어져 다루지 않습니다.&#x20; 보안그룹은 80(선택), 443열어두시고, elb임을 식별할 수 있는 고유한 보안그룹도 하나 할당해주세요. 추후 ecs에서 로드 밸런서 한정으로 포트를 열 때 사용합니다. 80포트 열어두실거면 아래의 리스너 설정을 추가로 해주세요. 우선 이대로 생성을 해줍니다. (선택)로드 밸런서: 리스너 설정 생성이 완료 되셨으면 이제 리스너들 설정을 좀 더 해줍니다. dev서버와 prod서버를 하나의 alb로 처리하고 싶으신 분, http로 접속 시 https로 리디렉션 시키고 싶으신 분은 이 단락을 따라와주세요. 호스트 헤더로 조건 분기하기 먼저 앞서 만든 것과 동일한 방식으로 dev 혹은 staging, monitoring 등 원하는 다른 대상 그룹을 만들어 줍니다. 글에서는 dev 서버 배포를 예시로 들겠습니다. 아까만든 HTTPS:443 리스너에 들어가서 리스너 규칙을 위와 같이 추가해줍니다. 호스트헤더로 규칙을 설정해주시면 되며 이 경우 기본값은 503으로 바꿔줍니다. HTTP -> HTTPS로 리디렉션 리스너 추가를 누르시고 URL로 리디렉션 선택 하신 후 생성하시면 됩니다. OIDC용 IAM Role 만들기 이제 ecs에 올릴 도커 이미지를 준비해야 합니다. github action을 사용해 cd 파이프라인을 구축하겠습니다. 먼저 github에서 aws에 잘 접속할 수 있게 OIDC를 이용하겠습니다. OIDC는 관련이 없는 두 애플리케이션이 사용자 자격 증명을 손상시키지 않고 사용자 프로필 정보를 공유할 수 있도록 하는 데 사용되는 ID 인증 프로토콜인 OpenID Connect 프로토콜을 의미합니다. aws <-> github간의 OIDC에 대한 자세한 내용은 아래를 참조해주세요! https://docs.github.com/ko/actions/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services 우선 IAM에 들어가서 역할 생성 -> 웹 자격 증명을 선택하고, ID 제공업체 및 Audience에 다음과 같이 입력합니다. 하단의 조직, 리포지토리, 브랜치 옵션은 본인의 서버 배포 환경에 맞게 설정해주세요. 권한은 최소권한정책을 유지하면서 설정해주시면 좋습니다. 자세한 설정은 각각 OIDC의 사용처마다 다르기 때문에 찾아보시기 바랍니다. 일단 따라오시기 편하게 저는 FullAccess 때리고 가겠습니다. 실제 사용하시는 서비스면 이렇게 하면 안됩니다..! https://github.com/aws-actions/amazon-ecs-deploy-task-definition?tab=readme-ov-file#permissions ecs 관련 role 설정은 위 레포 README의 permissions 부분 참조해주세요. ECR repository 생성하기 Amazon ECR로 들어가서 repo를 만들어줍니다. 이름만 입력하시고 기본값 그대로 두시고 생성하시면 됩니다. 생성된 ecr은 github action을 통해 빌드한 서버의 이미지를 푸시할 저장소로 사용됩니다. ECS 설정 이제 ecs 생성 이전 모든 작업을 마쳤습니다. 마지막으로 ecs내에서 어떤 이미지를 배포할 것인지에 따라 요구조건이 조금 바뀌는데, 만약 ecs 내 서비스간의 통신을 할 생각이라면 네임스페이스 생성을 먼저 진행해주세요. msa gateway <-> services 통신이라던가, 내부에 redis / kafka 등을 직접 구축해서 사용하신다던가, 모니터링 서버를 띄우고 각각 서버의 메트릭을 수집한다던가 하시면 네임스페이스 생성을 해야합니다. (선택) 네임스페이스 생성 CloudMap에서 네임스페이스 생성을 누릅니다. docker compose에서 컨테이너 이름을 통해 컨테이너간 통신을 했던 것과 유사한 서비스라 생각하면 됩니다. ecs 내부 여러 task들은 ip가 아닌 네임스페이스를 통해 각자 통신할 수 있습니다. 사진과 같이 세팅하시고 생성을 합니다. 클러스터 생성 ECS -> 클러스터 생성을 누르고 Fargate로 클러스터를 생성해줍니다. 네임스페이스를 생성해두셨으면 선택해줍니다.&#x20; Task Definition 정의 task는 ecs에서 실행할 컨테이너 개별 단위라 보시면 좋습니다. task definition을 통해 어떤 이미지를 어떤 인프라 요구사항으로 배포할 것인 지 정의해둘 수 있습니다. Fargate로 생성합니다. OS 및 CPU, 메모리는 운용하시는 서버의 요구사항에 맞춰서 생성하시면 됩니다. 최근에 Fargate에서 ARM64를 지원해주기 시작하여 저는 ARM64 환경을 사용하겠습니다. 저처럼 ARM64를 사용하실 분은 Docker 이미지 빌드 시에 --platform을 명시해주셔야 합니다. task 실행역할은 이미 aws에서 정의해준 TaskExecutionRole을 사용해줍니다. 만약 로깅을 하실 분들은 위와 같이 설정을 하고 생성을 했을 때 오류가 뜰수도 있습니다. ecsTaskExecutionRole에 CreateLogGroup 정책을 하나 만들어서 넣어줍니다. 로그를 따지 않으실 분은 넘어가도 좋습니다. 아까 생성한 ecr의 URI 복사해서 넣습니다. 어차피 ci/cd하면서 덮어쓸 값인데 우선 초기값 설정은 해둡시다. 컨테이너 포트는 서버가 실행되는 포트입니다. 서버가 3000에서 실행된다고 하면, 해당 값을 그대로 넣어주시면 됩니다. 도커의 80:3000 으로 매핑하는 건 fargate에선 지원해주지 않습니다. 환경변수를 추가해야 하는데, 문제가 저희는 이 task-definition 자체를 cd를 위해 repository에 저장해두고 사용할 예정입니다. 따라서 여기에 환경변수를 설정해두시면 그대로 repository에 노출됩니다. 이를 위해 Secrets Manager를 사용합니다. 일단 비워두시고 생성합니다. 나중에 CICD때 수정할 예정입니다. Secrets Manager로 환경변수 관리 Secrets Manager에 들어가서 보안 암호를 생성합니다. 이렇게 생성해둔 암호는 task-definition에서 arn:aws:secretsmanager:ap-northeast-2:381492248147:secret:test-api-rDw6Wm:ADMIN\_CLIENT\_URL:: 위와 같이 설정하면 읽어올 수 있습니다. service 생성 시작 유형 FARGATE로 service를 생성해줍니다. 앞서 정의해둔 task definition을 가져와주고, 원하는 테스크 수를 정해줍니다. 이 수만큼 컨테이너가 올라갑니다. 상태 검사 유예 기간을 넉넉하게 잡아줍니다. 서버가 부팅되는 동안 헬스체크가 진행되어 배포 실패로 처리되는 것을 막아줍니다. 무중단 배포 옵션을 선택할 수 있습니다. 기본 값으로 두면 배포 시 task 수가 일시적으로 2배로 늘어나고, 새 task가 헬스체크를 통과하면 기존 task를 수거합니다. 보안그룹을 설정해줍시다. 네트워크 탭은 서비스 최초 생성 시를 제외하고는 변경이 불가능하니 신중하게 설정해주세요. 퍼블릭 IP는 secrets manager를 사용하려면 필요합니다. 만약 클러스터 내부 서비스 간의 통신을 원한다면 서비스 연결을 켜야합니다. 인바운드 요청을 수신할 필요가 있으면 클라이언트 및 서버로 설정해주세요. 만약 서버인 경우, 포트 매핑에서 설정한 DNS 값을 타 서비스에서 통신할 때 HOST로 넣어주면 됩니다. 가령 redis를 띄운다고 했을 때, DNS 이름을 redis.test.internal 로 설정해주시고, 외부 서비스 환경변수에 REDIS\_HOST = redis.test.internal로 넣어서 연결합니다. 여러 인스턴스가 띄워져 있는 경우 여러 IP를 반환합니다. 별도의 복잡한 로드밸런싱이 필요 없는 경우, 라운드 로빈이나 랜덤한 IP를 엑세스 하는 방식으로 내부 통신을 쉽게 구현할 수 있습니다. 서버 등 외부에서 접근이 필요한 경우, 로드 밸런서를 꼭 연결해줍니다. 여기서 대상 그룹을 지정해두면, ecs task들을 자동으로 대상 그룹에 올려줍니다. CD: github action -> ecr -> ecs 이제 github action과 연동하여 cd를 구축해보겠습니다. 다음과 같은 파이프라인을 거칩니다. repo 기반으로 docker image build ecr에 push ecr에 push 된 이미지를 기반으로 task-definition 새 version 생성 새 version을 이용해서 ecs 서비스 배포 task-definition 추출 생성된 task definition의 json 파일을 다운받아 줍니다. 위 필드들은 ecs에서 자동으로 생성하는 값들로 지워줍니다. 프로젝트 내부에 위치시켜 줍니다. 저는 바로 루트에 넣었습니다. json의 secrets에 환경변수를 모두 넣어줍니다. github action yml 만들기 https://docs.github.com/en/actions/use-cases-and-examples/deploying/deploying-to-amazon-elastic-container-service Creating the workflow 탭에 보시면 CD 가이드 코드가 있는데 그대로 긁어와서 환경변수나 배포 조건만 원하시는 대로 커스텀 해줍니다. aws credentials 방식은 access key 대신 더 안전하게 OIDC를 활용하게 해줍니다. 배포하기 여기까지 잘 따라오셨으면 github action을 트리거하면 자동으로 배포가 될 겁니다. 혹시 에러가 뜨는 분들은 메세지를 잘 확인하고 고쳐주시길 바랍니다. 대부분은 권한 문제, 환경변수 문제, 보안 그룹 이나 네트워크 설정 문제 중 하나입니다. 이제 초기 기획했던 아키텍처의 구성을 완료하였습니다. 마지막으로 도메인에 ALB의 CNAME을 넣어주시면 클라이언트 측에서 접속 가능합니다. 마무리하며: 보완할 포인트들 다음은 추가적으로 보완하면 좋은 포인트들입니다. OIDC 권한 최소화 : GitHub OIDC Role에 최소 권한만 부여 ( sts:AssumeRoleWithWebIdentity \+ 필수 ECS 권한만) Redis 등 stateful 서비스는 EBS 또는 EFS로 볼륨 유지 : task 재시작/이동에도 데이터 보존 컨테이너 이미지 서명 및 스캔 활성화 : ECR 이미지에 취약점 스캐닝 적용 Security Group 최소화 : 서비스 간 통신만 허용하는 VPC 보안 그룹 설계 서브넷 분리 구성 : 퍼블릭/프라이빗 서브넷 분리로 외부 노출 최소화 내부 로그 정비 & CloudWatch 로그 유지 기간 및 알람 설정 : 로그 비용 및 모니터링 효율화 Auto Scaling 정책 설정 : CPU/메모리 기반으로 태스크 수 자동 조정


AWS ECS로 만드는 무중단 인프라: OIDC, CD, 로드밸런싱까지
AWS
ECS
+4

NestJS DI의 모든 것, 내부 코드 분석 및 디자인 패턴에서의 활용

들어가며 의존성 주입(DI, Dependency Injection)은 모던 백엔드 프레임워크의 핵심 설계 철학이자 아키텍처의 근간을 이루는 중요한 개념입니다. 이번 글에서는 NestJS 프레임워크의 DI를 프레임워크 코드를 파헤쳐가며 심도 있게 분석하며, 프레임워크 기능들을 우아하게 사용하기 위해 필요한 여러 인사이트를 도출합니다. 글에선 NestJS 패키지 내부의 코드가 부분부분 발췌될 예정입니다. 글 읽으시면서 등장하는 코드는 대충 읽지 마시고 꼭 천천히 읽어보시길 바랍니다. 아무래도 프레임워크 코드다 보니 설계가 되게 깔끔하게 되어있어 읽는 것 만으로도 꽤나 인사이트를 줄 수 있을 겁니다. 마찬가지로 코드 읽으시면서 따라오는게 이 글을 통해 얻어가는 것이 더 많을 것임을 보장합니다. NestJS DI의 내부 구조와 활용 흔히 DI라고 하면 사용자 입장에선 아래 처럼 생각합니다. Injectable이 붙은 class를 Singletone(혹은 scope에 맞게) 프레임워크가 알아서 주입해주는 것 실제로 동작을 보면 NestJS는 TypeScript의 Reflect Metadata API와 데코레이터를 통해 DI를 구현합니다. Reflect Metadata API : 클래스와 생성자에 메타데이터를 정의하여 런타임에 인스턴스를 동적으로 주입합니다. DI 컨테이너 : NestJS가 제공하는 컨테이너는 프로바이더의 라이프사이클을 관리하며, 모듈 컴파일 시점에 프로바이더를 등록하고, 런타임 시에 요청에 따라 인스턴스를 생성 및 반환합니다. 한번 내부 구조를 뜯어보겠습니다. Provider와 @Injectable 데코레이터 Injectable() 데코레이터 코드의 주석 요약하면 @Injectable은 Provider로 만들어주는 데코레이터이며, Nest에서 Provider란 DI 가능한 요소로 모듈에 들어 있어야 DI가 가능하다 정도로 정의됩니다. 왜 그럴까, 그리고 어떻게 동작할까. @Injectable() 의 구현부 코드를 보겠습니다. Injectable 데코레이터의 기능입니다. 굉장히 심플한데, Reflect를 통해 class의 메타데이터를 마킹합니다. 이 마킹을 어떻게 활용하고 있을까 찾아보겠습니다. Nest Factory - initialize()&#x20; NestJS의 app을 만들어주는 nest-factory 클래스의 initialize() 메서드입니다.&#x20; injector, instanceLoader, metadataScanner, dependencyScanner를 차례로 생성합니다. 같은 메서드의 좀 더 아래를 보겠습니다. scan ➡ createInstance ➡ apply 순으로 진행합니다. 하나하나 주요한 기능들인데, 먼저 Scanner의 scan 메서드를 보겠습니다. Scanner 의존성 스캔하는 부분(scan Modules For Dependencies)만 좀 더 보면, reflect 관련 메서드들을 호출하고 있습니다. @Module에서 자주보던 imports, providers, controllers, exports들을 확인할 수 있습니다. reflect메서드의 기능은 모두 유사하여 하나만 꼽아 보겠습니다. @Injectable()로 코드에서 마킹해둔 metaData를 읽고 있습니다. 이를 통해 스캐너의 기능은 @Module, @Controller, @Injectable 등 데코레이터가 붙어있는 코드를 그래프 탐색하듯 순회하여 의존하고 있는 클래스들을 체크하는 것이라 볼 수 있습니다. insertProvider() 부분이 중요한데, Provider 개념과 같이 보겠습니다. NestJS의 Provider 통상적인 케이스providers: \[CatsService] 의 경우 바로 container에 등록합니다. 다만 CustomProvider의 경우 별도의 처리를 하는데 자세히 보겠습니다.&#x20; Custom Provider | 종류 | 예시 | 설명 | | -------------- | ---------------------------------------------------------- | ---------------------------------------------- | | 일반 Provider | providers: \[UserService] | 클래스 자체를 주입 대상 등록 | | CustomProvider | provide + useClass / useValue / useFactory / useExisting | Nest에게 Token을 provide하며 “이 Token에는 이걸 써!”라고 명시 | 코드를 보면 provide가 정의된 경우를 CustomProvider 취급 하는데, 자세한 예시를 보여드리자면 아래와 같은 케이스가 커스텀 provider입니다. provide 에 아래와 같은 InjectionToken을 넣은 경우 CustomProvider입니다. Token은 Provider를 주입할 때 식별하기 위한 일종의 식별자입니다. 사용가능한 타입들을 잘 봐두시기 바랍니다. 위를 통해 DIP(의존관계 역전원칙)의 의존 관계 역전 등 여러 테크닉을 구사할 수 있습니다. 먼저 조금은 생소할 수도 있는 symbol을 보고 가겠습니다, symbol이란? Symbol은 ES6에서 도입된 원시 데이터 타입 중 하나로, 생성할 때마다 고유한 값(유일 식별자)을 생성합니다. 이로 인해 객체의 프로퍼티 키로 사용하거나, 의존성 주입 시 Provider의 토큰으로 활용되어 이름 충돌 없이 고유한 식별을 가능하게 합니다. 특히, Java Spring 스타일의 인터페이스 기반 DIP 구현에서, Symbol을 이용해 프로바이더 식별 및 관리가 쉬워져 의존성 역전을 효과적으로 구현할 수 있습니다. Provider 활용 의존관계 역전 구현 Interface 기반 DIP Custom Provider과 Symbol을 활용한 Java Spring 스타일의 interface 기반 DIP 코드 예제입니다. 이렇게 심볼을 통해 provide를 해줘야하는 이유는 TS에서는 interface가 런타임에서 휘발되기 때문입니다. 별도로 Inject를 명시해야한다던가 용법이 굉장히 귀찮죠? 제가 조금 더 추천하는 좀 더 TS 스러운 Abstract Class 기반 DIP 예시입니다. Abstract Class 기반 DIP AbstractClass+abstract 메서드를 사용하면 런타임에도 class 정보가 남아있기 때문에 다음과 같은 Inject 토큰 명시 없이 사용할 수 있습니다. Instance Loader 다시 initialize()로 돌아와서, Instance Loader 쪽을 보겠습니다. Scanner쪽과 비슷한 구조입니다. 마찬가지로 하나만 보겠습니다. 주입에 관한 상세 로직은 Injector 쪽에 들어있습니다. Injector 이 Injector 클래스는 NestJS의 내부 의존성 주입 시스템(DI Container) 의 핵심 구현체로, 클래스 인스턴스를 생성하고 의존성을 주입하는 역할을 담당합니다. NestJS 프레임워크에서 컴포넌트, 서비스, 컨트롤러, 미들웨어 등의 객체를 생성하고 필요한 의존성을 제공하는 핵심 로직입니다. loadInstance() 메서드로 주어진 InstanceWrapper에 해당하는 인스턴스를 실제로 생성하고 의존성 주입을 수행합니다. InstanceWrapper과 Injector의 기능을 정리하고 가겠습니다. | 클래스 | 설명 | | --------------- | ---------------------------------- | | Injector | 의존성 인스턴스를 생성하고 주입하는 책임 | | InstanceWrapper | 생성된 인스턴스의 스코프/컨텍스트 상태를 추적하는 래퍼 | 핵심 메서드인 만큼 코드가 되게 깁니다. 기능을 요약하자면, 초기 contextId 기반 인스턴스 조회 pending 상태(순환 참조 등) 체크 settlementSignal을 통한 비동기 완료 핸들링 생성자 의존성(resolveConstructorParams)과 프로퍼티 의존성(resolveProperties) resolve instantiateClass() 호출로 인스턴스 실제 생성 한부분 한부분 쪼개보면 도출할 수 있는 인사이트가 되게 많은데, 제일 초반 부분에 스코프와 관련된 핵심 처리가 등장합니다. NestJS의 Scope와 싱글톤 원리 Nest는 스코프(Scope) 에 따라 인스턴스를 다르게 보관합니다. 그리고 의존성을 생성할 때 항상 contextId를 기준으로 인스턴스를 가져옵니다. | 스코프 | 보관 위치 | 컨텍스트 | | --------- | ---------------- | ---------------- | | DEFAULT | 싱글톤 | STATIC\_CONTEXT | | REQUEST | 요청마다 새로 생성 | 요청의 contextId | | TRANSIENT | 의존한 인스턴스마다 새로 생성 | inquirerId 기반 분기 | 가져오는 쪽 코드를 보면, const instancePerContext = this.values.get(contextId)이 부분에서 contextId 기반으로 인스턴스를 가져오는데, 싱글톤의 경우 STATIC\_CONTEXT라는 별도의 전용 contextId가 존재해서 같은 로직으로 Request Scope와 Default Scope를 같이 처리합니다. STATIC\_CONTEXT 정의 부분 또한 InstanceWrapper의 프로퍼트 부분을 보면 WeakMap으로 contextId에 해당하는 인스턴스를 저장해놓고 있습니다. STATIC\_CONTEXT에 해당하는 인스턴스 또한 이렇게 싱글톤으로 관리되고 있는 걸 알 수 있습니다. 마찬가지로 RequestScope 별 1개의 인스턴스를 유지하는 방식도 Wrapper를 통해 Map으로 관리되기에 가능하였습니다. 정리하자면 Default 스코프: 항상 STATIC\_CONTEXT 사용 → 싱글톤 Request 스코프: contextId 별로 따로 저장됨 Transient 스코프: contextId + inquirerId 조합으로 저장됨 스코프 + 컨텍스트 조합으로 인스턴스를 구분하여 반환하는 기능을 수행합니다.&#x20; Resolve와 Context-Aware 이후 플로우는 생성자 리졸브 ➡ (콜백함수 실행) ➡ 프로퍼티 리졸브 ➡ 인스턴스 생성 으로 이어집니다. Resolve 라는 개념 또한 핵심인데, NestJS DI 시스템에서 필요할 때마다(즉, 런타임에) 해당 의존성을 가져온다는 의미입니다. 가령 Request 스코프의 인스턴스가 존재하면, NestJS는 요청 단위로 새로운 인스턴스를 생성하고 그에 맞는 인스턴스를 주입합니다. 그렇다면 싱글톤 인스턴스가 Request 스코프의 인스턴스를 의존성으로 지닌다면 어떻게 될까요? 지금까지 본 코드에 따르면 내부에 존재하는 Request 스코프의 인스턴스가 요청단위로 런타임에 생성되어 싱글톤에 주입될 것이라 예상할 수 있습니다. 다만 문제는 싱글톤 인스턴스는 말 그대로 한번만 생성되어야 하는데, 이렇게 인스턴스 내부에 의존성을 매 요청마다 주입해야 하는 경우, 소비자인 싱글톤 인스턴스도 요청단위로 새로운 인스턴스가 생겨야만 Request 스코프의 인스턴스를 주입 받을 수 있지 않을까요? 일단 이후 과정을 코드로 보겠습니다. 각 파라미터를 비동기로 resolve하기 위해 resolveParam 함수를 정의합니다. 만약 해당 파라미터가 INQUIRER (즉, 요청자)라면, 이미 resolve된 부모 인스턴스를 반환합니다. Transient 스코프의 경우, 부모 inquirer가 존재하면 이를 대체하여 사용합니다. resolveSingleParam()를 호출하여, 해당 파라미터에 해당하는 InstanceWrapper를 얻고, 그 후 paramWrapper.getInstanceByContextId()를 통해 현재 contextId (즉, 요청 혹은 다른 컨텍스트)에 맞는 인스턴스를 가져옵니다. 이후 모든 의존성 resolve후 Promise.all(dependencies.map(resolveParam))를 사용해 모든 파라미터의 인스턴스를 resolve합니다. 만약 모든 의존성이 resolve되었다면, 준비된 인스턴스 배열을 callback으로 전달하여 아까 보았던 콜백 함수인 실제 인스턴스 생성(instantiateClass) 및 프로퍼티 주입(applyProperties)이 진행됩니다. 여태까지 내용을 정리해보겠습니다. | 메서드 | 역할 | | -------------------------- | ---------------------------- | | getInstanceByContextId() | 스코프 + 컨텍스트 조합으로 인스턴스 분리 | | loadInstance() | 의존성 그래프 따라 재귀적으로 인스턴스 생성 | | resolveConstructorParams() | 생성자 파라미터를 context-aware하게 주입 | Nest Injection Scopes에 대한 공식 문서 내용입니다. 스코프에 대한 설명을 해줍니다. Request Scope <-> Singleton CatsService 가 Request 스코프로 설정되어 있다면, 이를 주입받는 CatsController 도 Request 스코프로 전환 됩니다. 반면, CatsRepository 는 별도의 스코프가 지정되지 않았고, CatsService와의 의존성 체인에 직접 포함되어 있지 않으므로 기본(singleton) 스코프로 유지됩니다. Transient Scope <-> Singleton Transient로 지정된 의존성은 주입 시점마다 매번 새로운 인스턴스가 생성됩니다. 단, 이 경우에도 의존성 주입 체인 전체가 Transient가 되는 것은 아닙니다. DogsService 는 여전히 Singleton 으로 유지됩니다. Transient 한 LoggerService 는 DogsService에 주입될 때, DogsService 인스턴스 전용 LoggerService 인스턴스가 생성됩니다. 이제 동작 원리를 이해하셨을까요? contextId에 따라 매 요청마다 다른 인스턴스로 리졸브되는 메커니즘이 contextId로 그루핑되는 Request 스코프의 특성 때문에 주입받는 사용자측을 리졸브하는 시점에서 Request 스코프로 버블링 되는 것입니다. 아래와 같은 사항을 명심하시면 좋을 것 같습니다. "Request 스코프 기능의 사용에는 상위 인스턴스 생성 스코프 버블링으로 인해 굉장한 주의가 필요하다"&#x20; "Transient는 싱글톤 인스턴스를 해치지 않고 사용할 수 있다" Apply Application Providers 다시 initialize()로 돌아와 보겠습니다. applyApplicationProviders()메서드로 넘어갑니다. provider maps를 먼저 가져옵니다. APP\_INTERCEPTOR, APP\_PIPE, APP\_GUARD, APP\_FILTER 로 등록된 프로바이더들은 글로벌 컨텍스트에 등록됩니다. main.ts에서 app.addGlobalXXX()를 수행하는 것이나 모듈 프로바이더에서 {provide: APP\_FILTER, useClass: XXXFilter} 로 등록하는 것이나 동일 로직으로 동작함을 알 수 있습니다. 이후 graphInspector에 등록을 해줍니다. 이 부분을 타고타고 가면&#x20; 이런식으로 SerializedGraph에서 관리되는 모습. &#x20; (NestJS에서는 Guards, Interceptors, Pipes, Filters를 Enhancher라고 정의합니다.) Initialize()가 끝났습니다. 드디어 bootstrap()함수에 쓰는 app을 반환받습니다. &#x20;이후 factory에서 열심히 만든 모듈이 담긴 container, applicationConfig 등을 app으로 넘겨주고, app.init() 에서 실제 nestApp에 resolve됩니다.


NestJS DI의 모든 것, 내부 코드 분석 및 디자인 패턴에서의 활용
NestJS
DI
+1

OAuth provider 별 고려해야 할 점 - 네이버(NAVER)

시작하며 자체 로그인에 비해 간편하고 책임이 덜하며, 좋은 UX도 지닌 OAuth는 최근 소프트웨어 생태계에서 많이 사랑 받고 있는데요! 이를 활용하면 간편하게 회원가입 기능을 만들 수 있을 뿐더러 일부 정보를 제공 받아 온보딩 스탭을 건너뛴다던가, 약관 동의도 원클릭으로 편리하게 진행할 수 있죠. 다만, 실무에서 사용하다 보면 OAuth 제공자별로 각각의 구현 방식 및 제공해주는 API 기능들이 천차만별이라 OAuth 의존적인 로그인 로직을 만들다보면 여러 문제가 발생할 수 있습니다. 따라 OAuth를 새로 도입한다면 도입 전에 신중하게 고려해야 하지만 직접 연동해보기 전까진 이 OAuth가 우리 서비스에 맞는 지 알 턱이 없죠. 이 글에서는 특히 네이버 OAuth를 도입할 경우 생길 수 있는 매우 디테일한, 뚜껑 열기 전까지는 모를 수 있는 문제상황들을 나열합니다.&#x20; 로그인 개인정보 필수 동의를 하더라도 정보가 비어있을 수 있음 온보딩 플로우 간소화를 위해서 필요한 정보를 필수 동의 항목으로 넣는 경우가 많습니다. 다만, 네이버 계정 자체에 해당 정보가 기입되지 않았을 경우 동의해도 빈 값이 올 수 있습니다. 휴대전화번호 등 가입 시 당연히 받을 것으로 예측되는 정보들도 비어있는 경우가 꽤 많습니다. (전화번호 기준 대략 1000개의 계정 중 4~5개 내외로 파악됩니다.) 따라 네이버를 통해 제공받는 모든 정보는 비어있을 수 있음을 가정하고 별도의 온보딩 플로우를 제작해야 합니다. API 호출을 위해 접근 토큰 관리 필요 네이버는 명시적으로 유저가 로그인 버튼을 눌러서, naver 로그인을 수행한 경우 해당 유저에 대한 정보 조회 api등을 호출 할 수 있는 인증 정보가 담긴 access\_token과 access\_token을 재발급 받을 수 있는 refresh\_token을 줍니다. 이 토큰들이 있어야 해당 유저의 회원 정보 조회, 약관 동의 내역 조회 등을 호출할 수 있습니다. 문제는 이 refresh\_token의 만료 기한이 1년이라는 건데, 1년이 지나기 전에 명시적으로 갱신을 시키거나, 재취득하지 못한다면 naver api를 사용할 수가 없습니다. 다행히 갱신 시를 기점으로 refresh\_token의 유효기간이 연장되기 때문에, 한번 취득한 refresh\_token을 재 때 갱신만 시켜준다면 무한 갱신은 가능합니다. 따라 아래 3가지 중 하나의 전략을 선택해야 합니다. 최초 로그인 시 제외하고 naver api를 사용하지 않기 (token 자체를 쓸 일 없게 설계) 보통의 로그인대행은 oauth provider에게 unique\_id 취득 정도만 하고 이를 트리거로 자체 jwt를 발행합니다. 이 이상의 서비스(약관동의 대행, 회원 탈퇴 시 oauth 채널 연결 끊기 등)를 사용하지 않는다면 토큰 관리를 하지 않아도 됩니다. 서버에서 refresh\_token의 만료 기한을 보유하고 있고, 1년이 다되어가는 토큰들을 스케줄러를 통해 갱신 시키기 특정 주기로 데이터베이스 내의 토큰들을 스캔하여 만료가 임박한 토큰들은 재발급 요청을 보내는 방식입니다. 다만 로직 상 각각 refresh api를 호출해야 하여 조금 무거워질 수 있습니다. refresh\_token이 만료된 경우 강제로 재로그인 시켜 버리기 만약 naver api를 사용해야하는 시점에서 토큰이 만료됐을 경우 유저를 로그아웃 시키고, 다시 네이버 로그인 버튼을 누르게 해 refresh\_token을 재취득 하는 방법입니다. 다만 ux적으로 굉장히 이상한 그림이 나옵니다. 가령 회원탈퇴를 눌렀는데 refresh\_token이 만료된 경우 탈퇴는 안되고 로그아웃이 되버립니다. 유저는 재로그인 한 후 탈퇴를 다시 눌러야 정상 탈퇴됩니다. 약관동의대행 네이버 유저의 약관 동의를 직접 해줄 수 없음 naver 약관동의대행은 말그대로 대행입니다. 약관에 관련된 전적인 요소를 네이버에서 책임지고 대신 행해준다는 것이고, 반대로 말하면 네이버 유저에 대해서는 자체 서비스가 지닌 약관 동의에 대한 책임을 네이버에 위임하여 잃게 됩니다. 이러한 상황이 왜 일어나는 지 좀 더 구체적으로 말씀드리자면, 만약 이렇게 자체적 약관 동의 체계를 가지고 있다면, 유저의 약관동의에 대한 정보가 이미 수집되어 쌓여있거나, 서비스 내에서 약관을 재동의하는 플로우가 존재할 것 입니다. 이 때 만약 유저가 마케팅 수신 정보를 비동의 했다고 했을 때, 네이버 측에서 관리하는, 네이버 로그인 시 유저가 동의한 약관 내용을 직접 동기화 할 수 없습니다. 네이버 로그인을 하면 위와 같이 약관 동의 입력이 뜰 테고, 유저의 접근 토큰을 활용해 약관 조회를 할 경우 아래와 같은 응답을 받을 수 있습니다. 이 값을 기반으로 서비스 내부에서 관리 중인 유저별 약관 동의 db에 동기화를 함으로써 약관동의 대행이 이루어 질 것입니다. 다만 이때, 유저가 다른 채널을 통해 약관을 동의 했을 경우 네이버 측에 전달해 동의를 했음을 알릴 방법이 존재하지 않습니다. 결국 두 선택지가 남게 되는데 각각 단점이 존재합니다. 네이버 로그인한 유저의 경우 다른 채널 없이 무조건 네이버를 통해야만 약관을 동의할 수 있게 설계한다. 이렇게 할 경우 oauth 다연결 정책은 사용할 수 없습니다. 한 계정에 여러 인증 채널이 들어가게 되면 로그인이 네이버를 통하지 않더라도 될 것이고, 네이버를 통한 약관 동의를 플로우적으로 담보하기 힘들기 때문이죠. 또한 서비스 내부에서 약관 관리 로직을 넣는 경우 굉장히 세심히 설계 해야 합니다. 약관이 변경되어 재동의가 필요할 때, 네이버 로그인 유저에 한해서는 서비스 자체적인 약관 재동의 플로우 대신 네이버 재로그인을 통해서 약관을 받을 수 있게 연결되도록 설계해야 합니다. 다만, 네이버는 약관이 변동되어도 재로그인 시 동의 창을 다시 띄워주지 않아서 이를 식별하고 강제로 띄우게 하는 설계가 필요합니다. (이 문제는 본문의 다른 헤더에서 자세히 다룹니다.) 네이버 약관 동의는 오직 동의를 추가적으로 받는 보조 창구로 쓴다. 말 그대로 네이버 측에서는 동의한 약관만 받아서 서비스에 반영하는 방식입니다. 네이버 로그인 시 동의하지 않아도, 동의하지 않았다는 것을 반영하진 않습니다. 잠재된 문제가 엄청 많이 생길 수 있어 추천하진 않습니다. 가령 사용자가 서비스에서 약관에 동의했지만, 네이버에서 다시 약관 동의 팝업이 뜨면서 동의를 해제하는 경우, 법적으로 사용자 동의 상태가 불분명 해짐. 네이버 약관 동의를 참고하지 않는다면, 서비스 내부 약관 동의 기록과 네이버 동의 기록이 다를 수 있음. 개인정보 처리 방침 변경 시 네이버에서 동의 해제했더라도 서비스에서는 반영되지 않으면 문제가 됨 동의 항목이 바뀌어도 자동으로 재동의 창이 뜨지 않음 2025.03.06 기준으로 달라질 수 있습니다. 기존에 로그인을 한 유저가 다시 로그인을 했을 때, 약관 동의 항목이 달라진 경우에 한해, 동의 창이 자동으로 뜨지 않고 바로 넘어갑니다. (개인정보 제공이 바뀐 건 다시 뜹니다) 상식적으로는 동의 항목이 바뀌면 정보동의 창이 재노출 되는 게 맞는 것 같습니다만, 약관동의대행에 한해서는 다시 트리거 해주지 않더라고요. 따라서 해당 동의항목 창을 강제로 트리거 해서 띄울 필요가 있는데 auth\_type 을 ‘reprompt’로 로그인 api를 재호출한 경우 인증요청을 다시 받을 수 있습니다. 서버에서 약관 동의 변동 사항이 존재하고, 접속한 유저가 동의하지 않은 경우 해당 페이지로 강제 리다이렉트 시키는 식으로 해서 동의를 받을 수 있습니다.


OAuth provider 별 고려해야 할 점 - 네이버(NAVER)
인증
인가
+1