CS 면접 바이블: Chapter 1

정지인·2026년 8월 5일
post-thumbnail

이 정리는 나중에 내가 확인하기 위한 정리이다.

그래서 오로지 나를 위해서 내가 알아볼 수 있도록 정리할 것이며, 내가 이해하기 쉬운 방법으로 정리해보려고한다.

내가 그렇게 정리에 능통하지 않아서, 내가 먼저 정리한 것을 AI의 도움을 받아 첨삭할 예정이다.

다만 모든 글쓰기를 AI에게 맡기는 것이 아닌, 무조건 내가 먼저 정리한 후에 그 정리본을 AI를 통해 좀 더 보기 쉽게 정리할 예정이다.


디자인 패턴과 프로그래밍 패러다임

라이브러리프레임워크를 제대로 사용하려면 그 기반이 되는 디자인 패턴프로그래밍 패러다임을 이해할 필요가 있다.

프로그래밍 패러다임

  • 프로그램의 데이터와 로직을 어떤 관점에서 구성할 것인지에 대한 전반적인 사고방식

디자인 패턴

  • 프로그램을 설계하면서 반복적으로 발생하는 문제를 해결하기 위해 정리해놓은 재사용 가능한 설계 방법

라이브러리

  • 라이브러리는 특정 기능을 미리 구현해놓은 코드의 모음이다.
  • 일종의 공구함이라고 생각하면 된다. 개발자가 기능이 필요한 시점에 라이브러리를 직접 호출하여 사용한다.
  • 예시는 다음과 같다.
날짜 및 시간 처리
수학 계산
HTTP 통신
JSON 변환
파일 처리
  • Java에서는 java.time, Math, Jackson 등이 라이브러리 또는 API의 예시에 해당한다.
int result = Math.max(10, 20);
  • 위 코드에서는 개발자가 필요한 시점에 Math.max()를 직접 호출한다.

라이브러리에서는 개발자가 전체 실행 흐름을 관리하고, 필요한 기능을 직접 호출한다.

프레임워크

  • 프레임워크는 프로그램을 만들기 위한 전체적인 구조와 실행 흐름을 제공한다.
  • 건물의 골조뿐만 아니라 건축 순서와 작업 규칙까지 어느 정도 정해놓은 시스템이라고 생각하면 된다.
  • 프레임워크는 일반적으로 다음과 같은 내용을 제공한다.
  • 프로그램의 전체 실행 구조
코드가 작성될 위치와 형식
객체의 생성과 관리
요청 및 응답 처리
데이터베이스 접근 방식
보안과 예외 처리
각 기능이 실행되는 순서
  • 개발자는 프레임워크가 정한 구조와 규칙에 맞추어 필요한 코드를 작성한다.
  • 예를 들어 Spring에서는 개발자가 컨트롤러 메서드를 작성해두면, HTTP 요청이 들어왔을 때 Spring이 해당 메서드를 찾아 실행한다.
@GetMapping("/users")
public List<User> getUsers() {
    return userService.findAll();
}
  • 이처럼 프레임워크가 전체 실행 흐름을 관리하고 개발자의 코드를 호출하는 것을 제어의 역전이라고 한다.

라이브러리는 내가 호출하지만, 프레임워크는 내 코드를 호출한다.


1.1 디자인 패턴

디자인 패턴은 소프트웨어를 설계할 때 반복적으로 발생하는 문제를 효과적으로 해결하기 위해 정리해놓은 재사용 가능한 설계 방법이다.

디자인 패턴은 완성된 코드나 반드시 지켜야 하는 문법이 아니다. 특정 문제를 해결하기 위해 객체에 어떤 역할을 부여하고, 객체 사이의 관계를 어떻게 구성할 것인지 설명하는 설계 아이디어에 가깝다.

일반적으로 디자인 패턴에는 다음 내용이 포함된다.

어떤 상황에서 발생하는 문제인지
어떤 객체가 필요한지
객체들이 어떤 역할을 담당하는지
객체들이 어떻게 상호작용하는지
해당 구조의 장점과 단점은 무엇인지

예를 들어 싱글턴 패턴은 프로그램 전체에서 하나의 객체만 존재해야 하는 문제를 해결하기 위한 설계 방법이다.

즉, 디자인 패턴은 과거의 개발자들이 반복적으로 경험한 설계 문제와 해결 방법을 정리해놓은 일종의 설계 참고서라고 볼 수 있다.

1.1.1 싱글톤 패턴

싱글톤 패턴은 특정 클래스의 인스턴스가 애플리케이션 전체에서 단 하나만 생성되도록 제한하고, 필요한 곳에서 동일한 인스턴스를 공유하도록 만드는 디자인 패턴이다.

일반적인 클래스는 하나의 클래스를 기반으로 여러 인스턴스를 생성할 수 있다.

const user1 = new User(); 
const user2 = new User();

이 경우 user1과 user2는 같은 클래스로 생성되었지만 서로 다른 인스턴스이다.

반면 싱글톤 패턴에서는 클래스를 여러 번 호출하더라도 새로운 인스턴스를 계속 생성하지 않고, 처음 만들어진 인스턴스를 반환한다.

싱글톤 패턴은 다음과 같이 생성 비용이 크거나 애플리케이션 전체에서 동일한 상태를 공유해야 하는 객체에 주로 사용된다.

  • 데이터베이스 커넥션 풀
  • 애플리케이션 설정 객체
  • 로거
  • 캐시 관리자
  • 외부 API 클라이언트

데이터베이스의 경우 실제로는 하나의 데이터베이스 연결만 공유하기보다, 여러 연결을 관리하는 커넥션 풀 객체를 하나 생성하여 공유하는 경우가 많다.

장점

  • 애플리케이션 전체에서 동일한 인스턴스 공유
  • 불필요한 객체 중복 생성 방지
  • 생성 비용이 큰 객체의 초기화 비용 절감
  • 공통 상태나 자원의 일관된 관리

단점

  • 여러 모듈이 하나의 객체에 강하게 의존
  • 전역 상태로 사용될 가능성
  • 한 곳의 상태 변경이 다른 코드에 영향을 줄 가능성
  • 단위 테스트 시 가짜 객체로 대체하기 어려움
  • 객체 사이의 의존 관계가 명확하게 드러나지 않을 수 있음

즉, 싱글톤 패턴은 객체를 하나만 만든다는 장점이 있지만, 지나치게 사용하면 코드 사이의 결합도가 높아질 수 있다.


자바스크립트 객체의 비교

자바스크립트에서 객체는 내부 값이 같더라도 각각 별도의 메모리 공간에 생성된다.

const obj1 = {
    a: 27
};

const obj2 = {
    a: 27
};

console.log(obj1 === obj2); // false

obj1과 obj2는 내부 값은 같지만 서로 다른 위치에 생성된 별개의 객체이므로 false가 출력된다.

반면 같은 객체를 다른 변수에 할당하면 두 변수는 동일한 객체를 참조한다.

const obj1 = { a: 27 }; 
const obj2 = obj1; 

console.log(obj1 === obj2); // true

자바스크립트의 싱글톤 패턴

싱글톤 클래스는 인스턴스가 아직 존재하지 않을 때만 새로운 인스턴스를 생성하고, 이미 인스턴스가 존재한다면 기존 인스턴스를 반환하도록 구성할 수 있다.

class Singleton {
	constructor() { 
    	if (Singleton.instance) { 
        	return Singleton.instance; 
        } 
        
        Singleton.instance = this; 
    } 
} 

const a = new Singleton(); 
const b = new Singleton(); 

console.log(a === b); // true

동작 과정은 다음과 같다.

1. new Singleton()을 처음 호출
2. Singleton.instance가 존재하지 않으므로 새로운 인스턴스 생성
3. 생성된 인스턴스를 Singleton.instance에 저장
4. 두 번째로 new Singleton() 호출
5. 이미 저장된 Singleton.instance 반환
6. a와 b가 동일한 인스턴스를 참조

다만 생성자를 계속 호출하는 방식보다, 인스턴스를 가져오는 정적 메서드를 별도로 두는 방식이 의도를 더 명확하게 표현할 수 있다.

class Singleton { 
	static instance; 
    
    static getInstance() { 
    	if (!Singleton.instance) { 
        	Singleton.instance = new Singleton(); 
        }
        
        return Singleton.instance; 
    } 
} 

const a = Singleton.getInstance(); 
const b = Singleton.getInstance(); 

console.log(a === b); // true

이 코드에서는 getInstance()가 다음 역할을 한다.

  • 기존 인스턴스가 없으면 새로운 인스턴스 생성
  • 기존 인스턴스가 있으면 해당 인스턴스 반환
  • 호출하는 모든 코드에 동일한 인스턴스 제공

따라서 싱글톤 패턴의 핵심은 단순히 객체 하나를 만드는 것이 아니라, 인스턴스 생성을 통제하고 동일한 인스턴스에 접근하도록 만드는 것이다.


데이터베이스 연결 모듈에서의 싱글톤 패턴

데이터베이스 연결 객체는 생성 비용이 크거나 애플리케이션 전체에서 공유해야 하는 경우가 많다.

따라서 데이터베이스 연결을 관리하는 객체를 매번 새롭게 생성하지 않고, 하나의 객체를 생성한 뒤 여러 모듈에서 공유하도록 싱글톤 패턴을 적용할 수 있다.

const URL = "mongodb://localhost:27017/kundolapp";

const createConnection = (url) => ({
    url,
    connected: true
});

class DB {
    constructor(url) {
        if (!DB.instance) {
            DB.instance = createConnection(url);
        }

        return DB.instance;
    }
}

const a = new DB(URL);
const b = new DB(URL);

console.log(a === b); // true

동작 과정은 다음과 같다.

  1. new DB(URL)을 처음 호출한다.
  2. DB.instance가 존재하지 않으므로 데이터베이스 연결 객체를 생성한다.
  3. 생성된 연결 객체를 DB.instance에 저장한다.
  4. 두 번째로 new DB(URL)을 호출한다.
  5. 이미 생성된 DB.instance를 반환한다.
  6. 따라서 ab는 동일한 객체를 참조한다.
console.log(a === b); // true

즉, DB.instance에 저장된 하나의 데이터베이스 연결 객체를 ab가 함께 사용한다.

이를 통해 데이터베이스 연결 객체를 불필요하게 여러 번 생성하는 것을 방지할 수 있다.

다만 위 코드에서 DB.instanceDB 클래스의 인스턴스가 아니라 createConnection()이 반환한 연결 객체이다.

자바스크립트 생성자에서 객체를 명시적으로 반환하면 새롭게 생성된 this 대신 반환한 객체가 new 표현식의 결과가 되기 때문이다.

따라서 다음과 같은 DB 클래스의 메서드는 사용할 수 없다.

class DB {
    constructor(url) {
        if (!DB.instance) {
            DB.instance = createConnection(url);
        }

        return DB.instance;
    }

    connect() {
        return DB.instance;
    }
}

new DB()의 결과가 DB 객체가 아니라 연결 객체이므로 connect() 메서드를 가지고 있지 않기 때문이다.

const db = new DB(URL);

db.connect();
// TypeError: db.connect is not a function

DB 클래스 자체를 하나의 인스턴스로 유지하고 싶다면 다음과 같이 작성할 수 있다.

const URL = "mongodb://localhost:27017/kundolapp";

const createConnection = (url) => ({
    url,
    connected: true
});

class DB {
    constructor(url) {
        if (DB.instance) {
            return DB.instance;
        }

        this.connection = createConnection(url);
        DB.instance = this;
    }

    connect() {
        return this.connection;
    }
}

const a = new DB(URL);
const b = new DB(URL);

console.log(a === b); // true
console.log(a.connect() === b.connect()); // true

이 구조에서는 다음 두 객체가 구분된다.

  • DB.instance: 데이터베이스 연결을 관리하는 DB 클래스의 인스턴스
  • this.connection: 실제 데이터베이스 연결 객체

따라서 ab는 동일한 DB 인스턴스를 가리키며, connect()를 통해 동일한 연결 객체에 접근한다.

실제 서버에서는 데이터베이스 연결 하나만 공유하기보다, 여러 연결을 관리하는 데이터베이스 클라이언트 또는 커넥션 풀 객체를 하나 생성하여 공유하는 경우가 일반적이다.


자바에서의 싱글톤 패턴

자바에서는 중첩 정적 클래스를 활용하여 싱글톤을 구현할 수 있다.

이를 일반적으로 정적 내부 클래스 방식 또는 Initialization-on-demand holder idiom이라고 부른다.

class Singleton {

    private Singleton() {
    }

    private static class SingletonInstanceHolder {
        private static final Singleton INSTANCE = new Singleton();
    }

    public static Singleton getInstance() {
        return SingletonInstanceHolder.INSTANCE;
    }
}

public class HelloWorld {

    public static void main(String[] args) {
        Singleton a = Singleton.getInstance();
        Singleton b = Singleton.getInstance();

        System.out.println(a == b); // true
    }
}

1. 생성자를 private으로 선언

private Singleton() {
}

클래스 외부에서 다음과 같이 직접 인스턴스를 생성하지 못하도록 제한한다.

Singleton singleton = new Singleton();
// 컴파일 오류

생성자를 제한하지 않으면 외부에서 새로운 인스턴스를 계속 생성할 수 있으므로 싱글톤 패턴이 성립하지 않는다.

2. 정적 내부 클래스에서 인스턴스 생성

private static class SingletonInstanceHolder {
    private static final Singleton INSTANCE = new Singleton();
}

SingletonInstanceHolder 클래스는 getInstance()가 처음 호출되어 실제로 사용되는 시점에 초기화된다.

따라서 애플리케이션이 시작되자마자 싱글톤 인스턴스를 생성하지 않고, 필요한 시점에 생성하는 지연 초기화가 가능하다.

3. getInstance()를 통해 인스턴스 반환

public static Singleton getInstance() {
    return SingletonInstanceHolder.INSTANCE;
}

외부에서는 생성자를 직접 호출하지 않고 getInstance()를 사용하여 인스턴스를 가져온다.

Singleton a = Singleton.getInstance();
Singleton b = Singleton.getInstance();

두 변수에는 동일한 인스턴스가 반환된다.

System.out.println(a == b); // true

자바에서 객체에 == 연산자를 사용하면 객체 내부의 값을 비교하는 것이 아니라 두 변수가 같은 객체를 참조하는지 비교한다.

따라서 결과가 true라는 것은 ab가 메모리상 동일한 Singleton 인스턴스를 참조한다는 뜻이다.


정적 내부 클래스 방식의 장점

  • 인스턴스가 필요한 시점에 생성되는 지연 초기화 지원
  • 클래스 초기화 과정이 JVM에 의해 안전하게 처리됨
  • 별도의 synchronized 없이 스레드 안전성 확보
  • 구현이 비교적 간단함

즉, 여러 스레드가 동시에 getInstance()를 호출하더라도 JVM의 클래스 초기화 규칙에 따라 INSTANCE는 한 번만 생성된다.

자바스크립트와 자바 구현 모두 다음 원리를 따른다.

  1. 외부에서 인스턴스가 무분별하게 생성되지 않도록 통제한다.
  2. 최초 요청 시 인스턴스를 하나 생성한다.
  3. 생성한 인스턴스를 별도의 정적 공간에 저장한다.
  4. 이후 요청에는 새 객체를 생성하지 않고 기존 인스턴스를 반환한다.

싱글톤 패턴의 핵심은 단순히 객체 하나를 만드는 것이 아니라, 객체의 생성 경로를 통제하여 항상 동일한 인스턴스를 사용하도록 만드는 것이다.


Mongoose의 싱글톤 패턴

싱글톤 패턴은 Node.js에서 MongoDB를 연결할 때 사용하는 Mongoose에서도 찾아볼 수 있다.

다만 정확히 말하면,

mongoose.connect()가 싱글톤 인스턴스를 새롭게 만들어 직접 반환하는 것이 아니라, Node.js에서 공유되는 하나의 Mongoose 객체가 기본 데이터베이스 연결을 관리하는 구조이다.


Mongoose 객체가 공유되는 과정

Node.js에서 다음과 같이 Mongoose 모듈을 불러올 수 있다.

const mongoose = require("mongoose");

Node.js의 CommonJS 모듈은 처음 불러온 뒤 캐시에 저장된다. 따라서 동일한 경로의 모듈을 다시 require()하면 모듈이 새롭게 실행되는 것이 아니라, 기존에 반환했던 것과 동일한 객체가 반환된다.

const mongooseA = require("mongoose");
const mongooseB = require("mongoose");

console.log(mongooseA === mongooseB); // true

따라서 애플리케이션의 여러 파일에서 Mongoose를 불러오더라도 일반적으로 동일한 Mongoose 객체를 공유한다.

userService.js ── require("mongoose") ──┐
                                       │
orderService.js ─ require("mongoose") ──┼── 동일한 Mongoose 객체
                                       │
app.js ───────── require("mongoose") ───┘

이러한 구조가 싱글톤 패턴과 유사한 효과를 만든다.

단, 프로젝트에 Mongoose가 중복으로 설치되어 서로 다른 경로에서 불러와지는 등의 특수한 상황에서는 서로 다른 객체가 반환될 수도 있다.


mongoose.connect()의 역할

Mongoose에서 MongoDB에 연결할 때는 다음과 같이 connect()를 사용한다.

const mongoose = require("mongoose");

const uri = "mongodb://127.0.0.1:27017/kundolapp";

await mongoose.connect(uri);

mongoose.connect()는 Mongoose가 가지고 있는 기본 연결 객체를 사용하여 MongoDB에 연결한다. 연결 상태는 다음 객체를 통해 확인할 수 있다.

mongoose.connection;

Mongoose 공식 문서에서는 mongoose.connect()가 기본 Mongoose 연결을 열며, 반환된 Promise가 연결에 성공하면 기존 Mongoose 객체로 이행된다고 설명한다.

따라서 다음과 같이 이해해야 한다.

const result = await mongoose.connect(uri);

console.log(result === mongoose); // true

여기서 connect()가 호출될 때마다 새로운 싱글톤 객체를 만드는 것이 아니다.

이미 공유되고 있는 mongoose 객체가 자신의 기본 연결인 mongoose.connection을 이용하여 MongoDB에 연결하고, 연결이 끝나면 자기 자신인 Mongoose 객체를 반환하는 구조이다.

mongoose
 ├── connect(uri)
 ├── connection       ← 기본 데이터베이스 연결
 ├── model()
 ├── Schema
 └── 기타 Mongoose 기능

즉, Mongoose에서 싱글톤처럼 공유되는 핵심 대상은 다음과 같다.

  • Node.js 모듈 시스템을 통해 공유되는 기본 mongoose 객체
  • 해당 객체가 관리하는 기본 연결인 mongoose.connection
  • 기본 Mongoose 객체에 등록된 모델과 스키마

URI란?

mongoose.connect()의 첫 번째 매개변수 이름은 보통 uri이다.

mongoose.connect(uri);

URI는 Uniform Resource Identifier의 약자로, 특정 자원을 식별하기 위한 문자열이다.

쉽게 말하면 다음과 같다.

URI는 어떤 자원을 구분하고 가리키기 위한 전체적인 식별자이다.

RFC 3986에서는 URI를 추상적 또는 물리적 자원을 식별하는 문자들의 집합으로 정의한다.

MongoDB에서는 다음과 같은 연결 문자열이 URI에 해당한다.

mongodb://127.0.0.1:27017/kundolapp

MongoDB 공식 문서에서도 이러한 문자열을 MongoDB 연결 URI 또는 Connection String이라고 표현한다.


MongoDB URI의 구조

mongodb://127.0.0.1:27017/kundolapp

각 부분을 나누면 다음과 같다.

mongodb://  127.0.0.1  :27017  /kundolapp
──────────  ─────────  ──────  ──────────
스킴         호스트       포트     데이터베이스

mongodb://

자원에 어떤 방식으로 접근하는지를 나타내는 스킴(Scheme)이다.

http://
https://
ftp://
mongodb://
mongodb+srv://

mongodb://는 일반적인 MongoDB 연결에 사용하는 스킴이다.

127.0.0.1

MongoDB 서버가 실행되고 있는 호스트이다.

127.0.0.1

현재 컴퓨터를 의미하는 루프백 주소이다.

27017

MongoDB가 기본적으로 사용하는 포트 번호이다.

27017

/kundolapp

접속할 기본 데이터베이스의 이름이다.

kundolapp

따라서 전체 URI는 다음 의미를 가진다.

현재 컴퓨터의 27017번 포트에서 실행 중인 MongoDB 서버에 연결하고, 기본 데이터베이스로 kundolapp을 사용한다.

MongoDB의 표준 연결 URI 형식은 다음과 같이 사용자 정보, 여러 호스트와 접속 옵션 등을 추가로 포함할 수도 있다.

mongodb://[사용자:비밀번호@]호스트[:포트]/[데이터베이스][?옵션]

예시는 다음과 같다.

mongodb://user:password@127.0.0.1:27017/kundolapp?authSource=admin

URI와 URL의 차이

URI와 URL은 비슷하지만 기준이 약간 다르다.

URI

자원을 식별하는 전체적인 개념이다.

특정 자원이 무엇인지 구분하는 식별자

URL

URL은 Uniform Resource Locator의 약자로, 자원이 어디에 있으며 어떤 방법으로 접근할 수 있는지를 나타낸다.

자원의 위치와 접근 방법을 나타내는 식별자

개념적으로는 URL이 URI에 포함된다고 이해할 수 있다.

URI
 └── URL

예를 들어 다음 주소는 웹 페이지의 위치와 접근 방법을 나타낸다.

https://example.com/users
  • https: 접근 방법
  • example.com: 서버의 위치
  • /users: 서버 내부의 자원 경로

따라서 URL이면서 URI이기도 하다.

MongoDB 연결 문자열도 서버의 위치와 접근 방법을 포함한다.

mongodb://127.0.0.1:27017/kundolapp

따라서 URL과 유사한 성격을 가지지만, MongoDB와 Mongoose에서는 공식적으로 다음 표현을 주로 사용한다.

  • MongoDB URI
  • Connection URI
  • Connection String

변수 이름은 문법적으로 자유롭게 정할 수 있다.

const url = "mongodb://127.0.0.1:27017/kundolapp";
mongoose.connect(url);

다음처럼 작성해도 동일하게 동작한다.

const connectionString =
    "mongodb://127.0.0.1:27017/kundolapp";

mongoose.connect(connectionString);

그러나 Mongoose의 함수 매개변수와 공식 문서에서 uri라는 표현을 사용하므로, 일반적으로 다음처럼 작성한다.

const uri = "mongodb://127.0.0.1:27017/kundolapp";

await mongoose.connect(uri);

실제 연결 코드

const mongoose = require("mongoose");

const MONGODB_URI =
    "mongodb://127.0.0.1:27017/kundolapp";

async function connectDB() {
    try {
        await mongoose.connect(MONGODB_URI);

        console.log("MongoDB 연결 성공");

        return mongoose.connection;
    } catch (error) {
        console.error("MongoDB 연결 실패:", error);
        throw error;
    }
}

module.exports = connectDB;

다른 파일에서는 다음과 같이 사용한다.

const connectDB = require("./connectDB");

async function startServer() {
    await connectDB();

    // 데이터베이스 연결이 완료된 뒤 서버 실행
}

startServer();

여러 파일에서 require("mongoose")를 사용하더라도 일반적인 Node.js 실행 환경에서는 동일한 Mongoose 객체와 기본 연결을 공유하게 된다.


기존 내부 코드에 대한 주의점

다음 코드는 과거 Mongoose의 내부 구현을 단순화한 코드에 가깝다.

Mongoose.prototype.connect = function(uri, options, callback) {
    // ...
};

현재 Mongoose를 설명할 때는 이 코드를 그대로 사용하는 것이 적절하지 않다.

Mongoose 7부터 Mongoose.prototype.connect()의 콜백 방식이 제거되었으며, 현재는 Promise 또는 async/await 방식으로 사용한다.

// 현재 사용 방식
await mongoose.connect(uri);

또한 기존 코드에 작성된 다음 부분은 문법상 잘못된 표현이다.

this.instanceof Mongoose

정확한 자바스크립트 문법은 다음과 같다.

this instanceof Mongoose

다만 Mongoose의 내부 소스 코드는 버전에 따라 변경될 수 있기 때문에, 내부 코드 자체를 외우기보다는 다음 흐름을 이해하는 것이 중요하다.

  1. Node.js는 한 번 불러온 CommonJS 모듈을 캐시에 저장한다.
  2. 여러 파일에서 require("mongoose")를 호출해도 일반적으로 동일한 Mongoose 객체가 반환된다.
  3. 이 Mongoose 객체가 기본 연결인 mongoose.connection을 관리한다.
  4. mongoose.connect(uri)는 기본 연결을 MongoDB에 연결한다.
  5. connect()는 현재 Promise를 반환하며, 연결 성공 시 기존 Mongoose 객체로 이행된다.
  6. 따라서 connect()가 싱글톤을 새로 생성한다기보다, 공유되는 Mongoose 객체와 기본 연결이 싱글톤처럼 사용된다고 보는 것이 정확하다.
  7. uri는 MongoDB 서버, 포트, 데이터베이스와 접속 옵션을 표현하는 연결 식별자이다.

Mongoose의 싱글톤 구조는 connect()가 객체 하나를 생성하는 구조라기보다, Node.js 모듈 캐싱을 통해 공유되는 하나의 Mongoose 객체가 기본 데이터베이스 연결을 관리하는 구조이다.


MySQL의 싱글톤 패턴

Node.js 에서 MySQL 데이터베이스를 연결할 때도 싱글톤 패턴이 쓰임

// 메인 모듈
const mysql = require('mysql');
const pool = mysql.createPool({ // 커넥션 풀 생성
  conncetionLimit: 10,
  host: 'example.org',
  user: 'kundol',
  password: 'secret',
  database: '승철이디비'
});
pool.connect();

// 모듈 A
pool.query(query, function (error, results, fields) {
  if (error) throw error;
  console.log('The solution is: ', results[0].solution);
});

싱글톤 패턴의 단점

싱글톤 패턴은 애플리케이션 전체에서 하나의 인스턴스를 공유하기 때문에 사용하기 쉽고 실용적이다.

하지만 하나의 객체와 상태를 여러 모듈이 함께 사용하기 때문에 다음과 같은 문제가 발생할 수 있다.

  • 테스트 간 상태 공유
  • 모듈 간 강한 결합
  • 의존 관계 파악의 어려움
  • 객체 교체의 어려움

TDD와 단위 테스트에서의 문제

TDD는 Test Driven Development의 약자로, 테스트 코드를 먼저 작성한 뒤 해당 테스트를 통과하도록 실제 코드를 구현하는 개발 방식이다.

TDD에서는 일반적으로 단위 테스트를 많이 작성한다.

단위 테스트는 다음 조건을 만족하는 것이 좋다.

  • 각각의 테스트가 서로 독립적으로 실행되어야 함
  • 테스트 실행 순서가 결과에 영향을 주지 않아야 함
  • 특정 테스트의 실행 결과가 다른 테스트에 영향을 주지 않아야 함
  • 테스트할 대상만 분리하여 검증할 수 있어야 함

하지만 싱글톤 객체는 애플리케이션 전체에서 하나의 인스턴스를 공유한다.

테스트 A ─┐
         ├── 동일한 싱글톤 인스턴스
테스트 B ─┘

싱글톤 객체가 내부 상태를 가지고 있다면 테스트 A에서 변경한 상태가 테스트 B에도 남아 있을 수 있다.

class Counter {
    constructor() {
        if (Counter.instance) {
            return Counter.instance;
        }

        this.count = 0;
        Counter.instance = this;
    }

    increase() {
        this.count += 1;
    }
}
const counter = new Counter();

counter.increase();

console.log(counter.count); // 1

다른 테스트에서 다시 객체를 생성해도 새로운 객체가 만들어지지 않는다.

const anotherCounter = new Counter();

console.log(anotherCounter.count); // 1

anotherCounter는 새롭게 생성된 독립적인 객체가 아니라 기존의 싱글톤 인스턴스이다.

따라서 이전 테스트에서 변경된 count 값이 그대로 남아 있을 수 있다.

이로 인해 다음과 같은 문제가 발생한다.

  • 테스트 실행 순서에 따라 결과가 달라질 수 있음
  • 테스트마다 새로운 인스턴스를 생성하기 어려움
  • 테스트가 실패했을 때 원인을 찾기 어려움
  • 테스트용 가짜 객체로 교체하기 어려움

싱글톤 패턴이 TDD 자체를 불가능하게 만드는 것은 아니다.

다만 상태를 가진 싱글톤 객체를 전역적으로 사용하면 테스트의 독립성을 유지하기 어려워질 수 있다.

싱글톤의 핵심적인 테스트 문제는 인스턴스가 하나라는 사실 자체보다, 하나의 상태와 의존성을 여러 테스트가 공유한다는 점이다.


의존성이란?

의존성은 한 객체나 모듈이 자신의 기능을 수행하기 위해 다른 객체나 모듈을 필요로 하는 관계이다.

A가 B에 의존한다는 것은 다음과 같은 의미이다.

A가 동작하기 위해 B가 필요하며, B의 구현이나 사용 방식이 변경되면 A도 영향을 받을 가능성이 있다.

예를 들어 OrderService가 직접 MySQLDatabase를 생성한다고 가정한다.

class MySQLDatabase {
    save(order) {
        console.log("MySQL에 주문 저장");
    }
}

class OrderService {
    constructor() {
        this.database = new MySQLDatabase();
    }

    createOrder(order) {
        this.database.save(order);
    }
}

OrderService는 내부에서 MySQLDatabase를 직접 생성한다.

this.database = new MySQLDatabase();

따라서 OrderServiceMySQLDatabase의 구체적인 구현에 강하게 의존한다.

나중에 데이터베이스를 MongoDB로 변경하려면 OrderService 내부 코드도 수정해야 한다.

MySQLDatabase 변경
        ↓
OrderService도 수정

이처럼 한 모듈의 변경이 다른 모듈의 변경으로 이어지는 정도를 결합도라고 한다.

  • 결합도가 높음: 모듈들이 서로 강하게 연결됨
  • 결합도가 낮음: 모듈을 비교적 독립적으로 변경 가능

의존성 주입

의존성 주입은 Dependency Injection, 줄여서 DI라고 한다.

의존성 주입은 객체가 자신에게 필요한 의존 객체를 직접 생성하지 않고 외부에서 전달받는 방식이다.

기존 방식에서는 OrderService가 직접 데이터베이스 객체를 생성했다.

class OrderService {
    constructor() {
        this.database = new MySQLDatabase();
    }
}

의존성 주입을 적용하면 사용할 데이터베이스 객체를 외부에서 전달받는다.

class OrderService {
    constructor(database) {
        this.database = database;
    }

    createOrder(order) {
        this.database.save(order);
    }
}

외부에서는 필요한 구현체를 생성하여 전달한다.

const database = new MySQLDatabase();
const orderService = new OrderService(database);

동작 구조는 다음과 같다.

기존 방식

OrderService
     ↓ 직접 생성
MySQLDatabase
의존성 주입 적용

외부 조립 영역
   ├── MySQLDatabase 생성
   └── OrderService에 전달

OrderService는 데이터베이스 객체를 직접 생성하지 않는다.

외부에서 전달받은 객체를 사용할 뿐이다.

의존성 주입은 필요한 객체를 내부에서 직접 만들지 않고 외부에서 넣어주는 방식이다.


의존성 주입자

객체를 생성하고 필요한 의존성을 연결해주는 역할을 하는 대상을 의존성 주입자라고 볼 수 있다.

의존성 주입자
    ├── 하위 모듈 생성
    ├── 상위 모듈 생성
    └── 상위 모듈에 하위 모듈 전달

직접 의존성을 주입할 수도 있다.

const database = new MySQLDatabase();
const orderService = new OrderService(database);

Spring과 같은 프레임워크에서는 DI 컨테이너가 객체를 생성하고 연결하는 역할을 담당한다.

Spring 컨테이너
    ├── Repository 생성
    ├── Service 생성
    └── Service에 Repository 주입

즉, 메인 모듈이 하위 모듈을 직접 생성하지 않고 외부의 조립 영역이나 DI 컨테이너가 두 모듈을 연결한다.

이를 통해 모듈 사이의 결합도를 낮출 수 있다.

이러한 관계를 디커플링 또는 느슨한 결합이라고 한다.


의존성 주입을 통한 싱글톤 문제 완화

싱글톤 객체를 모듈 내부에서 직접 가져오면 해당 모듈은 싱글톤 구현에 강하게 의존한다.

class UserService {
    constructor() {
        this.database = Database.getInstance();
    }
}

UserService는 내부에서 Database.getInstance()를 직접 호출한다.

따라서 실제 데이터베이스 대신 테스트용 객체를 사용하기 어렵다.

의존성 주입을 적용하면 데이터베이스 객체를 외부에서 전달받을 수 있다.

class UserService {
    constructor(database) {
        this.database = database;
    }
}

실제 애플리케이션에서는 싱글톤 데이터베이스 객체를 전달한다.

const database = Database.getInstance();
const userService = new UserService(database);

테스트에서는 가짜 데이터베이스 객체를 전달한다.

class FakeDatabase {
    findUser() {
        return {
            id: 1,
            name: "테스트 사용자"
        };
    }
}

const fakeDatabase = new FakeDatabase();
const userService = new UserService(fakeDatabase);

이 구조에서는 UserService가 싱글톤 데이터베이스를 직접 가져오지 않는다.

외부에서 어떤 객체를 전달하느냐에 따라 실제 객체와 테스트 객체를 자유롭게 교체할 수 있다.

실제 실행

UserService
     ↓
실제 Database
테스트 실행

UserService
     ↓
FakeDatabase

따라서 싱글톤을 사용하더라도 해당 싱글톤을 모듈 내부에서 직접 참조하지 않고 의존성 주입으로 전달하면 결합도를 낮추고 테스트하기 쉬운 구조를 만들 수 있다.


의존성 주입의 장점

1. 객체를 쉽게 교체할 수 있음

모듈 내부 코드를 수정하지 않고 외부에서 전달하는 구현체만 변경할 수 있다.

const orderService =
    new OrderService(new MySQLDatabase());
const orderService =
    new OrderService(new MongoDatabase());

OrderService는 수정하지 않아도 된다.


2. 테스트하기 쉬움

실제 데이터베이스나 외부 API 대신 테스트용 가짜 객체를 주입할 수 있다.

const orderService =
    new OrderService(new FakeDatabase());

외부 시스템에 실제로 연결하지 않고도 OrderService의 로직만 독립적으로 테스트할 수 있다.


3. 마이그레이션이 쉬움

데이터베이스, 메시지 브로커, 외부 API 등의 구현 기술을 변경할 때 영향을 받는 코드의 범위를 줄일 수 있다.

MySQL → MongoDB
SMTP → 외부 이메일 API
로컬 저장소 → 클라우드 저장소

4. 의존 관계가 명확해짐

생성자의 매개변수를 보면 해당 객체가 어떤 의존성을 필요로 하는지 확인할 수 있다.

class OrderService {
    constructor(database, paymentService) {
        this.database = database;
        this.paymentService = paymentService;
    }
}

OrderService가 다음 두 객체에 의존한다는 사실이 명확하게 드러난다.

  • database
  • paymentService

5. 의존성 방향을 일관되게 관리할 수 있음

구현체에 직접 의존하는 대신 인터페이스나 추상화에 의존하도록 설계할 수 있다.

OrderService
     ↓
Database 인터페이스
     ↑
MySQLDatabase
MongoDatabase
FakeDatabase

상위 모듈은 특정 데이터베이스 구현을 몰라도 된다.


의존성 주입의 단점

1. 클래스와 인터페이스 수 증가

구현체와 추상화 계층을 분리하면서 클래스나 파일의 수가 늘어날 수 있다.

Database
MySQLDatabase
MongoDatabase
FakeDatabase
DatabaseFactory

작은 프로그램에서는 이러한 구조가 오히려 과도하게 복잡하게 느껴질 수 있다.


2. 초기 구조 이해의 어려움

객체가 내부에서 직접 생성되지 않기 때문에 실제로 어떤 객체가 주입되는지 여러 설정 파일이나 DI 컨테이너를 확인해야 할 수 있다.

Service 코드
    ↓
인터페이스
    ↓
DI 설정
    ↓
실제 구현체

3. DI 컨테이너에 대한 학습 필요

Spring과 같은 프레임워크에서는 객체의 생성과 주입을 컨테이너가 담당한다.

따라서 다음과 같은 개념을 추가로 이해해야 한다.

  • 빈 등록
  • 컴포넌트 스캔
  • 생성자 주입
  • 객체 생명주기
  • 스코프
  • 순환 의존성

4. 일부 런타임 비용

DI 컨테이너를 사용하는 경우 객체 탐색, 생성, 리플렉션, 프록시 생성 등의 과정에서 약간의 추가 비용이 발생할 수 있다.

다만 일반적인 애플리케이션에서는 이러한 비용보다 유지보수성과 테스트 편의성의 장점이 더 큰 경우가 많다.


의존성 주입과 의존성 역전 원칙

다음 내용은 엄밀히 말하면 의존성 주입 자체의 원칙이라기보다 의존성 역전 원칙에 해당한다.

의존성 역전 원칙은 Dependency Inversion Principle, 줄여서 DIP라고 한다.

상위 수준 모듈은 하위 수준 모듈의 구체적인 구현에 직접 의존하면 안 된다. 상위 모듈과 하위 모듈 모두 추상화에 의존해야 한다.

또한 다음 원칙을 따른다.

추상화는 구체적인 세부 구현에 의존하지 않고, 세부 구현이 추상화에 의존해야 한다.


잘못된 의존 방향

OrderService
     ↓
MySQLDatabase

OrderService가 구체적인 MySQLDatabase에 직접 의존한다.

MySQL을 MongoDB로 변경하면 OrderService도 수정해야 할 수 있다.


의존성 역전 원칙 적용

OrderService
     ↓
Database 인터페이스
     ↑
MySQLDatabase
MongoDatabase
  • 상위 모듈: OrderService
  • 추상화: Database
  • 하위 모듈: MySQLDatabase, MongoDatabase

상위 모듈과 하위 모듈이 모두 Database라는 추상화에 의존한다.

class OrderService {
    constructor(database) {
        this.database = database;
    }
}

OrderService는 전달받은 객체가 MySQL인지 MongoDB인지 알 필요가 없다.

해당 객체가 필요한 기능을 제공하기만 하면 된다.


DI와 DIP의 차이

DI와 DIP는 관련이 있지만 같은 개념은 아니다.

DI

객체에 필요한 의존성을 외부에서 전달하는 구현 방식이다.

필요한 객체를 외부에서 주입

DIP

상위 모듈과 하위 모듈이 구체적인 구현이 아니라 추상화에 의존하도록 만드는 설계 원칙이다.

구현체가 아닌 추상화에 의존

의존성 주입은 의존성 역전 원칙을 구현하는 대표적인 방법 중 하나이다.

DIP
 └── 이를 구현하는 방법 중 하나가 DI

  1. 싱글톤은 하나의 인스턴스와 상태를 여러 모듈이 공유한다.
  2. 상태가 공유되면 테스트 간 독립성이 깨질 수 있다.
  3. 싱글톤 객체를 모듈 내부에서 직접 가져오면 결합도가 높아진다.
  4. 의존성 주입은 필요한 객체를 내부에서 생성하지 않고 외부에서 전달받는 방식이다.
  5. 의존성 주입을 사용하면 실제 객체와 테스트 객체를 쉽게 교체할 수 있다.
  6. 의존성 주입은 테스트, 유지보수, 기술 변경에 유리하다.
  7. 클래스와 설정이 늘어나 구조가 복잡해질 수 있다는 단점도 있다.
  8. 상위 모듈과 하위 모듈이 모두 추상화에 의존해야 한다는 내용은 DI 원칙이 아니라 의존성 역전 원칙이다.

싱글톤의 문제는 하나의 객체를 사용한다는 사실 자체보다, 해당 객체를 여러 모듈이 직접 참조하고 상태까지 공유하면서 강하게 결합되는 데서 발생한다.

의존성 주입은 객체를 직접 생성하거나 찾아오지 않고 외부에서 전달받게 하여 모듈 간 결합도를 낮추는 방법이다.

profile
인천대학교 멋쟁이사자 13기 백엔드 & 14기 백엔트 파트장 및 부회장

0개의 댓글