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주 ago

HTTP 상태코드 총정리 서버-클라이언트 간 명확한 의사소통

HTTP 상태코드란 무엇인가? HTTP 상태코드(HTTP Status Code)는 서버가 클라이언트의 요청을 처리한 결과를 수치화된 코드로 나타내는…

2주 ago

HTTP 메서드 완벽 가이드 GET, POST, PUT, PATCH, DELETE 등 총정리

HTTP란 무엇인가? HTTP(Hypertext Transfer Protocol)는 웹에서 데이터를 주고받기 위해 사용하는 응용 계층 프로토콜입니다. 우리가 브라우저에서…

3주 ago

HTTP 기본과 특징 알아둬야 할 모든 것

HTTP란 무엇인가? HTTP(Hypertext Transfer Protocol)는 인터넷에서 웹 브라우저와 웹 서버가 서로 통신하기 위해 사용하는 프로토콜입니다.…

4주 ago

Network 네트워크 URI와 웹 브라우저 요청 흐름 알아보기

들어가며 우리가 인터넷에서 웹사이트에 접속할 때 가장 먼저 하는 일은 브라우저 주소창에 어떤 문자열을 입력하는…

1개월 ago

Network 인터넷 네트워크란? 개념과 구조 완벽 정리

인터넷 네트워크란? "인터넷(Internet)"이라는 단어는 "인터네트워크(Internetwork)"의 줄임말입니다. 즉, 여러 개의 네트워크가 상호 연결되어 전 세계적으로 하나의…

1개월 ago