백준 1931번 회의실 배정

 

1931번: 회의실 배정

(1,4), (5,7), (8,11), (12,14) 를 이용할 수 있다.

www.acmicpc.net

 

📖 문제

 

그리디 알고리즘의 대표적인 예시 문제라고 해도 될 문제입니다. 그래서 풀이에 대해서 조금 정리해보려고 합니다.

 

 

 

🔎 접근 방법

  • 회의 일정에는 시작 시간과 종료 시간이 존재합니다.
  • 회의 일정은 겹치면 안되고, 최대한 많은 회의를 진행해야 합니다.

 

위 조건을 생각해봤을 때, 이전 회의 일정의 종료 시간과 겹치지 않아야 하고 이후 일정의 시작 시간과 겹치지 않아야 합니다.

즉, 회의 일정을 종료 시간 기준으로 오름차순 정렬하여 종료 시간이 빠른 일정부터 해결해나가면 정답에 접근할 수 있습니다.

 

1. 회의 일정을 저장할 수 있는 클래스 선언 후 Comparable 구현하기

배열을 사용하는 풀이도 많았지만 가장 직관적이고 자바라는 언어 특성을 사용할 수 있도록 Meeting 클래스를 만들어 객체의 비교 기준을 정의할 수 있는 Comparable를 구현해 줬습니다.

class Meeting implements Comparable<Meeting> {
    int start;
    int end;

    public Meeting(int start, int end) {
        this.start = start;
        this.end = end;
    }

    @Override
    public int compareTo(Meeting obj) {
        // 종료 시간이 동일하다면 시작 시점이 빠른 순으로 정렬
        if (this.end == obj.end) {
            return this.start - obj.start;
        }
        // 종료 시점이 빠른 순으로 회의 일정을 정렬
        return this.end - obj.end;
    }
}

start와 end는 회의의 시작 시간과 종료 시간이고, Comparable을 구현하기 위한 compareTo() 메소드를 보면 정렬 조건을 확인할 수 있습니다. 

 

위에서는 정렬 조건을 종료 시간만을 언급했지만, 문제에서 "회의의 시작시간과 끝나는 시간이 같을 수도 있다."란 조건이 존재하기 때문에 회의 시작 시간도 신경 써줘야 합니다. 반례를 한번 생각해 볼까요

[입력]
4
3 5
1 3
8 8
5 8

위처럼 만약 입력이 들어왔다고 했을 때, 정답이 몇일까요? 정답은 4입니다. 하지만 회의의 종료 시간만을 고려해서 정렬하게 되면 3이라는 결과를 얻을 수도 있습니다. 어떻게 3이 나올 수 있었을까요. 회의의 종료시간을 기준으로 정렬을 해보겠습니다.

Meeting A(1, 3) -> Meeting B(3, 5) -> Meeting C(8, 8) -> Meeting D(5, 8)

회의의 종료 시간을 오름차순으로 정렬했습니다. 이제 처음 데이터부터 확인하면서 회의 일정을 확인해 보면 아래와 같습니다.

1시 - 3시 : Meeting A
3시 - 5시 : Meeting B
8시 - 8시 : Meeting C

Meeting D의 시작 시간인 5시가 이전 미팅 Meeting C의 종료 시간 8시보다 작기 때문에 이어서 회의를 진행할 수 없습니다.

이런 반례가 발생할 수 있기 때문에 해당 문제는 회의의 종료 시간도 오름차순으로 정렬해줘야 하고, 회의 종료 시간이 같을 때는 시작 시간으로 오름차순 정렬을 해줘야 합니다.

 

 

2. 입력받은 데이터로 Meeting 인스턴스 만들어서 PriorityQueue에 넣기

BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
StringTokenizer st;
PriorityQueue<Meeting> meetings = new PriorityQueue<>();
int n = Integer.parseInt(br.readLine());
for (int i = 0; i < n; i++) {
    st = new StringTokenizer(br.readLine());
    int start = Integer.parseInt(st.nextToken());
    int end = Integer.parseInt(st.nextToken());
    meetings.offer(new Meeting(start, end));
}

Meeting 인스턴스를 저장해서 바로 우리가 정의한 Comparable의 정렬 조건대로 정렬하기 위해서 우선순위 큐(Priority Queue)를 사용했습니다. 물론 ArrayList를 사용해서 Collections.sort()를 사용해 줘도 됩니다.

 

 

3. Priority Queue의 Meeting 객체를 하나씩 꺼내오면서 이전 Meeting의 종료 시간과 비교하여 정답을 확인

Meeting prevMeeting = meetings.poll();
int answer = 1; // 가장 처음 회의 일정은 추가되기 때문에 1부터 시작
while (!meetings.isEmpty()) {
    Meeting meeting = meetings.poll();
    // 현재 회의 시작 시간이 이전 회의 일정의 종료 시간과 동일하거나 크면 정답에 추가
    if (prevMeeting.end <= meeting.start) {
        answer++;
        // 현재 회의를 이전 회의로 지정
        prevMeeting = meeting;
    }
}
System.out.println(answer);

prevMeeting(이전 회의)에 가장 우선순위가 높은 회의 일정을 하나 넣어줍니다. N은 1 이상이기 때문에 비어있는 검사를 하지 않고 바로 poll()을 사용해서 넣어줬습니다. 방금 꺼내준 회의 일정은 바로 추가되기 때문에 answer는 1로 초기화합니다.

 

그리고 이제 Priority Queue가 비워질 때까지 반복문을 돌면서 회의 일정을 확인합니다. 만약 이전 회의의 종료 시간보다 현재 회의의 시작 시간이 크거나 같다면 정답 카운트를 증가시키고, prevMeeting를 다시 현재 회의 meeting으로 갱신합니다.

 

 

 

🧑🏻‍💻 전체 코드

import java.io.*;
import java.util.PriorityQueue;
import java.util.StringTokenizer;

public class Main {

    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        StringTokenizer st;
        PriorityQueue<Meeting> meetings = new PriorityQueue<>();
        int n = Integer.parseInt(br.readLine());
        for (int i = 0; i < n; i++) {
            st = new StringTokenizer(br.readLine());
            int start = Integer.parseInt(st.nextToken());
            int end = Integer.parseInt(st.nextToken());
            meetings.offer(new Meeting(start, end));
        }
        Meeting prevMeeting = meetings.poll();
        int answer = 1; // 가장 처음 회의 일정은 추가되기 때문에 1부터 시작
        while (!meetings.isEmpty()) {
            Meeting meeting = meetings.poll();
            // 현재 회의 시작 시간이 이전 회의 일정의 종료 시간과 동일하거나 크면 정답에 추가
            if (prevMeeting.end <= meeting.start) {
                answer++;
                // 현재 회의를 이전 회의로 지정
                prevMeeting = meeting;
            }
        }
        System.out.println(answer);
    }
}

class Meeting implements Comparable<Meeting> {
    int start;
    int end;

    public Meeting(int start, int end) {
        this.start = start;
        this.end = end;
    }

    @Override
    public int compareTo(Meeting obj) {
        // 종료 시간이 동일하다면 시작 시점이 빠른 순으로 정렬
        if (this.end == obj.end) {
            return this.start - obj.start;
        }
        // 종료 시점이 빠른 순으로 회의 일정을 정렬
        return this.end - obj.end;
    }
}

DFS 문제 따로 BFS 문제 따로 풀어본 적은 있는데 한 문제에서 동일한 그래프로 DFS와 BFS를 모두 사용해야 하는 문제였습니다.

 

🔗 문제 링크

https://www.acmicpc.net/problem/1260

 

1260번: DFS와 BFS

첫째 줄에 정점의 개수 N(1 ≤ N ≤ 1,000), 간선의 개수 M(1 ≤ M ≤ 10,000), 탐색을 시작할 정점의 번호 V가 주어진다. 다음 M개의 줄에는 간선이 연결하는 두 정점의 번호가 주어진다. 어떤 두 정점 사

www.acmicpc.net

 

 

 

🔍 문제 분석

  • 첫 째줄에 정점의 개수(N, 노드의 개수)와 간선의 개수(M) 그리고 탐색 시작 정점(V)이 공백으로 주어집니다.
  • 그다음 간선의 개수(M)만큼의 입력이 주어지고, 각 입력 줄에는 연결되어 있는 Node와 Node가 공백으로 주어집니다.
  • 정점의 번호는 1번부터 N번까지 이며, DFS 탐색 결과와 BFS 탐색 결과를 출력해 주면 되는 문제입니다.
  • 여기서 신경써줘야 할 점은 연결된 Node가 여러 개라면 크기가 작은 것부터 탐색을 시작합니다.

 

 

 

🖥️ 문제 풀이 코드

import java.io.*;
import java.util.*;

public class Main {
    static int n;
    static StringBuilder builder;
    static boolean[] visited;

    public static void main(String[] args) throws IOException {
        BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));

        String[] inputData = reader.readLine().split(" ");
        n = Integer.parseInt(inputData[0]);
        int m = Integer.parseInt(inputData[1]);
        int v = Integer.parseInt(inputData[2]);

        // step 1. 그래프 만들기
        int[][] graph = new int[n + 1][n + 1];
        for (int i = 0; i < m; i++) {
            inputData = reader.readLine().split(" ");
            int startNum = Integer.parseInt(inputData[0]);
            int endNum = Integer.parseInt(inputData[1]);

            graph[startNum][endNum] = 1;
            graph[endNum][startNum] = 1;
        }
        builder = new StringBuilder();
        visited = new boolean[n + 1];
        dfs(v, graph);
        builder.append("\n");
        visited = new boolean[n + 1];
        bfs(v, graph);
        System.out.println(builder);
    }

    // step 2. BFS 탐색 (Queue)
    public static void bfs(int start, int[][] graph) {
        Queue<Integer> queue = new LinkedList<>();
        queue.offer(start);
        visited[start] = true;

        while (!queue.isEmpty()) {
            int vortex = queue.poll();
            builder.append(vortex).append(" ");
            int[] vortexWithNodes = graph[vortex];

            for (int i = 1; i <= n; i++) {
                if (vortexWithNodes[i] == 1 && !visited[i]) {
                    queue.offer(i);
                    visited[i] = true;
                }
            }
        }
    }

    // step 3. DFS 탐색 (재귀)
    public static void dfs(int start, int[][] graph) {
        visited[start] = true;
        builder.append(start).append(" ");
        int[] startWithNodes = graph[start];

        for (int i = 1; i <= n; i++) {
            if (startWithNodes[i] == 1 && !visited[i]) {
                dfs(i, graph);
            }
        }
    }
}

 

  • 처음에 Graph를 어떤 방식으로 해놓을지 고민했었습니다. 첫 번째 방법은 위에 구현한 방법과 같은 인접 행렬 방법을 사용하는 방법이 있고, 두 번째는 각 노드에 연결된 점들을 리스트에 모아두는 인접 리스트 방법이 존재합니다.
  • 인접 행렬 방법을 선택한 이유는 간단합니다. "연결된 노드가 여러 개라면 크기가 작은 노드부터 방문합니다."라는 조건에서 연결된 Node들이 정렬되어 있어야 된다는 것 때문입니다.
  • 만약 인접 리스트 방법을 사용했다면, 연결된 노드들을 정렬해 주는 Collections.sort()를 사용해야 됐습니다.

 

 

 

🧑🏻‍💻 문제 풀이 전략

1. BFS는 Queue를 이용해서 구현했습니다.

public static void bfs(int start, int[][] graph) {
    Queue<Integer> queue = new LinkedList<>();
    queue.offer(start);
    visited[start] = true;

    while (!queue.isEmpty()) {
        int vortex = queue.poll();
        builder.append(vortex).append(" ");
        int[] vortexWithNodes = graph[vortex];

        for (int i = 1; i <= n; i++) {
            if (vortexWithNodes[i] == 1 && !visited[i]) { // <-- 이미 탐색한 노드인지 확인
                queue.offer(i);
                visited[i] = true;
            }
        }
    }
}
  • 큐(Queue)를 통해서 탐색 노드에 연결된 노드들을 모두 탐색하고, 탐색이 가능한 데이터(값이 1이고, 방문기록이 false인 노드)라면 Queue에 모두 넣습니다.
  • 이때 Queue에 넣으면서 해당 노드의 방문처리를 해줘야 합니다.
  • Queue에 데이터가 없을 때까지 반복하여 BFS 탐색을 진행합니다.

 

2. DFS는 재귀를 이용해서 구현했습니다.

public static void dfs(int start, int[][] graph) {
    visited[start] = true;
    builder.append(start).append(" ");
    int[] startWithNodes = graph[start];

    for (int i = 1; i <= n; i++) {
        if (startWithNodes[i] == 1 && !visited[i]) { // <-- 이미 탐색한 노드인지 확인
            dfs(i, graph);
        }
    }
}
  • 재귀를 통해서 가장 깊은 곳까지 탐색을 하고, 더이상 탐색할 데이터가 없으면 빠져나오게 됩니다.

 

3. Graph와 Visited는 공유하는 데이터입니다.

  • 그래프 탐색을 하는데 있어서 Graph의 내부 값을 변경시키지는 않습니다.
  • 하지만 Visited는 값을 false에서 true로 변경시키며 탐색하기 때문에 static으로 선언해서 탐색 시작 전에 new boolean[]으로 초기화를 진행해줬습니다.
visited = new boolean[n + 1]; // <-- 탐색 전 초기화
dfs(v, graph);

visited = new boolean[n + 1]; // <-- 탐색 전 초기화
bfs(v, graph);

 

크리티컬 한 오류는 아니지만 IntelliJ에서 작동이 안 되길래 찾아본 오류입니다.

 

🤔 문제발생

@ConfigurationProperties(prefix = "spring.thymeleaf")를 사용하여 사용자 정의 프로퍼티(spring.thymeleaf3)를 만드는 코드를 작성했습니다.

물론 main 메서드가 있는 클래스에서 해당 프로퍼티를 스캔할 수 있도록 @ConfigurationPropertiesScan도 붙여줬습니다.

/*  앞 코드 생략  */

@Getter
@RequiredArgsConstructor
@ConstructorBinding
@ConfigurationProperties(prefix = "spring.thymeleaf3")
public static class Thymeleaf3Properties {
    /**
     * Thymeleaf 3 Decoupled Logic 기능의 활성화
     */
    private final boolean decoupledLogic;
}

/*  뒤 코드 생략  */

 

그리고 나서 사용자 정의 프로퍼티에 대한 지원을 해주는 의존성인 configuration-processor도 추가해 줬습니다.

// build.gradle

dependencies {
    /* 생략 */
    
    annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
}

 

위 의존성을 추가해 주고, gradle을 다시 build 했습니다. 그리고 나서 application.properties에 들어가서 사용자 정의한 "spring.thymeleaf3"가 자동완성되는지 확인했는데, 작동이 안 됐습니다.

 

 

 

🙆🏻‍♂️ 시도 & 해결

구글링에 들어갔습니다. 주요 검색 키워드는 configuration-processor is not working...

 

(1) Clean 하고 다시 Build를 해보자.

 

수도 없이 해봤지만 작동하지 않았습니다. IntelliJ 캐시 문제인가..?라는 생각이 들어서 캐시를 비우고, IDE를 재시작하고 여러 방법을 시도해 봤습니다.

 

이 방법도 근데 소용은 없었습니다. 그럼 캐시 문제도 아니고, 빌드의 문제도 아니라는 것입니다. 

 

 

 

(2) 사용자 정의 프로퍼티를 만들면 spring-configuration-metadata.json 파일이 생성된다는 글을 발견

어디에 생성되는지 찾아보니 아래와 같은 경로에 생성되었습니다.

build/classes/java/main/META-INF/spring-configuration-metadata.json

json 파일로 생성되고, 우리가 Java Doc으로 작성한 내용도 저장되며, 해당 프로퍼티에 대한 메타 데이터가 들어가 있는 파일이었습니다.

{
  "groups": [
    {
      "name": "spring.thymeleaf3",
      "type": "com.fastcampus.getinline.config.ThymeleafConfig$Thymeleaf3Properties",
      "sourceType": "com.fastcampus.getinline.config.ThymeleafConfig$Thymeleaf3Properties"
    }
  ],
  "properties": [
    {
      "name": "spring.thymeleaf3.decoupled-logic",
      "type": "java.lang.Boolean",
      "description": "Thymeleaf 3 Decoupled Logic 기능의 활성화",
      "sourceType": "com.fastcampus.getinline.config.ThymeleafConfig$Thymeleaf3Properties",
      "defaultValue": false
    }
  ],
  "hints": []
}

 

즉, Build는 정상적으로 되었고 메타 데이터도 정상적으로 들어왔다는 것이 확인되었습니다.

그럼 문제는 하나 IntelliJ에서 해당 메타 데이터를 인식하지 못한다는 점이었습니다.

 

 

 

(3) IntelliJ Project Setting 변경

그렇게 구글을 찾아서 돌아다니다가 글 하나를 발견했습니다. 

https://youtrack.jetbrains.com/issue/IDEA-191886

 

spring-configuration-metadata.json ignored in Gradle "build" directory : IDEA-191886

I am trying to get IDEA to recognize custom properties in my Spring Boot application. The required metadata file is generated by Gradle in "build/classes/java/main/META-INF/spring-configuration-metadata.json" but IDEA does not seem to recognise it. As soon

youtrack.jetbrains.com

제목부터가 "spring-configuration-metadata.json ignored in Gradle "build" directory"이어서 뭔가 내가 찾던 문제와 같은 문제를 겪고 있는 거 같았습니다.

The required metadata file is generated by Gradle in "build/classes/java/main/META-INF/spring-configuration-metadata.json" but IDEA does not seem to recognise it.

 

해당 글 중에서 제가 말했던 경로에 metadata.json이 만들어는 졌는데 IDEA가 그걸 인지하지 못한다는 질문이었습니다. 역시... 구글링은 영어로 하는 것이 정답인가... 영어를....

 

해당 질문 댓글 중에서 해결책 하나가 쓰여있는 거 같아서 해봤습니다.

 

IntelliJ에서 Project Structrue에 들어갑니다. 맥에서는 {Command + ;}를 누르면 들어가집니다. 위 Tool bar를 통해서도 들어갈 수 있습니다.

 

Modules -> main -> Paths를 눌러보면 아래와 같은 화면이 나옵니다.

 

Output path를 보면 지금 out/production/classes로 되어 있는데, 이것을 Metadata.json이 있던 경로로 변경해 줬습니다.

 

out/production/classes -> build/classes/java/main으로 변경해 줬습니다. 

 

그리고 나서 Gradle clean을 하고 다시 build를 해주고 나서 application.properties에서 사용자 정의한 "spring.thymeleaf3"이 자동완성이 되는지 해봤습니다.

 

우와...됐다... 이전에는 자동완성이 나오지 않았었는데, spring.만 쳐도 thymeleaf3가 나오는 것을 볼 수 있습니다.

 

 

 

🙆🏻‍♂️ 알게 된 점 & 느낀 점

  • 구글링을 한글로 막 검색하다 보니 자료도 많이 안 나왔던 거 같습니다.
  • 해당 문제가 뭐 때문에 발생하는지 판단을 먼저 해야 정확한 구글링이 될 수 있다는 것을 느꼈습니다.
  • 저는 해당 문제가 Spring configuration processor의 문제라고 생각하고, 그쪽으로 검색을 엄청했었는데, IntelliJ의 문제일 수도 있겠다는 생각으로 검색을 하니 방법을 찾을 수 있었다고 생각합니다.

 

 

 

📘 참고자료

https://docs.spring.io/spring-boot/docs/current/reference/html/configuration-metadata.html#appendix.configuration-metadata.annotation-processor.automatic-metadata-generation

 

Configuration Metadata

Configuration metadata files are located inside jars under META-INF/spring-configuration-metadata.json. They use a JSON format with items categorized under either “groups” or “properties” and additional values hints categorized under "hints", as sh

docs.spring.io

 

평소에 늘 JDK11 버전을 사용해서 모르고 있었는데, 최근 JDK16으로 버전을 바꿔보면서 새롭게 사용해 본 기능이 있어서 기록해 볼까 합니다. 추가로 평소에 궁금했었던 불변 객체에 대해서도 조금 찾아서 기록해보려고 합니다.

 

 

📖 불변 객체(Immutable Object)란?

  • 생성 후 그 상태를 바꿀 수 없는 객체를 의미합니다.
  • 불변객체는 읽을 수만 있는 Read-Only 메서드만을 제공합니다.
  • Java의 대표적인 불변객체로는 String이 있습니다.

 

# String Class Description

 

그럼 String은 생성된 후 값을 변경할 수 없다는건가? 그럼 아래 코드는 무엇인가? 값을 변경하는 것이 아닌가?

public class Main {
    public static void main(String[] args) {
        String stringEx = "Hello";
        System.out.println(stringEx); // Hello
        
        stringEx = "World";
        System.out.println(stringEx); // World
    }
}
  • stringEx라는 String 객체의 값을 Hello에서 World로 바꾼 것이 아닌가?라는 생각이 들 수 있습니다.
  • 이는 값을 변경한 것이 아니고 기존에는 String Constant Pool에 있는 "Hello"를 참조하고 있다가 "World"로 참조 방향이 변경된 것 입니다. 즉, stringEx의 값이 변경된 것이 아닙니다.
  • String 포스팅이 아니기 때문에 해당 링크를 참고해주시면 이해가 쉬울 거 같습니다.

 

 

 

📖 불변 객체를 만드는 방법

1. final 키워드를 이용하자

  • Java에서는 불변성을 확보하기 위해서 final이라는 키워드를 제공합니다.
  • Java의 기본 필드는 가변적이지만 final 키워드를 앞에 붙여주면 객체 생성시 값 초기화는 가능하지만, 생성된 이후에는 변경이 불가능합니다.

 

# final 키워드를 이용한 필드 불변성 확보 시 문제점

  • 하지만 final 키워드를 사용하더라도 해당 객체 내부의 상태를 변경시키지 못하는 것은 아닙니다.
  • 아래의 예시는 List에 final 키워드를 붙여서 불변성을 확보했습니다. 그리고 List에 값을 추가해 보는 예시입니다.
public class Ex {
    public static void main(String[] args) {
        WorkSpace workSpace = new WorkSpace("Test WorkSpace");
        workSpace.getWorkSpace().add(new Worker("윤씨", 27)); // 불변 필드의 내부 상태를 변경
        workSpace.getWorkSpace().add(new Worker("정씨", 29)); // 불변 필드의 내부 상태를 변경
        
        workSpace.getWorkers().forEach(worker -> {
            System.out.println(worker);
        });
        // [출력]
        // Worker[김씨, 26]
        // Worker[박씨, 30]
        // Worker[윤씨, 27]
        // Worker[정씨, 29]
    }
}

class WorkSpace {
    // 불변성을 확보하기 위해서 필드를 final로 선언
    private final List<Worker> workers;
    private final String workSpaceName;

    public WorkSpace(String workSpaceName) {
        this.workers = new ArrayList<>(List.of(new Worker("김씨", 26), new Worker("박씨", 30)));
        this.workSpaceName = workSpaceName;
    }

    // final 필드를 읽기만 하는 Read-Only 메서드 선언
    public List<Worker> getWorkers() {
        return this.workers;
    }
    
    public String getWorkSpaceName() {
        return this.workSpaceName;
    }
}

class Worker {
    private String name;
    private Integer age;

    public Worker(String name, Integer age) {
        this.name = name;
        this.age = age;
    }
    
    @Override
    public String toString() {
        return "Worker[" + name + ", " + age + "]";
    }
}
  • 위 코드를 보면 알 수 있듯이 WorkSpace Class 내부의 workSpace 필드를 불변성 확보를 위해서 final 키워드로 선언했습니다.
  • 그리고 해당 필드를 읽을 수 있는 Getter 메서드를 선언해줬습니다.
  • 하지만 외부(Ex Class)에서 final로 불변성을 확보한 필드 내부에 새로운 객체가 계속 추가돼도 문제가 없는 상태가 되었습니다.
  • Java에서는 위와 같이 참조에 의해서 값이 변경될 수 있는 점을 주의해야 합니다. 그래서 완벽한 불변 객체를 만들기 위해서는 "불변 클래스"로 만들어야 합니다.

 

 

2. 불변 클래스로 만들자

  • 불변 클래스가 되기 위해서는 Class를 final 키워드를 사용해서 선언합니다.
  • Class의 모든 필드를 private final로 선언합니다.
  • 객체를 생성할 수 있는 생성자 또는 정적 팩토리 메서드(of)를 선언합니다.
  • 위에서 봤던 List와 같이 참조에 의해서 변경될 가능성이 있는 필드는 방어적 복사를 이용하여 제공합니다.

 

# WorkSpace를 완벽한 불변 클래스로 만드는 리펙토링

// final 키워드로 class를 선언
final class WorkSpace {
    // 불변성을 확보하기 위해서 필드를 private final로 선언
    private final List<Worker> workers;
    private final String workSpaceName;

    // 생성자를 내부적으로 호출할 수 있도록 private로 선언
    private WorkSpace(String workSpaceName) {
        this.workers = new ArrayList<>(List.of(new Worker("김씨", 26), new Worker("박씨", 30)));
        this.workSpaceName = workSpaceName;
    }

    // 정적 팩토리 메서드를 선언
    public static WorkSpace of(String workSpaceName) {
        return new WorkSpace(workSpaceName);
    }

    // 방어적 복사를 이용하여 workers를 제공
    public List<Worker> getWorkers() {
        return Collections.unmodifiableList(this.workers);
    }

    public String getWorkSpaceName() {
        return this.workSpaceName;
    }
}
  • 첫 번째로 final 키워드로 class를 선언하여 불변 클래스로 만들었습니다.
  • 두 번째로 모든 필드는 private final로 선언해 줬습니다.
  • 세 번째로 내부 생성자를 private로 선언하여 정적 팩토리 메서드를 통해서만 객체를 생성할 수 있도록 했습니다.

    • 이렇게 설정한 이유는 아무런 생성자 없이 정적 팩토리 메서드만 생성한다면 기본적으로 비어있는 내부 생성자를 컴파일러에서 자동으로 만들어줍니다. 그렇기 때문에 클래스 외부에서 어디서든 객체를 만들고 접근할 수 있기 때문입니다.
  • 마지막으로 workers List가 필요한 경우가 있을 수 있기 때문에 불변성을 확보하기 위해서 방어적 복사를 사용하여 List를 제공했습니다.

 

 

 

📖 Java의 Record

  • JDK14에서 preview로 소개하고, JDK16 버전부터 정식으로 사용할 수 있게 되었습니다.
  • 불변 데이터 객체를 쉽게 생성할 수 있도록 도와줍니다.

출처: https://www.baeldung.com/java-15-new

 

1. Record의 특징

  • Record는 따로 적어주지 않아도 암묵적으로 final 클래스이기 때문에 상속이 불가능합니다.
  • 다른 클래스를 상속(extends) 받을 수는 없지만, 인터페이스를 구현(implements)은 할 수 있습니다.
  • 필드에 따로 private final 키워드를 붙여주지 않아도 됩니다.
  • 모든 필드를 파라미터로 갖는 생성자를 기본적으로 제공합니다.
  • 객체 생성 시 재정의 해줘야 하는 toString(), hashCode(), equals()를 기본적으로 제공합니다.

 

 

2. Class  vs Record (비교)

비교를 위해서 먼저 Record를 사용하지 않고, 불변 객체를 만드는 코드를 작성해 봅니다.

 

# 기존의 Class를 이용하여 불변 객체 Student를 생성

public final class Student {
    private final String studentId;
    private final String name;
    private final Integer age;
    
    public Student(String studentId, String name, Integer age) {
        this.studentId = studentId;
        this.name = name;
        this.age = age;
    }

    public String getStudentId() {
        return this.studentId;
    }

    public String getName() {
        return this.name;
    }

    public Integer getAge() {
        return this.age;
    }

    @Override
    public String toString() {
        return String.format("Student[studentId=%s, name=%s, age=%d]", studentId, name, age);
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;

        Student student = (Student) o;

        if (!Objects.equals(studentId, student.studentId)) return false;
        if (!Objects.equals(name, student.name)) return false;
        return Objects.equals(age, student.age);
    }

    @Override
    public int hashCode() {
        return Objects.hash(studentId, name, age);
    }
}

 

# Record를 이용하여 불변객체 Student 생성

public record Student(String studentId, String name, Integer age) {
   // 끝
}
  • 이렇게만 봐도 엄청난 기능을 가지고 있다고 볼 수 있습니다. 값을 가져올 수 있는 getter()도 모두 기본적으로 제공됩니다. 메서드의 이름은 만약 studentId를 가져오고 싶다면 그냥 studentId()라는 메서드로 사용하면 getter와 동일한 기능을 합니다.
  • @Override 해서 재정의해줬던 toString(), hashCode(), equals()도 모두 기본적으로 제공하기 때문에 따로 정의해주지 않아도 됩니다.
  • 모든 필드를 파라미터로 갖는 생성자도 기본적으로 제공해 주기 때문에 따로 정의해주지 않아도 됩니다.

 

 

3. Record의 추가적인 기능

추가적인 기능을 기존의 class와 비교하면서 설명드리겠습니다.

 

 

(1) 생성자로 들어오는 파라미터 유효성 검사하기

20살이 넘는 age 값이 들어왔을 때는 객체가 생성되지 않고, 예외를 던지도록 코드를 설정해 봅니다.

// Class 사용

public final class Student {
    private final String studentId;
    private final String name;
    private final Integer age;

    public Student(String studentId, String name, Integer age) {
        if (age >= 20) {
            throw new IllegalArgumentException("20살 이상은 학생이 아닙니다!");
        }
        this.studentId = studentId;
        this.name = name;
        this.age = age;
    }
}

 

// Record 사용

public record Student(String studentId, String name, Integer age) {

    public Student {
        if (age >= 20) {
            throw new IllegalArgumentException("20살 이상은 학생이 아닙니다!");
        }
    }
}

위 방법을 이용하면 Null에 대한 처리도 충분히 할 수 있을 거 같습니다.

 

 

(2) Person interface 구현하기

// Person Interface

public interface Person {
    void walk();
    void eat();
}
// Class 사용

public final class Student implements Person {
    /* 코드 생략 */
    
    @Override
    public void walk() {
        System.out.println(this.name + "가 걷습니다.");
    }

    @Override
    public void eat() {
        System.out.println(this.name + "가 밥을 먹습니다.");
    }
}
// Record 사용

public record Student(String studentId, String name, Integer age) implements Person {
    /* 코드 생략 */
    
    @Override
    public void walk() {
        System.out.println(this.name + "가 걷습니다.");
    }

    @Override
    public void eat() {
        System.out.println(this.name + "가 밥을 먹습니다.");
    }

}

Record는 Interface는 기존의 Class처럼 구현할 수 있습니다. 하지만 Class를 상속(extends) 받는 것은 불가능합니다.

 

Record에서 extends를 하려고 할 때

 

 

 

 

 

사실 Lombok 라이브러리나 IDE에서 toString(), hashCode(), equals()를 자동으로 Generate해주는 기능도 보면서 정말 편하게 사용하기 위해서 여러 가지 기능을 만들고 있구나라는 생각을 했는데, Record를 보고 나서 발전은 정말 끝이 없다는 것을 느꼈습니다. 나중에는 어떤 기능들이 점점 더 추가될지 기대되면서도 무섭다는 생각을 했습니다.

 

 

 

[Reference]

https://www.baeldung.com/java-15-new

https://scshim.tistory.com/372

https://www.baeldung.com/java-native

https://mangkyu.tistory.com/131

IntelliJ 사용 중 아래와 같이 계속 주석에 밑줄이 생겼다.

 

 

문법적인 오류로 발생한건지... 영어랑 한글이랑 저렇게 붙여서 쓰면 계속 밑줄이 생겨서 거슬린다. 제거해 버리자.

 

 

1. Setting으로 들어가기

맥북은 Command + , 을 누르면 들어가진다. 윈도우는 찾아봐야 할 거 같지만, 단축키 없이도 들어갈 수 있다.

 

 

 

 

2. "Inspection" 검색

 

 

 

3. "Proofreading" 검색

 

이렇게 Proofreading을 검색하고 들어가서 "Typo"를 누르면 오른쪽에 체크박스가 나옵니다.

그중에서 Process comments의 체크박스를 풀어주면 됩니다.

 

 

 

4. 사라졌는지 확인

 

밑줄이 사라진 것을 볼 수 있다. 계속 거슬렸는데 이렇게 설정해서 없애니깐 속이 시원해졌습니다. 😤

 

근데 인텔리제이의 설정은 프로젝트마다 설정이 존재하는 것으로 보입니다.

다른 프로젝트로 변경하여 설정에 들어가서 동일하게 Inspection -> Proofreading -> Typo를 들어가 보면 아래와 같이 나옵니다.

 

다른 프로젝트로 들어가서 다시 확인해봤을 때의 모습

 

결론은 번거로워서 자주 해주는 설정은 아니지만, 오랫동안 봐야 하는 프로젝트를 할 때마다 설정 방법을 까먹어서 이렇게 기록해 두고 쓰려고 합니다.

 

밑줄 아무렇지 않아 보이지만 코드 작성할 때마다 계속 신경 쓰일 겁니다 😈

🤔 문제 발생

Request 요청을 위한 DTO로 사용하기 위한 클래스를 생성한 후 해당 객체를 ObjectMapper의 writeValueAsString() 메서드에 넣어 serialize 해서 json 데이터로 mvc에 content에 담아 POST 요청을 하는 테스트를 진행했습니다.

 

# PlaceRequest.java 코드

@AllArgsConstructor
public class PlaceRequest {
    private PlaceType placeType;
    private String placeName;
    private String address;
    private String phoneNumber;
    private Integer capacity;
    private String memo;

    public static PlaceRequest of(
            PlaceType placeType,
            String placeName,
            String address,
            String phoneNumber,
            Integer capacity,
            String memo
    ) {
        return new PlaceRequest(placeType, placeName, address, phoneNumber, capacity, memo);
    }
}

위 코드의 static 메서드인 of()로 만든 객체를 ObjectMapper.writeValueAsString()에 넣어 serialize를 진행했고, 아래와 같은 오류가 발생했습니다.

com.fasterxml.jackson.databind.exc.InvalidDefinitionException: No serializer found for class

 

 

 

🙆🏻‍♂️ 시도 / 해결

1. PlaceRequest 클래스에 빈 생성자가 존재하지 않으면 Serializer가 작동하지 않는다.

@NoArgsConstructor
@AllArgsConstructor
public class PlaceRequest {
    /* 코드생략 */
}

@NoArgsConstructor 애노테이션을 붙여 비어있는 생성자를 만들어줬습니다. 그리고 다시 실행했을 때의 결과는 동일한 예외가 발생되었습니다.

 

JackSon에서 Deserialize(역직렬화, json -> Object)를 할 때는 기본 생성자로 객체를 생성한 후 필드 값을 찾아서 바인딩해준다고 합니다. 하지만 우리가 하려는 과정은 Deserialize가 아닌 Serialize였기 때문에 사실상 지금은 @NoArgsConstructor가 아무런 효과가 없었습니다.

 

 

2. serialize를 하는 과정에서 PlaceRequest 클래스의 필드에 접근하게 되는데, 이때 public 접근제한자 또는 getter를 통해서 접근하게 된다.

여기서 2가지 방법을 시도해볼 수 있었습니다.

 

(1) 필드를 모두 public으로 바꿔보자

@AllArgsConstructor
public class PlaceRequest {
    public PlaceType placeType;
    public String placeName;
    public String address;
    public String phoneNumber;
    public Integer capacity;
    public String memo;
    
    /* 코드 생략 */
}

테스트를 돌려보니 통과하는 것을 볼 수 있습니다.

 

 

(2) 필드는 그대로 private로 두고, Getter를 만들어보자

@Getter
@AllArgsConstructor
public class PlaceRequest {
    private PlaceType placeType;
    private String placeName;
    private String address;
    private String phoneNumber;
    private Integer capacity;
    private String memo;
}

이 방법도 동일하게 테스트가 통과되는 것을 볼 수 있습니다. 저는 private 필드를 사용하기 위해서 두 번째 방법을 사용해서 코드를 작성했습니다.

 

 

 

💡 알게된 점

JackSon을 사용해서 Serialize(Object -> json)할 때는 필드를 public으로 지정하거나 private으로 해두었다면 JackSon이 해당 필드에 접근할 수 있도록 Getter를 만들어주어야 합니다.

 

반대로 Deserialize(json -> Object)를 할 때는 비어있는 기본 생성자(NoArgsConstructor)로 객체를 생성하기 때문에 반드시 기본 생성자를 클래스 안에 만들어줘야 합니다.

 

사실 해결 방법에는 @JsonProperty를 필드에 붙여주는 방법도 찾았던 거 같은데, 이 부분은 나중에 따로 시간을 내서 알아보고자 합니다. 

+ Recent posts