728x90
반응형
SMALL

데이터베이스에서 빼놓을 수 없는 개념인 Index. 이 내용을 정리해보고자 한다. 

우선 다음과 같이 데이터베이스에 데이터가 저장되어 있다고 가정해보자.

 

그리고 질문은 다음과 같다.

Q: age = 20인 행을 찾으려면?

 

간단한 쿼리를 통해 가져올 수 있다.

SELECT * FROM XXX WHERE age = 20;

 

이 쿼리를 날리면 컴퓨터는 age가 20인 행을 찾기위해 모든 행을 하나씩 다 뒤지게 된다. 뭐 지금처럼 레코드가 5개만 있으면 1초도 안 걸린다. 근데 1억개가 넘게 있으면 또는 그 이상 있으면 있을수록 느리게 동작할 것이다.

 

그러다가 어떤 특이점에 도달하게 되면 데이터베이스는 굉장히 무시무시한 일이 일어날 수 있다. 그렇기 때문에 이러한 특이점이 일어나기 전 컴퓨터에게 도움을 줄 수 있는데 그게 바로 index다.

 

index가 어떻게 동작하는지 쉽게 이해하기 위해 다음 그림을 보자.

1부터 100까지 숫자가 있는 카드가 있다고 치자. A와 B가 게임을 하는거다. A가 카드 한장을 뽑으면 B가 맞추는 형식이다.

이 때 B는 A한테 1이야? 2야? 3이야? 4야? ... 100이야? 이렇게 순차적으로 물어보는게 효율적일까? 물론 뽑은 카드가 1이라면 그럴 수 있겠지만 뽑은 카드가 100이라면 절대 아니다. B는 A한테 이런식으로 질문해야 더 적은 질문으로 더 빠른 해답을 찾을 수 있다.

"50보다 커?" - "75보다 작아?" 이렇게 중간지점에서 대소를 비교하는 식으로 말이다.

 

데이터베이스도 이런식으로 질문을 하면 더 효율적이지 않을까? 맞다. 그러나 여기서 전제조건이 있다.

순서대로 정렬이 되어 있어야 저렇게 반씩 날릴 수 있는 질문이 가능하다라는 것. 

 

그래서, 데이터베이스에서도 저렇게 질문을 하고 싶다면 같은 컬럼을 복사해서 순서대로 정렬해 놓음이 필요하다.

그리고 이 정렬해서 복사해둔 컬럼을 index라고 부른다.

 

근데, 이 정렬하는 방식이 궁금하다. 정말 순서대로 저렇게 정렬해서 index를 만들까?

그렇다면 다음과 같이 그냥 ArrayLinked List로 순서대로 정렬해도 될 것 같다.

 

그런데 실제 데이터베이스들은 index를 만들 때 이렇게 만들지 않고 Tree 형태로 만든다. 뭐 이렇게 말이지.

즉, 모든 데이터들을 다 가져와서 일렬로 순서대로 정렬하는게 아니라, 아무렇게나 흩뿌려져 있는 데이터들을 가지고 와서 이렇게 가지치기 형식으로 정렬을 한다. 이렇게 해도 반으로 갈라낼수가 있기 때문이다. 예를 들어, 다음과 같은 질문을 받았다고 하자.

 

Q: 저는 5가 어디 저장되어 있는지 알고 싶어요.

 

1. 그럼 가장 상단에 있는 4한테 물어본다. 5는 4보다 큽니까? Yes 

2. 위 질문에 Yes가 나왔으니 오른쪽으로 빠진다. 그리고 만난 6한테 물어본다. 6보다 작습니까? Yes

두 번의 질문으로 원하는 답을 찾게 된다.

 

결론은 데이터베이스에서 index를 만들라고 하면 트리형태로 위 그림처럼 만들어준다는 얘기다.

이를 전문용어로 Binary Search Tree라고 한다.

 

근데, 저기서 조금 더 개선시킬 방법이 보인다. 저 하나 하나의 카드를 Node라고 부르는데, 이 노드에 숫자 하나만을 담는게 아니라 숫자를 두 개씩 넣어버리면 데이터가 많아지면 많아질수록 더 시원하게 날려버릴 수 있지 않을까? 다음 그림처럼 말이다. 

이렇게 한 노드에 여러 데이터를 넣어서 한번에 많은 양의 필요없는 데이터를 쳐낼 수 있다.

예를 들어 또 같은 질문이 들어왔다고 가정해보자.

 

Q: 저는 5가 어디 저장되어 있는지 알고 싶어요.

 

1. 4/8 이 들어있는 최상단 노드에 5보다 큰가?를 물어봤을 때 4는 No, 8은 Yes를 답하게 되니 중간 다리로 내려간다.

2. 6한테 5보다 작니?를 물어봤을 때 No를 답하니 5를 찾게 된다. 

 

데이터가 위 사진보다 더 많아졌음에도 불구하고 똑같이 2번의 질문만으로 답을 찾아낼 수 있게 된다.

 

 

근데, 여기서 또 다른 방식이 있다. B+Tree라는 구조인데 이건 다음과 같이 생겼다.

이 구조는 데이터는 전부 가장 밑바닥에 존재하고 (여기서 가장 하단의 노드를 가리키는 말로 '리프노드'라고한다) 가이드 라인만 제공하는 형식이다. 이렇게 되더라도 여전히 같은 맥락으로 절반씩 쳐내는 게 가능하다. 똑같이 2번의 질문 만으로 데이터를 찾아낼 수 있다.

 

근데 이 B+Tree의 다른점은 하단에 데이터끼리도 연결을 해 둔다는 것이다.

이렇게 하단에도 연결을 해두면 뭐가 좋을까? 범위 검색이 쉬워진다. 예를 들어 4부터 8까지의 데이터를 가져오고 싶으면 연결된 선으로 4부터 8까지 쭉 가져오기만 하면 된다. 4만 찾으면 말이지. B Tree랑 비교하면 훨씬 더 우월한 검색이 가능하다. B Tree로는 범위 검색 시 가지를 왔다리 갔다리 해야한다. 

 

정리를 하자면

age = 20인 데이터를 찾아줘!

 

  • index가 없는 경우: 모든 행을 다 뒤져서 찾아냈을 때 돌려준다.
  • index가 있는 경우: 자동으로 index 컬럼부터 보고 적은 질문으로 더 빨리 찾아낸다. 찾아낸 후 인덱스에는 원래 행을 찾을 수 있는 주소가 있는데 그 주소를 통해 찾고자 하는 데이터를 돌려준다.

 

그러나, index가 장점만 있지는 않다.

index를 구현하면 어떤 단점이 있나? 같은 내용을 담는 컬럼을 복사한다. 그 말은 데이터베이스의 용량을 더 사용한다는 의미이다.

즉, index가 많아지면 많아질수록 더 많은 데이터베이스의 용량을 가져다 사용한다는 뜻이다. 또 다른 단점은 만약 원래 데이터베이스에 레코드가 삽입, 수정, 삭제가 된다면? 그 레코드를 인덱스에도 똑같이 삽입, 수정, 삭제의 과정을 겪어야 한다. (근데, 요즘은 뭐 컴퓨터 성능이 너무 좋아서 사실 이런것까지 고려할 필요가 있나 싶다)

 

참고: PK는 index 생성이 필요없다. 왜냐하면 자동으로 정렬이 되어 있기 때문에. 그리고 이를 clustered index라고 표현한다.

 

728x90
반응형
LIST

'Spring + Database' 카테고리의 다른 글

[Renewal] 예외  (0) 2024.12.05
[Renewal] 스프링의 트랜잭션  (0) 2024.11.24
[Renewal] 트랜잭션, DB 락 이해  (0) 2024.11.22
[Renewal] 커넥션풀과 DataSource 이해  (2) 2024.11.21
[Renewal] JDBC 이해  (2) 2024.11.20
728x90
반응형
SMALL

참고 자료:

 

김영한의 실전 자바 - 중급 1편 | 김영한 - 인프런

김영한 | 실무에 필요한 자바의 다양한 중급 기능을 예제 코드로 깊이있게 학습합니다., [사진]국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바

www.inflearn.com

 

날짜와 시간에 대한 작업은 굉장히 아주 많이 어렵고 까다롭다. 왜냐하면 고려할 사항이 무진장 많기 때문인데 윤년, 각 달의 일수, 일광 절약 시간(DST), 타임존(GMT, UTC, ...) 등 고려할 사항이 너무 많다. 그래서 그냥 개발자가 직접 날짜와 시간을 계산하면 99.9%의 확률로 잘못된 계산이 될 것이다.

 

그래서 이러한 복잡성 때문에 대부분의 현대 개발 환경에서는 날짜와 시간을 처리하기 위해 잘 설계된 라이브러리를 사용해야 한다. 자바의 날짜와 시간 라이브러리는 java.time 패키지다.

이 표가 자바가 제공하는 날짜와 시간 라이브러리의 축약버전이다. 다양한 클래스들이 있고 그 클래스가 표현하는 단위와 방식이 어떻게 되는지가 자세히 나와있다. 하나씩 자세히 알아보자.

 

LocalDate, LocalTime, LocalDateTime

LocalDate: 날짜만 표현할 때 사용한다. 년월일을 다룬다. 예) 2024-04-04

LocalTime: 시간만을 표현할 때 사용한다. 시분초를 다룬다. 예) 09:20:30.213

LocalDateTime: LocalDate와 LocalTime을 합한 개념이다. 예) 2024-04-04T09:20:30.213

 

앞에 Local(현지의)이 붙는 이유는 세계 시간대를 고려하지 않아서 타임존이 적용되지 않기 때문이다. 특정 지역의 날짜와 시간만 고려할 때 사용한다. 예) 국내 서비스만 고려하는 애플리케이션

 

ZonedDateTime, OffsetDateTime

ZonedDateTime: 시간대를 고려한 날짜와 시간을 표현할 때 사용한다. 여기에는 시간대를 표현하는 타임존이 포함된다.

  • 예) 2024-04-04T09:20:30.213+9:00[Asia/Seoul]
  • +9:00은 UTC(협정 세계시)로 부터의 시간대 차이이다. 오프셋이라고 하고 +0900으로도 잘 표현된다. 한국은 UTC보다 +9:00 시간이다.
  • Asia/Seoul을 타임존이라고 한다. 이 타임존을 알면 오프셋과 일광 절약 시간제에 대한 정보를 알 수 있다.
  • 일광 절약 시간제가 적용된다.

OffsetDateTime: 시간대를 고려한 날짜와 시간을 표현할 때 사용한다. 여기에는 타임존은 없고, UTC로부터의 시간대 차이인 고정된 오프셋만 포함한다.

  • 예) 2024-04-04T09:20:30.213+9:00
  • 일광 절약 시간제가 적용되지 않는다.

 

Asia/Seoul 같은 타임존 안에는 일광 절약 시간제에 대한 정보와 UTC+9:00와 같은 UTC로부터 시간 차이인 오프셋 정보를 모두 포함하고 있다. 일광 절약 시간제(DST, 썸머타임)를 알려면 타임존을 알아야 한다. 따라서 ZonedDateTime은 일광 절약 시간제를 함께 처리한다. 반면에 타임존을 알 수 없는 OffsetDateTime은 일광 절약 시간제를 처리하지 못한다. 

 

Year, Month, YearMonth, MonthDay

년, 월, 년월, 달일을 각각 다룰 때 사용하나 자주 사용하지는 않는다. DayOfWeek와 같이 월, 화, 수, 목, 금, 토, 일을 나타내는 클래스도 있다. 

 

Instant

Instant는 UTC(협정 세계시)를 기준으로 하는, 시간의 한 지점을 나타낸다. Instant는 날짜와 시간을 나노초 정밀도로 표현하며, 1970년 1월 1일 0시 0분 0초(UTC)를 기준으로 경과한 시간으로 계산된다. 쉽게 이야기해서 Instant 내부에는 초 데이터만 들어있다.

 

Period, Duration

시간의 개념은 크게 2가지로 표현될 수 있다.

  • 특정 시점의 시간(시각)
    • 이 프로젝트는 2024년 4월 20일까지 완료해야해
    • 다음 회의는 10시에 진행한다
  • 시간의 간격(기간)
    • 이 프로젝트는 3개월 남았어
    • 라면은 3분동안 끓어야 해

Period, Duration은 시간의 간격(기간)을 표현하는데 사용된다.

 

Period

두 날짜 사이의 간격을 년, 월, 일 단위로 나타낸다

 

Duration

두 시간 사이의 간격을 시, 분, 초(나노초) 단위로 나타낸다.

 

 

LocalDate, LocalTime, LocalDateTime 생성

모든 날짜 클래스는 불변이다. 따라서 변경이 발생하는 경우 새로운 객체를 생성해서 반환하므로 반환값을 꼭 받아야 한다.

LocalDate

생성

  • now(): 현재 시간을 기준으로 생성한다.
  • of(...): 특정 날짜를 기준으로 생성한다. 년, 월, 일을 입력할 수 있다.

계산

  • plusDays(): 특정 일을 더한다. 다양한 plusXxx() 메서드가 존재한다.

LocalTime

생성

  • now(): 현재 시간을 기준으로 생성한다.
  • of(...): 특정 날짜를 기준으로 생성한다. 시, 분, 초를 입력할 수 있다.

계산

  • plusSeconds(): 특정 초을 더한다. 다양한 plusXxx() 메서드가 존재한다.

LocalDateTime

생성

  • now(): 현재 시간을 기준으로 생성한다.
  • of(...): 특정 날짜와 시간을 기준으로 생성한다.

분리

  • 날짜(LocalDate)와 시간(LocalTime)을 toXxx() 메서드로 분리할 수 있다.

합체

  • LocalDateTime.of(localDate, localTime) 메서드로 날짜와 시간을 사용해서 LocalDateTime을 만든다.

계산

  • plusSeconds(): 특정 초을 더한다. 다양한 plusXxx() 메서드가 존재한다.

비교

  • isBefore(): 다른 날짜시간과 비교한다. 현재 날짜와 시간이 이전이라면 true를 반환한다.
  • isAfter(): 다른 날짜시간과 비교한다. 현재 날짜와 시간이 이후라면 true를 반환한다.
  • isEquals(): 다른 날짜시간과 날짜시간적으로 동일한지 비교한다. 시간이 같으면 true를 반환한다.

isEquals() vs equals()

  • isEquals()는 단순히 비교 대상이 날짜시간적으로 같으면 true를 반환한다. 객체가 다르고, 타임존이 달라도 시간적으로 같으면 true를 반환한다. 쉽게 이야기해서 날짜시간을 계산해서 날짜시간만으로 둘을 비교한다. 예) 서울의 9시와 UTC의 0시는 시간적으로 같다. 이 둘을 비교하면 true를 반환한다.
  • equals()는 객체의 타입, 타임존 등등 내부 데이터의 모든 구성요소가 같아야 true를 반환한다. 예) 서울의 9시와 UTC의 0시는 시간적으로 같다. 이 둘을 비교하면 타임존의 데이터가 다르기 때문에 false를 반환한다.

 

ZonedDateTime

"Asia/Seoul" 같은 타임존 안에는 일광 절약 시간제에 대한 정보와 UTC+9:00와 같은 UTC로부터 시간 차이인 오프셋 정보를 모두 포함하고 있다. 자바는 타임존을 ZondId 클래스로 제공한다.

public class ZonedDateTimeMain {
    public static void main(String[] args) {
        Set<String> availableZoneIds = ZoneId.getAvailableZoneIds();
        for (String availableZoneId : availableZoneIds) {
            System.out.println("availableZoneId = " + availableZoneId);
        }

        ZoneId zoneId = ZoneId.systemDefault();
        System.out.println("SystemDefaultZoneId = " + zoneId);
        System.out.println("zoneId.getRules() = " + zoneId.getRules());
    }
}

 

실행결과:

....
availableZoneId = America/Winnipeg
availableZoneId = Europe/Vatican
availableZoneId = Asia/Amman
availableZoneId = Etc/UTC
availableZoneId = SystemV/AST4ADT
availableZoneId = Asia/Tokyo
availableZoneId = America/Toronto
availableZoneId = Asia/Singapore
availableZoneId = Australia/Lindeman
availableZoneId = America/Los_Angeles
availableZoneId = SystemV/EST5EDT
availableZoneId = Pacific/Majuro
availableZoneId = America/Argentina/Buenos_Aires
availableZoneId = Europe/Nicosia
availableZoneId = Pacific/Guadalcanal
availableZoneId = Europe/Athens
availableZoneId = US/Pacific
availableZoneId = Europe/Monaco
SystemDefaultZoneId = Asia/Seoul
zoneId.getRules() = ZoneRules[currentStandardOffset=+09:00]

 

ZoneId.systemDefault() 메서드는 시스템이 사용하는 기본 ZoneId를 반환한다. 현재 내 PC의 ZoneId는 Asia/Seoul이다.

ZoneId는 내부에 일광 절약 시간 관련 정보, UTC와의 오프셋 정보를 포함하고 있다.

 

ZonedDateTime

ZonedDateTime은 LocalDateTime에 시간대 정보인 ZoneId가 합쳐진 것이다.

ZonedDateTime 클래스

public class ZonedDateTime {
     private final LocalDateTime dateTime;
     private final ZoneOffset offset;
     private final ZoneId zone;
}

 

시간대를 고려한 날짜와 시간을 표현할 때 사용한다. 여기에는 시간대를 표현하는 타임존이 포함된다.

예) 2013-11-21T18:20:30.213+9:00[Asia/Seoul]

+9:00는 UTC(협정 세계시)로 부터의 시간대 차이이다. 오프셋이라고 한다.

ZoneId를 통해 타임존을 알면 일광 절약 시간제에 대한 정보도 알 수 있다. 따라서 일광 절약 시간제가 적용된다.

 

public class ZonedDateTimeMain {
    public static void main(String[] args) {
        ZonedDateTime zdt = ZonedDateTime.now();
        System.out.println("zdt = " + zdt);

        LocalDateTime ldt = LocalDateTime.of(2030, 1, 1, 13, 30, 50);
        ZonedDateTime zdt2 = ZonedDateTime.of(ldt, ZoneId.of("Asia/Seoul"));
        System.out.println("zdt2 = " + zdt2);

        ZonedDateTime zdt3 = ZonedDateTime.of(2030, 1, 1, 13, 30, 50, 0, ZoneId.of("Asia/Seoul"));
        System.out.println("zdt3 = " + zdt3);

        ZonedDateTime utc = zdt3.withZoneSameInstant(ZoneId.of("UTC"));
        System.out.println("utc = " + utc);
    }
}

실행결과:

zdt = 2024-04-04T16:52:47.055743+09:00[Asia/Seoul]
zdt2 = 2030-01-01T13:30:50+09:00[Asia/Seoul]
zdt3 = 2030-01-01T13:30:50+09:00[Asia/Seoul]
utc = 2030-01-01T04:30:50Z[UTC]

 

위 코드에서 보면 생성하는 방법이 역시 2가지다. now(), of(...).

 

그리고 타임존을 변경할 수도 있다. withZoneSameInstant(ZoneId) 메서드를 사용하면 타임존을 변경한다. 타임존에 맞추어 시간도 함께 변경된다. 이 메서드를 통해 지금 서울 시간으로 다른 나라는 몇 시 인지 확인할 수 있다.

 

OffsetDateTime

OffsetDateTime은 LocalDateTime에 UTC 오프셋 정보인 ZoneOffset이 합쳐진 것이다.

 

OffsetDateTime 클래스

public class OffsetDateTime {
     private final LocalDateTime dateTime;
     private final ZoneOffset offset;
}

시간대를 고려한 날짜와 시간을 표현할 때 사용한다. 여기에는 타임존은 없고, UTC로 부터의 시간대 차이인 고정된 오프셋만 포함한다.

예) 2013-11-21T08:20:30.213+9:00

ZoneId가 없으므로 일광 절약 시간제가 적용되지 않는다.

public class OffsetDateTimeMain {
    public static void main(String[] args) {
        OffsetDateTime now = OffsetDateTime.now();
        System.out.println("now = " + now);

        LocalDateTime localDateTime = LocalDateTime.of(2023, 1, 1, 13, 30, 50);
        System.out.println("localDateTime = " + localDateTime);

        OffsetDateTime offsetDateTime = OffsetDateTime.of(localDateTime, ZoneOffset.of("+01:00"));
        System.out.println("offsetDateTime = " + offsetDateTime);
    }
}

 

실행 결과:

now = 2024-04-04T19:54:45.743425+09:00
localDateTime = 2023-01-01T13:30:50
offsetDateTime = 2023-01-01T13:30:50+01:00

 

ZoneOffset은 +01:00처럼 UTC와의 시간 차이인 오프셋 정보만 보관한다.

 

ZoneDateTime vs OffsetDateTime

- ZoneDateTime은 구체적인 지역 시간대를 다룰 때 사용하며, 일광 절약 시간을 자동으로 처리할 수 있다. 사용자 지정 시간대에 따른 시간 계산이 필요할 때 적합하다.

- OffsetDateTime은 UTC와의 시간 차이만을 나타낼 때 사용하며, 지역 시간대의 복잡성을 고려하지 않는다. 시간대 변환 없이 로그를 기록하고, 데이터를 저장하고 처리할 때 적합하다.

 

참고로, 이 두가지는 글로벌 서비스를 하지 않으면 잘 사용하지 않는다.

 

Instant (기계 중심의 시간)

Instant는 UTC(협정 세계시)를 기준으로 하는, 시간의 한 지점을 나타낸다. Instant는 날짜와 시간을 나노초 정밀도로 표현하며, 1970년 1월 1일 0시 0분 0초(UTC 기준)를 기준으로 경과한 시간으로 계산된다. 쉽게 이야기해서 Instant 내부에는 초 데이터만 들어있다. (나노초 포함). 따라서 날짜와 시간을 계산에 사용할 때는 적합하지 않다.

 

Instant 클래스

public class Instant {
     private final long seconds;
     private final int nanos;
     ...
}
  • UTC 기준 1970년 1월 1일 0시 0분 0초라면 seconds에 0이 들어간다.
  • UTC 기준 1970년 1월 1일 0시 0분 10초라면 seconds에 10이 들어간다.
  • UTC 기준 1970년 1월 1일 0시 1분 0초라면 seconds에 60이 들어간다. 

참고 - Epoch 시간

Epoch time 또는 Unix timestamp는 컴퓨터 시스템에서 시간을 나타내는 방법 중 하나이다. 이는 1970년 1월 1일 00:00:00 UTC부터 현재까지 경과된 시간을 초 단위로 표현한 것이다. 즉, Unix 시간은 1970년 1월 1일 이후로 경과한 전체 초의 수로, 시간대에 영향을 받지 않는 절대적인 시간 표현 방식이다. 참고로 Epoch라는 뜻은 어떤 중요한 사건이 발생한 시점을 기준으로 삼는 시작점을 뜻하는 용어다. Instant는 바로 이 Epoch 시간을 다루는 클래스이다.

 

Instant 특징

  • 장점
    • 시간대 독립성: Instant는 UTC를 기준으로 하므로, 시간대에 영향을 받지 않는다. 이는 전 세계 어디서나 동일한 시점을 가리키는데 유용하다.
    • 고정된 기준점: 모든 Instant는 1970년 1월 1일 0시 0분 0초 UTC를 기준으로 하기 때문에 시간 계산 및 비교가 명확하고 일관된다.
  • 단점
    • 사용자 친화적이지 않음: Instant는 기계적인 시간 처리에는 적합하지만, 사람이 읽고 이해하기에는 직관적이지 않다. 예를 들어, 날짜와 시간을 계산하고 사용하는데 필요한 기능이 부족하다.
    • 시간대 정보 부재: Instant에는 시간대 정보가 포함되어 있지 않아, 특정 지역의 날짜와 시간으로 변환하려면 추가적인 작업이 필요하다.

사용 예 (물론, 글로벌 서비스를 다룬다고 가정할 때 의미가 생긴다)

  • 전 세계적인 시간 기준 필요 시: Instant는 UTC를 기준으로 하므로, 전 세계적으로 일관된 시점을 표현할 때 사용하기 좋다. 예를 들어, 로그 기록이나, 트랜잭션 타임스탬프, 서버 간의 시간 동기화 등이 이에 해당한다.
  • 시간대 변환 없이 시간 계산 필요 시: 시간대의 변화 없이 순수하게 시간의 흐름(예: 지속 시간 계산)만을 다루고 싶을 때 Instant가 적합하다. 이는 시간대 변환의 복잡성 없이 시간 계산을 할 수 있게 해준다.
  • 데이터 저장 및 교환: 데이터베이스에 날짜와 시간 정보를 저장하거나, 다른 시스템과 날짜와 시간 정보를 교환할 때 Instant를 사용하면, 모든 시스템에서 동일한 기준점(UTC)을 사용하게 되므로 데이터의 일관성을 유지하기 쉽다.
public class InstantMain {
    public static void main(String[] args) {
        Instant now = Instant.now();
        System.out.println("now = " + now);

        ZonedDateTime zdt = ZonedDateTime.now();
        System.out.println("zdt = " + zdt);

        Instant from = Instant.from(zdt);
        System.out.println("from = " + from);

        // Epoch Time에서 0초 경과 후 시간
        Instant epochStart = Instant.ofEpochSecond(0);
        System.out.println("epochStart = " + epochStart);

        // Epoch Time에서 3600초(1시간) 경과 후 시간
        Instant later = epochStart.plusSeconds(3600);
        System.out.println("later = " + later);

        // Epoch Time 으로부터 얼마나 지났나요? 
        long laterEpochSecond = later.getEpochSecond();
        System.out.println("laterEpochSecond = " + laterEpochSecond);
    }
}

 

실행결과:

now = 2024-04-04T13:27:02.390809Z
zdt = 2024-04-04T22:27:02.413119+09:00[Asia/Seoul]
from = 2024-04-04T13:27:02.413119Z
epochStart = 1970-01-01T00:00:00Z
later = 1970-01-01T01:00:00Z
laterEpochSecond = 3600

 

생성

  • now(): UTC를 기준 현재 시간의 Instant를 생성한다.
  • from(): 다른 타입의 날짜와 시간을 기준으로 Instant를 생성한다. 참고로 Instant는 UTC를 기준으로 하기 때문에 시간대 정보가 필요하다. 따라서 LocalDateTime은 사용할 수 없다.
  • ofEpochSecond(): 에포크 시간을 기준으로 Instant를 생성한다. 0초를 선택하면 에포크 시간인 1970년 1월 1일 0시 0분 0초로 생성된다.

계산

  • plusSeconds(): 초를 더한다. 초, 밀리초, 나노초 정도만 더하는 간단한 메서드가 제공된다.

조회

  • getEpochSecond(): 에포크 시간인 UTC 1970년 1월 1일 0시 0분 0초를 기준으로 흐른 초를 반환한다.
  • 여기서는 앞서 에포크 시간에 3600초를 더했기 때문에 3600이 반환된다.

 

Instant 정리

  • 조금 특별한 시간, 기계 중심, UTC 기준
  • 에포크 시간으로부터 흐른 시간을 초 단위로 저장
  • 전세계 모든 서버 시간을 똑같이 맞출 수 있음. 항상 UTC 기준이므로 한국에 있는 Instant, 미국에 있는 Instant의 시간이 똑같음
  • 서버 로그, epoch 시간 기반 계산이 필요할 때, 간단히 두 시간의 차이를 구할 때
  • 단점: 초 단위의 간단한 연산 기능, 복잡한 연산 못함
  • 대안: 날짜 계산 필요하면 LocalDateTime 또는 ZonedDateTime 사용

 

기간, 시간의 간격 Duration, Period

위에서 Duration, Period의 차이를 얘기했었다. 둘 다 시간의 간격(기간)을 표현하는데 사용하는데 표현하는 방식이 다르다.

 

Period

두 날짜 사이의 간격을 년, 월, 일 단위로 나타낸다. (예: 이 프로젝트는 3개월 남았다)

 

Duration

두 시간 사이의 간격을 시, 분, 초(나노초)단위로 나타낸다 (예: 라면을 끓이는 시간은 3분이다)

 

Period

public class Period {
     private final int years;
     private final int months;
     private final int days;
}

다음과 같이 가지고 있는 필드가 년, 월, 일로 있다.

 

Period를 사용해서 원하는 기간만큼 날짜에 더하거나 뺄 수 있고, 두 날짜 사이의 기간도 구할 수 있다.

public static void main(String[] args) {

    Period period = Period.ofDays(10);
    System.out.println("period = " + period);

    LocalDate ld = LocalDate.of(2030, 1, 1);
    LocalDate plusDate = ld.plus(period);
    System.out.println("ld = " + ld);
    System.out.println("plusDate = " + plusDate);

    LocalDate startDate = LocalDate.of(2023, 1, 1);
    LocalDate endDate = LocalDate.of(2023, 4, 2);
    Period between = Period.between(startDate, endDate);
    System.out.println("기간 = " + between.getMonths() + "개월" + between.getDays() + "일");
}

실행결과:

period = P10D
ld = 2030-01-01
plusDate = 2030-01-11
기간 = 3개월1일

 

 

Duration

두 시간 사이의 간격을 시, 분, 초(나노초)로 나타내고 다음과 같이 클래스가 구성되어 있다.

public class Duration {
     private final long seconds;
     private final int nanos;
}

내부에서 초를 기반으로 시, 분, 초를 계산해서 사용한다.

 

Duration을 사용해서 특정 시간에 시분초값을 더하거나 뺄 수 있으며, 두 시간 사이의 간격도 시분초로 나타낼 수 있다.

public static void main(String[] args) {

    Duration duration = Duration.ofMinutes(10);
    System.out.println("duration = " + duration);

    LocalTime lt = LocalTime.of(1, 0);
    System.out.println("기준 시간 = " + lt);

    LocalTime plusLt = lt.plus(duration);
    System.out.println("더한 시간 = " + plusLt);

    LocalTime start = LocalTime.of(9, 0);
    LocalTime end = LocalTime.of(10, 0);
    Duration between = Duration.between(start, end);
    System.out.println("차이 = " + between.getSeconds() + "초");
    System.out.println("근무 시간 = " + between.toHours() + "시간 " + between.toMinutesPart() + "분");
}

실행결과:

duration = PT10M
기준 시간 = 01:00
더한 시간 = 01:10
차이 = 3600초
근무 시간 = 1시간 0분

 

 

날짜와 시간의 핵심 인터페이스

시간은 크게 두가지 분류를 할 수 있다.

  • 특정 시점의 시간
  • 시간의 간격

둘을 표현하기 위해서 사용한 클래스들은 다음과 같다.

  • 특정 시점의 시간: LocalDateTime, LocalDate, LocalTime, ZonedDateTime, OffsetDateTime
  • 시간의 간격: Duration, Period

벌써 느낌이 둘로 인터페이스가 나뉠 것 같고 인터페이스를 구현한 구현체가 저들일거란 생각이 든다. 그래서 명세를 제공하고 그 명세를 어떤방식으로 구현하던 사용하는 클라이언트는 변경을 할 필요가 없게끔 설계했을것만 같은 생각이 든다. 맞다.

 

 

TemporalAccessor

  • 날짜와 시간을 읽기 위한 기본 인터페이스
  • 이 인터페이스는 특정 시점의 날짜와 시간 정보를 읽을 수 있는 최소한의 기능을 제공한다.

Temporal

  • TemporalAccessor의 하위 인터페이스로, 날짜와 시간을 조작(추가, 빼기 등)하기 위한 기능을 제공한다. 이를 통해 날짜와 시간을 변경하거나 조정할 수 있다.

간단히 정리하자면, TemporalAccessor는 읽기 전용 접근을, Temporal은 읽기와 쓰기(조작) 모두를 지원한다.

 

 

여기에 추가적으로 알아야 할 인터페이스가 있다. 

 

시간의 단위시간 필드.

 

시간의 단위TemporalUnit이다.

근데 시간의 단위가 뭐야?

초, 분, 시간 단위를 말한다고 생각하면 된다. 코드로 보면 바로 이해가 될 것이다.

 

TemporalUnitMain

import java.time.temporal.ChronoUnit;

public class TemporalUnitMain {
    public static void main(String[] args) {

        ChronoUnit[] values = ChronoUnit.values();
        for (ChronoUnit value : values) {
            System.out.println("value = " + value);
        }
    }
}

코드를 보면, TemporalUnit을 구현한 ChronoUnit의 values() 메서드를 사용한다.

즉, ENUM 클래스란 이야기다. 찍어보면 다음과 같이 나온다.

실행결과:

value = Nanos
value = Micros
value = Millis
value = Seconds
value = Minutes
value = Hours
value = HalfDays
value = Days
value = Weeks
value = Months
value = Years
value = Decades
value = Centuries
value = Millennia
value = Eras
value = Forever

 

시간 단위(ChronoUnit)로 시간 계산하기

그래서 이 단위를 이용해서 원하는 시간 단위로 시간의 계산이 가능하다.

System.out.println("시간 단위 = " + ChronoUnit.HOURS);
System.out.println("1시간을 초 단위로 = " + ChronoUnit.HOURS.getDuration().getSeconds());

System.out.println("일 단위 = " + ChronoUnit.DAYS);
System.out.println("1일을 초 단위로 = " + ChronoUnit.DAYS.getDuration().getSeconds());

LocalTime lt1 = LocalTime.of(1, 10, 0);
LocalTime lt2 = LocalTime.of(1, 20, 0);

long secondsBetween = ChronoUnit.SECONDS.between(lt1, lt2);
System.out.println("두 시간의 차이를 초 단위로 = " + secondsBetween);

long millisBetween = ChronoUnit.MILLIS.between(lt1, lt2);
System.out.println("두 시간의 차이를 밀리초 단위로 = " + millisBetween);

long minBetween = ChronoUnit.MINUTES.between(lt1, lt2);
System.out.println("두 시간의 차이를 분 단위로 = " + minBetween);

실행결과:

시간 단위 = Hours
1시간을 초 단위로 = 3600
일 단위 = Days
1일을 초 단위로 = 86400
두 시간의 차이를 초 단위로 = 600
두 시간의 차이를 밀리초 단위로 = 600000
두 시간의 차이를 분 단위로 = 10

 

이런게 시간의 단위 TemporalUnit이란 인터페이스.

그리고 그 인터페이스를 구현한 ChronoUnit.

 

 

그럼 시간 필드는 진짜 뭐야?

이건 개념이 조금 헷갈리는데 코드를 보면 또 이해가 된다.

 

ChronoFieldMain

import java.time.temporal.ChronoField;

public class TemporalFieldMain {
    public static void main(String[] args) {

        ChronoField[] values = ChronoField.values();
        for (ChronoField value : values) {
            System.out.println("value = " + value);
        }
    }
}

이 또한, 열거형인 클래스 ChronoField.

찍어보면 다음과 같이 나온다.

실행 결과:

value = NanoOfSecond
value = NanoOfDay
value = MicroOfSecond
value = MicroOfDay
value = MilliOfSecond
value = MilliOfDay
value = SecondOfMinute
value = SecondOfDay
value = MinuteOfHour
value = MinuteOfDay
value = HourOfAmPm
value = ClockHourOfAmPm
value = HourOfDay
value = ClockHourOfDay
value = AmPmOfDay
value = DayOfWeek
value = AlignedDayOfWeekInMonth
value = AlignedDayOfWeekInYear
value = DayOfMonth
value = DayOfYear
value = EpochDay
value = AlignedWeekOfMonth
value = AlignedWeekOfYear
value = MonthOfYear
value = ProlepticMonth
value = YearOfEra
value = Year
value = Era
value = InstantSeconds
value = OffsetSeconds

 

그러니까 이게 필드로 제공이 되는데, DayOfMonth란 필드를 예시로 들면 그 달의 며칠을 말하는 것.

다음 코드를 보자.

System.out.println("일년의 개월 범위 " + ChronoField.MONTH_OF_YEAR.range());
System.out.println("한 달의 일 수 범위 " + ChronoField.DAY_OF_MONTH.range());

실행 결과:

일년의 개월 범위 1 - 12
한 달의 일 수 범위 1 - 28/31

이런게 필드라고 생각하면 된다. 이걸 어떻게 사용할 수 있을까?

 

ChangeTimeMain

public class ChangeTimeMain {
    public static void main(String[] args) {

        LocalDateTime localDateTime = LocalDateTime.of(2030, 1, 1, 13, 30, 59);
        System.out.println("시간 필드의 Year = " + localDateTime.get(ChronoField.YEAR));
        System.out.println("시간 필드의 연도를 기준으로 월 = " + localDateTime.get(ChronoField.MONTH_OF_YEAR));
        System.out.println("시간 필드의 달을 기준으로 일 = " + localDateTime.get(ChronoField.DAY_OF_MONTH));
        System.out.println("시간 필드의 하루를 기준으로 시간 = " + localDateTime.get(ChronoField.HOUR_OF_DAY));
        System.out.println("시간 필드의 시간을 기준으로 분 = " + localDateTime.get(ChronoField.MINUTE_OF_HOUR));
        System.out.println("시간 필드의 분을 기준으로 초 = " + localDateTime.get(ChronoField.SECOND_OF_MINUTE));

    }
}

예를 들면 초를 나타낼 때 100초, 1000초로도 표현이 가능하지만, 1시간에 정해진 초는 0초 ~ 59초 사이이다. 이걸 표현하고 싶을 때 사용한다.

실행결과:

시간 필드의 Year = 2030
시간 필드의 연도를 기준으로 월 = 1
시간 필드의 달을 기준으로 일 = 1
시간 필드의 하루를 기준으로 시간 = 13
시간 필드의 시간을 기준으로 분 = 30
시간 필드의 분을 기준으로 초 = 59

 

근데, 저렇게 잘 안쓰고 편의 메서드를 사용한다. 그니까 저것을 가지고 이런 편의 메서드를 제공한다고 보면 된다.

System.out.println("getYear = " + localDateTime.getYear());
System.out.println("getMonthValue = " + localDateTime.getMonthValue());
System.out.println("getDayOfMonth = " + localDateTime.getDayOfMonth());
System.out.println("getHour = " + localDateTime.getHour());
System.out.println("getMinute = " + localDateTime.getMinute());
System.out.println("getSecond = " + localDateTime.getSecond());

실행결과:

getYear = 2030
getMonthValue = 1
getDayOfMonth = 1
getHour = 13
getMinute = 30
getSecond = 59

 

 

물론, 없는 편의 메서드도 있다.

getMinuteOfDay, getSecondOfDay 이런건 없다.

System.out.println("일 단위를 기준으로 분 = " + localDateTime.get(ChronoField.MINUTE_OF_DAY));
System.out.println("일 단위를 기준으로 초 = " + localDateTime.get(ChronoField.SECOND_OF_DAY));
일 단위를 기준으로 분 = 810
일 단위를 기준으로 초 = 48659

 

그래서, 지금까지 시간의 기간(간격)을 더하고 빼는 여러 방법을 다 나열해보자.

LocalDateTime localDateTime = LocalDateTime.of(2030, 1, 1, 13, 30, 59);

LocalDateTime plusDt1 = localDateTime.plus(10, ChronoUnit.YEARS);
System.out.println("plusDt1 = " + plusDt1);

LocalDateTime plusDt2 = localDateTime.plusYears(10);
System.out.println("plusDt2 = " + plusDt2);

Period period = Period.ofYears(10);
LocalDateTime plusDt3 = localDateTime.plus(period);
System.out.println("plusDt3 = " + plusDt3);

실행결과:

plusDt1 = 2040-01-01T13:30:59
plusDt2 = 2040-01-01T13:30:59
plusDt3 = 2040-01-01T13:30:59

 

여기서 핵심은 이것이다.

TemporalAccessor.get(), Temporal.plus()와 같은 인터페이스를 통해 특정 구현 클래스와 무관하게 일관성있는 시간 조회, 조작 기능을 제공한다는 것. plus() 메서드를 보라. 10이라는 int, Period 타입, ChronoUnit까지 전부 다 받아준다.

 

물론, 모든게 다 되는건 아니다. 다음 코드는 에러가 난다.

LocalDate now = LocalDate.now();
int minute = now.get(ChronoField.SECOND_OF_MINUTE);
System.out.println("minute = " + minute);

실행결과:

Exception in thread "main" java.time.temporal.UnsupportedTemporalTypeException: Unsupported field: SecondOfMinute
	at java.base/java.time.LocalDate.get0(LocalDate.java:698)
	at java.base/java.time.LocalDate.get(LocalDate.java:641)
	at time.ChangeTimeMain.main(ChangeTimeMain.java:41)

당연히 LocalDate 타입은 시분초에 대한 정보는 없기 때문에 필드 SECOND_OF_MINUTE으로는 값을 가져올 수 없다.

그래서 이런 문제를 예방하기 위해 TemporalAccessor와 Temporal 인터페이스는 이 타입이 특정 시간 단위나 필드를 사용할 수 있는지 확인할 수 있는 메서드도 제공한다.

TemporalAccessor

boolean isSupported(TemporalField field);

Temporal

boolean isSupported(TemporalUnit unit);

 

그래서 아래 코드처럼 안전하게 확인 후 시간을 가져오거나 가져오지 않거나 할 수 있다.

LocalDate now = LocalDate.now();
boolean supported = now.isSupported(ChronoField.SECOND_OF_MINUTE);
if (supported) {
    int minute = now.get(ChronoField.SECOND_OF_MINUTE);
    System.out.println("minute = " + minute);
}

 

날짜와 시간 조회하고 조작하기 (중요⭐️)

날짜와 시간을 조작하고 조회하는 경우를 은근 많이 만난다. 그래서 중요하다.

우선 날짜와 시간을 조작하는 with() 메서드를 사용해보자.

 

ChangeTime2Main

import java.time.LocalDateTime;
import java.time.temporal.ChronoField;

public class ChangeTime2Main {
    public static void main(String[] args) {
        LocalDateTime ldt = LocalDateTime.of(2020, 1, 1, 13, 30, 59);
        System.out.println("ldt = " + ldt);

        LocalDateTime newLdt = ldt.with(ChronoField.YEAR, 2030);
        System.out.println("newLdt = " + newLdt);

        LocalDateTime newLdt2 = ldt.withYear(2030);
        System.out.println("newLdt2 = " + newLdt2);
    }
}

 

with() 메서드를 사용하면 원하는 필드에 대해 날짜와 시간을 조작할 수 있다.

실행결과:

ldt = 2020-01-01T13:30:59
newLdt = 2030-01-01T13:30:59
newLdt2 = 2030-01-01T13:30:59

근데 이제 with() 메서드를 사용하려면 필드를 사용하는게 귀찮으니까 편의 메서드를 제공한다. withYear()가 그 예시이다. 

그리고 불변객체에 대해서 공부할 때 새로운 객체를 만들어낼 때 메서드의 이름을 통상 withXxx() 라고 말했는데 여기가 딱 그 대표적인 예시이다.

 

근데 이제 이런 경우가 있다. 정해진 LocalDateTime을 기준으로 다음으로 오는 금요일이라던지, 해당 달의 마지막 일요일이라던지 이런값을 구하고 싶을 때가 있다. TemporalAdjuster 인터페이스를 사용을 하면 된다.

 

원래라면, 이 인터페이스를 직접 구현한 구현체가 필요하겠지만 자바는 이미 필요한 구현체들을 TemporalAdjusters 라는 구현클래스에 구현체를 다 모아두었다. 이걸 사용하면 된다.

 

ChangeTime2Main 일부

LocalDateTime now = LocalDateTime.now();

LocalDateTime nextFriday = now.with(TemporalAdjusters.next(DayOfWeek.FRIDAY));
System.out.println("nextFriday = " + nextFriday);

LocalDateTime lastSundayInMonth = now.with(TemporalAdjusters.lastInMonth(DayOfWeek.SUNDAY));
System.out.println("lastSundayInMonth = " + lastSundayInMonth);

실행결과:

nextFriday = 2024-04-19T10:51:35.605616
lastSundayInMonth = 2024-04-28T10:51:35.605616

 

기가 막힌다. 지금까지는 직접 구하고 그랬는데 아는게 힘인게 역시 맞다..

그리고 이 클래스가 제공하는 주요 메서드들은 다음과 같다.

 

TemporalAdjusters 클래스의 주요 메서드

아래표에서 파라미터는 설명을 위해 몇개만 써넣은거지 안 써넣은게 없다는 뜻은 아니다.

메서드 설명
dayOfWeekInMonth(int ordinal, DayofWeek dayOfWeek) 주어진 요일이 몇 번째인지에 따라 날짜를 조정한다. 예를 들어, 파라미터에 (2, DayOfWeek.MONDAY) 이렇게 들어가면 2번째 주 월요일을 반납한다.
firstDayOfMonth 해당 월의 첫번째 날로 조정한다.
firstDayOfNextMonth 해당 월의 다음달 첫번째 날로 조정한다.
firstDayOfNextYear 다음 년도 첫번째 날로 조정한다.
firstDayOfYear 이번 년도 첫번째 날로 조정한다.
firstInMonth(DayOfWeek dayOfWeek) 첫번째 주의 주어진 요일로 조정한다. 예를 들어, 파라미터에 (DayOfWeek.TUESDAY)이렇게 들어가면 첫번째 주 화요일을 반환한다.
lastDayOfMonth 해당 월의 마지막 날로 조정한다.
lastDayOfNextMonth 다음 달의 마지막 날로 조정한다.
lastDayOfNextYear 다음 년도 마지막 날로 조정한다.
lastDayOfYear 이번 년도 마지막 날로 조정한다.
lastInMonth(DayOfWeek dayOfWeek) 주어진 요일 중 해당 월의 마지막 요일로 조정한다.
next(DayOfWeek dayOfWeek) 주어진 요일 이후의 가장 가까운 요일로 조정한다.
nextOrSame 주어진 요일 이후의 가장 가까운 요일로 조정하되, 현재 날짜가 주어진 요일인 경우 현재 날짜를 반환한다.
previous 주어진 요일 이전의 가장 가까운 요일로 조정한다.
previousOrSame 주어진 요일 이전의 가장 가까운 요일로 조정하되, 현재 날짜가 주어진 요일인 경우 현재 날짜를 반환한다.

 

 

날짜와 시간 문자열 파싱과 포맷팅

이게 용어를 정리하면 뭔가 좀 확 익숙해진다.

 

  • 포맷팅: 날짜를 원하는 형태의 문자로 변경 (날짜 -> 문자)
  • 파싱: 문자를 날짜로 변경 (문자 -> 날짜)

FormatterAndParseMain

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

public class FormatterAndParseMain {
    public static void main(String[] args) {

        LocalDateTime now = LocalDateTime.of(2024, 4, 14, 11, 16, 59);
        System.out.println("now = " + now);
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy년 MM월 dd일");

        String formattedDateTime = now.format(formatter);
        System.out.println("formattedDateTime = " + formattedDateTime);
    }
}

실행결과:

now = 2024-04-14T11:16:59
formattedDateTime = 2024년 04월 14일

 

이렇게 원하는 형태의 문자열로 날짜를 문자로 변경할 수 있다. 이것을 포맷팅이라고 한다.

 근데 그 때 형태가 중요하다. MM월을 mm월로 사용할 수 없다. 정해진 규칙이 있기 때문에.

mm이라고 소문자로 표현하면 분을 의미하게 된다.

https://docs.oracle.com/javase/8/docs/api/java/time/format/DateTimeFormatter.html#patterns

 

DateTimeFormatter (Java Platform SE 8 )

Parses the text using this formatter, without resolving the result, intended for advanced use cases. Parsing is implemented as a two-phase operation. First, the text is parsed using the layout defined by the formatter, producing a Map of field to value, a

docs.oracle.com

이번엔 문자열을 날짜와 시간 타입으로 변경해보자.

FormatterAndParseMain 일부

DateTimeFormatter formatter2 = DateTimeFormatter.ofPattern("yyyy년 MM월 dd일 HH시 mm분 ss초");
String dateTimeString = "2020년 11월 03일 11시 20분 15초";
LocalDateTime parsedDateTime = LocalDateTime.parse(dateTimeString, formatter2);

System.out.println("dateTimeString = " + dateTimeString);
System.out.println("parsedDateTime = " + parsedDateTime);

실행결과:

dateTimeString = 2020년 11월 03일 11시 20분 15초
parsedDateTime = 2020-11-03T11:20:15

 

문자열로 된 날짜와 시간을 날짜와 시간 타입으로 변경했다. 

포맷팅과 파싱을 보면 알겠지만 패턴은 형식이 그대로 일치해야 한다! 

그니까, 파싱할 때 패턴을 "yyyy년 MM월 dd일 HH:mm:ss" 이렇게 설정했는데 문자열이 "yyyy년 MM월 dd일 HH시 mm분 ss초" 이렇게 모양새가 다르면 파싱 못한다.

 

 

 

728x90
반응형
LIST

'JAVA의 가장 기본이 되는 내용' 카테고리의 다른 글

중첩 클래스(정적 중첩 클래스, 내부 클래스)  (0) 2024.04.14
Stream API  (2) 2024.04.07
Enum  (0) 2024.04.03
Class 클래스  (0) 2024.04.03
Wrapper Class  (0) 2024.04.02
728x90
반응형
SMALL

자바는 열거형(Enum Type)을 제공하는데, 이 열거형을 제대로 이해하기 위해서 열거형이 생겨난 이유를 알아보자.

다음과 같은 요구사항이 들어왔다고 생각해보자.

 

등급별 할인율 적용

  • BASIC 등급 - 10%
  • GOLD 등급 - 20%
  • DIAMOND 등급 - 30%

이 요구사항을 처리하기 위해 다음과 같은 코드를 작성했다.

 public class DiscountService {
     public int discount(String grade, int price) {
        int discountPercent = 0;
        if (grade.equals("BASIC")) {
            discountPercent = 10;
        } else if (grade.equals("GOLD")) {
            discountPercent = 20;
        } else if (grade.equals("DIAMOND")) {
            discountPercent = 30;
        } else {
        System.out.println(grade + ": 할인X");
        }
        return price * discountPercent / 100;
    }
}

 

그래서 파라미터로 등급과 금액을 받아서 등급으로 들어온 문자열이 BASIC, GOLD, DIAMOND 중 어느것이냐에 따라 할인율을 적용한 후 할인 금액을 반환한다.

 

그리고 이 메서드를 사용하는 코드를 다음과 같이 작성했다.

public class StringGradeEx0_1 {
    public static void main(String[] args) {
        int price = 10000;
        
        DiscountService discountService = new DiscountService();
        int basic = discountService.discount("BASIC", price);
        int gold = discountService.discount("GOLD", price);
        int diamond = discountService.discount("DIAMOND", price);
		
        System.out.println("BASIC 등급의 할인 금액: " + basic);
        System.out.println("GOLD 등급의 할인 금액: " + gold);
        System.out.println("DIAMOND 등급의 할인 금액: " + diamond);
	}
}

 

실행결과:

BASIC 등급의 할인 금액: 1000 
GOLD 등급의 할인 금액: 2000
DIAMOND 등급의 할인 금액: 3000

 

문제없이 잘 동작하는 것 같지만, 이 코드는 아주 많은 잠재적 버그를 내포하고 있다.

우선, 등급을 넣는 파라미터에 잘못된 값이 들어가도 문자열이기 때문에 어떠한 컴파일 에러도 발생하지 않는다.

 

예를 들어, 다음 코드같이 작성해도 컴파일 단계에서 에러가 발생하지 않는다.

int vip = discountService.discount("VIP", price);
System.out.println("VIP 등급의 할인 금액: " + vip);

 

VIP라는 등급은 현재 없다. 그럼에도 불구하고 등급 파라미터에 넣을 수 있다.

또는 오타가 발생할 수도 있다. "GOLD"를 "GOLDD"라고 작성해도 아무런 문제가 발생하지 않는다.

또는 소문자로 입력을 하면 등급을 찾지 못한다. 

 

즉, 문자열을 사용하는 이 방식은 다음과 같은 문제가 있다.

  • 타입 안정성 부족: 어떠한 문자열이 들어가도 입력이 가능하게 되어있다.
  • 데이터 일관성: GOLD를 gold라고 작성하면 골드 등급임에도 할인 적용을 받지 못한다.

 

그래서 이러한 문제를 해결해보기 위해 다음과 같이 나름의 고민끝에 코드가 작성됐다. 등급을 클래스 내 상수로 만들어 두고 이 상수를 사용해보는 것이다.

public class StringGrade {
     public static final String BASIC = "BASIC";
     public static final String GOLD = "GOLD";
     public static final String DIAMOND = "DIAMOND";
}

 

 public class DiscountService {
     public int discount(String grade, int price) {
         int discountPercent = 0;
         if (grade.equals(StringGrade.BASIC)) {
             discountPercent = 10;
         } else if (grade.equals(StringGrade.GOLD)) {
             discountPercent = 20;
         } else if (grade.equals(StringGrade.DIAMOND)) {
             discountPercent = 30;
        } else {
        System.out.println(grade + ": 할인X");
        }
         return price * discountPercent / 100;
     }
}

 

public class StringGradeEx1_1 {
     public static void main(String[] args) {
     	int price = 10000;
     	
        DiscountService discountService = new DiscountService();
     	int basic = discountService.discount(StringGrade.BASIC, price);
     	int gold = discountService.discount(StringGrade.GOLD, price);
     	int diamond = discountService.discount(StringGrade.DIAMOND, price);
        
        System.out.println("BASIC 등급의 할인 금액: " + basic);
        System.out.println("GOLD 등급의 할인 금액: " + gold);
        System.out.println("DIAMOND 등급의 할인 금액: " + diamond);
	} 
}

 

이렇게 사용을 하니, 오타를 방지할 수 있다. 예를 들면 StringGrade.GOLDD라는 값을 넣으면 컴파일 에러가 발생할 것이다. 왜냐하면 StringGrade 클래스에는 GOLDD라는 필드는 없기 때문이다. 근데 이 또한 문제가 있다. 어떤 문제냐면 여전히 받는 파라미터는 문자열 타입이라는 것이다. 그래서 다음 코드가 여전히 문제가 된다는 것.

int vip = discountService.discount("VIP", price); 
System.out.println("VIP 등급의 할인 금액: " + vip);

 

이러한 문제를 해결하기 위해 Enum이 탄생했다. 

 

열거형 - Enum Type

enum은 enumeration의 줄임말로 열거라는 뜻을 가지고 있다. 어떤 항목들을 나열한다는 의미이다. 다음 코드를 보자.

public enum Grade {
     BASIC, GOLD, DIAMOND
}

 

열거형을 정의할 땐 class 대신 enum이라는 키워드를 사용한다.

이 코드를 클래스로 표현하면 다음 코드와 거의 같다.

public class Grade extends Enum {
 	public static final Grade BASIC = new Grade();
 	public static final Grade GOLD = new Grade();
 	public static final Grade DIAMOND = new Grade();
    
    //private 생성자 추가
 	private Grade() {}
}
  • 열거형(Enum)도 클래스다.
  • 열거형은 자동으로 java.lang.Enum을 상속받는다.
  • 열거형은 외부에서 임의로 생성할 수 없다. (private constructor)

실제로 Enum으로 선언한 BASIC, GOLD, DIAMOND는 각 참조값이 따로 존재하고 모두 Grade라는 클래스의 인스턴스다.

코드로 확인해보자.

public class Main {
    public static void main(String[] args) {
        System.out.println(Grade.BASIC.getClass());
        System.out.println(Grade.GOLD.getClass());
        System.out.println(Grade.DIAMOND.getClass());

        System.out.println(Grade.BASIC);
        System.out.println(Grade.GOLD);
        System.out.println(Grade.DIAMOND);
    }
}

실행결과:

class enums.Grade
class enums.Grade
class enums.Grade
BASIC
GOLD
DIAMOND

 

실행결과를 봤더니 전부 Grade라는 클래스 소속이고, 참조값을 확인해보기 위해 찍은 자기 자신이 문자 그대로가 나왔다. 이 이유는 열거형에서는 toString()을 알아서 본인이 찍히도록 오버라이딩되어 있기 때문이다. 그래서 이 각 열거값들의 참조값을 알아보기 위해 다음과 같이 코드를 수정했다.

public class Main {
    public static void main(String[] args) {
        System.out.println(Grade.BASIC.getClass());
        System.out.println(Grade.GOLD.getClass());
        System.out.println(Grade.DIAMOND.getClass());


        System.out.println(Integer.toHexString(System.identityHashCode(Grade.BASIC)));
        System.out.println(Integer.toHexString(System.identityHashCode(Grade.GOLD)));
        System.out.println(Integer.toHexString(System.identityHashCode(Grade.DIAMOND)));
    }
}

실행결과:

class enums.Grade
class enums.Grade
class enums.Grade
30f39991
452b3a41
4a574795

 

결과를 확인했더니 서로 다른 참조값을 가지는 것을 알 수 있다. 그래서 이 열거형으로 위에서 겪었던 문제를 해결해보자. 

등급을 받는 파라미터의 타입을 Grade라는 열거형으로 변경하여 해당 열거형에 존재하지 않는 값 자체를 받지 못하게 설정한다.

public class DiscountService {
    public int discount(Grade grade, int price) {
        int discountPercent = 0;

        if (grade == Grade.BASIC) {
            discountPercent = 10;
        } else if (grade == Grade.GOLD) {
            discountPercent = 20;
        } else if (grade == Grade.DIAMOND) {
            discountPercent = 30;
        } else {
            System.out.println("할인X");
        }
        return price * discountPercent / 100;
    }
}

 

사용하는 코드에서는 이 Grade라는 타입을 받는 파라미터를 입력하는 부분에서 Grade에 속한 값 외에 어떤 값도 넣지 못한다. 

Grade라는 열거형 클래스의 인스턴스도 만들지 못한다. 생성자가 외부에서 접근하지 못하도록 private으로 선언되어 있기 때문이다.

public class Main {
    public static void main(String[] args) {
        int price = 10000;
        
        DiscountService discountService = new DiscountService();
        int basic = discountService.discount(Grade.BASIC, price);
        int gold = discountService.discount(Grade.GOLD, price);
        int diamond = discountService.discount(Grade.DIAMOND, price);
        
        System.out.println("BASIC 등급의 할인 금액: " + basic);
        System.out.println("GOLD 등급의 할인 금액: " + gold); 
        System.out.println("DIAMOND 등급의 할인 금액: " + diamond);
    }
}

 

열거형으로 인한 다음 두 가지 장점이 생겼다

  • 타입 안정성 향상: 열거형으로 사전에 정의된 상수들만으로 구성되어 유효하지 않은 값이 입력될 가능성은 없다. 이럴 경우 컴파일 에러가 발생한다.
  • 간결성 및 일관성: 열거형을 사용하면 코드가 더 간결해지고 명확해진다.
  • 확장성: 새로운 회원등급을 추가하고 싶을 때 열거형에 상수하나만 추가해주면 된다.

위 코드를 조금 더 간단하게 바꿀 수도 있다. static import를 하면 조금 더 간결해진다. 실무에서 많이 사용하는 방식이다. 

import static enums.Grade.*;

public class Main {
    public static void main(String[] args) {
        int price = 10000;

        DiscountService discountService = new DiscountService();
        int basic = discountService.discount(BASIC, price);
        int gold = discountService.discount(GOLD, price);
        int diamond = discountService.discount(DIAMOND, price);

        System.out.println("BASIC 등급의 할인 금액: " + basic);
        System.out.println("GOLD 등급의 할인 금액: " + gold);
        System.out.println("DIAMOND 등급의 할인 금액: " + diamond);
    }
}

 

열거형의 주요 메서드

열거형도 클래스다. 클래스라서 메서드가 있다. 대표적인 메서드들은 다음과 같다.

  • values(): 모든 ENUM 상수를 포함하는 배열을 반환한다.
  • valueOf(String name): 주어진 이름과 일치하는 ENUM 상수를 반환한다.
  • name(): ENUM 상수의 이름을 문자열로 반환한다.
  • ordinal(): ENUM 상수의 선언 순서(0부터 시작)를 반환한다. 
  • toString(): ENUM 상수의 이름을 문자열로 반환한다. name() 메서드와 유사하지만 toString()은 직접 오버라이딩할 수 있다.

근데, ordinal()은 가급적 사용하면 안된다. 왜냐하면 ENUM을 만들고 추후에 추가적으로 새로운 상수를 중간에 추가하고 싶어질 때가 있을 수 있다. 다음과 같이 말이다.

public enum Grade {
    BASIC, SILVER, GOLD, DIAMOND
}

그럼 기존에는 BASIC(0), GOLD(1), DIAMOND(2)였던게 BASIC(0), SILVER(1), GOLD(2), DIAMOND(3)이 되어버린다. 기존에 ordinal()을 사용해서 코드를 작성했다면 끔찍한 일이 벌어질 것이다.

 

 

열거형 - 리팩토링

기존에 서비스 코드를 다시 한번 보자.

public class DiscountService {
    public int discount(Grade grade, int price) {
        int discountPercent = 0;

        if (grade == Grade.BASIC) {
            discountPercent = 10;
        } else if (grade == Grade.GOLD) {
            discountPercent = 20;
        } else if (grade == Grade.DIAMOND) {
            discountPercent = 30;
        } else {
            System.out.println("할인X");
        }
        return price * discountPercent / 100;
    }
}

이 코드를 보면 등급에 따라 할인율을 적용하고 있다. 그럼 결국 할인율이란 것은 등급에 극도로 의존하고 있다. 그럼 할인율을 계산하는 코드는 사실 ENUM 클래스 내부에 있어도 될 것 같다. 그게 바로 객체 지향이니까. 그리고 등급에 따라 할인율이 고정적으로 정해져 있기 때문에 아예 등급을 선언할 때부터 해당 등급의 할인율을 필드로 가지고 있으면 더 좋을 것 같다. 다시 한번 말하지만 ENUM도 클래스다.

 

그래서 이 코드를

public enum Grade {
    BASIC, GOLD, DIAMOND
}

 

다음과 같이 변경했다. (참고로 상수외에 다른게 있으면 저렇게 상수가 끝나는 지점에 세미콜론(;)이 있어야 한다.)

public enum Grade {
    BASIC(10),
    GOLD(20),
    DIAMOND(30);

    private final int discountPercent;

    Grade(int discountPercent) {
        this.discountPercent = discountPercent;
    }

    public int getDiscountPercent() {
        return discountPercent;
    }

    public int discount(int price) {
        return price * discountPercent / 100;
    }
}

 

각 등급별 discountPercent 필드에 10, 20, 30을 지정해준다. 그리고 이 클래스 안에서 할인가격을 구하는 기능(메서드)을 만들면 된다.

그럼 DiscountService가 이렇게 간단해진다.

public class DiscountService {
    public int discount(Grade grade, int price) {
        return grade.discount(price);
    }
}

 

그럼 결국 DiscountService가 하는것은 위임밖에 없다. 이 말은 이 DiscountService 자체가 없어도 된다는 뜻이다. 날려버리고 메인 메서드를 이렇게 만들어보자. 너무나 깔끔하다.

public class Main {
    public static void main(String[] args) {
        int price = 10000;

        int basic = BASIC.discount(price);
        int gold = GOLD.discount(price);
        int diamond = DIAMOND.discount(price);

        System.out.println("BASIC 등급의 할인 금액: " + basic);
        System.out.println("GOLD 등급의 할인 금액: " + gold);
        System.out.println("DIAMOND 등급의 할인 금액: " + diamond);
    }
}

 

 

정리

열거형을 사용해서 다음과 같은 문제를 해결할 수 있다.

  • 타입 안정성 문제
  • 데이터의 일관성

열거형의 특징은 다음과 같다.

  • 열거형도 클래스이다.
  • 클래스란 것은 필드기능이 존재할 수 있다는 뜻이다.
  • 외부에서 생성하지 못한다 (생성자가 private)

 

728x90
반응형
LIST

'JAVA의 가장 기본이 되는 내용' 카테고리의 다른 글

Stream API  (2) 2024.04.07
날짜와 시간  (0) 2024.04.04
Class 클래스  (0) 2024.04.03
Wrapper Class  (0) 2024.04.02
Method Chaining  (0) 2024.04.02
728x90
반응형
SMALL

자바에서는 Class 클래스가 있고 이 클래스는 특정 클래스의 정보(메타데이터)를 다루는데 사용된다. Class 클래스를 통해 개발자는 실행중인 자바 애플리케이션 내에서 필요한 클래스의 속성과 메서드에 대한 정보를 조회하고 조작할 수 있다.

 

Class 클래스의 주요 기능은 다음과 같다.

  • 타입 정보 얻기: 클래스의 이름, 슈퍼클래스, 인터페이스, 접근 제한자 등과 같은 정보를 조회할 수 있다.
  • 리플렉션: 클래스에 정의된 메서드, 필드, 생성자 등을 조회하고 이들을 통해 객체 인스턴스를 생성하거나 메서드를 호출하는 등의 작업을 할 수 있다.
  • 동적 로딩과 생성: Class.forName() 메서드를 사용하여 클래스를 동적으로 로드하고, newInstance() 메서드를 통해 새로운 인스턴스를 생성할 수 있다.
  • 애노테이션 처리: 클래스에 적용된 애노테이션을 조회하고 처리하는 기능을 제공한다.

 

특정 클래스 객체를 가져오는 방법은 크게 3가지가 있다.

Class<String> stringClass = String.class;
Class<? extends String> stringClass = new String().getClass();
Class<?> stringClass = Class.forName("java.lang.String");

 

이런 방법들 중 하나를 택해서 특정 클래스의 클래스 객체를 가져오면 다음과 같은 작업들을 해 볼 수 있다.

// 모든 필드 조회
Field[] declaredFields = stringClass.getDeclaredFields();
for (Field declaredField : declaredFields) {
    System.out.println("declaredField = " + declaredField);
}
// 모든 메서드 조회
Method[] declaredMethods = stringClass.getDeclaredMethods();
for (Method declaredMethod : declaredMethods) {
    System.out.println("declaredMethod = " + declaredMethod);
}
// 부모 클래스 조회
Class<? super String> superclass = stringClass.getSuperclass();
System.out.println("superclass = " + superclass);
// 모든 인터페이스 조회
Class<?>[] interfaces = stringClass.getInterfaces();
for (Class<?> anInterface : interfaces) {
    System.out.println("anInterface = " + anInterface);
}

 

실행해보면 다음과 같이 메타데이터들이 나온다.

declaredField = private final byte[] java.lang.String.value
declaredField = private final byte java.lang.String.coder
declaredField = private int java.lang.String.hash
declaredField = private boolean java.lang.String.hashIsZero
declaredField = private static final long java.lang.String.serialVersionUID
declaredField = static final boolean java.lang.String.COMPACT_STRINGS
declaredField = private static final java.io.ObjectStreamField[] java.lang.String.serialPersistentFields
declaredField = private static final char java.lang.String.REPL
declaredField = public static final java.util.Comparator java.lang.String.CASE_INSENSITIVE_ORDER
declaredField = static final byte java.lang.String.LATIN1
declaredField = static final byte java.lang.String.UTF16
declaredMethod = byte[] java.lang.String.value()
declaredMethod = public boolean java.lang.String.equals(java.lang.Object)
declaredMethod = public int java.lang.String.length()
declaredMethod = public java.lang.String java.lang.String.toString()
declaredMethod = static void java.lang.String.checkIndex(int,int)
declaredMethod = public int java.lang.String.hashCode()
declaredMethod = public void java.lang.String.getChars(int,int,char[],int)
declaredMethod = public int java.lang.String.compareTo(java.lang.Object)
declaredMethod = public int java.lang.String.compareTo(java.lang.String)
declaredMethod = public int java.lang.String.indexOf(java.lang.String,int,int)
declaredMethod = static int java.lang.String.indexOf(byte[],byte,int,java.lang.String,int)
declaredMethod = public int java.lang.String.indexOf(java.lang.String,int)
...
declaredMethod = public java.lang.Object java.lang.String.transform(java.util.function.Function)
declaredMethod = public java.lang.String java.lang.String.formatted(java.lang.Object[])
declaredMethod = public static java.lang.String java.lang.String.copyValueOf(char[],int,int)
declaredMethod = public static java.lang.String java.lang.String.copyValueOf(char[])
declaredMethod = public native java.lang.String java.lang.String.intern()
declaredMethod = static void java.lang.String.checkOffset(int,int)
declaredMethod = static java.lang.String java.lang.String.valueOfCodePoint(int)
declaredMethod = public java.util.Optional java.lang.String.describeConstable()
declaredMethod = private static java.lang.String java.lang.String.lambda$stripIndent$3(int,java.lang.String)
declaredMethod = private static java.lang.String java.lang.String.lambda$indent$2(int,java.lang.String)
declaredMethod = private static java.lang.String java.lang.String.lambda$indent$1(java.lang.String)
declaredMethod = private static java.lang.String java.lang.String.lambda$indent$0(java.lang.String,java.lang.String)
superclass = class java.lang.Object
anInterface = interface java.io.Serializable
anInterface = interface java.lang.Comparable
anInterface = interface java.lang.CharSequence
anInterface = interface java.lang.constant.Constable
anInterface = interface java.lang.constant.ConstantDesc

 

이번엔 직접 만든 클래스로 Class 클래스를 사용해보자.

Hello

public class Hello {
    private final String value;

    public Hello(String value) {
        this.value = value;
    }

    public void hello() {
        System.out.println(value);
    }
}

 

Main

public class Main {
    public static void main(String[] args) throws ClassNotFoundException {

        Class<Hello> helloClass = Hello.class;

        Field[] declaredFields = helloClass.getDeclaredFields();
        for (Field declaredField : declaredFields) {
            System.out.println("declaredField = " + declaredField);
        }
    }
}

 

실행결과:

declaredField = private final java.lang.String clazz.Hello.value

이렇게 기존 방법대로 하는것도 있고, 다음과 같이 문자열로 패키지명과 클래스이름을 통해 클래스를 가져와서 newInstance()로 새 인스턴스를 만드는 방법도 있다.

public class Main {
    public static void main(String[] args) throws Exception {

        Class<?> aClass = Class.forName("clazz.Hello");
        Hello o = (Hello) aClass.getDeclaredConstructor(String.class).newInstance("Hi");

        o.hello();
    }
}

 

실행결과:

Hi

 

newInstance()를 사용하면 반환 타입은 Object 타입이다. 그래서 내가 원하는 클래스로 다운캐스팅 해줘야 한다.

getDeclaredConstructor() 메서드를 사용해서 생성자 메서드를 가져오는데 Hello 클래스는 파라미터가 있는 생성자밖에 없기 때문에 파라미터로 파라미터 타입이 String인 생성자를 가져와서 newInstance() 메서드를 실행한다. 

 

그리고 이렇게 Class 클래스를 이용해서 클래스의 메타 정보를 기반으로 클래스에 정의된 메서드, 필드, 생성자 등을 조회하고, 이들을 통해 객체 인스턴스를 생성하거나 메서드를 호추랗는 작업을 할 수 있는데 이를 리플렉션이라고 한다.

728x90
반응형
LIST

'JAVA의 가장 기본이 되는 내용' 카테고리의 다른 글

날짜와 시간  (0) 2024.04.04
Enum  (0) 2024.04.03
Wrapper Class  (0) 2024.04.02
Method Chaining  (0) 2024.04.02
String 클래스  (0) 2024.04.01
728x90
반응형
SMALL

Wrapper Class는 특정 기본형을 감싸서 객체로 만들어 놓은 것을 말한다. Integer, Boolean, Long 등이 있다.

그럼 이 Wrapper 클래스는 왜 필요한걸까?

 

기본형의 한계

1. 객체가 아니다

우선 기본형은 객체가 아니다. 그 말은 객체가 가지는 장점을 활용할 수 없다는 얘기다. 가장 대표적인 것으로 본인의 속성을 사용하는 기능을 가질 수 없고 외부에서 만들어 사용해야 한다. 다음 코드를 보자.

public class Main {
    public static void main(String[] args) {
        int intValue = 10;

        int i1 = compareTo(intValue, 5);
        int i2 = compareTo(intValue, 10);
        int i3 = compareTo(intValue, 20);

        System.out.println("i1 = " + i1);
        System.out.println("i2 = " + i2);
        System.out.println("i3 = " + i3);
    }

    public static int compareTo(int value, int target) {
        if (value < target) {
            return -1;
        } else if (value > target) {
            return 1;
        } else {
            return 0;
        }
    }
}

 

기본형인 int 타입의 변수에 어떤 값이 들어있을 때 이 값과 다른 값을 비교해서 더 큰지 작은지,두개가 같은지 비교하는 메서드다.

본인의 값을 가지고 비교하고 있음에도 객체가 아니기 때문에 내부적인 메서드를 만들지 못하고 어디선가 만들어 놓은 외부 메서드를 가져다가 사용해야 한다. Wrapper Class를 사용하면 다음과 같이 깔끔하게 변경할 수 있다.

public class Main {
    public static void main(String[] args) {
        Integer intValue = 10;

        int i1 = intValue.compareTo(10);
        int i2 = intValue.compareTo(20);
        int i3 = intValue.compareTo(5);

        System.out.println("i1 = " + i1);
        System.out.println("i2 = " + i2);
        System.out.println("i3 = " + i3);
    }
}

자신의 속성을 가지고 비교하는 메서드를 객체 지향이라는 언어에 맞게 내부적으로 메서드를 만들어서 사용하는 것이다. 캡슐화가 잘 지켜진 코드라고도 볼 수 있다.

 

 

2. null을 사용할 수 없다.

가끔은 값이 비어있음을 표현해야 할 때가 있다. 그러나 기본형은 그럴 수 없다. 반드시 값이 들어있어야 한다.

그래서 이 경우에도 Wrapper 클래스의 도움을 받을 수 있다. 굳이 이 말 외에 예제 코드가 필요하진 않을듯하여 예제 코드는 생략하겠다.

 

 

자바가 제공하는 래퍼 클래스 특징

1. Integer, Long, Byte, Double, Boolean 등 자바가 제공하는 Wrapper 클래스는 다 불변객체이다. 

2. 클래스니까 equals()로 비교해야한다.

 

그리고, 클래스라고 했기 때문에 new로 생성할 수 있는데 new로 생성하면 안된다. 왜냐하면 자바9부터 Deprecated 됐다.

 

그 이유는 valueOf() 메서드를 사용하면 효율적으로 더 좋기 때문이다. 왜 그러냐면, 예를 들어 Integer인 경우 "-128 ~ 127"까지는 Integer 클래스를 미리 생성해준다. 그래서 새로 인스턴스를 new로 생성하기보다 더 메모리 사용도 효율적으로 할 수 있고 그에 따라 속도도 개선된다. 마치 String의 문자열 풀과 같은 것. "어? 그럼 그 범위 밖은요?" 그 범위 밖은 자바가 알아서 new로 새롭게 인스턴스를 만들어준다.

 

그래서 사용할 땐 아래처럼 그냥 valueOf()를 사용하면 된다.

Integer boxedValue = Integer.valueOf(10);

 

래퍼 클래스 생성 - 박싱(Boxing)

위 코드처럼 기본형을 래퍼 클래스로 변경하는 것을 마치 박스에 물건을 넣은 것 같다고 해서 박싱(Boxing)이라 한다.

근데 이거를 아주 간단하게 그냥 이렇게 쓰면 된다. 이것을 오토박싱이라고 한다.

(근데 사실 이런 명칭자체가 그렇게 크게 중요하진 않다고 생각한다.)

Integer intValue = 10;

 

intValue() - 언박싱(Unboxing)

Integer intValue = 10;
int i = intValue.intValue();

박스에 담은 기본형(래퍼 클래스)을 다시 기본형으로 돌려버리는 것을 언박싱이라고 한다.

근데 이거를 아주 간단하게 그냥 이렇게 쓰면 된다. 그리고 이것을 오토언박싱이라 한다.

Integer intValue = 10;
int unboxedValue = intValue;

 

 

728x90
반응형
LIST

'JAVA의 가장 기본이 되는 내용' 카테고리의 다른 글

Enum  (0) 2024.04.03
Class 클래스  (0) 2024.04.03
Method Chaining  (0) 2024.04.02
String 클래스  (0) 2024.04.01
불변객체 (Immutable Object)  (2) 2024.04.01
728x90
반응형
SMALL
SMALL

이번에는 굉장히 자주 사용되는 방식 중 하나인 메서드 체이닝에 대해 알아봤다.

대표적인 예시 중 하나가 StringBuilder, QueryDSL같은 라이브러리나 모듈이 이 기법을 사용하고 그 외 여러 라이브러리에서도 많이 사용하는 방법이다. 간단하다.

 

다음 코드를 보자.

Value

public class Value {
    private int value;

    public Value(int value) {
        this.value = value;
    }

    public Value add(int value) {
        this.value += value;
        return this;
    }

    public int getValue() {
        return value;
    }

    public void setValue(int value) {
        this.value = value;
    }
}

 

항상 만들어왔던 클래스처럼 생겼는데 add() 메서드를 보자. 반환 타입이 같은 클래스 타입이다. 그리고 자기 자신을 반환하고 있다.

이렇게 만들어 놓으면 메서드 체이닝이 가능해진다. 직접 사용해보자.

 

Main

public class Main {
    public static void main(String[] args) {

        Value data = new Value(10);

        data.add(1).add(2).add(7);

        System.out.println("data = " + data.getValue());
    }
}

위 코드에서 이 부분을 자세히 봐야한다. 

data.add(1).add(2).add(7);

메서드에 메서드를 계속 추가하고 있다. 이게 가능한 이유는 add() 메서드가 반환하는 것이 자기 자신이기 때문이다. 그리고 이 코드의 실행결과는 다음과 같다.

 

실행 결과:

data = 20

 

이렇게 메서드를 계속 이어가서 사용하는 방식을 메서드 체이닝이라고 한다.

728x90
반응형
LIST

'JAVA의 가장 기본이 되는 내용' 카테고리의 다른 글

Class 클래스  (0) 2024.04.03
Wrapper Class  (0) 2024.04.02
String 클래스  (0) 2024.04.01
불변객체 (Immutable Object)  (2) 2024.04.01
java.lang 패키지와 그 첫번째 - Object  (0) 2024.03.31
728x90
반응형
SMALL

불변객체의 가장 대표적인 클래스인 String. 자바에서 문자열을 다룰 때 사용하곤 한다.

자바에서 문자를 다루는 대표적인 타입은 char, String 2가지가 있다.

 public class CharArrayMain {
     public static void main(String[] args) {
         char[] charArr = new char[]{'h', 'e', 'l', 'l', 'o'};
         System.out.println(charArr);
         String str = "hello";
         System.out.println("str = " + str);
     }
}

실행결과:

hello
str = hello

 

보는 것과 같이 char 타입은 한 글자씩만 사용할 수 있기 때문에 문자열로 적합하지 않다. 그래서 String 클래스를 사용하는데, 이 String 클래스를 사용해서 문자열을 생성하는 방법은 2가지가 있다.

  • 쌍따옴표 사용: "hello"
  • 객체 생성: new String("hello");

그러나 String은 클래스고, 참조값을 가져야 하는데 이 코드는 뭔가 이상하다.

String str = "hello";

이는 자바가 문자열 다루는 것 자체가 너무 자주 사용되니까 편의상 허용해준 방법이다. 즉, 저렇게 인스턴스로 직접 만들지 않고 리터럴로 대입을 해도 허용하도록 개발자들에게 편의를 제공한다.

 

 

String 클래스 구조

String 클래스는 클래스이므로 속성과 기능을 가진다. 그리고 내부적으로 다음과 같은 속성을 가지고 있다.

private final char[] value; // 자바 9 이전
private final byte[] value; // 자바 9 이후

 

그러니까 결국 String 클래스도 내부적으로 char 타입의 배열로 문자열을 보관한다는 의미다.

 

근데 자바 9 이전과 이후에 왜 타입이 달라질까? 자바에서 문자 하나를 표현하는 char는 2byte를 차지한다. 그런데 영어, 숫자는 보통 1byte로 표현이 가능하다. 그래서 단순 영어, 숫자로만 표현된 경우 1byte를 사용하고(정확히는 Latin-1 인코딩인 경우 1byte 사용) 그렇지 않은 나머지의 경우 2byte인 UTF-16 인코딩을 사용한다. 따라서 메모리를 더 효율적으로 사용할 수 있게 변경된 것.

 

기능(메서드)은 엄청 많다. 대표적인 기능으로는 다음과 같은 것들이 있다.

  • length()
  • charAt(int index)
  • substring(int beginIndex, int endIndex)
  • indexOf(String str)
  • toLowerCase(), toUpperCase()

그리고 이렇듯 String은 클래스이기 때문에 기본형이 아닌 참조형이다. 참조형은 변수에 참조값(메모리 주소)가 들어간다. 그 말은 연산이 불가능하단 말이고 "+"와 같은 연산을 사용할 수 없는게 원칙적이나 자바에서는 문자열이 너무 자주 다루어지다 보니 편의상 특별히 "+" 연산을 제공한다.

 

String 클래스 비교

결론부터 말하면 String 클래스를 비교할 땐 반드시 equals() 메서드를 사용해야 한다.

근데, 신기한점이 있다. 다음 코드를 보자.

 

public class Main {
    public static void main(String[] args) {
        String a = "hello";
        String b = "hello";

        System.out.println(a == b);
    }
}

이 실행결과는 어떻게 될까?

실행결과:

true

 

이상하다. '=='은 동일성 비교를 할 때 사용하는 연산자이다. 즉, 완전히 같아야 한다는 소린데 String은 클래스고 클래스라는 것은 참조형이며 참조형이면 각자 가지고 있는 메모리가 다를 것이다. 그런데도 불구하고 참이 나온다. 실제로 아래 코드를 수행해보면 기존에 알던 지식 그대로의 결과가 나온다.

public class Main {
    public static void main(String[] args) {
        String a = new String("hello");
        String b = new String("hello");

        System.out.println(a == b);
    }
}

실행결과:

false

 

당연히 위 코드는 납득이 된다. a와 b는 각자 서로 다른 인스턴스이고 그 말은 서로 다른 참조값을 가지니까 동일성 비교('==')는 당연히 다르다. 근데 인스턴스를 새로 만들어서 값을 저장하는게 아닌 리터럴로 저장할 때는 참이 나오는데는 이유가 있다. 

 

자바는 실행되는 시점에 클래스에 String a = "hello";와 같은 문자열 리터럴이 있으면 문자열 풀에 String 인스턴스를 미리 만들어둔다. 이때 같은 문자열이 있으면 만들지 않는다. 그래서 String a = "hello";String b = "hello";는 결국 같은 참조값을 가지고 있게 된다. 즉, 자바가 문자열 풀을 만들어두고 미리 이런 문자열 리터럴을 보관해주는 덕분에 메모리 사용량을 줄이고 성능도 최적화를 해준다.

 

그럼, 여기서 질문이 생긴다. 문자열 리터럴을 사용하는 경우에는 비교 시 '=='를 사용하고 인스턴스를 직접 생성해서 사용하는 경우에만 equals()를 사용하면 될까? 아니다. 무조건 equals()를 사용해야 한다.

 

그 이유는 우선, 코드가 길어지면 질수록 이 코드가 문자열 리터럴로 만들어진것인지 아닌지 알 턱이 없고, 만들어내는 개발자와 비교 로직을 작성하는 개발자가 다르면 더더욱 알 턱이 없어진다. 그렇기 때문에 반드시 문자열은 equals() 비교를 해야한다.

 

불변객체 String

String은 불변객체의 아주 대표적인 예이다. 한번 직접 확인해봐도 좋다. String은 setter도 없고 속성도 final로 선언되어 있다. 

 

"어? 저는 지금까지 잘 바꿔왔는데요?" 그건 기존값을 변경한 게 아니고 새 참조값을 넣었을 뿐이다. 이 질문은 가령 이런 내용일 것이다.

String a = "hello";
a = "hello java";

이건 변수 a의 값을 "hello"에서 "hello java"로 변경한 게 아니고 "hello"라는 값을 담은 인스턴스를 문자열 풀에 자바가 만들어 둔 것을 그대로 둔 상태에서 "hello java"라는 값을 담은 새로운 인스턴스의 참조값을 a에 대입한 것이다. 

 

그리고 저번 포스팅에서 다룬 불변객체가 값을 바꿀 땐 새로운 불변객체를 만들어낸다고 했는데 그것을 String 클래스에서도 확인할 수 있다. 다음 코드를 보자. 문자열을 합치는 concat() 메서드를 사용했지만 다음 코드는 전혀 값의 변화가 없다.

실행결과:

hello

 

기존에 있는 값에 저 문자열을 추가한게 아니라 추가한 문자열을 가지는 새로운 문자열을 만들어내는 불변객체이기 때문이다. 그래서 저 문자열을 추가한 결과를 찍고 싶으면 반환값을 받아야한다.

실행결과:

hello
hellohello java

 

String이 불변객체인 이유

왜 String이 불변객체일까? 문자열 풀에 있는 String 인스턴스의 값이 중간에 변경되면 같은 문자열을 참고하는 다른 변수의 값도 함께 변경되기 때문이다. 그러니까 만약에 다음 코드를 보면,

String a = "hello";
a = "hello java";

 

String a = "hello";(x001)를 자바가 보고 문자열 풀에 "hello"라는 문자열을 담은 인스턴스를 딱 한개만 보관한다. 그래서 다른 곳에서 같은 "hello"라는 문자열을 사용할 때 같은 참조값(x001)을 돌려주게 되어있다.

 

그런데 저 코드가 "hello"라는 문자열을 담은 인스턴스를 "hello java"를 담는것으로 변경해버리는 거라면 다른곳에서 참조하는 "hello"도 "hello java"로 변경된다는 것이다. 그러면 사이드 이펙트가 발생할 수 있다. 그래서 그게 아니라 "hello java"라는 문자열을 담는 새로운 인스턴스 참조값을 변수 a에 대입하는 것이다.

 

불변객체 String의 단점과 StringBuilder

String이 불변객체이고 불변객체인 이유까지 알았다. 근데 불변객체라고 장점만 있는것은 아니다. 어떤 단점이 있을까? 예를 들어 다음과 같은 코드가 있다고 가정해보자.

String str = "A" + "B" + "C" + "D";

 

아주 단순하게 A, B, C, D라는 문자를 더하는 것 같지만 이 연산을 수행하기 위해 다음과 같은 절차를 거쳐야한다.

1. A, B, C, D를 각각 담는 4개의 String 인스턴스를 만든다.

2. String은 불변객체이므로 "A" + "B"를 하기 위해 "AB"라는 새로운 String 인스턴스를 만든다.

3. String은 불변객체이므로 "AB" + "C"를 하기 위해 "ABC"라는 새로운 String 인스턴스를 만든다.

4. String은 불변객체이므로 "ABC" + "D"를 하기 위해 "ABCD"라는 새로운 String 인스턴스를 만든다.

 

저 한줄의 연산을 위해 3번의 새로운 인스턴스를 만든다. 얼마나 비효율적인가? 그럼 더 나아가서 문자열이 길어지면 길어질수록 더더더 많은 인스턴스를 만들 수 밖에 없다. 이것이 String이라는 불변객체의 단점이다. (물론, 원리는 이게 맞지만 자바가 최적화를 한다)

 

그래서 이런 문제를 해결하려면 ? 불변객체가 아닌 가변객체를 사용하면 된다.

StringBuilder

자바가 StringBuilder라는 가변 String을 제공해준다. "어 정말 가변인가요?" StringBuilder 클래스에 들어가보면 String과는 달리 final 키워드가 없는 것을 확인해볼 수 있다.

 

그럼 StringBuilder를 어떻게 사용하는지 확인해보자. 

public class SbMain {
    public static void main(String[] args) {
        StringBuilder builder = new StringBuilder();

        builder.append("A");
        builder.append("B");
        builder.append("C");
        builder.append("D");
        System.out.println("builder = " + builder);

        builder.insert(4, "JAVA");
        System.out.println("builder = " + builder);

        builder.delete(4, 8);
        System.out.println("builder = " + builder);

        builder.reverse();
        System.out.println("builder = " + builder);

        String string = builder.toString();
        System.out.println("string = " + string);
    }
}

실행결과:

builder = ABCD
builder = ABCDJAVA
builder = ABCD
builder = DCBA
string = DCBA

StringBuilder 객체를 생성한 후, 해당 객체에 append() 메서드를 사용해서 문자열을 뒤로 계속하여 추가할 수 있다. 보면 알겠지만 append() 메서드의 반환값을 받아온 게 아니고 그대로 builder를 사용해서 진행하고 있다. 즉, 불변객체가 아니라는 소리다.

 

insert()는 특정 위치에 문자열을 추가한다.

delete()는 특정 범위의 문자열을 삭제한다.

reverse()는 문자열을 뒤집는다.

 

toString()은 String 객체를 만들어낸다. 불변객체를 만들어낸다는 의미이고 StringBuilder로 작업을하고 모든 작업이 끝나면 사이드 이펙트를 방지하기 위해 불변객체로 변환하는것도 좋은 습관이 될 수 있다.

 

 

String 최적화

다음 코드를 얘기하면서 불변객체가 주는 비효율성을 얘기했다.

String str = "A" + "B" + "C" + "D";

그리고 이 작업 원리에 대해 자바가 최적화를 해준다고도 했다. 어떤 최적화를 해주냐면 자바가 컴파일 시에 다음 코드를 보고 그냥 저 문자를 합쳐버리는 행위를 한다. 다음과 같이.

String str = "ABCD";

즉, 런타임 시에 연산 작업을 하지 않게 자바가 컴파일 하면서 최적화를 해준다.

 

그러나 저건 그냥 문자열이니까 가능한데 변수에 담긴 문자열은 어떻게 될까?

String str = str1 + str2;

다음 코드 같은 경우에는 어떤 값이 들어있는지 컴파일 시점에는 알 수 없기 때문에 단순하게 합칠 수 없다. 그래서 다음과 같이 비슷하게 최적화를 해준다.

String str = new StringBuilder().append(str1).append(str2).toString();

그러니까 최대한 최적화할 수 있는 만큼은 해준다는거고 이렇게 자바가 알아서 다 해주기 때문에 개발자는 "어.. 이거를 최적화해야겠다"하고 StringBuilder를 사용해서 String str = StringBuilder().append(str1).append(str2).toString() 이렇게 직접 작성안해도 된다. 그냥 + 연산을 하면 된다. 알아서 자바가 해준다.

 

근데, 그럼에도 String 최적화가 어려운 경우가 있다. 그래서 개발자가 직접 최적화해야 하는 경우가 있다. 어떤 경우냐면, 루프안에서 문자열을 더하는 경우이다.

public class Main {
    public static void main(String[] args) {
        long startTime = System.currentTimeMillis();

        String result = "";

        for (int i = 0; i < 100000; i++) {
            result += "Hello Java";
        }

        long endTime = System.currentTimeMillis();

        System.out.println(result);
        System.out.println("time = " + (endTime - startTime) + "ms");
    }
}

 

이런 경우 자바가 어떤 작업을 하냐면 대략 이런 작업을 한다. 

String result = "";
 for (int i = 0; i < 100000; i++) {
     result = new StringBuilder().append(result).append("Hello Java
 ").toString();
}

근데, 이게 최적화가 되는것 같아도 그게 아니다. 오히려 더 성능을 악화시킨다. 왜냐하면, 반복 횟수만큼 StringBuilder 객체를 생성하고 있다. 거기다가 반복횟수만큼 String 객체도 생성한다. 이게 얼마나 오래 걸릴지 실행해보자.

....
JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello Java
time = 7142ms

7초가 걸렸다. 고작 100,000번의 반복으로 7초가 걸리는건 정말 문제가 있는거다. 이럴땐 개발자가 직접 최적화하는게 훨씬 효율적이다.

다음 코드가 개발자가 직접 최적화한 경우이다.

public class StringBuilderMain {
    public static void main(String[] args) {
        long startTime = System.currentTimeMillis();

        StringBuilder stringBuilder = new StringBuilder();

        for (int i = 0; i < 100000; i++) {
            stringBuilder.append("Hello Java");
        }

        long endTime = System.currentTimeMillis();

        String result = stringBuilder.toString();
        System.out.println(result);
        System.out.println("time = " + (endTime - startTime) + "ms");
    }
}

StringBuilder 객체를 딱 한번만 만들고, String 객체도 딱 한번만 만든다. 이 코드는 얼마나 걸릴까? 

...
JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello Java
time = 10ms

1초도 안 걸린다. 700배 정도 차이가 있는거다. 어마어마한 차이다. 이렇듯 개발자가 직접 최적화를 해야하는 경우도 있고 단순한 경우 자바가 알아서 최적화를 해주기 때문에 고려를 하지 않아도 되는 경우가 있다.

 

그럼 StringBuilder를 직접 사용하는 경우가 더 좋은 경우는 다음과 같은 경우가 있다고 정리하고 마치면 될듯하다.

  • 반복문에서 반복해서 문자를 연결할 때
  • 조건문을 통해 동적으로 문자열을 조합할 때
  • 복잡한 문자열의 특정 부분을 변경해야 할 때
  • 매우 긴 대용량 문자열을 다룰 때

 

728x90
반응형
LIST

'JAVA의 가장 기본이 되는 내용' 카테고리의 다른 글

Wrapper Class  (0) 2024.04.02
Method Chaining  (0) 2024.04.02
불변객체 (Immutable Object)  (2) 2024.04.01
java.lang 패키지와 그 첫번째 - Object  (0) 2024.03.31
OCP (Open-Closed Principle) 원칙  (0) 2024.03.30
728x90
반응형
SMALL

참고 자료:

 

김영한의 실전 자바 - 중급 1편 | 김영한 - 인프런

김영한 | 실무에 필요한 자바의 다양한 중급 기능을 예제 코드로 깊이있게 학습합니다., [사진]국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바

www.inflearn.com

불변객체가 필요한 이유

불변객체에 대해서 완전히 제대로 알아보자. 불변객체가 뭔지 알기 전에 불변 객체가 왜 필요한 지부터 알아야 한다. 자바에서 가장 크게 변수는 두 가지 타입이 있다.

  • 기본형 (Primitive type)
  • 참조형 (Reference type)

기본형은 값의 공유가 절대로 일어나지 않는다. 즉, 특정값을 어떤 기본형 변수에 넣으면 변수끼리 그 값을 공유할 수 있는 방법은 없다.

언제나 자바에서 대입은? 값을 복사해서 대입한다.

 

다음 코드를 보자. a라는 변수에 10을 담고 b라는 변수에 a를 대입했다. a와 b가 값을 공유하나? 아니다. 값을 복사해서 b에 넣어준 것뿐이다. 실제로 b를 변경해도 a에는 아무런 영향이 없다.

int a = 10;
int b = a;

 

정말로 그런지 확인해보자.

실행 결과:

a = 10
b = 10
a = 10
b = 20

 

b를 변경해도 a에는 아무런 영향이 없다.

 

근데, 참조형은 어떨까? 참조형은 참조값을 복사해서 대입한다. 코드로 보자.

 

Value

public class Value {
    private int value;

    public Value(int value) {
        this.value = value;
    }

    public int getValue() {
        return value;
    }

    public void setValue(int value) {
        this.value = value;
    }

    @Override
    public String toString() {
        return "Value{" +
                "value=" + value +
                '}';
    }
}

 

Main

public class Main {
    public static void main(String[] args) {
        Value v1 = new Value(3);
        Value v2 = v1;

        System.out.println(v1.getValue());
        System.out.println(v2.getValue());

        v2.setValue(10);
        System.out.println(v1.getValue());
        System.out.println(v2.getValue());
    }
}

 

Value라는 클래스의 인스턴스를 v1, v2로 만들었다. v2는 v1을 대입한다. 참조형은 참조값을 복사해서 대입한다. 즉, v1과 v2는 같은 메모리 주소를 공유한다. 이 상태에서 둘 중 하나의 값을 바꿔버리면 나머지 하나도 변경되는 상황이 생겨난다.

 

실행결과:

3
3
10
10

 

이걸 의도해서 만든 거라면 아무런 문제가 없지만, 그게 아니라면? 문제가 생기는 것이다. 특정 개발자는 3이라는 값을 가진 Value 인스턴스를 그저 복사해서 같은 3이라는 값을 가지는 새로운 인스턴스를 원했던 것인데 하나를 바꾸니 나머지 하나도 변경이 되는 것이다.

 

이걸 해결하는 근본적인 방법은 "새로운 인스턴스를 만드는 것이다". 다음이 그 예시이다.

public class Main {
    public static void main(String[] args) {
        Value v1 = new Value(3);
        Value v2 = new Value(5);

        System.out.println(v1.getValue());
        System.out.println(v2.getValue());

        v2.setValue(10);
        System.out.println(v1.getValue());
        System.out.println(v2.getValue());
    }
}

 

실행결과:

3
5
3
10

 

이제 v2값을 변경해도 v1에는 영향이 끼치지 않는다. 좋다. 근데 남아 있는 문제는 결국엔 두 객체 간 참조값 공유를 막을 방법은 없다는 것이다. 어떤 개발자가 다음과 같은 코드를 작성했을 때 이를 막아줄 방법이 없다. 왜냐면 문법적으로 잘못된 게 없으니까. 

Value v1 = new Value(3);
Value v2 = v1;

 

그러니까 결국 위와 같은 문제를 방지하려면 개발자가 알아서 이런 경우를 만들지 않도록 코드를 짜야하는데 가장 멀리해야 하는 게 스스로를 신뢰하는 것 아닌가? 저렇게 코드를 안 짜리라고 보장할 수 없다. 이걸 해결하기 위해 불변객체가 등장(?)한 것.

 

 

불변객체 (Immutable Object)

위 문제를 해결하기 위해 불변 객체라는 것이 사용된다. 불변 객체는 객체의 상태(객체 내부의 값, 필드, 멤버 변수)가 변하지 않는 객체를 불변 객체라고 한다. 사실 그렇다. 위 문제에서 참조값을 공유하는 것을 막을 수 있는 방법은 없다. 문법적으로도 잘못된 게 아니다. 그러면 근본적인 원인은 해결할 수가 없고, 대안을 찾아야 하는데 결국 문제가 발생하는 지점은 두 객체가 가지고 있는 값을 한 쪽에서 변경할 때 발생하지 않는가? 그럼 그 값을 변경하지 못하도록 막아버리는 방법이 있다. 그리고 이게 불변 객체이다.

 

그리고 실제로 참조값 공유는 유용하다. 만약 같은 값 3을 가지는 Value 인스턴스가 정말 필요하다면 굳이 새로운 인스턴스를 만들어서 괜히 메모리를 더 쓰는게 아니라 이미 가지고 있는 인스턴스를 공유하면 더 효율적이기 때문이다. 그러니 참조값 공유를 막는것을 생각하지 말고 객체가 가지는 상태를 변경하는 것을 막는것으로 바꾸는 것이다.

 

방법은 꽤나 간단하다. 필드를 final로 선언하고 setter를 빼버리면 된다.

public class Value {
    private final int value;

    public Value(int value) {
        this.value = value;
    }

    public int getValue() {
        return value;
    }

    @Override
    public String toString() {
        return "Value{" +
                "value=" + value +
                '}';
    }
}

 

이렇게 불변 객체로 만들면 한번 값이 적용된 후 값을 바꾸려고 하면 컴파일 에러가 발생한다.

 

위와 같은 에러가 발생하면 개발자는 "어? 하고 어떤 에러가 났는지 볼 것이고 보니까 아 이거 불변 객체구나! 값을 바꾸려면 새로 인스턴스를 만들어야겠다." 라는 생각을 할 수 있게 만들어 주는것.

 

 

불변객체 예시 코드

그럼 예시를 한번 만들어보자.

Member

public class Member {
    private String name;
    private Address address;

    public Member(String name, Address address) {
        this.name = name;
        this.address = address;
    }

    public String getName() {
        return name;
    }

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

    public Address getAddress() {
        return address;
    }

    public void setAddress(Address address) {
        this.address = address;
    }

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

Address

public class Address {
    private String value;

    public Address(String value) {
        this.value = value;
    }

    public String getValue() {
        return value;
    }

    public void setValue(String value) {
        this.value = value;
    }

    @Override
    public String toString() {
        return "Address{" +
                "value='" + value + '\'' +
                '}';
    }
}

 

이 예시는 Member 클래스와 Address 클래스가 있을 때, Member 클래스에는 name, address라는 필드가 있다. 그래서 이 두개의 클래스를 가지고 다음과 같이 실행 코드를 만들었다고 해보자.

Main

public class Main {
    public static void main(String[] args) {
        Address address = new Address("서울");
        
        Member m1 = new Member("A", address);
        Member m2 = new Member("B", address);
    }
}

 

주소가 서울인 회원 두명이 있다. 이렇게 회원이 잘 만들어 졌는데 요구사항이 들어왔다. "회원 B의 주소를 서울에서 부산으로 변경해라."

그럼 개발자는 다음과 같은 행위를 한다.

m2.getAddress().setValue("부산");

 

좋다, 이제 그래서 다음과 같이 변경사항을 출력하는 코드까지 작성하고 실행 결과를 확인했다.

public class Main {
    public static void main(String[] args) {
        Address address = new Address("서울");

        Member m1 = new Member("A", address);
        Member m2 = new Member("B", address);

        System.out.println(m1);
        System.out.println(m2);

        System.out.println("주소 변경---------");

        m2.getAddress().setValue("부산");
        System.out.println(m1);
        System.out.println(m2);
    }
}

 

실행결과:

Member{name='A', address=Address{value='서울'}}
Member{name='B', address=Address{value='서울'}}
주소 변경---------
Member{name='A', address=Address{value='부산'}}
Member{name='B', address=Address{value='부산'}}

 

실행 결과를 보니 원하지 않는 결과가 발생했다. 회원 B의 주소만 변경했는데 회원 A의 주소까지 변경됐다. 

이제 이런 문제를 불변객체를 사용해서 방지해보자. Member는 불변객체가 아니다. Member 클래스의 필드(name, address)는 변경할 수 있어야한다. 

ImmutableAddress

public class ImmutableAddress {
    private final String value;

    public ImmutableAddress(String value) {
        this.value = value;
    }

    public String getValue() {
        return value;
    }
    
    @Override
    public String toString() {
        return "Address{" +
                "value='" + value + '\'' +
                '}';
    }
}

 

그래서 Address 클래스를 불변 객체로 변경했다. 필드에 final을 붙이고 setter를 떼버렸다. 이 클래스로 기존 코드를 대체해보자.

Main

그랬더니 다음과 같이 컴파일 에러가 발생한다. 개발자는 보고 어? 왜 에러가 발생했지?로 시작해서 아! 이거 불변객체라서 이렇게 사용못하는구나!"를 깨닫는다. 그래서 다음과 같은 코드로 대체한다.

그래서 결국 원하는 결과를 도출할 수 있었다.

 

실행결과:

Member{name='A', address=Address{value='서울'}}
Member{name='B', address=Address{value='서울'}}
주소 변경---------
Member{name='A', address=Address{value='서울'}}
Member{name='B', address=Address{value='부산'}}

 

 

불변객체의 값 변경

아무리 불변객체라고 해도 값을 변경하고 싶을때가 있다. 이럴땐 어떻게 하면 될까? 새로운 인스턴스를 반환하면 된다. 다음 코드를 보자.

ImmutableObj

public class ImmutableObj {
    private final int value;

    public ImmutableObj(int value) {
        this.value = value;
    }
    
    public ImmutableObj withValue(int value) {
        return new ImmutableObj(value);
    }

    public int getValue() {
        return value;
    }

    @Override
    public String toString() {
        return "ImmutableObj{" +
                "value=" + value +
                '}';
    }
}

 

불변 객체인 ImmutableObj 클래스가 가진 value라는 필드를 변경하고 싶은 경우 위 코드처럼 withValue()라는 메서드를 하나 만든다.

보통은 이렇게 불변객체에 새로운 값을 넣고 새로운 인스턴스를 반환하는 메서드를 만들 때 관례상 with___() 메서드 명을 따른다. 변경하고자 하는 값을 파라미터로 받아서 새로운 ImmutableObj 객체를 리턴하는 방식이다.

Main

public class Main {
    public static void main(String[] args) {
        ImmutableObj obj = new ImmutableObj(3);
        ImmutableObj obj2 = obj.withValue(10);

        System.out.println(obj);
        System.out.println(obj2);
    }
}

 

그래서 이 코드를 실행해보면 기존 객체는 값이 전혀 변경되지 않고, 새로운 객체를 만들어 새로운 값을 가지는 녀석으로 반환한다.

실행결과:

ImmutableObj{value=3}
ImmutableObj{value=10}
728x90
반응형
LIST

'JAVA의 가장 기본이 되는 내용' 카테고리의 다른 글

Method Chaining  (0) 2024.04.02
String 클래스  (0) 2024.04.01
java.lang 패키지와 그 첫번째 - Object  (0) 2024.03.31
OCP (Open-Closed Principle) 원칙  (0) 2024.03.30
다형성 (Part.2) 사용하기 ✨  (0) 2024.03.29
728x90
반응형
SMALL

참고 자료:

 

김영한의 실전 자바 - 중급 1편 | 김영한 - 인프런

김영한 | 실무에 필요한 자바의 다양한 중급 기능을 예제 코드로 깊이있게 학습합니다., [사진]국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바

www.inflearn.com

패키지 중 java.lang이라는 패키지가 있다. 자바가 기본으로 제공하는 라이브러리(클래스 모음) 중에 가장 기본이 되는 것이 바로 java.lang 패키지이다. 여기서 lang은 Language의 줄임말이다. 

 

java.lang 패키지의 대표적인 클래스들

  • Object: 모든 자바 객체의 최상위 부모 클래스
  • String: 문자열
  • Integer, Long, Double: Wrapper 타입, 기본형 데이터 타입을 객체로 만든 것
  • Class: 클래스 메타 정보
  • System: 시스템과 관련된 기본 기능들을 제공

이 클래스들은 너무나 기본이 되는 내용들이고 중요하기 때문에 반드시 잘 알아두어야 한다.

 

import 생략 가능

java.lang 패키지는 모든 자바 애플리케이션에 자동으로 임포트가 된다. 따라서 임포트 구문을 사용하지 않아도 된다.

다음과 같이 임포트 없이 사용할 수 있다.

 

Object 클래스

모든 객체의 최상위에는 항상 Object 클래스가 있다. "어? 저는 Object를 상속받은 클래스가 없는데요?" 자바가 알아서 해준다.

그러니까 extends 키워드로 특정 클래스를 상속받지 않는 클래스는 자바가 암묵적으로 extends Object를 넣어준다.

 

Object 클래스가 필요한 이유

그럼 왜 이 Object 클래스가 필요한 걸까? Object 클래스에는 다음과 같은 대표적인 메서드가 있다.

  • toString()
  • getClass()
  • equals()

이는 객체가 어떤 객체인지 알기 위해 반드시 필요한 기능이고 있어야만 한다. 근데 만약 Object라는 클래스가 없으면 개발자마다 저 기능들을 본인 입맛에 맞게 구현할 텐데 그때마다 다 시그니쳐가 다를 거다. 예를 들어, 객체끼리 서로 같은 객체인지 비교하기 위한 메서드인 equals()라는 메서드를 누군가는 same()이라는 메서드로 만들 수 있을 것이다. 이렇게 반드시 객체라면 필요한 기능을 규칙 없이 개발자마다 달라지는 게 아니라 자바가 딱 하나로 정의를 해두고 그것을 약속하면 서로 다른 개발자들끼리 혼동이 없을 것이다. 이 때문에 Object라는 클래스가 존재한다. 

 

그리고 한가지 더 Object라는 클래스가 존재하는 이유는 객체 지향의 꽃인 다형성의 시작을 내포하기 때문이다. Object는 모든 객체의 결국 최상위 부모이다. 그 말은 어떤 객체를 만들어도 Object라는 타입으로 담을 수 있다는 얘기다. 객체와 메모리 구조를 잘 떠올려보라.

어떤 객체를 새로 만들어도 위 그림과 같이 결국 최상위에는 Object가 있기 때문에 어떤 객체를 만들어도 Object로 업캐스팅이 가능하다. 즉, 시작부터 다형성인 것이다. 따라서 타입이 다른 객체들을 어딘가에 보관해야 한다면 바로 Object에 보관하면 된다.

 

이 말을 코드로써 표현해보자. 예를 들어, 배열이 있다고 생각해 보자. 근데 그 배열에는 여러 타입을 담고 싶은 것이다. 보통의 배열이라면 특정 타입으로 선언해서 해당 타입에 맞는 요소들만 담을 수 있는데 그게 아니고 이 타입 저 타입 모두 한 배열에 담고 싶은 요구사항이 있는 것이다. 이때 사용할 수 있는 게 Object인 것이다.

 

다음 코드를 보자. 전혀 다른 타입의 요소들이 하나의 배열에 들어가 있다. 이것이 바로 Object라는 클래스가 주는 다형성이다.

 

어떤 타입이던 Object는 그 상위에 있는 부모이기 때문에 담을 수가 있다. 만약, Object가 없다면 이러한 행위가 불가능하다. 물론, MyObject라는 클래스를 직접 개발자가 정의해서 모든 클래스마다 이 MyObject를 상속받으면 담을 수 있겠지만 그건 그 코드한정이다. 다른 개발자가 만든 코드에선 절대 호환되지 않을 것이다. 다른 개발자가 만든 코드에는 MyObject가 없을 테니까. 이게 Object 클래스가 있는 이유이다.

 

Object와 OCP

OCP 원칙을 기억하는가? Open-Closed Principle 즉, 확장에는 열려있고 변경에는 닫혀있는 이 원칙이 Object에도 이미 적용되어 있다. 이 원칙에 가장 대표적인 예시가 System.out.println()Object이다. 우리가 알고 그냥 썼던 System.out.println()은 파라미터로 Object 타입을 받는다. 그리고 실제 그 내부 코드를 들어가 보면 파라미터로 받는 ObjecttoString() 메서드를 호출한다는 사실을 아는가?

 

System.out.println()은 다형성의 끝판왕이다.

 

Object라는 가장 상위 부모인 클래스를 파라미터로 받아 다형적 참조가 가능하게 했고, toString()이라는 메서드를 호출할 때 어떤 인스턴스가 들어오던 toString()을 오버라이딩 했다면 그 오버라이딩한 메서드를 호출하고 그게 아니라면 부모가 가지고 있는 toString()이라는 메서드를 호출해서 메서드 오버라이딩 기능을 제대로 사용하고 있는 것이다.

 

그리고 그 결과 아무리 많은 클래스를 만들어도 그 클래스의 부모는 Object이기 때문에 확장에 무한히 열려있다(Open). 그리고 이렇게 확장을 원하는 대로 하더라도 사용하는 클라이언트 코드인 System.out.println()에는 어떠한 변경도 필요가 없다(Closed). 

 

완벽한 OCP 원칙이라고 볼 수 있다.

 

 

equals()

Object는 동등성 비교를 위한 equals() 메서드를 제공한다.

 

자바는 두 객체가 같다는 표현을 2가지로 분리해서 제공한다.

  • 동일성(Identity): == 연산자를 사용해서 두 객체의 참조가 동일한 객체를 가리키고 있는지 확인
  • 동등성(Equality): equals() 메서드를 사용해서 두 객체가 논리적으로 동등한 지 확인

동일은 완전히 같음을 의미한다. 반면, 동등은 같은 가치나 수준을 의미하지만 그 형태나 외관 등이 완전히 같지는 않을 수 있다.

쉽게 이야기해서 동일성은 물리적으로 같은 메모리에 있는 객체 인스턴스인지 참조값을 확인하는 것이고, 동등성은 논리적으로 같은지 확인하는 것이다. 동일성은 자바 머신 기준이고 메모리의 참조가 기준이므로 물리적이다. 반면 동등성은 보통 사람이 생각하는 논리적인 기준에 맞추어 비교한다. 

 

예를 들어, 같은 회원 번호를 가진 회원 객체가 2개 있다고 가정해 보자.

User user1 = new User("id-100");
User user2 = new User("id-100");

 

이 경우, 물리적으로 다른 메모리에 있는 다른 객체이지만, 회원 번호를 기준으로 생각해 보면 논리적으로는 같은 회원으로 볼 수 있다.

그 말은 동일성은 다르지만 동등성은 같다. 

 

문자의 경우도 생각해 보자.

String s1 = "hello";
String s2 = "hello";

이 경우 물리적으로는 각각의 "hello" 문자열이 다른 메모리에 존재할 수 있지만, 논리적으로는 같은 "hello"라는 문자열이다.

(사실 이 경우 자바가 같은 메모리를 사용하도록 최적화한다. 그래서 == 비교를 해도 '참'을 반환한다.)

 

동일성과 동등성 비교

예제를 통해서 동일성과 동등성을 비교해 보자.

 

어떤 결과가 나올 것 같은가? 우선 '==' 연산자를 사용한 경우 동일성, 즉 같은 메모리에 들어있는가?를 묻고 있는 것이기 때문에 거짓이 될 것이다. 그럼 동등성을 비교하는 equals()를 사용했을 때 두 User 객체가 같은 ID를 사용하므로 참을 반환할까? 결과는 다음과 같다.

 

실행결과:

Identity = false
Equality = false

 

'==' 연산자는 납득이 되는데 equals()를 사용했을 때도 false가 나왔다. 이 이유는 기본적으로 Object 클래스에 있는 equals() 메서드는 다음과 같이 생겼다. 즉, 똑같이 '==' 비교를 하고 있다.

 

엇? 왜 이럴까? 생각해 보면 모든 클래스는 다 동등성을 비교하는 기준이 다를 것이다. 어떤 클래스는 ID로, 어떤 클래스는 Name으로, 어떤 클래스는 그 두 개를 동시에 비교해서 같은지를 판단할 것이다. 즉, 누가 어떻게 어떤 의도로 만들었냐에 따라 동등성은 다 달라질 것이다라는 말이다. 그걸 Object라는 하나의 클래스가 모든 케이스를 다 정의할 수 없다. 그래서, 이 equals() 메서드를 사용자가 재정의(오버라이딩) 해야 한다. 그렇지 않으면 기본으로 Object 클래스는 eqauls() 메서드 내부에서 동일성 비교를 한다.

 

그럼 직접 equals() 메서드를 오버라이딩 해보자. 근데, 그럴 필요가 없다. 왜냐? IDE에서 잘 알아서 해준다. 그래서 우린 IDE의 도움을 받으면 된다. 어떻게 하냐? 맥 기준 커맨드 + N 을 눌러보면 다음과 같이 나온다.

여기어 보이는것과 같이 equals() and hashCode()를 선택하면 된다. 그럼 여러 선택 팝업이 나오는데 일단 이 부분은 Next를 클릭한다.

 

그리고 그 다음 나오는 부분이 중요하다면 중요할 수 있는데, 어떤 필드를 기준으로 동등성을 체크할지를 묻는 팝업이다. 지금은 id가 같으면 동등하다고 볼 것이므로 다음과 같이 ID를 체크하고 넘어가면 된다.

 

그래서 딱히 변경할 것 없이 다 Next 하고 Create 하면 다음과 같이 이쁜 equals()를 만들어준다.

 

코드를 하나씩 뜯어보자.

1. 처음에 '==' 비교가 있다. 즉, 참조값 자체가 같으면 동등을 떠나 동일하다는 거니까 바로 참을 반환한다.

2. 어떤 객체가 null인 경우 반드시 그건 동일하지 않아야 하며, 혹여나 현재 클래스와 다른 인스턴스 타입이라면 그것도 또한 동일하지 않다. 그래서 그 조건 중 하나라도 참이라면 거짓을 반환한다. 

3. 위 조건을 만족해야만 현재 클래스로 다운 캐스팅이 가능하다.

4. 체크한 id를 기반으로 this.id와 파라미터로 넘겨받은 객체의 id가 같다면 참, 다르다면 거짓을 반환한다.

 

 

 

728x90
반응형
LIST

'JAVA의 가장 기본이 되는 내용' 카테고리의 다른 글

String 클래스  (0) 2024.04.01
불변객체 (Immutable Object)  (2) 2024.04.01
OCP (Open-Closed Principle) 원칙  (0) 2024.03.30
다형성 (Part.2) 사용하기 ✨  (0) 2024.03.29
다형성 (Part.1) 매우중요 ✨  (0) 2024.03.28
728x90
반응형
SMALL

참고 자료:

 

김영한의 실전 자바 - 중급 1편 | 김영한 - 인프런

김영한 | 실무에 필요한 자바의 다양한 중급 기능을 예제 코드로 깊이있게 학습합니다., [사진]국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바

www.inflearn.com

좋은 객체 지향 설계 원칙 중 하나로 OCP 원칙이라는 것이 있다.

  • Open for extension: 새로운 기능의 추가나 변경 사항이 생겼을 때, 기존 코드는 확장할 수 있어야 한다.
  • Closed for modification: 기존의 코드는 수정되지 않아야 한다.

확장에는 열려있고, 변경에는 닫혀 있다는 뜻인데, 쉽게 이야기해서 기존의 코드 수정 없이 새로운 기능을 추가할 수 있다는 의미다. 인터페이스(또는 순수 추상 클래스)와 다형성을 이용해서 새로운 구현 클래스가 계속 늘어나더라도 그 인터페이스를 사용하는 클라이언트는 구현 클래스가 늘어난 사실조차 몰라도 아무런 코드의 변경없이 원래 의도대로 동작하는 것을 말한다. 

 

예를 들어, 다음 그림을 보자.

Driver라는 클래스는 인터페이스 Car를 의존한다. 여기서 의존이라는 말은 알고 있다는 사실로 봐도 무방하다. 그래서 Car라는 인터페이스가 가지고 있는 메서드 startEngine(), offEngine(), pressAccelerator() 이 세가지 메서드를 사용한다. 그리고 실제 Car라는 인터페이스를 구현하는 여러 구현 클래스가 있을 것이다. 그 중 K3, Model 3, Model Y, Genesis 등 여러 구현 클래스가 계속해서 늘어나더라도 Driver라는 클라이언트 입장에서는 늘어났다는 사실조차 몰라도 아무런 코드의 변경이 없이 기존 코드 그대로 동작가능하게 설계하는 것이 Open-Closed Principle이다.

 

코드로 하나하나 이해해보자.

 

Car

다음은 인터페이스 Car 코드이다. 이 Car라는 인터페이스는 3개의 메서드를 가지고 있다. 이제 Car를 구현하는 클래스는 반드시 이 세개의 메서드를 오버라이딩해야 한다.

public interface Car {

    void startEngine();
    void offEngine();
    void pressAccelerator();
}

 

K3

다음은 K3 클래스이다. Car를 구현하게 설계했으므로 반드시 오버라이딩 해야하는 3개의 메서드를 전부 오버라이딩한다.

public class K3 implements Car {

    @Override
    public void startEngine() {
        System.out.println("K3 시동 켜기");
    }

    @Override
    public void offEngine() {
        System.out.println("K3 시동 끄기");
    }

    @Override
    public void pressAccelerator() {
        System.out.println("K3 엑셀 밟기");
    }
}

 

Model 3

다음은 Model 3 클래스이다. K3와 마찬가지이다.

public class Model3 implements Car {

    @Override
    public void startEngine() {
        System.out.println("Model 3 시동 켜기");
    }

    @Override
    public void offEngine() {
        System.out.println("Model 3 시동 끄기");
    }

    @Override
    public void pressAccelerator() {
        System.out.println("Model 3 엑셀 밟기");
    }
}

 

Driver

Driver 클래스는 Car라는 인터페이스를 사용하는 클라이언트 입장이다. Car라는 인터페이스를 구현하는 클래스가 계속해서 늘어나더라도 이 코드의 변경사항은 없다. 즉, 여기서 바로 OCP원칙이 두각이 드러난다. 아무리 기능이 확장되어도(차종이 늘어나는것) 코드의 변경이 없다. 

public class Driver {

    private Car car;

    public void drive() {
        car.startEngine();
        car.pressAccelerator();
        car.offEngine();
    }

    public void setCar(Car car) {
        this.car = car;
    }

    public Car getCar() {
        return car;
    }
}

 

Main

실행 코드에서 확인해보자. 일단 K3를 Driver 클래스가 사용한다. K3는 Car라는 인터페이스를 구현하기 때문에 Driver의 메서드 setCar()에 파라미터로 K3를 넘겨줄 수 있다. 부모는 자식을 허용하기 때문에. 정확히는 객체와 메모리 구조를 생각해보면 K3라는 인스턴스는 참조 공간에 부모와 같이 쌓아 올려진다. 그렇기 때문에 부모 타입 변수에 담을 수 있는것이다. (업캐스팅)

public class Main {
    public static void main(String[] args) {
        Driver driver = new Driver();
        K3 k3 = new K3();

        driver.setCar(k3);

        driver.drive();

    }
}

이렇게 해서 실행해보면 다음과 같이 잘 실행된다.

실행결과:

K3 시동 켜기
K3 엑셀 밟기
K3 시동 끄기

 

근데 여기서 Model 3 로 자동차를 바꿔보자. 아예 없던 클래스라고 생각하고 새로 만들었다고 생각해보자. 즉, 기능의 확장이 일어난것이다. 근데 Driver 코드는 변경되지 않는다. 바뀌는 부분은 사용하는 코드만 변경될 뿐이다. 다음 코드처럼.

public class Main {
    public static void main(String[] args) {
        Driver driver = new Driver();

        Model3 model3 = new Model3();
        driver.setCar(model3);
        driver.drive();
    }
}

 

실행결과:

Model 3 시동 켜기
Model 3 엑셀 밟기
Model 3 시동 끄기

 

이것을 OCP 원칙이라고 한다. 그러니까 기존의 코드에 대한 변경이 아예 없을 순 없다. 새로운 기능을 추가하는 것 자체가 기존 코드에 변경이 일어나는데 어떻게 아예 변경을 안하겠는가? 그러나 절대적인 원칙은 지켜져야한다. 클라이언트의 코드는 변경되지 않거나 변경하더라도 최소화해야 한다. 이 코드에서 클라이언트는 누구인가? Driver다. 서버는 누구인가? Car라는 인터페이스다. 클라이언트는 서버만 알면 된다. 그 서버를 실제로 구현한 클래스가 100개든 1000개든 알 필요가 없다. 그리고 클라이언트의 코드는 변경이 필요가 없다. 이게 중요한 것이다. 사용할 때는 당연히 코드 변경이 필요하다. 위에 예시처럼, K3에서 Model3로 바꾸는 그런 과정들. 그리고 이것이 디자인 패턴 중에정말 중요한 하나인 전략 패턴이랑 매우매우 유사한데 전략 패턴을 하나 배웠다고 해도 과언이 아니다. 

728x90
반응형
LIST

+ Recent posts