[새배내] 지하철 노선도 관리 미션을 하면서 새로 배운 내용

Junseo Kim·2021년 5월 8일
0

[우아한테크코스3기]

목록 보기
18/27
post-thumbnail

2021.05.04 ~ 2021.05.11


단위 테스트, 통합 테스트, E2E 테스트, 인수 테스트

단위 테스트 : 가장 작은 단위의 테스트를 말하며 보통 메서드 레벨 테스트를 말한다. 즉각적인 피드백을 알 수 있다. 하나의 단위가 잘 동작한다는 것을 보장할 수는 있으나, 결합되었을때 잘 동작한다는 것은 보장할 수 없다.

통합 테스트 : 여러 모듈을 통합한 테스트. 모듈간의 호환성 문제를 찾아내기 위한 테스트. 스프링에서는 @SpringBootTest 어노테이션을 사용하여 전체적인 흐름을 테스트 할 수 있다.

E2E 테스트 : 해당 시스템과 해당 시스템을 구축하고 배포하는 프로세스를 모두 테스트하는 것. 세세한 내부 기능들까지 테스트할 필요는 없다. 테스트를 만들기 힘들고, 신뢰하기도 힘들다. (가장 바깥 레이어에서 가장 안쪽 레이어까지의 총 흐름을 테스트한다고 이해했다. request - controller - service - dao - service - controller - response)

인수 테스트 : 클라이언트에 초점을 맞춘 테스트. 실제로 서버에 요청을 보내서 정상작동하는지 테스트한다. 인수 테스트시 CRUD 요청을 따로 메서드 추출해서 상위클래스에 두고 사용하면 중복을 줄일 수 있다. 허나 이렇게 상위 클래스로 추출해서 사용할때 상위 클래스에는 특정 인수테스트와 연관된 메서드는 두면 안된다.(예를 들어, findLineRequest와 같이 Line에 종속적인 메서드)

테스트를 작성하는 이유 중 하나가 빠른 피드백이다. 그러나 E2E나 통합테스트는 오래 걸린다. 가능하면 단위테스트로 처리하자!

솔직히 지금은, 통합 테스트나, E2E 테스트나, 인수테스트나 크게 다른 점을 못 느끼겠다. 현재는 각 도메인의 단위테스트, 각 서비스 계층의 단위테스트, 인수테스트 이렇게만 해줘도 충분하다고 생각이 든다.

+) 이미 테스트하여 검증된 단위는 잘 동작한다고 생각하고 의존관계에 있는 단위를 테스트할 수 있다는 의견도 있다. 예를 들어 Service 계층 단위 테스트시 Dao를 의존하고 있다면, Dao가 필요한데, Dao에서 JdbcTemplate을 사용한다고 했을 때, JdbcTemplate이 잘 동작하니까 제대로 동작한다고 믿고 사용할 경우 Dao를 Mocking을 하지 않고도 Service 계층의 단위테스트가 가능하다는 의견이다.

트랜잭션 처리

[정리] Transaction(트랜잭션)

Dto를 얼마나 만들어야 할까?

실무에서는 각 api 메서드마다 dto를 따로 만든다고 한다. 각 api 메서드의 request / response 도 나눠서 만들어준다고 한다. 하나의 Dto로 요청을 처리하다가 새로운 요구사항이 생겨 Dto에 변경이 생기면, 해당 Dto를 사용하는 모든 객체에 영향이 가기 때문이다.

Log를 찍는 위치

로그를 쌓는 목적이 무엇인지에 따라 어느 계층에 로그를 남기는지 결정한다.

http request에 대한 정보 -> controller
dao 반환 값 -> service

개발할 때는 보통 console로 로그를 확인하는 걸로 충분하다.

개발, 운영서버로 올렸을 때 logback 같은 로깅 프레임워크를 사용해서 각 서버에 파일로 저장한다.

디스크 용량이 제한이 있으니 일정 주기마다 삭제하거나 aws cloudwatch, elk 같은 로그를 쌓고 모니터링하고 분석하는 서비스 이용한다.

로그를 찍을때는 임의의 로그명을 설정하는 것 보다 어느 클래스에서 남긴 로그인지 파악할 수 있도록 하는 게 좋다.

public static Logger getLogger(Class<?> clazz)

ex) private static final Logger log = LoggerFactory.getLogger(LineService.class);

또 log.info 자체에 info 라는 정보가 포함되어있으므로 로그 메세지 작성시 "info message: ~~ " 등과 같이 info를 포함할 필요는 없다.

클래스에 포함된 정보로 메서드 중복 줄이기

LineService의 삭제를 담당하는 메서드는 deleteLine같은 이름보다 delete가 더 좋다. 왜냐하면 LineService 자체가 이미 Line에 대한 관리를 한다는 이름이기 때문이다.

또 예를 들어 테이블의 정보를 id값을 통해 수정할때, id와 수정할 정보를 넘기는 것 보다, 객체를 만들어 객체 자체를 넘기는 것이 객체지향적이다.

lineDao.update(id, updatedLine); // x

lineDao.update(updatedLine); // o - updatedLine이 id값을 포함하고 있음

Dao 클래스 명

보통 JdbcLineDao, InMemoryLineDao 등과 같이 세부사항(Jdbc, InMemory) 등을 앞에 작성해준다. 팀 컨벤션에 따라 차이는 있을 수 있다.

테스트 코드 한글 사용

테스트 코드도 유지보수해야 되는 코드이기 때문에 의미 있는 이름이 필요하다.
테스트 코드에 한글 변수명을 쓰면 좀더 읽기 쉽고 파악하기 좋다.

추가로 메서드 분리도 해주기!

given when then

given : 주어진 값
when : 발생 - 메서드 실행은 여기서 & 필요시 결과 추출하는 구문
then : 검증 - assertion 구문은 여기서

특정 데이터를 제외하는 쿼리

자기 자신의 이름을 제외하고 나머지 중에서 일치하는 이름이 있는지 찾아볼 때

SELECT name FROM line WHERE name = ? AND name NOT IN (?);

@TestPropertySource

테스트에서 특정 설정 정보를 사용하고 싶을 때 사용.

@TestPropertySource("classpath:application.yml")

Mockito

Mock(진짜 객체 처럼 동작하지만 프로그래머가 직접 컨트롤 할 수 있는 객체)을 지원하는 프레임워크이다.

Mock 객체를 만들고 관리하고 검증 할 수 있는 방법을 제공해준다.(가짜 객체를 만들어준다고 생각)

구현체가 아직 없는 경우나, 구현체가 있더라도 특정 단위만 테스트 하고 싶을 경우 주로 사용한다.

생성 방법


Mockito.mock(JdbcLineDao.class);

@Mock
JbcdLineDao jdbcLineDao;

@Test
void test(@Mock JdbcLineDao jdbcLineDao) {
    
}
  • given(Mock 객체 메서드 호출).willReturn(반환값) 으로 사용
  • verify(Mock 객체).메서드 로 해당 메서드가 호출되었는지 확인

MockMvc vs RestAssured

MockMvc와 RestAssured 모두 테스트를 용이하게 해주는 도구이다.

MockMvc는 주로 컨트롤러 단위 테스트에 사용한다. 요청을 보내면 실제 객체를 사용하는 것은 아니며 가짜 객체를 만들어서 요청을 처리한다.

RestAssured는 주로 REST 웹 서비스를 검증하는 라이브러리이며 주로 E2E 테스트에 사용한다.

참고
MockMvc VS RestAssured
SpringBoot의 MockMvc를 사용하여 GET, POST 응답 테스트하기

@Mock vs @MockBean

@Mock : 가짜 객체로 사용하겠다는 뜻

@MockBean : 가짜 Bean으로 사용하겠다는 뜻. 빈 컨테이너에 생성되어야만 하는 가짜 객체인 경우 사용

참고
@Mock vs @MockBean

application.yml 나누기

개발 환경에 따라 환경설정을 다르게 해줘야하는 경우가 있다. DB정보가 다를 수도 있고, 파일 경로도 다를 수 있다. 이런 경우에 환경설정 파일을 나눠서 관리해준다.

하나의 application.yml 파일에서 ---로 각 프로파일을 구분해서 환경설정을 할 수 있다.

spring:
  config:
    activate:
      on-profile: develop
  datasource:
    driver-class-name: org.h2.Driver
    url: jdbc:h2:mem:testdb
    username: sa
    password:
  h2:
    console:
      enabled: true

---

spring:
  config:
    activate:
      on-profile: production
  datasource:
    driver-class-name: com.mysql.jdbc.Driver
    url: jdbc:mysql://localhost:13306/chess?useSSL=false&serverTimezone=UTC&characterEncoding=utf8
    username: root
    password: root

application-dev.yml와 같이 application-{profile}.yml로 각각 파일로 만들어서 사용할 수도 있다.

참고
[ Spring ] 외부 설정 파일과 프로파일

무분별하게 Wrapper 클래스 사용 하지 않기

null이 허용될때만 Wrapper 클래스를 사용해라. null이 허용되지 않는 곳에 Wrapper 클래스를 사용하게 되면 NullPointerException이 발생할 수도 있다.

// 엔티티
public class Section {
    private Long id;
    private Line Line;
    private Station upStation;
    private Station downStation;
    private int distance; // Integer 사용 x, 거리가 null 일 수 없다.
    
}

dto의 경우도 @NotNull등의 조건을 준 경우 굳이 Long, Integer같은 wrapper 클래스를 쓸 필요가 없다.

public class LineCreateRequest {
    @NotBlank
    private String name;
    @NotBlank
    private String color;
    @NotNull
    private long upStationId;
    @NotNull
    private long downStationId;
    @NotNull
    private int distance;
    
}

jdbcTemplate의 queryForObject

jdbcTemplate의 queryForObject는 단일 조회 건에 사용되는 메서드이다. 그러나 조회시 데이터가 없는 경우에는 EmptyResultDataAccessException 예외가 발생해버린다..

이를 처리하기 위해 queryForObject대신 query 메서드를 사용하여 List로 반환받은후, stream().findAny()와 같은 식으로 처리해줄 수 있다.

하지만 query메서드의 경우 만약, 중복된 id가 DB에 들어가있어도 정상작동한다. 이럴 경우는 예외를 던져주는 것이 맞다. 따라서 queryForObject를 사용하고 try-catch 처리로 다른 예외를 던져주는 것이 더 낫다.

그렇다고 try-catch로 잡아서 Optional.empty()를 반환하거나 하여 service단에서 예외처리를 하는 것은 추천하지 않는다. 왜냐하면 이미 예외가 발생했는데, 예외검증을 다시 하는 꼴이기 때문이다.

Dao에서 예외처리

dao에서 try-catch이후에 다시 Optional을 반환 x

// 이런 경우
@Override
public Optional<Line> findById(Long id) {
    try {
        String query = "SELECT * FROM line WHERE id = ?";
        return Optional.ofNullable(jdbcTemplate.queryForObject(query, lineRowMapper(), id));
    } catch (EmptyResultDataAccessException e) {
        return Optional.empty(); // 이 부분
    }
}

새로운 예외를 던지거나 하는 방식을 사용. 왜냐하면 Optional.empty()로 던져주면 밖에서 한 번더 예외가 발생했는지를 검증해야하기 때문에

@Override
public Line findById(Long id) {
    try {
        String query = "SELECT * FROM line WHERE id = ?";
        return jdbcTemplate.queryForObject(query, lineRowMapper(), id);
    } catch (EmptyResultDataAccessException e) {
        throw new LineNotFoundExeption();
    }
}

추가로 jpa를 보면 findById 같은 메서드가 Optional을 리턴한다. 하지만 굳이 거기에 맞춰 사용할 필요는 없다고 한다. 이번 미션같은 경우 jdbcTemplate을 사용했는데, 단일 조회인 경우 queryForObject를 사용하여 굳이 Optional로 반환해줄 필요가 없었다.

의존관계 설계

1) 컨트롤러가 여러 서비스
2) 서비스가 다른 서비스
3) 서비스가 여러 Dao

다른 경우가 더 있을 수도 있지만, 이번 미션 설계시 이 3가지 구조를 놓고 고민을 했다.

처음에는 1) 컨트롤러가 여러 서비스 를 가지게 구현했었는데, 이 방법은 컨트롤러단에 로직이 들어가게 되었다. 결국 2), 3) 중 고민을 하다가 dao는 하나의 서비스에서 사용하는 게 좋아보여 2)을 선택했다.

실제로는 3가지 경우를 다 사용할 수 있고 상황에 맞게 사용하면 된다고 한다.(하지만 컨트롤러에는 로직이 들어가면 안된다.) 각 계층에서 처리해야 할 객체와 의존 관계를 가졌는지가 중요하다고 한다.

Controller에서는 http처리만

Service에서 다른 Service나 여러 Dao를 의존하는게 싫어서 Controller에서 여러 Service를 가지도록 구현했었다. 하지만 이 경우 흐름이라는 일종의 로직이 Controller에 들어가게 되었다.

Controller는 http 처리만 해줘야한다. 로직이 들어가는 경우 후에 컨트롤러 자체를 다시짜야할 수 있기 때문.

Queue 인터페이스의 poll()과 remove()의 차이점

둘다 큐에서 값을 꺼내는 동작을 하지만 큐가 비어있는 경우 poll()은 null을 반환하고 remove()는 NoSuchElementException을 반환한다.

또한 remove는 삭제한다는 의미가 강하므로, 꺼낸 후 다시 넣을 수도 있는 경우는 의미상으로 remove()보다 poll()을 쓰는 것이 좋다.

given - when - then

테스트 작성시 사용할 수 있는 방법. 가독성 있는 테스트를 작성할 수 있다.

  • given : 주어진 값
  • when : 발생
  • then : 검증

독립적인 테스트

1) @Transactional
@Transactional을 테스트 클래스나 메서드에 붙여주면 롤백이 실행된다. 하지만 auto_increment에 대한 롤백은 일어나지 않는다.

2) @Sql
@Sql을 테스트 클래스나 메서드에 붙여주면 테스트 실행전에 sql 스크립트를 실행할 수 있게 해준다. 테이블 자체를 DROP 시키는 식으로 초기화해줄 수 있다.

테스트시 동등성 비교

assertj의 usingRecursiveComparison()나 usingRecursiveFieldByFieldElementComparator() 같은 메서드를 사용하여 equals & hashcode 재정의 없이 객체의 동등성을 비교할 수 있다.

테이블은 양방향, 엔티티는 단방향

DB의 테이블은 외래키로 다른 테이블의 id(식별자)를 가지고 있다면 양방향이다. 즉 A join B와 B join A가 둘 다 가능하며 결과도 같다.

반면에 엔티티는 단방향이다.

public class Section {
    private Long id;
    private Line line;
    // ...
}
public class Line {
    private Long id;
    private String name;
    private String color;
    // ...
}

이런 관계가 있다면 Section에서는 Line을 참조할 수 있지만, Line에서는 Section을 참조할 수 없다!

Batch Insert

여러 insert 쿼리문을 한 번에 처리하는 것이며 Batch Insert는 하나의 트랜잭션으로 묶인다.

insert문을 최적화하려면 많은 작은 작업을 하나의 큰 작업으로 결합하여 처리한다. 하나의 커넥션을 만들고, 여러 행에 대한 데이터를 한 번에 보내며, 모든 index 업데이트와 consistency checking을 지연시키는 것이 이상적인 방법이다.

하나의 insert문을 실행할때 이런 부분에서 영향을 받는다고 한다.

Connecting: (3)
Sending query to server: (2)
Parsing query: (2)
Inserting row: (1 × size of row)
Inserting indexes: (1 × number of indexes)
Closing: (1)

매번 insert 쿼리를 날리는 것 보다, 한 번에 날리는 것이 이런 비용을 줄 일 수 있다.

(지하철 노선도 미션에 사용한 jdbcTemplate을 사용한 batch insert쿼리)

public void batchInsert(List<Section> sections) {
    String query = "INSERT INTO section (line_id, up_station_id, down_station_id, distance) VALUES (?, ?, ?, ?)";
    jdbcTemplate.batchUpdate(query,
            new BatchPreparedStatementSetter() {
                @Override
                public void setValues(PreparedStatement pstmt, int index) throws SQLException {
                    pstmt.setLong(1, sections.get(index).lineId());
                    pstmt.setLong(2, sections.get(index).upStationId());
                    pstmt.setLong(3, sections.get(index).downStationId());
                    pstmt.setInt(4, sections.get(index).getDistance());
                }

                @Override
                public int getBatchSize() {
                    return sections.size();
                }
            });
}

참고
insert와 bulk insert 무엇을 써야할까요?

패러다임 불일치 & 영속성 컨텍스트

[정리] 패러다임 불일치와 영속성 컨텍스트

@Valid 검증 예외는 ExceptionHandler로

@Valid로 넘어오는 데이터의 값들을 검증할 때 여러 가지 방법이 있다.

1) Errors 이용

@PostMapping
public ResponseEntity<LineCreateResponse> createLine(@RequestBody @Valid LineCreateRequest request,
                                                     Errors errors) {
    if (errors.hasErrors()) {
        // 예외 처리
    }
    LineCreateResponse response = lineService.create(request);
    return ResponseEntity.created(URI.create("/lines/" + response.getId()))
            .body(response);
}

2) bindingResult(Errors 인터페이스의 하위 인터페이스) 이용

@PostMapping
public ResponseEntity<LineCreateResponse> createLine(@RequestBody @Valid LineCreateRequest request,
                                                     BindingResult bindingResult) {
    if (bindingResult.hasErrors()) {
        // 예외 처리
    }
    LineCreateResponse response = lineService.create(request);
    return ResponseEntity.created(URI.create("/lines/" + response.getId()))
            .body(response);
}

위의 두 방식은 컨트롤러에서 메서드마다 if절로 조건을 체크하는 로직을 만들어야한다. 따라서 보통 validation 어노테이션 사용 시 exception advice에서 처리하도록 해준다. @Valid를 사용할 때 유효하지 않는 데이터인 경우 MethodArgumentNotValidException이 발생한다.

3) exception advice 사용

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<String> validationException(MethodArgumentNotValidException e) {
        BindingResult bindingResult = e.getBindingResult();

        StringBuilder builder = new StringBuilder();
        for (FieldError fieldError : bindingResult.getFieldErrors()) {
            builder.append(fieldError.getDefaultMessage());
            builder.append("/n");
        }
        String message = builder.toString();
        log.error(message);
        return ResponseEntity.badRequest().body(message);
    }

Fixtrue

테스트를 작성하면서 매번 새로운 객체를 생성해주는 과정이 중복이 많이 생긴다고 생각하고 미리 데이터를 생성해 놓은 클래스를 만들었다. 처음에는 한 클래스에 지하철 역, 노선, 구간 정보를 모두 생성했지만, 도메인 별로 나눠줬다. 한 곳에 모아둘 경우 클래스가 너무 비대해질 수 있기 때문이다.

public class StationFixture {
    public static Station 왕십리역 = new Station(1L, "왕십리역");
    public static Station 잠실역 = new Station(2L, "잠실역");
    public static Station 강남역 = new Station(3L, "강남역");
    public static Station 구의역 = new Station(4L, "구의역");
    public static Station 건대입구역 = new Station(5L, "건대입구");
    public static Station 한양대역 = new Station(6L, "한양대");
}

서비스 용도에 따른 반환값

Service 계층의 메서드는 구조에 따라 Controller - Service / Service - Service 에게 값을 리턴해준다.

나는 개인적으로 Controller - Service 사이 통신은 dto로 해주고, Service - Dao 사이의 통신은 엔티티나 도메인으로 해주는 게 맞다고 생각한다. Service - Service의 경우는 Dto를 사용해야할까 도메인을 사용해야할까 고민이 되었고 구구에게 질문했다.

저는 서비스도 용도에 따라 나누는 편이에요. 도메인 서비스는 도메인을 반환하도록 설계합니다. 이것도 개발자마다 설계하는데 차이가 있을 수 있어서 에어가 규칙을 정하고 구현하시면 됩니다. 나중에 현업에서는 팀에서 정한 룰을 따르시면 될거에요

현재 시점 정한 규칙은 Service 계층에 반환해 줄 때는 도메인을 반환해줘도 된다이다.

🤯 대규모 리팩토링

이번 미션을 하면서 구조자체를 바꿔야 할 필요가 있었다.. 그런데 한 곳을 바꾸니 빨간줄 테러를 당했다.. 결국 3번 정도 리팩토링을 다시 시작했다. 구구에게 리팩토링 관련 질문을 했고 답변을 첨부한다.

대규모 리팩토링이 쉽지 않죠. 현업에서도 대규모 리팩토링이 쉽지 않다보니 레거시가 축적되다가 차세대 프로젝트를 띄워서 다시 만들고 하는 안타까운 상황이 생기기도 하죠.

리팩토링을 하기 위해서 선행되어야 하는 일은 테스트 코드 작성입니다. 내가 수정한 내용이 정상적으로 동작하는지 확인하는게 오래 걸리고, 수동으로 매번 확인해야된다면 너무 힘들어요. 그냥 새로 만들고 말지라는 생각을 수차례하게 되죠 ㅎㅎ;

그래서 테스트 코드를 처음부터 작성해둬야 변경사항이 생겼을 때 두려움 없이 빠르게 개선해나갈 수 있습니다. 테스트 코드 작성도 쉬운 일은 아니지만 한 번에 크게 바꾸는 것보다 비용이 적습니다.

결론은 테스트 코드를 작성하시면 됩니다!

다음부터는 막 리팩토링을 수정하는 것이 아니라 수정된 상황에 맞게 테스트 코드를 작성 후, 리팩토링을 진행하는 식으로 진행해봐야겠다.

Entity

[정리] Entity(엔티티)

@DirtiesContext

@DirtiesContext는 빈을 초기화 해주는 것이다. 시간은 오래걸린다. 이 애노테이션이 없다면 컨텍스트 하나로 테스트에서 재사용하게 된다.

원래 빈의 설정이나 상태가 바뀌면 컨텍스트를 재생성하게 되는데 @DirtiesContext를 사용하면 강제로 빈의 설정이나 상태를 건드려서 컨택스트가 재생성 되게 하는 것이다.

순환 참조

서로 다른 여러 빈들이 서로에 대해 의존하고 있어, 필요한 빈을 주입받지 못하는 경우를 뜻한다.

예를 들어 서로 다른 2개의 서비스 계층이 서로에 대해 의존하고 있는 경우 발생할 수 있다.

ex)

@Service
public class AService {
    private final BService bService;
    
    public AService (BService bService) {
        this.bService = bService;
    }
    // ...
}
@Service
public class BService {
    private final AService aService;

    public BService (AService aService) {
      this.aService = aService;
    }
    // ...
}

AService의 빈이 메모리에 올라가기전에 BService의 빈이 AService의 빈을 의존주입하거나 그 반대의 경우 순환참조한다고 한다.

생성자를 통한 의존성 주입을 해주면, 객체 생성 시점에 순환참조가 일어나서 스프링 애플리케이션이 실행되지 않는다.

반면에 필드, 세터 의존 주입은 getter / setter 를 통해 의존 주입을 하게 되는데, 해당 객체가 메모리에 올라간 후에 빈을 주입하게 된다. 따라서 실행은 된다.

즉, 생성자 주입은 객체를 생성하는 동시에 빈을 주입하지만 필드, 세터 주입은 객체를 생성한 후 빈을 주입해준다.

참고
스프링 순환 참조(Circular Reference)
Spring - Field vs Constructor vs Setter Injection 그리고 순환참조(Circular Reference)

2개의 댓글

comment-user-thumbnail
2021년 5월 11일

잘 정리된 글 잘보고 갑니다.
저도 준서님과 같이 좋은 개발자가 되고 싶습니다.

1개의 답글