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
그래서 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"로 바꿔서 모든 출력값이 정상적으로 변경되는지 확인해 봅시다.
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();
}
}
코드를 잘 작성한 것 같은데...뭐가 문제지...라는 생각으로 엄청난 구글링에 들어갔습니다. 주요 키워드는 "@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));
}
예상했던 결과는 에러코드인 INTERNAL_SERVER_ERROR과 errorMessage인 "서버에 오류가 발생했습니다."가 BookErrorResponse에 저장되어 반환되는 것이었습니다. 즉, 정상적으로 ExceptionHandler가 작동했다면 아래와 같은 결과가 나왔을 것입니다.
{
"errorCode": INTERNAL_SERVER_ERROR,
"errorMessage": "서버에 오류가 발생했습니다."
}
음 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는 @ControllerAdvice와 @ResponseBody의 기능을 모두 할 수 있는 어노테이션이구나... 그러고 나서 @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 내부에 넣어주면 됩니다.
이제 정상적으로 제가 생각했던 결과가 나오는 것을 볼 수 있습니다.
👀 문제를 해결하면서 느낀 점
일단 가장 컸던 것은 컴포넌트 스캔에 대해서 좀 알게 되었던 거 같습니다. 우리가 간단하게만 사용했던 SpringBoot에서 어떠한 설정도 해주지 않았는데, 자동으로 Component Scan을 하는 것인지 궁금해서 찾아봤습니다.
정답은 main 메서드가 존재하는 클래스에 붙은 @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);
}
}
이렇게 설정한 뒤 한번 실행시켜 봤습니다.
생각했던 대로 정상적으로 작동되는 것을 확인할 수 있었습니다.
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이라는 명령어를 사용하면 스테이지에 올라와 있는 변경사항들을 하나의 버전으로서 저장소에 커밋할 수 있게 됩니다. 즉, 저장소는 버전이 만들어지고 관리되는 공간입니다.
개발에 관심이 있거나 개발을 해본 분들은 Github에 대해서 많이 들어봤을 것이다. 근데 Git은 무엇인가? 둘이 똑같은 거 아닌가?라는 생각을 할 수도 있다.
필자도 개발에 처음 입문했을 때 당시 정확한 의미를 모르고 사용했을 때, Github를 줄여서 편하게 Git이라고 부르는 줄 알고 있었다. 😂
둘은 다른 소프트웨어이고, 서로 맡은 역할이 다르다.
Git이란?
버전을 관리하기 위한 소프트웨어를 말한다. 여기서 버전이라는 것은 그럼 무엇일까?
버전은 변경사항이라는 단어로 대체해서 사용할 수도 있는데, 유의미한 결과가 결과물로서 나오는 것을 버전이라고 한다.
아직도 어려운 느낌이 있다. 그럼 유의미한 결과는 무엇일까?
수정하고, 삭제하고, 새롭게 생성하고, 버그를 수정하는 등의 행위들이 모두 유의미한 결과가 될 수 있다.
즉, 정리해 보면 Git이란유의미한 결과(변화)들이 최종적인 결과물로서 나오는 과정을 관리하기 위해서 사용되는 소프트웨어이다. 정리를 해봤지만 아직도 말이 조금 어려운 것 같다.
쉽게 말해서 내 컴퓨터에서 작업(수정, 삭제, 생성 등)을 했을 때, Git에서 작업 내용을 버전에 따라서 관리할 수 있도록 기능을 제공한다. 내가 원하는 변경 사항들만 스테이지에 올린 후 하나의 버전으로 만들 수 있고, 지금까지 작업했던 내용이 마음에 안 들면 원하는 버전까지 되돌아가는 등의 기능도 제공해 준다. 자세한 부분은 Git을 실습해 보면 크게 와닿을 수 있을 거라고 생각한다.
그럼 Github는 무엇인가?
Github는 원격 저장소 호스팅 서비스라고도 불린다. 말이 조금 어렵기 때문에 이렇게 바꾸어보자. Github는 인터넷상 어딘가에 있는 저장소에서 Git으로 관리한 프로젝트를 호스팅 해주는 서비스이다.
Git으로 관리한 프로젝트를 호스팅 해준다는 의미가 무엇일까? Git을 사용하면 내 컴퓨터(로컬)에서 편리하게 프로젝트를 관리할 수 있다. 하지만 이 프로젝트는 내 컴퓨터에 있기 때문에 나만 볼 수 있다. 근데 만약 다른 사람들에게 내가 Git으로 관리한 프로젝트를 공유하고 싶다면 어떻게 해야 할까라는 고민을 해보면 해답을 얻을 수 있다.
첫 번째 방법은 이 프로젝트 파일 자체를 다른 사람에게 USB나 외장하드를 통해서 전달해 주는 방법이다. 이것이 과연 효율적일까? 만약 내 프로젝트를 100명에게 줘야 한다면 USB를 100개를 사서 파일을 공유하던가 아니면 USB 1개를 사서 100번을 돌아다녀야 한다. 정말 비효율적인 방법이라고 할 수 있다.
두 번째 방법은 이 프로젝트를 인터넷상 어딘가에 올려서 다른 사람들도 볼 수 있도록 하는 것이다. 우리가 일상에서 사용하는 네이버 클라우드나 구글 클라우드를 생각해 보면 이해하기 쉬울 것이다.
Github는 두 번째 방법을 간편하게 이용할 수 있도록 호스팅 서비스를 제공해 준다. Github에 대해서 다시 정의해 보면 Git으로 관리한 내 컴퓨터에 있는 프로젝트를 인터넷상 어딘가에 저장할 수 있도록 자리를 빌려주는 서비스(호스팅)라고 할 수 있다.
그럼 그냥 공유 드라이브에 프로젝트 파일을 올리고 다운받아서 사용하면 되는 거 아닌가라는 의문이 들 수도 있다. Github에서는 단순하게 파일만 추가하고, 내려받는 것이 아니라 이 프로젝트를 진행하면서 생긴 변화 과정(버전)에 대한 정보(History)도 저장이 됩니다.
즉, 내가 원하는 버전으로 돌아갈 수도 있고 되돌릴 수도 있다는 것입니다. 그래서 개발자들이 협업을 할 때 Github를 많이 사용합니다. Git과 Github에 대해서 잘 학습해 두는 것이 나중에 팀원들과 협업할 때 유용하게 작용할 수 있을 거라고 생각합니다.
다음 게시물에서는 이 Git에서 기본적으로 사용하는 명령어와 Git을 터미널에서 어떻게 사용하는지에 대해서 기록해 볼 것이다. Git을 터미널에서 사용할 때는 CLI 명령어도 같이 사용되기 때문에 이와 관련된 내용을 같이 학습하고 넘어가는 것이 좋다.