JAVASCRIPT · 심층 가이드
JavaScript 객체지향 완전 정리
class 문법 아래의 프로토타입 동작을 전제로 캡슐화, 합성, 불변 객체, 값 비교, DTO 매핑까지 JavaScript 객체 설계를 20개 예제로 정리합니다.
JavaScript의 class는 프로토타입 위에 씌운 문법입니다. 메서드는 인스턴스마다 복사되지 않고 프로토타입 객체에 한 벌만 존재하며, extends는 그 프로토타입 체인을 이어 붙일 뿐입니다. Java나 C#을 먼저 배운 사람에게 어긋나는 부분은 여기서 나옵니다. 컴파일 시점에 강제되는 인터페이스가 없고, 타입이 아니라 '그 메서드가 있는가'로 동작이 결정됩니다. 그래서 이 언어에서 객체지향을 잘 쓴다는 건 상속 계층을 정교하게 짜는 것보다 경계를 명확히 나누는 쪽에 가깝습니다.
기본 클래스 또는 구조체와 생성자와 초기화로 형태를 잡은 뒤 캡슐화에서 # 접두사 private 필드를 확인하면, 관례적으로 _name을 쓰던 시절과 무엇이 달라졌는지 분명해집니다. 상속 또는 확장 바로 뒤에 합성으로 기능 묶기가 놓인 배치도 의도적입니다. 둘을 비교해 보면 상속이 필요한 경우가 생각보다 드물다는 게 드러납니다. 후반의 불변 데이터 객체, 값 객체 비교, 변경 불가능 업데이트는 하나의 흐름으로, 상태를 바꾸는 대신 새로 만드는 방식을 다룹니다.
클래스 메서드를 콜백으로 넘기는 순간 this가 끊깁니다. arr.forEach(this.handle)은 this가 undefined가 되어 터지므로, 생성자에서 bind하거나 클래스 필드에 화살표 함수로 정의해야 합니다. 불변성 쪽에서는 Object.freeze가 얕게만 동작한다는 점을 놓치기 쉽습니다. 중첩 객체나 배열의 내부는 그대로 변경 가능하므로, 스프레드로 복사할 때도 같은 이유로 한 단계 아래는 참조가 공유됩니다. 값 객체를 비교할 때 ===가 참조를 비교한다는 사실도 함께 기억해야 합니다.
01기본 클래스 또는 구조체
User 클래스가 name과 level을 받아 필드로 저장하고, summary()가 템플릿 리터럴로 두 값을 이어 붙여 돌려줍니다. 데이터와 그 데이터를 다루는 동작을 한곳에 묶고 싶을 때 이 형태로 출발합니다. class는 문법 설탕이라 summary는 인스턴스마다 복사되지 않고 User.prototype에 한 번만 올라갑니다.
JavaScript code
// 기본 클래스 또는 구조체
class User {
constructor(name, level) { this.name = name; this.level = level; }
summary() { return `${this.name}:${this.level}`; }
}
console.log(new User("Ada", 1).summary());메서드는 프로토타입에 한 번만 올라가 인스턴스를 아무리 많이 만들어도 함수가 복제되지 않습니다. 생성자 안에서 함수를 대입하면 그 이점이 사라집니다.
클래스 본문은 항상 엄격 모드로 동작해, 선언하지 않은 변수에 대입하면 전역이 만들어지는 대신 그 자리에서 오류가 납니다.
02생성자와 초기화
Connection은 mode 하나만 받는 생성자를 두고, 정적 메서드 local()이 값을 미리 채운 인스턴스를 대신 만들어 냅니다. 초기화 조합이 몇 가지로 정해져 있을 때 이름 붙은 진입점을 열어 두는 방식입니다. 정적 메서드 안의 this는 인스턴스가 아니라 클래스 자신을 가리킨다는 점이 인스턴스 메서드와 다릅니다.
JavaScript code
// 생성자와 초기화
class Connection {
constructor(mode) { this.mode = mode; }
static local() { return new Connection("local"); }
}
console.log(Connection.local());정적 메서드에서 new this(...)를 쓰면 이 클래스를 상속한 쪽이 같은 메서드를 물려받았을 때 자식 타입의 인스턴스가 만들어집니다.
정적 메서드 안에서 클래스 이름을 직접 쓰면 상속해도 언제나 부모 인스턴스가 나옵니다. 확장을 염두에 둔 코드라면 나중에 문제가 됩니다.
03인스턴스 메서드
로그 출력만 담당하는 Logger를 Service가 생성자 인자로 받아 보관하고, run()에서 this.logger.log를 호출합니다. 협력 객체를 밖에서 넣어 주므로 테스트에서 가짜 로거로 갈아 끼우기 쉽습니다. 다만 const f = svc.run처럼 메서드를 떼어 내면 this 연결이 끊겨 호출 시 오류가 납니다.
JavaScript code
// 인스턴스 메서드
class Logger { log(message) { console.log(message); } }
class Service { constructor(logger) { this.logger = logger; } run() { this.logger.log("start"); } }
new Service(new Logger()).run();협력 객체를 생성자로 받으면 테스트에서 호출 기록만 남기는 가짜 로거로 바꿔 끼울 수 있어, 출력 부작용 없이 동작을 확인할 수 있습니다.
메서드만 떼어 콜백으로 넘기면 this가 끊겨 this.logger를 읽을 때 오류가 납니다. bind하거나 화살표 함수로 감싸야 합니다.
04정적 메서드
Object.freeze로 굳힌 객체 리터럴을 그대로 출력합니다. 설정값처럼 만든 뒤로는 바뀌면 곤란한 데이터에 이 처리를 겁니다. 동결은 한 겹에만 적용되어 안쪽 tags 배열에는 여전히 push가 먹히고, 비엄격 모드에서는 속성 수정 시도가 오류 없이 조용히 무시됩니다.
JavaScript code
// 정적 메서드
const profile = Object.freeze({ name: "Ada", tags: ["js"] });
console.log(profile);Object.isFrozen으로 동결 여부를 확인할 수 있고, 속성 추가만 막고 값 수정은 허용하고 싶다면 Object.seal이라는 중간 단계가 있습니다.
비엄격 모드에서는 동결된 객체의 속성을 바꿔도 오류 없이 무시되어, 값이 반영되지 않는 이유를 한참 뒤에야 알게 됩니다.
05상속 또는 확장
코드에는 extends가 없고 User 한 클래스만 정의된 뒤 레벨 5짜리 인스턴스의 summary() 결과를 찍습니다. 공통 동작을 물려주기 전, 부모가 될 후보를 단순하게 정의해 두는 자리로 볼 수 있습니다. 자바스크립트의 상속은 프로토타입을 잇는 것이며, 자식 생성자에서 super()를 부르기 전에는 this에 손댈 수 없습니다.
JavaScript code
// 상속 또는 확장
class User {
constructor(name, level) { this.name = name; this.level = level; }
summary() { return `${this.name}:${this.level}`; }
}
console.log(new User("Ada", 5).summary());extends로 상속하면 정적 메서드까지 함께 물려받아, 부모에 정의한 팩토리 메서드를 자식 이름으로도 부를 수 있습니다.
자식 생성자에서 super()를 부르기 전에 this를 건드리면 참조 오류가 납니다. 필드 초기화 순서를 옮기다 자주 만나는 실수입니다.
06인터페이스 또는 trait
자바스크립트에는 인터페이스 선언 문법이 없어, 예시는 mode 필드와 정적 local()을 갖춘 구체 클래스만 보여 줍니다. 규약을 맞추려면 같은 이름의 메서드를 가진 객체를 여러 개 두고 호출부가 동일하게 다루는 식으로 대신합니다. 검사가 필요하면 typeof obj.connect === 'function' 같은 덕 타이핑이 사실상 유일한 런타임 수단입니다.
JavaScript code
// 인터페이스 또는 trait
class Connection {
constructor(mode) { this.mode = mode; }
static local() { return new Connection("local"); }
}
console.log(Connection.local());규약을 코드로 남기고 싶다면 필요한 메서드가 있는지 생성 시점에 확인하는 검사를 두어, 실제 호출 지점보다 훨씬 앞에서 문제를 드러낼 수 있습니다.
instanceof는 프로토타입 체인을 보기 때문에, 모양이 같아도 다른 경로로 만들어진 객체는 규약을 만족하는데도 거짓이 나옵니다.
07캡슐화
Service가 logger를 필드에 담아 두고 run() 안에서만 쓰므로, 바깥은 로그가 어떻게 남는지 몰라도 됩니다. 세부 구현을 감추고 호출 창구를 하나로 줄일 때 쓰는 배치입니다. 다만 this.logger는 공개 속성이라 진짜 은닉을 원하면 #logger 같은 private 필드를 써야 하고, 이 필드는 클래스 본문 밖에서 접근하면 문법 오류가 납니다.
JavaScript code
// 캡슐화
class Logger { log(message) { console.log(message); } }
class Service { constructor(logger) { this.logger = logger; } run() { this.logger.log("start"); } }
new Service(new Logger()).run();#logger처럼 #을 붙인 필드는 클래스 밖에서 접근하면 문법 오류라, 관례가 아니라 언어 차원에서 접근이 막힙니다.
이름 앞에 밑줄만 붙인 _logger는 약속일 뿐 아무것도 막지 못하고, 직렬화 결과나 로그에도 그대로 노출됩니다.
08합성으로 기능 묶기
이름과 태그 배열을 담은 객체를 동결해 하나의 값 묶음으로 다룹니다. 상속으로 기능을 물려받는 대신 필요한 조각을 속성으로 모아 두는 방향에 어울리는 형태입니다. 동결된 객체라도 새 필드를 더한 사본은 { ...profile, role: 'admin' }처럼 얼마든지 만들 수 있습니다.
JavaScript code
// 합성으로 기능 묶기
const profile = Object.freeze({ name: "Ada", tags: ["js"] });
console.log(profile);필요한 동작을 속성으로 모으면 상속 계층을 깊게 만들지 않고도 조합을 바꿀 수 있어, 경우의 수가 늘 때 클래스가 폭발하는 것을 피할 수 있습니다.
동결된 객체를 전개해 사본을 만들면 그 사본은 동결되지 않습니다. 불변을 유지하려면 새로 만든 객체도 다시 얼려야 합니다.
09팩터리 메서드
여기서는 별도 팩터리 없이 new User('Ada', 9)를 직접 불러 인스턴스를 얻습니다. 생성 규칙이 늘어나면 이 new 호출을 함수 뒤로 숨겨 호출부가 클래스 이름을 몰라도 되게 바꿉니다. 참고로 생성자가 객체를 return하면 그 객체가 new의 결과를 대체해 버립니다.
JavaScript code
// 팩터리 메서드
class User {
constructor(name, level) { this.name = name; this.level = level; }
summary() { return `${this.name}:${this.level}`; }
}
console.log(new User("Ada", 9).summary());생성 규칙을 함수 뒤로 숨기면 나중에 캐시된 인스턴스를 돌려주거나 다른 구현으로 바꿔도 호출부를 전혀 고치지 않아도 됩니다.
생성자에서 객체를 반환하면 new가 만든 인스턴스 대신 그 객체가 결과가 되어, instanceof 검사가 조용히 실패합니다.
10열거형 상태 표현
mode 문자열로 연결 방식을 표현하고, Connection.local()이 그중 하나를 고정한 인스턴스를 돌려줍니다. 상태 값이 몇 개로 한정될 때 문자열을 그대로 흘리지 않고 이런 진입점으로 묶습니다. 자바스크립트에는 enum 문법이 없어 보통 동결한 상수 객체나 Symbol로 오타를 막습니다.
JavaScript code
// 열거형 상태 표현
class Connection {
constructor(mode) { this.mode = mode; }
static local() { return new Connection("local"); }
}
console.log(Connection.local());상수 객체를 동결해 두면 값이 바뀌지 않고, 허용된 값 목록을 Object.values로 뽑아 그대로 유효성 검사에 쓸 수 있습니다.
상태를 문자열 그대로 흘리면 철자를 하나 틀려도 오류 없이 지나가고 조건문만 조용히 거짓이 되어, 아무 일도 일어나지 않는 버그가 됩니다.
11불변 데이터 객체
예시 자체는 불변과 무관하게, 주입받은 Logger로 Service가 run()을 한 번 실행하는 흐름입니다. 불변 객체를 만들려면 생성자에서 받은 값을 이후 어떤 메서드도 건드리지 않는다는 규칙을 지켜야 합니다. const는 재할당만 막을 뿐 객체 내부 속성 변경은 전혀 막지 못합니다.
JavaScript code
// 불변 데이터 객체
class Logger { log(message) { console.log(message); } }
class Service { constructor(logger) { this.logger = logger; } run() { this.logger.log("start"); } }
new Service(new Logger()).run();생성자에서 받은 값을 이후 어떤 메서드도 고치지 않는다는 규칙을 지키면, 같은 인스턴스를 여러 곳에서 공유해도 동기화 문제가 생기지 않습니다.
const svc = new Service(logger)의 const는 변수 재할당만 막습니다. svc.logger = other는 그대로 통해 협력 객체가 바뀔 수 있습니다.
12빌더 패턴
완성된 객체를 Object.freeze로 잠근 뒤 출력하는 짧은 코드입니다. 빌더는 이런 최종 결과물을 여러 단계 호출로 조립하고 마지막에 굳혀 내보내는 구조로 확장됩니다. 단계마다 같은 객체를 고쳐 쓰면 중간에 새어 나간 참조까지 함께 바뀌므로, 각 단계가 새 객체를 반환하게 두는 편이 안전합니다.
JavaScript code
// 빌더 패턴
const profile = Object.freeze({ name: "Ada", tags: ["js"] });
console.log(profile);각 단계가 새 객체를 돌려주면 중간 상태를 그대로 저장하거나 거기서 갈라져 여러 결과를 만들 수 있어, 같은 빌더를 재사용해도 결과가 섞이지 않습니다.
빌더가 내부 객체를 계속 고쳐 쓰면서 자기 자신을 돌려주면, 중간에 새어 나간 참조가 나중 단계에서 함께 바뀝니다. 마지막에 얼려도 이미 늦습니다.
13다형성
summary()는 인스턴스마다 다른 name과 level을 읽어 서로 다른 문자열을 만들어 냅니다. 같은 메서드 이름으로 여러 타입을 동일하게 호출하고 싶을 때의 출발점입니다. 호출 시점에 프로토타입 체인을 따라 메서드를 찾기 때문에, 자식 클래스가 같은 이름을 정의하면 별도 선언 없이 그쪽이 먼저 걸립니다.
JavaScript code
// 다형성
class User {
constructor(name, level) { this.name = name; this.level = level; }
summary() { return `${this.name}:${this.level}`; }
}
console.log(new User("Ada", 13).summary());자식이 같은 이름의 메서드를 정의하면 프로토타입 체인에서 먼저 발견되어 부모 구현을 가리고, 부모 것이 필요하면 super로 부를 수 있습니다.
메서드 이름의 철자를 틀리면 재정의가 아니라 새 메서드가 되어 부모 구현이 계속 불립니다. 오류가 없어 동작이 바뀌지 않은 이유를 찾기 어렵습니다.
14의존성 주입
Connection은 접속 방식을 생성자 인자로 받고, local()은 그 인자를 미리 정해 둔 편의 생성자 역할을 합니다. 대상을 코드 안에 박지 않고 밖에서 넣어 주는 형태라 테스트용 값으로 바꾸기 쉽습니다. 반대로 local()처럼 값을 안에서 정해 버린 경로는 다시 고정된 의존이 됩니다.
JavaScript code
// 의존성 주입
class Connection {
constructor(mode) { this.mode = mode; }
static local() { return new Connection("local"); }
}
console.log(Connection.local());생성자로 의존을 받으면 어떤 협력자가 필요한지가 시그니처에 드러나, 객체를 만들 때 무엇을 준비해야 하는지 문서 없이도 알 수 있습니다.
편의 생성자 안에 대상을 고정해 두면 그 경로로 만든 인스턴스는 테스트에서 바꿔 끼울 수 없습니다. 고정 값은 인자 기본값으로 두는 편이 낫습니다.
15값 객체 비교
코드에 비교 로직은 없고 new Service(new Logger()).run()으로 객체를 만들어 곧바로 실행할 뿐입니다. 값 객체라면 필드가 같을 때 같다고 판단해야 하므로 equals를 직접 정의해야 합니다. ===는 객체를 참조로만 비교해서, 내용이 완전히 같아도 인스턴스가 다르면 false가 나옵니다.
JavaScript code
// 값 객체 비교
class Logger { log(message) { console.log(message); } }
class Service { constructor(logger) { this.logger = logger; } run() { this.logger.log("start"); } }
new Service(new Logger()).run();값 객체는 필드가 같으면 같다고 봐야 하므로, 비교용 키 문자열을 만들어 두면 Set이나 Map의 키로도 그대로 쓸 수 있습니다.
===는 객체를 참조로만 비교해 내용이 완전히 같아도 거짓입니다. 배열 includes나 Set 중복 제거가 기대와 다르게 동작하는 원인입니다.
16저장소 객체
조회 결과처럼 쓰일 프로필 객체를 만들어 동결한 다음 그대로 출력합니다. 저장소는 이런 레코드를 꺼내 주는 자리이며, 호출부가 실수로 고치지 못하도록 동결해 내보내는 선택이 가능합니다. 대신 동결한 객체를 캐시에 두면 갱신할 때 속성을 고칠 수 없어 새 객체로 통째 교체해야 합니다.
JavaScript code
// 저장소 객체
const profile = Object.freeze({ name: "Ada", tags: ["js"] });
console.log(profile);저장소가 내보내는 레코드를 동결하면 호출부가 실수로 고쳐도 보관 중인 값이 오염되지 않아, 다음 조회에서 결과가 달라지는 사고를 막습니다.
동결한 레코드를 갱신하려고 속성에 대입하면 엄격 모드에서 TypeError가 납니다. 갱신은 새 객체를 만들어 통째로 교체해야 합니다.
17서비스 객체
User는 필드 두 개와 그 값을 문자열로 합치는 메서드 하나만 가진 데이터 중심 클래스입니다. 서비스 객체는 반대로 상태를 거의 두지 않고 이런 데이터를 인자로 받아 처리합니다. 표현용 summary 같은 메서드를 데이터 클래스에 계속 붙이면 도메인 규칙과 출력 형식이 한 파일에서 뒤섞이기 쉽습니다.
JavaScript code
// 서비스 객체
class User {
constructor(name, level) { this.name = name; this.level = level; }
summary() { return `${this.name}:${this.level}`; }
}
console.log(new User("Ada", 17).summary());상태를 갖지 않는 서비스는 인스턴스를 하나만 만들어 공유해도 되고, 함수 몇 개를 내보내는 모듈로 대체해도 동작이 달라지지 않습니다.
데이터 클래스에 화면용 서식 메서드를 계속 붙이면 표시 형식이 바뀔 때마다 도메인 코드를 고치게 됩니다. 출력 형식은 바깥 계층으로 빼는 편이 낫습니다.
18DTO 매핑
local()은 평범한 문자열 하나를 Connection 인스턴스로 감싸 돌려줍니다. 외부에서 들어온 원시 값을 내부 타입으로 바꿔 넣는 매핑 지점이 대체로 이런 모양입니다. 반대 방향으로 내보낼 때는 JSON.stringify가 프로토타입의 메서드는 빼고 자기 속성만 직렬화한다는 점을 감안해야 합니다.
JavaScript code
// DTO 매핑
class Connection {
constructor(mode) { this.mode = mode; }
static local() { return new Connection("local"); }
}
console.log(Connection.local());외부 값을 내부 타입으로 바꾸는 지점을 한곳에 모으면, 응답 형식이 바뀌었을 때 고쳐야 할 자리가 그 함수 하나로 좁혀집니다.
JSON.stringify는 프로토타입의 메서드와 private 필드를 빼고 자기 속성만 남깁니다. 직렬화 후 되살린 값은 더 이상 그 클래스의 인스턴스가 아닙니다.
19변경 불가능 업데이트
Service는 생성자에서 받은 logger를 이후 한 번도 재할당하지 않고 읽기만 합니다. 상태를 제자리에서 고치는 대신 이렇게 한 번 정해 두면 실행 도중 대상이 바뀌는 사고가 줄어듭니다. 값을 바꿔야 한다면 필드를 덮어쓰기보다 새 인스턴스를 만들어 교체하는 쪽이 변경 시점을 추적하기 쉽습니다.
JavaScript code
// 변경 불가능 업데이트
class Logger { log(message) { console.log(message); } }
class Service { constructor(logger) { this.logger = logger; } run() { this.logger.log("start"); } }
new Service(new Logger()).run();필드를 덮어쓰는 대신 새 인스턴스로 교체하면 변경 시점이 대입 한 줄로 드러나, 언제 무엇이 바뀌었는지 추적하기가 훨씬 쉬워집니다.
생성자에서 받은 객체를 그대로 필드에 담으면 바깥에서 그 객체를 계속 고칠 수 있습니다. 진짜 고정이 필요하면 사본을 만들어 보관해야 합니다.
20확장 스타일 동작
객체 리터럴을 동결해 속성 추가와 수정이 막힌 상태로 만든 뒤 출력합니다. 기존 타입에 동작을 덧붙이고 싶을 때 원본을 고치는 대신 감싸는 함수를 두게 되는 근거가 여기 있습니다. 동결된 객체에 속성을 넣으면 엄격 모드에서는 TypeError가 던져집니다.
JavaScript code
// 확장 스타일 동작
const profile = Object.freeze({ name: "Ada", tags: ["js"] });
console.log(profile);기존 타입에 동작을 더할 때는 프로토타입을 고치는 대신 값을 받아 처리하는 함수를 두면, 다른 라이브러리와 이름이 충돌하지 않습니다.
내장 객체의 프로토타입에 속성을 추가하면 for...in 순회에 그 이름이 섞여 들어와, 전혀 무관한 코드가 깨질 수 있습니다.
정리하며
- 내부 상태는 _ 관례 대신 # private 필드로 감춰 외부 접근을 실제로 차단합니다
- 상속은 진짜 하위 타입일 때만 쓰고 기능 재사용은 합성으로 해결합니다
- 메서드를 콜백으로 넘길 때는 bind나 클래스 필드 화살표 함수로 this를 고정합니다
- Object.freeze와 스프레드 복사는 얕게 동작하므로 중첩 구조는 단계별로 처리합니다
더 깊이 들어가고 싶다면 JavaScript 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.