Categories: 개발일지

읽기 좋은 코드를 작성하는 사고법: 논리, 사고의 흐름과 SOLID

들어가며

코드를 작성할 때 종종 “이 로직을 어떻게 단순하고 읽기 쉽게 표현할 수 있을까?” 고민하게 됩니다. 제가 수강 중인 “Readable Code: 읽기 좋은 코드를 작성하는 사고법” 강의 [논리, 사고의 흐름] 섹션에서는, 인지적 부담을 줄이고 가독성을 높이기 위해 고려해야 할 여러 포인트를 다루는데, 개인적으로 불필요한 else 제거 (Early Return), 중첩 분기/반복 최소화, 변수 선언 위치 등이 특히 인상 깊었습니다.

이 내용들을 학습하고 나니, 작은 코드 한 줄도 사람의 사고 방식을 기준으로 재배치하거나 단순화할 수 있다는 걸 체감했습니다. 오늘은 강의를 보고 개인적으로 느낀 점 그리고 함께 진행중인 인프런 워밍업 클럽 스터디 3기 미션까지 공유해 보겠습니다.

논리, 사고의 흐름

  • 뇌 메모리(인지적 경제성) 절약하기
    • 한 번에 너무 많은 정보를 처리하게 만들면, 읽는 사람이 코드를 해석하다가 지칩니다.
    • 단순하게 보는 습관, 불필요한 조건 분기나 깊은 중첩을 줄여야 합니다.
  • Early Return (else 줄이기)
    • 조건이 맞지 않으면 즉시 return 해주는 방식으로, 복잡한 else 구문을 없앨 수 있습니다.
    • 이렇게 하면 코드를 “위→아래”로 일관성 있게 읽어 내려갈 수 있어, 가독성이 올라갑니다.
  • 중첩 분기·반복의 적절한 활용
    • 무조건 중첩을 없애는 게 정답은 아니지만,
    • 필요 이상으로 중첩된 구조는 사고 흐름을 방해하므로, 메서드 분리 혹은 조건문 재배치를 고민합니다.
  • 변수 선언을 가까운 곳에 (필요한 시점에)
    • 변수를 너무 일찍 선언해두면, 먼 훗날에 그 변수가 어디서, 왜 쓰이는지 까먹기 쉽습니다.
    • 필요한 시점에서 선언해주면, “이 변수는 이런 목적”이라는 흐름을 분명히 알 수 있습니다.
  • 부정어(negation) 최소화
    • if (!isSomething()) 보다는 if (isNotSomething()) 또는 좀 더 적극적인 표현을 쓸 수 있는지 고민하기
    • 특히, 메서드명에서 부정어를 쓰기보다 긍정(또는 다른 적절한 단어)으로 바꿔서 가독성을 높일 수 있습니다.

미션1: 코드 리팩토링

아래 코드는 사용자가 생성한 “주문(Order)”이 유효한지 검증하는 메서드입니다.
[섹션 3. 논리, 사고의 흐름]에서 이야기한 내용을 적용해 읽기 좋은 코드로 리팩토링해 봅시다.

원본 코드

Java
public boolean validateOrder(Order order) {
    if (order.getItems().size() == 0) {
        log.info("주문 항목이 없습니다.");
        return false;
    } else {
        if (order.getTotalPrice() > 0) {
            if (!order.hasCustomerInfo()) {
                log.info("사용자 정보가 없습니다.");
                return false;
            } else {
                return true;
            }
        } else if (!(order.getTotalPrice() > 0)) {
            log.info("올바르지 않은 총 가격입니다.");
            return false;
        }
    }
    return true;
}
Java

  • 중첩 if-else 구조가 눈에 띕니다.
  • 조건 로직이 잘게 나누어져 있지만, 읽는 사람이 살짝 혼동할 수 있습니다.
리팩토링 예시 1 (단순화, Early Return)
Java
public boolean validateOrder(Order order) {
    // 주문 항목 유무
    if (order.getItems() == null || order.getItems().isEmpty()) {
        log.info("주문 항목이 없습니다.");
        return false;
    }

    // 총 가격 검증
    if (order.getTotalPrice() <= 0) {
        log.info("올바르지 않은 총 가격입니다.");
        return false;
    }

    // 고객 정보 검증
    if (!order.hasCustomerInfo()) {
        log.info("사용자 정보가 없습니다.");
        return false;
    }

    // 여기까지 왔다면 유효한 주문
    return true;
}
Java

  • Early Return을 적극 활용해, “조건 미충족”일 때는 즉시 false 리턴 → else가 거의 사라졌습니다.
  • 코드를 위→아래로 읽으면서, “주문 항목 → 가격 → 고객정보” 순으로 자연스럽게 흐릅니다.
  • 만약 null 처리나 추가 검증 로직이 필요하다면, 해당 if문을 제일 위쪽에 간단히 넣어 처리할 수 있습니다.
리팩토링 예시 2 (Order 내부 책임 분리)
Java
public boolean validateOrder(Order order) {
    // Order에게 스스로 검사하도록 메시지(메서드)를 보낼 수 있음
    // 내부 로직에서 "항목, 총가격, 사용자 정보"를 판단
    return order.isValid();
}
Java

Java
// 예: Order 내부
public boolean isValid() {
    if (this.items == null || this.items.isEmpty()) {
        log.info("주문 항목이 없습니다.");
        return false;
    }
    if (this.totalPrice <= 0) {
        log.info("올바르지 않은 총 가격입니다.");
        return false;
    }
    if (!this.hasCustomerInfo()) {
        log.info("사용자 정보가 없습니다.");
        return false;
    }
    return true;
}
Java

  • 도메인 객체(Order)가 자기 책임을 맡도록 하는 방식도 가능합니다.
  • 외부에서 validateOrder() 로직을 매번 작성할 필요 없이, Order 자체가 “유효성 검증”을 수행하도록 합니다.

어떠한 방법을 택하든, 중첩된 조건을 가능한 단순화하고, 각 조건의 의도를 빠르게 파악할 수 있도록 작성하는 것이 핵심이라고 생각합니다.

미션2: SOLID를 자기 언어로 정리하기

SOLID에 대하여 자기만의 언어로 정리해 봅시다.

미션에서 강조된 포인트 중 하나는, ‘자신의 언어로 표현할 수 있는가’ 였습니다.
아래는 제가 개인적으로 이해한 SOLID 각 원칙입니다.

SRP (Single Responsibility Principle: 단일 책임 원칙)

정의
하나의 클래스(혹은 모듈)는 “오직 하나의 책임(변경 이유)”만 가져야 한다.

  • 제 해석
    • 이 클래스가 해야 할 일이 2개 이상이면, 수정 포인트가 여러 군데 생긴다.
    • 예를 들어, 데이터베이스 액세스 + 비즈니스 로직이 한 클래스에 엉켜 있으면, DB 구조가 바뀌어도 이 클래스를 건드려야 하고, 비즈니스 규칙이 바뀌어도 이 클래스를 건드려야 합니다. 유지보수하기가 점점 어려워집니다.
    • SRP를 지키면, “이 클래스는 이 일만 담당한다.” 라고 말할 수 있어, 변경 책임을 명확히 구분하기 쉽습니다.
예시 코드
Java
// SRP를 어긴 예시 (하나의 클래스가 여러 책임 수행)
public class OrderService {
    // 주문 처리 로직
    public void processOrder(Order order) {
        // 비즈니스 로직...
    }

    // DB 접근 로직 (DAO 역할)
    public void saveOrderToDatabase(Order order) {
        // DB connection...
        // SQL INSERT...
    }

    // 메일 발송 로직
    public void sendOrderConfirmationMail(Order order) {
        // SMTP 로직...
    }
}
Java

Java
// SRP 준수 예시 (책임을 구분하여 클래스 분리)
public class OrderService {
    private final OrderRepository orderRepository;
    private final MailService mailService;

    public OrderService(OrderRepository orderRepository, MailService mailService) {
        this.orderRepository = orderRepository;
        this.mailService = mailService;
    }

    // 주문 처리 (비즈니스 로직)
    public void processOrder(Order order) {
        // 1) 주문 관련 비즈니스 로직...
        // 2) 주문 DB 저장은 OrderRepository에게 맡김
        orderRepository.save(order);
        // 3) 메일 발송은 MailService에게 맡김
        mailService.sendConfirmation(order);
    }
}

// DB 접근 전담
public class OrderRepository {
    public void save(Order order) {
        // DB 커넥션 및 SQL 로직...
    }
}

// 메일 발송 전담
public class MailService {
    public void sendConfirmation(Order order) {
        // SMTP, 메일 전송 로직...
    }
}
Java

  • 핵심: 주문 처리는 OrderService가 하지만, DB 접근과 메일 발송 로직을 따로 빼서 책임을 분리했습니다. 이렇게 하면 주문 프로세스DB 로직, 메일 발송이 각각 변경될 때 수정 범위를 서로 최소화할 수 있습니다.

OCP (Open-Closed Principle: 개방-폐쇄 원칙)

정의
확장에는 열려 있고, 수정에는 닫혀 있어야 한다.

  • 제 해석
    • 새로운 기능을 추가하려고 할 때, 기존 코드를 자꾸 뜯어고쳐야 한다면 OCP가 깨진 것입니다.
    • 인터페이스(추상화)를 잘 활용해 두면, 새 기능을 만들 때 기존 클래스를 수정하지 않고도(=닫혀 있다) 새 클래스를 추가해(=열려 있다) 시스템을 확장할 수 있습니다.
예시 코드
Java
// OCP를 어긴 예시: 할인 정책 추가할 때마다 if문 수정
public class DiscountService {
    // type으로 어떤 할인인지 구분
    public int getDiscountPrice(int price, String discountType) {
        if ("rate".equals(discountType)) {
            return price - (int)(price * 0.1); // 10% 할인
        } else if ("fixed".equals(discountType)) {
            return price - 2000;              // 2000원 할인
        } else if ("vip".equals(discountType)) {
            return price - 3000;              // 추가로 VIP 할인
        }
        return price;
    }
}
Java

  • 새로운 할인 정책(예: special)을 추가하면, 매번 else if를 추가해야 하고 기존 코드를 수정해야 합니다.

Java
// OCP 준수 예시: 인터페이스로 추상화 -> 새로운 할인 정책을 추가할 때 확장
public interface DiscountPolicy {
    int applyDiscount(int price);
}

public class RateDiscountPolicy implements DiscountPolicy {
    @Override
    public int applyDiscount(int price) {
        return price - (int)(price * 0.1);
    }
}

public class FixedDiscountPolicy implements DiscountPolicy {
    @Override
    public int applyDiscount(int price) {
        return price - 2000;
    }
}

// 사용처
public class DiscountService {
    private final DiscountPolicy discountPolicy;

    public DiscountService(DiscountPolicy discountPolicy) {
        this.discountPolicy = discountPolicy;
    }

    public int getDiscountPrice(int price) {
        return discountPolicy.applyDiscount(price);
    }
}
Java

  • 새로운 할인 정책이 필요하면 DiscountPolicy를 구현한 클래스를 추가(예: VipDiscountPolicy)하면 됩니다.
  • DiscountService 내부 코드는 수정 없이(=닫혀 있음), 새로운 구현체만 추가(=열려 있음)하면 되므로 OCP를 만족합니다.

LSP (Liskov Substitution Principle: 리스코프 치환 원칙)

정의
부모 클래스(인터페이스) 자리에 자식 클래스를 대입해도, 정상적으로 동작해야 한다.

  • 제 해석
    • 부모(상위 타입)가 약속한 동작을 자식이 깨뜨리면 안 됩니다.
    • 자식 타입을 생성해 놨더니, 부모 로직에서 예상하는 방식과 다르게 행동하거나 예외가 터지는 경우 LSP 위반입니다.
예시 코드
Java
// LSP를 어긴 예시
public class Rectangle {
    protected int width;
    protected int height;

    public void setWidth(int width) { this.width = width; }
    public void setHeight(int height) { this.height = height; }

    public int area() {
        return width * height;
    }
}

public class Square extends Rectangle {
    // 정사각형은 width, height가 같아야 하므로
    // 오버라이딩 시, LSP 위반 가능성
    @Override
    public void setWidth(int width) {
        this.width = width;
        this.height = width; // 정사각형이므로 height도 width에 맞춤
    }

    @Override
    public void setHeight(int height) {
        this.width = height;
        this.height = height;
    }
}

// LSP를 위반하는 케이스
// rectangle 객체로 치환했을 때 문제가 생김
public static void testRectangle(Rectangle r) {
    r.setWidth(10);
    r.setHeight(5);
    // 우리가 기대하는 area는 10*5=50
    // But Square로 치환되어 있으면 area()=25(정사각형이 돼버림)
}
Java

  • Square를 Rectangle로 대입했을 때, 우리가 기대하는 값과 다른 동작이 생깁니다.
  • 그런 경우, 부모로서 약속한 로직(가로, 세로 따로 세팅)이 자식에서 깨지는 것이라 볼 수 있습니다.

해결

  • Rectangle과 Square를 별도 상속 구조로 두지 않거나,
  • Shape라는 상위 추상화로 나누고, 사각형 vs 정사각형이 서로 다른 구현으로 존재하게 하는 등 다른 접근을 모색해야 합니다.

ISP (Interface Segregation Principle: 인터페이스 분리 원칙)

정의
클라이언트는 자신이 사용하지 않는 메서드에 의존하면 안 된다.

  • 제 해석
    • 너무 많은 기능을 갖는 ‘비대한 인터페이스’가 있으면, 일부 구현체는 필요 없는 메서드까지 억지로 구현해야 합니다.
    • 이런 상황은 유지보수성을 떨어뜨립니다.
    • 따라서, 필요한 기능끼리만 묶어서 인터페이스를 작게 잘 나누는 것이 좋습니다.
예시 코드
Java
// ISP를 어긴 예시: 인터페이스가 너무 비대함
public interface MultiFunction {
    void print(String text);
    void scan(String filePath);
    void fax(String number);
}

// 단순 프린터는 fax 기능 불필요
public class SimplePrinter implements MultiFunction {
    @Override
    public void print(String text) {
        // print logic
    }

    @Override
    public void scan(String filePath) {
        // scan 필요없어도 구현 강제
    }

    @Override
    public void fax(String number) {
        // fax 필요없어도 구현 강제
    }
}
Java

  • SimplePrinter는 팩스·스캔 기능이 필요 없는데도, 인터페이스 구현을 위해 빈 메서드를 만들어야 합니다.
Java
// ISP 준수 예시: 인터페이스 분리
public interface Printer {
    void print(String text);
}

public interface Scanner {
    void scan(String filePath);
}

public interface Fax {
    void fax(String number);
}

// 필요한 기능만 구현
public class SimplePrinter implements Printer {
    @Override
    public void print(String text) {
        // print logic
    }
}
Java

  • 이렇게 분리하면, 각 구현체는 자신이 사용/제공해야 하는 기능만 구현하면 됩니다.

DIP (Dependency Inversion Principle: 의존성 역전 원칙)

정의
고수준 모듈(주요 로직)이 저수준 모듈(세부 구현)에 의존하지 않도록, 둘 다 추상화된 인터페이스에 의존하라.

  • 제 해석
    • 간단히 말해, “인터페이스” 또는 “추상 클래스”를 사이에 두어, 실제 구현체를 몰라도(또는 교체해도) 고수준 로직이 영향을 받지 않도록 하는 원칙입니다.
    • 스프링의 DI 컨테이너가 이 개념을 적극 활용합니다.
    • 예를 들어, OrderService가 OrderRepository 인터페이스에만 의존하고, 어떤 구현체(MySQL, MongoDB 등)를 쓰는지는 외부에서 주입만 해주면 됩니다.
예시 코드
Java
// DIP 위반 예시: 고수준 모듈이 직접 저수준 구현을 생성하고 의존
public class OrderService {
    private MySqlOrderRepository repository = new MySqlOrderRepository();

    public void processOrder(Order order) {
        repository.save(order);
    }
}
Java

  • OrderService가 MySqlOrderRepository 구현체를 직접 생성하므로, DB 구현을 바꾸려면 OrderService 코드를 수정해야 합니다.
Java
// DIP 준수 예시: 추상화(인터페이스)에 의존
public class OrderService {
    private final OrderRepository repository; // 인터페이스

    public OrderService(OrderRepository repository) {
        this.repository = repository; // 외부에서 구현체 주입
    }

    public void processOrder(Order order) {
        repository.save(order);
    }
}

public interface OrderRepository {
    void save(Order order);
}

public class MySqlOrderRepository implements OrderRepository {
    @Override
    public void save(Order order) {
        // MySQL 저장 로직...
    }
}

public class MongoOrderRepository implements OrderRepository {
    @Override
    public void save(Order order) {
        // MongoDB 저장 로직...
    }
}
Java

  • 이제 OrderService는 구현체가 무엇인지 모릅니다.
  • 고수준(OrderService)과 저수준(MySqlOrderRepository, MongoOrderRepository) 모두 OrderRepository 추상화에 의존하므로, DB가 MySQL에서 MongoDB로 바뀌어도 OrderService 코드를 건드릴 필요가 없습니다.

이상 SOLID 각 원칙을 제 관점에서 풀어보고, 간단한 예시 코드를 곁들여 보았습니다.

  • SRP: 클래스 하나가 여러 가지 책임(변경 이유)을 지고 있지 않은가?
  • OCP: 기존 코드 변경 없이(닫혀 있음) 새 기능 추가(열려 있음)가 가능한가?
  • LSP: 상속(또는 인터페이스 구현) 시 부모가 약속한 행동을 자식이 제대로 지키는가?
  • ISP: 인터페이스가 너무 비대해 필요 없는 기능까지 구현해야 하는가?
  • DIP: 고수준 로직이 저수준 세부 구현에 직접 의존하지 않고, 추상을 통해 느슨하게 연결되어 있는가?

이 5가지 원칙을 모두 지키면 객체지향 설계의 큰 틀이 잡히며, 유지보수성과 확장성이 좋아집니다.

마치며

논리, 사고의 흐름에서 강조된 내용들을 실습해보며, 중첩 if-else긴 메서드를 단순화하는 것만으로도 코드를 읽는 사람의 인지적 부담이 크게 줄어든다는 점을 체감했습니다.

또한 SOLID 원칙은 객체지향 설계를 할 때 왜 이렇게 나누고, 왜 이렇게 의존관계를 맺는가?에 대한 큰 방향을 잡아줍니다. 처음부터 완벽하게 지키기는 어렵지만, 코드를 리팩토링할 때마다 조금씩 적용해보면 확실히 확장성이나 가독성이 올라가는 걸 느낄 수 있습니다.

이상으로, 강의 내용을 바탕으로 읽기 좋은 코드에 대해 다시 생각해본 후기와 미션 과제 정리를 마칩니다.
감사합니다.

출처
suover

Recent Posts

그림으로 쉽게 배우는 자료구조와 알고리즘: 미션1 | 메모리 검색

들어가며 소프트웨어를 개발할 때 메모리 관리 방식은 프로그램의 안정성과 성능을 좌우하는 핵심 요소입니다. 특히 자바스크립트,…

1일 ago

만들면서 쉽게 배우는 컴퓨터 구조: 미션1 | 진리표부터 회로 구현까지

들어가며 소프트웨어 개발자는 코드가 어떻게 실행되는지 정확히 이해해야 할 필요가 있습니다. 우리가 작성한 프로그램은 결국…

2일 ago

웹 서버(Web Server)와 WAS(Web Application Server) 알아보기

서론 현대 웹 애플리케이션 아키텍처에서 웹 서버(Web Server) 와 웹 애플리케이션 서버(WAS, Web Application Server)…

1개월 ago

HTTP 헤더(Header)란 무엇인가?

HTTP 헤더(Header)란? HTTP(Header)는 클라이언트와 서버 간에 교환되는 메타데이터로, 요청(Request)과 응답(Response)에 부가적인 정보를 실어 나르는 역할을…

2개월 ago

인프런 워밍업 클럽 스터디 3기 – 백엔드 클린 코드, 테스트 코드 후기

Readable Code: 읽기 좋은 코드를 작성하는 사고법Practical Testing: 실용적인 테스트 가이드 강의와 함께한 인프런 워밍업 클럽…

2개월 ago

인프런 워밍업 클럽 스터디 3기 – 백엔드 클린 코드, 테스트 코드 4주차 회고

Readable Code: 읽기 좋은 코드를 작성하는 사고법Practical Testing: 실용적인 테스트 가이드 강의와 함께한 인프런 워밍업 클럽…

2개월 ago