Java 생성자, 상속, 실행 순서 완전 정복

Java 생성자의 동작 원리부터 상속 관계에서의 실행 순서까지, 헷갈리는 개념을 코드와 다이어그램으로 정리합니다.

생성자란?

생성자(Constructor)는 객체가 만들어질 때 딱 한 번 자동으로 호출되는 특수 메서드다. 목적은 하나 — 객체의 초기 상태를 설정하는 것.

비유: 생성자는 새 건물을 지을 때 쓰는 설계도 겸 인테리어 공사다. 건물(객체)이 완성되는 순간 공사(생성자)가 실행되고, 이후엔 일반 메서드로만 상태를 바꿀 수 있다.

class Car {
    String model;
    int speed;

    // 생성자: 클래스 이름과 동일, 반환 타입 없음
    Car(String model, int speed) {
        this.model = model;  // this = 지금 만들어지는 이 객체
        this.speed = speed;
    }
}

Car myCar = new Car("Tesla", 100);
// new → 메모리 할당 → 생성자 호출 → 객체 완성

생성자의 규칙

규칙 설명
클래스 이름과 동일 Car 클래스의 생성자는 Car()
반환 타입 없음 void도 쓰지 않는다
new 키워드로만 호출 일반 메서드처럼 직접 호출 불가
여러 개 가능 매개변수가 다르면 여러 개 선언 가능 (오버로딩)

기본 생성자 vs 매개변수 생성자

기본 생성자 (Default Constructor)

생성자를 하나도 안 만들면 컴파일러가 자동으로 빈 생성자를 추가한다.

class Dog {
    String name;
    // 생성자를 선언하지 않음
}

// 컴파일러가 이걸 자동으로 추가:
// Dog() {}

Dog d = new Dog();  // 가능

주의: 매개변수 있는 생성자를 하나라도 직접 만들면, 기본 생성자는 자동 추가되지 않는다.

class Dog {
    String name;

    Dog(String name) {  // 직접 선언
        this.name = name;
    }
}

Dog d1 = new Dog("바둑이");  // OK
Dog d2 = new Dog();          // 컴파일 에러! 기본 생성자 없음

기본 생성자도 필요하다면 직접 추가해야 한다.

class Dog {
    String name;

    Dog() { this.name = "이름없음"; }        // 직접 추가

    Dog(String name) { this.name = name; }
}

this 와 this()

this — 자기 자신을 가리키는 참조

class Person {
    String name;
    int age;

    Person(String name, int age) {
        // 매개변수 name과 필드 name이 이름 충돌
        this.name = name;  // this.name = 필드, name = 매개변수
        this.age  = age;
    }
}

this() — 같은 클래스의 다른 생성자 호출

생성자 안에서 다른 생성자를 재사용할 수 있다. 반드시 첫 번째 줄에 있어야 한다.

class Person {
    String name;
    int age;
    String job;

    Person(String name, int age) {
        this.name = name;
        this.age  = age;
        this.job  = "무직";
    }

    Person(String name, int age, String job) {
        this(name, age);   // 위의 생성자를 재사용 (첫 줄이어야 함)
        this.job = job;
    }
}
new Person("김철수", 30, "개발자") 호출 시:
  → Person(name, age, job) 진입
  → this(name, age) → Person(name, age) 먼저 실행
  → job = "개발자" 설정

상속 (Inheritance)

상속이란?

부모 클래스의 필드와 메서드를 자식 클래스가 물려받는 것. 공통 코드를 중복 없이 재사용하고, 기능을 확장할 수 있다.

비유: 부모님의 재산(코드)을 자식이 물려받되, 자식은 자신만의 재산을 추가할 수 있다.

// 부모 클래스
class Animal {
    String name;

    Animal(String name) {
        this.name = name;
        System.out.println("Animal 생성자: " + name);
    }

    void breathe() {
        System.out.println(name + " 숨 쉬는 중");
    }
}

// 자식 클래스 — extends로 상속
class Dog extends Animal {
    String breed;

    Dog(String name, String breed) {
        super(name);   // 부모 생성자 호출 (반드시 첫 줄)
        this.breed = breed;
        System.out.println("Dog 생성자: " + breed);
    }

    void bark() {
        System.out.println(name + " 왈왈!");  // 부모 필드 사용 가능
    }
}
Dog dog = new Dog("바둑이", "진돗개");

출력:
Animal 생성자: 바둑이   ← 부모 먼저
Dog 생성자: 진돗개      ← 자식 나중

상속 관계 구조

        Animal
        ┌──────────────┐
        │ name         │
        │ breathe()    │
        └──────┬───────┘
               │ extends
        ┌──────▼───────┐
        │ Dog          │
        │ breed        │  ← Dog만의 추가 필드
        │ breathe()    │  ← Animal에서 상속
        │ bark()       │  ← Dog만의 추가 메서드
        └──────────────┘

Java 상속의 제약

  • 단일 상속만 가능: extends는 하나만 (다중 상속 불가)
  • private 멤버는 상속되지 않음 (존재하지만 직접 접근 불가)
  • final 클래스는 상속 불가 (String이 대표적 예)

super 와 super()

super() — 부모 생성자 호출

자식 생성자의 첫 번째 줄에 위치해야 한다. 명시적으로 쓰지 않으면 컴파일러가 super() (부모 기본 생성자 호출)를 자동 삽입한다.

class Animal {
    String name;

    // 기본 생성자 없음! 매개변수 생성자만 있음
    Animal(String name) {
        this.name = name;
    }
}

class Dog extends Animal {
    Dog() {
        // 컴파일러가 super() 삽입 시도 → Animal에 기본 생성자 없어서 에러!
    }
}

부모가 기본 생성자가 없으면 자식은 반드시 super(인자)를 명시해야 한다.

class Dog extends Animal {
    Dog(String name) {
        super(name);   // 명시적으로 부모 생성자 호출
    }
}

super — 부모의 필드/메서드에 접근

자식과 부모에 같은 이름이 있을 때 구분하는 용도.

class Animal {
    String name = "동물";

    void speak() {
        System.out.println("...");
    }
}

class Dog extends Animal {
    String name = "개";   // 부모와 같은 이름

    void showNames() {
        System.out.println(name);        // "개" (자식)
        System.out.println(super.name);  // "동물" (부모)
    }

    @Override
    void speak() {
        super.speak();               // 부모 메서드 호출
        System.out.println("왈왈!"); // 추가 동작
    }
}

메서드 오버라이딩 (Overriding)

부모에게 물려받은 메서드를 자식이 재정의하는 것.

class Shape {
    double area() {
        return 0;
    }
}

class Circle extends Shape {
    double radius;

    Circle(double radius) {
        this.radius = radius;
    }

    @Override  // 오버라이딩 명시 (생략 가능하지만 붙이는 게 좋다)
    double area() {
        return Math.PI * radius * radius;
    }
}

class Rectangle extends Shape {
    double width, height;

    Rectangle(double width, double height) {
        this.width = width;
        this.height = height;
    }

    @Override
    double area() {
        return width * height;
    }
}

오버라이딩 규칙

항목 규칙
메서드 이름 동일해야 함
매개변수 동일해야 함
반환 타입 동일하거나 자식 타입(공변 반환)
접근 제어자 부모보다 좁아질 수 없음 (publicprivate 불가)
예외 부모보다 넓은 예외 추가 불가

오버로딩 vs 오버라이딩 — 헷갈리지 않기

class Calculator {
    // 오버로딩(Overloading): 같은 클래스, 같은 이름, 다른 매개변수
    int add(int a, int b)       { return a + b; }
    double add(double a, double b) { return a + b; }

    // → 컴파일 시점에 어떤 메서드 쓸지 결정 (정적 바인딩)
}

class SmartCalculator extends Calculator {
    // 오버라이딩(Overriding): 부모 메서드를 자식이 재정의
    @Override
    int add(int a, int b) {
        System.out.println("더하기 실행");
        return a + b;
    }
    // → 런타임에 어떤 메서드 쓸지 결정 (동적 바인딩)
}

초기화 블록 (Initializer Block)

생성자 외에 객체/클래스를 초기화하는 두 가지 블록이 있다.

인스턴스 초기화 블록 {}

모든 생성자가 공통으로 실행해야 할 코드를 넣는다. 생성자보다 먼저 실행된다.

class Server {
    String host;
    int port;
    long startTime;

    // 인스턴스 초기화 블록 — 모든 생성자 실행 전에 항상 실행
    {
        startTime = System.currentTimeMillis();
        System.out.println("서버 초기화 블록 실행");
    }

    Server() {
        this.host = "localhost";
        this.port = 8080;
    }

    Server(String host, int port) {
        this.host = host;
        this.port = port;
    }
}

static 초기화 블록 static {}

클래스가 JVM에 처음 로딩될 때 딱 한 번 실행된다. static 변수 초기화에 사용.

class DatabaseConfig {
    static String url;
    static String driver;

    // static 초기화 블록 — 클래스 로딩 시 1회 실행
    static {
        System.out.println("DB 설정 로딩");
        url    = "jdbc:mysql://localhost:3306/mydb";
        driver = "com.mysql.jdbc.Driver";
    }
}

// DatabaseConfig.url 처음 접근하는 순간 static 블록 실행
System.out.println(DatabaseConfig.url);

실행 순서 — 핵심

이게 가장 헷갈리는 부분이다. 상속 관계에서 객체 생성 시 순서를 정확히 알아야 한다.

단일 클래스의 초기화 순서

1. static 변수 초기화 (클래스 로딩 시 1회)
2. static 초기화 블록 (클래스 로딩 시 1회)
───── 이하는 new 할 때마다 ─────
3. 인스턴스 변수 기본값 설정 (int → 0, String → null...)
4. 인스턴스 변수 선언부 초기화
5. 인스턴스 초기화 블록 {}
6. 생성자
class Example {
    static int staticVar = 10;              // 1. static 변수

    static {
        System.out.println("static 블록");  // 2. static 블록
    }

    int instanceVar = 20;                   // 4. 인스턴스 변수

    {
        System.out.println("인스턴스 블록"); // 5. 인스턴스 블록
    }

    Example() {
        System.out.println("생성자");       // 6. 생성자
    }
}

// new Example() 시 출력:
// static 블록       ← 클래스 최초 로딩 시 1회
// 인스턴스 블록      ← new 할 때마다
// 생성자            ← new 할 때마다

상속 관계의 실행 순서

핵심 규칙: 항상 부모부터 완전히 초기화된 후 자식이 초기화된다.

1. 부모 static 변수 & static 블록
2. 자식 static 변수 & static 블록
───── 이하는 new 할 때마다 ─────
3. 부모 인스턴스 변수 초기화
4. 부모 인스턴스 블록
5. 부모 생성자
6. 자식 인스턴스 변수 초기화
7. 자식 인스턴스 블록
8. 자식 생성자

코드로 확인:

class Animal {
    static { System.out.println("1. Animal static 블록"); }

    String name;

    { System.out.println("3. Animal 인스턴스 블록"); }

    Animal(String name) {
        this.name = name;
        System.out.println("4. Animal 생성자: " + name);
    }
}

class Dog extends Animal {
    static { System.out.println("2. Dog static 블록"); }

    String breed;

    { System.out.println("5. Dog 인스턴스 블록"); }

    Dog(String name, String breed) {
        super(name);   // 부모 생성자 호출 → 3, 4번 실행
        this.breed = breed;
        System.out.println("6. Dog 생성자: " + breed);
    }
}

// new Dog("바둑이", "진돗개") 실행 시 출력:
// 1. Animal static 블록   ← 클래스 로딩 시 1회
// 2. Dog static 블록      ← 클래스 로딩 시 1회
// 3. Animal 인스턴스 블록
// 4. Animal 생성자: 바둑이
// 5. Dog 인스턴스 블록
// 6. Dog 생성자: 진돗개

3단계 상속의 실행 순서

class A {
    A() { System.out.println("A 생성자"); }
}

class B extends A {
    B() {
        super();  // 컴파일러가 자동 삽입
        System.out.println("B 생성자");
    }
}

class C extends B {
    C() {
        super();  // 컴파일러가 자동 삽입
        System.out.println("C 생성자");
    }
}

new C();

// 출력:
// A 생성자   ← 최상위 부모부터
// B 생성자
// C 생성자   ← 자식이 마지막
new C() 호출
  → C() 진입
    → super() → B() 진입
      → super() → A() 진입
        → "A 생성자" 출력
      → A() 완료
      → "B 생성자" 출력
    → B() 완료
    → "C 생성자" 출력
  → C() 완료

오버라이딩과 실행 순서 — 주의할 함정

부모 생성자에서 오버라이딩된 메서드를 호출하면 예상과 다른 결과가 나올 수 있다.

class Parent {
    int value = 10;

    Parent() {
        print();  // 이 시점에 자식 오버라이딩 메서드가 호출됨!
    }

    void print() {
        System.out.println("Parent.value = " + value);
    }
}

class Child extends Parent {
    int value = 99;  // 자식의 value

    Child() {
        super();  // 부모 생성자 호출
    }

    @Override
    void print() {
        // 부모 생성자 실행 시점에 자식 value는 아직 초기화 안 됨!
        System.out.println("Child.value = " + value);
    }
}

new Child();
// 출력: Child.value = 0   ← 99가 아님!
// 이유: 부모 생성자 실행 시점엔 자식 필드가 아직 기본값(0)

생성자에서 오버라이딩 가능한 메서드를 호출하지 않는 것이 안전하다.


전체 흐름 요약 다이어그램

new Dog("바둑이", "진돗개") 호출
│
├── [클래스 첫 로딩 시]
│   ├── Animal static 변수/블록
│   └── Dog static 변수/블록
│
└── [매번 new 할 때]
    ├── Dog() 생성자 진입
    │   └── super("바둑이") 호출
    │       ├── Animal 인스턴스 변수 초기화
    │       ├── Animal 인스턴스 블록
    │       └── Animal("바둑이") 생성자 실행 ✓
    ├── Dog 인스턴스 변수 초기화
    ├── Dog 인스턴스 블록
    └── Dog("바둑이", "진돗개") 나머지 실행 ✓

정리

개념 핵심 한 줄
생성자 new 시 자동 호출, 객체 초기 상태 설정
기본 생성자 직접 생성자 없으면 자동 추가. 하나라도 만들면 자동 추가 안 됨
this() 같은 클래스의 다른 생성자 호출. 반드시 첫 줄
super() 부모 생성자 호출. 반드시 첫 줄. 없으면 컴파일러가 자동 삽입
오버라이딩 부모 메서드를 자식이 재정의. 런타임에 실제 타입으로 결정
인스턴스 블록 모든 생성자 실행 전에 먼저 실행
static 블록 클래스 로딩 시 딱 1회 실행
실행 순서 static → 부모 인스턴스 블록 → 부모 생성자 → 자식 인스턴스 블록 → 자식 생성자
주의 생성자에서 오버라이딩 가능한 메서드 호출 금지 — 자식 필드 미초기화 상태