회사에서 JDK 17 버전에서 21로 변경하며 Virtual Thread를 도입하기 위해 학습한 내용을 기록하였습니다.

Virtual Thread 개념 정리

Java 19에서 프리뷰로 도입되고 Java 21에서 정식 출시된 Virtual Thread(가상 스레드)에 대해 학습하였습니다. 기존 Platform Thread와 비교하면 상당한 차이가 있었습니다.

 

기존 Platform Thread의 한계점

  • 스레드당 약 2MB의 메모리 사용
  • 생성 및 삭제 비용이 높음
  • 일반적으로 수십~수백 개 정도만 생성 가능

Virtual Thread의 개선점

  • 스레드당 약 10KB로 매우 가벼움
  • 수십만~수백만 개까지 생성 가능
  • I/O 대기 시간을 효율적으로 활용

동작 원리 학습

Virtual Thread는 Platform Thread 위에서 동작하며, 핵심은 Mount/Unmount 메커니즘입니다.

동작 흐름:
1. Virtual Thread A가 Platform Thread 1에서 실행 (Mount 상태)
2. DB 조회 등 I/O 작업 시작
3. Virtual Thread A가 Platform Thread에서 분리 (Unmount)
4. Platform Thread 1이 다른 Virtual Thread B를 실행
5. I/O 완료 시 Virtual Thread A가 다시 Platform Thread에 연결되어 실행 재개

핵심 용어

  • 마운트(Mount): Virtual Thread가 Platform Thread에서 실행되는 상태
  • 언마운트(Unmount): I/O 블로킹 시 Platform Thread에서 분리되어 대기하는 상태
  • Carrier Thread: Virtual Thread를 실제로 실행하는 Platform Thread

I/O 바운드 작업에서의 효과

Virtual Thread는 I/O 집약적인 작업에서 뛰어난 성능을 보입니다.

public void processUserData() {
    // 1. DB 사용자 조회 - I/O 대기 시 언마운트 발생
    User user = userRepository.findById(userId);
    
    // 2. 외부 API 호출 - I/O 대기 시 언마운트 발생
    ApiResponse response = externalApiClient.call(user.getId());
    
    // 3. 파일 저장 - I/O 대기 시 언마운트 발생
    fileService.saveUserData(user, response);
}

위와 같은 코드에서는 각 I/O 작업마다 Virtual Thread가 언마운트되어 Platform Thread가 다른 Virtual Thread를 실행할 수 있게 됩니다.

참고로 Thread.sleep()도 Virtual Thread에서는 언마운트됩니다.

주의해야 할 제약사항

synchronized 블록 사용 시 문제점

학습 중 가장 중요하다고 판단한 부분입니다.

// Virtual Thread 효과가 제한되는 경우
public void inefficientMethod() {
    synchronized (lock) {
        // Virtual Thread가 Platform Thread를 계속 점유
        // 언마운트되지 않아 비효율적
        expensiveOperation();
    }
}

// Virtual Thread에 적합한 방식
private final ReentrantLock lock = new ReentrantLock();

public void efficientMethod() {
    lock.lock();
    try {
        // Virtual Thread 언마운트 가능
        expensiveOperation();
    } finally {
        lock.unlock();
    }
}

synchronized는 JVM 모니터 락으로 OS 레벨에서 스레드를 블로킹하기 때문에 언마운트가 불가능합니다.

CPU 바운드 작업의 한계

public void cpuIntensiveTask() {
    for (int i = 0; i < 1000000; i++) {
        // CPU 연산 중심 작업에서는 언마운트 포인트가 없음
        Math.sin(i) * Math.cos(i);
    }
}

CPU 집약적인 작업은 Virtual Thread를 많이 생성해도 CPU 코어 수의 제약을 받습니다.

Spring Boot 환경에서의 적용

설정은 매우 간단합니다.

# application.yml
spring:
  threads:
    virtual:
      enabled: true

이 설정만으로 Spring의 요청 처리 스레드가 Virtual Thread로 변경됩니다.

실제 적용 예시

@RestController
public class UserController {
    
    @GetMapping("/users/{id}")
    public UserDetailResponse getUserDetail(@PathVariable Long id) {
        // 전체 메서드가 Virtual Thread에서 실행
        
        // 사용자 정보 조회 - 언마운트 발생
        User user = userService.findById(id);
        
        // 권한 정보 조회 - 언마운트 발생
        List<Permission> permissions = permissionService.findByUserId(id);
        
        // 외부 프로필 이미지 조회 - 언마운트 발생
        String profileImageUrl = profileService.getImageUrl(user.getEmail());
        
        return UserDetailResponse.of(user, permissions, profileImageUrl);
    }
}

CPU 작업 혼합 시 최적화 방안

보고서 생성과 같이 CPU 계산이 포함된 경우, CPU 작업만 별도 Thread Pool로 분리하는 것이 효과적입니다.

@Configuration
@EnableAsync
public class ThreadPoolConfig {
    
    @Bean("cpuExecutor")
    public ExecutorService cpuExecutor() {
        return Executors.newFixedThreadPool(
            Runtime.getRuntime().availableProcessors()
        );
    }
}

@Service
public class ReportService {
    
    @Autowired
    @Qualifier("cpuExecutor")
    private ExecutorService cpuExecutor;
    
    public CompletableFuture<ReportResponse> generateReport(ReportRequest request) {
        return CompletableFuture
            // I/O 작업: 데이터 수집 (Virtual Thread)
            .supplyAsync(() -> dataService.collectData(request))
            
            // CPU 작업: 복잡한 계산 (Platform Thread Pool)
            .thenApplyAsync(data -> processCalculation(data), cpuExecutor)
            
            // I/O 작업: 결과 저장 (Virtual Thread)
            .thenApply(result -> dataService.save(result));
    }
}

도입 시 고려사항

DB Connection Pool 용량 검토

Virtual Thread로 동시 처리량이 증가하면 DB 커넥션 부족 현상이 발생할 수 있습니다. 기존 스레드 풀 크기에 맞춰 설정된 커넥션 풀을 재검토해야 합니다.

외부 API Rate Limiting 대비

동시 호출량 급증으로 외부 서비스에서 Rate Limit에 걸릴 가능성이 있습니다. Circuit Breaker 패턴이나 Rate Limiter 도입을 검토해야 합니다.

로그 시스템 영향도 분석

처리량 증가에 따른 로그 급증으로 다음과 같은 문제가 발생할 수 있습니다:

  • 로그 파일 크기 급증
  • 로그 I/O 병목 현상
  • 디스크 용량 부족

메모리 사용 패턴 변화

  • Direct Memory 사용량 증가 가능성
  • Young Generation GC 빈도 변화
  • 기존 JVM 튜닝 파라미터 재검토 필요

도입 적합성 판단 기준

적극 도입 권장 환경

  • 웹 서버, REST API 서비스
  • 마이크로서비스 아키텍처
  • I/O 집약적 배치 처리
  • 실시간 통신 서버

신중한 검토 필요 환경

  • CPU 집약적 애플리케이션
  • synchronized 사용이 많은 레거시 코드
  • 엄격한 성능 요구사항이 있는 시스템

도입 전 체크포인트

  • 현재 병목 지점이 I/O 중심인지 확인
  • DB Connection Pool 여유분 검토
  • 외부 API 의존성 및 제약사항 파악
  • 기존 코드의 synchronized 사용량 분석
  • 모니터링 체계의 Virtual Thread 대응 가능성 확인

학습 결론

Virtual Thread는 I/O 바운드 작업이 많은 웹 애플리케이션에서 상당한 성능 향상을 기대할 수 있는 기술입니다.

특히 설정이 간단하고 기존 코드 변경이 최소화되어 도입 부담이 적습니다.

다만 synchronized 블록의 제약사항이나 의존 시스템에 미치는 영향을 충분히 검토해야 합니다.

프로젝트 개요

목적

  • 지급 프로세스를 자동화하여 업무 효율성과 지급 정확도를  높임

달성한 목표

24시간 무중단 지급 프로세스 구축

  • 스케줄링 기반의 안정적이고 지속 가능한 지급 시스템 운영

기존 로직 분석 및 구조 개선

  • 기존 v1 지급 로직을 전면 분석하여 지급 흐름을 새롭게 설계
  • 수기 지급 시 셀러의 정산 계좌 및 신용 상태를 사람이 직접 검토하던 구조를 시스템이 자동 검증하도록 개선
  • 이로 인해 CX팀 리소스 소요가 약 70% 감소하는 효과 체감

레거시 구조 통합

  • 지급 프로세스가 PHP, Node.js, Java에 분산되어 있던 구조를 Java 단일 서버로 통합
  • 기존에는 Transaction 처리가 없어 데이터 무결성에 문제가 있었지만, 엄격한 트랜잭션 제어로 안정성 확보

성능 개선

  • 동기 처리 → 멀티 스레드 병렬 처리로 전환하여 속도 개선
  • 기존에는 신청건이 N개일 경우, 건당 약 1분의 크롤링 시간이 소요되어 전체 처리 시간이 선형적으로 증가하는 구조
  • CompletableFuture를 활용해 비동기 병렬 처리 구조로 전환, 다수의 신청건을 동시에 처리할 수 있도록 개선

예외 상황 대응

  • 외부 시스템 연동 중 발생할 수 있는 다양한 예외에 철저히 대응
  • PaymentWaitException, PaymentFailureException 등의 Custom Exception을 도입해 예외 상황 명확화
  • 지급 단계별 STEP을 도입해 문제 지점을 추적 및 모니터링 가능

긴급 중단 제어 기능

  • 지급 중단이 필요한 상황에 대비해 Redis에 상태값을 설정하고 이를 기준으로 프로세스의 흐름을 동적으로 제어

어려움과 해결 과정

1. CompletableFuture와 JPA의 영속성 관리

  • 문제:
    • JPA의 영속성 컨텍스트는 ThreadLocal 기반이기 때문에 CompletableFuture.runAsync() 내에서 EntityManager 접근 불가
    • 이로 인해 비동기 로직 내에서 JPA 관련 작업(save(), findById() 등)을 호출하면 LazyInitializationException
      예외가 발생.
  • 해결:
    • JPA 기반의 데이터 처리(조회, 저장 등)는 현재 트랜잭션 내의 메인 스레드에서 모두 처리
    • 이후 크롤링 작업만 CompletableFuture로 비동기 분리

2. 외부 시스템 연동 예외 처리

  • 문제: 각 단계에서 발생하는 예외를 일관성 있게 처리하기 어려움
  • 해결:
    • Custom Exception에 지급 단계 및 원인 정보를 포함
    • 예외 발생 시에도 유연하게 롤백 여부 설정
      (@Transactional(noRollbackFor = { PaymentWaitException.class }) 등)

3. 트랜잭션 경계 관리

  • 문제: 트랜잭션의 동작 방식에 대한 경험 부족으로 Rollback 여부 판단이 어려웠음
  • 해결: 다양한 상황을 겪으며 트랜잭션의 특성과 동작 시점을 학습하고 적용
  • 참고링크: https://techblog.woowahan.com/2606/

테스트 환경 구성

  • 외부 연동을 제외한 내부 로직은 Testcontainer를 활용하여 실제 인프라와 유사한 테스트 환경 구성
  • 로컬에서도 Redis, DB, 등의 구성 요소를 포함한 통합 테스트가 가능하도록 설계

관련기사 : https://wowtale.net/2025/01/31/236617/

시작하며

Node.js 서버에서 금전적인 행위를 처리하는 배치 작업이 종료(Shutdown)될 때 안전하게 종료되지 않아 특정 유저의 출금이 두 번 발생하는 문제가 종종 발생했다. 원인을 분석해보니, Shutdown 시점에 출금 처리는 되었으나 데이터 처리까지 완료되지 않아, 다음 배치 작업에서 중복 출금이 발생하는 문제였다. 이를 해결하기 위해 Graceful Shutdown을 도입하는 과정에서 배운 점들을 기록하였다.

Graceful Shutdown이란?

직역하면 우아한 종료로, 애플리케이션이 완전히 종료되기 전에 진행 중인 모든 작업을 마무리하고, 리소스를 해제하며 데이터의 무결성을 유지한 상태로 안전하게 종료되는 과정을 의미한다.

Node-schedule 사용

Node.js에서 스케줄업무를 하는 라이브러리는 크게 Node-schedule 과 Node-cron이 있다. 현재 프로젝트에서는 Node-schedule 버전 1.3.3 을 사용중이다. 

 

※ 버전 확인 명령어

npm list node-schedule

 

만약 node-schedule의 버전을 2.x대 를 사용중이라면 기본 제공하는 gracefulShutdown() 메서드를 사용하여 안전한 종료가 가능하다. 하지만 1.x 대 버전을 사용한다면 gracefulShutdown() 메서드를 지원하지 않기때문에 메서드를 만들어 사용할수 있다.

gracefulShutdown 구현

Graceful Shutdown을 위해 작업 관리 리스트를 만들어 scheduledJobs와 runningJobs 배열에 작업을 추가, 삭제하는 구조로 관리한다.

1. 스케줄 관리 함수

const scheduledJobs = [];
let runningJobs = [];

const manageGracefulShutdownJob = (interval, jobFunction) => {
    let job = Schedule.scheduleJob(interval, async () => {
        const promise = jobFunction();
        runningJobs.push(promise);
        try {
            await promise;
        } finally {
            runningJobs = runningJobs.filter(job => job !== promise);
        }
    });
    scheduledJobs.push(job);
};

 

manageGracefulShutdownJob이 실행시 scheduleJobs 변수에 push 하여 관리대상으로 등록하고, 실행시 promise 객체를 반환하여 runningJobs에 push한다. 이후 await으로 해당 실행이 종료된 이후 runningJobs에서 해제를 시켜줌으로서, 현재실행중이 스케줄 잡을 관리한다.

manageGracefulShutdownJob('실행 주기', 메서드명);

2. Graceful Shutdown 적용

SIGINT 신호가 발생하면 모든 스케줄 작업을 취소하고, 실행 중인 작업이 완료될 때까지 대기한다.

process.on('SIGINT', async () => {
    console.log('Graceful shutdown in progress start');
    for (const job of scheduledJobs) {
        job.cancel();
    }
    console.log('All scheduled jobs cancelled');

    await Promise.all(runningJobs);

    console.log('All running jobs completed');
    process.exit(0);
});

 

위 코드를 통해 Graceful Shutdown을 구현할 수 있다. 그러나 개발 서버에서 테스트 시 SIGINT 신호가 발생하면 즉시 종료되는 현상을 확인했다. 이는 PM2의 kill_timeout 기본 설정이 1.6초로, 해당 시간동안 종료되지 않으면 SIGKILL로 변환되어 강제 종료되기 때문이다. kill_timeout을 1시간으로 늘려 테스트한 결과, 정상적으로 Graceful Shutdown이 작동했다.

참고 링크

https://github.com/node-schedule/node-schedule?tab=readme-ov-file#graceful-shutdown

https://pm2.keymetrics.io/docs/usage/signals-clean-restart/

시작하며

Readable Code: 읽기 좋은 코드를 작성하는 사고법 강의를 보고 정리한내용으로,

회사의 서비스 개선을 준비하는 단계에서 우리팀에 적용해보면 좋겠다. 라는 생각이 들어 정리한 내용입니다.

 

이름짓기

1. 단수와 복수를 구분하기

  • ~(e)s를 붙여 변수, 클래스 등 단수인지, 복수인지 구분

2. 이름줄이지 않기

  • 관용어처럼 많은 사람들이 자주사용하는 줄임말 정도만 사용하고, 무분별한 줄임말은 자제 또한, 줄임말이 이해될 수 있는 것은 문맥때문이기에 문맥을 잘 활용
  • ex) column -> col, latitude -> lat, longitude -> lon

 

3. 은어 / 방언 사용하지 않기

  • 일부 팀원 / 현재의 우리팀만 아는 용어금지
    • 새로운 사람이 팀에 합류했을때 용어를 이해하기 힘들다.
  • 도메인 용어 사용하기
    • 팀단위의 도메인 용어를 먼저 정의하는 과정이 필요하다.

4. 좋은 코드를 보고 습득하기

  • 비슷한 상황에서 자주사용하는 단어, 개념 습득하기

메서드와 추상화

하나의 메서드는 하나의 주제만을 가져야한다.

예시로 메서드의 이름은 추상적이며, 내용은 구체화하여 작성하여야한다.

왼쪽은 좋은예시로 메서드 이름은 추상적이며 내용은 구체적이지만, 반면 오른쪽은 하나의 메서드내에 여러개의 구체적인 내용이 포함되어

내용을 유추하기가 힘들다.

만약 이럴경우 여러개의 메서드로 분리하여 메서드당 하나의책임으로 추상화할 수 있다.

매직 넘버, 매직 스트링

  • 의미를 갖고 있으나, 상수로 추출되지 않은 숫자, 문자열 등
  • 상수 추출로 이름을 짓고 의미를 부여함으로써 가독성, 유지보수성이 향상된다.

사고의 Depth 줄이기: 중첩 분기문, 중첩 반복문

  • 보이는 Depth를 줄이는데에 급급한것이 아니라, 추상화를 통한 사고 과정의 Depth를 줄이는것이 중요
  • 2중 중첩구조로 표현하는것이 사고하는데에 더 도움이 된다고 판단한다면, 메서드 분리보다 그대로 놔두는것이 더나은 선택일 수 있다.
  • 때로는 메서드를 분리하는것이 더 혼선을 줄 수도 있다.

 

시작하며

개발자라면 누구나 CI/CD라는 용어를 들어봤을 것입니다. 저도 AWS 프리티어를 이용해 간단히 CI/CD 파이프라인을 구축해본 경험만 있고, 사내에서는 이미 구축된 시스템을 사용해왔기 때문에 직접적인 경험은 부족했습니다. 이 글에서는 사내에서 신규 프로젝트의 CI/CD 파이프라인을 구축한 경험을 공유하고자 합니다. 저처럼 경험이 부족한 분들에게 간접적인 경험을 통해 조금이나마 도움이 되길 바랍니다.

 

실제 구축하면서 여러 차례 Git Action Failure가 발생하여 구글링을 통해 해결하였으며, 모든 설정 파일을 완벽하게 외우지는 못하지만, 대략적인 그림이 그려진다면 다른 환경에서도 작업이 가능할 것이라 생각됩니다.

먼저 CI/CD란 무엇인가? 

대표적인 CI/CD 툴로는 Jenkins, GitHub Actions, Travis CI 등이 있으며, 필자는 Git Action을 선택하였습니다.

  • Git Action 장점
    • GitHub Actions는 GitHub와 통합되어 있어 별도의 서버 설치가 필요 없습니다.
    • GitHub Actions는 간편한 설정과 강력한 기능을 제공하여 빠르게 CI/CD 파이프라인을 구축할 수 있습니다.

그 외에 Docker 컨테이너 관리를 위해 AWS의 ECR을 선택하였고, 안전한 관리를 위해 Kubernetes 클러스터 서비스인 EKS를 활용하였습니다.

  • ECR의 장점
    • AWS 내에서 Docker 컨테이너 이미지를 저장하고 관리하기 위한 서비스로, AWS의 다른 서비스들과의 통합성이 뛰어납니다.
  • EKS의 장점
    • EKS는 고가용성, 확장성, 보안성 등의 특징을 제공하여 안정적인 운영 환경을 제공합니다.

필자는 위 장점들 때문에 해당 기술스택을 사용하였습니다.

1. Git Secret 설정하기

Git Repository의 Settings에서 GitHub Actions에 필요한 환경 변수를 설정할 수 있습니다. 민감한 정보를 보호하기 위해, 환경 변수는 Secret에 Key-Value 형태로 등록합니다.

  • 환경 변수 등록
    • 시크릿의 이름(Key)과 값을 입력합니다.
    • Secret을 등록후 Value 값을 확인할 수는 없다.

이렇게 등록된 시크릿은 GitHub Actions 워크플로우에서 참조하며, 민감한 정보가 노출되지 않도록 보호됩니다.

 

2. DockerFile 작성

Git Action을 통해 Docker Image를 만들 DockerFile을 작성합니다. 아래는 예제 Dockerfile입니다.

# 빌드 스테이지
FROM adoptopenjdk/openjdk8 AS build

# 애플리케이션 파일 복사
COPY gradlew .
COPY gradle gradle
COPY build.gradle .
COPY settings.gradle .
COPY lib/nsso-agent-15.jar .
COPY src src
COPY lib lib

# 권한 설정 및 빌드
RUN chmod +x ./gradlew
RUN ./gradlew bootJar

# 실행 스테이지
FROM adoptopenjdk/openjdk8:latest
LABEL maintainer="myProject"
VOLUME /tmp

# 환경 변수 설정
ARG SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE:-dev}
ENV SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE:-dev}
RUN echo ${SPRING_PROFILES_ACTIVE}

# 빌드된 JAR 파일 복사
COPY --from=build build/libs/*.jar app.jar

# 타임존 설정
ENV TZ=Asia/Seoul
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone

# 사용자 및 권한 설정
RUN groupadd -r springboot && useradd -r -g springboot springboot
RUN mkdir -p /logs/sso && chown -R springboot:springboot /logs/sso

# 사용자 변경
USER springboot

# 포트 설정
EXPOSE 17010

# 애플리케이션 실행
ENTRYPOINT ["java", "-Djava.security.egd=file:/dev/./urandom", "-jar", "app.jar"]
  • 실행 스테이지
    • 빌드된 JAR 파일을 실행 환경으로 복사하고, 환경 변수 및 타임존 설정을 수행합니다.
    • 애플리케이션을 실행할 사용자와 권한을 설정한 후, JAR 파일을 실행합니다.

이 Dockerfile을 사용하여 GitHub Actions에서 Docker 이미지를 빌드하고, 이를 Kubernetes 클러스터에 배포할 수 있습니다.

3. Kubernetes File 작성

애플리케이션을 Kubernetes 클러스터에 배포하기 위한 YAML 파일을 작성합니다. 

Deployment 파일 (deployment.yaml)

apiVersion: v1
kind: Namespace
metadata:
  namespace: myProject
  name: myProject
---
apiVersion: apps/v1
kind: Deployment
metadata:
  namespace: myProject
  name: myProject
spec:
  replicas: 1
  selector:
    matchLabels:
      app: myProject
  template:
    metadata:
      labels:
        app: myProject
    spec:
      containers:
        - name: myProject
          image: kustomization-eks-repository
          imagePullPolicy: Always
          ports:
            - containerPort: 17010
          env:
            - name: SPRING_PROFILES_ACTIVE
              value: dev
            - name: AWS_REGION
              value: ap-northeast-2

Kustomization 파일 (kustomization.yaml)

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - deployment.yaml
  - service.yaml
images:
  - name: kustomization-eks-repository

 

Service 파일 (service.yaml)

apiVersion: v1
kind: Service
metadata:
  namespace: myProject
  name: myProject
spec:
  selector:
    app: myProject
  ports:
    - port: 80
      targetPort: 17010
      name: http
  • Namespace
    • Namespace 리소스를 정의하여 myProject라는 네임스페이스를 생성합니다.
  • Deployment
    • 애플리케이션이 실행될 컨테이너 이미지를 지정하고, 환경 변수를 설정합니다.
    • 클러스터 내에서 애플리케이션의 복제본 수(replicas)를 정의합니다.
  • Kustomization
    • deployment.yaml과 service.yaml 파일을 리소스로 포함하고, 이미지 태그를 latest로 설정합니다.
  • Service:
    • service.yaml 파일에서 Service 리소스를 정의하여 클러스터 외부에서 애플리케이션에 접근할 수 있도록 합니다.
    • selector를 사용하여 Deployment와 연결하고, 포트 매핑을 설정합니다.

이렇게 작성된 Kubernetes 파일들을 사용하여, 애플리케이션을 클러스터에 배포하고 관리할 수 있습니다.

4. Git Action 파일작성

├── .github
│   └── workflows
│       └── pipeline-push-dev.yaml
name: DEV - Deploy to Amazon EKS

on:
  push:
    branches: [ dev ]
    paths-ignore:
      - ".github/**"

env:
  AWS_ACCESS_KEY_ID: ${{ secrets.DEV_AWS_ACCESS_KEY_ID }}
  AWS_SECRET_ACCESS_KEY: ${{ secrets.DEV_AWS_SECRET_ACCESS_KEY }}
  EKS_CLUSTER: ${{ secrets.DEV_EKS_CLUSTER_NAME }}
  ECR_REPOSITORY: ${{ secrets.DEV_ECR_REPOSITORY }}
  APP_NAME:  myProject  # Application 이름. Image TAG Prefix로 사용 됨
  AWS_REGION: ap-northeast-2 # AWS EKS & ECR이 위치한 AWS Region
  DEPLOYMENT_NAME:  myProject # Kubernetes Deployment 명
  EKS_NAMESPACE: myProject
  YAML_ENV: kustomize/dev

jobs:
  build:
    name: Build and Push Docker Image
    runs-on: ubuntu-latest

    steps:
      # 소스 가져오기
      - name: Checkout
        uses: actions/checkout@v2

      # AWS credentials 설정
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v1
        with:
          aws-access-key-id: ${{ env.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ env.AWS_SECRET_ACCESS_KEY }}
          aws-region: ${{ env.AWS_REGION }}

      # AWS ECR 로그인
      - name: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v1

      # sha 난수 생성
      - name: Short sha
        run: echo "short_sha=`echo ${{ github.sha }} | cut -c1-8`" >> $GITHUB_ENV

      # Docker 빌드 및 ECR로 Push 진행
      - name: Build, tag, and push image to Amazon ECR
        id: build-image
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          ECR_REPOSITORY: ${{ env.ECR_REPOSITORY }}
          IMAGE_TAG: DEV_${{ env.APP_NAME }}
          SHORT_SHA: ${{ env.short_sha }}
        run: |-
          pwd
          ls -al
          docker build -t ${ECR_REGISTRY}/${ECR_REPOSITORY}:${IMAGE_TAG}_${SHORT_SHA} -f Dockerfile_DEV .
          docker push ${ECR_REGISTRY}/${ECR_REPOSITORY}:${IMAGE_TAG}_${SHORT_SHA}
          docker tag ${ECR_REGISTRY}/${ECR_REPOSITORY}:${IMAGE_TAG}_${SHORT_SHA} ${ECR_REGISTRY}/${ECR_REPOSITORY}:${IMAGE_TAG}_latest
          docker push ${ECR_REGISTRY}/${ECR_REPOSITORY}:${IMAGE_TAG}_latest
          echo "::set-output name=image::${ECR_REGISTRY}/${ECR_REPOSITORY}:${IMAGE_TAG}_${SHORT_SHA}"

  deploy:
    needs: build
    name: Deploy to DEV Environment
    runs-on: ubuntu-latest

    steps:
      # 소스 가져오기
      - name: Checkout
        uses: actions/checkout@v2

      # AWS credentials 설정
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v1
        with:
          aws-access-key-id: ${{ env.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ env.AWS_SECRET_ACCESS_KEY }}
          aws-region: ${{ env.AWS_REGION }}

      # AWS ECR 로그인
      - name: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v1

      # sha 난수 생성
      - name: Short sha
        run: echo "short_sha=`echo ${{ github.sha }} | cut -c1-8`" >> $GITHUB_ENV

      # EKS 배포를 위한 Kubeconfig 설정
      - name: Setup kubeconfig
        id: setup-kubeconfig
        env:
          AWS_REGION: ${{ env.AWS_REGION }}
          EKS_CLUSTER: ${{ env.EKS_CLUSTER }}
        run: |-
          aws eks --region ${AWS_REGION} update-kubeconfig --name ${EKS_CLUSTER}

      # EKS로 배포
      - name: Deploy to DEV Environment
        id: deploy-eks
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          ECR_REPOSITORY: ${{ env.ECR_REPOSITORY }}
          IMAGE_TAG: DEV_${{ env.APP_NAME }}
          SHORT_SHA: ${{ env.short_sha }}
          EKS_NAMESPACE: ${{ env.EKS_NAMESPACE }}
        run: |-
          cd $YAML_ENV

 

GitHub Actions 파일을 작성하여 CI/CD 파이프라인을 자동화합니다. 필자는 GitHub Actions 템플릿이 아닌 직접 작성한 Git Action 파일을 사용하여 배포를 수행하는 작업을 구성했습니다. dev 브랜치에 푸시될 때 작업이 수행되며, Docker 이미지를 빌드하고 Amazon ECR에 푸시한 후 Kubernetes 클러스터에 배포합니다.

의도는 "메시징"이다. 훌륭하고 성장 가능한 시스템을 만들기 위한 핵심은 모듈 내부의 속성과 행동이 어떤가보다는 모듈이 어떻게 커뮤니케이션하는가에 달려 있다.

 

자율적인 책임

자율적인 객체란 스스로 정한 원칙에 따라 판단하고 스스로의 의지를 기반으로 행동하는 객체다. 타인이 정한 규칙이나 명령에 따라 판단하고 행동하는 객체는 자율적인 객체라고 부르기 어렵다.

객체가 어떤 행동을 하는 유일한 이유는 다른 객체로부터 요청을 수신했기 때문이다. 요청을 처리하기 위해 객체가 수행하는 행동을 책임이라고 한다. 따라서 자율적인 객체란 스스로의 의자와 판단에 따라 각자 맡은 책임을 수행하는 객체를 의미한다.

적절한 책임이 자율적인 객체를 낳고, 자율적인 객체들이 모여 유연하고 단순한 협력을 낳는다. 따라서 협력에 참여하는 객체가 얼마나 자율적인지가 전체 애플리케이션의 품질을 결정한다.

 

왕은 목격자인 모자장수에 "증언하라" 라는 요청을 전송한다. 모자장수가 재판이라는 협력에 참여하기위해서는 왕의 요청을 적절하게 처리한 후 응답해야한다. 요청은 수신자의 책임을 암시하므로 모자 장수는 재판이라는 협력에 참여하기 위해 '증언할' 책임을 진다. 왕은 모자 장수에게 '증언하라'라는 자신의 요청에 반응해 책임을 완수할 수만 있다면 어떤 방법으로 증언하는지에 관해서는 싱겨을 쓰지않으며, 스스로의 의자와 판단에 따라 자유롭게 선택할 수 있다.

반면 왕이 모자 장수가 증언하는데 필요한 행동을 좀더 상세히 요청한다고 가정해보자 '목격했던 장면을 떠올리고', '떠오르는 기억을 시간 순서대로 재구성'한 후 '말로 간결하게 표현' 해야 하는 책임을 떠안게 된다.

 

협력의 결과로 모자장수가 왕의 요청을 받아 자신이 목격한것을 증언하게 된다는점에서는 동일하다. 하지만 모자 장수에게 주어진 권한에는 큰 차이가 있다. 

첫번째 모자장수에게 할당된 '증언하라' 라는 책임은 그 자체로 모자 장수의 자율성을 충분히 보장 할 수 있을 정도로 포괄적이고 추상적이면서도 모자 장수가 해야 할일을 명확하게 지시하고 있다. 반면 두번째 협력에서 모자 장수에게 할당된 좀 더 상세한 수준의 책임들은 모자 장수의 자율성을 제한한다.

자율적인 책임의 특징은 객체가 '어떻게' 해야하는가가 아니라 '무엇을' 해야하는가를 요청한다. 무엇은 수신한 객체의 책임이다.

 

책임이라는 말 속에는 어떤 행동을 수행한다는 의미가 포함되어 있다. 객체가  자신에게 할당된 책임을 수행하도록 만드는것은 외부에서 전달되는 요청이다. 객체가 다른 객체에게 접근할 수 있는 유일한 방법은 요청을 전송하는 것뿐이다. 그리고 이 요청을 메시지라고 한다. 메시지는 객체로 하여금 자신의 책임, 즉 행동을 수행하게 만드는 유일한 방법이다. 

 

우리 모두 합친것보다 더 현명한 사람은 없다.

 

객체지향 설계의 전체적인 품질을 결정하는 것은 개별 객체의 품질이 아니라 여러 객체들이 모여  이뤄내는 협력의 품질이다. 훌륭한 객체 지향 설계자는 객체들간의 요청과 응답 속에서 창발하는 협력에 초점을 맞춰 애플리케이션을 설계한다. 협력이 자리를 잡으면 저절로 객체의 행동이 드러나고 뒤이어 적절한 객체의 상태가 결정된다.

협력

책의 파이를 훔친 하트 잭을 읽고

 

객체지향 관점에서 재판 자에면에 등장하는 모든 등장인물들은 객체다. 왕과 하얀 토끼, 모자 장수라고 불리는 객체들은 하트 잭을 재판하기 위해 서로 협력하고 있다. 즉, 이야기에 등장하는 객체들은 하트 잭의 재판이라는 동일한 목적을 달성하기 위해 협력하고 있다.

 

왕의 증인 호출 요구(요청), 증인의 입장(응답) 등의 과정을 통해 협력이 이뤄진다.

어떤 등장인물들이 특정한 요청을 받아들일 수 있는 이유는 그 요청에 대해 적절한 방식으로 응답하는데 필요한 지식과 행동 방식을 가지고 있기 때문이다. 그리고 요청과 응답은 협력에 참여하는 객체가 수행할 책임을 정의한다.

책임

객체지향의 세계에서는 어떤 객체가 어떤 요청에 대해 대답해 줄 수 있거나, 적절한 행동을 할 의무가 있는 경우 해당 객체가 책이믈 가진다고 말한다.

 

객체의 책임은 '객체가 무엇을 알고 있는가'와 '무엇을 할 수 있는가'로 구성된다.

  • 하는것
    • 객체를 생성하거나 계산을 하는 등의 스스로 하는것
    • 다른 객체의 행동을 시작시키는 것
    • 다른 객체의 활동을 제어하고 조절하는 것
  • 아는것
    • 개인적인 정보에 관해 아는것
    • 관련된 객체에 관해 아는것
    • 자신이 유도하거나 계산할 수 있는것에 관해 아는것

역할

역할은 협력내에서 다른 객체로 대체할 수 있음을 나타내는 일종의 표식이다. 협력안에서 역할은 "이자는 해당 역할을 수행할 수 있는 어떤 객체라도 대신할 수 있습니다." 라고 말하는 것과 같다. 역할을 대체하기 위해서는 역할이 수신할 수 있는 메시지를 동일한 방식으로 이해해야한다. 메시지가 책임을 의미하기때문에 동일한 역할을 수행할 수 있다는것은 해당 객체들이 협력 내에서 동일한 책임의 집합을 수행할 수 있다는 것을 의미한다. 요약하면 역할의 개념을 사용하면 유사한 협력을 추상화해서 인지 과부하를 줄일 수 있다. 또한 다양한 객체들이 협력에 참여할 수 있기 때문에 협력이 좀더 유연해지며 다양한 객체들이 동일한 협력에 참여할 수 있디 떄문에 재사용성이 높아진다. 역할은 객체 지향설계의 단순성, 유연성, 재사용성을 뒷받침하는 핵심 개념이다.

 

 

일단 컴퓨터를 조작하는 것이 추상화를 구축하고, 조작하고, 추론하는것에 관한 모든것이라는 것을 깨닫고 나면 (훌륭한) 컴퓨터 프로그램을 작성하기 위한 중요한 전제 조건은  추상화를 정확하게 다루는 능력이라는 것이 명확해진다.

 

책의 해리백 이야기를 읽고

추상화를 통한 복잡성 극복

현실에 존재하는 다양한 현상 및 사물과 상호작용하기 위해서는 우선 현실을 이해해야한다. 문제는 복잡성의 총체인 현실이라는 괴물을 그대로 수용하기에는 인간이 지니고 있는 인지 능력과 저장 공간이 너무나도 보잘것 없다. 따라서 사람들은 본능적으로 이해하기 쉽고 예측 가능한 수준으로 현실을 분해하고 단순화하는 전략을 따른다.

 

해리백의 지하철 노선도는 불필요한 지형 정보를 제거함으로써 단순함을 달성한 추상화의 훌륭햔 예이다. 진정한 의미에서 추상화란 현실에서 출발하되 불필요한 부분을 도려내가면서 사물의 놀라운 본질을 드러나게 하는 과정이라고 할 수 있다. 추상화의 목적은 불필요한 부분을 무시함으로써 현실에 존재하는 복잡성을 극복하는 것이다.

추상화
어떤 양상, 세부 사항, 구조를 좀 더 명확하게 이해하기 위해 특정 절차나 물체를 의도적으로 생략하거나 감춤으로써 복잡도를 극복하는 방법이다. 복잡성을 다루기 위해 추상화는 두 차원에서 이뤄진다.

첫번째 차원은 구체적인 사물들 간의 공통점은 취하고 차이점은 버리는 일반화를 통해 단순하게 만드는것이다.
두번째 차원은 중요한 부분을 강조하기 위해 불필요한 세부사항을 제거함으로써 단순하게 만드는것이다.

모든 경우에 추상화의 목적은 복잡성을 이해하기 쉬운 수준으로 단순화하는 것이라는 점을 기억하라.

객체지향과 추상화

책의 모두 트럼프일뿐을 읽고

 

앨리스는 객체들중에서 하얀 토끼를 제외한 모든 객체를 '트럼프'라는 하나의 개념으로 단순화해서 바라보고 있다. 앨리스는 정원사들 , 병사들, 왕자와 공주, 하객으로 참석한 왕과 왕비들, 하트 왕과 하트여왕의 차이점은 과감하게무시한채 공통점만을 취해 단순화 해버렸다.

결과적으로 앨리스는 정원에 있는 인물을 두개의 그룹으로 나눴다. 하나는 트럼프 그룹이고 또 다른 하나는 토끼그룹이다.

 

앨리스가 인물들의 차이점을 무시하고 공통점만을 취해 트럼프라는 개념으로 단순화한것은 추상화의 일종이다. 이처럼 공통점을 기반으로

객체들을 묶기 위한 그릇을 개념이라고한다. 개념을 이용하면 객체를 여러 그룹으로 분류 할 수 있다. 앨리스가 정원에 존재하는 객체를 '트럼프'와 '토끼' 라는 두개의 개념으로 나누고는 두개념에 적합한 객체가 각그룹에 포함되도록 분류했다는 사실에 주목하라. 결과적으로 개념은 공통점을 기반으로 객체를 분류 할 수 있는 일종의 체라고 할 수 있다.

 

분류는 객체지향의 가장 중요한 개념 중 하나다. 어떤 객체를 어떤 개념으로 분류할지가 객체지향의 품질을 결정한다. 객체를 적절한 개념에 따라 분류하지 못한 애플리케이션은 유지보수가 어렵고 변화에 쉽게 대처하지 못한다. 

 

시작하며

해당 글은 제목에서 보셨듯이 3년동안 방치된 증권사 폐쇄망  레거시 프로젝트를 배포하며 필자의 우당탕탕 경험을 작성하였다.

 

사내에서 간단한 변경개발 건이라며 000사의 프로젝트를 받게되었다.

 

해당 프로젝트의 초기 개발자들은 이미 퇴사한 팀장 급 분들이 개발하셨고, 남은 우리팀의 팀장님만 해당 프로젝트의 간략한 히스토리를 알고계셨다.

서버 프로젝트는 Admin, App으로 총 2개의 프로젝트로 구성되어 있으며, 환경은 아래와 같다.

프로젝트 구성 구분 버전
Admin Java 1.8
Spring 2.3.1
Apache Tomcat  
App Java 1.8
Spring Boot 2.3.1

 

여기서 문제는 폐쇄망이라는점과 코드의 히스토리를 아는사람이 없다 라는것이다.

 

Admin 서버의 경우 프로젝트를 Clone 받은 후 Spring 이기 때문에 Apache Tomcat설정을 추가하였다.

Tomcat Context-path 및 context.xml 등등 여러 설정을 추가하고 사내 개발서버를 구축하여 테스트를 하였다.

Admin 모듈의경우 큰 변수없이 잘 마무리하였다.

 

Admin Tomcat 설정 참고사이트

하지만 ApServer가 쉽지 않았다. 프로젝트를 Clone 받을때 부터 라이브러리 문제로 모든프로젝트가 컴파일에러가 발생하고 있었다.

해당 라이브러리는 프로젝트내에 위치하고 있지만 인식되지 않아 pom.xml 에 수동으로 system-path를 설정하여 컴파일 시켰다.

이후 App 서버도 변경 개발 후 사내 개발서버에 구축하여 테스트를 하고 마무리 되는줄 알았으나, 코드가 이상하다는걸 느꼈다.

 

"왜 return을  true 로 고정하는걸까?" 분명 권한 인증단계인데.. 아.... 이거 코드가 운영 코드가 아니다.. 라는걸 느꼈다.

모두 이상한건 아니지만, 인증단계의 몇 부분이 이상하다보니 해당 코드에대한 신뢰도가 많이 떨어졌다.

 

팀장님께 여쭤보니 고객사 내부에서 개발하고 코드를 가지고 나와 커밋하였고 팀장님은 다른 모듈을 개발해 정확히 기억나지 않는다고 하셨다.  고객사 담당자분께 여쭤보니 사내 이클립스로  최초배포하고, 변경 개발도 있었다고 들었다. 즉, 최신 코드는 사내의 이클립스에 있다. 라는 것이다.

 

정리를 해보자면 코드는 배포 직전의 코드인것같고, 코드는 신뢰 할 수 없다. 그로인해 war 파일 추출은 불가능하여,

.Java 파일을 컴파일 하여 .class 파일로 운영서버의 ROOT 디렉토리의  class 파일을 교체하면 정상 반영이 되기때문에 해당방법을 선택하기로하였다. 현재 변경 파일이 인증단계이기 때문에  Local 코드로는 불가능한것이다.

 

고객사에 방문하여 Admin은 Local의 코드로 war를 말아서 배포하였고, 특이사항은 없었다.

App 서버 코드를 빌드 시키려하였으나, 폴더 정리가 안되어있고, 담당자분 말씀으로는 보안프로그램이 작동되어 특정 하위폴더는 삭제가된다라고 말씀하시며 정확한 폴더와 삭제파일에 대해서는 인지를 못하시고 계셨다. 

 

반입 프로그램중 이클립스를 찾아 App 서버를 import 시키고 빌드하며 겪은 이슈 및 참고 사이트이다.

고객사에 반입한 라이브러리의 문제인지 Maven 빌드가 지속 실패하였고, 배포를 우선시 해야했기에 .class 파일을 생성하기위해 삽질을하였고, 결국 .class 파일로 배포하였다.

 

배포는 문제없었지만 깨진창문에 커튼을 달아놓는 격이기 때문에 여유가 있다면 배포환경을 다시 구축하고 싶다. 

 

 

+ Recent posts