각종 후기/우아한테크코스

[우아한 테크코스 3기] LEVEL 2 회고 - 협업 미션 1차 피드백을 받아 보다 (116일차)

제이온 (Jayon) 2021. 5. 28.

안녕하세요? 제이온입니다.

 

오늘은 온라인으로 우테코를 진행하였습니다. 오전에는 피드백을 반영을 했고, 오후에는 자습을 하였습니다.

 

 

협업 미션 1차 피드백

 

 

첫 번째 수정 사항은 리눅스 명령어로 log 파일을 만들기 보다는 logback 라이브러리를 통해 스프링 단에서 로그 파일을 자동으로 만들어 보라는 것입니다. 이 부분은 전에 배포 인프라 2단계 미션에서 진행했던 로깅 강의 자료를 많이 참고했습니다.

 

먼저, logback.xml 파일을 작성해야 합니다. logback의 기본 설정 파일은 logback.xml인데, logback 라이브러리는 classpath 아래에 위치하는 logback.xml을 1번째로 찾아보기 때문입니다.

 

 

<configuration debug="false">

    <!--spring boot의 기본 logback base.xml은 그대로 가져간다.-->
    <include resource="org/springframework/boot/logging/logback/base.xml" />
    <include resource="file-appender.xml" />

    <!--    logger name이 file일때 적용할 appender를 등록한다.-->
    <logger name="file" level="INFO" >
        <appender-ref ref="file" />
    </logger>
</configuration> 

 

 

그리고 logger name이 file일 때 적용할 appender를 등록해야 하는데, 해당 파일 이름은 file-appender.xml이라고 정의해 주었습니다. file-appender.xml 내용은 아래와 같습니다.

 

 

  <property name="home" value="log/" />

    <appender name="file" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <!--로깅이 기록될 위치-->
        <file>${home}file.log</file>
        <!--로깅 파일이 특정 조건을 넘어가면 다른 파일로 만들어 준다.-->
        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
            <fileNamePattern>${home}file-%d{yyyyMMdd}-%i.log</fileNamePattern>
            <timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
                <maxFileSize>15MB</maxFileSize>
            </timeBasedFileNamingAndTriggeringPolicy>
        </rollingPolicy>
        <!--   해당 로깅의 패턴을 설정   -->
        <encoder>
            <charset>utf8</charset>
            <Pattern>
                %d{yyyy-MM-dd HH:mm:ss.SSS} %thread %-5level %logger - %m%n
            </Pattern>
        </encoder>
    </appender>

 

 

첫째 줄부터 보겠습니다. 홈 디렉토리에서 log라는 디렉토리를 새롭게 만든다는 이야기이고, 그 다음은 appender의 이름이 file이라는 RollingFileAppender를 선언합니다. 해당 Appender는 로그의 크기가 일정량 차면 다른 파일을 만들어 줍니다. 그리고 특정 조건이란 것은 TimeBasedRollingPolicy, SizeAndTimeBasedFNATP를 통해 정의가 되는데, 파일의 크기가 15MB가 넘어가면 날짜가 붙은 파일을 만들어 줍니다. 그리고 마지막으로 해당 로깅의 패턴을 정의합니다.

 

이제, 실제로 로그를 찍어봐야 합니다. 저는 로깅이 어색해서 일단은 에러 부분만 찍어 보겠습니다.

 

 

@RestControllerAdvice
public class GlobalExceptionHandler {

    private static final Logger fileLogger = LoggerFactory.getLogger("file");

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> unValidBindingHandler(MethodArgumentNotValidException exception) {
        fileLogger.error(exception.getMessage());
        return ResponseEntity.badRequest().body(new ErrorResponse(bindingResultMessage(exception)));
    }

    @ExceptionHandler(AuthorizationException.class)
    public ResponseEntity<ErrorResponse> authorizeExceptionHandler(final AuthorizationException exception) {
        fileLogger.error(exception.getMessage());
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(new ErrorResponse(exception.getMessage()));
    }
    
    ...
}

 

 

이런 식으로 LoggerFactory에서 file이라는 이름의 Logger를 등록해주고 fileLogger.error()를 통해 error 수준의 로깅을 해 주었습니다. 그러면 에러가 발생할 때 마다 /log/file.log에 다음과 같은 내용이 작성됩니다.

 

 

 

 

현재는 로깅 수준을 ERROR로만 지정해 놓았지만, 아래와 같이 로깅 수준은 5종류가 있습니다.

 

 

 

 

운영부터는 INFO 이상 사용하되, 용도에 따라 적절히 나누는 듯합니다. 이 부분은 차근차근 개발 경험을 익히면서 적용해봐야겠습니다.

 

 

 

 

두 번째 수정 사항은 에러 메시지로 기술적인 정보를 담지 말라는 것입니다. 커스텀으로 핸들링하지 않은 예외에 대해서는 온갖 기술적인 내용이 담긴 에러 메시지가 생기고, 그러한 에러 메시지를 담은 ErrorResponse가 프론트 단으로 전송될 것입니다. 그러면, 사용자도 당황하고 개발자도 당황하고 해커는 흐뭇해할 것입니다. 따라서, 적절하게 "서버에서 오류가 발생했습니다."정도로 띄워주는 편이 바람직하다고 합니다. 추가로, RuntimeException으로도 잡히지 않는 에러가 있을 수 있으므로 Exception으로 핸들링하는 것을 추천해 주셨습니다.

 

 

 

 

세 번째 수정 사항은 비로그인자 판별 방법을 바꿔보라는 것입니다. 처음 피드백은 비로그인자의 경우 LoginMember을 null로 받아서 null인지 확인하라고 하셨으나, 저는 특정 객체가 null인지 확인하는 것은 NullpointerException의 우려도 있고 바람직하지 않다고 생각했습니다. 지금 코드에서 LoginMember는 필드로 isLogin을 가지고 있고, 그 변수의 getter도 isLogin()인 상황입니다. 저는 LoginMember라는 이름 대신 LoginUser로 바꾸고, LoginUser과 GuestUser을 상속하는 User 객체를 만들었습니다.

 

 

public abstract class User {
    private Long id;
    private String email;
    private Integer age;

    public User() {
    }

    public User(Long id, String email, Integer age) {
        this.id = id;
        this.email = email;
        this.age = age;
    }

    public abstract boolean isLogin();

    public Long getId() {
        return id;
    }

    public String getEmail() {
        return email;
    }

    public Integer getAge() {
        return age;
    }
}

 

 

User는 추상 클래스이고, isLogin()을 추상 메소드로 정의하였습니다. 그리고 예상하셨듯이, LoginUser는 해당 메소드의 반환값이 true, GuestUser는 해당 메소드의 반환값이 false가 되도록 재정의하였습니다.

 

 

public class LoginUser extends User {

    public LoginUser() {

    }
    public LoginUser(Long id, String email, Integer age) {
        super(id, email, age);
    }

    @Override
    public boolean isLogin() {
        return true;
    }
}

public class GuestUser extends User {

    @Override
    public boolean isLogin() {
        return false;
    }
}

 

 

그리고 비로그인자인지 판별하는 부분은 아래와 같이 바꿀 수 있습니다.

 

 

    private int calculateTotalFare(User user, SubwayPath subwayPath) {
        final int basicFare = FareCalculator.calculateFare(subwayPath.calculateDistance());
        final int extraFareWithLine = FareCalculator.calculateFareWithLine(basicFare, subwayPath.getLines());
        if (user.isLogin()) {
            return FareCalculator.discountFareByAge(extraFareWithLine, user.getAge());
        }
        return extraFareWithLine;
    }

 

 

사실 실제로 비로그인인지 판별하는 곳은 크게 바뀐 내용이 없습니다. 다만, 비로그인 유저와 로그인 유저에 의미를 부여한 객체를 나누고, 해당 객체의 상위 클래스를 만들어서 다형성을 구현한 것은 변화에 유연한 설계라고 생각했습니다.

 

 

 

 

마지막 피드백은 함수형 인터페이스를 통해 다형성을 구현하라는 것입니다. 먼저, 기존 코드부터 보겠습니다.

 

 

public enum DiscountRate {
    TWENTIES(19, 0, 0),
    TEENAGERS(13, 350, 0.2),
    PRESCHOOLER(6, 350, 0.5),
    BABIES(0, 0, 1);

    private final int ageBaseline;
    private final int deductionFare;
    private final double discountRate;

    DiscountRate(int ageBaseline, int deductionFare, double discountRate) {
        this.ageBaseline = ageBaseline;
        this.deductionFare = deductionFare;
        this.discountRate = discountRate;
    }

    public static DiscountRate compareAgeBaseline(int age) {
        if (TWENTIES.ageBaseline <= age) {
            return TWENTIES;
        }
        if (TEENAGERS.ageBaseline <= age) {
            return TEENAGERS;
        }
        if (PRESCHOOLER.ageBaseline <= age) {
            return PRESCHOOLER;
        }
        return BABIES;
    }

    public int calculateFare(int currentFare) {
        return (int) ((currentFare - deductionFare) * (1 - discountRate));
    }
}

 

 

위는 나이에 따른 할인 정책을 의미하는 Enum 클래스입니다. 19세 이상에 대해서는 할인이 없고, 13세 이상 19세 미만은 20%, 6세 이상 13세 미만은 50%, 그 미만은 100%로 설정되어 있습니다. 그리고 0%나 100%가 아닌 할인률에 대해서는 공제 요금이 존재합니다. 예컨대, 지불할 요금이 1350원인데, 나이가 7세라면 '(1350-350) * 0.5' = 500이 되는 것입니다.

 

자, 여기서 주목해야할 부분은 compareAgeBaseline() 메소드입니다. 해당 메소드는 ageBaseline 값과 age를 비교하면서 if문을 나열하고 있습니다. 만약, 할인 정책이 늘어난다면 해당 분기는 끝도 없이 늘어날 것입니다. 이를 함수형 인터페이스인 Predicate를 통해 해결해 보겠습니다.

 

 

public enum DiscountRate {
    ADULTS(0, 0, (age) -> 19 <= age),
    TEENAGERS(350, 0.2, (age) -> 13 <= age),
    PRESCHOOLER(350, 0.5, (age) -> 6 <= age),
    BABIES(0, 1, (age) -> 6 > age);

    private final int deductionFare;
    private final double discountRate;
    private final IntPredicate predicate;

    DiscountRate(int deductionFare, double discountRate, IntPredicate predicate) {
        this.deductionFare = deductionFare;
        this.discountRate = discountRate;
        this.predicate = predicate;
    }

    public static DiscountRate compareAgeBaseline(int age) {
        return Arrays.stream(values())
            .filter(discountRate -> discountRate.predicate.test(age))
            .findFirst()
            .orElseThrow(() -> new NoSuchElementException("해당하는 DiscountRate 객체가 없습니다."));
    }

    public int calculateFare(int currentFare) {
        return (int) ((currentFare - deductionFare) * (1 - discountRate));
    }
}

 

 

기존의 ageBaseline은 지우고, 대신 IntPredicate를 정의하였습니다. 그리고 Predicate의 test() 메소드를 활용하여 조건식에 해당 인자를 넣으면 참인지 확인하여 참인 DiscountRate만 걸러냅니다. 마지막으로, 걸러낸 DiscountRate 중에 첫 번째 요소를 반환하면 되는 것이죠. 이를 통해 compareAgeBaseline()은 새로운 할인 정책이 생겨도 코드 하나 건들 것 없이 단지 열거형 상수만 추가하면 된다는 장점이 있습니다.

 

 

원격으로 DB를 연결할 때 bind-address의 의미

저번 시간에 원격으로 외부 DB 인스턴스에 접근하려고 했습니다. 그 당시에 포트 번호는 8080으로 주었고, bind-address는 0.0.0.0으로 설정해 주었습니다. 다만, 0.0.0.0은 네트워크 전체 대역에서 오는 요청을 허용해 주는 것이었고 그 당시에는 별의별 방법을 써 봤지만 0.0.0.0 외에는 해답을 못 찾았습니다. 그런데, 0.0.0.0이 아니라 해당 DB 인스턴스의 private IP를 bind-address에 넣어주고, 외부에서는 해당 IP로 연결한다면 가능했습니다.

 

왜 이러한 일이 가능한 것일까요? 저는 아직 설명할 짬이 못 돼서 열심히 노력해 주신 조앤의 포스팅을 홍보하겠습니다.

 

 

정리

이후에는 모의 면접을 대비할 겸 OOP부터 복습을 하였습니다. 그리고 오늘 다형성에 대해 포스팅을 한 것처럼 거의 매일(?) OOP 관련 공부한 내용을 포스팅하려고 합니다. 앞으로 보시는 분들 많은 피드백 바랍니다.

 

댓글

추천 글