Design Pattern - Adapter

해당 포스팅은 Java 언어로 배우는 디자인 패턴 입문 책을 참고해 작성했습니다.
사용된 코드는 jongmin92/Design-Pattern repository 에서 확인할 수 있습니다.

Adapter 패턴

이미 제공되어 있는 것을 그대로 사용할 수 없을 때, 필요한 형태로 교환하고 사용하는 일이 자주 있습니다. ‘이미 제공되어 있는 것’과 ‘필요한 것’ 사이(서로 다른 두 개)의 ‘차이’를 없애주는 디자인 패턴이 Adapter 패턴 입니다.

Adapter 패턴은 Wrapper 패턴으로 불리기도 하며, 다음과 같은 두 가지 종류가 있습니다.

  • 클래스에 의한 Adapter 패턴 (상속을 사용한 Adapter 패턴)
  • 인스턴스에 의한 Adapter 패턴 (위임을 사용한 Adapter 패턴)

예제 프로그램(1) - 상속을 사용한 Adapter 패턴

만들 예제 프로그램은 주어진 문자열을 아래와 같이 표시하는 간단한 것입니다.

1
2
(Hello)
*Hello*

Banner 클래스에는 문자열을 괄호로 묶어서 표시하는 showWithParen 메소드와 문자열 전후에 *를 붙여 표시하는 showWithAster 메소드가 준비되어 있습니다. (이미 제공되어 있는 것)

Print 인터페이스에는 문자열을 느슨하게(괄호 사용) 표시하기 위한 printWeak 메소드와 문자열을 강하게 표시하기 위한 (* 표시를 앞뒤에 붙여 강조) printStrong 메소드가 선언되어 있습니다.

지금 하고 싶은 일은 Banner 클래스를 사용해서 Print 인터페이스를 충족시키는 클래스를 만드는 일입니다.

PrintBanner 클래스가 어댑터의 역할을 담당합니다. 이 클래스는 제공되어 있는 Banner 클래스를 상속해서, 필요로 하는 Print 인터페이스를 구현합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Banner {
private String string;

public Banner(String string) {
this.string = string;
}

public void showWithParen() {
System.out.println('(' + string + ')');
}

public void showWithAster() {
System.out.println('*' + string + '*');
}
}
1
2
3
4
5
public interface Print {
void printWeak();

void printStrong();
}

PrintBanner 클래스

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class PrintBanner extends Banner implements Print {
public PrintBanner(String string) {
super(string);
}

@Override
public void printWeak() {
showWithParen();
}

@Override
public void printStrong() {
showWithAster();
}
}

Main 클래스

1
2
3
4
5
6
7
public class Main {
public static void main(String[] args) {
Print p = new PrintBanner("Hello");
p.printWeak();
p.printStrong();
}
}

Main 클래스는 Print 인터페이스를 사용하고 있습니다. Banner 클래스나 showWithParen 메소드나 showWithAster 메소드는 Main 클래스 소스 코드 상에서는 완전히 감추어져 있습니다.

예제 프로그램(2) - 위임을 사용한 Adapter 패턴

Main 클래스, Banner 클래스, Print 인터페이스는 예제 프로그램(1)과 동일합니다.

PrintBanner 클래스는 banner 필드에서 Banner 클래스의 인스턴스를 가집니다. 이 인스턴스는 PrintBanner 클래스의 생성자에서 생성합니다.

이전 예와는 달리, 이번에는 필드를 경우해서 호출하고 있습니다. 즉 위임을 하고 있습니다.

PrintBanner 클래스

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class PrintBanner implements Print {
private Banner banner;

public PrintBanner(String string) {
this.banner = new Banner(string);
}

@Override
public void printWeak() {
banner.showWithParen();
}

@Override
public void printStrong() {
banner.showWithAster();
}
}

Adapter 패턴의 구성요소

  • Target(대상)의 역할
    지금 필요한 메소드를 결정합니다. 예제 프로그램에서 Print 인터페이스(상속의 경우)나 Print 클래스(위임의 경우)가 이 역할을 합니다.
  • Client(의뢰자)의 역할
    Target 역할의 메소드를 사용해서 일을 합니다. 예제 프로그램에서 Main 클래스가 이 역할을 합니다.
  • Adaptee(개조되는 쪽)의 역할
    Adaptee는 이미 준비되어 있는 메소드를 갖고 있는 역할입니다. Adaptee역의 메소드가 Target 역할의 메소드와 일치하면 다음 Adapter의 역할은 필요없습니다.
  • Adapter의 역할
    Adapter 패턴의 주인공입니다. Adaptee 역할의 메소드를 사용해서 어떻게든 Target 역할을 만족시키기 위한 것이 Adapter 패턴의 목적이며, Adapter 역할의 임무입니다. 예제 프로그램에서는 PrintBanner 클래스가 Adapter의 역할을 합니다.

사고 넓히기

어떤 경우에 사용할까?

Adapter 패턴은 기존의 클래스를 개조해서 필요한 클래스를 만듭니다. 이 패턴으로 필요한 메소드를 빠르게 만들 수 있습니다.

이미 만들어진 클래스를 새로운 인터페이스(API)에 맞게 개조시킬 때 기존 클래스의 소스를 바꾸어서 ‘수정’하려고 합니다. 그러나 그렇게하면 테스트가 이미 끝난 기존의 클래스를 수정한 후에 다시 한 번 테스트 해야 합니다. Adapter 패턴은 기존의 클래스를 전혀 수정하지 않고 목적한 인터페이스(API)에 맞추려는 것입니다.

만약 버그가 발생해도 기존의 클래스(Adaptee의 역할)에는 버그가 없으므로 Adapter 역할의 클래스를 중점적으로 조사하면 되고, 프로그램 검사도 상당히 쉬워집니다.

Design Pattern - Iterator

해당 포스팅은 Java 언어로 배우는 디자인 패턴 입문 책을 참고해 작성했습니다.
사용된 코드는 jongmin92/Design-Pattern repository 에서 확인할 수 있습니다.

Iterator 패턴

Java 언어에서 배열 arr의 모든 요소를 표시하기 위해서는 다음과 같이 for문을 사용합니다.

1
2
3
for (int i = 0; i < arr.length; i++) {
System.out.println(arr[i]);
}

for문의 i++에서 i를 하나씩 증가시키면서 배열 arr의 요소 전체를 처음부터 차례대로 검색하게 됩니다. 여기서 사용되고 있는 변수 i의 기능을 추상화해서 일반화한 것을 디자인 패턴에서는 Iterator 패턴이라고 합니다.

Iterator 패턴이란, 무엇인가 많이 모여있는 것들을 순서대로 지정하면서 전체를 검색하는 처리를 실행하기 위한 것입니다. Iterator는 무엇인가를 ‘반복한다’라는 의미이며, 반복자라고도 합니다.

예제 프로그램

Iterator 패턴을 사용해 책장에 꽂혀 있는 책들을 하나씩 검색해 책 이름을 출력해보는 예제 프로그램을 작성해 보겠습니다.

Aggregate 인터페이스

Aggregate 인터페이스는 요소들이 나열되어 있는 ‘집합체’를 나타냅니다. 이 인터페이스를 구현하고 있는 클래스는 배열과 같이 무엇인가가 많이 모여 있습니다.

1
2
3
public interface Aggregate {
Iterator iterator();
}

Aggregate 인터페이스에 선언되어 있는 메소드는 iterator 메소드 하나뿐입니다. 이 메소드는 집합체에 대응하는 Iterator를 1개 작성하기 위한 것입니다.

Iterator 인터페이스

Iterator 인터페이스는 요소를 하나씩 나열하면서 루프 변수와 같은 역할을 수행합니다.

1
2
3
4
public interface Iterator() {
boolean hasNext();
Object next();
}

hasNext 메소드는 다음 요소가 존재하는지를 조사하기 위한 메소드입니다. 다음 요소가 존재하면 true를 반환하고, 다음 요소가 존재하지 않는 마지막 요소라면 false를 반환합니다. 즉, hasNext는 루프의 종료 조건으로 사용됩니다.

next 메소드는 집합체의 요소를 1개 반환합니다. 또한 next 메소드를 호출했을 때 다음 요소를 반환하도록 내부 상태를 다음으로 진행시켜 두는 역할도 함께합니다.

Book 클래스

Book 클래스는 책을 나타내는 클래스입니다.

1
2
3
4
5
6
7
8
9
10
11
public class Book {
private String name;

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

public String getName() {
return name;
}
}

BookShelf 클래스

BookShelf 클래스는 책장을 나타내는 클래스입니다. 이 클래스를 집합체로 다루기 위해 Aggregate 인터페이스를 구현합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class BookShelf implements Aggregate {
private Book[] books;
private int last = 0;

public BookShelf(int maxsize) {
this.books = new Book[maxsize];
}

public Book getBookAt(int index) {
return books[index];
}

public void appendBook(Book book) {
this.books[last] = book;
last++;
}

public int getLength() {
return last;
}

@Override
public Iterator iterator() {
return new BookShelfIterator(this);
}
}

iterator 메소드는 BookShelf 클래스에 대응하는 Iterator로서, BookShelfIterator라는 클래스의 인스턴스를 생성해서 그것을 반환합니다.

BookShelfIterator 클래스

BookshelfIterator 를 Iterator로서 다루기 위해 Iterator 인터페이스를 구현합니다. bookShelf 필드는 BookShelfIterator가 검색할 책장이고, index 필드는 현재 가리키는 책을 가리키는 첨자입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
 public class BookShelfIterator implements Iterator {
private BookShelf bookShelf;
private int index;

public BookShelfIterator(BookShelf bookShelf) {
this.bookShelf = bookShelf;
this.index = 0;
}

@Override
public boolean hasNext() {
return index < bookShelf.getLength();
}

@Override
public Object next() {
Book book = bookShelf.getBookAt(index);
index++;
return book;
}
}

Main 클래스

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Main {
public static void main(String[] args) {
BookShelf bookShelf = new BookShelf(4);
bookShelf.appendBook(new Book("Effective Java"));
bookShelf.appendBook(new Book("Head First Java"));
bookShelf.appendBook(new Book("Thinking In Java"));
bookShelf.appendBook(new Book("Agile Java"));

Iterator it = bookShelf.iterator();
while (it.hasNext()) {
Book book = (Book) it.next();
System.out.println(book.getName());
}
}
}
1
2
3
4
5
// 실행결과
Effective Java
Head First Java
Thinking In Java
Agile Java

Iterator 패턴의 구성요소

  • Iterator(반복자)의 역할
    요소를 순서대로 검색해가는 인터페이스(API)를 결정 (hasNext, next)

  • ConcreteIterator(구체적인 반복자)의 역할
    Iterator가 결정한 인터페이스(API)를 실제로 구현

  • Aggregate(집합체)의 역할
    Iterator 역할을 만들어내는 인터페이스(API)를 결정

  • ConcreteAggregate(구체적인 집합체)의 역할
    Aggregate 역할이 결정한 인터페이스(API)를 실제로 구현

사고 넓히기

구현에 상관 없이 Iterator를 사용할 수 있다.

1
2
3
4
while (it.hasNext()) {
Book book = (Book) it.next();
System.out.println(book.getName());
}

여기서 사용되고 있는 것은 hasNext와 next라는 Iterator의 메소드 뿐입니다. BooShelf의 구현에서 사용되고 있는 메소드는 호출되고 있지 않습니다. 결국 위 코드의 while 루프는 BookShelf의 구현에 의존하지 않습니다.

그렇기 때문에 현재 배열을 사용해 구현하고 있는 BookShelf를 List를 사용하도록 수정해도, 위의 while 루프는 전혀 변경하지 않아도 동작합니다.

Aggregate와 Iterator의 대응

BookShelfIterator는 BookShelf가 어떻게 구현되고 있는지 알기 때문에, ‘다음 책’을 얻기 위해 getBookAt 메소드를 호출할 수 있었습니다.

만약 BookShelf의 구현을 전부 변경하고, getBookAt 메소드라는 인터페이스(API)도 변경된다면 BookShelfIterator의 수정이 필요하게 됩니다.

소나큐브

해당 포스팅은 자바 필수 프로젝트 유틸리티 책의 8.3 소나큐브 부분을 참고해 작성했습니다.

소나큐브

소나큐브를 사용해서 작성한 코드를 정적 분석하고 작성한 테스트로 얼마나 검증했는지 측정할 수 있다.

소나큐브를 로컬 환경에 설치하고 젠킨스와 소나큐브를 연결해서 소나큐브의 기본 퀄리티 게이트(Quality Gate)를 실행시켜보자. 퀄리티 게이트는 조직의 모든 소스가 통과해야만 하는 소스의 품질을 정의해둔 것으로 소스의 품질을 보증하는 수단으로 이용된다.

기능과 특징

소나큐브의 주요 기능은 아래와 같다.

  • 복잡도 확인
  • 코드 중복 검출
  • 코딩 규칙 확인
  • 잠재적 버그 검출
  • 단위 테스트
  • 커버리지

소나큐브 자체에서 지원하는 심플한 UI를 이용해 프로젝트의 소스 코드가 얼마나 개선되고 있는지를 직관적으로 확인할 수 있다. 소나큐브의 특징은 다음과 같다.

  • 서버는 크게 웹 서버, 검색 서버, 연산 서버로 구성된다.
  • 데이터베이스는 소나큐브 자체의 설정과 각 프로젝트의 정보가 저장된다.
  • 플러그인을 추가할 수 있다.
  • 소나큐브 스캐너(SonarQube Scanner)로 코드를 분석한다.

설치

brew를 이용해 설치 후 실행한다.

1
2
$ brew install sonarqube
$ brew services start sonarqube

brew를 이용해 설치하게 되면 다음과 /usr/local/Cellar/sonarqube/7.2.1/libexec/conf 디렉터리에 설치된다. 각 디렉터리의 역할은 다음과 같다.

  • bin : 운영체제별 실행파일이 있다.
  • conf : 소나큐브의 설정 팡리이 있다. 설정 파일에서 데이터베이스 연결과 웹 서버의 설정 등을 한다.
  • data : 기본 데이터베이스인 H2 데이터베이스를 사용할 때 데이터가 저장되는 곳이다. 테스트 목적이 아니라면, 실제 운영할 때는 다른 데이터베이스를 사용해야 한다.
  • elasticsearch : 루씬 기반의 검색 엔진인 elasticsearch가 포함되어 있다.
  • extensions : jdbc-driver와 플러그인이 포함된다.
  • lib : 실제 애플리케이션 바이너리가 포함되어 있다.
  • logs : 각 로그가 출력되는 디렉터리이다. 설정 파일에서 변경 가능하다.
  • temp : 서버 실행 시에 필요한 임시 파일이 저장된다. 실행 중에 삭제하면 안된다.
  • web : UI에 필요한 이미지와 CSS, JS 파일이 있다.

실행 후 http://localhost:9000에 접속하면 다음과 같은 화면을 볼 수 있다.

관리자로 접속해서 젠킨스와 연결할 사용자를 만들어 보자. 오른쪽 상단 위에 있는 ‘Log in’ 을 클릭한 후 초기 ID/PW인 admin/admin을 입력해서 로그인한다.

로그인이 완료되면 튜토리얼 페이지가 나온다. 우측 상단의 ‘Skip this tutorial’ 을 클릭해서 스킵한다.

아래와 같이 프로젝트 페이지를 확인할 수 있다.

젠킨스 사용자 생성

젠킨스에서 소나큐브에 접속할 때 사용할 사용자를 생성한다.

화면 상단의 메뉴에서 ‘Administration’ -> ‘Security’ -> ‘Users’ -> ‘Create User’ 를 클릭해서 사용자 생성 창을 출력한다.

jenkins라는 이름으로 사용자를 생성한다.

젠킨스에서 접속할 때 사용할 토큰을 생성한다. 조금전 생성한 jenkins 유저의 ‘Token’ 을 클릭한다.

‘Enter Token Name’ 에 적당한 이름을 넣고 ‘Generate’ 버튼을 눌러서 생성한다.

생성된 토큰 앞에 있는 ‘Copy’ 버튼을 눌러 젠킨스에서 사용할 키를 저장한다.

소나큐브 스캐너 설정

이번에는 젠킨스의 빌드 과정에서 소나큐브를 연동한다.

‘Jenkins 관리’ -> ‘플러그인 관리’에서 ‘SonarQube Scanner’를 설치 한다.

‘Jenkins 관리’ -> ‘시스템 설정’ 으로 이동하면, ‘SonarQube servers’ 라는 설정이 추가된것을 확인할 수 있다. 만약 플러그인을 설치했는데도 해당 설정이 표시되지 않는다면 젠킨스 서버를 재실행해본다.

‘Add SonarQube’ 를 클릭한다.

‘고급’ 버튼을 클릭한 후 시스템 환경 변수를 사용하도록 각각 다음과 같이 설정한다.

  • Environment variables : 체크 박스 선택
  • Name : SonarQube-Local 입력 (스페이스와 한글을 입력하지 않는다.)
  • Server URL : 소나큐브의 URL 입력(여기서는 default인 http://localhost:9000)
  • Server authentication token : 소나큐브에서 생성한 토큰 입력
  • Additional analysis properties :
    sonar.sources=src sonar.java.binaries=target/classes, target/test-classes

‘저장’ 을 클릭해서 저장한다.

소나큐브 스캐너를 추가하는 작업을 진행한다. ‘Jenkins 관리’ -> ‘Global Tool Configuration’ 을 클릭한다.

‘SonarQube Scanner for MSBuild’와 ‘SonarQube Scanner’ 아래의 버튼을 클릭해서 각각 스캐너를 추가한다.

‘Name’에 적당한 이름을 넣고 ‘Install automatically’ 가 체크되어 있는지 확인 후 저장한다.

새로운 잡을 생성해서 테스트 해보자.

  1. 젠킨스 메인 페이지에서 ‘새로운 Item’을 클릭하고 잡 이름에 ‘SonarQubeTset’를 입력하고 ‘Freestyle project’를 선택 후 저장 버튼을 클릭한다.

  2. 소스 코드 관리 : 테스트 프로젝트의 깃허브 주소를 넣는다. (여기서는 ‘자바 필수 프로젝트 유틸리티‘ 책에서 제공한 ‘spring-mvc-example‘ 프로젝트를 fork해 사용했다.)

  3. Credentials : 저장되어 있는 자격증명에서 선택한다.

  4. 빌드 환경에서 ‘Prepare SonarQube Scanner environment’를 선택한다. (이번 테스트에서는 필요하지는 않지만, ‘빌드 환경’에서 호나경 변수를 사용할 수 있게 하려고 체크하는 것이다.)

  5. Build : ‘Invoke top-level Maven targets’를 선택한다.

  6. Goals : ‘clean install’을 입력한다. (다른 골을 추가해도 된다.)

  7. 다시 Build의 ‘Add build step’을 클릭해서 ‘Execute SonarQube Scaner’를 추가한 후 ‘Analysis properties’에 아래의 정보를 입력한다.

    1
    2
    3
    sonar.projectKey=spring-mvc-example
    sonar.projectName=spring-mvc-example
    sonar.projectVersion=1.0.0

    이 테스트에서는 고정값으로 입력했지만 매개변수로 처리하거나 환경 변수에서 치환하는 방법도 많이 사용한다.

  8. ‘저장’ 버튼을 클릭해서 잡 대시보드로 이동한다.

소나큐브의 아이콘이 추가된것을 확인할 수 있다.

소나큐브 빌드

‘Build Now’ 를 클릭해서 빌드한다. 정상 종료되면 ‘SonarQube Quality Gate’ 가 표시되고 실행 결과를 알려준다.

화면에 표시되는 ‘SonarQube’ 링크나 ‘OK’를 클릭하면 소나큐브의 대시보드로 이동한다.

상단의 메뉴에서 ‘Issues’, ‘Code’를 통해 각 결과를 확인할 수 있다.

젠킨스

젠킨스

젠킨스 는 자바로 작성된 오픈 소스 소트트웨어로 지속적 통합(Continuous Integration, CI)과 지속적 배포(Continuous Delivery, CD)를 제공한다.

웹 애플리케이션 형태로 제공되고 있어서 어떠한 환경에서도 손쉽게 설치할 수 있으며 도커를 사용해 설치할 수도 있다. 또한 천 개 이상의 플러그인으로 다양한 시스템과 연동할 수 있다.

젠킨스의 주요 기능은 다음과 같다.

  • 형상관리 도구와의 연동
  • 소스 코드 체크아웃
  • 웹 인터페이스
  • 테스트 보고서 생성
  • 빌드 및 테스트 자동화
  • 실행 결과 통보
  • 코드 품질 감시
  • 다양한 인증 기반과 결합한 인증 및 권한 관리
  • 배포 관리 자동화
  • 분산 빌드(마스터 슬레이브)
  • 그루비 스크립트를 이용한 자유로운 잡 스케줄링

젠킨스는 개발자가 소스코드를 추가, 수정한 뒤 형상관리 도구에 저장하면 자동으로 읽어 빌드 및 테스트를 실행한다.

젠킨스 설치

젠킨스를 사용하려면 JDK와 메이븐이 필요하다.

macOS에서 brew를 이용해 쉽게 설치가 가능하다.

1
$ brew install jenkins

추후 필요하다면 다음과 같이 삭제할 수도 있다.

1
$ brew remove jenkins

설치 후 다음과 같이 젠킨스를 백그라운드 서비스로 구동 및 중지가 가능하다.

1
2
$ brew services start jenkins
$ brew services stop jenkins

젠킨스를 실행 후 웹사이트에 접속한다. http://localhost:8080

처음 접속 시 Administrator password를 입력하게 되어 있는데 아래의 경로에 있는 파일에서 키를 확인해 입력한다.

/Users/{사용자 계정}/.jenkins/secrets/initialAdminPassword


젠킨스를 어떻게 설치할지 결정할 수 있다. ‘Install suggested plugins’를 선택하면 젠킨스에서 추천하는 플러그인들이 같이 설치되고, ‘Select plugins to install’을 선택하면 필요한 플러그인을 선택하여 설치할 수 있다.


필요한 플러그인들을 자동으로 설치하기 시작한다.


설치가 끝나면 관리자 정보 입력 화면이 나온다. 정보를 입력한 후 ‘Save and Finish’ 버튼을 클릭한다.


빌드 잡(job) 생성하기

  1. 좌측 메뉴 상단에 있는 ‘새로운 Item’을 클릭한다. ‘Enter an item name’에 적당한 이름을 넣고 아래의 템플릿 중 ‘Freestyle project’를 선택한 후 ‘OK’ 버튼을 클릭한다. (Freestyle project는 거의 모든 젠킨스의 설정을 자유롭게 설정할 수 있다.)
  2. Github에 테스트를 위해 간단하게 만들어 놓은 프로젝트를 가져와 빌드를 테스트 한다.
  3. Github에서 프로젝트를 가져오기 위해서는 자격 증명을 추가해야 한다. ssh-key를 등록하거나 Github의 계정을 입력해야 한다.
  4. Credentials 선택 박스에서 조금 전 추가한 계정을 선택한 후 ‘저장’ 버튼을 클릭한다.
  5. ‘Build Now’를 클릭해 빌드를 진행한다.
  6. ‘Console Output’을 누르면 빌드가 성공한 것을 확인할 수 있다.

그레이들 기초

그레이들

그루비와 그레이들

그루비 는 자바 가상머신에서 동작하는 오픈 소스 스크립트 언어이다. 그루비는 자바 문법을 더욱 쉽게 쓰기 위해 스크립트 언어와 비슷한 문법으로 되어 있어서, 대부분의 자바 프로그래머는 자바 코드를 작성하는 느낌으로 그루비를 작성할 수 있다. 동적 언어이며 자바와 달리 작성한 스크립트를 컴파일할 필요 없이 직접 실행할 수도 있다.

또한, 일부 자바 프레임워크에서는 그루비를 지원한다. 대표적인 스프링 프레임워크에서도 그루비를 이용할 수 있다.

이처럼 자바와 거의 같고, 스크립트 언어처럼 부담 없이 작성해서 바로 실행할 수 있는 특징을 고려하면, 그루비를 사용해서 자바 빌드 도구를 만들려는 생각은 자연스럽다.

그루비의 이러한 이점을 최대한 활용해서 개발한 빌드 도구가 그레이들 이다.

그레이들이란

그레이들은 그루비를 사용한 빌드 도구이다. 메이븐은 XML을 이용하여 빌드 정보를 기술했는데, 그레이들은 그루비를 이용해 빌드 정보를 기술하기 한다. 때문에 자바 프로그래머가 좀 더 쉽게 다룰 수 있다.

그레이들의 특징은 다음과 같다.

  • 유연한 언어로 기술
    그루비라는 프로그래밍 언어를 사용해서 기술하기 때문에 유연하게 각종 처리를 수행할 수 있다. 또한 기술하는 내용을 분할하거나 구조화하는 것도 간단하다.
  • 태스크로 처리
    그레이들은 ‘태스크’라는 개념을 이용해 프로그램을 작성한다. 다양한 용도별로 태스크를 만들어서 그 안에 처리를 기술한다.
  • 자바/그루비/스칼라 기본 지원 + 알파
    그레이들은 자바 가상 머신에서 동작하는 언어를 중심으로 지원한다. (별도의 네이티브 코드 플러그인을 사용하면 C/C++ 등, 다른 언어에도 대응할 수 있다.)
  • 각종 도구와 통합
    여러 도구들(앤트, 아파치 아이비 등)과 통합되어 처리를 실행할 수 있다. 또한, 메이븐의 pom.xml을 그레이들용으로 변환하는 도구도 있다.
  • 메이븐 중앙 저장소 대응
    그레이들에서는 메이븐 중앙 저장소를 지원하기 때문에, 중앙 저장소에 있는 라이브러리 모두 그대로 이용 가능하다.

그레이들 사용하기

그레이들 소프트웨어에는 그루비가 포함되어 있기 때문에, 단지 그레이들을 사용하는 용도라면 그루비를 설치할 필요는 없다. (그레이들을 설치하는 것만으로는 그루비 언어를 이용해서 프로그래밍할 수는 없다.)

그레이들 프로젝트 생성

그레이들 명령어를 사용해 프로젝트를 생성할 수 있다.

1
2
3
mkdir gradle-app
cd gradle-app
gradle init --type java-library

gradle init

그레이들 명령어는 gradle OO 형태로 실행한다. 위에서 프로젝트를 생성하며 사용한 init은 그레이들에서 '태스크' 라고 부른다. 이 init은 프로젝트의 기본적은 파일과 폴더를 생성한다.

–type은 생성할 프로젝트의 타입을 지정하는 옵션인데, java-library는 자바 프로젝트임을 나타낸다. 지정한 언어의 샘플 코드를 생성한다. 이 옵션을 생략하면 그레이들 프로젝트의 기본적인 파일들만 생성된다.

그레이들 실행과 태스크

그레이들에서는 다양한 처리를 위해 ‘태스크’를 이용한다. 태스크란, 실행할 처리를 모아놓은 단위로 그레이들에서 처음부터 포함된 것도 있고, 프로그래머가 작성할 수도 있다. 이 태스크를 실행하는 것이 그레이들에서 빌드를 관리하는 기본적인 방법이다. 프로젝트를 컴파일하거나, 실행하는 모든 처리에 태스크를 이용한다.

생성된 gradle-app 폴더 내부의 구조를 살펴보자.

  • .gradle 폴더 : 태스크로 생성된 파일 등을 보존한다.
  • gradle 폴더 : 기본값으로는 그레이들 환경을 모아놓은 wrapper 파일이라고 하는 파일들이 들어 있다.
  • src 폴더 : 소스 코드 관련 파일을 이곳에 작성한다.
  • build.gradle : 그레이들 빌드 파일, 이곳에 프로젝트의 빌드 내용을 기술한다.
  • settings.gradle : 빌드 설정 정보를 기술한 파일. 빌드를 실행하기 전에 읽히기 때문에, 필요한 라이브러리를 읽는 등의 기술을 할 수 있다.

이번에는 생성된 파일의 코드를 확인해보자.

build.gradle

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 자바 프로그램을 빌드할 경우에는 java 플러그인을 로드한다.
apply plugin: 'java'

// 저장소 정보를 관리하는 프로퍼티이다. 이곳에서 저장소를 설정할 수 있다. (로컬 환경이나 원격 저장소 기술)
// jcenter는 그레이들에서 중앙 저장소로 이용되는 저장소이다.
// 메이븐 중앙 저장소는 mavenCentral 메서드를 이용해서 사용할 수 있다.
repositories {
jcenter()
}

// 의존성에 관한 설정을 관리하는 프로퍼티이다. 필요한 라이브러리등의 정보를 기술한다.
dependencies {
compile 'org.slf4j:slf4j-api:1.7.21'
testCompile 'junit:junit:4.12'
}

settings.gradle

1
2
// 루트 프로젝트의 이름을 설정한다. 루트 프로젝트는 다수의 프로젝트를 관리할 때 기본이 되는 프로젝트를 가리킨다. 여기에서는 '이 빌드 파일로 빌드할 프로젝트의 이름'이라고 생각하면 된다.
rootProject.name = 'gradle-app'

인텔리제이에서 사용하기

인텔리제이는 표준으로 그레이들을 지원한다. 인텔리제이에서 그레이들용 프로젝트를 생성할 경우, gradle init –type java-library 명령어를 사용하는 경우와 결과가 조금 다르다.

build.gradle

1
2
3
4
5
6
7
8
9
10
11
12
13
14
group 'com.jongmin'
version '1.0-SNAPSHOT'

apply plugin: 'java'

sourceCompatibility = 1.8

repositories {
mavenCentral()
}

dependencies {
testCompile group: 'junit', name: 'junit', version: '4.12'
}

시작 부분에 그룹 ID와 버전을 지정하는 문장이 포함되어 있다. gradle init으로 작성한 build.gradle에서는 포함되지 않았는데, 그 이유는 메이븐 저장소를 이용하기 때문이다. 메이븐에는 모든 프로그램에 그룹 ID와 아티팩트 ID가 할당되어 있다.

sourceCompatibility는 자바 소스 코드의 버전을 가리킨다.

태스크 실행

메이븐과 마찬가지로 실행할 내용을 ‘Run…’ -> ‘Edit Configurations’ 메뉴에서 컨피그레이션에 설정하면 인텔리제이의 ‘Run’으로 프로그램의 빌드와 실행, 디버그 등의 기능을 수행할 수 있다.

build.gradle

그래이들은 build.gradle에 기술한 코드를 필요에 따라 실행하여 빌드를 실행한다.

그레이들은 ‘그루비를 사용하는 빌드 도구’이다. 하지만 이는 정확한 설명은 아니다. 그레이들은 ‘그루비 그 자체’는 아니고 **’그루비 기반의 DSL(Domain Specific Language)’**이다.

DSL은 ‘도메인 고유 언어’라고 불리는데, 특정한 용도에 한정된 언어를 말한다. 그 언어 그 자체는 아니고, 특정한 용도에 맞게 해당 언어를 기반으로 각색한 것이다. 그레이들에서 사용되는 언어는 ‘그루비를 기반으로 작성된 Gradle DLS’이다.

그레이들은 기본적으로 태스크를 작성하여 실행한다. 태스크는 실행할 처리를 모아서 명령어로서 실행할 수 있게 한 것이다. 다음과 같은 형식으로 정의한다.

1
2
3
task 이름{
...실행할 처리...
}

이렇게 정의한 태스크는 명령행에서 gradle 이름 형태로 실행할 수 있다.

간단한 예제를 작성해 실행해보자. 다음과 같이 build.gradle을 수정한다.

1
2
3
4
5
6
7
8
9
task hello {
doLast {
println();
println("=================");
println("Welcome to Gradle!");
println("=================");
println();
}
}

수정한 후, 명령행에서 다음과 같이 실행한다.

1
gradle hello

결과는 다음과 같다.

1
2
3
4
5
6
7
8
> Task :hello

=================
Welcome to Gradle!
=================

BUILD SUCCESSFUL in 2s
1 actionable task: 1 executed

그레이들은 기본적으로 빌드 도구이지만, 여기에서는 빌드도 컴파일도 하지 않고, 단지 메시지를 표시했다. 태스크는 작성된 처리를 실행할 뿐이지, 반드시 빌드와 관련된 기능이 포함되어야 하는 것은 아니다.

quiet 모드로 실행

이번에는 quiet 모드로 실행해보자.

1
gradle -q hello

결과는 다음과 같다.

1
2
3
=================
Welcome to Gradle!
=================

이렇게 하면 hello에서 println한 내용만 출력되며, 그 이외의 내용은 표시되지 않는다. -q 옵션은 태스크를 quiet 모드로 실행해 예외 발생 등 중요한 문제 이외의 표시가 제한된다. 태스크의 실행 결과만 알고 싶을 때 편리한 옵션이다.

액션 리스트

태스크는 다양한 ‘액션’을 내부에 가지고 있다. 태스크를 실행하면 준비된 액션이 순서대로 실행된다. 이 액션을 관리하는 것이 ‘액션 리스트’이다. 액션은 어떤 역할을 하는지가 정해져 있다. 필요에 따라 액션에 처리를 추가해 태스크를 조합할 수 있다.

액션 중에서도 doFirstdoLast가 가장 많이 사용된다. 각각 ‘처음에 실행되는 액션’과 ‘마지막에 실행되는 액션’이다. 이들을 이용해 태스크의 처음과 마지막에 처리를 실행할 수 있다.

매개변수 이용

태스크를 실행할 때 어떤 정보를 태스크에 전달하고 싶은 경우도 있다. 이럴 때 매개변수를 이용할 수 있다.

1
2
3
4
5
6
7
8
9
task hello {
doLast {
def total = 0;
for(def i in 1..num.toInteger()) {
total += i;
}
println("total: " + total);
}
}

1에서 num까지 합하는 태스크이다. 여기서 사용되는 num이 매개변수로 전달되는 프로퍼티이다. 이처럼 매개변수에서 전달되는 값을 프로퍼티로 사용할 수 있다. 단, 주의할 점은 값이 String이라는 것이다.

hello 태스크는 다음과 같이 실행한다.

1
gradle hello -q -Pnum=100

이처럼 태스크를 실행하는 동안에는 -P프로퍼티=값 형태로 특정 변수에 값을 전달할 수 있다.

동적 태스크 실행

스크립트를 사용해서 동적으로 태스크를 생성할 수도 있다.

1
2
3
4
5
6
7
8
def arr = ["one", "two", "three"];
arr.each {s ->
task "$s" {
doLast {
println("this is $s task.");
}
}
}

다음과 같이 실행한다.

1
gradle -q one

java 플러그인 사용하기

자바로 개발할 때 필요한 기본적인 기능은 ‘java’ 플러그인에 포함되어 있다. build.gradle에서 로드하여 이용한다.

1
apply plugin: 'java'

다음의 명령어로 빌드한다.

1
gradle java

위 명령어를 실행하면 프로젝트가 컴파일되고 JAR 파일이 생성된다. 컴파일로 생성된 파일들은 build 폴더에 보관된다. 이 안의 libs 폴더 안에 gradle-app.jar 파일이 생성된다.

build 폴더 안에는 classes 폴더도 있는데, 여기에는 컴파일된 클래스 파일이 보관된다. gradle java에서는 우선 소스 코드를 컴파일하여 클래스 파일을 작성한 후, 이를 모아서 JAR 파일로 만드는 일련의 처리가 자동으로 수행된다.

gradle java와 gradle build

grade java는 자바 프로그램의 빌드를 수행하지만, gradle build는 어떤 언어로 작성된 프로젝트라도 빌드한다. (그레이들은 자바 이외에도 그루비나 스칼라 등 많은 언어를 지원하고 각각의 언어에서 빌드를 수행하는 플러그인을 제공한다.)

java 플러그인의 태스크

java 플러그인에는 이 외에도 몇 가지 태스크가 더 있다.

  • java
    자바 소스 코드를 컴파일하고 그 외에 필요한 리소스 파일들을 모아서 JAR 파일을 생성한다. 프로그램을 배포할 때 이 태스크로 JAR 파일을 만들면 유용하다. 단, 이 java 태스크로 생성된 JAR 파일은 Executable이 아니라는 점에 주의해야 한다.
  • compileJava
    자바 소스 코드를 모두 컴파일한다. 보존할 장소(build 안의 classes 폴더)가 없다면 폴더를 자동으로 생성하고 그 안에 클래스 파일을 작성한다.
  • processResources
    리소스 파일을 클래스 디렉터리(classes 폴더) 안에 복사한다.
  • classes
    소스 코드 컴파일과 리소스 파일 복사를 실행한다. compileJava와 processResources가 합쳐진 것이라 생각해도 된다.
  • test
    프로그램 테스트를 실행한다. 소스 코드와 관련된 컴파일을 수행하고 테스트에 필요한 리소스 복사 등을 수행한 뒤 JUnit으로 테스트를 실행한다. JUnit 라이브러리를 이용할 수 없는 상태에서는 테스트를 실행할 때 오류가 발생한다.
  • jar
    프로그램을 컴파일하고 리소스 파일 등을 준비한 뒤, JAR 파일로 패키징한다. 단, 파일을 단순히 JAR 파일에 모을 뿐이며 Executable jar을 작성하는 것은 아니다.
  • javadoc
    소스 코드를 해석하여 Javadoc 파일을 생성한다. build 안의 docs 폴더 안에 javadoc 폴더를 작성하여 파일을 보관한다.
  • clean
    빌드로 생성된 파일을 모두 삭제한다.

java 플러그인의 태스크 이용하기

1
2
3
4
5
task doit(dependsOn: [compileJava, jar]) {
doLast {
println "*** compiled and created jar! ***"
}
}

doit 태스크에는 매개변수가 포함되어 있다. dependsOn은 이 매개변수가 ‘의존성’을 지정하는 것을 가리킨다. ‘compileJava, jar’은 2개의 태스크를 모아놓은 배열이다. 즉, doit이 compileJava와 jar이라는 2개의 태스크에 의존한다.

이 상태에서 dependsOn에서 태스크를 지정하면 그 태스크가 실행되기 전에 의존하는 모든 태스크가 실행된다. 그리고 의존하는 태스크의 실행이 완료된 후에 doit 태스크의 doLast가 호출된다.

의존성을 지정해서 실행하는 방법 이외에 태스크를 직접 실행할 수도 있는데, 태스크의 execute 메서드를 호출하면 된다.

1
2
3
4
5
6
7
8
task doit {
doLast {
println "*** compiled now! ***"
tasks.compileJava.execute()
println "*** create jar! ***"
tasks.jar.execute()
}
}

실행하면 의존성을 지정해서 실행할 때와 같은 결과를 얻지만, “Deprecated Gradle features were used in this build, making it incompatible with Gradle 5.0.” 라는 메시지가 출력된다. 그레이들 버전 5.0 부터는 execute 메서드를 직접 호출하는 것이 호환되지 않기 때문이다.

application 플러그인

java 플러그인에는 프로그램을 실행하는 태스크가 없어 application 플러그인을 사용해 애플리케이션을 실행해야 한다.

1
apply plugin: 'application"

build.gradle에 이처럼 작성하고 그 다음에 mainClassName 프로퍼티에 메인 클래스(실행할 클래스)를 설정한다.

1
mainClassName = "com.jongmin.gradle.App"

application 플러그인에는 run 태스크가 포함되어 있다. 이것을 실행하면 mainClassName에 지정된 클래스가 실행된다.

1
gradle run

다음과 같이 작성하면 jar을 실행한 후 run으로 클래스를 실행할 수 있다.

1
2
3
4
5
6
7
8
9
10
apply plugin: 'java'
apply plugin: 'application'

mainClassName = "com.jongmin.gradle.App"

task doit(dependsOn:[jar, run]) {
doLast {
println("*** do it! ***")
}
}

빌드 도구와 메이븐

빌드 도구

빌드 도구란?

개발 환경의 변화와 빌드

자바를 처음 공부할 때는 이클립스 혹은 인텔리제이 같은 IDE만으로 프로그램을 뚝딱 만들 수 있다. 그러나 현업에 투입되면 무언가 복잡한 환경을 만나게 된다.

하지만 실제 자바 입문시절에 배운 과정과 현업에서 사용하는 과정에 차이는 없다. 다만 다양한 도구를 사용하여 더 전문화하여 현업 도구에 활용할 뿐이다.

  • 명령행에서 컴파일하기
    아주 간단한 프로그램이라면 명령행에서 javac를 이용하는 것만으로도 충분하다. 하지만 라이브러리 등을 이용하면, classpath에 다수의 라이브러리 경로를 기술한 뒤에 컴파일 해야 한다. 또한 생성된 클래스 파일을 모아 JAR 파일을 생성하려면 이 역시 모두 명령어로 실행해야 한다.
    소스 코드의 컴파일에서 JAR 파일의 생성까지 긴 과정을 수행하려면 방대한 명령어가 필요한데, 이를 매번 수작업으로 작성하면 큰 수고가 든다.
  • 프로젝트 및 라이브러리 설치
    최근에는 개발할 때 모든 프로그램을 처음부터 만드는 경우가 거의 없다. 프로그램에 필요한 기능은 라이브러리를 이용하거나, 프레임워크를 이용하여 애플리케이션을 개발한다.
    이런 경우, 필요한 소프트웨어를 갖추고 정해진 대로 파일을 구성해야 한다.
  • 테스트 자동화
    단순한 프로그램이라면 컴파일 후 실행 및 동작만 확인하는 것으로 충분하지만, 어느 정도 규모 있는 프로그램은 프로그램 생성과 함께 테스트를 실행하는 것이 일반적이다.
  • 프로그램 배포
    웹 애플리케이션은 구현한 프로그램을 서버에 배포하게 된다. 이런 작업을 수작업으로 시행하기가 번거롭다.

빌드 도구의 역할

‘빌드 도구’는 단순히 프로그램을 컴파일하여 애플리케이션을 생성하는 작업 그 이상으로 다양한 기능을 제공한다.

  • 프로그램 빌드
    프로그램을 컴파일하고, 지정된 디렉터리에 필요한 리소스를 모아서 프로그램을 완성한다. 그때 라이브러리등 필요한 파일을 설치하도록 지정할 수 있다.
  • 프로그램 테스트와 실행
    빌드된 프로그램의 실행뿐 아니라 테스트 기능도 제공한다. 빌드를 실행 할 때, 빌드가 완료되면 곧바로 테스트를 실행하는 도구도 있다.
  • 라이브러리 관리
    프로그램에서 필요한 라이브러리들을 관리한다. 빌드 실행 시 자동으로 라이브러리를 다운로드하고 설치하는 등의 작업을 한다.
  • 배포 작업
    빌드한 프로그램을 배포하는 기능을 제공한다.

개발 도구와 빌드 도구

개발 도구에 있어 빌드 도구를 다루는 방식은 크게 두 가지이다.

  • 빌드 도구를 이용하는 기능이 포함된 경우
    이클립스는 메이븐, 인텔리제이는 메이븐과 그레이들을 지원한다.
  • 개발 도구에서 명령어로 실행하는 경우

메이븐 기초

메이븐은 아파치 소프트웨어 재단이 개발하는 오픈 소스 빌드 도구이다. ‘아파치 앤트(Ant)’의 후속으로 개발되었고, 자바 프로그램 개발을 대상으로 한 오픈 소스 빌드 도구이다.

메이븐 특징

  • 빌드 파일은 XML로 작성
  • 단위 작업 ‘골’
    골은 메이븐에서 실행하는 작업의 목적을 지정한다. 메이븐 명령어를 실행할 때 골을 지정하면, 어떤 작업을 수행하여 무엇을 작성할지 지정할 수 있다.
  • 라이브러리 관리와 중앙 저장소
    빌드를 실행하는 사이에, 빌드 파일에 기술된 정보를 바탕으로 필요한 라이브러리를 자동으로 다운로드하여 포함시킨다. 이를 가능하게 하는 것이 중앙 저장소이다. 중앙 저장소는 메이븐에서 이용 가능한 라이브러리를 모아서 관리하는 웹 서비스이다.
  • 테스트와 문서 생성
    엔트의 표준에는 포함되지 않았던 JUnit 테스트 및 Javadoc 문서 생성 등의 기능을 갖추고 있다.
  • 플러그인을 이용한 확장
    플러그인을 사용하면 메이븐에 기능을 추가할 수 있다.

메이븐 프로젝트 생성

메이븐에 포함된 **archetype:generate**라는 골을 이용하면, 간단하게 프로젝트의 기본 부분을 만들 수 있다. 아키타입(archetype)은 프로그램의 템플릿 모음이다.

Intellij를 이용해서도 메이븐을 기반으로 프로그램을 빌드하는 프로젝트를 생성할 수 있다. New Project -> 목록에서 Maven 선택 -> Create from archetype 체크 -> maven-archetype-quickstart 선택

메이븐에서는 mvn 명령어로 각종 조작을 할 수 있는데, 이 명령어들을 인텔리제이의 ‘Run’을 이용하여 실행할 수 있다. 실행할 내용을 컨피그레이션에 설정하면 인텔리제이의 ‘Run’으로 프로그램의 빌드와 실행, 디버그 등의 기능을 수행할 수 있다. (‘Run…’ -> Edit Configurations’ 메뉴에서 설정 가능)

pom.xml

pom.xml 파일에서 POM은 ‘Project Object Model’을 말한다. 이 파일에 프로젝트에 관한 각종 정보를 기술한다.

<project>와 기본속성

  • 모델 버전

    1
    <modeVersion>4.0.0</modeVersion>

    기본적으로 메이븐은 하위 호환성을 지원하기 때문에 이후 새로운 버전이 되더라도 이곳의 버전 번호를 바꾸면 이외 부분은 크게 수정하지 않고도 사용할 수 있다.

  • 그룹 ID

    1
    <groupId>com.jongmin</groupId>

    그룹 ID는 작성할 프로그램이 어디에 소속되어 있는지를 나타낸다.

  • 아티팩트 ID

    1
    <artifactId>mvn-app</artifactId>

    그룹 ID와 함께 프로그램을 식별하는 데 사용된다. ID이기 때문에 같은 그룹 내에서 같은 프로젝트 이름이 중복되지 않도록 주의해야 한다.

  • 버전

    1
    <version>1.0-SNAPSHOT</version>

    메이븐을 사용하는 프로젝트를 빌드하거나 패키징한 경우 여기서 지정된 번호가 생성된 프로그램의 버전으로 설정된다. 보통은 생성된 JAR 파일의 파일명에도 사용된다.

  • 패키지 종류

    1
    <packaging>jar</packaging>

    보통은 jar을 지정하지만, zip이라고 지정하면 ZIP 파일로 패키징한다.

  • 애플리케이션 이름

    1
    <name>mvn-app</name>

    작성하는 애플리케이션의 이름을 지정한다. 그룹 ID나 아티팩트 ID와 달리 유일한 값일 필요가 없다.

  • URL

    1
    <url>http://maven.apache.org</url>

    기본값으로는 메이븐 사이트의 URL이 지정되어 있다.

**<properties>**는 pom.xml에서 이용되는 속성값을 설정한다.

1
2
3
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>

기본값으로는 <project.build.sourceEncoding>라는 항목이 설정되어 있는데 이는 소스 파일의 문자 인코딩 방식을 지정한다.

<dependencies>와 의존성 설정

dependencies 태크를 통해 필요한 라이브러리를 관리할 수 있다. 여기에 의존성을 적어두면 필요한 라이브러리 등을 자동으로 다운로드하여 설치 등을 할 수 있다.

<dependency> 태그를 설정하는 것만으로 의존 라이브러리가 자동으로 로드되는 것은 메이븐의 중앙 저장소 때문이다. 중앙 저장소는 메이븐을 개발한 아파치 소프트웨어 재단이 운영하는 사이트이다.

기본적인 ‘골’

메이븐은 골을 지정하여 실행할 처리의 역할을 정한다.

1
mvn 골
  • compile

    1
    mvn compile

    자바 소스 코드 파일을 컴파일 한다. 프로젝트 폴더 내에 target 폴더가 생성된다.

  • test-compile

    1
    mvn test-compile

    유닛 테스트용 클래스를 컴파일한다. src 폴더 안의 test 안에 작성된 유닛 테스트용 소스 코드 파일을 컴파일하여, target 폴더 안에 test-class 폴더를 작성하고 그 안에 클래스 파일을 생성한다.

  • test

    1
    mvn test

    메이븐은 테스트(유닛 테스트)가 거의 표준 기능으로 포함되어 있다. 테스트를 개별적으로 실행하는 골이 test이다.작성한 유닛 테스트용 클래스를 이용하여 테스트가 실행되고 그 결과가 출력된다.

  • package

    1
    mvn package

    mvn compile을 실행하면 클래스 파일이 생성되지만, 일반적으로 자바 프로그램은 클래스파일을 그대로 배포하지는 않는다. 일반적으로 JAR 파일 등으로 패키징하여 배포한다.

    명령 한 번으로 프로그램을 컴파일하여 유닛 테스트를 실행한 후 JAR 파일로 패키징하는 처리가 모두 자동적으로 수행된다.

    실행 후 target 폴더 안에 jar 파일이 생성된다.

  • clean

    1
    mvn clean

    메이븐은 프로그램을 빌드하면서 컴파일된 클래스 파일 뿐만 아니라, 테스트, 압축을 실행하는 파일 등을 만든다. clean은 부가적으로 생성된 파일을 모두 지운다.

프로그램 실행하기

클래스가 하나인 코드는 java 명령어로도 쉽게 실행할 수 있다. 그러나 다양한 라이브러리를 이용하는 프로젝트에서는 모든 클래스 경로를 직접 지정해야 하기 때문에 java 명령어를 이용해 실행하는 일은 번거롭다.

메이븐에는 표준으로 자바 프로그램을 실행하는 골은 없다. 하지만 exec-java-plugin 플러그인을 이용하면 프로그램을 실행할 수 있다.

pom.xml의 <project> 태그 안에 있는 <dependencies> 종료 태그의 다음 행에 다음과 같이 플러그인을 추가한다.

1
2
3
4
5
6
7
8
9
10
11
12
<build>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>1.5.0</version>
<configuration>
<mainClass>com.jongmin.App</mainClass>
</configuration>
</plugin>
</plugins>
</build>

명령행에 다음과 같이 실행하면 App 클래스가 실행된다. 만약 mvn clean으로 프로젝트의 빌드 결과물을 제거한 경우 다시 mvn package로 빌드한 후에 실행한다.

1
mvn exec:java

exec-maven-plugin은 메인 클래스를 지정해야 한다. 플러그인에 정보를 지정할 때는 <configuration> 태그를 이용한다.

빌드 플러그인

<build> 태그는 빌드에 관한 정보를 기술하는 태그이다. 형태는 다음과 같다.

1
2
3
4
5
6
7
<build>
<plugins>
<plugin>...</plugin>
<plugin>...</plugin>
...
</plugins>
</build>

<build>와 <plugins> 태그는 여러 개 사용할 수 없다. 반드시 1개씩 있고, 그 안에 모든 <plugin>을 모아서 사용한다.

메이븐의 골과 플러그인

exec:java 골은 플러그인을 사용해 추가된 것이다. 사실, 지금까지 사용했던 모든 골들도 플러그인으로 추가된 것이다.

  • compile : maven-compiler-plugin
  • package : maven-jar-plugin
  • test : maven-surefire-plugin

하지만 위의 플러그인은 표준으로 포함되어 있기 때문에 플러그인이라고 의식하지 못했던 것이다.

표준이 아닌 <plugin> 태그에 의해 추가된 플러그인의 골을 지정하는 경우에는 xx:xx와 같이 요소가 둘인 경우가 일반적이다. 플러그인 하나가 여러 골을 가질 수도 있기 때문에 ‘플로그인:골’ 형태로 기술한다.

<plugin>이 필수는 아니다. 플러그인으로 추가하여 이용하는 골이라고 해서 <plugin>에 기술하지 않으면 사용하지 못하는 것은 아니다. <plugin>은 플러그인에 포함된 설정 등의 정보를 기술하는 태그이다. 그렇기 때문에 설정이 필요하지 않으면 기술할 필요가 없다.

인텔리제이에서 사용하기

플러그인을 통해 개발 도구의 프로젝트로 변환이 가능하다.

1
mvn idea:idea

위 골을 실행하면 인텔리제이에서 프로젝트를 다루는데 필요한 파일들이 생성된다.

1
mvn idea:clean

인텔리제이 프로젝트에서 인텔리제이 관련 파일을 삭제하여 원래의 메이븐 프로젝트로 돌리려면 위의 골을 실행한다.

실행 가능한 JAR 파일 만들기

앞서 mvn package로 패키징했지만 이렇게 생성된 JAR 파일은 단순히 패키징 된 것이기 때문에 실행되지는 않는다.

1
java -jar 00.jar

따라서 위의 명렁을 실행해도 00.jar에 기본 Manifest 속성이 없어 실행에 실패하게 된다.

실행 가능한 JAR 파일을 만들기 위해서는 maven-jar-plugin을 이용해 다음과 같은 <plugin> 태그를 작성하면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>버전</version>
<configuration>
<archive>
<manifest>
<addClasspath>true</addClasspath>
<mainClass>메인 클래스</mainClass>
</manifest>
</archive>
</configuration>
</plugin>

<archive> 태그는 압축에 관한 설정이다. addClasspath는 클래스 경로에 JAR 파일이 있는 경로를 추가하기 위한 태그인데 보통은 true로 지정한다.

이렇게 설정 후 다시 mvn package로 JAR 파일을 생성한 후 java -jar로 실행해보면 문제없이 실행할 수 있다.

저장소 이용

<dependency> 를 추가하는 것만으로 필요한 라이브러리를 추가해 사용할 수 있었던 것은 중앙 저장소 때문이다. 그런데 저장소가 중앙 저장소만 있는 것은 아니다. 다른 원격 저장소나 로컬 저장소도 있다.

로컬 저장소

자신이 만든 라이브러린, 그다지 유명하지 않은 라이브러리라면 아직 중앙 저장소에 공개되지 않을 수도 있다. 이러한 라이브러리는 로컬 저장소를 이용해 사용할 수 있다.

  • 원격 저장소 : 네트워크를 거쳐 서버에 접속하여 이용하는 공개된 저장소. 중앙 저장소도 원격 저장소의 한 종류이다.
  • 로컬 저장소 : 로컬 환경에 있는 저장소이다.

원격 저장소 이용

원격 저장소는 pom.xml에 <repositories> 태그 안에 저장소 정보를 기술한다.

1
2
3
4
5
6
7
<repositories>
<repository>
<id>저장소 ID</id>
<name>이름</name>
<url>저장소 주소(URL)</url>
</repository>
</repositories>

로컬 저장소에 라이브러리 추가하기

추가하고자 하는 라이브러리 프로젝트에서 다음과 같이 실행하면 target에 빌드된 JAR 파일을 로컬 저장소에 설치한다.

1
mvn install

또는 설치할 JAR 파일이 별도로 준비되어 있다면 install:install-file 골을 실행해서 지정한 라이브러리 파일을 로컬 저장소에 설치할 수 있다.

1
2
3
4
5
6
mvn install:install-file
-Dfile="라이브러리 jar의 경로"
-DgroupId="그룹 ID"
-DartifactId="아티팩트 ID"
-Dpackaging="패키징(jar)"
-Dversio="버전(1.0)"

로컬 저장소의 위치 알아보기

로컬 저장소의 위치는 다음과 같다.

1
홈 디렉터리/.m2/repository

이 폴더에는 라이브러리가 그룹 ID마다 폴더로 정리되어 있다.

람다 & 스트림

해당 포스팅의 내용은 Java의 정석 2권 - Chapter 14 람다 & 스트림에 있는 내용을 요약한 것입니다. 해당 책으로 복습하며 정리한 내용이고 문제가 된다면 바로 해당 포스팅을 삭제하도록 하겠습니다.

람다와 스트림

람다식이란?

람다식(Lambda expression)은 간단히 말해서 메서드를 하나의 ‘식(expression)’으로 표현한 것이다. 람다식은 함수를 간략하면서도 명확한 식으로 표현할 수 있게 해준다.

메서드를 람다식으로 표현하면 메서드의 이름과 반환값이 없어지므로, 람다식을 ‘익명 함수(annonymous function)’이라고도 한다.

1
2
int[] arr = new int[5];
Arrays.setAll(arr, i -> (int)(Math.random()*5)+1); // arr=[1,5,2,1,1]

위의 문장에서 ‘i -> (int)(Math.random()*5)+1)’이 람다식이다. 이 람다식이 하는 일을 메서드로 표현하면 다음과 같다.

1
2
3
int method(int i) {
return (int)(Math.random()*5) + 1;
}

위의 메서드보다 람다식이 간결하고 이해하기 쉽다. 게다가 모든 메서드는 클래스에 포함되어야 하므로 클래스도 새로 만들어야 하고, 객체도 생성해야만 비로소 이 메서드를 호출할 수 있다. 그러나 람다식은 이 모든 과정없이 오직 람다식 자체만으로도 이 메서드의 역할을 대신할 수 있다.

또한, 람다식은 메서드의 매개변수로 전달되어지는 것이 가능하고, 메서드의 결과로 반환될 수도 있다. 람다식으로 인해 메서드를 변수처럼 다루는 것이 가능해진 것이다.

람다식 작성하기

랃마식은 ‘익명 함수’답게 메서드에서 이름과 반환타입을 제거하고 매개변수 선언부와 몸통{} 사이에 ‘->’를 추가한다.

반환값이 있는 경우, return문 대신 ‘식(expression)’으로 대신 할 수 있다. 식의 연산결과가 자동으로 반환값이 된다. 이때는 ‘문장(statement)’이 아닌 ‘식’이므로 끝에 ‘;’을 붙이지 않는다.

람다식에 선언된 매개변수의 타입은 추론이 가능한 경우는 생략할 수 있는데, 대부분의 경우에 생략가능하다. 람다식에 반환타입이 없는 이유도 항상 추론이 가능하기 때문이다.

매개변수가 하나뿐인 경우에는 괄호()를 생략할 수 있다. 단, 매개변수의 타입이 있으면 괄호()를 생략할 수 없다.

마찬가지로 괄호{}안의 문장이 하나일 때는 괄호{}를 생략할 수 있다. 이 때 문장의 끝에 ‘;’를 붙이지 않아야 한다. 그러나 괄호{} 안의 문장이 return문일 경우 괄호{}를 생략할 수 없다.

함수형 인터페이스(Funtional Interface)

자바에서 모든 메서드는 클래스 내에 포함되어야 한다. 사실 람다식은 익명 클래스의 객체와 동일하다.

하나의 메서드가 선언된 인터페이스를 정의해서 람다식을 다루는 것은 기존의 자바의 규칙들을 어기지 않으면서도 자연스럽다. 그래서 인터페이스를 통해 람다식을 다루기로 결정되었으며, 람다식을 다루기 위한 인터페이스를 함수형 인터페이스(functional interface)라고 부른다.

단, 함수형 인터페이스에는 오직 하나의 추상 메서드만 정의되어 있어야 한다는 제약이 있다. 그래야 람다식과 인터페이스의 메서드가 1:1로 연결 될 수 있기 때문이다. 반면에 static 메서드와 default 메서드의 개수에는 제약이 없다.

**@FunctionalInterface**를 붙이면, 컴파일러가 함수형 인터페이스를 올바르게 정의했는지 확인해주므로, 꼭 붙이는 것이 좋다.

함수형 인터페이스 타입의 매개변수와 반환타입
함수형 인터페이스 MyFunction이 아래와 같이 정의되어 있을 때,

1
2
3
4
@FunctionalInterface
interface MyFunction {
void myMethod(); // 추상 메서드
}

메서드의 매개변수가 MyFunction 타입이면, 이 메서드를 호출할 때 람다식을 참조하는 참조변수를 매개변수로 지정해야한다는 뜻이다.

1
2
3
4
5
6
void aMethod(MyFunction f) {
f.myMethod();
}
// ...
MyFunction f = () -> System.out.println("myMethod()");
aMethod(f);

또는 참조변수 없이 직접 람다식을 매개변수로 지정하는 것도 가능하다.

1
aMethod(() -> System.out.println("myMethod()"));

메서드의 반환타입이 함수형 인터페이스라면, 이 함수형 인터페이스의 추상 메서드와 동등한 람다식을 가리키는 참조변수를 반환하거나 람다식을 직접 반환할 수 있다.

1
2
3
4
MyFunction mymethod() {
MyFunction f = () -> {};
return f;
}

람다식을 참조변수로 다룰 수 있다는 것은 메서드를 통해 람다식을 주고받을 수 있다는 것을 의미한다. 즉, 변수처럼 메서드를 주고받는 것이 가능해진 것이다. (사실상 메서드가 아니라 객체를 주고받는 것이라 달라진 것은 없다.)

람다식의 타입과 형변환
함수형 인터페이스로 람다식을 참조할 수 있는 것일 뿐, 람다식의 타입이 함수형 인터페이스의 타입과 일치하는 것은 아니다. 람다식은 익명 객체이고 익명 객체는 타입이 없다. 정확히는 타입은 있지만 컴파일러가 임의로 이름을 정하기 때문에 알 수 없는 것이다. 그래서 대입 연산자의 양변의 타입을 일치시키기 위해 형변환이 필요하다.

1
MyFunction f = (Myfunction)(() -> {});

람다식은 MyFunction 인터페이스를 직접 구현하지 않았지만, 이 인터페이스를 구현한 클래스의 객체와 완전히 동일하기 때문에 위처럼 형변환을 허용한다. 그리고 이 형변환은 생략가능하다.

람다식은 이름이 없을 뿐 객체인데도, Object 타입으로 형변환 할 수 없다. 람다식은 오직 함수형 인터페이스로만 형변환이 가능하다.

일반적인 익명 객체라면, 객체의 타입이 **’외부클래스이름$번호’**와 같은 형식으로 타입이 결정되었을 텐데, 람다식의 타입은 **’외부클래스이름$$Lambda$번호’**와 같은 형식으로 되어 있다.

외부 변수를 참조하는 람다식
람다식도 익명 객체, 즉 익명 클래스의 인스턴스이므로 람다식에서 외부에 선언된 변수에 접근하는 규칙은 익명 클래스와 동일하다.

람다식 내에서 참조하는 지역변수는 final이 붙지 않았어도 상수로 간주한다.(인스턴스 변수는 변경 가능) 람다식 내에서 지역변수를 참조하면 람다식 내에서나 다른 어느 곳에서도 이 변수의 값을 변경할 수 없다.

java.util.function 패키지

java.util.function 패키지에 일반적으로 자주 쓰이는 형식의 메서드를 함수형 인터페이스로 미리 정의해 놓았다. 매번 새로운 함수형 인터페이스를 정의하지 말고, 가능하면 이 패키지의 인터페이스를 활용하는 것이 좋다.

그래야 함수형 인터페이스에 정의된 메서드 이름도 통일되고, 재사용성이나 유지보수 측면에서도 좋다. 자주 쓰이는 가장 기본적인 함수형 인터페이스는 다음과 같다.

  • java.lang.Runnable
    • 메서드 : void run()
    • 매개변수도 없고, 반환값도 없음.
  • Supplier<T>
    • 메서드 : T get()
    • 매개변수는 없고, 반환값만 있음.
  • Consumer<T>
    • 메서드 : void accept(T t)
    • Supplier와 반대로 매개변수만 있고, 반환값이 없음
  • Function<T, R>
    • 메서드 : R apply(T t)
    • 일반적인 함수, 하나의 매개변수를 받아서 결과를 반환
  • Predicate<T>
    • 메서드 : boolean test(T t)
    • 조건식을 표현하는데 사용됨.

타입 문자 ‘T’는 ‘Type’을, ‘R’은 ‘Return Type’을 의미한다.

인터페이스 이름 앞에 접두사 ‘Bi’가 붙으면 매개변수가 두 개인 함수형 인터페이스이다.

3개 이상의 매개변수를 갖는 함수형 인터페이스를 선언한다면 직접 만들어서 서야한다.

컬렉션 프레임웍의 인터페이스에 디폴트 메서드가 추가되었다.

  • Collection
    • boolean removeIf(Predicate<E> filter)
      : 조건에 맞는 요소를 삭제
  • List
    • void replaceAll(UnaryOperator<E> operator)
      : 모든 요소를 변환하여 대체
  • Iterable
    • void forEach(Consumer<T> action)
      : 모든 요소에 작업 action을 수행
  • Map
    • V compute(K key, BiFunction<K,V,V> f)
      : 지정된 키의 값에 작업 f를 수행
    • V computeIfAbsent(K key, BiFunction<K,V> f)
      : 키가 없으면, 작업 f 수행 후 추가
    • V computeIfPresentt(K key, BiFunction<K,V,V> f)
      : 지정된 키가 있을 때, 작업 f 수행
    • V merge(K key, V value, BiFunction<V,V,V> f)
      : 모든 요소에 병합작업 f를 수행
    • void forEach(BiConsumer<K,V> action)
      : 모든 요소에 작업 action을 수행
    • void replaceAll(BiFunction<K,V,V> f)
      : 모든 요소에 치환작업 f를 수행

래퍼클래스를 사용하는 것은 비효율적이다. 그래서 보다 효율적으로 처리할 수 있도록 기본형을 사용하는 함수형 인터페이스들이 제공된다.

  • DoubleToIntfunction : AToBFunction은 입력이 A타입 출력이 B타입
  • ToIntFunction<T> : ToBFunction은 출력이 B타입이다. 입력은 generic 타입
  • intFunction<R> : AFunction은 입력이 A타입이고 출력은 generic 타입
  • ObjintConsumer<T> : ObjAFunction은 입력이 T, A타입이고 출력은 없다.

Function의 합성과 Predicate의 결합

java.util.function 패키지의 함수형 인터페이스에는 추상형메서드 외에도 디폴트 메서드와 static 메서드가 정의되어 있다.

Function의 합성
함수 f, g가 있을 때, **f.andThen(g)**는 함수 f를 먼저 적용하고, 그 다음에 함수 g를 적용한다. 그리고 **f.compose(g)**는 반대로 g를 먼저 적용하고 f를 적용한다.

1
2
3
4
Function<String, Integer> f = (s) -> Integer.parseInt(s, 16);
Function<Integer, String> g = (i) -> Integer.toBinaryString(i);
Function<String, String> h = f.andThen(g);
Function<Integer, Integer> i = f.compose(g);

Predicate의 결합
여러 조건식을 논리 연산자로 연결해서 하나의 식을 구성할 수 있는 것처럼, 여러 Predicate를 and(), or(), negate()로 연결해서 하나의 새로운 Predicate를 결합할 수 있다.

메서드 참조

람다식을 더욱 간결하게 표현할 수 있는 방법이 있다. 람다식이 하나의 메서드만 호출하는 경우에는 메서드 참조라는 방법으로 람다식을 간략히 할 수 있다.

1
2
3
4
5
/// 변환 전
Function<String, Integer> f = (String s) -> Integer.parseInt(s);

/// 변환 후
Function<String, Integer> f = Integer::parseInt;

하나의 메서드만 호출하는 람다식은 ‘클래스이름::메서드이름’ 또는 ‘참조변수::메서드이름’

으로 바꿀 수 있다.

메서드 참조는 람다식을 마치 static 변수처럼 다룰 수 있게 해준다. 메서드 참조는 코드를 간략히 하는데 유용해서 많이 사용된다.

스트림(stream)

스트림이란?

스트림은 데이터 소스를 추상화하고, 데이터를 다루는데 자주 사용되는 메서드들을 정의해 놓았다. 데이터 소스를 추상화했다는 것은, 데이터 소스가 무엇이던 간에 같은 방식으로 다룰 수 있게 되었다는 것과 코드의 재사용성이 높아진다는 것을 의미한다.

1
2
3
4
5
6
7
8
String[] strArr = {"aaa", "bbb", "ccc"};
List<String> strList = Arrays.asList(strArr);

stream<String> strStream1 = strList.stream();
stream<String> strStream2 = Arrays.stream(strArr);

strStream1.sorted().forEach(System.out::println);
strStream2.sorted().forEach(System.out::println);

스트림은 데이터 소스를 변경하지 않는다.
스트림은 데이터 소스로부터 데이터를 읽기만할 뿐, 데이터 소스를 변경하지 않는다. 필요하다면, 결과를 컬렉션이나 배열에 담아서 반환할 수도 있다.

1
List<String> sortedList = strStream2.sorted().collect(Collectors.toList());

스트림은 일회용이다.
스트림은 Iterator처럼 일회용이다. 한번 사용하면 닫혀서 다시 사용할 수 없다. 필요하다면 스트림을 다시 생성해야 한다.

스트림은 작업을 내부 반복으로 처리한다.
내부 반복이라는 것은 반복문을 메서드의 내부에 숨길 수 있다는 것을 의미한다. forEach()는 스트림에 정의된 메서드 중의 하나로 매개변수에 대입된 람다식을 데이터 소스의 모든 요소에 적용한다. (forEach()는 메서드 안에 for문을 넣어버린 것이다.)

스트림의 연산
스트림이 제공하는 다양한 연산을 이용해서 복잡한 작업들을 간단히 처리할 수 있다.

  • 중간 연산 : 연산 결과가 스트림인 연산. 스트림에 연속해서 중간 연산을 할 수 있음
  • 최종 연산 : 연산 결과가 스트림이 아닌 연산. 스트림의 요소를 소모하므로 단 한번만 가능

지연된 연산
스트림 연산에서 한 가지 중요한 점은 최종 연산이 수행되기 전까지는 중간 연산이 수행되지 않는다는 것이다. 중간 연산을 호출하는 것은 단지 어떤 작업이 수행되어야하는지를 지정해주는 것일 뿐이다. 최종 연산이 수행되어야 비로소 스트림의 요소들이 중간 연산을 거쳐 최종 연산에서 소모된다.

병렬 스트림
스트림으로 데이터를 다룰 때의 장점 중 하나가 병렬 처리가 쉽다는 것이다. 병렬 스트림은 내부적으로 fork&join을 이용해서 자동적으로 연산을 병렬로 수행한다. 모든 스트림은 기본적으로 병렬 스트림이 아니므로 병렬 스트림을 사용하려면 parallelStream() 메서드를 사용해 병렬 스트림으로 전환해야 한다.

스트림 만들기

스트림의 소스가 될 수 있는 대상은 배열, 컬렉션, 임의의 수 등 다양하다.

컬렉션
컬렉션의 최고 조상인 Collection에 stream()이 정의되어 있다. 그래서 Collection의 자손인 List와 Set을 구현한 컬렉션 클래스들은 모두 이 메서드로 스트림을 생성할 수 있다. stream()은 해당 컬렉션을 소스로 하는 스트림을 반환한다.

1
Stream<T> Collection.stream()

배열
배열을 소스로 하는 스트림을 생성하는 메서드는 다음과 같이 Stream과 Arrays에 static 메서드로 정의되어 있다.

1
2
3
4
Stream<T> Stream.of(T... values)
Stream<T> Stream.of(T[])
Stream<T> Arrays.stream(T[])
Stream<T> Arrays.stream(T[] array, int startInclusive, int endExclusive)

그리고 int, long, double과 같은 기본형 배열을 소스로 하는 스트림을 생성하는 메서드도 있다.

1
2
3
4
IntStream IntStream.of(int ...values) // 가변인자
IntStream IntStream.of(int[])
IntStream Arrays.stream(int[])
IntStream Arrays.stream(int[] array, int startInclusive, int endExclusive)

특정 범위의 정수
IntStream과 LongStream은 지정된 범위의 연속된 정수를 스트림으로 생성해서 반환하는 range()와 rangeClosed()를 가지고 있다.

임의의 수
난수를 생성하는데 사용하는 Random 클래스에는 해당 타입의 난수들로 이루어지는 스트림을 반환하는 인스턴스 메서드들이 포함되어 있다.

스트림의 중간연산

스트림 자르기 - skip(), limit()
skip()과 limit()은 스트림의 일부를 잘라낼 때 사용한다.

스트림의 요소 걸러내기 - filter(), distinct()
distinct()는 스트림에서 중복된 요소들을 제거하고, filter()는 주어진 조건(Predicate)에 맞지 않는 요소를 걸러낸다.

정렬 - sorted()
스트림을 정렬할 때는 sorted()를 사용하면 된다.

sorted()는 지정된 Comparator로 스트림을 정렬하는데, Comparator대신 int값을 반환하는 람다식을 사용하는 것도 가능하다. Comparator를 지정하지 않으면 스트림 요소의 기본 정렬 기준(Comparable)으로 정렬한다. 단, 스트림의 요소가 Comparable을 구현한 클래스가 아니면 예외가 발생한다.

JDK 1.8부터 Comparator 인터페이스에 static 메서드와 디폴트 메서드가 많이 추가되었는데, 이 메서드들을 이용하면 정렬이 쉬워진다. 이 메서드들은 모두 Comparator<T>를 반환한다.

변환 - map()
스트림의 요소에 저장된 값 중에서 원하는 필드만 뽑아내거나 특정 형태로 변환해야 할 때가 있다. 이 때 사용하는 것이 바로 map()이다. 이 메서드의 선언부는 아래와 같으며, 매개변수로 T타입을 R타입으로 변환해서 반환하는 함수를 지정해야한다.

1
Stream<R> map(Function<? super T,? extends R> mapper)

조회 - peek()
연산과 연산 사이에 올바르게 처리되었는지 확인하고 싶다면, peek()를 사용한다. forEach()와 달리 스트림의 요소를 소모하지 않으므로 연산 사이에 여러 번 끼워 넣어도 문제가 되지 않는다.

filter()나 map()의 결과를 확인할 때 유용하게 사용될 수 있다.

mapToInt(), mapToLong(), mapToDouble()
map()은 연산의 결과로 Stream<T> 타입의 스트림을 반환하는데, 스트림의 요소를 숫자로 변환하는 경우 IntStream과 같은 기본형 스트림으로 변환하는 것이 더 유용할 수 있다.

count()만 지원하는 Stream<T>와 달리 IntStream과 같은 기본형 스트림은 아래와 같이 숫자를 다루는데 편리한 메서드들을 제공한다.

  • Int sum() : 스트림의 모든 요소의 총합
  • OptionalDouble average() : sum() / (double)count()
  • OptionalInt max() : 스트림의 요소 중 제일 큰 값
  • OptionalInt min() : 스트림의 요소 중 제일 작은 값

위의 메서드들은 최종연산이기 때문에 호출 후 스트림이 닫힌다는 점을 주의해야 한다.

sum()과 average()를 모두 호출해야할 때, 스트림을 또 생성해야하므로 불편하다. 그래서 summaryStatistics()라는 메서드가 따로 제공된다.

반대로 IntStream을 Stream<T>로 변환할 때는 mapToObj()를, Stream<Integer>로 변환할 때는 boxed()를 사용한다.

1
2
3
IntStream intStream = new Random().ints(1, 46); // 1~45 사이의 정수
Stream<String> lottoStream = intStream.distinct().limit(6).sorted().mapToObj(i -> i + ",");
lottoStream.forEach(System.out::print);

flatMap() - Stream<T[]>를 Stream<T>로 변환
스트림의 요소가 배열이거나 map()의 연산결과가 배열인 경우, 즉 스트림의 타입이 Stream<T[]>인 경우, Stream<T>로 다루는 것이 더 편리할 때가 있다. 그럴 때는 map()대신 flatMap()을 사용하면 된다.

1
2
3
4
Stream<String[]> strArrStrm = Stream.of(
new String[]{"abc", "def", "ghi"},
new String[]{"ABC", "GHI", "JKLMN"}
);
1
Stream<String> strStrm = strArrStrm.flatMap(Arrays::stream);

요소의 타입이 Stream<String>인 스트림(Stream<Stream<String>>)이 있을때, 이 스트림을 Stream<T>으로 변환하려면 다음과 같이 map()과 flatMap()을 함께 사용해야 한다.

1
2
3
Stream<String> strStream = strStrm
.map(s -> s.toArray(String[]::new)) // Stream<Stream<String>> -> Stream<String[]>
.flatMap(Arrays::stream); // Stream<String[]> -> Stream<String>

toArray()는 스트림을 배열로 변환해서 반환한다. 매개변수를 지정하지 않으면 Object[]을 반환하므로 특정 타입의 생성자를 지정해줘야 한다. 위에서는 String배열의 생성자(String[]::new)를 지정하였다.

Optional<T>와 OptionalInt

최종 연산의 결과 타입이 Optional인 경우가 있다. Optional<T>은 지네릭 클래스로 ‘T타입의 객체’를 감싸는 래퍼 클래스이다. 그래서 Optional타입의 객체에는 모든 타입의 참조변수를 담을 수 있다.

1
2
3
4
public final class Optional<T> {
private final T value; // T타입의 참조변수
...
}

최종 연산의 결과를 그냥 반환하는게 아니라 Optional 객체에 담아서 반환한다. 이처럼 객체에 담아서 반환을 하면, 반환된 결과가 null인지 매번 if문으로 체크하는 대신 Optional에 정의된 메서드를 통해서 간단히 처리할 수 있다.

Objects클래스에 isNull(), nonNull(), requireNonNull()과 같은 메서드가 있는 것도 널 체크를 위한 if문을 메서드 안으로 넣어서 코드의 복잡도를 낮추기 위한 것이다.

Optional 객체 생성하기
Optional 객체를 생성할 때는 of() 또는 ofNullable()을 사용한다.

1
2
String str = "abc";
Optional<String> optVal = Optional.of(str);

만일 참조변수의 값이 null일 가능성이 있으면, of()대신 ofNullable()을 사용해야 한다. of()는 매개변수의 값이 null이면 NullPointerException을 발생하기 때문이다.

Optional<T>타입의 참조변수를 기본값으로 초기화 할 때는 empty()를 사용한다. null로 초기화하는 것이 가능하지만, empty()로 초기화하는 것이 바람직하다.

1
2
Optional<String> optVal = null; // null로 초기화
Optional<String> optVal = Optional.<String>empty(); // 빈 객체로 초기화

Optional 객체의 값 가져오기

1
2
3
Optional<String> optVal = Optional.of("abc");
String str1 = optVal.get(); // optVal에 저장된 값을 반환. null이면 예외 발생
String str2 = optVal.orElse(""); // optVal에 저장된 값이 null일 때는, ""을 반환

**orElse()**의 변형으로 null을 대체할 값을 반환하는 람다식을 지정할 수 있는 **orElseGet()**과 null일 때 지정된 예외를 발생시키는 **orElseThrow()**가 있다.

1
2
String str3 = optVal2.orElseGet(String::new); // () -> new String()과 동일
String str4 = optVal2.orElseThrow(NullPointerException::new); // null이면 예외 발생

Stream처럼 Optional 객체에도 filter()와 map(), 그리고 flatMap()을 사용할 수 있다.

**isPresent()**는 Optional 객체의 값이 null이면 false를, 아니면 true를 반환한다. **ifPresent()**은 값이 있으면 주어진 람다식을 실행하고 , 없으면 아무 일도 하지 않는다. ifPresent()는 Optional<T>를 반환하는 findAny()나 findFirst()와 같은 최종 연산과 잘 어울린다.

스트림의 최종 연산

최종 연산은 스트림의 요소를 소모해서 결과를 만들어낸다. 그래서 최종 연산후에는 스트림이 닫히게 되고 더 이상 사용할 수 없다. 최종 연산의 결과는 스트림 요소의 합과 같은 단일 값이거나, 스트림의 요소가 담긴 배열 또는 컬렉션일 수 있다.

forEach()
반환 타입이 void이므로 스트림의 요소를 출력하는 용도로 많이 사용된다.

1
void forEach(Consumer<? super T> action)

조건 검사 - allMatch(), anyMatch(), noneMatch(), findFirst(), findAny()
스트림의 요소에 대해 지정된 조건에 모든 요소가 일치하는지, 일부가 일치하는지 아니면 어떤 요소도 일치하지 않는지 확인하는데 사용할 수 있는 메서드들이다. 이 메서드들은 모두 매개변수로 Predicate를 요구하며, 연산결과로 boolean을 반환한다.

통계 - count(), sum(), average(), max(), min()
IntStream과 같은 기본형 스트림에는 스트림의 요소들에 대한 통계 정보를 얻을 수 있는 메서드들이 있다. 대부분의 경우 위의 메서드를 사용하기보다 기본형 스트림으로 변환하거나 reduce()와 collect()를 사용해 통계 정보를 얻는다.

리듀싱 - reduce()
스트림의 요소를 줄여나가면서 연산을 수행하고 최종결과를 반환한다. 처음 두 요소를 가지고 연산한 결과를 가지고 그 다음 요소와 연산한다. 그래서 매개변수의 타입이 BinaryOperator<T>인 것이다. 이 과정에서 스트림의 요소를 하나씩 소모하게 되며, 스트림의 모든 요소를 소모하게 되면 그 결과를 반환한다.

최종 연산 count()와 sum() 등은 내부적으로 모두 reduce()를 이용해서 작성되어 있다.

1
2
3
4
int count = intStream.reduce(0, (a,b) -> a + 1); // count()
int sum = intStream.reduce(0, (a,b) -> a + b); // sum()
int max = intStream.reduce(Integer.MIN_VALUE, (a,b) -> a>b ? a:b); // max()
int min = intStream.reduce(Integer.MAX_VALUE, (a,b) -> a<b ? a:b); // min()

Collect()

**collect()는 스트림의 요소를 수집하는 최종 연산으로 리듀싱(reducing)과 유사하다. **collect()가 스트림의 요소를 수집하려면, 어떻게 수집할 것인가에 대한 방법이 정의되어 있어야 하는데, 이 방법을 정의한 것이 바로 컬렉터(collector)이다.

컬렉터는 Collector 인터페이스를 구현한 것으로, 직접 구현할 수도 있고 미리 작성된 것을 사용할 수도 있다. Collectors 클래스는 미리 작성된 다양한 종류의 컬렉터를 반환하는 static 메서드를 갖고 있다.

  • collect() : 스트림의 최종연산, 매개변수로 컬렉터를 필요로 한다.
  • Collector : 인터페이스, 컬렉터는 이 인터페이스를 구현해야 한다.
  • Collectors : 클래스, static 메서드로 미리 작성된 컬렉터를 제공한다.

스트림을 컬렉션과 배열로 변환 - toList(), toSet(), toMap(), toCollection(), toArray()
List나 Set이 아닌 특정 컬렉션을 지정하려면, toCollection()에 해당 컬렉션의 생성자 참조를 매개변수로 넣어주면 된다.

1
2
3
4
List<String> names = stuStream.map(Student::getName)
.collect(Collectors.toList());
ArrayList<String> list = names.stream()
.collect(Collectors.toCollection(ArrayList::new));

Map은 키와 값의 쌍으로 저장해야하므로 객체의 어떤 필드를 키로 사용할지와 값으로 사용할지를 지정해줘야 한다.

1
Map<String, Person> map = personStream.collect(Collectors.toMap(p->p.getRegId(), p->p));

스트림에 저장된 요소들을 ‘T[]’ 타입의 배열로 변환하려면, toArray()를 사용하면 된다. 단, 해당 타입의 생성자 참조를 매개변수로 지정해줘야 한다. 만일 매개변수를 지정하지 않으면 반환되는 배열의 타입은 ‘Object[]’이다.

1
2
3
Student[] stuNames = studentStream.toArray(Student[]::new); // OK
Student[] stuNames = studentStream.toArray(); // 에러
Object[] stuNames = studentStream.toArray(); // OK

통계 - countint(), summingInt(), averagingInt(), maxBy(), minBy()
최종 연산들이 제공하는 통계 정보를 collect()로 똑같이 얻을 수 있다.

리듀싱 - reducing()
리듀싱 역시 collect()로 가능하다.

1
2
3
4
5
6
7
IntStream intStream = new Random().ints(1, 46).distinct().limit(6);

OptionalInt max = intStream.reduce(Integer::max);
Optional<Integer> max = intStream.boxed().collect(reducing(Integer::max));

long sum = intStream.reduce(0, (a, b) -> a + b);
long sum = intStream.boxed().collect(reducing(0, (a, b) -> a + b));
1
2
int grandTotal = stuStream.map(Student::getTotalScore).reduce(0, Integer::sum);
int grandTotal = stuStream.collect(reducing(0, Student::getTotalScore, Integer::sum));

문자열 결합 - joining()
문자열 스트림의 모든 요소를 하나의 문자열로 연결해서 반환한다. 구분자를 지정해줄 수도 있고, 접두사와 접미사도 가능하다. 스트림의 요소가 String이나 StringBuffer처럼 CharSequence의 자손인 경우에만 결합이 가능하므로 스트림의 요소가 문자열이 아닌 경우에는 먼저 map()을 이용해서 스트림의 요소를 문자열로 변환해야 한다.

만일 map()없이 스트림에 바로 joining()하면, 스트림의 요소에 toString()을 호출한 결과를 결합한다.

그룹화와 분할 - groupingBy, partitioningBy()
그룹화는 스트림의 요소를 특정 기준으로 그룹화하는 것을 의미하고, 분할은 스트림의 요소를 두 가지, 지정된 조건에 일치하는 그룹과 일치하지 않는 그룹으로의 분할을 의미한다. 스트림을 두 개의 그룹으로 나눠야 한다면, partitioningBy()로 분할하는 것이 더 빠르다. 그 외에는 groupingBy()를 쓰면 된다. 그룹화와 분할의 결과는 Map에 반환된다.

참고

Collection Framework

해당 포스팅의 내용은 Java의 정석 2권 - Chapter 11 컬렉션 프레임웍에 있는 내용을 요약한 것입니다. 해당 책으로 복습하며 정리한 내용이고 문제가 된다면 바로 해당 포스팅을 삭제하도록 하겠습니다.

컬렉션 프레임웍

Java API 문서에서는 컬렉션 프레임웍을 **’데이터 군(group)을 다루고 표현하기 위한 단일화된 구조’**라고 정의하고 있다.

JDK 1.2부터 컬렉션 프레임웍이 등장하면서 다양한 종류의 컬렉션 클래스가 추가되고 모든 컬렉션 클래스를 표준화된 방식으로 다룰 수 있도록 체계화되었다.

컬렉션 프레임웍의 핵심 인터페이스

컬렉션 프리임웍에서는 컬렉션(데이터 그룹)을 크게 3가지 타입으로 구분하여 3가지 인터페이스를 정의했다. 그리고 인터페이스 중 List와 Set의 공통된 부분을 다시 뽑아서 새로운 인터페이스은 Collection을 추가로 정의하였다.

인터페이스 List와 Set을 구현한 컬렉션 클래스들은 서로 많은 공통부분이 있어서, 공통된 부분을 다시 뽑아 Collection 인터페이스를 정의할 수 있었지만 Map 인터페이스는 이들과는 전혀 다른 형태로 컬렉션을 다루기 때문에 같은 상속계층도에 포함되지 못했다.

  • List : 순서가 있는 데이터의 집합. 데이터의 중복을 허용한다.
    • 구현 클래스 : ArrayList, LinkedList, Stack, Vector 등
  • Set : 순서를 유지하지 않는 데이터의 집함. 데이터의 중복을 허용하지 않는다.
    • 구현 클래스 : HashSet, TreeSet 등
  • Map : 키(key)와 값(value)의 쌍(pair)으로 이루어진 데이터의 집합. 순서는 유지되지 않으며, 키는 중복을 허용하지 않고, 값은 중복을 허용한다.
    • 구현 클래스 : HashMap, TreeMap, Hashtable, Properties 등

Vector, Stack, Hashtable, Properties와 같은 클래스들은 컬렉션 프레임웍이 만들어지기 이전부터 존재하던 것이기 때문에 컬렉션 프레임웍의 명명법을 따르지 않는다.

Vector나 Hashtable과 같은 기존의 컬렉션 클래스들은 호환을 위해, 설계를 변경해서 남겨두었지만 가능하면 사용하지 않는 것이 좋다. 그 대신 새로 추가된 ArrayList와 HashMap을 사용하자.

Collection 인터페이스

List와 Set의 조상인 Collection 인터페이스에는 다음과 같은 메서드들이 정의되어 있다. Collection 인터페이스는 컬렉션 클래스에 저장된 데이터를 읽고, 추가하고 삭제하는 등 컬렉션을 다루는데 가장 기본적인 메서드들을 정의하고 있다.

  • boolean add(Object o) : 지정된 객체(o)를 Collection에 추가한다.
  • boolean addAll(Collection c) : 지정된 Collection(c)의 객체들을 Collection에 추가한다.
  • void clear() : Collection의 모든 객체를 삭제한다.
  • boolean contains(Object o) : 지정된 객체(o)가 Collection에 포함되어 있는지 확인한다.
  • boolean equals(Object o) : 동일한 Collection인지 비교한다.
  • int hashCode() : Collection의 hash code를 반환한다.
  • boolean isEmpty() : Collection이 비어있는지 확인한다.
  • Iterator iterator() : Collection의 Iterator를 얻어서 반환한다.
  • boolean remove(Object o) : 지정된 객체를 삭제한다.
  • int size() : Collection에 저장된 객체의 개수를 반환한다.
  • Object[] toArray() : Collection에 저장된 객체를 객체배열(Object[])로 반환한다.
  • Object[] toArray(Object[] a) : 지정된 배열에 Collection의 객체를 저장해서 반환한다.

List 인터페이스

List 인터페이스는 중복을 허용하면서 저장순서가 유지되는 컬렉션을 구현하는데 사용된다.

  • void add(int index, Object element) : 지정된 위치(index)에 객체(element) 또는 컬렉션에 포함된 객체들을 추가한다.
  • Object get(int index) : 지정된 위치(index)에 있는 객체를 반환한다.
  • int indexOf(Object o) : 지정된 객체의 위치(index)를 반환한다. (List의 첫 번째 요소부터 순방향으로 찾는다.)
  • lastIndexOf(Object o) : 지정된 객체의 위치(index)를 반환한다. (List의 마지막 요소부터 역방향으로 찾는다.)
  • ListIterator listIterator() : List의 객체에 접근할 수 있는 ListIterator를 반환한다.
  • Object remove(int index) : 지정된 위치(index)에 있는 객체를 삭제하고 삭제된 객체를 반환한다.
  • Object set(int index, Object element) : 지정된 위치(index)에 객체(element)를 저장한다.
  • void sort(Comparator c) : 지정된 비교자(comparator)로 List를 정렬한다.
  • List subList(int fromIndex, int toIndex) : 지정된 범위(fromIndex 부터 toIndex)에 있는 객체를 반환한다.

Set 인터페이스

Set 인터페이스는 중복을 허용하지 않고 저장순서가 유지되지 않는 컬렉션 클래스를 구현하는데 사용된다.

Map 인터페이스

Map 인터페이스는 키(key)와 값(value)을 하나의 쌍으로 묶어서 저장하는 컬렉션 클래스를 구현하는 데 사용된다. 키는 중복될 수 없지만 값은 중복을 허용한다. 구현 클래스로는 Hashtable, HashMap, LinkedHashMap, SortedMap, TreeMap 등이 있다.

  • void clear() : Map의 모든 객체를 삭제한다.
  • boolean containsKey(Object key) : 지정된 key객체와 일치하는 Map의 Key객체가 있는지 확인한다.
  • boolean containsValue(Object value) : 지정된 value객체와 일치하는 Map의 Value객체가 있는지 확인한다.
  • Set entrySet() : Map에 저장되어 있는 key-value 쌍을 Map.Entry 타입의 객체로 저장한 Set으로 반환한다.
  • booelan equals(Object o) : 동일한 Map인지 비교한다.
  • Object get(Object key) : 지정한 key객체에 대응하는 value객체를 찾아서 반환한다.
  • int hashCode() : 해시코드를 반환한다.
  • boolean isEmpty() : Map이 비어있는지 확인한다.
  • Set keySet() : Map에 저장된 모든 Key객체를 반환한다.
  • Object put(Object key, Object value) : Map에 value객체를 key객체에 연결(mapping)하여 저장한다.
  • void putAll(Map t) : 지정된 Map의 모든 key-value 쌍을 추가한다.
  • Object remove(Object key) : 지정한 key객체와 일치하는 key-value객체를 삭제한다.
  • int size() : Map에 저장된 key-value 쌍의 개수를 반환한다.
  • Collection values() : Map에 저장된 모든 value객체를 반환한다.

Map 인터페이스에서 값(value)은 중복을 허용하기 때문에 Collection 타입으로 반환하고, 키(key)는 중복을 허용하지 않기 때문에 Set 타입으로 반환한다.

Map.Entry 인터페이스

Map.Entry 인터페이스는 Map 인터페이스의 내부 인터페이스이다. 내부 클래스와 같이 인터페이스도 인터페이스 안에 인터페이스를 정의하는 내부 인터페이스(inner interface)를 정의하는 것이 가능하다.

Map에 저장되는 key-value 쌍을 다루기 위해 내부적으로 Entry 인터페이스를 정의해 놓았다.

  • boolean equals(Object o) : 동일한 Entry인지 비교한다.
  • Object getKey() : Entry의 key객체를 반환한다.
  • Object getValue() : Entry의 value객체를 반환한다.
  • int hashCode() : Entry의 해시코드를 반환한다.
  • Object setValue(Object value) : Entry의 value객체를 지정된 객체로 바꾼다.

ArrayList

List 인터페이스를 구현하기 때문에 데이터의 저장순서가 유지되고 중복을 허용한다는 특징을 갖는다.

ArrayList는 기존의 Vector를 개선한 것으로 Vector와 구현원리와 기능적인 측면은 동일하다.

ArrayList는 Object배열을 이용해서 데이터를 순차적으로 저장한다. 계속 배열에 순서대로 저장되며, 배열에 더 이상 저장할 공간이 없으면 보다 큰 새로운 배열을 생성해서 기존의 배열에 저장된 내용을 새로운 배열로 복사한 다음에 저장된다.

(Vector는 capacity가 부족할 경우 자동적으로 기존의 크기보다 2배의 크기로 증가된다. 그러나 생성자 Vector(int initialCapacity, int capacityIncrement)를 사용해서 인스턴스를 생성한 경우에는 지정해준 capacityIncrement만큼 증가하게 된다.)

배열은 크기를 변경할 수 없기 때문에 ArrayList나 Vector 같이 배열을 이용한 자료구조는 데이터를 읽어오고 저장하는 데는 효율이 좋지만, 용량을 변경해야 할 때는 새로운 배열을 생성한 후 기존의 배열로부터 새로 생성된 배열로 데이터를 복사해야하기 때문에 상당히 효율이 떨어진다는 단점을 가지고 있다.

LinkedList

배열은 가장 기본적인 형태의 자료구조로 구조가 간단하며 사용하기 쉽고 데이터를 읽어오는데 걸리는 시간(접근시간, access time)이 가장 빠르다는 장점을 가지고 있지만 다음과 같은 단점도 가지고 있다.

  1. 크기를 변경할 수 없다.
    • 크기를 변경할 수 없으므로 새로운 배열을 생성해서 데이터를 복사하는 작업이 필요 하다.
    • 실행속도를 향상시키기 위해서는 충분히 큰 크기의 배열을 생성해야 하므로 메모리가 낭비된다.
  2. 비순차적인 데이터의 추가 또는 삭제에 시간이 많이 걸린다.
    • 차례대로 데이터를 추가하고 마지막에서부터 데이터를 삭제하는 것은 빠르지만,
    • 배열의 중간에 데이터를 추가하려면, 빈자리를 만들기 위해 다른 데이터들을 복사해서 이동해야 한다.

이러한 배열의 단점을 보완하기 위해서 링크드 리스트(linked list)라는 자료구조가 고안되었다. 배열은 모든 데이터가 연속적으로 존재하지만 링크드 리스트는 불연속적으로 존재하는 데이터를 서로 연결(link)한 형태로 구성되어 있다.

링크드 리스트의 각 요소(node)들은 자신과 연결된 다음 요소에 대한 참조(주소값)와 데이터로 구성되어 있다.

1
2
3
4
class Node {
Node next; // 다음 요소의 주소를 저장
Object obj; // 데이터를 저장
}

링크드 리스트는 이동방향이 단방향이기 때문에 다음 요소에 대한 접근은 쉽지만 이전요소에 대한 접근은 어렵다. 이 점을 보완한 것이 더블 링크드 리스트(이중 연결리스트, doubly linked list)이다.

더블 링크드 리스트는 링크드 리스트보다 각 요소에 대한 접근과 이동이 쉽기 때문에 링크드 리스트보다 더 많이 사용된다.

1
2
3
4
5
class Node {
Node next; // 다음 요소의 주소를 저장
Node previous; // 이전 요소의 주소를 저장
Object obj; // 데이터를 저장
}

더블 링크드 리스트의 접근성을 보다 향상시킨 것이 ‘더블 써큘러 링크드 리스트(이중 연결형 연결 리스트)’이다. 단순히 더블 링크드 리스트의 첫 번째 요소와 마지막 요소를 서로 연결시킨 것이다.

실제로 LinkedList 클래스는 이름과 달리 ‘링크드 리스트’가 아닌 ‘더블 링크드 리스트’로 구현되어 있는데, 이는 링크드 리스트의 단점인 낮은 접근성(accessability)을 높이기 위한 것이다.

  1. 순차적으로 추가/삭제하는 경우에는 ArrayList가 LinkedList보다 빠르다.
    만약 ArrayList의 크기가 충분하지 않으면, 새로운 크기의 ArrayList를 생성하고 데이터를 복사하는 일이 발생하게 되므로 순차적으로 데이터를 추가해도 ArrayList보다 LinkedList가 더 빠를 수 있다.
    순차적으로 삭제한다는 것은 마지막 데이터부터 역순으로 삭제해나간다는 것을 의미하며, ArrayList는 마지막 데이터부터 삭제할 경우 각 요소들의 재배치가 필요하지 않기 때문에 상당히 빠르다. (단지 마지막 요소의 값을 null로만 바꾸면 되기 때문이다.)

  2. 중간 데이터를 추가/삭제하는 경우에는 LinkedList가 ArrayList보다 빠르다.
    LinkedList는 각 요소간의 연결만 변경해주면 되기 때문에 처리속도가 상당히 빠르다. 반면에 ArrayList는 각 요소들을 재배치하여 추가할 공간을 확보하거나 빈 공간을 채워야하기 때문에 처리속도가 늦다. 사실 데이터의 개수가 그리 크지 않다면 어느 것을 사용해도 큰 차이가 나지는 않는다.

  3. 데이터의 개수가 많아질수록 데이터를 읽어 오는 시간, 즉 접근시간(access time)은 ArrayList가 LinkedList보다 빠르다.
    배열의 경우 만일 n번째 원소의 값을 얻어 오고자 한다면 단순히 아래와 같은 수식을 계산함으로써 해결된다. (배열은 각 요소들이 연속적으로 메모리상에 존재하기 때문이다.)

    n번째 데이터의 주소 = 배열의 주소 + n * 데이터 타입의 크기

    그러나, LinkedList는 불연속적으로 위치한 각 요소들이 서로 연결된 것이 아니기 때문에 처음부터 n번째 데이터까지 차례대로 따라가야만 원하는 값을 얻을 수 있다.

Stack과 Queue

순차적으로 데이터를 추가하고 삭제하는 스택에는 ArrayList와 같은 배열기반의 컬렉션 클래스가 적합하지만, 큐는 데이터를 꺼낼 때 항상 첫 번째 저장된 데이터를 삭제하므로, ArrayList와 같은 배열기반의 컬렉션 클래스를 사용한다면 데이터를 꺼낼 때마다 빈 공간을 채우기 위해 데이터의 복사가 발생하므로 비효율적이다. 그래서 큐는 ArrayList보다 데이터의 추가/삭제가 쉬운 LinkedList로 구현하는 것이 더 적합하다.

  • Stack의 메서드
    • boolean empty() : Stack이 비어있는지 알려준다.
    • Object peek() : Stack의 맨 위에 저장된 객체를 반환. pop()과 달리 Stack에서 객체를 꺼내지는 않음.
    • Object pop() : Stack의 맨 위에 저장된 객체를 꺼낸다.
    • Object push(Object item) : Stack에 객체(item)를 저장한다.
    • int search(Object o) : Stack에서 주어진 객체(o)를 찾아서 그 위치를 반환. 못찾으면 -1을 반환.
  • Queue의 메서드
    • boolean add(Object o) : 지정된 객체를 Queue에 추가한다.
    • Object remove() : Queue에서 객체를 꺼내 반환.
    • Object element() : 삭제없이 요소를 읽어온다.
    • boolean offer(Object o) : Queue에 객체를 저장.
    • Object poll() : Queue에서 객체를 꺼내서 반환. 비어있으면 null을 반환
    • Object peek() : 삭제없이 요소를 읽어 온다. Queue가 비어있으면 null을 반환

자바에서는 스택을 Stack클래스로 구현하여 제공하고 있지만 큐는 Queue인터페이스로만 정의해 놓았을 뿐 별도의 클래스를 제공하고 있지 않다. 대신 Queue인터페이스를 구현한 클래스들이 있어서 이 들 중의 하나를 선택해서 사용하면 된다.

PriorityQueue

Queue인터페이스의 구현체 중의 하나로, 저장한 순서에 관계없이 우선순위(priority)가 높은 것부터 꺼내게 된다는 특징이 있다. 그리고 null은 저장할 수 없다.

Deque(Double-Ended Queue)

Queue의 변형으로, 한 쪽 끝으로만 추가/삭제할 수 있는 Queue와 달리, Deque은 양쪽 끝에 추가/삭제가 가능하다. Deque의 조상은 Queue이며, 구현체로는 ArrayDeque와 LinkedList 등이 있다.

덱은 스택과 큐를 하나로 합쳐놓은 것과 같으며 스택으로 사용할 수도 있고, 큐로 사용할 수도 있다.

Iterator, ListIterator, Enumeration

Iterator, ListIterator, Enumeration은 모두 컬렉션에 저장된 요소를 접근하는데 사용되는 인터페이스이다. Enumeration은 Iterator의 구버젼이며, ListIterator는 Iterator의 기능을 향상 시킨 것이다.

Iterator

컬렉션 프레임웍에서는 컬렉션에 저장된 요소들을 읽어오는 방법을 표준화하였다. 컬렉션에 저장된 각 요소에 접근하는 기능을 가진 Iterator인터페이스를 정의하고, Collection인터페이스에는 Iterator를 반환하는 iterator()를 정의하고 있다.

iterator()는 Collection인터페이스에 정의된 메서드이므로 Collection인터페이스의 자손인 List와 Set에도 포함되어 있다. 그래서 List나 Set인터페이스를 구현하는 컬렉션은 iterator()가 각 컬렉션의 특징에 알맞게 작성되어 있다.

  • boolean hasNext() : 읽어 올 요소가 남아있는지 확인한다.
  • Object next() : 다음 요소를 읽어 온다. next()를 호출하기 전에 hasNext()를 호출해서 읽어 올 요소가 있는지 확인하는 것이 안전하다.
  • void remove() : next()로 읽어 온 요소를 삭제한다.

ListIterator와 Enumeration

Enumeration은 컬렉션 프레임웍이 만들어지기 이전에 사용하던 것으로 Iterator의 구버젼이라고 생각하면 된다.

ListIterator는 Iterator를 상속받아서 기능을 추가한 것으로, 컬렉션의 요소에 접근할 때 Iterator는 단방향으로만 이동할 수 있는 데 반해 ListIterator는 양방향으로의 이동이 가능하다. 다만 List인터페이스를 구현한 컬렉션에서만 사용할 수 있다.

Arrays

Arrays클래스에는 배열을 다루는데 유용한 메서드가 정의되어 있다. Arrays에 정의된 메서드는 모두 static메서드이다.

배열의 복사 - copyOf(), copyOfRagne()

copyOf()는 배열 전체를, copyOfRange()는 배열의 일부를 복사해서 새로운 배열을 만들어 반환한다. copyOfRange()에 지저왼 범위의 끝은 포함되지 안는다.

배열 채우기 - fill(), setAll()

fill()은 배열의 모든 요소를 지정된 값으로 채운다. setAll()은 배열을 채우는데 사용할 함수형 인터페이스를 매개변수로 받는다. 이 메서드를 호출할 때는 함수형 인터페이스를 구현한 객체를 매개변수로 지정하던가 아니면 람다식을 지정해야 한다.

배열의 정렬과 검색 - sort(), binarySearch()

sort()는 배열을 정렬할 때, 그리고 배열에 저장된 요소를 검색할 때는 binarySearch()를 사용한다. binarySearch()는 배열에서 지정된 값이 저장된 위치(index)를 찾아서 반환하는데, 반드시 배열이 정렬된 상태이어야 올바른 결과를 얻는다. (검색한 값과 일치하는 요소가 여러 개 있다면, 이 중 어떤 것의 위치가 반환될지는 알 수 없다.)

문자열의 비교와 출력 - equals(), toString(), deepEquals(), deepToString()

toString()은 배열의 모든 요소를 문자열로 편하게 출력할 수 있다. toString은 일차원 배열에만 사용할 수 있으므로, 다차원 배열에서는 deepToString()을 사용해야 한다. deepToString()은 배열의 모든 요소를 재귀적으로 접근해서 문자열을 구성하므로 2차원뿐만 아니라 3차원 이상의 배열에 대해서도 동작한다.

equals()는 두 배열에 저장된 모든 요소를 비교해서 같으면 true, 다르면 false를 반환한다. equals()도 일차원 배열에만 사용가능하므로, 다차원 배열의 비교에는 deepEquals()를 사용해야 한다.

배열을 List로 변환 - asList(Object… a)

asList()는 배열을 List에 담아서 반환한다. 한 가지 주의할 점은 asList()가 반환한 List의 크기를 변경할 수 없다는 것이다. 저장된 내용은 변경 가능하나, 추가 또는 삭제가 불가능하다. 만약 크기를 변경할 수 있는 List가 필요하다면 다음과 같이 하면 된다.

1
List list = new ArrayList(Arrays.asList(1, 2, 3, 4, 5));

parallelXXX(), spliterator(), stream()

parallel로 시작하는 이름의 메서드는 빠른 결과를 얻기 위해 여러 쓰레드가 작업을 나누어 처리하도록 한다. spliterator()는 여러 쓰레드가 처리할 수 있게 하나의 작업을 여러 작업으로 나누는 Spliterator를 반환하며, stream()은 컬렉션을 스트림으로 변환한다.

Comparator와 Comparable

ComparatorComparable은 모두 인터페이스로 컬렉션을 정렬하는데 필요한 메서드를 정의하고 있으며, Comparable을 구현하고 있는 클래스들은 같은 타입의 인터페이스끼리 서로 비교할 수 있는 클래스들(주로 wrapper클래스)이 있으며, 기본적으로 오름차순으로 구현되어 있다. 그래서 Comparable을 구현한 클래스는 정렬이 가능하다는 것을 의미한다.

1
2
3
4
5
6
7
8
public interface Comparator {
int compare(Object o1, Object o2);
boolean equals(Object obj);
}

public interface Comparable {
public int compareTo(Object o);
}

Comparator의 compare()와 Comparable의 compareTo()는 두 객체를 비교한다는 같은 기능을 목적으로 만들어 졌다. compareTo()는 반환값은 int지만 실제로는 비교하는 두 객체가 같으면 0, 비교하는 값보다 작으면 음수, 크면 양수를 반환하도록 구현해야한다. compare()도 객체를 비교해서 음수, 0, 양수 중의 하나를 반환하도록 구현해야한다.

Comparable : 기본 정렬기준(오름차순)을 구현하는데 사용.

Comparator : 기본 정렬기준 외에 다른 기준으로 정렬하고자할 때 사용

Arrays.sort()는 배열을 정렬할 때, Comparator를 지정해주지 않으면 저장하는 객체에 구현된 내용에 따라 정렬된다.

1
2
static void sort(Object[] a) // 객체 배열에 저장된 객체가 구현한 Comparable에 의한 정렬
static void sort(Object[] a, Comparator c) // 지정한 Comparator에 의한 정렬

HashSet

HashSet은 Set인터페이스를 구현한 가장 대표적인 컬렉션이며, Set인터페이스의 특징대로 HashSet은 중복된 요소를 저장하지 않는다.

ArrayList와 같이 List인터페이스를 구현한 컬렉션과 달리 HashSet은 저장순서를 유지하지 않으므로 저장순서를 유지하고자 한다면 LinkedHashSet을 사용해야 한다.

HashSet은 내부적으로 HashMap을 이용해서 만들어졌으며, HashSet이란 이름은 해싱(hasing)을 이용해서 구현했기 때문에 붙여진 것이다.

HashSet의 add메서드는 새로운 요소를 추가하기 전에 기존에 저장된 요소와 같은 것인지 판별하기 위해 추가하려는 요소의 equals()와 hashCode()를 호출하기 때문에 equals()와 hashCode()를 목적에 맞게 오버라이딩해야 한다.

오버라이딩을 통해 작성된 hashCode()는 다음의 세 가지 조건을 만족 시켜야 한다.

  1. 실행 중인 애플리케이션 내의 동일한 객체에 대해서 여러 번 hashCode()를 호출해도 동일한 int 값을 반환해야 한다. 하지만, 실행시마다 동일한 int값을 반환할 필요는 없다.
    (String 클래스는 문자열의 내용으로 해시코드를 만들어 내기 때문에 내용이 같은 문자열에 대한 hashCode() 호출은 항상 동일한 해시코드를 반환한다. 반면에 Object클래스는 객체의 주소로 해시코드를 만들어 내기 때문에 실행할 때마다 해시코드값이 달라질 수 있다.)
  2. equals메서드를 이용한 비교에 의해서 true를 얻은 두 객체에 대해 각각 hashCode()를 호출해서 얻은 결과는 반드시 같아야 한다.
  3. equals메서드를 호출했을 때 false를 반환하는 두 객체는 hashCode() 호출에 대해 같은 int값을 반환하는 경우가 있어도 괜찮지만, 해싱(hashing)을 사용하는 컬렉션의 성능을 향상시키기 위해서는 다른 int값을 반환하는 것이 좋다.

TreeSet

TreeSet은 이진 검색 트리(binary search tree)라는 자료구조의 형태로 데이터를 저장하는 컬렉션 클래스이다. 이진 검색 트리는 정렬, 검색, 범위검색(range search)에 노은 성능을 보이는 자료구조이며 TreeSet은 이진 검색 트리의 성능을 향상시킨 ‘레드-블랙 트리(Red-Black tree)’로 구현되어 있다.

Set인터페이스를 구현했으므로 중복된 데이터의 저장을 허용하지 않으며 정렬된 위치에 저장하므로 저장순서를 유지하지도 않는다.

HashMap과 Hashtable

Hashtable과 HashMap의 관계는 Vector와 ArrayList의 관계와 같아서 Hashtable보다는 새로운 버전인 HashMap을 사용할 것을 권한다.

HashMap은 Map을 구현했으므로 Map의 특징인 키(key)와 값(value)을 묶어서 하나의 데이터(entry)로 저장한다는 특징을 갖는다. 그리고 해싱(hashing)을 사용하기 때문에 많은 양의 데이터를 검색하는데 있어서 뛰어난 성능을 보인다.

1
2
3
4
5
6
7
8
9
public class HashMap extends AbstractMap implements Map, Cloneable, Serializable {
transient Entry[] table;
//...
static class Entry implements Map.Entry {
final Object key;
Object value;
//...
}
}

HashMap은 Entry라는 내부 클래스를 다시 정의하고, 다시 Entry타입의 배열을 선언하고 있다. 키(key)와 값(value)은 별개의 값이 아니라 서로 관련된 값이기 때문에 각각의 배열로 선언하기 보다는 하나의 클래스로 정의해서 하나의 배열로 다루는 것이 데이터의 무결성적인 측면에서 더 바람직하기 때문이다.

HashMap은 키와 값을 각각 Object타입으로 저장한다. 즉 어떠한 객체도 저장할 수 있지만 키는 주로 String을 대문자 또는 소문자로 통일해서 사용하곤 한다.

  • Set entrySet() : HashMap에 저장된 키와 값을 엔트리(키와 값의 결합)의 형태로 Set에 저장해서 반환
  • Object get(Object key) : 지정된 키(key)의 값(객체)을 반환. 못찾으면 null 반환
  • Set keySet() : HashMap에 저장된 모든 키가 저장된 Set을 반환
  • Object put(Object key, Object value) : 지정된 키와 값을 HashMap에 저장
  • Collection values() : HashMap에 저장된 모든 값을 컬렉션의 형태로 반환

해싱과 해시함수

해싱이란 해시함수(hash function)을 이용해서 데이터를 해시테이블(hash table)에 저장하고 검색하는 기법을 말한다. 해시함수는 데이터가 저장되어 있는 곳을 알려주기 때문에 다량의 데이터 중에서도 원하는 데이터를 빠르게 찾을 수 있다.

해싱에서 사용하는 자료구조는 배열과 링크드 리스트의 조합으로 되어 있다.

저장할 데이터의 키를 해시함수에 넣으면 배열의 한 요소를 얻게 되고, 다시 그 곳에 연결되어 있는 링크드 리스트에 저장하게 된다.

  1. 검색하고자 하는 값의 키로 해시함수를 호출한다.
  2. 해시함수의 계산결과인 해시코드를 이용해서 해당 값이 저장되어 있는 링크드 리스트를 찾는다.
  3. 링크드 리스트에서 검색한 키와 일치하는 데이터를 찾는다.

링크드 리스트는 검색에 불리한 자료구조이기 때문에 링크드 리스트의 크기가 커질수록 검색속도가 떨어지게 된다.

하나의 링크드 리스트에 최소한의 데이터만 저장되려면, 저장될 데이터의 크기를 고려해서 HashMap의 크기를 적절하게 지정해주어야 하고, 해시함수가 서로 다른 키에 대해서 중복된 해시코드의 반환을 최소화해야 한다. 그래야 HashMap에서 빠른 검색시간을 얻을 수 있다.

실제로는 HashMap과 같이 해싱을 구현한 컬렉션 클래스에는 Object클래스에 정의된 hashCode()를 해시함수로 사용한다. Object클래스에 정의된 hashCode()는 객체의 주소를 이용하는 알고리즘으로 해시코드를 만들어 내기 때문에 모든 객체에 대헤 hashCode()를 호출한 결과가 서로 다른 좋은 방법이다.

String클래스의 경우 Object로부터 상속받은 hashCode()를 오버라이딩해서 문자열의 내용으로 해시코드를 만들어 낸다. 그래서 서로 다른 String인스턴스일지라도 같은 내용의 문자열을 가졌다면 hashCode()를 호출하면 같은 해시코드를 얻는다.

HashSet과 마찬가지로 HashMap에서도 서로 다른 두 객체에 대해 equals()로 비교한 결과가 true인 동시에 hashCode()의 반환값이 같아야 같은 객체로 인식한다. (이미 존재하는 키에 대한 값을 저장하면 기존의 값을 새로운 값으로 덮어쓴다.)

그래서 새로운 클래스를 정의할 때 equals()를 오버라이딩해다 한다면 hashCode()도 같이 오버라이딩해서 equals()의 결과가 true인 두 객체의 해시코드가 항상 같도록 해주어야 한다.

그렇지 않으면 HashMap과 같이 해싱을 구현한 컬렉션 클래스에서는 equals()의 호출결과가 true지만 해시코드가 다른 두 객체를 서로 다른 것으로 인식하고 따로 저장할 것이다.

TreeMap

TreeMap은 이진검색트리의 형태로 키와 값의 쌍으로 이루어진 데이터를 저장한다. 그래서 검색과 정렬에 적합한 컬렉션 클래스이다.

검색에 관한 대부분의 경우에서는 HashMap이 TreeMap보다 더 뛰어나므로 HashMap을 사용하는 것이 좋다. 다만 범위검색이나 정렬이 필요한 경우에는 TreeMap을 사용하자.

Properties

Properties는 HashMap의 구버전인 Hashtable을 상속받아 구현한 것으로, Hashtable은 키와 값을 (Object, Object)의 형태로 저장하는데 비해 Properties는 (String, String)의 형태로 저장하는 보다 단순화된 컬렉션클래스이다.

주로 애플리케이션의 환경설정과 관련된 속성(property)을 저장하는데 사용되며 데이터를 파일로부터 읽고 쓰는 편리한 기능을 제공한다.

Collections

Arrays가 배열과 관련된 메서드를 제공하는 것처럼, Collections는 컬렉션과 관련된 메서드를 제공한다. fill(), copy(), sort(), binarySearch() 등의 메서드는 두 클래스에 포함되어 있으며 같은 기능을 한다.

컬렉션의 동기화

멀티 쓰레드 프로그래밍에서는 하나의 객체를 여러 쓰레드가 동시에 접근할 수 있기 때문에 데이터의 일관성(consistency)을 유지하기 위해서는 공유되는 객체의 동기화(synchronization)가 필요하다.

Vector와 Hashtable과 같은 구버전(JDK1.2 이전)의 클래스들은 자체적으로 동기화처리가 되어 있는데, 멀티쓰레드 프로그래밍이 아닌 경우에는 불필요한 기능이 되어 성능을 떨어뜨리는 요인이 된다.

그래서 새로 추가된 ArrayList와 HashMap과 같은 컬렉션은 동기화를 자체적으로 처리하지 않고 필요한 경우에만 java.util.Collections클래스의 동기화 메서드를 이용해서 동기화처리가 가능하도록 변경하였다.

1
2
3
4
5
6
static Collection synchronizedCollection(Collection c)
static List synchronizedList(List list)
static Set synchronizedSet(Set s)
static Map synchronizedMap(Map m)
static SortedSet synchronizedSortedSet(SortedSet s)
static SortedMap synchronizedSortedMap(SortedMap m)

변경불가 컬렉션 만들기

컬렉션에 저장된 데이터를 보호하기 위해서 컬렉션을 변경할 수 없게 읽기전용으로 만들어야 할 때가 있다.

1
2
3
4
5
6
static Collection unmodifiableCollection(Collection c)
static List unmodifiableList(List list)
static Set unmodifiableSet(Set s)
static Map unmodifiableMap(Map m)
static SortedSet unmodifiableSortedSet(SortedSet s)
static SortedMap unmodifiableSortedMap(SortedMap m)

컬렉션 클래스 정리 & 요약

  • ArrayList : 배열기반, 데이터의 추가와 삭제에 불리, 순차적인 추가/삭제는 제일 빠름. 임의의 요소에 대한 접근성이 뛰어남.
  • LinkedList : 연결기반, 데이터의 추가와 삭제에 유리. 임의의 요소에 대한 접근성이 좋지 않다.
  • HashMap : 배열과 연결이 결합된 형태. 추가, 삭제, 검색, 접근성이 모두 뛰어남. 검색에는 최고성능을 보인다.
  • TreeMap : 연결기반, 정렬과 검색(특히 범위검색)에 적합. 검색성능은 HashMap보다 떨어짐.
  • Stack : Vector를 상속받아 구현
  • Queue : LinkedList가 Queue인터페이스를 구현
  • Properties : Hashtable을 상속받아 구현(String, String)
  • HashSet : HashMap을 이용해서 구현
  • TreeSet : TreeMap을 이용해서 구현
  • LinkedHashMap, LinkedHashSet : HashMap과 HashSet에 저장순서유지기능을 추가하였음.

날짜와 시간 & 형식화

해당 포스팅의 내용은 Java의 정석 2권 - Chapter 10 날짜와 시간 & 형식화에 있는 내용을 요약한 것입니다. 해당 책으로 복습하며 정리한 내용이고 문제가 된다면 바로 해당 포스팅을 삭제하도록 하겠습니다.

날짜와 시간 & 형식화

타임존 포함 ISO 8601 문자열의 표현

날짜/시간 및 타임존을 다루는 국제적인 규약은 다양하다. RFC 822, 1036, 1123, 2822, 3339, ISO 8601 등이 있다. 여기서는 ISO 8601RFC 3339와 관련된 표기법을 알아본다.

1
2
3
4
5
6
7
8
// 로컬 시간을 의미하는 ISO 8601 문자열
2017-11-06T15:00:00.000

// UTC(GMT) 시간을 의미하는 ISO 8601 문자열
2017-11-06T06:00:00.000Z

// 로컬 시간을 의미하면서 UTC(GMT) 대비 +09:00 임을 의미하는 ISO 8601 문자열
2017-11-06T15:00:00.000+09:00
  • 2017-11-06T15:00:00.000ISO 8601의 기본 형식이다. 해당 시간이 로컬 시간 임을 의미한다.
  • 2017-11-06T06:00:00.000Z와 같이 뒤에 Z 식별자를 추가하면 해당 시간이 UTC(GMT) 시간 임을 의미한다.
  • 2017-11-06T15:00:00.000+09:00와 같이 뒤에 Z 대신 +HH:mm 식별자를 추가하면 해당 시간이 로컬 시간이면서 **UTC(GMT)**와 09:00 만큼 차이가 남을 의미한다. 이 형식의 장점은 인간이 손쉽게 추가적인 계산 없이 로컬 시간을 인지하면서 추가적으로 타임존 정보까지 제공하기 때문에 가장 인간친화적이라고 할 수 있다.

날짜와 시간

Date는 날짜와 시간을 다룰 목적으로 JDK 1.0부터 제공되어온 클래스이다. Date 클래스는 기능이 부족했기 때문에, Calendar라는 새로운 클래스를 그 다음 버젼인 JDK 1.1부터 제공하기 시작했다. Calendar는 Date보다는 훨씬 나았지만 몇 가지 단점들이 있었고, JDK 1.8부터 java.time 패키지로 기존의 단점들을 개선한 새로운 클래스들이 추가되었다.

Date 클래스는 java.util 패키지에 속해있다.

Date와 Calendar간의 변환

Calendar가 새로 추가되면서 Date는 대부분의 메서드가 ‘deprecated’되었으므로 잘 사용되지 않는다. 그럼에도 불구하고 여전히 Date를 필요로 하는 메서드들이 존재하기 때문에 Calendar를 Date로 또는 그 반대로 변환할 일이 생긴다.

1
2
3
4
5
6
7
8
9
1. Calendar를 Date로 변환
Calendar cal = Calendar.getInstance();
Date d1 = new Date(cal.getTimeInMillis()); // Date(long date)
Date d2 = cal.getTime();

2. Date를 Calendar로 변환
Date d = new Date();
Calendar cal = Calendar.getInstance();
cal.setTime(d);

Calendar.getInstance()를 통해서 얻은 인스턴스는 기본적으로 현재 시스템의 날짜와 시간에 대한 정보를 담고 있다. (GregorianCalendar, BuddhistCalendar)

형식화 클래스

자바의 형식화 클래스는 java.text 패키지에 포함되어 있으며 숫자, 날짜, 텍스트 데이터를 일정한 형식에 맞게 표현할 수 있는 방법을 객체지향적으로 설계하여 표준화하였다. 형식화 클래스는 형식화에 사용될 패턴을 정의하는데, 데이터를 정의된 패턴에 맞춰 형식화할 수 있을 뿐만 아니라 역으로 형식화된 데이터에서 원래의 데이터를 얻어낼 수도 있다. 즉, 형식화된 데이터의 패턴만 정의해주면 복잡한 문자열에서도 substring()을 사용하지 않고도 쉽게 원하는 값을 얻어낼 수 있다는 것이다.

DecimalFormat

형식화 클래스 중에서 숫자를 형식화 하는데 사용되는 것이 DecimalFormat이다. DecimalFormat을 이용하면 숫자 데이터를 정수, 부동소수점, 금액 등의 다양한 형식으로 표현할 수 있으며, 반대로 일정한 형식의 테스트 데이터를 숫자로 쉽게 변환하는 것도 가능하다.

1
2
3
double number = 1234567.89;
DecimalFormat df = new DecimalFormat("#.#E0");
String result = df.format(number);

Number 클래스는 Integer, Double과 같은 숫자를 저장하는 래퍼 클래스의 조상이며, doubleValue()는 Number에 저장된 값을 double형의 값으로 변환하여 반환한다. 이 외에도 intValue(), floatValue()등의 메서드가 Number클래스에 정의되어 있다.

SimpleDateFormat

Date와 Calendar만으로는 날짜 데이터를 원하는 형태로 다양하게 출력하는 것은 불편하고 복잡하다. 그러나 SimpleDateFormat을 사용하면 이러한 문제들이 간단하게 해결된다.

DateFormat은 추상클래스로 SimpleDateFormat의 조상이다. DateFormat는 추상클래스이므로 인스턴스를 생성하기 위해서는 getDateInstance()와 같은 static 메서드를 이용해야 한다. getDateInstance()에 의해서 반환되는 것은 DateFormat을 상속받아 완전하게 구현한 SimpleDateFormat 인스턴스이다.

1
2
3
4
5
Date today = new Date();
SimpleDateFormat dt = new SimpleDateFormat("yyyy-MM-dd");

// 오늘 날짜를 yyyy-MM-dd 형태로 변환하여 반환한다.
String result = df.format(today);

Date 인스턴스만 format 메서드에 사용될 수 있다.

1
2
DateFormat df = new SimpleDateFormat("yyyy년 MM월 dd일");
Date d = df.parse("2018년 6월 6일");

parse(String source)를 사용하여 날짜 데이터의 출력형식을 변환하는 방법을 보여주는 예제이다. Integer의 parseInt()가 문자열을 정수로 변환하는 것처럼 SimpleDateFormat의 parse(String source)는 문자열(source)을 날짜(Date인스턴스)로 변환해주기 때문에 매우 유용하게 쓰일 수 있다.

ChoiceFormat

ChoiceFormat은 특정 범위에 속하는 값을 문자열로 변환해준다. 연속적 또는 불연속적인 범위의 값들을 처리하는 데 있어서 if문이나 switch문은 적절하지 못한 경우가 많다. 이럴때 ChoiceFormat을 잘 사용하면 복잡하게 처리될 수밖에 없었던 코드를 간단하고 직관적으로 만들 수 있다.

MessageFormat

MessageFormat은 데이터를 정해진 양식에 맞게 출력할 수 있도록 도와준다. 데이터가 들어갈 자리를 마련해 놓은 양식을 미리 작성하고 프로그램을 이용해서 다수의 데이터를 같은 양식으로 출력할 때 사용하면 좋다. 하나의 데이터를 다양한 양식으로 출력할 때 사용한다.

그리고 SimpleDateFormat의 parse처럼 MessageFormat의 parse를 이용하면 지정된 양식에서 필요한 데이터만을 손쉽게 추출해 낼 수도 있다.

1
2
3
4
String msg = "Name: {0} \nTel: {1} \nnAge:{2} \nBirthday:{3}";
Object[] arguments = {"이름", "01-234-5678", "27", "04-27"};

String result = MessageFormat.format(msg, arguments);

MessageFormat에 사용될 양식인 문자열 msg를 작성할 때 ‘{숫자}’로 표시된 부분이 데이터가 출력될 자리이다.

데이터를 양식에 넣어서 출려하는것 뿐만 아니라, parse(String source)를 이용해서 출력된 데이터로부터 필요한 데이터만을 뽑아낼 수 있다.

Java.time 패키지

java의 탄생부터 지금까지 날짜와 시간을 다루는데 사용해왔던, Date와 Calendar가 가지고 있던 단점들을 해소하기 위해 JDK1.8부터 ‘java.time 패키지’가 추가되었다. 이 패키지는 다음과 같이 4개의 하위 패키지를 가지고 있다.

  • java.time : 날짜와 시간을 다루는데 필요한 핵심 클래스들을 제공
    • java.time.chrono : 표준(ISO)이 아닌 달력 시스템을 위한 클래스들을 제공
    • java.time.format : 날짜와 시간을 파싱하고, 형식화하기 위한 클래스들을 제공
    • java.time.temporal : 날짜와 시간의 필드(field)와 단위(unit)를 위한 클래스들을 제공
    • java.time.zone : 시간대(time-zone)와 관련된 클래스들을 제공

위의 패키지들에 속한 클래스들의 가장 큰 특징은 String 클래스처럼 **불변(immutable)**이라는 것이다. 그래서 날짜나 시간을 변경하는 메서드들은 기존의 객체를 변경하는 대신 항상 변경된 새로운 객체를 반환한다. 기존 Calendar 클래스는 변경 가능하므로, 멀티 쓰레드 환경에서 안전하지 못하다.

멀티 쓰레드 환경에서는 동시에 여러 쓰레드가 같은 개겣에 접근할 수 있기 때문에, 변경 가능한 객체는 데이터가 잘못될 가능성이 있으며, 이를 쓰레드에 안전(thread-safe)하지 않다고 한다.

java.time 패키지의 핵심 클래스

날짜와 시간을 하나로 표현하는 Calendar 클래스와 달리, java.time 패키지에서는 날짜와 시간을 별도의 클래스로 분리해 놓았다. 시간을 표현할 때는 LocalTime 클래스를 사용하고, 날짜를 표현할 때는 LocalDate 클래스를 사용한다. 그리고 날짜와 시간이 모두 필요할 때는 LocalDateTime 클래스를 사용하면 된다.

LocalDate + LocalTime -> LocalDateTime
날짜 시간 날짜 & 시간

여기에 시간대(time-zone)까지 다뤄야 한다면, ZonedDateTime 클래스를 사용한다.

LocalDateTime + 시간대 -> ZonedDateTime

Calendar는 ZonedDateTime처럼, 날짜와 시간 그리고 시간대까지 모두 가지고 있다. Date와 유사한 클래스로는 Instant가 있는데, 이 클래스는 날짜와 시간을 초 단위(정확히는 나노초)로 표현한다. 날짜와 시간을 초단위로 표현한 값을 타임스탬프(time-stamp) 라고 부르는데, 이 값은 날짜와 시간을 하나의 정수로 표현할 수 있으므로 날짜와 시간의 차이를 계산하거나 순서를 비교하는데 유리해서 데이터베이스에 많이 사용한다.

객체 생성하기 - now(), of()

java.time 패키지에 속한 클래스의 객체를 생성하는 가장 기본적인 방법은 now()와 of()를 사용하는 것이다.

1
2
3
4
LocalDate date = LocalDate.now(); // 2018-06-06
LocalTime time = LocalTime.now(); // 02:34:50.223
LocalDateTime dateTime = LocalDateTime.now(); // 2018-06-06T02:34:50.223
ZonedDateTime dateTimeInKr = ZonedDateTime.now(); // 2018-06-06T02:34:50.223+09:00[Asia/Seoul]

LocalDate와 LocalTime

LocalDateLocalTime은 java.time 패키지의 가장 기본이 되는 클래스이며, 나머지 클래스들은 이들의 확장이므로 이 두 클래스만 잘 이해하고 나면 나머지는 아주 쉬워진다.

객체를 생성하는 방법은 현재의 날짜와 시간을 LocalDate와 LocalTime으로 각각 반환하는 now()와 지정된 날짜와 시간으로 LocalDate와 LocalTime 객체를 생성하는 of()가 있다.

  • 특정 필드의 값 가져오기 - get(), getXXX()
  • 필드의 값 변경하기 - with(), plus(), minus()
  • 날짜와 시간의 비교 - isAfter(), isBefore(), isEqual()

Instant

Instant는 에포크 타임(EPOCH TIME, 1970-01-01 00:00:00 UTC)부터 경과된 시간을 나노초 단위로 표현한다. 사람이 보기에는 불편하지만, 단일 진법으로 다루기 때문에 계산에는 편리하다. 사람이 사용하는 날짜와 시간에는 여러 진법이 섞여있어서 계산하기 어렵다.

1
2
3
Instant now = Instant.now();
Instant now2 = Instant.ofEpochSecond(now.getEpochSecond());
Instant now3 = Instant.ofEpochSecond(now.getEpochSecond(), now.getNano());

Instant를 생성할 때는 위와 같이 now()와 ofEpochSecond()를 사용한다. 그리고 필드에 저장된 값을 가져올 때는 다음과 같이 한다.

1
2
long epochSec = now.getEpochSecond();
int nano = now.getNano();

위의 코드처럼, Instant는 시간을 초 단위와 나노초 단위로 나누어 저장한다. 오라클 데이터베이스의 타임스탬프(timestamp)처럼 밀리초 단위의 EPOCH TIME을 필요로 하는 경우를 위해 toEpochMilli()가 정의되어 있다.

1
long toEpochMilli()

Instant는 항상 UTC(+00:00)를 기준으로 하기 때문에, LocalTime과 차이가 있을 수 있다. 예를 들어 한국은 시간대가 ‘+09:00’이므로 Instant와 LocalTime간에는 9시간의 차이가 있다. 시간대를 고려해야하는 경우 OffsetDateTime을 사용하는 것이 더 나은 선택일 수 있다.

UTC는 ‘Coordinated Universal Time’의 약어로 ‘세계 협정시’이라고 하며, 1972년 1월 1일부터 시행된 국제 표준시이다. 이전에 사용되던 GMT(Greenwich Mean Time)와 UTC는 거의 같지만, UTC가 좀 더 정확하다.

LocalDateTime과 ZonedDateTime

LocalDateTime에 시간대(time-zone)를 추가하면, ZonedDateTime이 된다. 기존에는 TimeZone클래스로 시간대를 다뤘지만 새로운 시간 패키지에서는 ZoneId라는 클래스를 사용한다. ZoneId는 일광 절약시간(DST, Daylight Saving Time)을 자동적으로 처리해주므로 더 편리하다.

LocalDate에 시간 정보를 추가하는 atTime()을 쓰면 LocalDateTime을 얻을 수 있는 것처럼, LocalDateTime에 atZone()으로 시간대 정보를 추가하면, ZonedDateTime을 얻을 수 있다.

1
2
3
ZoneId zid = ZoneId.of("Asia/Seoul");
zonedDateTime zdt = dateTime.atZone(zid);
Syste.out.println(zdt); // 2018-06-06T14:23:50.235+09:00[Asia/Seoul]

만일 현재 특정 시간대의 시간, 예를 들어 뉴욕을 알고 싶다면 다음과 같이 하면 된다.

1
2
ZoneId nyId = ZoneId.of("America/New_York");
ZonedDateTime nyTime = ZonedDateTime.now().withZoneSameInstant(nyId);

ZoneOffSet

UTC로부터 얼마만큼 떨어져 있는지를 ZoneOffSet으로 표현한다. 위의 결과에서 알 수 있듯이 서울은 ‘+9’이다. 즉, UTC보다 9시간(32400초=60*60*9)이 빠르다.

1
2
ZoneOffset krOffset = ZonedDateTime.now().getOffset();
int krOffsetInSec = KrOffset.get(ChronoField.OFFSET_SECONDS); // 32400초

OffsetDateTime

ZonedDateTime은 ZoneId로 구역을 표현하는데, ZoneId가 아닌 ZoneOffset을 사용하는 것이 OffSetDateTime이다. ZoneId는 일광절약시간처럼 시간대와 관련된 규칙들을 포함하고 있는데, ZoneOffset은 단지 시간대를 시간의 차이로만 구분한다. 컴퓨터에게 일광절약시간처럼 계절별로 시간을 더했다 뺐다 하는 것과 같은 행위는 위험하다. 아무런 변화 없이 일관된 시간체계를 유지하는 것이 더 안전하다. 같은 지역 내의 컴퓨터 간에 데이터를 주고 받을 때, 전송시간을 표현하기에 LocalDateTime이면, 충분하겠지만, 서로 다른 시간대에 존재하는 컴퓨터간의 통신에는 OffsetDateTime이 필요하다.

일광 절약 시간제(Daylight saving time, DST) 또는 서머 타임(summer time)은 하절기에 표준시를 원래 시간보다 한 시간 앞당긴 시간을 쓰는 것을 말한다. 즉, 0시에 일광 절약 시간제를 실시하면 1시로 시간을 조정해야 하는 것이다. 실제 낮 시간과 사람들이 활동하는 낮 시간 사이의 격차를 줄이기 위해 사용한다.

1
2
3
4
5
ZonedDateTime zdt = ZondedDateTime.of(date, time, zid);
OffsetDateTime odt = offsetDateTime.of(date, time, krOffset);

// ZonedDatetime -> OffsetDateTime
OffsetDateTime odt = zdt.toOffsetDateTime();

OffsetDateTime을 ZonedDateTime처럼, LocalDate와 LocalTime에 ZonedOffset을 더하거나, ZonedDateTime에 toOffsetDateTime()을 호출해서 얻을 수도 있다.

지금까지의 내용을 예제로 확인해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
LocalDate ld = LocalDate.now();
LocalTime lt = LocalTime.now();
System.out.println("LocalDate : " + ld);
System.out.println("LocalTime : " +lt);

LocalDateTime dt = LocalDateTime.of(ld, lt);
System.out.println("LocalDateTime : " + dt);

ZoneId zid = ZoneId.of("Asia/Seoul");
ZonedDateTime zdt = dt.atZone(zid);
System.out.println("ZonedDateTime1 : " + zdt);

ZonedDateTime seoulTime = ZonedDateTime.now();
System.out.println("ZonedDateTime2 : " + seoulTime);

// 특정 구역 시간의 다른 구역 시간 구하기
ZoneId nyId = ZoneId.of("America/New_York");
ZonedDateTime nyTime = ZonedDateTime.now().withZoneSameInstant(nyId);
System.out.println("ZonedDateTime3 : " + nyTime);

// 출력 결과
LocalDate : 2018-06-06
LocalTime : 10:28:37.743
LocalDateTime : 2018-06-06T10:28:37.743
ZonedDateTime1 : 2018-06-06T10:28:37.743+09:00[Asia/Seoul]
ZonedDateTime2 : 2018-06-06T10:28:37.744+09:00[Asia/Seoul]
ZonedDateTime3 : 2018-06-05T21:28:37.747-04:00[America/New_York]

TemporalAdjusters

plus(), minus()와 같은 메서드로 날짜와 시간을 계산하기에는 불편한 경우가 있다. (Ex. 이번 달의 3번째 금요일) 그래서 자주 쓰일만한 날짜 계산들을 대신 해주는 메서드를 정의해놓은 것이 TemporalAdjusters 클래스이다.

1
2
LocalDate today = LocalDate.now();
LocalDate nextMonday = today.with(TemporalAdjusters.next(DayofWeek.MONDAY))l

with()는 LocalTime, LocalDateTime, ZonedDateTime, Instant 등 대부분의 날짜와 시간에 관련된 클래스에 포함되어 있다.

Period와 Duration

Period는 날짜의 차이를, Duration은 시간의 차이를 계산하기 위한 것이다.

between()

두 날짜 date1과 date2의 차이를 나타내는 Period는 between()으로 얻을 수 있다.

1
2
3
LocalDate date1 = LocalDate.of(2014, 1, 1);
LocalDate date2 = LocalDate.of(2018, 6, 6);
Period pe = Period.between(date1, date2)

date1이 date2보다 날짜 상으로 이전이면 양수로, 이후면 음수로 Period에 저장된다. 그리고 시간차이를 구할 때는 Duration을 사용한다는 것을 제외하고는 Period와 똑같다.

Period, Duration에서 특정 필드의 값을 얻을 때는 get()을 사용한다.

1
2
3
4
5
6
long year = pe.get(ChronoUnit.YEARS); // int getYears()
long month = pe.get(ChronoUnit.MONTHS); // int getMonths()
long day = pe.get(ChronoUnit.DAYS); // int getDays()

long sec = du.get(ChronoUnit.SECONDS); // long getSeconds()
long nano = du.get(ChronoUnit.NANOS); // int getNano()

between()과 until()

until()은 between()과 거의 같은 일을 한다. between()은 static 메서드이고, until()은 인스턴스 메서드라는 차이가 있다.

Period는 년월일을 분리해서 저장하기 때문에, D-day를 구하려는 경우에는 두 개의 매개변수를 받는 until()을 사용하는 것이 낫다.

파싱과 포맷

날짜와 시간을 원하는 형식으로 출력하고 해석(파싱)을 위한 형식화(formatting)와 관련된 클래스들은 java.time.format 패키지에 들어 있다. 그 중에서 DateTimeFormatter가 핵심이다. 이 클래스에는 자주 쓰이는 다양한 형식들을 기본적으로 정의하고 있으며, 그 외의 형식이 필요하다면 직접 정의해서 사용할 수도 있다.

1
2
3
LocalDate date = LocalDate.of(2016, 6, 6);
String yyyymmdd = DateTimeFormatter.ISO_LOCAL_DATE.format(date); // "2016-06-06"
String yyyymmdd = date.format(DateTimeFormatter.ISO_LOCAL_DATE); // "2018-06-06

날짜와 시간의 형식화에는 format()이 사용되는데, 이 메서드는 DateTimeFormatter뿐만 아니라 LocalDate나 LocalTime같은 클래스에도 있다. 같은 기능을 하기 때문에 상황에 따라 편한 쪽을 선택해서 사용하면 된다.

문자열을 날짜와 시간으로 파싱하기

문자열을 날짜 또는 시간으로 변환하려면 static 메서드 parse()를 사용하면 된다. 자주 사용되는 기본적인 형식의 문자열은 ISO_LOCAL_DATE와 같은 형식화 상수를 사용하지 않고도 파싱이 가능하다.

1
2
3
4
LocalDate date = LocalDate.parse("2018-06-06", DateTimeFormatter.ISO_LOCAL_DATE);
LocalDate newDate = LocalDate.parse("2018-06-06");
LocalTime newTime = LocalTime.parse("23:59:59");
LocalDateTime newDateTime = LocalDateTime.parse("2018-06-06T23:59:59");

참고

토비의 스프링 8장 (스프링이란 무엇인가?)

스프링이란 무엇인가?

스프링의 정의

스프링이란 어떤 것이다라고 한마디로 정의하기는 쉽지 않다. 스프링에 대해 가장 잘 알려진 정의는 이렇다.

자바 엔터프라이즈 개발을 편하게 해주는 오픈소스 경량급 애플리케이션 프레임워크

정의를 봐도 스프링이 무엇인지 감이 바로 오지는 않는다. 하지만 이 정의에는 스프링의 중요한 특징이 잘 담겨 있다.

  • 애플리케이션 프레임워크

    일반적으로 라이브러리나 프레임워크는 특정 업무 분야나 한 가지 기술에 특화된 목표를 가지고 만들어진다. 그러나 애플리케이션 프레임워크는 조금 다르다.
    애플리케이션 프레임워크는 특정 계층이나, 기술, 업무 분야에 국한되지 않고 애플리케이션의 전 영역을 포괄하는 범용적인 프레임워크를 말한다. 애플리케이션 프레임워크는 애플리케이션 개발의 전 과정을 빠르고 편리하며 효율적으로 진행하는데 일차적인 목표를 두는 프레임워크다.
    단지 여러 계층의 다양한 기술을 한데 모아뒀기 때문에 애플리케이션 프레임워크라고 불리는 건 아니다. 애플리케이션의 전 영역을 관통하는 일관된 프로그래밍 모델과 핵심 기술을 바탕으로 해서 각 분야의 특성에 맞는 필요를 채워주고 있기 때문이다.

  • 경량급

    스프링이 경량급이라는 건 스프링 자체가 아주 가볍다거나 작은 규모의 코드로 이뤄졌다는 뜻은 아니다.
    그럼에도 가볍다고 하는 이유는 불필요하게 무겁지 않다는 의미다. 특히 스프링이 처음 등장하던 시절의 자바 주류 기술이었던 예전의 EJB 같은 과도한 엔지니어링이 적용된 기술과 스프링을 대비시켜 설명하려고 사용했던 표현이다.
    스프링은 가장 단순한 서버환경인 톰캣(Tomcat)이나 제티(Jetty)에서도 완벽하게 동작한다. 단순한 개발툴과 기본적인 개발환경으로도 엔터프라이즈 개발에서 필요로 하는 주요한 기능을 갖춘 애플리케이션을 개발하기에 충분하다. 스프링의 장점은 그런 가볍고 단순한 환경에서도 복잡한 EJB와 고가의 WAS를 갖춰야만 가능했던 엔터프라이즈 개발의 고급 기술을 대부분 사용할 수 있다는 점이다.
    결과적으로 스프링은 EJB를 대표로 하는 기존의 많은 기술이 불필요하게 무겁고 복잡했음을 증명한 셈이고, 그런 면에서 스프링은 군더더기 없이 깔끔한 기술을 가진 ‘경량급’ 프레임워크라고 불린 것이다.
    만들어진 코드가 지원하는 기술수준은 비슷하더라도 그것을 훨씬 빠르고 간편하게 작성하게 해줌으로써 생산성과 품질 면에서 유리하다는 것이 바로 경량급이라는 말로 표현되는 스프링의 특징이다.

  • 자바 엔터프라이즈 개발을 편하게

    스프링은 근본적인 부분에서 엔터프라이즈 개발의 복잡함을 제거해내고 진정으로 개발을 편하게 해주는 해결책을 제시한다. 단순히 편리한 몇 가지 도구나 기능을 제공해주는 차원이 아니다.
    편리한 애플리케이션 개발이란 개발자가 복잡하고 실수하기 쉬운 로우레벨 기술에 많은 신경을 쓰지 않으면서도 애플리케이션의 핵심인 사용자의 요구사항, 즉 비즈니스 로직을 빠르고 효과적으로 구현하는 것을 말한다.

  • 오픈소스

스프링의 목적

스프링을 사용해서 엔터프라이즈 애플리케이션 개발을 편하게 하려는 이유는 뭘까? 원래 엔터프라이즈 개발이란 편하지 않기 때문이다.

엔터프라이즈 개발의 복잡함

자바 엔터프라이즈(JavaEE) 개발이 실패하는 가장 대표적인 이유는 ‘엔터프라이즈 시스템 개발이 너무 복잡해져서’였다.

복잡합의 근본적인 이유

엔터프라이즈 시스템 개발이 복잡한 원인은 크게 두 가지가 있다. (엔터프라이즈 시스템이란 서버에서 동작하며 기업과 조직의 업무를 처리해주는 시스템을 말한다.)

  • 기술적인 제약조건과 요구사항이 늘어가기 때문이다.
  • 엔터프라이즈 애플리케이션이 구현해야 할 핵심기능인 비즈니스 로직의 복잡함이 증가하기 때문이다.

전통적인 자바 엔터프라이즈 개발 기법은 대부분 비즈니스 로직의 복잡한 구현 코드와 엔터프라이즈 서비스를 이용하는 기술적인 코드가 자꾸 혼재될 수 밖에 없는 방식이었다. 결국 개발자가 동시에 그 두 가지를 모두 신경 써서 개발해야 하는 과도한 부담을 줬고, 그에 따라 전체적인 복잡함은 몇 배로 가중됐다.

복잡함을 해결하려는 도전

제거될 수 없는 근본적인 복잡함

엔터프라이즈 개발의 근본적인 복잡함의 원인은 제거할 대상은 아니다. 현실적으로는 불가능하기 때문이다.

근본적으로 엔터프라이즈 개발에 나타나는 복잡함의 원인은 제거 대상이 아니다. 대신 그 복잡함을 효과적으로 상대할 수 있는 전략과 기법이 필요하다. 문제는 비즈니스 로직의 복잡함을 효과적으로 다루기 위한 방법과 기술적인 복잡함을 효과적으로 처리하는 데 적용되는 방법이 다르다는 점이다. 따라서 두 가지 복잡함이 코드에 한데 어우러져 나타나는 전통적인 개발 방식에서는 효과적으로 복잡함을 다루기가 힘들다. 따라서 가장 먼저 할 일은 성격이 다른 이 두 가지 복잡함을 분리해내는 것이다.

복잡함을 상대하는 스프링의 전략

스프링의 기본적인 전략은 비즈니스 로직을 담은 애플리케이션 코드와 엔터프라이즈 기술을 처리하는 코드를 분리시키는 것이다. 이 분리를 통해 두 가지 복잡함의 문제를 효과적으로 공략하게 해준다.

기술적 복잡함을 상대하는 전략

기술적인 복잡함을 분리해서 생각하면 그것을 효과적으로 상대할 수 있는 적절한 전략을 발견할 수 있다. 스프링은 엔터프라이즈 기술을 적용했을 때 발생하는 복잡함의 문제를 두 가지로 분류하고 각각에 대한 적절한 대응 방법을 제공한다.

  • 첫 번째 문제 : 기술에 대한 접근 방식이 일관성이 없고, 특정 환경에 종속적이다.

    일관성 없는 기술과 서버환경의 변화에 대한 스프링의 공략 방법은 바로 서비스 추상화다. 앞에서 보았던 트랜잭션 추상화, OXM 추상화, 데이터 액세스에 관한 일관된 예외변환 기능, 데이터 액세스 기술에 독립적으로 적용 가능한 트랜잭션 동기화 기법 등이 대포적인 예다. 기술적인 복잡합은 일단 추상화를 통해 로우레벨의 기술 구현 부분과 기술을 사용하는 인터페이스를 분리하고, 환경과 세부기술에 독립적인 접근 인터페이스를 제공하는 것이 가장 좋은 해결책이다.

  • 두 번째 문제 : 기술적인 처리를 담당하는 코드가 성격이 다른 코드에 섞여서 등장한다.

    책임에 따라 계층을 구분하고 그 사이에 서로의 기술과 특성에 의존적인 인터페이스나 예외처리 등을 최대한 제거한다고 할지라도 근본적으로 엔터프라이즈 서비스를 적용하는 한 이런 문제는 쉽게 해결할 수 없다. 이런 기술과 비즈니스 로직의 혼재로 발생하는 복잡함을 해결하기 위한 스프링의 접근 방법은 바로 AOP다.
    AOP는 최후까지 애플리케이션 로직을 담당하는 코드에 남아 있는 기술 관련 코드를 깔끔하게 분리해서 별도의 모듈로 관리하게 해주는 강력한 기술이다.

비즈니스와 애플리케이션 로직의 복잡함을 상대하는 전략

비즈니스 로직의 복잡함을 상대하는 전략은 자바라는 객체지향 기술 그 자체다. 스프링은 단지 객체지향 언어의 장점을 제대로 살리지 못하게 방해했던 요소를 제거하도록 도와줄 뿐이다.

핵심 도구 : 객체지향과 DI

객체지향의 설계 기법을 잘 적용할 수 있는 구조를 만들기 위해 DI 같은 유용한 기술을 편하게 적용하도록 도와주는 것이 스프링의 기본 전략이다.

지금까지 보았듯이 기술적인 복잡함을 효과적으로 다루게 해주는 기법은 모두 DI를 바탕으로 하고 있다. 서비스 추상화, 템플릿/콜백, AOP와 같은 스프링의 기술은 DI 없이는 존재할 수 없는 것들이다.

그리고 DI는 객체지향 설계 기술 없이는 그 존재의미가 없다. DI란 특별한 기술이라기보다는 유연하게 확장할 수 있는 오브젝트 설계를 하다 보면 자연스럽게 적용하게 되는 객체지향 프로그래밍 기법일 뿐이다. 스프링은 단지 그것을 더욱 편하고 쉽게 사용하도록 도와줄 뿐이다.

기술적인 복잡함을 해결하는 문제나 기술적인 복잡함이 비즈니스 로직에 침범하지 못하도록 분리하는 경우에도 DI가 바탕이 된 여러 가지 기법이 활용된다. 반면에 비즈니스 로직 자체의 복잡함을 해결하려면 DI보다는 객체지향 설계 기법이 더 중요하다.

POJO 프로그래밍

스프링 핵심 개발자들은 “스프링의 정수(essence)”는 엔터프라이즈 서비스 기능을 POJO에 제공하는 것”이라고 했다. 엔터프라이즈 서비스라고 하는 것은 보안, 트랜잭션과 같은 엔터프라이즈 시스템에서 요구되는 기술을 말한다. 이런 기술을 POJO에 제공한다는 말은, 뒤집어 생각해보면 엔터프라이즈 서비스 기술과 POJO라는 애플리케이션 로직을 담은 코드를 분리했다는 뜻이기도 하다. ‘분리됐지만 반드시 필요한 엔터프라이즈 서비스 기술을 POJO 방식으로 개발된 애플리케이션 핵심 로직을 담은 코드에 제공한다’는 것이 스프링의 가장 강력한 특징과 목표다.

스프링의 핵심 : POJO

스프링 애플리케이션은 POJO를 이용해서 만든 애플리케이션 코드와, POJO가 어떻게 관계를 맺고 동작하는지를 정의해놓은 설계정보로 구분된다. DI의 기본 아이디어는 유연하게 확장 가능한 오브젝트를 만들어두고 그 관계는 외부에서 다이내믹하게 설정해준다는 것이다. 이런 DI의 개념을 애플리케이션 전반에 걸쳐 적용하는 것이 스프링의 프로그래밍 모델이다.

스프링의 주요 기술인 IoC/DI, AOP, 서비스추상화는 애플리케이션을 POJO로 개발할 수 있게 해주는 가능기술이라고 불린다.

POJO란 무엇인가?

POJO는 Plain Old Java Object의 첫 글자를 따서 만든 약자다.

POJO의 조건

단순하게 보자면 그냥 평범한 자바오브젝트라고 할 수 있지만 좀 더 명확하게 하자면 적어도 다음의 조건을 충족해야 POJO라고 불릴 수 있다.

  • 특정 규약에 종속되지 않는다.

    POJO는 자바 언어와 꼭 필요한 API 외에는 종속되지 않아야 한다. 따라서 EJB2와 같이 특정 규약을 따라 비즈니스 컴포넌트를 만들어야 하는 경우는 POJO가 아니다. 특정 규약을 따라 만들게 하는 경우는 대부분 규약에서 제시하는 특정 클래스를 상속하도록 요구한다. 그럴 경우 자바의 단일 상속 제한 때문에 더 이상 해당 클래스에 객체지향적인 설계 기법을 적용하기가 어려워지는 문제가 생긴다. 또한 규약이 적용된 환경에 종속적이 되기 때문에 다른 환경으로 이전이 힘들다는 문제점이 있다.

  • 특정 환경에 종속되지 않는다.

    어떤 경우는 특정 벤더의 서버나 특정 기업의 프레임워크 안에서만 동작 가능한 코드로 작성되기도 한다. 또 환경에 종속적인 클래스나 API를 직접 쓴 경우도 있다. 순수한 애플리케이션 로직을 담고 있는 오브젝트 코드가 특정 환경에 종속되게 만드는 경우라면 그것 역시 POJO라고 할 수 없다. POJO는 환경에 독립적이어야한다.
    특히 비즈니스 로직을 담고 있는 POJO 클래스는 웹이라는 환경정보나 웹 기술을 담고 있는 클래스나 인터페이스를 사용해서는 안된다. 비즈니스 로직을 담은 코드에 HttpServletRequest나 HttpSession, 캐시와 관련된 API가 등장하거나 웹 프레임워크의 클래스를 직접 이용하는 부분이 있다면 그것은 진정한 POJO라고 볼 수 없다.
    단지 자바의 문법을 지키고, 순수하게 JavaSE API만을 사용했다고 해서 그 코드를 POJO라고 할 수는 없다. POJO는 객체지향적인 자바 언어의 기본에 충실하게 만들어져야 하기 때문이다.

POJO의 장점

POJO가 될 수 있는 조건이 그대로 POJO의 장점이 된다.

특정한 기술과 환경에 종속되지 않는 오브젝트는 그만큼 깔끔한 코드가 될 수 있다. 로우레벨의 기술과 환경에 종속적인 코드가 비즈니스 로직과 함께 섞여 나오는 것만큼 지저분하고 복잡한 코드도 없다.

POJO로 개발된 코드는 자동화된 테스트에 매우 유리하다. 환경의 제약은 코드의 자동화된 테스트를 어렵게 한다. 컨테이너에서만 동작을 확인할 수 있는 EJB 2는 테스트하려면 서버의 구동 및 빌드와 배치 과정까지 필요하다. 자동화된 테스트가 불가능한 건 아니지만 매우 복잡하고 번거로우므로 대부분 수동 테스트 방식을 선호한다. 그에 반해 어떤 환경에도 종속되지 않은 POJO 코드는 매우 유연한 방식으로 원하는 레벨에서 코드를 빠르고 명확하게 테스트할 수 있다.

객체지향적인 설계를 자유롭게 적용할 수 있다는 것도 큰 장점이다.

POJO 프레임워크

스프링은 POJO를 이용한 엔터프라이즈 애플리케이션 개발을 목적으로 하는 프레임워크이다. POJO 프로그래밍이 가능하도록 기술적인 기반을 제공하는 프레임워크를 POJO 프레임워크라고 한다. 스프링은 엔터프라이즈 애플리케이션 개발의 모든 영역과 계층에서 POJO 방식의 구현이 가능하게 하려는 목적으로 만들어졌다.

스프링을 이용하면 POJO 프로그래밍의 장점을 그대로 살려서 엔터프라이즈 애플리케이션의 핵심 로직을 객체지향적인 POJO를 기반으로 깔끔하게 구현하고, 동시에 엔터프라이즈 환경의 각종 서비스와 기술적인 필요를 POJO 방식으로 만들어진 코드에 적용할 수 있다.

스프링은 비즈니스 로직의 복잡함과 엔터프라이즈 기술의 복잡함을 분리해서 구성할 수 있게 도와준다. 하지만 자신은 기술영역에만 관여하지 비즈니스 로직을 담당하는 POJO에서는 모습을 감춘다. 데이터 액세스 로직이나 웹 UI 로직을 다룰 때만 최소한의 방법으로 관여한다. POJO 프레임워크로서 스프링은 자신을 직접 노출하지 않으면서 애플리케이션을 POJO로 쉽게 개발할 수 있게 지원해준다.

스프링의 기술

제어의 역전(IoC) / 의존관계 주입(DI)

왜 두 개의 오브젝트를 분리해서 만들고, 인터페이스를 두고 느슨하게 연결한 뒤, 실제 사용할 대상은 DI를 통해 외부에서 지정하는 것일까? 직접 자신이 사용할 오브젝트를 new 키워드로 생성해서 사용하는 강한 결합을 쓰는 방법보다 나은 점은 무엇일까?

가장 간단한 답변은 **’유연한 확징이 가능하게 하기 위함’**이다. DI는 개방 폐쇄 원칙(OCP)이라는 객체지향 설계 원칙으로 잘 설명될 수 있다. 유연한 확장이라는 장점은 OCP의 ‘확장에는 열려 있다(개방)’에 해당한다. DI는 역시 OCP의 ‘변경에는 닫혀 있다(폐쇄)’라는 말로도 설명이 가능하다. 폐쇄 관점에서 볼 때 장점은 ‘재사용이 가능하다’라고 볼 수 있다.

DI의 활용 방법

  • 핵심기능의 변경

    DI의 가장 대표적인 적용 방법은 바로 의존 대상의 구현을 바꾸는 것이다. 디자인 패턴의 전략 패턴이 대표적인 예다. 실제 의존하는 대상이 가진 핵심기능을 DI 설정을 통해 변경하는 것이 대표적인 DI의 활용 방법이다.

  • 핵심기능의 동적인 변경

    두 번째 활용 방법은 첫 번째랑 비슷하게 의존 오브젝트의 핵심 기능 자체를 바꾸는 것이다. DI도 기본적으로는 런타임 시에 동적으로 의존 오브젝트를 연결해주는 것이긴 하지만, 일단 DI 되고 나면 그 후로는 바뀌지 않는다. 즉 동적인 방식으로 연결되지만 한 번 DI되면 바뀌지 않는 정적인 관계를 맺어주는 것이다. 하지만 DI를 잘 활용하면 애플리케이션이 동작하는 중간에 그 의존 대상을 다이내믹하게 변경할 수 있다.

  • 부가기능의 추가

    세 번째 활용 방법은 핵심기능은 그대로 둔 채로 부가기능을 추가하는 것이다. 데코레이터 패턴을 생각해보면 된다. 인터페이스를 두고 사용하게 하고, 실제 사용할 오브젝트는 외부에서 주입하는 DI를 적용해두면 데코레이터 패턴을 쉽게 적용할 수 있다. 그래서 핵심기능과 클라이언트 코드에는 전혀 영향을 주지 않으면서 부가적인 기능을 얼마든지 추가할 수 있다.

  • 인터페이스의 변경

    사용하려고 하는 오브젝트가 가진 인터페이스가 클라이언트와 호환되지 않는 경우가 있다. 이렇게 클라이언트가 사용하는 인터페이스와 실제 오브젝트 사이에 인터페이스가 일치하지 않는 경우에도 DI가 유용하다. 디자인 패턴에서 말하는 오브젝트 방식의 어댑터 패턴의 응용이라고 볼 수 있다.
    이를 좀 더 일반화해서 아예 인터페이스가 다른 다양한 구현을 같은 방식으로 사용하도록, 중간에 인터페이스 어댑터 역할을 해주는 레이어를 하나 추가하는 방법도 있다. 서비스 추상화(PSA)가 그런 방법이다. PSA는 클라이언트가 일관성 있게 사용할 수 있는 인터페이스를 정의해주고 DI를 통해 어댑터 역할을 하는 오브젝트를 이용하게 해준다. 이를 통해 다른 인터페이스를 가진 로우레벨의 기술을 변경하거나 확장해가면서 사용할 수 있는 것이다.

  • 프록시

    필요한 시점에서 실제 사용할 오브젝트를 초기화하고 리소스를 준비하게 해주는 지연된 로딩(lazy loading)을 적용하려면 프록시가 필요하다. 원격 오브젝트를 호출할 때 마치 로컬에 존재하는 오브젝트처럼 사용할 수 있게 해주는 원격 프록시를 적용하려고 할 때도 프록시가 필요하다. 두 가지 방법 모두 DI를 필요로 한다.

  • 템플릿과 콜백

    템플릿/콜백 패턴은 DI의 특별한 적용 방법이다. 반복적으로 등장하지만 항상 고정적인 작업 흐름과 그 사이에서 자주 바뀌는 부분을 분리해서 템플릿과 콜백으로 만들 고 이를 DI 원리를 응용해 적용하면 지저분하게 매번 만들어야 하는 코드를 간결하게 만들 수 있다.

  • 싱글톤과 오브젝트 스코프

    DI가 필요한 중요한 이유 중 한 가지는 DI 할 오브젝트의 생명주기를 제어할 수 있다는 것이다. DI를 프레임워크로 이용한다는 건 DI 대상 오브젝트를 컨테이너가 관리한다는 의미다. 오브젝트의 생성부터 관계 설정, 이용, 소멸에 이르기까지의 모든 과정을 DI 컨테이너가 주관하기 때문에 그 오브젝트의 스코프를 자유롭게 제어할 수 있다.
    스프링의 DI는 기본적으로 싱글톤으로 오브젝트를 만들어서 사용하게 한다. 컨테이너가 알아서 싱글톤으로 만들고 관리하기 때문에 클래스 자체는 싱글톤을 고려하지 않고 자유롭게 설계해도 된다는 장점이 있다.

  • 테스트

    다른 오브젝트와 협력해서 동작하는 오브젝트를 효과적으로 테스트하는 방법은 가능한 한 고립시키는 것이다. 즉 다른 오브젝트와의 사이에서 일어나는 일을 테스트를 위해 조작할 수 있도록 만든다. 그래야만 테스트 대상인 오브젝트의 기능에 충실하게 테스트가 가능하다. 복잡한 테스트할 대상에 의존하는 오브젝트를, 테스트를 목적으로 만들어진 목 오브젝트로 대체하면 유용하다.

애스펙트 지향 프로그래밍(AOP)

AOP도 스프링의 3개 기술중의 하나다. 사실 애스펙트 지향 프로그래밍은 객체지향 프로그래밍(OOP)처럼 독립적인 프로그래밍 패러다임이 아니다. AOP와 OOP는 서로 배타적이 아니라는 말이다.

객체지향 기술은 매우 성공적은 프로그래밍 방식임에 분명하다. 하지만 한편으로는 복잡해져 가는 애플리케이션의 요구조건과 기술적인 난해함을 모두 해결하는데 한계가 있기도 하다. AOP는 바로 이러한 객체지향 기술의 한계와 단점을 극복하도록 도와주는 보조적인 프로그래밍 기술이다.

IOC/DI를 이용해서 POJO에 선언적인 엔터프라이즈 서비스를 제공할 수 있지만 일부 서비스는 순수한 객체지향 기법만으로는 POJO의 조건을 유지한 채로 적용하기 힘들다. 바로 이런 문제를 해결하기 위해 AOP가 필요하다.

AOP 적용 기법

AOP를 자바 언어에 적용하는 기법은 크게 두 가지로 분류할 수 있다.

  • 스프링과 같이 다이내믹 프록시를 사용하는 방법

    이 방법은 기존 코드에 영향을 주지 않고 부가기능을 적용하게 해주는 데코레이터 패턴을 응용한 것이다. 만들기 쉽고 적용하기 간편하지만 부가기능을 부여할 수 있는 곳은 메소드의 호출이 일어나는 지점뿐이라는 제약이 있다. 인터페이스와 DI를 활용하는 데코레이터 패턴이 기반원리이기 때문이다.

  • 자바 언어의 한계를 넘어서는 언어의 확장을 이용하는 방법

    AspectJ라는 유명한 오픈소스 AOP 툴이 있다. AspectJ는 프록시 방식의 AOP에서는 불가능한 다양한 조인포인트를 제공한다. 메소드 호출뿐 아니라 인스턴스 생성, 필드 액세스, 특정 호출 경로를 가진 메소드 호출 등에도 부가기능을 제공할 수 있다. 이런 고급 AOP 기능을 적용하려면 자바 언어와 JDK의 지원만으로는 불가능하다. 그 대신 별도의 AOP 컴파일러를 이용한 빌드 과정을 거치거나, 클래스가 메모리로 로딩될 때 그 바이트 코드를 조작하는 위빙과 같은 별도의 방법을 이용해야 한다.

포터블 서비스 추상화(PSA)

세 번째 기능기술은 환경과 세부 기술의 변화에 관계없이 일관된 방식으로 기술에 접근 할 수 있게 해주는 PSA(Portable Service Abstraction)다. POJO로 개발된 코드는 특정 환경이나 구현 방식에 종속적이지 않아야 한다. 스프링은 JavaEE를 기존 플랫폼으로 하는 자바 엔터프라이즈 개발에 주로 사용된다. 따라서 다양한 JavaEE 기술에 의존적일 수밖에 없다 .특정 환경과 기술에 종속적이지 않다는 게 그런 기술을 사용하지 않는다는 뜻은 아니다. 다만 POJO 코드가 그런 기술에 직접 노출되어 만들어지지 않는다는 말이다. 이를 위해 스프링이 제공하는 대표적인 기술이 바로 일관성 있는 서비스 추상화 기술이다.

참고

토비의 스프링 3.1 Vol. 1 스프링의 이해와 원리