풀스택 개발자 과정 20일차

너구·2026년 6월 4일

풀스택 성장과정

목록 보기
20/79

가공/변환하기

변환, 필터링, 정렬 등 데이터를 가공하는 단계이다.
중간 연산의 결과 역시 또 다른 스트림이므로 메서드 체이닝을 활용할 수 있다.
다음은 주요 가공 단계의 메서드이다.


결과 처리하기

가공이 끝난 데이터들을 최종적으로 수집하거나 소비해 스트림 공정을 끝내는 단계이다.
다음은 스트림의 결과 처리용 메서드이다.


합계 구하기 예제

다음과 같은 정수형 배열 nums가 있을 때 스트림으로 변환해 요소들의 합계를 구하라.

{10, 20, 30, 40}

import java.util.Arrays;

public class Main {
    public static void main(String[] args) {
        
        int[] nums = {10, 20, 30, 40};

        // 기존 방법으로 요소의 합계 구하기
        // int sum = 0;

        // for (int num : nums) { // foreach문, 향상된 for문
        //     sum += num;
        // }

        // System.out.println(sum);

        // stream을 활용해서 합계 구하기
        int sum = Arrays.stream(nums)
            .sum(); // 스트림의 sum 메서드 호출
            // sum 메서드: 요소의 합계를 구한 다음 결과를 반환

        System.out.println(sum);
    }
}

먼저 정수 배열을 생성한다.
배열에는 10, 20, 30, 40이 저장되어 있다.
기존 방식으로 합계를 구하려면 반복문을 사용해야 한다.
배열의 요소를 하나씩 꺼내서 sum에 더하는 방식이다.
하지만 Stream을 사용하면 더 간단하게 작성이 가능하다.
Arrays.stream(nums)는 배열 nums를 IntStream으로 변환한다.
즉 배열을 Stream이 처리할 수 있는 형태로 바꾸는 것이다.
그 다음 .sum()을 호출하면 Stream 안에 있는 모든 요소를 더한 후 결과를 반환한다.
실제로는 10 + 20 + 30 + 40 이라는 계산이 이루어진다.
그렇기 때문에 출력 결과는 100이 나온다.


데이터 필터링하기 예제 (1)

다음과 같이 구성된 배열 ages를 스트림으로 변환해 성인만 출력하라.

{12, 18, 22, 30}

import java.util.Arrays;

public class Main {

    public static void main(String[] args) {

    int[] ages = {12, 18, 22, 30};

    // 기존의 방식
    // for(int age : ages) {
    //     if (age >= 18) {
    //         System.out.println(age);
    //             }
    //         }
    

        // 스트림으로 처리
        Arrays.stream(ages) // ages로부터 스트림을 생성
        .filter(age -> age >= 18) // filter 메서드 호출
        .forEach(age -> System.out.println(age)); // foreach 호출. 요소를 출력
    }
}

기존 방식은 반복문과 if문을 사용해 조건을 직접 검사해야했다.
배열의 요소를 하나씩 꺼내서 18이상인지 확인한 뒤 출력하는 방식이다.
하지만 Stream을 사용하면 조건에 맞는 데이터만 걸러낼 수 있다.
Arrays.stream(ages)로 배열 ages를 IntStream으로 변환한다.
즉 {}안의 배열을 Stream이 처리할 수 있는 형태로 바꾸는 것이다.
그 다음 .filter(age -> age >= 18)을 통해 조건에 맞는 데이터만 남긴다.
마지막으로 .forEach(age -> Systeam.out.println(age))를 통해 남아있는 요소를 출력한다.

데이터 필터링하기 예제 (2)

다음과 같이 구성된 배열 persons를 스트림으로 변환하고 J가 들어가는 사람들만 출력하라.

{"John Doe", "Jane Doe", "Mary Doe", "Steve Jobs"}

import java.util.Arrays;

public class Main {
    public static void main(String[] args) {
        
        String persons[] = 
        {"John Doe", "Jane Doe", "Mary Doe", "Steve Jobs"};

        // 스트림이 없을 때
        // for (String person : persons) {
        //     if (person.indexOf("J") > -1) {
        //         System.out.println(person);
        //     }
        // }

        Arrays.stream(persons) // 스트림 생성
        .filter(person -> person.contains("J")) // 필터링
        .forEach(person -> System.out.println(person)); // 결과 소비
    }
}

먼저 문자열 배열을 생성하고 배열안에 4명의 이름을 저장한다.
기존 방식은 반복문과 if문을 사용해 직접 조건을 검사해야했다.
indexOf("J")는 문자열에 J가 있으면 위치를 반환하고 없으면 -1을 반환한다.
따라서 J가 포함된 사람만 출력이 된다.

아래 코드는 같은 작업을 Stream으로 처리한 것이다.
persons 배열을 Stream으로 변환한다.
그 다음 filter()를 사용해 "J"가 포함된 이름만 남긴다.
마지막으로 forEach를 사용해 필터링된 요소를 하나씩 꺼내 출력한다.
그렇기 때문에 실행된다면 이름에 J가 들어간 사람들만 출력이 된다.


데이터 가공하기 예제 (1)

다음과 같이 구성된 배열 nums가 있을 때 각 요소를 10배 크게 출력하라.

{10, 20, 30, 40}

import java.util.Arrays;

public class Main {
    public static void main(String[] args) {
        
        int[] nums = {10, 20, 30, 40};

        // 스트림이 없을 때
        // for (int num : nums) {
        //     System.out.println(num * 10);
        // }

        // 스트림
        Arrays.stream(nums)
        .map(num -> num * 10)
        .forEach(num -> System.out.println(num));
    }
}

int[]를 통해 정수형 배열 nums를 생성한다.
배열 안에는 10, 20, 30, 40이 저장된다.
Stream을 사용하지 않았을 때는 배열의 요소를 하나씩 꺼내서 10을 곱한 뒤 출력했다.

Arrays.Stream(nums)를 통해 배열 nums를 Stream으로 변환한다.
이 시점에서는 아직 아무 작업도 수행하지 않고 데이터가 흐를 통로(Stream)만 생성한다.
map()은 각 요소를 다른 값으로 변환하는 메서드이다.
배열의 각 요소에서 원본 데이터를 가공해 새로운 Stream을 만든다.
그 다음 forEach()를 사용해 Stream의 요소를 하나씩 꺼내 사용한다.
여기서는 각 요소를 출력한다.
그렇기 때문에 출력을 해보면 100 200 300 400 이렇게 원본 데이터에서 10배가 곱해진 값이 나온다.

데이터 가공하기 예제 (2)

다음과 같이 구성된 배열 data로부터 각 요소를 대문자로 변환하고 출력하라.

{"foo", "bar", "baz"}

import java.util.Arrays;

public class Main {
    public static void main(String[] args) {
        
        String[] words = {"foo", "bar", "baz"};

        Arrays.stream(words)
        .map(word -> word.toUpperCase())
        .forEach(word -> System.out.println(word));
    }
}

String[]으로 문자열 배열을 생성한 다음 Arrays.stream(words)로 배열을 Stream으로 반환한다.
그 다음 .map을 통해 각 문자열을 대문자로 변환한다.
.toUppercase() -> 문자를 대문자로 변환
그 다음 forEach를 통해 변환된 결과를 하나씩 출력한다.
그럼 모든 문자열들이 대문자로 출력이 되는 것을 확인할 수 있다.


명령형 프로그래밍 vs 선언형 프로그래밍

  • 명령형: 컴퓨터가 좋아하는 방식
    반복문이나 조건문 같은 제어문을 활용해 작업을 처리한다.

  • 선언형: 사람이 좋아하는 방식
    함수를 활용하는 방식이며 함수형 프로그래밍이라고도 한다.
    어떤 일을 하는지 쉽게 알 수 있다.
    ex) filtering, mapping 등


Optional

Optional은 일반 객체 또는 null을 담을 수 있는 래퍼(Wrapper) 클래스이다.
개발자의 코딩 실수로 인한 NullPointerException (NPE)를 방지하기 위해 만들어졌다.
대부분 반환 타입으로만 사용한다.

Optional 생성하기

  1. Optional.of(객체)
    절대 null이 아닌 객체를 담을 때 사용한다.
    null을 넣으면 NPE가 발생한다.

  2. Optional.OfNullable(객체)
    null일 수도 있고 아닐 수도 있는 객체를 담을 때 사용한다.
    가장 많이 쓰인다.

  3. Optional.empty()
    빈 Optional 객체를 만들 때 사용한다.

Optional 객체 생성 예제

앞의 3가지 유형으로 각각 Optional 객체를 생성하라.

import java.util.Optional;

public class Main {
    public static void main(String[] args) {
        
        // 값이 null이 아닐 때
        Optional<String> opt1 = Optional.of("foo");
        // 값이 null일 수도 아닐 수도 있을 때
        Optional<String> opt2 = Optional.ofNullable("foo");
        Optional<String> opt3 = Optional.ofNullable(null);
        // null을 가지도록 합니다
        Optional<String> opt4 = Optional.empty();

        System.out.println(opt1); // Optional[foo]
        System.out.println(opt2); // Optional[foo]
        System.out.println(opt3); // Optional.empty
        System.out.println(opt4); // Optional.empty
    }    
}

Optional.of(value) → 값이 반드시 있어야 함
Optional.ofNullable(value) → 값이 있거나 없어도 됨
Optional.empty() → 비어 있는 Optional 생성
Optional은 null을 직접 다루는 대신 객체로 감싸서 더 안전하게 처리하기 위한 클래스이다.


Optional 메서드

Optional이 감싸고 있는 객체의 null 여부에 따라 처리 방법을 정의한다.


Optional 없이 null 다루기 예제

입력받은 id와 일치하는 사용자를 반환하는 함수 findUserById를 정의하라.
만약 일치하는 사용자가 없는 경우 null을 반환하도록 해라.
마지막으로 검색 결과가 null인 경우 NPE를 방지하기 위한 코드를 작성하라.

class User {

    int id;
    String username;

    User(int id, String username) {
        this.id = id;
        this.username = username;
    }
}

public class Main {
    // 입력받은 id에 일치하는 사용자를 검색하는 메서드
    static User findUserById(int id) {

        // 사용자 데이터베이스
        User[] users = {
            new User(1, "johndoe"),
            new User(2, "janedoe"),
            new User(3, "marydoe")
        };

        // 찾은 유저를 저장할 변수
        User resultUser = null; // null로 초기화

        for (User user : users) {
            if (id == user.id) {
                resultUser = user;
            }
        }

        // 일치하는 사용자를 반환하거나 null을 반환
        return resultUser;
    }

    public static void main(String[] args) {
        
        User user = findUserById(1);

        // null 체크 -> NPE를 방지하기 위해
        if (user == null) { // illegal
            throw new IllegalStateException
            ("사용자를 찾을 수 없습니다.");
        }

        // NPE가 발생할 수 있는 부분
        // user가 null인 경우 username에 접근을 시도하면 NPE가 발생
        System.out.println("사용자 정보: " + user.username);
    }
}

class User로 User 객체를 생성하였다.
그 다음 int로 id를 String으로 username을 저장하는 변수를 생성하였다.
그 다음 생성자를 만들고 this.로 전달받은 값을 객체의 변수에 저장하였다.

findUserById 메서드는 id를 받아서 해당 사용자를 찾는 메서드이다.
찾으면 User 객체를 반환하고 없으면 null을 반환한다.

그 다음 User 객체들을 저장하는 배열을 생성한다.
User resultUser = null;을 통해 찾은 사용자를 저장할 변수를 생성한다.
아직 찾지 못했으므로 null로 초기화한다.

그 다음 for문으로 users 배열을 하나씩 순회한다.
그리고 if문으로 전달받은 id와 현재 사용자의 id가 같은지 확인한다.
resultUser = user;로 일치하면 해당 사용자를 저장한다.

return resultUser;로 찾은 사용자를 반환하고 찾지 못했다면 null을 반환한다.

User user = findUserById(1);로 id가 1인 사용자를 검색하고 if문을 통해 사용자를 찾았는지 확인한다.
그 다음 throw new IllegalStateException로 사용자가 없으면 예외를 발생시킨다.
프로그램 실행을 중단한다.

그 다음 사용자의 이름을 출력하면 사용자 정보: johndoe가 나온다.

만약 User user = findUserById(100);처럼 존재하지 않는 id를 검색하면 user = null이 된다.
그 상태에서 user.username을 실행하면 NullPointerException이 발생한다.
그래서 먼저 if (user == null)로 확인한 뒤 안전하게 처리하는 것이다.

Optional 활용 예제

앞에 예제에서 반환값을 Optional로 변환 후 똑같은 과정을 작성하라.

import java.util.Optional;

// 마찬가지로 데이터베이스에서 사용자를 검색하고 다루는 과정

class User {

    int id;
    String username;

    User(int id, String username) {
        this.id = id;
        this.username = username;
    }
}

public class Main {
    // 반환 타입이 Optional
    static Optional<User> findUserById(int id) {

        // 사용자 데이터베이스
        User[] users = {
            new User(1, "johndoe"),
            new User(2, "janedoe"),
            new User(3, "marydoe")
        };

        User resultUser = null;

        for (User user : users) {
            if (id == user.id) {
                resultUser = user;
            }
        }

        // ofNullable: 값이 null일 수도 있고 아닐 수도 있을 때
        return Optional.ofNullable(resultUser);
    }

    public static void main(String[] args) {
        
        User user = findUserById(1)
        // 결과가 null이면 입력된 함수를 실행하겠다
        .orElseThrow(() -> new IllegalStateException
        ("사용자를 찾을 수 없습니다."));

        // null 체크 부분이 없어짐

        // NPE가 발생할 수 있는 부분
        System.out.println("사용자 정보: " + user.username);
    }
}

이 코드는 이전 예제의 null 반환 방식을 Optional 반환 방식으로 개선한 코드이다.
이전 코드에서는 사용자를 찾지 못하면 return null;을 반환했다.
그래서 사용할 때마다 if(user == null) 같은 null 체크가 필요했다.
이번 코드는 Optional< User >를 반환하여 값이 없을 수도 있다는 사실을 명확하게 표현했다.

또 반환타입이 User가 아니라 Optional< User >이다.
즉 사용자가 있으면 User를 담아서 반환하고 사용자가 없으면 비어있는 Optional을 반환하라는 의미이다.

그 다음 User[]를 통해 사용자 데이터를 저장한다.
User resultUser = null;로 검색 결과를 저장할 변수를 생성한다.
그 다음 for문으로 배열을 순회하고 if문으로 입력받은 id와 사용자의 id가 같으면 저장한다.

return Optional.ofNullable(resultUser); 여기가 핵심이다.
만약 resultUser = new User(1, "johndoe")이면 Optional[User]를 반환한다.
만약 resultUser = null;이면 Optional.empty를 반환한다.

findUserById로 id가 1인 사용자를 검색한다.
결과는 Optional< User >이다.

.orElseThrow(() -> new IllegalStateException를 통ㅎ애 Optional 안에 값이 있으면 User를 꺼낸다.
값이 없으면 IllegalStateException 예외를 발생시킨다.

그 다음 사용자의 이름을 출력한다.

이 코드의 핵심은 Optional< User > 를 사용하여 null을 직접 반환하지 않는다는 점이다.
그리고 .orElseThrow(...) 를 사용해 값이 없을 때의 처리를 한 줄로 작성할 수 있다.
덕분에 if (user == null) 같은 null 체크 코드가 사라지고, NPE(NullPointerException) 발생 가능성도 줄어든다.


내부 클래스

클래스 내부에서 선언된 클래스로 두 클래스가 긴밀한 관계를 맻고 있을 때 사용한다.
주로 DTO(데이터 전송 객체) Builder 패턴 구현에 쓰인다.
다음은 내부 클래스가 제공하는 주요 장점이다.

  • 논리적 그룹화: 특정 클래스 안에서만 사용되는 헬퍼 클래스들을 내부에 가두어 외부 클래스 파일 목록을 깔끔하게 유지한다.

  • 캡슐화: 바깥 클래스의 private 멤버 변수나 메서드를 제약없이 자유롭게 접근 가능.

  • 가독성과 유지보수성 향상: 데이터와 그 데이터를 다루는 로직이 한 파일 공간에 묶여있어 코드를 파악하기 쉬움

내부 클래스 유형

  1. 인스턴스 내부 클래스 (Instance Inner Class)
    외부 클래스(Outer)의 인스턴스가 있어야 내부 클래스의 객체 생성이 가능하다.

  2. 정적 내부 클래스 (Static Inner Class)
    바깥 객체 없이 독립적으로 객체 생성이 가능하다.
    메모리 관리에 최적화되어있어 일반적으로 가장 많이 활용하는 방식이다.

내부 클래스 생성 예제

클래스 Outer에 인스턴스 내부 클래스와 정적 내부 클래스를 선언하고 각 내부 클래스의 인스턴스를 생성하라.

class Outer {
    // 인스턴스 내부 클래스
    class InstanceInner{}
    // 정적 내부 클래스
    static class StaticInner {}
}

public class Main {
    public static void main(String[] args) {
        
        // 인스턴스 내부 클래스의 객체 생성
        Outer outer = new Outer(); // 외부 클래스의 인스턴스
        // 내부 클래스의 객체를 생성하기 위해 외부 클래스의 개체가 필요
        Outer.InstanceInner instanceInner = 
        outer.new InstanceInner();

        // 정적 내부 클래스의 객체 생성하기
        // 외부 클래스의 인스턴스 없이 단독적으로 객체를 생성할 수 있다
        Outer.StaticInner staticInner = new Outer.StaticInner();
    }
}

이 코드는 내부 클래스(Inner Class)의 두 종류를 보여준다.
InstanceInner는 인스턴스 내부 클래스이고 StaticInner는 정적 내부 클래스이다.

먼저 외부 클래스 Outer의 객체를 만든다.
그 다음 outer 객체를 통해 내부 클래스 객체를 생성한다.
인스턴스 내부 클래스는 반드시 외부 클래스 객체에 소속되어야 한다.
그래서 생성할 때 outer.new InstanceInner() 형태를 사용한다.

정적 내부 클래스, 여기서는 Outer 객체를 만들지 않는다.
StaticInner는 static으로 선언되어 있기 때문에 외부 객체와 독립적으로 존재할 수 있다.
정적 내부 클래스는 외부 클래스의 인스턴스 없이 생성 가능하다.
마치 일반 클래스처럼 new Outer.StaticInner()로 생성한다.

인스턴스 내부 클래스는 외부 객체 안에 속한 클래스이고 정적 내부 클래스는 외부 객체와 독립적으로 존재하는 클래스이다.

내부 클래스 활용 예제

게시물을 다루는 PostDto 클래스 안에 다음의 내부 클래스들을 선언하라.

// dto는 자잘자잘한 클래스가 많아 관련된 클래스를 모으기도 함
class PostDto { // 게시물(post) 관련 dto를 모아놓은 곳

    // 게시글 등록용
    public static class CreateRequest {
        private String title;
        private String content;

        public String getTitle() { return title; }
        public String getContent() { return content; }
    }

    // 게시글 수정용
    public static class UpdateRequest {
        private String title;
        private String content;

        public String getTitle() { return title; }
        public String getContent() { return content; }
    }

    // 게시글 응답용
    public static class Response {
        private Long id;
        private String title;
        private String content;

        public Long getId() { return id; }
        public String getTitle() { return title; }
        public String getContent() { return content; }
    }
}

public class Main {
    
}

이 코드는 정적 내부 클래스(static class)를 활용해 게시글 관련 DTO들을 하나로 묶은 예제이다.

PostDto.CreateRequest -> 게시글 등록 요청 데이터를 담는 DTO
PostDto.UpdateRequest -> 게시글 수정 요청 데이터를 담는 DTO
PostDto.Response -> 게시글 조회 및 응답 데이터를 담는 DTO

게시글과 관련된 DTO들을 각각 만들 수 있지만 PostDto 안에 정적 내부 클래스로 묶으면 관련 클래스들을 한 곳에서 관리할 수 있다.


예외와 에러

  1. 예외(Exception)
    개발자의 실수나 비즈니스 로직상 필요한 경우, 또는 외부 환경과 통신중에 발생하는 문제 상황이다.
    예외 처리문을 통해 해결할 수 있다.

  2. 에러(Error)
    시스템 레벨에서 발생하는 심각한 문제이다.
    소스코드 수준에서 예외처리(try-catch)를 할 수 없으며 프로그램을 즉시 종료하고 인프라 환경을 점검해야 한다.
    ex) OutOfMemoryError(메모리 고갈), StackOverflowEroor(무한 재귀 호출)

예외의 종류

  1. Unchecked Exception (언체크 예외)
    프로그램 실행 중에 개발자의 실수나 비즈니스 로직 오류로 인해 발생하는 예외이다.
    컴파일러가 예외처리를 강제하지 않는다.
    예외 처리를 강제하지 않으므로 사전에 방어로직을 짜는 것이 정석이다.
    ex) NullPointerException, IndexOutOfBoundsException

  1. Checked Exception (체크 예외)
    주로 외부 환경(파일, 네트워크, DB)과 통신 과정에서 발생하는 예외이다.
    컴파일러가 예외처리를 강제한다.
    처리를 안하면 컴파일 자체가 되지 않는다.
    try-catch 또는 throws로 처리해야 한다.
    ex)IOException, SQLException, FileNotFoundException

예외 처리 (Exception handling)

예외로 인해 프로그램이 비정상적으로 종료되는 것을 방지하는 것을 의미한다.
try-catch문을 통해 예외를 처리한다.

try {
	예외가 발생할 수 있는 코드
    } catch(Exception e) {
    예외가 발생한 경우 처리할 코드
    }

예외 처리하기 예제 (1)

User 인스턴스가 잘못된 사용자 이름(username)할당을 요청받는 경우 작업을 거부하고 예외를 던지는 코드를 작성하라

class User {
    String username;

    // 아이디를 할당하는 메서드
    void setUsername(String username) {
        if (username.length() < 5) { // 아이디가 유효하지 않으면
            // 예외를 던진다(throw)
            throw new IllegalStateException
            ("유효하지 않은 아이디");
            // IllegalStateException(잘못된 인자 예외): 메서드에 잘못된 인자가 입력되는 경우 발생
        }
        // 예외를 던지면 아래 코드는 실행되지 않는다
        this.username = username;
    }
}

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

        try {
            user.setUsername("j");

            System.out.println("가입이 완료되었습니다");
            
        } catch (Exception e) {
            System.out.println("예외 발생! " + e.getMessage());
        }
    }
}

예외를 직접 발생시키고 try-catch로 처리했다.
User 클래스를 만들어 사용자 아이디를 저장하였고 setUsername으로 아이디를 설정하는 매서드를 구현하였다.

if문으로 입력한 아이디의 길이가 5글자 미만인지 검사하였으며 throw new IlegalStateException으로 예외를 강제로 발생시킨다.
아이디가 유효하지 않으므로 프로그램 흐름을 중단하고 예외를 발생시키라는 의미이다.

this.username = username;은 아이디가 정상일 때만 실행된다.
하지만 예외가 발생하면 이 코드는 실행되지 않는다.

main에서 User 객체를 생성하고 try를 통해 예외가 발생할 수 있는 코드를 실행한다.
j -> 길이가 1인 아이디를 전달하면 username.length가 5 미만, 참이므로 예외가 발생된다.
그렇기 때문에 실행이 되지 않는다.

catch 블록으로 발생한 예외를 받아서 처리한다.
e.getMessage()는 유효하지 않은 아이디를 반환하며 print로 예외 메시지를 출력한다.
그러므로 실행시 예외 발생! 유효하지 않은 아이디가 출력된다.


마무리

오늘은 Stream, Optional, 내부 클래스, 예외 처리에 대해 학습했다.

특히 Stream을 사용해 배열 데이터를 합계 계산, 필터링, 변환하는 과정을 실습하면서 반복문 없이도 데이터를 효율적으로 처리할 수 있다는 점이 인상적이었다. 또한 filter(), map(), forEach() 같은 메서드를 활용해 원하는 데이터만 추출하거나 가공하는 방법을 익혔다.

Optional을 통해 null을 안전하게 처리하는 방법도 배웠다. 기존에는 null 체크를 직접 해야 했지만 Optional과 orElseThrow()를 사용하면 코드를 더 깔끔하게 작성할 수 있다는 점을 알게 되었다.

내부 클래스에서는 인스턴스 내부 클래스와 정적 내부 클래스의 차이를 학습했고, DTO를 하나의 클래스 안에서 관리하는 방법도 이해할 수 있었다. 마지막으로 예외 처리에서는 throw와 try-catch를 활용해 잘못된 입력을 방어하고 프로그램을 안정적으로 실행하는 방법을 배웠다.

오늘 학습을 통해 자바에서 데이터를 처리하고 예외 상황에 대응하는 다양한 방법을 익힐 수 있었으며, 실제 프로그램을 더욱 안전하고 효율적으로 작성할 수 있다는 점을 알게 되었다.

0개의 댓글