
들어가며
나의 첫번째 도전이었던 "3주차 로또 문제 코틀린으로 리팩토링 하기!" 를 완료하였다. 빠르게 코틀린 정리를 끝내고 바로 문제 리팩토링을 마친 지금은 코틀린과 많이(?) 친해졌다고 할 수 있다 ㅎㅎ. 그래도 그 과정은 정말 쉽지 않았다...!!
지난 주차 피드백 적용 부분을 말하고, 전체 코드를 구성하면서 배운 코틀린의 특징과 이전 코드를 비교하며 적어볼까 한다. 가보자! 🔥
🚨 틀린 내용이 있다면 댓글 부탁드립니다 🚨
아래 블로그는 리팩토링 전 빠르게 코틀린의 특징을 정리한 블로그이다. 자바와 비교하며 정리하였다.
https://geniusjun4663.tistory.com/53
[Kotlin] 자바 개발자를 위한 코틀린 정리(차이점을 위주로)
들어가며누군가가 나에게 주언어가 뭐에요? 라고 물어본다면 "자바가 제일 자신있습니다" 라고 답할 것이다.그렇다. 이 글은 계속 자바와 비교하며 코틀린을 정리할 것이다. 내가 정리하려고 쓰
geniusjun4663.tistory.com
4일동안 진행한 코틀린 리팩토링 레포지토리이다. 하루마다 브랜치를 새로 파서 하루가 끝나면 머지하고 새로 진행하는 식으로 하였다.
https://github.com/geniusjun/kotlin-practice
GitHub - geniusjun/kotlin-practice: 코틀린 공부 레포지토리입니다.
코틀린 공부 레포지토리입니다. Contribute to geniusjun/kotlin-practice development by creating an account on GitHub.
github.com
본론으로
🧐 리팩토링 하며 느꼈던 코틀린의 특이한 점들(이전 자바 코드와 비교하며)
1. public을 안써도 기본이 public이다.
2. 코틀린에서는 의존성 주입이 매우 간단하게 축약된다.
public class LottoController {
private final InputView inputView;
public LottoController(InputView inputView) {
this.inputView = inputView;
}
}
자바 였다면 의존성을 주입하려면 생성자가 필요하기에 위처럼 코드가 기본적으로 길어진다. 근데 코틀린은 아래와 같이 한줄로 끝난다.
class LottoController(private val inputView: InputView)
private val은 자동으로 필드 + 초기화를 한번에 해준다. → 생성자 매개변수이자 멤버 필드로 동시에 동작.
그래서 실제로 LottoController의 생성자 주입코드가 사라지니 Controller의 윗부분이 매우 깔끔해졌다. 아래 코드의 차이를 보자!
// LottoController.kt
class LottoController(
private val outputView: OutputView,
private val inputView: InputView,
private val lottoService: LottoService,
private val loop: InputLoop,
) {
fun play{
...
}
ㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡ
// LottoController.java
public class LottoController {
private final OutputView outputView;
private final InputView inputView;
private final LottoService lottoService;
private final InputLoop loop;
public LottoController(OutputView outputView, InputView inputView, LottoService lottoService, InputLoop loop) {
this.outputView = outputView;
this.inputView = inputView;
this.lottoService = lottoService;
this.loop = loop;
}
public void play() {
...}
3. 전역적으로 쓰겠다는 자바의 static한 메서드를 코틀린의 object로 변경가능하다!
// Parser.kt
object Parser {
fun stringToInt(input: String): Int {
...
}
fun stringToNumbers(text: String): List<LottoNumber> {
...
}
}
ㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡ
// Parser.java
public class Parser {
public static int stringToInt(String input) {
...
}
public static List<Number> stringToNumbers(String s) {
...
}
- object는 “이 클래스는 인스턴스를 딱 하나만 쓴다”는 선언이다.
- 그래서 선언하는 순간 싱글톤 인스턴스가 만들어진다.
- → 이렇게 만들면 Parser()로 생성 못 하고
- → 그냥 Parser.stringToInt(...)로 바로 호출 가능하다.
자바의 “static 메서드만 있는 유틸 클래스” -> 코틀린에서는 object로 만든다
4. 자바의 Stream 반환 vs 코틀린의 Sequence 반환
원래 자바코드에서는 List.copyOf(방어적 복사)를 하여 반환하기에 getter를 열어주는 것이 아닌 조회만 가능한 Stream을 반환했었다! 그래야 방어적 복사의 의미가 지켜지니 말이다!
private final List<Lotto> lottos = List.copyOf(lottos);
public Stream<Lotto> getStream() {
return lottos.stream();
}
그런데 코틀린 로직은 아래처럼 작성하였다.
class Lottos private constructor(
lottos: List<Lotto>,
) {
private val lottos: List<Lotto> = lottos.toList()
fun asSequence(): Sequence<Lotto> = lottos.asSequence()
}
- lottos는 불변 복사해뒀고
- 밖에는 “흘려보기만” 주는 거라서 내부 리스트를 직접 못 건드린다.
- 이렇게 자바의 Stream 역할을 코틀린식으로 해보았다.
그래서 Strean과 Sequence가 뭐가 다른데?
- Stream: 자바 컬렉션을 함수형 스타일로 처리하려고 자바 8에서 넣은 것이다. 자바 표준 라이브러리.
- Sequence: 코틀린이 “우리 컬렉션 체이닝 하면 너무 중간 리스트 많이 생기는데?” 해서 자기 컬렉션 시스템에 맞춰 만든 것이다. 코틀린 표준.
→ 둘 다 “게으르게(map/filter 하고 마지막에 collect)” 이 패턴은 똑같다. 자세히 더 봐보자.
- Stream: 스트림 파이프라인이라는 별도 추상화 위에서 돌아감. IntStream, LongStream 같은 원시 타입 최적화도 있고, 병렬 스트림도 지원한다.
- Sequence: 진짜 단순하다. 그냥 Iterator를 한 번에 한 원소씩 흘려보내는 구조다. 파이프라인도 가볍고, 병렬도 없다.
→ 그래서 Sequence는 “코틀린 컬렉션 + 람다” 조합에 딱 맞고, Stream은 “자바 생태계 + 병렬 + 원시 최적화”에 더 맞다.
Stream이 더 기능 많고 무거운 버전, Sequence는 코틀린 컬렉션에 맞게 가볍게 만든 버전으로 이해해도 될 것 같다.
5. 코틀린의 buildList 기능
자바 코드에서는 Service로직에서 우테코 랜덤함수를 이용한 로또를 생성할 때 아래와 같이 for문을 돌며 생성했었다.
public Lottos buyLottos(Cost cost) {
List<Lotto> lottos = new ArrayList<>();
for (int i = 0; i < cost.getCount(); i++) {
lottos.add(lottoFactory.create());
}
return Lottos.from(lottos);
}
근데 코틀린은 아래와 같이 생성했다.
fun buyLottos(cost: Cost): Lottos {
val lottos = buildList {
repeat(cost.count) {
add(lottoFactory.create())
}
}
return Lottos.from(lottos)
}
위의 내용을 해석하자면 아래와 같다.
- 리스트 하나 만듦
- 블록 안에서 원하는 만큼 add(...)
- 블록이 끝나면 불변(List) 로 반환
- 리스트로 로또 생성
엄청 직관적이지 않은가! for문으로 돌려서 리스트를 만들 수도 있겠지만, buildList와 repeat, add라는 직관적인 콜렉션 메서드를 활용해보았다.
6. 함수형 인터페이스 vs 코틀린 함수 타입
자바에서는 무언가를 “조건으로 넘기고 싶다” 하면 보통 Predicate<T> 같은 함수형 인터페이스를 썼었다. 그래서 필드 타입도 Predicate<MatchResult>로 잡아야 했고, 호출할 때도 rule.test(result)처럼 인터페이스의 메서드 이름을 호출해야 했었다.
private final Predicate<MatchResult> rule;
...
result -> result.matchedCount() < 3
근데 아래의 코틀린 코드를 보자!
private val rule: (MatchResult) -> Boolean
...
{ it.matchedCount < 3 }
코틀린에서는 함수를 별도의 인터페이스로 감싸지 않아도, 그 자체로 변수처럼 다룰 수 있다.
예를 들어 (T) -> R 형태의 함수 타입을 그대로 필드나 매개변수로 쓸 수 있다. 함수 호출도 rule(result)처럼 간단하게 할 수 있어서, test(...) 같은 메서드 이름이 필요 없다..! 덕분에 enum 내부에 조건을 정의할 때 코드가 훨씬 짧고 직관적으로 표현된다. wow!!
결론 : 자바에서는 조건 검사를 하려면 Predicate<T> 같은 함수형 인터페이스가 필요하지만, 코틀린은 함수 타입 (T) -> Boolean을 그대로 쓸 수 있어서 훨씬 간결하다.
7. 게터/세터 vs 프로퍼티
원래 자바였다면 아래의 private한 변수의 값을 얻어오려면 getter를 추가를 추가하던가 값을 변경하려면 setter를 추가해야했다.
private int prizeAmount;
public int getPrizeAmount() { return prizeAmount; }
val이나 var로 선언된 모든 프로퍼티는 자동으로 getter(그리고 var이면 setter도)를 만들어준다. (“프로퍼티 = 값 + 접근 방법”)
var prizeAmount: Int
즉 변수 선언하면 자동으로 getter, setter를 만들어준다.(val이면 getter)만
8. record vs data class
자바에서는 값을 담기 위한 용도로 record를 많이 쓴다.
public record MatchResult(int matchedCount, boolean bonusMatched) { ... }
코틀린에서는 data class라는 형식을 쓴다.
data class MatchResult(
val matchedCount: Int,
val bonusMatched: Boolean
)
9. 비슷하지만 다른느낌의 Stream과 코틀린의 컬렉션함수
// .java
public static WinningResult of(...) {
WinningResult aggregated = new WinningResult();
purchasedLottos.getStream()
.map(lotto -> decideType(lotto, drawnNumbers))
.forEach(aggregated::increment);
return aggregated;
}
// .kt
fun of(purchased: Lottos, drawn: WinningNumbers): WinningResult {
val result = WinningResult()
purchased.asList()
.map { decideType(it, drawn) }
.forEach { result.increment(it) }
return result
}
- 흐름은 완전 같다
- 빈 결과 만들고
- 산 로또들 전부 등수로 변환하고
- 하나씩 카운트 올리고
- 코틀린에서는 stream 안 쓰고 컬렉션 확장 함수로 처리했을 뿐이다!
“자바 스트림 체인은 코틀린에선 컬렉션의 map/forEach로 자연스럽게 치환된다. 구조는 같고, 문법만 줄어든다.”
10. 유틸 클래스를 만들려고 썻던 자바의 여러 줄이... 코틀린에서는 하나의 표현으로..?!
public class LottoFormatter {
private LottoFormatter() {
}
public static ...
}
- 클래스지만 생성자를 private으로 막아서 인스턴스 생성을 금지시켰다.
- 대신 모든 메서드를 static으로 만들어서 “이거는 그냥 유틸 모음이야”라고 표현하려고 한 것이다.
코틀린으로는 아래와 같이 표현하였다.
object LottoFormatter {
...
}
WHAT?
- object는 처음부터 싱글턴 + 인스턴스화 금지 + 바로 호출 가능이 한 번에 들어있다.
- 그래서 private constructor도 필요 없고 static도 필요 없다.
차이점을 아래에서 정확하게 보자! 왜 object가 좋은지...!
- 의도가 더 드러남
- 자바: “이거 생성하면 안 돼요”를 코드로 강제로 만들어야 한다.
- 코틀린: “이건 원래 한 개만 있어야 하는 객체예요”를 문법(object)로 바로 표현가능하다.
- 보일러플레이트(의미는 별로 없는데 매번 반복해서 써야 하는 코드) 감소
- 자바는 private LottoFormatter() {} 같은 코드가 꼭 들어간다..ㅜㅜ
- 코틀린은 그게 사라져서 포맷 로직만 남는다..(이러면 너무 차이 나잖아,,, 자바 좋은데...)
- 진짜로 상태를 들고 있어도 됨
- object는 진짜 객체라서 나중에 캐시, 포맷 문자열, Locale 같은 걸 들고 있어도 자연스럽다.
- 자바 static 유틸 클래스는 상태 추가하면 살짝 어색해진다.
원래 자바에서는 포맷터/유틸리티를 만들 때 private 생성자 + static 메서드 패턴을 썼다. 코틀린에서는 이 패턴을 object로 한 줄에 표현할 수 있어서, 인스턴스를 만들 수 없고 전역에서 바로 호출할 수 있는 “유틸 전용 객체”를 더 자연스럽게 만들 수 있다.
11. 문자열 포멧 함수 차이
// java
return String.format(YIELD_TEMPLATE, yieldPercent);
// kotlin
return YIELD_TEMPLATE.format(yieldPercent)
코틀린은 문자열에 바로 .format() 붙여서 표현 가능하다. 가독성이 좀 많이 올라간다.
12. enum 접근
// java
type.label()
// kotlin
type.label
- 코틀린 enum은 값 함수처럼 안 부르고 그냥 속성으로 쓴다. (프로퍼티이니!)
🤔 지난 주차 피드백 적용
1. 재입력을 시도하는 유틸성 메서드에서 단일 책임 원칙(SRP)를 지키지 못하였다.
저번주차 문제에서 에러 발생시 에러메시지를 출력하고 재시도 하는 로직 때문에 try-catch문이 필수였고, 그 반복되는 try-catch문 때문에 코드가 뚱뚱해지는 경험을 하였다. 그래서 아래 로직과 같이 함수형 인터페이스를 활용하여 형식을 지정하는 메서드를 만들었었는데, 아래와 같은 피드백을 받았었다.

출력->검증->객체 생성 순서가 계속 반복되길래 위의 코드와 같이 만들었었는데, 이는 사실 출력과 생성을 동시에 두가지 역할을 하는 메서드인 것이었다. 그래서 아래와 같이 메서드 하나만 실행하며 출력은 controller에서 호출하는 쪽으로 역할을 나누었다.
// InputLoop.kt
fun <T> retryUntilSuccess(action: () -> T): T {
while (true) {
try {
return action()
} catch (e: IllegalArgumentException) {
out.printlnMessage((ERROR_PREFIX + e.message))
}
}
}
// LottoController.kt
private fun askCost() =
loop.retryUntilSuccess {
outputView.printlnMessage(COST_REQUEST_MESSAGE.message)
val input = inputView.enterMessage()
lottoService.parseCost(input)
}
private fun askWinningNumbers(): WinningNumbers {
val winningLotto = loop.retryUntilSuccess {
outputView.printlnMessage(WINNING_REQUEST_MESSAGE.message)
val input = inputView.enterMessage()
lottoService.parseWinningLotto(input)
}
InputLoop는 “재시도 로직만 담당”하도록 단일 책임 원칙(SRP)에 맞게 변경되었다.
프롬프트 출력은 컨트롤러에서 처리하도록 역할을 분리해 유지보수가 쉬워졌다.
2. ENUM이름을 의미있게 변경하자.
아래와 같은 따끔한 피드백이 왔었다!

그래서 이번 코틀린 리팩토링에서는 Enum의 이름들을 조금 더 의미있게 바꾸었다!
package lotto.global.constants
enum class LottoConstants(val value: Int) {
COST_UNIT(1000),
MIN_LOTTO_NUMBER(1),
MAX_LOTTO_NUMBER(45),
LOTTO_SIZE(6),
}
3. 변수명도 의미있게 짓자.
enum도 그렇고 변수도 그렇고 네이밍에 너무 생각을 얕게 한 듯하다. 아래와 같은 네이밍 조언이 하나 더 있었다. 엄청 반성하게 된다.

그래서 아래와 같은 식으로 처음보는 사람도 어떤 숫자인지 알 수 있게끔 바꾸었다.
class LottoNumber private constructor(val value: Int) {
...
}
4. controller가 직접 도메인을 생성한다면 너무 많은 책임이 생긴다.
아래와 같은 피드백이 왔다.

이전 자바 코드에서는 컨트롤러에서 도메인을 생성하는 로직을 직접 호출하여 그것을 다른 메서드에 넘겨서 진행하는 식으로 로직을 구현했었다. 피드백 내용을 보고 다시 생각해보니, 컨트롤러는 전체적인 프로그램 흐름이나 사용자와의 입출력 호출을 다루기도 바쁜데 도메인의 생성까지 직접 담당하면 너무 많은 책임을 가지게 된다고 생각이 든다.
그래서 피드백의 내용처럼 도메인과 직접적으로 관련된 로직은 서비스와 도메인 내로 옮기고 컨트롤러는 이를 호출하는 식으로 책임을 덜어내는 것이 맞다고 판단이 들었다!!! 책임적인 측면에서 안목을 넓혀준 너무나 좋은 피드백이었다..(우테코 사람들 짱짱)
그래서 서비스레이어에서 직접 도메인을 생성하고, controller레이어에서는 이를 호출하는 식으로 설계하여 책임을 덜어주었다.
class LottoService(
private val lottoFactory: LottoFactory
) {
/**
* 검증된 Cost로부터 로또 여러 장 구매
*/
fun buyLottos(cost: Cost): Lottos {
val lottos = buildList {
repeat(cost.count) {
add(lottoFactory.create())
}
}
return Lottos.from(lottos)
}
/**
* 사용자가 입력한 문자열을 비용 도메인으로 변환
*/
fun parseCost(input: String): Cost =
Cost.from(input)
/**
* 당첨 번호(6개) 문자열을 Lotto로 변환
*/
fun parseWinningLotto(input: String): Lotto =
Lotto.from(Parser.stringToNumbers(input))
/**
* 보너스 번호 문자열을 Bonus로 변환
*/
fun parseBonus(input: String, winning: Lotto): Bonus {
val number = LottoNumber.valueOf(Parser.stringToInt(input))
return Bonus.of(number, winning)
}
}
마무리하며
이렇게 3주차 피드백을 적용하며 코틀린으로 리팩토링을 진행했다.
사실 문제 해석 자체는 이미 지난 일주일간 충분히 고민해서 어렵지 않았지만, 표현 방식이 달라서 처음엔 많이 헤맸다. 여러 블로그를 찾아보면 자바와 코틀린은 닮았다고 하지만, 막상 코드를 써보면 전혀 다른 언어처럼 느껴졌다. 그래도 이제는 많이 익숙해졌고 문법적인 개선 덕분에 자바 특유의 보일러플레이트를 걷어냈다는 뿌듯함이 있다.😁
처음엔 "오픈미션"이라는 목표를 위한 도전으로 시작했지만, 하다 보니 마음속에서 무언가가 끓어올랐다. 예전엔 C언어가 그렇게 어렵게만 느껴졌는데, 이번엔 강한 동기와 목적을 가지고 배우니 그 어떤 언어도 재밌게 배울 수 있다는 확신이 생겼다. 정말 뭐든 할 수 있겠다는 자신감이 든다.😃
목적 없이 언어를 새로 배웠다면 아마 오래 걸리고 중간에 포기했을지도 모른다. 하지만 "우테코 오픈미션"이라는 도전적인 마지막 주제가 내 안의 불씨를 다시 지폈고, 요즘 나를 계속 달리게 만든다. 2주라는 짧은 시간 동안 후회 없는 시도를 해보고 싶다는 생각에 시작한 코틀린 리팩토링은 예상보다 훨씬 빨리 완성되었다.
오픈미션이 나에게 가속력과 자신감을 동시에 준 계기가 된 것 같다.
그래서 이번엔 한 번도 해보지 않았던 안드로이드 앱 개발에 도전해보려 한다. 코틀린을 배운 김에 점진적으로 로또와 관련된 앱을 직접 만들어보는 것이 내 다음 목표다. 당장 Android Studio 다운로드 받으러 가보자🔥
'Various Dev > 우아한테크코스' 카테고리의 다른 글
| [우아한테크코스] 8기 프리코스 오픈미션 회고 (0) | 2025.11.29 |
|---|---|
| [우아한테크코스] 프리코스 3주차 미션 회고 - 로또 (0) | 2025.11.05 |
| [우아한테크코스] 테스트 코드 작성 연습해보기(우테코 제공 라이브러리 적극 활용! NsTest, assertSimpleTest) (0) | 2025.10.30 |
| [우아한테크코스] 프리코스 2주차 문제 회고 - 자동차 경주 (2) | 2025.10.28 |
| [우아한테크코스] 프리코스 1주차 문제 회고 - 문자열 덧셈 계산기 (0) | 2025.10.19 |