Kotlin logo

Kotlin

A concise multiplatform language developed by JetBrains

실제 프로젝트에서 Kotlin 평가하기

Read this post in other languages:

선임 소프트웨어 엔지니어이자 JetBrains 공인 Kotlin 트레이너로 활동 중인 Urs Peter가 기고한 글입니다. Urs는 더 체계적인 방법으로 Kotlin 역량을 높이고 싶은 독자를 대상으로 Xebia Academy에서 Kotlin 역량 강화 프로그램도 운영하고 있습니다.

이 글은 Java 기반 환경에서 Kotlin을 성공적으로 도입하기 위한 완벽 가이드라는 제목의 시리즈 중 두 번째 글입니다. 이 시리즈는 한 개발자의 호기심에서 시작하여 회사 전체의 변화로 이어지는 과정을 통해 실제 팀 내에서 Kotlin 도입이 어떻게 점진적으로 확대되는지 보여줍니다.

첫 번째 글 Java 개발자를 위한 Kotlin 시작하기도 읽어보세요.


평가 단계: Kotlin 확장 응용

테스트 단계에서 Kotlin을 사용하는 방법을 익힌 후에는 더 실질적인 평가를 시작할 수 있습니다. 여기에는 크게 두 가지 접근 방식이 있습니다.

  1. Kotlin으로 새로운 마이크로서비스/애플리케이션 구축
  2. 기존 Java 애플리케이션 확장/변환

1. Kotlin으로 새로운 마이크로서비스/애플리케이션 구축

새로운 애플리케이션이나 마이크로서비스를 처음부터 개발하면 기존 코드의 제약 없이 Kotlin의 모든 기능을 충분히 이용할 수 있습니다. 이러한 접근 방식은 종종 가장 좋은 학습 경험을 제공하며 Kotlin의 장점을 가장 분명하게 보여줍니다.

전문가 팁: 이 단계에서는 전문가의 도움을 구하세요. 물론 개발자로서 자신의 능력에 당연히 자신이 있겠지만, 초기 실수(예: Java 스타일의 Kotlin 코드를 작성하거나 기본 Kotlin 라이브러리를 사용하지 않음)를 피하면 수개월의 기술 부채를 줄일 수 있습니다.

Java 배경을 가진 개발자라면 Kotlin을 사용할 때 피해야 할 몇 가지 일반적인 함정을 소개합니다.

함정: Java에서 사용한 것과 다른 프레임워크 선택

팁: 기존 프레임워크를 고수하세요.

Java에서 Spring Boot를 사용하고 있다면 Kotlin에서도 계속 사용하세요. Spring Boot는 Kotlin을 최고 수준으로 지원하므로 다른 프레임워크로 전환하여 얻을 수 있는 추가적 이점은 없습니다. 게다가 새로운 언어를 배워야 할 뿐만 아니라, 새로운 프레임워크도 추가로 익혀야 하므로 복잡성만 가중시킬 뿐 아무런 이점도 없습니다.

중요 참고 사항: Spring은 Kotlin의 ‘설계에 따른 상속’ 원칙과 충돌합니다. 이 원칙에 따라 클래스를 확장하려면 클래스를 명시적으로 open으로 표시해야 합니다.

모든 Spring 관련 클래스(@Configuration 등)에 open 키워드를 추가하지 않으려면 빌드 플러그인(https://kotlinlang.org/docs/all-open-plugin.html#spring-support)을 사용할 수 있습니다. 잘 알려진 온라인 도구인 Spring Initializr을 사용하여 Spring 프로젝트를 생성하면 이 빌드 플러그인이 미리 구성되어 있습니다.

함정: Kotlin 표준 라이브러리 대신 공통 Java API를 사용하여 Java 스타일로 Kotlin 코드 작성

이러한 함정은 너무 많으므로 몇 가지 가장 흔한 함정에 대해 살펴보겠습니다.

함정 1: Kotlin 컬렉션 대신 Java 스트림 사용

팁: 항상 Kotlin 컬렉션을 사용하세요.

Kotlin 컬렉션은 Java 컬렉션과 완벽하게 상호 운용 가능하며 간결하고 기능이 풍부한 고차 함수를 제공하므로 Java 스트림이 필요 없습니다.

다음은 제품 카테고리별로 그룹화된 매출이 가장 높은(가격 * 판매량) 상위 3개 제품을 선택하도록 설계된 예입니다.

Java

record Product(String name, String category, double price, int sold){}

List products = List.of(
           new Product("Lollipop", "sweets", 1.2, 321),
           new Product("Broccoli", "vegetable", 1.8, 5);

Map<String, List> top3RevenueByCategory =
       products.stream()
          .collect(Collectors.groupingBy(
                Product::category,
                Collectors.collectingAndThen(
                    Collectors.toList(),
                    list -> list.stream()
                              .sorted(Comparator.comparingDouble(
                                  (Product p) -> p.price() * p.sold())
                                   .reversed())
                                   .limit(3)
                                   .toList()
                       		)
          )
);

Kotlin

val top3RevenueByCategory: Map<String, List> =
   products.groupBy { it.category }
       .mapValues { (_, list) ->
           list.sortedByDescending { it.price * it.sold }.take(3)
       }

Kotlin은 Java와 상호 운용이 가능하기 때문에 기본 Kotlin과 마찬가지로 Java 클래스와 레코드를 사용할 수 있습니다. 하지만 Kotlin(데이터) 클래스를 대신 사용할 수도 있습니다.

함정 2: Java의 Optional을 계속해서 사용

팁: Null 가능 타입을 활용하세요.

Java 개발자가 Kotlin으로 전환하는 핵심적인 이유 중 하나는 Kotlin의 기본적인 null 값 가능성 지원으로, NullPointerException을 완전히 방지할 수 있다는 것입니다. 따라서 null 가능 타입만 사용하고 Optional은 피하세요. 인터페이스에 Optional이 여전히 포함되어 있나요? 다음 코드에서 볼 수 있듯이 이를 null 가능 타입으로 변환하면 쉽게 제거할 수 있습니다.

Kotlin

//Let’s assume this repository is hard to change, because it’s a library you depend on
class OrderRepository {
      //it returns Optional, but we want nullable types
      fun getOrderBy(id: Long): Optional = …
}

//Simply add an extension method and apply the orElse(null) trick
fun OrderRepository.getOrderByOrNull(id: Long): Order? = 
                                    getOrderBy(id).orElse(null)

//Now enjoy the safety and ease of use of nullable types:

//Past:
 val g = repository.getOrderBy(12).flatMap { product ->
     product.goody.map { it.name }
}.orElse("No goody found")

//Future:
 val g = repository.getOrderByOrNull(12)?.goody?.name ?: "No goody found"

함정 3: 정적 래퍼를 계속해서 사용

팁: 확장 메서드를 활용하세요.

확장 메서드는 많은 이점을 제공합니다.

  • 래퍼와 비교했을 때 확장 메서드를 사용하면 코드를 더 매끄럽고 읽기 쉽게 만들 수 있습니다.
  • 확장 메서드는 코드 완성으로 찾을 수 있지만 래퍼의 경우는 그렇지 않습니다.
  • 확장 메서드에는 가져오기가 필요하므로 애플리케이션의 특정 부분에서 확장 기능을 선택적으로 사용할 수 있습니다.

Java

//Very common approach in Java to add additional helper methods
public class DateUtils {
      public static final DateTimeFormatter DEFAULT_DATE_TIME_FORMATTER = 
           DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

      public String formatted(LocalDateTime dateTime, 
		              DateTimeFormatter formatter) {
         return dateTime.format(formatter);
      }

      public String formatted(LocalDateTime dateTime) {
         return formatted(dateTime, DEFAULT_DATE_TIME_FORMATTER);
      }
}

//Usage
 formatted(LocalDateTime.now());

Kotlin

val DEFAULT_DATE_TIME_FORMATTER: DateTimeFormatter = 
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")

//Use an extension method, including a default argument, which omits the need for an overloaded method. 
fun LocalDateTime.asString(
   formatter: DateTimeFormatter = DEFAULT_DATE_TIME_FORMATTER): String = 
      this.format(formatter)

//Usage
LocalDateTime.now().formatted()

Kotlin은 최상위 메서드와 변수를 제공한다는 점을 잊지 마세요. 즉, Java에서처럼 객체에 바인딩하지 않고도 DEFAULT_DATE_TIME_FORMATTER와 같은 요소를 최상위 요소로 직접 선언할 수 있습니다.

함정 4: (다루기 힘든) Java API 사용

팁: 더 간단하고 원활한 대안으로 Kotlin을 사용하세요. 

Kotlin 표준 라이브러리는 기본 구현이 여전히 Java라도 확장 메서드를 통해 Java 라이브러리를 더 사용자 친화적으로 만듭니다. 거의 모든 주요 타사 라이브러리와 프레임워크(예: Spring)가 동일한 접근 방식을 채택했습니다.

표준 라이브러리의 예:

Java

String text;
try (
       var reader = new BufferedReader(
                  new InputStreamReader(new FileInputStream("out.txt"), 
            StandardCharsets.UTF_8))) {
   text = reader
            .lines()
            .collect(Collectors.joining(System.lineSeparator()));
}
System.out.println("Downloaded text: " +  text + "n");

Kotlin

//Kotlin has enhanced the Java standard library with many powerful extension methods, like on java.io.*, which makes input stream processing a snap due to its fluent nature, fully supported by code completion

val text = FileInputStream("path").use {
             it.bufferedReader().readText()
           }
println("Downloaded text: $textn");

Spring 예시:
Java

final var books =  RestClient.create()
       .get()
       .uri("http://.../api/books")
       .retrieve()
       .body( new ParameterizedTypeReference<List>(){}); // ⇦ inconvenient ParameterizedTypeReference

Kotlin

import org.springframework.web.client.body
val books = RestClient.create()
   .get()
   .uri("http://.../api/books")
   .retrieve()
   .body<List>() //⇦ Kotlin offers an extension that only requires the type without the need for a ParameterizedTypeReference

함정 5: public 클래스마다 별도의 파일 사용

팁: 관련 public 클래스를 하나의 파일에 병합하세요. 

이렇게 하면 수십 개의 파일을 훑어보지 않고도 특정 (하위)도메인의 구조를 명확하게 이해할 수 있습니다.

Java

Kotlin

//For domain classes consider data classes - see why below
data class User(val email: String,
            //Use nullable types for safety and expressiveness
           val avatarUrl: URL? = null, 
           var isEmailVerified: Boolean)

data class Account(val user:User,
              val address: Address,
              val mfaEnabled:Boolean,
              val createdAt: Instant)

data class Address(val street: String,
              val city: String,
              val postalCode: String)

함정 6: 가변 프로그래밍 패러다임 사용

팁: 불변성을 채택하세요. Kotlin의 기본 원칙입니다.

Java 포함한 많은 프로그래밍 언어에서 불변성이 점차 가변성을 대체하는 추세는 매우 분명합니다.

이유는 간단합니다. 불변성은 예상치 못한 부수 효과를 방지하여 코드를 더 안전하고 예측 가능하며 이해하기 쉽게 만들어주기 때문입니다. 또한, 불변 데이터는 경합 조건의 위험 없이 여러 스레드 간에 자유롭게 공유할 수 있으므로 동시성이 간소화됩니다.

이러한 이유로 Kotlin을 포함한 대부분의 현대 언어는 기본적으로 불변성을 강조하거나 강력하게 옹호합니다. Kotlin에서는 불변성이 기본이지만, 정말 필요한 경우 가변성을 선택할 수도 있습니다.

다음은 Kotlin의 불변성 도구에 대한 간단한 가이드입니다.

1. var 대신 val 사용

가능하면 var 대신 val을 사용하세요. val을 사용할 수 있는 곳에서 var를 사용하면 IntelliJ IDEA가 이를 알려줍니다.

2. copy(...)와 함께 (변경 불가능한) data 클래스 사용

도메인 관련 클래스의 경우 val과 함께 data 클래스를 사용하세요. Kotlin의 data 클래스는 종종 Java의 records 클래스와 비교됩니다. 서로 중첩되는 부분이 있기는 하지만 data 클래스는 copy(...)라는 핵심 기능을 제공합니다. 이 메서드가 없으면 비즈니스 로직에서 종종 필요한 records의 변환 작업이 매우 복잡해집니다.

Java

//only immutable state
public record Person(String name, int age) {
   //Lack of default parameters requires overloaded constructor
   public Person(String name) { 
       this(name, 0);
   }
   //+ due to lack of String interpolation
  public String sayHi() {
       return "Hello, my name is " + name + " and I am " + age + " years old.";
   }
}

//Usage
final var jack = new Person("Jack", 42);
jack: Person[name=Jack, age=5]

//The issue is here: transforming a record requires manually copying the identical state to the new instance ☹️
final var fred = new Person("Fred", jack.name);

Kotlin

//also supports mutable state (var)
data class Person(val name: String,
                  val age: Int = 0) {
  //string interpolation
  fun sayHi() = "Hi, my name is $name and I am $age years old."
}
val jack = Person("Jack", 42)
jack: Person(name=Jack, age=42)

//Kotlin offers the copy method, which, due to the ‘named argument’ feature, allows you to only adjust the state you want to change 😃
val fred = jack.copy(name = "Fred")
fred: Person(name=Fred, age=42)

또한, 가능한 한 도메인 관련 클래스에 data 클래스를 사용해야 합니다. 이러한 불변성 덕분에 애플리케이션의 핵심 로직을 처리할 때 안전하고 간단하며 원활한 경험이 보장됩니다.

팁: 가변 컬렉션보다는 불변 컬렉션을 선택하세요.

불변 컬렉션은 안전하게 전달될 수 있고 이해하기 쉽기 때문에 스레드 안전성 측면에서 상당한 이점이 있습니다. Java 컬렉션은 일부 불변성을 제공하지만 이를 사용하면 런타임 예외가 쉽게 발생할 수 있으므로 위험이 따릅니다.

Java

List.of(1,2,3).add(4); ❌unsafe 😬! .add(...) compiles, but throws UnsupportedOperationException

Kotlin

//The default collections in Kotlin are immutable (read-only)
listOf(1,2,3).add(4);  //✅safe: does not compile

val l0 = listOf(1,2,3) 
val l1 = l0 + 4 //✅safe: it will return a new list containing the added element
l1 shouldBe listOf(1,2,3,4) //✅

Collections.unmodifiableList(...)를 사용하는 경우도 마찬가지입니다. 이는 안전하지 않을 뿐만 아니라 추가 할당도 필요합니다.

Java

class PersonRepo {
   private final List cache = new ArrayList();
   // Java – must clone or wrap every call
   public List getItems() {
       return Collections.unmodifiableList(cache);   //⚠️extra alloc
   }
}

//Usage
personRepo.getItems().add(joe) ❌unsafe 😬! .add(...) can be called but throws UnsupportedOperationException

Kotlin

class PersonRepo {

//The need to type ‘mutable’ for mutable collections is intentional: Kotlin wants you to use immutable ones by default. But sometimes you need them:

   private val cache: MutableList = mutableListOf()

   fun items(): List = cache //✅safe: though the underlying collection is mutable, by returning it as its superclass List, it only exposes the read-only interface

}

//Usage
personRepo.items().add(joe) //✅safe:😬! Does not compile

동시성 측면에서 컬렉션을 포함한 불변 데이터 구조를 우선적으로 사용해야 합니다. Java에서는 CopyOnWriteArrayList와 같이 다르거나 제한된 API를 제공하는 일부 특수 컬렉션을 사용하려면 추가적으로 작업해야 합니다. 반면, Kotlin에서는 읽기 전용 List가 거의 모든 사용 사례의 요구 사항을 충족할 수 있습니다.

변경 가능하고 스레드로부터 안전한 컬렉션이 필요한 경우, Kotlin은 동일하면서 강력한 인터페이스를 공유하는 영구 컬렉션(persistentListOf(...), persistentMapOf(...))을 제공합니다.

Java

ConcurrentHashMap persons = new ConcurrentHashMap();
persons.put("Alice", 23);
persons.put("Bob",   21);

//not fluent and data copying going on
Map incPersons = new HashMap(persons.size());
persons.forEach((k, v) -> incPersons.put(k, v + 1));

//wordy and data copying going on
persons
   .entrySet()
   .stream()
   .forEach(entry -> 
      entry.setValue(entry.getValue() + 1));

Kotlin

persistentMapOf("Alice" to 23, "Bob" to 21)
         .mapValues { (key, value) -> value + 1 } //✅same rich API like any other Kotlin Map type and not data copying going on

함정 7: 빌더를 계속해서 사용(심지어 Lombok을 사용하려고 함) 

팁: 명명된 인수를 사용하세요.

빌더는 Java에서 매우 일반적입니다. 이러한 빌더는 사용하기 편리하지만 코드를 추가하고, 안전하지 않으며, 복잡성을 증가시킬 수 있습니다. Kotlin에서 빌더는 전혀 쓸모가 없습니다. 간단한 언어 기능인 명명된 인수가 그 역할을 대신하기 때문입니다.

Java

public record Person(String name, int age) {

   // Builder for Person
   public static class Builder {
       private String name;
       private int age;

       public Builder() {}

       public Builder name(String name) {
           this.name = name;
           return this;
       }

       public Builder age(int age) {
           this.age = age;
           return this;
       }

       public Person build() {
           return new Person(name, age);
       }
   }
}

//Usage
new JPerson.Builder().name("Jack").age(36).build(); //compiles and succeeds at runtime

new JPerson.Builder().age(36).build(); //❌unsafe 😬: compiles but fails at runtime.

Kotlin

data class Person(val name: String, val age: Int = 0)

//Usage - no builder, only named arguments.
Person(name = "Jack") //✅safe: if it compiles, it always succeeds at runtime
Person(name = "Jack", age = 36) //✅

2. 기존 Java 애플리케이션 확장/변환

Kotlin을 처음부터 시도해 볼 리소스가 없다면 기존 Java 코드베이스에 새로운 Kotlin 기능이나 완전한 Kotlin 모듈을 추가할 수 있습니다. Kotlin은 Java와 원활하게 상호 운용되므로 Java 간 호출자와 같이 Kotlin 코드를 쓸 수 있습니다. 이 접근 방식으로 다음이 가능합니다.

  • 대대적으로 재작성할 필요 없이 점진적으로 마이그레이션
  • 특정 컨텍스트에서 실제로 Kotlin 테스트
  • 프로덕션 수준의 Kotlin 코드로 팀 신뢰 구축

아무 목적 없이 시작하는 대신 다음과 같은 다양한 접근 방식을 고려해 보세요.

밖에서 안으로:

애플리케이션의 ‘간단한’ 부분(예: 컨트롤러, 일괄 작업 등)부터 시작하여 점차 핵심 영역으로 이동합니다. 이 접근 방식에는 다음과 같은 장점이 있습니다.

  • 컴파일타임 격리. Leaf 클래스에 의존하는 다른 구성 요소는 거의 없으므로 시스템의 나머지 부분은 변경 없이 빌드하면서 이를 Kotlin으로 변환할 수 있습니다.
  • 연쇄적 편집이 줄어듭니다. 원활한 상호 운용성 덕분에 변환된 UI/컨트롤러는 기존 Java 도메인 코드를 호출할 때 거의 변경할 필요가 없습니다.
  • 풀 리퀘스트가 적을수록 검토가 더 쉬워집니다. 마이그레이션은 파일이나 기능별로 수행할 수 있습니다.

안에서 밖으로:

중심에서 바깥으로 이동하는 접근 방식은 위험한 경우가 많습니다. 앞서 언급한 ‘밖에서 안으로’ 접근 방식의 장점이 약화되기 때문입니다. 그러나 다음과 같은 상황에서는 이 방법이 여전히 실행 가능한 옵션입니다.

  • 핵심 부분이 매우 작거나 자체적으로 모두 포함. 도메인 계층에 POJO와 서비스가 몇 개만 있는 경우, 조기에 변환하는 데 비용이 덜 들고 관용적 구문(data 클래스, value 클래스, sealed 계층 구조)을 즉시 사용할 수 있습니다.
  • 구조를 새롭게 설계. 마이그레이션 중에 불변식을 리팩터링하거나 DDD 패턴(값 객체, 집계)을 도입할 계획이라면 먼저 Kotlin에서 도메인을 재설계하는 것이 더 깔끔할 때가 있습니다.
  • 엄격한 null 안전 관리. Kotlin을 중심에 놓으면 도메인을 ‘null 안전 요새’로 변환할 수 있습니다. 바깥 Java 코드는 여전히 null을 전달할 수 있지만 경계가 더 명확해지고 관리하기 쉬워집니다.

모듈별

  • 아키텍처가 계층이 아닌 기능별로 구성되어 있고 모듈 크기가 관리 가능한 수준이라면 모듈을 하나씩 변환하는 것이 좋습니다.

Java를 Kotlin으로 변환하기 위한 언어 기능

Kotlin은 Kotlin 코드가 네이티브 Java처럼 동작할 수 있도록 하는 다양한 기능(일차적으로 어노테이션)을 제공합니다. 이는 특히 Kotlin과 Java가 동일한 코드베이스에 공존하는 하이브리드 환경에서 매우 효과적입니다.
Kotlin

class Person @JvmOverloads constructor(val name: String,
                          var age: Int = 0) {
  companion object {

  @JvmStatic
  @Throws(InvalidNameException::class)
  fun newBorn(name: String): Person = if (name.isEmpty()) 
       throw InvalidNameException("name not set")
     else Person(name, 0)

   @JvmField
   val LOG = LoggerFactory.getLogger(KPerson.javaClass)
  }
}

Java

//thanks to @JvmOverloads an additional constructor is created, propagating Kotlin’s default arguments to Java
var john =  new Person("John");

//Kotlin automatically generates getters (val) and setters (var) for Java
john.setAge(23);
var name = ken.getName();

//@JvmStatic and @JvmField all accessing (companion) object fields and methods as statics in Java

//Without @JvmStatic it would be: Person.Companion.newBorn(...)
var ken =  Person.newBorn("Ken"); 

//Without @JvmField it would be: Person.Companion.LOG
Person.LOG.info("Hello World, Ken ;-)");

//@Throws(...) will put the checked Exception in the method signature 
try {
  Person ken =  Person.newBorn("Ken");
} catch (InvalidNameException e) {
  //…
}

Kotlin

@file:JvmName("Persons")
package org.abc

@JvmName("prettyPrint")

fun Person.pretty() =
      Person.LOG.info("$name is $age old")

Java

//@JvmName for files and methods makes accessing static fields look like Java: without it would be: PersonKt.pretty(...)
Persons.prettyPrint(ken)

IntelliJ IDEA의 Java에서 Kotlin으로의 변환기

IntelliJ IDEA는 Java에서 Kotlin으로의 변환기를 제공하므로 이론적으로는 이 도구로 변환을 처리할 수 있습니다. 하지만 생성된 코드는 완벽함과는 거리가 멀기 때문에 출발점으로만 사용하는 것이 좋습니다. 이를 바탕으로 코드를 더욱 Kotlin스럽게 다듬어야 합니다. 이 주제는 이 블로그 시리즈의 마지막 게시물인 대규모 Kotlin 도입의 성공 요인에서는 더 자세히 다룰 예정입니다.

Java로 시작하면 Java스러운 Kotlin 코드를 작성할 가능성이 높고 이는 이점이 되는 측면도 있지만 Kotlin의 잠재력을 완전히 이끌어내지 못합니다. 그러므로 저는 새로운 애플리케이션을 작성하는 접근 방식을 선호합니다.

시리즈의 다음 게시물

Java 기반 환경에서 Kotlin을 성공적으로 도입하기 위한 완벽 가이드 시리즈의 블로그 글에서는 Kotlin 실험을 점진적으로 프로덕션에 사용할 수 있는 코드로 개발하는 방법을 보여 드렸습니다. 다음 글에서는 Kotlin 도입의 인간적 요인, 즉 동료 설득에 대해 중점적으로 다루겠습니다. 이 글에서는 코드 중심의 논의를 명확히 하고, 새로운 개발자를 지도하며, 팀 내에 작지만 지속 가능한 Kotlin 커뮤니티를 구축하는 방법을 소개합니다.

Urs Peter

Urs는 20년 이상에 걸쳐 Kotlin과 Scala를 주로 사용하여 복원력 있고 확장 가능하며 임무 수행에 필수적인 시스템을 구축한 풍부한 경험을 가진 소프트웨어 엔지니어이자 솔루션 아키텍트, 콘퍼런스 연사, 트레이너입니다.

컨설턴트로 활동하는 외에도 열정적인 트레이너로서 Kotlin 및 Scala 언어 과정부터 마이크로서비스 및 이벤트 기반 아키텍처에 대한 아키텍처 교육까지 광범위한 주제를 다루는 다양한 교육 과정을 저술했습니다.

천성적으로 사교적인 사람으로, 모임과 콘퍼런스에서 지식을 공유하고 동료들과 영감을 나누는 것을 좋아합니다. Urs는 JetBrains 인증 Kotlin 트레이너입니다.

image description

Discover more