김찬진의 개발 블로그

[프리코스 3주차] 미션 수행 기록 본문

우테코 회고록/프리코스 회고록

[프리코스 3주차] 미션 수행 기록

kim chan jin 2023. 11. 3. 12:20

우테코 6기 지원자 단톡방에서 레코드를 사용했다는 이야기를 들었습니다.

저도 써보고 싶어서 공부할 것에 추가했습니다.

이번 3주차 로또 미션에서 enum 사용이 요구사항이었습니다.

예전부터 공부해야지 생각만 하다 그냥 상수를 선언하는 걸로 미뤘었는데, 드디어 공부하게 되었습니다.

우선 record, enum 에 대해 공부하고 오겠습니다! 

 

 

 

 

 

 

 

 


레코드 (record)

 


레코드의 사용 목적

1. 보일러 플레이트를 줄이기 위해

- equals, hashcode, toString, getter 등을 컴파일러가 알아서 생성해줌

- 보일러 플레이트란? equals(), hashcode(), getter() 등의 꼭 필요하지만 반복적인 노동을 요구하는 상용구 코드

2. 명시적으로 데이터 클래스임을 알리기 위해

- 값 객체(Value Object)를 직접 만드는 것도 대안일 수 있지만, 레코드는 그 자체가 데이터 클래스임을 의미함

3. 컴파일러가 equals, hashcode, toString, getter 를 자동으로 생성해줌

- 만약 레코드에 새로운 필드를 추가한다 하더라도 equals, hashcode, toString, getter 를 수정할 필요없음 -> OCP 준수!

 

레코드 주의 사항

1. 레코드 생성자는 일반 생성자와 다르다. 컴파일러가 레코드를 선언할 때 넣은 파라미터를 사용하여 컴팩트 생성자를 만든다. 

public record Location(int location) {
    private static final BasicValidator<Integer> locationValidator = new LocationValidator();

    public Location { // 컴팩트 생성자
        locationValidator.validate(location);
    }
}

 

2. 레코드는 다른 클래스를 상속할 수 없다. 이미 내부적으로 Record 클래스를 상속받기 때문이다. 위 레코드를 표현한 코드블럭의 실제 작동원리를 표현하자면 아래와 같다.

public final class Location extends java.lang.Record {
    private final String location
    
    private static final BasicValidator<Integer> locationValidator = new LocationValidator();

    public Location(String location) { // 컴팩트 생성자
        locationValidator.validate(location);
        this.location = location;
    }
    
    @Override
    public final String toString() {
    	...
    }
    
    @Override
    public final boolean hashCode() {
    	...
    }
    
    @Override
    public final boolean equals(Object o) {
    	...
    }
    
    // getter
    public String location() {
    	return this.location;
    }
}

 

3. 만약 파라미터의 개수가 다른 생성자를 만들고 싶다면 컴팩트 생성자를 활용해야 한다. 즉 this() 를 사용해야 한다. 또한 컴팩트 생성자의 파라미터의 개수와 동일하게 파라미터를 갖는 생성자를 선언할 수는 없다.

import org.junit.jupiter.api.Test;

public class RecordTest {
    private record Person(String name, String address) {
        
        public Person {
            System.out.println("컴팩트 생성자 기본 생성");
        }
        
        public Person(String name) {
            this(name, "미정"); // this() 는 컴팩트 생성자를 의미
        }

        public Person() {
            this("미정", "미정"); // this() 는 컴팩트 생성자를 의미
        }
    }

    @Test
    void test() {
        String name = "김찬진";
        String address = "서울";

        Person person1 = new Person(name, address);
        Person person2 = new Person("안금장");
        Person person3 = new Person();

        System.out.println(person1.name()); // 컴파일러가 필드명으로 getter 생성
        System.out.println(person2.name()); // 컴파일러가 필드명으로 getter 생성
        System.out.println(person3.name()); // 컴파일러가 필드명으로 getter 생성
    }
}

 

4. 레코드에는 인스턴스 필드를 선언할 수 없다. 즉, 정적 필드만 가능하다.

- 인스턴스 필드는 레코드를 선언할 때 작성하기 때문에 중복되어 인스턴스 필드를 선언할 수 없게 하는 것이다

5. 레코드는 암시적으로 final 이며 abstract 일 수 없다

- 레코드는 데이터 클래스로 사용하기 위한 목적이므로 한번 초기화되면 변경불가능하도록 기능한다

6. 레코드는 인터페이스는 상속받을 수 있다. 

- 레코드는 내부적으로 java.lang.Record 를 상속받을 뿐이기 때문에, 인터페이스 상속은 가능하다.

 

 

 

 

 

 

 

 

 

 

 


열거형 (enum)

 


enum 사용 목적

수많은 발전을 거쳐서 나온 것이 enum 이었다.

과거에 토이 프로젝트할 때 상수를 정수와 매칭시켜서 데이터를 프론트에게 넘기곤 했다.

// in python

CATEGORY = (("사회부문", 0),
            ("교육부문", 1),
            ...
            ("정치부문", 9))

 

하지만 "사회부문"이 0 이랑 매칭되어야할 필연적인 이유도 없으며,

데이터 전달을 위해 "사회부문"과 0 을 매칭시켰다고 하더라도, 정수 자체로는 문법적으로 문제가 없기 때문에 특정 상황(switch, if 등)에 컴파일 에러를 미리 잡아내지 못할 수 없다.

그래서 상수를 객체로써 다루는 방법으로 enum 이 나온 것이다!

 

enum 주의사항

1. enum 은 내부적으로 static final 변수, 즉 클래스 변수를 갖는다

2. enum 은 내부적으로 private String name 인스턴스 변수를 갖는다

3. enum 은 내부적으로 private String name 인스턴스 변수를 인자로 하는 생성자를 갖는다.

enum Direction {EAST, WEST, SOUTH, NORTH}

 

위 enum 가 내부적으로 작동하는 방식을 클래스 단위로 표현한다면 아래와 같다.

num Direction {

    static final Direction EAST = new Direction("EAST");
    static final Direction WEST = new Direction("WEST");
    static final Direction SOUTH = new Direction("SOUTH");
    static final Direction NORTH = new Direction("NORTH");
  
    private String name;
  
    private Direction(String name)  { this.name = name; }
}

 

그래서 다른 클래스에서 다음과 같이 enum Direction 의 필드들을 사용할 수 있다.

public class EnumTest {
    public static void main(String[] args) {
        System.out.println(Direction.EAST); // EAST

        System.out.println(Direction.valueOf("EAST")); // EAST

        for(Direction direction : Direction.values()) {
            System.out.print(direction + " "); // EAST WEST SOUTH NORTH
        }

        System.out.println();
        for(Direction direction : Direction.values()) {
            System.out.print(direction.ordinal() + " "); // 0 1 2 3
        }

        System.out.println();
        for(Direction direction : Direction.values()) {
            System.out.print(direction.name() + " "); // EAST WEST SOUTH NORTH
        }
    }
}

 

4. enum 은 int ordinal(), String name(), String toString(), Enum<T>[] values(), Enum<T> valueOf(String name) 메서드를 제공한다.

5. int compareTo(E o), boolean equals(Object other) 를 제공한다. 두 메서드는 enum 클래스 안에서 상수들이 선언된 순서를 기준으로 비교한다.

6. enum 은 다른 클래스를 상속할 수 없다. 이미 java.lang.Enum<E> 를 상속받고 있기 때문이다.

7. enum 의 enum 타입 변수가 static final 로 구현되어 있으므로 enum 타입을 비교할 때에는 equals() 가 아니라 == 를 사용해도 된다.

8. enum 에 추상메서드를 선언하고 각 상수에서 오버라이딩한다면 상수마다 추상메서드를 가질 수 있다. -> 다형성 

 

 

 


비교 및 정리

 


record 와 enum 의 차이?

record 는 데이터 클래스를 위해 만들어진 참조타입이다. 즉 인스턴스를 찍어내는 클래스라는 것이다.

enum 은 상수를 위해 만들어진 참조타입이다. static final 인 enum 참조타입 클래스 변수를 사용하기 위한 클래스이다.

 

record 의 (내부적으로 생성되는) 컴팩트 생성자는 public 이다. new 키워드로 인스턴스 생성이 가능하다는 것이다.

enum 의 (내부적으로 생성되는) 디폴트 생성자는 private 이다. enum 은 인스턴스 생성을 위한 목적이 아니란 것이다. 

 

record 는 추상메서드를 가질 수 없다. 

enum 은 추상메서드를 선언한 후, 각 상수에서 오버라이딩한다면 추상메서드를 가질 수 있다.

 

record 는 자바 컴파일러가 파라미터 값에 따라 getter, equals, hashCode, toString 을 오버라이딩해준다.

enum 은 자바 컴파일러가 toString 을 오버라이딩해준다. (static 이므로 equals, hashCode 오버라이딩할 필요없다. 단 하나만 존재하기 때문에!) 하지만 직접 getter 를 만들어야 한다.

enum 은 자바 컴파일러가 ordinal(), name(), values(), valueOf(String name) 메서드를 구현해준다

 

record 는 자바 컴파일러가 파라미터 값에 따라 컴팩트 생성자를 만들어준다. 만약 파라미터 개수가 다른 생성자를 만들고 싶다면 this()를 사용한다. 

enum 은 상수 이름(String name)을 기준으로 디폴트 생성자가 만들어진다.

 

record 는 인스턴스 변수를 가질 수 없다. 즉, 정적 변수만 가능하다. 이미 record 를 선언할 때 인스턴스 변수를 넣기 때문!

enum 은 인스턴스 변수를 가질 수 있다. 내부적으로 상수 이름을 따와 존재하는 String name 인스턴스 변수 이외에도 추가적으로 인스턴스 변수를 가질 수 있다

 

record 와 enum 의 공통점?

다른 클래스를 상속할 수 없다.

record 는 내부적으로 이미 java.lang.Record 를 상속받기 때문이다.

enum 은 내부적으로 이미 java.lang.Enum<E> 를 상속받기 때문이다.

 

암시적으로 final 이다. (단, record 는 static 이고 enum 은 non-static 이다.)

record 는 데이터 클래스이므로 불변이 원칙이다.

enum 은 상수 클래스이므로 불변이 원칙이다.

 

 

 

 

 

 

 

 

 


미션 시작!

 


 

 

[첫번째 의문과 깨달음]

enum 을 왜? 어떻게? 쓸 것인가

 

처음엔 1 - 45 수를 이넘으로 만들려 했다. 자주 쓰이는 상수라고 생각했기 때문이다.

하지만 ONE 은 1 을, TWO는 2를, ... FOURTY_FIVE 는 45 를 의미하는게 필연적이고 옳다.

이 방법으로 enum 을 쓰는 건 틀린 생각이다. (하면서도 이게 맞나 싶었다...ㅋㅋ)

public enum LottoNumber {
    ONE(1), TWO(2), THREE(3), FOUR(4), FIVE(5),
    SIX(6), SEVEN(7), EIGHT(8), NINE(9), TEN(10),
    ELEVEN(11), TWELVE(12), THIRTEEN(13), FOURTEEN(14), FIFTEEN(15),
    SIXTEEN(16), SEVENTEEN(17), EIGHTEEN(18), NINETEEN(19), TWENTY(20),
    TWENTY_ONE(21), TWENTY_TWO(22), TWENTY_THREE(23), TWENTY_FOUR(24), TWENTY_FIVE(25),
    TWENTY_SIX(26), TWENTY_SEVEN(27), TWENTY_EIGHT(28), TWENTY_NINE(29), THIRTY(30),
    THIRTY_ONE(31), THIRTY_TWO(32), THIRTY_THREE(33), THIRTY_FOUR(34), THIRTY_FIVE(35),
    THIRTY_SIX(36), THIRTY_SEVEN(37), THIRTY_EIGHT(37), THIRTY_NINE(39), FORTY(40),
    FORTY_ONE(41), FORTY_TWO(42), FORTY_THREE(43), FORTY_FOUR(44), FORTY_FIVE(45);

    private final Integer value;

    LottoNumber(Integer value) {
        this.value = value;
    }
}

enum 은 상수가 아닌 것을 상수화시켜 편리하게 사용하는 것이 목적이다!

예를 들어 Integer 타입의 1 을 Rank.ONE 으로 정의하는 것이 아니라

5등은 번호가 3개일치, 당첨금은 5000원 등과 같은 정보들을 상수화시키는 것이 맞는 것 같다! 다음과 같은 enum 처럼 말이다!

public enum Rank {

    FIFTH("3개 일치 (5,000원)"),
    FOURTH("4개 일치 (50,000원)"),
    THIRD("5개 일치 (1,500,000원)"),
    SECOND("5개 일치, 보너스 볼 일치 (30,000,000원)"),
    FIRST("6개 일치 (2,000,000,000원)");

    private final String info;

    Rank(String info) {
        this.info = info;
    }
}

 

[두번째 의문과 깨달음]

ProducerNumbers 와 ProducerBonusNumbers 를 어떻게 관리할 것인가

 

ProducerNumbers 는 당첨 번호

ProducerBonusNumbers 는 당첨 보너스 번호

 

이 둘을 어떻게 관리해야 효율적으로 관리할 수 있을까?

이 둘을 레코드로 관리해도 될까?

레코드는 데이터 클래스이니깐, 숫자는 한번 발행되면 바뀌지 않으니깐, 보일러 플레이트를 줄일 수 있으니깐! 충분히 쓸 만하다!

 

이 둘을 묶는 상위 클래스가 필요할까?

만약 묶는다면 어떤 점이 좋을까?

묶는다면 두 개의 레코드를 따로 관리할 필요가 없는 점이 장점인 것 같다

일단 record Producer 로 묶어서 하나로 관리해보자!

 

 

[세번째 의문과 깨달음]

record... 너무 어렵다!

 

record 를 선언할 때 인스턴스 변수를 넣는 것을 제외하고는 모두 클래스 변수(정적 변수)로 선언해야 한다.

그리고 그 클래스 변수는 final 을 붙이지 않더라도 암묵적으로 final 이 된다.

근데 price, count 는 사용자로부터 입력값을 받아서 초기화해야 한다.

근데 Consumer 은 domain 이기 때문에 생성자에 view 로직이 들어가면 안된다.

 

항상 그랬듯이

컨트롤러에서 InputView 를 참조하고

InputView 에서 사용자로부터 입력값을 받고

그 값을 Consumer 생성자에서 파라미터로 받으면 된다!

물론 레코드는 컴팩트 생성자이기 때문에 레코드를 선언할 때 파라미터로 넣은 변수를 사용해서 검증을 걸어주면 된다!

 

레코드 안에 레코드들이 있도록 구현해보자

Consumer -> ConsumberNumbers, Count, Price

Producer -> ProducerNumbers, ProducerBonusNumber

이런 식으로!

 

[네섯번째 의문과 깨달음]

record 는 완전 신세계인데, 그럼 모든 클래스를 레코드로 바꿔버릴까?

 

위 질문은 레코드를 공부하고 미션에 적용하며 느꼈던 생각이다.

하지만 간과한 것이 있다. 

레코드는 데이터용 클래스라는 것이었다!

도메인 정도는 모두 레코드로 사용해도 될 것 같은데,

InputView, OutputView, Controller 등 로직을 담아야 하는 것들은 레코드로 사용하기에 부적합하다! 

 

[다섯번째 의문과 깨달음]

검증은 InputView 에서 한번만 걸어도 할까? 도메인 생성자에서도 또 걸어야 할까?

 

일단 가장 좋은 건 둘 다 거는 것 같은데, 

지금까지 두 군데에서 다 걸어본 적 없으니깐 시간 상관하지 말고 걸어보자!

 

한 5시간은 프로젝트 구조 수정하느라 소모한 것 같다...

예외가 중복되는 문제를 어떻게 해야 할지 몰랐기 때문이다.

내가 떠올린 방법은 공통 예외와 세부 예외를 나눈 것이다.

 

InputView 의 검증에서는 숫자를 입력할 곳에 숫자를 입력하도록, 허용되지 않은 특수문자는 입력하지 않도록 등의 공통예외를 검증했다.

그 이후에 도메인 Lotto, Price, Bonus 등의 생성자에서 2차 검증을 걸어줬다. 

검증에서는 중복되지 않은 다른 숫자를 입력하도록, 특정 범위의 숫자만 입력하도록, 1000단위의 숫자만 입력하는 등의 세부 예외를 검증했다.

 

이렇게 하는게 맞을까 하는 의문이 들긴한다.

왜냐면 애초에 InputView 에서 완벽하게 1차 검증하면 굳이 도메인 생성자에서 2차 검증을 걸어줄 필요가 없지 않을까? 하는 생각 때문이다. 그래도 1차, 2차 검증을 유지한 이유는 다음과 같다.

 

1차 검증은 입력값 그 자체에 대한 검증이다.

숫자 입력할 곳에 문자를 입력하지 않도록, 문자 입력할 곳에 숫자 입력하지 않도록 검증하는 것이다

 

2차 검증은 로직을 위한 검증이다.

로또 번호는 1 이상 45 이하의 수로만 입력하도록, 가격은 1000 단위로 입력하도록 등을 검증하는 것이다

 

이런 이유로 검증을 두 곳에서 나눠 걸어줬다!

 

[여섯번째 의문과 깨달음]

예외는 어디서 잡아야 하지? 도메인 로직에서? inputView 공통로직에서?

 

위에서 검증을 1차로 InputView 에서 걸어줬고, 2차로 도메인 생성자에 걸어줬다.

그렇다면 예외를 InputView 와 도메인 생성자에서 잡아야 할까?

그건 아니다! InputView, 도메인 모두를 참조할 수 있는 컨트롤러에서 예외를 잡는 것이 맞는 것 같다!

 

[일곱번째 의문과 깨달음]

'10'은 Character 가 아니다!

 

로또 번호는 이런 식으로 입력받는다.

[1,2,3,4,5,6]

 

그래서 콤마로 split 하고 문자열 배열을 Character 로 캐스팅해서

문자 하나하나를 가져와서 Character.isDigit(char c) 을 사용해서 문자가 숫자를 의미하는 문자인지를 검증하려 했다.

 

하지만 검증이 이뤄지지 않았고, 이유를 찾느라 1시간 소모했다...!

이유는 다음과 같다.

 

로또번호는 1 이상 45 이하의 숫자이다.

Character.isDigit('0') 은 문자 0 이 숫자라고 잘 판단할 것이다.

Character.isDigit('9') 은 문자 9 가 숫자라고 잘 판단할 것이다.

하지만 Character.isDigit('10') 할 때 10 은 문자가 아니라 문자열이다. 그래서 검증이 계속 실패했었다!

 

 

[여덟번째 의문과 깨달음]

로또 당첨 메커니즘을 짜는게 제~~일 어려웠다.

그냥 반복문 돌면서 하나씩 당첨 여부 판단해서 뽑으면 어찌저찌 될 줄 알았는데 막상 해보니 헬난이도였다...!

 

첫번째 문제는 당첨 여부를 구분하는 것이었다. 예를 들어 다음과 같다.

5등 - 번호 3개 일치

4등 - 번호 4개 일치

3등 - 번호 5개 일치

2등 - 번호 5개 일치 + 보너스 번호 일치

1등 - 번호 6개 일치

 

두번째 문제는 당첨 결과에 따라 당첨금 총액을 구하는 것이었다. 예를 들어 다음과 같다.

5등 - 3개 일치(5000원) - a번

4등 - 4개 일치(50000원) - b번

3등 - 번호 5개 일치 - c번

2등 - 번호 5개 일치 + 보너스 번호 일치 - d번

1등 - 번호 6개 일치 - e번

만약 위의 당첨 결과의 당첨금 총액을 구하려면 5a + 4b + 3c + 2d + 1e 를 구해야 하는 것이다.

 

2등과 3등을 구분하는 것이 어려웠다.

번호가 5개 일치한다면,

보너스 번호 일치 여부에 따라 2등과 3등을 또 다시 나누고

2등이라면 3등은 배제하고, 3등이라면 2등은 배제하게 해야 하는데,

이상하게 2등이 당첨되면 3등까지 중복 당첨되는 문제가 발생하고 있었다.

 

스트림을 써서 어떻게든 깔끔하게 짜보려고 했지만 하루종일해도 로직을 짤 수 없어서 

결국 반복문을 도입하기로 했다... 그리고 enum 도 사용하기로 했다...!

 

consumerLottos, producerLotto, bonus 를 활용해야 한다.

List<Lotto> consumerLottos : 구매한 로또 (가격에 따라 n개만큼의 Lotto들로 이뤄진 일급컬렉션)

Lotto producerLotto : 로또 당첨 번호 객체 (6개의 번호들로 이뤄진 일급컬렉션)

Bonus bonus : 로또 당첨 보너스 번호 객체

 

또한 Rank enum을 다음과 같이 수정해야 한다. 스트림을 돌때 조건으로 삼기 위함이다.

FIFTH("3개 일치 (5,000원)", 3, false, 5_000),
FOURTH("4개 일치 (50,000원)", 4, false, 50_000),
THIRD("5개 일치 (1,500,000원)", 5, false, 1_500_000),
SECOND("5개 일치, 보너스 볼 일치 (30,000,000원)", 5, true, 30_000_000),
FIRST("6개 일치 (2,000,000,000원)", 6, false, 2_000_000_000);

첫번째 인자는 당첨 메시지

두번째 인자는 로또 번호 일치 갯수

세번째 인자는 로또 보너스 번호 일치 여부

네번째 인자는 로또 당첨금 금액을 의미한다.

 

최대한 간결하게 로직을 정리한다면 다음과 같다.

1. 스트림으로 consumerLottos 의 Lotto 객체의 6개의 번호들을 하나씩 순회하며 producerLotto 의 6개의 번호들과 비교하여 일치하는 갯수를 세고 그 갯수를 반환하는 메서드를 준비한다

2. 스트림으로 consumerLottos 의 Lotto 객체의 6개의 번호들을 하나씩 순회하며 bonus 의 1개의 번호와 비교하여 일치 여부를 판단하고 true/false 를 반환하는 메서드를 준비한다

3. consumerLottos 를 순회하며 위 1번, 2번에서 사용한 메서드들을 사용하여 Map<Integer, Boolean> matchingCountMap 에 저장하고 그 map 을 반환하는 메서드를 준비한다. 이 때 Integer 는 로또 번호 일치 갯수, Boolean 은 로또 보너스 번호 일치 여부를 의미한다.

4. matchingCountMap 을 순회하며 Key가 Rank의 두번째 인자(로또 번호 일치 갯수)와 일치하는 것을 필터링하고 그 갯수를 반환하는 메서드를 준비한다. 이는 1,3,4,5등을 필터링하기 위한 메서드이다.

5. matchingCountMap 을 순회하며 Key가 Rank의 두번째 인자(로또 번호 일치 갯수)와 일치하는 것 && Rank의 세번째 인자(로또 보너스 번호 일치 갯수)와 일치하는 것을 필터링하고 그 갯수를 반환하는 메서드를 준비한다. 이는 2등을 필터링하기 위한 메서드이다.

6. Rank.values() 를 순회하며 4번에서 준비한 메서드(1,3,4,5등을 필터링하기 위한 메서드)를 호출하고 이후 5번에서 준비한 메서드(2등)를 호출하며 당첨 횟수를 계산하고 반환한다. 로또 번호 당첨 횟수와 당첨 횟수를 헷갈리지 않도록 조심해야 한다.

로또 번호 당첨 횟수는 1,2,3,4,5,6 중 몇 개의 번호가 일치하는지 인것이고

당첨 횟수는 5등을 몇번 당첨했는지인 것이다.

동시에 순회하는 Rank 객체의 네번째 인자에 접근하여 당첨금과 당첨 횟수를 곱해서 총 당첨금액을 계산하여 반환한다.

그 총 당첨금액을 OutputView 에서 사용하여 출력한다.

 

위 로직을 짜기 까지 정말 많이 고생했다...! 더 깔끔하게 짜고 싶었지만 도저히 방법을 몰라서 어떻게든 구현해보았다!

 

 

 

 

 

 

 


마무리 소감

 


 

 

이번주는 할일이 너무 많았다.

게임소프트웨어, 정보 및 시스템 보안, C++ 등 학교 과제들을 하느라 바빴다.

매일 저녁 3시간식 알바하면서 졸 뻔도 했다.

매일 4시간을 자면서 월화수목금을 살아간다는게 신기하다.

그래도 이렇게 할 수 있는 이유는 재밌기 때문이라고 생각한다.

취업 때문에, 돈 때문에, 부모님 때문에 하는게 아니라 내가 원해서 하는 공부니깐 버틸 수 있는 것 같다.

 

이제 1주만 더 지나면 프리코스는 끝난다. 

최종코테에 선발된다면 그 경험을 하는 것만으로도 좋겠지만, 

지금까지 프리코스로도 많은 것들을 고민하고 배울 수 있어서 좋았다.

우테코가 왜 모두에게 프리코스에 도전해볼 기회를 줬는지 알 것 같다.

같이 성장하려고 하는 우테코를 볼수록 우테코에 들어가고 싶은 마음이 점점 더 커진다.

더 열심히, 더 행복하게 공부해야겠다!

 

 

Comments