LostCatBox

SpringProject-TrackingPost-CH06

Word count: 1.7kReading time: 10 min
2022/12/24 Share

Project 통합택배조회 api 06편 (다번째 기타-provider특징+개선점정리)

Created Time: September 20, 2022 5:09 PM
Last Edited Time: October 12, 2022 10:43 PM

스펙 선택 이유

spring

상황

토이 프로젝트로 택배 조회 프로젝트를 시작하였다. 목표는 각 택배사의 택배상태조회 API요청을 통해, 배송지 추적을 손쉽게할수있는 통합 API만들기.

선택

올해 6월 java를 배우기시작하며 spring을 처음 배우고 두번째 프로젝트였기때문에 익숙하였고, python으로 django를 활용하기보다는 spring으로 구현하여, java의 객체지향언어에 대한 특성(추상화, 다형성, 캡슐화, 상속)을 겪어보고싶었다.

mysql

상황

  • relation은 사용하지않을계획
  • DB성능은 현재로는 신경쓸필요없음

선택

mysql을 선택하였다.

  • 일관성 → 관계형 DB를 사용시 미리 정해진 스키마(제약, 규칙)를 사용하며, 알맞게 입력해야지만 기록할수있다.
  • 예외적상황이 줄고, DB의 무결성 보장
  • PostDB하나뿐이므로 일관적인 데이터만 사용
  • nosql이 빠른 쿼리가 가능하지만, 현재 서비스 기준으로 그렇게 많은 쿼리가 발생하지않았고, 해당 특성을 사용하지않으므로 선택하지않았다.
  • sql에서 보편적으로 많이 사용되는 mysql을 선택

kafka

상황

  • 실시간데이터 처리를 위해 scale-out가능한 msa구조
  • 각 서비스간의 원할한 소통구조
  • 직접 통신하는 연결구조보다 의존성이 낮은것 선호
  • 비동기적 처리

선택

선택은 KafkaApache로 하였다.

이유는 3가지

  • scale-out이 보다쉬운 시스템
  • 한 메세지에 여러 프로그램구독가능→logging쉬움
  • 대용량 처리를 하더라도 파일시스템으로 영속성보장과 성능보장
    • ActiveMQ
      • JMS으로 다른 자바 애플리케이션만 사용가능
      • ActiveMQ사용안한 자바 애플리케이션의 JMS와는 통신할수없다.
    • RabbitMQ
      • AMQP프로토콜 사용
      • AMQP기반이므로 프로토콜이 맞다면 애플리케이션끼리 모두 통신가능
      • 플러그인을 통한 큐 다양한 기능 존재
      • queue가 message를 comsumer에게 할당 선택→ push 형식
    • KafkaApache
      • 단순 헤더를 지닌TCP 기반의 프로토콜을 사용하여 프로토콜에 의한 오버헤드 감소
      • 다수의 메시지를 전송할때, 각 메시지를 개별적으로 전송이 아니라, batch형태로 broker에게 한번에 전달할수있으므로 TCP/IP 비용 줄임
      • 메시지를 파일로 저장하기때문에 데이터의 영속성보장
      • consumer가 pull해오는 방식이므로 consumer가 처리할수있는만큼만 처리
      • 기존 큐 다양한 기능 포기 → 대용량 실시간 메시지 처리가능 중점

docker

상황

scaleout에 대해 효율적인 msa구조를 적용하기로한 다음, 각각의 서버에서 Spring, DB, kafka, zookeeper등이 돌아가야했고, 개발환경은 메인컴퓨터와 AWS EC2인스턴스1개로 제한되어있었다.

선택

다음과 같은 이유로 docker를 선택하여 사용하였다.

  • dockerfile을 이용하여, 항상 내가 원하는 명령어로 세팅된 이미지를 활용할수있었다.
  • docker-compose를 활용하여, 개발환경 및 aws ec2에서도 어떤 곳에서든 똑같은 아키텍쳐와 구조를 구현이 간편했다.
  • 모든 컨테이너는 필요한 외부에서 접근하는 포트 8080,80,8082포트를 제외한 것은 모두 내부망을 사용하므로 네트워크 비용을 줄였다.

구현 힘들었던 부분

크롤러와 매니저

상황

계속 해서 택배사는 추가될것이며, 일관적이고 SOLID를 지키는 구조의 설계가 필요했다.

해결

SOLID원칙을 지켜 추상화된 provider들과 Manager로 다음과같이 책임을 분리하였다.

  • Manager는 PostDto의 객체를 반환하는 책임
  • provider인터페이스는 크롤링후 정보를 ProtDto에 담아 반환하는 책임

Manager가 provider 인터페이스에 의존하므로써 계속 추가될 각 택배사의 해당하는 크롤러에 대해 확장성이 개선되었다.

1

  • Manager
    • 매니저는 등록되어있는 provider들에게 isSupport 호출→ true 반환시 provider의 책임인 getpost()요청 → null또는 PostDto반환함
    • Manager는 null 또는 PostDto를 provider에게 받은후 해당 응답을 Optional로 Wrapping

2

  • provider 인터페이스

3

  • 각 provider 인터페이스이 구현체는 리스코프의 원칙에 따라 해당 함수를 구현함.

scale-out가능한 아키택처설계

카프카 도입의 중요성

  • 비동기적으로 처리가능해짐
  • scale-out이 각 서비스별로 가능하므로 효율적
  • kafka를 통해서 서비스간의 의존성이 전보다 줄어듬
  • 같은 토픽에 대해 많은 consumer가 동시에 구독가능하므로 logging등 다양한 곳에 활용가능

위와 같은 특징으로 sacle-out가능한 아키택처설계에 kafka가 필요하였고, 이로인해 3가지 경우를 그려볼수있었다.

첫번째안

4

해당 구조는 분명 scale out시에 서비스 각자의 DB를 가지므로 이 DB에 대해서 동기화라는 부분이 중요하였다. 이를 kafka connect로 해결할수있었지만, 지금 내가 구현하고싶은 서비스가 이정도로 확장성을 고려해야할지 의문이라 선택하지 않았다.

두번째안

5

kafka를 메시지 q로 사용하여, 외부 API조회용과 값을반영하여 DB에 저장하기위해서 DBservice를 통해 DB에 중복체크 및 반영되도록 설계하였다. DBservice가 DB만을 관리하는 서비스로 하고싶었지만, DB를 조회하는 입장에서는 kafka를 통해서 할수가없었으므로 (조회후 응답받아야함.) DB랑 직접 연결하였다. 그럼 DBservice가 그렇게 큰 역할을 하고있지않았다. 또한 외부 택배 API조회가 동기 처리가되어있으므로, 카카오 채널에 대해 응답이 바로 나갈수없었고 기다려야했으므로 선택하지않았다.

세번째안(선택)

6

(선택한 아키텍처) 카카오 채널 요청을 바로 응답해줄수있을뿐만 아니라, 외부 택배 조회 api로 나눠 해당 서비스 필요시 내부 kafka요청을 통해 동작요청이 가능하게하였고, 하나의 DB에 외부택배조회서비스와 조회기능서비스가 같은 DB를 바라보고있으므로, 각 서비스별로 DB에 대해서 동기화해줄필요도없었다. → 이는 DB가 하나이므로 장점이될수있다.

다만, 추후 서비스가 DB가 하나이므로 트랙잭션 처리가 매우 중요해질수있다…그때는 각 서비스별로 DB를 쪼개고 동기화하는 방식인 첫번째 안을 고려할것이다.

7

각 택배사의 API보안요소

CU 택배

종류

  • 홈택배
  • 일반 택배
  • CU끼리 택배

홈택배+일반 택배 특징

8

CU끼리 택배 특징

9

해결방안

  • CU 보통 택배 조회 요청 →응답 파싱후, 만약 특정값이 "검색된 결과가 없습니다." 와 같다면, 다시 CU끼리로 택배 조회 요청 → 응답 파싱후, 그래도 실패할경우, null반환 → Manager에서 Optional로 Wrapping

CJ대한통운

특징

  • "https://www.cjlogistics.com/ko/tool/parcel/tracking-detail"
  • post요청
  • csrf보안 + sessionid가 일치해야헀다.
    • 요청엔 다음과같은 양식으로 requestBody에 csrf값 + 운송장번호
      "_csrf="+csrf+"&paramInvcNo="+postNumber
    • 요청에 csrf발급과 일치하는 cookie의 sessionid값 포함 필수

10

해결

  • 첫번째 get요청을 통해 cookie의 sessionId값 및 html의 csrf값을 추출함
  • 추출한 쿠키(sessionId)와 requestbody에 csrf값과 운송장번호 함께 택배 조회 API 주소로 POST요청

개선을 위한 생각

null?Optional?빈 객체 반환?

빈값, Null값인 상황에서 3개 중 과연 어떤 것이 가장 적절한 것일까? 고민을했다.

상황별로 장단점을 고민한후 적용하였다.

  • 런타임오류인 NullPointerException은 나타나지않도록 노력해야한다.→ null로 반환한다면 호출자는 if(xxx≠null)로확인해줘야하므로 그만큼 NPE가 나타날확률이 높아진다.
  • null 반환, Optional반환, 빈 객체 반환
  • Optional반환은 반드시 null값이 있을수있음을 암시, 즉, 호출자가 검토해줘야함을 의미
  • null판단의 책임은 옵셔널에게만 있다.

11

12

  • 그래서 나는 각 택배 회사의 provider에서는 크롤링으로 각종 에러시 null값을 반환하였다.
  • 그후 Manager를 통해 null값 등을 Optional로 Wrapping하여, 그후 Controller에서 Optional을 ifPresentOrElse처리하여 if-else문을 사용하지않고 처리하였다

하지만 이렇게 모든 곳에 Optional을 활용하면 매우큰부작용을 겪는다.

  • Optional을 쓴다는것은 null인지 아닌지를 언젠가는 판별해야한다는것이고,
    이는 인풋 →db→밸리데이션 → 프로세싱에서의 벨리데이션의역할을 제대로하지않아, 프로세싱까지 NPE를 만날확률이 높아지게된다.
  • 따라서 다음과 같이 JPA에서 데이터를 불러오거나 할때 Optional을 이용하여 바로 orElse, orElseGet을 활용또는 orElseThrow를 활용하여, Optional을 제거하고, null이 아닌 객체를 반환하는 책임을 갖는것이 훨씬 좋은 방향이였다.(책임회피X)(다른곳에선 null체크안해도됨)

따라서 다음과같이 변경하였다.

  • Optional을 반환하는 JPA에 대해서 orElseThrow를 통해 Exception이 발생하도록하였다.

  • 이에따라 getRecentPost는 항상 PostDto를 반환하는것을 보장할수있다.(또는 RuntimeException이니까)

    13

멀티 모듈

공통의 관심사를 가진 것을 하나로 묶어놓는 것

common(공통적인) 코드들을 묶어서 놓을수있고, DTO등 많은 부분들을 공유가능하여, 변경사항이 생겻을때 따로 각자 변경할필요없는 장점을 가진다

root와 module로 나눴다.

  • 변경전

14

  • 변경후

15

좋아진점

msa 구조를 위해 각 service별로 분리해야했지만, 같은 DTO,Entity,Repository등 상당부분의 클래스 코드가 겹쳤다. 중복을 피하기위해 멀티모듈을 사용하여, tracking-core 모듈은 공통적으로 쓰이는 클래스를 모아두었고, 대신 bootjar로 패키징할필요는없으므로 false로 해놓았다.

반면에 각 서비스에 맞게 externalpost, kakaochannel, servicehome으로 나눴고, 이는 한 서버에서 돌아가던 여러 기능들이 분리되어 각각의 책임을 더욱 확실히 할수있었다.

logging개선

16

  • 일일히 모든 로직에 log를 선언하는것은 일관되지않았고,유지보수가 힘들었다.

  • 따라서 공통 관심 사항(부가기능)과 핵심 관심 사항(핵심기능)을 다음과 다음과같이 분리하였다.

    17

    • AOP를 활용하여 접속 및 응답시 logging(공통 기능)(부가기능)
    • @ExceptionHandler 를 통해 CustomException발생시 logging(핵심기능)
    • save등등 민감한 부분의 경우 따로 직접 logging처리(핵심기능)

AOP

  • 이를 통해, 모든 외부 요청에 대해서 처리하는 kakaochannel과 servicehome 모듈에 대해 공통적으로 logging을 할수있었다.
  • 요청시 →Request에 관한 정보 logging →비즈니스로직 →Response에관한 정보→ 종료
  • @Logging 에너테이션을 기준으로 공통 logging 기능구현을 함에 따라 추후에도 추가 및 변경에 용이하도록하였다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
public class LogConfig {
//around이므로 메서드 실행 전, 후 시점에 메서드 실행함. 본 메서드 실행은 pjp.proceed()
////within으로 범위설정가능
@Around("@annotation(Logging)")
public Object logging(ProceedingJoinPoint pjp) throws Throwable {
String params = getRequestParams(); //request값 가져오기

long startAt = System.currentTimeMillis();
log.info("-----------> REQUEST : {}({}) = {}", pjp.getSignature().getDeclaringTypeName(),
pjp.getSignature().getName(), params);

Object result = pjp.proceed(); // 본 메서드 실행

long endAt = System.currentTimeMillis();

log.info("-----------> RESPONSE : {}({}) = {} ({}ms)", pjp.getSignature().getDeclaringTypeName(),
pjp.getSignature().getName(), result, endAt - startAt);

return result;
}

// Get request values
private String getRequestParams() {

String params = "없음";

RequestAttributes requestAttributes = RequestContextHolder
.getRequestAttributes(); // 3

if (requestAttributes != null) {
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder
.getRequestAttributes()).getRequest();

Map<String, String[]> paramMap = request.getParameterMap();
if (!paramMap.isEmpty()) {
params = " [" + paramMapToString(paramMap) + "]";
}
}

return params;

}
private String paramMapToString(Map<String, String[]> paramMap) {
return paramMap.entrySet().stream()
.map(entry -> String.format("%s -> (%s)",
entry.getKey(), Joiner.on(",").join(entry.getValue())))
.collect(Collectors.joining(", "));
}
}

CustomException과 Advice

  • CustomException을 통해 다양한 에러에 대해 명확한 정보를 기록할수있도록하였다.

  • @RestControllerAdvice 로 각각의 CException에 대해 @ExceptionHandler 를 활용하여, 해당 에러에 대해 logging을 할수있도록하였다.

  • 예시

    18

savePost()와 같은 핵심기능의 logging

  • 아래와같이 서비스에서의 중요로직에 대해서는 직접 logging을 해주었다.
1
2
3
4
5
6
7
8
9
10
11
12
13
public PostDto savePost(PostDto postDto) {
//Optional 반환하는 JPA사용시 Optional를 바로 null 체크하여, 값을 무조건 갖도록한다.(NPE피함+Optional로 책임전가 불가)
//추후 JPA에서 boolen사용하자
Post recentPost = postRepository.findTopByPostNumberAndKakaoIdOrderByModifiedDateDesc(postDto.getPostNumber(), postDto.getKakaoId()).orElse(Post.getDefaultErrorPost());
if (recentPost.getStatusData().equals(postDto.getStatusData())) {
recentPost.update(LocalDateTime.now()); //spring에서 한 트랜젝션에서의 변경은 더티체킹일어남
log.info("해당 택배 상태는 이미 최신 것: "+postDto.toString());
return postDto;
}
postRepository.save(postDto.toEntity());
log.info("해당 택배를 새로이 업데이트함: "+postDto.toString());
return postDto;
}

그밖에 개선한점(작성중)

외부망에 노출된 포트들 최소한 방어해놓기

  • 현재 80, 8080, 8082 포트에 대해 방어해놓을수있는것 서술

8080포트

  • 현재 kakaochannel과의 소통만을 하고있으며, PostMapping으로만 → userHost와 port를 사용해보니 재각각이였기때문에 모든 0.0.0.0.으로 열어두었다.

80포트

  • 80포트역시, 링크 복사와 공유를 통해 어디서든 사용될수있으므로 0.0.0.0. 으로 활용하였다

8080포트(???)(안됨??)

  • 이 포트는 80포트에서 제공하는 frontend를 통해 axios로 요청한다. 따라서 요청하는 도메인은 정해져있고, 그래서 localhosttracking.lostcatbox.com 도메인에 CORS 가능하도록하였다.

  • 나머지 도메인들이 요청한다면 CORS 이슈가 일어날것이다

  • @CrossOrigin(origins={"http://localhost","http://tracking.lostcatbox.com"},

    19

문제

AOP구조를 멀티모듈에서 활용시 not working

현상

멀티모듈 구조시 core기능의 모듈에 logging에너테이션 정의와 logConfig를 활용하여 Aspect를 설계하였다. 하지만 다른 모듈에서 정상적으로 동작하지않았다.

원인

Aspect는 해당 모듈에서는 Component로 자동 빈에 등록되지만, 다른 모듈에서는 빈에 등록되지않았다.
(다른 모듈입장에서는 it is not in the path scanned by the SpringBoot application.)

1
2
3
4
5
6
@Slf4j
@Component
@Aspect
public class LogConfig {
...
}

https://stackoverflow.com/questions/64814633/spring-aop-aspect-doesnt-work-in-multi-module-project

해결

따라서 해당 Aspect가 정의되어있는 trackingpost-core 모듈의 aop폴더를 @ComponentScan을 통해 다른 모듈에서 빈 등록처리를하였다.

  • kakaochannel모듈예시

    20

CATALOG
  1. 1. Project 통합택배조회 api 06편 (다번째 기타-provider특징+개선점정리)
  2. 2. 스펙 선택 이유
    1. 2.1. spring
      1. 2.1.1. 상황
      2. 2.1.2. 선택
    2. 2.2. mysql
      1. 2.2.1. 상황
      2. 2.2.2. 선택
    3. 2.3. kafka
      1. 2.3.1. 상황
      2. 2.3.2. 선택
    4. 2.4. docker
      1. 2.4.1. 상황
      2. 2.4.2. 선택
  3. 3. 구현 힘들었던 부분
    1. 3.1. 크롤러와 매니저
      1. 3.1.1. 상황
      2. 3.1.2. 해결
    2. 3.2. scale-out가능한 아키택처설계
      1. 3.2.1. 카프카 도입의 중요성
    3. 3.3. 각 택배사의 API보안요소
      1. 3.3.1. CU 택배
      2. 3.3.2. 해결방안
    4. 3.4. CJ대한통운
      1. 3.4.1. 특징
    5. 3.5. 해결
  4. 4. 개선을 위한 생각
    1. 4.1. null?Optional?빈 객체 반환?
      1. 4.1.1. 하지만 이렇게 모든 곳에 Optional을 활용하면 매우큰부작용을 겪는다.
      2. 4.1.2. 따라서 다음과같이 변경하였다.
    2. 4.2. 멀티 모듈
      1. 4.2.1. root와 module로 나눴다.
      2. 4.2.2. 좋아진점
    3. 4.3. logging개선
      1. 4.3.1. AOP
      2. 4.3.2. CustomException과 Advice
      3. 4.3.3. savePost()와 같은 핵심기능의 logging
  5. 5. 그밖에 개선한점(작성중)
    1. 5.1. 외부망에 노출된 포트들 최소한 방어해놓기
      1. 5.1.1. 8080포트
      2. 5.1.2. 80포트
      3. 5.1.3. 8080포트(???)(안됨??)
  6. 6. 문제
    1. 6.1. AOP구조를 멀티모듈에서 활용시 not working
      1. 6.1.1. 현상
      2. 6.1.2. 원인
      3. 6.1.3. 해결