Easy Understanding

스프링부트로 페이 시스템 구축 도전해보기(초보 난이도) (2) - 조회/충전 시스템 개발 본문

Spring

스프링부트로 페이 시스템 구축 도전해보기(초보 난이도) (2) - 조회/충전 시스템 개발

appleg1226 2020. 10. 20. 00:03

초보 주의

예전 개발 경험이 적을 때 작성한 거라서 지금 저의 시점으로 보니 너무 도움이 안 되는 글이네요...

솔직히 지우고 싶으나 풋풋함을 기억하기 위해서 남겨둡니다. 

(심지어 지금 결제 도메인 개발 중이라 더 부끄러움)

 

스프링 프로젝트 구성

조회/충전 모듈은 널리 알려진 패키지 구조를 따릅니다.

domain, repository, service, controller 이렇게 네 가지 패키지에 각각 필요한 코드를 작성할 것입니다.

 

패키지 계층

 

당장 사용한 DB는 MongoDB로서, Docker를 이용해서 설치를 했습니다.

Docker를 통하여 설치해주기만 하면, 스프링부트 프로젝트를 실행했을 때 자동으로 연결이 되는 마법이 일어납니다. 

(자주 봤지만 여전히 편리해서 놀라는 auto configure 기능..)

 

구현할 기능은

'조회 기능', '결제 수단 등록', '페이머니 충전' 

이렇게 세 가지를 순서대로 구현할 예정입니다.


1. 조회 기능

도메인 설계는 왜 오래 걸렸는가?(데이터베이스 구조에 대하여)

MongoDB는 Document형 데이터베이스라고 불립니다. 하지만 이것은 그냥 저장 단위를 뜻하는 이름이고,

저에게 더욱 와닿는 설명은 'JSON 데이터베이스' 라고 하는게 더 좋았습니다.

즉, 모든 데이터가 JSON 형식으로 저장되는 데이터베이스입니다.

 

RDBMS에서 주구장창 배우는 개념이 테이블 간의 관계입니다.

이 테이블 간의 관계로부터 외래키 개념, 조인, 일대일 다대일 등의 테이블 설계 등의 개념이 나오게 되죠.

RDBMS에서는 여러모로 까다롭게 설계 과정을 점검해야 합니다.

 

그러나 MongoDB는 스키마가 없습니다.

 

그냥 그대로 JSON으로 넣어버립니다.

게다가 내부 테이블도 가능해서, 중첩된 형태의 구조도 저장이 가능합니다.

그렇다고 RDBMS처럼 키를 통한 참조가 되지 않는 데이터베이스도 아니기는 합니다.

 

문제는 두 가지 방식이 다 가능하기 때문에 어떤 것을 골라야할지 고민이 되었다는 점이죠.

 

1. 유저의 정보를 갖고 있는 document와

2. 거래 내역(송금, 결제)의 정보를 가지고 있는 document

1 안에다가 2 를 리스트로 중첩해서 넣을까,

아니면 키를 통해서 2가 1을 참조하도록 할까라는 고민이 계속 들었습니다.

 

두 가지 방법은 각자 장단점이 있습니다.

(coderwall.com/p/px3c7g/mongodb-schema-design-embedded-vs-references)

NoSQL을 사용하는 이유는 자유롭기 때문이 아닐까요?

저는 paymentId의 관리를 위하여 테이블을 나누기로 결정했습니다. 

이러한 유연성도 NoSQL의 장점이 아닐까 생각해봅니다.

 

이 과정에서 어떤 식으로 해야 나중에 개발을 할 때, 그리고 서비스를 한다면 좋을까를 계속 염두해야 합니다.

물론 저는 아직 많은 경험은 없지만, 이후 관리를 위하여 고민을 많이 하면서 구조를 생각하고 개발했습니다.

 

이 간단한 객체 두 개를 만드는 데에 이렇게 오랜 시간이 걸릴 수도 있다니,

참 설계라는게 금방 익숙하게 되지는 않는 것 같습니다. 

@Builder
@AllArgsConstructor
@NoArgsConstructor
@Data
@Document
public class PayUser {
    public enum ChargeMethod {
        CARD, BANK_ACCOUNT
    }

    @Id
    private String userId;
    private String email;
    private Set<ChargeMethod> chargeMethods;	// 등록된 결제 수단 목록
    private Set<String> cardCompanyNames;	// 등록된 카드 회사 이름
    private Set<String> bankCompanyNames;	// 등록된 계좌의 은행 이름
    private long payMoney;			// 현재 페이머니 잔액
}
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Data
@Document
public class Exchange {
    public enum EXCHANGE_TYPE{
        PAYMENT, SEND
    }

    @Id
    private long paymentId; 		 // 자동으로 생성할 id
    private EXCHANGE_TYPE exchangeType;  // 결제인지 송금인지
    private String myId;    		 // 나의 id
    private String otherId; 		 // PAYMENT이면 가게 id, SEND이면 유저 id
    private long money;     		 // 음수이면 송금, 양수이면 받음
    private LocalDateTime exchangeDate;	 // 거래일자
    private boolean isComplete;		 // 상대방이 송금을 받았는지
}

그렇다면 어떤 것을 조회할 것인가?

조심스럽게 페이 시스템에서 어떤 정보들을 볼 수 있으면 좋을지 상상해봅시다.

 

먼저 '내 정보 보기'는 무조건 있어야 할 것 같습니다.

잔액도 봐야하고, 여러가지 상태도 볼 수 있으면 좋구요.

그래서 그 API를 가장 먼저 만들어야 합니다.

 

다음으로는 거래 내역을 쭉 리스트로 볼 수 있어야 합니다.

앱을 켜보면 '최근 거래 내역'이라는 것은 무조건 있어야하는 필수 기능이죠.

그래서 거래 내역을 가져오기로 할 겁니다.

 

그런데 여기에선 '필터가 필요'합니다.

결제인지 송금 내역 확인인지 두 가지를 구분해서 보고 싶을 수도 있구요,

내가 송금한 내역만 보고 싶다든지, 받은 내역만 보고 싶다든지 할 수도 있습니다.

또한 isCompleted라는 변수가 있는데,

이게 뭐냐면 상대방이 확실히 수령을 했다는 flag입니다.

이것이 안 되어 있는 건 당연히 조회가 되어야겠죠.

 

물론 프런트 단에서 필터를 해도 되지만, 할거면 서버에서 해서 보내주는 것이 더 좋을 것 같네요.

 

기본적인 조회는 금방 구현할 수 있다.

(Spring data repository의 구현은 설명하지 않으며 github 코드를 참고하시면 됩니다.)

 

다양한 상황에 맞게 조회를 실행하는 코드를 만들었지만,그 중에서 가장 대표적인 조회 세 가지 코드를 아래 가져왔습니다.

// UserInformationService.java

// 유저 데이터 조회
public PayUser showUserInfo(String userId){
    return payUserRepository.findById(userId).orElseThrow(NoSuchElementException::new);
}

// 한 유저의 모든 거래 기록 가져오기
public List<Exchange> findAllExchanges(String userId){
  return exchangeRepository.findAllByMyId(userId)
  	.stream()
   	.sorted(Comparator.comparing(Exchange::getExchangeDate, Comparator.reverseOrder()))
    	.collect(Collectors.toList());
}

// 한 유저의 결제 목록 가져오기
public List<Exchange> findAllPayments(String userId){
	return exchangeRepository.findAllByMyId(userId)
    	.stream()
        .filter(exchange -> exchange.getExchangeType().equals(Exchange.EXCHANGE_TYPE.PAYMENT))
        .sorted(Comparator.comparing(Exchange::getExchangeDate, Comparator.reverseOrder()))
	.collect(Collectors.toList());
}

첫 번째 유저 데이터 조회는 위의 PayUser 객체를 찾아서 곧장 넘겨주어 조회하도록 했습니다.

 

두 번째에 있는 메서드는 한 유저의 모든 거래 목록을 가져오는 코드입니다.

즉 결제와 송금 두 가지 내용을 하나의 리스트로 볼 수 있도록 합니다.

은행앱이나 페이앱을 보시면 종합적인 거래 기록이 같이 있는 것을 보실 수 있을 것입니다.

repository query 결과를 list로 받아왔으며, 그것을 stream으로 처리해서 날짜 역순으로 배열한 메서드입니다.

 

세 번째 메서드는 모든 거래 중에서 결제 목록만 가져오는 코드입니다.

filter를 이용해서 결제 기록만 가져올 수 있도록 진행했습니다.

(제대로 사용하려면 mongo 자체 쿼리를 사용하는 것도 괜찮을 것 같습니다)

 

스트림은 iterable한 자료구조를 사용하는 데에 있어서 익숙해지면 너무너무 깔끔하고 좋습니다.

 

테스트는 간단하게 아래처럼 진행했습니다.

저장이 잘 되었는지 확인만 해보는 코드에요.

@Test
@DisplayName("위의 두 번째 메소드에 대한 테스트 코드")
void findAllExchangesTest(){
    // 이미 chong 이름으로 5개의 거래 내역이 저장되어 있음
    List<Exchange> result = userInformationService.findAllExchanges("chong");
    assertEquals(result.size(), 5);
}

2. 결제 수단 추가

자 이제 페이머니 충전을 위한 결제 수단을 연결해봅시다.

물론 실제로는 굉장히 많은 방법이 있겠지만, 여기에서는 대표적으로 계좌와 카드만 등록해보도록 합니다.

 

무엇을 구현해야할지 흐름을 상상해봅시다.

 

1. 사용자가 결제 수단과 그 관련 정보를 입력합니다.

   카드라면 카드번호와 유효기간, 계좌라면 계좌번호를 입력할 것입니다.

 

2. 그러면 서버는 그 정보를 가지고, 실제 카드, 은행 시스템 api에 해당 요청을 보내야할 것입니다.

 

3. 그리고 그것을 받아서 우리의 데이터베이스에도 결제 수단이 추가되었음을 기록하면 얼추 마무리가 됩니다.

 

일단 만들어보자

자 이제 그러면 클래스는 어떻게 만들까요.

 

카드 등록을 담당하는 서비스 클래스 하나 CardRegisterService

계좌 등록을 담당하는 서비스 클래스 하나 BankRegisterService 

두 가지를 만들어서 안에서 작업을 해주면 되겠네요!

 

CardRegisterService에는 다음의 메소드가 들어갑니다.

카드가 유효한지 확인하고 그것을 등록만 해주면 됩니다.

public boolean isValidCard(CardInfo card, CardCompany company);
public boolean registerCard(CardInfo card, CardCompany company);

registerCard 메서드 안에서는 이제 카드별로 로직을 호출하면 됩니다.

API 호출 코드 같은 것이 들어가겠네요.

이건 당장 어떻게 할 수가 없으므로 간단하게 로그만 찍는 비어있는 메서드가 될 겁니다.

public boolean registerCard(CardInfo card, CardCompany company){
    if(cardCompany.equals(BD)){
        bdCardRegister(card);
    } else if(cardCompany.equals(MASTAR){
        mastarCardRegister(card);
    } else if(cardCompanuy.equals(VISSA){
        vissaCardRegister(card);
    }
    
}

private static bdCardRegister(CardInfo card){
    // bd 카드 등록 API 호출
    // DB에 해당 결제 수단 등록
}

private static mastarCardRegister(CardInfo card){
    ......
}

자 이제 위에처럼 if-else나 switch문을 이용해서 하면 깔끔하게 하나의 클래스에 들어가겠네요.

 

카드를 위한 검증과 등록 클래스 하나 만들고, 내부적으로 카드회사별 로직 구현해주고,

계좌를 위한 검증과 등록 클래스 하나 만들고, 내부적으로 은행별 로직을 구현해주면 

와 정말 간단하긴 하네요.

그런데 여기에서 끝이라고 이상하다고 생각하셨다면, 네 아직 끝이 아닙니다.

 

한 번 리팩토링이라는 것을 해보자.

저런 코드가 좋지 않은 코드라는 것은 아닙니다. 나름 간단한 시스템에서는 그럭저럭 괜찮다고 생각합니다.

그렇지만 문제는 이후에 새로운 기능을 추가할 때 발생합니다.

 

만약에 카드와 계좌번호 말고 휴대폰 결제 같은 새로운 결제 수단을 추가한다면? 

새로운 카드회사들을 추가한다면?

모든 결제 시스템에 새로운 db 칼럼을 업데이트하고자 한다면?

 

어우, 이후에 어떤 코드부터 건드려야 할지 참 머리가 복잡해집니다.

그래서 미리 이후를 생각하고 코드의 구조를 건드려보기로 했습니다.

 

여러 방법들이 있지만 저는 인터페이스를 사용하고, 클래스를 나눌 겁니다.

 

1. 먼저는 결제 수단을 인터페이스로 등록합니다.

Connector라는 인터페이스를 두고, 이것을 구현한 CardCompanyConnector, BankAccountConnector를 둘 겁니다.

그래서 사용하는 입장에서는 Connector 객체로 동일한 메서드를 이용하여

내부 구현을 모른채로 분기 없이 사용할 수 있습니다.

 

아래에서 RegisterForm 객체는 자식 객체로 CardRegister, BankRegister 두 가지를 가지고 있으며,

받은 객체가 어떤 객체인지를 확인해서 connector의 객체를 바꿔 끼울 수 있도록 하고 있습니다.

public boolean registerChargeMethod(String userId, RegisterForm registerForm){
    
    Connector connector;
    
    if(registerForm instanceof CardRegister) {
        connector = new CardCompanyConnector();
    } else {
        connector = new BankAccountConnector();
    }

    if(connector.isValid(registerForm)){
        return connector.registerCard(registerForm);
    } else {
        log.info("not valid!");
        return false;
    }
}

분기문과 switch 문이 있으면 좋지 않다고는 하지만, 객체 생성 과정에서 한 번은 필요합니다.

그 대신 이후에는 더 이상 추가할 분기가 최대한 없어야합니다.

 

이제 구현한 각 CardCompanyConnector, BankAccountConnector 두 클래스에서는 각자 

등록 관련된 로직을 처리하게 될 겁니다.

 

2. 다음으로는 카드사별, 은행별 로직도 인터페이스로 나눠볼겁니다.

이것도 만들 때는 팩토리 메서드 하나만 분기문을 넣어줄 겁니다.

아래처럼 카드 정보를 넣어주면 이것을 가지고 새로운 객체를 생성해서 등록해줄겁니다.

public class BankAccountConnector implements Connector {

    private BankRegisterHandler bankRegisterHandler;

    public void setCompany(RegisterForm registerForm){
        switch (registerForm.getCompanyName()){
            case "KC":
                this.bankRegisterHandler = new KCBankRegisterHandler();
                break;
            case "HELLO":
                this.bankRegisterHandler = new HelloBankRegisterHandler();
                break;
        }
    }
}

 

다음의 클래스는 위의 BankRegisterHandler를 구현한 클래스로 각 은행별로 검증 및 등록 코드를 넣으면 됩니다.

아래의 코드는 실제로는 더욱 복잡한 코드가 들어가야하지만,

실제로 api콜을 할 수 있는 수단이 없어 로그로만 구성했습니다.

@Log
public class HelloBankRegisterHandler implements BankRegisterHandler {
    @Override
    public boolean isValidAccount(RegisterForm registerForm) {
        log.info("this HelloBank Account is valid");
        return true;
    }

    @Override
    public boolean registerAccount(RegisterForm registerForm) {
        log.info("HelloBank Account is registered!");
        return true;
    }
}

 

사실 위의 설명만으로는 크게 이해가 안될 것 같아서 어떻게 설계했는지 그림으로 정리하면 다음과 같습니다.

userInformationService에서는 등록에 대한 요청을 처리하게 되는데 여기에서 Connector를 어떤 것을 쓸지 결정합니다.

그리고 각 Connector는 어떤 회사의 로직을 사용할지 분리를 해서 사용하게 됩니다.

이렇게하면 나중에 핵심로직을 변경할 때도 간편하고,

새로운 결제 수단을 등록하는 데에도 강점이 있게 될겁니다.

 

그러나 여전히 완벽히 나뉘지 않는 코드들이 많은 것 같아서

여전히 더 공부를 해야할 것 같습니다.


3. 충전하는 기능 추가

충전 기능도 위의 방법을 따르기로 했습니다.

충전 방법도 위에서 등록된 두 가지 결제 수단을 이용한 두 가지 방법으로 나뉩니다.

 

두 가지 수단별로 방법이 달라야하고, 회사별로 api를 날리거나 세부 방법이 다르기 때문에

위와 비슷한 방향으로 갈 수 밖에 없습니다.

그래서 위와 거의 비슷한 구조로 설계를 했습니다.

 

내부적으로는 입력된 금액만큼 해당 유저의 잔액을 늘려주는 데이터베이스 업데이트 로직만 다릅니다.


4. 컨트롤러 추가

자 이제 컨트롤러에서는 위에 구현한 기능들을 하나하나 경로에 매핑시켜주면 됩니다.

컨트롤러 내부 로직은 복잡하지 않습니다.

 

다음은 대표적인 컨트롤러 메서드 몇 가지만 가져왔습니다.

이미 서비스 단에서 대부분의 복잡한 로직을 처리하기 때문에 이렇게 간단한 코드가 나오게 됩니다.

// 아직 완료되지 않은 송금 리스트 확인
@GetMapping("/user/exchanges/send/not-completed/{id}")
public ResponseEntity<List<Exchange>> showUserSendNotCompletedList(@PathVariable("id") String userId){
    return new ResponseEntity<>(userInformationService.findAllSendsNotCompleted(userId), HttpStatus.OK);
}

// 카드를 계정에 등록
@PostMapping("/user/register/card/{id}")
public ResponseEntity<String> registerCard(@PathVariable("id") String userId, @RequestBody CardRegister cardRegister){
    boolean result = userInformationService.registerChargeMethod(userId, cardRegister);

    if(result == true){
        return new ResponseEntity<>("register success", HttpStatus.OK);
    } else {
        return new ResponseEntity<>("register failed", HttpStatus.BAD_REQUEST);
    }
}

// 카드를 이용하여 계정 페이머니 충전
@PostMapping("/user/charge/card/{id}")
public ResponseEntity<String> chargeMoneyByCard(@PathVariable("id") String userId, @RequestBody long money){
    ChargeService chargeService = new ChargeService();
    chargeService.setChargeMethod(new CardCharge(payUserRepository));
    long result = chargeService.chargeMoney(userId, money);

    if(result == -1){
        return new ResponseEntity<>("charge failed", HttpStatus.BAD_REQUEST);
    } else{
        return new ResponseEntity<>("charge success", HttpStatus.OK);
    }
}

조회와 결제 수단 등록 / 충전 기능에서 중점적으로 신경쓴 부분은

1. 데이터를 어떻게 구성할 것인가

2. 객체 구조를 어떻게 효율적으로 리팩토링할 것인가

두 부분이었습니다.

 

금방 끝날 수 있는 기능이라고 생각했지만, 여러 가지를 고려하다보니 그렇게 간단하게 끝나진 않았네요.

 

다음 글에서는 결제 시스템을 구현해보도록 하겠습니다.

 

(실제 구현 코드)

github.com/appleg1226/sample-pay-system/tree/main/user-service