악마의 Clean Code: 20년 된 레거시 프로젝트 마이그레이션에서 얻은 교훈

발행: (2026년 1월 20일 오전 02:41 GMT+9)
19 min read
원문: Dev.to

Source: Dev.to

악마의 클린 코드: 20년 된 레거시 프로젝트 마이그레이션에서 얻은 교훈

프로젝트를 처음 접했을 때, “이건 레거시라서 손대면 안 된다”는 말이 가장 크게 들려왔습니다. 하지만 20년이라는 세월 동안 쌓인 기술 부채와 설계 결함을 그대로 두고는 새로운 기능을 추가하거나 성능을 개선하기가 거의 불가능했습니다. 결국 우리는 레거시를 그대로 유지하면서도 현대적인 아키텍처와 클린 코드를 도입해야 했고, 그 과정에서 여러 가지 교훈을 얻었습니다.

아래에서는 마이그레이션을 진행하면서 마주한 문제와 그 해결책, 그리고 앞으로 비슷한 상황에 처한 팀에게 도움이 될 만한 실전 팁을 정리했습니다.


1️⃣ 레거시라는 레이블에 속지 마라

“레거시”라는 말은 종종 “수정 금지” 혹은 **“그대로 두어야 한다”**는 의미로 오해됩니다. 실제로는 **“지속적인 유지보수가 필요하고, 현재 구조가 비효율적이다”**라는 신호일 뿐입니다.

  • 사실 확인: 코드베이스를 직접 탐색하고, 의존 관계와 데이터 흐름을 시각화해 보세요.
  • 기술 부채 측정: SonarQube, Code Climate 같은 도구를 활용해 복잡도, 중복도, 테스트 커버리지를 정량화합니다.
  • 우선순위 매기기: 비즈니스에 가장 큰 영향을 미치는 모듈부터 리팩터링을 시작합니다.

2️⃣ 리팩터링 전, 테스트는 필수

레거시 시스템은 테스트가 거의 없거나, 테스트가 있더라도 신뢰성이 낮은 경우가 많습니다. **“테스트 없이 리팩터링하면 안 된다”**는 원칙을 지키기 위해 다음을 수행했습니다.

# 기존 코드에 대한 스모크 테스트를 빠르게 만들기
npm install jest --save-dev
// 예시: 기존 함수에 대한 최소 테스트
describe('legacyFunction', () => {
  it('should return expected result', () => {
    expect(legacyFunction(2)).toBe(4);
  });
});
  • 스모크 테스트: 핵심 로직이 정상 동작하는지 확인하는 최소한의 테스트를 먼저 작성합니다.
  • 점진적 커버리지 확대: 리팩터링하면서 새로운 테스트를 추가하고, 기존 테스트를 보강합니다.
  • 테스트 자동화: CI 파이프라인에 테스트 실행을 포함시켜, 리팩터링 중에 회귀가 발생하지 않도록 합니다.

3️⃣ 작은 단위로 나눠서 마이그레이션

전체 시스템을 한 번에 옮기려 하면 위험이 커집니다. **“Strangler Fig 패턴”**을 적용해 점진적으로 새로운 모듈을 도입했습니다.

  1. 경계 정의: 기존 시스템과 새로운 시스템 사이의 API 경계를 명확히 합니다.
  2. 프록시 레이어: 요청을 라우팅하는 프록시를 두어, 새 모듈이 준비되면 점차 트래픽을 전환합니다.
  3. 점진적 전환: 하나의 기능(예: 인증)부터 새로운 서비스로 옮기고, 테스트 후 안정성을 확인합니다.
# 예시: Nginx 프록시 설정 (새 서비스로 라우팅)
location /auth/ {
    proxy_pass http://new-auth-service/;
}

4️⃣ 코드 스타일과 규칙을 강제한다

레거시 코드에는 일관되지 않은 네이밍, 과도한 중복, 불필요한 전역 변수 등이 난무합니다. 이를 해결하기 위해 ESLint, Prettier, Stylelint 등을 도입했습니다.

// .eslintrc.json (핵심 규칙)
{
  "extends": ["eslint:recommended", "plugin:prettier/recommended"],
  "rules": {
    "no-var": "error",
    "prefer-const": "error",
    "eqeqeq": ["error", "always"]
  }
}
  • 자동 포맷팅: PR 생성 시 자동으로 코드 포맷을 맞추어 리뷰 부담을 감소시킵니다.
  • CI 검증: npm run lint && npm run test 를 CI에 포함해, 규칙 위반이 배포되지 않도록 합니다.

5️⃣ 문서화는 선택이 아니라 필수

레거시 프로젝트는 보통 **“문서가 없다”**는 문제가 가장 큰 장애물 중 하나입니다. 마이그레이션 과정에서 다음을 실천했습니다.

  • README 업데이트: 프로젝트 구조, 빌드 방법, 배포 흐름을 최신 상태로 유지합니다.
  • 코드 주석: 복잡한 비즈니스 로직에 대한 설명을 추가하고, 왜 특정 구현을 선택했는지 기록합니다.
  • 위키 페이지: 아키텍처 다이어그램, 데이터베이스 스키마, 외부 서비스 연동 정보를 한눈에 볼 수 있게 정리합니다.

6️⃣ 팀 문화와 커뮤니케이션을 강화한다

기술적인 변화만큼 중요한 것이 팀 내 협업 방식입니다. 레거시 마이그레이션은 여러 부서와 이해관계자가 얽혀 있기 때문에, 다음과 같은 문화적 접근이 필요했습니다.

  • 데일리 스탠드업: 진행 상황과 장애물을 공유해 빠르게 문제를 해결합니다.
  • 페어 프로그래밍: 신규 모듈을 작성할 때 경험이 풍부한 개발자와 짝을 이루어 지식 전파를 촉진합니다.
  • 리팩터링 워크숍: 정기적인 코드 리뷰와 리팩터링 세션을 열어, 클린 코드 원칙을 팀 전체에 내재화합니다.

📌 핵심 정리

교훈핵심 포인트
레거시 라벨에 속지 말라실제 문제를 파악하고 우선순위를 정한다
테스트 없이 리팩터링 금지스모크 테스트 → 점진적 커버리지 확대
작은 단위로 전환Strangler Fig 패턴, 프록시 라우팅
코드 스타일 강제ESLint + Prettier + CI 검증
문서화는 필수README, 위키, 코드 주석
팀 문화 개선데일리 스탠드업, 페어 프로그래밍, 워크숍

마무리

20년 된 레거시 프로젝트를 마이그레이션하면서 가장 크게 깨달은 점은 **“레거시 자체가 목표가 아니라, 비즈니스 가치를 지속적으로 제공하는 것이 목표”**라는 사실입니다. 클린 코드를 도입하고, 자동화된 테스트와 CI 파이프라인을 구축하며, 팀 문화까지 개선한다면 레거시라는 무게에 짓눌리지 않고도 안정적인 성장과 혁신을 이룰 수 있습니다.

다음 단계: 현재 프로젝트에 적용 가능한 작은 리팩터링 파일 하나를 선택하고, 위에서 소개한 테스트와 린트 설정을 적용해 보세요. 작은 성공이 곧 큰 변화를 만들어냅니다.

TL;DR

  • 테스트를 작성하세요. 코드를 실제로 단위 테스트해 보기 전까지는 코드가 얼마나 엉망인지 제대로 알 수 없습니다.
  • 어노테이션을 이해하세요. @Getter@Setter만 필요할 때 @Data를 사용하지 마세요.
  • 리팩터를 두려워하지 마세요. 코드를 다시 작성하고 단순화할 기회가 있다면 잡으세요.

과거 아키텍처의 유령

2025년에 나는 20년 된 JSP 프로젝트를 최신 마이크로서비스 아키텍처로 마이그레이션한다는 벅찬 도전에 나섰다.

비즈니스 로직을 이해하기 위해 나는 오래된 JSP 파일들을 파고들어, 20년 전 개발자가 무엇을 달성하려 했는지 해독하려 애썼다. 클라이언트의 초기 요청은 간단해 보였지만 악몽으로 변했다:

“모델을 새 프로젝트에 복사하고 빈(bean)을 서비스로 변환해라.”

현실은 엉망이었다. 수십 개의 도메인 패키지에 흩어져 있는 수백 개의 모델 클래스들을 발견했다. 각 패키지에는 DAO와 DTO를 조작하는 정적 메서드가 가득한 utility 클래스가 최소 하나씩 있었다. 상속 트리는 마트료시카 인형처럼—클래스가 다른 클래스를 상속하고, 또 다른 클래스를 상속하면서 원래 목적이 사라졌다.

빠르게 진행해야 한다는 압박 속에서 우리는 고전적인 실수를 저질렀다: 오래된 구조를 새 서비스에 그대로 복사‑붙여넣기했다. 상태를 가진 변수를 무상태 메서드 파라미터로 바꾸는 것만으로 충분하다고 생각했다. 우리는 틀렸다.


“Bugfest”와 맞서기

코드가 새로운 “모던” 환경에 배치되자, 우리는 SonarQube로 분석했습니다. 그 결과는 재앙이었습니다.

보고서는 서사시적인 규모의 “버그 축제”였습니다: SQL‑인젝션 취약점, 비표준 네이밍 규칙, 그리고 방대한 중복 코드 블록들. 나의 일상은 좌절의 연속이 되었고, 원래 개발자들을 욕하고, 내 진로를 의심하고, 결국 소매를 걷어붙이고 이 혼란을 정리하게 되었습니다.

정리 단계가 끝날 무렵, 나는 다음을 달성했습니다:

  • 사용되지 않는 클래스를 거의 2,000개 삭제했습니다.
  • 중복 코드를 30 % 제거했습니다.
  • 수백 개의 버그를 수정하고 레거시 경고를 억제했습니다.

드디어 “마이크로서비스”가 실제처럼 보이기 시작했으며, 현재 900개의 클래스와 55 000줄의 코드가 있었습니다. 이제 마지막 보스, 80 % 테스트 커버리지에 도전할 차례였습니다.


“Devil’s Clean Code”에 대한 테스트 작성

레거시 코드를 테스트하는 것은, 자신이 작성하지 않은 코드를 다루는 독특한 도전 과제입니다. 저는 테스트 스캐폴딩을 생성하기 위해 AI 도우미에 크게 의존했으며, 그 덕분에 작업 속도가 크게 빨라졌습니다. 하지만 테스트하고 있던 로직을 살펴보니, 저는 Clean Code의 “악마 버전”을 읽고 있다는 것을 깨달았습니다.

  • Indentation Hell: 8–10 단계의 중첩 iffor 루프를 가진 메서드.
  • Condition Fatigue: 10–15개의 서로 다른 논리 게이트를 포함한 if 문.
  • Dead Wood: 도달할 수 없는 코드와 사용되지 않는 변수가 대량으로 존재함.

테스트를 진행하면서 이러한 문제들을 직면하게 되었습니다. 테스트에서 절대로 도달할 수 없는 분기가 있다면, 그 분기는 코드에 존재해서는 안 됩니다.


내가 배운 교훈 (고통스러운 방법)

1. 테스트는 진단 도구다

테스트는 다른 사람의 코드를 이해하는 가장 좋은 방법이다. 테스트를 통해 정확히 어디가 “스파게티”인지 알 수 있다. 메서드가 테스트하기 너무 어렵다면, 코드를 단순히 패치하는 것이 아니라 다시 작성해야 한다는 신호이다.

// BEFORE: The "Pyramid of Doom" (deeply nested logic)
public void processLegacyRuo(RuoDir ruoDir) {
    if (ruoDir != null) {
        if ("ACTIVE".equals(ruoDir.getTPRuo())) {
            if (ruoDir.getRuoArr() != null) {
                for (RuoItem item : ruoDir.getRuoArr()) {
                    if (item.getGG() > 0) {
                        // Business logic buried 5 levels deep
                        performOperation(item);
                    }
                }
            }
        }
    }
}

// AFTER: Flattened logic using guard clauses and streams
public void processRuo(RuoDir ruoDir) {
    // 1. Exit early if the object is invalid or not in the right state
    if (ruoDir == null || !"ACTIVE".equals(ruoDir.getTPRuo())) {
        return;
    }

    // 2. Handle null collections gracefully
    if (ruoDir.getRuoArr() == null) {
        return;
    }

    // 3. Use functional programming to handle the collection
    ruoDir.getRuoArr().stream()
          .filter(item -> item.getGG() > 0)
          .forEach(this::performOperation);
}

2. Lombok 함정

내가 가장 크게 “아하!” 했던 순간 중 하나는 Lombok이었다. 우리는 처음에 모든 모델에 @Data를 붙여 시간을 절약했다. 하지만 곧 브랜치 커버리지가 급격히 떨어지는 것을 발견했다.

왜 그럴까? @Data는 자동으로 @EqualsAndHashCode@ToString을 생성한다. 이 메서드들은 테스트해야 할 숨겨진 논리 브랜치를 많이 만들기 때문에 높은 커버리지 비율을 달성하기 어렵게 만든다. @Getter@Setter 같은 구체적인 애노테이션만 사용하도록 바꾸면서 불필요한 복잡성을 없애고 커버리지 목표를 달성할 수 있었다.

// BEFORE: @Data generates equals, hashCode, and toString, adding hidden branches
@Data
public class UserDto {
    private Long id;
    private String name;
    private String email;
}

// AFTER: Explicit annotations only for what we actually use
@Getter
@Setter
@NoArgsConstructor
public class UserDto {
    private Long id;
    private String name;
    private String email;
}

3. 리팩터링을 두려워하지 말라

레거시 코드는 위협적으로 보일 수 있지만, 두려워해서는 안 된다. 코드의 의도를 이해했다면 명확하게 다시 작성하라. 미래의 자신(그리고 팀)에게 큰 도움이 될 것이다.

// BEFORE: A maintenance nightmare with 20+ conditions
if ((input.getDtVersAA() == null || input.getDtVersAA().trim().isEmpty()) && 
    (input.getDtVersMM() == null || input.getDtVersMM().trim().isEmpty()) &&
    // ... imagine 15 more lines of this ...
    (input.getCodFisc() == null || input.getCodFisc().trim().isEmpty())) {

    // Do something if everything is empty
}

// AFTER: Using method references and streams for clarity
List<Supplier<String>> fieldsToCheck = Arrays.asList(
        input::getDtVersAA,
        input::getDtVersMM,
        // ... other getters ...
        input::getCodFisc
);

boolean allEmpty = fieldsToCheck.stream()
        .map(Supplier::get)
        .allMatch(value -> value == null || value.trim().isEmpty());

if (allEmpty) {
    // Do something if everything is empty
}

요점

  • 테스트를 일찍 작성하세요 – 숨겨진 복잡성을 드러냅니다.
  • Lombok을 현명하게 사용하세요 – 필요한 애노테이션만 사용합니다.
  • 적극적으로 리팩터링하세요 – 깨끗한 코드베이스는 테스트와 유지보수가 더 쉽습니다.

테스트를 진단 도구로 활용하고, 불필요한 Lombok‑생성 코드를 정리하며, 리팩터링을 두려워하지 않음으로써, 가장 복잡한 레거시 시스템조차도 유지보수가 쉽고 잘 테스트된 마이크로서비스로 변환될 수 있습니다.


리팩터링된 코드 예시

// Collect all the getters we want to check
List<Supplier<String>> fieldsToCheck = List.of(
    input::getDtVersMM,
    // ... add all 15 more lines ...
    input::getCodFisc
);

// We use a helper method (checkCampoVuoto) and allMatch to verify the state
boolean allFieldsEmpty = fieldsToCheck.stream()
    .allMatch(fieldGetter -> checkCampoVuoto(fieldGetter.get()));

if (allFieldsEmpty) {
    // Logic is now readable and easy to extend
}

최종 생각

레거시 시스템을 마이그레이션하는 것은 단순히 코드를 한 곳에서 다른 곳으로 옮기는 것이 아니라, 오래된 아이디어를 현대적인 표준으로 번역하는 일입니다. 혼란스럽고 좌절감이 들지만 궁극적으로 보람 있는 과정입니다.

가장 힘들었던 레거시 코드는 무엇인가요? 댓글에서 공포 이야기를 나눠봅시다!

Back to Blog

관련 글

더 보기 »