# 개요

학원 다니면서 개인 프로젝트로 플래너 개발을 해보기로 했다.

처음에는 타이머로 시작했다가 내가 생각했던 방향이 아닌거 같아서 플래너로 바꿨다.

처음에 타이머로 생각한 이유는 단순히 뽀모도로 타이머(https://chromewebstore.google.com/detail/focus-to-do-%EB%BD%80%EB%AA%A8%EB%8F%84%EB%A1%9C-%ED%83%80%EC%9D%B4%EB%A8%B8-+-%EC%97%85%EB%AC%B4/ngceodoilcgpmkijopinlkmohnfifjfb?pli=1)를 개발하려고 했기 때문인데.. 코드 작성하다보니 타이머가 돌아가는 로직은 프론트에서 간단하게 처리 할 수 있다고 생각했기 때문이다.

원래는 프로젝트 개발진행하면서 블로그도 같이 쓰려고했는데, 개발 중간에서야 작성하게 됐다 ㅎㅎ;

 


# CRUD의 R(Read) 기능 추가

Read one / Read All에 대한 View 클래스 추가와 Controller에 메서드를 추가했다

Read One기능은 TimerController의 필드인 List의 Index 기반으로 했다. 나중에 데이터베이스 추가되면 Primary Key인 Id로 조회하거나 Front에 해당 객체 선택할 수 있도록 구현할 수 있겠지?

리팩토링을 위해 Create와 마찬가지로 Read기능에 대한 View와 Controller 테스트 코드를 작성했다. 

https://github.com/JinHoooooou/MiniTimer/commit/8b7c49d965d0a6cf870733550104a31537bb6650

 

TimerController의 필드인 List에 대한 캡슐화가 제대로 이루어진것 같지 않아서 isEmpty(), size()메서드를 따로 작성했다.

https://github.com/JinHoooooou/MiniTimer/commit/b104fe410aa618d69b0ed644c7780ebafd2f7aa2

 


# CRUD의 U(Update) 기능 추가

Update에 대한 View 클래스와 Controller에 메서드를 추가했다.

https://github.com/JinHoooooou/MiniTimer/commit/a03e217edf16138eedf27ccdded28b94a82d1293

 


# CRUD의 D(Delete) 기능 추가

Delete에 대한 View 클래스와 Controller에 메서드를 추가했다.

https://github.com/JinHoooooou/MiniTimer/commit/d37bd9a292c2eee7898cd19eac4b18228e77399d

https://github.com/JinHoooooou/MiniTimer/commit/e63dcb2ab82b5ef598241e522f4617fb258b5bd3

 


# 간단 회고

기능을 추가할 때 마다 테스트 코드를 작성했다. TDD로 구현한 것 까지는 아니지만, 테스트가 있어야 공격적인 리팩토링이 가능하기 때문에 작성했다.

근데 작성할 수록 View에 대한 테스트 코드를 작성하는것에 대해 회의감을 느꼈다. '이렇게 까지 View 테스트 코드를 작성해야 하나?" 라는 생각이 들었다. 그도 그럴 것이, View 부분은 비즈니스 로직 없이 단순히 콘솔에 보여주는 화면만 구성하는데 이것을 위한 테스트 코드까지 작성하는 것은 시간 낭비라는 생각이 계속 나를 지배했다. 그럼에도 불구하고 '테스트 코드 작성 연습하고 공부하자'라는 생각으로 작성했다.

Controller에서 getList()메서드로 List필드를 가져오고, 그 List에대한 메서드를 호출하는 것 보다. Controller에게 물어보는 메서드를 작성하는것이 더 올바른 캡슐화라고 생각해서 size(), isEmpty() 메서드를 작성했다. 근데 지금와서 보니 이 메서드들은 테스트 코드에서만 사용하는 메서드가 돼버려서 잘 못 작성한것 같다.

# 개요

학원 다니면서 개인 프로젝트로 플래너 개발을 해보기로 했다.

처음에는 타이머로 시작했다가 내가 생각했던 방향이 아닌거 같아서 플래너로 바꿨다.

처음에 타이머로 생각한 이유는 단순히 뽀모도로 타이머(https://chromewebstore.google.com/detail/focus-to-do-%EB%BD%80%EB%AA%A8%EB%8F%84%EB%A1%9C-%ED%83%80%EC%9D%B4%EB%A8%B8-+-%EC%97%85%EB%AC%B4/ngceodoilcgpmkijopinlkmohnfifjfb?pli=1)를 개발하려고 했기 때문인데.. 코드 작성하다보니 타이머가 돌아가는 로직은 프론트에서 간단하게 처리 할 수 있다고 생각했기 때문이다.

원래는 프로젝트 개발진행하면서 블로그도 같이 쓰려고했는데, 개발 중간에서야 작성하게 됐다 ㅎㅎ;

 


# 타이머 개발

기본적으로 학원 수업 진행 방향을 따라서 개발을 진행했고 거기에 추가적으로 내가 공부하고싶은 부분을 적용시켰다.

객체(Timer)에 대한 필드 설계를 하고 콘솔창에 View를 출력하고 사용자 입력 (Scanner)을 통해 실행되는 어플리케이션을 만드는 것으로 시작했다.

뽀모도로 타이머 어플을 보고 처음 설계한 것은 Timer 객체에 Hour, Minute, Second가 있고 Title과 Memo가 있어서 해당 요소들을 필드로 추가했다.

MainView에서 Timer 생성, 조회, 수정, 삭제 (CRUD)에 대한 View들을 사용자 입력으로 받고 그 기능들은 TimerController의 메서드로 구현했다.

처음에는 command 입력에 대한 기능들을 분기처리로 구현했다가 다형성을 이용하여 클래스를 분리하여 구현했다.

https://github.com/JinHoooooou/MiniTimer/commit/f3135fcb067d836ef2b534a3909c484dd39582e4

 

각 View에 대한 출력을 어플리케이션 실행이 아닌 단위 테스트 실행으로 확인할 수 있게 테스트 코드를 사용했다.

https://github.com/JinHoooooou/MiniTimer/commit/75d45a5b7524028afc3dc15f5a21e326c4b281ba 

 


# CRUD의 C(Create) 기능 추가

그리고 Create Timer에 대한 테스트 코드, View, Controller를 구현했다. 각 객체에 대한 저장은 아직 데이터베이스 연결을 하지 않았기 때문에 Controller에 있는 List에 저장하도록 구현했다.

https://github.com/JinHoooooou/MiniTimer/commit/eb696ee727a3b18035f14e2b03c6dbd7dfee3234

https://github.com/JinHoooooou/MiniTimer/commit/554c813326b17bd83210f36429371bdf8533ecca

 


# 간단 회고

지금 블로그에 작성하는건 커밋한지 약 1~2개월 후에 작성하는 것인데... 커밋 메시지 내용이 너무 개판이고 커밋 파일들도 중구난방이라 확인하는 것이 너무 힘들다..

아무리 혼자 하는 개발이라고 하지만 커밋 정리를 잘 하자.. 앞으로 한동안 커밋 개판인거 블로그에 옮기려니 벌써 어지럽다... ㅜㅜ

# 개요

리팩토링 9장, 조건문 간결화 읽고 간단 정리

각 카탈로그에서 설명하는 내용 중 중요하게 생각하는 부분 작성하고 내 생각도 덧붙임

코드 작성하다가 이 예시가 이거구나 싶을 때는 예시코드도 붙일 예정

 


# 조건문 간결화

객체 지향 프로그램은 절차 프로그램에 비해 조건에 따른 동작이 대체로 적은데, 이것은 조건에 따른 동작 대부분이 재정의를 통해 처리되기 때문이다.

재정의 방식이 좋은 이유는 호출 코드가 조건문 원리를 알 필요가 없어 조건문을 확장하기가 더 간편하기 때문이다.

 


Decompose Conditional (조건문 쪼개기)

복잡한 조건문이 있을 땐 각 부분을 메서드로 빼내자

조건을 검사하고 다양한 조건에 따라 다른 작업을 처리하는 코드를 작성하다 보면 금방 메서드가 길어진다. 메서드는 길어지기만 해도 알아보기 힘든데 조건문까지 많으면 더 심각하다.

  • 6장의 메서드 추출 카탈로그에서 한 얘기와 비슷하다. 결국 메서드는 짧고 이해하기 쉬워야 하는데 조건문이 길면 이해하기 어렵다. 바로 적절한 이름을 가진 메서드로 추출하자.

 


Consolidate Conditional Expression (중복 조건식 통합)

여러 조건 검사식의 결과가 같을 땐 하나의 조건문으로 합친 후 메서드로 빼내자

조건문을 합쳐야 하는 이유는 두 가지이다.

  1. 조건식을 합치면 여러 검사를 OR 연산자로 연결해서 하나의 검사 수행을 표현해서 무엇을 검사하는지 확실히 이해할 수 있다.
  2. 이러한 조건식 통합을 실시하면 메서드 추출을 할 기반이 마련된다.

조건식이 독립적이고 하나의 검사로 인식되지 말아야 할 땐 이 기법을 사용하면 안된다. 그 코드는 이미 개발자의 의도에 맞기 때문이다.

  • 나는 대체로 isValid같은 메서드를 만들 때 이런 고민을 많이한다. ||나 && 연산자를 이용하여 한 줄로 간단하게 표현하는게 좋을까? 아니면 각 하나의 조건이 valid/invalid를 명시하도록 하기 위해 조건절을 나누는게 좋을까?
  • 근데 대체로 조건절을 나눠서 표현하는 경우가 많다. (그게 맞는지는 정확히 모르겠다)

 


Consolidate Duplicate Conditional Fragments (조건식의 공통 실행 코드 빼내기)

조건문의 모든 절에 같은 코드가 있을 땐 같은 부분을 조건문 밖으로 빼내자

  • 조건문이 늘어나면 코드가 당연히 길어지는데 최대한 중복을 줄일 수 있는 부분은 줄이는 것이 중요하다고 생각한다.

 


Remove Control Flag (제어 플래그 제거)

논리 연산식의 제어 플래그 역할을 하는 변수가 있을 땐 그 변수를 break나 return문으로 바꾸자

제어 플래그는 유용함을 능가하는 단점이 있다. 제어 플래그로 인해 이탈점이 한 개가 되면 코드 안의 각종 특이한 플래그로 조건문이 복잡해진다.

  • 플래그가 하나면 그러려니 하겠는데 여러 개인 경우 정말 복잡해진다. 거기다 break/continue/return 등으로 해당 조건이 탈출 조건이라는 것을 명시할 수 있어서 가독성 면에서도 더 좋다고 생각한다.

 


Replace Nested Conditional with Guard Claueses (여러 겹의 조건문을 감시 절로 전환)

메서드에 조건문이 있어서 정상적인 실행 경로를 파악하기 힘들 땐 모든 특수한 경우에 감시 절을 사용하자.

조건문은 두 가지 형태를 띤다

  1. 어느 한 경로가 정상적인 동작의 일부인지 검사
  2. 조건식 판별의 한 결과만 정상적인 동작을 나타내고 나머지는 비정상적인 동작을 나타냄

조건문은 다양한 의도가 있는데 그 의도는 코드에 반영되어 드러나야 한다.

  1. 둘 다 정상 동작의 일부라면 if ~ else로 구성된 조건문을 사용하고
  2. 조건문이 특이한 조건이라며 그 조건을 검사하여 조건이 true일 경우 반환하도록 한다.

 


Replace Conditional with Polymorphism (조건문을 재정의로 전환)

객체 타입에 따라 다른 기능을 실행하는 조건문이 있을 땐 조건문의 각 절을 하위 클래스의 재정의 메서드 안으로 옮기고 원본 메서드는 abstract 타입으로 수정하자.

재정의의 본질은 타입에 따라 기능이 달라지는 여러 객체가 있을 때 일일이 조건문을 작성하지 않아도 다형적으로 호출되게 할 수 있다는 것이다.

  • 내 개인적인 생각인데 객체 지향 언어의 꽃이 이 부분이라고 생각한다. 이 기법을 잘 사용하면 코드 확장성도 유용해지고 하나의 클래스는 하나의 역할만 갖게 된다고 생각한다.

 


# 내 생각

사실상 조건문을 재정의로 전환하는것이 가장 중요하고 그만큼 어렵다고 생각한다. 이번에 프로젝트를 할 때 책을 보며 이 기법을 적용 해 보았는데 확실히 코드가 가독성도 좋아졌고 새 기능을 추가하기도 쉬워졌다는 생각이 들었다.

 

 

# 개요

리팩토링 8장, 데이터 체계화 읽고 간단 정리

각 카탈로그에서 설명하는 내용 중 중요하게 생각하는 부분 작성하고 내 생각도 덧붙임

코드 작성하다가 이 예시가 이거구나 싶을 때는 예시코드도 붙일 예정


# 데이터 체계화

객체지향 언어는 구형 언어의 간순 데이터 타입으론 불가능했던 것까지 할 수 있는 새로운 타입을 정의 할 수 있어서 좋다.


Self Encapsulate Field (필드 자체 캡슐화)

필드에 직접 접근할 때 그 필드로의 결합에 문제가 생길 때 getter/setter 메서드를 작성하여 그 메서드를 통해서 접근하게 하자

  • VO, DTO등의 클래스를 만들면 습관적으로 getter/setter 메서드를 만들긴 한다. (lombok을 쓰기도)
  • 근데 사실 습관적으로 getter/setter 메서드를 만드는 경향이 있다.
  • 캡슐화 목적으로 필드에 직접 접근할 수 없게 private로 선언하고 메서드를 통해서 접근하도록 하기 위해서이다.
  • 하지만 그게 캡슐화일까? 결국 직접 접근이 안 될 뿐이지 메서드로 접근할 수 있는데 그게 정보보호/은닉의 효과가 있다고 생각은 하지 않는다. 확실하게 아는 것이 아니기 때문에 더 공부해야 할 것 같다.

변수 간접 접근 방식은 하위 클래스가 메서드에 해당 정보를 가져오는 방식을 재정의 할 수 있어서 데이터 관리가 더 유연해진다.

변수 직접 접근 방식은 코드를 더욱 알아보기 쉽게 한다.

  • 클린코드의 관점으로 봤을 땐 더욱 알아보기 쉬운 직접 접근 방식이 더 좋은것 같다.

 


Replace Data Value with Object (데이터 값을 객체로 전환)

데이터 항목에 데이터나 기능을 더 추가해야 할 때는 데이터 항목을 객체로 만들자.

개발 초기 단계에는 단순 정보를 간단한 데이터 항목으로 표현하는데 개발이 진행되다 보면 그런 간단한 항목이 점점 복잡해진다.

한 두 항목은 객체 안에 메서드를 넣어도 되겠지만 금세 '중복 코드'나 '잘못된 소속'이라는 구린내가 풍기게 된다. 그렇다면 즉시 데이터 값을 객체로 전환하자.

  • 주로 한 클래스의 한 메서드 내에 코드를 계속 작성하다 보면 어느 부분을 메서드로 추출해야하는 때가 생긴다.
  • 그렇게 메서드 추출을 통해 메서드를 여러개 만들다 보면 어떤 변수는 모든 메서드에서 공통으로 사용되기 때문에 필드로 올려야 할 때가 생긴다.
  • 또 그렇게 계속 작성하다보면 한 클래스에 여러 필드가 생기게 되고 그 필드들을 하나의 객체로 전환하여 사용하는 것이 낫다는 얘기인 것 같다.

 


Change Value to Reference (값을 참조로 변환)

클래스에 같은 인스턴스가 많이 들어 있어서 이것들을 하나의 객체로 바꿔야 할 땐 그 객체를 참조 객체로 전환하자

참조 객체는 고객이나 계좌 같은 것이다. 각 객체는 현실에서의 한 객체에 대응하므로 둘이 같은지 검사할 때 객체 ID를 사용한다.

값 객체는 날짜나 돈 같은 것이다. 전적으로 데이터 값을 통해서만 정의된다. 두 객체가 같은지 판단할 땐 equals과 hashCode 메서드를 재정의 해야 한다.

  • 값 객체와 참조 객체가 이해는 되는데 막상 코드 짜다보면 크게 구분 없이 작성하게 되는 것 같다. 두 용어가 자주 헷갈리기도 하고..

 


Change Reference to Value (참조를 값으로 전환)

참조 객체가 작고 수정할 수 없고 관리하기 힘들 땐 그 참조 객체를 값 객체로 만들자

참조 객체를 사용한 작업이 복잡해지는 순간이 참조를 값으로 바꿔야 할 시점이다. 참조 객체는 어떤 식으로든 제어되어야 한다.

값 객체는 변경할 수 없어야 한다는 주요 특성이 있다. 하나에 대한 질의를 호출하면 항상 결과가 같아야 한다.

  • Money라는 클래스가 있고 value가 125라고 한다면 A가 가진 Money와 B가 가진 Money 객체는 같아야 한다. 반면 Person이라는 클래스의 name이 A도 아무무고 B도 아무무라고 같은 Person은 아니다.
  • 근데 이게 단적인 예로 보면 이해가 잘 되는데 막상 코드 작성할 땐 크게 생각 안하고 작성한다.. 구별하는게 큰 의미가 있는건지도 잘 모르겠다.

 


Replace Array with Object (배열을 객체로 전환)

배열을 구성하는 특정 원소가 별의 별 의미를 지닐 땐 그 배열을 각 원소마다 필드가 하나씩 든 객체로 전환하자.

배열은 비슷한 객체들의 컬렉션을 일정 순서로 담는 용도로만 사용해야 한다.

 


Duplicate Observed Data (관측 데이터 복제)

도메인 데이터는 GUI 컨트롤 안에서만 사용 가능한데, 도메인 메서드가 그 데이터에 접근해야 할 땐 그 데이터를 도메인 객체로 복사하고 양측의 데이터를 동기화 하는 관측 인터페이스 Observer를 작성하자.

비즈니스 로직과 UI가 분리되는 이유

  1. 비슷한 비즈니스 로직을 여러 인터페이스가 처리 해야 하는 경우라서
  2. 비즈시스 로직까지 처리하려면 UI가 너무 복잡해져서
  3. GUI와 분리된 도메인 객체가 더욱 유지보수 하기 쉬워서
  4. 두 부분을 서로 다른 개발자가 다루게 될 수 있어서

비즈니스 로직과 UI를 분리할 때 기능은 간단히 분리할 수 있지만 데이터는 분리하기 어려울 때가 많다. 도메인 모델에 있는 데이터와 같은 의미를 지닌 데이터를 GUI 컨트롤에 넣어야하기 때문이다.

MVC를 시작으로 사용자 인터페이스 프레임워크는 이러한 데이터를 제공하고 모든 데이터의 동기화를 유지하는 다층 시스템을 사용했다.

  • MVC 패턴의 예로 비유하자면 View에서 비즈니스 로직을 처리하면 안된다는 얘기인것 같다.
  • 내가 했던 프로젝트를 생각하면 데이터를 복사하진 않고 View에서 Controller의 메서드를 호출하게 하고 Controller에서 비즈니스 로직을 수행 후 결과 객체를 View로 반환했는데, 이 방법을 얘기하는게 맞는건지 잘 모르겠다..

 


Change Unidirectional Association to Bidirectional (클래스의 단방향 연결을 양방향으로 전환)

두 클래스가 서로의 기능을 사용해야 하는데 한 방향으로만 연결되어 있을 땐 역 포인터를 추가하고 두 클래스를 모두 업데이트 할 수 있게 접근 한정자를 수정하자

역방향 참조가 아닌 다른 경로를 찾아서 해결 할 수도 있다. 하지만 이게 불가능 할 때는 양방향 참조를 설정해야한다. 처음엔 뒤죽박죽되기 쉽지만 익숙해지면 별로 복잡하지 않다.

  • MVC를 예로 생각하면 View에서 Controller를 필드로 갖고 마찬가지로 Controller에서도 View를 필드로 갖는 형태인 것 같은데, 결합도가 강해져서 별로 좋은 선택은 아닌것 같다.
  • 그리고 처음엔 뒤죽박죽되기 쉽지만 익숙해지면 별로 복잡하지 않다는 얘기 자체가 뒤죽박죽 될 수 있다는 얘기인데 굳이 그런 위험을 감수해야할까?

 


Change Bidirectional Association to Unidirectional (클래스의 양방향 연결을 단방향으로 전환)

두 클래스가 양방향으로 연결되어 있는데 한 클래스가 다른 클래스의 기능을 더 이상 사용하지 않게 됐을 땐 불필요한 방향의 연결을 끊자

양방향 연결은 쓸모가 많지만 대가가 따른다. 양방향 연결을 유지하고 객체가 적절히 생성되고 제거되는지 확인하는 복잡함이 더해진다.

양방향 연결로 인해 두 클래스는 서로 종속된다. 한 클래스를 수정하면 다른 클래스도 변경된다. 종속성이 많으면 시스템의 결합력이 강해져서 사소한 수정에도 얘기치 못한 각종 문제가 발생한다.

  • 위에서 얘기했듯이 이 이유 때문에 굳이 위험을 감수해서 양방향 연결을 하는것은 좋지 않다고 생각한다. 다른 방법으로 해결하는 것이 더 낫다고 생각한다.

 


Replace Magic Number with Symbolic Constant (매직 넘버를 상수로 전환)

특수 의미로 지닌 리터럴이 있을 땐 의미를 살린 이름의 상수를 작성한 후 리터럴을 상수로 교체하자

상수를 사용하면 단점이나 부작용 없이 성능이 향상되며 가독성이 엄청나게 향상된다

  • 이 기법도 중요하면서도 간단하게 할 수 있는 리팩토링이라고 생각한다.

 


Encapsulate Collection (컬렉션 캡슐화)

메서드가 컬렉션을 반환할 땐 그 메서드가 읽기 전용 뷰를 반환하게 수정하고 추가 메서드와 삭제 메서드를 작성하자.

읽기 메서드는 컬렉션 객체 자체를 반환해서는 안된다. 컬렉션을 참조하는 부분이 반환 받은 컬렉션의 내용을 조작해도 그 컬렉션이 든 클래스는 무슨 일이 일어나는지 알 수 없기 때문이다. 

이로 인해 컬렉션을 참조하는 코드에게 데이터의 구조가 지나치게 노출된다. 값이 여러 개인 속성을 읽는 읽기 메서드는 컬렉션 조작이 불가능한 형식을 반환하고 불필요하게 자세한 컬렉션 구조 정보는 감춰야 한다.

  • 내가 했던 프로젝트를 예로 들면 getter가 List를 반환 할 때 그냥 List 필드 자체를 반환했는데 필드 List에 들어있는 데이터들을 새 List에 복사해서 반환하는것이 더 안전하다는 생각이 들었다.

 


Replace Type Code with Class(분류 부호를 클래스로 전환)

기능에 영향을 미치는 숫자형 분류 부호가 든 클래스가 있을 땐 그 숫자들을 새 클래스로 바꾸자

분류 부호 이름을 상징적인 것으로 정하면 코드가 상당히 이해하기 쉬워진다.

 


Replace Type Code with Subclass (분류 부호를 하위 클래스로 전환)

클래스 기능에 영향을 주는 변경 불가 분류 부호가 있을 땐 분류 부호를 하위 클래스로 만들자.

분류 부호가 클래스 기능에 영향을 미치는 현상은 조건문이 있을 때 주로 나타난다.

분류 부호의 값을 검사해서 그 값에 따라 다른 코드를 실행하는 경우이다. 이런 조건문은 재정의로 바꿔야한다.

재정의를 하기 위해서 상속 구조로 고쳐야 하는데 가장 간단한 방법이 분류 부호를 하위 클래스로 전환하는 것이다.

이 기법의 장점은 클래스 사용 부분에 있던 다형적인 기능 관련 데이터가 클래스 자체로 이동 된다는 것이다.

변형된 새 기능을 추가할 땐 하위 클래스만 하나 더 추가하면 된다. 재정의를 이용하지 않는다면 조건문을 전부 찾아서 일일이 수정 해야 한다.

 


Replace Type Code with State/Strategy (분류 부호를 상태/전략 패턴으로 전환)

분류 부호가 클래스의 기능에 영향을 주지만 하위 클래스로 전환할 수 없을 땐 그 분류 부호를 상태 객체로 만들자

하나의 알고리즘을 단순화 해야 할 때는 전략 패턴이 더 적절하고, 강태별 데이터를 이동하고 객체를 변화하는 상태로 생각할 때는 상태 패턴이 더 적절하다

 


# 내 생각

대부분은 잘 이해가 됐지만 어려웠던 부분이 몇 개 있었다. 

1. 값 ↔ 참조로 전환

  • 값과 참조 자체는 이해가 설명과 예시 코드를 보니 이해가 되긴 했는데,  막상 내가 코드를 작성할 때 두 개를 크게 구분 지어서 생각하지 않았던 것 같다.

2. 관측 데이터 복제

  • Ovserver 패턴을 잘 모르기도 했고, MVC 패턴을 알고는 있지만 데이터를 동기화 한다기 보다는 단순히 Controller에서 View로 값을 반환하는 형태로 이해하고 있어서 내용과 조금 다른 것 같다.

3. 분류 부호 → 클래스/하위클래스/상태or패턴 전환

  • 미니 프로젝트를 하며 나름대로 적용 해 보았지만 내가 완전히 이해하고 딱 딱 적용했다기 보다는 책을 보며 겨우 해낸 리팩토링이라 더 공부해야 할 것 같다. 근데 확실히 그렇게 리팩토링 해 놓으니 확장성이 좋아졌다는 느낌을 받았다.

개요

코드 스타일 통일을 위해 IntelliJ에 Linter와 Formatter를 적용해보았다

옛날에 파이썬 쓸 때, black, flake8같은 도구들이 매우 좋았는데 자바에는 없나 싶어서 찾아보니 있었다!

 


CheckStyle 설치

IntelliJ에서 설정 → Plugins → Marketplace에 CheckStyle 플러그인 설치

나는 이미 설치되어있어서 Installed에 있지만 Marketplace에서 설치해야한다.

 

 


Linter(CheckStyle), Formatter(Code Style) 적용

네이버 캠퍼스 핵데이 Java 코딩 컨벤션

1. Formatter (Code Style)

naver-intellij-formatter.xml 설치

설정 → Editor → Code Style → Java에서 설치한 naver-intellij-formatter.xml import

적용

 

2. Linter (CheckStyle)

naver-checkystyle-rules.xml, naver-checkstyle-suppressions.xml 설치

설정 → Tools → Checkstyle에서 설치한 naver-checkstyle-rules.xml import

next → 설치한 naver-checkstyle-suppressions.xml 등록

주의점!! CheckStyle 버전을 8.24로 해야한다고 한다.

 


Google Java Style

1. Formatter (Code Style)

intellij-java-google-style.xml 설치

설정 → Editor → Code Style → Java에서 설치한 intellij-java-google-style.xml import

  •  

적용

2. Linter (CheckStyle)

google_checks.xml 설치

설정 → Tools → Checkstyle에서 설치한 google_checks.xml import

별도의 Property 작성이나 버전 변경 없이 그대로 사용

참고사항!! Missing JavaDoc Warning은 표시하고 싶지 않을 때

설치한 google_checks.xml에서 이 부분에 위처럼 작성

 


Reference

https://velog.io/@geun/Intellij-Formatter-Checkstyle-세팅하기

 

[Intellij] Formatter, Checkstyle 세팅하기!

Formatter, Linter를 적용해서 클린 코드를 작성해봅시다..!

velog.io

https://naver.github.io/hackday-conventions-java/#class-noun

 

캠퍼스 핵데이 Java 코딩 컨벤션

중괄호({,}) 는 클래스, 메서드, 제어문의 블럭을 구분한다. 5.1. K&R 스타일로 중괄호 선언 클래스 선언, 메서드 선언, 조건/반복문 등의 코드 블럭을 감싸는 중괄호에 적용되는 규칙이다. 중괄호

naver.github.io

https://stackoverflow.com/questions/23868476/how-to-fully-disable-javadoc-checking-with-checkstyle-maven-plugin

 

How to fully disable javadoc checking with checkstyle maven plugin

i want to use the Maven Checkstyle plugin with a custom configuration that tells Checkstyle to not warn or error on missing Javadoc. Is there a way to do this?

stackoverflow.com

 

# 개요

JUnit에 Display()에 작성한 한글들이 깨진다.

해결법 간단하게 정리하고 다음에 또 찾아보지 않게 블로그에 포스팅


# 상황

테스트 실행하는데 한글이 깨져서 나온다..


# 해결

VM Custom Option에서 

-Dfile.encoding=UTF-8을 작성한다.

Help → Edit Custom VM Options

 

주의할 점

작성했는데도 안된다면 해당 테스트로 가서 한 글자만 지우고 실행해보면 됨;

깨진 테스트 가서 한 글자만 지우고 테스트 실행하면 잘 된다.. 뭐임;;

# 개요

IntelliJ에서 No Matching in any candidates test task 뜨면서 테스트가 안 되는 상황이 발생하면서

간단하게 정리하고 다음에 또 찾아보지 않게 기록함


# 상황

다른 로컬에서 작업하다가 노트북에서 이어서 작업해야해서 Github에 올려놓고 노트북에서 pull 이후에 테스트를 실행했는데 이런 에러가 뜨면서 테스트 실행이 안된다.


# 해결

정확히는 모르겠지만 Gradle 프로젝트라서 발생한 오류같다. 찾아보니 Settings → Build, Execution, Deployment → Build Tools → Gradle에서 Run tests using을 Gradle에서 IntelliJ로 바꿔주면 된다고 한다.

Gradle로 되어있는 부분을 IntelliJ로 변경

주의할점

변경했다고 바로 Alt + Shift + X 눌러서 최근 실행 다시 실행 하지 말고 Ctrl + F10으로 실행해야한다.

근데 한글은 왜깨져;;

 

# 개요


벨만 포드 알고리즘 정리

용어가 좀 혼용될 수 있다

  • Ex) 노드 == 정점, 간선 == 엣지

예시 그림을 다익스트라와 같은걸로 하다보니 좀 짧다 (더 좋은 예시 있으면 수정 할 예정)

 

# 벨만 포드 알고리즘


음수 사이클이 없는 그래프에서 한 정점에서 다른 모든 정점까지의 최단 경로 및 거리를 구하는 알고리즘

Dijkstra 알고리즘과의 차이

  • 음수 간선이 있는 경우에도 구할 수 있다.
  • Dijkstra 알고리즘 보다 느리다.

 

# 동작 과정


다음과 같은 순서로 간선이 그려졌다고 가정하자 (간선 순서에 따라 갱신 과정이 조금 다를 수 있다)

출발 노드를 0번 노드로 설정한다

  • cost[0] = 0

간선 순서에 따라 Relax 연산을 수행하면

  1. cost[3] = INF에서 cost[0] + 7 = 7 →  7로 갱신 된다.
    • cost[3] = 7
  2. cost[2] = INF에서 cost[0] + 7 = 7  → 7로 갱신 된다.
    • cost[2] = 7
  3. cost[0] = 0인데 cost[2] + 7 = 14이므로 갱신 되지 않는다.
  4. cost[1] = INF인데 cost[2] + 1 = 8 → 8로 갱신 된다.
    • cost[1] = 8
  5. cost[5] = INF인데 cost[2] + 2 = 9 → 9로 갱신 된다.
    • cost[5] = 9
  6. cost[5] = 9인데 cost[1] + 8 = 16이므로 갱신 되지 않는다.
  7. cost[6] = INF인데 cost[1] + 6 = 14 → 14로 갱신 된다.
    • cost[6] = 14
  8. cost[5] = 9 인데 cost[3] + 7 = 14이므로 갱신 되지 않는다.
  9. cost[6] = 14인데 cost[5] + 7 = 16이므로 갱신 되지 않는다.
  10. cost[2] = 7인데 cost[4] + 6 = INF이므로 갱신 되지 않는다.
  11. cost[7] = INF인데 cost[5] + 7 = 16 → 16으로 갱신 된다.
    • cost[7] = 16
  12. cost[7] = INF인데 cost[4] + 7 = INF이므로 갱신 되지 않는다.

1 Round만에 모두 갱신되어 완성이 되었지만 실제로는 총 N-1번 Round를 한다.

Round가 끝나고 음수 사이클 여부를 확인하기 위해 한번의 Round를 더 수행한다.

  • 그 때 다시 갱신이 된다면 음수 사이클이 존재한다는 뜻이다.

 

# 코드


의사코드

Bellman_Ford(G, w, S) {
	Init(G, S)
    for i = 1 to N-1
    	for each edge(u,v)
        	relax(u,v,w)
    for each edge(u,v)
    	if (cost[v] > cost[u] + w(u,v))
        	then negative-cycle
}

 

자바 코드

private static int[] bellmanFord() {
    int[] distance = new int[vertexCount + 1];
    Arrays.fill(distance, Integer.MAX_VALUE);
    distance[source] = 0;
    
    for (int i = 0; i < edgeCount; i++) {
    	for (Edge edge : edges) {
            if (distance[edge.from] == Integer.MAX_VALUE) {
            	continue;
            }
            if (distance[edge.to] > distance[edge.from] + edge.weight) {
            	distance[edge.to] = distance[edge.from] + edge.weight;
            }
        }
    }

    for (Edge edge : edges) {
    	if (distance[edge.from] == Integer.MAX_VALUE) {
    		continue;
        }
        if (distance[edge.to] > distance[edge.from] + edge.weight) {
        	return null;
        }
    }

    return distance;
}

 

# Reference


권오흠 교수님 알고리즘 유튜브 및 강의자료

 

Algorithm Visualization

 

Dynamic Programming - Bellman-Ford's Shortest Path

 

algorithm-visualizer.org

 

# 개요


다익스트라 알고리즘 정리

용어가 좀 혼용 될 수 있다.

  • Ex) 노드 == 정점, 엣지 == 간선

 

# Dijkstra (다익스트라) 알고리즘


  • 음수 가중치가 없는 그래프에서 한 정점에서 다른 모든 정점까지의 최단 경로 및 거리를 구하는 알고리즘
  • Bellman Ford 알고리즘과의 차이
    • 음수 가중치가 있다면 동작하지 않는다.
    • O(n^2)로 Bellman Ford 알고리즘 보다 빠르다. (우선 순위 큐를 사용한다면 mlogn, m은 간선 개수)

 

# 동작 과정


그림으로 설명

노드 0부터 다른 모든 노드 까지의 최단 경로 거리를 구하려고 한다.

초기화, 출발 노드가 0번 노드이므로 cost[0] = 0으로 초기화한다.

출발 노드인 0번 노드 방문

  • visit[0] = true

현재 노드에서 나가는 간선들에 대해 목적지 노드의 cost와 path를 갱신한다.

  • 현재 노드가 0번 노드이므로 0번 노드에서 나가는 간선들에 대해 목적지 노드의 cost와 path를 갱신한다.
    • cost[2] = 7, path[2] = 0
    • cost[3] = 7, path[3] = 0

방문하지 않은 노드들 중 cost가 가장 작은 노드를 방문한다

  • 현재 방문하지 않은 노드들 중 cost가 가장 작은 노드는 2,3번인데 index순서에 따라 2번 노드를 방문한다
    • visit[2] = true

현재 노드에서 나가는 간선들에 대해 목적지 노드의 cost와 path를 갱신한다.

  • 현재 노드가 2번이므로 2번 노드에서 나가는 간선에 대해 목적지 노드의 cost와 path를 갱신한다.
    • cost[0] = 0 (0 < cost[2] + 7), path[0] = -1 (갱신 X)
    • cost[1] = 8 (INF > cost[2] + 1), path[1] = 2 (갱신 O)
    • cost[5] = 9 (INF > cost[2] + 2), path[5] = 2 (갱신 O)

방문하지 않은 노드들 중 cost가 가장 작은 노드를 방문한다.

  • 현재 방문하지 않은 노드들 중 cost가 가장 작은 노드는 3번이므로 3번 노드를 방문한다.
    • visit[3] = true

현재 노드에서 나가는 간선들에 대해 목적지 노드의 cost와 path를 갱신한다.

  • 현재 노드가 3번이므로 3번 노드에서 나가는 간선에 대해 목적지 노드의 cost와 path를 갱신한다.
    • cost[5] = 9 (9 < cost[3] + 7), path[5]=2 (갱신 X)

방문하지 않은 노드들 중 cost가 가장 작은 노드를 방문한다.

  • 현재 방문하지 않은 노드들 중 cost가 가장 작은 노드는 1번이므로 1번 노드를 방문한다.
    • visit[1] = true

현재 노드에서 나가는 간선들에 대해 목적지 노드의 cost와 path를 갱신한다.

  • 현재 노드가 1번이므로 1번 노드에서 나가는 간선에 대해 목적지 노드의 cost와 path를 갱신한다.
    • cost[5] = 9 (9 < cost[1] + 8), path[5] = 2 (갱신 X)
    • cost[6] = 14 (INF > cost[1] + 6), path[6] = 1 (갱신 O)

방문하지 않은 노드들 중 cost가 가장 작은 노드를 방문한다.

  • 현재 방문하지 않은 노드들 중 cost가 가장 작은 노드는 5번이므로 5번 노드를 방문한다.
    • visit[5] = true

현재 노드에서 나가는 간선들에 대해 목적지 노드의 cost와 path를 갱신한다.

  • 현재 노드가 5번이므로 5번 노드에서 나가는 간선에 대해 목적지 노드의 cost와 path를 갱신한다.
    • cost[6] = 14 (14 < cost[5] + 7), path[6] = 1 (갱신 X)
    • cost[7] = 16 (INF < cost[5] + 7), path[7] = 5 (갱신 O)

방문하지 않은 노드들 중 cost가 가장 작은 노드를 방문한다.

  • 현재 방문하지 않은 노드들 중 cost가 가장 작은 노드는 6번이므로 6번 노드를 방문한다.
    • visit[6] = true

현재 노드에서 나가는 간선들에 대해 목적지 노드의 cost와 path를 갱신한다.

  • 현재 노드가 6번이므로 6번 노드에서 나가는 간선에 대해 목적지 노드의 cost와 path를 갱신한다.
    • 6번 노드에서 나가는 간선이 없기 때문에 그냥 종료

방문하지 않은 노드들 중 cost가 가장 작은 노드를 방문한다.

  • 현재 방문하지 않은 노드들 중 cost가 가장 작은 노드는 7번이므로 7번 노드를 방문한다.
    • visit[7] = true

현재 노드에서 나가는 간선들에 대해 목적지 노드의 cost와 path를 갱신한다.

  • 현재 노드가 7번이므로 7번 노드에서 나가는 간선에 대해 목적지 노드의 cost와 path를 갱신한다.
    • 7번 노드에서 나가는 간선이 없기 때문에 그냥 종료

방문하지 않은 노드들 중 cost가 가장 작은 노드를 방문한다.

  • 현재 방문하지 않은 노드는 4번 뿐인데, 출발 노드인 0번 노드에서 4번 노드로 가는 경로가 없기 때문에 그냥 종료한다.

종료, 출발 노드인 0번 노드로부터 각 노드의 최단 경로와 그 길이이다.

 

# 코드 (Java)


의사 코드

// 우선순위 큐 없이

Dijkstra(G, w, S) {
	Init(G, S)
    visit = {}
    cost[S] = 0
    for ( i = 0; i<N; i++) {
    	u = find_minimum_node_not_visit()
        visit[u] = true
        for each v of adjacent u {
        	if cost[v] > cost[u] + w(u,v)
            	then cost[v] = cost[u] + w(u,v)
                and  path[v] = u
        }
    }
}

// 우선순위 큐 사용
Dijkstra(G, w, S) {
	Init(G, S)
    visit = {}
    cost[S] = 0
    pq.add(S, cost[S])
    
    while(pq is not empty) {
    	u = extract_min(pq)
        visit[u] = true
        for each v of adjacent u {
        	if cost[v] > cost[u] + w(u,v)
            	then cost[v] = cost[u] + w(u,v)
                and  path[v] = u
        }
    }
}

시간복잡도

우선순위 큐 없을 시: O(n^2)

우선순위 큐 사용 시: (mlogn^2)

n: 노드 개수, m: 간선 개수

 

코드 (Java, 우선순위 큐 사용)

public class Dijkstra {	
    static int[] minDistanceFromSource;
    static int[] predecessor;
   	static int vertexCount;
	static int edgeCount;
	static int source;
	static List<Edge>[] graph;
	
    public static void main(String[] args) throws IOException {
		// initGraph() -> 이 부분은 상황에 따라 달라지므로 단순히 이렇게 표현
		dijkstra();    
	}

	private static int[] dijkstra() {
		PriorityQueue<Edge> queue = new PriorityQueue<>(Comparator.comparing(x -> x.weight));
		minDistanceFromSource = new int[vertexCount + 1];
        	predecessor = new int[vertexCount + 1];
       		boolean[] visit = new boolean[vertexCount + 1];
		for (int i = 1; i <= vertexCount; i++) {
			distance[i] = Integer.MAX_VALUE;
		}
		distance[source] = 0;
		queue.add(new Edge(source, source, 0));

		while (!queue.isEmpty()) {
			Edge minEdge = queue.remove();
            	visit[Edge.to] = true;

			for (Edge adjEdge : graph[minEdge.to]) {
				if (distance[adjEdge.to] > distance[adjEdge.from] + adjEdge.weight) {
					distance[adjEdge.to] = distance[adjEdge.from] + adjEdge.weight;
					queue.add(new Edge(adjEdge.from, adjEdge.to, distance[adjEdge.to]));
                    	predecessor[adjEdge.to] = adjEdge.from;
				}
			}
		}
		return distance;
	}
}

class Edge {
	int from;
    int to;
    int weight
    
    public Edge(int from, int to, int weight) {
    	this.from = from;
        this.to = to;
        this.weight = weight;
    }
}

 

# Reference


권오흠 교수님 유튜브 및 강의자료

알고리즘 Visualization

 

Data Structure Visualization

 

www.cs.usfca.edu

 

# 개요


최단 경로 문제 정리

단어가 좀 혼용될 수 있다

  • Ex) 노드==정점 / 간선==엣지 

 

# 최단 경로 문제


가중치가 있는 방향/무방향 그래프에서 두 노드 사이의 가장 짧은 경로를 찾는 문제

 

# 최단 경로 문제의 유형


Single-Source / Single-Destination (One - to - All)

  • 하나의 출발 노드로부터 다른 모든 노드까지의 최단 경로를 찾는 문제
  • 모든 노드로부터 하나의 목적지 노드까지의 최단 경로를 찾는 문제
  • 방향을 뒤집으면 서로 같아지기 때문에 사실상 같은 유형으로 본다.
  • Dijkstra 알고리즘, Bellman Ford 알고리즘

 

All-Pairs (All - to - All)

  • 모든 노드 쌍에 대하여 최단 경로를 찾는 문제
  • Floyd-Warshall 알고리즘

 

# 최단경로와 음수 가중치


Dijkstra 알고리즘은 음수 가중치가 있을 경우 작동하지 않는다.

Bellman-Ford와 Floyd-Warshall 알고리즘은 음수 사이클이 없다는 가정하에 음수 가중치가 있어도 작동한다.

 

음수 사이클

음수 사이클이 있으면 최단 경로가 정의되지 않는다.

  • 사이클의 합이 음수라면 계속 사이클을 돌게 되고 결국 합이 -∞되어 최단 경로를 찾을 수 없다.
  • 단, 그래프 내에 음수 사이클이 있어도 그 사이클이 목적지까지 가는 경로에 없다면 상관없다.

 

# 최단 경로 문제의 기본 특성


1. 최단 경로의 어떤 부분 경로 역시 최단 경로이다. (Optimal SubStructure → DP)

  • 위 경로가 u → v의 최단 경로라면, x → y의 경로 또한 최단 경로이다.

 

증명 (귀류법)

  • 만약 더 짧은 경로(x → y)가 있다고 가정해보자
  • 이 경로가 더 짧다면 u → v의 최단 경로는 u → v가 아니라 x → y를 거쳐가는 u → x → y → v가 될 것이다.
  • 이것은 처음 가정(u →v 가 최단 경로라면)의 모순이 된다.
  • 그러므로 최단 경로의 부분 경로도 최단 경로이다.

 

2.  최단 경로는 사이클을 포함하지 않는다.

 

# Single-Source 최단 경로 문제


음수 사이클이 없는 가중치 방향 그래프 G = (V, E)와 출발 노드 S에서 각 노드 v에 대하여 다음을 계산한다.

  • d[v] (distance estimation): 출발 노드 S로부터 각 노드v에 대하여 현재까지 알고 있는 최단 경로의 길이 ( 알고리즘이 진행됨에 따라 갱신 된다.)
    • 처음에는 d[S] = 0, 각 d[v]들은 ∞으로 초기화한다.
      • S는 출발 노드이고, 음수 사이클이 없으므로 가장 작은 값인 0
      • 그 외의 다른 노드들 v는 아직 탐색 시작을 안했으므로 ∞
    • 알고리즘이 수행됨에 따라 d[v]가 점점 감소하며 최종적으로 d[v]값이 S →  v의 최단 경로 길이가 된다.
  • π[v] (predecessor): S에서 v까지의 최단 경로 상에서 v의 직전 노드
    • 처음에는 π[v]와 π[S]는 null로 초기화
      • 출발 노드S의 이전 노드는 없으므로 null
      • 다른 노드들 v는 아직 탐색 시작을 안했으므로 null
    • 최단경로의 길이 외에 그 경로 자체를 요구할 수 있으므로 이전 노드를 기록하며 역추적한다.

 

기본 연산: Relaxation

1. d[u] = 5, d[v] = 9, w(u,v) = 2(간선 u → v의 가중치 값)인 상황

  • d[v]는 "현재까지 알고 있는" S → v의 최단 경로 길이 이므로 Relaxation 연산에  따라 갱신 될 수 있다.
  • 기존에 알고 있는 S →  v의 최단 경로는 S →  v로 d[v] = 9 였는데 더 짧은 경로인 S → u → v경로를 발견했으므로 d[v] = d[u] + w(u,v)로 갱신한다 (9 > 5+2)

2. d[u] = 5, d[v] = 6, w(u,v) = 2인 상황

  • Relaxation 연산을 했지만 기존 최단 경로인 S → v d[v]가 더 짧은 경로이므로 갱신하지 않는다.

Relaxtaion 의사 코드

Relax(u, v, w) {
	if(d[v] > d[u] + w(u,v))
    	then d[v] ← d[u] + w(u,v)
        	 π[v] ← u
}

 

대부분의 Single-Source 최단 경로 알고리즘의 기본 구조

  1. 초기화: d[S] = 0, 노드 v에 대해 d[v] = ∞, π[v]=null
  2. 간선들에 대해서 반복적인 Relaxtation 연산

의사 코드

Generic-Single-Source(G, w, S) {
	Init(G, S)
    Repeat
    	for each edge(u,v)
        	Relax(u,v,w)
    until there is no change
}
  1. 의문: 이렇게 반복하면 최단 경로가 정말 찾아질까?
  2. 의문: 찾아진다면 몇 번 반복해야 찾아질까?

증명

  • 가정: N개의 정점에서, S → vn 경로가 최단 경로라면 이 경로에 포함된 간선의 개수는 최대 N-1개이다.
  • 첫 번째 Round
    • 모든 간선들에 대해 Relax연산을 하고, d[S] = 0, d[v1]이  ∞에서 d[S] + w(S, v1)을 통해 갱신 된다.
    • S → vn까지의 최단 경로의 부분 경로 역시 최단 경로이므로 갱신 된 d[v1]이 곧 최단 경로가 된다.
  • 두 번째 Round
    • 모든 간선들에 대해 Relax연산을 하고, d[v2]이  ∞에서 d[v1] + w(v1, v2)을 통해 갱신 된다.
    • 마찬가지로 갱신 된 d[v1]이 곧 최단 경로가 된다.
  • 반복하면 d[vn]은 n-1번째 Round에 최단 경로가 된다.
  • 즉 노드의 개수가 N개라면 N-1번의 반복으로 모든 노드의 최단 경로를 구할 수 있다.

직관적으로 봐도 vn까지 최단 경로의 간선 수는 최대 N-1라는 것을 생각할 수 있는데

  • vx를 거쳐가는 더 짧은 경로가 있다면? → 그럼 그 경로가 최단 경로가 되며, N개의 정점에서 N+1개의 정점이 되었다. 그러므로 간선의 개수는 최대 N개가 될 것이다.

  • v2 → v3 → v4 경로를 통해 가는 것보다 v3 → v2 갔다가 다시 v2 → v3으로 가는 것이 더 빠르다면? → 그 자체로 음수 사이클이 생긴다는 뜻이며 음수 사이클이 없다는 전제에 모순된다.

Worst Case

시간 복잡도: O(n^3) → 효율적인 알고리즘이라고 할 수 없다.

다음과 같은 상황을 보자

  • 첫 번째 Round에서 간선들을 어떠한 순서로 Relax 연산을 하다보니 d[v] = 1000이 되었다.
  • 두 번째 Round에서 간선들을 어떠한 순서로 Relax 연산을 하다보니 d[v] = 800이 되었다.
  • 최악의 상황은 마지막 Round에서 d[v] = 200으로 갱신 되고 이것이 최단 경로라는 것을 알게 되었을 때이다. 그제서야 v 이후의 노드들에 대한 최단 경로를 알 수 있게 된다.
  • 하지만 처음부터 가중치가 50인 경로로 Relax를 했다면 더 빠르게 최단 경로를 찾을 수 있지 않았을까?
    • Dijkstra 알고리즘으로 해결
  • 최악의 경우 시간 복잡도가 O(n^3)인데도 사용하는 이유는 가중치가 음수일 때도 동작하고, 음수 사이클 여부를 알 수 있기 때문이다.

 

# Reference

권오흠 교수님 유튜브 및 강의자료

 

+ Recent posts