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 |
---|
댓글