2021 11 10 놀토 정리
2021-11-10 놀토 정리¶
놀토의 예외처리 전략¶
- 1. 어플리케이션에서 발생하는 최상단 예외를 NoltoException으로 정의
@Getter public class NoltoException extends RuntimeException { private final HttpStatus httpStatus; private final ExceptionResponse body; public NoltoException(HttpStatus httpStatus, ErrorType errorType) { super(errorType.getMessage()); this.httpStatus = httpStatus; this.body = new ExceptionResponse(errorType.getErrorCode(), errorType.getMessage()); } }
- 2. ExceptionResponse를 만들어서 Exception의 body로 errorCode와 message를 줄 수 있도록 설정
@Getter public class ExceptionResponse { private String errorCode; private String message; public ExceptionResponse(String errorCode, String message) { this.errorCode = errorCode; this.message = message; } public static ExceptionResponse of(ErrorType errorType) { return new ExceptionResponse(errorType.getErrorCode(), errorType.getMessage()); } }
- 3. NoltoException을 상속하여 Error_Status_Code를 담은 에러를 만듬
public class InternalServerErrorException extends NoltoException { public static final HttpStatus INTERNAL_SERVER_ERROR = HttpStatus.INTERNAL_SERVER_ERROR; public InternalServerErrorException(ErrorType errorType) { super(INTERNAL_SERVER_ERROR, errorType); } } public class NotFoundException extends NoltoException { public static final HttpStatus NOT_FOUND = HttpStatus.NOT_FOUND; public NotFoundException(ErrorType errorType) { super(NOT_FOUND, errorType); } }
- 4. Enum으로 예외를 관리하기
- 예외 생성할 때 생성자로 해당 ErrorType을 넣어줘야함
- 프론트와 합의함에 따라, errorCode
@Getter @RequiredArgsConstructor public enum ErrorType { DATA_BINDING_ERROR("common-001", "요청값이 잘못되었습니다."), INVALID_TOKEN("auth-001", "유효하지 않은 토큰입니다."), FEED_NOT_FOUND("feed-001", "존재하지 않는 피드입니다."), NOT_SUPPORTED_IMAGE("image-001", "지원하지 않는 이미지 입니다."), ALREADY_LIKED("like-001", "이미 좋아요 누른 글 입니다."), COMMENT_NOT_FOUND("comment-001", "존재하지 않는 댓글입니다."), ALREADY_EXIST_NICKNAME("member-001", "이미 존재하는 닉네임입니다."), private final String errorCode; private final String message; }
- 5. ControllerAdvice에서 처리해주기
놀토의 테스트 전략¶
- 테스트 커버리지
-
- 테스트 전략 - Layer별 슬라이스 테스트 + RestAssured를 활용한 통합 테스트
- 컨트롤러 테스트
-
@WebMvcTest+@MockBean을 통해 작성 -@WebMvcTest- 컨트롤러가 똑바로 동작하는지 확인하기 위함 - 다음과 같은 내용만 스캔하여 가벼운 테스트 가능 -@Controller,@ControllerAdvice,@JsonComponent, - Converter, GenericConverter, Filter, HandlerInterceptor, - WebMvcConfigurer, HandlerMethodArgumentResolver -@MockBean- 따라서 구동에 필요한 서비스 클래스등은 모킹해줘야함 - SpringRestDocs를 활용하기 위함도 있음 - 참고: https://techblog.woowahan.com/2597/
- 서비스 테스트
- 필요에 따라 다음과 같은 어노테이션 활용
-
@ActiveProfiles("test"): yml 테스트 프로퍼티 환경에서 돌리기 위함 -@SpringBootTest: 실제 스프링 구동환경에서 사용될 Bean을 띄워놓고 테스트하기 위함 -@Transactional: 테스트 이후 롤백을 위함 -@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL):@Autowired가 없어도 주입해주세요 -@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD): 어플리케이션 컨텍스트를 재구동해야할 경우
- 도메인 단위 테스트 - Junit5 기반의 단위 테스트 - 도메인의 기능들에 대한 테스트케이스 작성
- 리포지토리 테스트
-
@DataJpaTest활용 - JPQL이 제대로 동작하는지 확인용도로 주로 씀
- 통합 테스트
- 시나리오 기반의 RestAssured 통합테스트
- 독립적인 테스트 환경을 보장하고자
@DirtiesContext를 활용했지만, 새로운 어플리케이션 컨텍스트는 많은 시간 소요 - 결국 매 테스트 마다 DB 제거를 해주는게 중요하다라고 판단해 개선점 찾아봄 - DatabaseCleanup이라는 빈을 활용해, 매 테스트마다 DB를 제거해주도록 개선 - 6분 30초 가량 소요되던 빌드 1분 30초로 단축@Service @ActiveProfiles("test") class DatabaseCleanup implements InitializingBean { @PersistenceContext private EntityManager entityManager; private List<String> tableNames; @Override public void afterPropertiesSet() { tableNames = entityManager.getMetamodel().getEntities().stream() .filter(e -> e.getJavaType().getAnnotation(Entity.class) != null) .filter(e -> e.getJavaType().getAnnotation(Table.class) == null) .map(e -> CaseFormat.UPPER_CAMEL.to(CaseFormat.LOWER_UNDERSCORE, e.getName())) .collect(Collectors.toList()); List<String> tableNamesWithAnnotation = entityManager.getMetamodel().getEntities().stream() .filter(e -> e.getJavaType().getAnnotation(Table.class) != null) .map(e -> CaseFormat.UPPER_CAMEL.to(CaseFormat.LOWER_UNDERSCORE, e.getJavaType().getAnnotation(Table.class).name())) .collect(Collectors.toList()); tableNames.addAll(tableNamesWithAnnotation); } @Transactional public void execute() { entityManager.flush(); entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY FALSE").executeUpdate(); for (String tableName : tableNames) { entityManager.createNativeQuery("TRUNCATE TABLE " + tableName).executeUpdate(); entityManager.createNativeQuery("ALTER TABLE " + tableName + " ALTER COLUMN ID RESTART WITH 1").executeUpdate(); } entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY TRUE").executeUpdate(); } }- 최상단 AcceptanceTest의 `@BeforeEach`에서 databaseCleanup.execute(); 실행- 공통된 로직은 AcceptanceTest로 올려버림 - static 메서드로 여기저기서 쓰일 수 있는 메서드들은 개방해줌
- 아쉬운 점
- 서비스 테스트에 클래스 단위로 통째로
@Transactional을 쓴 것 - 서비스 테스트를 슬라이스 테스트로 구현하지 못한 것 같아 - given, setUp에 대해서 싹 다 객체 엔티티로 관리해버리고 하나의 영속성 컨텍스트에서 관리해줬어 - 즉, 통합테스트 마냥 쓰다보니 em.flush(), em.clear()를 통해 의도적으로 영속성 컨텍스트 관리해줘야 했었어 - 테스트하고자 하는 when 절이 생각보다 똑바로 동작안하더라고
놀토의 로깅 전략¶
- 로깅은 Logback을 사용
- 참고: https://meetup.toast.com/posts/149
-
application.yml의 설정을 읽어오거나, 특정 profile의 동작을 수행할 수 있다는 장점 -logging.file: 절대 경로로 표현되거나, 현재 경로의 상대 경로로 로그 파일명 지정 -logging.file.path:logging.file없는 경우, 지정된 경로에spring.log로 로그 남김 -logging.file.max-size: 로그 파일이 지정된 임계치 초과하면, 파일명에 인덱스 추가한 후 새로운 파일 작성 -logging.file.max-history: Spring-boot 2.0.0 부터는 로그가 지정된 일 수 넘어가면 자동 삭제 -logging.level.*: path 기반의 logger level 지정 - 스프링부트에서는logback-spring.xml을 사용해서 Spring이 logback을 구동할 수 있도록 지원해줌
- application.yml
- logback-spring.xml
<?xml version="1.0" encoding="UTF-8"?> <configuration> <springProperty name="SLACK_WEBHOOK_URI" source="logging.slack.webhook-uri"/> // 콘솔 로깅 <springProfile name="console-logging"> <appender name="LOCAL_CONSOLE_POLICY" class="ch.qos.logback.core.ConsoleAppender"> <layout class="ch.qos.logback.classic.PatternLayout"> <Pattern>%d{HH:mm:ss.SSS} %highlight([%-5level]) [%thread] %cyan([%logger{36}]) - %m%n</Pattern> </layout> </appender> </springProfile> // 쿼리 로깅 (TRACE 부터) <springProfile name="file-sql-logging"> <appender name="local-file-logger" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>${LOG_PATH}/logs/nolto-sql.log</file> <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy"> <fileNamePattern>${LOG_PATH}/logs/dateLog/%d{yyyy_MM_dd}_%i.nolto-sql.log </fileNamePattern> <maxFileSize>50MB</maxFileSize> <maxHistory>14</maxHistory> <totalSizeCap>1GB</totalSizeCap> </rollingPolicy> <encoder> <charset>utf8</charset> <Pattern>%d{HH:mm:ss.SSS} [%-5level] [%thread] [%logger{36}] - %m%n</Pattern> </encoder> </appender> <logger name="org.hibernate.SQL" level="TRACE"> <appender-ref ref="local-file-logger"/> </logger> <logger name="org.hibernate.type.descriptor" level="TRACE"> <appender-ref ref="local-file-logger"/> </logger> </springProfile> // INFO 로깅 <springProfile name="file-info-logging"> <appender name="INFO_FILE_POLICY" class="ch.qos.logback.core.rolling.RollingFileAppender"> <filter class="ch.qos.logback.classic.filter.ThresholdFilter"> <level>INFO</level> </filter> <file>${LOG_PATH}/logs/nolto-info.log</file> <encoder> <Pattern>%d{HH:mm:ss.SSS} [%-5level] [%thread] [%logger{36}] - %m%n</Pattern> </encoder> <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy"> <fileNamePattern>${LOG_PATH}/logs/dateLog/%d{yyyy_MM_dd}_%i.nolto-info.log </fileNamePattern> <maxFileSize>50MB</maxFileSize> <maxHistory>14</maxHistory> <totalSizeCap>1GB</totalSizeCap> </rollingPolicy> </appender> </springProfile> // ERROR 로깅 <springProfile name="file-error-logging"> <appender name="ERROR_FILE_POLICY" class="ch.qos.logback.core.rolling.RollingFileAppender"> <filter class="ch.qos.logback.classic.filter.ThresholdFilter"> <level>ERROR</level> </filter> <file>${LOG_PATH}/logs/nolto-error.log</file> <encoder> <Pattern>%d{HH:mm:ss.SSS} [%-5level] [%thread] [%logger{36}] - %m%n</Pattern> </encoder> <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy"> <fileNamePattern>${LOG_PATH}/logs/dateLog/%d{yyyy_MM_dd}_%i.nolto-error.log </fileNamePattern> <maxFileSize>50MB</maxFileSize> <maxHistory>14</maxHistory> <totalSizeCap>1GB</totalSizeCap> </rollingPolicy> </appender> </springProfile> // [DEV] ERROR 로그 발생 시, Slack에 전송 <springProfile name="slack-dev-error-logging"> <appender name="SLACK" class="com.github.maricn.logback.SlackAppender"> <webhookUri>${SLACK_WEBHOOK_URI}</webhookUri> <layout class="ch.qos.logback.classic.PatternLayout"> <pattern>[DEV] %-4relative [%thread] %-5level %class - %msg%n</pattern> </layout> <colorCoding>true</colorCoding> </appender> <appender name="ASYNC_SLACK" class="ch.qos.logback.classic.AsyncAppender"> <appender-ref ref="SLACK"/> <filter class="ch.qos.logback.classic.filter.ThresholdFilter"> <level>ERROR</level> </filter> </appender> </springProfile> // [PROD] ERROR 로그 발생 시, Slack에 전송 <springProfile name="slack-prod-error-logging"> <appender name="PROD-SLACK" class="com.github.maricn.logback.SlackAppender"> <webhookUri>${SLACK_WEBHOOK_URI}</webhookUri> <layout class="ch.qos.logback.classic.PatternLayout"> <pattern>[PROD] %-4relative [%thread] %-5level %class - %msg%n</pattern> </layout> <colorCoding>true</colorCoding> </appender> <appender name="PROD_ASYNC_SLACK" class="ch.qos.logback.classic.AsyncAppender"> <appender-ref ref="PROD-SLACK"/> <filter class="ch.qos.logback.classic.filter.ThresholdFilter"> <level>ERROR</level> </filter> </appender> </springProfile> <root level="INFO"> <springProfile name="console-logging"> <appender-ref ref="LOCAL_CONSOLE_POLICY"/> </springProfile> <springProfile name="file-info-logging"> <appender-ref ref="INFO_FILE_POLICY"/> </springProfile> <springProfile name="file-error-logging"> <appender-ref ref="ERROR_FILE_POLICY"/> </springProfile> <springProfile name="slack-dev-error-logging"> <appender-ref ref="ASYNC_SLACK"/> </springProfile> <springProfile name="slack-prod-error-logging"> <appender-ref ref="PROD_ASYNC_SLACK"/> </springProfile> </root> </configuration>
- 프로파일 환경 별 다른 로깅 전략 - Local - 매 실행 시 마다 console에 로그 메시지 출력 - 로그 level은 info - Dev, Prod - ERROR 레벨 이상의 로그 nolto-error.log - INFO 레벨 이상의 로그 nolto-info.log - 해당 로그 파일 하루 지나면, dateLog 디렉토리에 날짜와 함께 저장 - 2021_11_09_0.nolto-info.log - 2021_11_09_0.nolto-error.log - 어펜더 설정에 따라 14일이 지나면 폐기 - 50MB 이하의 용량
- Slack 연동
- 런타임에 Error 레벨의 로그 출력시, 지정한 slack 채널의 메시지 보내도록 구현함
-

- 고민했던 부분
- 처음엔 그냥 에러 로그 한 줄로 남김
- 그러다보니 그 전 상황들을 파악할 길이 없음
- 따라서 에러 로그에 대해선 stacktrace를 로그로 남기기로함
16:18:39.273 [ERROR] [http-nio-8080-exec-14] [c.w.nolto.exception.ControllerAdvice] - ༼;´༎ຶ ༎ຶ༽ 에러 메세지: Request method 'GET' not supported%n༼;´༎ຶ ༎ຶ༽ 𝙒𝙝𝙮𝙧𝙖𝙣𝙤... ༼;´༎ຶ ༎ຶ༽ 에러 로그 시작 ༼;´༎ຶ ༎ຶ༽ [org.springframework.web.servlet.mvc.method.RequestMappingInfoHandlerMapping.handleNoMatch(RequestMappingInfoHandlerMapping.java:253) org.springframework.web.servlet.handler.AbstractHandlerMethodMapping.lookupHandlerMethod(AbstractHandlerMethodMapping.java:436) org.springframework.web.servlet.handler.AbstractHandlerMethodMapping.getHandlerInternal(AbstractHandlerMethodMapping.java:377) org.springframework.web.servlet.mvc.method.RequestMappingInfoHandlerMapping.getHandlerInternal(RequestMappingInfoHandlerMapping.java:125) org.springframework.web.servlet.mvc.method.RequestMappingInfoHandlerMapping.getHandlerInternal(RequestMappingInfoHandlerMapping.java:67) org.springframework.web.servlet.handler.AbstractHandlerMapping.getHandler(AbstractHandlerMapping.java:498) org.springframework.web.servlet.DispatcherServlet.getHandler(DispatcherServlet.java:1257) org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1039) org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:963) org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1006) org.springframework.web.servlet.FrameworkServlet.doGet(FrameworkServlet.java:898) javax.servlet.http.HttpServlet.service(HttpServlet.java:655) org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:883) javax.servlet.http.HttpServlet.service(HttpServlet.java:764) org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:228) org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:163) org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:53) org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:190) org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:163) org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100) org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119) org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:190) org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:163) org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:93) org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119) org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:190) org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:163) org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201) org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119) org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:190) org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:163) org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:202) org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:97) org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:542) org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:143) org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:92) org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:78) net.rakugakibox.spring.boot.logback.access.tomcat.LogbackAccessTomcatValve.invoke(LogbackAccessTomcatValve.java:91) org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:357) org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:382) org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:65) org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:893) org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1723) org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:49) java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149) java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624) org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61) java.lang.Thread.run(Thread.java:748)] ༼;´༎ຶ ༎ຶ༽ 에러 로그 끝 ༼;´༎ຶ ༎ຶ༽ ༼;´༎ຶ ༎ຶ༽ 𝙒𝙝𝙮𝙧𝙖𝙣𝙤...
놀토 인프라¶

-
Why replication? - Read는 Slave, CUD는 master에서 처리 - 어노테이션 기반으로 다른 데이터 소스를 사용하도록 설정함 - 각각 다른 DB를 사용함으로써, 자연 스런 부하분산 - Read가 아무래도 작업량이 많다보니까, 이걸 scale up 할 수 있는 구조로 설계하였음 - 데이터 분석을 위한 DB 따로 등등,,,
-
Why load-balancing? - 부하 분산을 위한 로드 밸런싱 도입 - WAS 인스턴스 두 대에서 돌리는 중 - 상태를 가지는 Redis가 하나 있어서 각각 WAS에 Redis 설치 - Redis에는 ip, userid에 대해 RefreshToken을 가지고 있음 - 이를 저장하기 위해 도입함 - 클라이언트의 ip에 따라 지정된 was에서 처리할 수 있도록 nginx에서 처리해줌 - ip_hash 옵션 활용