1. Overview

코드를 작성하다보면 로깅, 예외 처리 등등 반복되어 작성하는 코드가 발생합니다.

이런 경우 코드의 중복을 없애기 위한 패턴 중 하나가 템플릿 메서드 패턴입니다.

AbstractClass (추상 클래스) 는 템플릿을 제공하고 이를 상속 받는 하위 클래스가 구체적인 로직을 작성합니다.

추상 클래스가 전체적인 골격을 정의하고 일부 로직은 하위 상속 클래스에서 구현합니다.

중복된 로직은 추상 클래스에 정의하고 달라지는 비즈니스 로직만 상속 클래스에서 재정의 (오버라이딩) 합니다.

여기서 중복된 로직은 일반적으로 "변하지 않는 부분" 이고 비즈니스 로직은 "변하는 부분" 이라고 할 수 있습니다.


2. Example

간단한 예시 코드를 작성해봅니다.

  • 비즈니스 로직 1 존재
  • 비즈니스 로직 2 존재
  • 각 비즈니스 로직의 실행 시간을 측정하는 공통된 로직 존재

2.1. Before

public class BeforeTemplateMethodApp {

    public static void main(String[] args) {
        logic1();
        logic2();
    }

    private static void logic1() {
        StopWatch stopWatch = new StopWatch();
        stopWatch.start();

        // 비즈니스 로직 시작
        System.out.println("비즈니스 로직 1 실행");
        // 비즈니스 로직 종료

        stopWatch.stop();
        System.out.println("실행 시간 = " + stopWatch.getTotalTimeMillis());
    }

    private static void logic2() {
        StopWatch stopWatch = new StopWatch();
        stopWatch.start();

        // 비즈니스 로직 시작
        System.out.println("비즈니스 로직 2 실행");
        // 비즈니스 로직 종료

        stopWatch.stop();
        System.out.println("실행 시간 = " + stopWatch.getTotalTimeMillis());
    }
}

요구사항을 단순하게 구현하면 이렇게 공통된 로직이 존재합니다.

위 코드에서 다른 부분은 "비즈니스 로직 실행" 하나뿐이고 나머지는 전부 중복된 코드입니다.

템플릿 메서드 패턴을 적용해서 리팩토링 해봅시다.


2.2. Abstract Class

public abstract class AbstractTemplate {

    public void execute() {
        StopWatch stopWatch = new StopWatch();
        stopWatch.start();

        // 비즈니스 로직 시작
        call();
        // 비즈니스 로직 종료

        stopWatch.stop();
        System.out.println("실행 시간 = " + stopWatch.getTotalTimeMillis());
    }

    protected abstract void call();
}

공통된 로직을 갖고 있는 추상 클래스 입니다.

execute() 메서드 내부에서 비즈니스 로직 부분은 call() 메서드 호출로 대체합니다.

call() 메서드는 이 추상 클래스를 상속하는 자식 클래스에서 오버라이딩 합니다.


2.3. Sub Class

public class SubClassLogic1 extends AbstractTemplate {

    @Override
    protected void call() {
        System.out.println("비즈니스 로직 1 실행");
    }
}

public class SubClassLogic2 extends AbstractTemplate {

    @Override
    protected void call() {
        System.out.println("비즈니스 로직 2 실행");
    }
}

추상 클래스를 상식하는 SubClass 입니다.

비즈니스 로직만 정의해둔 간단한 메서드입니다.

이후에 또다른 비즈니스 로직 3 이 필요하다면 SubClassLogic3 을 정의해서 사용하면 됩니다.


2.4. Application (Client)

public class AfterTemplateMethodApp {

    public static void main(String[] args) {
        AbstractTemplate template1 = new SubClassLogic1();
        template1.execute();

        AbstractTemplate template2 = new SubClassLogic2();
        template2.execute();
    }
}

실제로 사용하는 부분입니다.

execute() 메서드를 호출하는 건 동일하지만 어떤 객체를 만드냐에 따라 로직이 달라집니다.


3. 장단점

  • 장점
    • 중복된 코드를 없애고 SubClass 에서는 비즈니스 로직에만 집중할 수 있음 (SRP)
    • 나중에 새로운 비즈니스 로직이 추가되어도 기존 코드를 수정하지 않아도 됨 (OCP)
  • 단점
    • 클래스 파일을 계속 만들어야 함
    • 자식 클래스는 실제로 부모 클래스를 사용하지 않는데 단순히 패턴 구현을 위한 상속 때문에 의존 관계를 갖고 있음

템플릿 메서드 패턴과 비슷한 역할을 하면서 상속의 단점을 제거할 수 있는 디자인 패턴으로 Strategy (전략) 패턴이 있습니다.


Reference

1. Overview

Factory 패턴 시리즈의 마지막인 추상 팩토리 패턴입니다.

추상 팩토리는 얼핏 보면 팩토리 메서드 패턴과 비슷하다고 느낄 수도 있습니다.

가장 큰 차이점은 팩토리 메서드 패턴은 어떤 객체를 생성 할지에 집중하고 추상 팩토리 패턴은 연관된 객체들을 모아둔다는 것에 집중합니다.


2. Abstract Method

추상 팩토리 패턴은 연관된 객체들의 생성을 하나의 팩토리에서 담당합니다.


3. Example

스포츠 팀을 만든다고 가정합니다.

스포츠 팀에는 플레이어와 매니저가 필수로 존재하기 때문에 팀의 구성요소라고 볼 수 있습니다.


3.1. ProductA (Manager)

public interface Manager {
}

public class SoccerManager implements Manager {
}

public class TennisManager implements Manager {
}
  • Manager 인터페이스와 클래스를 정의합니다.
  • 축구팀과 테니스팀이 존재하기 때문에 두 개의 Manager 구현 클래스를 정의합니다.

3.2. ProductB (Player)

public interface Player {
}

public class SoccerPlayer implements Player {
}

public class TennisPlayer implements Player {
}
  • Player 인터페이스와 클래스를 정의합니다.
  • 매니저와 마찬가지로 축구 선수와 테니스 선수를 정의합니다.

3.3. Factory (StaffFactory)

public interface StaffFactory {
    Manager createManager();
    Player createPlayer();
}

public class SoccerStaffFactory implements StaffFactory {

    @Override
    public Manager createManager() {
        return new SoccerManager();
    }

    @Override
    public Player createPlayer() {
        return new SoccerPlayer();
    }
}

public class TennisStaffFactory implements StaffFactory {

    @Override
    public Manager createManager() {
        return new TennisManager();
    }

    @Override
    public Player createPlayer() {
        return new TennisPlayer();
    }
}
  • Product 를 생성하는 Factory 클래스를 정의합니다.
  • Manager, Player 는 축구라는 하나의 공통점으로 묶을 수 있습니다.
  • 그래서 SoccerManager, SoccerPlayer 를 생산하는 SoccerStaffFactory 와 반대로 테니스 객체들을 생성하는 TennisStaffFactory 를 정의합니다.
  • 단순하게 생각하면 팩토리 메서드 패턴과 동일하지만 공통된 집합을 모아둔다는 점이 특징입니다.

3.4. Client

public class AbstractFactoryApp {
    public static void main(String[] args) {
        use(new SoccerStaffFactory());
        use(new TennisStaffFactory());
    }

    private static void use(StaffFactory factory) {
        Manager manager = factory.createManager();
        Player player = factory.createPlayer();
    }
}
  • 구체 클래스가 아닌 인터페이스에 의존하게 작성할 수 있습니다.
  • 어떤 Factory 를 넘겨받는지에 관계 없이 클라이언트는 Manager, Player 를 생성해서 사용할 수 있습니다.

3.5. 의존 관계 다이어그램

의존 관계 그림을 보면 맨 처음에 봤던 다이어그램과 동일한 것을 볼 수 있습니다.


4. 장단점

  • 장점
    • 팩토리 메서드 패턴과 마찬가지로 수정에는 닫혀 있고 확장에는 열려 있습니다.
    • 여러 개의 비슷한 집합 객체 생성을 하나의 팩토리에 모아둘 수 있습니다. (위 예시 뿐만 아니라 자동차의 부품 등)
  • 단점
    • 팩토리 메서드 패턴과 마찬가지로 클래스 갯수가 늘어납니다.

Reference

1. Overview

Factory 패턴은 객체 생성과 관련된 디자인 패턴입니다.

1편에서 봤던 Simple Factory 는 객체 생성 역할을 담당하면서 각 클라이언트에서 구현 클래스에 직접 의존하지 않도록 분리했습니다.

하지만 새로운 클래스가 추가되었을 때 Factory 클래스를 수정해야 한다는 한계가 있었습니다.

기존 코드의 변경 없이 확장하기 위한 디자인 패턴이 Factory Method 패턴입니다.


2. Factory Method

Factory Method Pattern (팩토리 메소드 패턴) 은 생성 패턴 중 하나로 객체를 생성할 때 어떤 클래스의 인스턴스를 만들 지 서브 클래스에서 결정하게 합니다.

즉, 인스턴스 생성을 서브 클래스에게 위임합니다.

부모 추상 클래스는 인터페이스에만 의존하고 실제로 어떤 구현 클래스를 호출할 지는 서브 클래스에서 구현합니다.

이렇게 하면 새로운 구현 클래스가 추가되어도 기존 Factory 코드의 수정 없이 새로운 Factory 를 추가하면 됩니다.


3. Example

사용자 관리 프로그램이 있고 네이버 계정으로 가입할 수 있다고 가정합니다.


2.1. Product (User)

public interface User {
    void signup();
}
  • User 인터페이스 정의

public class NaverUser implements User {
    @Override
    public void signup() {
        System.out.println("네이버 아이디로 가입");
    }
}
  • User 인터페이스를 구현하는 NaverUser 클래스 정의
  • 오버라이드한 메서드에는 네이버 유저 전용 로직 추가

2.2. Creator (UserFactory)

public abstract class UserFactory {

    public User newInstance() {
        User user = createUser();
        user.signup();
        return user;
    }

    protected abstract User createUser();
}
  • 추상 클래스로 UserFactory 를 정의
  • 외부에서 User 객체를 생성할 때는 newInstance() 메서드를 호출하면 되고, 실제로 어떤 객체를 생성할 지는 추상 메서드로 정의해서 하위 클래스에서 정의
  • Java 8 부터는 인터페이스에서 default 메서드를 사용할 수 있기 때문에 인터페이스로 정의할 수도 있지만 protected 키워드를 사용해 접근을 제한하고 싶어서 추상 클래스를 사용

public class NaverUserFactory extends UserFactory {
    @Override
    protected User createUser() {
        return new NaverUser();
    }
}
  • UserFactory 를 상속받는 NaverUserFactory 를 정의
  • NaverUser 를 반환하도록 오버라이드

2.3. Client

UserFactory userFactory = new NaverUserFactory();
User user = userFactory.newInstance();
  • 클라이언트 코드에서 NaverUser 클래스에 대한 의존성 없이 사용 가능
  • 의존성 주입을 사용해서 외부에서 Factory 클래스를 받아온다면 NaverUserFactory 에 대한 의존성도 제거 가능

3. 스펙 확장

팩토리 메서드 패턴의 장점은 확장할 때 기존 코드의 변경이 없어도 된다는 점이라고 했습니다.

카카오 서비스가 오픈되고 사용자가 많아져서 카카오 계정으로도 가입할 수 있게 확장되었다고 가정합니다.

Simple Factory 에서는 UserFactory 코드를 수정해야 했습니다.

깜빡하고 switch-case 문을 추가하지 않으면 코드에 오류가 생기며 Enum 으로 어느정도 방어할 수는 있지만 수정에도 열려있다는 단점은 변하지 않습니다.

하지만 우리는 Factory Method 패턴을 적용했기 때문에 기존 코드 (User, NaverUser, UserFactory, NaverUserFactory) 의 수정 없이 새로운 코드를 추가만 하면 됩니다.


3.1. 새로운 Product 와 Creator

public class KakaoUser implements User {
    @Override
    public void signup() {
        System.out.println("카카오 아이디로 가입");
    }
}
  • NaverUser 클래스와 동일하게 User 인터페이스를 구현하는 KakaoUser 클래스 추가

public class KakaoUserFactory extends UserFactory {
    @Override
    protected User createUser() {
        return new KakaoUser();
    }
}
  • NaverUserFactory 클래스와 동일하게 KakaoUserFactory 정의

UserFactory userFactory = new NaverUserFactory();
User user = userFactory.newInstance();

// 위 클라이언트 코드 수정 없이 다른 곳에서 사용 가능
UserFactory kakaoUserFactory = new KakaoUserFactory();
User kakaoUser = kakaoUserFactory.newInstance();
  • 기존 코드의 변경 없이 새로 선언한 클래스만 사용하여 확장 가능

4. 장단점

  • 장점: Factory Method 패턴의 가장 큰 장점은 지금까지 본 것처럼 수정에 닫혀있고 확장에는 열려있는 OCP 원칙을 지킬 수 있다는 점입니다.
  • 단점: 간단한 기능을 사용할 때보다 많은 클래스를 정의해야 하기 때문에 코드량이 증가합니다.

Reference

1. Overview

Factory 패턴은 객체 생성 역할을 별도의 클래스 (Factory) 에게 위임하는 것이 가장 궁극적인 목표입니다.

디자인 패턴 중 Facotry 와 관련된 패턴은 크게 두 가지가 있습니다.

팩토리 메서드 패턴과 추상 팩토리 패턴인데요.

이 두 가지 패턴의 베이스가 되는 가장 단순한 형태의 Factory 패턴이 존재합니다.

엄밀히 따지면 디자인 패턴이 아니라 객체 지향 프로그래밍에서의 자주 쓰이는 관용구 느낌이라 별도의 이름은 없지만 Simple Factory 라는 이름으로 많이 불립니다.

이 글에서는 Simple Factory 에 대해 알아보고 이후 나머지 두 패턴에 대해 알아볼 예정입니다.


2. Simple Factory

Simple Factory 는 굉장히 단순합니다.

객체는 여러 곳에서 생성될 수 있는데, 호출하는 쪽이 객체의 생성자에 직접 의존하고 있으면 나중에 변경되었을 때 수정되어야 하는 코드가 많이 발생합니다.

그래서 생성자 호출 (new) 을 별도의 클래스 (Factory) 에서 담당하고 클라이언트 코드에서는 팩토리를 통해 객체를 생성합니다.


3. Example

public interface Pet {
}

public class Cat implements Pet {
}

public class Dog implements Pet {
}

애완 동물을 한번 예시로 들어봅니다.

공통 인터페이스인 Pet 을 정의하고 이를 구현하는 Cat, Dog 클래스를 만들었습니다.

이제 클라이언트 코드에서 Cat, Dog 을 사용하기 위해 생성할 수 있습니다.


3.1. Before

Pet cat = new Cat();
Pet dog = new Dog();

일반적인 사용법은 new 를 사용해 구현 클래스를 생성한 후 호출하는 겁니다.

하지만 이렇게 하면 Client 와 클래스들 사이에 다음과 같은 의존관계가 생깁니다.


이렇게 구현 클래스를 직접 의존하고 있으면 해당 클래스의 생성자나 전처리 코드가 변경되었을 때 사용하는 모든 Client 코드를 변경해야 합니다.

그래서 객체의 생성만을 담당하는 별도의 Factory 클래스를 만들어 생성 역할을 넘겨봅니다.


3.2. After

public interface Pet {
    enum Type {
        CAT, DOG
    }
}

우선 Pet 인터페이스에 enum 으로 타입을 선언합니다.


public class PetFactory {

    public Pet createPet(Pet.Type petType) {
        switch (petType) {
            case CAT:
                return new Cat();
            case DOG:
                return new Dog();
            default:
                throw new IllegalArgumentException("Pet 타입이 아닙니다");
        }
    }
}

PetFactory 를 만든 후 Pet.Type 에 따라 다른 객체를 생성해서 반환합니다.


PetFactory petFactory = new PetFactory();
Pet cat = petFactory.createPet(Pet.Type.CAT);
Pet dog = petFactory.createPet(Pet.Type.DOG);

PetFactory 를 선언한 후 생성 메서드만 호출하면 실제 구현 클래스인 Cat, Dog 에 의존하지 않은 코드를 작성할 수 있습니다.


의존 관계를 그림으로 표현하면 위와 같이 변경됩니다.

Client 에서 구현 클래스를 직접 의존하지 않기 때문에 나중에 클래스 이름이 변경되거나, 생성자가 변경되는 경우에도 PetFactory 내부만 수정하면 됩니다.


4. Simple Factory 의 한계

Simple Factory 는 앞서 말했듯이 디자인 패턴으로 분류되지는 않습니다.

이 패턴은 객체의 생성 역할을 담당하며 확장이 용이하다는 장점이 있지만 변경에 닫혀 있어야 한다는 OCP 원칙에 위배됩니다.

만약 새로운 애완 동물 구현 클래스로 Bird 가 추가 되었다고 가정합니다.

그럼 PetFactory 내부에 존재하는 switch 문에 해당 클래스를 추가해줘야 합니다.

객체지향 원칙은 확장을 할 때 기존 코드에 영향을 주지 않는 것을 지향합니다.

팩토리 메서드나 추상 팩토리 패턴을 활용한다면 기존 클래스에 영향을 주지 않고 확장이 가능합니다.

인덱스에 대해 알고 있는 정보를 정리합니다.

잘못된 정보가 있으면 지적 부탁드립니다.

Overview

인덱스는 "목차" 라고 할 수 있습니다.

예를 들어 책에서 어떤 단어의 위치를 찾으려면 책을 전부 다 봐야하지만 가나다순으로 목차를 만들어두면 쉽게 찾을 수 있습니다.

목차는 순서대로 정렬되어 있기 때문에 데이터를 더 빠르게 찾을 수 있게 도와줍니다.

하지만 목차를 넣으면 별도 페이지를 만들어야 하기 때문에 책이 조금 더 두꺼워집니다.

그리고 새로운 단어가 추가되면 목차에도 추가해줘야 하고 중간 단어를 넣는다면 순서를 조정해서 페이지도 다시 만들어야 합니다.


1. 인덱스란

인덱스는 위에서 설명한 목차와 비슷합니다.

어떤 데이터를 찾을 때 DB 테이블을 찾는 대신 미리 만들어둔 인덱스를 먼저 탐색합니다.

인덱스를 설정하면 특정 컬럼들을 키 값으로 메모리 영역에 트리 구조로 저장해둡니다.

그리고 디스크 저장소에 바로 접근하는 대신 메모리 저장소에 있는 인덱스를 먼저 조회해서 빠르게 데이터를 가져올 수 있습니다.

인덱스가 데이터를 빠르게 가져올 수 있는 이유는 항상 정렬된 상태를 유지하기 때문입니다.

이를 위해 데이터가 추가/삭제 될 때마다 자료구조를 정렬하기 때문에 인덱스는 SELECT 성능을 향상시키는 대신 INSERT, UPDATE, DELETE 의 성능이 떨어지게 됩니다.


2. 인덱스 종류

인덱스 종류는 어떤 자료구조를 사용하냐에 따라 나눌수도 있고, 데이터 저장 방식에 따라 클러스터드 (Clustered) 인덱스와 넌클러스티드 (Non Clustered) 인덱스로 나누기도 합니다.


2.1. Clustered Index vs Non-Clustered Index

  • Clustered Index
    • 이름 그대로 인접한 데이터들을 한곳으로 모았다는 뜻
    • PK 설정 시 자동으로 클러스터드 인덱스로 만들어짐
    • 테이블당 1개씩만 허용
    • 물리적인 데이터를 갖고 있음
    • 항상 정렬된 상태를 유지하고 노드 내에서도 정렬되어 있음
    • Non Clustered 인덱스에 비해 조회 속도가 빠르지만 삽입/수정/삭제는 더 느림
  • Non Clustered Index
    • UNIQUE 로 설정된 컬럼에 자동으로 생성됨
    • 인덱스 페이지는 로그 파일에 저장됨
    • 레코드의 원본은 정렬되지 않고 인덱스 페이지만 정렬됨

2.2. Primary Index vs Secondary Index

  • Primary Index
    • PK (기본키) 를 기반으로 만들어진 인덱스
    • PK 는 하나만 존재할 수 있기 때문에 Primary Index 도 단 하나만 존재
  • Secondary Index
    • 기본키는 아니지만 성능 향상을 위해 임의의 컬럼을 지정해서 만든 인덱스
    • 여러 개의 Secondary Index 가 존재할 수 있음

2.3. 자료구조에 따른 분류

  • B-Tree 인덱스
    • 가장 많이 사용되는 구조
  • Hash 인덱스
    • 컬럼 값을 해싱해서 사용하며 매우 빠른 검색 가능
    • 컬럼 값과 인덱스 키 값이 일치하지 않기 때문에 문자열 검색과 같이 일부 일치에 대해 검색 불가능
  • Fractal-Tree 인덱스
    • B-Tree 인덱스의 단점을 보완하기 위해 만듬
    • 컬럼 값을 변형하지 않으며 데이터의 저장/삭제 처리 비용을 많이 줄임
    • 아직 많이 사용되지는 않음

3. B-Tree 인덱스

Balanced Tree 의 약자로서 데이터베이스 인덱싱 알고리즘 가운데 가장 일반적으로 사용되는 알고리즘 입니다.

B-Tree 인덱스는 컬럼의 값을 변경시키지 않고 구조체 내에서 항상 정렬된 상태를 유지합니다.

B-Tree 는 최상위에 루트 (Root) 노드가 존재하고 하위에 브랜치 (Branch) 노드, 마지막에 리프 (Leaf) 노드로 되어 있습니다.

부모 노드를 기준으로 왼쪽 자식 노드는 더 작은 값 오른쪽 자식 노드는 더 큰값을 갖고 있습니다.


3.1. 왜 B-Tree 인가?

인덱스로 사용할 수 있는 자료구조는 여러 개가 있을 겁니다.

Tree 구조의 Worst 시간복잡도는 한쪽으로 모든 자식 노드가 쏠려있는 형태인 O(n) 입니다.

그래서 우리는 자식 노드가 양쪽에 골고루 퍼져있는 Balanced Tree 를 사용합니다.

Balanced Tree 중에는 RedBlack Tree 도 있는데 왜 사용하지 않을까요?

해시 테이블은 O(1) 인데 왜 사용하지 않을까요?


3.2. 다른 트리 구조를 사용하지 않는 이유

위에서 잠시 언급한 RedBlack Tree 는 B-Tree 와 마찬가지로 정렬된 상태와 밸런스를 유지합니다.

그럼 B-Tree 와 차이가 없을 것 같은데 왜 사용하지 않을까요?

가장 큰 차이점은 B-Tree 는 노드 하나에 여러 개의 데이터를 저장할 수 있다는 겁니다.

노드에서 배열 형태로 여러 데이터를 저장할 수 있기 때문에 트리 포인터를 참조해서 계속 depth 를 타고 들어가는 것보다 효율적이고 이는 데이터가 많아질수록 차이가 두드러집니다.


3.3. Hash 테이블을 사용하지 않는 이유

해시 테이블은 Hash 함수를 사용해서 키 값을 해싱한 후에 테이블에 저장합니다.

해시 테이블은 분명 한 가지 키에 대한 탐색은 효율적입니다.

하지만 데이터가 정렬되어 있지 않기 때문에 부등호 (<, >) 를 사용하지 못한다는 단점이 있습니다.


3.4. B-Tree 인덱스의 쿼리별 특징

  • SELECT
    • 특정 키 값을 찾기 위해 자식 노드를 계속 타고 들어가는 방식
    • 마지막 리프 노드에는 레코드의 주소가 존재하고 이 값으로 테이블 레코드를 찾을 수 있음
  • INSERT
    • B-Tree 에 새로운 키값을 저장할 때는 우선 적절한 위치를 찾아야함
    • 새로운 키값과 레코드 정보는 리프 노드에 저장
    • 만약 리프 노드가 꽉찼다면 트리를 재구성하여 리프 노드를 분리
    • 분리 과정에서 해당 리프 노드의 부모 노드까지 영향이 갈 수 있음
    • 이러한 이유로 INSERT 작업은 상대적으로 비용이 많이 듬
    • 인덱스가 많으면 많을수록 이런 비용이 추가로 들기 때문에 너무 많은 인덱스를 추가하는 건 성능에 영향을 줌
  • DELETE
    • B-Tree 에서 키 값 삭제는 간단
    • 해당 키를 찾아서 삭제 마크만 하면 작업이 완료
    • 삭제 마킹된 인덱스 키 공간은 그대로 두거나 재활용 가능
  • UPDATE
    • 인덱스는 항상 정렬된 상태로 유지됨
    • 단순히 인덱스 키 값을 수정한다면 트리의 전체 구조를 바꿔야 할 수도 있음
    • 그래서 B-Tree 에선 키 변경이 아닌 기존 키 삭제 (DELETE) 후 새로운 키 추가 (INSERT) 방식을 사용
    • 따라서 키 값의 잦은 수정은 성능에 큰 영향을 미침

4. 인덱스 설정 시 고려사항

인덱스는 조회 속도를 향상시키지만 어느정도 오버헤드가 존재하기 때문에 아무렇게나 추가해버리면 안됩니다.


4.1. 인덱스의 갯수

인덱스의 갯수는 3 ~ 4 개가 적당합니다.

인덱스의 갯수가 너무 많으면 다음과 같은 이슈가 존재합니다.

  • 데이터 삽입/수정/삭제 시마다 인덱스도 같이 추가하거나 수정/삭제 해주어야 해서 성능상 이슈가 존재
  • 데이터 삽입시마다 인덱스도 같이 추가하기 때문에 인덱스가 늘어날수록 더 많은 메모리를 차지함
  • 인덱스가 많아지면 옵티마이저가 잘못된 인덱스를 선택할 확률이 높아짐 (인덱스 힌트로 원하는 인덱스를 지정할 순 있음)

4.2. 인덱스를 걸기에 적절한 컬럼

인덱스의 갯수에 한계가 있다면 적절한 인덱스 컬럼을 정하는 것도 중요합니다.

인덱스는 카디널리티 (Cardinality) 가 높은 컬럼에 지정하는 게 좋습니다.

카디널리티가 높다는 말은 데이터의 중복이 적다는 뜻인데 대표적으로 ID, 주민번호 등이 있습니다.

반대로 성별 같은 중복된 데이터가 많은 경우 카디널리티가 낮다고 표현합니다.

성별에 인덱스를 거는 경우 인덱스를 타더라도 남/녀 두가지만 존재하기 때문에 결국 나머지 조건에 맞는 데이터는 직접 풀스캔을 해서 찾아야 합니다.

하지만 ID 같이 중복된 값이 없는 경우 해당하는 데이터를 빠르게 찾을 수 있습니다.


4.3. 읽어야 하는 레코드 갯수

인덱스는 일반적으로 단 하나의 데이터를 구할 때 가장 효율적입니다.

여러 개의 데이터를 구한다면 인덱스를 통해 레코드의 주소를 찾아 데이터의 레코드를 읽는 작업을 반복해야 합니다.

그래서 만약 많은 레코드를 한번에 조회한다면 오히려 인덱스를 사용하지 않고 직접 테이블을 읽는 것이 더 효율적일 수 있습니다.

예를 들어 테이블에 10 개의 데이터가 존재하고, 5건의 데이터를 읽는다고 가정한다면 인덱스를 통하는 것보다 테이블을 직접 읽는 게 효율적일 수도 있습니다.

일반적으로 DBMS 의 옵티마이저는 인덱스를 사용해 레코드 1건을 읽는 것이 테이블에서 직접 읽는 것보다 4 ~ 5배 정도 비용이 더 많이 든다고 예측합니다.

그러므로 인덱스를 통해 읽어야 할 레코드가 전체 테이블의 20 ~ 25% 이상이라면 직접 테이블을 읽는 것이 효율적입니다.

사실 인덱스가 걸려있어도 옵티마이저가 판단하여 더 효율적이라고 생각되는 방법을 선택하기 때문에 개발자가 직접 고려할 일은 없지만 기본적으로 알 고 있으면 좋습니다.


4.4. 복합 인덱스를 구성할 때

인덱스는 여러 개의 컬럼을 동시에 지정할 수도 있는데 어떤 순서로 구성하느냐에 따라 성능이 달라집니다.

위에서 인덱스는 트리 구조로 되어있다고 했는데, 여러 개의 컬럼을 함께 키 값으로 지정하는 경우 먼저 첫 번재 컬럼을 기준으로 정렬된 뒤에 두번째 컬럼이 정렬되어 있습니다.

이 말은 즉 첫 번째 컬럼 없이 두 번째 컬럼만 갖고 인덱스를 조회하면 제대로 된 위치를 찾을 수 없다는 뜻입니다.

그러므로 복합 인덱스를 구성했다면 조회할 때 앞 순서의 조건을 반드시 포함해야 인덱스를 태울 수 있습니다.

예를 들어 A, B, C 컬럼 순으로 인덱스를 구성했다면 A 라는 컬럼을 조회 조건에 포함해야 최소한의 인덱스를 태울 수 있고 B, C 컬럼만 조회 조건에 있다면 인덱스를 타지 못합니다. (정확히 말하면 인덱스 풀 스캔을 하는데 이런 경우 일반적으로 인덱스를 타지 않는다고 표현)

그리고 여러 개의 컬럼이 있다면 카디널리티가 높은 순에서 낮은 순으로 지정하는게 인덱스의 효율을 이끌어낼 수 있습니다.

그리고 과거에는 인덱스의 컬럼 순서와 조회 컬럼 순서를 맞춰야 인덱스를 탔지만 최근에는 옵티마이저가 알아서 인덱스 순서에 맞춰주기 때문에 거의 차이가 없습니다.

그래도 재배열하는 과정을 생략하기 위해 최대한 맞추는게 좋긴 합니다.


5. 인덱스 사용 시 주의사항

지금까지도 조금씩 언급했지만 인덱스는 사용할 대 주의할 점이 몇개 존재합니다.

  • 다중 인덱스를 사용할 때 범위 조건은 인덱스를 타지만 이후 컬럼들은 인덱스를 타지 않음
    • 범위 조건으로 사용할 때 주의
  • 인덱스로 지정한 컬럼은 그대로 사용해야 함
    • WHERE age * 10 > 20 처럼 조회조건에 있는 컬럼을 변경하면 안됨
    • WHERE age > 20 / 10 처럼 컬럼을 그대로 사용해야 함

6. InnoDB Adaptive Hash Index

MySQL 은 기본으로 InnoDB 를 사용하고 InnoDB 는 B-Tree 를 사용합니다.

PK 가 아닌 컬럼으로 인덱스를 지정하면 Secondary Index 가 생성됩니다.

그래서 인덱스로 컬럼을 조회하면 Secondary 인덱스를 기반으로 PK 를 찾은 뒤 다시 Primary Index 로 데이터를 찾아냅니다.

인덱스를 두번 타기 때문에 2 * O(log n) 비용이 듭니다.

그래서 자주 사용되는 컬럼을 해시로 정의해서 B-Tree 를 타지 않고 바로 데이터에 접근할 수 있게 하는 걸 Adaptive Hash Index 라고 합니다.

미리 캐싱한 해시값으로 조회하기 때문애 O(1) 의 속도를 보여주지만 어떤 값을 해싱할지는 옵티마이저가 판단하기 때문에 제어할 수 없다는 약점이 있습니다.


7. Covering Index

일반적으로 인덱스를 설계할 때는 WHERE 절에 대해서만 이야기하지만 사실 쿼리 전체에 대해 인덱스 설계가 필요합니다.

인덱스를 사용하면 특정 컬럼 값을 키로 하여 데이터의 주소값을 구한 뒤 해당 주소값으로 다시 테이블에 접근해서 최종 데이터를 구합니다.

커버링 인덱스란 인덱스에 이미 필요한 데이터가 전부 존재해서 테이블에 접근할 필요가 없는 인덱스를 의미합니다.

예를 들어 (A, B) 컬럼으로 인덱스를 구성한 경우 SELECT * FROM table WHERE A = ? 와 같은 형식으로 쿼리를 사용하면 인덱스를 탄 후에 전체 컬럼값을 모두 구하기 위해 테이블에 접근합니다.

하지만 SELECT A, B FROM table WHERE A = ? 와 같이 구하려는 컬럼값이 모두 인덱스에 이미 존재한다면 테이블에 다시 접근할 필요가 없습니다.

인덱스는 기본적으로 Non Clustered Index 에서 먼저 값을 구하고 Clustered Index 에서 다시 데이터를 구합니다.

여기서 커버링 인덱스가 사용되었다는건 Clustered Index 까지 통하지 않고 Non Clustered Index 만으로도 데이터를 구할 수 있다는 뜻입니다.

커버링 인덱스가 적용되면 EXPLAIN 실행 시 Extra 필드에 Using index 라고 표시됩니다.


Reference

'공부 > Database' 카테고리의 다른 글

MySQL Optimizer 와 USE INDEX vs FORCE INDEX  (0) 2022.06.21
Cache 전략  (0) 2022.05.20
Redis 설치 및 명령어  (0) 2021.08.07

Overview

What is Caching ? 글을 참고하여 캐시 전략에 대해 정리했습니다.


1. Cache 란?

위키 백과에는 이렇게 나와 있습니다.

데이터나 값을 미리 복사해 놓는 임시 장소를 가리킨다.
캐시는 캐시의 접근 시간에 비해 원래 데이터를 접근하는 시간이 오래 걸리는 경우나 값을 다시 계산하는 시간을 절약하고 싶은 경우에 사용한다.
캐시에 데이터를 미리 복사해 놓으면 계산이나 접근 시간 없이 더 빠른 속도로 데이터에 접근할 수 있다.

설명 그대로 저장소에 접근하는 시간을 절약하기 위해 별도의 저장소에 저장해두는 것을 의미합니다.

서버에서는 MySQL, Oracle 같은 DBMS 에 매번 접근하지 않도록 메모리에 임시로 저장해두는 용도로 많이 사용합니다.

Redis 같은 별도의 캐시 저장소를 사용하는게 일반적이며, DB 내부에 있는 캐시, 브라우저 캐시, CDN 등등도 다 캐시라고 볼 수 있습니다.


2. 캐싱하기에 적절한 데이터

캐시가 효율적으로 동작하기 위해선 데이터가 다음과 같은 특징을 가져야 합니다.

  • 자주 조회됨
  • 데이터의 결과값이 일정함
  • 연산이 무거움

3. 읽기 캐시 전략

캐시를 읽을 때 어떤 전략을 사용할지 결정합니다.

기본적으로는 캐시 먼저 조회 후 DB 를 다시 확인한 뒤, 캐시를 갱신하는 방법입니다.


3.1. Cache Aside

  1. 캐시에 데이터가 있는지 확인
  2. 데이터가 존재하면 (Cache Hit) 해당 캐시 데이터를 반환
  3. 데이터가 존재하지 않으면 (Cache Miss) 애플리케이션에서 DB 에 데이터 요청 후 캐시에 저장하고 데이터를 반환

애플리케이션에서 가장 일반적으로 사용되는 캐시 전략입니다.

주로 읽기 작업이 많은 애플리케이션에 적합합니다.

Cache Hit 의 경우 DB 를 확인하지 않기 때문에 캐시가 최신 데이터를 가지고 있는지 (동기화) 가 중요합니다.

캐시가 분리되어 있기 때문에 원하는 데이터만 별도로 구성하여 캐시에 저장할 수 있고 캐시에 장애가 발생해도 DB 에서 데이터를 가져오는 데 문제가 없습니다.

하지만 캐시에 장애가 발생했다는 뜻은 DB 로 직접 오는 요청이 많아져서 전체적인 장애로 이어질 수 있습니다.


3.2. Read Through

  1. 캐시에 데이터 요청
  2. 캐시는 데이터가 있으면 (Cache Hit) 바로 반환
  3. 데이터가 없다면 (Cache Miss) 캐시가 DB 에서 데이터를 조회한 후에 캐시에 저장 후 반환

Cache Aside 와 비슷하지만 데이터 동기화를 라이브러리 또는 캐시 제공자에게 위임하는 방식이라는 차이점이 있습니다.

마찬가지로 읽기 작업이 많은 경우에 적합하며 두 방법 다 데이터를 처음 읽는 경우에는 Cache Miss 가 발생해서 느리다는 특징이 있습니다.

Cache Aside 와는 다르게 캐시에 의존성이 높기 때문에 캐시에 장애가 발생한 경우 바로 전체 장애로 이어집니다.

이를 방지하기 위해 Cache Cluster 등 가용성 높은 시스템을 구축해두는 것이 중요합니다.


3.3. 읽기 캐시에서 발생 가능한 장애: Thundering Herd

캐시 서버를 구축했다고 해서 아무런 문제가 없는 것은 아닙니다.

캐시 읽기 전략에서는 공통적으로 캐시 확인 -> DB 확인 순서로 이어지는데 이 과정에서 캐시에 데이터가 있으면 DB 확인을 생략하는 것으로 성능을 향상시킵니다.

하지만 서비스를 이제 막 오픈해서 캐시가 비어있는 경우에는 들어오는 요청이 전부 Cache Miss 가 발생하고 DB 조회 후 캐시를 갱신하느라 장애가 발생할 수 있습니다.

이를 회피하기 위해서 캐시에 데이터를 미리 세팅해두는 Cache Warm up 작업을 하거나 첫 요청이 캐시 갱신될 때까지 기다린 후에 이후 요청은 전부 캐시에서 반환하게 할 수 있습니다.

Cache Warm up 작업을 할 때 어떤 데이터를 넣느냐에 따라 마찬가지로 Cache Miss 가 발생할 수 있기 때문에 자주 들어올만한 데이터의 예측이 중요합니다.


4. 쓰기 캐시 전략

쓰기 요청 시 어떤 시점에 캐시 갱신을 하는지에 따라 나뉩니다.

  • Write Around: 캐시를 갱신하지 않음
  • Write Through: 캐시를 바로 갱신
  • Write Back: 캐시를 모아서 나중에 갱신

4.1. Write Around

  1. 데이터 추가/업데이트 요청이 들어오면 DB 에만 데이터를 반영
  2. 쓰기 작업에서 캐시는 건들지 않고 읽기 작업 시 Cache Miss 가 발생하면 업데이트 됨

캐시 쓰기 전략이라고 하기는 좀 애매하게 캐시를 전혀 건들지 않습니다.

수정사항은 DB 에만 반영하고 캐시는 그대로 두기 때문에 Cache Miss 가 발생하기 전까지는 캐시 갱신이 발생하지 않습니다.

Cache 가 갱신된지 얼마 안된 경우에는 캐시 Expire 처리 되기 전까지 계속 DB 와 다른 데이터를 갖고 있다는 단점이 있습니다.

만약 업데이트 이후 바로 조회되지 않을거라는 확신이 있다면 캐시를 초기화하여 Cache Miss 를 유도하는 방법으로 보완할 수 있습니다.


4.2. Write Through

  1. 캐시에 데이터를 추가하거나 업데이트
  2. 캐시가 DB 에 동기식으로 데이터 갱신
  3. 캐시 데이터를 반환

Read Through 와 마찬가지로 DB 동기화 작업을 캐시에게 위임합니다.

동기화까지 완료한 후에 데이터를 반환하기 때문에 캐시를 항상 최신 상태로 유지할 수 있다는 장점이 있습니다.

캐시 및 DB 를 동기식으로 갱신한 후에 최종 데이터 반환이 발생하기 때문에 전반적으로 느려질 수 있습니다.

새로운 데이터를 캐시에 미리 넣어두기 때문에 읽기 성능을 향상시킬 수 있지만 이후에 읽히지 않을 데이터도 넣어두는 리소스 낭비가 발생할 수 있습니다.


4.3. Write Back (Write Behind)

  1. 캐시에 데이터를 추가하거나 업데이트
  2. 캐시 데이터 반환
  3. 캐시에 있던 데이터는 이후에 별도 서비스 (이벤트 큐 등) 를 통해 DB 에 업데이트

캐시와 DB 동기화를 비동기로 하는 방법이며 동기화 과정이 생략되기 때문에 쓰기 작업이 많은 경우에 적합합니다.

캐시에서 일정 시간 또는 일정량의 데이터를 모아놓았다가 한번에 DB 에 업데이트 하기 때문에 쓰기 비용을 절약할 수 있습니다.

다른 캐시 전략에 비해 구현하기 복잡한 편이며 캐시에서 DB 로 데이터를 업데이트 하기 전에 장애가 발생하면 데이터가 유실될 수 있습니다.


4.4. Refresh Ahead

자주 사용되는 데이터를 캐시 만료 전에 미리 TTL (Expire time) 을 갱신합니다.

캐시 미스 발생을 최소화 할 수 있지만 Warm Up 작업과 마찬가지로 자주 사용되는 데이터를 잘 예측해야 합니다.


Reference

'공부 > Database' 카테고리의 다른 글

MySQL Optimizer 와 USE INDEX vs FORCE INDEX  (0) 2022.06.21
MySQL Index 특징 및 유의사항 정리  (2) 2022.05.22
Redis 설치 및 명령어  (0) 2021.08.07

Overview

애플리케이션을 개발하면 외부에서도 접근 가능하도록 클라우드 환경에 배포합니다.

이전에 포스팅 했던 AWS 1편에서는 마지막에 scp 명령어로 로컬에 존재하는 빌드 파일을 EC2 인스턴스로 복사한 후 ssh 로 접속해서 실행시켰습니다.

하지만 매 배포마다 이렇게 하면 굉장히 번거롭고 실수할 가능성도 높아집니다.

그래서 이런 수작업을 자동화하는 여러 가지 툴과 기법들이 등장했고 Github Actions 도 그 중 하나입니다.

Github Actions 에 대해서는 지난 포스팅 에서 한번 다룬 적이 있습니다.

이번에는 Github Actions 를 사용해서 AWS EC2 에 자동으로 배포하는 과정을 알아봅니다.

글은 다음과 같은 순서로 진행됩니다.

  1. Github Actions 에서 AWS 에 배포하는 방법
  2. AWS EC2 설정 추가
  3. AWS S3 버킷 생성
  4. AWS CodeDeploy 앱 생성 및 배포 설정
  5. Github Actions 에서 사용할 사용자 권한 추가
  6. AppSpec 파일 작성
  7. 배포 스크립트 작성
  8. Github Actions Workflow 작성
  9. Github 에서 push 로 배포하기

1. 배포 방법

main 브랜치에 Push 하면 자동으로 EC2 까지 배포되는 Workflow 를 만들어봅시다.

먼저 Workflow 를 작성하기 전에 어떤 방식으로 EC2 까지 배포가 이루어지는 지 전체적인 플로우를 알아야 합니다.

Github Actions 를 확인하면 CI 과정에서 했던 것처럼 aws.yml 이라는 기본 Workflow 를 제공합니다.

배포하는 방법은 여러 가지 있겠지만 AWS 의 경우 큰 흐름은 하나입니다.

소스 코드를 압축하여 AWS 스토리지에 저장 후 서버에 전달해서 실행한다

그리고 AWS 에서 공식적으로 가이드하는 방법은 크게 두 가지가 있습니다.

  1. AWS S3 빌드파일 압축해서 업로드 -> AWS EC2 배포 (CodeDeploy 활용)
  2. AWS ECR 에 도커 이미지 업로드 -> AWS ECS 배포 (Task Definition 활용)

1.1. 어떤 차이가 있을까?

우리는 EC2 인스턴스에 배포해야 하기 때문에 1번 방법을 사용합니다.

Github Actions 에서 제공하는 AWS Workflow 는 2번 방법을 안내하고 있어서 차이점을 먼저 알아봤습니다.

AWS ECR 은 Docker Image 를 저장하는 레지스트리고 AWS ECS 일종의 도커 컨테이너 서비스입니다.

AWS ECS 는 미리 정의한 Task Definition 을 기반으로 클러스터에 인스턴스를 생성하고 ECR 에 저장된 도커 이미지를 배포하는 등 인스턴스를 관리하며 스케일 인/아웃을 지원합니다.

여러 서버 인스턴스를 관리하기 위해선 더 편할 수 있으나 우리는 이미 존재하는 EC2 인스턴스에 배포하는 걸 목적으로 하기 때문에 1번 방법을 사용합니다.


1.2. 배포 과정

큰 흐름을 요약하면 다음과 같습니다.

  1. Github Actions 에서 코드 빌드 (테스트는 CI 에서 했다고 검증했다고 판단하여 생략)
  2. AWS 인증
  3. 코드 압축해서 AWS S3 에 업로드
  4. AWS CodeDeploy 실행하여 S3 에 있는 코드 EC2 에 배포

결국 Github 프로젝트 코드를 AWS S3 에 업로드 한 후 AWS EC2 에서 끌어다 쓰는 것이 가장 핵심이며 AWS CodeDeploy 는 그걸 보조해주는 역할을 담당합니다.

Github 에서 CodeDeploy, S3 에 접근하기 위한 권한이 필요하고 EC2 에서 S3 에 접근하기 위한 권한도 필요하기 때문에 설정이 조금 복잡하게 느껴질 수도 있습니다.

차근차근 진행 해보겠습니다.


2. EC2 설정 추가

AWS EC2 편 에서 생성한 인스턴스를 기준으로 아래 작업들을 추가로 진행합니다.

  1. Tag 추가 (CodeDeploy 에서 어떤 인스턴스에 실행할 지 구분하는 값)
  2. IAM 역할 등록
  3. EC2 서버에 CodeDeploy Agent 설치

2.1. Tag 추가

CodeDeploy 를 생성할 때 어떤 인스턴스에서 수행할 지 구분하는 값으로 태그를 사용하기 때문에 추가가 필요합니다.

기존에 인스턴스 생성할 때 태그까지 같이 생성했다면 이 과정은 생략해도 괜찮습니다.


2.1.1. EC2 설정에서 태그 관리 선택

EC2 인스턴스 정보에 들어가 태그 관리를 선택합니다.


2.1.2. 태그 추가

원하는 키 값을 입력하고 저장을 누릅니다.


2.1.3. 태그 확인

다시 EC2 인스턴스 정보에서 태그가 등록되었는지 확인할 수 있습니다.


2.2. IAM 역할 추가

EC2 인스턴스에서 S3 에 올려놓은 파일에 접근할 수 있도록 권한을 추가해줘야 합니다.


2.2.1. IAM 역할 관리 페이지로 이동

기본적으로 존재하는 역할들이 있는데 신경쓰지 말고 새로운 역할 만들기를 선택합니다.


2.2.2. EC2 엔티티 선택

IAM 역할을 연결할 서비스를 선택합니다.


2.2.3. S3 접근 권한 추가

EC2 인스턴스에서 S3 접근할 수 있도록 AmazonS3FullAccess 권한을 추가합니다.


2.2.4. 이름 설정

마지막으로 원하는 이름을 입력한 뒤 생성을 완료합니다.


2.2.5. EC2 인스턴스에서 IAM 연결

EC2 인스턴스 관리 페이지로 이동해서 "작업 > 보안 > IAM 역할 수정" 을 선택합니다.


방금 만든 EC2 전용 IAM 역할을 선택한 뒤 저장을 누르면 연결이 완료됩니다.


2.3. CodeDeploy Agent 설치

$ sudo apt update
$ sudo apt install ruby-full
$ sudo apt install wget
$ cd /home/ubuntu
$ wget https://aws-codedeploy-ap-northeast-2.s3.ap-northeast-2.amazonaws.com/latest/install
$ chmod +x ./install
$ sudo ./install auto > /tmp/logfile
$ sudo service codedeploy-agent status

CodeDeploy Agent 설치 를 보고 명령어를 따라 치기만 하면 됩니다.

EC2 환경이 Ubuntu 가 아니거나 버전이 다르다면 공식 문서를 참고해주세요.


정상적으로 설치가 완료되면 이런 응답이 와야 합니다.


3. AWS S3 생성

AWS S3 버킷이란 이미지 또는 zip 파일을 저장하기 위한 스토리지 서비스입니다.

빌드한 프로젝트 코드를 압축해서 S3 에 저장한 후 EC2 서버에서 S3 에 접근해서 압축한 파일을 가져오기 위해 사용합니다.


3.1. S3 메뉴에서 버킷 생성

S3 메뉴로 이동해서 "버킷 만들기" 를 누릅니다.


3.2. 일반 구성과 객체 소유권 설정

원하는 버킷 이름과 리전을 선택합니다.

ACL 은 기본값을 선택해서 비활성화 합니다.


3.3. 액세스, 버킷 버전, 암호화 비활성화

나머지 설정을 마저 합니다.

변경할 필요 없이 기본값 그대로 두면 됩니다.


3.4. S3 버킷 생성 완료

버킷 생성이 완료되면 이렇게 나타납니다.


4. CodeDeploy 생성

배포를 도와주는 CodeDeploy 생성 및 설정을 진행해봅니다.


4.1. CodeDeploy 전용 IAM 역할 만들기

CodeDeploy 를 사용하기 위해선 IAM 에서 역할을 만들어야 합니다.


4.1.1. IAM 메뉴에서 역할 선택

IAM 서비스로 이동해서 역할 만들기를 선택합니다.


4.1.2. CodeDeploy 엔티티 선택

기본적으로 제공되는 AWS 서비스에서 CodeDeploy 를 검색한 후 가장 기본적인 걸 선택합니다.


4.1.3. IAM 이름 설정

나머지는 건들 필요 없고 이름만 새로 추가합니다.

저는 my-codedeploy-iam 로 설정했습니다.

이름을 입력했다면 "역할 생성" 을 눌러 마무리합니다.


4.2. CodeDeploy 애플리케이션 생성

이제 우리가 사용할 CodeDeploy 앱을 생성합니다.


메뉴에서 생성 버튼을 누릅니다.


원하는 이름을 입력 후 컴퓨팅 플랫폼은 EC2/온프레미스 를 선택합니다.


4.3. CodeDeploy 배포 그룹 생성

CodeDeploy 애플리케이션에서 사용하는 배포 그룹을 생성합니다.


4.3.1. 메뉴에서 선택

방금 만든 애플리케이션에서 배포 그룹 생성을 누릅니다.


4.3.2. 이름, 역할, 유형 선택

원하는 배포 그룹 이름, 역할, 유형을 설정합니다.

서비스 역할은 위에서 만든 IAM 역할을 선택할 수 있게 나옵니다.


4.3.3. EC2 인스턴스 선택

어떤 인스턴스에서 동작할 지 선택합니다.

EC2 인스턴스에서 태그를 추가해야 선택할 수 있습니다.

우리는 위에서 이미 기존 EC2 인스턴스에 태그를 추가했기 때문에 해당 태그 키를 선택합니다.


4.3.4. 나머지 설정 후 배포 그룹 생성

AWS Systems Manager 는 크게 중요한거 같지 않으니 적당히 선택하고 로드 밸런싱을 사용하지 않으니 체크만 해제합니다.

다 설정했으면 배포 그룹 생성을 눌러 마무리합니다.


5. Github Actions 에서 사용할 IAM 사용자 추가

AWS 를 Github Actions 워크 플로우에서 접근하려면 권한이 필요합니다.

지금까지는 IAM 역할만 추가해서 특정 서비스 (EC2, CodeDeploy) 에게 부여 했지만 이번에는 IAM 사용자를 추가해봅니다.


5.1. IAM 사용자 메뉴로 이동

IAM 메뉴에서 사용자 추가를 선택합니다.


5.2. IAM 사용자 이름 및 액세스 유형 설정

사용자 이름을 추가하고 액세스 유형을 선택합니다.

우리는 Github Actions 에서 사용해야 하기 때문에 암호 방식 대신 액세스 키 방식을 선택합니다.


5.3. 접근이 필요한 권한 추가

이 사용자에게 추가할 접근 권한을 고릅니다.

워크 플로우에서 CodeDeploy 를 실행해야 하기 때문에 다음 두 권한을 추가합니다.

  • AWSCodeDeployFullAccess
  • AmazonS3FullAccess

5.4. 사용자 만들기 완료

태그는 필요 없기 때문에 생략하고 잘못된 설정이 없는지 마지막으로 확인 후 사용자를 만듭니다.


5.5. Access Key 및 Secret Key 확인

사용자를 만들고 나면 "액세스 키 ID" 와 "비밀 액세스 키" 가 존재합니다.

이 두 개의 키 값을 사용해서 IAM 권한을 획득할 수 있습니다.

우리는 이걸 Github Actions 에서 사용할 수 있도록 등록합니다.


5.6. Github Repository 의 Secrets 추가

Github Actions 을 적용하려는 Github > Repository > Settings > Secrets 로 이동해서 위 키 값들을 등록합니다.

키 이름은 적당히 편한 것으로 설정합니다.

Github Secrets 에 저장한다고 해도 값을 직접 확인할 수 없기 때문에 필요한 경우 따로 저장해둡니다.


6. AppSpec 파일 작성

지금까지 우리는 서버를 띄울 EC2, 배포할 결과물을 저장할 S3, 배포를 도와줄 CodeDeploy 이렇게 총 세 가지 AWS 서비스를 만들었습니다.

이제 CodeDeploy 에서 배포를 위해 참조할 AppSpec 파일을 작성합니다.

AppSpec 파일을 사용해서 우리는 프로젝트의 어떤 파일들을 EC2 의 어떤 경로에 복사할지 설정 가능하고, 배포 프로세스 이후에 수행할 스크립트를 지정하여 자동으로 서버를 띄울 수도 있습니다.

AppSpec 파일은 기본적으로 루트 디렉터리에 위치해야 합니다.


version: 0.0
os: linux

files:
  - source:  /
    destination: /home/ubuntu/app
    overwrite: yes

permissions:
  - object: /
    pattern: "**"
    owner: ubuntu
    group: ubuntu

hooks:
  AfterInstall:
    - location: scripts/stop.sh
      timeout: 60
      runas: ubuntu
  ApplicationStart:
    - location: scripts/start.sh
      timeout: 60
      runas: ubuntu

전체 파일은 위와 같으며 각 섹션별로 조금씩만 살펴보겠습니다.


6.1. files 섹션

files:
  - source:  /
    destination: /home/ubuntu/app
    overwrite: yes

배포 파일에 대한 설정입니다.

  • source: 인스턴스에 복사할 디렉터리 경로
  • destination: 인스턴스에서 파일이 복사되는 위치
  • overwrite: 복사할 위치에 파일이 있는 경우 대체

AppSpec "files" 섹션 문서를 참고하면 더 자세한 내용을 알 수 있습니다.


6.2. permissions 섹션

permissions:
  - object: /
    pattern: "**"
    owner: ubuntu
    group: ubuntu

files 섹션에서 복사한 파일에 대한 권한 설정입니다.

  • object: 권한이 지정되는 파일 또는 디렉터리
  • pattern (optional): 매칭되는 패턴에만 권한 부여
  • owner (optional): object 의 소유자
  • group (optional): object 의 그룹 이름

AppSpec "permissions" 섹션 문서를 참고하면 더 자세한 내용을 알 수 있습니다.


6.3. hooks 섹션

hooks:
  AfterInstall:
    - location: scripts/stop.sh
      timeout: 60
      runas: ubuntu
  ApplicationStart:
    - location: scripts/start.sh
      timeout: 60
      runas: ubuntu

배포 이후에 수행할 스크립트를 지정할 수 있습니다.

일련의 라이프사이클이 존재하기 때문에 적절한 Hook 을 찾아 실행할 스크립트를 지정하면 됩니다.

위 코드에서는 파일을 설치한 후 AfterInstall 에서 기존에 실행중이던 애플리케이션을 종료시키고 ApplicationStart 에서 새로운 애플리케이션을 실행합니다.

  • location: hooks 에서 실행할 스크립트 위치
  • timeout (optional): 스크립트 실행에 허용되는 최대 시간이며, 넘으면 배포 실패로 간주됨
  • runas (optional): 스크립트를 실행하는 사용자

AppSpec "hooks" 섹션 문서를 참고하면 더 자세한 내용을 알 수 있습니다.


7. 배포 스크립트 작성

바로 위 AppSpec Hooks 에서 실행할 스크립트 stop.shstart.sh 를 설정했습니다.

이제 수행할 스크립트 파일을 작성합니다.


7.1. stop.sh

#!/usr/bin/env bash

PROJECT_ROOT="/home/ubuntu/app"
JAR_FILE="$PROJECT_ROOT/spring-webapp.jar"

DEPLOY_LOG="$PROJECT_ROOT/deploy.log"

TIME_NOW=$(date +%c)

# 현재 구동 중인 애플리케이션 pid 확인
CURRENT_PID=$(pgrep -f $JAR_FILE)

# 프로세스가 켜져 있으면 종료
if [ -z $CURRENT_PID ]; then
  echo "$TIME_NOW > 현재 실행중인 애플리케이션이 없습니다" >> $DEPLOY_LOG
else
  echo "$TIME_NOW > 실행중인 $CURRENT_PID 애플리케이션 종료 " >> $DEPLOY_LOG
  kill -15 $CURRENT_PID
fi

애플리케이션이 이미 떠있으면 종료하는 스크립트입니다.

간단히 주석으로 설명을 달아두었으니 쉘 스크립트를 작성할 줄 안다면 보기에 어려움은 없을 겁니다.


7.2. start.sh

#!/usr/bin/env bash

PROJECT_ROOT="/home/ubuntu/app"
JAR_FILE="$PROJECT_ROOT/spring-webapp.jar"

APP_LOG="$PROJECT_ROOT/application.log"
ERROR_LOG="$PROJECT_ROOT/error.log"
DEPLOY_LOG="$PROJECT_ROOT/deploy.log"

TIME_NOW=$(date +%c)

# build 파일 복사
echo "$TIME_NOW > $JAR_FILE 파일 복사" >> $DEPLOY_LOG
cp $PROJECT_ROOT/build/libs/*.jar $JAR_FILE

# jar 파일 실행
echo "$TIME_NOW > $JAR_FILE 파일 실행" >> $DEPLOY_LOG
nohup java -jar $JAR_FILE > $APP_LOG 2> $ERROR_LOG &

CURRENT_PID=$(pgrep -f $JAR_FILE)
echo "$TIME_NOW > 실행된 프로세스 아이디 $CURRENT_PID 입니다." >> $DEPLOY_LOG

애플리케이션을 실행하는 스크립트입니다.

Github Actions 워크플로우에서 이미 빌드는 마쳤기 때문에 JAR 파일만 복사 후 실행합니다.


7.3. build.gradle 파일 수정

위 스크립트를 보면 /build/libs/*.jar 파일을 $JAR_FILE 파일로 복사합니다.

그런데 Spring Boot 2.5 버전부터는 빌드 시 일반 jar 파일 하나와 -plain.jar 파일 하나가 함께 만들어집니다.

그래서 빌드 시 plain jar 파일은 만들어지지 않도록 build.gradle 파일에 다음 내용을 추가해야 합니다.

jar {
    enabled = false
}

build.gradle.kts 파일은 이렇게 작성하시면 됩니다.

tasks.getByName<Jar>("jar") {
    enabled = false
}

8. Github Actions Workflow 작성

이제 필요한 사전 작업은 모두 끝났으니 Github Actions 워크 플로우만 작성하면 됩니다.


8.1. Sample Workflow 선택

Github Actions CI 편에서는 기본적으로 제공되는 gradle 샘플을 수정했지만 배포 플로우는 거의 다 수정해야 하므로 그냥 가장 심플한 워크 플로우를 선택합니다.


8.2. deploy.yml 파일 작성

name: Deploy to Amazon EC2

on:
  push:
    branches:
      - main

# 본인이 설정한 값을 여기서 채워넣습니다.
# 리전, 버킷 이름, CodeDeploy 앱 이름, CodeDeploy 배포 그룹 이름
env:
  AWS_REGION: ap-northeast-2
  S3_BUCKET_NAME: my-github-actions-s3-bucket
  CODE_DEPLOY_APPLICATION_NAME: my-codedeploy-app
  CODE_DEPLOY_DEPLOYMENT_GROUP_NAME: my-codedeploy-deployment-group

permissions:
  contents: read

jobs:
  deploy:
    name: Deploy
    runs-on: ubuntu-latest
    environment: production

    steps:
    # (1) 기본 체크아웃
    - name: Checkout
      uses: actions/checkout@v3

    # (2) JDK 11 세팅
    - name: Set up JDK 11
      uses: actions/setup-java@v3
      with:
        distribution: 'temurin'
        java-version: '11'

    # (3) Gradle build (Test 제외)
    - name: Build with Gradle
      uses: gradle/gradle-build-action@0d13054264b0bb894ded474f08ebb30921341cee
      with:
        arguments: clean build -x test

    # (4) AWS 인증 (IAM 사용자 Access Key, Secret Key 활용)
    - name: Configure AWS credentials
      uses: aws-actions/configure-aws-credentials@v1
      with:
        aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
        aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
        aws-region: ${{ env.AWS_REGION }}

    # (5) 빌드 결과물을 S3 버킷에 업로드
    - name: Upload to AWS S3
      run: |
        aws deploy push \
          --application-name ${{ env.CODE_DEPLOY_APPLICATION_NAME }} \
          --ignore-hidden-files \
          --s3-location s3://$S3_BUCKET_NAME/$GITHUB_SHA.zip \
          --source .

    # (6) S3 버킷에 있는 파일을 대상으로 CodeDeploy 실행
    - name: Deploy to AWS EC2 from S3
      run: |
        aws deploy create-deployment \
          --application-name ${{ env.CODE_DEPLOY_APPLICATION_NAME }} \
          --deployment-config-name CodeDeployDefault.AllAtOnce \
          --deployment-group-name ${{ env.CODE_DEPLOY_DEPLOYMENT_GROUP_NAME }} \
          --s3-location bucket=$S3_BUCKET_NAME,key=$GITHUB_SHA.zip,bundleType=zip

전체 파일은 위와 같으며 스텝 별로 간단한 주석을 달아두었습니다.

결국 프로젝트를 빌드한 후 AWS S3 버킷에 푸시 후 CodeDeploy 를 수행하는 겁니다.

(4), (5), (6) 에 대해서만 간략한 설명을 덧붙이겠습니다.


8.2.1. (4) AWS 인증

# (4) AWS 인증 (IAM 사용자 Access Key, Secret Key 활용)
- name: Configure AWS credentials
  uses: aws-actions/configure-aws-credentials@v1
  with:
    aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
    aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
    aws-region: ${{ env.AWS_REGION }}

AWS 에 접근하기 위해 인증하는 스텝입니다.

우리는 위에서 IAM 사용자를 만든 후 Access Key, Secret Key 를 Github 레포에 저장했습니다.

그러면 secrets 변수를 통해 우리가 저장한 키 값들을 가져와서 사용할 수 있습니다.

필요한 Access Key, Secret Key 등을 프로젝트 코드에 노출시키지 않은 채로 사용할 수 있다는 편리함이 있습니다.


8.2.2. (5) AWS S3 에 업로드

# (5) 빌드 결과물을 S3 버킷에 업로드
- name: Upload to AWS S3
  run: |
    aws deploy push \
      --application-name ${{ env.CODE_DEPLOY_APPLICATION_NAME }} \
      --s3-location s3://$S3_BUCKET_NAME/$GITHUB_SHA.zip \
      --ignore-hidden-files \
      --source .

원하는 파일들을 압축해서 AWS S3 에 업로드 하는 스텝입니다.

공식 문서를 참고하면 더 자세한 정보를 알 수 있습니다.

  • --application-name: CodeDeploy 애플리케이션 이름
  • --s3-location: 압축 파일을 업로드 할 S3 버킷 정보
  • --ignore-hidden-files (optional): 숨겨진 파일까지 번들링할지 여부

$GITHUB_SHA 라는 변수가 보이는데 간단하게 생각해서 Github 자체에서 커밋마다 생성하는 랜덤한 변수값입니다. (자세한 정보는 Github Context 참조)

이렇게 랜덤한 값을 사용하면 파일 업로드 시에 이름 중복으로 충돌날 일이 없습니다.


8.2.3. (6) AWS EC2 에 배포

# (6) S3 버킷에 있는 파일을 대상으로 CodeDeploy 실행
- name: Deploy to AWS EC2 from S3
  run: |
    aws deploy create-deployment \
      --application-name ${{ env.CODE_DEPLOY_APPLICATION_NAME }} \
      --deployment-config-name CodeDeployDefault.AllAtOnce \
      --deployment-group-name ${{ env.CODE_DEPLOY_DEPLOYMENT_GROUP_NAME }} \
      --s3-location bucket=$S3_BUCKET_NAME,key=$GITHUB_SHA.zip,bundleType=zip

위 스텝에서 S3 에 저장한 파일을 EC2 에서 땡겨온 후 압축을 풀고 스크립트를 실행합니다.

공식 문서를 참고하면 더 자세한 정보를 알 수 있습니다.

  • --application-name: CodeDeploy 애플리케이션 이름
  • --deployment-config-name: 배포 방식인데 기본값을 사용
  • --deployment-group-name: CodeDeploy 배포 그룹 이름
  • --s3-location: 버킷 이름, 키 값, 번들타입

9. Github Actions 사용해서 배포하기

이제 모든 세팅이 끝났으므로 배포를 진행해봅니다.

yaml 파일로 설정한 것처럼 main 브랜치에 push 되는 경우 Github Actions Workflow 가 수행됩니다.


9.1. Github Repo 에서 확인

워크 플로우가 정상적으로 수행되면 이렇게 커밋에 체크 표시가 생깁니다.


"Details" 를 눌러보거나 "Actions" 탭으로 이동하면 수행한 스텝이 나옵니다.

만약 실패한다면 어떤 스텝에서 실패했는지 확인할 수 있습니다.


9.2. CodeDeploy 에서 배포 내역 확인

AWS CodeDeploy 메뉴로 이동하면 배포 내역을 확인인할 수 있습니다.


9.3. EC2 서버에서 애플리케이션 실행 확인

EC2 서버에 접속해서 확인 해보면 Spring Boot 프로젝트 코드가 있으며 서버도 정상적으로 떠있는 것을 확인할 수 있습니다.

  • CodeDeploy 배포 로그 확인: /var/log/aws/codedeploy-agent/codedeploy-agent.log
  • CodeDeploy Hooks 로그 확인: /opt/codedeploy-agent/deployment-root/deployment-logs/codedeploy-agent-deployments.log

Reference

'공부 > CI & CD' 카테고리의 다른 글

Github Actions CI: 자동 빌드 및 테스트 하기  (0) 2022.04.27
AWS 2편: RDS 생성 후 EC2 와 연동  (2) 2022.01.23
AWS 1편: EC2 생성 후 Spring Boot 띄우기  (7) 2022.01.21
Docker 명령어  (0) 2022.01.05
kubectl 명령어  (0) 2021.12.23

Overview

한 프로젝트를 여러 사람이 개발할 때 코드의 안정성은 굉장히 중요합니다.

환경 변수 변경, 비즈니스 로직 수정, Git 충돌 등 여러 사람들이 코드를 공유하면서 발생할 수 있는 문제점은 굉장히 많습니다.

실수를 방지하기 위해 테스트 코드를 작성하지만 매 PR 리뷰 때마다 각 리뷰어들이 일일히 테스트 코드를 돌려보며 리뷰하면 생산성이 저하됩니다.

단순히 리뷰 요청자의 테스트 잘 돌아갑니다~ 같은 코멘트보다 테스트의 성공을 확실히 보장해주는 수단이 필요합니다.

그래서 CI/CD 라는 개념이 등장했습니다.


1. CI/CD

  • CI (Continuous Integration)
    • 해석하면 "지속적 통합" 으로 여러 개발자가 하나의 프로젝트를 같이 개발할 때 발생하는 불일치를 최소화 해주는 개념입니다.
    • CI 를 제대로 구현하면 애플리케이션 변경 사항 반영 시 자동으로 빌드 및 테스트 되어 잘못된 코드가 공유되는 걸 방지합니다.
  • CD (Continuous Deployment)
    • "지속적 배포" 라는 뜻으로 프로젝트의 변경 사항을 가상 환경에 자동으로 배포하는 것을 의미합니다.
    • CD 를 구성해두면 변경 사항을 배포할 때 사용하는 파이프라인을 공유하여 번거로움을 없앨 수 있습니다.

좀더 자세한 내용은 RedHat 공식문서를 참고하세요.

쉽게 표현하자면 CI 는 자동 빌드 및 테스트를 진행하여 여러 개발자들이 공유하는 코드의 신뢰성을 높이는 개념이고 CD 는 배포 플로우를 자동화하여 누구나 동일한 플로우로 배포할 수 있게 만들어주는 개념입니다.


2. Github Actions

Github Actions 는 Github 에서 제공하는 CI/CD 툴입니다.

build, test, deploy 등 필요한 Workflow 를 등록해두면 Gihtub 의 특정 이벤트 (push, pull request) 가 발생했을 때 해당 워크 플로우를 수행합니다.

예를 들어 Pull Request 를 올리면 자동으로 해당 코드의 테스트를 수행하여 수행한다던지 master branch 에 코드를 push 하면 자동으로 코드를 배포하는 등 여러 가지 반복적인 작업을 자동으로 수행해줍니다.

Jenkins, Circle CI, Travis CI 등 다른 후보들도 있지만 Github Actions 은 별다른 툴을 설치하지 않아도 Github Repository 에서 바로 사용할 수 있다는 장점이 있습니다.


3. Github Actions 자동 빌드 및 테스트

Github Actions 를 사용한 CI 환경을 구축해봅니다.

Pull Request 를 올렸을 때 자동으로 빌드 및 테스트를 수행하여 코드의 품질을 검사합니다.


3.1. Workflow 선택

Github Repository 로 이동해서 Actions 탭에서 새로운 Workflow 를 추가할 수 있습니다.

자주 사용되는 언어나 프레임워크는 이미 제공되는게 있기 때문에 검색해서 선택만 하면 됩니다.

Configure 버튼을 누르면 기본적으로 제공되는 gradle.yml 파일이 제공됩니다.


3.2. gradle.yml 파일 수정

name: Java CI with Gradle

on:
  pull_request:
    branches: [ main ]

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest

    steps:

    # 1) 워크플로우 실행 전 기본적으로 체크아웃 필요
    - uses: actions/checkout@v3

    # 2) JDK 11 버전 설치, 다른 JDK 버전을 사용한다면 수정 필요
    - name: Set up JDK 11
      uses: actions/setup-java@v3
      with:
        java-version: '11'
        distribution: 'temurin'

    # 3) Gradle 사용. arguments 를 붙이면 뒤에 그대로 실행된다고 생각하면 됨
    # 이 워크플로우는 gradle clean build 를 수행함
    - name: Build with Gradle
      uses: gradle/gradle-build-action@0d13054264b0bb894ded474f08ebb30921341cee
      with:
        arguments: clean build

기본적으로 제공되는 파일에서 조금 수정했습니다.

크게 신경써야 해줄 부분은 on 부분과 jobs 부분입니다.

on 에서는 워크플로우를 수행할 이벤트를 결정합니다.

위 코드는 main 을 베이스 브랜치로 한 Pull Request 를 생성하였을 때 수행된다는 뜻입니다.

jobs 에서는 수행할 워크플로우를 차례대로 입력하면 됩니다.

어떤 동작인지 간단하게 주석으로 표현했으며 더 궁금한 점은 Github Actions Docs 를 참고해주세요.


3.3. Pull Request 작성

이제 PR 을 작성하면 자동으로 Github Actions 가 동작하여 빌드를 실행합니다.


3.3.1. Workflow 실패

Github Actions 이 실패하면 이렇게 실패했다고 알려줍니다.

여러 워크 플로우를 한번에 수행시킬 수도 있으며, Github 설정에 따라 워크 플로우가 성공하지 않으면 머지 불가능 하도록 제한할 수도 있습니다.


3.3.2. Workflow 성공

모든 워크플로우가 성공하면 이렇게 녹색으로 성공 여부를 알려줍니다.

코드를 리뷰하는 사람은 직접 코드를 돌려보지 않아도 빌드와 테스트가 성공한다는 사실을 알 수 있습니다.


Conclusion

Github Actions 의 가장 큰 이점은 Github 과의 연계성이라고 생각합니다.

대부분의 프로젝트가 Github 저장소를 활용하고 있기 때문에 별다른 세팅 없이도 쉽게 이용할 수 있습니다.

이번 포스팅 내용에서 좀더 나아가 Workflow 에서 테스트 커버리지를 파악해서 알려주는 라이브러리도 존재합니다.

커버리지가 일정 기준을 넘지 않으면 머지 불가능하게 만들어 테스트 코드 작성을 강제할 수도 있습니다.

다음에는 Github Actions 를 사용해 AWS EC2 에 배포하는 과정을 작성해볼 예정입니다.

'공부 > CI & CD' 카테고리의 다른 글

Github Actions CD: AWS EC2 에 Spring Boot 배포하기  (24) 2022.05.05
AWS 2편: RDS 생성 후 EC2 와 연동  (2) 2022.01.23
AWS 1편: EC2 생성 후 Spring Boot 띄우기  (7) 2022.01.21
Docker 명령어  (0) 2022.01.05
kubectl 명령어  (0) 2021.12.23

+ Recent posts