본문 바로가기
Java

[OOP] 객체지향 생활체조 원칙

by 덩라 2024. 10. 6.

1. 객체지향 생활체조 원칙이란?

  • 소트윅스 앤솔러지” 라는 책에서 제시한 객체지향 프로그래밍을 잘하기 위한 9가지 원칙
  • 객체지향적으로 설계 / 구현하는 것은 단순 암기로 해결되는 것이 아니라 꾸준히 연습하는 과정을 통해 성장하는 역량이라는 의미로 “생활체조” 라는 표현을 사용합니다.
❗아래 나올 9가지 원칙을 지키고 있지 않다고 해서 코드를 잘 못 짜고 있다고 할 수 없습니다. 무조건 지켜야하는 법칙이 아니므로 “이런 원칙도 있구나” 라고 참고 정도만 해주시길 부탁드립니다.

2. 객체지향 생활제초 원칙 9가지

1) 한 메서드에서 한 단계의 들여쓰기(indent) 만 한다.

  • if / for / while 같이 들여쓰기가 발생하는 코드를 중첩으로 사용하는 경우, 하나의 메서드가 1개 이상의 책임을 지고 있을 수 있습니다.
  • 하나의 메서드에서 하나의 책임만 질 수 있도록, “메서드 분리” 같은 방법으로 중첩 구조를 피할 수 있습니다.
// 특가 요금 정보 저장
// for 문 내부에 if 문 중첩이 걸려있는 상황
public void settingSpecialChargeInfo(Object args....) {
	
    // 1. 특가 요금 정보를 순회하면서
    for (SpecialChargeInfo item : specialChargeInfoList) {

        // 2. 특가 요금 정보에 필요한 데이터를 세팅해주고
        item.setCompanyId(companyId);
        item.setGroupId(groupId);
        item.setChargeSectionId(chargeSectionId);

        // 3. DB에 데이터를 저장 혹은 갱신
        if (StringUtils.isNotBlank(item.getEventType())) {
            mapper.specialChargeInfoUpdate(item);
        } else {
            item.setEventType(eventType);
            mapper.specialChargeInfoInsert(item);
        }
    }
    // .. 이하 생략
}

 

// 특가 요금 정보 저장
// for 문 내부에 if 문을 별도의 메서드로 분리한 상황
public void settingSpecialChargeInfo(Object args....) {
	
    // 요청받은 특가 요금 정보를 순회하는건 해당 메서드가 책임지고
    for (SpecialChargeInfo item : specialChargeInfoList) {

        item.setCompanyId(companyId);
        item.setGroupId(groupId);
        item.setChargeSectionId(chargeSectionId);

        // DB 에 데이터 반영하는 책임을 다른 메서드에서 수행하도록!
        saveSpecialChargeInfo(item);
    }
}

private void saveSpecialChargeInfo(SpecialChargeInfo item) {
    if (StringUtils.isNotBlank(item.getEventType())) {
        mapper.specialChargeInfoUpdate(item);
    } else {
        item.setEventType(eventType);
        mapper.specialChargeInfoInsert(item);
    }
}

 

2) else 예약어를 사용하지 않는다.

  • if - else 가 무분별하게 많아지면, 케이스에 따른 로직을 한 눈에 보기 어렵습니다.
  • 이런 경우, else (혹은 else-if) 조건에서 early return 으로 처리하여 코드의 가독성을 높일 수 있습니다.
// if - else 구조로 된 요금 계산 메서드
public int calculateAmount(ChargeCalType type) {
    int amount = 0;

    if(type.isTime()) { // 요금 계산 방식이 시간 기준인 경우
        amount = calculateForTime();
    } else if(type.isDistance()) { // 요금 계산 방식이 거리 기준인 경우
        amount = calculateForDistance();
    } else { // 그 외 정의되지 않은 방식인 경우
        throw new RuntimeException("정의되지 않은 방식이라 계산할 수 없습니다!");
    }

    return amount;
}
// early return 으로 처리된 요금 계산 메서드
public int calculateAmount(ChargeCalType type) {
		
    // 요금 계산 방식이 시간 기준인 경우
    if(type.isTime()) { 
        return calculateForTime();
    } 

    // 요금 계산 방식이 거리 기준인 경우
    if(type.isDistance()) { 
        return calculateForDistance();
    }

    // 그 외 정의되지 않은 방식인 경우
    throw new RuntimeException("정의되지 않은 방식이라 계산할 수 없습니다!");
}

 

3) 원시값을 포장한다.

  • int / double / String 같은 단일 데이터 타입을 class 로 포장하여, 데이터에 의미를 부여할 수 있습니다.
// User.java 내부에 String 으로 된 연락처 정보 + 연락처 validation
public class User {
    private String name;
    private String phoneNumber;

    public User(String name, String phoneNumber) {
        if(phoneNumber 가 전화번호 형식이 아닌 경우) {
            throw new RuntimeException("전화번호 형식이 아닙니다!");
        }

        this.name = name;
        this.phoneNumber = phoneNumber;
    }
}
// String 을 감싼 PhoneNumber 를 생성해 validation 을 은닉한 구조
public class User {
    private String name;
    private PhoneNumber phoneNumber; // 전화번호 라는 의미 부여까지 확실하게!

    public User(String name, PhoneNumber phoneNumber) {
        this.name = name;
        this.phoneNumber = phoneNumber;
    }
}

// String 타입 필드 1개를 가진 PhoneNumber class
public class PhoneNumber {
    private String value;

    public PhoneNumber(String value) {
        if(value 가 전화번호 형식이 아닌 경우) {
            throw new RuntimeException("전화번호 형식이 아닙니다!");
        }

        this.value = value;
    }
}

 

 

4) 한 줄에 점은 한 번만 찍는다.

  • 객체 그래프를 따라 계속해서 메서드를 호출하게 되면, 외부에 객체 간의 관계를 노출하게 됩니다. (정보 은닉이 안 된다는 의미)
  • 이 경우, 최상위 객체 내부로 로직을 위임시켜 캡슐화를 준수하며 객체 간의 관계를 숨길 수 있습니다.
public String getRentModelName(Reservation reservation) {
    // Reservation -> Car -> Model 로 이어지는 객체 그래프를 따라 호출
    // 세 객체 간의 결합도를 확인할 수 있는 형태
    return reservation.getCar().getModel().getName();
}
public String getReservationMemberName(Reservation reservation) {
    // Reservation 이 알아서 model 명을 가져오게 역할을 위임
    // 해당 메서드에서는 Reservation -> Car -> Model 의 관계를 알 필요가 없음
    return reservation.getRentModelName();
}

 

 

5) 이름을 줄여쓰지 않는다.

  • 애매하게 줄여쓰는 이름은 코드를 읽는 사람을 매우 힘들게 합니다.
  • 줄여쓰게 된다면 의미를 명확하게 전달할 수 있는 수준으로 줄여쓰는 것이 중요합니다.
// kName 과 eName 의 의미는?
public class User {
    private String kName;
    private String eName;
}
// 한국이름과 영어이름을 field 로 가지는 User class
public class User {
    private String koreanName;
    private String englishName;
}

 

 

6) 모든 엔티티를 작게 유지한다.

  • 하나의 엔티티가 너무 많은 정보를 담고 있으면, 해당 엔티티의 의미가 모호해질 수 있습니다.
  • 클래스 1개당 50 줄을 넘지 않고, 패키지 1곳 당 10개 미만의 파일을 유지하는 것을 권장합니다. (실무에선 지키기 힘들지도?)
  • 단일 책임 원칙(SRP)과 연관된 원칙으로, 하나의 엔티티가 커지는 경우 “하나 이상의 책임을 지고 있는가” 라는 의문을 가져봐야 합니다.
public class Company {
    private Long id; // line 1
    //... 45라인 추가
    private Bank bank;                                   // line 47
    private String account;                              // line 48
    private String accountHolder;                        // line 49 
    private BalanceAccountsPeriod balanceAccountsPeriod; // line 50
    private BigDecimal balanceAccountsRate;              // line 51
}
public class Company {
    private Long id; // line 1
    //... 45라인 추가
    private CompanyAccount companyAccount;               // line 47
}

public class CompanyAccount {
    private Bank bank;                                   // line 1
    private String account;                              // line 2
    private String accountHolder;                        // line 3 
    private BalanceAccountsPeriod balanceAccountsPeriod; // line 4
    private BigDecimal balanceAccountsRate;              // line 5
}

 

 

7) 3개 이상의 인스턴스 변수를 가지지 않도록 클래스를 구현한다.

  • 덩치가 큰(?) 객체일수록 코드를 이해하기 어려워지고, 유지보수 또한 쉽지 않습니다.
  • 하나의 큰 객체를 작은 여러 개의 객체 간의 협력관계로 풀어나가면 코드를 이해하기 쉬워지고, 자연스럽게 필드의 수가 줄어들게 됩니다.
// 3개 이상의 인스턴스 변수를 필드로 가지는 Company
public class Company{
    private Long id;
    private String name;
    private String tel;
    private String ceoName;
    private String address;
    private String addressDetail;
    private String zipcode;
}
// 2개 이하의 인스턴스 변수로 구성된 객체 간의 관계로 표현한 Company
public class Company{
    private Long id;
    private BaseInfo baseInfo;
}

public class BaseInfo {
    private String name;
    private AdditionalInfo additionalInfo;
}

public class AdditionalInfo {
    private Contact contact;
    private Location location;
}

public class Contact {
    private String tel;
    private String ceoName;
}

public class Location {
    private Address address;
    private String zipcode;
}

public class Address {
    private String main;
    private String detail;	
}

 

 

8) 일급 컬렉션을 사용한다.

  • 일급 컬렉션 이란, Collection 타입을 감싼 class 로, 해당 class 는 Collection 타입을 가지는 1개의 필드만 가지게 됩니다.
  • 일급 컬렉션을 사용하면 Collection 의 메서드를 추상화하거나, 의미있는 비지니스 메서드를 구현하기 쉽습니다.
public class Reservation {
    private List<ReservationExtension> extensions;

    // 실제 이용 완료 시간을 구하는 메서드
    // Reservation 에서 extensions 가 List인 것을 활용해 원하는 결과를 조회
    public LocalDateTime getFinalEndDtm() {
        return this.extensions.stream()
                    .map(ReservationExtension::getEndDtm)
                    .max(LocalDateTime::compareTo)
                    .orElse(this.enrollEndDtm);
    }
}
public class Reservation {
    private ReservationExtensions extensions;

    // Reservation 에서 ReservationExtensions 객체에 이용 완료 시간 조회 로직을 위임
    // ReservatinoExtensions 가 어떤 형태로 이루어져 있는지 알 필요가 없다.
    public LocalDateTime getFinalEndDtm() {
        return this.extensions.getFinalEndDtm();
    }
}

public class ReservationExtensions {
    private List<ReservationExtension> extension;

    // 실제 이용 완료 시간을 구하는 메서드
    public LocalDateTime getFinalEndDtm() {
        return this.extensions.stream()
                    .map(ReservationExtension::getEndDtm)
                    .max(LocalDateTime::compareTo)
                    .orElse(this.enrollEndDtm);
    }
}

 

 

9) getter/setter 를 사용하지 않는다.

  • getter / setter 가 필요한 로직인 경우, 객체의 상태를 getter / setter 를 통해 외부에서 제어하지 않고 객체에게 그 로직을 위임해야 하다는 의미입니다.
public class MemberService {
    public void lock(Member member) {
        // member 내 loginFailCount를 외부(Service)로 꺼내서 검사
        if(member.getLoginFailCount() > 5) {

            // member 내 setter 를 직접 호출
            member.setStatus(LOCK);
        }
    }
}
public class MemberService {
    public void lock(Member member) {
        // loginFailCount 초과 여부를 member 에서 판단하도록 위임
        if(member.isLoginFailCountOver()) {

            // 계정 상태 변경을 setter 가 아닌 의미있는 이름의 메서드로 처리
            member.lock();
        }
    }
}

3. 마치며

  • 개인적으로 실무에서 개발하면서 앞선 9가지 원칙을 모두 지키는 것은 거의 불가능하다고 생각합니다.
  • 해당 원칙이 개발하면서 무조건 지켜야 하는 절대적인 원칙도 아니고, 못 지켰다고 해서 나쁜 코드라고 평가할 순 없다고 생각합니다.
  • 단지, 객체지향 언어를 다루면서 “내가 정말 객체지향적으로 구현을 하고 있는가?” 라는 고민에 대한 답을 내리는데 어느정도 도움이 되는 지표 정도로 받아들이는 것이 좋다고 생각하고, "이런 원칙이 있구나." 정도로 인지하고 적용할 수 있는 부분을 적절히 적용하는 방향으로 접근하는 것이 좀 더 바람직하지 않나 싶습니다.

'Java' 카테고리의 다른 글

[Java 14~] record 란? (feat. Lombok)  (0) 2023.10.24

댓글