자바 스터디 2회차 준비

24 minute read

아래는 5월 30일 진행 할 스터디 준비 내용이다.

  • 2주차 목차
    • 배열
      • 배열이란?
      • 배열의 선언과 생성
      • 배열의 인덱스
      • 배열의 저장과 조회
      • 배열의 길이
    • String
      • equals
    • 2차원 배열
      • 2차원 배열의 선언과 생성
      • 2차원 배열의 인덱스
      • 2차원 배열의 저장과 조회
      • 2차원 배열의 길이
      • Arrays.sort()
    • 객체지향 프로그래밍 1
      • 객체지향 언어 (Object Oriented Programming)
      • 클래스의 구조
      • 필드, 생성자, 메서드
        • 메서드의 선언
        • 메서드의 호출
        • 메서드와 함수의 차이
        • return문
        • 매개변수
      • 객체
      • 객체와 인스턴스
      • 한 파일에 여러 클래스 작성하기
      • 객체의 생성과 사용
      • 선언위치에 따른 변수의 종류
      • static은 언제 붙일까?
      • 호출스택
      • 메서드 간의 호출과 참조
      • 오버로딩
      • this
      • this()
      • 변수의 초기화
    • 1주차 과제
      • 객체지향 언어의 4가지 특징
        • 캡슐화
        • 추상화
        • 다형성
        • 상속
      • 객체지향 설계 원칙 5가지 (SOLID)
        • SRP : Single Responsibiliy Principal
        • OCP : Open Closed Principal
        • LSP : Liskov Substitution Principal
        • ISP : Interface Segregation Principal
        • DIP : Dependency Inversion Principal

1. 배열

1-1) 배열이란?

같은 타입의 여러 변수들을 하나의 묶음으로 만들어 사용하는 것을 배열 (Array) 라고 한다.
더 큰 의미로는 자료의 조직, 관리, 저장을 의미하는 자료구조 중 하나이다.

만약 10억개의 변수를 생성하고, 값을 부여해야 한다면, 10억번의 선언과 저장을 해야한다.
배열을 잘 활용한다면 이런 노고는 반드시 줄어들 것이다.


1-2) 배열의 선언과 생성

배열의 선언은 변수의 선언과 크게 다를 것 없다.
선언할 배열의 타입, 배열의 이름, 그리고 배열임을 의미하는 대괄호를[] 사용한다.

타입[] 변수명;
타입 변수명[];

위 두 가지의 방법이 있다.
다행스러운 것은 두 개의 방법에 차이는 없다.

그저 C언어에서는 두 번째 방법으로 배열을 선언하는데,
자바언어의 모체격인 C언어의 특징을 살려 둘 다 사용 할 수 있도록 구현한 것이다.
일반적으로는 첫 번째 방법을 많이 사용하니 참고하자.

public static void main(String[] args) {
    
    int[] integerArr;
    String[] stringArr;

}

선언을 했으면 배열을 생성해야 한다.
배열을 생성하기 위해선 new 연산자를 사용한다.

타입[] 변수명;
변수명 = new 타입[배열의 길이];

길이가 5인 int 배열을 생성해보자.

public static void main(String[] args) {
    int[] integerArr;
    integerArr = new int[5];
}

첫번째 줄은 그저 배열을 다룰 integerArr 변수를 생성한 것이고,
두번째 줄에서 비로소 5개의 값을 저장할 수 있는 배열의 공간을 만든 것이다.

위 두 줄의 코드를 줄여서 아래와 같이 표현 할 수 있다.

public static void main(String[] args) {
    int[] integerArr = new int[5];
}

위와 같은 배열 공간을 만들면, 배열은 타입의 기본값으로 채워진다.
예를들어 int 배열이라면 0으로, boolean 배열이라면 false로,
String 배열이라면 null로 초기화된다.


1-3) 배열의 인덱스

인덱스는 굉장히 중요한 요소이니 충분히 공부할 필요성이 있다.
인덱스란 배열의 요소에 주어진 일련번호이다.

먼저 배열의 구조를 그림으로 살펴보자.
arr라는 이름의 배열이 있다고 가정하자.

error

네모 4개를 합친 큰 네모는 arr 라는 배열 자체이다.
그리고 각각의 배열 방이 있다.
그 방 안에는 10, 50, 30, 5 라는 Element가 (요소) 있으며,
각각의 배열방의 번호가 바로 index이다.

다만 조금 헷갈릴 수 있는 것은 인덱스는 1부터 시작이 아닌, 0부터 시작이라는 것이다.

쉽게 생각하자면 arr 호텔의 0번방에 묵는 10, 1번방에 묵는 50 .. 이라고 생각 하면 간단 할 수 있다.

그렇기 때문에 배열의 요소에 정확히 접근하기 위해서는 인덱스를 알아야한다.


1-4) 배열의 저장과 조회

이제 인덱스를 알았으니, 배열에 값을 저장해본다.
배열에 값을 저장하는 것 또한 변수에 값을 저장하는 것과 큰 차이가 없다.

public static void main(String[] args) {
    int[] arr = new int[4];

    arr[0] = 10;
    arr[1] = 50;
    arr[2] = 30;
    arr[3] = 5;
}

위 그림과 동일한 배열을 만들고, 값을 저장했다.

또한 선언과 동시에 값을 저장할 수 도 있다.

public static void main(String[] args) {
    // 아래 방법은 가능하긴 하나, 불편하기 때문에 잘 쓰지 않는다.  
    // int[] arr = new int[] { 10, 50, 30, 5 };
    int[] arr = { 10, 50, 30, 5 };
}

이 경우엔 배열의 크기와 new 연산자를 굳이 작성하지 않아도 된다.
다만 선언과 저장을 따로 할 경우엔 new 연산자를 생략하면 컴파일 에러가 발생하니 조심하자.

public static void main(String[] args) {
    // 이건 가능하다.
    int[] arr = { 10, 50, 30, 5 };

    // 이건 불가능하다.
    int[] arr2;
    arr2 = { 10, 50, 30, 5 };

    // 따로 하려면 아래와 같이 한다.
    int[] arr3;
    arr3 = new int[] { 10, 50, 30, 5 };
}

이러한 배열을 만들었으면, 사용을 해야 한다.

public static void main(String[] args) {
    int[] arr = { 10, 50, 30, 5 };
    System.out.println(arr);
}

이렇게 사용하면 될까?
이 땐 [I@704a52ec 와 같은 타입@주소 형태의 배열의 참조변수가 출력된다.

그렇다면 순회할 수 있는 몇 가지 방법을 알아보자.

public static void main(String[] args) {

    int[] arr = { 10, 50, 30, 5 };

    // 1번 방법 : index로 접근
    arr[3] = 100;               // arr[3]의 값을 100으로 수정
    System.out.println(arr[3]); // 100

    // 2번 방법 : for문으로 순회
    for (int i = 0; i < arr.length; i++) {
        arr[i] = i * 10;
        System.out.println(arr[i]);
    }

    // 3번 방법 : for-each문으로 순회
    // 지난 시간에 말했듯 for-each는 원본의 값을 수정하진 못한다.  
    for (int i : arr) {
		System.out.println(i);
    }

    // 4번 방법 Arrays 클래스의 toString 메서드를 사용한다. 이 경우 import는 필수이다. 
    System.out.println(Arrays.toString(arr));

}

1-5) 배열의 길이

배열의 길이를 한번에 알아낼 수 있는 방법이 있다.
바로 length 인데, 이 length는 조금 특이하다.

일반적으로 이런 기능은 메서드가 수행하는데,
length는 메서드가 아니며, 배열만의 고유한 필드(클래스 내부의 변수) 이다.

공식 문서를 찾지 못해 정확하진 않지만,
length는 final로 선언된 상수이며 (값을 변경하지 못하기 때문에),

배열을 만들면서 크기를 지정해주면서, length의 값이 함께 지정되는 것으로 판단된다.

사용하는 방법은 배열뒤에 . 연산자를 사용하고, length를 붙여준다.
주의할 점은 0부터 카운트하는 인덱스와는 다르게 length는 순전히 배열의 크기라는 것 이다.

public static void main(String[] args) {
    int[] arr = { 5, 10, 30 };
    System.out.println(arr.length); 
    // 0번 인덱스, 1번 인덱스, 2번 인덱스 legnth는 2... 가 아니라 length는 3이다.
}

또 한 가지 중요한 점은 배열은 고정적인 크기를 가진 자료구조이다.
크기를 벗어난 인덱스를 호출하면, ArrayIndexOutOfBoundsException 예외가 발생한다.

public static void main(String[] args) {
    int[] arr = { 5, 10, 30 };
    System.out.println(arr[100]); 
}

// Exception in thread "main" java.lang.  
// ArrayIndexOutOfBoundsException: Index 100 out of bounds for length 3

2. String

String 클래스는 상당히 내용이 많다.
자세한 내용은 Chapter 9 에서 다시 다루도록 한다.

2-1) equals

지금까지 Primitive Type의 비교를 할 때 == 이라는 기호를 사용했다.
Reference Type인 String은 == 을 사용했을때 정상적인 비교가 불가능하다.

예시를 통해 이유를 알아보자.

public static void main(String[] args) {

    String a = "true가 나올까?";
    String b = "true가 나올까?";
    String c = a;
    
    System.out.println("a == b ? " + a == b);
    System.out.println("a == c ? " + a == c);
    System.out.println("b == c ? " + b == c);

    System.out.println("a.equals(b) ? " + a.equals(b));
    System.out.println("a.equals(c) ? " + a.equals(c));
    System.out.println("b.equals(c) ? " + b.equals(c));
    
}

a와 b 그리고 c는 결과적으로 값이 동일하다.
그러나, 가지고 있는 주소값은 각자 다르다.

== 연산자를 통해 비교했을때 모두 false가 나온 이유는 == 연산자는 주소값을 비교하기 때문이다.
반면 equals 메서드는 실제 값을 비교한다.
그렇기 때문에 equals 메서드를 통해 비교했을 땐 모두 true가 나오는 것이다.

그렇다면, a와 b는 그렇다 쳐도, c는 a를 복사해 만든 것이니 주소가 같아야 하지 않을까?
결론부터 말하자면 그렇지 않다.
이는 String의 특성 때문인데, String은 값을 변경할 때 무조건 새로운 주소값에 저장한다.

더 깊은 내용은 Chapter 9 에서 공부하도록 한다.


3. 2차원 배열

앞서 공부한 배열은 1차원 배열이다.
지금 공부할 2차원 배열은 테이블 형태의 데이터를 담는데 사용된다.

대부분의 내용은 1차원 배열과 동일하지만, 2차원 배열을 잘 이해해야 그 이상의 다차원 배열을 쉽게 이해할 수 있다.

3-1) 2차원 배열의 선언과 생성

2차원 배열의 선언은 1차원 배열과 거의 동일하다.
기존 대괄호에, 대괄호를 하나 더 붙인 것 뿐이다.

int[][] arr;

int arr[][];

int[] arr[];

가장 위의 방법을 제일 많이 사용하니 참고하자.

다음은 배열을 생성한다.

int[][] arr = new int[4][3];    // 4행 3열의 배열이다.

error

위와 같은 테이블 형태로 배열방이 생성된다.
두 개의 대괄호 중 왼쪽은 가로, 오른쪽은 세로의 크기이다.
가로 한 줄은 하나의 배열이다.

다시말해 2차원 배열은 1차원 배열들의 배열인 셈이다.


3-2) 2차원 배열의 인덱스

2차원 배열 마찬가지로 요소에 접근하기 위해선 인덱스를 통해야 한다.

public static void main(String[] args) {
    
    int[][] arr = new int[4][3];
    arr[0][0] = 100;
    arr[1][3] = 300;

}

3-3) 2차원 배열의 저장과 조회

다음은 2차원 배열을 저장하는 방법이다.

public static void main(String[] args) {
    
    // 1차원 배열과 마찬가지로 선언과 저장을 동시에 할 경우 new 연산자는 생략 가능하다.  
    // int[][] arr = new int[][] { { 1, 2, 3 }, { 4, 5, 6 } };

    int[][] arr = {
        { 1, 2, 3 },
        { 4, 5, 6 }
    };

}

다음은 2차원 배열을 조회하는 몇 가지 방법이다.

public static void main(String[] args) {

    int[][] arr = {
        { 1, 2, 3 },
        { 4, 5, 6 }
    };

    System.out.println("인덱스로 조회");
    System.out.print(arr[0][0] + " ");
    System.out.println(arr[1][2] + "\n");
    
    System.out.println("for문으로 조회");
    for (int i = 0; i < arr.length; i++) {
        for (int j = 0; j < arr[i].length; j++) {
            System.out.print(arr[i][j] + " ");
        }
        System.out.println();
    }
    
    System.out.println();
		
    System.out.println("for-each문으로 조회");
    for (int[] i : arr) {
        for (int j : i) {
            System.out.print(j + " ");
        }
        System.out.println();
    }
    
    System.out.println();    

    System.out.println("Arrays.deepToString() 메서드로 조회");
    System.out.println(Arrays.deepToString(arr));

}

인덱스로 조회
1 6

for문으로 조회
1 2 3 
4 5 6 

for-each문으로 조회
1 2 3 
4 5 6 

Arrays.deepToString() 메서드로 조회
[[1, 2, 3], [4, 5, 6]]

1차원 배열과 비슷하게 Arrays 클래스에는 deepToString 메서드를 사용할 수 있다.

for문과 for-each문은 2중 for문을 사용해야 한다.


3-4) 2차원 배열의 길이

2차원 배열의 길이도 마찬가지로 length를 통해 알 수 있다.
그러나 조금 다른 점이 있다.

public static void main(String[] args) {
    int[][] arr = {
            { 1, 2, 3 },
            { 4, 5 }
        };

    System.out.println("arr 전체 길이 : " + arr.length);
    System.out.println("arr[0] 길이 : " + arr[0].length);
    System.out.println("arr[1] 길이 : " + arr[1].length);
}

arr 전체 길이 : 2
arr[0] 길이 : 3
arr[1] 길이 : 2

2차원 배열의 length를 출력해보면 전체의 길이가 나오는데,
이때 기준은 세로의 개수이다.

즉 2차원 배열은 1차원 배열들의 배열이기 때문이다.
그렇기 때문에 각자의 배열 길이를 확인할 수 있다.


3-5) Arrays.sort()

Arrays 클래스에는 다양한 메서드가 있다.
이 중 sort를 알아보자.
sort는 정렬이다.
종류는 Selection Sort, Bubble Sort, Insertion Sort, Quick Sort, Merge Sort 등등 굉장히 많다.
이런 알고리즘 중 Arrays.sort() 메서드는 Insertion Sort, Dual Pivot Quick Sort, Count Sort, Merge Sort, Tim Sort를 상황에 맞게 더욱 효과적인 사용한다…

1차원 배열의 정렬이다.

public static void main(String[] args) {
    int[] arr = { 100, 20, 32 };

    
    System.out.println("Sort 전");
    System.out.println(Arrays.toString(arr) + "\n");

    Arrays.sort(arr);
    
    System.out.println("Sort 후");
    System.out.println(Arrays.toString(arr));
}

Sort 
[100, 20, 32]

Sort 
[20, 32, 100]

아래는 2차원 배열의 정렬이다.
2차원 배열의 정렬은 종류가 많아, 각 배열의 0번 원소값을 비교해 정렬하는 코드만 첨부했다.

public static void main(String[] args) {
    int[][] arr = {
            { 100, 20, 32 },
            { 40, 54 }
        };

    
    System.out.println("Sort 전");
    System.out.println(Arrays.deepToString(arr) + "\n");

    Arrays.sort(arr, Comparator.comparingInt(e -> e[0]));
    
    System.out.println("Sort 후");
    System.out.println(Arrays.deepToString(arr));
}

Sort 
[[100, 20, 32], [40, 54]]

Sort 
[[40, 54], [100, 20, 32]]

4. 객체지향 프로그래밍 1

4-1) 객체지향 언어 (Object Oriented Programming)

객체지향의 개념은 다양한 프로그래밍 패러다임 중 하나로,
객체 간의 상호작용을 통해 프로그램을 개발하는 방법이다.

객체지향언어의 특징은 다음과 같다.

  1. 코드의 재사용성이 높다.
  2. 코드의 관리가 용이하다.
  3. 신뢰성이 높은 프로그래밍을 할 수 있다.

4-2) 클래스의 구조

클래스의 구조를 익혀보자.

error
클래스는 크게 필드와, 생성자, 그리고 메서드로 이루어져있다.

자바는 변수를 지칭하는 용어가 꽤 많다.
필드(Field), 전역변수(Global Variable), 지역변수 (Local Variable) …

지역변수는 메서드, for문, if문 등에 정의된 변수를 의미한다.

필드는 클래스에 정의 된 변수를 의미한다.
멤버변수 혹은 전역변수라고 부르며, “객체의 속성”을 나타낸다.
그리고 자체적으로 값을 지정하기 보단 대부분의 경우 외부에서 값을 지정하게 된다.

이때 객체를 생성함과 동시에 값을 지정할 수 있도록 해주는 역할을 생성자가 하며,

메서드는 “객체의 기능”을 나타낸다.


4-3) 필드, 생성자, 메서드

간단한 예시를 보자.

public class Person {
    
    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    private String name;
    private int age;

    public String toString() {
        return "Person{" +
                "신규회원 이름 = " + name +
                ", 신규회원 나이 = '" + age + '\'' +
                '}';
    }
}

public class Main {
    public static void main(String[] args) {
        Person constructor = new Person("LEE GI CHEOL", 26);
        System.out.println(constuctor.toString());
    }
}

클래스 맨위의 클래스와 동명의 메서드 같은 것이 있다.
이게 바로 생성자이다.

클래스와 동일한 접근제어자, 반환값은 항상 void이기 때문에 작성하지 않고,
생성과 동시에 저장할 변수의 값을 인자로 받는다.

자바는 기본적으로 클래스 당 생성자는 무조건 한개가 필요한데,
아무런 생성자를 작성하지 않았을 때 컴파일러가 아무런 인자도 없는 기본 생성자를 만들어준다.

잠시 1회차 스터디 자료를 다시 보도록 하자.

error

error

분명 main 메서드와 print 메서드만 있는 클래스인데, 바이트코드엔 생성자가 포함되어있다.

다음은 필드이다. 위 예제에 name, age 변수는 메서드안에 속한 변수가 아니라,
클래스안에 속한 전역변수이다.
이러한 변수들을 멤버변수 혹은 필드라 부른다.

메서드는 다음 예제를 보도록 한다.

public class Person {
    public Person() {}

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    private String name;
    private int age;
    private static Map<String, String> map = new HashMap<>();

    public String getName() {
        return name;
    }

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

    public int getAge() {
        return age; 
    }

    public void setAge(int age) {
        this.age = age;
    }

    public void save(String key, Person person) {
        map.put(key, person.toString());
    }

    public void printUser() {
        Set<String> key = map.keySet();

        for (String s : key) {
            System.out.println("회원 목록 : " + map.get(s));
        }
    }

    public String toString() {
        return "Person{" +
				"신규회원 이름 = " + name +
				", 신규회원 나이 = '" + age + '\'' +
				'}';
    }
}

public class SignUpUser {

    public static void main(String[] args) {
        Person signUpPerson = new Person();
        signUpPerson.setName("LEE GI CHEOL");
        signUpPerson.setAge(26);
        
        signUpPerson.save("LEEGICHEOL", signUpPerson);

        signUpPerson.setName("EKKKK1");
        signUpPerson.setAge(20);
        
        signUpPerson.save("EKKKK1", signUpPerson);
        
        signUpPerson.printUser();
    }

}

Person 클래스를 선언하기 위해선 new 연산자를 사용한다.
이때 이 단계에서 참조변수 signUpPerson에 객체의 주소가 저장이 되며, 객체가 생성된다.

클래스와 객체가 있기 때문에 이름과 나이를 메서드를 통해 지정하고,
save, printUser 메서드를 통해 저장/출력을 할 수 있다.
이것이 바로 코드의 재사용성이다.

지금은 간단한 코드라 와닿지 않겠지만,
만약 save 메서드가 정말 회원가입 메서드라면,
DB Connection과 같은 많은 코드가 있을 것이다.
과장해서 100줄이 넘는 코드라고 상상을 해보자.

저장할때마다 100줄 이상의 코드를 작성한다는 것은
너무나도 귀찮을뿐더러 유지보수성이 너무나도 떨어진다.

근데 메서드를 사용하면 단 한줄로 100줄의 코드를 호출할 수 있고,
이름만 잘 지어준다면, 코드를 자세히 읽지 않아도 대략적으로 무엇을 하는지 알 수 있다.

그렇기 때문에 메서드는 “객체의 기능” 을 나타낸다고 할 수 있다.


4-3-1) 메서드의 선언

메서드를 조금 더 깊이 알아본다.
메서드는 선언부와 구현부로 이루어져 있으며, 선언부는 메서드의 이름, 매개변수, 그리고 리턴타입
구현부는 실제 메서드가 수행하는 문장을 작성한다.

public void print() {
    System.out.println("void 메서드");
}

public int sum(int i, int j) {
    return i + j;
}

매개변수는 필요에 따라 작성을 해도, 하지 않아도 된다.
작성을 할 경우 꼭 타입 변수명 의 규칙을 따라야 한다.
두개 이상의 매개변수가 있을 경우 , 를 통해 구분한다.

리턴타입은 반드시 작성되어야 한다.
만일 리턴값이 없는 경우라면 void를 작성한다.


4-3-2) 메서드의 호출

메서드를 정의하고 호출하는 방법이다.
위 예제에서 정의한 메서드를 사용해본다.

public static void main(String[] args) {
    print();

    int num = 1;
    int num2 = 10;

    int result = sum(num, num2);
}

메서드를 선언할 때 괄호안의 값은 매개변수,
메서드를 호출할 때 괄호안의 값은 인자 라 부른다.

호출할 때 두개의 갯수는 동일해야하며, 타입이 다를 경우 컴파일 에러가 발생한다.


4-3-3) 메서드와 함수의 차이

메서드와 함수는 보통 혼용해서 사용하는 경우가 많다.
실제로 메서드와 함수는 동일하다.
다만 함수는 클래스 안, 밖 어디든 선언 가능하지만, 메서드는 무조건 클래스 안에 선언되어 있어야 한다.

역할 자체는 같기 때문에 헷갈릴 수 있고, 그 때문에 혼용을 하는데
완전히 같은 개념이 아니라는 것을 알아두자.


4-3-4) return문

return문을 사용하면 현재 실행중인 메서드를 종료하고 직전에 호출했던 메서드로 돌아간다.
메서드는 항상 하나 이상의 return문이 필요하며, 지금까지 return 없이 사용한 메서드가 이상 없었던 이유는 컴파일러가 직접 return문을 추가해주기 때문이다.

// class version 55.0 (55)
// access flags 0x21
public class ReturnTest {

  // compiled from: ReturnTest.java

  // access flags 0x1
  public <init>()V
   L0
    LINENUMBER 1 L0
    ALOAD 0
    INVOKESPECIAL java/lang/Object.<init> ()V
    RETURN
   L1
    LOCALVARIABLE this LReturnTest; L0 L1 0
    MAXSTACK = 1
    MAXLOCALS = 1

  // access flags 0x9
  public static main([Ljava/lang/String;)V
   L0
    LINENUMBER 4 L0
    RETURN      // <-- 리턴문이 추가 되었음
   L1
    LOCALVARIABLE args [Ljava/lang/String; L0 L1 0
    MAXSTACK = 0
    MAXLOCALS = 1
}

반환타입이 void가 아닌 경우 필수로 개발자가 직접 return을 해주어야 한다.
return문이 없다면 컴파일 에러가 발생한다.

주의할 점은 리턴타입에 맞는 값을 리턴시키지 않으면 컴파일 에러가 발생한다.


4-3-5) 매개변수

메서드의 매개변수의 값이 기본형 타입이라면 그 변수의 값이 복사해 메서드에서 사용한다. (Call By Value)
그에반해 참조형 타입이라면 그 객체의 주소가 복사된다. (Call By Reference)

이 점을 주의하지 않으면 원하는 결과를 얻지 못할 수 있다.

public class CBVCBR {
    public static void main(String[] args) {
        Data data = new Data();
        data.x = 10;
        System.out.println("before x : " + data.x);

        change(data.x);
        System.out.println("after x : " + data.x);

        realChange(data);
        System.out.println("after data.x : " + data.x);
    }

    public static void change(int x) {
        x = 1000;
        System.out.println("change x : " + x);
    }

    public static void realChange(Data data) {
        data.x = 1000;
        System.out.println("change data.x : " + data.x);
    }
}

public class Data {
    int x;
}

4-4) 객체

자 그렇다면 객체란 무엇일까.
자바는 클래스에 변수, 메서드 등을 작성을 하면서 개발을 한다.
이 클래스는 쉽게 말하자면 설계도 정도라고 볼 수 있다.

객체는 이러한 클래스로부터 생성된 것을 말하며,
설계도를 통해 생성된 일종의 제품이다.


4-5) 객체와 인스턴스

객체는 클래스가 선언 되어 있을 때 객체라 부르며,
인스턴스는 그 객체가 메모리에 할당되어 사용될 때를 말한다.

객체가 인스턴스를 포함한 더 큰 개념이다.

사실 상 둘은 같은 의미이기 때문에 혼용해서 사용하는 경우가 대다수이다.


4-6) 한 파일에 여러 클래스 작성하기

public class ManyClass {
    
}

class SomeThing {

}

한 파일 (java파일)에 여러 클래스를 작성한다면 단순히 이렇게 사용하면 된다.

다만 주의 할 점은 파일명과 동일한 클래스는 항상 public 이거나, default (SomeThing 클래스처럼 아무것도 작성되지 않는 경우) 여야 한다.
SomeThing 클래스는 항상 default 여야 한다.

error

error

이렇게 만들어도 컴파일 시 ManyClass와, SomeThing은 분리되어 class파일이 생성된다.

ManyClass 안에 Class를 만들 수 있지만, 지금은 우선 여기까지만 공부하도록 한다.

참고사항으로 위의 경우 ManyClass.class파일과, ManyClass$SomeThing.class 파일이 생성된다.


4-7) 객체의 생성과 사용

객체를 생성할 때는 new 라는 연산자를 사용한다.

클래스이름 참조변수명 = new 생성자();

여기서 new는 단순히 객체를 생성한다기보단 굉장히 많은 역할을 한다.

우선 new 연산자를 사용해서 만들어진 객체는 Reference Type이다.

Reference Type은 무엇일까?

자바는 메모리 영역이 크게 3가지가 있다.
static, stack, heap
static은 필드, static 키워드가 붙은 변수 등을 저장한다.
프로그램이 시작되고, 종료될때까지 이 영역에 저장되어 사용된다.
그렇기에 전역변수를 어디에서나 사용할 수 있는 것이다.
또한 그렇기에 static 영역에 메모리를 지나치게 많이 사용한다면, 프로그램에 굉장히 부담스러운 일이 될 것이므로 주의해서 사용하도록 한다.

stack은 지역변수가 저장되는 공간이다.
이 영역엔 하나의 if문, for문, method가 실행될 때 저장되고, 종료될때 제거된다.
자료구조의 stack으로 구성되어 있으며, 지역변수가 다른 곳에서 사용되지 못하는 이유이다.

heap은 바로 이 new 연산자를 통해 생성된 Reference Type이 저장되는 곳이다.
대부분의 변수, 메서드가 사용되는 곳은 stack 영역인데, heap 영역에 저장된 데이터의 참조값을 stack 영역에서 리턴받아 사용하는 방식으로 이루어진다.

public class HeapTest {
    public static void main(String[] args) {
        String hello = "HelloWorld";
    }
}

이러한 String 타입 변수가 있다면, HelloWorld라는 값은 Heap영역에 저장되고, 이 값을 hello라는 변수를 통해 리턴받아 아래와 같이 사용하는 것이다.

error

만약 heap 영역의 데이터가 더 이상 필요없어지는 시기가 온다면, Garbage Collector에 의해 메모리가 해제된다.


4-8) 선언위치에 따른 변수의 종류

변수의 종류는 필드(인스턴스 변수), 클래스 변수(static 변수) 지역변수가 있다.

public class Main {
    int field;                      // 필드
    static int staticVariable;      // 클래스 변수

    public void method() {
        int localVariable;          // 지역 변수
    } 

}

필드는 클래스 영역에 선언해야 한다.
해당 객체를 생성할때 만들어지므로 해당 변수를 사용하려면 객체를 만들어 사용해야한다.
객체마다 별도의 공간을 가지기 때문에 서로 다른 값을 가질 수 있다.

클래스 변수는 클래스가 메모리에 올라갈 때, 즉 프로그램이 실행될 때 생성되며, 모든 객체가 공통된 저장공간을 공유하게 된다.
객체를 만들지 않아도 클래스이름.클래스변수로 사용할 수 있다.

지역변수는 메서드, for문, if문과 같은 블럭 내에서 사용되고, 블럭이 끝나면 제거되는 변수이다.


모든 곳에서 공통적으로 사용되는 경우 어디에서나 공유되는 클래스 변수를 사용하며,
그렇지 않은 경우 필드를 사용한다.

static 키워드가 붙은 메서드와 인스턴스 메서드도 변수와 마찬가지이다.


4-9) static은 언제 붙일까?

책 190p 참고


4-10) 호출스택

책 184p 호출스택 참고


4-11) 메서드 간의 호출과 참조

public class Main {
    void instanceMethod() {}
    static void staticMethod() {}
    
    void instanceMethod2() {
        instanceMethod();
        staticMethod();
    }
    
    static void staticMethod2() {
        instanceMethod(); // 에러발생!

        // 그래도 사용하려면 이렇게 한다.
        Main main = new Main();
        main.instanceMethod();

        staticMethod();
    }
}

인스턴스 메서드는 스태틱 메서드를 호출 할 수 있으나, 반대의 경우는 왜 불가능할까?
이유는 스태틱 메서드는 프로그램이 실행될 때 생성이 된다.
반면 인스턴스 메서드는 객체가 생성되었을 때 생성된다.
그렇기 때문에 스태틱 메서드에서 인스턴스 메서드를 호출하지 못한다.


4-12) 오버로딩

메서드를 만들다보면 같은 기능이긴하나, 조금씩 다른 일을 수행할 경우가 생기는데, 이름짓기가 굉장히 난감하다.
이럴 때 사용할 수 있는게 오버로딩이다.

public class Main {
 
    public int sum(int i, int j) {
        return i + j;
    }
    
    public int sum(int i, int j, int k) {
        return i + j + k;
    }

    public double sum(double i, double j) {
        return i + j;
    }

}

오버로딩은 꼭 지켜줘야하는 규칙이 있다.

  • 메서드의 이름이 같아야 한다.
  • 매개변수의 개수 또는 타입이 달라야 한다.
  • 리턴 타입은 오버로딩과 관련이 없다.

생성자를 여러개 만들 수 있는 것도 오버로딩이므로 참고하도록 하자.


4-13) this

this는 객체 자신을 가리킨다.

public Person {
    int age;
    String name;

    /* 만약 매개변수의 값이 필드와 구분된다면 상관없다. 
    public Person(int a, String n) {
        age = a;
        name = n;
    }
     */

    // 동일하다면?
    public Person(int age, String name) {
        this.age = age;
        this.name = name;
    }
}

this는 인스턴스의 주소가 저장되어 있다.
모든 인스턴스 메서드에 지역변수로 존재한다.

public class Person {

    public Person getThis() {
        return this;
    }
}

public class Main {
    public static void main(String[] args) {
        Person person = new Person();
        
        System.out.println(person);
        System.out.println(person.getThis());
    }
}

두 개의 주소값이 동일 한것을 알 수 있다.


4-14) this()

생성자가 여러개 있는 경우, 생성자끼리의 호출도 가능하다.
이때 this() 를 사용한다.

public class ThisTest {

    private int age;
    private String name;

    public ThisTest() {
        this(26, "LEE GI CHEOL");
        System.out.println("Default Constructor");
    }

    public ThisTest(int age) {
        this("EKKKK1");
        System.out.println("age Constructor");
    }

    public ThisTest(String name) {
        System.out.println("name Constructor"); // 컴파일 에러
        this(26, name);
    }

    public ThisTest(int age, String name) {
        this.age = age;
        this.name = name;
    }

}

중간에 컴파일 에러라 작성된 부분이 있는데,
this() 는 생성자의 맨 첫 줄에서만 사용가능한데, 두 번째 줄에서 사용하려 했기 때문이다.

이러한 규칙이 생긴 이유는 생성자에서 초기화 작업을 하고나서 다른 생성자를 호출 할 경우 이전의 초기화 작업은 의미가 없어지기 때문에 사전에 방지하는 것 이다.


4-15) 변수의 초기화

변수 혹은 객체는 가능하다면 선언과 동시에 초기화를 해주는 것이 좋다.
의도치 않은 값이 들어가거나, NPE가 발생할 수 있기 때문이다.

멤버변수는 초기화를 하지 않아도 기본값으로 초기화되지만,
지역변수는 초기화를 꼭 해줘야한다.

public class Main {

    int x;
    int y = x;

    void method() {
        int i;
        int j = i;  // 컴파일 에러
    }
    
}

y는 x의 기본값인 0이 저장되지만 j는 i가 초기화되지 않으므로 생성되지않는다.
이 상태에서 j를 초기화 하지 못하므로 컴파일 에러가 발생한다.


5. 1주차 과제

5-1) 객체지향 언어의 4가지 특징

5-1-1) 캡슐화

캡슐화는 연관성있는 변수와 메서드를 클래스로 묶는 작업을 의미한다.
캡슐화를 함으로써 얻는 이점은 은닉성이다.
중요한 데이터를 외부에서 쉽게 접근/변경하지 못하도록 할 수 있다.

접근제어자를 통해 변수 혹은 메서드를 은닉하거나 접근할 수 있다.

예를들어 아래와 같은 코드가 있다.

public class Person {

    private int age;
    private String name;
    private String phoneNumber;

}

위 3개의 변수는 private 접근제어자를 사용하여 Person 클래스에서만 사용할 수 있도록 은닉한다.
접근/변경할 때는 어떻게 하는지 확인해보자.

public class Person {

    private int age;
    private String name;
    private String phoneNumber;


    public int getAge() {
		return age;
	}

	public void setAge(int age) {
		this.age = age;
	}
	
    public String getName() {
		return name;
	}
	
    public void setName(String name) {
		this.name = name;
	}
	
    public String getPhoneNumber() {
		return phoneNumber;
	}
	
    public void setPhoneNumber(String phoneNumber) {
		this.phoneNumber = phoneNumber;
	}

}

변수는 private으로 선언되어있어 다른 클래스에서 사용하지 못한다.
그러나 위 메서드들은 public으로 선언되어있어 다른 클래스에서도 사용할 수 있다.
이러한 메서드들을 Getter, Setter 라고 부른다.

그럼 어차피 메서드를 통해 사용할 수 있는데 과연 이게 무슨 소용일지 궁금할 수 있을텐데,
여기서 중요한 것은 Person 클래스의 변수를 접근/변경을 하기 위해선 꼭 Person 클래스가 필요하다.
아무리 Getter Setter가 public이라해도, Person 클래스가 없다면 사용 불가하다는 의미이다.


5-1-2) 상속

상속이란 기존의 클래스를 재사용해 새로운 클래스를 만드는 것이다.
이를 통해 코드의 재사용성을 높이고, 코드의 중복을 줄일 수 있어 생산성과 유지보수에 유리해진다.

상속을 받기 위해선 extends 키워드를 사용하고 상속받을 클래스의 이름을 작성하면 된다.

public class Person {

    private int age;
    private String name;
    
    
    public void setAge(int age) {
        this.age = age;
    }

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

    @Override
    public String toString() {
        return "age=" + age +
                ", name='" + name;
    }
}


public class Me extends Person {

    public static void main(String[] args) {
        Me me = new Me();
        me.setAge(26);
        me.setName("LEE GI CHEOL");

        System.out.println(me.toString());
    }

}

Me 클래스에는 setAge, setName, toString 메서드가 존재하지 않는다.
그러나 Person 클래스를 상속받았기 때문에 사용할 수 있다.

주의할 점 자식 클래스는 부모 클래스의 모든 멤버를 상속받지만 생성자, 초기화 블럭, private은 사용 불가하다.

또한 클래스는 기본적으로 Object 클래스를 상속받아야 하며, 한개 이상의 클래스를 상속받을 수 없다.

Person과 Me 클래스는 Object를 상속받지 않았다.
어떻게 된걸까?

public class Something {
    
    public static void main(String[] args) {
        System.out.println(Something.class.getSuperclass());
    }

}

아무것도 상속받지 않은 클래스는 기본적으로 Object 클래스를 상속 받게 된다.
Me 클래스는 Person을 상속받고, Person은 Object를 상속받았으니,
Me 역시 Object를 상속받게 되는 것이다.
그 다른 증거로 equals, hashcode 등의 메서드가 Object 클래스의 메서드이다.


5-1-3) 추상화

클래스는 설계도라고 했었다. 추상화는 미완성 설계도이다.

예를들어 라면을 끓인다고 가정해보자.
농심, 오뚜기, 팔도 등 다양한 기업이 있지만,
어떠한 라면을 끓이더라도 그저 물의 양, 끓이는 시간, 부가 재료 등의 차이는 있겠지만 이를 위해 끓이는 방법을 굳이 찾아보진 않을 것이다.

자동차도 마찬가지이다.
삼성, 현대, 기아 등 다양한 기업이 있지만,
엑셀을 밟았을 때 올라가는 속도의 차이가 있겠지만 이를 위해 운전면허를 다시 따거나, 다시 공부할 일은 없을 것이다.

이와 같은 일을 하는 것이 추상화이다.
공통적인 사항을 뽑아 클래스로 만들고, 사용하는 것이다.

먼저 추상 클래스를 만들고, 추상 메서드를 사용해야한다.
라면 끓이기 예시를 보자.

public abstract class Ramen {

    public abstract void putWater();
    public abstract void putSoup();
    public abstract void waitTime();

}

라면을 끓이기 위한 대략적인 방법을 담은 클래스이다.
라면마다 조금씩 다르겠지만 물을 넣고, 스프를 넣고, 기다리면 된다.

이제 코드를 보자.
기존에 사용하던 클래스와 구조가 조금 다르다.
abstract라는 키워드를 사용해 클래스를 만들었고,
구현부가 없이 선언부만 있는 abstract 키워드가 붙은 메서드가 존재한다.

이는 추상 클래스와, 추상 메서드이다.

이러한 추상 클래스는 일반적인 방법으로는 사용하지 못한다. 사용 예시를 보자.

public class SinRamen extends Ramen {

    @Override
    public void putWater() {
        System.out.println("신라면은 물 500ml를 넣는다.");
    }

    @Override
    public void putSoup() {
        System.out.println("스프를 넣는다.");
    }

    @Override
    public void waitTime() {
        System.out.println("3분 후 완료!\n");
    }
}



public class Ansungtangmen extends Ramen {

    @Override
    public void putWater() {
        System.out.println("안성탕면은 물 550ml를 넣는다.");
    }

    @Override
    public void putSoup() {
        System.out.println("스프를 넣는다.");
    }

    @Override
    public void waitTime() {
        System.out.println("4분 후 완료!\n");
    }

}

먼저 라면의 종류를 생성한다.
예시는 신라면과 안성탕면이며, 라면 클래스를 상속받는다. (extends)
이곳에서 정확한 레시피를 작성한다.
이때 주의사항은 abstract 키워드가 적힌 메서드는 필수로 작성해야 한다. (구현)

public class Person {

    public static void main(String[] args) {
        SinRamen sinRamen = new SinRamen();
        sinRamen.putWater();
        sinRamen.putSoup();
        sinRamen.waitTime();

        Ansungtangmen ansungtangmen = new Ansungtangmen();
        ansungtangmen.putWater();
        ansungtangmen.putSoup();
        ansungtangmen.waitTime();
    }

}

이제 드디어 라면 끓이는 사람이다.
Ramen 클래스가 아닌 각각의 라면 클래스를 만들어서 선언했으며, 둘 다 동일한 메서드를 사용했으나, 다른 내용이 출력된다.


5-1-4) 다형성

다형성은 같은 자료형에 여러 객체를 대입함으로써 다른 결과를 얻어내는 성질이다.
즉 부모 클래스 타입의 참조변수는 자식 클래스의 인스턴스에 참조할 수 있다는 뜻이다.

예제를 통해 알아보자.

위 예제와 동일하며, 기존과 다른 조리방식인 불닭볶음면이 새롭게 출시되었다.

public class BuldakBokemmen extends Ramen {

    @Override
    public void putWater() {
        System.out.println("불닭볶음면은 물 600ml를 넣는다.");
    }

    public void pourWater() {
        System.out.println("물을 버린다.");
    }

    @Override
    public void putSoup() {
        System.out.println("스프를 넣는다.");
    }

    @Override
    public void waitTime() {
        System.out.println("5분 후 완료!\n");
    }

}

기존 방식과 다른 물을 버리는 행동이 추가되었다.
일반 라면은 물을 버리지 않으니 오버라이드가 아닌 새로운 메서드를 추가했다.

public class Person {

	public static void main(String[] args) {
		SinRamen sinRamen = new SinRamen();
		sinRamen.putWater();
		sinRamen.putSoup();
		sinRamen.waitTime();

		Ansungtangmen ansungtangmen = new Ansungtangmen();
		ansungtangmen.putWater();
		ansungtangmen.putSoup();
		ansungtangmen.waitTime();

		BuldakBokemmen buldakBokemmen = new BuldakBokemmen();
		buldakBokemmen.putWater();
		buldakBokemmen.pourWater();
		buldakBokemmen.putSoup();
		buldakBokemmen.waitTime();

        Ramen ramen = new BuldakBokemmen();
		ramen.putWater();
		ramen.pourWater(); // 사용불가
		ramen.putSoup();
		ramen.waitTime();
	}

}

마지막 라면 타입으로 불닭볶음면 타입의 객체를 생성한 것을 알 수 있다.
부모 타입의 메서드는 사용할 수 있으나, 자식 타입의 메서드는 사용하지 못한다.

헷갈린다면 이렇게 생각해보자.

  1. 불닭볶음면은 라면이다.
  2. 라면은 불닭볶음면이다.

1번은 정상적이지만, 2번은 조금 이상하는것이 느껴질 것 이다.

그렇다면 다형성은 어떤 경우에 사용될까
예제를 보자.

public class Person {

	public static void main(String[] args) {
		SinRamen sinRamen = new SinRamen();
		getRamen(sinRamen);
		
		Ansungtangmen ansungtangmen = new Ansungtangmen();
		getRamen(ansungtangmen);
	}

	public static void getRamen(Ramen ramen) {
		ramen.putWater();
		ramen.putSoup();
		ramen.waitTime();
	}

}

위의 예제보다 조금 더 객체지향적으로 작성한 코드이다.
getRamen 메서드의 인자는 Ramen이다.
그렇기 때문에 Ramen을 상속하고 있는 클래스는 모두 받을 수 있다.

그렇다면 이와 같은 경우 불닭볶음면의 물을 버리는 과정은 어떻게 구현할까?

public class Person {

	public static void main(String[] args) {
		SinRamen sinRamen = new SinRamen();
		getRamen(sinRamen);

		Ansungtangmen ansungtangmen = new Ansungtangmen();
		getRamen(ansungtangmen);

		BuldakBokemmen buldakBokemmen = new BuldakBokemmen();
		getRamen(buldakBokemmen);
	}

	public static void getRamen(Ramen ramen) {
		ramen.putWater();

		if(ramen instanceof BuldakBokemmen)
			((BuldakBokemmen) ramen).pourWater();

		ramen.putSoup();
		ramen.waitTime();
	}

}

먼저 참조변수간 형변환은 상속관계에 있을 때 가능하며,
기본형 형변환처럼 주소값을 변환하는 것이 아닌 객체의 타입 자체를 변경한다.

이때 instanceof 연산자를 사용해 형변환이 가능한지 확인할 수 있다.
ramen 참조변수가 BuldakBokemmen으로 형변환이 가능한지 boolean 으로 리턴을 받고, ramen 참조변수를 형변환 후 pourWater를 실행한다.


5-2) 객체지향 설계 원칙 5가지 (SOLID)

객체지향 설계 원칙 5가지의 앞글자를 따 SOLID라 부른다.

5-2-1) SRP : Single Responsibiliy Principal

단일책임원칙이다.
한 클래스, 메서드는 단 하나의 기능만을 책임져야 한다.
만약 여러개의 기능을 한 곳에서 사용한다면, 분리를 하는것이 좋다.

예시를 통해 알아보자.

public class Person {
 
    private int age;
    private String name; 

    /* 
        이것보단 아래의 코드로
    public void work() {
        System.out.println("일을 합니다.");
        System.out.println("휴식을 합니다.");
    }
     */

    public void work() {
        System.out.println("일을 합니다.");
    }

    public void rest() {
        System.out.println("휴식을 합니다.");
    }

    // Person 클래스에 맞지 않는 메서드
    public void price() {
        System.out.println("사과의 가격은 1000원입니다.");
    }

}

5-2-2) OCP : Open Closed Principal

개방-폐쇄원칙이다.
기존 코드를 변경하는 것은 폐쇄적이어야하지만, 새로운 코드를 추가하는 것은 개방적이어야 한다.

마치 자바의 코드가 JVM에 의해서 OS에 종속적이지 않은 것과 같다.


5-2-3) LSP : Liskov Substitution Principal

리스코프 치환원칙이다.
자식 타입은 언제나 부모타입으로 교체될 수 있어야 한다는 원칙이다.

다음은 정상적인 경우이다.

public class Ramen {}
public class SinRamen extends Ramen {}
public class Ansungtangmen extends Ramen {}
  • 신라면은 라면이다.
  • 안성탕면은 라면이다.

다음은 비정상적인 경우이다.

public class Parent {}
public class Son extends Parent {}
public class Daughter extends Parent {}

언뜻보면 이상하지 않은 구조이다.
아들과 딸이 부모를 상속받은 구조이기 때문에.

  • 아들은 부모님이다.
  • 딸은 부모님이다.

근데 풀어놓고 보면 이상한 점이있다.
이런 경우 리스코프 치환원칙을 위배하는 것이다.


5-2-4) ISP : Interface Segregation Principal

인터페이스 분리 원칙이다.
자신이 사용하지 않는 기능에 의존하지 않아야 한다는 원칙이다.

사용하지 않는 기능을 의존할 바에 여러개의 인터페이스를 만드는게 낫다 라는건데,
정리가 잘된 블로그가 있어 첨부한다.

defacto-standard 블로그


5-2-5) DIP : Dependency Inversion Principal

의존성 역전 원칙이다.
의존 관계를 맺을 때, 변화하기 쉬운것 보단 변화하기 어려운 것에 의존해야 한다는 원칙이다.

기본적으로 변화하기 쉬운것은 구현 된 클래스이며,
변화하기 어려운 것은 인터페이스 (혹은 추상클래스)이다.

error

쇼핑몰에 주문 시스템이 있다고 가정하자.
결제방식은 다양할텐데 위와 같이 주문 시스템이 카드결제에 의존한다면, 다른 결제를 하기 어려울 것이다.

때문에 아래와 같이 구현하도록 한다.

error

error

결제 방식이라는 인터페이스를 만든 후, 그 인터페이스를 구현한 카드, 휴대폰, 무통장 입금 클래스를 만든다.

결제방식이라는 인터페이스를 의존함으로써 만약 새로운 결제방식이 생기더라도 비즈니스 로직이 변경될 필요가 없어지게 된다.


Leave a comment