티스토리 뷰

지급대행

앞에서 우리 서비스에 등록된 판매자들이 정산받기 위해 등록하는 계좌에 대한 검증을 금융결제원의 계좌실명조회 API를 통해 하려고 했었다. 하지만 이는 일반적인 가맹점에서는 불가능하고 토스페이먼츠에서 제공하는 API를 통해서 서브몰들을 등록/조회/정산 등을 할 수 있다. 따라서 기존 금융결제원 방식의 계좌번호 실명조회 기능을 버리고 토스페이먼츠에서 제공하는 지급대행 API를 통해서 서브몰을 등록하고 조회 후 정산 관련 업무를 위한 기능까지 만들어보겠다.


서브몰 구조 & 등록 구현

우리 서비스를 이용하는 판매자는 우선적으로 자신의 은행, 계좌번호 등을 등록하여 서브몰을 등록해줘야 한다. 이때 서브몰의 고유한 아이디, 상호명, 대표명, 사업자 등록번호, 개인/법인 등이 필요하고 정산을 받기 위한 은행 - 계좌번호가 필요하다.

 

서브몰 등록을 위해 받는 DTO 클래스

@Data
public class SubmallReqDto {
	@ApiModelProperty(value = "코디네이터 이메일", required = true)
	private String crdiEmail;
	@ApiModelProperty(value = "상호 명", required = true)
	private String companyName;
	@ApiModelProperty(value = "대표명", required = true)
	private String representativeName;
	@ApiModelProperty(value = "사업자 유형(개인/법인)", required = true)
	private String type;
	@ApiModelProperty(value = "사업자 등록 번호 (법인의 경우 입력)", example = "0000000000")
	private String businessNumber;
	private SubmallAccountDto account;
    
	public Submall toEntity(String subMallId) {
		...
	}

	public TosspaymentSubmallReq toTossReq() {
		...
	}
}
@Data
public class SubmallAccountDto {
	@ApiModelProperty(value = "은행명", required = true)
	private BANK_CODE bank;
	@ApiModelProperty(value = "계좌번호", required = true)
	private String accountNumber;
	@ApiModelProperty(value = "예금주", required = true)
	private String holderName;

	public TosspaymentSubmallAccountDto toTossAccount() {
		...
	}
}

해당 DTO들을 통해서 사용자들의 요청 정보를 받게 된다.

Entity 서브몰 객체

public class Submall {
	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	@Column(name = "seq", nullable = false)
	private Long seq;

	@Column(nullable = false)
	private String crdiEmail;

	@Column(nullable = false)
	private String submallId;

	@Column(nullable = false)
	private String companyName;

	@Column(nullable = false)
	private String representativeName;

	@Column
	private String businessNumber;

	@Column
	private String type;

	@Column(nullable = false)
	private String accountNumber;

	@Column(nullable = false)
	private String bank;

	@Setter
	@Column(nullable = false)
	private boolean activate;

	@Setter
	@ManyToOne(fetch = FetchType.LAZY)
	@JoinColumn(name = "CRDI_SEQ")
	private Member crdi;

	public SubmallResDto toDto() {
		return SubmallResDto.builder()
				.crdiEmail(crdiEmail)
				.submallId(submallId)
				.companyName(companyName)
				.representativeName(representativeName)
				.businessNumber(businessNumber)
				.type(type)
				.accountNumber(accountNumber)
				.bank(bank)
				.build();
	}
}
  • 실제 DB에 테이블로서 존재하게 될 서브몰 객체이다.
  • 고객 당 활성화된 서브몰을 하나씩만 갖게 하도록 하기 위해 activate를 사용하게 했다.

Controller 서브몰 등록 API

public class SubmallController {

	private final SubmallService submallService;
	private final ResponseService responseService;

	@PostMapping
	@ApiOperation(value = "서브몰 등록", notes = "판매자가 은행, 계좌번호 등을 이용해 자신의 서브몰을 등록합니다.")
	public SingleResult<SubmallResDto> registSubmall(
			@ApiParam(value = "요청 객체") @ModelAttribute SubmallReqDto submallReqDto
	) {
		try {
			return responseService.getSingleResult(
					submallService.registSubmall(submallReqDto)
			);
		} catch (Exception e) {
			e.printStackTrace();
			throw new BussinessException(e.getMessage());
		}
	}
}
  • 먼저 프론트측에서 서브몰을 등록 할 수 있도록 API를 열어주고 Service 쪽에서 토스페이먼츠의 API를 호출하여 서브몰을 생성하게 된다.
  • 사용자가 서브몰을 생성할 때 전달하는 은행, 계좌번호, 예금주 등의 정보를 통해 토스페이먼츠 측에서 제대로 된 정보인지 검증 후 알려주게 된다.

잘못된 서브몰 생성 요청 시 응답 전문

Service 서브몰 등록 구현

@Transactional
public SubmallResDto registSubmall(SubmallReqDto submallReqDto) {

    Optional<Submall> submallOptional = submallRepository
            .findByCrdiEmailAndActivateTrue(submallReqDto.getCrdiEmail());
    if (submallOptional.isPresent()) {
        throw new BussinessException(ExMessage.SUBMALL_ERROR_ALREADY_REGIST);
    }

    if (!(submallReqDto.getType().equals("법인") || submallReqDto.getType().equals("개인"))) {
        throw new BussinessException(ExMessage.SUBMALL_ERROR_WRONG_BUSINESS_NUMBER);
    }

    if (submallReqDto.getType().equals("법인") && submallReqDto.getBusinessNumber().length() != 10) {
        throw new BussinessException(ExMessage.SUBMALL_ERROR_WRONG_BUSINESS_NUMBER);
    }

    RestTemplate rest = new RestTemplate();
    HttpHeaders headers = new HttpHeaders();
    String encodedAuth = new String(Base64.getEncoder().encode((testSecretApiKey + ":").getBytes(StandardCharsets.UTF_8)));
    headers.setBasicAuth(encodedAuth);
    headers.setContentType(MediaType.APPLICATION_JSON);
    headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));

    TosspaymentSubmallReq submallReq = submallReqDto.toTossReq();
    TosspaymentSubmallRes subMallRes;
    try {
        subMallRes = rest.exchange(
                tossOriginUrl + "/payouts/sub-malls",
                HttpMethod.POST,
                new HttpEntity<>(new Gson().toJson(submallReq), headers),
                TosspaymentSubmallRes.class
        ).getBody();
        if (subMallRes == null) throw new BussinessException("NULL");
    } catch (Exception e) {
        e.printStackTrace();
        String errorResponse = e.getMessage().split(": ")[1];
        String errorMessage = new Gson()
                .fromJson(
                        errorResponse.substring(1, errorResponse.length() - 1),
                        TossErrorDto.class
                ).getMessage();
        throw new BussinessException(errorMessage);
    }

    try {
        Submall submall = submallReqDto.toEntity(submallReq.getSubMallId());
        memberRepository.findByEmailFJ(submallReqDto.getCrdiEmail())
                .ifPresentOrElse(
                        C -> C.addSubmalls(submall)
                        , () -> {
                            throw new BussinessException(ExMessage.MEMBER_ERROR_NOT_FOUND);
                        });
        return submall.toDto();
    } catch (Exception e) {
        throw new BussinessException(ExMessage.DB_ERROR_SAVE);
    }
}
  • 먼저 해당 회원 앞으로 활성화된 서브몰이 존재하는지 확인한다.
  • 서브몰을 등록하기 위해 필요한 정보를 담은 객체를 토스페이먼츠 측에 요청을 위한 형태로 변경 후 보낸다. 정상적으로 이뤄지면 해당 회원에 서브몰 연관관계를 맺어줌으로서 레포지토리에 저장한다.
  • 서브몰 등록을 요청할 때 RequestBody에 JSON 형식으로 요청 데이터를 담아서 보내야 한다. 따라서 HttpEntity의 body부분에 Gson을 이용해서 JSON문자열을 담아줬다.
  • 저장 후 응답객체로 변환하여 반환해준다. 만약 토스페이먼츠 측에서 에러를 감지하여 전달하면 해당 내용을 반환하게 된다.

서브몰 등록 테스트

이제 서브몰 등록 API를 완성했으니 테스트를 진행해 보겠다. 필요로 하는 값들을 넣고 요청을 보낸다.

테스트 실패 정리

사실 위에처럼 하면 잘 요청이 가고 등록도 된다. 하지만 나는 두가지 이유로 실패했는데 해당 부분을 정리하고자 한다.

  1. 위에서는 요청 시 type 매개변수를 통해 (개인/법인)을 넣어주고 있지만 처음에는 넣지 않았었다.
  2. 서브몰을 구분하기 위한  고유한 키로서 필요한 subMallId에 UUID를 자동생성 후 넣어줬었다.

1. TYPE

참고로 이 당시에는 토스페이먼츠의 서브몰 등록 API 설명 문서에 TYPE 관련 설명이 나와있지 않았습니다.

 

먼저 TYPE 관련 문제를 살펴보자. 필자는 공식 문서에 나와있는대로 필수값들을 다 넣고 요청을 진행했었다. 그런데 요청을 하면 필수 파라미터라고 나와있는 부분을 다 입력했음에도 불구하고 필수 파라미터를 누락했다는 에러메시지가 반환되어 다시 토스페이먼츠 공식 문서를 찾아보았다.

TYPE 관련 내용은 없다.

공식문서에는 위와 같이 서브몰 아이디, 계좌정보 등이 필수라고 되어있는 것을 볼 수 있다. 현재 나는 서브몰 ID를 UUID로 만들어주고 있으므로 입력할 필요는 없었고 그 외 정보는 모두 입력해줬는데 도데체 어디가 누락되었다는 것인지 알 수가 없었다. 헤더에도 Basic 인가코드를 제대로 넣어주고 있었으므로 인가코드 문제 또한 아니었다.

 

그런데 토스페이먼츠의 API 테스트를 확인해보니 정작 테스트에서는 앞에 나와있던 서브몰 등록 API와 다른 파라미터를 추가적으로 받고있었고 필수 여부도 달랐다.

 

처음보는 account.holderName, type 파라미터가 존재하는 것을 볼 수 있다. holderName이야 필수값이 아니니 논외하더라도, API 문서에서는 못보던 필수 파라미터 TYPE이 추가된 것이 문제였다.

여기서 테스트를 해봐도 필수 파라미터가 누락되었다고 나오는 것을 확인할 수 있었다. TYPE에 필요한 값이 뭔질 모르니 당연히 누락일 수 밖에 없긴하다.

TYPE에 어떠한 값이 들어가야되는지 전혀 알 수가 없었으므로 결국 토스페이먼츠 측에 문의를 넣어보았다.

이렇게 문의 후 기다려보았고

연락이 왔다. 따로 공유는 받지 못했지만 공식문서를 확인해보니 변경된 것을 볼 수 있었다.

이렇게 type에 대한 정의가 추가되었고, type이 개인/법인 일 때에 맞춰서 어떤 값들이 필수인지 추가 되었다. 결국 공식문서에 정확한 내용이 나와있지 않아서 문의를 넣었었고, 그 결과 API에 맞게 정확하게 문서가 수정된 것을 확인할 수 있었다.

2. subMallId

서브몰을 구분하기 위한 고유한 값으로 필수 값이다. 필자는 해당 값을 따로 사용자에게 받지 않고 서브몰을 신청하면 UUID를 통해 중복이없는 값을 자동 생성 후 넣어주도록 했다. 하지만 지속적으로 등록에 실패하였다.

 

알고보니 subMallId로 가질 수 있는 최대 길이가 20byte 였고, uuid는 24byte이므로 실패하게 된 것이였다. 이에 날짜 & 시간을 이용한 방식으로 서브몰id를 생성 후 넣어주도록 변경하였다. 문서에 subMallId가 최대로 가질 수 있는 길이를 알려줬으면 더 좋았을거 같다.

 

어쨋든 위와같은 방법으로 두가지 오류를 해결할 수 있었다.


테스트

요청 후 정상적으로 등록 후 반환된 것을 볼 수 있다.


Reference

https://docs.tosspayments.com/guides/apis/payout-submall#%EC%84%9C%EB%B8%8C%EB%AA%B0-%EC%A0%95%EB%B3%B4-%EA%B4%80%EB%A6%AC%ED%95%98%EA%B8%B0.

반응형
Comments
반응형
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday