프로젝트 투입전 목표

  • 동료들도 이용할 수 있는 코드 작성
  • 학습한 design pattern을 service에 적용
  • test code 작성
  • 선언적 프로그래밍

동료들도 이용할 수 있는 코드

프로젝트에서 민감정보 마스킹 처리와 해제에 관련하여 가이드가 내려왔다.

  1. 민감정보는 마스킹 처리를 해야한다. 마스킹 처리는 MaskUtil 내에 있는 메서드를 이용한다.
  2. 마스킹 해제 시에 로깅 서비스를 호출해야 한다.

기존에 공통 팀에서 개발해준대로 사용해도 됐지만 뭔가 좀 더 간편한 방법으로 해결하고 싶었다. 그래서 나는 위 두가지 가이드를 목표로 잡아 해결하려고 했다. 우선 해결 방법으로 생각한것은 1번은 JsonSerialize되는 시점에 dto에 @Mask 어노테이션이 붙어 있을때 마스킹 처리를 하도록 고안했다.

2번은 Aop를 사용하여 @MaskApplyLog(uri=””, menuId=””, sqld=””, crerNo=”“)를 통하여 서비스와 Aop JoinPoint 인자값으로 받을 수 있도록 개발했다. Aop에서 전달받은 인자를 자바 reflection을 이용하여 generic하게 각각의 호출하는 서비스의 reqDto로 typeCasting하여 mybatis에 queryParam으로 사용할 수 있도록 했다. 위 개발을 진행하면서 reflection과 aop에 대해 알 수 있는 시간이었다.

결론부터 말하면 1번은 아직 과제로 남아있고 2번은 성공하여 공통에 반영된것을 확인했다.

아직 해결하지 못한 @Mask 어노테이션 개발 ( 글을 작성하며 test code를 작성해서 성공시켰다.. 2024.12.23일 업무시간에 적용 성공했다..)

우선 1번 문제를 해결하기 위해 통신하는 시점에 직렬화를 처리해줄 JsonSerialize, Enum, Annotation을 이용하여 만들어보려 했다. 잘 정리된 오픈 소스가 많으니 간단하게 직렬화, 역직렬화 시점에 어떻게 마스킹을 적용하는지에 대해서만 간단하게 알아보고 어떤 어려움을 겪어 이 방법을 끝내 성공시키지 못했는지에 대해서만 작성해보려고 한다.

1.@Masked Annotation 생성

@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface MaskApply {
    MaskType value();
}

위 어노테이션이 적용된 field에 masking 처리를 적용한다. MaskType은 Enum으로 어떤 마스킹을 적용할지에 대한 타입을 명시한다.

public enum MaskType {
  PHONE, ID, NAME, CONTACT, DETAIL_ADDRESS, EMAIL;
}

spring beanProperty spring에서 bean property는 아래와 같은 역할을 한다.

  1. DI : 어노테이션이나 xml기반의 설정에서 객체 속성을 설정할 때
  2. Data binding: controller 레이어에서 폼 데이터를 객체로 바인딩
  3. validation: bean validation api 사용시

나는 jackson을 사용하여 bean property에 접근하기 때문에 jackson에서 활용을 알아보자. jackson은 javabean의 getter와 setter 기준으로 필드를 매핑하며 이는 데이터의 직렬화/역직렬화에 beanProperty가 사용된다는 의미이다. 나는 masking annotation이 적용된 Field의 Type을 불러오기 위해 사용했다. 내부 함수를 살펴보면 결국 jackson의 Std는 Bean property 인터페이스의 구현체로 볼 수 있다.

public interface BeanProperty extends Named {
    String getName();

    JavaType getType();

    <A extends Annotation> A getAnnotation(Class<A> var1);

    <A extends Annotation> A getContextAnnotation(Class<A> var1);

    AnnotatedMember getMember();

    public static class Std implements BeanProperty {
        protected final String _name;
        protected final JavaType _type;
        protected final AnnotatedMember _member;
        protected final Annotations _contextAnnotations;

        public Std(String name, JavaType type, Annotations contextAnnotations, AnnotatedMember member) {
            this._name = name;
            this._type = type;
            this._member = member;
            this._contextAnnotations = contextAnnotations;
        }


        public <A extends Annotation> A getAnnotation(Class<A> acls) {
            return this._member == null ? null : this._member.getAnnotation(acls);
        }
  
      ...

        public JavaType getType() {
            return this._type;
        }
  
    ...
    }
}
public class MaskStringSerializer extends StdSerializer<String> implements ContextualSerializer {

    MaskType maskType;

    protected MaskStringSerializer() {
        super(String.class);
    }
    
    protected MaskStringSerializer(MaskType maskType) {
        super(String.class);
        this.maskType = maskType;

    }

    @Override
    public void serialize(String s, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException, JsonGenerationException {
        try {
            jsonGenerator.writeString(MaskingUtil.mask(maskType, s));
        } catch (RuntimeException e) {
            e.printStackTrace();
        }
    }

    /**
     *
     * @param serializerProvider
     * @param beanProperty
     * @return
     * @throws JsonMappingException
     * 해당 어노테이션이 적용되어 있을 경우 마스킹 타입으로 MaskStringSerializer로 반환
     */
    @Override
    public JsonSerializer<?> createContextual(SerializerProvider serializerProvider, BeanProperty beanProperty) throws JsonMappingException {
        MaskApply maskApply = null;
        MaskType maskType = null;
        // jackson에서 제공하는 beanProperty
        if (beanProperty != null) {
            maskApply = beanProperty.getAnnotation(MaskApply.class);
        }
        if(maskApply != null) {
            maskType = maskApply.type();
        }
        return new MaskStringSerializer(maskType);
    }
}

성공하여 공통에 기여한 @MaskApplyLog

위 과정을 진행하면서 내가 학습한 내용을 업무에 적용할 수 있어서 행복했고 동료들도 좋은 피드백을 해주어 기뻣다. 환경이 열악하거나 개발되어 있지 않다면 항상 고민하며 학습해보고 업무에 적용하려고 노력하면 해낼 수 있다고 느낄 수 있는 값진 시간이었다.

MaskApplyLog는 어노테이션을 기반으로 AOP의 @After를 통하여 로깅한다. 마스킹이 정상적으로 해제 될 시에 RDB에 특정 사용자와 메뉴가 마스킹 해제 로직을 탔는지 저장한다. 기존에 사용하던 방식은 모든 Service가 로그를 쌓는 서비스에 의존하여 호출하는 방식으로 동작했다. 하지만 MaskApplyLog를 사용할 시에 각 서비스에서의 의존성을 제거하고 annotation의 파라미터로 값을 넘겨주면 아래와 같이 request의 타입캐스팅을 통해 본 쿼리의 실행을 대리하며 정상적인 로직을 처리할 수 있었다.

T t = clazz.cast(response);

Composite Pattern, Factory Pattern을 판매원장에 녹이기

공부를 해도 실전에 적용해보며 고민하지 않으니 학습이 제대로 됐는지 확인할 방법이 없었다. 실제로 production에 적용할 수 있어야 제대로 학습했다라고 생각했다. 실제로 적절한 서비스가 있어 학습했던 내용을 더듬고 고민하여 원장에 도입해보았다. 간단하게 어떤 구조로 작성했는지 코드를 통해 정리하려고 한다.

test code 작성

coverage를 70% 이상 기록해보기

위 목표를 달성하면서 봉착했던 문제와 습득한 기술에 대하여 간략하게 기록하려고 한다.

  • service 레이어 -> mocking 으로 테스트할 수 있다.
    • 현재 개발에 참여하고 있는 프로젝트에서는 mybatis를 사용하고 있다. 어떻게 하면 mybatis까지 커버하여 테스트할 수 있을지
    • 그래서 우선 mockito를 활용하여 테스트하였다.
  • controller 레이어 -> @SpringBootTest, @WebMvcTest 어떤걸 이용해야 하며 controller 레이어에서 사용하고 있는 클래스의 의존성 문제를 어떻게 해결할 것인가.

현재 프로젝트는 여러개의 모듈로 구성된 msa 구조로 작성되어 있고 내가 테스트를 하려고 하는 모듈은 서브 모듈로 의존성을 따로 주입하지 않으면, @SpringBootTest를 진행할 수 없다. 위 문제를 공통 설정이나 config를 변경하지 않고 해결하려고 노력했으나 단위테스트로 코드 검증을 진행하는 방식으로 test code를 작성하는 것으로 마무리됐다. 아직 끝나지 않았지만 해결하기 위해 새로 알게된 내용이나 인사이트에 대해 공유하려고 한다.

새로 알게된 내용

  1. 의존성 주입에 대한 중요성
  2. test fixture
  3. mock과 spy의 간단한 차이점

의존성 주입에 대한 중요성

Test Code를 이전에는 작성해본 경험이 없기에 정확이 스텁 테스트나 통합 테스트 그 어떠한 개념이 머릿속에 존재하지 않았다. 그 와중에 next step에서 제공하는 tdd 자바 플레이그라운드라는 tdd를 약하게 접해볼 수 있는 교육을 들으며 테스트의 막강함과 중요성에 대하여 깨달았다. 주제와 무관하지만 이러한 서사가 누구도 작성하지 않았던 테스트 코드를 이번 프로젝트를 계기로 시작해본 것이다. @SpringBootTest는 테스트하려는 소스를 대상으로 모든 의존관계가 성립해야 한다.

Java 8을 최대한 활용하여 개발하기

Optional, stream을 이용하여 선언적으로 개발해보자

stream은 보면 볼수록 직관적이다. 예를 들어 다음과 같은 for문을 stream 한줄로 summary할 수 있으며 사용자 측면에서도 매우 직관적이다. 아래 간단한 예시로 확인해보자.

@Test
void testStreamAndForLoop() {
  List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
  List<Integer> resultForForLoop = new ArrayList<>();
  List<Integer> resultForForStream = new ArrayList<>();

  for (Integer number : numbers) {
    if(number > 5){
      resultForForLoop.add(number);
    }
  }
  numbers.stream().filter(number -> number > 5).forEach(number -> resultForForStream.add(number));

  assertEquals(resultForForLoop, resultForForStream);
}

테스트가 통과하는 것을 확인할 수 있다. stream에 관련한 성능 이슈도 있지만 가독성 좋은 코드는 뛰어난 강점이 있다고 생각한다.

img.png

History

사이트 관리

ENM에서 재무관리 시스템 개발 이후 본사에서 Coding Assistant에 대한 TF로 약 1달의 시간을 보냈다. 정확히 1달이 다 되어 갈 무렵 팀장님이 차주 월요일부터 CGV 프로젝트에 투입될 것이라고 말씀해주셨다. 사이트는 어떤 개발을 진행하게 될지에 대해 약간 막막함도 있었다. 사이트에서 어떤걸 개발하게 될지와 아직 사용해보지 않은 React에 대한 걱정이 심했던 기억이 있다.

판매, 결제, 할인 그리고 적립..

처음 프로젝트에 투입될 당시, 사이트 관리나 React 작업에 대해 걱정이 있었지만, 실제로는 전혀 문제가 되지 않았다. 역시 어떤 일이든 직접 부딪혀봐야 진짜로 알 수 있다는 점을 다시 한번 깨달았다. ㅋ.ㅋ 프로젝트 초반, PL님께서 내가 주로 판매와 결제와 관련된 조회성 화면만 개발할 것이라고 하셨다. 처음에는 “조회성 개발만 하면 되는 건가? 그렇다면 도메인에 대한 깊은 이해가 꼭 필요할까?”라는 의문이 들었다. 그러나 약 2개월간 프로젝트에 참여하며 CGV에서 사용되는 영업, 판매, 결제, 인터페이스 관련 도메인을 전반적으로 파악하고, 이를 기반으로 개발을 진행했다. 단순히 조회 화면을 개발하는 데 그치지 않고, 넓고 복잡한 비즈니스 로직을 이해하면서 업무를 진행해야 했던 점은 다소 어려웠지만, 도메인을 깊이 이해하는 계기가 되었다. 특히 AS-IS 구조에서 자주 사용되던 예매 테이블과 결제_카드 정산 테이블이 TO-BE 설계에서 제외되면서, 새로운 설계에 적응하고 이를 기반으로 코드를 작성하는 데 고민과 노력이 필요했다.

비록 실제 코드를 모두 담아낼 수는 없지만, 프로젝트를 통해 배운 점과 업무에 적용했던 사례를 기록하며, 개발자로서 성장해 나가고 있음을 확인할 수 있었다.