[Immutable Vs Mutable]

프로그램의 오류는 예기치 못한 동작으로 인해 발생하게 됩니다.
이 예기치못한 동작을 줄이고 예측 가능하게 동작시키려면 어떻게 설계해야 할까요??
 
자바는 생성된 이후 상태를 변경할 수 있는 Mutable Type과 그렇지 않은 Immutable Type이 존재합니다.
 
지금부터 각 타입의 특징과 장단점에 대해 알아보겠습니다.

Immutable Type (불변 타입)

Immutable Type은 생성된 후 상태를 유지하는 타입을 의미합니다.
대표적인 예시로 String 이 존재합니다.

String Class

 
 
value , coder 등등의 field가 final로 선언된 것을 볼 수 있습니다.

이를 통해 String Type을 불변객체로 만드는 것이죠
 
하지만 우리는 한가지 의문에 빠집니다.
값이 안바껴?? += 연산으로 값 바뀌던데??

String str="1234";
str+="5678";

불변객체 임에도 다음의 연산이 가능한 이유는 "1234" 값을 갖고있는 str의 값이 변하는게 아닌
"12345678" 값을 갖고있는 새로운 str 객체를 만들어 내는 것이기 떄문입니다.
 
사실상 아래와 같은 연산을 하는 것이죠

String str = new String("12345678");

(이러한 연산또한 막고싶다면 final String 으로 선언하면 됩니다)
 

Constant Value

static final int MAX_SIZE = 10;

상수또한 불변타입에 속합니다.
불변타입이기 떄문에 값을 변경하려고 시도하면 에러가 발생하는 것을 볼 수 있습니다.

MAX_SIZE=20; // 에러발생

불변타입을 사용한다면 상태변화를 막을수 있기 때문에 객체가 초기에 설정한 상태를 유지하게 되고
상태변화로 인한 버그를 막을 수 있게 됩니다.
 
또한 다른 메서드의 파라미터로 전달될 떄에도 다른 코드에 의해 수정될 염려가 없기 떄문에 안전하게 전달가능합니다.
뿐만 아니라 불변값임을 명시함으로서 더욱 이해하기 쉬운 코드를 작성할 수 있습니다.

public static int rangeSum(final int start, final int end) {

        int result = 0;

        for (int i = start; i <= end; i++) {
            result += i;
        }
        return result;
    }

시작지점부터 끝나는 지점까지의 정수합을 반환하는 메서드입니다.

시작지점과 끝나는 지점을 불변하게 만듦으로서 이해를 돕고 버그를 방지합니다.
 
불변타입은 변경할 수 없기 떄문에 호환성이 떨어지고 코드의 작성을 어렵게 만들지만 그로인한 버그또한 줄여줍니다.
따라서 견고한 프로그램을 작성하고 싶다면 불변타입을 사용하는 것이 좋습니다.
 
 

Mutable Type(가변 타입)

StringBuilder는 String과 비슷한 기능을 하지만 가변객체 입니다.

StringBuilder sb = new StringBuilder();
sb.append("1234");
sb.append("5678");
System.out.println(sb);

이 코드는 실제로 sb가 가지고 있는 값이 변경되는 것이죠
 

String str = sb.toString();
System.out.println(str);

StringBuilder는 toString을 통해서 String Type으로도 사용가능하고
객체를 새로 만들어 내는 것이 아니기 떄문에 성능측면에서도 String보다 뛰어납니다.
 
하지만 이러한 호환성이 견고한 프로그램 작성을 어렵게 만듭니다.

Risks Of Mutaion (가변위험성)

Mutable Type은 위에서 명시한 것 처럼 장점이 많습니다.
상태 변경이 가능하기 떄문에 호환성이 뛰어나며 이로인해 쉬운 코드를 작성할 수 있는것이죠
 
하지만 똑같은 이유로 단점이됩니다.
상태 변경이 가능하기 떄문에 프로그램 시작에서의 상태와 프로그램의 특정 시점에서의 상태가 다를 수 있기 떄문이죠
 
따라서 객체가 어떤 상태인지 이해하기 어려워지고 Contract를 강제하는데 어려움이 생깁니다.
예시를 통해 알아보겠습니다.
 

Passing Mutable values

 public static int sum(List<Integer> list) {

        int sum = 0;
        for (int num : list) {
            sum += num;
        }

        return sum;
    }

List요소의 합을 반환하는 메서드

  public static int sumAbsolute(List<Integer> list) {
        for (int i = 0; i < list.size(); i++) {
            list.set(i, Math.abs(list.get(i)));
        }
        return sum(list);
    }

List 요소의 절대값 합을 반환하는 메서드
 
Mutable Type을 인자로 받았기 떄문에 상태변경이 가능합니다.
또한 sumAbsolute 메서드는 기존에 작성한 sum메서드를 재사용하기 떄문에 효율적으로 보이기까지 합니다.
 
하지만 이 코드는 치명적인 버그를 발생시킬 것입니다.
sumAbsolute() 메서드를 호출한 후에는 List요소 값이 변하기 떄문입니다.

List<Integer> list = new ArrayList<>(Arrays.asList(1,2,3,-1,-2,-3));

System.out.println(sum(list));
System.out.println(sumAbsolute(list));
System.out.println(sum(list));

이 코드의 결과 값은 다음과 같습니다.
 

list가 가변객체이기 때문에 처음 정의한 list와 sumAbsolute를 호출한 뒤의 list의 정의가 달라집니다.
이런식으로 재사용이나 성능에서 장점을 가질 수 있지만 추적하기 어려운 버그를 일으킬 수 있습니다.

 

Aliasing Risk

Alias(동일한 참조를 갖는 변수)가 존재할때 문제가 생길 수 있습니다.

List<Integer> list = new ArrayList<>(Arrays.asList(1, 2, 3, -1, -2, -3));
List<Integer> list2 = list;

list2.add(5);
list2.add(6);
list2.add(7);

System.out.println(sum(list) + " " + sum(list2));

list2는 list와 동일한 참조를 갖기 떄문에 list2의 상태변화가 list에게도 영향을 주어 버그를 일으킬 수 있습니다.
 
 

Mutation undermines an iterator

 public static void removeUnderZero(List<Integer> list) {
        for (int i = 0; i < list.size(); i++) {
            if (list.get(i) < 0) {
                list.remove(i);
            }
        }
    }

0보다 작은 요소를 삭제하는 메서드입니다.

문제될 것이 없어보이지만 list가 가변적으로 변함으로서 요소가 삭제될시 size() 또한 가변적으로 변하게 됩니다.
따라서 결과값이 예상과 다르게 반환됩니다.

 
 

Mutable objects reduce changeability

가변객체의 사용은 코드를 변경하기 어렵게 만듭니다.
list의 sum에 대해서 불필요한 연산을 막고자 cache를 도입하는 상황을 가정하겠습니다.
 

public static Map<List<Integer>, Integer> cache = new HashMap<>();

    public static void main(String[] args) {

        List<Integer> list = new ArrayList<>(Arrays.asList(1, 2, 3));

        addToCache(list);

        System.out.println(cache.get(list));
    }

public static void addToCache(List<Integer> list) {
        if (cache.get(list) == null) {
            cache.put(list, sum(list));
        }
    }

문제없이 동작하는 것을 볼 수 있습니다.
 
하지만 코드가 추가된다면 어떨까요??

list.add(4);
list.add(5);

System.out.println(cache.get(list));

list의 상태가 변경되면서 cache가 정상적으로 동작하지 않게됩니다.
 
이렇게 가변객체는 논리적인 버그를 발생시킬 수 있습니다. 따라서 보다 안전한 방법으로 사용되야 합니다.
 

Use Producer

Producer는 기존객체에서 새로운 객체를 생성하는 것을 의미합니다.

  public static void sortList(List<Integer> list) {

        for (int i = 0; i < list.size(); i++) {
            for (int j = 0; j < list.size(); j++) {
                if (list.get(i) < list.get(j)) {
                    int temp = list.get(i);
                    list.set(i, list.get(j));
                    list.set(j, temp);
                }
            }
        }
    }

다음은 list를 오름차순으로 정렬하는 메서드입니다.
 
이 연산은 문제없이 동작하지만 list의 요소를 변화시킵니다. 이로인해 생기는 버그를 막기 위해 메서드를 변경하겠습니다.

 public static List<Integer> sortList(List<Integer> list) {

        List<Integer> result = new ArrayList<>(list);

        for (int i = 0; i < result.size(); i++) {
            for (int j = 0; j < result.size(); j++) {
                if (result.get(i) < result.get(j)) {
                    int temp = result.get(i);
                    result.set(i, result.get(j));
                    result.set(j, temp);
                }
            }
        }

        return result;
    }

새로운 List 객체를 반환하도록 만듦으로서 기존 list를 변화시키지 않아 버그발생을 줄일 수 있습니다.
(이런 방식을Defensive Copy라 합니다.)
 

Localize Value

변수가 사용되는 범위를 줄여야 합니다. 가변객체가 넓은 범위에서 사용될 수 있다면 (Static 이거나 메서드 자체가 거대하다면)
상태변화로 생기는 버그를 잡기 어려울 것입니다.
 
 
이 글에서는 Immutable Type 과 Mutable Type의 이해와 장단점에 대해 다뤘습니다.
도움이 되셨다면 좋겠습니다.

Specification

프로그램에서 터지는 버그는 대부분 동작에 대한 오해로 발생합니다.

이러한 동작의 오해를 줄이기 위한 대표적인 방법으로 Spec(명세)이 존재합니다.

또한 완성된 프로그램은 필수적으로 Spec을 갖고있어야 합니다.

 

그렇다면 Spec은 무엇을 의미하는 걸까요??

다음은 실제로 사용되는 BigInteger 클래스 Spec입니다.

Class BigInteger

public BigInteger add(BigInteger val)
Returns a BigInteger whose value is (this + val).
Parameters:
val - value to be added to this BigInteger.
Returns:
this + val

add 메서드의 Signature와 실행되는 동작에 대한 간단한 설명이 존재합니다.

 

이러한 명세를 통틀어 Spce이라 말합니다.

인터페이스에 존재하는 Abstract Method도  Signature를 명시해놓은 Spce의 일부로 볼 수 있습니다.

 

만약 Spec이 없다면 이 메서드가 어떤연산을 하는지 알 수 없으며 어떤 연산을 하는지 정해지지 않았기 떄문에
테스트코드 작성도 불가능해집니다.

 

또한 구현이 바뀔때마다 해당 메서드가 어떤 기능을 하는지 내부를 확인해야 알 수 있음으로 Spec 명시는 필수적입니다.

 

Contract

Contract(계약)은 클라이언트와 개발자의 책임을 의미합니다.

  • 개발자의 책임 : 구현을 완료하고 Spec을 명시합니다. 또 Spec만 따르면 해당 기능은 정상적으로 동작함을 책임집니다.
  • 클라이언트의 책임 : 개발자가 구현한 기능을 사용할때 Spec에 맞게 사용해야 한다.

이런 책임을 묶어 Contract라 지칭합니다. 또한 Contract의 개념을 적용시켜 PreCondition , PostCondition 이 만들어집니다.

 

PreCondition(전제조건) 은 클라이언트의 책임입니다.

만약 Spec이 다음처럼 명시돼있다면

Requirements : 중복이없는 배열 
Effects : 중복없는 배열을 오름차순 정렬함

사용자는 Spec에 맞게 중복이없는 배열을 인자로 넘겨주어야 합니다.

그렇지않으면 Spec을 위반하는것으로 결과값을 올바르지 않을 수 있습니다. (이 경우에는 보통 Exception을 발생시켜 줍니다)
이러한 조건을 PreCondition 이라 합니다.

 

반면에 PostCondition(사후조건)은 구현자의 책임입니다.

전제조건이 맞으면 올바른 값을 반환해야 한다는 것이죠

이러한 조건을 이용해 테스트코드 작성이 가능합니다.

 

Test Code

우리는 Spec이 왜 필요한지 어떤 역할을 하는지 알아봤습니다.

이를 이용해 Test Code를 작성해 보겠습니다.

Test Code에는 크게 2가지 가 존재합니다.

  1. BlackBox Test
    • 연산내부가 어떻게 구현됐는지 모름 , 오직 Spec에 대한 명시만으로 테스트 작성
  2. WhiteBox Test
    • 연산내부 구현의 지식으로 테스트 작성

각각의 테스트는 장단점이 존재합니다.

BlackBox Test는 코드의 내부를 몰라도 된다는 장점이 있지만

테스트 케이스를 설계할 때 누락된 기능이 있을 수 있습니다.

또한 주로 입력과 출력에 중점을 두는 테스트 방법입니다.

 

WhiteBox Test는 코드의 내부 구조와 논리를 이해하고 검증하는 방법입니다.

따라서 논리적 결함을 발견하기 용이하지만 구조를 이해하고 있어야 하기 때문에 코드에 대한 지식이 필요합니다.

BlackBox Test

  public static boolean inputPassword(String passWord) {

    String regex = "^[0-9]*";

        if (passWord.equals("")||!Pattern.matches(regex,passWord)) {
            return false;
        }
        return true;
    }

다음은 매우 간단한 연산이며 Spec은 다음과 같습니다.

Requirments : String Value
Effects :  비어있지 않은 숫자로만 이루어진 경우 True , 그외는 False 반환 

우리는 이 코드의 내부를 전혀 모르지만 Spec을 통해 테스트 가능합니다.

System.out.println(inputPassword("1234")); //  숫자로만 이루어짐 , true
System.out.println(inputPassword("asd")); // 영어로만 이루어짐 , false
System.out.println(inputPassword("a123")); // 문자 영어 섞임 , false
System.out.println(inputPassword("")); // 빈 문자열 , false


(지금은 출력을통해서 메서드의 반환값이 올바른지 확인했지만 실제로는 JUnit5 혹은 Assert를 사용하면 됩니다.)

WhiteBox Test

 public static int factorial(int n) {

     if (n < 0) {
            throw new IllegalArgumentException();
        }

        int result = 1;

        for (int i = 1; i <= n; i++) {
            result *= i;
        }
        return result;
    }

다음은 팩토리얼 연산을 하는 메서드입니다.

이 메서드의 Spec은 다음과 같습니다.

Requirments : 계산할 팩토리얼의 대상이 되는 0이상의 정수 
Effects : 정수의 팩토리얼 값 

Spec에는 정수값을 넣었을때

이 코드의 내부의 구현에 대한 지식이 없다면 다음과 같은 테스트만 작성 가능합니다.

try {
            factorial(-1);
        } catch (Exception e) {
            System.out.println("에러 체크");
}

매우 한정적인 테스트만 작성 가능함으로 WhiteBox Testing이 필요합니다.

assert factorial(5) == 120; // n이 양수인 경우 
assert factorial(0) == 0; // n이 0인 경우 

이 경우에 PreCondition 은 0이상의 정수를 의미하며
PostCondition은 올바른 팩토리얼 값을 의미합니다.

 

 

이 글에서는 Spec에 필요성 및 간단한 기초 활용법을 알아봤습니다.

 

이 글이 도움이 되셨으면 좋겠습니다.

Collection FramWork

자바는 Collection FramWork를 제공합니다

이 안에는 대부분의 자료구조가 존재합니다. List, Map, Set, Tree...

또 기본적으로 Collection은 Parameterized Type을 사용합니다.

 

Collection을 한마디로 정리하면 자료를 다루는 방법과 저장하는 구조가 다른 Abstract Data Type의 집합입니다.
따라서 용도에 따라 어떤 자료구조를 사용할 것인지 잘 이해했다면 Collection FrameWork를 이해했다고 볼 수 있습니다.

 

 

List

List Interface를 상속받는 구현체는 대표적으로 ArrayList, LinkedList 가 존재합니다.

둘다 같은 List를 상속받아 구현체로써 사용되지만 , 구현할떄 사용되는 Representation이 다릅니다.

이런식으로 Collection Interface를 상속받은 구현체는 구현시 사용되는 자료구조가 다르거나 정렬의 유무에서 차이가 나는 경우게 대부분입니다.

 

데이터 구조

  1. ArrayList 배열 사용
  2. LinkedList node구조 사용

이렇게 구조에서 차이가 나지만 실제 동작에서는 ArrayList가 할 수 있는 일은 LinkedList가 전부 할 수 있으며

LinkedList 가 할 수 있는 일은 ArrayList가 전부 수행 가능합니다.

 

그럼 왜 나눠서 쓰는 걸까요??
그냥 하나로 통일해서 쓰면 안될까요??

 

ArrayList는 배열을 사용합니다. 따라서 주소가 sequential하게 연결돼있습니다.

따라서 특정 index에 접근하는게 매우 빠릅니다 시작주소 + 3 하면 3번쨰 index에 접근 가능하게 되는 것이죠

반면에 원소를 더하거나 삭제하는 경우에는 배열의 요소들을 한 칸씩 뒤로 이동시키거나 앞으로 당겨야 하기 때문에 상당히 느린 연산을 수행하게됩니다.

 

LinkedList는 node 구조를 사용합니다 배열이 아님으로 주소가 sequential하게 연결돼있지 않습니다.
따라서 특정 index에 접근하는게 ArrayList에 비해 느립니다.

 

하지만 원소를 더하거나 삭제하는 연산을 할때 node들 사이에 연결만 이어주고 끊어주면 되기때문에 ArrayList보다 연산이 빠릅니다.

정리하자면 다음과 같습니다.

 

  • ArrayList : 리스트 크기 변경 안될때 , 데이터 접근이 빈번할 때
  • LinkedList : 리스트 크기 변경이 자주 일어날떄 , 요소의 삽입/삭제가 빈번할 때

이렇게 같은 List자료구조지만 구현돼있는 내부 구조가 다르기 떄문에 용도에 맞게 사용할 수 있어야 합니다.

 

 

Set

Set은 중복을 허용하지 않는 대표적인 자료구조입니다.

크게 HashSet , TreeSet 으로 나뉩니다.

Hash는 이름그대로 HashCode를 사용하며 Tree는 내부적으로 BinarySearchTree 구조를 사용합니다.

 

이점은 중복을 어떻게 확인할 것인지에 큰 차이가 생깁니다.

  • HashSet
    1. HashCode 비교
    2. Equals 비교
    3. 중복없음

 

  • TreeSet
    1. 요소들이 이미 정렬돼있음
    2. Tree를 순회하면서 직접 Element 비교 (Comparable Implements 필요)
    3. 중복없음

HashSet은 HashCode와 Equals만 이용하면 되기 떄문에 원소를 추가하는 연산이 빠릅니다.

반면에 TreeSet은 직접적인 순회를 해야하기 떄문에 HashSet에 비해 느립니다. 하지만 정렬된 상태를 유지하기 떄문에

 

정렬된 상태가 필요한 경우에는 TreeSet을 그렇지 않다면 HashSet쓰는게 일반적으로 유리합니다.

public class Main {

    public static void main(String[] args) {

        Set<Person> set = new HashSet<>();

        set.add(new Person(20, "홍길동"));
        set.add(new Person(20, "홍길동"));

        for (Person p : set) {
            System.out.println(p);
        }

    }
}

class Person {


    int age;
    String name;

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

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (o == null || getClass() != o.getClass()) {
            return false;
        }
        Person person = (Person) o;

        return age == person.age && Objects.equals(name, person.name);
    }

//    @Override
//    public int hashCode() {
//        return Objects.hash(age, name);
//    }

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

}

Equals 구현 , HashCode 주석처리

따라서 중복체크를 똑바로 못합니다.

 

 

HashCode의 주석처리를 지워주면 중복체크가 정상적으로 작동합니다.

public class Main {

    public static void main(String[] args) {

        Set<Person> set = new TreeSet<>();

        set.add(new Person(20, "홍길동"));
        set.add(new Person(20, "홍길동"));

        for (Person p : set) {
            System.out.println(p);
        }

    }
}

class Person implements Comparable<Person> {


    int age;
    String name;

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

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

    @Override
    public int compareTo(Person o) {

        return this.age - o.age;

    }
}

TreeSet은 해시코드와 Equals를 쓰지 않습니다.


대신 Comparable의 compareTo 를 사용합니다

지금은 age를 기준으로 compareTo가 구현이 돼있습니다.

public static void main(String[] args) {

        Set<Person> set = new TreeSet<>();

        set.add(new Person(20, "홍길동"));
        set.add(new Person(21, "홍길동"));

        for (Person p : set) {
            System.out.println(p);
        }

    }

이 코드에서는 나이가 다르면 이름이 같아도 다른 객체기 떄문에 2개가 들어갑니다.

 

Map

Map은 Key : Value 쌍으로 이루어져 있습니다.

여기서는 Key가 해시코드 역할을 합니다.

Map 자료구조도 대표적으로 HashMap , TreeMap 이 존재합니다.

또 내부적으로 HashMap은 배열과 연결리스트 기반 , Tree는 노드기반 구조를 사용합니다

기존과 똑같이 Hash는 HashCode , Equals 를 사용합니다.

public class Main {

    public static void main(String[] args) {

        Map<Person, Integer> map = new HashMap();

        map.put(new Person(20, "홍길동"), 1);
        map.put(new Person(20, "홍길동"), 2);


        for (Person p : map.keySet()) {
            System.out.println(p);
        }

    }
}

class Person {


    int age;
    String name;

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

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

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (o == null || getClass() != o.getClass()) {
            return false;
        }
        Person person = (Person) o;

        return age == person.age && Objects.equals(name, person.name);
    }

    @Override
    public int hashCode() {
        return Objects.hash(age, name);
    }


}

 

 

마찬가지로 TreeMap은 compareTo를 구현해 써야 합니다.

public class Main {

    public static void main(String[] args) {

        Map<Person, Integer> map = new TreeMap<>();

        map.put(new Person(20, "홍길동"), 1);
        map.put(new Person(20, "홍길동"), 2);


        for (Person p : map.keySet()) {
            System.out.println(p);
        }

    }
}

class Person implements Comparable<Person> {


    int age;
    String name;

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

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


    @Override
    public int compareTo(Person o) {
        return this.age - o.age;
    }
}

 

Collection FramWork를 이해하고 응용한다는 것은

자료구조를 용도에 맞게 문제 해결에 더 적합한 것으로 선택해서 사용할 수 있음을 의미합니다.

 

도움이 되셨다면 좋겠습니다.

Abstract Class Vs Interface

Abstract Class(추상클래스) 와 Interface(인터페이스)는 어떤 차이가 있을까요?

기능적인 차이점 부터 알아보겠습니다.

추상클래스

  1. 타입에 제한없는 변수
  2. 메서드 제한 없음
  3. extend KeyWord로 상속
  4. 다중 상속 불가능

인터페이스

  1. static final 상수 변수
  2. abstract method , default method
  3. implements KeyWord로 상속
  4. 다중 상속 가능

보다 다양한 차이점이 있지만 크게 4가지만 간추렸습니다.

 

여기서 중요한것은 단순히 기능적인 차이점이 아니라 어쨰서? 입니다

 

왜 이런 기능적인 차이가 있는걸까요?

지금부터 왜? 에 대해서 알아보겠습니다.

 

(이 글은 클래스의 Implementation과 Abstraction 개념을 알고있다는전제하에 작성된 글입니다.

따라서 관련된 내용이 궁금하신 분들은 다음을 참고해 주세요)

 

Abstraction(추상화) 기본 개념 - 1편

Abstraction(추상화)란? 자바에서는 추상클래스 , 추상메서드 , 추상화 등 "추상"이라는 말이 자주 쓰입니다. 또한 Abstract으로 선언된 클래스 , 메서드 등을 보고 추상화시켰다 합니다. 그렇다면 추상

masiljangajji-coding.tistory.com

 

클래스 설계와 Abstraction Barrier

클래스란 클래스란 특정한 타입을 지정하는것을 의미합니다. 우리는 이미 자바에서 제공하는 Primitive Type을 사용하고있습니다. (byte,short,int,long,folat,double,boolean,char) 이 Type은 각각 특정한 목적을

masiljangajji-coding.tistory.com

 

추상클래스 Vs 클래스

추상클래스는 기본적으로 클래스이기 떄문에 Implementation을 갖습니다.

또한 일반적인 클래스와 같이 특정한 타입을 정의하기 위해서 사용됩니다.

그럼 일반 클래스와 어떤 점이 다를까요?

일반 클래스와 차이점은 단 하나입니다. 이 타입이 필요한 연산을 구현하지는 않고 명시만 하여 간추린 것이죠

자바에서는 이를 Abstract method 라고 지칭합니다.

 

좀더 포괄적으로 이해하면 다음과 같습니다.

  • 일반클래스 : 이 타입은 Implementation을 갖고 이런 연산을 수행해 !
  • 추상클래스 : 이 타입은 Implementation을 갖고 이런 연산을 할수는 있어야 해 !

추상클래스는 미완성 설계도 , 미완성된 클래스 라고 이해하면 됩니다.

구현하지 않고 명시만 해놓은 연산을 extends받는 클래스에서 구현하여 추상클래스의 설계도를 완성시키는 것이죠

이점 외에는 클래스와 동일하기 때문에 당연히 변수와 메서드에 제한이 없습니다.

 

하지만 이것만으로 해결이 안되는 부분이 존재합니다 . 바로 다중상속이 불가능한 것입니다.

왜 클래스는 다중상속이 불가능 할까요??

이를 알아보겠습니다.

class Sparrow {
    int wing;
    int beak;
    int feather;
}

class Pigeon {
    int wing;
    int beak;
    int feather;
}

참새와 비둘기 타입을 정의했습니다.

참새와 비둘기 타입 모두 날개,부리,깃털을 가집니다.

또한 이것들은 각각의 타입에 대한 Implementation에 속합니다.

참새,비둘기 모두 Implementation을 갖기 떄문에 이 둘을 상속받는다면

두 클래스의 Implementation을 지켜줘야 합니다.

그렇다면 상속받은 클래스에서 사용되는 날개는 어떤 클래스의 날개일까요?


마찬가지로 상속받은 클래스에서 사용되는 깃털은 어떤 클래스의 깃털인가요??

또 부리를 이용해 불을 내뿜는 Dragon타입을 추가적으로 상속받는다면 어떤 클래스의 부리를 이용해 불을 내뿜을 건가요??

 

이런식으로 Implementation끼리의 충돌이 발생할 수 있습니다.

따라서 클래스는 원천적으로 다중상속이 불가능합니다.

그렇기 떄문에 상속할때도 extends(확장)라는 KeyWord를 사용합니다.

기존 클래스의 Implementation은 지키면서 추가적으로 살을 덧붙여 확장시켜 사용하라는 의미입니다.

동일한 논리로 미완성이기 댸문에 new를 통한 객체생성도 불가능 합니다.

 

인터페이스 Vs 추상클래스

앞서 말했듯 추상클래스는 Implementation과 연산에 대한 명시가 돼있는 클래스를 의미합니다.

그런데 이런 경우에는 어떨까요?

추상클래스의 추상화 정도가 점점 높아져 어떠한 필드변수도 갖지않고 연산에대한 명시만 돼있는 것입니다.

abstract class Dragon {

    abstract void fly(int wing);

    abstract void breath(int beak);

}

이 Dragon 추상클래스는 날수있는 기능과 불을뿜는 기능을 명시해놓은 명세서입니다.

별도의 Implementation을 갖지않는 클래스기 떄문에 다중상속이 되어도 문제가 발생하지 않습니다.

하지만 여전히 클래스의 범주에 속하기 떄문에 다중상속이 불가능합니다.

 

그래서 나온것이 바로 Interface입니다.

 

추상클래스의 추상화정도가 계속해서 증가하여 필드변수를 갖지않고 기능에대한 명시만 있을때는
다중상속의 문제점이 사라지기 떄문에 Interface라 지칭하고 다중상속이 가능하게끔 만든 것 입니다.

 

따라서 인터페이스는 Implementation을 갖지 않고 다중상속이 되어도 문제가없는 상수값과 기능에대한 명시만 가집니다.

 

같은 논리로 상속의 KeyWord도 extends가 아닌 implements(구현)을 사용합니다. 그럼 default method는 무엇인가요??

이것또한 인터페이스의 등장과 동일합니다.

인터페이스를 사용하다보니 어라?? 이런 연산은 다중상속되도 문제가 없을 것 같은데?? 라고 하는것을

default method로 만들어 놓은 것입니다.

 

이렇게 되면 해당 인터페이스를 구현한 클래스들이 그 메서드를 구현하지 않아도 되는 장점이 있습니다.

 

자바는 언어가 설계될떄 모든 연산을 클래스를 통해 해결이 가능하다 믿었습니다.
하지만 프로그래밍이 고도화 됨에 따라 요구사항이 증가했고 기존에 사용하던 클래스만으로는 극복불가능한 문제점들이 생기게 됐습니다.

 

따라서 클래스라는 타입이 갖는 태생적인 한계를 극복하기 위해 여러가지 기능을 덧붙여서 만들었다고 보면 될 것 같습니다.

 

도움이 되셨다면 좋겠습니다.

클래스란

클래스란 특정한 타입을 지정하는것을 의미합니다.

우리는 이미 자바에서 제공하는 Primitive Type을 사용하고있습니다. (byte,short,int,long,folat,double,boolean,char)

 

이 Type은 각각 특정한 목적을 가집니다.

  1. 정수를 표현하기 위한 byte,short,int ,long (1,2,4,8 byte로 표현가능한 크기가 다름)
  2. 실수를 표현하기 위한 float , double (4,8 byte로 표현가능한 크기가 다름)
  3. 참,거짓을 표현하기 위한 boolean
  4. 문자를 표현하기 위한 char

이렇게 타입은 특정한 목적을 가지고 설계됩니다.

 

우리가 만드는 클래스또한 타입을 정의하는 것으로 , 특정한 목적을 가지고 연산을 수행하게 됩니다.

따라서 "클래스를 작성한다"는 것은 '특정한 목적을 갖는 타입을 정의한다" 는 의미입니다.

클래스 설계

클래스를 만들떄 이 클래스를 정의하는 , 즉 근간이되는 것을 Implementation이라 합니다.

 

또한 Implementation은  Abstraction Barrier로 보호되어야 합니다. 유리수를 예시로 들겠습니다.

public class Main {

    public static void main(String[] args) {

        RationalNumber rationalNumber = new RationalNumber(1, 2);

        System.out.println(rationalNumber.numerator + "/" + rationalNumber.denominator);

    }
}

class RationalNumber {

    int numerator; // 분자
    int denominator; // 분모

    public RationalNumber(int numerator, int denominator) {
        this.numerator = numerator;
        this.denominator = denominator;
    }

}

이 클래스는 유리수라는 타입을 지정한 것입니다.

 

유리수의 정의는 정수들의 비로 나타낼수 있는 수를 의미합니다.

 

따라서 2개의 정수를 받아야 하며 이 정수가 유리수를 정의하기 떄문에 Implementation에 속합니다.

하지만 이 클래스는 치명적인 문제점을 갖습니다.

rationalNumber.denominator=5;
System.out.println(rationalNumber.numerator + "/" + rationalNumber.denominator);

이런식으로 언제든지 rationalNumber의 필드변수에 접근하여 값을 바꿀수 있습니다.

 

이렇게 된다면 기존에 정의했던 유리수에서 다른 유리수로 변경되게 됩니다.

 

이렇게 Implementation에 속하는 것들은 의도치 않게 변경된다면 기존의 정의와는 아예 달라지기 떄문에

Abstraction Barrier로 보호할 필요가 있습니다.

public class Main {

    public static void main(String[] args) {

        RationalNumber rationalNumber = new RationalNumber(1, 2);

        System.out.println(rationalNumber.getNumerator() + "/" + rationalNumber.getDenominator());

        //rationalNumber.denominator = 5; 오류발생 !

    }
}

class RationalNumber {

    private int numerator; // 분자
    private int denominator; // 분모

    public RationalNumber(int numerator, int denominator) {
        this.numerator = numerator;
        this.denominator = denominator;
    }

    public int getNumerator() {
        return numerator;
    }

    public int getDenominator() {
        return denominator;
    }

}

이 코드는 유리수를 정의하는 정수를 Abstraction Barrier로 감싼것으로
클래스의 필드변수에 바로 접근하는 것이 아니라 getter를 사용해 우회적으로 접근하게 합니다.

(getter또한 Implementation 에 속합니다.)

 

 

하지만 여전히 치명적인 문제점을 갖습니다. 유리수에 정의에 대해서 누락된 것들이 있기 떄문입니다.

  1. 유리수는 분모에 0이 들어올수 없다.
  2. 분모 분자의 비가 같다면 동일한 유리수다.
  3. 정수가 음수로 들어왔을때의 처리

이 조건들 또한 충족시킬수 있도록 보완하겠습니다.

 

1. 유리수는 분모에 0이 들어올수 없다.

RationalNumber rationalNumber = new RationalNumber(1, 0);
System.out.println(rationalNumber.getNumerator() + "/" + rationalNumber.getDenominator());

유리수는 분모에 0이 들어올수 없음에도 다음과 같이 동작하는 것을 볼 수 있습니다.

따라서 타입 정의에 맞게 바꿔주겠습니다.

public RationalNumber(int numerator, int denominator) {

        if (denominator == 0) {
            throw new IllegalArgumentException("올바른 분모 입력이 아닙니다");
        }

        this.numerator = numerator;
        this.denominator = denominator;
    }

생성자를 통해 객체가 만들어질떄 분모에 대한 제한조건을 거는 것 입니다.

이는 PreCondition을 체크하는 것과 동일합니다.

 

2. 분모 분자의 비가 같다면 동일한 유리수다.

RationalNumber rationalNumber = new RationalNumber(1, 2);
RationalNumber rationalNumber2 = new RationalNumber(2, 4);

System.out.println(rationalNumber.equals(rationalNumber2));

유리수에서 1/2와 2/4는 동일한 비를 같기떄문에 같은유리수지만 실행결과는 false인 것을 알 수 있습니다.

따라서 다음과 같이 수정하겠습니다.

 

public RationalNumber(int numerator, int denominator) {

        if (denominator == 0) {
            throw new IllegalArgumentException("올바른 분모 입력이 아닙니다");
        }

        this.numerator = numerator;
        this.denominator = denominator;

        nomarize();
    }


private void nomarize() {
        int number = gcd(this.numerator, this.denominator);

        this.numerator = this.numerator / number;
        this.denominator = this.denominator / number;
    }

    private int gcd(int number1, int number2) {

        if (number2 == 0) {
            return number1;
        }

        return gcd(number2, number1 % number2);
    }

@Override
public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (o == null || getClass() != o.getClass()) {
            return false;
        }
        RationalNumber that = (RationalNumber) o;
        return numerator == that.numerator && denominator == that.denominator;
    }

2번의 조건을 만족하기 위해 유리수를 기약분수의 형태로 만들어줬습니다.

 

기약분수형태로 만들기 위해 최대공약수를 찾는 gcd 알고리즘을 추가한후 nomarize를 통해 기약분수 꼴로 변경합니다.

이제 1/2 와 2/4 를 비교시 true 값이 나옵니다.

 

 

3. 정수가 음수로 들어왔을때의 처리

RationalNumber rationalNumber3 = new RationalNumber(-1, 2);
System.out.println(rationalNumber3.getNumerator() + "/" + rationalNumber3.getDenominator());

음수가 들어왔을때 출력되는 형식이 다른것을 알 수 있습니다.

따라서 이 양식을 맞춰주도록 하겠습니다.

private void nomarize() {

        int number = gcd(this.numerator, this.denominator);

        this.numerator = this.numerator / number;
        this.denominator = this.denominator / number;

        if (this.denominator < 0) {
            this.denominator *= -1;
            this.numerator *= -1;
        }

    }


@Override
    public String toString() {
        return numerator + "/" + denominator;
    }

이제부터는 음수가 들어갔을떄 모두 동일한 형식을 갖게 됩니다.

 

추가적으로 toString 메서드를 Override해 출력에 편의성을 증가시켰습니다.

지금은 getter를 이용한 다른 연산이 없기 떄문에 getter또한 삭제 가능합니다.

  public static void main(String[] args) {

        RationalNumber rationalNumber3 = new RationalNumber(-1, 2);
        RationalNumber rationalNumber4 = new RationalNumber(1, -2);
        RationalNumber rationalNumber5 = new RationalNumber(-16, -32);

        System.out.println(rationalNumber3);
        System.out.println(rationalNumber4);
        System.out.println(rationalNumber5);
        System.out.println(rationalNumber3.equals(rationalNumber4));
    }

이렇게 유리수 클래스를 설계해봤습니다.

 

클래스를 만든다는 것은 특정한 타입을 정의하는 것으로  이 타입을 만든 목적에 맞게 설계해줘야 합니다.

또한 이 타입의 가져서는 안되는 값들에 대해서도 충분한 고민이 필요합니다.

 

이외에도 간략하게 자동차 클래스를 설계한다면

 

자동차를 정의내리는 바퀴,핸들,이름 뿐만 아니라
자동차라는 타입이 갖는 필수적인 연산들 (가속 , 브레이크 등등)을 Implementation으로 두고 

타입을 설계해 나가면 될 것입니다. 

 

도움이 되셨다면 좋겠습니다.

Abstraction(추상화)란?

 

자바에서는 추상클래스 , 추상메서드 , 추상화 등 "추상"이라는 말이 자주 쓰입니다.

 

그렇다면 추상화란 무엇일까요?

 

추상화는 프로그래밍에서 매우 중요한 개념 중 하나이며 매우 큰 범위를 지칭하는 말로

데이터나 절차를 단순하게 표현하여 간추리는 것을 의미합니다.

 

또한 Abstraction을 추상화라고 직역하는 것도 상당히 어폐가 있음으로 추상화를 전부 Abstraction으로 지칭하겠습니다.

이제 Abstraction을 크게 2가지로 포괄적으로 이해해보겠습니다.

 

1. Data를 간략화 시킨 Data Abstraction

2. 절차를 간략화 시킨 Procedure Abstraction

(단순히 코드를 Extends하여 상속받는 것 또한 Code Abstraction에 속합니다.)

 

Data Abstraction

 

Data Abstraction은 Data를 다루고 저장하는 방법을 간략화 시킨 것 입니다.

예시로는 Array , List , Stack , Queue 와 같은 자료구조들이나 사용자가 정의하는 타입이 이에 해당합니다.

 

Stack과 Queue에 가장 큰 특징은 각각이 FILO , FIFO 구조를 띈다는 것입니다.

이 규칙을 지키기만 한다면 내부적으로는 Array로 구현하든 List로 구현하든 상관없이 Stack 과 Queue라는 자료구조로 지칭됩니다.

 

또한 Set처럼 중복을 허용하지 않는 구조나 Map처럼 특정 Key를 통해 1:1 Mapping되는 구조등 다양한 구조가 존재합니다.

이러한 자료구조는 Data를 다루는 방식을 명시하고 규칙으로 지정한 것입니다.

 

또 이러한 규칙을 가진 구조를 단순히 Stack , Queue 등의 타입으로 간략화 시켜 사용가능하게 만든것을 Data Abstraction이라 합니다.

 

이 구조가 내부적으로 어떻게 구현됐는지 몰라도 간단한 사용법만 알면 충분히 사용하게 만드는 것이죠.

 

만약 프로그램이 Set이라는 자료구조로 작성이 됐다면
프로그램의 코드를 확인하지 않더라도 중복을 허용하지 않는 프로그램이구나 ! 라고 충분히 유추가 가능합니다.

 

이렇게 사용자가 정확한 내부의 구현을 모르더라도 충분히 사용이 가능하고 유추가 가능하게 만드는 것 또한 Abstraction 이라 합니다.

 

Procedure Abstraction

 

Procedure Abstraction은 절차를 간략화 시키는 것입니다.

 

절차를 간략화 시킨다는 것은 사용자가 수동으로 처리해야 할 부분을 프로그래밍 언어에서 자체적으로 처리하게 만들거나
기능이 비슷한 연산들을 묶어서 하나로 표현하는 것을 의미합니다.

 

간단한 사칙연산 프로그램을 예시로 들어보겠습니다.

public class Main {

    public static void main(String[] args) {


        int number1 = 100;
        int number2 = 5;

        System.out.println(plus(number1, number2));
        System.out.println(minus(number1, number2));
        System.out.println(multiply(number1, number2));
        System.out.println(divide(number1, number2));

    }

    public static double plus(int a, int b) {
        return a + b;
    }

    public static double minus(int a, int b) {
        return a - b;
    }

    public static double multiply(int a, int b) {
        return a * b;
    }

    public static double divide(int a, int b) {
        return a / b;
    }

}

간단한 사칙연산 프로그램이며 큰 문제는 없어 보입니다.

하지만 자세히 보면 메서드들의 파라미터와 반환 타입이 동일합니다.

 

이점을 이용해 사칙연산 코드를 하나의 연산으로 묶는 Procedure Abstraction을 해보겠습니다.

SubTyping 을 이용한 Procedure Absraction

public class Main {

    public static void main(String[] args) {


        int number1 = 100;
        int number2 = 5;

        System.out.println(calc(new Add(), number1, number2));
        System.out.println(calc(new Minus(), number1, number2));
        System.out.println(calc(new Multi(), number1, number2));
        System.out.println(calc(new Divide(), number1, number2));

    }

    public static double calc(Calculator calculator, int a, int b) {
        return calculator.calc(a, b);
    }
}

class Add implements Calculator {

    @Override
    public double calc(double a, double b) {
        return a + b;
    }
}

class Minus implements Calculator {

    @Override
    public double calc(double a, double b) {
        return a - b;
    }
}

class Multi implements Calculator {

    @Override
    public double calc(double a, double b) {
        return a * b;
    }
}

class Divide implements Calculator {

    @Override
    public double calc(double a, double b) {
        return a / b;
    }
}


@FunctionalInterface
interface Calculator {
    double calc(double a, double b);
}

Calculator 라는 Interface를 지정한 후 implements 를 이용해 SubType관계를 만들어 줬습니다.

 

이 관계를 이용해 4개의 메서드로 분리돼있던 기능이 calc 메서드 하나로 묶인것을 볼 수 있습니다.

이런식으로 공통된 부분을 묶어주어 간추리는 방식을 Procedure Abstraction이라 합니다.

 

실제로 SubTyping 을 이용하는 테크닉은 매우 효율적이지만 이 예시에서는 클래스를 여러개 만들어 코드의 양이 많아지고

복잡해 보이는 문제점을 갖게됐습니다. 좋은 Procedure Abstraction은 아닌 것 이죠

 

따라서 이 코드를 다른 방법으로 Abstraction 하겠습니다.

 

Lambda 를 이용한 Procedure Abstraction

public class Main {

    public static void main(String[] args) {


        int number1 = 100;
        int number2 = 5;

        System.out.println(calc((a, b) -> a + b, number1, number2));
        System.out.println(calc((a, b) -> a - b, number1, number2));
        System.out.println(calc((a, b) -> a * b, number1, number2));
        System.out.println(calc((a, b) -> a / b, number1, number2));

    }

    public static double calc(Calculator calculator, int a, int b) {
        return calculator.calc(a, b);
    }
}


@FunctionalInterface
interface Calculator {
    double calc(double a, double b);
}

똑같은 역할을 하는 프로그램이지만 Lambda식을 사용함으로써 연산 절차를 간추리는 Abstraction과 코드의 재사용성을 늘려줬습니다.

 

이 글에서는 Abstraction에 대해서 간략하게 알아 봤습니다.

 

Abstraction은 프로그래밍을 더욱 효율적으로 만들어주는 중요한 원칙 중 하나입니다 .

또한 매우 큰 범위를 지칭하는 말이기 떄문에 이 글에서 사용된 것 외에도 더욱 많은 예시가 존재합니다.

 

더 많은 이점을 알고 싶다면 다음을 추천합니다Abstraction(추상화) 기본 개념 - 2편

 

Abstraction(추상화) 기본 개념 - 2편

Why Abstraction?? 우리는 이전의 글을 통해 Abstraction의 개념을 간략하게 이해했습니다. Abstraction(추상화) 기본 개념 - 1편 Abstraction(추상화) 기본 개념 - 1편 Abstraction(추상화)란? 자바에서는 추상클래스

masiljangajji-coding.tistory.com

 

도움이 되셨다면 좋겠습니다.

[Static Vs Dynamic] Series

Static은 정적이며 Dynamic은 동적이라는 의미를 담고 있습니다.
또한 자바에서는 Static이라는 키워드를 자주 볼 수 있습니다.

 

Ex)

public static void main(String[]args) 
public static int num =5; 

Static은 변수와 메서드에 하나의 키워드로 사용됩니다.

 

Static은 컴파일 시간에 동작한다는 개념입니다. 이를 통해 컴파일 시간에 어떤 동작이 일어날지 알 수 있습니다.

반면, Dynamic은 실행 시간에 뭔가가 동작한다는 개념입니다. 이는 프로그램이 실제로 실행될 때 동작이 결정된다는 의미입니다.

 

이런 개념을 기반으로 쓰임의 차이를 알아보겠습니다.

 

Static Typing Vs Dynamic Typing

가장 간단한 Static Typing에 대해 알아보겠습니다.

 

자바는 기본적으로 Static Typing을 사용하는 언어입니다.
Static Typing은 컴파일 시간에 변수의 타입이 결정되는 것입니다. 즉, 사용자는 변수를 선언할 때 타입을 지정해야 합니다.

 

변수를 선언할떄

num = 5 
num; 

이런식으로 Type없이 선언은 불가능합니다.

int num;
int num2= 5;

다음과 같은 형식으로 선언하게 되는 것이죠

 

반대로 Dynamic Typing을 하는 파이썬같은 언어도 존재합니다.

x = 10
y = "Hello"

이런식으로 자바에서는 불가능한 변수선언이 가능합니다.

 

 

Static Binding Vs Dynamic Binding

프로그램은 컴파일될떄 변수나 연산에 대해서 Binding하게 됩니다.

Binding 이란 값 또는 연산의 타입 또는 동작이 결정되는 것을 의미합니다.

예를 들어 코드를 작성하면

int x= 5; 

x=5 라는 Statement에서 x와 5가 Binding되어 x에 5라는 값을 할당하게 됩니다.


Binding에는 2가지가 존재합니다.

  1. Static Binding
  2. Dynamic Binding

여기서 말하는 Binding의 의미는 위에서 기술한 것과 동일합니다.

 

다른점은 앞에 Static, Dynamic이 붙은 것이죠

 

Static Binding은 컴파일 시간에 변수나 연산의 타입 또는 동작이 결정됩니다.
Dynamuc Binding은 런타임 시간에 변수나 연산의 타입 또는 동작이 결정됩니다.

 

자세히 말하면 Static Binding의 경우 컴파일러가 컴파일 과정 중에 어떤 메서드나 변수를 호출하는지 알고있는 것입니다.

 

 

다음은 자바의 System.out.println()메서드 입니다.

여러 타입에 대해서 오버로딩돼있는 것을 볼 수 있습니다.

 

사용자가 작성한 println메서드의 파라미터 타입은 컴파일 시간에 정해지며 어떤 println()을 사용할지 결정합니다.

컴파일 시간에 어떤 메서드가 호출될지 결정됨으로 Static Binding에 속합니다.

 

이 외에도 Private , Final , Static 메서드 혹은 변수는 Static Binding 하게 됩니다.

이 3가지는 전부 오버라이딩이 불가능하기 때문에 컴파일타임때 Binding 시켜도 문제가 없습니다.

 

반면에 다음의 코드는 다르게 동작합니다.

public class Main {

    public static void main(String[] args) {

        Animal animal = new dog();

        animal.print();

    }
}

class Animal{
    public void print() {
        System.out.println("It's Animal");
    }
}
class dog extends Animal{
    public void print() {
        System.out.println("It's Dog");
    }
}

class rat extends Animal{
    public void print() {
        System.out.println("It's Rat");
    }
}

class Cat extends Animal{
    public void print() {
        System.out.println("It's Cat");
    }
}

이 예시에서 animal객체는 Animal 타입으로 선언됐지만 실제 객체 타입은 Dog입니다.

따라서 animal.print() 가 호출될때 Animal의 print 메서드가 아닌 실제 타입의 print가 호출됩니다

이렇게 호출된 메서드는 실행시점에서 실제 객체의 타입에 따라 결정됩니다.

 

이를 Dynamic Binding 이라 합니다.

 

또한 Animal 의 경우 Apparent Type 

Dog의 경우 Actual Type이라 부릅니다.

 

(Static Binding 이 Dynamic Binding의 경우보다 약간 더 빠릅니다.)

 

 

Static Checking Vs Dynamic Checking

프로그래밍 언어는 기본적으로 에러에대한 체크를 합니다.

 

이는 거의 대부분의 언어가 동일하며 언어가 자체적으로 에러체크를 하지 않는 경우에 사용자의 부담이 매우 커질 것 입니다.

 

에러에대한 체크는 크게 3가지로 나뉩니다.

  1. Static Checking
  2. Dynamic Checking
  3. No Checking

 

No Checking은 에러체크를 해주지 않는 경우임으로 제외하고 나머지를 집중적으로 보겠습니다.

 

Static Checking 은 컴파일 타임에 잡히는 버그들을 말합니다. 예를 들어 다음과 같은 코드가 존재할떄

int num = 5;

int num = 4;

중복된 변수명을 사용하고 있기 때문에 프로그램을 돌리기도 전에 사용자에게 오류가 발생했음을 알려줄 것입니다.

 

또 다른 예시로

char ch = "abcdef"; 

이와 같은 코드를 작성해도 똑같이 문제가 생김을 바로 알 수 있습니다.

ch 라는 변수는 char 의 타입만 받아야하는데 String 타입을 받아 생기는 오류입니다.

혹은 메서드의 시그니처를 잘못 적은 경우에도 에러가 발생 합니다.

 

주로 Syntax(문법)자체에 오류가있는 경우에 Static Checking에 걸리게 됩니다.

 

Dynamic Checking은 Static과는 반대로 런타임시간(프로그램이 실제로 동작하는 시간)에 잡히는 에러들입니다 .

int number = 5;
int number2 = 0;

int result = number / number2;

System.out.println(result);

다음과 같은 코드가 있다면 , 이는 문법적으로는 아무런 문제가 존재하지 않습니다.

하지만 숫자를 0으로 나누게 됨으로써 ArithmeticException이 발생하게 됩니다.

이렇게 실질적으로 프로그램이 구동되어야 알 수 있는 것을 Dynamic Checking이라 합니다.

또 다른 예시로

String str = null;
System.out.println(str.length());

위의코드는 NullPointerException이 발생하게 됩니다.

이또한 Dynamic Checking에 속합니다.

자바에서의 Thread Safety

Concurrency Programing 이란 여러개의 작업을 동시에 처리하는 프로그래밍 방식입니다.

실제로 우리가 사용하는 대부분의 서비스들은 다중 프로세스 및 스레드를 사용해 동시에 여러 작업을 수행합니다.

 

하지만 여러 작업들이 동시성을 갖고진행되기 때문에 프로그램은 복잡해지며

다음과 같은 문제들을 야기할 수 있습니다.

  1. Race Condition 으로 인한 결과값 변동
  2. Deadlock , Concurrency Bugs 같은 버그
  3. Synchronized Overhead 같은 성능 문제

이 글에서는 위와같은 문제들을 해결하기 위한 Thread Safety를 집중적으로 다룰 것 입니다.

1. Thread Safety의 필요성

Thread Safety는 왜 필요할까요?

또 똑바로 지켜지지 않는다면 어떤 문제가 발생할 까요?

다음의 코드를 통해서 확인해보겠습니다.

public class Main {


    public static void main(String[] args) {

        Number number = new Number();

        Counter counter1 = new Counter(number, true);
        Counter counter2 = new Counter(number, false);

        counter1.start();
        counter2.start();

        while (counter1.isAlive() || counter2.isAlive()) {
        } // Thread종료 될떄 까지 기다리기


        System.out.println(counter1.getState() + " " + counter2.getState());
        System.out.println(number.count);


    }
}

class Number {

    int count;

    public Number() {
        this.count = 0;
    }

    public void increase() {
        this.count++;
    }

    public void decrease() {
        this.count--;
    }

}

class Counter extends Thread {


    private final Number number;

    private boolean flag;

    public Counter(Number number, boolean flag) {
        this.number = number;
        this.flag = flag;
    }


    @Override
    public void run() {

        for (int i = 0; i < 10000000; i++) {

            if (flag) {
                number.increase();
            } else {
                number.decrease();
            }
        }

    }
}

Counter를 통해서 Number 객체의 count를 증감시키는 코드입니다.

똑같은 Number 객체를 갖고있기 때문에 Number는 공유자원이 됩니다.

똑같은 횟수를 증감하기 떄문에 출력은 0이 되야 정상이지만 0이 아닌값이 출력됩니다.

왜 이런 일이 생기는 것일까요?

Multi Thread환경에서는 특정 자원(Shared Resources)을 공유하게 됩니다.

위의 코드에서는 Number가 공유 자원에 속합니다.

count 값이 5일때 동시에 increase , decrease 연산이 일어나는 경우를 생각해 보겠습니다.

  1. incrase,decrease 둘 다 5의 count 값 가짐
  2. increase 동작 전에 decrease 동작 완료 count 값 4 변경
  3. increase는 이미 count 값 5를 가지고 있음으로 6으로 증가
  4. 각각 한번의 increase , decrease 가 일어 났지만 결과 값은 5가 아닌 6

따라서 공유자원에 대한 접근을 제한하지 않는다면 , 즉 Thread Safety하게 만들지 않으면

예상치 못한 결과값이 만들어 질 수 있습니다. (Data Consistency 위반)

 

좀더 General한 상황에 대해 정리하면 특정 작업을 수행함에 있어 순서를 정해주지 않는다면 문제가 발생할 수 있는 경우에

Thread Safety 를 신경써줘야 합니다. 

 

또 공유 자원에 대한 접근이 이뤄지는 영역을 Critical Section(임계영역) 이라 부릅니다.

 

2. Thread Safety를 위한 방법

자바에서는 Thread Safety를 지키기 위해

크게 3가지 방법을 사용합니다.

1. Monitor

   Synchronized keyword 사용 

2. Mutex

Lock 인터페이스를 구현한 클래스 사용 (Lock 기반)

3. Semaphore

Binary Semaphroe , Counting Semaphroe

Semaphore 클래스 사용 (Signal 기반)

 

 

각각 무슨 차이가 존재할까요?

이제부터 알아보겠습니다.

 

 

 

 

3. Monitor 

모니터는 모니터 큐를 통해 공유 자원에 대한 작업들을 순차적으로 처리합니다.

 

만약 공유자원 A에 대해 다수의 Thread가 접근한다면 큐를 이용해 대기열에 집어넣은 후 

맨 앞의  스레드를 꺼내 공유자원에 대한 접근을 허용하는 방식입니다.

스레드의 작업이 끝난다면 대기열에 쌓여있는 스레드의 순서대로 순차적으로 작업이 일어납니다.

 

하나의 스레드만 공유자원에 접근 가능하기 때문에 상호 배제가 자동이며 구현또한 간단합니다.

뒤에서 말할 Mutex , Semaphore보다 저수준의 추상화를 나타냅니다.

 

자바에서는 Synchronized Keyword를 통해 사용됩니다.

 

Synchronized

 public synchronized void increase() { // number에 대한 모니터락 획득
        this.count++; 
    }

    public synchronized void decrease() { //number에 대한 모니터락 획득
        this.count--;
    }

위에서 사용된 코드지만 메서드에 Synchronized Keyword가 붙었습니다.

 

Synchronized가 붙음으로써 이 메서드를 호출시 객체 자체에 대해서 Monitor Lock이 걸립니다.

이렇게 메서드를 수정하고 난 후에는 count값이 정상적으로 0 이 나오는 것을 볼 수 있습니다.

 

메서드에 적용하는 방법 뿐 아니라 , 블록에도 적용 가능합니다.

@Override
    public void run() {

        for (int i = 0; i < 10000000; i++) {

            if (flag) {
                synchronized (number) { // number 모니터 락 획득
                    number.increase();
                }
            } else {
                synchronized (number) { // number 모니터 락 획득
                    number.decrease();
                }
            }
        }

    }

이렇게 사용한다면 객체 자체에 Monitor Lock이 걸리는 것이 아니라 Synchronized로 선언된 블럭에서만  

Monitor Lock을 획득해 접근을 제어하는것이 됩니다.

 

같은 Synchronized Keyword를 사용해도 메서드에 사용하는 것과 블럭에 사용하는것의 차이가 큼으로 유의해서 사용바랍니다.

 

 

4. Mutual Exclusion (Mutex)

 

Mutual Exclusion 은 둘 이상의 thread가 동시에 Critical Section에 접근하는 것을 막음을 의미합니다.

즉 공유자원에 대해서 한번에 하나의 thread만 접근하도록 만드는 것입니다.

 

일상생활의 예시를 들자면 변기가 하나인 화장실을 생각하면 됩니다.

변기(공유자원)에 대해서 한번에 한 사람만 사용이 가능합니다.

 

또 사용자는 Lock을 검으로써 (화장실문 잠금) 사용중임을 나타냅니다.

화장실을 전부 사용한 후에는 Unlcok 함으로써 아무나 사용가능한 상태로 변경합니다.

 

대표적으로 Lock Interface를 구현한 ReentrantLock가 존재합니다.

 

Lock

Lock Interface는 java.util.concurrent.locks.Lock 에 존재하며

동기화 문제를 해결하기 위한 메커니즘 중 하나입니다. 이를 사용하여 코드 블록을 동기화하고 잠금을 걸 수 있습니다.

주로 Lock 인터페이스를 구현한 클래스 중 하나인 ReentrantLock이 사용됩니다.

Lock lock = new ReentrantLock();

public void increase() {
        lock.lock(); // lock 획득 
        this.count++; // 보호받음
        lock.unlock(); // lock 해제
    }

    public void decrease() {
        lock.lock(); // lock 획득
        this.count--; // 보호받음
        lock.unlock(); // lock 해제
    }

 

 

여기서 사용된 lock() , unlock() 메서드 외에도 tryLock() , lockInterruptibly() , Condition을 통해서

synchronized 보다 더 유연하게 사용 가능합니다.

 

5. Semaphore

Semaphore는 일반화된 Mutex라고 생각하면 됩니다.

Mutex는 변기가 하나인 경우를 의미하고 , Semaphore는 변기가 1개 이상인 경우입니다.

 

java.util.concurrent.Semaphore 에 존재하며 , acquire() , release() 메서드를 통해 사용됩니다.

 

acquire() 라는 신호를 줌으로써 변기(공유자원)에 대한 사용을 알리고

사용이 끝난경우 release() 라는 신호를 주어 사용이 끝났음을 알립니다.

 

다음과 같이 사용될 수 있습니다.

import java.util.concurrent.Semaphore;

public class Main {


    public static void main(String[] args) {

        Number number = new Number();

        Counter[] counters = new Counter[5];

        for (int i = 0; i < 5; i++) {
            counters[i] = new Counter(number);
            counters[i].start(); // 5개의 스래드가 돌아가기 때문에 원래는 5씩 증가해야 한다. 
        }


        while (counters[0].isAlive() || counters[1].isAlive() || counters[2].isAlive() || counters[3].isAlive() ||
                counters[4].isAlive()) {

            try {
                Thread.sleep(500);
            } catch (InterruptedException e) {
                System.out.println(e.getMessage());
            }
            System.out.println(number.getCount());
        }

        System.out.println("종료");
    }


}

class Number {


    private Semaphore semaphore = new Semaphore(3); // 스래드 3개까지 동시 엑세스 허용

    public int getCount() {
        return count;
    }

    private int count;

    public Number() {
        this.count = 0;
    }

    public void increase() {
        try {
            semaphore.acquire(); // 스래드 엑세스 허용
            System.out.println("증가 시작");
            this.count++; 
            Thread.sleep(500);

        } catch (InterruptedException e) {
            System.out.println(e.getMessage());
        }
        semaphore.release(); // 스래드 엑세스 해제

    }

}

class Counter extends Thread {


    private final Number number;


    public Counter(Number number) {
        this.number = number;
    }


    @Override
    public void run() {

        for (int i = 0; i < 10000; i++) {
            number.increase();
        }

    }
}

원래라면 5개의 스래드가 돌아가고 있기 때문에

5씩 count가 증가해야 하지만 Semaphore를 이용해 3개의 스레드만 허용했음으로

3씩 증가하는 것을 볼 수 있습니다.

 

 

공유자원 접근을 허용할 스레드의 개수를 1로 하는 경우 Mutex와 동일한 기능을 하게 됩니다.

 

이 경우를 Binary Semaphore라 하며 위에서 사용한 3개의 스레드를 허용하는 경우를 Counting Semaphore라 합니다.

기능의 유사성 때문에 Binary Semaphore와 Mutex는 같다고도 말할 수 있지만

 

엄밀히 따지면 Mutex는 Lock기반이며 Semaphore는 Signal기반임으로 다르다고 할 수 있습니다.

 

이렇게 Thread Safety를 위한 동기화 기법을 알아봤습니다.

 

마지막으로 Monitor와 Semaphore를 사용하는

 

서점 예시를 통해 동기화 기법을 정리하고 마무리 하겠습니다.

https://github.com/masiljangajji/Thread_Safety_Library_Example

 

GitHub - masiljangajji/Thread_Safety_Library_Example: https://masiljangajji-coding.tistory.com/manage/newpost/38?type=post&retur

https://masiljangajji-coding.tistory.com/manage/newpost/38?type=post&returnURL=https%3A%2F%2Fmasiljangajji-coding.tistory.com%2Fmanage%2Fposts - GitHub - masiljangajji/Thread_Safety_Library_Exa...

github.com

서점 조건

  1. 서점에는 5개의 책이 존재한다.
  2. 생산자는 5개의 책중 하나를 랜덤하게 납품한다.
  3. 책은 이름과 번호를 가진다.
  4. 똑같은 종류의 책 재고는 최대 10권까지 가능하며 , 그 이상 부터는 납품을 미룬다.
  5. 재고가 없는 경우에는 출고(판매)하기 위해 납품을 기다린다.
  6. 생산자는 최대 3명까지 허용한다.
  7. 소비자는 책의 번호별로 구매가능하다.
    즉, 여러 사람이 각기 다른 번호의 책을 구매할 경우 동시 구매가 가능하다.

 

 

실행 결과

+ Recent posts