Overview

과거 포스트에서 이미 MacOS OpenJDK 설치 및 버전 관리에 대해 다뤄본 적이 있으나 asdf 를 이용해서 설치하는 방법을 안내하려고 합니다.

원래 Java 를 설치하려면 brew 를 사용하거나 직접 홈페이지에 들어가 JDK 파일을 다운받아야 합니다.

하지만 한 PC 에서 여러 Java 버전을 사용한다면 터미널에서 빌드할 때마다 Java 버전을 바꿔야하고 관리하기도 까다롭습니다.

jenv 라는 Java 버전 관리 툴이 존재하지만 jenv 는 Java 를 직접 설치할 수는 없습니다.

하지만 asdf 라는 툴을 사용하면 Java 의 설치/삭제를 간단하게 하고 버전 관리도 편하게 할 수 있습니다.

뿐만 아니라 asdf 는 Java 외의 여러 언어, 오픈소스 등의 버전도 관리할 수 있습니다.


1. asdf 설치

mysetting - asdf 을 참고하면 설치 및 사용방법 등을 알 수 있습니다.

# install dependencies (필요시)
$ brew install coreutils curl git

# install asdf
$ brew install asdf

# add to shell
$ echo -e "\n. $(brew --prefix asdf)/asdf.sh" >> ~/.zshrc

우선 asdf 를 설치합니다.

마지막의 add to shell 은 사용자마다 다릅니다.

저는 zsh 를 사용하고 있기 때문에 ~/.zshrc 에 추가했고 만약 bash 를 사용한다면 ~/.bash_profile 에 추가하면 됩니다.


2. Java Plugin 설치

$ asdf plugin add java
$ asdf plugin update java

설치를 위해선 플러그인을 먼저 설치해야 합니다.


3. Java 버전 리스트 확인

$ asdf list-all java
adoptopenjdk-11.0.15+10
adoptopenjdk-11.0.16+8
adoptopenjdk-11.0.16+101
adoptopenjdk-11.0.17+8
adoptopenjdk-17.0.0+35
...
..
.
zulu-jre-javafx-19.30.11

설치할 수 있는 Java 버전을 확인합니다.

저는 원래 AdoptOpenJDK 를 사용하였으나 deprecated 되었기 때문에 Adoptimu 에서 권장하는 Temurin 버전을 사용합니다.

(AdoptOpenJDK Blog - Good-bye AdoptOpenJDK. Hello Adoptium! 참고)


4. Java 설치

# 설치
$ asdf install java temurin-11.0.17+8

# 설치된 확인
$ asdf list java
  temurin-11.0.17+8

Temurin 의 Java 11 버전 중 가장 최신 버전을 설치합니다.

설치 후에는 asdf list <언어> 명령어로 설치된 버전을 확인할 수 있으며 asdf list 만 입력하면 설치된 모든 오픈 소스의 모든 버전을 볼 수 있습니다.


5. 사용할 버전 지정

# global
$ asdf global java temurin-11.0.17+8

# local
$ asdf local java temurin-11.0.17+8

프로젝트 별로 설정하고 싶다면 local, 전역으로 설정하고 싶다면 global 을 사용해 지정합니다.


6. JAVA_HOME 설정하기

$ . ~/.asdf/plugins/java/set-java-home.zsh

halcyon/asdf-java - JAVA_HOME 를 참고해서 본인이 쓰는 shell 에 맞게 입력합니다.


7. Java 설치 완료

$ java -version
openjdk version "11.0.17" 2022-10-18
OpenJDK Runtime Environment Temurin-11.0.17+8 (build 11.0.17+8)
OpenJDK 64-Bit Server VM Temurin-11.0.17+8 (build 11.0.17+8, mixed mode)

터미널에서 자바 버전을 확인해서 제대로 나온다면 설치 완료입니다.


Reference

Overview

Java 에서 메서드를 호출 시 파라미터를 전달하는 방법에 대해 알아봅니다.

순서는 다음과 같이 진행합니다.

  1. Call by Value, Call by Reference 차이
  2. Java 에서의 파라미터 전달 방법
  3. JVM 메모리에 변수가 저장되는 위치
  4. 원시 타입 (Primitive Type) 전달
  5. 참조 타입 (Reference Type) 전달

1. Call by Value, Call by Reference

메서드를 호출할 때 파라미터를 전달하는 방법에는 두 가지가 있습니다.


1.1. Call by Value

Call by Value 는 메서드를 호출할 때 값을 넘겨주기 때문에 Pass by Value 라고도 부릅니다.

메서드를 호출하는 호출자 (Caller) 의 변수와 호출 당하는 수신자 (Callee) 의 파라미터는 복사된 서로 다른 변수입니다.

값만을 전달하기 때문에 수신자의 파라미터를 수정해도 호출자의 변수에는 아무런 영향이 없습니다.


1.2. Call by Reference

Call by Reference 는 참조 (주소) 를 직접 전달하며 Pass By Reference 라고도 부릅니다.

참조를 직접 넘기기 때문에 호출자의 변수와 수신자의 파라미터는 완전히 동일한 변수입니다.

메서드 내에서 파라미터를 수정하면 그대로 원본 변수에도 반영됩니다.


2. Java 에서의 파라미터 전달 방법

그럼 Java 에서는 어떤 방법을 사용할까요?

Java 로 개발을 해봤다면 메서드로 변수를 넘기고 거기서 값을 수정해본 경험이 있을 겁니다.

그래서, Call by Reference 라고 오해하기 쉽지만, Java 는 오직 Call by Value 로만 동작합니다.


3. JVM 메모리에 변수가 저장되는 위치

Java 의 Call by Value 에 대해 이해하기 위해선 먼저 변수 생성 시 메모리에 어떤 식으로 저장되는 지 알아야 합니다.

Java 에서 변수를 선언하면 Stack 영역에 할당됩니다.

원시 타입 (Primitive Type) 은 Stack 영역에 변수와 함께 저장되며

참조 타입 (Reference Type) 객체는 Heap 영역에 저장되고 Stack 영역에 있는 변수가 객체의 주소값을 갖고 있습니다.


그림으로 표현하면 이렇습니다.

원시 타입, 참조 타입을 생성할 때마다 동일한 방식으로 메모리에 할당됩니다.

이제 각 타입별로 파라미터를 넘겨줄 때 어떤 식으로 동작하는지 알아봅니다.


4. 원시 타입 (Primitive Type) 전달

원시 타입은 Stack 영역에 위치합니다

메서드 호출 시 넘겨받는 파라미터들도 원시 타입이라면 Stack 영역에 생성됩니다.

간단한 예시 코드와 함께 확인해봅니다.


public class PrimitiveTypeTest {

    @Test
    @DisplayName("Primitive Type 은 Stack 메모리에 저장되어서 변경해도 원본 변수에 영향이 없다")
    void test() {
        int a = 1;
        int b = 2;

        // Before
        assertEquals(a, 1);
        assertEquals(b, 2);

        modify(a, b);

        // After: modify(a, b) 호출 후에도 값이 변하지 않음
        assertEquals(a, 1);
        assertEquals(b, 2);
    }

    private void modify(int a, int b) {
        // 여기 있는 파라미터 a, b 는 이름만 같을 뿐 test() 에 있는 a, b 와 다른 변수
        a = 5;
        b = 10;
    }
}

위 코드에서 test() 의 변수 a, bmodify(a, b) 로 전달받은 파라미터 a, b 의 이름과 값은 같습니다.

하지만 다른 변수입니다.

modify(a, b) 를 호출하는 순간 Stack 영역에 새로운 변수 a, b 가 새로 생성되어 총 4 개의 변수가 존재합니다.


그림으로 보면 한눈에 이해가기 쉽습니다.

Stack 내부에 test()modify() 라는 영역이 나뉘어져 있고 거기에 동일한 이름을 가진 변수 a, b 가 존재합니다.

그래서 modify() 영역의 값을 바꿔도 test() 영역의 변수는 변화가 없습니다.

원시 타입의 전달은 값만 전달하는 Call by Value 로 동작합니다.


5. 참조 타입 (Reference Type) 전달

참조 타입은 원시 타입과는 조금 다릅니다.

변수 자체는 Stack 영역에 생성되지만 실제 객체는 Heap 영역에 위치합니다.

그리고 Stack 에 있는 변수가 Heap 에 있는 객체를 바라보고 있는 형태입니다.

마찬가지로 코드 예시와 함께 알아봅니다.


class User {
    public int age;

    public User(int age) {
        this.age = age;
    }
}

public class ReferenceTypeTest {

    @Test
    @DisplayName("Reference Type 은 주소값을 넘겨 받아서 같은 객체를 바라본다" +
                 "그래서 변경하면 원본 변수에도 영향이 있다")
    void test() {
        User a = new User(10);
        User b = new User(20);

        // Before
        assertEquals(a.age, 10);
        assertEquals(b.age, 20);

        modify(a, b);

        // After
        assertEquals(a.age, 11);
        assertEquals(b.age, 20);
    }

    private void modify(User a, User b) {
        // a, b 와 이름이 같고 같은 객체를 바라본다.
        // 하지만 test 에 있는 변수와 확실히 다른 변수다.

        // modify 의 a 와 test 의 a 는 같은 객체를 바라봐서 영향이 있음
        a.age++;

        // b 에 새로운 객체를 할당하면 가리키는 객체가 달라지고 원본에는 영향 없음
        b = new User(30);
        b.age++;
    }
}

원시 타입 코드와 마찬가지로 동일한 변수 a, b 가 존재합니다.

여기서 modify(a, b) 를 호출한 후에 a.age 의 값이 변경되었기 때문에 Call by Reference 로 파라미터를 넘겨주었다고 착각하기 쉽습니다.

하지만 Reference 자체를 전달하는 게 아니라 주소값만 전달해주고 modify() 에서 생긴 변수들이 주소값을 보고 객체를 같이 참조하고 있는 겁니다.

단계별 그림으로 확인해봅니다.


5.1. 처음 변수 선언 시 메모리 상태

원시 타입과는 다르게 변수만 Stack 영역에 생성되고 실제 객체는 Heap 영역에 생성됩니다.

각 변수는 Heap 영역에 있는 객체를 바라보고 있습니다.


5.2. modify(a, b) 호출 시점의 메모리 상태

넘겨받은 파라미터는 Stack 영역에 생성되고 넘겨받은 주소값을 똑같이 바라봅니다.


5.3. modify(a, b) 수행 직후 메모리 상태

test() 영역과 modify() 영역에 존재하는 a 라는 변수들은 같은 객체인 User01 을 바라보고 있기 때문에 객체를 공유합니다.

b 라는 변수는 서로 같은 객체인 User02 를 바라보고 있었지만 modify(a, b) 내부에서 새로운 객체를 생성해서 할당했기 때문에 User03 이라는 객체를 바라봅니다.

그래서 User03age 값을 변경해도 test() 에 있는 b 에는 아무런 변화가 없습니다.


5.4. test() 끝난 후 최종 메모리 상태

modify(a, b) 메서드를 빠져나오면 Stack 영역에 할당된 변수들은 사라집니다.

최종적으로 위와 같은 상태가 되며 User03 은 어떤 곳에서도 참조되고 있지 않기 때문에 나중에 Garbage Collector 에 의해 제거될 겁니다.


Conclusion

"결국 주소값을 넘기는 게 결국 Call by Reference 아닌가?" 라는 생각을 할 수도 있습니다.

하지만 Call by Reference 는 참조 자체를 넘기기 때문에 새로운 객체를 할당하면 원본 변수도 영향을 받습니다.

가장 큰 핵심은 호출자 변수와 수신자 파라미터는 Stack 영역 내에서 각각 존재하는 다른 변수다 라고 생각합니다.


Reference

Overview

compile 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-web'

testCompile 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.boot:spring-boot-starter-test'

build.gradle 에 관한 설정을 검색하다보면 의존 라이브러리를 추가할 때 두가지 방법을 자주 봅니다.

compileimplementation 은 무슨 차이가 있을까요?


1. compile 은 상위 모듈까지 가져온다

compileimplementation 보다 더 많은 라이브러리를 빌드합니다.

예를 들어 다음과 같이 의존하는 관계의 프로젝트 세 개가 있다고 가정합니다.

myApp -> mySpring -> myJava

myApp 에서 mySpring 을 의존하고 mySpring 은 myJava 를 의존합니다.

이 때 compile 을 사용해서 mySpring 을 빌드하게 되면 mySpring 이 의존하고 있는 myJava 까지 함께 빌드합니다.

그래서 myApp 에서 myJava 모듈이 제공하는 API 까지 사용할 수 있습니다.

만약 myJava 를 직접적으로 사용할 필요가 없다면 필요하지 않은 API 들이 노출되고 빌드 시간도 오래 걸리기 때문에 비효율적인 행동이 됩니다.

대신 implementation 을 사용해서 빌드하면 mySpring 모듈만 가져오기 때문에 빌드 속도가 빠르고 필요한 API 만 노출해서 사용할 수 있습니다.


2. compile 은 deprecated 되었다

그리고 compile 은 deprecated 되고 api 로 대체되었습니다.

그러니 만약 상위 모듈까지 전부 가져오고 싶을 땐 compile 대신 api 를 사용하면 됩니다.

일반적인 경우에는 implementation 을 사용해서 빌드 속도를 향상시키는 것이 좋습니다.


Conclusion

  • implementation 은 지정한 모듈만 가져오고 compile, api 는 상위 모듈까지 전부 가져옵니다.
  • compile 은 deprecated 되었고 대신 api 를 사용하면 됩니다.
  • 일반적인 상황에서는 빌드 속도가 빠르고 필요한 모듈만 가져오는 implementation 을 사용하면 됩니다.

Reference

1. Overview

Java Enum 1편 : Enum 기본적인 사용에 대해서는 이미 학습했습니다.

이번에는 Enum 에 메소드를 추가하여 원하는 동작을 만들어내는 방법과 그밖의 활용법을 알아봅니다.


2. 메소드 추가 1: Enum 상수 별로 다른 동작이 필요할 때

가장 쉽게 떠올릴 수 있는 방법은 switch 문입니다.

하지만 Enum 클래스에는 상수별 메소드 구현 (Constant-specific Method Implementation) 이라는 좀더 깔끔한 방법이 있습니다.


2.1. Before

public enum Operation {
    PLUS, MINUS, TIMES, DIVIDE;

    // 상수가 뜻하는 연산을 수행한다.
    public double apply(double x, double y) {
        switch (this) {
            case PLUS: return x + y;
            case MINUS: return x - y;
            case TIMES: return x * y;
            case DIVIDE: return x / y;
        }
        throw new AssertionError("알 수 없는 연산: " + this);
    }
}
  • 깔끔해보이지만 뭔가 아쉬움
  • 마지막 AssertionError 는 실제로는 도달하지 기술적으로는 도달 가능하기 때문에 생략 불가능
  • 새로운 상수를 추가하면 case 문도 추가해야함

2.2. After

public enum Operation {
    PLUS   { public double apply(double x, double y) { return x + y; }},
    MINUS  { public double apply(double x, double y) { return x - y; }},
    TIMES  { public double apply(double x, double y) { return x * y; }},
    DIVIDE { public double apply(double x, double y) { return x / y; }};

    public abstract double apply(double x, double y);
}
  • Enum 상수값 별로 다르게 동작하는 코드를 구현
  • apply 라는 추상 메소드를 선언하고 각 상수에서 재정의
  • 이를 상수별 메소드 구현 (constant-specific method implementation) 이라고 함
  • 추상 메소드로 정의되어 있기 때문에 새로운 상수를 추가해도 실수할 가능성이 적음

3. 메소드 추가 2: Enum 상수 일부가 같은 동작을 공유할 때

위에서 본 방법은 Enum 에 있는 각각의 상수가 모두 다른 동작을 할 때 사용했습니다.

만약 일부 상수끼리 같은 동작을 공유해야 할 때는 어떻게 해야 할까요?

일반적으로 생각 가능한 방법은 두가지가 있습니다.

  1. 상수별로 메소드를 구현해서 같은 동작 코드를 중복해서 넣는다.
  2. 별도의 메소드를 하나 만들어서 상수별 메소드에서 호출한다.

위 두가지 방법 모두 중복된 코드를 작성해야 한다는 단점이 있습니다.

다행히 Enum 클래스에서는 이러한 상황에서 전략 열거 타입 (Enum) 이라는 방법이 있습니다.


3.1. Before

public enum Fruit {
    APPLE, ORANGE, BANANA, STRAWBERRY;

    public void printColor() {
        switch (this) {
            case APPLE:
            case STRAWBERRY:
                System.out.println("This is Red");
                break;
            default:
                System.out.println("This is Not Red");
        }
    }
}
  • 과일을 나타내는 Fruit Enum 클래스
  • printColor() 메소드를 호출하면 빨간색 과일들과 나머지 과일들의 출력 결과문이 다름
  • 위의 문제점과 마찬가지로 새로운 빨간색 과일을 추가했을 때 switch 문에도 추가하지 않으면 빨간색 과일인데 "This is Not Red" 가 출력됨

3.2. After

public enum Fruit {
    APPLE(ColorType.RED),
    ORANGE(ColorType.OTHER),
    BANANA(ColorType.OTHER),
    STRAWBERRY(ColorType.RED);

    private final ColorType colorType;

    Fruit(ColorType colorType) {
        this.colorType = colorType;
    }

    public void printColor() {
        colorType.printColor();
    }

    enum ColorType {
        RED {
            void printColor() {
                System.out.println("This is Red");
            }
        },
        OTHER {
            void printColor() {
                System.out.println("This is Not Red");
            }
        };

        abstract void printColor();
    }
}
  • Fruit Enum 클래스 내부에 ColorType 이라는 Inner Enum 클래스를 정의
  • printColor() 의 동작을 ColorType 에 위임
  • 새로운 빨간색 과일이 추가되더라도 ColorType 을 지정해야 하므로 실수할 일이 적음

4. 메소드 추가 3: 여러 상수별 동작이 혼합될 때

한 Enum 상수값의 동작에 다른 Enum 상수값이 필요하다면 그냥 switch 문을 쓰는 것이 좋습니다.

public enum Direction {
    NORTH, EAST, SOUTH, WEST;

    public static Direction rotate(Direction dir) {
        switch (dir) {
            case NORTH: return EAST;
            case EAST:  return SOUTH;
            case SOUTH: return WEST;
            case WEST:  return NORTH;
        }
        throw new AssertionError("알 수 없는 방향: " + dir);
    }
}

5. ordinal 메서드 대신 인스턴스 필드를 사용하라

Enum 클래스에는 기본적으로 ordinal 이라는 메소드를 제공합니다.

0 부터 시작되며 특정 상수값의 위치 (Index) 를 리턴해줍니다.

Enum API 문서를 보면 ordinal 에 대해서 이렇게 쓰여 있습니다.

"대부분의 개발자는 이 메소드를 쓸 일이 없다. 이 메소드는 EnumSetEnumMap 같이 열거 타입 기반 범용 자료구조에 쓸 목적으로 설계되었다."

oridnal 을 사용할 때의 단점은 여러 가지 있습니다.

  • 나중에 추가될 Enum 상수값이 꼭 순서대로라는 보장이 없다
  • 중복된 숫자를 가져야 할 때 구분이 불가능하다

그러므로 ordinal 메소드를 사용하지 말고 별도의 인스턴스 필드를 선언해서 사용합시다.


6. ordinal 인덱싱 대신 EnumMap 을 사용하라

Enum 값을 Index 로 사용하고 싶을 때 배열 + ordinal 을 사용하는 것보다 EnumMap 을 사용하는 것이 좋습니다.

EnumMap 도 내부적으로 ordinal 을 사용하기 때문에 성능 상의 차이도 없습니다.

위에서도 한번 언급했었지만 개발자가 직접 ordinal 을 쓸 상황은 없습니다.


7. 비트 필드 대신 EnumSet 을 사용하라

과거에는 여러 값들을 집합으로 사용해야 할 경우 비트로 사용했습니다.

public class Text {
    public static final int STYLE_BOLD          = 1 << 0;   // 1
    public static final int STYLE_ITALIC        = 1 << 1;   // 2
    public static final int STYLE_UNDERLINE     = 1 << 2;   // 4
    public static final int STYLE_STRIKETHROUGH = 1 << 3;   // 8

    // 매개변수 styles 는 0 개 이상의 STYLE_ 상수를 비트별 OR 한 값
    public void applyStyles(int styles) {
        // ...
    }
}

// usage
text.applyStyles(STYLE_BOLD | STYLE_ITALIC);
  • 여러 개의 상수값을 OR 하여 사용하면 집합을 나타낼 수 있음
  • 이렇게 만들어진 집합을 비트 필드 (bit field) 라고 함
  • 비트 필드 값은 해석하기 어려움
  • 최대 몇 비트가 필요한지 API 작성 시 미리 예측하여 적절합 타입 (int, long) 을 선택해야 함

7.1. EnumSet 클래스

public class Text {
    public enum Style { BOLD, ITALIC, UNDERLINE, STRIKETHROUGH }

    public void applyStyles(Set<Style> styles) {
        // ...
    }
}

// usage
text.applyStyles(EnumSet.of(Text.Style.BOLD, Text.Style.ITALIC));
  • java.util 패키지
  • Set 인터페이스를 구현하며, 타입 안전하고, 다른 어떤 Set 구현체와도 함께 사용 가능
  • EnumSet 내부는 비트 벡터로 구현됨

Reference

1. Overview

Java Enum 타입은 일정 개수의 상수 값을 정의하고, 그 외의 값은 허용하지 않습니다.

과거에는 특정 상수값을 사용하기 위해선 모두 상수로 선언해서 사용했습니다.

public static final String MON = "Monday";
public static final String TUE = "Tuesday";
public static final String WED = "Wednesday";

이렇게 사용하면 개발자가 실수하기도 쉽고 한눈에 알아보기도 쉽지 않습니다.

그리고 관련있는 값들끼리 묶으려면 접두어를 사용해서 점점 변수명도 지저분해집니다.

Enum 클래스는 이러한 문제점을 말끔히 해결해주는 굉장히 유용한 클래스입니다.

추가적인 활용법은 Java Enum 2편 : 여러가지 활용법 에서 다루기로 하고 여기서는 기본적인 사용법에 대해서 알아봅니다.


2. 정의

public enum Day {
    MON, TUE, WED, THU, FRI, SAT, SUN
}

위처럼 단순하게 요일을 열거한 Enum 클래스를 만들 수 있습니다.

하지만 각각의 요소들이 특정한 값을 갖게 하고 싶을 수도 있습니다.

예를 들어, 각 요일의 풀네임 (full-name) 이 필요할 때도 있을겁니다.


3. 생성자와 final 필드 추가

public enum Day {
    MON("Monday"),
    TUE("Tuesday"),
    WED("Wednesday"),
    THU("Thursday"),
    FRI("Friday"),
    SAT("Saturday"),
    SUN("Sunday")
    ;

    private final String label;

    Day(String label) {
        this.label = label;
    }

    public String label() {
        return label;
    }
}

Enum 요소에 특정 값을 매핑하고 싶다면 위 코드처럼 필드값을 추가하면 됩니다.

여기서는 label 이라는 String 값을 추가했습니다.

필드값을 추가하면 생성자도 함께 추가해야하는데 Enum 클래스는 생성자가 있다고 하더라도 new 연산으로 생성할 수 없습니다.


System.out.println(Day.MON.name());      // MON
System.out.println(Day.MON.label());     // Monday

이렇게 규칙이 존재하는 특정 요소들을 하나의 Enum 클래스로 묶어두면 가독성도 좋아지고 if 문으로 일일히 검사할 필요도 없어서 편리합니다.

필드값을 추가할 때 이름을 name 으로 정하는건 피하는게 좋습니다.

Enum 클래스 자체에서 name() 이라는 메소드를 제공하기 때문에 헷갈릴 수 있습니다.


4. 필드값으로 Enum 값 찾기

Enum 은 자체적으로 name() 값으로 Enum 값을 찾는 valueOf() 메소드를 제공합니다.

특정 필드값으로 찾는 기능은 제공하지 않기 때문에 직접 만들어야 합니다.


4.1. 직접 Enum values() 순회하며 찾기

public enum Day {
    // ..codes

    public static Day valueOfLabel(String label) {
        return Arrays.stream(values())
                    .filter(value -> value.label.equals(label))
                    .findAny()
                    .orElse(null);
    }
}

다른 필드값으로 찾기 위해선 위 코드와 같이 모든 Enum 값을 순회하면서 일치하는 값이 있는지 찾아야 합니다.


4.1. 캐싱해서 순회 피하기

public enum Day {
    // ..codes

    private static final Map<String, Day> BY_LABEL =
            Stream.of(values()).collect(Collectors.toMap(Day::label, e -> e));

    public static Day valueOfLabel(String label) {
        return BY_LABEL.get(label);
    }
}

HashMap 을 사용해서 값을 미리 캐싱해두면 조회할 때마다 모든 값을 순회할 필요가 없습니다.

처음부터 값을 캐싱해두는게 싫다면 valueOfLabel() 에 처음 접근할 때 Lazy Caching 할 수 있습니다.

다만, 이 때는 동시성 문제 해결을 위해 HashMap 을 동기화 해야 합니다.

그리고 위 코드에서는 valueOfLabel() 메소드를 그냥 리턴했기 때문에 없는 label 값으로 호출하면 null 값이 리턴됩니다.

이런 경우에 사용자에게 nullable 가능성을 알려주기 위해 반환 값을 Optional<Day> 로 넘겨주는 방법도 있습니다.


5. 여러 개의 값 연결하기

public enum Day {
    MON("Monday", 10),
    TUE("Tuesday", 20),
    WED("Wednesday", 30),
    THU("Thursday", 40),
    FRI("Friday", 50),
    SAT("Saturday", 60),
    SUN("Sunday", 70)
    ;

    private final String label;
    private final int number;

    Day(String label, int number) {
        this.label = label;
        this.number = number;
    }

    public String label() {
        return label;
    }

    public int number() {
        return number;
    }

    private static final Map<String, Day> BY_LABEL =
            Stream.of(values()).collect(Collectors.toMap(Day::label, Function.identity()));

    private static final Map<Integer, Day> BY_NUMBER =
            Stream.of(values()).collect(Collectors.toMap(Day::number, Function.identity()));

    public static Day valueOfLabel(String label) {
        return BY_LABEL.get(label);
    }

    public static Day valueOfNumber(int number) {
        return BY_NUMBER.get(number);
    }
}

위 코드처럼 여러 개의 필드값을 세팅할 수 있습니다.

여기서 사용한 예시는 요일이라서 그냥 number 필드를 추가했지만 만약 과일이라면 사과의 이름, 색, 무게, 가격 등등 여러 요소를 한번에 매핑시켜서 정의할 수 있습니다.


Reference

Overview

Java Coding Best Practices And Standards 에 있는 글을 번역한 글입니다.

여러 개의 항목이 있었는데, 그 중에서 제가 개발하면서 공감되었던 부분들만 간단하게 정리했습니다.

굉장히 기본적인 내용도 있는데 초심을 잊지 말자는 의미에서 기록합니다.

  1. NullPointerException 을 고려하자
  2. String 생성할 때 new 키워드를 사용하지 말자
  3. 반복문 내에서 새로운 객체를 생성하지 말자
  4. Collections 을 반복하는 동안 수정하지 말자
  5. Switch-Case 문에서 break 키워드를 뺴먹지 말자
  6. 객체 비교 "==" 와 "eqauls()" 의 차이를 알자
  7. 무작정 StringBuffer 를 사용하지 말자
  8. Java 파일 작성 시 표준

1. NullPointerException 을 고려하자

개발할 때 NullPointerException, 즉 NPE 를 고려하지 않는 경우가 있습니다.

항상 발생가능한 상황을 고려해서 null 체크를 해주어야 합니다.

Java 8 부터는 Optional 을 사용할 수도 있습니다.


가장 흔한 케이스가 Stringequals() 입니다.

// Bad Case : name 으로 null 값이 넘어오면 NPE 발생
public boolean isKim(String name) {
    return name.equals("Kim");
}

// Good Case 1 : name 이 null 값이어도 NPE 발생하지 않음
public boolean isKim(String name) {
    return "Kim".equals(name);
}

// Good Case 2 : org.apache.commons.lang 에서 제공하는 StringUtils 클래스 사용해서 비교
// (내부적으로 null 체크해서 안전함)
public boolean isKim(String name) {
    return StringUtils.equals(name, "Kim");
}

빈 컬렉션에 null 값을 세팅하는 것도 나쁜 습관입니다.

null 값 대신에 Collections.EMPTY_LIST 같은 값을 넣어줍시다.

// Bad Case : null 세팅
List<String> strings = null;

// Good Case : EMPTY_LISTY 세팅
List<String> strings = Collections.EMPTY_LIST

2. String 생성할 때 new 키워드를 사용하지 말자

// Slow
String s = new String("hello");

// Fast
String s = "hello";

new 키워드를 사용하면 항상 새로운 객체를 만들고 힙에 추가합니다.

반면, 사용하지 않으면 String pool 을 먼저 확인하고 없는 경우에만 추가하기 때문에 좀더 효율적입니다.


3. 반복문 내에서 새로운 객체를 생성하지 말자

for (int i = 0; i < 5; i++) {
    Foo f = new Foo();
    f.getMethod();
}

루프 반복횟수 만큼 많은 객체가 생성되기 때문에 지양해야 합니다.


4. Collections 을 반복하는 동안 수정하지 말자

List<String> names = new ArrayList<>();
names.add("Kim");
names.add("Lee");
names.add("Park");

for (String name : names) {
    if ("Kim".equals(name)) {
        names.remove(name);
    }
}

예를 들어 이름 목록에서 Kim 을 삭제하려고 합니다.

위 코드는 names 라는 리스트를 반복하는 동시에 remove() 로 요소를 삭제하고 있습니다.

이러면 ConcurrentModificationException 가 발생할 수 있습니다.


5. Switch-Case 문에서 break 키워드를 빼먹지 말자

int index = 0;

switch (index) {
    case 0:
        System.out.println("Zero");
    case 1:
        System.out.println("One");
        break;
    case 2:
        System.out.println("Two");
    break;
    // ...
    default:
        System.out.println("Default");
}

위 코드를 보면 case 0 에서 break 문을 빼먹었기 때문에 "Zero" 이후에 "One" 까지 출력됩니다.


6. 객체 비교 "==" 와 "eqauls()" 의 차이를 알자

== 연산자는 객체 참조가 같은 지 비교합니다.

equals() 메소드는 객체의 값을 비교합니다.

대부분은 equals() 메소드를 사용해서 비교하지만, 이 둘의 차이점을 잘 모르고 사용하는 경우가 있습니다.


7. 무작정 StringBuffer 를 사용하지 말자

StringBuilderStringBuffer 의 차이를 잘 모르고 무작정 StringBuffer 를 사용하는 경우가 있습니다.

StringBuffer 는 기본적으로 동기화되기 때문에 많은 오버헤드를 생성할 수 있습니다.

따라서 동기화가 우선순위가 아니라면 StringBuilder 를 사용하는 것도 고려해보아야 합니다.


8. Java 파일 작성 시 표준

  • 변수는 public, protected, private 순으로 정의합니다.
  • 생성자는 필드 수가 적은 생성자를 먼저 정의합니다.
  • 메소드는 접근성 (accessibility) 이 아닌 기능별 (functionality) 로 그룹화되어야 합니다. 예를 들어 public 메소드 사이에 private 메소드가 올 수도 있습니다.
  • 코드 설명을 위해 주석을 사용할 수도 있지만, 최대한 주석을 줄이고 가독성 있는 코드를 작성하는 것이 좋습니다.

Reference

1. Overview

Java 의 람다 표현식은 익명 함수처럼 자유 변수 (Free Variable) 를 활용할 수 있습니다.

여기서 자유변수란 파라미터로 넘겨진 변수가 아닌 외부에서 정의된 변수 를 의미합니다.


2. 지역 변수 제약

하지만 모든 자유 변수를 사용할 수 있는건 아닙니다.

변수의 종류에는 클래스 변수, 인스턴스 변수, 지역 변수가 있는데 이 중에서 지역 변수는 final 이거나 final 처럼 사용 되어야 합니다.

여기서 final 처럼 사용되어야 한다는건 effectively final 라고 표현하는데 final 선언이 되어 있지 않지만 변수의 재할당이 발생하지 않는 변수 를 의미합니다.


3. Effectively final

람다 표현식에서 사용 불가능한 지역 변수는 인텔리제이에서 다음과 같이 경고해줍니다.

Variable used in lambda expression should be final or effectively final

간단한 예를 통해 effectively final 가 뭔지 쉽게 이해해 봅니다.


3.1. 가능

int number = 1357;
Runnable r = () -> System.out.println(number);

위 코드에서 number 변수는 final 선언이 되어 있지 않지만 에러가 발생하지 않습니다.

변수의 재할당이 이루어지지 않았기 때문입니다.


3.2. 불가능

int number = 1357;
number = 2;
Runnable r = () -> System.out.println(number);

위 코드는 number 가 한번 선언된 후 2 로 재할당 되었기 때문에 람다 표현식 내부에서 사용 불가능합니다.


int number = 1357;
Runnable r = () -> System.out.println(number);
number = 3;

마찬가지로 사용한 이후에 변경되어도 사용 불가능합니다.


4. 이유가 뭘까?

우선 인스턴스 변수와 지역 변수의 차이점부터 알아야 합니다.

인스턴스 변수는 힙에 저장되고 지역 변수는 스택에 저장됩니다.

스택 영역은 힙과 달리 각 쓰레드끼리 공유되지 않는 영역 입니다.

참고로 클래스 변수는 쓰레드끼리 공유 되는 메소드 영역에 저장됩니다.


4.1. Lambda Capturing

람다 표현식은 다른 쓰레드에서도 실행 가능합니다.

만약 A 쓰레드에서 생성한 람다 표현식을 B 쓰레드에서 사용한다고 생각해봅니다.

인스턴스 변수는 쓰레드끼리 공유 가능한 힙 영역에 저장되기 때문에 공유 가능하지만 스택 영역에 있는 지역 변수는 외부 쓰레드에서 접근 불가능합니다.

따라서 자바에서는 스택 영역에 있는 변수를 사용할 수 있도록 지역 변수의 복사본을 제공해서 람다 표현식에서 접근 가능하도록 합니다.

이를 람다 캡쳐링이라고 합니다.

그런데 원본 값이 바뀌면 복사본에 있는 값과의 동기화가 보장되지 않기 때문에 동시성 이슈가 발생합니다.

그래서 지역 변수는 값이 변하지 않는다는 final 임이 보장되어야 합니다.


5. Conclusion

Inner 클래스, 익명 (Anonymous) 클래스, 람다 표현식 등에서 사용되는 외부 지역 변수가 final 또는 effectively final 상태여야 하는 이유를 알아봤습니다.

요약하면 아래와 같습니다.

  1. 람다 표현식은 여러 쓰레드에서 사용할 수 있다.
  2. 힙 영역에 저장되는 인스턴스 변수와 달리 스택 영역에 저장되는 지역 변수는 외부 쓰레드에서 접근 불가능하다.
  3. 외부 쓰레드에서도 지역 변수 값을 사용할 수 있도록 복사본 기능을 제공하는데 이를 람다 캡쳐링이라고 한다.
  4. 복사본은 원본의 값이 바뀌어도 알 수 없기 때문에 쓰레드 동기화를 위해 지역 변수는 final 또는 effectively final 상태여야 한다.

Reference

Overview

Java 8 Stream 에는 count() 라는 종결 함수가 있습니다.

현재 Stream 의 원소 갯수를 카운트 해서 long 타입으로 리턴합니다.


count 의 중간 연산으로 peek 사용

public class NotePad {
    public static void main(String[] args) {
        Stream.of(1, 2, 3, 4, 5)
              .peek(System.out::println)
              .count();
    }
}

위 코드는 어떻게 동작할까요?

아무것도 나오지 않습니다.

원인을 몰라서 구글링 해봤더니 Java 9 Stream API Reference 에 다음과 같은 글이 있었습니다.


The number of elements covered by the stream source, a List, is known and the intermediate operation, peek, does not inject into or remove elements from the stream (as may be the case for flatMap or filter operations). Thus the count is the size of the List and there is no need to execute the pipeline and, as a side-effect, print out the list elements.


요약하자면 count() 라는 종결함수는 Stream 의 갯수를 세기 때문에 효율을 위해 중간 연산을 생략하기도 한다는 뜻입니다.

그래서 중간 연산을 강제로 실행시키고 싶다면 filterflatMap 과 같이 Stream 요소의 갯수를 변화시킬 수 있는 중간 연산을 추가하면 됩니다.


filter 로 강제 출력

public class NotePad {
    public static void main(String[] args) {
        Stream.of(1, 2, 3, 4, 5)
              .filter(e -> e > 0)
              .peek(System.out::println)
              .count();
    }
}

filter 를 추가하면 peek 도 정상적으로 동작합니다.


1
2
3
4
5

Reference

+ Recent posts