Java 클래스 완전 정복 — 캡슐화·상속·다형성부터 생성자·메모리까지

LG Display에서 일할 때 Java를 처음 제대로 파고든 기억이 납니다. 그때 C 언어를 어느 정도 다뤘지만, 클래스가 등장하는 순간 “왜 이걸 굳이 묶어야 하나”라는 의문이 먼저 들었습니다. 변수도 있고, 함수도 있는데 클래스라는 덩어리가 왜 필요한가.

그 답을 찾는 데는 꽤 시간이 걸렸습니다. 지금 생각해보면 Java 클래스는 코드를 조직화하는 단위이자, 대규모 팀 프로젝트에서 협업이 가능하게 해주는 설계 도구였습니다. 이 글은 2020년 12월 직접 정리한 학습 노트를 기반으로, 클래스의 핵심 개념을 실무 관점에서 다시 풀어쓴 것입니다.


OOP와 클래스 — 캡슐화·상속·다형성이 핵심이다

객체 지향의 3대 특징

Java는 객체 지향 언어(Object Oriented Programming, OOP)입니다. OOP의 핵심을 세 가지로 요약하면 이렇습니다.

특징영문설명
캡슐화Encapsulation관련 있는 속성과 메소드를 클래스로 묶는 것
상속성Inheritance상위 클래스의 모든 것을 하위 클래스가 물려받는 것
다형성Polymorphism동일한 메시지에 대해 호출하는 객체에 따라 다른 메소드가 실행되는 것

OOP의 장점은 코드 재사용성입니다. 한 번 잘 만든 클래스는 여러 프로젝트에서 가져다 쓸 수 있습니다. 단점은 함수형 프로그래밍에 비해 무겁다는 것입니다.

클래스 vs 인스턴스 — 이 차이가 핵심

클래스와 인스턴스는 붕어빵 틀과 붕어빵으로 비유할 수 있습니다.

  • 클래스: 인스턴스들의 공통된 특징을 소유한 설계도. 정적(한 번 만들면 수정 안 됨)
  • 인스턴스(객체): 클래스를 기반으로 생성된 실제 객체. 동적(만들고 수정하고 삭제 가능)

중요한 것은 메모리 생명주기입니다. 클래스는 처음 사용할 때 메모리에 로드되고 프로그램이 종료될 때까지 소멸되지 않습니다. 인스턴스는 필요할 때 만들고 필요 없으면 제거할 수 있습니다.

인스턴스를 만드는 기본 방법은 new 생성자이름(매개변수) 입니다. new는 메모리 할당(allocation)과 초기화(initialize)를 한 후 참조를 리턴합니다.

두 클래스를 나눈 이유는 역할 분리 때문입니다. SampleClass는 데이터와 동작을 담당하는 보조 클래스이고, MainClass는 프로그램 진입점(main)을 가진 실행 클래스입니다. 이 구조가 이후 MVC 패턴이나 Spring의 레이어 분리로 이어집니다.

// main 메소드가 없는 보조 클래스
public class SampleClass {
}

// 실행 클래스에서 SampleClass 인스턴스 만들기
public class MainClass {
    public static void main(String[] args) {
        // new SampleClass()가 인스턴스를 생성하고 참조를 리턴
        // 재사용하려면 동일한 자료형의 변수에 참조를 대입
        SampleClass obj = new SampleClass();
    }
}

변수 3종류 — 지역·멤버·static의 메모리 생명주기

Java 클래스 안에서 쓰는 변수는 위치와 키워드에 따라 세 가지로 나뉩니다. 이 차이를 모르면 런타임 에러의 원인을 찾기가 힘들어집니다.

변수 종류 한눈에 비교

구분선언 위치접근 지정자초기화접근 방법
지역 변수메소드 안없음초기화 필수 (안 하면 컴파일 에러)메소드 내에서만
멤버 변수클래스 안, 메소드 밖 (static 없음)있음기본값으로 자동 초기화인스턴스만
static 변수클래스 안, 메소드 밖 (static 있음)있음기본값으로 자동 초기화클래스와 인스턴스 모두

멤버 변수와 static 변수의 기본값: boolean → false, 정수 → 0, 실수 → 0.0, 나머지 → null

static vs 멤버 — 오버로딩 코드로 비교

public class SampleClass {
    // static 변수 — 모든 인스턴스가 공유, 클래스와 인스턴스 모두 접근 가능
    static int share = 1;

    // 멤버 변수(인스턴스 변수) — 각 인스턴스가 별도로 소유, 인스턴스만 접근 가능
    String name;
}

// Main에서 차이 확인
SampleClass ob1 = new SampleClass();
SampleClass ob2 = new SampleClass();

ob1.name = "첫번째 인스턴스";
ob2.name = "두번째 인스턴스";
System.out.println(ob1.name); // 첫번째 인스턴스
System.out.println(ob2.name); // 두번째 인스턴스

// static 변수는 하나만 만들어서 공유
ob1.share = 100;
SampleClass.share = 200; // 클래스 이름으로 접근 권장
System.out.println(ob1.share); // 200 — ob1이 바꿔도 반영됨
System.out.println(SampleClass.share); // 200

shareob1.share = 100으로 바꿔도 SampleClass.share = 200으로 다시 쓰면 200이 됩니다. 모든 인스턴스가 같은 값을 공유하기 때문입니다. static 멤버는 인스턴스보다 클래스 이름으로 접근하는 것을 권장합니다.

static initializer — 클래스 로드 시 1회 실행

변수뿐 아니라 메소드에도 접근 지정자가 적용되므로 먼저 정리해둡니다. static 변수에는 선언과 동시에 초기화할 수도 있지만, 복잡한 초기화 로직이 필요할 때는 static initializer를 사용합니다.

클래스 안에 아래 형태로 작성하면 클래스가 메모리에 로드될 때 단 1번만 실행됩니다.

static {
    내용
}
  • static 변수는 사용할 수 있지만 member(instance) 변수는 사용할 수 없음
  • 주로 static 변수의 초기화에 활용
  • 여러 번 작성하면 작성한 순서대로 실행
// SampleClass에 static 초기화 블럭 예시
static {
    System.out.println("클래스가 처음 호출될 때 1번만 수행");
}

MainClass를 실행하면 이 블럭의 내용이 가장 먼저 출력됩니다.

접근 지정자 정리

멤버 변수와 메소드에 붙이는 접근 지정자는 4가지입니다.

지정자설명
private클래스 내부에서만 사용
default(생략)동일 패키지에서는 public, 다른 패키지에서는 private
protecteddefault + 상속받은 클래스에서도 사용 가능
public클래스 외부에서 클래스나 인스턴스를 통해 접근 가능

메소드 — 오버로딩·varargs·call by value/reference

메소드 오버로딩(Method Overloading)

같은 이름의 메소드를 매개변수 개수나 자료형을 달리해서 여러 개 만드는 것을 오버로딩이라고 합니다. 객체 지향 언어 면접에서 반드시 나오는 개념입니다. C언어였다면 dispWithoutParam(), dispWithInt() 같은 식으로 이름을 다르게 지어야 했을 것입니다. Java에서는 오버로딩 덕분에 add(int, int), add(double, double) 처럼 같은 이름으로 자료형만 바꿔 쓸 수 있습니다.

public class MethodClass {
    // 매개변수 없음 — 3번 출력
    public void disp() {
        for (int i = 0; i < 3; i = i + 1) {
            System.out.println("Hello Java");
        }
    }

    // 매개변수 int 1개 — n번 출력 (오버로딩)
    public void disp(int n) {
        for (int i = 0; i < n; i = i + 1) {
            System.out.println("Hello Java");
        }
    }
}

disp()disp(int n)은 이름이 같지만 매개변수가 다릅니다. 호출할 때 매개변수 유무에 따라 자바가 알아서 맞는 메소드를 선택합니다.

varargs — 가변 길이 인자

Java 1.5부터 지원하는 기능으로, 매개변수의 개수를 미리 정하지 않고 유연하게 받을 수 있습니다. 매개변수를 (자료형 ... 매개변수이름) 형태로 선언하면, 메소드 내부에서는 배열로 취급됩니다. Python의 *args와 동일한 개념입니다. 대표적인 사용 예가 printf 메소드입니다.

// 정수 데이터가 몇 개가 오던지 합계를 구해서 출력해주는 메소드
public void sum(int ... ar) {
    // ... 이름을 이용하면 내부에서는 배열로 취급
    int sum = 0;
    // 배열의 모든 데이터를 순서대로 접근해서 sum에 추가
    for (int data : ar) {
        sum = sum + data;
    }
    System.out.println("합계:" + sum);
}
// 호출 — 인자 개수에 제한 없음
obj.sum(10, 30);
obj.sum(20, 30, 10, 50);

call by value vs call by reference

메소드에 매개변수를 넘기는 방식 두 가지입니다.

  • call by value: 값 자체를 복사해서 전달. 메소드 안에서 변경해도 원본에 영향 없음 (기본형: int, double 등)
  • call by reference: 참조(주소)를 전달. 메소드 안에서 변경하면 원본도 바뀔 수 있음 (참조형: 배열, 객체 등)
// call by value — 정수를 받아서 1 증가
public void inc(int n) {
    n = n + 1;
    System.out.println("n:" + n);
}

// call by reference — 배열의 첫 번째 값을 1 감소
public void dec(int[] ar) {
    ar[0] = ar[0] - 1;
    System.out.println("ar[0]:" + ar[0]);
}

// 호출 결과
int x = 100;
int[] br = {100, 200, 300};
obj.inc(x);
System.out.println("x:" + x); // x는 100 그대로 — value 복사였으니까

obj.dec(br);
System.out.println("br[0]:" + br[0]); // br[0]은 99로 바뀜 — 참조를 전달했으니까

참조형 데이터를 받아서 return을 하지 않는 메소드는 원본 데이터를 변경할 가능성이 높다는 것, 실무에서 꼭 기억해두세요. 예외는 화면에 출력만 하는 메소드(print, 그래프 등)입니다.


생성자와 객체 복제 — 인스턴스 생명주기 완전 이해

getter / setter와 DTO 패턴

생성자를 배우기 전에 getter/setter 패턴을 먼저 짚겠습니다. 객체 지향에서는 인스턴스 변수에 외부에서 직접 접근하는 것을 권장하지 않습니다. 동기화(Synchronized) 문제 때문입니다. 대신 getter와 setter 메소드를 통해 간접 접근합니다.

public class Student {
    private int num;
    private String name;
    private String major;

    public int getNum() { return num; }
    public void setNum(int num) { this.num = num; }

    public String getName() { return name; }
    public void setName(String name) { this.name = name; }

    public String getMajor() { return major; }
    public void setMajor(String major) { this.major = major; }
}

// 사용 예시
Student student = new Student();
student.setNum(1);
student.setName("park");
student.setMajor("CS");

System.out.println("번호:" + student.getNum());
System.out.println("이름:" + student.getName());
System.out.println("전공:" + student.getMajor());

이처럼 여러 정보를 묶어 하나로 만드는 클래스를 DTO(Data Transfer Object)라고 합니다. 실무에서는 Spring 프레임워크와 함께 거의 모든 데이터 전달에 DTO 패턴을 씁니다.

getter/setter로 필드를 노출시켰다면, 이제 생성자로 인스턴스 초기화 시점을 제어할 수 있습니다.

생성자 — 인스턴스가 태어나는 순간

생성자는 new 키워드와 함께 인스턴스를 만들 때 호출되는 메소드입니다. 생성자의 이름은 반드시 클래스 이름과 같아야 하고, return 타입이 없습니다. 매개변수가 없는 생성자를 Default Constructor라고 합니다.

public class Member {
    private String id;
    private String pw;

    // Default Constructor — 매개변수 없는 기본 생성자
    // id를 "root", pw를 "1234"로 초기화
    public Member() {
        id = "root";
        pw = "1234";
    }

    public String getId() { return id; }
    public void setId(String id) { this.id = id; }
    public String getPw() { return pw; }
    public void setPw(String pw) { this.pw = pw; }
}

// setter 없이도 생성자가 초기화해줌
Member mem1 = new Member();
System.out.println("id:" + mem1.getId() + " pw:" + mem1.getPw());
// 출력: id:root pw:1234

생성자를 별도로 만들지 않으면 인스턴스 변수는 기본값(boolean → false, 정수 → 0, null)으로 채워집니다. 위의 경우 생성자 없이 만들면 id:null pw:null이 출력됩니다.

인스턴스 배열에서의 생성자

인스턴스 배열을 만들 때 크기만 설정한 경우, 각각의 인덱스에 생성자를 호출해서 직접 대입해줘야 합니다.

클래스이름[] 배열이름 = new 생성자이름[개수];

이 구문은 인스턴스를 저장할 공간만 만든 것입니다. 인스턴스 자체는 아직 생성되지 않은 상태입니다. 각 인덱스에 배열이름[인덱스] = new 생성자(); 로 개별 생성해야 합니다.

// Member 클래스의 인스턴스 배열을 생성
// Member 인스턴스의 참조를 저장할 수 있는 공간 2개를 생성
Member[] ar = new Member[2];

// 공간에 인스턴스를 생성해서 대입
// 이 구문을 생략하면 모두 null
ar[0] = new Member();
ar[1] = new Member();

for (Member imsi : ar) {
    System.out.println(imsi);
}

배열 선언만 하고 인스턴스를 생성하지 않으면 NullPointerException이 발생합니다. 인스턴스 배열을 쓸 때 가장 흔한 실수입니다.

인스턴스 필드 이니셜라이저

클래스 안에 { }를 만들고 괄호 안에 내용을 작성하면, 생성자를 호출할 때마다 { } 안의 내용이 생성자보다 먼저 자동으로 수행됩니다. static initializer가 클래스 로드 시 1회만 실행되는 것과 달리, 인스턴스 필드 이니셜라이저는 인스턴스 생성마다 매번 실행됩니다.

// 클래스 안에 { }를 만들고 작성하면 생성자를 호출할 때
// 생성자보다 먼저 호출되서 내용을 수행
{
    System.out.println("객체를 생성합니다.");
}

new Member()를 여러 번 호출하면 그만큼 이 블럭이 반복 실행되는 것을 확인할 수 있습니다.

Deep Copy — 참조형 변수 대입의 함정

참조형 변수끼리 단순 대입을 하면 같은 객체를 가리키게 됩니다. 한쪽을 수정하면 다른 쪽도 바뀌는 위험한 상황입니다.

Member origin = new Member();
origin.setId("ggangpae1");
origin.setPw("100100100");

// 참조형 사이의 대입 — 같은 인스턴스를 가리킴
Member weak = origin;
weak.setId("itggangpae"); // weak를 바꿨는데 origin도 바뀜
System.out.println(origin.getId()); // itggangpae — 의도치 않은 변경!

이 문제를 해결하려면 Deep Copy(복제)를 써야 합니다. clone() 메소드를 만들어서 새로운 인스턴스에 데이터를 복사하는 방식입니다.

// Member 클래스에 clone 메소드 추가
public Member clone() {
    Member other = new Member();
    other.id = this.id;
    other.pw = this.pw;
    return other;
}

// 사용
Member deep = origin.clone(); // 복제 — 별개의 인스턴스
deep.setId("choongang");
System.out.println(deep.getId());   // choongang
System.out.println(origin.getId()); // ggangpae1 — 영향 없음

메모리 관리 — Garbage Collection과 null 대입

Java는 heap에 만들어진 객체 중 가리키는 변수가 없으면 GC(Garbage Collection) 정리 대상이 됩니다. 강제로 정리 대상이 되게 하려면 참조형 변수에 null을 대입합니다.

Member origin = new Member();
Member weak = origin;
origin = null; // null을 대입해도 weak가 가리키고 있어서 정리 안 됨
weak = null;   // 이것까지 해야 GC 정리 대상이 됨

참조형 변수 사이의 대입을 할 때, 한쪽에만 null을 대입해도 다른 변수가 여전히 같은 인스턴스를 가리키면 메모리가 정리되지 않습니다. Deep Copy와 함께 기억해두면 메모리 누수 방지에 도움이 됩니다. 실무에서는 명시적으로 null을 대입해 GC를 유도하는 것보다, 변수의 스코프(scope)를 좁게 유지하는 쪽이 더 효과적입니다.


핵심 정리

개념요점
OOP 3대 특징캡슐화(묶기), 상속성(물려받기), 다형성(다르게 반응)
클래스 vs 인스턴스클래스는 설계도(정적), 인스턴스는 실체(동적)
지역 변수메소드 안에서만. 초기화 안 하면 컴파일 에러
멤버 변수인스턴스별 소유. 기본값 자동 초기화
static 변수모든 인스턴스 공유. 클래스 이름으로 접근 권장
static initializer클래스 로드 시 1회 실행. static 변수 초기화에 사용
메소드 오버로딩이름 같고 매개변수 개수/자료형 다르게 여러 개 선언
varargs(자료형 ... 이름) — 가변 길이 인자, 내부에서 배열로 처리
call by value기본형 전달 — 메소드 안 변경이 원본에 영향 없음
call by reference참조형 전달 — 메소드 안 변경이 원본에 영향 있을 수 있음
생성자인스턴스 생성 시 호출. 이름 = 클래스이름. return 타입 없음
인스턴스 배열배열 선언만으로 인스턴스가 생성되지 않음. 각 인덱스에 new 필요
인스턴스 필드 이니셜라이저클래스 안 { } — 생성자마다 먼저 실행
Deep Copyclone()으로 새 인스턴스 생성. 참조형 대입의 사이드이펙트 방지
GC가리키는 변수 없으면 정리 대상. null 대입으로 정리 유도 가능

이 글은 2020년 12월 직접 작성한 Java 학습 노트를 기반으로 재구성했습니다.

Similar Posts