Overview

Kotlin 에서 엔티티를 정의할 때 @Id 값을 정의하는 방법에는 여러가지가 있습니다.

Java 에서는 기본적으로 모든 변수가 nullable 하기 때문에 딱히 의견이 갈릴 일이 없었는데요.

Kotlin 에서 많이 사용하는 대표적인 @Id 정의 스타일과 장단점을 알아보겠습니다.


1. val + nullable 정의

@MappedSuperclass
abstract class BaseEntity1 {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long? = null
}

아마 Kotlin + JPA 를 사용하면서 가장 많이 보이는 스타일일 것 같습니다.

특징

  • 엔티티의 특성상 DB 에 저장되기 전까지는 null 값이므로 nullable 타입을 사용

이 스타일의 단점이라고 하면 id 가 nullable 이기 때문에 도메인 로직에서 사용할 때는 id!!requireNotNull(id) 등을 사용해야 할 수 있습니다.


2. id 기본값을 0L 으로 지정

@MappedSuperclass
abstract class BaseEntity2 {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0L
}

이 스타일 역시 Kotlin + JPA 에서 많이 보이는 스타일입니다.

특징

  • Notnull 타입으로 정의했기 때문에 매번 !! 을 붙이지 않아도 됨 (NPE 방지)
  • DDD 관점에서는 도메인 객체가 "불완전한 상태(null ID)"를 가지는 것이 자연스럽지 않기 때문에 이를 해결

가장 큰 특징으로는 nullable 을 지양하는 Kotlin 의 철학을 지킬 수 있다는 점입니다.

엔티티의 id 는 DB 에 저장되기 전까지 null 인데 어떻게 0L 을 사용할 수 있는걸까요?


2.1. Repository#save

@Override
@Transactional
public <S extends T> S save(S entity) {

    Assert.notNull(entity, ENTITY_MUST_NOT_BE_NULL);

    if (entityInformation.isNew(entity)) {
        entityManager.persist(entity);
        return entity;
    } else {
        return entityManager.merge(entity);
    }
}

JPA 의 Repository saveisNew 라는 함수를 사용해서 엔티티가 새로 생성된건지 기존 데이터인지 검사합니다.

새로 생성된 데이터라면 persist 를 진행해서 데이터를 저장, id 에 값을 주입하고 기존 데이터라면 merge 를 사용해서 업데이트 합니다.


2.2. isNew

@Override
public boolean isNew(T entity) {

    ID id = getId(entity);
    Class<ID> idType = getIdType();

    if (!idType.isPrimitive()) {
        return id == null;
    }

    if (id instanceof Number n) {
        return n.longValue() == 0L;
    }

    throw new IllegalArgumentException(String.format("Unsupported primitive id type %s", idType));
}

Spring Data JPA 내부의 Repository 구현을 보면 isNew 함수에서 id == null 뿐만 아니라 id == 0L 또한 "새로운 객체" 라고 판단해줍니다.

그래서 null 대신 0L 을 입력해도 JPA 가 정상적으로 동작하는 겁니다.

하지만 JPA 의 구현체로 Hibernate 가 아닌 다른 걸 사용한다면 0L 을 저장되지 않은 구현체로 보장하지 않기 때문에 문제가 발생할 수 있습니다.

그리고 id 를 0L 로 정의한다는 것 자체가 어색하거나 불편하게 느껴질 수도 있습니다.


3. var + nullable 정의

@MappedSuperclass
abstract class BaseEntity3 {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null
}

Java 와 가장 유사하게 동작하는 스타일입니다.

Jetbrain 이나 Spring 에서 제공하는 예제 코드에서 등장하는데 오픈소스 예제에서는 테스트 시 직접 id 값을 설정하거나 유연하게 다루기 위해 때문에 사용하는 것 같습니다.


4. 개인적인 사용 방식

저는 위의 스타일들 중에서 마음에 안드는 부분이 하나씩 있었습니다.

  • 저장되지 않은 id0L 을 넣는 것
  • nullable 하게 정의하면 호출할 때 id!! 를 사용해야 하는 것
  • 그렇다고 별도의 id() 메서드를 정의하는 것도 마음에 안듬

그래서 Backing Field 를 사용했습니다.


@MappedSuperclass
abstract class BaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    protected val _id: Long? = null

    val id: Long = _id ?: throw IllegalStateException("엔티티의 ID 값이 존재하지 않습니다.")
}

id 정의하는데 이렇게까지 해야하나? 하는 생각이 들수도 있지만 BaseEntity 에만 정의해두면 다시 건들 일이 없긴 합니다.

  • @Id 가 사용되는 _id 변수는 nullable 이라 저장되지 않았을 때 null
  • protected 로 정의하여 상속 후 프록시객체로 필드 주입 가능
  • 외부에서 id 호출시에도 !! 를 붙이거나 함수처럼 id() 형태로 사용하지 않아도 됨

Conclusion

그래서 뭘 써야하나?

정답은 없습니다.

팀 컨벤션이나 개인적인 선호도에 따라 1번을 쓰기도 하고 2번을 쓰기도 합니다.

현재 대중화된 Spring Boot + JPA Hibernate 기준으로는 어떤 스타일을 해도 정상 동작합니다.

가장 많이 쓰는 방식은 1번 (val id: Long? = null) 으로 알고 있습니다.

Overview

Kotlin 에서 제공하는 유용한 기능 중에 JPA 에서는 권장되지 않거나 사용하면 안되는 기능들이 있습니다.


1. data class

Kotlin 에서 제공하는 data class는 JPA의 @Entity에서는 사용하지 않는 것이 좋습니다.

data class 는 불변성과 값 기반 비교를 지향하지만, JPA의 엔티티는 보통 가변 상태를 가지며 식별자(ID) 기반으로 관리되기 때문에 둘의 지향점이 다릅니다.

또한, data class 가 자동 생성하는 equals, hashCode 구현은 모든 필드를 기반으로 비교하기 때문에 id 가 아직 할당되지 않은 상태에서는 동일한 엔티티임에도 불구하고 서로 다른 객체로 인식될 수 있습니다.

그래서 Kotlin 에서 엔티티 객체를 정의할 때는 기본 class 를 사용해야 합니다.


2. init

Kotlin 에서는 init 블럭을 사용할 수 있습니다.

init 블럭은 "객체 생성 직후" 실행되지만, JPA 는 리플렉션을 통해 엔티티를 "빈 객체로 생성 -> 필드 값을 주입" 하는 순서로 동작합니다.

따라서 init 블럭에서 필드 값을 참조하면 아직 값이 설정되지 않아 NullPointerException 또는 예상치 못한 로직 오류가 발생할 수 있습니다.

엔티티 객체 생성 이후에 뭔가를 처리하고 싶다면 외부에서 처리하거나, 내부에서 처리해야 한다면 @PostLoad 등을 활용해야 합니다.


Conclusion

Kotlin 은 여러 편의 기능을 제공하지만, JPA 와 함께 사용할 때는 특정 Kotlin 기능이 JPA 의 동작 방식과 충돌할 수 있습니다.

그 중 대표적인 예로 data classinit 블럭 사용을 지양해야 하는 이유를 알아보았습니다.

Overview

Kotlin 으로 Spring Boot 를 만들다보면 JPA 를 함께 사용하는 일이 많습니다.

JPA 는 대표적인 Spring Boot 의 ORM 이지만 Kotlin 과 함께 사용하려면 몇가지 불편한 점이 존재합니다.

이런 불편한 점들을 해결하기 위해 Kotlin 측에서는 JPA 에서 사용하기 적합한 몇가지 플러그인을 제공합니다.


1. Kotlin JPA plugin

Kotlin 에서 제공하는 plugin.jpa 에는 다음 두 가지 플러그인이 포함되어 있습니다.

  • plugin.allopen
  • plugin.noarg

1.1. all-open plugin

Kotlin 에서는 기본적으로 모든 클래스와 메서드가 final 입니다.

이 말은 클래스나 메서드를 상속/오버라이드 할 수 없다는 뜻입니다.

Java 에서는 기본적으로 open 이라 상속에 열려있는 반면에 Kotlin 은 의도된 상속 구조를 사용하기 위해 기본적으로 final 이고 상속 가능한 클래스만 명시적으로 open 을 선언합니다.

JPA 에서는 지연 로딩 (lazy loading), 더티 체킹 (dirty checking) 등에 사용되는 프록시 객체를 만들기 위해서는 클래스가 상속 가능해야 합니다.

그래서 Kotlin 에서는 JPA 클래스를 open 으로 변경시켜주는 "all-open" 플러그인을 제공합니다.


1.2. no-arg plugin

JPA 에서는 엔티티 객체를 리플렉션 (Reflection) 으로 생성햐는데 구현상 반드시 파라미터 없는 기본 생성자가 필요합니다.

Kotlin 에서는 정의된 생성자를 통해 모든 필드를 초기화하도록 요구하므로, 별도로 기본생성자를 만들어주지 않으면 JPA 에서 인스턴스를 생성할 수 없습니다.

사실 기본생성자가 없는 문제는 Kotlin 만의 문제는 아니고 Java 에서도 @Entity 객체는 항상 기본생성자를 직접 만들어주거나 Lombok 의 @NoArgConstructor 를 붙여줘야 합니다.

Kotlin 에서는 JPA 클래스에 기본생성자를 붙여주는 "no-arg" 플러그인을 제공합니다.


2. 적용 방법

plugins {
    val kotlinVersion = "2.1.20"
    kotlin("plugin.jpa") version kotlinVersion
}

Kotlin 2.1.20 버전 사용 기준으로 위와 같이 추가해주시면 됩니다.

allopen, noarg 플러그인을 별도 사용시 원래는 allOpen, noArg 블럭을 추가하여 적용 대상을 지정해야 했는데 plugin.jpa 에는 해당 설정 또한 내장되어 있기 때문에 따로 추가할 필요가 없습니다.


Conclusion

Spring Initializr 에서는 Kotlin + JPA 선택 시 plugin.jpa 를 자동으로 추가해줍니다.

그래서 Spring Initializr 나 Gradle 설정을 통해 무심코 적용되는 경우가 많지만 실제로 Kotlin 에서 JPA 를 안정적으로 사용하기 위해 중요한 역할을 하는 플러그인들을 정리해봤습니다.

History

  • 2022.11.26
    • 첫 작성
  • 2025.06.15
    • asdf 버전업으로 인한 명령어 변경 반영
    • Java 버전 corretto 21 로 변경

Overview

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

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

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

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

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

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


1. asdf 설치

# install asdf
$ brew install asdf

# add to shell
$ echo . /opt/homebrew/opt/asdf/libexec/asdf.sh >> ~/.zshrc

homebrew 를 사용해서 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 | grep corretto-21
corretto-21.0.0.34.1
corretto-21.0.0.35.1
corretto-21.0.1.12.1
corretto-21.0.2.13.1
corretto-21.0.3.9.1
corretto-21.0.4.7.1
corretto-21.0.5.11.1
corretto-21.0.6.7.1
corretto-21.0.7.6.1

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

여러가지 버전이 있으나 AWS 에서 사용되는 corretto 버전을 사용하겠습니다.

JVM 버전은 21 을 사용했습니다.


4. Java 설치

# 설치
$ asdf install java corretto-21.0.7.6.1

# 설치된 확인
$ asdf list java
 *corretto-21.0.7.6.1

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


5. 사용할 버전 지정

$ asdf set java corretto-21.0.7.6.1

설치되었다고 끝난게 아니라 사용할 Java 버전을 지정해야 합니다.

프로젝트 별로 Java 버전을 다르게 사용한다면 맞춰서 설정할 수 있습니다.


6. JAVA_HOME 설정하기

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

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

저는 ~/.zshrc 에 추가했습니다.


7. Java 설치 완료

$ java -version
openjdk version "21.0.7" 2025-04-15 LTS
OpenJDK Runtime Environment Corretto-21.0.7.6.1 (build 21.0.7+6-LTS)
OpenJDK 64-Bit Server VM Corretto-21.0.7.6.1 (build 21.0.7+6-LTS, mixed mode, sharing)

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


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

Java8 에서는 Collection 을 다루기 위해 Stream 을 사용합니다.

Kotlin 은 Collections 자체에서 filter, map 등의 여러 가지 API 를 제공하기 때문에 매번 .streams() 를 붙이지 않아도 사용 가능하다는 장점이 있습니다.

하지만 비슷해보이는 두 코드 사이에는 큰 차이점이 하나 있는데요.

바로 Lazy Evaluation 입니다.


1. Lazy Evaluation

Lazy Evaluation 이란 쉽게 말해서 필요하지 않은 연산을 하지 않는다 라고 이해할 수 있습니다.

어떤 로직이나 연산을 그 즉시 수행하지 않고 실제로 사용되기 전까지 미루는 걸 의미합니다.

반대의 의미인 Eager Evaluation 은 연산이 있으면 그때그때 수행하는 것을 의미합니다.

Java Stream 의 예시와 함께 보면 쉽게 이해할 수 있습니다.


2. Java Streams

Stream.of(1, 2, 3, 4, 5, 6)
        .filter(e -> {
            System.out.println("filter: " + e);
            return e < 3;
        })
        .map(e -> {
            System.out.println("map: " + e);
            return e * e;
        })
        .anyMatch(e -> {
            System.out.println("anyMatch: " + e);
            return e > 2;
        });
  • (1, 2, 3, 4, 5, 6) 의 숫자 묶음 존재
  • filter: 3 보다 작은 수만 추출
  • map: 제곱으로 변환
  • anyMatch: 2 보다 큰 수가 있는지 확인

조금 지저분해 보이지만 이해를 돕기 위해 연산 중간중간마다 print 문을 추가했습니다.

위 코드를 있는 그대로 나열하면 6 개의 숫자 묶음에서 3 보다 작은 수만 뽑아서 제곱한 뒤 그 중에서 2 보다 큰 수가 있는지 확인하는 겁니다.

위 코드의 결과값은 아래와 같습니다.


filter: 1
map: 1
anyMatch: 1
filter: 2
map: 2
anyMatch: 4

총 6 개의 숫자가 있었지만 실제로 연산된 것은 두 개 뿐입니다.

anyMatch 조건에 해당하는 숫자가 나오자 이후 숫자들은 볼 필요가 없다고 판단하여 전부 생략했습니다.

이게 바로 Lazy Evaluation (필요하지 않는 연산은 하지 않는다) 입니다.


3. Kotlin Collections

listOf(1, 2, 3, 4, 5, 6)
    .filter {
        println("filter: $it")
        it < 3
    }
    .map {
        println("map: $it")
        it * it
    }
    .any {
        println("any: $it")
        it > 2
    }

그럼 이제 같은 로직을 Kotlin 으로 작성해보았습니다.

위 로직을 실행하며 어떻게 될까요?


filter: 1
filter: 2
filter: 3
filter: 4
filter: 5
filter: 6
map: 1
map: 2
any: 1
any: 4

한 눈에 봐도 결과가 다른 것을 알 수 있습니다.

Kotlin Collections 는 매 연산마다 모든 원소에 대해서 수행합니다.

데이터의 양이 많으면 많을수록 성능 차이는 더욱 벌어질 겁니다.


4. Kotlin Sequences

Kotlin 에서도 Lazy Evaluation 을 수행하게 하는 방법이 있습니다.

바로 Sequence 를 사용하는 겁니다.

위 코드에서 한줄만 추가하면 됩니다.


listOf(1, 2, 3, 4, 5, 6)
    .asSequence()   // 이 부분을 추가해서 Sequence 로 변경
    .filter {
        println("filter: $it")
        it < 3
    }
    .map {
        println("map: $it")
        it * it
    }
    .any {
        println("any: $it")
        it > 2
    }

Collection 에서 수행하지 말고 asSequence() 를 사용해서 Sequence 로 변경한 뒤에 연산을 수행하면 됩니다.

위 코드의 결과는 다음과 같습니다.


filter: 1
map: 1
any: 1
filter: 2
map: 2
any: 4

이제 불필요한 연산을 하지 않는 것을 볼 수 있습니다.


5. 왜 그럴까?

Lazy Evaluation 에 대해 좀더 설명하면 중간 단계 (intermediate step) 의 결과를 바로 리턴하냐 아니냐의 차이에 있습니다.

Kotlin Collections 은 매 연산을 수행할 때마다 결과 Collection 을 반환합니다.

이에 비해 Kotlin Sequences 또는 Java Streams 는 종료 (terminate) 함수가 호출되기 전까지는 연산을 수행하지 않습니다.

위에서 사용한 any() 함수 또한 종료 함수입니다.

이 차이를 쉽게 알려면 종료 함수가 없는 Sequences 를 사용해보면 됩니다.


5.1. Kotlin Sequences 의 Lazy Evaluation 확인

val sequence: Sequence<Int> = listOf(1, 2, 3)
    .asSequence()
    .filter {
        println("filter: $it")
        it < 2
    }
    .map {
        println("map: $it")
        it * it
    }

println("종료함수를 아직 호출하지 않음")
sequence.toList()

Sequences 는 매 함수의 결과로 Sequence 를 반환합니다.

그래서 최종적으로 Collection 으로 변환하려면 다시 toList() 를 호출해야 합니다.

toList() 역시 종료함수라서 호출되는 순간에 모든 연산이 수행됩니다.

Java Streams 의 collect(Collectors.toList()) 와 같다고 생각하시면 됩니다.


종료함수를 아직 호출하지 않음
filter: 1
map: 1
filter: 2
filter: 3

5.2. Kotlin Collections 의 Eager Evaluation 확인

val list: List<Int> = listOf(1, 2, 3)
    .filter {
        println("filter: $it")
        it < 2
    }
    .map {
        println("map: $it")
        it * it
    }

println("Collection 은 매번 List 를 반환하기 때문에 이미 연산됨")

Sequences 와 다르게 Collections 은 매 함수의 결과로 Collection 을 반환합니다.

사실상 매 함수가 모두 종료 함수라고 볼 수 있으며, 그래서 결과를 다음 단계로 넘기지 못하고 매번 전부 연산을 하는겁니다.


filter: 1
filter: 2
filter: 3
map: 1
Collection 은 매번 List 를 반환하기 때문에 이미 연산됨

Conclusion

이제 Kotlin Sequences 는 Lazy Evaluation 때문에 불필요한 연산을 생략한다는 점을 알았습니다.

하지만 Sequences 가 항상 좋은 것은 아닙니다.

Sequences by Kotlin Reference 를 보면 다음과 같은 문구가 있습니다.

So, the sequences let you avoid building results of intermediate steps, therefore improving the performance of the whole collection processing chain. However, the lazy nature of sequences adds some overhead which may be significant when processing smaller collections or doing simpler computations. Hence, you should consider both Sequence and Iterable and decide which one is better for your case.


요약하자면 Sequences 는 중간 단계의 결과를 생략하기 때문에 성능 향상이 되지만, 오버헤드가 있기 때문에 데이터가 적거나 연산이 단순한 컬렉션을 처리할 때는 오히려 안좋을 수가 있다고 합니다.

그러므로 각자 상황에 맞춰 적절한 방법을 선택하는게 가장 좋습니다.


Reference

Overview

이미지는 HTML 에서 넣거나 CSS 에서 넣을 수 있습니다.

둘다 이미지가 노출된다는 사실은 같으나 약간의 차이점이 있습니다.


1. HTML 에서 태그 사용

<img src="/temp/image">
  • <img> 태그를 사용하면 이미지 업로드 실패 시 "깨진 이미지 아이콘" 과 "alt" 가 함께 노출된다.
  • SEO 나 성능 등에서 이점이 많다.

2. CSS 에서 background-image 속성 사용

background-image: url(image.jpg);
  • 순전히 디자인 목적이라면 CSS 를 이용해도 된다.
  • CSS 는 이미지 사이즈가 큰 경우 로딩하는데 시간이 더 걸린다
  • 이미지 업로드 실패 시 아무것도 노출되지 않는다.

3. 각각 언제 사용하는게 좋을까?

만약 배경 이미지가 있어도 그만 없어도 문제 없는 상황이라면 실패했을 경우 아예 이미지가 노출되지 않는 편이 좋을 수도 있습니다.

이미지가 없어도 컨텐츠를 이해하는 데 무리가 없기 때문에 사용자에게 굳이 에러 상황을 알려줄 필요가 없습니다.

img 태그는 이미지가 컨텐츠와 관련이 깊고 검색 엔진에 노출이 필요한 경우에 사용하고 background-image 속성은 순수하게 디자인을 위한 목적인 경우에 사용합니다.

제 개인적인 생각으로는 웹 접근성도 고려해서 웬만하면 HTML 태그를 사용하는 게 좋다고 생각합니다.


Reference

'Language > HTML and CSS' 카테고리의 다른 글

Textarea 내부에 HTML Tag 를 넣고 싶을 때  (2) 2021.05.21

Overview

JavaScript 에서는 마지막 부분을 잘라내는 방법 (drop) 이 여러 가지 있습니다.

그 중에서 가장 일반적으로 사용하는 게 substringslice 인데 둘의 사용법과 차이점을 알아봅니다.


1. substring

string.substring(start, end);

substring 은 이름 그대로 문자열의 일부를 구하는 함수이며 사용법은 위와 같습니다.

잘라내고자 하는 문자열의 시작 (start) 과 끝 (end) 인덱스를 입력합니다.

가장 헷갈릴 만한 점은 start 는 자르는 대상에 포함되고 end 는 포함되지 않습니다.


Example

// 마지막 문자 n 개 버리기
string.substring(0, string.length - n);

마지막 문자들만 버릴 예정이므로 start 는 무조건 0 으로 하고 자를 문자의 갯수만큼 n 을 입력하면 됩니다.


2. slice

string.slice(start, end);

slicesubstring 과 사용법과 문법이 완전히 같습니다.

하지만 단 하나의 차이가 있다면 파라미터로 음수값을 넘길 수 있다는 점입니다.

음수값은 쉽게 이해하자면 -n == string.length - n 으로 생각하면 됩니다.


Example

// 마지막 문자 n 개 버리기
string.slice(0, -n);

음수를 사용할 수 있다는 점 때문에 substring 보다 훨씬 간결합니다.


Conclusion

사용법은 비슷하지만 음수 파라미터의 사용이 가능한 slice 가 훨씬 사용하기 편한 것 같습니다.

StackOverflow 에서는 substring 이 속도가 더 빠르다는 결과도 있었던 것 같은데, 과거의 이야기고 현재는 비슷하다고 하네요.

실제로 벤치마크 가능한 사이트에서 slice vs substr vs substring 을 테스트 해보면 비슷하게 나옵니다.


Reference

+ Recent posts