Skip to content

Latest commit

 

History

History
585 lines (443 loc) · 26.3 KB

README.md

File metadata and controls

585 lines (443 loc) · 26.3 KB

목차

캡슐화의 정의

캡슐화는 정보은닉을 통해 높은 응집도와 낮은 결합도를 갖도록 한다. 정보 은닉이란 말 그대로 알 필요가 없는 정보는 외부에서 접근하지 못하도록 제한하는 것이다.

여기서 중요한 키워드는 높은 응집도, 낮은 결합도, 정보 은닉 입니다. 핵심 키워들 기반으로 설명을 진행하겠습니다.

정보 은닉(information hiding) 의미 오류에 대해서 gyuwon님이 친절하게 코멘트 달아주신 부분도 참고하시면 좋을거 같습니다.

객체지향 개념에서 캡슐화는 정말 중요한 개념이라고 생각합니다. 캡슐화를 잘 지켜야 클래스 간의 결합도를 낮추어 코드를 유지 보수하기 쉽게 합니다. 이미 수많은 책이 이 개념에 관해서 설명하고 있습니다. 글을 읽을 때는 이해되지만 정작 캡슐화 좋은 코드를 작성하는 것은 또 다른 영역입니다.

저와 같은 주니어분들이 조금이라도 이해를 돕기 위해 제가 생각하는 캡슐화에 대해서 실무에서 많이 사용하는 Spring Boot, JPA 기반에서 설명해볼까 합니다.

내가 생각하는 캡슐화란 (1)...

요구사항

  • 주문을 신청할 때 배송 출발시 받을 메시지 플랫폼을 N개 선택 할 수 있다.
  • 메시지 플랫폼은 KAKAO, SMS, EMAIL 등이 있다.
  • 메시지 플랫폼은 지속적으로 추가 될 수 있다.

캡슐화가 안좋은 안티 패턴

@Entity
@Table(name = "product")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long id;

    @Column(name = "anti_message_types")
    private String messageTypes;

    @Builder
    public Order(String messageTypes) {
        this.messageTypes = messageTypes;
    }
}
  • Order 엔티티 객체가 있습니다.
  • Order 엔티티는 주문을 완료시 메시지 플랫폼을 받을 수 있는 메시지 타입을 저장할 messageTypes 멤버 필드를 갖습니다.
    • 데이터베이스 정규화는 생략했습니다. 저런 타입일 경우 정규화의 대상이 된다고 생각하지 않습니다. 이 부분은 크게 생각 안하시고 순수하게 캡슐화의 관점에서 설명드리겠습니다.
  • 복수개의 문자열이 들어 오기 때문에 "," 기반으로 문자열을 split 합니다.

테스트 코드

public class OrderUnitTest {

    @Test
    // (1) Order의 getMessageTypes 메서드를 사용 할 때 불편하다
    // 안좋은 캡슐화
    public void anti_message_test_01() {
        final Order order = build("KAKAO,EMAIL,SMS");
        final String[] split = order.getMessageTypes().split(",");

        assertThat(split, hasItemInArray("KAKAO"));
        assertThat(split, hasItemInArray("EMAIL"));
        assertThat(split, hasItemInArray("SMS"));
    }

    @Test
    // (2) KAKAO를 KAOKO 라고 잘못 입력했을 경우
    public void anti_message_test_02() {
        final Order order = build("KAOKO,EMAIL,SMS");
        final String[] split = order.getMessageTypes().split(",");

        assertThat(split, not(hasItemInArray("KAKAO")));
        assertThat(split, hasItemInArray("EMAIL"));
        assertThat(split, hasItemInArray("SMS"));
    }

    @Test
    // (3) 메시지에 KAKAO, EMAIL, SMS 처럼 공백이 들어 간다면 실패한다
    public void anti_message_test_03() {
        final Order order = build("KAKAO, EMAIL, SMS");
        final String[] split = order.getMessageTypes().split(",");

        assertThat(split, hasItemInArray("KAKAO"));
        assertThat(split, not(hasItemInArray("EMAIL")));
        assertThat(split, not(hasItemInArray("SMS")));
    }


    @Test
    // (4) 메시지가 없을 때 빈문자열("")을 보낼 경우
    public void anti_message_test_04() {
        final Order order = build("");
        final String[] split = order.getMessageTypes().split(",");

        assertThat(split, hasItemInArray(""));
    }


    @Test(expected = NullPointerException.class)
    // (5) 메시지가 없을 때 null 을 보낼 경우
    public void anti_message_test_05() {
        final Order order = build(null);
        order.getMessageTypes().split(",");
    }

    @Test
    // (6) 메시지가 중복으로 올경우
    public void anti_message_test_06() {
        final Order order = build("KAKAO, KAKAO, KAKAO");
        final String[] split = order.getMessageTypes().split(",");

        assertThat(split, hasItemInArray("KAKAO"));
        assertThat(split.length, is(3));
    }
}

(1) Order의 getMessageTypes 메서드를 사용 할 때 불편하다

가장 쉽게 생각할 수 있는 방법입니다. 단순히 getter 메서드를 이용해서 외부 객체가 사용하게 제공합니다. 이는 캡슐화에 엄청난 악영향을 미치게 됩니다.

우선 getMessageTypes() 메서드를 사용 하는 모든 곳에서 split() 메서드를 이용해서 메시지 타입을 배열로 만들어서 사용해야합니다.

(2) KAKAO를 KAOKO 라고 잘못 입력했을 경우

단순하게 String을 사용하기 때문에 type safe 하지 않습니다. KAKAO를 KAOKO로 잘못 입력해도 제대로 검증하기 어려우며 검증하는 로직을 추가 하더라도 해당 에러는 Runtime으로 넘어가게 됩니다. 이처럼 단순 문자열이면 이러한 단점을 갖게 됩니다.

(3) 메시지에 KAKAO, EMAIL, SMS 처럼 공백이 들어 간다면 실패한다

일반적으로 웹 개발을 하면 대부분의 요청은 컨트롤러에서 받게 됩니다. 이 때 KAKAO, EMAIL, SMS 문자열 사이에 빈 공백이 들어오게 되면 split() 함수가 제대로 동작하지 않습니다. 테스트 코드를 보면 EMAIL, SMSnot(hasItemInArray..) 으로 검증됩니다.

물론 앞뒤 공백을 자르는 로직이 추가되면 되지만 이렇게 되면 점점 로직의 복잡도가 높아지게 됩니다.

(4) 메시지가 없을 때 빈문자열("")을 보낼 경우

컨트롤러 요청을 받을 때 받을 메시지 플랫폼이 없다면 "" 으로 받게 됩니다. 이 빈 공백 이라는 것이 의미하는 게 어떤 메시지 플랫폼도 선택하지 않았다는 의미로 해석되기는 어렵습니다. 문자열 자체는 이러한 타입에 적합하지 않기 때문에 메시지가 없을 때 어떤 식으로 처리해야 할지 고민하게 됩니다.

(5) 메시지가 없을 때 null 을 보낼 경우

빈 공백의 의미가 정확하지 않다고 생각했을 경우 null로 요청을 받게 되면 split() 메서드에서 RuntimeException이 발생하게 됩니다. 물론 로직을 추가해서 null인 경우를 처리할 수 있지만, 이것은 3번에서 언급했던 것처럼 계속 코드의 복잡성이 높아지게 됩니다.

(6) 메시지가 중복으로 올경우

메시지 플랫폼이 중복으로 넘어오게 될 실제 메시지가 중복으로 발송되기 때문에 로직을 추가 해야 됩니다.

테스트 코드의 중요성

캡슐화에서 벗어난 주제이기 때문에 테스트 코드에 대한 부가적인 설명해 드리는 것이 조금은 어색하지만, 테스트 코드의 중요성을 한번 언급하고 싶었습니다.

문제 있는 코드를 빨리 파악할 수 있다.

해당 기능의 테스트 코드를 작성하면 이 코드의 문제점을 가장 빠르게 파악할 수 있습니다. 저는 코드가 왜 캡슐화에 안 좋은 코드인지 테스트 코드를 통해서 알게 됐습니다. 위에서 언급한 1~5 문제들을 테스트 코드 작성 시 파악했고 리팩토링 작업을 진행했습니다.

테스트 코드는 냄세나는 코드를 빠르게 찾게 해줍니다. 이것도 테스트 코드의 엄청난 장점이라고 생각합니다.

좋은 캡슐화 패턴

public class Order {
    ...
    @Embedded
    private Message message;
}

@Embeddable
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Message {

    @Column(name = "message_type")
    private String type;

    private Message(String type) {
        this.type = StringUtils.isEmpty(type) ? null : type;
    }

    public static Message of(Set<MessageType> types) {
        return new Message(joining(types));
    }

    public List<MessageType> getTypes() {
        if (StringUtils.isEmpty(type)) {
            return new ArrayList<>();
        }

        return new ArrayList<>(doSplit());
    }

    private static String joining(Set<MessageType> types) {
        return types.stream()
                .map(Enum::name)
                .collect(Collectors.joining(","));
    }


    private Set<MessageType> doSplit() {
        final String[] split = this.type.split(",");
        return Arrays.stream(split)
                .map(MessageType::valueOf)
                .collect(Collectors.toSet());
    }
}

public enum MessageType {
    EMAIL, SMS, KAKAO;
}

Message of(Set types)

기본 생성자를 private 메서드로 지정했기 때문에 외부에서 해당 객체를 생성 할 수 있는 유일한 방법은 of() 메서드를 이용하는 방법뿐입니다.

이처럼 객체 생성도 최대한 제한해서 객체를 올바르게 생성할 수 있도록 제공해야 합니다. 이로써 좋은 캡슐화가 진행되고 있습니다.

joining(types) 메서드를 통해서 넘겨받은 Set 자료형 types를 String 객체로 변경했습니다. 실제 데이터베이스에 문자열 자료형으로 저장하게 됩니다.

public List getTypes()

위에서 언급한 정보 은닉 개념입니다.

정보 은닉이란 말 그대로 알 필요가 없는 정보는 외부에서 접근하지 못하도록 제한하는 것이다.

여기서 말하는 알 필요 없는 정보는 실제 데이터베이스에는 메시지 타입이 "," 기준으로 메시지 타입을 구분하고 있다는 점입니다.

getTypes 리턴 타입은 List이기 때문에 외부에서는 절대 데이터베이스에 저장돼 있는 평문 문자 "KAKAO,SMS,EAML" 문자열을 접근할 수 없습니다.

그것보다 더 중요한 건 데이터베이스에 어떤 형식으로 저장돼있는지 세부적인 것들은 관심 대상이 아니게 됩니다.

내가 필요할 때 메시지 타입을 편하게 List 형식으로 가져다 사용하면 됩니다. 이것이 캡슐화라고 생각합니다.

테스트 코드

public class MessageTest {

    @Test
    public void 메시지_타입이_EMAIL_KAKAO_SMS_일경우() {
        final Set<MessageType> types = new HashSet<>();
        types.add(MessageType.EMAIL);
        types.add(MessageType.KAKAO);
        types.add(MessageType.SMS);

        final Message message = Message.of(types);

        assertThat(message.getTypes(), hasItem(MessageType.EMAIL));
        assertThat(message.getTypes(), hasItem(MessageType.KAKAO));
        assertThat(message.getTypes(), hasItem(MessageType.KAKAO));
        assertThat(message.getTypes(), hasItem(MessageType.SMS));
        assertThat(message.getTypes(), hasSize(3));
    }

    @Test
    public void 메시지_타입이_EMAIL_KAKAO일경우() {
        final Set<MessageType> types = new HashSet<>();
        types.add(MessageType.EMAIL);
        types.add(MessageType.KAKAO);

        final Message message = Message.of(types);

        assertThat(message.getTypes(), hasItem(MessageType.EMAIL));
        assertThat(message.getTypes(), hasItem(MessageType.KAKAO));
        assertThat(message.getTypes(), not(hasItem(MessageType.SMS)));
        assertThat(message.getTypes(), hasSize(2));
    }

    @Test
    public void 메시지_타입이_없을경우() {
        final Set<MessageType> types = Collections.emptySet();
        final Message message = Message.of(types);

        assertThat(message.getTypes(), hasSize(0));
    }

    @Test
    public void 메시지_타입이_중복되는경우() {
        final Set<MessageType> types = new HashSet<>();
        types.add(MessageType.EMAIL);
        types.add(MessageType.EMAIL);
        types.add(MessageType.EMAIL);

        final Message message = Message.of(types);

        assertThat(message.getTypes(), hasItem(MessageType.EMAIL));
        assertThat(message.getTypes(), not(hasItem(MessageType.SMS)));
        assertThat(message.getTypes(), not(hasItem(MessageType.KAKAO)));
        assertThat(message.getTypes(), hasSize(1));
    }
}

테스트 코드를 보시면 항상 올바른 데이터만 입력할 수 있고, 그것을 검증하는 것도 단순해졌고 이해하고 예측하기 쉬워졌습니다. 이런 것이 좋은 캡슐화라고 생각합니다.

문제 해결

  • (1) Order의 getMessageTypes 메서드를 사용 할 때 불편하다
    • 사용하는 곳에서 리스트를 만드는 것아 이나리 getTypes()의 리턴자료형이 List이기 때문에 사용하기 편합니다.
  • (2) KAKAO를 KAOKO 라고 잘못 입력했을 경우
    • enum 자료형을 사용했기 때문에 유효하지 않은 데이터를 입력할 수 없습니다.
  • (3) 메시지에 KAKAO, EMAIL, SMS처럼 공백이 들어간다면 실패한다
    • enum 자료형을 사용했기 때문에 공백처럼 유효하지 않은 데이터를 입력할 수 없습니다.
  • (4) 메시지가 없을 때 빈 문자열("")을 보내면
    • enum 자료형을 사용했기 때문에 공백처럼 유효하지 않은 데이터를 입력할 수 없습니다.
  • (5) 메시지가 없을 때 null을 보낼 경우
    • 비어 있을 경우 null 아 아닌 빈 Set 객체를 넘기면 되기 때문에 null 자체를 입력하게 될 이유가 없어졌습니다.
  • (6) 메시지가 중복으로올 경우
    • 자료형이기 때문에 데이터가 중복으로 입력되더라도 최종적으로는 중복을 제거하게 됩니다.

캡슐화를 통한 장점들

  • 위에서 작성한 코드 구조상 유효하지 않은 메시지 타입을 강제로 입력하고 싶더라도 어렵습니다.
  • 실제 데이터베이스에 입력돼 있는 문자열을 가져올 수도 없으며, 어떤 형식으로 저장돼 있는지 관심을 가질 필요가 없게 되었습니다.
  • 메시지 타입이 없는 경우 컬렉션이 empty이기 때문에 보다 명확합니다.
  • 응집도가 높아졌습니다. 메시지에 대한 세부 로직들이 Order에서 분리되고 Message 객체에 응집해 있습니다.
  • 재사용성이 높아졌습니다. 만약 상품 등록이 성공했을 경우 메시지 플랫폼을 통해서 응답받고 싶다면 Product에서 Message 객체를 선언하기만 하면 됩니다.

웹 환경

웹 환경에서 추가적으로 설명드리겠습니다.

Controller

@RestController
@RequestMapping("/orders")
public class OrderApi {

    private final OrderRepository orderRepository;

    @PostMapping
    public Order create(@RequestBody @Valid OrderRequest request) {
        final Order order = buildOrder(request);
        return orderRepository.save(order);
    }
}

@Getter
public class OrderRequest {
    @NotNull
    private Set<MessageType> messageType;
}

Request

요청은 배열으로 받게 합니다. 만약 받을 메시지가 없다면 빈 배열로 넘깁니다.

테스트 코드

@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
public class OrderApiTest {
    @Autowired
    private ResourceLoader resourceLoader;

    @Autowired
    private MockMvc mvc;
    
    @Test
    public void 정상요청() throws Exception {
        final String json = readJson("valid-request.json");
        mvc.perform(post("/orders")
                .contentType(MediaType.APPLICATION_JSON_UTF8)
                .content(json))
                .andDo(print())
                .andExpect(status().isOk());
    }

    @Test
    public void 요청바디가_유효하지않을경우() throws Exception {
        final String json = readJson("invalid-request.json");
        mvc.perform(post("/orders")
                .contentType(MediaType.APPLICATION_JSON_UTF8)
                .content(json))
                .andDo(print())
                .andExpect(status().isBadRequest());
    }
}

정상요청

{
  "messageType": [
    "EMAIL", "SMS"
  ]
}

모든 값이 유효합니다. 200을 응답 받습니다.

요청바디가_유효하지않을경우

{
  "messageType": [
    "EMAIL", "KKA"
  ]
}

유효하지 않은 값일 경우 400 응답을 받게됩니다.

값 확인

배열 형식의 받을 입력 받고 응답해주지만, 실제 값은 ","으로 구분하는 문자열입니다.

다시 한번 강조하지만 외부 객체에서는 저 문자열을 가져올 수 없을 뿐만 아니라 실제 데이터베이스에 문자열로 저장돼있는지 관심조차 가질 필요가 없습니다. getTypes() 메서드로 List형으로 외부에 제공해주기만 하면 됩니다. 이것이 캡슐화의 기본적 개념이라고 생각합니다.

내가 생각하는 캡슐화란 (2)...

요구사항

  • 쿠폰을 통해 할인을 받을 수 있다.
  • 쿠폰의 종류는 다양하고 지속해서 추가될 예정이다.
    • 현재는 첫 구매 시 할인해 주는 쿠폰이 있다.

묻지 말고 시켜라

메시지를 먼저 결졍하고 객체가 메시지를 따르게 하는 설계 방식은 객체가 외부에 제공하는 인터페이스가 독특한 스타일을 따르게 한다. 이 스타일을 묻지 말고 시켜라 Tell, Don't

송신자는 수신자가 어떤 객체 인지 모르기 때문에 객체에 관해 꼬치꼬치 캐물을 수 없다. 단지 송신자는 수신자가 어떤 객체인지는 모르지만 자신이 전송한 메시지를 잘 처리할 것이라는 것을 믿고 메시지를 전송할 수 밖에 없다.

이런 스타일의 협력 패턴은 묻지 말고 시켜라라는 이름으로 널리 알려져 있다. 이 스타일은 객체지향 애플리케이션이 자율적인 객체들의 공동 체라는 사실을 강조한다. 어떤 객체가 존재하는지도 모르는데 어떻게 객체의 내부 상태를 가정할 수 있겠는가 ?

객체는 다른 객체의 상태를 묻지 말아야 한다. 객체가 다른 객체의 상태를 묻는 다는 것은 메시지를 전송하기 이전에 객체가 가져야 하는 상태에 관해 너무 많이 고민 하고 있다는 증거다. 고민을 연기하라 단지 필요한 메시지를 전송하기만 하고 메시지를 수신하는 객체가 스스로 메시지의 처리 방법을 결정하게 하라.

쿠폰 객체

@Entity
@Table(name = "coupon")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Coupon {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long id;

    @Column(name = "used", nullable = false)
    private boolean used;

    @Column(name = "amount", nullable = false, updatable = false)
    private double amount;

    @Column(name = "expiration_date", nullable = false, updatable = false)
    private LocalDate expirationDate;

    @Builder
    public Coupon(double amount, LocalDate expirationDate) {
        this.amount = amount;
        this.expirationDate = expirationDate;
        this.used = false;
    }

    public boolean isExpiration() {
        return LocalDate.now().isAfter(expirationDate);
    }

    public void apply() {
        verifyCouponIsAvailable();
        this.used = true;
    }

    private void verifyCouponIsAvailable() {
        verifyExpiration();
        verifyUsed();
    }

    private void verifyUsed() {
        if (used) {
            throw new IllegalStateException("이미 사용한 쿠폰입니다.");
        }
    }

    private void verifyExpiration() {
        if (LocalDate.now().isAfter(getExpirationDate())) {
            throw new IllegalStateException("사용 기간이 만료된 쿠폰입니다.");
        }
    }
}

쿠폰이 사용 가능한지 해당 객체에서 관리하고 있습니다. 이 처럼 해당 객체가 본인의 상태를 결정하고 그 행동까지 실행 가능한지 아닌지를 객체 스스로가 알고 있습니다.

서비스 계층

@Service
@Transactional
public class FirstOrderCoupon implements CouponIssueAble {

    private final CouponRepository couponRepository;

    public FirstOrderCoupon(CouponRepository couponRepository) {
        this.couponRepository = couponRepository;
    }

    @Override
    public boolean canIssued() {
        // TODO: 첫 구매인지 확인 하는 로직 ...
        return true;
    }

    /**
     * 안티 패턴
     *
     * 꼬치꼬치 캐묻고 있습니다.
     */
    public void antiApply(final long couponId) {
        final Coupon coupon = couponRepository.findById(couponId).get();

        if (LocalDate.now().isAfter(coupon.getExpirationDate())) {
            throw new IllegalStateException("사용 기간이 만료된 쿠폰입니다.");
        }

        if (coupon.isUsed()) {
            throw new IllegalStateException("이미 사용한 쿠폰입니다.");
        }

        if (canIssued()) {
           coupon.setUsed(false);
        }
    }

    /**
     * 좋은 패턴
     *
     * 묻지 말고 시켜라. 쿠폰 객체의 apply() 메서드를 통해서 묻지 말고 쿠폰을 적용하고 있습니다.
     */
    public void apply(final long couponId) {
        if (canIssued()) {
            final Coupon coupon = couponRepository.findById(couponId).get();
            coupon.apply();
        }
    }    
}

안티 패턴

안티 패턴의 경우 꼬치꼬치 캐묻고 있습니다. 쿠폰 사용 기간이 만료되었는지, 이미 사용한 쿠폰인지 예제 코드는 이 정도로 단순하지만, 실제 실무에서는 이보다 더 많은 것들을 확인해야 합니다. 이 모든 세부적인 것들을 알고 확인하는 코드를 작성해야지 비로소 쿠폰 적용 코드를 완성할 수 있습니다.

더 중요한 것은 중복 코드입니다. 지금은 첫 구매에 대해서 할인 쿠폰 적용 로직이지만 앞으로 추가될 때마다 해당 로직이 중복으로 추가될 수밖에 없습니다. 해결 방법은 간단합니다. 객체의 상태를 스스로가 판단하고 결정할 수 있게 설계하는 것입니다.

좋은 패턴

coupon.apply() 메서드를 통해서 묻지 말고 시키고 있습니다. 쿠폰이 만료되었는지, 사용 여부 등을 묻지 않고 그냥 쿠폰을 적용하라고 지시만 하고 있습니다.

만약 새로운 쿠폰이 생기더라도 해당 쿠폰의 고유한 발급 조건을 CouponIssueAble 인터페이스를 상속해서 canIssued() 메서드를 적절하게 구현하고 apply() 메서드 호출을 통해서 쿠폰을 적용하면 됩니다. 물론 Production 레벨의 코드는 더 복잡하겠지만 전체적인 구조는 이해하기 쉬운 구조를 갖게 된다고 생각합니다.