캐시(cache)는 무엇일까?

캐시에 대한 정의를 먼저 알아보고 가야 할 것 같습니다. 캐시란 데이터나 값을 미리 복사해서 보관하는 임시 장소를 말합니다.

그럼 캐시를 왜 사용하는 걸까요?

 

 

 

캐시(cache)를 사용하는 이유?

같은 정보를 반복해서 읽어오는 경우가 있던가, 데이터에 접근하는데 시간이 오래 걸리는 데이터인 경우에 사용을 고려해볼 수 있습니다.

말 그대로 반복해서 가져오는 데이터를 복사해서 캐시에 저장해 두고, 호출될 때마다 캐시에 보관된 데이터를 빠르게 가져올 수 있습니다.

데이터에 접근하는 시간이 긴 경우도 해당 데이터를 미리 캐시에 복사해서 넣어두고, 호출하면 캐시에 보관된 데이터를 가져와서 사용합니다.

 

이렇게 해주면 I/O가 감소하고, 프로젝트의 성능을 올려줄 수 있습니다. 하지만 캐시를 사용할 때 고려해줘야 하는 점은 다음과 같습니다.

캐시가 있을 때, 없었을 때와 프로젝트의 기대 동작이 동일해야 합니다.

 

 

이제 코드 예시로 한번 살펴봅시다. 이번에 생각해본 시나리오는 온라인 서점에서 책(Book)의 정보를 조회할 때, 같은 책을 여러 번 조회하는 상황을 만들어볼 것입니다.

 

기대하는 결과는 최초에 요청된 Book만 Repository에 접근해서 데이터를 가져오고, 그 뒤에 들어온 요청의 경우에는 캐시에서 데이터를 가져와서 사용하는 것입니다.

 

 

 

필요한 기본 코드 작성

# Book.java

@NoArgsConstructor
@AllArgsConstructor(staticName = "of") // .of()로 Book 객체를 만들 수 있도록 해줍니다.
@Data
public class Book {
    private String title;
    private String author;
    private String publishedDate; // 0000-00-00 형태의 문자열로 입력받음
}

BookRepository에 제목(title), 저자(author), 출간일(publishedDate)를 받아서 저장합니다.

 

 

# BookRepository.java

@NoArgsConstructor
@Repository
public class BookRepository {
    private Map<String, Book> bookShelf = new HashMap<>();

    public void enrollBookInfo(String title, String author, String publishedDate) {
        bookShelf.put(title, Book.of(title, author, publishedDate));
    }

    public Book getBook(String title) {
        System.out.println(title + "을 찾기 위해서 데이터베이스에 접근....");
        if (bookShelf.keySet().contains(title)) {
            return bookShelf.get(title);
        }
        throw new RuntimeException("찾는 도서는 존재하지 않습니다.");
    }
}

enrollBookInfo()는 도서를 등록할 수 있는 메서드입니다. getBook()에서 찾고자하는 도서가 없다면 RuntimeException을 발생시키도록 설계해 봤습니다.

 

캐시는 Repository로 따로 들어오지 않기 때문에 만약 Service를 통해서 Repository에 접근했다면 "title을 찾기 위해서 데이터베이스에 접근...."이라는 메시지를 출력하도록 해놨습니다.

 

 

# BookService.java

@RequiredArgsConstructor
@Service
public class BookService {

    private final BookRepository bookRepository;

    @PostConstruct
    public void init() {
        // 도서 4개를 등록합니다.
        bookRepository.enrollBookInfo("자바는 정석이다", "김정석", "2022-12-12");
        bookRepository.enrollBookInfo("파이썬은 정석이다", "이정석", "2012-06-13");
        bookRepository.enrollBookInfo("자바스크립트는 정석이다", "박정석", "2020-02-14");
        bookRepository.enrollBookInfo("C는 정석이다", "정정석", "2015-01-11");
    }

    public void printBookInfo(String title) {
        try {
            Book book = bookRepository.getBook(title);
            System.out.println("도서명: " + book.getTitle() + " 저자: " + book.getAuthor() + " 출간일: " + book.getPublishedDate());
        } catch (RuntimeException exception) {
            System.out.println(exception.getMessage());
        }
    }
}

@PostConstruct 애노테이션을 이용하여 BookService의 생성자가 실행된 후에 바로 4개의 도서를 등록합니다. 이렇게 등록하면 BookRepository에 해당 책들이 등록됩니다.

 

그리고 조회에 사용할 때는 printBookInfo()를 이용하여 출력할 수 있도록 했습니다. 이때 Repository에서 발생시킨 RuntimeException에 대한 예외를 처리해 줍니다.

 

 

# MainApplication.java

@RequiredArgsConstructor
@SpringBootApplication
public class MainApplication {
    private final BookService bookService;

    public static void main(String[] args) {
         SpringApplication.run(MainApplication.class, args);
    }

    @EventListener(ApplicationReadyEvent.class) // <-- 모든 빈이 다 등록이 끝났을 때의 이벤트를 말함
    public void run() {
        bookService.printBookInfo("자바는 정석이다");
        bookService.printBookInfo("자바는 정석이다");
        bookService.printBookInfo("자바는 정석이다");
        bookService.printBookInfo("자바는 정석이다");
    }
}

BookService를 DI 받아서 "자바는 정석이다"라는 도서를 BookRepository에서 꺼내서 조회합니다. 해당 작업을 4번 연속으로 조회하는 작업입니다. 출력되는 결과를 한번 확인해 보겠습니다.

 

 

출력된 결과를 보면 "자바는 정석이다을 찾기 위해서 데이터베이스에 접근...."이라는 메시지가 4번 떴습니다. 그 말은 즉, "자바는 정석이다"라는 책을 4번 조회하기 위해서 BookRepository에 4번 접근했다는 것을 의미합니다.

 

그럼 이제 캐시를 사용해서 최초 접근에만 BookRepository에 접근하도록 설정해 봅시다. 만약 성공적으로 캐시가 작동했다면 기대하는 결과는 아래와 같습니다.

자바는 정석이다을 찾기 위해서 데이터베이스에 접근....
도서명: 자바는 정석이다 저자: 김정석 출간일: 2022-12-12
도서명: 자바는 정석이다 저자: 김정석 출간일: 2022-12-12
도서명: 자바는 정석이다 저자: 김정석 출간일: 2022-12-12
도서명: 자바는 정석이다 저자: 김정석 출간일: 2022-12-12

 

 

 

Redis 설치하기

Redis Homepage에 들어가면 각 운영체제마다 설치할 수 있는 방법이 있습니다. 이 부분에 대해서는 다루지 않겠습니다.

저는 Mac M1을 사용하고 있어서 homebrew로 간단하게 설치했습니다.

 

 

 

Redis 실행시키기 (터미널)

# Redis 서버 실행하기

>> redis-server --loglevel verbose

 

 

이렇게 입력해 주면 로컬에서 Redis Server를 여는데 성공했습니다. --loglevel verbose는 debug와 비슷하게 로그 내역을 상세하게 출력해 줍니다. 예를 들면 서버 접속 이력, 캐시 등록 이력 이런 내용이 모두 출력됩니다. 원한다면 특정 로그 레벨만 출력되도록 해줄 수 있습니다.

 

 

# Redis Client로 접속하기

열어둔 서버에 Client로 접속해 보겠습니다.

>> redis-cli

 

 

클라이언트로 Redis 서버에 접속하니 Redis 서버 쪽에서 이력이 남는 것을 볼 수 있습니다.

 

 

# Redis에 등록된 캐시(Key) 데이터 확인하기

>> keys *

 

 

등록된 모든 Key를 출력해 볼 수 있는 명령어입니다. 결과는 (empty array)라고 나와야 정상입니다. 저희는 아직 캐시를 사용하기 위한 설정을 하지 않았습니다. 이제 다시 프로젝트로 넘어가서 Redis를 사용하기 위한 설정을 해볼 것입니다.

 

 

 

Redis 의존성 추가하기

# build.gradle

implementation 'org.springframework.boot:spring-boot-starter-data-redis'

dependencies에 해당 Redis 의존성을 추가해 줍니다. 아 참고로 저는 지금 스프링부트 2.5.2 버전에 자바는 11 버전을 사용하고 있습니다. 혹시나 스프링부트 버전이 많이 낮으신 분들은 Redis를 지원하는 버전인지 확인해 볼 필요가 있습니다. 대부분 2.5.2 이상은 쓰고 있을 것으로 판단됩니다.

 

 

 

어떤 부분에 캐시를 적용할지 찾고, 적용하기

우리가 캐시를 적용해야 하는 부분은 Repository에 접근하여 Book 정보를 가져오는 행위를 하는 곳에 캐시를 적용해야 합니다. 찾아보면 BookRepository의 getBook() 메서드에서 해당 역할을 담당하고 있습니다. 

 

그럼 BookRepository의 getBook() 메서드에 캐시를 적용해 보겠습니다.

 

# getBook() in BookRepository.java

@Cacheable("book")
public Book getBook(String title) {
    System.out.println(title + "을 찾기 위해서 데이터베이스에 접근....");
    if (bookShelf.keySet().contains(title)) {
        return bookShelf.get(title);
    }
    throw new RuntimeException("찾는 도서는 존재하지 않습니다.");
}

@Cacheable() 애노테이션으로 캐시를 적용시켰습니다. 캐시에서 사용할 key 이름은 "book"으로 설정했습니다.

 

 

# MainAppalication.java에 @EnableCaching 적용하기

@EnableCaching
@RequiredArgsConstructor
@SpringBootApplication
public class MainApplication {
    private final BookService bookService;

    public static void main(String[] args) {
         SpringApplication.run(MainApplication.class, args);
    }

    @EventListener(ApplicationReadyEvent.class) // <-- 모든 빈이 다 등록이 끝났을 때의 이벤트를 말함
    public void run() {
        bookService.printBookInfo("자바는 정석이다");
        bookService.printBookInfo("자바는 정석이다");
        bookService.printBookInfo("자바는 정석이다");
        bookService.printBookInfo("자바는 정석이다");
    }
}

캐시를 사용하기 위해서 붙여줘야 하는 애노테이션입니다. Configuration 빈에 붙여도 되지만, 지금은 따로 캐시에 관련된 Config 설정을 한 것이 없기 때문에 MainApplication에 붙여줬습니다. 이제 다시 프로그램을 돌려보겠습니다.

 

 

실행이 되긴 하는데, Serialize 할 수 없다고 뜹니다. 이건 Redis에 캐시 데이터를 저장할 때, 우리가 만든 Book 클래스를 Redis에서 기본설정되어 있는 Serialize 방법으로 직렬화하여 저장하게 됩니다. 이 말은 즉 캐시 데이터로 저장될 Book 클래스는 Serialize가 가능한 클래스가 되어야 한다는 것을 의미합니다. 코드를 수정해 보겠습니다.

 

# Book.java

@NoArgsConstructor
@AllArgsConstructor(staticName = "of")
@Data
public class Book implements Serializable {
    private String title;
    private String author;
    private String publishedDate;
}

Serializable 인터페이스를 implements 받아서 serialize가 가능한 클래스로 만들어줬습니다. 이제 다시 MainApplication을 재시작해봅시다.

 

 

우리가 원하는 결과가 나왔습니다! 가장 처음에만 데이터베이스에 접근을 해서 데이터를 가져왔고, 그 뒤로는 데이터베이스가 아닌 캐시에 있는 데이터를 가져와서 출력했습니다.

 

이제 Redis Client로 가서 잘 등록되었는지 확인해 봅시다.

 

 

key가 잘 등록됐습니다. book이 key이고, (::)로 구분한 후 뒤에 나오는 부분이 "자바는 정석이다"라는 문자열을 인코딩하여 넣어둔 문자열 형태인 것 같습니다. 처음부터 영어로 잘 설정했으면 괜찮았을 거 같은데, 이 부분은 뒤쪽 리펙토링에서 한번 추가적으로 다뤄봅시다.

 

Redis를 사용해서 프로젝트에 캐시를 적용해 보는 작업은 끝났습니다. 하지만 몇 가지 더 코드를 리펙토링 해봅시다.

 

 

 

추가적인 리펙토링 작업

첫 번째로 캐시 데이터가 얼마나 저장되게 할 것인가를 설정해 봅시다. 이 부분은 성능과도 연관될 수 있는 부분이기 때문에 중요한 기능입니다. 캐시도 너무 많이 저장되고, 관리되지 않으면 서버에 부하가 걸립니다. 

 

두 번째로 직렬화 방법을 바꿔볼 것입니다. 지금은 Redis의 Default 설정을 사용하고 있지만 우리에게 더 익숙한 JackSon 라이브러리를 사용하여 Serialize 방법을 바꿔봅시다. 그럼 더 보기 좋게 출력될 것입니다.

 

 

캐시 데이터 보관 기간 설정하기

Redis에 대한 설정을 커스텀하기 위해서는 @Configuration 애노테이션을 갖는 설정 클래스를 하나 생성해야 합니다. 

 

# CustomRedisConfig.java

@EnableCaching
@Configuration
public class CustomRedisConfig {

    @Bean
    public RedisCacheConfiguration redisCacheConfiguration() {
        return RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofSeconds(5)); // <-- 5초 뒤에 캐시 데이터가 삭제됩니다.
    }
}

MainApplication에 등록했었던 @EnableCaching 애노테이션을 가져옵니다. 이제 @Configuration이 존재하기 때문에 여기에 붙여주면 됩니다.

 

Redis 커스텀 설정을 위해서 RedisCacheConfiguration을 Bean으로 등록합니다. 우리가 설정하려는 TTL(Time To Live, 데이터 유효기간)을 5초로 설정한 후 return 하여 해당 인스턴스가 스프링 빈으로 등록될 수 있도록 합니다.

 

이제 다시 MainApplication을 실행시킨 다음에 5초 후에 "자바는 정석이다"가 사라지는지 확인해 봅시다. 그전에 해야 할 것이 있습니다. 아까 테스트할 때 등록되었던 캐시 데이터를 먼저 비워줘야 합니다.

>> flushall

 

 

해당 명령어를 사용해서 모든 캐시 데이터를 삭제합니다. 그 후에 keys *로 삭제가 잘 되었는지 확인합니다. 그런 다음 MainApplciation을 한번 다시 돌려보겠습니다.

 

 

실행했을 때 처음에 keys *를 했을 때는 위처럼 데이터가 등록된 것을 볼 수 있고, 5초 후에 다시 keys *를 해보면 데이터가 사라진 것을 확인할 수 있습니다. 그래도 못 믿겠다면 Redis 서버에 찍힌 로그를 확인해 볼 수도 있습니다.

 

 

 

Serialize 방법 변경하기

이 부분도 Configuration에서 RedisCacheConfiguration의 설정을 변경해서 빈에 등록해 주면 됩니다. 앞서 사용했던 Bean에서 추가로 적용해 보겠습니다.

 

# CustomRedisConfig.java

import org.springframework.data.redis.serializer.RedisSerializationContext;

@EnableCaching
@Configuration
public class CustomRedisConfig {

    @Bean
    public RedisCacheConfiguration redisCacheConfiguration() {
        return RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofSeconds(5))
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
    }
}

이렇게 . 연산자가 많아지고, 가독성이 떨어질 때는 static import를 이용해서 아래와 같이 정리해 볼 수 있습니다.

import org.springframework.data.redis.serializer.RedisSerializationContext.SerializationPair;

@EnableCaching
@Configuration
public class CustomRedisConfig {

    @Bean
    public RedisCacheConfiguration redisCacheConfiguration() {
        return RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofSeconds(100))
                .serializeValuesWith(SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
    }
}

이제 Redis의 Value 직렬화 방법을 Jackson2로 변경했습니다. 다시 MainApplication을 돌려봅시다. 캐시 TTL을 설정해서 Redis에 캐시 데이터가 남아있지 않지만 혹시 남아있는 분들은 꼭 다 삭제하고 재시작하시길 바랍니다.

 

Serialize가 적용된 것을 확인하기 위해서 TTL을 100초로 변경하고 해 보겠습니다. 5초면 등록되자마자 거의 바로 삭제되기 때문에 확인이 어렵습니다.

 

 

음... 직렬화가 되지 않았습니다. 여기서 문제점 한 가지가 있었습니다. 저희는 Configuration에서 직렬화 방법을 변경해 줬습니다. 그렇기 때문에 이전에 Book 클래스에 붙여줬던 Serializable 인터페이스의 implements는 지워줘야 합니다.

 

 

# Book.java

@NoArgsConstructor
@AllArgsConstructor(staticName = "of")
@Data
public class Book {
    private String title;
    private String author;
    private String publishedDate;
}

추가적으로 JackSon을 사용할 때는 @NoArgsConstructor를 사용해서 비어있는 생성자를 꼭 만들어두어야 정상적으로 작동됩니다.

 

이제 다시 MainApplication을 재시작해봅시다.

 

 

한글은 인코딩 되어 저렇게 뜨고 있지만 title, author, publishedDate에 대한 객체가 Jackson을 이용하여 JSON 형태로 Serialize 된 것을 볼 수 있습니다.

 

 

 

번외: Redis에서 한글 인코딩 방식 변경해 보기

Redis에서 한글을 보기 위해서는 다음과 같은 명령어로 Client를 실행하면 된다.

>> redis-cli --raw

 

 

결과를 보면 "자바는 정석이다"라는 문자열이 잘 나오는 것을 볼 수 있다. JSON 타입으로 직렬화된 데이터도 잘 확인이 된다. Good ~

3월 17일 날 시작한 후 3주가 지났다. 사실 많으면 매주 회고글을 작성해보고 싶었는데, 아직 초반부라서 그렇게 적을 내용도 많진 않았다.

 

적응 기간도 조금 필요했고, 3주밖에 안 지났지만 나름 루틴에 적응을 해가고 있습니다. 해당 부분에 대해서 짧은 회고를 조금 남겨보려고 합니다.

 

 

🏃🏻 7개월을 꾸준히 참여하기 위해서 필요한 것은 '체력'

월요일부터 금요일까지 오후 1시부터 10시까지 매일 컴퓨터 앞에 앉아있다 보니 몸소 느낀 것이 있다.  '체력'을 키워야 개발자도 꾸준히할 수 있겠구나...

 

처음에는 별로 대수롭지 않게 생각을 했었는데, 요즘은 크게 느껴지고 있는 것 같다. 그래서 하루에 적어도 10분 정도씩은 나가서 뛰는 루틴을 추가해 봤다. 물론...이런 저런 핑계를 대며 안 갔었던 날도 많지만 확실히 작심삼일은 넘겼다. 이것도 나름 대견한 결과이다.

 

물론 10분이 짧을 수도 있지만, 아침에 이렇게 뛰고 오면 나름 오후 1시부터 시작하는 일과 때 정신이 조금 맑아지는 느낌이 난다. 확실히 안 뛰었을 때와는 다른 활력이 조금은 생긴 것 같다. 기회가 된다면 하프 마라톤! 해보고 싶다.

 

작심삼일은 넘긴 나의 도전 😀

 

 

 

🧐 하루에 한 번은 뇌를 돌리자 (알고리즘 문제 풀기)

뇌도 안 쓰다가 쓰려고 하면 저릿한 느낌이 든다. 쉬운 문제라도 조금씩 머리를 돌려보는 것이 중요하다고 느꼈고, Java 언어를 학습하면서 알고리즘 문제를 하루에 적어도 1문제를 해결해보자라는 목표를 정했다.

 

하지만 평소와는 다른 방법으로 알고리즘 문제에 접근해 보기로 했다. 사실 이 부분은 패스트캠퍼스 멘토님이 추천해 주신 방법이다. 

 

항상 알고리즘 문제를 풀 때 고민했었다. 스스로 문제를 푸는 것은 중요한데 이렇게 많은 시간을 쏟는 것이 효율적인 것인가라는 생각이다. 나는 지금껏 알고리즘 문제를 풀 때, 따로 시간을 지정해두지는 않았다. 그래서 어떨 때는 정말 4시간~5시간을 한 문제에 쏟았던 적도 많았다. 

 

근데 문득 이 방법은 그리 효율적인 방법이 아니라는 느낌이 들기 시작했고, 멘토님에게 질문을 드렸다. 멘토님의 의견을 받아들여 내가 정한 알고리즘 문제 풀이 방식은 다음과 같습니다.

1. 문제를 읽자마자 코드를 작성하지 않는다.

이전에는 문제를 읽고난 후 코드를 바로 작성했었다. 이렇게 했을 때의 문제점은 코드를 작성하는 과정 중간에서 막히면 코드를 싹 초기화하고 다시 작성하는 일이 빈번했다. 

그래서 결정한 방법은 처음에 문제를 정독한 후 20~30분 정도는 오직 알고리즘만을 생각하며 종이에 사용할 알고리즘의 방향성에 대해서 적어나간다.

만약 이 과정에서 1시간이 넘게 알고리즘 구상도 못하고 있다면, 그 문제는 모르는 문제로 생각하여 다른 사람의 알고리즘을 참고한다. 이때 코드 답을 바로 보지 않고, 알고리즘 방향만 먼저 참고한 후 해당 알고리즘대로 코드를 작성해봅니다.

알고리즘을 참고하고, 코드를 작성했는데도 계속 풀지 못한다면 다른 사람의 코드도 참고합니다. 이렇게 해결한 문제는 반드시 이해하고 넘어가야 합니다. 그리고 도움을 받은 문제는 따로 깃허브 레포지토리 Readme에 기록해 둡니다.
2. 도움받은 문제는 반드시 다시 풀어보기

일주일 이상이 지난 후 해당 문제를 다시 풀어봅니다. 이 과정은 내가 문제를 정확히 이해하고 넘어갔는지 확인하기 위한 작업입니다.
3. 새롭게 알게 된 알고리즘이나 개념이 있으면 블로그 기록하기

처음 사용해 본 알고리즘이나 기록해뒀으면 하는 과정이 존재하는 알고리즘 문제는 블로그에 따로 기록해 둡니다.

 

 

깃허브 레포지토리 README.md

 

 

 

🙋🏻‍♂️ 일일 계획의 성공률을 높여보자

멘토님을 만나기 전까지는 미친 듯이 달려 나가는 계획을 작성했었다. 물론 결과는 처참했고, 성공률도 저조했다.

성공률이 낮으니깐 뭔가 성취도도 조금 떨어지는 것 같고, 하루 만족도가 많이 떨어졌었다.

 

원인을 생각해 보면 "무리한 계획", "핑계"가 대부분을 차지했다.

 

아 오늘은 조금 힘드니깐 달리기 미뤄야겠다. 강의 듣느냐고 알고리즘 문제 못 풀었다. 내일은 꼭 풀어야지~ 이런 식의 핑계들이었다. 문제점은 파악했으니 해결방안을 생각해 봤다.

 

1. 계획은 최대한 구체적으로 작성

원래는 chapter 단위로 크게 크게 묶었었는데, 이번주에는 chapter안에 세부 chapter 단위로 나누어서 계획을 작성해 봤다.
이렇게 했을 때의 결과는 크게 크게 잡았던 계획보다는 확실히 성공률이 많이 늘었다. 그리고 추후에 복습에 대한 계획을 정할 때도 도움이 많이 됐다. 
2. 일과 시작 전에 알고리즘 문제 풀고 시작하기

2주 차까지는 온라인 강의, 실시간 강의 모든 일과를 다 마친 후에 알고리즘 문제를 풀 계획을 잡고 있었다. 근데 문제는 강의 계획이 지켜지지 않은 날은 알고리즘을 풀지 않고 넘어가는 날이 많았다. 사실 핑계다.

그래서 같은 그룹 스터디원의 의견을 수용하여 일과를 시작하기 전에 알고리즘 문제를 먼저 풀고 시작하는 계획으로 변경했다. 이렇게 했을 때 얻을 수 있었던 효과는 알고리즘 문제에 시간을 과도하게 사용하지 않는다는 점과 일과 시작 전에 뇌를 약간 워밍업 하는 느낌으로 시작할 수 있다는 점이 있었다.

 

이렇게 계획을 변경하고 난 후 3주 차 계획 성공률을 많이 높일 수 있었다. 이렇게 계속 적용해보고 점점 계획을 개선해볼 생각이다. 여기까지 3주차 회고를 마치며 다음 회고글은 언제일지 모르겠지만 그때는 지금보다 조금 더 성장해 있을 거라는 확신이 들었다. 🙂

application.properties로 사용하는 분들도 계시지만 방법은 동일하기 때문에 저는 application.yml 기준으로 혼자 복습하며 기록해보려고 합니다.

 

만약 A, B, C라는 클래스가 존재할 때, 이 3개의 클래스 내부에서 companyName이라는 값을 모두 같은 "Fast Company"라는 값을 사용한다고 가정을 해봅시다.

 

이때 만약 회사 이름이 변경되어서 "Slow Company"로 바뀌었다고 생각해 봅시다. 그럼 우리는 A, B, C 클래스로 모두 이동해서 "Fast Company"라는 값을 "Slow Company"로 모두 변경해줘야 합니다.

 

물론 클래스가 3개 밖에 없어서 간단하게 할 수 있지만, 만약 클래스가 1000개가 넘는다고 생각하면 어떨까요? 무려 1000번을 변경해줘야 합니다. 이때 이런 공통적인 값들을 properties이나 yml(yaml) 파일에서 설정 파일의 값만 바꿔주면 모든 클래스에 적용시켜 줄 수 있습니다.

 

이렇게 클래스에서 프로퍼티 값을 가져오는 방법에는 여러 가지가 존재하는데, 코드를 작성하며 한번 확인해 봅시다.

 

일단 먼저 application.yml에 우리가 사용할 설정 값을 입력해 둡니다.

# application.yml

company:
  name: "Fast Company"

 

 

1. @Value("${}")

제일 먼저 등장하는 것은 @Value 어노테이션입니다. SpEL(Spring Expression Langauge)로 내가 설정한 property 이름을 표현해 주면 해당 값을 가져올 수 있는 방법입니다.

 

A class에서는 @Value를 이용해서 application.yml의 company.name을 가져와볼 것입니다.

// A.java

@Component
public class A {

    @Value("${company.name}")
    private String companyName;

    public A() {

    }

    public void printCompanyName() {
        System.out.println("A class = " + companyName);
    }
}

클래스 A를 @Component를 이용해서 빈으로 등록합니다. 그리고 @Value를 이용해서 우리가 원하는 설정 값을 가져와서 companyName에 넣을 것이고, main 함수가 있는 클래스에서 printCompanyName()을 이용해서 값이 잘 입력되었는지 확인해 볼 것입니다.

 

main에서는 생성자 주입을 통해서 A, B, C 클래스 빈에 대해서 DI를 받을 것이고, 모든 생성자 주입이 끝난 후에 실행시켜 줄 것이기 때문에 @PostConstruct를 사용할 것입니다. 아래 코드에 해당 과정이 작성되어 있습니다.

// MainApplication.java

@SpringBootApplication
public class MainApplication {

    private final A a;
    private final B b;
    private final C c;
    
    public MainApplication(A a, B b, C c) {
        this.a = a;
        this.b = b;
        this.c = c;
    }

    public static void main(String[] args) {
         SpringApplication.run(MainApplication.class, args);
    }

    @PostConstruct
    public void init() {
        a.printCompanyName();
        b.printCompanyName();
        c.printCompanyName();
    }

}

코드를 실행시켜 보면 콘솔에 찍히는 값은 다음과 같습니다. 나머지 로그들은 모두 제외했습니다. 간단하게 출력 값만 적어봤습니다. 

A class = Fast Company
B class = null
C class = null

결과를 보면 A class에서 Fast Company라는 값을 제대로 가져온 것을 확인할 수 있습니다. B와 C는 아직 아무 설정도 안 해줬기 때문에 null이 나오는 것입니다. 

 

A class를 조금 더 멋스럽게 변경해 보겠습니다. 필드에 주입하는 것이 아닌 우리는 생성자를 사용해서 필드값을 초기화하는 방법을 더 많이 사용하기 때문에 이렇게 변경해보겠습니다.

// A.java

@Component
public class A {

    private String companyName;

    public A(@Value("${company.name}") String companyName) {
        this.companyName = companyName;
    }

    public void printCompanyName() {
        System.out.println("A class = " + companyName);
    }
}

매우 간단하게 해냈습니다. 필드 값 위에 입력하는 것이 아닌 생성자의 매개변수에 @Value를 붙여서 넣어주면 됩니다. 조금 멋있어졌군요.

 

 

 

2. Enviroment Bean (ApplicationContext)

두 번째 방법은 Enviroment 빈을 사용해서 프로퍼티 값을 가져오는 방법입니다. Spring Bean으로 등록되어 있는 Enviroment Bean을 주입받아서 getProperty()를 이용하여 해당 프로퍼티 값을 가져올 수 있는 방법입니다. 

 

B class는 이 방법을 사용해서 프로퍼티 값을 한번 가져와보겠습니다.

// B.java

@Component
public class B {

    private String companyName;

    private final Environment environment; // 생성자 주입을 통해서 DI

    public B(Environment environment) {
        this.environment = environment;
        companyName = this.environment.getProperty("company.name");
    }

    public void printCompanyName() {
        System.out.println("B class = " + companyName);
    }
}

 

org.springframework.core.env에 있는 Enviroment를 생성자를 통해서 의존성 주입을 받아옵니다. 그런 다음 Enviroment의 getProperty() 메서드를 이용하여 원하는 프로퍼티 값을 가져와서 companyName에 초기화해 줬습니다.

 

다시 main 메서드를 돌려서 출력 값을 확인해 봅니다.

A class = Fast Company
B class = Fast Company
C class = null

B class에도 값이 잘 가져와진 것을 볼 수 있습니다. 위에서 작성한 Enviroment를 사용하는 코드는 직접 ApplicationContext를 가져와서 사용해 볼 수도 있습니다. 코드를 한번 리펙토링 해보겠습니다.

// B.java

@Component
public class B {

    private String companyName;

    private final ApplicationContext applicationContext;

    public B(ApplicationContext applicationContext) {
        this.applicationContext = applicationContext;
        companyName = this.applicationContext.getEnvironment().getProperty("company.name");
    }

    public void printCompanyName() {
        System.out.println("B class = " + companyName);
    }
}

main을 다시 재시작해보니 출력되는 값은 동일하게 성공했습니다. 하지만 뭔가 코드 가독성면에서는 별로 좋아 보이진 않는다는 생각이 드는 코드입니다.

 

 

 

3. Configuration Properties

다음 방법은 자바 클래스로 따로 분리해서 값을 가져올 수 있는 방법입니다. @Value를 사용했을 때와는 다른 점은 클래스로 값을 매핑해서 가져올 수 있기 때문에 Type Safe 합니다. 그리고 각 프로퍼티 값에 대한 Meta 데이터를 작성할 수 있습니다. 

 

사용하는 방법은 @ConfigurationProperties("") 애노테이션을 사용하여 작성할 수 있습니다. 해당 클래스를 Bean으로 등록해서 사용할 수 있게 하기 위해서는 @Configuration 애노테이션을 사용해야 합니다. 코드를 먼저 작성해 보겠습니다.

 

 

# @Configuration 애노테이션 사용

// CompanyProperties.java

@ConfigurationProperties("company")
@Configuration
public class CompanyProperties {

    private String name;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

@ConfigurationProperties("company")를 이용하여 나는 application.yml에서 company라는 프로퍼티 값을 가져와서 매핑한다는 것을 의미한다.

 

@Configuration을 붙여주는 이유는 CompanyProperties 클래스를 스프링 빈으로서 등록하기 위함이다. 그래야 다른 곳에서 DI 받아서 사용할 수 있기 때문이다.

 

private String name에 company.name을 가져와서 값을 초기화시키는 것이다. 이때 우리는 setter를 정의해 주면 된다. getter는 외부에서 해당 값을 사용하기 위해서는 필요하다.

 

이제 스프링 빈으로 등록된 CompanyProperties를 주입받아서 C class에서 사용해 볼 것이다.

// C.java

@Component
public class C {

    private CompanyProperties companyProperties;

    public C(CompanyProperties companyProperties) {
        this.companyProperties = companyProperties;
    }

    public void printCompanyName() {
        System.out.println("C class = " + companyProperties.getName());
    }
}

생성자 주입을 통해서 CompanyProperties를 주입받습니다. 그런 후 getName()을 통해서 해당 프로퍼티 값을 받아서 printCompanyName()에 넣어줍니다. 이제 main을 재시작해서 출력값을 확인해 보겠습니다.

A class = Fast Company
B class = Fast Company
C class = Fast Company

C class도 적절하게 값을 받아오는 것을 확인할 수 있습니다.

 

위에서 작성한 코드를 조금 리펙토링 해보겠습니다.

 

CompanyProperties에서 @Configuration을 떼어보겠습니다. 어 그럼 Bean으로 등록되지 않아서...주입을 받아오지 못하지 않나?라는 의문이 들어야 합니다.

 

그래서 MainApplication로 가서 @ConfigurationPropertiesScan 애노테이션을 붙여줍니다. @ConfigurationProperties가 붙은 클래스를 자동으로 Scan 하여 빈으로 등록해 줍니다.

 

 

# @Configuration을 빼고, @ConfigurationPropertiesScan을 사용

// CompanyProperties.java

@ConfigurationProperties("company")
public class CompanyProperties {

    private String name;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}
// MainApplication.java

@ConfigurationPropertiesScan
@SpringBootApplication
public class MainApplication {

    /* 코드 생략 */

}

이렇게 설정해 두고 다시 main을 재시작해보면 적절하게 잘 출력되는 것을 확인해 볼 수 있습니다.

 

하지만 여기서 조금 생각해 볼 것이 있습니다. CompanyProperties를 보면 setName()을 통해서 해당 값을 초기화합니다. 이렇게 되면 언제든 setter를 통해서 값을 변경할 수 있다는 것은 조금 위험한 내용인 것 같습니다. 필드를 final로 선언해서 생성자를 통해서 해당 값을 초기화할 수 있도록 코드를 리펙토링 해봅시다. 이때는 @ConstructorBinding 애노테이션을 사용합니다. 생성자를 통해 프로퍼티 값을 바인딩받을 수 있도록 해주는 애노테이션입니다.

 

 

# Setter를 빼고, @ConstructorBinding을 사용하여 생성자 바인딩

// CompanyProperties.java

@ConstructorBinding
@ConfigurationProperties("company")
public class CompanyProperties {

    private final String name;

    public CompanyProperties(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }
}

main을 다시 재시작해봅니다. 그럼 값이 적절하게 출력되는 것을 볼 수 있습니다. 리펙토링 성공이네요. 이렇게 불변의 값으로 설정하고 싶다면 @ConstructorBinding을 사용해서 해당 값을 다루는 것이 좋습니다.

 

 

 

이제 코드를 완성했으니 우리가 생각했던 시나리오대로 "Fast Company"를 "Slow Company"로 바꿔서 모든 출력값이 정상적으로 변경되는지 확인해 봅시다.

# application.yml

company:
  name: "Slow Company"

콘솔 출력값

마지막은 캡처로 깔끔하게 확인해 봤습니다. 모든 출력값이 정상적으로 Slow Company로 변경되었습니다. 좋습니다~ 🙄

 

이번 정리 글에서는 많은 애노테이션이 새롭게 나와서 하나씩 정리해 보는 시간을 가지는 것이 좋을 거 같습니다. 프로젝트에서 이 내용을 써보게 되는 날이 왔으면 좋겠습니다.

🤔 문제 상황

Global Exception 처리에 대한 코드를 작성하는 과정에서 @RestControllerAdvice가 작동되지 않는 오류가 발생했습니다.

프로젝트 구조는 아래와 같이 구성했었습니다.

 

Book을 등록하는 간단한 프로그램입니다. 예상했던 시나리오는 BookController의 "/register"로 POST 요청을 보냅니다. 이때 BookService 로직에서 POST 요청으로 들어온 Book의 name과 같은 name의 Book이 이미 Repositroy에 존재한다면 BookException을 던져주도록 작성했습니다.

 

그 후 BookController에서 BookException이 발생했을 때, Global 하게 예외를 처리하기 위해서 BookExceptionHandler를 추가해 줬습니다. 

 

즉, 제가 생각한 flow는 POST 요청 ->  BookController -> BookService에서 BookException이 발생 -> BookExceptionHandler에서 해당 예외를 처리였습니다.

 

# BookController

package com.example.selftest.controller;

import com.example.selftest.dto.book.CreateBookDto;
import com.example.selftest.dto.book.ResponseBookDto;
import com.example.selftest.service.BookService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RequiredArgsConstructor
@RestController
public class BookController {
    private final BookService bookService;

    @PostMapping("/register")
    public ResponseBookDto registerBook(@RequestBody CreateBookDto dto) {
        return ResponseBookDto.fromEntity(bookService.registerBook(dto));
    }
}

 

# BookService

package com.example.selftest.service;

import com.example.exception.BookException;
import com.example.selftest.dto.book.CreateBookDto;
import com.example.selftest.entity.Book;
import com.example.selftest.repository.BookRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import static com.example.exception.ErrorCode.DUPLICATE_BOOK_NAME;

@RequiredArgsConstructor
@Service
public class BookService {
    private final BookRepository bookRepository;

    @Transactional
    public Book registerBook(CreateBookDto dto) {
        validateRegisterBook(dto);
        return bookRepository.save(dto.toEntity());
    }

    private void validateRegisterBook(CreateBookDto dto) {
        bookRepository.findByName(dto.getName()).ifPresent((book) -> {
            throw new BookException(DUPLICATE_BOOK_NAME);
        });
    }
}

 

# BookException

package com.example.exception;

import lombok.Getter;

@Getter
public class BookException extends RuntimeException {
    private ErrorCode errorCode;

    public BookException(ErrorCode errorCode) {
        super(errorCode.getErrorMessage());
        this.errorCode = errorCode;
    }
}

 

# BookExceptionHandler

package com.example.exception;

import com.example.selftest.dto.book.BookErrorResponse;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import javax.servlet.http.HttpServletRequest;

@RestControllerAdvice
public class BookExceptionHandler {
    @ExceptionHandler(BookException.class)
    public BookErrorResponse handlerException(BookException e, HttpServletRequest request) {
        System.out.println("에러가 발생!!!");
        return BookErrorResponse.builder()
                .errorCode(e.getErrorCode())
                .errorMessage(e.getMessage())
                .build();
    }
}

 

# BookErrorResponse

package com.example.selftest.dto.book;

import com.example.exception.ErrorCode;
import lombok.*;

@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class BookErrorResponse {
    private ErrorCode errorCode;
    private String errorMessage;
}

 

🔎 문제 분석 및 시도

코드를 잘 작성한 것 같은데...뭐가 문제지...라는 생각으로 엄청난 구글링에 들어갔습니다. 주요 키워드는 "@RestControllerAdvice not working"였습니다.

 

1. 예외가 Service에서 던져져서 @RestControllerAdvice에서 못 찾는 것인가?

사실 이거도 바보같은 생각이었지만 2시간 내내 이리저리 돌아다녔기 때문에 지푸라기라도 잡는 심정으로 해봤다.

그럼 BookController에서 직접 예외를 던져봤다. POST요청할 때 보내는 값 중에서는 name(책이름), author(저자)가 있었고, 만약 request로 들어온 데이터의 name이 "자바의 정석"이라면 직접 controller에서 BookException을 던지도록 해봤다.

 

@PostMapping("/register")
public ResponseBookDto registerBook(@RequestBody CreateBookDto dto) {
	if (dto.getName().equals("자바의 정석")) {
        throw new BookException(INTERNAL_SERVER_ERROR);
    }
    return ResponseBookDto.fromEntity(bookService.registerBook(dto));
}

이렇게 하고 POST 요청을 아래와 같이 보내봤다.

POST http://localhost:8080/register
Content-Type: application/json

{
  "name": "자바의 정석",
  "author": "남궁성"
}

예상했던 결과는 에러코드인 INTERNAL_SERVER_ERROR과 errorMessage인 "서버에 오류가 발생했습니다."가 BookErrorResponse에 저장되어 반환되는 것이었습니다. 즉, 정상적으로 ExceptionHandler가 작동했다면 아래와 같은 결과가 나왔을 것입니다.

{
  "errorCode": INTERNAL_SERVER_ERROR,
  "errorMessage": "서버에 오류가 발생했습니다."
}

하지만 실제 결과는 다음과 같이 나왔습니다.

{
  "timestamp": "2023-04-03T07:58:38.217+00:00",
  "status": 500,
  "error": "Internal Server Error",
  "path": "/register"
}

음 ExceptionHandler가 작동하지 않았구나... 일단 위에서 작성했던 코드는 다시 원상복구 해놨습니다.

 

 

2. 내 프로젝트 빌드 버전에 문제가 있는 것일까?

저는 SpringBoot 2.5.2 버전을 사용했습니다. 몇몇 검색 결과에서는 @RestController가 4.3 버전 이후부터 지원이 된다는...그런 말들이 돌아다녔습니다. 물론 정확한 정보인지는 공식문서를 찾아보는 것이 정확하기 때문에 이곳저곳 찾아봤습니다. 제 구글링 능력 밖인지 아니면 영어 난독증에 걸려서인지 찾을 수 없었습니다.

 

@RestControllerAdvice가 아닌 @ExceptionHandler의 문제인지 궁금했습니다. 그래서 Controller 내부에서 메서드로 @ExceptionHandler로 처리를 해봤습니다.

@RequiredArgsConstructor
@RestController
public class BookController {
    private final BookService bookService;

    @PostMapping("/register")
    public ResponseBookDto registerBook(@RequestBody CreateBookDto dto) {
        return ResponseBookDto.fromEntity(bookService.registerBook(dto));
    }

    @ExceptionHandler(BookException.class)
    public BookErrorResponse handlerException(BookException e, HttpServletRequest request) {
        System.out.println("에러가 발생!!!");
        return BookErrorResponse.builder()
                .errorCode(e.getErrorCode())
                .errorMessage(e.getMessage())
                .build();
    }
}

이렇게 작성하고 {name = "자바의 정석", author = "남궁성"}을 넣어서 POST 요청을 해봤습니다. 첫 번째 요청은 역시 성공적으로 되었고, 같은 요청으로 두 번째 요청을 해봤습니다.

 

결과는 다음과 같이 성공적으로 나왔습니다.

{
  "errorCode": "DUPLICATE_BOOK_NAME",
  "errorMessage": "중복되는 이름의 도서가 존재합니다."
}

역시 @ExceptionHandler가 잘못된 게 아니고 내가 잘못된 거구나 컴퓨터는 역시 거짓말을 하지 않는다. 다른 방법을 찾아보기로 했습니다.

 

 

3. @RestControllerAdvice도 IoC 컨테이너에 등록되는 Bean이 아니었던가?

코드를 다시 원상 복구한 후 @RestControllerAdvice를 한번 쭉 살펴봤습니다. (차분하게)

@RestControllerAdvice 어노테이션

음 @RestControllerAdvice는 @ControllerAdvice와 @ResponseBody의 기능을 모두 할 수 있는 어노테이션이구나... 그러고 나서 @ControllerAdvice로 들어가 봤습니다.

@ControllerAdvice 어노테이션

@ControllerAdvice는 @Component 어노테이션을 가지고 있어서 Spring Bean으로 등록되어 IoC 컨테이너에서 관리될 수 있도록 되어 있었습니다.

 

그럼 @RestControllerAdvice를 사용해서 작성했던 BookExceptionHandler는 스프링 Bean으로 등록이 될 것이고, DI를 받아도 오류가 나지 않아야 한다는 말인데... 문득 궁금했습니다.

 

ApplicationContext를 가져와서 Bean 목록에 존재하는지 확인하는 방법도 있었지만, 간단하게 테스트해보고 싶었기 때문에 다음과 같은 생각을 한번 해봤습니다.

 

Spring Application이 실행이 되면 Component Scan을 하면서 @Component가 붙어있는 클래스들을 IoC 컨테이너에 등록합니다. 정상적으로 등록이 되었다면 BookController에서 생성자 DI로 BookExceptionHandler를 주입받은 다음에 한번 확인해 보자라는 생각을 했습니다.

 

@RequiredArgsConstructor
@RestController
public class BookController {
    private final BookService bookService;

    private final BookExceptionHandler exceptionHandler;
    
    /* ... 생략 ... */
}

이렇게 하고 한번 Application을 실행시켜 봤습니다.

***************************
APPLICATION FAILED TO START
***************************

Description:

Parameter 1 of constructor in com.example.selftest.controller.BookController required a bean of type 'com.example.exception.BookExceptionHandler' that could not be found.

BookController의 생성자 주입 과정에서 BookExceptionHandler를 찾을 수 없다는 문구를 날리면서 프로그램이 종료되었습니다. 이 말은 즉... BookExceptionHandler가 아예 Bean으로 조차 등록되지 않았다는 것을 의미합니다.

 

이때부터 거의 멘탈이 와사삭 내려앉았습니다. 아니 이게 뭐야...@RestControllerAdvice를 붙이면...Bean으로 등록이 되어야 하는데...

 

 

 

🙆🏻‍♂️ 문제해결

계속 붙잡고 싸움만 하다가 정말 어이없는 곳에서 문제를 해결했습니다. 사실 창피해서 기록하지 않으려고 하다가 컴포넌트 스캔에 대해서 다시 생각해 볼 수 있는 기회인 것 같아서 기록하기로 결심했습니다.

 

정확한 원인은 바로 프로젝트의 패키지 구조에 있었습니다.

눈치채셨을까요. 제 프로젝트의 시작할 때 초기 구조는 com.example.selftest 였습니다. selftest 패키지 내부에 service, entity, repositroy, controller 등의 패키지가 존재합니다. 

 

빨리 코드를 치려고 하다 보니깐 example에 새로운 패키지인 exception 패키지를 만들게 되었고, Spring의 컴포넌트 스캔은 com.example.selftest 부터 스캔되기 때문에 BookExceptionHandler는 아예 스캔되지도 않았던 것입니다.

 

정말 바보 같은 실수를 하게 된 것입니다. 해결 방안은 모두가 알고 있듯이 exception 패키지를 selftest 내부에 넣어주면 됩니다.

첫 번째 POST 요청 응답 결과
두 번째 POST 요청 응답 결과 (BookExceptionHandler 정상 작동)

이제 정상적으로 제가 생각했던 결과가 나오는 것을 볼 수 있습니다. 

 

 

 

👀 문제를 해결하면서 느낀 점

일단 가장 컸던 것은 컴포넌트 스캔에 대해서 좀 알게 되었던 거 같습니다. 우리가 간단하게만 사용했던 SpringBoot에서 어떠한 설정도 해주지 않았는데, 자동으로 Component Scan을 하는 것인지 궁금해서 찾아봤습니다.

 

정답은 main 메서드가 존재하는 클래스에 붙은 @SpringBootApplication이라는 어노테이션 안에 있었습니다.

@SpringBootApplication

 

@ComponentScan이라는 어노테이션이 있어서 우리가 별도로 설정파일을 구성해주지 않아도 자동으로 Component Scan을 하고, Bean으로 등록해 줬던 것입니다.

 

단순한 궁금점으로 만약 문제 상황과 같이 com.example 아래 exception과 selftest 패키지가 있으니, 컴포넌트 스캔의 시작 지점을 기본 설정값인 com.example.selftest가 아닌 com.example로 바꿔도 문제가 해결되지 않을까라는 생각으로 한번 시도해 봤습니다.

 

컴포넌트 스캔의 시작 지점을 바꿀 때는 main 메서드가 있는 클래스에 @ComponentScan() 어노테이션을 붙여주고, basePackages 옵션을 넣어주면 됩니다.

@EnableJpaAuditing
@ComponentScan(basePackages = {"com.example"})
@SpringBootApplication
public class SelfPracticeApplication {
    public static void main(String[] args) {
        SpringApplication.run(SelfPracticeApplication.class, args);
    }

}

이렇게 설정한 뒤 한번 실행시켜 봤습니다.

같은 데이터로 POST 요청을 2번 했을 때의 응답 결과

 

생각했던 대로 정상적으로 작동되는 것을 확인할 수 있었습니다. 

 

Spring에서 컴포넌트 스캔의 디폴트 값을 설정해 둔 이유가 있다고 생각되기 때문에 위와 같이 basePackage를 바꾸는 방법은 이 문제의 근본적인 해결방법은 아니라고 생각합니다. 

 

결론은 눈을 똑바로 뜨고, 정확히 클릭해서 정확하게 잘 코딩하자입니다. 

궁금증이 들어서 코드를 쳐보다가 알게 된 점이 있어서 TreeSet에 대해서 기록해두고자 합니다.

 

기본적으로 Set은 중복을 허용하지 않는 자료구조입니다. 여기에 Tree가 붙으면서 이진 탐색 트리(binary search tree)의 형태로 Set이 구현된 자료구조가 됩니다. 즉, 데이터들이 정렬이 되어 저장된다는 것입니다.

 

정렬이 된다는 부분에서 Integer 타입이나 String 타입이 들어갔을 때는 아 정렬이 쉽게 되겠구나라고 생각을 했습니다. 근데 만약 Student 같은 커스텀 객체가 들어간다면 어떻게 될까라는 궁금점이 생겨서 코드를 작성해 봤습니다.

 

# Student 객체 생성

Student 객체는 학번(studentId)과 이름(name)만을 필드로 갖는 객체입니다.

public class Student {
    private String name;
    private int studentId;


   public Student(int studentId, String name) {
       this.studentId = studentId;
       this.name = name;
   }

    public int getStudentId() {
        return studentId;
    }

    public String getName() {
        return name;
    }
}

 

 

# TreeSet에 Student 객체 넣기

TreeSet을 만들어서 이 안에 Student 객체를 넣어볼 것입니다.

public class RunStudent {
    public static void main(String[] args) {
        TreeSet<Student> school = new TreeSet<>();
        school.add(new Student(1, "김씨"));
    }
}

1이라는 학번과 "김씨"라는 이름을 갖는 학생을 하나 넣어줬습니다. 컴파일 오류도 안 나고 한번 실행시켜 봅니다.

Exception in thread "main" java.lang.ClassCastException: class comparator_comparable.Student cannot be cast to class java.lang.Comparable (comparator_comparable.Student is in unnamed module of loader 'app'; java.lang.Comparable is in module java.base of loader 'bootstrap') at java.base/java.util.TreeMap.compare(TreeMap.java:1291) at java.base/java.util.TreeMap.put(TreeMap.java:536) at java.base/java.util.TreeSet.add(TreeSet.java:255) at comparator_comparable.RunStudent.main(RunStudent.java:11)

ClassCastException이 발생합니다. 읽어보면 Student는 Comparable를 cast 하지 않았다는 어쩌고 쓰여있습니다. 이는 TreeSet은 데이터를 넣었을 때, 정렬을 해주는 자료구조이기 때문에 해당 객체의 정렬 기준이 필요합니다.

 

하지만 Student 클래스에는 학번과 이름이라는 2개의 필드가 존재하는데 Java에서 난 뭘로 정렬을 해야 할지 모르겠다는 예외를 발생시킨 겁니다. 그럼 Comparable 인터페이스를 사용해서 Student의 정렬 기준을 설정해 보겠습니다.

 

 

# Student implements Comparable

public class Student implements Comparable<Student> {
    private String name;
    private int studentId;

    ... 생략 ...
    
    @Override
    public int compareTo(Student std) {
       return this.studentId - std.studentId;
    }
}

Comparable 인터페이스를 implements 받으면 반드시 compareTo() 메서드를 오버라이드해서 재정의해줘야 합니다. 여기서 Student 클래스의 정렬 기준을 정의할 수 있습니다. 

 

compareTo() 메서드는 양수(1), 음수(-1), 0을 리턴값으로 사용할 수 있습니다. 위에서 작성한 compareTo()는 파라미터로 들어온 다른 Student 객체와 나 자신(this)을 비교합니다. 즉, 정렬의 기준이 studentId라는 것을 의미합니다.

 

나의 학번(this.studentId)과 다른 사람의 학번(std.studentId)를 뺀 값을 리턴합니다. 이 결괏값은 같으면 0, 내가 크면 양수, 쟤가 크면 음수의 리턴 값을 가집니다. 내가(this) 비교의 앞에 있기 때문에 위 코드는 학번을 기준으로 오름차순 정렬이 진행됩니다. 

 

그럼 내림차순 정렬을 하고 싶을 때는 어떻게 해야 할까?

@Override
public int compareTo(Student std) {
   return std.studentId - this.studentId;
}

이렇게 두 개를 바꿔서 리턴을 해주면 됩니다. Comparable의 compareTo()를 잘 이용해 주면 정렬 조건이 2개여도 쉽게 정렬을 할 수 있게 됩니다.

 

 

# TreeSet 다시 실행해 보기

이제 오류 없이 잘 돌아가는 것을 확인할 수 있습니다. 학생을 4명 정도 넣어서 정렬이 잘 되어서 나오는지 코드로 확인해 보겠습니다.

public class RunStudent {
    public static void main(String[] args) {
        TreeSet<Student> school = new TreeSet<>();
        school.add(new Student(1, "김씨"));
        school.add(new Student(2, "박씨"));
        school.add(new Student(3, "정씨"));
        school.add(new Student(4, "오씨"));

        for (Student student : school) {
            System.out.println("학번: " + student.getStudentId() + " 이름: " + student.getName());
        }
    }
}
<출력 결과>
학번: 1 이름: 김씨
학번: 2 이름: 박씨
학번: 3 이름: 정씨
학번: 4 이름: 오씨

의도한 대로 오름차순으로 정렬돼서 결과가 출력된 것을 확인할 수 있었습니다. 근데 한 가지 의문점은 여기서부터 시작됩니다. 만약 학번이 1인 학생이 한 명 더 추가될 때는 어떻게 될까? (같은 학번의 학생)

 

 

# 같은 학번을 가진 학생을 추가해 보기

public class RunStudent {
    public static void main(String[] args) {
        TreeSet<Student> school = new TreeSet<>();
        school.add(new Student(1, "김씨"));
        school.add(new Student(2, "박씨"));
        school.add(new Student(3, "정씨"));
        school.add(new Student(4, "오씨"));

        school.add(new Student(1, "이씨"));

        for (Student student : school) {
            System.out.println("학번: " + student.getStudentId() + " 이름: " + student.getName());
        }
    }
}

출력 결과를 보면 "이씨"는 출력되지 않았습니다. 즉 추가가 되지 않았다는 것을 볼 수 있습니다. 궁금해서 TreeSet의 contains() 메서드를 사용해서 어떤 결과가 나오는지 확인해 봤습니다.

school.add(new Student(1, "김씨"));
school.add(new Student(2, "박씨"));
school.add(new Student(3, "정씨"));
school.add(new Student(4, "오씨"));

Student lee = new Student(1, "이씨");

if (school.contains(lee)) {
    System.out.println(lee.getName() + "은 이미 학교에 존재합니다.");
} else {
    System.out.println(lee.getName() + "은 학교에 존재하지 않습니다.");
}

출력 결과는 "이씨은 이미 학교에 존재합니다."가 나왔습니다. school.contains()에 Integer나 String이 아닌 Student 객체를 넣어준 것인데, 존재 여부를 어떻게 판단할 수 있는 걸까 라는 의문이 들었습니다.

 

그래서 school에 존재하는 모든 학생 객체를 순회하면서 equals()를 사용하여 lee와 같은 객체가 있는지 확인해 봤습니다.

TreeSet<Student> school = new TreeSet<>();
school.add(new Student(1, "김씨"));
school.add(new Student(2, "박씨"));
school.add(new Student(3, "정씨"));
school.add(new Student(4, "오씨"));

Student lee = new Student(1, "이씨");

for (Student student : school) {
    if (student.equals(lee)) {
        System.out.println("lee와 같은 객체입니다.");
    }
}

결과는 아무것도 출력되지 않았습니다. 즉, "김씨", "박씨", "정씨", "오씨" 모두 "이씨"와는 다른 객체라는 것입니다. 근데 contains()에서 포함이 되어있다고 한다...?

 

궁금해서 Student에 다른 필드 값인 학년(grade)을 넣어서 확인해 봤습니다.

 

 

# Student 클래스에 grade 필드값 추가

public class Student implements Comparable<Student> {
    private String name;
    private int studentId;
    private int grade;


   public Student(int studentId, String name, int grade) {
       this.studentId = studentId;
       this.name = name;
       this.grade = grade;
   }

    ... 생략 ...

    public int getGrade() {
       return grade;
    }

    @Override
    public int compareTo(Student std) {
       return this.studentId - std.studentId;
    }
}

새롭게 추가해 주고, 다시 contains()를 사용해 봤습니다.

TreeSet<Student> school = new TreeSet<>();
school.add(new Student(1, "김씨", 1));
school.add(new Student(2, "박씨", 2));
school.add(new Student(3, "정씨", 3));
school.add(new Student(4, "오씨", 4));

Student lee = new Student(1, "이씨", 1);

System.out.println(school.contains(lee));

여전히 결괏값은 true가 나옵니다. lee가 school에 포함되어 있다는... school을 순회하면 모두 출력했을 때는 "이씨"가 존재하지 않습니다. 여기서 이제 "이씨"의 학번을 겹치지 않게 5로 바꾸고, 학년은 "김씨"와 겹치는 1로 유지한채 school에 넣어봤습니다.

TreeSet<Student> school = new TreeSet<>();
school.add(new Student(1, "김씨", 1));
school.add(new Student(2, "박씨", 2));
school.add(new Student(3, "정씨", 3));
school.add(new Student(4, "오씨", 4));

Student lee = new Student(5, "이씨", 1);
school.add(lee);

for (Student student : school) {
    System.out.println("학번: " + student.getStudentId() + " 이름: " + student.getName());
}
학번: 1 이름: 김씨
학번: 2 이름: 박씨
학번: 3 이름: 정씨
학번: 4 이름: 오씨
학번: 5 이름: 이씨

어..? grade는 겹치게 넣었는데, 이번에는 들어가네라는 의문에서 거의 확신이 섰습니다. 이진트리는 중복되는 숫자가 존재하지 않습니다. 즉, 우리가 Student를 만들 때 정렬 조건을 studentId로 지정했고 이것이 Student의 key 값이 되어 적용되고 있었습니다. 그래서 학번이 같으면 TreeSet에 넣어지지 않고, 학년이 같았을 때는 넣어졌던 것입니다.

 

이것에 확신을 얻기 위해서 Student의 정렬 조건을 학번(studentId)에서 학년(grade)으로 바꿔서 진행해 봤습니다.

@Override
public int compareTo(Student std) {
   return this.getGrade() - std.getGrade();
}
학번: 1 이름: 김씨
학번: 2 이름: 박씨
학번: 3 이름: 정씨
학번: 4 이름: 오씨

예상했던 대로 "김씨"와 학년이 같았던 "이씨"는 추가되지 않았습니다. 이렇게 TreeSet에 대해서... 좀 더 확실히 알 수 있는 계기가 되었던 거 같습니다.

 

자주 사용했던 자료구조는 아니었는데, 궁금해서 TreeSet을 사용한 출석부를 만들어봤습니다. 근데 그 과정에서 위와 같은 의문점이 들었고 TreeSet에 대해서 확실히 알게 되었던 과정이었습니다.. 👀

Git을 사용하기 위해서는 먼저 Git을 설치해 줘야 합니다. Git 다운로드 링크에 들어가서 각자 운영체제에 맞는 Git을 설치해 줍니다. 저는 Mac을 사용하기 때문에 homebrew로 설치해 줬습니다.

 

그리고 Git을 터미널에서 사용하는 과정에서 CLI 명령어가 주로 사용될 것이기 때문에 미리 익혀두시는 것이 좋을 거 같습니다.

 

터미널에서 명령어로 Git을 사용하다 보면 화면에서 편하게 눈으로 보면서 클릭으로 간편하게 Git을 사용할 수 있는 GUI(Graphical User Interface) 툴인 Sourcetree나 Gitkraken 등을 사용하면 되지 않을까라는 생각을 하게 됩니다.

 

Git이 어떤 방식, 어떤 명령어로서 버전을 관리할 수 있는지 원리를 알아야 나중에 GUI 툴을 사용하게 되더라도 쉽게 입문할 수 있다고 생각됩니다. 그래서 먼저 터미널에서 직접 명령어로 Git을 사용하는 방법을 익히는 것이 도움이 될 수 있습니다. 

 

 

Git에서 버전을 관리할 때 사용되는 3개의 공간

Git 설치를 완료했다면 먼저 Git이 어떤 방식으로 버전을 관리할 수 있는지에 대해서 알아보고자 합니다. Git에는 3개의 공간이 존재합니다. 눈에 실제로 보이는 공간인가라고 물어보시면, 눈으로 직접 볼 수 있는 공간도 있고 보이지 않는 가상의 공간도 존재합니다.

 

1. 작업 디렉터리 (working directory)

뒤에서 git init이라는 명령어를 통해서 이 디렉터리에서 이제 Git으로 관리를 시작하겠습니다라고 알려주면서 .git이라는 파일이 생성될 것입니다. 이 .git이라는 파일이 존재하는 디렉터리를 작업 디렉터리라고 합니다.

 

작업 디렉터리에서 작업한 변경 내용(수정, 삭제, 생성 등)들은 Git에서 추적을 해서 버전으로 관리할 수 있도록 해줍니다.

 

2. 스테이지 (stage)

작업 디렉터리와는 다르게 눈에 보이지 않는 공간입니다. 다음 버전이 될 수 있는 후보들이 올라가 있는 공간입니다. 쉽게 말하면 작업 디렉터리에서 작업을 한 후 git add라는 명령어로 작업들을 스테이지로 올려줄 수 있습니다. 이렇게 스테이지에 올라와 있는 변경 사항들은 다음 버전이 될 수 있는 상태가 됩니다. 이 공간이 스테이지입니다.

 

3. 저장소 (repositroy)

스테이지에 올라온 변경 사항들을 이제 버전으로 만들기 위해 Commit이라는 걸 하게 됩니다. git commit이라는 명령어를 사용하면 스테이지에 올라와 있는 변경사항들을 하나의 버전으로서 저장소에 커밋할 수 있게 됩니다. 즉, 저장소는 버전이 만들어지고 관리되는 공간입니다.

 

 

Git 명령어 사용해 보기

# git 초기 설정하기

>> git config --global user.name "{Github Username}"
>> git config --global user.email {Github Email}

위에 있는 2개의 설정 명령어를 먼저 입력해 줍니다. 잘 설정되었는지 확인하기 위해서는 git config --list라고 쳐보면 확인이 가능합니다.

 

 

# git init

내가 지금 현재 위치해 있는 디렉터리를 작업 디렉터리로 만들기 위한 명령어입니다.

해당 명령어를 입력하면 .git이라는 숨김파일이 생성되고, 이는 곧 Git으로 관리를 시작하겠다는 것을 의미합니다.

노란색으로 뭔가 hint 이런 게 많이 뜨는데 저는 초기 브랜치 설정을 안 해줘서 이렇게 뜨는 것이기 때문에 문제는 없습니다. 안 보이는 분들도 있고, 보이는 분들도 있으실 텐데 거슬리면 hint를 읽어보고 git config 설정을 해주면 됩니다.

 

 

# git add

위에서도 말했듯이 작업 디렉터리에서 발생한 변경 사항을 스테이지로 올릴 수 있는 명령어입니다. 

예시를 위해서 test.txt라는 파일을 만들어서 진행했습니다. (touch test.txt 명령어를 쓰면 비어있는 파일을 만들 수 있음)

 

 

# git status

작업 디렉터리에 어떤 변화가 있었는지 확인할 수 있는 명령어 입니다. 위에서 test.txt를 생성해 주고 git status로 한번 확인해 줍니다.

test.txt가 Git에 의해 감지된 모습

이제 git add test.txt를 통해서 생성한 파일을 스테이지에 올린 후 다시 git status를 사용해서 상태를 확인해 봅니다.

초록색으로 바뀌고 스테이지에 올라간 것을 볼 수 있는 모습

색이 초록색으로 변경되고 스테이지에 잘 올라간 것을 볼 수 있습니다. 

 

 

# git commit

이제 스테이지에 올라와 있는 test.txt를 버전으로 만들어줘야 하는데, 이때 사용하는 명령어가 git commit입니다. 

그냥 git commit이라고만 치고 엔터를 누르면 커밋 메시지를 작성할 수 있는 vi 편집기 창이 열립니다. 커밋 메시지를 작성해 주고 vi 편집기 창을 닫아주면 됩니다.

git commit 후 열린 vi 편집기창

첫 줄에는 제목을 입력하고, 한 칸 띄우고 본문을 작성해 줍니다. 그리고 :wq 명령어를 이용하여 저장하고 나와주면 커밋이 완료됩니다.

 

 

# git commit -m "커밋 메시지 제목"

만약 나는 vi 편집기창으로 들어가서 적는 게 싫다. 제목만 써서 커밋을 하고 싶다고 하시면 이 명령어를 사용하면 바로 커밋을 할 수 있습니다.

git commit -m 명령어를 이용하여 커밋

이렇게 제목만 입력해서 바로 커밋이 되는 것을 확인할 수 있습니다.

 

 

# git log

커밋은 했어. 근데 어디서 확인해라는 질문에 답이 될 수 있는 명령어입니다. 지금까지 커밋한 목록들이 출력됩니다.

git log

저는 vi 편집기로 한번, git commit -m으로 한번 했기 때문에 2개의 커밋이 존재합니다. 여기서 노란색으로 막 적혀있는 부분은 해당 커밋을 지칭하는 고유의 문자열이고, 커밋 해쉬라고 부릅니다.

 

커밋 해쉬는 커밋끼리 작업을 비교하거나 HEAD를 옮기고, 병합할 때 사용됩니다. 여기서는 무엇을 의미하는지만 알고 넘어가시면 될 거 같습니다.

 

그럼 저 하늘색 HEAD랑 초록색 master는 무엇인지 궁금하실 겁니다. HEAD는 내가 지금 어디서 작업을 하고 있는지 알려줍니다. 일반적으로는 가장 최근 커밋에 HEAD가 위치합니다.

 

master는 내가 지금 있는 브랜치를 의미합니다. 정리해 보면 HEAD -> master는 나는 지금 master라는 브랜치에서 작업을 하고 있다는 것을 의미합니다.

 

 

# git log --oneline

위에서 본 커밋 목록을 간결하게 짧은 커밋 해쉬로 볼 수 있는 명령어입니다.

git log --oneline

지금은 커밋이 2개밖에 없어서 그렇지 나중에 커밋이 많아질 때 유용하게 사용되는 명령어입니다. 짧은 해쉬를 보면 위에서 봤던 긴 해쉬의 앞부분을 줄여놓은 것이라고 알 수 있습니다.

 

 

간단하게 사용되는 Git 명령어들을 터미널에서 사용해 봤습니다. 🙋🏻‍♂️

+ Recent posts