[프로젝트] 개발자 포트폴리오 - 9. [백엔드] Controller, Service, Repository 생성

개발자 포트폴리오 프로젝트

9. [백엔드] Controller, Service, Repository 생성

전체 소스 - https://github.com/vividswan/Portfolio-For-Developers


포트폴리오 컨트롤러, 서비스, 리포지터리 생성

source commit - 1b416cf

PortfolioController.java

package com.portfolio.backend.portfolio.contoller;

// ... import 생략

@Api(tags = {"Portfolio API"})
@RestController
@RequiredArgsConstructor
@RequestMapping(value = "/api")
public class PortfolioController {

    private final PortfolioService portfolioService;

    @ApiOperation(value = "포트폴리오 한 건 조회 API", notes = "조회하는 계정의 닉네임 전송")
    @GetMapping(value = "/portfolio/{nickname}")
    public CommonResponse getOnePort(@ApiParam(value = "포트폴리오를 조회할 사용자 nickname", required = true) @PathVariable String nickname){
        return portfolioService.getOnePort(nickname);
    }

    @ApiOperation(value = "포트폴리오 다 건 조회 API")
    @GetMapping(value = "/portfolios")
    public CommonResponse getAllPort(@PageableDefault Pageable pageable){
        return portfolioService.getAllPort(pageable);
    }

    @ApiImplicitParams({
            @ApiImplicitParam(name = "X-AUTH-TOKEN", value = "로그인 성공 후 토큰", required = false, dataType = "String", paramType = "header")
    })
    @ApiOperation(value = "포트폴리오 정보 변경", notes = "기술스택, 경력, 학교, 프로젝트를 제외한 나머지 정보 수정")
    @PutMapping(value = "/portfolio")
    public CommonResponse updatePort(@ApiParam(value = "포트폴리오 정보 변경 객체", required = true) @RequestBody @Valid PortUpdateRequest portUpdateRequest, Errors errors){
        if(errors.hasErrors()) throw new CustomValidationException();
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        String email = authentication.getName();
        return portfolioService.updatePort(portUpdateRequest, email);
    }
}

계정의 nickname으로 조회, 다 건(모든 포트폴리오) 조회, 포트폴리오 수정을 하는 컨트롤러를 생성했다.
이때 수정에 대한 Request DTO의 설계대로 수정은 일대다로 매핑된 TechStack과 Project 컬렉션을 제외한 나머지 포트폴리오의 정보를 수정할 수 있다.
기본적으로 조회는 인증이 안 된 사용자도 할 수 있도록 하였고, 수정은 해당 포트폴리오의 계정이 인증되었는지 확인해 주기 위해 Service 단에 계정의 email을 전달하였다.
또한 DTO에 대한 Vaildation 확인(형식검사)도 컨트롤러에서 진행한다.

PortfolioService.java

package com.portfolio.backend.portfolio.service;

// ... import 생략

@RequiredArgsConstructor
@Transactional
@Service
public class PortfolioService {

    private final ResponseService responseService;
    private final PortfolioRepository portfolioRepository;

    @Transactional(readOnly = true)
    public CommonResponse getOnePort(String nickname) {

        Portfolio portfolio = portfolioRepository.findPortfolioByAccountNickname(nickname)
                .orElseThrow(CustomUserNotFoundException::new);

        return responseService.getSingleResponse(new PortfolioResponse(portfolio));
    }

    @Transactional(readOnly = true)
    public CommonResponse getAllPort(Pageable pageable) {
        Page<Portfolio> portfolios = portfolioRepository.findAll(pageable);
        return responseService.getPageResponse(portfolios.map(PortfolioResponse::new));
    }

    public CommonResponse updatePort(PortUpdateRequest portUpdateRequest, String email) {

        Portfolio portfolio = portfolioRepository.findPortfolioByEmail(email)
                .orElseThrow(CustomUserNotFoundException::new);

        portfolio.updatePortInfo(portUpdateRequest);

        return responseService.getSuccessResponse();
    }
}

DTO에 대한 형식검사는 Controller 객체에서 진행했지만, 유저 정보 조회, 포트폴리오 조회 등의 논리 검사는 Service 객체에서 진행하였다.
조회의 기능만 하는 메서드에는 @Transactional(readOnly = true)으로 효율성을 보장하려 하였다.
수정 시 해당 포트폴리오의 주인인지에 대한 확인은 Controller 객체에서 넘겨준 email로 확인하였다.
Service의 응답 값은 ResponseService 객체로 감싸서 return 해준다.
이때,

    return responseService.getSingleResponse(new PortfolioResponse(portfolio));

다음과 같이 하나의 포트폴리오 객체는 Response Dto 객체의 생성자를 통해 리턴해주고,

    return responseService.getPageResponse(portfolios.map(PortfolioResponse::new));

다음과 같은 Page<Entity> 타입의 리턴 값은, map을 이용해 Page<Response DTO> 타입의 값으로 리턴한다.

PortfolioRepository.java

package com.portfolio.backend.portfolio.repository;

// ... import 생략

public interface PortfolioRepository extends JpaRepository<Portfolio, Long> {

    Optional<Portfolio> findPortfolioByEmail(String email);

    Optional<Portfolio> findPortfolioByAccountNickname(String accountNickname);

    Optional<Portfolio> findPortfolioById(Long id);

    Page<Portfolio> findAll(Pageable pageable);

}

다음과 같이 필요한 메서드를 선언하여 리포지터리를 만들었다.
Return 값이 Page 타입일 땐 Pageable 타입의 값을 인자로 넣어줘야 한다.


커리어 컨트롤러, 서비스, 리포지터리 생성

source commit - 3bf65a8

CareerController.java

package com.portfolio.backend.portfolio.contoller;

// ... import 생략

@Api(tags = {"Career API"})
@RestController
@RequiredArgsConstructor
@RequestMapping(value = "/api")
public class CareerController {

    private final CareerService careerService;

    @ApiImplicitParams({
            @ApiImplicitParam(name = "X-AUTH-TOKEN", value = "로그인 성공 후 토큰", required = false, dataType = "String", paramType = "header")
    })
    @ApiOperation(value = "커리어 생성", notes = "시작 날짜, 이름, 완료 유무 -> 필수 값, 경로에 dType 명시해야함")
    @PostMapping("/career/{dType}")
    public CommonResponse createCareer(@ApiParam(value = "dType", required = true)
                                            @PathVariable DType dType, @ApiParam(value = "커리어 생성 정보 객체", required = true) @RequestBody @Valid CareerDataRequest dto, Errors errors){
        if(errors.hasErrors()) throw new CustomValidationException();
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        String email = authentication.getName();
        return careerService.createCareer(email, dto, dType);
    }


    @ApiOperation(value = "포트폴리오 커리어 전체 조회", notes = "조회하는 프로젝트의 포트폴리오의 ID 전송, 경로에 dType 명시해야함")
    @GetMapping("/career/portfolio/{portfolioId}/{dType}")
    public CommonResponse getAllCareer(@ApiParam(value = "포트폴리오 ID", required = true) @PathVariable Long portfolioId,
                                        @ApiParam(value = "dType", required = true) @PathVariable DType dType, @PageableDefault(size = 5) Pageable pageable){
        return careerService.getAllCareer(portfolioId, pageable,dType);
    }

    @ApiOperation(value = "커리어 한 건 조회", notes = "조회하는 커리어의 DType, ID 전송")
    @GetMapping("/career/{dType}/{careerId}")
    public CommonResponse getOneCareer(@ApiParam(value = "dType", required = true) @PathVariable DType dType ,
                                       @ApiParam(value = "커리어 ID", required = true) @PathVariable Long careerId){
        return careerService.getOneCareer(dType, careerId);
    }

    @ApiImplicitParams({
            @ApiImplicitParam(name = "X-AUTH-TOKEN", value = "로그인 성공 후 토큰", required = false, dataType = "String", paramType = "header")
    })
    @ApiOperation(value = "커리어 수정", notes = "시작 날짜, 이름, 완료 유무 -> 필수 값, dType 명서 ")
    @PutMapping("/career/{dType}/{careerId}")
    public CommonResponse updateCareer(@ApiParam(value = "dType", required = true) @PathVariable DType dType ,
                                       @PathVariable Long careerId,@ApiParam(value = "커리어 정보 수정 객체", required = true)
    @RequestBody @Valid CareerDataRequest dto ,Errors errors){
        if(errors.hasErrors()) throw new CustomValidationException();
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        String email = authentication.getName();
        return careerService.updateCareer(email, careerId, dto, dType);
    }

    @ApiImplicitParams({
            @ApiImplicitParam(name = "X-AUTH-TOKEN", value = "로그인 성공 후 토큰", required = false, dataType = "String", paramType = "header")
    })
    @ApiOperation(value = "커리어 삭제", notes = "삭제 할 커리어 Id 전달")
    @DeleteMapping("/career/{careerId}")
    public CommonResponse deleteCareer(@PathVariable Long careerId){
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        String email = authentication.getName();
        return careerService.deleteCareer(email, careerId);
    }
}

커리어의 경우에는 Project, School, Company 를 상속하고 있으므로 Delete 메소드를 제외한 모든 HTTP 메소드에서 dType으로 셋 중 어떤 것을 요구하는지 구분하게 설계하였다.
CareerDataRequest의 경우엔 Project, School, Company가 담아야 하는 모든 정보를 필드에 갖고 있기 때문에 해당 Request DTO를 이용해서 커리어 생성 및 수정이 가능하다.
포트폴리오와 마찬가지로 조회에 대한 요청은 인증이 필요 없게 설계하였고, 인증이 필요한 메소드는 서비스에 email 값을 전달한다.

CareerService.java

package com.portfolio.backend.portfolio.service;

// ... import 생략

@RequiredArgsConstructor
@Transactional
@Service
public class CareerService {

    private final PortfolioRepository portfolioRepository;
    private final AccountRepository accountRepository;
    private final CareerRepository careerRepository;
    private final ResponseService responseService;

    @Transactional(readOnly = true)
    public CommonResponse getAllCareer(Long portfolioId, Pageable pageable, DType dType) {

        Portfolio portfolio = portfolioRepository.findPortfolioById(portfolioId)
                .orElseThrow(CustomDataNotFoundException::new);

        if (dType.equals(DType.P)) {
            Page<Project> projects =careerRepository.findAllProjectByPortfolio(portfolio, pageable);
            Page<ProjectResponse> projectResponses = projects.map(ProjectResponse::new);
            return responseService.getPageResponse(projectResponses);
        }

        else if (dType.equals(DType.C)) {
            Page<Company> companies = careerRepository.findAllCompanyByPortfolio(portfolio, pageable);
            Page<CompanyResponse> commonResponses  = companies.map(CompanyResponse::new);
            return responseService.getPageResponse(commonResponses);
        }

        else {
            Page<School> schools = careerRepository.findAllSchoolByPortfolio(portfolio, pageable);
            Page<SchoolResponse> schoolResponses = schools.map(SchoolResponse::new);
            return responseService.getPageResponse(schoolResponses);
        }
    }




    @Transactional(readOnly = true)
    public CommonResponse getOneCareer(DType dType, Long careerId) {

        if (dType.equals(DType.P)) return responseService
                .getSingleResponse(new ProjectResponse
                        (careerRepository.findProjectById(careerId)
                        .orElseThrow(CustomDataNotFoundException::new)));

        else if (dType.equals(DType.C)) return responseService
                .getSingleResponse(new CompanyResponse
                        (careerRepository.findCompanyById(careerId)
                        .orElseThrow(CustomDataNotFoundException::new)));

        else return responseService
                    .getSingleResponse(new SchoolResponse
                            (careerRepository.findSchoolById(careerId)
                            .orElseThrow(CustomDataNotFoundException::new)));

    }

    public CommonResponse createCareer(String email, CareerDataRequest dto, DType dType) {

        Account account = accountRepository.findAccountByEmail(email)
                .orElseThrow(CustomUserNotFoundException::new);

        Portfolio portfolio = portfolioRepository.findPortfolioByEmail(email)
                .orElseThrow(CustomDataNotFoundException::new);

        if (dType.equals(DType.P)) {
            Project project = dto.getProjectEntity(account, portfolio);
            careerRepository.save(project);
            return responseService.getSingleResponse(new ProjectResponse(project));
        } else if (dType.equals(DType.C)) {
            Company company = dto.getCompanyEntity(account, portfolio);
            careerRepository.save(company);
            return responseService.getSingleResponse(new CompanyResponse(company));
        } else {
            School school = dto.getSchoolEntity(account, portfolio);
            careerRepository.save(school);
            return responseService.getSingleResponse(new SchoolResponse(school));
        }


    }

    public CommonResponse updateCareer(String email, Long careerId, CareerDataRequest dto, DType dType) {

        Account account = accountRepository.findAccountByEmail(email)
                .orElseThrow(CustomUserNotFoundException::new);

        Career career = careerRepository.findCareerById(careerId)
                .orElseThrow(CustomDataNotFoundException::new);

        if (!account.getNickname().equals(career.getAccountNickname())) throw new CustomNotOwnerException();

        if (dType.equals(DType.P)) {
            Project project = careerRepository.findProjectById(careerId)
                    .orElseThrow(CustomDataNotFoundException::new);

            project.updateProject(dto);
        } else if (dType.equals(DType.C)) {
            Company company = careerRepository.findCompanyById(careerId)
                    .orElseThrow(CustomDataNotFoundException::new);

            company.updateProject(dto);
        } else {
            School school = careerRepository.findSchoolById(careerId)
                    .orElseThrow(CustomDataNotFoundException::new);

            school.updateProject(dto);
        }

        return responseService.getSuccessResponse();

    }

    public CommonResponse deleteCareer(String email, Long careerId) {
        Account account = accountRepository.findAccountByEmail(email)
                .orElseThrow(CustomUserNotFoundException::new);

        Career career = careerRepository.findCareerById(careerId)
                .orElseThrow(CustomDataNotFoundException::new);

        if (!account.getNickname().equals(career.getAccountNickname())) throw new CustomNotOwnerException();

        careerRepository.deleteById(careerId);

        return responseService.getSuccessResponse();
    }
}

각 메소드마다 DType을 확인한 뒤 분기문을 통해 DType에 맞는 객체와 관련된 작업을 수행하게 하였다.
커리어 객체도 포트폴리오와 동일한 방식으로 리턴 시 Entity가 아닌 Response DTO를 리턴하게 설계했다.

CareerRepository.java

package com.portfolio.backend.portfolio.repository;

// ... import 생략

public interface CareerRepository extends JpaRepository<Career, Long> {

    Optional<Career> findCareerById(Long id);

    Page<Project> findAllProjectByPortfolio(Portfolio portfolio, Pageable pageable);
    Optional<Project> findProjectById(Long id);

    Page<School> findAllSchoolByPortfolio(Portfolio portfolio, Pageable pageable);
    Optional<School> findSchoolById(Long id);

    Page<Company> findAllCompanyByPortfolio(Portfolio portfolio, Pageable pageable);
    Optional<Company> findCompanyById(Long id);

}

Repository에서 조회 시 필요한 메소드들을 선언하였다.


TechStack 컨트롤러, 서비스, 리포지터리 생성

source commit - 3f0236b

TechStackService.java

package com.portfolio.backend.portfolio.contoller;

// ... import 생략

@Api(tags = {"TechStack API"})
@RestController
@RequiredArgsConstructor
@RequestMapping(value = "/api")
public class TechStackController {

    private final TechStackService techStackService;

    @ApiImplicitParams({
            @ApiImplicitParam(name = "X-AUTH-TOKEN", value = "로그인 성공 후 토큰", required = false, dataType = "String", paramType = "header")
    })
    @ApiOperation(value = "기술 스택 생성", notes = "Techstack Create Request로 전달")
    @PostMapping("/techStack")
    public CommonResponse createCareer(
            @ApiParam(value = "기술 스택 생성 request", required = true) @RequestBody TechStackCreateRequest dto, Errors errors){

        if(errors.hasErrors()) throw new CustomValidationException();
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        String email = authentication.getName();

        return techStackService.createTechStack(email, dto.getDetailsType(), dto.getRequestId(), dto.getTechType());
    }

    @ApiOperation(value = "기술 스택 다건 조회", notes = "포트폴리오 or 프로젝트 기술 스택 다건 조회")
    @GetMapping("techStack/{detailsType}/{id}")
    public CommonResponse getTechStacks(@ApiParam(value = "포트폴리오 or 프로젝트", required = true) @PathVariable DetailsType detailsType,
                                        @ApiParam(value = "포트폴리오 or 프로젝트 id", required = true) @PathVariable Long id){
        return techStackService.getTechStacks(detailsType, id);
    }

    @ApiImplicitParams({
            @ApiImplicitParam(name = "X-AUTH-TOKEN", value = "로그인 성공 후 토큰", required = false, dataType = "String", paramType = "header")
    })
    @ApiOperation(value = "기술 스택 삭제", notes = "포트폴리오 or 프로젝트 기술 스택 삭제")
    @DeleteMapping("techStack/{detailsType}/{id}")
    public CommonResponse deleteTechStack(@ApiParam(value = "포트폴리오 or 프로젝트", required = true) @PathVariable DetailsType detailsType,
            @ApiParam(value = "기술 스택 id", required = true) @PathVariable Long id){

        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        String email = authentication.getName();

        return techStackService.deleteTechStack(email, detailsType ,id);
    }
}

TechStack의 경우에는 포트폴리오 Entity나 프로젝트 Entity 중 하나에서 조회를 하기 때문에 이를 DeetailsType 으로 구분하도록 설계했다.
그 외는 포트폴리오와 커리어 Controller와 동일한 설계로 구현했다.

TechStackService.java

package com.portfolio.backend.portfolio.service;

// ... import 생략

@Transactional
@RequiredArgsConstructor
@Service
public class TechStackService {

    private final TechStackRepository techStackRepository;
    private final CareerRepository careerRepository;
    private final PortfolioRepository portfolioRepository;
    private final AccountRepository accountRepository;
    private final ResponseService responseService;

    public CommonResponse createTechStack(String email, DetailsType detailsType, Long requestId, TechType techType) {

        TechStack techStack = null;

        Account account = accountRepository.findByEmail(email)
                .orElseThrow(CustomUserNotFoundException::new);

        if (detailsType.equals(DetailsType.portfolio)) {
            Portfolio portfolio = portfolioRepository.findPortfolioById(requestId)
                    .orElseThrow(CustomDataNotFoundException::new);

            if(!portfolio.getAccount().equals(account)) throw new CustomDataNotFoundException();

            if(techStackRepository.findByPortfolioAndTechType(portfolio, techType).isPresent()) return responseService.getFailResponse("중복 된 기술 스택입니다.");
            techStack = new TechStack(portfolio, techType);

            techStackRepository.save(techStack);

        } else if (detailsType.equals(DetailsType.project)) {
            Project project = careerRepository.findProjectById(requestId)
                    .orElseThrow(CustomDataNotFoundException::new);

            if(!project.getAccount().equals(account)) throw new CustomDataNotFoundException();

            if(techStackRepository.findByProjectAndTechType(project, techType).isPresent()) return responseService.getFailResponse("중복 된 기술 스택입니다.");
            techStack = new TechStack(project, techType);

            techStackRepository.save(techStack);
        }

        return responseService.getSingleResponse(new TechStackResponse(techStack));
    }

    @Transactional(readOnly = true)
    public CommonResponse getTechStacks(DetailsType detailsType, Long id) {

        List<TechStack> techStacks = null;

        if (detailsType.equals(DetailsType.portfolio)) {

            Portfolio portfolio = portfolioRepository.findPortfolioById(id)
                    .orElseThrow(CustomDataNotFoundException::new);

            techStacks = techStackRepository.findAllByPortfolio(portfolio);
        } else if (detailsType.equals(DetailsType.project)) {

            Project project = careerRepository.findProjectById(id)
                    .orElseThrow(CustomDataNotFoundException::new);

            techStacks = techStackRepository.findAllByProject(project);
        }

        return responseService.getListResponse(techStacks.stream().map(TechStackResponse::new).collect(Collectors.toList()));
    }

    public CommonResponse deleteTechStack(String email, DetailsType detailsType ,Long id) {
        Account account = accountRepository.findAccountByEmail(email)
                .orElseThrow(CustomUserNotFoundException::new);

        TechStack techStack = techStackRepository.findById(id)
                .orElseThrow(CustomDataNotFoundException::new);

        if(techStack.isAuthorized(detailsType, account)){
            techStackRepository.delete(techStack);
            return responseService.getSuccessResponse();
        }
        else throw new CustomNotOwnerException();
    }
}

서비스단도 Response DTO로 리턴, 논리 검사 등이 동일하다.
조회하는 자료가 없을 땐 CustomDataNotFoundException 예외를 throw 해준다.
isAuthorized(detailsType, account) 메서드는 TechStack의 도메인 영역에서 구현해 줄 예정이다.

TechStackRepository.java

package com.portfolio.backend.portfolio.repository;

// ... import 생략

public interface TechStackRepository extends JpaRepository<TechStack, Long> {

    List<TechStack> findAllByPortfolio(Portfolio portfolio);
    List<TechStack> findAllByProject(Project project);
    Optional<TechStack> findByPortfolioAndTechType(Portfolio portfolio,TechType techType);
    Optional<TechStack> findByProjectAndTechType(Project project, TechType techType);
}

필요한 자료를 조회할 수 있는 메소드를 만들어줬으며, 포트폴리오에서 찾을 땐 포트폴리오 객체를, 프로젝트에서 찾을 땐 프로젝트 객체를 인자로 넣어준다.


Get 요청 경로 Security에 허용 추가, TechStack 도메인 영역에 Account Check 메소드 추가

source commit - 3a953ec

SecurityConfig.java

    // ...
    .antMatchers(HttpMethod.GET,"/api/portfolio/**","/api/portfolios","/api/career/**","/api/techStack/**").permitAll()
    // ...

WebSecurityConfigurerAdapter를 상속한 SecurityConfig.java의 http configure 메소드에 다음과 같은 antMatchers를 추가하여 조회는 인증되지 않은 사용자도 할 수 있도록 변경해 준다.

TechStack.java


  // ...

  public boolean isAuthorized(DetailsType detailsType, Account account){
        if(detailsType.equals(DetailsType.portfolio)){
            if(this.portfolio.getAccount().equals(account)) return true;
            else return false;
        }
        else{
            if(this.project.getAccount().equals(account)) return true;
            else return false;
        }
    }

  // ...  

TechStack의 도메인 영역에 다음과 같이 TechStack의 인증된 사용자임을 확인하는 isAuthorized 메소드를 만들었다.