[Java] Java 04 Starter to Object-Oriented Programming


Java

Period 01 — 이름과 점수를 따로따로 들고 다니지 마세요

  1. String[] namesint[] scores를 따로 두면 어떤 위험이 있는지 설명할 수 있다.
  2. 평행 배열에서 한쪽만 정렬했을 때 데이터가 깨지는 현상을 직접 코드로 재현할 수 있다.
  3. “이름+점수는 한 사람의 정보다”라는 사실을 자바가 표현하지 못한다는 점을 알아챈다.
  4. “객체(object)”라는 개념이 왜 등장하는지, 다음 교시 class가 어떤 답이 될지 예측할 수 있다.

붕어빵틀과 붕어빵 — “묶음”을 자바가 부르는 이름

  • 🏗️ 클래스(Class)의 정의
    • 객체를 생성하기 위한 설계도(Blueprint) 또는 틀이다.
    • 클래스 자체는 개념이자 규격일 뿐이며, 메모리에 물리적인 실체로 존재하며 작동하는 데이터가 아니다.
  • 🧱 객체(Object)의 정의
    • 클래스라는 설계도를 바탕으로 메모리에 실제로 구현된 실체이다.
    • 자바 프로그램이 실행되는 동안 메모리 위에서 살아서 움직이는 모든 데이터 단위를 의미한다.
  • ⚡ 붕어빵틀과 붕어빵의 비유
    • 붕어빵틀(클래스)은 하나만 있으면 되지만, 이를 통해 찍어내는 붕어빵(객체)은 메모리 허용량이 허락하는 한 무한히 만들어낼 수 있다.
    • 각각의 붕어빵은 메모리상에서 서로 독립적인 상태를 유지한다.

실습 1 — 평행 배열, 한 번 직접 써본다

public class StudentList {
  public static void main(String[] args) {
    String[] names  = {"홍길동", "김철수", "이민호", "박영희"};
    int[]    scores = {92,        45,        88,        71      };

    System.out.println("=== 학생 목록 ===");
    for (int i=0; i<names.length; i++) {
      System.out.println(
        (i+1) + ". " + names[i] + " — " + scores[i] + "점");
    }
  }
}

실습 2 - 이름을 가나다순으로 정렬하면, 어떻게 되나?

import java.util.Arrays;

public class StudentSort {
  public static void main(String[] args) {
    String[] names  = {"홍길동", "김철수", "이민호", "박영희"};
    int[]    scores = {92,        45,        88,        71      };

    // 이름만 가나다순으로 정렬해보자
    Arrays.sort(names);   // ← 이 한 줄만!

    for (int i=0; i<names.length; i++) {
      System.out.println(
        names[i] + " — " + scores[i] + "점");
    }
  }
}

데이터 무결성 붕괴 현장 — 조용히 주인이 바뀌다

  • 🏗️ 평행 배열 정렬의 치명적인 함정
    • String[] namesint[] scores를 따로 운영하는 상황이 존재한다.
    • 이때 이름 배열만 가나다순으로 정렬하는 Arrays.sort(names)를 실행하면 데이터 간의 동기화가 완전히 깨진다.
  • 🧱 데이터와 데이터의 연결고리 상실
    • 이름 배열의 인덱스는 마구 뒤섞이지만 점수 배열은 정렬되지 않고 기존 위치에 그대로 머물러 있게 된다.
    • 이로 인해 원래 데이터의 쌍이 완전히 찢어지며, 특정 사람의 점수가 다른 사람의 점수로 둔갑하는 치명적인 왜곡이 발생한다.
  • ⚡ 무결성(Integrity) 파괴의 정의
    • 데이터가 정확성과 일관성의 규칙을 잃고 현실의 실체와 일치하지 않게 된 상태를 의미한다.
    • 자바 컴파일러는 이 현상을 에러로 잡아내지 못하므로 시스템의 신뢰도를 떨어뜨리는 가장 무서운 버그가 된다.

실습 3 — “한 묶음”을 만들어볼 수 없을까? (String[][] 시도)

String[][] students = {
  {"홍길동", "92"},   // ← 점수가 문자열이 됨!
  {"김철수", "45"},
  {"이민호", "88"},
  {"박영희", "71"}
};

int sum = 0;
for (String[] s : students) {
  // "92"는 String — 더하려면 변환 필요
  sum += Integer.parseInt(s[1]);
}
System.out.println("합계: " + sum);

Period 02 — 붕어빵틀과 붕어빵

  1. class Student { ... }로 클래스를 정의하고 필드를 선언할 수 있다.
  2. new Student()로 객체를 만드로 .연산자로 필드에 접근할 수 있다.
  3. Stack의 참조 변수와 Heap의 객체를 메모리 그림으로 구분할 수 있다.
  4. NullPointerException이 왜 나는지, new없이는 무엇이 비어 있는지 설명할 수 있다.

한 줄 코드, 메모리 두 곳에 흔적

  • 🏗️ 객체 생성 시 발생하는 물리적 분리 구조
    • Student student = new Student();와 같은 한 줄의 선언 코드가 실행되면, 자바 가상 머신(JVM)은 메모리를 단일 공간으로 처리하지 않고 Stack(스택) 영역과 Heap(힙) 영역 두 곳에 물리적인 흔적을 동시에 남긴다.
  • 🧱 Stack 영역의 흔적: 참조 변수(손)
    • 선언부인 Student student에 의해 Stack 영역에 실제 객체의 주소값(예: 0x2C7B)을 가리킬 수 있는 변수 공간이 생성된다.
    • 이는 실제 데이터 본체가 아니라, 데이터가 위치한 메모리 주소를 붙잡고 있는 ‘손’의 역할을 수행한다.
  • ⚡ Heap 영역의 흔적: 인스턴스 본체(제품)
    • 연산자 구문인 new Student()에 의해 Heap 영역의 빈 공간에 객체의 필드(상태)와 실제 데이터가 살아 움직일 수 있는 물리적인 인스턴스 본체가 독립적으로 할당된다.
    • 최종적으로 Stack에 생성된 참조 변수방에 Heap 영역 인스턴스의 메모리 주소값이 대입되면서 두 곳의 메모리 흔적이 하나로 연결된다.

실습 1 — 첫 클래스 (Student 정의 + 객체 1개 만들기)

// Student.java
public class Student {
  String name;
  int    score;
}
// StudentApp.java
public class StudentApp {
  public static void main(String[] args) {
    Student s = new Student();
    s.name  = "홍길동";
    s.score = 92;

    System.out.println(s.name + " " + s.score + "점");
  }
}

실습 2 — 붕어빵 3마리 (한 틀로 객체 여러 개)

public class StudentApp {
  public static void main(String[] args) {
    Student s1 = new Student();
    s1.name="홍길동";  s1.score=92;

    Student s2 = new Student();
    s2.name="김철수";  s2.score=45;

    Student s3 = new Student();
    s3.name="이민호";  s3.score=88;

    System.out.println(s1.name+" "+s1.score);
    System.out.println(s2.name+" "+s2.score);
    System.out.println(s3.name+" "+s3.score);
  }
}

new없는 참조의 운명 — NullPointerException

public class NpeDemo {
  public static void main(String[] args) {
    Student s;          // 선언만, new는 없음

    System.out.println(s.name);  // ??
  }
}
  • 🏗️ 참조 변수 선언과 null 초기화
    • Student student;와 같이 참조 변수만 선언하고 new 연산자를 이용해 객체를 생성하지 않으면 Stack 영역의 변수 공간에는 아무런 메모리 주소도 할당되지 않는다.
    • 이 상태의 변수에는 가리키는 대상이 없음을 명시하는 null 값이 들어간다. 즉, 주소록에 이름만 적어두고 주소 칸은 비워둔 상태와 같다.
  • 🧱 가리킬 곳이 없는 점(.) 연산자의 실행
    • 자바에서 점(.) 연산자는 참조 변수에 저장된 메모리 주소(위치)로 찾아가서 필드나 메서드에 접근하라는 명령이다.
    • 변수에 null이 들어있는 상태에서 student.name = "홍길동";과 같이 주소로 찾아가라는 명령을 내리면, 참조할 물리적 공간 자체가 존재하지 않기 때문에 컴퓨터는 길을 잃게 된다.
  • ⚡ NullPointerException (NPE) 발생의 결과
    • 가리킬 주소가 없는 상태에서 멤버에 접근을 시도하면 자바 가상 머신(JVM)은 즉시 NullPointerException이라는 치명적인 런타임 에러를 발생시킨다.
    • 자바 컴파일러는 코드 문법 자체에 문제가 없으므로 컴파일 시점에 이를 잡아내지 못하며, 프로그램 실행 중에 시스템이 강제 종료되는 결과를 초래한다.
class Box { Student s; }   // 필드는 자동 null

Box b = new Box();
System.out.println(b.s.name);
// Exception in thread "main"
// java.lang.NullPointerException: Cannot read field
//   "name" because "this.s" is null

실습 3 — 객체 배열의 함정(만들었는데 또 NullPointerException?)

Student[] arr = new Student[3];

// 배열은 만들어졌다 — 그런데 안의 Student는?
System.out.println(arr[0]);          // → null
System.out.println(arr[0].name);     // 💥
$ java StudentArr
null
Exception in thread "main"
java.lang.NullPointerException:
Cannot read field "name"
because "arr[0]" is null
// 올바른 사용 — 각 칸마다 new
for (int i=0; i<arr.length; i++) {
  arr[i] = new Student();
  arr[i].name  = "학생" + i;
  arr[i].score = 80 + i;
}

도전 문제 — BankAccount 클래스

// 문제

BankAccount 클래스를 정의하고, main에서 2개의 계좌 객체를 만들어 잔액 출력

// 클래스 요구사항

  • 필드: String owner, int balance
  • a1: 홍길동·10000, a2: 김철수·50000

// 추가 미션

  1. 두 계좌 출력 (예: ”홍길동: 10000원”)
  2. 일부러 BankAccount a3; 선언만 하고 a3.owner 출력 시 메시지 적어 오기
public class BankApp {
    public static void main(String[] args) {
        BankAccount a1 = new BankAccount();
        a1.owner = "홍길동";
        a1.balance = 10000;

        BankAccount a2 = new BankAccount();
        a2.owner = "김철수";
        a2.balance = 50000;

        System.out.println("=== 계좌 목록 ===");
        System.out.println(a1.owner+": "+a1.balance+"원");
        System.out.println(a2.owner+": "+a2.balance+"원");

        BankAccount a3 = new BankAccount();
        System.out.println(a3.owner);
    }
}

Period 03 — 객체에게 일을 시키자

  1. 클래스에 메서드를 정의하고 객체.메서드()로 호출할 수 있다
  2. 메서드 안에서 자기 필드를 읽고 쓰는 동작을 작성할 수 있다
  3. == 는 참조(주소)를 비교하고 equals는 값을 비교한다는 차이를 설명할 수 있다
  4. 두 객체의 “값 동일성”을 판별하는 sameAs()메서드를 직접 작성할 수 있다

객체 안에는 두 칸이 있다 — 상태와 동작

  • 🏗️ 상태(Field)와 동작(Method)의 물리적 분리
    • 클래스를 통해 Heap 영역에 생성된 인스턴스(객체) 내부 구조를 들여다보면, 메모리상에서 크게 고유 데이터 상태를 담는 칸과 실행 코드를 가리키는 칸 두 가지 영역으로 정밀하게 쪼개져 존재한다.
  • 🧱 첫 번째 칸: 인스턴스 멤버 변수 (상태)
    • 객체가 가져야 하는 고유한 속성값들이 저장되는 공간이다. 예를 들어 Student 객체라면 name, score 같은 필드 데이터가 각각 변수 타입에 맞는 크기만큼 할당되어 실제 데이터 값을 보관한다.
    • 이 영역은 Heap 메모리에 생성되는 객체마다 완전히 독립된 물리 공간으로 매번 새로 할당되므로, 객체 고유의 상태를 유지할 수 있다.
  • ⚡ 두 번째 칸: 메서드 영역 참조 주소 (동작)
    • 객체의 동작을 정의하는 메서드 코드가 저장되는 공간이다. 다만, 수천 개의 객체가 생성될 때마다 동일한 실행 코드(메서드 바디)를 Heap 영역에 중복해서 복사하면 메모리가 극심하게 낭비된다.
    • 이를 방지하기 위해 객체 내부의 메서드 칸에는 실제 동작 코드가 아닌, JVM의 Method(Static) 영역에 단 하나만 로드되어 있는 해당 메서드 소스코드의 메모리 주소값(참조값)만을 보관한다. 객체의 메서드를 호출하면 이 칸에 적힌 주소를 타고 공유 영역으로 이동하여 코드를 실행한다.
class Student {
  String name;
  int    score;

  void introduce() {     // ← 메서드 정의
    System.out.println("안녕하세요 " + name);
  }
}

// main
Student s = new Student();
s.name = "홍길동";
s.introduce();          // ← 호출

실습 1 — 첫 메서드 introduce()

// Student.java
public class Student {
  String name;
  int    score;

  void introduce() {
    System.out.println(
      "안녕하세요! 저는 " + name +
      "이고 " + score + "점입니다.");
  }
}
// IntroApp.java
public class IntroApp {
  public static void main(String[] args) {
    Student s1 = new Student();
    s1.name="홍길동"; s1.score=92;
    Student s2 = new Student();
    s2.name="김철수"; s2.score=45;

    s1.introduce();
    s2.introduce();
  }
}

실습 2 — 변환값 있는 메서드 bonus()

public class Student {
  String name;
  int    score;

  int bonus() {
    if (score >= 90) {
      return 10;       // 10점 보너스
    } else {
      return 0;        // 없음
    }
  }
}
// BonusApp.java
Student s1 = new Student();
s1.name="홍길동"; s1.score=92;
Student s2 = new Student();
s2.name="김철수"; s2.score=45;

int total1 = s1.score + s1.bonus();
int total2 = s2.score + s2.bonus();
System.out.println(s1.name+" 최종:"+total1);
System.out.println(s2.name+" 최종:"+total2);

bonus()의 반환 타입을 void로 바꾸면 어떻게 될까요? → 직접 IDE에서 시도해보고 어떤 빨간 줄이 뜨는지 적어 오세요.

Cannot return a value from a method with void result type 라는 에러가 뜬다. 이는 반환 타입과 함수 반환 타입 정의가 다르기 때문이다.


“보기엔 같은데” 실험 — 참조 비교의 본질

Student s1 = new Student();
s1.name="홍길동"; s1.score=92;

Student s2 = new Student();
s2.name="홍길동"; s2.score=92;

System.out.println(s1 == s2);       // ?
System.out.println(
  s1.name.equals(s2.name));         // ?
$ java EqDemo
false # s1 == s2
true # name.equals(name)
  • 🏗️ 실험의 조건과 코드 설계
    • Student s1 = new Student("홍길동", 90);Student s2 = new Student("홍길동", 90);를 각각 독립적으로 생성한다.
    • 두 객체 내부의 이름과 점수 데이터는 인간의 눈으로 보기에 완전히 일치하는 상태이다.
  • 🧱 동등성 비교 연산(==)의 반전 결과
    • 콘솔창에 System.out.println(s1 == s2);를 실행하면 두 객체의 데이터가 같음에도 불구하고 결과는 false가 출력된다.
  • ⚡ 물리적 주소 비교 메커니즘
    • 참조 타입에서 == 연산자는 객체 내부의 실제 필드 값을 비교하지 않는다.
    • new 연산자에 의해 힙(Heap) 영역의 서로 다른 주소(예: 0x1000x200)에 각각 독립적으로 자리 잡은 두 인스턴스의 ‘참조값(메모리 주소)’ 자체를 비교하기 때문이다.

실습 3 — “값 동일성”을 직접 만들기 sameAs()

public class Student {
  String name;
  int    score;

  boolean sameAs(Student other) {
    if (other == null) return false;
    return this.name.equals(other.name)
        && this.score == other.score;
  }
}
Student a = new Student();
a.name="홍길동"; a.score=92;

Student b = new Student();
b.name="홍길동"; b.score=92;

Student c = new Student();
c.name="홍길동"; c.score=88;

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

1) a.sameAs(a) — 자기 자신과 비교하면?

System.out.println(a.sameAs(a));    // true

2) a.sameAs(null) — null 넣으면?

System.out.println(a.sameAs(null)); // false

3) name만 다르고 score는 같으면? 점수만 다르면?

// Student c = new Student();
// c.name="홍길동"; c.score=88;
// Student d = new Student();
// d.name="김철수"; d.score=88;

System.out.println(c.sameAs(d));    // false

도전 문제 — Book의 isExpensive() + sameAs()

// 문제

Book 클래스에 두 메서드를 추가. main에서 책 3권 비교 출력.

// 클래스 요구사항

  • 필드: String title, int price
  • boolean isExpensive() — price≥15000
  • boolean sameAs(Book other) — title·price 모두 일치, null 안전

// 검증 시나리오

  1. b1(자바,30000) · b2(자바,30000) · b3(자바,25000)
  2. 각 책의 isExpensive 결과 출력
  3. b1==b2, b1.sameAs(b2), b1.sameAs(b3) 출력
public class Book {
    String title;
    int price;

    boolean isExpensive() {
        return price >= 15000;
    }

    boolean sameAs(Book other) {
        if (other == null) return false;
        return title.equals(other.title) && price == other.price;
    }
}
public class BookApp {
    public static void main(String[] args) {
        Book b1 = new Book();
        b1.title = "자바";
        b1.price = 30000;

        Book b2 = new Book();
        b2.title = "자바";
        b2.price = 30000;

        Book b3 = new Book();
        b3.title = "자바";
        b3.price = 25000;

        System.out.printf("%s (%d) 비쌈? %b%n", b1.title, b1.price, b1.isExpensive());
        System.out.printf("%s (%d) 비쌈? %b%n", b2.title, b2.price, b2.isExpensive());
        System.out.printf("%s (%d) 비쌈? %b%n", b3.title, b3.price, b3.isExpensive());

        System.out.println("b1==b2 ? "+(b1==b2));
        System.out.println("b1 sameAs b2 ? "+b1.sameAs(b2));
        System.out.println("b1 sameAs b3 ? "+b1.sameAs(b3));
    }
}
자바 (30000) 비쌈? true
자바 (30000) 비쌈? true
자바 (25000) 비쌈? true
b1==b2 ? false
b1 sameAs b2 ? true
b1 sameAs b3 ? false

완성 후 추가 도전. Book 객체 4권을 Book[4]에 담아 for로 돌면서 비싼 책만 골라 제목을 출력하세요.

Book b4 = new Book();
b4.title = "자바";
b4.price = 35000;
        // . . .
Book[] books = {b1, b2, b3, b4};
int maxIdx = 0;
for(int i=1; i < books.length; i++) {
    if (books[i].price > books[maxIdx].price) maxIdx = i;
}

System.out.printf("가장 비싼 책은 %s (%d원)입니다.%n", books[maxIdx].title, books[maxIdx].price);
가장 비싼 책은 자바 (35000원)이다.

Period 04 — 객체는 어떻게 태어나는가

  1. 매개변수 있는 생성자를 작성하고 new Student("홍길동", 92) 로 객체를 한 줄에 만들 수 있다
  2. this.name = name; 에서 두 name의 의미를 구분해 설명할 수 있다
  3. 매개변수 생성자를 추가하면 기본 생성자가 사라진다는 사실과 이유를 설명할 수 있다.
  4. 생성자 오버로딩으로 여러 가지 초기화 방식을 만들 수 있다

생성자 문법 + this의 정체

  • 🏗️ 생성자(Constructor)의 핵심 문법 규격
    • 생성자는 객체가 new 연산자에 의해 Heap 영역에 태어날 때 생애 단 한 번만 실행되는 아주 특별한 메서드입니다.
    • 일반 메서드와 달리 반환 타입(Return Type)을 아예 적지 않으며, 이름은 반드시 클래스명과 대소문자까지 완전히 일치해야 합니다.
  • 🧱 생성자의 매개변수와 필드 충돌
    • 생성자 내부에서 멤버 변수(필드)의 값을 초기화할 때, 가독성을 위해 매개변수 이름을 필드 이름과 똑같이 짓는 경우가 많습니다 (예: this.name = name;).
    • 이때 이름이 완전히 같으면 자바는 ‘가장 가까운 곳에 선언된 변수(매개변수)’를 우선 인식하므로, 필드 앞에 식별자를 붙여주지 않으면 멤버 변수에 접근할 수 없는 이름 가림 현상(Shadowing)이 발생합니다.
  • this 키워드의 실제 정체
    • this는 메모리상에서 ‘지금 이 생성자 코드를 실행하고 있는 객체 바로 자신’의 Heap 영역 메모리 주소를 가리키는 내장 참조 변수입니다.
    • 즉, this.name이라고 명시하는 것은 “매개변수로 넘어온 지역변수 name 말고, 이 객체 힙 공간에 방금 만들어진 멤버 변수 name에 값을 채워 넣어라”고 컴퓨터에게 명확한 주소를 짚어주는 역할을 합니다.

실습 1 — 매개변수 있는 생성자 (한 줄로 객체 만들기)

public class Student {
  String name;
  int    score;

  public Student(String name, int score) {
    this.name  = name;
    this.score = score;
  }

  void introduce() {
    System.out.println(name+" "+score+"점");
  }
}
// CtorApp.java
Student s1 = new Student("홍길동", 92);
Student s2 = new Student("김철수", 45);

s1.introduce();
s2.introduce();

실습 2 — 생성자 오버로딩 (같은 이름, 다른 시그니처)

public class Student {
  String name;
  int    score;

  // ① 이름만 받기
  public Student(String name) {
    this.name  = name;
    this.score = 0;       // 기본 0점
  }

  // ② 이름 + 점수
  public Student(String name, int score) {
    this.name  = name;
    this.score = score;
  }
}
// 사용
Student a = new Student("홍길동");          // → ①
Student b = new Student("김철수", 45);      // → ②
System.out.println(a.name+" "+a.score);
System.out.println(b.name+" "+b.score);

// 규칙

  • 이름은 같아도 됨 (당연 — 클래스명과 동일)
  • 매개변수 개수/타입/순서가 달라야 함
  • 반환 타입만 다른 건 오버로딩 아님 (생성자엔 반환 타입이 없음)

사라진 기본 생성자 — 컴파일러의 배신

public class Student {
  String name; int score;

  public Student(String name, int score) {
    this.name=name; this.score=score;
  }
}

// main
Student s = new Student();    // 어쩌면 잘 될 것 같지만…
$ javac Demo.java
Demo.java:3: error: constructor Student in class Student
cannot be applied to given types;
required: String,int
found: no arguments
reason: actual and formal argument lists differ in length
  • 🏗️ 기본 생성자(Default Constructor) 자동 제공 조건
    • 클래스 내부에 개발자가 정의한 생성자가 단 하나도 존재하지 않을 때만, 자바 컴파일러가 매개변수와 바디가 비어 있는 기본 생성자(public ClassName() {})를 자동으로 삽입해 준다.
  • 🧱 사용자 정의 생성자 추가 시 자동 소멸
    • 개발자가 매개변수가 있는 생성자를 단 하나라도 명시적으로 작성하는 순간, 컴파일러가 무료로 제공하던 기본 생성자는 그 즉시 자동 소멸하여 완전히 사라진다.
  • ⚡ 컴파일 에러 발생 상황과 해결책
    • 기본 생성자가 소멸한 상태에서 외부에서 인자 없이 new Student()를 호출하면, 일치하는 생성자가 물리적으로 존재하지 않으므로 컴파일 에러가 발생한다.
    • 이를 해결하려면 매개변수가 있는 생성자 외에, 클래스 내부에 빈 기본 생성자를 명시적으로 직접 선언해 주어야 한다.
// 처방 1 - 기본 생성자도 직접 추가

public Student() {     // ← 다시 만들어줘야 함
  // 비워둬도 OK, 기본값 채워도 OK
}
public Student(String name, int score) {
  this.name=name; this.score=score;
}
// 처방 2 - 항상 인자 넣어 호출

// 생성자가 매개변수 받는 형태뿐이라면
Student s = new Student("", 0);

실습 3 — 실험 (this를 빼면 어떻게 될까?)

// ❌ this 없이 작성
public Student(String name, int score) {
  name  = name;    // 무엇이 무엇에 대입?
  score = score;
}

// main
Student s = new Student("홍길동", 92);
System.out.println(s.name + " " + s.score);
$ java NoThisDemo
null 0 # 값이  들어갔다!

name = name;은 자바에게 “매개변수 name에 매개변수 name을 대입”으로 읽힙니다. 필드는 손도 대지 않았다는 뜻. 그래서 null/0이 그대로.

// 처방 - this 또는 이름 바꾸기

// 방법 A — this 사용
this.name  = name;

// 방법 B — 매개변수 이름 다르게
public Student(String n, int s) {
  name  = n;
  score = s;
}

도전 문제 — Car 클래스 (생성자 3종 오버로딩)

// 문제

Car에 3종 생성자를 만들고, main에서 3가지 방식으로 객체 생성·출력

// 필드

String model · int year · int mileage

// 생성자 3종

  1. 없음 → “Unknown”, 0, 0
  2. model만 → 나머지 0
  3. 셋 다 받음

// 마지막 미션

①번 삭제 후 new Car() 호출 시 컴파일 오류 메시지를 그대로 적어 오기

public class Car {
    String model;
    int year;
    int mileage;

    public Car() {
        this("Unknown", 0, 0);
    }
    public Car(String model) {
        this(model, 0, 0);
    }
    public Car(String model, int year, int mileage) {
        this.model = model;
        this.year = year;
        this.mileage = mileage;
    }

    void show() {
        System.out.println("Car{model="+model+"', year="+year+", mileage="+mileage+"}");
    }
}
public class CarApp {
    public static void main(String[] args) {
        Car c1 = new Car();
        Car c2 = new Car("Sonata");
        Car c3 = new Car("Avante", 2024, 15000);

        c1.show();
        c2.show();
        c3.show();
    }
}

Period 05 — 외부와 내부, 그 사이의 벽

  1. 필드가 public일 때 어떤 위험이 있는지 코드 예시로 설명할 수 있다
  2. 필드를 private 로 바꾸고 getter/setter를 생성해 통제할 수 있다
  3. setter에 검증 로직을 넣어 잘못된 값(예: 음수 정수)을 거부할 수 있다
  4. “캡슐화 = 통제권”의 진짜 의미를 설명하고 read-only 필드를 만들 수 있다

접근 제어자 + Getter / Setter 패턴

  • 🏗️ private을 통한 데이터 은닉 (Encapsulation)
    • 클래스의 필드를 private으로 선언하여 외부에서 점(.) 연산자로 직접 값을 읽거나 수정하지 못하도록 원천 차단한다.
    • 이는 객체의 내부 상태를 부적절한 외부 간섭으로부터 안전하게 보호하는 캡슐화의 핵심 기반이 된다.
  • 🧱 Getter와 Setter 메서드의 역할
    • private 필드에 안전하게 접근할 수 있도록 통로를 열어주는 역할을 하는 public 제한자의 메서드이다.
    • Getter는 내부 필드의 값을 안전하게 읽어와 반환하고, Setter는 외부에서 전달된 값을 받아 필드에 대입하는 기능을 수행한다.
  • ⚡ Setter를 통한 데이터 검증 기능
    • Setter 메서드 내부에 조건문(if) 등의 로직을 삽입하면, 음수 데이터나 비정상적인 범위의 값이 필드에 그대로 주입되는 현상을 사전에 필터링할 수 있다.
    • 직접적인 필드 수정을 막고 코드를 거치게 만듦으로써 데이터의 정확성과 무결성을 유지하는 강력한 방어선 역할을 한다.
public class Student {
  private String name;
  private int    score;

  // getter — 읽기
  public String getName() {
    return name;
  }
  // setter — 쓰기
  public void setName(String name) {
    this.name = name;
  }

  public int  getScore()         { return score; }
  public void setScore(int score) { this.score = score; }
}

// 이름 규칙

  • getter: get + 필드명(첫 글자 대문자)
  • setter: set + 필드명(첫 글자 대문자)
  • boolean 필드는 isXxx도 흔함

실습 1 — 필드 private + getter/setter (컴파일러가 막아준다)

public class Student {
  private String name;
  private int    score;

  public Student(String name, int score) {
    this.name=name; this.score=score;
  }

  public String getName()  { return name; }
  public int getScore() { return score; }

  public void setName(String name)   { this.name=name; }
  public void setScore(int score) { this.score=score; }
}
Student s = new Student("홍길동", 92);

s.score = -1000;        // 💥 컴파일 오류
s.setScore(85);         // OK
System.out.println(s.getScore());
error: score has private access in Student
s.score = -1000;
^

setter에 문지기 — 검증 로직 넣기

public void setScore(int score) {
  if (score < 0 || score > 100) {
    System.out.println(
      "[경고] 점수는 0~100 사이여야 합니다. 변경 거부: " + score);
    return;          // 필드 안 바꾸고 종료
  }
  this.score = score;  // 통과한 값만 반영
}
// GuardedApp.java
Student s = new Student("홍길동", 92);

s.setScore(-1000);      // 거부
s.setScore(200);        // 거부
s.setScore(85);         // OK
System.out.println("현재 점수: "+s.getScore());
$ java GuardedApp
[경고] 점수는 0~100 사이여야 합니다. 변경 거부: -1000
[경고] 점수는 0~100 사이여야 합니다. 변경 거부: 200
현재 점수: 85

잠깐 — getter/setter 다 만들면 private의 의미가 없잖아?

  • 🏗️ 합리적인 의문과 겉보기상의 모순
    • 필드를 private으로 감싸놓고 모든 필드에 대해 기계적으로 public getter/setter를 제공한다면, 결국 외부에서 값을 마음대로 읽고 쓰는 모양새가 된다.
    • 이 때문에 처음에는 데이터 은닉과 캡슐화의 취지가 완전히 퇴색되는 것처럼 보일 수 있다.
  • 🧱 결정적 차이: 제어권(Control)의 주체
    • 필드를 직접 노출하면 외부에 통제되지 않는 접근 권한을 주게 되지만, 메서드를 통하면 제어권이 완전히 객체 자신에게 귀속된다.
    • 추후 데이터 검증 로직이 추가되거나 필드의 데이터 타입·구조가 변경되더라도, 외부 호출처의 코드를 단 한 줄도 수정하지 않고 객체 내부의 메서드 바디만 수정하여 안전하게 대응할 수 있는 강력한 유지보수성을 얻는다.
  • ⚡ 조건부 제공과 설계의 유연성
    • 모든 필드에 무조건 setter를 개설할 필요는 없다.
    • 외부에 변경을 허용하지 않고 읽기만 가능해야 하는 필드는 getter만 제공함으로써, 자연스럽게 해당 필드나 객체 전체를 ‘읽기 전용(Read-Only)’으로 안전하게 설계할 수 있다.

실습 3 — read-only (한 번 정해지면 못 바꾸는 필드)

public class Student {
  private final int id;        // ← final + setter 없음
  private String    name;
  private int       score;

  public Student(int id, String name, int score) {
    this.id    = id;       // 생성자에서 한 번만 가능
    this.name  = name;
    this.score = score;
  }

  public int    getId()    { return id; }
  public String getName()  { return name; }
  public int    getScore() { return score; }
  // setId 없음! setter 안 만든다 = 외부 read-only

  public void setName(String name)   { this.name=name; }
  public void setScore(int score) { this.score=score; }
}
$ 외부에서 변경 시도
s.setId(9999); → error: cannot find symbol
s.id = 9999; → error: id has private access
s.getId() → 1001 (읽기는 OK)

// 어디에 쓰나 고유 식별자: id, 주민번호, 사원번호 변하지 않는 사실: 생년월일, 가입일 한번 정한 옵션: 통화(KRW/USD), 단위


도전 문제 — Account 클래스 (deposit / withdraw로만 잔액을 바꾼다)

// 문제 balance를 private으로, deposit/withdraw를 통해서만 변경 통제

// 필드 final String owner (read-only) private int balance

// 메서드 deposit(amount): amount≤0 거부 withdraw(amount): amount≤0 / 잔액부족 거부 getBalance(): 잔액 조회

// 4가지 시나리오 출력 10000원 정상 입금 0원 입금 → 거부 3000원 정상 출금 잔액 초과 출금 → 거부

public class Account {
    final String owner;
    private int balance;

    public Account(String owner) {
        this(owner, 0);
    }
    public Account(String owner, int balance) {
        this.owner = owner;
        this.balance = balance;
    }

    public int getBalance() {
        return this.balance;
    }

    public void setBalance(int balance) {
        this.balance = balance;
    }

    public void deposit(int amount) {
        if (amount <= 0) {
            System.out.println("[거부] 입금액은 0보다 커야 합니다: "+amount);
            return;
        }
        setBalance(getBalance() + amount);
        System.out.printf("입금 %d - 잔액: %d%n", amount, getBalance());
    }

    public void withdraw(int amount) {
        if (amount <= 0) {
            System.out.println("[거부] 출금액은 0보다 커야 합니다: "+amount);
            return;
        } else if (amount > getBalance()) {
            System.out.printf("[거부] 잔액 부족 (요청 %d, 잔액 %d)%n", amount, getBalance());
            return;
        }
        setBalance(getBalance() - amount);
        System.out.printf("출금 %d - 잔액: %d%n", amount, getBalance());
    }
}

transfer(Account other, int amount)

public void transfer(Account other, int amount) {
		if (other == null) {
		    System.out.println("입금계좌가 Null입니다. 계좌를 재확인하세요.");
		    return;
		} else if (amount > getBalance()) {
		    System.out.printf(
		        "[거부] 잔액 부족 (요청 %d, 잔액 %d)%n", amount, getBalance());
		    return;
		}
		setBalance(getBalance() - amount);
		other.setBalance(other.getBalance() + amount);
		System.out.printf("출금 %d - 잔액: %d%n", amount, getBalance());
		System.out.println("출금 성공 !!!");
}

Period 06 — 객체에 속하는가 클래스에 속하는가

  1. “객체에 속한다 vs 클래스에 속한다”의 차이를 설명할 수 있다
  2. static int totalCount를 추가하고 생성자에서 ++로 인원수를 셀 수 있다
  3. static 메서드를 만들어 Student.getTotalCount()로 호출할 수 있다
  4. static 변수를 객체 통해 변경하면 모든 객체에 반영되는 이유를 그림으로 설명할 수 있다

메모리 — 세 번째 영역 등장 (Static / Method 영역)

  • 🏗️ 자바 메모리 구조의 완성
    • 참조 변수가 할당되는 Stack 영역과 인스턴스 본체가 생성되는 Heap 영역에 이어, 세 번째 물리 공간인 Static(또는 Method) 영역이 등장하며 자바의 핵심 메모리 구조가 완성된다.
    • 이 공간은 프로그램 실행의 전체 설계도와 공통 데이터가 집중 관리되는 곳이다.
  • 🧱 Static 영역의 보관 대상과 특징
    • 클래스의 구조 정보(바이트코드), 메서드들의 실제 실행 코드 엔진, 그리고 static 키워드가 붙은 정적 멤버(필드 및 메서드)가 이 영역에 보관된다.
    • new 연산자를 통한 객체 생성 여부와 관계없이 프로그램이 시작되어 클래스가 로드될 때 메모리에 단 하나만 할당되는 전역 공유 공간이다.
  • ⚡ 생명 주기: 프로그램의 시작과 끝을 함께함
    • 가비지 컬렉터(GC)에 의해 수시로 수거되고 관리되는 Heap 영역의 인스턴스들과 달리, Static 영역에 로드된 데이터는 프로그램이 완전히 종료되어 JVM이 내려갈 때까지 메모리에 영구히 상주한다.

실습 1 — 학생 인원 세기 (static int totalCount)

public class Student {
  private String name;
  private static int totalCount;

  public Student(String name) {
    this.name = name;
    totalCount++;       // 클래스 한 곳을 +1
  }

  public static int getTotalCount() {
    return totalCount;
  }
}
// CountApp.java
Student a = new Student("홍길동");
Student b = new Student("김철수");
Student c = new Student("이민호");

// 인스턴스 없이도 접근 가능
System.out.println("총 인원: " + Student.getTotalCount());
$ java CountApp
총 인원: 3

실습 2 — 유틸리티 메서드 (객체와 무관한 일)

public class ScoreUtil {
  // 객체가 필요 없음 — 입력만 받으면 됨
  public static String grade(int score) {
    if (score >= 90) return "A";
    if (score >= 80) return "B";
    if (score >= 70) return "C";
    return "F";
  }

  public static double average(int[] arr) {
    int sum = 0;
    for (int x : arr) sum += x;
    return (double)sum / arr.length;
  }
}
// UtilApp.java
System.out.println(ScoreUtil.grade(92));     // A
System.out.println(ScoreUtil.grade(65));     // F
System.out.println(ScoreUtil.average(new int[]{92,85,78})); // 85.0

언제 static 메서드?

적합한 경우

  • 입력만 받아서 결과만 반환 (필드 안 씀)
  • 유틸리티: Math.max, Integer.parseInt
  • 특정 객체에 묶을 필요가 없는 일반 로직

부적합한 경우

  • 객체의 필드를 읽거나 써야 한다면 → instance 메서드
  • 객체마다 다르게 동작해야 한다면 → instance 메서드
  • 객체의 상태와 동작이 함께 가야 한다면 (예: introduce) → instance 메서드

“모두 함께 바뀐다” 실험 — static 변수의 공유성

  • 🏗️ 실험 환경: 독립된 두 인스턴스 생성
    • Student s1 = new Student();Student s2 = new Student();를 각각 독립적으로 생성한다.
    • 클래스 내부에는 전체 학생 수를 추적하기 위한 static int totalStudents;라는 정적 변수가 선언되어 있다.
  • 🧱 특정 객체를 통한 값 변경의 파장
    • 참조 변수 s1을 이용하여 s1.totalStudents = 100;과 같이 정적 변수의 값을 100으로 변경한다.
    • 그 후, 직접 값을 변경하지 않은 다른 객체 s2를 통해 s2.totalStudents 값을 콘솔에 출력하여 상태를 확인해 본다.
  • ⚡ 실험 결과와 원인: 단 하나의 저장소 공유
    • 출력 결과, 값을 건드리지 않은 s2.totalStudents 역시 100으로 똑같이 변경되어 출력되는 동기화 현상이 발생한다.
    • static 변수는 Heap 영역의 개별 객체 내부에 각각 생성되는 것이 아니라, Static 영역에 단 하나만 생성되어 모든 인스턴스가 하나의 메모리 주소를 공유하기 때문이다. (따라서 혼선을 방지하기 위해 객체명 대신 Student.totalStudents와 같이 클래스명으로 접근하는 것이 올바른 문법이다.)
Student s1 = new Student("A");
Student s2 = new Student("B");
Student s3 = new Student("C");

System.out.println(s1.totalCount);  // 3
System.out.println(s2.totalCount);  // 3
System.out.println(s3.totalCount);  // 3

// s1을 통해 0으로 바꾼다 — s1만 바뀔까?
s1.totalCount = 0;

System.out.println(s1.totalCount);  // 0
System.out.println(s2.totalCount);  // ??
System.out.println(s3.totalCount);  // ??
$ java SharedDemo
3 3 3
0 0 0 # 모두 같이 바뀜!

실습 3 — static 안에서 instance 필드를 쓰면?

public class Student {
  private String name;
  private static int totalCount;

  // ❌ static 메서드 안에서 name(instance) 접근
  public static void printName() {
    System.out.println(name);
  }
}
error: non-static variable name cannot be referenced
from a static context
System.out.println(name);
^
// ✅ instance 메서드로 만들거나, 매개변수로 받기
public void printName() {        // static 제거
  System.out.println(name);
}

// 또는
public static void printName(Student s) {
  System.out.println(s.name);
}

도전 문제 — Counter(instance 버전 vs static 버전, 출력으로 차이 증명)

// 문제 두 클래스를 만드세요: (A) InstanceCounter — instance count + increment() (B) StaticCounter — static count + increment()

// 시나리오 각 클래스로 객체 3개씩 생성 각 객체가 모두 increment() 호출 마지막에 각 객체의 count를 출력

// 관찰 (A)는 1,1,1이지만 (B)는 3,3,3. 왜? 메모리 그림으로 한 줄 설명을 함께.

class InstanceCounter {
    int count;

    void increment() {
        count++;
    }
}

class StaticCounter {
    static int count;

    void increment() {
        count++;
    }
}

public class CounterApp {
    public static void main(String[] args) {
        InstanceCounter a1 = new InstanceCounter();
        InstanceCounter a2 = new InstanceCounter();
        InstanceCounter a3 = new InstanceCounter();

        StaticCounter b1 = new StaticCounter();
        StaticCounter b2 = new StaticCounter();
        StaticCounter b3 = new StaticCounter();

        a1.increment(); a2.increment(); a3.increment();
        System.out.println(
                "a1.count="+a1.count+" "+
                "a2.count="+a2.count+" "+
                "a3.count="+a3.count);

        b1.increment(); b2.increment(); b3.increment();
        System.out.println(
                "b1.count="+StaticCounter.count+" "+
                "b2.count="+StaticCounter.count+" "+
                "b3.count="+StaticCounter.count);
    }
}

Period 07 — v0를 객체로 다시 짓다

  1. private 필드 + 생성자 + getter + toString + static totalCount를 모두 갖춘 Student 클래스를 작성할 수 있다
  2. Student[]로 평균/최고점/이름순 정렬 등 기본 연산을 처리할 수 있다
  3. while + switch 메뉴 루프로 모든 기능을 하나의 콘솔 앱에 통합할 수 있다
  4. v0 대비 v1의 코드 길이와 확장성을 비교하고, 5장으로 미루는 한계를 설명할 수 있다

Student 완성

public class Student {
    private final int id;
    private String name;
    private int score;
    private static int totalCount;

    public Student(int id, String name, int score) {
        this.id = id;
        this.name = name;
        setScore(score);
        totalCount++;
    }

    public int getId() {
        return id;
    }
    public String getName() {
        return name;
    }
    public int getScore() {
        return score;
    }
    public static int getTotalCount() {
        return totalCount;
    }

    public void setName(String name) {
        if (name == null || name.trim().isEmpty()) {
            System.out.println("[경고] 이름은 문자열이여야 합니다. 변경 거부: "+ name);
            return;
        }
        this.name = name;
    }
    public void setScore(int score) {
        if (score < 0 || score > 100) {
            System.out.println("[경고] 점수는 0~100 사이여야 합니다. 변경 거부: " + score);
            return;
        }
        this.score = score;
    }

    static double average(Student[] arr) {
        int sum = 0;
        for (Student s : arr) sum += s.getScore();
        return (double)sum / arr.length;
    }

    static Student top(Student[] arr) {
        Student best = arr[0];
        for (int i=1; i < arr.length; i++)
            if (arr[i].getScore() > best.getScore())
                best = arr[i];
        return best;
    }

    static void sortDesc(Student[] arr) {
        for (int i=0; i < arr.length; i++) {
            int maxIdx = i;
            for (int j=i+1; j<arr.length; j++)
                if (arr[j].getScore() > arr[maxIdx].getScore())
                    maxIdx = j;
            Student tmp = arr[i];   // 참조 교환!
            arr[i] = arr[maxIdx];
            arr[maxIdx] = tmp;
        }
    }

    void introduce() {
        System.out.println("안녕하세요! 저는 " +name+"이고 "+score+"점입니다.");
    }

    void shout() {
        System.out.println(name+"!!!");
    }

    int bonus() {
        if (score >= 90) return 10;
        else return 0;
    }

    boolean sameAs(Student other) {
        if (other == null) return false;
        return this.name.equals(other.name)
                && this.score == other.score;
    }

    public String toString() {
        return id+" "+name+" "+score;
    }
}

GradeAppV1

import java.util.Scanner;

public class GradeAppV1 {
    public static void main(String[] args) {
        Student[] arr = new Student[5];
        arr[0] = new Student(1001,"홍길동",92);
        arr[1] = new Student(1002,"김철수",78);
        arr[2] = new Student(1003,"이민호",88);
        arr[3] = new Student(1004,"박영희",71);
        arr[4] = new Student(1005,"최지우",95);

        Scanner sc = new Scanner(System.in);

        while (true) {
            System.out.println("\n=== 학생 성적 관리 v1 ===");
            System.out.println("[1] 전체  [2] 평균  [3] 최고점  [4] 정렬  [0] 종료");
            System.out.print("선택 ▷ ");
            int m = sc.nextInt();

            switch (m) {
                case 1 -> {
                    for (Student s : arr) System.out.println(s);
                    System.out.println("(총원: "+Student.getTotalCount()+"명)");
                }
                case 2 -> System.out.println("평균: "+Student.average(arr));
                case 3 -> System.out.println("최고: "+Student.top(arr));
                case 4 -> {
                    Student.sortDesc(arr);
                    for (Student s : arr) System.out.println(s);
                }
                case 0 -> {
                    System.out.println("종료합니다.");
                    return;
                }
                default -> System.out.println("올바른 번호를 입력하세요");
            }
        }
    }
}

메뉴 [5] 학번 검색 + 메뉴 [6] 학생 추가 — 한계까지 가보기

// 미션 1 — 학번 검색

  • 메뉴 [5] “학번으로 학생 찾기”
  • 학번 입력받아 일치 Student 출력
  • 없으면 “찾을 수 없습니다”
  • static Student findById(Student[] arr, int id)
static String findById(Student[] arr, int id) {
    if (arr == null) return "학생이 없습니다.";
    for (Student s : arr) {
        if (s.getId() == id) return s.toString();
    }
    return "찾을 수 없습니다.";
}
case 5 -> {
    System.out.print("학번 ▷ ");
    int id = sc.nextInt();
    System.out.println(Student.findById(arr, id));
}





© 2017. by isme2n

Powered by aiden