JDK 21이 출시되었다. 최신 문법 보고 가요

이동엽·2023년 10월 9일
18

java

목록 보기
18/18

최근 Spring 공식 홈페이지에 Hello, Java 21이라는 글이 올라왔다.
내용은 요약하면, JDK 21이 출시되었고 새로운 LTS 버전이라는 것과 함께 최신 문법들을 소개한다.


이번에 나온 Java 21은 Spring Boot 기준으로 3.2부터 지원한다고 하는데..
Java 11 & Spring Boot 2.xJava 17 & Spring Boot 3.x 로 바꾼 지 얼마 되지 않은 시점에서 조금 이르지 않나 싶어 당황스럽긴 하다.

물론 Spring Boot 3.x로 이전한 상태에서 당장 바로 3.2 버전이 나온다고 바꾸지는 않을 것 같다하더라고, 최신 문법에 관심을 가져서 나쁠리는 없다.


아래 내용에서는 JDK 1.5부터 JDK 21까지의 주요 문법 및 최신 문법에 대해 소개한다.

Java 11까지는 모두들 얼추 알고 있다면, 어떤 이유로 생긴 문법인지에 초점을 맞춰 읽어보고,
Java 11 이후 문법들에 대해서는 사용해보지 않은 문법이 있다고 하면 직접 프로젝트에 적용해보는 것도 좋은 학습 방법일거라고 생각이 든다.

당장 최신 문법이 궁금한 것이라면 JDK 14부터 읽는 것을 추천합니다.

추가본

예제 코드와 새로운 내용들은 계속해서 노션에 작성하고 있습니다.


🔥 JDK 1.5

2004년 9월에 출시, 이때 부터 버전 중 앞의 1을 빼고 표기하기 시작했다. (= Java 5)

Generics

  • 잘못된 타입이 사용될 수 있는 문제를 컴파일 시점에 제거할 수 있게 되었다.
  • 정의할 때 타입을 파라미터로 사용할 수 있도록 한다.
    • 타입 파라미터는 코드 작성 시 구체적인 타입으로 대체되어 다양한 코드를 생성하도록 해준다.

제네릭 도입 전

필드에 모든 종류의 객체를 저장하기 위해 자바에서 가장 상위 클래스인 Object 타입으로 선언

자식 객체 생성 시에 자동 타입 변환이 발생하는데, 빈도가 빈번하다면 프로그램 성능에 좋지 못하다.

public class Box {
	private Object object;
	public void set(Object object) { this.object = object; }
	public Object get() { return object; }
}

제네릭 도입 후

제네릭을 사용함으로써 타입 변환이 일어나지 않는다.

public class Box<T> {
	private T t;
	public T get() { return t; }
	public void set(T t) { this.t = t; }
}

타입을 제한한 제네릭 사용법

public class Example {
    public static <T extends Number> int compare(T t1, T t2) {
        double v1 = t1.doubleValue();
        double v2 = t2.doubleValue();
        return Double.compare(v1,v2);
    }
}

Enumeration

  • 한정된 값만을 갖는 데이터 타입
  • 열거 상수는 모두 대문자로 작성하는 관례가 있음

예시

public enum Week{
	MONDAY("월요일"),
	TUESDAY("화요일"),
	...
	SUNDAY("일요일");

	private String name;

	Week(String name) { this.name = name; }
}

Annotation

  • 컴파일 과정과 실행 과정에서 코드를 어떻게 컴파일하고 처리할 것인지를 알려준다.

사용 예시

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

		...

    @Override
    public void addInterceptors(final InterceptorRegistry registry){ ... }
}

사용자 정의 어노테이션을 만들 시 적용 대상을 지정할 수 있다.

@Target({ ElementType.FIELD, ElementType.METHOD }) 
public @interface AnnotationName {
}

마찬가지로, 유지 정책(언제까지 유지할 지)도 지정할 수 있다.

@Target({ ElementType.TYPE, ElementType.FIELD, ElementType.METHOD }) 
@Retention(RetentionPolicy.RUNTIME)
public @interface AnnotationName {
}

🔥 JDK 1.6

2006년 12월 출시

기능 변화

  • 이때부터 J2SE → Java SE로 표기가 바뀜
  • 가비지 컬렉터 G1(Garbage First) GC를 테스트용으로만 사용하도록 추가
    • Heap 영역 내에서 unreachable object를 찾아 회수하여 메모리를 관리

🔥 JDK 1.7

2011년 7월 출시

Diamond Operator

변수를 선언할 때 동일한 타입으로 호출하고 싶다면, 타입을 명시하지 않고 <> 만 붙일 수 있다.


적용 전

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

적용 후

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

🔥 JDK 1.8 (LTS)

2014년 3월 출시

오라클 인수 후의 첫 번째 버전이며, 2개 버전으로 나뉜다. (Oracle JDK, Open JDK)

Oracle JDK와 Open JDK

  • 둘 다 각각 Java Development Kit이며, 본질적으로 코드 변경이 거의 없어 기능적으로 매우 유사.
  • Java 11 이후부터는 Oracle JDK와 Open JDK의 빌드가 동일
  • 차이점은 Oracle JDK는 라이선스 제한이 있고, Open JDK는 오픈소스

람다 표현식 (Lambda Expression)

람다식은 익명 함수를 생성하기 위한 식으로, 객체 지향 언어보다는 함수 지향 언어에 가깝다.

람다식 적용 전

Comparator<Apple> beWeight = new Comparator<Apple>() {
		public int compare(Apple a1, Apple a2) {
				return a1.getWeight().compareTo(a2.getWeight());
		}
}

람다식 적용 후

Comparator<Apple> byWeight =
		(Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight());

💡 그럼 람다식은 어디에 적용할 수 있을까?

함수형 인터페이스라는 문맥에서 람다 표현식을 사용할 수 있다.
여기서 함수형 인터페이스는 하나의 추상 메서드를 지정하는 인터페이스를 의미한다.


기존의 함수형 인터페이스들

  • Comparator, Runnable 등

Java 8에 추가된 함수형 인터페이스들

  • Consumer, Suppiler, Predicate, Funcion, Operator


메서드 참조 (Method Reference)

메서드 참조는 특정 메서드만을 호출하는 람다의 축약형으로, 기존의 메서드 정의를 재활용해서 람다처럼 전달할 수 있다.

메서드 참조 적용 전, 람다식 사용 시

final Member member = memberRepository.findById(memberId)
					                  .orElseThrow(() -> new MemberNotFoundException());

메서드 참조 사용

final Member member = memberRepository.findById(memberId)
					  		    		.orElseThrow(MemberNotFoundException::new);

Default Methods in Interface

기존의 인터페이스에서는 메서드 정의만 가능하고, 구현은 할 수 없지만,
Java 8에 추가된 디폴트 메서드를 이용하면 구현 내용을 작성할 수 있다.

예시 코드

public interface Example {
		public default void print() {
				System.out.println("이동엽 파이팅");
		}
}

null 대신 Optional

Java를 사용하면 가장 흔히 발생하는 에러가 NullPointerException 이다.

이렇기에 매번 null 체크를 하기 위해서는 코드가 지저분해 질 수 있다.

Optional 클래스 동작 방식

  • 값이 있으면? → 값을 감싸고,
  • 값이 없으면? → Optional.empty 메서드로 Optional을 반환한다.


Stream API

스트림을 이용하면 선언형으로 컬렉션 데이터를 처리할 수 있다.

여기서 선언형은 데이터를 처리하는 임시 구현 코드 대신 질의로 표현하는 방식을 말한다.
또한 스트림을 이용하면 멀티스레드 코드를 구현하지 않아도 데이터를 투명하게 병렬 처리할 수 있다.

이 외에도 파이프라이닝, 내부반복 등의 특징이 존재


스트림 사용 전

  • 칼로리가 400이 넘지 않는 음식들을 오름차순으로 정렬하여, 음식의 이름들을 추출
List<Dish> lowCaloricDishes = new ArrayList<>();
for (Dish dish: menu) {
		if (dish.getCalories() < 400) {
				lowCaloricDishes.add(dish);
		}
}

Collections.sort(lowCaloricDishes, new Comparator<Dish>() {
		public int compare(Dish dish1, Dish dish2) {
				return Integer.compare(dish1.getCalories(), dish2.getCalories());
		}
});

Lish<String> lowCaloricDishesName = new ArrayList<>();
for (Dish dish: lowCaloricDishes) {
		lowCaloricDishesName.add(dish.getName());
}

스트림 사용 후

import static java.util.Comparator.comparing;
import static java.util.stream.Collectors.toList;

List<String> lowCaloricDishesName = menu.stream() // .parallelStream()은 병렬처리
										.filter(d -> d.getCalories() < 400)
										.sorted(comparing(Dish::getCalories))
										.map(Dish::getName)  // 음식명 추출
										.collect(toList());  // 리스트에 저장

🔥 JDK 9

java.net.http 패키지

기존에 사용하던 HttpURLConnection을 대체할 패키지가 추가

예시 코드

final HttpRequest request = HttpRequest.newBuilder()
                .uri(new URI("songareeit.com"))
							  .headers("Content-Type", "text/plain;charset=UTF-8")
							  .POST(HttpRequest.BodyPublishers.ofString("request body"))
							  .build();

Stream API 추가

takeWhile()

  • 조건에 대해 참이 아닐 경우 바로 멈춘다.
  • 이미 정렬되어 있다면 false가 등장한 위치부터 반복을 중단할 수 있기 때문에 크기가 큰 Stream의 경우 많은 시간을 절약할 수 있다.
List<Integer> takeWhileList = numbers.stream()
                                     .takeWhile(i -> i < 50)
                                     .collect(toList()); // 12, 17, 29, 35, 41, 

dropWhile()

  • takeWhile의 정반대 작업으로, 처음으로 false가 등장하는 시점까지의 요소를 모두 버리고 남은 요소를 반환한다.
List<Integer> dropWhileList = numbers.stream()
                                     .dropWhile(i -> i < 50)
                                     .collect(toList()); // 50, 66, 72, 80

Interface Private Method

“ 인터페이스 내에 private 메서드 사용이 가능해졌다.

public interface MyInterface {

    private static void myPrivateMethod(){
        System.out.println("Yay, I am private!");
    }
}

Try-With-Resources 개선

try 블럭에서 선언된 객체들에 대해서 try 문이 종료될 때 자동으로 자원을 해제해주는 기능이다.

Java 9 이후에는 try 블럭 밖에서 선언한 변수를 가져와 사용할 수 있다.

// Java 9 이전
void tryWithResourcesByJava7() throws IOException {
    BufferedReader reader1 = new BufferedReader(new FileReader("test.txt"));
    try (BufferedReader reader2 = reader1) {
        // do something
    }
}

// Java 9 ~
void tryWithResourcesByJava9() throws IOException {
    BufferedReader reader = new BufferedReader(new FileReader("test.txt"));
try (reader) {
        // do something
    }
}

🔥 JDK 10

var 키워드

  • 로컬 변수 타입 추론에 사용되는 var 키워드가 등장했다.
  • 메서드 내부의 변수에만 적용 가능하다.
  • 초기화를 하지 않으면 컴파일 에러가 발생한다.
// Pre-Java 10
String myName = "Marco";

// With Java 10
var myName = "Marco"

🔥 JDK 11 (LTS)

Oracle JDK와 OpenJDK가 통합되었으며 Oracle JDK 가 유료 모델로 전환

String & File API 개선

"Marco".isBlank(); // 공백인지 판단
"Mar\nco".lines(); // 문자열을 줄 단위로 쪼갬
"Marco  ".strip(); // 문자열 앞, 뒤의 공백 제거

Lambda 파라미터로 var 사용

(var x, var y) -> x.process(y) => (x, y) -> x.process(y)

🔥 JDK 12

Switch Expression 확장 (Preview)

//기존 방식
switch (day) {
    case MONDAY:
    case FRIDAY:
    case SUNDAY:
        System.out.println(6);
        break;
    case TUESDAY:
        System.out.println(7);
        break;
    case THURSDAY:
    case SATURDAY:
        System.out.println(8);
        break;
    case WEDNESDAY:
        System.out.println(9);
        break;
}

//Java SE 12 부터의 방식
switch (day) {
    case MONDAY, FRIDAY, SUNDAY -> System.out.println(6);
    case TUESDAY                -> System.out.println(7);
    case THURSDAY, SATURDAY     -> System.out.println(8);
    case WEDNESDAY              -> System.out.println(9);
}

🔥 JDK 13

Switch Expression 개선 (Preview)

스위치문이 값을 반환할 수 있도록 yield 키워드가 추가

var a = switch (day) {
    case MONDAY, FRIDAY, SUNDAY:
        yield 6;
    case TUESDAY:
        yield 7;
    case THURSDAY, SATURDAY:
        yield 8;
    case WEDNESDAY:
        yield 9;
};

Multiline Strings (Preview)

줄 바꿈 문자가 자동으로 포함되는 문법

String str = """
   This
   is
   text block
""";

🔥 JDK 14

12, 13에서 프리뷰로 제공되었던 스위치 표현식이 표준화(Standard)되었음

record (Preview)

  • 변수의 타입과 이름을 이용해 private final 필드를 자동 생성
  • 생성자, Getter, hashCode(), equals(), toString()도 자동 생성
final class Point {
    public final int x;
    public final int y;

    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }
}

// 레코드 사용 시
record Point(int x, int y) { }

NullPointerExceptions 개선

정확히 어떤 변수가 null 인지 자세히 나타낸다.

author.age = 35;
---

Exception in thread *"main"* java.lang.NullPointerException:
     Cannot assign field *"age"* because *"author"* is null

Pattern Matching for instanceof (Preview)

JDK 14 이전 : 형변환 후 작업 수행

if (obj instanceof String) {
    String s = (String) obj;
		System.out.println(s.contains("songareeit"));
}

JDK 14 ~

if (obj instanceof String s) {
    System.out.println(s.contains("songareeit"));
}

🔥 JDK 15

Sealed Class (Preview)

  • Sealed Class 혹은 Interface는 상속/구현을 특정 클래스에만 허용하도록 제한한다.
  • sealed 키워드를 이용해 선언하고, permits 키워드로 상속받을 서브 클래스를 명시한다.
public sealed class Developer permits Dongyeop { ... }

자식 클래스는 sealed , final , non-sealed 세 종류로 나뉜다.

  • sealed : 자식도 마찬가지로 상속받을 서브 클래스를 명시할 수 있다.
  • final : 더 이상 확장할 수 없다.
  • non-sealed : 모든 서브 클래스들에 의해 확장될 수 있다.
public sealed class DongyeopBaby1 extends Dongyeop permits DongyeopBaby11 { ... }
public final class DongyeopBaby2 extends Dongyeop { ... }
public non-sealed class DongyeopBaby3 extends Dongyeop { ... }

🔥 JDK 16

OpenJDK의 버전 관리가 Mercurial 에서 Git으로 바뀌었다.

Unix 도메인 소켓 채널

아래와 같이 Unix 도메인 소켓에 연결할 수 있다. (프로세스 간 통신을 위한 기능)

socket.connect(UnixDomainSocketAddress.of(
        *"/var/run/postgresql/.s.PGSQL.5432"*)
);

🔥 JDK 17 (LTS)

Pattern Matching for switch (Preview)

JDK 17 이전

static String formatter(Object o) {
    String formatted = "unknown";
    if (o instanceof Integer i) {
        formatted = String.format("int %d", i);
    } else if (o instanceof Long l) {
        formatted = String.format("long %d", l);
    } else if (o instanceof Double d) {
        formatted = String.format("double %f", d);
    } else if (o instanceof String s) {
        formatted = String.format("String %s", s);
    }
    return formatted;
}

JDK 17 ~

static String formatterPatternSwitch(Object o) {
    return switch (o) {
        case Integer i -> String.format("int %d", i);
        case Long l    -> String.format("long %d", l);
        case Double d  -> String.format("double %f", d);
        case String s  -> String.format("String %s", s);
        default        -> o.toString();
    };
}

🔥 JDK 18

UTF-8 by Default

  • JDK 18 이전 : 런타임 환경(운영 체제, 사용자 로케일 및 기타 요인)을 기반으로 시작 시 Charset을 선택
  • JDK 18 ~ : UTF-8이 디폴트로 변경됨

Code Snippets in Java API Documentation

API 문서에 예제 소스 코드 포함을 단순화하기 위해  JavaDoc의 표준 Doclet에 @snippet 태그를 도입


Deprecate Finalization for Removal

JDK 18 이전 : finally 구문에서 리소스들을 Close 처리함

FileInputStream  input  = null;
FileOutputStream output = null;
try {
    input  = new FileInputStream(file1);
    output = new FileOutputStream(file2);
    ... copy bytes from input to output ...
    output.close();  output = null;
    input.close();   input  = null;
} finally {
    if (output != null) output.close();
    if (input  != null) input.close();
}

위 방식의 문제점

  • 예측할 수 없는 대기시간
  • 항상 finalizer이 활성화됨

JDK 18 ~

finalizer 를 이용한 Close 처리는 오랫동안 사용되어 와서 전환하는 시간이 걸린다.

따라서 향후에는 종료를 비활성화하기 위한 --finalization=enabled 와 같은 옵션이 제공될 예정.


🔥 JDK 19 ~ 20

Record Patterns (Preview)

JDK 19 이전

record Point(int x, int y) {}

static void printSum(Object o) {
    if (o instanceof Point p) {
        int x = p.x();
        int y = p.y();
        System.out.println(x+y);
    }
}

JDK 19 ~

record Point(int x, int y) {}

void printSum(Object o) {
    if (o instanceof Point(int x, int y)) {
        System.out.println(x+y);
    }
}

Virtual Threads (Preview)

사용 방식

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    IntStream.range(0, 10_000).forEach(i -> {
        executor.submit(() -> {
            Thread.sleep(Duration.ofSeconds(1));
            return i;
        });
    });
}  // executor.close() is called implicitly, and waits

Virtual threads are cheap and plentiful, and thus should never be pooled: A new virtual thread should be created for every application task. Most virtual threads will thus be short-lived and have shallow call stacks, performing as little as a single HTTP client call or a single JDBC query. Platform threads, by contrast, are heavyweight and expensive, and thus often must be pooled. They tend to be long-lived, have deep call stacks, and be shared among many tasks.

가상 스레드는 저렴하고 풍부하므로 풀링되어서는 안 됩니다. 모든 애플리케이션 작업에 대해 새로운 가상 스레드를 생성해야 합니다. 따라서 대부분의 가상 스레드는 수명이 짧고 얕은 호출 스택을 가지며 단일 HTTP 클라이언트 호출이나 단일 JDBC 쿼리만 수행합니다. 이와 대조적으로 플랫폼 스레드는 무겁고 비용이 많이 들기 때문에 종종 풀링되어야 합니다. 수명이 길고, 호출 스택이 깊으며, 여러 작업에서 공유되는 경향이 있습니다.

출처 : https://openjdk.org/jeps/425


플랫폼 스레드란?

일반적으로 자바에서 스레드(java.lang.Thread)라고 부르는 OS에서 생성한 스레드를 매핑해 JVM에서 사용하는 것을 말한다.

OS에 의해 스케줄링되기 때문에 스레드 간 전환을 위한 문맥 교환이 발생하기도 하고,
플랫폼 스레드를 생성하는 것이 OS에도 스레드를 하나 생성하는 것이기 때문에 동작이 매우 느려, 스레드 풀을 사용한다.

그러다보면 스레드 풀을 또 관리해야 하는데, 많은 양의 스레드를 만들다보면 메모리에 문제가 생길 수도 있다.


가상 스레드란?

가상 스레드는 OS의 스레드와 1:1로 대응되지 않는다. 위에서 플랫폼 스레드라고 부르던 것은 이제 캐리어 스레드라고 부른다.

캐리어 스레드는 포크조인 풀 안에 Worker Thread로 생성되어 스케줄링 되고, 각 Worker Thread들은 큐를 가진다. 이때 Task를 스케줄링하는데 가상 스레드 자체가 각각의 Task가 되어 큐에 들어가게 된다.

가상 스레드는 OS의 스레드와 대응되는 개념도 아니고, JVM에서 직접 스레드를 생성하기 때문에 생성 비용이 비싸지도 않다. 또한 크기가 자동으로 조절되기 때문에 스레드 풀의 개수를 관리할 필요도 없다.


기존 코드와의 호환성

VirtualThread의 부모 타입인 BaseVirtualThread는 Thread 클래스를 상속받아, 기존 Thread 구현의 변경 없이 사용할 수 있다.

Spring Boot Starter Web을 사용시 3.0버전 이상에서는 Embedded Tomcat을 사용한다.
이때, 가상 스레드를 사용하기 위해서는 아래와 같이 설정하면 된다.

다만 가상 스레드이므로 로그에 스레드 이름이 찍히지 않는 점을 알아두자.

@Bean
public TomcatProtocolHandlerCustomizer<?> protocolHandlerVirtualThreadExecutorCustomizer() {
    return protocolHandler -> protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
}

Structured Concurrency (Incubator)

  • 구조화된 동시성을 위한 API를 도입하여 멀티스레드 프로그래밍을 단순화한다.
  • 기본적으로 가상 스레드를 사용

기존 동시성을 사용한 코드

Future<Shelter> shelter;
Future<List<Dog>> dogs;

// ThreadPool의 크기가 3인 Executor 생성
try (ExecutorService executorService = Executors.newFixedThreadPool(3)) {
		
		// get shelter
    shelter = executorService.submit(this::getShelter);
		// get dohs
    dogs = executorService.submit(this::getDogs);
		
    Shelter theShelter = shelter.get();   // Join the shelter
    List<Dog> theDogs = dogs.get();  // Join the dogs
    Response response = new Response(theShelter, theDogs);
} catch (ExecutionException | InterruptedException e) {
    throw new RuntimeException(e);
}

만약 위 코드에서 getShelter() 가 실행되는 동안 getDocs() 에서 예외가 발생한다면? 상황은 아래와 같다.


Structured Concurrency를 사용

// ShutdownOnFailure()은 해당 scope에서 문제가 발생할 경우 하위 작업을 모두 종료하는 생성자
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Future<Shelter> shelter = scope.fork(this::getShelter);
    Future<List<Dog>> dogs = scope.fork(this::getDogs);

    scope.join();
    Response response = new Response(shelter.resultNow(), dogs.resultNow());
    // ...
}

기타 사용법

// 예외를 전파
scope.throwIfFailed(e -> new RuntimeException("ERROR_MESSAGE"));

// 마감일 설정
scope.joinUntil(Instant.now().plusSeconds(1));

🔥 JDK 21 (LTS)

Virtual Threads가 표준화 되었음

Sequenced Collections

  • 정의된 만남 순서로 컬렉션을 나타내는 새로운 인터페이스를 도입
  • 이러한 각 컬렉션에는 잘 정의된 첫 번째 요소, 두 번째 요소 등을 거쳐 마지막 요소까지 포함
  • 또한 첫 번째 요소와 마지막 요소에 액세스하고 해당 요소를 역순으로 처리하기 위한 통일된 API를 제공

JDK 21 이전 : 컬렉션 별 마지막 요소 구하는 방법


Sequenced Collections 사용 시

interface SequencedCollection<E> extends Collection<E> {
    // new method
    SequencedCollection<E> reversed();

    // methods promoted from Deque
    void addFirst(E);
    void addLast(E);
    E getFirst();
    E getLast();
    E removeFirst();
    E removeLast();
}

Unnamed Patterns and Variables (Preview)

  • 구성 요소의 이름이나 유형을 명시하지 않고 이름 없는 패턴 및 변수로, 밑줄 문자(_)로 표시

JDK 21 이전

int total = 0;
for (Order order : orders) {
		// 사실상 사용되지 않는 order
    if (total < LIMIT) { 
        ... 
				total++ 
				...
    }
}

JDK 21 ~

int total = 0;
for (Order _ : orders) {
    if (total < LIMIT) { 
        ... 
				total++ 
				...
    }
}

Unnamed Classes and Instance Main Methods (Preview)

학생들이 대규모 프로그램용으로 설계된 언어 기능을 이해할 필요 없이
첫 번째 프로그램을 작성할 수 있도록 Java 언어를 발전시키기 위함.


JDK 21 이전

  • 처음 시작하는 사용자들에게 public 접근 제한, static , args 등의 많은 지식을 요구한다.
public class HelloWorld { 
    public static void main(String[] args) { 
        System.out.println("Hello, World!");
    }
}

JDK 21 ~

void main() {
    System.out.println("Hello, World!");
}

자료 출처

profile
백엔드 개발자로 등 따숩고 배 부르게 되는 그 날까지

5개의 댓글

comment-user-thumbnail
2023년 10월 9일

잘 보고 가요.

답글 달기
comment-user-thumbnail
2023년 10월 20일

잘보고갑니다!

답글 달기
comment-user-thumbnail
2023년 10월 20일

잘보고갑니다~

답글 달기
comment-user-thumbnail
2023년 11월 20일

잘 봤습니다!

답글 달기
comment-user-thumbnail
2023년 12월 3일

항상 많이 배우고 갑니다 멋있어요

답글 달기