Showing posts with label javascript. Show all posts
Showing posts with label javascript. Show all posts

Monday, February 26, 2024

Dart対JavaScript:包括的な言語比較

DartとJavaScriptの紹介

DartとJavaScriptは、ウェブ開発で広く使われているプログラミング言語です。どちらもブラウザで直接実行でき、クライアントサイドとサーバーサイドの両方で使用できます。

Dartについて

DartはGoogleが開発したプログラミング言語で、強力なツールチェーンと言語機能を提供します。Dartはウェブ、サーバー、モバイルアプリケーション開発に使用され、特にFlutterフレームワークと一緒にモバイルアプリ開発に主に使用されます。


// Dart コード例
void main() {
  print('Hello, Dart!');
}

JavaScriptについて

JavaScriptは、ウェブページに動的な要素を追加するために使用されるインタプリタ言語です。ウェブブラウザで実行するように設計され、Node.jsなどのプラットフォームを通じてサーバーサイド開発にも使用できます。JavaScriptはウェブ開発の核心技術の一つで、HTMLとCSSとともにウェブの三つの基本要素の一つです。


// JavaScript コード例
console.log('Hello, JavaScript!');

DartとJavaScriptの主な違い

DartとJavaScriptはどちらもウェブ開発に広く使われるプログラミング言語ではありますが、両言語にはいくつかの重要な違いがあります。

型システム

Dartは静的型言語です。これは、変数を宣言するときにその変数の型を明示する必要があることを意味します。一方、JavaScriptは動的型言語で、変数の型は実行時に決定されます。


// Dart コード例
int number = 10;

// JavaScript コード例
var number = 10;

クラス定義と継承

Dartはクラスベースのオブジェクト指向プログラミング言語で、クラス定義と継承を直感的に処理できます。一方、JavaScriptはプロトタイプベースのオブジェクト指向言語で、継承はプロトタイプチェーンを通じて処理されます。


// Dart コード例
class Animal {
  void eat() {
    print('Eating...');
  }
}

class Dog extends Animal {
  void bark() {
    print('Barking...');
  }
}

// JavaScript コード例
function Animal() {}

Animal.prototype.eat = function() {
  console.log('Eating...');
}

function Dog() {}

Dog.prototype = Object.create(Animal.prototype);

Dog.prototype.bark = function() {
  console.log('Barking...');
}

コンパイルと実行

DartはAhead-Of-Time(AOT)コンパイルをサポートし、単一の実行可能ファイルを生成します。これにより、アプリの起動時間が改善され、パフォーマンスが向上します。一方、JavaScriptはインタプリタ言語で、コードは実行時に解釈され実行されます。

Dartの長所と短所

Dartの長所

Dartはいくつかの面で長所を持っています:

  • パフォーマンス: DartはAOTコンパイルをサポートし、高速な起動時間とスムーズなアニメーションを提供します。また、DartはガーベージコレクションとJIT(Just In Time)コンパイルをサポートし、開発中の迅速なイテレーションを可能にします。
  • 柔軟性: Dartはクライアントサイドとサーバーサイドの両方で使用できます。これは、Dartがモバイル、ウェブ、サーバー開発のすべてで使用できることを意味します。
  • 統合開発環境: Dartは強力なツールチェーンを持っています。これには、コードの自動補完、リアルタイムのエラー検出、デバッグなどが含まれています。

Dartの短所

しかし、Dartにも短所があります:

  • 人気度: Dartの人気度はJavaScriptに比べて相対的に低いです。これは、Dartに関するチュートリアルやライブラリが相対的に少ないことを意味します。
  • 学習曲線: DartはCスタイルの文法を持っていますが、それが他の言語と簡単に互換性を持つわけではありません。特に、Dartの非同期プログラミングは初心者にとって難しいかもしれません。

JavaScriptの長所と短所

JavaScriptの長所

JavaScriptは以下のいくつかの主要な長所を持っています:

  • 汎用性: JavaScriptはウェブの基本言語であり、ほぼすべてのウェブブラウザでサポートされています。また、Node.jsを通じてサーバーサイドプログラミングも可能です。
  • 豊富なライブラリとフレームワーク: JavaScriptは多くのライブラリとフレームワークを持っており、開発者は様々なウェブアプリケーションを簡単に開発することができます。
  • コミュニティサポート: JavaScriptは非常に大きく活発なコミュニティを持っており、問題解決や新しい技術の学習に助けを得やすいです。

JavaScriptの短所

しかし、JavaScriptにもいくつかの短所があります:

  • 動的型: JavaScriptは動的型言語であり、実行時にエラーを発見しやすいです。これはデバッグを困難にする可能性があります。
  • 非標準的な動作: さまざまなブラウザがJavaScriptを異なる方法で解釈し実行するため、同じJavaScriptのコードがすべてのブラウザで同様に動作するとは限りません。

DartとJavaScriptを選択する際の考慮事項

プログラミング言語の選択は、プロジェクトの要件、開発チームの能力、利用可能なリソースなど、多くの要素によって異なります。DartとJavaScriptの間で選択する際に考慮すべきいくつかの主要な要素は次のとおりです:

  • プロジェクトの規模: 大規模なプロジェクトの場合、Dartの強力なツールチェーンと静的型システムがプロジェクト管理を容易にします。一方、小規模なプロジェクトやプロトタイプ開発では、JavaScriptの柔軟性と簡潔さがより有用である可能性があります。
  • 開発チームの能力: チームが既にJavaScriptに精通している場合、JavaScriptを選択することでチームの生産性を向上させることができます。一方、チームが新たな挑戦を求めているか、Dartの特定の機能(例:Flutterとの統合)を活用したい場合、Dartを選択することができます。
  • コミュニティサポート: JavaScriptは大きなコミュニティと豊富なライブラリを持っています。これは問題解決や新技術の学習を容易にします。一方、Dartのコミュニティは比較的小さいですが、Flutterというフレームワークの人気が上昇しているため、コミュニティが成長しています。

結論

DartとJavaScriptは、それぞれ独自の長所と短所を持っています。Dartは強力なツールチェーン、静的型システム、そしてFlutterとの統合などにより魅力的な選択肢となることがあります。一方、JavaScriptはウェブの基本言語としての位置、豊富なライブラリとフレームワーク、そして大きなコミュニティサポートなどにより、依然として強力なプログラミング言語です。

したがって、DartとJavaScriptのどちらを選択するかは、個々のプロジェクトの要件、開発チームの能力、そして利用可能なリソースなど、多くの要素によって異なるでしょう。どの言語を選択しても、選んだ言語で効果的でメンテナンスが容易なコードを書くことが最も重要です。

Dart vs JavaScript: A Comprehensive Language Comparison

Introduction to Dart and JavaScript

Dart and JavaScript are widely used programming languages in web development. Both can run directly in the browser and can be used on both the client side and the server side.

About Dart

Dart is a programming language developed by Google, providing a powerful toolchain and strong language features. Dart is used for web, server, and mobile application development, especially in conjunction with the Flutter framework for mobile app development.


// Dart code example
void main() {
  print('Hello, Dart!');
}

About JavaScript

JavaScript is an interpreted language used to add dynamic elements to web pages. It was designed to run in web browsers and can also be used for server-side development through platforms like Node.js. JavaScript is one of the core technologies of web development, along with HTML and CSS.


// JavaScript code example
console.log('Hello, JavaScript!');

Main Differences Between Dart and JavaScript

Although Dart and JavaScript are both widely used programming languages for web development, they have several important differences.

Type System

Dart is a statically typed language. This means that you need to specify the type of a variable when declaring it. On the other hand, JavaScript is a dynamically typed language, where the type of a variable is determined at runtime.


// Dart code example
int number = 10;

// JavaScript code example
var number = 10;

Class Definition and Inheritance

Dart is a class-based object-oriented programming language that handles class definitions and inheritance intuitively. In contrast, JavaScript is a prototype-based object-oriented language, where inheritance is handled through prototype chains.


// Dart code example
class Animal {
  void eat() {
    print('Eating...');
  }
}

class Dog extends Animal {
  void bark() {
    print('Barking...');
  }
}

// JavaScript code example
function Animal() {}

Animal.prototype.eat = function() {
  console.log('Eating...');
}

function Dog() {}

Dog.prototype = Object.create(Animal.prototype);

Dog.prototype.bark = function() {
  console.log('Barking...');
}

Compilation and Execution

Dart supports Ahead-Of-Time (AOT) compilation that generates a single executable file. This improves the startup time of the app and provides better performance. In contrast, JavaScript is an interpreted language, and the code is interpreted and executed at runtime.

Pros and Cons of Dart

Pros of Dart

Dart has several advantages:

  • Performance: Dart supports AOT compilation, providing fast startup times and smooth animations. In addition, Dart supports garbage collection and JIT (Just In Time) compilation, allowing for fast iterations during development.
  • Versatility: Dart can be used on both the client side and the server side. This means Dart can be used for mobile, web, and server development.
  • Integrated Development Environment: Dart has a powerful toolchain. This includes features like code auto-completion, real-time error detection, and debugging.

Cons of Dart

However, Dart also has its disadvantages:

  • Popularity: Dart is less popular compared to JavaScript. This means there are relatively fewer tutorials and libraries for Dart.
  • Learning curve: Although Dart has a C-style syntax, it doesn't always easily align with other languages. In particular, Dart's asynchronous programming can be challenging for beginners.

Pros and Cons of JavaScript

Pros of JavaScript

JavaScript has several key advantages:

  • Universality: As the basic language of the web, JavaScript is supported by almost all web browsers. Also, server-side programming is possible through Node.js.
  • Rich libraries and frameworks: JavaScript has a variety of libraries and frameworks, making it easier for developers to develop various web applications.
  • Community support: JavaScript has a very large and active community, making it easier to get help when solving problems or learning new technologies.

Cons of JavaScript

However, JavaScript also has several disadvantages:

  • Dynamic typing: As a dynamically typed language, JavaScript is prone to discovering errors at runtime. This can make debugging more difficult.
  • Non-standard behavior: Because different browsers interpret and execute JavaScript differently, the same JavaScript code may not behave the same in all browsers.

Factors to Consider When Choosing Between Dart and JavaScript

The choice of programming language depends on several factors, including the requirements of the project, the capabilities of the development team, and the resources available. Some of the key factors to consider when choosing between Dart and JavaScript include:

  • Project size: For large projects, Dart's powerful toolchain and static typing system can make project management easier. On the other hand, JavaScript's flexibility and brevity may be more useful for small projects or prototype development.
  • Capabilities of the development team: If the team is already familiar with JavaScript, choosing JavaScript can increase the team's productivity. On the other hand, if the team wants a new challenge or wants to utilize specific features of Dart (e.g., integration with Flutter), Dart can be chosen.
  • Community support: JavaScript has a large community and abundant libraries, making problem-solving and learning new technologies easier. In contrast, Dart's community is relatively small, but the community is growing with the increasing popularity of frameworks like Flutter.

Conclusion

Dart and JavaScript each have their unique strengths and weaknesses. Dart can be an attractive choice due to its powerful toolchain, static typing system, and integration with Flutter. On the other hand, JavaScript remains a powerful programming language due to its position as the basic language of the web, its rich libraries and frameworks, and its extensive community support.

Therefore, whether to choose Dart or JavaScript will depend on various factors, including the requirements of the individual project, the capabilities of the development team, and the resources available. Regardless of which language you choose, the most important thing is to write effective and maintainable code through the chosen language.

Dart vs JavaScript: 선택의 기준을 제시하는 언어 비교

Dart와 JavaScript 소개

Dart와 JavaScript는 웹 개발에서 널리 사용되는 프로그래밍 언어들입니다. 둘 다 브라우저에서 직접 실행될 수 있으며, 클라이언트 사이드와 서버 사이드 모두에서 사용될 수 있습니다.

Dart에 대하여

Dart는 구글이 개발한 프로그래밍 언어로, 강력한 툴체인과 강력한 언어 기능들을 제공합니다. Dart는 웹, 서버, 모바일 애플리케이션 개발에 사용되며, 특히 Flutter 프레임워크와 함께 모바일 앱 개발에 주로 사용됩니다.


// Dart 코드 예시
void main() {
  print('Hello, Dart!');
}

JavaScript에 대하여

JavaScript는 웹 페이지에 동적인 요소를 추가하는데 사용되는 인터프리터 언어입니다. 웹 브라우저에서 실행되도록 설계되었으며, Node.js와 같은 플랫폼을 통해 서버 사이드 개발에도 사용될 수 있습니다. JavaScript는 웹 개발의 핵심 기술 중 하나로, HTML과 CSS와 함께 웹의 세 가지 기본 요소 중 하나입니다.


// JavaScript 코드 예시
console.log('Hello, JavaScript!');

Dart와 JavaScript의 주요 차이점

비록 Dart와 JavaScript가 모두 웹 개발에 널리 사용되는 프로그래밍 언어라고는 하나, 두 언어는 몇 가지 중요한 차이점들을 가지고 있습니다.

타입 시스템

Dart는 정적 타입 언어입니다. 이는 변수를 선언할 때 그 변수의 타입을 명시해야 한다는 것을 의미합니다. 반면에 JavaScript는 동적 타입 언어로, 변수의 타입이 실행 시간에 결정됩니다.


// Dart 코드 예시
int number = 10;

// JavaScript 코드 예시
var number = 10;

클래스 정의와 상속

Dart는 클래스 기반의 객체 지향 프로그래밍 언어로, 클래스 정의와 상속을 직관적으로 처리할 수 있습니다. 반면에 JavaScript는 프로토타입 기반의 객체 지향 언어로, 상속은 프로토타입 체인을 통해 처리됩니다.


// Dart 코드 예시
class Animal {
  void eat() {
    print('Eating...');
  }
}

class Dog extends Animal {
  void bark() {
    print('Barking...');
  }
}

// JavaScript 코드 예시
function Animal() {}

Animal.prototype.eat = function() {
  console.log('Eating...');
}

function Dog() {}

Dog.prototype = Object.create(Animal.prototype);

Dog.prototype.bark = function() {
  console.log('Barking...');
}

컴파일 및 실행

Dart는 단일 실행 파일을 생성하는 Ahead-Of-Time(AOT) 컴파일을 지원합니다. 이는 앱의 시작 시간을 개선하고, 더 나은 성능을 제공합니다. 반면에 JavaScript는 인터프리터 언어로, 코드는 실행 시간에 해석되고 실행됩니다.

Dart의 장단점

Dart의 장점

Dart는 여러 방면에서 장점을 가지고 있습니다:

  • 성능: Dart는 AOT 컴파일을 지원하여 빠른 시작 시간과 부드러운 애니메이션을 제공합니다. 또한, Dart는 가비지 컬렉션과 JIT(Just In Time) 컴파일을 지원하여 개발 중에 빠른 반복을 가능하게 합니다.
  • 유연성: Dart는 클라이언트 사이드와 서버 사이드 모두에서 사용할 수 있습니다. 이는 Dart가 모바일, 웹, 서버 개발에 모두 사용될 수 있다는 것을 의미합니다.
  • 통합 개발 환경: Dart는 강력한 툴체인을 가지고 있습니다. 이에는 코드 자동 완성, 실시간 오류 검출, 디버깅 등이 포함되어 있습니다.

Dart의 단점

그러나 Dart 역시 단점들을 가지고 있습니다:

  • 인기도: JavaScript에 비해 Dart의 인기도는 상대적으로 낮습니다. 이는 Dart에 대한 튜토리얼과 라이브러리가 상대적으로 적다는 것을 의미합니다.
  • 학습 곡선: Dart는 C 스타일의 문법을 가지고 있지만, 그것이 늘 다른 언어와 쉽게 호환되는 것은 아닙니다. 특히, Dart의 비동기 프로그래밍은 초보자에게 어려울 수 있습니다.

JavaScript의 장단점

JavaScript의 장점

JavaScript는 다음과 같은 몇 가지 주요 장점을 가지고 있습니다:

  • 범용성: JavaScript는 웹의 기본 언어로, 거의 모든 웹 브라우저에서 지원됩니다. 또한, Node.js를 통해 서버 사이드 프로그래밍도 가능합니다.
  • 풍부한 라이브러리와 프레임워크: JavaScript는 다양한 라이브러리와 프레임워크를 가지고 있어, 개발자들이 다양한 웹 애플리케이션을 쉽게 개발할 수 있습니다.
  • 커뮤니티 지원: JavaScript는 매우 크고 활발한 커뮤니티를 가지고 있어, 문제를 해결하거나 새로운 기술을 배울 때 도움을 받기 쉽습니다.

JavaScript의 단점

그러나 JavaScript도 몇 가지 단점을 가지고 있습니다:

  • 동적 타입: JavaScript는 동적 타입 언어로, 실행 시간에 오류를 발견하기 쉽습니다. 이는 디버깅을 어렵게 만들 수 있습니다.
  • 비표준적인 동작: 다양한 브라우저가 JavaScript를 다르게 해석하고 실행하기 때문에, 동일한 JavaScript 코드가 모든 브라우저에서 동일하게 동작하지 않을 수 있습니다.

Dart와 JavaScript 선택 시 고려해야 할 요소

프로그래밍 언어 선택은 프로젝트의 요구 사항, 개발 팀의 능력, 사용 가능한 리소스 등 여러 요소에 따라 달라집니다. Dart와 JavaScript 사이에서 선택할 때 고려해야 할 몇 가지 주요 요소들은 다음과 같습니다:

  • 프로젝트의 규모: 대규모 프로젝트의 경우, Dart의 강력한 툴체인과 정적 타입 시스템이 프로젝트 관리를 더 쉽게 만들 수 있습니다. 반면에, 작은 프로젝트나 프로토타입 개발에는 JavaScript의 유연성과 간결성이 더 유용할 수 있습니다.
  • 개발 팀의 능력: 팀이 JavaScript에 이미 익숙하다면, JavaScript를 선택하는 것이 팀의 생산성을 높일 수 있습니다. 반면에, 팀이 새로운 도전을 원하거나 Dart의 특정 기능(예: Flutter와의 통합)을 활용하려는 경우, Dart를 선택할 수 있습니다.
  • 커뮤니티 지원: JavaScript는 큰 커뮤니티와 풍부한 라이브러리를 가지고 있습니다. 이는 문제 해결과 새로운 기술 학습을 쉽게 만듭니다. 반면에, Dart의 커뮤니티는 상대적으로 작지만, Flutter와 같은 프레임워크의 인기 상승으로 커뮤니티가 성장하고 있습니다.

결론

Dart와 JavaScript는 각각의 독특한 장점과 단점을 가지고 있습니다. Dart는 강력한 툴체인, 정적 타입 시스템, 그리고 Flutter와의 통합 등으로 인해 매력적인 선택지가 될 수 있습니다. 반면에, JavaScript는 웹의 기본 언어로서의 위치, 풍부한 라이브러리와 프레임워크, 그리고 큰 커뮤니티 지원 등으로 인해 여전히 강력한 프로그래밍 언어입니다.

따라서, Dart와 JavaScript 중 어떤 것을 선택할지는 개별 프로젝트의 요구 사항, 개발 팀의 능력, 그리고 사용 가능한 리소스 등 여러 요소에 따라 달라질 것입니다. 어떤 언어를 선택하든, 선택한 언어를 통해 효과적이고 유지 관리가 쉬운 코드를 작성하는 것이 가장 중요합니다.

Thursday, July 13, 2023

Evolving Your Data: Techniques for Adding Properties to JavaScript Objects

In the landscape of modern web development, JavaScript objects are the fundamental building blocks for structuring and managing data. They are versatile, dynamic collections of key-value pairs, serving as the backbone for everything from simple data storage to complex application state. A core operation in working with these objects is the ability to add new properties or update existing ones. This seemingly simple task has multiple facets and methods in JavaScript, each with its own nuances, performance characteristics, and implications for code maintainability, especially concerning the concept of mutability.

Before diving into the mechanics, it's crucial to clarify a common point of confusion: the relationship between JavaScript Objects and JSON (JavaScript Object Notation). JSON is a lightweight data-interchange format. It is a text-based representation of data, derived from the syntax for JavaScript object literals. A JavaScript object, on the other hand, is a dynamic data structure in memory within a running JavaScript program. While we often parse JSON strings into JavaScript objects (using JSON.parse()) and serialize objects into JSON strings (using JSON.stringify()), the methods we will discuss apply to the in-memory JavaScript objects themselves, not the static JSON string format.

Understanding how to correctly and efficiently add properties to these objects is not just about syntax; it's about writing predictable, bug-free, and scalable code. Whether you are directly modifying an object, creating a new one with merged properties, or ensuring the immutability of your application's state, the choice of method matters. This article provides an in-depth exploration of the primary techniques for adding properties to JavaScript objects, from basic direct assignment to modern, declarative approaches using `Object.assign()` and the spread syntax, while also touching upon advanced concepts like property descriptors and deep cloning.

1. The Foundational Approach: Direct Property Assignment

The most direct and fundamental way to add a property to a JavaScript object is through direct assignment. This method modifies the object in place—an action known as mutation. JavaScript provides two syntactical flavors for this operation: Dot Notation and Bracket Notation.

Dot Notation (object.property)

Dot notation is the most common and readable syntax for accessing and assigning properties. It is used when the property key is a valid JavaScript identifier.

A valid JavaScript identifier must adhere to the following rules:

  • It must start with a letter (a-z, A-Z), an underscore (_), or a dollar sign ($).
  • Subsequent characters can be letters, digits (0-9), underscores, or dollar signs.
  • It cannot be a reserved JavaScript keyword (e.g., if, for, class).

Here's how you use dot notation to add new properties to an existing object:


// Start with an empty user profile object
let userProfile = {};

// Add properties using dot notation
userProfile.username = 'dev_user';
userProfile.email = 'dev@example.com';
userProfile.loginCount = 1;

console.log(userProfile);
// Output: { username: 'dev_user', email: 'dev@example.com', loginCount: 1 }

// You can also update an existing property the same way
userProfile.loginCount = 2;
console.log(userProfile);
// Output: { username: 'dev_user', email: 'dev@example.com', loginCount: 2 }

This method is simple, intuitive, and highly performant. For most straightforward cases where property keys are known ahead of time and are valid identifiers, dot notation is the preferred choice.

Bracket Notation (object['property'])

Bracket notation is a more versatile and powerful alternative to dot notation. It allows you to use any string as a property key, including those that are not valid identifiers. This is essential for keys that contain spaces, hyphens, start with numbers, or are JavaScript reserved words.


let reportData = {};

// Using keys that are not valid identifiers
reportData['report-id'] = 'rpt_12345';
reportData['Total Revenue'] = 50000;
reportData['1st-quarter-sales'] = 20000;
reportData['for'] = 'Q1 Report'; // 'for' is a reserved keyword

console.log(reportData);
/*
Output: {
  'report-id': 'rpt_12345',
  'Total Revenue': 50000,
  '1st-quarter-sales': 20000,
  'for': 'Q1 Report'
}
*/

The true power of bracket notation, however, lies in its ability to use variables or expressions to define the property key dynamically. This is impossible with dot notation, which treats the text after the dot as a literal string.


let settings = {};
let settingKey = 'theme';
let settingValue = 'dark';

// The variable `settingKey` is evaluated, and its value ('theme') becomes the property key.
settings[settingKey] = settingValue;

console.log(settings);
// Output: { theme: 'dark' }

// This is extremely useful in loops or when processing dynamic data
const preferences = ['language', 'timezone', 'notifications'];
const userPreferences = {};

preferences.forEach(pref => {
  // Use the value of `pref` as the key for each property
  userPreferences[pref] = getUserPreference(pref); // Assume getUserPreference is a function
});

function getUserPreference(key) {
  const values = { language: 'en', timezone: 'UTC', notifications: true };
  return values[key];
}

console.log(userPreferences);
// Output: { language: 'en', timezone: 'UTC', notifications: true }

Implications of Mutability

Both dot and bracket notation directly mutate the object they are called on. In many contexts, this is perfectly fine and intended. However, in modern JavaScript applications, especially those using frameworks like React or functional programming paradigms, immutability is a core principle. Mutating an object directly can lead to unpredictable side effects, difficult-to-track bugs, and performance issues in frameworks that rely on reference checking to detect changes.

When you pass an object to a function, you are passing a reference to that object. If the function modifies the object, that change is reflected everywhere that reference is used, which may not be the intended behavior.


const originalConfig = {
  version: '1.0'
};

function addTimestamp(config) {
  // This function MUTATES the original object
  config.timestamp = Date.now();
  return config;
}

// The originalConfig object is modified
addTimestamp(originalConfig);

console.log(originalConfig);
// Output: { version: '1.0', timestamp: 1678886400000 } (example timestamp)

To avoid this, you need methods that create a *new* object with the added properties, which leads us to the next techniques.

2. The Merger Approach: `Object.assign()`

Introduced in ECMAScript 2015 (ES6), the Object.assign() method provides a way to copy the values of all enumerable own properties from one or more source objects to a target object. It returns the modified target object.

The syntax is: Object.assign(target, ...sources)

  • target: The object to which properties will be copied. This object is mutated.
  • sources: One or more objects from which to copy properties.

Properties in later source objects will overwrite properties with the same key in earlier ones.


const user = { name: 'Alice', id: 101 };
const permissions = { canPost: true, canComment: true };
const userDetails = { name: 'Alice B.', city: 'New York' };

// Merge permissions and userDetails into the user object
// Note: This mutates the 'user' object
const updatedUser = Object.assign(user, permissions, userDetails);

console.log(user);
// Output: { name: 'Alice B.', id: 101, canPost: true, canComment: true, city: 'New York' }

console.log(updatedUser === user); // true, because 'user' was the target and was modified in place.

In the example above, the name property from userDetails overwrote the original name in user because it appeared later in the list of sources.

Achieving Immutability with `Object.assign()`

The key to using Object.assign() in an immutable way is to provide an empty object ({}) as the first argument (the target). This ensures that none of the original source objects are modified. Instead, a brand new object is created and returned.


const defaultConfig = { timeout: 5000, retries: 3 };
const userConfig = { retries: 5, verbose: true };

// Create a new object by merging defaultConfig and userConfig
// Neither original object is changed.
const finalConfig = Object.assign({}, defaultConfig, userConfig);

console.log(finalConfig);
// Output: { timeout: 5000, retries: 5, verbose: true }

console.log(defaultConfig); // { timeout: 5000, retries: 3 } - Unchanged
console.log(userConfig);    // { retries: 5, verbose: true } - Unchanged

This pattern is fundamental for tasks like setting default options in a function or updating state in certain state management patterns without causing side effects.

The Critical Caveat: Shallow Copying

A crucial detail about Object.assign() is that it performs a shallow copy, not a deep copy. This means that if a property's value is another object or an array, only the reference to that nested object is copied, not the object itself. Modifying the nested object in the new, copied structure will also modify it in the original source object.

This is a common source of bugs for developers who are not aware of this behavior.


const original = {
  id: 1,
  metadata: {
    author: 'John Doe',
    tags: ['js', 'es6']
  }
};

// Create a shallow copy
const copy = Object.assign({}, original);

// Modify a property on the nested 'metadata' object in the copy
copy.metadata.author = 'Jane Smith';
copy.id = 2; // Modify a primitive property

console.log(original.id);
// Output: 1 (The primitive value was not affected)

console.log(original.metadata.author);
// Output: 'Jane Smith' (The original object's nested property was mutated!)

Because copy.metadata and original.metadata point to the exact same object in memory, a change via one reference is visible through the other. To create a fully independent copy (a deep clone), you would need to use other techniques, such as a recursive function, a library like Lodash's _.cloneDeep(), or the modern structuredClone() API.

3. The Modern Syntax: Spread Syntax (`...`)

Introduced in ECMAScript 2018 (ES9) for objects, the spread syntax (...) provides a concise and highly readable way to accomplish the same immutable merging as Object.assign({}, ...). It has quickly become the preferred method for many developers due to its declarative nature.

The spread syntax "spreads out" the key-value pairs of an object into a new object literal.


const base = { a: 1, b: 2 };
const extension = { b: 3, c: 4 };

// Create a new object by spreading properties from base and extension
const merged = { ...base, ...extension };

console.log(merged);
// Output: { a: 1, b: 3, c: 4 }

console.log(base);      // { a: 1, b: 2 } - Unchanged
console.log(extension); // { b: 3, c: 4 } - Unchanged

Just like with Object.assign(), the order matters. Properties from objects spread later will overwrite those from objects spread earlier.

The spread syntax is not just for merging existing objects; it's also an elegant way to add new properties to create a new object.


const book = {
  title: 'The Pragmatic Programmer',
  author: 'David Thomas'
};

// Create a new object with an added 'publicationYear' property
const updatedBook = { ...book, publicationYear: 1999 };

console.log(updatedBook);
// Output: { title: 'The Pragmatic Programmer', author: 'David Thomas', publicationYear: 1999 }

// Create a new object with an updated 'author' and a new 'pages' property
const revisedBook = { ...book, author: 'Andrew Hunt & David Thomas', pages: 352 };
console.log(revisedBook);
// Output: { title: 'The Pragmatic Programmer', author: 'Andrew Hunt & David Thomas', pages: 352 }

Shallow Copying with Spread Syntax

It is vital to remember that the spread syntax, like Object.assign(), also performs a shallow copy. It carries the exact same risk of unintentional mutation of nested objects.


const userProfile = {
  name: 'Alex',
  preferences: {
    theme: 'dark',
    notifications: {
      email: true,
      push: false
    }
  }
};

const newProfile = { ...userProfile, name: 'Alexandra' };

// Let's change a nested preference
newProfile.preferences.theme = 'light';

console.log(userProfile.preferences.theme);
// Output: 'light' -- The original object was unintentionally mutated!

When working with nested data structures, you must handle the cloning of each level of the object manually if you need a deep copy.


// Correctly updating a nested object immutably
const updatedProfile = {
  ...userProfile,
  name: 'Alexandra',
  preferences: {
    ...userProfile.preferences, // Spread the nested object
    theme: 'light'             // Overwrite only the desired property at this level
  }
};

console.log(userProfile.preferences.theme);
// Output: 'dark' -- The original is now safe!
console.log(updatedProfile.preferences.theme);
// Output: 'light'

This pattern of "spreading at every level" is very common in state management libraries like Redux.

Comparison: Which Method Should You Use?

Choosing the right method depends on your specific goal, your project's codebase conventions, and required browser compatibility.

Feature Direct Assignment (. or []) Object.assign() Spread Syntax (...)
Mutability Mutates the original object. Mutates the target (first) argument. Can be used immutably by passing {} as the target. Always creates a new object (immutable).
Syntax Simple and direct (obj.prop = val). Functional and more verbose (Object.assign({}, obj, {prop: val})). Declarative and concise ({...obj, prop: val}).
Copy Depth Not applicable (direct modification). Shallow copy. Shallow copy.
ES Version ES1 (Original JavaScript) ES6 (2015) ES9 (2018) for objects
Typical Use Case Building an object step-by-step within a local scope where mutation is safe and intended. Merging multiple source objects, polyfills, or working in ES6 environments without transpilers for newer features. Modern JavaScript development, especially in React/Vue/Redux for state updates, and creating shallow clones or merged objects with superior readability.

Beyond the Basics: Advanced Property Definition

While the three methods above cover over 99% of use cases, JavaScript provides a lower-level, more powerful mechanism for defining properties: Object.defineProperty(). This method gives you fine-grained control over a property's characteristics, known as its "property descriptor."

A property descriptor is an object that defines the attributes of a property, such as:

  • value: The value of the property.
  • writable: If true, the property's value can be changed.
  • enumerable: If true, the property will appear in for...in loops and Object.keys().
  • configurable: If true, the property can be deleted, and its attributes can be changed.

When you add a property via direct assignment, writable, enumerable, and configurable all default to true. With Object.defineProperty(), they all default to false.


const obj = {};

Object.defineProperty(obj, 'readOnlyId', {
  value: 42,
  writable: false, // This property cannot be changed
  enumerable: true // It will show up in loops
});

console.log(obj.readOnlyId); // 42

try {
  obj.readOnlyId = 50; // This will fail
} catch (e) {
  console.error(e); // In strict mode, this throws a TypeError
}

console.log(obj.readOnlyId); // Still 42

// Create a non-enumerable property
Object.defineProperty(obj, 'internalSecret', {
    value: 'secret-key',
    enumerable: false
});

console.log(Object.keys(obj)); // ['readOnlyId'] - 'internalSecret' is not listed
console.log(obj.internalSecret); // 'secret-key' - but it can be accessed directly

This method is rarely needed for everyday data manipulation but is invaluable for creating APIs, libraries, or frameworks where you need to define immutable properties or hide internal implementation details from enumeration.

Conclusion

Manipulating object properties is a daily task for any JavaScript developer. While direct assignment using dot or bracket notation is the simplest way to mutate an object, the modern JavaScript ecosystem increasingly favors immutable patterns. For this, the spread syntax (...) offers the most concise and readable approach for creating new objects with updated or added properties. Object.assign() remains a perfectly viable and important tool, especially when dealing with multiple source objects or in older codebases.

The most critical takeaway is the distinction between mutable and immutable operations and the concept of a shallow copy. Understanding that both Object.assign() and the spread syntax only copy one level deep is essential to prevent subtle bugs that arise from shared references in nested data structures. By choosing the right technique for your context—be it quick and mutable, or safe and immutable—you can write cleaner, more predictable, and more maintainable JavaScript code.

자바스크립트 객체 프로퍼티 추가 및 수정 심층 분석

자바스크립트에서 객체(Object)는 언어의 핵심을 이루는 가장 중요한 데이터 구조입니다. 원시 타입을 제외한 거의 모든 것이 객체로 취급되며, 개발자는 끊임없이 객체를 생성하고, 수정하며, 확장하는 작업을 수행합니다. 객체는 키(key)와 값(value)의 쌍으로 이루어진 프로퍼티(property)들의 동적인 컬렉션으로, 이러한 프로퍼티를 어떻게 효율적으로 다루는지는 코드의 품질과 직결됩니다.

많은 개발자들이 자바스크립트 객체를 단순히 'JSON과 유사한 것' 또는 '맵(Map)과 비슷한 것'으로 간주하곤 합니다. 하지만 이는 정확한 이해가 아닙니다. JSON(JavaScript Object Notation)은 데이터를 교환하기 위한 경량의 문자열 형식이며, 자바스크립트 객체 리터럴({})에서 파생되었지만 둘은 엄연히 다릅니다. 또한, ES6에서 도입된 Map 객체는 키-값 쌍을 저장한다는 점은 같지만, 키로 다양한 타입을 허용하고 순서를 보장하는 등 고유한 특징과 메서드를 가진 별개의 자료구조입니다. 이 글에서는 순수한 자바스크립트 일반 객체를 중심으로, 프로퍼티를 추가하고 수정하는 다양한 기법들을 기초부터 심화까지 면밀히 분석하고 각 방법의 장단점과 적절한 사용 시나리오를 제시합니다.


1. 가장 기본적인 접근: 직접 할당 (Direct Assignment)

객체 프로퍼티를 추가하는 가장 직관적이고 기본적인 방법은 객체에 직접 값을 할당하는 것입니다. 자바스크립트는 이를 위해 두 가지 표기법을 제공합니다: 점 표기법(Dot Notation)대괄호 표기법(Bracket Notation)입니다. 이 두 방식은 유사해 보이지만, 결정적인 차이점을 가지며 상황에 따라 선택적으로 사용해야 합니다.

점 표기법 (Dot Notation)

점 표기법은 가장 흔하게 사용되는 방식으로, 객체 이름 뒤에 점(.)을 찍고 프로퍼티 이름을 명시하여 값을 할당하거나 접근합니다. 코드가 간결하고 가독성이 높다는 장점이 있습니다.


const person = {
  name: 'Alice'
};

// 'age' 프로퍼티 추가
person.age = 30;

// 'email' 프로퍼티 추가
person.email = 'alice@example.com';

console.log(person);
// 출력: { name: 'Alice', age: 30, email: 'alice@example.com' }

하지만 점 표기법에는 명확한 한계가 존재합니다. 프로퍼티 이름(키)이 반드시 유효한 자바스크립트 식별자(Identifier) 규칙을 따라야 합니다.

  • 문자, 밑줄(_), 또는 달러 기호($)로 시작해야 합니다.
  • 두 번째 글자부터는 숫자도 포함할 수 있습니다.
  • 공백이나 하이픈(-), 예약어 등은 사용할 수 없습니다.

만약 이러한 규칙을 어기는 프로퍼티 이름을 사용하려고 하면 구문 오류(SyntaxError)가 발생합니다.


const car = {};

// 유효한 식별자
car.model_name = 'Model S'; // (O)

// 유효하지 않은 식별자 - SyntaxError 발생
// car.top-speed = 300; // 하이픈(-)은 뺄셈 연산자로 해석됨
// car.1st_owner = 'Bob'; // 숫자로 시작할 수 없음

대괄호 표기법 (Bracket Notation)

대괄호 표기법은 점 표기법의 한계를 극복하는 강력한 대안입니다. 이 방식은 프로퍼티 이름을 문자열로 평가하여 사용하기 때문에, 자바스크립트 식별자 규칙에 얽매이지 않고 어떤 문자열이든 키로 사용할 수 있습니다.


const settings = {};

// 공백이 포함된 프로퍼티 이름
settings['user interface theme'] = 'dark';

// 하이픈이 포함된 프로퍼티 이름
settings['font-size'] = 16;

// 숫자로 시작하는 프로퍼티 이름
settings['1st-priority'] = 'performance';

console.log(settings);
// 출력: {
//   'user interface theme': 'dark',
//   'font-size': 16,
//   '1st-priority': 'performance'
// }

대괄호 표기법의 진정한 힘은 프로퍼티 이름을 동적으로 생성할 때 드러납니다. 변수나 표현식의 결과를 프로퍼티 키로 사용할 수 있기 때문입니다. 이는 런타임에 키가 결정되는 상황에서 필수적입니다.


const dataStore = {};
const keyName = 'user_id';
const value = 12345;

// 변수 `keyName`의 값인 'user_id'를 프로퍼티 키로 사용
dataStore[keyName] = value;

console.log(dataStore); // 출력: { user_id: 12345 }

// 함수와 반복문을 이용한 동적 프로퍼티 추가
function addDynamicProperties(obj, propArray) {
  for (let i = 0; i < propArray.length; i++) {
    const propName = `property_${i + 1}`;
    obj[propName] = propArray[i];
  }
}

const myObject = {};
addDynamicProperties(myObject, ['A', 'B', 'C']);

console.log(myObject);
// 출력: { property_1: 'A', property_2: 'B', property_3: 'C' }

위 예시처럼, 변수나 표현식을 사용해 프로퍼티 이름을 동적으로 결정하는 것은 점 표기법으로는 불가능합니다. 따라서 서버로부터 받은 데이터의 키를 기반으로 객체를 구성하거나, 사용자의 입력에 따라 객체의 구조를 변경해야 할 때 대괄호 표기법은 لا غنى عنه인 도구입니다.

핵심: 직접 할당 방식은 기존 객체를 직접 수정(mutate)합니다. 즉, 원본 객체 자체가 변경됩니다. 이는 간단한 스크립트에서는 문제가 되지 않지만, 객체의 불변성(immutability)이 중요한 React나 Vue와 같은 프레임워크 환경에서는 의도치 않은 부작용(side effects)을 유발할 수 있으므로 주의해야 합니다.


2. 여러 프로퍼티의 병합: Object.assign()

ES6(ECMAScript 2015)에서 도입된 Object.assign() 메서드는 하나 이상의 출처(source) 객체로부터 대상(target) 객체로 프로퍼티를 복사하는 데 사용됩니다. 이를 이용해 여러 프로퍼티를 한 번에 추가하거나 객체들을 병합할 수 있습니다.

메서드의 시그니처는 다음과 같습니다: Object.assign(target, ...sources)

  • target: 프로퍼티를 복사하여 붙여넣을 대상 객체. 이 객체는 직접 수정됩니다.
  • sources: 프로퍼티를 복사해 올 출처 객체들. 여러 개를 전달할 수 있습니다.

Object.assign()은 출처 객체들의 열거 가능한(enumerable) 자체(own) 프로퍼티만을 대상 객체에 복사합니다. 상속받은 프로퍼티나 열거 불가능한 프로퍼티는 복사되지 않습니다.

기존 객체에 프로퍼티 추가 (Mutable Pattern)

기존 객체를 target으로 지정하면, 해당 객체에 새로운 프로퍼티들이 추가되거나 덮어씌워집니다.


const user = {
  name: 'John Doe',
  age: 30
};

const details = {
  city: 'New York',
  age: 31 // 'user' 객체의 'age' 프로퍼티와 충돌
};

// user 객체에 details 객체의 프로퍼티를 병합
// user 객체가 직접 변경됨
Object.assign(user, details);

console.log(user);
// 출력: { name: 'John Doe', age: 31, city: 'New York' }

위 예제에서 볼 수 있듯, 동일한 키('age')가 존재할 경우, 뒤따라오는 출처 객체(details)의 값이 대상 객체(user)의 기존 값을 덮어씁니다. 이 방식은 명시적으로 원본 객체를 변경하고자 할 때 유용합니다.

새로운 객체 생성 (Immutable Pattern)

원본 객체들을 변경하지 않고, 모든 프로퍼티가 병합된 새로운 객체를 만들고 싶다면, target 인자로 빈 객체({})를 전달하면 됩니다. 이는 불변성을 유지하는 매우 일반적인 패턴입니다.


const defaults = {
  theme: 'light',
  fontSize: 14,
  showToolbar: true
};

const userSettings = {
  fontSize: 16,
  theme: 'dark'
};

// 빈 객체를 타겟으로 하여, 원본 객체들을 변경하지 않고 새로운 객체를 생성
const finalSettings = Object.assign({}, defaults, userSettings);

console.log(finalSettings);
// 출력: { theme: 'dark', fontSize: 16, showToolbar: true }

console.log(defaults);
// 출력: { theme: 'light', fontSize: 14, showToolbar: true } (원본 불변)
console.log(userSettings);
// 출력: { fontSize: 16, theme: 'dark' } (원본 불변)

얕은 복사(Shallow Copy)의 함정

Object.assign()을 사용할 때 반드시 이해해야 할 중요한 개념은 이 메서드가 얕은 복사(Shallow Copy)를 수행한다는 점입니다. 만약 프로퍼티의 값이 객체나 배열과 같은 참조 타입이라면, 값 자체가 복사되는 것이 아니라 메모리 주소(참조)만 복사됩니다.

이로 인해 원본 객체와 복사된 객체가 내부의 중첩된 객체를 공유하게 되며, 한쪽에서 이 중첩된 객체를 변경하면 다른 쪽에도 영향을 미치는 부작용이 발생할 수 있습니다.


const original = {
  id: 1,
  meta: {
    author: 'Admin',
    tags: ['js', 'object']
  }
};

const clone = Object.assign({}, original);

// clone의 중첩 객체 프로퍼티를 수정
clone.meta.author = 'Editor';
clone.meta.tags.push('es6');

console.log(original.meta.author); // 출력: 'Editor'
console.log(original.meta.tags); // 출력: ['js', 'object', 'es6']

위 코드에서 clone 객체의 meta.author를 변경했는데, 원본인 original 객체의 값까지 함께 변경되었습니다. 이는 original.metaclone.meta가 동일한 객체를 가리키고 있기 때문입니다. 이러한 동작을 피하고 싶다면, 중첩된 구조까지 모두 복사하는 깊은 복사(Deep Copy)를 수행해야 하며, 이는 Lodash 라이브러리의 _.cloneDeep() 함수를 사용하거나 직접 재귀 함수를 구현하여 해결해야 합니다.


3. 현대적이고 간결한 방식: 전개 구문 (Spread Syntax)

ES2018(ES9)부터 객체 리터럴에서도 전개 구문(...)을 사용할 수 있게 되었습니다. 이는 Object.assign()을 이용한 불변적 병합 패턴을 훨씬 더 간결하고 가독성 높게 표현할 수 있게 해주는 현대적인 방식입니다.

전개 구문은 항상 새로운 객체를 생성하며, 기존 객체를 펼쳐서 그 프로퍼티들을 새로운 객체 리터럴 안에 포함시킵니다.


const obj1 = { a: 1, b: 2 };
const obj2 = { b: 3, c: 4 };

// obj1과 obj2의 프로퍼티를 펼쳐서 새로운 객체를 생성
const combinedObj = { ...obj1, ...obj2 };

console.log(combinedObj);
// 출력: { a: 1, b: 3, c: 4 }

프로퍼티 덮어쓰기 규칙은 Object.assign()과 동일합니다. 나중에 위치한 객체의 프로퍼티가 이전에 위치한 객체의 동일한 프로퍼티를 덮어씁니다. 전개 구문의 큰 장점 중 하나는 기존 프로퍼티를 병합하면서 동시에 새로운 프로퍼티를 쉽게 추가할 수 있다는 점입니다.


const baseUser = {
  id: 'user01',
  name: 'Jane'
};

const userWithRole = {
  ...baseUser,
  role: 'admin', // 새로운 프로퍼티 추가
  name: 'Jane Smith' // 기존 프로퍼티 덮어쓰기
};

console.log(userWithRole);
// 출력: { id: 'user01', name: 'Jane Smith', role: 'admin' }

console.log(baseUser);
// 출력: { id: 'user01', name: 'Jane' } (원본 불변)

이러한 간결함과 가독성 덕분에, React와 같은 상태 관리 라이브러리에서 이전 상태를 기반으로 새로운 상태를 만들 때 전개 구문은 거의 표준처럼 사용됩니다.


// React 상태 업데이트 예시
// this.setState(prevState => ({
//   user: {
//     ...prevState.user,
//     isLoggedIn: true
//   }
// }));

전개 구문과 얕은 복사

중요한 점은 전개 구문 역시 Object.assign()과 마찬가지로 얕은 복사를 수행한다는 것입니다. 따라서 중첩된 객체를 다룰 때는 동일한 주의가 필요합니다.


const original = {
  id: 1,
  meta: { author: 'Admin' }
};

const clone = { ...original };

clone.meta.author = 'Editor';

console.log(original.meta.author); // 출력: 'Editor' (원본 객체가 변경됨)

전개 구문은 문법적 설탕(syntactic sugar)에 가까우며, 내부 동작 원리는 Object.assign()과 매우 유사합니다. 따라서 문법의 편리함에 가려져 얕은 복사의 특성을 잊어서는 안 됩니다.


4. 정교한 제어의 세계: Object.defineProperty()

지금까지 소개된 방법들은 단순히 값을 할당하는 것에 초점이 맞춰져 있습니다. 하지만 때로는 프로퍼티의 동작 자체를 세밀하게 제어하고 싶을 때가 있습니다. 예를 들어, 읽기 전용(read-only) 프로퍼티를 만들거나, 특정 프로퍼티가 for...in 루프에서 보이지 않게 숨기거나, 삭제할 수 없도록 만들고 싶을 수 있습니다. 이때 사용하는 것이 바로 Object.defineProperty() 메서드입니다.

이 메서드는 객체에 직접 프로퍼티를 정의하거나 이미 존재하는 프로퍼티의 속성을 수정합니다. 이 때 프로퍼티의 동작을 기술하는 프로퍼티 서술자(Property Descriptor) 객체를 사용합니다.

프로퍼티 서술자에는 두 가지 유형이 있으며, 주요 키들은 다음과 같습니다.

  • 데이터 서술자 (Data Descriptor)
    • value: 프로퍼티의 값.
    • writable: true일 경우 할당 연산자로 값을 수정할 수 있습니다. (기본값: false)
  • 접근자 서술자 (Accessor Descriptor)
    • get: 프로퍼티에 접근할 때 호출되는 함수.
    • set: 프로퍼티에 값을 할당할 때 호출되는 함수.
  • 공통 키
    • enumerable: true일 경우 for...in 루프나 Object.keys()와 같은 열거 작업에 포함됩니다. (기본값: false)
    • configurable: true일 경우 프로퍼티를 삭제하거나 서술자를 다시 수정할 수 있습니다. (기본값: false)

일반적인 할당 방식(obj.prop = value)은 writable, enumerable, configurable이 모두 true인 프로퍼티를 생성하는 것과 같습니다. 반면 Object.defineProperty()는 이 값들의 기본값이 false이므로 훨씬 더 엄격한 제어가 가능합니다.


const user = {};

// 읽기 전용, 열거 가능, 삭제 불가능한 'id' 프로퍼티 정의
Object.defineProperty(user, 'id', {
  value: 'abc-123',
  writable: false,
  enumerable: true,
  configurable: false
});

console.log(user.id); // 출력: 'abc-123'

// 값 변경 시도 (엄격 모드에서는 TypeError 발생, 비엄격 모드에서는 조용히 실패)
try {
  user.id = 'def-456';
} catch(e) {
  console.error(e);
}
console.log(user.id); // 여전히 'abc-123'

// 삭제 시도 (false 반환, 엄격 모드에서는 TypeError)
delete user.id;
console.log(user.id); // 여전히 'abc-123'

// 열거는 가능
console.log(Object.keys(user)); // 출력: ['id']

// 여러 프로퍼티를 한번에 정의할 때는 Object.defineProperties() 사용
Object.defineProperties(user, {
  _name: { // 데이터를 저장할 숨겨진 프로퍼티
    value: 'Chris',
    writable: true,
    enumerable: false
  },
  name: { // 외부에 노출될 접근자 프로퍼티
    get() {
      return this._name;
    },
    set(value) {
      if (value.length > 0) {
        this._name = value;
      } else {
        console.error('이름은 비어 있을 수 없습니다.');
      }
    },
    enumerable: true,
    configurable: true
  }
});

user.name = 'Christopher';
console.log(user.name); // 출력: 'Christopher'

user.name = ''; // 에러 메시지 출력
console.log(user.name); // 여전히 'Christopher'
console.log(Object.keys(user)); // 출력: ['id', 'name'] (_name은 보이지 않음)

Object.defineProperty()는 프레임워크나 라이브러리 내부에서 객체의 상태를 안전하게 관리하거나, 외부에 공개되는 API의 동작을 정밀하게 설계할 때 필수적인 도구입니다. 일반적인 애플리케이션 개발에서는 자주 사용되지 않을 수 있지만, 자바스크립트 객체의 내부 동작을 깊이 이해하는 데 큰 도움이 됩니다.


결론: 상황별 최적의 선택 가이드

자바스크립트에서 객체 프로퍼티를 추가하는 다양한 방법을 살펴보았습니다. 어떤 방법을 선택할지는 코드의 요구사항과 컨텍스트에 따라 달라집니다. 다음은 상황별 권장 가이드입니다.

방법 주요 사용 사례 원본 객체 변경 여부 장점 주의사항
직접 할당
(점/대괄호 표기법)
단일 프로퍼티를 간단히 추가/수정할 때. 동적 키를 사용해야 할 때. 변경 (Mutable) 가장 간단하고 직관적이며 빠름. 불변성이 중요한 환경에서 부작용 유발 가능.
Object.assign() 여러 객체를 병합할 때. ES6 환경에서 불변성을 유지하며 객체를 복사/병합할 때. 불변 패턴 가능 ({} 사용 시) 여러 소스 객체를 한 번에 처리. 명시적. 얕은 복사로 인한 중첩 객체 공유 문제.
전개 구문 (...) React/Vue 등 현대 프레임워크에서의 상태 업데이트. 불변성을 유지하며 객체를 확장/병합할 때. 항상 불변 (Immutable) 간결하고 가독성이 매우 높음. 선언적. 얕은 복사로 인한 중첩 객체 공유 문제.
Object.defineProperty() 읽기 전용, 열거 불가 등 프로퍼티의 동작을 정밀하게 제어해야 할 때. (라이브러리/프레임워크 개발) 변경 (Mutable) 프로퍼티 속성에 대한 완벽한 제어 가능. 코드가 길고 복잡해짐. 일반적인 용도로는 과함.

결론적으로, 현대 자바스크립트 개발에서는 전개 구문(...)이 불변성을 지키면서 객체를 다루는 가장 일반적이고 권장되는 방법입니다. 하지만 동적인 키를 다룰 때는 대괄호 표기법이 필수적이며, 객체의 내부 동작을 깊이 있게 제어해야 하는 특별한 상황에서는 Object.defineProperty()의 존재를 기억하는 것이 중요합니다. 각 방법의 특성과 장단점을 명확히 이해하고 상황에 맞는 최적의 도구를 선택하는 것이 견고하고 유연한 코드를 작성하는 핵심입니다.

JavaScriptオブジェクトのプロパティ操作: 動的追加から不変性の維持まで

JavaScriptにおけるオブジェクトは、単なるデータコンテナ以上の存在です。それは、アプリケーションの構造を形作り、状態を管理し、ロジックをカプセル化する、言語の中核をなす動的なエンティティです。この動的性の根幹にあるのが、オブジェクトのプロパティをいつでも自由に追加、変更、削除できるという特性です。この柔軟性はJavaScriptの大きな強みである一方、その操作方法を深く理解していなければ、予期せぬバグやパフォーマンスの低下、保守性の低いコードを生み出す原因ともなり得ます。

本稿では、JavaScriptオブジェクトにプロパティを追加するための様々な手法を、基本的なアプローチからモダンなES6以降の構文、さらには高度な制御を可能にするメソッドまで、網羅的に探求します。単に「どう書くか」だけでなく、「なぜその方法を選ぶのか」「それぞれの方法がもたらす影響は何か」という点に焦点を当て、各手法の長所、短所、そして最適な利用シナズリオを詳細に解説します。オブジェクトのミュータビリティ(可変性)とイミュータビリティ(不変性)の概念にも触れながら、現代のフロントエンド開発、特にReactやVueのような宣言的UIフレームワークにおいて、いかにして安全かつ効率的に状態を管理するかという実践的な視点も提供します。この記事を読み終える頃には、あなたは単なるコードの書き手から、データ構造を意のままに操る熟練した開発者へと一歩近づいていることでしょう。

第1章: 基本の「き」 - プロパティへの直接代入

JavaScriptでオブジェクトにプロパティを追加する最も直接的で古典的な方法は、代入演算子(=)を使用することです。このアプローチには、主に「ドット記法」と「ブラケット記法」の2つの形式が存在し、それぞれに特徴と適切な使用場面があります。

1.1 ドット記法 (Dot Notation): シンプルさと可読性

ドット記法は、プロパティ名がJavaScriptの有効な識別子(identifier)のルールに従っている場合に使用できる、最も一般的で読みやすい方法です。有効な識別子とは、ざっくり言うと、数字で始まらず、スペースや特殊文字($_を除く)を含まない文字列のことです。


// 空のオブジェクトを定義
const user = {};

// ドット記法でプロパティを追加
user.name = "Satoshi Tanaka";
user.age = 35;
user.isAdmin = true;
user.department = "Engineering";

// ネストしたオブジェクトも同様に追加可能
user.contact = {};
user.contact.email = "s.tanaka@example.com";
user.contact.phone = "090-1234-5678";

console.log(user);
/*
{
  name: "Satoshi Tanaka",
  age: 35,
  isAdmin: true,
  department: "Engineering",
  contact: {
    email: "s.tanaka@example.com",
    phone: "090-1234-5678"
  }
}
*/

// 既存のプロパティの値を更新
user.age = 36;
console.log(user.age); // 36

このコードが示すように、ドット記法はobject.propertyNameという形式でプロパティにアクセスし、値を代入します。その直感的な構文はコードの可読性を高め、静的解析ツールやIDEによるコード補完の恩恵も受けやすいという利点があります。

1.2 ブラケット記法 (Bracket Notation): 動的なキーと特殊文字への対応

ドット記法が静的でクリーンなプロパティ名に適しているのに対し、ブラケット記法はその柔軟性で真価を発揮します。ブラケット記法はobject['propertyName']という形式をとり、プロパティ名を文字列として指定します。

この「文字列として指定する」という点が重要で、これによりドット記法では扱えないケースに対応できます。

ケース1: 無効な識別子をプロパティ名として使用する

プロパティ名にスペース、ハイフン、数字から始まる名前など、JavaScriptの識別子として無効な文字を含めたい場合、ブラケット記法が必須となります。


const report = {};

// スペースを含むプロパティ名
report["creation date"] = "2023-10-27";

// ハイフンを含むプロパティ名
report["report-id"] = "xyz-001";

// 数字で始まるプロパティ名
report["1st_reviewer"] = "Kenji Suzuki";

console.log(report);
/*
{
  "creation date": "2023-10-27",
  "report-id": "xyz-001",
  "1st_reviewer": "Kenji Suzuki"
}
*/

console.log(report["creation date"]); // "2023-10-27"
// console.log(report.creation date); // これは SyntaxError になる

ケース2: 変数を使って動的にプロパティ名 を決定する

ブラケット記法の最も強力な機能の一つが、プロパティ名を動的に生成できることです。ブラケット内には文字列リテラルだけでなく、文字列を返す変数や式を置くことができます。これは、ループ処理や関数の引数に基づいてオブジェクトを構築する際に非常に役立ちます。


const settings = {};
const settingKey = "theme";
const settingValue = "dark";

// 変数を使ってプロパティを追加
settings[settingKey] = settingValue;

console.log(settings); // { theme: "dark" }

// ループ内で動的にプロパティを追加する例
const fruitCounts = {};
const fruits = ["apple", "banana", "apple", "orange", "banana", "apple"];

for (const fruit of fruits) {
  if (fruitCounts[fruit]) {
    fruitCounts[fruit]++;
  } else {
    fruitCounts[fruit] = 1;
  }
}

console.log(fruitCounts); // { apple: 3, banana: 2, orange: 1 }

上記のフルーツの例では、ループの各イテレーションでfruit変数の現在の値("apple"や"banana"など)がプロパティ名として使用されています。このような処理をドット記法で実現することは不可能です。

1.3 JavaScriptエンジン内部での動き(少しだけ深掘り)

JavaScriptエンジン(V8など)は、オブジェクトのプロパティアクセスを高速化するために「隠しクラス」や「シェイプ」と呼ばれる内部的な最適化を行っています。同じ構造を持つオブジェクトは同じ隠しクラスを共有します。しかし、オブジェクトが作成された後でプロパティを追加・削除すると、エンジンはこの隠しクラスを新しいものに遷移させる必要があり、これがわずかながらパフォーマンス上のオーバーヘッドになることがあります。

とはいえ、現代のJavaScriptエンジンは非常に高度に最適化されているため、アプリケーションのボトルネックがここになることは稀です。基本的には、コードの可読性とメンテナンス性を最優先し、プロパティの動的追加をためらう必要はありません。ただし、パフォーマンスが極めて重要な処理(例:ゲームのメインループ内での大量のオブジェクト生成など)では、オブジェクトの形状を最初に固定する(すべてのプロパティを初期値で定義しておく)ことが有効な場合もあります。

第2章: オブジェクトの合成と一括追加 - Object.assign()

一度に複数のプロパティを追加したり、あるオブジェクトのプロパティを別のオブジェクトにコピーしたりしたい場合、Object.assign()メソッドが便利です。これはES2015 (ES6)で導入された静的メソッドで、オブジェクトのマージ操作を標準的な方法で提供します。

2.1 基本的な構文と動作

Object.assign()は、第一引数に指定されたターゲットオブジェクトに、第二引数以降に指定された一つ以上のソースオブジェクトのプロパティをコピーします。そして、変更後のターゲットオブジェクトを返します。

Object.assign(target, ...sources)

  • target: プロパティのコピー先となるオブジェクト。このオブジェクトは変更(mutate)されます。
  • sources: プロパティのコピー元となるオブジェクト。複数指定可能です。

const targetObj = { a: 1, b: 2 };
const sourceObj1 = { b: 3, c: 4 };
const sourceObj2 = { d: 5 };

const returnedObj = Object.assign(targetObj, sourceObj1, sourceObj2);

console.log(targetObj); // { a: 1, b: 3, c: 4, d: 5 }
console.log(returnedObj); // { a: 1, b: 3, c: 4, d: 5 }

// targetObj と returnedObj は同じオブジェクトを指している
console.log(targetObj === returnedObj); // true

この例の重要なポイントは2つです。

  1. プロパティの上書き: ソースオブジェクトにターゲットオブジェクトと同じ名前のプロパティが存在する場合(この例ではb)、後のソースオブジェクトの値(sourceObj1b: 3)がターゲットの元の値(b: 2)を上書きします。
  2. ターゲットオブジェクトの変更: Object.assign()targetObj自体を直接変更します。これは、元のオブジェクトを不変に保ちたい場合には望ましくない副作用となります。

2.2 不変性(Immutability)を維持するパターン

ReactやReduxなどの現代的なライブラリ/フレームワークでは、状態の不変性が重要視されます。オブジェクトを直接変更するのではなく、変更を加えた新しいオブジェクトを生成することで、状態の変更を追跡しやすくし、予期せぬ副作用を防ぎます。

Object.assign()を使ってこの不変性の原則を守るには、第一引数(ターゲット)に空のオブジェクト{}を渡します。これにより、ソースオブジェクトのプロパティはすべて新しい空のオブジェクトにコピーされ、元のオブジェクトは一切変更されません。


const originalUser = {
  id: 101,
  name: "Yuki"
};

const updates = {
  name: "Yuki Sato",
  age: 28
};

// 不変な方法でオブジェクトをマージ
const updatedUser = Object.assign({}, originalUser, updates);

console.log(updatedUser); // { id: 101, name: "Yuki Sato", age: 28 }
console.log(originalUser); // { id: 101, name: "Yuki" } - 変更されていない!

このObject.assign({}, ...sources)というイディオムは、ES6以降でオブジェクトを安全にコピー・マージするための定番の方法となりました。

2.3 注意点: シャローコピー(Shallow Copy)の罠

Object.assign()を扱う上で最も重要な注意点は、シャローコピー(浅いコピー)しか行わないということです。これは、プロパティの値がプリミティブ型(数値、文字列、真偽値など)であれば値そのものがコピーされますが、値がオブジェクトや配列であった場合、そのオブジェクトや配列への参照がコピーされるだけ、という意味です。

結果として、コピー後のオブジェクトでネストしたオブジェクトを変更すると、コピー元のオブジェクトにも影響が及んでしまいます。


const userProfile = {
  id: 202,
  name: "Emi",
  details: {
    city: "Osaka",
    hobbies: ["reading", "travel"]
  }
};

const clonedProfile = Object.assign({}, userProfile);

// コピーしたオブジェクトのネストしたプロパティを変更
clonedProfile.details.city = "Kyoto";
clonedProfile.details.hobbies.push("photography");

// 元のオブジェクトも変更されてしまっている!
console.log(userProfile.details.city); // "Kyoto"
console.log(userProfile.details.hobbies); // ["reading", "travel", "photography"]

この挙動は、不変性を期待している場合に深刻なバグの原因となります。オブジェクトを階層の奥深くまで完全に複製(ディープコピー)したい場合は、Object.assign()だけでは不十分です。ディープコピーを実現するには、以下のような方法を検討する必要があります。

  • structuredClone(): モダンブラウザとNode.jsで利用可能な、ディープコピーのための新しい標準APIです。関数やDOMノードなど一部の型はコピーできませんが、ほとんどのデータオブジェクトに対して最も推奨される方法です。
  • JSON.parse(JSON.stringify(obj)): 手軽なディープコピーのハックですが、Dateオブジェクトが文字列に変換されたり、undefinedや関数が失われるなど、データの種類によっては問題が発生します。
  • Lodashなどのライブラリ: Lodashの_.cloneDeep()のような関数は、様々なデータ型を正確に扱う、堅牢なディープコピー機能を提供します。

2.4 その他の特性

  • Object.assign()は、ソースオブジェクトの列挙可能(enumerable)な自身のプロパティのみをコピーします。プロトタイプチェーン上のプロパティや、Object.defineProperty()enumerable: falseと設定されたプロパティはコピーされません。
  • ゲッター(getter)は実行され、その返り値がコピーされます。セッター(setter)はコピーされません。
  • シンボル(Symbol)をキーに持つプロパティもコピー対象となります。

第3章: モダンJavaScriptの流儀 - スプレッド構文 (...)

ES2018でオブジェクトリテラルにも導入されたスプレッド構文(Spread Syntax)は、Object.assign({}, ...sources)のより簡潔で直感的な代替手段として、現在のJavaScript開発で広く使われています。オブジェクトの合成やプロパティの追加を、宣言的かつ読みやすく記述できます。

3.1 構文と基本的な使い方

スプレッド構文は、オブジェクトリテラル{}の中で、オブジェクトの前に3つのドット...を付けることで使用します。これにより、そのオブジェクトが持つ列挙可能な自身のプロパティが、新しいオブジェクト内に展開されます。


const defaults = {
  level: "user",
  theme: "light",
  notifications: true
};

const userSettings = {
  theme: "dark",
  username: "akira"
};

// スプレッド構文を使ってオブジェクトをマージ
const finalSettings = { ...defaults, ...userSettings };

console.log(finalSettings);
// { level: "user", theme: "dark", notifications: true, username: "akira" }

この例はObject.assign({}, defaults, userSettings)とほぼ同等です。Object.assign()と同様に、後に展開されたオブジェクトのプロパティ(userSettingstheme)が、前のオブジェクトのプロパティ(defaultstheme)を上書きします。スプレッド構文は常に新しいオブジェクトを生成するため、本質的に不変な操作であるという点が大きな利点です。

3.2 プロパティの追加・更新への応用

スプレッド構文は、既存のオブジェクトのプロパティを一部更新したり、新しいプロパティを追加したりした新しいオブジェクトを作成する、という一般的なタスクを非常にエレガントに記述できます。


const book = {
  title: "JavaScript The Good Parts",
  author: "Douglas Crockford",
  year: 2008
};

// 新しいプロパティを追加
const bookWithGenre = { ...book, genre: "Technology" };
console.log(bookWithGenre);
/*
{
  title: "JavaScript The Good Parts",
  author: "Douglas Crockford",
  year: 2008,
  genre: "Technology"
}
*/

// 既存のプロパティを更新
const updatedBook = { ...book, year: 2009 }; // 仮に改訂版が出たと仮定
console.log(updatedBook);
/*
{
  title: "JavaScript The Good Parts",
  author: "Douglas Crockford",
  year: 2009
}
*/

// 順番が重要: プロパティを上書きするには、後から指定する
const incorrectUpdate = { year: 2009, ...book };
console.log(incorrectUpdate); // { year: 2008, title: ..., author: ... } - bookのyearで上書きされてしまう

// 元のオブジェクトは不変
console.log(book); // { title: "...", author: "...", year: 2008 }

このconst newObj = { ...oldObj, key: newValue };というパターンは、ReactのState更新などで頻繁に目にする、現代JavaScriptにおける不変な更新処理の基本形です。

3.3 Object.assign() との比較、そして同じ罠

スプレッド構文は多くの点で Object.assign() のシンタックスシュガー(より簡単な書き方)と見なせますが、いくつかの違いがあります。

  • 構文: スプレッド構文は式(expression)であり、より宣言的です。Object.assign()は関数呼び出しです。
  • 不変性: スプレッド構文は常に新しいオブジェクトを作成します。Object.assign()は第一引数を変更するため、不変性を保つには一手間({}を渡す)必要です。

そして最も重要な共通点は、スプレッド構文もまたシャローコピーを行うということです。Object.assign()のセクションで説明したネストしたオブジェクトの問題は、スプレッド構文でも全く同じように発生します。


const original = {
  a: 1,
  nested: { b: 2 }
};

const copy = { ...original };

copy.nested.b = 99;

// originalも変更されてしまう
console.log(original.nested.b); // 99

ネストしたオブジェクトを不変に更新する場合は、その階層までスプレッド構文を適用する必要があります。


const updated = {
  ...original, // トップレベルのプロパティをコピー
  nested: {
    ...original.nested, // ネストしたオブジェクトのプロパティをコピー
    b: 100 // ネストしたオブジェクトのプロパティを更新
  }
};

console.log(updated.nested.b); // 100
console.log(original.nested.b); // 99 (前の例で変更されたまま)

このように、ネストが深くなるとスプレッド構文を何度も繰り返す必要があり、コードが冗長になることがあります。このような場合は、Immerのようなライブラリや、より構造化された状態管理手法を検討する価値があります。

第4章: 高度な操作と代替データ構造

これまでに紹介した方法で、ほとんどのプロパティ追加・更新タスクはカバーできます。しかし、より細かい制御が必要な場合や、プレーンなオブジェクトが最適ではないシナリオも存在します。

4.1 プロパティの属性を制御する: `Object.defineProperty()`

オブジェクトのプロパティには、値(value)以外にも、内部的な属性が存在します。これらはプロパティデスクリプタと呼ばれ、以下のものが含まれます。

  • writable: trueの場合、プロパティの値を変更できる。
  • enumerable: trueの場合、for...inループやObject.keys()などで列挙される。
  • configurable: trueの場合、プロパティを削除したり、デスクリプタを再定義したりできる。

Object.defineProperty()メソッドを使うと、これらの属性を明示的に指定してプロパティを追加できます。通常の方法(ドット記法やブラケット記法)でプロパティを追加すると、これらの属性はすべてtrueになります。


const obj = {};

Object.defineProperty(obj, 'readOnlyProp', {
  value: "この値は変更できません",
  writable: false,
  enumerable: true,
  configurable: true
});

console.log(obj.readOnlyProp); // "この値は変更できません"

try {
  obj.readOnlyProp = "新しい値"; // strict modeではTypeError
} catch (e) {
  console.error(e);
}
console.log(obj.readOnlyProp); // "この値は変更できません"

Object.defineProperty(obj, 'hiddenProp', {
  value: "このプロパティは列挙されません",
  writable: true,
  enumerable: false, // for...inなどで見えなくなる
  configurable: true
});

console.log(Object.keys(obj)); // ["readOnlyProp"] - hiddenPropは含まれない
for (const key in obj) {
  console.log(key); // "readOnlyProp" のみが出力される
}

console.log(obj.hiddenProp); // 直接アクセスは可能

Object.defineProperty()(やその複数形であるObject.defineProperties())は、ライブラリやフレームワークが内部的なプロパティをオブジェクトに追加する際や、不変の定数をオブジェクトに定義したい場合など、特殊な要件がある場面で利用されます。

4.2 プレーンオブジェクトの代替: `Map` オブジェクト

JavaScriptのプレーンなオブジェクト({})は非常に便利ですが、キーとして使用できるのは文字列かシンボルのみです。また、歴史的な経緯から、プロトタイプチェーンに由来する意図しないプロパティ(例:toString)を持ってしまう可能性があります。

ES6で導入されたMapオブジェクトは、より純粋なキーと値のペアを扱うためのデータ構造です。

`Map`とプレーンオブジェクトの主な違い:

  • キーの型: `Map`はオブジェクトや関数など、任意の型の値をキーとして使用できます。プレーンオブジェクトは文字列(またはシンボル)に暗黙的に変換されます。
  • 順序の保証: `Map`は要素が追加された順序を保持します。プレーンオブジェクトのプロパティの順序は、ES2015以降ある程度保証されるようになりましたが、歴史的には保証されていませんでした。
  • サイズ取得: `Map`は.sizeプロパティで要素数を簡単に取得できます。プレーンオブジェクトではObject.keys(obj).lengthのように一手間かかります。
  • パフォーマンス: 要素の追加や削除が頻繁に行われる場合、一般的に`Map`の方がパフォーマンスが良いとされています。
  • プロトタイプ: `Map`はプロトタイプを持たないため、キーの衝突を心配する必要がありません。

Mapに要素を追加するには、.set()メソッドを使用します。


const userMap = new Map();

const user1 = { id: 1, name: "Taro" };
const user2 = { id: 2, name: "Hanako" };

// .set(key, value) で要素を追加
userMap.set(user1, { role: "admin", lastLogin: "2023-10-26" });
userMap.set(user2, { role: "editor", lastLogin: "2023-10-27" });

// オブジェクトをキーにして値を取得
console.log(userMap.get(user1)); // { role: "admin", lastLogin: "2023-10-26" }

// .has() でキーの存在を確認
console.log(userMap.has(user2)); // true

// .size で要素数を取得
console.log(userMap.size); // 2

// .delete() で要素を削除
userMap.delete(user1);
console.log(userMap.size); // 1

データキャッシュや、DOM要素に追加情報を紐付ける場合など、キーが文字列以外である場合に`Map`は非常に強力な選択肢となります。

第5章: 結論 - どの方法をいつ使うべきか

JavaScriptオブジェクトへのプロパティ追加には、多様な方法が存在します。それぞれの特性を理解し、文脈に応じて最適なツールを選択することが、クリーンで効率的なコードを書くための鍵となります。

以下に、選択のための指針をまとめます。

  • 単一のプロパティをシンプルに追加・更新したい場合:
    • プロパティ名が静的で有効な識別子ならドット記法obj.prop = value)。可読性が最も高い。
    • プロパティ名が動的(変数)または特殊文字を含むならブラケット記法obj[prop] = value)。柔軟性が高い。
  • 複数のプロパティをマージして新しいオブジェクトを作成したい場合(不変な操作):
    • スプレッド構文{ ...obj1, ...obj2 })が第一選択。モダンで簡潔、直感的。
    • レガシーな環境をサポートする必要がある場合はObject.assign({}, obj1, obj2)
  • 既存のオブジェクトに別のオブジェクトのプロパティをマージしたい場合(可変な操作):
    • `Object.assign(target, source)`がこの目的のために設計されている。ただし、オブジェクトの直接的な変更は副作用を伴うため、意図を明確にして使用すること。
  • プロパティの属性(書き込み可、列挙可など)を細かく制御したい場合:
    • `Object.defineProperty()`を使用する。ライブラリ開発など、特殊なケースでの利用が主。
  • キーとして文字列やシンボル以外を使いたい、または純粋な辞書データ構造が欲しい場合:
    • プレーンオブジェクトの代わりに`Map`オブジェクト.set()メソッドの使用を検討する。

特に重要なのは、シャローコピーの挙動を常に意識することです。Object.assign()もスプレッド構文も、ネストしたオブジェクトは参照をコピーするだけです。深い階層のデータを不変に扱うには、ネストしたレベルでもマージ操作を繰り返すか、structuredClone()や専門のライブラリの利用が必要です。

JavaScriptにおけるオブジェクト操作は、言語の柔軟性を象徴する機能です。これらの多様な手法をマスターすることで、あなたはより表現力豊かで、堅牢かつメンテナンス性の高いアプリケーションを構築できるようになるでしょう。

Wednesday, November 6, 2019

실무에서 바로 쓰는 자바스크립트 정규표현식 완벽 분석

자바스크립트 개발자라면 누구나 문자열이라는 거대한 바다를 항해해야 합니다. 사용자 입력 폼의 유효성을 검사하고, 복잡한 로그 파일에서 의미 있는 데이터를 추출하며, 특정 형식의 텍스트를 동적으로 변환하는 등, 문자열 처리는 프로젝트의 성패를 좌우하는 핵심 기술입니다. 이 광활한 바다에서 가장 강력한 나침반이자 만능 도구가 되어주는 것이 바로 **정규표현식(Regular Expression, 이하 Regex)**입니다.

하지만 많은 개발자들이 Regex의 첫인상에 압도당합니다. 마치 고대 상형문자처럼 보이는 기호들의 나열은 시작도 하기 전에 포기하게 만드는 높은 장벽처럼 느껴집니다. '일단 구글에서 복사해서 붙여넣고 보자'는 생각으로 임시방편적인 해결에 그치는 경우도 부지기수입니다. 이 글은 더 이상 Regex를 두려움의 대상으로 남겨두고 싶지 않은 모든 개발자를 위한 최종 안내서입니다. Regex가 무엇인지 근본적인 개념부터 시작해, 자바스크립트에서 이를 자유자재로 다루는 방법, 그리고 까다로운 실무 문제들을 해결하는 고급 기법과 실전 예제까지, 모든 것을 깊이 있게 파헤쳐 보겠습니다. 이 글을 끝까지 정독하고 나면, 당신은 더 이상 암호를 해독하는 고고학자가 아니라, 문자열의 패턴을 창조하고 지배하는 마법사가 될 것입니다.

1. 정규표현식과의 첫 만남: 개념 바로 세우기

정규표현식이란 무엇일까요? 가장 단순하게 정의하면 **'문자열의 특정 패턴을 기술하는 형식 언어(Formal Language)'**입니다. 파일 탐색기에서 `*.jpg`를 입력해 모든 JPEG 이미지를 찾는 것과 원리는 비슷하지만, 그 표현력과 정교함은 차원을 달리합니다. Regex를 사용하면 다음과 같은 복잡한 규칙들을 단 한 줄의 패턴으로 정의할 수 있습니다.

  • "대한민국 휴대폰 번호 형식(010-XXXX-XXXX 또는 010XXXXXXXX)에 맞는 문자열 찾기"
  • "HTML 문자열에서 태그만 찾아내고, 그 안의 src 속성 값 추출하기"
  • "최소 8자 이상이며, 대문자, 소문자, 숫자를 반드시 하나 이상 포함하는 비밀번호 형식 검증하기"

이처럼 Regex는 단순 텍스트 검색을 넘어, 문자열의 '구조'와 '규칙'을 찾아내는 강력한 도구입니다. JavaScript뿐만 아니라 Python, Java, C#, Ruby, PHP, 심지어 데이터베이스(Oracle, MySQL)나 텍스트 에디터(VS Code, Sublime Text)에 이르기까지 IT 기술 전반에서 표준처럼 사용됩니다. 따라서 한번 제대로 익혀두면 당신의 개발 생산성과 문제 해결 능력을 비약적으로 향상시키는 평생의 자산이 될 것입니다.

2. JavaScript에서 Regex 생성하기: 두 가지 핵심 방법론

자바스크립트에서는 정규표현식 객체를 생성하는 두 가지 방법을 제공합니다. 바로 '리터럴(Literal)' 방식과 '생성자(Constructor)' 방식입니다. 이 둘의 차이점을 명확히 이해하는 것이 Regex 활용의 첫걸음입니다.

2.1. 정규표현식 리터럴 (Regular Expression Literal)

가장 널리 쓰이고 직관적인 방법입니다. 찾고 싶은 패턴을 슬래시(/) 기호로 감싸서 직접 작성합니다. 이 방식의 가장 큰 특징은 **스크립트가 로드되는 시점에 컴파일된다**는 점입니다. 따라서 패턴이 고정되어 있는 경우, 반복적으로 사용될 때 성능상 이점을 가집니다.

const regex = /pattern/flags;
  • /pattern/: 이 부분이 실제 검색 패턴입니다. 예를 들어, `javascript`라는 단어를 찾는다면 /javascript/가 됩니다.
  • flags (선택 사항): 검색의 동작을 제어하는 옵션(플래그)입니다. 여러 개를 동시에 사용할 수 있습니다.
    • g (Global): 문자열 전체에서 일치하는 모든 패턴을 찾습니다. 이 플래그가 없으면 첫 번째 일치 항목만 찾고 검색을 종료합니다.
    • i (Ignore case): 대소문자를 무시하고 검색합니다. /apple/i는 "apple", "Apple", "APPLE" 모두와 일치합니다.
    • m (Multi-line): 여러 줄(multi-line) 모드를 활성화합니다. 이 플래그가 있으면, 문자열의 시작(^)과 끝($)을 찾는 메타문자가 각 줄(line)의 시작과 끝에 대응하게 됩니다.
    • s (dotAll): 메타문자 .이 개행 문자(\n)까지 포함한 모든 문자와 일치하도록 만듭니다. 기본적으로 .은 개행 문자와 일치하지 않습니다.
    • u (Unicode): 유니코드 문자를 올바르게 처리합니다. 이모지(emoji)나 다양한 언어의 문자를 다룰 때 필수적입니다.
    • y (Sticky): `g` 플래그와 유사하지만, 특정 위치에서부터의 검색만 수행합니다. Regex 객체의 `lastIndex` 속성과 함께 사용됩니다.

예시:

const text = "JavaScript is a powerful language. I love javascript!";
// 'javascript'라는 단어를 대소문자 구분 없이, 문자열 전체에서 찾기
const regex = /javascript/gi;

const matches = text.match(regex);
console.log(matches); // ["JavaScript", "javascript"]

2.2. RegExp 생성자 (RegExp Constructor)

new RegExp() 생성자를 호출하여 정규표현식 객체를 만듭니다. 이 방법의 핵심 용도는 **패턴이 동적으로 변경되어야 할 때**입니다. 예를 들어, 사용자 입력값이나 변수에 담긴 문자열을 Regex 패턴으로 사용하고 싶을 때 유용합니다.

const regex = new RegExp('pattern', 'flags');

리터럴 방식과 달리, 패턴과 플래그를 모두 문자열 형태로 전달합니다. 여기서 초보자들이 가장 많이 저지르는 실수가 발생합니다. 패턴을 일반 문자열로 작성하기 때문에, Regex에서 특별한 의미를 가지는 메타문자 중 백슬래시(\)를 사용하는 경우, 자바스크립트 문자열 규칙에 따라 한 번 더 이스케이프(escape) 처리를 해주어야 합니다. 즉, '모든 숫자'를 의미하는 \d를 패턴으로 사용하려면 문자열 내에서는 '\\d'라고 작성해야 합니다.

예시:

const userInput = "apple"; // 사용자가 검색어로 'apple'을 입력했다고 가정
const flags = "gi";

// 변수에 담긴 값을 패턴으로 사용
const dynamicRegex = new RegExp(userInput, flags);

const text = "An Apple a day keeps the doctor away. apple pie is delicious.";
console.log(text.match(dynamicRegex)); // ["Apple", "apple"]

// 백슬래시 이스케이프 예제
const textWithNumbers = "My age is 30.";
// const digitRegex = new RegExp('\d+', 'g'); // 잘못된 사용! '\d'는 문자열에서 이스케이프 시퀀스로 인식되지 않을 수 있음
const digitRegex = new RegExp('\\d+', 'g'); // 올바른 사용! '\\'가 문자열 내에서 '\' 문자를 의미하게 됨

console.log(textWithNumbers.match(digitRegex)); // ["30"]

가장 흔한 함정: 리터럴과 문자열의 혼동

자바스크립트의 문자열 메서드(.match(), .replace() 등)는 인자로 Regex 객체뿐만 아니라 일반 문자열도 받을 수 있습니다. 이때, Regex 리터럴처럼 생긴 문자열을 전달하면 비극이 시작됩니다.

const myString = "There are 100 ways to code.";

// ❌ 잘못된 사용: '/[0-9]+/'는 정규표현식이 아니라, 슬래시와 대괄호, 숫자 등으로 이루어진 '일반 문자열'입니다.
// 자바스크립트는 myString에서 '/[0-9]+/'라는 텍스트를 글자 그대로 찾으려고 시도합니다.
console.log(myString.match('/[0-9]+/')); // null

// ✅ 올바른 사용 1: 정규표현식 리터럴 사용
// 따옴표 없이 슬래시로 감싸서 자바스크립트 엔진이 이를 Regex 객체로 인식하게 합니다.
console.log(myString.match(/[0-9]+/)); 
// ["100", index: 10, input: "There are 100 ways to code.", groups: undefined]

// ✅ 올바른 사용 2: RegExp 생성자 사용
// 패턴을 문자열 형태로 전달합니다.
const regex = new RegExp('[0-9]+');
console.log(myString.match(regex));
// ["100", index: 10, input: "There are 100 ways to code.", groups: undefined]

결론적으로, 패턴이 고정되어 있다면 가독성과 성능 면에서 리터럴(/[0-9]+/)을, 패턴이 변수처럼 동적으로 결정되어야 한다면 생성자(new RegExp())를 사용하는 것이 황금률입니다.

3. Regex 암호 해독: 핵심 문법 완전 정복

Regex의 진정한 힘은 다양한 메타문자와 기호들을 조합하여 정교한 패턴을 만드는 데서 나옵니다. 이 '암호'들을 하나씩 해독해 봅시다.

3.1. 기본 구성 요소: 앵커와 문자 클래스

  • . (마침표): '임의의 한 글자'를 의미합니다. 단, 개행 문자(\n)는 제외됩니다. (s 플래그 사용 시 포함)
    • /h.t/는 "hat", "hot", "h1t" 등과 일치하지만 "ht"나 "heat"와는 일치하지 않습니다.
  • ^ (캐럿): 문자열의 **시작**을 의미하는 앵커(anchor)입니다.
    • /^Hello/는 "Hello world"와 일치하지만, "world, Hello"와는 일치하지 않습니다.
  • $ (달러 기호): 문자열의 **끝**을 의미하는 앵커입니다.
    • /world$/는 "Hello world"와 일치하지만, "world is beautiful"과는 일치하지 않습니다.
    • /^apple$/는 오직 "apple"이라는 문자열과 정확히 일치합니다.
  • \d: 숫자(Digit) 한 개와 일치합니다. [0-9]와 동일합니다.
  • \D: 숫자가 아닌 문자 한 개와 일치합니다. [^0-9]와 동일합니다.
  • \w: 영문자, 숫자, 언더스코어(_)를 포함하는 '단어(Word) 문자' 한 개와 일치합니다. [A-Za-z0-9_]와 동일합니다.
  • \W: 단어 문자가 아닌 문자(공백, 특수문자 등) 한 개와 일치합니다. [^A-Za-z0-9_]와 동일합니다.
  • \s: 공백, 탭, 개행 문자 등 모든 공백(Space) 문자 한 개와 일치합니다.
  • \S: 공백 문자가 아닌 문자 한 개와 일치합니다.
  • \b: 단어의 경계(Word Boundary)를 의미합니다. 단어의 시작이나 끝 부분, 즉 단어 문자와 비단어 문자 사이의 위치와 일치합니다.
    • /\bcat\b/는 "The cat sat"의 'cat'과는 일치하지만, "catch"나 "tomcat"의 'cat'과는 일치하지 않습니다. 매우 유용합니다.
  • \B: 단어의 경계가 아닌 위치와 일치합니다.

3.2. 반복의 미학: 수량자(Quantifiers)

수량자는 바로 앞의 문자나 그룹이 몇 번 반복되는지를 지정합니다.

  • *: 바로 앞의 패턴이 0번 이상 반복되는 경우와 일치합니다. (없거나, 많거나)
    • /go*gle/는 "ggle", "google", "goooogle" 등과 일치합니다.
  • +: 바로 앞의 패턴이 1번 이상 반복되는 경우와 일치합니다. (최소 한 번은 등장해야 함)
    • /go+gle/는 "google", "goooogle" 등과 일치하지만 "ggle"과는 일치하지 않습니다.
  • ?: 바로 앞의 패턴이 0번 또는 1번 나타나는 경우와 일치합니다. (있거나 없거나)
    • /colou?r/는 "color"와 "colour" 모두와 일치합니다.
  • {n}: 바로 앞의 패턴이 정확히 n 반복되는 경우와 일치합니다.
    • /\d{3}/는 "123"과 일치하지만 "12"나 "1234"의 일부만 일치합니다.
  • {n,}: 바로 앞의 패턴이 최소 n번 이상 반복되는 경우와 일치합니다.
    • /\d{2,}/는 "12", "123", "12345" 등과 일치합니다.
  • {n,m}: 바로 앞의 패턴이 최소 n번, 최대 m 반복되는 경우와 일치합니다.
    • /^\d{3,5}$/는 3자리, 4자리, 5자리 숫자로만 이루어진 문자열과 일치합니다.

탐욕스러운(Greedy) vs 게으른(Lazy) 매칭

기본적으로 수량자(*, +, {})는 '탐욕스럽게(Greedy)' 동작합니다. 이는 가능한 한 가장 긴 문자열을 찾으려고 시도하는 것을 의미합니다.

const text = "

first

and

second

"; const greedyRegex = /<p>.*<\/p>/; console.log(text.match(greedyRegex)[0]); // 결과: "<p>first</p> and <p>second</p>" // `.*`가 첫 번째 `

`부터 마지막 `

`까지 모든 것을 삼켜버렸습니다.

이런 동작을 원하지 않을 경우, 수량자 뒤에 물음표(?)를 붙여 '게으르게(Lazy)' 만들 수 있습니다. 게으른 수량자는 가능한 한 가장 짧은 문자열을 찾으려고 시도합니다.

const text = "

first

and

second

"; const lazyRegex = /<p>.*?<\/p>/; console.log(text.match(lazyRegex)[0]); // 결과: "<p>first</p>" // `.*?`가 첫 번째 `</p>`를 만나자마자 매칭을 멈췄습니다.

3.3. 구조화와 선택: 그룹과 범위

  • [...] (문자 집합): 대괄호 안의 어떤 문자든 '하나'와 일치합니다.
    • /[abc]/는 'a', 'b', 'c' 중 한 글자와 일치합니다.
    • 하이픈(-)으로 범위를 지정할 수 있습니다: [a-z], [0-9], [A-Z].
    • /[a-zA-Z0-9]/는 영문 대소문자와 숫자 중 한 글자와 일치합니다. (\w와 비슷하지만 _는 제외)
  • [^...] (부정 문자 집합): 대괄호 안의 캐럿(^)은 '부정(NOT)'을 의미합니다. 괄호 안에 없는 모든 문자와 일치합니다.
    • /[^0-9]/는 숫자가 아닌 모든 문자와 일치합니다. (\D와 동일)
  • (...) (캡처링 그룹): 괄호는 여러 문자를 하나의 단위로 묶어줍니다. 수량자의 영향을 받게 하거나, 검색 결과에서 이 부분만 따로 추출(캡처)할 수 있습니다.
    • /(ha)+/는 "ha", "haha", "hahaha" 등과 일치합니다.
    • 캡처된 그룹은 나중에 $1, $2와 같은 형태로 재참조할 수 있습니다.
  • (?:...) (비캡처링 그룹): 그룹으로 묶어주지만, 캡처하지는 않습니다. 단순히 패턴의 우선순위를 지정하거나 그룹화를 할 목적일 때 사용합니다. 캡처링에 드는 약간의 메모리 오버헤드를 줄일 수 있습니다.
  • | (OR 연산자): 여러 패턴 중 하나를 선택합니다. '또는'의 의미입니다.
    • /cat|dog/는 "cat" 또는 "dog"와 일치합니다.
    • /I love (apple|banana)/는 "I love apple" 또는 "I love banana"와 일치합니다.

3.4. 고급 기술: 주변을 둘러보는 룩어라운드(Lookaround)

룩어라운드는 특정 패턴이 일치하는지를 '확인'만 하고, 실제 결과에는 포함시키지 않는 강력한 기능입니다. 'Zero-width assertion'이라고도 불리며, 조건은 만족해야 하지만 결과값에서는 빼고 싶을 때 사용합니다.

  • 긍정형 전방 탐색 (Positive Lookahead) (?=...): ... 패턴이 뒤따라오는 경우에만 앞의 패턴과 일치합니다.
    • 비밀번호 검증에 유용합니다: /^(?=.*\d)(?=.*[a-z]).{8,}$/는 '숫자가 포함'되고 '소문자가 포함'된 8자리 이상의 문자열과 일치합니다. 여기서 (?=.*\d) 부분은 실제 문자열을 소비하지 않고 조건만 확인합니다.
  • 부정형 전방 탐색 (Negative Lookahead) (?!...): ... 패턴이 뒤따라오지 않는 경우에만 앞의 패턴과 일치합니다.
    • /q(?!u)/는 'q' 다음에 'u'가 오지 않는 'q'와 일치합니다. "Iraq"의 'q'와는 일치하지만 "queen"의 'q'와는 일치하지 않습니다.
  • 긍정형 후방 탐색 (Positive Lookbehind) (?<=...): ... 패턴이 앞에 있는 경우에만 뒤의 패턴과 일치합니다.
    • /(?<=\$)\d+/는 달러 기호($) 바로 뒤에 오는 숫자들과 일치합니다. "Price: $100"에서 "100"을 찾아내지만, "$"는 결과에 포함하지 않습니다.
  • 부정형 후방 탐색 (Negative Lookbehind) (?: ... 패턴이 앞에 오지 않는 경우에만 뒤의 패턴과 일치합니다.
    • /(?는 달러 기호가 앞에 없는 숫자들과 일치합니다.

4. JavaScript 메서드와 Regex의 협주곡

이제 잘 만들어진 Regex 패턴을 자바스크립트의 내장 메서드들과 함께 사용하여 실제 작업을 수행해 봅시다.

4.1. `RegExp.prototype.test()`: 존재 유무 확인

가장 단순합니다. 문자열이 정규표현식과 일치하는지 여부를 `true` 또는 `false`로 반환합니다. 유효성 검사에 완벽합니다.

const isKoreanPhoneNumber = /^(010|011|016|017|018|019)-\d{3,4}-\d{4}$/;
console.log(isKoreanPhoneNumber.test("010-1234-5678")); // true
console.log(isKoreanPhoneNumber.test("01012345678"));   // false (하이픈 없음)
console.log(isKoreanPhoneNumber.test("02-123-4567"));    // false (01X가 아님)

4.2. `RegExp.prototype.exec()`: 상세한 정보와 상태를 가진 탐색

String.prototype.match()와 비슷하지만, `g` 플래그와 함께 사용할 때 상태를 유지한다는 중요한 차이점이 있습니다. `exec()`를 반복 호출하면, `lastIndex` 속성을 갱신하며 다음 일치 항목을 계속해서 찾아냅니다. 일치 항목을 찾으면 상세 정보가 담긴 배열을, 더 이상 없으면 `null`을 반환합니다.

const text = "Color: #FF0000, Background: #00FF00, Border: #0000FF";
const hexCodeRegex = /#([A-Fa-f0-9]{6})\b/g; // g 플래그 필수!

let match;
while ((match = hexCodeRegex.exec(text)) !== null) {
  console.log(`Found: ${match[0]}, Color code: ${match[1]}, Next search starts at: ${hexCodeRegex.lastIndex}`);
}
// 출력:
// Found: #FF0000, Color code: FF0000, Next search starts at: 15
// Found: #00FF00, Color code: 00FF00, Next search starts at: 35
// Found: #0000FF, Color code: 0000FF, Next search starts at: 53

4.3. `String.prototype.match()`: 일치 항목 배열로 가져오기

문자열에서 Regex와 일치하는 부분을 찾아 배열로 반환합니다. `g` 플래그의 유무에 따라 반환 값이 완전히 달라집니다.

  • g 플래그가 없을 때: 첫 번째 일치 항목에 대한 상세 정보(일치한 전체 문자열, 캡처링 그룹, 인덱스 등)가 담긴 배열을 반환합니다. `exec()`의 첫 번째 결과와 유사합니다.
  • g 플래그가 있을 때: 문자열 전체에서 일치하는 모든 부분을 찾아 '문자열만' 담긴 배열을 반환합니다. 캡처링 그룹이나 인덱스 정보는 포함되지 않습니다.
const log = "ERROR: User not found. INFO: Connected. WARNING: Deprecated API.";
// g 플래그가 있을 때: 모든 일치 항목을 배열로
const allLevels = log.match(/(ERROR|INFO|WARNING)/g);
console.log(allLevels); // ["ERROR", "INFO", "WARNING"]

// g 플래그가 없을 때: 첫 번째 일치 항목의 상세 정보
const firstLevelDetails = log.match(/(ERROR|INFO|WARNING): (.*)/);
console.log(firstLevelDetails[0]); // "ERROR: User not found." (전체 일치)
console.log(firstLevelDetails[1]); // "ERROR" (첫 번째 캡처링 그룹)
console.log(firstLevelDetails[2]); // "User not found." (두 번째 캡처링 그룹)

4.4. `String.prototype.matchAll()`: 모든 상세 정보를 반복자로

`g` 플래그가 있는 `match()`의 단점(캡처링 그룹 정보를 주지 않음)과 `exec()`를 루프로 돌려야 하는 번거로움을 한 번에 해결하는 현대적인 메서드입니다. `g` 플래그가 필수이며, 모든 일치 항목에 대한 상세 정보를 포함하는 이터레이터(iterator)를 반환합니다.

const text = "User: John (ID: 123), User: Jane (ID: 456)";
const userRegex = /User: (\w+) \(ID: (\d+)\)/g; // g 플래그 필수!

const matches = text.matchAll(userRegex);

for (const match of matches) {
  console.log(`Full match: ${match[0]}, Name: ${match[1]}, ID: ${match[2]}`);
}
// 출력:
// Full match: User: John (ID: 123), Name: John, ID: 123
// Full match: User: Jane (ID: 456), Name: Jane, ID: 456

// 배열로 변환하여 사용도 가능
const allMatchesArray = [...text.matchAll(userRegex)];
console.log(allMatchesArray[1][1]); // "Jane"

4.5. `String.prototype.replace()`: 찾아서 바꾸기의 제왕

문자열에서 Regex와 일치하는 부분을 다른 문자열로 대체합니다. 두 번째 인자로 문자열뿐만 아니라 함수를 전달할 수 있어 매우 강력합니다.

대체 문자열 사용

특별한 패턴($1, $2, $& 등)을 사용하여 캡처링 그룹이나 일치한 전체 문자열을 참조할 수 있습니다.

const date = "2023-10-26";
// YYYY-MM-DD -> DD/MM/YYYY 형식으로 변경
const reorderedDate = date.replace(/(\d{4})-(\d{2})-(\d{2})/, "$3/$2/$1");
console.log(reorderedDate); // "26/10/2023"

const text = "Markdown uses **bold** and *italic* text.";
let html = text.replace(/\*\*(.*?)\*\*/g, "$1");
html = html.replace(/\*(.*?)\*/g, "$1");
console.log(html); // "Markdown uses bold and italic text."

대체 함수 사용

대체할 내용을 동적으로 결정해야 할 때 함수를 전달합니다. 함수는 인자로 `(전체일치문자열, 캡처그룹1, 캡처그룹2, ..., 오프셋, 원본문자열)`을 받습니다.

// URL 쿼리스트링에서 kebab-case 파라미터를 camelCase로 변환하기
const url = "/api/users?sort-by=name&page-number=1";

function kebabToCamel(match, p1, p2) {
  return p1 + p2.toUpperCase();
}

const newUrl = url.replace(/-(\w)/g, kebabToCamel);
console.log(newUrl); // "/api/users?sortBy=name&pageNumber=1"

4.6. `String.prototype.split()`: 패턴으로 문자열 분할

단순 문자열뿐만 아니라 Regex를 기준으로 문자열을 분할하여 배열을 만들 수 있습니다.

const csvData = "apple, banana , orange; grape";
// 쉼표, 세미콜론, 그리고 주변의 공백을 모두 구분자로 취급
const fruits = csvData.split(/\s*[,;]\s*/);
console.log(fruits); // ["apple", "banana", "orange", "grape"]

5. 실전 Regex 레시피: 문제 해결을 위한 패턴 모음

이제 이론을 바탕으로 실제 프로젝트에서 마주칠 법한 문제들을 해결하는 Regex 레시피들을 소개합니다.

레시피 1: 비밀번호 강도 검사 (룩어라운드 활용)

회원가입 폼에서 '최소 8자, 대문자, 소문자, 숫자, 특수문자 각 1개 이상 포함' 같은 복잡한 규칙을 검증할 때 룩어라운드는 최고의 도구입니다.

function checkPasswordStrength(password) {
  // 패턴 해설:
  // ^                   : 문자열 시작
  // (?=.*[a-z])         : 소문자가 최소 1개 있는지 확인 (전방 탐색)
  // (?=.*[A-Z])         : 대문자가 최소 1개 있는지 확인 (전방 탐색)
  // (?=.*\d)            : 숫자가 최소 1개 있는지 확인 (전방 탐색)
  // (?=.*[@$!%*?&])    : 특수문자가 최소 1개 있는지 확인 (전방 탐색)
  // .{8,}               : 전체 길이가 최소 8자인지 확인
  // $                   : 문자열 끝
  const strongPasswordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&]).{8,}$/;
  return strongPasswordRegex.test(password);
}

console.log(checkPasswordStrength("weak"));             // false
console.log(checkPasswordStrength("GoodPassword123"));  // false (특수문자 없음)
console.log(checkPasswordStrength("Strong!Pass1"));     // true

레시피 2: URL 완전 분해 (이름 가진 캡처 그룹 활용)

URL을 프로토콜, 호스트, 경로, 쿼리 등으로 정교하게 분해하고 싶을 때, 이름 가진 캡처 그룹((?<name>...))을 사용하면 결과를 훨씬 직관적으로 다룰 수 있습니다.

function parseUrl(url) {
  const urlRegex = /^(?<protocol>https?):\/\/(?<hostname>[\w.-]+)(?::(?<port>\d+))?(?<pathname>\/[^?#]*)?(?<search>\?[^#]*)?(?<hash>#.*)?$/;
  const match = urlRegex.exec(url);
  
  if (!match) {
    return null;
  }
  
  return match.groups; // .groups 속성으로 이름 붙인 결과에 바로 접근 가능
}

const url = "https://www.example.co.kr:8080/path/to/resource?id=123&type=user#section-one";
const parsed = parseUrl(url);
console.log(parsed);
/*
{
  protocol: "https",
  hostname: "www.example.co.kr",
  port: "8080",
  pathname: "/path/to/resource",
  search: "?id=123&type=user",
  hash: "#section-one"
}
*/

레시피 3: 유튜브 비디오 ID 추출

다양한 형태의 유튜브 URL에서 비디오 ID만 정확히 추출하는 작업은 Regex의 좋은 활용 사례입니다.

function getYouTubeId(url) {
    // 다양한 유튜브 URL 패턴을 처리
    // 1. youtu.be/ID
    // 2. youtube.com/watch?v=ID
    // 3. youtube.com/embed/ID
    // 4. youtube.com/v/ID
    const youtubeRegex = /(?:https?:\/\/)?(?:www\.)?(?:youtu\.be\/|youtube\.com\/(?:embed\/|v\/|watch\?v=|watch\?.+&v=))([\w-]{11})/;
    const match = url.match(youtubeRegex);
    return match ? match[1] : null;
}

console.log(getYouTubeId("https://www.youtube.com/watch?v=dQw4w9WgXcQ")); // dQw4w9WgXcQ
console.log(getYouTubeId("https://youtu.be/dQw4w9WgXcQ"));              // dQw4w9WgXcQ
console.log(getYouTubeId("https://www.youtube.com/embed/dQw4w9WgXcQ")); // dQw4w9WgXcQ

6. 전문가의 길: 성능, 함정, 그리고 철학

6.1. 재앙적 백트래킹(Catastrophic Backtracking)을 피하라

잘못 작성된 Regex는 시스템을 멈추게 할 수 있습니다. 이를 'Regex 서비스 거부 공격(ReDoS)'이라고도 합니다. 주로 중첩된 수량자와 불분명한 패턴이 만날 때 발생합니다. 엔진이 일치 항목을 찾기 위해 경우의 수를 기하급수적으로 탐색하다가 무한 루프에 가까운 상태에 빠지는 현상입니다.

위험한 패턴의 예: /^(a+)+$/

이 패턴에 "aaaaaaaaaaaaaaaaaaaaaaaa! "와 같이 끝이 일치하지 않는 긴 문자열을 입력하면, Regex 엔진은 a+를 묶는 다양한 모든 경우의 수를 테스트하느라 엄청난 시간을 소모하게 됩니다.

예방책:

  • 중첩된 수량자(e.g., (a+)*, (a*)*)를 피하세요.
  • 패턴을 최대한 구체적으로 작성하세요. .* 대신 [^<]* 처럼 명확한 부정을 사용하세요.
  • 게으른 수량자(*?, +?)나 비캡처링 그룹((?:...))을 적절히 활용하세요.

6.2. Regex는 만병통치약이 아니다

망치를 든 사람에게는 모든 것이 못으로 보인다는 말이 있습니다. Regex에 익숙해지면 모든 문자열 문제를 Regex로 해결하려는 유혹에 빠질 수 있습니다. 하지만 항상 최선의 선택은 아닙니다.

  • 단순 작업에는 단순 메서드를: 특정 문자열 포함 여부는 .includes(), 시작/끝 여부는 .startsWith()/.endsWith()가 훨씬 빠르고 가독성이 좋습니다.
  • HTML/XML/JSON 파싱에는 전용 파서를: Regex로 복잡한 HTML을 파싱하려는 시도는 재앙적 백트래킹과 엣지 케이스 처리 실패로 이어지기 쉽습니다. 브라우저 환경에서는 DOMParser를, Node.js 환경에서는 jsdom, cheerio 같은 검증된 라이브러리를 사용하세요. JSON은 JSON.parse()를 사용해야 합니다.

6.3. 가독성과 유지보수를 생각하라

복잡한 Regex는 작성한 사람조차 몇 달 뒤에는 해독하기 어렵습니다. 팀 프로젝트에서는 더욱 심각한 문제가 됩니다.

  • 주석을 활용하세요: 코드에 주석을 달아 각 패턴 부분이 어떤 역할을 하는지 설명하세요.
  • 패턴을 분리하고 조합하세요: 매우 복잡한 패턴은 여러 개의 작은 패턴으로 분리한 뒤, 문자열로 조합하여 new RegExp()로 생성하는 것도 좋은 방법입니다.
  • 온라인 도구를 적극 활용하세요: Regex101, RegExr 같은 사이트는 패턴을 시각적으로 분석해주고, 각 부분이 어떻게 동작하는지 상세히 설명해주므로 디버깅과 학습에 필수적입니다.

마치며: 암호를 넘어 언어로

이 긴 여정을 통해 우리는 정규표현식이 더 이상 해독 불가능한 암호가 아니라, 문자열의 세계를 탐험하고 조작하는 정교하고 논리적인 '언어'임을 확인했습니다. 처음에는 낯설고 복잡하게 느껴질 수 있지만, 이 글에서 다룬 개념과 문법, 실전 예제들을 꾸준히 연습하고 실제 프로젝트에 적용하다 보면 어느새 문자열 처리 작업에 대한 강력한 자신감을 갖게 될 것입니다.

이제 구글에서 의미도 모른 채 패턴을 복사해 붙여넣던 시절과는 작별을 고할 시간입니다. 문제를 정확히 분석하고, 필요한 패턴을 직접 설계하며, 성능과 가독성까지 고려하는 진정한 문자열 전문가로 거듭나세요. 정규표현식이라는 강력한 무기를 당신의 개발 무기고에 추가한 것을 축하합니다.

Sunday, September 15, 2019

실무에서 바로 쓰는 정규표현식: 연속된 문자, 숫자 완벽하게 찾아내기

개발을 하다 보면 문자열 데이터 속에서 특정 패턴을 찾아야 하는 경우가 비일비재합니다. 사용자 아이디의 유효성을 검사하거나, 비밀번호 정책을 강제하거나, 혹은 잘못 입력된 데이터를 정리해야 할 때가 대표적입니다. 예를 들어, 'test0000'처럼 동일한 숫자가 과도하게 반복되는 아이디를 막거나, 'password111'과 같이 취약한 비밀번호를 걸러내고 싶을 수 있습니다. 또한, 텍스트 편집기에서 사용자가 실수로 '안녕하세요오오'라고 입력한 오타를 '안녕하세요'로 교정해주는 기능도 필요할 수 있습니다.

이러한 문제들을 해결하기 위해 매번 반복문(for, while)과 조건문(if)을 조합하여 코드를 작성하는 것은 매우 번거롭고 비효율적입니다. 코드가 길어지고, 가독성이 떨어지며, 다양한 예외 케이스를 처리하기 위해 로직은 점점 더 복잡해질 것입니다. 바로 이럴 때, '정규표현식(Regular Expression, 줄여서 Regex)'이 강력한 해결사로 등장합니다.

정규표현식은 문자열의 특정 패턴을 표현하는 언어입니다. 단 몇 줄의 코드로 복잡한 문자열 검색, 치환, 추출 작업을 놀랍도록 간결하고 우아하게 처리할 수 있습니다. 이번 글에서는 그중에서도 매우 실용적이면서도 강력한 기능인 '연속으로 반복되는 문자나 숫자'를 찾아내는 정규표현식 패턴에 대해 아주 깊이 있게 파헤쳐 보겠습니다. 이 글을 끝까지 읽으시면, 단순히 패턴을 복사해서 붙여넣는 수준을 넘어, 그 원리를 완벽하게 이해하고 여러분의 코드에 자유자재로 응용할 수 있는 능력을 갖추게 될 것입니다.


1. 문제 정의: 우리는 왜 '반복'을 찾아야 하는가?

본격적인 정규표현식 탐구에 앞서, 우리가 해결하려는 문제가 무엇인지 구체적인 시나리오를 통해 명확히 해봅시다. '연속된 문자 반복'이라는 패턴은 다양한 애플리케이션에서 중요한 검사 항목이 됩니다.

  • 사용자 입력 데이터 유효성 검사 (Input Validation)
    • 아이디/닉네임 생성 규칙: 'aaaaa', 'user0000' 등 성의 없거나 의미 없는 아이디 생성을 방지하여 서비스 품질을 유지합니다.
    • 게시글 및 댓글 작성: 'ㅋㅋㅋㅋㅋㅋㅋ', '!!!!!!!' 와 같이 특정 문자를 무의미하게 반복하여 도배하는 행위를 제한할 수 있습니다.
  • 비밀번호 보안 강화 (Password Security)
    • 가장 대표적인 사용 사례입니다. 'pass1111'이나 'qwertyzzz'처럼 동일한 문자나 숫자가 연속으로 3번 이상 나타나는 비밀번호는 추측하기 쉬워 매우 취약합니다. 대부분의 서비스에서는 이러한 패턴을 금지하는 정책을 가지고 있습니다.
  • 데이터 정제 및 클렌징 (Data Cleansing)
    • 사용자의 오타나 기계적인 오류로 인해 데이터가 잘못 입력되는 경우가 있습니다. 예를 들어, 'Hellooo World'를 'Hello World'로, '미팅 장소는 강남역 1번 출구구구'를 '강남역 1번 출구'로 교정하는 작업에 활용될 수 있습니다.
  • 로그 분석 및 시스템 모니터링 (Log Analysis)
    • 시스템 로그에서 비정상적으로 반복되는 특정 에러 코드나 메시지 시퀀스를 찾아내어 시스템의 이상 징후를 조기에 발견할 수 있습니다. 예를 들어, 'FAIL FAIL FAIL FAIL'과 같은 패턴을 감지할 수 있습니다.
  • 자연어 처리 및 텍스트 마이닝 (NLP & Text Mining)
    • 텍스트 데이터에서 강조나 감정 표현(예: '정말 대박이다ㅏㅏㅏ')을 분석하거나, 반대로 정규화(Normalization) 과정에서 이러한 반복을 제거하여 분석의 정확도를 높이는 데 사용됩니다.

이처럼 반복되는 문자를 찾는 기술은 단순히 재미있는 코딩 트릭이 아니라, 안정적이고 신뢰성 높은 소프트웨어를 구축하기 위한 핵심적인 요소 중 하나입니다. 이제, 이 모든 문제를 해결해 줄 마법 같은 정규표현식을 만나보겠습니다.


2. 핵심 원리: 마법의 패턴 /(\w)\1+/g 완전 해부

연속된 문자나 숫자를 찾는 가장 기본적인 정규표현식은 바로 /(\w)\1+/g 입니다. 처음 보면 암호처럼 보일 수 있지만, 각 구성 요소의 의미를 하나씩 뜯어보면 놀랍도록 논리적이고 간단합니다. 이 패턴을 완벽히 이해하는 것이 이번 여정의 핵심입니다.

/ ( \w ) \1 + / g

이 패턴을 6개의 조각으로 나누어 세밀하게 분석해 보겠습니다.

2.1. 슬래시 (/): 패턴의 시작과 끝

JavaScript에서 정규표현식 리터럴은 슬래시(/)로 시작하고 슬래시로 끝납니다. 즉, /.../는 "이 안에 있는 내용이 정규표현식 패턴이다"라고 인터프리터에게 알려주는 약속입니다.

2.2. 괄호 ( ... ): '기억'을 위한 캡처 그룹(Capturing Group)

정규표현식에서 괄호는 매우 특별하고 강력한 역할을 합니다. 괄호로 감싸인 부분은 '캡처 그룹'이 됩니다. 캡처 그룹의 핵심 기능은 '그룹 안의 패턴과 일치하는 문자열을 기억(캡처)하는 것'입니다.

예를 들어, (abc)라는 패턴은 'abc'라는 문자열과 일치하며, 일치하는 순간 'abc'를 메모리에 저장해 둡니다. 이 '기억' 기능은 나중에 다시 참조하기 위해 사용되며, 이것이 바로 우리가 살펴볼 '역참조'의 기반이 됩니다.

2.3. 역슬래시 w (\w): 단어 문자(Word Character)

\w는 '단어 문자'를 의미하는 정규표현식의 약칭(shorthand)입니다. 이것은 다음 문자의 집합과 동일한 의미를 가집니다.

  • 알파벳 대문자 (A-Z)
  • 알파벳 소문자 (a-z)
  • 숫자 (0-9)
  • 언더스코어 (_)

즉, \w[A-Za-z0-9_]와 정확히 같습니다. 우리의 패턴 (\w)는 "알파벳, 숫자, 언더스코어 중 하나의 문자와 일치하고, 그 문자를 첫 번째 캡처 그룹으로 기억하라"는 의미가 됩니다.

만약 문자열 'apple'을 이 패턴으로 검사한다면, 엔진은 다음과 같이 동작합니다.

  1. 'a'를 만납니다. \w와 일치합니다. 'a'를 첫 번째 캡처 그룹(\1)에 저장합니다.
  2. 'p'를 만납니다. \w와 일치합니다. 'p'를 첫 번째 캡처 그룹에 저장합니다. (이전 값 'a'는 덮어쓰여집니다.)
  3. ... 이런 식으로 계속 진행됩니다.

아직은 각각의 문자와 한 번씩만 매칭되고 있습니다. 이제 '반복'을 찾아내는 마법이 등장할 차례입니다.

2.4. 역슬래시 1 (\1): 마법의 열쇠, 역참조(Backreference)

\1은 '역참조(Backreference)'라고 불리며, 정규표현식의 가장 강력한 기능 중 하나입니다. 이것의 의미는 "첫 번째(1) 캡처 그룹이 기억하고 있는 바로 그 내용과 정확히 일치하는 문자"를 찾으라는 명령입니다.

앞서 (\w)가 문자를 찾아 기억한다고 했습니다. \1은 그 기억된 문자를 다시 불러와 사용하는 것입니다.

이제 (\w)\1 이라는 패턴을 'apple'과 'aa'에 적용해 봅시다.

  • 'apple'의 경우:
    1. 첫 번째 문자 'a'를 만납니다. (\w)가 'a'와 일치하고, 'a'를 \1에 기억합니다.
    2. 다음으로 \1을 검사할 차례입니다. \1은 'a'를 의미합니다. 하지만 문자열의 다음 문자는 'p'입니다. 'a'와 'p'는 다르므로 일치하지 않습니다. 패턴 매칭이 실패하고 처음부터 다시 시작합니다.
    3. 두 번째 문자 'p'부터 다시 시작합니다. (\w)가 'p'와 일치하고, 'p'를 \1에 기억합니다.
    4. 다음으로 \1('p'를 의미)을 검사합니다. 문자열의 다음 문자는 'p'입니다. 일치합니다! 하지만 그 다음이 없으므로 'pp'는 일치하지 않습니다. (만약 'apple'이 아니라 'appple'이었다면 'pp'가 일치했을 것입니다)
  • 'aa'의 경우:
    1. 첫 번째 문자 'a'를 만납니다. (\w)가 'a'와 일치하고, 'a'를 \1에 기억합니다.
    2. 다음으로 \1('a'를 의미)을 검사합니다. 문자열의 다음 문자는 'a'입니다. 'a'와 'a'는 일치합니다!
    3. 결과적으로, (\w)\1 패턴은 'aa'라는 문자열과 성공적으로 일치합니다.

이것이 바로 '연속된 동일한 문자'를 찾는 핵심 원리입니다. 첫 번째 문자를 기억하고, 바로 다음 문자가 기억된 문자와 같은지 비교하는 것입니다.

2.5. 플러스 (+): 하나 이상의 반복 (Quantifier)

+는 '수량자(Quantifier)'라고 불리며, 바로 앞에 있는 패턴이 1번 이상 반복되는 경우를 찾습니다. 즉, "as many times as possible, but at least once"의 의미입니다.

우리의 패턴에서 +\1 바로 뒤에 붙어있습니다. 따라서 \1+는 "첫 번째 캡처 그룹에서 기억한 문자가 1번 이상 연속으로 나타나는 부분"을 의미합니다.

이제 전체 패턴 (\w)\1+를 'helooo'라는 문자열에 적용해 보겠습니다.

  1. 'h', 'e', 'l'까지는 일치하는 반복이 없어 넘어갑니다.
  2. 첫 번째 'o'를 만납니다. (\w)가 'o'와 일치하고, 'o'를 \1에 기억합니다.
  3. 이제 \1+를 검사할 차례입니다.
    • 다음 문자는 'o'입니다. \1('o'를 의미)과 일치합니다. (1번 반복)
    • 그 다음 문자도 'o'입니다. \1('o'를 의미)과 다시 일치합니다. (2번 반복)
  4. \1이 총 2번 반복되었습니다. +(1번 이상 반복) 조건을 만족합니다.
  5. 따라서, (\w)에 일치하는 'o'와, \1+에 일치하는 'oo'가 합쳐져, 최종적으로 'ooo'라는 문자열이 이 패턴과 일치하게 됩니다.

2.6. g 플래그 (/g): 전역 검색 (Global Search)

마지막으로 패턴의 끝에 붙는 g는 '플래그(flag)'라고 불리며, 검색 옵션을 지정합니다. g'전역(Global)' 검색을 의미합니다.

만약 g 플래그가 없다면, 정규표현식 엔진은 패턴과 일치하는 첫 번째 결과만 찾고 검색을 종료합니다. 하지만 g 플래그가 있으면, 문자열 전체를 스캔하여 패턴과 일치하는 모든 결과를 찾아냅니다.

예를 들어, 'aaabbc_111'이라는 문자열에 /(\w)\1+/ (g 없음)를 적용하면 'aaa'만 찾아내고 멈춥니다. 하지만 /(\w)\1+/g (g 있음)를 적용하면 'aaa', 'bb', '111'을 모두 찾아냅니다.

이것으로 우리는 /(\w)\1+/g라는 패턴의 모든 구성 요소를 완벽하게 이해했습니다. 다시 한번 정리하면 다음과 같습니다.

/(\w)\1+/g : 문자열 전체(g)에서, 단어 문자(\w)가 나온 뒤, 바로 그 문자(\1)가 1번 이상 연속으로 반복(+)되는 모든 부분을 찾아라. 이때, 첫 번째 단어 문자는 기억(())해 두어야 한다.


3. JavaScript 실전 예제: 다양한 메서드 활용법

원리를 이해했으니 이제 JavaScript에서 이 정규표현식을 실제로 어떻게 활용하는지 다양한 메서드를 통해 알아보겠습니다. 각 메서드는 고유한 특징과 반환 값을 가지므로, 상황에 맞게 적절한 것을 선택하는 것이 중요합니다.


// 테스트에 사용할 정규표현식과 문자열
const pattern = /(\w)\1+/g;
const text = 'Heeelloo, thiiis is a teest string with reepeated characters... and numbers 111, 22, 3333.';

3.1. String.prototype.match(): 모든 일치 항목 배열로 얻기

match() 메서드는 정규표현식과 일치하는 부분을 검색합니다. g 플래그가 있을 때와 없을 때 동작이 다릅니다.

g 플래그 사용 시:

g 플래그가 있으면, 일치하는 모든 문자열을 담은 배열을 반환합니다. 일치하는 것이 없으면 null을 반환합니다. 이는 가장 일반적으로 사용되는 방법입니다.


const pattern = /(\w)\1+/g;
const text = 'Heeelloo, thiiis is a teest string with reepeated characters... and numbers 111, 22, 3333.';

const matches = text.match(pattern);

console.log(matches);
// 결과: [ "eee", "ll", "oo", "iii", "s", "s", "ee", "t", "ee", "111", "22", "3333" ]

보시는 바와 같이, 문자열 내에서 연속으로 반복되는 모든 부분을 정확하게 찾아내어 배열로 만들어 줍니다. 이를 이용해 "반복되는 문자가 3개 이상인 경우"를 필터링하는 등의 추가 작업을 쉽게 할 수 있습니다.


const longRepeats = matches.filter(match => match.length >= 3);
console.log(longRepeats);
// 결과: [ "eee", "iii", "111", "3333" ]

g 플래그 미사용 시:

g 플래그가 없으면, 첫 번째로 일치하는 부분에 대한 상세 정보를 담은 배열을 반환합니다. 이 배열에는 전체 일치 문자열 외에, 각 캡처 그룹의 내용, 인덱스 등의 추가 정보가 포함됩니다.


const patternWithoutG = /(\w)\1+/; // g 플래그 제거
const firstMatchInfo = text.match(patternWithoutG);

console.log(firstMatchInfo);
// 결과:
// [
//   "eee",      // 0: 전체 일치 문자열 (전체 패턴과 매칭된 부분)
//   "e",        // 1: 첫 번째 캡처 그룹 (\w)가 캡처한 내용
//   index: 1,      // 일치하는 부분의 시작 인덱스
//   input: "Heeelloo, thiiis is a teest...", // 원본 문자열
//   groups: undefined
// ]

이처럼 g 플래그 없이 사용하면, 단순히 일치 여부뿐만 아니라 어떤 문자가('e') 반복되었는지, 그리고 어디서(index: 1) 시작되었는지 등의 훨씬 상세한 정보를 얻을 수 있습니다.

3.2. RegExp.prototype.test(): 존재 여부만 빠르게 확인하기

test() 메서드는 문자열이 정규표현식과 일치하는지 여부만 확인하고 싶을 때 사용합니다. true 또는 false를 반환하므로, 조건문에서 간단하게 사용하기 좋습니다.


const pattern = /(\w)\1+/; // g 플래그는 test()와 함께 사용할 때 주의가 필요합니다.
const weakPassword1 = "password111";
const weakPassword2 = "mylovelysun";
const strongPassword = "abc_123_def";

console.log(`"${weakPassword1}"에 반복 문자가 있나요?`, pattern.test(weakPassword1)); // true
console.log(`"${weakPassword2}"에 반복 문자가 있나요?`, pattern.test(weakPassword2)); // true ('l' 반복)
console.log(`"${strongPassword}"에 반복 문자가 있나요?`, pattern.test(strongPassword)); // false

주의: g 플래그가 있는 정규표현식 객체에 test()를 여러 번 호출하면 예상과 다르게 동작할 수 있습니다. 정규표현식 객체는 마지막으로 일치한 위치(lastIndex)를 기억하기 때문에, 다음 검색은 그 위치부터 시작합니다. 따라서 일관된 결과를 원한다면 test()를 사용할 때는 g 플래그를 빼거나, 매번 새로운 정규표현식 객체를 생성하는 것이 안전합니다.

3.3. RegExp.prototype.exec(): 상세 정보와 함께 모든 항목 순회하기

exec() 메서드는 match()(g 없음)와 유사하게 상세 정보를 반환하지만, g 플래그와 함께 사용될 때 진가를 발휘합니다. exec()는 호출될 때마다 다음 일치 항목을 찾아 반환하며, 더 이상 일치하는 것이 없으면 null을 반환합니다. 이를 이용해 while 루프 안에서 모든 일치 항목을 순회하며 상세 정보를 얻을 수 있습니다.


const pattern = /(\w)\1+/g; // g 플래그 필수!
const text = 'aa-bb-cc-111';
let matchInfo;

while ((matchInfo = pattern.exec(text)) !== null) {
  console.log(
    `전체 일치: "${matchInfo[0]}", ` +
    `반복된 문자: "${matchInfo[1]}", ` +
    `시작 위치: ${matchInfo.index}`
  );
}

// 결과:
// 전체 일치: "aa", 반복된 문자: "a", 시작 위치: 0
// 전체 일치: "bb", 반복된 문자: "b", 시작 위치: 3
// 전체 일치: "cc", 반복된 문자: "c", 시작 위치: 6
// 전체 일치: "111", 반복된 문자: "1", 시작 위치: 9

exec()match()보다 더 많은 제어권을 제공하며, 각 일치 항목의 위치와 캡처 그룹 내용을 모두 알아야 할 때 매우 유용합니다.

3.4. String.prototype.replace(): 찾아낸 패턴을 다른 문자열로 바꾸기

replace()는 정규표현식의 활용도를 극대화하는 메서드입니다. 패턴에 일치하는 부분을 다른 문자열로 바꿀 수 있습니다. 이때, 교체될 문자열 안에서 특별한 패턴($&, $1 등)을 사용하여 원본의 일치 정보를 재활용할 수 있습니다.

  • $&: 일치한 전체 문자열
  • $1, $2, ...: 첫 번째, 두 번째, ... 캡처 그룹의 내용

예제 1: 반복된 문자 압축하기 ('helooo' -> 'helo')

이것은 캡처 그룹과 역참조의 개념을 완벽히 보여주는 예제입니다. 우리는 (\w)\1+ 패턴을 사용하여 'ooo'를 찾은 다음, 그것을 캡처된 문자 \1 즉, 'o' 하나로 교체할 것입니다.


const pattern = /(\w)\1+/g;
const text = 'helooo woorld, I am verrry happyyy!';

// $1은 첫 번째 캡처 그룹, 즉 반복되는 문자를 가리킨다.
const compressedText = text.replace(pattern, '$1'); 

console.log(compressedText);
// 결과: "helo world, I am very happy!"

이 코드는 'ooo'를 찾아 $1('o')로 바꾸고, 'oo'를 찾아 $1('o')로, 'rr'을 $1('r')로, 'yyy'를 $1('y')로 바꿔줍니다. 단 한 줄의 코드로 매우 효과적인 데이터 정제가 가능합니다.

예제 2: 반복된 부분 강조하기

이번에는 찾은 부분을 대문자로 바꾸고 괄호로 감싸서 강조해 보겠습니다.


const pattern = /(\w)\1+/g;
const text = 'A mississippi river boat.';

const highlightedText = text.replace(pattern, (match) => `[${match.toUpperCase()}]`);

console.log(highlightedText);
// 결과: "A mi[SS]i[SS]ippi river boat."

replace()의 두 번째 인자로 함수를 전달하면, 더 복잡하고 동적인 치환 로직을 구현할 수 있습니다. 함수의 첫 번째 매개변수(match)는 일치한 전체 문자열($&와 동일)을 받습니다.


4. 패턴 확장 및 고급 응용 기술

기본 패턴 /(\w)\1+/g의 원리를 마스터했다면, 이제 여러분의 필요에 맞게 패턴을 자유자재로 변형하고 확장할 수 있습니다. 정규표현식의 진정한 힘은 이러한 유연성에 있습니다.

4.1. 검색 대상 문자 변경하기

\w(알파벳, 숫자, 언더스코어) 대신 다른 문자 집합을 대상으로 반복을 찾고 싶을 수 있습니다.

모든 문자(줄바꿈 제외)에서 반복 찾기: (.)\1+

.(점) 메타문자는 줄바꿈 문자(\n)를 제외한 모든 문자와 일치합니다. 이를 이용하면 특수문자나 공백의 반복도 찾아낼 수 있습니다.


const pattern = /(.)\1+/g;
const text = 'Wow!!! That is sooooo cool...  Right??';

console.log(text.match(pattern));
// 결과: [ "!!!", "ooooo", "...", "  ", "??" ]

\w를 사용했다면 '!!!', '...', ' ', '??'는 찾지 못했을 것입니다. (.)로 바꾸는 것만으로 검색 범위가 훨씬 넓어졌습니다.

숫자만으로 반복 찾기: (\d)\1+ 또는 ([0-9])\1+

\d는 숫자(digit)를 의미하며, [0-9]와 같습니다. 전화번호나 계좌번호 등에서 연속된 숫자를 찾을 때 유용합니다.


const pattern = /(\d)\1+/g;
const text = 'My phone number is 010-1111-2223';

console.log(text.match(pattern));
// 결과: [ "1111", "222" ]

특정 문자들로만 반복 찾기: ([abc])\1+

대괄호 [] 안에 원하는 문자들을 넣어 '문자 집합(character set)'을 만들 수 있습니다. 아래 예제는 a, b, c 중에서 반복되는 경우만 찾습니다.


const pattern = /([abc])\1+/g;
const text = 'aaabbbcccdddeee';

console.log(text.match(pattern));
// 결과: [ "aaa", "bbb", "ccc" ] 
// 'ddd'와 'eee'는 [abc] 집합에 포함되지 않으므로 무시됩니다.

4.2. 반복 횟수 제어하기: 수량자 {n,m}

+는 '1번 이상'을 의미했지만, 때로는 '정확히 3번' 또는 '2번에서 4번 사이'와 같이 더 정교하게 반복 횟수를 제어해야 합니다. 이때 중괄호 수량자 {}를 사용합니다.

  • {n}: 정확히 n번 반복
  • {n,}: 최소 n번 이상 반복
  • {n,m}: 최소 n번, 최대 m번 반복

3번 이상 연속되는 문자 찾기 (비밀번호 규칙)

이는 비밀번호 유효성 검사에서 매우 흔하게 사용되는 규칙입니다. 'aaa'나 '1111'은 허용하지 않는 경우입니다.


const pattern = /(\w)\1{2,}/; // \1이 2번 이상 반복, 즉 전체 문자는 3번 이상 연속
const password_ok = "pa55word";
const password_fail1 = "passwooorrd";
const password_fail2 = "1234444abc";

console.log(pattern.test(password_ok));    // false
console.log(pattern.test(password_fail1)); // true ('ooo' 때문에)
console.log(pattern.test(password_fail2)); // true ('4444' 때문에)

(\w)\1{2,}를 분석해보면, (\w)가 한 문자를 차지하고, \1{2,}가 그 문자의 2번 이상 반복을 의미하므로, 총 1 + 2 = 3번 이상 연속되는 문자를 찾게 됩니다.

정확히 2번 연속되는 문자만 찾기


const pattern = /(\w)\1{1}/g; // \1이 정확히 1번 반복. 즉, 전체 문자는 2번 연속
// 참고: {1}은 생략할 수 없으므로, (\w)\1 과는 다르게 동작할 수 있습니다. 
// 더 명확하게는 부정형 탐색을 사용해야 하지만, 기본적인 개념은 이렇습니다.
// 정확히 2번만 찾기 위한 더 정교한 패턴: /(\w)\1(?!\1)/g
// (?!\1)은 'lookahead' 문법으로, 뒤에 \1이 오지 않는 경우에만 일치하라는 뜻입니다.

const text = 'aa bbb cccc d ee';
const precisePattern = /(\w)\1(?!\1)/g;

console.log(text.match(precisePattern));
// 결과: [ "aa", "ee" ] 
// 'bbb'나 'cccc'는 뒤에 같은 문자가 또 오기 때문에 (?!\1) 조건에 걸려 제외됩니다.

4.3. 고급 활용 사례: 반복되는 '단어' 찾기

지금까지는 '문자'의 반복을 찾았습니다. 역참조를 응용하면 '단어'의 반복도 쉽게 찾을 수 있습니다. 예를 들어, "I love love this." 와 같은 문장에서 중복된 'love'를 찾는 것입니다.

패턴은 다음과 같습니다: /\b(\w+)\s+\1\b/g

  • \b: 단어 경계(Word Boundary). 단어의 시작이나 끝을 의미합니다. 공백, 구두점, 문자열의 시작/끝과 단어 문자 사이의 위치에 해당합니다. 이것이 없으면 'the theater'에서 'the'와 'theater'의 'the'를 반복으로 오인할 수 있습니다.
  • (\w+): 1개 이상의 단어 문자로 이루어진 '단어'를 캡처합니다.
  • \s+: 1개 이상의 공백 문자(스페이스, 탭, 줄바꿈 등). 단어와 단어 사이의 간격을 의미합니다.
  • \1: 첫 번째 캡처 그룹, 즉 앞에서 찾은 바로 그 '단어'를 의미합니다.
  • \b: 다시 단어 경계로 끝나야 완전한 단어 반복입니다.

const pattern = /\b(\w+)\s+\1\b/gi; // i 플래그로 대소문자 무시
const text = "This is a test test string. Paris in the the spring. Hello hello world!";

console.log(text.match(pattern));
// 결과: [ "test test", "the the", "Hello hello" ]

// 중복 단어 제거하기
const correctedText = text.replace(pattern, '$1'); // $1은 캡처된 단어
console.log(correctedText);
// 결과: "This is a test string. Paris in the spring. Hello world!"

이처럼 기본 원리를 응용하면 훨씬 더 복잡하고 유용한 패턴을 만들어 낼 수 있습니다.


5. 결론: 하나의 패턴, 무한한 가능성

우리는 오늘 /(\w)\1+/g라는 하나의 정규표현식으로 시작하여 그 내부의 동작 원리를 원자 단위까지 깊이 파고들었습니다. 캡처 그룹 ()의 '기억' 능력과 역참조 \1의 '재활용' 능력이 어떻게 결합하여 '반복'이라는 패턴을 찾아내는지 명확히 이해했습니다.

더 나아가, 우리는 이 기본 패턴을 다양한 상황에 맞게 변형하는 방법을 배웠습니다.

  • \w., \d, [] 등으로 바꾸어 검색 대상을 변경했습니다.
  • +{n,m} 수량자로 바꾸어 반복 횟수를 정교하게 제어했습니다.
  • JavaScript의 match, test, exec, replace 메서드를 활용하여 단순히 찾는 것을 넘어, 확인하고, 순회하고, 수정하는 실용적인 코드를 작성했습니다.
  • 마지막으로, 개념을 확장하여 반복되는 '문자'가 아닌 반복되는 '단어'를 찾는 고급 기술까지 살펴보았습니다.

정규표현식은 처음에는 외계어처럼 보일 수 있지만, 그 핵심 원리를 이해하고 나면 코드를 작성하는 방식을 근본적으로 바꾸는 강력한 도구가 됩니다. 복잡한 문자열 처리 로직을 단 한 줄의 표현식으로 압축할 때의 짜릿함은 개발자만이 느낄 수 있는 큰 즐거움 중 하나입니다.

오늘 배운 역참조(Backreference)의 개념을 잊지 마십시오. 이것은 정규표현식의 수많은 고급 기능으로 통하는 문을 열어주는 열쇠입니다. 이제 여러분의 코드에 흩어져 있는 비효율적인 문자열 처리 로직들을 이 우아하고 강력한 정규표현식으로 리팩토링해 보시는 것은 어떨까요? 하나의 패턴을 마스터한 여러분의 가능성은 이제 무한합니다.