저작권 안내: 저작권자표시 Yes 상업적이용 No 컨텐츠변경 No

스프링4 입문

스프링 4

DDD Start

객체 지향과
디자인 패턴

JSP 2.3

JPA 입문

기존 코드의 중복 코드: 흐름 제어 중복


최근에 정리한 코드는 다음과 같은 형태를 가졌다.


private ErrorLogger errorLogger;


public Response chooseMeeting(Request req) {

    String apiId = "E0004";

    String code = null;

    String jupno = null;

    String transt = null;

    try {

        jupno = request.getJupno();

        changeTranst(jupno, transt = "0", req.getId(), req.getIp()); // 전송전 상태

        Result result = tranService.chooseMeeting(req); // 전송 처리

        code = eocsResult.getCode();

        changeTranst(jupno, transt = "1", req.getId(), req.getIp()); // 전송완료 상태

    }catch (EocsException ex) {

        transt(jupno, transt = "2", req.getId(), request.getIp()); // 전송오류 상태

        code = ex.getEocsErrorCode();

        errorLogger.append(apiId, code, jupno, transt, req, req.getId(), req.getIp(),....);

    }catch (Exception e) {

        code = "C00003";

        errorLogger.append(apiId, code, jupno, transt, req, req.getId(), req.getIp()....);

    }

    return new Response(apiId , code);

}


private changeTranst(String jupno, String transt, String id, String ip) {

    ...

}


chooseMeeting() 메서드는 다음 흐름을 갖는다.

  1. 전송전 상태 변경: changeTranst()
  2. 실제 전송 업무 처리: tranService.chooseMeeting()
  3. 전송완료 상태 변경: changeTranst()
    1. 전송오류 발생시 전송오류 상태 변경 후 로그 기록
    2. 이 외 익셉션 로그 기록
  4. 결과 리턴

chooseMeeting() 메서드만 이런 코드를 가진 게 아니다. 이와 동일한 흐름을 갖는 메서드가 3개가 더 있었다. 차이점은 apiId와 전송 처리 코드뿐이었다. 예를 들어, 다른 코드는 아래와 같다.


public Response choosePip(PipRequest req) {

    String apiId = "E0003";

    String code = null;

    String jupno = null;

    String transt = null;

    try {

        jupno = request.getJupno();

        changeTranst(jupno, transt = "0", req.getId(), req.getIp()); // 전송전 상태

        Result result = tranService.choosePip(req); // 전송 처리

        code = eocsResult.getCode();

        changeTranst(jupno, transt = "1", req.getId(), req.getIp()); // 전송완료 상태

    }catch (EocsException ex) {

        transt(jupno, transt = "2", req.getId(), request.getIp()); // 전송오류 상태

        code = ex.getEocsErrorCode();

        errorLogger.append(apiId, code, jupno, transt, req, req.getId(), req.getIp(),....);

    }catch (Exception e) {

        code = "C00003";

        errorLogger.append(apiId, code, jupno, transt, req, req.getId(), req.getIp()....);

    }

    return new Response(apiId , code);

}



메서드 추출로 하려다가...


처음엔 대충 다음과 같은 모양을 상상하면서 메서도 추출로 가려했다.


public Response chooseMeeting(Request req) {

    String apiId = "E0004";

    return runTrans(apiId, req.getJupno(), req.getId(), req.getIp(), 

                         () -> tranService.chooseMeeting(req));

}


public Response choosePip(PipRequest req) {

    String apiId = "E0003";

    return runTrans(apiId, req.getJupno(), req.getId(), req.getIp(), 

                         () -> tranService.choosePip(req));

}


private Response runTrans(String apiId, 

                          String jupno, String id, String ip, // 상태처리나 로그 기록에 필요

                          Supplier<Result> transition) {

    String code = null;

    String transt = null;

    try {

        jupno = request.getJupno();

        changeTranst(jupno, transt = "0", id, ip); // 전송전 상태

        Result result = transition.get(); // 전송 처리

        code = eocsResult.getCode();

        changeTranst(jupno, transt = "1", id, ip); // 전송완료 상태

    }catch (EocsException ex) {

        transt(jupno, transt = "2", id, ip); // 전송오류 상태

        code = ex.getEocsErrorCode();

        errorLogger.append(apiId, code, jupno, transt, id, ip,....);

    }catch (Exception e) {

        code = "C00003";

        errorLogger.append(apiId, code, jupno, transt, id, ip,....);

    }

    return new Response(apiId , code);

}


private changeTranst(String jupno, String transt, String id, String ip) {

    ...

}


runTrans() 메서드는 로그를 남기거나 상태 변경을 처리하는 코드에서 필요한 값을 파라미터로 받는데, 파라미터가 다소 많다. 로그를 남길 때 사용할 jupno, id, ip를 구하는 코드가 달라서, 이 세 개 값을 파라미터로 전달해야 했다.


이렇게 되면 코드를 추가로 정리할 때 번잡함이 발생한다. 예를 들어, runTrans()에서 changeTranst()에 의미를 더 부여하기 위해 다음과 같이 변경한다고 하자.


private Response runTrans(String apiId, String jupno, 

                                   String id, String ip, Object req, Supplier<Result> transition) {

    String code = null;

    String transt = null;

    try {

        jupno = request.getJupno();

        transt = beforeTranst(jupno, id, ip); // 전송전 상태

        Result result = transition.get(); // 전송 처리

        code = eocsResult.getCode();

        transt = afterTranst(jupno, id, ip); // 전송완료 상태

    }catch (EocsException ex) {

        errorTranst(jupno, id, ip); // 전송오류 상태

        code = ex.getEocsErrorCode();

        errorLogger.append(apiId, code, jupno, transt, req, id, ip,....);

    }catch (Exception e) {

        code = "C00003";

        errorLogger.append(apiId, code, jupno, transt, req, id, ip,....);

    }

    return new Response(apiId , code);

}


private String beforeTranst(String jupno, String id, String ip) {

    changeTranst(jupno, "1", id, ip);

    return "1";

}

...afterTranst()와 errorTranst()도 비슷하게 구현


private changeTranst(String jupno, String transt, String id, String ip) {

    ...

}


runTrans() 메서드는 에러 로그를 기록하는데 transt 로컬 변수를 사용한다. 그래서 beforeTranst() 메서드는 상태 변경 실행후 transt 로컬 변수에 할당할 값을 리턴해야 한다. 게다가 beforeTranst() 메서드는 changeTranst() 메서드를 호출하기 위해 필요한 값을 파라미터 3개로 받고 있다.


흐름 처리를 수행하는 클래스 작성


메서드 추출로 중복을 없앨 수는 있지만, 그 결과로 만들어진 코드가 이쁘지 않다. 로컬 변수나 파라미터 개수가 많기에 이를 별도 객체로 뽑아서 중복을 없애는 것으로 방법을 바꿨다. 먼저 다음과 같이 프로세스를 처리하는 클래스를 만들었다.


public class TransitionProcess {

    private ErrorLogger errorLogger;


    private String apiId;

    private String jupno;

    private String id;

    private String ip;


    private String transt;

    private String code;


    @Builder

    public TransitionProcess(

            ErrorLogger errorLogger, String apiId,

            String jupno, String id, String ip) {

        this.errorLogger = errorLogger;

        this.apiId = apiId;

        this.jupno = jupno;

        this.id = id;

        this.ip = ip;

    }

    

    public Response runWith(Supplier<Result> transition) {

        try {

            beforeTranst();

            Result result = transition.get();

            code = result.getCode();

            afterTranst();

        } catch (EocsClientException ex) {

            transtError();

            code = ex.getEocsErrorCode();

            appendErrorLog(ex);

        } catch (Exception ex) {

            code = "C00003";

            appendErrorLog(ex);

        }

        return new Response(apiId , code);

    }


    private void beforeTranst() {

        transt = "0";

        changeTranst();

    }


    ...


    private void changeTranst() {

        ... // apiId, jupno, code, transt 등 필요한 값이 필드에 존재

    }


    private void appendError(Exception ex) {

        errorLogger.append(apiId, code, jupno, transt, id, ip, ex);

    }

}


TransitionProcess 클래스는 생성자를 통해서 로그 기록이나 상태 변경에 필요한 값을 받는다. runWith() 메서드는 전송 처리 기능을 함수형 인터페이스로 전달받는다.


상태 변경을 수행하는데 필요한 값(jupno, id, ip, transt)이 필드에 존재하므로 changeTranst() 메서드는 파라미터가 필요없다. 동일하게 beforeTranst() 메서드나 afterTranst() 메서드도 파라미터가 필요 없다.


에러 로그를 남기기 위한 appendError()도 동일하게 에러 로그를 기록하는데 필요한 값 중 Exception을 제외한 나머지는 필드에 존재한다. 그래서 파라미터로 Exception만 받으면 된다.


사실, TransitionProcess의 beforeTrans(), afterTrans(), errorTrans(), appendError() 메서드는 처음부터 존재한 것이 아니고 클래스로 추출한 뒤에 리팩토링하는 과정에서 생긴 것이다. 파라미터로 필요한 값을 전달할 필요가 없기 때문에 코드를 정리하기 더 쉽고, 메서드 호출에 파라미터가 없으므로 코드도 덜 복잡하다.


결과


다음은 흐름 처리 객체를 이용햇서 변경한 코드이다.


public Response chooseMeeting(Request req) {

    String apiId = "E0004";

    TransitionProcess process = TransitionProcess.builder()

        .errorLogger(errorLogger)

        .apiId(apiId)

        .jupno(req.getJupno())

        .id(req.getId())

        .ip(req.getIp())

        .build();

    return process.runWith(() -> tranService.chooseMeeting(req));

}


public Response choosePip(PipRequest req) {

    String apiId = "E0003";

    TransitionProcess process = TransitionProcess.builder()

        .errorLogger(errorLogger)

        .apiId(apiId)

        .jupno(req.getJupno())

        .id(req.getId())

        .ip(req.getIp())

        .build();

    return process.runWith(() -> tranService.choosePip(req));

}


// changeTranst 메서드 제거. TransitionProcess로 이동.


개인적으로는 처음 메서드 추출로 중복을 제거하려고 했던 것보다 더 깔끔하고 프로세스 처리가 별도 클래스로 빠져서 프로세스 분석이나 수정이 용이해진 것 같다.

Posted by 최범균 madvirus

댓글을 달아 주세요

페이스북 친구들과 댓글을 공유하고 싶다면 아래를 이용해주세요.


2016-07-16 DDD Start 수다 세미나 발표 영상




Posted by 최범균 madvirus

댓글을 달아 주세요

페이스북 친구들과 댓글을 공유하고 싶다면 아래를 이용해주세요.

DDD Start 부록 모임 발표자료.




Posted by 최범균 madvirus

댓글을 달아 주세요

  1. MJC 2016.07.22 02:36 신고  댓글주소  수정/삭제  댓글쓰기

    안녕하세요.

    여기저기서 책 소개를 보고 구매를 고려중입니다.

    책 목차를 보니까 DDD 전체가 아닌 코딩 부분인 tactic 부분만 있는거 같은데요...
    Eric Evans가 자신의 책에서 tactic 부분을 앞부분 챕터에 배치한것이 가장 큰 실수라고 했고..그것을 보완하기 위해 Vaughn Vernon의 속칭 빨간책에서는 tactic 부분은 약간 뒤로 밀려놨습니다. 두 저자 모두가 DDD의 핵심은 strategy라고 계속 강조를 하는데 DDD Start에서는 strategy 부분이 아예 통째로 다 빠진거 같습니다.

    특별한 이유가 있나요? 슬라이드를 보면 또 아닌거 같은데..목차만 보면 그런 느낌이 듭니다.

    한글책이 나왔다는 소식에 읽어보려고 하는데 의아해서 질문 드립니다.

    • 최범균 madvirus 2016.07.25 17:51 신고  댓글주소  수정/삭제

      안녕하세요, MJC님.
      말씀하신 것처럼 이 책은 tactic 위주로 구성이 되어 있고 strategy에 해당하는 내용은 9장에 갼락하게 나와 있습니다.

      이렇게 구성한 이유는 이 책이 DDD에 입문하는데 도움을 주는 징검다리로 사용하기 위함입니다.

      evans나 vernon의 책이 매우 훌륭한 책이지만 그 책을 어려워하는 분들이 많았기에 입문자가 조금 쉽게 DDD에 접근할 수 있는 책이 있었으면 좋겠다고 생각했고, 그 어려워하는 것 중에 구현 관점에서 이 책을 구성하게 되었습니다.

      이를 통해 DDD에 관심을 유발하고, 이후에 더 깊게 빠지고 싶은 분들이 자연스럽게 저 책들로 연결되기를 바라고 있습니다.

      그러다보니 이미 DDD에 익숙하신 분들은 이 책보다는 evans의 책이나 다른 깊이 있는 책을 한번 더 읽는게 좋습니다.

  2. MJC 2016.07.26 04:10 신고  댓글주소  수정/삭제  댓글쓰기

    답변 감사드립니다.

    혹시 strategy쪽으로도 책을 내실 생각이 있으신가요?

    개인적으로 접근하기에는 아무래도 tatic위주면 코드로 빨리 시작할수있고 해서 좋은데..아무래도 DDD를 팀 혹은 회사 전체에 적용하려면 strategy중요한데...이 부분이 사실 쉽지가 않더라구요..경험이 많은 개발자들은 evans나 vernon의 책을 봐도 되겠지만 신입의 경우는 쉽지가 않더군요. 아무래도 한글이 있으면 많은 도움이 될거같습니다.

페이스북 친구들과 댓글을 공유하고 싶다면 아래를 이용해주세요.

신림 프로그래머 공개 모임 2016에서 발표한 MVP 패턴 소개 자료



Posted by 최범균 madvirus

댓글을 달아 주세요

페이스북 친구들과 댓글을 공유하고 싶다면 아래를 이용해주세요.

작년 11월 KSUG 세미나에서 발표한 영상입니다.





Posted by 최범균 madvirus

댓글을 달아 주세요

페이스북 친구들과 댓글을 공유하고 싶다면 아래를 이용해주세요.

애그리것의 메서드를 실행할 때 도메인 서비스 객체를 파라미터로 전달한다는 것은 애그리것이 도메인 서비스에 의존한다는 것을 뜻한다. 스프링의 DI와 AOP를 공부하다보면 애그리것이 의존하는 도메인 서비스를 의존 주입으로 처리하고 싶어질 수 있다. 관련 기술에 빠져 있으면 특히 그렇다. 프레임워크가 제공하는 의존 주입 기능을 사용해서 도메인 서비스를 애그리것에 주입해야 기술적으로 나은 것 같은 착각도 한다.


하지만, 이는 좋은 방법이 아니다. 의존 주입을 하기 위해 애그리것 루트 엔티티에 도메인 서비스에 대한 참조를 필드로 추가했다고 하자.


public class Order {

    @Autowired

    private DiscountCalculationService discountCalculationService;

    …

}


도메인 객체는 필드(프로퍼티)로 구성된 데이터와 메서드를 이용한 기능을 이용해서 개념적으로 하나인 모델을 표현한다. 모델의 데이터를 담는 필드는 모델에서 중요한 구성 요소이다. 그런데, discountCalculationService 필드는 데이터 자체와는 관련이 없다. Order 객체를 DB에 보관할 때 다른 필드와는 달리 저장 대상도 아니다.


또 Order가 제공하는 모든 기능에서 discountCalculationService를 필요로 하는 것도 아니다. 일부 기능만 필요로 한다. 일부 기능을 위해 굳이 도메인 서비스 객체를 애그리것에 의존 주입할 이유는 없다. 이는 프레임워크의 기능을 사용하고 싶은 개발자의 욕심을 채우는 것에 불과하다.



Posted by 최범균 madvirus

댓글을 달아 주세요

페이스북 친구들과 댓글을 공유하고 싶다면 아래를 이용해주세요.
2015-11-28일 KSUG에서 발표한 자료 공유합니다.


Posted by 최범균 madvirus

댓글을 달아 주세요

페이스북 친구들과 댓글을 공유하고 싶다면 아래를 이용해주세요.

요즘 코딩 하다가 일단 돌아가게 만들고 리팩토링 한 코드가 있어서 공유해 본다. 실제 완전한 코드는 공개할 수 없어 일부 이름을 변경했다.


대충 시작한 코드는 아래와 같다. 아래 코드는 "접두어:포맷"으로 구성된 홀더파트(holderPart)에서 필요한 값을 추출한다.


while(leftCur != -1) {

    ...

    String holderPart = ...;

    if (holderPart.startsWith("date:")) {

        String holderFormat = holderPart.substring("date:".length());

        result.add( new HolderFormat(HolderType.DATE, holderFormat) );

        regEx += "(.{" + holderFormat.length() + "})";

    }

    ...

}


위 코드는 홀더파트의 접두어가 "date"면, HolderType.DATE에 해당하는 HolderFormat을 생성한다. 그리고, 관련 정규 표현식으로 "(.{포맷길이})"를 추가한다.


HolderType은 접두어에 해당하는 타입 목록을 정의한 enum 타입으로 코드는 다음과 같다.


public enum HolderType {


    DATE("date"), NAME("name"), TS("ts"), ANY("any");


    private String name;   

    

    private HolderType(String name) {

        this.name = name;

    }


    public String getName() {

        return name;

    }


}


필요한 기능을 점진적으로 구현하다보니 코드가 다음과 같이 바뀌었다.


while(leftCur != -1) {

    ...

    String holderPart = ...;

    if (holderPart.startsWith("date:")) {

        String holderFormat = holderPart.substring("date:".length());

        result.add( new HolderFormat(HolderType.DATE, holderFormat) );

        regEx += "(.{" + holderFormat.length() + "})";

    } else if (holderPart.startsWith("name:")) {

        String holderFormat = holderPart.substring("name:".length());

        result.add( new HolderFormat(HolderType.NAME, holderFormat) );

        regEx += "(" + holderFormat + ")";

    } else if (holderPart.equals("ts")) {

        result.add(new HolderFormat(HolderType.TS, ""));

        regEx += "([0-9]+)";

    } else if (holderPart.equals("any")) {

        result.add(new HolderFormat(HolderType.ANY, ""));

        regEx += "(.*)";

    } else {

        throw new IllegalArgumentException(String.format("not supported holder '%s'", holderPart));

    }

    ...

}


if-else로 구성된 코드를 보면 각 if 블록이 다음과 같이 구성된 것을 알 수 있다.

  • 홀더 파트가 특정 접두어에 해당하는지 검사하고,
    • 접두어에 따라 포맷 부분을 생성
    • 접두어에 해당하는 정규 표현식을 생성
정규 표현식을 생성할 때 포맷 부분을 사용하는 경우도 있고, 아닌 경우도 있다. 예를 들어, 접두어가 date나 name인 경우는 포맷 부분을 이용해서 정규 표현식을 생성하지만, ts인 경우는 포맷 부분과 상관없이 정규 표현식으로 "([0-9]+)"를 추가한다. 또한, 포맷 부분을 사용하더라도 접두어가 date인 경우는 포맷의 길이를 정규 표현식에 사용하는 반면 접두어 name인 경우는 포맷 부분을 그대로 정규 표현식에 사용한다.

즉, if 블록의 코드는 동일한 행위(접두어 비교, 포맷 생성, 정규 표현식 생성)를 하는 코드인데, 실제 행위의 구현은 접두어에 따라 달라지는 코드이다. 여기서 접두어는 HolderType에 해당하므로, 실제 행위는 HolderType에 따라 달라진다고 할 수 있다.

HolderType에 따라 기능이 달라진다는 것은 그 기능이 HolderType에 있어야 할 가능성이 높아 보인다. 그래서, if 블록의 코드를 HolderType으로 이전하기 시작했다. 이전을 위해 작성한 첫 번째 코드는 다음과 같다.

public enum HolderType {

    DATE("date") {
        @Override
        public FormatAndRegex extract(String holderValue) {
            String regex = "(.{" + holderValue.length() + "})";
            return new FormatAndRegex(holderValue, regex);
        }
    },
    NAME("name"), TS("ts"), ANY("any");

    private String nameVal;

    private HolderType(String name) {
        this.nameVal = name;
    }

    public String getName() {
        return nameVal;
    }

    public boolean matchName(String holderName) {
        return this.nameVal.equals(holderName);
    }

    public FormatAndRegex extract(String holderValue) { return null; }

    public static class FormatAndRegex {
        public final String format;
        public final String regex;

        public FormatAndRegex(String format, String regex) {
            this.format = format;
            this.regex = regex;
        }
    }
}


if 블록에서 수행한 기능 세 가지를 위해 추가한 코드는 다음과 같다.

  • 홀더파트가 특정 접두어에 해당하는지 비교 -> matchName() 메서드
  • 접두어에 해당하는 포맷 생성, 정규 표현식 생성 -> extract() 메서드
    • 포맷과 정규 표현식을 담기 위한 FormatAndRegex 클래스 구현
첫 번째로 이전한 기능이 DATE와 관련되어 있으므로 if-else 블록에서 "date" 부분을 다음과 같이 변경했다.

while(leftCur != -1) {
    ...
    String holderPart = ...;
    String[] holderElements = holderPart.split(":", 2);
    String holderName = holderElements[0];
    String holderValue = holderElements.length == 1 ? "" : holderElements[1];    
    if (HolderType.DATE.matchName(holderName)) {
        FormatAndRegex formatAndReg = HolderType.DATE.extract(holderValue);
        result.add( new HolderTypeFormat(HolderType.DATE, formatAndReg.format) );
        regEx += formatAndReg.regex;
    } else if (holderPart.startsWith("name:")) {
        String holderFormat = holderPart.substring("name:".length());
        result.add( new HolderTypeFormat(HolderType.NAME, holderFormat) );
        regEx += "(" + holderFormat + ")";
    } else if (holderPart.equals("ts")) {
        result.add(new HolderTypeFormat(HolderType.TS, ""));
        regEx += "([0-9]+)";
    } else if (holderPart.equals("any")) {
        result.add(new HolderTypeFormat(HolderType.ANY, ""));
        regEx += "(.*)";
    } else {
        throw new IllegalArgumentException(String.format("not supported holder '%s'", holderPart));
    }
    ...
}

나머지 홀더타입과 관련된 코드도 차례대로 HolderType으로 이관하고 그에 맞춰 if-else 블록 코드를 수정해 나갔다.


while(leftCur != -1) {

    ...

    String holderPart = ...;

    String[] holderElements = holderPart.split(":", 2);

    String holderName = holderElements[0];

    String holderValue = holderElements.length == 1 ? "" : holderElements[1];    

    if (HolderType.DATE.matchName(holderName) {

        FormatAndRegex formatAndReg = HolderType.DATE.extract(holderValue);

        result.add( new HolderTypeFormat(HolderType.DATE, formatAndReg.format) );

        regEx += formatAndReg.regex;

    } if (HolderType.NAME.matchName(holderName) {

        FormatAndRegex formatAndReg = HolderType.NAME.extract(holderValue);

        result.add( new HolderTypeFormat(HolderType.NAME, formatAndReg.format) );

        regEx += formatAndReg.regex;

    } if (HolderType.TS.matchName(holderName) {

        FormatAndRegex formatAndReg = HolderType.TS.extract(holderValue);

        result.add( new HolderTypeFormat(HolderType.TS, formatAndReg.format) );

        regEx += formatAndReg.regex;

    } if (HolderType.ANY.matchName(holderName) {

        FormatAndRegex formatAndReg = HolderType.ANY.extract(holderValue);

        result.add( new HolderTypeFormat(HolderType.ANY, formatAndReg.format) );

        regEx += formatAndReg.regex;

    } else {

        throw new IllegalArgumentException(String.format("not supported holder '%s'", holderPart));

    }

    ...

}


위 if-else 블록은 enum 타입 값만 다를 뿐 완전 동일하다. 그래서, 위 코드를 다음과 같이 for 문을 이용해서 변경했다.


while(leftCur != -1) {

    ...

    String holderPart = ...;

    String[] holderElements = holderPart.split(":", 2);

    String holderName = holderElements[0];

    String holderValue = holderElements.length == 1 ? "" : holderElements[1];    

    

    HolderType matchHolderType = null;

    for (HolderType holderType : HolderType.values()) {

        if (holderType.matchName(holderName)) {

            matchHolderType = holderType;

            break;

        }

    }

    if (matchHolderType == null) throw .....


    FormatAndRegex formatAndReg = matchHolderType.extract(holderValue);

    result.add( new HolderTypeFormat(matchHolderType, formatAndReg.format) );

    regEx += formatAndReg.regex;

    ...

}


이후 몇 번의 리팩토링을 거쳐 코드를 조금씩 개선해 나갔다. 사소한 것 하나를 언급하자면 while에서 사용한 leftCur 변수는 실제로는 "{ .... }" 형식의 문자열에서 "{"의 위치를 뜻하는데, "{"는 홀더파트를 열고 "}"는 홀더파트를 닫는다는 의미를 갖는다. 그래서, leftCur를 openCur로 이름을 바꿨다.


while(openCur != -1) {

    ...

}


그리고, (openCur != -1)은 실제로는 홀더파트를 여는 괄호를 찾았는지를 의미하므로, 코드를 다음과 같이 바꿨다.


while(foundOpenCur()) {

    ...

}


private boolean foundOpenCur() {

    return openCur != -1;

}


점진적으로 메서드를 추출하면서 while() 부분이 다음과 같이 바뀌었다.


while (foundOpenCur()) {

    ...

    findCloseCurAfterOpenCur();

    parseHolderPartAndAddToResult();

    ...

}


Posted by 최범균 madvirus

댓글을 달아 주세요

페이스북 친구들과 댓글을 공유하고 싶다면 아래를 이용해주세요.

아래 질문에 대한 답변을 이곳에 남긴다. (객체 지향과 디자인 패턴 책의 일부 내용을 각색했다.)


질문 내용: "절차 지향은 데이터를 중심으로 프로시저를 구현한다. 객체 지향은 기능 중심으로 구현한다."가 무슨 의미인가?

원문: https://www.facebook.com/groups/itbook4u/permalink/1012985252066531/


어려운 질문이다. 아래 코드로 얘기를 풀어보자.


someServiceCheck() {

    ...

    if (new Date().after(member.getExpirationDate()))) { ... } 

    ...

}


// Member의 일부

private Date expirationDate;

public Date getExpirationDate() {

    return expirationDate;

}

public void setExpirationDate(Date expDate) {

    expirationDate = expDate;

}


위 코드는 메서드(즉, 프로시저)의 일부 코드다. 이 코드는 회원 만료 여부를 확인하기 위해 member.getExpirationDate()로 만료일을 구한다. 즉, someServiceCheck() 메서드는 member의 expirationDate 데이터를 사용하고 있다. 현재 시점에서 expirationDate 데이터는 someServiceCheck()와 Member가 공유하고 있다.


만료일을 1년 늘려주는 코드는 어떻게 될까? 아래와 같이 구현해 볼 수 있을 것 같다.


renewContract() {

    Date date = member.getExpirationDate();

    Date renewedDate = ... // date에 1년 더한 값

    member.setExpirationDate(renewedDate);

}


이제 Member의 expirationDate를 공유하는 프로시저는 someServiceCheck()와 renewContract()로 늘어났다. 여기서 Member는 객체일까? 아니다. 정확히 말하면 Member는 객체라기 보다는 데이터를 담고 있는 구조체에 가깝다. 즉, 두 함수가 데이터를 공유하고 이를 기준으로 구현하는 전형적인 절차지향 방식이다.



만료 여부를 확인하는 코드가 많아지거나 만료 데이터를 변경하는 코드가 많아질수록 아래 그림처럼 expirationDate라는 데이터를 중심으로 프로시저를 구현하게 된다.



이렇게 절차 지향은 데이터를 중심으로 코드를 구현한다. 개별 프로시저가 일부 기능을 구현하지만, 그 기능의 완성은 데이터 공유에 있다.


절차 지향은 데이터를 중심으로 프로시저를 끈끈하게 묶어준다. 여기서 재앙이 시작된다. 예를 들어, 만료 없이 무한정 서비스를 받을 수 있다는 것을 표현하기 위해 expirationDate에 null을 할당하기로 했다고 해 보자. 이 순간 데이터를 공유하는 모든 프로시저가 영향을 받는다. 기존 코드에 null 검사를 추가해야 하고, null이면 에러가 아니라 만료일이 없도록 로직을 수정해야 한다. 또는 null 대신 9999년 12월 31일을 만료일자로 주기로 했다고 해 보자. 이 경우 남은 기간을 중심으로 환불 금액을 구하는 refund() 함수와 기타 만료일을 중심으로 중요 로직을 수행하는 코드들이 영향을 받게 될 것이다.


객체 지향은 데이터 구조체가 아닌 기능을 중심으로 프로그램이 엮인다. 예를 들어, Member를 구조체가 아닌 기능을 제공하는 객체로 바꿔보면 다음과 같이 바뀐다.


public class Member {

    private Date expirationDate;

    public boolean isExpired() {

        return new Date().after(expirationDate);

    }


    public int getRestDay() {

        ... //

    }


    public boolean renewContract() { // 데이터와 관련된 일부 기능이 객체로 들어옴

        .... //

    }

}


이제 다른 기능들은 만료 여부를 확인하기 위해 expirationDate 데이터를 사용하지 않는다. 대신 Member가 제공하는 isExpired() 메서드를 사용한다. 예를 들면 다음과 같이 바뀐다.


someServiceCheck() {

    ...

    if (member.isExpired())) { ... } 

    ...

}


일부 기능은 Member 안으로 들어갔다. 계약 갱신 기능이 그렇다. 데이터와 밀접하게 연결된 기능을 데이터와 같은 객체의 기능으로 넣는다. 이렇게 함으로써 객체의 내부 구현(특히 데이터)를 외부에 노출하지 않을 수 있다. 즉, 캡슐화를 할 수 있다.


기능 구현을 캡슐화하면 내부 구현 변경을 조금 더 쉽게 할 수 있다. (Member 데이터가 아닌) Member 객체를 사용함으로써 만료 여부 로직을 변경할 때 다른 코드는 영향을 받지 않게 된다. 무한대로 사용할 수 있는 사용자의 만료 데이터를 null로 하든, 9999년 12월 31일로 하든, Member 객체를 사용하는 코드는 isExpired() 라는 기능을 사용하면 된다. 만료 데이터 저장 방식 때문에 영향을 받는 코드는 Member로 수렴함으로 변경이 그 만큼 용이해진다.


자바나 C#과 같은 언어가 아니라 C와 같은 언어를 사용해도 객체 지향적으로 코딩할 수 있다. 핵심은 데이터 중심이 아닌 기능 중심으로 구현하는 것이다. 즉, 여러 프로시저가 데이터를 공유하는 방식이 아니라 프로시저가 다른 프로시저를 사용하는 방식으로 구현을 하고, 데이터 공유를 적절히 제한하면 캡슐화 효과를 얻을 수 있다.

 

Posted by 최범균 madvirus

댓글을 달아 주세요

  1. 백명석 2015.07.15 10:04 신고  댓글주소  수정/삭제  댓글쓰기

    현업에서는 구현을 하는 것보다 유지보수에 훨씬 더 많은 리소스가 소요된다.
    구현 후에 변경은 기능 자체에 대한 것 보다는 화면/DB 등에 존재하는 데이터에 대한 것이 대부분이다.
    데이터의 변경이 발생했을 때 이 변경이 시스템의 다른 부분(코드)에 영향을 없애는 것이 유지보수 측면에서 매우 중요하다. 그래서 데이터의 변경을 외부에 노출시키지 않을 수 있는 캡슐화가 큰 의미를 갖는다.
    라는 측면에서 객체지향이 의미를 갖는다고 생각함.

페이스북 친구들과 댓글을 공유하고 싶다면 아래를 이용해주세요.

최근 기능 개선 프로젝트를 진행하면서 리팩토링한 코드가 있는데, 리팩토링 전/후 내용을 정리해 본다. 수정한 코드는 마이플랫폼 코드이다. 마이플랫폼 코드는 겉모습은 자바 스크립트와 유사한데, 객체로 묶는 단위가 없고 함수 위주로 코드를 작성한다. 마이플랫폼에 대해 몰라도 이 글에서 사용하는 코드를 읽는 데 문제는 없을 것이다.


내용 설명


수정할 코드는 서버쪽 인증과 관련되어 있는데, 서버 기능 변경을 위해 클라이언트 코드인 마이플랫폼 코드도 변경하게 되었다. 마이플랫폼 코드에서 인증은 크게 두 가지 방식으로 진행한다.

  • 포탈에서 로그인한 뒤 마이플랫폼 구동 시 : 인증 키를 넘겨서 인증 처리
  • 마이플랫폼 바로 구동 시 : 로그인 폼에서 아이디/암호를 입력받아 인증 처리
기존 코드 구조와 문제


기존 코드를 그림으로 정리해봤다. 설명을 위해 실제 코드에서 사용한 이름을 약간 바꿨다. 패키지는 파일 한 개를, 이름에 괄호가 포함된 것은 함수를, 괄호가 없는 것은 변수를 의미한다. 예를 들어, login.js 파일에는 Login() 함수, Login2() 함수, Success() 함수가 있고, isLoginPage라는 변수가 있다. 의존은 특정 함수에서 다른 함수를 호출함을 의미한다. 아래 그림의 경우 Login2() 함수에서 LoginEnable() 함수를 호출한다.



그림만 봐도 뭔가 복잡해 보이는데, 실행 흐름을 보면 순환 의존이 더 명확해진다.


Login.xml은 독립된 어플리케이션을 실행할 때 사용하는 화면이다. 실행 흐름은 대략 다음과 같다.

  1. Login.xml: OnLoadCompleted() 실행
  2. Login.xml->OnLoadCompleted() : login.js->Login2(true) 실행
  3. login.js->Login2() : 파라미터로 전달받은 true를 isLoginPage에 할당
  4. login.js->Login2() :
    1. 인증에 성공하면, 인증 처리 후 Success(아이디) 실행
    2. 인증에 실패했는데
      1. isLoginPage가 true면 Login.xml->LoginEnable() 실행 후 리턴. 로그인 폼이 보여짐
  5. login.js->Success()
    1. isLoginPage가 true면, Login.xml->initSession() 실행. 이후 프로그램 실행 처리

SDI.xml은 단일 화면을 실행할 때 사용한다. 실행 흐름은 다음과 같다.

  1. SDI.xml: OnLoadCompleted() 실행
  2. SDI.xml->OnLoadCompleted() : login.js->Login2(false) 실행
  3. login.js->Login2() : 파라미터로 전달받은 false를 isLoginPage에 할당
  4. login.js->Login2() :
    1. 인증에 성공하면, 인증 처리 후 Success(아이디) 실행
    2. 인증에 실패했는데
      1. isLoginPage가 false면 SDI.xml->LoginCheck(false)실행.
  5. login.js->Success()
    1. isLoginPage가 false면, Login.xml->LoginCheck(true) 실행. 이후 프로그램 실행 처리

login.js의 Login2() 함수와 Success() 함수는 로그인 성공이나 실패시 사용할 함수를 선택할 때 isLoginPage 변수를 사용한다. 이 변수가 true면, FrameLoing.xml의 함수를 호출하고, false면 SDI.xml의 함수를 호출한다. isLoginPage 변수를 알맞게 설정하기 위해 Login2() 함수를 호출할 때, FrameLogin.xml에서는 true를 전달하고 SDI.xml에서는 false를 전달한다.


문제점을 보자.

  • FrameLogin.xml에서 login.js의 Login2() 함수를 호출할 때 true를 전달하는데, FrameLogin.xml 코드를 처음보면 이 true가 무엇을 의미하는지 알 수 없다. 이는 SDI.xml도 마찬가지다.
  • 인증 성공이나 실패시 어떤 함수를 실행할지 여부는 FrameLogin.xml에서 결졍해야 하는데, 이 결정을 login.js에서 하고 있다. 즉, login.js는 인증만 처리하는 것이 아니라 화면의 실행흐름까지 관리한다.이러면서 순환의존이 발생하고, 어떤 화면에서 인증 요청을 했는지 알기 위해 isLoginPage 변수를 사용하고 있다.
순환 의존 정리

의존 순환을 없애려고 인증 성공/실패시 화면에 따라 분기 처리하는 코드를 원래 있어야 할 자리로 옮겼다. 기존 login.js 코드는 다음과 같았다. 실제로는 더 복잡한데 설명에 필요한 부분만 표시하고 나머지는 생략했다.

// login.js
var isLoginPage;

function Login2(flag) {
    isLoginPage = flag;
    // 인증 처리
    if (인증성공) {
        Success(아이디);
    } else {
        if (isLoginPage) LoginEnable()
        else LoginCheck(false);
    }
}

function Success(id) {
    if (isLoginPage) initSession();
    else LoginCheck(true);
}


여기서 isLoingPage가 true일 때 사용한 코드를 FrameLogin.xml로 옮겼다.


// FrameLogin.xml 코드

function OnLoadCompleted() {

    Login2(true);

    if (인증성공) initSession();

    else LoginEnable();

}


비슷하게 isLoginPage가 false일 때 사용한 코드를 SDI.xml로 옮겼다.


// SDI.xml 코드

function OnLoadCompleted() {

    Login2(false);

    if (인증성공) LoginCheck(true);

    else LoginCheck(false);

}


코드를 옮긴 후 login.js 코드는 다음과 같이 단순해졌다. Success() 함수는 더 이상 필요없으므로 지웠다.


// login.js

var isLoginPage;

function Login2(flag) {

    isLoginPage = flag;

    // 인증처리

}


isLoginPage 변수도 필요 없으므로 이 변수를 지우고, FrameLogin.xml과 SDI.xml에서 Login2() 함수를 호출할 때 전달한 true/false 인자도 삭제했다.


// login.js

function Login2() {

    // 인증 처리

}


// FrameLogin.xml 코드

function OnLoadCompleted() {

    Login2();

    if (인증성공) initSession();

    else LoginEnable();

}


// SDI.xml 코드

function OnLoadCompleted() {

    Login2(false);

    if (인증성공) LoginCheck(true);

    else LoginCheck(false);

}


변경 후 구조


변경 후 구조는 다음과 같다.



구조를 변경하기 전과 비교해 다음이 나아졌다.

  • 순환 의존을 일부 제거했다. login.js->Login() 함수에서 여전히 FrameLogin.xml->initSession()을 호출하기 때문에, 순환 의존이 남아 있지만, 전체 구조가 간결해졌다.
  • 실행 흐름이 보다 명확해졌다. FrameLogin.xml과 SDI.xml 코드만 봐도 인증 성공/실패시 어떤 코드를 실행하는지 알 수 있다. 변경 전에는 코드를 쫓아가서 login.js->Login2()나 login.js->Success() 코드를 봐야 전체 실행 흐름을 알 수 있었다.
  • login.js의 역할이 보다 명확해졌다. 이전 구조에서 login.js는 인증 처리뿐만 아니라 각 화면별로 실행 흐름을 제어했다. 실행 흐름 제어 부분을 알맞은 곳으로 옮겨서 login.js는 인증 자체에 집중하게 되었다. 이 과정에서 Success() 함수나 isLoginPage같은 변수도 날렸다. 

의존 순환을 더 제거해야 하지만, 코드 구조가 전체적으로 명확해졌다.

Posted by 최범균 madvirus

댓글을 달아 주세요

페이스북 친구들과 댓글을 공유하고 싶다면 아래를 이용해주세요.

처음 회사라는 곳에 들어가서 업무를 하던 시절, 그 회사에서 UML로 클래스 다이어그램을 그리는 사람은 나뿐이었다. 개발자가 10여명 정도 되는 작은 회사였는데 회사를 다니는 2년 8개월 동안 데이터 모델인 ERD를 그리는 사람이 1-2명 있는 정도였다.


사실 내가 그린 클래스 다이어그램은 데이터 모델이나 다름 없었다. 지금처럼 엔티티, 밸류에 대한 고민도 할 수 없었던 시기라 사실상 ERD를 클래스 다이어그램으로 그린 것이나 다름없었다. 약간의 차이라면 데이터뿐만 아니라 로직이나 다른 역할의 클래스들도 표현하곤 했다는 것이었다.


대학시절에 객체 지향이나 도메인 모델에 대해 제대로 공부하지 못했던 관계로 직장 생활을 하면서 눈치밥으로 도메인 모델이 이런 건가하는 감을 잡아 왔다.


사회생활 초기에는 ERD나 다름 없는 클래스 다이어그램이 도메인 모델 같았다. 특정 방법론을 따르는 SI 프로젝트에 참여했을 때 설계는 모두 데이터 중심이었고, 지식이 부족했던 당시에 읽었던 책들도 비슷한 내용을 담고 있었다.


그런데, 설계에 대한 여러 책들을 읽다보니 도메인 모델은 그런 것이 아니었다. 명쾌하게 답을 내릴 수 없었지만, 도메인 모델이란 것은 단순히 ERD의 클래스다이어그램 버전은 아니라는 생각이 들었고, 조금 더 객체 설계에 가깝다는 느낌을 받게 되었다.


[도메인 모델이란?]


아직도 도메인 모델이 뭐다라고 꼭 찝어 한 줄로 정의할 수 없기에, 도메인 모델이 뭔지 알아보고자 이런 저런 문서를 뒤져보았다.


우선, '도메인 모델'이란 단어에 출현하는 '도메인'과 '모델'에 대한 이해가 먼저 필요할 것 같다. '도메인Domain'에 대한 정의부터 뒤져보기로 했다. 위키피디아님의 정의(http://goo.gl/gw4w84)에 따르면, '도메인 공학'은 다음과 같다.

A domain is a field of study that defines a set of common requirements, terminology, and functionality for any software program constructed to solve a problem in the area of computer programming, known as domain engineering.


컴퓨터 프로그래밍으로 문제를 해결하기 위해 만들 소프트웨어 프로그램을 위한 요구사항, 용어, 기능을 정의하는 학문 영역이 도메인 공학이다.

이 정의에 따르면, '도메인'은 해결하고자 하는 문제 영역 정도가 될 것 같다.


위키피디아님이 도메인 모델에 대한 정의(http://goo.gl/Bna2U)도 다음과 같이 내려주셨다.

A domain model in problem solving and software engineering is a conceptual model of all the topics related to a specific problem. It describes the various entities, their attributes, roles, and relationships, plus the constraints that govern the problem domain. It does not describe solutions to the problem.


소프트웨어 공학에서 도메인 모델이란 특정 문제와 관련된 모든 주제의 개념 모델이다. 도메인 모델은 다양한 엔티티, 엔티티의 속성, 역할, 관계, 제약을 기술한다. 문제에 대한 솔루션을 기술하지 않는다.

예전에 얼핏 봤던 방법론이 요구사항 분석 과정에서 UML로 개념 모델을 만들었는데 이 때의 개념 모델이 도메인 모델에 해당된다. 유스 케이스나 유저 스토리 같은 것이 도메인의 동적 측면을 보여준다면, 도메인 모델은 도메인의 정적 구조를 보여준다.


도메인 모델은 지식을 공유하고 소통하는 도구로 사용하기에 적합하다. 요구사항 분석 과정에서 분석가(기술팀)는 구축한 도메인 모델을 통해 자신이 올바르게 이해했는지 확인할 수 있고, 반대로 업무 전문가들도 자기들끼리 의견을 맞추는데 도메인 모델이 도움이 된다.


아키텍처에서 도메인 레이어의 결과물을 도메인 모델로 부르기도 한다. 프리젠테이션 레이어, 어플리케이션 레이어, 도메인 레이어, 인프라스트럭처 레이어와 같은 구조에서, 도메인 레이어는 도메인의 개념, 도메인의 정보, 도메인의 규칙을 표현하는 책임을 진다.


예전에는 분석가(컨설턴트)라는 사람들이 이런 도메인 모델을 만들어서 구현 담당자에게 넘겨주고 떠나곤 했다. 이를 넘겨 받은 구현자들은 도메인 모델을 다시 구현 모델(즉, 소프트웨어 설계)로 변환하는 과정을 거친 뒤에 구현을 진행했다. 그런데 여기서 문제는 도메인 모델이 구현 모델로 넘어가는 과정에서 많은 도메인 지식들이 유실되면서 실제 도메인 전문가가 요구하는 소프트웨어가 만들어지지 않는다는 것이다. 게다가 모든 요구사항은 프로젝트가 진행되는 동안 완성되어 나가기 때문에 구현 과정에서 발견되는 도메인에 대한 통찰이나 개념들이 도메인 모델에 다시 반영되지 않는 문제도 있다.


도메인과 구현 사이의 불일치는 (프로그래머와 도메인 전문가 사이의) 불필요한 해석 과정을 야기하고, 이는 잘못된 소프트웨어를 만드는 원인이 되기도 한다. 이런 불일치를 해소하기 위한 노력 중 하나가 도메인 주도 설계Domain-Driven Design이다. DDD는 도메인 모델의 적용 범위를 구현까지 확장하는데, 이를 통해 도메인 지식이 구현 코드에 반영되도록 하고 있다.


------

  • 조영호 님 : 도메인은 저희 입장에서 소프트웨어를 개발하는 대상 영역정도로 생각해도 무방합니다. 택시 앱을 만든다면 택시 기사님께 콜을 하고, 탑승하고, 요금을 지불하는 전 과정이 도메인이 됩니다. 물론 프로젝트를 할 때는 이 중에서 소프트웨어로 개발될 범위로 한정해서 범위를 좁하게 됩니다. 이렇게 개발 대상과 범위를 간단히 도메인이라고 봐도 무방할 것 같습니다. 도메인 모델이란 도메인을 모든 사람이 동일한 관점에서 이해할 수 있고 공유할 수 있도록 단순화시킨 것이라고 보시면 됩니다. 이게 꼭 클래스 다이어그램의 형식으로 표현될 필요는 없지만 객체지향 프로그래밍을 하는 경우에는 일반적으로 클래스 다이어그램의 표기법을 사용해서 도메인 모델을 정리하는게 여러모로 유용합니다. 이렇게 하는 이유는 객체지향 패러다임에서 사용하는 유사한 기법에 기반하는게 코드와 모델을 유사한 형태로 유지하는데 이롭기도 하고 일단 도메인 모델을 이해하면 그 모델을 기반으로 코드를 쉽게 이해하고 수정할 수 있기 때문이죠. 다른 패러다임인 경우에는 그 패러다임에서 구현하기 쉬운 형태로 작성하면 되겠죠. 마지막에 아키텍처 상에서 말하는 도메인 모델은 마틴 파울러가 PEAA에서 언급한 것으로 도메인 레이어를 객체지향적으로 구현하는 패턴을 가리키는 용어입니다. 즉 패턴의 일종이고 원래의 도메인 모델과는 약간 거리가 있습니다.
  • 박성철 님 : 도메인 모델이란 용어 자체만 보면 문제 영역을 개념적으로 모델링한다는 평범한 의미인데 이 용어가 OOA/D 분야에서 사용되었기 때문에 객체 모델링이 함의된 것 같습니다. 흔히 도메인 모델이라고 하면 정적 데이터 요소를 표현하는 것으로 국한하는 것 같은데 동적인 요소(예를 들어 유즈 케이스)까지 고려가 되어야 할 것 같습니다.


Posted by 최범균 madvirus

댓글을 달아 주세요

  1. kayano 2015.04.14 18:06 신고  댓글주소  수정/삭제  댓글쓰기

    정말 블로그처럼

페이스북 친구들과 댓글을 공유하고 싶다면 아래를 이용해주세요.

신림프로그래머 모임에 발표할 모델링 연습 리뷰 자료입니다.



발표 자료에 나오는 JPA의 AttributeConverter에 대한 내용은 아래 링크에 정리했습니다.

  • http://javacan.tistory.com/entry/How-to-use-JPA-21-by-AttributeConverter-for-custom-value-type


Posted by 최범균 madvirus

댓글을 달아 주세요

페이스북 친구들과 댓글을 공유하고 싶다면 아래를 이용해주세요.

메일로 다음과 같은 문의를 받았습니다.

"@Autowired가 필드 이름과 빈의 식별자가 같으면 인젝션이 되는데, 님(저)이 필드 이름을 이용해서 의존 주입하는 것을 선호하지 않는 이유는 무엇인가요?"

"그리고, 이 경우 이름이 일치하도록 하면 @Qualifier를 사용할 필요가 없는 거 아닌가요?"

이 외에도 몇 가지 질문이 있었지만, 위 질문에 대한 답을 장문의 메일로 쓰기가 쉽지 않아, 블로그에 글로 남깁니다. (이후부터는 높임을 하지 않고 편하게 적습니다.)


시작은 의존 설계


게시글에 대한 검색 기능을 구현한다고 해 보자. 아직 게시글 검색 기능을 구현하지 않았을 뿐만 아니라 어떤 기술을 사용할지 결정된 바도 없다. 꼭 TDD를 좋아하지 않더라도 게시글 검색 기능과 연동되는 클래스를 테스트하려면(또는 검색 기능과 연동되는 클래스를 구현하려면), 다음처럼 검색 기능을 인터페이스로 정의하고 해당 인터페이스를 사용하도록 구현할 것이다.


public interface SearchService {

    public List<Document> search(String query);

}


public class ArticleController {

    @Autowired

    private SearchService searchService; // 인터페이스를 사용


    public String search(String query) {

         ...

    }

}


컨텐츠 검색 처리를 위한 ContentController도 검색 기능이 필요하기에 다음과 같이 SearchService를 이용해서 코드 구현을 시작했다고 하자.


public class ContentController {

    @Autowired

    private SearchService searchService; // 인터페이스를 사용

    ...

}


ArticleController를 구현하는 과정에서 Lucene을 이용한 SearchService의 구현이 완료되었다.


public class LuceneSearchService implement SearchService { ... }


그리고,  게시글이 아닌 다른 컨텐츠는 외부에서 제공하는 검색 서비스를 이용해서 구현하기로 했다.


public class QueryJetSearchService implements SearchService { ... }


즉, 객체 간의 관계는 다음과 같이 연결(wiring)이 된다.

  • ArticleController 객체 --> LuceneSearchService 객체
  • ContentController 객체 --> QueryJetSearchService 객체


스프링 설정에서 빈의 이름은?


각 클래스의 구현이 마무리 되었다. 스프링 설정은 어떻게 만들까?


<!-- autowired 관련 설정이 되었다고 가정 -->

<bean id="articleController" class="ArticleController" />

<bean id="searchController" class="ContentController" />


<bean id="???" class="LuceneSearchService" />

<bean id="???" class="QueryJetSearchService" />


위 설정에서 보면 LeceneSearchService와 QueryJetSearchService의 이름을 어떻게 줘야 하나? 구현 기술을 따라 다음과 같이 이름을 주었다고 해 보자.

  • luceneSearchService
  • queryJetSearchService

이렇게 이름을 줄 경우, @Autowired가 적용된 ArticleController와 ContentController의 두 searchService 필드는, luceneSearchService 빈과 queryJetSearchService 빈 중 어떤 걸 의존 연결 대상으로 선택해야 할지 알 수 없기 때문에 스프링이 빈 객체를 생성하는 과정에서 익셉션을 발생시키게 된다.


이름을 이용해서 의존 객체를 찾는 방식으로 이 문제를 해결하려면 아래 코드처럼 빈의 이름과 동일한 필드 이름을 갖도록 자바 코드를 변경하면, 일단은 된다.


<bean id="luceneSearchService" class="LuceneSearchService" />
<bean id="queryJetSearchService" class="QueryJetSearchService" />


public class ArticleController {

    @Autowired

    private SearchService luceneSearchService; // 이름 변경 발생!

    ...

}


public class ContentController {

    @Autowired

    private SearchService queryJetSearchService; // 이름 변경 발생!

}


필드 이름에 lecene이나 queryJet과 같은 구현 기술에 종속된 이름이 나오는 건 코드의 의미를 전달하기에 적합하지 않으므로, 의미가 잘 드러나도록 각각 articleSearchService, contentSearchService로 이름을 변경할 수도 있을 것이다.


<bean id="articleSearchService" class="LuceneSearchService" />
<bean id="contentSearchService" class="QueryJetSearchService" />

public class ArticleController {

    @Autowired

    private SearchService articleSearchService; // 이름 변경 발생!
    ...
}

public class ContentController {

    @Autowired
    private SearchService contentSearchService; // 이름 변경 발생!
}


뭐,아직까지는 나빠보이지 않는다. 그런데, Lucene을 이용한 검색에서 외부의 QueryJet을 이용한 검색으로 이관하기로 했다고 하자. 그럼, 이 경우에는 어떻게 해야 하나? 이름을 기준으로 의존 객체를 찾으면 ArticleController의 코드가 다음과 같이 바뀐다.


public class ArticleController {
    @Autowired
    private SearchService contentSearchService; // 이름 변경 발생!
    ...
}


contentSearchService라니? Article도 함께 검색할 수 있으므로, 뭔가 이름이 어울리지 않는다. 그래서 다시 스프링 빈의 이름과 필드 이름을 바꾼다.


<bean id="articleSearchService" class="LuceneSearchService" />
<bean id="externalSearchService" class="QueryJetSearchService" /> <!-- 이름 변경 발생 -->

public class ArticleController {
    @Autowired
    private SearchService externalSearchService; // 이름 변경 발생!
    ...
}
public class ContentController {

    @Autowired
    private SearchService externalSearchService; // 이름 변경 발생!
}


게다가 이름을 이용한 자동 의존 설정 방식을 사용하면, 역으로 필드 이름을 변경해야 하면, 스프링 설정의 이름도 변경해 줘야 한다.


왜 이런 일이....??


왜 이렇게 이름을 바꿔줘야 하는 상황이 발생하는지 좀 생각해보면, 단위 테스트 역량과 설계 역량이 쌓일수록 의존하는 부분을 인터페이스로 정의하기 때문인 듯 하다. 이런 역량이 쌓이면, 앞서 ArticleController의 경우처럼, 아직 SearchService의 상세 구현이 없는 상태에서 SearchService를 사용하는 ArticleController를 구현할 수 있게 된다. 비슷하게 ContentController도 실제 사용할 SearchService의 컨크리트 클래스 없이 구현할 수 있게 된다. 이 상황에서 나중에 스프링 설정에 사용할 빈 이름을 미리 고려해서 각자 사용할 필드의 이름을 다르게 정할 이유가 없다. 그냥 searchService라는 필드 이름으로 충분할 것이다.


// SearchService의 실제 구현이 뭐가 될지 모르는 상황에서

// 필드 이름을 미리 특정 구현 기술이나(luceneSearchService나 qjSearchService 등)

// 빈의 이름을 미리 에측해서 (internalSerachSvc, extSearchSvc 등) 정할 이유가 없다!

public class ArticleController {
    private SearchService searchService;
    ...
}

public class ContentController {
    private SearchService searchService;
    ...
}


이런 상태에서 자연스럽게 구현이 진행되고 그러다보면 두 개의 서로 다른 SearchService가 출현할 수 있는 것이다.


@Qualifier는?


같은 타입 또는 같은 인터페이스를 상속받은 서로 다른 클래스를 이용해서 두 개의 빈을 정의할 경우, @Autowired는 필드 이름과 일치하는 빈을 찾지 못하면 익셉션을 발생시킨다. 이런 경우에 필드의 이름을 변경하는 방법은, 결국 필드 이름이 특정 구현 기술이나 의존하는 빈 객체의 이름에 영향을 받게 되므로 그다지 좋은 방법이 아니다. 게다가 필드 이름을 바꾸면 (리팩토링 도구가 알아서 해 준다 하더라도) 그 필드를 사용하는 코드도 함께 변경을 하게 된다.


이럴 때, 변화를 최소화할 수 있는 방법이 @Autowired와 @Qualifer를 함께 사용하는 것이다. 예를 들어, 스프링 설정을 다음과 같이 했다고 해 보자.


<bean id="articleSearchService" class="LuceneSearchService">
    <qualifier value="internal" />
</bean>
<bean id="contentSearchService" class="QueryJetSearchService">
    <qualifier value="external" />
</bean>


이 경우, @Qualifier를 사용하면 이미 구현한 클래스의 필드 이름 변경 없이 자동으로 의존 연결을 처리할 수 있게 된다.


public class ArticleController {

    @Qualifier("internal")
    private SearchService searchService;
    ...
}

public class ContentController {

    @Qualifier("external")
    private SearchService searchService;
    ...
}


ArticleController가 사용할 SearchService를 "contentSearchService" 빈으로 변경해야 하는 상황이 발생해도, 필드 이름을 변경할 필요는 없다. 단지 @Qualifier의 값만 바꿔주면 된다.


public class ArticleController {
    @Qualifier("external") // 요기만 변경
    private SearchService searchService;
    ...
}


뭔가 바꾼거니까 필드 이름 바꾸는 거나 별 차이가 없지 않냐라고 할 수도 있지만, 필드 이름을 바꾸면 그 필드를 사용하는 모든 코드가 영향을 받지만, @Qualifier를 사용하는 경우에는 이 태그의 값만 바꿔주면 된다. 그 만큼 변경의 여파가 작은 것이다.


정리하며


다시, 존칭 모드로 돌아와서, 이 글이 질문을 보내신 분에게 약간이나마 '제가 왜 이름을 이용한 자동 의존 설정'을 선호하지 않는지에 대한 답이 되었으면 합니다.


Posted by 최범균 madvirus

댓글을 달아 주세요

페이스북 친구들과 댓글을 공유하고 싶다면 아래를 이용해주세요.


Posted by 최범균 madvirus

댓글을 달아 주세요

페이스북 친구들과 댓글을 공유하고 싶다면 아래를 이용해주세요.

최근에 온라인 쇼핑 시스템을 구축하는 프로젝트를 시작했다. 쇼핑 시스템 자체는 솔루션을 약간 커스터마이징해서 사용할 예정이다. 이 시스템의 사용자는 다음의 요구 사항을 갖고 있다.

  • 현재 사용중인 ERP 시스템에서 쇼핑 시스템의 판매 정보(매출 등)도 함께 보고 싶음
  • 현재 사용중인 ERP 시스템의 재고와 쇼핑 시스템의 재고를 맞추고 싶음
  • 이 ERP 시스템은 외부에서 서비스로 제공
우리가 맡은 책임 중 하나는 쇼핑 시스템과 외부 ERP 시스템 사이의 연동을 처리하는 것이다. 이 프로젝트는 이제 막 시작되었으며 현재는 쇼핑 시스템의 디자인과 요구 사항을 정리하는 과정에 있다. 아직 쇼핑 시스템의 개발자가 본격 투입 전이고, ERP 시스템과의 연계 방식을 논의한 회의를 몇 차례 가졌다.

이 시점에서 우리는 무엇을 할 수 있을까? ERP 시스템과의 연동을 위한 논의만 했을 뿐, 아직 연동을 할 수 있는 시스템은 마련되지 않았다. 또한, 쇼핑 시스템과의 연계 방식은 논의조차 시작하지 못했다. 우리가 만들어야 할 (쇼핑 시스템과 외부 ERP 시스템 간의) 중계 기능의 구현을 시작하기에는 미결정 사항들이 (많이) 존재한다. 게다가 그 결정을 바로 할 수 있는 것도 아니다.

문제는 이 프로젝트의 일정이 녹녹치 않다는 데 있다. 만약 우리가 중계 기능을 제 때에 만들어내지 못하면, 우리 때문에 전체 일정이 밀리게 된다. 그런데, 구현을 위해 필요한 결정을 지금 바로 할 수 없는 처지이다. 그렇다면, 우리는 나중에 뒤에 가서 '그 때 외부 ERP 시스템 개발자와 쇼핑 시스템 개발자가 구현에 필요한 정보를 늦게 알려줘서 어쩔 수 없었다'라고 변경해야 하나? 이런 변명이 통한다면야 좋겠지만, 뒤에 가서 일정을 맞추느라 개!고생을 하게 될 것이다.

고수준 모듈과 저수준 모듈 구별하기

자! 그럼, 우리는 뻔히 보이는 고생을 그대로 받아들여야 하나? 답은 "아니오"이다. ERP 시스템과의 연동 API가 정해지지 않았어도, 아직 쇼핑몰 시스템의 개발자와 미팅을 할 수 없어도, 우리는 중계 기능의 일부를 미리 만들어 낼 수 있다. 우리가 만들 수 있는 영역은 바로 "고수준 모듈"과 관련된 코드이다.

소프트웨어는 고수준 모듈과 저수준 모듈로 구분할 수 있다. 고수준 모듈은 의미 있는 기능을 제공하는 모듈이며, 저수준 모듈은 고수준 모듈의 기능을 구현하기 위해 필요한 하위 기능의 실제 구현이 된다. 우리가 만들어볼 중계 기능의 일부를 예로 들면 고수준 모듈과 저수준 모듈은 아래와 같이 구분할 수 있다.


여기서, 우리가 저수준 모듈에서 할 수 있는 것이라곤 결과를 보관할 DB 테이블을 정하는 정도이다. 쇼핑 DB에서 데이터를 가져오는 방법도, ERP 서비스 제공 업체에 데이터를 넘기는 방법도 우리가 마음대로 할 수 없다. 하지만, 이런 저수준의 구현 상세함이 정해지지 않았다 하더라도 우리는 고수준의 기능을 구현할 수 있다. 비밀은 바로 DIP에 있다.


DIP를 적용해서 저수준 모듈 없이 고수준 모듈 구현하기


DIP는 Dependency Inversion Principle의 약자로, 이 원칙에 대한 설명은 본 글에서는 생략한다. 이에 대한 내용이 궁금한 독자는 http://en.wikipedia.org/wiki/Dependency_inversion_principle 글 또는 필자가 지은 '객체 지향과 디자인 패턴' 책을 참고하기 바란다.


DIP를 적용해서 저수준 모듈이 고수준 모듈에 의존하게 바꾸면, 저수준 모듈의 상세한 구현 없이도 고수준 모듈을 (어느 정도 수준까지) 만들어낼 수 있다. 아래 그림은 DIP를 적용한 결과를 보여주고 있다.



OrderInfoSync가 의존하는 타입은 모두 인터페이스이므로 Mock을 이용해서 얼마든지 다양한 시나리오의 테스트 코드를 만들 수 있다. 우리는 아래쪽에 붉은 색 상자안에 위치한 저수준 모듈 클래스의 구현이 존재하는지 여부에 상관없이 OrderInfoSync 클래스를 구현할 수 있다.


실제로 지금 이 방식으로 외부 연동을 위한 고수준 모듈의 코드를 한 줄 한 줄 만들어나가고 있다. 물론, 저수준 모듈의 구현없이 고수준 모듈을 완벽하게 구현할 순 없지만, 상당한 양의 구현을 사전에 미리 만들어 낼 수가 있다. 그리고 이 핵심에는 DIP가 있다!



Posted by 최범균 madvirus

댓글을 달아 주세요

페이스북 친구들과 댓글을 공유하고 싶다면 아래를 이용해주세요.